文件
phonerail/.claude/pcb2_measure_read.py
T

220 行
8.8 KiB
Python
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- 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()