220 行
8.8 KiB
Python
220 行
8.8 KiB
Python
# -*- 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()
|