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/_serializer.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Serialization for recipe files (YAML + data files)."""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Literal, Optional, Union
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from ruamel.yaml import YAML
|
|
10
|
+
|
|
11
|
+
from ._recorder import FigureRecord
|
|
12
|
+
from ._utils._numpy_io import save_array, load_array, DataFormat
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _convert_numpy_types(obj: Any) -> Any:
|
|
16
|
+
"""Recursively convert numpy types to Python native types.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
obj : Any
|
|
21
|
+
Object to convert.
|
|
22
|
+
|
|
23
|
+
Returns
|
|
24
|
+
-------
|
|
25
|
+
Any
|
|
26
|
+
Object with numpy types converted to native Python types.
|
|
27
|
+
"""
|
|
28
|
+
if isinstance(obj, np.ndarray):
|
|
29
|
+
return obj.tolist()
|
|
30
|
+
elif isinstance(obj, np.integer):
|
|
31
|
+
return int(obj)
|
|
32
|
+
elif isinstance(obj, np.floating):
|
|
33
|
+
return float(obj)
|
|
34
|
+
elif isinstance(obj, np.bool_):
|
|
35
|
+
return bool(obj)
|
|
36
|
+
elif isinstance(obj, dict):
|
|
37
|
+
return {k: _convert_numpy_types(v) for k, v in obj.items()}
|
|
38
|
+
elif isinstance(obj, (list, tuple)):
|
|
39
|
+
converted = [_convert_numpy_types(item) for item in obj]
|
|
40
|
+
return type(obj)(converted) if isinstance(obj, tuple) else converted
|
|
41
|
+
else:
|
|
42
|
+
return obj
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def save_recipe(
|
|
46
|
+
record: FigureRecord,
|
|
47
|
+
path: Union[str, Path],
|
|
48
|
+
include_data: bool = True,
|
|
49
|
+
data_format: DataFormat = "csv",
|
|
50
|
+
) -> Path:
|
|
51
|
+
"""Save a figure record to YAML file.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
record : FigureRecord
|
|
56
|
+
The figure record to save.
|
|
57
|
+
path : str or Path
|
|
58
|
+
Output path (.yaml).
|
|
59
|
+
include_data : bool
|
|
60
|
+
If True, save large arrays to separate files.
|
|
61
|
+
data_format : str
|
|
62
|
+
Format for data files: 'csv' (default), 'npz', or 'inline'.
|
|
63
|
+
- 'csv': Human-readable CSV files with dtype header
|
|
64
|
+
- 'npz': Compressed numpy binary format
|
|
65
|
+
- 'inline': Store all data directly in YAML (may be large)
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
Path
|
|
70
|
+
Path to saved YAML file.
|
|
71
|
+
"""
|
|
72
|
+
path = Path(path)
|
|
73
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
|
|
75
|
+
# Create data directory for large arrays
|
|
76
|
+
data_dir = path.parent / f"{path.stem}_data"
|
|
77
|
+
|
|
78
|
+
# Convert record to dict
|
|
79
|
+
data = record.to_dict()
|
|
80
|
+
|
|
81
|
+
# Process arrays: save large ones to files, update references
|
|
82
|
+
if include_data and data_format != "inline":
|
|
83
|
+
data = _process_arrays_for_save(data, data_dir, record.id, data_format)
|
|
84
|
+
|
|
85
|
+
# Convert numpy types to native Python types
|
|
86
|
+
data = _convert_numpy_types(data)
|
|
87
|
+
|
|
88
|
+
# Save YAML
|
|
89
|
+
yaml = YAML()
|
|
90
|
+
yaml.preserve_quotes = True
|
|
91
|
+
yaml.indent(mapping=2, sequence=4, offset=2)
|
|
92
|
+
|
|
93
|
+
with open(path, "w") as f:
|
|
94
|
+
yaml.dump(data, f)
|
|
95
|
+
|
|
96
|
+
return path
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _process_arrays_for_save(
|
|
100
|
+
data: Dict[str, Any],
|
|
101
|
+
data_dir: Path,
|
|
102
|
+
fig_id: str,
|
|
103
|
+
data_format: DataFormat = "csv",
|
|
104
|
+
) -> Dict[str, Any]:
|
|
105
|
+
"""Process arrays in data dict, saving large ones to files.
|
|
106
|
+
|
|
107
|
+
Parameters
|
|
108
|
+
----------
|
|
109
|
+
data : dict
|
|
110
|
+
Data dictionary to process.
|
|
111
|
+
data_dir : Path
|
|
112
|
+
Directory for array files.
|
|
113
|
+
fig_id : str
|
|
114
|
+
Figure ID for naming files.
|
|
115
|
+
data_format : str
|
|
116
|
+
Format for data files: 'csv', 'npz', or 'inline'.
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
dict
|
|
121
|
+
Processed data with file references.
|
|
122
|
+
"""
|
|
123
|
+
data_dir_created = False
|
|
124
|
+
|
|
125
|
+
for ax_key, ax_data in data.get("axes", {}).items():
|
|
126
|
+
for call_list in [ax_data.get("calls", []), ax_data.get("decorations", [])]:
|
|
127
|
+
for call in call_list:
|
|
128
|
+
call_id = call.get("id", "unknown")
|
|
129
|
+
|
|
130
|
+
# Process args
|
|
131
|
+
for i, arg in enumerate(call.get("args", [])):
|
|
132
|
+
if "_array" in arg:
|
|
133
|
+
# Large array - save to file
|
|
134
|
+
if not data_dir_created:
|
|
135
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
data_dir_created = True
|
|
137
|
+
|
|
138
|
+
arr = arg.pop("_array")
|
|
139
|
+
filename = f"{call_id}_{arg.get('name', f'arg{i}')}"
|
|
140
|
+
file_path = save_array(arr, data_dir / filename, data_format)
|
|
141
|
+
arg["data"] = str(file_path.relative_to(data_dir.parent))
|
|
142
|
+
|
|
143
|
+
return data
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def load_recipe(path: Union[str, Path]) -> FigureRecord:
|
|
147
|
+
"""Load a figure record from YAML file.
|
|
148
|
+
|
|
149
|
+
Parameters
|
|
150
|
+
----------
|
|
151
|
+
path : str or Path
|
|
152
|
+
Path to .yaml recipe file.
|
|
153
|
+
|
|
154
|
+
Returns
|
|
155
|
+
-------
|
|
156
|
+
FigureRecord
|
|
157
|
+
Loaded figure record.
|
|
158
|
+
"""
|
|
159
|
+
path = Path(path)
|
|
160
|
+
|
|
161
|
+
yaml = YAML()
|
|
162
|
+
with open(path) as f:
|
|
163
|
+
data = yaml.load(f)
|
|
164
|
+
|
|
165
|
+
# Resolve data file references
|
|
166
|
+
data = _resolve_data_references(data, path.parent)
|
|
167
|
+
|
|
168
|
+
return FigureRecord.from_dict(data)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _resolve_data_references(
|
|
172
|
+
data: Dict[str, Any],
|
|
173
|
+
base_dir: Path,
|
|
174
|
+
) -> Dict[str, Any]:
|
|
175
|
+
"""Resolve file references to actual array data.
|
|
176
|
+
|
|
177
|
+
Parameters
|
|
178
|
+
----------
|
|
179
|
+
data : dict
|
|
180
|
+
Data dictionary with file references.
|
|
181
|
+
base_dir : Path
|
|
182
|
+
Base directory for resolving relative paths.
|
|
183
|
+
|
|
184
|
+
Returns
|
|
185
|
+
-------
|
|
186
|
+
dict
|
|
187
|
+
Data with arrays loaded.
|
|
188
|
+
"""
|
|
189
|
+
for ax_key, ax_data in data.get("axes", {}).items():
|
|
190
|
+
for call_list in [ax_data.get("calls", []), ax_data.get("decorations", [])]:
|
|
191
|
+
for call in call_list:
|
|
192
|
+
for arg in call.get("args", []):
|
|
193
|
+
data_ref = arg.get("data")
|
|
194
|
+
|
|
195
|
+
# Check if it's a file reference
|
|
196
|
+
if isinstance(data_ref, str) and (
|
|
197
|
+
data_ref.endswith(".npy")
|
|
198
|
+
or data_ref.endswith(".npz")
|
|
199
|
+
or data_ref.endswith(".csv")
|
|
200
|
+
):
|
|
201
|
+
file_path = base_dir / data_ref
|
|
202
|
+
if file_path.exists():
|
|
203
|
+
arr = load_array(file_path)
|
|
204
|
+
arg["data"] = arr.tolist()
|
|
205
|
+
arg["_loaded_array"] = arr
|
|
206
|
+
|
|
207
|
+
return data
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def recipe_to_dict(path: Union[str, Path]) -> Dict[str, Any]:
|
|
211
|
+
"""Load recipe as raw dictionary (for inspection).
|
|
212
|
+
|
|
213
|
+
Parameters
|
|
214
|
+
----------
|
|
215
|
+
path : str or Path
|
|
216
|
+
Path to .yaml recipe file.
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
dict
|
|
221
|
+
Raw recipe data.
|
|
222
|
+
"""
|
|
223
|
+
path = Path(path)
|
|
224
|
+
|
|
225
|
+
yaml = YAML()
|
|
226
|
+
with open(path) as f:
|
|
227
|
+
return dict(yaml.load(f))
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Load and query matplotlib function signatures."""
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from typing import Any, Dict, List, Optional, Set
|
|
7
|
+
|
|
8
|
+
import matplotlib.pyplot as plt
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Cache for signatures
|
|
12
|
+
_SIGNATURE_CACHE: Dict[str, Dict[str, Any]] = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_signature(method_name: str) -> Dict[str, Any]:
|
|
16
|
+
"""Get signature for a matplotlib Axes method.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
method_name : str
|
|
21
|
+
Name of the method (e.g., 'plot', 'scatter').
|
|
22
|
+
|
|
23
|
+
Returns
|
|
24
|
+
-------
|
|
25
|
+
dict
|
|
26
|
+
Signature information with 'args' and 'kwargs' keys.
|
|
27
|
+
"""
|
|
28
|
+
if method_name in _SIGNATURE_CACHE:
|
|
29
|
+
return _SIGNATURE_CACHE[method_name]
|
|
30
|
+
|
|
31
|
+
# Create a temporary axes to introspect
|
|
32
|
+
fig, ax = plt.subplots()
|
|
33
|
+
plt.close(fig)
|
|
34
|
+
|
|
35
|
+
method = getattr(ax, method_name, None)
|
|
36
|
+
if method is None:
|
|
37
|
+
return {"args": [], "kwargs": {}}
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
sig = inspect.signature(method)
|
|
41
|
+
except (ValueError, TypeError):
|
|
42
|
+
return {"args": [], "kwargs": {}}
|
|
43
|
+
|
|
44
|
+
args = []
|
|
45
|
+
kwargs = {}
|
|
46
|
+
|
|
47
|
+
for name, param in sig.parameters.items():
|
|
48
|
+
if name == "self":
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
if param.kind == inspect.Parameter.VAR_POSITIONAL:
|
|
52
|
+
args.append({"name": f"*{name}", "type": "*args"})
|
|
53
|
+
elif param.kind == inspect.Parameter.VAR_KEYWORD:
|
|
54
|
+
kwargs["**kwargs"] = {"type": "**kwargs"}
|
|
55
|
+
elif param.default is inspect.Parameter.empty:
|
|
56
|
+
# Positional argument
|
|
57
|
+
args.append({
|
|
58
|
+
"name": name,
|
|
59
|
+
"type": _get_type_str(param.annotation),
|
|
60
|
+
})
|
|
61
|
+
else:
|
|
62
|
+
# Keyword argument with default
|
|
63
|
+
kwargs[name] = {
|
|
64
|
+
"type": _get_type_str(param.annotation),
|
|
65
|
+
"default": _serialize_default(param.default),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
result = {"args": args, "kwargs": kwargs}
|
|
69
|
+
_SIGNATURE_CACHE[method_name] = result
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _get_type_str(annotation) -> Optional[str]:
|
|
74
|
+
"""Convert annotation to string."""
|
|
75
|
+
if annotation is inspect.Parameter.empty:
|
|
76
|
+
return None
|
|
77
|
+
if hasattr(annotation, "__name__"):
|
|
78
|
+
return annotation.__name__
|
|
79
|
+
return str(annotation)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _serialize_default(default) -> Any:
|
|
83
|
+
"""Serialize default value."""
|
|
84
|
+
if default is inspect.Parameter.empty:
|
|
85
|
+
return None
|
|
86
|
+
if callable(default):
|
|
87
|
+
return f"<{type(default).__name__}>"
|
|
88
|
+
try:
|
|
89
|
+
import json
|
|
90
|
+
json.dumps(default)
|
|
91
|
+
return default
|
|
92
|
+
except (TypeError, ValueError):
|
|
93
|
+
return repr(default)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_defaults(method_name: str) -> Dict[str, Any]:
|
|
97
|
+
"""Get default values for a method's kwargs.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
method_name : str
|
|
102
|
+
Name of the method.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
dict
|
|
107
|
+
Mapping of kwarg names to default values.
|
|
108
|
+
"""
|
|
109
|
+
sig = get_signature(method_name)
|
|
110
|
+
defaults = {}
|
|
111
|
+
|
|
112
|
+
for name, info in sig.get("kwargs", {}).items():
|
|
113
|
+
if name != "**kwargs" and "default" in info:
|
|
114
|
+
defaults[name] = info["default"]
|
|
115
|
+
|
|
116
|
+
return defaults
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def validate_kwargs(
|
|
120
|
+
method_name: str,
|
|
121
|
+
kwargs: Dict[str, Any],
|
|
122
|
+
) -> Dict[str, List[str]]:
|
|
123
|
+
"""Validate kwargs against method signature.
|
|
124
|
+
|
|
125
|
+
Parameters
|
|
126
|
+
----------
|
|
127
|
+
method_name : str
|
|
128
|
+
Name of the method.
|
|
129
|
+
kwargs : dict
|
|
130
|
+
Kwargs to validate.
|
|
131
|
+
|
|
132
|
+
Returns
|
|
133
|
+
-------
|
|
134
|
+
dict
|
|
135
|
+
Validation result with 'valid', 'unknown', and 'missing' keys.
|
|
136
|
+
"""
|
|
137
|
+
sig = get_signature(method_name)
|
|
138
|
+
known_kwargs = set(sig.get("kwargs", {}).keys()) - {"**kwargs"}
|
|
139
|
+
|
|
140
|
+
# If method accepts **kwargs, all are valid
|
|
141
|
+
if "**kwargs" in sig.get("kwargs", {}):
|
|
142
|
+
return {
|
|
143
|
+
"valid": list(kwargs.keys()),
|
|
144
|
+
"unknown": [],
|
|
145
|
+
"missing": [],
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
valid = []
|
|
149
|
+
unknown = []
|
|
150
|
+
|
|
151
|
+
for key in kwargs:
|
|
152
|
+
if key in known_kwargs:
|
|
153
|
+
valid.append(key)
|
|
154
|
+
else:
|
|
155
|
+
unknown.append(key)
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
"valid": valid,
|
|
159
|
+
"unknown": unknown,
|
|
160
|
+
"missing": [], # Not checking required kwargs for now
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def list_plotting_methods() -> List[str]:
|
|
165
|
+
"""List all available plotting methods.
|
|
166
|
+
|
|
167
|
+
Returns
|
|
168
|
+
-------
|
|
169
|
+
list
|
|
170
|
+
Names of plotting methods.
|
|
171
|
+
"""
|
|
172
|
+
fig, ax = plt.subplots()
|
|
173
|
+
plt.close(fig)
|
|
174
|
+
|
|
175
|
+
# Common plotting methods
|
|
176
|
+
methods = [
|
|
177
|
+
"plot", "scatter", "bar", "barh", "hist", "hist2d",
|
|
178
|
+
"boxplot", "violinplot", "pie", "errorbar", "fill",
|
|
179
|
+
"fill_between", "fill_betweenx", "stackplot", "stem",
|
|
180
|
+
"step", "imshow", "pcolor", "pcolormesh", "contour",
|
|
181
|
+
"contourf", "quiver", "barbs", "streamplot", "hexbin",
|
|
182
|
+
"eventplot", "stairs", "ecdf",
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
# Filter to only those that exist
|
|
186
|
+
return [m for m in methods if hasattr(ax, m)]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Utility modules for figrecipe."""
|
|
4
|
+
|
|
5
|
+
from ._numpy_io import load_array, save_array
|
|
6
|
+
from ._diff import get_non_default_kwargs, is_default_value
|
|
7
|
+
from ._units import mm_to_inch, inch_to_mm, mm_to_pt, pt_to_mm
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"save_array",
|
|
11
|
+
"load_array",
|
|
12
|
+
"get_non_default_kwargs",
|
|
13
|
+
"is_default_value",
|
|
14
|
+
"mm_to_inch",
|
|
15
|
+
"inch_to_mm",
|
|
16
|
+
"mm_to_pt",
|
|
17
|
+
"pt_to_mm",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
# Optional: image comparison (requires PIL)
|
|
21
|
+
try:
|
|
22
|
+
from ._image_diff import compare_images, create_comparison_figure
|
|
23
|
+
__all__.extend(["compare_images", "create_comparison_figure"])
|
|
24
|
+
except ImportError:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
# Optional: crop utility (requires PIL)
|
|
28
|
+
try:
|
|
29
|
+
from ._crop import crop, find_content_area
|
|
30
|
+
__all__.extend(["crop", "find_content_area"])
|
|
31
|
+
except ImportError:
|
|
32
|
+
pass
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Auto-crop figures to content area with optional margin.
|
|
4
|
+
|
|
5
|
+
This utility automatically detects the content area of saved figures
|
|
6
|
+
and crops them, removing excess whitespace while preserving a specified margin.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__all__ = ["crop", "find_content_area", "mm_to_pixels"]
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional, Tuple, Union
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_content_area(image_path: Union[str, Path]) -> Tuple[int, int, int, int]:
|
|
19
|
+
"""Find the bounding box of the content area in an image.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
image_path : str or Path
|
|
24
|
+
Path to the image file
|
|
25
|
+
|
|
26
|
+
Returns
|
|
27
|
+
-------
|
|
28
|
+
tuple
|
|
29
|
+
(left, upper, right, lower) bounding box coordinates
|
|
30
|
+
|
|
31
|
+
Raises
|
|
32
|
+
------
|
|
33
|
+
FileNotFoundError
|
|
34
|
+
If the image cannot be read
|
|
35
|
+
"""
|
|
36
|
+
from PIL import Image
|
|
37
|
+
|
|
38
|
+
img = Image.open(image_path)
|
|
39
|
+
img_array = np.array(img)
|
|
40
|
+
|
|
41
|
+
# Check if image has alpha channel (RGBA)
|
|
42
|
+
if len(img_array.shape) == 3 and img_array.shape[2] == 4:
|
|
43
|
+
# Use alpha channel to find content (non-transparent pixels)
|
|
44
|
+
alpha = img_array[:, :, 3]
|
|
45
|
+
rows = np.any(alpha > 0, axis=1)
|
|
46
|
+
cols = np.any(alpha > 0, axis=0)
|
|
47
|
+
else:
|
|
48
|
+
# For RGB images, detect background color from corners
|
|
49
|
+
if len(img_array.shape) == 3:
|
|
50
|
+
h, w = img_array.shape[:2]
|
|
51
|
+
corners = [
|
|
52
|
+
img_array[0, 0],
|
|
53
|
+
img_array[0, w - 1],
|
|
54
|
+
img_array[h - 1, 0],
|
|
55
|
+
img_array[h - 1, w - 1],
|
|
56
|
+
]
|
|
57
|
+
bg_color = np.median(corners, axis=0).astype(np.uint8)
|
|
58
|
+
diff = np.abs(img_array.astype(np.int16) - bg_color.astype(np.int16))
|
|
59
|
+
is_content = np.any(diff > 10, axis=2)
|
|
60
|
+
else:
|
|
61
|
+
# Grayscale
|
|
62
|
+
h, w = img_array.shape
|
|
63
|
+
corners = [
|
|
64
|
+
img_array[0, 0],
|
|
65
|
+
img_array[0, w - 1],
|
|
66
|
+
img_array[h - 1, 0],
|
|
67
|
+
img_array[h - 1, w - 1],
|
|
68
|
+
]
|
|
69
|
+
bg_value = np.median(corners)
|
|
70
|
+
is_content = np.abs(img_array.astype(np.int16) - bg_value) > 10
|
|
71
|
+
|
|
72
|
+
rows = np.any(is_content, axis=1)
|
|
73
|
+
cols = np.any(is_content, axis=0)
|
|
74
|
+
|
|
75
|
+
if np.any(rows) and np.any(cols):
|
|
76
|
+
y_min, y_max = np.where(rows)[0][[0, -1]]
|
|
77
|
+
x_min, x_max = np.where(cols)[0][[0, -1]]
|
|
78
|
+
return x_min, y_min, x_max + 1, y_max + 1
|
|
79
|
+
else:
|
|
80
|
+
return 0, 0, img.width, img.height
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def mm_to_pixels(mm: float, dpi: int = 300) -> int:
|
|
84
|
+
"""Convert millimeters to pixels at given DPI.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
mm : float
|
|
89
|
+
Size in millimeters
|
|
90
|
+
dpi : int
|
|
91
|
+
Resolution in dots per inch (default: 300)
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
int
|
|
96
|
+
Size in pixels (rounded)
|
|
97
|
+
"""
|
|
98
|
+
return round(mm * dpi / 25.4)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def crop(
|
|
102
|
+
input_path: Union[str, Path],
|
|
103
|
+
output_path: Optional[Union[str, Path]] = None,
|
|
104
|
+
margin_mm: float = 1.0,
|
|
105
|
+
margin_px: Optional[int] = None,
|
|
106
|
+
overwrite: bool = False,
|
|
107
|
+
verbose: bool = False,
|
|
108
|
+
return_offset: bool = False,
|
|
109
|
+
crop_box: Optional[Tuple[int, int, int, int]] = None,
|
|
110
|
+
) -> Union[Path, Tuple[Path, dict]]:
|
|
111
|
+
"""Crop a figure image to its content area with a specified margin.
|
|
112
|
+
|
|
113
|
+
Automatically detects background color (from corners) and crops to
|
|
114
|
+
content, leaving only the specified margin around it.
|
|
115
|
+
|
|
116
|
+
Parameters
|
|
117
|
+
----------
|
|
118
|
+
input_path : str or Path
|
|
119
|
+
Path to the input image (PNG, JPEG, etc.)
|
|
120
|
+
output_path : str or Path, optional
|
|
121
|
+
Path to save the cropped image. If None and overwrite=True,
|
|
122
|
+
overwrites the input. If None and overwrite=False, adds '_cropped' suffix.
|
|
123
|
+
margin_mm : float, optional
|
|
124
|
+
Margin in millimeters to keep around content (default: 1.0mm).
|
|
125
|
+
Converted to pixels using image DPI (or 300 DPI if not available).
|
|
126
|
+
margin_px : int, optional
|
|
127
|
+
Margin in pixels (overrides margin_mm if provided).
|
|
128
|
+
overwrite : bool, optional
|
|
129
|
+
Whether to overwrite the input file (default: False)
|
|
130
|
+
verbose : bool, optional
|
|
131
|
+
Whether to print detailed information (default: False)
|
|
132
|
+
return_offset : bool, optional
|
|
133
|
+
If True, also return the crop offset for metadata adjustment.
|
|
134
|
+
crop_box : tuple, optional
|
|
135
|
+
Explicit crop coordinates (left, upper, right, lower). If provided,
|
|
136
|
+
skips auto-detection and uses these exact coordinates.
|
|
137
|
+
|
|
138
|
+
Returns
|
|
139
|
+
-------
|
|
140
|
+
Path or tuple
|
|
141
|
+
Path to the saved cropped image. If return_offset=True, returns
|
|
142
|
+
(path, offset_dict) with crop boundaries.
|
|
143
|
+
|
|
144
|
+
Examples
|
|
145
|
+
--------
|
|
146
|
+
>>> import figrecipe as fr
|
|
147
|
+
>>> fig, ax = fr.subplots(axes_width_mm=60, axes_height_mm=40)
|
|
148
|
+
>>> ax.plot([1, 2, 3], [1, 2, 3], id='line')
|
|
149
|
+
>>> fig.savefig("figure.png", dpi=300)
|
|
150
|
+
>>> fr.crop("figure.png", overwrite=True) # 1mm margin
|
|
151
|
+
>>> fr.crop("figure.png", margin_mm=2.0) # 2mm margin
|
|
152
|
+
>>> fr.crop("figure.png", margin_px=24) # explicit 24 pixels
|
|
153
|
+
"""
|
|
154
|
+
from PIL import Image
|
|
155
|
+
|
|
156
|
+
input_path = Path(input_path)
|
|
157
|
+
|
|
158
|
+
# Determine output path
|
|
159
|
+
if output_path is None:
|
|
160
|
+
if overwrite:
|
|
161
|
+
output_path = input_path
|
|
162
|
+
else:
|
|
163
|
+
output_path = input_path.with_stem(f"{input_path.stem}_cropped")
|
|
164
|
+
else:
|
|
165
|
+
output_path = Path(output_path)
|
|
166
|
+
|
|
167
|
+
img = Image.open(input_path)
|
|
168
|
+
original_width, original_height = img.size
|
|
169
|
+
|
|
170
|
+
# Get DPI from image metadata (default to 300 if not available)
|
|
171
|
+
dpi = 300
|
|
172
|
+
if "dpi" in img.info:
|
|
173
|
+
dpi_info = img.info["dpi"]
|
|
174
|
+
if isinstance(dpi_info, tuple):
|
|
175
|
+
dpi = int(dpi_info[0]) # Use horizontal DPI
|
|
176
|
+
else:
|
|
177
|
+
dpi = int(dpi_info)
|
|
178
|
+
|
|
179
|
+
# Calculate margin in pixels
|
|
180
|
+
if margin_px is not None:
|
|
181
|
+
margin = margin_px
|
|
182
|
+
else:
|
|
183
|
+
margin = mm_to_pixels(margin_mm, dpi)
|
|
184
|
+
|
|
185
|
+
if verbose:
|
|
186
|
+
print(f"Original: {original_width}x{original_height}")
|
|
187
|
+
print(f"DPI: {dpi}")
|
|
188
|
+
print(f"Margin: {margin_mm}mm = {margin}px")
|
|
189
|
+
|
|
190
|
+
# Use explicit crop_box or auto-detect
|
|
191
|
+
if crop_box is not None:
|
|
192
|
+
left, upper, right, lower = crop_box
|
|
193
|
+
if verbose:
|
|
194
|
+
print(f"Using explicit crop_box: {crop_box}")
|
|
195
|
+
else:
|
|
196
|
+
left, upper, right, lower = find_content_area(input_path)
|
|
197
|
+
if verbose:
|
|
198
|
+
print(f"Content area: left={left}, upper={upper}, right={right}, lower={lower}")
|
|
199
|
+
|
|
200
|
+
# Add margin, clamping to image boundaries
|
|
201
|
+
left = max(left - margin, 0)
|
|
202
|
+
upper = max(upper - margin, 0)
|
|
203
|
+
right = min(right + margin, img.width)
|
|
204
|
+
lower = min(lower + margin, img.height)
|
|
205
|
+
|
|
206
|
+
if verbose:
|
|
207
|
+
print(f"Cropping to: {left},{upper} -> {right},{lower}")
|
|
208
|
+
print(f"New size: {right - left}x{lower - upper}")
|
|
209
|
+
|
|
210
|
+
# Crop the image
|
|
211
|
+
cropped_img = img.crop((left, upper, right, lower))
|
|
212
|
+
|
|
213
|
+
# Preserve metadata
|
|
214
|
+
save_kwargs = {}
|
|
215
|
+
if "dpi" in img.info:
|
|
216
|
+
save_kwargs["dpi"] = img.info["dpi"]
|
|
217
|
+
|
|
218
|
+
ext = output_path.suffix.lower()
|
|
219
|
+
if ext == ".png":
|
|
220
|
+
save_kwargs["compress_level"] = 0
|
|
221
|
+
save_kwargs["optimize"] = False
|
|
222
|
+
|
|
223
|
+
# Preserve PNG text chunks
|
|
224
|
+
from PIL import PngImagePlugin
|
|
225
|
+
pnginfo = PngImagePlugin.PngInfo()
|
|
226
|
+
for key, value in img.info.items():
|
|
227
|
+
if isinstance(value, (str, bytes)):
|
|
228
|
+
try:
|
|
229
|
+
pnginfo.add_text(key, str(value) if isinstance(value, bytes) else value)
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
save_kwargs["pnginfo"] = pnginfo
|
|
233
|
+
|
|
234
|
+
elif ext in [".jpg", ".jpeg"]:
|
|
235
|
+
save_kwargs["quality"] = 100
|
|
236
|
+
save_kwargs["subsampling"] = 0
|
|
237
|
+
save_kwargs["optimize"] = False
|
|
238
|
+
|
|
239
|
+
cropped_img.save(output_path, **save_kwargs)
|
|
240
|
+
|
|
241
|
+
final_width, final_height = cropped_img.size
|
|
242
|
+
if verbose:
|
|
243
|
+
area_reduction = 1 - ((final_width * final_height) / (original_width * original_height))
|
|
244
|
+
print(f"Saved {area_reduction * 100:.1f}% of original area")
|
|
245
|
+
if output_path != input_path:
|
|
246
|
+
print(f"Saved to: {output_path}")
|
|
247
|
+
|
|
248
|
+
if return_offset:
|
|
249
|
+
offset = {
|
|
250
|
+
"left": left,
|
|
251
|
+
"upper": upper,
|
|
252
|
+
"right": right,
|
|
253
|
+
"lower": lower,
|
|
254
|
+
"original_width": original_width,
|
|
255
|
+
"original_height": original_height,
|
|
256
|
+
"new_width": final_width,
|
|
257
|
+
"new_height": final_height,
|
|
258
|
+
}
|
|
259
|
+
return output_path, offset
|
|
260
|
+
|
|
261
|
+
return output_path
|