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/__init__.py +84 -0
- vipsim/__main__.py +4 -0
- vipsim/assets/macular-degeneration.jpg +0 -0
- vipsim/filters.py +2522 -0
- vipsim/gpu.py +2538 -0
- vipsim-0.1.0.dist-info/METADATA +355 -0
- vipsim-0.1.0.dist-info/RECORD +10 -0
- vipsim-0.1.0.dist-info/WHEEL +4 -0
- vipsim-0.1.0.dist-info/entry_points.txt +2 -0
- vipsim-0.1.0.dist-info/licenses/LICENSE +21 -0
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()
|