Files
cnx_vinyl/docs/equipment/inline-qc-station/annotate_render.py
T
grabowski e8674795f5 Inline QC station: real 40x40 profiles, labelled investor render
User, 18 Oct 2026: use real 40x40 profiles, make a render with text
pointing out the parts to show the investor; the ring lights looked
like they were floating.

- build_station.py: frame drawn from the real 8-slot 40 series section
  (profile_shape: slots, centre bore, corner cavities; 40x80 with centre
  cavity) and 24 cast angle brackets; top beams now butt between the
  uprights; label-camera posts moved beside the cameras so both ring
  lights clamp straight to them; top line-scan on a side post with
  visible camera plate and light hangers; slide strips on carrier angles,
  pulley axles, end-stop posts, presence-sensor brackets, backlight post,
  cabinet on base crosses, chute on two supports and clear of the rail,
  pneumatics rail, stack light on the back upright, touch screen on the
  front end upright. A connectivity check at the end lists any part that
  does not reach the floor through touching parts: none.
- render_annotated.py (FreeCAD): front three-quarter view, camera backed
  off so nothing is clipped, no navigation cube or preselection, label
  positions projected from the camera and checked with marker renders.
- annotate_render.py (Pillow): title, 16 labels in two columns, leaders
  assigned so none cross -> inline-qc-station-annotated.png.
- render_station.py: same camera and preselection fixes; all six views
  re-rendered.
- BOM: 32 m of 40x40 in 45 pieces; total still USD 16k to 33k.
- inline-optical-qc.md and README updated; .gitignore for the render
  intermediates.
2026-10-18 14:07:41 +07:00

112 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Draw the part labels onto the investor render of the inline QC station.
# uv run --with pillow python docs/equipment/inline-qc-station/annotate_render.py
# Reads inline-qc-station-annotated-base.png and _anchors.json from render_annotated.py
# (FreeCAD) and writes inline-qc-station-annotated.png.
import json
import os
from PIL import Image, ImageChops, ImageDraw, ImageFont
D = os.path.dirname(os.path.abspath(__file__))
FONTS = "C:/Windows/Fonts/"
SCALE = 0.75 # 4000 px render, drawn at 3/4 size (anti-aliased)
TITLE = "Inline optical QC station"
SUBTITLE = ("Every record checked on both faces in one turn: surface, label, warp and centring. "
"Failed records leave the line automatically.")
FOOTER = ("CNX Vinyl, concept model (FreeCAD); bought parts drawn as outlines. 1.9 × 0.65 × 1.5 m, "
"front panels and lid removed for the view. Hardware about USD 16k to 33k per station (estimate).")
NAVY, GREY, LINE = (18, 53, 91), (85, 95, 105), (60, 74, 90)
def font(name, size):
return ImageFont.truetype(FONTS + name, size)
F_TITLE, F_SUB = font("seguisb.ttf", 76), font("segoeui.ttf", 36)
F_LAB, F_DESC, F_FOOT = font("seguisb.ttf", 40), font("segoeui.ttf", 31), font("segoeui.ttf", 27)
meta = json.load(open(os.path.join(D, "_anchors.json"), encoding="utf-8"))
img = Image.open(os.path.join(D, "inline-qc-station-annotated-base.png")).convert("RGB")
img = img.resize((round(img.width * SCALE), round(img.height * SCALE)), Image.LANCZOS)
bb = ImageChops.difference(img, Image.new("RGB", img.size, (255, 255, 255))).getbbox()
pad = 30
crop = (max(0, bb[0] - pad), max(0, bb[1] - pad), min(img.width, bb[2] + pad), min(img.height, bb[3] + pad))
img = img.crop(crop)
anchors = [dict(a, xy=(a["xy"][0] * SCALE - crop[0], a["xy"][1] * SCALE - crop[1])) for a in meta["anchors"]]
COL_W, GAP, TOP, BOTTOM, MX = 760, 90, 280, 140, 90
W = img.width + 2 * (MX + COL_W + GAP)
H = img.height + TOP + BOTTOM
canvas = Image.new("RGB", (W, H), (255, 255, 255))
ox, oy = MX + COL_W + GAP, TOP
canvas.paste(img, (ox, oy))
d = ImageDraw.Draw(canvas)
d.text((MX, 60), TITLE, font=F_TITLE, fill=NAVY)
d.text((MX + 4, 165), SUBTITLE, font=F_SUB, fill=GREY)
d.line([(MX, 238), (W - MX, 238)], fill=(215, 220, 226), width=2)
d.text((MX, H - 90), FOOTER, font=F_FOOT, fill=GREY)
# split: the left-most half of the anchors on the left, the rest on the right
order = sorted(anchors, key=lambda a: a["xy"][0])
half = (len(order) + 1) // 2
cols = {"L": order[:half], "R": order[half:]}
BLOCK = 108 # label height incl. spacing
def _cross(p1, p2, p3, p4):
def o(a, b, c):
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
return (o(p1, p2, p3) > 0) != (o(p1, p2, p4) > 0) and (o(p3, p4, p1) > 0) != (o(p3, p4, p2) > 0)
def place(items, edge_x):
"""Label slots kept near each anchor's height without overlapping, then the anchor-to-slot
assignment that makes no two leader lines cross (fewest crossings, then least drift)."""
import itertools
items = sorted(items, key=lambda a: a["xy"][1])
lo, hi = oy - 20, oy + img.height - BLOCK + 20
ys = [min(max(a["xy"][1] + oy - 26, lo), hi) for a in items]
for i in range(1, len(ys)):
ys[i] = max(ys[i], ys[i - 1] + BLOCK)
over = ys[-1] - hi
if over > 0:
ys = [y - over for y in ys]
for i in range(len(ys) - 2, -1, -1):
ys[i] = min(ys[i], ys[i + 1] - BLOCK)
best = None
for perm in itertools.permutations(range(len(items))):
segs = [((edge_x, ys[k] + 26), (items[i]["xy"][0] + ox, items[i]["xy"][1] + oy)) for k, i in enumerate(perm)]
n = sum(_cross(*segs[a], *segs[b]) for a in range(len(segs)) for b in range(a + 1, len(segs)))
drift = sum(abs(ys[k] + 26 - items[i]["xy"][1] - oy) for k, i in enumerate(perm))
score = n * 1e6 + drift
if best is None or score < best[0]:
best = (score, perm)
print("crossings", int(best[0] // 1e6))
return [(items[i], ys[k]) for k, i in enumerate(best[1])]
for side, items in cols.items():
edge = MX + COL_W + GAP - 20 if side == "L" else ox + img.width + 20
for a, y in place(items, edge):
ax, ay = a["xy"][0] + ox, a["xy"][1] + oy
# text in a fixed column, left-aligned; the leader leaves from the end of the title line,
# runs level to the column edge, then straight to the part
tl = d.textlength(a["title"], font=F_LAB)
ly = y + 26
if side == "L":
tx = MX
pts = [(tx + tl + 16, ly), (MX + COL_W + GAP - 20, ly), (ax, ay)]
else:
tx = ox + img.width + GAP
pts = [(tx - 16, ly), (ox + img.width + 20, ly), (ax, ay)]
d.line(pts, fill=LINE, width=3, joint="curve")
d.ellipse([ax - 10, ay - 10, ax + 10, ay + 10], fill=(255, 255, 255))
d.ellipse([ax - 7, ay - 7, ax + 7, ay + 7], fill=NAVY)
d.text((tx, y), a["title"], font=F_LAB, fill=NAVY)
d.text((tx, y + 52), a["desc"], font=F_DESC, fill=GREY)
out = os.path.join(D, "inline-qc-station-annotated.png")
canvas.save(out, optimize=True)
print("saved", out, canvas.size)