Skip to content

Commit bf9d441

Browse files
authored
Merge pull request #34 from CSSFrancis/feat/hyperspy-parity-0.3.0
Update documentation for 0.3.0 release
2 parents 60d03d3 + b4f7953 commit bf9d441

16 files changed

Lines changed: 653 additions & 9 deletions
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""
2+
Interactive figure annotations — double-click to add, drag to place
3+
===================================================================
4+
5+
anyplotlib has a *figure-level* annotation layer that floats above the panels,
6+
positioned in **figure fractions** (0…1, origin top-left) rather than any
7+
panel's data coordinates. Set it with
8+
:meth:`~anyplotlib.Figure.set_figure_markers`; the supported kinds are
9+
``"text"``, ``"circle"``, ``"rect"`` and ``"arrow"``.
10+
11+
Turn on ``fig.edit_chrome`` and every annotation becomes draggable in the
12+
browser. Combine that with a ``double_click`` handler and you get a simple
13+
annotate-by-clicking workflow: double-click a feature to drop a labelled
14+
arrow, then drag it to line it up. Positions round-trip back to Python via
15+
:attr:`~anyplotlib.Figure.figure_markers`.
16+
"""
17+
import numpy as np
18+
import anyplotlib as apl
19+
20+
rng = np.random.default_rng(7)
21+
data = rng.standard_normal((160, 160)).cumsum(0).cumsum(1)
22+
data = (data - data.min()) / (data.max() - data.min())
23+
24+
fig, ax = apl.subplots(1, 1, figsize=(520, 520))
25+
v = ax.imshow(data, cmap="magma", units="px")
26+
27+
# Enable the editable-annotation ("report builder") mode so figure markers are
28+
# hit-testable and draggable, and the figure emits background/marker events.
29+
fig.edit_chrome = True
30+
31+
# %%
32+
# Seed a couple of annotations
33+
# ----------------------------
34+
# Each marker is a dict with a ``kind`` and fraction-space geometry. A text
35+
# label and an arrow pointing into the image to start with.
36+
37+
fig.set_figure_markers([
38+
{"kind": "text", "x": 0.5, "y": 0.06,
39+
"text": "Double-click a feature to annotate it",
40+
"color": "#ffffff", "fontsize": 14},
41+
{"kind": "arrow", "x": 0.20, "y": 0.30, "u": 0.12, "v": 0.12,
42+
"color": "#ffd54f", "linewidth": 2},
43+
])
44+
45+
fig
46+
47+
# %%
48+
# Double-click to drop a new annotation
49+
# -------------------------------------
50+
# On a single-panel figure the panel nearly fills the canvas, so we turn the
51+
# click's device pixels into a figure fraction with the panel's
52+
# ``display_width`` / ``display_height`` and append an arrow + label there.
53+
# Because ``edit_chrome`` is on, the new marker is immediately draggable.
54+
55+
56+
def _on_double_click(event):
57+
if event.x is None or event.display_width is None:
58+
return
59+
fx = float(np.clip(event.x / event.display_width, 0.02, 0.98))
60+
fy = float(np.clip(event.y / event.display_height, 0.02, 0.98))
61+
markers = fig.figure_markers # current list (a copy)
62+
n = sum(1 for m in markers if m["kind"] == "text")
63+
markers.append({"kind": "arrow", "x": fx, "y": fy,
64+
"u": 0.08, "v": -0.08, "color": "#40c4ff", "linewidth": 2})
65+
markers.append({"kind": "text", "x": fx + 0.08, "y": fy - 0.10,
66+
"text": f"mark {n}", "color": "#40c4ff", "fontsize": 13})
67+
fig.set_figure_markers(markers)
68+
69+
70+
v.add_event_handler(_on_double_click, "double_click")
71+
72+
fig.set_help(
73+
"Double-click on the image to drop a labelled arrow.\n"
74+
"Drag any annotation to reposition it (edit mode is on)."
75+
)
76+
77+
fig # Interactive
78+
79+
# %%
80+
# Read the placements back
81+
# ------------------------
82+
# After the user drags things around, ``fig.figure_markers`` reflects the
83+
# current fraction positions — persist them, export them, or feed them into a
84+
# report.
85+
86+
for m in fig.figure_markers:
87+
print(m["kind"], round(m["x"], 3), round(m["y"], 3))

‎Examples/Interactive/plot_voxel_grain_explorer.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
rng = np.random.default_rng(11)
3030

3131
# ── 1. Synthetic 3-D polycrystal: nearest-seed voxel grain map ──────────────
32-
N = 48 # volume is N³ voxels, indexed V[z, y, x]
32+
N = 24 # volume is N³ voxels, indexed V[z, y, x]
3333
N_GRAINS = 40
3434

3535
seeds = rng.uniform(0, N, size=(N_GRAINS, 3)) # (z, y, x)
@@ -76,7 +76,8 @@ def random_rotations(n):
7676
# planes. This anchors the highlight exactly where the slices intersect,
7777
# shows real slice contents in 3-D, and scales: the on-plane count is
7878
# ~3·(N/step)² regardless of N, so it stays fast even for a 256³ volume.
79-
VSTEP = max(1, N // 48) # in-plane downsample → ~48² cubes per plane
79+
VSTEP = max(1, N // 48) # in-plane downsample, capping at ~48² cubes/plane
80+
# for large N (no downsampling at this N=24)
8081

8182
# Voxel cube size in data units. A touch larger than VSTEP so the three
8283
# slabs read as solid sheets rather than a dotted grid.
@@ -140,6 +141,8 @@ def slice_voxels(ix, iy, iz):
140141
size=VOXSIZE, alpha=0.55,
141142
x_label="x", y_label="y", z_label="z",
142143
bounds=((0, N - 1),) * 3, zoom=1.1,
144+
# gpu="auto" (default): ~1.7k cubes is over the ~1k voxel threshold, so the
145+
# WebGPU instanced path handles each drag re-slice when WebGPU is present.
143146
)
144147
v_vol.set_title("Grain volume — drag a plane to re-slice")
145148

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""
2+
Coordinate transforms — pin markers to the axes or the screen
3+
=============================================================
4+
5+
Every ``add_*`` marker method takes a ``transform`` that decides which
6+
coordinate system its positions live in:
7+
8+
- ``"data"`` (default) — data coordinates; the marker moves and scales with
9+
zoom / pan, staying glued to the underlying data.
10+
- ``"axes"`` — axes-normalised ``(0, 0)`` bottom-left … ``(1, 1)`` top-right;
11+
the marker stays in the same corner of the panel no matter how you zoom.
12+
- ``"display"`` — raw CSS pixels within the panel; a fixed-size decoration.
13+
14+
The ``"axes"`` and ``"display"`` transforms are how you build overlays that
15+
should *not* track the data — a navigation index in the corner, a scale bar, a
16+
persistent legend chip.
17+
"""
18+
import numpy as np
19+
import anyplotlib as apl
20+
21+
rng = np.random.default_rng(1)
22+
data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1)
23+
data = (data - data.min()) / (data.max() - data.min())
24+
xy = np.linspace(0, 10, 128)
25+
26+
fig, ax = apl.subplots(1, 1, figsize=(480, 480))
27+
v = ax.imshow(data, axes=[xy, xy], units="nm")
28+
29+
# Data-anchored label — sits at (5, 5) in nm and rides along on zoom/pan.
30+
v.add_texts(offsets=[(5, 5)], texts=["feature @ (5, 5)"],
31+
color="#ffffff", fontsize=12, name="data_label")
32+
33+
# Axes-anchored index — stays pinned to the top-left corner of the panel
34+
# regardless of zoom (0, 1 = top-left in axes fractions).
35+
v.add_texts(offsets=[(0.04, 0.96)], texts=["frame 3 / 20"],
36+
transform="axes", color="#ffd54f", fontsize=13, name="nav_index")
37+
38+
fig
39+
40+
# %%
41+
# Try it
42+
# ------
43+
# Zoom into the image: the white ``feature`` label moves with the data, while
44+
# the yellow ``frame 3 / 20`` index stays locked to the corner — because it is
45+
# positioned in ``"axes"`` coordinates.
46+
47+
fig.set_help("Zoom in: the corner index stays put; the data label moves.")
48+
49+
fig # Interactive
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""
2+
GPU-accelerated voxels
3+
======================
4+
5+
:meth:`~anyplotlib.Axes.voxels` renders shaded translucent cubes for a
6+
volumetric field. With ``gpu="auto"`` (the default) anyplotlib uses WebGPU
7+
instancing when a GPU is available and the cube count is large, so hundreds of
8+
thousands of voxels stay interactive; otherwise it falls back to the Canvas2D
9+
path. Read :attr:`~anyplotlib.Plot3D.gpu_active` after the first frame to see
10+
which path was chosen.
11+
12+
Here we build a dense spherical shell — enough cubes that the WebGPU path
13+
kicks in — and drop a draggable :class:`~anyplotlib.PlaneWidget` through it as
14+
a slice selector.
15+
"""
16+
import numpy as np
17+
import anyplotlib as apl
18+
19+
# %%
20+
# Build a volumetric field
21+
# ------------------------
22+
# Voxel *centres* are passed as three flat coordinate arrays (not a dense 3-D
23+
# grid), so you only send the cubes you actually want drawn. We keep the
24+
# voxels inside a spherical shell and colour them by radius.
25+
26+
N = 64
27+
g = np.arange(N)
28+
Z, Y, X = np.meshgrid(g, g, g, indexing="ij")
29+
r = np.sqrt((X - N / 2) ** 2 + (Y - N / 2) ** 2 + (Z - N / 2) ** 2)
30+
shell = (r > N * 0.30) & (r < N * 0.42) # a hollow sphere
31+
32+
xs, ys, zs = X[shell], Y[shell], Z[shell]
33+
print(f"{xs.size:,} voxels") # tens of thousands → GPU path under gpu='auto'
34+
35+
# Colour by radius with the viridis-ish default cycle mapped through intensity.
36+
t = (r[shell] - r[shell].min()) / (np.ptp(r[shell]) + 1e-9)
37+
colors = np.stack([0.2 + 0.8 * t, 0.4 * np.ones_like(t), 1.0 - 0.8 * t], axis=1)
38+
39+
fig, ax = apl.subplots(1, 1, figsize=(560, 520))
40+
vol = ax.voxels(
41+
xs, ys, zs, colors=colors,
42+
size=1.0, alpha=0.35,
43+
bounds=((0, N - 1),) * 3,
44+
azimuth=-55, elevation=28, zoom=1.1,
45+
gpu="auto", # WebGPU when available, Canvas2D otherwise
46+
)
47+
vol.set_title("Spherical shell — drag to rotate, scroll to zoom")
48+
49+
# %%
50+
# Add a slice-selector plane
51+
# --------------------------
52+
# A :class:`~anyplotlib.PlaneWidget` is a draggable axis-aligned plane. Voxels
53+
# lying on it render more opaque, so the current slice pops out of the
54+
# translucent volume. Drag it along z in the browser, or move it from Python.
55+
56+
plane = vol.add_widget("plane", axis="z", position=N // 2,
57+
color="#40c4ff", alpha=0.18)
58+
59+
60+
@plane.add_event_handler("pointer_move")
61+
def _on_slice(event):
62+
# Fires while the plane is dragged; pw.position holds the live slice index.
63+
print("slice at z =", round(plane.position, 1))
64+
65+
66+
fig.set_help(
67+
"Drag: rotate · Scroll: zoom · R: reset view\n"
68+
"Drag the blue plane to slide the z-slice through the shell."
69+
)
70+
71+
fig # Interactive
72+
73+
# %%
74+
# Which render path ran?
75+
# ----------------------
76+
# ``gpu_active`` is populated once the browser reports back after the first
77+
# frame. It is ``True`` when the WebGPU instanced path is live, ``False`` on
78+
# the Canvas2D fallback, and ``None`` before the first frame (as when this
79+
# gallery page is built headlessly). Force a path with ``gpu=True`` /
80+
# ``gpu=False`` if you need determinism.
81+
82+
print("gpu_active:", vol.gpu_active)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""
2+
Step lines and log-scale spectra
3+
================================
4+
5+
Two 1-D options that suit spectral data: a **mid-riser step** line (constant
6+
within each bin, jumping at bin midpoints) via ``linestyle="step-mid"``, and a
7+
**logarithmic y-axis** via :meth:`~anyplotlib.Axes.semilogy` (or
8+
``ax.plot(..., yscale="log")``).
9+
"""
10+
import numpy as np
11+
import anyplotlib as apl
12+
13+
# A noisy binned spectrum.
14+
rng = np.random.default_rng(0)
15+
energy = np.linspace(0, 20, 60)
16+
counts = (np.exp(-(energy - 6) ** 2 / 4) * 1000
17+
+ np.exp(-(energy - 13) ** 2 / 8) * 400
18+
+ rng.uniform(0, 20, energy.size))
19+
20+
# %%
21+
# Step line
22+
# ---------
23+
# ``linestyle="step-mid"`` draws a horizontal segment centred on each x value
24+
# with vertical risers between them — the standard way to show histogram-like
25+
# spectra without implying interpolation between channels.
26+
27+
fig, ax = apl.subplots(1, 1, figsize=(560, 340))
28+
ax.plot(counts, axes=[energy], color="#4fc3f7",
29+
linestyle="step-mid", label="counts")
30+
31+
fig
32+
33+
# %%
34+
# Log y-axis
35+
# ----------
36+
# ``semilogy`` is shorthand for a log y-scale, which brings out the small
37+
# secondary peak that the linear plot flattens. Combine it with the step line
38+
# for a classic spectroscopy view.
39+
40+
fig2, ax2 = apl.subplots(1, 1, figsize=(560, 340))
41+
ax2.semilogy(counts, axes=[energy], color="#ff7043", linestyle="step-mid")
42+
43+
fig2

0 commit comments

Comments
 (0)