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,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))