fluxplot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fluxplot/__init__.py +115 -0
- fluxplot/_fieldmap.py +97 -0
- fluxplot/_mesh_reduce.py +54 -0
- fluxplot/_scene3d_size.py +95 -0
- fluxplot/_viewer/THIRD-PARTY.txt +23 -0
- fluxplot/_viewer/flux-model3d-viewer.min.js +4221 -0
- fluxplot/_viewer/stamp.json +4 -0
- fluxplot/api.py +1196 -0
- fluxplot/autotag.py +164 -0
- fluxplot/base.mplstyle +0 -0
- fluxplot/brackets.py +242 -0
- fluxplot/canonical_json.py +23 -0
- fluxplot/capture.py +150 -0
- fluxplot/colorcheck.py +285 -0
- fluxplot/colors.py +727 -0
- fluxplot/colorscale.py +477 -0
- fluxplot/data.py +178 -0
- fluxplot/definitions/colormaps.json +1639 -0
- fluxplot/definitions/flexoki.tokens.json +2571 -0
- fluxplot/definitions/palettes.json +2547 -0
- fluxplot/descriptors.py +87 -0
- fluxplot/fields.py +611 -0
- fluxplot/fits.py +240 -0
- fluxplot/glb.py +84 -0
- fluxplot/ids.py +173 -0
- fluxplot/images.py +362 -0
- fluxplot/integrity.py +27 -0
- fluxplot/manifest.py +788 -0
- fluxplot/mesh3d.py +376 -0
- fluxplot/panels.py +284 -0
- fluxplot/postprocess.py +638 -0
- fluxplot/presets.py +66 -0
- fluxplot/provenance.py +177 -0
- fluxplot/raster.py +295 -0
- fluxplot/recipe.py +178 -0
- fluxplot/render.py +66 -0
- fluxplot/roles.py +147 -0
- fluxplot/scene3d.py +386 -0
- fluxplot/scene3d_manifest.py +112 -0
- fluxplot/scene3d_viewer.py +633 -0
- fluxplot/schemas/.gitkeep +0 -0
- fluxplot/schemas/manifest.schema.json +2479 -0
- fluxplot/schemas/recipe.schema.json +179 -0
- fluxplot/schemas/scene3d.schema.json +461 -0
- fluxplot/seaborn_adapters.py +323 -0
- fluxplot/signature_fluxplots/__init__.py +18 -0
- fluxplot/signature_fluxplots/_colour.py +412 -0
- fluxplot/signature_fluxplots/fluxbox.py +433 -0
- fluxplot/signature_fluxplots/glowbar.py +769 -0
- fluxplot/signature_fluxplots/hexmatrix.py +927 -0
- fluxplot/stats/__init__.py +63 -0
- fluxplot/stats/_common.py +196 -0
- fluxplot/stats/multi_group.py +443 -0
- fluxplot/stats/paired.py +209 -0
- fluxplot/stats/two_group.py +149 -0
- fluxplot/style.py +469 -0
- fluxplot/surface.py +487 -0
- fluxplot/surface3d.py +197 -0
- fluxplot/tagger.py +561 -0
- fluxplot/version.py +19 -0
- fluxplot-0.1.0.dist-info/METADATA +1199 -0
- fluxplot-0.1.0.dist-info/RECORD +65 -0
- fluxplot-0.1.0.dist-info/WHEEL +4 -0
- fluxplot-0.1.0.dist-info/licenses/LICENSE +21 -0
- fluxplot-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +472 -0
fluxplot/images.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""``fp.image`` and ``fp.scalebar``: micrographs and other pixel data as first-class, addressable
|
|
2
|
+
plot content.
|
|
3
|
+
|
|
4
|
+
An image is one or more **channels** (a ``(H, W)`` matrix each), every channel with its own LUT
|
|
5
|
+
(a colormap, or a colour name meaning a black-to-colour ramp) and **display range** (the black and
|
|
6
|
+
white points; default the 1st–99.8th percentiles). fluxplot composites the channels into one RGB
|
|
7
|
+
``imshow`` in Python and records everything a consumer needs to redo it: each channel is a colour
|
|
8
|
+
scale in the manifest (``<series>.<channel>``, linear norm over the display range,
|
|
9
|
+
``recolor: "regenerate"`` — or ``"raster"`` with ``value_raster=True``, when the channel's values
|
|
10
|
+
travel beside the SVG) and a recipe control, so Flux's colour-scale editor edits a channel's LUT or
|
|
11
|
+
brightness/contrast and reruns. ``pixel_size`` puts the axes in physical units, and
|
|
12
|
+
:func:`scalebar` draws a vector bar of a stated length in those units.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from . import colorscale as _colorscale
|
|
22
|
+
from . import data as _data
|
|
23
|
+
from . import tagger as _tagger
|
|
24
|
+
from .descriptors import Mark
|
|
25
|
+
|
|
26
|
+
__all__ = ["image", "scalebar", "ImageResult", "DEFAULT_CHANNEL_COLOURS", "DEFAULT_DISPLAY_PERCENTILES"]
|
|
27
|
+
|
|
28
|
+
#: the LUT colours of a multi-channel image without ``luts``: the fluorescence-microscopy
|
|
29
|
+
#: convention (green / magenta first, since they stay distinct for every common colour deficiency)
|
|
30
|
+
DEFAULT_CHANNEL_COLOURS = ("#00ff00", "#ff00ff", "#00ffff", "#ffff00", "#ff0000", "#0000ff")
|
|
31
|
+
#: the default display range: the (black, white) percentiles of a channel's finite values
|
|
32
|
+
DEFAULT_DISPLAY_PERCENTILES = (1.0, 99.8)
|
|
33
|
+
COMPOSITES = ("add", "max")
|
|
34
|
+
_MONO_PREFIX = "image.mono:"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------------------------
|
|
38
|
+
# LUTs
|
|
39
|
+
# ---------------------------------------------------------------------------------------------
|
|
40
|
+
def resolve_lut(spec):
|
|
41
|
+
"""A channel LUT: a Colormap as is; a registered colormap name (matplotlib's or fluxplot's);
|
|
42
|
+
else a colour (``"green"``, ``"#00ff00"``) meaning the black-to-colour ramp
|
|
43
|
+
``image.mono:<hex>``."""
|
|
44
|
+
from matplotlib import colors as mcolors
|
|
45
|
+
from ._fieldmap import resolve_colormap
|
|
46
|
+
if spec is None:
|
|
47
|
+
return resolve_colormap("gray")
|
|
48
|
+
if isinstance(spec, mcolors.Colormap):
|
|
49
|
+
return spec
|
|
50
|
+
name = str(spec)
|
|
51
|
+
if name.startswith(_MONO_PREFIX):
|
|
52
|
+
return _mono(name[len(_MONO_PREFIX):])
|
|
53
|
+
try:
|
|
54
|
+
return resolve_colormap(name)
|
|
55
|
+
except (KeyError, ValueError):
|
|
56
|
+
pass
|
|
57
|
+
if not mcolors.is_color_like(name):
|
|
58
|
+
raise ValueError(f"image: LUT {spec!r} is neither a colormap name nor a colour")
|
|
59
|
+
return _mono(name)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _mono(colour):
|
|
63
|
+
from matplotlib import colors as mcolors
|
|
64
|
+
hex_ = mcolors.to_hex(colour, keep_alpha=False).lower()
|
|
65
|
+
cm = mcolors.LinearSegmentedColormap.from_list(_MONO_PREFIX + hex_, ["#000000", hex_], N=256)
|
|
66
|
+
cm.set_bad((0.0, 0.0, 0.0, 0.0))
|
|
67
|
+
return cm
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------------------------
|
|
71
|
+
# the image
|
|
72
|
+
# ---------------------------------------------------------------------------------------------
|
|
73
|
+
@dataclass
|
|
74
|
+
class ImageResult:
|
|
75
|
+
"""What :func:`image` drew."""
|
|
76
|
+
|
|
77
|
+
ax: Any
|
|
78
|
+
#: the composited RGB ``AxesImage``
|
|
79
|
+
artist: Any
|
|
80
|
+
#: channel names in stack order (``None`` names → ``ch0, ch1, …``; a 2-D image has one channel)
|
|
81
|
+
channels: List[str]
|
|
82
|
+
#: channel name → the values drawn (``float``, NaN for missing), ``(H, W)`` each
|
|
83
|
+
data: Dict[str, np.ndarray]
|
|
84
|
+
#: channel name → its LUT (a Colormap)
|
|
85
|
+
luts: Dict[str, Any]
|
|
86
|
+
#: channel name → ``(black, white)`` display range actually used
|
|
87
|
+
display_range: Dict[str, Tuple[float, float]]
|
|
88
|
+
#: channel name → the recipe control key / manifest colour-scale id
|
|
89
|
+
keys: Dict[str, str]
|
|
90
|
+
#: ``(row, column)`` pixel size in ``units``, or ``None`` (pixel coordinates)
|
|
91
|
+
pixel_size: Optional[Tuple[float, float]]
|
|
92
|
+
units: str
|
|
93
|
+
#: the ``imshow`` extent ``[left, right, bottom, top]``
|
|
94
|
+
extent: List[float]
|
|
95
|
+
#: the composited RGB array, ``(H, W, 3)`` in ``[0, 1]``
|
|
96
|
+
rgb: np.ndarray = field(repr=False, default=None)
|
|
97
|
+
#: channel name → a ``ScalarMappable`` (LUT + display range) for ``fp.colorbar``
|
|
98
|
+
mappables: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def shape(self) -> Tuple[int, int]:
|
|
102
|
+
return self.rgb.shape[:2]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _stack(data, channels):
|
|
106
|
+
"""``(values (C, H, W) float, names)`` from a 2-D image or a (C, H, W) / (H, W, C) stack."""
|
|
107
|
+
arr = np.ma.filled(np.ma.asarray(data, dtype=float), np.nan)
|
|
108
|
+
if arr.ndim == 2:
|
|
109
|
+
if channels is not None and len(channels) != 1:
|
|
110
|
+
raise ValueError(f"image: a 2-D image has one channel, but channels= names {len(channels)}")
|
|
111
|
+
return arr[None], (None if channels is None else [str(channels[0])])
|
|
112
|
+
if arr.ndim != 3:
|
|
113
|
+
raise ValueError(f"image: data must be (H, W), (C, H, W) or (H, W, C); got shape {arr.shape}")
|
|
114
|
+
if channels is not None:
|
|
115
|
+
n = len(channels)
|
|
116
|
+
if arr.shape[0] == n and arr.shape[-1] != n:
|
|
117
|
+
stack = arr
|
|
118
|
+
elif arr.shape[-1] == n:
|
|
119
|
+
stack = np.moveaxis(arr, -1, 0)
|
|
120
|
+
elif arr.shape[0] == n:
|
|
121
|
+
stack = arr
|
|
122
|
+
else:
|
|
123
|
+
raise ValueError(f"image: channels= names {n} channels but data has shape {arr.shape}")
|
|
124
|
+
return stack, [str(c) for c in channels]
|
|
125
|
+
if arr.shape[0] <= 4 < arr.shape[-1]:
|
|
126
|
+
stack = arr
|
|
127
|
+
elif arr.shape[-1] <= 4:
|
|
128
|
+
stack = np.moveaxis(arr, -1, 0)
|
|
129
|
+
else:
|
|
130
|
+
raise ValueError(f"image: cannot tell the channel axis of shape {arr.shape}; pass channels=[...]")
|
|
131
|
+
return stack, [f"ch{i}" for i in range(stack.shape[0])]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _per_channel(value, n, what):
|
|
135
|
+
"""Broadcast a per-channel option: ``None`` → ``[None] * n``; one value → repeated; a list of
|
|
136
|
+
``n`` → as is."""
|
|
137
|
+
if value is None:
|
|
138
|
+
return [None] * n
|
|
139
|
+
if isinstance(value, (list, tuple)) and len(value) == n and (n != 2 or what != "display_range"
|
|
140
|
+
or any(isinstance(v, (list, tuple)) or v is None for v in value)):
|
|
141
|
+
return list(value)
|
|
142
|
+
if what == "display_range":
|
|
143
|
+
if isinstance(value, (list, tuple)) and len(value) == 2 and all(isinstance(v, (int, float, np.number)) for v in value):
|
|
144
|
+
return [tuple(value)] * n
|
|
145
|
+
raise ValueError(f"image: display_range must be (lo, hi) or one pair per channel ({n})")
|
|
146
|
+
if isinstance(value, (list, tuple)):
|
|
147
|
+
if len(value) != n:
|
|
148
|
+
raise ValueError(f"image: {what} has {len(value)} entries for {n} channels")
|
|
149
|
+
return list(value)
|
|
150
|
+
return [value] * n
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _auto_range(values):
|
|
154
|
+
finite = values[np.isfinite(values)]
|
|
155
|
+
if finite.size == 0:
|
|
156
|
+
return 0.0, 1.0
|
|
157
|
+
lo, hi = (float(q) for q in np.percentile(finite, DEFAULT_DISPLAY_PERCENTILES))
|
|
158
|
+
if lo == hi:
|
|
159
|
+
lo, hi = float(finite.min()), float(finite.max())
|
|
160
|
+
if lo == hi:
|
|
161
|
+
hi = lo + 1.0
|
|
162
|
+
return lo, hi
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def image(ax, data, *, series, pixel_size=None, units="µm", origin="upper", channels=None, luts=None,
|
|
166
|
+
display_range=None, composite="add", value_raster=False, key=None, **imshow_kw) -> ImageResult:
|
|
167
|
+
"""Draw a single- or multi-channel image with recorded LUTs and display ranges.
|
|
168
|
+
|
|
169
|
+
Parameters
|
|
170
|
+
----------
|
|
171
|
+
ax, data
|
|
172
|
+
The axes and the pixels: ``(H, W)``, or a ``(C, H, W)`` / ``(H, W, C)`` stack (the channel
|
|
173
|
+
axis is the one matching ``len(channels)``, else the one of length ≤ 4). Missing pixels
|
|
174
|
+
(NaN / masked) are transparent.
|
|
175
|
+
series
|
|
176
|
+
The series name: the image is ``<series>.image`` and every channel's colour scale and
|
|
177
|
+
recipe control is ``<series>.<channel>`` (a 2-D image with no ``channels``: ``<series>``).
|
|
178
|
+
pixel_size, units
|
|
179
|
+
The physical size of a pixel (one number, or ``(row, column)``) and its unit; the axes are
|
|
180
|
+
then in those units (``extent``), and :func:`scalebar` lengths are too.
|
|
181
|
+
origin
|
|
182
|
+
``"upper"`` (row 0 at the top, the imaging convention) or ``"lower"``.
|
|
183
|
+
channels
|
|
184
|
+
Channel names for a stack (default ``ch0, ch1, …``).
|
|
185
|
+
luts
|
|
186
|
+
Per-channel LUT (one for all, or a list): a colormap name / Colormap, or a colour name
|
|
187
|
+
meaning a black-to-colour ramp. Default ``gray`` for one channel, else green, magenta,
|
|
188
|
+
cyan, yellow, red, blue.
|
|
189
|
+
display_range
|
|
190
|
+
Per-channel ``(black, white)`` (one pair for all, or a list): the linear norm's limits.
|
|
191
|
+
Default the 1st–99.8th percentiles of each channel's finite values.
|
|
192
|
+
composite
|
|
193
|
+
How channels combine: ``"add"`` (sum, clipped) or ``"max"``.
|
|
194
|
+
value_raster
|
|
195
|
+
Write each channel's values as ``<plot>.<key>.values.json`` beside the SVG and mark its
|
|
196
|
+
scale ``recolor: "raster"``, so a consumer can repaint from the values.
|
|
197
|
+
key
|
|
198
|
+
Override the control-key root (default ``series``).
|
|
199
|
+
**imshow_kw
|
|
200
|
+
Passed to ``Axes.imshow`` (``interpolation`` defaults to ``"nearest"``).
|
|
201
|
+
|
|
202
|
+
Returns
|
|
203
|
+
-------
|
|
204
|
+
ImageResult
|
|
205
|
+
"""
|
|
206
|
+
from matplotlib.cm import ScalarMappable
|
|
207
|
+
from matplotlib import colors as mcolors
|
|
208
|
+
from .fields import _options
|
|
209
|
+
if composite not in COMPOSITES:
|
|
210
|
+
raise ValueError(f"image: composite must be one of {COMPOSITES}, got {composite!r}")
|
|
211
|
+
if origin not in ("upper", "lower"):
|
|
212
|
+
raise ValueError(f"image: origin must be 'upper' or 'lower', got {origin!r}")
|
|
213
|
+
stack, names = _stack(data, channels)
|
|
214
|
+
n, h, w = stack.shape
|
|
215
|
+
single_unnamed = names is None
|
|
216
|
+
if single_unnamed:
|
|
217
|
+
names = ["ch0"]
|
|
218
|
+
if len(set(names)) != n:
|
|
219
|
+
raise ValueError(f"image: channel names must be distinct, got {names}")
|
|
220
|
+
lut_list = _per_channel(luts, n, "luts")
|
|
221
|
+
ranges = _per_channel(display_range, n, "display_range")
|
|
222
|
+
root = str(key) if key is not None else str(series)
|
|
223
|
+
reg = _tagger.registry_for(ax.figure)
|
|
224
|
+
|
|
225
|
+
rgb = np.zeros((h, w, 3))
|
|
226
|
+
result_luts, result_ranges, keys, mappables, channel_records, scale_records, controls = {}, {}, {}, {}, [], [], {}
|
|
227
|
+
for i, name in enumerate(names):
|
|
228
|
+
vals = stack[i]
|
|
229
|
+
lut = lut_list[i]
|
|
230
|
+
if lut is None:
|
|
231
|
+
lut = "gray" if n == 1 else DEFAULT_CHANNEL_COLOURS[i % len(DEFAULT_CHANNEL_COLOURS)]
|
|
232
|
+
lo, hi = ranges[i] if ranges[i] is not None else _auto_range(vals)
|
|
233
|
+
ckey = root if single_unnamed else f"{root}.{name}"
|
|
234
|
+
opts = {"cmap": lut if isinstance(lut, mcolors.Colormap) else str(lut), "vmin": float(lo), "vmax": float(hi)}
|
|
235
|
+
# the recipe's colour controls for this channel (a LUT or display range edited in Flux)
|
|
236
|
+
ckey = _options(ax, series, ckey, opts, resolve=resolve_lut)
|
|
237
|
+
cmap = resolve_lut(opts["cmap"])
|
|
238
|
+
norm = opts.get("norm")
|
|
239
|
+
if norm is None:
|
|
240
|
+
norm = mcolors.Normalize(vmin=opts.get("vmin", lo), vmax=opts.get("vmax", hi))
|
|
241
|
+
if norm.vmin is None or norm.vmax is None:
|
|
242
|
+
norm.vmin, norm.vmax = (lo if norm.vmin is None else norm.vmin), (hi if norm.vmax is None else norm.vmax)
|
|
243
|
+
sm = ScalarMappable(norm=norm, cmap=cmap)
|
|
244
|
+
rgba = sm.to_rgba(np.ma.masked_invalid(vals), bytes=False)
|
|
245
|
+
layer = rgba[..., :3] * rgba[..., 3:4] # a transparent (missing) pixel adds nothing
|
|
246
|
+
rgb = np.maximum(rgb, layer) if composite == "max" else rgb + layer
|
|
247
|
+
result_luts[name], result_ranges[name], keys[name], mappables[name] = cmap, (float(norm.vmin), float(norm.vmax)), ckey, sm
|
|
248
|
+
field_like = {"normalization": {"kind": type(norm).__name__, "vmin": float(norm.vmin), "vmax": float(norm.vmax),
|
|
249
|
+
"clip": bool(norm.clip)}, "cmap": cmap.name,
|
|
250
|
+
"cmapSpec": _colorscale.cmap_spec(cmap, resolve_lut)}
|
|
251
|
+
controls[ckey] = _colorscale.controls_state(field_like)
|
|
252
|
+
record = _colorscale.scale_record(ckey, sm, label=name if not single_unnamed else None,
|
|
253
|
+
extend="neither", recolor="raster" if value_raster else "regenerate")
|
|
254
|
+
scale_records.append(record)
|
|
255
|
+
channel_records.append({"name": name, "lut": controls[ckey]["cmap"], "displayRange": [float(norm.vmin), float(norm.vmax)],
|
|
256
|
+
"scale": ckey, "controlKey": ckey})
|
|
257
|
+
rgb = np.clip(rgb, 0.0, 1.0)
|
|
258
|
+
|
|
259
|
+
if pixel_size is not None:
|
|
260
|
+
py, px = (float(pixel_size), float(pixel_size)) if np.ndim(pixel_size) == 0 else (float(pixel_size[0]), float(pixel_size[1]))
|
|
261
|
+
if py <= 0 or px <= 0:
|
|
262
|
+
raise ValueError("image: pixel_size must be positive")
|
|
263
|
+
extent = [0.0, w * px, h * py, 0.0] if origin == "upper" else [0.0, w * px, 0.0, h * py]
|
|
264
|
+
size = (py, px)
|
|
265
|
+
else:
|
|
266
|
+
extent = [-0.5, w - 0.5, h - 0.5, -0.5] if origin == "upper" else [-0.5, w - 0.5, -0.5, h - 0.5]
|
|
267
|
+
size = None
|
|
268
|
+
imshow_kw.setdefault("interpolation", "nearest")
|
|
269
|
+
artist = ax.imshow(rgb, origin=origin, extent=extent, **imshow_kw)
|
|
270
|
+
|
|
271
|
+
payload = {"shape": [int(h), int(w)], "channels": channel_records, "composite": composite, "origin": origin,
|
|
272
|
+
"extent": [float(v) for v in extent], "units": str(units),
|
|
273
|
+
"pixelSize": None if size is None else [size[0], size[1]]}
|
|
274
|
+
mark_data = {"image": payload, "color_scales": scale_records, "color_controls": controls, "color_paint": "fill"}
|
|
275
|
+
if value_raster:
|
|
276
|
+
mark_data["value_rasters"] = [
|
|
277
|
+
{"key": keys[name], "payload": {"spec": "fluxplot/values", "scale": keys[name], "shape": [int(h), int(w)],
|
|
278
|
+
"values": _data.values(stack[i].reshape(-1))}}
|
|
279
|
+
for i, name in enumerate(names)]
|
|
280
|
+
reg.add(Mark(role="image", series=series, kind="image", x=None, y=None, artists=[artist], axes=ax, data=mark_data))
|
|
281
|
+
return ImageResult(ax=ax, artist=artist, channels=list(names), data={nm: stack[i] for i, nm in enumerate(names)},
|
|
282
|
+
luts=result_luts, display_range=result_ranges, keys=keys, pixel_size=size, units=str(units),
|
|
283
|
+
extent=[float(v) for v in extent], rgb=rgb, mappables=mappables)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------------------------------------
|
|
287
|
+
# the scale bar
|
|
288
|
+
# ---------------------------------------------------------------------------------------------
|
|
289
|
+
_LOCS = {
|
|
290
|
+
"lower right": (1.0, 0.0), "lower left": (0.0, 0.0), "upper right": (1.0, 1.0), "upper left": (0.0, 1.0),
|
|
291
|
+
"lower center": (0.5, 0.0), "upper center": (0.5, 1.0),
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def scalebar(ax, length, units="µm", *, loc="lower right", label=None, color=None, thickness=2.0,
|
|
296
|
+
pad=0.4, name=None, text_kw=None):
|
|
297
|
+
"""A vector scale bar of ``length`` data units, anchored in a corner of the axes.
|
|
298
|
+
|
|
299
|
+
The bar is a ``Line2D`` whose x extent is exactly ``length`` in data coordinates (so it is
|
|
300
|
+
true to :func:`image`'s ``pixel_size``) and whose vertical anchor is an axes fraction, with
|
|
301
|
+
its label (default ``"<length> <units>"``) centred above it (below, for the upper corners).
|
|
302
|
+
``pad`` is the inset from the axes edge in units of the label's font size. ``color`` paints
|
|
303
|
+
bar and label (default: the theme's ink, so it reads on a dark ground too). Registers
|
|
304
|
+
``Mark(role="scalebar")`` with ``{length, units}``; the label is ``<id>.label``.
|
|
305
|
+
"""
|
|
306
|
+
import matplotlib
|
|
307
|
+
from matplotlib import transforms as mtransforms
|
|
308
|
+
from matplotlib.lines import Line2D
|
|
309
|
+
if length <= 0:
|
|
310
|
+
raise ValueError("scalebar: length must be positive")
|
|
311
|
+
if loc not in _LOCS:
|
|
312
|
+
raise ValueError(f"scalebar: loc must be one of {sorted(_LOCS)}, got {loc!r}")
|
|
313
|
+
reg = _tagger.registry_for(ax.figure)
|
|
314
|
+
idx = reg.next_overlay_index("scalebar")
|
|
315
|
+
if name is None:
|
|
316
|
+
name = str(idx)
|
|
317
|
+
text_kw = dict(text_kw or {})
|
|
318
|
+
fontsize = text_kw.get("fontsize", matplotlib.rcParams["font.size"])
|
|
319
|
+
fontsize_pt = matplotlib.font_manager.FontProperties(size=fontsize).get_size_in_points()
|
|
320
|
+
# the axes' size in points, for pad (font sizes) → axes fraction
|
|
321
|
+
bbox = ax.get_position()
|
|
322
|
+
fig_w, fig_h = ax.figure.get_size_inches()
|
|
323
|
+
ax_w_pt, ax_h_pt = max(bbox.width * fig_w * 72, 1e-9), max(bbox.height * fig_h * 72, 1e-9)
|
|
324
|
+
pad_x, pad_y = pad * fontsize_pt / ax_w_pt, pad * fontsize_pt / ax_h_pt
|
|
325
|
+
hx, hy = _LOCS[loc]
|
|
326
|
+
x0, x1 = ax.get_xlim()
|
|
327
|
+
span = x1 - x0
|
|
328
|
+
frac_len = length / abs(span) if span else 0.0
|
|
329
|
+
if hx == 1.0:
|
|
330
|
+
right = 1.0 - pad_x
|
|
331
|
+
left = right - frac_len
|
|
332
|
+
elif hx == 0.0:
|
|
333
|
+
left = pad_x
|
|
334
|
+
right = left + frac_len
|
|
335
|
+
else:
|
|
336
|
+
left, right = 0.5 - frac_len / 2, 0.5 + frac_len / 2
|
|
337
|
+
y = pad_y if hy == 0.0 else 1.0 - pad_y
|
|
338
|
+
# x in data units (the bar's length is the statement), y as an axes fraction (the anchor)
|
|
339
|
+
xa = x0 + left * span
|
|
340
|
+
xb = xa + (length if span >= 0 else -length)
|
|
341
|
+
trans = mtransforms.blended_transform_factory(ax.transData, ax.transAxes)
|
|
342
|
+
themed = color is None
|
|
343
|
+
if themed:
|
|
344
|
+
color = matplotlib.rcParams["text.color"]
|
|
345
|
+
bar = Line2D([xa, xb], [y, y], transform=trans, color=color, linewidth=thickness, solid_capstyle="butt", zorder=10)
|
|
346
|
+
ax.add_line(bar)
|
|
347
|
+
if label is None:
|
|
348
|
+
label = f"{length:g} {units}".rstrip()
|
|
349
|
+
gap = 0.4 * fontsize_pt / ax_h_pt
|
|
350
|
+
if hy == 0.0:
|
|
351
|
+
ty, va = y + gap, "bottom"
|
|
352
|
+
else:
|
|
353
|
+
ty, va = y - gap, "top"
|
|
354
|
+
text_kw.setdefault("fontsize", fontsize)
|
|
355
|
+
txt = ax.text((xa + xb) / 2, ty, label, transform=trans, ha="center", va=va, color=color, zorder=10, **text_kw)
|
|
356
|
+
data = {"length": float(length), "units": str(units), "label": str(label), "label_artist": txt, "index": idx, "loc": loc}
|
|
357
|
+
if themed:
|
|
358
|
+
data["ink"] = {"stroke": "ink"}
|
|
359
|
+
data["ink_label"] = {"fill": "ink"}
|
|
360
|
+
reg.add(Mark(role="scalebar", series=None, name=name, artists=[bar], axes=ax, data=data))
|
|
361
|
+
bar._fluxplot_label = txt
|
|
362
|
+
return bar
|
fluxplot/integrity.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Referential checks against the SVG actually drawn, before any files are replaced."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def validate_references(manifest, present):
|
|
6
|
+
refs = set()
|
|
7
|
+
for axis in manifest['axes']:
|
|
8
|
+
if axis.get('svgId'): refs.add(axis['svgId'])
|
|
9
|
+
for series in manifest['series']:
|
|
10
|
+
for value in series['svg'].values():
|
|
11
|
+
refs.update(value if isinstance(value, list) else [value])
|
|
12
|
+
for part in series.get('components', []):
|
|
13
|
+
refs.add(part['svgId']); refs.update(part.get('members', []))
|
|
14
|
+
for point in series.get('points', []): refs.add(point['svgId'])
|
|
15
|
+
for guide in manifest.get('guides', []):
|
|
16
|
+
if guide.get('svgId'): refs.add(guide['svgId'])
|
|
17
|
+
if guide.get('mappable'): refs.add(guide['mappable'])
|
|
18
|
+
for part in guide.get('parts', []): refs.add(part['svgId'])
|
|
19
|
+
for overlay in manifest.get('overlays', []): refs.add(overlay['svgId'])
|
|
20
|
+
def walk(node):
|
|
21
|
+
if node.get('ref'): refs.add(node['ref'])
|
|
22
|
+
refs.update(node.get('members', []))
|
|
23
|
+
for child in node.get('children', []): walk(child)
|
|
24
|
+
walk(manifest['parts'])
|
|
25
|
+
missing = refs - present
|
|
26
|
+
if missing:
|
|
27
|
+
raise ValueError('manifest references missing SVG parts: ' + ', '.join(sorted(missing)[:10]))
|