1058 lines
48 KiB
Python
1058 lines
48 KiB
Python
"""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) # µs,D435 红外常见默认附近
|
||
self.var_rgb_exposure = tk.DoubleVar(value=156) # µs,RGB 常见默认附近
|
||
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()
|