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,487 @@
|
|
|
1
|
+
"""Command-line interface for the texture generator, built on Click.
|
|
2
|
+
|
|
3
|
+
``texture-gen`` is a command group: one subcommand per material, plus ``all``,
|
|
4
|
+
``sheet``, ``samples`` and ``list``. Examples::
|
|
5
|
+
|
|
6
|
+
texture-gen wood --variant board --size 1024x512 --seed 42 -o wood.png
|
|
7
|
+
texture-gen metal --variant brushed --size 512 -n 5 --outdir /tmp/m
|
|
8
|
+
texture-gen all --size 256 --outdir /tmp/textures
|
|
9
|
+
texture-gen sheet --size 256 -o sheet.png
|
|
10
|
+
texture-gen samples --only wood --count 6
|
|
11
|
+
texture-gen list --json
|
|
12
|
+
|
|
13
|
+
``python -m texture_generators ...`` is equivalent to ``texture-gen ...``;
|
|
14
|
+
``texture-gen --version`` prints the package version, and an unknown material,
|
|
15
|
+
variant or flag is a usage error (exit status 2).
|
|
16
|
+
|
|
17
|
+
The materials, their variants and the ``--variant`` choices are all derived
|
|
18
|
+
from :data:`texture_generators.MATERIALS` at import time, so a new material or
|
|
19
|
+
variant appears in the CLI — and in its help — without editing this module.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import secrets
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import click
|
|
30
|
+
from PIL import Image
|
|
31
|
+
|
|
32
|
+
from . import (
|
|
33
|
+
MATERIALS,
|
|
34
|
+
__version__,
|
|
35
|
+
all_pairs,
|
|
36
|
+
generate,
|
|
37
|
+
resolve_variant,
|
|
38
|
+
sample_sheet,
|
|
39
|
+
samples,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SizeType(click.ParamType):
|
|
44
|
+
"""A pixel size: ``N`` for a square texture, or ``WxH`` (also ``W*H``)."""
|
|
45
|
+
|
|
46
|
+
name = "size"
|
|
47
|
+
|
|
48
|
+
def convert(
|
|
49
|
+
self, value: Any, param: click.Parameter | None, ctx: click.Context | None
|
|
50
|
+
) -> int | tuple[int, int]:
|
|
51
|
+
"""Parse ``N`` or ``WxH``, rejecting any dimension below the 2x2 floor.
|
|
52
|
+
|
|
53
|
+
The generator itself raises on a smaller size, so checking here turns a
|
|
54
|
+
degenerate ``--size`` into a usage error rather than a traceback.
|
|
55
|
+
"""
|
|
56
|
+
parsed = self._parse(value, param, ctx)
|
|
57
|
+
dimensions = parsed if isinstance(parsed, tuple) else (parsed,)
|
|
58
|
+
if any(dimension < 2 for dimension in dimensions):
|
|
59
|
+
self.fail(f"size must be at least 2x2, got {value!r}", param, ctx)
|
|
60
|
+
return parsed
|
|
61
|
+
|
|
62
|
+
def _parse(
|
|
63
|
+
self, value: Any, param: click.Parameter | None, ctx: click.Context | None
|
|
64
|
+
) -> int | tuple[int, int]:
|
|
65
|
+
"""Parse ``N`` or ``WxH`` into an int or a ``(width, height)`` tuple."""
|
|
66
|
+
if isinstance(value, (int, tuple)): # already-converted default
|
|
67
|
+
return value
|
|
68
|
+
cleaned = str(value).strip().lower().replace("*", "x")
|
|
69
|
+
if "x" in cleaned:
|
|
70
|
+
parts = cleaned.split("x")
|
|
71
|
+
if len(parts) != 2:
|
|
72
|
+
self.fail(f"bad size {value!r}; use N or WxH", param, ctx)
|
|
73
|
+
try:
|
|
74
|
+
return (int(parts[0]), int(parts[1]))
|
|
75
|
+
except ValueError:
|
|
76
|
+
self.fail(f"bad size {value!r}; use N or WxH", param, ctx)
|
|
77
|
+
try:
|
|
78
|
+
return int(cleaned)
|
|
79
|
+
except ValueError:
|
|
80
|
+
self.fail(f"bad size {value!r}; use N or WxH", param, ctx)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
SIZE = SizeType()
|
|
84
|
+
|
|
85
|
+
size_option = click.option(
|
|
86
|
+
"-s",
|
|
87
|
+
"--size",
|
|
88
|
+
type=SIZE,
|
|
89
|
+
default=512,
|
|
90
|
+
show_default=True,
|
|
91
|
+
metavar="SIZE",
|
|
92
|
+
help="pixel size: N for a square, or WxH.",
|
|
93
|
+
)
|
|
94
|
+
seed_option = click.option(
|
|
95
|
+
"--seed",
|
|
96
|
+
type=int,
|
|
97
|
+
default=None,
|
|
98
|
+
metavar="INTEGER",
|
|
99
|
+
help="integer seed; the same seed always renders the same texture. "
|
|
100
|
+
"Omit for fresh entropy.",
|
|
101
|
+
)
|
|
102
|
+
outdir_option = click.option(
|
|
103
|
+
"--outdir",
|
|
104
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
105
|
+
default=None,
|
|
106
|
+
metavar="PATH",
|
|
107
|
+
help="output directory (default: the current directory).",
|
|
108
|
+
)
|
|
109
|
+
out_option = click.option(
|
|
110
|
+
"-o",
|
|
111
|
+
"--out",
|
|
112
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
113
|
+
default=None,
|
|
114
|
+
metavar="PATH",
|
|
115
|
+
help="output image path; the format follows the file extension (PNG by default).",
|
|
116
|
+
)
|
|
117
|
+
json_option = click.option(
|
|
118
|
+
"--json",
|
|
119
|
+
"as_json",
|
|
120
|
+
is_flag=True,
|
|
121
|
+
help="emit a JSON report on stdout instead of plain path lines.",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _default_name(material: str, variant: str | None, seed: int | None) -> str:
|
|
126
|
+
"""Build ``<material>[-<variant>][-<seed>].png``."""
|
|
127
|
+
parts = [material]
|
|
128
|
+
if variant:
|
|
129
|
+
parts.append(variant)
|
|
130
|
+
if seed is not None:
|
|
131
|
+
parts.append(str(seed))
|
|
132
|
+
return "-".join(parts) + ".png"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _resolve_path(out: Path | None, outdir: Path | None, name: str) -> Path:
|
|
136
|
+
"""Choose the output path: ``out`` verbatim, else ``name`` under ``outdir``."""
|
|
137
|
+
return Path(out) if out else Path(outdir or ".") / name
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _save(img: Image.Image, path: Path) -> None:
|
|
141
|
+
"""Write ``img`` to ``path``, creating parents, and wrap any failure.
|
|
142
|
+
|
|
143
|
+
Pillow raises ``ValueError`` for a path it cannot infer a format from and
|
|
144
|
+
``OSError`` for one it cannot write; both are the user's problem, not a bug,
|
|
145
|
+
so they surface as ``Error: …`` and exit 1 rather than as a traceback.
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
img.save(path)
|
|
150
|
+
except (OSError, ValueError) as exc:
|
|
151
|
+
raise click.ClickException(f"could not write {path}: {exc}") from exc
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _one_destination(out: Path | None, outdir: Path | None) -> None:
|
|
155
|
+
"""Reject ``-o`` together with ``--outdir``: ``--outdir`` would be ignored."""
|
|
156
|
+
if out is not None and outdir is not None:
|
|
157
|
+
raise click.UsageError(
|
|
158
|
+
"use either --out (one file) or --outdir (a directory), not both"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _record(path: Path, img: Image.Image, **fields: Any) -> dict[str, str | int | None]:
|
|
163
|
+
"""One ``written`` entry: the path, the given fields, then the pixel size."""
|
|
164
|
+
width, height = img.size
|
|
165
|
+
return {"path": str(path), **fields, "width": width, "height": height}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _emit(records: list[dict[str, str | int | None]], as_json: bool) -> None:
|
|
169
|
+
"""Report what was written, as JSON or as one path per line."""
|
|
170
|
+
if as_json:
|
|
171
|
+
click.echo(json.dumps({"written": records}, indent=2))
|
|
172
|
+
else:
|
|
173
|
+
for record in records:
|
|
174
|
+
click.echo(record["path"])
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _seed_for(seed: int | None, index: int = 0) -> int:
|
|
178
|
+
"""``seed + index`` for a fixed seed, else a fresh random seed."""
|
|
179
|
+
return seed + index if seed is not None else secrets.randbelow(2**31)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
MATERIAL_HELP = """Render a {material} texture as a PNG.
|
|
183
|
+
|
|
184
|
+
Variants: {variants} (default: one picked with the seed).
|
|
185
|
+
|
|
186
|
+
Writes to --out, or to <material>-<variant>-<seed>.png under --outdir (the
|
|
187
|
+
current directory by default), and prints every path written — naming the
|
|
188
|
+
variant the seed picked, even when --variant was omitted. With -n/--count the
|
|
189
|
+
seeds increment from --seed, so a fixed seed gives a reproducible run.
|
|
190
|
+
|
|
191
|
+
\b
|
|
192
|
+
Example:
|
|
193
|
+
texture-gen {material} --variant {variant} --size 1024x512 --seed 42 -o {material}.png
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _material_command(material: str) -> click.Command:
|
|
198
|
+
"""Build the subcommand for one material, from that material's variants."""
|
|
199
|
+
variants = list(MATERIALS[material].VARIANTS)
|
|
200
|
+
|
|
201
|
+
@click.command(
|
|
202
|
+
name=material,
|
|
203
|
+
short_help=f"Render a {material} texture.",
|
|
204
|
+
help=MATERIAL_HELP.format(
|
|
205
|
+
material=material,
|
|
206
|
+
variants=", ".join(variants),
|
|
207
|
+
variant=variants[0],
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
@size_option
|
|
211
|
+
@seed_option
|
|
212
|
+
@click.option(
|
|
213
|
+
"--variant",
|
|
214
|
+
type=click.Choice(variants),
|
|
215
|
+
default=None,
|
|
216
|
+
help=f"which {material} variant to render.",
|
|
217
|
+
)
|
|
218
|
+
@click.option(
|
|
219
|
+
"-n",
|
|
220
|
+
"--count",
|
|
221
|
+
type=click.IntRange(min=1),
|
|
222
|
+
default=1,
|
|
223
|
+
show_default=True,
|
|
224
|
+
metavar="COUNT",
|
|
225
|
+
help="render N textures (seeds increment from --seed).",
|
|
226
|
+
)
|
|
227
|
+
@out_option
|
|
228
|
+
@outdir_option
|
|
229
|
+
@json_option
|
|
230
|
+
def command(
|
|
231
|
+
size: int | tuple[int, int],
|
|
232
|
+
seed: int | None,
|
|
233
|
+
variant: str | None,
|
|
234
|
+
count: int,
|
|
235
|
+
out: Path | None,
|
|
236
|
+
outdir: Path | None,
|
|
237
|
+
as_json: bool,
|
|
238
|
+
) -> None:
|
|
239
|
+
_one_destination(out, outdir)
|
|
240
|
+
if out is not None and count > 1:
|
|
241
|
+
raise click.UsageError(
|
|
242
|
+
"--out names a single file; use --outdir with --count"
|
|
243
|
+
)
|
|
244
|
+
records = []
|
|
245
|
+
for index in range(count):
|
|
246
|
+
seed_i = _seed_for(seed, index)
|
|
247
|
+
# Name the variant before rendering, so an omitted --variant is
|
|
248
|
+
# reported and filed as the variant the seed actually picks.
|
|
249
|
+
resolved = resolve_variant(material, seed_i, variant)
|
|
250
|
+
img = generate(material, size, seed=seed_i, variant=variant)
|
|
251
|
+
path = _resolve_path(out, outdir, _default_name(material, resolved, seed_i))
|
|
252
|
+
_save(img, path)
|
|
253
|
+
records.append(
|
|
254
|
+
_record(path, img, material=material, variant=resolved, seed=seed_i)
|
|
255
|
+
)
|
|
256
|
+
_emit(records, as_json)
|
|
257
|
+
|
|
258
|
+
return command
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class TextureGenGroup(click.Group):
|
|
262
|
+
"""The group, always naming itself ``texture-gen`` in help and errors.
|
|
263
|
+
|
|
264
|
+
Click would otherwise take the program name from ``sys.argv[0]``, which
|
|
265
|
+
makes ``python -m texture_generators --help`` advertise a different
|
|
266
|
+
interface from the identical console script.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
def main(self, *args: Any, **kwargs: Any) -> Any:
|
|
270
|
+
"""Invoke the group, defaulting the program name to the script's."""
|
|
271
|
+
kwargs.setdefault("prog_name", "texture-gen")
|
|
272
|
+
return super().main(*args, **kwargs)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@click.group(
|
|
276
|
+
cls=TextureGenGroup,
|
|
277
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
278
|
+
)
|
|
279
|
+
@click.version_option(
|
|
280
|
+
__version__,
|
|
281
|
+
"-V",
|
|
282
|
+
"--version",
|
|
283
|
+
prog_name="texture-gen",
|
|
284
|
+
message="%(prog)s %(version)s",
|
|
285
|
+
)
|
|
286
|
+
def main() -> None:
|
|
287
|
+
"""Generate random but realistic procedural material textures (metal,
|
|
288
|
+
plastic, wood, paper) as PNGs.
|
|
289
|
+
|
|
290
|
+
Every texture comes from a single seeded generator, so the same seed
|
|
291
|
+
renders the same image; omit --seed for fresh entropy. --size takes N for a
|
|
292
|
+
square or WxH. Run "texture-gen list" to see every material and variant.
|
|
293
|
+
|
|
294
|
+
\b
|
|
295
|
+
Example:
|
|
296
|
+
texture-gen wood --variant board --size 1024x512 --seed 42 -o wood.png
|
|
297
|
+
texture-gen metal --size 512 -n 5 --outdir /tmp/metal
|
|
298
|
+
texture-gen all --size 256 --outdir /tmp/textures
|
|
299
|
+
texture-gen samples --only wood --count 6
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
for _material in MATERIALS:
|
|
304
|
+
main.add_command(_material_command(_material))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@main.command(name="all")
|
|
308
|
+
@size_option
|
|
309
|
+
@seed_option
|
|
310
|
+
@outdir_option
|
|
311
|
+
@json_option
|
|
312
|
+
def all_command(
|
|
313
|
+
size: int | tuple[int, int],
|
|
314
|
+
seed: int | None,
|
|
315
|
+
outdir: Path | None,
|
|
316
|
+
as_json: bool,
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Render every material/variant combination, one PNG each.
|
|
319
|
+
|
|
320
|
+
Files are named <material>-<variant>-<seed>.png under --outdir (the current
|
|
321
|
+
directory by default). There is no -o/--out: this writes many files.
|
|
322
|
+
|
|
323
|
+
\b
|
|
324
|
+
Example:
|
|
325
|
+
texture-gen all --size 256 --seed 42 --outdir /tmp/textures
|
|
326
|
+
"""
|
|
327
|
+
records = []
|
|
328
|
+
for material, variant in all_pairs():
|
|
329
|
+
seed_i = _seed_for(seed)
|
|
330
|
+
img = generate(material, size, seed=seed_i, variant=variant)
|
|
331
|
+
path = _resolve_path(
|
|
332
|
+
None, outdir or Path("."), _default_name(material, variant, seed_i)
|
|
333
|
+
)
|
|
334
|
+
_save(img, path)
|
|
335
|
+
records.append(
|
|
336
|
+
_record(path, img, material=material, variant=variant, seed=seed_i)
|
|
337
|
+
)
|
|
338
|
+
_emit(records, as_json)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@main.command(name="sheet")
|
|
342
|
+
@size_option
|
|
343
|
+
@seed_option
|
|
344
|
+
@out_option
|
|
345
|
+
@outdir_option
|
|
346
|
+
@json_option
|
|
347
|
+
def sheet_command(
|
|
348
|
+
size: int | tuple[int, int],
|
|
349
|
+
seed: int | None,
|
|
350
|
+
out: Path | None,
|
|
351
|
+
outdir: Path | None,
|
|
352
|
+
as_json: bool,
|
|
353
|
+
) -> None:
|
|
354
|
+
"""Render a labelled contact sheet: one tile per material/variant.
|
|
355
|
+
|
|
356
|
+
--size is the per-tile size, so WxH gives non-square tiles. Writes to
|
|
357
|
+
--out, or sheet-<seed>.png under --outdir.
|
|
358
|
+
|
|
359
|
+
\b
|
|
360
|
+
Example:
|
|
361
|
+
texture-gen sheet --size 256 --seed 42 -o sheet.png
|
|
362
|
+
"""
|
|
363
|
+
_one_destination(out, outdir)
|
|
364
|
+
# Resolve the seed up front rather than letting sample_sheet draw one, so
|
|
365
|
+
# the reported seed is the one that rendered this sheet.
|
|
366
|
+
seed_i = _seed_for(seed)
|
|
367
|
+
img = sample_sheet(size=size, seed=seed_i)
|
|
368
|
+
path = _resolve_path(out, outdir, _default_name("sheet", None, seed_i))
|
|
369
|
+
_save(img, path)
|
|
370
|
+
_emit([_record(path, img, kind="sheet", seed=seed_i)], as_json)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@main.command(name="samples")
|
|
374
|
+
@size_option
|
|
375
|
+
@click.option(
|
|
376
|
+
"--seed",
|
|
377
|
+
type=int,
|
|
378
|
+
default=None,
|
|
379
|
+
metavar="INTEGER",
|
|
380
|
+
help="base seed; each pair renders seeds base..base+count-1 (default 0).",
|
|
381
|
+
)
|
|
382
|
+
@click.option(
|
|
383
|
+
"-n",
|
|
384
|
+
"--count",
|
|
385
|
+
type=click.IntRange(min=1),
|
|
386
|
+
default=3,
|
|
387
|
+
show_default=True,
|
|
388
|
+
metavar="COUNT",
|
|
389
|
+
help="seeds to render per material/variant pair.",
|
|
390
|
+
)
|
|
391
|
+
@click.option(
|
|
392
|
+
"--outdir",
|
|
393
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
394
|
+
default=None,
|
|
395
|
+
metavar="PATH",
|
|
396
|
+
help="run-directory root (default: $TEXTURE_GEN_SAMPLES_DIR, else "
|
|
397
|
+
"/tmp/texture-gen-samples).",
|
|
398
|
+
)
|
|
399
|
+
@click.option(
|
|
400
|
+
"--only",
|
|
401
|
+
metavar="MAT[/VARIANT]",
|
|
402
|
+
multiple=True,
|
|
403
|
+
help="restrict to a material or material/variant (repeatable).",
|
|
404
|
+
)
|
|
405
|
+
@click.option("--no-sheet", is_flag=True, help="skip the contact sheet.")
|
|
406
|
+
@click.option("--no-sweeps", is_flag=True, help="skip the parameter-sweep rows.")
|
|
407
|
+
@click.option(
|
|
408
|
+
"--jobs",
|
|
409
|
+
type=click.IntRange(min=1),
|
|
410
|
+
default=None,
|
|
411
|
+
metavar="INTEGER",
|
|
412
|
+
help="worker processes (default: one per CPU; 1 = serial).",
|
|
413
|
+
)
|
|
414
|
+
@json_option
|
|
415
|
+
def samples_command(
|
|
416
|
+
size: int | tuple[int, int],
|
|
417
|
+
seed: int | None,
|
|
418
|
+
count: int,
|
|
419
|
+
outdir: Path | None,
|
|
420
|
+
only: tuple[str, ...],
|
|
421
|
+
no_sheet: bool,
|
|
422
|
+
no_sweeps: bool,
|
|
423
|
+
jobs: int | None,
|
|
424
|
+
as_json: bool,
|
|
425
|
+
) -> None:
|
|
426
|
+
"""Render the visual-review set: every pair at fixed seeds, plus a gallery.
|
|
427
|
+
|
|
428
|
+
Writes a fresh timestamped run directory (under --outdir, else
|
|
429
|
+
$TEXTURE_GEN_SAMPLES_DIR or /tmp/texture-gen-samples) holding the PNGs, the
|
|
430
|
+
contact sheet, the parameter sweeps and an index.html, and repoints a
|
|
431
|
+
"latest" symlink at it. Seeds are fixed, so the same command before and
|
|
432
|
+
after a change renders the same textures.
|
|
433
|
+
|
|
434
|
+
\b
|
|
435
|
+
Example:
|
|
436
|
+
texture-gen samples --only wood/board --count 6 --size 512
|
|
437
|
+
"""
|
|
438
|
+
try:
|
|
439
|
+
run_dir = samples.run(
|
|
440
|
+
outdir=Path(outdir) if outdir else samples.default_outdir(),
|
|
441
|
+
size=size,
|
|
442
|
+
count=count,
|
|
443
|
+
base_seed=seed if seed is not None else 0,
|
|
444
|
+
only=list(only) or None,
|
|
445
|
+
sheet=not no_sheet,
|
|
446
|
+
workers=jobs,
|
|
447
|
+
sweeps=not no_sweeps,
|
|
448
|
+
)
|
|
449
|
+
except ValueError as exc:
|
|
450
|
+
raise click.UsageError(str(exc)) from exc
|
|
451
|
+
n_images = len(list(run_dir.glob("*.png")))
|
|
452
|
+
index = run_dir / "index.html"
|
|
453
|
+
if as_json:
|
|
454
|
+
click.echo(
|
|
455
|
+
json.dumps(
|
|
456
|
+
{"run_dir": str(run_dir), "index": str(index), "count": n_images},
|
|
457
|
+
indent=2,
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
else:
|
|
461
|
+
click.echo(f"{n_images} PNGs -> {run_dir}")
|
|
462
|
+
click.echo(index)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@main.command(name="list")
|
|
466
|
+
@click.option(
|
|
467
|
+
"--json",
|
|
468
|
+
"as_json",
|
|
469
|
+
is_flag=True,
|
|
470
|
+
help="emit the registry as JSON instead of one line per material.",
|
|
471
|
+
)
|
|
472
|
+
def list_command(as_json: bool) -> None:
|
|
473
|
+
"""List every material and its variants, in registry order.
|
|
474
|
+
|
|
475
|
+
\b
|
|
476
|
+
Example:
|
|
477
|
+
texture-gen list --json
|
|
478
|
+
"""
|
|
479
|
+
materials = [
|
|
480
|
+
{"name": name, "variants": list(module.VARIANTS)}
|
|
481
|
+
for name, module in MATERIALS.items()
|
|
482
|
+
]
|
|
483
|
+
if as_json:
|
|
484
|
+
click.echo(json.dumps({"materials": materials}, indent=2))
|
|
485
|
+
else:
|
|
486
|
+
for entry in materials:
|
|
487
|
+
click.echo(f"{entry['name']}: {', '.join(entry['variants'])}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared procedural building blocks: noise, cellular noise, warping, shading."""
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""CIELAB <-> sRGB conversion, and the linear-light helpers that go with it.
|
|
2
|
+
|
|
3
|
+
Paper colour is specified in the trade as L*a*b* (plus ISO brightness and CIE
|
|
4
|
+
whiteness), so building the palette from Lab rather than from hand-picked RGB
|
|
5
|
+
is both more faithful and easier to reason about: whiteness is -b*, ageing is
|
|
6
|
+
+b*, kraft is a specific (L*, a*, b*) region.
|
|
7
|
+
|
|
8
|
+
The other materials in this library work directly in display values. Paper
|
|
9
|
+
does not: translucent fibres composite through one another, and compositing
|
|
10
|
+
in gamma space darkens overlaps incorrectly, so the paper pipeline works in
|
|
11
|
+
linear light and encodes to sRGB once at the end.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"lab_to_linear_rgb",
|
|
20
|
+
"lab_to_srgb",
|
|
21
|
+
"linear_rgb_to_lab",
|
|
22
|
+
"linear_to_srgb",
|
|
23
|
+
"srgb_to_lab",
|
|
24
|
+
"srgb_to_linear",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# sRGB primaries, D65 white point (IEC 61966-2-1).
|
|
28
|
+
_XYZ_TO_RGB = np.asarray(
|
|
29
|
+
[
|
|
30
|
+
[3.2404542, -1.5371385, -0.4985314],
|
|
31
|
+
[-0.9692660, 1.8760108, 0.0415560],
|
|
32
|
+
[0.0556434, -0.2040259, 1.0572252],
|
|
33
|
+
],
|
|
34
|
+
dtype=np.float64,
|
|
35
|
+
)
|
|
36
|
+
_WHITE_D65 = np.asarray([0.95047, 1.00000, 1.08883], dtype=np.float64)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _lab_f_inv(t: np.ndarray) -> np.ndarray:
|
|
40
|
+
"""Inverse of the CIELAB companding function."""
|
|
41
|
+
delta = 6.0 / 29.0
|
|
42
|
+
return np.where(t > delta, t**3, 3.0 * delta * delta * (t - 4.0 / 29.0))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def lab_to_linear_rgb(lab) -> np.ndarray:
|
|
46
|
+
"""Convert ``(L*, a*, b*)`` to linear sRGB in [0, 1] (clipped)."""
|
|
47
|
+
lab = np.asarray(lab, dtype=np.float64)
|
|
48
|
+
ell, a, b = lab[..., 0], lab[..., 1], lab[..., 2]
|
|
49
|
+
fy = (ell + 16.0) / 116.0
|
|
50
|
+
fx = fy + a / 500.0
|
|
51
|
+
fz = fy - b / 200.0
|
|
52
|
+
xyz = np.stack([_lab_f_inv(fx), _lab_f_inv(fy), _lab_f_inv(fz)], axis=-1)
|
|
53
|
+
xyz = xyz * _WHITE_D65
|
|
54
|
+
rgb = xyz @ _XYZ_TO_RGB.T
|
|
55
|
+
return np.clip(rgb, 0.0, 1.0).astype(np.float32)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def linear_to_srgb(x: np.ndarray) -> np.ndarray:
|
|
59
|
+
"""Linear light to sRGB display values, both in [0, 1]."""
|
|
60
|
+
x = np.clip(np.asarray(x, dtype=np.float32), 0.0, 1.0)
|
|
61
|
+
return np.where(
|
|
62
|
+
x <= 0.0031308, x * 12.92, 1.055 * np.power(x, 1.0 / 2.4) - 0.055
|
|
63
|
+
).astype(np.float32)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def srgb_to_linear(x: np.ndarray) -> np.ndarray:
|
|
67
|
+
"""sRGB display values to linear light, both in [0, 1]."""
|
|
68
|
+
x = np.clip(np.asarray(x, dtype=np.float32), 0.0, 1.0)
|
|
69
|
+
return np.where(x <= 0.04045, x / 12.92, np.power((x + 0.055) / 1.055, 2.4)).astype(
|
|
70
|
+
np.float32
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def lab_to_srgb(lab) -> np.ndarray:
|
|
75
|
+
"""Convert ``(L*, a*, b*)`` to sRGB display values in [0, 1]."""
|
|
76
|
+
return linear_to_srgb(lab_to_linear_rgb(lab))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _lab_f(t: np.ndarray) -> np.ndarray:
|
|
80
|
+
"""The CIELAB companding function."""
|
|
81
|
+
delta = 6.0 / 29.0
|
|
82
|
+
return np.where(t > delta**3, np.cbrt(t), t / (3.0 * delta * delta) + 4.0 / 29.0)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def linear_rgb_to_lab(rgb) -> np.ndarray:
|
|
86
|
+
"""Convert linear sRGB in [0, 1] to ``(L*, a*, b*)``.
|
|
87
|
+
|
|
88
|
+
The inverse of :func:`lab_to_linear_rgb`, and the way a *rendered* colour is
|
|
89
|
+
checked against the measured value it was built from: a difference of a few
|
|
90
|
+
display levels means nothing on its own, whereas a dL* or a dC* is the same
|
|
91
|
+
quantity the source measurement was quoted in.
|
|
92
|
+
"""
|
|
93
|
+
rgb = np.asarray(rgb, dtype=np.float64)
|
|
94
|
+
xyz = (rgb @ np.linalg.inv(_XYZ_TO_RGB).T) / _WHITE_D65
|
|
95
|
+
f = _lab_f(xyz)
|
|
96
|
+
return np.stack(
|
|
97
|
+
[
|
|
98
|
+
116.0 * f[..., 1] - 16.0,
|
|
99
|
+
500.0 * (f[..., 0] - f[..., 1]),
|
|
100
|
+
200.0 * (f[..., 1] - f[..., 2]),
|
|
101
|
+
],
|
|
102
|
+
axis=-1,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def srgb_to_lab(rgb) -> np.ndarray:
|
|
107
|
+
"""Convert sRGB display values in [0, 1] to ``(L*, a*, b*)``."""
|
|
108
|
+
return linear_rgb_to_lab(srgb_to_linear(rgb))
|