aleph65 commited on
Commit
f827b9d
·
verified ·
1 Parent(s): 75ec5b4

Upload folder using huggingface_hub

Browse files
custom_nodes/plxr_deteriorate/__init__.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PLXR Deteriorate — apply the training-time synthetic degradation to an image.
2
+
3
+ Wraps the recipe families from the qwen-edit-restore project's deteriorate.py
4
+ (bundled as deteriorate_core.py) so a workflow can force-degrade an input
5
+ before restoration, for testing / guaranteeing visible deterioration.
6
+ """
7
+
8
+ import io
9
+ import random
10
+
11
+ import numpy as np
12
+ import torch
13
+ from PIL import Image
14
+
15
+ from . import deteriorate_core as core
16
+
17
+ _REMBG_SESSION = None
18
+
19
+
20
+ def _person_mask_from_array(img01):
21
+ """Soft subject mask via rembg, from a float [H,W,3] array. None on failure."""
22
+ global _REMBG_SESSION
23
+ try:
24
+ from rembg import remove, new_session
25
+ if _REMBG_SESSION is None:
26
+ _REMBG_SESSION = new_session("u2net")
27
+ buf = io.BytesIO()
28
+ Image.fromarray(core.to_uint8(img01)).save(buf, format="PNG")
29
+ out = remove(buf.getvalue(), session=_REMBG_SESSION, only_mask=True)
30
+ m = np.asarray(Image.open(io.BytesIO(out)).convert("L"),
31
+ dtype=np.float32) / 255.0
32
+ if m.shape != img01.shape[:2]:
33
+ import cv2
34
+ m = cv2.resize(m, (img01.shape[1], img01.shape[0]))
35
+ return m
36
+ except Exception as e:
37
+ print(f"[plxr_deteriorate] person mask failed ({e}); bg-blur ops degrade to global blur")
38
+ return None
39
+
40
+
41
+ class PLXRDeteriorate:
42
+ CATEGORY = "image/plxr"
43
+ RETURN_TYPES = ("IMAGE",)
44
+ FUNCTION = "run"
45
+
46
+ @classmethod
47
+ def INPUT_TYPES(cls):
48
+ return {
49
+ "required": {
50
+ "image": ("IMAGE",),
51
+ "family": (["random", "atmospheric", "digital"],),
52
+ "severity_min": ("FLOAT", {"default": 0.35, "min": 0.0, "max": 1.0, "step": 0.05}),
53
+ "severity_max": ("FLOAT", {"default": 0.85, "min": 0.0, "max": 1.0, "step": 0.05}),
54
+ "seed": ("INT", {"default": 0, "min": 0, "max": 2**32 - 1}),
55
+ "use_person_mask": ("BOOLEAN", {"default": True}),
56
+ }
57
+ }
58
+
59
+ def run(self, image, family, severity_min, severity_max, seed, use_person_mask):
60
+ out = []
61
+ for b in range(image.shape[0]):
62
+ x0 = image[b].cpu().numpy().astype(np.float32) # [H,W,C] 0..1
63
+ x0 = np.clip(x0[..., :3], 0.0, 1.0)
64
+
65
+ item_seed = seed + b
66
+ rng_np = np.random.default_rng(item_seed)
67
+ pyrng = random.Random(item_seed ^ 0xABCD)
68
+
69
+ class R:
70
+ uniform = staticmethod(pyrng.uniform)
71
+ choice = staticmethod(pyrng.choice)
72
+ random = staticmethod(pyrng.random)
73
+ normal = staticmethod(rng_np.normal)
74
+
75
+ lo, hi = sorted((severity_min, severity_max))
76
+ sev = pyrng.uniform(lo, hi)
77
+
78
+ fam = family
79
+ if fam == "random":
80
+ fam = pyrng.choice(["atmospheric", "digital"])
81
+ recipe = (core.recipe_atmospheric if fam == "atmospheric"
82
+ else core.recipe_digital)
83
+
84
+ mask = _person_mask_from_array(x0) if use_person_mask else None
85
+ y, label = recipe(x0.copy(), R, sev, mask)
86
+ print(f"[plxr_deteriorate] applied {label} sev={sev:.2f} seed={item_seed}")
87
+ y = np.clip(y, 0.0, 1.0)
88
+ out.append(torch.from_numpy(y.astype(np.float32)))
89
+ return (torch.stack(out).to(image.device),)
90
+
91
+
92
+ NODE_CLASS_MAPPINGS = {"PLXRDeteriorate": PLXRDeteriorate}
93
+ NODE_DISPLAY_NAME_MAPPINGS = {"PLXRDeteriorate": "PLXR Deteriorate (restore-lora test)"}
custom_nodes/plxr_deteriorate/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (5.51 kB). View file
 
custom_nodes/plxr_deteriorate/__pycache__/deteriorate_core.cpython-312.pyc ADDED
Binary file (30.8 kB). View file
 
custom_nodes/plxr_deteriorate/deteriorate_core.py ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Deterioration generator for the Qwen-Image-Edit restore LoRA dataset.
4
+
5
+ Takes clean "instagram quality" photos and produces degraded control images
6
+ spanning: slightly-soft instagram save -> hazy/faded low-contrast phone pic ->
7
+ crushed, recompressed WhatsApp forward -> overprocessed (halo sharpening /
8
+ heavy grain / fake portrait-mode background blur).
9
+
10
+ Each source image gets N variants (default 2) with *forced-different* recipe
11
+ families so the pair never looks alike:
12
+ v1 -> family A "atmospheric": haze veil, lifted blacks, bland/faded color,
13
+ cast, bloom/veiling flare, lens softness, light grain, jpeg
14
+ v2 -> family B "digital/processed", one of:
15
+ B1 grainy+compressed (downscale-upscale, strong grain, low jpeg)
16
+ B2 overprocessed (halo oversharpen, sat/contrast push, grain, jpeg)
17
+ B3 portrait-mode fail (background-only blur via person mask,
18
+ mild global softness, cast, jpeg)
19
+
20
+ Outputs (same pixel size as source):
21
+ out/control/{stem}_v{k}.jpg degraded input
22
+ out/target/{stem}_v{k}.jpg clean target (copy of source, same name)
23
+
24
+ Usage:
25
+ python deteriorate.py --src ../official_photos_dataset --out ./dataset \
26
+ --limit 20 --variants 2 --seed 1234 --sheets ./review_sheets
27
+ """
28
+
29
+ import argparse
30
+ import hashlib
31
+ import io
32
+ import os
33
+ import random
34
+ import sys
35
+
36
+ import cv2
37
+ import numpy as np
38
+ from PIL import Image
39
+
40
+ # rembg is only needed for the background-blur op; loaded lazily.
41
+ _REMBG_SESSION = None
42
+
43
+
44
+ # ----------------------------------------------------------------------------
45
+ # helpers
46
+ # ----------------------------------------------------------------------------
47
+
48
+ def load_rgb(path):
49
+ with Image.open(path) as im:
50
+ return np.asarray(im.convert("RGB"), dtype=np.float32) / 255.0
51
+
52
+
53
+ def to_uint8(x):
54
+ return (np.clip(x, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
55
+
56
+
57
+ def gaussian(x, sigma):
58
+ if sigma <= 0:
59
+ return x
60
+ k = int(sigma * 6) | 1
61
+ return cv2.GaussianBlur(x, (k, k), sigma)
62
+
63
+
64
+ def luma(x):
65
+ return x[..., 0] * 0.299 + x[..., 1] * 0.587 + x[..., 2] * 0.114
66
+
67
+
68
+ def scale_of(x):
69
+ """Relative size factor so op strengths look similar at any resolution."""
70
+ return max(x.shape[0], x.shape[1]) / 1500.0
71
+
72
+
73
+ def person_mask(path, shape):
74
+ """Soft [0,1] mask of the subject via rembg/u2net. None on failure."""
75
+ global _REMBG_SESSION
76
+ try:
77
+ from rembg import remove, new_session
78
+ if _REMBG_SESSION is None:
79
+ _REMBG_SESSION = new_session("u2net")
80
+ with open(path, "rb") as f:
81
+ out = remove(f.read(), session=_REMBG_SESSION, only_mask=True)
82
+ m = np.asarray(Image.open(io.BytesIO(out)).convert("L"),
83
+ dtype=np.float32) / 255.0
84
+ if m.shape != shape[:2]:
85
+ m = cv2.resize(m, (shape[1], shape[0]))
86
+ return m
87
+ except Exception as e:
88
+ print(f" [warn] person mask failed ({e}); skipping bg-blur op",
89
+ file=sys.stderr)
90
+ return None
91
+
92
+
93
+ # ----------------------------------------------------------------------------
94
+ # degradation ops (all take/return float32 RGB in [0,1])
95
+ # ----------------------------------------------------------------------------
96
+
97
+ def op_fade(x, rng, sev):
98
+ """Lifted blacks + reduced contrast: the faded/bland look."""
99
+ lift = rng.uniform(0.02, 0.13) * sev
100
+ ceil = 1.0 - rng.uniform(0.0, 0.05) * sev
101
+ x = x * (ceil - lift) + lift
102
+ c = 1.0 - rng.uniform(0.08, 0.30) * sev
103
+ return (x - 0.5) * c + 0.5
104
+
105
+
106
+ def op_desaturate(x, rng, sev):
107
+ s = rng.uniform(0.12, 0.45) * sev
108
+ l = luma(x)[..., None]
109
+ return x * (1 - s) + l * s
110
+
111
+
112
+ def op_cast(x, rng, sev):
113
+ """Random color cast: warm, cool, magenta or green."""
114
+ a = rng.uniform(0.03, 0.11) * sev
115
+ kind = rng.choice(["warm", "cool", "magenta", "green"])
116
+ g = {
117
+ "warm": (1 + a, 1 + a * 0.35, 1 - a),
118
+ "cool": (1 - a, 1 + a * 0.15, 1 + a),
119
+ "magenta": (1 + a * 0.7, 1 - a * 0.6, 1 + a * 0.7),
120
+ "green": (1 - a * 0.5, 1 + a * 0.7, 1 - a * 0.5),
121
+ }[kind]
122
+ return x * np.asarray(g, dtype=np.float32)
123
+
124
+
125
+ def op_haze(x, rng, sev):
126
+ """Flat veil of light, slightly warm gray — washed-out sunset haze."""
127
+ h = rng.uniform(0.05, 0.20) * sev
128
+ veil = np.asarray(
129
+ [0.92, 0.88 + rng.uniform(-0.04, 0.02), 0.82 + rng.uniform(-0.06, 0.06)],
130
+ dtype=np.float32)
131
+ return x * (1 - h) + veil * h
132
+
133
+
134
+ def op_bloom(x, rng, sev):
135
+ """Highlights bleed/glow (bright windows, sky) via screen blend."""
136
+ thr = rng.uniform(0.55, 0.75)
137
+ amt = rng.uniform(0.35, 0.9) * sev
138
+ bright = np.clip((x - thr) / (1 - thr), 0, 1)
139
+ glow = gaussian(bright, max(3.0, 45.0 * scale_of(x)))
140
+ return 1 - (1 - x) * (1 - glow * amt)
141
+
142
+
143
+ def op_flare(x, rng, sev):
144
+ """Veiling flare: warm radial gradient from a corner, screen-blended."""
145
+ h, w = x.shape[:2]
146
+ cy = rng.choice([0.0, 1.0]) * h + rng.uniform(-0.2, 0.2) * h
147
+ cx = rng.choice([0.0, 1.0]) * w + rng.uniform(-0.2, 0.2) * w
148
+ yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
149
+ d = np.sqrt((yy - cy) ** 2 + (xx - cx) ** 2) / np.hypot(h, w)
150
+ fall = np.clip(1 - d / rng.uniform(0.55, 0.95), 0, 1) ** 2
151
+ amt = rng.uniform(0.15, 0.40) * sev
152
+ tint = np.asarray([1.0, 0.93, 0.80], dtype=np.float32)
153
+ flare = fall[..., None] * tint * amt
154
+ return 1 - (1 - x) * (1 - flare)
155
+
156
+
157
+ def op_overexpose(x, rng, sev):
158
+ """Blown highlights / flat bright light: sky goes paper-white."""
159
+ gain = 1 + rng.uniform(0.15, 0.45) * sev
160
+ x = np.clip(x * gain, 0, 1)
161
+ c = 1.0 - rng.uniform(0.05, 0.2) * sev
162
+ return (x - 0.5) * c + 0.5
163
+
164
+
165
+ def op_underexpose(x, rng, sev):
166
+ """Moody dark edit: dropped exposure, punchy contrast."""
167
+ gain = 1 - rng.uniform(0.15, 0.4) * sev
168
+ x = x * gain
169
+ c = 1 + rng.uniform(0.1, 0.35) * sev
170
+ return (x - 0.45) * c + 0.45
171
+
172
+
173
+ def op_ghost(x, rng, sev):
174
+ """Lens ghost: translucent bright blob(s), like night flash shots."""
175
+ h, w = x.shape[:2]
176
+ n = rng.choice([1, 1, 2])
177
+ for _ in range(n):
178
+ cy, cx = rng.uniform(0.2, 0.8) * h, rng.uniform(0.2, 0.8) * w
179
+ r = rng.uniform(0.05, 0.16) * max(h, w)
180
+ yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
181
+ d = np.sqrt((yy - cy) ** 2 + (xx - cx) ** 2)
182
+ blob = np.clip(1 - np.abs(d - r * 0.7) / (r * 0.5), 0, 1) ** 1.5
183
+ amt = rng.uniform(0.06, 0.18) * sev
184
+ tint = np.asarray([rng.uniform(0.7, 1.0), 1.0, rng.uniform(0.7, 1.0)],
185
+ dtype=np.float32)
186
+ x = 1 - (1 - x) * (1 - blob[..., None] * tint * amt)
187
+ return x
188
+
189
+
190
+ def op_halo(x, rng, sev, mask):
191
+ """HDR-edit glow hugging the subject outline (bad dodge/burn halo)."""
192
+ if mask is None:
193
+ return op_bloom(x, rng, sev)
194
+ s = max(3.0, 25.0 * scale_of(x))
195
+ edge = np.clip(gaussian(mask, s * 2.2) - gaussian(mask, s * 0.5), 0, 1)
196
+ edge = edge / max(edge.max(), 1e-4)
197
+ amt = rng.uniform(0.15, 0.4) * sev
198
+ return 1 - (1 - x) * (1 - edge[..., None] * amt)
199
+
200
+
201
+ def op_soft(x, rng, sev):
202
+ """Global lens softness / slight defocus."""
203
+ sigma = rng.uniform(0.5, 2.4) * max(0.5, scale_of(x)) * (0.4 + 0.6 * sev)
204
+ return gaussian(x, sigma)
205
+
206
+
207
+ def op_bg_blur(x, rng, sev, mask):
208
+ """Blur only the background behind the subject (fake bokeh / focus miss)."""
209
+ if mask is None:
210
+ return op_soft(x, rng, sev)
211
+ sigma = rng.uniform(4.0, 13.0) * max(0.5, scale_of(x)) * (0.5 + 0.5 * sev)
212
+ bg = gaussian(x, sigma)
213
+ m = gaussian(mask, max(2.0, 6.0 * scale_of(x)))[..., None]
214
+ return x * m + bg * (1 - m)
215
+
216
+
217
+ def op_grain(x, rng, sev):
218
+ """ISO-style grain: luma noise (optionally coarse) + weaker chroma noise."""
219
+ h, w = x.shape[:2]
220
+ std = rng.uniform(0.015, 0.065) * (0.4 + 0.6 * sev)
221
+ if rng.random() < 0.5: # coarse grain: generate low-res, upscale
222
+ f = rng.uniform(1.5, 3.0)
223
+ n = rng.normal(0, std, (int(h / f), int(w / f), 1)).astype(np.float32)
224
+ n = cv2.resize(n, (w, h), interpolation=cv2.INTER_LINEAR)[..., None]
225
+ else:
226
+ n = rng.normal(0, std, (h, w, 1)).astype(np.float32)
227
+ x = x + n
228
+ if rng.random() < 0.6:
229
+ cn = rng.normal(0, std * 0.5, (h, w, 3)).astype(np.float32)
230
+ x = x + cn
231
+ return x
232
+
233
+
234
+ def op_oversharpen(x, rng, sev):
235
+ """Unsharp-mask overdone -> edge halos, the 'overprocessed' look."""
236
+ radius = rng.uniform(1.2, 3.0) * max(0.5, scale_of(x))
237
+ amount = rng.uniform(0.7, 2.0) * (0.5 + 0.5 * sev)
238
+ return x + (x - gaussian(x, radius)) * amount
239
+
240
+
241
+ def op_oversaturate(x, rng, sev):
242
+ hsv = cv2.cvtColor(to_uint8(x), cv2.COLOR_RGB2HSV).astype(np.float32)
243
+ hsv[..., 1] *= 1 + rng.uniform(0.15, 0.5) * sev
244
+ hsv[..., 1] = np.clip(hsv[..., 1], 0, 255)
245
+ return cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB).astype(
246
+ np.float32) / 255.0
247
+
248
+
249
+ def op_crush(x, rng, sev):
250
+ """Too much contrast: crushed shadows / clipped highlights."""
251
+ c = 1 + rng.uniform(0.15, 0.5) * sev
252
+ x = (x - 0.5) * c + 0.5
253
+ g = rng.uniform(1.02, 1.18)
254
+ return np.clip(x, 0, 1) ** g
255
+
256
+
257
+ def op_downup(x, rng, sev):
258
+ """Downscale then upscale back: low-res resample mush."""
259
+ h, w = x.shape[:2]
260
+ f = 1.0 - rng.uniform(0.2, 0.55) * sev
261
+ interp = rng.choice([cv2.INTER_LINEAR, cv2.INTER_AREA])
262
+ small = cv2.resize(x, (max(64, int(w * f)), max(64, int(h * f))),
263
+ interpolation=interp)
264
+ return cv2.resize(small, (w, h), interpolation=cv2.INTER_LINEAR)
265
+
266
+
267
+ def op_vignette(x, rng, sev):
268
+ h, w = x.shape[:2]
269
+ yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
270
+ r2 = ((yy - h / 2) / (h / 2)) ** 2 + ((xx - w / 2) / (w / 2)) ** 2
271
+ v = rng.uniform(0.10, 0.30) * sev
272
+ return x * (1 - v * r2[..., None] / 2)
273
+
274
+
275
+ def op_jpeg(x, rng, q_lo, q_hi, passes=1):
276
+ for _ in range(passes):
277
+ q = int(rng.uniform(q_lo, q_hi))
278
+ ok, buf = cv2.imencode(".jpg", cv2.cvtColor(to_uint8(x),
279
+ cv2.COLOR_RGB2BGR),
280
+ [cv2.IMWRITE_JPEG_QUALITY, q])
281
+ x = cv2.cvtColor(cv2.imdecode(buf, cv2.IMREAD_COLOR),
282
+ cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
283
+ return x
284
+
285
+
286
+ # ----------------------------------------------------------------------------
287
+ # recipes
288
+ # ----------------------------------------------------------------------------
289
+
290
+ def recipe_atmospheric(x, rng, sev, mask):
291
+ """Family A: hazy / faded / bland / glowing. The 'bad light + bad save'."""
292
+ x = op_fade(x, rng, sev)
293
+ x = op_desaturate(x, rng, sev)
294
+ x = op_cast(x, rng, sev)
295
+ if rng.random() < 0.4: # blown flat sky (balcony-shot look)
296
+ x = op_overexpose(x, rng, sev)
297
+ if rng.random() < 0.65:
298
+ x = op_haze(x, rng, sev)
299
+ if rng.random() < 0.6:
300
+ x = op_bloom(x, rng, sev)
301
+ if rng.random() < 0.45:
302
+ x = op_flare(x, rng, sev)
303
+ if rng.random() < 0.15:
304
+ x = op_ghost(x, rng, sev)
305
+ if rng.random() < 0.35 and mask is not None:
306
+ x = op_bg_blur(x, rng, sev * 0.7, mask)
307
+ x = op_soft(x, rng, sev * rng.uniform(0.4, 1.0))
308
+ if rng.random() < 0.8:
309
+ x = op_grain(x, rng, sev * rng.uniform(0.3, 0.8))
310
+ if rng.random() < 0.35:
311
+ x = op_vignette(x, rng, sev)
312
+ # light-to-medium recompress; heavier when severity is high
313
+ x = op_jpeg(x, rng, 55 if sev > 0.75 else 68, 88,
314
+ passes=2 if sev > 0.85 else 1)
315
+ return x, "atmospheric"
316
+
317
+
318
+ def recipe_digital(x, rng, sev, mask):
319
+ """Family B: digitally mangled / overprocessed."""
320
+ mode = rng.choice(["grainy_compressed", "overprocessed", "portrait_fail",
321
+ "moody_grain"])
322
+
323
+ if mode == "grainy_compressed": # WhatsApp-forward territory
324
+ if rng.random() < 0.5:
325
+ x = op_fade(x, rng, sev * 0.7)
326
+ x = op_desaturate(x, rng, sev * rng.uniform(0.5, 1.0))
327
+ if rng.random() < 0.5:
328
+ x = op_cast(x, rng, sev)
329
+ x = op_downup(x, rng, sev)
330
+ x = op_grain(x, rng, sev * rng.uniform(0.8, 1.3))
331
+ x = op_jpeg(x, rng, 35, 62, passes=2 if rng.random() < 0.5 else 1)
332
+
333
+ elif mode == "overprocessed": # halo glow, pushed color, heavy filter
334
+ if rng.random() < 0.65:
335
+ x = op_oversaturate(x, rng, sev)
336
+ else:
337
+ x = op_desaturate(x, rng, sev * 0.6)
338
+ if rng.random() < 0.5: # heavy warm/cool 'filter' cast (yacht look)
339
+ x = op_cast(x, rng, min(1.0, sev * 1.6))
340
+ if rng.random() < 0.55:
341
+ x = op_crush(x, rng, sev)
342
+ if rng.random() < 0.5 and mask is not None: # HDR halo (beach look)
343
+ x = op_halo(x, rng, sev, mask)
344
+ x = op_soft(x, rng, sev * 0.5)
345
+ else:
346
+ x = op_oversharpen(x, rng, sev)
347
+ x = op_grain(x, rng, sev * rng.uniform(0.6, 1.1))
348
+ if rng.random() < 0.3:
349
+ x = op_vignette(x, rng, sev)
350
+ x = op_jpeg(x, rng, 55, 82)
351
+
352
+ elif mode == "moody_grain": # dark saturated edit + film grain + vignette
353
+ x = op_underexpose(x, rng, sev)
354
+ if rng.random() < 0.7:
355
+ x = op_oversaturate(x, rng, sev * 0.8)
356
+ if rng.random() < 0.5:
357
+ x = op_cast(x, rng, sev * 0.7)
358
+ x = op_grain(x, rng, sev * rng.uniform(0.9, 1.4))
359
+ x = op_vignette(x, rng, min(1.0, sev * 1.3))
360
+ if rng.random() < 0.3:
361
+ x = op_soft(x, rng, sev * 0.4)
362
+ x = op_jpeg(x, rng, 55, 85)
363
+
364
+ else: # portrait_fail: blurred background + mediocre color
365
+ x = op_bg_blur(x, rng, sev, mask)
366
+ x = op_fade(x, rng, sev * 0.8)
367
+ if rng.random() < 0.7:
368
+ x = op_cast(x, rng, sev)
369
+ if rng.random() < 0.5:
370
+ x = op_soft(x, rng, sev * 0.5)
371
+ x = op_grain(x, rng, sev * rng.uniform(0.3, 0.8))
372
+ x = op_jpeg(x, rng, 55, 85)
373
+
374
+ return x, f"digital/{mode}"
375
+
376
+
377
+ # ----------------------------------------------------------------------------
378
+ # driver
379
+ # ----------------------------------------------------------------------------
380
+
381
+ def seed_for(name, variant, base_seed):
382
+ h = hashlib.sha256(f"{base_seed}:{name}:{variant}".encode()).digest()
383
+ return int.from_bytes(h[:8], "big")
384
+
385
+
386
+ def make_contact_sheet(orig, variants, labels, path, height=768):
387
+ def prep(a):
388
+ h, w = a.shape[:2]
389
+ return cv2.resize(a, (int(w * height / h), height))
390
+ tiles = [prep(orig)] + [prep(v) for v in variants]
391
+ sheet = np.concatenate(tiles, axis=1)
392
+ sheet = to_uint8(sheet)
393
+ for i, lab in enumerate(["original"] + labels):
394
+ xoff = sum(t.shape[1] for t in tiles[:i]) + 12
395
+ cv2.putText(sheet, lab, (xoff, 34), cv2.FONT_HERSHEY_SIMPLEX, 0.9,
396
+ (0, 0, 0), 4, cv2.LINE_AA)
397
+ cv2.putText(sheet, lab, (xoff, 34), cv2.FONT_HERSHEY_SIMPLEX, 0.9,
398
+ (255, 255, 255), 2, cv2.LINE_AA)
399
+ Image.fromarray(sheet).save(path, quality=90)
400
+
401
+
402
+ def main():
403
+ ap = argparse.ArgumentParser()
404
+ ap.add_argument("--src", required=True)
405
+ ap.add_argument("--out", required=True)
406
+ ap.add_argument("--limit", type=int, default=0, help="0 = all")
407
+ ap.add_argument("--variants", type=int, default=2)
408
+ ap.add_argument("--seed", type=int, default=1234)
409
+ ap.add_argument("--sheets", default="", help="dir for review contact sheets")
410
+ ap.add_argument("--exclude", default="", help="comma-sep filenames to skip")
411
+ args = ap.parse_args()
412
+
413
+ exclude = {s.strip() for s in args.exclude.split(",") if s.strip()}
414
+ files = sorted(f for f in os.listdir(args.src)
415
+ if f.lower().endswith((".jpg", ".jpeg", ".png"))
416
+ and f not in exclude)
417
+ if args.limit:
418
+ rng0 = random.Random(args.seed)
419
+ files = sorted(rng0.sample(files, min(args.limit, len(files))))
420
+
421
+ ctrl_dir = os.path.join(args.out, "control")
422
+ tgt_dir = os.path.join(args.out, "target")
423
+ os.makedirs(ctrl_dir, exist_ok=True)
424
+ os.makedirs(tgt_dir, exist_ok=True)
425
+ if args.sheets:
426
+ os.makedirs(args.sheets, exist_ok=True)
427
+
428
+ families = [recipe_atmospheric, recipe_digital]
429
+
430
+ for idx, fname in enumerate(files):
431
+ stem = os.path.splitext(fname)[0]
432
+ src_path = os.path.join(args.src, fname)
433
+ x0 = load_rgb(src_path)
434
+ # mask computed lazily only if some variant will use it
435
+ mask = None
436
+ mask_tried = False
437
+ variants, labels = [], []
438
+
439
+ for v in range(args.variants):
440
+ rng = np.random.default_rng(seed_for(fname, v, args.seed))
441
+ pyrng = random.Random(seed_for(fname, v, args.seed) ^ 0xABCD)
442
+
443
+ class R: # tiny facade: uniform/choice/random/normal on one seed
444
+ uniform = staticmethod(pyrng.uniform)
445
+ choice = staticmethod(pyrng.choice)
446
+ random = staticmethod(pyrng.random)
447
+ normal = staticmethod(rng.normal)
448
+
449
+ sev = pyrng.uniform(0.25, 1.0)
450
+ fam = families[v % len(families)]
451
+ if not mask_tried:
452
+ mask = person_mask(src_path, x0.shape)
453
+ mask_tried = True
454
+ y, label = fam(x0.copy(), R, sev, mask)
455
+ label = f"{label} sev={sev:.2f}"
456
+
457
+ out_name = f"{stem}_v{v + 1}.jpg"
458
+ Image.fromarray(to_uint8(y)).save(
459
+ os.path.join(ctrl_dir, out_name), quality=95)
460
+ Image.fromarray(to_uint8(x0)).save(
461
+ os.path.join(tgt_dir, out_name), quality=97)
462
+ variants.append(y)
463
+ labels.append(f"v{v + 1} {label}")
464
+
465
+ if args.sheets:
466
+ make_contact_sheet(x0, variants, labels,
467
+ os.path.join(args.sheets, f"{stem}_sheet.jpg"))
468
+ print(f"[{idx + 1}/{len(files)}] {fname}: "
469
+ + " | ".join(labels), flush=True)
470
+
471
+
472
+ if __name__ == "__main__":
473
+ main()