added optics test (undergoing)
This commit is contained in:
+144
-7
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user