diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8639206 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +test/ +Log/ +.vscode/ +camera_params.json +**.pyc** \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..c0b5758 --- /dev/null +++ b/main.py @@ -0,0 +1,831 @@ +#!/usr/bin/env python3 +""" +Orbbec 数据采集 GUI +- 启动时选择彩色 / 深度分辨率 +- 数据采集:实时预览 + 一键拍照(RGB / IR / 深度 / D2C深度 / 点云) +- Orbbec Viewer 深度图 D2C:批量软件 D2C 转换 +""" + +import os +import sys +import json +import time +import threading +import contextlib +import io +import numpy as np +import cv2 +from pathlib import Path +from datetime import datetime + +# 确保 utils.py 可被 import +sys.path.insert(0, str(Path(__file__).parent)) + +from PyQt5.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QPushButton, QLineEdit, QCheckBox, QTabWidget, + QComboBox, QDialog, QDialogButtonBox, QGroupBox, QFileDialog, + QTextEdit, QFrame, QMessageBox, QSizePolicy, +) +from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer +from PyQt5.QtGui import QImage, QPixmap, QFont + +from d2c_batch import batch_convert + +DATA_DIR = Path("data") +PARAMS_FILE = "camera_params.json" +MIN_DEPTH_MM = 200 +MAX_DEPTH_MM = 5000 + + +# ───────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────── + +def ndarray_to_pixmap(img_bgr: np.ndarray, max_w: int, max_h: int) -> QPixmap: + """BGR numpy array → scaled QPixmap.""" + if img_bgr is None or img_bgr.size == 0: + return QPixmap() + h, w = img_bgr.shape[:2] + scale = min(max_w / max(w, 1), max_h / max(h, 1), 1.0) + if scale < 1.0: + img_bgr = cv2.resize(img_bgr, (int(w * scale), int(h * scale)), + interpolation=cv2.INTER_AREA) + rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) + rh, rw = rgb.shape[:2] + qimg = QImage(rgb.data.tobytes(), rw, rh, rw * 3, QImage.Format_RGB888) + return QPixmap.fromImage(qimg) + + +def depth_to_vis(depth_uint16: np.ndarray, scale: float) -> np.ndarray: + """uint16 深度图 → JET 伪彩 BGR 图(无效像素为黑色)。""" + depth_mm = depth_uint16.astype(np.float32) * scale + norm = np.clip((depth_mm - MIN_DEPTH_MM) / (MAX_DEPTH_MM - MIN_DEPTH_MM), 0.0, 1.0) + gray = (norm * 255).astype(np.uint8) + vis = cv2.applyColorMap(gray, cv2.COLORMAP_JET) + vis[depth_uint16 == 0] = 0 + return vis + + +def process_ir_frame(ir_frame) -> np.ndarray | None: + """IR frame → 8-bit BGR 图像。""" + if ir_frame is None: + return None + try: + from pyorbbecsdk import OBFormat + vf = ir_frame.as_video_frame() + data = np.asanyarray(vf.get_data()) + w, h, fmt = vf.get_width(), vf.get_height(), vf.get_format() + if fmt == OBFormat.Y8: + arr = np.resize(data, (h, w)).astype(np.uint8) + elif fmt == OBFormat.MJPG: + arr = cv2.imdecode(data, cv2.IMREAD_GRAYSCALE) + if arr is None: + return None + else: + arr = np.frombuffer(data, dtype=np.uint16).reshape(h, w) + arr = cv2.normalize(arr, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) + return cv2.cvtColor(arr, cv2.COLOR_GRAY2BGR) + except Exception: + return None + + +# ───────────────────────────────────────────────────────────────── +# Profile Selection Dialog +# ───────────────────────────────────────────────────────────────── + +class ProfileDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("选择分辨率") + self.setMinimumWidth(540) + self.pipeline = None + self.color_profiles: list = [] + self.depth_profiles: list = [] + self.selected_color = None + self.selected_depth = None + self._build_ui() + self._load_profiles() + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(14) + + grp_c = QGroupBox("彩色 (RGB) 分辨率") + lc = QVBoxLayout(grp_c) + self.color_combo = QComboBox() + lc.addWidget(self.color_combo) + layout.addWidget(grp_c) + + grp_d = QGroupBox("深度分辨率") + ld = QVBoxLayout(grp_d) + self.depth_combo = QComboBox() + ld.addWidget(self.depth_combo) + layout.addWidget(grp_d) + + self.status_lbl = QLabel("正在连接相机,请稍候…") + self.status_lbl.setAlignment(Qt.AlignCenter) + layout.addWidget(self.status_lbl) + + btns = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + btns.accepted.connect(self._on_ok) + btns.rejected.connect(self.reject) + layout.addWidget(btns) + + def _load_profiles(self): + try: + from pyorbbecsdk import Pipeline, OBSensorType + self.pipeline = Pipeline() + + color_list = self.pipeline.get_stream_profile_list(OBSensorType.COLOR_SENSOR) + for i in range(color_list.get_count()): + p = color_list.get_stream_profile_by_index(i) + vp = p.as_video_stream_profile() + if vp is None: + continue + fmt = str(vp.get_format()).split(".")[-1] + self.color_combo.addItem( + f"{vp.get_width()}×{vp.get_height()} @ {vp.get_fps()} fps [{fmt}]") + self.color_profiles.append(vp) + + depth_list = self.pipeline.get_stream_profile_list(OBSensorType.DEPTH_SENSOR) + for i in range(depth_list.get_count()): + p = depth_list.get_stream_profile_by_index(i) + vp = p.as_video_stream_profile() + if vp is None: + continue + fmt = str(vp.get_format()).split(".")[-1] + self.depth_combo.addItem( + f"{vp.get_width()}×{vp.get_height()} @ {vp.get_fps()} fps [{fmt}]") + self.depth_profiles.append(vp) + + self.status_lbl.setText( + f"已找到 {len(self.color_profiles)} 个彩色配置," + f"{len(self.depth_profiles)} 个深度配置") + except Exception as exc: + self.status_lbl.setText(f"相机连接失败:{exc}") + + def _on_ok(self): + if not self.color_profiles or not self.depth_profiles: + QMessageBox.warning(self, "错误", "没有可用的相机配置,请检查设备连接。") + return + self.selected_color = self.color_profiles[self.color_combo.currentIndex()] + self.selected_depth = self.depth_profiles[self.depth_combo.currentIndex()] + self.accept() + + +# ───────────────────────────────────────────────────────────────── +# Camera Worker Thread +# ───────────────────────────────────────────────────────────────── + +class CameraWorker(QThread): + preview_ready = pyqtSignal(object, object) # color_bgr, depth_vis + capture_done = pyqtSignal(str) # save_dir + params_ready = pyqtSignal(dict) + log_msg = pyqtSignal(str) + + def __init__(self, pipeline, color_profile, depth_profile): + super().__init__() + self._pipeline = pipeline + self._color_profile = color_profile + self._depth_profile = depth_profile + self._lock = threading.Lock() + self._running = True + self._d2c = False + self._do_capture = False + self._capture_dir = "" + self._camera_params = None + self._has_dual_ir = False + self._last_emit_ts = 0.0 + + # ── Public controls ── + + def set_d2c(self, enabled: bool): + with self._lock: + self._d2c = enabled + + def trigger_capture(self, save_dir: str): + with self._lock: + self._capture_dir = save_dir + self._do_capture = True + + def stop(self): + self._running = False + self.wait(6000) + + # ── Thread body ── + + def run(self): + try: + from pyorbbecsdk import ( + Config, AlignFilter, PointCloudFilter, + OBStreamType, OBSensorType, OBFrameType, OBFormat, + save_point_cloud_to_ply, + ) + from utils import frame_to_bgr_image + except ImportError as exc: + self.log_msg.emit(f"导入 pyorbbecsdk 失败:{exc}") + return + + config = Config() + config.enable_stream(self._color_profile) + config.enable_stream(self._depth_profile) + + # 探测 IR 传感器类型 + try: + device = self._pipeline.get_device() + sensor_list = device.get_sensor_list() + for i in range(len(sensor_list)): + st = sensor_list[i].get_type() + if st in (OBSensorType.LEFT_IR_SENSOR, OBSensorType.RIGHT_IR_SENSOR): + self._has_dual_ir = True + if self._has_dual_ir: + config.enable_video_stream(OBSensorType.LEFT_IR_SENSOR) + config.enable_video_stream(OBSensorType.RIGHT_IR_SENSOR) + else: + config.enable_video_stream(OBSensorType.IR_SENSOR) + except Exception as exc: + self.log_msg.emit(f"IR 传感器初始化警告:{exc}") + + try: + self._pipeline.start(config) + except Exception as exc: + self.log_msg.emit(f"Pipeline 启动失败:{exc}") + return + + align_filter = AlignFilter(align_to_stream=OBStreamType.COLOR_STREAM) + point_cloud_filter = PointCloudFilter() + + while self._running: + try: + frames = self._pipeline.wait_for_frames(500) + if frames is None: + continue + + with self._lock: + d2c = self._d2c + do_cap = self._do_capture + cap_dir = self._capture_dir + if do_cap: + self._do_capture = False + + # 首帧提取相机参数 + if self._camera_params is None: + cf = frames.get_color_frame() + df = frames.get_depth_frame() + if cf and df: + try: + self._camera_params = self._extract_params(cf, df) + self.params_ready.emit(self._camera_params) + except Exception: + pass + + # 执行拍照 + if do_cap and cap_dir: + try: + self._run_capture( + frames, cap_dir, + align_filter, point_cloud_filter, + frame_to_bgr_image, save_point_cloud_to_ply, + OBFrameType, OBFormat, + ) + except Exception as exc: + self.log_msg.emit(f"拍照错误:{exc}") + continue + + # ── 预览 ── + view_frames = frames + if d2c: + af = align_filter.process(frames) + if af: + view_frames = af.as_frame_set() + + cf = view_frames.get_color_frame() + df = view_frames.get_depth_frame() + if not cf or not df: + continue + + color_bgr = frame_to_bgr_image(cf) + if color_bgr is None: + continue + + depth_data = np.frombuffer(df.get_data(), dtype=np.uint16).reshape( + df.get_height(), df.get_width()) + depth_vis = depth_to_vis(depth_data, df.get_depth_scale()) + + # 限制发射帧率 ~30 fps + now = time.monotonic() + if now - self._last_emit_ts >= 0.033: + self._last_emit_ts = now + self.preview_ready.emit(color_bgr.copy(), depth_vis.copy()) + + except Exception as exc: + if self._running: + self.log_msg.emit(f"相机错误:{exc}") + time.sleep(0.1) + + try: + self._pipeline.stop() + except Exception: + pass + + # ── Capture helper ── + + def _run_capture(self, frames, save_dir, + align_filter, point_cloud_filter, + frame_to_bgr_image, save_point_cloud_to_ply, + OBFrameType, OBFormat): + Path(save_dir).mkdir(parents=True, exist_ok=True) + + cf = frames.get_color_frame() + df = frames.get_depth_frame() + + # ① 彩色图 + if cf: + bgr = frame_to_bgr_image(cf) + if bgr is not None: + cv2.imwrite(str(Path(save_dir) / "color.png"), bgr) + + # ② 红外图 + if self._has_dual_ir: + lir = frames.get_frame(OBFrameType.LEFT_IR_FRAME) + rir = frames.get_frame(OBFrameType.RIGHT_IR_FRAME) + img = process_ir_frame(lir) + if img is not None: + cv2.imwrite(str(Path(save_dir) / "ir_left.png"), img) + img = process_ir_frame(rir) + if img is not None: + cv2.imwrite(str(Path(save_dir) / "ir_right.png"), img) + else: + ir = frames.get_frame(OBFrameType.IR_FRAME) + img = process_ir_frame(ir) + if img is not None: + cv2.imwrite(str(Path(save_dir) / "ir.png"), img) + + # ③ 原始深度图 + if df: + raw = np.frombuffer(df.get_data(), dtype=np.uint16).reshape( + df.get_height(), df.get_width()) + cv2.imwrite(str(Path(save_dir) / "depth.png"), raw) + + # ④ D2C 深度图 + 伪彩 + 点云 + aligned = align_filter.process(frames) + if aligned: + aset = aligned.as_frame_set() + adf = aset.get_depth_frame() + if adf: + ad = np.frombuffer(adf.get_data(), dtype=np.uint16).reshape( + adf.get_height(), adf.get_width()) + cv2.imwrite(str(Path(save_dir) / "depth_d2c.png"), ad) + vis = depth_to_vis(ad, adf.get_depth_scale()) + cv2.imwrite(str(Path(save_dir) / "depth_d2c_vis.png"), vis) + + try: + point_cloud_filter.set_create_point_format(OBFormat.POINT) + pc = point_cloud_filter.process(aligned) + if pc: + save_point_cloud_to_ply(str(Path(save_dir) / "point_cloud.ply"), pc) + except Exception as exc: + self.log_msg.emit(f"点云警告:{exc}") + + try: + point_cloud_filter.set_create_point_format(OBFormat.RGB_POINT) + pc = point_cloud_filter.process(aligned) + if pc: + save_point_cloud_to_ply(str(Path(save_dir) / "point_cloud_color.ply"), pc) + except Exception as exc: + self.log_msg.emit(f"彩色点云警告:{exc}") + + # ⑤ 相机参数 + if self._camera_params: + with open(str(Path(save_dir) / "camera_params.json"), "w") as fh: + json.dump(self._camera_params, fh, indent=2) + + self.capture_done.emit(save_dir) + + # ── Static helpers ── + + @staticmethod + def _extract_params(color_frame, depth_frame) -> dict: + cvp = color_frame.get_stream_profile().as_video_stream_profile() + dvp = depth_frame.get_stream_profile().as_video_stream_profile() + + def intr(i): + return {"fx": float(i.fx), "fy": float(i.fy), + "cx": float(i.cx), "cy": float(i.cy), + "width": int(i.width), "height": int(i.height)} + + def dist(d): + return {"k1": float(d.k1), "k2": float(d.k2), + "k3": float(d.k3), "k4": float(d.k4), + "k5": float(d.k5), "k6": float(d.k6), + "p1": float(d.p1), "p2": float(d.p2)} + + ext = dvp.get_extrinsic_to(cvp) + return { + "color": { + "width": color_frame.get_width(), + "height": color_frame.get_height(), + "fps": cvp.get_fps(), + "format": str(cvp.get_format()).split(".")[-1], + "intrinsic": intr(cvp.get_intrinsic()), + "distortion": dist(cvp.get_distortion()), + }, + "depth": { + "width": depth_frame.get_width(), + "height": depth_frame.get_height(), + "fps": dvp.get_fps(), + "format": str(dvp.get_format()).split(".")[-1], + "scale": float(depth_frame.get_depth_scale()), + "intrinsic": intr(dvp.get_intrinsic()), + "distortion": dist(dvp.get_distortion()), + }, + "extrinsic": { + "rot": ext.rot.tolist(), + "transform": ext.transform.tolist(), + }, + } + + +# ───────────────────────────────────────────────────────────────── +# Batch D2C Worker Thread +# ───────────────────────────────────────────────────────────────── + +class BatchD2CWorker(QThread): + log = pyqtSignal(str) + done = pyqtSignal() + + def __init__(self, params: dict, depth_dir: str): + super().__init__() + self._params = params + self._depth_dir = depth_dir + + def run(self): + class _LineCapture(io.StringIO): + def __init__(self, sig): + super().__init__() + self._sig = sig + def write(self, s): + for line in s.splitlines(): + if line.strip(): + self._sig.emit(line) + + cap = _LineCapture(self.log) + with contextlib.redirect_stdout(cap): + try: + batch_convert(self._params, self._depth_dir) + except Exception as exc: + self.log.emit(f"Error: {exc}") + self.done.emit() + + +# ───────────────────────────────────────────────────────────────── +# Main Window +# ───────────────────────────────────────────────────────────────── + +class MainWindow(QMainWindow): + def __init__(self, pipeline, color_profile, depth_profile): + super().__init__() + self.setWindowTitle("Orbbec 数据采集 & D2C") + self.resize(1300, 820) + + self._session_dir: Path | None = None + self._batch_worker: BatchD2CWorker | None = None + + self._worker = CameraWorker(pipeline, color_profile, depth_profile) + self._worker.preview_ready.connect(self._update_preview) + self._worker.capture_done.connect(self._on_capture_done) + self._worker.params_ready.connect(self._on_params_ready) + self._worker.log_msg.connect(self._append_log) + + self._build_ui() + self._worker.start() + + # ── UI construction ── + + def _build_ui(self): + central = QWidget() + self.setCentralWidget(central) + root = QVBoxLayout(central) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + tabs = QTabWidget() + tabs.addTab(self._build_capture_tab(), " 数据采集 ") + tabs.addTab(self._build_d2c_tab(), " Orbbec Viewer 深度图 D2C ") + root.addWidget(tabs) + + def _build_capture_tab(self) -> QWidget: + w = QWidget() + layout = QVBoxLayout(w) + layout.setContentsMargins(12, 10, 12, 10) + layout.setSpacing(8) + + # ── 顶部控制行 ── + ctrl = QHBoxLayout() + + ctrl.addWidget(QLabel("保存目录名称:")) + today = datetime.now() + default_name = f"{today.year}年{today.month}月{today.day}日" + self.session_edit = QLineEdit(default_name) + self.session_edit.setMinimumWidth(180) + ctrl.addWidget(self.session_edit) + + create_btn = QPushButton("创建 / 载入目录") + create_btn.setFixedHeight(30) + create_btn.clicked.connect(self._create_session) + ctrl.addWidget(create_btn) + + self.session_status = QLabel("(未创建)") + self.session_status.setStyleSheet("color: gray; font-size: 12px;") + ctrl.addWidget(self.session_status) + + ctrl.addStretch() + + self.d2c_check = QCheckBox("启用 D2C 对齐预览") + self.d2c_check.setToolTip("勾选后实时预览使用 D2C 对齐;拍照时始终保存全部数据") + self.d2c_check.stateChanged.connect( + lambda s: self._worker.set_d2c(s == Qt.Checked)) + ctrl.addWidget(self.d2c_check) + + layout.addLayout(ctrl) + + # ── 预览区 ── + preview = QFrame() + preview.setFrameShape(QFrame.StyledPanel) + preview.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + pv_layout = QHBoxLayout(preview) + pv_layout.setSpacing(6) + pv_layout.setContentsMargins(6, 6, 6, 6) + + for attr, title in (("color_lbl", "彩色图"), ("depth_lbl", "深度伪彩图")): + box = QVBoxLayout() + hdr = QLabel(title) + hdr.setAlignment(Qt.AlignCenter) + hdr.setStyleSheet("font-weight: bold; font-size: 13px;") + box.addWidget(hdr) + lbl = QLabel("等待画面…") + lbl.setAlignment(Qt.AlignCenter) + lbl.setMinimumSize(560, 400) + lbl.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + lbl.setStyleSheet("background:#111; border:1px solid #555; color:#888;") + setattr(self, attr, lbl) + box.addWidget(lbl) + pv_layout.addLayout(box) + + layout.addWidget(preview, stretch=1) + + # ── 拍照按钮 ── + btn_row = QHBoxLayout() + btn_row.addStretch() + self.capture_btn = QPushButton("拍照") + self.capture_btn.setFixedSize(160, 48) + f = QFont() + f.setPointSize(14) + f.setBold(True) + self.capture_btn.setFont(f) + self.capture_btn.setStyleSheet( + "QPushButton{" + " background:#2a6ebb; color:white; border-radius:8px;" + " border:none;" + "}" + "QPushButton:hover{ background:#3a80dd; }" + "QPushButton:pressed{ background:#1a559e; }" + "QPushButton:disabled{ background:#555; color:#999; }" + ) + self.capture_btn.clicked.connect(self._on_capture) + btn_row.addWidget(self.capture_btn) + btn_row.addStretch() + layout.addLayout(btn_row) + + return w + + def _build_d2c_tab(self) -> QWidget: + w = QWidget() + layout = QVBoxLayout(w) + layout.setContentsMargins(12, 10, 12, 10) + layout.setSpacing(8) + + # 深度图目录 + row1 = QHBoxLayout() + row1.addWidget(QLabel("深度图目录:")) + self.d2c_dir_edit = QLineEdit() + self.d2c_dir_edit.setPlaceholderText("选择包含 PNG 深度图的文件夹…") + row1.addWidget(self.d2c_dir_edit) + br1 = QPushButton("浏览…") + br1.setFixedWidth(70) + br1.clicked.connect(lambda: self._browse_dir(self.d2c_dir_edit)) + row1.addWidget(br1) + layout.addLayout(row1) + + # 相机参数文件 + row2 = QHBoxLayout() + row2.addWidget(QLabel("相机参数文件:")) + self.params_path_edit = QLineEdit() + self.params_path_edit.setPlaceholderText(f"留空则自动查找 {PARAMS_FILE}") + row2.addWidget(self.params_path_edit) + br2 = QPushButton("浏览…") + br2.setFixedWidth(70) + br2.clicked.connect(lambda: self._browse_json(self.params_path_edit)) + row2.addWidget(br2) + layout.addLayout(row2) + + # 运行按钮 + run_row = QHBoxLayout() + run_row.addStretch() + self.run_d2c_btn = QPushButton("▶ 开始批量 D2C 转换") + self.run_d2c_btn.setFixedHeight(40) + self.run_d2c_btn.setStyleSheet( + "QPushButton{ background:#2a7a2a; color:white; border-radius:6px; border:none; }" + "QPushButton:hover{ background:#3a9a3a; }" + "QPushButton:disabled{ background:#555; color:#999; }" + ) + self.run_d2c_btn.clicked.connect(self._run_batch_d2c) + run_row.addWidget(self.run_d2c_btn) + run_row.addStretch() + layout.addLayout(run_row) + + # 日志区 + layout.addWidget(QLabel("输出日志:")) + self.d2c_log = QTextEdit() + self.d2c_log.setReadOnly(True) + self.d2c_log.setStyleSheet( + "font-family: Consolas, 'Courier New', monospace; font-size: 12px;") + layout.addWidget(self.d2c_log, stretch=1) + + return w + + # ── Slots ── + + def _update_preview(self, color_bgr: np.ndarray, depth_vis: np.ndarray): + w, h = self.color_lbl.width(), self.color_lbl.height() + self.color_lbl.setPixmap(ndarray_to_pixmap(color_bgr, w, h)) + w2, h2 = self.depth_lbl.width(), self.depth_lbl.height() + self.depth_lbl.setPixmap(ndarray_to_pixmap(depth_vis, w2, h2)) + + def _create_session(self): + name = self.session_edit.text().strip() + if not name: + QMessageBox.warning(self, "提示", "请输入目录名称。") + return + session_dir = DATA_DIR / name + session_dir.mkdir(parents=True, exist_ok=True) + self._session_dir = session_dir + + # 如果已有相机参数,立即写入 + if self._worker._camera_params: + self._save_params_to_session(self._worker._camera_params) + + self.session_status.setText(f"✓ {session_dir}") + self.session_status.setStyleSheet("color: #2a9a2a; font-size: 12px;") + + def _on_params_ready(self, params: dict): + if self._session_dir and self._session_dir.exists(): + self._save_params_to_session(params) + + def _save_params_to_session(self, params: dict): + try: + p = self._session_dir / "camera_params.json" + with open(p, "w") as fh: + json.dump(params, fh, indent=2) + except Exception: + pass + + def _on_capture(self): + # 若尚未创建目录,自动创建 + if self._session_dir is None: + self._create_session() + if self._session_dir is None: + return + + ts = datetime.now().strftime("%Y%m%d%H%M%S") + save_dir = str(self._session_dir / ts) + + self.capture_btn.setEnabled(False) + self.capture_btn.setText("拍摄中…") + self._worker.trigger_capture(save_dir) + + def _on_capture_done(self, save_dir: str): + self.capture_btn.setEnabled(True) + self.capture_btn.setText("拍照") + self._show_toast("拍摄完成!", f"已保存至:\n{save_dir}") + + def _show_toast(self, title: str, message: str, ms: int = 3000): + """短暂显示通知,ms 毫秒后自动关闭。""" + dlg = QMessageBox(QMessageBox.Information, title, message, + QMessageBox.Ok, self) + dlg.setWindowModality(Qt.NonModal) + dlg.show() + QTimer.singleShot(ms, dlg.accept) + + def _append_log(self, msg: str): + self.d2c_log.append(msg) + + # ── Batch D2C tab ── + + def _browse_dir(self, edit: QLineEdit): + d = QFileDialog.getExistingDirectory(self, "选择文件夹") + if d: + edit.setText(d) + + def _browse_json(self, edit: QLineEdit): + f, _ = QFileDialog.getOpenFileName(self, "选择相机参数文件", "", "JSON (*.json)") + if f: + edit.setText(f) + + def _resolve_params_file(self) -> str | None: + """返回有效的 camera_params.json 路径,找不到返回 None。""" + explicit = self.params_path_edit.text().strip() + if explicit and Path(explicit).is_file(): + return explicit + + # 默认路径 + for candidate in [ + Path(PARAMS_FILE), + *(([self._session_dir / "camera_params.json"] + if self._session_dir else [])), + ]: + if candidate.is_file(): + return str(candidate) + return None + + def _run_batch_d2c(self): + depth_dir = self.d2c_dir_edit.text().strip() + if not depth_dir: + QMessageBox.warning(self, "错误", "请先选择深度图目录。") + return + + params_file = self._resolve_params_file() + if params_file is None: + QMessageBox.warning( + self, "错误", + "找不到相机参数文件。\n请先在「数据采集」页面连接相机以自动生成," + "或手动指定 JSON 文件。") + return + + with open(params_file) as fh: + params = json.load(fh) + + self.d2c_log.clear() + self.d2c_log.append(f"[参数文件] {params_file}") + self.d2c_log.append(f"[深度图目录] {depth_dir}\n") + self.run_d2c_btn.setEnabled(False) + + self._batch_worker = BatchD2CWorker(params, depth_dir) + self._batch_worker.log.connect(self.d2c_log.append) + self._batch_worker.done.connect(self._on_batch_done) + self._batch_worker.start() + + def _on_batch_done(self): + self.run_d2c_btn.setEnabled(True) + self.d2c_log.append("\n[ 转换完成 ]") + + # ── Window close ── + + def closeEvent(self, event): + self._worker.stop() + super().closeEvent(event) + + +# ───────────────────────────────────────────────────────────────── +# Entry Point +# ───────────────────────────────────────────────────────────────── + +def main(): + app = QApplication(sys.argv) + app.setStyle("Fusion") + + # 深色调色板(可选,让界面更现代) + from PyQt5.QtGui import QPalette, QColor + palette = QPalette() + palette.setColor(QPalette.Window, QColor(45, 45, 45)) + palette.setColor(QPalette.WindowText, QColor(220, 220, 220)) + palette.setColor(QPalette.Base, QColor(30, 30, 30)) + palette.setColor(QPalette.AlternateBase, QColor(50, 50, 50)) + palette.setColor(QPalette.ToolTipBase, QColor(255, 255, 220)) + palette.setColor(QPalette.ToolTipText, QColor(0, 0, 0)) + palette.setColor(QPalette.Text, QColor(220, 220, 220)) + palette.setColor(QPalette.Button, QColor(60, 60, 60)) + palette.setColor(QPalette.ButtonText, QColor(220, 220, 220)) + palette.setColor(QPalette.BrightText, QColor(255, 0, 0)) + palette.setColor(QPalette.Link, QColor(42, 130, 218)) + palette.setColor(QPalette.Highlight, QColor(42, 130, 218)) + palette.setColor(QPalette.HighlightedText, QColor(0, 0, 0)) + app.setPalette(palette) + + # 分辨率选择 + dlg = ProfileDialog() + if dlg.exec_() != QDialog.Accepted: + sys.exit(0) + + if dlg.pipeline is None or dlg.selected_color is None or dlg.selected_depth is None: + QMessageBox.critical(None, "错误", "相机初始化失败,请检查设备连接后重启程序。") + sys.exit(1) + + win = MainWindow(dlg.pipeline, dlg.selected_color, dlg.selected_depth) + win.show() + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main()