new grayscale output for testing laser, not working, will change to new design

This commit is contained in:
ykcheungab
2026-09-30 16:12:06 +08:00
parent 37e3e8400b
commit f0454253c2
27 changed files with 381132 additions and 270 deletions
+29 -6
View File
@@ -681,9 +681,21 @@ def _clipped_shapes(shapes, width, height):
def write_svg_vector( def write_svg_vector(
output_path, texture_width, texture_height, width_mm, height_mm, output_path, texture_width, texture_height, width_mm, height_mm,
mesh_polygons, patch_shapes, cross_rects, cross_color, uv_map mesh_polygons, patch_shapes, cross_rects, cross_color, uv_map,
background=None,
): ):
"""Write editable grouped SVG geometry; no raster data is embedded.""" """Write editable grouped SVG geometry; no raster data is embedded.
``background`` is the page fill. Left as ``None`` it is the black production ground; the
laser-engraving variant passes white, because a laser cuts what is dark and an
unengraved sheet must be light.
"""
if background is None:
background_fill = "#000000"
else:
background_fill = "#" + "".join(
f"{round(_clamp_channel(channel) * 255):02x}" for channel in background[:3]
)
lines = [ lines = [
'<?xml version="1.0" encoding="UTF-8"?>', '<?xml version="1.0" encoding="UTF-8"?>',
( (
@@ -705,7 +717,7 @@ def write_svg_vector(
( (
f' <rect id="background-fill" x="0" y="0" ' f' <rect id="background-fill" x="0" y="0" '
f'width="{texture_width}" height="{texture_height}" ' f'width="{texture_width}" height="{texture_height}" '
f'fill="#000000" stroke="none"/>' f'fill="{background_fill}" stroke="none"/>'
), ),
" </g>", " </g>",
' <g id="mesh">', ' <g id="mesh">',
@@ -785,15 +797,26 @@ def _build_pdf(objects):
def write_pdf_vector( def write_pdf_vector(
output_path, texture_width, texture_height, width_mm, height_mm, output_path, texture_width, texture_height, width_mm, height_mm,
mesh_polygons, patch_shapes, cross_rects, cross_color mesh_polygons, patch_shapes, cross_rects, cross_color,
background=None,
): ):
"""Write native vector PDF paths at the configured physical page size.""" """Write native vector PDF paths at the configured physical page size.
``background`` as for `write_svg_vector`. The default emits the original literal command
so existing documents stay byte-identical.
"""
page_width_pt = float(width_mm) * 72.0 / 25.4 page_width_pt = float(width_mm) * 72.0 / 25.4
page_height_pt = float(height_mm) * 72.0 / 25.4 page_height_pt = float(height_mm) * 72.0 / 25.4
scale_x = page_width_pt / float(texture_width) scale_x = page_width_pt / float(texture_width)
scale_y = page_height_pt / float(texture_height) scale_y = page_height_pt / float(texture_height)
content = ["q", "% background", "0 0 0 rg"] if background is None:
background_command = "0 0 0 rg"
else:
background_command = _pdf_set_fill(
(background[0], background[1], background[2], 1.0)
)
content = ["q", "% background", background_command]
content.append( content.append(
f"0 0 {_format_number(page_width_pt)} {_format_number(page_height_pt)} re f" f"0 0 {_format_number(page_width_pt)} {_format_number(page_height_pt)} re f"
) )
+139 -1
View File
@@ -46,9 +46,13 @@ from random_patch_dd02c import ( # noqa: E402
_require_uv_layer, _require_uv_layer,
_uv_centre, _uv_centre,
build_patch_geometry, build_patch_geometry,
fill_polygon_value,
generate_patch_texture, generate_patch_texture,
precompute_cell_colors, precompute_cell_colors,
write_pdf_vector,
write_svg_vector,
) )
from patch_geometry import clip_rect # noqa: E402
from texture_merge import ( # noqa: E402 from texture_merge import ( # noqa: E402
MergeConfigError, MergeConfigError,
MergePolicy, MergePolicy,
@@ -183,8 +187,12 @@ def _resolve_pairs(job: dict[str, Any]) -> list[dict[str, Any]]:
if not isinstance(artifacts, dict): if not isinstance(artifacts, dict):
raise ConfigError(f"{where}.artifacts: must be a mapping") raise ConfigError(f"{where}.artifacts: must be a mapping")
paths: dict[str, str] = {} paths: dict[str, str] = {}
for format_name in ARTIFACT_FORMATS: # The three print formats are required. The `gray_*` laser-engraving set is optional,
# so it is validated only when the job asks for it.
for format_name in (*ARTIFACT_FORMATS, *(f"gray_{f}" for f in ARTIFACT_FORMATS)):
value = artifacts.get(format_name) value = artifacts.get(format_name)
if value is None and format_name.startswith("gray_"):
continue
if not isinstance(value, str) or not value.strip(): if not isinstance(value, str) or not value.strip():
raise ConfigError( raise ConfigError(
f"{where}.artifacts.{format_name}: must be a non-empty path" f"{where}.artifacts.{format_name}: must be a non-empty path"
@@ -207,6 +215,9 @@ def _resolve_pairs(job: dict[str, Any]) -> list[dict[str, Any]]:
"pattern_camera": pattern_camera, "pattern_camera": pattern_camera,
"uv_map": uv_map, "uv_map": uv_map,
"seed": None if seed is None else int(seed), "seed": None if seed is None else int(seed),
# Carried through, not defaulted: dropping this silently ignored every
# per-pair `page:` override because the global default masked it.
"page": entry.get("page"),
"artifacts": paths, "artifacts": paths,
} }
) )
@@ -537,6 +548,100 @@ def _triangle_seam_edges(
return out return out
def _gray_pixel_polygon(uv_polygon, width, height):
"""The colour path's UV -> pixel mapping, reproduced exactly.
`generate_patch_texture` builds this as a nested closure over `texture_width`/`height`, so
it cannot be imported. Duplicating these two lines is the cost of keeping the gray image
registered with the colour image: **if the colour mapping ever changes, this must too.**
"""
return [
(u * (width - 1), v * (height - 1))
for u, v in clip_rect(uv_polygon, 0.0, 1.0, 0.0, 1.0)
]
def _gray_shapes(patches, patch_colors, width, height):
"""The laser-engraving artwork as (pixel_polygon, rgba) shapes.
**Polarity is inverted relative to the print texture, deliberately.** Most laser software
engraves what is DARK, and the print texture's background is black -- so a straight
luminance conversion would engrave the entire sheet and leave the marker cells almost
untouched. Here the page and the mesh ground are white (untouched) and only the pattern
cells carry tone, so the laser cuts the marker and nothing else.
Tone is Rec.709 luminance of the colour the cell would have been printed in, so engraving
depth varies cell to cell the way printed colour does. The seam margin is inherited from
``patches``, which is the already-clipped emitted set, so every gray format carries the
same alignment band as the colour file.
"""
shapes = []
for i, j, uv_poly in patches:
pixel_poly = _gray_pixel_polygon(uv_poly, width, height)
if len(pixel_poly) < 3:
continue
red, green, blue = patch_colors[j, i]
tone = 0.2126 * float(red) + 0.7152 * float(green) + 0.0722 * float(blue)
shapes.append((pixel_poly, (tone, tone, tone, 1.0)))
return shapes
def _write_grayscale_png(path, width, height, shapes):
"""Rasterise the laser-engraving artwork: pattern dark on white."""
grid = np.ones((height, width), dtype=np.float32)
for pixel_poly, (tone, _g, _b, _a) in shapes:
fill_polygon_value(grid, width, height, pixel_poly, tone)
rgba = np.ones((height, width, 4), dtype=np.float32)
rgba[:, :, 0] = grid
rgba[:, :, 1] = grid
rgba[:, :, 2] = grid
# Same row convention as the colour path: fill_polygon_value indexes grid[y, x] and the
# colour writer indexes pixels[(y * width + x) * 4], so the two images register exactly.
out = Path(path)
out.parent.mkdir(parents=True, exist_ok=True)
name = "LaserGray"
if name in bpy.data.images:
bpy.data.images.remove(bpy.data.images[name])
image = bpy.data.images.new(name, width=width, height=height, alpha=True)
try:
image.pixels.foreach_set(rgba.reshape(-1))
image.update()
image.filepath_raw = str(out)
image.file_format = "PNG"
image.save()
finally:
bpy.data.images.remove(image)
def _write_grayscale_vector(
svg_path, pdf_path, width, height, page_width_mm, page_height_mm, shapes, uv_name
):
"""Write the laser-engraving artwork as vector geometry.
White page, grey pattern cells, no mesh layer and no reference cross -- the same artwork
`_write_grayscale_png` rasterises, so the three formats cannot disagree about what is cut.
The mesh ground is omitted rather than drawn white: the page background is already white,
so emitting thousands of coincident white polygons would bloat the file for nothing.
"""
# White because a laser cuts what is dark and an unengraved sheet must be light; see
# `_gray_shapes` for why the polarity is inverted relative to the print texture.
white = (1.0, 1.0, 1.0)
no_cross: list = []
if svg_path:
write_svg_vector(
svg_path, width, height, page_width_mm, page_height_mm,
[], shapes, no_cross, (1.0, 0.0, 0.0, 1.0), uv_name, background=white,
)
if pdf_path:
write_pdf_vector(
pdf_path, width, height, page_width_mm, page_height_mm,
[], shapes, no_cross, (1.0, 0.0, 0.0, 1.0), background=white,
)
def _derive_pair_page(obj, camera, uv_name, depsgraph, px_per_mm): def _derive_pair_page(obj, camera, uv_name, depsgraph, px_per_mm):
"""Derive and verify one pair's page from the geometry its layout projects. """Derive and verify one pair's page from the geometry its layout projects.
@@ -1018,6 +1123,39 @@ def main() -> int:
) )
return 1 return 1
# The laser-engraving set is written before the artifact check below, so it is verified
# by the same existence test as the print set rather than trusted.
gray_paths = {
name: pair["artifacts"].get(f"gray_{name}") for name in ARTIFACT_FORMATS
}
if any(gray_paths.values()):
try:
gray_shapes = _gray_shapes(
emitted_patches, patch_colors, texture_width, texture_height
)
if gray_paths["png"]:
_write_grayscale_png(
gray_paths["png"], texture_width, texture_height, gray_shapes
)
_write_grayscale_vector(
gray_paths["svg"], gray_paths["pdf"],
texture_width, texture_height, page_width_mm, page_height_mm,
gray_shapes, uv_map,
)
except Exception as exc: # noqa: BLE001
print(
f"ERROR: laser-engraving texture failed for view {pair_id!r}: {exc}",
file=sys.stderr,
)
return 1
detail(
f"[{pair_id}] laser gray {texture_width}x{texture_height} px, "
f"{len(gray_shapes)} cells -> "
+ ", ".join(
Path(path).name for path in gray_paths.values() if path
)
)
for format_name, output in pair["artifacts"].items(): for format_name, output in pair["artifacts"].items():
path = Path(output) path = Path(output)
if not path.is_file() or path.stat().st_size == 0: if not path.is_file() or path.stat().st_size == 0:
@@ -184,7 +184,10 @@
"artifacts": { "artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.png", "png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.svg", "svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.svg",
"pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.pdf" "pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.pdf",
"gray_png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1_gray.png",
"gray_svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1_gray.svg",
"gray_pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1_gray.pdf"
} }
}, },
{ {
@@ -306,7 +309,10 @@
"artifacts": { "artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.png", "png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.svg", "svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.svg",
"pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.pdf" "pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.pdf",
"gray_png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2_gray.png",
"gray_svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2_gray.svg",
"gray_pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2_gray.pdf"
} }
} }
], ],
@@ -1,6 +1,7 @@
{ {
"schema": "orisys-sensor-design/texture-job-multiview-v1", "schema": "orisys-sensor-design/texture-job-multiview-v1",
"mode": "patches-multiview", "mode": "patches-multiview",
"grayscale": true,
"revision_dir": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01", "revision_dir": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01",
"source_blend": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01\\source\\geometry.blend", "source_blend": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01\\source\\geometry.blend",
"objects": { "objects": {
@@ -70,7 +71,10 @@
"artifacts": { "artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.png", "png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.svg", "svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.svg",
"pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.pdf" "pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.pdf",
"gray_png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1_gray.png",
"gray_svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1_gray.svg",
"gray_pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1_gray.pdf"
} }
}, },
{ {
@@ -83,7 +87,10 @@
"artifacts": { "artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.png", "png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.svg", "svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.svg",
"pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.pdf" "pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.pdf",
"gray_png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2_gray.png",
"gray_svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2_gray.svg",
"gray_pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2_gray.pdf"
} }
} }
] ]
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 781 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 2.0 MiB

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

After

Width:  |  Height:  |  Size: 805 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 2.1 MiB

+8 -2
View File
@@ -184,7 +184,10 @@
"artifacts": { "artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.png", "png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.svg", "svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.svg",
"pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.pdf" "pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.pdf",
"gray_png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1_gray.png",
"gray_svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1_gray.svg",
"gray_pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1_gray.pdf"
} }
}, },
{ {
@@ -306,7 +309,10 @@
"artifacts": { "artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.png", "png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.svg", "svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.svg",
"pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.pdf" "pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.pdf",
"gray_png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2_gray.png",
"gray_svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2_gray.svg",
"gray_pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2_gray.pdf"
} }
} }
], ],
@@ -1,6 +1,7 @@
{ {
"schema": "orisys-sensor-design/texture-job-multiview-v1", "schema": "orisys-sensor-design/texture-job-multiview-v1",
"mode": "patches-multiview", "mode": "patches-multiview",
"grayscale": true,
"revision_dir": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger\\r01", "revision_dir": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger\\r01",
"source_blend": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger\\r01\\source\\geometry.blend", "source_blend": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger\\r01\\source\\geometry.blend",
"objects": { "objects": {
@@ -70,7 +71,10 @@
"artifacts": { "artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.png", "png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.svg", "svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.svg",
"pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.pdf" "pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.pdf",
"gray_png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1_gray.png",
"gray_svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1_gray.svg",
"gray_pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1_gray.pdf"
} }
}, },
{ {
@@ -83,7 +87,10 @@
"artifacts": { "artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.png", "png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.svg", "svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.svg",
"pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.pdf" "pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.pdf",
"gray_png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2_gray.png",
"gray_svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2_gray.svg",
"gray_pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2_gray.pdf"
} }
} }
] ]
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 1.4 MiB

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

After

Width:  |  Height:  |  Size: 491 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 1.3 MiB

+12
View File
@@ -0,0 +1,12 @@
# orisys-sensor-design documentation
| document | answers | status |
|---|---|---|
| [design-parameters.md](design-parameters.md) | What does each `design.yaml` key do, what were the measured values, and what invalidates them? | current |
| [probe-pipeline.md](probe-pipeline.md) | How does the probe sensitivity bench work, and what is its contract? | current |
| [probe-method.md](probe-method.md) | Why is the bench measured this way? Contains explicit retractions. | current |
| [probe-findings.md](probe-findings.md) | What did the probe bench find? | current |
| [xynova-texture-investigation.md](xynova-texture-investigation.md) | How the xynova texture question was investigated. | current |
| [code-review.md](code-review.md) | Code review notes and process. | current |
The workflow-level index lives outside this repository at `D:\orisys_ws\docs\README.md`.
+449
View File
@@ -0,0 +1,449 @@
# `design.yaml` 参数说明
本文是 `sensors/<sensor>/r01/design.yaml` 的详细说明,主要面向传感器研发工程师。
本文重点解释两个命令:
- `texture_patch`:在普通 UV 展开图上生成一整张标记图案;
- `texture_patch_views`:把同一套图案拆成多个相机投影视图,并决定每个视图负责印刷哪一部分。
例子:`orisys-design texture-views .\sensors\finger\r01 --grayscale`
## 1. 总览
传感器设计工具依赖开源3维绘图软件blender以及自带的Python API 开发,需要传感器3维CAD模型、镜头FOV参数、cmos的尺寸。目前主要用于摄像头位置和标记点的设计,后续会加入光源优化。
摄像头位置的设计主要是确保在镜头FOV限制下,可以完全覆盖传感的表面,分为感知面积最大化和死区清除两部分。
标记点设计目的是:
* 算法上要导出unwarp conformal的平面图,把3维变形映射到2维计算同时降低畸变。
* 生产上要在不可展曲面上印刷/雕刻标记点,程序需要导出曲面在加工平面上投影的平面图,格式包括 PNG、SVG 或 PDF。
导出的图片类型一览:
```text
Blender 三维传感表面 (sensing surface)
├─ `export` conformal UV 展开 → 计算用
│
├─ `texture` 单相机投影(projected from view) UV 展开 → 生产用,一张图覆盖整个表面
│
└─ `texture-views` 多个相机投影视图 + 分割 → 生产用,每张图只印自己负责的区域
```
设计参数放置在`sensors/<sensor>/r01/design.yaml`里面,具体设计资源参考现有传感器的文件树。
### 1.1 基础原理
叠动视触觉传感器基于单色光技术,标记图案要具备高密度纹理信息,用于光流追踪,目前采用随机彩色方格矩阵纹理设计和UV打印生产,对于平面传感器可以简单密铺,对于Finger曲面传感器需要考虑曲面畸变、镜头透视误差、可视区域、可生产性等等,要在3维层面进行设计,与三维绘图的呈现方式类似。考虑到代码兼容性、价格、功能等因素,采用开源的三维绘图软件Blender和配套的python进行程序开发,可以从相机、模型、图案、光源4个部分独立和耦合设计。
设计流程是:1. 结构传感器曲面设计;2.模型导入Blender摄像头位置摆放;3.曲面不可视区域去除;4.结构曲面修改;5.模型导入Blender和标记图案设计;6.生产摄像头位置摆放和图案生产文件导出。
曲面结构除了标品外,要根据客户灵巧手可释放的空间来设计,但一般有一个非触觉版本的模型,可以作为基准来修改,考虑塑料壳、硅胶层的厚度,得出一个初步的感知曲面形状和位置。然后导入Blender进入详细设计,在结构和摄像头FOV的约束下,选择合适的摄像头摆放位置来达到可视区域的最大化,再根据摄像头FOV去除曲面的不可视区域,然后反馈结构进行修改。最后根据修改后的摄像头位置、曲面结构,生成计算用和生产用标记点图案。
计算用标记点图案作用是把在三维空间的曲面投影到二维的平面上,方便通过过去用于平面的算法计算,投影方法目前采用conformal投影,未来可以尝试其他方法。三维到二维的投影通过Blender里面在实际摄像头位置的透视投影相机,和感知曲面的模型获取,并通过`export`指令导出图案和投影矩阵。生产用图案是通过Blender的正交投影相机模拟在传感器治具正上方,由加工设备(UV打印机或激光雕刻机)看到的形状/投影,从而把我们在曲面上的目标图案,从三维空间投影到生产的二维平面上,支持多角度生产和重复部分去除功能。
通过Bleder Python API,在具备视触觉传感器结构和材料特性下可以进行物理准确的仿真,是本设计和模拟工具的核心支撑。
### 1.2 Blender 使用
以下文档基于Blender 5.0 版本 (5.0.1)编写,如非必要不进行版本更新。
![blender_layout](.\img\design_parameters\blender_layout.png)
Blender的资源树在右上方,不同集合(Collection)分别代表不同的资源:
| 集合 | 种类 | 作用 | 导出 |
| --- | --- | --- |--- |
| ASSEMBLY| mesh、摄像头 | 外壳模型、生产视角摄像头 |所有网格物体,cam_uv_**x** |
| EXCLUDE | 非导出物体 | 文件暂存 |无|
| EXPORT_COMPUTE| mesh | 感知曲面 |sensing_surface|
| FRAMES| empty | 原点定义 |origin_sensor |
| DESIGN_SIM| 摄像头、光源 | 视场设计仿真 |cam_sensor |
---
# 下方文档是AI生成,待整理
---
### 1.1 初学者需要知道的几个词
|术语|简单解释|
|---|---|
|UV|把三维表面的点映射到二维平面的坐标。`U`、`V` 类似图像中的 `x`、`y`|
|UV 展开(unwrap)|像展开纸盒一样,把弯曲表面摊到二维平面;允许人为安排接缝和布局|
|Project from View (Bounds)|从指定相机看向物体,把画面投影到 UV,并缩放到 `[0,1]²`|
|pattern camera|定义标记图案从哪个方向投到表面,即“图案来源”|
|region camera|判断一个区域由哪个印刷视图负责,即“区域分工”|
|ownership|某个三角形最终归哪一个视图打印|
|hole|没有任何视图负责的表面区域,会形成漏印或“秃斑”|
|seam|Blender 中标记的接缝,用于把表面分区|
|page|输出 SVG/PDF 对应的实际物理尺寸,单位为毫米|
|raster|PNG 的像素尺寸,例如 `3700 × 3280 px`|
### 1.2 阅读参数时遵守三条规则
1. **每一个测量值都必须说明失效条件。** 例如相机移动后,以旧相机位置测出的覆盖率就不能
继续使用。
2. **测量条件是结果的一部分。** 涉及区域相机时,必须同时记录相机位姿,因为位姿就是算法
输入。
3. **文件哈希只代表测量当时的文件。** `geometry.blend` 会持续修改。引用旧哈希前必须重新
计算,不能把历史哈希当成当前状态。
本文最重要的目的,是防止一个过期数值继续显得“很合理”。只把说明写在 YAML 注释里,没有
自动失败路径;参数失效后程序仍可能正常运行,却生成错误结果。因此本文会明确写出每个关键
数值的来源、失效条件和重测方法。
## 2. 两种纹理命令的区别
|项目|`texture`|`texture-views`|
|---|---|---|
|YAML 配置块|`texture_patch`|`texture_patch_views`|
|输出定义|`uv_outputs`:名称到文件名的映射|`pairs[]`:视图 ID、相机、UV map|
|二维布局|普通 UV 展开,不是相机投影|由相机拟合到边界的投影视图|
|物理页面|`fixed`:读取 `texture_patch.vector` 中声明的毫米尺寸|`auto`:从相机图像平面推导,并验证它确实是投影视图|
|区域归属|不需要,一张图覆盖整个表面|必须,每个 pair 都包含完整表面,但只打印归自己负责的部分|
|任务格式|`texture-job-v3`|`texture-job-multiview-v1`|
|Blender 驱动|`blender/scripts/random_patch_dd02c.py`|`blender/scripts/random_patch_multiview.py`|
`texture_patch_views` 会从 `texture_patch` 继承以下“图案本身”的设置:
- 栅格尺寸 `width`、`height`;
- 网格数量 `nx`、`ny`;
- 随机种子 `seed`;
- 固定页面备用值 `vector`;
- 颜色配置;
- `edge_sample_grid`。
代码入口是 `texture_views.resolve_views_settings(...)`,内部调用
`resolve_texture_settings(...)`。它不会继承 `uv_outputs`、`merge`、`seam` 或 `print`,
因为这些字段分别属于单图输出或多视图分区。
继承方向只有一个:`texture_patch` 是页面、颜色和图案随机性的基础配置;
`texture_patch_views` 在此基础上增加投影视图和区域分工。
> 为什么不合并两个配置块?在 `texture_patch` 中,页面是人工声明的输入,例如
> `18.5 × 16.4 mm`;在 `texture_patch_views` 中,页面是经过验证后从相机投影推导出的
> 结果。合并后,`page.mode` 的含义会依赖旁边是否出现某个键,还会削弱当前严格的未知键
> 检查,使拼错的配置可能被静默忽略。如果未来出现第三个消费者,或者 `uv_1`、`uv_2`
> 不再是边界投影,再重新评估公共配置结构。
## 3. `texture_patch`:在普通 UV 展开图上生成图案
命令会为 `uv_outputs` 中的每个 UV map 各生成一套 PNG、SVG 和 PDF。所有输出共用相同的
随机颜色和页面设置,仅 UV 布局不同。
UV 展开必须预先在 Blender GUI 中完成并保存在 `source_blend`。无界面运行只读取已有 UV
map,不会自动创建或重新展开 UV。
### 3.1 `uv_outputs` 的顺序不能随意调整
程序通过下面的方式构造输出列表:
```python
block["uv_outputs"].items()
```
因此 YAML mapping 的顺序会进入 `texture-job-v3` 的 `outputs[]`。目前四个测试固定要求顺序
为:
```text
["produce", "compute"]
```
仅从生成像素来看,调换顺序不会改变颜色,因为 `patch_colors` 在遍历各 UV 前只计算一次。
但任务文件数组顺序会改变并导致测试失败,所以不要为了“整理”而重排。
### 3.2 为什么有的配置是 `nx == ny`,有的是 `80 × 45`
`nx`、`ny` 表示相机空间中的图案网格数量。`finger-xynova/r01` 的 `cam_sensor` 是正方形
画幅,所以 `nx == ny` 可以让相机空间中的单元接近正方形。
`finger/r01` 使用 `80 × 45`,来自 16:9 相机。这个比例对它自己的相机是合理的,不能因为
另一个型号采用方形网格就统一修改。
### 3.3 `width`、`height` 与 `vector` 的关系
- `width`、`height`:PNG 栅格尺寸,单位是像素;
- `vector.width_mm`、`vector.height_mm`:SVG/PDF 对应的物理页面尺寸,单位是毫米。
当前两个 revision 都使用 200 px/mm:
```text
3280 / 16.4 = 200
3200 / 16.0 = 200
3700 / 18.5 = 200
```
维护时应满足:
```text
raster pixels = px_per_mm × physical millimetres
```
`_warn_if_page_aspect_differs` 会比较栅格长宽比与物理页面长宽比。如果不一致,SVG/PDF
填满页面时会沿某一方向被拉伸。保持上述关系,警告就不会出现。
### 3.4 `seed` 是整个物理图案的随机种子
固定 `seed` 是为了让同一配置反复运行得到相同的图案。同一次生成中的 PNG、SVG、PDF 和
所有 UV map 共用一套颜色。
`texture-views` 里的每个 pair 也必须使用相同 seed,因为它们打印的是同一个物理表面的
不同部分。如果每个视图使用 `seed + i`,两个印刷文件会在同一个表面上产生不同图案。
> 已知文档缺陷:`orisys-design texture-views --seed` 的帮助文字声称“第 i 个 pair 使用
> `seed + i`”,但实际代码让所有 pair 使用 `settings.seed`。实际代码行为是正确的,CLI
> 帮助文字需要另行修正。
## 4. `texture_patch_views`:多个投影视图和区域分工
这个流程较容易混淆,因为它同时使用三类信息。
### 4.1 三种角色
1. **图案相机 `pattern_camera`**:决定标记图案从哪个方向投到传感表面。所有视图共用一个
图案来源,默认是 `cam_sensor`。
2. **区域相机 `pairs[].camera`**:决定该视图适合负责哪些三角形。它只参与区域分工,不是
图案来源。
3. **`pairs[].uv_map`**:决定该视图最终写入哪个二维布局。
可以把它们理解成:pattern camera 决定“印什么”,region camera 决定“谁来印”,UV map
决定“印刷文件中的坐标”。区分这三者,也能解释为什么片段数量和三角形数量不是同一个概念。
### 4.2 `merge.incidence_limit_deg`
区域归属以 loop triangle 为单位。一个三角形只有同时满足以下条件,才可能归某个视图负责:
- 区域相机能看到它;
- 相机视线与表面法向的夹角不超过 `incidence_limit_deg`;
- 投影面积达到要求。
没有任何视图能够负责的三角形就是 hole,会造成漏印。
在 `finger-xynova/r01` 上,使用真正的 `assign_ownership` 和本次运行生成的 ground truth,
测得:
|`incidence_limit_deg`|hole fraction|相对 `max_hole_fraction: 0.02`|
|---|---|---|
|62°|**0.0480**|**失败**|
|65°|0.0130|通过|
|**70°**|**0.0001**|通过|
这里 `min_triangle_coverage` 不是限制因素:把它从 0.60 放宽到 0,在 62° 和 70° 下都不改变
结果。
这组测量对应以下区域相机位姿:
|相机|方位角|仰角|距离|
|---|---:|---:|---:|
|`cam_uv_1`|308.26°|−24.46°|32.62 mm|
|`cam_uv_2`|231.74°|−24.46°|32.62 mm|
**只要移动区域相机,这张表就立即失效。** `incidence_limit_deg` 描述的是“特定相机位姿与
特定表面”的组合,不是传感器型号自身永远不变的属性。重新调整相机后必须重跑扫描。
> 已撤回的旧结论:早期 `design.yaml` 曾写过“默认 62° 时 hole 面积为 0.100 mm²、比例
> 0.0002”,以及“4960/5007 个三角形有归属,输出 20454 + 21483 个片段”。这些数据在
> 相机移动前成立,现在已经失效。保留这段记录,是为了说明为什么相机相关数据必须写明
> 重测触发条件。
不要删除 YAML 中显式设置的 `incidence_limit_deg: 70.0`。代码中
`texture_views.MERGE_DEFAULTS` 和 `blender/scripts/texture_merge.py` 的默认值都是 62.0,
并有测试要求两处一致。但对当前 Xynova 表面,62° 会产生 4.8% hole;删除覆盖值会静默恢复
错误默认值。
### 4.3 `max_hole_fraction` 是阻断门槛
当 hole fraction 超过 `max_hole_fraction` 时,命令会失败并报告:
- 实际 hole fraction;
- 未覆盖的三角形数量;
- 可能原因。
它不是普通警告。这项检查是在一次 4.8% 漏印被静默输出之后增加的,目的是让“表面有秃斑”
从肉眼发现的问题变成自动失败。
### 4.4 `page.mode: auto` 与 `fixed`
`auto` 只适合使用 Blender **Project from View (Bounds)** 生成的布局。这种布局把相机图像
平面映射到 `[0,1]²`,程序可通过最小二乘仿射拟合反推出页面的实际尺寸。
程序不会因为写了 `auto` 就盲目信任 UV。它会验证:
- 最大拟合残差不超过 `1e-5`;
- UV 边界位于 `[0,1]²`。
真实投影视图的拟合误差大约为 `1.2e-7`;普通 UV 展开的误差约为 0.17–0.70,因此会被拒绝。
`auto` 不会在失败时悄悄退回固定页面。
`fixed` 使用 `texture_patch.vector` 中的页面尺寸。`produce` 和 `compute` 是真正的普通 UV
展开,必须使用 `fixed`;从相机反推它们的页面没有物理意义。
自动页面最终按整数像素确定:
```text
page size = rounded raster pixels / px_per_mm
```
这样页面和栅格的长宽比天然一致。200 px/mm 下,舍入导致的尺寸误差最多为 0.0025 mm,
远低于当前 0.1 mm 对位公差。
已经测得的页面尺寸:
|revision|view|对应测量位姿下的页面|
|---|---|---|
|`finger/r01`|`uv_1`|17.510 × 15.975 mm|
|`finger/r01`|`uv_2`|17.510 × 15.980 mm|
|`finger-xynova/r01`|`uv_1` / `uv_2`|17.670 × 23.565 mm|
这些数值会随相机位置改变,正是使用 `auto` 的原因。Xynova 相机重新定位后,页面从
21.200 × 23.200 mm 自动更新为 17.670 × 23.565 mm。引入自动推导前,固定共享页面在
`finger/r01` 上误差为 6.9%,在 `finger-xynova/r01` 上误差达到 23%,而且横竖方向相反。
### 4.5 `seam.boundary`、`pins` 与 `gap`
`boundary: use_seam` 表示使用 Blender 中人工标记的 seam 作为区域边界。图案生成阶段不会
读取 seam;`patch_projection.py` 只读取 `loop_triangles` 和 `uv_layers`。seam 由后续
ownership 步骤使用。
`pins` 用于在自动分配不符合设计意图时,强制某个连通区域归指定视图。默认留空,让覆盖率
指标决定归属。每次增加 pin 都应写清原因,避免把几何或相机错误隐藏成“人工设置”。
如果启用 gap,总间隙宽度是 `alignment_tolerance_mm` 的两倍。原因是两次独立印刷都可能
产生最大对位误差;每一侧退让一个公差,最坏情况下仍不会重叠。
- `apply: post_process`:将 UV 多边形裁剪到 seam 偏移后的半平面,生成物理宽度准确的直边;
- `apply: geometry_trim`:丢弃整个片段,会产生锯齿边,仅保留作比较。
> 当前仍可看到的阶梯形状来自按三角形分配的 ownership 边界,而不是 gap 自身。gap 只是
> 从这条阶梯边界向内退让。要得到平滑边界,需要支持三角形内部的更细粒度分区。
### 4.6 `print.base_fill`
`base_fill: owned` 会把本视图负责的三角形填成白色,其余区域填黑。这样白色底层也已经沿
seam 分开,两张印刷文件不会在同一位置重复打印白底。
## 5. 常用参数速查
|参数|当前值|来源/测量条件|什么时候失效|怎样重测|
|---|---:|---|---|---|
|Xynova `incidence_limit_deg`|70.0|第 4.2 节记录的相机位姿|移动区域相机或修改表面|对新 ground truth 运行 `assign_ownership`,扫描 62/65/70/75°|
|自动页面|见第 4.4 节|对应区域相机位姿|移动相机或修改网格|重跑 `texture-views`,读取 `_print_views.json`|
|`px_per_mm`|200|设计选择|修改栅格或页面尺寸|计算 `width / vector.width_mm` 和 `height / vector.height_mm`|
|`alignment_tolerance_mm`|0.1 mm|当前印刷与对位能力假设|更换打印、夹具或对位工艺|通过实际计量重新评估,不由本仓库推断|
### 内置默认值不适合当前表面的键
|键|内置默认值|为什么必须保留在 YAML 中|
|---|---:|---|
|Xynova `incidence_limit_deg`|62.0|当前表面会产生 4.8% hole;显式 70.0 是必要配置|
## 6. `expected.*`:目前还没有真正起到保护作用
`expected.sensing_vertices` 和 `expected.sensing_triangles` 看起来像拓扑变化门槛,但当前
`src/`、`blender/`、`tools/`、`tests/` 中没有代码读取它们。
Xynova YAML 当前写的是 2595 vertices / 5005 triangles,实际测得为 2584 vertices /
5007 triangles。早期注释曾声称 `expected.*` 能发现这类变化,这个说法不成立,因为字段尚未
接入导出 manifest 或测试。
在它们真正接入检查之前:
- 把这些数值视为“未验证”;
- 不要在报告中引用它们作为自动验收结果;
- 不要因为字段存在,就假设拓扑变化已经受到保护。
理想的后续实现是:导出时测量实际拓扑,与 `expected.*` 比较;不一致时明确失败并打印差异。
## 7. 已知几何漂移
仓库中的历史资料对 Xynova sensing surface 的描述互不一致:
|来源|记录值|
|---|---|
|`probe-pipeline.md`|2508 vertices / 4834 polygons|
|`xynova-texture-investigation.md`|4818 triangles + 16 quads|
|当前实际测量|2584 vertices / 5007 triangles|
`build/probe/finger-xynova/r01/probe_frames.npz` 来自 2508 顶点的
`sensing_surface_918_s`,而当前带 seam、`uv_1` 和 `uv_2` 的表面有 2584 顶点。因此
`tests/test_render.py::test_end_to_end_render_manifest` 会在顶点数量检查处失败。
这是过期 fixture,不是纹理生成错误。修复方式需要先决定哪个表面才是该 revision 的唯一权威
表面,然后选择其中一种方案:
1. 为当前权威表面重新生成 probe frames;或
2. 把 seam 和 UV 布局制作到已经生成 probe frames 的那个表面上。
当前 revision 包含六个 sensing-surface 变体:
```text
sensing_surface
sensing_surface_0
sensing_surface_0918
sensing_surface_0928
sensing_surface_45
sensing_surface_918_s
```
其中 `_0928` 与 `sensing_surface` 位于相同位置,是重合副本。`EXCLUDE` collection 的
`exclude=False` 表明它只是名称叫 EXCLUDE,并没有在 view layer 中真正排除。射线检测会先
击中这个副本,随后把所有片段判为不可用。
目前 `_0928` 已隐藏;`texture-views` 现在也会在发生遮挡时失败,并指出拦截射线的对象,
不会继续生成空白纹理。
## 8. 两个 revision 的差异
|项目|`finger/r01`|`finger-xynova/r01`|
|---|---|---|
|栅格尺寸|3280 × 3200|3700 × 3280|
|固定页面|16.4 × 16.0 mm|18.5 × 16.4 mm|
|`nx`, `ny`|80, 45(16:9 相机)|80, 80(方形相机)|
|seam 边数|32|45|
|自身 hole|0.049 mm²(0.0001)|0.092 mm²(0.00015)|
|`incidence_limit_deg`|62.0;对应旧相机位姿,移动后必须重测|70.0|
|`optics` 配置|无|有|
不能为了让两个 revision 的 YAML 看起来一致,就统一这些值。分辨率、页面、网格比例、相机
位姿和入射角门槛都是各自设计与测量条件的一部分。
## 9. 新同事修改参数时的建议步骤
1. 先确认正在修改哪个 sensor 和 revision,不要把 `finger` 的参数复制到
`finger-xynova` 后直接使用。
2. 判断改动属于“图案”“UV 布局”“相机位姿”“印刷工艺”还是“几何”。
3. 根据第 5 节查找被改动失效的测量值。
4. 如果移动区域相机,重测 ownership、hole fraction 和自动页面。
5. 如果修改网格,核对拓扑、UV、seam、probe fixture 和 field-domain 相关输出。
6. 如果修改栅格或物理页面,重新计算两个方向的 `px_per_mm`,不能只检查宽度。
7. 运行对应测试并查看生成任务中的 manifest,不要只凭最终图片看起来正常。
8. 将新数值、测量条件、结果和下次失效条件一起更新到本文。
遇到结果异常时,可以按以下顺序排查:
```text
输出为空
→ 是否有重合/遮挡对象
→ region camera 是否能看到表面
→ UV map 名称和对象是否正确
表面有漏印
→ 查看 hole fraction
→ 检查 incidence_limit_deg
→ 检查相机位姿和 footprint 条件
页面比例不对
→ 确认 UV 是 unwrap 还是 Project from View (Bounds)
→ unwrap 使用 fixed;投影视图才使用 auto
→ 检查 raster / px_per_mm / page 的关系
接缝呈阶梯形
→ 先区分 ownership 边界与 gap
→ gap 使用 post_process 只能让退让边变直
→ 平滑 ownership 需要更细的分区算法
```
最后,不要删除“已知缺陷”“撤回结论”和失效条件。这些内容不是历史包袱,而是防止同类问题
再次以合理外观进入生产输出的工程约束。
Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

+59 -142
View File
@@ -8,12 +8,6 @@ meters_per_source_unit: 0.001
source_blend: source/geometry.blend 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: 2595
sensing_triangles: 5005
frames: frames:
sensor: origin_sensor sensor: origin_sensor
@@ -22,115 +16,79 @@ objects:
cam_sensor: cam_sensor cam_sensor: cam_sensor
origin_sensor: origin_sensor origin_sensor: origin_sensor
# Headless colored-patch texture generation (`orisys-design texture`). # Read on every export; changing `assembly` silently changes what ships.
# UV unwrapping is still done in the Blender GUI: the headless run consumes the collections:
# UV maps already stored in source_blend and never creates or unwraps one. frames: FRAMES
# One PNG/SVG/PDF set is written per UV map below. All maps and formats share export_compute: EXPORT_COMPUTE
# the settings and patch colours, so they differ only by UV layout. assembly: ASSEMBLY
# These are the revision defaults; CLI flags override every value. design_sim: DESIGN_SIM
exclude: EXCLUDE
# Topology guard. NOT YET WIRED - no code reads these, and the measured topology is
# 2584/5007. Unverified until connected to the export manifest; see
# docs/design-parameters.md part 5.
expected:
sensing_vertices: 2595
sensing_triangles: 5005
# Camera-space artwork on UV unwraps (`orisys-design texture`).
# See docs/design-parameters.md part 1.
texture_patch: texture_patch:
mesh: sensing_surface mesh: sensing_surface
camera: cam_sensor camera: cam_sensor
uv_outputs: uv_outputs: # ORDER IS OBSERVABLE: becomes the job's outputs[] array
produce: patch_texture_produce.png # hardware production layout produce: patch_texture_produce.png
compute: patch_texture_compute.png # compute/runtime layout compute: patch_texture_compute.png
width: 3700 # 1850*2 width: 3700 # raster px; keep == px_per_mm * vector below
height: 3280 # 1640*2 height: 3280
# Camera frame is square (1000x1000), so equal counts make camera-space nx: 80 # square camera frame, so nx == ny
# elements square. The original 80x45 grid was copied from a 16:9 camera.
nx: 80
ny: 80 ny: 80
color_r: 0.05 color_r: 0.05
edge_sample_grid: 5 edge_sample_grid: 5
seed: 7 # fixed so PNG/SVG/PDF share one reproducible colour set seed: 7 # pinned: one reproducible colour set across PNG/SVG/PDF
vector: vector: # physical page; the `page: fixed` fallback for texture_patch_views
width_mm: 18.5 # editable SVG/PDF production page width width_mm: 18.5
height_mm: 16.4 # editable SVG/PDF production page height height_mm: 16.4
cross: cross:
enabled: true enabled: true
length_px: 40 length_px: 40
thickness_px: 3 thickness_px: 3
color: [1.0, 0.0, 0.0, 1.0] # red reference cross color: [1.0, 0.0, 0.0, 1.0]
# Multi-view production textures (`orisys-design texture-views`). # Projected views with per-triangle ownership (`orisys-design texture-views`).
# # See docs/design-parameters.md part 2.
# `cam_uv_1` / `cam_uv_2` already existed in this blend; `uv_1` / `uv_2` and the seam were
# added to `sensing_surface`. Verified: the marked seam is a valid cut — 45 edges splitting
# the surface into 2 components of 308.314 / 308.311 mm^2 (50.0 / 50.0) — and at the default
# 62 degree limit the partition leaves 0.100 mm^2 of hole (fraction 0.0002), well inside
# max_hole_fraction. Coverage: 4960/5007 triangles owned, 20454 + 21483 fragments emitted.
#
# NOTE: this revision contains SIX sensing-surface variants (`sensing_surface`, `_0`, `_0918`,
# `_0928`, `_45`, `_918_s`). `_0928` is the only one carrying the seam and `uv_1`/`uv_2`, so
# `sensing_surface` is a copy of it. Two consequences to be aware of:
# 1. `_0928` is a COINCIDENT duplicate of `sensing_surface` at identical coordinates. The
# `EXCLUDE` collection has `exclude=False`, so it is not actually excluded from the view
# layer, and the raycast hits the duplicate instead of the target. That rejects every
# fragment and the region cameras return 0 — `texture-views` now fails loudly and names
# the intercepting object rather than writing blank textures. `_0928` is hidden.
# 2. The probe/render baseline (`build/probe/finger-xynova/r01/probe_frames.npz`) was built
# from the 2508-vertex `sensing_surface_918_s`, while `sensing_surface` has 2584 vertices.
# `tests/test_render.py::test_end_to_end_render_manifest` therefore fails on the vertex
# count. That is a fixture/stale-baseline mismatch, not a texture bug: either regenerate
# the probe frames for the current surface, or author the seam and the extra UV layouts
# on whichever surface is canonical. `expected.*` below would have caught this and is
# still read by nothing.
texture_patch_views: texture_patch_views:
enabled: true enabled: true
pattern_camera: cam_sensor # the pattern every layout carries, same source as `compute` pattern_camera: cam_sensor # the ink source; a pair's `camera` only decides ownership
# Per-view page sizing. Measured here: the uv_1/uv_2 layouts are 21.1989 x 23.2003 mm
# (aspect 0.9137) and 21.2001 x 23.2003 mm (aspect 0.9138) — PORTRAIT. `vector:` above says
# 18.5 x 16.4 (aspect 1.1280), which is LANDSCAPE, so the artwork was stretched 23% in the
# wrong direction. `auto` derives the page per view and proves the layout really is a
# bounds-projection from the named camera before using the result.
#
# `px_per_mm: 200` reproduces the existing density exactly (3700/18.5 = 200.0).
#
# `fixed` keeps `texture_patch.vector`, which unwrapped layouts (`produce`, `compute`)
# require — they are not projections.
#
# Overlay note: the scene render is 250x250 while these layouts are 0.9138 aspect, so set the
# render resolution to the emitted raster size and use "Stretch to fill".
page: page:
mode: auto mode: auto # auto = derived per view (projections only); fixed = use vector above
px_per_mm: 200 px_per_mm: 200
pairs: pairs:
- id: uv_1 - id: uv_1
camera: cam_uv_1 # REGION camera camera: cam_uv_1 # REGION camera, not the pattern source
uv_map: uv_1 uv_map: uv_1
- id: uv_2 - id: uv_2
camera: cam_uv_2 camera: cam_uv_2
uv_map: uv_2 uv_map: uv_2
merge: merge:
mode: authored # honour the 45-edge seam marked on the mesh mode: authored # honour the seam marked on the mesh
# RE-MEASURED 2026-09-29 after the region cameras moved (cam_uv_1 az 318.9 -> 308.3, incidence_limit_deg: 70.0 # DO NOT DELETE: built-in default 62.0 FAILS here (4.8% hole)
# elevation -43.3 -> -24.5, distance 37.6 -> 32.6 mm). Sweep at min_triangle_coverage 0.60: max_hole_fraction: 0.02 # enforced: the run fails above this rather than shipping a gap
# 62deg -> hole 0.0480 FAILS max_hole_fraction (a visible bald patch of 29.6 mm^2)
# 65deg -> hole 0.0130 passes, but barely
# 70deg -> hole 0.0001 passes with margin <-- current
# The limit is a property of the camera poses and the surface, so MOVING THE CAMERAS
# INVALIDATES IT. Re-run the sweep after any re-pose. min_triangle_coverage is not binding
# here: relaxing it to 0 changes nothing at 62 or 70 degrees.
incidence_limit_deg: 70.0
min_footprint_mm2: 2.0e-3 min_footprint_mm2: 2.0e-3
min_triangle_coverage: 0.60 min_triangle_coverage: 0.60
max_hole_fraction: 0.02 # enforced: the run fails above this rather than shipping a gap
component_policy: best_coverage component_policy: best_coverage
seam: seam:
boundary: use_seam boundary: use_seam
pins: [] pins: []
# Empty band along the seam, to absorb two-pass registration error. Default total width is
# 2 x alignment_tolerance_mm = 0.2 mm, half per file; `total_mm` overrides the derivation.
gap: gap:
enabled: false # false = hard butt at the seam, no band at all enabled: false # false = hard butt at the seam, no band at all
apply: post_process # post_process = straight edge | geometry_trim = sawtooth apply: post_process # post_process = straight edge | geometry_trim = sawtooth
treatment: no_print # the band carries nothing treatment: no_print
alignment_tolerance_mm: 0.1 # per-pass placement accuracy, mm alignment_tolerance_mm: 0.1
print: print:
base_fill: owned # white ground, clipped by the seam base_fill: owned # white ground, already split by the seam
# Post-generation printability inspection (`orisys-design inspect`). Advisory # Advisory only: reports a verdict, never blocks generation or changes exit codes.
# only: it reports a verdict but never blocks generation or changes exit codes.
# Millimetres are measured on the vector page declared above. # Millimetres are measured on the vector page declared above.
printability: printability:
printer_dpi: 600 # target UV printer resolution printer_dpi: 600 # target UV printer resolution
@@ -140,21 +98,14 @@ printability:
max_sliver_ratio: 4.0 # longest edge / minimum width max_sliver_ratio: 4.0 # longest edge / minimum width
min_patch_area_mm2: 1.0e-4 # area above which a polygon is a material feature min_patch_area_mm2: 1.0e-4 # area above which a polygon is a material feature
# Optical stack above the markers, ordered from the camera inward. # Optical stack above the markers, ordered camera-inward. ior values are LITERATURE
# The markers sit on the sensing surface underneath these layers, so their image # ASSUMPTIONS, not measurements - that is what ior_source records.
# is displaced relative to the straight-line pinhole model.
#
# ior values are LITERATURE ASSUMPTIONS, not measurements - that is what
# ior_source records, so an assumed index can never pass for a measured one.
# Replace them with measured values when available; nothing else needs to change.
optics: optics:
marker_surface: sensing_surface marker_surface: sensing_surface
layers: layers:
# Solids, not surface pairs: `shell` and `rubber-1` are each one closed mesh, # Solids, not surface pairs: `shell` and `rubber-1` are each one closed mesh, so the
# so the ray enters and leaves the same object. # ray enters and leaves the same object. rubber-2 is BEHIND the markers, so it does
# Stack: camera > shell > rubber-1 > sensing_surface > rubber-2. # not refract camera light and is deliberately not a layer.
# rubber-2 is BEHIND the markers, so it does not refract camera light and is
# deliberately not a layer.
- name: plastic_shell - name: plastic_shell
solid: shell solid: shell
ior: 1.59 ior: 1.59
@@ -165,48 +116,33 @@ optics:
ior_source: assumed_silicone_literature ior_source: assumed_silicone_literature
# Probe sensitivity bench (`orisys-design deform|render|track|sensitivity|optimise`). # Probe sensitivity bench (`orisys-design deform|render|track|sensitivity|optimise`).
# Deforms the vertices of `group` through known x/y/z displacements expressed in a # Contract: docs/probe-pipeline.md. The .blend is never written by the pipeline.
# local probe-average basis (normal force along z, shear along x/y), renders what
# `camera` sees, and measures DIS optical flow against the zero-displacement frame.
#
# Contract: docs/probe-pipeline.md. The `.blend` is never written by the pipeline.
probe: probe:
mesh: sensing_surface mesh: sensing_surface
camera: cam_sensor camera: cam_sensor
group: probe group: probe
uv_map: compute uv_map: compute
texture: patch_texture_compute.png texture: patch_texture_compute.png
# Proportional falloff radius for the outer rubber. Note this mesh has a median # Median mesh edge here is 0.63 mm, so 0.5 mm reaches only immediate neighbours; the
# edge of 0.63 mm, so 0.5 mm reaches only the probe's immediate neighbours; the
# deform report prints how many vertices actually move rather than hiding it. # deform report prints how many vertices actually move rather than hiding it.
radius_mm: 0.5 radius_mm: 0.5
falloff: smooth falloff: smooth
step_mm: 0.01 step_mm: 0.01
max_mm: 0.10 max_mm: 0.10
axes: [x, y, z] axes: [x, y, z]
# Frames are rendered at the revision's real camera resolution. Keep this in step with # The pipeline overrides the scene resolution from here, so a blend edited to a
# the camera in source/geometry.blend: the pipeline overrides the scene resolution from # different size is silently ignored. Keep in step with source/geometry.blend.
# here, so a blend edited to a different size is silently ignored otherwise.
#
# Single scale on purpose. The ROI radius is radius_mm x the probe-local px/mm, so at
# 125x125 it would be 1.66 px against an 8 px DIS window: the estimator measures its own
# regulariser, not the displacement. See docs/probe-findings.md section 2.
resolution: [250, 250] resolution: [250, 250]
process_resolution: [250, 250] process_resolution: [250, 250]
# Mirrors the SDK sensor tracker keys (orisys-sdk config/finger.json), EXCEPT # Mirrors the SDK tracker keys EXCEPT dis_mode: the 8*2**dis_mode window against a
# dis_mode. The estimator window is 8 * 2**dis_mode input px (16 at dis_mode=1, 8 at 0) # ~6.6 px moving region makes the shipped dis_mode=1 collapse the estimate ~3x.
# and the moving region is ~6.6 px across at 250x250, so the shipped dis_mode=1 gives
# window/moving = 2.42: DIS's smoothness pulls the estimate toward zero and the measured
# response drops ~3x (1.42 -> 4.30 px/mm at the matched cell). dis_mode=0 gives 1.21,
# the same ratio as 500x500 with dis_mode=1, which recovers to ~73%.
# See docs/probe-findings.md section 2. # See docs/probe-findings.md section 2.
# If the deployed tracker is fixed at dis_mode=1, run the bench at 500x500 instead.
tracker: tracker:
algorithm: dis algorithm: dis
dis_preset: medium dis_preset: medium
dis_mode: 0 dis_mode: 0
# Standard (not AgX) and denoise off: a filmic view transform silently distorts # Standard (not AgX) and denoise off: a filmic view transform silently distorts the
# the marker values the tracker is supposed to measure. # marker values the tracker is supposed to measure.
render: render:
engine: CYCLES engine: CYCLES
samples: 16 samples: 16
@@ -214,39 +150,20 @@ probe:
view_transform: Standard view_transform: Standard
interpolate: Closest interpolate: Closest
seed: 0 seed: 0
# Noise calibration and the injected noise model. # `floor_px` from a re-render at seed+1 is Cycles antialiasing jitter, NOT sensor
# # noise, so it is kept only as a reproducibility diagnostic; the score is anchored to
# The estimator's `floor_px` is measured as reference vs a re-render at seed+1, which # the injected model plus the idle-footage calibration below.
# is Cycles antialiasing jitter, NOT sensor noise: on this data it differs by ~5 grey # See docs/probe-findings.md section 2.
# levels RMS on half the pixels and scales with edge contrast. Ranking patterns by
# gain/that-floor is unsound - the ordering reverses under additive noise, and it
# understates the real idle floor measured from the footage below, coarse patterns
# most. So the render-seed floor is kept only as a reproducibility diagnostic and the
# score is anchored to the injected model plus this measured calibration.
noise: noise:
# Real idle (no-contact) footage, relative to the repo root. Measured: floor
# ~0.23 px at 1280x720 (short baseline == long baseline, so it is white noise and
# the clip is genuinely idle), dominant marker period ~25 px, i.e. ~0.0093 floor
# per period. That is 2-6x the simulated render-seed floor per cell.
idle_footage: ../data/dd02-0703/190.mp4 idle_footage: ../data/dd02-0703/190.mp4
roi_fraction: 0.18 roi_fraction: 0.18
max_frames: 120 max_frames: 120
# Additive white Gaussian in grey levels. Defaults are deliberately small: each
# sigma costs a full DIS pass, and the process scale is a resample of the render
# one, so only the render scale is evaluated unless asked.
sigmas: [4.0, 16.0] sigmas: [4.0, 16.0]
draws: 1 draws: 1
seed: 0 seed: 0
models: [render_seed, injected] models: [render_seed, injected]
scales: [render] scales: [render]
collections:
frames: FRAMES
export_compute: EXPORT_COMPUTE
assembly: ASSEMBLY
design_sim: DESIGN_SIM
exclude: EXCLUDE
notes: | notes: |
Canonical source is geometry.blend with named collections and objects. Canonical source is geometry.blend with named collections and objects.
Compute export uses sensing_surface + cam_sensor relative to origin_sensor. Compute export uses sensing_surface + cam_sensor relative to origin_sensor.
Binary file not shown.
+52 -104
View File
@@ -8,12 +8,6 @@ meters_per_source_unit: 0.001
source_blend: source/geometry.blend 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: frames:
sensor: origin_sensor sensor: origin_sensor
@@ -22,124 +16,85 @@ objects:
cam_sensor: cam_sensor cam_sensor: cam_sensor
origin_sensor: origin_sensor origin_sensor: origin_sensor
# Headless colored-patch texture generation (`orisys-design texture`). # Read on every export; changing `assembly` silently changes what ships.
# UV unwrapping is still done in the Blender GUI: the headless run consumes the collections:
# UV maps already stored in source_blend and never creates or unwraps one. frames: FRAMES
# One PNG/SVG/PDF set is written per UV map below. All maps and formats share export_compute: EXPORT_COMPUTE
# the settings and patch colours, so they differ only by UV layout. assembly: ASSEMBLY
# These are the revision defaults; CLI flags override every value. design_sim: DESIGN_SIM
exclude: EXCLUDE
# Topology guard. NOT YET WIRED - no code reads these, and the measured topology is
# 1034/1977. Unverified until connected to the export manifest; see
# docs/design-parameters.md part 5.
expected:
sensing_vertices: 1032
sensing_triangles: 1974
# Camera-space artwork on UV unwraps (`orisys-design texture`).
# See docs/design-parameters.md part 1.
texture_patch: texture_patch:
mesh: sensing_surface mesh: sensing_surface
camera: cam_sensor camera: cam_sensor
uv_outputs: uv_outputs: # ORDER IS OBSERVABLE: becomes the job's outputs[] array
produce: patch_texture_produce.png # hardware production layout produce: patch_texture_produce.png
compute: patch_texture_compute.png # compute/runtime layout compute: patch_texture_compute.png
width: 3280 # 1640*2 width: 3280 # raster px; keep == px_per_mm * vector below
height: 3200 # 1600*2 height: 3200
nx: 80 nx: 80 # 16:9 camera frame, left as-is from the original grid
ny: 45 ny: 45
color_r: 0.05 color_r: 0.05
edge_sample_grid: 5 edge_sample_grid: 5
seed: 7 # fixed so PNG/SVG/PDF share one reproducible colour set seed: 7 # pinned: one reproducible colour set across PNG/SVG/PDF
vector: vector: # physical page; the `page: fixed` fallback for texture_patch_views
width_mm: 16.4 # editable SVG/PDF production page width width_mm: 16.4
height_mm: 16.0 # editable SVG/PDF production page height height_mm: 16.0
cross: cross:
enabled: true enabled: true
length_px: 40 length_px: 40
thickness_px: 3 thickness_px: 3
color: [1.0, 0.0, 0.0, 1.0] # red reference cross color: [1.0, 0.0, 0.0, 1.0]
# Multi-view production textures (`orisys-design texture-views`). # Projected views with per-triangle ownership (`orisys-design texture-views`).
# # The PATTERN is grounded on cam_sensor, not on the production views: uv_1 and uv_2 are two
# The PATTERN is grounded on the sensing camera, not on the production views. `uv_1` and # parameterisations of the one surface pattern `compute` carries, so the printed marker stays
# `uv_2` are two different *parameterisations* of the one surface pattern that # a single pattern. `cam_uv_1`/`cam_uv_2` authored those layouts (Project from View (Bounds))
# `texture_compute` carries, so the printed marker stays a single pattern across the whole # and now serve as each pair's REGION camera, deciding what that pair may own.
# surface. `cam_uv_1` / `cam_uv_2` authored those layouts in Blender (Project from View # Ownership closes the overlap: both layouts are full-surface, so without a partition printing
# (Bounds)) and now serve as each pair's REGION camera: they decide which surface that pair # both would double-ink essentially the whole part.
# may own. Neither is a pattern source. # See docs/design-parameters.md part 2.
#
# OWNERSHIP closes the overlap. `uv_1` and `uv_2` are both full-surface layouts, so printing
# both would double-ink essentially the whole part. Every loop triangle is assigned to
# exactly one pair and each file emits only its own triangles, which makes zero double ink a
# property of the partition rather than something to measure after printing.
#
# Rendering settings (page size, grid, palette, seed) come from `texture_patch` above,
# because they are decisions about the printed page rather than about a particular layout.
# `produce`/`compute` and `orisys-design texture` are untouched.
texture_patch_views: texture_patch_views:
enabled: true enabled: true
pattern_camera: cam_sensor # the pattern every layout carries, same source as `compute` pattern_camera: cam_sensor # the ink source; a pair's `camera` only decides ownership
# Per-view page sizing. A layout authored by Project from View (Bounds) maps [0,1]^2 to the
# image-plane extent of the mesh through ITS camera, so the page is measurable and differs
# per view: measured 17.5116 x 15.9772 mm for uv_1 (aspect 1.0960) and 17.5114 x 15.9820 mm
# for uv_2 (aspect 1.0957). `vector:` above says 16.4 x 16.0 (aspect 1.0250), which stretched
# the artwork by 6.9% along u. `auto` derives it and proves the layout really is a
# bounds-projection from the named camera before using the result.
#
# `px_per_mm: 200` reproduces the existing density exactly (3280/16.4 = 3700/18.5 = 200.0),
# and the page is set from the rounded raster so page and raster always share an aspect.
#
# `fixed` keeps `texture_patch.vector`. Unwrapped layouts (`produce`, `compute`) are not
# projections and must use it — a camera-derived page would be meaningless there.
#
# To overlay a texture on a render from its camera: set the render resolution to the emitted
# raster size and the background image to "Stretch to fill". At any other resolution the
# scene aspect (1280x720 here) will distort the overlay regardless of the page.
page: page:
mode: auto mode: auto # auto = derived per view (projections only); fixed = use vector above
px_per_mm: 200 px_per_mm: 200
pairs: pairs:
- id: uv_1 # layout authored from cam_uv_1 - id: uv_1
camera: cam_uv_1 # REGION camera: what this pair may own camera: cam_uv_1 # REGION camera, not the pattern source
uv_map: uv_1 uv_map: uv_1
- id: uv_2 - id: uv_2
camera: cam_uv_2 camera: cam_uv_2
uv_map: uv_2 uv_map: uv_2
merge: merge:
# `authored` honours the seam marked on the mesh: 32 edges on y = 0, which cuts the mode: authored # honour the seam marked on the mesh
# surface into 2 components of 177.882 / 177.885 mm^2 (50.0 / 50.0). Because the two incidence_limit_deg: 62.0 # calibrated to THIS revision's cameras; re-measure if they move
# cameras are mirrored about XZ that plane is also where incidence_uv1 == incidence_uv2, max_hole_fraction: 0.02 # enforced: the run fails above this rather than shipping a gap
# so the hand-drawn seam coincides with the metric-optimal boundary: measured coverage min_footprint_mm2: 2.0e-3
# differs from `computed` by 0.0014 at 60 deg and by nothing from 62 deg up.
mode: authored
# Calibrated on this mesh (355.767 mm^2, 1977 tris): 60 deg leaves 0.0218 holes and FAILS
# max_hole_fraction, 62 deg leaves 0.0000. 65 deg was an earlier estimate from the
# pre-edit mesh; 62 is the measured threshold.
incidence_limit_deg: 62.0
min_footprint_mm2: 2.0e-3 # ~1 printer dot at 600 dpi, squared x 1.1
min_triangle_coverage: 0.60 min_triangle_coverage: 0.60
max_hole_fraction: 0.02
component_policy: best_coverage # not `pinned`: both views qualify, so let the metric rank component_policy: best_coverage # not `pinned`: both views qualify, so let the metric rank
seam: seam:
boundary: use_seam # read mesh.edges[].use_seam as a hard graph cut boundary: use_seam # read mesh.edges[].use_seam as a hard graph cut
pins: [] # {component, view, reason} to override a component's owner pins: [] # {component, view, reason} to override a component's owner
# Empty band along the seam, to absorb two-pass registration error. Without it the two
# files butt exactly at the seam and any placement error makes them overlap there, which
# is the delamination case.
#
# The width is derived, not guessed. Each pass is placed to within
# `alignment_tolerance_mm`, so the two can close on each other by twice that; the band
# must be at least that wide. Default total = 2 x tolerance = 0.2 mm, half per file, and
# `total_mm` overrides the derivation if a different margin is wanted.
gap: gap:
enabled: true # false = hard butt at the seam, no band at all enabled: true # false = hard butt at the seam, no band at all
apply: post_process # post_process = straight edge | geometry_trim = sawtooth apply: post_process # post_process = straight edge | geometry_trim = sawtooth
treatment: no_print # the band carries nothing treatment: no_print
alignment_tolerance_mm: 0.1 # per-pass placement accuracy, mm alignment_tolerance_mm: 0.1 # per-pass placement accuracy; band total = 2x this
# total_mm: 0.2 # default: 2 x alignment_tolerance_mm
print: print:
# Every artifact carries a full-page black `<rect id="background-fill">`. That background base_fill: owned # white ground, already split by the seam
# is itself a print element, so it cannot be used as the "no print" marker: the split is
# expressed by WHERE the white mesh layer is drawn and where the colour patches are.
#
# `owned` keeps the same white ground the `compute` texture carries, but clipped to the
# triangles this view owns. So white is split by the seam exactly like the pattern is,
# and neither file paints white over the other's territory.
base_fill: owned
# Post-generation printability inspection (`orisys-design inspect`). Advisory # Advisory only: reports a verdict, never blocks generation or changes exit codes.
# only: it reports a verdict but never blocks generation or changes exit codes.
# Millimetres are measured on the vector page declared above. # Millimetres are measured on the vector page declared above.
printability: printability:
printer_dpi: 600 # target UV printer resolution printer_dpi: 600 # target UV printer resolution
@@ -149,13 +104,6 @@ printability:
max_sliver_ratio: 4.0 # longest edge / minimum width max_sliver_ratio: 4.0 # longest edge / minimum width
min_patch_area_mm2: 1.0e-4 # area above which a polygon is a material feature min_patch_area_mm2: 1.0e-4 # area above which a polygon is a material feature
collections:
frames: FRAMES
export_compute: EXPORT_COMPUTE
assembly: ASSEMBLY
design_sim: DESIGN_SIM
exclude: EXCLUDE
notes: | notes: |
Canonical source is geometry.blend with named collections and objects. Canonical source is geometry.blend with named collections and objects.
Compute export uses sensing_surface + cam_sensor relative to origin_sensor. Compute export uses sensing_surface + cam_sensor relative to origin_sensor.
+16 -3
View File
@@ -488,7 +488,9 @@ def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int:
overrides = texture_overrides_from_args(args) overrides = texture_overrides_from_args(args)
try: try:
settings = texture_views.resolve_views_settings(paths, overrides) settings = texture_views.resolve_views_settings(paths, overrides)
pairs = texture_views.resolve_view_pairs(paths, settings) pairs = texture_views.resolve_view_pairs(
paths, settings, grayscale=bool(getattr(args, "grayscale", False))
)
output_dir = resolve_texture_output_dir(root, paths, args.output_dir) output_dir = resolve_texture_output_dir(root, paths, args.output_dir)
except TextureConfigError as exc: except TextureConfigError as exc:
for message in str(exc).splitlines(): for message in str(exc).splitlines():
@@ -496,7 +498,9 @@ def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int:
return 2 return 2
targets = texture_views.multiview_output_paths(output_dir, pairs) targets = texture_views.multiview_output_paths(output_dir, pairs)
job = texture_views.build_multiview_job(paths, settings, pairs, output_dir) job = texture_views.build_multiview_job(
paths, settings, pairs, output_dir, grayscale=bool(getattr(args, "grayscale", False))
)
job_path = texture_views.write_multiview_job(job, output_dir) job_path = texture_views.write_multiview_job(job, output_dir)
# The job path is a fact about an intermediate file, not a deliverable: verbose only. # The job path is a fact about an intermediate file, not a deliverable: verbose only.
console.detail(f"Wrote multi-view texture job: {job_path}") console.detail(f"Wrote multi-view texture job: {job_path}")
@@ -2096,7 +2100,16 @@ def main(argv: list[str] | None = None) -> None:
help="Edge sampling grid for partially covered patches", help="Edge sampling grid for partially covered patches",
) )
texture_views_p.add_argument( texture_views_p.add_argument(
"--seed", type=int, default=None, help="Base seed; pair i uses seed + i" "--seed", type=int, default=None, help="Base seed; every pair shares it deliberately"
)
texture_views_p.add_argument(
"--grayscale",
action="store_true",
help=(
"Also write a laser-engraving set per view (<stem>_gray.png/.svg/.pdf): pattern "
"dark on a white page, tone = Rec.709 luminance of the colour it replaces. "
"Additive - the colour PNG/SVG/PDF set is unchanged"
),
) )
manifest_p = sub.add_parser("manifest", help="Write release manifest with file hashes") manifest_p = sub.add_parser("manifest", help="Write release manifest with file hashes")
+23 -3
View File
@@ -111,13 +111,33 @@ class TextureOutputTarget:
"""Backward-compatible convenience for the PNG artifact path.""" """Backward-compatible convenience for the PNG artifact path."""
return Path(output_dir) / self.artifact_filename("png") return Path(output_dir) / self.artifact_filename("png")
def artifact_paths(self, output_dir: Path) -> dict[str, Path]: def gray_filename(self, format_name: str) -> str:
"""Return absolute artifact paths in stable PNG/SVG/PDF order.""" """Return the laser-engraving companion's name for one format.
A separate stem (``<stem>_gray.<ext>``) rather than a replacement, so the colour
production set stays byte-identical and a print run cannot accidentally consume
engraving artwork.
"""
return f"{Path(self.filename).stem}_gray.{format_name}"
def artifact_paths(self, output_dir: Path, grayscale: bool = False) -> dict[str, Path]:
"""Return absolute artifact paths in stable PNG/SVG/PDF order.
``grayscale`` appends the laser-engraving set (``gray_png``/``gray_svg``/``gray_pdf``)
after the print set. Off by default, so the single-view path and every existing caller
are unaffected.
"""
resolved_dir = Path(output_dir).resolve() resolved_dir = Path(output_dir).resolve()
return { paths = {
format_name: resolved_dir / self.artifact_filename(format_name) format_name: resolved_dir / self.artifact_filename(format_name)
for format_name in TEXTURE_ARTIFACT_FORMATS for format_name in TEXTURE_ARTIFACT_FORMATS
} }
if grayscale:
for format_name in TEXTURE_ARTIFACT_FORMATS:
paths[f"gray_{format_name}"] = (
resolved_dir / self.gray_filename(format_name)
)
return paths
def to_job_entry(self, output_dir: Path) -> dict[str, Any]: def to_job_entry(self, output_dir: Path) -> dict[str, Any]:
"""Return the v3 job entry Blender consumes (absolute paths, ordered).""" """Return the v3 job entry Blender consumes (absolute paths, ordered)."""
+13 -3
View File
@@ -166,6 +166,8 @@ class ViewPair:
target: TextureOutputTarget target: TextureOutputTarget
seed: int | None seed: int | None
page_mode: str = PAGE_DEFAULTS["mode"] page_mode: str = PAGE_DEFAULTS["mode"]
#: Also emit the laser-engraving PNG for this pair (`--grayscale`).
grayscale: bool = False
def to_job_entry(self, output_dir: Path) -> dict[str, Any]: def to_job_entry(self, output_dir: Path) -> dict[str, Any]:
return { return {
@@ -177,7 +179,9 @@ class ViewPair:
"page": self.page_mode, "page": self.page_mode,
"artifacts": { "artifacts": {
format_name: str(path) format_name: str(path)
for format_name, path in self.target.artifact_paths(output_dir).items() for format_name, path in self.target.artifact_paths(
output_dir, grayscale=self.grayscale
).items()
}, },
} }
@@ -478,6 +482,7 @@ def resolve_view_pairs(
paths: RevisionPaths, paths: RevisionPaths,
settings: TexturePatchSettings, settings: TexturePatchSettings,
block: Mapping[str, Any] | None = None, block: Mapping[str, Any] | None = None,
grayscale: bool = False,
) -> tuple[ViewPair, ...]: ) -> tuple[ViewPair, ...]:
"""Resolve the views block into concrete pairs, or raise ViewPairConfigError.""" """Resolve the views block into concrete pairs, or raise ViewPairConfigError."""
if block is None: if block is None:
@@ -520,6 +525,7 @@ def resolve_view_pairs(
), ),
seed=None if seed is None else int(seed), seed=None if seed is None else int(seed),
page_mode=str(raw.get("page") or default_page_mode), page_mode=str(raw.get("page") or default_page_mode),
grayscale=grayscale,
) )
) )
return tuple(pairs) return tuple(pairs)
@@ -623,6 +629,7 @@ def build_multiview_job(
pairs: Sequence[ViewPair], pairs: Sequence[ViewPair],
output_dir: Path, output_dir: Path,
block: Mapping[str, Any] | None = None, block: Mapping[str, Any] | None = None,
grayscale: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Build the JSON job ``random_patch_multiview.py`` consumes.""" """Build the JSON job ``random_patch_multiview.py`` consumes."""
if not pairs: if not pairs:
@@ -633,6 +640,9 @@ def build_multiview_job(
return { return {
"schema": MULTIVIEW_JOB_SCHEMA, "schema": MULTIVIEW_JOB_SCHEMA,
"mode": "patches-multiview", "mode": "patches-multiview",
# Also emit the laser-engraving PNG per view. Recorded in the job so the run's
# inputs are fully described by the job rather than by how it was invoked.
"grayscale": bool(grayscale),
"revision_dir": str(paths.root.resolve()), "revision_dir": str(paths.root.resolve()),
"source_blend": str(paths.source_blend.resolve()), "source_blend": str(paths.source_blend.resolve()),
"objects": {"mesh": settings.mesh}, "objects": {"mesh": settings.mesh},
@@ -661,10 +671,10 @@ def multiview_output_paths(
output_dir: Path, output_dir: Path,
pairs: Sequence[ViewPair], pairs: Sequence[ViewPair],
) -> list[tuple[ViewPair, dict[str, Path]]]: ) -> list[tuple[ViewPair, dict[str, Path]]]:
"""Pair each view with its ordered PNG/SVG/PDF artifact paths.""" """Pair each view with its ordered PNG/SVG/PDF (and optional laser-gray) artifact paths."""
resolved_dir = Path(output_dir).resolve() resolved_dir = Path(output_dir).resolve()
return [ return [
(pair, pair.target.artifact_paths(resolved_dir)) (pair, pair.target.artifact_paths(resolved_dir, grayscale=pair.grayscale))
for pair in pairs for pair in pairs
] ]