migrated texture generation script to cli
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
sensor: finger
|
||||
sensor: finger-xynova
|
||||
revision: r01
|
||||
status: draft
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cdceb6bb091a7f8e1f820063c18b84ea9fa686949381f6d7b1cee74d63c27c18
|
||||
size 677092
|
||||
oid sha256:e2a9aba857f0ea951da02424d36ae28a62c2cabafbaadaa15d06b6626ae46e39
|
||||
size 673299
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user