为相机网格笔画增加双视图导出

沿用彩色纹理的视图归属、接缝留白和自动页面尺寸。
已验证双视图与单视图 Blender 导出、相关测试及文档门禁。
This commit is contained in:
ykcheungab
2026-10-06 11:01:45 +08:00
parent e8d231ec39
commit afe1ce39cb
16 changed files with 197989 additions and 86 deletions
+11 -4
View File
@@ -233,12 +233,19 @@ blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_pa
```
`stroke-texture` exports black capsule strokes using the same camera grid,
projection and UV output size as `texture_patch`. First generate the single-view
texture job, then run `orisys-design stroke-texture sensors/finger-xynova/r01`.
projection and UV output size as `texture_patch`. First generate the texture job,
then run `orisys-design stroke-texture sensors/finger-xynova/r01` for the
single-view UV maps. For two-view production, first run `texture-views`, then use:
```powershell
orisys-design stroke-texture sensors/finger-xynova/r01 --views --output-dir build/textures/finger-xynova/r01/strokes-views
```
The stroke output is independent of the color artwork pixels. The revision's
`stroke_pattern` block controls the seed, width and length ratios, and orientation
mode; `--output-dir` selects a fresh destination. The command writes PNG/SVG/PDF,
resolved settings and a hash manifest. Two-view `--views` jobs are not supported.
mode; `--output-dir` selects a fresh destination. `--views` uses the existing
`texture-views` job's triangle ownership, seam margin and
per-view page sizes. The command writes PNG/SVG/PDF, resolved settings and a hash manifest.
The projected-cell ratios do not guarantee a 0.2 mm physical stroke width;
measure the production SVG before manufacturing.
+28 -21
View File
@@ -63,11 +63,9 @@ def _pixel_polygon(uv_polygon, width, height):
for u, v in clip_rect(uv_polygon, 0.0, 1.0, 0.0, 1.0)]
def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
angle_map=None, artifact_prefix="camera_grid_stroke"):
width, height = settings["width"], settings["height"]
nx, ny = settings["nx"], settings["ny"]
seed = settings.get("seed") or 0
def render_stroke_polygons(uv_name, mesh, strokes, width, height, vector,
base):
"""Write the shared raster and vector artwork from UV polygons."""
image_name = f"CameraGridStroke_{uv_name}"
if image_name in bpy.data.images:
bpy.data.images.remove(bpy.data.images[image_name])
@@ -75,21 +73,14 @@ def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
pixels = list(image.pixels)
fill_background(pixels, width, height, (0, 0, 0, 1))
mesh_polygons = [_pixel_polygon(poly, width, height) for poly in geometry["mesh"]]
mesh_polygons = [_pixel_polygon(poly, width, height) for poly in mesh]
mesh_polygons = [poly for poly in mesh_polygons if len(poly) >= 3]
for poly in mesh_polygons:
fill_polygon(None, pixels, width, height, poly, (1, 1, 1, 1))
shapes = []
outline_cache = {}
for i, j, fragment, loop_uvs in geometry["camera_fragments"]:
key = (i, j)
if key not in outline_cache:
outline_cache[key] = capsule_in_cell(
i, j, nx, ny, seed, width_ratio, length_ratio,
angle=None if angle_map is None else float(angle_map[j, i]))
uv_poly = projected_stroke_uv(fragment, loop_uvs, outline_cache[key])
pixel_poly = _pixel_polygon(uv_poly, width, height)
for polygon in strokes:
pixel_poly = _pixel_polygon(polygon, width, height)
if len(pixel_poly) < 3:
continue
shapes.append((pixel_poly, (0, 0, 0, 1)))
@@ -97,14 +88,9 @@ def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
image.pixels[:] = pixels
image.update()
label = artifact_prefix
if angle_map is not None and artifact_prefix == "camera_grid_stroke":
label += "_optimized"
base = output_dir / f"{label}_{uv_name}"
save_png_image(image, base.with_suffix(".png"))
page_mesh = _clipped_polygons(mesh_polygons, width, height)
page_shapes = _clipped_shapes(shapes, width, height)
vector = settings["vector"]
vector_args = (width, height, vector["width_mm"], vector["height_mm"])
write_svg_vector(base.with_suffix(".svg"), *vector_args,
page_mesh, page_shapes, [], (0, 0, 0, 1), uv_name,
@@ -112,7 +98,28 @@ def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
write_pdf_vector(base.with_suffix(".pdf"), *vector_args,
page_mesh, page_shapes, [], (0, 0, 0, 1),
artwork_label="stroke")
print(f"{uv_name}: {len(outline_cache)} cells, {len(shapes)} projected fragments")
print(f"{uv_name}: {len(shapes)} projected stroke fragments")
def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
angle_map=None, artifact_prefix="camera_grid_stroke"):
nx, ny = settings["nx"], settings["ny"]
seed = settings.get("seed") or 0
outline_cache = {}
strokes = []
for i, j, fragment, loop_uvs in geometry["camera_fragments"]:
key = (i, j)
if key not in outline_cache:
outline_cache[key] = capsule_in_cell(
i, j, nx, ny, seed, width_ratio, length_ratio,
angle=None if angle_map is None else float(angle_map[j, i]))
strokes.append(projected_stroke_uv(fragment, loop_uvs, outline_cache[key]))
label = artifact_prefix
if angle_map is not None and artifact_prefix == "camera_grid_stroke":
label += "_optimized"
base = output_dir / f"{label}_{uv_name}"
render_stroke_polygons(uv_name, geometry["mesh"], strokes,
settings["width"], settings["height"], settings["vector"], base)
def main():
+116 -32
View File
@@ -53,6 +53,10 @@ from random_patch_dd02c import ( # noqa: E402
write_svg_vector,
)
from patch_geometry import clip_rect # noqa: E402
from camera_grid_stroke import DEFAULT_PARAMS as STROKE_DEFAULT_PARAMS # noqa: E402
from camera_grid_stroke import render_stroke_polygons # noqa: E402
from camera_grid_stroke_geometry import capsule_in_cell, projected_stroke_uv # noqa: E402
from camera_grid_stroke_optimizer import optimize_angles # noqa: E402
from texture_merge import ( # noqa: E402
MergeConfigError,
MergePolicy,
@@ -109,9 +113,39 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
description="Multi-view colored-patch texture generator (job JSON only)"
)
parser.add_argument("--job", required=True, type=Path, help="path to the job JSON")
parser.add_argument("--stroke-params", type=Path, default=None)
parser.add_argument("--stroke-output-dir", type=Path, default=None)
return parser.parse_args(argv)
def _stroke_job(job: dict[str, Any], params_path: Path, output_dir: Path):
"""Point a multiview geometry job at stroke artifacts without changing the source job."""
if job.get("schema") != SCHEMA:
raise ConfigError(f"stroke views require {SCHEMA}")
try:
params = json.loads(params_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise ConfigError(f"cannot read stroke params: {exc}") from exc
if not isinstance(params, dict) or set(params) != set(STROKE_DEFAULT_PARAMS):
raise ConfigError("stroke params must contain the complete camera-grid settings")
if not 0 < params["width_ratio"] <= params["length_ratio"] < 1:
raise ConfigError("stroke ratios require 0 < width_ratio <= length_ratio < 1")
if params["orientation_mode"] not in ("random", "optimized") or params["proposals"] < 1:
raise ConfigError("invalid stroke orientation settings")
copied = dict(job)
copied["pairs"] = []
for pair in job["pairs"]:
pair_id = pair["id"]
copied["pairs"].append({
**pair,
"artifacts": {
fmt: str(output_dir / f"stroke_texture_{pair_id}.{fmt}")
for fmt in ARTIFACT_FORMATS
},
})
return copied, params
def _load_job(path: Path) -> dict[str, Any]:
try:
with path.open(encoding="utf-8") as handle:
@@ -707,7 +741,17 @@ def _write_ground_truth(path: Path, payload: dict[str, Any]) -> None:
def main() -> int:
args = parse_args(_argv_after_double_dash())
try:
config = _resolve_config(_load_job(args.job.resolve()))
job = _load_job(args.job.resolve())
stroke_params = None
if args.stroke_params is not None:
if args.stroke_output_dir is None:
raise ConfigError("--stroke-params requires --stroke-output-dir")
job, stroke_params = _stroke_job(
job, args.stroke_params.resolve(), args.stroke_output_dir.resolve()
)
elif args.stroke_output_dir is not None:
raise ConfigError("--stroke-output-dir requires --stroke-params")
config = _resolve_config(job)
except (ConfigError, OSError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
@@ -747,6 +791,15 @@ def main() -> int:
start_time = time.time()
policy: MergePolicy = config["policy"]
priority = [pair["id"] for pair, _a, _b, _c in resolved]
angle_map = None
if stroke_params is not None and stroke_params["orientation_mode"] == "optimized":
angle_map, report_data = optimize_angles(
config["nx"], config["ny"], stroke_params["seed"],
stroke_params["width_ratio"], stroke_params["length_ratio"],
stroke_params["proposals"],
)
report_path = args.stroke_output_dir / "stroke_texture_optimization.json"
report_path.write_text(json.dumps(report_data, indent=2) + "\n", encoding="utf-8")
detail(
f"Multi-view: {len(resolved)} pair(s), {config['nx']}x{config['ny']} grid, "
@@ -878,6 +931,9 @@ def main() -> int:
pattern_cameras: dict[str, Any] = {}
for pair, _rc, pattern_cam, _layer in resolved:
pattern_cameras[pair["pattern_camera"]] = pattern_cam
if stroke_params is not None and len(pattern_cameras) != 1:
print("ERROR: stroke views require one shared pattern camera", file=sys.stderr)
return 2
uv_maps = [pair["uv_map"] for pair, _a, _b, _c in resolved]
pattern_geometry: dict[str, Any] | None = None
pattern_meta: dict[str, Any] | None = None
@@ -889,6 +945,7 @@ def main() -> int:
fill_unresolved_leaves=True,
seam_segments=seam_segments,
seam_search_mm=gap["inset_mm"],
capture_camera_fragments=stroke_params is not None,
)
except Exception as exc: # noqa: BLE001
print(
@@ -905,6 +962,7 @@ def main() -> int:
assert pattern_geometry is not None and pattern_meta is not None
pattern_triangles = list(pattern_meta["fragment_triangle"])
stroke_outline_cache = {}
# ---- 5. Emit: mask each layout to its owned triangles ---------------------------
emitted_stats: dict[str, dict[str, Any]] = {}
@@ -912,10 +970,11 @@ def main() -> int:
pair_id = pair["id"]
uv_map = pair["uv_map"]
detail(f"--- view {pair_id!r}: region={pair['camera']!r} pattern={pair['pattern_camera']!r} uv_map={uv_map!r}")
if pair["seed"] is not None:
if stroke_params is None and pair["seed"] is not None:
np.random.seed(pair["seed"])
detail(f" colour seed {pair['seed']}")
patch_colors = precompute_cell_colors(config["nx"], config["ny"], config["color_r"])
patch_colors = (precompute_cell_colors(config["nx"], config["ny"], config["color_r"])
if stroke_params is None else None)
# ---- page: derived per view, or inherited from texture_patch ----------------
pair_page_mode = str(pair.get("page") or config["page"]["mode"])
@@ -956,18 +1015,35 @@ def main() -> int:
}
patches = pattern_geometry[uv_map]["patches"]
camera_fragments = pattern_geometry[uv_map].get("camera_fragments")
mesh_layer = pattern_geometry[uv_map]["mesh"]
def fragment_polygon(k: int, patches=patches, camera_fragments=camera_fragments):
if stroke_params is None:
return patches[k][2]
i, j, fragment, loop_uvs = camera_fragments[k]
key = (i, j)
if key not in stroke_outline_cache:
stroke_outline_cache[key] = capsule_in_cell(
i, j, config["nx"], config["ny"], stroke_params["seed"],
stroke_params["width_ratio"], stroke_params["length_ratio"],
angle=None if angle_map is None else float(angle_map[j, i]),
)
return projected_stroke_uv(fragment, loop_uvs, stroke_outline_cache[key])
fragment_seam = pattern_meta.get("fragment_seam_distance_mm")
triangle_seam = pattern_meta.get("triangle_seam_distance_mm")
gap_cfg = config["gap"]
inset = gap_cfg["inset_mm"] if gap_cfg["enabled"] else 0.0
is_post = gap_cfg["enabled"] and gap_cfg["apply"] == "post_process" and triangle_seam_edges
def outside_margin(distance: float | None) -> bool:
def outside_margin(distance: float | None, inset=inset) -> bool:
"""True when this fragment/triangle is clear of the seam margin."""
return distance is None or distance >= inset
def gap_planes(triangle_index: int):
plane_cache: dict[int, list] = {}
def gap_planes(triangle_index: int, plane_cache=plane_cache,
mesh_layer=mesh_layer, inset=inset):
"""Cached UV half-planes cutting this triangle back from its seam edges."""
cached = plane_cache.get(triangle_index)
if cached is None:
@@ -984,7 +1060,6 @@ def main() -> int:
plane_cache[triangle_index] = cached
return cached
plane_cache: dict[int, list] = {}
owned_here = [k for k, t in enumerate(pattern_triangles) if owner.get(t) == pair_id]
if is_post:
# Post-process: every owned fragment is kept, then clipped. A fragment is a
@@ -994,7 +1069,10 @@ def main() -> int:
emitted_patches = []
trimmed = 0
for k in keep:
i, j, polygon = patches[k]
i, j, _ = patches[k]
polygon = fragment_polygon(k)
if len(polygon) < 3:
continue
planes = gap_planes(pattern_triangles[k])
if planes:
before_area = seam_gap.polygon_area(polygon)
@@ -1016,7 +1094,11 @@ def main() -> int:
k for k in owned_here
if outside_margin(None if fragment_seam is None else fragment_seam[k])
]
emitted_patches = [patches[k] for k in keep]
emitted_patches = [
(patches[k][0], patches[k][1], polygon)
for k in keep
if len(polygon := fragment_polygon(k)) >= 3
]
owned_triangles = sorted(t for t, v in owner.items() if v == pair_id)
kept_triangles = sorted({pattern_triangles[k] for k in keep})
@@ -1056,7 +1138,8 @@ def main() -> int:
"owned_triangles": len(owned_triangles),
"owned_mm2": sum(meta["triangle_area_mm2"][t] for t in owned_triangles),
"inked_triangles": len(kept_triangles),
"inked_mm2": sum(meta["triangle_area_mm2"][t] for t in kept_triangles),
"inked_mm2": (sum(meta["triangle_area_mm2"][t] for t in kept_triangles)
if stroke_params is None else None),
"emitted_fragments": len(emitted_patches),
"total_fragments": len(patches),
"gap_enabled": gap_cfg["enabled"],
@@ -1067,7 +1150,7 @@ def main() -> int:
"gap_clipped_polygons": len(keep) - len(emitted_patches) if is_post else 0,
"gap_trimmed_polygons": trimmed_polygons if is_post else 0,
"base_triangles": len(base_layer),
"seed": pair["seed"],
"seed": stroke_params["seed"] if stroke_params is not None else pair["seed"],
"artifacts": pair["artifacts"],
}
summary = (
@@ -1089,33 +1172,31 @@ def main() -> int:
)
report(summary)
uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None
try:
if stroke_params is None:
uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None
generate_patch_texture(
obj,
pattern_cameras[pair["pattern_camera"]],
scene,
depsgraph,
nx=config["nx"],
ny=config["ny"],
r=config["color_r"],
texture_width=texture_width,
texture_height=texture_height,
obj, pattern_cameras[pair["pattern_camera"]], scene, depsgraph,
nx=config["nx"], ny=config["ny"], r=config["color_r"],
texture_width=texture_width, texture_height=texture_height,
output_path=pair["artifacts"]["png"],
svg_path=pair["artifacts"]["svg"],
pdf_path=pair["artifacts"]["pdf"],
vector_width_mm=page_width_mm,
vector_height_mm=page_height_mm,
uv_centre=uv_centre,
cross_length=config["cross_length"],
vector_width_mm=page_width_mm, vector_height_mm=page_height_mm,
uv_centre=uv_centre, cross_length=config["cross_length"],
cross_thickness=config["cross_thickness"],
cross_color=config["cross_color"],
edge_sample_grid=config["edge_sample_grid"],
uv_layer=uv_layer,
image_name=f"PatchTexture_{pair_id}",
patch_colors=patch_colors,
edge_sample_grid=config["edge_sample_grid"], uv_layer=uv_layer,
image_name=f"PatchTexture_{pair_id}", patch_colors=patch_colors,
geometry=filtered,
)
else:
render_stroke_polygons(
uv_map, base_layer, [poly for _, _, poly in emitted_patches],
texture_width, texture_height,
{"width_mm": page_width_mm, "height_mm": page_height_mm},
Path(pair["artifacts"]["png"]).with_suffix(""),
)
except Exception as exc: # noqa: BLE001
print(
f"ERROR: texture generation failed for view {pair_id!r}: {exc}",
@@ -1217,10 +1298,11 @@ def main() -> int:
"hole_mm2": round(result["hole_mm2"], 6),
"total_mm2": round(result["total_mm2"], 6),
"hole_fraction": round(result["hole_fraction"], 8),
"inked_mm2": round(sum(triangle_area[t] for t in inked_triangles), 6),
"inked_fraction": round(
"inked_mm2": (round(sum(triangle_area[t] for t in inked_triangles), 6)
if stroke_params is None else None),
"inked_fraction": (round(
sum(triangle_area[t] for t in inked_triangles) / result["total_mm2"], 8
) if result["total_mm2"] else 0.0,
) if result["total_mm2"] else 0.0) if stroke_params is None else None,
},
"seam_margin": dict(config["gap"]),
"views": [],
@@ -1255,7 +1337,9 @@ def main() -> int:
f"polar={world['polar_from_z_deg']:7.2f} d={world['distance_to_centroid_mm']:.2f}mm\n"
f" object-local az={local['azimuth_deg']:8.2f} el={local['elevation_deg']:+8.2f} "
f"polar={local['polar_from_z_deg']:7.2f}\n"
f" owned={entry['owned_mm2']:.3f}mm^2 inked={entry['inked_mm2']:.3f}mm^2"
f" owned={entry['owned_mm2']:.3f}mm^2 "
+ (f"inked={entry['inked_mm2']:.3f}mm^2" if stroke_params is None
else "stroke ink area not measured")
)
pc = angle_report["pattern_camera"]
detail(
File diff suppressed because one or more lines are too long
@@ -0,0 +1,403 @@
{
"schema": "orisys-sensor-design/print-views-v1",
"mesh": "sensing_surface",
"units": "mm",
"angle_convention": "TWO frames are reported per camera, because they answer different questions. `world` is measured against the scene axes: that is the frame Blender's Transform panel shows for the camera's location and rotation, so check the numbers here against Blender. `object_local` is measured against the mesh object's own axes, which is the physically meaningful frame when the part is a body of revolution. They differ whenever the mesh object carries a rotation. In both: azimuth = atan2(y, x) in the frame's XY plane from +X, wrapped to [0, 360); elevation = signed angle from that plane; polar_from_z_deg = angle from that frame's +Z. The flat azimuth_deg/elevation_deg keys are the WORLD frame.",
"centroid_world_mm": [
0.000793,
-17.867939,
-5.954863
],
"centroid_object_mm": [
0.000793,
0.515454,
18.827053
],
"part_transform": {
"rotation_euler_deg": [
110.0,
-0.0,
0.0
],
"scale": [
1.0,
1.0,
1.0
]
},
"settings": {
"nx": 80,
"ny": 80,
"default_raster": [
3700,
3280
],
"default_page_mm": [
18.5,
16.4
],
"page": {
"mode": "auto",
"px_per_mm": 200.0
},
"seed": 7
},
"ownership": {
"mode": "authored",
"incidence_limit_deg": 70.0,
"min_footprint_mm2": 0.002,
"min_triangle_coverage": 0.6,
"max_hole_fraction": 0.02,
"triangles": 5007,
"owned_mm2": 616.533224,
"hole_mm2": 0.091787,
"total_mm2": 616.625011,
"hole_fraction": 0.00014885,
"inked_mm2": null,
"inked_fraction": null
},
"seam_margin": {
"enabled": false,
"apply": "post_process",
"treatment": "no_print",
"inset_mm": 0.1,
"total_mm": 0.2,
"alignment_tolerance_mm": 0.1
},
"views": [
{
"name": "cam_uv_1",
"type": "ORTHO",
"world": {
"position_mm": [
18.384392,
-41.181198,
-19.463104
],
"view_direction": [
0.563601971,
-0.714734852,
-0.414133906
],
"distance_to_centroid_mm": 32.618052,
"azimuth_deg": 308.2574,
"elevation_deg": -24.4648,
"polar_from_z_deg": 114.4648
},
"object_local": {
"position_mm": [
18.384392,
-4.204537,
45.354443
],
"view_direction": [
0.563601971,
-0.144704893,
0.813273251
],
"distance_to_centroid_mm": 32.618053,
"azimuth_deg": 345.6003,
"elevation_deg": 54.417,
"polar_from_z_deg": 35.583
},
"camera_transform": {
"rotation_euler_deg": [
45.0,
135.0,
-0.0
],
"scale": [
0.014988,
0.014988,
0.014988
],
"parent": null
},
"part_transform": {
"rotation_euler_deg": [
110.0,
-0.0,
0.0
],
"scale": [
1.0,
1.0,
1.0
]
},
"ortho_scale_mm": 27.0,
"lens_mm": null,
"sensor_width_mm": 36.0,
"clip_start_mm": 0.1,
"clip_end_mm": 1000.0,
"position_mm": [
18.384392,
-41.181198,
-19.463104
],
"view_direction": [
0.563601971,
-0.714734852,
-0.414133906
],
"distance_to_centroid_mm": 32.618052,
"azimuth_deg": 308.2574,
"elevation_deg": -24.4648,
"polar_from_z_deg": 114.4648,
"azimuth_deg_frame": "world",
"id": "uv_1",
"uv_map": "uv_1",
"region_camera": "cam_uv_1",
"pattern_camera": "cam_sensor",
"page_source": "auto",
"page_mm": [
17.67,
23.565
],
"raster_px": [
3534,
4713
],
"page_residual": [
1.2885921574223858e-07,
1.0443071318810837e-07
],
"page_slope": [
0.05659285670700212,
0.04243635765680492
],
"owned_triangles": 2471,
"owned_mm2": 308.2663995595292,
"inked_triangles": 2471,
"inked_mm2": null,
"emitted_fragments": 8292,
"total_fragments": 32593,
"gap_enabled": false,
"gap_apply": null,
"gap_requested_total_mm": 0.0,
"gap_achieved_total_mm": null,
"gap_removed_fragments": 0,
"gap_clipped_polygons": 0,
"gap_trimmed_polygons": 0,
"base_triangles": 2471,
"seed": 7,
"artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\stroke_texture_uv_1.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\stroke_texture_uv_1.svg",
"pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\stroke_texture_uv_1.pdf"
}
},
{
"name": "cam_uv_2",
"type": "ORTHO",
"world": {
"position_mm": [
-18.384399,
-41.181198,
-19.463104
],
"view_direction": [
-0.563635349,
-0.714715183,
-0.414122522
],
"distance_to_centroid_mm": 32.618951,
"azimuth_deg": 231.7401,
"elevation_deg": -24.4641,
"polar_from_z_deg": 114.4641
},
"object_local": {
"position_mm": [
-18.384399,
-4.204537,
45.354443
],
"view_direction": [
-0.56363529,
-0.1447009,
0.81325078
],
"distance_to_centroid_mm": 32.618952,
"azimuth_deg": 194.3985,
"elevation_deg": 54.4148,
"polar_from_z_deg": 35.5852
},
"camera_transform": {
"rotation_euler_deg": [
45.0,
-135.0,
-0.0
],
"scale": [
0.014988,
0.014988,
0.014988
],
"parent": null
},
"part_transform": {
"rotation_euler_deg": [
110.0,
-0.0,
0.0
],
"scale": [
1.0,
1.0,
1.0
]
},
"ortho_scale_mm": 27.0,
"lens_mm": null,
"sensor_width_mm": 36.0,
"clip_start_mm": 0.1,
"clip_end_mm": 1000.0,
"position_mm": [
-18.384399,
-41.181198,
-19.463104
],
"view_direction": [
-0.563635349,
-0.714715183,
-0.414122522
],
"distance_to_centroid_mm": 32.618951,
"azimuth_deg": 231.7401,
"elevation_deg": -24.4641,
"polar_from_z_deg": 114.4641,
"azimuth_deg_frame": "world",
"id": "uv_2",
"uv_map": "uv_2",
"region_camera": "cam_uv_2",
"pattern_camera": "cam_sensor",
"page_source": "auto",
"page_mm": [
17.67,
23.565
],
"raster_px": [
3534,
4713
],
"page_residual": [
1.0079875600865762e-07,
9.84046944485506e-08
],
"page_slope": [
0.056592803427866885,
0.04243927082965147
],
"owned_triangles": 2488,
"owned_mm2": 308.2668241740881,
"inked_triangles": 2488,
"inked_mm2": null,
"emitted_fragments": 8334,
"total_fragments": 32593,
"gap_enabled": false,
"gap_apply": null,
"gap_requested_total_mm": 0.0,
"gap_achieved_total_mm": null,
"gap_removed_fragments": 0,
"gap_clipped_polygons": 0,
"gap_trimmed_polygons": 0,
"base_triangles": 2488,
"seed": 7,
"artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\stroke_texture_uv_2.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\stroke_texture_uv_2.svg",
"pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\stroke_texture_uv_2.pdf"
}
}
],
"seam": {
"boundary": "use_seam",
"edges": 45,
"components": 2,
"component_owner": {
"0": "uv_2",
"1": "uv_1"
}
},
"pattern_camera": {
"name": "cam_sensor",
"type": "PERSP",
"world": {
"position_mm": [
0.0,
-9.29984,
4.357839
],
"view_direction": [
-5.912e-05,
0.639047205,
0.769167483
],
"distance_to_centroid_mm": 13.407615,
"azimuth_deg": 90.0053,
"elevation_deg": 50.2792,
"polar_from_z_deg": 39.7208
},
"object_local": {
"position_mm": [
0.0,
7.275762,
7.248523
],
"view_direction": [
-5.912e-05,
0.504214048,
-0.863578737
],
"distance_to_centroid_mm": 13.407614,
"azimuth_deg": 90.0067,
"elevation_deg": -59.7208,
"polar_from_z_deg": 149.7208
},
"camera_transform": {
"rotation_euler_deg": [
30.0,
-0.0,
-179.9999
],
"scale": [
4.671241,
4.671243,
4.671243
],
"parent": null
},
"part_transform": {
"rotation_euler_deg": [
110.0,
-0.0,
0.0
],
"scale": [
1.0,
1.0,
1.0
]
},
"ortho_scale_mm": null,
"lens_mm": 0.396041,
"sensor_width_mm": 1.4,
"clip_start_mm": 0.01,
"clip_end_mm": 100.0,
"position_mm": [
0.0,
-9.29984,
4.357839
],
"view_direction": [
-5.912e-05,
0.639047205,
0.769167483
],
"distance_to_centroid_mm": 13.407615,
"azimuth_deg": 90.0053,
"elevation_deg": 50.2792,
"polar_from_z_deg": 39.7208,
"azimuth_deg_frame": "world"
}
}
@@ -0,0 +1,7 @@
{
"length_ratio": 0.84,
"orientation_mode": "random",
"proposals": 100000,
"seed": 7,
"width_ratio": 0.42
}
@@ -0,0 +1,77 @@
{
"schema": "orisys-sensor-design/stroke-texture-manifest-v2",
"revision_dir": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01",
"views": true,
"source_job": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\_texture_views_job.json",
"sha256": "7259df462de85a86ec575a6296592bf78c008d44ae531d7efd233118686b194f"
},
"source_blend": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01\\source\\geometry.blend",
"sha256": "2a48957a88c760775582e4dc4780d98bf9fc94348918cff8efd82e840217530c"
},
"settings": {
"mode": "camera_grid_capsules",
"resolved": {
"seed": 7,
"width_ratio": 0.42,
"length_ratio": 0.84,
"orientation_mode": "random",
"proposals": 100000
},
"design_yaml": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01\\design.yaml",
"sha256": "711ad69649ef0d771e8f68295bea129af0f13c0e003d9aaf361e25ea5ab8c34e"
},
"params": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\_stroke_pattern_params.json",
"sha256": "823639aa72706b1d47d0c531e704cb99f36517e87fcad7de707417cb474e6993"
}
},
"outputs": [
{
"id": "uv_1",
"artifacts": {
"png": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\stroke_texture_uv_1.png",
"sha256": "53a8af45eecedd370a152223d32fa0ff3b42cbf6d5729953ac04bcb20db7a393"
},
"svg": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\stroke_texture_uv_1.svg",
"sha256": "392363d18ff980e3f698ebb2e745a0f0e5efa3870c9af966671b19d39831420e"
},
"pdf": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\stroke_texture_uv_1.pdf",
"sha256": "11956e5dc65963334b9277797270cb28b1a0d6d5b80d8b927122d04dce3ff6cb"
}
}
},
{
"id": "uv_2",
"artifacts": {
"png": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\stroke_texture_uv_2.png",
"sha256": "25ca871f39de3d7380289b825021e5f555182be63b1809857de38952121a0fec"
},
"svg": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\stroke_texture_uv_2.svg",
"sha256": "76a72e09a2d7ff22972b99b14b5849d500be9060208f875719360d1cb45321c1"
},
"pdf": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\stroke_texture_uv_2.pdf",
"sha256": "09e7cee75d459aaae234362350046af1bdbc6557d5ef950c87462e48e2b907fa"
}
}
}
],
"auxiliary": {
"_multiview_ground_truth.json": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\_multiview_ground_truth.json",
"sha256": "a54e3ecc696471bf2e64b8e9693aa006538292954bc7dd8d2309b4f31447a160"
},
"_print_views.json": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\_print_views.json",
"sha256": "abd5938d35fc8863b91753b2915fb5b39d261f0920286ef3eca2cc4e6a6c89bf"
}
}
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 705 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 1.8 MiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 1.8 MiB

+1 -1
View File
@@ -95,7 +95,7 @@ texture_patch_views:
base_fill: owned # white ground, already split by the seam
# Camera-grid capsule strokes (`orisys-design stroke-texture`). Geometry and output size
# follow texture_patch. This mode currently accepts single-view jobs only.
# follow texture_patch; --views follows texture_patch_views ownership and page sizing.
stroke_pattern:
seed: 7
width_ratio: 0.42
+27 -15
View File
@@ -478,15 +478,15 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
def _stroke_source_job(paths: RevisionPaths, root: Path, args: argparse.Namespace):
"""Validate the single-view patch geometry job used by the stroke generator."""
if args.views:
raise ValueError("camera-grid strokes require a single-view job; --views is unsupported")
"""Validate the patch geometry job used by the stroke generator."""
texture_dir = resolve_texture_output_dir(root, paths, None)
job_path = (args.source_job or texture_dir / "_texture_job.json").resolve()
job_name = "_texture_views_job.json" if args.views else "_texture_job.json"
job_path = (args.source_job or texture_dir / job_name).resolve()
if not job_path.is_file():
raise FileNotFoundError(f"texture job missing: {job_path}")
job = json.loads(job_path.read_text(encoding="utf-8"))
expected_schema = "orisys-sensor-design/texture-job-v3"
expected_schema = ("orisys-sensor-design/texture-job-multiview-v1" if args.views
else "orisys-sensor-design/texture-job-v3")
if job.get("schema") != expected_schema:
raise ValueError(f"expected {expected_schema}, got {job.get('schema')!r}")
if Path(job["revision_dir"]).resolve() != paths.root.resolve():
@@ -495,8 +495,8 @@ def _stroke_source_job(paths: RevisionPaths, root: Path, args: argparse.Namespac
raise ValueError("source job was built from a different Blender scene")
if not isinstance(job["settings"], dict) or not isinstance(job["objects"], dict):
raise ValueError("texture job needs settings and objects")
entries = job["outputs"]
source_ids = [str(entry["uv_map"]) for entry in entries]
entries = job["pairs"] if args.views else job["outputs"]
source_ids = [str(entry["id"] if args.views else entry["uv_map"]) for entry in entries]
if (not source_ids or len(source_ids) != len(set(source_ids))
or any(not source_id or not all(
char.isalnum() or char in "_-" for char in source_id
@@ -557,20 +557,29 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
}
manifest_path = output_dir / "stroke_texture_manifest.json"
temporary = manifest_path.with_suffix(".json.tmp")
if manifest_path.exists() or temporary.exists() or (output_dir / "_stroke_pattern_params.json").exists() or any(path.exists() for artifacts in outputs.values()
for path in artifacts.values()):
auxiliary = ([output_dir / "_multiview_ground_truth.json",
output_dir / "_print_views.json"] if args.views else [])
if settings["orientation_mode"] == "optimized":
auxiliary.append(output_dir / "stroke_texture_optimization.json")
if (manifest_path.exists() or temporary.exists()
or (output_dir / "_stroke_pattern_params.json").exists()
or any(path.exists() for path in auxiliary)
or any(path.exists() for artifacts in outputs.values()
for path in artifacts.values())):
print("ERROR: stroke artifacts already exist; choose a fresh --output-dir",
file=sys.stderr)
return 2
params_path = write_stroke_params(settings, output_dir)
code = _run_blender_script(
"camera_grid_stroke.py", paths.source_blend,
script = "random_patch_multiview.py" if args.views else "camera_grid_stroke.py"
script_args = (["--job", str(job_path), "--stroke-params", str(params_path),
"--stroke-output-dir", str(output_dir)] if args.views else
["--job", str(job_path), "--output-dir", str(output_dir),
"--artifact-prefix", "stroke_texture", "--params", str(params_path)],
)
"--artifact-prefix", "stroke_texture", "--params", str(params_path)])
code = _run_blender_script(script, paths.source_blend, script_args)
if code != 0:
return code
missing = [path for artifacts in outputs.values() for path in artifacts.values()
missing = [path for path in auxiliary if not path.is_file() or path.stat().st_size == 0]
missing += [path for artifacts in outputs.values() for path in artifacts.values()
if not path.is_file() or path.stat().st_size == 0]
if missing:
for path in missing:
@@ -579,6 +588,7 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
manifest = {
"schema": "orisys-sensor-design/stroke-texture-manifest-v2",
"revision_dir": str(paths.root),
"views": bool(args.views),
"source_job": {"path": str(job_path), "sha256": sha256_file(job_path)},
"source_blend": {"path": str(paths.source_blend),
"sha256": sha256_file(paths.source_blend)},
@@ -596,6 +606,8 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
for fmt in ("png", "svg", "pdf")}}
for source_id in source_ids
],
"auxiliary": {path.name: {"path": str(path), "sha256": sha256_file(path)}
for path in auxiliary},
}
temporary.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
temporary.replace(manifest_path)
@@ -2256,7 +2268,7 @@ def main(argv: list[str] | None = None) -> None:
stroke_p.add_argument("revision_dir", type=Path)
stroke_p.add_argument(
"--views", action="store_true",
help="Unsupported for camera-grid strokes; single-view jobs only",
help="Use the multiview job and preserve ownership, seam margin and per-view page size",
)
stroke_p.add_argument(
"--source-job", type=Path, default=None,
+30 -4
View File
@@ -59,7 +59,33 @@ def test_exports_from_geometry_job(tmp_path, monkeypatch):
assert len(calls) == 1
def test_rejects_multiview(tmp_path, monkeypatch):
job = _job(tmp_path)
monkeypatch.setattr(cli, "_run_blender_script", lambda *_: 99)
assert cli.cmd_stroke_texture(REVISION, _args(job, tmp_path / "out", views=True)) == 2
def test_multiview_exports_owned_pairs_without_colour_artwork(tmp_path, monkeypatch):
paths = RevisionPaths.from_revision_dir(REVISION)
job = tmp_path / "_texture_views_job.json"
job.write_text(json.dumps({
"schema": "orisys-sensor-design/texture-job-multiview-v1",
"revision_dir": str(paths.root), "source_blend": str(paths.source_blend),
"settings": {"nx": 80, "ny": 80}, "objects": {"mesh": "sensing_surface"},
"pairs": [{"id": "uv_1"}, {"id": "uv_2"}],
}), encoding="utf-8")
output_dir = tmp_path / "strokes"
calls = []
def run_blender(script, blend, args):
calls.append((script, blend, args))
for pair in ("uv_1", "uv_2"):
for fmt in ("png", "svg", "pdf"):
(output_dir / f"stroke_texture_{pair}.{fmt}").write_bytes(pair.encode())
for name in ("_multiview_ground_truth.json", "_print_views.json"):
(output_dir / name).write_text("{}", encoding="utf-8")
return 0
monkeypatch.setattr(cli, "_run_blender_script", run_blender)
assert cli.cmd_stroke_texture(REVISION, _args(job, output_dir, views=True)) == 0
assert calls[0][0] == "random_patch_multiview.py"
assert "--stroke-params" in calls[0][2]
manifest = json.loads((output_dir / "stroke_texture_manifest.json").read_text())
assert manifest["views"] is True
assert [entry["id"] for entry in manifest["outputs"]] == ["uv_1", "uv_2"]
assert set(manifest["auxiliary"]) == {"_multiview_ground_truth.json", "_print_views.json"}
assert len(calls) == 1