figrecipe 0.5.0__py3-none-any.whl → 0.6.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 +361 -93
- figrecipe/_dev/__init__.py +120 -0
- figrecipe/_dev/demo_plotters/__init__.py +195 -0
- figrecipe/_dev/demo_plotters/plot_acorr.py +24 -0
- figrecipe/_dev/demo_plotters/plot_angle_spectrum.py +28 -0
- figrecipe/_dev/demo_plotters/plot_bar.py +25 -0
- figrecipe/_dev/demo_plotters/plot_barbs.py +30 -0
- figrecipe/_dev/demo_plotters/plot_barh.py +25 -0
- figrecipe/_dev/demo_plotters/plot_boxplot.py +24 -0
- figrecipe/_dev/demo_plotters/plot_cohere.py +29 -0
- figrecipe/_dev/demo_plotters/plot_contour.py +30 -0
- figrecipe/_dev/demo_plotters/plot_contourf.py +29 -0
- figrecipe/_dev/demo_plotters/plot_csd.py +29 -0
- figrecipe/_dev/demo_plotters/plot_ecdf.py +24 -0
- figrecipe/_dev/demo_plotters/plot_errorbar.py +28 -0
- figrecipe/_dev/demo_plotters/plot_eventplot.py +25 -0
- figrecipe/_dev/demo_plotters/plot_fill.py +29 -0
- figrecipe/_dev/demo_plotters/plot_fill_between.py +30 -0
- figrecipe/_dev/demo_plotters/plot_fill_betweenx.py +28 -0
- figrecipe/_dev/demo_plotters/plot_hexbin.py +25 -0
- figrecipe/_dev/demo_plotters/plot_hist.py +24 -0
- figrecipe/_dev/demo_plotters/plot_hist2d.py +25 -0
- figrecipe/_dev/demo_plotters/plot_imshow.py +23 -0
- figrecipe/_dev/demo_plotters/plot_loglog.py +27 -0
- figrecipe/_dev/demo_plotters/plot_magnitude_spectrum.py +28 -0
- figrecipe/_dev/demo_plotters/plot_matshow.py +23 -0
- figrecipe/_dev/demo_plotters/plot_pcolor.py +29 -0
- figrecipe/_dev/demo_plotters/plot_pcolormesh.py +29 -0
- figrecipe/_dev/demo_plotters/plot_phase_spectrum.py +28 -0
- figrecipe/_dev/demo_plotters/plot_pie.py +23 -0
- figrecipe/_dev/demo_plotters/plot_plot.py +27 -0
- figrecipe/_dev/demo_plotters/plot_psd.py +29 -0
- figrecipe/_dev/demo_plotters/plot_quiver.py +30 -0
- figrecipe/_dev/demo_plotters/plot_scatter.py +24 -0
- figrecipe/_dev/demo_plotters/plot_semilogx.py +27 -0
- figrecipe/_dev/demo_plotters/plot_semilogy.py +27 -0
- figrecipe/_dev/demo_plotters/plot_specgram.py +30 -0
- figrecipe/_dev/demo_plotters/plot_spy.py +29 -0
- figrecipe/_dev/demo_plotters/plot_stackplot.py +29 -0
- figrecipe/_dev/demo_plotters/plot_stairs.py +27 -0
- figrecipe/_dev/demo_plotters/plot_stem.py +27 -0
- figrecipe/_dev/demo_plotters/plot_step.py +27 -0
- figrecipe/_dev/demo_plotters/plot_streamplot.py +30 -0
- figrecipe/_dev/demo_plotters/plot_tricontour.py +28 -0
- figrecipe/_dev/demo_plotters/plot_tricontourf.py +28 -0
- figrecipe/_dev/demo_plotters/plot_tripcolor.py +29 -0
- figrecipe/_dev/demo_plotters/plot_triplot.py +25 -0
- figrecipe/_dev/demo_plotters/plot_violinplot.py +25 -0
- figrecipe/_dev/demo_plotters/plot_xcorr.py +25 -0
- figrecipe/_editor/__init__.py +230 -0
- figrecipe/_editor/_bbox.py +978 -0
- figrecipe/_editor/_flask_app.py +1229 -0
- figrecipe/_editor/_hitmap.py +937 -0
- figrecipe/_editor/_overrides.py +318 -0
- figrecipe/_editor/_renderer.py +349 -0
- figrecipe/_editor/_templates/__init__.py +75 -0
- figrecipe/_editor/_templates/_html.py +406 -0
- figrecipe/_editor/_templates/_scripts.py +2778 -0
- figrecipe/_editor/_templates/_styles.py +1326 -0
- figrecipe/_params/_DECORATION_METHODS.py +27 -0
- figrecipe/_params/_PLOTTING_METHODS.py +58 -0
- figrecipe/_params/__init__.py +9 -0
- figrecipe/_recorder.py +126 -73
- figrecipe/_reproducer.py +658 -41
- figrecipe/_seaborn.py +14 -9
- figrecipe/_serializer.py +2 -2
- figrecipe/_signatures/README.md +68 -0
- figrecipe/_signatures/__init__.py +12 -2
- figrecipe/_signatures/_loader.py +515 -56
- figrecipe/_utils/__init__.py +6 -4
- figrecipe/_utils/_crop.py +10 -4
- figrecipe/_utils/_image_diff.py +37 -33
- figrecipe/_utils/_numpy_io.py +0 -1
- figrecipe/_utils/_units.py +11 -3
- figrecipe/_validator.py +12 -3
- figrecipe/_wrappers/_axes.py +860 -46
- figrecipe/_wrappers/_figure.py +115 -18
- figrecipe/plt.py +0 -1
- figrecipe/pyplot.py +2 -1
- figrecipe/styles/__init__.py +9 -10
- figrecipe/styles/_style_applier.py +332 -28
- figrecipe/styles/_style_loader.py +172 -44
- figrecipe/styles/presets/MATPLOTLIB.yaml +94 -0
- figrecipe/styles/presets/SCITEX.yaml +176 -0
- figrecipe-0.6.0.dist-info/METADATA +394 -0
- figrecipe-0.6.0.dist-info/RECORD +90 -0
- figrecipe-0.5.0.dist-info/METADATA +0 -336
- figrecipe-0.5.0.dist-info/RECORD +0 -26
- {figrecipe-0.5.0.dist-info → figrecipe-0.6.0.dist-info}/WHEEL +0 -0
- {figrecipe-0.5.0.dist-info → figrecipe-0.6.0.dist-info}/licenses/LICENSE +0 -0
figrecipe/__init__.py
CHANGED
|
@@ -50,20 +50,29 @@ Inspecting a recipe:
|
|
|
50
50
|
"""
|
|
51
51
|
|
|
52
52
|
from pathlib import Path
|
|
53
|
-
from typing import Any, Dict, List,
|
|
53
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
54
54
|
|
|
55
|
-
import matplotlib.pyplot as plt
|
|
56
55
|
from matplotlib.axes import Axes
|
|
57
56
|
from matplotlib.figure import Figure
|
|
57
|
+
from numpy.typing import NDArray
|
|
58
58
|
|
|
59
|
-
from ._recorder import
|
|
59
|
+
from ._recorder import CallRecord, FigureRecord
|
|
60
|
+
from ._reproducer import get_recipe_info
|
|
61
|
+
from ._reproducer import reproduce as _reproduce
|
|
62
|
+
from ._serializer import load_recipe
|
|
63
|
+
from ._utils._numpy_io import DataFormat
|
|
64
|
+
from ._utils._units import (
|
|
65
|
+
inch_to_mm,
|
|
66
|
+
mm_to_inch,
|
|
67
|
+
mm_to_pt,
|
|
68
|
+
mm_to_scatter_size,
|
|
69
|
+
normalize_color,
|
|
70
|
+
pt_to_mm,
|
|
71
|
+
)
|
|
72
|
+
from ._validator import ValidationResult
|
|
60
73
|
from ._wrappers import RecordingAxes, RecordingFigure
|
|
61
74
|
from ._wrappers._figure import create_recording_subplots
|
|
62
|
-
from .
|
|
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
|
|
75
|
+
from .styles._style_applier import check_font, list_available_fonts
|
|
67
76
|
|
|
68
77
|
# Notebook display format flag (set once per session)
|
|
69
78
|
_notebook_format_set = False
|
|
@@ -82,16 +91,20 @@ def _enable_notebook_svg():
|
|
|
82
91
|
try:
|
|
83
92
|
# Method 1: matplotlib_inline (IPython 7.0+, JupyterLab)
|
|
84
93
|
from matplotlib_inline.backend_inline import set_matplotlib_formats
|
|
85
|
-
|
|
94
|
+
|
|
95
|
+
set_matplotlib_formats("svg")
|
|
86
96
|
_notebook_format_set = True
|
|
87
97
|
except (ImportError, Exception):
|
|
88
98
|
try:
|
|
89
99
|
# Method 2: IPython config (older IPython)
|
|
90
100
|
from IPython import get_ipython
|
|
101
|
+
|
|
91
102
|
ipython = get_ipython()
|
|
92
|
-
if ipython is not None and hasattr(ipython,
|
|
103
|
+
if ipython is not None and hasattr(ipython, "kernel"):
|
|
93
104
|
# Only run in actual Jupyter kernel, not IPython console
|
|
94
|
-
ipython.run_line_magic(
|
|
105
|
+
ipython.run_line_magic(
|
|
106
|
+
"config", "InlineBackend.figure_formats = ['svg']"
|
|
107
|
+
)
|
|
95
108
|
_notebook_format_set = True
|
|
96
109
|
except Exception:
|
|
97
110
|
pass # Not in Jupyter environment or method not available
|
|
@@ -122,6 +135,7 @@ def _get_sns():
|
|
|
122
135
|
global _sns_recorder
|
|
123
136
|
if _sns_recorder is None:
|
|
124
137
|
from ._seaborn import get_seaborn_recorder
|
|
138
|
+
|
|
125
139
|
_sns_recorder = get_seaborn_recorder()
|
|
126
140
|
return _sns_recorder
|
|
127
141
|
|
|
@@ -146,6 +160,8 @@ __all__ = [
|
|
|
146
160
|
"load",
|
|
147
161
|
"extract_data",
|
|
148
162
|
"validate",
|
|
163
|
+
# GUI Editor
|
|
164
|
+
"edit",
|
|
149
165
|
# Style system
|
|
150
166
|
"load_style",
|
|
151
167
|
"unload_style",
|
|
@@ -174,6 +190,8 @@ __all__ = [
|
|
|
174
190
|
"ValidationResult",
|
|
175
191
|
# Image utilities
|
|
176
192
|
"crop",
|
|
193
|
+
# Panel labels
|
|
194
|
+
"panel_label",
|
|
177
195
|
# Version
|
|
178
196
|
"__version__",
|
|
179
197
|
]
|
|
@@ -230,6 +248,7 @@ def load_style(style="SCITEX", dark=False):
|
|
|
230
248
|
40
|
|
231
249
|
"""
|
|
232
250
|
from .styles import load_style as _load_style
|
|
251
|
+
|
|
233
252
|
return _load_style(style, dark=dark)
|
|
234
253
|
|
|
235
254
|
|
|
@@ -248,6 +267,7 @@ def unload_style():
|
|
|
248
267
|
>>> fig, ax = fr.subplots() # Vanilla matplotlib
|
|
249
268
|
"""
|
|
250
269
|
from .styles import unload_style as _unload_style
|
|
270
|
+
|
|
251
271
|
_unload_style()
|
|
252
272
|
|
|
253
273
|
|
|
@@ -266,6 +286,7 @@ def list_presets():
|
|
|
266
286
|
['MINIMAL', 'PRESENTATION', 'SCIENTIFIC']
|
|
267
287
|
"""
|
|
268
288
|
from .styles import list_presets as _list_presets
|
|
289
|
+
|
|
269
290
|
return _list_presets()
|
|
270
291
|
|
|
271
292
|
|
|
@@ -293,9 +314,10 @@ def apply_style(ax, style=None):
|
|
|
293
314
|
>>> ax.plot(x, y, lw=trace_lw)
|
|
294
315
|
"""
|
|
295
316
|
from .styles import apply_style_mm, get_style, to_subplots_kwargs
|
|
317
|
+
|
|
296
318
|
if style is None:
|
|
297
319
|
style = to_subplots_kwargs(get_style())
|
|
298
|
-
elif hasattr(style,
|
|
320
|
+
elif hasattr(style, "to_subplots_kwargs"):
|
|
299
321
|
style = style.to_subplots_kwargs()
|
|
300
322
|
return apply_style_mm(ax, style)
|
|
301
323
|
|
|
@@ -305,10 +327,12 @@ class _StyleProxy:
|
|
|
305
327
|
|
|
306
328
|
def __getattr__(self, name):
|
|
307
329
|
from .styles import STYLE
|
|
330
|
+
|
|
308
331
|
return getattr(STYLE, name)
|
|
309
332
|
|
|
310
333
|
def to_subplots_kwargs(self):
|
|
311
334
|
from .styles import to_subplots_kwargs
|
|
335
|
+
|
|
312
336
|
return to_subplots_kwargs()
|
|
313
337
|
|
|
314
338
|
|
|
@@ -331,7 +355,7 @@ def subplots(
|
|
|
331
355
|
style: Optional[Dict[str, Any]] = None,
|
|
332
356
|
apply_style_mm: bool = True,
|
|
333
357
|
**kwargs,
|
|
334
|
-
) -> Tuple[RecordingFigure, Union[RecordingAxes,
|
|
358
|
+
) -> Tuple[RecordingFigure, Union[RecordingAxes, NDArray]]:
|
|
335
359
|
"""Create a figure with recording-enabled axes.
|
|
336
360
|
|
|
337
361
|
This is a drop-in replacement for plt.subplots() that wraps the
|
|
@@ -380,8 +404,8 @@ def subplots(
|
|
|
380
404
|
-------
|
|
381
405
|
fig : RecordingFigure
|
|
382
406
|
Wrapped figure object.
|
|
383
|
-
axes : RecordingAxes or
|
|
384
|
-
Wrapped axes (single for 1x1,
|
|
407
|
+
axes : RecordingAxes or ndarray
|
|
408
|
+
Wrapped axes (single for 1x1, numpy array otherwise matching matplotlib).
|
|
385
409
|
|
|
386
410
|
Examples
|
|
387
411
|
--------
|
|
@@ -408,6 +432,7 @@ def subplots(
|
|
|
408
432
|
"""
|
|
409
433
|
# Get global style for default values (if loaded)
|
|
410
434
|
from .styles._style_loader import _STYLE_CACHE
|
|
435
|
+
|
|
411
436
|
global_style = _STYLE_CACHE
|
|
412
437
|
|
|
413
438
|
# Helper to get value with priority: explicit > global style > hardcoded default
|
|
@@ -418,7 +443,11 @@ def subplots(
|
|
|
418
443
|
try:
|
|
419
444
|
val = global_style
|
|
420
445
|
for key in style_path:
|
|
421
|
-
val =
|
|
446
|
+
val = (
|
|
447
|
+
val.get(key)
|
|
448
|
+
if isinstance(val, dict)
|
|
449
|
+
else getattr(val, key, None)
|
|
450
|
+
)
|
|
422
451
|
if val is None:
|
|
423
452
|
break
|
|
424
453
|
if val is not None:
|
|
@@ -428,98 +457,115 @@ def subplots(
|
|
|
428
457
|
return default
|
|
429
458
|
|
|
430
459
|
# Check if mm-based layout is requested (explicit OR from global style)
|
|
431
|
-
has_explicit_mm = any(
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
460
|
+
has_explicit_mm = any(
|
|
461
|
+
[
|
|
462
|
+
axes_width_mm is not None,
|
|
463
|
+
axes_height_mm is not None,
|
|
464
|
+
margin_left_mm is not None,
|
|
465
|
+
margin_right_mm is not None,
|
|
466
|
+
margin_bottom_mm is not None,
|
|
467
|
+
margin_top_mm is not None,
|
|
468
|
+
space_w_mm is not None,
|
|
469
|
+
space_h_mm is not None,
|
|
470
|
+
]
|
|
471
|
+
)
|
|
441
472
|
|
|
442
473
|
# Also use mm layout if global style has mm values
|
|
443
474
|
has_style_mm = False
|
|
444
475
|
if global_style is not None:
|
|
445
476
|
try:
|
|
446
477
|
has_style_mm = (
|
|
447
|
-
global_style.get(
|
|
448
|
-
getattr(getattr(global_style,
|
|
478
|
+
global_style.get("axes", {}).get("width_mm") is not None
|
|
479
|
+
or getattr(getattr(global_style, "axes", None), "width_mm", None)
|
|
480
|
+
is not None
|
|
449
481
|
)
|
|
450
482
|
except (KeyError, AttributeError):
|
|
451
483
|
pass
|
|
452
484
|
|
|
453
485
|
use_mm_layout = has_explicit_mm or has_style_mm
|
|
454
486
|
|
|
455
|
-
if use_mm_layout and
|
|
487
|
+
if use_mm_layout and "figsize" not in kwargs:
|
|
456
488
|
# Get mm values: explicit params > global style > hardcoded defaults
|
|
457
|
-
aw = _get_mm(axes_width_mm, [
|
|
458
|
-
ah = _get_mm(axes_height_mm, [
|
|
459
|
-
ml = _get_mm(margin_left_mm, [
|
|
460
|
-
mr = _get_mm(margin_right_mm, [
|
|
461
|
-
mb = _get_mm(margin_bottom_mm, [
|
|
462
|
-
mt = _get_mm(margin_top_mm, [
|
|
463
|
-
sw = _get_mm(space_w_mm, [
|
|
464
|
-
sh = _get_mm(space_h_mm, [
|
|
489
|
+
aw = _get_mm(axes_width_mm, ["axes", "width_mm"], 40)
|
|
490
|
+
ah = _get_mm(axes_height_mm, ["axes", "height_mm"], 28)
|
|
491
|
+
ml = _get_mm(margin_left_mm, ["margins", "left_mm"], 15)
|
|
492
|
+
mr = _get_mm(margin_right_mm, ["margins", "right_mm"], 5)
|
|
493
|
+
mb = _get_mm(margin_bottom_mm, ["margins", "bottom_mm"], 12)
|
|
494
|
+
mt = _get_mm(margin_top_mm, ["margins", "top_mm"], 8)
|
|
495
|
+
sw = _get_mm(space_w_mm, ["spacing", "horizontal_mm"], 8)
|
|
496
|
+
sh = _get_mm(space_h_mm, ["spacing", "vertical_mm"], 10)
|
|
465
497
|
|
|
466
498
|
# Calculate total figure size
|
|
467
499
|
total_width_mm = ml + (ncols * aw) + ((ncols - 1) * sw) + mr
|
|
468
500
|
total_height_mm = mb + (nrows * ah) + ((nrows - 1) * sh) + mt
|
|
469
501
|
|
|
470
502
|
# Convert to inches and set figsize
|
|
471
|
-
kwargs[
|
|
503
|
+
kwargs["figsize"] = (mm_to_inch(total_width_mm), mm_to_inch(total_height_mm))
|
|
472
504
|
|
|
473
505
|
# Store mm metadata for recording (will be extracted by create_recording_subplots)
|
|
474
506
|
mm_layout = {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
507
|
+
"axes_width_mm": aw,
|
|
508
|
+
"axes_height_mm": ah,
|
|
509
|
+
"margin_left_mm": ml,
|
|
510
|
+
"margin_right_mm": mr,
|
|
511
|
+
"margin_bottom_mm": mb,
|
|
512
|
+
"margin_top_mm": mt,
|
|
513
|
+
"space_w_mm": sw,
|
|
514
|
+
"space_h_mm": sh,
|
|
483
515
|
}
|
|
484
516
|
else:
|
|
485
517
|
mm_layout = None
|
|
486
518
|
|
|
487
519
|
# Apply DPI from global style if not explicitly provided
|
|
488
|
-
if
|
|
520
|
+
if "dpi" not in kwargs and global_style is not None:
|
|
489
521
|
# Try figure.dpi first, then output.dpi
|
|
490
522
|
style_dpi = None
|
|
491
523
|
try:
|
|
492
|
-
if hasattr(global_style,
|
|
524
|
+
if hasattr(global_style, "figure") and hasattr(global_style.figure, "dpi"):
|
|
493
525
|
style_dpi = global_style.figure.dpi
|
|
494
|
-
elif hasattr(global_style,
|
|
526
|
+
elif hasattr(global_style, "output") and hasattr(
|
|
527
|
+
global_style.output, "dpi"
|
|
528
|
+
):
|
|
495
529
|
style_dpi = global_style.output.dpi
|
|
496
530
|
except (KeyError, AttributeError):
|
|
497
531
|
pass
|
|
498
532
|
if style_dpi is not None:
|
|
499
|
-
kwargs[
|
|
533
|
+
kwargs["dpi"] = style_dpi
|
|
500
534
|
|
|
501
535
|
# Handle style parameter
|
|
502
536
|
if style is not None:
|
|
503
|
-
if hasattr(style,
|
|
537
|
+
if hasattr(style, "to_subplots_kwargs"):
|
|
504
538
|
# Merge style kwargs (style values are overridden by explicit params)
|
|
505
539
|
style_kwargs = style.to_subplots_kwargs()
|
|
506
540
|
for key, value in style_kwargs.items():
|
|
507
541
|
if key not in kwargs:
|
|
508
542
|
kwargs[key] = value
|
|
509
543
|
|
|
510
|
-
#
|
|
511
|
-
|
|
512
|
-
if
|
|
513
|
-
|
|
544
|
+
# Check if style specifies constrained_layout
|
|
545
|
+
style_constrained = False
|
|
546
|
+
if global_style is not None:
|
|
547
|
+
from .styles._style_loader import to_subplots_kwargs
|
|
548
|
+
|
|
549
|
+
style_dict_check = to_subplots_kwargs(global_style)
|
|
550
|
+
style_constrained = style_dict_check.get("constrained_layout", False)
|
|
551
|
+
|
|
552
|
+
# Use constrained_layout if: style specifies it, or non-mm layout (better auto-spacing)
|
|
553
|
+
if "constrained_layout" not in kwargs:
|
|
554
|
+
if style_constrained:
|
|
555
|
+
kwargs["constrained_layout"] = True
|
|
556
|
+
elif not use_mm_layout:
|
|
557
|
+
kwargs["constrained_layout"] = True
|
|
514
558
|
|
|
515
559
|
# Create the recording subplots
|
|
516
560
|
fig, axes = create_recording_subplots(nrows, ncols, **kwargs)
|
|
517
561
|
|
|
518
562
|
# Record constrained_layout setting for reproduction
|
|
519
|
-
fig.record.constrained_layout = kwargs.get(
|
|
563
|
+
fig.record.constrained_layout = kwargs.get("constrained_layout", False)
|
|
520
564
|
|
|
521
565
|
# Store mm_layout metadata on figure for serialization
|
|
522
|
-
if
|
|
566
|
+
# Skip mm-based layout if constrained_layout is True (they're incompatible)
|
|
567
|
+
use_constrained = kwargs.get("constrained_layout", False)
|
|
568
|
+
if mm_layout is not None and not use_constrained:
|
|
523
569
|
fig._mm_layout = mm_layout
|
|
524
570
|
|
|
525
571
|
# Apply subplots_adjust to position axes correctly
|
|
@@ -547,12 +593,12 @@ def subplots(
|
|
|
547
593
|
|
|
548
594
|
# Record layout in figure record for reproduction
|
|
549
595
|
fig.record.layout = {
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
596
|
+
"left": left,
|
|
597
|
+
"right": right,
|
|
598
|
+
"bottom": bottom,
|
|
599
|
+
"top": top,
|
|
600
|
+
"wspace": wspace,
|
|
601
|
+
"hspace": hspace,
|
|
556
602
|
}
|
|
557
603
|
|
|
558
604
|
# Apply styling if requested and a style is actually loaded
|
|
@@ -562,25 +608,32 @@ def subplots(
|
|
|
562
608
|
if style is not None:
|
|
563
609
|
# Explicit style parameter provided
|
|
564
610
|
should_apply_style = True
|
|
565
|
-
style_dict =
|
|
611
|
+
style_dict = (
|
|
612
|
+
style.to_subplots_kwargs()
|
|
613
|
+
if hasattr(style, "to_subplots_kwargs")
|
|
614
|
+
else style
|
|
615
|
+
)
|
|
566
616
|
elif apply_style_mm and global_style is not None:
|
|
567
617
|
# Use global style if loaded and has meaningful values (not MATPLOTLIB)
|
|
568
618
|
from .styles import to_subplots_kwargs
|
|
619
|
+
|
|
569
620
|
style_dict = to_subplots_kwargs(global_style)
|
|
570
621
|
# Only apply if style has essential mm values (skip MATPLOTLIB which has all None)
|
|
571
|
-
if style_dict and style_dict.get(
|
|
622
|
+
if style_dict and style_dict.get("axes_thickness_mm") is not None:
|
|
572
623
|
should_apply_style = True
|
|
573
624
|
|
|
574
625
|
if should_apply_style and style_dict:
|
|
575
626
|
from .styles import apply_style_mm as _apply_style
|
|
627
|
+
|
|
576
628
|
if nrows == 1 and ncols == 1:
|
|
577
629
|
_apply_style(axes._ax, style_dict)
|
|
578
630
|
else:
|
|
579
631
|
# Handle 2D array of axes
|
|
580
632
|
import numpy as np
|
|
633
|
+
|
|
581
634
|
axes_array = np.array(axes)
|
|
582
635
|
for ax in axes_array.flat:
|
|
583
|
-
_apply_style(ax._ax if hasattr(ax,
|
|
636
|
+
_apply_style(ax._ax if hasattr(ax, "_ax") else ax, style_dict)
|
|
584
637
|
|
|
585
638
|
# Record style in figure record for reproduction
|
|
586
639
|
fig.record.style = style_dict
|
|
@@ -676,52 +729,64 @@ def save(
|
|
|
676
729
|
)
|
|
677
730
|
|
|
678
731
|
# Determine image and YAML paths based on extension
|
|
679
|
-
IMAGE_EXTENSIONS = {
|
|
680
|
-
|
|
732
|
+
IMAGE_EXTENSIONS = {
|
|
733
|
+
".png",
|
|
734
|
+
".pdf",
|
|
735
|
+
".svg",
|
|
736
|
+
".jpg",
|
|
737
|
+
".jpeg",
|
|
738
|
+
".eps",
|
|
739
|
+
".tiff",
|
|
740
|
+
".tif",
|
|
741
|
+
}
|
|
742
|
+
YAML_EXTENSIONS = {".yaml", ".yml"}
|
|
681
743
|
|
|
682
744
|
suffix_lower = path.suffix.lower()
|
|
683
745
|
|
|
684
746
|
if suffix_lower in IMAGE_EXTENSIONS:
|
|
685
747
|
# User provided image path
|
|
686
748
|
image_path = path
|
|
687
|
-
yaml_path = path.with_suffix(
|
|
749
|
+
yaml_path = path.with_suffix(".yaml")
|
|
688
750
|
img_format = suffix_lower[1:] # Remove leading dot
|
|
689
751
|
elif suffix_lower in YAML_EXTENSIONS:
|
|
690
752
|
# User provided YAML path
|
|
691
753
|
yaml_path = path
|
|
692
754
|
# Determine image format from style or default
|
|
693
755
|
if image_format is not None:
|
|
694
|
-
img_format = image_format.lower().lstrip(
|
|
756
|
+
img_format = image_format.lower().lstrip(".")
|
|
695
757
|
else:
|
|
696
758
|
# Check global style for preferred format
|
|
697
759
|
from .styles._style_loader import _STYLE_CACHE
|
|
760
|
+
|
|
698
761
|
if _STYLE_CACHE is not None:
|
|
699
762
|
try:
|
|
700
763
|
img_format = _STYLE_CACHE.output.format.lower()
|
|
701
764
|
except (KeyError, AttributeError):
|
|
702
|
-
img_format =
|
|
765
|
+
img_format = "png"
|
|
703
766
|
else:
|
|
704
|
-
img_format =
|
|
705
|
-
image_path = path.with_suffix(f
|
|
767
|
+
img_format = "png"
|
|
768
|
+
image_path = path.with_suffix(f".{img_format}")
|
|
706
769
|
else:
|
|
707
770
|
# Unknown extension - treat as base name, add both extensions
|
|
708
|
-
yaml_path = path.with_suffix(
|
|
771
|
+
yaml_path = path.with_suffix(".yaml")
|
|
709
772
|
if image_format is not None:
|
|
710
|
-
img_format = image_format.lower().lstrip(
|
|
773
|
+
img_format = image_format.lower().lstrip(".")
|
|
711
774
|
else:
|
|
712
775
|
from .styles._style_loader import _STYLE_CACHE
|
|
776
|
+
|
|
713
777
|
if _STYLE_CACHE is not None:
|
|
714
778
|
try:
|
|
715
779
|
img_format = _STYLE_CACHE.output.format.lower()
|
|
716
780
|
except (KeyError, AttributeError):
|
|
717
|
-
img_format =
|
|
781
|
+
img_format = "png"
|
|
718
782
|
else:
|
|
719
|
-
img_format =
|
|
720
|
-
image_path = path.with_suffix(f
|
|
783
|
+
img_format = "png"
|
|
784
|
+
image_path = path.with_suffix(f".{img_format}")
|
|
721
785
|
|
|
722
786
|
# Get DPI from style if not specified
|
|
723
787
|
if dpi is None:
|
|
724
788
|
from .styles._style_loader import _STYLE_CACHE
|
|
789
|
+
|
|
725
790
|
if _STYLE_CACHE is not None:
|
|
726
791
|
try:
|
|
727
792
|
dpi = _STYLE_CACHE.output.dpi
|
|
@@ -733,31 +798,44 @@ def save(
|
|
|
733
798
|
# Get transparency setting from style
|
|
734
799
|
transparent = False
|
|
735
800
|
from .styles._style_loader import _STYLE_CACHE
|
|
801
|
+
|
|
736
802
|
if _STYLE_CACHE is not None:
|
|
737
803
|
try:
|
|
738
804
|
transparent = _STYLE_CACHE.output.transparent
|
|
739
805
|
except (KeyError, AttributeError):
|
|
740
806
|
pass
|
|
741
807
|
|
|
808
|
+
# Finalize tick configuration for all axes (avoids categorical axis interference)
|
|
809
|
+
from .styles._style_applier import finalize_ticks
|
|
810
|
+
|
|
811
|
+
for ax in fig.fig.get_axes():
|
|
812
|
+
finalize_ticks(ax)
|
|
813
|
+
|
|
742
814
|
# Save the image
|
|
743
|
-
fig.fig.savefig(image_path, dpi=dpi, bbox_inches=
|
|
815
|
+
fig.fig.savefig(image_path, dpi=dpi, bbox_inches="tight", transparent=transparent)
|
|
744
816
|
|
|
745
817
|
# Save the recipe
|
|
746
|
-
saved_yaml = fig.save_recipe(
|
|
818
|
+
saved_yaml = fig.save_recipe(
|
|
819
|
+
yaml_path, include_data=include_data, data_format=data_format
|
|
820
|
+
)
|
|
747
821
|
|
|
748
822
|
# Validate if requested
|
|
749
823
|
if validate:
|
|
750
824
|
from ._validator import validate_on_save
|
|
825
|
+
|
|
751
826
|
result = validate_on_save(fig, saved_yaml, mse_threshold=validate_mse_threshold)
|
|
752
827
|
status = "PASSED" if result.valid else "FAILED"
|
|
753
828
|
if verbose:
|
|
754
|
-
print(
|
|
829
|
+
print(
|
|
830
|
+
f"Saved: {image_path} + {yaml_path} (Reproducible Validation: {status})"
|
|
831
|
+
)
|
|
755
832
|
if not result.valid:
|
|
756
833
|
msg = f"Reproducibility validation failed (MSE={result.mse:.1f}): {result.message}"
|
|
757
834
|
if validate_error_level == "error":
|
|
758
835
|
raise ValueError(msg)
|
|
759
836
|
elif validate_error_level == "warning":
|
|
760
837
|
import warnings
|
|
838
|
+
|
|
761
839
|
warnings.warn(msg, UserWarning)
|
|
762
840
|
# "debug" level: silent, just return the result
|
|
763
841
|
return image_path, yaml_path, result
|
|
@@ -902,8 +980,17 @@ def extract_data(path: Union[str, Path]) -> Dict[str, Dict[str, Any]]:
|
|
|
902
980
|
|
|
903
981
|
# Decoration functions to skip
|
|
904
982
|
decoration_funcs = {
|
|
905
|
-
"set_xlabel",
|
|
906
|
-
"
|
|
983
|
+
"set_xlabel",
|
|
984
|
+
"set_ylabel",
|
|
985
|
+
"set_title",
|
|
986
|
+
"set_xlim",
|
|
987
|
+
"set_ylim",
|
|
988
|
+
"legend",
|
|
989
|
+
"grid",
|
|
990
|
+
"axhline",
|
|
991
|
+
"axvline",
|
|
992
|
+
"text",
|
|
993
|
+
"annotate",
|
|
907
994
|
}
|
|
908
995
|
|
|
909
996
|
for ax_key, ax_record in record.axes.items():
|
|
@@ -921,7 +1008,11 @@ def extract_data(path: Union[str, Path]) -> Dict[str, Dict[str, Any]]:
|
|
|
921
1008
|
return np.array(data["data"])
|
|
922
1009
|
if hasattr(data, "tolist"): # Already array-like
|
|
923
1010
|
return np.array(data)
|
|
924
|
-
return np.array(
|
|
1011
|
+
return np.array(
|
|
1012
|
+
list(data)
|
|
1013
|
+
if hasattr(data, "__iter__") and not isinstance(data, str)
|
|
1014
|
+
else data
|
|
1015
|
+
)
|
|
925
1016
|
|
|
926
1017
|
# Extract positional arguments based on function type
|
|
927
1018
|
if call.function in ("plot", "scatter", "fill_between"):
|
|
@@ -950,7 +1041,11 @@ def extract_data(path: Union[str, Path]) -> Dict[str, Dict[str, Any]]:
|
|
|
950
1041
|
for key in ("c", "s", "yerr", "xerr", "weights", "bins"):
|
|
951
1042
|
if key in call.kwargs:
|
|
952
1043
|
val = call.kwargs[key]
|
|
953
|
-
if
|
|
1044
|
+
if (
|
|
1045
|
+
isinstance(val, (list, tuple))
|
|
1046
|
+
or hasattr(val, "__iter__")
|
|
1047
|
+
and not isinstance(val, str)
|
|
1048
|
+
):
|
|
954
1049
|
call_data[key] = to_array(val)
|
|
955
1050
|
else:
|
|
956
1051
|
call_data[key] = val
|
|
@@ -961,10 +1056,6 @@ def extract_data(path: Union[str, Path]) -> Dict[str, Dict[str, Any]]:
|
|
|
961
1056
|
return result
|
|
962
1057
|
|
|
963
1058
|
|
|
964
|
-
# Import ValidationResult for type hints
|
|
965
|
-
from ._validator import ValidationResult, validate_recipe
|
|
966
|
-
|
|
967
|
-
|
|
968
1059
|
def validate(
|
|
969
1060
|
path: Union[str, Path],
|
|
970
1061
|
mse_threshold: float = 100.0,
|
|
@@ -1003,11 +1094,13 @@ def validate(
|
|
|
1003
1094
|
"""
|
|
1004
1095
|
# For standalone validation, we reproduce twice and compare
|
|
1005
1096
|
# (This validates the recipe is self-consistent)
|
|
1006
|
-
from ._reproducer import reproduce
|
|
1007
|
-
from ._utils._image_diff import compare_images
|
|
1008
1097
|
import tempfile
|
|
1098
|
+
|
|
1009
1099
|
import numpy as np
|
|
1010
1100
|
|
|
1101
|
+
from ._reproducer import reproduce
|
|
1102
|
+
from ._utils._image_diff import compare_images
|
|
1103
|
+
|
|
1011
1104
|
path = Path(path)
|
|
1012
1105
|
|
|
1013
1106
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
@@ -1040,7 +1133,9 @@ def validate(
|
|
|
1040
1133
|
valid=valid,
|
|
1041
1134
|
mse=mse if not np.isnan(mse) else float("inf"),
|
|
1042
1135
|
psnr=diff["psnr"],
|
|
1043
|
-
max_diff=diff["max_diff"]
|
|
1136
|
+
max_diff=diff["max_diff"]
|
|
1137
|
+
if not np.isnan(diff["max_diff"])
|
|
1138
|
+
else float("inf"),
|
|
1044
1139
|
size_original=diff["size1"],
|
|
1045
1140
|
size_reproduced=diff["size2"],
|
|
1046
1141
|
same_size=diff["same_size"],
|
|
@@ -1049,7 +1144,14 @@ def validate(
|
|
|
1049
1144
|
)
|
|
1050
1145
|
|
|
1051
1146
|
|
|
1052
|
-
def crop(
|
|
1147
|
+
def crop(
|
|
1148
|
+
input_path,
|
|
1149
|
+
output_path=None,
|
|
1150
|
+
margin_mm=1.0,
|
|
1151
|
+
margin_px=None,
|
|
1152
|
+
overwrite=False,
|
|
1153
|
+
verbose=False,
|
|
1154
|
+
):
|
|
1053
1155
|
"""Crop a figure image to its content area with a specified margin.
|
|
1054
1156
|
|
|
1055
1157
|
Automatically detects background color (from corners) and crops to
|
|
@@ -1087,4 +1189,170 @@ def crop(input_path, output_path=None, margin_mm=1.0, margin_px=None, overwrite=
|
|
|
1087
1189
|
>>> fr.crop("figure.png", margin_mm=2.0) # 2mm margin
|
|
1088
1190
|
"""
|
|
1089
1191
|
from ._utils._crop import crop as _crop
|
|
1192
|
+
|
|
1090
1193
|
return _crop(input_path, output_path, margin_mm, margin_px, overwrite, verbose)
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def edit(
|
|
1197
|
+
source,
|
|
1198
|
+
style=None,
|
|
1199
|
+
port: int = 5050,
|
|
1200
|
+
open_browser: bool = True,
|
|
1201
|
+
):
|
|
1202
|
+
"""Launch interactive GUI editor for figure styling.
|
|
1203
|
+
|
|
1204
|
+
Opens a browser-based editor that allows interactive adjustment of
|
|
1205
|
+
figure styles using hitmap-based element selection.
|
|
1206
|
+
|
|
1207
|
+
Parameters
|
|
1208
|
+
----------
|
|
1209
|
+
source : RecordingFigure, str, or Path
|
|
1210
|
+
Either a live RecordingFigure object or path to a .yaml recipe file.
|
|
1211
|
+
style : str or dict, optional
|
|
1212
|
+
Style preset name (e.g., 'SCITEX', 'SCITEX_DARK') or style dict.
|
|
1213
|
+
If None, uses the currently loaded global style.
|
|
1214
|
+
port : int, optional
|
|
1215
|
+
Flask server port (default: 5050). Auto-finds available port if occupied.
|
|
1216
|
+
open_browser : bool, optional
|
|
1217
|
+
Whether to open browser automatically (default: True).
|
|
1218
|
+
|
|
1219
|
+
Returns
|
|
1220
|
+
-------
|
|
1221
|
+
dict
|
|
1222
|
+
Final style overrides after editing session.
|
|
1223
|
+
|
|
1224
|
+
Examples
|
|
1225
|
+
--------
|
|
1226
|
+
Edit a live figure:
|
|
1227
|
+
|
|
1228
|
+
>>> import figrecipe as fr
|
|
1229
|
+
>>> fig, ax = fr.subplots()
|
|
1230
|
+
>>> ax.plot([1, 2, 3], [1, 4, 9], id='quadratic')
|
|
1231
|
+
>>> overrides = fr.edit(fig)
|
|
1232
|
+
|
|
1233
|
+
Edit a saved recipe:
|
|
1234
|
+
|
|
1235
|
+
>>> overrides = fr.edit('my_figure.yaml')
|
|
1236
|
+
|
|
1237
|
+
With explicit style:
|
|
1238
|
+
|
|
1239
|
+
>>> overrides = fr.edit(fig, style='SCITEX_DARK')
|
|
1240
|
+
|
|
1241
|
+
Notes
|
|
1242
|
+
-----
|
|
1243
|
+
Requires Flask to be installed. Install with:
|
|
1244
|
+
pip install figrecipe[editor]
|
|
1245
|
+
or:
|
|
1246
|
+
pip install flask pillow
|
|
1247
|
+
"""
|
|
1248
|
+
from ._editor import edit as _edit
|
|
1249
|
+
|
|
1250
|
+
return _edit(source, style=style, port=port, open_browser=open_browser)
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def panel_label(
|
|
1254
|
+
ax,
|
|
1255
|
+
label: str,
|
|
1256
|
+
loc: str = "upper left",
|
|
1257
|
+
fontsize: Optional[float] = None,
|
|
1258
|
+
fontweight: str = "bold",
|
|
1259
|
+
offset: Tuple[float, float] = (-0.1, 1.05),
|
|
1260
|
+
**kwargs,
|
|
1261
|
+
):
|
|
1262
|
+
"""Add a panel label (A, B, C, ...) to an axes.
|
|
1263
|
+
|
|
1264
|
+
Panel labels are commonly used in multi-panel scientific figures to
|
|
1265
|
+
identify individual subplots. This function places a label at the
|
|
1266
|
+
specified location relative to the axes.
|
|
1267
|
+
|
|
1268
|
+
Parameters
|
|
1269
|
+
----------
|
|
1270
|
+
ax : Axes or RecordingAxes
|
|
1271
|
+
The axes to label.
|
|
1272
|
+
label : str
|
|
1273
|
+
The label text (e.g., 'A', 'B', 'a)', '(1)').
|
|
1274
|
+
loc : str, optional
|
|
1275
|
+
Label location: 'upper left' (default), 'upper right',
|
|
1276
|
+
'lower left', 'lower right', or 'outside'.
|
|
1277
|
+
fontsize : float, optional
|
|
1278
|
+
Font size in points. If None, uses title font size from style or 10.
|
|
1279
|
+
fontweight : str, optional
|
|
1280
|
+
Font weight: 'bold' (default), 'normal', etc.
|
|
1281
|
+
offset : tuple of float, optional
|
|
1282
|
+
(x, y) offset in axes coordinates. Default (-0.1, 1.05) places
|
|
1283
|
+
label slightly outside top-left corner.
|
|
1284
|
+
**kwargs
|
|
1285
|
+
Additional arguments passed to ax.text().
|
|
1286
|
+
|
|
1287
|
+
Returns
|
|
1288
|
+
-------
|
|
1289
|
+
Text
|
|
1290
|
+
The matplotlib Text object.
|
|
1291
|
+
|
|
1292
|
+
Examples
|
|
1293
|
+
--------
|
|
1294
|
+
>>> import figrecipe as fr
|
|
1295
|
+
>>> fig, axes = fr.subplots(nrows=2, ncols=2)
|
|
1296
|
+
>>> for i, ax in enumerate(axes.flat):
|
|
1297
|
+
... fr.panel_label(ax, chr(65 + i)) # A, B, C, D
|
|
1298
|
+
|
|
1299
|
+
>>> # Custom styling
|
|
1300
|
+
>>> fr.panel_label(ax, 'a)', fontsize=12, fontweight='normal')
|
|
1301
|
+
|
|
1302
|
+
>>> # Outside position (default)
|
|
1303
|
+
>>> fr.panel_label(ax, 'A', loc='upper left')
|
|
1304
|
+
"""
|
|
1305
|
+
# Get fontsize from style if available, otherwise default to 10pt
|
|
1306
|
+
if fontsize is None:
|
|
1307
|
+
try:
|
|
1308
|
+
from .styles._style_loader import _STYLE_CACHE
|
|
1309
|
+
|
|
1310
|
+
if _STYLE_CACHE is not None:
|
|
1311
|
+
fontsize = getattr(
|
|
1312
|
+
getattr(_STYLE_CACHE, "fonts", None), "panel_label_pt", 10
|
|
1313
|
+
)
|
|
1314
|
+
else:
|
|
1315
|
+
fontsize = 10
|
|
1316
|
+
except Exception:
|
|
1317
|
+
fontsize = 10
|
|
1318
|
+
|
|
1319
|
+
# Calculate position based on loc
|
|
1320
|
+
if loc == "upper left":
|
|
1321
|
+
x, y = offset
|
|
1322
|
+
elif loc == "upper right":
|
|
1323
|
+
x, y = 1.0 + abs(offset[0]), offset[1]
|
|
1324
|
+
elif loc == "lower left":
|
|
1325
|
+
x, y = offset[0], -abs(offset[1]) + 1.0
|
|
1326
|
+
elif loc == "lower right":
|
|
1327
|
+
x, y = 1.0 + abs(offset[0]), -abs(offset[1]) + 1.0
|
|
1328
|
+
else:
|
|
1329
|
+
x, y = offset
|
|
1330
|
+
|
|
1331
|
+
# Default kwargs - use 'axes' as transform string (handled by reproducer)
|
|
1332
|
+
text_kwargs = {
|
|
1333
|
+
"fontsize": fontsize,
|
|
1334
|
+
"fontweight": fontweight,
|
|
1335
|
+
"transform": "axes", # Special string marker for axes coordinates
|
|
1336
|
+
"va": "bottom",
|
|
1337
|
+
"ha": "right" if "right" in loc else "left",
|
|
1338
|
+
}
|
|
1339
|
+
text_kwargs.update(kwargs)
|
|
1340
|
+
|
|
1341
|
+
# Get the underlying matplotlib axes
|
|
1342
|
+
mpl_ax = ax._ax if hasattr(ax, "_ax") else ax
|
|
1343
|
+
|
|
1344
|
+
# For actual rendering, use the real transform
|
|
1345
|
+
render_kwargs = text_kwargs.copy()
|
|
1346
|
+
render_kwargs["transform"] = mpl_ax.transAxes
|
|
1347
|
+
|
|
1348
|
+
# Record the call using recorder's method (handles args/kwargs processing)
|
|
1349
|
+
if hasattr(ax, "_recorder") and hasattr(ax, "_position"):
|
|
1350
|
+
ax._recorder.record_call(
|
|
1351
|
+
ax_position=ax._position,
|
|
1352
|
+
method_name="text",
|
|
1353
|
+
args=(x, y, label),
|
|
1354
|
+
kwargs=text_kwargs, # Contains transform: "axes"
|
|
1355
|
+
)
|
|
1356
|
+
|
|
1357
|
+
# Render directly on matplotlib axes with actual transform
|
|
1358
|
+
return mpl_ax.text(x, y, label, **render_kwargs)
|