ethix commited on
Commit
5e8be73
·
1 Parent(s): 2a8ed16

Refactored for Gradio / HF Spaces usage.

Browse files

- Introduced `app_gradio.py` for a lightweight Gradio front-end to the image processing pipeline.
- Updated README.md with instructions for running the Gradio app locally and deploying to Hugging Face Spaces.
- Fixed channel reversal issue in camera pipeline simulation.
- Updated requirements.txt to include Gradio and removed PyQt5.
- Added test scripts for Gradio functionality and processing validation.

README.md CHANGED
@@ -151,6 +151,50 @@ def process_image(inpath: str, outpath: str, args):
151
  - PRs welcome. If you modify UI layout or parameter names, keep the `args` mapping consistent or update `README` and `worker.py` accordingly.
152
  - Add unit tests for `worker.py` and the parameter serialization if you intend to refactor.
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  ---
155
 
156
  ## License
 
151
  - PRs welcome. If you modify UI layout or parameter names, keep the `args` mapping consistent or update `README` and `worker.py` accordingly.
152
  - Add unit tests for `worker.py` and the parameter serialization if you intend to refactor.
153
 
154
+ ## Gradio / Hugging Face Spaces (Web UI)
155
+
156
+ This repository includes a lightweight Gradio front-end (`app_gradio.py`) that wraps the existing
157
+ `process_image(inpath, outpath, args)` pipeline. The Gradio app is suitable for local testing and
158
+ for deployment to Hugging Face Spaces (Gradio-backed web apps).
159
+
160
+ ### Quick local run
161
+
162
+ 1. Install dependencies:
163
+
164
+ ```bash
165
+ pip install -r requirements.txt
166
+ ```
167
+
168
+ 2. Launch the Gradio app:
169
+
170
+ ```bash
171
+ python3 app_gradio.py
172
+ ```
173
+
174
+ Open http://localhost:7860 in your browser. The UI saves the uploaded image to a temporary file,
175
+ calls the existing `process_image` pipeline, and returns the processed image.
176
+
177
+ ### Deploying to Hugging Face Spaces
178
+
179
+ 1. Ensure the following are present at the repository root:
180
+ - `app_gradio.py` (the Gradio entrypoint)
181
+ - `requirements.txt` (must include `gradio` and any other runtime deps)
182
+
183
+ 2. Push the repository to a new Space on Hugging Face (create a new Space and connect this repo or
184
+ push to the Space's Git remote). Spaces will automatically run the Gradio app.
185
+
186
+ Notes & tips for Spaces:
187
+ - Keep default upload/processing sizes modest to avoid long CPU usage in the free tier.
188
+ - If your pipeline uses optional packages (OpenCV, piexif, etc.), make sure they are listed in
189
+ `requirements.txt` so Spaces installs them.
190
+ - If processing is slow, consider reducing default image size or exposing fewer parameters to the
191
+ main UI and keeping advanced controls hidden in an "Advanced" section.
192
+
193
+ ### Troubleshooting
194
+ - If Gradio is not installed, `app_gradio.py` will raise an error; add `gradio` to `requirements.txt`.
195
+ - Any import errors from `image_postprocess` will surface when calling the app; run the smoke test
196
+ (`python3 test_smoke_gradio.py`) locally to validate imports and pipeline execution before pushing.
197
+
198
  ---
199
 
200
  ## License
app_gradio.py ADDED
@@ -0,0 +1,661 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gradio web UI wrapper for the Image Postprocess pipeline.
3
+
4
+ This lightweight wrapper saves the uploaded image to a temporary file,
5
+ constructs a minimal args namespace expected by `process_image`, runs the
6
+ processing pipeline, and returns the result to the browser.
7
+
8
+ Designed for quick deployment on Huggingface Spaces.
9
+ """
10
+ from pathlib import Path
11
+ import tempfile
12
+ import os
13
+ from types import SimpleNamespace
14
+ from typing import Optional
15
+ from PIL import Image
16
+ import io
17
+ import matplotlib.pyplot as plt
18
+ import numpy as np
19
+ import json
20
+
21
+ # Preset persistence file (in repo root)
22
+ PRESETS_FILE = Path(__file__).parent / "presets.json"
23
+
24
+ # Builtin presets
25
+ BUILTIN_PRESETS = {
26
+ "Default": {},
27
+ "NovaNodes (reference)": {
28
+ "noise_std": 0.02,
29
+ "clahe_clip": 2.0,
30
+ "tile": 8,
31
+ "cutoff": 0.25,
32
+ "fstrength": 0.9,
33
+ "randomness": 0.05,
34
+ # align perturb with NovaNodes default
35
+ "perturb": 0.01,
36
+ "phase_perturb": 0.08,
37
+ "radial_smooth": 5,
38
+ "jpeg_cycles": 1,
39
+ "jpeg_qmin": 88,
40
+ # camera simulation enabled by default for 'reference'
41
+ "sim_camera": True,
42
+ "vignette_strength": 0.35,
43
+ "chroma_strength": 1.2,
44
+ "iso_scale": 1.0,
45
+ "read_noise": 2.0,
46
+ # align hot pixel probability with NovaNodes default
47
+ "hot_pixel_prob": 1e-7,
48
+ "no_no_bayer": False,
49
+ # align LUT strength to node default (1.0)
50
+ "lut_strength": 1.0,
51
+ },
52
+ "High JPEG cycles": {"jpeg_cycles": 3, "jpeg_qmin": 70},
53
+ "Aggressive": {"noise_std": 0.06, "fstrength": 1.0, "perturb": 0.02, "jpeg_cycles": 2},
54
+ "Subtle": {"noise_std": 0.01, "fstrength": 0.6},
55
+
56
+ # Conservative preview preset (closer to input image): disables camera simulation
57
+ "Preview (no camera sim)": {"sim_camera": False, "noise_std": 0.01, "fstrength": 0.6},
58
+ }
59
+
60
+
61
+ def load_custom_presets():
62
+ if PRESETS_FILE.exists():
63
+ try:
64
+ return json.loads(PRESETS_FILE.read_text())
65
+ except Exception:
66
+ return {}
67
+ return {}
68
+
69
+
70
+ def save_custom_preset(name: str, data: dict):
71
+ presets = load_custom_presets()
72
+ presets[name] = data
73
+ PRESETS_FILE.write_text(json.dumps(presets, indent=2))
74
+
75
+
76
+ def get_preset_overrides(name: str):
77
+ if name in BUILTIN_PRESETS:
78
+ return BUILTIN_PRESETS[name].copy()
79
+ customs = load_custom_presets()
80
+ return customs.get(name, {}).copy()
81
+
82
+
83
+ def preset_summary(name: str):
84
+ overrides = get_preset_overrides(name)
85
+ if not overrides:
86
+ return "(no overrides)"
87
+ return json.dumps(overrides, indent=2)
88
+
89
+ gr = None
90
+
91
+ try:
92
+ from image_postprocess import process_image
93
+ except Exception as e:
94
+ process_image = None
95
+ IMPORT_ERROR = str(e)
96
+ else:
97
+ IMPORT_ERROR = None
98
+
99
+
100
+ def _mk_temp_file(suffix: str = ".png") -> str:
101
+ f = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
102
+ f.close()
103
+ return f.name
104
+
105
+
106
+ def run_process(
107
+ img: Image.Image,
108
+ noise_std: float = 0.02,
109
+ clahe_clip: float = 2.0,
110
+ tile: int = 8,
111
+ cutoff: float = 0.25,
112
+ fstrength: float = 0.9,
113
+ awb: bool = True,
114
+ sim_camera: bool = True,
115
+ lut_file: Optional[Path] = None,
116
+ lut_strength: float = 0.1,
117
+ ):
118
+ """Run the repository's processing pipeline on a PIL image and return a PIL image.
119
+
120
+ Returns (pil.Image or None, status string).
121
+ """
122
+ if process_image is None:
123
+ return None, f"Backend import error: {IMPORT_ERROR}"
124
+
125
+ tmp_files = []
126
+ try:
127
+ in_path = _mk_temp_file(suffix=".png")
128
+ img.save(in_path)
129
+ tmp_files.append(in_path)
130
+
131
+ out_path = _mk_temp_file(suffix=".jpg")
132
+ tmp_files.append(out_path)
133
+
134
+ lut_path = None
135
+ if lut_file is not None:
136
+ # gr.File gives a pathlib.Path-like object; accept either str or Path
137
+ lut_path = str(lut_file)
138
+
139
+ args = SimpleNamespace(
140
+ input=in_path,
141
+ output=out_path,
142
+ awb=bool(awb),
143
+ ref=None,
144
+ noise_std=float(noise_std),
145
+ clahe_clip=float(clahe_clip),
146
+ tile=int(tile),
147
+ cutoff=float(cutoff),
148
+ fstrength=float(fstrength),
149
+ randomness=0.05,
150
+ perturb=0.008,
151
+ seed=None,
152
+ fft_ref=None,
153
+ fft_mode="auto",
154
+ fft_alpha=1.0,
155
+ phase_perturb=0.08,
156
+ radial_smooth=5,
157
+ sim_camera=bool(sim_camera),
158
+ no_no_bayer=False,
159
+ jpeg_cycles=1,
160
+ jpeg_qmin=88,
161
+ jpeg_qmax=96,
162
+ vignette_strength=0.35,
163
+ chroma_strength=1.2,
164
+ iso_scale=1.0,
165
+ read_noise=2.0,
166
+ hot_pixel_prob=1e-6,
167
+ banding_strength=0.0,
168
+ motion_blur_kernel=1,
169
+ lut=(lut_path if lut_path else None),
170
+ lut_strength=float(lut_strength),
171
+ )
172
+
173
+ # Debug: print args passed to process_image for tracing
174
+ try:
175
+ print("process_image called with args:")
176
+ for k, v in vars(args).items():
177
+ print(f" {k}: {v}")
178
+ except Exception:
179
+ pass
180
+
181
+ try:
182
+ process_image(in_path, out_path, args)
183
+ except Exception as e:
184
+ return None, f"Processing error: {e}"
185
+
186
+ out_img = Image.open(out_path).convert("RGB")
187
+ return out_img, "OK"
188
+
189
+ finally:
190
+ for p in tmp_files:
191
+ try:
192
+ os.unlink(p)
193
+ except Exception:
194
+ pass
195
+
196
+
197
+ def run_process_with_exif(
198
+ img: Image.Image,
199
+ noise_std: float = 0.02,
200
+ clahe_clip: float = 2.0,
201
+ tile: int = 8,
202
+ cutoff: float = 0.25,
203
+ fstrength: float = 0.9,
204
+ awb: bool = True,
205
+ sim_camera: bool = True,
206
+ lut_file: Optional[Path] = None,
207
+ lut_strength: float = 0.1,
208
+ awb_ref: Optional[Image.Image] = None,
209
+ fft_ref: Optional[Image.Image] = None,
210
+ seed: Optional[int] = None,
211
+ jpeg_cycles: int = 1,
212
+ jpeg_qmin: int = 88,
213
+ jpeg_qmax: int = 96,
214
+ vignette_strength: float = 0.35,
215
+ chroma_strength: float = 1.2,
216
+ iso_scale: float = 1.0,
217
+ read_noise: float = 2.0,
218
+ no_no_bayer: bool = False,
219
+ randomness: float = 0.05,
220
+ perturb: float = 0.008,
221
+ phase_perturb: float = 0.08,
222
+ radial_smooth: int = 5,
223
+ fft_mode: str = "auto",
224
+ fft_alpha: float = 1.0,
225
+ apply_exif: bool = True,
226
+ hot_pixel_prob: float = 1e-6,
227
+ banding_strength: float = 0.0,
228
+ motion_blur_kernel: int = 1,
229
+ ):
230
+ """Run pipeline like `run_process` but return (pil_img, status, exif_hex_or_empty).
231
+
232
+ This function is used by the Gradio UI to expose EXIF metadata.
233
+ """
234
+ if process_image is None:
235
+ return None, f"Backend import error: {IMPORT_ERROR}", ""
236
+
237
+ tmp_files = []
238
+ try:
239
+ in_path = _mk_temp_file(suffix=".png")
240
+ img.save(in_path)
241
+ tmp_files.append(in_path)
242
+
243
+ # optional refs
244
+ awb_ref_path = None
245
+ if awb_ref is not None:
246
+ p = _mk_temp_file(suffix=".png")
247
+ awb_ref.save(p)
248
+ awb_ref_path = p
249
+ tmp_files.append(p)
250
+
251
+ fft_ref_path = None
252
+ if fft_ref is not None:
253
+ p = _mk_temp_file(suffix=".png")
254
+ fft_ref.save(p)
255
+ fft_ref_path = p
256
+ tmp_files.append(p)
257
+
258
+ out_path = _mk_temp_file(suffix=".jpg")
259
+ tmp_files.append(out_path)
260
+
261
+ lut_path = None
262
+ if lut_file is not None:
263
+ lut_path = str(lut_file)
264
+
265
+ args = SimpleNamespace(
266
+ input=in_path,
267
+ output=out_path,
268
+ awb=bool(awb),
269
+ ref=awb_ref_path,
270
+ noise_std=float(noise_std),
271
+ clahe_clip=float(clahe_clip),
272
+ tile=int(tile),
273
+ cutoff=float(cutoff),
274
+ fstrength=float(fstrength),
275
+ randomness=float(randomness),
276
+ perturb=float(perturb),
277
+ seed=seed,
278
+ fft_ref=fft_ref_path,
279
+ fft_mode=fft_mode,
280
+ fft_alpha=float(fft_alpha),
281
+ phase_perturb=float(phase_perturb),
282
+ radial_smooth=int(radial_smooth),
283
+ sim_camera=bool(sim_camera),
284
+ no_no_bayer=bool(no_no_bayer),
285
+ jpeg_cycles=int(jpeg_cycles),
286
+ jpeg_qmin=int(jpeg_qmin),
287
+ jpeg_qmax=int(jpeg_qmax),
288
+ vignette_strength=float(vignette_strength),
289
+ chroma_strength=float(chroma_strength),
290
+ iso_scale=float(iso_scale),
291
+ read_noise=float(read_noise),
292
+ hot_pixel_prob=float(hot_pixel_prob),
293
+ banding_strength=float(banding_strength),
294
+ motion_blur_kernel=int(motion_blur_kernel),
295
+ lut=(lut_path if lut_path else None),
296
+ lut_strength=float(lut_strength),
297
+ )
298
+
299
+ # Debug: print args passed to process_image for tracing
300
+ try:
301
+ print("process_image called with args:")
302
+ for k, v in vars(args).items():
303
+ print(f" {k}: {v}")
304
+ except Exception:
305
+ pass
306
+
307
+ try:
308
+ process_image(in_path, out_path, args)
309
+ except Exception as e:
310
+ return None, f"Processing error: {e}", ""
311
+
312
+ out_img = Image.open(out_path).convert("RGB")
313
+
314
+ # try to extract EXIF bytes
315
+ exif_hex = ""
316
+ try:
317
+ info = Image.open(out_path).info
318
+ exif_bytes = info.get('exif')
319
+ if exif_bytes:
320
+ exif_hex = exif_bytes.hex()
321
+ except Exception:
322
+ exif_hex = ""
323
+
324
+ return out_img, "OK", exif_hex
325
+
326
+ finally:
327
+ for p in tmp_files:
328
+ try:
329
+ os.unlink(p)
330
+ except Exception:
331
+ pass
332
+
333
+ # ------------------ Headless analysis helpers (from AnalysisPanel) ------------------
334
+ def pil_to_gray_array(pil_img: Image.Image):
335
+ arr = np.array(pil_img.convert('RGB'))
336
+ gray = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.float32)
337
+ return gray
338
+
339
+
340
+ def compute_fft_magnitude(gray_arr, eps=1e-8):
341
+ f = np.fft.fft2(gray_arr)
342
+ fshift = np.fft.fftshift(f)
343
+ mag = np.abs(fshift)
344
+ mag_log = np.log1p(mag)
345
+ return mag, mag_log
346
+
347
+
348
+ def radial_profile(mag, center=None, nbins=100):
349
+ h, w = mag.shape
350
+ if center is None:
351
+ center = (int(h / 2), int(w / 2))
352
+ y, x = np.indices((h, w))
353
+ r = np.sqrt((x - center[1]) ** 2 + (y - center[0]) ** 2)
354
+ r_flat = r.ravel()
355
+ mag_flat = mag.ravel()
356
+ max_r = np.max(r_flat)
357
+ if max_r <= 0:
358
+ return np.linspace(0, 1, nbins), np.zeros(nbins)
359
+ bins = np.linspace(0, max_r, nbins + 1)
360
+ inds = np.digitize(r_flat, bins) - 1
361
+ radial_mean = np.zeros(nbins)
362
+ for i in range(nbins):
363
+ sel = inds == i
364
+ if np.any(sel):
365
+ radial_mean[i] = mag_flat[sel].mean()
366
+ else:
367
+ radial_mean[i] = 0.0
368
+ centers = 0.5 * (bins[:-1] + bins[1:]) / max_r
369
+ return centers, radial_mean
370
+
371
+
372
+ def fig_to_pil(fig):
373
+ buf = io.BytesIO()
374
+ fig.savefig(buf, format='png', bbox_inches='tight')
375
+ plt.close(fig)
376
+ buf.seek(0)
377
+ return Image.open(buf).convert('RGB')
378
+
379
+
380
+ def make_analysis_images(pil_img: Image.Image):
381
+ """Return (hist_img, fft_img, radial_img) as PIL Images for the provided PIL image."""
382
+ gray = pil_to_gray_array(pil_img)
383
+
384
+ # Histogram
385
+ fig1 = plt.figure(figsize=(3, 2), dpi=100)
386
+ ax1 = fig1.add_subplot(111)
387
+ flat = gray.ravel()
388
+ if flat.dtype.kind == 'f' and flat.max() <= 1.0:
389
+ flat = (flat * 255.0).astype(np.uint8)
390
+ ax1.hist(flat, bins=256, range=(0, 255))
391
+ ax1.set_title('Grayscale histogram')
392
+ ax1.set_xlabel('Intensity')
393
+ ax1.set_ylabel('Count')
394
+ hist_img = fig_to_pil(fig1)
395
+
396
+ # FFT magnitude (log)
397
+ mag, mag_log = compute_fft_magnitude(gray)
398
+ fig2 = plt.figure(figsize=(3, 2), dpi=100)
399
+ ax2 = fig2.add_subplot(111)
400
+ ax2.imshow(mag_log, origin='lower', aspect='auto')
401
+ ax2.set_title('FFT magnitude (log)')
402
+ ax2.set_xticks([])
403
+ ax2.set_yticks([])
404
+ fft_img = fig_to_pil(fig2)
405
+
406
+ # Radial profile
407
+ centers, radial = radial_profile(mag)
408
+ fig3 = plt.figure(figsize=(3, 2), dpi=100)
409
+ ax3 = fig3.add_subplot(111)
410
+ ax3.plot(centers, radial)
411
+ ax3.set_title('Radial freq profile')
412
+ ax3.set_xlabel('Normalized radius')
413
+ ax3.set_ylabel('Mean magnitude')
414
+ radial_img = fig_to_pil(fig3)
415
+
416
+ return hist_img, fft_img, radial_img
417
+
418
+
419
+ def make_delta_image(orig: Image.Image, proc: Image.Image, max_size: int = 256):
420
+ """Return (diff_pil, mse, norm_diff) comparing orig vs proc.
421
+
422
+ - diff_pil: absolute-difference thumbnail (RGB)
423
+ - mse: mean squared error (float)
424
+ - norm_diff: mean absolute difference normalized to [0..1]
425
+ """
426
+ try:
427
+ # Downscale to reasonable size for cheap diffing
428
+ orig_small = orig.copy()
429
+ proc_small = proc.copy()
430
+ orig_small.thumbnail((max_size, max_size))
431
+ proc_small.thumbnail((max_size, max_size))
432
+
433
+ a = np.asarray(orig_small).astype(np.float32)
434
+ b = np.asarray(proc_small).astype(np.float32)
435
+ # Ensure same shape
436
+ if a.shape != b.shape:
437
+ # try to convert proc to orig shape via resize
438
+ proc_small = proc_small.resize(orig_small.size)
439
+ b = np.asarray(proc_small).astype(np.float32)
440
+
441
+ diff = np.abs(a - b)
442
+ mse = float(((a - b) ** 2).mean())
443
+ norm_diff = float(diff.mean() / 255.0)
444
+
445
+ # Scale diff for visibility
446
+ diff_vis = np.clip(diff * 4.0, 0, 255).astype(np.uint8)
447
+ diff_img = Image.fromarray(diff_vis)
448
+ metrics = f"MSE: {mse:.2f}\nMean abs diff (norm): {norm_diff:.4f}"
449
+ return diff_img, mse, metrics
450
+ except Exception as e:
451
+ return None, 0.0, f"delta error: {e}"
452
+
453
+
454
+ def build_interface():
455
+ try:
456
+ import gradio as gr
457
+ except Exception:
458
+ raise RuntimeError("Gradio is not installed. Add 'gradio' to requirements.txt and install it.")
459
+
460
+ with gr.Blocks() as demo:
461
+ gr.Markdown("# Image Postprocess — Gradio frontend\nWraps the repository's `process_image` pipeline.")
462
+
463
+ with gr.Row():
464
+ inp = gr.Image(type="pil", label="Input image")
465
+ out = gr.Image(type="pil", label="Processed image")
466
+
467
+ # Preset selector + save/load
468
+ customs = list(load_custom_presets().keys())
469
+ preset_choices = [*BUILTIN_PRESETS.keys(), *customs]
470
+ with gr.Row():
471
+ preset = gr.Dropdown(choices=preset_choices, value="Preview (no camera sim)", label="Preset")
472
+ preset_name = gr.Textbox(label="Save preset as (name)")
473
+ save_preset_btn = gr.Button("Save preset")
474
+ preset_summary_box = gr.Textbox(value=preset_summary("Default"), label="Preset summary", interactive=False)
475
+
476
+ with gr.Row():
477
+ hist_out = gr.Image(type="pil", label="Processed hist")
478
+ fft_out = gr.Image(type="pil", label="Processed FFT")
479
+ radial_out = gr.Image(type="pil", label="Processed radial")
480
+ delta_out = gr.Image(type="pil", label="Diff (abs) thumb")
481
+ delta_metrics = gr.Textbox(label="Delta metrics", interactive=False)
482
+
483
+ # Reference images and EXIF output
484
+ with gr.Row():
485
+ awb_ref = gr.Image(type="pil", label="AWB reference (optional)")
486
+ fft_ref = gr.Image(type="pil", label="FFT reference (optional)")
487
+ exif_out = gr.Textbox(label="EXIF (hex)")
488
+
489
+ with gr.Row():
490
+ noise_std = gr.Slider(0.0, 0.1, value=0.02, step=0.001, label="Noise STD (fraction)")
491
+ clahe_clip = gr.Slider(0.5, 10.0, value=2.0, step=0.1, label="CLAHE clip")
492
+ tile = gr.Slider(2, 32, value=8, step=1, label="CLAHE tile")
493
+
494
+ with gr.Row():
495
+ cutoff = gr.Slider(0.0, 1.0, value=0.25, step=0.01, label="Fourier cutoff")
496
+ fstrength = gr.Slider(0.0, 1.0, value=0.9, step=0.01, label="Fourier strength")
497
+ awb = gr.Checkbox(label="Apply AWB (auto white balance)", value=True)
498
+
499
+ with gr.Row():
500
+ sim_camera = gr.Checkbox(label="Simulate camera pipeline", value=False)
501
+ lut_file = gr.File(label="Optional LUT (png/npy/cube)")
502
+ lut_strength = gr.Slider(0.0, 1.0, value=0.1, step=0.01, label="LUT strength")
503
+
504
+ with gr.Row():
505
+ seed = gr.Number(value=None, label="Seed (integer, optional)")
506
+ jpeg_cycles = gr.Slider(1, 5, value=1, step=1, label="JPEG cycles")
507
+ jpeg_qmin = gr.Slider(30, 100, value=88, step=1, label="JPEG quality (min)")
508
+
509
+ with gr.Row():
510
+ vignette_strength = gr.Slider(0.0, 1.0, value=0.35, step=0.01, label="Vignette strength")
511
+ chroma_strength = gr.Slider(0.0, 5.0, value=1.2, step=0.1, label="Chroma strength")
512
+ iso_scale = gr.Slider(0.1, 16.0, value=1.0, step=0.1, label="ISO scale")
513
+
514
+ with gr.Row():
515
+ read_noise = gr.Slider(0.0, 50.0, value=2.0, step=0.1, label="Read noise")
516
+ no_no_bayer = gr.Checkbox(label="Disable Bayer (no demosaic)", value=False)
517
+
518
+ # Advanced panel for expert parameters
519
+ with gr.Accordion("Advanced parameters (expert)", open=False):
520
+ randomness = gr.Slider(0.0, 0.5, value=0.05, step=0.001, label="Fourier randomness")
521
+ perturb = gr.Slider(0.0, 0.05, value=0.008, step=0.001, label="Perturb magnitude")
522
+ phase_perturb = gr.Slider(0.0, 0.5, value=0.08, step=0.001, label="Phase perturb")
523
+ radial_smooth = gr.Slider(0, 50, value=5, step=1, label="Radial smooth")
524
+ fft_mode = gr.Dropdown(["auto", "ref", "model"], value="auto", label="FFT mode")
525
+ fft_alpha = gr.Slider(0.1, 4.0, value=1.0, step=0.1, label="FFT alpha (1/f)")
526
+
527
+ status = gr.Textbox(label="Status", interactive=False)
528
+
529
+ def _wrap(preset, inp_img, noise_std, clahe_clip, tile, cutoff, fstrength, awb, sim_camera, lut_file, lut_strength, awb_ref, fft_ref, seed, jpeg_cycles, jpeg_qmin, vignette_strength, chroma_strength, iso_scale, read_noise, no_no_bayer, randomness, perturb, phase_perturb, radial_smooth, fft_mode, fft_alpha):
530
+ jpeg_qmax = 96
531
+ lut_path = lut_file.name if getattr(lut_file, 'name', None) else None
532
+
533
+ # Build effective parameters mapping from UI inputs
534
+ params = {
535
+ "noise_std": float(noise_std),
536
+ "clahe_clip": float(clahe_clip),
537
+ "tile": int(tile),
538
+ "cutoff": float(cutoff),
539
+ "fstrength": float(fstrength),
540
+ "awb": bool(awb),
541
+ "sim_camera": bool(sim_camera),
542
+ "lut_file": lut_path,
543
+ "lut_strength": float(lut_strength),
544
+ "awb_ref": awb_ref,
545
+ "fft_ref": fft_ref,
546
+ "seed": int(seed) if (seed is not None and str(seed) != "") else None,
547
+ "jpeg_cycles": int(jpeg_cycles),
548
+ "jpeg_qmin": int(jpeg_qmin),
549
+ "jpeg_qmax": int(jpeg_qmax),
550
+ "vignette_strength": float(vignette_strength),
551
+ "chroma_strength": float(chroma_strength),
552
+ "iso_scale": float(iso_scale),
553
+ "read_noise": float(read_noise),
554
+ "no_no_bayer": bool(no_no_bayer),
555
+ "randomness": float(randomness),
556
+ "perturb": float(perturb),
557
+ "phase_perturb": float(phase_perturb),
558
+ "radial_smooth": int(radial_smooth),
559
+ "fft_mode": fft_mode,
560
+ "fft_alpha": float(fft_alpha),
561
+ }
562
+
563
+ # Overlay preset overrides (builtin or custom)
564
+ overrides = get_preset_overrides(preset)
565
+ for k, v in overrides.items():
566
+ params[k] = v
567
+
568
+ # Call the pipeline with explicit params
569
+ try:
570
+ result, msg, exif = run_process_with_exif(
571
+ inp_img,
572
+ noise_std=params.get("noise_std"),
573
+ clahe_clip=params.get("clahe_clip"),
574
+ tile=params.get("tile"),
575
+ cutoff=params.get("cutoff"),
576
+ fstrength=params.get("fstrength"),
577
+ awb=params.get("awb"),
578
+ sim_camera=params.get("sim_camera"),
579
+ lut_file=params.get("lut_file"),
580
+ lut_strength=params.get("lut_strength"),
581
+ awb_ref=params.get("awb_ref"),
582
+ fft_ref=params.get("fft_ref"),
583
+ seed=params.get("seed"),
584
+ jpeg_cycles=params.get("jpeg_cycles"),
585
+ jpeg_qmin=params.get("jpeg_qmin"),
586
+ jpeg_qmax=params.get("jpeg_qmax"),
587
+ vignette_strength=params.get("vignette_strength"),
588
+ chroma_strength=params.get("chroma_strength"),
589
+ iso_scale=params.get("iso_scale"),
590
+ read_noise=params.get("read_noise"),
591
+ no_no_bayer=params.get("no_no_bayer"),
592
+ randomness=params.get("randomness"),
593
+ perturb=params.get("perturb"),
594
+ phase_perturb=params.get("phase_perturb"),
595
+ radial_smooth=params.get("radial_smooth"),
596
+ fft_mode=params.get("fft_mode"),
597
+ fft_alpha=params.get("fft_alpha"),
598
+ )
599
+ except Exception as e:
600
+ return None, None, None, None, None, "", f"Processing error: {e}"
601
+
602
+ if result is None:
603
+ return None, None, None, None, None, "", msg
604
+
605
+ try:
606
+ hist_img, fft_img, radial_img = make_analysis_images(result)
607
+ except Exception as e:
608
+ return result, None, None, None, None, exif, f"Analysis error: {e}"
609
+
610
+ # Delta preview
611
+ try:
612
+ diff_img, mse_val, metrics = make_delta_image(inp_img, result)
613
+ except Exception as e:
614
+ diff_img, mse_val, metrics = None, 0.0, f"delta error: {e}"
615
+
616
+ return result, hist_img, fft_img, radial_img, diff_img, metrics, exif, msg
617
+
618
+ btn = gr.Button("Run")
619
+ btn.click(
620
+ _wrap,
621
+ inputs=[preset, inp, noise_std, clahe_clip, tile, cutoff, fstrength, awb, sim_camera, lut_file, lut_strength, awb_ref, fft_ref, seed, jpeg_cycles, jpeg_qmin, vignette_strength, chroma_strength, iso_scale, read_noise, no_no_bayer, randomness, perturb, phase_perturb, radial_smooth, fft_mode, fft_alpha],
622
+ outputs=[out, hist_out, fft_out, radial_out, delta_out, delta_metrics, exif_out, status],
623
+ )
624
+
625
+ def _save_preset(name, preset, noise_std, clahe_clip, tile, cutoff, fstrength, awb, sim_camera, lut_strength, seed, jpeg_cycles, jpeg_qmin, vignette_strength, chroma_strength, iso_scale, read_noise, no_no_bayer, randomness, perturb, phase_perturb, radial_smooth, fft_mode, fft_alpha):
626
+ if not name:
627
+ return "Provide a name to save the preset"
628
+ data = {
629
+ "noise_std": float(noise_std),
630
+ "clahe_clip": float(clahe_clip),
631
+ "tile": int(tile),
632
+ "cutoff": float(cutoff),
633
+ "fstrength": float(fstrength),
634
+ "randomness": float(randomness),
635
+ "perturb": float(perturb),
636
+ "phase_perturb": float(phase_perturb),
637
+ "radial_smooth": int(radial_smooth),
638
+ "jpeg_cycles": int(jpeg_cycles),
639
+ "jpeg_qmin": int(jpeg_qmin),
640
+ "vignette_strength": float(vignette_strength),
641
+ "chroma_strength": float(chroma_strength),
642
+ "iso_scale": float(iso_scale),
643
+ "read_noise": float(read_noise),
644
+ "no_no_bayer": bool(no_no_bayer),
645
+ }
646
+ save_custom_preset(name, data)
647
+ return f"Saved preset: {name}"
648
+
649
+ save_preset_btn.click(_save_preset, inputs=[preset_name, preset, noise_std, clahe_clip, tile, cutoff, fstrength, awb, sim_camera, lut_strength, seed, jpeg_cycles, jpeg_qmin, vignette_strength, chroma_strength, iso_scale, read_noise, no_no_bayer, randomness, perturb, phase_perturb, radial_smooth, fft_mode, fft_alpha], outputs=[preset_summary_box])
650
+
651
+ def _update_summary(selected):
652
+ return preset_summary(selected)
653
+
654
+ preset.change(_update_summary, inputs=[preset], outputs=[preset_summary_box])
655
+
656
+ return demo
657
+
658
+
659
+ if __name__ == "__main__":
660
+ iface = build_interface()
661
+ iface.launch(server_name="0.0.0.0", server_port=7860)
image_postprocess/camera_pipeline.py CHANGED
@@ -217,7 +217,9 @@ def simulate_camera_pipeline(img_arr: np.ndarray,
217
  # 1) Bayer mosaic + demosaic (if enabled)
218
  if bayer:
219
  try:
220
- mosaic = _bayer_mosaic(out[:, :, ::-1]) # we built mosaic assuming R,G,B order; send RGB
 
 
221
  if _HAS_CV2:
222
  # cv2 expects a single-channel Bayer and provides demosaicing codes
223
  # We'll use RGGB code (COLOR_BAYER_RG2BGR) so convert back to RGB after
 
217
  # 1) Bayer mosaic + demosaic (if enabled)
218
  if bayer:
219
  try:
220
+ # Build mosaic from the RGB image (no channel reversal). Previously the code
221
+ # reversed channels here which caused R/B swapping and strong green tint.
222
+ mosaic = _bayer_mosaic(out)
223
  if _HAS_CV2:
224
  # cv2 expects a single-channel Bayer and provides demosaicing codes
225
  # We'll use RGGB code (COLOR_BAYER_RG2BGR) so convert back to RGB after
requirements.txt CHANGED
@@ -1,4 +1,4 @@
1
- pyqt5
2
  pillow
3
  numpy
4
  matplotlib
 
1
+ gradio
2
  pillow
3
  numpy
4
  matplotlib
test_isolate_stages.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image
2
+ import numpy as np
3
+ from app_gradio import run_process_with_exif
4
+
5
+
6
+ def mse(a, b):
7
+ a = np.array(a).astype(np.float32)
8
+ b = np.array(b).astype(np.float32)
9
+ return float(((a - b) ** 2).mean())
10
+
11
+
12
+ def make_input():
13
+ img = Image.new('RGB', (256, 256))
14
+ for y in range(256):
15
+ for x in range(256):
16
+ img.putpixel((x, y), (x % 256, y % 256, (x + y) % 256))
17
+ return img
18
+
19
+
20
+ def run_variant(name, **kwargs):
21
+ inp = make_input()
22
+ out, status, exif = run_process_with_exif(inp, **kwargs)
23
+ import os
24
+ os.makedirs('test', exist_ok=True)
25
+ path = os.path.join('test', f"test_out_{name}.jpg")
26
+ if out is not None:
27
+ out.save(path)
28
+ print(f"{name}: status={status}, exif_len={len(exif) if exif else 0}, mse={mse(inp, out) if out is not None else 'NA'}, saved={path if out is not None else 'no'}")
29
+
30
+
31
+ if __name__ == '__main__':
32
+ # Default
33
+ run_variant('default', noise_std=0.02, clahe_clip=2.0, tile=8, cutoff=0.25, fstrength=0.9, awb=True, sim_camera=True, seed=0)
34
+ # AWB disabled
35
+ run_variant('no_awb', noise_std=0.02, clahe_clip=2.0, tile=8, cutoff=0.25, fstrength=0.9, awb=False, sim_camera=True, seed=0)
36
+ # camera sim disabled
37
+ run_variant('no_cam', noise_std=0.02, clahe_clip=2.0, tile=8, cutoff=0.25, fstrength=0.9, awb=True, sim_camera=False, seed=0)
38
+ # both disabled
39
+ run_variant('no_awb_no_cam', noise_std=0.02, clahe_clip=2.0, tile=8, cutoff=0.25, fstrength=0.9, awb=False, sim_camera=False, seed=0)
test_smoke_gradio.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image
2
+ import io
3
+ import os
4
+
5
+ # Import the run_process function from the Gradio wrapper
6
+ from app_gradio import run_process
7
+
8
+ # Create a small synthetic test image (RGB gradient)
9
+ img = Image.new('RGB', (128, 128))
10
+ for y in range(128):
11
+ for x in range(128):
12
+ img.putpixel((x, y), (int(x*2), int(y*2), int((x+y)/2)))
13
+
14
+ out_img, status = run_process(img)
15
+ print('Status:', status)
16
+ if out_img is not None:
17
+ out_path = '/tmp/test_gradio_output.jpg'
18
+ out_img.save(out_path)
19
+ print('Wrote output to', out_path)
20
+ else:
21
+ print('No output image generated')
test_smoke_gradio_debug.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image
2
+ import os
3
+
4
+ # Import the richer runner (with EXIF) from the Gradio wrapper
5
+ from app_gradio import run_process_with_exif
6
+
7
+ # Create a small synthetic test image (RGB gradient)
8
+ img = Image.new('RGB', (128, 128))
9
+ for y in range(128):
10
+ for x in range(128):
11
+ img.putpixel((x, y), (int(x*2), int(y*2), int((x+y)/2)))
12
+
13
+ # Call with explicit parameters to exercise many code paths
14
+ out_img, status, exif = run_process_with_exif(
15
+ img,
16
+ noise_std=0.02,
17
+ clahe_clip=2.0,
18
+ tile=8,
19
+ cutoff=0.25,
20
+ fstrength=0.9,
21
+ awb=True,
22
+ sim_camera=True,
23
+ lut_file=None,
24
+ lut_strength=0.1,
25
+ awb_ref=None,
26
+ fft_ref=None,
27
+ seed=0,
28
+ jpeg_cycles=1,
29
+ jpeg_qmin=88,
30
+ jpeg_qmax=96,
31
+ vignette_strength=0.35,
32
+ chroma_strength=1.2,
33
+ iso_scale=1.0,
34
+ read_noise=2.0,
35
+ no_no_bayer=False,
36
+ randomness=0.05,
37
+ perturb=0.008,
38
+ phase_perturb=0.08,
39
+ radial_smooth=5,
40
+ fft_mode="auto",
41
+ fft_alpha=1.0,
42
+ hot_pixel_prob=1e-6,
43
+ banding_strength=0.0,
44
+ motion_blur_kernel=1,
45
+ )
46
+
47
+ print('Status:', status)
48
+ print('EXIF (hex length):', len(exif) if exif else 0)
49
+ if out_img is not None:
50
+ os.makedirs('test', exist_ok=True)
51
+ out_path = os.path.join('test', 'test_gradio_output_debug.jpg')
52
+ out_img.save(out_path)
53
+ print('Wrote output to', out_path)
54
+ else:
55
+ print('No output image generated')