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.
@@ -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')}")