# Grouper: trace the blueprint's views (plain Python: PIL + numpy, no scipy). # * white line pixels: the sheet draws white lines on blue with a dim lighter-blue grid; the red channel separates them # (background R 0-25, grid R 15-60, lines R 120-255); # * the silhouette of a view: flood the background from the view box's border through non-line pixels, the rest is the # part (lines included), then an opening removes leader lines, dimension lines and other thin strokes, and the largest # component is kept; # * per column (SIDE, TOP, BOTTOM) or per row (FRONT, BACK) the outline: the outer edge of the silhouette. # python gr_trace.py -> reference/mask_*.png, lines_*.png, trace_*.png, build/trace_data.json import os, json import numpy as np from PIL import Image, ImageDraw HERE = os.path.dirname(os.path.abspath(__file__)) REF = os.path.normpath(os.path.join(HERE, "..", "reference")) IM = np.asarray(Image.open(os.path.join(REF, "user_blueprint.jpg")).convert("RGB")).astype(np.int16) H, W = IM.shape[:2] R = IM[..., 0] LINE_T = 100 def shift(m, dy, dx): o = np.zeros_like(m) h, w = m.shape ys, yd = (slice(0, h - dy), slice(dy, h)) if dy >= 0 else (slice(-dy, h), slice(0, h + dy)) xs, xd = (slice(0, w - dx), slice(dx, w)) if dx >= 0 else (slice(-dx, w), slice(0, w + dx)) o[yd, xd] = m[ys, xs] return o def dilate(m, r=1): o = m.copy() for dy in range(-r, r + 1): for dx in range(-r, r + 1): if dy * dy + dx * dx <= r * r + r: o |= shift(m, dy, dx) return o def erode(m, r=1): return ~dilate(~m, r) def propagate(seed, free): cur = seed & free while True: nxt = dilate(cur, 1) & free if (nxt == cur).all(): return cur cur = nxt def components(m): """label 4-connected components (simple two-pass union-find)""" h, w = m.shape lab = np.zeros((h, w), np.int32) parent = [0] def find(a): while parent[a] != a: parent[a] = parent[parent[a]] a = parent[a] return a nl = 0 for y in range(h): row = m[y] for x in range(w): if not row[x]: continue up = lab[y - 1, x] if y else 0 lf = lab[y, x - 1] if x else 0 if up and lf: a, b = find(up), find(lf) lab[y, x] = min(a, b) if a != b: parent[max(a, b)] = min(a, b) elif up or lf: lab[y, x] = up or lf else: nl += 1 parent.append(nl) lab[y, x] = nl for y in range(h): for x in range(w): if lab[y, x]: lab[y, x] = find(lab[y, x]) return lab # dimension and extension lines that would close background pockets against the hull: erased before the flood # (x0, y0, x1, y1) in original pixels, inclusive-exclusive ERASE = { "top": [(440, 75, 716, 78), (440, 221, 690, 224), (440, 78, 640, 80), (440, 219, 636, 221), (440, 95, 556, 101), (440, 198, 566, 203), (514, 40, 519, 107), (939, 40, 944, 115)], "bottom": [(440, 547, 566, 552), (440, 686, 590, 691), (440, 571, 516, 576), (440, 662, 516, 667)], "side": [(440, 473, 548, 478), (514, 421, 519, 520), (948, 458, 953, 520)], "front": [(85, 293, 200, 299), (85, 473, 140, 478), (150, 470, 156, 525), (310, 470, 316, 525), (150, 225, 158, 300), (305, 225, 310, 300), (170, 476, 174, 503), (291, 476, 295, 503)], "back": [(1118, 470, 1124, 525), (1288, 470, 1294, 525), (1139, 476, 1143, 503), (1270, 476, 1274, 503)], } def line_mask(name=None): m = R > LINE_T for (a, b, c, d) in ERASE.get(name, []): m[b:d, a:c] = False return m def silhouette(box, close=1, open_r=2, keep_extra=(), name=None): x0, y0, x1, y1 = box line = line_mask(name)[y0:y1, x0:x1] barrier = dilate(line, close) if close else line seed = np.zeros_like(barrier) seed[0, :] = seed[-1, :] = True seed[:, 0] = seed[:, -1] = True bg = propagate(seed & ~barrier, ~barrier) sil = ~bg if close: sil = erode(sil, close) | (sil & line) if open_r: sil = dilate(erode(sil, open_r), open_r) & sil lab = components(sil) ids, cnt = np.unique(lab[lab > 0], return_counts=True) keep = {int(ids[np.argmax(cnt)])} | {int(i) for i, c in zip(ids, cnt) if c > 400 and i in keep_extra} out = np.zeros((H, W), bool) out[y0:y1, x0:x1] = np.isin(lab, list(keep)) return out, line BOXES = { "side": (505, 285, 962, 484), "top": (505, 60, 962, 236), "bottom": (505, 540, 962, 715), "front": (138, 290, 322, 483), "back": (1110, 290, 1300, 482), } if __name__ == "__main__": data = {} for name, box in BOXES.items(): sil, line = silhouette(box, name=name) x0, y0, x1, y1 = box Image.fromarray((sil * 255).astype(np.uint8)).save(os.path.join(REF, "mask_%s_raw.png" % name)) prof = {} if name in ("side", "top", "bottom"): for x in range(x0, x1): ys = np.where(sil[:, x])[0] if len(ys): prof[x] = (int(ys.min()), int(ys.max())) else: for y in range(y0, y1): xs = np.where(sil[y, :])[0] if len(xs): prof[y] = (int(xs.min()), int(xs.max())) data[name] = prof # overlay im = Image.open(os.path.join(REF, "user_blueprint.jpg")).convert("RGB").crop((x0 - 10, y0 - 10, x1 + 10, y1 + 10)) k = 3 im = im.resize((im.width * k, im.height * k), Image.LANCZOS) d = ImageDraw.Draw(im) pts_a, pts_b = [], [] for key, (a, b) in sorted(prof.items()): if name in ("side", "top", "bottom"): pts_a.append(((key - x0 + 10.5) * k, (a - y0 + 10.5) * k)); pts_b.append(((key - x0 + 10.5) * k, (b - y0 + 10.5) * k)) else: pts_a.append(((a - x0 + 10.5) * k, (key - y0 + 10.5) * k)); pts_b.append(((b - x0 + 10.5) * k, (key - y0 + 10.5) * k)) d.line(pts_a, fill=(255, 40, 40), width=1) d.line(pts_b, fill=(255, 220, 0), width=1) im.save(os.path.join(REF, "trace_%s_raw.png" % name)) print(name, len(prof)) json.dump({k: {str(a): b for a, b in v.items()} for k, v in data.items()}, open(os.path.join(HERE, "trace_data.json"), "w"))