granatpy 0.1.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.
granatpy/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .core import naturalize_rgb_image, naturalize_single_channel
2
+ from .metrics import compute_all_metrics, compare_images
3
+ from .visualization import overlay_naturalness_heatmap
4
+ from .naturalizer import GraNat
granatpy/core.py ADDED
@@ -0,0 +1,327 @@
1
+ import numpy as np
2
+ import math
3
+
4
+ __all__ = ["naturalize_rgb_image", "naturalize_single_channel"]
5
+
6
+ # Constants from the original code
7
+ EPS = 0.0001
8
+ T1_pr = 0.3754
9
+ N_Lap = 2041
10
+ Lap_Offset = 1020
11
+ N_Grad = 512
12
+ Grad_Offset = 256
13
+ T2_pr = [0.2421, 0.2550, 0.2474, 0.24816666]
14
+
15
+ verbose = False
16
+
17
+ def _calculate_psnr(nf):
18
+ """Calculate PSNR from naturalization factor"""
19
+ if 0 <= nf <= 0.934:
20
+ return 23.65 * math.exp(0.6 * nf) - 20.0 * math.exp(-7.508 * nf)
21
+ elif 0.934 < nf < 1.07:
22
+ return float('inf') # > 40
23
+ elif 1.07 <= nf < 1.9:
24
+ return -11.566 * nf + 52.776
25
+ else:
26
+ return 13.06 * nf**4 - 121.4 * nf**3 + 408.5 * nf**2 - 595.5 * nf + 349
27
+
28
+ def _find_t_error(p_t, N, offset, T):
29
+ """Calculate error for finding optimal T parameter"""
30
+ error = 0.0
31
+ for i in range(-offset, N - offset):
32
+ tmp = math.atan(T * i) - p_t[i + offset]
33
+ error += tmp * tmp
34
+ return error
35
+
36
+ def _find_t_golden_section(data, N, offset, eps):
37
+ """
38
+ Find optimal T parameter using golden section search.
39
+ EXACT implementation matching the Java code.
40
+ """
41
+ left = 0.0
42
+ right = 1.0
43
+
44
+ # Create p_t array (shift and rescale) - use float32 to match Java float
45
+ p_t = np.array([(x - 0.5) * math.pi for x in data], dtype=np.float32)
46
+
47
+ m1 = 0.0
48
+ m2 = 0.0
49
+
50
+ # While the precision is bigger than eps
51
+ while right - left >= eps:
52
+ # Move left and right by 1/3
53
+ m1 = left + (right - left) / 3
54
+ m2 = right - (right - left) / 3
55
+
56
+ # Evaluate on m1 and m2, and move the extreme point
57
+ error1 = _find_t_error(p_t, N, offset, m1)
58
+ error2 = _find_t_error(p_t, N, offset, m2)
59
+
60
+ if error1 <= error2:
61
+ right = m2
62
+ else:
63
+ left = m1
64
+
65
+ # Return the average (this is critical - matches Java exactly)
66
+ return (m1 + m2) / 2
67
+
68
+ def _compute_gradient_field_forward(image):
69
+ """
70
+ Compute forward gradient field matching Java implementation.
71
+ G_x = I(x+1, y) - I(x, y)
72
+ G_y = I(x, y+1) - I(x, y)
73
+ """
74
+ gx = np.zeros_like(image, dtype=np.float32)
75
+ gy = np.zeros_like(image, dtype=np.float32)
76
+
77
+ # Forward differences
78
+ gx[:, :-1] = image[:, 1:] - image[:, :-1]
79
+ gy[:-1, :] = image[1:, :] - image[:-1, :]
80
+
81
+ return gx, gy
82
+
83
+ def _compute_laplacian_field(image):
84
+ """Compute Laplacian field using 5-point stencil."""
85
+ lap = np.zeros_like(image, dtype=np.float32)
86
+
87
+ # 5-point stencil: -4*center + right + left + down + up
88
+ lap[1:-1, 1:-1] = (
89
+ image[1:-1, 2:] + # right
90
+ image[1:-1, :-2] + # left
91
+ image[2:, 1:-1] + # down
92
+ image[:-2, 1:-1] - # up
93
+ 4 * image[1:-1, 1:-1] # center
94
+ )
95
+
96
+ return lap
97
+
98
+ def _calculate_laplace_field_and_gradient_optimized(image, lap_cdf, grad_d):
99
+ """
100
+ Calculate laplacian field and gradient histogram.
101
+
102
+ Key insight: Java stores grad_d[gy, gx] due to indexD[1-i]
103
+ """
104
+ # Ensure image is float32 in [0, 255] range
105
+ if image.max() <= 1:
106
+ image = (image * 255).astype(np.float32)
107
+ else:
108
+ image = image.astype(np.float32)
109
+
110
+ if verbose:
111
+ print(f" Image stats: shape={image.shape}, min={image.min():.2f}, max={image.max():.2f}, mean={image.mean():.2f}")
112
+
113
+ # Compute laplacian and gradients
114
+ laplace_field = _compute_laplacian_field(image)
115
+ gx, gy = _compute_gradient_field_forward(image)
116
+
117
+ # Get valid regions (exclude borders)
118
+ valid_laplace = laplace_field[1:-1, 1:-1]
119
+ valid_gx = gx[1:-1, 1:-1]
120
+ valid_gy = gy[1:-1, 1:-1]
121
+
122
+ if verbose:
123
+ print(f" Valid region: {valid_laplace.shape}, n_pixels={valid_laplace.size}")
124
+ print(f" Laplacian: min={valid_laplace.min():.2f}, max={valid_laplace.max():.2f}")
125
+ print(f" Gradient X: min={valid_gx.min():.2f}, max={valid_gx.max():.2f}")
126
+ print(f" Gradient Y: min={valid_gy.min():.2f}, max={valid_gy.max():.2f}")
127
+
128
+ n_pixels = valid_laplace.size
129
+ f = np.float32(1.0 / n_pixels) # Use float32 to match Java
130
+
131
+ # Vectorized histogram binning for laplacian
132
+ lap_bins = (valid_laplace + Lap_Offset).astype(np.int32)
133
+ lap_bins = np.clip(lap_bins, 0, N_Lap - 1)
134
+ np.add.at(lap_cdf, lap_bins.ravel(), f)
135
+
136
+ if verbose:
137
+ print(f" Laplacian histogram: non-zero bins={np.count_nonzero(lap_cdf)}, sum={lap_cdf.sum():.6f}")
138
+
139
+ # Vectorized 2D gradient histogram
140
+ # Java: indexD[1-i] means grad_d[gy, gx]
141
+ gx_bins = np.clip((Grad_Offset + valid_gx).astype(np.int32), 0, N_Grad - 1)
142
+ gy_bins = np.clip((Grad_Offset + valid_gy).astype(np.int32), 0, N_Grad - 1)
143
+
144
+ gx_flat = gx_bins.ravel()
145
+ gy_flat = gy_bins.ravel()
146
+
147
+ # Accumulate: grad_d[gy, gx]
148
+ np.add.at(grad_d, (gy_flat, gx_flat), f)
149
+
150
+ if verbose:
151
+ print(f" Gradient 2D histogram: non-zero bins={np.count_nonzero(grad_d)}, sum={grad_d.sum():.6f}")
152
+
153
+ def _calculate_lap_cdf(lap_cdf):
154
+ """Convert laplacian histogram to CDF (in-place)"""
155
+ np.cumsum(lap_cdf, out=lap_cdf, dtype=np.float32)
156
+
157
+ def _convert_grad_2d_to_cdf(grad_d):
158
+ """
159
+ Convert 2D gradient HISTOGRAM to 2D CDF (in-place).
160
+ This performs cumsum in both dimensions using float32 precision.
161
+ """
162
+ # Ensure we're working with float32
163
+ if grad_d.dtype != np.float32:
164
+ grad_d = grad_d.astype(np.float32)
165
+
166
+ # Row integration (cumsum along axis 0) - matches Java first loop
167
+ np.cumsum(grad_d, axis=0, out=grad_d, dtype=np.float32)
168
+
169
+ # Column integration (cumsum along axis 1) - matches Java second loop
170
+ np.cumsum(grad_d, axis=1, out=grad_d, dtype=np.float32)
171
+
172
+ def _calculate_grad_cdf(grad_cdf, grad_d):
173
+ """
174
+ Calculate 1D gradient CDFs from 2D CDF.
175
+
176
+ Java code integrates (sums) the 2D CDF to get marginal CDFs:
177
+ - Gradx[gx] = sum over gy of grad_d[gy, gx]
178
+ - Grady[gy] = sum over gx of grad_d[gy, gx]
179
+ """
180
+ # Integrate the 2D CDF - use float32 for consistency
181
+ grad_x = np.sum(grad_d, axis=0, dtype=np.float32)
182
+ grad_y = np.sum(grad_d, axis=1, dtype=np.float32)
183
+
184
+ # Scale by 255.0 (not 256!) as per Java code
185
+ grad_x = grad_x / np.float32(255.0)
186
+ grad_y = grad_y / np.float32(255.0)
187
+
188
+ # Store in output array
189
+ grad_cdf[:, 0] = grad_x
190
+ grad_cdf[:, 1] = grad_y
191
+
192
+ def _find_naturalization_factor(image, t2_prior):
193
+ """Find optimal naturalization factor"""
194
+ # Initialize histograms - use float32 to match Java
195
+ lap_cdf = np.zeros(N_Lap, dtype=np.float32)
196
+ grad_d = np.zeros((N_Grad, N_Grad), dtype=np.float32)
197
+
198
+ # Calculate laplacian and gradient histograms
199
+ _calculate_laplace_field_and_gradient_optimized(image, lap_cdf, grad_d)
200
+
201
+ # Convert 2D gradient histogram to 2D CDF
202
+ _convert_grad_2d_to_cdf(grad_d)
203
+
204
+ # Extract 1D CDFs from 2D CDF
205
+ grad_cdf = np.zeros((N_Grad, 2), dtype=np.float32)
206
+ _calculate_grad_cdf(grad_cdf, grad_d)
207
+
208
+ # Convert laplacian histogram to CDF
209
+ _calculate_lap_cdf(lap_cdf)
210
+
211
+ # Find T1 parameter - USE GOLDEN SECTION (critical fix!)
212
+ t1_x = _find_t_golden_section(grad_cdf[:, 0], N_Grad, Grad_Offset, EPS)
213
+ t1_y = _find_t_golden_section(grad_cdf[:, 1], N_Grad, Grad_Offset, EPS)
214
+ t1 = (t1_x + t1_y) / (2 * T1_pr)
215
+
216
+ # Find T2 parameter - USE GOLDEN SECTION (critical fix!)
217
+ t2 = _find_t_golden_section(lap_cdf, N_Lap, Lap_Offset, EPS) / t2_prior
218
+
219
+ # Calculate naturalization factor
220
+ theta = 0.5
221
+ nf = (1 - theta) * t1 + theta * t2
222
+
223
+ if verbose:
224
+ print(f" Results: T1={t1:.6f}, T2={t2:.6f}, Nf={nf:.6f}, T2_prior={t2_prior:.4f}")
225
+
226
+ return nf, t1, t2
227
+
228
+ def _naturalize_image(image, nf):
229
+ """Apply naturalization to image"""
230
+ # Calculate mean
231
+ mean_original = np.mean(image)
232
+
233
+ # Apply naturalization: (I - mean) * Nf + mean
234
+ result = (image - mean_original) * nf + mean_original
235
+
236
+ # Round and clamp to [0, 255] - matches Java rounding
237
+ result = np.round(result)
238
+ result = np.clip(result, 0, 255)
239
+
240
+ return result.astype(np.uint8)
241
+
242
+ def naturalize_single_channel(image: np.ndarray, channel_idx: int = 0) -> tuple:
243
+ """
244
+ Naturalize a single image channel using its corresponding T2 prior.
245
+
246
+ The T2 prior varies per channel to account for differences in the
247
+ statistical properties of R, G, and B channels. For grayscale images,
248
+ pass channel_idx=3 to use the averaged prior.
249
+
250
+ Args:
251
+ image: 2D numpy array of shape (H, W) representing a single
252
+ channel, with pixel values in the range [0, 255].
253
+ channel_idx: Index indicating which channel is being processed.
254
+ 0=Red, 1=Green, 2=Blue, 3=Grayscale. Defaults to 0.
255
+
256
+ Returns:
257
+ tuple: A tuple of (result, nf, t1, t2, psnr) where:
258
+ result: Naturalized channel as a uint8 numpy array of shape (H, W).
259
+ nf: Naturalization factor applied to this channel.
260
+ t1: T1 parameter derived from the gradient CDF.
261
+ t2: T2 parameter derived from the Laplacian CDF.
262
+ psnr: Estimated PSNR value based on the naturalization factor.
263
+ """
264
+ # Select appropriate T2 prior - matches Java indexing
265
+ if channel_idx <= 2:
266
+ t2_prior = T2_pr[2 - channel_idx] # R=0->2, G=1->1, B=2->0
267
+ else:
268
+ t2_prior = T2_pr[3] # Grayscale average
269
+
270
+ # Find optimal naturalization factor
271
+ nf, t1, t2 = _find_naturalization_factor(image, t2_prior)
272
+
273
+ # Apply naturalization
274
+ result = _naturalize_image(image, nf)
275
+
276
+ # Calculate PSNR
277
+ psnr = _calculate_psnr(nf)
278
+
279
+ return result, nf, t1, t2, psnr
280
+
281
+ def naturalize_rgb_image(image: np.ndarray) -> tuple:
282
+ """
283
+ Naturalize an RGB or grayscale image by adjusting each channel independently.
284
+
285
+ For RGB images, each channel is processed separately using its corresponding
286
+ T2 prior. For grayscale images, a single averaged T2 prior is used instead.
287
+
288
+ Args:
289
+ image: Input image as a numpy array. Either (H, W) for grayscale
290
+ or (H, W, 3) for RGB, with pixel values in the range [0, 255].
291
+
292
+ Returns:
293
+ tuple: A tuple of (result, nfs, t1s, t2s, psnrs) where:
294
+ result: Naturalized image as a uint8 numpy array, same shape as input.
295
+ nfs: List of naturalization factors, one per channel.
296
+ t1s: List of T1 parameters, one per channel.
297
+ t2s: List of T2 parameters, one per channel.
298
+ psnrs: List of estimated PSNR values, one per channel.
299
+ """
300
+ if len(image.shape) == 2:
301
+ # Single channel
302
+ result, nf, t1, t2, psnr = naturalize_single_channel(image, channel_idx=3)
303
+ return result, [nf], [t1], [t2], [psnr]
304
+ else:
305
+ # RGB image
306
+ results = []
307
+ nfs = []
308
+ t1s = []
309
+ t2s = []
310
+ psnrs = []
311
+
312
+ for i in range(min(3, image.shape[2])):
313
+ channel = image[:, :, i]
314
+ result, nf, t1, t2, psnr = naturalize_single_channel(channel, channel_idx=i)
315
+ results.append(result)
316
+ nfs.append(nf)
317
+ t1s.append(t1)
318
+ t2s.append(t2)
319
+ psnrs.append(psnr)
320
+
321
+ # Combine channels
322
+ if len(results) == 3:
323
+ result = np.stack(results, axis=2)
324
+ else:
325
+ result = results[0]
326
+
327
+ return result, nfs, t1s, t2s, psnrs
granatpy/metrics.py ADDED
@@ -0,0 +1,99 @@
1
+ import numpy as np
2
+ from skimage.metrics import peak_signal_noise_ratio, structural_similarity, mean_squared_error, normalized_root_mse
3
+ import imageio.v3 as imageio
4
+ from typing import Dict, List
5
+ from .core import naturalize_rgb_image
6
+
7
+
8
+ __all__ = ["compute_all_metrics", "compare_images"]
9
+
10
+
11
+ def _compute_nf(image: np.ndarray) -> float:
12
+ """Compute the mean naturalness factor across RGB channels for an image."""
13
+ _, nfs, _, _, _ = naturalize_rgb_image(image)
14
+ return float(np.mean(nfs))
15
+
16
+
17
+ def compute_all_metrics(real: np.ndarray, synthetic: np.ndarray, verbose = False) -> Dict[str, float]:
18
+ """
19
+ Compute all image quality metrics between a reference and a synthetic image.
20
+
21
+ Args:
22
+ real: Reference (real) image as a numpy array.
23
+ synthetic: Comparison (synthetic) image as a numpy array.
24
+ verbose: Whether to show status logs.
25
+
26
+ Returns:
27
+ Dictionary containing MSE, PSNR, SSIM, NRMSE, and dNf values.
28
+ dNf is the delta naturalness factor (synthetic Nf minus real Nf).
29
+ """
30
+ if real.shape != synthetic.shape:
31
+ raise ValueError(
32
+ f"Images must have the same shape. Got {real.shape} and {synthetic.shape}."
33
+ )
34
+
35
+ if verbose: print("Computing naturalness for first image...")
36
+ nf_real = _compute_nf(real)
37
+ if verbose: print("Computing naturalness for second image...")
38
+ nf_synthetic = _compute_nf(synthetic)
39
+
40
+ if verbose: print("Calculating remaining metrics...")
41
+ results = {
42
+ "MSE": mean_squared_error(real, synthetic) / (255 ** 2),
43
+ "PSNR": peak_signal_noise_ratio(real, synthetic),
44
+ "SSIM": structural_similarity(real, synthetic, channel_axis=-1),
45
+ "NRMSE": normalized_root_mse(real, synthetic),
46
+ "Nf_real": nf_real,
47
+ "Nf_synthetic": nf_synthetic,
48
+ "dNf": nf_synthetic - nf_real,
49
+ }
50
+
51
+ for key, value in results.items():
52
+ if key == "PSNR":
53
+ print(f" {key:<12} {value:.3f} dB")
54
+ else:
55
+ print(f" {key:<12} {value:.4f}")
56
+
57
+ return results
58
+
59
+
60
+ def compare_images(real: str, images: List[str], verbose = False) -> Dict[str, Dict[str, float]]:
61
+ """
62
+ Compare a reference image against a list of synthetic images,
63
+ printing a summary and returning all metrics.
64
+
65
+ Args:
66
+ real: Path to a reference (real) image.
67
+ images: List of image paths.
68
+ verbose: Whether to show status logs
69
+
70
+ Returns:
71
+ Dictionary mapping each image name to its computed metrics.
72
+ """
73
+ results = {}
74
+ if verbose: print(f"Reading image {real}")
75
+ real_img = imageio.imread(real)
76
+
77
+ for img_str in images:
78
+ if verbose: print(f"Reading image {img_str}")
79
+ img = imageio.imread(img_str)
80
+
81
+ if img.shape != real_img.shape:
82
+ print(f"Skipping '{img_str}': shape mismatch ({img.shape} vs {real_img.shape}).")
83
+ continue
84
+
85
+ metrics = compute_all_metrics(real_img, img, verbose)
86
+ results[img_str] = metrics
87
+
88
+ print(
89
+ f"{img_str}: "
90
+ f"PSNR={metrics['PSNR']:.2f} dB, "
91
+ f"MSE={metrics['MSE']:.6f}, "
92
+ f"SSIM={metrics['SSIM']:.4f}, "
93
+ f"NRMSE={metrics['NRMSE']:.4f}, "
94
+ f"Nf_real={metrics['Nf_real']:.4f}, "
95
+ f"Nf_synthetic={metrics['Nf_synthetic']:.4f}, "
96
+ f"dNf={metrics['dNf']:+.4f}"
97
+ )
98
+
99
+ return results
@@ -0,0 +1,144 @@
1
+ import numpy as np
2
+ import imageio.v3 as imageio
3
+ import matplotlib.pyplot as plt
4
+ from .core import naturalize_rgb_image
5
+ from .visualization import overlay_naturalness_heatmap
6
+
7
+
8
+ __all__ = ["GraNat"]
9
+
10
+
11
+ class GraNat:
12
+ """
13
+ A convenience wrapper around the GraNatPy naturalization pipeline.
14
+
15
+ This class provides an interface to the core naturalization functions,
16
+ plus loading, saving and image visualization without managing intermediate results manually.
17
+
18
+ Typical usage::
19
+
20
+ # From a file path
21
+ result = GraNat.load_image("photo.tif").naturalize()
22
+ # From an existing numpy array
23
+ result = GraNat(img_array).naturalize()
24
+ # Chained with visualization
25
+ GraNat.load_image("photo.tif").naturalize().plot_heatmap()
26
+
27
+ Attributes:
28
+ image: The input image as a uint8 numpy array of shape (H, W, 3)
29
+ or (H, W) for grayscale.
30
+ result: The naturalized image as a uint8 numpy array. None until
31
+ naturalize() has been called.
32
+ nfs: List of naturalization factors, one per channel. None until
33
+ naturalize() has been called.
34
+ t1s: List of T1 parameters, one per channel. None until
35
+ naturalize() has been called.
36
+ t2s: List of T2 parameters, one per channel. None until
37
+ naturalize() has been called.
38
+ psnrs: List of estimated PSNR values, one per channel. None until
39
+ naturalize() has been called.
40
+ """
41
+
42
+ def __init__(self, image: np.ndarray):
43
+
44
+ self.image = image
45
+ self.result = None
46
+ self.nfs = None
47
+ self.t1s = None
48
+ self.t2s = None
49
+ self.psnrs = None
50
+
51
+ @classmethod
52
+ def load_image(cls, path: str) -> "GraNat":
53
+ """
54
+ Load an image from disk and return a GraNat instance.
55
+ Supports any format readable by imageio (TIFF, PNG, JPEG, etc.).
56
+
57
+ Args:
58
+ path: Path to the image file.
59
+
60
+ Returns:
61
+ A new GraNat instance initialised with the loaded image.
62
+
63
+ Raises:
64
+ FileNotFoundError: If no file exists at the given path.
65
+ OSError: If the file exists but cannot be read as an image.
66
+
67
+ Example::
68
+
69
+ naturalizer = GraNat.load_image("photo.tif")
70
+ """
71
+ return cls(imageio.imread(path))
72
+
73
+ def naturalize(self, show = False) -> "GraNat":
74
+ """
75
+ Run the naturalization pipeline on the loaded image.
76
+
77
+ Processes each channel independently and stores the results in the
78
+ instance attributes. Returns self to allow method chaining.
79
+
80
+ Args:
81
+ show: Whether to directly plot the image with matplotlib.
82
+ Returns:
83
+ self, to allow chaining e.g. GraNat.load_image(...).naturalize()
84
+ Raises:
85
+ ValueError: If the image array is not a valid 2D or 3D numpy array.
86
+ Example::
87
+
88
+ naturalizer = GraNat.load_image("photo.tif").naturalize()
89
+ print(naturalizer.nfs)
90
+ """
91
+ self.result, self.nfs, self.t1s, self.t2s, self.psnrs = naturalize_rgb_image(self.image)
92
+ if show:
93
+ plt.imshow(self.result)
94
+ return self
95
+
96
+ def plot_heatmap(self, **kwargs):
97
+ """
98
+ Overlay a naturalness factor heatmap on the original image.
99
+
100
+ Divides the image into a grid of cells and computes the average naturalization factor per cell,
101
+ then renders a semi-transparent heatmap overlay. Keyword arguments are forwarded directly to
102
+ overlay_naturalness_heatmap().
103
+
104
+ Common keyword arguments:
105
+ grid_rows (int): Number of rows in the grid. Default 10.
106
+ grid_cols (int): Number of columns in the grid. Default 15.
107
+ cmap (str): Matplotlib colormap name. Default 'YlOrRd'.
108
+ alpha (float): Heatmap opacity in [0, 1]. Default 0.5.
109
+ figsize (tuple): Figure size as (width, height). Default (12, 8).
110
+
111
+ Returns:
112
+ A tuple of (fig, ax, heatmap_data) where heatmap_data is a 2D
113
+ numpy array of shape (grid_rows, grid_cols) containing the
114
+ average naturalization factor per cell.
115
+
116
+ Raises:
117
+ ValueError: If the image has not been loaded correctly.
118
+
119
+ Example::
120
+
121
+ fig, ax, heatmap = GraNat.load_image("photo.tif").plot_heatmap(grid_rows=20)
122
+ """
123
+ return overlay_naturalness_heatmap(self.image, **kwargs)
124
+
125
+ def save(self, path: str) -> "GraNat":
126
+ """
127
+ Save the naturalized image to disk.
128
+
129
+ Args:
130
+ path: Output file path. The format is inferred from the file
131
+ extension (e.g. '.png', '.tif', '.jpg').
132
+ Returns:
133
+ self, to allow chaining.
134
+ Raises:
135
+ RuntimeError: If naturalize() has not been called yet.
136
+ OSError: If the file cannot be written to the given path.
137
+ Example::
138
+
139
+ GraNat.load_image("photo.tif").naturalize().save("output.png")
140
+ """
141
+ if self.result is None:
142
+ raise RuntimeError("No result to save. Call naturalize() first.")
143
+ imageio.imwrite(path, self.result)
144
+ return self