added optics test (undergoing)

This commit is contained in:
ykcheungab
2026-09-23 10:01:08 +08:00
parent f9662df08c
commit bc5a928852
11 changed files with 2099 additions and 9 deletions
+144 -7
View File
@@ -21,6 +21,7 @@ from __future__ import annotations
import argparse
import json
import math
import sys
from pathlib import Path
@@ -53,8 +54,8 @@ def _require_object(name: str, expected_type: str | None = None) -> bpy.types.Ob
return obj
def _load_job(path: Path) -> tuple[str | None, dict, list[str] | None]:
"""Load output path, objects map, and optional housing collections."""
def _load_job(path: Path) -> tuple[str | None, dict, list[str] | None, dict | None]:
"""Load output path, objects map, housing collections and the optics stack."""
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}")
@@ -70,7 +71,13 @@ def _load_job(path: Path) -> tuple[str | None, dict, list[str] | None]:
raise ValueError(
f"Export job 'housing_collections' must be a list of names: {path}"
)
return (str(output) if output else None), objects, housing
optics = data.get("optics")
if optics is not None:
if not isinstance(optics, dict) or not isinstance(optics.get("layers"), list):
raise ValueError(
f"Export job 'optics' must be a mapping with 'layers': {path}"
)
return (str(output) if output else None), objects, housing, optics
def export_camera_intrinsics(camera_obj: bpy.types.Object, scene: bpy.types.Scene):
@@ -255,6 +262,124 @@ def export_housing_in_sensor_frame(
return len(part_names), int(len(vertices)), int(len(faces))
def _mesh_world(obj: bpy.types.Object):
"""Triangulated mesh plus its world matrix, with a cleanup callback."""
return _triangulated_mesh(obj)
def _measure_layer_thickness(
outer_obj: bpy.types.Object, inner_obj: bpy.types.Object
) -> dict[str, float | int | None]:
"""Thickness of one layer, by casting from the outer surface onto the inner.
Each outer vertex is sent inward along its negated vertex normal and the
distance to the inner surface is recorded, which measures the real layer
rather than the nearest-neighbour gap between two point clouds (that
overestimates wherever the surfaces are oblique to each other).
"""
from mathutils.bvhtree import BVHTree
inner_mesh, inner_world, inner_cleanup = _mesh_world(inner_obj)
try:
inner_vertices = [inner_world @ vertex.co for vertex in inner_mesh.vertices]
inner_faces = [tuple(poly.vertices) for poly in inner_mesh.polygons]
if not inner_faces:
return {"count": 0, "p05": None, "p50": None, "p95": None}
tree = BVHTree.FromPolygons(inner_vertices, inner_faces, all_triangles=True)
finally:
inner_cleanup()
outer_mesh, outer_world, outer_cleanup = _mesh_world(outer_obj)
try:
rotation = outer_world.to_3x3()
distances: list[float] = []
for vertex in outer_mesh.vertices:
origin = outer_world @ vertex.co
normal = rotation @ vertex.normal
if normal.length <= 0.0:
continue
normal.normalize()
hit = tree.ray_cast(origin - normal * 1e-4, -normal)
location = hit[0]
if location is None:
continue
distances.append(float((location - origin).length))
finally:
outer_cleanup()
if not distances:
return {"count": 0, "p05": None, "p50": None, "p95": None}
distances.sort()
def pick(fraction: float) -> float:
position = (len(distances) - 1) * fraction
low = int(math.floor(position))
high = int(math.ceil(position))
if low == high:
return distances[low]
return distances[low] + (distances[high] - distances[low]) * (position - low)
return {
"count": len(distances),
"p05": pick(0.05),
"p50": pick(0.50),
"p95": pick(0.95),
}
def export_optics_in_sensor_frame(
layers: list[dict],
t_sensor_from_world: Matrix,
filepath: Path,
) -> int:
"""Export the refracting interfaces, in the sensor frame, one pair per layer."""
payload: dict = {"layer_names": np.array([layer["name"] for layer in layers], dtype=np.str_)}
thickness_p50: list[float] = []
ior_values: list[float] = []
for index, layer in enumerate(layers):
if layer.get("solid"):
targets = [("solid", _require_object(str(layer["solid"]), "MESH"))]
else:
targets = [
("outer", _require_object(str(layer["outer"]), "MESH")),
("inner", _require_object(str(layer["inner"]), "MESH")),
]
for prefix, obj in targets:
mesh, world, cleanup = _mesh_world(obj)
try:
transform = t_sensor_from_world @ world
payload[f"layer{index}_{prefix}_vertices"] = np.array(
[transform @ vertex.co for vertex in mesh.vertices], dtype=np.float32
)
payload[f"layer{index}_{prefix}_faces"] = np.asarray(
[tuple(poly.vertices) for poly in mesh.polygons], dtype=np.int32
)
finally:
cleanup()
if layer.get("solid"):
# Thickness of a solid: enter one wall and leave by the opposite one.
measured = _measure_layer_thickness(targets[0][1], targets[0][1])
else:
measured = _measure_layer_thickness(targets[0][1], targets[1][1])
declared = layer.get("thickness_mm")
thickness = float(declared) if declared is not None else (measured["p50"] or 0.0)
thickness_p50.append(thickness)
ior_values.append(float(layer["ior"]))
print(
f" optics layer {layer['name']!r}: ior={float(layer['ior']):g} "
f"thickness={thickness:.4f} mm "
f"({'declared' if declared is not None else 'measured p50'}), "
f"measured p05={measured['p05']} p95={measured['p95']}"
)
payload["ior"] = np.asarray(ior_values, dtype=np.float64)
payload["thickness_mm"] = np.asarray(thickness_p50, dtype=np.float64)
np.savez_compressed(filepath, **payload)
print(f"Optics exported to {filepath} ({len(layers)} layer(s))")
return len(layers)
def export_camera_pose_sensor_frame(
camera_obj: bpy.types.Object,
t_sensor_from_world: Matrix,
@@ -307,16 +432,17 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
def _resolve_config(
args: argparse.Namespace,
) -> tuple[Path, str, str, str, list[str]]:
) -> tuple[Path, str, str, str, list[str], dict | None]:
"""Merge --job JSON with explicit flag overrides. Explicit flags win."""
job_output: str | None = None
job_objects: dict = {}
job_housing: list[str] | None = None
job_optics: dict | None = None
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, job_housing = _load_job(job_path)
job_output, job_objects, job_housing, job_optics = _load_job(job_path)
output_raw = args.output or job_output
if not output_raw:
@@ -342,13 +468,20 @@ def _resolve_config(
]
elif job_housing is not None:
housing = list(job_housing)
return Path(output_raw).resolve(), str(mesh), str(cam), str(origin), housing
return (
Path(output_raw).resolve(),
str(mesh),
str(cam),
str(origin),
housing,
job_optics,
)
def main() -> int:
args = parse_args(_argv_after_double_dash())
try:
output_dir, mesh_name, cam_name, origin_name, housing_collections = (
output_dir, mesh_name, cam_name, origin_name, housing_collections, optics = (
_resolve_config(args)
)
except Exception as exc:
@@ -378,6 +511,10 @@ def main() -> int:
export_housing_in_sensor_frame(
_housing_objects(housing_collections), t_sensor_from_world, housing_path
)
if optics and optics.get("layers"):
export_optics_in_sensor_frame(
list(optics["layers"]), t_sensor_from_world, output_dir / "optics.npz"
)
K, width, height = export_camera_intrinsics(cam_obj, scene)
np.savez(
intrinsics_path,