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/presets.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Per-role animation preset hints (spec §8).
|
|
2
|
+
|
|
3
|
+
The generator has the most semantic knowledge, so it ships sensible *defaults* — the easy path
|
|
4
|
+
becomes the beautiful path (Flux Slide can produce an elegant build with zero hand-authoring). These
|
|
5
|
+
are hints only; a consumer may override everything.
|
|
6
|
+
|
|
7
|
+
Tier note (see SciForge_Stack_Decision §3.4 / style_principles): straight-edged marks animate via
|
|
8
|
+
compositor-friendly transforms (``grow-from-baseline`` = scaleY); only genuine curves use the
|
|
9
|
+
surgical ``draw-on`` (stroke-dashoffset).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
#: The closed vocabulary of build animations a preset may name. Flux maps each to a player
|
|
14
|
+
#: preset (``grow-from-baseline`` → ``growBaseline``, ``fade-rise`` → ``fadeRise``, …); a name
|
|
15
|
+
#: outside this tuple would silently fall back to a plain fade, so the schema enumerates it.
|
|
16
|
+
PRESET_NAMES = ("draw-on", "fade-in", "stagger-in", "grow-from-baseline", "fade-rise", "write-on", "pop-in")
|
|
17
|
+
|
|
18
|
+
#: What a ``stagger-in`` orders its members by: a data attribute every member carries
|
|
19
|
+
#: (``data-x`` / ``data-y`` / ``data-index`` / ``data-value`` / ``data-count``) or its category.
|
|
20
|
+
STAGGER_BY = ("x", "y", "index", "value", "count", "category")
|
|
21
|
+
|
|
22
|
+
ROLE_PRESETS = {
|
|
23
|
+
"line": {"animation": "draw-on", "durationMs": 800},
|
|
24
|
+
"point": {"animation": "stagger-in", "staggerBy": "index", "staggerMs": 40, "durationMs": 240},
|
|
25
|
+
"bar": {"animation": "grow-from-baseline", "durationMs": 500},
|
|
26
|
+
"area": {"animation": "fade-in", "durationMs": 500},
|
|
27
|
+
"errorbar": {"animation": "fade-in", "durationMs": 300},
|
|
28
|
+
"box": {"animation": "grow-from-baseline", "durationMs": 500},
|
|
29
|
+
# composite statistics fade; filled bodies get no speculative draw-on (plan §4)
|
|
30
|
+
"violin": {"animation": "fade-in", "durationMs": 500},
|
|
31
|
+
"whisker": {"animation": "fade-in", "durationMs": 300},
|
|
32
|
+
"cap": {"animation": "fade-in", "durationMs": 300},
|
|
33
|
+
"median": {"animation": "fade-in", "durationMs": 300},
|
|
34
|
+
"flier": {"animation": "fade-in", "durationMs": 240},
|
|
35
|
+
"mean": {"animation": "fade-in", "durationMs": 300},
|
|
36
|
+
"segment": {"animation": "fade-in", "durationMs": 300},
|
|
37
|
+
"axis": {"animation": "draw-on", "durationMs": 400},
|
|
38
|
+
"gridline": {"animation": "fade-in", "durationMs": 300},
|
|
39
|
+
"legend": {"animation": "fade-rise", "durationMs": 300},
|
|
40
|
+
"colorbar": {"animation": "fade-in", "durationMs": 300},
|
|
41
|
+
"title": {"animation": "fade-in", "durationMs": 300},
|
|
42
|
+
"annotation": {"animation": "fade-rise", "delayMs": 150, "durationMs": 300},
|
|
43
|
+
"reference-line": {"animation": "draw-on", "durationMs": 400},
|
|
44
|
+
"significance-bracket": {"animation": "fade-rise", "delayMs": 200, "durationMs": 300},
|
|
45
|
+
"extra": {"animation": "fade-in", "durationMs": 400},
|
|
46
|
+
# colour-mapped fields: a hexmatrix builds up from its emptiest to its fullest hexagon;
|
|
47
|
+
# meshes, images and contour bands fade as one layer
|
|
48
|
+
"x-hexbin": {"animation": "stagger-in", "staggerBy": "value", "staggerMs": 4, "durationMs": 240},
|
|
49
|
+
"x-hex": {"animation": "stagger-in", "staggerBy": "value", "staggerMs": 4, "durationMs": 240},
|
|
50
|
+
"x-heatmap": {"animation": "fade-in", "durationMs": 500},
|
|
51
|
+
"cell": {"animation": "fade-in", "durationMs": 500},
|
|
52
|
+
"x-contour": {"animation": "draw-on", "durationMs": 600},
|
|
53
|
+
"x-contourf": {"animation": "fade-in", "durationMs": 500},
|
|
54
|
+
"contour-level": {"animation": "fade-in", "durationMs": 500},
|
|
55
|
+
# surface (brain) maps: every region is a filled part
|
|
56
|
+
"surface": {"animation": "fade-in", "durationMs": 500},
|
|
57
|
+
"surface-region": {"animation": "fade-in", "durationMs": 500},
|
|
58
|
+
"scalebar": {"animation": "fade-in", "durationMs": 300},
|
|
59
|
+
}
|
|
60
|
+
assert all(v["animation"] in PRESET_NAMES for v in ROLE_PRESETS.values())
|
|
61
|
+
assert all(v.get("staggerBy", "x") in STAGGER_BY for v in ROLE_PRESETS.values())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def presets_for(roles) -> dict:
|
|
65
|
+
"""Return the preset map restricted to the roles actually present in a plot."""
|
|
66
|
+
return {r: ROLE_PRESETS[r] for r in roles if r in ROLE_PRESETS}
|
fluxplot/provenance.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Save-time provenance — automatic, honest discovery of the producing script (plan §1).
|
|
2
|
+
|
|
3
|
+
``fp.save`` can determine the producing script, interpreter, package versions, source hash and
|
|
4
|
+
Git state without asking the user to repeat facts the runtime already knows. Discovery is
|
|
5
|
+
**deterministic and conservative**: identity comes only from ``__main__.__file__`` (or, when a
|
|
6
|
+
runner such as ``pytest`` is the entry point, the caller's own file), never from notebook history
|
|
7
|
+
or heuristics, and anything ambiguous yields ``None`` (the recipe then says so via
|
|
8
|
+
``scriptDiscovery: "unavailable"`` rather than guessing).
|
|
9
|
+
|
|
10
|
+
All of this is host-specific, run-varying material — it belongs in the recipe (the provenance
|
|
11
|
+
surface), never in the deterministic SVG/manifest.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
_GIT_TIMEOUT_S = 2.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_real_script(path) -> bool:
|
|
25
|
+
"""True for an existing regular ``.py`` file that plausibly *is* the producing script."""
|
|
26
|
+
if not path or not isinstance(path, str):
|
|
27
|
+
return False
|
|
28
|
+
if path.startswith("<"): # <stdin>, <string>, <ipython-input-...>
|
|
29
|
+
return False
|
|
30
|
+
if not path.endswith(".py"):
|
|
31
|
+
return False
|
|
32
|
+
if not os.path.isfile(path):
|
|
33
|
+
return False
|
|
34
|
+
# ipykernel writes each cell to a real temp file (tmp/ipykernel_<pid>/<hash>.py) for
|
|
35
|
+
# debugger support — a cell fragment is not a rerunnable script. Reject that exact
|
|
36
|
+
# pattern; legitimate scripts in temp directories are unaffected.
|
|
37
|
+
if os.path.basename(os.path.dirname(path)).startswith("ipykernel_"):
|
|
38
|
+
return False
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _in_installed_packages(path: str) -> bool:
|
|
43
|
+
parts = os.path.abspath(path).split(os.sep)
|
|
44
|
+
return "site-packages" in parts or "dist-packages" in parts
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def discover_script() -> str | None:
|
|
48
|
+
"""The absolute path of the producing ``.py`` script, or ``None`` when there isn't one.
|
|
49
|
+
|
|
50
|
+
Rules (deterministic, in order):
|
|
51
|
+
|
|
52
|
+
1. ``__main__.__file__`` if it names an existing regular ``.py`` file that is not part of
|
|
53
|
+
an installed package (``python -m pytest`` must not claim pytest produced the plot).
|
|
54
|
+
2. When the interpreter was started through an installed entry point — ``python -m pkg``
|
|
55
|
+
(``__main__.__file__`` inside site-packages) or a console script such as ``pytest``
|
|
56
|
+
(``__main__.__file__`` that is not a ``.py`` file) — the first caller frame whose file is
|
|
57
|
+
an existing ``.py`` outside FluxPlot's own code: the user's script, run by a runner.
|
|
58
|
+
3. Otherwise ``None`` — interactive sessions, notebooks (``__main__`` has no ``__file__``
|
|
59
|
+
at all under ipykernel), ``python -c`` and frozen apps are honestly not rerunnable
|
|
60
|
+
scripts. In particular a notebook cell calling ``mylib.plot()`` must never record
|
|
61
|
+
``mylib.py`` as the recipe: rerunning it would not reproduce the figure.
|
|
62
|
+
"""
|
|
63
|
+
import __main__
|
|
64
|
+
|
|
65
|
+
main_file = getattr(__main__, "__file__", None)
|
|
66
|
+
if _is_real_script(main_file) and not _in_installed_packages(main_file):
|
|
67
|
+
return os.path.abspath(main_file)
|
|
68
|
+
if not isinstance(main_file, str) or not main_file or main_file.startswith("<"):
|
|
69
|
+
return None # no entry-point file: interactive / notebook / -c
|
|
70
|
+
|
|
71
|
+
pkg_dir = os.path.dirname(os.path.abspath(__file__))
|
|
72
|
+
frame = sys._getframe()
|
|
73
|
+
while frame is not None:
|
|
74
|
+
fn = frame.f_code.co_filename
|
|
75
|
+
if fn and not fn.startswith("<"):
|
|
76
|
+
abs_fn = os.path.abspath(fn)
|
|
77
|
+
if not abs_fn.startswith(pkg_dir + os.sep) and _is_real_script(abs_fn) \
|
|
78
|
+
and not _in_installed_packages(abs_fn):
|
|
79
|
+
return abs_fn
|
|
80
|
+
frame = frame.f_back
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
#: where a running kernel says which notebook it serves — each set by exactly one host, none guessed
|
|
85
|
+
NOTEBOOK_ENV = "QUARTO_DOCUMENT_PATH"
|
|
86
|
+
NOTEBOOK_GLOBALS = ("__vsc_ipynb_file__", "__session__")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def discover_notebook() -> str | None:
|
|
90
|
+
"""The absolute path of the notebook the current kernel serves, or ``None``.
|
|
91
|
+
|
|
92
|
+
Only what the host states outright counts: ``$QUARTO_DOCUMENT_PATH`` (Quarto rendering or
|
|
93
|
+
a live QMD kernel), then the ``__vsc_ipynb_file__`` / ``__session__`` globals VS Code and
|
|
94
|
+
Jupyter put in ``__main__``. Nothing is inferred from the working directory or the stack: a
|
|
95
|
+
wrong notebook would be worse than none.
|
|
96
|
+
"""
|
|
97
|
+
import __main__
|
|
98
|
+
|
|
99
|
+
candidates = [os.environ.get(NOTEBOOK_ENV)]
|
|
100
|
+
candidates += [getattr(__main__, name, None) for name in NOTEBOOK_GLOBALS]
|
|
101
|
+
for cand in candidates:
|
|
102
|
+
if isinstance(cand, str) and cand and not cand.startswith("<"):
|
|
103
|
+
if cand.startswith("file://"):
|
|
104
|
+
from urllib.parse import unquote, urlparse
|
|
105
|
+
cand = unquote(urlparse(cand).path)
|
|
106
|
+
if os.path.isfile(cand):
|
|
107
|
+
return os.path.abspath(cand)
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def notebook_block(path: str, cell) -> dict:
|
|
112
|
+
"""The recipe's ``notebook`` block: the path, the cell label and the file's SHA-256 (``None``
|
|
113
|
+
when it cannot be read)."""
|
|
114
|
+
out = {"path": str(path), "cell": None if cell is None else str(cell), "sha256": None}
|
|
115
|
+
try:
|
|
116
|
+
with open(path, "rb") as f:
|
|
117
|
+
out["sha256"] = hashlib.sha256(f.read()).hexdigest()
|
|
118
|
+
except OSError:
|
|
119
|
+
pass
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _git_info(directory: str) -> dict | None:
|
|
124
|
+
"""``{"commit": ..., "dirty": ...}`` for the repo containing ``directory``.
|
|
125
|
+
|
|
126
|
+
Fails closed: outside a repository, without git installed, or on any error/timeout this
|
|
127
|
+
returns ``None`` and the caller omits the block — provenance must never break a save.
|
|
128
|
+
"""
|
|
129
|
+
def run(*args):
|
|
130
|
+
return subprocess.run(
|
|
131
|
+
["git", "-C", directory, *args],
|
|
132
|
+
capture_output=True, text=True, timeout=_GIT_TIMEOUT_S,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
head = run("rev-parse", "HEAD")
|
|
137
|
+
commit = head.stdout.strip()
|
|
138
|
+
if head.returncode != 0 or not commit:
|
|
139
|
+
return None
|
|
140
|
+
out = {"commit": commit}
|
|
141
|
+
status = run("status", "--porcelain")
|
|
142
|
+
if status.returncode == 0:
|
|
143
|
+
out["dirty"] = bool(status.stdout.strip())
|
|
144
|
+
return out
|
|
145
|
+
except Exception:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def build_provenance(script_path: str | None, discovery: str) -> dict:
|
|
150
|
+
"""The recipe's additive ``provenance`` block.
|
|
151
|
+
|
|
152
|
+
``discovery`` distinguishes ``automatic`` (we found the script), ``explicit`` (the caller
|
|
153
|
+
recorded it), ``notebook`` (a notebook cell produced it — recorded, not rerunnable) and
|
|
154
|
+
``unavailable`` (no script — the artifact is not rerunnable). Script-hash failures omit only
|
|
155
|
+
that field; Git is optional and omitted outside a repository. For a notebook, ``script_path``
|
|
156
|
+
is the notebook file: its hash and Git state are recorded the same way.
|
|
157
|
+
"""
|
|
158
|
+
import matplotlib
|
|
159
|
+
|
|
160
|
+
from .version import __version__
|
|
161
|
+
|
|
162
|
+
prov = {
|
|
163
|
+
"scriptDiscovery": discovery,
|
|
164
|
+
"python": platform.python_version(),
|
|
165
|
+
"platform": platform.platform(),
|
|
166
|
+
"packages": {"fluxplot": __version__, "matplotlib": matplotlib.__version__},
|
|
167
|
+
}
|
|
168
|
+
if script_path:
|
|
169
|
+
try:
|
|
170
|
+
with open(script_path, "rb") as f:
|
|
171
|
+
prov["scriptSha256"] = hashlib.sha256(f.read()).hexdigest()
|
|
172
|
+
except OSError:
|
|
173
|
+
pass
|
|
174
|
+
git = _git_info(os.path.dirname(os.path.abspath(script_path)) or ".")
|
|
175
|
+
if git is not None:
|
|
176
|
+
prov["git"] = git
|
|
177
|
+
return prov
|
fluxplot/raster.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Automatic rasterization of pathologically heavy artists — the safety default.
|
|
2
|
+
|
|
3
|
+
Why this exists
|
|
4
|
+
---------------
|
|
5
|
+
A matplotlib artist that draws *N* primitives becomes *N* SVG nodes. At scientific data
|
|
6
|
+
scale that is routinely 10^4–10^5 nodes in a single panel:
|
|
7
|
+
|
|
8
|
+
- a ``LineCollection`` built from per-edge segments (the usual way to draw an SWC
|
|
9
|
+
reconstruction or a graph) emits **one ``<path>`` per segment**;
|
|
10
|
+
- a ``scatter`` emits **one ``<use>`` per point**.
|
|
11
|
+
|
|
12
|
+
Flux inlines every panel's SVG as live DOM, so those nodes are real cost forever after.
|
|
13
|
+
A measured 14-panel figure carrying three neuron reconstructions plus 8.7k-point scatters
|
|
14
|
+
reached 260,907 nodes and ~390 ms per pan frame — roughly 2.5 fps against a 100 ms
|
|
15
|
+
interaction budget — while the *same figure* with its heavy layers rasterized rendered at
|
|
16
|
+
vsync from 5,493 nodes.
|
|
17
|
+
|
|
18
|
+
matplotlib already ships the right tool: ``Artist.set_rasterized(True)`` draws that ONE
|
|
19
|
+
artist through the Agg backend and embeds the result as a single ``<image>``, leaving
|
|
20
|
+
axes, ticks, tick labels, legend, annotations and every other artist as vector. This
|
|
21
|
+
module decides which artists get that treatment, so a plot that would cripple a downstream
|
|
22
|
+
editor is never written in the first place. ``fp.save(..., force_vectors=True)`` opts out.
|
|
23
|
+
|
|
24
|
+
The matplotlib mechanic that makes this non-trivial
|
|
25
|
+
---------------------------------------------------
|
|
26
|
+
Rasterization **drops the artist's gid**. ``MixedModeRenderer.stop_rasterizing`` draws the
|
|
27
|
+
composited buffer through a *fresh* ``GraphicsContext``, so the ``<image>`` lands carrying
|
|
28
|
+
matplotlib's generated id (``image`` + 10 hex chars) and no wrapping ``<g id="...">``.
|
|
29
|
+
Left alone that would silently delete the part from fluxplot's semantic contract — the
|
|
30
|
+
whole point of the library.
|
|
31
|
+
|
|
32
|
+
Each planned artist receives a temporary draw wrapper that opens an SVG group
|
|
33
|
+
before mixed-mode rendering and flushes the raster inside that group. :func:`reattach`
|
|
34
|
+
then transfers the identity to its actual image. Empty/clipped layers cannot shift
|
|
35
|
+
another layer's identity; multiple images retain a named container. Draw methods,
|
|
36
|
+
raster flags, axes z-order rasterization and figure compositing state are restored.
|
|
37
|
+
The wrapper is local to this figure's export; no Matplotlib class/global is patched.
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import re
|
|
42
|
+
from contextlib import contextmanager
|
|
43
|
+
from dataclasses import dataclass
|
|
44
|
+
|
|
45
|
+
SVG = "http://www.w3.org/2000/svg"
|
|
46
|
+
|
|
47
|
+
#: matplotlib's generated element id (``backend_svg._make_id``): a type prefix + 10 hex chars.
|
|
48
|
+
AUTO_IMAGE_ID = re.compile(r"^image[0-9a-f]{6,}$")
|
|
49
|
+
|
|
50
|
+
#: Per-artist primitive budget above which a layer is rasterized. A Flux figure composes up
|
|
51
|
+
#: to ~14 panels into one inlined SVG, so the per-panel allowance has to leave the *figure*
|
|
52
|
+
#: comfortable. 800 catches the mid-size clouds a 2,000 cutoff left vector (per-class scatters
|
|
53
|
+
#: of 1–2k cells, minor transgenic-line series), pushing a full 14-panel projection figure
|
|
54
|
+
#: toward vsync, while still leaving ordinary plots untouched — a 200-point scatter or a
|
|
55
|
+
#: 500-segment line stays fully vector and per-point addressable.
|
|
56
|
+
DEFAULT_THRESHOLD = 800
|
|
57
|
+
|
|
58
|
+
#: Resolution for the embedded PNGs. Vector content is unaffected (SVG user space is points,
|
|
59
|
+
#: verified in ``NOTES_matplotlib_svg.md`` §6); this only sets how many pixels a rasterized
|
|
60
|
+
#: layer gets. 600 dpi leaves ~2x headroom over a 300 dpi print of a typical 2–3 in panel,
|
|
61
|
+
#: so the layer still looks sharp zoomed in inside a figure editor.
|
|
62
|
+
DEFAULT_DPI = 600
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class RasterItem:
|
|
67
|
+
"""One artist selected for rasterization, with the state needed to undo it."""
|
|
68
|
+
|
|
69
|
+
artist: object
|
|
70
|
+
gid: "str | None"
|
|
71
|
+
count: int
|
|
72
|
+
was_rasterized: bool
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def label(self) -> str:
|
|
76
|
+
return self.gid or type(self.artist).__name__
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def primitive_count(artist) -> int:
|
|
80
|
+
"""How many SVG primitives ``artist`` will emit, or 0 when that can't be determined.
|
|
81
|
+
|
|
82
|
+
Counting is deliberately defensive: a save must never fail because a third-party artist
|
|
83
|
+
confused the estimator, so anything unexpected reports 0 (= "not heavy") and is left alone.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
from matplotlib.artist import Artist
|
|
87
|
+
from matplotlib.collections import Collection, QuadMesh
|
|
88
|
+
from matplotlib.image import _ImageBase
|
|
89
|
+
from matplotlib.lines import Line2D
|
|
90
|
+
|
|
91
|
+
if not isinstance(artist, Artist):
|
|
92
|
+
return 0
|
|
93
|
+
if isinstance(artist, _ImageBase):
|
|
94
|
+
return 0 # already a single <image>
|
|
95
|
+
if isinstance(artist, QuadMesh):
|
|
96
|
+
# get_paths() would materialize every quad just to count them; read the mesh shape.
|
|
97
|
+
coords = getattr(artist, "_coordinates", None)
|
|
98
|
+
if coords is not None and getattr(coords, "ndim", 0) == 3:
|
|
99
|
+
return max(int(coords.shape[0]) - 1, 0) * max(int(coords.shape[1]) - 1, 0)
|
|
100
|
+
return 0
|
|
101
|
+
if isinstance(artist, Collection):
|
|
102
|
+
# scatter: one marker path cycled over N offsets → N <use>.
|
|
103
|
+
# LineCollection: N paths, no meaningful offsets. max() covers both.
|
|
104
|
+
offsets = artist.get_offsets()
|
|
105
|
+
return max(len(artist.get_paths()), 0 if offsets is None else len(offsets))
|
|
106
|
+
if isinstance(artist, Line2D):
|
|
107
|
+
marker = artist.get_marker()
|
|
108
|
+
n = len(artist.get_xdata())
|
|
109
|
+
return (n if marker not in (None, "", " ", "None") else 0) + 1
|
|
110
|
+
return 1
|
|
111
|
+
except Exception:
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _draw_order(fig) -> list:
|
|
116
|
+
"""Every artist in matplotlib's own draw order.
|
|
117
|
+
|
|
118
|
+
Mirrors ``Figure.draw``/``Axes.draw``: children sorted by zorder with a *stable* sort, so
|
|
119
|
+
ties keep insertion order exactly as matplotlib emits them.
|
|
120
|
+
"""
|
|
121
|
+
out: list = []
|
|
122
|
+
seen: set = set()
|
|
123
|
+
|
|
124
|
+
def visit(artist) -> None:
|
|
125
|
+
if id(artist) in seen:
|
|
126
|
+
return
|
|
127
|
+
seen.add(id(artist))
|
|
128
|
+
out.append(artist)
|
|
129
|
+
|
|
130
|
+
def descend(artist):
|
|
131
|
+
if hasattr(artist, 'get_xaxis'):
|
|
132
|
+
for child in sorted(artist.get_children(), key=_zorder):
|
|
133
|
+
descend(child)
|
|
134
|
+
else:
|
|
135
|
+
visit(artist)
|
|
136
|
+
for child in sorted(fig.get_children(), key=_zorder):
|
|
137
|
+
descend(child)
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _zorder(artist) -> float:
|
|
142
|
+
try:
|
|
143
|
+
return float(artist.get_zorder())
|
|
144
|
+
except Exception:
|
|
145
|
+
return 0.0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def plan(fig, threshold: int = DEFAULT_THRESHOLD, per_artist=None) -> list:
|
|
149
|
+
"""Artists heavy enough to rasterize, in the order matplotlib will emit their images.
|
|
150
|
+
|
|
151
|
+
Only *visible* artists count: an invisible one draws nothing, so it emits no image and
|
|
152
|
+
would throw off the document-order match in :func:`reattach`. ``per_artist`` maps
|
|
153
|
+
``id(artist)`` to a threshold of its own (``Mark.data["raster_threshold"]``): a hexmatrix
|
|
154
|
+
keeps thousands of hexagons addressable where a generic collection would be rasterized.
|
|
155
|
+
"""
|
|
156
|
+
per_artist = per_artist or {}
|
|
157
|
+
items = []
|
|
158
|
+
for artist in _draw_order(fig):
|
|
159
|
+
try:
|
|
160
|
+
if not artist.get_visible():
|
|
161
|
+
continue
|
|
162
|
+
except Exception:
|
|
163
|
+
continue
|
|
164
|
+
n = primitive_count(artist)
|
|
165
|
+
# A colour key's solids are drawn as one exact vector gradient by postprocess (A6), never
|
|
166
|
+
# as an image — matplotlib's default rasterization of them is undone around the render.
|
|
167
|
+
cb = getattr(getattr(artist, 'axes', None), '_colorbar', None)
|
|
168
|
+
if cb is not None and artist is cb.solids:
|
|
169
|
+
continue
|
|
170
|
+
# An artist the CALLER already flagged rasterized is planned too, however few primitives it
|
|
171
|
+
# has. matplotlib will emit an <image> for it either way, and reattach keeps each image in its own
|
|
172
|
+
# explicit draw scope, including caller-rasterized lightweight artists. The commonest case is a
|
|
173
|
+
# colorbar: its solids are rasterized by default, are far below any heaviness threshold, and
|
|
174
|
+
# would otherwise silently orphan the plot they belong to.
|
|
175
|
+
ax = getattr(artist, 'axes', None)
|
|
176
|
+
cutoff = ax.get_rasterization_zorder() if ax is not None else None
|
|
177
|
+
inherited = ax is not None and (ax.get_rasterized() or (cutoff is not None and _zorder(artist) < cutoff))
|
|
178
|
+
limit = per_artist.get(id(artist), threshold)
|
|
179
|
+
if n > limit or bool(artist.get_rasterized()) or inherited:
|
|
180
|
+
items.append(
|
|
181
|
+
RasterItem(
|
|
182
|
+
artist=artist,
|
|
183
|
+
gid=artist.get_gid(),
|
|
184
|
+
count=n,
|
|
185
|
+
was_rasterized=bool(artist.get_rasterized()),
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
return items
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@contextmanager
|
|
192
|
+
def rasterizing(fig, items, force_vectors=False):
|
|
193
|
+
"""Render ``fig`` with ``items`` rasterized, then hand the figure back untouched.
|
|
194
|
+
|
|
195
|
+
``suppressComposite`` is the load-bearing detail. ``Artist.allow_rasterization`` only
|
|
196
|
+
ends a raster run when a NON-rasterized artist is drawn, so *consecutive* rasterized
|
|
197
|
+
artists are flattened into a single ``<image>`` — two heavy layers in one panel (an
|
|
198
|
+
axon and a dendrite, say) would become one element and the second would lose its
|
|
199
|
+
identity entirely. Setting ``suppressComposite`` makes matplotlib stop and restart
|
|
200
|
+
rasterizing around every artist ("restart rasterizing to prevent merging"), which is
|
|
201
|
+
what guarantees the one-image-per-artist mapping :func:`reattach` relies on. Verified:
|
|
202
|
+
2/3/4 same-zorder rasterized layers emit 1/1/1 images by default and 2/3/4 with it set.
|
|
203
|
+
|
|
204
|
+
It is applied only when something is actually rasterized, so an ordinary save keeps
|
|
205
|
+
matplotlib's default compositing and byte-identical output.
|
|
206
|
+
"""
|
|
207
|
+
from .panels import all_axes
|
|
208
|
+
previous = fig.suppressComposite
|
|
209
|
+
axes_state = [(ax, ax.get_rasterization_zorder(), ax.get_rasterized()) for ax in all_axes(fig)]
|
|
210
|
+
draws = []
|
|
211
|
+
fig.suppressComposite = True
|
|
212
|
+
for ax, _, _ in axes_state:
|
|
213
|
+
ax.set_rasterization_zorder(None)
|
|
214
|
+
ax.set_rasterized(False)
|
|
215
|
+
try:
|
|
216
|
+
for it in items:
|
|
217
|
+
it.artist.set_rasterized(not force_vectors)
|
|
218
|
+
if force_vectors or not it.gid: continue
|
|
219
|
+
art = it.artist
|
|
220
|
+
original = art.draw
|
|
221
|
+
had_draw = 'draw' in art.__dict__
|
|
222
|
+
saved_draw = art.__dict__.get('draw')
|
|
223
|
+
draws.append((art, had_draw, saved_draw))
|
|
224
|
+
def draw(renderer, original=original, gid=it.gid):
|
|
225
|
+
# Scope identity around the actual draw, so empty/clipped layers
|
|
226
|
+
# cannot shift another layer's identity. Matplotlib's public
|
|
227
|
+
# renderer group API encloses the mixed-mode image flush.
|
|
228
|
+
renderer.open_group('fluxplot-raster', gid=gid)
|
|
229
|
+
try:
|
|
230
|
+
original(renderer)
|
|
231
|
+
if getattr(renderer, '_rasterizing', False) and renderer._raster_depth == 0:
|
|
232
|
+
renderer.stop_rasterizing()
|
|
233
|
+
renderer._rasterizing = False
|
|
234
|
+
finally:
|
|
235
|
+
renderer.close_group('fluxplot-raster')
|
|
236
|
+
draw._supports_rasterization = True
|
|
237
|
+
art.draw = draw
|
|
238
|
+
yield
|
|
239
|
+
finally:
|
|
240
|
+
fig.suppressComposite = previous
|
|
241
|
+
for art, had_draw, saved_draw in draws:
|
|
242
|
+
if had_draw: art.draw = saved_draw
|
|
243
|
+
else: del art.__dict__['draw']
|
|
244
|
+
for it in items:
|
|
245
|
+
it.artist.set_rasterized(it.was_rasterized)
|
|
246
|
+
for ax, zorder, rasterized in axes_state:
|
|
247
|
+
ax.set_rasterization_zorder(zorder)
|
|
248
|
+
ax.set_rasterized(rasterized)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def reattach(root, items, warnings) -> set:
|
|
252
|
+
"""Re-attach each rasterized artist's gid to the ``<image>`` matplotlib emitted for it.
|
|
253
|
+
|
|
254
|
+
Returns the set of gids restored (empty when the match was ambiguous and skipped).
|
|
255
|
+
"""
|
|
256
|
+
if not items:
|
|
257
|
+
return set()
|
|
258
|
+
by_id = {el.get('id'): el for el in root.iter() if el.get('id')}
|
|
259
|
+
restored = set()
|
|
260
|
+
for it in items:
|
|
261
|
+
group = by_id.get(it.gid)
|
|
262
|
+
if group is None: continue
|
|
263
|
+
images = list(group.iter(f'{{{SVG}}}image'))
|
|
264
|
+
if len(images) == 1:
|
|
265
|
+
# Preserve the existing contract: the layer ID names the image.
|
|
266
|
+
group.attrib.pop('id', None)
|
|
267
|
+
images[0].set('id', it.gid)
|
|
268
|
+
images[0].set('data-rasterized', '1')
|
|
269
|
+
restored.add(it.gid)
|
|
270
|
+
elif images:
|
|
271
|
+
group.set('data-rasterized', '1')
|
|
272
|
+
for i, image in enumerate(images): image.set('id', f'{it.gid}.raster.{i}')
|
|
273
|
+
restored.add(it.gid)
|
|
274
|
+
elif not len(group):
|
|
275
|
+
group.getparent().remove(group)
|
|
276
|
+
return restored
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def describe(items, *, plot_name: str, dpi: int, rasterized: bool) -> str:
|
|
280
|
+
"""One human sentence about what happened (or would happen) to the heavy layers."""
|
|
281
|
+
listed = ", ".join(f"{it.label} ({it.count:,} primitives)" for it in items[:4])
|
|
282
|
+
if len(items) > 4:
|
|
283
|
+
listed += f", +{len(items) - 4} more"
|
|
284
|
+
total = sum(it.count for it in items)
|
|
285
|
+
if rasterized:
|
|
286
|
+
return (
|
|
287
|
+
f"fluxplot: '{plot_name}' — rasterized {len(items)} layer(s) at {dpi} dpi: "
|
|
288
|
+
f"{listed}. Axes, ticks, labels and legend stay vector. "
|
|
289
|
+
f"Pass force_vectors=True to keep everything as vectors."
|
|
290
|
+
)
|
|
291
|
+
return (
|
|
292
|
+
f"fluxplot: '{plot_name}' — kept {len(items)} layer(s) as vectors "
|
|
293
|
+
f"(force_vectors=True): {listed}. That is ~{total:,} SVG nodes; editors that inline "
|
|
294
|
+
f"large vector layers can slow inline editors."
|
|
295
|
+
)
|