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
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Utilities for comparing default vs passed values."""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_default_value(value: Any, default: Any) -> bool:
|
|
11
|
+
"""Check if value equals the default.
|
|
12
|
+
|
|
13
|
+
Parameters
|
|
14
|
+
----------
|
|
15
|
+
value : Any
|
|
16
|
+
Value to check.
|
|
17
|
+
default : Any
|
|
18
|
+
Default value.
|
|
19
|
+
|
|
20
|
+
Returns
|
|
21
|
+
-------
|
|
22
|
+
bool
|
|
23
|
+
True if value equals default.
|
|
24
|
+
"""
|
|
25
|
+
# Handle None
|
|
26
|
+
if value is None and default is None:
|
|
27
|
+
return True
|
|
28
|
+
|
|
29
|
+
# Handle numpy arrays
|
|
30
|
+
if isinstance(value, np.ndarray) or isinstance(default, np.ndarray):
|
|
31
|
+
try:
|
|
32
|
+
return np.array_equal(value, default)
|
|
33
|
+
except (TypeError, ValueError):
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
# Handle callables
|
|
37
|
+
if callable(value) or callable(default):
|
|
38
|
+
return value is default
|
|
39
|
+
|
|
40
|
+
# Standard comparison
|
|
41
|
+
try:
|
|
42
|
+
return value == default
|
|
43
|
+
except (TypeError, ValueError):
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_non_default_kwargs(
|
|
48
|
+
passed_kwargs: Dict[str, Any],
|
|
49
|
+
signature_defaults: Optional[Dict[str, Any]] = None,
|
|
50
|
+
) -> Dict[str, Any]:
|
|
51
|
+
"""Filter kwargs to only include non-default values.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
passed_kwargs : dict
|
|
56
|
+
Kwargs that were passed to the function.
|
|
57
|
+
signature_defaults : dict, optional
|
|
58
|
+
Default values from function signature.
|
|
59
|
+
|
|
60
|
+
Returns
|
|
61
|
+
-------
|
|
62
|
+
dict
|
|
63
|
+
Only kwargs that differ from defaults.
|
|
64
|
+
"""
|
|
65
|
+
if signature_defaults is None:
|
|
66
|
+
# If no defaults provided, return all kwargs
|
|
67
|
+
return passed_kwargs.copy()
|
|
68
|
+
|
|
69
|
+
non_default = {}
|
|
70
|
+
for key, value in passed_kwargs.items():
|
|
71
|
+
default = signature_defaults.get(key)
|
|
72
|
+
if not is_default_value(value, default):
|
|
73
|
+
non_default[key] = value
|
|
74
|
+
|
|
75
|
+
return non_default
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def merge_with_defaults(
|
|
79
|
+
stored_kwargs: Dict[str, Any],
|
|
80
|
+
signature_defaults: Dict[str, Any],
|
|
81
|
+
) -> Dict[str, Any]:
|
|
82
|
+
"""Merge stored kwargs with defaults for reproduction.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
stored_kwargs : dict
|
|
87
|
+
Non-default kwargs that were stored.
|
|
88
|
+
signature_defaults : dict
|
|
89
|
+
Default values from function signature.
|
|
90
|
+
|
|
91
|
+
Returns
|
|
92
|
+
-------
|
|
93
|
+
dict
|
|
94
|
+
Complete kwargs for function call.
|
|
95
|
+
"""
|
|
96
|
+
merged = signature_defaults.copy()
|
|
97
|
+
merged.update(stored_kwargs)
|
|
98
|
+
return merged
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Image comparison utilities for roundtrip testing."""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Tuple, Union
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_image(path: Union[str, Path]) -> np.ndarray:
|
|
12
|
+
"""Load image as numpy array.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
path : str or Path
|
|
17
|
+
Path to image file.
|
|
18
|
+
|
|
19
|
+
Returns
|
|
20
|
+
-------
|
|
21
|
+
np.ndarray
|
|
22
|
+
Image as (H, W, C) array with values 0-255.
|
|
23
|
+
"""
|
|
24
|
+
from PIL import Image
|
|
25
|
+
img = Image.open(path).convert('RGB')
|
|
26
|
+
return np.array(img)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def compute_diff(
|
|
30
|
+
img1: np.ndarray,
|
|
31
|
+
img2: np.ndarray,
|
|
32
|
+
) -> Tuple[float, np.ndarray]:
|
|
33
|
+
"""Compute pixel-level difference between two images.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
img1 : np.ndarray
|
|
38
|
+
First image (H, W, C).
|
|
39
|
+
img2 : np.ndarray
|
|
40
|
+
Second image (H, W, C).
|
|
41
|
+
|
|
42
|
+
Returns
|
|
43
|
+
-------
|
|
44
|
+
mse : float
|
|
45
|
+
Mean squared error (0 = identical).
|
|
46
|
+
diff_img : np.ndarray
|
|
47
|
+
Difference image (absolute difference).
|
|
48
|
+
"""
|
|
49
|
+
# Ensure same shape
|
|
50
|
+
if img1.shape != img2.shape:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"Image shapes differ: {img1.shape} vs {img2.shape}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Compute difference
|
|
56
|
+
diff = np.abs(img1.astype(float) - img2.astype(float))
|
|
57
|
+
mse = np.mean(diff ** 2)
|
|
58
|
+
|
|
59
|
+
# Normalize diff for visualization
|
|
60
|
+
diff_img = (diff / diff.max() * 255).astype(np.uint8) if diff.max() > 0 else diff.astype(np.uint8)
|
|
61
|
+
|
|
62
|
+
return mse, diff_img
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def compare_images(
|
|
66
|
+
path1: Union[str, Path],
|
|
67
|
+
path2: Union[str, Path],
|
|
68
|
+
diff_path: Union[str, Path] = None,
|
|
69
|
+
) -> dict:
|
|
70
|
+
"""Compare two image files.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
path1 : str or Path
|
|
75
|
+
Path to first image.
|
|
76
|
+
path2 : str or Path
|
|
77
|
+
Path to second image.
|
|
78
|
+
diff_path : str or Path, optional
|
|
79
|
+
If provided, save difference image here.
|
|
80
|
+
|
|
81
|
+
Returns
|
|
82
|
+
-------
|
|
83
|
+
dict
|
|
84
|
+
Comparison results:
|
|
85
|
+
- identical: bool (True if MSE == 0)
|
|
86
|
+
- mse: float (mean squared error)
|
|
87
|
+
- psnr: float (peak signal-to-noise ratio, inf if identical)
|
|
88
|
+
- max_diff: float (maximum pixel difference)
|
|
89
|
+
- size1: tuple (H, W) of first image
|
|
90
|
+
- size2: tuple (H, W) of second image
|
|
91
|
+
- same_size: bool (True if dimensions match)
|
|
92
|
+
- file_size1: int (file size in bytes)
|
|
93
|
+
- file_size2: int (file size in bytes)
|
|
94
|
+
"""
|
|
95
|
+
import os
|
|
96
|
+
|
|
97
|
+
img1 = load_image(path1)
|
|
98
|
+
img2 = load_image(path2)
|
|
99
|
+
|
|
100
|
+
# File sizes
|
|
101
|
+
file_size1 = os.path.getsize(path1)
|
|
102
|
+
file_size2 = os.path.getsize(path2)
|
|
103
|
+
|
|
104
|
+
# Check if same size
|
|
105
|
+
same_size = img1.shape == img2.shape
|
|
106
|
+
|
|
107
|
+
if same_size:
|
|
108
|
+
mse, diff_img = compute_diff(img1, img2)
|
|
109
|
+
else:
|
|
110
|
+
# Can't compute pixel diff for different sizes
|
|
111
|
+
mse = float('nan')
|
|
112
|
+
diff_img = None
|
|
113
|
+
|
|
114
|
+
# Peak signal-to-noise ratio
|
|
115
|
+
if mse == 0:
|
|
116
|
+
psnr = float('inf')
|
|
117
|
+
elif np.isnan(mse):
|
|
118
|
+
psnr = float('nan')
|
|
119
|
+
else:
|
|
120
|
+
psnr = 10 * np.log10(255**2 / mse)
|
|
121
|
+
|
|
122
|
+
# Max difference
|
|
123
|
+
if same_size:
|
|
124
|
+
max_diff = np.max(np.abs(img1.astype(float) - img2.astype(float)))
|
|
125
|
+
else:
|
|
126
|
+
max_diff = float('nan')
|
|
127
|
+
|
|
128
|
+
# Save diff image if requested
|
|
129
|
+
if diff_path is not None and diff_img is not None:
|
|
130
|
+
from PIL import Image
|
|
131
|
+
Image.fromarray(diff_img).save(diff_path)
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
'identical': mse == 0,
|
|
135
|
+
'mse': float(mse),
|
|
136
|
+
'psnr': float(psnr),
|
|
137
|
+
'max_diff': float(max_diff),
|
|
138
|
+
'size1': (img1.shape[0], img1.shape[1]),
|
|
139
|
+
'size2': (img2.shape[0], img2.shape[1]),
|
|
140
|
+
'same_size': img1.shape == img2.shape,
|
|
141
|
+
'file_size1': file_size1,
|
|
142
|
+
'file_size2': file_size2,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def create_comparison_figure(
|
|
147
|
+
original_path: Union[str, Path],
|
|
148
|
+
reproduced_path: Union[str, Path],
|
|
149
|
+
output_path: Union[str, Path],
|
|
150
|
+
title: str = "Roundtrip Comparison",
|
|
151
|
+
):
|
|
152
|
+
"""Create a side-by-side comparison figure.
|
|
153
|
+
|
|
154
|
+
Parameters
|
|
155
|
+
----------
|
|
156
|
+
original_path : str or Path
|
|
157
|
+
Path to original image.
|
|
158
|
+
reproduced_path : str or Path
|
|
159
|
+
Path to reproduced image.
|
|
160
|
+
output_path : str or Path
|
|
161
|
+
Path to save comparison figure.
|
|
162
|
+
title : str
|
|
163
|
+
Title for the comparison.
|
|
164
|
+
"""
|
|
165
|
+
import matplotlib.pyplot as plt
|
|
166
|
+
|
|
167
|
+
img1 = load_image(original_path)
|
|
168
|
+
img2 = load_image(reproduced_path)
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
mse, diff_img = compute_diff(img1, img2)
|
|
172
|
+
except ValueError:
|
|
173
|
+
# Different sizes, just show side by side
|
|
174
|
+
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
|
|
175
|
+
axes[0].imshow(img1)
|
|
176
|
+
axes[0].set_title('Original')
|
|
177
|
+
axes[0].axis('off')
|
|
178
|
+
axes[1].imshow(img2)
|
|
179
|
+
axes[1].set_title('Reproduced')
|
|
180
|
+
axes[1].axis('off')
|
|
181
|
+
fig.suptitle(f'{title}\n(Different sizes)', fontsize=14)
|
|
182
|
+
fig.savefig(output_path, dpi=150, bbox_inches='tight', facecolor='white')
|
|
183
|
+
plt.close(fig)
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
|
|
187
|
+
|
|
188
|
+
axes[0].imshow(img1)
|
|
189
|
+
axes[0].set_title('Original')
|
|
190
|
+
axes[0].axis('off')
|
|
191
|
+
|
|
192
|
+
axes[1].imshow(img2)
|
|
193
|
+
axes[1].set_title('Reproduced')
|
|
194
|
+
axes[1].axis('off')
|
|
195
|
+
|
|
196
|
+
axes[2].imshow(diff_img)
|
|
197
|
+
axes[2].set_title(f'Difference (MSE: {mse:.2f})')
|
|
198
|
+
axes[2].axis('off')
|
|
199
|
+
|
|
200
|
+
status = "IDENTICAL" if mse == 0 else f"MSE: {mse:.2f}"
|
|
201
|
+
fig.suptitle(f'{title}\n{status}', fontsize=14, fontweight='bold')
|
|
202
|
+
|
|
203
|
+
fig.savefig(output_path, dpi=150, bbox_inches='tight', facecolor='white')
|
|
204
|
+
plt.close(fig)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""NumPy array I/O utilities for figrecipe."""
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal, Union
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Threshold for inline vs file storage (in elements)
|
|
13
|
+
INLINE_THRESHOLD = 100
|
|
14
|
+
|
|
15
|
+
# Data format type
|
|
16
|
+
DataFormat = Literal["csv", "npz", "inline"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def should_store_inline(data: np.ndarray) -> bool:
|
|
20
|
+
"""Determine if array should be stored inline or as file.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
data : np.ndarray
|
|
25
|
+
Array to check.
|
|
26
|
+
|
|
27
|
+
Returns
|
|
28
|
+
-------
|
|
29
|
+
bool
|
|
30
|
+
True if array is small enough for inline storage.
|
|
31
|
+
"""
|
|
32
|
+
return data.size <= INLINE_THRESHOLD
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def save_array(
|
|
36
|
+
data: np.ndarray,
|
|
37
|
+
path: Union[str, Path],
|
|
38
|
+
data_format: DataFormat = "csv",
|
|
39
|
+
) -> Path:
|
|
40
|
+
"""Save numpy array to file.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
data : np.ndarray
|
|
45
|
+
Array to save.
|
|
46
|
+
path : str or Path
|
|
47
|
+
Output file path (extension will be set based on format).
|
|
48
|
+
data_format : str
|
|
49
|
+
Format to use: 'csv' (default), 'npz', or 'inline'.
|
|
50
|
+
|
|
51
|
+
Returns
|
|
52
|
+
-------
|
|
53
|
+
Path
|
|
54
|
+
Path to saved file.
|
|
55
|
+
"""
|
|
56
|
+
path = Path(path)
|
|
57
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
|
|
59
|
+
if data_format == "csv":
|
|
60
|
+
path = path.with_suffix(".csv")
|
|
61
|
+
save_array_csv(data, path)
|
|
62
|
+
elif data_format == "npz":
|
|
63
|
+
path = path.with_suffix(".npz")
|
|
64
|
+
np.savez_compressed(path, data=data)
|
|
65
|
+
else:
|
|
66
|
+
path = path.with_suffix(".npy")
|
|
67
|
+
np.save(path, data)
|
|
68
|
+
|
|
69
|
+
return path
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def save_array_csv(data: np.ndarray, path: Union[str, Path]) -> Path:
|
|
73
|
+
"""Save numpy array to CSV file with dtype header.
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
data : np.ndarray
|
|
78
|
+
Array to save.
|
|
79
|
+
path : str or Path
|
|
80
|
+
Output CSV file path.
|
|
81
|
+
|
|
82
|
+
Returns
|
|
83
|
+
-------
|
|
84
|
+
Path
|
|
85
|
+
Path to saved file.
|
|
86
|
+
"""
|
|
87
|
+
path = Path(path)
|
|
88
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
|
|
90
|
+
with open(path, "w", newline="") as f:
|
|
91
|
+
writer = csv.writer(f)
|
|
92
|
+
# Write header with dtype info
|
|
93
|
+
writer.writerow([f"# dtype: {data.dtype}"])
|
|
94
|
+
# Write data
|
|
95
|
+
if data.ndim == 1:
|
|
96
|
+
for val in data:
|
|
97
|
+
writer.writerow([val])
|
|
98
|
+
else:
|
|
99
|
+
for row in data:
|
|
100
|
+
writer.writerow(row if hasattr(row, "__iter__") else [row])
|
|
101
|
+
|
|
102
|
+
return path
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_array_csv(path: Union[str, Path]) -> np.ndarray:
|
|
106
|
+
"""Load numpy array from CSV file.
|
|
107
|
+
|
|
108
|
+
Parameters
|
|
109
|
+
----------
|
|
110
|
+
path : str or Path
|
|
111
|
+
Path to CSV file.
|
|
112
|
+
|
|
113
|
+
Returns
|
|
114
|
+
-------
|
|
115
|
+
np.ndarray
|
|
116
|
+
Loaded array.
|
|
117
|
+
"""
|
|
118
|
+
path = Path(path)
|
|
119
|
+
dtype = None
|
|
120
|
+
data_rows = []
|
|
121
|
+
|
|
122
|
+
with open(path, "r", newline="") as f:
|
|
123
|
+
reader = csv.reader(f)
|
|
124
|
+
for row in reader:
|
|
125
|
+
if row and row[0].startswith("# dtype:"):
|
|
126
|
+
dtype_str = row[0].replace("# dtype:", "").strip()
|
|
127
|
+
dtype = np.dtype(dtype_str)
|
|
128
|
+
else:
|
|
129
|
+
data_rows.append(row)
|
|
130
|
+
|
|
131
|
+
# Parse data
|
|
132
|
+
if not data_rows:
|
|
133
|
+
return np.array([], dtype=dtype)
|
|
134
|
+
|
|
135
|
+
# Check if 1D (single column)
|
|
136
|
+
if all(len(row) == 1 for row in data_rows):
|
|
137
|
+
data = [row[0] for row in data_rows]
|
|
138
|
+
else:
|
|
139
|
+
data = data_rows
|
|
140
|
+
|
|
141
|
+
return np.array(data, dtype=dtype)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def load_array(path: Union[str, Path]) -> np.ndarray:
|
|
145
|
+
"""Load numpy array from file.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
path : str or Path
|
|
150
|
+
Path to .npy, .npz, or .csv file.
|
|
151
|
+
|
|
152
|
+
Returns
|
|
153
|
+
-------
|
|
154
|
+
np.ndarray
|
|
155
|
+
Loaded array.
|
|
156
|
+
"""
|
|
157
|
+
path = Path(path)
|
|
158
|
+
|
|
159
|
+
if path.suffix == ".npz":
|
|
160
|
+
with np.load(path) as f:
|
|
161
|
+
return f["data"]
|
|
162
|
+
elif path.suffix == ".csv":
|
|
163
|
+
return load_array_csv(path)
|
|
164
|
+
else:
|
|
165
|
+
return np.load(path)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def to_serializable(data) -> Union[list, dict]:
|
|
169
|
+
"""Convert numpy array to serializable format for inline storage.
|
|
170
|
+
|
|
171
|
+
Parameters
|
|
172
|
+
----------
|
|
173
|
+
data : array-like
|
|
174
|
+
Data to convert.
|
|
175
|
+
|
|
176
|
+
Returns
|
|
177
|
+
-------
|
|
178
|
+
list or dict
|
|
179
|
+
Serializable representation.
|
|
180
|
+
"""
|
|
181
|
+
if hasattr(data, "tolist"):
|
|
182
|
+
return data.tolist()
|
|
183
|
+
elif hasattr(data, "values"): # pandas
|
|
184
|
+
return data.values.tolist()
|
|
185
|
+
else:
|
|
186
|
+
return list(data)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def from_serializable(data, dtype=None) -> np.ndarray:
|
|
190
|
+
"""Convert serializable format back to numpy array.
|
|
191
|
+
|
|
192
|
+
Parameters
|
|
193
|
+
----------
|
|
194
|
+
data : list
|
|
195
|
+
Serializable data.
|
|
196
|
+
dtype : dtype, optional
|
|
197
|
+
Target dtype.
|
|
198
|
+
|
|
199
|
+
Returns
|
|
200
|
+
-------
|
|
201
|
+
np.ndarray
|
|
202
|
+
Numpy array.
|
|
203
|
+
"""
|
|
204
|
+
return np.array(data, dtype=dtype)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Unit conversion utilities for figrecipe.
|
|
4
|
+
|
|
5
|
+
Provides conversions between millimeters, inches, and points for precise
|
|
6
|
+
figure layout control.
|
|
7
|
+
|
|
8
|
+
Constants:
|
|
9
|
+
- 1 inch = 25.4 mm
|
|
10
|
+
- 1 inch = 72 points (PostScript points)
|
|
11
|
+
- 1 mm = 72/25.4 points
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__all__ = ["mm_to_inch", "inch_to_mm", "mm_to_pt", "pt_to_mm", "mm_to_scatter_size", "normalize_color"]
|
|
15
|
+
|
|
16
|
+
from typing import List, Tuple, Union
|
|
17
|
+
|
|
18
|
+
# Conversion constants
|
|
19
|
+
MM_PER_INCH = 25.4
|
|
20
|
+
PT_PER_INCH = 72.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def mm_to_inch(mm: float) -> float:
|
|
24
|
+
"""Convert millimeters to inches.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
mm : float
|
|
29
|
+
Value in millimeters
|
|
30
|
+
|
|
31
|
+
Returns
|
|
32
|
+
-------
|
|
33
|
+
float
|
|
34
|
+
Value in inches
|
|
35
|
+
|
|
36
|
+
Examples
|
|
37
|
+
--------
|
|
38
|
+
>>> mm_to_inch(25.4)
|
|
39
|
+
1.0
|
|
40
|
+
>>> mm_to_inch(40) # Nature figure width
|
|
41
|
+
1.5748031496062993
|
|
42
|
+
"""
|
|
43
|
+
return mm / MM_PER_INCH
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def inch_to_mm(inch: float) -> float:
|
|
47
|
+
"""Convert inches to millimeters.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
inch : float
|
|
52
|
+
Value in inches
|
|
53
|
+
|
|
54
|
+
Returns
|
|
55
|
+
-------
|
|
56
|
+
float
|
|
57
|
+
Value in millimeters
|
|
58
|
+
|
|
59
|
+
Examples
|
|
60
|
+
--------
|
|
61
|
+
>>> inch_to_mm(1.0)
|
|
62
|
+
25.4
|
|
63
|
+
"""
|
|
64
|
+
return inch * MM_PER_INCH
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def mm_to_pt(mm: float) -> float:
|
|
68
|
+
"""Convert millimeters to points (PostScript points).
|
|
69
|
+
|
|
70
|
+
Parameters
|
|
71
|
+
----------
|
|
72
|
+
mm : float
|
|
73
|
+
Value in millimeters
|
|
74
|
+
|
|
75
|
+
Returns
|
|
76
|
+
-------
|
|
77
|
+
float
|
|
78
|
+
Value in points (1 pt = 1/72 inch)
|
|
79
|
+
|
|
80
|
+
Examples
|
|
81
|
+
--------
|
|
82
|
+
>>> mm_to_pt(0.2) # Typical line thickness
|
|
83
|
+
0.5669291338582677
|
|
84
|
+
>>> mm_to_pt(0.8) # Typical tick length
|
|
85
|
+
2.267716535433071
|
|
86
|
+
"""
|
|
87
|
+
return mm * PT_PER_INCH / MM_PER_INCH
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def pt_to_mm(pt: float) -> float:
|
|
91
|
+
"""Convert points to millimeters.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
pt : float
|
|
96
|
+
Value in points
|
|
97
|
+
|
|
98
|
+
Returns
|
|
99
|
+
-------
|
|
100
|
+
float
|
|
101
|
+
Value in millimeters
|
|
102
|
+
|
|
103
|
+
Examples
|
|
104
|
+
--------
|
|
105
|
+
>>> pt_to_mm(1.0)
|
|
106
|
+
0.3527777777777778
|
|
107
|
+
"""
|
|
108
|
+
return pt * MM_PER_INCH / PT_PER_INCH
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def mm_to_scatter_size(diameter_mm: float) -> float:
|
|
112
|
+
"""Convert mm diameter to matplotlib scatter 's' parameter.
|
|
113
|
+
|
|
114
|
+
matplotlib's scatter() uses 's' as marker AREA in points².
|
|
115
|
+
This function converts a desired diameter in mm to the correct
|
|
116
|
+
's' value for circular markers.
|
|
117
|
+
|
|
118
|
+
Parameters
|
|
119
|
+
----------
|
|
120
|
+
diameter_mm : float
|
|
121
|
+
Desired marker diameter in millimeters
|
|
122
|
+
|
|
123
|
+
Returns
|
|
124
|
+
-------
|
|
125
|
+
float
|
|
126
|
+
Value for scatter's 's' parameter (area in points²)
|
|
127
|
+
|
|
128
|
+
Examples
|
|
129
|
+
--------
|
|
130
|
+
>>> import figrecipe as fr
|
|
131
|
+
>>> # 0.8mm diameter markers (matches tick length)
|
|
132
|
+
>>> s = fr.mm_to_scatter_size(0.8)
|
|
133
|
+
>>> ax.scatter(x, y, s=s)
|
|
134
|
+
|
|
135
|
+
>>> # Use style's marker size
|
|
136
|
+
>>> style = fr.load_style()
|
|
137
|
+
>>> s = fr.mm_to_scatter_size(style.markers.size_mm)
|
|
138
|
+
>>> ax.scatter(x, y, s=s)
|
|
139
|
+
|
|
140
|
+
Notes
|
|
141
|
+
-----
|
|
142
|
+
For a circle: area = π * r² = π * (d/2)²
|
|
143
|
+
where d is diameter in points.
|
|
144
|
+
"""
|
|
145
|
+
import math
|
|
146
|
+
diameter_pt = mm_to_pt(diameter_mm)
|
|
147
|
+
return math.pi * (diameter_pt / 2) ** 2
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def normalize_color(
|
|
151
|
+
color: Union[List[int], Tuple[int, ...], str]
|
|
152
|
+
) -> Union[Tuple[float, ...], str]:
|
|
153
|
+
"""Normalize color to matplotlib-compatible format.
|
|
154
|
+
|
|
155
|
+
Converts RGB [0-255] values to normalized [0-1] tuples.
|
|
156
|
+
Hex strings and named colors are passed through unchanged.
|
|
157
|
+
|
|
158
|
+
Parameters
|
|
159
|
+
----------
|
|
160
|
+
color : list, tuple, or str
|
|
161
|
+
Color in various formats:
|
|
162
|
+
- RGB list/tuple [0-255]: [0, 128, 192] -> (0.0, 0.5, 0.75)
|
|
163
|
+
- Hex string: "#0080C0" -> "#0080C0"
|
|
164
|
+
- Named color: "blue" -> "blue"
|
|
165
|
+
|
|
166
|
+
Returns
|
|
167
|
+
-------
|
|
168
|
+
tuple or str
|
|
169
|
+
Matplotlib-compatible color specification
|
|
170
|
+
|
|
171
|
+
Examples
|
|
172
|
+
--------
|
|
173
|
+
>>> normalize_color([0, 128, 192])
|
|
174
|
+
(0.0, 0.5019607843137255, 0.7529411764705882)
|
|
175
|
+
>>> normalize_color("#0080C0")
|
|
176
|
+
'#0080C0'
|
|
177
|
+
>>> normalize_color("blue")
|
|
178
|
+
'blue'
|
|
179
|
+
"""
|
|
180
|
+
if isinstance(color, str):
|
|
181
|
+
return color
|
|
182
|
+
if isinstance(color, (list, tuple)):
|
|
183
|
+
# Check if already normalized (values <= 1)
|
|
184
|
+
if all(c <= 1.0 for c in color):
|
|
185
|
+
return tuple(color)
|
|
186
|
+
# Normalize 0-255 to 0-1
|
|
187
|
+
return tuple(c / 255.0 for c in color)
|
|
188
|
+
return color
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
# Test conversions
|
|
193
|
+
print("Unit conversion tests:")
|
|
194
|
+
print(f" 25.4 mm = {mm_to_inch(25.4):.4f} inch")
|
|
195
|
+
print(f" 1 inch = {inch_to_mm(1.0):.1f} mm")
|
|
196
|
+
print(f" 0.2 mm = {mm_to_pt(0.2):.4f} pt")
|
|
197
|
+
print(f" 1 pt = {pt_to_mm(1.0):.4f} mm")
|
|
198
|
+
print(f"\nColor normalization:")
|
|
199
|
+
print(f" [0, 128, 192] -> {normalize_color([0, 128, 192])}")
|
|
200
|
+
print(f" '#0080C0' -> {normalize_color('#0080C0')}")
|