Fioceen commited on
Commit
79de47c
·
1 Parent(s): 0e68fd6

File modularization

Browse files
image_postprocess/processor.py CHANGED
@@ -172,7 +172,7 @@ def build_argparser():
172
 
173
  # LUT options
174
  p.add_argument('--lut', type=str, default=None, help='Path to a 1D PNG (256x1) or .npy LUT, or a .cube 3D LUT')
175
- p.add_argument('--lut-strength', type=float, default=1.0, help='Strength to blend LUT (0.0 = no effect, 1.0 = full LUT)')
176
 
177
  return p
178
 
 
172
 
173
  # LUT options
174
  p.add_argument('--lut', type=str, default=None, help='Path to a 1D PNG (256x1) or .npy LUT, or a .cube 3D LUT')
175
+ p.add_argument('--lut-strength', type=float, default=0.1, help='Strength to blend LUT (0.0 = no effect, 1.0 = full LUT)')
176
 
177
  return p
178
 
image_postprocess/utils.py DELETED
@@ -1,456 +0,0 @@
1
- """
2
- utils.py
3
-
4
- Helper functions for image postprocessing, including EXIF removal, noise addition,
5
- color correction, and Fourier spectrum matching.
6
- """
7
- import os
8
- import re
9
- from PIL import Image, ImageOps
10
- import numpy as np
11
- try:
12
- import cv2
13
- _HAS_CV2 = True
14
- except Exception:
15
- cv2 = None
16
- _HAS_CV2 = False
17
- from scipy.ndimage import gaussian_filter1d
18
-
19
- def remove_exif_pil(img: Image.Image) -> Image.Image:
20
- data = img.tobytes()
21
- new = Image.frombytes(img.mode, img.size, data)
22
- return new
23
-
24
- def add_gaussian_noise(img_arr: np.ndarray, std_frac=0.02, seed=None) -> np.ndarray:
25
- if seed is not None:
26
- np.random.seed(seed)
27
- std = std_frac * 255.0
28
- noise = np.random.normal(loc=0.0, scale=std, size=img_arr.shape)
29
- out = img_arr.astype(np.float32) + noise
30
- out = np.clip(out, 0, 255).astype(np.uint8)
31
- return out
32
-
33
- def clahe_color_correction(img_arr: np.ndarray, clip_limit=2.0, tile_grid_size=(8,8)) -> np.ndarray:
34
- if _HAS_CV2:
35
- lab = cv2.cvtColor(img_arr, cv2.COLOR_RGB2LAB)
36
- l, a, b = cv2.split(lab)
37
- clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=tile_grid_size)
38
- l2 = clahe.apply(l)
39
- lab2 = cv2.merge((l2, a, b))
40
- out = cv2.cvtColor(lab2, cv2.COLOR_LAB2RGB)
41
- return out
42
- else:
43
- pil = Image.fromarray(img_arr)
44
- channels = pil.split()
45
- new_ch = []
46
- for ch in channels:
47
- eq = ImageOps.equalize(ch)
48
- new_ch.append(eq)
49
- merged = Image.merge('RGB', new_ch)
50
- return np.array(merged)
51
-
52
- def randomized_perturbation(img_arr: np.ndarray, magnitude_frac=0.008, seed=None) -> np.ndarray:
53
- if seed is not None:
54
- np.random.seed(seed)
55
- mag = magnitude_frac * 255.0
56
- perturb = np.random.uniform(low=-mag, high=mag, size=img_arr.shape)
57
- out = img_arr.astype(np.float32) + perturb
58
- out = np.clip(out, 0, 255).astype(np.uint8)
59
- return out
60
-
61
- def radial_profile(mag: np.ndarray, center=None, nbins=None):
62
- h, w = mag.shape
63
- if center is None:
64
- cy, cx = h // 2, w // 2
65
- else:
66
- cy, cx = center
67
-
68
- if nbins is None:
69
- nbins = int(max(h, w) / 2)
70
- nbins = max(1, int(nbins))
71
-
72
- y = np.arange(h) - cy
73
- x = np.arange(w) - cx
74
- X, Y = np.meshgrid(x, y)
75
- R = np.sqrt(X * X + Y * Y)
76
-
77
- Rmax = R.max()
78
- if Rmax <= 0:
79
- Rnorm = R
80
- else:
81
- Rnorm = R / (Rmax + 1e-12)
82
- Rnorm = np.minimum(Rnorm, 1.0 - 1e-12)
83
-
84
- bin_edges = np.linspace(0.0, 1.0, nbins + 1)
85
- bin_idx = np.digitize(Rnorm.ravel(), bin_edges) - 1
86
- bin_idx = np.clip(bin_idx, 0, nbins - 1)
87
-
88
- sums = np.bincount(bin_idx, weights=mag.ravel(), minlength=nbins)
89
- counts = np.bincount(bin_idx, minlength=nbins)
90
-
91
- radial_mean = np.zeros(nbins, dtype=np.float64)
92
- nonzero = counts > 0
93
- radial_mean[nonzero] = sums[nonzero] / counts[nonzero]
94
-
95
- bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:])
96
- return bin_centers, radial_mean
97
-
98
- def fourier_match_spectrum(img_arr: np.ndarray,
99
- ref_img_arr: np.ndarray = None,
100
- mode='auto',
101
- alpha=1.0,
102
- cutoff=0.25,
103
- strength=0.9,
104
- randomness=0.05,
105
- phase_perturb=0.08,
106
- radial_smooth=5,
107
- seed=None):
108
- if seed is not None:
109
- rng = np.random.default_rng(seed)
110
- else:
111
- rng = np.random.default_rng()
112
-
113
- h, w = img_arr.shape[:2]
114
- cy, cx = h // 2, w // 2
115
- nbins = max(8, int(max(h, w) / 2))
116
-
117
- if mode == 'auto':
118
- mode = 'ref' if ref_img_arr is not None else 'model'
119
-
120
- bin_centers_src = np.linspace(0.0, 1.0, nbins)
121
-
122
- model_radial = None
123
- if mode == 'model':
124
- eps = 1e-8
125
- model_radial = (1.0 / (bin_centers_src + eps)) ** (alpha / 2.0)
126
- lf = max(1, nbins // 8)
127
- model_radial = model_radial / (np.median(model_radial[:lf]) + 1e-12)
128
- model_radial = gaussian_filter1d(model_radial, sigma=max(1, radial_smooth))
129
-
130
- ref_radial = None
131
- ref_bin_centers = None
132
- if mode == 'ref' and ref_img_arr is not None:
133
- if ref_img_arr.shape[0] != h or ref_img_arr.shape[1] != w:
134
- ref_img = Image.fromarray(ref_img_arr).resize((w, h), resample=Image.BICUBIC)
135
- ref_img_arr = np.array(ref_img)
136
- ref_gray = np.mean(ref_img_arr.astype(np.float32), axis=2) if ref_img_arr.ndim == 3 else ref_img_arr.astype(np.float32)
137
- Fref = np.fft.fftshift(np.fft.fft2(ref_gray))
138
- Mref = np.abs(Fref)
139
- ref_bin_centers, ref_radial = radial_profile(Mref, center=(h // 2, w // 2), nbins=nbins)
140
- ref_radial = gaussian_filter1d(ref_radial, sigma=max(1, radial_smooth))
141
-
142
- out = np.zeros_like(img_arr, dtype=np.float32)
143
-
144
- y = np.linspace(-1, 1, h, endpoint=False)[:, None]
145
- x = np.linspace(-1, 1, w, endpoint=False)[None, :]
146
- r = np.sqrt(x * x + y * y)
147
- r = np.clip(r, 0.0, 1.0 - 1e-6)
148
-
149
- for c in range(img_arr.shape[2]):
150
- channel = img_arr[:, :, c].astype(np.float32)
151
- F = np.fft.fft2(channel)
152
- Fshift = np.fft.fftshift(F)
153
- mag = np.abs(Fshift)
154
- phase = np.angle(Fshift)
155
-
156
- bin_centers_src_calc, src_radial = radial_profile(mag, center=(h // 2, w // 2), nbins=nbins)
157
- src_radial = gaussian_filter1d(src_radial, sigma=max(1, radial_smooth))
158
- bin_centers_src = bin_centers_src_calc
159
-
160
- if mode == 'ref' and ref_radial is not None:
161
- ref_interp = np.interp(bin_centers_src, ref_bin_centers, ref_radial)
162
- eps = 1e-8
163
- ratio = (ref_interp + eps) / (src_radial + eps)
164
- desired_radial = src_radial * ratio
165
- elif mode == 'model' and model_radial is not None:
166
- lf = max(1, nbins // 8)
167
- scale = (np.median(src_radial[:lf]) + 1e-12) / (np.median(model_radial[:lf]) + 1e-12)
168
- desired_radial = model_radial * scale
169
- else:
170
- desired_radial = src_radial.copy()
171
-
172
- eps = 1e-8
173
- multiplier_1d = (desired_radial + eps) / (src_radial + eps)
174
- multiplier_1d = np.clip(multiplier_1d, 0.2, 5.0)
175
- mult_2d = np.interp(r.ravel(), bin_centers_src, multiplier_1d).reshape(h, w)
176
-
177
- edge = 0.05 + 0.02 * (1.0 - cutoff) if 'cutoff' in globals() else 0.05
178
- edge = max(edge, 1e-6)
179
- weight = np.where(r <= 0.25, 1.0,
180
- np.where(r <= 0.25 + edge,
181
- 0.5 * (1 + np.cos(np.pi * (r - 0.25) / edge)),
182
- 0.0))
183
-
184
- final_multiplier = 1.0 + (mult_2d - 1.0) * (weight * strength)
185
-
186
- if randomness and randomness > 0.0:
187
- noise = rng.normal(loc=1.0, scale=randomness, size=final_multiplier.shape)
188
- final_multiplier *= (1.0 + (noise - 1.0) * weight)
189
-
190
- mag2 = mag * final_multiplier
191
-
192
- if phase_perturb and phase_perturb > 0.0:
193
- phase_sigma = phase_perturb * np.clip((r - 0.25) / (1.0 - 0.25 + 1e-6), 0.0, 1.0)
194
- phase_noise = rng.standard_normal(size=phase_sigma.shape) * phase_sigma
195
- phase2 = phase + phase_noise
196
- else:
197
- phase2 = phase
198
-
199
- Fshift2 = mag2 * np.exp(1j * phase2)
200
- F_ishift = np.fft.ifftshift(Fshift2)
201
- img_back = np.fft.ifft2(F_ishift)
202
- img_back = np.real(img_back)
203
-
204
- blended = (1.0 - strength) * channel + strength * img_back
205
- out[:, :, c] = blended
206
-
207
- out = np.clip(out, 0, 255).astype(np.uint8)
208
- return out
209
-
210
- def auto_white_balance_ref(img_arr: np.ndarray, ref_img_arr: np.ndarray = None) -> np.ndarray:
211
- """
212
- Auto white-balance correction using a reference image.
213
- If ref_img_arr is None, uses a gray-world assumption instead.
214
- """
215
- img = img_arr.astype(np.float32)
216
-
217
- if ref_img_arr is not None:
218
- ref = ref_img_arr.astype(np.float32)
219
- ref_mean = ref.reshape(-1, 3).mean(axis=0)
220
- else:
221
- # Gray-world assumption: target is neutral gray
222
- ref_mean = np.array([128.0, 128.0, 128.0], dtype=np.float32)
223
-
224
- img_mean = img.reshape(-1, 3).mean(axis=0)
225
-
226
- # Avoid divide-by-zero
227
- eps = 1e-6
228
- scale = (ref_mean + eps) / (img_mean + eps)
229
-
230
- corrected = img * scale
231
- corrected = np.clip(corrected, 0, 255).astype(np.uint8)
232
-
233
- return corrected
234
-
235
- def apply_1d_lut(img_arr: np.ndarray, lut: np.ndarray, strength: float = 1.0) -> np.ndarray:
236
- """
237
- Apply a 1D LUT to an image.
238
- - img_arr: HxWx3 uint8
239
- - lut: either shape (256,) (applied equally to all channels), (256,3) (per-channel),
240
- or (N,) / (N,3) (interpolated across [0..255])
241
- - strength: 0..1 blending between original and LUT result
242
- Returns uint8 array.
243
- """
244
- if img_arr.ndim != 3 or img_arr.shape[2] != 3:
245
- raise ValueError("apply_1d_lut expects an HxWx3 image array")
246
-
247
- # Normalize indices 0..255
248
- arr = img_arr.astype(np.float32)
249
- # Prepare LUT as float in 0..255 range if necessary
250
- lut_arr = np.array(lut, dtype=np.float32)
251
-
252
- # If single channel LUT (N,) expand to three channels
253
- if lut_arr.ndim == 1:
254
- lut_arr = np.stack([lut_arr, lut_arr, lut_arr], axis=1) # (N,3)
255
-
256
- if lut_arr.shape[1] != 3:
257
- raise ValueError("1D LUT must have shape (N,) or (N,3)")
258
-
259
- # Build index positions in source LUT space (0..255)
260
- N = lut_arr.shape[0]
261
- src_positions = np.linspace(0, 255, N)
262
-
263
- # Flatten and interpolate per channel
264
- out = np.empty_like(arr)
265
- for c in range(3):
266
- channel = arr[..., c].ravel()
267
- mapped = np.interp(channel, src_positions, lut_arr[:, c])
268
- out[..., c] = mapped.reshape(arr.shape[0], arr.shape[1])
269
-
270
- out = np.clip(out, 0, 255).astype(np.uint8)
271
- if strength >= 1.0:
272
- return out
273
- else:
274
- blended = ((1.0 - strength) * img_arr.astype(np.float32) + strength * out.astype(np.float32))
275
- return np.clip(blended, 0, 255).astype(np.uint8)
276
-
277
- def _trilinear_sample_lut(img_float: np.ndarray, lut: np.ndarray) -> np.ndarray:
278
- """
279
- Vectorized trilinear sampling of 3D LUT.
280
- - img_float: HxWx3 floats in [0,1]
281
- - lut: SxSxS x 3 floats in [0,1]
282
- Returns HxWx3 floats in [0,1]
283
- """
284
- S = lut.shape[0]
285
- if lut.shape[0] != lut.shape[1] or lut.shape[1] != lut.shape[2]:
286
- raise ValueError("3D LUT must be cubic (SxSxSx3)")
287
-
288
- # map [0,1] -> [0, S-1]
289
- idx = img_float * (S - 1)
290
- r_idx = idx[..., 0]
291
- g_idx = idx[..., 1]
292
- b_idx = idx[..., 2]
293
-
294
- r0 = np.floor(r_idx).astype(np.int32)
295
- g0 = np.floor(g_idx).astype(np.int32)
296
- b0 = np.floor(b_idx).astype(np.int32)
297
-
298
- r1 = np.clip(r0 + 1, 0, S - 1)
299
- g1 = np.clip(g0 + 1, 0, S - 1)
300
- b1 = np.clip(b0 + 1, 0, S - 1)
301
-
302
- dr = (r_idx - r0)[..., None]
303
- dg = (g_idx - g0)[..., None]
304
- db = (b_idx - b0)[..., None]
305
-
306
- # gather 8 corners: c000 ... c111
307
- c000 = lut[r0, g0, b0]
308
- c001 = lut[r0, g0, b1]
309
- c010 = lut[r0, g1, b0]
310
- c011 = lut[r0, g1, b1]
311
- c100 = lut[r1, g0, b0]
312
- c101 = lut[r1, g0, b1]
313
- c110 = lut[r1, g1, b0]
314
- c111 = lut[r1, g1, b1]
315
-
316
- # interpolate along b
317
- c00 = c000 * (1 - db) + c001 * db
318
- c01 = c010 * (1 - db) + c011 * db
319
- c10 = c100 * (1 - db) + c101 * db
320
- c11 = c110 * (1 - db) + c111 * db
321
-
322
- # interpolate along g
323
- c0 = c00 * (1 - dg) + c01 * dg
324
- c1 = c10 * (1 - dg) + c11 * dg
325
-
326
- # interpolate along r
327
- c = c0 * (1 - dr) + c1 * dr
328
-
329
- return c # float in same range as lut (expected [0,1])
330
-
331
- def apply_3d_lut(img_arr: np.ndarray, lut3d: np.ndarray, strength: float = 1.0) -> np.ndarray:
332
- """
333
- Apply a 3D LUT to the image.
334
- - img_arr: HxWx3 uint8
335
- - lut3d: SxSxSx3 float (expected range 0..1)
336
- - strength: blending 0..1
337
- Returns uint8 image.
338
- """
339
- if img_arr.ndim != 3 or img_arr.shape[2] != 3:
340
- raise ValueError("apply_3d_lut expects an HxWx3 image array")
341
-
342
- img_float = img_arr.astype(np.float32) / 255.0
343
- sampled = _trilinear_sample_lut(img_float, lut3d) # HxWx3 floats in [0,1]
344
- out = np.clip(sampled * 255.0, 0, 255).astype(np.uint8)
345
- if strength >= 1.0:
346
- return out
347
- else:
348
- blended = ((1.0 - strength) * img_arr.astype(np.float32) + strength * out.astype(np.float32))
349
- return np.clip(blended, 0, 255).astype(np.uint8)
350
-
351
- def apply_lut(img_arr: np.ndarray, lut: np.ndarray, strength: float = 1.0) -> np.ndarray:
352
- """
353
- Auto-detect LUT type and apply.
354
- - If lut.ndim in (1,2) treat as 1D LUT (per-channel if shape (N,3)).
355
- - If lut.ndim == 4 treat as 3D LUT (SxSxSx3) in [0,1].
356
- """
357
- lut = np.array(lut)
358
- if lut.ndim == 4 and lut.shape[3] == 3:
359
- # 3D LUT (assumed normalized [0..1])
360
- # If lut is in 0..255, normalize
361
- if lut.dtype != np.float32 and lut.max() > 1.0:
362
- lut = lut.astype(np.float32) / 255.0
363
- return apply_3d_lut(img_arr, lut, strength=strength)
364
- elif lut.ndim in (1, 2):
365
- return apply_1d_lut(img_arr, lut, strength=strength)
366
- else:
367
- raise ValueError("Unsupported LUT shape: {}".format(lut.shape))
368
-
369
- def load_cube_lut(path: str) -> np.ndarray:
370
- """
371
- Parse a .cube file and return a 3D LUT array of shape (S,S,S,3) with float values in [0,1].
372
- Note: .cube file order sometimes varies; this function assumes standard ordering
373
- where data lines are triples of floats and LUT_3D_SIZE specifies S.
374
- """
375
- with open(path, 'r', encoding='utf-8', errors='ignore') as f:
376
- lines = [ln.strip() for ln in f if ln.strip() and not ln.strip().startswith('#')]
377
-
378
- size = None
379
- data = []
380
- domain_min = np.array([0.0, 0.0, 0.0], dtype=np.float32)
381
- domain_max = np.array([1.0, 1.0, 1.0], dtype=np.float32)
382
-
383
- for ln in lines:
384
- if ln.upper().startswith('LUT_3D_SIZE'):
385
- parts = ln.split()
386
- if len(parts) >= 2:
387
- size = int(parts[1])
388
- elif ln.upper().startswith('DOMAIN_MIN'):
389
- parts = ln.split()
390
- domain_min = np.array([float(p) for p in parts[1:4]], dtype=np.float32)
391
- elif ln.upper().startswith('DOMAIN_MAX'):
392
- parts = ln.split()
393
- domain_max = np.array([float(p) for p in parts[1:4]], dtype=np.float32)
394
- elif re.match(r'^-?\d+(\.\d+)?\s+-?\d+(\.\d+)?\s+-?\d+(\.\d+)?$', ln):
395
- parts = [float(x) for x in ln.split()]
396
- data.append(parts)
397
-
398
- if size is None:
399
- raise ValueError("LUT_3D_SIZE not found in .cube file: {}".format(path))
400
-
401
- data = np.array(data, dtype=np.float32)
402
- if data.shape[0] != size**3:
403
- raise ValueError("Cube LUT data length does not match size^3 (got {}, expected {})".format(data.shape[0], size**3))
404
-
405
- # Data ordering in many .cube files is: for r in 0..S-1: for g in 0..S-1: for b in 0..S-1: write RGB
406
- # We'll reshape into (S,S,S,3) with indices [r,g,b]
407
- lut = data.reshape((size, size, size, 3))
408
- # Map domain_min..domain_max to 0..1 if domain specified (rare)
409
- if not np.allclose(domain_min, [0.0, 0.0, 0.0]) or not np.allclose(domain_max, [1.0, 1.0, 1.0]):
410
- # scale lut values from domain range into 0..1
411
- lut = (lut - domain_min) / (domain_max - domain_min + 1e-12)
412
- lut = np.clip(lut, 0.0, 1.0)
413
- else:
414
- # ensure LUT is in [0,1] if not already
415
- if lut.max() > 1.0 + 1e-6:
416
- lut = lut / 255.0
417
- return lut.astype(np.float32)
418
-
419
- def load_lut(path: str) -> np.ndarray:
420
- """
421
- Load a LUT from:
422
- - .npy (numpy saved array)
423
- - .cube (3D LUT)
424
- - image (PNG/JPG) that is a 1D LUT strip (common 256x1 or 1x256)
425
- Returns numpy array (1D, 2D, or 4D LUT).
426
- """
427
- ext = os.path.splitext(path)[1].lower()
428
- if ext == '.npy':
429
- return np.load(path)
430
- elif ext == '.cube':
431
- return load_cube_lut(path)
432
- else:
433
- # try interpreting as image-based 1D LUT
434
- try:
435
- im = Image.open(path).convert('RGB')
436
- arr = np.array(im)
437
- h, w = arr.shape[:2]
438
- # 256x1 or 1x256 typical 1D LUT
439
- if (w == 256 and h == 1) or (h == 256 and w == 1):
440
- if h == 1:
441
- lut = arr[0, :, :].astype(np.float32)
442
- else:
443
- lut = arr[:, 0, :].astype(np.float32)
444
- return lut # shape (256,3)
445
- # sometimes embedded as 512x16 or other tile layouts; attempt to flatten
446
- # fallback: flatten and try to build (N,3)
447
- flat = arr.reshape(-1, 3).astype(np.float32)
448
- # if length is perfect power-of-two and <= 1024, assume 1D
449
- L = flat.shape[0]
450
- if L <= 4096:
451
- return flat # (L,3)
452
- raise ValueError("Image LUT not recognized size")
453
- except Exception as e:
454
- raise ValueError(f"Unsupported LUT file or parse error for {path}: {e}")
455
-
456
- # --- end appended LUT helpers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
run.py CHANGED
@@ -1,613 +1,34 @@
1
  #!/usr/bin/env python3
2
  """
3
- Main GUI application for image_postprocess pipeline with camera-simulator controls.
4
-
5
- Updated: added LUT support UI (enable checkbox, file chooser, strength) and wiring to on_run.
6
  """
7
 
8
  import sys
9
- import os
10
  from pathlib import Path
11
- from PyQt5.QtWidgets import (
12
- QApplication, QMainWindow, QWidget, QLabel, QPushButton, QFileDialog,
13
- QHBoxLayout, QVBoxLayout, QFormLayout, QSlider, QSpinBox, QDoubleSpinBox,
14
- QProgressBar, QMessageBox, QLineEdit, QComboBox, QCheckBox, QToolButton, QScrollArea
15
- )
16
- from PyQt5.QtCore import Qt
17
- from PyQt5.QtGui import QPixmap
18
- from worker import Worker
19
- from analysis_panel import AnalysisPanel
20
- from utils import qpixmap_from_path
21
- from collapsible_box import CollapsibleBox
22
  from theme import apply_dark_palette
23
 
24
  try:
25
  from image_postprocess import process_image
26
  except Exception as e:
27
- process_image = None
28
  IMPORT_ERROR = str(e)
29
  else:
30
  IMPORT_ERROR = None
31
 
32
- class MainWindow(QMainWindow):
33
- def __init__(self):
34
- super().__init__()
35
- self.setWindowTitle("Image Postprocess — GUI (Camera Simulator)")
36
- self.setMinimumSize(1200, 760)
37
-
38
- central = QWidget()
39
- self.setCentralWidget(central)
40
- main_h = QHBoxLayout(central)
41
-
42
- # Left: previews & file selection
43
- left_v = QVBoxLayout()
44
- main_h.addLayout(left_v, 2)
45
-
46
- # Input/Output collapsible
47
- io_box = CollapsibleBox("Input / Output")
48
- left_v.addWidget(io_box)
49
- in_layout = QFormLayout()
50
- io_container = QWidget()
51
- io_container.setLayout(in_layout)
52
- io_box.content_layout.addWidget(io_container)
53
-
54
- self.input_line = QLineEdit()
55
- self.input_btn = QPushButton("Choose Input")
56
- self.input_btn.clicked.connect(self.choose_input)
57
-
58
- self.ref_line = QLineEdit()
59
- self.ref_btn = QPushButton("Choose AWB Reference (optional)")
60
- self.ref_btn.clicked.connect(self.choose_ref)
61
-
62
- self.fft_ref_line = QLineEdit()
63
- self.fft_ref_btn = QPushButton("Choose FFT Reference (optional)")
64
- self.fft_ref_btn.clicked.connect(self.choose_fft_ref)
65
-
66
- self.output_line = QLineEdit()
67
- self.output_btn = QPushButton("Choose Output")
68
- self.output_btn.clicked.connect(self.choose_output)
69
-
70
- in_layout.addRow(self.input_btn, self.input_line)
71
- in_layout.addRow(self.ref_btn, self.ref_line)
72
- in_layout.addRow(self.fft_ref_btn, self.fft_ref_line)
73
- in_layout.addRow(self.output_btn, self.output_line)
74
-
75
- # Previews
76
- self.preview_in = QLabel(alignment=Qt.AlignCenter)
77
- self.preview_in.setFixedSize(480, 300)
78
- self.preview_in.setStyleSheet("background:#121213; border:1px solid #2b2b2b; color:#ddd; border-radius:6px")
79
- self.preview_in.setText("Input preview")
80
-
81
- self.preview_out = QLabel(alignment=Qt.AlignCenter)
82
- self.preview_out.setFixedSize(480, 300)
83
- self.preview_out.setStyleSheet("background:#121213; border:1px solid #2b2b2b; color:#ddd; border-radius:6px")
84
- self.preview_out.setText("Output preview")
85
-
86
- left_v.addWidget(self.preview_in)
87
- left_v.addWidget(self.preview_out)
88
-
89
- # Actions
90
- actions_h = QHBoxLayout()
91
- self.run_btn = QPushButton("Run — Process Image")
92
- self.run_btn.clicked.connect(self.on_run)
93
- self.open_out_btn = QPushButton("Open Output Folder")
94
- self.open_out_btn.clicked.connect(self.open_output_folder)
95
- actions_h.addWidget(self.run_btn)
96
- actions_h.addWidget(self.open_out_btn)
97
- left_v.addLayout(actions_h)
98
-
99
- self.progress = QProgressBar()
100
- self.progress.setTextVisible(True)
101
- self.progress.setRange(0, 100)
102
- self.progress.setValue(0)
103
- left_v.addWidget(self.progress)
104
-
105
- # Right: controls + analysis panels (with scroll area)
106
- scroll_area = QScrollArea()
107
- scroll_area.setWidgetResizable(True)
108
- scroll_area.setStyleSheet("QScrollArea { border: none; }")
109
- main_h.addWidget(scroll_area, 3)
110
-
111
- scroll_widget = QWidget()
112
- right_v = QVBoxLayout(scroll_widget)
113
- scroll_area.setWidget(scroll_widget)
114
-
115
- # Auto Mode toggle (keeps top-level quick switch visible)
116
- self.auto_mode_chk = QCheckBox("Enable Auto Mode")
117
- self.auto_mode_chk.setChecked(False)
118
- self.auto_mode_chk.stateChanged.connect(self._on_auto_mode_toggled)
119
- right_v.addWidget(self.auto_mode_chk)
120
-
121
- # Make Auto Mode section collapsible
122
- self.auto_box = CollapsibleBox("Auto Mode")
123
- right_v.addWidget(self.auto_box)
124
- auto_layout = QFormLayout()
125
- auto_container = QWidget()
126
- auto_container.setLayout(auto_layout)
127
- self.auto_box.content_layout.addWidget(auto_container)
128
-
129
- strength_layout = QHBoxLayout()
130
- self.strength_slider = QSlider(Qt.Horizontal)
131
- self.strength_slider.setRange(0, 100)
132
- self.strength_slider.setValue(25)
133
- self.strength_slider.valueChanged.connect(self._update_strength_label)
134
- self.strength_label = QLabel("25")
135
- self.strength_label.setFixedWidth(30)
136
- strength_layout.addWidget(self.strength_slider)
137
- strength_layout.addWidget(self.strength_label)
138
-
139
- auto_layout.addRow("Aberration Strength", strength_layout)
140
-
141
- # Parameters (Manual Mode) collapsible
142
- self.params_box = CollapsibleBox("Parameters (Manual Mode)")
143
- right_v.addWidget(self.params_box)
144
- params_layout = QFormLayout()
145
- params_container = QWidget()
146
- params_container.setLayout(params_layout)
147
- self.params_box.content_layout.addWidget(params_container)
148
-
149
- # Noise-std
150
- self.noise_spin = QDoubleSpinBox()
151
- self.noise_spin.setRange(0.0, 0.1)
152
- self.noise_spin.setSingleStep(0.001)
153
- self.noise_spin.setValue(0.02)
154
- self.noise_spin.setToolTip("Gaussian noise std fraction of 255")
155
- params_layout.addRow("Noise std (0-0.1)", self.noise_spin)
156
-
157
- # CLAHE-clip
158
- self.clahe_spin = QDoubleSpinBox()
159
- self.clahe_spin.setRange(0.1, 10.0)
160
- self.clahe_spin.setSingleStep(0.1)
161
- self.clahe_spin.setValue(2.0)
162
- params_layout.addRow("CLAHE clip", self.clahe_spin)
163
-
164
- # Tile
165
- self.tile_spin = QSpinBox()
166
- self.tile_spin.setRange(1, 64)
167
- self.tile_spin.setValue(8)
168
- params_layout.addRow("CLAHE tile", self.tile_spin)
169
-
170
- # Cutoff
171
- self.cutoff_spin = QDoubleSpinBox()
172
- self.cutoff_spin.setRange(0.01, 1.0)
173
- self.cutoff_spin.setSingleStep(0.01)
174
- self.cutoff_spin.setValue(0.25)
175
- params_layout.addRow("Fourier cutoff (0-1)", self.cutoff_spin)
176
-
177
- # Fstrength
178
- self.fstrength_spin = QDoubleSpinBox()
179
- self.fstrength_spin.setRange(0.0, 1.0)
180
- self.fstrength_spin.setSingleStep(0.01)
181
- self.fstrength_spin.setValue(0.9)
182
- params_layout.addRow("Fourier strength (0-1)", self.fstrength_spin)
183
-
184
- # Randomness
185
- self.randomness_spin = QDoubleSpinBox()
186
- self.randomness_spin.setRange(0.0, 1.0)
187
- self.randomness_spin.setSingleStep(0.01)
188
- self.randomness_spin.setValue(0.05)
189
- params_layout.addRow("Fourier randomness", self.randomness_spin)
190
-
191
- # Phase_perturb
192
- self.phase_perturb_spin = QDoubleSpinBox()
193
- self.phase_perturb_spin.setRange(0.0, 1.0)
194
- self.phase_perturb_spin.setSingleStep(0.001)
195
- self.phase_perturb_spin.setValue(0.08)
196
- self.phase_perturb_spin.setToolTip("Phase perturbation std (radians)")
197
- params_layout.addRow("Phase perturb (rad)", self.phase_perturb_spin)
198
-
199
- # Radial_smooth
200
- self.radial_smooth_spin = QSpinBox()
201
- self.radial_smooth_spin.setRange(0, 50)
202
- self.radial_smooth_spin.setValue(5)
203
- params_layout.addRow("Radial smooth (bins)", self.radial_smooth_spin)
204
-
205
- # FFT_mode
206
- self.fft_mode_combo = QComboBox()
207
- self.fft_mode_combo.addItems(["auto", "ref", "model"])
208
- self.fft_mode_combo.setCurrentText("auto")
209
- params_layout.addRow("FFT mode", self.fft_mode_combo)
210
-
211
- # FFT_alpha
212
- self.fft_alpha_spin = QDoubleSpinBox()
213
- self.fft_alpha_spin.setRange(0.1, 4.0)
214
- self.fft_alpha_spin.setSingleStep(0.1)
215
- self.fft_alpha_spin.setValue(1.0)
216
- self.fft_alpha_spin.setToolTip("Alpha exponent for 1/f model when using model mode")
217
- params_layout.addRow("FFT alpha (model)", self.fft_alpha_spin)
218
-
219
- # Perturb
220
- self.perturb_spin = QDoubleSpinBox()
221
- self.perturb_spin.setRange(0.0, 0.05)
222
- self.perturb_spin.setSingleStep(0.001)
223
- self.perturb_spin.setValue(0.008)
224
- params_layout.addRow("Pixel perturb", self.perturb_spin)
225
-
226
- # Seed
227
- self.seed_spin = QSpinBox()
228
- self.seed_spin.setRange(0, 2 ** 31 - 1)
229
- self.seed_spin.setValue(0)
230
- params_layout.addRow("Seed (0=none)", self.seed_spin)
231
-
232
- # AWB checkbox
233
- self.awb_chk = QCheckBox("Enable auto white-balance (AWB)")
234
- self.awb_chk.setChecked(False)
235
- self.awb_chk.setToolTip("If checked, AWB is applied. If a reference image is chosen, it will be used; otherwise gray-world AWB is applied.")
236
- params_layout.addRow(self.awb_chk)
237
-
238
- # Camera simulator toggle
239
- self.sim_camera_chk = QCheckBox("Enable camera pipeline simulation")
240
- self.sim_camera_chk.setChecked(False)
241
- self.sim_camera_chk.stateChanged.connect(self._on_sim_camera_toggled)
242
- params_layout.addRow(self.sim_camera_chk)
243
-
244
- # --- LUT support UI ---
245
- self.lut_chk = QCheckBox("Enable LUT")
246
- self.lut_chk.setChecked(False)
247
- self.lut_chk.setToolTip("Enable applying a 1D/.npy/.cube LUT to the output image")
248
- self.lut_chk.stateChanged.connect(self._on_lut_toggled)
249
- params_layout.addRow(self.lut_chk)
250
-
251
- # LUT chooser (hidden until checkbox checked)
252
- self.lut_line = QLineEdit()
253
- self.lut_btn = QPushButton("Choose LUT")
254
- self.lut_btn.clicked.connect(self.choose_lut)
255
- lut_box = QWidget()
256
- lut_box_layout = QHBoxLayout()
257
- lut_box_layout.setContentsMargins(0, 0, 0, 0)
258
- lut_box.setLayout(lut_box_layout)
259
- lut_box_layout.addWidget(self.lut_line)
260
- lut_box_layout.addWidget(self.lut_btn)
261
- self.lut_file_label = QLabel("LUT file (png/.npy/.cube)")
262
- params_layout.addRow(self.lut_file_label, lut_box)
263
-
264
- self.lut_strength_spin = QDoubleSpinBox()
265
- self.lut_strength_spin.setRange(0.0, 1.0)
266
- self.lut_strength_spin.setSingleStep(0.01)
267
- self.lut_strength_spin.setValue(1.0)
268
- self.lut_strength_spin.setToolTip("Blend strength for LUT (0.0 = no effect, 1.0 = full LUT)")
269
- self.lut_strength_label = QLabel("LUT strength")
270
- params_layout.addRow(self.lut_strength_label, self.lut_strength_spin)
271
-
272
- # Initially hide LUT controls and their labels
273
- self.lut_file_label.setVisible(False)
274
- lut_box.setVisible(False)
275
- self.lut_strength_label.setVisible(False)
276
- self.lut_strength_spin.setVisible(False)
277
-
278
- # Store all widgets that need their visibility toggled
279
- self._lut_controls = (self.lut_file_label, lut_box, self.lut_strength_label, self.lut_strength_spin)
280
-
281
- # Camera simulator collapsible group
282
- self.camera_box = CollapsibleBox("Camera simulator options")
283
- right_v.addWidget(self.camera_box)
284
- cam_layout = QFormLayout()
285
- cam_container = QWidget()
286
- cam_container.setLayout(cam_layout)
287
- self.camera_box.content_layout.addWidget(cam_container)
288
-
289
- # Enable bayer
290
- self.bayer_chk = QCheckBox("Enable Bayer / demosaic (RGGB)")
291
- self.bayer_chk.setChecked(True)
292
- cam_layout.addRow(self.bayer_chk)
293
-
294
- # JPEG cycles
295
- self.jpeg_cycles_spin = QSpinBox()
296
- self.jpeg_cycles_spin.setRange(0, 10)
297
- self.jpeg_cycles_spin.setValue(1)
298
- cam_layout.addRow("JPEG cycles", self.jpeg_cycles_spin)
299
-
300
- # JPEG quality min/max
301
- self.jpeg_qmin_spin = QSpinBox()
302
- self.jpeg_qmin_spin.setRange(1, 100)
303
- self.jpeg_qmin_spin.setValue(88)
304
- self.jpeg_qmax_spin = QSpinBox()
305
- self.jpeg_qmax_spin.setRange(1, 100)
306
- self.jpeg_qmax_spin.setValue(96)
307
- qbox = QHBoxLayout()
308
- qbox.addWidget(self.jpeg_qmin_spin)
309
- qbox.addWidget(QLabel("to"))
310
- qbox.addWidget(self.jpeg_qmax_spin)
311
- cam_layout.addRow("JPEG quality (min to max)", qbox)
312
-
313
- # Vignette strength
314
- self.vignette_spin = QDoubleSpinBox()
315
- self.vignette_spin.setRange(0.0, 1.0)
316
- self.vignette_spin.setSingleStep(0.01)
317
- self.vignette_spin.setValue(0.35)
318
- cam_layout.addRow("Vignette strength", self.vignette_spin)
319
-
320
- # Chromatic aberration strength
321
- self.chroma_spin = QDoubleSpinBox()
322
- self.chroma_spin.setRange(0.0, 10.0)
323
- self.chroma_spin.setSingleStep(0.1)
324
- self.chroma_spin.setValue(1.2)
325
- cam_layout.addRow("Chromatic aberration (px)", self.chroma_spin)
326
-
327
- # ISO scale
328
- self.iso_spin = QDoubleSpinBox()
329
- self.iso_spin.setRange(0.1, 16.0)
330
- self.iso_spin.setSingleStep(0.1)
331
- self.iso_spin.setValue(1.0)
332
- cam_layout.addRow("ISO/exposure scale", self.iso_spin)
333
-
334
- # Read noise
335
- self.read_noise_spin = QDoubleSpinBox()
336
- self.read_noise_spin.setRange(0.0, 50.0)
337
- self.read_noise_spin.setSingleStep(0.1)
338
- self.read_noise_spin.setValue(2.0)
339
- cam_layout.addRow("Read noise (DN)", self.read_noise_spin)
340
-
341
- # Hot pixel prob
342
- self.hot_pixel_spin = QDoubleSpinBox()
343
- self.hot_pixel_spin.setDecimals(9)
344
- self.hot_pixel_spin.setRange(0.0, 1.0)
345
- self.hot_pixel_spin.setSingleStep(1e-6)
346
- self.hot_pixel_spin.setValue(1e-6)
347
- cam_layout.addRow("Hot pixel prob", self.hot_pixel_spin)
348
-
349
- # Banding strength
350
- self.banding_spin = QDoubleSpinBox()
351
- self.banding_spin.setRange(0.0, 1.0)
352
- self.banding_spin.setSingleStep(0.01)
353
- self.banding_spin.setValue(0.0)
354
- cam_layout.addRow("Banding strength", self.banding_spin)
355
-
356
- # Motion blur kernel
357
- self.motion_blur_spin = QSpinBox()
358
- self.motion_blur_spin.setRange(1, 51)
359
- self.motion_blur_spin.setValue(1)
360
- cam_layout.addRow("Motion blur kernel", self.motion_blur_spin)
361
-
362
- self.camera_box.setVisible(False)
363
-
364
- self.ref_hint = QLabel("AWB uses the 'AWB reference' chooser. FFT spectral matching uses the 'FFT Reference' chooser.")
365
- right_v.addWidget(self.ref_hint)
366
-
367
- self.analysis_input = AnalysisPanel(title="Input analysis")
368
- self.analysis_output = AnalysisPanel(title="Output analysis")
369
- right_v.addWidget(self.analysis_input)
370
- right_v.addWidget(self.analysis_output)
371
-
372
- right_v.addStretch(1)
373
-
374
- # Status bar
375
- self.status = QLabel("Ready")
376
- self.status.setStyleSheet("color:#bdbdbd;padding:6px")
377
- self.status.setAlignment(Qt.AlignLeft)
378
- self.status.setFixedHeight(28)
379
- self.status.setContentsMargins(6, 6, 6, 6)
380
- self.statusBar().addWidget(self.status)
381
-
382
- self.worker = None
383
- self._on_auto_mode_toggled(self.auto_mode_chk.checkState())
384
-
385
- def _on_sim_camera_toggled(self, state):
386
- enabled = state == Qt.Checked
387
- self.camera_box.setVisible(enabled)
388
-
389
- def _on_auto_mode_toggled(self, state):
390
- is_auto = (state == Qt.Checked)
391
- self.auto_box.setVisible(is_auto)
392
- self.params_box.setVisible(not is_auto)
393
-
394
- def _update_strength_label(self, value):
395
- self.strength_label.setText(str(value))
396
-
397
- def choose_input(self):
398
- path, _ = QFileDialog.getOpenFileName(self, "Choose input image", str(Path.home()), "Images (*.png *.jpg *.jpeg *.bmp *.tif)")
399
- if path:
400
- self.input_line.setText(path)
401
- self.load_preview(self.preview_in, path)
402
- self.analysis_input.update_from_path(path)
403
- out_suggest = str(Path(path).with_name(Path(path).stem + "_out" + Path(path).suffix))
404
- if not self.output_line.text():
405
- self.output_line.setText(out_suggest)
406
-
407
- def choose_ref(self):
408
- path, _ = QFileDialog.getOpenFileName(self, "Choose AWB reference image", str(Path.home()), "Images (*.png *.jpg *.jpeg *.bmp *.tif)")
409
- if path:
410
- self.ref_line.setText(path)
411
-
412
- def choose_fft_ref(self):
413
- path, _ = QFileDialog.getOpenFileName(self, "Choose FFT reference image", str(Path.home()), "Images (*.png *.jpg *.jpeg *.bmp *.tif)")
414
- if path:
415
- self.fft_ref_line.setText(path)
416
-
417
- def choose_output(self):
418
- path, _ = QFileDialog.getSaveFileName(self, "Choose output path", str(Path.home()), "JPEG (*.jpg *.jpeg);;PNG (*.png);;TIFF (*.tif)")
419
- if path:
420
- self.output_line.setText(path)
421
-
422
- def choose_lut(self):
423
- path, _ = QFileDialog.getOpenFileName(self, "Choose LUT file", str(Path.home()), "LUTs (*.png *.npy *.cube);;All files (*)")
424
- if path:
425
- self.lut_line.setText(path)
426
-
427
- def _on_lut_toggled(self, state):
428
- visible = (state == Qt.Checked)
429
- for w in self._lut_controls:
430
- w.setVisible(visible)
431
-
432
- def load_preview(self, widget: QLabel, path: str):
433
- if not path or not os.path.exists(path):
434
- widget.setText("No image")
435
- widget.setPixmap(QPixmap())
436
- return
437
- pix = qpixmap_from_path(path, max_size=(widget.width(), widget.height()))
438
- widget.setPixmap(pix)
439
-
440
- def set_enabled_all(self, enabled: bool):
441
- for w in self.findChildren((QPushButton, QDoubleSpinBox, QSpinBox, QLineEdit, QComboBox, QCheckBox, QSlider, QToolButton)):
442
- w.setEnabled(enabled)
443
-
444
- def on_run(self):
445
- from types import SimpleNamespace
446
- inpath = self.input_line.text().strip()
447
- outpath = self.output_line.text().strip()
448
- if not inpath or not os.path.exists(inpath):
449
- QMessageBox.warning(self, "Missing input", "Please choose a valid input image.")
450
- return
451
- if not outpath:
452
- QMessageBox.warning(self, "Missing output", "Please choose an output path.")
453
- return
454
-
455
- awb_ref_val = self.ref_line.text() or None
456
- fft_ref_val = self.fft_ref_line.text() or None
457
- args = SimpleNamespace()
458
-
459
- if self.auto_mode_chk.isChecked():
460
- strength = self.strength_slider.value() / 100.0
461
- args.noise_std = strength * 0.04
462
- args.clahe_clip = 1.0 + strength * 3.0
463
- args.cutoff = max(0.01, 0.4 - strength * 0.3)
464
- args.fstrength = strength * 0.95
465
- args.phase_perturb = strength * 0.1
466
- args.perturb = strength * 0.015
467
- args.jpeg_cycles = int(strength * 2)
468
- args.jpeg_qmin = max(1, int(95 - strength * 35))
469
- args.jpeg_qmax = max(1, int(99 - strength * 25))
470
- args.vignette_strength = strength * 0.6
471
- args.chroma_strength = strength * 4.0
472
- args.motion_blur_kernel = 1 + 2 * int(strength * 6)
473
- args.banding_strength = strength * 0.1
474
- args.tile = 8
475
- args.randomness = 0.05
476
- args.radial_smooth = 5
477
- args.fft_mode = "auto"
478
- args.fft_alpha = 1.0
479
- args.alpha = 1.0
480
- seed_val = int(self.seed_spin.value())
481
- args.seed = None if seed_val == 0 else seed_val
482
- args.sim_camera = bool(self.sim_camera_chk.isChecked())
483
- args.no_no_bayer = True
484
- args.iso_scale = 1.0
485
- args.read_noise = 2.0
486
- args.hot_pixel_prob = 1e-6
487
- else:
488
- seed_val = int(self.seed_spin.value())
489
- args.seed = None if seed_val == 0 else seed_val
490
- sim_camera = bool(self.sim_camera_chk.isChecked())
491
- enable_bayer = bool(self.bayer_chk.isChecked())
492
- args.noise_std = float(self.noise_spin.value())
493
- args.clahe_clip = float(self.clahe_spin.value())
494
- args.tile = int(self.tile_spin.value())
495
- args.cutoff = float(self.cutoff_spin.value())
496
- args.fstrength = float(self.fstrength_spin.value())
497
- args.strength = float(self.fstrength_spin.value())
498
- args.randomness = float(self.randomness_spin.value())
499
- args.phase_perturb = float(self.phase_perturb_spin.value())
500
- args.perturb = float(self.perturb_spin.value())
501
- args.fft_mode = self.fft_mode_combo.currentText()
502
- args.fft_alpha = float(self.fft_alpha_spin.value())
503
- args.alpha = float(self.fft_alpha_spin.value())
504
- args.radial_smooth = int(self.radial_smooth_spin.value())
505
- args.sim_camera = sim_camera
506
- args.no_no_bayer = bool(enable_bayer)
507
- args.jpeg_cycles = int(self.jpeg_cycles_spin.value())
508
- args.jpeg_qmin = int(self.jpeg_qmin_spin.value())
509
- args.jpeg_qmax = int(self.jpeg_qmax_spin.value())
510
- args.vignette_strength = float(self.vignette_spin.value())
511
- args.chroma_strength = float(self.chroma_spin.value())
512
- args.iso_scale = float(self.iso_spin.value())
513
- args.read_noise = float(self.read_noise_spin.value())
514
- args.hot_pixel_prob = float(self.hot_pixel_spin.value())
515
- args.banding_strength = float(self.banding_spin.value())
516
- args.motion_blur_kernel = int(self.motion_blur_spin.value())
517
-
518
- # AWB handling to match the new --awb flag in the backend
519
- if self.awb_chk.isChecked():
520
- args.awb = True
521
- args.ref = awb_ref_val # This can be the path or None (for grey-world)
522
- else:
523
- args.awb = False
524
- args.ref = None # Not strictly necessary as backend ignores it, but good practice
525
-
526
- # FFT spectral matching reference
527
- args.fft_ref = fft_ref_val
528
-
529
- # LUT handling: only include if LUT checkbox is checked and a path is provided
530
- if self.lut_chk.isChecked():
531
- lut_path = self.lut_line.text().strip()
532
- args.lut = lut_path if lut_path else None
533
- args.lut_strength = float(self.lut_strength_spin.value())
534
- else:
535
- args.lut = None
536
- args.lut_strength = 1.0
537
-
538
- self.worker = Worker(inpath, outpath, args)
539
- self.worker.finished.connect(self.on_finished)
540
- self.worker.error.connect(self.on_error)
541
- self.worker.started.connect(lambda: self.on_worker_started())
542
- self.worker.start()
543
-
544
- self.progress.setRange(0, 0)
545
- self.status.setText("Processing...")
546
- self.set_enabled_all(False)
547
-
548
- def on_worker_started(self):
549
- pass
550
-
551
- def on_finished(self, outpath):
552
- self.progress.setRange(0, 100)
553
- self.progress.setValue(100)
554
- self.status.setText("Done — saved to: " + outpath)
555
- self.load_preview(self.preview_out, outpath)
556
- self.analysis_output.update_from_path(outpath)
557
- self.set_enabled_all(True)
558
-
559
- def on_error(self, msg, traceback_text):
560
- from PyQt5.QtWidgets import QDialog, QTextEdit
561
- self.progress.setRange(0, 100)
562
- self.progress.setValue(0)
563
- self.status.setText("Error")
564
-
565
- dialog = QDialog(self)
566
- dialog.setWindowTitle("Processing Error")
567
- dialog.setMinimumSize(700, 480)
568
- layout = QVBoxLayout(dialog)
569
-
570
- error_label = QLabel(f"Error: {msg}")
571
- error_label.setWordWrap(True)
572
- layout.addWidget(error_label)
573
-
574
- traceback_edit = QTextEdit()
575
- traceback_edit.setReadOnly(True)
576
- traceback_edit.setText(traceback_text)
577
- traceback_edit.setStyleSheet("font-family: monospace; font-size: 12px;")
578
- layout.addWidget(traceback_edit)
579
-
580
- ok_button = QPushButton("OK")
581
- ok_button.clicked.connect(dialog.accept)
582
- layout.addWidget(ok_button)
583
-
584
- dialog.exec_()
585
- self.set_enabled_all(True)
586
-
587
- def open_output_folder(self):
588
- out = self.output_line.text().strip()
589
- if not out:
590
- QMessageBox.information(self, "No output", "No output path set yet.")
591
- return
592
- folder = os.path.dirname(os.path.abspath(out))
593
- if not os.path.exists(folder):
594
- QMessageBox.warning(self, "Not found", "Output folder does not exist: " + folder)
595
- return
596
- if sys.platform.startswith('darwin'):
597
- os.system(f'open "{folder}"')
598
- elif os.name == 'nt':
599
- os.startfile(folder)
600
- else:
601
- os.system(f'xdg-open "{folder}"')
602
 
603
  def main():
604
  app = QApplication([])
605
  apply_dark_palette(app)
 
606
  if IMPORT_ERROR:
607
- QMessageBox.critical(None, "Import error", "Could not import image_postprocess module:\n" + IMPORT_ERROR)
 
 
608
  w = MainWindow()
609
  w.show()
610
  sys.exit(app.exec_())
611
 
612
  if __name__ == '__main__':
613
- main()
 
1
  #!/usr/bin/env python3
2
  """
3
+ Entry point for Image Postprocess GUI (camera simulator).
4
+ Handles the import check for image_postprocess and launches the MainWindow.
 
5
  """
6
 
7
  import sys
 
8
  from pathlib import Path
9
+ from PyQt5.QtWidgets import QApplication, QMessageBox
 
 
 
 
 
 
 
 
 
 
10
  from theme import apply_dark_palette
11
 
12
  try:
13
  from image_postprocess import process_image
14
  except Exception as e:
 
15
  IMPORT_ERROR = str(e)
16
  else:
17
  IMPORT_ERROR = None
18
 
19
+ from main_window import MainWindow
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  def main():
22
  app = QApplication([])
23
  apply_dark_palette(app)
24
+
25
  if IMPORT_ERROR:
26
+ QMessageBox.critical(None, "Import error",
27
+ "Could not import image_postprocess module:\n" + IMPORT_ERROR)
28
+
29
  w = MainWindow()
30
  w.show()
31
  sys.exit(app.exec_())
32
 
33
  if __name__ == '__main__':
34
+ main()