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.
112 lines
5.1 KiB
Python
112 lines
5.1 KiB
Python
# 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)
|