vipsim 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vipsim/filters.py ADDED
@@ -0,0 +1,2522 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ VipSim image filters — Python reimplementation of VIP-Sim Unity shaders.
4
+
5
+ Simulates visual impairments on screenshots for VLM accessibility analysis.
6
+ Based on shader code from https://github.com/Max-Raed/VIP-Sim (CC BY 4.0).
7
+
8
+ Usage (installed: `vipsim ...` or `python -m vipsim ...`):
9
+ vipsim input.png output.png --filter blur --severity 0.5
10
+ vipsim input.png output.png --filter cvd --type deuteranomaly --severity 0.8
11
+ vipsim input.png output.png --filter bcg --brightness 0.8 --contrast 0.7 --gamma 1.5
12
+ vipsim input.png output.png --filter cataracts --severity 0.5
13
+ vipsim input.png output.png --preset moderate_low_vision
14
+ """
15
+
16
+ import argparse
17
+ import math
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+ from PIL import Image, ImageFilter, ImageOps
22
+
23
+
24
+ # =============================================================================
25
+ # Color Vision Deficiency (from myRecolour.cs)
26
+ # =============================================================================
27
+ # Exact transformation matrices from VIP-Sim, indexed by severity 0.0-1.0 (11 steps)
28
+
29
+ CVD_MATRICES = {
30
+ "protanomaly": np.array([
31
+ [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
32
+ [[0.856167, 0.182038, -0.038205], [0.029342, 0.955115, 0.015544], [-0.00288, -0.001563, 1.004443]],
33
+ [[0.734766, 0.334872, -0.069637], [0.05184, 0.919198, 0.028963], [-0.004928, -0.004209, 1.009137]],
34
+ [[0.630323, 0.465641, -0.095964], [0.069181, 0.890046, 0.040773], [-0.006308, -0.007724, 1.014032]],
35
+ [[0.539009, 0.579343, -0.118352], [0.082546, 0.866121, 0.051332], [-0.007136, -0.011959, 1.019095]],
36
+ [[0.458064, 0.679578, -0.137642], [0.092785, 0.846313, 0.060902], [-0.007494, -0.016807, 1.024301]],
37
+ [[0.38545, 0.769005, -0.154455], [0.100526, 0.829802, 0.069673], [-0.007442, -0.02219, 1.029632]],
38
+ [[0.319627, 0.849633, -0.169261], [0.106241, 0.815969, 0.07779], [-0.007025, -0.028051, 1.035076]],
39
+ [[0.259411, 0.923008, -0.18242], [0.110296, 0.80434, 0.085364], [-0.006276, -0.034346, 1.040622]],
40
+ [[0.203876, 0.990338, -0.194214], [0.112975, 0.794542, 0.092483], [-0.005222, -0.041043, 1.046265]],
41
+ [[0.152286, 1.052583, -0.204868], [0.114503, 0.786281, 0.099216], [-0.003882, -0.048116, 1.051998]],
42
+ ]),
43
+ "deuteranomaly": np.array([
44
+ [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
45
+ [[0.866435, 0.177704, -0.044139], [0.049567, 0.939063, 0.01137], [-0.003453, 0.007233, 0.99622]],
46
+ [[0.760729, 0.319078, -0.079807], [0.090568, 0.889315, 0.020117], [-0.006027, 0.013325, 0.992702]],
47
+ [[0.675425, 0.43385, -0.109275], [0.125303, 0.847755, 0.026942], [-0.00795, 0.018572, 0.989378]],
48
+ [[0.605511, 0.52856, -0.134071], [0.155318, 0.812366, 0.032316], [-0.009376, 0.023176, 0.9862]],
49
+ [[0.547494, 0.607765, -0.155259], [0.181692, 0.781742, 0.036566], [-0.01041, 0.027275, 0.983136]],
50
+ [[0.498864, 0.674741, -0.173604], [0.205199, 0.754872, 0.039929], [-0.011131, 0.030969, 0.980162]],
51
+ [[0.457771, 0.731899, -0.18967], [0.226409, 0.731012, 0.042579], [-0.011595, 0.034333, 0.977261]],
52
+ [[0.422823, 0.781057, -0.203881], [0.245752, 0.709602, 0.044646], [-0.011843, 0.037423, 0.974421]],
53
+ [[0.392952, 0.82361, -0.216562], [0.263559, 0.69021, 0.046232], [-0.01191, 0.040281, 0.97163]],
54
+ [[0.367322, 0.860646, -0.227968], [0.280085, 0.672501, 0.047413], [-0.01182, 0.04294, 0.968881]],
55
+ ]),
56
+ "tritanomaly": np.array([
57
+ [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
58
+ [[0.92667, 0.092514, -0.019184], [0.021191, 0.964503, 0.014306], [0.008437, 0.054813, 0.93675]],
59
+ [[0.89572, 0.13333, -0.02905], [0.029997, 0.9454, 0.024603], [0.013027, 0.104707, 0.882266]],
60
+ [[0.905871, 0.127791, -0.033662], [0.026856, 0.941251, 0.031893], [0.01341, 0.148296, 0.838294]],
61
+ [[0.948035, 0.08949, -0.037526], [0.014364, 0.946792, 0.038844], [0.010853, 0.193991, 0.795156]],
62
+ [[1.017277, 0.027029, -0.044306], [-0.006113, 0.958479, 0.047634], [0.006379, 0.248708, 0.744913]],
63
+ [[1.104996, -0.046633, -0.058363], [-0.032137, 0.971635, 0.060503], [0.001336, 0.317922, 0.680742]],
64
+ [[1.193214, -0.109812, -0.083402], [-0.058496, 0.97941, 0.079086], [-0.002346, 0.403492, 0.598854]],
65
+ [[1.257728, -0.139648, -0.118081], [-0.078003, 0.975409, 0.102594], [-0.003316, 0.501214, 0.502102]],
66
+ [[1.278864, -0.125333, -0.153531], [-0.084748, 0.957674, 0.127074], [-0.000989, 0.601151, 0.399838]],
67
+ [[1.255528, -0.076749, -0.178779], [-0.078411, 0.930809, 0.147602], [0.004733, 0.691367, 0.3039]],
68
+ ]),
69
+ }
70
+
71
+
72
+ def apply_cvd(img: Image.Image, cvd_type: str = "deuteranomaly", severity: float = 1.0) -> Image.Image:
73
+ """Apply color vision deficiency simulation.
74
+
75
+ Args:
76
+ img: Input PIL Image (RGB).
77
+ cvd_type: One of 'protanomaly', 'deuteranomaly', 'tritanomaly', 'monochrome'.
78
+ severity: 0.0 (no effect) to 1.0 (full effect).
79
+ """
80
+ severity = float(np.clip(severity, 0.0, 1.0))
81
+ if severity == 0.0:
82
+ return img.copy()
83
+
84
+ # VIP-Sim is a Linear-colour-space project, so myRecolour.shader applies
85
+ # the matrices to linear RGB (the space Machado et al. derive them in).
86
+ lin = _image_to_linear(img.convert("RGB"))
87
+
88
+ if cvd_type == "monochrome":
89
+ gray_weights = np.array([0.299, 0.587, 0.114])
90
+ gray = np.dot(lin, gray_weights)
91
+ gray_rgb = np.stack([gray] * 3, axis=-1)
92
+ result = lin * (1 - severity) + gray_rgb * severity
93
+ else:
94
+ matrices = CVD_MATRICES[cvd_type]
95
+ # Interpolate between matrix steps (same as VIP-Sim)
96
+ n = len(matrices)
97
+ idx = severity * (n - 1)
98
+ i0 = int(np.floor(idx))
99
+ i1 = min(i0 + 1, n - 1)
100
+ w = idx - i0
101
+ mat = matrices[i0] * (1 - w) + matrices[i1] * w # 3x3
102
+ result = np.dot(lin, mat.T)
103
+
104
+ return Image.fromarray(_to_uint8(_linear_to_srgb(result)))
105
+
106
+
107
+ # =============================================================================
108
+ # Gaussian Blur / Hyperopia (from myBlur.shader)
109
+ # =============================================================================
110
+
111
+ def _check_blur_radius(radius) -> None:
112
+ """Raise ValueError for exactly the radii Pillow 13's GaussianBlur
113
+ rejects: NaN, infinite, or 2**31 - 64 px or more either way. Pillow 12
114
+ segfaults on those radii, taking the whole interpreter down. The
115
+ messages are Pillow 13's ("radius must be >= 0", "radius too large"),
116
+ but beyond ~1.4e15 px Pillow 13 picks between them by float32 rounding,
117
+ so there only the exception type is guaranteed to match."""
118
+ r = float(radius)
119
+ if not np.isfinite(r):
120
+ raise ValueError("radius must be >= 0")
121
+ if abs(r) >= 2147483584.0:
122
+ raise ValueError("radius too large")
123
+
124
+
125
+ def apply_blur(img: Image.Image, severity: float = 0.5) -> Image.Image:
126
+ """Apply Gaussian blur to simulate reduced visual acuity (hyperopia).
127
+
128
+ Args:
129
+ img: Input PIL Image.
130
+ severity: 0.0 (no blur) to 1.0 (heavy blur). Maps to blur radius 0-15px.
131
+ """
132
+ severity = float(np.clip(severity, 0.0, 1.0))
133
+ if severity == 0.0:
134
+ return img.copy()
135
+ radius = severity * 15.0
136
+ _check_blur_radius(radius) # NaN severity survives np.clip
137
+ return img.filter(ImageFilter.GaussianBlur(radius=radius))
138
+
139
+
140
+ # =============================================================================
141
+ # sRGB <-> linear helpers
142
+ # =============================================================================
143
+ # Unity renders shaders in linear color space when the project is set to
144
+ # Linear (the default for VIP-Sim). All shader colour math therefore happens
145
+ # on linear values, and the framebuffer is sRGB-encoded on write. To match
146
+ # Unity output we mirror that round-trip here.
147
+
148
+ def _srgb_to_linear(x: np.ndarray) -> np.ndarray:
149
+ return np.where(x <= 0.04045, x / 12.92, ((x + 0.055) / 1.055) ** 2.4)
150
+
151
+
152
+ # An 8-bit image has only 256 levels, so decode each level once and index the
153
+ # table with the image: table[levels] holds exactly the values of
154
+ # _srgb_to_linear(levels.astype(dtype) / 255.0), element for element, without
155
+ # a per-pixel power.
156
+ _SRGB_TO_LINEAR_LUT = {
157
+ np.dtype(dt): _srgb_to_linear(np.arange(256, dtype=dt) / 255.0)
158
+ for dt in (np.float32, np.float64)
159
+ }
160
+
161
+
162
+ def _image_to_linear(img, dtype=np.float32) -> np.ndarray:
163
+ """_srgb_to_linear(np.asarray(img, dtype) / 255.0), by table for 8-bit data."""
164
+ levels = np.asarray(img)
165
+ if levels.dtype != np.uint8: # 1-bit, 16-bit, 32-bit and float modes
166
+ return _srgb_to_linear(levels.astype(dtype) / 255.0)
167
+ return _SRGB_TO_LINEAR_LUT[np.dtype(dtype)][levels]
168
+
169
+
170
+ def _linear_to_srgb(x: np.ndarray) -> np.ndarray:
171
+ x = np.clip(x, 0.0, 1.0)
172
+ # np.where(x <= 0.0031308, x * 12.92, 1.055 * x ** (1 / 2.4) - 0.055),
173
+ # evaluated in place: the same arithmetic per element, fewer temporaries.
174
+ out = np.power(x, 1.0 / 2.4)
175
+ if type(out) is not np.ndarray: # scalar, 0-d input or ndarray subclass
176
+ return np.where(x <= 0.0031308, x * 12.92, 1.055 * out - 0.055)
177
+ out *= 1.055
178
+ out -= 0.055
179
+ np.multiply(x, 12.92, out=out, where=x <= 0.0031308)
180
+ return out
181
+
182
+
183
+ def _to_uint8(x: np.ndarray, scale: float = 255.0) -> np.ndarray:
184
+ """Quantise to uint8 by rounding; `scale` maps the input range to 0..255.
185
+
186
+ Truncating with astype(np.uint8) instead biases every output ~0.5 LSB
187
+ darker, and the bias accumulates when filters are chained.
188
+ """
189
+ y = np.multiply(x, scale)
190
+ if not (type(y) is np.ndarray and y.dtype.kind == "f"):
191
+ return np.clip(np.rint(np.asarray(x) * scale), 0, 255).astype(np.uint8)
192
+ np.rint(y, out=y)
193
+ np.clip(y, 0, 255, out=y)
194
+ return y.astype(np.uint8)
195
+
196
+
197
+ # =============================================================================
198
+ # Brightness / Contrast / Gamma (from myBrightnessContrastGamma.shader)
199
+ # =============================================================================
200
+
201
+ def apply_bcg(img: Image.Image, brightness: float = 1.0, contrast: float = 1.0, gamma: float = 1.0,
202
+ saturation: float = 1.0,
203
+ color_space: str = "linear") -> Image.Image:
204
+ """Apply brightness, contrast, gamma and (extension) saturation.
205
+
206
+ Matches the VIP-Sim shader formula (executed in linear colour space, as
207
+ Unity does when the project Color Space is Linear):
208
+ color *= brightness
209
+ color = (color - 0.5) * contrast + 0.5
210
+ color = pow(color, gamma)
211
+
212
+ Args:
213
+ img: Input PIL Image; converted to RGB like every other filter input
214
+ (alpha is dropped, not tone-mapped).
215
+ brightness: Multiplier (1.0 = no change, <1 darker, >1 brighter).
216
+ contrast: Multiplier (1.0 = no change, <1 less contrast, >1 more contrast).
217
+ gamma: Exponent (1.0 = no change, >1 darker midtones, <1 lighter midtones).
218
+ saturation: 1.0 = no change, 0.0 = full greyscale, >1 more vivid.
219
+ NOT in the Unity shader (extension for profile matching): blends
220
+ each pixel toward its luminance, so colors drain without touching
221
+ tone/contrast. Applied after the b/c/g chain, in the same space.
222
+ color_space: "linear" (default, faithful to a Linear-space Unity
223
+ project) or "srgb" — run the same formula directly on the encoded
224
+ sRGB values, as Unity would in a Gamma-space project. In sRGB
225
+ space the identical contrast value desaturates far more
226
+ aggressively and treats highlights differently; kept as an option
227
+ because the original VIP-Sim project's colour-space setting is
228
+ unconfirmed.
229
+ """
230
+ rgb = img.convert("RGB")
231
+ if color_space == "srgb":
232
+ arr = np.asarray(rgb, dtype=np.float32) / 255.0
233
+ else:
234
+ arr = _image_to_linear(rgb)
235
+
236
+ # Brightness
237
+ arr = arr * brightness
238
+ # Contrast
239
+ arr = (arr - 0.5) * contrast + 0.5
240
+ # Clamp before gamma to avoid NaN from negative values
241
+ arr = np.clip(arr, 0.0, 1.0)
242
+ # Gamma
243
+ if gamma != 1.0:
244
+ arr = np.power(arr, gamma)
245
+
246
+ arr = np.clip(arr, 0.0, 1.0)
247
+ # Saturation (extension): lerp toward per-pixel luminance
248
+ if saturation != 1.0:
249
+ lum = (arr @ np.array([0.2126, 0.7152, 0.0722],
250
+ dtype=np.float32))[..., None]
251
+ arr = np.clip(lum + (arr - lum) * saturation, 0.0, 1.0)
252
+ out = arr if color_space == "srgb" else _linear_to_srgb(arr)
253
+ return Image.fromarray(_to_uint8(out))
254
+
255
+
256
+ # =============================================================================
257
+ # Cataracts — faithful port of myCataract.cs + cfxFrost.shader
258
+ # =============================================================================
259
+ # Pipeline:
260
+ # 1. Per-channel BCG with brightness=(1-severity), contrast=(1-severity),
261
+ # coeffs=(0.7, 0.7, 0.4), gamma=1/Gamma.
262
+ # Per-channel coeff acts as the contrast pivot — blue pivots toward 0.4
263
+ # (yellow lens tint) while R/G pivot toward 0.7.
264
+ # 2. Frost: per-pixel UV displacement driven by a tiled Perlin noise texture,
265
+ # scaled by 10*severity (matches `_Scale` in the shader).
266
+
267
+ _PERLIN_NOISE_CACHE: dict = {}
268
+
269
+
270
+ def _perlin_noise_2d(size: int = 256, freq: float = 20.0, seed: int = 0) -> np.ndarray:
271
+ """Perlin-style smooth noise tile in [0, 1], shape (size, size).
272
+
273
+ Mirrors `myCataract.GenerateNoiseTexture`: sample at coord/size * 20.
274
+ Implementation uses value-noise with smoothstep interpolation — visually
275
+ equivalent to Unity's Mathf.PerlinNoise for tile use.
276
+ """
277
+ key = (size, freq, seed)
278
+ if key in _PERLIN_NOISE_CACHE:
279
+ return _PERLIN_NOISE_CACHE[key]
280
+
281
+ rng = np.random.default_rng(seed)
282
+ grid_n = int(np.ceil(freq)) + 2
283
+ lattice = rng.random((grid_n, grid_n)).astype(np.float32)
284
+
285
+ # Sample lattice with smoothstep interpolation
286
+ coords = np.linspace(0, freq, size, endpoint=False, dtype=np.float32)
287
+ xs, ys = np.meshgrid(coords, coords, indexing="xy")
288
+ x0 = np.floor(xs).astype(np.int32)
289
+ y0 = np.floor(ys).astype(np.int32)
290
+ fx = xs - x0
291
+ fy = ys - y0
292
+ # Smoothstep
293
+ sx = fx * fx * (3.0 - 2.0 * fx)
294
+ sy = fy * fy * (3.0 - 2.0 * fy)
295
+
296
+ v00 = lattice[y0, x0]
297
+ v10 = lattice[y0, x0 + 1]
298
+ v01 = lattice[y0 + 1, x0]
299
+ v11 = lattice[y0 + 1, x0 + 1]
300
+ a = v00 * (1 - sx) + v10 * sx
301
+ b = v01 * (1 - sx) + v11 * sx
302
+ out = a * (1 - sy) + b * sy
303
+ out = np.clip(out, 0.0, 1.0).astype(np.float32)
304
+
305
+ _PERLIN_NOISE_CACHE[key] = out
306
+ return out
307
+
308
+
309
+ def _apply_bcg_per_channel(lin: np.ndarray, brightness: float, contrast: float,
310
+ coeffs=(0.5, 0.5, 0.5), gamma: float = 1.0) -> np.ndarray:
311
+ """BCG with per-channel contrast pivot — matches myBrightnessContrastGamma.shader.
312
+
313
+ Input is linear RGB in [0,1] (see _image_to_linear), output is sRGB in
314
+ [0,1]; the shader math runs in linear space to mirror Unity's Linear
315
+ colour-space rendering.
316
+
317
+ Formula (per-channel, linear):
318
+ color *= brightness
319
+ color = (color - coeff) * contrast + coeff
320
+ color = clamp(0, 1)
321
+ color = pow(color, gamma)
322
+ """
323
+ coeffs_arr = np.array(coeffs, dtype=np.float32).reshape(1, 1, 3)
324
+ out = lin * brightness
325
+ out = (out - coeffs_arr) * contrast + coeffs_arr
326
+ out = np.clip(out, 0.0, 1.0)
327
+ if gamma != 1.0:
328
+ out = np.power(out, gamma)
329
+ return _linear_to_srgb(np.clip(out, 0.0, 1.0))
330
+
331
+
332
+ def apply_cataracts(img: Image.Image, severity: float = 0.5,
333
+ use_brightness: bool = True, use_contrast: bool = True,
334
+ use_frosting: bool = True,
335
+ contrast_coeff=(0.7, 0.7, 0.4),
336
+ gamma: float = 1.0) -> Image.Image:
337
+ """Faithful port of VIP-Sim cataract effect.
338
+
339
+ Source: `Assets/VisualEffects/Scripts/myCataract.cs` +
340
+ `Assets/VisualEffects/Shaders/cfxFrost.shader` (CC BY 4.0).
341
+
342
+ Args:
343
+ img: Input PIL Image (RGB).
344
+ severity: 0.0 (no effect) to 1.0 (heavy cataracts).
345
+ use_brightness: Apply brightness reduction (Unity default: True).
346
+ use_contrast: Apply contrast reduction (Unity default: True).
347
+ use_frosting: Apply Perlin-noise UV-displacement frost (default: True).
348
+ contrast_coeff: Per-channel contrast pivot (default (0.7, 0.7, 0.4) —
349
+ blue pivots low, producing the yellow lens tint).
350
+ gamma: Optional gamma. Shader uses 1/gamma; we mirror that.
351
+ """
352
+ severity = float(np.clip(severity, 0.0, 1.0))
353
+ if severity == 0.0:
354
+ return img.copy()
355
+
356
+ lin = _image_to_linear(img.convert("RGB"))
357
+ h, w = lin.shape[:2]
358
+
359
+ # 1. Per-channel BCG (brightness/contrast both shrink linearly with severity)
360
+ brightness = (1.0 - severity) if use_brightness else 1.0
361
+ contrast = (1.0 - severity) if use_contrast else 1.0
362
+ arr = _apply_bcg_per_channel(lin, brightness, contrast,
363
+ coeffs=contrast_coeff, gamma=1.0 / gamma)
364
+
365
+ # 2. Frost via Perlin-noise UV displacement
366
+ if use_frosting:
367
+ scale = 10.0 * severity # mirrors `_Scale = 10f * severityIndex`
368
+ # Build a noise field at output resolution (sample noise at uv * 20)
369
+ noise_tile = _perlin_noise_2d(size=256)
370
+ # Per-pixel uv → noise sample (scaled coords mod tile size)
371
+ ys = np.arange(h, dtype=np.float32) / max(h - 1, 1)
372
+ xs = np.arange(w, dtype=np.float32) / max(w - 1, 1)
373
+ uy, ux = np.meshgrid(ys, xs, indexing="ij")
374
+ nx = ((ux * 20.0) * 256.0).astype(np.int32) % 256
375
+ ny = ((uy * 20.0) * 256.0).astype(np.int32) % 256
376
+ n = noise_tile[ny, nx]
377
+ # Same fractional offsets as the shader
378
+ dx = -0.005 + (n - 0.008 * np.floor(n / 0.008))
379
+ dy = -0.006 + (n - 0.010 * np.floor(n / 0.010))
380
+ # Source UVs (clamped)
381
+ src_u = np.clip(ux + dx * scale, 0.0, 1.0)
382
+ src_v = np.clip(uy + dy * scale, 0.0, 1.0)
383
+ sx = (src_u * (w - 1)).astype(np.int32)
384
+ sy = (src_v * (h - 1)).astype(np.int32)
385
+ arr = arr[sy, sx]
386
+
387
+ out = _to_uint8(arr)
388
+ return Image.fromarray(out)
389
+
390
+
391
+ # =============================================================================
392
+ # Bloom — faithful port of myBloom.cs + myBloom.shader
393
+ # =============================================================================
394
+ # Pipeline:
395
+ # 1. Downsample by 4 with 4-tap box average.
396
+ # 2. Threshold-extract: max(downsampled - threshold, 0) * intensity.
397
+ # 3. Separable Gaussian blur (7-tap kernel from shader) horizontal+vertical,
398
+ # repeated `iterations` times with growing radius.
399
+ # 4. Upsample to source resolution and add to original.
400
+
401
+ # Gaussian-ish weights used in myBloom.shader.fragBlur8
402
+ _BLOOM_KERNEL = np.array([0.0205, 0.0855, 0.232, 0.324, 0.232, 0.0855, 0.0205],
403
+ dtype=np.float32)
404
+
405
+
406
+ def _separable_gaussian(arr: np.ndarray, radius_px: float) -> np.ndarray:
407
+ """Separable Gaussian-like blur using the VIP-Sim 7-tap weight curve.
408
+
409
+ The shader spaces 7 taps `radius_px` apart in each direction, so
410
+ larger radius = wider blur. We approximate by scaling the kernel via
411
+ PIL's GaussianBlur, which is mathematically equivalent for our purposes
412
+ (separable, normalized, monotonic in radius).
413
+ """
414
+ if radius_px <= 0:
415
+ return arr
416
+ _check_blur_radius(radius_px)
417
+ pil = Image.fromarray(_to_uint8(arr))
418
+ blurred = pil.filter(ImageFilter.GaussianBlur(radius=float(radius_px)))
419
+ return np.asarray(blurred, dtype=np.float32) / 255.0
420
+
421
+
422
+ def apply_bloom(img: Image.Image, severity: float = 0.5,
423
+ intensity: float = None, threshold: float = None,
424
+ blur_size: float = None, iterations: int = 1) -> Image.Image:
425
+ """Faithful port of VIP-Sim bloom effect (light scatter / glare / photophobia).
426
+
427
+ Source: `myBloom.cs` + `myBloom.shader` (CC BY 4.0).
428
+
429
+ If `severity` is given (0..1), it sets sensible defaults:
430
+ intensity = 0.25 + severity * 2.25 (Unity range 0.0–2.5)
431
+ threshold = max(0.05, 0.5 - severity * 0.45) (lower → more sources)
432
+ blur_size = 1.0 + severity * 4.5 (Unity range 0.25–5.5)
433
+
434
+ Explicit kwargs override the severity-derived defaults.
435
+ """
436
+ severity = float(np.clip(severity, 0.0, 1.0))
437
+ if severity == 0.0 and intensity is None and threshold is None and blur_size is None:
438
+ return img.copy()
439
+
440
+ # NOTE: Unity defaults (intensity up to 2.5, low threshold) were tuned for
441
+ # game scenes with sparse bright sources. Web pages are mostly bright
442
+ # background so we use much gentler defaults — only near-white pixels
443
+ # bloom, and the composite is moderate. Override via kwargs for a truly
444
+ # faithful Unity-style bloom (e.g. on dark UI screenshots).
445
+ if intensity is None:
446
+ intensity = 0.1 + severity * 0.6 # 0.1 → 0.7 across severity range
447
+ if threshold is None:
448
+ threshold = max(0.6, 0.95 - severity * 0.3) # 0.95 → 0.65
449
+ if blur_size is None:
450
+ blur_size = 1.0 + severity * 4.5
451
+
452
+ # Unity renders bloom in linear colour space (sRGB framebuffer write).
453
+ arr = _image_to_linear(img.convert("RGB"))
454
+ h, w = arr.shape[:2]
455
+
456
+ # 1. Downsample by 4 (Resolution.Low → divider=4, widthMod=0.5)
457
+ divider = 4
458
+ width_mod = 0.5
459
+ rt_w, rt_h = max(1, w // divider), max(1, h // divider)
460
+ down = np.asarray(
461
+ Image.fromarray(_to_uint8(arr)).resize((rt_w, rt_h), Image.BILINEAR),
462
+ dtype=np.float32,
463
+ ) / 255.0
464
+
465
+ # 2. Threshold-extract: max(color - threshold, 0) * intensity
466
+ bright = np.clip(down - threshold, 0.0, None) * intensity
467
+
468
+ # 3. Separable Gaussian, repeated with growing radius (matches C# loop)
469
+ for i in range(iterations):
470
+ radius = blur_size * width_mod + i * 1.0
471
+ # Single Gaussian call covers vertical+horizontal separably
472
+ bright = _separable_gaussian(bright, radius_px=radius * 1.5)
473
+
474
+ # 4. Upsample bloom and additive composite (fragBloom: color + bloom)
475
+ bloom_full = np.asarray(
476
+ Image.fromarray(_to_uint8(bright)).resize(
477
+ (w, h), Image.BILINEAR
478
+ ),
479
+ dtype=np.float32,
480
+ ) / 255.0
481
+ out_lin = np.clip(arr + bloom_full, 0.0, 1.0)
482
+ out = _linear_to_srgb(out_lin)
483
+ return Image.fromarray(_to_uint8(out))
484
+
485
+
486
+ # =============================================================================
487
+ # Field Loss — port of myFieldLoss.cs + myFieldLoss.shader
488
+ # =============================================================================
489
+ # Original samples a "degradation" texture (e.g. macular-degeneration.png),
490
+ # converts grayscale to a weight w = 1 - gray, and uses w * MaxLODlevel to
491
+ # pick a mip level — high w (dark mask region) = heavy blur, low w = sharp.
492
+ #
493
+ # We don't have the asset texture, so we substitute a generic radial mask
494
+ # (documented Phase-1 deviation). `field_type`:
495
+ # - "central" → blurred center, sharp periphery (macular-degeneration shape)
496
+ # - "peripheral" → sharp center, blurred periphery (tunnel-vision shape)
497
+
498
+ def _build_mip_pyramid(arr: np.ndarray, levels: int) -> list:
499
+ """Return `levels` progressively-blurred versions of `arr` (level 0 = sharp).
500
+
501
+ Approximates GPU mipmap chain — radius doubles per level, matching how
502
+ each mip level halves resolution.
503
+ """
504
+ return [_mip_level(arr, lvl) for lvl in range(levels)]
505
+
506
+
507
+ def _mip_level(arr: np.ndarray, lvl: int) -> np.ndarray:
508
+ """Level `lvl` of _build_mip_pyramid(arr, ...), built on its own."""
509
+ if lvl == 0:
510
+ return arr
511
+ return _separable_gaussian(arr, radius_px=float(2 ** (lvl - 1)))
512
+
513
+
514
+ def _blend_mip_levels(arr: np.ndarray, bias: np.ndarray, n_levels: int) -> np.ndarray:
515
+ """Per-pixel lerp between mip levels floor(bias) and floor(bias) + 1.
516
+
517
+ The upper level is clamped to n_levels - 1. Evaluates the same expression
518
+ on the same values as stacking _build_mip_pyramid and fancy-indexing it,
519
+ one floor(bias) value at a time, so the n_levels x frame stack is never
520
+ built. Levels no pixel samples are never blurred, and where every frac of
521
+ a group is 0 the upper level's weight is exactly 0, so the lower level
522
+ stands in for it. Level indices follow the stack's indexing too: a
523
+ negative floor(bias) counts from the top level, and one outside
524
+ [-n_levels, n_levels) raises IndexError (a NaN bias casts to -2**31).
525
+ """
526
+ lo = np.floor(bias).astype(np.int32)
527
+ if lo.size and (lo.min() < -n_levels or lo.max() >= n_levels):
528
+ bad = lo.min() if lo.min() < -n_levels else lo.max()
529
+ raise IndexError(f"index {bad} is out of bounds for axis 0 "
530
+ f"with size {n_levels}")
531
+ frac = bias - lo # float32 - int32 promotes to float64
532
+ out = np.empty(arr.shape, dtype=np.result_type(arr, frac))
533
+ built = {}
534
+
535
+ def level(i):
536
+ if i not in built:
537
+ built[i] = _mip_level(arr, i)
538
+ return built[i]
539
+
540
+ for i in np.unique(lo).tolist():
541
+ sel = lo == i
542
+ f = frac[sel][:, None]
543
+ lower = level(i % n_levels)[sel]
544
+ upper = (level(min(max(i + 1, 0), n_levels - 1))[sel] if f.any()
545
+ else lower)
546
+ out[sel] = lower * (1 - f) + upper * f
547
+ del built[i % n_levels] # lower levels are not sampled again
548
+ return out
549
+
550
+
551
+ def _radial_weight_mask(h: int, w: int, kind: str = "central",
552
+ center=(0.5, 0.5), radius: float = 0.45,
553
+ softness: float = 0.25) -> np.ndarray:
554
+ """Generate a radial weight mask in [0, 1] used in place of the missing
555
+ `macular-degeneration.png` overlay.
556
+
557
+ Returns w where 1.0 = max blur, 0.0 = sharp.
558
+ """
559
+ cy, cx = center
560
+ ys = (np.arange(h, dtype=np.float32) / max(h - 1, 1) - cy)
561
+ xs = (np.arange(w, dtype=np.float32) / max(w - 1, 1) - cx)
562
+ yy, xx = np.meshgrid(ys, xs, indexing="ij")
563
+ dist = np.sqrt(xx * xx + yy * yy)
564
+
565
+ # smoothstep edge
566
+ inner = max(0.0, radius - softness)
567
+ outer = radius
568
+ t = np.clip((dist - inner) / max(outer - inner, 1e-6), 0.0, 1.0)
569
+ s = t * t * (3.0 - 2.0 * t) # smoothstep
570
+
571
+ if kind == "central":
572
+ # Blur in the center (mask high in center, low at edges)
573
+ w = 1.0 - s
574
+ elif kind == "peripheral":
575
+ # Blur at the periphery (mask low in center, high at edges)
576
+ w = s
577
+ else:
578
+ raise ValueError(f"Unknown field_type: {kind}")
579
+ return w.astype(np.float32)
580
+
581
+
582
+ # Path to the VIP-Sim macular-degeneration overlay (copied from
583
+ # VIP-Sim/windows/Assets/Resources/macular-degeneration.jpg), shipped as
584
+ # package data. Used as the faithful overlay for apply_field_loss; falls back
585
+ # to the radial mask if the asset is missing.
586
+ _MACULAR_OVERLAY_PATH = (
587
+ Path(__file__).parent / "assets" / "macular-degeneration.jpg"
588
+ )
589
+ _MACULAR_OVERLAY_CACHE: dict = {}
590
+
591
+
592
+ def _load_macular_overlay(h: int, w: int, field_type: str = "central",
593
+ overlay_scale: float = 1.0,
594
+ center=(0.5, 0.5)) -> "np.ndarray | None":
595
+ """Load the VIP-Sim macular-degeneration overlay, sampled like the shader.
596
+
597
+ Mirrors `myFieldLoss.shader`:
598
+ scaledUV = (uv - 0.5) / overlay_scale + (mousePos - 0.5)/overlay_scale + 0.5
599
+ scaledUV = clamp(scaledUV, 0.0, 1.0) # IMPORTANT: clamp, not zero-pad
600
+ degradation = tex2D(_Overlay, scaledUV)
601
+ w = 1 - luminance(degradation) # dark JPG region → high blur
602
+
603
+ Clamping (vs Python's old zero-pad) is the bugfix: pixels outside the
604
+ scaled overlay get the *edge* sample, which for the macular-degeneration
605
+ JPG is bright (no blur), so the periphery is left sharp.
606
+
607
+ Returns a per-pixel weight mask in [0, 1] (1 = max blur, 0 = sharp), or
608
+ None if the asset isn't on disk.
609
+ """
610
+ if not _MACULAR_OVERLAY_PATH.is_file():
611
+ return None
612
+ cache_key = (h, w, field_type, round(overlay_scale, 4),
613
+ round(center[0], 4), round(center[1], 4))
614
+ if cache_key in _MACULAR_OVERLAY_CACHE:
615
+ return _MACULAR_OVERLAY_CACHE[cache_key]
616
+
617
+ overlay = Image.open(_MACULAR_OVERLAY_PATH).convert("L")
618
+ o_w, o_h = overlay.size
619
+ overlay_arr = np.asarray(overlay, dtype=np.float32) / 255.0
620
+
621
+ cy, cx = center
622
+ ys = (np.arange(h, dtype=np.float32) + 0.5) / h
623
+ xs = (np.arange(w, dtype=np.float32) + 0.5) / w
624
+ yy, xx = np.meshgrid(ys, xs, indexing="ij")
625
+
626
+ # Shader transform: sample point = (uv - 0.5)/scale + 0.5, with mouse
627
+ # offset absorbed (mouse=center → no shift).
628
+ scaled_u = (xx - 0.5) / max(overlay_scale, 1e-6) + cx
629
+ scaled_v = (yy - 0.5) / max(overlay_scale, 1e-6) + cy
630
+ scaled_u = np.clip(scaled_u, 0.0, 1.0)
631
+ scaled_v = np.clip(scaled_v, 0.0, 1.0)
632
+ su = np.clip((scaled_u * (o_w - 1)).astype(np.int32), 0, o_w - 1)
633
+ sv = np.clip((scaled_v * (o_h - 1)).astype(np.int32), 0, o_h - 1)
634
+ sampled = overlay_arr[sv, su]
635
+
636
+ w_central = 1.0 - sampled # dark overlay = high blur weight
637
+ if field_type == "central":
638
+ result = w_central
639
+ elif field_type == "peripheral":
640
+ result = 1.0 - w_central
641
+ else:
642
+ raise ValueError(f"Unknown field_type: {field_type}")
643
+
644
+ result = np.clip(result, 0.0, 1.0).astype(np.float32)
645
+ _MACULAR_OVERLAY_CACHE[cache_key] = result
646
+ return result
647
+
648
+
649
+ def apply_field_loss(img: Image.Image, severity: float = 0.5,
650
+ field_type: str = "central",
651
+ overlay_scale: float = 0.75,
652
+ use_overlay_asset: bool = True,
653
+ mask_center=None,
654
+ mask_radius: float = None) -> Image.Image:
655
+ """Field-loss simulation via per-pixel mip-level selection.
656
+
657
+ Source: `myFieldLoss.cs` + `myFieldLoss.shader` (CC BY 4.0).
658
+ Uses the real VIP-Sim `macular-degeneration.jpg` overlay if available
659
+ in the package's `assets/`; otherwise falls back to a generic radial mask.
660
+
661
+ Args:
662
+ img: Input PIL Image (RGB).
663
+ severity: 0.0 (no effect) to 1.0 (max blur in masked region).
664
+ field_type: "central" (macular-style, blurred center) or
665
+ "peripheral" (tunnel-vision-style, blurred periphery).
666
+ overlay_scale: Mirrors Unity `_OverlayScale` — larger = overlay covers
667
+ a larger fraction of the image.
668
+ use_overlay_asset: If True, try to load the VIP-Sim overlay JPG
669
+ (`vipsim/assets/macular-degeneration.jpg`); falls back to a
670
+ radial mask if missing. Set False to force the radial mask.
671
+ mask_center: Optional (cy, cx) in normalised coords. When given, the
672
+ radial mask is used (overlay asset skipped) and centred there —
673
+ lets the blur region follow a repositioned scotoma. Default None
674
+ keeps the original behaviour exactly.
675
+ mask_radius: Optional direct radius of the radial mask (fraction of
676
+ image, ~0.05..0.7). Overrides the 0.45/overlay_scale mapping;
677
+ None keeps the original behaviour.
678
+ """
679
+ severity = float(np.clip(severity, 0.0, 1.0))
680
+ if severity == 0.0:
681
+ return img.copy()
682
+
683
+ arr = np.asarray(img.convert("RGB"), dtype=np.float32) / 255.0
684
+ h, w = arr.shape[:2]
685
+
686
+ # numLevelsOfBlur in Unity = 1 + 1 + floor(ln(max(W,H))). For 2048×1024
687
+ # this is 1+1+floor(ln 2048)=1+1+7=9. Match that so max blur is comparable.
688
+ n_levels = int(2 + np.floor(np.log(max(h, w))))
689
+
690
+ # Try the real Unity overlay first; fall back to a radial mask
691
+ mask = None
692
+ if use_overlay_asset and mask_center is None:
693
+ mask = _load_macular_overlay(h, w, field_type=field_type,
694
+ overlay_scale=overlay_scale)
695
+ if mask is None:
696
+ # Fallback: generic radial mask (Phase-1 substitute)
697
+ r = (mask_radius if mask_radius is not None
698
+ else 0.45 / max(overlay_scale, 0.1))
699
+ mask = _radial_weight_mask(h, w, kind=field_type,
700
+ center=mask_center or (0.5, 0.5),
701
+ radius=min(r, 0.7))
702
+ # Severity scales the maximum mip level effectively reached
703
+ w_scaled = mask * severity
704
+
705
+ # Bias = w * (n_levels - 1): per-pixel float "level"
706
+ bias = w_scaled * (n_levels - 1)
707
+ if bias.shape == (h, w):
708
+ out = _blend_mip_levels(arr, bias, n_levels)
709
+ else: # array mask kwargs broadcast bias to another shape: as before
710
+ lo = np.floor(bias).astype(np.int32)
711
+ hi = np.clip(lo + 1, 0, n_levels - 1)
712
+ frac = (bias - lo)[..., None]
713
+ stack = np.stack(_build_mip_pyramid(arr, n_levels), axis=0)
714
+ iy, ix = np.indices((h, w))
715
+ out = (stack[lo, iy, ix] * (1 - frac) + stack[hi, iy, ix] * frac)
716
+
717
+ return Image.fromarray(_to_uint8(out))
718
+
719
+
720
+ # =============================================================================
721
+ # Distortion — faithful port of myDistortionMap.cs + myDistortionMap.shader
722
+ # =============================================================================
723
+ # Original precomputes a 2D warp field from N "warp centers", each defined by
724
+ # (x, y, radius, magnitude). At runtime each pixel samples vx/vy and shifts.
725
+ # We compute the warp field in numpy and apply directly via nearest-neighbor
726
+ # resample, mirroring the shader's UV displacement.
727
+
728
+ def _compute_warp_field(h: int, w: int,
729
+ warp_x, warp_y, warp_radius, warp_magn) -> tuple:
730
+ """Reproduces myDistortionMap.compute() and the shader's UV-offset math.
731
+
732
+ Returns (vx, vy) arrays of shape (h, w) clipped to [-0.1, 0.1].
733
+ """
734
+ SQRT2 = float(np.sqrt(2.0))
735
+ vx = np.zeros((h, w), dtype=np.float32)
736
+ vy = np.zeros((h, w), dtype=np.float32)
737
+ # Use width as the divisor for both axes — matches the original code
738
+ # (which uses width_px for both, leading to non-square scaling that we
739
+ # preserve for fidelity).
740
+ ys = np.arange(h, dtype=np.float32) / float(w)
741
+ xs = np.arange(w, dtype=np.float32) / float(w)
742
+ xx, yy = np.meshgrid(xs, ys, indexing="xy")
743
+
744
+ for i in range(len(warp_x)):
745
+ magn = warp_magn[i]
746
+ if magn == 0:
747
+ continue
748
+ radius = warp_radius[i]
749
+ if radius <= 0:
750
+ continue
751
+ ox = xx - warp_x[i]
752
+ oy = yy - warp_y[i]
753
+ dist = np.sqrt(ox * ox + oy * oy)
754
+ percent = 1.0 - ((radius - dist) / radius) * magn / SQRT2
755
+ if magn < 0:
756
+ percent = np.maximum(1.0, percent)
757
+ else:
758
+ percent = np.minimum(1.0, percent)
759
+ vx += ox - ox * percent
760
+ vy += oy - oy * percent
761
+
762
+ vx = np.clip(vx, -0.1, 0.1)
763
+ vy = np.clip(vy, -0.1, 0.1)
764
+ return vx, vy
765
+
766
+
767
+ def apply_distortion(img: Image.Image, severity: float = 0.5,
768
+ warp_centers=None) -> Image.Image:
769
+ """Radial UV distortion (e.g. metamorphopsia from macular damage).
770
+
771
+ Source: `myDistortionMap.cs` + `myDistortionMap.shader` (CC BY 4.0).
772
+ Gaze-contingent in Unity; we use fixed centers (Phase-1 deviation).
773
+
774
+ Args:
775
+ img: Input PIL Image (RGB).
776
+ severity: 0..1. Scales the magnitude of all warp centers.
777
+ warp_centers: Optional list of (x, y, radius, magnitude) tuples in
778
+ normalized [0,1] coords. Defaults to the two centers used in
779
+ Unity's `myDistortionMap` initial config.
780
+ """
781
+ severity = float(np.clip(severity, 0.0, 1.0))
782
+ if severity == 0.0:
783
+ return img.copy()
784
+
785
+ if warp_centers is None:
786
+ # Defaults from myDistortionMap.cs warp_x/warp_y/warp_radius/warp_magn.
787
+ # These coords live in 512×512 warp-texture space (uv ∈ [0,1]).
788
+ warp_centers = [
789
+ (0.0, 0.0, 0.15, 1.0),
790
+ (0.33, 0.66, 0.15, -1.0),
791
+ ]
792
+
793
+ wx = [c[0] for c in warp_centers]
794
+ wy = [c[1] for c in warp_centers]
795
+ wr = [c[2] for c in warp_centers]
796
+ wm = [c[3] * severity for c in warp_centers]
797
+
798
+ arr = np.asarray(img.convert("RGB"), dtype=np.float32)
799
+ h, w = arr.shape[:2]
800
+
801
+ # Bug-faithful port of myDistortionMap.shader. Four things to reproduce:
802
+ #
803
+ # 1) Y-flip in texture encoding (myDistortionMap.cs:180 stores `vy[x,H-1-y]`).
804
+ # So warpY sampled at UV (s', t') = vy_field at (s', 1-t').
805
+ #
806
+ # 2) Unity D3D-Blit UV convention: i.uv.y = 0 at bottom of screen, 1 at top.
807
+ # Our numpy uy = 0 at top of array. So Unity's `i.uv.y = 1 - uy_python`.
808
+ # This means:
809
+ # - For vx eval (no Y flip in encoding): degcoords.y = 0.75 - 0.5*uy
810
+ # - For vy eval (Y flip in encoding): degcoords.y = 0.25 + 0.5*uy
811
+ # And the sampling Y direction is inverted: src_uy_python = uy − yOffset.
812
+ #
813
+ # 3) Shader decode bug (myDistortionMap.shader:69-74):
814
+ # `xOffset = (0.2*w) - 0.0425; xOffset = -xOffset;`
815
+ # `yOffset = (0.2*w) - 0.0425;`
816
+ # The correct inverse of `vx*5+0.5` would have been `0.2*w - 0.1`. Off by
817
+ # +0.0575 in the constant term. Combined with sRGB texture sampling
818
+ # (Texture2D RGB24 defaults to sRGB), the actual decoded w is the
819
+ # sRGB→linear of the encoded value — which happens to make the
820
+ # *zero-warp* case lie inside the deadband (so clean regions don't
821
+ # shift), while warped regions get ~57% boosted asymmetric displacement.
822
+ #
823
+ # 4) Deadband: `if |offset| < 0.001 → 0`.
824
+ ys = np.arange(h, dtype=np.float32) / max(h - 1, 1)
825
+ xs = np.arange(w, dtype=np.float32) / max(w - 1, 1)
826
+ uy, ux = np.meshgrid(ys, xs, indexing="ij")
827
+ deg_x = ux * 0.5 + 0.25
828
+ deg_y_for_x = 0.75 - 0.5 * uy # vx_field evaluation coord (Unity UV space)
829
+ deg_y_for_y = 0.25 + 0.5 * uy # vy_field eval coord (post Y-flip in encoding)
830
+
831
+ SQRT2 = float(np.sqrt(2.0))
832
+
833
+ def _accumulate_v(field_x, field_y, want_y=False):
834
+ """Run Unity's compute-pass accumulation. Returns vx if want_y=False, vy otherwise."""
835
+ f = np.zeros((h, w), dtype=np.float32)
836
+ for cx, cy, r, m in zip(wx, wy, wr, wm):
837
+ if m == 0 or r <= 0:
838
+ continue
839
+ ox = field_x - cx
840
+ oy = field_y - cy
841
+ dist = np.sqrt(ox * ox + oy * oy)
842
+ percent = 1.0 - ((r - dist) / r) * m / SQRT2
843
+ percent = np.maximum(1.0, percent) if m < 0 else np.minimum(1.0, percent)
844
+ if want_y:
845
+ f += oy - oy * percent
846
+ else:
847
+ f += ox - ox * percent
848
+ return np.clip(f, -0.1, 0.1)
849
+
850
+ vx_field = _accumulate_v(deg_x, deg_y_for_x, want_y=False)
851
+ vy_field = _accumulate_v(deg_x, deg_y_for_y, want_y=True)
852
+
853
+ # sRGB→linear conversion (Unity samples RGB24 textures as sRGB by default).
854
+ def _srgb_to_linear(s):
855
+ a = 0.055
856
+ s = np.asarray(s, dtype=np.float32)
857
+ return np.where(s <= 0.04045, s / 12.92, ((s + a) / (1.0 + a)) ** 2.4)
858
+
859
+ w_x = _srgb_to_linear(5.0 * vx_field + 0.5)
860
+ w_y = _srgb_to_linear(5.0 * vy_field + 0.5)
861
+
862
+ # Shader-bug decode (with negation on X).
863
+ x_off = -(0.2 * w_x - 0.0425)
864
+ y_off = (0.2 * w_y - 0.0425)
865
+
866
+ # Deadband: zero out very small offsets (mirrors the shader's HACK block).
867
+ x_off = np.where(np.abs(x_off) < 0.001, 0.0, x_off)
868
+ y_off = np.where(np.abs(y_off) < 0.001, 0.0, y_off)
869
+
870
+ # Apply: src_ux = ux + xOff (X axis matches), src_uy = uy − yOff (Y inverted).
871
+ src_u = np.clip(ux + x_off, 0.0, 1.0)
872
+ src_v = np.clip(uy - y_off, 0.0, 1.0)
873
+ sx = (src_u * (w - 1)).astype(np.int32)
874
+ sy = (src_v * (h - 1)).astype(np.int32)
875
+ out = arr[sy, sx]
876
+ return Image.fromarray(out.astype(np.uint8))
877
+
878
+
879
+ # =============================================================================
880
+ # Double Vision — port of DoubleVision.shader (monocular 2D blend)
881
+ # =============================================================================
882
+ # Unity's myDoubleVision.cs is largely binocular (rotates eye in 3D); for our
883
+ # single-screenshot use we port the monocular `DoubleVision.shader`:
884
+ # sample left = src(uv + (-d, 0)); sample right = src(uv + (d, 0));
885
+ # out = 0.5 * (left + right)
886
+
887
+ def apply_double_vision(img: Image.Image, severity: float = 0.5,
888
+ axis: str = "horizontal") -> Image.Image:
889
+ """Monocular double vision (diplopia) via two-sample UV-offset blend.
890
+
891
+ Source: `DoubleVision.shader` (CC BY 4.0).
892
+
893
+ Args:
894
+ img: Input PIL Image (RGB).
895
+ severity: 0..1. Maps to displacement up to ~2% of image width.
896
+ axis: "horizontal" (default), "vertical", or "diagonal".
897
+ """
898
+ severity = float(np.clip(severity, 0.0, 1.0))
899
+ if severity == 0.0:
900
+ return img.copy()
901
+
902
+ arr = np.asarray(img.convert("RGB"), dtype=np.float32)
903
+ h, w = arr.shape[:2]
904
+ disp = severity * 0.025 # normalized UV (matches Unity DoubleVisionEffect.displacementAmount = 0.025)
905
+
906
+ if axis == "horizontal":
907
+ dx, dy = disp, 0.0
908
+ elif axis == "vertical":
909
+ dx, dy = 0.0, disp
910
+ elif axis == "diagonal":
911
+ s = disp / float(np.sqrt(2.0))
912
+ dx, dy = s, s
913
+ else:
914
+ raise ValueError(f"Unknown axis: {axis}")
915
+
916
+ px = int(round(dx * (w - 1)))
917
+ py = int(round(dy * (h - 1)))
918
+
919
+ # left/up sample (uv - displacement) and right/down sample (uv + displacement)
920
+ def shifted(arr_, sx_, sy_):
921
+ out = np.zeros_like(arr_)
922
+ H, W = arr_.shape[:2]
923
+ # source range
924
+ src_y0 = max(0, sy_); src_y1 = min(H, H + sy_)
925
+ src_x0 = max(0, sx_); src_x1 = min(W, W + sx_)
926
+ dst_y0 = max(0, -sy_); dst_y1 = dst_y0 + (src_y1 - src_y0)
927
+ dst_x0 = max(0, -sx_); dst_x1 = dst_x0 + (src_x1 - src_x0)
928
+ out[dst_y0:dst_y1, dst_x0:dst_x1] = arr_[src_y0:src_y1, src_x0:src_x1]
929
+ # fill edges with original (avoids black borders that would skew the blend)
930
+ if dst_y0 > 0:
931
+ out[:dst_y0] = arr_[:dst_y0]
932
+ if dst_y1 < H:
933
+ out[dst_y1:] = arr_[dst_y1:]
934
+ if dst_x0 > 0:
935
+ out[:, :dst_x0] = arr_[:, :dst_x0]
936
+ if dst_x1 < W:
937
+ out[:, dst_x1:] = arr_[:, dst_x1:]
938
+ return out
939
+
940
+ left = shifted(arr, -px, -py)
941
+ right = shifted(arr, px, py)
942
+ out = 0.5 * (left + right)
943
+ return Image.fromarray(_to_uint8(out, 1.0))
944
+
945
+
946
+ # =============================================================================
947
+ # Detail Loss — bit-depth posterize (per Phase-1 spec)
948
+ # =============================================================================
949
+ # Approximates loss of fine luminance/chroma detail (e.g. cataract residual,
950
+ # very low contrast sensitivity). Per-channel posterization via PIL.
951
+
952
+ def apply_detail_loss(img: Image.Image, severity: float = 0.5) -> Image.Image:
953
+ """Reduce per-channel bit depth to simulate detail loss.
954
+
955
+ Args:
956
+ img: Input PIL Image (RGB).
957
+ severity: 0..1. Maps to bits-per-channel: 0.0 → 8 bits (no change),
958
+ 1.0 → 2 bits (heavy posterization).
959
+ """
960
+ severity = float(np.clip(severity, 0.0, 1.0))
961
+ if severity == 0.0:
962
+ return img.copy()
963
+ # 0.0 → 8 bits (no change), 0.5 → 4 bits (visible banding), 1.0 → 1 bit
964
+ bits = int(round(8 - severity * 7))
965
+ bits = max(1, min(8, bits))
966
+ return ImageOps.posterize(img.convert("RGB"), bits)
967
+
968
+
969
+ # =============================================================================
970
+ # Phase 2 — remaining VIP-Sim filters
971
+ # =============================================================================
972
+ # Static-clean (faithful pixel math): pixelation, led, vortex, wiggle, noise.
973
+ # Static-tricky (faithful with frozen state): glitch, teichopsia (scintillating
974
+ # scotoma), foveal darkness.
975
+ # Constrained (frozen-frame approximations of animated effects): nystagmus,
976
+ # flickering_stars, floaters.
977
+ # Skipped: inpainter / inpainter2 (depend on field-loss output + a kdtree
978
+ # compute shader + missing resources; not portable to a single-frame numpy
979
+ # pipeline without major Phase-3 work).
980
+
981
+
982
+ # -- Pixelation --------------------------------------------------------------
983
+ # Source: PixelationEffect.cs/shader. Faithful: floor(uv * pixel_size) /
984
+ # pixel_size with bilinear sampling. We use PIL resize down/up — equivalent
985
+ # image content; the shader's bilinear blend between cell corners is
986
+ # subsumed by BILINEAR downsample.
987
+
988
+ def apply_pixelation(img: Image.Image, severity: float = 0.5,
989
+ pixel_size: float = None, hard: bool = False,
990
+ sampling: str = "average",
991
+ grid_offset_x: float = 0.0,
992
+ grid_offset_y: float = 0.0) -> Image.Image:
993
+ """Pixelation / mosaic downsampling.
994
+
995
+ Unity: `_PixelSize = 1000 - pixelRadius` cells across (default 990 →
996
+ barely visible). The shader downsamples to that grid then bilinearly
997
+ interpolates between the 4 surrounding cells when reading back.
998
+
999
+ Args:
1000
+ severity: 0..1 — if `pixel_size` is None, maps to cells-across
1001
+ 1000..20 (high sev = blockier).
1002
+ pixel_size: Override — cells across the WIDTH (Unity's `_PixelSize`).
1003
+ Unity default 990 (pixelRadius=10).
1004
+ hard: If True, upscale with NEAREST-neighbour so the blocks have HARD
1005
+ square edges (true mosaic / detail loss); if False (default), the
1006
+ faithful soft-bilinear shader look. Blocks are kept ~square by
1007
+ deriving the vertical cell count from the aspect ratio.
1008
+ sampling: "average" (default) box-averages all pixels in a cell —
1009
+ partially-covered cells turn muddy. "point" reads ONE source
1010
+ point per cell like the Unity shader's tex2D lookup: cells
1011
+ snap to a single local color, so high-contrast features (e.g.
1012
+ crosswalk stripes) become bold solid chunks that can fuse into
1013
+ L-shapes with their neighbours.
1014
+ grid_offset_x / grid_offset_y: 0..1 — shifts the block grid by that
1015
+ fraction of a cell, so the mosaic boundaries can be aligned with
1016
+ a reference image without moving the content. 0 = the grid starts
1017
+ at the image origin (previous behaviour). Implemented by edge-pad
1018
+ shifting the image, pixelating, and shifting back, so the outer
1019
+ ring of blocks samples replicated border pixels.
1020
+ """
1021
+ severity = float(np.clip(severity, 0.0, 1.0))
1022
+ if severity == 0.0 and pixel_size is None:
1023
+ return img.copy()
1024
+ if pixel_size is None:
1025
+ pixel_size = float(1000.0 - severity * 980.0) # 1000..20
1026
+ pixel_size = max(2.0, float(pixel_size))
1027
+
1028
+ w, h = img.size
1029
+ cells_x = max(2, int(round(pixel_size)))
1030
+ cells_y = max(2, int(round(cells_x * h / w))) if hard else cells_x
1031
+ ox = float(grid_offset_x) % 1.0
1032
+ oy = float(grid_offset_y) % 1.0
1033
+ dx = int(round(ox * (w / cells_x)))
1034
+ dy = int(round(oy * (h / cells_y)))
1035
+ if dx or dy:
1036
+ arr = np.asarray(img)
1037
+ arr = np.pad(arr, ((dy, 0), (dx, 0)) + ((0, 0),) * (arr.ndim - 2),
1038
+ mode="edge")[:h, :w]
1039
+ img = Image.fromarray(arr)
1040
+
1041
+ down = Image.NEAREST if sampling == "point" else Image.BILINEAR
1042
+ if hard:
1043
+ small = img.resize((cells_x, cells_y), down)
1044
+ out = small.resize((w, h), Image.NEAREST) # HARD square blocks
1045
+ else:
1046
+ # Faithful shader look: square grid + bilinear up (soft blocks).
1047
+ small = img.resize((cells_x, cells_x), down)
1048
+ out = small.resize((w, h), Image.BILINEAR)
1049
+
1050
+ if dx or dy: # shift the content back; the grid keeps the offset
1051
+ arr = np.asarray(out)
1052
+ arr = np.pad(arr, ((0, dy), (0, dx)) + ((0, 0),) * (arr.ndim - 2),
1053
+ mode="edge")[dy:dy + h, dx:dx + w]
1054
+ out = Image.fromarray(arr)
1055
+ return out
1056
+
1057
+
1058
+ # -- LED grid ----------------------------------------------------------------
1059
+ # Source: myLed.cs/shader. Pixelate + per-cell sin(uv*PI)*sin(uv*PI) shape
1060
+ # mask (round bright dot in cell center, dark gaps).
1061
+
1062
+ def apply_led(img: Image.Image, severity: float = 0.5,
1063
+ scale: float = None, brightness: float = 1.0,
1064
+ shape: float = 1.5, margin: float = 0.0,
1065
+ automatic_ratio: bool = True) -> Image.Image:
1066
+ """LED-board look — faithful port of myLed.cs + myLed.shader.
1067
+
1068
+ Unity algorithm:
1069
+ 1. Pixelate via `coord = ds * ceil(uv/ds)` with `ds = 1/scale` on X and
1070
+ `ds*ratio` on Y (so cells are square when ratio = W/H).
1071
+ 2. Multiply by Brightness booster.
1072
+ 3. Cell mask: `mv = abs(sin(uv*scale*PI, uv*scale/ratio*PI)) * Shape`,
1073
+ `s = mv.x*mv.y`, `c = step(s, 1.0)` → with Shape=1.5 the bright
1074
+ "dot" region is where s>=1, surrounded by sin-falloff into dark gaps.
1075
+
1076
+ Args:
1077
+ severity: Convenience knob — if `scale` is None, maps 0..1 → 200..20
1078
+ cells across (high sev = blockier).
1079
+ scale: Override — cells across the width (Unity default 80).
1080
+ brightness: LED brightness multiplier (default 1.0).
1081
+ shape: Mask shape, softer→harsher (default 1.5 per Unity).
1082
+ margin: Blank black border fraction [0, 0.45] (default 0).
1083
+ automatic_ratio: If True, use W/H so cells are square (Unity default).
1084
+ """
1085
+ severity = float(np.clip(severity, 0.0, 1.0))
1086
+ if severity == 0.0 and scale is None:
1087
+ return img.copy()
1088
+ levels = np.asarray(img.convert("RGB"))
1089
+ arr = levels.astype(np.float32) / 255.0
1090
+ h, w = arr.shape[:2]
1091
+ if scale is None:
1092
+ # Map severity (subtle→strong) to scale (cells-across): 200 → 20.
1093
+ scale = float(200.0 - severity * 180.0)
1094
+ ratio = (w / h) if automatic_ratio else 1.0
1095
+
1096
+ # 1. Pixelate: coord = ds * ceil(uv/ds), with ds=1/scale on X
1097
+ # and ds*ratio on Y (so cell aspect matches image aspect).
1098
+ ys = (np.arange(h, dtype=np.float32) + 0.5) / h # pixel centers
1099
+ xs = (np.arange(w, dtype=np.float32) + 0.5) / w
1100
+ ds_x = 1.0 / scale
1101
+ ds_y = ds_x * ratio
1102
+ coord_x = ds_x * np.ceil(xs / ds_x)
1103
+ coord_y = ds_y * np.ceil(ys / ds_y)
1104
+ # Sample _MainTex at (coord_x, coord_y) — nearest texel
1105
+ sx = np.clip((coord_x * w).astype(np.int32), 0, w - 1)
1106
+ sy = np.clip((coord_y * h).astype(np.int32), 0, h - 1)
1107
+ pixelated = arr[sy[:, None], sx[None, :]] * brightness # shape (h,w,3)
1108
+
1109
+ # 2. LED mask: mv = |sin(uv * (scale, scale/ratio) * PI)| * shape
1110
+ yy, xx = np.meshgrid(ys, xs, indexing="ij")
1111
+ mvx = np.abs(np.sin(xx * scale * np.pi)) * shape
1112
+ mvy = np.abs(np.sin(yy * (scale / ratio) * np.pi)) * shape
1113
+ s = mvx * mvy
1114
+ c = (s <= 1.0).astype(np.float32) # step(s, 1.0): 1 in bright dot, 0 in gaps
1115
+ # color = ((1 - c) * color) + ((color * s) * c)
1116
+ # = pixelated where s>1, else pixelated*s
1117
+ mask = (1.0 - c) + c * s
1118
+ # Unity renders this shader in linear color space; apply the mask in
1119
+ # linear and convert back to sRGB so cell-centre brightness matches.
1120
+ if np.ndim(brightness) == 0 and brightness == 1 and pixelated.dtype == np.float32:
1121
+ # Unscaled cells are still 8-bit levels: decode them by table.
1122
+ lin = _image_to_linear(levels[sy[:, None], sx[None, :]])
1123
+ else:
1124
+ lin = _srgb_to_linear(pixelated)
1125
+ out_lin = np.clip(lin * mask[..., None], 0.0, 1.0)
1126
+ out = _linear_to_srgb(out_lin)
1127
+
1128
+ # 3. Optional margin (black border)
1129
+ if margin > 0:
1130
+ m = np.ones((h, w), dtype=np.float32)
1131
+ m[(yy < margin) | (yy > 1.0 - margin)] = 0.0
1132
+ m[:, (xs < margin) | (xs > 1.0 - margin)] = 0.0
1133
+ out = out * m[..., None]
1134
+
1135
+ return Image.fromarray(_to_uint8(out))
1136
+
1137
+
1138
+ # -- Vortex ------------------------------------------------------------------
1139
+ # Source: VortexEffect.cs/shader. Radial swirl; gaze-contingent in Unity, we
1140
+ # use a fixed center (Phase-2 deviation). Inner gray fade preserved.
1141
+
1142
+ def apply_vortex(img: Image.Image, severity: float = 0.5,
1143
+ center=(0.5, 0.5),
1144
+ vortex_radius: float = None,
1145
+ radius_y: float = None,
1146
+ radius_y_down: float = None,
1147
+ suction_strength: float = None,
1148
+ falloff_exponent: float = 2.0,
1149
+ swirl_strength: float = 0.0,
1150
+ inner_circle_radius: float = 0.01,
1151
+ inner_color: float = 0.5,
1152
+ inner_center=None,
1153
+ inner_fade: float = None,
1154
+ inner_opacity: float = 1.0,
1155
+ halo_radius: float = 0.0,
1156
+ halo_center=None,
1157
+ halo_thickness: float = 0.012,
1158
+ halo_color: float = 1.0,
1159
+ halo_opacity: float = 0.5,
1160
+ noise_amount: float = 0.05,
1161
+ noise_scale: float = 10.0,
1162
+ fade_width: float = 0.05) -> Image.Image:
1163
+ """Localised radial-suction vortex — faithful port of VortexEffect.shader.
1164
+
1165
+ Unity algorithm (gaze-centered):
1166
+ - Aspect-corrected distance from gaze point (UV.x *= W/H).
1167
+ - Outside `vortex_radius`: pixel unchanged.
1168
+ - Inside: sample further from centre — `r_sample = dist * (1 + k(dist))`
1169
+ where `k = suction * (1 - dist/R)`. This produces an inward-pulling /
1170
+ fish-eye-like suction (NOT angular rotation).
1171
+ - Small noise added to distance and angle (default amount 0.05, scale 10).
1172
+ - Tiny inner circle (default 0.01) blends to grey via smoothstep.
1173
+ - Outer ring blends back to source via `smoothstep(R - fade, R, dist)`.
1174
+
1175
+ Args:
1176
+ severity: 0..1 — if `vortex_radius` is None, maps to radius 0.05..0.5.
1177
+ If `suction_strength` is None, maps to strength 0.2..2.0.
1178
+ center: (cy, cx) gaze point in normalised UV.
1179
+ vortex_radius: Override — fraction of image diagonal-ish (Unity 0.27).
1180
+ radius_y: Vertical semi-axis (same units as vortex_radius, 1.0 = image
1181
+ height). Makes the field elliptical (e.g. wide-but-flat so a broad
1182
+ band is affected without reaching floors above/below). None keeps
1183
+ the original circular field. NOT in the Unity shader (extension
1184
+ for profile matching); content-agnostic, transfers to any image.
1185
+ radius_y_down: Optional separate vertical reach for the LOWER half
1186
+ of the field; `radius_y` then only governs the upper half.
1187
+ suction_strength: Override — Unity default 1.0.
1188
+ falloff_exponent: Steepness of the pull's decay toward the rim.
1189
+ 2.0 reproduces the Unity shader exactly; lower values keep
1190
+ more pull near the rim (same zero boundary at the radius).
1191
+ swirl_strength: Angular rotation (radians) added on top of the radial
1192
+ pinch, peaking at the center and decaying quadratically to 0 at
1193
+ `vortex_radius`. NOT in the Unity shader (extension for profile
1194
+ matching); default 0.0 keeps the original pure-suction behaviour.
1195
+ inner_circle_radius: Tiny grey core (Unity default 0.01).
1196
+ inner_color: Grey level of that core, 0..1. Unity blends to
1197
+ mid-grey (0.5); 1.0 gives the bright 'eye' dot seen in some
1198
+ reference renders.
1199
+ inner_center: Optional (cy, cx) position of the core dot; None
1200
+ keeps it at the vortex center (Unity behaviour). The dot is
1201
+ clipped to the vortex field, so keep it inside the radius.
1202
+ inner_fade: Edge softness of the dot alone; None inherits
1203
+ `fade_width` (Unity behaviour).
1204
+ inner_opacity: 0..1 blend of the dot over the vortexed image;
1205
+ 1.0 = solid (Unity behaviour), lower = translucent.
1206
+ halo_radius: Radius of a luminous orb. 0 disables it (Unity
1207
+ behaviour). Fully bright inside this radius, then fades
1208
+ softly outward (no visible structure inside the orb).
1209
+ halo_center: Optional (cy, cx) position of the orb; None uses
1210
+ the core dot's center (which itself defaults to the vortex
1211
+ center). Lets dot and halo sit at different spots.
1212
+ halo_thickness: Gaussian width of the outward glow falloff.
1213
+ halo_color: Grey level of the orb, 0..1.
1214
+ halo_opacity: 0..1 peak translucency of the orb.
1215
+ noise_amount: Edge-wobble magnitude (Unity default 0.05).
1216
+ noise_scale: Noise grid frequency (Unity default 10).
1217
+ fade_width: Outer smoothstep width (Unity shader default 0.05).
1218
+ """
1219
+ severity = float(np.clip(severity, 0.0, 1.0))
1220
+ if severity == 0.0:
1221
+ return img.copy()
1222
+ levels = np.asarray(img.convert("RGB"))
1223
+ arr = levels.astype(np.float32) / 255.0
1224
+ h, w = arr.shape[:2]
1225
+ cy, cx = center
1226
+ if vortex_radius is None:
1227
+ vortex_radius = 0.05 + severity * 0.45 # 0.05..0.5
1228
+ if suction_strength is None:
1229
+ suction_strength = 0.2 + severity * 1.8 # 0.2..2.0
1230
+
1231
+ aspect = w / h
1232
+ # Elliptical field: stretch y so the ellipse becomes a circle of radius
1233
+ # `vortex_radius` in the working space; sampling positions are unstretched
1234
+ # on the way back out. `radius_y_down` allows an asymmetric vertical
1235
+ # reach (upper vs lower half of the field).
1236
+ ys_up = vortex_radius / max(radius_y, 1e-6) if radius_y else 1.0
1237
+ ys_dn = (vortex_radius / max(radius_y_down, 1e-6)
1238
+ if radius_y_down else ys_up)
1239
+
1240
+ # Only pixels with dist_n < vortex_radius change (the inner dot and halo
1241
+ # are clipped to them too), and |value noise| < 1, so every changed pixel
1242
+ # has dist < R + |noise_amount * R|. Compute only inside that ellipse's
1243
+ # bounding box (+2 px for rounding), on the same global pixel coordinates.
1244
+ use_box = _vortex_box_ok(
1245
+ (cy, cx, vortex_radius, radius_y, radius_y_down, suction_strength,
1246
+ falloff_exponent, swirl_strength, inner_circle_radius, inner_fade,
1247
+ inner_opacity, halo_radius, halo_thickness, halo_opacity,
1248
+ noise_amount, noise_scale, fade_width),
1249
+ (inner_center, halo_center), (inner_color, halo_color))
1250
+ if use_box:
1251
+ R, A = float(vortex_radius), float(noise_amount)
1252
+ fcx, fcy = float(cx), float(cy)
1253
+ reach = R + abs(A * R)
1254
+ reach_up = reach / abs(float(ys_up)) if ys_up else np.inf
1255
+ reach_dn = reach / abs(float(ys_dn)) if ys_dn else np.inf
1256
+ # The field is computed in float32: keep its rounding (a few ulps of
1257
+ # these magnitudes, in pixels) far below the 2 px margin, and keep
1258
+ # every value inside float32 range. A non-positive reach (negative
1259
+ # radius) also takes the whole frame.
1260
+ scale = h * (aspect * (1.0 + abs(fcx)) + 1.0 + abs(fcy)
1261
+ + reach + reach_up + reach_dn)
1262
+ use_box = (reach > 0 and np.isfinite(scale) and scale < 2.0 ** 18
1263
+ and max(abs(R), abs(A)) < 1e30)
1264
+ if use_box:
1265
+ # |dx| < reach <=> |col + 0.5 - cx * w| < reach * h, likewise rows
1266
+ c0 = int(min(w, max(0, np.floor(fcx * w - 0.5 - reach * h) - 2)))
1267
+ c1 = int(max(0, min(w, np.ceil(fcx * w - 0.5 + reach * h) + 3)))
1268
+ r0 = int(min(h, max(0, np.floor(fcy * h - 0.5 - reach_up * h) - 2)))
1269
+ r1 = int(max(0, min(h, np.ceil(fcy * h - 0.5 + reach_dn * h) + 3)))
1270
+ else: # anything unusual: the whole frame, exactly as before
1271
+ r0, r1, c0, c1 = 0, h, 0, w
1272
+ box = (slice(r0, r1), slice(c0, c1))
1273
+
1274
+ ys_norm = (np.arange(h, dtype=np.float32) + 0.5) / h
1275
+ xs_norm = (np.arange(w, dtype=np.float32) + 0.5) / w
1276
+ yy, xx = np.meshgrid(ys_norm[r0:r1], xs_norm[c0:c1], indexing="ij")
1277
+
1278
+ adj_x = xx * aspect
1279
+ adj_y = yy
1280
+ adj_cx = cx * aspect
1281
+ adj_cy = cy
1282
+ dx = adj_x - adj_cx
1283
+ dy_raw = adj_y - adj_cy
1284
+ ys = np.where(dy_raw < 0, ys_up, ys_dn)
1285
+ dy = dy_raw * ys
1286
+ dist = np.sqrt(dx * dx + dy * dy)
1287
+
1288
+ # Value-noise grid (matches the shader's rand/lerp construction)
1289
+ def _value_noise(u, v, scale):
1290
+ p_u = np.floor(u * scale)
1291
+ p_v = np.floor(v * scale)
1292
+ f_u = u * scale - p_u
1293
+ f_v = v * scale - p_v
1294
+
1295
+ def _rand(a, b):
1296
+ return np.modf(np.sin(a * 12.9898 + b * 78.233) * 43758.5453)[0]
1297
+
1298
+ a = _rand(p_u, p_v)
1299
+ b = _rand(p_u + 1, p_v)
1300
+ c = _rand(p_u, p_v + 1)
1301
+ d = _rand(p_u + 1, p_v + 1)
1302
+ su = f_u * f_u * (3.0 - 2.0 * f_u)
1303
+ sv = f_v * f_v * (3.0 - 2.0 * f_v)
1304
+ return (a + (b - a) * su) + (c - a) * sv * (1.0 - su) + (d - b) * su * sv
1305
+
1306
+ noise_val = _value_noise(xx, yy, noise_scale)
1307
+ dist_n = dist + noise_val * noise_amount * vortex_radius
1308
+
1309
+ inside = dist_n < vortex_radius
1310
+ # Pixels outside the field pass through: _to_uint8(level / 255) == level
1311
+ # for every 8-bit level, so they keep their input value.
1312
+ out = levels.copy()
1313
+ if np.any(inside):
1314
+ eps = 1e-8
1315
+ src = arr[box]
1316
+ angle = np.arctan2(dy, dx)
1317
+ # Optional angular noise (small); the same field as noise_val
1318
+ angle = angle + noise_val * noise_amount
1319
+
1320
+ # Radial suction: sample further out
1321
+ d_over_R = np.clip(dist_n / max(vortex_radius, eps), 0.0, 1.0)
1322
+ r_sample = dist_n * (1.0 + suction_strength
1323
+ * (1.0 - d_over_R) ** falloff_exponent)
1324
+
1325
+ # Swirl: rotate the sample angle, strongest at center, 0 at the rim
1326
+ if swirl_strength:
1327
+ angle = angle + swirl_strength * (1.0 - d_over_R) ** 2
1328
+
1329
+ sample_x = adj_cx + r_sample * np.cos(angle)
1330
+ sin_a = np.sin(angle)
1331
+ ys_back = np.where(sin_a < 0, ys_up, ys_dn)
1332
+ sample_y = adj_cy + r_sample * sin_a / ys_back
1333
+ sample_x = sample_x / aspect # undo aspect correction
1334
+ sample_x = np.clip(sample_x, 0.0, 1.0)
1335
+ sample_y = np.clip(sample_y, 0.0, 1.0)
1336
+ # Bilinear sampling (Unity tex2D is bilinear; nearest-neighbour
1337
+ # shreds strong stretches into aliased filaments)
1338
+ fx = sample_x * (w - 1)
1339
+ fy = sample_y * (h - 1)
1340
+ x0 = np.floor(fx).astype(np.int32)
1341
+ y0 = np.floor(fy).astype(np.int32)
1342
+ x1 = np.minimum(x0 + 1, w - 1)
1343
+ y1 = np.minimum(y0 + 1, h - 1)
1344
+ wx = (fx - x0)[..., None]
1345
+ wy = (fy - y0)[..., None]
1346
+ top = arr[y0, x0] * (1.0 - wx) + arr[y0, x1] * wx
1347
+ bot = arr[y1, x0] * (1.0 - wx) + arr[y1, x1] * wx
1348
+ vortexed = top * (1.0 - wy) + bot * wy
1349
+
1350
+ # Outer fade back to source (smoothstep R-fade..R)
1351
+ t_out = np.clip((dist_n - (vortex_radius - fade_width))
1352
+ / max(fade_width, eps), 0.0, 1.0)
1353
+ s_out = t_out * t_out * (3.0 - 2.0 * t_out)
1354
+ blended = vortexed * (1.0 - s_out[..., None]) + src * s_out[..., None]
1355
+
1356
+ # Inner core dot (grey in Unity; position/brightness/softness/opacity
1357
+ # configurable — only rendered inside the vortex field)
1358
+ has_halo = halo_radius > 0 and halo_opacity > 0
1359
+ if inner_circle_radius > 0 or has_halo:
1360
+ if inner_center is not None:
1361
+ icy, icx = inner_center
1362
+ idx = adj_x - icx * aspect
1363
+ idy = adj_y - icy
1364
+ idist = np.sqrt(idx * idx + idy * idy)
1365
+ else:
1366
+ idist = dist_n
1367
+
1368
+ if inner_circle_radius > 0:
1369
+ i_fade = fade_width if inner_fade is None else inner_fade
1370
+ grey = np.full_like(src, inner_color)
1371
+ t_in = np.clip((idist - (inner_circle_radius - i_fade))
1372
+ / max(i_fade, eps), 0.0, 1.0)
1373
+ s_in = t_in * t_in * (3.0 - 2.0 * t_in)
1374
+ alpha = (1.0 - s_in)[..., None] * inner_opacity
1375
+ alpha = np.where((idist < inner_circle_radius)[..., None], alpha, 0.0)
1376
+ blended = grey * alpha + blended * (1.0 - alpha)
1377
+
1378
+ # Halo: luminous orb (solid glow inside halo_radius, Gaussian
1379
+ # falloff outward); own center if given
1380
+ if has_halo:
1381
+ if halo_center is not None:
1382
+ hcy, hcx = halo_center
1383
+ hdx = adj_x - hcx * aspect
1384
+ hdy = adj_y - hcy
1385
+ hdist = np.sqrt(hdx * hdx + hdy * hdy)
1386
+ else:
1387
+ hdist = idist
1388
+ band = np.where(
1389
+ hdist <= halo_radius, 1.0,
1390
+ np.exp(-0.5 * ((hdist - halo_radius)
1391
+ / max(halo_thickness, eps)) ** 2))
1392
+ h_alpha = band[..., None] * halo_opacity
1393
+ halo_col = np.full_like(src, halo_color)
1394
+ blended = halo_col * h_alpha + blended * (1.0 - h_alpha)
1395
+
1396
+ res = np.where(inside[..., None], blended, src)
1397
+ # Without the box, return what the whole-frame code returned, even
1398
+ # where array kwargs broadcast it to another shape.
1399
+ if use_box:
1400
+ out[box] = _to_uint8(res)
1401
+ else:
1402
+ out = _to_uint8(res)
1403
+
1404
+ return Image.fromarray(out)
1405
+
1406
+
1407
+ def _vortex_box_ok(scalars, centres, colours) -> bool:
1408
+ """True when apply_vortex may compute only inside its bounding box.
1409
+
1410
+ Every per-pixel kwarg must be a real (int or float) scalar or None, the
1411
+ centres pairs of them, and the colours broadcast to one value per
1412
+ channel. Array kwargs broadcast the field to their own shape, so they
1413
+ take the whole frame.
1414
+ """
1415
+ def real(v):
1416
+ return v is None or (np.ndim(v) == 0
1417
+ and np.asarray(v).dtype.kind in "fiu")
1418
+ try:
1419
+ vals = list(scalars)
1420
+ for c in centres:
1421
+ if c is not None:
1422
+ if np.ndim(c) != 1 or len(c) != 2:
1423
+ return False
1424
+ vals.extend(c)
1425
+ return (all(real(v) for v in vals)
1426
+ and all(np.broadcast_shapes(np.shape(c), (3,)) == (3,)
1427
+ for c in colours))
1428
+ except Exception: # let the whole-frame code raise what it raised
1429
+ return False
1430
+
1431
+
1432
+ # -- Wiggle ------------------------------------------------------------------
1433
+ # Source: myWiggle.cs/shader (Simple mode). Original is animated:
1434
+ # t.x += sin(timer + t.x * freq) * amp
1435
+ # Frozen-frame: timer = 0 → static sinusoidal warp on both axes.
1436
+
1437
+ def apply_wiggle(img: Image.Image, severity: float = 0.5,
1438
+ freq: float = 12.0, amplitude: float = 0.01,
1439
+ timer: float = 0.0, mode: str = "complex") -> Image.Image:
1440
+ """UV-displacement wiggle — faithful port of myWiggle.shader.
1441
+
1442
+ Unity defaults: Frequency=12, Amplitude=0.01, Mode=Complex. The shader
1443
+ uses `Timer * 0.1` for the complex pass. Capture is animation-frozen
1444
+ after a couple of frames so `timer` is small (≈0.05). We expose `timer`
1445
+ so the validator can sweep it; `severity` scales `amplitude`.
1446
+
1447
+ Args:
1448
+ img: input PIL image (RGB).
1449
+ severity: 0..1; multiplies `amplitude`.
1450
+ freq: Frequency (Unity default 12).
1451
+ amplitude: Amplitude (Unity default 0.01).
1452
+ timer: Frozen-frame timer (Unity multiplies by 0.1 for complex mode).
1453
+ mode: "simple" or "complex" (Unity default complex).
1454
+ """
1455
+ severity = float(np.clip(severity, 0.0, 1.0))
1456
+ if severity == 0.0:
1457
+ return img.copy()
1458
+
1459
+ arr = np.asarray(img.convert("RGB"), dtype=np.float32)
1460
+ h, w = arr.shape[:2]
1461
+ amp = amplitude * severity
1462
+
1463
+ ys = np.arange(h, dtype=np.float32) / max(h - 1, 1)
1464
+ xs = np.arange(w, dtype=np.float32) / max(w - 1, 1)
1465
+ uy, ux = np.meshgrid(ys, xs, indexing="ij")
1466
+
1467
+ if mode == "simple":
1468
+ # t.x += sin(timer + uv.x * freq) * amp
1469
+ # t.y += cos(timer + uv.y * freq) * amp - amp
1470
+ du = np.sin(timer + ux * freq) * amp
1471
+ dv = np.cos(timer + uy * freq) * amp - amp
1472
+ else: # complex (Unity default)
1473
+ # Unity multiplies Timer by 0.1 for complex
1474
+ t = timer * 0.1
1475
+ def shift(u, v):
1476
+ fx = freq * (u + t)
1477
+ fy = freq * (v + t)
1478
+ sx_ = np.cos(np.cos(fx - fy) * np.cos(fy))
1479
+ sy_ = np.cos(np.sin(fx + fy) * np.sin(fy))
1480
+ return sx_, sy_
1481
+ px, py = shift(ux, uy)
1482
+ qx, qy = shift(ux + 1.0, uy + 1.0)
1483
+ du = amp * (px - qx)
1484
+ dv = amp * (py - qy)
1485
+
1486
+ src_u = np.clip(ux + du, 0.0, 1.0)
1487
+ src_v = np.clip(uy + dv, 0.0, 1.0)
1488
+ sx = (src_u * (w - 1)).astype(np.int32)
1489
+ sy = (src_v * (h - 1)).astype(np.int32)
1490
+ return Image.fromarray(arr[sy, sx].astype(np.uint8))
1491
+
1492
+
1493
+ # -- Noise -------------------------------------------------------------------
1494
+ # Source: myNoise.cs/shader. Original cycles through 10 textures; we use a
1495
+ # single frozen Perlin frame. Shader formula treated as additive noise blend
1496
+ # (the literal `lerp(color, (color+n)/16, intensity)` collapses the image at
1497
+ # high intensity which is not the perceptual intent — we use the standard
1498
+ # additive form, scaled by intensity).
1499
+
1500
+ def apply_noise(img: Image.Image, severity: float = 0.5,
1501
+ seed: int = 0) -> Image.Image:
1502
+ """Per-pixel additive luminance noise."""
1503
+ severity = float(np.clip(severity, 0.0, 1.0))
1504
+ if severity == 0.0:
1505
+ return img.copy()
1506
+
1507
+ arr = np.asarray(img.convert("RGB"), dtype=np.float32) / 255.0
1508
+ h, w = arr.shape[:2]
1509
+ rng = np.random.default_rng(seed)
1510
+ n = (rng.random((h, w)).astype(np.float32) - 0.5) * 2.0 # [-1, 1]
1511
+ intensity = severity * 0.35
1512
+ out = arr + n[..., None] * intensity
1513
+ return Image.fromarray(_to_uint8(out))
1514
+
1515
+
1516
+ # -- Glitch (RGB channel offset) --------------------------------------------
1517
+ # Source: myGlitch.cs/shader (Interferences mode). Per-row RGB channel
1518
+ # horizontal displacement driven by random blocks. Original uses _Time.y
1519
+ # for animation — we freeze with a fixed seed.
1520
+
1521
+ def apply_glitch(img: Image.Image, severity: float = 0.5,
1522
+ max_displace: float = 0.05, seed: int = 0) -> Image.Image:
1523
+ """Frozen RGB-channel-shift glitch (interferences-style)."""
1524
+ severity = float(np.clip(severity, 0.0, 1.0))
1525
+ if severity == 0.0:
1526
+ return img.copy()
1527
+
1528
+ arr = np.asarray(img.convert("RGB"), dtype=np.float32)
1529
+ h, w = arr.shape[:2]
1530
+ rng = np.random.default_rng(seed)
1531
+
1532
+ # Random per-block horizontal displacement (a few wide bands)
1533
+ n_blocks = max(1, h // 12)
1534
+ disp_blocks = (rng.random(n_blocks) - 0.5) * 2.0 * max_displace * severity
1535
+ block_size = max(1, h // n_blocks)
1536
+ disp = np.repeat(disp_blocks, block_size)[:h]
1537
+ # Pad if shorter
1538
+ if disp.size < h:
1539
+ disp = np.pad(disp, (0, h - disp.size), mode="edge")
1540
+ disp_px = (disp * w).astype(np.int32)
1541
+
1542
+ r = arr[..., 0].copy()
1543
+ g = arr[..., 1].copy()
1544
+ b = arr[..., 2].copy()
1545
+ rows = np.arange(h)
1546
+ for y in rows:
1547
+ d = int(disp_px[y])
1548
+ r[y] = np.roll(arr[y, :, 0], d)
1549
+ g[y] = np.roll(arr[y, :, 1], d // 2)
1550
+ b[y] = np.roll(arr[y, :, 2], -d)
1551
+ out = np.stack([r, g, b], axis=-1)
1552
+ return Image.fromarray(_to_uint8(out, 1.0))
1553
+
1554
+
1555
+ # -- Teichopsia (scintillating scotoma) --------------------------------------
1556
+ # Source: myTeichopsia.cs + myScintillate.shader (CC BY 4.0).
1557
+ #
1558
+ # Unity algorithm:
1559
+ # 1. CPU generates a 512² mask texture: a ring-shaped polygon (outer spiky
1560
+ # polygon minus inner less-spiky polygon, centered at (256, 256)).
1561
+ # - outer: 30 points around a circle, radius = 150*(1-0.75) + Uniform[0, 150*0.75]
1562
+ # - inner: same angles, radius = outer - Normal(70, 15) but ≥ 30
1563
+ # 2. Shader samples mask at degcoords = (uv - gazeOffset)*0.5 + 0.25 — mask
1564
+ # covers the central 50%×50% of screen, centered on gaze.
1565
+ # 3. Inside the white mask region: boost saturation x2 in HSV, then apply
1566
+ # multiplicative per-channel scintillating noise:
1567
+ # nr = frac(n)*2, ng = frac(n*1.2154)*2, nb = frac(n*1.3453)*2
1568
+ # result = lerp(color, color * mult, strength * (1 - lum*lumContrib))
1569
+ # 4. Outside mask: unchanged.
1570
+ #
1571
+ # This Python port reproduces all of the above with a seeded RNG so a frozen
1572
+ # frame is reproducible.
1573
+
1574
+ def _box_muller(n: int, rng: "np.random.Generator") -> np.ndarray:
1575
+ """Normal(0, 1) samples via Box-Muller (matches Unity's generateNormalRandom)."""
1576
+ r1 = np.maximum(rng.random(n), 1e-10).astype(np.float32)
1577
+ r2 = rng.random(n).astype(np.float32)
1578
+ return np.sqrt(-2.0 * np.log(r1)) * np.cos(2.0 * np.pi * r2)
1579
+
1580
+
1581
+ def apply_teichopsia(img: Image.Image, severity: float = 0.75,
1582
+ lum_contribution: float = 0.75,
1583
+ gaze=(0.5, 0.5), seed: int = 0,
1584
+ sat_boost: float = 2.0) -> Image.Image:
1585
+ """Scintillating scotoma — port of myTeichopsia + myScintillate (colored+lum pass).
1586
+
1587
+ The ring is generated at Unity's exact parameters (30 points, outer
1588
+ radius 150px / inner-width ~70±15px in a 512² mask space) and remapped
1589
+ to cover the central half of the screen, centered on `gaze`.
1590
+
1591
+ Args:
1592
+ img: input PIL image (RGB).
1593
+ severity: shader `_Params.y` (Strength). Default 0.75 = Unity default.
1594
+ lum_contribution: shader `_Params.z` — reduces effect in bright pixels.
1595
+ gaze: (x, y) in [0, 1] screen UV. Default (0.5, 0.5) for fixed-center.
1596
+ seed: RNG seed for deterministic polygon + noise.
1597
+ sat_boost: HSV saturation multiplier inside the ring (Unity hard-codes 2).
1598
+ """
1599
+ severity = float(np.clip(severity, 0.0, 1.0))
1600
+ if severity == 0.0:
1601
+ return img.copy()
1602
+
1603
+ try:
1604
+ from skimage.color import rgb2hsv, hsv2rgb
1605
+ from skimage.draw import polygon as skpolygon
1606
+ except ImportError as e:
1607
+ raise ImportError("teichopsia requires scikit-image: "
1608
+ "pip install 'vipsim[teichopsia]'") from e
1609
+
1610
+ arr = np.asarray(img.convert("RGB"), dtype=np.float32) / 255.0
1611
+ h, w = arr.shape[:2]
1612
+
1613
+ # 1) Build the ring mask in 512² mask space (Unity's exact parameters).
1614
+ mask_size = 512
1615
+ cx_mask = cy_mask = mask_size // 2
1616
+ n_points = 30
1617
+ max_radius = 150.0
1618
+ spikiness = 0.75
1619
+ width_mu = 70.0
1620
+ width_std = 15.0
1621
+ min_radius_px = 30.0
1622
+ min_pixels = 10.0
1623
+
1624
+ rng = np.random.default_rng(seed)
1625
+ # Outer: rho = maxRadius*(1-spikiness) + UniformInt[0, maxRadius*spikiness)
1626
+ rho_out = max_radius * (1 - spikiness) + \
1627
+ rng.integers(0, int(max_radius * spikiness), n_points).astype(np.float32)
1628
+ thetas = (2 * np.pi / (n_points + 1)) * np.arange(n_points, dtype=np.float32)
1629
+ outer_x = cx_mask + rho_out * np.cos(thetas)
1630
+ outer_y = cy_mask + rho_out * np.sin(thetas)
1631
+
1632
+ # Inner: subtract Normal(mu, std) clamped to >= min_pixels, then clamp final
1633
+ # radius to >= min_radius_px.
1634
+ ring_w = np.maximum(width_mu + width_std * _box_muller(n_points, rng), min_pixels)
1635
+ rho_in = np.maximum(rho_out - ring_w, min_radius_px)
1636
+ inner_x = cx_mask + rho_in * np.cos(thetas)
1637
+ inner_y = cy_mask + rho_in * np.sin(thetas)
1638
+
1639
+ ring_mask = np.zeros((mask_size, mask_size), dtype=bool)
1640
+ rr_o, cc_o = skpolygon(outer_y, outer_x, ring_mask.shape)
1641
+ rr_i, cc_i = skpolygon(inner_y, inner_x, ring_mask.shape)
1642
+ ring_mask[rr_o, cc_o] = True
1643
+ ring_mask[rr_i, cc_i] = False # outer ∖ inner = ring
1644
+
1645
+ # 2) Map mask to screen via degcoords = (uv - gazeOffset)*0.5 + 0.25
1646
+ # i.e. screen-UV [0, 1] → mask-UV [0.25 - gazeOff, 0.75 - gazeOff].
1647
+ gx, gy = gaze
1648
+ gaze_off_x = 0.5 - gx
1649
+ gaze_off_y = 0.5 - gy
1650
+ ys = np.arange(h, dtype=np.float32) / max(h - 1, 1)
1651
+ xs = np.arange(w, dtype=np.float32) / max(w - 1, 1)
1652
+ uy, ux = np.meshgrid(ys, xs, indexing="ij")
1653
+ deg_x = (ux - gaze_off_x) * 0.5 + 0.25
1654
+ deg_y = (uy - gaze_off_y) * 0.5 + 0.25
1655
+ # Sample mask (NEAREST). Outside [0, 1] sample → False (no ring effect).
1656
+ mx = (deg_x * (mask_size - 1)).astype(np.int32)
1657
+ my = (deg_y * (mask_size - 1)).astype(np.int32)
1658
+ in_bounds = (mx >= 0) & (mx < mask_size) & (my >= 0) & (my < mask_size)
1659
+ screen_mask = np.zeros((h, w), dtype=bool)
1660
+ screen_mask[in_bounds] = ring_mask[my[in_bounds], mx[in_bounds]]
1661
+
1662
+ # 3) Inside the ring: HSV saturation boost, multiplicative scintillating noise,
1663
+ # luminance-attenuated mixing — exactly the colored_lum pass of myScintillate.
1664
+ hsv = rgb2hsv(arr)
1665
+ hsv[..., 1] = np.clip(hsv[..., 1] * sat_boost, 0.0, 1.0)
1666
+ boosted = hsv2rgb(hsv).astype(np.float32)
1667
+
1668
+ rng2 = np.random.default_rng(seed + 1)
1669
+ n = rng2.random((h, w)).astype(np.float32)
1670
+ nr = (n % 1.0) * 2.0
1671
+ ng = ((n * 1.2154) % 1.0) * 2.0
1672
+ nb = ((n * 1.3453) % 1.0) * 2.0
1673
+ mult = np.stack([nr, ng, nb], axis=-1).astype(np.float32)
1674
+
1675
+ lum = 0.299 * boosted[..., 0] + 0.587 * boosted[..., 1] + 0.114 * boosted[..., 2]
1676
+ strength = severity * (1.0 - lum * lum_contribution)
1677
+ noisy = boosted * mult
1678
+ scint = boosted * (1.0 - strength[..., None]) + noisy * strength[..., None]
1679
+
1680
+ out = arr.copy()
1681
+ out[screen_mask] = scint[screen_mask]
1682
+ return Image.fromarray(_to_uint8(out))
1683
+
1684
+
1685
+ # -- Foveal Darkness ---------------------------------------------------------
1686
+ # Source: FovealDarkness.cs/shader. Smoothstep-faded dark spot at gaze
1687
+ # location (we fix to image center).
1688
+
1689
+ def apply_foveal_darkness(img: Image.Image, severity: float = 0.5,
1690
+ center=(0.5, 0.5), radius: float = None,
1691
+ radius_x: float = None,
1692
+ radius_y: float = None,
1693
+ edge_width: float = None,
1694
+ mode: str = "dark",
1695
+ invert: bool = False,
1696
+ band_width: float = None) -> Image.Image:
1697
+ """Central dark spot with smoothstep edge (gaze-fixed).
1698
+
1699
+ `radius` is in NORMALISED units (fraction of each axis: radius 0.2 =>
1700
+ the darkened ellipse spans ~0.4 of the width AND ~0.4 of the height).
1701
+ Because the image is usually wider than tall, a single radius renders as a
1702
+ horizontal oval in pixels. Pass `radius_x` / `radius_y` to size the two
1703
+ axes independently (fractions of width / height respectively) — e.g.
1704
+ radius_x=0.15, radius_y=0.20 gives a compact oval ~30% wide x ~40% tall.
1705
+
1706
+ `edge_width` (0..1, optional): fraction of the radius used as the edge
1707
+ feather. Default None keeps the original profile (smoothstep across the
1708
+ FULL radius: darkest at center, fading continuously). With e.g. 0.3 the
1709
+ interior is a flat plateau at full severity and only the outer 30% of the
1710
+ ellipse feathers to 0 — a uniform semi-transparent patch with a defined
1711
+ boundary rather than a center-peaked cloud.
1712
+
1713
+ `mode`: "dark" (default) blends toward black — the original behaviour.
1714
+ "light" blends toward white instead (whitish veil / halo ellipse).
1715
+ "desat" drains color toward per-pixel grey (luminance) — a localized
1716
+ desaturation ellipse; combine with `invert` to grey a corner/edge.
1717
+
1718
+ `invert` (default False): apply the effect OUTSIDE the ellipse instead
1719
+ of inside — e.g. mode='light' + invert gives a peripheral white glow
1720
+ ring/veil that fades in across the edge feather. False keeps the
1721
+ original behaviour exactly.
1722
+
1723
+ `band_width` (only with invert, default None): limit the inverted
1724
+ effect to a BAND just outside the ellipse. Units are fractions of the
1725
+ radius: 0.5 means the effect dies out again by 1.5x the radius, so the
1726
+ far corners return to untouched. None = the inverted effect extends
1727
+ all the way out (previous behaviour).
1728
+ """
1729
+ severity = float(np.clip(severity, 0.0, 1.0))
1730
+ if severity == 0.0:
1731
+ return img.copy()
1732
+
1733
+ if radius is None:
1734
+ radius = 0.05 + severity * 0.20
1735
+ rx = radius_x if radius_x is not None else radius
1736
+ ry = radius_y if radius_y is not None else radius
1737
+
1738
+ arr = np.asarray(img.convert("RGB"), dtype=np.float32)
1739
+ h, w = arr.shape[:2]
1740
+ cy, cx = center
1741
+ ys = np.arange(h, dtype=np.float32) / max(h - 1, 1) - cy
1742
+ xs = np.arange(w, dtype=np.float32) / max(w - 1, 1) - cx
1743
+ yy, xx = np.meshgrid(ys, xs, indexing="ij")
1744
+ # Elliptical normalised distance: 1.0 exactly on the ellipse boundary.
1745
+ t_raw = np.sqrt((xx / max(rx, 1e-6)) ** 2 + (yy / max(ry, 1e-6)) ** 2)
1746
+ t = np.clip(t_raw, 0.0, 1.0)
1747
+ if edge_width is not None:
1748
+ e = float(np.clip(edge_width, 1e-3, 1.0))
1749
+ u = np.clip((t - (1.0 - e)) / e, 0.0, 1.0) # 0 inside plateau, 1 at rim
1750
+ s = u * u * (3.0 - 2.0 * u)
1751
+ else:
1752
+ s = t * t * (3.0 - 2.0 * t) # smoothstep
1753
+ darken = (s if invert else (1.0 - s)) * severity
1754
+ if invert and band_width:
1755
+ u2 = np.clip((t_raw - 1.0) / max(band_width, 1e-6), 0.0, 1.0)
1756
+ darken = darken * (1.0 - u2 * u2 * (3.0 - 2.0 * u2))
1757
+
1758
+ if mode == "desat":
1759
+ lum = arr @ np.array([0.299, 0.587, 0.114], dtype=np.float32)
1760
+ out = arr + (lum[..., None] - arr) * darken[..., None]
1761
+ elif mode == "light":
1762
+ out = arr + (255.0 - arr) * darken[..., None]
1763
+ else:
1764
+ out = arr * (1.0 - darken[..., None])
1765
+ return Image.fromarray(_to_uint8(out, 1.0))
1766
+
1767
+
1768
+ # -- Nystagmus ---------------------------------------------------------------
1769
+ # Source: myNystagmus.cs/shader. Animated jerk oscillation. Frozen-frame:
1770
+ # motion-blur along the oscillation axis (perceptually equivalent to seeing
1771
+ # constantly-moving content).
1772
+
1773
+ def apply_nystagmus(img: Image.Image, severity: float = 0.5,
1774
+ axis: str = "horizontal") -> Image.Image:
1775
+ """Frozen-frame motion-blur approximation of jerk nystagmus."""
1776
+ severity = float(np.clip(severity, 0.0, 1.0))
1777
+ if severity == 0.0:
1778
+ return img.copy()
1779
+
1780
+ arr = np.asarray(img.convert("RGB"), dtype=np.float32) / 255.0
1781
+ h, w = arr.shape[:2]
1782
+ amp = severity * 0.04
1783
+ n_samples = 7
1784
+ offsets = np.linspace(-amp, amp, n_samples)
1785
+
1786
+ accum = np.zeros_like(arr)
1787
+ for o in offsets:
1788
+ if axis == "horizontal":
1789
+ shift = int(round(o * (w - 1)))
1790
+ sh = np.roll(arr, shift, axis=1)
1791
+ elif axis == "vertical":
1792
+ shift = int(round(o * (h - 1)))
1793
+ sh = np.roll(arr, shift, axis=0)
1794
+ else: # both
1795
+ sx = int(round(o * (w - 1)))
1796
+ sy = int(round(o * (h - 1)))
1797
+ sh = np.roll(np.roll(arr, sx, axis=1), sy, axis=0)
1798
+ accum += sh
1799
+ out = accum / n_samples
1800
+ return Image.fromarray(_to_uint8(out))
1801
+
1802
+
1803
+ # -- Flickering Stars --------------------------------------------------------
1804
+ # Source: FlickeringStars.cs. Random star sprites that fade in/out. Frozen-
1805
+ # frame: render N stars at fixed brightness (sampled from the fade curve).
1806
+
1807
+ def apply_flickering_stars(img: Image.Image, severity: float = 0.5,
1808
+ n_stars: int = None, seed: int = 0) -> Image.Image:
1809
+ """Frozen flickering-star overlay (random bright dots)."""
1810
+ severity = float(np.clip(severity, 0.0, 1.0))
1811
+ if severity == 0.0:
1812
+ return img.copy()
1813
+
1814
+ arr = np.asarray(img.convert("RGB"), dtype=np.float32)
1815
+ h, w = arr.shape[:2]
1816
+ if n_stars is None:
1817
+ n_stars = int(20 + severity * 80)
1818
+ rng = np.random.default_rng(seed)
1819
+ star_y = rng.integers(0, h, n_stars)
1820
+ star_x = rng.integers(0, w, n_stars)
1821
+ brightness = rng.random(n_stars).astype(np.float32) * severity
1822
+
1823
+ out = arr.copy()
1824
+ # Sprite footprint: 5x5 with gaussian falloff
1825
+ dy_grid, dx_grid = np.meshgrid(np.arange(-2, 3), np.arange(-2, 3), indexing="ij")
1826
+ fall = np.clip(1.0 - (dy_grid * dy_grid + dx_grid * dx_grid) / 8.0, 0.0, 1.0)
1827
+ for sy_, sx_, b in zip(star_y, star_x, brightness):
1828
+ y0, y1 = max(0, sy_ - 2), min(h, sy_ + 3)
1829
+ x0, x1 = max(0, sx_ - 2), min(w, sx_ + 3)
1830
+ py0 = y0 - (sy_ - 2); py1 = py0 + (y1 - y0)
1831
+ px0 = x0 - (sx_ - 2); px1 = px0 + (x1 - x0)
1832
+ sprite = (255.0 * b * fall[py0:py1, px0:px1])[..., None]
1833
+ out[y0:y1, x0:x1] = np.maximum(out[y0:y1, x0:x1], sprite)
1834
+ return Image.fromarray(_to_uint8(out, 1.0))
1835
+
1836
+
1837
+ # -- Floaters ----------------------------------------------------------------
1838
+ # Source: myFloaters.cs/shader. Original generates a 1024² texture with N
1839
+ # elliptical floater shapes (dark/light/scintillating modes), gaze-contingent.
1840
+ # Frozen-frame: paint random oriented elliptical translucent blobs over the
1841
+ # image (gaze-fixed, single texture frame).
1842
+
1843
+ # Mask draws _paint_blobs makes per chunk: about 40 MiB of temporaries.
1844
+ _BLOB_DRAWS_PER_CHUNK = 1 << 20
1845
+
1846
+
1847
+ def _paint_blobs(out: np.ndarray, xs, ys, sizes, rng: "np.random.Generator",
1848
+ alpha: float, target_val: float,
1849
+ overlay_size: float = 1024.0) -> None:
1850
+ """Blend `target_val` into a random ~60% of each blob's box, in place.
1851
+
1852
+ A blob of overlay size `sz` centred on pixel (y, x) covers the box of
1853
+ pixels within s // 2 of it on each axis, s = max(1, round(sz * frame
1854
+ side / overlay_size)), clipped to the frame, and blends the box pixels
1855
+ where its rng.random(box shape) > 0.4. Vectorised over the blobs, with
1856
+ the same result as painting them one at a time:
1857
+ - PCG64 spends one draw per double, so rng.random calls over all
1858
+ boxes in blob order, in chunks of any size, yield the same values
1859
+ (empty boxes draw nothing);
1860
+ - every hit applies the same float32 blend, so a pixel hit n times gets
1861
+ it n times in a row.
1862
+ """
1863
+ if len(sizes) == 0: # no blobs, no draws
1864
+ return
1865
+ h, w = out.shape[:2]
1866
+ f_x = np.asarray(sizes * w / overlay_size, dtype=np.float64)
1867
+ f_y = np.asarray(sizes * h / overlay_size, dtype=np.float64)
1868
+ bad = ~(np.isfinite(f_x) & np.isfinite(f_y)
1869
+ & (np.abs(f_x) < 2.0 ** 52) & (np.abs(f_y) < 2.0 ** 52))
1870
+ if bad.any():
1871
+ # astype(int) would wrap these; paint one blob at a time with Python
1872
+ # ints as before, which raises (or not) exactly as it did.
1873
+ for sx, sy, sz in zip(xs, ys, sizes):
1874
+ s_x = max(1, int(round(sz * w / overlay_size)))
1875
+ s_y = max(1, int(round(sz * h / overlay_size)))
1876
+ hx, hy = s_x // 2, s_y // 2
1877
+ x0, x1 = max(0, sx - hx), min(w, sx + hx + 1)
1878
+ y0, y1 = max(0, sy - hy), min(h, sy + hy + 1)
1879
+ if x1 <= x0 or y1 <= y0:
1880
+ continue
1881
+ mask = rng.random((y1 - y0, x1 - x0)) > 0.4
1882
+ for c in range(3):
1883
+ tile = out[y0:y1, x0:x1, c]
1884
+ tile[mask] = tile[mask] * (1.0 - alpha) + target_val * alpha
1885
+ out[y0:y1, x0:x1, c] = tile
1886
+ return
1887
+ s_x = np.maximum(1, np.rint(f_x).astype(np.int64))
1888
+ s_y = np.maximum(1, np.rint(f_y).astype(np.int64))
1889
+ hx, hy = s_x // 2, s_y // 2
1890
+ x0, x1 = np.maximum(0, xs - hx), np.minimum(w, xs + hx + 1)
1891
+ y0, y1 = np.maximum(0, ys - hy), np.minimum(h, ys + hy + 1)
1892
+ keep = (x1 > x0) & (y1 > y0)
1893
+ bw, bh = (x1 - x0)[keep], (y1 - y0)[keep]
1894
+ x0, y0 = x0[keep], y0[keep]
1895
+ area = bw * bh
1896
+ ends = np.cumsum(area)
1897
+ starts = ends - area
1898
+ total = int(ends[-1]) if ends.size else 0
1899
+ # The draws for all boxes are made in chunks of _BLOB_DRAWS_PER_CHUNK, so
1900
+ # memory stays bounded however large or many the blobs are. Consecutive
1901
+ # rng.random calls continue the same PCG64 stream (one draw per double),
1902
+ # and hit counts add up across chunks.
1903
+ hits = np.zeros(h * w, dtype=np.int64)
1904
+ for s in range(0, total, _BLOB_DRAWS_PER_CHUNK):
1905
+ e = min(total, s + _BLOB_DRAWS_PER_CHUNK)
1906
+ d = s + np.flatnonzero(rng.random(e - s) > 0.4) # draws that hit
1907
+ b = np.searchsorted(ends, d, side="right") # blob of each draw
1908
+ k = d - starts[b] # row-major position within the blob's box
1909
+ bw_k = bw[b]
1910
+ hits += np.bincount((y0[b] + k // bw_k) * w + x0[b] + k % bw_k,
1911
+ minlength=h * w)
1912
+ hits = hits.reshape(h, w)
1913
+
1914
+ r, c = np.nonzero(hits)
1915
+ left = hits[r, c]
1916
+ while r.size:
1917
+ out[r, c] = out[r, c] * (1.0 - alpha) + target_val * alpha
1918
+ left -= 1
1919
+ more = left > 0
1920
+ r, c, left = r[more], c[more], left[more]
1921
+
1922
+
1923
+ # apply_floaters' Gaussian and disc rejection samplers loop until enough
1924
+ # points land in the frame, so parameters under which (almost) no draw can
1925
+ # land would loop forever. They are checked before sampling: a non-finite
1926
+ # centre, spread or angle, a disc radius of NaN or -inf, or a Gaussian
1927
+ # whose draws land in the frame with probability below this raise
1928
+ # ValueError. Nothing is drawn, so every run that does not raise samples
1929
+ # exactly as before (at this rate one point already takes ~1e7 draws).
1930
+ _FLOATER_MIN_ACCEPTANCE = 1e-7
1931
+
1932
+
1933
+ def _normal_cdf(z: np.ndarray) -> np.ndarray:
1934
+ """Standard normal CDF, elementwise (NumPy has no erf; math.erfc keeps
1935
+ the core dependencies at numpy and Pillow)."""
1936
+ flat = [0.5 * math.erfc(-float(t) / math.sqrt(2.0)) for t in z.ravel()]
1937
+ return np.asarray(flat, dtype=np.float64).reshape(z.shape)
1938
+
1939
+
1940
+ def _gaussian_frame_probability(mu_x, mu_y, sigma_x, sigma_y, cos_a, sin_a,
1941
+ size, n: int = 2048) -> float:
1942
+ """Probability that one draw of apply_floaters' rotated Gaussian lands
1943
+ in [0, size)^2.
1944
+
1945
+ A draw is mu + sigma_x*u*(cos_a, sin_a) + sigma_y*v*(-sin_a, cos_a) with
1946
+ u, v ~ N(0, 1). For fixed u both frame bounds are linear in v, so the
1947
+ v-mass is a difference of normal CDFs; that is integrated over the
1948
+ range of u that can reach the frame. The midpoint rule never samples
1949
+ the range ends, where an axis-aligned frame edge makes the integrand
1950
+ jump."""
1951
+ cx = np.array([0.0, size, 0.0, size])
1952
+ cy = np.array([0.0, 0.0, size, size])
1953
+ u_c = ((cx - mu_x) * cos_a + (cy - mu_y) * sin_a) / sigma_x
1954
+ lo, hi = max(float(u_c.min()), -38.0), min(float(u_c.max()), 38.0)
1955
+ if not lo < hi: # frame beyond 38 sigma along u
1956
+ return 0.0
1957
+ h = (hi - lo) / n
1958
+ u = lo + h * (np.arange(n) + 0.5)
1959
+ v_lo = np.full(n, -np.inf)
1960
+ v_hi = np.full(n, np.inf)
1961
+ with np.errstate(over="ignore", invalid="ignore"):
1962
+ for mu, a, c in ((mu_x, sigma_x * cos_a, -sigma_y * sin_a),
1963
+ (mu_y, sigma_x * sin_a, sigma_y * cos_a)):
1964
+ b = mu + a * u # this coordinate at v = 0
1965
+ if c == 0.0:
1966
+ v_hi = np.where((b >= 0.0) & (b < size), v_hi, -np.inf)
1967
+ else:
1968
+ e0, e1 = -b / c, (size - b) / c
1969
+ v_lo = np.maximum(v_lo, np.minimum(e0, e1))
1970
+ v_hi = np.minimum(v_hi, np.maximum(e0, e1))
1971
+ mass = np.clip(_normal_cdf(v_hi) - _normal_cdf(v_lo), 0.0, None)
1972
+ pdf = np.exp(-0.5 * u * u) / math.sqrt(2.0 * math.pi)
1973
+ return float(h * np.sum(pdf * mass))
1974
+
1975
+
1976
+ def _check_gaussian_reachable(mu_x, mu_y, sigma_x, sigma_y, cos_a, sin_a,
1977
+ size) -> None:
1978
+ """Raise when a draw of the Gaussian sampler lands in the frame with
1979
+ probability below _FLOATER_MIN_ACCEPTANCE (or its parameters are not
1980
+ finite)."""
1981
+ params = np.array([mu_x, mu_y, sigma_x, sigma_y, cos_a, sin_a],
1982
+ dtype=np.float64)
1983
+ if not np.isfinite(params).all():
1984
+ p = 0.0
1985
+ else:
1986
+ # The disc of radius d (centre to nearest edge) lies in the frame and
1987
+ # holds at least 1 - exp(-d^2 / 2 sigma_max^2) of the mass: when that
1988
+ # suffices, skip the integral (~5 ms) for the usual in-frame centre.
1989
+ d = min(mu_x, size - mu_x, mu_y, size - mu_y)
1990
+ if d > 0 and -math.expm1(-0.5 * (d / max(sigma_x, sigma_y)) ** 2) >= _FLOATER_MIN_ACCEPTANCE:
1991
+ return
1992
+ p = _gaussian_frame_probability(*params, size)
1993
+ if not p >= _FLOATER_MIN_ACCEPTANCE:
1994
+ raise ValueError(
1995
+ "apply_floaters: no point would land in the frame (a draw lands "
1996
+ f"there with probability {p:.3g}); check center, spread and angle")
1997
+
1998
+
1999
+ def _check_disc_reachable(r_o) -> None:
2000
+ """Raise when the disc sampler's radius (circle_radius_uv * overlay
2001
+ size) is NaN or -inf: its acceptance is then NaN everywhere. Any other
2002
+ radius accepts at least 1% of draws."""
2003
+ if np.isnan(r_o) or r_o == -np.inf:
2004
+ raise ValueError(
2005
+ "apply_floaters: no point would land in the frame "
2006
+ f"(circle_radius_uv * 1024 = {r_o!r}); check circle_radius_uv")
2007
+
2008
+
2009
+ def apply_floaters(img: Image.Image, severity: float = 0.5,
2010
+ n_floaters: int = None, mode: str = "dark",
2011
+ floater_size: int = 1, circle_radius_uv: float = 0.146,
2012
+ seed: int = 0, distribution: str = "disc",
2013
+ center=(0.5, 0.5), spread: float = 0.16,
2014
+ spread_x: float = None, spread_y: float = None,
2015
+ size_variance: bool = False,
2016
+ dot_brightness: float = None,
2017
+ angle: float = 0.0,
2018
+ region=None,
2019
+ region_feather: float = 0.02,
2020
+ region_center_bias: float = 0.0) -> Image.Image:
2021
+ """Vitreous floaters: many tiny grain dots clustered over the image.
2022
+
2023
+ Source: `myFloaters.cs` (CC BY 4.0).
2024
+
2025
+ Two spatial distributions:
2026
+ - "disc" (default, faithful Unity port): uniform sampling accepted via
2027
+ IsInsideCircle's 3 zones — dense disc + soft falloff + 1% noise
2028
+ EVERYWHERE (so some specks land full-frame).
2029
+ - "gaussian": a 2-D Gaussian-weighted spray — highest density at `center`,
2030
+ decaying with `spread` (std as a fraction of the overlay), and NO
2031
+ uniform background noise, so the periphery thins out. Reads as a
2032
+ concentrated central BURST rather than full-frame grain.
2033
+
2034
+ Args:
2035
+ severity: 0..1. Scales number of floaters and per-pixel alpha.
2036
+ n_floaters: explicit count override. Default = severity * 50000.
2037
+ mode: "dark" (black spots) or "light" (white spots).
2038
+ floater_size: base speck size in overlay (1024²) px (Unity=1).
2039
+ circle_radius_uv: disc radius as fraction of overlay ("disc" mode).
2040
+ distribution: "disc" or "gaussian".
2041
+ center: (cy, cx) cluster centre in normalised [0,1] ("gaussian" mode).
2042
+ spread: Gaussian std as a fraction of the overlay ("gaussian" mode).
2043
+ spread_x, spread_y: override the horizontal / vertical std separately
2044
+ (fraction of overlay). E.g. spread_x < spread_y gives a tall
2045
+ vertical PLUME rather than a round/horizontal cluster.
2046
+ size_variance: if True, mix larger blobs among the fine specks.
2047
+ dot_brightness: grey level of the dots, 0..1. Default None keeps
2048
+ the Unity behaviour (pure white in 'light' mode, pure black in
2049
+ 'dark' mode); e.g. 0.85 gives softer off-white specks.
2050
+ angle: rotation of the gaussian ellipse in degrees (0 = spread_x
2051
+ horizontal / spread_y vertical; positive tilts clockwise).
2052
+ Only affects 'gaussian' distribution.
2053
+ region: optional polygon [[y, x], ...] in normalised coords. When
2054
+ given it OVERRIDES `distribution`: dots are scattered uniformly
2055
+ inside the polygon (a hand-drawn cluster shape). This is a
2056
+ visual-field shape (like center/spread), so it transfers to
2057
+ arbitrary images.
2058
+ region_feather: soft edge of the region mask (fraction of overlay;
2059
+ 0 = hard boundary).
2060
+ region_center_bias: 0 = dots spread evenly across the region
2061
+ (uniform); toward 1 = dots concentrate around the region's
2062
+ middle, thinning out toward its outline (dense-core look).
2063
+ seed: RNG seed.
2064
+ """
2065
+ severity = float(np.clip(severity, 0.0, 1.0))
2066
+ if severity == 0.0:
2067
+ return img.copy()
2068
+
2069
+ arr = np.asarray(img.convert("RGB"), dtype=np.float32)
2070
+ h, w = arr.shape[:2]
2071
+ if n_floaters is None:
2072
+ n_floaters = int(severity * 50000)
2073
+ n_floaters = int(max(0, n_floaters))
2074
+ if n_floaters == 0:
2075
+ return img.copy()
2076
+
2077
+ rng = np.random.default_rng(seed)
2078
+
2079
+ # Unity samples in a 1024² overlay; we replicate then stretch to screen.
2080
+ overlay_size = 1024.0
2081
+ target = n_floaters
2082
+
2083
+ region_mask = None
2084
+ if region is not None and len(region) >= 3:
2085
+ from PIL import ImageDraw, ImageFilter
2086
+ m = Image.new("L", (int(overlay_size), int(overlay_size)), 0)
2087
+ poly = [(float(x) * overlay_size, float(y) * overlay_size)
2088
+ for y, x in region]
2089
+ ImageDraw.Draw(m).polygon(poly, fill=255)
2090
+ if region_feather:
2091
+ _check_blur_radius(region_feather * overlay_size)
2092
+ m = m.filter(ImageFilter.GaussianBlur(region_feather * overlay_size))
2093
+ region_mask = np.asarray(m, np.float32) / 255.0
2094
+ if region_mask.max() <= 0:
2095
+ region_mask = None # degenerate polygon: fall back
2096
+ elif region_center_bias > 0:
2097
+ ys_m, xs_m = np.nonzero(region_mask > 0.05)
2098
+ cy_m, cx_m = ys_m.mean(), xs_m.mean()
2099
+ gy, gx = np.mgrid[0:region_mask.shape[0], 0:region_mask.shape[1]]
2100
+ dist_c = np.hypot(gy - cy_m, gx - cx_m)
2101
+ dmax = max(dist_c[region_mask > 0.05].max(), 1.0)
2102
+ falloff = np.clip(1.0 - dist_c / dmax, 0.0, 1.0) \
2103
+ ** (1.0 + 5.0 * float(np.clip(region_center_bias, 0, 1)))
2104
+ region_mask = region_mask * falloff
2105
+ region_mask /= max(region_mask.max(), 1e-6)
2106
+
2107
+ if region_mask is not None:
2108
+ pts_x, pts_y = [], []
2109
+ ov = int(overlay_size)
2110
+ for _ in range(200):
2111
+ if len(pts_x) >= target:
2112
+ break
2113
+ n = max((target - len(pts_x)) * 3, 1024)
2114
+ bx = rng.uniform(0.0, overlay_size, n)
2115
+ by = rng.uniform(0.0, overlay_size, n)
2116
+ pa = region_mask[np.clip(by.astype(int), 0, ov - 1),
2117
+ np.clip(bx.astype(int), 0, ov - 1)]
2118
+ keep = rng.random(n) < pa
2119
+ pts_x.extend(bx[keep].tolist())
2120
+ pts_y.extend(by[keep].tolist())
2121
+ pts_x = np.asarray(pts_x[:target], dtype=np.float64)
2122
+ pts_y = np.asarray(pts_y[:target], dtype=np.float64)
2123
+ target = len(pts_x)
2124
+ elif distribution == "uniform":
2125
+ # Even scatter across the WHOLE frame (denser toward the periphery
2126
+ # relative to a centre-gaussian) — stray floaters everywhere.
2127
+ pts_x = rng.uniform(0.0, overlay_size, size=target)
2128
+ pts_y = rng.uniform(0.0, overlay_size, size=target)
2129
+ elif distribution == "gaussian":
2130
+ # 2-D Gaussian spray centred at `center`, std = `spread` of overlay.
2131
+ # Reject out-of-bounds samples (keeps the periphery thin, no edge pile).
2132
+ cyN, cxN = center
2133
+ mu_x = cxN * overlay_size
2134
+ mu_y = cyN * overlay_size
2135
+ sigma_x = max(1.0, (spread_x if spread_x is not None else spread) * overlay_size)
2136
+ sigma_y = max(1.0, (spread_y if spread_y is not None else spread) * overlay_size)
2137
+ rad = np.deg2rad(angle)
2138
+ cos_a, sin_a = np.cos(rad), np.sin(rad)
2139
+ _check_gaussian_reachable(mu_x, mu_y, sigma_x, sigma_y, cos_a, sin_a,
2140
+ overlay_size)
2141
+ pts_x, pts_y = [], []
2142
+ while len(pts_x) < target:
2143
+ n = (target - len(pts_x)) * 2
2144
+ dx = rng.normal(0.0, sigma_x, n)
2145
+ dy = rng.normal(0.0, sigma_y, n)
2146
+ bx = mu_x + dx * cos_a - dy * sin_a
2147
+ by = mu_y + dx * sin_a + dy * cos_a
2148
+ ok = (bx >= 0) & (bx < overlay_size) & (by >= 0) & (by < overlay_size)
2149
+ pts_x.extend(bx[ok].tolist())
2150
+ pts_y.extend(by[ok].tolist())
2151
+ pts_x = np.asarray(pts_x[:target], dtype=np.float64)
2152
+ pts_y = np.asarray(pts_y[:target], dtype=np.float64)
2153
+ else:
2154
+ # Faithful disc rejection sampling (3-zone IsInsideCircle).
2155
+ cx_o = cy_o = overlay_size * 0.5
2156
+ r_o = circle_radius_uv * overlay_size
2157
+ _check_disc_reachable(r_o)
2158
+ pts_x, pts_y = [], []
2159
+ while len(pts_x) < target:
2160
+ batch = max(target * 2, 4096)
2161
+ cxs = rng.uniform(0.0, overlay_size, size=batch)
2162
+ cys = rng.uniform(0.0, overlay_size, size=batch)
2163
+ d = np.hypot(cxs - cx_o, cys - cy_o)
2164
+ falloff = np.clip(1.0 - (d - r_o) / r_o, 0.0, 1.0)
2165
+ accept_p = np.where(d <= r_o, 1.0, 0.01 + 0.99 * falloff)
2166
+ keep = rng.random(batch) < accept_p
2167
+ pts_x.extend(cxs[keep].tolist())
2168
+ pts_y.extend(cys[keep].tolist())
2169
+ pts_x = np.asarray(pts_x[:target], dtype=np.float64)
2170
+ pts_y = np.asarray(pts_y[:target], dtype=np.float64)
2171
+
2172
+ # Map overlay coords → screen pixel coords.
2173
+ xs = np.clip((pts_x * (w / overlay_size)).astype(int), 0, w - 1)
2174
+ ys = np.clip((pts_y * (h / overlay_size)).astype(int), 0, h - 1)
2175
+
2176
+ # Per-floater size: constant, or a weighted mix (mostly fine, some blobs).
2177
+ if size_variance:
2178
+ pool = np.array([1, 1, 1, 1, 2, 2, 3, floater_size + 2, floater_size + 4])
2179
+ sizes = rng.choice(pool, size=target)
2180
+ else:
2181
+ sizes = np.full(target, floater_size, dtype=int)
2182
+
2183
+ out = arr.copy()
2184
+ if dot_brightness is not None:
2185
+ target_val = float(np.clip(dot_brightness, 0.0, 1.0)) * 255.0
2186
+ else:
2187
+ target_val = 0.0 if mode == "dark" else 255.0
2188
+ alpha = severity
2189
+
2190
+ # Fast path: single-pixel specks (size <= 1) painted vectorised.
2191
+ small = sizes <= 1
2192
+ if np.any(small):
2193
+ smx, smy = xs[small], ys[small]
2194
+ for c in range(3):
2195
+ out[smy, smx, c] = arr[smy, smx, c] * (1.0 - alpha) + target_val * alpha
2196
+
2197
+ # Larger blobs painted with a random sub-pixel mask each.
2198
+ _paint_blobs(out, xs[~small], ys[~small], sizes[~small], rng,
2199
+ alpha, target_val, overlay_size)
2200
+ return Image.fromarray(_to_uint8(out, 1.0))
2201
+
2202
+
2203
+ # =============================================================================
2204
+ # Vignette — peripheral darkening (tunnel-vision / peripheral field loss)
2205
+ # =============================================================================
2206
+ # Not a distinct VIP-Sim shader, but the "Vision loss, peripheral" percept
2207
+ # (glaucoma / retinitis-pigmentosa tunnel vision) darkens the periphery in
2208
+ # addition to the mip-blur that apply_field_loss(peripheral) already provides.
2209
+ # This multiplies a radial darkening mask: ~1 in the centre, falling to
2210
+ # (1 - strength) toward the corners via a smoothstep beyond `radius`.
2211
+
2212
+ def apply_vignette(img: Image.Image, severity: float = 0.5,
2213
+ strength: float = None, radius: float = 0.5,
2214
+ softness: float = 0.5, mode: str = "dark") -> Image.Image:
2215
+ """Radial peripheral shading (vignette).
2216
+
2217
+ `mode="dark"` darkens the periphery (tunnel-vision, e.g. glaucoma).
2218
+ `mode="light"` brightens/blooms the periphery toward white (a bright
2219
+ veiling fog that lightens the edges — e.g. some RP / hazy peripheral loss).
2220
+
2221
+ Args:
2222
+ img: Input PIL Image (RGB).
2223
+ severity: 0..1 — if `strength` is None, maps directly to how strong the
2224
+ corner shading is (0 = none, 1 = corners fully black / white).
2225
+ strength: Override for the corner shading amount (0..1).
2226
+ radius: Normalised distance (0=centre, 1=corner) where shading starts.
2227
+ softness: Width of the smoothstep falloff from `radius` outward.
2228
+ mode: "dark" (multiply toward black) or "light" (blend toward white).
2229
+ """
2230
+ severity = float(np.clip(severity, 0.0, 1.0))
2231
+ if strength is None:
2232
+ strength = severity
2233
+ strength = float(np.clip(strength, 0.0, 1.0))
2234
+ if strength == 0.0:
2235
+ return img.copy()
2236
+
2237
+ arr = np.asarray(img.convert("RGB"), dtype=np.float32) / 255.0
2238
+ h, w = arr.shape[:2]
2239
+ ys = np.arange(h, dtype=np.float32) / max(h - 1, 1) - 0.5
2240
+ xs = np.arange(w, dtype=np.float32) / max(w - 1, 1) - 0.5
2241
+ yy, xx = np.meshgrid(ys, xs, indexing="ij")
2242
+ # Normalise so the corner distance is 1.0 (0.707 = sqrt(0.5^2+0.5^2)).
2243
+ dist = np.sqrt(xx * xx + yy * yy) / 0.7071
2244
+ t = np.clip((dist - radius) / max(softness, 1e-6), 0.0, 1.0)
2245
+ s = (t * t * (3.0 - 2.0 * t)) * strength # smoothstep * strength
2246
+
2247
+ if mode == "light":
2248
+ # Blend toward white at the periphery (bright veiling fog).
2249
+ out = arr * (1.0 - s[..., None]) + 1.0 * s[..., None]
2250
+ elif mode == "dark":
2251
+ out = arr * (1.0 - s[..., None])
2252
+ else:
2253
+ raise ValueError(f"Unknown vignette mode: {mode}")
2254
+ return Image.fromarray(_to_uint8(out))
2255
+
2256
+
2257
+ # =============================================================================
2258
+ # Presets — common combinations for study use
2259
+ # =============================================================================
2260
+
2261
+ PRESETS = {
2262
+ "mild_low_vision": {
2263
+ "description": "Mild low vision: slight blur + reduced contrast",
2264
+ "filters": [
2265
+ ("blur", {"severity": 0.2}),
2266
+ ("bcg", {"brightness": 0.9, "contrast": 0.8, "gamma": 1.1}),
2267
+ ],
2268
+ },
2269
+ "moderate_low_vision": {
2270
+ "description": "Moderate low vision: noticeable blur + contrast loss",
2271
+ "filters": [
2272
+ ("blur", {"severity": 0.4}),
2273
+ ("bcg", {"brightness": 0.85, "contrast": 0.7, "gamma": 1.2}),
2274
+ ],
2275
+ },
2276
+ "severe_low_vision": {
2277
+ "description": "Severe low vision: heavy blur + major contrast loss",
2278
+ "filters": [
2279
+ ("blur", {"severity": 0.7}),
2280
+ ("bcg", {"brightness": 0.75, "contrast": 0.5, "gamma": 1.4}),
2281
+ ],
2282
+ },
2283
+ "deuteranomaly_moderate": {
2284
+ "description": "Moderate red-green color blindness (deuteranomaly)",
2285
+ "filters": [
2286
+ ("cvd", {"cvd_type": "deuteranomaly", "severity": 0.6}),
2287
+ ],
2288
+ },
2289
+ "protanomaly_moderate": {
2290
+ "description": "Moderate red color blindness (protanomaly)",
2291
+ "filters": [
2292
+ ("cvd", {"cvd_type": "protanomaly", "severity": 0.6}),
2293
+ ],
2294
+ },
2295
+ "cataracts_moderate": {
2296
+ "description": "Moderate cataracts: blur + yellowing + contrast loss",
2297
+ "filters": [
2298
+ ("cataracts", {"severity": 0.5}),
2299
+ ],
2300
+ },
2301
+ "elderly_vision": {
2302
+ "description": "Typical elderly vision: mild cataracts + slight CVD",
2303
+ "filters": [
2304
+ ("cataracts", {"severity": 0.3}),
2305
+ ("cvd", {"cvd_type": "tritanomaly", "severity": 0.3}),
2306
+ ],
2307
+ },
2308
+ "photophobia": {
2309
+ "description": "Light sensitivity / glare from bright regions (bloom)",
2310
+ "filters": [
2311
+ ("bloom", {"severity": 0.5}),
2312
+ ],
2313
+ },
2314
+ "macular_degeneration": {
2315
+ "description": "Central field loss (mip-blur in center via radial mask)",
2316
+ "filters": [
2317
+ ("field_loss", {"severity": 0.6, "field_type": "central"}),
2318
+ ],
2319
+ },
2320
+ "tunnel_vision": {
2321
+ "description": "Peripheral field loss (mip-blur at edges)",
2322
+ "filters": [
2323
+ ("field_loss", {"severity": 0.6, "field_type": "peripheral"}),
2324
+ ],
2325
+ },
2326
+ "metamorphopsia": {
2327
+ "description": "Visual distortion / wavy lines (radial UV warp)",
2328
+ "filters": [
2329
+ ("distortion", {"severity": 0.5}),
2330
+ ],
2331
+ },
2332
+ "diplopia": {
2333
+ "description": "Double vision (horizontal offset blend)",
2334
+ "filters": [
2335
+ ("double_vision", {"severity": 0.4}),
2336
+ ],
2337
+ },
2338
+ "low_contrast_sensitivity": {
2339
+ "description": "Detail loss via posterization (severe contrast sensitivity loss)",
2340
+ "filters": [
2341
+ ("detail_loss", {"severity": 0.6}),
2342
+ ],
2343
+ },
2344
+ "low_resolution": {
2345
+ "description": "Heavily pixelated view (e.g. very low acuity)",
2346
+ "filters": [
2347
+ ("pixelation", {"severity": 0.5}),
2348
+ ],
2349
+ },
2350
+ "led_display": {
2351
+ "description": "LED-board appearance (cellular dot pattern)",
2352
+ "filters": [
2353
+ ("led", {"severity": 0.5}),
2354
+ ],
2355
+ },
2356
+ "vortex_distortion": {
2357
+ "description": "Local swirling distortion at fixed gaze point",
2358
+ "filters": [
2359
+ ("vortex", {"severity": 0.5}),
2360
+ ],
2361
+ },
2362
+ "static_wiggle": {
2363
+ "description": "Static sinusoidal warp (wiggle frozen)",
2364
+ "filters": [
2365
+ ("wiggle", {"severity": 0.5}),
2366
+ ],
2367
+ },
2368
+ "visual_noise": {
2369
+ "description": "Per-pixel additive noise (snow / interference)",
2370
+ "filters": [
2371
+ ("noise", {"severity": 0.5}),
2372
+ ],
2373
+ },
2374
+ "rgb_glitch": {
2375
+ "description": "Frozen RGB-channel-shift glitch",
2376
+ "filters": [
2377
+ ("glitch", {"severity": 0.5}),
2378
+ ],
2379
+ },
2380
+ "scintillating_scotoma": {
2381
+ "description": "Migraine aura: crescent of shimmering colored noise",
2382
+ "filters": [
2383
+ ("teichopsia", {"severity": 0.6}),
2384
+ ],
2385
+ },
2386
+ "central_scotoma": {
2387
+ "description": "Central dark spot (foveal darkness)",
2388
+ "filters": [
2389
+ ("foveal_darkness", {"severity": 0.6}),
2390
+ ],
2391
+ },
2392
+ "nystagmus_motion": {
2393
+ "description": "Frozen-frame motion blur from involuntary eye oscillation",
2394
+ "filters": [
2395
+ ("nystagmus", {"severity": 0.5}),
2396
+ ],
2397
+ },
2398
+ "flickering_stars": {
2399
+ "description": "Random bright dots scattered across the field",
2400
+ "filters": [
2401
+ ("flickering_stars", {"severity": 0.5}),
2402
+ ],
2403
+ },
2404
+ "floaters": {
2405
+ "description": "Dark elliptical translucent floaters",
2406
+ "filters": [
2407
+ ("floaters", {"severity": 0.5}),
2408
+ ],
2409
+ },
2410
+ }
2411
+
2412
+
2413
+ _FILTER_DISPATCH = {
2414
+ "blur": apply_blur,
2415
+ "cvd": apply_cvd,
2416
+ "bcg": apply_bcg,
2417
+ "cataracts": apply_cataracts,
2418
+ "bloom": apply_bloom,
2419
+ "field_loss": apply_field_loss,
2420
+ "distortion": apply_distortion,
2421
+ "double_vision": apply_double_vision,
2422
+ "detail_loss": apply_detail_loss,
2423
+ # Phase 2
2424
+ "pixelation": apply_pixelation,
2425
+ "led": apply_led,
2426
+ "vortex": apply_vortex,
2427
+ "wiggle": apply_wiggle,
2428
+ "noise": apply_noise,
2429
+ "glitch": apply_glitch,
2430
+ "teichopsia": apply_teichopsia,
2431
+ "foveal_darkness": apply_foveal_darkness,
2432
+ "nystagmus": apply_nystagmus,
2433
+ "flickering_stars": apply_flickering_stars,
2434
+ "floaters": apply_floaters,
2435
+ "vignette": apply_vignette,
2436
+ }
2437
+
2438
+
2439
+ def apply_preset(img: Image.Image, preset_name: str) -> Image.Image:
2440
+ """Apply a named preset (chain of filters)."""
2441
+ preset = PRESETS[preset_name]
2442
+ result = img.copy()
2443
+ for filter_name, kwargs in preset["filters"]:
2444
+ fn = _FILTER_DISPATCH[filter_name]
2445
+ result = fn(result, **kwargs)
2446
+ return result
2447
+
2448
+
2449
+ # =============================================================================
2450
+ # CLI
2451
+ # =============================================================================
2452
+
2453
+ def main():
2454
+ parser = argparse.ArgumentParser(description="VipSim image filters for VLM accessibility analysis")
2455
+ # Optional at parse time so --list-presets works without paths; enforced below.
2456
+ parser.add_argument("input", nargs="?", help="Input image path")
2457
+ parser.add_argument("output", nargs="?", help="Output image path")
2458
+
2459
+ filter_choices = list(_FILTER_DISPATCH.keys())
2460
+ group = parser.add_mutually_exclusive_group(required=True)
2461
+ group.add_argument("--filter", choices=filter_choices, help="Single filter to apply")
2462
+ group.add_argument("--preset", choices=list(PRESETS.keys()), help="Named preset")
2463
+ group.add_argument("--list-presets", action="store_true", help="List available presets")
2464
+
2465
+ # Filter-specific args
2466
+ parser.add_argument("--severity", type=float, default=0.5,
2467
+ help="Severity 0.0-1.0 (used by all severity-driven filters)")
2468
+ parser.add_argument("--type", dest="cvd_type", default="deuteranomaly",
2469
+ choices=["protanomaly", "deuteranomaly", "tritanomaly", "monochrome"],
2470
+ help="CVD type")
2471
+ parser.add_argument("--brightness", type=float, default=1.0, help="BCG brightness")
2472
+ parser.add_argument("--contrast", type=float, default=1.0, help="BCG contrast")
2473
+ parser.add_argument("--gamma", type=float, default=1.0, help="BCG gamma")
2474
+ parser.add_argument("--field-type", dest="field_type", default="central",
2475
+ choices=["central", "peripheral"],
2476
+ help="Field-loss mask type")
2477
+ parser.add_argument("--axis", default="horizontal",
2478
+ choices=["horizontal", "vertical", "diagonal", "both"],
2479
+ help="Displacement axis (double-vision: horizontal/vertical/diagonal; "
2480
+ "nystagmus: horizontal/vertical/both)")
2481
+ parser.add_argument("--floater-mode", dest="floater_mode", default="dark",
2482
+ choices=["dark", "light"],
2483
+ help="Floater appearance mode")
2484
+
2485
+ args = parser.parse_args()
2486
+
2487
+ if args.list_presets:
2488
+ for name, preset in PRESETS.items():
2489
+ print(f" {name}: {preset['description']}")
2490
+ return
2491
+ if args.input is None or args.output is None:
2492
+ parser.error("the following arguments are required: input, output")
2493
+ if args.filter == "double_vision" and args.axis == "both":
2494
+ parser.error("double_vision supports --axis horizontal, vertical or diagonal")
2495
+
2496
+ img = Image.open(args.input).convert("RGB")
2497
+
2498
+ if args.preset:
2499
+ result = apply_preset(img, args.preset)
2500
+ elif args.filter == "cvd":
2501
+ result = apply_cvd(img, cvd_type=args.cvd_type, severity=args.severity)
2502
+ elif args.filter == "bcg":
2503
+ result = apply_bcg(img, brightness=args.brightness, contrast=args.contrast, gamma=args.gamma)
2504
+ elif args.filter == "field_loss":
2505
+ result = apply_field_loss(img, severity=args.severity, field_type=args.field_type)
2506
+ elif args.filter == "double_vision":
2507
+ result = apply_double_vision(img, severity=args.severity, axis=args.axis)
2508
+ elif args.filter == "nystagmus":
2509
+ result = apply_nystagmus(img, severity=args.severity, axis=args.axis)
2510
+ elif args.filter == "floaters":
2511
+ result = apply_floaters(img, severity=args.severity, mode=args.floater_mode)
2512
+ else:
2513
+ # Generic severity-only filters
2514
+ fn = _FILTER_DISPATCH[args.filter]
2515
+ result = fn(img, severity=args.severity)
2516
+
2517
+ result.save(args.output)
2518
+ print(f"Saved: {args.output}")
2519
+
2520
+
2521
+ if __name__ == "__main__":
2522
+ main()