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

按最终矢量页面汇总每个相机单元的笔画包络,并标出低于 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
+28 -9
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,
params["width_ratio"], params["length_ratio"], angle_map,
args.artifact_prefix)
views.append(_render(name, geometry[name], settings, args.output_dir,
params["width_ratio"], params["length_ratio"], angle_map,
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