增加笔画生产尺寸与数量报告

按最终矢量页面汇总每个相机单元的笔画包络,并标出低于 0.2 mm 的数量。
已核对 SVG、清单哈希和 Blender 导出,175 项相关测试通过。
This commit is contained in:
ykcheungab
2026-10-06 14:17:13 +08:00
parent afe1ce39cb
commit ccf0f5f940
11 changed files with 34026 additions and 19 deletions
+8 -2
View File
@@ -246,8 +246,14 @@ The stroke output is independent of the color artwork pixels. The revision's
mode; `--output-dir` selects a fresh destination. `--views` uses the existing
`texture-views` job's triangle ownership, seam margin and
per-view page sizes. The command writes PNG/SVG/PDF, resolved settings and a hash manifest.
The projected-cell ratios do not guarantee a 0.2 mm physical stroke width;
measure the production SVG before manufacturing.
It also writes `stroke_texture_production_report.json`: per-view stroke and
vector-fragment counts, minimum/median/maximum printed width and length in mm,
the number of strokes below the 0.2 mm width target, and an estimated black
area ratio. One visible camera cell counts as one stroke even when UV triangles
split its SVG outline. Width and length are the short and long sides of the
minimum-area rectangle around the emitted fragments on the print page. This
envelope does not certify the narrowest local feature or guarantee 0.2 mm
manufacturing width; inspect the production SVG before manufacturing.
## Inspect printability (advisory)
+27 -8
View File
@@ -24,12 +24,14 @@ from random_patch_dd02c import (
_prepare_mesh_object,
_require_object,
_require_uv_layer,
clip_polygon_to_page,
fill_background,
fill_polygon,
save_png_image,
write_pdf_vector,
write_svg_vector,
)
from stroke_production_report import summarize_view, write_report
MODE = "camera_grid_capsules"
DEFAULT_PARAMS = {
@@ -64,8 +66,8 @@ def _pixel_polygon(uv_polygon, width, height):
def render_stroke_polygons(uv_name, mesh, strokes, width, height, vector,
base):
"""Write the shared raster and vector artwork from UV polygons."""
base, view_id=None):
"""Write artwork and measure the same page-clipped vector polygons."""
image_name = f"CameraGridStroke_{uv_name}"
if image_name in bpy.data.images:
bpy.data.images.remove(bpy.data.images[image_name])
@@ -79,11 +81,13 @@ def render_stroke_polygons(uv_name, mesh, strokes, width, height, vector,
fill_polygon(None, pixels, width, height, poly, (1, 1, 1, 1))
shapes = []
for polygon in strokes:
labelled_shapes = []
for i, j, polygon in strokes:
pixel_poly = _pixel_polygon(polygon, width, height)
if len(pixel_poly) < 3:
continue
shapes.append((pixel_poly, (0, 0, 0, 1)))
labelled_shapes.append((i, j, pixel_poly))
fill_polygon(None, pixels, width, height, pixel_poly, (0, 0, 0, 1))
image.pixels[:] = pixels
@@ -99,6 +103,16 @@ def render_stroke_polygons(uv_name, mesh, strokes, width, height, vector,
page_mesh, page_shapes, [], (0, 0, 0, 1),
artwork_label="stroke")
print(f"{uv_name}: {len(shapes)} projected stroke fragments")
report_shapes = [
(i, j, [(round(x, 4), round(y, 4)) for x, y in clipped])
for i, j, polygon in labelled_shapes
if (clipped := clip_polygon_to_page(polygon, width, height))
]
return summarize_view(
view_id or uv_name, uv_name, report_shapes, page_mesh,
(width, height), (vector["width_mm"], vector["height_mm"]),
base.with_suffix(".svg"),
)
def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
@@ -113,13 +127,14 @@ def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
outline_cache[key] = capsule_in_cell(
i, j, nx, ny, seed, width_ratio, length_ratio,
angle=None if angle_map is None else float(angle_map[j, i]))
strokes.append(projected_stroke_uv(fragment, loop_uvs, outline_cache[key]))
strokes.append((i, j, projected_stroke_uv(fragment, loop_uvs, outline_cache[key])))
label = artifact_prefix
if angle_map is not None and artifact_prefix == "camera_grid_stroke":
label += "_optimized"
base = output_dir / f"{label}_{uv_name}"
render_stroke_polygons(uv_name, geometry["mesh"], strokes,
settings["width"], settings["height"], settings["vector"], base)
return render_stroke_polygons(uv_name, geometry["mesh"], strokes,
settings["width"], settings["height"], settings["vector"],
base)
def main():
@@ -164,10 +179,14 @@ def main():
names, settings["nx"], settings["ny"], settings["edge_sample_grid"],
capture_camera_fragments=True,
)
views = []
for name in names:
_render(name, geometry[name], settings, args.output_dir,
views.append(_render(name, geometry[name], settings, args.output_dir,
params["width_ratio"], params["length_ratio"], angle_map,
args.artifact_prefix)
args.artifact_prefix))
report_path = args.output_dir / f"{args.artifact_prefix}_production_report.json"
write_report(report_path, args.job.resolve(), params, views)
print(f"Stroke production report: {report_path}")
if __name__ == "__main__":
+13 -3
View File
@@ -57,6 +57,8 @@ from camera_grid_stroke import DEFAULT_PARAMS as STROKE_DEFAULT_PARAMS # noqa:
from camera_grid_stroke import render_stroke_polygons # noqa: E402
from camera_grid_stroke_geometry import capsule_in_cell, projected_stroke_uv # noqa: E402
from camera_grid_stroke_optimizer import optimize_angles # noqa: E402
from stroke_production_report import REPORT_FILENAME as STROKE_REPORT_FILENAME # noqa: E402
from stroke_production_report import write_report as write_stroke_report # noqa: E402
from texture_merge import ( # noqa: E402
MergeConfigError,
MergePolicy,
@@ -966,6 +968,7 @@ def main() -> int:
# ---- 5. Emit: mask each layout to its owned triangles ---------------------------
emitted_stats: dict[str, dict[str, Any]] = {}
stroke_views = []
for pair, region_cam, _pattern_cam, uv_layer in resolved:
pair_id = pair["id"]
uv_map = pair["uv_map"]
@@ -1191,12 +1194,13 @@ def main() -> int:
geometry=filtered,
)
else:
render_stroke_polygons(
uv_map, base_layer, [poly for _, _, poly in emitted_patches],
stroke_views.append(render_stroke_polygons(
uv_map, base_layer, emitted_patches,
texture_width, texture_height,
{"width_mm": page_width_mm, "height_mm": page_height_mm},
Path(pair["artifacts"]["png"]).with_suffix(""),
)
view_id=pair_id,
))
except Exception as exc: # noqa: BLE001
print(
f"ERROR: texture generation failed for view {pair_id!r}: {exc}",
@@ -1407,6 +1411,12 @@ def main() -> int:
_write_ground_truth(truth_path, truth)
detail(f" ground truth -> {truth_path}")
if stroke_params is not None:
stroke_report_path = truth_path.parent / STROKE_REPORT_FILENAME
write_stroke_report(stroke_report_path, args.job.resolve(), stroke_params,
stroke_views)
report(f"stroke production report -> {stroke_report_path}")
# The artifact inventory is owned by the HOST, which is the only side that can verify the
# files exist and are non-empty. Repeating it here was 11 of the run's lines printed twice.
detail(f"Completed in {time.time() - start_time:.2f} seconds.")
+171
View File
@@ -0,0 +1,171 @@
"""Measure final camera-grid stroke polygons on their physical print pages.
One camera cell is one intended mark. Projection, UV triangles, view ownership and
seam clipping can split it into many vector polygons; this module groups those
pieces before measuring a mark's planar envelope.
"""
from __future__ import annotations
import hashlib
import json
import math
from collections import defaultdict
from pathlib import Path
REPORT_SCHEMA = "orisys-sensor-design/stroke-production-report-v1"
REPORT_FILENAME = "stroke_texture_production_report.json"
WIDTH_TARGET_MM = 0.2
def _area(points):
return abs(sum(x * points[(k + 1) % len(points)][1]
- points[(k + 1) % len(points)][0] * y
for k, (x, y) in enumerate(points))) / 2
def _hull(points):
unique = sorted(set(points))
if len(unique) < 3:
return unique
def cross(a, b, c):
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
lower = []
for point in unique:
while len(lower) >= 2 and cross(lower[-2], lower[-1], point) <= 0:
lower.pop()
lower.append(point)
upper = []
for point in reversed(unique):
while len(upper) >= 2 and cross(upper[-2], upper[-1], point) <= 0:
upper.pop()
upper.append(point)
return lower[:-1] + upper[:-1]
def _envelope(points):
"""Short and long sides of the minimum-area rotated rectangle in mm."""
hull = _hull(points)
if len(hull) < 3:
return (0.0, math.dist(*hull) if len(hull) == 2 else 0.0)
best = None
for a, b in zip(hull, hull[1:] + hull[:1]):
dx, dy = b[0] - a[0], b[1] - a[1]
edge = math.hypot(dx, dy)
if edge == 0:
continue
ux, uy = dx / edge, dy / edge
along = [x * ux + y * uy for x, y in hull]
across = [-x * uy + y * ux for x, y in hull]
side_a = max(along) - min(along)
side_b = max(across) - min(across)
candidate = (side_a * side_b, min(side_a, side_b), max(side_a, side_b))
if best is None or candidate < best:
best = candidate
return best[1:]
def _stats(values):
if not values:
return {"min": None, "p5": None, "median": None, "p95": None, "max": None}
ordered = sorted(values)
def percentile(fraction):
position = (len(ordered) - 1) * fraction
lo = math.floor(position)
hi = math.ceil(position)
return ordered[lo] + (ordered[hi] - ordered[lo]) * (position - lo)
return {"min": round(ordered[0], 6), "p5": round(percentile(0.05), 6),
"median": round(percentile(0.5), 6), "p95": round(percentile(0.95), 6),
"max": round(ordered[-1], 6)}
def summarize_view(view_id, uv_map, polygons, mesh_polygons, raster, page_mm, svg_path):
"""Measure page-clipped pixel polygons labelled with their source camera cell."""
width_px, height_px = raster
width_mm, height_mm = page_mm
sx, sy = width_mm / width_px, height_mm / height_px
cells = defaultdict(lambda: {"points": [], "fragments": 0})
ink_area = 0.0
fragment_count = 0
for i, j, polygon in polygons:
if len(polygon) < 3:
continue
scaled = [(x * sx, y * sy) for x, y in polygon]
area = _area(scaled)
if area <= 0:
continue
item = cells[(i, j)]
item["points"].extend(scaled)
item["fragments"] += 1
fragment_count += 1
ink_area += area
marks = []
for (i, j), item in cells.items():
width, length = _envelope(item["points"])
marks.append({"cell": [i, j], "width_mm": width, "length_mm": length,
"fragments": item["fragments"]})
marks.sort(key=lambda item: (item["width_mm"], item["cell"]))
base_area = sum(_area([(x * sx, y * sy) for x, y in polygon])
for polygon in mesh_polygons if len(polygon) >= 3)
return {
"id": view_id,
"uv_map": uv_map,
"svg": {"path": str(svg_path),
"sha256": hashlib.sha256(Path(svg_path).read_bytes()).hexdigest()},
"page_mm": [width_mm, height_mm],
"raster_px": [width_px, height_px],
"stroke_count": len(marks),
"vector_fragment_count": fragment_count,
"width_mm": _stats([mark["width_mm"] for mark in marks]),
"length_mm": _stats([mark["length_mm"] for mark in marks]),
"width_below_target_count": sum(
mark["width_mm"] < WIDTH_TARGET_MM - 1e-9 for mark in marks
),
"smallest_width_strokes": [
{**mark, "width_mm": round(mark["width_mm"], 6),
"length_mm": round(mark["length_mm"], 6)} for mark in marks[:10]
],
"camera_cells": [mark["cell"] for mark in sorted(marks, key=lambda m: m["cell"])],
"summed_black_polygon_area_mm2": round(ink_area, 6),
"summed_white_base_area_mm2": round(base_area, 6),
"black_to_base_area_ratio_estimate": (
round(ink_area / base_area, 6) if base_area > 0 else None
),
}
def write_report(path, source_job, parameters, views):
"""Write one report with distinct per-view and cross-view stroke counts."""
counts = defaultdict(int)
for view in views:
for cell in view["camera_cells"]:
counts[tuple(cell)] += 1
job = json.loads(Path(source_job).read_text(encoding="utf-8"))
nx, ny = job["settings"]["nx"], job["settings"]["ny"]
report = {
"schema": REPORT_SCHEMA,
"units": {"width": "mm", "length": "mm", "area": "mm2"},
"measurement_method": (
"Each visible camera cell is one stroke per view. SVG-bound polygons are clipped "
"to the print page, grouped by camera cell, then measured as the short and long "
"sides of their minimum-area rotated convex-hull rectangle. These are planar "
"envelope dimensions, not a guarantee of minimum local feature width. Summed "
"polygon areas may double-count overlaps and are only an occupancy estimate."
),
"width_target_mm": WIDTH_TARGET_MM,
"source_job": {"path": str(source_job),
"sha256": hashlib.sha256(Path(source_job).read_bytes()).hexdigest()},
"parameters": dict(parameters),
"camera_grid": [nx, ny],
"grid_cell_count": nx * ny,
"total_view_strokes": sum(view["stroke_count"] for view in views),
"unique_camera_cell_count": len(counts),
"cells_in_multiple_views": sum(n > 1 for n in counts.values()),
"views": views,
}
Path(path).write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
return report
@@ -72,6 +72,10 @@
"_print_views.json": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\_print_views.json",
"sha256": "abd5938d35fc8863b91753b2915fb5b39d261f0920286ef3eca2cc4e6a6c89bf"
},
"stroke_texture_production_report.json": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\stroke_texture_production_report.json",
"sha256": "d4286f0b1f446755a623aea297a34c470d4d87c92e2e4a79dc63e162009e08a9"
}
}
}
@@ -62,5 +62,11 @@
}
}
}
]
],
"auxiliary": {
"stroke_texture_production_report.json": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_production_report.json",
"sha256": "c128562a57d78291db4d8157a89eabbc8af179fec3675ed81ab8f8eaa7ee3e44"
}
}
}
File diff suppressed because it is too large Load Diff
+32 -3
View File
@@ -557,8 +557,11 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
}
manifest_path = output_dir / "stroke_texture_manifest.json"
temporary = manifest_path.with_suffix(".json.tmp")
auxiliary = ([output_dir / "_multiview_ground_truth.json",
output_dir / "_print_views.json"] if args.views else [])
production_report_path = output_dir / "stroke_texture_production_report.json"
auxiliary = [production_report_path]
if args.views:
auxiliary.extend((output_dir / "_multiview_ground_truth.json",
output_dir / "_print_views.json"))
if settings["orientation_mode"] == "optimized":
auxiliary.append(output_dir / "stroke_texture_optimization.json")
if (manifest_path.exists() or temporary.exists()
@@ -585,6 +588,21 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
for path in missing:
print(f"ERROR: stroke artifact missing or empty: {path}", file=sys.stderr)
return 3
try:
production_report = json.loads(production_report_path.read_text(encoding="utf-8"))
if production_report["schema"] != "orisys-sensor-design/stroke-production-report-v1":
raise ValueError("unexpected production report schema")
measured = production_report["views"]
if [view["id"] for view in measured] != source_ids:
raise ValueError("production report views differ from the source job")
for view in measured:
if view["svg"]["sha256"] != sha256_file(outputs[view["id"]]["svg"]):
raise ValueError(f"production report SVG hash differs for {view['id']}")
if view["stroke_count"] < 1 or view["width_mm"]["min"] is None:
raise ValueError(f"production report has no measurable strokes for {view['id']}")
except (OSError, ValueError, KeyError, TypeError) as exc:
print(f"ERROR: invalid stroke production report: {exc}", file=sys.stderr)
return 3
manifest = {
"schema": "orisys-sensor-design/stroke-texture-manifest-v2",
"revision_dir": str(paths.root),
@@ -613,11 +631,22 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
temporary.replace(manifest_path)
artifacts = [path for entry in outputs.values() for path in entry.values()]
if console.get_level() == console.QUIET:
console.artifact_paths([*artifacts, manifest_path])
console.artifact_paths([*artifacts, production_report_path, manifest_path])
else:
console.report(f"OK: wrote {len(artifacts)} stroke texture artifacts to {output_dir}")
for path in artifacts:
console.report(f" {path}")
for view in measured:
width = view["width_mm"]
length = view["length_mm"]
console.report(
f" {view['id']}: {view['stroke_count']} strokes, "
f"width {width['min']:.4g}-{width['max']:.4g} mm, "
f"length {length['min']:.4g}-{length['max']:.4g} mm, "
f"{view['width_below_target_count']} below "
f"{production_report['width_target_mm']:g} mm width target"
)
console.report(f" production report: {production_report_path}")
console.report(f" manifest: {manifest_path}")
return 0
+67
View File
@@ -0,0 +1,67 @@
"""Production dimensions are measured per visible stroke, not per UV triangle."""
from __future__ import annotations
import json
import math
import sys
from pathlib import Path
import pytest
SCRIPT_DIR = Path(__file__).resolve().parents[1] / "blender" / "scripts"
sys.path.insert(0, str(SCRIPT_DIR))
from stroke_production_report import summarize_view, write_report # noqa: E402
def test_fragments_of_one_cell_are_one_stroke(tmp_path):
svg = tmp_path / "stroke_texture_uv_1.svg"
svg.write_text("<svg/>", encoding="utf-8")
# 100 px/mm; the first 0.6 x 0.2 mm mark is split by a UV triangle.
polygons = [
(1, 2, [(10, 10), (40, 10), (40, 30), (10, 30)]),
(1, 2, [(40, 10), (70, 10), (70, 30), (40, 30)]),
(3, 4, [(100, 10), (130, 10), (130, 20), (100, 20)]),
]
mesh = [[(0, 0), (200, 0), (200, 200), (0, 200)]]
view = summarize_view("uv_1", "uv_1", polygons, mesh,
(1000, 1000), (10, 10), svg)
assert view["stroke_count"] == 2
assert view["vector_fragment_count"] == 3
assert view["width_mm"]["min"] == pytest.approx(0.1)
assert view["width_mm"]["max"] == pytest.approx(0.2)
assert view["length_mm"]["max"] == pytest.approx(0.6)
assert view["width_below_target_count"] == 1
assert view["black_to_base_area_ratio_estimate"] == pytest.approx(0.0375)
def test_rotated_stroke_dimensions_use_print_page_units(tmp_path):
svg = tmp_path / "stroke.svg"
svg.write_text("<svg/>", encoding="utf-8")
angle = math.radians(31)
ux, uy = math.cos(angle), math.sin(angle)
vx, vy = -uy, ux
# 2 px/mm horizontally, 4 px/mm vertically: convert the mm corners to pixels.
corners = [(sx * 0.3 * ux + sy * 0.1 * vx,
sx * 0.3 * uy + sy * 0.1 * vy) for sx, sy in
((-1, -1), (1, -1), (1, 1), (-1, 1))]
pixels = [(x * 2 + 50, y * 4 + 50) for x, y in corners]
view = summarize_view("compute", "compute", [(7, 8, pixels)], [],
(200, 400), (100, 100), svg)
assert view["width_mm"]["min"] == pytest.approx(0.2)
assert view["length_mm"]["max"] == pytest.approx(0.6)
def test_report_counts_split_camera_cells_across_views(tmp_path):
source = tmp_path / "job.json"
source.write_text('{"settings": {"nx": 10, "ny": 8}}', encoding="utf-8")
a = {"camera_cells": [[1, 2], [3, 4]], "stroke_count": 2}
b = {"camera_cells": [[3, 4], [5, 6]], "stroke_count": 2}
output = tmp_path / "report.json"
report = write_report(output, source, {"seed": 7}, [a, b])
assert report["unique_camera_cell_count"] == 3
assert report["cells_in_multiple_views"] == 1
assert report["total_view_strokes"] == 4
assert report["camera_grid"] == [10, 8]
assert json.loads(output.read_text(encoding="utf-8"))["width_target_mm"] == 0.2
+38 -1
View File
@@ -12,6 +12,21 @@ from orisys_sensor_design.manifest import RevisionPaths, sha256_file
REVISION = Path(__file__).resolve().parents[1] / "sensors" / "finger-xynova" / "r01"
def _production_report(output_dir: Path, ids: tuple[str, ...]) -> None:
views = []
for view_id in ids:
svg = output_dir / f"stroke_texture_{view_id}.svg"
views.append({
"id": view_id, "stroke_count": 3, "width_mm": {"min": 0.1, "max": 0.3},
"length_mm": {"min": 0.2, "max": 0.5}, "width_below_target_count": 1,
"svg": {"sha256": sha256_file(svg)},
})
(output_dir / "stroke_texture_production_report.json").write_text(json.dumps({
"schema": "orisys-sensor-design/stroke-production-report-v1",
"width_target_mm": 0.2, "views": views,
}), encoding="utf-8")
def _args(job: Path, output_dir: Path, views=False) -> argparse.Namespace:
return argparse.Namespace(
views=views, source_job=job, output_dir=output_dir,
@@ -42,6 +57,7 @@ def test_exports_from_geometry_job(tmp_path, monkeypatch):
calls.append((script, blend, args))
for fmt in ("png", "svg", "pdf"):
(output_dir / f"stroke_texture_compute.{fmt}").write_bytes(fmt.encode())
_production_report(output_dir, ("compute",))
return 0
monkeypatch.setattr(cli, "_run_blender_script", run_blender)
@@ -55,6 +71,7 @@ def test_exports_from_geometry_job(tmp_path, monkeypatch):
assert manifest["schema"] == "orisys-sensor-design/stroke-texture-manifest-v2"
assert manifest["source_job"]["sha256"] == sha256_file(job)
assert set(manifest["outputs"][0]["artifacts"]) == {"png", "svg", "pdf"}
assert "stroke_texture_production_report.json" in manifest["auxiliary"]
assert cli.cmd_stroke_texture(REVISION, _args(job, output_dir)) == 2
assert len(calls) == 1
@@ -78,6 +95,7 @@ def test_multiview_exports_owned_pairs_without_colour_artwork(tmp_path, monkeypa
(output_dir / f"stroke_texture_{pair}.{fmt}").write_bytes(pair.encode())
for name in ("_multiview_ground_truth.json", "_print_views.json"):
(output_dir / name).write_text("{}", encoding="utf-8")
_production_report(output_dir, ("uv_1", "uv_2"))
return 0
monkeypatch.setattr(cli, "_run_blender_script", run_blender)
@@ -87,5 +105,24 @@ def test_multiview_exports_owned_pairs_without_colour_artwork(tmp_path, monkeypa
manifest = json.loads((output_dir / "stroke_texture_manifest.json").read_text())
assert manifest["views"] is True
assert [entry["id"] for entry in manifest["outputs"]] == ["uv_1", "uv_2"]
assert set(manifest["auxiliary"]) == {"_multiview_ground_truth.json", "_print_views.json"}
assert set(manifest["auxiliary"]) == {
"_multiview_ground_truth.json", "_print_views.json",
"stroke_texture_production_report.json",
}
assert len(calls) == 1
def test_rejects_report_for_a_different_svg(tmp_path, monkeypatch):
job = _job(tmp_path)
output_dir = tmp_path / "strokes"
def run_blender(_script, _blend, _args):
for fmt in ("png", "svg", "pdf"):
(output_dir / f"stroke_texture_compute.{fmt}").write_bytes(fmt.encode())
_production_report(output_dir, ("compute",))
(output_dir / "stroke_texture_compute.svg").write_bytes(b"changed")
return 0
monkeypatch.setattr(cli, "_run_blender_script", run_blender)
assert cli.cmd_stroke_texture(REVISION, _args(job, output_dir)) == 3
assert not (output_dir / "stroke_texture_manifest.json").exists()