migrated texture generation script to cli

This commit is contained in:
ykcheungab
2026-09-16 21:34:59 +08:00
parent a29ca6846c
commit 026f495f77
12 changed files with 1292 additions and 124 deletions
+4
View File
@@ -6,6 +6,10 @@ __pycache__/
*.egg-info/
dist/
build/export/
# Generated textures are tracked (Git LFS via *.png) so they can be synced to
# hardware production. Only the per-run job file is ignored: it records absolute
# machine paths and is rewritten by every run.
build/textures/**/_texture_job.json
.pytest_cache/
# Blender
+52 -2
View File
@@ -66,7 +66,7 @@ cd D:\orisys_ws\orisys-sensor-design
python -m pip install -e .
```
Three commands for v1:
Four commands for v1:
```powershell
# 1) Check revision metadata and (optionally) Blender scene conventions
@@ -75,7 +75,10 @@ orisys-design validate sensors/finger/r01
# 2) Export nominal geometry for a profile (compute → SDK layout)
orisys-design export sensors/finger/r01 --profile compute
# 3) Write release manifest with file hashes
# 3) Generate the colored patch texture headlessly
orisys-design texture sensors/finger/r01
# 4) Write release manifest with file hashes
orisys-design manifest sensors/finger/r01
```
@@ -108,6 +111,53 @@ build/export/finger/r01/
└── world_to_cam.npy
```
## Texture patches (headless)
`orisys-design texture` generates the colored patch texture headlessly. UV
unwrapping is **still an in-app Blender step**: this command consumes the UV map
already stored in `source_blend` and never creates or unwraps one. Unwrap the
mesh in the Blender GUI, save the `.blend`, then run the command.
```powershell
orisys-design texture sensors/finger/r01
# writes build/textures/finger/r01/_texture_job.json then runs Blender
```
Output is revision-scoped, so nothing has to be copied by hand:
```text
build/textures/finger/r01/
├── _texture_job.json # Blender input, regenerated by texture (gitignored)
└── patch_texture.png # colored patch texture (tracked, Git LFS)
```
Generated PNGs are **committed** so a revision's texture can be synced to
hardware production; `.gitattributes` already routes `*.png` through Git LFS
(`git lfs install` / `git lfs pull` as usual). Only `_texture_job.json` is
ignored, because it records absolute machine paths and is rewritten every run.
Defaults come from the `texture_patch` block in `design.yaml` — mesh, camera,
texture size, patch grid, colour separation, edge sampling, reference cross,
file name, and an optional seed. CLI flags override every value:
```powershell
orisys-design texture sensors/finger/r01 `
--width 1024 --height 1024 --nx 16 --ny 12 --seed 7 --no-cross
```
Manual Blender invocation with the same job:
```powershell
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_patch_dd02c.py -- `
--job D:\orisys_ws\orisys-sensor-design\build\textures\finger\r01\_texture_job.json
```
Only the colored "patches" mode is reachable headlessly; the checker and dot
helpers remain in `random_patch_dd02c.py` but are not exposed by the CLI. The
default 3280×3200 grid matches the in-app script and is deliberately slow
(pure-Python rasterization) — use smaller `--width/--height/--nx/--ny` for quick
check runs, and the full default grid for the texture you actually ship.
Collection check:
```powershell
+287 -115
View File
@@ -1,15 +1,33 @@
"""Blender headless colored-patch texture generator (YAML-free).
The host CLI (orisys-design texture) reads design.yaml, writes a JSON job file,
and invokes this script. Blender only needs stdlib json — not PyYAML:
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_patch_dd02c.py -- \
--job <abs/build/textures/finger/r01/_texture_job.json>
Manual overrides (optional, win over job values):
--output FILE.png --mesh sensing_surface --cam cam_sensor --width 3280 --height 3200 \
--nx 80 --ny 45 --color-r 0.05 --edge-sample-grid 5 --seed 7 --no-cross
Scope: the colored "patches" mode only. checker/dots helpers remain available to
callers but are not reachable from the headless entry point. The mesh must
already have an active UV map: UV unwrapping is still an in-app Blender step and
is never performed here.
- Mesh UVs are filled white so that non-visible parts become white.
- Texture background (no-mesh areas) stays black.
"""
Texture Generator for Arbitrary 3D Surfaces (Blender Script)
- Mode: "patches" (colored patches), "checker" (black/white patches),
or "dots" (circular markers)
- Mesh UVs are filled white so that non‑visible parts become white.
- Texture background (no‑mesh areas) stays black.
"""
import argparse
import json
import sys
import time
from pathlib import Path
import bpy
import numpy as np
import os
import time
from mathutils import Vector
# ----------------------------------------------------------------------
@@ -489,9 +507,11 @@ def generate_patch_texture(
obj, cam, scene, depsgraph,
nx=40, ny=30, r=0.05,
texture_width=2048, texture_height=2048,
output_path="//patch_texture.png", uv_centre=None, cross_length=40,
output_path=None, uv_centre=None, cross_length=40,
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5
):
if not output_path:
raise ValueError("output_path is required (absolute PNG path)")
cam_data = cam.data
render = scene.render
res_x = render.resolution_x
@@ -569,11 +589,12 @@ def generate_patch_texture(
image.pixels[:] = pixels
image.update()
abs_path = bpy.path.abspath(output_path)
image.filepath_raw = abs_path
out_path = Path(output_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
image.filepath_raw = str(out_path)
image.file_format = 'PNG'
image.save()
print(f"Patch texture saved to {abs_path}")
print(f"Patch texture saved to {out_path}")
return image
# ----------------------------------------------------------------------
@@ -583,10 +604,12 @@ def generate_checker_texture(
obj, cam, scene, depsgraph,
nx=40, ny=40,
texture_width=2048, texture_height=2048,
output_path="//checker_texture.png", uv_centre=None, cross_length=40,
output_path=None, uv_centre=None, cross_length=40,
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5,
gap_fill_px=6
):
if not output_path:
raise ValueError("output_path is required (absolute PNG path)")
cam_data = cam.data
render = scene.render
res_x = render.resolution_x
@@ -668,11 +691,12 @@ def generate_checker_texture(
image.pixels[:] = pixels
image.update()
abs_path = bpy.path.abspath(output_path)
image.filepath_raw = abs_path
out_path = Path(output_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
image.filepath_raw = str(out_path)
image.file_format = 'PNG'
image.save()
print(f"Checker texture saved to {abs_path}")
print(f"Checker texture saved to {out_path}")
return image
# ----------------------------------------------------------------------
@@ -684,8 +708,10 @@ def generate_dot_texture(
spacing_pixels=40,
dot_color=(0,0,0,1), # RGBA 0-1
texture_width=2048, texture_height=2048,
output_path="//dot_texture.png",uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1)
output_path=None,uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1)
):
if not output_path:
raise ValueError("output_path is required (absolute PNG path)")
render = scene.render
res_x = render.resolution_x
res_y = render.resolution_y
@@ -797,121 +823,267 @@ def generate_dot_texture(
image.pixels[:] = pixels
image.update()
abs_path = bpy.path.abspath(output_path)
image.filepath_raw = abs_path
out_path = Path(output_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
image.filepath_raw = str(out_path)
image.file_format = 'PNG'
image.save()
print(f"Dot texture saved to {abs_path}")
print(f"Dot texture saved to {out_path}")
return image
# ----------------------------------------------------------------------
# Main
# Headless configuration (job file from `orisys-design texture`)
# ----------------------------------------------------------------------
def main():
# ==================== CONFIGURATION ====================
MODE = "patches" # "patches", "checker", or "dots"
OBJ_NAME = "FingerMesh0525.001"
CAM_NAME = "Camera0607"
TEXTURE_WIDTH = 1640*2
TEXTURE_HEIGHT = 1600*2
OUTPUT_NAMES = {
"patches": "patch_texture.png",
"checker": "checker_texture.png",
"dots": "dot_texture.png",
_DEFAULT_MESH = "sensing_surface"
_DEFAULT_CAMERA = "cam_sensor"
class ConfigError(ValueError):
"""Invalid job/flags (exit code 2)."""
class SceneError(RuntimeError):
"""Missing or unusable scene data (exit code 1)."""
def _argv_after_double_dash():
if "--" not in sys.argv:
return []
return sys.argv[sys.argv.index("--") + 1:]
def parse_args(argv):
parser = argparse.ArgumentParser(prog="random_patch_dd02c.py")
parser.add_argument(
"--job", type=Path, default=None,
help="JSON texture job written by orisys-design (output + objects + settings)",
)
parser.add_argument("--output", type=Path, default=None, help="Absolute output PNG (overrides job)")
parser.add_argument("--mesh", default=None, help="Mesh object name (overrides job)")
parser.add_argument("--cam", default=None, help="Camera object name (overrides job)")
parser.add_argument("--width", type=int, default=None, help="Texture width (overrides job)")
parser.add_argument("--height", type=int, default=None, help="Texture height (overrides job)")
parser.add_argument("--nx", type=int, default=None, help="Patch columns (overrides job)")
parser.add_argument("--ny", type=int, default=None, help="Patch rows (overrides job)")
parser.add_argument("--color-r", dest="color_r", type=float, default=None, help="Colour separation")
parser.add_argument(
"--edge-sample-grid", dest="edge_sample_grid", type=int, default=None,
help="Edge sampling grid for partially covered patches",
)
parser.add_argument("--seed", type=int, default=None, help="Seed for reproducible colours")
parser.add_argument(
"--no-cross", dest="no_cross", action="store_true", default=None,
help="Do not draw the reference cross",
)
parser.add_argument("--cross-length", dest="cross_length", type=int, default=None)
parser.add_argument("--cross-thickness", dest="cross_thickness", type=int, default=None)
parser.add_argument(
"--cross-color", dest="cross_color", type=float, nargs=4,
metavar=("R", "G", "B", "A"), default=None,
)
return parser.parse_args(argv)
def _load_job(path):
if not path.is_file():
raise ConfigError(f"Texture job not found: {path}")
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ConfigError(f"Texture job must be a JSON object: {path}")
return data
def _as_int(value, label, minimum=1):
if not isinstance(value, int) or isinstance(value, bool) or value < minimum:
raise ConfigError(f"{label}: must be an integer >= {minimum} (got {value!r})")
return int(value)
def _as_color(value, label):
if not isinstance(value, (list, tuple)) or len(value) not in (3, 4):
raise ConfigError(f"{label}: must be a list of 3 or 4 numbers in [0, 1] (got {value!r})")
for channel in value:
if not isinstance(channel, (int, float)) or isinstance(channel, bool):
raise ConfigError(f"{label}: every channel must be a number (got {value!r})")
if not 0.0 <= float(channel) <= 1.0:
raise ConfigError(f"{label}: every channel must be in [0, 1] (got {value!r})")
rgba = [float(channel) for channel in value]
if len(rgba) == 3:
rgba.append(1.0)
return tuple(rgba)
def _resolve_config(args):
"""Merge --job JSON with explicit flags. Explicit flags win over job values."""
job = {}
if args.job is not None:
job = _load_job(args.job.resolve())
output_raw = args.output or job.get("output")
if not output_raw:
raise ConfigError("Provide --job <path.json> and/or --output <file.png>")
output = Path(output_raw).expanduser()
if not output.is_absolute():
raise ConfigError(f"output must be an absolute path (got {str(output_raw)!r})")
if output.suffix.lower() != ".png":
raise ConfigError(f"output must be a .png file (got {str(output_raw)!r})")
objects = job.get("objects") or {}
if not isinstance(objects, dict):
raise ConfigError("job 'objects' must be a mapping")
settings = job.get("settings") or {}
if not isinstance(settings, dict):
raise ConfigError("job 'settings' must be a mapping")
job_cross = settings.get("cross") or {}
if not isinstance(job_cross, dict):
raise ConfigError("job 'settings.cross' must be a mapping")
def pick(flag, key, default):
if flag is not None:
return flag
value = settings.get(key, default)
return default if value is None else value
def pick_cross(flag, key, default):
if flag is not None:
return flag
value = job_cross.get(key, default)
return default if value is None else value
mesh = args.mesh or objects.get("mesh") or _DEFAULT_MESH
camera = args.cam or objects.get("camera") or _DEFAULT_CAMERA
width = _as_int(pick(args.width, "width", 2048), "width")
height = _as_int(pick(args.height, "height", 2048), "height")
nx = _as_int(pick(args.nx, "nx", 80), "nx")
ny = _as_int(pick(args.ny, "ny", 45), "ny")
edge_sample_grid = _as_int(
pick(args.edge_sample_grid, "edge_sample_grid", 5), "edge_sample_grid", minimum=2
)
color_r = pick(args.color_r, "color_r", 0.05)
if not isinstance(color_r, (int, float)) or isinstance(color_r, bool):
raise ConfigError(f"color_r: must be a number in [0, 1] (got {color_r!r})")
if not 0.0 <= float(color_r) <= 1.0:
raise ConfigError(f"color_r: must be a number in [0, 1] (got {color_r!r})")
seed = pick(args.seed, "seed", None)
if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)):
raise ConfigError(f"seed: must be an integer or null (got {seed!r})")
cross_enabled = bool(pick_cross(args.no_cross, "enabled", True))
if args.no_cross is not None:
cross_enabled = not args.no_cross
cross_length = _as_int(pick_cross(args.cross_length, "length_px", 40), "cross.length_px")
cross_thickness = _as_int(pick_cross(args.cross_thickness, "thickness_px", 3), "cross.thickness_px")
cross_color = _as_color(
args.cross_color if args.cross_color is not None else job_cross.get("color", [1.0, 0.0, 0.0, 1.0]),
"cross.color",
)
return {
"output": output,
"mesh": str(mesh),
"camera": str(camera),
"width": width,
"height": height,
"nx": nx,
"ny": ny,
"color_r": float(color_r),
"edge_sample_grid": edge_sample_grid,
"seed": None if seed is None else int(seed),
"cross_enabled": cross_enabled,
"cross_length": cross_length,
"cross_thickness": cross_thickness,
"cross_color": cross_color,
}
OUTPUT_PATH = "//" + OUTPUT_NAMES.get(MODE, "texture.png")
# Patch mode
PATCH_NX = 80; PATCH_NY = 45; COLOR_R = 0.05
EDGE_SAMPLE_GRID = 5
# Checker mode
CHECKER_NX = 40; CHECKER_NY = 40; CHECKER_GAP_FILL_PX = 6
# Dot mode
DOT_DIAMETER_PIX = 12; DOT_SPACING_PIX = 40; DOT_COLOR = (0,0,0,1)
# Cross
CROSS_LENGTH_PX = 40
CROSS_THICKNESS_PX = 3
CROSS_COLOR = (1, 0, 0, 1) # red cross
# ======================================================
start_time = time.time()
def _require_object(name, expected_type, label):
obj = bpy.data.objects.get(name)
if obj is None:
raise SceneError(f"{label} object not found: {name!r}")
if obj.type != expected_type:
raise SceneError(f"{label} {name!r} has type {obj.type!r}, expected {expected_type!r}")
return obj
def _uv_centre(obj):
"""UV bounding-box centre; fails if the mesh was never unwrapped."""
uv_layer = obj.data.uv_layers.active
if uv_layer is None or len(uv_layer.data) == 0:
raise SceneError(
f"Mesh {obj.name!r} has no active UV map with loops. UV unwrapping is still an "
"in-app Blender step: unwrap the mesh in the GUI, save the .blend, then re-run "
"orisys-design texture."
)
uv = np.array(
[uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))],
dtype=np.float64,
)
centre = (uv.min(axis=0) + uv.max(axis=0)) / 2.0
print(f"UV centre: ({centre[0]:.4f}, {centre[1]:.4f})")
return centre
def main():
args = parse_args(_argv_after_double_dash())
try:
config = _resolve_config(args)
except (ConfigError, json.JSONDecodeError, OSError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
output = config["output"]
try:
output.parent.mkdir(parents=True, exist_ok=True)
except OSError as exc:
print(f"ERROR: cannot create output directory {output.parent}: {exc}", file=sys.stderr)
return 2
scene = bpy.context.scene
depsgraph = bpy.context.evaluated_depsgraph_get()
if OBJ_NAME:
obj = bpy.data.objects.get(OBJ_NAME)
else:
obj = bpy.context.active_object
if obj is None or obj.type != 'MESH':
raise ValueError("No valid mesh object found.")
try:
obj = _require_object(config["mesh"], "MESH", "Mesh")
cam = _require_object(config["camera"], "CAMERA", "Camera")
uv_centre = _uv_centre(obj)
except SceneError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
cam = bpy.data.objects.get(CAM_NAME) if CAM_NAME else scene.camera
if cam is None or cam.type != 'CAMERA':
raise ValueError("No camera found.")
if config["seed"] is not None:
np.random.seed(config["seed"])
print(f"Using colour seed {config['seed']}")
if not obj.data.uv_layers.active:
obj.data.uv_layers.new(name="UVMap")
# Compute UV centre
uv_layer = obj.data.uv_layers.active
uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))])
uv_min = uv_coords.min(axis=0)
uv_max = uv_coords.max(axis=0)
uv_centre = (uv_min + uv_max) / 2.0
print(f"UV centre: ({uv_centre[0]:.4f}, {uv_centre[1]:.4f})")
# Compute UV bounding box centre
me = obj.data
uv_layer = me.uv_layers.active
uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))])
uv_min = uv_coords.min(axis=0)
uv_max = uv_coords.max(axis=0)
uv_centre = (uv_min + uv_max) / 2.0
print(f"UV centre: {uv_centre[0]:.4f}, {uv_centre[1]:.4f}")
if MODE == "patches":
start_time = time.time()
try:
generate_patch_texture(
obj, cam, scene, depsgraph,
nx=PATCH_NX, ny=PATCH_NY, r=COLOR_R,
texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
output_path=OUTPUT_PATH,
uv_centre=uv_centre,
cross_length=CROSS_LENGTH_PX,
cross_thickness=CROSS_THICKNESS_PX,
cross_color=CROSS_COLOR,
edge_sample_grid=EDGE_SAMPLE_GRID
nx=config["nx"], ny=config["ny"], r=config["color_r"],
texture_width=config["width"], texture_height=config["height"],
output_path=str(output),
uv_centre=uv_centre if config["cross_enabled"] else None,
cross_length=config["cross_length"],
cross_thickness=config["cross_thickness"],
cross_color=config["cross_color"],
edge_sample_grid=config["edge_sample_grid"],
)
elif MODE == "checker":
generate_checker_texture(
obj, cam, scene, depsgraph,
nx=CHECKER_NX, ny=CHECKER_NY,
texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
output_path=OUTPUT_PATH,
uv_centre=uv_centre,
cross_length=CROSS_LENGTH_PX,
cross_thickness=CROSS_THICKNESS_PX,
cross_color=CROSS_COLOR,
edge_sample_grid=EDGE_SAMPLE_GRID,
gap_fill_px=CHECKER_GAP_FILL_PX
)
elif MODE == "dots":
generate_dot_texture(
obj, cam, scene, depsgraph,
dot_diameter_pixels=DOT_DIAMETER_PIX,
spacing_pixels=DOT_SPACING_PIX,
dot_color=DOT_COLOR,
texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
output_path=OUTPUT_PATH,
uv_centre=uv_centre,
cross_length=CROSS_LENGTH_PX,
cross_thickness=CROSS_THICKNESS_PX,
cross_color=CROSS_COLOR
)
else:
raise ValueError(f"Unknown MODE: {MODE!r}. Use 'patches', 'checker', or 'dots'.")
except Exception as exc:
print(f"ERROR: texture generation failed: {exc}", file=sys.stderr)
return 1
if not output.is_file() or output.stat().st_size == 0:
print(f"ERROR: texture was not written: {output}", file=sys.stderr)
return 1
print(f"Texture complete: {output}")
print(f" {config['width']}x{config['height']} grid {config['nx']}x{config['ny']}")
print(f"Completed in {time.time() - start_time:.2f} seconds.")
return 0
print(f"Completed in {time.time()-start_time:.2f} seconds.")
if __name__ == "__main__":
main()
raise SystemExit(main())
+1 -1
View File
@@ -1,3 +1,3 @@
sensor: finger
sensor: finger-xynova
revision: r01
status: draft
+1 -1
View File
@@ -1,4 +1,4 @@
sensor: finger
sensor: finger-xynova
revision: r01
status: draft
@@ -1,7 +1,7 @@
profile: compute
description: Runtime sensing bundle consumed by orisys-sdk assets_dir
output_dir: build/export/finger/r01/compute
output_dir: build/export/finger-xynova/r01/compute
# Files must match orisys-sdk / Studio runtime layout.
artifacts:
@@ -2,7 +2,7 @@ profile: studio
description: Compile a Studio .oryscene from compute export + SDK unroll map
# Always compile from compute export, never from visualization/ pose mesh.
compute_export_dir: build/export/finger/r01/compute
compute_export_dir: build/export/finger-xynova/r01/compute
# Measured field domain — lives in orisys-sdk, not this repo.
unroll_map: orisys-sdk/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz
+21
View File
@@ -22,6 +22,27 @@ objects:
cam_sensor: cam_sensor
origin_sensor: origin_sensor
# Headless colored-patch texture generation (`orisys-design texture`).
# UV unwrapping is still done in the Blender GUI: the headless run consumes the
# UV map already stored in source_blend and never creates or unwraps one.
# These are the revision defaults; CLI flags override every value.
texture_patch:
mesh: sensing_surface
camera: cam_sensor
filename: patch_texture.png
width: 3280 # 1640*2
height: 3200 # 1600*2
nx: 80
ny: 45
color_r: 0.05
edge_sample_grid: 5
seed: null # set an integer for reproducible colours
cross:
enabled: true
length_px: 40
thickness_px: 3
color: [1.0, 0.0, 0.0, 1.0] # red reference cross
collections:
frames: FRAMES
export_compute: EXPORT_COMPUTE
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cdceb6bb091a7f8e1f820063c18b84ea9fa686949381f6d7b1cee74d63c27c18
size 677092
oid sha256:e2a9aba857f0ea951da02424d36ae28a62c2cabafbaadaa15d06b6626ae46e39
size 673299
+126 -1
View File
@@ -1,4 +1,4 @@
"""orisys-design CLI — validate, export, manifest."""
"""orisys-design CLI — validate, export, texture, manifest."""
from __future__ import annotations
@@ -24,6 +24,14 @@ from orisys_sensor_design.manifest import (
write_export_job,
write_manifest,
)
from orisys_sensor_design.texture import (
TextureConfigError,
build_texture_job,
resolve_texture_output_dir,
resolve_texture_settings,
texture_overrides_from_args,
write_texture_job,
)
def _blender_from_lock(root: Path) -> Path | None:
@@ -215,6 +223,53 @@ def cmd_export(revision_dir: Path, profile: str) -> int:
return 0
def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
"""Generate the colored patch texture headlessly for one revision."""
root = repo_root(revision_dir)
paths = RevisionPaths.from_revision_dir(revision_dir)
errors = validate_revision_metadata(paths)
if errors:
for message in errors:
print(f"ERROR: {message}", file=sys.stderr)
return 1
overrides = texture_overrides_from_args(args)
try:
settings = resolve_texture_settings(paths, overrides)
output_dir = resolve_texture_output_dir(root, paths, args.output_dir)
except TextureConfigError as exc:
for message in str(exc).splitlines():
print(f"ERROR: {message}", file=sys.stderr)
return 2
output_path = output_dir / settings.filename
job = build_texture_job(paths, settings, output_path)
job_path = write_texture_job(job, output_dir)
print(f"Wrote texture job: {job_path}")
code = _run_blender_script(
"random_patch_dd02c.py",
paths.source_blend,
["--job", str(job_path)],
)
if code != 0:
return code
if not output_path.is_file() or output_path.stat().st_size == 0:
print(
f"ERROR: Blender finished but the texture is missing or empty: {output_path}",
file=sys.stderr,
)
return 3
print(f"OK: colored patch texture written to {output_path}")
print(
f" mode=patches {settings.width}x{settings.height} "
f"grid={settings.nx}x{settings.ny} mesh={settings.mesh!r} camera={settings.camera!r}"
)
return 0
def cmd_manifest(revision_dir: Path, out: Path | None, profile: str) -> int:
root = repo_root(revision_dir)
paths = RevisionPaths.from_revision_dir(revision_dir)
@@ -253,6 +308,74 @@ def main(argv: list[str] | None = None) -> None:
export_p.add_argument("revision_dir", type=Path)
export_p.add_argument("--profile", default="compute", choices=["compute"])
texture_p = sub.add_parser(
"texture",
help="Generate the colored patch texture headlessly (UV unwrap stays in-app)",
)
texture_p.add_argument("revision_dir", type=Path)
texture_p.add_argument(
"--output-dir",
type=Path,
default=None,
help="Output directory (default: build/textures/<sensor>/<revision>)",
)
texture_p.add_argument(
"--filename",
default=None,
help="PNG file name (default: design.yaml texture_patch.filename)",
)
texture_p.add_argument("--mesh", default=None, help="Sensing surface mesh object name")
texture_p.add_argument("--cam", default=None, help="Sensor camera object name")
texture_p.add_argument("--width", type=int, default=None, help="Texture width in pixels")
texture_p.add_argument("--height", type=int, default=None, help="Texture height in pixels")
texture_p.add_argument("--nx", type=int, default=None, help="Patch columns across the frame")
texture_p.add_argument("--ny", type=int, default=None, help="Patch rows across the frame")
texture_p.add_argument(
"--color-r",
dest="color_r",
type=float,
default=None,
help="Colour separation between neighbouring patches",
)
texture_p.add_argument(
"--edge-sample-grid",
dest="edge_sample_grid",
type=int,
default=None,
help="Edge sampling grid for partially covered patches",
)
texture_p.add_argument("--seed", type=int, default=None, help="Seed for reproducible colours")
texture_p.add_argument(
"--no-cross",
dest="no_cross",
action="store_true",
default=None,
help="Do not draw the red reference cross at the UV centre",
)
texture_p.add_argument(
"--cross-length",
dest="cross_length",
type=int,
default=None,
help="Reference cross arm length in pixels",
)
texture_p.add_argument(
"--cross-thickness",
dest="cross_thickness",
type=int,
default=None,
help="Reference cross line thickness in pixels",
)
texture_p.add_argument(
"--cross-color",
dest="cross_color",
type=float,
nargs=4,
metavar=("R", "G", "B", "A"),
default=None,
help="Reference cross RGBA colour in 0-1",
)
manifest_p = sub.add_parser("manifest", help="Write release manifest with file hashes")
manifest_p.add_argument("revision_dir", type=Path)
manifest_p.add_argument(
@@ -268,6 +391,8 @@ def main(argv: list[str] | None = None) -> None:
code = cmd_validate(args.revision_dir, args.blender)
elif args.command == "export":
code = cmd_export(args.revision_dir, args.profile)
elif args.command == "texture":
code = cmd_texture(args.revision_dir, args)
elif args.command == "manifest":
code = cmd_manifest(args.revision_dir, args.out, args.profile)
else:
+399
View File
@@ -0,0 +1,399 @@
"""Headless colored-patch texture settings, output paths, and job I/O.
The host CLI (``orisys-design texture``) reads the ``texture_patch`` mapping in
``design.yaml``, merges explicit CLI overrides, writes a stdlib-JSON job file,
and invokes ``blender/scripts/random_patch_dd02c.py`` headlessly. Every path
handed to Blender is absolute, so no manual copying between directories is
required.
UV unwrapping is deliberately outside this module: the headless run consumes the
UV map already authored in the Blender GUI.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from orisys_sensor_design.manifest import (
RevisionPaths,
export_object_names,
load_yaml,
)
TEXTURE_JOB_SCHEMA = "orisys-sensor-design/texture-job-v1"
TEXTURE_JOB_FILENAME = "_texture_job.json"
TEXTURE_RELATIVE_ROOT = Path("build") / "textures"
TEXTURE_MODE = "patches"
# Built-in fallbacks. sensors/<sensor>/<revision>/design.yaml is the source of
# truth for a revision; these only apply when a key is absent there.
DEFAULT_TEXTURE_PATCH: dict[str, Any] = {
"filename": "patch_texture.png",
"width": 2048,
"height": 2048,
"nx": 80,
"ny": 45,
"color_r": 0.05,
"edge_sample_grid": 5,
"seed": None,
"cross": {
"enabled": True,
"length_px": 40,
"thickness_px": 3,
"color": [1.0, 0.0, 0.0, 1.0],
},
}
_TEXTURE_PATCH_KEYS = frozenset(
{
"mesh",
"camera",
"filename",
"width",
"height",
"nx",
"ny",
"color_r",
"edge_sample_grid",
"seed",
"cross",
}
)
_CROSS_KEYS = frozenset({"enabled", "length_px", "thickness_px", "color"})
_MAX_TEXTURE_EDGE = 32768
class TextureConfigError(ValueError):
"""Raised when texture_patch metadata or CLI overrides are invalid.
The message may span several lines; each line is one problem.
"""
@dataclass(frozen=True)
class TexturePatchSettings:
"""Resolved colored-patch settings for one headless run."""
mesh: str
camera: str
filename: str
width: int
height: int
nx: int
ny: int
color_r: float
edge_sample_grid: int
seed: int | None
cross_enabled: bool
cross_length_px: int
cross_thickness_px: int
cross_color: tuple[float, float, float, float]
def to_job_settings(self) -> dict[str, Any]:
"""Return the JSON-serialisable settings block consumed by Blender."""
return {
"width": self.width,
"height": self.height,
"nx": self.nx,
"ny": self.ny,
"color_r": self.color_r,
"edge_sample_grid": self.edge_sample_grid,
"seed": self.seed,
"cross": {
"enabled": self.cross_enabled,
"length_px": self.cross_length_px,
"thickness_px": self.cross_thickness_px,
"color": list(self.cross_color),
},
}
def _is_int(value: Any) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def _is_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
def load_texture_patch_block(paths: RevisionPaths) -> dict[str, Any]:
"""Return the raw ``texture_patch`` mapping from design.yaml ({} when absent)."""
design = load_yaml(paths.design_yaml)
raw = design.get("texture_patch")
if raw is None:
return {}
if not isinstance(raw, dict):
raise TextureConfigError(
f"{paths.design_yaml}: 'texture_patch' must be a mapping, got {type(raw).__name__}"
)
return raw
def _check_known_keys(block: dict[str, Any], where: str, errors: list[str]) -> None:
unknown = sorted(set(block) - _TEXTURE_PATCH_KEYS)
if unknown:
allowed = ", ".join(sorted(_TEXTURE_PATCH_KEYS))
errors.append(f"{where}: unknown key(s) {', '.join(unknown)} (allowed: {allowed})")
def merge_texture_patch(
paths: RevisionPaths,
overrides: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Merge built-in defaults ← design.yaml ``texture_patch`` ← CLI overrides.
Overrides with a value of ``None`` are ignored so an unset CLI flag never
erases a configured value.
"""
merged: dict[str, Any] = {
key: (dict(value) if isinstance(value, dict) else value)
for key, value in DEFAULT_TEXTURE_PATCH.items()
}
errors: list[str] = []
for block, where in (
(load_texture_patch_block(paths), f"{paths.design_yaml} texture_patch"),
(
{key: value for key, value in (overrides or {}).items() if value is not None},
"CLI overrides",
),
):
if not block:
continue
_check_known_keys(block, where, errors)
for key, value in block.items():
if key not in _TEXTURE_PATCH_KEYS:
continue
if key == "cross":
if not isinstance(value, dict):
errors.append(f"{where}.cross: must be a mapping")
continue
unknown_cross = sorted(set(value) - _CROSS_KEYS)
if unknown_cross:
errors.append(
f"{where}.cross: unknown key(s) {', '.join(unknown_cross)} "
f"(allowed: {', '.join(sorted(_CROSS_KEYS))})"
)
for cross_key, cross_value in value.items():
if cross_key in _CROSS_KEYS:
merged["cross"][cross_key] = cross_value
continue
merged[key] = value
if errors:
raise TextureConfigError("\n".join(errors))
return merged
def validate_texture_patch(block: dict[str, Any], objects: dict[str, str]) -> list[str]:
"""Return one message per invalid value in a merged texture_patch mapping."""
errors: list[str] = []
mesh = block.get("mesh")
if mesh is None:
block["mesh"] = objects["sensing_surface"]
elif not isinstance(mesh, str) or not mesh.strip():
errors.append("mesh: must be a non-empty object name")
camera = block.get("camera")
if camera is None:
block["camera"] = objects["cam_sensor"]
elif not isinstance(camera, str) or not camera.strip():
errors.append("camera: must be a non-empty object name")
filename = block.get("filename")
if not isinstance(filename, str) or not filename.strip():
errors.append("filename: must be a non-empty file name")
elif (
"/" in filename
or "\\" in filename
or Path(filename).name != filename
or filename in {".", ".."}
):
errors.append(f"filename: must be a bare file name without directories ({filename!r})")
elif Path(filename).suffix.lower() != ".png":
errors.append(f"filename: must end with .png ({filename!r})")
for key in ("width", "height"):
value = block.get(key)
if not _is_int(value) or value <= 0:
errors.append(f"{key}: must be a positive integer (got {value!r})")
elif value > _MAX_TEXTURE_EDGE:
errors.append(f"{key}: must be <= {_MAX_TEXTURE_EDGE} (got {value})")
for key in ("nx", "ny"):
value = block.get(key)
if not _is_int(value) or value < 1:
errors.append(f"{key}: must be an integer >= 1 (got {value!r})")
color_r = block.get("color_r")
if not _is_number(color_r) or not 0.0 <= float(color_r) <= 1.0:
errors.append(f"color_r: must be a number in [0, 1] (got {color_r!r})")
edge_sample_grid = block.get("edge_sample_grid")
if not _is_int(edge_sample_grid) or edge_sample_grid < 2:
errors.append(f"edge_sample_grid: must be an integer >= 2 (got {edge_sample_grid!r})")
seed = block.get("seed")
if seed is not None and not _is_int(seed):
errors.append(f"seed: must be an integer or null (got {seed!r})")
cross = block.get("cross")
if not isinstance(cross, dict):
errors.append("cross: must be a mapping")
return errors
if not isinstance(cross.get("enabled"), bool):
errors.append(f"cross.enabled: must be true or false (got {cross.get('enabled')!r})")
for key in ("length_px", "thickness_px"):
value = cross.get(key)
if not _is_int(value) or value < 1:
errors.append(f"cross.{key}: must be an integer >= 1 (got {value!r})")
color = cross.get("color")
if not isinstance(color, (list, tuple)) or len(color) not in (3, 4):
errors.append("cross.color: must be a list of 3 or 4 numbers in [0, 1]")
elif any(not _is_number(channel) or not 0.0 <= float(channel) <= 1.0 for channel in color):
errors.append(f"cross.color: every channel must be a number in [0, 1] (got {color!r})")
return errors
def resolve_texture_settings(
paths: RevisionPaths,
overrides: dict[str, Any] | None = None,
) -> TexturePatchSettings:
"""Merge and validate settings for a headless colored-patch run."""
block = merge_texture_patch(paths, overrides)
objects = export_object_names(paths)
errors = validate_texture_patch(block, objects)
if errors:
raise TextureConfigError("\n".join(errors))
cross = block["cross"]
color = [float(channel) for channel in cross["color"]]
if len(color) == 3:
color.append(1.0)
return TexturePatchSettings(
mesh=str(block["mesh"]),
camera=str(block["camera"]),
filename=str(block["filename"]),
width=int(block["width"]),
height=int(block["height"]),
nx=int(block["nx"]),
ny=int(block["ny"]),
color_r=float(block["color_r"]),
edge_sample_grid=int(block["edge_sample_grid"]),
seed=None if block["seed"] is None else int(block["seed"]),
cross_enabled=bool(cross["enabled"]),
cross_length_px=int(cross["length_px"]),
cross_thickness_px=int(cross["thickness_px"]),
cross_color=(color[0], color[1], color[2], color[3]),
)
def texture_revision_rel(root: Path, paths: RevisionPaths) -> Path:
"""Return ``<sensor>/<revision>`` for the revision below the repo's sensors/.
Directory names are used rather than design.yaml's ``sensor``/``revision``
fields because those are not reliable across sensor folders (finger-xynova
still declares ``sensor: finger``).
"""
sensors_root = (root / "sensors").resolve()
try:
rel = paths.root.relative_to(sensors_root)
except ValueError:
design = load_yaml(paths.design_yaml)
sensor = str(design.get("sensor") or paths.root.parent.name)
revision = str(design.get("revision") or paths.root.name)
return Path(sensor) / revision
return rel
def texture_output_dir(root: Path, paths: RevisionPaths) -> Path:
"""Default output directory: build/textures/<sensor>/<revision>."""
return (root / TEXTURE_RELATIVE_ROOT / texture_revision_rel(root, paths)).resolve()
def resolve_texture_output_dir(
root: Path,
paths: RevisionPaths,
output_dir: Path | None = None,
) -> Path:
"""Resolve the texture output directory (explicit --output-dir wins)."""
if output_dir is None:
return texture_output_dir(root, paths)
return (Path(output_dir).expanduser()).resolve()
def texture_job_path(output_dir: Path) -> Path:
"""Return the job file path written beside the generated PNG."""
return Path(output_dir) / TEXTURE_JOB_FILENAME
def build_texture_job(
paths: RevisionPaths,
settings: TexturePatchSettings,
output_path: Path,
) -> dict[str, Any]:
"""Build the JSON job dict that random_patch_dd02c.py consumes (stdlib JSON)."""
return {
"schema": TEXTURE_JOB_SCHEMA,
"mode": TEXTURE_MODE,
"revision_dir": str(paths.root.resolve()),
"source_blend": str(paths.source_blend.resolve()),
"output": str(Path(output_path).resolve()),
"objects": {
"mesh": settings.mesh,
"camera": settings.camera,
},
"settings": settings.to_job_settings(),
}
def write_texture_job(job: dict[str, Any], output_dir: Path) -> Path:
"""Write _texture_job.json into the output directory; return its path."""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
job_path = texture_job_path(output_dir)
job_path.write_text(json.dumps(job, indent=2) + "\n", encoding="utf-8")
return job_path
def texture_overrides_from_args(args: Any) -> dict[str, Any]:
"""Collect CLI override values from an argparse namespace (None = unset)."""
overrides: dict[str, Any] = {
"mesh": getattr(args, "mesh", None),
"camera": getattr(args, "cam", None),
"filename": getattr(args, "filename", None),
"width": getattr(args, "width", None),
"height": getattr(args, "height", None),
"nx": getattr(args, "nx", None),
"ny": getattr(args, "ny", None),
"color_r": getattr(args, "color_r", None),
"edge_sample_grid": getattr(args, "edge_sample_grid", None),
"seed": getattr(args, "seed", None),
}
cross: dict[str, Any] = {}
length = getattr(args, "cross_length", None)
thickness = getattr(args, "cross_thickness", None)
color = getattr(args, "cross_color", None)
if length is not None:
cross["length_px"] = length
if thickness is not None:
cross["thickness_px"] = thickness
if color is not None:
cross["color"] = list(color)
no_cross = getattr(args, "no_cross", None)
if no_cross is not None:
cross["enabled"] = not bool(no_cross)
if cross:
overrides["cross"] = cross
return overrides
+397
View File
@@ -0,0 +1,397 @@
"""Tests for headless colored-patch texture settings, job I/O, and CLI wiring."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import pytest
from orisys_sensor_design import cli
from orisys_sensor_design.manifest import RevisionPaths, repo_root
from orisys_sensor_design.texture import (
DEFAULT_TEXTURE_PATCH,
TEXTURE_JOB_FILENAME,
TEXTURE_JOB_SCHEMA,
TextureConfigError,
build_texture_job,
load_texture_patch_block,
merge_texture_patch,
resolve_texture_output_dir,
resolve_texture_settings,
texture_job_path,
texture_output_dir,
texture_revision_rel,
write_texture_job,
)
@pytest.fixture()
def revision_dir() -> Path:
return repo_root() / "sensors" / "finger" / "r01"
def _texture_namespace(
revision_dir: Path, output_dir: Path | None = None, **overrides: object
) -> argparse.Namespace:
namespace = argparse.Namespace(
revision_dir=revision_dir,
output_dir=output_dir,
filename=None,
mesh=None,
cam=None,
width=None,
height=None,
nx=None,
ny=None,
color_r=None,
edge_sample_grid=None,
seed=None,
no_cross=None,
cross_length=None,
cross_thickness=None,
cross_color=None,
)
for key, value in overrides.items():
setattr(namespace, key, value)
return namespace
def _write_revision(
tmp_path: Path,
texture_patch_yaml: str = "",
*,
sensor_dir: str = "finger",
revision_name: str = "r99",
) -> Path:
"""Create a minimal sensors/<sensor_dir>/<revision_name> revision under tmp_path."""
revision = tmp_path / "sensors" / sensor_dir / revision_name
(revision / "profiles").mkdir(parents=True)
(revision / "source").mkdir(parents=True)
(revision / "source" / "assembly.blend").write_bytes(b"")
design = (
"sensor: finger\n"
f"revision: {revision_name}\n"
"meters_per_source_unit: 0.001\n"
"source_blend: source/assembly.blend\n"
)
design += texture_patch_yaml
(revision / "design.yaml").write_text(design, encoding="utf-8")
for name in ("compute.yaml", "studio.yaml"):
(revision / "profiles" / name).write_text(
f"profile: {name.split('.')[0]}\n", encoding="utf-8"
)
return revision
# ---------------------------------------------------------------- paths
def test_output_dir_is_revision_scoped(revision_dir: Path) -> None:
root = repo_root()
paths = RevisionPaths.from_revision_dir(revision_dir)
assert texture_revision_rel(root, paths).as_posix() == "finger/r01"
assert texture_output_dir(root, paths) == (
root / "build" / "textures" / "finger" / "r01"
).resolve()
def test_output_dir_explicit_override_wins(revision_dir: Path, tmp_path: Path) -> None:
root = repo_root()
paths = RevisionPaths.from_revision_dir(revision_dir)
assert resolve_texture_output_dir(root, paths, tmp_path / "elsewhere") == (
tmp_path / "elsewhere"
).resolve()
def test_output_dir_uses_directory_names_not_design_sensor_field(
tmp_path: Path,
) -> None:
"""finger-xynova declares `sensor: finger`; the folder name must win."""
revision = _write_revision(tmp_path, sensor_dir="finger-xynova", revision_name="r01")
paths = RevisionPaths.from_revision_dir(revision)
assert texture_revision_rel(tmp_path, paths) == Path("finger-xynova") / "r01"
def test_job_path_sits_beside_texture(tmp_path: Path) -> None:
assert texture_job_path(tmp_path / "out") == tmp_path / "out" / TEXTURE_JOB_FILENAME
# ------------------------------------------------------- settings merge
def test_defaults_come_from_design_yaml(revision_dir: Path) -> None:
settings = resolve_texture_settings(RevisionPaths.from_revision_dir(revision_dir))
assert settings.mesh == "sensing_surface"
assert settings.camera == "cam_sensor"
assert settings.filename == "patch_texture.png"
assert (settings.width, settings.height) == (3280, 3200)
assert (settings.nx, settings.ny) == (80, 45)
assert settings.color_r == pytest.approx(0.05)
assert settings.edge_sample_grid == 5
assert settings.seed is None
assert settings.cross_enabled is True
assert settings.cross_length_px == 40
assert settings.cross_thickness_px == 3
assert settings.cross_color == (1.0, 0.0, 0.0, 1.0)
def test_builtin_defaults_when_design_block_absent(tmp_path: Path) -> None:
revision = _write_revision(tmp_path)
paths = RevisionPaths.from_revision_dir(revision)
assert load_texture_patch_block(paths) == {}
settings = resolve_texture_settings(paths)
assert settings.width == DEFAULT_TEXTURE_PATCH["width"]
assert settings.height == DEFAULT_TEXTURE_PATCH["height"]
# Object names fall back to design.yaml `objects` defaults.
assert settings.mesh == "sensing_surface"
assert settings.camera == "cam_sensor"
def test_cli_overrides_beat_design_yaml(revision_dir: Path) -> None:
paths = RevisionPaths.from_revision_dir(revision_dir)
settings = resolve_texture_settings(
paths,
{
"width": 640,
"height": 512,
"nx": 4,
"ny": 3,
"mesh": "other_mesh",
"camera": "other_cam",
"color_r": 0.25,
"edge_sample_grid": 3,
"seed": 11,
"cross": {"enabled": False, "length_px": 8, "color": [0.0, 1.0, 0.0]},
},
)
assert (settings.width, settings.height) == (640, 512)
assert (settings.nx, settings.ny) == (4, 3)
assert settings.mesh == "other_mesh"
assert settings.camera == "other_cam"
assert settings.color_r == pytest.approx(0.25)
assert settings.edge_sample_grid == 3
assert settings.seed == 11
assert settings.cross_enabled is False
assert settings.cross_length_px == 8
# A 3-channel cross colour is widened to RGBA.
assert settings.cross_color == (0.0, 1.0, 0.0, 1.0)
def test_unset_overrides_do_not_erase_configured_values(revision_dir: Path) -> None:
paths = RevisionPaths.from_revision_dir(revision_dir)
merged = merge_texture_patch(paths, {"width": None, "mesh": None, "cross": None})
assert merged["width"] == 3280
assert merged["mesh"] == "sensing_surface"
assert merged["cross"]["length_px"] == 40
def test_unknown_design_key_is_rejected(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, "texture_patch:\n widht: 100\n")
with pytest.raises(TextureConfigError) as excinfo:
resolve_texture_settings(RevisionPaths.from_revision_dir(revision))
assert "widht" in str(excinfo.value)
def test_unknown_cross_key_is_rejected(tmp_path: Path) -> None:
revision = _write_revision(
tmp_path, "texture_patch:\n cross:\n thickness: 3\n"
)
with pytest.raises(TextureConfigError) as excinfo:
resolve_texture_settings(RevisionPaths.from_revision_dir(revision))
assert "thickness" in str(excinfo.value)
def test_texture_patch_must_be_a_mapping(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, "texture_patch: patches\n")
with pytest.raises(TextureConfigError) as excinfo:
resolve_texture_settings(RevisionPaths.from_revision_dir(revision))
assert "must be a mapping" in str(excinfo.value)
@pytest.mark.parametrize(
("overrides", "expected"),
[
({"width": 0}, "width"),
({"height": -4}, "height"),
({"nx": 0}, "nx"),
({"ny": "many"}, "ny"),
({"color_r": 2.0}, "color_r"),
({"edge_sample_grid": 1}, "edge_sample_grid"),
({"seed": "abc"}, "seed"),
({"filename": "tex.jpg"}, "filename"),
({"filename": "nested/tex.png"}, "filename"),
({"filename": "..\\tex.png"}, "filename"),
({"cross": {"enabled": "yes"}}, "cross.enabled"),
({"cross": {"length_px": 0}}, "cross.length_px"),
({"cross": {"color": [2.0, 0.0, 0.0, 1.0]}}, "cross.color"),
({"cross": {"color": [0.0, 0.0]}}, "cross.color"),
],
)
def test_invalid_overrides_are_rejected(
revision_dir: Path, overrides: dict, expected: str
) -> None:
paths = RevisionPaths.from_revision_dir(revision_dir)
with pytest.raises(TextureConfigError) as excinfo:
resolve_texture_settings(paths, overrides)
assert expected in str(excinfo.value)
# ------------------------------------------------------------- job I/O
def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path) -> None:
paths = RevisionPaths.from_revision_dir(revision_dir)
settings = resolve_texture_settings(paths, {"width": 256, "height": 256, "seed": 3})
output_dir = tmp_path / "textures"
output_path = output_dir / settings.filename
job = build_texture_job(paths, settings, output_path)
assert job["schema"] == TEXTURE_JOB_SCHEMA
assert job["mode"] == "patches"
assert set(job) == {
"schema",
"mode",
"revision_dir",
"source_blend",
"output",
"objects",
"settings",
}
assert set(job["objects"]) == {"mesh", "camera"}
assert set(job["settings"]) == {
"width",
"height",
"nx",
"ny",
"color_r",
"edge_sample_grid",
"seed",
"cross",
}
assert set(job["settings"]["cross"]) == {
"enabled",
"length_px",
"thickness_px",
"color",
}
job_path = write_texture_job(job, output_dir)
assert job_path == output_dir / TEXTURE_JOB_FILENAME
loaded = json.loads(job_path.read_text(encoding="utf-8"))
assert loaded["output"] == str(output_path.resolve())
assert loaded["source_blend"] == str(paths.source_blend.resolve())
assert Path(loaded["output"]).is_absolute()
assert loaded["objects"] == {"mesh": "sensing_surface", "camera": "cam_sensor"}
assert loaded["settings"]["seed"] == 3
assert loaded["settings"]["cross"]["color"] == [1.0, 0.0, 0.0, 1.0]
def test_write_job_creates_output_directory(revision_dir: Path, tmp_path: Path) -> None:
paths = RevisionPaths.from_revision_dir(revision_dir)
settings = resolve_texture_settings(paths)
nested = tmp_path / "a" / "b"
write_texture_job(build_texture_job(paths, settings, nested / settings.filename), nested)
assert (nested / TEXTURE_JOB_FILENAME).is_file()
# ----------------------------------------------------------- CLI wiring
def _fake_blender(monkeypatch: pytest.MonkeyPatch, exit_code: int, write_png: bool):
calls: list[tuple[str, Path, list[str]]] = []
def fake_run(script: str, blend: Path, extra_args: list[str]):
calls.append((script, blend, list(extra_args)))
if write_png:
job_path = Path(extra_args[extra_args.index("--job") + 1])
job = json.loads(job_path.read_text(encoding="utf-8"))
Path(job["output"]).write_bytes(b"\x89PNG\r\n\x1a\n")
return exit_code
monkeypatch.setattr(cli, "_run_blender_script", fake_run)
return calls
def test_cmd_texture_forwards_job_and_verifies_artifact(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
) -> None:
calls = _fake_blender(monkeypatch, 0, write_png=True)
out_dir = tmp_path / "out"
assert cli.cmd_texture(revision_dir, _texture_namespace(revision_dir, out_dir)) == 0
script, blend, extra = calls[0]
assert script == "random_patch_dd02c.py"
assert blend == revision_dir.resolve() / "source" / "geometry.blend"
assert extra[0] == "--job"
job_path = Path(extra[1])
assert job_path == out_dir.resolve() / TEXTURE_JOB_FILENAME
assert (out_dir / "patch_texture.png").is_file()
def test_cmd_texture_propagates_blender_failure(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
) -> None:
_fake_blender(monkeypatch, 9, write_png=False)
namespace = _texture_namespace(revision_dir, tmp_path / "out")
assert cli.cmd_texture(revision_dir, namespace) == 9
def test_cmd_texture_detects_missing_texture(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
) -> None:
_fake_blender(monkeypatch, 0, write_png=False)
namespace = _texture_namespace(revision_dir, tmp_path / "out")
assert cli.cmd_texture(revision_dir, namespace) == 3
def test_main_texture_parses_flags_into_job(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
) -> None:
calls = _fake_blender(monkeypatch, 0, write_png=True)
out_dir = tmp_path / "flagged"
with pytest.raises(SystemExit) as excinfo:
cli.main(
[
"texture",
str(revision_dir),
"--output-dir",
str(out_dir),
"--filename",
"custom.png",
"--mesh",
"sensing_surface",
"--width",
"256",
"--height",
"256",
"--nx",
"4",
"--ny",
"3",
"--seed",
"7",
"--no-cross",
]
)
assert excinfo.value.code == 0
job = json.loads(
Path(calls[0][2][1]).read_text(encoding="utf-8")
)
assert job["settings"]["width"] == 256
assert job["settings"]["nx"] == 4
assert job["settings"]["ny"] == 3
assert job["settings"]["seed"] == 7
assert job["settings"]["cross"]["enabled"] is False
assert (out_dir / "custom.png").is_file()
def test_main_texture_rejects_invalid_flag_with_exit_2(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
) -> None:
_fake_blender(monkeypatch, 0, write_png=True)
with pytest.raises(SystemExit) as excinfo:
cli.main(["texture", str(revision_dir), "--width", "0"])
assert excinfo.value.code == 2