commit a29ca6846c31b04360ccb42973dd6319093b3d8f Author: ykcheungab Date: Wed Sep 16 21:04:28 2026 +0800 initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..642cfa3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# Git LFS — enable after Gitea LFS_START_SERVER=true on the NAS +*.blend filter=lfs diff=lfs merge=lfs -text +*.blend1 filter=lfs diff=lfs merge=lfs -text +*.exr filter=lfs diff=lfs merge=lfs -text +*.hdr filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text +*.tif filter=lfs diff=lfs merge=lfs -text +*.tiff filter=lfs diff=lfs merge=lfs -text +*.npz filter=lfs diff=lfs merge=lfs -text +*.glb filter=lfs diff=lfs merge=lfs -text +*.gltf filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0d4567 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +.venv-*/ +*.egg-info/ +dist/ +build/export/ +.pytest_cache/ + +# Blender +*.blend1 +*.blend2 + +# Renders and caches +**/renders/ +**/.cache/ +*.tmp + +# Editor +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e4b595 --- /dev/null +++ b/README.md @@ -0,0 +1,219 @@ +# orisys-sensor-design + +Nominal sensor geometry and Blender authoring for Orisys. This repo is the **design** +source of truth. Measured calibration, unroll maps, and shipped runtime bundles live in +`orisys-sdk` and compiled `.oryscene` packages live in `orisys-app`. + +For the full long-term workflow see `sensor-design-workflow.md` in the workspace root. +This repository intentionally implements a **minimal v1** for a small team. + +## Two buckets (daily work) + +| Bucket | Where | Contents | +|--------|-------|----------| +| **Design** | this repo | `.blend`, nominal export, release manifest | +| **Runtime** | `orisys-sdk` / `orisys-app` | measured cal, unroll map, wheel/App assets, `.oryscene` | + +Never copy runtime NPZ files into this repo to “fix” a mismatch. Promote through a +manifest + lock file instead. + +## Layout + +```text +sensors/finger/ +├── current.yaml # active revision pointer +└── r01/ + ├── design.yaml # units, frames, object names + ├── source/geometry.blend # LFS: named collections + objects + ├── profiles/ + │ ├── compute.yaml # → SDK bundle layout (Studio runtime) + │ └── studio.yaml # inputs for orisys-app compile_scene + └── golden/ # optional preview + metrics +``` + +## Prerequisites + +- Python 3.10+ +- [Git LFS](https://git-lfs.com/) on every client (`git lfs install`) +- Blender version pinned in `blender.lock.json` (install separately) + +### Finding Blender on Windows + +The CLI looks for Blender in this order: + +1. Env `ORISYS_BLENDER` or `BLENDER_EXE` (absolute path to `blender.exe`) +2. `blender` / `blender.exe` on `PATH` +3. Auto-scan `%ProgramFiles%\Blender Foundation\Blender *\blender.exe` + (prefers `blender.lock.json` version when installed, otherwise newest) +4. Optional `"executable"` in `blender.lock.json` (local only; do not commit machine paths) + +```powershell +$env:ORISYS_BLENDER = "C:\Program Files\Blender Foundation\Blender 5.0\blender.exe" +``` + +Convenience launcher (auto-finds Blender for this process, then exports): + +```powershell +.\tools\export-compute.bat +``` + +## Commands + +Install the CLI (editable): + +```powershell +cd D:\orisys_ws\orisys-sensor-design +python -m pip install -e . +``` + +Three commands for v1: + +```powershell +# 1) Check revision metadata and (optionally) Blender scene conventions +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 +orisys-design manifest sensors/finger/r01 +``` + +Headless Blender export uses a JSON job file (stdlib `json` inside Blender — no +PyYAML required). `orisys-design export` writes it automatically from `design.yaml`: + +```powershell +orisys-design export sensors/finger/r01 --profile compute +# writes build/export/finger/r01/_export_job.json then runs Blender +``` + +Manual Blender invocation with the same job: + +```powershell +blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/export.py -- ` + --job D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\_export_job.json +``` + +Optional flag overrides (`--output`, `--mesh`, `--cam`, `--origin`) win over job values. + +Export output is grouped by revision: + +```text +build/export/finger/r01/ +├── _export_job.json # Blender input, regenerated by export +├── manifest.json # release manifest from `orisys-design manifest` +└── compute/ # clean SDK-shaped payload + ├── mesh.npz + ├── camera_intrinsics.npz + └── world_to_cam.npy +``` + +Collection check: + +```powershell +blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/validate.py -- sensors/finger/r01 +``` + +## Profiles + +- **`compute`** — `sensing_surface` + `cam_sensor` relative to `origin_sensor`. Writes + `mesh.npz`, `camera_intrinsics.npz`, `world_to_cam.npy` (here “world” means the + sensor frame). Studio and the wheel build consume this tree via `orisys-sdk` → + `assets_dir`. +- **`studio`** — documents how to compile a `.oryscene` from the **compute** export + plus the measured unroll map in SDK. Observation cameras in Studio are derived from + mesh bounds; no Blender `cam_viz` is required. + +## Promotion (SDK / App) + +After export: + +1. Tag this repo, e.g. `finger-r01-assets-v1.0.0`. +2. Copy or attach the `build/export/finger/r01/compute/` folder to the SDK asset path + (or publish as a release artifact). +3. Keep `build/export/finger/r01/manifest.json` beside the payload, and update + optional `design-lock.json` in `orisys-sdk` with commit + bundle SHA256. +4. Run `orisys-app` `compile_scene.py` using the locked compute export + SDK unroll map. + +Example lock file: `examples/design-lock.json`. + +## Conventions + +- **Units**: metres at export boundaries. Author in mm if you like; set + `meters_per_source_unit: 0.001` in `design.yaml`. +- **Collections**: `FRAMES`, `EXPORT_COMPUTE`, `ASSEMBLY`, `DESIGN_SIM`, `EXCLUDE`. +- **Objects**: `origin_sensor`, `sensing_surface`, `cam_sensor`. +- **Matrix names** must state direction; exported pose file remains `world_to_cam.npy` + for SDK compatibility (sensor frame → camera). +- **Git LFS**: `*.blend`, large textures, exported `*.npz` in `build/export/`. + +## Git LFS on Gitea (NAS) + +**Short answer: not difficult.** Gitea has built-in LFS; NAS hosting is common. Plan for +extra disk on the NAS volume that holds LFS objects, not just the git bare repos. + +### Server (one-time, ~5 minutes) + +1. **Enable LFS in Gitea** — edit `app.ini` (or Docker env): + + ```ini + [server] + LFS_START_SERVER = true + + [lfs] + PATH = /data/git/lfs + ``` + + Docker Compose equivalent: + + ```yaml + environment: + - GITEA__server__LFS_START_SERVER=true + ``` + + Point `PATH` at persistent NAS storage (same volume as `/data` is fine). + +2. **Restart Gitea** after changing config. + +3. **Optional**: set `LFS_JWT_SECRET` in Docker/env for stable LFS auth across restarts + (generate once, keep in your NAS secrets). + +4. **Reverse proxy**: LFS uploads use HTTP(S) batch API. Ensure your NAS proxy allows + large request bodies (nginx `client_max_body_size`, etc.). SSH git push still works + for normal objects; LFS file transfer is usually HTTPS unless you enable pure SSH LFS. + +5. **Backup**: include the LFS directory in NAS backup — it is not inside git history. + +Requirements: Git ≥ 2.1.2 on the server (Gitea’s embedded git is enough). + +### Clients (each developer) + +```powershell +git lfs install +git clone git@your-nas:team/orisys-sensor-design.git +cd orisys-sensor-design +git lfs pull +``` + +This repo’s `.gitattributes` already tracks `*.blend` and large binaries. After +`git lfs track`, always commit `.gitattributes`. + +### Typical pitfalls + +| Issue | Fix | +|-------|-----| +| Push succeeds but `.blend` is a tiny pointer file | Run `git lfs install` before commit; re-add file | +| LFS upload 413 / timeout | Increase proxy body size and Gitea `[server] LFS_HTTP_TIMEOUT` if needed | +| Wrong permissions on NAS volume | Match container `USER_UID` / `USER_GID` to volume owner | +| Repo already has large blobs in history | Do not rewrite history casually; use `git lfs migrate` only with team agreement | + +Pure SSH LFS (`LFS_ALLOW_PURE_SSH`) is optional and still has client quirks; HTTPS LFS +behind your NAS reverse proxy is the path of least resistance. + +## Phase plan + +| Phase | Goal | +|-------|------| +| **A** (now) | Repo + LFS + r01 + headless compute export | +| **B** | Lock file in SDK/App + CI reproduces export hashes | +| **C** (later) | Markers, camera metrics, simulation datasets | diff --git a/blender.lock.json b/blender.lock.json new file mode 100644 index 0000000..2927467 --- /dev/null +++ b/blender.lock.json @@ -0,0 +1,6 @@ +{ + "blender_version": "4.2.0", + "blender_lts": "4.2", + "executable": null, + "notes": "Pin the same major.minor across the team. Set ORISYS_BLENDER or optional 'executable' to the blender binary if it is not on PATH. Headless export: blender -b -P blender/scripts/export.py -- --output DIR --mesh sensing_surface --cam cam_sensor --origin origin_sensor" +} diff --git a/blender/scripts/blender_export.py b/blender/scripts/blender_export.py new file mode 100644 index 0000000..aea409e --- /dev/null +++ b/blender/scripts/blender_export.py @@ -0,0 +1,14 @@ +"""Deprecated interactive export helpers. + +Use the headless script instead: + + blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/export.py -- \\ + --output --mesh sensing_surface --cam cam_sensor --origin origin_sensor + +Or: orisys-design export sensors/finger/r01 --profile compute +""" + +raise SystemExit( + "blender_export.py is deprecated. Use blender/scripts/export.py (headless) " + "or: orisys-design export sensors/finger/r01 --profile compute" +) diff --git a/blender/scripts/export.py b/blender/scripts/export.py new file mode 100644 index 0000000..9c74ead --- /dev/null +++ b/blender/scripts/export.py @@ -0,0 +1,281 @@ +"""Blender headless nominal compute export (YAML-free). + +The host CLI (orisys-design export) 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/export.py -- \\ + --job + +Manual overrides (optional, win over job values): + + --output DIR --mesh sensing_surface --cam cam_sensor --origin origin_sensor + +Writes only the compute payload (mesh.npz, camera_intrinsics.npz, world_to_cam.npy) +under the job's output directory. Metadata such as _export_job.json and +manifest.json live one level above the payload directory. +""" + +# pyright: reportMissingImports=false + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +import bmesh +import bpy +import numpy as np +from mathutils import Matrix + +_DEFAULT_OBJECTS = { + "sensing_surface": "sensing_surface", + "cam_sensor": "cam_sensor", + "origin_sensor": "origin_sensor", +} + + +def _argv_after_double_dash() -> list[str]: + if "--" not in sys.argv: + return [] + return sys.argv[sys.argv.index("--") + 1 :] + + +def _require_object(name: str, expected_type: str | None = None) -> bpy.types.Object: + obj = bpy.data.objects.get(name) + if obj is None: + raise RuntimeError(f"Object not found: {name!r}") + if expected_type is not None and obj.type != expected_type: + raise RuntimeError( + f"Object {name!r} has type {obj.type!r}, expected {expected_type!r}" + ) + return obj + + +def _load_job(path: Path) -> tuple[str | None, dict]: + """Load output path and objects map from a JSON export job.""" + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"Export job must be a JSON object: {path}") + objects = data.get("objects") or {} + if not isinstance(objects, dict): + raise ValueError(f"Export job 'objects' must be a mapping: {path}") + output = data.get("output") + return (str(output) if output else None), objects + + +def export_camera_intrinsics(camera_obj: bpy.types.Object, scene: bpy.types.Scene): + """Return OpenCV-style K (3x3) and render width/height from Blender camera.""" + cam = camera_obj.data + render = scene.render + res_x = int(render.resolution_x) + res_y = int(render.resolution_y) + + if cam.sensor_fit == "HORIZONTAL": + sensor_width = float(cam.sensor_width) + sensor_height = sensor_width / (res_x / res_y) + else: + sensor_height = float(cam.sensor_height) + sensor_width = sensor_height * (res_x / res_y) + focal = float(cam.lens) + + pixel_width = sensor_width / res_x + pixel_height = sensor_height / res_y + fx = focal / pixel_width + fy = focal / pixel_height + cx = res_x / 2.0 + cy = res_y / 2.0 + + K = np.array([[fx, 0.0, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]], dtype=np.float64) + return K, res_x, res_y + + +def _triangulated_mesh( + obj: bpy.types.Object, +) -> tuple[bpy.types.Mesh, Matrix, object]: + """Return evaluated triangulated mesh, world matrix, and cleanup callback.""" + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_obj = obj.evaluated_get(depsgraph) + mesh = eval_obj.to_mesh() + world = eval_obj.matrix_world.copy() + bm = bmesh.new() + try: + bm.from_mesh(mesh) + bmesh.ops.triangulate(bm, faces=bm.faces[:]) + bm.to_mesh(mesh) + finally: + bm.free() + mesh.update() + + def _cleanup() -> None: + eval_obj.to_mesh_clear() + + return mesh, world, _cleanup + + +def export_mesh_in_sensor_frame( + obj: bpy.types.Object, + t_sensor_from_world: Matrix, + filepath: Path, +) -> tuple[int, int]: + """Export triangulated mesh vertices in the sensor frame.""" + mesh, world, cleanup = _triangulated_mesh(obj) + try: + if not mesh.uv_layers.active: + raise RuntimeError(f"Mesh {obj.name!r} has no active UV layer") + + t_sensor_from_object = t_sensor_from_world @ world + + vertices = np.array( + [t_sensor_from_object @ v.co for v in mesh.vertices], + dtype=np.float32, + ) + faces_list = [tuple(poly.vertices) for poly in mesh.polygons] + if not faces_list: + raise RuntimeError(f"Mesh {obj.name!r} has no faces") + if any(len(face) != 3 for face in faces_list): + raise RuntimeError(f"Mesh {obj.name!r} is not fully triangulated after export") + faces = np.asarray(faces_list, dtype=np.int32) + + uv_layer = mesh.uv_layers.active.data + uv_data = np.array([uv_layer[i].uv[:] for i in range(len(uv_layer))], dtype=np.float32) + loop_to_vertex = np.array( + [mesh.loops[i].vertex_index for i in range(len(mesh.loops))], + dtype=np.int32, + ) + + np.savez_compressed( + filepath, + vertices=vertices, + faces=faces, + uv_data=uv_data, + loop_to_vertex=loop_to_vertex, + ) + print(f"Mesh exported to {filepath} ({len(vertices)} verts, {len(faces)} tris)") + return int(len(vertices)), int(len(faces)) + finally: + cleanup() + + +def export_camera_pose_sensor_frame( + camera_obj: bpy.types.Object, + t_sensor_from_world: Matrix, + filepath: Path, +) -> None: + """Save world_to_cam.npy where 'world' means the sensor frame (origin_sensor).""" + t_sensor_from_cam = t_sensor_from_world @ camera_obj.matrix_world + cam_from_sensor = np.linalg.inv(np.array(t_sensor_from_cam, dtype=np.float64)) + np.save(filepath, cam_from_sensor.astype(np.float32)) + print(f"Camera pose exported to {filepath}") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(prog="export.py") + parser.add_argument( + "--job", + type=Path, + default=None, + help="JSON job written by orisys-design (output + object names)", + ) + parser.add_argument( + "--output", + type=Path, + default=None, + help="Absolute output directory (overrides job)", + ) + parser.add_argument( + "--mesh", + default=None, + help="Sensing surface mesh object name (overrides job)", + ) + parser.add_argument( + "--cam", + default=None, + help="Sensor camera object name (overrides job)", + ) + parser.add_argument( + "--origin", + default=None, + help="Sensor-frame empty object name (overrides job)", + ) + return parser.parse_args(argv) + + +def _resolve_config(args: argparse.Namespace) -> tuple[Path, str, str, str]: + """Merge --job JSON with explicit flag overrides. Explicit flags win.""" + job_output: str | None = None + job_objects: dict = {} + if args.job is not None: + job_path = args.job.resolve() + if not job_path.is_file(): + raise RuntimeError(f"Export job not found: {job_path}") + job_output, job_objects = _load_job(job_path) + + output_raw = args.output or job_output + if not output_raw: + raise RuntimeError("Provide --job and/or --output ") + + mesh = ( + args.mesh + or job_objects.get("sensing_surface") + or _DEFAULT_OBJECTS["sensing_surface"] + ) + cam = args.cam or job_objects.get("cam_sensor") or _DEFAULT_OBJECTS["cam_sensor"] + origin = ( + args.origin + or job_objects.get("origin_sensor") + or _DEFAULT_OBJECTS["origin_sensor"] + ) + return Path(output_raw).resolve(), str(mesh), str(cam), str(origin) + + +def main() -> int: + args = parse_args(_argv_after_double_dash()) + try: + output_dir, mesh_name, cam_name, origin_name = _resolve_config(args) + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + output_dir.mkdir(parents=True, exist_ok=True) + + try: + mesh_obj = _require_object(mesh_name, "MESH") + cam_obj = _require_object(cam_name, "CAMERA") + origin_obj = _require_object(origin_name) + except RuntimeError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + t_sensor_from_world = origin_obj.matrix_world.inverted() + scene = bpy.context.scene + + mesh_path = output_dir / "mesh.npz" + intrinsics_path = output_dir / "camera_intrinsics.npz" + pose_path = output_dir / "world_to_cam.npy" + + try: + export_mesh_in_sensor_frame(mesh_obj, t_sensor_from_world, mesh_path) + K, width, height = export_camera_intrinsics(cam_obj, scene) + np.savez( + intrinsics_path, + K=K, + width=np.int32(width), + height=np.int32(height), + ) + print(f"Intrinsics exported to {intrinsics_path} ({width}x{height})") + export_camera_pose_sensor_frame(cam_obj, t_sensor_from_world, pose_path) + except Exception as exc: + print(f"ERROR: export failed: {exc}", file=sys.stderr) + return 1 + + print(f"Export complete: {output_dir}") + print(f" {mesh_path.name}") + print(f" {intrinsics_path.name}") + print(f" {pose_path.name}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/blender/scripts/random_patch_dd02c.py b/blender/scripts/random_patch_dd02c.py new file mode 100644 index 0000000..c3a282b --- /dev/null +++ b/blender/scripts/random_patch_dd02c.py @@ -0,0 +1,917 @@ +""" +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 bpy +import numpy as np +import os +import time +from mathutils import Vector + +# ---------------------------------------------------------------------- +# Common Utilities +# ---------------------------------------------------------------------- +def draw_cross(pixels, width, height, cx_uv, cy_uv, length_px, thickness_px, color): + """ + Draw a cross (plus sign) in pixel space. + cx_uv, cy_uv: centre of cross in UV coordinates (0‑1), origin at bottom‑left. + length_px: total length of each arm (pixels). + thickness_px: line thickness (odd number gives symmetric result). + """ + # Convert UV centre to pixel coordinates (y from bottom) + cx = int(round(cx_uv * (width - 1))) + cy = int(round(cy_uv * (height - 1))) + + half_len = length_px // 2 + half_thick = thickness_px // 2 + + # Horizontal arm + x0 = max(0, cx - half_len) + x1 = min(width - 1, cx + half_len) + y0 = max(0, cy - half_thick) + y1 = min(height - 1, cy + half_thick) + for y in range(y0, y1 + 1): + for x in range(x0, x1 + 1): + idx = (y * width + x) * 4 + pixels[idx:idx+4] = color + + # Vertical arm + x0 = max(0, cx - half_thick) + x1 = min(width - 1, cx + half_thick) + y0 = max(0, cy - half_len) + y1 = min(height - 1, cy + half_len) + for y in range(y0, y1 + 1): + for x in range(x0, x1 + 1): + idx = (y * width + x) * 4 + pixels[idx:idx+4] = color + +def fill_background(pixels, width, height, color): + """Fill the entire image with a solid RGBA color (0-1 floats).""" + r, g, b, a = color + for y in range(height): + for x in range(width): + idx = (y * width + x) * 4 + pixels[idx] = r + pixels[idx+1] = g + pixels[idx+2] = b + pixels[idx+3] = a + +def fill_polygon(image, pixels, width, height, polygon, color): + """Fill a polygon in the pixel array (flattened, RGBA).""" + poly = [(int(round(x)), int(round(y))) for x, y in polygon] + if len(poly) < 3: + return + min_x = max(0, min(p[0] for p in poly)) + max_x = min(width-1, max(p[0] for p in poly)) + min_y = max(0, min(p[1] for p in poly)) + max_y = min(height-1, max(p[1] for p in poly)) + for y in range(min_y, max_y + 1): + intersections = [] + for i in range(len(poly)): + x1, y1 = poly[i] + x2, y2 = poly[(i+1) % len(poly)] + if y1 == y2: + continue + if (y1 <= y < y2) or (y2 <= y < y1): + t = (y - y1) / (y2 - y1) + x = x1 + t * (x2 - x1) + intersections.append(x) + intersections.sort() + for k in range(0, len(intersections), 2): + if k+1 >= len(intersections): + break + x_start = int(round(intersections[k])) + x_end = int(round(intersections[k+1])) + x_start = max(0, min(width-1, x_start)) + x_end = max(0, min(width-1, x_end)) + for x in range(x_start, x_end + 1): + idx = (y * width + x) * 4 + pixels[idx:idx+4] = color + +def fill_polygon_value(grid, width, height, polygon, value): + """Fill a polygon into a 2D scalar grid.""" + poly = [(int(round(x)), int(round(y))) for x, y in polygon] + if len(poly) < 3: + return + min_x = max(0, min(p[0] for p in poly)) + max_x = min(width - 1, max(p[0] for p in poly)) + min_y = max(0, min(p[1] for p in poly)) + max_y = min(height - 1, max(p[1] for p in poly)) + for y in range(min_y, max_y + 1): + intersections = [] + for i in range(len(poly)): + x1, y1 = poly[i] + x2, y2 = poly[(i + 1) % len(poly)] + if y1 == y2: + continue + if (y1 <= y < y2) or (y2 <= y < y1): + t = (y - y1) / (y2 - y1) + x = x1 + t * (x2 - x1) + intersections.append(x) + intersections.sort() + for k in range(0, len(intersections), 2): + if k + 1 >= len(intersections): + break + x_start = int(round(intersections[k])) + x_end = int(round(intersections[k + 1])) + x_start = max(0, min(width - 1, x_start)) + x_end = max(0, min(width - 1, x_end)) + grid[y, x_start:x_end + 1] = value + +def unwrap_periodic_values(values): + """ + Unwrap periodic UV coordinates so points straddling the 0/1 seam stay local. + """ + arr = np.asarray(values, dtype=np.float64).copy() + if arr.size == 0: + return arr + if arr.max() - arr.min() > 0.5: + arr[arr < 0.5] += 1.0 + return arr + +def wrap_uv_polygon_for_raster(uv_poly): + """ + Return a seam-aware version of a UV polygon. + + UV islands may cross the 0/1 seam. If we rasterize those raw coordinates as a + single polygon, Blender's repeated UVs turn into a long diagonal triangle + across the texture. We unwrap seam-crossing polygons first, then draw wrapped + copies in the texture tile(s) they overlap. + """ + if len(uv_poly) < 3: + return [] + + uv_arr = np.array([(float(u), float(v)) for u, v in uv_poly], dtype=np.float64) + uv_arr[:, 0] = unwrap_periodic_values(uv_arr[:, 0]) + uv_arr[:, 1] = unwrap_periodic_values(uv_arr[:, 1]) + return [tuple(pt) for pt in uv_arr] + +def uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height): + """ + Convert one UV polygon into one or more pixel polygons, duplicating seam- + crossing polygons into adjacent tiles so rasterization stays local. + """ + unwrapped = wrap_uv_polygon_for_raster(uv_poly) + if len(unwrapped) < 3: + return [] + + u_vals = [u for u, _ in unwrapped] + v_vals = [v for _, v in unwrapped] + u_shift_start = int(np.floor(min(u_vals))) + u_shift_end = int(np.floor(max(u_vals))) + v_shift_start = int(np.floor(min(v_vals))) + v_shift_end = int(np.floor(max(v_vals))) + + pixel_polys = [] + for u_shift in range(u_shift_start, u_shift_end + 1): + for v_shift in range(v_shift_start, v_shift_end + 1): + shifted = [(u - u_shift, v - v_shift) for u, v in unwrapped] + shifted_u = [u for u, _ in shifted] + shifted_v = [v for _, v in shifted] + if max(shifted_u) < 0.0 or min(shifted_u) > 1.0: + continue + if max(shifted_v) < 0.0 or min(shifted_v) > 1.0: + continue + pixel_polys.append([ + (u * (tex_width - 1), v * (tex_height - 1)) + for u, v in shifted + ]) + return pixel_polys + +def draw_ellipse(pixels, width, height, cx, cy, a, b, angle, color): + """Filled ellipse with semi‑axes a,b (pixels), rotation angle (rad).""" + cos_a = np.cos(angle) + sin_a = np.sin(angle) + rx = abs(a * cos_a) + abs(b * sin_a) + ry = abs(a * sin_a) + abs(b * cos_a) + x_min = int(max(0, cx - rx)) + x_max = int(min(width-1, cx + rx)) + y_min = int(max(0, cy - ry)) + y_max = int(min(height-1, cy + ry)) + for y in range(y_min, y_max + 1): + for x in range(x_min, x_max + 1): + dx = x - cx + dy = y - cy + u = dx * cos_a + dy * sin_a + v = -dx * sin_a + dy * cos_a + if (u/a)**2 + (v/b)**2 <= 1.0: + idx = (y * width + x) * 4 + pixels[idx:idx+4] = color + +def get_uv_from_face(obj, face_index, location): + """World‑space location → UV coordinate on the mesh face.""" + me = obj.data + local_loc = obj.matrix_world.inverted() @ location + poly = me.polygons[face_index] + vert_indices = poly.vertices + loop_indices = poly.loop_indices + if len(vert_indices) < 3: + return None + v0 = me.vertices[vert_indices[0]].co + v1 = me.vertices[vert_indices[1]].co + v2 = me.vertices[vert_indices[2]].co + uv_layer = me.uv_layers.active.data + uv0 = uv_layer[loop_indices[0]].uv + uv1 = uv_layer[loop_indices[1]].uv + uv2 = uv_layer[loop_indices[2]].uv + def tri_area(a, b, c): + return 0.5 * ((b - a).cross(c - a)).length + total_area = tri_area(v0, v1, v2) + if total_area == 0: + return None + w0 = tri_area(local_loc, v1, v2) / total_area + w1 = tri_area(local_loc, v2, v0) / total_area + w2 = 1.0 - w0 - w1 + uv = w0 * uv0 + w1 * uv1 + w2 * uv2 + return Vector((uv.x, uv.y)) + +def raycast_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height): + """Camera raycast through normalised sensor coordinates (0‑1).""" + sx = (u_ndc - 0.5) * frame_width + sy = (v_ndc - 0.5) * frame_height + direction_cam = Vector((sx, sy, -1.0)) + direction_world = cam.matrix_world.to_quaternion() @ direction_cam + direction_world.normalize() + origin = cam.matrix_world.translation + hit, location, normal, face_index, hit_obj, matrix = scene.ray_cast( + depsgraph, origin, direction_world) + if hit and hit_obj == obj: + return location, face_index + return None, None + +def raycast_uv_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height): + """Camera raycast through normalized sensor coordinates and return UV.""" + loc, face_idx = raycast_from_camera( + scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height + ) + if loc is None: + return None + return get_uv_from_face(obj, face_idx, loc) + +def convex_hull_uv(points): + """Return UV points ordered around their 2D convex hull.""" + unwrapped = wrap_uv_polygon_for_raster([(p.x, p.y) for p in points]) + unique = sorted(set(unwrapped)) + if len(unique) < 3: + return None + + def cross(o, a, b): + return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) + + lower = [] + for p in unique: + while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0: + lower.pop() + lower.append(p) + + upper = [] + for p in reversed(unique): + while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0: + upper.pop() + upper.append(p) + + hull = lower[:-1] + upper[:-1] + if len(hull) < 3: + return None + return [Vector((u, v)) for u, v in hull] + +def sample_patch_uv_polygon( + scene, depsgraph, cam, obj, + u_start, u_end, v_start, v_end, + frame_width, frame_height, + edge_sample_grid=5 +): + """ + Build a UV polygon for one camera-space patch. + + 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 + not dropped just because some corners miss the silhouette. + """ + corners_ndc = [ + (u_start, v_start), + (u_end, v_start), + (u_end, v_end), + (u_start, v_end), + ] + + corner_uvs = [] + for u_ndc, v_ndc in corners_ndc: + uv = raycast_uv_from_camera( + scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height + ) + if uv is not None: + corner_uvs.append(uv) + + if len(corner_uvs) >= 3: + return corner_uvs + + sampled_uvs = list(corner_uvs) + for sy in range(edge_sample_grid): + v_ndc = v_start + (v_end - v_start) * sy / (edge_sample_grid - 1) + for sx in range(edge_sample_grid): + u_ndc = u_start + (u_end - u_start) * sx / (edge_sample_grid - 1) + uv = raycast_uv_from_camera( + scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height + ) + if uv is not None: + sampled_uvs.append(uv) + + return convex_hull_uv(sampled_uvs) + +def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1)): + """ + Fill all triangles of the active UV map with 'color' (default white). + This marks every part of the mesh, visible or not, as white. + """ + me = obj.data + uv_layer = me.uv_layers.active + if not uv_layer: + print("Warning: No active UV layer – mesh rasterization skipped.") + return + uv_data = uv_layer.data + for poly in me.polygons: + # Get loop indices for this polygon + loops = poly.loop_indices + # Build UV polygon + uv_poly = [] + for loop_idx in loops: + uv = uv_data[loop_idx].uv + uv_poly.append((uv.x, uv.y)) + # Convert UV (0‑1) to pixel coordinates + for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height): + fill_polygon(None, pixels, tex_width, tex_height, pixel_poly, color) + +def rasterize_mesh_mask(obj, tex_width, tex_height): + """Rasterize the active UV map to a boolean mesh mask.""" + mask = np.zeros((tex_height, tex_width), dtype=bool) + me = obj.data + uv_layer = me.uv_layers.active + if not uv_layer: + return mask + uv_data = uv_layer.data + for poly in me.polygons: + uv_poly = [] + for loop_idx in poly.loop_indices: + uv = uv_data[loop_idx].uv + uv_poly.append((uv.x, uv.y)) + for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height): + fill_polygon_value(mask, tex_width, tex_height, pixel_poly, True) + return mask + +def paint_mask_color(pixels, width, height, mask, color): + """Paint all True pixels of a boolean mask with an RGBA color.""" + r, g, b, a = color + ys, xs = np.nonzero(mask) + for y, x in zip(ys, xs): + idx = (y * width + x) * 4 + pixels[idx:idx+4] = (r, g, b, a) + +def complete_checker_gaps(pixels, width, height, mesh_mask, patch_state, max_gap_px=6): + """ + Fill small white slivers inside the mesh by expanding nearby checker colors. + + `patch_state`: -1 = unassigned, 0 = black patch, 1 = white patch. + """ + for _ in range(max_gap_px): + candidate = mesh_mask & (patch_state < 0) + if not np.any(candidate): + break + + black_counts = np.zeros_like(patch_state, dtype=np.uint8) + white_counts = np.zeros_like(patch_state, dtype=np.uint8) + + for dy in (-1, 0, 1): + for dx in (-1, 0, 1): + if dx == 0 and dy == 0: + continue + + src_y0 = max(0, -dy) + src_y1 = height - max(0, dy) + src_x0 = max(0, -dx) + src_x1 = width - max(0, dx) + dst_y0 = max(0, dy) + dst_y1 = height - max(0, -dy) + dst_x0 = max(0, dx) + dst_x1 = width - max(0, -dx) + + neighbor_vals = patch_state[src_y0:src_y1, src_x0:src_x1] + black_counts[dst_y0:dst_y1, dst_x0:dst_x1] += (neighbor_vals == 0) + white_counts[dst_y0:dst_y1, dst_x0:dst_x1] += (neighbor_vals == 1) + + fillable = candidate & ((black_counts + white_counts) > 0) + if not np.any(fillable): + break + + patch_state[fillable] = np.where( + black_counts[fillable] > white_counts[fillable], 0, 1 + ).astype(np.int8) + + paint_mask_color(pixels, width, height, patch_state == 0, (0.0, 0.0, 0.0, 1.0)) + paint_mask_color(pixels, width, height, patch_state == 1, (1.0, 1.0, 1.0, 1.0)) + +# ---------------------------------------------------------------------- +# Color generation (for patch mode) +# ---------------------------------------------------------------------- +def merge_intervals(intervals): + if not intervals: return [] + intervals.sort(key=lambda x: x[0]) + merged = [intervals[0]] + for current in intervals[1:]: + last = merged[-1] + if current[0] <= last[1]: + merged[-1] = (last[0], max(last[1], current[1])) + else: + merged.append(current) + return merged + +def get_allowed_intervals(neighbor_values, delta): + forbidden = [] + for c in neighbor_values: + low = max(0, c - delta) + high = min(1, c + delta) + forbidden.append((low, high)) + merged = merge_intervals(forbidden) + allowed = [] + if merged: + if merged[0][0] > 0: + allowed.append((0, merged[0][0])) + for i in range(1, len(merged)): + allowed.append((merged[i-1][1], merged[i][0])) + if merged[-1][1] < 1: + allowed.append((merged[-1][1], 1)) + else: + allowed.append((0, 1)) + return allowed + +def sample_from_allowed(allowed): + lengths = [end - start for start, end in allowed] + total_length = sum(lengths) + if total_length <= 0: return None + point = np.random.rand() * total_length + cumulative = 0 + for (start, end), length in zip(allowed, lengths): + cumulative += length + if point <= cumulative: + return np.random.uniform(start, end) + return allowed[-1][0] + +def sample_channel(neighbor_values, delta): + allowed = get_allowed_intervals(neighbor_values, delta) + if not allowed: return None + return sample_from_allowed(allowed) + +def generate_valid_color(neighbors, r): + delta = np.sqrt(r) + color = np.zeros(3) + for channel in range(3): + neighbor_channel = [n[channel] for n in neighbors] + sampled = sample_channel(neighbor_channel, delta) + if sampled is None: + sampled = np.random.rand() + color[channel] = sampled + return color + +def checker_patch_color(i, j): + """Return a black/white checker color for patch index (i, j).""" + if (i + j) % 2 == 0: + return np.array([1.0, 1.0, 1.0], dtype=np.float32) + return np.array([0.0, 0.0, 0.0], dtype=np.float32) + +# ---------------------------------------------------------------------- +# Mode 1: Colored Patches +# ---------------------------------------------------------------------- +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, + cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5 +): + cam_data = cam.data + render = scene.render + res_x = render.resolution_x + res_y = render.resolution_y + if cam_data.sensor_fit == 'HORIZONTAL': + sensor_width = cam_data.sensor_width + sensor_height = sensor_width / (res_x / res_y) + else: + sensor_height = cam_data.sensor_height + sensor_width = sensor_height * (res_x / res_y) + focal = cam_data.lens + frame_width = sensor_width / focal + frame_height = sensor_height / focal + + edge_sample_grid = max(2, int(edge_sample_grid)) + patch_uv_polygons = [] + patch_colors = np.zeros((ny, nx, 3), dtype=np.float32) + skipped_patches = 0 + + print(f"Raycasting {nx*ny} patches...") + for j in range(ny): + v_start = j / ny + v_end = (j+1) / ny + for i in range(nx): + u_start = i / nx + u_end = (i+1) / nx + uv_poly = sample_patch_uv_polygon( + scene, depsgraph, cam, obj, + u_start, u_end, v_start, v_end, + frame_width, frame_height, + edge_sample_grid=edge_sample_grid + ) + if uv_poly is None: + patch_uv_polygons.append(None) + skipped_patches += 1 + continue + patch_uv_polygons.append(uv_poly) + + neighbors = [] + if j > 0: neighbors.append(patch_colors[j-1, i]) + if i > 0: neighbors.append(patch_colors[j, i-1]) + color = np.random.rand(3) if len(neighbors)==0 else generate_valid_color(neighbors, r) + patch_colors[j, i] = color + print(f"Skipped {skipped_patches} patches with fewer than 3 mesh samples.") + + # Create image – start with black background + img_name = "PatchTexture" + if img_name in bpy.data.images: + bpy.data.images.remove(bpy.data.images[img_name]) + image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True) + pixels = list(image.pixels) + + # Fill entire image with black (no mesh areas) + fill_background(pixels, texture_width, texture_height, (0,0,0,1)) + + # Rasterize all mesh UVs in white (visible + non‑visible parts) + rasterize_mesh_uvs(obj, pixels, texture_width, texture_height, color=(1,1,1,1)) + + # Now draw the visible patches on top + idx = 0 + for j in range(ny): + for i in range(nx): + uv_poly = patch_uv_polygons[idx]; idx += 1 + if uv_poly is None: continue + color = patch_colors[j,i] + rgba = (color[0], color[1], color[2], 1.0) + for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, texture_width, texture_height): + fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba) + + # (after drawing all patches) + if uv_centre is not None: + draw_cross(pixels, texture_width, texture_height, + uv_centre[0], uv_centre[1], + cross_length, cross_thickness, cross_color) + + image.pixels[:] = pixels + image.update() + abs_path = bpy.path.abspath(output_path) + image.filepath_raw = abs_path + image.file_format = 'PNG' + image.save() + print(f"Patch texture saved to {abs_path}") + return image + +# ---------------------------------------------------------------------- +# Mode 2: Black/White Checker Patches +# ---------------------------------------------------------------------- +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, + cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5, + gap_fill_px=6 +): + cam_data = cam.data + render = scene.render + res_x = render.resolution_x + res_y = render.resolution_y + if cam_data.sensor_fit == 'HORIZONTAL': + sensor_width = cam_data.sensor_width + sensor_height = sensor_width / (res_x / res_y) + else: + sensor_height = cam_data.sensor_height + sensor_width = sensor_height * (res_x / res_y) + focal = cam_data.lens + frame_width = sensor_width / focal + frame_height = sensor_height / focal + + edge_sample_grid = max(2, int(edge_sample_grid)) + patch_uv_polygons = [] + patch_colors = np.zeros((ny, nx, 3), dtype=np.float32) + skipped_patches = 0 + + print(f"Raycasting {nx*ny} checker patches...") + for j in range(ny): + v_start = j / ny + v_end = (j+1) / ny + for i in range(nx): + u_start = i / nx + u_end = (i+1) / nx + uv_poly = sample_patch_uv_polygon( + scene, depsgraph, cam, obj, + u_start, u_end, v_start, v_end, + frame_width, frame_height, + edge_sample_grid=edge_sample_grid + ) + if uv_poly is None: + patch_uv_polygons.append(None) + skipped_patches += 1 + continue + patch_uv_polygons.append(uv_poly) + patch_colors[j, i] = checker_patch_color(i, j) + print(f"Skipped {skipped_patches} checker patches with fewer than 3 mesh samples.") + + img_name = "CheckerTexture" + if img_name in bpy.data.images: + bpy.data.images.remove(bpy.data.images[img_name]) + image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True) + pixels = list(image.pixels) + mesh_mask = rasterize_mesh_mask(obj, texture_width, texture_height) + patch_state = np.full((texture_height, texture_width), -1, dtype=np.int8) + + fill_background(pixels, texture_width, texture_height, (0,0,0,1)) + paint_mask_color(pixels, texture_width, texture_height, mesh_mask, (1,1,1,1)) + + idx = 0 + for j in range(ny): + for i in range(nx): + uv_poly = patch_uv_polygons[idx]; idx += 1 + if uv_poly is None: + continue + color = patch_colors[j,i] + rgba = (color[0], color[1], color[2], 1.0) + patch_value = 1 if np.mean(color) >= 0.5 else 0 + for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, texture_width, texture_height): + fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba) + fill_polygon_value( + patch_state, + texture_width, + texture_height, + pixel_poly, + patch_value, + ) + + complete_checker_gaps( + pixels, texture_width, texture_height, mesh_mask, patch_state, max_gap_px=gap_fill_px + ) + + if uv_centre is not None: + draw_cross(pixels, texture_width, texture_height, + uv_centre[0], uv_centre[1], + cross_length, cross_thickness, cross_color) + + image.pixels[:] = pixels + image.update() + abs_path = bpy.path.abspath(output_path) + image.filepath_raw = abs_path + image.file_format = 'PNG' + image.save() + print(f"Checker texture saved to {abs_path}") + return image + +# ---------------------------------------------------------------------- +# Mode 3: Circular Dot Markers +# ---------------------------------------------------------------------- +def generate_dot_texture( + obj, cam, scene, depsgraph, + dot_diameter_pixels=12, + 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) +): + render = scene.render + res_x = render.resolution_x + res_y = render.resolution_y + cam_data = cam.data + if cam_data.sensor_fit == 'HORIZONTAL': + sensor_width = cam_data.sensor_width + sensor_height = sensor_width / (res_x / res_y) + else: + sensor_height = cam_data.sensor_height + sensor_width = sensor_height * (res_x / res_y) + focal = cam_data.lens + frame_width = sensor_width / focal + frame_height = sensor_height / focal + + margin = spacing_pixels // 2 + x_centers = np.arange(margin, res_x, spacing_pixels) + y_centers = np.arange(margin, res_y, spacing_pixels) + offset_pix = max(2, dot_diameter_pixels // 4) + du_ndc = offset_pix / res_x + dv_ndc = offset_pix / res_y + + dots = [] # (cx_uv, cy_uv, a_uv, b_uv, angle_uv) + print(f"Raycasting {len(x_centers)*len(y_centers)} dot centers...") + for y_pix in y_centers: + v_ndc = y_pix / res_y + for x_pix in x_centers: + u_ndc = x_pix / res_x + center_loc, face_idx = raycast_from_camera(scene, depsgraph, cam, obj, + u_ndc, v_ndc, frame_width, frame_height) + if center_loc is None: continue + center_uv = get_uv_from_face(obj, face_idx, center_loc) + if center_uv is None: continue + + offsets = [ + ( u_ndc + du_ndc, v_ndc), + ( u_ndc - du_ndc, v_ndc), + ( u_ndc, v_ndc + dv_ndc), + ( u_ndc, v_ndc - dv_ndc) + ] + uv_offsets = [] + for (u_off, v_off) in offsets: + uv_off = raycast_uv_from_camera( + scene, depsgraph, cam, obj, + u_off, v_off, frame_width, frame_height + ) + uv_offsets.append(uv_off) + + scale_u = 1.0 / res_x + scale_v = 1.0 / res_y + r_pix = dot_diameter_pixels / 2.0 + + right_uv, left_uv, up_uv, down_uv = uv_offsets + if right_uv is not None and left_uv is not None: + du_dx = (right_uv - left_uv) / (2 * offset_pix * scale_u) + a_uv = r_pix * du_dx.length * scale_u + elif right_uv is not None: + du_dx = (right_uv - center_uv) / (offset_pix * scale_u) + a_uv = r_pix * du_dx.length * scale_u + elif left_uv is not None: + du_dx = (center_uv - left_uv) / (offset_pix * scale_u) + a_uv = r_pix * du_dx.length * scale_u + else: + du_dx = Vector((1.0, 0.0)) + a_uv = r_pix / texture_width + + if up_uv is not None and down_uv is not None: + dv_dx = (up_uv - down_uv) / (2 * offset_pix * scale_v) + b_uv = r_pix * dv_dx.length * scale_v + elif up_uv is not None: + dv_dx = (up_uv - center_uv) / (offset_pix * scale_v) + b_uv = r_pix * dv_dx.length * scale_v + elif down_uv is not None: + dv_dx = (center_uv - down_uv) / (offset_pix * scale_v) + b_uv = r_pix * dv_dx.length * scale_v + else: + b_uv = r_pix / texture_height + + a_uv = max(a_uv, 1.0 / texture_width) + b_uv = max(b_uv, 1.0 / texture_height) + angle = np.arctan2(du_dx.y, du_dx.x) if du_dx.length > 0 else 0.0 + dots.append((center_uv.x, center_uv.y, a_uv, b_uv, angle)) + + print(f"Found {len(dots)} valid dots.") + + # Create image + img_name = "DotTexture" + if img_name in bpy.data.images: + bpy.data.images.remove(bpy.data.images[img_name]) + image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True) + pixels = list(image.pixels) + + # Black background (no mesh) + fill_background(pixels, texture_width, texture_height, (0,0,0,1)) + # White for all mesh areas + rasterize_mesh_uvs(obj, pixels, texture_width, texture_height, color=(1,1,1,1)) + # Draw dots + for (cx_uv, cy_uv, a_uv, b_uv, angle_uv) in dots: + cx_px = cx_uv * (texture_width - 1) + cy_px = cy_uv * (texture_height - 1) + a_px = a_uv * texture_width + b_px = b_uv * texture_height + draw_ellipse(pixels, texture_width, texture_height, + cx_px, cy_px, a_px, b_px, angle_uv, dot_color) + + if uv_centre is not None: + draw_cross(pixels, texture_width, texture_height, + uv_centre[0], uv_centre[1], + cross_length, cross_thickness, cross_color) + + image.pixels[:] = pixels + image.update() + abs_path = bpy.path.abspath(output_path) + image.filepath_raw = abs_path + image.file_format = 'PNG' + image.save() + print(f"Dot texture saved to {abs_path}") + return image + +# ---------------------------------------------------------------------- +# Main +# ---------------------------------------------------------------------- +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", + } + 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() + 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.") + + 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 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": + 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 + ) + 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'.") + + print(f"Completed in {time.time()-start_time:.2f} seconds.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/blender/scripts/validate.py b/blender/scripts/validate.py new file mode 100644 index 0000000..02b6bc3 --- /dev/null +++ b/blender/scripts/validate.py @@ -0,0 +1,82 @@ +"""Blender headless scene validation (collections and naming). + +Usage (from repo root): + blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/validate.py -- sensors/finger/r01 + +Note: this script loads design.yaml with PyYAML. Prefer installing PyYAML into +Blender's Python, or run metadata-only checks via: orisys-design validate +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import bpy + +try: + import yaml +except ImportError: + yaml = None + + +def _argv_after_double_dash() -> list[str]: + if "--" not in sys.argv: + return [] + return sys.argv[sys.argv.index("--") + 1 :] + + +def _load_design(revision_dir: Path) -> dict: + if yaml is None: + raise RuntimeError( + "PyYAML is not installed in Blender's Python. " + "Run: orisys-design validate without --blender, " + "or pip install PyYAML into Blender's Python." + ) + with (revision_dir / "design.yaml").open(encoding="utf-8") as handle: + data = yaml.safe_load(handle) + if not isinstance(data, dict): + raise ValueError("design.yaml must be a mapping") + return data + + +def main() -> int: + args = _argv_after_double_dash() + if not args: + print("validate.py: pass revision directory after --", file=sys.stderr) + return 2 + + revision_dir = Path(args[0]).resolve() + try: + design = _load_design(revision_dir) + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + collections = design.get("collections", {}) + objects = design.get("objects", {}) + + errors: list[str] = [] + present = {col.name for col in bpy.data.collections} + + for key, expected_name in collections.items(): + if expected_name not in present: + errors.append(f"Missing collection {expected_name!r} (design key: {key})") + + for key, expected_name in objects.items(): + obj = bpy.data.objects.get(expected_name) + if obj is None: + errors.append(f"Missing object {expected_name!r} (design key: {key})") + + if errors: + for message in errors: + print(f"ERROR: {message}", file=sys.stderr) + print(f"Present collections: {sorted(present)}", file=sys.stderr) + return 1 + + print(f"OK: Blender scene matches conventions for {revision_dir.name}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/design-lock.json b/examples/design-lock.json new file mode 100644 index 0000000..7fae40c --- /dev/null +++ b/examples/design-lock.json @@ -0,0 +1,11 @@ +{ + "sensor": "finger", + "revision": "r01", + "design_repo": "git@your-nas:team/orisys-sensor-design.git", + "design_tag": "finger-r01-assets-v1.0.0", + "design_commit": "0000000000000000000000000000000000000000", + "bundle_sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "exported_at": "2026-09-16T00:00:00Z", + "sdk_assets_dir": "assets/30fps-0703", + "notes": "Copy into orisys-sdk/sensors/finger/design-lock.json after promotion" +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..49a3ca4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "orisys-sensor-design" +version = "0.1.0" +description = "Nominal Orisys sensor geometry design and export tooling" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "PyYAML>=6.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", +] + +[project.scripts] +orisys-design = "orisys_sensor_design.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/sensors/finger-xynova/current.yaml b/sensors/finger-xynova/current.yaml new file mode 100644 index 0000000..b9ea9fd --- /dev/null +++ b/sensors/finger-xynova/current.yaml @@ -0,0 +1,3 @@ +sensor: finger +revision: r01 +status: draft diff --git a/sensors/finger-xynova/r01/design.yaml b/sensors/finger-xynova/r01/design.yaml new file mode 100644 index 0000000..ff7753e --- /dev/null +++ b/sensors/finger-xynova/r01/design.yaml @@ -0,0 +1,28 @@ +sensor: finger +revision: r01 +status: draft + +# Authoring unit in Blender (mm). Export converts using meters_per_source_unit. +authoring_unit: mm +meters_per_source_unit: 0.001 + +source_blend: source/assembly.blend + +# Expected sensing surface topology for r01 (from shipped 30fps-0703 runtime). +expected: + sensing_vertices: 1032 + sensing_triangles: 1974 + +frames: + world: world + camera: camera + +collections: + sensing_surface: SENSOR_SURFACE + housing: SENSOR_HOUSING + marker_pattern: MARKER_PATTERN + camera_rig: CAMERA_RIG + +notes: | + Legacy bootstrap blend is vtbs-blender/thumb.blend copied to source/assembly.blend. + Split into sensing-surface.blend + housing.blend when two people edit geometry. diff --git a/sensors/finger-xynova/r01/golden/README.md b/sensors/finger-xynova/r01/golden/README.md new file mode 100644 index 0000000..6c6ee76 --- /dev/null +++ b/sensors/finger-xynova/r01/golden/README.md @@ -0,0 +1,4 @@ +Optional golden outputs for regression: + +- `preview.png` — viewport or render still +- `expected-metrics.json` — vertex counts, UV residual, bounding box in metres diff --git a/sensors/finger-xynova/r01/profiles/compute.yaml b/sensors/finger-xynova/r01/profiles/compute.yaml new file mode 100644 index 0000000..5ecc875 --- /dev/null +++ b/sensors/finger-xynova/r01/profiles/compute.yaml @@ -0,0 +1,20 @@ +profile: compute +description: Runtime sensing bundle consumed by orisys-sdk assets_dir + +output_dir: build/export/finger/r01/compute + +# Files must match orisys-sdk / Studio runtime layout. +artifacts: + - mesh.npz + - camera_intrinsics.npz + - world_to_cam.npy + +# Measured unroll maps stay in SDK (not overwritten by nominal export). +runtime_owned_by_sdk: + - unroll_map_uv_s0.4.npz + +validation: + # orisys-app validate_scene.py --legacy-dir --map + require_sensing_surface_only: true + expected_vertices: 1032 + expected_triangles: 1974 diff --git a/sensors/finger-xynova/r01/profiles/studio.yaml b/sensors/finger-xynova/r01/profiles/studio.yaml new file mode 100644 index 0000000..a175401 --- /dev/null +++ b/sensors/finger-xynova/r01/profiles/studio.yaml @@ -0,0 +1,20 @@ +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 + +# 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 + +compile: + tool: orisys-app/tools/scene_assets/compile_scene.py + meters_per_source_unit: 0.001 + output: orisys-app/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene + +validate: + tool: orisys-app/tools/scene_assets/validate_scene.py + +notes: | + visualization/world_to_cam.npy is for SDK Dashboard viz_pose only. + Do not point Studio compile at assets/visualization/. diff --git a/sensors/finger-xynova/r01/source/README.md b/sensors/finger-xynova/r01/source/README.md new file mode 100644 index 0000000..fb3b647 --- /dev/null +++ b/sensors/finger-xynova/r01/source/README.md @@ -0,0 +1,14 @@ +# Source blends (Git LFS) + +Place `assembly.blend` here. Bootstrap from legacy: + +```powershell +Copy-Item D:\orisys_ws\vtbs-blender\thumb.blend .\assembly.blend +``` + +Ensure Git LFS is installed before the first commit: + +```powershell +git lfs install +git add assembly.blend +``` diff --git a/sensors/finger-xynova/r01/source/assembly.blend b/sensors/finger-xynova/r01/source/assembly.blend new file mode 100644 index 0000000..361d5a8 --- /dev/null +++ b/sensors/finger-xynova/r01/source/assembly.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4d88cd2e6604e95c44dd483ec5380797dcebdd02ff584b68fb2182bd8e24b511 +size 8256170 diff --git a/sensors/finger/current.yaml b/sensors/finger/current.yaml new file mode 100644 index 0000000..b9ea9fd --- /dev/null +++ b/sensors/finger/current.yaml @@ -0,0 +1,3 @@ +sensor: finger +revision: r01 +status: draft diff --git a/sensors/finger/r01/design.yaml b/sensors/finger/r01/design.yaml new file mode 100644 index 0000000..f840847 --- /dev/null +++ b/sensors/finger/r01/design.yaml @@ -0,0 +1,35 @@ +sensor: finger +revision: r01 +status: draft + +# Authoring unit in Blender (mm). Export converts using meters_per_source_unit. +authoring_unit: mm +meters_per_source_unit: 0.001 + +source_blend: source/geometry.blend + +# Expected sensing surface topology for r01 (from shipped 30fps-0703 runtime). +# First exports from geometry.blend may differ until topology is locked. +expected: + sensing_vertices: 1032 + sensing_triangles: 1974 + +frames: + sensor: origin_sensor + +objects: + sensing_surface: sensing_surface + cam_sensor: cam_sensor + origin_sensor: origin_sensor + +collections: + frames: FRAMES + export_compute: EXPORT_COMPUTE + assembly: ASSEMBLY + design_sim: DESIGN_SIM + exclude: EXCLUDE + +notes: | + Canonical source is geometry.blend with named collections and objects. + Compute export uses sensing_surface + cam_sensor relative to origin_sensor. + Studio observation cameras are derived from mesh bounds; no cam_viz required. diff --git a/sensors/finger/r01/golden/README.md b/sensors/finger/r01/golden/README.md new file mode 100644 index 0000000..6c6ee76 --- /dev/null +++ b/sensors/finger/r01/golden/README.md @@ -0,0 +1,4 @@ +Optional golden outputs for regression: + +- `preview.png` — viewport or render still +- `expected-metrics.json` — vertex counts, UV residual, bounding box in metres diff --git a/sensors/finger/r01/profiles/compute.yaml b/sensors/finger/r01/profiles/compute.yaml new file mode 100644 index 0000000..5ecc875 --- /dev/null +++ b/sensors/finger/r01/profiles/compute.yaml @@ -0,0 +1,20 @@ +profile: compute +description: Runtime sensing bundle consumed by orisys-sdk assets_dir + +output_dir: build/export/finger/r01/compute + +# Files must match orisys-sdk / Studio runtime layout. +artifacts: + - mesh.npz + - camera_intrinsics.npz + - world_to_cam.npy + +# Measured unroll maps stay in SDK (not overwritten by nominal export). +runtime_owned_by_sdk: + - unroll_map_uv_s0.4.npz + +validation: + # orisys-app validate_scene.py --legacy-dir --map + require_sensing_surface_only: true + expected_vertices: 1032 + expected_triangles: 1974 diff --git a/sensors/finger/r01/profiles/studio.yaml b/sensors/finger/r01/profiles/studio.yaml new file mode 100644 index 0000000..a175401 --- /dev/null +++ b/sensors/finger/r01/profiles/studio.yaml @@ -0,0 +1,20 @@ +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 + +# 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 + +compile: + tool: orisys-app/tools/scene_assets/compile_scene.py + meters_per_source_unit: 0.001 + output: orisys-app/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene + +validate: + tool: orisys-app/tools/scene_assets/validate_scene.py + +notes: | + visualization/world_to_cam.npy is for SDK Dashboard viz_pose only. + Do not point Studio compile at assets/visualization/. diff --git a/sensors/finger/r01/source/README.md b/sensors/finger/r01/source/README.md new file mode 100644 index 0000000..118f9ff --- /dev/null +++ b/sensors/finger/r01/source/README.md @@ -0,0 +1,16 @@ +# Source blends (Git LFS) + +Canonical file: `geometry.blend` + +Required objects (by name): + +- `sensing_surface` — in `EXPORT_COMPUTE` +- `cam_sensor` — in `DESIGN_SIM` +- `origin_sensor` — Empty in `FRAMES` (sensor-frame datum) + +Ensure Git LFS is installed before the first commit: + +```powershell +git lfs install +git add geometry.blend +``` diff --git a/sensors/finger/r01/source/dd02c_0703.blend b/sensors/finger/r01/source/dd02c_0703.blend new file mode 100644 index 0000000..c30eda9 --- /dev/null +++ b/sensors/finger/r01/source/dd02c_0703.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:928a0012a17b68f3de9bab42bd9314fa8661f62f624d6460f6c1918ba17813f5 +size 769866 diff --git a/sensors/finger/r01/source/geometry.blend b/sensors/finger/r01/source/geometry.blend new file mode 100644 index 0000000..055e73c --- /dev/null +++ b/sensors/finger/r01/source/geometry.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cdceb6bb091a7f8e1f820063c18b84ea9fa686949381f6d7b1cee74d63c27c18 +size 677092 diff --git a/sensors/finger/r01/source/patch_texture_0703_X80Y45_print.png b/sensors/finger/r01/source/patch_texture_0703_X80Y45_print.png new file mode 100644 index 0000000..b37f273 --- /dev/null +++ b/sensors/finger/r01/source/patch_texture_0703_X80Y45_print.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c92c9d800d22e37f161a9ee4a29b53ff514b7c1d224575a91c5144237cf39963 +size 650206 diff --git a/src/orisys_sensor_design/__init__.py b/src/orisys_sensor_design/__init__.py new file mode 100644 index 0000000..c0350e8 --- /dev/null +++ b/src/orisys_sensor_design/__init__.py @@ -0,0 +1,3 @@ +"""Orisys nominal sensor design tooling (minimal v1).""" + +__version__ = "0.1.0" diff --git a/src/orisys_sensor_design/cli.py b/src/orisys_sensor_design/cli.py new file mode 100644 index 0000000..2fca6cb --- /dev/null +++ b/src/orisys_sensor_design/cli.py @@ -0,0 +1,281 @@ +"""orisys-design CLI — validate, export, manifest.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +from orisys_sensor_design import __version__ +from orisys_sensor_design.manifest import ( + RevisionPaths, + build_export_job, + build_manifest, + default_manifest_path, + export_revision_dir, + export_output_dir, + load_profile, + repo_root, + validate_revision_metadata, + write_export_job, + write_manifest, +) + + +def _blender_from_lock(root: Path) -> Path | None: + lock_path = root / "blender.lock.json" + if not lock_path.is_file(): + return None + try: + data = json.loads(lock_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + raw = data.get("executable") + if not raw: + return None + path = Path(str(raw)).expanduser() + if path.is_file(): + return path.resolve() + return None + + +def _lock_preferred_versions(root: Path | None) -> list[str]: + """Return preferred version tokens from blender.lock.json (lts then full).""" + if root is None: + return [] + lock_path = root / "blender.lock.json" + if not lock_path.is_file(): + return [] + try: + data = json.loads(lock_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return [] + preferred: list[str] = [] + for key in ("blender_lts", "blender_version"): + value = data.get(key) + if isinstance(value, str) and value.strip(): + preferred.append(value.strip()) + return preferred + + +def _blender_install_candidates() -> list[Path]: + """Collect blender.exe under Program Files / Blender Foundation.""" + roots: list[Path] = [] + for env_name in ("ProgramFiles", "ProgramFiles(x86)"): + raw = os.environ.get(env_name) + if raw: + roots.append(Path(raw)) + # Fallbacks when env is missing (some sandboxed shells). + for fallback in (r"C:\Program Files", r"C:\Program Files (x86)"): + path = Path(fallback) + if path not in roots: + roots.append(path) + + found: list[Path] = [] + seen: set[Path] = set() + for root in roots: + base = root / "Blender Foundation" + if not base.is_dir(): + continue + for child in sorted(base.glob("Blender *"), key=lambda p: p.name, reverse=True): + exe = child / "blender.exe" + if not exe.is_file(): + continue + resolved = exe.resolve() + if resolved not in seen: + seen.add(resolved) + found.append(resolved) + return found + + +def _blender_from_program_files(root: Path | None = None) -> Path | None: + """Pick a Blender install from Program Files; prefer lock version, else newest.""" + candidates = _blender_install_candidates() + if not candidates: + return None + + for preferred in _lock_preferred_versions(root): + for exe in candidates: + # Folder names look like "Blender 4.2" or "Blender 5.0". + if preferred in exe.parent.name: + return exe + + # Newest by folder name (already sorted descending in candidates). + return candidates[0] + + +def resolve_blender_executable(root: Path | None = None) -> Path | None: + """Find Blender without hard-coding a machine path. + + Order: ORISYS_BLENDER / BLENDER_EXE → PATH → Program Files scan → + optional blender.lock.json executable. + """ + for env_name in ("ORISYS_BLENDER", "BLENDER_EXE"): + raw = os.environ.get(env_name) + if raw: + path = Path(raw).expanduser() + if path.is_file(): + return path.resolve() + for name in ("blender", "blender.exe"): + found = shutil.which(name) + if found: + return Path(found).resolve() + from_pf = _blender_from_program_files(root) + if from_pf is not None: + return from_pf + if root is not None: + return _blender_from_lock(root) + return None + + +def _run_blender_script(script: str, blend: Path, extra_args: list[str]) -> int: + root = repo_root() + blender = resolve_blender_executable(root) + script_path = root / "blender" / "scripts" / script + if blender is None: + manual = ( + f"blender -b {blend} -P {script_path} -- {' '.join(extra_args)}" + ) + print( + "Blender not found. Set ORISYS_BLENDER (or BLENDER_EXE) to blender.exe, " + "add Blender to PATH, install under Program Files\\Blender Foundation, " + "or set optional 'executable' in blender.lock.json.\n" + f"Manual command:\n {manual}", + file=sys.stderr, + ) + return 2 + cmd = [ + str(blender), + "-b", + str(blend), + "-P", + str(script_path), + "--", + *extra_args, + ] + print("Running:", " ".join(cmd)) + return subprocess.call(cmd) + + +def cmd_validate(revision_dir: Path, blender: bool) -> int: + 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 + + print(f"OK: metadata for {paths.root}") + if not blender: + print("Tip: pass --blender to run in-scene collection checks.") + return 0 + return _run_blender_script("validate.py", paths.source_blend, [str(revision_dir.resolve())]) + + +def cmd_export(revision_dir: Path, profile: str) -> int: + 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 + + load_profile(paths, profile) + output_dir = export_output_dir(root, paths, profile) + output_dir.mkdir(parents=True, exist_ok=True) + job = build_export_job(paths, output_dir, profile) + job_path = write_export_job(job, export_revision_dir(root, paths, profile)) + print(f"Wrote export job: {job_path}") + + code = _run_blender_script( + "export.py", + paths.source_blend, + ["--job", str(job_path)], + ) + if code != 0: + return code + + print(f"Export target: {output_dir}") + missing = [] + for name in load_profile(paths, profile).get("artifacts", []): + if not (output_dir / name).is_file(): + missing.append(name) + if missing: + print( + f"ERROR: export finished but artifacts are missing: {', '.join(missing)}", + file=sys.stderr, + ) + return 3 + print("OK: all profile artifacts present") + 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) + errors = validate_revision_metadata(paths, require_blend=False) + if errors: + for message in errors: + print(f"ERROR: {message}", file=sys.stderr) + return 1 + + manifest = build_manifest(root=root, revision_dir=revision_dir, profile=profile) + if out is None: + out = default_manifest_path(root, paths, profile) + write_manifest(out, manifest) + print(f"Wrote {out}") + if "bundle_sha256" in manifest: + print(f"bundle_sha256: {manifest['bundle_sha256']}") + else: + print("WARN: export directory empty — run export before promoting", file=sys.stderr) + return 0 + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser(prog="orisys-design") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + sub = parser.add_subparsers(dest="command", required=True) + + validate_p = sub.add_parser("validate", help="Check revision metadata and optional Blender scene") + validate_p.add_argument("revision_dir", type=Path) + validate_p.add_argument( + "--blender", + action="store_true", + help="Run blender/scripts/validate.py headless", + ) + + export_p = sub.add_parser("export", help="Export nominal geometry for a profile") + export_p.add_argument("revision_dir", type=Path) + export_p.add_argument("--profile", default="compute", choices=["compute"]) + + 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( + "--out", + type=Path, + default=None, + help="Manifest path (default: build/export///manifest.json)", + ) + manifest_p.add_argument("--profile", default="compute") + + args = parser.parse_args(argv) + if args.command == "validate": + code = cmd_validate(args.revision_dir, args.blender) + elif args.command == "export": + code = cmd_export(args.revision_dir, args.profile) + elif args.command == "manifest": + code = cmd_manifest(args.revision_dir, args.out, args.profile) + else: + parser.error(f"Unknown command: {args.command}") + return + + raise SystemExit(code) + + +if __name__ == "__main__": + main() diff --git a/src/orisys_sensor_design/manifest.py b/src/orisys_sensor_design/manifest.py new file mode 100644 index 0000000..e6c1c9e --- /dev/null +++ b/src/orisys_sensor_design/manifest.py @@ -0,0 +1,229 @@ +"""Release manifest and revision metadata helpers.""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import yaml + + +@dataclass(frozen=True) +class RevisionPaths: + root: Path + design_yaml: Path + source_blend: Path + profiles_dir: Path + + @classmethod + def from_revision_dir(cls, revision_dir: Path) -> RevisionPaths: + revision_dir = revision_dir.resolve() + design_yaml = revision_dir / "design.yaml" + if not design_yaml.is_file(): + raise FileNotFoundError(f"Missing design.yaml: {design_yaml}") + design = load_yaml(design_yaml) + blend_rel = design.get("source_blend", "source/assembly.blend") + return cls( + root=revision_dir, + design_yaml=design_yaml, + source_blend=(revision_dir / blend_rel).resolve(), + profiles_dir=revision_dir / "profiles", + ) + + +def repo_root(start: Path | None = None) -> Path: + here = (start or Path.cwd()).resolve() + for candidate in [here, *here.parents]: + if (candidate / "pyproject.toml").is_file() and (candidate / "sensors").is_dir(): + return candidate + raise FileNotFoundError("Could not locate orisys-sensor-design repository root") + + +def load_yaml(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as handle: + data = yaml.safe_load(handle) + if not isinstance(data, dict): + raise ValueError(f"Expected mapping in {path}") + return data + + +def git_head_commit(root: Path) -> str | None: + try: + result = subprocess.run( + ["git", "-C", str(root), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + except (OSError, subprocess.CalledProcessError): + return None + return result.stdout.strip() or None + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +GENERATED_EXPORT_METADATA = frozenset({"_export_job.json", "manifest.json"}) + + +def _is_generated_metadata(path: Path) -> bool: + return path.name in GENERATED_EXPORT_METADATA + + +def sha256_directory(directory: Path) -> str: + if not directory.is_dir(): + raise FileNotFoundError(f"Export directory not found: {directory}") + parts: list[str] = [] + for path in sorted(directory.rglob("*")): + if path.is_file() and not _is_generated_metadata(path): + rel = path.relative_to(directory).as_posix() + parts.append(f"{rel}\0{sha256_file(path)}") + digest = hashlib.sha256() + digest.update("\n".join(parts).encode("utf-8")) + return digest.hexdigest() + + +def validate_revision_metadata(paths: RevisionPaths, *, require_blend: bool = True) -> list[str]: + errors: list[str] = [] + design = load_yaml(paths.design_yaml) + + for key in ("sensor", "revision", "meters_per_source_unit"): + if key not in design: + errors.append(f"design.yaml missing required key: {key}") + + if require_blend and not paths.source_blend.is_file(): + errors.append(f"Source blend not found: {paths.source_blend}") + + if not paths.profiles_dir.is_dir(): + errors.append(f"Profiles directory not found: {paths.profiles_dir}") + else: + for name in ("compute.yaml", "studio.yaml"): + if not (paths.profiles_dir / name).is_file(): + errors.append(f"Missing profile: {paths.profiles_dir / name}") + + return errors + + +def load_profile(paths: RevisionPaths, profile: str) -> dict[str, Any]: + profile_path = paths.profiles_dir / f"{profile}.yaml" + if not profile_path.is_file(): + raise FileNotFoundError(f"Unknown profile: {profile_path}") + data = load_yaml(profile_path) + if data.get("profile") != profile: + raise ValueError(f"Profile name mismatch in {profile_path}") + return data + + +DEFAULT_EXPORT_OBJECTS = { + "sensing_surface": "sensing_surface", + "cam_sensor": "cam_sensor", + "origin_sensor": "origin_sensor", +} + + +def export_object_names(paths: RevisionPaths) -> dict[str, str]: + """Resolve Blender object names for headless export (design.yaml overrides).""" + design = load_yaml(paths.design_yaml) + objects = design.get("objects") or {} + if not isinstance(objects, dict): + raise ValueError("design.yaml 'objects' must be a mapping") + return { + key: str(objects.get(key, default)) + for key, default in DEFAULT_EXPORT_OBJECTS.items() + } + + +EXPORT_JOB_SCHEMA = "orisys-sensor-design/export-job-v1" +EXPORT_JOB_FILENAME = "_export_job.json" +MANIFEST_FILENAME = "manifest.json" + + +def build_export_job( + paths: RevisionPaths, + output_dir: Path, + profile: str, +) -> dict[str, Any]: + """Build the JSON job dict that Blender's export.py consumes (stdlib JSON).""" + return { + "schema": EXPORT_JOB_SCHEMA, + "output": str(output_dir.resolve()), + "profile": profile, + "objects": export_object_names(paths), + } + + +def export_revision_dir(root: Path, paths: RevisionPaths, profile: str) -> Path: + """Return the revision export folder that contains profile payload dirs.""" + return export_output_dir(root, paths, profile).parent + + +def default_manifest_path(root: Path, paths: RevisionPaths, profile: str) -> Path: + """Default release manifest path beside profile payload directories.""" + return export_revision_dir(root, paths, profile) / MANIFEST_FILENAME + + +def write_export_job(job: dict[str, Any], export_root: Path) -> Path: + """Write _export_job.json beside profile output dirs; return its path.""" + export_root.mkdir(parents=True, exist_ok=True) + job_path = export_root / EXPORT_JOB_FILENAME + job_path.write_text(json.dumps(job, indent=2) + "\n", encoding="utf-8") + return job_path + + +def export_output_dir(root: Path, paths: RevisionPaths, profile: str) -> Path: + profile_data = load_profile(paths, profile) + output_rel = profile_data.get("output_dir") + if not output_rel: + raise ValueError(f"Profile {profile} missing output_dir") + return (root / output_rel).resolve() + + +def build_manifest( + *, + root: Path, + revision_dir: Path, + profile: str = "compute", +) -> dict[str, Any]: + paths = RevisionPaths.from_revision_dir(revision_dir) + design = load_yaml(paths.design_yaml) + profile_data = load_profile(paths, profile) + export_dir = export_output_dir(root, paths, profile) + + files: dict[str, str] = {} + if export_dir.is_dir(): + for path in sorted(export_dir.rglob("*")): + if path.is_file() and not _is_generated_metadata(path): + rel = path.relative_to(export_dir).as_posix() + files[rel] = sha256_file(path) + + manifest: dict[str, Any] = { + "schema": "orisys-sensor-design/manifest-v1", + "generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(), + "design_repo_commit": git_head_commit(root), + "sensor": design.get("sensor"), + "revision": design.get("revision"), + "profile": profile, + "meters_per_source_unit": design.get("meters_per_source_unit"), + "source_blend": str(paths.source_blend.relative_to(root)).replace("\\", "/"), + "export_dir": str(export_dir.relative_to(root)).replace("\\", "/"), + "expected_artifacts": profile_data.get("artifacts", []), + "files": files, + } + if export_dir.is_dir(): + manifest["bundle_sha256"] = sha256_directory(export_dir) + return manifest + + +def write_manifest(path: Path, manifest: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..d3fbe5d --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,184 @@ +"""Tests for revision metadata and manifest generation.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from orisys_sensor_design.cli import resolve_blender_executable +from orisys_sensor_design.manifest import ( + EXPORT_JOB_SCHEMA, + RevisionPaths, + build_export_job, + build_manifest, + default_manifest_path, + export_object_names, + export_output_dir, + export_revision_dir, + repo_root, + sha256_directory, + validate_revision_metadata, + write_export_job, + write_manifest, +) + + +@pytest.fixture() +def revision_dir() -> Path: + root = repo_root() + return root / "sensors" / "finger" / "r01" + + +def test_repo_root_finds_pyproject(revision_dir: Path) -> None: + root = repo_root(revision_dir) + assert (root / "pyproject.toml").is_file() + + +def test_validate_metadata_reports_missing_blend(tmp_path: Path) -> None: + revision = tmp_path / "r99" + (revision / "profiles").mkdir(parents=True) + (revision / "design.yaml").write_text( + "sensor: finger\nrevision: r99\nmeters_per_source_unit: 0.001\n" + "source_blend: source/assembly.blend\n", + encoding="utf-8", + ) + for name in ("compute.yaml", "studio.yaml"): + (revision / "profiles" / name).write_text(f"profile: {name.split('.')[0]}\n", encoding="utf-8") + paths = RevisionPaths.from_revision_dir(revision) + errors = validate_revision_metadata(paths) + assert any("Source blend not found" in message for message in errors) + + +def test_export_object_names_from_design(revision_dir: Path) -> None: + names = export_object_names(RevisionPaths.from_revision_dir(revision_dir)) + assert names == { + "sensing_surface": "sensing_surface", + "cam_sensor": "cam_sensor", + "origin_sensor": "origin_sensor", + } + + +def test_build_export_job_round_trips_json(revision_dir: Path, tmp_path: Path) -> None: + paths = RevisionPaths.from_revision_dir(revision_dir) + output_dir = tmp_path / "compute" + job = build_export_job(paths, output_dir, "compute") + assert job["schema"] == EXPORT_JOB_SCHEMA + assert job["profile"] == "compute" + assert job["objects"] == { + "sensing_surface": "sensing_surface", + "cam_sensor": "cam_sensor", + "origin_sensor": "origin_sensor", + } + assert Path(job["output"]) == output_dir.resolve() + + job_path = write_export_job(job, tmp_path) + assert job_path.name == "_export_job.json" + assert job_path.parent == tmp_path + loaded = json.loads(job_path.read_text(encoding="utf-8")) + assert loaded["schema"] == EXPORT_JOB_SCHEMA + assert loaded["objects"]["cam_sensor"] == "cam_sensor" + assert loaded["objects"]["sensing_surface"] == "sensing_surface" + assert loaded["objects"]["origin_sensor"] == "origin_sensor" + + +def test_export_output_dir_matches_profile(revision_dir: Path) -> None: + root = repo_root(revision_dir) + paths = RevisionPaths.from_revision_dir(revision_dir) + out = export_output_dir(root, paths, "compute") + assert out == (root / "build/export/finger/r01/compute").resolve() + assert export_revision_dir(root, paths, "compute") == ( + root / "build/export/finger/r01" + ).resolve() + assert default_manifest_path(root, paths, "compute") == ( + root / "build/export/finger/r01/manifest.json" + ).resolve() + + +def test_bundle_hash_ignores_generated_metadata(tmp_path: Path) -> None: + payload = tmp_path / "compute" + payload.mkdir() + (payload / "mesh.npz").write_bytes(b"mesh") + digest = sha256_directory(payload) + (payload / "_export_job.json").write_text("{}", encoding="utf-8") + (payload / "manifest.json").write_text("{}", encoding="utf-8") + assert sha256_directory(payload) == digest + + +def test_manifest_writes_without_export(revision_dir: Path, tmp_path: Path) -> None: + manifest = build_manifest( + root=repo_root(revision_dir), + revision_dir=revision_dir, + profile="compute", + ) + out = tmp_path / "manifest.json" + write_manifest(out, manifest) + loaded = json.loads(out.read_text(encoding="utf-8")) + assert loaded["sensor"] == "finger" + assert loaded["revision"] == "r01" + assert loaded["profile"] == "compute" + assert loaded["expected_artifacts"] == [ + "mesh.npz", + "camera_intrinsics.npz", + "world_to_cam.npy", + ] + assert loaded["source_blend"].endswith("geometry.blend") + + +def test_resolve_blender_respects_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + fake = tmp_path / "blender.exe" + fake.write_text("", encoding="utf-8") + monkeypatch.setenv("ORISYS_BLENDER", str(fake)) + monkeypatch.delenv("BLENDER_EXE", raising=False) + assert resolve_blender_executable() == fake.resolve() + + +def test_resolve_blender_from_program_files( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.delenv("ORISYS_BLENDER", raising=False) + monkeypatch.delenv("BLENDER_EXE", raising=False) + monkeypatch.setattr("orisys_sensor_design.cli.shutil.which", lambda _name: None) + + pf = tmp_path / "ProgramFiles" + v42 = pf / "Blender Foundation" / "Blender 4.2" + v50 = pf / "Blender Foundation" / "Blender 5.0" + v42.mkdir(parents=True) + v50.mkdir(parents=True) + exe42 = v42 / "blender.exe" + exe50 = v50 / "blender.exe" + exe42.write_text("", encoding="utf-8") + exe50.write_text("", encoding="utf-8") + + monkeypatch.setenv("ProgramFiles", str(pf)) + monkeypatch.setenv("ProgramFiles(x86)", str(tmp_path / "unused-x86")) + + # No lock preference → newest folder name wins (5.0). + assert resolve_blender_executable(root=None) == exe50.resolve() + + +def test_resolve_blender_prefers_lock_lts( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.delenv("ORISYS_BLENDER", raising=False) + monkeypatch.delenv("BLENDER_EXE", raising=False) + monkeypatch.setattr("orisys_sensor_design.cli.shutil.which", lambda _name: None) + + pf = tmp_path / "ProgramFiles" + v42 = pf / "Blender Foundation" / "Blender 4.2" + v50 = pf / "Blender Foundation" / "Blender 5.0" + v42.mkdir(parents=True) + v50.mkdir(parents=True) + (v42 / "blender.exe").write_text("", encoding="utf-8") + (v50 / "blender.exe").write_text("", encoding="utf-8") + monkeypatch.setenv("ProgramFiles", str(pf)) + monkeypatch.setenv("ProgramFiles(x86)", str(tmp_path / "unused-x86")) + + root = tmp_path / "repo" + root.mkdir() + (root / "blender.lock.json").write_text( + json.dumps({"blender_version": "4.2.0", "blender_lts": "4.2"}), + encoding="utf-8", + ) + assert resolve_blender_executable(root=root) == (v42 / "blender.exe").resolve() diff --git a/tools/export-compute.bat b/tools/export-compute.bat new file mode 100644 index 0000000..dfdbb2b --- /dev/null +++ b/tools/export-compute.bat @@ -0,0 +1,49 @@ +@echo off +setlocal EnableExtensions EnableDelayedExpansion + +rem Convenience launcher: find Blender on this machine and run compute export. +rem Session-local only — does not write machine paths into the repo. + +cd /d "%~dp0\.." +if errorlevel 1 ( + echo ERROR: could not cd to repo root from %~dp0 + exit /b 1 +) + +if defined ORISYS_BLENDER ( + if exist "%ORISYS_BLENDER%" goto :run +) + +set "ORISYS_BLENDER=" +set "PF=%ProgramFiles%" +if not defined PF set "PF=C:\Program Files" + +rem Prefer Blender 5.0 when present, then newest Blender * folder. +if exist "%PF%\Blender Foundation\Blender 5.0\blender.exe" ( + set "ORISYS_BLENDER=%PF%\Blender Foundation\Blender 5.0\blender.exe" + goto :found +) + +for /f "delims=" %%D in ('dir /b /ad /o-n "%PF%\Blender Foundation\Blender *" 2^>nul') do ( + if exist "%PF%\Blender Foundation\%%D\blender.exe" ( + set "ORISYS_BLENDER=%PF%\Blender Foundation\%%D\blender.exe" + goto :found + ) +) + +echo ERROR: blender.exe not found under "%PF%\Blender Foundation" +echo Set ORISYS_BLENDER to your blender.exe path, then re-run. +exit /b 2 + +:found +echo Using Blender: %ORISYS_BLENDER% + +:run +where orisys-design >nul 2>&1 +if errorlevel 1 ( + echo ERROR: orisys-design not on PATH. From repo root run: python -m pip install -e . + exit /b 1 +) + +orisys-design export sensors/finger/r01 --profile compute +exit /b %ERRORLEVEL%