new grayscale output for testing laser, not working, will change to new design
@@ -681,9 +681,21 @@ def _clipped_shapes(shapes, width, height):
|
||||
|
||||
def write_svg_vector(
|
||||
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 = [
|
||||
'<?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'width="{texture_width}" height="{texture_height}" '
|
||||
f'fill="#000000" stroke="none"/>'
|
||||
f'fill="{background_fill}" stroke="none"/>'
|
||||
),
|
||||
" </g>",
|
||||
' <g id="mesh">',
|
||||
@@ -785,15 +797,26 @@ def _build_pdf(objects):
|
||||
|
||||
def write_pdf_vector(
|
||||
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_height_pt = float(height_mm) * 72.0 / 25.4
|
||||
scale_x = page_width_pt / float(texture_width)
|
||||
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(
|
||||
f"0 0 {_format_number(page_width_pt)} {_format_number(page_height_pt)} re f"
|
||||
)
|
||||
|
||||
@@ -46,9 +46,13 @@ from random_patch_dd02c import ( # noqa: E402
|
||||
_require_uv_layer,
|
||||
_uv_centre,
|
||||
build_patch_geometry,
|
||||
fill_polygon_value,
|
||||
generate_patch_texture,
|
||||
precompute_cell_colors,
|
||||
write_pdf_vector,
|
||||
write_svg_vector,
|
||||
)
|
||||
from patch_geometry import clip_rect # noqa: E402
|
||||
from texture_merge import ( # noqa: E402
|
||||
MergeConfigError,
|
||||
MergePolicy,
|
||||
@@ -183,8 +187,12 @@ def _resolve_pairs(job: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if not isinstance(artifacts, dict):
|
||||
raise ConfigError(f"{where}.artifacts: must be a mapping")
|
||||
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)
|
||||
if value is None and format_name.startswith("gray_"):
|
||||
continue
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ConfigError(
|
||||
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,
|
||||
"uv_map": uv_map,
|
||||
"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,
|
||||
}
|
||||
)
|
||||
@@ -537,6 +548,100 @@ def _triangle_seam_edges(
|
||||
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):
|
||||
"""Derive and verify one pair's page from the geometry its layout projects.
|
||||
|
||||
@@ -1018,6 +1123,39 @@ def main() -> int:
|
||||
)
|
||||
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():
|
||||
path = Path(output)
|
||||
if not path.is_file() or path.stat().st_size == 0:
|
||||
|
||||
@@ -184,7 +184,10 @@
|
||||
"artifacts": {
|
||||
"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",
|
||||
"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": {
|
||||
"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",
|
||||
"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",
|
||||
"mode": "patches-multiview",
|
||||
"grayscale": true,
|
||||
"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",
|
||||
"objects": {
|
||||
@@ -70,7 +71,10 @@
|
||||
"artifacts": {
|
||||
"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",
|
||||
"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": {
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
After Width: | Height: | Size: 781 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 805 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
@@ -184,7 +184,10 @@
|
||||
"artifacts": {
|
||||
"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",
|
||||
"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": {
|
||||
"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",
|
||||
"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",
|
||||
"mode": "patches-multiview",
|
||||
"grayscale": true,
|
||||
"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",
|
||||
"objects": {
|
||||
@@ -70,7 +71,10 @@
|
||||
"artifacts": {
|
||||
"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",
|
||||
"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": {
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
After Width: | Height: | Size: 469 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 491 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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`.
|
||||
@@ -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的资源树在右上方,不同集合(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 需要更细的分区算法
|
||||
```
|
||||
|
||||
最后,不要删除“已知缺陷”“撤回结论”和失效条件。这些内容不是历史包袱,而是防止同类问题
|
||||
再次以合理外观进入生产输出的工程约束。
|
||||
|
After Width: | Height: | Size: 197 KiB |
@@ -8,12 +8,6 @@ meters_per_source_unit: 0.001
|
||||
|
||||
source_blend: source/geometry.blend
|
||||
|
||||
# Expected sensing surface topology for r01 (from shipped 30fps-0703 runtime).
|
||||
# First exports from geometry.blend may differ until topology is locked.
|
||||
expected:
|
||||
sensing_vertices: 2595
|
||||
sensing_triangles: 5005
|
||||
|
||||
frames:
|
||||
sensor: origin_sensor
|
||||
|
||||
@@ -22,115 +16,79 @@ objects:
|
||||
cam_sensor: cam_sensor
|
||||
origin_sensor: origin_sensor
|
||||
|
||||
# Headless colored-patch texture generation (`orisys-design texture`).
|
||||
# UV unwrapping is still done in the Blender GUI: the headless run consumes the
|
||||
# UV maps already stored in source_blend and never creates or unwraps one.
|
||||
# One PNG/SVG/PDF set is written per UV map below. All maps and formats share
|
||||
# the settings and patch colours, so they differ only by UV layout.
|
||||
# These are the revision defaults; CLI flags override every value.
|
||||
# Read on every export; changing `assembly` silently changes what ships.
|
||||
collections:
|
||||
frames: FRAMES
|
||||
export_compute: EXPORT_COMPUTE
|
||||
assembly: ASSEMBLY
|
||||
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:
|
||||
mesh: sensing_surface
|
||||
camera: cam_sensor
|
||||
uv_outputs:
|
||||
produce: patch_texture_produce.png # hardware production layout
|
||||
compute: patch_texture_compute.png # compute/runtime layout
|
||||
width: 3700 # 1850*2
|
||||
height: 3280 # 1640*2
|
||||
# Camera frame is square (1000x1000), so equal counts make camera-space
|
||||
# elements square. The original 80x45 grid was copied from a 16:9 camera.
|
||||
nx: 80
|
||||
uv_outputs: # ORDER IS OBSERVABLE: becomes the job's outputs[] array
|
||||
produce: patch_texture_produce.png
|
||||
compute: patch_texture_compute.png
|
||||
width: 3700 # raster px; keep == px_per_mm * vector below
|
||||
height: 3280
|
||||
nx: 80 # square camera frame, so nx == ny
|
||||
ny: 80
|
||||
color_r: 0.05
|
||||
edge_sample_grid: 5
|
||||
seed: 7 # fixed so PNG/SVG/PDF share one reproducible colour set
|
||||
vector:
|
||||
width_mm: 18.5 # editable SVG/PDF production page width
|
||||
height_mm: 16.4 # editable SVG/PDF production page height
|
||||
seed: 7 # pinned: one reproducible colour set across PNG/SVG/PDF
|
||||
vector: # physical page; the `page: fixed` fallback for texture_patch_views
|
||||
width_mm: 18.5
|
||||
height_mm: 16.4
|
||||
cross:
|
||||
enabled: true
|
||||
length_px: 40
|
||||
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`).
|
||||
#
|
||||
# `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.
|
||||
# Projected views with per-triangle ownership (`orisys-design texture-views`).
|
||||
# See docs/design-parameters.md part 2.
|
||||
texture_patch_views:
|
||||
enabled: true
|
||||
pattern_camera: cam_sensor # the pattern every layout carries, same source as `compute`
|
||||
# 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".
|
||||
pattern_camera: cam_sensor # the ink source; a pair's `camera` only decides ownership
|
||||
page:
|
||||
mode: auto
|
||||
mode: auto # auto = derived per view (projections only); fixed = use vector above
|
||||
px_per_mm: 200
|
||||
pairs:
|
||||
- id: uv_1
|
||||
camera: cam_uv_1 # REGION camera
|
||||
camera: cam_uv_1 # REGION camera, not the pattern source
|
||||
uv_map: uv_1
|
||||
- id: uv_2
|
||||
camera: cam_uv_2
|
||||
uv_map: uv_2
|
||||
merge:
|
||||
mode: authored # honour the 45-edge seam marked on the mesh
|
||||
# RE-MEASURED 2026-09-29 after the region cameras moved (cam_uv_1 az 318.9 -> 308.3,
|
||||
# elevation -43.3 -> -24.5, distance 37.6 -> 32.6 mm). Sweep at min_triangle_coverage 0.60:
|
||||
# 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
|
||||
mode: authored # honour the seam marked on the mesh
|
||||
incidence_limit_deg: 70.0 # DO NOT DELETE: built-in default 62.0 FAILS here (4.8% hole)
|
||||
max_hole_fraction: 0.02 # enforced: the run fails above this rather than shipping a gap
|
||||
min_footprint_mm2: 2.0e-3
|
||||
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
|
||||
seam:
|
||||
boundary: use_seam
|
||||
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:
|
||||
enabled: false # false = hard butt at the seam, no band at all
|
||||
apply: post_process # post_process = straight edge | geometry_trim = sawtooth
|
||||
treatment: no_print # the band carries nothing
|
||||
alignment_tolerance_mm: 0.1 # per-pass placement accuracy, mm
|
||||
enabled: false # false = hard butt at the seam, no band at all
|
||||
apply: post_process # post_process = straight edge | geometry_trim = sawtooth
|
||||
treatment: no_print
|
||||
alignment_tolerance_mm: 0.1
|
||||
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
|
||||
# only: it reports a verdict but never blocks generation or changes exit codes.
|
||||
# Advisory only: reports a verdict, never blocks generation or changes exit codes.
|
||||
# Millimetres are measured on the vector page declared above.
|
||||
printability:
|
||||
printer_dpi: 600 # target UV printer resolution
|
||||
@@ -140,21 +98,14 @@ printability:
|
||||
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
|
||||
|
||||
# Optical stack above the markers, ordered from the camera inward.
|
||||
# The markers sit on the sensing surface underneath these layers, so their image
|
||||
# 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.
|
||||
# Optical stack above the markers, ordered camera-inward. ior values are LITERATURE
|
||||
# ASSUMPTIONS, not measurements - that is what ior_source records.
|
||||
optics:
|
||||
marker_surface: sensing_surface
|
||||
layers:
|
||||
# Solids, not surface pairs: `shell` and `rubber-1` are each one closed mesh,
|
||||
# so the ray enters and leaves the same object.
|
||||
# Stack: camera > shell > rubber-1 > sensing_surface > rubber-2.
|
||||
# rubber-2 is BEHIND the markers, so it does not refract camera light and is
|
||||
# deliberately not a layer.
|
||||
# Solids, not surface pairs: `shell` and `rubber-1` are each one closed mesh, so the
|
||||
# ray enters and leaves the same object. rubber-2 is BEHIND the markers, so it does
|
||||
# not refract camera light and is deliberately not a layer.
|
||||
- name: plastic_shell
|
||||
solid: shell
|
||||
ior: 1.59
|
||||
@@ -165,48 +116,33 @@ optics:
|
||||
ior_source: assumed_silicone_literature
|
||||
|
||||
# Probe sensitivity bench (`orisys-design deform|render|track|sensitivity|optimise`).
|
||||
# Deforms the vertices of `group` through known x/y/z displacements expressed in a
|
||||
# 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.
|
||||
# Contract: docs/probe-pipeline.md. The .blend is never written by the pipeline.
|
||||
probe:
|
||||
mesh: sensing_surface
|
||||
camera: cam_sensor
|
||||
group: probe
|
||||
uv_map: compute
|
||||
texture: patch_texture_compute.png
|
||||
# Proportional falloff radius for the outer rubber. Note this mesh has a median
|
||||
# edge of 0.63 mm, so 0.5 mm reaches only the probe's immediate neighbours; the
|
||||
# Median mesh edge here is 0.63 mm, so 0.5 mm reaches only immediate neighbours; the
|
||||
# deform report prints how many vertices actually move rather than hiding it.
|
||||
radius_mm: 0.5
|
||||
falloff: smooth
|
||||
step_mm: 0.01
|
||||
max_mm: 0.10
|
||||
axes: [x, y, z]
|
||||
# Frames are rendered at the revision's real camera resolution. Keep this in step with
|
||||
# the camera in source/geometry.blend: the pipeline overrides the scene resolution from
|
||||
# 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.
|
||||
# The pipeline overrides the scene resolution from here, so a blend edited to a
|
||||
# different size is silently ignored. Keep in step with source/geometry.blend.
|
||||
resolution: [250, 250]
|
||||
process_resolution: [250, 250]
|
||||
# Mirrors the SDK sensor tracker keys (orisys-sdk config/finger.json), EXCEPT
|
||||
# dis_mode. The estimator window is 8 * 2**dis_mode input px (16 at dis_mode=1, 8 at 0)
|
||||
# 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%.
|
||||
# Mirrors the SDK tracker keys EXCEPT dis_mode: the 8*2**dis_mode window against a
|
||||
# ~6.6 px moving region makes the shipped dis_mode=1 collapse the estimate ~3x.
|
||||
# See docs/probe-findings.md section 2.
|
||||
# If the deployed tracker is fixed at dis_mode=1, run the bench at 500x500 instead.
|
||||
tracker:
|
||||
algorithm: dis
|
||||
dis_preset: medium
|
||||
dis_mode: 0
|
||||
# Standard (not AgX) and denoise off: a filmic view transform silently distorts
|
||||
# the marker values the tracker is supposed to measure.
|
||||
# Standard (not AgX) and denoise off: a filmic view transform silently distorts the
|
||||
# marker values the tracker is supposed to measure.
|
||||
render:
|
||||
engine: CYCLES
|
||||
samples: 16
|
||||
@@ -214,39 +150,20 @@ probe:
|
||||
view_transform: Standard
|
||||
interpolate: Closest
|
||||
seed: 0
|
||||
# Noise calibration and the injected noise model.
|
||||
#
|
||||
# The estimator's `floor_px` is measured as reference vs a re-render at seed+1, which
|
||||
# is Cycles antialiasing jitter, NOT sensor noise: on this data it differs by ~5 grey
|
||||
# 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.
|
||||
# `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 injected model plus the idle-footage calibration below.
|
||||
# See docs/probe-findings.md section 2.
|
||||
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
|
||||
roi_fraction: 0.18
|
||||
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]
|
||||
draws: 1
|
||||
seed: 0
|
||||
models: [render_seed, injected]
|
||||
scales: [render]
|
||||
|
||||
collections:
|
||||
frames: FRAMES
|
||||
export_compute: EXPORT_COMPUTE
|
||||
assembly: ASSEMBLY
|
||||
design_sim: DESIGN_SIM
|
||||
exclude: EXCLUDE
|
||||
|
||||
notes: |
|
||||
Canonical source is geometry.blend with named collections and objects.
|
||||
Compute export uses sensing_surface + cam_sensor relative to origin_sensor.
|
||||
|
||||
@@ -8,12 +8,6 @@ meters_per_source_unit: 0.001
|
||||
|
||||
source_blend: source/geometry.blend
|
||||
|
||||
# Expected sensing surface topology for r01 (from shipped 30fps-0703 runtime).
|
||||
# First exports from geometry.blend may differ until topology is locked.
|
||||
expected:
|
||||
sensing_vertices: 1032
|
||||
sensing_triangles: 1974
|
||||
|
||||
frames:
|
||||
sensor: origin_sensor
|
||||
|
||||
@@ -22,124 +16,85 @@ objects:
|
||||
cam_sensor: cam_sensor
|
||||
origin_sensor: origin_sensor
|
||||
|
||||
# Headless colored-patch texture generation (`orisys-design texture`).
|
||||
# UV unwrapping is still done in the Blender GUI: the headless run consumes the
|
||||
# UV maps already stored in source_blend and never creates or unwraps one.
|
||||
# One PNG/SVG/PDF set is written per UV map below. All maps and formats share
|
||||
# the settings and patch colours, so they differ only by UV layout.
|
||||
# These are the revision defaults; CLI flags override every value.
|
||||
# Read on every export; changing `assembly` silently changes what ships.
|
||||
collections:
|
||||
frames: FRAMES
|
||||
export_compute: EXPORT_COMPUTE
|
||||
assembly: ASSEMBLY
|
||||
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:
|
||||
mesh: sensing_surface
|
||||
camera: cam_sensor
|
||||
uv_outputs:
|
||||
produce: patch_texture_produce.png # hardware production layout
|
||||
compute: patch_texture_compute.png # compute/runtime layout
|
||||
width: 3280 # 1640*2
|
||||
height: 3200 # 1600*2
|
||||
nx: 80
|
||||
uv_outputs: # ORDER IS OBSERVABLE: becomes the job's outputs[] array
|
||||
produce: patch_texture_produce.png
|
||||
compute: patch_texture_compute.png
|
||||
width: 3280 # raster px; keep == px_per_mm * vector below
|
||||
height: 3200
|
||||
nx: 80 # 16:9 camera frame, left as-is from the original grid
|
||||
ny: 45
|
||||
color_r: 0.05
|
||||
edge_sample_grid: 5
|
||||
seed: 7 # fixed so PNG/SVG/PDF share one reproducible colour set
|
||||
vector:
|
||||
width_mm: 16.4 # editable SVG/PDF production page width
|
||||
height_mm: 16.0 # editable SVG/PDF production page height
|
||||
seed: 7 # pinned: one reproducible colour set across PNG/SVG/PDF
|
||||
vector: # physical page; the `page: fixed` fallback for texture_patch_views
|
||||
width_mm: 16.4
|
||||
height_mm: 16.0
|
||||
cross:
|
||||
enabled: true
|
||||
length_px: 40
|
||||
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`).
|
||||
#
|
||||
# The PATTERN is grounded on the sensing camera, not on the production views. `uv_1` and
|
||||
# `uv_2` are two different *parameterisations* of the one surface pattern that
|
||||
# `texture_compute` carries, so the printed marker stays a single pattern across the whole
|
||||
# surface. `cam_uv_1` / `cam_uv_2` authored those layouts in Blender (Project from View
|
||||
# (Bounds)) and now serve as each pair's REGION camera: they decide which surface that pair
|
||||
# may own. Neither is a pattern source.
|
||||
#
|
||||
# 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.
|
||||
# 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
|
||||
# parameterisations of the one surface pattern `compute` carries, so the printed marker stays
|
||||
# a single pattern. `cam_uv_1`/`cam_uv_2` authored those layouts (Project from View (Bounds))
|
||||
# and now serve as each pair's REGION camera, deciding what that pair may own.
|
||||
# Ownership closes the overlap: both layouts are full-surface, so without a partition printing
|
||||
# both would double-ink essentially the whole part.
|
||||
# See docs/design-parameters.md part 2.
|
||||
texture_patch_views:
|
||||
enabled: true
|
||||
pattern_camera: cam_sensor # the pattern every layout carries, same source as `compute`
|
||||
# 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.
|
||||
pattern_camera: cam_sensor # the ink source; a pair's `camera` only decides ownership
|
||||
page:
|
||||
mode: auto
|
||||
mode: auto # auto = derived per view (projections only); fixed = use vector above
|
||||
px_per_mm: 200
|
||||
pairs:
|
||||
- id: uv_1 # layout authored from cam_uv_1
|
||||
camera: cam_uv_1 # REGION camera: what this pair may own
|
||||
- id: uv_1
|
||||
camera: cam_uv_1 # REGION camera, not the pattern source
|
||||
uv_map: uv_1
|
||||
- id: uv_2
|
||||
camera: cam_uv_2
|
||||
uv_map: uv_2
|
||||
merge:
|
||||
# `authored` honours the seam marked on the mesh: 32 edges on y = 0, which cuts the
|
||||
# surface into 2 components of 177.882 / 177.885 mm^2 (50.0 / 50.0). Because the two
|
||||
# cameras are mirrored about XZ that plane is also where incidence_uv1 == incidence_uv2,
|
||||
# so the hand-drawn seam coincides with the metric-optimal boundary: measured coverage
|
||||
# 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
|
||||
mode: authored # honour the seam marked on the mesh
|
||||
incidence_limit_deg: 62.0 # calibrated to THIS revision's cameras; re-measure if they move
|
||||
max_hole_fraction: 0.02 # enforced: the run fails above this rather than shipping a gap
|
||||
min_footprint_mm2: 2.0e-3
|
||||
min_triangle_coverage: 0.60
|
||||
max_hole_fraction: 0.02
|
||||
component_policy: best_coverage # not `pinned`: both views qualify, so let the metric rank
|
||||
seam:
|
||||
boundary: use_seam # read mesh.edges[].use_seam as a hard graph cut
|
||||
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.
|
||||
boundary: use_seam # read mesh.edges[].use_seam as a hard graph cut
|
||||
pins: [] # {component, view, reason} to override a component's owner
|
||||
gap:
|
||||
enabled: true # false = hard butt at the seam, no band at all
|
||||
apply: post_process # post_process = straight edge | geometry_trim = sawtooth
|
||||
treatment: no_print # the band carries nothing
|
||||
alignment_tolerance_mm: 0.1 # per-pass placement accuracy, mm
|
||||
# total_mm: 0.2 # default: 2 x alignment_tolerance_mm
|
||||
enabled: true # false = hard butt at the seam, no band at all
|
||||
apply: post_process # post_process = straight edge | geometry_trim = sawtooth
|
||||
treatment: no_print
|
||||
alignment_tolerance_mm: 0.1 # per-pass placement accuracy; band total = 2x this
|
||||
print:
|
||||
# Every artifact carries a full-page black `<rect id="background-fill">`. That background
|
||||
# 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
|
||||
base_fill: owned # white ground, already split by the seam
|
||||
|
||||
# Post-generation printability inspection (`orisys-design inspect`). Advisory
|
||||
# only: it reports a verdict but never blocks generation or changes exit codes.
|
||||
# Advisory only: reports a verdict, never blocks generation or changes exit codes.
|
||||
# Millimetres are measured on the vector page declared above.
|
||||
printability:
|
||||
printer_dpi: 600 # target UV printer resolution
|
||||
@@ -149,13 +104,6 @@ printability:
|
||||
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
|
||||
|
||||
collections:
|
||||
frames: FRAMES
|
||||
export_compute: EXPORT_COMPUTE
|
||||
assembly: ASSEMBLY
|
||||
design_sim: DESIGN_SIM
|
||||
exclude: EXCLUDE
|
||||
|
||||
notes: |
|
||||
Canonical source is geometry.blend with named collections and objects.
|
||||
Compute export uses sensing_surface + cam_sensor relative to origin_sensor.
|
||||
|
||||
@@ -488,7 +488,9 @@ def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int:
|
||||
overrides = texture_overrides_from_args(args)
|
||||
try:
|
||||
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)
|
||||
except TextureConfigError as exc:
|
||||
for message in str(exc).splitlines():
|
||||
@@ -496,7 +498,9 @@ def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int:
|
||||
return 2
|
||||
|
||||
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)
|
||||
# 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}")
|
||||
@@ -2096,7 +2100,16 @@ def main(argv: list[str] | None = None) -> None:
|
||||
help="Edge sampling grid for partially covered patches",
|
||||
)
|
||||
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")
|
||||
|
||||
@@ -111,13 +111,33 @@ class TextureOutputTarget:
|
||||
"""Backward-compatible convenience for the PNG artifact path."""
|
||||
return Path(output_dir) / self.artifact_filename("png")
|
||||
|
||||
def artifact_paths(self, output_dir: Path) -> dict[str, Path]:
|
||||
"""Return absolute artifact paths in stable PNG/SVG/PDF order."""
|
||||
def gray_filename(self, format_name: str) -> str:
|
||||
"""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()
|
||||
return {
|
||||
paths = {
|
||||
format_name: resolved_dir / self.artifact_filename(format_name)
|
||||
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]:
|
||||
"""Return the v3 job entry Blender consumes (absolute paths, ordered)."""
|
||||
|
||||
@@ -166,6 +166,8 @@ class ViewPair:
|
||||
target: TextureOutputTarget
|
||||
seed: int | None
|
||||
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]:
|
||||
return {
|
||||
@@ -177,7 +179,9 @@ class ViewPair:
|
||||
"page": self.page_mode,
|
||||
"artifacts": {
|
||||
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,
|
||||
settings: TexturePatchSettings,
|
||||
block: Mapping[str, Any] | None = None,
|
||||
grayscale: bool = False,
|
||||
) -> tuple[ViewPair, ...]:
|
||||
"""Resolve the views block into concrete pairs, or raise ViewPairConfigError."""
|
||||
if block is None:
|
||||
@@ -520,6 +525,7 @@ def resolve_view_pairs(
|
||||
),
|
||||
seed=None if seed is None else int(seed),
|
||||
page_mode=str(raw.get("page") or default_page_mode),
|
||||
grayscale=grayscale,
|
||||
)
|
||||
)
|
||||
return tuple(pairs)
|
||||
@@ -623,6 +629,7 @@ def build_multiview_job(
|
||||
pairs: Sequence[ViewPair],
|
||||
output_dir: Path,
|
||||
block: Mapping[str, Any] | None = None,
|
||||
grayscale: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the JSON job ``random_patch_multiview.py`` consumes."""
|
||||
if not pairs:
|
||||
@@ -633,6 +640,9 @@ def build_multiview_job(
|
||||
return {
|
||||
"schema": MULTIVIEW_JOB_SCHEMA,
|
||||
"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()),
|
||||
"source_blend": str(paths.source_blend.resolve()),
|
||||
"objects": {"mesh": settings.mesh},
|
||||
@@ -661,10 +671,10 @@ def multiview_output_paths(
|
||||
output_dir: Path,
|
||||
pairs: Sequence[ViewPair],
|
||||
) -> 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()
|
||||
return [
|
||||
(pair, pair.target.artifact_paths(resolved_dir))
|
||||
(pair, pair.target.artifact_paths(resolved_dir, grayscale=pair.grayscale))
|
||||
for pair in pairs
|
||||
]
|
||||
|
||||
|
||||