# 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 17k 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)