增加笔画生产尺寸与数量报告
按最终矢量页面汇总每个相机单元的笔画包络,并标出低于 0.2 mm 的数量。 已核对 SVG、清单哈希和 Blender 导出,175 项相关测试通过。
This commit is contained in:
@@ -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
|
mode; `--output-dir` selects a fresh destination. `--views` uses the existing
|
||||||
`texture-views` job's triangle ownership, seam margin and
|
`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.
|
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;
|
It also writes `stroke_texture_production_report.json`: per-view stroke and
|
||||||
measure the production SVG before manufacturing.
|
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)
|
## Inspect printability (advisory)
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,14 @@ from random_patch_dd02c import (
|
|||||||
_prepare_mesh_object,
|
_prepare_mesh_object,
|
||||||
_require_object,
|
_require_object,
|
||||||
_require_uv_layer,
|
_require_uv_layer,
|
||||||
|
clip_polygon_to_page,
|
||||||
fill_background,
|
fill_background,
|
||||||
fill_polygon,
|
fill_polygon,
|
||||||
save_png_image,
|
save_png_image,
|
||||||
write_pdf_vector,
|
write_pdf_vector,
|
||||||
write_svg_vector,
|
write_svg_vector,
|
||||||
)
|
)
|
||||||
|
from stroke_production_report import summarize_view, write_report
|
||||||
|
|
||||||
MODE = "camera_grid_capsules"
|
MODE = "camera_grid_capsules"
|
||||||
DEFAULT_PARAMS = {
|
DEFAULT_PARAMS = {
|
||||||
@@ -64,8 +66,8 @@ def _pixel_polygon(uv_polygon, width, height):
|
|||||||
|
|
||||||
|
|
||||||
def render_stroke_polygons(uv_name, mesh, strokes, width, height, vector,
|
def render_stroke_polygons(uv_name, mesh, strokes, width, height, vector,
|
||||||
base):
|
base, view_id=None):
|
||||||
"""Write the shared raster and vector artwork from UV polygons."""
|
"""Write artwork and measure the same page-clipped vector polygons."""
|
||||||
image_name = f"CameraGridStroke_{uv_name}"
|
image_name = f"CameraGridStroke_{uv_name}"
|
||||||
if image_name in bpy.data.images:
|
if image_name in bpy.data.images:
|
||||||
bpy.data.images.remove(bpy.data.images[image_name])
|
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))
|
fill_polygon(None, pixels, width, height, poly, (1, 1, 1, 1))
|
||||||
|
|
||||||
shapes = []
|
shapes = []
|
||||||
for polygon in strokes:
|
labelled_shapes = []
|
||||||
|
for i, j, polygon in strokes:
|
||||||
pixel_poly = _pixel_polygon(polygon, width, height)
|
pixel_poly = _pixel_polygon(polygon, width, height)
|
||||||
if len(pixel_poly) < 3:
|
if len(pixel_poly) < 3:
|
||||||
continue
|
continue
|
||||||
shapes.append((pixel_poly, (0, 0, 0, 1)))
|
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))
|
fill_polygon(None, pixels, width, height, pixel_poly, (0, 0, 0, 1))
|
||||||
|
|
||||||
image.pixels[:] = pixels
|
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),
|
page_mesh, page_shapes, [], (0, 0, 0, 1),
|
||||||
artwork_label="stroke")
|
artwork_label="stroke")
|
||||||
print(f"{uv_name}: {len(shapes)} projected stroke fragments")
|
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,
|
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(
|
outline_cache[key] = capsule_in_cell(
|
||||||
i, j, nx, ny, seed, width_ratio, length_ratio,
|
i, j, nx, ny, seed, width_ratio, length_ratio,
|
||||||
angle=None if angle_map is None else float(angle_map[j, i]))
|
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
|
label = artifact_prefix
|
||||||
if angle_map is not None and artifact_prefix == "camera_grid_stroke":
|
if angle_map is not None and artifact_prefix == "camera_grid_stroke":
|
||||||
label += "_optimized"
|
label += "_optimized"
|
||||||
base = output_dir / f"{label}_{uv_name}"
|
base = output_dir / f"{label}_{uv_name}"
|
||||||
render_stroke_polygons(uv_name, geometry["mesh"], strokes,
|
return render_stroke_polygons(uv_name, geometry["mesh"], strokes,
|
||||||
settings["width"], settings["height"], settings["vector"], base)
|
settings["width"], settings["height"], settings["vector"],
|
||||||
|
base)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -164,10 +179,14 @@ def main():
|
|||||||
names, settings["nx"], settings["ny"], settings["edge_sample_grid"],
|
names, settings["nx"], settings["ny"], settings["edge_sample_grid"],
|
||||||
capture_camera_fragments=True,
|
capture_camera_fragments=True,
|
||||||
)
|
)
|
||||||
|
views = []
|
||||||
for name in names:
|
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,
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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 import render_stroke_polygons # noqa: E402
|
||||||
from camera_grid_stroke_geometry import capsule_in_cell, projected_stroke_uv # 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 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
|
from texture_merge import ( # noqa: E402
|
||||||
MergeConfigError,
|
MergeConfigError,
|
||||||
MergePolicy,
|
MergePolicy,
|
||||||
@@ -966,6 +968,7 @@ def main() -> int:
|
|||||||
|
|
||||||
# ---- 5. Emit: mask each layout to its owned triangles ---------------------------
|
# ---- 5. Emit: mask each layout to its owned triangles ---------------------------
|
||||||
emitted_stats: dict[str, dict[str, Any]] = {}
|
emitted_stats: dict[str, dict[str, Any]] = {}
|
||||||
|
stroke_views = []
|
||||||
for pair, region_cam, _pattern_cam, uv_layer in resolved:
|
for pair, region_cam, _pattern_cam, uv_layer in resolved:
|
||||||
pair_id = pair["id"]
|
pair_id = pair["id"]
|
||||||
uv_map = pair["uv_map"]
|
uv_map = pair["uv_map"]
|
||||||
@@ -1191,12 +1194,13 @@ def main() -> int:
|
|||||||
geometry=filtered,
|
geometry=filtered,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
render_stroke_polygons(
|
stroke_views.append(render_stroke_polygons(
|
||||||
uv_map, base_layer, [poly for _, _, poly in emitted_patches],
|
uv_map, base_layer, emitted_patches,
|
||||||
texture_width, texture_height,
|
texture_width, texture_height,
|
||||||
{"width_mm": page_width_mm, "height_mm": page_height_mm},
|
{"width_mm": page_width_mm, "height_mm": page_height_mm},
|
||||||
Path(pair["artifacts"]["png"]).with_suffix(""),
|
Path(pair["artifacts"]["png"]).with_suffix(""),
|
||||||
)
|
view_id=pair_id,
|
||||||
|
))
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
print(
|
print(
|
||||||
f"ERROR: texture generation failed for view {pair_id!r}: {exc}",
|
f"ERROR: texture generation failed for view {pair_id!r}: {exc}",
|
||||||
@@ -1407,6 +1411,12 @@ def main() -> int:
|
|||||||
_write_ground_truth(truth_path, truth)
|
_write_ground_truth(truth_path, truth)
|
||||||
detail(f" ground truth -> {truth_path}")
|
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
|
# 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.
|
# 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.")
|
detail(f"Completed in {time.time() - start_time:.2f} seconds.")
|
||||||
|
|||||||
@@ -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": {
|
"_print_views.json": {
|
||||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\_print_views.json",
|
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\_print_views.json",
|
||||||
"sha256": "abd5938d35fc8863b91753b2915fb5b39d261f0920286ef3eca2cc4e6a6c89bf"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11335
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -557,8 +557,11 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
|||||||
}
|
}
|
||||||
manifest_path = output_dir / "stroke_texture_manifest.json"
|
manifest_path = output_dir / "stroke_texture_manifest.json"
|
||||||
temporary = manifest_path.with_suffix(".json.tmp")
|
temporary = manifest_path.with_suffix(".json.tmp")
|
||||||
auxiliary = ([output_dir / "_multiview_ground_truth.json",
|
production_report_path = output_dir / "stroke_texture_production_report.json"
|
||||||
output_dir / "_print_views.json"] if args.views else [])
|
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":
|
if settings["orientation_mode"] == "optimized":
|
||||||
auxiliary.append(output_dir / "stroke_texture_optimization.json")
|
auxiliary.append(output_dir / "stroke_texture_optimization.json")
|
||||||
if (manifest_path.exists() or temporary.exists()
|
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:
|
for path in missing:
|
||||||
print(f"ERROR: stroke artifact missing or empty: {path}", file=sys.stderr)
|
print(f"ERROR: stroke artifact missing or empty: {path}", file=sys.stderr)
|
||||||
return 3
|
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 = {
|
manifest = {
|
||||||
"schema": "orisys-sensor-design/stroke-texture-manifest-v2",
|
"schema": "orisys-sensor-design/stroke-texture-manifest-v2",
|
||||||
"revision_dir": str(paths.root),
|
"revision_dir": str(paths.root),
|
||||||
@@ -613,11 +631,22 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
|||||||
temporary.replace(manifest_path)
|
temporary.replace(manifest_path)
|
||||||
artifacts = [path for entry in outputs.values() for path in entry.values()]
|
artifacts = [path for entry in outputs.values() for path in entry.values()]
|
||||||
if console.get_level() == console.QUIET:
|
if console.get_level() == console.QUIET:
|
||||||
console.artifact_paths([*artifacts, manifest_path])
|
console.artifact_paths([*artifacts, production_report_path, manifest_path])
|
||||||
else:
|
else:
|
||||||
console.report(f"OK: wrote {len(artifacts)} stroke texture artifacts to {output_dir}")
|
console.report(f"OK: wrote {len(artifacts)} stroke texture artifacts to {output_dir}")
|
||||||
for path in artifacts:
|
for path in artifacts:
|
||||||
console.report(f" {path}")
|
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}")
|
console.report(f" manifest: {manifest_path}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -12,6 +12,21 @@ from orisys_sensor_design.manifest import RevisionPaths, sha256_file
|
|||||||
REVISION = Path(__file__).resolve().parents[1] / "sensors" / "finger-xynova" / "r01"
|
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:
|
def _args(job: Path, output_dir: Path, views=False) -> argparse.Namespace:
|
||||||
return argparse.Namespace(
|
return argparse.Namespace(
|
||||||
views=views, source_job=job, output_dir=output_dir,
|
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))
|
calls.append((script, blend, args))
|
||||||
for fmt in ("png", "svg", "pdf"):
|
for fmt in ("png", "svg", "pdf"):
|
||||||
(output_dir / f"stroke_texture_compute.{fmt}").write_bytes(fmt.encode())
|
(output_dir / f"stroke_texture_compute.{fmt}").write_bytes(fmt.encode())
|
||||||
|
_production_report(output_dir, ("compute",))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
monkeypatch.setattr(cli, "_run_blender_script", run_blender)
|
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["schema"] == "orisys-sensor-design/stroke-texture-manifest-v2"
|
||||||
assert manifest["source_job"]["sha256"] == sha256_file(job)
|
assert manifest["source_job"]["sha256"] == sha256_file(job)
|
||||||
assert set(manifest["outputs"][0]["artifacts"]) == {"png", "svg", "pdf"}
|
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 cli.cmd_stroke_texture(REVISION, _args(job, output_dir)) == 2
|
||||||
assert len(calls) == 1
|
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())
|
(output_dir / f"stroke_texture_{pair}.{fmt}").write_bytes(pair.encode())
|
||||||
for name in ("_multiview_ground_truth.json", "_print_views.json"):
|
for name in ("_multiview_ground_truth.json", "_print_views.json"):
|
||||||
(output_dir / name).write_text("{}", encoding="utf-8")
|
(output_dir / name).write_text("{}", encoding="utf-8")
|
||||||
|
_production_report(output_dir, ("uv_1", "uv_2"))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
monkeypatch.setattr(cli, "_run_blender_script", run_blender)
|
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())
|
manifest = json.loads((output_dir / "stroke_texture_manifest.json").read_text())
|
||||||
assert manifest["views"] is True
|
assert manifest["views"] is True
|
||||||
assert [entry["id"] for entry in manifest["outputs"]] == ["uv_1", "uv_2"]
|
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
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user