adde produce + compute texture export

This commit is contained in:
ykcheungab
2026-09-18 11:25:09 +08:00
parent 026f495f77
commit b1815b15ea
9 changed files with 503 additions and 161 deletions
+28 -11
View File
@@ -113,10 +113,12 @@ build/export/finger/r01/
## Texture patches (headless) ## Texture patches (headless)
`orisys-design texture` generates the colored patch texture headlessly. UV `orisys-design texture` generates one colored patch texture per UV map on the
unwrapping is **still an in-app Blender step**: this command consumes the UV map sensing surface. UV unwrapping is **still an in-app Blender step**: this command
already stored in `source_blend` and never creates or unwraps one. Unwrap the consumes the UV maps already stored in `source_blend` and never creates or
mesh in the Blender GUI, save the `.blend`, then run the command. unwraps one. Unwrap the mesh in the Blender GUI, save the `.blend`, then run the
command. A `.blend` saved in Edit mode is fine — the headless run enters Object
mode in memory and never writes the file.
```powershell ```powershell
orisys-design texture sensors/finger/r01 orisys-design texture sensors/finger/r01
@@ -127,18 +129,26 @@ Output is revision-scoped, so nothing has to be copied by hand:
```text ```text
build/textures/finger/r01/ build/textures/finger/r01/
├── _texture_job.json # Blender input, regenerated by texture (gitignored) ├── _texture_job.json # Blender input, regenerated by texture (gitignored)
└── patch_texture.png # colored patch texture (tracked, Git LFS) ├── patch_texture_produce.png # from the `produce` UV map (tracked, Git LFS)
└── patch_texture_compute.png # from the `compute` UV map (tracked, Git LFS)
``` ```
Generated PNGs are **committed** so a revision's texture can be synced to `produce` is the hardware-production layout and `compute` the compute/runtime
layout; they are the two UV maps authored on `sensing_surface`. Both outputs
share one set of settings and one seed, and both read the same camera-space
patch grid, so a given physical patch carries the same colour in either file —
the images differ only in how the unwrap arranges the patches.
Generated PNGs are **committed** so a revision's textures can be synced to
hardware production; `.gitattributes` already routes `*.png` through Git LFS hardware production; `.gitattributes` already routes `*.png` through Git LFS
(`git lfs install` / `git lfs pull` as usual). Only `_texture_job.json` is (`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. ignored, because it records absolute machine paths and is rewritten every run.
Defaults come from the `texture_patch` block in `design.yaml` — mesh, camera, Defaults come from the `texture_patch` block in `design.yaml` — mesh, camera, the
texture size, patch grid, colour separation, edge sampling, reference cross, `uv_outputs` map (UV map → PNG name), texture size, patch grid, colour
file name, and an optional seed. CLI flags override every value: separation, edge sampling, reference cross, and an optional seed. CLI flags
override the shared values for every output:
```powershell ```powershell
orisys-design texture sensors/finger/r01 ` orisys-design texture sensors/finger/r01 `
@@ -152,11 +162,18 @@ blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_pa
--job D:\orisys_ws\orisys-sensor-design\build\textures\finger\r01\_texture_job.json --job D:\orisys_ws\orisys-sensor-design\build\textures\finger\r01\_texture_job.json
``` ```
A single manual target is also possible (this cannot be combined with `--job`):
```powershell
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_patch_dd02c.py -- `
--uv-map produce --output D:\tmp\produce.png --width 1024 --height 1024
```
Only the colored "patches" mode is reachable headlessly; the checker and dot 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 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 default 3280×3200 grid matches the in-app script and is deliberately slow
(pure-Python rasterization) — use smaller `--width/--height/--nx/--ny` for quick (pure-Python rasterization) — use smaller `--width/--height/--nx/--ny` for quick
check runs, and the full default grid for the texture you actually ship. check runs, and the full default grid for the textures you actually ship.
Collection check: Collection check:
+210 -77
View File
@@ -6,15 +6,16 @@ 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 -- \ blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_patch_dd02c.py -- \
--job <abs/build/textures/finger/r01/_texture_job.json> --job <abs/build/textures/finger/r01/_texture_job.json>
Manual overrides (optional, win over job values): Manual single-target mode (cannot be combined with --job):
--output FILE.png --mesh sensing_surface --cam cam_sensor --width 3280 --height 3200 \ --uv-map produce --output FILE.png --mesh sensing_surface --cam cam_sensor \
--nx 80 --ny 45 --color-r 0.05 --edge-sample-grid 5 --seed 7 --no-cross --width 3280 --height 3200 --nx 80 --ny 45 --color-r 0.05 --seed 7 --no-cross
Scope: the colored "patches" mode only. checker/dots helpers remain available to 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 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 already carry the requested UV maps: UV unwrapping is still an in-app Blender
is never performed here. step and is never performed here. A scene saved in Edit mode is handled by
switching to Object mode in memory only — the .blend is never written.
- Mesh UVs are filled white so that non-visible parts become white. - Mesh UVs are filled white so that non-visible parts become white.
- Texture background (no-mesh areas) stays black. - Texture background (no-mesh areas) stays black.
@@ -220,9 +221,16 @@ def draw_ellipse(pixels, width, height, cx, cy, a, b, angle, color):
idx = (y * width + x) * 4 idx = (y * width + x) * 4
pixels[idx:idx+4] = color pixels[idx:idx+4] = color
def get_uv_from_face(obj, face_index, location): def get_uv_from_face(obj, face_index, location, uv_layer=None):
"""World‑space location → UV coordinate on the mesh face.""" """World‑space location → UV coordinate on the mesh face.
``uv_layer`` selects the mesh UV map explicitly. ``None`` keeps the previous
active-layer behaviour, which the non-headless checker/dot helpers rely on.
"""
me = obj.data me = obj.data
layer = uv_layer if uv_layer is not None else me.uv_layers.active
if layer is None:
return None
local_loc = obj.matrix_world.inverted() @ location local_loc = obj.matrix_world.inverted() @ location
poly = me.polygons[face_index] poly = me.polygons[face_index]
vert_indices = poly.vertices vert_indices = poly.vertices
@@ -232,10 +240,10 @@ def get_uv_from_face(obj, face_index, location):
v0 = me.vertices[vert_indices[0]].co v0 = me.vertices[vert_indices[0]].co
v1 = me.vertices[vert_indices[1]].co v1 = me.vertices[vert_indices[1]].co
v2 = me.vertices[vert_indices[2]].co v2 = me.vertices[vert_indices[2]].co
uv_layer = me.uv_layers.active.data uv_data = layer.data
uv0 = uv_layer[loop_indices[0]].uv uv0 = uv_data[loop_indices[0]].uv
uv1 = uv_layer[loop_indices[1]].uv uv1 = uv_data[loop_indices[1]].uv
uv2 = uv_layer[loop_indices[2]].uv uv2 = uv_data[loop_indices[2]].uv
def tri_area(a, b, c): def tri_area(a, b, c):
return 0.5 * ((b - a).cross(c - a)).length return 0.5 * ((b - a).cross(c - a)).length
total_area = tri_area(v0, v1, v2) total_area = tri_area(v0, v1, v2)
@@ -261,14 +269,16 @@ def raycast_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, f
return location, face_index return location, face_index
return None, None return None, None
def raycast_uv_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height): def raycast_uv_from_camera(
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height, uv_layer=None
):
"""Camera raycast through normalized sensor coordinates and return UV.""" """Camera raycast through normalized sensor coordinates and return UV."""
loc, face_idx = raycast_from_camera( loc, face_idx = raycast_from_camera(
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height
) )
if loc is None: if loc is None:
return None return None
return get_uv_from_face(obj, face_idx, loc) return get_uv_from_face(obj, face_idx, loc, uv_layer)
def convex_hull_uv(points): def convex_hull_uv(points):
"""Return UV points ordered around their 2D convex hull.""" """Return UV points ordered around their 2D convex hull."""
@@ -301,7 +311,7 @@ def sample_patch_uv_polygon(
scene, depsgraph, cam, obj, scene, depsgraph, cam, obj,
u_start, u_end, v_start, v_end, u_start, u_end, v_start, v_end,
frame_width, frame_height, frame_width, frame_height,
edge_sample_grid=5 edge_sample_grid=5, uv_layer=None
): ):
""" """
Build a UV polygon for one camera-space patch. Build a UV polygon for one camera-space patch.
@@ -309,6 +319,8 @@ def sample_patch_uv_polygon(
Full interior cells use the four corners. Edge cells that only partially hit Full interior cells use the four corners. Edge cells that only partially hit
the mesh are sampled more densely and converted to a convex hull so they are the mesh are sampled more densely and converted to a convex hull so they are
not dropped just because some corners miss the silhouette. not dropped just because some corners miss the silhouette.
``uv_layer`` selects which mesh UV map the raycasts are resolved against.
""" """
corners_ndc = [ corners_ndc = [
(u_start, v_start), (u_start, v_start),
@@ -320,7 +332,7 @@ def sample_patch_uv_polygon(
corner_uvs = [] corner_uvs = []
for u_ndc, v_ndc in corners_ndc: for u_ndc, v_ndc in corners_ndc:
uv = raycast_uv_from_camera( uv = raycast_uv_from_camera(
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height, uv_layer
) )
if uv is not None: if uv is not None:
corner_uvs.append(uv) corner_uvs.append(uv)
@@ -334,24 +346,27 @@ def sample_patch_uv_polygon(
for sx in range(edge_sample_grid): for sx in range(edge_sample_grid):
u_ndc = u_start + (u_end - u_start) * sx / (edge_sample_grid - 1) u_ndc = u_start + (u_end - u_start) * sx / (edge_sample_grid - 1)
uv = raycast_uv_from_camera( uv = raycast_uv_from_camera(
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height, uv_layer
) )
if uv is not None: if uv is not None:
sampled_uvs.append(uv) sampled_uvs.append(uv)
return convex_hull_uv(sampled_uvs) return convex_hull_uv(sampled_uvs)
def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1)): def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1), uv_layer=None):
""" """
Fill all triangles of the active UV map with 'color' (default white). Fill all triangles of one UV map with 'color' (default white).
This marks every part of the mesh, visible or not, as white. This marks every part of the mesh, visible or not, as white.
``uv_layer`` selects the mesh UV map; ``None`` keeps the active-layer
behaviour used by the checker/dot helpers.
""" """
me = obj.data me = obj.data
uv_layer = me.uv_layers.active layer = uv_layer if uv_layer is not None else me.uv_layers.active
if not uv_layer: if not layer:
print("Warning: No active UV layer – mesh rasterization skipped.") print("Warning: no UV layer selected – mesh rasterization skipped.")
return return
uv_data = uv_layer.data uv_data = layer.data
for poly in me.polygons: for poly in me.polygons:
# Get loop indices for this polygon # Get loop indices for this polygon
loops = poly.loop_indices loops = poly.loop_indices
@@ -508,7 +523,8 @@ def generate_patch_texture(
nx=40, ny=30, r=0.05, nx=40, ny=30, r=0.05,
texture_width=2048, texture_height=2048, texture_width=2048, texture_height=2048,
output_path=None, 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 cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5,
uv_layer=None, image_name="PatchTexture"
): ):
if not output_path: if not output_path:
raise ValueError("output_path is required (absolute PNG path)") raise ValueError("output_path is required (absolute PNG path)")
@@ -542,7 +558,7 @@ def generate_patch_texture(
scene, depsgraph, cam, obj, scene, depsgraph, cam, obj,
u_start, u_end, v_start, v_end, u_start, u_end, v_start, v_end,
frame_width, frame_height, frame_width, frame_height,
edge_sample_grid=edge_sample_grid edge_sample_grid=edge_sample_grid, uv_layer=uv_layer
) )
if uv_poly is None: if uv_poly is None:
patch_uv_polygons.append(None) patch_uv_polygons.append(None)
@@ -558,7 +574,7 @@ def generate_patch_texture(
print(f"Skipped {skipped_patches} patches with fewer than 3 mesh samples.") print(f"Skipped {skipped_patches} patches with fewer than 3 mesh samples.")
# Create image – start with black background # Create image – start with black background
img_name = "PatchTexture" img_name = image_name
if img_name in bpy.data.images: if img_name in bpy.data.images:
bpy.data.images.remove(bpy.data.images[img_name]) bpy.data.images.remove(bpy.data.images[img_name])
image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True) image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
@@ -568,7 +584,9 @@ def generate_patch_texture(
fill_background(pixels, texture_width, texture_height, (0,0,0,1)) fill_background(pixels, texture_width, texture_height, (0,0,0,1))
# Rasterize all mesh UVs in white (visible + non‑visible parts) # Rasterize all mesh UVs in white (visible + non‑visible parts)
rasterize_mesh_uvs(obj, pixels, texture_width, texture_height, color=(1,1,1,1)) rasterize_mesh_uvs(
obj, pixels, texture_width, texture_height, color=(1,1,1,1), uv_layer=uv_layer
)
# Now draw the visible patches on top # Now draw the visible patches on top
idx = 0 idx = 0
@@ -858,7 +876,14 @@ def parse_args(argv):
"--job", type=Path, default=None, "--job", type=Path, default=None,
help="JSON texture job written by orisys-design (output + objects + settings)", 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(
"--output", type=Path, default=None,
help="Absolute output PNG for a manual single-target run (needs --uv-map)",
)
parser.add_argument(
"--uv-map", dest="uv_map", default=None,
help="Mesh UV map name for a manual single-target run (with --output)",
)
parser.add_argument("--mesh", default=None, help="Mesh object name (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("--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("--width", type=int, default=None, help="Texture width (overrides job)")
@@ -913,20 +938,68 @@ def _as_color(value, label):
return tuple(rgba) return tuple(rgba)
def _as_output_path(raw, label="output"):
if raw is None or raw == "":
raise ConfigError(f"{label}: a PNG path is required")
if not isinstance(raw, (str, Path)):
raise ConfigError(f"{label}: must be a path (got {raw!r})")
path = Path(raw).expanduser()
if not path.is_absolute():
raise ConfigError(f"{label} must be an absolute path (got {raw!r})")
if path.suffix.lower() != ".png":
raise ConfigError(f"{label} must be a .png file (got {raw!r})")
return path
def _resolve_targets(args, job):
"""Return the ordered ``[(uv_map, output_path)]`` targets from the job or flags."""
if args.job is not None:
if args.output is not None or args.uv_map is not None:
raise ConfigError("--output/--uv-map cannot be combined with --job")
entries = job.get("outputs")
if not isinstance(entries, list) or not entries:
raise ConfigError("job 'outputs' must be a non-empty list of {uv_map, output}")
targets = []
for index, entry in enumerate(entries):
if not isinstance(entry, dict):
raise ConfigError(f"job outputs[{index}] must be a mapping")
uv_map = entry.get("uv_map")
if not isinstance(uv_map, str) or not uv_map.strip():
raise ConfigError(
f"job outputs[{index}]: 'uv_map' must be a non-empty string (got {uv_map!r})"
)
targets.append(
(uv_map, _as_output_path(entry.get("output"), f"job outputs[{index}].output"))
)
else:
if args.uv_map is None or args.output is None:
raise ConfigError(
"Provide --job <path.json>, or both --uv-map <name> and --output <file.png>"
)
targets = [(args.uv_map, _as_output_path(args.output))]
seen_names = set()
seen_paths = {}
for uv_map, path in targets:
if uv_map in seen_names:
raise ConfigError(f"duplicate UV map {uv_map!r} in outputs")
seen_names.add(uv_map)
key = str(path).lower()
if key in seen_paths:
raise ConfigError(
f"duplicate output path for {seen_paths[key]!r} and {uv_map!r}: {path}"
)
seen_paths[key] = uv_map
return targets
def _resolve_config(args): def _resolve_config(args):
"""Merge --job JSON with explicit flags. Explicit flags win over job values.""" """Merge --job JSON with explicit flags. Explicit flags win over job values."""
job = {} job = {}
if args.job is not None: if args.job is not None:
job = _load_job(args.job.resolve()) job = _load_job(args.job.resolve())
output_raw = args.output or job.get("output") targets = _resolve_targets(args, job)
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 {} objects = job.get("objects") or {}
if not isinstance(objects, dict): if not isinstance(objects, dict):
@@ -984,7 +1057,7 @@ def _resolve_config(args):
) )
return { return {
"output": output, "outputs": targets,
"mesh": str(mesh), "mesh": str(mesh),
"camera": str(camera), "camera": str(camera),
"width": width, "width": width,
@@ -1010,21 +1083,55 @@ def _require_object(name, expected_type, label):
return obj return obj
def _uv_centre(obj): def _prepare_mesh_object(name):
"""UV bounding-box centre; fails if the mesh was never unwrapped.""" """Return the named mesh object in Object mode so its UV data is readable.
uv_layer = obj.data.uv_layers.active
if uv_layer is None or len(uv_layer.data) == 0: source_blend may be saved with the object in Edit mode, where UV layer data
raise SceneError( reads as empty. The switch is in memory only — this script never saves the
f"Mesh {obj.name!r} has no active UV map with loops. UV unwrapping is still an " .blend file.
"in-app Blender step: unwrap the mesh in the GUI, save the .blend, then re-run " """
"orisys-design texture." obj = _require_object(name, "MESH", "Mesh")
) if obj.mode != "OBJECT":
print(f"Switching {obj.name!r} from {obj.mode} to OBJECT (in memory only)")
try:
for other in bpy.context.view_layer.objects:
other.select_set(False)
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.mode_set(mode="OBJECT")
except RuntimeError as exc:
raise SceneError(f"Could not leave Edit mode for {obj.name!r}: {exc}") from exc
return obj
def _require_uv_layer(obj, name):
"""Return the named UV layer, or fail listing the available maps."""
mesh = obj.data
for layer in mesh.uv_layers:
if layer.name == name:
if len(layer.data) == 0:
raise SceneError(
f"UV map {name!r} on mesh {obj.name!r} has no loop data. UV unwrapping "
"is still an in-app Blender step: unwrap the mesh in the GUI, save the "
".blend, then re-run orisys-design texture."
)
return layer
available = ", ".join(sorted(layer.name for layer in mesh.uv_layers)) or "none"
raise SceneError(
f"Mesh {obj.name!r} has no UV map named {name!r} (available: {available}). UV "
"unwrapping is still an in-app Blender step: unwrap the mesh in the GUI, save the "
".blend, then re-run orisys-design texture."
)
def _uv_centre(uv_layer):
"""UV bounding-box centre for one UV map."""
uv = np.array( uv = np.array(
[uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))], [uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))],
dtype=np.float64, dtype=np.float64,
) )
centre = (uv.min(axis=0) + uv.max(axis=0)) / 2.0 centre = (uv.min(axis=0) + uv.max(axis=0)) / 2.0
print(f"UV centre: ({centre[0]:.4f}, {centre[1]:.4f})") print(f"UV centre [{uv_layer.name}]: ({centre[0]:.4f}, {centre[1]:.4f})")
return centre return centre
@@ -1036,50 +1143,76 @@ def main():
print(f"ERROR: {exc}", file=sys.stderr) print(f"ERROR: {exc}", file=sys.stderr)
return 2 return 2
output = config["output"] # Validate every target before rendering anything so a partial pair is never
# produced: the mesh mode, both UV maps and both output paths must be usable.
try: try:
output.parent.mkdir(parents=True, exist_ok=True) obj = _prepare_mesh_object(config["mesh"])
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()
try:
obj = _require_object(config["mesh"], "MESH", "Mesh")
cam = _require_object(config["camera"], "CAMERA", "Camera") cam = _require_object(config["camera"], "CAMERA", "Camera")
uv_centre = _uv_centre(obj) targets = [
(uv_map, output, _require_uv_layer(obj, uv_map))
for uv_map, output in config["outputs"]
]
except SceneError as exc: except SceneError as exc:
print(f"ERROR: {exc}", file=sys.stderr) print(f"ERROR: {exc}", file=sys.stderr)
return 1 return 1
for uv_map, output, _layer in targets:
try:
output.parent.mkdir(parents=True, exist_ok=True)
except OSError as exc:
print(
f"ERROR: cannot create output directory for UV map {uv_map!r} "
f"({output.parent}): {exc}",
file=sys.stderr,
)
return 2
scene = bpy.context.scene
depsgraph = bpy.context.evaluated_depsgraph_get()
if config["seed"] is not None: if config["seed"] is not None:
np.random.seed(config["seed"]) np.random.seed(config["seed"])
print(f"Using colour seed {config['seed']}") print(f"Using colour seed {config['seed']}")
# One RNG snapshot is replayed for every target, so the same physical patch
# keeps the same colour in each UV layout (and reruns reproduce when seeded).
rng_state = np.random.get_state()
start_time = time.time() start_time = time.time()
try: for uv_map, output, uv_layer in targets:
generate_patch_texture( np.random.set_state(rng_state)
obj, cam, scene, depsgraph, uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None
nx=config["nx"], ny=config["ny"], r=config["color_r"], print(f"--- UV map {uv_map!r} -> {output}")
texture_width=config["width"], texture_height=config["height"], try:
output_path=str(output), generate_patch_texture(
uv_centre=uv_centre if config["cross_enabled"] else None, obj, cam, scene, depsgraph,
cross_length=config["cross_length"], nx=config["nx"], ny=config["ny"], r=config["color_r"],
cross_thickness=config["cross_thickness"], texture_width=config["width"], texture_height=config["height"],
cross_color=config["cross_color"], output_path=str(output),
edge_sample_grid=config["edge_sample_grid"], uv_centre=uv_centre,
) cross_length=config["cross_length"],
except Exception as exc: cross_thickness=config["cross_thickness"],
print(f"ERROR: texture generation failed: {exc}", file=sys.stderr) cross_color=config["cross_color"],
return 1 edge_sample_grid=config["edge_sample_grid"],
uv_layer=uv_layer,
image_name=f"PatchTexture_{uv_map}",
)
except Exception as exc:
print(
f"ERROR: texture generation failed for UV map {uv_map!r}: {exc}",
file=sys.stderr,
)
return 1
if not output.is_file() or output.stat().st_size == 0: if not output.is_file() or output.stat().st_size == 0:
print(f"ERROR: texture was not written: {output}", file=sys.stderr) print(
return 1 f"ERROR: texture was not written for UV map {uv_map!r}: {output}",
file=sys.stderr,
)
return 1
print(f"Texture complete: {output}") print(f"Texture complete: {len(targets)} file(s)")
for uv_map, output, _layer in targets:
print(f" [{uv_map}] {output} ({output.stat().st_size} bytes)")
print(f" {config['width']}x{config['height']} grid {config['nx']}x{config['ny']}") print(f" {config['width']}x{config['height']} grid {config['nx']}x{config['ny']}")
print(f"Completed in {time.time() - start_time:.2f} seconds.") print(f"Completed in {time.time() - start_time:.2f} seconds.")
return 0 return 0
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0977139a3df265d2abbb5225ffe262da24b2cb4f8c9268cca93b92ae4a061f3c
size 628658
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bdd83c4138fd42308a262b9f2ea7d421e24cd0ef742262de1b02167698c5cc3b
size 693526
+6 -2
View File
@@ -24,12 +24,16 @@ objects:
# Headless colored-patch texture generation (`orisys-design texture`). # Headless colored-patch texture generation (`orisys-design texture`).
# UV unwrapping is still done in the Blender GUI: the headless run consumes the # 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. # UV maps already stored in source_blend and never creates or unwraps one.
# One PNG is written per UV map below. All maps share the settings that follow
# and the same patch colours, so they differ only by UV layout.
# These are the revision defaults; CLI flags override every value. # These are the revision defaults; CLI flags override every value.
texture_patch: texture_patch:
mesh: sensing_surface mesh: sensing_surface
camera: cam_sensor camera: cam_sensor
filename: patch_texture.png uv_outputs:
produce: patch_texture_produce.png # hardware production layout
compute: patch_texture_compute.png # compute/runtime layout
width: 3280 # 1640*2 width: 3280 # 1640*2
height: 3200 # 1600*2 height: 3200 # 1600*2
nx: 80 nx: 80
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e2a9aba857f0ea951da02424d36ae28a62c2cabafbaadaa15d06b6626ae46e39 oid sha256:2e21136b063a6694882db3d24ac331cc6d66683ade570b8266b6ad4baba6ba61
size 673299 size 688142
+20 -15
View File
@@ -29,6 +29,7 @@ from orisys_sensor_design.texture import (
build_texture_job, build_texture_job,
resolve_texture_output_dir, resolve_texture_output_dir,
resolve_texture_settings, resolve_texture_settings,
texture_output_paths,
texture_overrides_from_args, texture_overrides_from_args,
write_texture_job, write_texture_job,
) )
@@ -224,7 +225,7 @@ def cmd_export(revision_dir: Path, profile: str) -> int:
def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int: def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
"""Generate the colored patch texture headlessly for one revision.""" """Generate one colored patch texture per configured UV map, headlessly."""
root = repo_root(revision_dir) root = repo_root(revision_dir)
paths = RevisionPaths.from_revision_dir(revision_dir) paths = RevisionPaths.from_revision_dir(revision_dir)
errors = validate_revision_metadata(paths) errors = validate_revision_metadata(paths)
@@ -242,8 +243,8 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
print(f"ERROR: {message}", file=sys.stderr) print(f"ERROR: {message}", file=sys.stderr)
return 2 return 2
output_path = output_dir / settings.filename targets = texture_output_paths(output_dir, settings)
job = build_texture_job(paths, settings, output_path) job = build_texture_job(paths, settings, output_dir)
job_path = write_texture_job(job, output_dir) job_path = write_texture_job(job, output_dir)
print(f"Wrote texture job: {job_path}") print(f"Wrote texture job: {job_path}")
@@ -255,14 +256,23 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
if code != 0: if code != 0:
return code return code
if not output_path.is_file() or output_path.stat().st_size == 0: missing = [
print( (target, path)
f"ERROR: Blender finished but the texture is missing or empty: {output_path}", for target, path in targets
file=sys.stderr, if not path.is_file() or path.stat().st_size == 0
) ]
if missing:
for target, path in missing:
print(
f"ERROR: Blender finished but the texture for UV map "
f"{target.uv_map!r} is missing or empty: {path}",
file=sys.stderr,
)
return 3 return 3
print(f"OK: colored patch texture written to {output_path}") print(f"OK: wrote {len(targets)} colored patch texture(s) to {output_dir}")
for target, path in targets:
print(f" [{target.uv_map}] {path}")
print( print(
f" mode=patches {settings.width}x{settings.height} " f" mode=patches {settings.width}x{settings.height} "
f"grid={settings.nx}x{settings.ny} mesh={settings.mesh!r} camera={settings.camera!r}" f"grid={settings.nx}x{settings.ny} mesh={settings.mesh!r} camera={settings.camera!r}"
@@ -310,7 +320,7 @@ def main(argv: list[str] | None = None) -> None:
texture_p = sub.add_parser( texture_p = sub.add_parser(
"texture", "texture",
help="Generate the colored patch texture headlessly (UV unwrap stays in-app)", help="Generate one colored patch texture per UV map (UV unwrap stays in-app)",
) )
texture_p.add_argument("revision_dir", type=Path) texture_p.add_argument("revision_dir", type=Path)
texture_p.add_argument( texture_p.add_argument(
@@ -319,11 +329,6 @@ def main(argv: list[str] | None = None) -> None:
default=None, default=None,
help="Output directory (default: build/textures/<sensor>/<revision>)", 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("--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("--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("--width", type=int, default=None, help="Texture width in pixels")
+101 -24
View File
@@ -6,8 +6,13 @@ and invokes ``blender/scripts/random_patch_dd02c.py`` headlessly. Every path
handed to Blender is absolute, so no manual copying between directories is handed to Blender is absolute, so no manual copying between directories is
required. required.
A revision may carry several UV maps on the sensing surface (for example
``produce`` and ``compute``). ``texture_patch.uv_outputs`` names one output PNG
per UV map; a single run renders all of them with the same camera-space patch
colours, so the files differ only in how the unwrap arranges the patches.
UV unwrapping is deliberately outside this module: the headless run consumes the UV unwrapping is deliberately outside this module: the headless run consumes the
UV map already authored in the Blender GUI. UV maps already authored in the Blender GUI.
""" """
from __future__ import annotations from __future__ import annotations
@@ -23,15 +28,22 @@ from orisys_sensor_design.manifest import (
load_yaml, load_yaml,
) )
TEXTURE_JOB_SCHEMA = "orisys-sensor-design/texture-job-v1" TEXTURE_JOB_SCHEMA = "orisys-sensor-design/texture-job-v2"
TEXTURE_JOB_FILENAME = "_texture_job.json" TEXTURE_JOB_FILENAME = "_texture_job.json"
TEXTURE_RELATIVE_ROOT = Path("build") / "textures" TEXTURE_RELATIVE_ROOT = Path("build") / "textures"
TEXTURE_MODE = "patches" TEXTURE_MODE = "patches"
# Built-in fallbacks. sensors/<sensor>/<revision>/design.yaml is the source of # 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. # truth for a revision; these only apply when a key is absent there.
#
# `uv_outputs` maps a UV map name on the mesh to the PNG written for it. Each run
# renders every entry with the same camera-space patch colours, so the maps
# differ only by how the unwrap arranges the patches.
DEFAULT_TEXTURE_PATCH: dict[str, Any] = { DEFAULT_TEXTURE_PATCH: dict[str, Any] = {
"filename": "patch_texture.png", "uv_outputs": {
"produce": "patch_texture_produce.png",
"compute": "patch_texture_compute.png",
},
"width": 2048, "width": 2048,
"height": 2048, "height": 2048,
"nx": 80, "nx": 80,
@@ -51,7 +63,7 @@ _TEXTURE_PATCH_KEYS = frozenset(
{ {
"mesh", "mesh",
"camera", "camera",
"filename", "uv_outputs",
"width", "width",
"height", "height",
"nx", "nx",
@@ -74,13 +86,35 @@ class TextureConfigError(ValueError):
""" """
@dataclass(frozen=True)
class TextureOutputTarget:
"""One generated PNG: the mesh UV map it reads and the file it writes."""
uv_map: str
filename: str
def output_path(self, output_dir: Path) -> Path:
return Path(output_dir) / self.filename
def to_job_entry(self, output_dir: Path) -> dict[str, Any]:
"""Return the job entry Blender consumes (absolute path, ordered)."""
return {
"uv_map": self.uv_map,
"output": str(self.output_path(output_dir).resolve()),
}
@dataclass(frozen=True) @dataclass(frozen=True)
class TexturePatchSettings: class TexturePatchSettings:
"""Resolved colored-patch settings for one headless run.""" """Resolved colored-patch settings for one headless run.
All rendering settings are shared by every target in ``outputs``; the targets
differ only in which UV map they read and the file they write.
"""
mesh: str mesh: str
camera: str camera: str
filename: str outputs: tuple[TextureOutputTarget, ...]
width: int width: int
height: int height: int
nx: int nx: int
@@ -189,6 +223,22 @@ def merge_texture_patch(
return merged return merged
def _bare_png_name_problems(value: Any, label: str) -> list[str]:
"""Return messages for a value that must be a bare ``.png`` file name."""
if not isinstance(value, str) or not value.strip():
return [f"{label}: must be a non-empty file name"]
if (
"/" in value
or "\\" in value
or Path(value).name != value
or value in {".", ".."}
):
return [f"{label}: must be a bare file name without directories ({value!r})"]
if Path(value).suffix.lower() != ".png":
return [f"{label}: must end with .png ({value!r})"]
return []
def validate_texture_patch(block: dict[str, Any], objects: dict[str, str]) -> list[str]: 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.""" """Return one message per invalid value in a merged texture_patch mapping."""
errors: list[str] = [] errors: list[str] = []
@@ -205,18 +255,30 @@ def validate_texture_patch(block: dict[str, Any], objects: dict[str, str]) -> li
elif not isinstance(camera, str) or not camera.strip(): elif not isinstance(camera, str) or not camera.strip():
errors.append("camera: must be a non-empty object name") errors.append("camera: must be a non-empty object name")
filename = block.get("filename") uv_outputs = block.get("uv_outputs")
if not isinstance(filename, str) or not filename.strip(): if not isinstance(uv_outputs, dict) or not uv_outputs:
errors.append("filename: must be a non-empty file name") errors.append(
elif ( "uv_outputs: must be a non-empty mapping of UV map name to PNG file name"
"/" in filename )
or "\\" in filename else:
or Path(filename).name != filename seen_outputs: dict[str, str] = {}
or filename in {".", ".."} for uv_name, out_name in uv_outputs.items():
): if not isinstance(uv_name, str) or not uv_name.strip():
errors.append(f"filename: must be a bare file name without directories ({filename!r})") errors.append(
elif Path(filename).suffix.lower() != ".png": f"uv_outputs: UV map names must be non-empty strings (got {uv_name!r})"
errors.append(f"filename: must end with .png ({filename!r})") )
continue
problems = _bare_png_name_problems(out_name, f"uv_outputs.{uv_name}")
if problems:
errors.extend(problems)
continue
if out_name in seen_outputs:
errors.append(
f"uv_outputs: duplicate output file name {out_name!r} "
f"for {seen_outputs[out_name]!r} and {uv_name!r}"
)
else:
seen_outputs[out_name] = uv_name
for key in ("width", "height"): for key in ("width", "height"):
value = block.get(key) value = block.get(key)
@@ -279,10 +341,15 @@ def resolve_texture_settings(
if len(color) == 3: if len(color) == 3:
color.append(1.0) color.append(1.0)
outputs = tuple(
TextureOutputTarget(uv_map=str(uv_name), filename=str(out_name))
for uv_name, out_name in block["uv_outputs"].items()
)
return TexturePatchSettings( return TexturePatchSettings(
mesh=str(block["mesh"]), mesh=str(block["mesh"]),
camera=str(block["camera"]), camera=str(block["camera"]),
filename=str(block["filename"]), outputs=outputs,
width=int(block["width"]), width=int(block["width"]),
height=int(block["height"]), height=int(block["height"]),
nx=int(block["nx"]), nx=int(block["nx"]),
@@ -301,8 +368,8 @@ def texture_revision_rel(root: Path, paths: RevisionPaths) -> Path:
"""Return ``<sensor>/<revision>`` for the revision below the repo's sensors/. """Return ``<sensor>/<revision>`` for the revision below the repo's sensors/.
Directory names are used rather than design.yaml's ``sensor``/``revision`` Directory names are used rather than design.yaml's ``sensor``/``revision``
fields because those are not reliable across sensor folders (finger-xynova fields so the output layout follows the on-disk revision tree regardless of
still declares ``sensor: finger``). what those metadata fields say.
""" """
sensors_root = (root / "sensors").resolve() sensors_root = (root / "sensors").resolve()
try: try:
@@ -336,10 +403,19 @@ def texture_job_path(output_dir: Path) -> Path:
return Path(output_dir) / TEXTURE_JOB_FILENAME return Path(output_dir) / TEXTURE_JOB_FILENAME
def texture_output_paths(
output_dir: Path,
settings: TexturePatchSettings,
) -> list[tuple[TextureOutputTarget, Path]]:
"""Pair every configured target with its absolute output path, in order."""
resolved_dir = Path(output_dir).resolve()
return [(target, target.output_path(resolved_dir)) for target in settings.outputs]
def build_texture_job( def build_texture_job(
paths: RevisionPaths, paths: RevisionPaths,
settings: TexturePatchSettings, settings: TexturePatchSettings,
output_path: Path, output_dir: Path,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Build the JSON job dict that random_patch_dd02c.py consumes (stdlib JSON).""" """Build the JSON job dict that random_patch_dd02c.py consumes (stdlib JSON)."""
return { return {
@@ -347,7 +423,9 @@ def build_texture_job(
"mode": TEXTURE_MODE, "mode": TEXTURE_MODE,
"revision_dir": str(paths.root.resolve()), "revision_dir": str(paths.root.resolve()),
"source_blend": str(paths.source_blend.resolve()), "source_blend": str(paths.source_blend.resolve()),
"output": str(Path(output_path).resolve()), "outputs": [
target.to_job_entry(Path(output_dir)) for target in settings.outputs
],
"objects": { "objects": {
"mesh": settings.mesh, "mesh": settings.mesh,
"camera": settings.camera, "camera": settings.camera,
@@ -370,7 +448,6 @@ def texture_overrides_from_args(args: Any) -> dict[str, Any]:
overrides: dict[str, Any] = { overrides: dict[str, Any] = {
"mesh": getattr(args, "mesh", None), "mesh": getattr(args, "mesh", None),
"camera": getattr(args, "cam", None), "camera": getattr(args, "cam", None),
"filename": getattr(args, "filename", None),
"width": getattr(args, "width", None), "width": getattr(args, "width", None),
"height": getattr(args, "height", None), "height": getattr(args, "height", None),
"nx": getattr(args, "nx", None), "nx": getattr(args, "nx", None),
+130 -30
View File
@@ -15,6 +15,7 @@ from orisys_sensor_design.texture import (
TEXTURE_JOB_FILENAME, TEXTURE_JOB_FILENAME,
TEXTURE_JOB_SCHEMA, TEXTURE_JOB_SCHEMA,
TextureConfigError, TextureConfigError,
TextureOutputTarget,
build_texture_job, build_texture_job,
load_texture_patch_block, load_texture_patch_block,
merge_texture_patch, merge_texture_patch,
@@ -22,10 +23,16 @@ from orisys_sensor_design.texture import (
resolve_texture_settings, resolve_texture_settings,
texture_job_path, texture_job_path,
texture_output_dir, texture_output_dir,
texture_output_paths,
texture_revision_rel, texture_revision_rel,
write_texture_job, write_texture_job,
) )
DEFAULT_OUTPUTS = [
("produce", "patch_texture_produce.png"),
("compute", "patch_texture_compute.png"),
]
@pytest.fixture() @pytest.fixture()
def revision_dir() -> Path: def revision_dir() -> Path:
@@ -38,7 +45,6 @@ def _texture_namespace(
namespace = argparse.Namespace( namespace = argparse.Namespace(
revision_dir=revision_dir, revision_dir=revision_dir,
output_dir=output_dir, output_dir=output_dir,
filename=None,
mesh=None, mesh=None,
cam=None, cam=None,
width=None, width=None,
@@ -108,16 +114,34 @@ def test_output_dir_explicit_override_wins(revision_dir: Path, tmp_path: Path) -
def test_output_dir_uses_directory_names_not_design_sensor_field( def test_output_dir_uses_directory_names_not_design_sensor_field(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""finger-xynova declares `sensor: finger`; the folder name must win.""" """The on-disk sensor folder must win over design.yaml's sensor field."""
revision = _write_revision(tmp_path, sensor_dir="finger-xynova", revision_name="r01") revision = _write_revision(tmp_path, sensor_dir="finger-xynova", revision_name="r01")
paths = RevisionPaths.from_revision_dir(revision) paths = RevisionPaths.from_revision_dir(revision)
assert texture_revision_rel(tmp_path, paths) == Path("finger-xynova") / "r01" assert texture_revision_rel(tmp_path, paths) == Path("finger-xynova") / "r01"
def test_output_paths_pair_each_target(revision_dir: Path, tmp_path: Path) -> None:
paths = RevisionPaths.from_revision_dir(revision_dir)
settings = resolve_texture_settings(paths)
targets = texture_output_paths(tmp_path, settings)
assert [(target.uv_map, path.name) for target, path in targets] == DEFAULT_OUTPUTS
assert all(path.parent == tmp_path.resolve() for _, path in targets)
def test_job_path_sits_beside_texture(tmp_path: Path) -> None: def test_job_path_sits_beside_texture(tmp_path: Path) -> None:
assert texture_job_path(tmp_path / "out") == tmp_path / "out" / TEXTURE_JOB_FILENAME assert texture_job_path(tmp_path / "out") == tmp_path / "out" / TEXTURE_JOB_FILENAME
def test_output_target_entry_is_absolute(revision_dir: Path, tmp_path: Path) -> None:
target = TextureOutputTarget(uv_map="produce", filename="a.png")
entry = target.to_job_entry(tmp_path)
assert entry["uv_map"] == "produce"
assert Path(entry["output"]).is_absolute()
assert Path(entry["output"]).name == "a.png"
# ------------------------------------------------------- settings merge # ------------------------------------------------------- settings merge
@@ -125,7 +149,7 @@ def test_defaults_come_from_design_yaml(revision_dir: Path) -> None:
settings = resolve_texture_settings(RevisionPaths.from_revision_dir(revision_dir)) settings = resolve_texture_settings(RevisionPaths.from_revision_dir(revision_dir))
assert settings.mesh == "sensing_surface" assert settings.mesh == "sensing_surface"
assert settings.camera == "cam_sensor" assert settings.camera == "cam_sensor"
assert settings.filename == "patch_texture.png" assert [(target.uv_map, target.filename) for target in settings.outputs] == DEFAULT_OUTPUTS
assert (settings.width, settings.height) == (3280, 3200) assert (settings.width, settings.height) == (3280, 3200)
assert (settings.nx, settings.ny) == (80, 45) assert (settings.nx, settings.ny) == (80, 45)
assert settings.color_r == pytest.approx(0.05) assert settings.color_r == pytest.approx(0.05)
@@ -144,11 +168,23 @@ def test_builtin_defaults_when_design_block_absent(tmp_path: Path) -> None:
settings = resolve_texture_settings(paths) settings = resolve_texture_settings(paths)
assert settings.width == DEFAULT_TEXTURE_PATCH["width"] assert settings.width == DEFAULT_TEXTURE_PATCH["width"]
assert settings.height == DEFAULT_TEXTURE_PATCH["height"] assert settings.height == DEFAULT_TEXTURE_PATCH["height"]
assert [target.uv_map for target in settings.outputs] == ["produce", "compute"]
# Object names fall back to design.yaml `objects` defaults. # Object names fall back to design.yaml `objects` defaults.
assert settings.mesh == "sensing_surface" assert settings.mesh == "sensing_surface"
assert settings.camera == "cam_sensor" assert settings.camera == "cam_sensor"
def test_design_yaml_uv_outputs_replace_builtin_defaults(tmp_path: Path) -> None:
revision = _write_revision(
tmp_path,
"texture_patch:\n uv_outputs:\n custom: my_custom_layout.png\n",
)
settings = resolve_texture_settings(RevisionPaths.from_revision_dir(revision))
assert [(target.uv_map, target.filename) for target in settings.outputs] == [
("custom", "my_custom_layout.png")
]
def test_cli_overrides_beat_design_yaml(revision_dir: Path) -> None: def test_cli_overrides_beat_design_yaml(revision_dir: Path) -> None:
paths = RevisionPaths.from_revision_dir(revision_dir) paths = RevisionPaths.from_revision_dir(revision_dir)
settings = resolve_texture_settings( settings = resolve_texture_settings(
@@ -177,6 +213,8 @@ def test_cli_overrides_beat_design_yaml(revision_dir: Path) -> None:
assert settings.cross_length_px == 8 assert settings.cross_length_px == 8
# A 3-channel cross colour is widened to RGBA. # A 3-channel cross colour is widened to RGBA.
assert settings.cross_color == (0.0, 1.0, 0.0, 1.0) assert settings.cross_color == (0.0, 1.0, 0.0, 1.0)
# Shared rendering settings leave both targets intact.
assert [target.uv_map for target in settings.outputs] == ["produce", "compute"]
def test_unset_overrides_do_not_erase_configured_values(revision_dir: Path) -> None: def test_unset_overrides_do_not_erase_configured_values(revision_dir: Path) -> None:
@@ -194,6 +232,14 @@ def test_unknown_design_key_is_rejected(tmp_path: Path) -> None:
assert "widht" in str(excinfo.value) assert "widht" in str(excinfo.value)
def test_legacy_filename_key_is_rejected(tmp_path: Path) -> None:
"""The singular filename key was replaced by uv_outputs."""
revision = _write_revision(tmp_path, "texture_patch:\n filename: patch_texture.png\n")
with pytest.raises(TextureConfigError) as excinfo:
resolve_texture_settings(RevisionPaths.from_revision_dir(revision))
assert "filename" in str(excinfo.value)
def test_unknown_cross_key_is_rejected(tmp_path: Path) -> None: def test_unknown_cross_key_is_rejected(tmp_path: Path) -> None:
revision = _write_revision( revision = _write_revision(
tmp_path, "texture_patch:\n cross:\n thickness: 3\n" tmp_path, "texture_patch:\n cross:\n thickness: 3\n"
@@ -220,9 +266,6 @@ def test_texture_patch_must_be_a_mapping(tmp_path: Path) -> None:
({"color_r": 2.0}, "color_r"), ({"color_r": 2.0}, "color_r"),
({"edge_sample_grid": 1}, "edge_sample_grid"), ({"edge_sample_grid": 1}, "edge_sample_grid"),
({"seed": "abc"}, "seed"), ({"seed": "abc"}, "seed"),
({"filename": "tex.jpg"}, "filename"),
({"filename": "nested/tex.png"}, "filename"),
({"filename": "..\\tex.png"}, "filename"),
({"cross": {"enabled": "yes"}}, "cross.enabled"), ({"cross": {"enabled": "yes"}}, "cross.enabled"),
({"cross": {"length_px": 0}}, "cross.length_px"), ({"cross": {"length_px": 0}}, "cross.length_px"),
({"cross": {"color": [2.0, 0.0, 0.0, 1.0]}}, "cross.color"), ({"cross": {"color": [2.0, 0.0, 0.0, 1.0]}}, "cross.color"),
@@ -238,6 +281,31 @@ def test_invalid_overrides_are_rejected(
assert expected in str(excinfo.value) assert expected in str(excinfo.value)
@pytest.mark.parametrize(
("overrides", "expected"),
[
({"uv_outputs": {}}, "uv_outputs"),
({"uv_outputs": "produce.png"}, "uv_outputs"),
({"uv_outputs": {"": "produce.png"}}, "uv_outputs"),
({"uv_outputs": {"produce": ""}}, "uv_outputs.produce"),
({"uv_outputs": {"produce": "texture.jpg"}}, "uv_outputs.produce"),
({"uv_outputs": {"produce": "nested/a.png"}}, "uv_outputs.produce"),
({"uv_outputs": {"produce": "..\\a.png"}}, "uv_outputs.produce"),
(
{"uv_outputs": {"produce": "same.png", "compute": "same.png"}},
"duplicate output file name",
),
],
)
def test_invalid_uv_outputs_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 # ------------------------------------------------------------- job I/O
@@ -245,9 +313,8 @@ def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path)
paths = RevisionPaths.from_revision_dir(revision_dir) paths = RevisionPaths.from_revision_dir(revision_dir)
settings = resolve_texture_settings(paths, {"width": 256, "height": 256, "seed": 3}) settings = resolve_texture_settings(paths, {"width": 256, "height": 256, "seed": 3})
output_dir = tmp_path / "textures" output_dir = tmp_path / "textures"
output_path = output_dir / settings.filename
job = build_texture_job(paths, settings, output_path) job = build_texture_job(paths, settings, output_dir)
assert job["schema"] == TEXTURE_JOB_SCHEMA assert job["schema"] == TEXTURE_JOB_SCHEMA
assert job["mode"] == "patches" assert job["mode"] == "patches"
assert set(job) == { assert set(job) == {
@@ -255,10 +322,16 @@ def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path)
"mode", "mode",
"revision_dir", "revision_dir",
"source_blend", "source_blend",
"output", "outputs",
"objects", "objects",
"settings", "settings",
} }
assert "output" not in job
assert [entry["uv_map"] for entry in job["outputs"]] == ["produce", "compute"]
assert [Path(entry["output"]).name for entry in job["outputs"]] == [
filename for _, filename in DEFAULT_OUTPUTS
]
assert all(Path(entry["output"]).is_absolute() for entry in job["outputs"])
assert set(job["objects"]) == {"mesh", "camera"} assert set(job["objects"]) == {"mesh", "camera"}
assert set(job["settings"]) == { assert set(job["settings"]) == {
"width", "width",
@@ -280,9 +353,8 @@ def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path)
job_path = write_texture_job(job, output_dir) job_path = write_texture_job(job, output_dir)
assert job_path == output_dir / TEXTURE_JOB_FILENAME assert job_path == output_dir / TEXTURE_JOB_FILENAME
loaded = json.loads(job_path.read_text(encoding="utf-8")) loaded = json.loads(job_path.read_text(encoding="utf-8"))
assert loaded["output"] == str(output_path.resolve()) assert loaded["outputs"] == job["outputs"]
assert loaded["source_blend"] == str(paths.source_blend.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["objects"] == {"mesh": "sensing_surface", "camera": "cam_sensor"}
assert loaded["settings"]["seed"] == 3 assert loaded["settings"]["seed"] == 3
assert loaded["settings"]["cross"]["color"] == [1.0, 0.0, 0.0, 1.0] assert loaded["settings"]["cross"]["color"] == [1.0, 0.0, 0.0, 1.0]
@@ -292,56 +364,76 @@ def test_write_job_creates_output_directory(revision_dir: Path, tmp_path: Path)
paths = RevisionPaths.from_revision_dir(revision_dir) paths = RevisionPaths.from_revision_dir(revision_dir)
settings = resolve_texture_settings(paths) settings = resolve_texture_settings(paths)
nested = tmp_path / "a" / "b" nested = tmp_path / "a" / "b"
write_texture_job(build_texture_job(paths, settings, nested / settings.filename), nested) write_texture_job(build_texture_job(paths, settings, nested), nested)
assert (nested / TEXTURE_JOB_FILENAME).is_file() assert (nested / TEXTURE_JOB_FILENAME).is_file()
# ----------------------------------------------------------- CLI wiring # ----------------------------------------------------------- CLI wiring
def _fake_blender(monkeypatch: pytest.MonkeyPatch, exit_code: int, write_png: bool): def _fake_blender(
monkeypatch: pytest.MonkeyPatch,
exit_code: int,
write_pngs: bool = True,
skip_uv: str | None = None,
):
calls: list[tuple[str, Path, list[str]]] = [] calls: list[tuple[str, Path, list[str]]] = []
def fake_run(script: str, blend: Path, extra_args: list[str]): def fake_run(script: str, blend: Path, extra_args: list[str]):
calls.append((script, blend, list(extra_args))) calls.append((script, blend, list(extra_args)))
if write_png: if write_pngs:
job_path = Path(extra_args[extra_args.index("--job") + 1]) job_path = Path(extra_args[extra_args.index("--job") + 1])
job = json.loads(job_path.read_text(encoding="utf-8")) job = json.loads(job_path.read_text(encoding="utf-8"))
Path(job["output"]).write_bytes(b"\x89PNG\r\n\x1a\n") for entry in job["outputs"]:
if skip_uv is not None and entry["uv_map"] == skip_uv:
continue
Path(entry["output"]).write_bytes(b"\x89PNG\r\n\x1a\n")
return exit_code return exit_code
monkeypatch.setattr(cli, "_run_blender_script", fake_run) monkeypatch.setattr(cli, "_run_blender_script", fake_run)
return calls return calls
def test_cmd_texture_forwards_job_and_verifies_artifact( def test_cmd_texture_forwards_job_and_verifies_both_artifacts(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
) -> None: ) -> None:
calls = _fake_blender(monkeypatch, 0, write_png=True) calls = _fake_blender(monkeypatch, 0)
out_dir = tmp_path / "out" out_dir = tmp_path / "out"
assert cli.cmd_texture(revision_dir, _texture_namespace(revision_dir, out_dir)) == 0 assert cli.cmd_texture(revision_dir, _texture_namespace(revision_dir, out_dir)) == 0
assert len(calls) == 1, "one Blender launch must render every target"
script, blend, extra = calls[0] script, blend, extra = calls[0]
assert script == "random_patch_dd02c.py" assert script == "random_patch_dd02c.py"
assert blend == revision_dir.resolve() / "source" / "geometry.blend" assert blend == revision_dir.resolve() / "source" / "geometry.blend"
assert extra[0] == "--job" assert extra[0] == "--job"
job_path = Path(extra[1]) job_path = Path(extra[1])
assert job_path == out_dir.resolve() / TEXTURE_JOB_FILENAME assert job_path == out_dir.resolve() / TEXTURE_JOB_FILENAME
assert (out_dir / "patch_texture.png").is_file()
for _, filename in DEFAULT_OUTPUTS:
assert (out_dir / filename).is_file()
def test_cmd_texture_propagates_blender_failure( def test_cmd_texture_propagates_blender_failure(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
) -> None: ) -> None:
_fake_blender(monkeypatch, 9, write_png=False) _fake_blender(monkeypatch, 9, write_pngs=False)
namespace = _texture_namespace(revision_dir, tmp_path / "out") namespace = _texture_namespace(revision_dir, tmp_path / "out")
assert cli.cmd_texture(revision_dir, namespace) == 9 assert cli.cmd_texture(revision_dir, namespace) == 9
def test_cmd_texture_detects_missing_texture( def test_cmd_texture_detects_both_textures_missing(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
) -> None: ) -> None:
_fake_blender(monkeypatch, 0, write_png=False) _fake_blender(monkeypatch, 0, write_pngs=False)
namespace = _texture_namespace(revision_dir, tmp_path / "out")
assert cli.cmd_texture(revision_dir, namespace) == 3
def test_cmd_texture_detects_partial_pair(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
) -> None:
"""One UV map written, the other missing, must not look like success."""
_fake_blender(monkeypatch, 0, skip_uv="compute")
namespace = _texture_namespace(revision_dir, tmp_path / "out") namespace = _texture_namespace(revision_dir, tmp_path / "out")
assert cli.cmd_texture(revision_dir, namespace) == 3 assert cli.cmd_texture(revision_dir, namespace) == 3
@@ -349,7 +441,7 @@ def test_cmd_texture_detects_missing_texture(
def test_main_texture_parses_flags_into_job( def test_main_texture_parses_flags_into_job(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
) -> None: ) -> None:
calls = _fake_blender(monkeypatch, 0, write_png=True) calls = _fake_blender(monkeypatch, 0)
out_dir = tmp_path / "flagged" out_dir = tmp_path / "flagged"
with pytest.raises(SystemExit) as excinfo: with pytest.raises(SystemExit) as excinfo:
cli.main( cli.main(
@@ -358,8 +450,6 @@ def test_main_texture_parses_flags_into_job(
str(revision_dir), str(revision_dir),
"--output-dir", "--output-dir",
str(out_dir), str(out_dir),
"--filename",
"custom.png",
"--mesh", "--mesh",
"sensing_surface", "sensing_surface",
"--width", "--width",
@@ -377,21 +467,31 @@ def test_main_texture_parses_flags_into_job(
) )
assert excinfo.value.code == 0 assert excinfo.value.code == 0
job = json.loads( job = json.loads(Path(calls[0][2][1]).read_text(encoding="utf-8"))
Path(calls[0][2][1]).read_text(encoding="utf-8") assert [entry["uv_map"] for entry in job["outputs"]] == ["produce", "compute"]
)
assert job["settings"]["width"] == 256 assert job["settings"]["width"] == 256
assert job["settings"]["nx"] == 4 assert job["settings"]["nx"] == 4
assert job["settings"]["ny"] == 3 assert job["settings"]["ny"] == 3
assert job["settings"]["seed"] == 7 assert job["settings"]["seed"] == 7
assert job["settings"]["cross"]["enabled"] is False assert job["settings"]["cross"]["enabled"] is False
assert (out_dir / "custom.png").is_file() for _, filename in DEFAULT_OUTPUTS:
assert (out_dir / filename).is_file()
def test_main_texture_rejects_filename_flag(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path
) -> None:
"""The singular --filename flag is gone; per-UV names come from design.yaml."""
_fake_blender(monkeypatch, 0)
with pytest.raises(SystemExit) as excinfo:
cli.main(["texture", str(revision_dir), "--filename", "custom.png"])
assert excinfo.value.code == 2
def test_main_texture_rejects_invalid_flag_with_exit_2( def test_main_texture_rejects_invalid_flag_with_exit_2(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path monkeypatch: pytest.MonkeyPatch, revision_dir: Path
) -> None: ) -> None:
_fake_blender(monkeypatch, 0, write_png=True) _fake_blender(monkeypatch, 0)
with pytest.raises(SystemExit) as excinfo: with pytest.raises(SystemExit) as excinfo:
cli.main(["texture", str(revision_dir), "--width", "0"]) cli.main(["texture", str(revision_dir), "--width", "0"])
assert excinfo.value.code == 2 assert excinfo.value.code == 2