From ef9103e6e967906818f60fe2c9ad6e06dc31673b Mon Sep 17 00:00:00 2001 From: Arkylin Date: Tue, 21 Apr 2026 21:26:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=AF=E4=BB=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 10 ++- main.py | 208 ++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 160 insertions(+), 58 deletions(-) diff --git a/.gitignore b/.gitignore index 9d318b5..cdd77be 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,12 @@ Log/ camera_params.json **.pyc** data/ -.env \ No newline at end of file +.env + +main.build/ +main.dist/ +main.onefile-build/ +main.spec + +dist/ +build/ \ No newline at end of file diff --git a/main.py b/main.py index 5eaad9b..c5709e7 100644 --- a/main.py +++ b/main.py @@ -166,10 +166,11 @@ class CameraWorker(QThread): log_msg = pyqtSignal(str) disconnected = pyqtSignal() - def __init__(self, color_cfg: dict, depth_cfg: dict): + def __init__(self, color_cfg: dict, depth_cfg: dict, preset: str | None = None): super().__init__() self._color_cfg = color_cfg self._depth_cfg = depth_cfg + self._preset = preset self._lock = threading.Lock() self._running = True self._d2c = False @@ -180,10 +181,9 @@ class CameraWorker(QThread): self._has_dual_ir = False self._ir_enabled = False self._last_emit_ts = 0.0 - self._last_frames = None # 最近一帧,供"按键即拍"使用 - self._capture_frames = None # 触发拍照时快照的帧 self._consec_timeouts = 0 self._disconnect_emitted = False + self._sync_wait_count = 0 # 等待同步帧的计数器 def set_d2c(self, enabled: bool): with self._lock: @@ -191,9 +191,8 @@ class CameraWorker(QThread): def trigger_capture(self, save_dir: str): with self._lock: - self._capture_dir = save_dir - self._capture_frames = self._last_frames # 快照当前帧 - self._do_capture = True + self._capture_dir = save_dir + self._do_capture = True def stop(self): self._running = False @@ -206,6 +205,7 @@ class CameraWorker(QThread): from pyorbbecsdk import ( Pipeline, Config, AlignFilter, PointCloudFilter, OBStreamType, OBSensorType, OBFrameType, OBFormat, + OBFrameAggregateOutputMode, save_point_cloud_to_ply, ) from utils import frame_to_bgr_image @@ -216,7 +216,7 @@ class CameraWorker(QThread): pipeline = Pipeline() config = Config() - # 获取 SN + 探测 IR 类型 + # 获取 SN + 探测 IR 类型 + 应用 preset try: device = pipeline.get_device() try: @@ -228,6 +228,12 @@ class CameraWorker(QThread): st = sensor_list[i].get_type() if st in (OBSensorType.LEFT_IR_SENSOR, OBSensorType.RIGHT_IR_SENSOR): self._has_dual_ir = True + if self._preset: + try: + device.load_preset(self._preset) + self.log_msg.emit(f"已加载 Preset:{self._preset}") + except Exception as exc: + self.log_msg.emit(f"Preset 加载失败:{exc}") except Exception as exc: self.log_msg.emit(f"设备信息获取警告:{exc}") @@ -245,6 +251,12 @@ class CameraWorker(QThread): config.enable_stream(color_profile) config.enable_stream(depth_profile) + # 要求所有启用的流都到齐才输出帧集,避免不同时刻的帧被混合返回 + try: + config.set_frame_aggregate_output_mode( + OBFrameAggregateOutputMode.FULL_FRAME_REQUIRE) + except Exception: + pass # 启用与深度流分辨率/帧率一致的 IR 流(找不到匹配则跳过,不影响主流) self._ir_enabled = False @@ -295,6 +307,11 @@ class CameraWorker(QThread): self.disconnected.emit() return + try: + pipeline.enable_frame_sync() + except Exception: + pass # 部分固件版本不支持,忽略 + align_filter = AlignFilter(align_to_stream=OBStreamType.COLOR_STREAM) point_cloud_filter = PointCloudFilter() @@ -312,14 +329,9 @@ class CameraWorker(QThread): self._consec_timeouts = 0 with self._lock: - self._last_frames = frames # 始终保存最新帧供快照 - d2c = self._d2c - do_cap = self._do_capture - cap_dir = self._capture_dir - cap_frames = self._capture_frames - if do_cap: - self._do_capture = False - self._capture_frames = None + d2c = self._d2c + do_cap = self._do_capture + cap_dir = self._capture_dir # 首帧提取相机参数 if self._camera_params is None: @@ -334,17 +346,34 @@ class CameraWorker(QThread): except Exception: pass - # 执行拍照 + # 执行拍照(等待 RGB-D 时间戳对齐后再拍) if do_cap and cap_dir: - try: - self._run_capture( - cap_frames or 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}") + ts_diff = self._frame_ts_diff(frames) + self._sync_wait_count += 1 + threshold_ms = 1000 // max(self._depth_cfg.get("fps", 30), 1) + force = self._sync_wait_count >= 10 + if ts_diff <= threshold_ms or force: + with self._lock: + self._do_capture = False + self._capture_dir = "" + self._sync_wait_count = 0 + if force and ts_diff > threshold_ms: + self.log_msg.emit( + f"警告:RGB-D 时间差 {ts_diff}ms 仍未对齐,强制拍摄") + else: + self.log_msg.emit(f"帧同步良好(RGB-D 差 {ts_diff}ms),开始拍摄") + 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}") + else: + self.log_msg.emit( + f"等待帧同步…RGB-D 差 {ts_diff}ms({self._sync_wait_count}/10)") continue # 预览 @@ -455,6 +484,18 @@ class CameraWorker(QThread): # ── Static helpers ── + @staticmethod + def _frame_ts_diff(frames) -> int: + """返回彩色帧与深度帧时间戳差值(ms),获取失败返回 0。""" + try: + cf = frames.get_color_frame() + df = frames.get_depth_frame() + if cf and df: + return abs(int(cf.get_timestamp()) - int(df.get_timestamp())) + except Exception: + pass + return 0 + @staticmethod def _extract_params(color_frame, depth_frame) -> dict: cvp = color_frame.get_stream_profile().as_video_stream_profile() @@ -671,6 +712,15 @@ class MainWindow(QMainWindow): self.depth_combo.setStyleSheet(COMBO_CSS) g2.addWidget(self.depth_combo) + g2.addSpacing(12) + g2.addWidget(QLabel("Preset:")) + self.preset_combo = QComboBox() + self.preset_combo.setMinimumWidth(160) + self.preset_combo.setMaxVisibleItems(12) + self.preset_combo.setStyleSheet(COMBO_CSS) + self.preset_combo.setEnabled(False) + g2.addWidget(self.preset_combo) + g2.addStretch() self.stream_btn = QPushButton("开启") self.stream_btn.setCheckable(True) @@ -770,32 +820,37 @@ class MainWindow(QMainWindow): def _build_d2c_tab(self) -> QWidget: w = QWidget() layout = QVBoxLayout(w) - layout.setContentsMargins(12, 10, 12, 10) - layout.setSpacing(8) + layout.setContentsMargins(12, 8, 12, 8) + layout.setSpacing(6) - # ── 深度图目录 ── - 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) + COMBO_CSS = "QComboBox { combobox-popup: 0; }" - # ── 相机参数 ── - params_grp = QGroupBox("相机参数") - pg = QVBoxLayout(params_grp) + # ── 第 1 步:刷新设备 ── + grp1 = self._step_group(1, "刷新设备") + g1 = QHBoxLayout(grp1) + g1.setContentsMargins(8, 4, 8, 6) + d2c_refresh_btn = QPushButton("刷新设备") + d2c_refresh_btn.setFixedHeight(28) + d2c_refresh_btn.clicked.connect(self._refresh_devices) + g1.addWidget(d2c_refresh_btn) + self.d2c_device_status = QLabel("未连接设备,请点击「刷新设备」") + self.d2c_device_status.setStyleSheet("color:#aaa; font-size:12px;") + g1.addWidget(self.d2c_device_status) + g1.addStretch() + layout.addWidget(grp1) + + # ── 第 2 步:相机参数 ── + grp2 = self._step_group(2, "相机参数(用于软件 D2C 计算)") + pg = QVBoxLayout(grp2) + pg.setContentsMargins(8, 4, 8, 6) pg.setSpacing(6) - # 从分辨率自动查找 combo_row = QHBoxLayout() combo_row.addWidget(QLabel("RGB:")) self.d2c_color_combo = QComboBox() self.d2c_color_combo.setMinimumWidth(240) self.d2c_color_combo.setMaxVisibleItems(12) - self.d2c_color_combo.setStyleSheet("QComboBox { combobox-popup: 0; }") + self.d2c_color_combo.setStyleSheet(COMBO_CSS) combo_row.addWidget(self.d2c_color_combo) combo_row.addSpacing(12) @@ -803,7 +858,7 @@ class MainWindow(QMainWindow): self.d2c_depth_combo = QComboBox() self.d2c_depth_combo.setMinimumWidth(240) self.d2c_depth_combo.setMaxVisibleItems(12) - self.d2c_depth_combo.setStyleSheet("QComboBox { combobox-popup: 0; }") + self.d2c_depth_combo.setStyleSheet(COMBO_CSS) combo_row.addWidget(self.d2c_depth_combo) combo_row.addSpacing(12) @@ -814,13 +869,11 @@ class MainWindow(QMainWindow): combo_row.addStretch() pg.addLayout(combo_row) - # 分隔线提示 sep = QLabel("── 或直接指定 JSON 文件 ──") sep.setAlignment(Qt.AlignCenter) sep.setStyleSheet("color:#666; font-size:11px;") pg.addWidget(sep) - # 手动选 JSON json_row = QHBoxLayout() self.params_path_edit = QLineEdit() self.params_path_edit.setPlaceholderText("留空则使用上方自动查找结果…") @@ -830,19 +883,33 @@ class MainWindow(QMainWindow): json_row.addWidget(br2) pg.addLayout(json_row) - # 当前选中的参数文件 self.params_selected_lbl = QLabel("(尚未选定参数文件)") self.params_selected_lbl.setStyleSheet("color:#888; font-size:12px;") pg.addWidget(self.params_selected_lbl) + layout.addWidget(grp2) - layout.addWidget(params_grp) + # ── 第 3 步:深度图目录 ── + grp3 = self._step_group(3, "深度图目录") + g3 = QHBoxLayout(grp3) + g3.setContentsMargins(8, 4, 8, 6) + g3.setSpacing(8) + self.d2c_dir_edit = QLineEdit() + self.d2c_dir_edit.setPlaceholderText("选择包含 PNG 深度图的文件夹…") + g3.addWidget(self.d2c_dir_edit) + br1 = QPushButton("浏览…"); br1.setFixedWidth(70) + br1.clicked.connect(lambda: self._browse_dir(self.d2c_dir_edit)) + g3.addWidget(br1) + layout.addWidget(grp3) - # ── Splat + 运行 ── - opt_run = QHBoxLayout() + # ── 第 4 步:开始转换 ── + step4_row = QHBoxLayout() + step4_lbl = QLabel("第 4 步 · 开始转换") + step4_lbl.setStyleSheet("color:#8ec8ff; font-weight:bold; font-size:12px;") + step4_row.addWidget(step4_lbl) self.splat_check = QCheckBox("Splat 3×3 填充(减少 D2C 空洞,默认开启)") self.splat_check.setChecked(True) - opt_run.addWidget(self.splat_check) - opt_run.addStretch() + step4_row.addWidget(self.splat_check) + step4_row.addStretch() self.run_d2c_btn = QPushButton("▶ 开始批量 D2C 转换") self.run_d2c_btn.setFixedHeight(36) self.run_d2c_btn.setStyleSheet( @@ -851,8 +918,8 @@ class MainWindow(QMainWindow): "QPushButton:disabled{ background:#444; color:#777; }" ) self.run_d2c_btn.clicked.connect(self._run_batch_d2c) - opt_run.addWidget(self.run_d2c_btn) - layout.addLayout(opt_run) + step4_row.addWidget(self.run_d2c_btn) + layout.addLayout(step4_row) # ── 日志 ── layout.addWidget(QLabel("输出日志:")) @@ -875,6 +942,7 @@ class MainWindow(QMainWindow): self.color_combo.clear() self.depth_combo.clear() + self.preset_combo.clear() self.d2c_color_combo.clear() self.d2c_depth_combo.clear() self._color_profiles_data.clear() @@ -916,14 +984,39 @@ class MainWindow(QMainWindow): self.depth_combo.addItem(label, cfg) self.d2c_depth_combo.addItem(label, cfg) + # 枚举 preset + try: + preset_list = p.get_device().get_available_preset_list() + current_preset = p.get_device().get_current_preset_name() + if preset_list and len(preset_list) > 0: + self.preset_combo.addItem("── 不切换 ──", None) + for name in preset_list: + self.preset_combo.addItem(name, name) + # 选中当前 preset + idx = self.preset_combo.findData(current_preset) + if idx >= 0: + self.preset_combo.setCurrentIndex(idx) + self.preset_combo.setEnabled(True) + else: + self.preset_combo.addItem("(设备不支持)", None) + self.preset_combo.setEnabled(False) + except Exception: + self.preset_combo.addItem("(不支持)", None) + self.preset_combo.setEnabled(False) + nc, nd = len(self._color_profiles_data), len(self._depth_profiles_data) - self.device_status.setText( - f"已连接 SN: {sn} 彩色 {nc} 项 / 深度 {nd} 项") + msg = f"已连接 SN: {sn} 彩色 {nc} 项 / 深度 {nd} 项" + self.device_status.setText(msg) self.device_status.setStyleSheet("color:#4aaa4a; font-size:12px;") + self.d2c_device_status.setText(msg) + self.d2c_device_status.setStyleSheet("color:#4aaa4a; font-size:12px;") except Exception as exc: - self.device_status.setText(f"未找到设备:{exc}") + msg = f"未找到设备:{exc}" + self.device_status.setText(msg) self.device_status.setStyleSheet("color:#cc6666; font-size:12px;") + self.d2c_device_status.setText(msg) + self.d2c_device_status.setStyleSheet("color:#cc6666; font-size:12px;") # ───────────────────────────────── # Stream Toggle @@ -947,7 +1040,8 @@ class MainWindow(QMainWindow): def _start_stream(self, color_cfg: dict, depth_cfg: dict): self._stop_stream() # 确保旧 worker 已停止 - self._worker = CameraWorker(color_cfg, depth_cfg) + preset = self.preset_combo.currentData() # None 表示不切换 + self._worker = CameraWorker(color_cfg, depth_cfg, preset=preset) 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)