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 +4 -0
- granatpy/core.py +327 -0
- granatpy/metrics.py +99 -0
- granatpy/naturalizer.py +144 -0
- granatpy/visualization.py +159 -0
- granatpy-0.1.0.dist-info/METADATA +88 -0
- granatpy-0.1.0.dist-info/RECORD +10 -0
- granatpy-0.1.0.dist-info/WHEEL +5 -0
- granatpy-0.1.0.dist-info/licenses/LICENSE +674 -0
- granatpy-0.1.0.dist-info/top_level.txt +1 -0
granatpy/__init__.py
ADDED
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
|
granatpy/naturalizer.py
ADDED
|
@@ -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
|