Fioceen commited on
Commit
128f42e
·
1 Parent(s): 1e88f06
README.md ADDED
@@ -0,0 +1 @@
 
 
1
+ # Image-Detection-Bypass-Utility
analysis_panel.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Analysis panel for histogram, FFT, and radial profile plots.
4
+ """
5
+
6
+ from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QSizePolicy
7
+ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
8
+ from matplotlib.figure import Figure
9
+ import numpy as np
10
+ import os
11
+ from utils import compute_gray_array, compute_fft_magnitude, radial_profile, make_canvas
12
+
13
+ class AnalysisPanel(QWidget):
14
+ def __init__(self, title="Analysis", parent=None):
15
+ super().__init__(parent)
16
+ v = QVBoxLayout(self)
17
+ box = QGroupBox(title)
18
+ vbox = QVBoxLayout()
19
+ box.setLayout(vbox)
20
+
21
+ row = QHBoxLayout()
22
+ self.hist_canvas, self.hist_ax = make_canvas(width=3, height=2)
23
+ self.fft_canvas, self.fft_ax = make_canvas(width=3, height=2)
24
+ self.radial_canvas, self.radial_ax = make_canvas(width=3, height=2)
25
+
26
+ for c in (self.hist_canvas, self.fft_canvas, self.radial_canvas):
27
+ c.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
28
+
29
+ row.addWidget(self.hist_canvas)
30
+ row.addWidget(self.fft_canvas)
31
+ row.addWidget(self.radial_canvas)
32
+
33
+ vbox.addLayout(row)
34
+ v.addWidget(box)
35
+
36
+ def update_from_path(self, path):
37
+ if not path or not os.path.exists(path):
38
+ self.clear_plots()
39
+ return
40
+ try:
41
+ gray = compute_gray_array(path)
42
+ except Exception:
43
+ self.clear_plots()
44
+ return
45
+
46
+ # Histogram
47
+ self.hist_ax.cla()
48
+ self.hist_ax.set_title('Grayscale histogram')
49
+ self.hist_ax.set_xlabel('Intensity')
50
+ self.hist_ax.set_ylabel('Count')
51
+ self.hist_ax.hist(gray.ravel(), bins=256)
52
+ self.hist_canvas.draw()
53
+
54
+ # FFT magnitude
55
+ mag, mag_log = compute_fft_magnitude(gray)
56
+ self.fft_ax.cla()
57
+ self.fft_ax.set_title('FFT magnitude (log)')
58
+ self.fft_ax.imshow(mag_log, origin='lower', aspect='auto')
59
+ self.fft_canvas.figure.subplots_adjust(right=0.85)
60
+ self.fft_canvas.draw()
61
+
62
+ # Radial profile
63
+ centers, radial = radial_profile(mag)
64
+ self.radial_ax.cla()
65
+ self.radial_ax.set_title('Radial freq profile')
66
+ self.radial_ax.set_xlabel('Normalized radius')
67
+ self.radial_ax.set_ylabel('Mean magnitude')
68
+ self.radial_ax.plot(centers, radial)
69
+ self.radial_canvas.draw()
70
+
71
+ def clear_plots(self):
72
+ for ax, canvas in ((self.hist_ax, self.hist_canvas), (self.fft_ax, self.fft_canvas), (self.radial_ax, self.radial_canvas)):
73
+ ax.cla()
74
+ canvas.draw()
image_postprocess/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .image_postprocess_with_camera_pipeline import process_image
2
+
3
+ __all__ = ['process_image']
image_postprocess/camera_pipeline.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ camera_pipeline.py
3
+
4
+ Functions for simulating a realistic camera pipeline, including Bayer mosaic/demosaic,
5
+ chromatic aberration, vignette, sensor noise, hot pixels, banding, motion blur, and JPEG recompression.
6
+ """
7
+
8
+ from io import BytesIO
9
+ from PIL import Image
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 convolve
18
+
19
+ def _bayer_mosaic(img: np.ndarray, pattern='RGGB') -> np.ndarray:
20
+ """Create a single-channel Bayer mosaic from an RGB image.
21
+
22
+ pattern currently supports 'RGGB' (most common). Returns uint8 2D array.
23
+ """
24
+ h, w = img.shape[:2]
25
+ mosaic = np.zeros((h, w), dtype=np.uint8)
26
+
27
+ # pattern mapping for RGGB:
28
+ # (0,0) R, (0,1) G
29
+ # (1,0) G, (1,1) B
30
+ R = img[:, :, 0]
31
+ G = img[:, :, 1]
32
+ B = img[:, :, 2]
33
+
34
+ # fill mosaic according to RGGB
35
+ mosaic[0::2, 0::2] = R[0::2, 0::2]
36
+ mosaic[0::2, 1::2] = G[0::2, 1::2]
37
+ mosaic[1::2, 0::2] = G[1::2, 0::2]
38
+ mosaic[1::2, 1::2] = B[1::2, 1::2]
39
+ return mosaic
40
+
41
+ def _demosaic_bilinear(mosaic: np.ndarray) -> np.ndarray:
42
+ """Simple bilinear demosaic fallback (no cv2). Outputs RGB uint8 image.
43
+
44
+ Not perfect but good enough to add demosaic artifacts.
45
+ """
46
+ h, w = mosaic.shape
47
+ # Work in float to avoid overflow
48
+ m = mosaic.astype(np.float32)
49
+
50
+ # We'll compute each channel by averaging available mosaic samples
51
+ R = np.zeros_like(m)
52
+ G = np.zeros_like(m)
53
+ B = np.zeros_like(m)
54
+
55
+ # RGGB pattern
56
+ R[0::2, 0::2] = m[0::2, 0::2]
57
+ G[0::2, 1::2] = m[0::2, 1::2]
58
+ G[1::2, 0::2] = m[1::2, 0::2]
59
+ B[1::2, 1::2] = m[1::2, 1::2]
60
+
61
+ # Convolution kernels for interpolation (simple)
62
+ k_cross = np.array([[0, 1, 0], [1, 4, 1], [0, 1, 0]], dtype=np.float32) / 8.0
63
+ k_diag = np.array([[1, 0, 1], [0, 0, 0], [1, 0, 1]], dtype=np.float32) / 4.0
64
+
65
+ # convolve using scipy.ndimage.convolve
66
+ R_interp = convolve(R, k_cross, mode='mirror')
67
+ G_interp = convolve(G, k_cross, mode='mirror')
68
+ B_interp = convolve(B, k_cross, mode='mirror')
69
+
70
+ out = np.stack((R_interp, G_interp, B_interp), axis=2)
71
+ out = np.clip(out, 0, 255).astype(np.uint8)
72
+ return out
73
+
74
+ def _apply_chromatic_aberration(img: np.ndarray, strength=1.0, seed=None):
75
+ """Shift R and B channels slightly in opposite directions to emulate CA.
76
+
77
+ strength is in pixels (float). Uses cv2.warpAffine if available; integer
78
+ fallback uses np.roll.
79
+ """
80
+ if seed is not None:
81
+ rng = np.random.default_rng(seed)
82
+ else:
83
+ rng = np.random.default_rng()
84
+
85
+ h, w = img.shape[:2]
86
+ max_shift = max(1.0, strength)
87
+ # small random subpixel shift sampled from normal distribution
88
+ shift_r = rng.normal(loc=0.0, scale=max_shift * 0.6)
89
+ shift_b = rng.normal(loc=0.0, scale=max_shift * 0.6)
90
+ # apply opposite horizontal shifts to R and B for lateral CA
91
+ r_x = shift_r
92
+ r_y = rng.normal(scale=0.3 * abs(shift_r))
93
+ b_x = -shift_b
94
+ b_y = rng.normal(scale=0.3 * abs(shift_b))
95
+
96
+ out = img.copy().astype(np.float32)
97
+ if _HAS_CV2:
98
+ def warp_channel(ch, tx, ty):
99
+ M = np.array([[1, 0, tx], [0, 1, ty]], dtype=np.float32)
100
+ return cv2.warpAffine(ch, M, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT)
101
+ out[:, :, 0] = warp_channel(out[:, :, 0], r_x, r_y)
102
+ out[:, :, 2] = warp_channel(out[:, :, 2], b_x, b_y)
103
+ else:
104
+ # integer fallback
105
+ ix_r = int(round(r_x))
106
+ iy_r = int(round(r_y))
107
+ ix_b = int(round(b_x))
108
+ iy_b = int(round(b_y))
109
+ out[:, :, 0] = np.roll(out[:, :, 0], shift=(iy_r, ix_r), axis=(0, 1))
110
+ out[:, :, 2] = np.roll(out[:, :, 2], shift=(iy_b, ix_b), axis=(0, 1))
111
+
112
+ out = np.clip(out, 0, 255).astype(np.uint8)
113
+ return out
114
+
115
+ def _apply_vignette(img: np.ndarray, strength=0.4):
116
+ h, w = img.shape[:2]
117
+ y = np.linspace(-1, 1, h)[:, None]
118
+ x = np.linspace(-1, 1, w)[None, :]
119
+ r = np.sqrt(x * x + y * y)
120
+ mask = 1.0 - (r ** 2) * strength
121
+ mask = np.clip(mask, 0.0, 1.0)
122
+ out = (img.astype(np.float32) * mask[:, :, None])
123
+ out = np.clip(out, 0, 255).astype(np.uint8)
124
+ return out
125
+
126
+ def _add_poisson_gaussian_noise(img: np.ndarray, iso_scale=1.0, read_noise_std=2.0, seed=None):
127
+ """Poisson-Gaussian sensor noise model.
128
+
129
+ iso_scale scales the signal before Poisson sampling (higher -> more Poisson),
130
+ read_noise_std is the sigma (in DN) of additive Gaussian read noise.
131
+ """
132
+ if seed is not None:
133
+ rng = np.random.default_rng(seed)
134
+ else:
135
+ rng = np.random.default_rng()
136
+
137
+ img_f = img.astype(np.float32)
138
+ # scale to simulate exposure/iso
139
+ scaled = img_f * iso_scale
140
+ # Poisson: we need integer counts; scale to a reasonable photon budget
141
+ # choose scale so that typical pixel values map to ~[0..2000] photons
142
+ photon_scale = 4.0
143
+ lam = np.clip(scaled * photon_scale, 0, 1e6)
144
+ noisy = rng.poisson(lam).astype(np.float32) / photon_scale
145
+ # add read noise
146
+ noisy += rng.normal(loc=0.0, scale=read_noise_std, size=noisy.shape)
147
+ noisy = np.clip(noisy, 0, 255).astype(np.uint8)
148
+ return noisy
149
+
150
+ def _add_hot_pixels_and_banding(img: np.ndarray, hot_pixel_prob=1e-6, banding_strength=0.0, seed=None):
151
+ if seed is not None:
152
+ rng = np.random.default_rng(seed)
153
+ else:
154
+ rng = np.random.default_rng()
155
+
156
+ h, w = img.shape[:2]
157
+ out = img.copy().astype(np.float32)
158
+ # hot pixels
159
+ n_pixels = int(h * w * hot_pixel_prob)
160
+ if n_pixels > 0:
161
+ ys = rng.integers(0, h, size=n_pixels)
162
+ xs = rng.integers(0, w, size=n_pixels)
163
+ vals = rng.integers(200, 256, size=n_pixels)
164
+ for y, x, v in zip(ys, xs, vals):
165
+ out[y, x, :] = v
166
+ # banding: add low-amplitude sinusoidal horizontal banding
167
+ if banding_strength > 0.0:
168
+ rows = np.arange(h)[:, None]
169
+ band = (np.sin(rows * 0.5) * 255.0 * banding_strength)
170
+ out += band[:, :, None]
171
+ out = np.clip(out, 0, 255).astype(np.uint8)
172
+ return out
173
+
174
+ def _motion_blur(img: np.ndarray, kernel_size=5):
175
+ if kernel_size <= 1:
176
+ return img
177
+ # simple linear motion kernel horizontally
178
+ kernel = np.zeros((kernel_size, kernel_size), dtype=np.float32)
179
+ kernel[kernel_size // 2, :] = 1.0 / kernel_size
180
+ out = np.zeros_like(img)
181
+ for c in range(3):
182
+ out[:, :, c] = convolve(img[:, :, c].astype(np.float32), kernel, mode='mirror')
183
+ out = np.clip(out, 0, 255).astype(np.uint8)
184
+ return out
185
+
186
+ def _jpeg_recompress(img: np.ndarray, quality=90) -> np.ndarray:
187
+ pil = Image.fromarray(img)
188
+ buf = BytesIO()
189
+ pil.save(buf, format='JPEG', quality=int(quality), optimize=False)
190
+ buf.seek(0)
191
+ rec = Image.open(buf).convert('RGB')
192
+ return np.array(rec)
193
+
194
+ def simulate_camera_pipeline(img_arr: np.ndarray,
195
+ bayer=True,
196
+ jpeg_cycles=1,
197
+ jpeg_quality_range=(88, 96),
198
+ vignette_strength=0.35,
199
+ chroma_aberr_strength=1.2,
200
+ iso_scale=1.0,
201
+ read_noise_std=2.0,
202
+ hot_pixel_prob=1e-6,
203
+ banding_strength=0.0,
204
+ motion_blur_kernel=1,
205
+ seed=None):
206
+ """Apply a set of realistic camera/capture artifacts to img_arr (RGB uint8).
207
+
208
+ Returns an RGB uint8 image.
209
+ """
210
+ if seed is not None:
211
+ rng = np.random.default_rng(seed)
212
+ else:
213
+ rng = np.random.default_rng()
214
+
215
+ out = img_arr.copy()
216
+
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
224
+ dem = cv2.demosaicing(mosaic, cv2.COLOR_BAYER_RG2BGR)
225
+ # cv2 returns BGR
226
+ dem = dem[:, :, ::-1]
227
+ out = dem
228
+ else:
229
+ out = _demosaic_bilinear(mosaic)
230
+ except Exception:
231
+ # if anything fails, keep original
232
+ out = img_arr.copy()
233
+
234
+ # 2) chromatic aberration
235
+ out = _apply_chromatic_aberration(out, strength=chroma_aberr_strength, seed=seed)
236
+
237
+ # 3) vignette
238
+ out = _apply_vignette(out, strength=vignette_strength)
239
+
240
+ # 4) noise (Poisson-Gaussian)
241
+ out = _add_poisson_gaussian_noise(out, iso_scale=iso_scale, read_noise_std=read_noise_std, seed=seed)
242
+
243
+ # 5) hot pixels and banding
244
+ out = _add_hot_pixels_and_banding(out, hot_pixel_prob=hot_pixel_prob, banding_strength=banding_strength, seed=seed)
245
+
246
+ # 6) motion blur
247
+ if motion_blur_kernel and motion_blur_kernel > 1:
248
+ out = _motion_blur(out, kernel_size=motion_blur_kernel)
249
+
250
+ # 7) JPEG recompression cycles
251
+ for i in range(max(1, int(jpeg_cycles))):
252
+ q = int(rng.integers(jpeg_quality_range[0], jpeg_quality_range[1] + 1))
253
+ out = _jpeg_recompress(out, quality=q)
254
+
255
+ return out
image_postprocess/image_postprocess_with_camera_pipeline.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ image_postprocess_with_camera_pipeline.py
4
+
5
+ Main pipeline for image postprocessing with an optional realistic camera-pipeline simulator.
6
+ This file retains the original interface for CLI and imports, ensuring compatibility with existing code.
7
+ Imports helper functions and camera pipeline simulation from separate modules.
8
+ """
9
+
10
+ import argparse
11
+ import os
12
+ from PIL import Image
13
+ import numpy as np
14
+
15
+ from .utils import remove_exif_pil, add_gaussian_noise, clahe_color_correction, randomized_perturbation, fourier_match_spectrum
16
+ from .camera_pipeline import simulate_camera_pipeline
17
+
18
+ def process_image(path_in, path_out, args):
19
+ img = Image.open(path_in).convert('RGB')
20
+ img = remove_exif_pil(img)
21
+ arr = np.array(img)
22
+
23
+ arr = clahe_color_correction(arr, clip_limit=args.clahe_clip, tile_grid_size=(args.tile, args.tile))
24
+
25
+ ref_arr = None
26
+ if args.fft_ref:
27
+ ref_img = Image.open(args.fft_ref).convert('RGB')
28
+ ref_arr = np.array(ref_img)
29
+
30
+ arr = fourier_match_spectrum(arr, ref_img_arr=ref_arr, mode=args.fft_mode,
31
+ alpha=args.fft_alpha, cutoff=args.cutoff,
32
+ strength=args.fstrength, randomness=args.randomness,
33
+ phase_perturb=args.phase_perturb, radial_smooth=args.radial_smooth,
34
+ seed=args.seed)
35
+
36
+ arr = add_gaussian_noise(arr, std_frac=args.noise_std, seed=args.seed)
37
+ arr = randomized_perturbation(arr, magnitude_frac=args.perturb, seed=args.seed)
38
+
39
+ # call the camera simulator if requested
40
+ if args.sim_camera:
41
+ arr = simulate_camera_pipeline(arr,
42
+ bayer=not args.no_no_bayer,
43
+ jpeg_cycles=args.jpeg_cycles,
44
+ jpeg_quality_range=(args.jpeg_qmin, args.jpeg_qmax),
45
+ vignette_strength=args.vignette_strength,
46
+ chroma_aberr_strength=args.chroma_strength,
47
+ iso_scale=args.iso_scale,
48
+ read_noise_std=args.read_noise,
49
+ hot_pixel_prob=args.hot_pixel_prob,
50
+ banding_strength=args.banding_strength,
51
+ motion_blur_kernel=args.motion_blur_kernel,
52
+ seed=args.seed)
53
+
54
+ out_img = Image.fromarray(arr)
55
+ out_img.save(path_out)
56
+
57
+ def build_argparser():
58
+ p = argparse.ArgumentParser(description="Image postprocessing pipeline with camera simulation")
59
+ p.add_argument('input', help='Input image path')
60
+ p.add_argument('output', help='Output image path')
61
+ p.add_argument('--ref', help='Optional reference image for color matching (not implemented)', default=None)
62
+ p.add_argument('--noise-std', type=float, default=0.02, help='Gaussian noise std fraction of 255 (0-0.1)')
63
+ p.add_argument('--clahe-clip', type=float, default=2.0, help='CLAHE clip limit')
64
+ p.add_argument('--tile', type=int, default=8, help='CLAHE tile grid size')
65
+ p.add_argument('--cutoff', type=float, default=0.25, help='Fourier cutoff (0..1)')
66
+ p.add_argument('--fstrength', type=float, default=0.9, help='Fourier blend strength (0..1)')
67
+ p.add_argument('--randomness', type=float, default=0.05, help='Randomness for Fourier mask modulation')
68
+ p.add_argument('--perturb', type=float, default=0.008, help='Randomized perturb magnitude fraction (0..0.05)')
69
+ p.add_argument('--seed', type=int, default=None, help='Random seed for reproducibility')
70
+
71
+ # FFT-matching options
72
+ p.add_argument('--fft-ref', help='Optional reference image for FFT spectral matching', default=None)
73
+ p.add_argument('--fft-mode', choices=('auto','ref','model'), default='auto', help='FFT mode: auto picks ref if available')
74
+ p.add_argument('--fft-alpha', type=float, default=1.0, help='Alpha for 1/f model (spectrum slope)')
75
+ p.add_argument('--phase-perturb', type=float, default=0.08, help='Phase perturbation strength (radians)')
76
+ p.add_argument('--radial-smooth', type=int, default=5, help='Radial smoothing (bins) for spectrum profiles')
77
+
78
+ # Camera-simulator options
79
+ p.add_argument('--sim-camera', action='store_true', help='Enable camera-pipeline simulation (Bayer, CA, vignette, JPEG cycles)')
80
+ p.add_argument('--no-no-bayer', dest='no_no_bayer', action='store_false', help='Disable Bayer/demosaic step (double negative kept for backward compat)')
81
+ p.set_defaults(no_no_bayer=True)
82
+ p.add_argument('--jpeg-cycles', type=int, default=1, help='Number of JPEG recompression cycles to apply')
83
+ p.add_argument('--jpeg-qmin', type=int, default=88, help='Min JPEG quality for recompression')
84
+ p.add_argument('--jpeg-qmax', type=int, default=96, help='Max JPEG quality for recompression')
85
+ p.add_argument('--vignette-strength', type=float, default=0.35, help='Vignette strength (0..1)')
86
+ p.add_argument('--chroma-strength', type=float, default=1.2, help='Chromatic aberration strength (pixels)')
87
+ p.add_argument('--iso-scale', type=float, default=1.0, help='ISO/exposure scale for Poisson noise')
88
+ p.add_argument('--read-noise', type=float, default=2.0, help='Read noise sigma for sensor noise')
89
+ p.add_argument('--hot-pixel-prob', type=float, default=1e-6, help='Per-pixel probability of hot pixel')
90
+ p.add_argument('--banding-strength', type=float, default=0.0, help='Horizontal banding amplitude (0..1)')
91
+ p.add_argument('--motion-blur-kernel', type=int, default=1, help='Motion blur kernel size (1 = none)')
92
+
93
+ return p
94
+
95
+ if __name__ == "__main__":
96
+ args = build_argparser().parse_args()
97
+ if not os.path.exists(args.input):
98
+ print("Input not found:", args.input)
99
+ raise SystemExit(2)
100
+ process_image(args.input, args.output, args)
101
+ print("Saved:", args.output)
image_postprocess/utils.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
8
+ from PIL import Image, ImageOps
9
+ import numpy as np
10
+ try:
11
+ import cv2
12
+ _HAS_CV2 = True
13
+ except Exception:
14
+ cv2 = None
15
+ _HAS_CV2 = False
16
+ from scipy.ndimage import gaussian_filter1d
17
+
18
+ def remove_exif_pil(img: Image.Image) -> Image.Image:
19
+ data = img.tobytes()
20
+ new = Image.frombytes(img.mode, img.size, data)
21
+ return new
22
+
23
+ def add_gaussian_noise(img_arr: np.ndarray, std_frac=0.02, seed=None) -> np.ndarray:
24
+ if seed is not None:
25
+ np.random.seed(seed)
26
+ std = std_frac * 255.0
27
+ noise = np.random.normal(loc=0.0, scale=std, size=img_arr.shape)
28
+ out = img_arr.astype(np.float32) + noise
29
+ out = np.clip(out, 0, 255).astype(np.uint8)
30
+ return out
31
+
32
+ def clahe_color_correction(img_arr: np.ndarray, clip_limit=2.0, tile_grid_size=(8,8)) -> np.ndarray:
33
+ if _HAS_CV2:
34
+ lab = cv2.cvtColor(img_arr, cv2.COLOR_RGB2LAB)
35
+ l, a, b = cv2.split(lab)
36
+ clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=tile_grid_size)
37
+ l2 = clahe.apply(l)
38
+ lab2 = cv2.merge((l2, a, b))
39
+ out = cv2.cvtColor(lab2, cv2.COLOR_LAB2RGB)
40
+ return out
41
+ else:
42
+ pil = Image.fromarray(img_arr)
43
+ channels = pil.split()
44
+ new_ch = []
45
+ for ch in channels:
46
+ eq = ImageOps.equalize(ch)
47
+ new_ch.append(eq)
48
+ merged = Image.merge('RGB', new_ch)
49
+ return np.array(merged)
50
+
51
+ def randomized_perturbation(img_arr: np.ndarray, magnitude_frac=0.008, seed=None) -> np.ndarray:
52
+ if seed is not None:
53
+ np.random.seed(seed)
54
+ mag = magnitude_frac * 255.0
55
+ perturb = np.random.uniform(low=-mag, high=mag, size=img_arr.shape)
56
+ out = img_arr.astype(np.float32) + perturb
57
+ out = np.clip(out, 0, 255).astype(np.uint8)
58
+ return out
59
+
60
+ def radial_profile(mag: np.ndarray, center=None, nbins=None):
61
+ h, w = mag.shape
62
+ if center is None:
63
+ cy, cx = h // 2, w // 2
64
+ else:
65
+ cy, cx = center
66
+
67
+ if nbins is None:
68
+ nbins = int(max(h, w) / 2)
69
+ nbins = max(1, int(nbins))
70
+
71
+ y = np.arange(h) - cy
72
+ x = np.arange(w) - cx
73
+ X, Y = np.meshgrid(x, y)
74
+ R = np.sqrt(X * X + Y * Y)
75
+
76
+ Rmax = R.max()
77
+ if Rmax <= 0:
78
+ Rnorm = R
79
+ else:
80
+ Rnorm = R / (Rmax + 1e-12)
81
+ Rnorm = np.minimum(Rnorm, 1.0 - 1e-12)
82
+
83
+ bin_edges = np.linspace(0.0, 1.0, nbins + 1)
84
+ bin_idx = np.digitize(Rnorm.ravel(), bin_edges) - 1
85
+ bin_idx = np.clip(bin_idx, 0, nbins - 1)
86
+
87
+ sums = np.bincount(bin_idx, weights=mag.ravel(), minlength=nbins)
88
+ counts = np.bincount(bin_idx, minlength=nbins)
89
+
90
+ radial_mean = np.zeros(nbins, dtype=np.float64)
91
+ nonzero = counts > 0
92
+ radial_mean[nonzero] = sums[nonzero] / counts[nonzero]
93
+
94
+ bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:])
95
+ return bin_centers, radial_mean
96
+
97
+ def fourier_match_spectrum(img_arr: np.ndarray,
98
+ ref_img_arr: np.ndarray = None,
99
+ mode='auto',
100
+ alpha=1.0,
101
+ cutoff=0.25,
102
+ strength=0.9,
103
+ randomness=0.05,
104
+ phase_perturb=0.08,
105
+ radial_smooth=5,
106
+ seed=None):
107
+ if seed is not None:
108
+ rng = np.random.default_rng(seed)
109
+ else:
110
+ rng = np.random.default_rng()
111
+
112
+ h, w = img_arr.shape[:2]
113
+ cy, cx = h // 2, w // 2
114
+ nbins = max(8, int(max(h, w) / 2))
115
+
116
+ if mode == 'auto':
117
+ mode = 'ref' if ref_img_arr is not None else 'model'
118
+
119
+ bin_centers_src = np.linspace(0.0, 1.0, nbins)
120
+
121
+ model_radial = None
122
+ if mode == 'model':
123
+ eps = 1e-8
124
+ model_radial = (1.0 / (bin_centers_src + eps)) ** (alpha / 2.0)
125
+ lf = max(1, nbins // 8)
126
+ model_radial = model_radial / (np.median(model_radial[:lf]) + 1e-12)
127
+ model_radial = gaussian_filter1d(model_radial, sigma=max(1, radial_smooth))
128
+
129
+ ref_radial = None
130
+ ref_bin_centers = None
131
+ if mode == 'ref' and ref_img_arr is not None:
132
+ if ref_img_arr.shape[0] != h or ref_img_arr.shape[1] != w:
133
+ ref_img = Image.fromarray(ref_img_arr).resize((w, h), resample=Image.BICUBIC)
134
+ ref_img_arr = np.array(ref_img)
135
+ 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)
136
+ Fref = np.fft.fftshift(np.fft.fft2(ref_gray))
137
+ Mref = np.abs(Fref)
138
+ ref_bin_centers, ref_radial = radial_profile(Mref, center=(h // 2, w // 2), nbins=nbins)
139
+ ref_radial = gaussian_filter1d(ref_radial, sigma=max(1, radial_smooth))
140
+
141
+ out = np.zeros_like(img_arr, dtype=np.float32)
142
+
143
+ y = np.linspace(-1, 1, h, endpoint=False)[:, None]
144
+ x = np.linspace(-1, 1, w, endpoint=False)[None, :]
145
+ r = np.sqrt(x * x + y * y)
146
+ r = np.clip(r, 0.0, 1.0 - 1e-6)
147
+
148
+ for c in range(img_arr.shape[2]):
149
+ channel = img_arr[:, :, c].astype(np.float32)
150
+ F = np.fft.fft2(channel)
151
+ Fshift = np.fft.fftshift(F)
152
+ mag = np.abs(Fshift)
153
+ phase = np.angle(Fshift)
154
+
155
+ bin_centers_src_calc, src_radial = radial_profile(mag, center=(h // 2, w // 2), nbins=nbins)
156
+ src_radial = gaussian_filter1d(src_radial, sigma=max(1, radial_smooth))
157
+ bin_centers_src = bin_centers_src_calc
158
+
159
+ if mode == 'ref' and ref_radial is not None:
160
+ ref_interp = np.interp(bin_centers_src, ref_bin_centers, ref_radial)
161
+ eps = 1e-8
162
+ ratio = (ref_interp + eps) / (src_radial + eps)
163
+ desired_radial = src_radial * ratio
164
+ elif mode == 'model' and model_radial is not None:
165
+ lf = max(1, nbins // 8)
166
+ scale = (np.median(src_radial[:lf]) + 1e-12) / (np.median(model_radial[:lf]) + 1e-12)
167
+ desired_radial = model_radial * scale
168
+ else:
169
+ desired_radial = src_radial.copy()
170
+
171
+ eps = 1e-8
172
+ multiplier_1d = (desired_radial + eps) / (src_radial + eps)
173
+ multiplier_1d = np.clip(multiplier_1d, 0.2, 5.0)
174
+ mult_2d = np.interp(r.ravel(), bin_centers_src, multiplier_1d).reshape(h, w)
175
+
176
+ edge = 0.05 + 0.02 * (1.0 - cutoff) if 'cutoff' in globals() else 0.05
177
+ edge = max(edge, 1e-6)
178
+ weight = np.where(r <= 0.25, 1.0,
179
+ np.where(r <= 0.25 + edge,
180
+ 0.5 * (1 + np.cos(np.pi * (r - 0.25) / edge)),
181
+ 0.0))
182
+
183
+ final_multiplier = 1.0 + (mult_2d - 1.0) * (weight * strength)
184
+
185
+ if randomness and randomness > 0.0:
186
+ noise = rng.normal(loc=1.0, scale=randomness, size=final_multiplier.shape)
187
+ final_multiplier *= (1.0 + (noise - 1.0) * weight)
188
+
189
+ mag2 = mag * final_multiplier
190
+
191
+ if phase_perturb and phase_perturb > 0.0:
192
+ phase_sigma = phase_perturb * np.clip((r - 0.25) / (1.0 - 0.25 + 1e-6), 0.0, 1.0)
193
+ phase_noise = rng.standard_normal(size=phase_sigma.shape) * phase_sigma
194
+ phase2 = phase + phase_noise
195
+ else:
196
+ phase2 = phase
197
+
198
+ Fshift2 = mag2 * np.exp(1j * phase2)
199
+ F_ishift = np.fft.ifftshift(Fshift2)
200
+ img_back = np.fft.ifft2(F_ishift)
201
+ img_back = np.real(img_back)
202
+
203
+ blended = (1.0 - strength) * channel + strength * img_back
204
+ out[:, :, c] = blended
205
+
206
+ out = np.clip(out, 0, 255).astype(np.uint8)
207
+ return out
image_postprocess_gui.py ADDED
@@ -0,0 +1,510 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Main GUI application for image_postprocess pipeline with camera-simulator controls.
4
+ """
5
+
6
+ import sys
7
+ import os
8
+ from pathlib import Path
9
+ from PyQt5.QtWidgets import (
10
+ QApplication, QMainWindow, QWidget, QLabel, QPushButton, QFileDialog,
11
+ QHBoxLayout, QVBoxLayout, QFormLayout, QSlider, QSpinBox, QDoubleSpinBox,
12
+ QProgressBar, QMessageBox, QGroupBox, QLineEdit, QComboBox, QCheckBox
13
+ )
14
+ from PyQt5.QtCore import Qt
15
+ from PyQt5.QtGui import QPixmap
16
+ from worker import Worker
17
+ from analysis_panel import AnalysisPanel
18
+ from utils import qpixmap_from_path
19
+
20
+ try:
21
+ from image_postprocess import process_image
22
+ except Exception as e:
23
+ process_image = None
24
+ IMPORT_ERROR = str(e)
25
+ else:
26
+ IMPORT_ERROR = None
27
+
28
+ class MainWindow(QMainWindow):
29
+ def __init__(self):
30
+ super().__init__()
31
+ self.setWindowTitle("Image Postprocess — GUI (with Camera Simulator)")
32
+ self.setMinimumSize(1200, 760)
33
+
34
+ central = QWidget()
35
+ self.setCentralWidget(central)
36
+ main_h = QHBoxLayout(central)
37
+
38
+ # Left: previews & file selection
39
+ left_v = QVBoxLayout()
40
+ main_h.addLayout(left_v, 2)
41
+
42
+ in_group = QGroupBox("Input / Output")
43
+ left_v.addWidget(in_group)
44
+ in_layout = QFormLayout()
45
+ in_group.setLayout(in_layout)
46
+
47
+ self.input_line = QLineEdit()
48
+ self.input_btn = QPushButton("Choose Input")
49
+ self.input_btn.clicked.connect(self.choose_input)
50
+ self.ref_line = QLineEdit()
51
+ self.ref_btn = QPushButton("Choose Reference (optional)")
52
+ self.ref_btn.clicked.connect(self.choose_ref)
53
+ self.output_line = QLineEdit()
54
+ self.output_btn = QPushButton("Choose Output")
55
+ self.output_btn.clicked.connect(self.choose_output)
56
+
57
+ in_layout.addRow(self.input_btn, self.input_line)
58
+ in_layout.addRow(self.ref_btn, self.ref_line)
59
+ in_layout.addRow(self.output_btn, self.output_line)
60
+
61
+ # Previews
62
+ self.preview_in = QLabel(alignment=Qt.AlignCenter)
63
+ self.preview_in.setFixedSize(480, 300)
64
+ self.preview_in.setStyleSheet("background:#111; border:1px solid #444; color:#ddd")
65
+ self.preview_in.setText("Input preview")
66
+
67
+ self.preview_out = QLabel(alignment=Qt.AlignCenter)
68
+ self.preview_out.setFixedSize(480, 300)
69
+ self.preview_out.setStyleSheet("background:#111; border:1px solid #444; color:#ddd")
70
+ self.preview_out.setText("Output preview")
71
+
72
+ left_v.addWidget(self.preview_in)
73
+ left_v.addWidget(self.preview_out)
74
+
75
+ # Actions
76
+ actions_h = QHBoxLayout()
77
+ self.run_btn = QPushButton("Run — Process Image")
78
+ self.run_btn.clicked.connect(self.on_run)
79
+ self.open_out_btn = QPushButton("Open Output Folder")
80
+ self.open_out_btn.clicked.connect(self.open_output_folder)
81
+ actions_h.addWidget(self.run_btn)
82
+ actions_h.addWidget(self.open_out_btn)
83
+ left_v.addLayout(actions_h)
84
+
85
+ self.progress = QProgressBar()
86
+ self.progress.setTextVisible(True)
87
+ self.progress.setRange(0, 100)
88
+ self.progress.setValue(0)
89
+ left_v.addWidget(self.progress)
90
+
91
+ # Right: controls + analysis panels
92
+ right_v = QVBoxLayout()
93
+ main_h.addLayout(right_v, 3)
94
+
95
+ # Auto Mode controls
96
+ self.auto_mode_chk = QCheckBox("Enable Auto Mode")
97
+ self.auto_mode_chk.setChecked(False)
98
+ self.auto_mode_chk.stateChanged.connect(self._on_auto_mode_toggled)
99
+ right_v.addWidget(self.auto_mode_chk)
100
+
101
+ self.auto_group = QGroupBox("Auto Mode")
102
+ auto_layout = QFormLayout()
103
+ self.auto_group.setLayout(auto_layout)
104
+
105
+ strength_layout = QHBoxLayout()
106
+ self.strength_slider = QSlider(Qt.Horizontal)
107
+ self.strength_slider.setRange(0, 100)
108
+ self.strength_slider.setValue(25)
109
+ self.strength_slider.valueChanged.connect(self._update_strength_label)
110
+ self.strength_label = QLabel("25")
111
+ self.strength_label.setFixedWidth(30)
112
+ strength_layout.addWidget(self.strength_slider)
113
+ strength_layout.addWidget(self.strength_label)
114
+
115
+ auto_layout.addRow("Aberration Strength", strength_layout)
116
+ right_v.addWidget(self.auto_group)
117
+
118
+ self.params_group = QGroupBox("Parameters (Manual Mode)")
119
+ right_v.addWidget(self.params_group)
120
+ params_layout = QFormLayout()
121
+ self.params_group.setLayout(params_layout)
122
+
123
+ # Noise-std
124
+ self.noise_spin = QDoubleSpinBox()
125
+ self.noise_spin.setRange(0.0, 0.1)
126
+ self.noise_spin.setSingleStep(0.001)
127
+ self.noise_spin.setValue(0.02)
128
+ self.noise_spin.setToolTip("Gaussian noise std fraction of 255")
129
+ params_layout.addRow("Noise std (0-0.1)", self.noise_spin)
130
+
131
+ # CLAHE-clip
132
+ self.clahe_spin = QDoubleSpinBox()
133
+ self.clahe_spin.setRange(0.1, 10.0)
134
+ self.clahe_spin.setSingleStep(0.1)
135
+ self.clahe_spin.setValue(2.0)
136
+ params_layout.addRow("CLAHE clip", self.clahe_spin)
137
+
138
+ # Tile
139
+ self.tile_spin = QSpinBox()
140
+ self.tile_spin.setRange(1, 64)
141
+ self.tile_spin.setValue(8)
142
+ params_layout.addRow("CLAHE tile", self.tile_spin)
143
+
144
+ # Cutoff
145
+ self.cutoff_spin = QDoubleSpinBox()
146
+ self.cutoff_spin.setRange(0.01, 1.0)
147
+ self.cutoff_spin.setSingleStep(0.01)
148
+ self.cutoff_spin.setValue(0.25)
149
+ params_layout.addRow("Fourier cutoff (0-1)", self.cutoff_spin)
150
+
151
+ # Fstrength
152
+ self.fstrength_spin = QDoubleSpinBox()
153
+ self.fstrength_spin.setRange(0.0, 1.0)
154
+ self.fstrength_spin.setSingleStep(0.01)
155
+ self.fstrength_spin.setValue(0.9)
156
+ params_layout.addRow("Fourier strength (0-1)", self.fstrength_spin)
157
+
158
+ # Randomness
159
+ self.randomness_spin = QDoubleSpinBox()
160
+ self.randomness_spin.setRange(0.0, 1.0)
161
+ self.randomness_spin.setSingleStep(0.01)
162
+ self.randomness_spin.setValue(0.05)
163
+ params_layout.addRow("Fourier randomness", self.randomness_spin)
164
+
165
+ # Phase_perturb
166
+ self.phase_perturb_spin = QDoubleSpinBox()
167
+ self.phase_perturb_spin.setRange(0.0, 1.0)
168
+ self.phase_perturb_spin.setSingleStep(0.001)
169
+ self.phase_perturb_spin.setValue(0.08)
170
+ self.phase_perturb_spin.setToolTip("Phase perturbation std (radians)")
171
+ params_layout.addRow("Phase perturb (rad)", self.phase_perturb_spin)
172
+
173
+ # Radial_smooth
174
+ self.radial_smooth_spin = QSpinBox()
175
+ self.radial_smooth_spin.setRange(0, 50)
176
+ self.radial_smooth_spin.setValue(5)
177
+ params_layout.addRow("Radial smooth (bins)", self.radial_smooth_spin)
178
+
179
+ # FFT_mode
180
+ self.fft_mode_combo = QComboBox()
181
+ self.fft_mode_combo.addItems(["auto", "ref", "model"])
182
+ self.fft_mode_combo.setCurrentText("auto")
183
+ params_layout.addRow("FFT mode", self.fft_mode_combo)
184
+
185
+ # FFT_alpha
186
+ self.fft_alpha_spin = QDoubleSpinBox()
187
+ self.fft_alpha_spin.setRange(0.1, 4.0)
188
+ self.fft_alpha_spin.setSingleStep(0.1)
189
+ self.fft_alpha_spin.setValue(1.0)
190
+ self.fft_alpha_spin.setToolTip("Alpha exponent for 1/f model when using model mode")
191
+ params_layout.addRow("FFT alpha (model)", self.fft_alpha_spin)
192
+
193
+ # Perturb
194
+ self.perturb_spin = QDoubleSpinBox()
195
+ self.perturb_spin.setRange(0.0, 0.05)
196
+ self.perturb_spin.setSingleStep(0.001)
197
+ self.perturb_spin.setValue(0.008)
198
+ params_layout.addRow("Pixel perturb", self.perturb_spin)
199
+
200
+ # Seed
201
+ self.seed_spin = QSpinBox()
202
+ self.seed_spin.setRange(0, 2 ** 31 - 1)
203
+ self.seed_spin.setValue(0)
204
+ params_layout.addRow("Seed (0=none)", self.seed_spin)
205
+
206
+ # Camera simulator toggle
207
+ self.sim_camera_chk = QCheckBox("Enable camera pipeline simulation")
208
+ self.sim_camera_chk.setChecked(False)
209
+ self.sim_camera_chk.stateChanged.connect(self._on_sim_camera_toggled)
210
+ params_layout.addRow(self.sim_camera_chk)
211
+
212
+ # Camera simulator group
213
+ self.camera_group = QGroupBox("Camera simulator options")
214
+ cam_layout = QFormLayout()
215
+ self.camera_group.setLayout(cam_layout)
216
+
217
+ # Enable bayer
218
+ self.bayer_chk = QCheckBox("Enable Bayer / demosaic (RGGB)")
219
+ self.bayer_chk.setChecked(True)
220
+ cam_layout.addRow(self.bayer_chk)
221
+
222
+ # JPEG cycles
223
+ self.jpeg_cycles_spin = QSpinBox()
224
+ self.jpeg_cycles_spin.setRange(0, 10)
225
+ self.jpeg_cycles_spin.setValue(1)
226
+ cam_layout.addRow("JPEG cycles", self.jpeg_cycles_spin)
227
+
228
+ # JPEG quality min/max
229
+ self.jpeg_qmin_spin = QSpinBox()
230
+ self.jpeg_qmin_spin.setRange(1, 100)
231
+ self.jpeg_qmin_spin.setValue(88)
232
+ self.jpeg_qmax_spin = QSpinBox()
233
+ self.jpeg_qmax_spin.setRange(1, 100)
234
+ self.jpeg_qmax_spin.setValue(96)
235
+ qbox = QHBoxLayout()
236
+ qbox.addWidget(self.jpeg_qmin_spin)
237
+ qbox.addWidget(QLabel("to"))
238
+ qbox.addWidget(self.jpeg_qmax_spin)
239
+ cam_layout.addRow("JPEG quality (min to max)", qbox)
240
+
241
+ # Vignette strength
242
+ self.vignette_spin = QDoubleSpinBox()
243
+ self.vignette_spin.setRange(0.0, 1.0)
244
+ self.vignette_spin.setSingleStep(0.01)
245
+ self.vignette_spin.setValue(0.35)
246
+ cam_layout.addRow("Vignette strength", self.vignette_spin)
247
+
248
+ # Chromatic aberration strength
249
+ self.chroma_spin = QDoubleSpinBox()
250
+ self.chroma_spin.setRange(0.0, 10.0)
251
+ self.chroma_spin.setSingleStep(0.1)
252
+ self.chroma_spin.setValue(1.2)
253
+ cam_layout.addRow("Chromatic aberration (px)", self.chroma_spin)
254
+
255
+ # ISO scale
256
+ self.iso_spin = QDoubleSpinBox()
257
+ self.iso_spin.setRange(0.1, 16.0)
258
+ self.iso_spin.setSingleStep(0.1)
259
+ self.iso_spin.setValue(1.0)
260
+ cam_layout.addRow("ISO/exposure scale", self.iso_spin)
261
+
262
+ # Read noise
263
+ self.read_noise_spin = QDoubleSpinBox()
264
+ self.read_noise_spin.setRange(0.0, 50.0)
265
+ self.read_noise_spin.setSingleStep(0.1)
266
+ self.read_noise_spin.setValue(2.0)
267
+ cam_layout.addRow("Read noise (DN)", self.read_noise_spin)
268
+
269
+ # Hot pixel prob
270
+ self.hot_pixel_spin = QDoubleSpinBox()
271
+ self.hot_pixel_spin.setDecimals(9)
272
+ self.hot_pixel_spin.setRange(0.0, 1.0)
273
+ self.hot_pixel_spin.setSingleStep(1e-6)
274
+ self.hot_pixel_spin.setValue(1e-6)
275
+ cam_layout.addRow("Hot pixel prob", self.hot_pixel_spin)
276
+
277
+ # Banding strength
278
+ self.banding_spin = QDoubleSpinBox()
279
+ self.banding_spin.setRange(0.0, 1.0)
280
+ self.banding_spin.setSingleStep(0.01)
281
+ self.banding_spin.setValue(0.0)
282
+ cam_layout.addRow("Banding strength", self.banding_spin)
283
+
284
+ # Motion blur kernel
285
+ self.motion_blur_spin = QSpinBox()
286
+ self.motion_blur_spin.setRange(1, 51)
287
+ self.motion_blur_spin.setValue(1)
288
+ cam_layout.addRow("Motion blur kernel", self.motion_blur_spin)
289
+
290
+ self.camera_group.setVisible(False)
291
+ right_v.addWidget(self.camera_group)
292
+
293
+ params_layout.addRow(self.camera_group)
294
+
295
+ self.ref_hint = QLabel("Reference color matching supported by OpenCV only.")
296
+ right_v.addWidget(self.ref_hint)
297
+
298
+ self.analysis_input = AnalysisPanel(title="Input analysis")
299
+ self.analysis_output = AnalysisPanel(title="Output analysis")
300
+ right_v.addWidget(self.analysis_input)
301
+ right_v.addWidget(self.analysis_output)
302
+
303
+ right_v.addStretch(1)
304
+
305
+ # Status bar
306
+ self.status = QLabel("Ready")
307
+ self.status.setStyleSheet("color:#bdbdbd;padding:6px")
308
+ self.status.setAlignment(Qt.AlignLeft)
309
+ self.status.setFixedHeight(28)
310
+ self.status.setContentsMargins(6, 6, 6, 6)
311
+ self.statusBar().addWidget(self.status)
312
+
313
+ self.worker = None
314
+ self._on_auto_mode_toggled(self.auto_mode_chk.checkState())
315
+
316
+ def _on_sim_camera_toggled(self, state):
317
+ enabled = state == Qt.Checked
318
+ self.camera_group.setVisible(enabled)
319
+
320
+ def _on_auto_mode_toggled(self, state):
321
+ is_auto = (state == Qt.Checked)
322
+ self.auto_group.setVisible(is_auto)
323
+ self.params_group.setVisible(not is_auto)
324
+
325
+ def _update_strength_label(self, value):
326
+ self.strength_label.setText(str(value))
327
+
328
+ def choose_input(self):
329
+ path, _ = QFileDialog.getOpenFileName(self, "Choose input image", str(Path.home()), "Images (*.png *.jpg *.jpeg *.bmp *.tif)")
330
+ if path:
331
+ self.input_line.setText(path)
332
+ self.load_preview(self.preview_in, path)
333
+ self.analysis_input.update_from_path(path)
334
+ out_suggest = str(Path(path).with_name(Path(path).stem + "_out" + Path(path).suffix))
335
+ if not self.output_line.text():
336
+ self.output_line.setText(out_suggest)
337
+
338
+ def choose_ref(self):
339
+ path, _ = QFileDialog.getOpenFileName(self, "Choose reference image", str(Path.home()), "Images (*.png *.jpg *.jpeg *.bmp *.tif)")
340
+ if path:
341
+ self.ref_line.setText(path)
342
+
343
+ def choose_output(self):
344
+ path, _ = QFileDialog.getSaveFileName(self, "Choose output path", str(Path.home()), "JPEG (*.jpg *.jpeg);;PNG (*.png);;TIFF (*.tif)")
345
+ if path:
346
+ self.output_line.setText(path)
347
+
348
+ def load_preview(self, widget: QLabel, path: str):
349
+ if not path or not os.path.exists(path):
350
+ widget.setText("No image")
351
+ widget.setPixmap(QPixmap())
352
+ return
353
+ pix = qpixmap_from_path(path, max_size=(widget.width(), widget.height()))
354
+ widget.setPixmap(pix)
355
+
356
+ def set_enabled_all(self, enabled: bool):
357
+ for w in self.findChildren((QPushButton, QDoubleSpinBox, QSpinBox, QLineEdit, QComboBox, QCheckBox, QSlider)):
358
+ w.setEnabled(enabled)
359
+
360
+ def on_run(self):
361
+ from types import SimpleNamespace
362
+ inpath = self.input_line.text().strip()
363
+ outpath = self.output_line.text().strip()
364
+ if not inpath or not os.path.exists(inpath):
365
+ QMessageBox.warning(self, "Missing input", "Please choose a valid input image.")
366
+ return
367
+ if not outpath:
368
+ QMessageBox.warning(self, "Missing output", "Please choose an output path.")
369
+ return
370
+
371
+ ref_val = self.ref_line.text() or None
372
+ args = SimpleNamespace()
373
+
374
+ if self.auto_mode_chk.isChecked():
375
+ strength = self.strength_slider.value() / 100.0
376
+ args.noise_std = strength * 0.04
377
+ args.clahe_clip = 1.0 + strength * 3.0
378
+ args.cutoff = max(0.01, 0.4 - strength * 0.3)
379
+ args.fstrength = strength * 0.95
380
+ args.phase_perturb = strength * 0.1
381
+ args.perturb = strength * 0.015
382
+ args.jpeg_cycles = int(strength * 2)
383
+ args.jpeg_qmin = max(1, int(95 - strength * 35))
384
+ args.jpeg_qmax = max(1, int(99 - strength * 25))
385
+ args.vignette_strength = strength * 0.6
386
+ args.chroma_strength = strength * 4.0
387
+ args.motion_blur_kernel = 1 + 2 * int(strength * 6)
388
+ args.banding_strength = strength * 0.1
389
+ args.tile = 8
390
+ args.randomness = 0.05
391
+ args.radial_smooth = 5
392
+ args.fft_mode = "auto"
393
+ args.fft_alpha = 1.0
394
+ args.alpha = 1.0
395
+ seed_val = int(self.seed_spin.value())
396
+ args.seed = None if seed_val == 0 else seed_val
397
+ args.sim_camera = bool(self.sim_camera_chk.isChecked())
398
+ args.no_no_bayer = True
399
+ args.iso_scale = 1.0
400
+ args.read_noise = 2.0
401
+ args.hot_pixel_prob = 1e-6
402
+ else:
403
+ seed_val = int(self.seed_spin.value())
404
+ args.seed = None if seed_val == 0 else seed_val
405
+ sim_camera = bool(self.sim_camera_chk.isChecked())
406
+ enable_bayer = bool(self.bayer_chk.isChecked())
407
+ args.noise_std = float(self.noise_spin.value())
408
+ args.clahe_clip = float(self.clahe_spin.value())
409
+ args.tile = int(self.tile_spin.value())
410
+ args.cutoff = float(self.cutoff_spin.value())
411
+ args.fstrength = float(self.fstrength_spin.value())
412
+ args.strength = float(self.fstrength_spin.value())
413
+ args.randomness = float(self.randomness_spin.value())
414
+ args.phase_perturb = float(self.phase_perturb_spin.value())
415
+ args.perturb = float(self.perturb_spin.value())
416
+ args.fft_mode = self.fft_mode_combo.currentText()
417
+ args.fft_alpha = float(self.fft_alpha_spin.value())
418
+ args.alpha = float(self.fft_alpha_spin.value())
419
+ args.radial_smooth = int(self.radial_smooth_spin.value())
420
+ args.sim_camera = sim_camera
421
+ args.no_no_bayer = bool(enable_bayer)
422
+ args.jpeg_cycles = int(self.jpeg_cycles_spin.value())
423
+ args.jpeg_qmin = int(self.jpeg_qmin_spin.value())
424
+ args.jpeg_qmax = int(self.jpeg_qmax_spin.value())
425
+ args.vignette_strength = float(self.vignette_spin.value())
426
+ args.chroma_strength = float(self.chroma_spin.value())
427
+ args.iso_scale = float(self.iso_spin.value())
428
+ args.read_noise = float(self.read_noise_spin.value())
429
+ args.hot_pixel_prob = float(self.hot_pixel_spin.value())
430
+ args.banding_strength = float(self.banding_spin.value())
431
+ args.motion_blur_kernel = int(self.motion_blur_spin.value())
432
+
433
+ args.ref = None
434
+ args.fft_ref = ref_val
435
+
436
+ self.worker = Worker(inpath, outpath, args)
437
+ self.worker.finished.connect(self.on_finished)
438
+ self.worker.error.connect(self.on_error)
439
+ self.worker.started.connect(lambda: self.on_worker_started())
440
+ self.worker.start()
441
+
442
+ self.progress.setRange(0, 0)
443
+ self.status.setText("Processing...")
444
+ self.set_enabled_all(False)
445
+
446
+ def on_worker_started(self):
447
+ pass
448
+
449
+ def on_finished(self, outpath):
450
+ self.progress.setRange(0, 100)
451
+ self.progress.setValue(100)
452
+ self.status.setText("Done — saved to: " + outpath)
453
+ self.load_preview(self.preview_out, outpath)
454
+ self.analysis_output.update_from_path(outpath)
455
+ self.set_enabled_all(True)
456
+
457
+ def on_error(self, msg, traceback_text):
458
+ from PyQt5.QtWidgets import QDialog, QTextEdit, QVBoxLayout
459
+ self.progress.setRange(0, 100)
460
+ self.progress.setValue(0)
461
+ self.status.setText("Error")
462
+
463
+ dialog = QDialog(self)
464
+ dialog.setWindowTitle("Processing Error")
465
+ dialog.setMinimumSize(700, 480)
466
+ layout = QVBoxLayout(dialog)
467
+
468
+ error_label = QLabel(f"Error: {msg}")
469
+ error_label.setWordWrap(True)
470
+ layout.addWidget(error_label)
471
+
472
+ traceback_edit = QTextEdit()
473
+ traceback_edit.setReadOnly(True)
474
+ traceback_edit.setText(traceback_text)
475
+ traceback_edit.setStyleSheet("font-family: monospace; font-size: 12px;")
476
+ layout.addWidget(traceback_edit)
477
+
478
+ ok_button = QPushButton("OK")
479
+ ok_button.clicked.connect(dialog.accept)
480
+ layout.addWidget(ok_button)
481
+
482
+ dialog.exec_()
483
+ self.set_enabled_all(True)
484
+
485
+ def open_output_folder(self):
486
+ out = self.output_line.text().strip()
487
+ if not out:
488
+ QMessageBox.information(self, "No output", "No output path set yet.")
489
+ return
490
+ folder = os.path.dirname(os.path.abspath(out))
491
+ if not os.path.exists(folder):
492
+ QMessageBox.warning(self, "Not found", "Output folder does not exist: " + folder)
493
+ return
494
+ if sys.platform.startswith('darwin'):
495
+ os.system(f'open "{folder}"')
496
+ elif os.name == 'nt':
497
+ os.startfile(folder)
498
+ else:
499
+ os.system(f'xdg-open "{folder}"')
500
+
501
+ def main():
502
+ app = QApplication([])
503
+ if IMPORT_ERROR:
504
+ QMessageBox.critical(None, "Import error", "Could not import image_postprocess module:\n" + IMPORT_ERROR)
505
+ w = MainWindow()
506
+ w.show()
507
+ sys.exit(app.exec_())
508
+
509
+ if __name__ == '__main__':
510
+ main()
utils.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Utility functions for image processing GUI.
4
+ """
5
+
6
+ from PyQt5.QtGui import QPixmap
7
+ from PyQt5.QtCore import Qt
8
+ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
9
+ from matplotlib.figure import Figure
10
+ from PIL import Image
11
+ import numpy as np
12
+
13
+ def qpixmap_from_path(p: str, max_size=(480, 360)) -> QPixmap:
14
+ pix = QPixmap(p)
15
+ if pix.isNull():
16
+ return QPixmap()
17
+ w, h = max_size
18
+ return pix.scaled(w, h, Qt.KeepAspectRatio, Qt.SmoothTransformation)
19
+
20
+ def make_canvas(width=4, height=3, dpi=100):
21
+ fig = Figure(figsize=(width, height), dpi=dpi)
22
+ canvas = FigureCanvas(fig)
23
+ ax = fig.add_subplot(111)
24
+ fig.tight_layout()
25
+ return canvas, ax
26
+
27
+ def compute_gray_array(path):
28
+ img = Image.open(path).convert('RGB')
29
+ arr = np.array(img)
30
+ gray = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.float32)
31
+ return gray
32
+
33
+ def compute_fft_magnitude(gray_arr, eps=1e-8):
34
+ f = np.fft.fft2(gray_arr)
35
+ fshift = np.fft.fftshift(f)
36
+ mag = np.abs(fshift)
37
+ mag_log = np.log1p(mag)
38
+ return mag, mag_log
39
+
40
+ def radial_profile(mag, center=None, nbins=100):
41
+ h, w = mag.shape
42
+ if center is None:
43
+ center = (int(h / 2), int(w / 2))
44
+ y, x = np.indices((h, w))
45
+ r = np.sqrt((x - center[1]) ** 2 + (y - center[0]) ** 2)
46
+ r_flat = r.ravel()
47
+ mag_flat = mag.ravel()
48
+ max_r = np.max(r_flat)
49
+ if max_r <= 0:
50
+ return np.linspace(0, 1, nbins), np.zeros(nbins)
51
+ bins = np.linspace(0, max_r, nbins + 1)
52
+ inds = np.digitize(r_flat, bins) - 1
53
+ radial_mean = np.zeros(nbins)
54
+ for i in range(nbins):
55
+ sel = inds == i
56
+ if np.any(sel):
57
+ radial_mean[i] = mag_flat[sel].mean()
58
+ else:
59
+ radial_mean[i] = 0.0
60
+ centers = 0.5 * (bins[:-1] + bins[1:]) / max_r
61
+ return centers, radial_mean
worker.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Worker thread for image processing.
4
+ """
5
+
6
+ from PyQt5.QtCore import QThread, pyqtSignal
7
+ import traceback
8
+
9
+ try:
10
+ from image_postprocess import process_image
11
+ except Exception:
12
+ process_image = None
13
+ IMPORT_ERROR = "Could not import process_image module"
14
+ else:
15
+ IMPORT_ERROR = None
16
+
17
+ class Worker(QThread):
18
+ finished = pyqtSignal(str)
19
+ error = pyqtSignal(str, str) # error message + traceback
20
+
21
+ def __init__(self, inpath, outpath, args):
22
+ super().__init__()
23
+ self.inpath = inpath
24
+ self.outpath = outpath
25
+ self.args = args
26
+
27
+ def run(self):
28
+ try:
29
+ if process_image is None:
30
+ raise RuntimeError("Could not import process_image: " + (IMPORT_ERROR or "unknown"))
31
+ process_image(self.inpath, self.outpath, self.args)
32
+ self.finished.emit(self.outpath)
33
+ except Exception as e:
34
+ tb = traceback.format_exc()
35
+ self.error.emit(str(e), tb)