texture-generator 0.3.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.
@@ -0,0 +1,122 @@
1
+ """Scalar-field utilities: normalisation, remapping, smoothstep, height to normal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from PIL import Image, ImageDraw
7
+
8
+ __all__ = [
9
+ "blur",
10
+ "draw_segments",
11
+ "height_to_normal",
12
+ "normalize01",
13
+ "remap",
14
+ "smoothstep",
15
+ ]
16
+
17
+
18
+ def normalize01(a: np.ndarray) -> np.ndarray:
19
+ """Rescale ``a`` to [0, 1]. Constant input maps to 0.5."""
20
+ a = np.asarray(a, dtype=np.float32)
21
+ lo = float(a.min())
22
+ hi = float(a.max())
23
+ if hi - lo < 1e-8:
24
+ return np.full(a.shape, 0.5, dtype=np.float32)
25
+ return ((a - lo) / (hi - lo)).astype(np.float32)
26
+
27
+
28
+ def remap(
29
+ a: np.ndarray,
30
+ in_lo: float,
31
+ in_hi: float,
32
+ out_lo: float = 0.0,
33
+ out_hi: float = 1.0,
34
+ ) -> np.ndarray:
35
+ """Linearly map ``[in_lo, in_hi]`` onto ``[out_lo, out_hi]`` (unclamped)."""
36
+ a = np.asarray(a, dtype=np.float32)
37
+ span = in_hi - in_lo
38
+ if abs(span) < 1e-8:
39
+ return np.full(a.shape, out_lo, dtype=np.float32)
40
+ t = (a - np.float32(in_lo)) / np.float32(span)
41
+ return (np.float32(out_lo) + t * np.float32(out_hi - out_lo)).astype(np.float32)
42
+
43
+
44
+ def smoothstep(edge0: float, edge1: float, x: np.ndarray) -> np.ndarray:
45
+ """Hermite smoothstep: 0 below ``edge0``, 1 above ``edge1``, smooth between."""
46
+ x = np.asarray(x, dtype=np.float32)
47
+ span = edge1 - edge0
48
+ if abs(span) < 1e-8:
49
+ return (x >= edge1).astype(np.float32)
50
+ t = np.clip((x - np.float32(edge0)) / np.float32(span), 0.0, 1.0)
51
+ return (t * t * (3.0 - 2.0 * t)).astype(np.float32)
52
+
53
+
54
+ def height_to_normal(
55
+ h: np.ndarray, strength: float = 1.0, periodic: bool = False
56
+ ) -> np.ndarray:
57
+ """Convert a height field to unit normals, shape (H, W, 3).
58
+
59
+ Uses the standard derivative relation ``n ~ (-s*dH/du, -s*dH/dv, 1)``,
60
+ with gradients from :func:`numpy.gradient` (central differences).
61
+
62
+ ``periodic`` wraps those differences at the edges instead of falling back
63
+ to one-sided ones. Use it when the height field itself is periodic --
64
+ otherwise the edge rows get a different derivative from everywhere else,
65
+ which shows up as a bright or dark rim and breaks tiling.
66
+ """
67
+ h = np.asarray(h, dtype=np.float32)
68
+ if periodic:
69
+ dhdv = (np.roll(h, -1, axis=0) - np.roll(h, 1, axis=0)) * np.float32(0.5)
70
+ dhdu = (np.roll(h, -1, axis=1) - np.roll(h, 1, axis=1)) * np.float32(0.5)
71
+ else:
72
+ dhdv, dhdu = np.gradient(h)
73
+ s = np.float32(strength)
74
+ nx = -s * dhdu
75
+ ny = -s * dhdv
76
+ nz = np.ones_like(h)
77
+ n = np.stack([nx, ny, nz], axis=-1)
78
+ length = np.sqrt((n * n).sum(axis=-1, keepdims=True))
79
+ return (n / np.maximum(length, 1e-8)).astype(np.float32)
80
+
81
+
82
+ def draw_segments(
83
+ shape: tuple[int, int],
84
+ segments,
85
+ width: int = 1,
86
+ ) -> np.ndarray:
87
+ """Rasterise signed line segments onto a float field of ``shape`` (H, W).
88
+
89
+ ``segments`` yields ``(x0, y0, x1, y1, value)`` in pixel coordinates. Uses
90
+ a PIL "F" canvas, so the Python loop runs over segments (hundreds to a few
91
+ thousand) rather than pixels. Overlapping segments overwrite rather than
92
+ accumulate, which is what scratches and fibres want.
93
+ """
94
+ h, w = int(shape[0]), int(shape[1])
95
+ canvas = Image.new("F", (w, h), 0.0)
96
+ draw = ImageDraw.Draw(canvas)
97
+ for x0, y0, x1, y1, value in segments:
98
+ draw.line(
99
+ (float(x0), float(y0), float(x1), float(y1)),
100
+ fill=float(value),
101
+ width=int(width),
102
+ )
103
+ return np.asarray(canvas, dtype=np.float32)
104
+
105
+
106
+ def blur(a: np.ndarray, radius: int = 1, passes: int = 1) -> np.ndarray:
107
+ """Cheap separable box blur (wrap-around), used to soften scatter layers."""
108
+ a = np.asarray(a, dtype=np.float32)
109
+ if radius < 1:
110
+ return a
111
+ k = 2 * radius + 1
112
+ out = a
113
+ for _ in range(max(1, int(passes))):
114
+ acc = np.zeros_like(out)
115
+ for shift in range(-radius, radius + 1):
116
+ acc += np.roll(out, shift, axis=1)
117
+ out = acc / np.float32(k)
118
+ acc = np.zeros_like(out)
119
+ for shift in range(-radius, radius + 1):
120
+ acc += np.roll(out, shift, axis=0)
121
+ out = acc / np.float32(k)
122
+ return out.astype(np.float32)
@@ -0,0 +1,315 @@
1
+ """Thin-film interference colour: a spectrally integrated thickness -> RGB table.
2
+
3
+ A transparent film on a reflective substrate reflects from *both* of its
4
+ surfaces. The two paths differ by an optical path of ``2 * n1 * d * cos(theta1)``,
5
+ which is fixed while the wavelength varies, so the reflectance oscillates in
6
+ ``1 / lambda`` -- and the period of that oscillation **shrinks as the thickness
7
+ grows**. That is why tempered steel runs through a colour ladder as its oxide
8
+ thickens, and why an oil slick is rainbow-coloured at its thin edges and pearly
9
+ grey in the middle: by a micron the fringes are finer than the eye's colour
10
+ matching functions and average away.
11
+
12
+ That shrinking period is also the trap. Evaluating the Airy summation at three
13
+ RGB primaries is confidently, catastrophically wrong above a few hundred
14
+ nanometres: the three samples alias against fringes narrower than the gaps
15
+ between them and return a saturated colour with no relation to what the eye
16
+ would see. So this module **integrates spectrally** -- 36 samples at 10 nm over
17
+ 380-730 nm against the CIE 1931 2-degree observer -- and bakes the result into a
18
+ small lookup table at import time. The fringe spacing at the coarsest case here
19
+ (2 um of oil) is ``lambda^2 / (2*n*d)`` ~ 52 nm, comfortably above the 20 nm
20
+ Nyquist limit of a 10 nm grid, so the desaturation of a thick film is *earned*
21
+ by the integration rather than asserted.
22
+
23
+ Runtime cost is then one bilinear gather per pixel over a (256 thickness x
24
+ 16 angle x 3) table -- 48 KB per optical system.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import numpy as np
30
+
31
+ __all__ = [
32
+ "SYSTEMS",
33
+ "TEMPER_LADDER",
34
+ "FilmSystem",
35
+ "film_table",
36
+ "film_tint",
37
+ "wavelength_rgb",
38
+ ]
39
+
40
+ # CIE 1931 2-degree standard observer, 380-730 nm at 10 nm (36 samples).
41
+ _CMF_START_NM = 380.0
42
+ _CMF_STEP_NM = 10.0
43
+ _CMF = np.asarray(
44
+ [
45
+ [0.001368, 0.000039, 0.006450],
46
+ [0.004243, 0.000120, 0.020050],
47
+ [0.014310, 0.000396, 0.067850],
48
+ [0.043510, 0.001210, 0.207400],
49
+ [0.134380, 0.004000, 0.645600],
50
+ [0.283900, 0.011600, 1.385600],
51
+ [0.348280, 0.023000, 1.747060],
52
+ [0.336200, 0.038000, 1.772110],
53
+ [0.290800, 0.060000, 1.669200],
54
+ [0.195360, 0.090980, 1.287640],
55
+ [0.095640, 0.139020, 0.812950],
56
+ [0.032010, 0.208020, 0.465180],
57
+ [0.004900, 0.323000, 0.272000],
58
+ [0.009300, 0.503000, 0.158200],
59
+ [0.063270, 0.710000, 0.078250],
60
+ [0.165500, 0.862000, 0.042160],
61
+ [0.290400, 0.954000, 0.020300],
62
+ [0.433450, 0.994950, 0.008750],
63
+ [0.594500, 0.995000, 0.003900],
64
+ [0.762100, 0.952000, 0.002100],
65
+ [0.916300, 0.870000, 0.001650],
66
+ [1.026300, 0.757000, 0.001100],
67
+ [1.062200, 0.631000, 0.000800],
68
+ [1.002600, 0.503000, 0.000340],
69
+ [0.854450, 0.381000, 0.000190],
70
+ [0.642400, 0.265000, 0.000050],
71
+ [0.447900, 0.175000, 0.000020],
72
+ [0.283500, 0.107000, 0.000000],
73
+ [0.164900, 0.061000, 0.000000],
74
+ [0.087400, 0.032000, 0.000000],
75
+ [0.046770, 0.017000, 0.000000],
76
+ [0.022700, 0.008210, 0.000000],
77
+ [0.011359, 0.004102, 0.000000],
78
+ [0.005790, 0.002091, 0.000000],
79
+ [0.002899, 0.001047, 0.000000],
80
+ [0.001440, 0.000520, 0.000000],
81
+ ],
82
+ dtype=np.float64,
83
+ )
84
+ _LAMBDA_NM = _CMF_START_NM + _CMF_STEP_NM * np.arange(_CMF.shape[0], dtype=np.float64)
85
+
86
+ # sRGB primaries, D65 (IEC 61966-2-1) -- the same matrix core.colour uses.
87
+ _XYZ_TO_RGB = np.asarray(
88
+ [
89
+ [3.2404542, -1.5371385, -0.4985314],
90
+ [-0.9692660, 1.8760108, 0.0415560],
91
+ [0.0556434, -0.2040259, 1.0572252],
92
+ ],
93
+ dtype=np.float64,
94
+ )
95
+
96
+ N_THICKNESS = 256
97
+ N_ANGLE = 16
98
+ MAX_ANGLE_DEG = 80.0
99
+
100
+ _THETA = np.deg2rad(np.linspace(0.0, MAX_ANGLE_DEG, N_ANGLE))
101
+
102
+
103
+ class FilmSystem:
104
+ """One film-on-substrate optical system: indices and a thickness range.
105
+
106
+ Attributes:
107
+ n_film: real refractive index of the transparent film.
108
+ n_sub: complex refractive index of the substrate (a metal, so ``k`` is
109
+ of the same order as ``n``).
110
+ max_nm: top of the tabulated thickness range, in nanometres.
111
+ """
112
+
113
+ __slots__ = ("max_nm", "n_film", "n_sub", "name", "note")
114
+
115
+ def __init__(
116
+ self,
117
+ name: str,
118
+ n_film: float,
119
+ n_sub: complex,
120
+ max_nm: float,
121
+ note: str = "",
122
+ ) -> None:
123
+ self.name = name
124
+ self.n_film = float(n_film)
125
+ self.n_sub = complex(n_sub)
126
+ self.max_nm = float(max_nm)
127
+ self.note = note
128
+
129
+
130
+ # UNVERIFIED domain knowledge: the film indices below are representative
131
+ # literature values, not measurements of any particular specimen.
132
+ # - Steel tempering oxide is a mixed Fe3O4/Fe2O3 scale; the effective index of
133
+ # the *interference-relevant* transparent layer is quoted anywhere from 1.8
134
+ # to 2.6. 1.90 is chosen here because it puts the ladder's landmarks (purple
135
+ # ~95 nm, royal blue ~130 nm) where the tempering table below wants them.
136
+ # - Oil / diesel on steel: n ~ 1.45-1.50.
137
+ # - Anodised titanium / niobium barrier oxide (TiO2 / Nb2O5): n ~ 2.3-2.5.
138
+ # NOTE on aluminium: coloured anodised aluminium is NOT interference. Its film
139
+ # is 5-25 um of *porous* oxide -- three orders of magnitude too thick to
140
+ # interfere -- and the colour is dye held in the pores. It is deliberately not
141
+ # modelled here.
142
+ SYSTEMS = {
143
+ "oxide": FilmSystem("oxide", 1.90, 2.90 + 3.20j, 450.0, "steel tempering scale"),
144
+ "oil": FilmSystem("oil", 1.47, 2.90 + 3.20j, 2200.0, "oil / fuel film on steel"),
145
+ "titania": FilmSystem("titania", 2.40, 2.60 + 3.30j, 300.0, "anodised Ti / Nb"),
146
+ }
147
+
148
+ # Steel tempering ladder -- UNVERIFIED domain knowledge (workshop tables), kept
149
+ # here because the thickness field generators quote it. Its endpoints are
150
+ # consistent with the VERIFIED result that an interference film on steel runs
151
+ # brown -> blue -> gold -> purple -> green as thickness goes 80 -> 400 nm
152
+ # (Surface & Coatings Technology, S0257897206003495), and with the VERIFIED
153
+ # fact that steel tempering colours occur over 200-400 degrees C.
154
+ TEMPER_LADDER = (
155
+ ("pale yellow", 200, 40),
156
+ ("light straw", 220, 50),
157
+ ("dark straw", 240, 60),
158
+ ("brown", 255, 70),
159
+ ("brown-purple", 265, 80),
160
+ ("purple", 280, 95),
161
+ ("dark blue", 290, 110),
162
+ ("royal blue", 300, 130),
163
+ ("pale grey-blue", 330, 150),
164
+ )
165
+
166
+
167
+ def _fresnel(
168
+ n_i: np.ndarray, cos_i: np.ndarray, n_t: np.ndarray, cos_t: np.ndarray
169
+ ) -> tuple[np.ndarray, np.ndarray]:
170
+ """Complex amplitude reflection coefficients ``(r_s, r_p)`` at one interface."""
171
+ r_s = (n_i * cos_i - n_t * cos_t) / (n_i * cos_i + n_t * cos_t)
172
+ r_p = (n_t * cos_i - n_i * cos_t) / (n_t * cos_i + n_i * cos_t)
173
+ return r_s, r_p
174
+
175
+
176
+ def _spectral_reflectance(
177
+ system: FilmSystem, thickness_nm: np.ndarray, theta: np.ndarray
178
+ ) -> np.ndarray:
179
+ """Unpolarised reflectance ``R[thickness, angle, wavelength]`` via Airy.
180
+
181
+ Three media -- air, the transparent film, the (absorbing) metal -- so the
182
+ two-interface Airy summation is exact, with complex refraction angles from
183
+ Snell's law and the s/p average that unpolarised illumination implies.
184
+ """
185
+ d = np.asarray(thickness_nm, dtype=np.float64)[:, None, None]
186
+ th = np.asarray(theta, dtype=np.float64)[None, :, None]
187
+ lam = _LAMBDA_NM[None, None, :]
188
+
189
+ n0 = np.complex128(1.0)
190
+ n1 = np.complex128(system.n_film)
191
+ n2 = np.complex128(system.n_sub)
192
+
193
+ sin0 = np.sin(th).astype(np.complex128)
194
+ cos0 = np.cos(th).astype(np.complex128)
195
+ cos1 = np.sqrt(1.0 - (n0 * sin0 / n1) ** 2)
196
+ cos2 = np.sqrt(1.0 - (n0 * sin0 / n2) ** 2)
197
+
198
+ r01_s, r01_p = _fresnel(n0, cos0, n1, cos1)
199
+ r12_s, r12_p = _fresnel(n1, cos1, n2, cos2)
200
+
201
+ # Round-trip phase through the film. This is the 1/lambda that everything
202
+ # else follows from.
203
+ beta = 2.0 * np.pi * n1 * d * cos1 / lam
204
+ phase = np.exp(2.0j * beta)
205
+
206
+ out = 0.0
207
+ for r01, r12 in ((r01_s, r12_s), (r01_p, r12_p)):
208
+ r = (r01 + r12 * phase) / (1.0 + r01 * r12 * phase)
209
+ out = out + np.abs(r) ** 2
210
+ return (0.5 * out).astype(np.float64)
211
+
212
+
213
+ def wavelength_rgb(lam_nm: np.ndarray | float) -> np.ndarray:
214
+ """Linear sRGB of a **monochromatic** wavelength, from the observer above.
215
+
216
+ The spectral locus lies outside the sRGB gamut, so the negative lobes are
217
+ clipped: what survives is the most saturated in-gamut colour with that hue,
218
+ which is the honest answer for a display. Normalised by the peak of ``ybar``,
219
+ so a 555 nm ray comes out at luminance ~1 and the caller's weight is the only
220
+ scale factor.
221
+
222
+ This is the *diffraction* counterpart to :func:`film_tint`. A film mixes the
223
+ whole spectrum and needs the integral; a grating sends one wavelength per
224
+ direction and needs a single point of the same table.
225
+
226
+ Args:
227
+ lam_nm: wavelength(s) in nanometres. Values outside 380-730 nm clamp to
228
+ the ends of the table (both of which are nearly black anyway), so
229
+ callers are expected to mask their own visible band.
230
+
231
+ Returns:
232
+ Float32 array of shape ``lam_nm.shape + (3,)``, linear light, >= 0.
233
+ """
234
+ lam = np.asarray(lam_nm, dtype=np.float64)
235
+ xyz = np.stack(
236
+ [np.interp(lam, _LAMBDA_NM, _CMF[:, i]) for i in range(3)], axis=-1
237
+ ) / float(_CMF[:, 1].max())
238
+ return np.clip(xyz @ _XYZ_TO_RGB.T, 0.0, None).astype(np.float32)
239
+
240
+
241
+ def film_table(system: FilmSystem) -> np.ndarray:
242
+ """Build the ``(N_THICKNESS, N_ANGLE, 3)`` **linear** sRGB table for ``system``.
243
+
244
+ Row 0 is the zero-thickness reference (bare substrate seen through a film of
245
+ no depth), which is what :func:`film_tint` divides by so the result is a
246
+ multiplicative tint that is exactly neutral where there is no film.
247
+ """
248
+ thickness = np.linspace(0.0, system.max_nm, N_THICKNESS)
249
+ refl = _spectral_reflectance(system, thickness, _THETA)
250
+ # Equal-energy illuminant: the substrate's own colour is divided out below,
251
+ # so what survives is the interference, not a lamp.
252
+ xyz = refl @ _CMF
253
+ xyz = xyz / _CMF[:, 1].sum()
254
+ rgb = xyz @ _XYZ_TO_RGB.T
255
+ return np.clip(rgb, 0.0, None).astype(np.float32)
256
+
257
+
258
+ _TABLES = {name: film_table(sys) for name, sys in SYSTEMS.items()}
259
+
260
+
261
+ def table_for(system: str) -> np.ndarray:
262
+ """The prebuilt table for a named system in :data:`SYSTEMS`."""
263
+ try:
264
+ return _TABLES[system]
265
+ except KeyError:
266
+ raise ValueError(
267
+ f"unknown film system {system!r}; choose from {sorted(SYSTEMS)}"
268
+ ) from None
269
+
270
+
271
+ def film_tint(
272
+ system: str,
273
+ thickness_nm: np.ndarray,
274
+ cos_theta: np.ndarray | float = 1.0,
275
+ ) -> np.ndarray:
276
+ """Look up the linear-RGB tint for a thickness field, relative to no film.
277
+
278
+ Args:
279
+ system: key into :data:`SYSTEMS`.
280
+ thickness_nm: (H, W) film thickness in nanometres. Values above the
281
+ system's tabulated range are clamped (a film that thick is neutral
282
+ anyway, which is the whole point of integrating spectrally).
283
+ cos_theta: (H, W) or scalar cosine of the incidence angle.
284
+
285
+ Returns:
286
+ (H, W, 3) float32 multiplier in linear light, ~1 where the film is
287
+ absent and swinging above and below 1 per channel as it thickens.
288
+ """
289
+ table = table_for(system)
290
+ sysdef = SYSTEMS[system]
291
+
292
+ d = np.clip(np.asarray(thickness_nm, dtype=np.float32), 0.0, sysdef.max_nm)
293
+ ti = d * np.float32((N_THICKNESS - 1) / sysdef.max_nm)
294
+
295
+ theta = np.arccos(np.clip(np.asarray(cos_theta, dtype=np.float32), 0.0, 1.0))
296
+ ai = np.clip(
297
+ theta * np.float32((N_ANGLE - 1) / _THETA[-1]), 0.0, np.float32(N_ANGLE - 1)
298
+ )
299
+ ai = np.broadcast_to(ai, d.shape)
300
+
301
+ # Bilinear gather, written out because the table is tiny and np.interp is
302
+ # 1-D only. Two lerps, no per-pixel Airy summation anywhere.
303
+ t0 = np.floor(ti).astype(np.int32)
304
+ t1 = np.minimum(t0 + 1, N_THICKNESS - 1)
305
+ tf = (ti - t0)[..., None]
306
+ a0 = np.floor(ai).astype(np.int32)
307
+ a1 = np.minimum(a0 + 1, N_ANGLE - 1)
308
+ af = (ai - a0)[..., None]
309
+
310
+ top = table[t0, a0] * (1.0 - af) + table[t0, a1] * af
311
+ bot = table[t1, a0] * (1.0 - af) + table[t1, a1] * af
312
+ value = top * (1.0 - tf) + bot * tf
313
+
314
+ ref = table[0, a0] * (1.0 - af) + table[0, a1] * af
315
+ return np.clip(value / np.maximum(ref, 1e-6), 0.0, 3.0).astype(np.float32)