本文最终能实现什么

在 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,主要瓶颈在:
- HD 摄像头 1920×1080 的图像处理(即使 proc_scale=0.5)
- 两路摄像头帧解码(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 上已安装:
ffmpeg、conda robot环境含opencv-python、numpy - 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 摄像头的芯片定位系统,核心突破是:
- USB 带宽问题:LOW 摄像头从 1920×1080 降至 1280×720,解决双流黑帧
- 同色检测问题:绿色 PCB 芯片在绿色传送带上无法用颜色区分,改用 Canny 边缘检测
- 边缘噪声抑制:三个关键步骤------禁用 CLAHE、提高 Canny 阈值、ROI 在形态学之前应用
- 稳定部署:nohup + DISPLAY/XAUTHORITY 配置,确保窗口持久显示

后续扩展方向
- 多芯片同时检测:当前只返回得分最高的一个目标,可改为返回全部通过筛选的轮廓列表,支持同时定位多个芯片
- 目标跟踪:引入 OpenCV Tracker(如 CSRT)在芯片移动时保持稳定 ID
- 接口化输出 :将
cx, cy, angle封装为 ZMQ 或 TCP 消息,供机械臂控制程序订阅 - Jetson GPU 加速:使用 CUDA-based cv2 函数或 TensorRT 推理替代 CPU 处理
- 自适应 HSV 标定:开机时自动采样空载传送带 HSV,动态校准检测阈值