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,603 @@
|
|
|
1
|
+
"""Stochastic fibre networks: deposit many curved fibres and accumulate mass.
|
|
2
|
+
|
|
3
|
+
A sheet of paper is a *stack* of fibres, not a background with lines drawn on
|
|
4
|
+
it. Total fibre length per unit area is grammage / coarseness, so 80 g/m^2
|
|
5
|
+
office paper carries roughly 440 mm of fibre per mm^2 -- with a 30 um fibre
|
|
6
|
+
that is about 13 layers of coverage. Drawing a few hundred opaque segments on
|
|
7
|
+
an empty ground gives coverage well below 1, which is why sparse line marks
|
|
8
|
+
read as scratches rather than as felt: they are isolated, they are opaque,
|
|
9
|
+
and each one is several times too wide.
|
|
10
|
+
|
|
11
|
+
This module fixes all three at once:
|
|
12
|
+
|
|
13
|
+
* **Count from coverage.** :func:`count_for_coverage` turns a target coverage
|
|
14
|
+
into a fibre count, so the density is a physical quantity rather than a
|
|
15
|
+
magic number.
|
|
16
|
+
* **Sub-pixel width.** A fibre 30 um wide is 0.15--1.0 px across at any
|
|
17
|
+
sensible capture scale, so it must contribute *partial* coverage.
|
|
18
|
+
Accumulating bilinearly-splatted mass does that exactly; rasterising a
|
|
19
|
+
1-px-wide opaque line cannot.
|
|
20
|
+
* **Curvature.** Fibre centrelines follow a 2-D worm-like chain -- heading
|
|
21
|
+
performs a random walk with variance ``2*ds/persistence`` per step -- plus
|
|
22
|
+
a Poisson process of sharp kinks. Measured curl indices put the persistence
|
|
23
|
+
length at roughly 0.7--2 fibre lengths, which bends a fibre visibly without
|
|
24
|
+
coiling it.
|
|
25
|
+
|
|
26
|
+
Everything is vectorised across fibres: one ``(F, S)`` array of headings, one
|
|
27
|
+
``cumsum``, one ``bincount`` per bilinear corner. Coordinates wrap, so the
|
|
28
|
+
fields tile.
|
|
29
|
+
|
|
30
|
+
Three further facts shape what a *single* fibre looks like, and all three are
|
|
31
|
+
sub-pixel, which is what decides how they must be implemented:
|
|
32
|
+
|
|
33
|
+
* **External fibrillation is opacity, not geometry.** Refining peels microfibril
|
|
34
|
+
bundles off the outer wall; they are 50 nm - 1 um wide (mode 0.1-0.5 um), and
|
|
35
|
+
whole peeled S1 ribbons only reach 1-5 um. At 25.6 px/mm a pixel is 39 um and
|
|
36
|
+
even a 2000 px render of a 20 mm crop is 10 um per pixel, so *every fibril is
|
|
37
|
+
far narrower than one pixel at every size this library renders*. Drawing them
|
|
38
|
+
as polylines would put sub-pixel objects on the raster as pixel-scale marks --
|
|
39
|
+
the same category error that made early procedural paper read as scratches --
|
|
40
|
+
so they are a coverage contribution instead: :func:`deposit` splats a soft
|
|
41
|
+
anisotropic fringe just outside each fibre's own edge, which composites
|
|
42
|
+
through the same Beer-Lambert ``over`` as the fibre and comes out as a hazy
|
|
43
|
+
fibre edge and inter-fibre webbing. It is parameterised the way a fibre
|
|
44
|
+
analyser reports it, as a **fibrillation index** -- fibril projected area over
|
|
45
|
+
parent-fibre projected area.
|
|
46
|
+
* **A fibre end is not a point.** Two populations: a *native* tracheid end
|
|
47
|
+
tapers over its last 150-500 um to a blunt tip 30-60% of mid-fibre width and
|
|
48
|
+
stops there, and a *cut* end (refining; 20-50% of ends) does not taper at all
|
|
49
|
+
-- the S2 fails along its microfibril helix, so the break frays into a brush
|
|
50
|
+
of 3-10 ribbons reaching 20-100 um past it. The ribbons are 0.5-3 um wide, so
|
|
51
|
+
they are a fanned opacity plume rather than drawn strands, for the reason
|
|
52
|
+
above.
|
|
53
|
+
* **Projected width varies along a fibre**, because collapse is intermittent:
|
|
54
|
+
an uncollapsed softwood tracheid presents ~20-30 um and a collapsed one
|
|
55
|
+
~25-45 um (a flattened ribbon is wider than the tube it was), and thick-walled
|
|
56
|
+
latewood resists collapse where thin-walled earlywood gives way. The variation
|
|
57
|
+
is therefore *correlated over hundreds of micrometres*, not white noise, which
|
|
58
|
+
is the visually load-bearing part.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
from __future__ import annotations
|
|
62
|
+
|
|
63
|
+
import numpy as np
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"axial_von_mises",
|
|
67
|
+
"cluster_points",
|
|
68
|
+
"count_for_coverage",
|
|
69
|
+
"deposit",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
# Splatting more than this many points at once costs more in temporaries than
|
|
73
|
+
# the chunking overhead saves.
|
|
74
|
+
_CHUNK = 1 << 21
|
|
75
|
+
|
|
76
|
+
# Native end taper length as a fraction of fibre length, for callers that do not
|
|
77
|
+
# state one in pixels: 0.22 of a 1.2 mm fibre is ~260 um, mid-band of the
|
|
78
|
+
# 150-500 um a softwood tracheid tapers over.
|
|
79
|
+
# UNVERIFIED: the 150-500 um and the 30-60% tip below are domain knowledge --
|
|
80
|
+
# no source in the research behind this module reports them.
|
|
81
|
+
_TAPER_FRAC = 0.22
|
|
82
|
+
_TIP_FRAC = (0.30, 0.60)
|
|
83
|
+
|
|
84
|
+
# Microfibril angle of the wall layer a fibril was peeled from, in degrees to the
|
|
85
|
+
# fibre axis. **The layer angles are textbook** -- S1 is an outer crossed helix
|
|
86
|
+
# at 50-90 degrees and is removed first by refining, S2 is the bulk at 5-20
|
|
87
|
+
# degrees (20-50 in juvenile or earlywood) and is exposed as refining goes on.
|
|
88
|
+
# **The two-mode mixture and its weights are inference from those angles, not a
|
|
89
|
+
# measured distribution.**
|
|
90
|
+
_S1_DEG = (45.0, 75.0)
|
|
91
|
+
_S2_DEG = (10.0, 25.0)
|
|
92
|
+
_S1_WEIGHT = 0.3
|
|
93
|
+
# Once free and dried a fibril lies back along the fibre, so the angle that
|
|
94
|
+
# shows in a micrograph is smaller than the angle it left the wall at: the
|
|
95
|
+
# reported apparent range is 10-40 degrees, which this factor lands the mixture
|
|
96
|
+
# in. Also inference.
|
|
97
|
+
_LAY_BACK = 0.55
|
|
98
|
+
|
|
99
|
+
# Cut-end brush: ribbons per end, and samples along each ribbon. The brush is
|
|
100
|
+
# 3-10 ribbons; six is the middle of that, and four samples over a 20-100 um
|
|
101
|
+
# reach is finer than a pixel at any size rendered here.
|
|
102
|
+
_BRUSH_RIBBONS = 6
|
|
103
|
+
_BRUSH_SAMPLES = 4
|
|
104
|
+
# Projected area of one brush relative to its parent fibre, per unit of
|
|
105
|
+
# fibrillation index: six 60 um ribbons at 1.5 um wide is ~540 um^2 against a
|
|
106
|
+
# 1.15 mm x 25 um fibre's ~29,000 um^2, i.e. ~2% at the 0.035 fibrillation of a
|
|
107
|
+
# normal printing furnish. Tying it to the index rather than fixing it is the
|
|
108
|
+
# physical statement that refining frays ends and peels fibrils together.
|
|
109
|
+
#
|
|
110
|
+
# The brush comes *out of* the index rather than on top of it: a fibre analyser
|
|
111
|
+
# measures fibril area projecting from the fibre and does not care whether a
|
|
112
|
+
# given fibril is rooted on the wall or on a frayed end, so both populations
|
|
113
|
+
# share one budget and :func:`deposit` gives the wall fringe whatever the ends
|
|
114
|
+
# leave. Adding the brush on top instead -- which is what this used to do --
|
|
115
|
+
# meant ``fibrillation = 0.060`` actually deposited 9.49% of extra mass, so the
|
|
116
|
+
# key did not mean what its name and docstring said.
|
|
117
|
+
_BRUSH_AREA_PER_INDEX = 0.6
|
|
118
|
+
|
|
119
|
+
# Standard deviation of smoothstep-interpolated unit-variance knots. For
|
|
120
|
+
# h(u) = 3u^2 - 2u^3 the interpolant has variance 1 - 2E[h] + 2E[h^2] = 0.742857
|
|
121
|
+
# averaged over the cell, so dividing by this makes the profile unit-variance
|
|
122
|
+
# without a reduction pass over the array.
|
|
123
|
+
_SMOOTHSTEP_SD = 0.861892
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def count_for_coverage(
|
|
127
|
+
shape: tuple[int, int],
|
|
128
|
+
coverage: float,
|
|
129
|
+
length_px: float,
|
|
130
|
+
width_px: float,
|
|
131
|
+
) -> int:
|
|
132
|
+
"""How many fibres of this size are needed to cover the canvas ``coverage`` times.
|
|
133
|
+
|
|
134
|
+
Coverage is the mean number of fibre layers over a point: area of canvas
|
|
135
|
+
times coverage, divided by the area of one fibre.
|
|
136
|
+
"""
|
|
137
|
+
if float(coverage) <= 0.0:
|
|
138
|
+
return 0
|
|
139
|
+
h, w = int(shape[0]), int(shape[1])
|
|
140
|
+
per_fibre = max(float(length_px) * float(width_px), 1e-6)
|
|
141
|
+
return max(
|
|
142
|
+
1,
|
|
143
|
+
round(h * w * float(coverage) / per_fibre),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def axial_von_mises(
|
|
148
|
+
rng: np.random.Generator, size: int, mu: float, kappa: float
|
|
149
|
+
) -> np.ndarray:
|
|
150
|
+
"""Sample ``size`` orientations from the axial distribution p(t) ~ exp(k cos 2(t-mu)).
|
|
151
|
+
|
|
152
|
+
Fibre direction is an axis, not an arrow: theta and theta+pi are the same
|
|
153
|
+
fibre. Sampling a von Mises in the doubled angle and halving it gives
|
|
154
|
+
exactly that density. ``kappa`` is small for paper -- measured fibre
|
|
155
|
+
orientation distributions have a peak-to-trough ratio of only 1.5--4,
|
|
156
|
+
i.e. kappa between 0.2 and 0.7. Large kappa produces parallel hatching,
|
|
157
|
+
which no paper looks like.
|
|
158
|
+
"""
|
|
159
|
+
if kappa <= 1e-6:
|
|
160
|
+
return rng.uniform(0.0, np.pi, size=size).astype(np.float32)
|
|
161
|
+
doubled = rng.vonmises(0.0, float(kappa), size=size)
|
|
162
|
+
return (float(mu) + 0.5 * doubled).astype(np.float32)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cluster_points(
|
|
166
|
+
shape: tuple[int, int],
|
|
167
|
+
rng: np.random.Generator,
|
|
168
|
+
count: int,
|
|
169
|
+
*,
|
|
170
|
+
clustered: float = 0.6,
|
|
171
|
+
parent_spacing_px: float = 60.0,
|
|
172
|
+
spread_px: float = 18.0,
|
|
173
|
+
aniso: float = 1.0,
|
|
174
|
+
angle: float = 0.0,
|
|
175
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
176
|
+
"""Positions from a Matern cluster (Neyman-Scott) process mixed with uniform.
|
|
177
|
+
|
|
178
|
+
Fibres arrive at the wire in flocs, and flocculation is what formation
|
|
179
|
+
measures. Generating clustered *positions* makes formation emerge from
|
|
180
|
+
the fibre layer instead of being painted on top as an independent cloud,
|
|
181
|
+
so the two agree by construction.
|
|
182
|
+
|
|
183
|
+
``clustered`` is the fraction of fibres drawn around parents; the rest are
|
|
184
|
+
uniform. Pure clustering looks blotchy, so keep it below ~0.75.
|
|
185
|
+
"""
|
|
186
|
+
h, w = int(shape[0]), int(shape[1])
|
|
187
|
+
n = int(count)
|
|
188
|
+
n_clustered = round(n * float(np.clip(clustered, 0.0, 1.0)))
|
|
189
|
+
n_uniform = n - n_clustered
|
|
190
|
+
|
|
191
|
+
xs = [rng.uniform(0.0, w, size=n_uniform).astype(np.float32)]
|
|
192
|
+
ys = [rng.uniform(0.0, h, size=n_uniform).astype(np.float32)]
|
|
193
|
+
|
|
194
|
+
if n_clustered > 0:
|
|
195
|
+
n_parents = max(
|
|
196
|
+
1,
|
|
197
|
+
round(h * w / max(parent_spacing_px**2, 1.0)),
|
|
198
|
+
)
|
|
199
|
+
px = rng.uniform(0.0, w, size=n_parents).astype(np.float32)
|
|
200
|
+
py = rng.uniform(0.0, h, size=n_parents).astype(np.float32)
|
|
201
|
+
which = rng.integers(0, n_parents, size=n_clustered)
|
|
202
|
+
# Flocs are sheared along the machine direction, so the offspring
|
|
203
|
+
# cloud is an ellipse rather than a disc.
|
|
204
|
+
ox = rng.normal(0.0, spread_px * float(aniso), size=n_clustered)
|
|
205
|
+
oy = rng.normal(0.0, spread_px, size=n_clustered)
|
|
206
|
+
ca, sa = np.cos(angle), np.sin(angle)
|
|
207
|
+
xs.append((px[which] + ox * ca - oy * sa).astype(np.float32))
|
|
208
|
+
ys.append((py[which] + ox * sa + oy * ca).astype(np.float32))
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
np.concatenate(xs).astype(np.float32) % np.float32(w),
|
|
212
|
+
np.concatenate(ys).astype(np.float32) % np.float32(h),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _splat(
|
|
217
|
+
shape: tuple[int, int], x: np.ndarray, y: np.ndarray, weight: np.ndarray
|
|
218
|
+
) -> np.ndarray:
|
|
219
|
+
"""Bilinearly accumulate ``weight`` at fractional positions, wrapping at the edges.
|
|
220
|
+
|
|
221
|
+
``np.bincount`` is used rather than ``np.add.at``: both accumulate
|
|
222
|
+
duplicates correctly, but bincount is roughly an order of magnitude
|
|
223
|
+
faster, which matters at tens of thousands of fibres.
|
|
224
|
+
"""
|
|
225
|
+
h, w = int(shape[0]), int(shape[1])
|
|
226
|
+
out = np.zeros(h * w, dtype=np.float64)
|
|
227
|
+
total = x.size
|
|
228
|
+
for start in range(0, total, _CHUNK):
|
|
229
|
+
stop = min(start + _CHUNK, total)
|
|
230
|
+
xc = np.mod(x[start:stop], w)
|
|
231
|
+
yc = np.mod(y[start:stop], h)
|
|
232
|
+
wt = weight[start:stop]
|
|
233
|
+
x0 = np.floor(xc).astype(np.int64)
|
|
234
|
+
y0 = np.floor(yc).astype(np.int64)
|
|
235
|
+
fx = (xc - x0).astype(np.float64)
|
|
236
|
+
fy = (yc - y0).astype(np.float64)
|
|
237
|
+
x0 %= w
|
|
238
|
+
y0 %= h
|
|
239
|
+
x1 = (x0 + 1) % w
|
|
240
|
+
y1 = (y0 + 1) % h
|
|
241
|
+
rows0 = y0 * w
|
|
242
|
+
rows1 = y1 * w
|
|
243
|
+
for idx, frac in (
|
|
244
|
+
(rows0 + x0, (1.0 - fx) * (1.0 - fy)),
|
|
245
|
+
(rows0 + x1, fx * (1.0 - fy)),
|
|
246
|
+
(rows1 + x0, (1.0 - fx) * fy),
|
|
247
|
+
(rows1 + x1, fx * fy),
|
|
248
|
+
):
|
|
249
|
+
out += np.bincount(idx, weights=frac * wt, minlength=h * w)
|
|
250
|
+
return out.reshape(h, w).astype(np.float32)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _correlated_profile(
|
|
254
|
+
rng: np.random.Generator, n: int, steps: int, corr_steps: float
|
|
255
|
+
) -> np.ndarray:
|
|
256
|
+
"""``(n, steps)`` zero-mean unit-variance noise, correlated along axis 1.
|
|
257
|
+
|
|
258
|
+
Along-fibre width variation is generated by intermittent collapse, so it is
|
|
259
|
+
correlated over a *collapse domain* -- hundreds of micrometres -- and white
|
|
260
|
+
noise at that amplitude would read as a beaded fibre instead of a fibre that
|
|
261
|
+
is fat in places. Independent knots every ``corr_steps`` samples, smoothstep
|
|
262
|
+
interpolated between them, give that correlation for one gather and two
|
|
263
|
+
multiplies per sample; an FFT along the step axis would cost several times
|
|
264
|
+
as much for a profile nothing measures the spectrum of.
|
|
265
|
+
"""
|
|
266
|
+
corr = max(float(corr_steps), 1.0)
|
|
267
|
+
knots = int(np.ceil(steps / corr)) + 2
|
|
268
|
+
k = rng.standard_normal((n, knots)).astype(np.float32)
|
|
269
|
+
pos = np.arange(steps, dtype=np.float32) / np.float32(corr)
|
|
270
|
+
i0 = np.floor(pos).astype(np.int64)
|
|
271
|
+
u = pos - i0.astype(np.float32)
|
|
272
|
+
hu = (u * u * (3.0 - 2.0 * u))[None, :]
|
|
273
|
+
prof = k[:, i0] * (np.float32(1.0) - hu) + k[:, i0 + 1] * hu
|
|
274
|
+
return (prof / np.float32(_SMOOTHSTEP_SD)).astype(np.float32)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _fibril_angles(rng: np.random.Generator, size: int) -> np.ndarray:
|
|
278
|
+
"""Apparent divergence angles of external fibrils, in radians.
|
|
279
|
+
|
|
280
|
+
A peeled fibril leaves the wall at the microfibril angle of the layer it came
|
|
281
|
+
from, and those layer angles are textbook (see :data:`_S1_DEG`). The mixture
|
|
282
|
+
weights and the dried lay-back factor are inference from them rather than a
|
|
283
|
+
measured distribution -- flagged where they are defined.
|
|
284
|
+
"""
|
|
285
|
+
s1 = rng.random(size) < _S1_WEIGHT
|
|
286
|
+
lo = np.where(s1, _S1_DEG[0], _S2_DEG[0])
|
|
287
|
+
hi = np.where(s1, _S1_DEG[1], _S2_DEG[1])
|
|
288
|
+
deg = lo + (hi - lo) * rng.random(size)
|
|
289
|
+
return np.deg2rad(deg * _LAY_BACK).astype(np.float32)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def deposit(
|
|
293
|
+
shape: tuple[int, int],
|
|
294
|
+
rng: np.random.Generator,
|
|
295
|
+
*,
|
|
296
|
+
count: int,
|
|
297
|
+
length_px: float,
|
|
298
|
+
width_px: float,
|
|
299
|
+
length_sigma: float = 0.6,
|
|
300
|
+
theta_mu: float = 0.0,
|
|
301
|
+
kappa: float = 0.3,
|
|
302
|
+
persistence: float = 1.2,
|
|
303
|
+
kinks_per_length: float = 3.0,
|
|
304
|
+
kink_deg: float = 25.0,
|
|
305
|
+
step_px: float = 2.5,
|
|
306
|
+
max_steps: int = 96,
|
|
307
|
+
positions: tuple[np.ndarray, np.ndarray] | None = None,
|
|
308
|
+
mass_jitter: float = 0.35,
|
|
309
|
+
width_cv: float = 0.0,
|
|
310
|
+
width_corr_px: float = 0.0,
|
|
311
|
+
taper_px: float | None = None,
|
|
312
|
+
cut_ends: float = 0.0,
|
|
313
|
+
fibrillation: float = 0.0,
|
|
314
|
+
fringe_px: float = 0.0,
|
|
315
|
+
brush_px: float = 0.0,
|
|
316
|
+
) -> np.ndarray:
|
|
317
|
+
"""Deposit ``count`` curved fibres and return the accumulated mass field.
|
|
318
|
+
|
|
319
|
+
The returned field is in coverage units -- its mean is the mean number of
|
|
320
|
+
fibre layers over a pixel. The end taper removes a fixed fraction of each
|
|
321
|
+
fibre's mass, so a count from :func:`count_for_coverage` lands somewhat below
|
|
322
|
+
its nominal coverage rather than exactly on it -- how far below depends on
|
|
323
|
+
``taper_px`` and ``cut_ends``, since a cut end does not taper at all.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
shape: ``(H, W)``.
|
|
327
|
+
rng: seeded generator.
|
|
328
|
+
count: number of fibres; see :func:`count_for_coverage`.
|
|
329
|
+
length_px: median fibre length in pixels (lognormal median).
|
|
330
|
+
width_px: median fibre width in pixels. Values below 1 are the normal
|
|
331
|
+
case and are handled exactly -- the fibre contributes partial
|
|
332
|
+
coverage rather than a thin opaque line.
|
|
333
|
+
length_sigma: lognormal sigma of length. 0.6 matches measured pulp
|
|
334
|
+
length distributions; 0.7+ for mixed recycled furnish.
|
|
335
|
+
theta_mu: machine direction in radians.
|
|
336
|
+
kappa: axial von Mises concentration; see :func:`axial_von_mises`.
|
|
337
|
+
persistence: persistence length as a multiple of fibre length. 0.7--2
|
|
338
|
+
spans never-dried chemical pulp through recycled.
|
|
339
|
+
kinks_per_length: expected sharp kinks per fibre length.
|
|
340
|
+
kink_deg: standard deviation of the kink angle in degrees.
|
|
341
|
+
step_px: arclength per polyline step. **Must be <= 1 px**: samples are
|
|
342
|
+
splatted bilinearly, so a footprint spans one pixel either side of
|
|
343
|
+
the sample and consecutive samples stop overlapping above that.
|
|
344
|
+
At 2.5 px a "fibre" is a string of disconnected beads. Values
|
|
345
|
+
above 1.0 are clamped.
|
|
346
|
+
max_steps: cap on steps per fibre; lengths are clipped to match.
|
|
347
|
+
positions: optional ``(x, y)`` start points, e.g. from
|
|
348
|
+
:func:`cluster_points`. Uniform if omitted.
|
|
349
|
+
mass_jitter: lognormal sigma on per-fibre mass (coarseness varies).
|
|
350
|
+
width_cv: coefficient of variation of projected width *along* one fibre,
|
|
351
|
+
from intermittent collapse. 0.15-0.25 is the plausible range and
|
|
352
|
+
**is unverified**: analysers report a *population* width
|
|
353
|
+
distribution, which is a different quantity. 0 disables it.
|
|
354
|
+
width_corr_px: correlation length of that profile -- the size of a
|
|
355
|
+
collapse domain, 200-500 um, also unverified. Both must be set for
|
|
356
|
+
the profile to apply; white-noise width variation is wrong.
|
|
357
|
+
taper_px: length of the *native* end taper. Defaults to
|
|
358
|
+
:data:`_TAPER_FRAC` of the fibre's own length. The taper runs to a
|
|
359
|
+
blunt tip of 30-60% of mid-fibre width, **not** to zero: a softwood
|
|
360
|
+
tracheid end is tapered, closed and bluntly pointed.
|
|
361
|
+
cut_ends: fraction of ends that are refining cuts rather than native
|
|
362
|
+
tips, 0.2-0.5 in a refined furnish. A cut end does not taper, and
|
|
363
|
+
frays into a brush if ``brush_px`` is set.
|
|
364
|
+
fibrillation: fibrillation index -- fibril projected area over parent
|
|
365
|
+
fibre projected area, so this fraction of each fibre's mass is
|
|
366
|
+
splatted again as external fibril. 0.01 unrefined sack kraft, 0.035 a
|
|
367
|
+
normal printing furnish, 0.06 heavily worked recycled. It is the
|
|
368
|
+
**total** budget: the cut-end brushes take their share of it (see
|
|
369
|
+
:data:`_BRUSH_AREA_PER_INDEX`) and the wall fringe gets the rest, so
|
|
370
|
+
the extra mass deposited is the index and not more.
|
|
371
|
+
fringe_px: how far a fibril reaches from the wall, i.e. the fringe band
|
|
372
|
+
width, 2-8 um. Needed as well as ``fibrillation`` for any fringe.
|
|
373
|
+
brush_px: how far a cut end's brush reaches past the break, 20-100 um.
|
|
374
|
+
Needs ``cut_ends`` and ``fibrillation`` as well.
|
|
375
|
+
"""
|
|
376
|
+
h, w = int(shape[0]), int(shape[1])
|
|
377
|
+
n = int(count)
|
|
378
|
+
if n <= 0:
|
|
379
|
+
return np.zeros((h, w), dtype=np.float32)
|
|
380
|
+
|
|
381
|
+
# Bilinear splatting spreads a sample one pixel either side, so samples
|
|
382
|
+
# must be at most a pixel apart or the fibre breaks into beads.
|
|
383
|
+
step = float(np.clip(step_px, 0.25, 1.0))
|
|
384
|
+
lengths = rng.lognormal(np.log(max(float(length_px), 1e-3)), float(length_sigma), n)
|
|
385
|
+
lengths = np.clip(lengths, step, step * max_steps).astype(np.float32)
|
|
386
|
+
|
|
387
|
+
steps = int(np.ceil(float(lengths.max()) / step))
|
|
388
|
+
steps = int(np.clip(steps, 2, max_steps))
|
|
389
|
+
|
|
390
|
+
if positions is None:
|
|
391
|
+
x0 = rng.uniform(0.0, w, size=n).astype(np.float32)
|
|
392
|
+
y0 = rng.uniform(0.0, h, size=n).astype(np.float32)
|
|
393
|
+
else:
|
|
394
|
+
x0 = np.asarray(positions[0], dtype=np.float32)[:n]
|
|
395
|
+
y0 = np.asarray(positions[1], dtype=np.float32)[:n]
|
|
396
|
+
if x0.size < n or y0.size < n:
|
|
397
|
+
raise ValueError(
|
|
398
|
+
f"positions supply {min(x0.size, y0.size)} points for {n} fibres"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
theta0 = axial_von_mises(rng, n, theta_mu, kappa)
|
|
402
|
+
|
|
403
|
+
# Worm-like chain: heading variance per step is 2*ds/lp, so the
|
|
404
|
+
# tangent-tangent correlation decays as exp(-s/lp).
|
|
405
|
+
lp = np.maximum(lengths * np.float32(max(persistence, 1e-3)), step)
|
|
406
|
+
turn_sd = np.sqrt(2.0 * step / lp).astype(np.float32)
|
|
407
|
+
dtheta = rng.standard_normal((n, steps)).astype(np.float32) * turn_sd[:, None]
|
|
408
|
+
|
|
409
|
+
# Sharp kinks on top of the smooth bend: this is what separates a paper
|
|
410
|
+
# fibre from a hair.
|
|
411
|
+
if kinks_per_length > 0.0:
|
|
412
|
+
p_kink = np.clip(
|
|
413
|
+
float(kinks_per_length) * step / np.maximum(lengths, 1e-3), 0.0, 1.0
|
|
414
|
+
)
|
|
415
|
+
hits = rng.random((n, steps)).astype(np.float32) < p_kink[:, None]
|
|
416
|
+
kick = np.abs(
|
|
417
|
+
rng.standard_normal((n, steps)).astype(np.float32)
|
|
418
|
+
* np.float32(np.deg2rad(kink_deg))
|
|
419
|
+
)
|
|
420
|
+
sign = np.where(rng.random((n, steps)) < 0.5, -1.0, 1.0).astype(np.float32)
|
|
421
|
+
dtheta = dtheta + np.where(hits, kick * sign, 0.0).astype(np.float32)
|
|
422
|
+
|
|
423
|
+
theta = theta0[:, None] + np.cumsum(dtheta, axis=1)
|
|
424
|
+
xs = x0[:, None] + np.cumsum(np.cos(theta) * np.float32(step), axis=1)
|
|
425
|
+
ys = y0[:, None] + np.cumsum(np.sin(theta) * np.float32(step), axis=1)
|
|
426
|
+
|
|
427
|
+
# Mask samples past each fibre's own length, and taper the last step so
|
|
428
|
+
# length is honoured to sub-step precision.
|
|
429
|
+
arclen = (np.arange(1, steps + 1, dtype=np.float32) * np.float32(step))[None, :]
|
|
430
|
+
live = np.clip((lengths[:, None] - arclen) / np.float32(step) + 1.0, 0.0, 1.0)
|
|
431
|
+
|
|
432
|
+
# --- Ends: two populations -------------------------------------------
|
|
433
|
+
# A native tracheid end tapers over its last 150-500 um to a blunt tip
|
|
434
|
+
# 30-60% of mid-fibre width and stops; it does *not* run out to zero, which
|
|
435
|
+
# is what a symmetric smoothstep over a sixth of the fibre used to do here
|
|
436
|
+
# and is why every fibre used to fade out at both ends like a brush stroke.
|
|
437
|
+
# A cut end is a transverse fracture, so it keeps full width right up to the
|
|
438
|
+
# break -- and gets a brush instead, further down.
|
|
439
|
+
taper_len = max(
|
|
440
|
+
float(taper_px) if taper_px else _TAPER_FRAC * float(length_px), step
|
|
441
|
+
)
|
|
442
|
+
tip = rng.uniform(_TIP_FRAC[0], _TIP_FRAC[1], size=n).astype(np.float32)[:, None]
|
|
443
|
+
cut = rng.random((n, 2)) < float(cut_ends)
|
|
444
|
+
|
|
445
|
+
def _blunt(d: np.ndarray, is_cut: np.ndarray) -> np.ndarray:
|
|
446
|
+
s = np.clip(d / np.float32(taper_len), 0.0, 1.0)
|
|
447
|
+
s = s * s * (3.0 - 2.0 * s)
|
|
448
|
+
return np.where(is_cut[:, None], np.float32(1.0), tip + (1.0 - tip) * s)
|
|
449
|
+
|
|
450
|
+
taper = np.minimum(
|
|
451
|
+
_blunt(arclen, cut[:, 0]),
|
|
452
|
+
_blunt(lengths[:, None] - arclen, cut[:, 1]),
|
|
453
|
+
).astype(np.float32)
|
|
454
|
+
|
|
455
|
+
coarseness = rng.lognormal(0.0, float(mass_jitter), n).astype(np.float32)
|
|
456
|
+
# Mass per sample = width * arclength, so the pixel-integrated coverage
|
|
457
|
+
# of one fibre is width_px * length_px regardless of step size.
|
|
458
|
+
weight = live * taper * (coarseness[:, None] * np.float32(float(width_px) * step))
|
|
459
|
+
|
|
460
|
+
# --- Along-fibre width variation --------------------------------------
|
|
461
|
+
# Collapse is intermittent along a fibre, so projected width is too: an
|
|
462
|
+
# uncollapsed tracheid presents ~20-30 um and a collapsed one ~25-45 um,
|
|
463
|
+
# alternating over collapse domains of a few hundred micrometres. Width
|
|
464
|
+
# enters as mass (the fibre is sub-pixel, so wider means denser, not
|
|
465
|
+
# broader) and as footprint (above a pixel it really is broader).
|
|
466
|
+
wmul = None
|
|
467
|
+
if width_cv > 1e-6 and width_corr_px > 1e-6:
|
|
468
|
+
wmul = np.clip(
|
|
469
|
+
1.0
|
|
470
|
+
+ np.float32(width_cv)
|
|
471
|
+
* _correlated_profile(rng, n, steps, float(width_corr_px) / step),
|
|
472
|
+
0.25,
|
|
473
|
+
None,
|
|
474
|
+
).astype(np.float32)
|
|
475
|
+
weight = weight * wmul
|
|
476
|
+
|
|
477
|
+
# Below a pixel, a fibre is a sub-unit-coverage mark and the bilinear
|
|
478
|
+
# footprint is already wider than it is: scale opacity, keep the footprint.
|
|
479
|
+
# Above a pixel it is a real ribbon and must actually be that wide, so lay
|
|
480
|
+
# down parallel strands across the local normal, splitting the mass
|
|
481
|
+
# between them. Without this, a 3 px fibre at 2000 px renders as a 1 px
|
|
482
|
+
# line at triple opacity -- narrow, hard-edged, and exactly the scribed
|
|
483
|
+
# look the rewrite exists to remove.
|
|
484
|
+
strands = int(np.clip(round(float(width_px)), 1, 4))
|
|
485
|
+
weight = weight / np.float32(strands)
|
|
486
|
+
|
|
487
|
+
# Every fibre is walked out to the longest fibre's step count and the
|
|
488
|
+
# surplus masked off, so roughly half the samples carry no weight. Drop
|
|
489
|
+
# them before splatting rather than paying four bincounts for zeros.
|
|
490
|
+
keep = (weight > 1e-6).ravel()
|
|
491
|
+
xs_f = xs.ravel()[keep]
|
|
492
|
+
ys_f = ys.ravel()[keep]
|
|
493
|
+
wt_f = weight.ravel()[keep].astype(np.float64)
|
|
494
|
+
tx_f = np.cos(theta).ravel()[keep]
|
|
495
|
+
ty_f = np.sin(theta).ravel()[keep]
|
|
496
|
+
nx_f = -ty_f
|
|
497
|
+
ny_f = tx_f
|
|
498
|
+
# Where the fibre is locally wider, its footprint is wider too.
|
|
499
|
+
wm_f = np.float32(1.0) if wmul is None else wmul.ravel()[keep]
|
|
500
|
+
|
|
501
|
+
mass = np.zeros((h, w), dtype=np.float32)
|
|
502
|
+
for k in range(strands):
|
|
503
|
+
offset = np.float32(
|
|
504
|
+
(k - (strands - 1) * 0.5) * (float(width_px) / max(strands, 1))
|
|
505
|
+
)
|
|
506
|
+
mass += _splat(
|
|
507
|
+
(h, w), xs_f + offset * wm_f * nx_f, ys_f + offset * wm_f * ny_f, wt_f
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# --- External fibrillation: a sub-pixel opacity fringe ------------------
|
|
511
|
+
# Fibrils are 50 nm - 1 um wide, so at 10-39 um per pixel they can only ever
|
|
512
|
+
# be *coverage just outside the fibre wall*, never geometry. Splatting
|
|
513
|
+
# ``fibrillation`` of each sample's mass one fibril-reach out along the local
|
|
514
|
+
# normal puts exactly that there: bilinear splatting spreads it into the
|
|
515
|
+
# neighbouring pixel, which is the softened hazy edge and the inter-fibre
|
|
516
|
+
# webbing that fibrillation actually looks like at these scales. One splat,
|
|
517
|
+
# with the side drawn per sample rather than one splat per side -- there are
|
|
518
|
+
# hundreds of samples per pixel, so both sides fill in either way and the
|
|
519
|
+
# deposition pass is the dominant cost of the whole generator.
|
|
520
|
+
# The index is the *total* external-fibril area, and the cut-end brushes below
|
|
521
|
+
# claim a share of it: 2 ends x P(cut) x _BRUSH_AREA_PER_INDEX per unit index.
|
|
522
|
+
# The wall fringe gets the remainder, so the two together deposit exactly
|
|
523
|
+
# ``fibrillation`` of each fibre's mass however hard the furnish was refined.
|
|
524
|
+
brush_share = min(
|
|
525
|
+
2.0 * float(np.clip(cut_ends, 0.0, 1.0)) * _BRUSH_AREA_PER_INDEX, 1.0
|
|
526
|
+
)
|
|
527
|
+
fringe_index = float(fibrillation) * (1.0 - brush_share)
|
|
528
|
+
if fringe_index > 1e-6 and fringe_px > 1e-6 and xs_f.size:
|
|
529
|
+
m = xs_f.size
|
|
530
|
+
phi = _fibril_angles(rng, m)
|
|
531
|
+
# Position along the fibril, so the fringe is a band rather than a line.
|
|
532
|
+
u = rng.random(m).astype(np.float32) * np.float32(fringe_px)
|
|
533
|
+
side = np.where(rng.random(m) < 0.5, np.float32(-1.0), np.float32(1.0))
|
|
534
|
+
# A fibril rooted at the wall reaches sideways by its length times the
|
|
535
|
+
# sine of its divergence angle and lies back along the fibre by the
|
|
536
|
+
# cosine -- so the S1/S2 angle mixture is what sets how far the fringe
|
|
537
|
+
# spreads, and a low-angle S2 fibril hugs the fibre as it should.
|
|
538
|
+
lat = np.float32(0.5 * float(width_px)) * wm_f + side * u * np.sin(phi)
|
|
539
|
+
lon = u * np.cos(phi)
|
|
540
|
+
mass += _splat(
|
|
541
|
+
(h, w),
|
|
542
|
+
xs_f + lat * nx_f + lon * tx_f,
|
|
543
|
+
ys_f + lat * ny_f + lon * ty_f,
|
|
544
|
+
wt_f * fringe_index,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
# --- Cut-end brushes ---------------------------------------------------
|
|
548
|
+
# The S2 fails along its microfibril helix, so a refining cut frays into a
|
|
549
|
+
# brush of 3-10 ribbons reaching 20-100 um past the break. At 0.5-3 um wide
|
|
550
|
+
# those are sub-pixel too, so the brush is a fanned plume of coverage; the
|
|
551
|
+
# ribbons are coherent along their length (they are ribbons, not dust), which
|
|
552
|
+
# is why this walks each one out rather than scattering points in the fan.
|
|
553
|
+
if fibrillation > 1e-6 and brush_px > 1e-6 and cut_ends > 1e-6 and cut.any():
|
|
554
|
+
last = np.clip(np.ceil(lengths / step).astype(np.int64) - 1, 0, steps - 1)
|
|
555
|
+
# Per-fibre mass, so a heavy fibre gets a heavy brush.
|
|
556
|
+
fibre_mass = weight.sum(axis=1)
|
|
557
|
+
px, py, pth, pw = [], [], [], []
|
|
558
|
+
for end, (idx, flip) in enumerate(
|
|
559
|
+
((last, 0.0), (np.zeros(n, np.int64), np.pi))
|
|
560
|
+
):
|
|
561
|
+
sel = np.nonzero(cut[:, end])[0]
|
|
562
|
+
if not sel.size:
|
|
563
|
+
continue
|
|
564
|
+
px.append(xs[sel, idx[sel]])
|
|
565
|
+
py.append(ys[sel, idx[sel]])
|
|
566
|
+
pth.append(theta[sel, idx[sel]] + np.float32(flip))
|
|
567
|
+
pw.append(fibre_mass[sel])
|
|
568
|
+
ex = np.concatenate(px)
|
|
569
|
+
ey = np.concatenate(py)
|
|
570
|
+
eth = np.concatenate(pth)
|
|
571
|
+
ew = np.concatenate(pw).astype(np.float64)
|
|
572
|
+
e = ex.size
|
|
573
|
+
# One splayed direction per ribbon, from the same microfibril-angle
|
|
574
|
+
# mixture the fringe uses -- the brush and the fringe are the same
|
|
575
|
+
# mechanism at the end of the fibre rather than along it.
|
|
576
|
+
fan = _fibril_angles(rng, e * _BRUSH_RIBBONS).reshape(e, _BRUSH_RIBBONS)
|
|
577
|
+
fan = fan * np.where(
|
|
578
|
+
rng.random((e, _BRUSH_RIBBONS)) < 0.5, np.float32(-1.0), np.float32(1.0)
|
|
579
|
+
)
|
|
580
|
+
ang = eth[:, None] + fan
|
|
581
|
+
# Ribbon lengths vary, and mass thins out towards the tips.
|
|
582
|
+
reach = np.float32(brush_px) * (
|
|
583
|
+
0.35 + 0.65 * rng.random((e, _BRUSH_RIBBONS)).astype(np.float32)
|
|
584
|
+
)
|
|
585
|
+
r = (np.arange(1, _BRUSH_SAMPLES + 1, dtype=np.float32) / _BRUSH_SAMPLES)[
|
|
586
|
+
None, None, :
|
|
587
|
+
]
|
|
588
|
+
rr = reach[..., None] * r
|
|
589
|
+
bx = ex[:, None, None] + rr * np.cos(ang)[..., None]
|
|
590
|
+
by = ey[:, None, None] + rr * np.sin(ang)[..., None]
|
|
591
|
+
# Total brush mass per end is a fixed share of the parent fibre, scaled
|
|
592
|
+
# by the fibrillation index: refining frays ends and peels fibrils
|
|
593
|
+
# together, so one parameter drives both.
|
|
594
|
+
share = float(_BRUSH_AREA_PER_INDEX * fibrillation) / (
|
|
595
|
+
_BRUSH_RIBBONS * _BRUSH_SAMPLES
|
|
596
|
+
)
|
|
597
|
+
# 1.5 - 0.8r averages to 1 over the four samples, so the share above is
|
|
598
|
+
# the whole brush's mass and the taper only redistributes it.
|
|
599
|
+
bw = np.broadcast_to((ew * share)[:, None, None], bx.shape) * np.broadcast_to(
|
|
600
|
+
1.5 - 0.8 * r, bx.shape
|
|
601
|
+
)
|
|
602
|
+
mass += _splat((h, w), bx.ravel(), by.ravel(), bw.ravel())
|
|
603
|
+
return mass
|