figrecipe 0.5.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.
- figrecipe/__init__.py +1090 -0
- figrecipe/_recorder.py +435 -0
- figrecipe/_reproducer.py +358 -0
- figrecipe/_seaborn.py +305 -0
- figrecipe/_serializer.py +227 -0
- figrecipe/_signatures/__init__.py +7 -0
- figrecipe/_signatures/_loader.py +186 -0
- figrecipe/_utils/__init__.py +32 -0
- figrecipe/_utils/_crop.py +261 -0
- figrecipe/_utils/_diff.py +98 -0
- figrecipe/_utils/_image_diff.py +204 -0
- figrecipe/_utils/_numpy_io.py +204 -0
- figrecipe/_utils/_units.py +200 -0
- figrecipe/_validator.py +186 -0
- figrecipe/_wrappers/__init__.py +8 -0
- figrecipe/_wrappers/_axes.py +327 -0
- figrecipe/_wrappers/_figure.py +227 -0
- figrecipe/plt.py +12 -0
- figrecipe/pyplot.py +264 -0
- figrecipe/styles/__init__.py +50 -0
- figrecipe/styles/_style_applier.py +412 -0
- figrecipe/styles/_style_loader.py +450 -0
- figrecipe-0.5.0.dist-info/METADATA +336 -0
- figrecipe-0.5.0.dist-info/RECORD +26 -0
- figrecipe-0.5.0.dist-info/WHEEL +4 -0
- figrecipe-0.5.0.dist-info/licenses/LICENSE +661 -0
figrecipe/__init__.py
ADDED
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
figrecipe - Record and reproduce matplotlib figures.
|
|
5
|
+
|
|
6
|
+
A lightweight library for capturing matplotlib plotting calls and
|
|
7
|
+
reproducing figures from saved recipes.
|
|
8
|
+
|
|
9
|
+
Usage
|
|
10
|
+
-----
|
|
11
|
+
Option 1: Import as module (recommended for explicit usage)
|
|
12
|
+
|
|
13
|
+
>>> import figrecipe as ps
|
|
14
|
+
>>> fig, ax = ps.subplots()
|
|
15
|
+
>>> ax.plot(x, y, id='my_data')
|
|
16
|
+
>>> ps.save(fig, 'recipe.yaml')
|
|
17
|
+
|
|
18
|
+
Option 2: Drop-in replacement for matplotlib.pyplot
|
|
19
|
+
|
|
20
|
+
>>> import figrecipe.pyplot as plt # Instead of: import matplotlib.pyplot as plt
|
|
21
|
+
>>> fig, ax = plt.subplots() # Automatically recording-enabled
|
|
22
|
+
>>> ax.plot(x, y, id='my_data')
|
|
23
|
+
>>> fig.save_recipe('recipe.yaml')
|
|
24
|
+
|
|
25
|
+
Examples
|
|
26
|
+
--------
|
|
27
|
+
Recording a figure:
|
|
28
|
+
|
|
29
|
+
>>> import figrecipe as ps
|
|
30
|
+
>>> import numpy as np
|
|
31
|
+
>>>
|
|
32
|
+
>>> x = np.linspace(0, 10, 100)
|
|
33
|
+
>>> y = np.sin(x)
|
|
34
|
+
>>>
|
|
35
|
+
>>> fig, ax = ps.subplots()
|
|
36
|
+
>>> ax.plot(x, y, color='red', linewidth=2, id='sine_wave')
|
|
37
|
+
>>> ax.set_xlabel('Time')
|
|
38
|
+
>>> ax.set_ylabel('Amplitude')
|
|
39
|
+
>>> ps.save(fig, 'my_figure.yaml')
|
|
40
|
+
|
|
41
|
+
Reproducing a figure:
|
|
42
|
+
|
|
43
|
+
>>> fig, ax = ps.reproduce('my_figure.yaml')
|
|
44
|
+
>>> plt.show()
|
|
45
|
+
|
|
46
|
+
Inspecting a recipe:
|
|
47
|
+
|
|
48
|
+
>>> info = ps.info('my_figure.yaml')
|
|
49
|
+
>>> print(info['calls'])
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
|
|
54
|
+
|
|
55
|
+
import matplotlib.pyplot as plt
|
|
56
|
+
from matplotlib.axes import Axes
|
|
57
|
+
from matplotlib.figure import Figure
|
|
58
|
+
|
|
59
|
+
from ._recorder import Recorder, FigureRecord, CallRecord
|
|
60
|
+
from ._wrappers import RecordingAxes, RecordingFigure
|
|
61
|
+
from ._wrappers._figure import create_recording_subplots
|
|
62
|
+
from ._serializer import save_recipe, load_recipe, recipe_to_dict
|
|
63
|
+
from ._reproducer import reproduce as _reproduce, get_recipe_info
|
|
64
|
+
from ._utils._numpy_io import DataFormat
|
|
65
|
+
from ._utils._units import mm_to_inch, mm_to_pt, inch_to_mm, pt_to_mm, mm_to_scatter_size, normalize_color
|
|
66
|
+
from .styles._style_applier import list_available_fonts, check_font
|
|
67
|
+
|
|
68
|
+
# Notebook display format flag (set once per session)
|
|
69
|
+
_notebook_format_set = False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _enable_notebook_svg():
|
|
73
|
+
"""Enable SVG format for Jupyter notebook display.
|
|
74
|
+
|
|
75
|
+
This provides crisp vector graphics at any zoom level.
|
|
76
|
+
Called automatically when load_style() or subplots() is used.
|
|
77
|
+
"""
|
|
78
|
+
global _notebook_format_set
|
|
79
|
+
if _notebook_format_set:
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
# Method 1: matplotlib_inline (IPython 7.0+, JupyterLab)
|
|
84
|
+
from matplotlib_inline.backend_inline import set_matplotlib_formats
|
|
85
|
+
set_matplotlib_formats('svg')
|
|
86
|
+
_notebook_format_set = True
|
|
87
|
+
except (ImportError, Exception):
|
|
88
|
+
try:
|
|
89
|
+
# Method 2: IPython config (older IPython)
|
|
90
|
+
from IPython import get_ipython
|
|
91
|
+
ipython = get_ipython()
|
|
92
|
+
if ipython is not None and hasattr(ipython, 'kernel'):
|
|
93
|
+
# Only run in actual Jupyter kernel, not IPython console
|
|
94
|
+
ipython.run_line_magic('config', "InlineBackend.figure_formats = ['svg']")
|
|
95
|
+
_notebook_format_set = True
|
|
96
|
+
except Exception:
|
|
97
|
+
pass # Not in Jupyter environment or method not available
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def enable_svg():
|
|
101
|
+
"""Manually enable SVG format for Jupyter notebook display.
|
|
102
|
+
|
|
103
|
+
Call this if figures appear pixelated in notebooks.
|
|
104
|
+
|
|
105
|
+
Examples
|
|
106
|
+
--------
|
|
107
|
+
>>> import figrecipe as fr
|
|
108
|
+
>>> fr.enable_svg() # Enable SVG rendering
|
|
109
|
+
>>> fig, ax = fr.subplots() # Now renders as crisp SVG
|
|
110
|
+
"""
|
|
111
|
+
global _notebook_format_set
|
|
112
|
+
_notebook_format_set = False # Force re-application
|
|
113
|
+
_enable_notebook_svg()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Lazy import for seaborn to avoid hard dependency
|
|
117
|
+
_sns_recorder = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _get_sns():
|
|
121
|
+
"""Get the seaborn recorder (lazy initialization)."""
|
|
122
|
+
global _sns_recorder
|
|
123
|
+
if _sns_recorder is None:
|
|
124
|
+
from ._seaborn import get_seaborn_recorder
|
|
125
|
+
_sns_recorder = get_seaborn_recorder()
|
|
126
|
+
return _sns_recorder
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _SeabornProxy:
|
|
130
|
+
"""Proxy object for seaborn access via ps.sns."""
|
|
131
|
+
|
|
132
|
+
def __getattr__(self, name: str):
|
|
133
|
+
return getattr(_get_sns(), name)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Create seaborn proxy
|
|
137
|
+
sns = _SeabornProxy()
|
|
138
|
+
|
|
139
|
+
__version__ = "0.4.0"
|
|
140
|
+
__all__ = [
|
|
141
|
+
# Main API
|
|
142
|
+
"subplots",
|
|
143
|
+
"save",
|
|
144
|
+
"reproduce",
|
|
145
|
+
"info",
|
|
146
|
+
"load",
|
|
147
|
+
"extract_data",
|
|
148
|
+
"validate",
|
|
149
|
+
# Style system
|
|
150
|
+
"load_style",
|
|
151
|
+
"unload_style",
|
|
152
|
+
"list_presets",
|
|
153
|
+
"STYLE",
|
|
154
|
+
"apply_style",
|
|
155
|
+
# Unit conversions
|
|
156
|
+
"mm_to_inch",
|
|
157
|
+
"mm_to_pt",
|
|
158
|
+
"inch_to_mm",
|
|
159
|
+
"pt_to_mm",
|
|
160
|
+
"mm_to_scatter_size",
|
|
161
|
+
"normalize_color",
|
|
162
|
+
# Font utilities
|
|
163
|
+
"list_available_fonts",
|
|
164
|
+
"check_font",
|
|
165
|
+
# Notebook utilities
|
|
166
|
+
"enable_svg",
|
|
167
|
+
# Seaborn support
|
|
168
|
+
"sns",
|
|
169
|
+
# Classes (for type hints)
|
|
170
|
+
"RecordingFigure",
|
|
171
|
+
"RecordingAxes",
|
|
172
|
+
"FigureRecord",
|
|
173
|
+
"CallRecord",
|
|
174
|
+
"ValidationResult",
|
|
175
|
+
# Image utilities
|
|
176
|
+
"crop",
|
|
177
|
+
# Version
|
|
178
|
+
"__version__",
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# Lazy imports for style system
|
|
183
|
+
_style_cache = None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def load_style(style="SCITEX", dark=False):
|
|
187
|
+
"""Load style configuration and apply it globally.
|
|
188
|
+
|
|
189
|
+
After calling this function, subsequent `subplots()` calls will
|
|
190
|
+
automatically use the loaded style (fonts, colors, theme, etc.).
|
|
191
|
+
|
|
192
|
+
Parameters
|
|
193
|
+
----------
|
|
194
|
+
style : str, Path, bool, or None
|
|
195
|
+
One of:
|
|
196
|
+
- "SCITEX" / "FIGRECIPE": Scientific publication style (default)
|
|
197
|
+
- "MATPLOTLIB": Vanilla matplotlib defaults
|
|
198
|
+
- Path to custom YAML file: "/path/to/my_style.yaml"
|
|
199
|
+
- None or False: Unload style (reset to matplotlib defaults)
|
|
200
|
+
dark : bool, optional
|
|
201
|
+
If True, apply dark theme transformation (default: False).
|
|
202
|
+
Equivalent to appending "_DARK" to preset name.
|
|
203
|
+
|
|
204
|
+
Returns
|
|
205
|
+
-------
|
|
206
|
+
DotDict or None
|
|
207
|
+
Style configuration with dot-notation access.
|
|
208
|
+
Returns None if style is unloaded.
|
|
209
|
+
|
|
210
|
+
Examples
|
|
211
|
+
--------
|
|
212
|
+
>>> import figrecipe as fr
|
|
213
|
+
|
|
214
|
+
>>> # Load scientific style (default)
|
|
215
|
+
>>> fr.load_style()
|
|
216
|
+
>>> fr.load_style("SCITEX") # explicit
|
|
217
|
+
|
|
218
|
+
>>> # Load dark theme
|
|
219
|
+
>>> fr.load_style("SCITEX_DARK")
|
|
220
|
+
>>> fr.load_style("SCITEX", dark=True) # equivalent
|
|
221
|
+
|
|
222
|
+
>>> # Reset to vanilla matplotlib
|
|
223
|
+
>>> fr.load_style(None) # unload
|
|
224
|
+
>>> fr.load_style(False) # unload
|
|
225
|
+
>>> fr.load_style("MATPLOTLIB") # explicit vanilla
|
|
226
|
+
|
|
227
|
+
>>> # Access style values
|
|
228
|
+
>>> style = fr.load_style("SCITEX")
|
|
229
|
+
>>> style.axes.width_mm
|
|
230
|
+
40
|
|
231
|
+
"""
|
|
232
|
+
from .styles import load_style as _load_style
|
|
233
|
+
return _load_style(style, dark=dark)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def unload_style():
|
|
237
|
+
"""Unload the current style and reset to matplotlib defaults.
|
|
238
|
+
|
|
239
|
+
After calling this, subsequent `subplots()` calls will use vanilla
|
|
240
|
+
matplotlib behavior without FigRecipe styling.
|
|
241
|
+
|
|
242
|
+
Examples
|
|
243
|
+
--------
|
|
244
|
+
>>> import figrecipe as fr
|
|
245
|
+
>>> fr.load_style("SCITEX") # Apply scientific style
|
|
246
|
+
>>> fig, ax = fr.subplots() # Styled
|
|
247
|
+
>>> fr.unload_style() # Reset to matplotlib defaults
|
|
248
|
+
>>> fig, ax = fr.subplots() # Vanilla matplotlib
|
|
249
|
+
"""
|
|
250
|
+
from .styles import unload_style as _unload_style
|
|
251
|
+
_unload_style()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def list_presets():
|
|
255
|
+
"""List available style presets.
|
|
256
|
+
|
|
257
|
+
Returns
|
|
258
|
+
-------
|
|
259
|
+
list of str
|
|
260
|
+
Names of available presets.
|
|
261
|
+
|
|
262
|
+
Examples
|
|
263
|
+
--------
|
|
264
|
+
>>> import figrecipe as ps
|
|
265
|
+
>>> ps.list_presets()
|
|
266
|
+
['MINIMAL', 'PRESENTATION', 'SCIENTIFIC']
|
|
267
|
+
"""
|
|
268
|
+
from .styles import list_presets as _list_presets
|
|
269
|
+
return _list_presets()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def apply_style(ax, style=None):
|
|
273
|
+
"""Apply mm-based styling to an axes.
|
|
274
|
+
|
|
275
|
+
Parameters
|
|
276
|
+
----------
|
|
277
|
+
ax : matplotlib.axes.Axes
|
|
278
|
+
Target axes to apply styling to.
|
|
279
|
+
style : dict or DotDict, optional
|
|
280
|
+
Style configuration. If None, uses default FIGRECIPE_STYLE.
|
|
281
|
+
|
|
282
|
+
Returns
|
|
283
|
+
-------
|
|
284
|
+
float
|
|
285
|
+
Trace line width in points.
|
|
286
|
+
|
|
287
|
+
Examples
|
|
288
|
+
--------
|
|
289
|
+
>>> import figrecipe as ps
|
|
290
|
+
>>> import matplotlib.pyplot as plt
|
|
291
|
+
>>> fig, ax = plt.subplots()
|
|
292
|
+
>>> trace_lw = ps.apply_style(ax)
|
|
293
|
+
>>> ax.plot(x, y, lw=trace_lw)
|
|
294
|
+
"""
|
|
295
|
+
from .styles import apply_style_mm, get_style, to_subplots_kwargs
|
|
296
|
+
if style is None:
|
|
297
|
+
style = to_subplots_kwargs(get_style())
|
|
298
|
+
elif hasattr(style, 'to_subplots_kwargs'):
|
|
299
|
+
style = style.to_subplots_kwargs()
|
|
300
|
+
return apply_style_mm(ax, style)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class _StyleProxy:
|
|
304
|
+
"""Proxy object for lazy style loading."""
|
|
305
|
+
|
|
306
|
+
def __getattr__(self, name):
|
|
307
|
+
from .styles import STYLE
|
|
308
|
+
return getattr(STYLE, name)
|
|
309
|
+
|
|
310
|
+
def to_subplots_kwargs(self):
|
|
311
|
+
from .styles import to_subplots_kwargs
|
|
312
|
+
return to_subplots_kwargs()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
STYLE = _StyleProxy()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def subplots(
|
|
319
|
+
nrows: int = 1,
|
|
320
|
+
ncols: int = 1,
|
|
321
|
+
# MM-control parameters
|
|
322
|
+
axes_width_mm: Optional[float] = None,
|
|
323
|
+
axes_height_mm: Optional[float] = None,
|
|
324
|
+
margin_left_mm: Optional[float] = None,
|
|
325
|
+
margin_right_mm: Optional[float] = None,
|
|
326
|
+
margin_bottom_mm: Optional[float] = None,
|
|
327
|
+
margin_top_mm: Optional[float] = None,
|
|
328
|
+
space_w_mm: Optional[float] = None,
|
|
329
|
+
space_h_mm: Optional[float] = None,
|
|
330
|
+
# Style parameters
|
|
331
|
+
style: Optional[Dict[str, Any]] = None,
|
|
332
|
+
apply_style_mm: bool = True,
|
|
333
|
+
**kwargs,
|
|
334
|
+
) -> Tuple[RecordingFigure, Union[RecordingAxes, List[RecordingAxes]]]:
|
|
335
|
+
"""Create a figure with recording-enabled axes.
|
|
336
|
+
|
|
337
|
+
This is a drop-in replacement for plt.subplots() that wraps the
|
|
338
|
+
returned figure and axes with recording capabilities.
|
|
339
|
+
|
|
340
|
+
Supports mm-based layout control for publication-quality figures.
|
|
341
|
+
|
|
342
|
+
Parameters
|
|
343
|
+
----------
|
|
344
|
+
nrows : int
|
|
345
|
+
Number of rows of subplots.
|
|
346
|
+
ncols : int
|
|
347
|
+
Number of columns of subplots.
|
|
348
|
+
|
|
349
|
+
MM-Control Parameters
|
|
350
|
+
---------------------
|
|
351
|
+
axes_width_mm : float, optional
|
|
352
|
+
Axes width in mm. If provided, overrides figsize.
|
|
353
|
+
axes_height_mm : float, optional
|
|
354
|
+
Axes height in mm.
|
|
355
|
+
margin_left_mm : float, optional
|
|
356
|
+
Left margin in mm (default: 15).
|
|
357
|
+
margin_right_mm : float, optional
|
|
358
|
+
Right margin in mm (default: 5).
|
|
359
|
+
margin_bottom_mm : float, optional
|
|
360
|
+
Bottom margin in mm (default: 12).
|
|
361
|
+
margin_top_mm : float, optional
|
|
362
|
+
Top margin in mm (default: 8).
|
|
363
|
+
space_w_mm : float, optional
|
|
364
|
+
Horizontal spacing between axes in mm (default: 8).
|
|
365
|
+
space_h_mm : float, optional
|
|
366
|
+
Vertical spacing between axes in mm (default: 10).
|
|
367
|
+
|
|
368
|
+
Style Parameters
|
|
369
|
+
----------------
|
|
370
|
+
style : dict, optional
|
|
371
|
+
Style configuration dictionary or result of load_style().
|
|
372
|
+
apply_style_mm : bool
|
|
373
|
+
If True (default), apply loaded style to axes after creation.
|
|
374
|
+
Set to False to disable automatic style application.
|
|
375
|
+
|
|
376
|
+
**kwargs
|
|
377
|
+
Additional arguments passed to plt.subplots() (e.g., figsize, dpi).
|
|
378
|
+
|
|
379
|
+
Returns
|
|
380
|
+
-------
|
|
381
|
+
fig : RecordingFigure
|
|
382
|
+
Wrapped figure object.
|
|
383
|
+
axes : RecordingAxes or list of RecordingAxes
|
|
384
|
+
Wrapped axes (single for 1x1, list otherwise).
|
|
385
|
+
|
|
386
|
+
Examples
|
|
387
|
+
--------
|
|
388
|
+
Basic usage:
|
|
389
|
+
|
|
390
|
+
>>> import figrecipe as ps
|
|
391
|
+
>>> fig, ax = ps.subplots()
|
|
392
|
+
>>> ax.plot([1, 2, 3], [4, 5, 6], color='blue')
|
|
393
|
+
>>> ps.save(fig, 'simple.yaml')
|
|
394
|
+
|
|
395
|
+
MM-based layout:
|
|
396
|
+
|
|
397
|
+
>>> fig, ax = ps.subplots(
|
|
398
|
+
... axes_width_mm=40,
|
|
399
|
+
... axes_height_mm=28,
|
|
400
|
+
... margin_left_mm=15,
|
|
401
|
+
... margin_bottom_mm=12,
|
|
402
|
+
... )
|
|
403
|
+
|
|
404
|
+
With style (automatically applied):
|
|
405
|
+
|
|
406
|
+
>>> ps.load_style("FIGRECIPE_DARK") # Load dark theme
|
|
407
|
+
>>> fig, ax = ps.subplots() # Style applied automatically
|
|
408
|
+
"""
|
|
409
|
+
# Get global style for default values (if loaded)
|
|
410
|
+
from .styles._style_loader import _STYLE_CACHE
|
|
411
|
+
global_style = _STYLE_CACHE
|
|
412
|
+
|
|
413
|
+
# Helper to get value with priority: explicit > global style > hardcoded default
|
|
414
|
+
def _get_mm(explicit, style_path, default):
|
|
415
|
+
if explicit is not None:
|
|
416
|
+
return explicit
|
|
417
|
+
if global_style is not None:
|
|
418
|
+
try:
|
|
419
|
+
val = global_style
|
|
420
|
+
for key in style_path:
|
|
421
|
+
val = val.get(key) if isinstance(val, dict) else getattr(val, key, None)
|
|
422
|
+
if val is None:
|
|
423
|
+
break
|
|
424
|
+
if val is not None:
|
|
425
|
+
return val
|
|
426
|
+
except (KeyError, AttributeError):
|
|
427
|
+
pass
|
|
428
|
+
return default
|
|
429
|
+
|
|
430
|
+
# Check if mm-based layout is requested (explicit OR from global style)
|
|
431
|
+
has_explicit_mm = any([
|
|
432
|
+
axes_width_mm is not None,
|
|
433
|
+
axes_height_mm is not None,
|
|
434
|
+
margin_left_mm is not None,
|
|
435
|
+
margin_right_mm is not None,
|
|
436
|
+
margin_bottom_mm is not None,
|
|
437
|
+
margin_top_mm is not None,
|
|
438
|
+
space_w_mm is not None,
|
|
439
|
+
space_h_mm is not None,
|
|
440
|
+
])
|
|
441
|
+
|
|
442
|
+
# Also use mm layout if global style has mm values
|
|
443
|
+
has_style_mm = False
|
|
444
|
+
if global_style is not None:
|
|
445
|
+
try:
|
|
446
|
+
has_style_mm = (
|
|
447
|
+
global_style.get('axes', {}).get('width_mm') is not None or
|
|
448
|
+
getattr(getattr(global_style, 'axes', None), 'width_mm', None) is not None
|
|
449
|
+
)
|
|
450
|
+
except (KeyError, AttributeError):
|
|
451
|
+
pass
|
|
452
|
+
|
|
453
|
+
use_mm_layout = has_explicit_mm or has_style_mm
|
|
454
|
+
|
|
455
|
+
if use_mm_layout and 'figsize' not in kwargs:
|
|
456
|
+
# Get mm values: explicit params > global style > hardcoded defaults
|
|
457
|
+
aw = _get_mm(axes_width_mm, ['axes', 'width_mm'], 40)
|
|
458
|
+
ah = _get_mm(axes_height_mm, ['axes', 'height_mm'], 28)
|
|
459
|
+
ml = _get_mm(margin_left_mm, ['margins', 'left_mm'], 15)
|
|
460
|
+
mr = _get_mm(margin_right_mm, ['margins', 'right_mm'], 5)
|
|
461
|
+
mb = _get_mm(margin_bottom_mm, ['margins', 'bottom_mm'], 12)
|
|
462
|
+
mt = _get_mm(margin_top_mm, ['margins', 'top_mm'], 8)
|
|
463
|
+
sw = _get_mm(space_w_mm, ['spacing', 'horizontal_mm'], 8)
|
|
464
|
+
sh = _get_mm(space_h_mm, ['spacing', 'vertical_mm'], 10)
|
|
465
|
+
|
|
466
|
+
# Calculate total figure size
|
|
467
|
+
total_width_mm = ml + (ncols * aw) + ((ncols - 1) * sw) + mr
|
|
468
|
+
total_height_mm = mb + (nrows * ah) + ((nrows - 1) * sh) + mt
|
|
469
|
+
|
|
470
|
+
# Convert to inches and set figsize
|
|
471
|
+
kwargs['figsize'] = (mm_to_inch(total_width_mm), mm_to_inch(total_height_mm))
|
|
472
|
+
|
|
473
|
+
# Store mm metadata for recording (will be extracted by create_recording_subplots)
|
|
474
|
+
mm_layout = {
|
|
475
|
+
'axes_width_mm': aw,
|
|
476
|
+
'axes_height_mm': ah,
|
|
477
|
+
'margin_left_mm': ml,
|
|
478
|
+
'margin_right_mm': mr,
|
|
479
|
+
'margin_bottom_mm': mb,
|
|
480
|
+
'margin_top_mm': mt,
|
|
481
|
+
'space_w_mm': sw,
|
|
482
|
+
'space_h_mm': sh,
|
|
483
|
+
}
|
|
484
|
+
else:
|
|
485
|
+
mm_layout = None
|
|
486
|
+
|
|
487
|
+
# Apply DPI from global style if not explicitly provided
|
|
488
|
+
if 'dpi' not in kwargs and global_style is not None:
|
|
489
|
+
# Try figure.dpi first, then output.dpi
|
|
490
|
+
style_dpi = None
|
|
491
|
+
try:
|
|
492
|
+
if hasattr(global_style, 'figure') and hasattr(global_style.figure, 'dpi'):
|
|
493
|
+
style_dpi = global_style.figure.dpi
|
|
494
|
+
elif hasattr(global_style, 'output') and hasattr(global_style.output, 'dpi'):
|
|
495
|
+
style_dpi = global_style.output.dpi
|
|
496
|
+
except (KeyError, AttributeError):
|
|
497
|
+
pass
|
|
498
|
+
if style_dpi is not None:
|
|
499
|
+
kwargs['dpi'] = style_dpi
|
|
500
|
+
|
|
501
|
+
# Handle style parameter
|
|
502
|
+
if style is not None:
|
|
503
|
+
if hasattr(style, 'to_subplots_kwargs'):
|
|
504
|
+
# Merge style kwargs (style values are overridden by explicit params)
|
|
505
|
+
style_kwargs = style.to_subplots_kwargs()
|
|
506
|
+
for key, value in style_kwargs.items():
|
|
507
|
+
if key not in kwargs:
|
|
508
|
+
kwargs[key] = value
|
|
509
|
+
|
|
510
|
+
# Use constrained_layout by default for non-mm layouts (better auto-spacing)
|
|
511
|
+
# Don't use it with mm-based layout since we manually control positioning
|
|
512
|
+
if not use_mm_layout and 'constrained_layout' not in kwargs:
|
|
513
|
+
kwargs['constrained_layout'] = True
|
|
514
|
+
|
|
515
|
+
# Create the recording subplots
|
|
516
|
+
fig, axes = create_recording_subplots(nrows, ncols, **kwargs)
|
|
517
|
+
|
|
518
|
+
# Record constrained_layout setting for reproduction
|
|
519
|
+
fig.record.constrained_layout = kwargs.get('constrained_layout', False)
|
|
520
|
+
|
|
521
|
+
# Store mm_layout metadata on figure for serialization
|
|
522
|
+
if mm_layout is not None:
|
|
523
|
+
fig._mm_layout = mm_layout
|
|
524
|
+
|
|
525
|
+
# Apply subplots_adjust to position axes correctly
|
|
526
|
+
total_width_mm = ml + (ncols * aw) + ((ncols - 1) * sw) + mr
|
|
527
|
+
total_height_mm = mb + (nrows * ah) + ((nrows - 1) * sh) + mt
|
|
528
|
+
|
|
529
|
+
# Calculate relative positions (0-1 range)
|
|
530
|
+
left = ml / total_width_mm
|
|
531
|
+
right = 1 - (mr / total_width_mm)
|
|
532
|
+
bottom = mb / total_height_mm
|
|
533
|
+
top = 1 - (mt / total_height_mm)
|
|
534
|
+
|
|
535
|
+
# Calculate spacing as fraction of figure size
|
|
536
|
+
wspace = sw / aw if ncols > 1 else 0
|
|
537
|
+
hspace = sh / ah if nrows > 1 else 0
|
|
538
|
+
|
|
539
|
+
fig.fig.subplots_adjust(
|
|
540
|
+
left=left,
|
|
541
|
+
right=right,
|
|
542
|
+
bottom=bottom,
|
|
543
|
+
top=top,
|
|
544
|
+
wspace=wspace,
|
|
545
|
+
hspace=hspace,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Record layout in figure record for reproduction
|
|
549
|
+
fig.record.layout = {
|
|
550
|
+
'left': left,
|
|
551
|
+
'right': right,
|
|
552
|
+
'bottom': bottom,
|
|
553
|
+
'top': top,
|
|
554
|
+
'wspace': wspace,
|
|
555
|
+
'hspace': hspace,
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
# Apply styling if requested and a style is actually loaded
|
|
559
|
+
style_dict = None
|
|
560
|
+
should_apply_style = False
|
|
561
|
+
|
|
562
|
+
if style is not None:
|
|
563
|
+
# Explicit style parameter provided
|
|
564
|
+
should_apply_style = True
|
|
565
|
+
style_dict = style.to_subplots_kwargs() if hasattr(style, 'to_subplots_kwargs') else style
|
|
566
|
+
elif apply_style_mm and global_style is not None:
|
|
567
|
+
# Use global style if loaded and has meaningful values (not MATPLOTLIB)
|
|
568
|
+
from .styles import to_subplots_kwargs
|
|
569
|
+
style_dict = to_subplots_kwargs(global_style)
|
|
570
|
+
# Only apply if style has essential mm values (skip MATPLOTLIB which has all None)
|
|
571
|
+
if style_dict and style_dict.get('axes_thickness_mm') is not None:
|
|
572
|
+
should_apply_style = True
|
|
573
|
+
|
|
574
|
+
if should_apply_style and style_dict:
|
|
575
|
+
from .styles import apply_style_mm as _apply_style
|
|
576
|
+
if nrows == 1 and ncols == 1:
|
|
577
|
+
_apply_style(axes._ax, style_dict)
|
|
578
|
+
else:
|
|
579
|
+
# Handle 2D array of axes
|
|
580
|
+
import numpy as np
|
|
581
|
+
axes_array = np.array(axes)
|
|
582
|
+
for ax in axes_array.flat:
|
|
583
|
+
_apply_style(ax._ax if hasattr(ax, '_ax') else ax, style_dict)
|
|
584
|
+
|
|
585
|
+
# Record style in figure record for reproduction
|
|
586
|
+
fig.record.style = style_dict
|
|
587
|
+
|
|
588
|
+
return fig, axes
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def save(
|
|
592
|
+
fig: Union[RecordingFigure, Figure],
|
|
593
|
+
path: Union[str, Path],
|
|
594
|
+
include_data: bool = True,
|
|
595
|
+
data_format: DataFormat = "csv",
|
|
596
|
+
validate: bool = True,
|
|
597
|
+
validate_mse_threshold: float = 100.0,
|
|
598
|
+
validate_error_level: str = "error",
|
|
599
|
+
verbose: bool = True,
|
|
600
|
+
dpi: Optional[int] = None,
|
|
601
|
+
image_format: Optional[str] = None,
|
|
602
|
+
):
|
|
603
|
+
"""Save a figure as image and recipe.
|
|
604
|
+
|
|
605
|
+
Automatically saves both the image file and the YAML recipe for
|
|
606
|
+
reproducibility. Specify either image or YAML path - the other
|
|
607
|
+
will be created with the same base name.
|
|
608
|
+
|
|
609
|
+
Parameters
|
|
610
|
+
----------
|
|
611
|
+
fig : RecordingFigure or Figure
|
|
612
|
+
The figure to save. Must be a RecordingFigure for recipe saving.
|
|
613
|
+
path : str or Path
|
|
614
|
+
Output path. Can be:
|
|
615
|
+
- Image path (.png, .pdf, .svg, .jpg): Saves image + YAML recipe
|
|
616
|
+
- YAML path (.yaml, .yml): Saves recipe + image
|
|
617
|
+
include_data : bool
|
|
618
|
+
If True, save large arrays to separate files.
|
|
619
|
+
data_format : str
|
|
620
|
+
Format for data files: 'csv' (default), 'npz', or 'inline'.
|
|
621
|
+
- 'csv': Human-readable CSV files with dtype header
|
|
622
|
+
- 'npz': Compressed numpy binary format (efficient)
|
|
623
|
+
- 'inline': Store all data directly in YAML
|
|
624
|
+
validate : bool
|
|
625
|
+
If True (default), validate reproducibility after saving by
|
|
626
|
+
reproducing the figure and comparing it to the original.
|
|
627
|
+
validate_mse_threshold : float
|
|
628
|
+
Maximum acceptable MSE for validation (default: 100).
|
|
629
|
+
validate_error_level : str
|
|
630
|
+
How to handle validation failures: 'error' (default), 'warning', or 'debug'.
|
|
631
|
+
- 'error': Raise ValueError on failure
|
|
632
|
+
- 'warning': Emit UserWarning on failure
|
|
633
|
+
- 'debug': Silent (check result.valid manually)
|
|
634
|
+
verbose : bool
|
|
635
|
+
If True (default), print save status. Set False for CI/scripts.
|
|
636
|
+
dpi : int, optional
|
|
637
|
+
DPI for image output. Uses style DPI or 300 if not specified.
|
|
638
|
+
image_format : str, optional
|
|
639
|
+
Image format when path is YAML ('png', 'pdf', 'svg').
|
|
640
|
+
Uses style's output.format or 'png' if not specified.
|
|
641
|
+
|
|
642
|
+
Returns
|
|
643
|
+
-------
|
|
644
|
+
tuple
|
|
645
|
+
(image_path, yaml_path, ValidationResult or None) tuple.
|
|
646
|
+
ValidationResult is None when validate=False.
|
|
647
|
+
|
|
648
|
+
Examples
|
|
649
|
+
--------
|
|
650
|
+
>>> import figrecipe as fr
|
|
651
|
+
>>> fig, ax = fr.subplots()
|
|
652
|
+
>>> ax.plot(x, y, color='red', id='my_data')
|
|
653
|
+
>>>
|
|
654
|
+
>>> # Save as PNG (also creates experiment.yaml)
|
|
655
|
+
>>> img_path, yaml_path, result = fr.save(fig, 'experiment.png')
|
|
656
|
+
>>>
|
|
657
|
+
>>> # Save as YAML (also creates experiment.png)
|
|
658
|
+
>>> img_path, yaml_path, result = fr.save(fig, 'experiment.yaml')
|
|
659
|
+
>>>
|
|
660
|
+
>>> # Save as PDF with custom DPI
|
|
661
|
+
>>> fr.save(fig, 'experiment.pdf', dpi=600)
|
|
662
|
+
|
|
663
|
+
Notes
|
|
664
|
+
-----
|
|
665
|
+
The recipe file contains:
|
|
666
|
+
- Figure metadata (size, DPI, matplotlib version)
|
|
667
|
+
- All plotting calls with their arguments
|
|
668
|
+
- References to data files for large arrays
|
|
669
|
+
"""
|
|
670
|
+
path = Path(path)
|
|
671
|
+
|
|
672
|
+
if not isinstance(fig, RecordingFigure):
|
|
673
|
+
raise TypeError(
|
|
674
|
+
"Expected RecordingFigure. Use fr.subplots() to create "
|
|
675
|
+
"a recording-enabled figure."
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
# Determine image and YAML paths based on extension
|
|
679
|
+
IMAGE_EXTENSIONS = {'.png', '.pdf', '.svg', '.jpg', '.jpeg', '.eps', '.tiff', '.tif'}
|
|
680
|
+
YAML_EXTENSIONS = {'.yaml', '.yml'}
|
|
681
|
+
|
|
682
|
+
suffix_lower = path.suffix.lower()
|
|
683
|
+
|
|
684
|
+
if suffix_lower in IMAGE_EXTENSIONS:
|
|
685
|
+
# User provided image path
|
|
686
|
+
image_path = path
|
|
687
|
+
yaml_path = path.with_suffix('.yaml')
|
|
688
|
+
img_format = suffix_lower[1:] # Remove leading dot
|
|
689
|
+
elif suffix_lower in YAML_EXTENSIONS:
|
|
690
|
+
# User provided YAML path
|
|
691
|
+
yaml_path = path
|
|
692
|
+
# Determine image format from style or default
|
|
693
|
+
if image_format is not None:
|
|
694
|
+
img_format = image_format.lower().lstrip('.')
|
|
695
|
+
else:
|
|
696
|
+
# Check global style for preferred format
|
|
697
|
+
from .styles._style_loader import _STYLE_CACHE
|
|
698
|
+
if _STYLE_CACHE is not None:
|
|
699
|
+
try:
|
|
700
|
+
img_format = _STYLE_CACHE.output.format.lower()
|
|
701
|
+
except (KeyError, AttributeError):
|
|
702
|
+
img_format = 'png'
|
|
703
|
+
else:
|
|
704
|
+
img_format = 'png'
|
|
705
|
+
image_path = path.with_suffix(f'.{img_format}')
|
|
706
|
+
else:
|
|
707
|
+
# Unknown extension - treat as base name, add both extensions
|
|
708
|
+
yaml_path = path.with_suffix('.yaml')
|
|
709
|
+
if image_format is not None:
|
|
710
|
+
img_format = image_format.lower().lstrip('.')
|
|
711
|
+
else:
|
|
712
|
+
from .styles._style_loader import _STYLE_CACHE
|
|
713
|
+
if _STYLE_CACHE is not None:
|
|
714
|
+
try:
|
|
715
|
+
img_format = _STYLE_CACHE.output.format.lower()
|
|
716
|
+
except (KeyError, AttributeError):
|
|
717
|
+
img_format = 'png'
|
|
718
|
+
else:
|
|
719
|
+
img_format = 'png'
|
|
720
|
+
image_path = path.with_suffix(f'.{img_format}')
|
|
721
|
+
|
|
722
|
+
# Get DPI from style if not specified
|
|
723
|
+
if dpi is None:
|
|
724
|
+
from .styles._style_loader import _STYLE_CACHE
|
|
725
|
+
if _STYLE_CACHE is not None:
|
|
726
|
+
try:
|
|
727
|
+
dpi = _STYLE_CACHE.output.dpi
|
|
728
|
+
except (KeyError, AttributeError):
|
|
729
|
+
dpi = 300
|
|
730
|
+
else:
|
|
731
|
+
dpi = 300
|
|
732
|
+
|
|
733
|
+
# Get transparency setting from style
|
|
734
|
+
transparent = False
|
|
735
|
+
from .styles._style_loader import _STYLE_CACHE
|
|
736
|
+
if _STYLE_CACHE is not None:
|
|
737
|
+
try:
|
|
738
|
+
transparent = _STYLE_CACHE.output.transparent
|
|
739
|
+
except (KeyError, AttributeError):
|
|
740
|
+
pass
|
|
741
|
+
|
|
742
|
+
# Save the image
|
|
743
|
+
fig.fig.savefig(image_path, dpi=dpi, bbox_inches='tight', transparent=transparent)
|
|
744
|
+
|
|
745
|
+
# Save the recipe
|
|
746
|
+
saved_yaml = fig.save_recipe(yaml_path, include_data=include_data, data_format=data_format)
|
|
747
|
+
|
|
748
|
+
# Validate if requested
|
|
749
|
+
if validate:
|
|
750
|
+
from ._validator import validate_on_save
|
|
751
|
+
result = validate_on_save(fig, saved_yaml, mse_threshold=validate_mse_threshold)
|
|
752
|
+
status = "PASSED" if result.valid else "FAILED"
|
|
753
|
+
if verbose:
|
|
754
|
+
print(f"Saved: {image_path} + {yaml_path} (Reproducible Validation: {status})")
|
|
755
|
+
if not result.valid:
|
|
756
|
+
msg = f"Reproducibility validation failed (MSE={result.mse:.1f}): {result.message}"
|
|
757
|
+
if validate_error_level == "error":
|
|
758
|
+
raise ValueError(msg)
|
|
759
|
+
elif validate_error_level == "warning":
|
|
760
|
+
import warnings
|
|
761
|
+
warnings.warn(msg, UserWarning)
|
|
762
|
+
# "debug" level: silent, just return the result
|
|
763
|
+
return image_path, yaml_path, result
|
|
764
|
+
|
|
765
|
+
if verbose:
|
|
766
|
+
print(f"Saved: {image_path} + {yaml_path}")
|
|
767
|
+
return image_path, yaml_path, None
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def reproduce(
|
|
771
|
+
path: Union[str, Path],
|
|
772
|
+
calls: Optional[List[str]] = None,
|
|
773
|
+
skip_decorations: bool = False,
|
|
774
|
+
) -> Tuple[Figure, Union[Axes, List[Axes]]]:
|
|
775
|
+
"""Reproduce a figure from a recipe file.
|
|
776
|
+
|
|
777
|
+
Parameters
|
|
778
|
+
----------
|
|
779
|
+
path : str or Path
|
|
780
|
+
Path to .yaml recipe file.
|
|
781
|
+
calls : list of str, optional
|
|
782
|
+
If provided, only reproduce these specific call IDs.
|
|
783
|
+
skip_decorations : bool
|
|
784
|
+
If True, skip decoration calls (labels, legends, etc.).
|
|
785
|
+
|
|
786
|
+
Returns
|
|
787
|
+
-------
|
|
788
|
+
fig : matplotlib.figure.Figure
|
|
789
|
+
Reproduced figure.
|
|
790
|
+
axes : Axes or list of Axes
|
|
791
|
+
Reproduced axes.
|
|
792
|
+
|
|
793
|
+
Examples
|
|
794
|
+
--------
|
|
795
|
+
>>> import figrecipe as ps
|
|
796
|
+
>>> fig, ax = ps.reproduce('experiment.yaml')
|
|
797
|
+
>>> plt.show()
|
|
798
|
+
|
|
799
|
+
>>> # Reproduce only specific plots
|
|
800
|
+
>>> fig, ax = ps.reproduce('experiment.yaml', calls=['scatter_001'])
|
|
801
|
+
"""
|
|
802
|
+
return _reproduce(path, calls=calls, skip_decorations=skip_decorations)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def info(path: Union[str, Path]) -> Dict[str, Any]:
|
|
806
|
+
"""Get information about a recipe without reproducing.
|
|
807
|
+
|
|
808
|
+
Parameters
|
|
809
|
+
----------
|
|
810
|
+
path : str or Path
|
|
811
|
+
Path to .yaml recipe file.
|
|
812
|
+
|
|
813
|
+
Returns
|
|
814
|
+
-------
|
|
815
|
+
dict
|
|
816
|
+
Recipe information including figure ID, creation time,
|
|
817
|
+
matplotlib version, size, and list of calls.
|
|
818
|
+
|
|
819
|
+
Examples
|
|
820
|
+
--------
|
|
821
|
+
>>> import figrecipe as ps
|
|
822
|
+
>>> recipe_info = ps.info('experiment.yaml')
|
|
823
|
+
>>> print(f"Created: {recipe_info['created']}")
|
|
824
|
+
>>> print(f"Calls: {len(recipe_info['calls'])}")
|
|
825
|
+
"""
|
|
826
|
+
return get_recipe_info(path)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def load(path: Union[str, Path]) -> FigureRecord:
|
|
830
|
+
"""Load a recipe as a FigureRecord object.
|
|
831
|
+
|
|
832
|
+
Parameters
|
|
833
|
+
----------
|
|
834
|
+
path : str or Path
|
|
835
|
+
Path to .yaml recipe file.
|
|
836
|
+
|
|
837
|
+
Returns
|
|
838
|
+
-------
|
|
839
|
+
FigureRecord
|
|
840
|
+
The loaded figure record.
|
|
841
|
+
|
|
842
|
+
Examples
|
|
843
|
+
--------
|
|
844
|
+
>>> import figrecipe as ps
|
|
845
|
+
>>> record = ps.load('experiment.yaml')
|
|
846
|
+
>>> # Modify the record
|
|
847
|
+
>>> record.axes['ax_0_0'].calls[0].kwargs['color'] = 'blue'
|
|
848
|
+
>>> # Reproduce with modifications
|
|
849
|
+
>>> fig, ax = ps.reproduce_from_record(record)
|
|
850
|
+
"""
|
|
851
|
+
return load_recipe(path)
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def extract_data(path: Union[str, Path]) -> Dict[str, Dict[str, Any]]:
|
|
855
|
+
"""Extract data arrays from a saved recipe.
|
|
856
|
+
|
|
857
|
+
This function allows you to import/recover the data that was
|
|
858
|
+
plotted in a figure from its recipe file.
|
|
859
|
+
|
|
860
|
+
Parameters
|
|
861
|
+
----------
|
|
862
|
+
path : str or Path
|
|
863
|
+
Path to .yaml recipe file.
|
|
864
|
+
|
|
865
|
+
Returns
|
|
866
|
+
-------
|
|
867
|
+
dict
|
|
868
|
+
Nested dictionary: {call_id: {'x': array, 'y': array, ...}}
|
|
869
|
+
Each call's data is stored under its ID with keys for each argument.
|
|
870
|
+
|
|
871
|
+
Examples
|
|
872
|
+
--------
|
|
873
|
+
>>> import figrecipe as ps
|
|
874
|
+
>>> import numpy as np
|
|
875
|
+
>>>
|
|
876
|
+
>>> # Create and save a figure
|
|
877
|
+
>>> x = np.linspace(0, 10, 100)
|
|
878
|
+
>>> y = np.sin(x)
|
|
879
|
+
>>> fig, ax = ps.subplots()
|
|
880
|
+
>>> ax.plot(x, y, id='sine_wave')
|
|
881
|
+
>>> ps.save(fig, 'figure.yaml')
|
|
882
|
+
>>>
|
|
883
|
+
>>> # Later, extract the data
|
|
884
|
+
>>> data = ps.extract_data('figure.yaml')
|
|
885
|
+
>>> x_recovered = data['sine_wave']['x']
|
|
886
|
+
>>> y_recovered = data['sine_wave']['y']
|
|
887
|
+
>>> np.allclose(x, x_recovered)
|
|
888
|
+
True
|
|
889
|
+
|
|
890
|
+
Notes
|
|
891
|
+
-----
|
|
892
|
+
- Data is extracted from all plot calls (plot, scatter, bar, etc.)
|
|
893
|
+
- For plot() calls: 'x' and 'y' contain the coordinates
|
|
894
|
+
- For scatter(): 'x', 'y', and optionally 'c' (colors), 's' (sizes)
|
|
895
|
+
- For bar(): 'x' (categories) and 'height' (values)
|
|
896
|
+
- For hist(): 'x' (data array)
|
|
897
|
+
"""
|
|
898
|
+
import numpy as np
|
|
899
|
+
|
|
900
|
+
record = load_recipe(path)
|
|
901
|
+
result = {}
|
|
902
|
+
|
|
903
|
+
# Decoration functions to skip
|
|
904
|
+
decoration_funcs = {
|
|
905
|
+
"set_xlabel", "set_ylabel", "set_title", "set_xlim", "set_ylim",
|
|
906
|
+
"legend", "grid", "axhline", "axvline", "text", "annotate",
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
for ax_key, ax_record in record.axes.items():
|
|
910
|
+
for call in ax_record.calls:
|
|
911
|
+
# Skip decoration calls
|
|
912
|
+
if call.function in decoration_funcs:
|
|
913
|
+
continue
|
|
914
|
+
|
|
915
|
+
call_data = {}
|
|
916
|
+
|
|
917
|
+
def to_array(data):
|
|
918
|
+
"""Convert data to numpy array, handling YAML types."""
|
|
919
|
+
# Handle dict with 'data' key (serialized array format)
|
|
920
|
+
if isinstance(data, dict) or (hasattr(data, "keys") and "data" in data):
|
|
921
|
+
return np.array(data["data"])
|
|
922
|
+
if hasattr(data, "tolist"): # Already array-like
|
|
923
|
+
return np.array(data)
|
|
924
|
+
return np.array(list(data) if hasattr(data, "__iter__") and not isinstance(data, str) else data)
|
|
925
|
+
|
|
926
|
+
# Extract positional arguments based on function type
|
|
927
|
+
if call.function in ("plot", "scatter", "fill_between"):
|
|
928
|
+
if len(call.args) >= 1:
|
|
929
|
+
call_data["x"] = to_array(call.args[0])
|
|
930
|
+
if len(call.args) >= 2:
|
|
931
|
+
call_data["y"] = to_array(call.args[1])
|
|
932
|
+
|
|
933
|
+
elif call.function == "bar":
|
|
934
|
+
if len(call.args) >= 1:
|
|
935
|
+
call_data["x"] = to_array(call.args[0])
|
|
936
|
+
if len(call.args) >= 2:
|
|
937
|
+
call_data["height"] = to_array(call.args[1])
|
|
938
|
+
|
|
939
|
+
elif call.function == "hist":
|
|
940
|
+
if len(call.args) >= 1:
|
|
941
|
+
call_data["x"] = to_array(call.args[0])
|
|
942
|
+
|
|
943
|
+
elif call.function == "errorbar":
|
|
944
|
+
if len(call.args) >= 1:
|
|
945
|
+
call_data["x"] = to_array(call.args[0])
|
|
946
|
+
if len(call.args) >= 2:
|
|
947
|
+
call_data["y"] = to_array(call.args[1])
|
|
948
|
+
|
|
949
|
+
# Extract relevant kwargs
|
|
950
|
+
for key in ("c", "s", "yerr", "xerr", "weights", "bins"):
|
|
951
|
+
if key in call.kwargs:
|
|
952
|
+
val = call.kwargs[key]
|
|
953
|
+
if isinstance(val, (list, tuple)) or hasattr(val, "__iter__") and not isinstance(val, str):
|
|
954
|
+
call_data[key] = to_array(val)
|
|
955
|
+
else:
|
|
956
|
+
call_data[key] = val
|
|
957
|
+
|
|
958
|
+
if call_data:
|
|
959
|
+
result[call.id] = call_data
|
|
960
|
+
|
|
961
|
+
return result
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
# Import ValidationResult for type hints
|
|
965
|
+
from ._validator import ValidationResult, validate_recipe
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def validate(
|
|
969
|
+
path: Union[str, Path],
|
|
970
|
+
mse_threshold: float = 100.0,
|
|
971
|
+
) -> ValidationResult:
|
|
972
|
+
"""Validate that a saved recipe can reproduce its original figure.
|
|
973
|
+
|
|
974
|
+
This is a standalone validation function for existing recipes.
|
|
975
|
+
For validation during save, use `ps.save(..., validate=True)`.
|
|
976
|
+
|
|
977
|
+
Parameters
|
|
978
|
+
----------
|
|
979
|
+
path : str or Path
|
|
980
|
+
Path to .yaml recipe file.
|
|
981
|
+
mse_threshold : float
|
|
982
|
+
Maximum acceptable MSE for validation to pass (default: 100).
|
|
983
|
+
|
|
984
|
+
Returns
|
|
985
|
+
-------
|
|
986
|
+
ValidationResult
|
|
987
|
+
Detailed comparison results including MSE, dimensions, etc.
|
|
988
|
+
|
|
989
|
+
Examples
|
|
990
|
+
--------
|
|
991
|
+
>>> import figrecipe as ps
|
|
992
|
+
>>> result = ps.validate('experiment.yaml')
|
|
993
|
+
>>> print(result.summary())
|
|
994
|
+
>>> if result.valid:
|
|
995
|
+
... print("Recipe is reproducible!")
|
|
996
|
+
|
|
997
|
+
Notes
|
|
998
|
+
-----
|
|
999
|
+
This function reproduces the figure from the recipe and compares
|
|
1000
|
+
the result to re-rendering the recipe. It cannot compare to the
|
|
1001
|
+
original figure unless you use `ps.save(..., validate=True)` which
|
|
1002
|
+
performs validation before closing the original figure.
|
|
1003
|
+
"""
|
|
1004
|
+
# For standalone validation, we reproduce twice and compare
|
|
1005
|
+
# (This validates the recipe is self-consistent)
|
|
1006
|
+
from ._reproducer import reproduce
|
|
1007
|
+
from ._utils._image_diff import compare_images
|
|
1008
|
+
import tempfile
|
|
1009
|
+
import numpy as np
|
|
1010
|
+
|
|
1011
|
+
path = Path(path)
|
|
1012
|
+
|
|
1013
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
1014
|
+
tmpdir = Path(tmpdir)
|
|
1015
|
+
|
|
1016
|
+
# Reproduce twice
|
|
1017
|
+
fig1, _ = reproduce(path)
|
|
1018
|
+
img1_path = tmpdir / "render1.png"
|
|
1019
|
+
fig1.savefig(img1_path, dpi=150)
|
|
1020
|
+
|
|
1021
|
+
fig2, _ = reproduce(path)
|
|
1022
|
+
img2_path = tmpdir / "render2.png"
|
|
1023
|
+
fig2.savefig(img2_path, dpi=150)
|
|
1024
|
+
|
|
1025
|
+
# Compare
|
|
1026
|
+
diff = compare_images(img1_path, img2_path)
|
|
1027
|
+
|
|
1028
|
+
mse = diff["mse"]
|
|
1029
|
+
if np.isnan(mse):
|
|
1030
|
+
valid = False
|
|
1031
|
+
message = f"Image dimensions differ: {diff['size1']} vs {diff['size2']}"
|
|
1032
|
+
elif mse > mse_threshold:
|
|
1033
|
+
valid = False
|
|
1034
|
+
message = f"MSE ({mse:.2f}) exceeds threshold ({mse_threshold})"
|
|
1035
|
+
else:
|
|
1036
|
+
valid = True
|
|
1037
|
+
message = "Recipe produces consistent output"
|
|
1038
|
+
|
|
1039
|
+
return ValidationResult(
|
|
1040
|
+
valid=valid,
|
|
1041
|
+
mse=mse if not np.isnan(mse) else float("inf"),
|
|
1042
|
+
psnr=diff["psnr"],
|
|
1043
|
+
max_diff=diff["max_diff"] if not np.isnan(diff["max_diff"]) else float("inf"),
|
|
1044
|
+
size_original=diff["size1"],
|
|
1045
|
+
size_reproduced=diff["size2"],
|
|
1046
|
+
same_size=diff["same_size"],
|
|
1047
|
+
file_size_diff=diff["file_size2"] - diff["file_size1"],
|
|
1048
|
+
message=message,
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def crop(input_path, output_path=None, margin_mm=1.0, margin_px=None, overwrite=False, verbose=False):
|
|
1053
|
+
"""Crop a figure image to its content area with a specified margin.
|
|
1054
|
+
|
|
1055
|
+
Automatically detects background color (from corners) and crops to
|
|
1056
|
+
content, leaving only the specified margin around it.
|
|
1057
|
+
|
|
1058
|
+
Parameters
|
|
1059
|
+
----------
|
|
1060
|
+
input_path : str or Path
|
|
1061
|
+
Path to the input image (PNG, JPEG, etc.)
|
|
1062
|
+
output_path : str or Path, optional
|
|
1063
|
+
Path to save the cropped image. If None and overwrite=True,
|
|
1064
|
+
overwrites the input. If None and overwrite=False, adds '_cropped' suffix.
|
|
1065
|
+
margin_mm : float, optional
|
|
1066
|
+
Margin in millimeters to keep around content (default: 1.0mm).
|
|
1067
|
+
Converted to pixels using image DPI (or 300 DPI if not available).
|
|
1068
|
+
margin_px : int, optional
|
|
1069
|
+
Margin in pixels (overrides margin_mm if provided).
|
|
1070
|
+
overwrite : bool, optional
|
|
1071
|
+
Whether to overwrite the input file (default: False)
|
|
1072
|
+
verbose : bool, optional
|
|
1073
|
+
Whether to print detailed information (default: False)
|
|
1074
|
+
|
|
1075
|
+
Returns
|
|
1076
|
+
-------
|
|
1077
|
+
Path
|
|
1078
|
+
Path to the saved cropped image.
|
|
1079
|
+
|
|
1080
|
+
Examples
|
|
1081
|
+
--------
|
|
1082
|
+
>>> import figrecipe as fr
|
|
1083
|
+
>>> fig, ax = fr.subplots(axes_width_mm=60, axes_height_mm=40)
|
|
1084
|
+
>>> ax.plot([1, 2, 3], [1, 2, 3], id='line')
|
|
1085
|
+
>>> fig.savefig("figure.png", dpi=300)
|
|
1086
|
+
>>> fr.crop("figure.png", overwrite=True) # 1mm margin
|
|
1087
|
+
>>> fr.crop("figure.png", margin_mm=2.0) # 2mm margin
|
|
1088
|
+
"""
|
|
1089
|
+
from ._utils._crop import crop as _crop
|
|
1090
|
+
return _crop(input_path, output_path, margin_mm, margin_px, overwrite, verbose)
|