Files
orisys 4ad53f4e97 first
2026-08-21 14:51:57 +08:00

1058 lines
48 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""RealSense D435/D405 双目采集:左右红外、RGB、可选深度,抽清晰帧后删除原视频。"""
from __future__ import annotations
import shutil
import threading
import time
import tkinter as tk
from datetime import datetime
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
import cv2
import numpy as np
import pyrealsense2 as rs
from PIL import Image, ImageTk
ROOT = Path(__file__).resolve().parent
DEFAULT_OUT = ROOT / "datasets" / "my_dataset" / "images" / "train"
RESOLUTIONS = [(1280, 720), (848, 480), (640, 480)]
COLOR_RESOLUTIONS = [(1280, 720), (1920, 1080), (848, 480), (640, 480)]
def sharpness(frame: np.ndarray) -> float:
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if frame.ndim == 3 else frame
return float(cv2.Laplacian(gray, cv2.CV_64F).var())
def pair_score(left: np.ndarray, right: np.ndarray, color: np.ndarray | None = None) -> float:
"""左右红外平均清晰度;有 RGB 时加权,避免弱纹理红外分数过低抽不到帧。"""
s_ir = 0.5 * (sharpness(left) + sharpness(right))
if color is None:
return s_ir
s_rgb = sharpness(color)
return 0.35 * s_ir + 0.65 * s_rgb
def to_bgr(frame: np.ndarray) -> np.ndarray:
if frame.ndim == 2:
return cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
return frame
def list_realsense_devices() -> list[tuple[str, str]]:
devices = []
for dev in rs.context().query_devices():
name = dev.get_info(rs.camera_info.name)
serial = dev.get_info(rs.camera_info.serial_number)
devices.append((f"{name} [{serial}]", serial))
return devices
def extract_clear_pairs(
left_path: Path,
right_path: Path,
out_dir: Path,
prefix: str,
min_sharpness: float,
interval: float,
max_frames: int,
rgb_path: Path | None = None,
depth_vis_path: Path | None = None,
depth_raw_dir: Path | None = None,
) -> tuple[int, str]:
"""抽取清晰成对帧。返回 (保存数量, 说明)。阈值过高时自动按相对清晰度回退。"""
cap_l = cv2.VideoCapture(str(left_path))
cap_r = cv2.VideoCapture(str(right_path))
cap_c = cv2.VideoCapture(str(rgb_path)) if rgb_path is not None else None
cap_d = cv2.VideoCapture(str(depth_vis_path)) if depth_vis_path is not None else None
if not cap_l.isOpened() or not cap_r.isOpened():
cap_l.release()
cap_r.release()
if cap_c is not None:
cap_c.release()
if cap_d is not None:
cap_d.release()
raise RuntimeError("无法读取左右目视频")
if rgb_path is not None and (cap_c is None or not cap_c.isOpened()):
raise RuntimeError("无法读取 RGB 视频")
if depth_vis_path is not None and (cap_d is None or not cap_d.isOpened()):
raise RuntimeError("无法读取深度视频")
fps = cap_l.get(cv2.CAP_PROP_FPS) or 30
min_gap = max(1, int(round(interval * fps)))
left_dir = out_dir / "left"
right_dir = out_dir / "right"
rgb_dir = out_dir / "rgb"
depth_dir = out_dir / "depth"
depth_vis_dir = out_dir / "depth_vis"
left_dir.mkdir(parents=True, exist_ok=True)
right_dir.mkdir(parents=True, exist_ok=True)
if rgb_path is not None:
rgb_dir.mkdir(parents=True, exist_ok=True)
if depth_vis_path is not None:
depth_dir.mkdir(parents=True, exist_ok=True)
depth_vis_dir.mkdir(parents=True, exist_ok=True)
# 每隔 interval 取最清晰一帧;阈值稍后过滤,过高则自动回退
windows: list[tuple[float, np.ndarray, np.ndarray, np.ndarray | None, np.ndarray | None, int]] = []
best: tuple[float, np.ndarray, np.ndarray, np.ndarray | None, np.ndarray | None, int] | None = None
last_saved_idx = -min_gap
idx = 0
all_scores: list[float] = []
while True:
ok_l, frame_l = cap_l.read()
ok_r, frame_r = cap_r.read()
frame_c = None
frame_d = None
if cap_c is not None:
ok_c, frame_c = cap_c.read()
if not ok_c:
break
if cap_d is not None:
ok_d, frame_d = cap_d.read()
if not ok_d:
break
if not ok_l or not ok_r:
break
score = pair_score(frame_l, frame_r, frame_c)
all_scores.append(score)
if best is None or score > best[0]:
best = (
score,
frame_l.copy(),
frame_r.copy(),
None if frame_c is None else frame_c.copy(),
None if frame_d is None else frame_d.copy(),
idx,
)
if best is not None and idx - last_saved_idx >= min_gap:
windows.append(best)
last_saved_idx = idx
best = None
idx += 1
if best is not None:
windows.append(best)
cap_l.release()
cap_r.release()
if cap_c is not None:
cap_c.release()
if cap_d is not None:
cap_d.release()
if not windows:
return 0, "视频里没有可读帧"
scores = np.asarray(all_scores, dtype=np.float64)
p50 = float(np.median(scores))
p80 = float(np.percentile(scores, 80))
thr = float(min_sharpness)
selected = [w for w in windows if w[0] >= thr]
if not selected:
adaptive = max(0.5, p50 * 0.5)
selected = [w for w in windows if w[0] >= adaptive]
if not selected:
selected = sorted(windows, key=lambda x: x[0], reverse=True)
keep = len(selected) if max_frames <= 0 else min(len(selected), max(1, max_frames))
selected = selected[:keep]
note = (
f"阈值 {thr:.0f} 过高(本段综合分 median={p50:.0f} p80={p80:.0f}),"
f"已按相对清晰度自动保存"
)
else:
note = f"按阈值 {thr:.0f} 筛选(本段 median={p50:.0f} p80={p80:.0f}"
selected.sort(key=lambda x: x[5])
if max_frames > 0 and len(selected) > max_frames:
selected = sorted(selected, key=lambda x: x[0], reverse=True)[:max_frames]
selected.sort(key=lambda x: x[5])
saved = 0
for item in selected:
saved += 1
name = f"{prefix}_{saved:04d}_s{item[0]:.0f}"
cv2.imwrite(str(left_dir / f"{name}.jpg"), item[1])
cv2.imwrite(str(right_dir / f"{name}.jpg"), item[2])
if item[3] is not None:
cv2.imwrite(str(rgb_dir / f"{name}.jpg"), item[3])
if item[4] is not None:
cv2.imwrite(str(depth_vis_dir / f"{name}.jpg"), item[4])
raw = None if depth_raw_dir is None else depth_raw_dir / f"{item[5]:06d}.png"
if raw is not None and raw.exists():
shutil.copy2(raw, depth_dir / f"{name}.png")
return saved, note
class CaptureApp(tk.Tk):
def __init__(self) -> None:
super().__init__()
self.title("RealSense D435 / D405 双目采集")
self.geometry("1480x900")
self.minsize(1200, 760)
self.pipeline: rs.pipeline | None = None
self.align: rs.align | None = None
self.colorizer = rs.colorizer()
self.has_rgb = False
self.has_depth = False
self.writer_left: cv2.VideoWriter | None = None
self.writer_right: cv2.VideoWriter | None = None
self.writer_rgb: cv2.VideoWriter | None = None
self.writer_depth: cv2.VideoWriter | None = None
self.preview_job: str | None = None
self.photo_left: ImageTk.PhotoImage | None = None
self.photo_right: ImageTk.PhotoImage | None = None
self.photo_rgb: ImageTk.PhotoImage | None = None
self.photo_depth: ImageTk.PhotoImage | None = None
self.busy = False
self.recording = False
self.stop_flag = False
self.rec_start = 0.0
self.rec_frames = 0
self.video_left: Path | None = None
self.video_right: Path | None = None
self.video_rgb: Path | None = None
self.video_depth: Path | None = None
self.depth_raw_dir: Path | None = None
self.devices: list[tuple[str, str]] = []
self.depth_sensor = None
self.color_sensor = None
self.shared_exposure = False
self._build()
self.protocol("WM_DELETE_WINDOW", self.on_close)
self.after(200, self.refresh_devices)
def _build(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
self.preview = ttk.Frame(self, padding=8)
self.preview.grid(row=0, column=0, sticky="nsew")
self.lbl_left = ttk.Label(self.preview, text="左目 Infrared 1", font=("Segoe UI", 11, "bold"))
self.lbl_right = ttk.Label(self.preview, text="右目 Infrared 2", font=("Segoe UI", 11, "bold"))
self.lbl_rgb = ttk.Label(self.preview, text="RGB Color", font=("Segoe UI", 11, "bold"))
self.lbl_depth = ttk.Label(self.preview, text="Depth 深度图", font=("Segoe UI", 11, "bold"))
self.view_left = tk.Label(self.preview, bg="#111", fg="#eee", text="左目")
self.view_right = tk.Label(self.preview, bg="#111", fg="#eee", text="右目")
self.view_rgb = tk.Label(self.preview, bg="#111", fg="#eee", text="RGB")
self.view_depth = tk.Label(self.preview, bg="#111", fg="#eee", text="深度图")
self.status = ttk.Label(self.preview, text="就绪")
panel = ttk.LabelFrame(self, text="RealSense 采集参数", padding=10)
panel.grid(row=1, column=0, sticky="ew", padx=8, pady=(0, 8))
for col in range(8):
panel.columnconfigure(col, weight=1)
self.var_device = tk.StringVar()
self.var_width = tk.IntVar(value=1280)
self.var_height = tk.IntVar(value=720)
self.var_fps = tk.IntVar(value=30)
self.var_duration = tk.DoubleVar(value=10)
self.var_sharp = tk.DoubleVar(value=5)
self.var_interval = tk.DoubleVar(value=0.2)
self.var_max = tk.IntVar(value=0)
self.var_swap = tk.BooleanVar(value=False)
self.var_emitter_off = tk.BooleanVar(value=True)
self.var_rgb = tk.BooleanVar(value=True)
self.var_align_rgb = tk.BooleanVar(value=True)
self.var_depth = tk.BooleanVar(value=False)
self.var_out = tk.StringVar(value=str(DEFAULT_OUT))
self.var_name = tk.StringVar(value="")
self.var_overwrite = tk.StringVar(value="rename")
self.var_preview_rec = tk.BooleanVar(value=False)
self.var_prev_left = tk.BooleanVar(value=True)
self.var_prev_right = tk.BooleanVar(value=False)
self.var_prev_rgb = tk.BooleanVar(value=True)
self.var_prev_depth = tk.BooleanVar(value=False)
self.var_ir_auto = tk.BooleanVar(value=True)
self.var_rgb_auto = tk.BooleanVar(value=True)
self.var_ir_exposure = tk.DoubleVar(value=8500) # µsD435 红外常见默认附近
self.var_rgb_exposure = tk.DoubleVar(value=156) # µsRGB 常见默认附近
self.save_prefix = ""
ttk.Label(panel, text="设备").grid(row=0, column=0, sticky="w")
self.cmb_device = ttk.Combobox(panel, textvariable=self.var_device, state="readonly")
self.cmb_device.grid(row=0, column=1, columnspan=2, sticky="ew")
ttk.Button(panel, text="刷新设备", command=self.refresh_devices).grid(row=0, column=3, sticky="ew", padx=(8, 0))
ttk.Checkbutton(panel, text="同时采集 RGB", variable=self.var_rgb, command=self.on_stream_toggle).grid(
row=0, column=4, sticky="w"
)
ttk.Checkbutton(panel, text="同时采集深度图", variable=self.var_depth, command=self.on_depth_toggle).grid(
row=0, column=5, sticky="w"
)
ttk.Checkbutton(panel, text="RGB对齐到左目", variable=self.var_align_rgb, command=self.on_stream_toggle).grid(
row=0, column=6, sticky="w"
)
ttk.Checkbutton(panel, text="交换左右", variable=self.var_swap).grid(row=0, column=7, sticky="w")
ttk.Label(panel, text="分辨率").grid(row=1, column=0, sticky="w", pady=(8, 0))
ttk.Combobox(panel, textvariable=self.var_width, values=[1280, 848, 640], width=8, state="readonly").grid(
row=1, column=1, sticky="w", pady=(8, 0)
)
ttk.Label(panel, text="x").grid(row=1, column=2, pady=(8, 0))
ttk.Combobox(panel, textvariable=self.var_height, values=[720, 480], width=8, state="readonly").grid(
row=1, column=3, sticky="w", pady=(8, 0)
)
ttk.Label(panel, text="FPS").grid(row=1, column=4, sticky="w", pady=(8, 0))
ttk.Combobox(panel, textvariable=self.var_fps, values=[30, 15, 6], width=8, state="readonly").grid(
row=1, column=5, sticky="w", pady=(8, 0)
)
self.chk_emitter = ttk.Checkbutton(panel, text="关闭红外点阵(D435)", variable=self.var_emitter_off)
self.chk_emitter.grid(row=1, column=6, columnspan=2, sticky="w", pady=(8, 0))
exp = ttk.LabelFrame(panel, text="曝光(单位 µs,取消自动后可手调)", padding=6)
exp.grid(row=2, column=0, columnspan=8, sticky="ew", pady=(10, 0))
self.chk_ir_auto = ttk.Checkbutton(
exp, text="红外自动曝光", variable=self.var_ir_auto, command=self.on_exposure_mode_change
)
self.chk_ir_auto.pack(side="left")
ttk.Label(exp, text="红外曝光").pack(side="left", padx=(10, 2))
self.spn_ir_exp = ttk.Spinbox(
exp,
from_=1,
to=165000,
increment=100,
textvariable=self.var_ir_exposure,
width=8,
command=self.apply_exposure,
)
self.spn_ir_exp.pack(side="left")
self.chk_rgb_auto = ttk.Checkbutton(
exp, text="RGB自动曝光", variable=self.var_rgb_auto, command=self.on_exposure_mode_change
)
self.chk_rgb_auto.pack(side="left", padx=(16, 0))
ttk.Label(exp, text="RGB曝光").pack(side="left", padx=(10, 2))
self.spn_rgb_exp = ttk.Spinbox(
exp,
from_=1,
to=10000,
increment=10,
textvariable=self.var_rgb_exposure,
width=8,
command=self.apply_exposure,
)
self.spn_rgb_exp.pack(side="left")
ttk.Button(exp, text="应用曝光", command=self.apply_exposure).pack(side="left", padx=(12, 0))
self.lbl_exp_hint = ttk.Label(exp, text="打开相机后改手动值再点应用即可生效")
self.lbl_exp_hint.pack(side="left", padx=(10, 0))
self.spn_ir_exp.bind("<Return>", lambda _e: self.apply_exposure())
self.spn_rgb_exp.bind("<Return>", lambda _e: self.apply_exposure())
self.on_exposure_mode_change(apply=False)
ttk.Label(panel, text="录制秒数").grid(row=3, column=0, sticky="w", pady=(8, 0))
ttk.Entry(panel, textvariable=self.var_duration, width=8).grid(row=3, column=1, sticky="w", pady=(8, 0))
ttk.Label(panel, text="清晰度阈值").grid(row=3, column=2, sticky="w", pady=(8, 0))
sharp_box = ttk.Frame(panel)
sharp_box.grid(row=3, column=3, sticky="w", pady=(8, 0))
ttk.Entry(sharp_box, textvariable=self.var_sharp, width=6).pack(side="left")
ttk.Button(sharp_box, text="宽松", width=4, command=lambda: self.var_sharp.set(2)).pack(side="left", padx=(4, 0))
ttk.Button(sharp_box, text="适中", width=4, command=lambda: self.var_sharp.set(5)).pack(side="left")
ttk.Button(sharp_box, text="严格", width=4, command=lambda: self.var_sharp.set(20)).pack(side="left")
ttk.Label(panel, text="抽帧间隔(秒)").grid(row=3, column=4, sticky="w", pady=(8, 0))
ttk.Entry(panel, textvariable=self.var_interval, width=8).grid(row=3, column=5, sticky="w", pady=(8, 0))
ttk.Label(panel, text="最多保存").grid(row=3, column=6, sticky="w", pady=(8, 0))
ttk.Entry(panel, textvariable=self.var_max, width=8).grid(row=3, column=7, sticky="w", pady=(8, 0))
ttk.Label(panel, text="输出目录").grid(row=4, column=0, sticky="w", pady=(8, 0))
ttk.Entry(panel, textvariable=self.var_out).grid(row=4, column=1, columnspan=6, sticky="ew", pady=(8, 0))
ttk.Button(panel, text="浏览", command=self.browse_out).grid(row=4, column=7, sticky="e", pady=(8, 0))
ttk.Label(panel, text="命名前缀").grid(row=5, column=0, sticky="w", pady=(8, 0))
ttk.Entry(panel, textvariable=self.var_name).grid(row=5, column=1, columnspan=2, sticky="ew", pady=(8, 0))
self.lbl_name_hint = ttk.Label(panel, text="空则用时间戳。示例: 批次A_20260818_173000_0001_s112.jpg")
self.lbl_name_hint.grid(row=5, column=3, columnspan=5, sticky="w", pady=(8, 0))
ttk.Label(panel, text="同名文件").grid(row=6, column=0, sticky="w", pady=(8, 0))
ow = ttk.Frame(panel)
ow.grid(row=6, column=1, columnspan=7, sticky="w", pady=(8, 0))
ttk.Radiobutton(ow, text="自动改名(不覆盖)", variable=self.var_overwrite, value="rename").pack(side="left")
ttk.Radiobutton(ow, text="覆盖已有同前缀文件", variable=self.var_overwrite, value="overwrite").pack(side="left", padx=(12, 0))
ttk.Radiobutton(ow, text="每次询问", variable=self.var_overwrite, value="ask").pack(side="left", padx=(12, 0))
prev = ttk.LabelFrame(panel, text="录制时 UI 预览(关掉可防止卡死)", padding=6)
prev.grid(row=7, column=0, columnspan=8, sticky="ew", pady=(10, 0))
ttk.Checkbutton(prev, text="录制时开启实时渲染", variable=self.var_preview_rec).pack(side="left")
ttk.Checkbutton(prev, text="左红外", variable=self.var_prev_left).pack(side="left", padx=(12, 0))
ttk.Checkbutton(prev, text="右红外", variable=self.var_prev_right).pack(side="left", padx=(8, 0))
ttk.Checkbutton(prev, text="RGB", variable=self.var_prev_rgb).pack(side="left", padx=(8, 0))
ttk.Checkbutton(prev, text="深度", variable=self.var_prev_depth).pack(side="left", padx=(8, 0))
ttk.Label(prev, text="未勾选的窗口录制时不刷新画面").pack(side="left", padx=(12, 0))
prog = ttk.Frame(panel)
prog.grid(row=8, column=0, columnspan=8, sticky="ew", pady=(10, 0))
prog.columnconfigure(1, weight=1)
ttk.Label(prog, text="进度").grid(row=0, column=0, sticky="w")
self.progress = ttk.Progressbar(prog, mode="determinate", maximum=100)
self.progress.grid(row=0, column=1, sticky="ew", padx=8)
self.lbl_progress = ttk.Label(prog, text="未录制", width=22)
self.lbl_progress.grid(row=0, column=2, sticky="e")
btns = ttk.Frame(panel)
btns.grid(row=9, column=0, columnspan=8, sticky="ew", pady=(12, 0))
self.btn_open = ttk.Button(btns, text="打开相机", command=self.open_camera_ui)
self.btn_open.pack(side="left")
self.btn_start = ttk.Button(btns, text="开始采集", command=self.start_capture)
self.btn_start.pack(side="left", padx=8)
self.btn_stop = ttk.Button(btns, text="停止", command=self.stop_capture, state="disabled")
self.btn_stop.pack(side="left")
self.var_name.trace_add("write", lambda *_: self.refresh_name_hint())
self.refresh_name_hint()
self.apply_preview_layout()
def apply_preview_layout(self) -> None:
for child in self.preview.winfo_children():
child.grid_forget()
show_depth = self.var_depth.get()
if show_depth:
for col in range(2):
self.preview.columnconfigure(col, weight=1)
self.preview.columnconfigure(2, weight=0)
self.preview.rowconfigure(1, weight=1)
self.preview.rowconfigure(3, weight=1)
self.lbl_left.grid(row=0, column=0, sticky="w")
self.lbl_right.grid(row=0, column=1, sticky="w")
self.view_left.grid(row=1, column=0, sticky="nsew", padx=(0, 4), pady=(0, 4))
self.view_right.grid(row=1, column=1, sticky="nsew", padx=(4, 0), pady=(0, 4))
self.lbl_rgb.grid(row=2, column=0, sticky="w")
self.lbl_depth.grid(row=2, column=1, sticky="w")
self.view_rgb.grid(row=3, column=0, sticky="nsew", padx=(0, 4), pady=(4, 0))
self.view_depth.grid(row=3, column=1, sticky="nsew", padx=(4, 0), pady=(4, 0))
self.status.grid(row=4, column=0, columnspan=2, sticky="w", pady=(8, 0))
else:
for col in range(3):
self.preview.columnconfigure(col, weight=1)
self.preview.rowconfigure(1, weight=1)
self.preview.rowconfigure(3, weight=0)
self.lbl_left.grid(row=0, column=0, sticky="w")
self.lbl_right.grid(row=0, column=1, sticky="w")
self.lbl_rgb.grid(row=0, column=2, sticky="w")
self.view_left.grid(row=1, column=0, sticky="nsew", padx=(0, 4))
self.view_right.grid(row=1, column=1, sticky="nsew", padx=4)
self.view_rgb.grid(row=1, column=2, sticky="nsew", padx=(4, 0))
self.status.grid(row=2, column=0, columnspan=3, sticky="w", pady=(8, 0))
def on_depth_toggle(self) -> None:
if self.var_depth.get():
self.var_emitter_off.set(False)
self.chk_emitter.state(["disabled"])
else:
self.chk_emitter.state(["!disabled"])
self.apply_preview_layout()
self.on_stream_toggle()
def on_stream_toggle(self) -> None:
if not self.recording and not self.busy and self.devices:
self.open_camera_ui()
def on_exposure_mode_change(self, apply: bool = True) -> None:
ir_auto = self.var_ir_auto.get()
rgb_auto = self.var_rgb_auto.get()
self.spn_ir_exp.state(["disabled"] if ir_auto else ["!disabled"])
# D405 等机型 RGB/红外共用曝光时,RGB 控件跟随红外
if self.shared_exposure:
self.chk_rgb_auto.state(["disabled"])
self.spn_rgb_exp.state(["disabled"])
self.var_rgb_auto.set(ir_auto)
if not ir_auto:
self.var_rgb_exposure.set(self.var_ir_exposure.get())
self.lbl_exp_hint.config(text="当前设备 RGB/红外共用曝光,请调左侧红外项")
else:
self.chk_rgb_auto.state(["!disabled"])
self.spn_rgb_exp.state(["disabled"] if rgb_auto else ["!disabled"])
self.lbl_exp_hint.config(text="打开相机后改手动值再点应用即可生效")
if apply:
self.apply_exposure()
@staticmethod
def _set_sensor_option(sensor, option, value: float) -> bool:
if sensor is None or not sensor.supports(option):
return False
rng = sensor.get_option_range(option)
clamped = max(rng.min, min(rng.max, float(value)))
if rng.step > 0:
clamped = rng.min + round((clamped - rng.min) / rng.step) * rng.step
clamped = max(rng.min, min(rng.max, clamped))
sensor.set_option(option, clamped)
return True
def _bind_exposure_sensors(self, device, with_rgb: bool) -> None:
self.depth_sensor = device.first_depth_sensor()
self.color_sensor = None
try:
self.color_sensor = device.first_color_sensor()
except Exception:
self.color_sensor = None
if self.color_sensor is None and with_rgb:
for sensor in device.query_sensors():
name = ""
if sensor.supports(rs.camera_info.name):
name = sensor.get_info(rs.camera_info.name).lower()
if "rgb" in name or "color" in name:
self.color_sensor = sensor
break
# D405:彩色与红外同属 Stereo Module,无独立 RGB sensor
self.shared_exposure = with_rgb and (
self.color_sensor is None or self.color_sensor is self.depth_sensor
)
if self.shared_exposure:
self.color_sensor = None
self.on_exposure_mode_change(apply=False)
def apply_exposure(self) -> None:
if self.pipeline is None:
self.set_status("相机未打开:曝光设置会在下次打开时生效")
return
notes: list[str] = []
try:
if self.depth_sensor is not None:
if self.var_ir_auto.get():
ok = self._set_sensor_option(self.depth_sensor, rs.option.enable_auto_exposure, 1.0)
notes.append("红外AE开" if ok else "红外AE不支持")
if self.shared_exposure and ok:
notes.append("RGB共用AE")
else:
self._set_sensor_option(self.depth_sensor, rs.option.enable_auto_exposure, 0.0)
ok = self._set_sensor_option(
self.depth_sensor, rs.option.exposure, float(self.var_ir_exposure.get())
)
if ok:
cur = self.depth_sensor.get_option(rs.option.exposure)
self.var_ir_exposure.set(cur)
if self.shared_exposure:
self.var_rgb_exposure.set(cur)
notes.append(f"共用曝光 {cur:.0f}µs")
else:
notes.append(f"红外曝光 {cur:.0f}µs")
else:
notes.append("红外手动曝光失败")
if self.color_sensor is not None and not self.shared_exposure:
if self.var_rgb_auto.get():
ok = self._set_sensor_option(self.color_sensor, rs.option.enable_auto_exposure, 1.0)
notes.append("RGB AE开" if ok else "RGB AE不支持")
else:
self._set_sensor_option(self.color_sensor, rs.option.enable_auto_exposure, 0.0)
ok = self._set_sensor_option(
self.color_sensor, rs.option.exposure, float(self.var_rgb_exposure.get())
)
if ok:
cur = self.color_sensor.get_option(rs.option.exposure)
self.var_rgb_exposure.set(cur)
notes.append(f"RGB曝光 {cur:.0f}µs")
else:
notes.append("RGB手动曝光失败")
except Exception as exc:
self.set_status(f"曝光设置失败: {exc}")
messagebox.showerror("曝光", str(exc))
return
if notes:
self.set_status("曝光已应用: " + " / ".join(notes))
def set_status(self, text: str) -> None:
self.status.config(text=text)
def refresh_name_hint(self) -> None:
custom = self.var_name.get().strip() or "时间戳"
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
prefix = f"{custom}_{stamp}" if self.var_name.get().strip() else stamp
self.lbl_name_hint.config(text=f"左右/RGB/深度同名。示例: {prefix}_0001_s112.jpg")
def make_prefix(self) -> str:
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
custom = self.var_name.get().strip()
return f"{custom}_{stamp}" if custom else stamp
def existing_prefix_files(self, out_dir: Path, prefix: str) -> list[Path]:
hits: list[Path] = []
for folder in ("left", "right", "rgb", "depth", "depth_vis"):
d = out_dir / folder
if not d.exists():
continue
hits.extend(sorted(d.glob(f"{prefix}_*")))
return hits
def unique_prefix(self, out_dir: Path, prefix: str) -> str:
candidate = prefix
n = 2
while self.existing_prefix_files(out_dir, candidate):
candidate = f"{prefix}_{n}"
n += 1
return candidate
def resolve_save_prefix(self, out_dir: Path, prefix: str) -> str | None:
existing = self.existing_prefix_files(out_dir, prefix)
if not existing:
return prefix
mode = self.var_overwrite.get()
msg = (
f"目录中已有 {len(existing)} 个同前缀文件:\n{prefix}_*.jpg/png\n\n"
"覆盖会删除这些旧文件。左右/RGB/深度使用同一文件名。"
)
if mode == "rename":
new_prefix = self.unique_prefix(out_dir, prefix)
self.set_status(f"已有同名,自动改为 {new_prefix}")
return new_prefix
if mode == "overwrite":
if not messagebox.askyesno("覆盖确认", msg + "\n\n确定覆盖?"):
return None
for path in existing:
path.unlink(missing_ok=True)
return prefix
choice = messagebox.askyesnocancel(
"同名文件",
msg + "\n\n是 = 覆盖\n否 = 自动改名\n取消 = 不开始录制",
)
if choice is None:
return None
if choice:
for path in existing:
path.unlink(missing_ok=True)
return prefix
return self.unique_prefix(out_dir, prefix)
def set_progress(self, ratio: float, text: str) -> None:
self.progress["value"] = max(0.0, min(100.0, ratio * 100.0))
self.lbl_progress.config(text=text)
def browse_out(self) -> None:
path = filedialog.askdirectory(initialdir=self.var_out.get() or str(ROOT))
if path:
self.var_out.set(path)
def current_serial(self) -> str:
label = self.var_device.get()
for text, serial in self.devices:
if text == label:
return serial
raise RuntimeError("请先选择 RealSense 设备")
def refresh_devices(self) -> None:
if self.recording or self.busy:
return
self.devices = list_realsense_devices()
labels = [item[0] for item in self.devices]
self.cmb_device["values"] = labels
if labels:
self.var_device.set(labels[0])
self.set_status(f"找到 {len(labels)} 台 RealSense,可点「打开相机」")
self.after(100, self.open_camera_ui)
else:
self.var_device.set("")
self.set_status("未检测到 D435/D405。请插上相机,并确认已安装 Intel RealSense 驱动")
def release_writers(self) -> None:
for attr in ("writer_left", "writer_right", "writer_rgb", "writer_depth"):
writer = getattr(self, attr)
if writer is not None:
writer.release()
setattr(self, attr, None)
def stop_pipeline(self) -> None:
if self.preview_job is not None:
self.after_cancel(self.preview_job)
self.preview_job = None
self.release_writers()
self.align = None
self.has_rgb = False
self.has_depth = False
self.depth_sensor = None
self.color_sensor = None
self.shared_exposure = False
if self.pipeline is not None:
try:
self.pipeline.stop()
except Exception:
pass
self.pipeline = None
def _try_start(self, serial: str, w: int, h: int, f: int, with_rgb: bool, with_depth: bool) -> rs.pipeline:
pipeline = rs.pipeline()
config = rs.config()
config.enable_device(serial)
config.enable_stream(rs.stream.infrared, 1, w, h, rs.format.y8, f)
config.enable_stream(rs.stream.infrared, 2, w, h, rs.format.y8, f)
if with_depth:
config.enable_stream(rs.stream.depth, w, h, rs.format.z16, f)
if with_rgb:
cw, ch = (w, h) if (w, h) in COLOR_RESOLUTIONS else (1280, 720)
config.enable_stream(rs.stream.color, cw, ch, rs.format.bgr8, f)
profile = pipeline.start(config)
self.pipeline = pipeline
self.has_rgb = with_rgb
self.has_depth = with_depth
need_align = (with_rgb and self.var_align_rgb.get()) or with_depth
self.align = rs.align(rs.stream.infrared) if need_align else None
self.var_width.set(w)
self.var_height.set(h)
device = profile.get_device()
self._bind_exposure_sensors(device, with_rgb)
if self.depth_sensor is not None and self.depth_sensor.supports(rs.option.emitter_enabled):
# 采深度时强制打开点阵;否则按勾选关闭
emitter_on = 1.0 if with_depth else (0.0 if self.var_emitter_off.get() else 1.0)
self.depth_sensor.set_option(rs.option.emitter_enabled, emitter_on)
self.apply_exposure()
return pipeline
def start_pipeline(self) -> None:
serial = self.current_serial()
width, height, fps = int(self.var_width.get()), int(self.var_height.get()), int(self.var_fps.get())
attempts = [(width, height, fps)] + [(w, h, fps) for w, h in RESOLUTIONS if (w, h) != (width, height)]
want_rgb = self.var_rgb.get()
want_depth = self.var_depth.get()
last_error = None
rgb_options = [True, False] if want_rgb else [False]
depth_options = [True, False] if want_depth else [False]
for with_depth in depth_options:
for with_rgb in rgb_options:
for w, h, f in attempts:
try:
self._try_start(serial, w, h, f, with_rgb, with_depth)
if want_depth and not with_depth:
self.set_status("深度流无法打开,已回退")
if want_rgb and not with_rgb:
self.set_status("RGB 流无法打开,已回退")
return
except Exception as exc:
last_error = exc
try:
if self.pipeline is not None:
self.pipeline.stop()
except Exception:
pass
self.pipeline = None
raise RuntimeError(f"无法打开 RealSense: {last_error}")
def open_camera_ui(self) -> None:
if self.recording or self.busy:
return
if not self.devices:
self.refresh_devices()
if not self.devices:
messagebox.showerror("RealSense", "没有检测到 D435/D405")
return
self.stop_pipeline()
try:
self.start_pipeline()
except Exception as exc:
self.view_left.config(image="", text=str(exc))
self.view_right.config(image="", text="")
self.view_rgb.config(image="", text="")
self.view_depth.config(image="", text="")
self.set_status(str(exc))
messagebox.showerror("RealSense", str(exc))
return
parts = ["左右红外"]
if self.has_rgb:
parts.append("RGB" + ("对齐左目" if self.var_align_rgb.get() else ""))
if self.has_depth:
parts.append("深度(点阵已开)")
self.set_status(f"{self.var_width.get()}x{self.var_height.get()} @{self.var_fps.get()} " + " + ".join(parts))
if not self.has_rgb:
self.view_rgb.config(image="", text="未开启 RGB")
if not self.has_depth:
self.view_depth.config(image="", text="未开启深度")
self.loop_preview()
def read_frames(self) -> tuple[np.ndarray, np.ndarray, np.ndarray | None, np.ndarray | None, np.ndarray | None] | None:
if self.pipeline is None:
return None
try:
frames = self.pipeline.wait_for_frames(1000)
except Exception:
return None
if self.align is not None:
frames = self.align.process(frames)
ir_l = frames.get_infrared_frame(1)
ir_r = frames.get_infrared_frame(2)
if not ir_l or not ir_r:
return None
left = to_bgr(np.asanyarray(ir_l.get_data()))
right = to_bgr(np.asanyarray(ir_r.get_data()))
if self.var_swap.get():
left, right = right, left
color = None
depth_raw = None
depth_vis = None
if self.has_rgb:
color_frame = frames.get_color_frame()
if color_frame:
color = np.asanyarray(color_frame.get_data())
if self.has_depth:
depth_frame = frames.get_depth_frame()
if depth_frame:
depth_raw = np.asanyarray(depth_frame.get_data())
depth_vis = np.asanyarray(self.colorizer.colorize(depth_frame).get_data())
return left, right, color, depth_raw, depth_vis
def loop_preview(self) -> None:
pack = self.read_frames()
if pack is not None:
left, right, color, depth_raw, depth_vis = pack
if self.recording:
duration = max(0.001, float(self.var_duration.get()))
elapsed = time.perf_counter() - self.rec_start
remain = max(0.0, duration - elapsed)
self.set_progress(elapsed / duration, f"录制 {elapsed:.1f}/{duration:.1f}s")
if self.writer_left is not None and self.writer_right is not None:
self.writer_left.write(left)
self.writer_right.write(right)
if self.writer_rgb is not None and color is not None:
self.writer_rgb.write(color)
if self.writer_depth is not None and depth_vis is not None:
self.writer_depth.write(depth_vis)
if self.depth_raw_dir is not None and depth_raw is not None:
cv2.imwrite(str(self.depth_raw_dir / f"{self.rec_frames:06d}.png"), depth_raw)
self.rec_frames += 1
if self.var_preview_rec.get():
if self.var_prev_left.get():
self.show_frame(self.view_left, left, "left")
if self.var_prev_right.get():
self.show_frame(self.view_right, right, "right")
if self.var_prev_rgb.get() and color is not None:
self.show_frame(self.view_rgb, color, "rgb")
if self.var_prev_depth.get() and depth_vis is not None:
self.show_frame(self.view_depth, depth_vis, "depth")
if remain <= 0 or self.stop_flag:
self.after(10, self.finish_capture)
return
else:
score_l, score_r = sharpness(left), sharpness(right)
score = pair_score(left, right, color)
thr = self.var_sharp.get()
show_l, show_r = left.copy(), right.copy()
ok = score >= thr
cv2.putText(
show_l,
f"L {score_l:.0f} score={score:.0f}/{thr:.0f}",
(18, 40),
cv2.FONT_HERSHEY_SIMPLEX,
0.7,
(0, 220, 0) if ok else (0, 180, 255),
2,
)
cv2.putText(
show_r,
f"R {score_r:.0f} score={score:.0f}/{thr:.0f}",
(18, 40),
cv2.FONT_HERSHEY_SIMPLEX,
0.7,
(0, 220, 0) if ok else (0, 180, 255),
2,
)
self.show_frame(self.view_left, show_l, "left")
self.show_frame(self.view_right, show_r, "right")
if color is not None:
show_c = color.copy()
s_rgb = sharpness(color)
cv2.putText(
show_c,
f"RGB {s_rgb:.0f} score={score:.0f}/{thr:.0f}",
(18, 40),
cv2.FONT_HERSHEY_SIMPLEX,
0.7,
(0, 220, 0) if ok else (0, 180, 255),
2,
)
self.show_frame(self.view_rgb, show_c, "rgb")
if depth_vis is not None:
show_d = depth_vis.copy()
cv2.putText(show_d, "DEPTH", (18, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 220, 0), 2)
self.show_frame(self.view_depth, show_d, "depth")
delay = 30 if self.recording and not self.var_preview_rec.get() else 10
self.preview_job = self.after(delay, self.loop_preview)
def show_frame(self, widget: tk.Label, frame: np.ndarray, side: str) -> None:
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
h, w = rgb.shape[:2]
max_w = max(widget.winfo_width(), 240)
max_h = max(widget.winfo_height(), 180)
scale = min(max_w / w, max_h / h)
if scale <= 0:
return
size = (max(1, int(w * scale)), max(1, int(h * scale)))
img = Image.fromarray(rgb).resize(size, Image.Resampling.BILINEAR)
photo = ImageTk.PhotoImage(img)
if side == "left":
self.photo_left = photo
elif side == "right":
self.photo_right = photo
elif side == "rgb":
self.photo_rgb = photo
else:
self.photo_depth = photo
widget.config(image=photo, text="")
def start_capture(self) -> None:
if self.pipeline is None:
self.open_camera_ui()
if self.pipeline is None:
return
if self.recording or self.busy:
return
duration = float(self.var_duration.get())
if duration <= 0:
messagebox.showwarning("参数", "录制秒数必须大于 0")
return
pack = self.read_frames()
if pack is None:
messagebox.showerror("采集", "读不到画面")
return
left, right, color, _, depth_vis = pack
out_dir = Path(self.var_out.get())
prefix = self.resolve_save_prefix(out_dir, self.make_prefix())
if prefix is None:
self.set_status("已取消录制")
return
self.save_prefix = prefix
fps = max(1, int(self.var_fps.get()))
tmp_dir = ROOT / "datasets" / "_tmp"
tmp_dir.mkdir(parents=True, exist_ok=True)
self.video_left = tmp_dir / f"{prefix}_left.avi"
self.video_right = tmp_dir / f"{prefix}_right.avi"
self.video_rgb = tmp_dir / f"{prefix}_rgb.avi" if self.has_rgb else None
self.video_depth = tmp_dir / f"{prefix}_depth.avi" if self.has_depth else None
self.depth_raw_dir = tmp_dir / f"{prefix}_depth_raw" if self.has_depth else None
if self.depth_raw_dir is not None:
self.depth_raw_dir.mkdir(parents=True, exist_ok=True)
h_l, w_l = left.shape[:2]
h_r, w_r = right.shape[:2]
self.writer_left = cv2.VideoWriter(str(self.video_left), cv2.VideoWriter_fourcc(*"XVID"), fps, (w_l, h_l))
self.writer_right = cv2.VideoWriter(str(self.video_right), cv2.VideoWriter_fourcc(*"XVID"), fps, (w_r, h_r))
if self.has_rgb and color is not None and self.video_rgb is not None:
h_c, w_c = color.shape[:2]
self.writer_rgb = cv2.VideoWriter(str(self.video_rgb), cv2.VideoWriter_fourcc(*"XVID"), fps, (w_c, h_c))
if self.has_depth and depth_vis is not None and self.video_depth is not None:
h_d, w_d = depth_vis.shape[:2]
self.writer_depth = cv2.VideoWriter(str(self.video_depth), cv2.VideoWriter_fourcc(*"XVID"), fps, (w_d, h_d))
if not self.writer_left.isOpened() or not self.writer_right.isOpened():
self.release_writers()
messagebox.showerror("采集", "无法写入视频")
return
self.recording = True
self.stop_flag = False
self.rec_start = time.perf_counter()
self.rec_frames = 0
self.btn_start.config(state="disabled")
self.btn_open.config(state="disabled")
self.btn_stop.config(state="normal")
extra = ""
if self.has_rgb:
extra += " + RGB"
if self.has_depth:
extra += " + 深度"
preview_hint = "预览已关" if not self.var_preview_rec.get() else "预览已开"
self.set_progress(0, f"录制 0.0/{duration:.1f}s")
self.set_status(f"正在同步录制左右红外{extra} {duration:.1f} 秒… 命名 {prefix} {preview_hint}")
def stop_capture(self) -> None:
if self.recording:
self.stop_flag = True
def finish_capture(self) -> None:
if not self.recording:
return
self.recording = False
self.btn_stop.config(state="disabled")
self.release_writers()
left_path, right_path = self.video_left, self.video_right
rgb_path, depth_path, depth_raw_dir = self.video_rgb, self.video_depth, self.depth_raw_dir
if left_path is None or right_path is None or not left_path.exists() or not right_path.exists():
self.btn_start.config(state="normal")
self.btn_open.config(state="normal")
self.set_status("没有录到左右目视频")
return
if self.has_rgb and (rgb_path is None or not rgb_path.exists()):
rgb_path = None
if self.has_depth and (depth_path is None or not depth_path.exists()):
depth_path = None
self.busy = True
self.set_progress(1.0, "正在抽帧…")
self.set_status("正在抽取清晰帧…")
prefix = self.save_prefix or left_path.stem.replace("_left", "")
out_dir = Path(self.var_out.get())
min_sharp = float(self.var_sharp.get())
interval = float(self.var_interval.get())
max_frames = int(self.var_max.get())
has_rgb = self.has_rgb
has_depth = self.has_depth
def work() -> None:
error = ""
saved = 0
note = ""
try:
saved, note = extract_clear_pairs(
left_path,
right_path,
out_dir,
prefix,
min_sharp,
interval,
max_frames,
rgb_path,
depth_path,
depth_raw_dir,
)
except Exception as exc:
error = str(exc)
finally:
left_path.unlink(missing_ok=True)
right_path.unlink(missing_ok=True)
if rgb_path is not None:
rgb_path.unlink(missing_ok=True)
if depth_path is not None:
depth_path.unlink(missing_ok=True)
if depth_raw_dir is not None and depth_raw_dir.exists():
shutil.rmtree(depth_raw_dir, ignore_errors=True)
def done() -> None:
self.busy = False
self.btn_start.config(state="normal")
self.btn_open.config(state="normal")
if error:
self.set_progress(0, "抽帧失败")
self.set_status(error)
messagebox.showerror("抽帧失败", error)
return
folders = f"{out_dir}/left 、 {out_dir}/right"
if has_rgb:
folders += f" 、 {out_dir}/rgb"
if has_depth:
folders += f" 、 {out_dir}/depth 16位)与 {out_dir}/depth_vis"
if saved == 0:
msg = "仍未抽到帧,请检查相机画面或缩短抽帧间隔后再试。原视频已删除"
else:
msg = f"已保存 {saved} 组,前缀 {prefix},到 {folders},原视频已删除"
if note:
msg += f"\n\n{note}"
self.set_progress(1.0, f"完成 {saved} 组")
self.set_status(msg.replace("\n", " "))
messagebox.showinfo("采集完成", msg + f"\n\n文件名格式: {prefix}_0001_s清晰度.jpg")
self.after(0, done)
threading.Thread(target=work, daemon=True).start()
def on_close(self) -> None:
self.recording = False
self.stop_pipeline()
for path in (self.video_left, self.video_right, self.video_rgb, self.video_depth):
if path is not None:
path.unlink(missing_ok=True)
if self.depth_raw_dir is not None and self.depth_raw_dir.exists():
shutil.rmtree(self.depth_raw_dir, ignore_errors=True)
self.destroy()
if __name__ == "__main__":
CaptureApp().mainloop()