From 2996ba96e7cebea18edf9a81d0edb55292eda847 Mon Sep 17 00:00:00 2001 From: Arkylin Date: Wed, 22 Apr 2026 10:45:09 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B9=E4=BA=86=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- d2c_batch.py | 17 +++---- main.py | 122 +++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 102 insertions(+), 37 deletions(-) diff --git a/d2c_batch.py b/d2c_batch.py index 8e67d96..3f03960 100644 --- a/d2c_batch.py +++ b/d2c_batch.py @@ -225,7 +225,7 @@ def _undistort_points(u, v, intr, dist): return x_u, y_u -def software_d2c(depth_img, params, splat=True): +def software_d2c(depth_img, params, splat_radius=1): """ Transform a uint16 depth image (depth camera space) to an aligned uint16 depth image in color camera space. @@ -301,12 +301,12 @@ def software_d2c(depth_img, params, splat=True): aligned = np.zeros((color_h, color_w), dtype=np.uint16) - if splat: - # Splat each depth pixel to a 3×3 neighbourhood to fill sub-pixel gaps + if splat_radius > 0: + # Splat each depth pixel to an N×N neighbourhood to fill sub-pixel gaps # that arise from forward projection. Far→near order is preserved so # the nearest surface always wins when splats overlap. - for dv in range(-1, 2): - for du in range(-1, 2): + for dv in range(-splat_radius, splat_radius + 1): + for du in range(-splat_radius, splat_radius + 1): us = np.clip(u_vals + du, 0, color_w - 1) vs = np.clip(v_vals + dv, 0, color_h - 1) aligned[vs, us] = z_vals @@ -333,7 +333,7 @@ def depth_to_colormap(aligned, min_depth_mm=200, max_depth_mm=5000, depth_scale= return colormap -def batch_convert(params, depth_dir_str, splat=True): +def batch_convert(params, depth_dir_str, splat_radius=1): depth_dir = Path(depth_dir_str.strip().strip('"\'')) if not depth_dir.exists(): print(f" Directory not found: {depth_dir}") @@ -361,7 +361,8 @@ def batch_convert(params, depth_dir_str, splat=True): print(f" Files : {len(png_files)}") print(f" Depth scale : {depth_scale:.6f} mm/unit") print(f" Output size : {color_w}x{color_h} (color resolution)") - print(f" Splat (3×3) : {'on' if splat else 'off'}") + sz = 2 * splat_radius + 1 + print(f" Splat : {f'{sz}×{sz}' if splat_radius > 0 else 'off'}") print() t0 = time.time() @@ -382,7 +383,7 @@ def batch_convert(params, depth_dir_str, splat=True): depth_img = depth_img[:, :, 0] depth_img = depth_img.astype(np.uint16) - aligned = software_d2c(depth_img, params, splat=splat) + aligned = software_d2c(depth_img, params, splat_radius=splat_radius) # Save aligned uint16 depth cv2.imwrite(str(out_dir / fpath.name), aligned) diff --git a/main.py b/main.py index 0d31077..6f5f5ef 100644 --- a/main.py +++ b/main.py @@ -76,24 +76,31 @@ def _sz(n: float) -> int: # ───────────────────────────────────────────────────────────────── class AppSettings: - _CONFIG_PATH = Path(__file__).parent / "app_settings.json" + _APP_DIR = Path(os.environ.get("APPDATA", Path.home())) / "OrbbecD2C" + _CONFIG_PATH = _APP_DIR / "app_settings.json" FONT_SCALE_AUTO = "auto" def __init__(self): - self.font_scale: str = self.FONT_SCALE_AUTO + self.font_scale: str = self.FONT_SCALE_AUTO + self.splat_radius: int = 1 # 0=关闭, 1=3×3, 2=5×5, 3=7×7 self._load() def _load(self): if self._CONFIG_PATH.is_file(): try: data = json.loads(self._CONFIG_PATH.read_text(encoding="utf-8")) - self.font_scale = data.get("font_scale", self.FONT_SCALE_AUTO) + self.font_scale = data.get("font_scale", self.FONT_SCALE_AUTO) + self.splat_radius = int(data.get("splat_radius", 1)) except Exception: pass def save(self): + self._APP_DIR.mkdir(parents=True, exist_ok=True) self._CONFIG_PATH.write_text( - json.dumps({"font_scale": self.font_scale}, ensure_ascii=False, indent=2), + json.dumps( + {"font_scale": self.font_scale, "splat_radius": self.splat_radius}, + ensure_ascii=False, indent=2, + ), encoding="utf-8", ) @@ -122,7 +129,7 @@ class SettingsDialog(QDialog): def __init__(self, settings: "AppSettings", parent=None): super().__init__(parent) self.setWindowTitle("程序设置") - self.setFixedSize(_sz(420), _sz(160)) + self.setFixedSize(_sz(440), _sz(210)) self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) self._settings = settings self._build_ui() @@ -161,6 +168,25 @@ class SettingsDialog(QDialog): info.setStyleSheet(f"color:#888; font-size:{_fs(11)}px;") layout.addWidget(info) + splat_row = QHBoxLayout() + splat_lbl = QLabel("Splat 填充大小:") + splat_lbl.setMinimumWidth(_sz(110)) + splat_row.addWidget(splat_lbl) + self._splat_combo = QComboBox() + self._splat_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents) + for val, label in [(0, "关闭"), (1, "3×3"), (2, "5×5"), (3, "7×7")]: + self._splat_combo.addItem(label, val) + idx = self._splat_combo.findData(self._settings.splat_radius) + if idx >= 0: + self._splat_combo.setCurrentIndex(idx) + splat_row.addWidget(self._splat_combo) + splat_row.addStretch() + layout.addLayout(splat_row) + + splat_info = QLabel("Splat 填充可减少深度图 D2C 后的空洞(修改立即对当前会话生效)") + splat_info.setStyleSheet(f"color:#888; font-size:{_fs(11)}px;") + layout.addWidget(splat_info) + layout.addStretch() btn_row = QHBoxLayout() @@ -176,7 +202,8 @@ class SettingsDialog(QDialog): layout.addLayout(btn_row) def _save_and_close(self): - self._settings.font_scale = self._scale_combo.currentData() + self._settings.font_scale = self._scale_combo.currentData() + self._settings.splat_radius = self._splat_combo.currentData() self._settings.save() self.accept() @@ -219,6 +246,17 @@ def ndarray_to_pixmap(img_bgr: np.ndarray, max_w: int, max_h: int) -> QPixmap: return QPixmap.fromImage(qimg) +def _splat_depth(depth: np.ndarray, radius: int) -> np.ndarray: + """Dilate non-zero depth pixels outward to fill projection gaps, then restore originals.""" + if radius <= 0: + return depth + kernel = np.ones((2 * radius + 1, 2 * radius + 1), dtype=np.uint8) + dilated = cv2.dilate(depth, kernel) + valid = depth > 0 + dilated[valid] = depth[valid] + return dilated + + def depth_to_vis(depth_uint16: np.ndarray, scale: float) -> np.ndarray: 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) @@ -306,11 +344,16 @@ class CameraWorker(QThread): self._consec_timeouts = 0 self._disconnect_emitted = False self._sync_wait_count = 0 # 等待同步帧的计数器 + self._splat_radius = 0 def set_d2c(self, enabled: bool): with self._lock: self._d2c = enabled + def set_splat(self, radius: int): + with self._lock: + self._splat_radius = radius + def trigger_capture(self, save_dir: str): with self._lock: self._capture_dir = save_dir @@ -451,9 +494,10 @@ class CameraWorker(QThread): self._consec_timeouts = 0 with self._lock: - d2c = self._d2c - do_cap = self._do_capture - cap_dir = self._capture_dir + d2c = self._d2c + do_cap = self._do_capture + cap_dir = self._capture_dir + splat_r = self._splat_radius # 首帧提取相机参数 if self._camera_params is None: @@ -516,6 +560,8 @@ class CameraWorker(QThread): depth_data = np.frombuffer(df.get_data(), dtype=np.uint16).reshape( df.get_height(), df.get_width()) + if splat_r > 0: + depth_data = _splat_depth(depth_data, splat_r) depth_vis = depth_to_vis(depth_data, df.get_depth_scale()) now = time.monotonic() @@ -763,11 +809,11 @@ class BatchD2CWorker(QThread): log = pyqtSignal(str) done = pyqtSignal() - def __init__(self, params: dict, depth_dir: str, splat: bool = True): + def __init__(self, params: dict, depth_dir: str, splat_radius: int = 1): super().__init__() - self._params = params - self._depth_dir = depth_dir - self._splat = splat + self._params = params + self._depth_dir = depth_dir + self._splat_radius = splat_radius def run(self): class _Cap(io.StringIO): @@ -781,7 +827,7 @@ class BatchD2CWorker(QThread): with contextlib.redirect_stdout(_Cap(self.log)): try: - batch_convert(self._params, self._depth_dir, splat=self._splat) + batch_convert(self._params, self._depth_dir, splat_radius=self._splat_radius) except Exception as exc: self.log.emit(f"Error: {exc}") self.done.emit() @@ -888,7 +934,15 @@ class MainWindow(QMainWindow): def _show_settings(self): dlg = SettingsDialog(self._app_settings, self) - dlg.exec_() + if dlg.exec_() == QDialog.Accepted: + # 立即更新当前流的 splat 半径 + if self._worker and self.capture_splat_check.isChecked(): + self._worker.set_splat(max(1, self._app_settings.splat_radius)) + + def _on_capture_splat_changed(self, state: int): + radius = max(1, self._app_settings.splat_radius) if state == Qt.Checked else 0 + if self._worker: + self._worker.set_splat(radius) @staticmethod def _step_group(num: int, title: str) -> QGroupBox: @@ -995,6 +1049,14 @@ class MainWindow(QMainWindow): self.d2c_check.stateChanged.connect( lambda s: self._worker and self._worker.set_d2c(s == Qt.Checked)) g3.addWidget(self.d2c_check) + + g3.addSpacing(8) + self.capture_splat_check = QCheckBox("Splat 填充") + self.capture_splat_check.setToolTip( + "对深度图应用 Splat 扩散填充,减少 D2C 空洞(大小在「设置→程序设置」中配置)") + self.capture_splat_check.setChecked(self._app_settings.splat_radius > 0) + self.capture_splat_check.stateChanged.connect(self._on_capture_splat_changed) + g3.addWidget(self.capture_splat_check) layout.addWidget(grp3) # ── 预览区 ── @@ -1142,7 +1204,7 @@ class MainWindow(QMainWindow): step4_lbl = QLabel("第 4 步 · 开始转换") step4_lbl.setStyleSheet(f"color:#8ec8ff; font-weight:bold; font-size:{_fs(12)}px;") step4_row.addWidget(step4_lbl) - self.splat_check = QCheckBox("Splat 3×3 填充(减少 D2C 空洞,默认开启)") + self.splat_check = QCheckBox("Splat 填充(减少 D2C 空洞,默认开启)") self.splat_check.setChecked(True) step4_row.addWidget(self.splat_check) step4_row.addStretch() @@ -1244,16 +1306,16 @@ class MainWindow(QMainWindow): nc, nd = len(self._color_profiles_data), len(self._depth_profiles_data) msg = f"已连接 SN: {sn} 彩色 {nc} 项 / 深度 {nd} 项" self.device_status.setText(msg) - self.device_status.setStyleSheet("color:#4aaa4a; font-size:12px;") + self.device_status.setStyleSheet(f"color:#4aaa4a; font-size:{_fs(12)}px;") self.d2c_device_status.setText(msg) - self.d2c_device_status.setStyleSheet("color:#4aaa4a; font-size:12px;") + self.d2c_device_status.setStyleSheet(f"color:#4aaa4a; font-size:{_fs(12)}px;") except Exception as exc: msg = f"未找到设备:{exc}" self.device_status.setText(msg) - self.device_status.setStyleSheet("color:#cc6666; font-size:12px;") + self.device_status.setStyleSheet(f"color:#cc6666; font-size:{_fs(12)}px;") self.d2c_device_status.setText(msg) - self.d2c_device_status.setStyleSheet("color:#cc6666; font-size:12px;") + self.d2c_device_status.setStyleSheet(f"color:#cc6666; font-size:{_fs(12)}px;") # ───────────────────────────────── # Stream Toggle @@ -1279,6 +1341,8 @@ class MainWindow(QMainWindow): self._stop_stream() # 确保旧 worker 已停止 preset = self.preset_combo.currentData() # None 表示不切换 self._worker = CameraWorker(color_cfg, depth_cfg, preset=preset) + if self.capture_splat_check.isChecked(): + self._worker.set_splat(max(1, self._app_settings.splat_radius)) 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) @@ -1323,7 +1387,7 @@ class MainWindow(QMainWindow): lbl.setText("设备已断开,请刷新后重新开启") lbl.setPixmap(QPixmap()) self.device_status.setText("设备已断开连接,请重新插入后点击「刷新设备」") - self.device_status.setStyleSheet("color:#cc8844; font-size:12px;") + self.device_status.setStyleSheet(f"color:#cc8844; font-size:{_fs(12)}px;") # ───────────────────────────────── # Preview @@ -1350,7 +1414,7 @@ class MainWindow(QMainWindow): if self._worker and 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;") + self.session_status.setStyleSheet(f"color:#2a9a2a; font-size:{_fs(12)}px;") def _on_params_ready(self, params: dict): sn = params.get("device", {}).get("serial_number", "unknown") @@ -1394,18 +1458,18 @@ class MainWindow(QMainWindow): di = self.d2c_depth_combo.currentIndex() if ci < 0 or di < 0: self.params_selected_lbl.setText("请先刷新设备并选择分辨率") - self.params_selected_lbl.setStyleSheet("color:#cc6666; font-size:12px;") + self.params_selected_lbl.setStyleSheet(f"color:#cc6666; font-size:{_fs(12)}px;") return cc = self.d2c_color_combo.itemData(ci) dc = self.d2c_depth_combo.itemData(di) if not cc or not dc: self.params_selected_lbl.setText("无配置数据,请先刷新设备") - self.params_selected_lbl.setStyleSheet("color:#cc6666; font-size:12px;") + self.params_selected_lbl.setStyleSheet(f"color:#cc6666; font-size:{_fs(12)}px;") return self.params_selected_lbl.setText("正在从设备获取参数…") - self.params_selected_lbl.setStyleSheet("color:#888; font-size:12px;") + self.params_selected_lbl.setStyleSheet(f"color:#888; font-size:{_fs(12)}px;") self._fetch_worker = FetchParamsWorker(cc, dc) self._fetch_worker.log.connect(self._append_d2c_log) self._fetch_worker.params_ready.connect(self._on_fetch_params_ready) @@ -1414,7 +1478,7 @@ class MainWindow(QMainWindow): def _on_fetch_params_ready(self, params: dict, path: str): self.params_path_edit.setText(path) self.params_selected_lbl.setText(f"✓ 已获取并缓存:{path}") - self.params_selected_lbl.setStyleSheet("color:#4aaa4a; font-size:12px;") + self.params_selected_lbl.setStyleSheet(f"color:#4aaa4a; font-size:{_fs(12)}px;") self._append_d2c_log( f"[参数获取] RGB {params['color']['width']}x{params['color']['height']} " f"/ Depth {params['depth']['width']}x{params['depth']['height']}" @@ -1476,7 +1540,7 @@ class MainWindow(QMainWindow): if f: edit.setText(f) self.params_selected_lbl.setText(f"✓ {f}") - self.params_selected_lbl.setStyleSheet("color:#4aaa4a; font-size:12px;") + self.params_selected_lbl.setStyleSheet(f"color:#4aaa4a; font-size:{_fs(12)}px;") def _run_batch_d2c(self): depth_dir = self.d2c_dir_edit.text().strip() @@ -1496,8 +1560,8 @@ class MainWindow(QMainWindow): 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, - splat=self.splat_check.isChecked()) + splat_r = self._app_settings.splat_radius if self.splat_check.isChecked() else 0 + self._batch_worker = BatchD2CWorker(params, depth_dir, splat_radius=splat_r) self._batch_worker.log.connect(self.d2c_log.append) self._batch_worker.done.connect(self._on_batch_done) self._batch_worker.start()