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.
Files changed (65) hide show
  1. fluxplot/__init__.py +115 -0
  2. fluxplot/_fieldmap.py +97 -0
  3. fluxplot/_mesh_reduce.py +54 -0
  4. fluxplot/_scene3d_size.py +95 -0
  5. fluxplot/_viewer/THIRD-PARTY.txt +23 -0
  6. fluxplot/_viewer/flux-model3d-viewer.min.js +4221 -0
  7. fluxplot/_viewer/stamp.json +4 -0
  8. fluxplot/api.py +1196 -0
  9. fluxplot/autotag.py +164 -0
  10. fluxplot/base.mplstyle +0 -0
  11. fluxplot/brackets.py +242 -0
  12. fluxplot/canonical_json.py +23 -0
  13. fluxplot/capture.py +150 -0
  14. fluxplot/colorcheck.py +285 -0
  15. fluxplot/colors.py +727 -0
  16. fluxplot/colorscale.py +477 -0
  17. fluxplot/data.py +178 -0
  18. fluxplot/definitions/colormaps.json +1639 -0
  19. fluxplot/definitions/flexoki.tokens.json +2571 -0
  20. fluxplot/definitions/palettes.json +2547 -0
  21. fluxplot/descriptors.py +87 -0
  22. fluxplot/fields.py +611 -0
  23. fluxplot/fits.py +240 -0
  24. fluxplot/glb.py +84 -0
  25. fluxplot/ids.py +173 -0
  26. fluxplot/images.py +362 -0
  27. fluxplot/integrity.py +27 -0
  28. fluxplot/manifest.py +788 -0
  29. fluxplot/mesh3d.py +376 -0
  30. fluxplot/panels.py +284 -0
  31. fluxplot/postprocess.py +638 -0
  32. fluxplot/presets.py +66 -0
  33. fluxplot/provenance.py +177 -0
  34. fluxplot/raster.py +295 -0
  35. fluxplot/recipe.py +178 -0
  36. fluxplot/render.py +66 -0
  37. fluxplot/roles.py +147 -0
  38. fluxplot/scene3d.py +386 -0
  39. fluxplot/scene3d_manifest.py +112 -0
  40. fluxplot/scene3d_viewer.py +633 -0
  41. fluxplot/schemas/.gitkeep +0 -0
  42. fluxplot/schemas/manifest.schema.json +2479 -0
  43. fluxplot/schemas/recipe.schema.json +179 -0
  44. fluxplot/schemas/scene3d.schema.json +461 -0
  45. fluxplot/seaborn_adapters.py +323 -0
  46. fluxplot/signature_fluxplots/__init__.py +18 -0
  47. fluxplot/signature_fluxplots/_colour.py +412 -0
  48. fluxplot/signature_fluxplots/fluxbox.py +433 -0
  49. fluxplot/signature_fluxplots/glowbar.py +769 -0
  50. fluxplot/signature_fluxplots/hexmatrix.py +927 -0
  51. fluxplot/stats/__init__.py +63 -0
  52. fluxplot/stats/_common.py +196 -0
  53. fluxplot/stats/multi_group.py +443 -0
  54. fluxplot/stats/paired.py +209 -0
  55. fluxplot/stats/two_group.py +149 -0
  56. fluxplot/style.py +469 -0
  57. fluxplot/surface.py +487 -0
  58. fluxplot/surface3d.py +197 -0
  59. fluxplot/tagger.py +561 -0
  60. fluxplot/version.py +19 -0
  61. fluxplot-0.1.0.dist-info/METADATA +1199 -0
  62. fluxplot-0.1.0.dist-info/RECORD +65 -0
  63. fluxplot-0.1.0.dist-info/WHEEL +4 -0
  64. fluxplot-0.1.0.dist-info/licenses/LICENSE +21 -0
  65. fluxplot-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +472 -0
@@ -0,0 +1,412 @@
1
+ """Colour sources shared by the signature fluxplots.
2
+
3
+ A signature plot takes *one* colour spec per group and turns it into (a) as many distinct point
4
+ colours as it needs and (b) one solid, representative group colour. The spec can be anything
5
+ fluxplot knows about:
6
+
7
+ * a colormap name from ``fp.colors.maps`` — qualified (``"cmasher.emerald"``, ``"crameri.batlow"``,
8
+ ``"tol.sunset"``, ``"cmr.emerald"``) or bare (``"emerald"``, ``"YlGnBu"``), ``_r`` for reversed;
9
+ * a palette from ``fp.colors.palettes`` — ``"brewer.Set2"``, ``"tol.bright"``, ``"flexoki.blue"`` or a
10
+ bare group name (``"bright"``) when no colormap or colour has that name;
11
+ * any matplotlib-registered colormap name or a ``Colormap`` object;
12
+ * a list of colours (a hand-made palette);
13
+ * a single colour (it becomes a pale → colour → deep ramp). A colour name wins over a palette
14
+ of the same name: ``"red"`` is the colour, ``"flexoki.red"`` the 13-step Flexoki ramp.
15
+
16
+ **Ordered** sources (sequential maps / palettes — lightness runs one way) are oriented light → dark;
17
+ points take equal perceptual steps between two lightness bounds and the group colour sits at a fixed
18
+ position along the ramp. **Unordered** continuous sources (diverging, cyclic, rainbow) are sampled at
19
+ equal perceptual steps along the part of the map inside the lightness bounds — so a diverging map's
20
+ pale centre is skipped, never used for a dot on white. **Qualitative** palettes keep their designer's
21
+ order (their first colours are the most distinct), minus any too pale to read. For unordered sources
22
+ the group colour is the map's most chromatic colour of mid lightness — its most characteristic hue.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field
27
+ from typing import Optional
28
+
29
+ import numpy as np
30
+
31
+ #: lightness window used when choosing a representative colour of an unordered source
32
+ _REP_LIGHTNESS = (35.0, 70.0)
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # perceptual space
37
+ # ---------------------------------------------------------------------------
38
+ def lab(rgb) -> np.ndarray:
39
+ """sRGB in [0, 1] (…, 3+) → CIELAB (D65)."""
40
+ c = np.asarray(rgb, float)[..., :3]
41
+ lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
42
+ m = np.array([[0.4124564, 0.3575761, 0.1804375],
43
+ [0.2126729, 0.7151522, 0.0721750],
44
+ [0.0193339, 0.1191920, 0.9503041]])
45
+ xyz = lin @ m.T / np.array([0.95047, 1.0, 1.08883])
46
+ f = np.where(xyz > 216 / 24389, np.cbrt(xyz), (24389 / 27 * xyz + 16) / 116)
47
+ return np.stack([116 * f[..., 1] - 16, 500 * (f[..., 0] - f[..., 1]),
48
+ 200 * (f[..., 1] - f[..., 2])], axis=-1)
49
+
50
+
51
+ def perceptual(rgb) -> np.ndarray:
52
+ """sRGB → a perceptually uniform space whose first axis is lightness on a 0–100 scale.
53
+
54
+ CAM02-UCS (the space viridis and cmasher are designed in) when ``colorspacious`` is importable —
55
+ it ships with cmasher, a fluxplot dependency — otherwise CIELAB.
56
+ """
57
+ rgb = np.asarray(rgb, float)[..., :3]
58
+ try:
59
+ from colorspacious import cspace_convert
60
+ except ImportError: # pragma: no cover - colorspacious comes with cmasher
61
+ return lab(rgb)
62
+ return cspace_convert(rgb, "sRGB1", "CAM02-UCS")
63
+
64
+
65
+ def darken(c, k):
66
+ from matplotlib.colors import to_rgba
67
+ r, g, b, a = to_rgba(c)
68
+ return (r * (1 - k), g * (1 - k), b * (1 - k), a)
69
+
70
+
71
+ def _monotone(rgbs, tol=1.0) -> bool:
72
+ """Lightness runs one way (within ``tol``) — i.e. the colours form an ordered ramp."""
73
+ L = lab(np.asarray(rgbs, float))[..., 0]
74
+ d = np.diff(L)
75
+ return bool(len(L) >= 3 and (np.all(d <= tol) or np.all(d >= -tol)))
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # resolving a spec
80
+ # ---------------------------------------------------------------------------
81
+ @dataclass
82
+ class ColourSource:
83
+ """One group's colour source, resolved."""
84
+
85
+ label: str
86
+ #: "continuous" (sample a colormap) or "discrete" (pick from a list of colours)
87
+ kind: str
88
+ #: lightness runs one way (sequential); ordered sources are oriented light → dark
89
+ ordered: bool
90
+ cmap: Optional[object] = None # continuous sources
91
+ colours: list = field(default_factory=list) # discrete sources, RGBA
92
+ fixed: Optional[tuple] = None # a single colour given as the spec: it IS the group colour
93
+ brewer: bool = False # a ColorBrewer ramp (its 0.75 tone is the designed group colour)
94
+
95
+ def ramp(self):
96
+ """A light→dark colormap for an ordered source (discrete lists are interpolated)."""
97
+ from matplotlib.colors import LinearSegmentedColormap
98
+ if self.cmap is not None:
99
+ return self.cmap
100
+ return LinearSegmentedColormap.from_list(f"glowbar-{self.label}", [c[:3] for c in self.colours])
101
+
102
+
103
+ def _from_colormap(cm, label, kind_hint=None, discrete_hint=None):
104
+ from matplotlib.colors import ListedColormap
105
+
106
+ from ..colors import DISCRETE_MAX
107
+ discrete = discrete_hint if discrete_hint is not None else (
108
+ isinstance(cm, ListedColormap) and cm.N <= DISCRETE_MAX)
109
+ if discrete and hasattr(cm, "colors"):
110
+ return _from_list(list(cm.colors), label, kind_hint)
111
+ if kind_hint in ("sequential", "diverging", "cyclic", "qualitative"):
112
+ ordered = kind_hint == "sequential"
113
+ else:
114
+ ordered = _monotone(cm(np.linspace(0, 1, 64)))
115
+ if ordered and lab(cm(0.0))[0] < lab(cm(1.0))[0]:
116
+ cm = cm.reversed()
117
+ return ColourSource(label, "continuous", ordered, cmap=cm)
118
+
119
+
120
+ def _rgba(c):
121
+ from matplotlib.colors import to_rgba
122
+ return to_rgba(c)
123
+
124
+
125
+ def _from_list(colours, label, kind_hint=None):
126
+ cols = [_rgba(c) for c in colours]
127
+ if not cols:
128
+ raise ValueError(f"colour source {label!r} is an empty palette")
129
+ ordered = kind_hint == "sequential" if kind_hint else _monotone([c[:3] for c in cols])
130
+ if ordered and lab(cols[0])[0] < lab(cols[-1])[0]:
131
+ cols = cols[::-1]
132
+ return ColourSource(label, "discrete", ordered, colours=cols)
133
+
134
+
135
+ def _palette(name):
136
+ """``(colours, type)`` for ``"collection.group"`` or a bare group name, else ``None``."""
137
+ from .. import colors as _colors
138
+ pal = _colors.palettes
139
+ if "." in name:
140
+ cid, _, group = name.partition(".")
141
+ candidates = [(cid, group)] if cid in pal.collections() else []
142
+ else:
143
+ candidates = [(cid, name) for cid in ("brewer", "tol", "flexoki")]
144
+ for cid, group in candidates:
145
+ for g in pal.info(cid)["groups"]:
146
+ if g["name"] == group:
147
+ return [s["hex"] for s in g["swatches"]], g.get("type")
148
+ return None
149
+
150
+
151
+ def resolve(spec, label="group") -> ColourSource:
152
+ """Resolve any colour spec fluxplot understands (see the module docstring)."""
153
+ from matplotlib.colors import Colormap
154
+
155
+ if isinstance(spec, Colormap):
156
+ info = _map_info(spec.name)
157
+ return _from_colormap(spec, label, info.get("type"), info.get("discrete"))
158
+ if isinstance(spec, str):
159
+ info = _map_info(spec)
160
+ src = _resolve_str(spec, label, info)
161
+ if src is not None:
162
+ src.brewer = _is_brewer(spec, info)
163
+ return src
164
+ elif isinstance(spec, (list, tuple)) and len(spec) and not _is_single_colour(spec):
165
+ return _from_list(list(spec), label)
166
+ return _single(spec, label)
167
+
168
+
169
+ def _resolve_str(spec, label, info):
170
+ """A named colormap or palette → its source, or ``None`` when the name is neither.
171
+
172
+ A string that names a single colour (``"red"``, ``"#4cb391"``, ``"tab:blue"``) is that
173
+ colour — the Flexoki ramp of the same name is spelled ``"flexoki.red"``.
174
+ """
175
+ import matplotlib as mpl
176
+ from matplotlib.colors import is_color_like
177
+
178
+ from .. import colors as _colors
179
+ if is_color_like(spec) and spec not in mpl.colormaps:
180
+ return None # resolve() turns it into a single-colour ramp
181
+ if spec in mpl.colormaps: # matplotlib's exact map (built-ins, cmr.*, fluxplot-registered names)
182
+ return _from_colormap(mpl.colormaps[spec], label, info.get("type"), info.get("discrete"))
183
+ try: # fluxplot's collections: bare names ("emerald", "batlow"), "cmasher.emerald", …
184
+ return _from_colormap(_colors.maps.get(spec), label, info.get("type"), info.get("discrete"))
185
+ except KeyError:
186
+ pass
187
+ found = _palette(spec)
188
+ if found is not None:
189
+ colours, kind = found
190
+ return _from_list(colours, label, kind)
191
+ return None
192
+
193
+
194
+ _BREWER = None
195
+
196
+
197
+ def _is_brewer(spec, info):
198
+ global _BREWER
199
+ if _BREWER is None:
200
+ from .. import colors as _colors
201
+ _BREWER = set(_colors.palettes.names("brewer"))
202
+ base = spec[:-2] if spec.endswith("_r") else spec
203
+ cid, _, bare = base.rpartition(".")
204
+ return bare in _BREWER and cid in ("", "brewer", "mpl")
205
+
206
+
207
+ def _single(spec, label):
208
+ import matplotlib as mpl
209
+ try:
210
+ colour = _rgba(spec)
211
+ except (ValueError, TypeError):
212
+ raise ValueError(
213
+ f"{label}: {spec!r} is not a colormap (fp.colors.maps / matplotlib), a palette "
214
+ "(fp.colors.palettes, e.g. 'brewer.Set2' or 'tol.bright'), a list of colours or a colour"
215
+ ) from None
216
+ rgb = np.array(colour[:3])
217
+ ramp = [tuple(1 - 0.82 * (1 - rgb)), tuple(rgb), darken(colour, 0.6)[:3]]
218
+ src = _from_colormap(mpl.colors.LinearSegmentedColormap.from_list(f"glowbar-{label}", ramp),
219
+ label, "sequential")
220
+ src.fixed = colour
221
+ return src
222
+
223
+
224
+ def _is_single_colour(spec) -> bool:
225
+ try:
226
+ _rgba(spec)
227
+ return True
228
+ except (ValueError, TypeError):
229
+ return False
230
+
231
+
232
+ def _map_info(name) -> dict:
233
+ from .. import colors as _colors
234
+ try:
235
+ return _colors.maps.info(name)
236
+ except Exception:
237
+ return {}
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # choosing colours
242
+ # ---------------------------------------------------------------------------
243
+ def even_shades(cmap, n, pale=88.0, dark=22.0):
244
+ """``n`` colours from a light→dark colormap, spaced EVENLY IN PERCEIVED COLOUR.
245
+
246
+ The shades run from lightness ``pale`` to ``dark`` (0 = black, 100 = white) in equal steps of
247
+ CAM02-UCS distance, i.e. equal *visible* differences. Sampling a map evenly in its parameter
248
+ instead makes some neighbours nearly identical (ColorBrewer's YlGnBu merges its two darkest
249
+ navies that way). A single shade is taken from the middle of the range.
250
+ """
251
+ if n <= 0:
252
+ return []
253
+ t = np.linspace(0.0, 1.0, 2048)
254
+ u = perceptual(cmap(t))
255
+ lightness = np.minimum.accumulate(u[:, 0]) # sequential maps darken monotonically; drop wiggles
256
+ keep = (lightness <= pale) & (lightness >= dark)
257
+ if keep.sum() < 2:
258
+ raise ValueError(
259
+ f"the colormap {getattr(cmap, 'name', cmap)!r} has no stretch between lightness "
260
+ f"{pale} and {dark}; widen shade_range")
261
+ arc = np.r_[0.0, np.cumsum(np.linalg.norm(np.diff(u[keep], axis=0), axis=1))]
262
+ targets = [arc[-1] / 2] if n == 1 else np.linspace(0.0, arc[-1], n)
263
+ return [cmap(float(ti)) for ti in np.interp(targets, arc, t[keep])]
264
+
265
+
266
+ def _even_along(cmap, n, pale, dark):
267
+ """Equal perceptual steps along the parts of ANY colormap inside the lightness window.
268
+
269
+ Jumps across excluded stretches (a diverging map's pale centre) add no distance, so the chosen
270
+ colours stay evenly spread over what is actually usable.
271
+ """
272
+ t = np.linspace(0.0, 1.0, 2048)
273
+ u = perceptual(cmap(t))
274
+ keep = (u[:, 0] <= pale) & (u[:, 0] >= dark)
275
+ if keep.sum() < 2:
276
+ raise ValueError(f"the colormap {getattr(cmap, 'name', cmap)!r} has no stretch between "
277
+ f"lightness {pale} and {dark}; widen shade_range")
278
+ tk, uk = t[keep], u[keep]
279
+ step = np.linalg.norm(np.diff(uk, axis=0), axis=1)
280
+ step[np.diff(np.flatnonzero(keep)) > 1] = 0.0 # no distance across a gap
281
+ arc = np.r_[0.0, np.cumsum(step)]
282
+ targets = [arc[-1] / 2] if n == 1 else np.linspace(0.0, arc[-1], n)
283
+ return [cmap(float(tk[min(np.searchsorted(arc, a), len(tk) - 1)])) for a in targets]
284
+
285
+
286
+ def shades(src: ColourSource, n, pale=88.0, dark=22.0):
287
+ """``n`` distinct point colours from a resolved source."""
288
+ if n <= 0:
289
+ return []
290
+ if src.ordered:
291
+ if src.kind == "discrete":
292
+ usable = [c for c in src.colours if dark <= perceptual(c[:3])[0] <= pale]
293
+ if len(usable) >= n: # the designer's own classes, evenly spread
294
+ idx = np.round(np.linspace(0, len(usable) - 1, n)).astype(int) if n > 1 else [len(usable) // 2]
295
+ return [usable[i] for i in idx]
296
+ return even_shades(src.ramp(), n, pale, dark)
297
+ if src.kind == "continuous":
298
+ return _even_along(src.cmap, n, pale, dark)
299
+ usable = [c for c in src.colours if perceptual(c[:3])[0] <= pale] or src.colours
300
+ if n > len(usable):
301
+ import warnings
302
+ warnings.warn(f"{src.label}: the palette has {len(usable)} usable colours for {n} points; "
303
+ "colours repeat — use a colormap (or a larger palette) to keep them distinct",
304
+ stacklevel=3)
305
+ return [usable[i % len(usable)] for i in range(n)]
306
+
307
+
308
+ #: neutral group colour for multi-hue sources on a light ground (Flexoki base-600): no single
309
+ #: hue stands for them
310
+ NEUTRAL = (0x6F / 255, 0x6E / 255, 0x69 / 255, 1.0)
311
+ #: its counterpart on a dark ground (Flexoki base-300)
312
+ NEUTRAL_DARK = (0xB7 / 255, 0xB5 / 255, 0xAC / 255, 1.0)
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # the ground: every ink is judged against what it is drawn on
317
+ # ---------------------------------------------------------------------------
318
+ def ground_lightness(ground) -> float:
319
+ """Perceptual lightness (0–100) of a ground colour."""
320
+ return float(perceptual(np.array(_rgba(ground)[:3]))[0])
321
+
322
+
323
+ def is_dark(ground) -> bool:
324
+ """A dark ground (lightness below 50): inks lift toward white instead of deepening."""
325
+ return ground_lightness(ground) < 50.0
326
+
327
+
328
+ def neutral_for(ground=None):
329
+ """The neutral ink for a multi-hue source: base-600 on a light ground, base-300 on a dark one."""
330
+ return NEUTRAL_DARK if ground is not None and is_dark(ground) else NEUTRAL
331
+
332
+
333
+ def rim(col, ground=None, k=0.3):
334
+ """A point's outline: a deeper shade of its own colour on a light ground, a lighter one on a
335
+ dark ground — so the palest and the deepest points both stay crisp against the ground."""
336
+ from matplotlib.colors import to_rgba
337
+ r, g, b, a = to_rgba(col)
338
+ if ground is not None and is_dark(ground):
339
+ return (r + (1 - r) * k, g + (1 - g) * k, b + (1 - b) * k, a)
340
+ return darken(col, k)
341
+
342
+
343
+ def shade_bounds(pale: float, dark: float, ground=None) -> tuple:
344
+ """The lightness window for a group's shades on ``ground``: as given on a light ground; on a
345
+ dark ground the dark bound is lifted to at least 20 lightness units above the ground, so the
346
+ deepest shade still stands off it (the "lift instead of deepen" rule)."""
347
+ if ground is None or not is_dark(ground):
348
+ return pale, dark
349
+ return pale, max(dark, min(pale - 10.0, ground_lightness(ground) + 20.0))
350
+
351
+
352
+ def median_ink(col, alpha, ground, contrast):
353
+ """The group colour, deepened — or, on a ground darker than it, lifted — only as far as it takes
354
+ to stand ``contrast`` lightness units (0–100) off the group's mark as rendered over ``ground``
355
+ at opacity ``alpha`` (the fluxbox's box wash, the glowbar's glow peak)."""
356
+ from matplotlib.colors import to_rgba
357
+ rgb, bg = np.array(to_rgba(col)[:3]), np.array(to_rgba(ground)[:3])
358
+ box = perceptual(alpha * rgb + (1 - alpha) * bg)[0]
359
+ k = np.linspace(0.0, 1.0, 101)[:, None]
360
+ toward = 0.0 if perceptual(rgb)[0] <= box else 1.0 # the mark is paler than its ink on a light ground
361
+ inks = rgb + (toward - rgb) * k # the ink → black (or → white), in 1 % steps
362
+ far = np.abs(perceptual(inks)[:, 0] - box) >= contrast
363
+ return (*inks[int(np.argmax(far)) if far.any() else -1], 1.0)
364
+
365
+
366
+ def representative(src: ColourSource, position=None, ground=None):
367
+ """One solid colour that stands for the whole source (glow, caps, the mean's base shade).
368
+
369
+ * a single colour given as the spec → that colour;
370
+ * ``position`` given → that point of the light → dark ramp (ordered sources);
371
+ * a ColorBrewer ramp → 0.75 along it (Brewer places a strong, saturated tone there);
372
+ * any other source → its most chromatic colour of mid lightness, provided its hues agree
373
+ (one hue family, e.g. cmasher ``emerald``); a source whose hues spread around the wheel
374
+ (diverging, rainbow) has no honest single hue, and a qualitative palette is a set of
375
+ different categories — both get a neutral ink, chosen against ``ground``.
376
+ """
377
+ neutral = neutral_for(ground)
378
+ if src.fixed is not None:
379
+ return src.fixed
380
+ if src.ordered and (position is not None or src.brewer):
381
+ return tuple(src.ramp()(0.75 if position is None else position))
382
+ if src.kind == "discrete" and not src.ordered:
383
+ return neutral # a qualitative palette: every colour is a different category, none is 'the' hue
384
+ cands = list(src.cmap(np.linspace(0, 1, 256))) if src.kind == "continuous" else list(src.colours)
385
+ u = perceptual(np.array([c[:3] for c in cands]))
386
+ chroma = np.hypot(u[:, 1], u[:, 2])
387
+ mid = (u[:, 0] >= _REP_LIGHTNESS[0]) & (u[:, 0] <= _REP_LIGHTNESS[1]) & (chroma > 5)
388
+ if not mid.any():
389
+ return neutral
390
+ hue = np.arctan2(u[mid, 2], u[mid, 1])
391
+ w = chroma[mid]
392
+ resultant = np.hypot((w * np.cos(hue)).sum(), (w * np.sin(hue)).sum()) / w.sum()
393
+ if resultant < 0.6: # hues spread around the wheel
394
+ return neutral
395
+ pool = np.flatnonzero(mid)
396
+ return tuple(cands[int(pool[np.argmax(chroma[pool])])])
397
+
398
+
399
+ def interleaved_order(n):
400
+ """Lane → shade rank such that neighbouring lanes are always ≥ 2 shades apart.
401
+
402
+ ``0, h, 1, h+1, …`` with ``h = ceil(n/2)`` — e.g. ``[0, 3, 1, 4, 2, 5]`` for six lanes. The palest
403
+ shade still sits in the first lane and the darkest in the last.
404
+ """
405
+ import itertools
406
+ h = (n + 1) // 2
407
+ order = []
408
+ for a, b in itertools.zip_longest(range(h), range(h, n)):
409
+ order.append(a)
410
+ if b is not None:
411
+ order.append(b)
412
+ return order