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.
- texture_generator-0.3.0.dist-info/METADATA +143 -0
- texture_generator-0.3.0.dist-info/RECORD +27 -0
- texture_generator-0.3.0.dist-info/WHEEL +5 -0
- texture_generator-0.3.0.dist-info/entry_points.txt +2 -0
- texture_generator-0.3.0.dist-info/licenses/LICENSE +201 -0
- texture_generator-0.3.0.dist-info/top_level.txt +1 -0
- texture_generators/__init__.py +223 -0
- texture_generators/__main__.py +6 -0
- texture_generators/cli.py +487 -0
- texture_generators/core/__init__.py +1 -0
- texture_generators/core/colour.py +108 -0
- texture_generators/core/fibres.py +603 -0
- texture_generators/core/fields.py +122 -0
- texture_generators/core/film.py +315 -0
- texture_generators/core/lic.py +371 -0
- texture_generators/core/noise.py +200 -0
- texture_generators/core/shading.py +488 -0
- texture_generators/core/spectral.py +227 -0
- texture_generators/core/warp.py +74 -0
- texture_generators/core/worley.py +91 -0
- texture_generators/materials/__init__.py +42 -0
- texture_generators/materials/metal.py +1368 -0
- texture_generators/materials/paper.py +1655 -0
- texture_generators/materials/plastic.py +272 -0
- texture_generators/materials/wood.py +2329 -0
- texture_generators/py.typed +0 -0
- texture_generators/samples.py +439 -0
|
@@ -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)
|