Showing Posts From
OpenCV
OpenCV 做辅助标注:模板匹配 + 多尺度 + ORB 特征
有一批已经标好的图片和几张未标的图,想利用现成 ROI 做辅助标注——不需要训练模型、也没有 GPU,OpenCV 就能干。适合数据集初期"边标边攒模板"的阶段。 完整流程 已标注图 → 切出目标 ROI → 模板库 ↓ 未标注图 → 在整图里搜索模板 → 最相似位置 + 置信度 ↓ 自动生成框 → 人工审核按目标形态不同,从简单到复杂选三种方案。 方案 1:单尺度模板匹配(最快) 假设你有一张图和已知的 ROI (x1, y1, x2, y2): import cv2img = cv2.imread("labeled.jpg") template = img[y1:y2, x1:x2] # 切出目标target = cv2.imread("unlabeled.jpg")result = cv2.matchTemplate(target, template, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc = cv2.minMaxLoc(result)h, w = template.shape[:2] x, y = max_loc box = [x, y, x + w, y + h] print(f"置信度 {max_val:.3f}, 位置 {box}")TM_CCOEFF_NORMED:归一化相关系数,返回 -1~1,越接近 1 越像 max_val > 0.8 是常见阈值,低于就认为没匹配上优点:一张 5000×5000 大图匹配几毫秒,几千张图几分钟。 致命缺点:对缩放和旋转极其敏感。模板是 100×100 的猫,目标图里是 120×120 的猫,直接找不到。 方案 2:多尺度模板匹配 一次不行就多来几次不同大小: import numpy as np, cv2def multi_scale_match(target, template, scales=np.arange(0.5, 2.0, 0.1)): best = (0, None, None) # (score, box, scale) for scale in scales: w = int(template.shape[1] * scale) h = int(template.shape[0] * scale) if w > target.shape[1] or h > target.shape[0]: continue resized = cv2.resize(template, (w, h)) res = cv2.matchTemplate(target, resized, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc = cv2.minMaxLoc(res) if max_val > best[0]: best = (max_val, (max_loc[0], max_loc[1], max_loc[0] + w, max_loc[1] + h), scale) return bestscore, box, scale = multi_scale_match(target, template)15-20 个尺度,能覆盖 50%-200% 的大小变化。速度成倍下降,但可接受。 方案 3:ORB 特征匹配(抗旋转、抗光照) 模板匹配的本质是像素直接对比。要抗旋转 / 光照变化 / 部分遮挡,得上特征点: import cv2orb = cv2.ORB_create(nfeatures=5000)kp1, des1 = orb.detectAndCompute(template, None) kp2, des2 = orb.detectAndCompute(target, None)matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True) matches = matcher.match(des1, des2) matches = sorted(matches, key=lambda m: m.distance)[:30]# 用 findHomography 拿到模板在目标中的位置 if len(matches) >= 10: src_pts = np.float32([kp1[m.queryIdx].pt for m in matches]).reshape(-1, 1, 2) dst_pts = np.float32([kp2[m.trainIdx].pt for m in matches]).reshape(-1, 1, 2) H, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0) # 模板四个角在目标图中的位置 h, w = template.shape[:2] corners = np.float32([[0, 0], [w, 0], [w, h], [0, h]]).reshape(-1, 1, 2) projected = cv2.perspectiveTransform(corners, H) print("目标位置四个角:", projected.reshape(-1, 2))ORB 是 SIFT 的开源替代(SIFT 有专利,OpenCV 4.4+ 已解禁但仍需 contrib) findHomography + RANSAC 会过滤掉离群点 汽车、行人、logo 这类有明显纹理的目标效果好;纯色物体(球、气球)不适合特征匹配方案 4:深度特征(更稳) 模板匹配 + ORB 都在传统 CV 范畴。想做语义级别的辅助标注,得走深度模型:DINO / DINOv2 — 自监督预训练的视觉特征提取 CLIP — 语义 embedding,跨模态匹配 SAM / SAM2 — 一键分割任意目标例如用 DINOv2 抽特征做检索: import torch from PIL import Imagedinov2 = torch.hub.load("facebookresearch/dinov2", "dinov2_vitb14") dinov2.eval().cuda()def extract(img_path): img = Image.open(img_path).convert("RGB").resize((224, 224)) x = torch.from_numpy(np.array(img)).permute(2, 0, 1).float().unsqueeze(0) / 255 with torch.no_grad(): return dinov2(x.cuda()).cpu().numpy()emb1 = extract("template.jpg") emb2 = extract("target.jpg") similarity = np.dot(emb1, emb2.T) / (np.linalg.norm(emb1) * np.linalg.norm(emb2))深度特征天生抗光照、抗轻微遮挡、语义相似的东西也能匹上。缺点是需要 GPU + 慢。 组合方案(生产推荐) 辅助标注 pipeline:快速筛选:ORB / DINOv2 找候选图片 精确定位:候选图上跑多尺度 matchTemplate 置信度过滤:只保留 score > 阈值 的框 人工审核:把框显示到 Web UI 让人 confirm比纯人工快 5-10 倍,比纯 CV 准得多。 一句话总结 辅助标注 pipeline:matchTemplate 最快但只抗平移、多尺度 matchTemplate 抗缩放、ORB 抗旋转、深度特征抗一切。从简单到复杂按需上,先跑通再优化。
SAM2 / SAM3 文本 Prompt 分割:识别图片中所有特定区域
SAM2 / SAM3 不是问答模型,而是 Promptable Concept Segmentation(PCS) 模型——用自然语言描述一个"概念",模型会找出图中所有匹配的实例,每个都生成一个 mask。 Prompt 写法原则 SAM3 理解的是名词短语(concept),不理解逻辑指令: ✅ 有效 question exam question question block text region❌ 无效 all questions detect all questions find every question on this page写"具体描述词",让模型自己找所有实例。 常用场景 Prompt 试卷 / 文档题目识别 # 基础 text_prompt = "question"# 更精准 text_prompt = "exam question" text_prompt = "question block" text_prompt = "problem statement"负例过滤(排除干扰区域) positive_prompt = "exam question text region" negative_prompt = "header, title, footer, logo, watermark"交通标志识别 text_prompt = "traffic sign" text_prompt = "road sign"物体实例分割 text_prompt = "car" text_prompt = "pedestrian" text_prompt = "building window"Python 调用示例(SAM2 + Grounding DINO 方案) 目前实现 text-prompted 分割的常见管线是 Grounding DINO + SAM2(SAM 官方的 text prompt 能力仍在完善中): from groundingdino.util.inference import load_model, load_image, predict from segment_anything import SAMPredictor# Grounding DINO 定位 boxes, logits, phrases = predict( model=gd_model, image=image, caption="exam question block", box_threshold=0.35, text_threshold=0.25 )# SAM2 分割 predictor.set_image(image) masks, scores, _ = predictor.predict( point_coords=None, point_labels=None, box=boxes, # 把 DINO 的 bbox 传给 SAM multimask_output=False )只做区域切割 + OCR 的方案 SAM 负责"切块",OCR 负责"读内容": # Step 1: SAM 找到所有"题目区域"的 mask masks = sam_segment(image, text_prompt="question block")# Step 2: 按 mask 裁剪图片区域 for i, mask in enumerate(masks): bbox = mask_to_bbox(mask) region = image[bbox[1]:bbox[3], bbox[0]:bbox[2]] # Step 3: OCR 识别文字 text = paddleocr.ocr(region) print(f"题目 {i}: {text}")OCR 推荐:PaddleOCR(中文强)、TrOCR(表格/印刷体强)、Tesseract(通用)。 SAM 的能力边界能力 SAM 支持找出所有"题目"区域 ✅生成 mask/bbox ✅理解"这是第几题" ❌读出题目内容 ❌(需要 OCR)判断题目难度 ❌(需要 LLM)SAM 只做像素级分割,不做语义理解。完整的文档结构化提取需要 SAM + OCR + LLM 组合。 一句话总结 SAM3 文本 prompt 用具体名词短语,它会自动找出所有匹配的实例。复杂场景可以加 negative prompt 排除干扰,或者组合 Grounding DINO + SAM2 + OCR 构建完整的图像理解管线。
SAM3 标注交通牌主体:正负点提示策略
用 SAM3 标注交通标志时,默认分割经常会把灯杆、支架、阴影一起圈进来。关键在于提示点的位置和正负样本的配合。 单点提示(最简方案) 把提示点打在牌面中央,SAM 通常只会分割牌面:牌型 提示点位置圆形限速牌 圆心三角警示牌 三角形中间矩形指示牌 牌面中央points = [[x_center, y_center]] labels = [1] # 1 = foreground正点 + 负点过滤杆子 如果单点还是把杆子带入,加负样本点排除: points = [ [牌面中心x, 牌面中心y], # 正样本 [杆子中间x, 杆子中间y], # 负样本 ] labels = [1, 0] # 1=前景, 0=背景实际调用: masks, scores, logits = predictor.predict( point_coords=np.array(points), point_labels=np.array(labels), multimask_output=True, )# 取得分最高的 mask best_mask = masks[np.argmax(scores)]检测框缩小(配合 Grounding DINO) Grounding DINO 给出的 bbox 通常包含了灯杆,传给 SAM 时手动收缩: def shrink_box(box, ratio=0.85): x1, y1, x2, y2 = box cx, cy = (x1 + x2) / 2, (y1 + y2) / 2 w, h = (x2 - x1) * ratio, (y2 - y1) * ratio return [cx - w/2, cy - h/2, cx + w/2, cy + h/2]tight_box = shrink_box(dino_box, ratio=0.8)masks, scores, _ = predictor.predict( point_coords=None, point_labels=None, box=np.array(tight_box), multimask_output=False, )缩小 15~20% 通常能去掉杆子区域。 mask 面积过滤 有时 SAM 返回多个候选 mask,按面积过滤掉太大或太小的: img_area = image.shape[0] * image.shape[1]valid_masks = [] for mask in masks: area = mask.sum() ratio = area / img_area if 0.01 < ratio < 0.4: # 占图片面积 1%~40% valid_masks.append(mask)交通牌通常不超过画面的 40%,过滤掉背景误判。 完整管线(Grounding DINO + SAM3) from groundingdino.util.inference import load_model, predict from segment_anything import SamPredictor# Step 1: DINO 定位交通牌 boxes, logits, phrases = predict( model=gd_model, image=image, caption="traffic sign", box_threshold=0.35, text_threshold=0.25, )# Step 2: 收缩框 tight_boxes = [shrink_box(b) for b in boxes]# Step 3: SAM 精确分割 predictor.set_image(image) for box in tight_boxes: masks, scores, _ = predictor.predict( box=np.array(box), multimask_output=False, ) # 保存 mask...调参建议 杆子还是出现 → 加负样本点在杆子上,或再缩小 bbox 5% 牌面被切掉 → bbox 缩小太多,调回 ratio=0.9 误检到路面标线 → 提高 DINO 的 box_threshold(0.35 → 0.45) 多块牌聚在一起 → multimask_output=True 后手动选面积最合适的 mask
OpenCV 识别户型图中的墙线与柱子:HoughLines、轮廓检测与 YOLO
图片/CAD 图纸方案 墙线识别 import cv2 import numpy as npimg = cv2.imread("floorplan.png") gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)# 边缘检测 edges = cv2.Canny(gray, 50, 150, apertureSize=3)# 霍夫直线检测 lines = cv2.HoughLinesP( edges, rho=1, theta=np.pi / 180, threshold=100, minLineLength=50, maxLineGap=10 )if lines is not None: for line in lines: x1, y1, x2, y2 = line[0] cv2.line(img, (x1, y1), (x2, y2), (0, 255, 0), 2)minLineLength 控制最短检测长度,maxLineGap 控制允许的断线间距。 圆柱识别 circles = cv2.HoughCircles( gray, cv2.HOUGH_GRADIENT, dp=1, minDist=20, param1=50, param2=30, minRadius=10, maxRadius=100 )if circles is not None: circles = np.uint16(circles[0]) for cx, cy, r in circles: cv2.circle(img, (cx, cy), r, (255, 0, 0), 2)方柱/立柱识别 通过轮廓检测找矩形区域: contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)for cnt in contours: area = cv2.contourArea(cnt) if area < 200: continue # 多边形逼近 epsilon = 0.02 * cv2.arcLength(cnt, True) approx = cv2.approxPolyDP(cnt, epsilon, True) # 4 个顶点且面积合理 → 矩形 → 立柱 if len(approx) == 4: x, y, w, h = cv2.boundingRect(approx) aspect = w / h if h > 0 else 0 if 0.5 < aspect < 2.0: # 长宽比合理 cv2.rectangle(img, (x, y), (x + w, y + h), (0, 0, 255), 2)3D 点云方案(激光雷达/深度图) 墙面提取(RANSAC 平面分割) import open3d as o3dpcd = o3d.io.read_point_cloud("scan.ply")# RANSAC 平面分割 plane_model, inliers = pcd.segment_plane( distance_threshold=0.01, ransac_n=3, num_iterations=1000 )wall_cloud = pcd.select_by_index(inliers) other_cloud = pcd.select_by_index(inliers, invert=True)重复调用提取多个平面(多面墙体)。 圆柱提取(PCL RANSAC) PCL 提供 SACMODEL_CYLINDER 直接拟合圆柱: pcl::SACSegmentationFromNormals<PointT, NormalT> seg; seg.setModelType(pcl::SACMODEL_CYLINDER); seg.setMethodType(pcl::SAC_RANSAC); seg.setDistanceThreshold(0.05); seg.segment(*inliers, *coefficients);Python 可通过 python-pcl 或 Open3D 的 cylinder_fit 实现。 实时摄像头方案(推荐) 传统几何算法在以下情况容易失效:图纸断线、噪声 光照变化 非标准图纸格式推荐直接用 YOLO 检测/分割模型: from ultralytics import YOLOmodel = YOLO("yolov8n-seg.pt")# 训练自定义数据集(wall / pillar / cylinder 三类) model.train(data="floorplan.yaml", epochs=100, imgsz=640)# 推理 results = model("room.jpg") results[0].show()YOLOv8-seg 直接输出分割 Mask,比 HoughLines 对真实环境更鲁棒。 方案选型建议场景 推荐方案标准黑白 CAD 图 Canny + HoughLinesP平面图圆柱 HoughCircles复杂/真实户型图 轮廓检测 + 面积过滤激光雷达点云 RANSAC 平面/圆柱拟合实时摄像头 YOLOv8-seg 训练定制模型实际项目中通常是"深度学习检测 + 几何后处理"组合使用,单纯依赖传统算法稳定性较差。
OpenCV.js 等比例模板匹配:Canvas 框选 + 局部搜索
问题场景 图 A(已标注,尺寸 W1×H1)在图 B(待匹配,尺寸 W2×H2)中找对应区域。如果 W1≠W2,直接用原始模板做 matchTemplate 会因为尺寸不匹配找错位置。 核心函数 /** * 等比例自适应模板匹配 * @param {HTMLCanvasElement} canvasA - 模板来源图(图A) * @param {HTMLCanvasElement} canvasB - 待搜索图(图B) * @param {{x,y,w,h}} cropRect - 图A中框选的区域 * @param {number} padding - 搜索区域外扩像素(默认150) */ function matchCanvasRegion(canvasA, canvasB, cropRect, padding = 150) { let matA = cv.imread(canvasA); let matB = cv.imread(canvasB); // 计算两图的尺寸比例 const scale = ((matB.cols / matA.cols) + (matB.rows / matA.rows)) / 2; // 从图A切出模板,并按比例缩放 let rawTempl = matA.roi(new cv.Rect( Math.round(cropRect.x), Math.round(cropRect.y), Math.round(cropRect.w), Math.round(cropRect.h) )); let templ = new cv.Mat(); cv.resize(rawTempl, templ, new cv.Size( Math.max(1, Math.round(cropRect.w * scale)), Math.max(1, Math.round(cropRect.h * scale)) ), 0, 0, cv.INTER_LINEAR ); rawTempl.delete(); // 在图B中计算模板的理论位置,加 padding 限定搜索 ROI const mappedX = cropRect.x * scale; const mappedY = cropRect.y * scale; const roiX = Math.max(0, Math.round(mappedX - padding)); const roiY = Math.max(0, Math.round(mappedY - padding)); const roiW = Math.min(matB.cols - roiX, templ.cols + padding * 2); const roiH = Math.min(matB.rows - roiY, templ.rows + padding * 2); let roi = matB.roi(new cv.Rect(roiX, roiY, roiW, roiH)); // 模板匹配(TM_SQDIFF_NORMED 取最小值) let resultMat = new cv.Mat(); let mask = new cv.Mat(); cv.matchTemplate(roi, templ, resultMat, cv.TM_SQDIFF_NORMED, mask); const result = cv.minMaxLoc(resultMat); // 局部坐标转全图坐标 const finalX = result.minLoc.x + roiX; const finalY = result.minLoc.y + roiY; // 清理 Mat,避免内存泄漏 roi.delete(); resultMat.delete(); mask.delete(); templ.delete(); matA.delete(); matB.delete(); return { x: finalX, y: finalY, width: templ.cols, height: templ.rows, score: result.minVal, // 越小越匹配 scale }; }Canvas 框选实现 let isDragging = false; let startX, startY;canvas.addEventListener("mousedown", e => { isDragging = true; const rect = canvas.getBoundingClientRect(); startX = e.clientX - rect.left; startY = e.clientY - rect.top; });canvas.addEventListener("mousemove", e => { if (!isDragging) return; const rect = canvas.getBoundingClientRect(); const curX = e.clientX - rect.left; const curY = e.clientY - rect.top; // 绘制选择框预览 drawSelectionBox(startX, startY, curX - startX, curY - startY); });canvas.addEventListener("mouseup", e => { isDragging = false; const rect = canvas.getBoundingClientRect(); const endX = e.clientX - rect.left; const endY = e.clientY - rect.top; cropRect = { x: Math.min(startX, endX), y: Math.min(startY, endY), w: Math.abs(endX - startX), h: Math.abs(endY - startY) }; });canvas 显示尺寸和图像实际尺寸可能不同(canvas.width vs CSS 宽度),传给 matchCanvasRegion 前需换算: const scaleX = canvas.width / canvas.offsetWidth; const scaleY = canvas.height / canvas.offsetHeight; const realCrop = { x: cropRect.x * scaleX, y: cropRect.y * scaleY, w: cropRect.w * scaleX, h: cropRect.h * scaleY };关键设计点 为什么用 TM_SQDIFF_NORMED:该方法返回差异值(越小越匹配),对光照变化比 TM_CCOEFF_NORMED 稍宽容,且结果在 0~1 之间便于设置阈值。 为什么限定 ROI 搜索:全图 matchTemplate 在高分辨率图上耗时长。图 A 和图 B 通常是同一场景的不同分辨率版本,目标位置按比例平移,padding 150px 足以覆盖拍摄角度差异。 内存管理:OpenCV.js 是 WebAssembly,new cv.Mat() 分配的内存不受 JS GC 管理,每次使用后必须手动 .delete(),否则会逐渐 OOM。
浏览器里动态加载 opencv.js:等 onRuntimeInitialized
想在网页里用 OpenCV.js 做图像处理,标准写法是: <script async src="https://docs.opencv.org/4.x/opencv.js" onload="onOpenCvReady();"></script>想改成 JS 动态加载(比如按需加载、或者写扩展 / 油猴脚本),有几个坑。 基础动态加载 const script = document.createElement("script"); script.src = "https://docs.opencv.org/4.x/opencv.js"; script.async = true; script.onload = () => onOpenCvReady(); document.head.appendChild(script);但这个 onload 触发时,OpenCV 还没准备好——window.cv 存在,但里面的 native 函数还没初始化(emscripten 编译的 WASM 需要额外时间)。 正确姿势:等 onRuntimeInitialized script.onload = () => { cv.onRuntimeInitialized = () => { console.log("OpenCV Ready"); onOpenCvReady(); }; };cv.onRuntimeInitialized 是 emscripten 的钩子,WASM 加载并 instantiate 完毕后调用。这时 cv.imread、cv.Mat 才能真正用。 Promise 化封装 function loadOpenCV() { return new Promise((resolve, reject) => { // 已加载过 if (window.cv && cv.Mat) return resolve(cv); // 加载中(多次调用不重复插 script) if (window.cv) { cv.onRuntimeInitialized = () => resolve(cv); return; } const script = document.createElement("script"); script.src = "https://docs.opencv.org/4.x/opencv.js"; script.async = true; script.onload = () => { cv.onRuntimeInitialized = () => resolve(cv); }; script.onerror = reject; document.head.appendChild(script); }); }// 用 loadOpenCV().then(cv => { const mat = new cv.Mat(100, 100, cv.CV_8UC3); console.log(mat); });关键点:if (window.cv && cv.Mat) — 双重判断,cv 存在不代表 ready 支持并发调用(多次 loadOpenCV() 只加载一次真正的 script) 错误传给 reject(网络挂了能捕获)油猴脚本里加载 油猴脚本里 document.createElement("script") 遇到有 CSP(Content-Security-Policy)的网站会失败——unsafe-inline 被禁的情况下,外链 script 加载被阻。 用 @require 声明,油猴会把脚本预先拉下来注入到你的沙箱里: // ==UserScript== // @name Load OpenCV // @match *://*/* // @require https://docs.opencv.org/4.x/opencv.js // @grant none // ==/UserScript==(function () { "use strict"; cv.onRuntimeInitialized = () => { console.log("OpenCV Ready"); const mat = new cv.Mat(100, 100, cv.CV_8UC3); console.log(mat); }; })();@require 走的是 Tampermonkey / Violentmonkey / ScriptCat 自己的加载机制,不走页面 CSP。这是给受 CSP 保护的网站注 OpenCV 最稳的方式。 常见问题 1. cv is not defined script 还没加载完就用 cv。用 Promise 版本,.then 里再用。 2. cv.imread is not a function onRuntimeInitialized 没等——script.onload 触发早于 runtime init。 3. 用了 async/await 但还是异步崩溃 外面 async 函数忘了 await: async function process() { const cv = await loadOpenCV(); // 别忘 await const mat = new cv.Mat(...); }4. 内存泄漏 OpenCV.js 的 Mat 是 WASM 分配的内存,必须手动 delete: const mat = cv.imread("input"); try { // 处理 mat } finally { mat.delete(); }不 delete,跑几张图页面就 OOM。 想省点带宽 opencv.js 从官方 CDN 拉大约 8MB。有几个选择:换镜像:https://cdn.jsdelivr.net/npm/@techstark/opencv-js@4.x.x/dist/opencv.js 裁剪版本:自己 build 只带需要的模块,webnn、videoio 之类不需要就砍 本地缓存:Service Worker 缓存 opencv.js,二次加载秒开 动态判断需求:只有用户点了"图像处理"按钮再加载第一次加载 8MB 是常态,别期望闪电般。 一句话总结 动态加载 opencv.js 要等 cv.onRuntimeInitialized,封成 Promise 用起来最顺。油猴脚本里遇到 CSP 就用 @require。用完 Mat 记得 delete()。
