# -*- coding: utf-8 -*- u"""把 `pcb2_measure_sheet.py` 那张测量底板的扫描件/照片反算成 mm。 /c/Python314/python .claude/pcb2_measure_read.py <图片> [--write] ## 干什么 底板上四个实心黑圆 Φ6,圆心构成精确的 160 × 110 mm 矩形。本脚本: 1. 找那四个圆的**质心**,按四点单应把图像映射到底板 mm(扫描仪/相机的比例误差、 歪斜、透视一起消掉,所以照片也能用,扫描件更准); 2. 抽红色墨迹,最大的一团 = 描的**板轮廓**,其余小团 = 从孔里**戳的点**; 3. 用轮廓的两条直边(接口边 + 上端边)定出**板自身坐标系**, 把每个戳点换算成 `(u, v)` = (距上端边, 距接口边)—— 和用户给数的口径一致; 4. 打印孔表 + 板长板宽;`--write` 把结果写进 `.claude/mi8_mech.json`。 ## 两个坑 - **描边笔宽 1~1.5 mm**,笔是贴着板边**外侧**走的 ⇒ 必须取**墨迹内沿**当板边, 直接用墨迹外沿会把板量大 1.5~3 mm(2026-09-06 第一次就这么错过)。 - 戳点可能和轮廓线**粘在一起**(孔离板边近的时候)。所以先把最大那团(轮廓)整个剔掉, 再找剩下的团;被粘住的那个会连轮廓一起被剔掉,**所以最后要核对点数**。 """ import json import math import os import sys sys.stdout.reconfigure(encoding='utf-8') HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import cv2 import numpy as np from scipy import ndimage FID_W, FID_H = 160.0, 110.0 # 基准圆心矩形(和 pcb2_measure_sheet.py 一致) FID_D = 6.0 MECH = os.path.join(HERE, 'mi8_mech.json') def find_fids(bgr): u"""找四个实心黑圆,返回按 [左下, 右下, 右上, 左上] 排好的**图像**坐标。""" g = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) thr = cv2.threshold(g, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1] lab, n = ndimage.label(thr > 0) cands = [] for i, sl in enumerate(ndimage.find_objects(lab), 1): h = sl[0].stop - sl[0].start w = sl[1].stop - sl[1].start area = int((lab[sl] == i).sum()) if w < 20 or h < 20 or w > 400 or h > 400: continue if not (0.8 < w / float(h) < 1.25): continue if area / float(w * h) < 0.72: # 实心 continue if abs(area / (math.pi * (w / 2.0) * (h / 2.0)) - 1.0) > 0.18: # 像个圆 continue cy, cx = ndimage.center_of_mass(lab == i) cands.append((area, cx, cy, w, h)) if len(cands) < 4: sys.exit(u'只找到 %d 个基准圆,需要 4 个(黑圆被挡住 / 没入镜?)' % len(cands)) cands.sort(reverse=True) # 四个基准圆大小一致;取面积最接近的四个连续候选 best, bestspread = None, 1e18 for k in range(len(cands) - 3): grp = cands[k:k + 4] spread = grp[0][0] / float(grp[-1][0]) if spread < bestspread: best, bestspread = grp, spread if bestspread > 1.6: sys.exit(u'四个候选圆大小差太多(%.2f 倍),认错了' % bestspread) pts = [(c[1], c[2]) for c in best] cx = sum(p[0] for p in pts) / 4.0 cy = sum(p[1] for p in pts) / 4.0 out = {} for x, y in pts: out[(x > cx, y > cy)] = (x, y) # (右?, 下?) try: return [out[(False, True)], out[(True, True)], out[(True, False)], out[(False, False)]] except KeyError: sys.exit(u'四个基准圆没有分处四角,认错了') def homography(fids): u"""图像 px -> 底板 mm(原点 = 基准矩形中心,x 右, y 上)。""" dst = np.float32([(-FID_W / 2, -FID_H / 2), (FID_W / 2, -FID_H / 2), (FID_W / 2, FID_H / 2), (-FID_W / 2, FID_H / 2)]) return cv2.getPerspectiveTransform(np.float32(fids), dst) def to_mm(H, pts): p = np.array(pts, np.float32).reshape(-1, 1, 2) return cv2.perspectiveTransform(p, H).reshape(-1, 2) def red_mask(bgr): B, G, R = (bgr[:, :, i].astype(np.int16) for i in range(3)) m = (R > 90) & (R - G > 40) & (R - B > 35) return ndimage.binary_closing(m, np.ones((3, 3))) def robust_line(xs, ys, keep=0.75, it=8): u"""稳健拟合 y = m*x + c,逐轮砍掉残差最大的那批。""" p = np.c_[np.asarray(xs, float), np.asarray(ys, float)] for _ in range(it): A = np.c_[p[:, 0], np.ones(len(p))] m, c = np.linalg.lstsq(A, p[:, 1], rcond=None)[0] r = np.abs(p[:, 1] - (m * p[:, 0] + c)) k = max(6, int(len(p) * keep)) idx = np.argsort(r)[:k] if len(idx) == len(p): break p = p[idx] A = np.c_[p[:, 0], np.ones(len(p))] m, c = np.linalg.lstsq(A, p[:, 1], rcond=None)[0] return m, c, float(np.abs(p[:, 1] - (m * p[:, 0] + c)).max()), len(p) def main(): if len(sys.argv) < 2: sys.exit(__doc__) src = sys.argv[1] bgr = cv2.imdecode(np.fromfile(src, dtype=np.uint8), cv2.IMREAD_COLOR) if bgr is None: sys.exit(u'读不了 %s' % src) print(u'图像 %d x %d' % (bgr.shape[1], bgr.shape[0])) fids = find_fids(bgr) H = homography(fids) d = [math.hypot(fids[i][0] - fids[j][0], fids[i][1] - fids[j][1]) for i, j in ((0, 1), (1, 2), (2, 3), (3, 0))] print(u'基准圆(图像 px):' + ' '.join('(%.0f,%.0f)' % p for p in fids)) print(u' 四边 px:%s ⇒ 每 mm %.2f / %.2f px' % ( ' '.join('%.0f' % t for t in d), d[0] / FID_W, d[1] / FID_H)) red = red_mask(bgr) lab, n = ndimage.label(red) if n == 0: sys.exit(u'没找到红色墨迹') sizes = ndimage.sum(red, lab, range(1, n + 1)) outline = (lab == int(np.argmax(sizes)) + 1) print(u'红墨 %d 团;最大一团 %d px 当作板轮廓' % (n, int(sizes.max()))) ys, xs = np.nonzero(outline) P = to_mm(H, np.c_[xs, ys]) ou, ov = P[:, 0], P[:, 1] # 板自身两条基准边:接口边(左,竖直)取墨迹内沿=最大 x;上端边(上,水平)取最小 y 的内沿 lo, hi = np.percentile(ov, 12), np.percentile(ov, 88) pts = [] for a in np.arange(lo, hi, 0.5): s = ou[(ov >= a) & (ov < a + 0.5)] if len(s) > 15: pts.append((a + 0.25, np.percentile(s, 2))) # 左侧墨迹 # 内沿 = 左侧墨条的右边界:改用「左侧 3 mm 带内的最大 x」 pts = [] xleft = np.percentile(ou, 1) for a in np.arange(lo, hi, 0.5): s = ou[(ov >= a) & (ov < a + 0.5) & (ou < xleft + 4.0)] if len(s) > 15: pts.append((a + 0.25, s.max())) mL = robust_line([p[0] for p in pts], [p[1] for p in pts]) print(u'接口边内沿 x = %+.5f*y %+.3f 残差<=%.3f mm (%d 段)' % mL) lo2, hi2 = np.percentile(ou, 12), np.percentile(ou, 88) ytop = np.percentile(ov, 99) pts = [] for a in np.arange(lo2, hi2, 0.5): s = ov[(ou >= a) & (ou < a + 0.5) & (ov > ytop - 4.0)] if len(s) > 15: pts.append((a + 0.25, s.min())) mT = robust_line([p[0] for p in pts], [p[1] for p in pts]) print(u'上端边内沿 y = %+.5f*x %+.3f 残差<=%.3f mm (%d 段)' % mT) thL = math.atan(mL[0]) # 接口边相对竖直的倾角 def board(x, y): v = (x - (mL[0] * y + mL[1])) * math.cos(thL) # 距接口边 u = ((mT[0] * x + mT[1]) - y) * math.cos(math.atan(mT[0])) # 距上端边 return u, v # 板外形:轮廓内沿的包络 ub, vb = board(ou, ov) print(u'\n板外形(轮廓墨迹内沿):长 %.2f mm(u 方向)· 宽 %.2f mm(v 方向)' % (np.percentile(ub, 99.9), np.percentile(vb, 99.9))) rest = red & ~outline lab2, n2 = ndimage.label(rest) holes = [] for i, sl in enumerate(ndimage.find_objects(lab2), 1): area = int((lab2[sl] == i).sum()) h = sl[0].stop - sl[0].start w = sl[1].stop - sl[1].start if area < 60 or w > 260 or h > 260: continue if not (0.45 < w / float(h) < 2.2): continue cy, cx = ndimage.center_of_mass(lab2 == i) (x, y), = to_mm(H, [(cx, cy)]) u, v = board(x, y) holes.append((round(float(u), 2), round(float(v), 2), area)) holes.sort() print(u'\n戳点 %d 个 (u = 距上端边, v = 距接口边)' % len(holes)) for i, (u, v, a) in enumerate(holes, 1): print(u' H%-2d u=%6.2f v=%6.2f (%d px)' % (i, u, v, a)) if '--write' in sys.argv: d = json.load(open(MECH, encoding='utf-8')) d['holes'] = {'H%d' % (i + 1): [h[0], h[1]] for i, h in enumerate(holes)} d['board_w'] = round(float(np.percentile(vb, 99.9)), 2) d['board_h'] = round(float(np.percentile(ub, 99.9)), 2) d['_source'] = u'2026-09-06 测量底板扫描件反算(pcb2_measure_read.py)' json.dump(d, open(MECH, 'w', encoding='utf-8'), ensure_ascii=False, indent=1) print(u'\n已写回 %s' % MECH) if __name__ == '__main__': main()