Jetson Nano 双摄像头芯片检测视觉系统:小尺度难定位问题解决,从零开始实现教程说明

本文最终能实现什么

在 Jetson Nano 上同时运行两路 USB 摄像头:

  • LOW 摄像头/dev/video0,1280×720):俯视整张工作台,在绿色传送带上定位芯片位置 (中心坐标 cx, cy、旋转角度 angle
  • HD 摄像头/dev/video2,1920×1080):近距离俯视取料区,提供高精度抓取定位

程序在 Jetson 的显示器上展示 2×2 四格实时预览窗口(原图+检测框、二值图),同时以 7~8 FPS 在终端输出检测坐标,可直接供机械臂控制程序调用。


适合谁阅读

  • 参加工业机器人相关竞赛、需要集成视觉定位的团队
  • 在 Jetson Nano / Jetson 系列设备上做双摄像头采集的工程师
  • 需要在绿色背景(传送带)上检测颜色相近小物件的视觉工程师
  • 了解 Python 和 OpenCV 基础,希望快速上手嵌入式视觉的开发者

前置知识和环境准备

硬件

项目 要求
主控 Jetson Nano(本教程在 Jetson Nano 4GB 上验证)
LOW 摄像头 USB 普通摄像头,/dev/video0,支持 MJPEG 1280×720@30fps
HD 摄像头 USB 高清摄像头,/dev/video2,支持 MJPEG 1920×1080@30fps
连接方式 两个摄像头分插在不同 USB 控制器端口(避免同一 USB Hub 带宽竞争)
开发机 Windows 10/11 PC,通过同一局域网 SSH 连接 Jetson

重要:USB 带宽问题

两路摄像头同时在 1920×1080 MJPEG 下采集,会争抢 USB 总线带宽,导致 LOW 摄像头输出全黑帧。解决方案是将 LOW 摄像头降至 1280×720,HD 摄像头保持 1920×1080,两路均能稳定工作。

软件(Jetson 端)

bash 复制代码
# 检查是否已安装 ffmpeg
ffmpeg -version

# 检查 Python 环境(使用 miniforge conda)
conda activate robot
python -c "import cv2; print(cv2.__version__)"

# 检查摄像头设备
ls /dev/video*
v4l2-ctl -d /dev/video0 --list-formats-ext | head -20

软件(开发机 Windows 端)

bash 复制代码
# 需要安装 paramiko(用于 SSH/SFTP 部署)
# 如果是 Anaconda yolo 环境已包含:
conda activate yolo
python -c "import paramiko; print('OK')"

项目文件说明

复制代码
Robot/
└── vision/
    ├── dual_cam_preview.py      # 主程序:双摄采集 + 芯片检测 + 预览窗口
    └── deploy_dual_preview.py   # 部署脚本:从 Windows 一键上传并启动
文件 作用
dual_cam_preview.py 部署到 Jetson 上运行,包含采集线程、检测算法、显示逻辑
deploy_dual_preview.py 在 Windows 开发机上运行,自动上传文件、杀掉旧进程、后台启动新实例

总体方案设计

复制代码
Windows 开发机
    │  deploy_dual_preview.py (paramiko SSH/SFTP)
    │  上传 → 启动 → tail 日志
    ▼
Jetson Nano
    ├─ CaptureThread (ffmpeg → pipe → numpy)  ← /dev/video0 LOW 1280×720
    ├─ CaptureThread (ffmpeg → pipe → numpy)  ← /dev/video2 HD  1920×1080
    │
    ├─ process_frame(LOW_CFG) → Canny 边缘检测 → 轮廓筛选 → cx, cy, angle
    ├─ process_frame(HD_CFG)  → HSV 颜色检测  → 轮廓筛选 → cx, cy, angle
    │
    └─ cv2.imshow()  →  Jetson 显示器(2×2 四格窗口)

两路摄像头采用不同检测策略,原因是:

  • HD 摄像头拍摄的芯片与绿色背景颜色差异明显,传统 HSV 颜色阈值法即可
  • LOW 摄像头拍摄整个工作台,经实测芯片的 HSV 与绿色传送带完全一致(H=70, S=165~197),颜色法完全失效,必须改用 Canny 边缘检测

核心原理解释

1. ffmpeg pipe 采集(替代 OpenCV VideoCapture)

OpenCV 的 VideoCapture 在 Jetson 上偶尔出现丢帧、格式不兼容等问题。本方案改用 ffmpeg 以 rawvideo/bgr24 格式通过管道输出,再用 numpy.frombuffer 转成帧矩阵。

可以把 ffmpeg 理解为一个"视频格式翻译官":摄像头输出 MJPEG 压缩帧,ffmpeg 解压后以未压缩的 BGR 原始字节流输出,Python 端按固定帧字节数读取即可,不需要关心解码细节。

python 复制代码
cmd = [
    "ffmpeg", "-f", "v4l2",
    "-input_format", "mjpeg",
    "-video_size", "1280x720",
    "-framerate", "30",
    "-i", "/dev/video0",
    "-f", "rawvideo", "-pix_fmt", "bgr24", "-an", "pipe:1",
]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
                        bufsize=1280*720*3*4)
# 每次读取一帧的字节数
fbytes = 1280 * 720 * 3
raw = proc.stdout.read(fbytes)
frame = np.frombuffer(raw, dtype=np.uint8).reshape(720, 1280, 3)

2. 生产者-消费者采集线程

采集是 IO 密集型任务,处理(Canny、轮廓检测)是 CPU 密集型任务。两者用独立线程 + size=1 队列解耦:

  • 采集线程持续从 ffmpeg 读帧,放入队列
  • 主线程从队列取最新帧处理
  • 队列满时丢弃旧帧(保证实时性,不积压)
python 复制代码
class CaptureThread:
    def __init__(self, dev, label, w, h, fps):
        # 启动 ffmpeg 子进程
        self._q = queue.Queue(maxsize=1)   # 只保留最新一帧
        threading.Thread(target=self._loop, daemon=True).start()

    def _loop(self):
        while self._running:
            raw = self._proc.stdout.read(self._fbytes)
            frame = np.frombuffer(raw, dtype=np.uint8).reshape(...)
            if self._q.full():
                self._q.get_nowait()   # 丢弃旧帧
            self._q.put(frame.copy())

3. LOW 相机:Canny 边缘检测定位芯片

为什么颜色检测失效?

通过 HSV 采样可以发现,传送带绿色的 HSV 范围是 H=6972、S=163197、V=74~120。实测放置芯片后,芯片位置的 HSV 值与背景完全一致(例如 H=70, S=197, V=101),说明芯片(绿色 PCB)与传送带颜色无法区分。

Canny 边缘检测原理

Canny 算法检测图像中亮度变化剧烈的位置(即"边缘")。即使芯片颜色与背景相同,芯片的物理边缘(板卡边界、引脚、高度差产生的阴影)依然会产生亮度梯度。Canny 可以在不依赖颜色的前提下找到这些边缘。

关键踩坑:ROI 必须在形态学操作之前应用

如果先做形态学操作(膨胀 + 闭合),传送带导轨的边缘会被膨胀连接成一个覆盖整个区域的巨型轮廓(实测面积达 115000px),导致找不到芯片。正确顺序:

复制代码
Canny 边缘 → 先裁剪 ROI → 再膨胀 → 再闭合 → 轮廓检测

关键踩坑:禁用 CLAHE

CLAHE(对比度限制的自适应直方图均衡化)本意是增强对比度,但在边缘检测模式下,它会在平滑的绿色区域制造大量人工纹理,导致 Canny 检测到 85% 的像素都是"边缘",完全淹没真实的芯片边缘。边缘模式下必须 use_clahe=False

关键踩坑:Canny 阈值要足够高

摄像头采集的 MJPEG 图像在解码后包含 8×8 像素块的压缩伪影。低 Canny 阈值(如 15/45)会把这些伪影全部检测为边缘,填满 ROI。必须使用较高阈值(如 40/120),只保留真实强边缘。

4. HD 相机:HSV 颜色检测

HD 摄像头近距离俯视取料区,芯片与背景颜色差异大,传统 HSV 阈值法可靠。流程:

复制代码
图像 → CLAHE 增强 → 高斯模糊 → HSV 转换 → 绿色阈值掩码 → bitwise_not(取非绿区域)
     → 形态学闭合(填充背景纹理空洞)→ 形态学开运算(去除噪点)→ 轮廓检测

5. 轮廓筛选:多维度过滤假阳性

找到所有轮廓后,通过以下指标按序过滤:

指标 含义 目的
area_min / area_max 轮廓面积范围 排除噪点(太小)和非目标大物体(太大)
solidity = 面积/凸包面积 形状紧凑度 排除细长、分叉的噪声轮廓
aspect = 长边/短边 长宽比 排除极细长的线段
rect_fill = 面积/最小外接矩形面积 矩形填充率 确保形状接近矩形

最终得分 score = area / aspect²,得分最高的轮廓作为检测目标。


完整代码

vision/dual_cam_preview.py(Jetson 端主程序)

python 复制代码
"""
双摄像头实时预览 + 绿色背景二值化 + 芯片检测定位
布局(2×2):
  左上: 低清摄像头原图 + 检测框   右上: 高清摄像头原图 + 检测框
  左下: 低清二值化结果            右下: 高清二值化结果
"""

import cv2
import numpy as np
import subprocess
import threading
import queue
import time

# ════════════════════════════════════════════════════════════════
#  摄像头配置
# ════════════════════════════════════════════════════════════════
CAM_LOW  = "/dev/video0"   # 低清摄像头
CAM_HD   = "/dev/video2"   # 高清摄像头

# 低清摄像头降分辨率,避免双流 USB 带宽竞争导致黑帧
LOW_W, LOW_H, LOW_FPS = 1280, 720, 30
HD_W,  HD_H,  HD_FPS  = 1920, 1080, 30

# 每格统一显示尺寸
DISP_W = 960
DISP_H = 540

WINDOW = "双摄预览 | Low(左) HD(右) | 原图(上) 二值(下)"


# ════════════════════════════════════════════════════════════════
#  每路摄像头独立参数
# ════════════════════════════════════════════════════════════════
# --- 低清摄像头(俯视整个工作台,传送带在图像中间竖条区域)---
# 芯片与传送带同色(绿色),颜色检测失效→改用边缘检测
# ROI:传送带绿色带(x:0.43-0.57),全高(y:0.05-0.95)
LOW_CFG = dict(
    proc_scale   = 1.0,
    blur_k       = 9,       # 较大blur消除JPEG压缩块噪声,保留真实芯片边缘
    use_edge     = True,    # 使用Canny边缘检测,不依赖颜色
    # ROI排除顶底固定件(y:0.20-0.70仅传送带绿色面)
    roi          = (0.43, 0.20, 0.57, 0.70),
    use_clahe    = False,   # 边缘模式禁用CLAHE:CLAHE会制造大量假边缘
    clahe_clip   = 1.0,
    clahe_tile   = (8, 8),
    _label       = 'LOW',
    # Canny高阈值过滤JPEG压缩块噪声,只保留强边缘(芯片阴影/轮廓)
    canny_lo     = 40,
    canny_hi     = 120,
    edge_dilate  = 3,
    edge_close   = 9,
    # 芯片面积(全分辨率,边缘闭合后约80-1200px)
    area_min     = 80,
    area_max     = 3000,
    aspect_max   = 4.0,
    solidity_min = 0.30,
    rect_fill    = 0.15,
    # 颜色检测参数保留(use_edge=False时备用)
    detect_bg    = True,
    bg_h_low     = 50,
    bg_h_high    = 90,
    bg_s_min     = 60,
    bg_v_min     = 50,
    close_k      = 15,
    open_k       = 3,
)

# --- 高清摄像头(俯视取料,参数沿用原主程序)---
HD_CFG = dict(
    proc_scale   = 0.5,
    blur_k       = 9,
    detect_bg    = True,    # 俯视:绿色背景取反
    bg_h_low     = 35,
    bg_h_high    = 85,
    bg_s_min     = 40,
    bg_v_min     = 40,
    close_k      = 13,
    open_k       = 5,
    area_min     = 11000,
    area_max     = 450000,
    aspect_max   = 2.0,
    solidity_min = 0.60,
    rect_fill    = 0.50,
    clahe_clip   = 2.0,
    clahe_tile   = (8, 8),
)


# ════════════════════════════════════════════════════════════════
#  ffmpeg 采集线程
# ════════════════════════════════════════════════════════════════
class CaptureThread:
    def __init__(self, dev, label="CAM", w=1920, h=1080, fps=30):
        cmd = [
            "ffmpeg", "-f", "v4l2",
            "-input_format", "mjpeg",
            "-video_size", f"{w}x{h}",
            "-framerate", str(fps),
            "-i", dev,
            "-f", "rawvideo", "-pix_fmt", "bgr24", "-an", "pipe:1",
        ]
        self._w = w; self._h = h
        self._proc = subprocess.Popen(
            cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
            bufsize=w * h * 3 * 4,
        )
        self._fbytes  = w * h * 3
        self._q       = queue.Queue(maxsize=1)
        self._running = True
        self._label   = label
        threading.Thread(target=self._loop, daemon=True).start()
        print(f"[{label}] 启动: {dev} {w}x{h}@{fps}fps")

    def _loop(self):
        while self._running:
            raw = self._proc.stdout.read(self._fbytes)
            if len(raw) != self._fbytes:
                print(f"[{self._label}] 采集流中断")
                break
            frame = np.frombuffer(raw, dtype=np.uint8).reshape(self._h, self._w, 3)
            if self._q.full():
                try: self._q.get_nowait()
                except queue.Empty: pass
            self._q.put(frame.copy())

    def read(self):
        try:    return self._q.get(timeout=1.0)
        except queue.Empty: return None

    def release(self):
        self._running = False
        self._proc.terminate()


# ════════════════════════════════════════════════════════════════
#  绿色背景二值化 + 芯片检测
# ════════════════════════════════════════════════════════════════
def process_frame(frame: np.ndarray, cfg: dict):
    """
    返回:
      binary  --- 半分辨率二值图(灰度)
      result  --- 在原图尺寸上的检测结果 dict 或 None
                keys: cx, cy, angle, box, area, w_px, h_px
      dbg_info --- 字符串,供打印调试
    """
    scale = cfg['proc_scale']
    inv   = 1.0 / scale
    sh    = int(frame.shape[0] * scale)
    sw    = int(frame.shape[1] * scale)
    small = cv2.resize(frame, (sw, sh), interpolation=cv2.INTER_AREA)

    # CLAHE 增强(可通过 use_clahe=False 跳过)
    if cfg.get('use_clahe', True):
        clahe = cv2.createCLAHE(clipLimit=cfg['clahe_clip'],
                                 tileGridSize=cfg['clahe_tile'])
        lab = cv2.cvtColor(small, cv2.COLOR_BGR2LAB)
        l, a, b = cv2.split(lab)
        small_eq = cv2.cvtColor(cv2.merge([clahe.apply(l), a, b]),
                                 cv2.COLOR_LAB2BGR)
    else:
        small_eq = small

    # 高斯模糊
    bk = cfg['blur_k']
    blurred = cv2.GaussianBlur(small_eq, (bk, bk), 0)

    # 调试:定期打印传送带中轴线多点 HSV(每100帧),用于识别芯片颜色
    if not hasattr(process_frame, '_dbg_cnt'):
        process_frame._dbg_cnt = {}
    lbl = cfg.get('_label', '?')
    process_frame._dbg_cnt[lbl] = process_frame._dbg_cnt.get(lbl, 0) + 1
    if lbl == 'LOW' and process_frame._dbg_cnt[lbl] % 100 == 1:
        hsv_dbg = cv2.cvtColor(blurred, cv2.COLOR_BGR2HSV)
        cx_belt = int(0.50 * sw)   # 传送带中轴 x
        print(f"[HSV-SCAN] belt-center x={cx_belt} (frame {process_frame._dbg_cnt[lbl]}):")
        for yf in [0.15, 0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.85]:
            py = int(yf * sh)
            hv = tuple(hsv_dbg[py, cx_belt].tolist())
            print(f"  y={yf:.2f}({py}px) HSV={hv}")

    if cfg.get('use_edge', False):
        # 边缘检测模式:适合芯片与背景同色的场景(不依赖颜色)
        gray  = cv2.cvtColor(small_eq, cv2.COLOR_BGR2GRAY)
        edges = cv2.Canny(gray, cfg.get('canny_lo', 15), cfg.get('canny_hi', 45))
        # 关键:先用ROI掩码裁剪Canny结果,再做形态学
        # 避免传送带边框/导轨边缘经dilate+close合并成巨型blob
        _roi = cfg.get('roi', None)
        if _roi is not None:
            _x1f, _y1f, _x2f, _y2f = _roi
            _rx1 = int(_x1f * sw); _rx2 = int(_x2f * sw)
            _ry1 = int(_y1f * sh); _ry2 = int(_y2f * sh)
            _roi_e = np.zeros_like(edges)
            _roi_e[_ry1:_ry2, _rx1:_rx2] = 255
            edges = cv2.bitwise_and(edges, _roi_e)
        # 膨胀连接断裂芯片边缘
        kd = cv2.getStructuringElement(cv2.MORPH_RECT,
                                       (cfg.get('edge_dilate', 4),)*2)
        binary = cv2.dilate(edges, kd, iterations=1)
        # 闭操作填充芯片矩形内部
        kc2 = cv2.getStructuringElement(cv2.MORPH_RECT,
                                        (cfg.get('edge_close', 7),)*2)
        binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kc2, iterations=2)
        # 开操作去除细线噪声
        ko2 = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
        binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, ko2, iterations=1)
    else:
        # HSV 掩码:detect_bg=True 取反(绿色背景取非绿),False 直接取绿色芯片
        hsv    = cv2.cvtColor(blurred, cv2.COLOR_BGR2HSV)
        lo     = np.array([cfg['bg_h_low'],  cfg['bg_s_min'], cfg['bg_v_min']])
        hi     = np.array([cfg['bg_h_high'], 255,             255])
        mask   = cv2.inRange(hsv, lo, hi)
        binary = cv2.bitwise_not(mask) if cfg.get('detect_bg', True) else mask
        # 形态学
        kc = cv2.getStructuringElement(cv2.MORPH_RECT,
                                       (cfg['close_k'], cfg['close_k']))
        binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kc, iterations=2)
        ko = cv2.getStructuringElement(cv2.MORPH_RECT,
                                       (cfg['open_k'], cfg['open_k']))
        binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, ko, iterations=1)

    # ROI 掩码(先于轮廓检测)
    roi = cfg.get('roi', None)
    if roi is not None:
        x1f, y1f, x2f, y2f = roi
        rx1 = int(x1f * sw); rx2 = int(x2f * sw)
        ry1 = int(y1f * sh); ry2 = int(y2f * sh)
        roi_mask = np.zeros_like(binary)
        roi_mask[ry1:ry2, rx1:rx2] = 255
        binary = cv2.bitwise_and(binary, roi_mask)

    # 轮廓检测
    contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL,
                                   cv2.CHAIN_APPROX_SIMPLE)

    # debug: mask whiteness + raw contour sizes
    mask_pct  = np.count_nonzero(binary) * 100.0 / binary.size
    raw_areas = sorted([int(cv2.contourArea(c)) for c in contours], reverse=True)[:6]

    best_cnt, best_score = None, 0.0
    all_areas = []

    for cnt in contours:
        area = cv2.contourArea(cnt)
        if not (cfg['area_min'] <= area <= cfg['area_max']):
            continue
        all_areas.append(int(area))

        hull      = cv2.convexHull(cnt)
        hull_area = cv2.contourArea(hull)
        solidity  = area / hull_area if hull_area > 0 else 0
        if solidity < cfg['solidity_min']:
            continue

        rect = cv2.minAreaRect(hull)
        rw, rh = rect[1]
        if min(rw, rh) < 3:
            continue
        aspect = max(rw, rh) / (min(rw, rh) + 1e-6)
        if aspect > cfg['aspect_max']:
            continue
        rect_fill = area / (rw * rh + 1e-6)
        if rect_fill < cfg['rect_fill']:
            continue

        score = area / (aspect ** 2)
        if score > best_score:
            best_score = score
            best_cnt   = cnt

    dbg_info = f"mask={mask_pct:.1f}% raw={raw_areas} pass={all_areas[:5]}"

    if best_cnt is None:
        return binary, None, dbg_info

    hull = cv2.convexHull(best_cnt)
    rect = cv2.minAreaRect(hull)
    cx_s, cy_s   = rect[0]
    rw_s, rh_s   = rect[1]
    angle_raw    = rect[2]
    angle        = angle_raw + 90.0 if rw_s < rh_s else angle_raw
    box_s        = cv2.boxPoints(rect).astype(int)

    # 放大回全分辨率
    cx   = cx_s  * inv
    cy   = cy_s  * inv
    box  = (box_s * inv).astype(int)
    w_px = max(rw_s, rh_s) * inv
    h_px = min(rw_s, rh_s) * inv
    area_full = cv2.contourArea(best_cnt) * (inv ** 2)

    result = dict(cx=cx, cy=cy, angle=angle,
                  box=box, area=area_full,
                  w_px=w_px, h_px=h_px)
    dbg_info = f"OK  cx={cx:.0f} cy={cy:.0f} ang={angle:.1f} area={area_full:.0f} mask={mask_pct:.1f}%"
    return binary, result, dbg_info


def draw_detection(img: np.ndarray, result, label: str, color) -> np.ndarray:
    """在原图(全分辨率)上绘制检测框、中心点、信息"""
    vis = img.copy()
    h, w = vis.shape[:2]

    # 十字中心线
    cx_img, cy_img = w // 2, h // 2
    cv2.line(vis, (cx_img, 0), (cx_img, h), (60, 60, 60), 1)
    cv2.line(vis, (0, cy_img), (w, cy_img), (60, 60, 60), 1)

    if result is not None:
        cx, cy = int(result['cx']), int(result['cy'])
        box    = result['box']
        angle  = result['angle']
        w_px   = result['w_px']
        h_px   = result['h_px']

        # 旋转框
        cv2.drawContours(vis, [box], 0, color, 2)

        # 中心十字(醒目)
        arm = 25
        cv2.line(vis, (cx - arm, cy), (cx + arm, cy), color, 2)
        cv2.line(vis, (cx, cy - arm), (cx, cy + arm), color, 2)
        cv2.circle(vis, (cx, cy), 6, color, -1)

        # 偏移量(相对图像中心)
        dx = cx - cx_img
        dy = cy - cy_img

        # 信息文字
        lines = [
            f"{label} FOUND",
            f"PX ({cx}, {cy})",
            f"d ({dx:+d}, {dy:+d})",
            f"{int(w_px)}x{int(h_px)}px  ang={angle:.1f}",
        ]
        for i, txt in enumerate(lines):
            y0 = h - 12 - (len(lines) - 1 - i) * 22
            cv2.putText(vis, txt, (8, y0),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 1, cv2.LINE_AA)
    else:
        cv2.putText(vis, f"{label} NO CHIP", (8, h - 12),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.65, (60, 60, 255), 2)

    return vis


# ════════════════════════════════════════════════════════════════
#  主程序
# ════════════════════════════════════════════════════════════════
def main():
    print("=" * 55)
    print("  双摄像头预览 + 芯片检测定位")
    print("  按 Q / ESC 退出")
    print("=" * 55)

    cam_low = CaptureThread(CAM_LOW, "LOW", w=LOW_W, h=LOW_H, fps=LOW_FPS)
    cam_hd  = CaptureThread(CAM_HD,  "HD ",  w=HD_W,  h=HD_H,  fps=HD_FPS)

    cv2.namedWindow(WINDOW, cv2.WINDOW_NORMAL)
    cv2.resizeWindow(WINDOW, 1920, 1080)
    cv2.moveWindow(WINDOW, 0, 0)   # 确保窗口出现在屏幕左上角

    fps_t0, fps_cnt, fps = time.perf_counter(), 0, 0.0
    blank_low = np.zeros((LOW_H, LOW_W, 3), dtype=np.uint8)
    blank_hd  = np.zeros((HD_H,  HD_W,  3), dtype=np.uint8)

    while True:
        f_low = cam_low.read()
        f_hd  = cam_hd.read()
        if f_low is None: f_low = blank_low.copy()
        if f_hd  is None: f_hd  = blank_hd.copy()

        # 处理两路
        bin_low, res_low, dbg_low = process_frame(f_low, LOW_CFG)
        bin_hd,  res_hd,  dbg_hd  = process_frame(f_hd,  HD_CFG)

        # 打印调试(每2秒)+ 每10秒保存一次 LOW 二值图用于参数调优
        fps_cnt += 1
        elapsed = time.perf_counter() - fps_t0
        if elapsed >= 2.0:
            fps     = fps_cnt / elapsed
            fps_cnt = 0
            fps_t0  = time.perf_counter()
            print(f"[LOW] {dbg_low}")
            print(f"[HD ] {dbg_hd}")
            print(f"[FPS] {fps:.1f}")
            if not hasattr(main, '_save_cnt'):
                main._save_cnt = 0
            main._save_cnt += 1
            if main._save_cnt % 5 == 1:   # 每 5×2=10秒保存一次
                cv2.imwrite("/tmp/low_bin_live.jpg", bin_low)
                cv2.imwrite("/tmp/low_raw_live.jpg",
                            cv2.resize(f_low, (640, 360)))

        # 在全分辨率原图上绘制检测
        raw_low_det = draw_detection(f_low, res_low, "LOW", (100, 255, 100))
        raw_hd_det  = draw_detection(f_hd,  res_hd,  "HD",  (100, 200, 255))

        # 二值图转 BGR
        bin_low_bgr = cv2.cvtColor(bin_low, cv2.COLOR_GRAY2BGR)
        bin_hd_bgr  = cv2.cvtColor(bin_hd,  cv2.COLOR_GRAY2BGR)

        # 在二值图上也标中心点(半分辨率坐标)
        if res_low:
            s = LOW_CFG['proc_scale']
            pt = (int(res_low['cx'] * s), int(res_low['cy'] * s))
            cv2.circle(bin_low_bgr, pt, 8, (0, 255, 0), -1)
        if res_hd:
            s = HD_CFG['proc_scale']
            pt = (int(res_hd['cx'] * s), int(res_hd['cy'] * s))
            cv2.circle(bin_hd_bgr, pt, 8, (0, 255, 0), -1)

        # 统一缩放到 DISP_W×DISP_H
        tl = cv2.resize(raw_low_det, (DISP_W, DISP_H))
        tr = cv2.resize(raw_hd_det,  (DISP_W, DISP_H))
        bl = cv2.resize(bin_low_bgr, (DISP_W, DISP_H))
        br = cv2.resize(bin_hd_bgr,  (DISP_W, DISP_H))

        # 标签
        def label(img, txt, col):
            cv2.rectangle(img, (0, 0), (len(txt)*10+8, 24), (20,20,20), -1)
            cv2.putText(img, txt, (4, 17), cv2.FONT_HERSHEY_SIMPLEX,
                        0.55, col, 1, cv2.LINE_AA)
            return img

        tl = label(tl, "LOW | RAW+DETECT",    (100, 255, 100))
        tr = label(tr, "HD  | RAW+DETECT",    (100, 200, 255))
        bl = label(bl, "LOW | BINARY",         (100, 255, 100))
        br = label(br, "HD  | BINARY",         (100, 200, 255))

        canvas = np.vstack([np.hstack([tl, tr]), np.hstack([bl, br])])

        # 顶栏
        cv2.rectangle(canvas, (0, 0), (canvas.shape[1], 22), (30,30,30), -1)
        low_st = "FOUND" if res_low else "NONE"
        hd_st  = "FOUND" if res_hd  else "NONE"
        cv2.putText(canvas,
            f"LOW:{low_st}  HD:{hd_st}  {fps:.1f}FPS  [Q]退出",
            (6, 16), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (200,200,200), 1)

        cv2.imshow(WINDOW, canvas)
        key = cv2.waitKey(1) & 0xFF
        if key in (ord('q'), ord('Q'), 27):
            break

    cam_low.release()
    cam_hd.release()
    cv2.destroyAllWindows()
    print("[INFO] 已退出")


if __name__ == "__main__":
    main()

vision/deploy_dual_preview.py(Windows 端部署脚本)

python 复制代码
"""
上传 dual_cam_preview.py 到 Jetson 并运行
程序以 nohup 后台方式启动,断开SSH后仍在Jetson上运行并显示窗口
"""
import paramiko
import os
import sys
import time

HOST = "192.168.4.5"
USER = "nvidia"
PASS = "nvidia"
LOCAL_FILE  = os.path.join(os.path.dirname(__file__), "dual_cam_preview.py")
REMOTE_FILE = "/home/nvidia/dual_cam_preview.py"
LOG_FILE    = "/tmp/dual_cam_preview.log"

def run(client, cmd):
    _, o, e = client.exec_command(cmd)
    return o.read().decode('utf-8', errors='replace')

def main():
    print(f"[SSH] 连接 {USER}@{HOST} ...")
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    client.connect(HOST, username=USER, password=PASS, timeout=10)
    print("[SSH] 连接成功")

    print(f"[SCP] 上传 {LOCAL_FILE} -> {REMOTE_FILE}")
    sftp = client.open_sftp()
    sftp.put(LOCAL_FILE, REMOTE_FILE)
    sftp.close()
    print("[SCP] 上传完成")

    CONDA = "/home/nvidia/miniforge3/bin/conda"

    # 杀掉旧实例
    print("[INFO] 杀掉旧进程...")
    run(client, "pkill -f dual_cam_preview.py 2>/dev/null; pkill -f arm_tracker_combined.py 2>/dev/null")
    time.sleep(2)

    # 用 nohup 后台启动,日志写到 /tmp/dual_cam_preview.log
    # 程序独立于SSH会话运行,关闭此脚本窗口后仍显示在Jetson屏幕上
    launch_cmd = (
        f"nohup bash -c '"
        f"export DISPLAY=:1; "
        f"export XAUTHORITY=/home/nvidia/.Xauthority; "
        f"cd /home/nvidia; "
        f"{CONDA} run --no-capture-output -n robot python dual_cam_preview.py"
        f"' > {LOG_FILE} 2>&1 &"
    )
    print("[INFO] 后台启动双摄预览 (nohup, DISPLAY=:1)...")
    run(client, launch_cmd)
    time.sleep(3)   # 等程序初始化

    # 检查进程是否启动
    pid_out = run(client, "pgrep -f dual_cam_preview.py")
    if pid_out.strip():
        print(f"[OK] 程序已启动, PID={pid_out.strip()}")
    else:
        print("[WARN] 未找到进程,可能启动失败,查看日志:")
        print(run(client, f"tail -30 {LOG_FILE}"))
        client.close()
        return

    # 实时监听日志(Ctrl+C 断开监听但程序继续运行)
    print(f"[INFO] 实时日志输出(Ctrl+C 停止监听,程序仍在Jetson运行):")
    print("-" * 60)

    transport = client.get_transport()
    channel   = transport.open_session()
    channel.get_pty(term='xterm', width=120, height=40)
    channel.exec_command(f"tail -f {LOG_FILE}")

    try:
        while True:
            if channel.recv_ready():
                data = channel.recv(4096).decode('utf-8', errors='replace')
                sys.stdout.write(data)
                sys.stdout.flush()
            if channel.exit_status_ready():
                break
            time.sleep(0.1)
    except KeyboardInterrupt:
        print("\n[INFO] 停止监听。程序仍在Jetson后台运行,窗口显示在Jetson屏幕上。")
        print(f"[INFO] 查看日志: ssh nvidia@{HOST} tail -f {LOG_FILE}")
        print(f"[INFO] 停止程序: ssh nvidia@{HOST} pkill -f dual_cam_preview.py")

    channel.close()
    client.close()


if __name__ == "__main__":
    main()

逐步实现流程

第一步:确认摄像头可用

在 Jetson 上运行:

bash 复制代码
# 列出摄像头
ls /dev/video*

# 检查 video0 支持的分辨率和格式
v4l2-ctl -d /dev/video0 --list-formats-ext | grep -A5 "MJPEG"

# 手动抓一帧,验证摄像头工作(非黑帧)
ffmpeg -f v4l2 -input_format mjpeg -video_size 1280x720 \
       -framerate 30 -i /dev/video0 -frames:v 1 /tmp/test_low.jpg

如果成功,应该看到:

复制代码
/tmp/test_low.jpg 文件存在,用图片查看器打开能看到正常图像(非全黑)

容易踩坑:如果两个摄像头同时以 1920×1080 运行,/dev/video0 可能输出全黑帧。解决方法是 LOW 摄像头使用 1280×720。

第二步:确认 X Display 权限

程序需要在 Jetson 的显示器上显示窗口。在 SSH 会话中测试 X 连接:

bash 复制代码
# 在 Jetson SSH 终端中
export DISPLAY=:1
export XAUTHORITY=/home/nvidia/.Xauthority
xeyes   # 应该在 Jetson 显示器上弹出眼睛图案

如果成功,应该看到: Jetson 显示器上出现一个跟随鼠标转动的眼睛程序。

如果报 cannot open display,说明 Xauthority 路径不对或 gdm 未在 :1 上运行。用 w 命令确认用户登录的 display 编号。

第三步:从 Windows 部署运行

在 Windows 开发机 PowerShell 中:

powershell 复制代码
cd C:\e\workspace\2026世校赛\Robot\vision

# 使用带 paramiko 的 Python 环境(此处为 Anaconda yolo 环境)
C:\d\Anaconda3\envs\yolo\python.exe deploy_dual_preview.py

如果成功,应该看到:

复制代码
[SSH] 连接 nvidia@192.168.4.5 ...
[SSH] 连接成功
[SCP] 上传 ...dual_cam_preview.py -> /home/nvidia/dual_cam_preview.py
[SCP] 上传完成
[INFO] 杀掉旧进程...
[INFO] 后台启动双摄预览 (nohup, DISPLAY=:1)...
[OK] 程序已启动, PID=xxxxxx
[INFO] 实时日志输出(Ctrl+C 停止监听,程序仍在Jetson运行):
------------------------------------------------------------
[LOW] 启动: /dev/video0 1280x720@30fps
[HD ] 启动: /dev/video2 1920x1080@30fps
[LOW] OK  cx=639 cy=236 ang=9.5 area=354 mask=0.1%
[HD ] OK  cx=740 cy=661 ang=83.7 area=89848 mask=4.4%
[FPS] 7.8

同时,Jetson 显示器的左上角会出现一个 1920×1080 的四格预览窗口。

第四步:在 Jetson 显示器上确认窗口

如果 Ctrl+C 停止监听后仍需看窗口:

  • 直接到 Jetson 显示器前按 Alt+Tab 找到标题 双摄预览 | Low(左) HD(右) | 原图(上) 二值(下) 的窗口
  • 或按 Super 键(Windows键) 进入 GNOME Activities Overview,能看到所有窗口缩略图

第五步:调试参数(下载实时图像到 Windows)

当检测效果不理想时,程序每 10 秒自动保存调试图像到 Jetson 的 /tmp/。创建下载脚本:

python 复制代码
# download_live.py(放在 Robot 目录)
import paramiko, os
HOST = "192.168.4.5"; USER = "nvidia"; PASS = "nvidia"
LOCAL = os.path.dirname(__file__)
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=PASS, timeout=10)
sftp = c.open_sftp()
for r, l in [("/tmp/low_bin_live.jpg", "low_bin_live.jpg"),
             ("/tmp/low_raw_live.jpg", "low_raw_live.jpg")]:
    sftp.get(r, os.path.join(LOCAL, l))
    print(f"下载 {l}")
sftp.close(); c.close()
powershell 复制代码
C:\d\Anaconda3\envs\yolo\python.exe download_live.py

关键参数说明

LOW 摄像头(Canny 边缘检测)

参数 含义
blur_k 9 高斯模糊核大小。值越大,越能消除 JPEG 压缩块噪声,但会模糊弱边缘
canny_lo 40 Canny 低阈值。低于此值的梯度点被丢弃
canny_hi 120 Canny 高阈值。高于此值的梯度点直接保留为边缘
edge_dilate 3 膨胀核大小。连接芯片轮廓上的小断点
edge_close 9 闭合核大小。填充芯片矩形内部,使其成为实心轮廓
roi (0.43, 0.20, 0.57, 0.70) 检测区域(相对坐标)。仅在传送带绿色面内检测,排除导轨和顶底固定件
area_min 80 最小轮廓面积(像素)。过小的噪声点被丢弃
area_max 3000 最大轮廓面积(像素)。过大的非芯片物体被丢弃

HD 摄像头(HSV 颜色检测)

参数 含义
proc_scale 0.5 处理分辨率缩放。HD 以半分辨率处理,减少计算量
bg_h_low/high 35~85 绿色背景 Hue 范围
bg_s_min 40 绿色背景最低饱和度
close_k 13 形态学闭合核大小,填充绿色背景上的纹理空洞
area_min 11000 最小芯片面积(HD 半分辨率下,芯片约 11000px)

结果验证

验证检测正常

终端输出中看到:

复制代码
[LOW] OK  cx=639 cy=236 ang=9.5 area=354 mask=0.1%

各字段含义:

  • cx=639, cy=236:芯片中心在 1280×720 图像中的像素坐标
  • ang=9.5:芯片旋转角度(度)
  • area=354:轮廓面积(像素)
  • mask=0.1%:二值图中白色像素占比(边缘模式下应极低,说明背景干净)

判断标准:

  • mask 应 < 1%(若 > 5% 说明有大量噪声边缘)
  • area 在 80~3000 之间
  • cx 在传送带水平范围内(约 550~730,即 1280px 的 43%~57%)

验证无芯片时不误报

将传送带清空,正常输出应为:

复制代码
[LOW] mask=0.0% raw=[] pass=[]

或仅有一个固定位置的小 blob(传送带上的固定参考件)。

验证 FPS 正常

复制代码
[FPS] 7.8

Jetson Nano 处理双路摄像头应能维持 6~9 FPS。若 < 4 FPS,说明 CPU 满载,可降低 proc_scale 或关闭调试输出。


常见错误和解决方法

问题:LOW 摄像头输出全黑帧

现象:

复制代码
frame mean: 0.0   center: [0, 0, 0]

原因: 两路摄像头同时在高分辨率下采集,USB 总线带宽不足,LOW 摄像头数据被丢弃。

解决: 将 LOW 摄像头分辨率降至 1280×720:

python 复制代码
LOW_W, LOW_H, LOW_FPS = 1280, 720, 30   # 而非 1920×1080

验证: 重启程序后检查 frame mean > 50。


问题:边缘检测产生巨型 blob,面积 > 100000

现象:

复制代码
[LOW] mask=11.1% raw=[115067] pass=[]

原因: ROI 掩码在形态学操作之后应用,导轨边缘在膨胀+闭合后连接成整块。

解决: 在代码中确保 ROI 掩码在 cv2.Canny 之后、cv2.dilate 之前应用:

python 复制代码
edges = cv2.Canny(gray, canny_lo, canny_hi)
# 先裁剪 ROI,再做形态学
roi_mask[ry1:ry2, rx1:rx2] = 255
edges = cv2.bitwise_and(edges, roi_mask)
binary = cv2.dilate(edges, kd, iterations=1)

验证: mask 降至 < 1%,不再出现超大 blob。


问题:CLAHE 导致边缘噪声爆炸

现象: 启用 use_clahe=True 时 mask=5~15%,禁用后降至 0.1%。

原因: CLAHE 在绿色均匀区域制造人工纹理对比,Canny 把这些伪纹理都检测为边缘。

解决: 边缘检测模式下必须设置:

python 复制代码
use_clahe = False

验证: mask < 0.2%,raw_areas 只有少量小 blob。


问题:OpenCV 窗口不显示在 Jetson 屏幕上

现象: 程序运行但 Jetson 显示器没有窗口。

原因: SSH 会话没有 X Display 权限,或 XAUTHORITY 未设置。

解决: 启动命令中加入:

bash 复制代码
export DISPLAY=:1
export XAUTHORITY=/home/nvidia/.Xauthority

另外,使用 nohup ... & 后台启动,而非直接运行(否则关闭 SSH 监听时进程会被 SIGHUP 杀掉):

bash 复制代码
nohup bash -c 'export DISPLAY=:1; export XAUTHORITY=...; python dual_cam_preview.py' \
    > /tmp/dual_cam_preview.log 2>&1 &

验证: pgrep -f dual_cam_preview.py 能找到 PID,Jetson 屏幕左上角出现窗口。


问题:芯片与背景颜色相同,颜色法检测不到

现象:

复制代码
[HSV-SCAN] y=0.45(324px) HSV=(70, 197, 101)   # 与传送带绿色一致
[LOW] mask=3.6% raw=[] pass=[]

原因: 绿色 PCB 芯片的 HSV 与绿色传送带完全重叠,HSV 阈值法无法区分。

解决: 切换到 Canny 边缘检测模式:

python 复制代码
use_edge = True
use_clahe = False
canny_lo = 40
canny_hi = 120

验证: 放置芯片后 mask=0.1%,能看到 OK cx=xxx cy=xxx


问题:芯片 blob 面积太小,被 area_min 过滤

现象:

复制代码
[LOW] mask=0.1% raw=[80, 12, 5] pass=[]

(raw 有 blob 但 pass 为空,说明 80px 的 blob 低于 area_min)

原因: 经过形态学操作后,芯片轮廓面积可能只有 80~150px。

解决: 降低 area_min

python 复制代码
area_min = 80   # 从 300 或 150 降低到 80

验证: pass 出现面积值,输出 OK 行。


性能优化建议

当前瓶颈

Jetson Nano 处理双路摄像头约 7~8 FPS,主要瓶颈在:

  1. HD 摄像头 1920×1080 的图像处理(即使 proc_scale=0.5)
  2. 两路摄像头帧解码(ffmpeg CPU 解码)

优化手段

手段 效果 代价
降低 proc_scale FPS +20~40% 小目标检测精度略降
减少调试 print 输出 FPS +5% 失去实时调试信息
HD 摄像头降分辨率至 1280×720 FPS +30% 取料精度轻微降低
使用 Jetson 硬件视频解码(nvv4l2) FPS +50~100% 需要改 ffmpeg 参数
关闭 HSV-SCAN 调试输出(改为按需触发) FPS +2~5%

推荐配置(竞赛场景)

python 复制代码
# 关闭调试扫描,提升帧率
# 在 process_frame 中将 % 100 改为仅首次
if lbl == 'LOW' and process_frame._dbg_cnt[lbl] <= 1:
    ...   # 仅第一帧打印 HSV,之后不再扫描

完整复现清单

准备文件

  • vision/dual_cam_preview.py
  • vision/deploy_dual_preview.py
  • Jetson 上已安装:ffmpegconda robot 环境含 opencv-pythonnumpy
  • Windows 上:conda activate yolo(含 paramiko

修改配置(根据实际环境)

python 复制代码
# deploy_dual_preview.py 顶部
HOST = "192.168.4.5"   # 替换为实际 Jetson IP
USER = "nvidia"
PASS = "nvidia"

# dual_cam_preview.py
CAM_LOW = "/dev/video0"   # 用 ls /dev/video* 确认
CAM_HD  = "/dev/video2"

运行步骤

powershell 复制代码
# Windows PowerShell
cd C:\e\workspace\2026世校赛\Robot\vision
C:\d\Anaconda3\envs\yolo\python.exe deploy_dual_preview.py
# 等待看到 [OK] 程序已启动, PID=xxx
# 查看 Jetson 显示器窗口
# Ctrl+C 停止监听(程序在 Jetson 后台继续运行)

停止程序

bash 复制代码
# 在 Jetson 终端或通过 SSH
pkill -f dual_cam_preview.py

检查成功

终端输出包含:

复制代码
[LOW] OK  cx=xxx cy=xxx ang=x.x area=xxx mask=0.1%
[HD ] OK  cx=xxx cy=xxx ang=xx.x area=xxxxx mask=4.x%
[FPS] 7.x

Jetson 显示器有 2×2 四格预览窗口,左上格原图有绿色检测框和坐标标注。


本教程实现了在 Jetson Nano 上稳定运行双路 USB 摄像头的芯片定位系统,核心突破是:

  1. USB 带宽问题:LOW 摄像头从 1920×1080 降至 1280×720,解决双流黑帧
  2. 同色检测问题:绿色 PCB 芯片在绿色传送带上无法用颜色区分,改用 Canny 边缘检测
  3. 边缘噪声抑制:三个关键步骤------禁用 CLAHE、提高 Canny 阈值、ROI 在形态学之前应用
  4. 稳定部署:nohup + DISPLAY/XAUTHORITY 配置,确保窗口持久显示

后续扩展方向

  • 多芯片同时检测:当前只返回得分最高的一个目标,可改为返回全部通过筛选的轮廓列表,支持同时定位多个芯片
  • 目标跟踪:引入 OpenCV Tracker(如 CSRT)在芯片移动时保持稳定 ID
  • 接口化输出 :将 cx, cy, angle 封装为 ZMQ 或 TCP 消息,供机械臂控制程序订阅
  • Jetson GPU 加速:使用 CUDA-based cv2 函数或 TensorRT 推理替代 CPU 处理
  • 自适应 HSV 标定:开机时自动采样空载传送带 HSV,动态校准检测阈值
相关推荐
菜菜的顾清寒15 小时前
力扣100(38)堆-数组中的第K个最大元素
算法·leetcode·排序算法
千寻girling15 小时前
机器学习 | 监督学习算法(了解) | 尚硅谷学习
开发语言·人工智能·后端·python·学习·算法·机器学习
强盛机器学习~15 小时前
2026年SCI一区新算法-灰叶猴优化算法(GLO)-公式原理详解与性能测评 Matlab代码免费获取
算法·matlab·进化计算·群体智能·智能优化算法·元启发式算法
崇山峻岭之间15 小时前
单片机RTC实验
单片机·嵌入式硬件·实时音视频
c2385615 小时前
MyVector模拟实现
算法
hoiii18715 小时前
matlab基础贝叶斯变换的压缩感知
算法·机器学习·matlab
闻缺陷则喜何志丹15 小时前
P8134 [ICPC 2020 WF] Opportunity Cost|普及+
c++·算法·洛谷
c2385615 小时前
MySrting的模拟实现
开发语言·c++·算法
踏着七彩祥云的小丑15 小时前
嵌入式测试学习第 21 天:常见硬件故障现象:不开机、死机、串口无输出
单片机·嵌入式硬件