2026-04-21 20:26:16 +08:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""
|
|
|
|
|
|
Orbbec 数据采集 GUI
|
2026-04-21 20:42:56 +08:00
|
|
|
|
- 主界面直接打开,刷新后选择分辨率 + 开关流
|
|
|
|
|
|
- 数据采集:实时预览 + 一键拍照
|
2026-04-21 20:26:16 +08:00
|
|
|
|
- Orbbec Viewer 深度图 D2C:批量软件 D2C 转换
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import json
|
|
|
|
|
|
import time
|
|
|
|
|
|
import threading
|
2026-04-21 20:26:21 +08:00
|
|
|
|
import tempfile
|
|
|
|
|
|
import shutil
|
2026-04-21 20:26:16 +08:00
|
|
|
|
import contextlib
|
|
|
|
|
|
import io
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
import cv2
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
|
|
|
|
|
|
def _load_env() -> dict:
|
|
|
|
|
|
"""解析脚本同级目录下的 .env 文件,返回 key=value 字典。"""
|
|
|
|
|
|
env_path = Path(__file__).parent / ".env"
|
|
|
|
|
|
result: dict = {}
|
|
|
|
|
|
if not env_path.is_file():
|
|
|
|
|
|
return result
|
|
|
|
|
|
for line in env_path.read_text(encoding="utf-8").splitlines():
|
|
|
|
|
|
line = line.strip()
|
|
|
|
|
|
if not line or line.startswith("#"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if "=" in line:
|
|
|
|
|
|
k, _, v = line.partition("=")
|
|
|
|
|
|
result[k.strip()] = v.strip()
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_ENV = _load_env()
|
|
|
|
|
|
_COPYRIGHT = _ENV.get("COPYRIGHT", "")
|
|
|
|
|
|
|
2026-04-21 20:26:16 +08:00
|
|
|
|
from PyQt5.QtWidgets import (
|
|
|
|
|
|
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
|
|
|
|
QLabel, QPushButton, QLineEdit, QCheckBox, QTabWidget,
|
2026-04-21 20:42:56 +08:00
|
|
|
|
QComboBox, QFileDialog, QTextEdit, QFrame, QMessageBox,
|
2026-04-22 10:36:05 +08:00
|
|
|
|
QSizePolicy, QGroupBox, QDialog, QAction,
|
2026-04-21 20:26:16 +08:00
|
|
|
|
)
|
|
|
|
|
|
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer
|
|
|
|
|
|
from PyQt5.QtGui import QImage, QPixmap, QFont
|
|
|
|
|
|
|
2026-04-22 11:35:40 +08:00
|
|
|
|
from d2c_batch import batch_convert, software_d2c, software_d2c_inv
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
PARAMS_FILE = "camera_params.json"
|
2026-04-21 20:42:56 +08:00
|
|
|
|
MIN_DEPTH_MM, MAX_DEPTH_MM = 200, 5000
|
|
|
|
|
|
_DISCONNECT_TIMEOUTS = 6 # 6 × 500 ms = 3 s 无帧 → 断线
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-22 10:36:05 +08:00
|
|
|
|
_UI_SCALE: float = 1.0 # initialized in main() before window creation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fs(n: float) -> int:
|
|
|
|
|
|
"""Scale a font-size (px or pt) value."""
|
|
|
|
|
|
return max(8, int(n * _UI_SCALE))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sz(n: float) -> int:
|
|
|
|
|
|
"""Scale a UI dimension (px)."""
|
|
|
|
|
|
return max(1, int(n * _UI_SCALE))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
# App Settings
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class AppSettings:
|
2026-04-22 10:45:09 +08:00
|
|
|
|
_APP_DIR = Path(os.environ.get("APPDATA", Path.home())) / "OrbbecD2C"
|
|
|
|
|
|
_CONFIG_PATH = _APP_DIR / "app_settings.json"
|
2026-04-22 10:36:05 +08:00
|
|
|
|
FONT_SCALE_AUTO = "auto"
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
2026-04-22 10:49:07 +08:00
|
|
|
|
self.font_scale: str = self.FONT_SCALE_AUTO
|
|
|
|
|
|
self.splat_radius: int = 1 # 0=关闭, 1=3×3, 2=5×5, 3=7×7
|
|
|
|
|
|
self.save_base_dir: str = "" # 空 = 当前目录
|
|
|
|
|
|
self.capture_rgb: bool = True
|
|
|
|
|
|
self.capture_depth: bool = True
|
|
|
|
|
|
self.capture_d2c: bool = True
|
|
|
|
|
|
self.capture_irl: bool = False
|
|
|
|
|
|
self.capture_irr: bool = False
|
|
|
|
|
|
self.capture_pc: bool = False
|
|
|
|
|
|
self.capture_pc_color:bool = False
|
2026-04-22 10:36:05 +08:00
|
|
|
|
self._load()
|
|
|
|
|
|
|
|
|
|
|
|
def _load(self):
|
|
|
|
|
|
if self._CONFIG_PATH.is_file():
|
|
|
|
|
|
try:
|
2026-04-22 10:49:07 +08:00
|
|
|
|
d = json.loads(self._CONFIG_PATH.read_text(encoding="utf-8"))
|
|
|
|
|
|
self.font_scale = d.get("font_scale", self.FONT_SCALE_AUTO)
|
|
|
|
|
|
self.splat_radius = int(d.get("splat_radius", 1))
|
|
|
|
|
|
self.save_base_dir = d.get("save_base_dir", "")
|
|
|
|
|
|
self.capture_rgb = bool(d.get("capture_rgb", True))
|
|
|
|
|
|
self.capture_depth = bool(d.get("capture_depth", True))
|
|
|
|
|
|
self.capture_d2c = bool(d.get("capture_d2c", True))
|
|
|
|
|
|
self.capture_irl = bool(d.get("capture_irl", False))
|
|
|
|
|
|
self.capture_irr = bool(d.get("capture_irr", False))
|
|
|
|
|
|
self.capture_pc = bool(d.get("capture_pc", False))
|
|
|
|
|
|
self.capture_pc_color= bool(d.get("capture_pc_color", False))
|
2026-04-22 10:36:05 +08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
def save(self):
|
2026-04-22 10:45:09 +08:00
|
|
|
|
self._APP_DIR.mkdir(parents=True, exist_ok=True)
|
2026-04-22 10:36:05 +08:00
|
|
|
|
self._CONFIG_PATH.write_text(
|
2026-04-22 10:45:09 +08:00
|
|
|
|
json.dumps(
|
2026-04-22 10:49:07 +08:00
|
|
|
|
{
|
|
|
|
|
|
"font_scale": self.font_scale,
|
|
|
|
|
|
"splat_radius": self.splat_radius,
|
|
|
|
|
|
"save_base_dir": self.save_base_dir,
|
|
|
|
|
|
"capture_rgb": self.capture_rgb,
|
|
|
|
|
|
"capture_depth": self.capture_depth,
|
|
|
|
|
|
"capture_d2c": self.capture_d2c,
|
|
|
|
|
|
"capture_irl": self.capture_irl,
|
|
|
|
|
|
"capture_irr": self.capture_irr,
|
|
|
|
|
|
"capture_pc": self.capture_pc,
|
|
|
|
|
|
"capture_pc_color": self.capture_pc_color,
|
|
|
|
|
|
},
|
2026-04-22 10:45:09 +08:00
|
|
|
|
ensure_ascii=False, indent=2,
|
|
|
|
|
|
),
|
2026-04-22 10:36:05 +08:00
|
|
|
|
encoding="utf-8",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def compute_auto_scale() -> float:
|
|
|
|
|
|
screen = QApplication.primaryScreen()
|
|
|
|
|
|
if screen:
|
|
|
|
|
|
dpr = screen.devicePixelRatio()
|
|
|
|
|
|
return round(max(0.8, min(2.0, dpr)), 2)
|
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
|
|
def get_scale_factor(self) -> float:
|
|
|
|
|
|
if self.font_scale == self.FONT_SCALE_AUTO:
|
|
|
|
|
|
return self.compute_auto_scale()
|
|
|
|
|
|
try:
|
|
|
|
|
|
return float(self.font_scale)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
# Settings Dialog
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class SettingsDialog(QDialog):
|
|
|
|
|
|
def __init__(self, settings: "AppSettings", parent=None):
|
|
|
|
|
|
super().__init__(parent)
|
|
|
|
|
|
self.setWindowTitle("程序设置")
|
2026-04-22 10:49:07 +08:00
|
|
|
|
self.setFixedSize(_sz(520), _sz(360))
|
2026-04-22 10:36:05 +08:00
|
|
|
|
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
|
|
|
|
|
|
self._settings = settings
|
|
|
|
|
|
self._build_ui()
|
|
|
|
|
|
|
|
|
|
|
|
def _build_ui(self):
|
|
|
|
|
|
layout = QVBoxLayout(self)
|
|
|
|
|
|
layout.setContentsMargins(_sz(16), _sz(14), _sz(16), _sz(14))
|
|
|
|
|
|
layout.setSpacing(_sz(10))
|
|
|
|
|
|
|
|
|
|
|
|
row = QHBoxLayout()
|
|
|
|
|
|
lbl = QLabel("字体缩放比例:")
|
|
|
|
|
|
lbl.setMinimumWidth(_sz(110))
|
|
|
|
|
|
row.addWidget(lbl)
|
|
|
|
|
|
|
|
|
|
|
|
self._scale_combo = QComboBox()
|
|
|
|
|
|
self._scale_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
|
|
|
|
|
for key, label in [
|
|
|
|
|
|
("auto", "自动(根据屏幕分辨率和缩放比例)"),
|
|
|
|
|
|
("0.8", "0.8× 小"),
|
|
|
|
|
|
("1.0", "1.0× 标准"),
|
|
|
|
|
|
("1.2", "1.2× 稍大"),
|
|
|
|
|
|
("1.5", "1.5× 大"),
|
|
|
|
|
|
("2.0", "2.0× 特大"),
|
|
|
|
|
|
]:
|
|
|
|
|
|
self._scale_combo.addItem(label, key)
|
|
|
|
|
|
|
|
|
|
|
|
idx = self._scale_combo.findData(self._settings.font_scale)
|
|
|
|
|
|
if idx >= 0:
|
|
|
|
|
|
self._scale_combo.setCurrentIndex(idx)
|
|
|
|
|
|
row.addWidget(self._scale_combo)
|
|
|
|
|
|
row.addStretch()
|
|
|
|
|
|
layout.addLayout(row)
|
|
|
|
|
|
|
|
|
|
|
|
auto_val = AppSettings.compute_auto_scale()
|
|
|
|
|
|
info = QLabel(f"修改后需重启程序生效。当前屏幕自动比例:×{auto_val:.2f}")
|
|
|
|
|
|
info.setStyleSheet(f"color:#888; font-size:{_fs(11)}px;")
|
|
|
|
|
|
layout.addWidget(info)
|
|
|
|
|
|
|
2026-04-22 10:45:09 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-04-22 10:49:07 +08:00
|
|
|
|
# ── 分隔 ──
|
|
|
|
|
|
sep = QLabel("── 采集设置 ──")
|
|
|
|
|
|
sep.setAlignment(Qt.AlignCenter)
|
|
|
|
|
|
sep.setStyleSheet(f"color:#666; font-size:{_fs(11)}px; margin-top:{_sz(4)}px;")
|
|
|
|
|
|
layout.addWidget(sep)
|
|
|
|
|
|
|
|
|
|
|
|
# 保存根目录
|
|
|
|
|
|
dir_row = QHBoxLayout()
|
|
|
|
|
|
dir_lbl = QLabel("根目录:")
|
|
|
|
|
|
dir_lbl.setMinimumWidth(_sz(80))
|
|
|
|
|
|
dir_row.addWidget(dir_lbl)
|
|
|
|
|
|
self._base_dir_edit = QLineEdit(self._settings.save_base_dir)
|
|
|
|
|
|
self._base_dir_edit.setPlaceholderText("留空 = 当前工作目录")
|
|
|
|
|
|
dir_row.addWidget(self._base_dir_edit)
|
|
|
|
|
|
browse_btn = QPushButton("浏览…")
|
|
|
|
|
|
browse_btn.setFixedWidth(_sz(60))
|
|
|
|
|
|
browse_btn.clicked.connect(self._browse_save_dir)
|
|
|
|
|
|
dir_row.addWidget(browse_btn)
|
|
|
|
|
|
layout.addLayout(dir_row)
|
|
|
|
|
|
|
|
|
|
|
|
# 默认采集内容
|
|
|
|
|
|
cap_lbl = QLabel("默认采集内容:")
|
|
|
|
|
|
layout.addWidget(cap_lbl)
|
|
|
|
|
|
|
|
|
|
|
|
self._cap_checks: dict = {}
|
|
|
|
|
|
cap_items = [
|
|
|
|
|
|
("capture_rgb", "RGB"),
|
|
|
|
|
|
("capture_depth", "Depth"),
|
|
|
|
|
|
("capture_d2c", "Depth D2C"),
|
|
|
|
|
|
("capture_irl", "IR-L"),
|
|
|
|
|
|
("capture_irr", "IR-R"),
|
|
|
|
|
|
("capture_pc", "点云"),
|
|
|
|
|
|
("capture_pc_color", "颜色点云"),
|
|
|
|
|
|
]
|
|
|
|
|
|
cap_row1 = QHBoxLayout()
|
|
|
|
|
|
cap_row1.setSpacing(_sz(8))
|
|
|
|
|
|
for attr, label in cap_items[:5]:
|
|
|
|
|
|
cb = QCheckBox(label)
|
|
|
|
|
|
cb.setChecked(getattr(self._settings, attr, True))
|
|
|
|
|
|
cap_row1.addWidget(cb)
|
|
|
|
|
|
self._cap_checks[attr] = cb
|
|
|
|
|
|
cap_row1.addStretch()
|
|
|
|
|
|
layout.addLayout(cap_row1)
|
|
|
|
|
|
|
|
|
|
|
|
cap_row2 = QHBoxLayout()
|
|
|
|
|
|
cap_row2.setSpacing(_sz(8))
|
|
|
|
|
|
for attr, label in cap_items[5:]:
|
|
|
|
|
|
cb = QCheckBox(label)
|
|
|
|
|
|
cb.setChecked(getattr(self._settings, attr, False))
|
|
|
|
|
|
cap_row2.addWidget(cb)
|
|
|
|
|
|
self._cap_checks[attr] = cb
|
|
|
|
|
|
cap_row2.addStretch()
|
|
|
|
|
|
layout.addLayout(cap_row2)
|
|
|
|
|
|
|
2026-04-22 10:36:05 +08:00
|
|
|
|
layout.addStretch()
|
|
|
|
|
|
|
|
|
|
|
|
btn_row = QHBoxLayout()
|
|
|
|
|
|
btn_row.addStretch()
|
|
|
|
|
|
ok_btn = QPushButton("确定")
|
|
|
|
|
|
ok_btn.setFixedWidth(_sz(80))
|
|
|
|
|
|
ok_btn.clicked.connect(self._save_and_close)
|
|
|
|
|
|
cancel_btn = QPushButton("取消")
|
|
|
|
|
|
cancel_btn.setFixedWidth(_sz(80))
|
|
|
|
|
|
cancel_btn.clicked.connect(self.reject)
|
|
|
|
|
|
btn_row.addWidget(ok_btn)
|
|
|
|
|
|
btn_row.addWidget(cancel_btn)
|
|
|
|
|
|
layout.addLayout(btn_row)
|
|
|
|
|
|
|
2026-04-22 10:49:07 +08:00
|
|
|
|
def _browse_save_dir(self):
|
|
|
|
|
|
d = QFileDialog.getExistingDirectory(
|
|
|
|
|
|
self, "选择数据保存根目录",
|
|
|
|
|
|
self._base_dir_edit.text() or str(Path.cwd()))
|
|
|
|
|
|
if d:
|
|
|
|
|
|
self._base_dir_edit.setText(d)
|
|
|
|
|
|
|
2026-04-22 10:36:05 +08:00
|
|
|
|
def _save_and_close(self):
|
2026-04-22 10:49:07 +08:00
|
|
|
|
self._settings.font_scale = self._scale_combo.currentData()
|
|
|
|
|
|
self._settings.splat_radius = self._splat_combo.currentData()
|
|
|
|
|
|
self._settings.save_base_dir = self._base_dir_edit.text().strip()
|
|
|
|
|
|
for attr, cb in self._cap_checks.items():
|
|
|
|
|
|
setattr(self._settings, attr, cb.isChecked())
|
2026-04-22 10:36:05 +08:00
|
|
|
|
self._settings.save()
|
|
|
|
|
|
self.accept()
|
|
|
|
|
|
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
# Helpers
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
def imwrite_utf8(path: str, img: np.ndarray) -> bool:
|
|
|
|
|
|
"""cv2.imwrite Unicode 路径安全版(Windows 中文路径)。"""
|
|
|
|
|
|
ext = Path(path).suffix.lower() or ".png"
|
|
|
|
|
|
ok, buf = cv2.imencode(ext, img)
|
|
|
|
|
|
if ok:
|
|
|
|
|
|
Path(path).write_bytes(buf.tobytes())
|
|
|
|
|
|
return ok
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def save_ply_utf8(target_path: str, pc_frame, save_func) -> None:
|
|
|
|
|
|
"""先写到 ASCII 临时目录,再移动到 Unicode 目标路径。"""
|
|
|
|
|
|
tmpdir = tempfile.mkdtemp()
|
|
|
|
|
|
try:
|
|
|
|
|
|
tmp = os.path.join(tmpdir, "pc.ply")
|
|
|
|
|
|
save_func(tmp, pc_frame)
|
|
|
|
|
|
shutil.move(tmp, target_path)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 20:26:16 +08:00
|
|
|
|
def ndarray_to_pixmap(img_bgr: np.ndarray, max_w: int, max_h: int) -> 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-22 10:45:09 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 20:26:16 +08:00
|
|
|
|
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)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
vis = cv2.applyColorMap((norm * 255).astype(np.uint8), cv2.COLORMAP_JET)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
vis[depth_uint16 == 0] = 0
|
|
|
|
|
|
return vis
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def process_ir_frame(ir_frame) -> np.ndarray | None:
|
|
|
|
|
|
if ir_frame is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
from pyorbbecsdk import OBFormat
|
2026-04-21 20:42:56 +08:00
|
|
|
|
vf = ir_frame.as_video_frame()
|
2026-04-21 20:26:16 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
def _find_profile(profile_list, cfg: dict):
|
|
|
|
|
|
"""在 profile_list 中找到 width/height/fps 匹配的 profile;失败时返回第一个。"""
|
|
|
|
|
|
for i in range(profile_list.get_count()):
|
|
|
|
|
|
p = profile_list.get_stream_profile_by_index(i)
|
|
|
|
|
|
vp = p.as_video_stream_profile()
|
|
|
|
|
|
if vp is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if (vp.get_width() == cfg["width"]
|
|
|
|
|
|
and vp.get_height() == cfg["height"]
|
|
|
|
|
|
and vp.get_fps() == cfg["fps"]):
|
|
|
|
|
|
return vp
|
|
|
|
|
|
return profile_list.get_stream_profile_by_index(0).as_video_stream_profile()
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
def _find_profile_exact(profile_list, cfg: dict):
|
|
|
|
|
|
"""严格匹配 width/height/fps;找不到返回 None(不回退)。"""
|
|
|
|
|
|
for i in range(profile_list.get_count()):
|
|
|
|
|
|
p = profile_list.get_stream_profile_by_index(i)
|
|
|
|
|
|
vp = p.as_video_stream_profile()
|
|
|
|
|
|
if vp is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if (vp.get_width() == cfg["width"]
|
|
|
|
|
|
and vp.get_height() == cfg["height"]
|
|
|
|
|
|
and vp.get_fps() == cfg["fps"]):
|
|
|
|
|
|
return vp
|
|
|
|
|
|
return None
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
# Camera Worker Thread
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class CameraWorker(QThread):
|
2026-04-21 20:42:56 +08:00
|
|
|
|
preview_ready = pyqtSignal(object, object) # color_bgr, depth_vis
|
|
|
|
|
|
capture_done = pyqtSignal(str) # save_dir
|
|
|
|
|
|
params_ready = pyqtSignal(dict)
|
|
|
|
|
|
log_msg = pyqtSignal(str)
|
|
|
|
|
|
disconnected = pyqtSignal()
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-21 21:26:51 +08:00
|
|
|
|
def __init__(self, color_cfg: dict, depth_cfg: dict, preset: str | None = None):
|
2026-04-21 20:26:16 +08:00
|
|
|
|
super().__init__()
|
2026-04-21 20:42:56 +08:00
|
|
|
|
self._color_cfg = color_cfg
|
|
|
|
|
|
self._depth_cfg = depth_cfg
|
2026-04-21 21:26:51 +08:00
|
|
|
|
self._preset = preset
|
2026-04-21 20:42:56 +08:00
|
|
|
|
self._lock = threading.Lock()
|
|
|
|
|
|
self._running = True
|
|
|
|
|
|
self._d2c = False
|
|
|
|
|
|
self._do_capture = False
|
|
|
|
|
|
self._capture_dir = ""
|
|
|
|
|
|
self._camera_params = None
|
|
|
|
|
|
self._serial_number = "unknown"
|
|
|
|
|
|
self._has_dual_ir = False
|
|
|
|
|
|
self._ir_enabled = False
|
|
|
|
|
|
self._last_emit_ts = 0.0
|
|
|
|
|
|
self._consec_timeouts = 0
|
2026-04-21 20:26:21 +08:00
|
|
|
|
self._disconnect_emitted = False
|
2026-04-21 21:26:51 +08:00
|
|
|
|
self._sync_wait_count = 0 # 等待同步帧的计数器
|
2026-04-22 10:45:09 +08:00
|
|
|
|
self._splat_radius = 0
|
2026-04-22 11:35:40 +08:00
|
|
|
|
self._d2c_method = "sdk"
|
2026-04-22 10:49:07 +08:00
|
|
|
|
self._capture_opts: dict = {}
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
def set_d2c(self, enabled: bool):
|
|
|
|
|
|
with self._lock:
|
|
|
|
|
|
self._d2c = enabled
|
|
|
|
|
|
|
2026-04-22 11:35:40 +08:00
|
|
|
|
def set_d2c_method(self, method: str):
|
|
|
|
|
|
with self._lock:
|
|
|
|
|
|
self._d2c_method = method
|
|
|
|
|
|
|
2026-04-22 10:45:09 +08:00
|
|
|
|
def set_splat(self, radius: int):
|
|
|
|
|
|
with self._lock:
|
|
|
|
|
|
self._splat_radius = radius
|
|
|
|
|
|
|
2026-04-22 10:49:07 +08:00
|
|
|
|
def trigger_capture(self, save_dir: str, capture_opts: dict | None = None):
|
2026-04-21 20:26:16 +08:00
|
|
|
|
with self._lock:
|
2026-04-22 10:49:07 +08:00
|
|
|
|
self._capture_dir = save_dir
|
|
|
|
|
|
self._capture_opts = capture_opts or {}
|
|
|
|
|
|
self._do_capture = True
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
def stop(self):
|
|
|
|
|
|
self._running = False
|
|
|
|
|
|
self.wait(6000)
|
|
|
|
|
|
|
|
|
|
|
|
# ── Thread body ──
|
|
|
|
|
|
|
|
|
|
|
|
def run(self):
|
|
|
|
|
|
try:
|
|
|
|
|
|
from pyorbbecsdk import (
|
2026-04-21 20:42:56 +08:00
|
|
|
|
Pipeline, Config, AlignFilter, PointCloudFilter,
|
2026-04-21 20:26:16 +08:00
|
|
|
|
OBStreamType, OBSensorType, OBFrameType, OBFormat,
|
2026-04-21 21:26:51 +08:00
|
|
|
|
OBFrameAggregateOutputMode,
|
2026-04-21 20:26:16 +08:00
|
|
|
|
save_point_cloud_to_ply,
|
|
|
|
|
|
)
|
|
|
|
|
|
from utils import frame_to_bgr_image
|
|
|
|
|
|
except ImportError as exc:
|
|
|
|
|
|
self.log_msg.emit(f"导入 pyorbbecsdk 失败:{exc}")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
pipeline = Pipeline()
|
|
|
|
|
|
config = Config()
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-21 21:26:51 +08:00
|
|
|
|
# 获取 SN + 探测 IR 类型 + 应用 preset
|
2026-04-21 20:26:16 +08:00
|
|
|
|
try:
|
2026-04-21 20:42:56 +08:00
|
|
|
|
device = pipeline.get_device()
|
2026-04-21 20:26:21 +08:00
|
|
|
|
try:
|
|
|
|
|
|
self._serial_number = device.get_device_info().get_serial_number()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2026-04-21 20:26:16 +08:00
|
|
|
|
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
|
2026-04-21 21:26:51 +08:00
|
|
|
|
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}")
|
2026-04-21 20:26:16 +08:00
|
|
|
|
except Exception as exc:
|
2026-04-21 20:42:56 +08:00
|
|
|
|
self.log_msg.emit(f"设备信息获取警告:{exc}")
|
|
|
|
|
|
|
|
|
|
|
|
# 找到匹配的流 profile
|
|
|
|
|
|
try:
|
|
|
|
|
|
color_profile = _find_profile(
|
|
|
|
|
|
pipeline.get_stream_profile_list(OBSensorType.COLOR_SENSOR),
|
|
|
|
|
|
self._color_cfg)
|
|
|
|
|
|
depth_profile = _find_profile(
|
|
|
|
|
|
pipeline.get_stream_profile_list(OBSensorType.DEPTH_SENSOR),
|
|
|
|
|
|
self._depth_cfg)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.log_msg.emit(f"获取流配置失败:{exc}")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
config.enable_stream(color_profile)
|
|
|
|
|
|
config.enable_stream(depth_profile)
|
2026-04-21 21:26:51 +08:00
|
|
|
|
# 要求所有启用的流都到齐才输出帧集,避免不同时刻的帧被混合返回
|
|
|
|
|
|
try:
|
|
|
|
|
|
config.set_frame_aggregate_output_mode(
|
|
|
|
|
|
OBFrameAggregateOutputMode.FULL_FRAME_REQUIRE)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2026-04-21 20:42:56 +08:00
|
|
|
|
|
|
|
|
|
|
# 启用与深度流分辨率/帧率一致的 IR 流(找不到匹配则跳过,不影响主流)
|
|
|
|
|
|
self._ir_enabled = False
|
|
|
|
|
|
try:
|
|
|
|
|
|
if self._has_dual_ir:
|
|
|
|
|
|
left_ok = right_ok = False
|
|
|
|
|
|
try:
|
|
|
|
|
|
vp = _find_profile_exact(
|
|
|
|
|
|
pipeline.get_stream_profile_list(OBSensorType.LEFT_IR_SENSOR),
|
|
|
|
|
|
self._depth_cfg)
|
|
|
|
|
|
if vp:
|
|
|
|
|
|
config.enable_stream(vp)
|
|
|
|
|
|
left_ok = True
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
try:
|
|
|
|
|
|
vp = _find_profile_exact(
|
|
|
|
|
|
pipeline.get_stream_profile_list(OBSensorType.RIGHT_IR_SENSOR),
|
|
|
|
|
|
self._depth_cfg)
|
|
|
|
|
|
if vp:
|
|
|
|
|
|
config.enable_stream(vp)
|
|
|
|
|
|
right_ok = True
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
self._ir_enabled = left_ok and right_ok
|
|
|
|
|
|
if not self._ir_enabled:
|
|
|
|
|
|
self.log_msg.emit(
|
|
|
|
|
|
f"IR 流提示:当前深度配置 "
|
|
|
|
|
|
f"{self._depth_cfg['width']}×{self._depth_cfg['height']}"
|
|
|
|
|
|
f"@{self._depth_cfg['fps']} 无匹配 IR profile,IR 不启用")
|
|
|
|
|
|
else:
|
|
|
|
|
|
vp = _find_profile_exact(
|
|
|
|
|
|
pipeline.get_stream_profile_list(OBSensorType.IR_SENSOR),
|
|
|
|
|
|
self._depth_cfg)
|
|
|
|
|
|
if vp:
|
|
|
|
|
|
config.enable_stream(vp)
|
|
|
|
|
|
self._ir_enabled = True
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.log_msg.emit(
|
|
|
|
|
|
f"IR 流提示:无匹配 IR profile,IR 不启用")
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.log_msg.emit(f"IR 传感器警告:{exc}")
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
try:
|
2026-04-21 20:42:56 +08:00
|
|
|
|
pipeline.start(config)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.log_msg.emit(f"Pipeline 启动失败:{exc}")
|
2026-04-21 20:42:56 +08:00
|
|
|
|
self.disconnected.emit()
|
2026-04-21 20:26:16 +08:00
|
|
|
|
return
|
|
|
|
|
|
|
2026-04-21 21:26:51 +08:00
|
|
|
|
try:
|
|
|
|
|
|
pipeline.enable_frame_sync()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass # 部分固件版本不支持,忽略
|
|
|
|
|
|
|
2026-04-21 20:26:16 +08:00
|
|
|
|
align_filter = AlignFilter(align_to_stream=OBStreamType.COLOR_STREAM)
|
|
|
|
|
|
point_cloud_filter = PointCloudFilter()
|
|
|
|
|
|
|
|
|
|
|
|
while self._running:
|
|
|
|
|
|
try:
|
2026-04-21 20:42:56 +08:00
|
|
|
|
frames = pipeline.wait_for_frames(500)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
if frames is None:
|
2026-04-21 20:26:21 +08:00
|
|
|
|
self._consec_timeouts += 1
|
|
|
|
|
|
if (self._consec_timeouts >= _DISCONNECT_TIMEOUTS
|
|
|
|
|
|
and not self._disconnect_emitted):
|
|
|
|
|
|
self._disconnect_emitted = True
|
|
|
|
|
|
self._running = False
|
|
|
|
|
|
self.disconnected.emit()
|
2026-04-21 20:26:16 +08:00
|
|
|
|
continue
|
2026-04-21 20:26:21 +08:00
|
|
|
|
self._consec_timeouts = 0
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
with self._lock:
|
2026-04-22 11:35:40 +08:00
|
|
|
|
d2c = self._d2c
|
|
|
|
|
|
d2c_method = self._d2c_method
|
|
|
|
|
|
do_cap = self._do_capture
|
|
|
|
|
|
cap_dir = self._capture_dir
|
|
|
|
|
|
splat_r = self._splat_radius
|
|
|
|
|
|
cap_opts = self._capture_opts
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
# 首帧提取相机参数
|
2026-04-21 20:26:16 +08:00
|
|
|
|
if self._camera_params is None:
|
|
|
|
|
|
cf = frames.get_color_frame()
|
|
|
|
|
|
df = frames.get_depth_frame()
|
|
|
|
|
|
if cf and df:
|
|
|
|
|
|
try:
|
2026-04-21 20:42:56 +08:00
|
|
|
|
p = self._extract_params(cf, df)
|
|
|
|
|
|
p['device'] = {'serial_number': self._serial_number}
|
|
|
|
|
|
self._camera_params = p
|
|
|
|
|
|
self.params_ready.emit(p)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2026-04-21 21:26:51 +08:00
|
|
|
|
# 执行拍照(等待 RGB-D 时间戳对齐后再拍)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
if do_cap and cap_dir:
|
2026-04-21 21:26:51 +08:00
|
|
|
|
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(
|
2026-04-22 10:49:07 +08:00
|
|
|
|
frames, cap_dir, cap_opts,
|
2026-04-21 21:26:51 +08:00
|
|
|
|
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)")
|
2026-04-21 20:26:16 +08:00
|
|
|
|
continue
|
|
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
# 预览
|
2026-04-22 11:35:40 +08:00
|
|
|
|
cf = frames.get_color_frame()
|
|
|
|
|
|
df = frames.get_depth_frame()
|
2026-04-21 20:26:16 +08:00
|
|
|
|
if not cf or not df:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
color_bgr = frame_to_bgr_image(cf)
|
|
|
|
|
|
if color_bgr is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2026-04-22 11:35:40 +08:00
|
|
|
|
if d2c:
|
|
|
|
|
|
if d2c_method == "forward" and self._camera_params is not None:
|
|
|
|
|
|
raw = np.frombuffer(df.get_data(), dtype=np.uint16).reshape(
|
|
|
|
|
|
df.get_height(), df.get_width())
|
|
|
|
|
|
depth_data = software_d2c(
|
|
|
|
|
|
raw, self._camera_params, splat_radius=splat_r)
|
|
|
|
|
|
depth_scale = df.get_depth_scale()
|
|
|
|
|
|
else:
|
|
|
|
|
|
af = align_filter.process(frames)
|
|
|
|
|
|
if af:
|
|
|
|
|
|
view = af.as_frame_set()
|
|
|
|
|
|
df2 = view.get_depth_frame()
|
|
|
|
|
|
if df2:
|
|
|
|
|
|
df = df2
|
|
|
|
|
|
depth_data = np.frombuffer(df.get_data(), dtype=np.uint16).reshape(
|
|
|
|
|
|
df.get_height(), df.get_width())
|
|
|
|
|
|
depth_scale = df.get_depth_scale()
|
|
|
|
|
|
else:
|
|
|
|
|
|
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_scale = df.get_depth_scale()
|
|
|
|
|
|
|
|
|
|
|
|
depth_vis = depth_to_vis(depth_data, depth_scale)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
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}")
|
2026-04-21 20:26:21 +08:00
|
|
|
|
self._consec_timeouts += _DISCONNECT_TIMEOUTS
|
|
|
|
|
|
if not self._disconnect_emitted:
|
|
|
|
|
|
self._disconnect_emitted = True
|
|
|
|
|
|
self._running = False
|
|
|
|
|
|
self.disconnected.emit()
|
2026-04-21 20:26:16 +08:00
|
|
|
|
time.sleep(0.1)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-04-21 20:42:56 +08:00
|
|
|
|
pipeline.stop()
|
2026-04-21 20:26:16 +08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
# ── Capture ──
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-22 10:49:07 +08:00
|
|
|
|
def _run_capture(self, frames, save_dir, capture_opts,
|
2026-04-21 20:26:16 +08:00
|
|
|
|
align_filter, point_cloud_filter,
|
|
|
|
|
|
frame_to_bgr_image, save_point_cloud_to_ply,
|
|
|
|
|
|
OBFrameType, OBFormat):
|
2026-04-21 20:42:56 +08:00
|
|
|
|
base = Path(save_dir)
|
|
|
|
|
|
base.mkdir(parents=True, exist_ok=True)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
cf = frames.get_color_frame()
|
|
|
|
|
|
df = frames.get_depth_frame()
|
|
|
|
|
|
|
2026-04-22 10:49:07 +08:00
|
|
|
|
if capture_opts.get("rgb", True) and cf:
|
2026-04-21 20:26:16 +08:00
|
|
|
|
bgr = frame_to_bgr_image(cf)
|
|
|
|
|
|
if bgr is not None:
|
2026-04-21 20:26:21 +08:00
|
|
|
|
imwrite_utf8(str(base / "color.png"), bgr)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
if self._ir_enabled:
|
|
|
|
|
|
if self._has_dual_ir:
|
2026-04-22 10:49:07 +08:00
|
|
|
|
if capture_opts.get("irl", True):
|
|
|
|
|
|
img = process_ir_frame(frames.get_frame(OBFrameType.LEFT_IR_FRAME))
|
|
|
|
|
|
if img is not None:
|
|
|
|
|
|
imwrite_utf8(str(base / "ir_left.png"), img)
|
|
|
|
|
|
if capture_opts.get("irr", True):
|
|
|
|
|
|
img = process_ir_frame(frames.get_frame(OBFrameType.RIGHT_IR_FRAME))
|
|
|
|
|
|
if img is not None:
|
|
|
|
|
|
imwrite_utf8(str(base / "ir_right.png"), img)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
else:
|
2026-04-22 10:49:07 +08:00
|
|
|
|
if capture_opts.get("irl", True):
|
|
|
|
|
|
img = process_ir_frame(frames.get_frame(OBFrameType.IR_FRAME))
|
|
|
|
|
|
if img is not None:
|
|
|
|
|
|
imwrite_utf8(str(base / "ir.png"), img)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-22 10:49:07 +08:00
|
|
|
|
if capture_opts.get("depth", True) and df:
|
2026-04-21 20:26:16 +08:00
|
|
|
|
raw = np.frombuffer(df.get_data(), dtype=np.uint16).reshape(
|
|
|
|
|
|
df.get_height(), df.get_width())
|
2026-04-21 20:26:21 +08:00
|
|
|
|
imwrite_utf8(str(base / "depth.png"), raw)
|
2026-04-22 11:35:36 +08:00
|
|
|
|
imwrite_utf8(str(base / "depth_vis.png"),
|
|
|
|
|
|
depth_to_vis(raw, df.get_depth_scale()))
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-22 11:35:40 +08:00
|
|
|
|
d2c_method = capture_opts.get("d2c_method", "sdk")
|
|
|
|
|
|
need_aligned = ((d2c_method == "sdk" and capture_opts.get("d2c", True)) or
|
2026-04-22 10:49:07 +08:00
|
|
|
|
capture_opts.get("pc", False) or
|
|
|
|
|
|
capture_opts.get("pc_color", False))
|
2026-04-22 11:35:40 +08:00
|
|
|
|
aset = None
|
2026-04-22 10:49:07 +08:00
|
|
|
|
if need_aligned:
|
|
|
|
|
|
aligned = align_filter.process(frames)
|
|
|
|
|
|
if aligned:
|
|
|
|
|
|
aset = aligned.as_frame_set()
|
2026-04-22 11:35:40 +08:00
|
|
|
|
|
|
|
|
|
|
# D2C 深度图保存
|
|
|
|
|
|
if capture_opts.get("d2c", True):
|
|
|
|
|
|
if d2c_method == "sdk" and aset:
|
|
|
|
|
|
adf = aset.get_depth_frame()
|
|
|
|
|
|
if adf:
|
2026-04-22 10:49:07 +08:00
|
|
|
|
ad = np.frombuffer(adf.get_data(), dtype=np.uint16).reshape(
|
|
|
|
|
|
adf.get_height(), adf.get_width())
|
|
|
|
|
|
imwrite_utf8(str(base / "depth_d2c.png"), ad)
|
|
|
|
|
|
imwrite_utf8(str(base / "depth_d2c_vis.png"),
|
|
|
|
|
|
depth_to_vis(ad, adf.get_depth_scale()))
|
2026-04-22 14:52:21 +08:00
|
|
|
|
elif self._camera_params is not None and d2c_method == "forward":
|
2026-04-22 11:35:40 +08:00
|
|
|
|
raw = np.frombuffer(df.get_data(), dtype=np.uint16).reshape(
|
|
|
|
|
|
df.get_height(), df.get_width())
|
2026-04-22 14:52:21 +08:00
|
|
|
|
splat_r = capture_opts.get("splat_radius", 1)
|
|
|
|
|
|
aligned_img = software_d2c(raw, self._camera_params, splat_radius=splat_r)
|
2026-04-22 11:35:40 +08:00
|
|
|
|
imwrite_utf8(str(base / "depth_d2c.png"), aligned_img)
|
|
|
|
|
|
imwrite_utf8(str(base / "depth_d2c_vis.png"),
|
|
|
|
|
|
depth_to_vis(aligned_img, df.get_depth_scale()))
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-22 11:35:40 +08:00
|
|
|
|
# 点云保存(始终使用 SDK AlignFilter 结果)
|
|
|
|
|
|
if aset:
|
|
|
|
|
|
if capture_opts.get("pc", False):
|
|
|
|
|
|
try:
|
|
|
|
|
|
point_cloud_filter.set_create_point_format(OBFormat.POINT)
|
|
|
|
|
|
pc = point_cloud_filter.process(aligned)
|
|
|
|
|
|
if pc:
|
|
|
|
|
|
save_ply_utf8(str(base / "point_cloud.ply"), pc,
|
|
|
|
|
|
save_point_cloud_to_ply)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.log_msg.emit(f"点云警告:{exc}")
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-22 11:35:40 +08:00
|
|
|
|
if capture_opts.get("pc_color", False):
|
|
|
|
|
|
try:
|
|
|
|
|
|
point_cloud_filter.set_create_point_format(OBFormat.RGB_POINT)
|
|
|
|
|
|
pc = point_cloud_filter.process(aligned)
|
|
|
|
|
|
if pc:
|
|
|
|
|
|
save_ply_utf8(str(base / "point_cloud_color.ply"), pc,
|
|
|
|
|
|
save_point_cloud_to_ply)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.log_msg.emit(f"彩色点云警告:{exc}")
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
self.capture_done.emit(save_dir)
|
|
|
|
|
|
|
|
|
|
|
|
# ── Static helpers ──
|
|
|
|
|
|
|
2026-04-21 21:26:51 +08:00
|
|
|
|
@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
|
|
|
|
|
|
|
2026-04-21 20:26:16 +08:00
|
|
|
|
@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": {
|
2026-04-21 20:42:56 +08:00
|
|
|
|
"width": color_frame.get_width(), "height": color_frame.get_height(),
|
|
|
|
|
|
"fps": cvp.get_fps(),
|
2026-04-21 20:26:16 +08:00
|
|
|
|
"format": str(cvp.get_format()).split(".")[-1],
|
|
|
|
|
|
"intrinsic": intr(cvp.get_intrinsic()),
|
|
|
|
|
|
"distortion": dist(cvp.get_distortion()),
|
|
|
|
|
|
},
|
|
|
|
|
|
"depth": {
|
2026-04-21 20:42:56 +08:00
|
|
|
|
"width": depth_frame.get_width(), "height": depth_frame.get_height(),
|
|
|
|
|
|
"fps": dvp.get_fps(),
|
2026-04-21 20:26:16 +08:00
|
|
|
|
"format": str(dvp.get_format()).split(".")[-1],
|
2026-04-21 20:42:56 +08:00
|
|
|
|
"scale": float(depth_frame.get_depth_scale()),
|
2026-04-21 20:26:16 +08:00
|
|
|
|
"intrinsic": intr(dvp.get_intrinsic()),
|
|
|
|
|
|
"distortion": dist(dvp.get_distortion()),
|
|
|
|
|
|
},
|
|
|
|
|
|
"extrinsic": {
|
|
|
|
|
|
"rot": ext.rot.tolist(),
|
|
|
|
|
|
"transform": ext.transform.tolist(),
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
# Batch D2C Worker Thread
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-04-21 21:31:46 +08:00
|
|
|
|
class FetchParamsWorker(QThread):
|
|
|
|
|
|
log = pyqtSignal(str)
|
|
|
|
|
|
params_ready = pyqtSignal(dict, str) # params dict, temp file path
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, color_cfg: dict, depth_cfg: dict):
|
|
|
|
|
|
super().__init__()
|
|
|
|
|
|
self._color_cfg = color_cfg
|
|
|
|
|
|
self._depth_cfg = depth_cfg
|
|
|
|
|
|
|
|
|
|
|
|
def run(self):
|
|
|
|
|
|
try:
|
|
|
|
|
|
from pyorbbecsdk import (
|
|
|
|
|
|
Pipeline, Config, OBSensorType, OBFrameAggregateOutputMode,
|
|
|
|
|
|
)
|
|
|
|
|
|
except ImportError as exc:
|
|
|
|
|
|
self.log.emit(f"导入 pyorbbecsdk 失败:{exc}")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
def _find_profile(profile_list, cfg: dict):
|
|
|
|
|
|
for i in range(profile_list.get_count()):
|
|
|
|
|
|
p = profile_list.get_stream_profile_by_index(i)
|
|
|
|
|
|
vp = p.as_video_stream_profile()
|
|
|
|
|
|
if vp is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if (vp.get_width() == cfg["width"]
|
|
|
|
|
|
and vp.get_height() == cfg["height"]
|
|
|
|
|
|
and vp.get_fps() == cfg["fps"]):
|
|
|
|
|
|
return vp
|
|
|
|
|
|
return profile_list.get_stream_profile_by_index(0).as_video_stream_profile()
|
|
|
|
|
|
|
|
|
|
|
|
pipeline = Pipeline()
|
|
|
|
|
|
config = Config()
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
color_profile = _find_profile(
|
|
|
|
|
|
pipeline.get_stream_profile_list(OBSensorType.COLOR_SENSOR),
|
|
|
|
|
|
self._color_cfg)
|
|
|
|
|
|
depth_profile = _find_profile(
|
|
|
|
|
|
pipeline.get_stream_profile_list(OBSensorType.DEPTH_SENSOR),
|
|
|
|
|
|
self._depth_cfg)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.log.emit(f"获取流配置失败:{exc}")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
config.enable_stream(color_profile)
|
|
|
|
|
|
config.enable_stream(depth_profile)
|
|
|
|
|
|
try:
|
|
|
|
|
|
config.set_frame_aggregate_output_mode(
|
|
|
|
|
|
OBFrameAggregateOutputMode.FULL_FRAME_REQUIRE)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
pipeline.start(config)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.log.emit(f"Pipeline 启动失败:{exc}")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
frames = None
|
|
|
|
|
|
for _ in range(50): # 最多等待 5 秒
|
|
|
|
|
|
frames = pipeline.wait_for_frames(100)
|
|
|
|
|
|
if frames is not None:
|
|
|
|
|
|
cf = frames.get_color_frame()
|
|
|
|
|
|
df = frames.get_depth_frame()
|
|
|
|
|
|
if cf and df:
|
|
|
|
|
|
break
|
|
|
|
|
|
if frames is None or not frames.get_color_frame() or not frames.get_depth_frame():
|
|
|
|
|
|
self.log.emit("等待帧超时,无法获取参数")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
cf = frames.get_color_frame()
|
|
|
|
|
|
df = frames.get_depth_frame()
|
|
|
|
|
|
params = CameraWorker._extract_params(cf, df)
|
|
|
|
|
|
|
|
|
|
|
|
sn = "unknown"
|
|
|
|
|
|
try:
|
|
|
|
|
|
sn = pipeline.get_device().get_device_info().get_serial_number()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
params["device"] = {"serial_number": sn}
|
|
|
|
|
|
|
2026-04-21 21:37:43 +08:00
|
|
|
|
tmp = Path(tempfile.gettempdir()) / (
|
|
|
|
|
|
f"SN{sn}_RGB{params['color']['width']}x{params['color']['height']}@{params['color']['fps']}"
|
|
|
|
|
|
f"_D{params['depth']['width']}x{params['depth']['height']}@{params['depth']['fps']}.json"
|
|
|
|
|
|
)
|
2026-04-21 21:31:46 +08:00
|
|
|
|
tmp.write_text(json.dumps(params, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
|
|
|
|
self.params_ready.emit(params, str(tmp))
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.log.emit(f"获取参数失败:{exc}")
|
|
|
|
|
|
finally:
|
|
|
|
|
|
try:
|
|
|
|
|
|
pipeline.stop()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 20:26:16 +08:00
|
|
|
|
class BatchD2CWorker(QThread):
|
|
|
|
|
|
log = pyqtSignal(str)
|
|
|
|
|
|
done = pyqtSignal()
|
|
|
|
|
|
|
2026-04-22 11:35:36 +08:00
|
|
|
|
def __init__(self, params: dict, depth_dir: str,
|
|
|
|
|
|
method: str = "inverse", splat_radius: int = 1):
|
2026-04-21 20:26:16 +08:00
|
|
|
|
super().__init__()
|
2026-04-22 10:45:09 +08:00
|
|
|
|
self._params = params
|
|
|
|
|
|
self._depth_dir = depth_dir
|
2026-04-22 11:35:36 +08:00
|
|
|
|
self._method = method
|
2026-04-22 10:45:09 +08:00
|
|
|
|
self._splat_radius = splat_radius
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
def run(self):
|
2026-04-21 20:42:56 +08:00
|
|
|
|
class _Cap(io.StringIO):
|
2026-04-21 20:26:16 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
with contextlib.redirect_stdout(_Cap(self.log)):
|
2026-04-21 20:26:16 +08:00
|
|
|
|
try:
|
2026-04-22 11:35:36 +08:00
|
|
|
|
batch_convert(self._params, self._depth_dir,
|
|
|
|
|
|
method=self._method,
|
|
|
|
|
|
splat_radius=self._splat_radius)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.log.emit(f"Error: {exc}")
|
|
|
|
|
|
self.done.emit()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 20:52:41 +08:00
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
# Toast Overlay(不阻塞鼠标,自动消失)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class _Toast(QLabel):
|
|
|
|
|
|
def __init__(self, parent: QWidget):
|
|
|
|
|
|
super().__init__(parent)
|
|
|
|
|
|
self.setAlignment(Qt.AlignCenter)
|
|
|
|
|
|
self.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
|
|
|
|
|
self.setStyleSheet(
|
2026-04-22 10:36:05 +08:00
|
|
|
|
f"background: rgba(30,140,30,210);"
|
|
|
|
|
|
f"color: white;"
|
|
|
|
|
|
f"border-radius: {_sz(10)}px;"
|
|
|
|
|
|
f"font-size: {_fs(15)}px;"
|
|
|
|
|
|
f"padding: {_sz(10)}px {_sz(28)}px;"
|
2026-04-21 20:52:41 +08:00
|
|
|
|
)
|
|
|
|
|
|
self._timer = QTimer(self)
|
|
|
|
|
|
self._timer.setSingleShot(True)
|
|
|
|
|
|
self._timer.timeout.connect(self.hide)
|
|
|
|
|
|
self.hide()
|
|
|
|
|
|
|
|
|
|
|
|
def show_msg(self, text: str, ms: int = 1000):
|
|
|
|
|
|
self.setText(text)
|
|
|
|
|
|
self.adjustSize()
|
|
|
|
|
|
p = self.parent()
|
|
|
|
|
|
self.move((p.width() - self.width()) // 2,
|
2026-04-22 10:36:05 +08:00
|
|
|
|
p.height() - self.height() - _sz(70))
|
2026-04-21 20:52:41 +08:00
|
|
|
|
self.raise_()
|
|
|
|
|
|
self.show()
|
|
|
|
|
|
self._timer.start(ms)
|
|
|
|
|
|
|
|
|
|
|
|
def reposition(self):
|
|
|
|
|
|
if self.isVisible():
|
|
|
|
|
|
p = self.parent()
|
|
|
|
|
|
self.move((p.width() - self.width()) // 2,
|
2026-04-22 10:36:05 +08:00
|
|
|
|
p.height() - self.height() - _sz(70))
|
2026-04-21 20:52:41 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 20:26:16 +08:00
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
# Main Window
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class MainWindow(QMainWindow):
|
2026-04-21 20:42:56 +08:00
|
|
|
|
def __init__(self):
|
2026-04-21 20:26:16 +08:00
|
|
|
|
super().__init__()
|
2026-04-21 20:42:56 +08:00
|
|
|
|
_title = "Orbbec 数据采集 & D2C"
|
|
|
|
|
|
if _COPYRIGHT:
|
|
|
|
|
|
_title += f" | © {_COPYRIGHT}"
|
|
|
|
|
|
self.setWindowTitle(_title)
|
2026-04-22 10:36:05 +08:00
|
|
|
|
self.resize(_sz(1300), _sz(860))
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
self._worker: CameraWorker | None = None
|
2026-04-21 20:26:16 +08:00
|
|
|
|
self._batch_worker: BatchD2CWorker | None = None
|
2026-04-21 21:31:46 +08:00
|
|
|
|
self._fetch_worker: FetchParamsWorker | None = None
|
2026-04-21 20:42:56 +08:00
|
|
|
|
self._session_dir: Path | None = None
|
|
|
|
|
|
self._params_filename = "camera_params.json"
|
|
|
|
|
|
self._color_profiles_data: list[dict] = []
|
|
|
|
|
|
self._depth_profiles_data: list[dict] = []
|
2026-04-22 10:36:05 +08:00
|
|
|
|
self._app_settings = AppSettings()
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
self._build_ui()
|
2026-04-21 20:52:41 +08:00
|
|
|
|
# toast 覆盖在 central widget 上
|
|
|
|
|
|
self._toast = _Toast(self.centralWidget())
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
# ─────────────────────────────────
|
|
|
|
|
|
# UI Construction
|
|
|
|
|
|
# ─────────────────────────────────
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
def _build_ui(self):
|
2026-04-22 10:36:05 +08:00
|
|
|
|
# 菜单栏
|
|
|
|
|
|
settings_menu = self.menuBar().addMenu("设置")
|
|
|
|
|
|
action_pref = QAction("程序设置…", self)
|
|
|
|
|
|
action_pref.triggered.connect(self._show_settings)
|
|
|
|
|
|
settings_menu.addAction(action_pref)
|
|
|
|
|
|
|
2026-04-22 14:52:21 +08:00
|
|
|
|
if _COPYRIGHT:
|
|
|
|
|
|
cr_widget = QWidget()
|
|
|
|
|
|
cr_layout = QHBoxLayout(cr_widget)
|
|
|
|
|
|
cr_layout.setContentsMargins(0, 0, 10, 0)
|
|
|
|
|
|
cr_lbl = QLabel(f"© {_COPYRIGHT}")
|
|
|
|
|
|
cr_lbl.setStyleSheet(f"color: #888; font-size:{_fs(11)}px;")
|
|
|
|
|
|
cr_layout.addWidget(cr_lbl)
|
|
|
|
|
|
cr_action = QWidgetAction(self)
|
|
|
|
|
|
cr_action.setDefaultWidget(cr_widget)
|
|
|
|
|
|
self.menuBar().addAction(cr_action)
|
|
|
|
|
|
|
2026-04-21 20:26:16 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-04-22 10:49:07 +08:00
|
|
|
|
def _browse_base_dir(self):
|
|
|
|
|
|
d = QFileDialog.getExistingDirectory(
|
|
|
|
|
|
self, "选择数据保存根目录",
|
|
|
|
|
|
self.base_dir_edit.text() or str(Path.cwd()))
|
|
|
|
|
|
if d:
|
|
|
|
|
|
self.base_dir_edit.setText(d)
|
|
|
|
|
|
self._app_settings.save_base_dir = d
|
|
|
|
|
|
self._app_settings.save()
|
|
|
|
|
|
|
2026-04-22 10:36:05 +08:00
|
|
|
|
def _show_settings(self):
|
|
|
|
|
|
dlg = SettingsDialog(self._app_settings, self)
|
2026-04-22 10:45:09 +08:00
|
|
|
|
if dlg.exec_() == QDialog.Accepted:
|
2026-04-22 14:52:21 +08:00
|
|
|
|
# 同步 Splat 下拉框
|
|
|
|
|
|
idx = self.capture_splat_combo.findData(self._app_settings.splat_radius)
|
|
|
|
|
|
if idx >= 0:
|
|
|
|
|
|
self.capture_splat_combo.setCurrentIndex(idx)
|
2026-04-22 10:45:09 +08:00
|
|
|
|
# 立即更新当前流的 splat 半径
|
2026-04-22 14:52:21 +08:00
|
|
|
|
if self._worker:
|
|
|
|
|
|
self._worker.set_splat(self._app_settings.splat_radius)
|
2026-04-22 10:49:07 +08:00
|
|
|
|
# 同步根目录显示
|
|
|
|
|
|
self.base_dir_edit.setText(
|
|
|
|
|
|
self._app_settings.save_base_dir or str(Path.cwd()))
|
|
|
|
|
|
# 同步采集内容复选框为新默认值
|
|
|
|
|
|
for key, cb in self._capture_checks.items():
|
|
|
|
|
|
cb.setChecked(getattr(self._app_settings, f"capture_{key}", cb.isChecked()))
|
2026-04-22 10:45:09 +08:00
|
|
|
|
|
2026-04-22 11:35:40 +08:00
|
|
|
|
def _on_capture_d2c_method_changed(self):
|
|
|
|
|
|
method = self.capture_d2c_method_combo.currentData()
|
|
|
|
|
|
if self._worker:
|
|
|
|
|
|
self._worker.set_d2c_method(method)
|
2026-04-22 14:52:21 +08:00
|
|
|
|
self.capture_splat_combo.setEnabled(method == "forward")
|
2026-04-22 11:35:40 +08:00
|
|
|
|
|
2026-04-22 14:52:21 +08:00
|
|
|
|
def _on_capture_splat_combo_changed(self):
|
|
|
|
|
|
radius = self.capture_splat_combo.currentData()
|
2026-04-22 10:45:09 +08:00
|
|
|
|
if self._worker:
|
|
|
|
|
|
self._worker.set_splat(radius)
|
2026-04-22 10:36:05 +08:00
|
|
|
|
|
2026-04-22 14:52:21 +08:00
|
|
|
|
def _on_d2c_method_changed(self):
|
|
|
|
|
|
method = self.d2c_method_combo.currentData()
|
|
|
|
|
|
self.d2c_splat_combo.setEnabled(method == "forward")
|
|
|
|
|
|
|
2026-04-21 20:52:41 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _step_group(num: int, title: str) -> QGroupBox:
|
|
|
|
|
|
grp = QGroupBox(f"第 {num} 步 · {title}")
|
|
|
|
|
|
grp.setStyleSheet(
|
2026-04-22 10:36:05 +08:00
|
|
|
|
f"QGroupBox {{ font-weight:bold; font-size:{_fs(12)}px; color:#8ec8ff;"
|
|
|
|
|
|
f" border:1px solid #444; border-radius:5px; margin-top:6px; padding-top:4px; }}"
|
|
|
|
|
|
f"QGroupBox::title {{ subcontrol-origin:margin; left:10px; padding:0 4px; }}"
|
2026-04-21 20:52:41 +08:00
|
|
|
|
)
|
|
|
|
|
|
return grp
|
|
|
|
|
|
|
2026-04-21 20:26:16 +08:00
|
|
|
|
def _build_capture_tab(self) -> QWidget:
|
|
|
|
|
|
w = QWidget()
|
|
|
|
|
|
layout = QVBoxLayout(w)
|
2026-04-21 20:52:41 +08:00
|
|
|
|
layout.setContentsMargins(12, 8, 12, 8)
|
|
|
|
|
|
layout.setSpacing(6)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-21 20:52:41 +08:00
|
|
|
|
COMBO_CSS = "QComboBox { combobox-popup: 0; }"
|
|
|
|
|
|
|
|
|
|
|
|
# ── 第 1 步:刷新设备 ──
|
|
|
|
|
|
grp1 = self._step_group(1, "刷新设备")
|
|
|
|
|
|
g1 = QHBoxLayout(grp1)
|
|
|
|
|
|
g1.setContentsMargins(8, 4, 8, 6)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
refresh_btn = QPushButton("刷新设备")
|
2026-04-22 10:36:05 +08:00
|
|
|
|
refresh_btn.setFixedHeight(_sz(28))
|
2026-04-21 20:42:56 +08:00
|
|
|
|
refresh_btn.clicked.connect(self._refresh_devices)
|
2026-04-21 20:52:41 +08:00
|
|
|
|
g1.addWidget(refresh_btn)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
self.device_status = QLabel("未连接设备,请点击「刷新设备」")
|
2026-04-22 10:36:05 +08:00
|
|
|
|
self.device_status.setStyleSheet(f"color:#aaa; font-size:{_fs(12)}px;")
|
2026-04-21 20:52:41 +08:00
|
|
|
|
g1.addWidget(self.device_status)
|
|
|
|
|
|
g1.addStretch()
|
|
|
|
|
|
layout.addWidget(grp1)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
|
2026-04-21 20:52:41 +08:00
|
|
|
|
# ── 第 2 步:流配置 ──
|
|
|
|
|
|
grp2 = self._step_group(2, "流配置")
|
|
|
|
|
|
g2 = QHBoxLayout(grp2)
|
|
|
|
|
|
g2.setContentsMargins(8, 4, 8, 6)
|
|
|
|
|
|
g2.setSpacing(10)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
|
2026-04-21 20:52:41 +08:00
|
|
|
|
g2.addWidget(QLabel("RGB:"))
|
2026-04-21 20:42:56 +08:00
|
|
|
|
self.color_combo = QComboBox()
|
2026-04-22 14:52:21 +08:00
|
|
|
|
self.color_combo.setMinimumWidth(_sz(340))
|
2026-04-22 10:36:05 +08:00
|
|
|
|
self.color_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
|
|
|
|
|
self.color_combo.setMaxVisibleItems(15)
|
2026-04-21 20:52:41 +08:00
|
|
|
|
self.color_combo.setStyleSheet(COMBO_CSS)
|
|
|
|
|
|
g2.addWidget(self.color_combo)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
|
2026-04-21 20:52:41 +08:00
|
|
|
|
g2.addSpacing(12)
|
|
|
|
|
|
g2.addWidget(QLabel("深度:"))
|
2026-04-21 20:42:56 +08:00
|
|
|
|
self.depth_combo = QComboBox()
|
2026-04-22 14:52:21 +08:00
|
|
|
|
self.depth_combo.setMinimumWidth(_sz(340))
|
2026-04-22 10:36:05 +08:00
|
|
|
|
self.depth_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
|
|
|
|
|
self.depth_combo.setMaxVisibleItems(15)
|
2026-04-21 20:52:41 +08:00
|
|
|
|
self.depth_combo.setStyleSheet(COMBO_CSS)
|
|
|
|
|
|
g2.addWidget(self.depth_combo)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
|
2026-04-21 21:26:51 +08:00
|
|
|
|
g2.addSpacing(12)
|
|
|
|
|
|
g2.addWidget(QLabel("Preset:"))
|
|
|
|
|
|
self.preset_combo = QComboBox()
|
2026-04-22 14:52:21 +08:00
|
|
|
|
self.preset_combo.setMinimumWidth(_sz(220))
|
2026-04-22 10:36:05 +08:00
|
|
|
|
self.preset_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
2026-04-21 21:26:51 +08:00
|
|
|
|
self.preset_combo.setMaxVisibleItems(12)
|
|
|
|
|
|
self.preset_combo.setStyleSheet(COMBO_CSS)
|
|
|
|
|
|
self.preset_combo.setEnabled(False)
|
|
|
|
|
|
g2.addWidget(self.preset_combo)
|
|
|
|
|
|
|
2026-04-21 20:52:41 +08:00
|
|
|
|
g2.addStretch()
|
2026-04-21 20:42:56 +08:00
|
|
|
|
self.stream_btn = QPushButton("开启")
|
|
|
|
|
|
self.stream_btn.setCheckable(True)
|
2026-04-22 10:36:05 +08:00
|
|
|
|
self.stream_btn.setFixedSize(_sz(88), _sz(30))
|
2026-04-21 20:42:56 +08:00
|
|
|
|
self.stream_btn.setStyleSheet(
|
2026-04-21 20:52:41 +08:00
|
|
|
|
"QPushButton{ border-radius:5px; border:none; font-weight:bold; }"
|
2026-04-21 20:42:56 +08:00
|
|
|
|
"QPushButton:!checked{ background:#555; color:#ccc; }"
|
|
|
|
|
|
"QPushButton:checked{ background:#2a8a2a; color:white; }"
|
|
|
|
|
|
"QPushButton:disabled{ background:#444; color:#777; }"
|
|
|
|
|
|
)
|
|
|
|
|
|
self.stream_btn.toggled.connect(self._on_stream_toggle)
|
2026-04-21 20:52:41 +08:00
|
|
|
|
g2.addWidget(self.stream_btn)
|
|
|
|
|
|
layout.addWidget(grp2)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
|
2026-04-22 10:49:07 +08:00
|
|
|
|
# ── 第 3 步:保存目录 & 采集内容 ──
|
2026-04-22 11:35:40 +08:00
|
|
|
|
grp3 = self._step_group(3, "保存目录 && 采集内容")
|
2026-04-22 10:49:07 +08:00
|
|
|
|
vg3 = QVBoxLayout(grp3)
|
|
|
|
|
|
vg3.setContentsMargins(8, 4, 8, 8)
|
|
|
|
|
|
vg3.setSpacing(5)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
|
2026-04-22 10:49:07 +08:00
|
|
|
|
# 行 1:根目录选择
|
|
|
|
|
|
row_base = QHBoxLayout()
|
|
|
|
|
|
row_base.setSpacing(6)
|
|
|
|
|
|
lbl_base = QLabel("根目录:")
|
2026-04-22 14:52:21 +08:00
|
|
|
|
lbl_base.setFixedWidth(_sz(80))
|
2026-04-22 10:49:07 +08:00
|
|
|
|
row_base.addWidget(lbl_base)
|
|
|
|
|
|
self.base_dir_edit = QLineEdit(
|
|
|
|
|
|
self._app_settings.save_base_dir or str(Path.cwd()))
|
|
|
|
|
|
row_base.addWidget(self.base_dir_edit)
|
|
|
|
|
|
browse_base_btn = QPushButton("浏览…")
|
|
|
|
|
|
browse_base_btn.setFixedWidth(_sz(58))
|
|
|
|
|
|
browse_base_btn.setFixedHeight(_sz(26))
|
|
|
|
|
|
browse_base_btn.clicked.connect(self._browse_base_dir)
|
|
|
|
|
|
row_base.addWidget(browse_base_btn)
|
|
|
|
|
|
vg3.addLayout(row_base)
|
|
|
|
|
|
|
|
|
|
|
|
# 行 2:子目录 + 状态 + D2C + Splat
|
|
|
|
|
|
row_ses = QHBoxLayout()
|
|
|
|
|
|
row_ses.setSpacing(8)
|
|
|
|
|
|
lbl_ses = QLabel("子目录:")
|
2026-04-22 14:52:21 +08:00
|
|
|
|
lbl_ses.setFixedWidth(_sz(80))
|
2026-04-22 10:49:07 +08:00
|
|
|
|
row_ses.addWidget(lbl_ses)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
today = datetime.now()
|
2026-04-21 20:42:56 +08:00
|
|
|
|
self.session_edit = QLineEdit(f"{today.year}年{today.month}月{today.day}日")
|
2026-04-22 10:49:07 +08:00
|
|
|
|
self.session_edit.setMinimumWidth(_sz(140))
|
|
|
|
|
|
row_ses.addWidget(self.session_edit)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
create_btn = QPushButton("创建 / 载入")
|
2026-04-22 10:49:07 +08:00
|
|
|
|
create_btn.setFixedHeight(_sz(26))
|
2026-04-21 20:26:16 +08:00
|
|
|
|
create_btn.clicked.connect(self._create_session)
|
2026-04-22 10:49:07 +08:00
|
|
|
|
row_ses.addWidget(create_btn)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
self.session_status = QLabel("(未创建)")
|
2026-04-22 10:36:05 +08:00
|
|
|
|
self.session_status.setStyleSheet(f"color:gray; font-size:{_fs(12)}px;")
|
2026-04-22 10:49:07 +08:00
|
|
|
|
row_ses.addWidget(self.session_status)
|
|
|
|
|
|
row_ses.addStretch()
|
|
|
|
|
|
self.d2c_check = QCheckBox("D2C 预览")
|
|
|
|
|
|
self.d2c_check.setToolTip("勾选后预览使用 D2C 对齐;拍照时始终保存全部选中的数据")
|
2026-04-22 14:52:21 +08:00
|
|
|
|
self.d2c_check.setChecked(True)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
self.d2c_check.stateChanged.connect(
|
2026-04-21 20:42:56 +08:00
|
|
|
|
lambda s: self._worker and self._worker.set_d2c(s == Qt.Checked))
|
2026-04-22 10:49:07 +08:00
|
|
|
|
row_ses.addWidget(self.d2c_check)
|
|
|
|
|
|
vg3.addLayout(row_ses)
|
|
|
|
|
|
|
|
|
|
|
|
# 行 3:采集内容复选框
|
|
|
|
|
|
row_cap = QHBoxLayout()
|
|
|
|
|
|
row_cap.setSpacing(_sz(8))
|
|
|
|
|
|
lbl_cap = QLabel("采集内容:")
|
2026-04-22 14:52:21 +08:00
|
|
|
|
lbl_cap.setFixedWidth(_sz(80))
|
2026-04-22 10:49:07 +08:00
|
|
|
|
row_cap.addWidget(lbl_cap)
|
|
|
|
|
|
self._capture_checks: dict = {}
|
|
|
|
|
|
for key, label, default in [
|
|
|
|
|
|
("rgb", "RGB", self._app_settings.capture_rgb),
|
|
|
|
|
|
("depth", "Depth", self._app_settings.capture_depth),
|
|
|
|
|
|
("d2c", "Depth D2C", self._app_settings.capture_d2c),
|
|
|
|
|
|
("irl", "IR-L", self._app_settings.capture_irl),
|
|
|
|
|
|
("irr", "IR-R", self._app_settings.capture_irr),
|
|
|
|
|
|
("pc", "点云", self._app_settings.capture_pc),
|
|
|
|
|
|
("pc_color", "颜色点云", self._app_settings.capture_pc_color),
|
|
|
|
|
|
]:
|
|
|
|
|
|
cb = QCheckBox(label)
|
|
|
|
|
|
cb.setChecked(default)
|
|
|
|
|
|
row_cap.addWidget(cb)
|
|
|
|
|
|
self._capture_checks[key] = cb
|
|
|
|
|
|
row_cap.addStretch()
|
|
|
|
|
|
vg3.addLayout(row_cap)
|
|
|
|
|
|
|
2026-04-22 11:35:40 +08:00
|
|
|
|
# 行 4:D2C 转换方式
|
|
|
|
|
|
row_d2c_method = QHBoxLayout()
|
|
|
|
|
|
row_d2c_method.setSpacing(8)
|
|
|
|
|
|
lbl_d2c_method = QLabel("D2C 方式:")
|
2026-04-22 14:52:21 +08:00
|
|
|
|
lbl_d2c_method.setFixedWidth(_sz(80))
|
2026-04-22 11:35:40 +08:00
|
|
|
|
row_d2c_method.addWidget(lbl_d2c_method)
|
|
|
|
|
|
self.capture_d2c_method_combo = QComboBox()
|
2026-04-22 14:52:21 +08:00
|
|
|
|
self.capture_d2c_method_combo.setMinimumWidth(_sz(220))
|
2026-04-22 11:35:40 +08:00
|
|
|
|
self.capture_d2c_method_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
2026-04-22 14:52:21 +08:00
|
|
|
|
self.capture_d2c_method_combo.addItem("逆向投影(SDK 同等)", "sdk")
|
2026-04-22 11:35:40 +08:00
|
|
|
|
self.capture_d2c_method_combo.addItem("正向投影 + Splat 填充", "forward")
|
|
|
|
|
|
self.capture_d2c_method_combo.currentIndexChanged.connect(
|
|
|
|
|
|
self._on_capture_d2c_method_changed)
|
|
|
|
|
|
row_d2c_method.addWidget(self.capture_d2c_method_combo)
|
2026-04-22 14:52:21 +08:00
|
|
|
|
|
|
|
|
|
|
lbl_splat = QLabel("Splat:")
|
|
|
|
|
|
self.capture_splat_combo = QComboBox()
|
|
|
|
|
|
self.capture_splat_combo.setMinimumWidth(_sz(80))
|
|
|
|
|
|
self.capture_splat_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
|
|
|
|
|
for val, label in [(0, "关闭"), (1, "3×3"), (2, "5×5"), (3, "7×7")]:
|
|
|
|
|
|
self.capture_splat_combo.addItem(label, val)
|
|
|
|
|
|
idx = self.capture_splat_combo.findData(self._app_settings.splat_radius)
|
|
|
|
|
|
if idx >= 0:
|
|
|
|
|
|
self.capture_splat_combo.setCurrentIndex(idx)
|
|
|
|
|
|
self.capture_splat_combo.currentIndexChanged.connect(
|
|
|
|
|
|
self._on_capture_splat_combo_changed)
|
|
|
|
|
|
row_d2c_method.addWidget(lbl_splat)
|
|
|
|
|
|
row_d2c_method.addWidget(self.capture_splat_combo)
|
|
|
|
|
|
self.capture_splat_combo.setEnabled(False)
|
2026-04-22 11:35:40 +08:00
|
|
|
|
row_d2c_method.addStretch()
|
|
|
|
|
|
vg3.addLayout(row_d2c_method)
|
|
|
|
|
|
|
2026-04-21 20:52:41 +08:00
|
|
|
|
layout.addWidget(grp3)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
# ── 预览区 ──
|
|
|
|
|
|
preview = QFrame()
|
|
|
|
|
|
preview.setFrameShape(QFrame.StyledPanel)
|
|
|
|
|
|
preview.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
pv = QHBoxLayout(preview)
|
|
|
|
|
|
pv.setSpacing(6)
|
|
|
|
|
|
pv.setContentsMargins(6, 6, 6, 6)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
for attr, title in (("color_lbl", "彩色图"), ("depth_lbl", "深度伪彩图")):
|
|
|
|
|
|
box = QVBoxLayout()
|
|
|
|
|
|
hdr = QLabel(title)
|
|
|
|
|
|
hdr.setAlignment(Qt.AlignCenter)
|
2026-04-22 10:36:05 +08:00
|
|
|
|
hdr.setStyleSheet(f"font-weight:bold; font-size:{_fs(13)}px;")
|
2026-04-21 20:26:16 +08:00
|
|
|
|
box.addWidget(hdr)
|
|
|
|
|
|
lbl = QLabel("等待画面…")
|
|
|
|
|
|
lbl.setAlignment(Qt.AlignCenter)
|
2026-04-22 10:36:05 +08:00
|
|
|
|
lbl.setMinimumSize(_sz(540), _sz(360))
|
2026-04-21 20:26:16 +08:00
|
|
|
|
lbl.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
lbl.setStyleSheet("background:#111; border:1px solid #555; color:#666;")
|
2026-04-21 20:26:16 +08:00
|
|
|
|
setattr(self, attr, lbl)
|
|
|
|
|
|
box.addWidget(lbl)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
pv.addLayout(box)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
layout.addWidget(preview, stretch=1)
|
|
|
|
|
|
|
2026-04-21 20:52:41 +08:00
|
|
|
|
# ── 第 4 步:拍照 ──
|
|
|
|
|
|
step4_row = QHBoxLayout()
|
|
|
|
|
|
step4_lbl = QLabel("第 4 步 · 拍照")
|
|
|
|
|
|
step4_lbl.setStyleSheet(
|
2026-04-22 10:36:05 +08:00
|
|
|
|
f"color:#8ec8ff; font-weight:bold; font-size:{_fs(12)}px;")
|
2026-04-21 20:52:41 +08:00
|
|
|
|
step4_row.addWidget(step4_lbl)
|
|
|
|
|
|
step4_row.addStretch()
|
|
|
|
|
|
|
|
|
|
|
|
hint = QLabel("快捷键 S")
|
2026-04-22 10:36:05 +08:00
|
|
|
|
hint.setStyleSheet(f"color:#666; font-size:{_fs(11)}px;")
|
2026-04-21 20:52:41 +08:00
|
|
|
|
step4_row.addWidget(hint)
|
|
|
|
|
|
|
2026-04-21 20:26:16 +08:00
|
|
|
|
self.capture_btn = QPushButton("拍照")
|
2026-04-22 10:36:05 +08:00
|
|
|
|
self.capture_btn.setFixedSize(_sz(160), _sz(44))
|
2026-04-21 20:42:56 +08:00
|
|
|
|
self.capture_btn.setEnabled(False)
|
2026-04-22 10:36:05 +08:00
|
|
|
|
f = QFont(); f.setPointSize(_fs(13)); f.setBold(True)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
self.capture_btn.setFont(f)
|
|
|
|
|
|
self.capture_btn.setStyleSheet(
|
2026-04-21 20:42:56 +08:00
|
|
|
|
"QPushButton{ background:#2a6ebb; color:white; border-radius:8px; border:none; }"
|
2026-04-21 20:26:16 +08:00
|
|
|
|
"QPushButton:hover{ background:#3a80dd; }"
|
|
|
|
|
|
"QPushButton:pressed{ background:#1a559e; }"
|
2026-04-21 20:42:56 +08:00
|
|
|
|
"QPushButton:disabled{ background:#444; color:#777; }"
|
2026-04-21 20:26:16 +08:00
|
|
|
|
)
|
|
|
|
|
|
self.capture_btn.clicked.connect(self._on_capture)
|
2026-04-21 20:52:41 +08:00
|
|
|
|
step4_row.addWidget(self.capture_btn)
|
|
|
|
|
|
layout.addLayout(step4_row)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
return w
|
|
|
|
|
|
|
|
|
|
|
|
def _build_d2c_tab(self) -> QWidget:
|
|
|
|
|
|
w = QWidget()
|
|
|
|
|
|
layout = QVBoxLayout(w)
|
2026-04-21 21:26:51 +08:00
|
|
|
|
layout.setContentsMargins(12, 8, 12, 8)
|
|
|
|
|
|
layout.setSpacing(6)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-21 21:26:51 +08:00
|
|
|
|
COMBO_CSS = "QComboBox { combobox-popup: 0; }"
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-21 21:26:51 +08:00
|
|
|
|
# ── 第 1 步:刷新设备 ──
|
|
|
|
|
|
grp1 = self._step_group(1, "刷新设备")
|
|
|
|
|
|
g1 = QHBoxLayout(grp1)
|
|
|
|
|
|
g1.setContentsMargins(8, 4, 8, 6)
|
|
|
|
|
|
d2c_refresh_btn = QPushButton("刷新设备")
|
2026-04-22 10:36:05 +08:00
|
|
|
|
d2c_refresh_btn.setFixedHeight(_sz(28))
|
2026-04-21 21:26:51 +08:00
|
|
|
|
d2c_refresh_btn.clicked.connect(self._refresh_devices)
|
|
|
|
|
|
g1.addWidget(d2c_refresh_btn)
|
|
|
|
|
|
self.d2c_device_status = QLabel("未连接设备,请点击「刷新设备」")
|
2026-04-22 10:36:05 +08:00
|
|
|
|
self.d2c_device_status.setStyleSheet(f"color:#aaa; font-size:{_fs(12)}px;")
|
2026-04-21 21:26:51 +08:00
|
|
|
|
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)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
pg.setSpacing(6)
|
|
|
|
|
|
|
|
|
|
|
|
combo_row = QHBoxLayout()
|
|
|
|
|
|
combo_row.addWidget(QLabel("RGB:"))
|
|
|
|
|
|
self.d2c_color_combo = QComboBox()
|
2026-04-22 14:52:21 +08:00
|
|
|
|
self.d2c_color_combo.setMinimumWidth(_sz(340))
|
2026-04-22 10:36:05 +08:00
|
|
|
|
self.d2c_color_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
|
|
|
|
|
self.d2c_color_combo.setMaxVisibleItems(15)
|
2026-04-21 21:26:51 +08:00
|
|
|
|
self.d2c_color_combo.setStyleSheet(COMBO_CSS)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
combo_row.addWidget(self.d2c_color_combo)
|
|
|
|
|
|
|
|
|
|
|
|
combo_row.addSpacing(12)
|
|
|
|
|
|
combo_row.addWidget(QLabel("深度:"))
|
|
|
|
|
|
self.d2c_depth_combo = QComboBox()
|
2026-04-22 14:52:21 +08:00
|
|
|
|
self.d2c_depth_combo.setMinimumWidth(_sz(340))
|
2026-04-22 10:36:05 +08:00
|
|
|
|
self.d2c_depth_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
|
|
|
|
|
self.d2c_depth_combo.setMaxVisibleItems(15)
|
2026-04-21 21:26:51 +08:00
|
|
|
|
self.d2c_depth_combo.setStyleSheet(COMBO_CSS)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
combo_row.addWidget(self.d2c_depth_combo)
|
|
|
|
|
|
|
|
|
|
|
|
combo_row.addSpacing(12)
|
2026-04-21 21:31:46 +08:00
|
|
|
|
fetch_btn = QPushButton("从设备获取参数")
|
2026-04-22 10:36:05 +08:00
|
|
|
|
fetch_btn.setFixedHeight(_sz(28))
|
2026-04-21 21:31:46 +08:00
|
|
|
|
fetch_btn.clicked.connect(self._fetch_params_from_device)
|
|
|
|
|
|
combo_row.addWidget(fetch_btn)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
combo_row.addStretch()
|
|
|
|
|
|
pg.addLayout(combo_row)
|
|
|
|
|
|
|
|
|
|
|
|
sep = QLabel("── 或直接指定 JSON 文件 ──")
|
|
|
|
|
|
sep.setAlignment(Qt.AlignCenter)
|
2026-04-22 10:36:05 +08:00
|
|
|
|
sep.setStyleSheet(f"color:#666; font-size:{_fs(11)}px;")
|
2026-04-21 20:42:56 +08:00
|
|
|
|
pg.addWidget(sep)
|
|
|
|
|
|
|
|
|
|
|
|
json_row = QHBoxLayout()
|
2026-04-21 20:26:16 +08:00
|
|
|
|
self.params_path_edit = QLineEdit()
|
2026-04-21 20:42:56 +08:00
|
|
|
|
self.params_path_edit.setPlaceholderText("留空则使用上方自动查找结果…")
|
|
|
|
|
|
json_row.addWidget(self.params_path_edit)
|
2026-04-22 10:36:05 +08:00
|
|
|
|
br2 = QPushButton("浏览…"); br2.setFixedWidth(_sz(70))
|
2026-04-21 20:26:16 +08:00
|
|
|
|
br2.clicked.connect(lambda: self._browse_json(self.params_path_edit))
|
2026-04-21 20:42:56 +08:00
|
|
|
|
json_row.addWidget(br2)
|
|
|
|
|
|
pg.addLayout(json_row)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
self.params_selected_lbl = QLabel("(尚未选定参数文件)")
|
2026-04-22 10:36:05 +08:00
|
|
|
|
self.params_selected_lbl.setStyleSheet(f"color:#888; font-size:{_fs(12)}px;")
|
2026-04-21 20:42:56 +08:00
|
|
|
|
pg.addWidget(self.params_selected_lbl)
|
2026-04-21 21:26:51 +08:00
|
|
|
|
layout.addWidget(grp2)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
|
2026-04-21 21:26:51 +08:00
|
|
|
|
# ── 第 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)
|
2026-04-22 10:36:05 +08:00
|
|
|
|
br1 = QPushButton("浏览…"); br1.setFixedWidth(_sz(70))
|
2026-04-21 21:26:51 +08:00
|
|
|
|
br1.clicked.connect(lambda: self._browse_dir(self.d2c_dir_edit))
|
|
|
|
|
|
g3.addWidget(br1)
|
|
|
|
|
|
layout.addWidget(grp3)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
|
2026-04-21 21:26:51 +08:00
|
|
|
|
# ── 第 4 步:开始转换 ──
|
|
|
|
|
|
step4_row = QHBoxLayout()
|
|
|
|
|
|
step4_lbl = QLabel("第 4 步 · 开始转换")
|
2026-04-22 10:36:05 +08:00
|
|
|
|
step4_lbl.setStyleSheet(f"color:#8ec8ff; font-weight:bold; font-size:{_fs(12)}px;")
|
2026-04-21 21:26:51 +08:00
|
|
|
|
step4_row.addWidget(step4_lbl)
|
2026-04-22 11:35:36 +08:00
|
|
|
|
self.d2c_method_combo = QComboBox()
|
2026-04-22 14:52:21 +08:00
|
|
|
|
self.d2c_method_combo.setMinimumWidth(_sz(220))
|
2026-04-22 11:35:36 +08:00
|
|
|
|
self.d2c_method_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
|
|
|
|
|
self.d2c_method_combo.addItem("正向投影 + Splat 填充", "forward")
|
2026-04-22 14:52:21 +08:00
|
|
|
|
self.d2c_method_combo.addItem("逆向投影(SDK 同等)", "sdk")
|
|
|
|
|
|
self.d2c_method_combo.currentIndexChanged.connect(
|
|
|
|
|
|
self._on_d2c_method_changed)
|
2026-04-22 11:35:36 +08:00
|
|
|
|
step4_row.addWidget(self.d2c_method_combo)
|
2026-04-22 14:52:21 +08:00
|
|
|
|
|
|
|
|
|
|
self.d2c_splat_combo = QComboBox()
|
|
|
|
|
|
self.d2c_splat_combo.setMinimumWidth(_sz(80))
|
|
|
|
|
|
self.d2c_splat_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
|
|
|
|
|
for val, label in [(0, "关闭"), (1, "3×3"), (2, "5×5"), (3, "7×7")]:
|
|
|
|
|
|
self.d2c_splat_combo.addItem(label, val)
|
|
|
|
|
|
idx = self.d2c_splat_combo.findData(self._app_settings.splat_radius)
|
|
|
|
|
|
if idx >= 0:
|
|
|
|
|
|
self.d2c_splat_combo.setCurrentIndex(idx)
|
|
|
|
|
|
step4_row.addWidget(QLabel("Splat:"))
|
|
|
|
|
|
step4_row.addWidget(self.d2c_splat_combo)
|
2026-04-21 21:26:51 +08:00
|
|
|
|
step4_row.addStretch()
|
2026-04-21 20:26:16 +08:00
|
|
|
|
self.run_d2c_btn = QPushButton("▶ 开始批量 D2C 转换")
|
2026-04-22 10:36:05 +08:00
|
|
|
|
self.run_d2c_btn.setFixedHeight(_sz(36))
|
2026-04-21 20:26:16 +08:00
|
|
|
|
self.run_d2c_btn.setStyleSheet(
|
|
|
|
|
|
"QPushButton{ background:#2a7a2a; color:white; border-radius:6px; border:none; }"
|
|
|
|
|
|
"QPushButton:hover{ background:#3a9a3a; }"
|
2026-04-21 20:42:56 +08:00
|
|
|
|
"QPushButton:disabled{ background:#444; color:#777; }"
|
2026-04-21 20:26:16 +08:00
|
|
|
|
)
|
|
|
|
|
|
self.run_d2c_btn.clicked.connect(self._run_batch_d2c)
|
2026-04-21 21:26:51 +08:00
|
|
|
|
step4_row.addWidget(self.run_d2c_btn)
|
|
|
|
|
|
layout.addLayout(step4_row)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
# ── 日志 ──
|
2026-04-21 20:26:16 +08:00
|
|
|
|
layout.addWidget(QLabel("输出日志:"))
|
|
|
|
|
|
self.d2c_log = QTextEdit()
|
|
|
|
|
|
self.d2c_log.setReadOnly(True)
|
|
|
|
|
|
self.d2c_log.setStyleSheet(
|
2026-04-22 10:36:05 +08:00
|
|
|
|
f"font-family: Consolas,'Courier New',monospace; font-size:{_fs(12)}px;")
|
2026-04-21 20:26:16 +08:00
|
|
|
|
layout.addWidget(self.d2c_log, stretch=1)
|
|
|
|
|
|
|
|
|
|
|
|
return w
|
|
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
# ─────────────────────────────────
|
|
|
|
|
|
# Device Refresh
|
|
|
|
|
|
# ─────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def _refresh_devices(self):
|
|
|
|
|
|
"""连接相机,枚举并填充所有分辨率下拉框(Tab 1 + Tab 2)。"""
|
|
|
|
|
|
self.device_status.setText("正在扫描设备…")
|
|
|
|
|
|
QApplication.processEvents()
|
|
|
|
|
|
|
|
|
|
|
|
self.color_combo.clear()
|
|
|
|
|
|
self.depth_combo.clear()
|
2026-04-21 21:26:51 +08:00
|
|
|
|
self.preset_combo.clear()
|
2026-04-21 20:42:56 +08:00
|
|
|
|
self.d2c_color_combo.clear()
|
|
|
|
|
|
self.d2c_depth_combo.clear()
|
|
|
|
|
|
self._color_profiles_data.clear()
|
|
|
|
|
|
self._depth_profiles_data.clear()
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from pyorbbecsdk import Pipeline, OBSensorType
|
|
|
|
|
|
p = Pipeline()
|
|
|
|
|
|
|
|
|
|
|
|
sn = "unknown"
|
|
|
|
|
|
try:
|
|
|
|
|
|
sn = p.get_device().get_device_info().get_serial_number()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
color_list = p.get_stream_profile_list(OBSensorType.COLOR_SENSOR)
|
|
|
|
|
|
for i in range(color_list.get_count()):
|
|
|
|
|
|
vp = color_list.get_stream_profile_by_index(i).as_video_stream_profile()
|
|
|
|
|
|
if vp is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
fmt = str(vp.get_format()).split(".")[-1]
|
|
|
|
|
|
cfg = {"width": vp.get_width(), "height": vp.get_height(),
|
|
|
|
|
|
"fps": vp.get_fps(), "format": fmt}
|
|
|
|
|
|
label = f"{vp.get_width()}×{vp.get_height()} @ {vp.get_fps()} fps [{fmt}]"
|
|
|
|
|
|
self._color_profiles_data.append(cfg)
|
|
|
|
|
|
self.color_combo.addItem(label, cfg)
|
|
|
|
|
|
self.d2c_color_combo.addItem(label, cfg)
|
|
|
|
|
|
|
|
|
|
|
|
depth_list = p.get_stream_profile_list(OBSensorType.DEPTH_SENSOR)
|
|
|
|
|
|
for i in range(depth_list.get_count()):
|
|
|
|
|
|
vp = depth_list.get_stream_profile_by_index(i).as_video_stream_profile()
|
|
|
|
|
|
if vp is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
fmt = str(vp.get_format()).split(".")[-1]
|
|
|
|
|
|
cfg = {"width": vp.get_width(), "height": vp.get_height(),
|
|
|
|
|
|
"fps": vp.get_fps(), "format": fmt}
|
|
|
|
|
|
label = f"{vp.get_width()}×{vp.get_height()} @ {vp.get_fps()} fps [{fmt}]"
|
|
|
|
|
|
self._depth_profiles_data.append(cfg)
|
|
|
|
|
|
self.depth_combo.addItem(label, cfg)
|
|
|
|
|
|
self.d2c_depth_combo.addItem(label, cfg)
|
|
|
|
|
|
|
2026-04-21 21:26:51 +08:00
|
|
|
|
# 枚举 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)
|
2026-04-21 21:37:54 +08:00
|
|
|
|
for i in range(len(preset_list)):
|
|
|
|
|
|
name = preset_list[i]
|
2026-04-21 21:26:51 +08:00
|
|
|
|
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)
|
2026-04-21 21:37:54 +08:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.preset_combo.addItem(f"(不支持: {exc})", None)
|
2026-04-21 21:26:51 +08:00
|
|
|
|
self.preset_combo.setEnabled(False)
|
|
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
nc, nd = len(self._color_profiles_data), len(self._depth_profiles_data)
|
2026-04-21 21:26:51 +08:00
|
|
|
|
msg = f"已连接 SN: {sn} 彩色 {nc} 项 / 深度 {nd} 项"
|
|
|
|
|
|
self.device_status.setText(msg)
|
2026-04-22 10:45:09 +08:00
|
|
|
|
self.device_status.setStyleSheet(f"color:#4aaa4a; font-size:{_fs(12)}px;")
|
2026-04-21 21:26:51 +08:00
|
|
|
|
self.d2c_device_status.setText(msg)
|
2026-04-22 10:45:09 +08:00
|
|
|
|
self.d2c_device_status.setStyleSheet(f"color:#4aaa4a; font-size:{_fs(12)}px;")
|
2026-04-21 20:42:56 +08:00
|
|
|
|
|
|
|
|
|
|
except Exception as exc:
|
2026-04-21 21:26:51 +08:00
|
|
|
|
msg = f"未找到设备:{exc}"
|
|
|
|
|
|
self.device_status.setText(msg)
|
2026-04-22 10:45:09 +08:00
|
|
|
|
self.device_status.setStyleSheet(f"color:#cc6666; font-size:{_fs(12)}px;")
|
2026-04-21 21:26:51 +08:00
|
|
|
|
self.d2c_device_status.setText(msg)
|
2026-04-22 10:45:09 +08:00
|
|
|
|
self.d2c_device_status.setStyleSheet(f"color:#cc6666; font-size:{_fs(12)}px;")
|
2026-04-21 20:42:56 +08:00
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────
|
|
|
|
|
|
# Stream Toggle
|
|
|
|
|
|
# ─────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def _on_stream_toggle(self, checked: bool):
|
|
|
|
|
|
if checked:
|
|
|
|
|
|
if not self._color_profiles_data or not self._depth_profiles_data:
|
|
|
|
|
|
self._set_stream_btn(False)
|
|
|
|
|
|
QMessageBox.warning(self, "提示", "请先点击「刷新设备」以获取可用配置。")
|
|
|
|
|
|
return
|
|
|
|
|
|
ci = self.color_combo.currentIndex()
|
|
|
|
|
|
di = self.depth_combo.currentIndex()
|
|
|
|
|
|
if ci < 0 or di < 0:
|
|
|
|
|
|
self._set_stream_btn(False)
|
|
|
|
|
|
return
|
|
|
|
|
|
self._start_stream(self._color_profiles_data[ci],
|
|
|
|
|
|
self._depth_profiles_data[di])
|
|
|
|
|
|
else:
|
|
|
|
|
|
self._stop_stream()
|
|
|
|
|
|
|
|
|
|
|
|
def _start_stream(self, color_cfg: dict, depth_cfg: dict):
|
|
|
|
|
|
self._stop_stream() # 确保旧 worker 已停止
|
2026-04-21 21:26:51 +08:00
|
|
|
|
preset = self.preset_combo.currentData() # None 表示不切换
|
|
|
|
|
|
self._worker = CameraWorker(color_cfg, depth_cfg, preset=preset)
|
2026-04-22 14:52:21 +08:00
|
|
|
|
self._worker.set_d2c(self.d2c_check.isChecked())
|
|
|
|
|
|
self._worker.set_d2c_method(self.capture_d2c_method_combo.currentData())
|
|
|
|
|
|
self._worker.set_splat(self.capture_splat_combo.currentData())
|
2026-04-21 20:42:56 +08:00
|
|
|
|
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_d2c_log)
|
|
|
|
|
|
self._worker.disconnected.connect(self._on_camera_disconnected)
|
|
|
|
|
|
self._worker.start()
|
|
|
|
|
|
self.stream_btn.setText("关闭")
|
|
|
|
|
|
self.color_combo.setEnabled(False)
|
|
|
|
|
|
self.depth_combo.setEnabled(False)
|
|
|
|
|
|
self.capture_btn.setEnabled(True)
|
|
|
|
|
|
|
|
|
|
|
|
def _stop_stream(self):
|
|
|
|
|
|
if self._worker:
|
|
|
|
|
|
self._worker.stop()
|
|
|
|
|
|
self._worker = None
|
|
|
|
|
|
self.capture_btn.setEnabled(False)
|
|
|
|
|
|
self.color_combo.setEnabled(True)
|
|
|
|
|
|
self.depth_combo.setEnabled(True)
|
|
|
|
|
|
self.stream_btn.setText("开启")
|
|
|
|
|
|
# 清空预览
|
|
|
|
|
|
for lbl in (self.color_lbl, self.depth_lbl):
|
|
|
|
|
|
lbl.setText("等待画面…")
|
|
|
|
|
|
lbl.setPixmap(QPixmap())
|
|
|
|
|
|
|
|
|
|
|
|
def _set_stream_btn(self, state: bool):
|
|
|
|
|
|
"""不触发 toggled 信号地设置按钮状态。"""
|
|
|
|
|
|
self.stream_btn.blockSignals(True)
|
|
|
|
|
|
self.stream_btn.setChecked(state)
|
|
|
|
|
|
self.stream_btn.setText("关闭" if state else "开启")
|
|
|
|
|
|
self.stream_btn.blockSignals(False)
|
|
|
|
|
|
|
|
|
|
|
|
def _on_camera_disconnected(self):
|
|
|
|
|
|
"""相机断开:静默关闭流,不弹窗。"""
|
|
|
|
|
|
if self._worker:
|
|
|
|
|
|
self._worker.deleteLater()
|
|
|
|
|
|
self._worker = None
|
|
|
|
|
|
self._set_stream_btn(False)
|
|
|
|
|
|
self.capture_btn.setEnabled(False)
|
|
|
|
|
|
self.color_combo.setEnabled(True)
|
|
|
|
|
|
self.depth_combo.setEnabled(True)
|
|
|
|
|
|
for lbl in (self.color_lbl, self.depth_lbl):
|
|
|
|
|
|
lbl.setText("设备已断开,请刷新后重新开启")
|
|
|
|
|
|
lbl.setPixmap(QPixmap())
|
|
|
|
|
|
self.device_status.setText("设备已断开连接,请重新插入后点击「刷新设备」")
|
2026-04-22 10:45:09 +08:00
|
|
|
|
self.device_status.setStyleSheet(f"color:#cc8844; font-size:{_fs(12)}px;")
|
2026-04-21 20:42:56 +08:00
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────
|
|
|
|
|
|
# Preview
|
|
|
|
|
|
# ─────────────────────────────────
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
# ─────────────────────────────────
|
|
|
|
|
|
# Session / Capture
|
|
|
|
|
|
# ─────────────────────────────────
|
|
|
|
|
|
|
2026-04-21 20:26:16 +08:00
|
|
|
|
def _create_session(self):
|
|
|
|
|
|
name = self.session_edit.text().strip()
|
|
|
|
|
|
if not name:
|
2026-04-22 10:49:07 +08:00
|
|
|
|
QMessageBox.warning(self, "提示", "请输入子目录名称。")
|
2026-04-21 20:26:16 +08:00
|
|
|
|
return
|
2026-04-22 10:49:07 +08:00
|
|
|
|
base = Path(self.base_dir_edit.text().strip() or ".")
|
|
|
|
|
|
session_dir = base / name
|
2026-04-21 20:26:16 +08:00
|
|
|
|
session_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
self._session_dir = session_dir
|
2026-04-21 20:42:56 +08:00
|
|
|
|
if self._worker and self._worker._camera_params:
|
2026-04-21 20:26:16 +08:00
|
|
|
|
self._save_params_to_session(self._worker._camera_params)
|
|
|
|
|
|
self.session_status.setText(f"✓ {session_dir}")
|
2026-04-22 10:45:09 +08:00
|
|
|
|
self.session_status.setStyleSheet(f"color:#2a9a2a; font-size:{_fs(12)}px;")
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
def _on_params_ready(self, params: dict):
|
2026-04-21 20:26:21 +08:00
|
|
|
|
sn = params.get("device", {}).get("serial_number", "unknown")
|
|
|
|
|
|
cw = params["color"]["width"]; ch = params["color"]["height"]
|
|
|
|
|
|
cfps = params["color"]["fps"]
|
|
|
|
|
|
dw = params["depth"]["width"]; dh = params["depth"]["height"]
|
|
|
|
|
|
dfps = params["depth"]["fps"]
|
2026-04-21 20:42:56 +08:00
|
|
|
|
self._params_filename = f"SN{sn}_RGB{cw}x{ch}@{cfps}_D{dw}x{dh}@{dfps}.json"
|
2026-04-21 20:26:16 +08:00
|
|
|
|
if self._session_dir and self._session_dir.exists():
|
|
|
|
|
|
self._save_params_to_session(params)
|
|
|
|
|
|
|
|
|
|
|
|
def _save_params_to_session(self, params: dict):
|
|
|
|
|
|
try:
|
2026-04-21 20:42:56 +08:00
|
|
|
|
(self._session_dir / self._params_filename).write_text(
|
|
|
|
|
|
json.dumps(params, indent=2, ensure_ascii=False), encoding="utf-8")
|
2026-04-21 20:26:16 +08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
def _on_capture(self):
|
|
|
|
|
|
if self._session_dir is None:
|
|
|
|
|
|
self._create_session()
|
|
|
|
|
|
if self._session_dir is None:
|
|
|
|
|
|
return
|
2026-04-21 20:42:56 +08:00
|
|
|
|
ts = datetime.now().strftime("%Y%m%d%H%M%S")
|
2026-04-21 20:26:16 +08:00
|
|
|
|
self.capture_btn.setEnabled(False)
|
|
|
|
|
|
self.capture_btn.setText("拍摄中…")
|
2026-04-22 10:49:07 +08:00
|
|
|
|
capture_opts = {k: cb.isChecked() for k, cb in self._capture_checks.items()}
|
2026-04-22 11:35:40 +08:00
|
|
|
|
capture_opts["d2c_method"] = self.capture_d2c_method_combo.currentData()
|
2026-04-22 14:52:21 +08:00
|
|
|
|
capture_opts["splat_radius"] = self.capture_splat_combo.currentData()
|
2026-04-22 10:49:07 +08:00
|
|
|
|
self._worker.trigger_capture(str(self._session_dir / ts), capture_opts)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
def _on_capture_done(self, save_dir: str):
|
|
|
|
|
|
self.capture_btn.setEnabled(True)
|
|
|
|
|
|
self.capture_btn.setText("拍照")
|
2026-04-21 20:52:41 +08:00
|
|
|
|
self._toast.show_msg("拍摄完成!", 1000)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
# ─────────────────────────────────
|
|
|
|
|
|
# Batch D2C Tab
|
|
|
|
|
|
# ─────────────────────────────────
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-21 21:31:46 +08:00
|
|
|
|
def _fetch_params_from_device(self):
|
|
|
|
|
|
"""根据当前选中的 RGB + 深度配置,临时打开设备流并提取相机参数。"""
|
2026-04-21 20:42:56 +08:00
|
|
|
|
ci = self.d2c_color_combo.currentIndex()
|
|
|
|
|
|
di = self.d2c_depth_combo.currentIndex()
|
|
|
|
|
|
if ci < 0 or di < 0:
|
|
|
|
|
|
self.params_selected_lbl.setText("请先刷新设备并选择分辨率")
|
2026-04-22 10:45:09 +08:00
|
|
|
|
self.params_selected_lbl.setStyleSheet(f"color:#cc6666; font-size:{_fs(12)}px;")
|
2026-04-21 20:42:56 +08:00
|
|
|
|
return
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
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("无配置数据,请先刷新设备")
|
2026-04-22 10:45:09 +08:00
|
|
|
|
self.params_selected_lbl.setStyleSheet(f"color:#cc6666; font-size:{_fs(12)}px;")
|
2026-04-21 20:42:56 +08:00
|
|
|
|
return
|
|
|
|
|
|
|
2026-04-21 21:31:46 +08:00
|
|
|
|
self.params_selected_lbl.setText("正在从设备获取参数…")
|
2026-04-22 10:45:09 +08:00
|
|
|
|
self.params_selected_lbl.setStyleSheet(f"color:#888; font-size:{_fs(12)}px;")
|
2026-04-21 21:31:46 +08:00
|
|
|
|
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)
|
|
|
|
|
|
self._fetch_worker.start()
|
2026-04-21 20:42:56 +08:00
|
|
|
|
|
2026-04-21 21:31:46 +08:00
|
|
|
|
def _on_fetch_params_ready(self, params: dict, path: str):
|
|
|
|
|
|
self.params_path_edit.setText(path)
|
|
|
|
|
|
self.params_selected_lbl.setText(f"✓ 已获取并缓存:{path}")
|
2026-04-22 10:45:09 +08:00
|
|
|
|
self.params_selected_lbl.setStyleSheet(f"color:#4aaa4a; font-size:{_fs(12)}px;")
|
2026-04-21 21:31:46 +08:00
|
|
|
|
self._append_d2c_log(
|
|
|
|
|
|
f"[参数获取] RGB {params['color']['width']}x{params['color']['height']} "
|
|
|
|
|
|
f"/ Depth {params['depth']['width']}x{params['depth']['height']}"
|
|
|
|
|
|
)
|
2026-04-21 21:37:54 +08:00
|
|
|
|
# 在日志区打印完整参数
|
2026-04-21 21:37:43 +08:00
|
|
|
|
c = params["color"]
|
|
|
|
|
|
d = params["depth"]
|
|
|
|
|
|
ci = c["intrinsic"]
|
|
|
|
|
|
di = d["intrinsic"]
|
2026-04-21 21:37:54 +08:00
|
|
|
|
cd = c["distortion"]
|
|
|
|
|
|
dd = d["distortion"]
|
2026-04-21 21:37:43 +08:00
|
|
|
|
ext = params["extrinsic"]
|
|
|
|
|
|
R = np.array(ext["rot"]).reshape(3, 3)
|
|
|
|
|
|
t = np.array(ext["transform"])
|
2026-04-21 21:37:54 +08:00
|
|
|
|
sn = params.get("device", {}).get("serial_number", "unknown")
|
2026-04-21 21:37:43 +08:00
|
|
|
|
self._append_d2c_log("--- Camera Parameters ---")
|
2026-04-21 21:37:54 +08:00
|
|
|
|
self._append_d2c_log(f" Device: SN {sn}")
|
2026-04-21 21:37:43 +08:00
|
|
|
|
self._append_d2c_log(f" RGB : {c['width']}x{c['height']} @ {c['fps']}fps {c['format']}")
|
2026-04-21 21:37:54 +08:00
|
|
|
|
self._append_d2c_log(f" intrinsic fx={ci['fx']:.4f} fy={ci['fy']:.4f} cx={ci['cx']:.4f} cy={ci['cy']:.4f}")
|
|
|
|
|
|
self._append_d2c_log(f" distortion k1={cd['k1']:.6f} k2={cd['k2']:.6f} k3={cd['k3']:.6f} k4={cd['k4']:.6f}")
|
|
|
|
|
|
self._append_d2c_log(f" k5={cd['k5']:.6f} k6={cd['k6']:.6f} p1={cd['p1']:.6f} p2={cd['p2']:.6f}")
|
2026-04-21 21:37:43 +08:00
|
|
|
|
self._append_d2c_log(f" Depth : {d['width']}x{d['height']} @ {d['fps']}fps {d['format']}")
|
2026-04-21 21:37:54 +08:00
|
|
|
|
self._append_d2c_log(f" intrinsic fx={di['fx']:.4f} fy={di['fy']:.4f} cx={di['cx']:.4f} cy={di['cy']:.4f}")
|
|
|
|
|
|
self._append_d2c_log(f" distortion k1={dd['k1']:.6f} k2={dd['k2']:.6f} k3={dd['k3']:.6f} k4={dd['k4']:.6f}")
|
|
|
|
|
|
self._append_d2c_log(f" k5={dd['k5']:.6f} k6={dd['k6']:.6f} p1={dd['p1']:.6f} p2={dd['p2']:.6f}")
|
2026-04-21 21:37:43 +08:00
|
|
|
|
self._append_d2c_log(f" scale={d['scale']:.6f} mm/unit")
|
|
|
|
|
|
self._append_d2c_log(f" Extrinsic R : {R[0].tolist()}")
|
|
|
|
|
|
self._append_d2c_log(f" {R[1].tolist()}")
|
|
|
|
|
|
self._append_d2c_log(f" {R[2].tolist()}")
|
|
|
|
|
|
self._append_d2c_log(f" Extrinsic t : {t.tolist()} (mm)")
|
2026-04-21 21:31:46 +08:00
|
|
|
|
self._fetch_worker = None
|
2026-04-21 20:42:56 +08:00
|
|
|
|
|
|
|
|
|
|
def _resolve_params_file(self) -> str | None:
|
|
|
|
|
|
explicit = self.params_path_edit.text().strip()
|
|
|
|
|
|
if explicit and Path(explicit).is_file():
|
|
|
|
|
|
return explicit
|
|
|
|
|
|
# session 目录下 SN*.json
|
|
|
|
|
|
if self._session_dir and self._session_dir.exists():
|
|
|
|
|
|
hits = sorted(self._session_dir.glob("SN*.json"))
|
|
|
|
|
|
if hits:
|
|
|
|
|
|
return str(hits[0])
|
|
|
|
|
|
fb = self._session_dir / PARAMS_FILE
|
|
|
|
|
|
if fb.is_file():
|
|
|
|
|
|
return str(fb)
|
|
|
|
|
|
hits = sorted(Path(".").glob("SN*.json"))
|
|
|
|
|
|
if hits:
|
|
|
|
|
|
return str(hits[0])
|
|
|
|
|
|
if Path(PARAMS_FILE).is_file():
|
|
|
|
|
|
return PARAMS_FILE
|
|
|
|
|
|
return None
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-04-21 20:42:56 +08:00
|
|
|
|
self.params_selected_lbl.setText(f"✓ {f}")
|
2026-04-22 10:45:09 +08:00
|
|
|
|
self.params_selected_lbl.setStyleSheet(f"color:#4aaa4a; font-size:{_fs(12)}px;")
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
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, "错误",
|
2026-04-21 20:42:56 +08:00
|
|
|
|
"找不到相机参数文件。\n"
|
|
|
|
|
|
"请先开启摄像头流(自动生成)或手动选择 JSON 文件。")
|
2026-04-21 20:26:16 +08:00
|
|
|
|
return
|
2026-04-21 20:42:56 +08:00
|
|
|
|
with open(params_file, encoding="utf-8") as fh:
|
2026-04-21 20:26:16 +08:00
|
|
|
|
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)
|
2026-04-22 11:35:36 +08:00
|
|
|
|
method = self.d2c_method_combo.currentData()
|
2026-04-22 14:52:21 +08:00
|
|
|
|
splat_r = self.d2c_splat_combo.currentData() if method == "forward" else 1
|
2026-04-22 11:35:36 +08:00
|
|
|
|
self._batch_worker = BatchD2CWorker(params, depth_dir,
|
|
|
|
|
|
method=method, splat_radius=splat_r)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
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[ 转换完成 ]")
|
|
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
def _append_d2c_log(self, msg: str):
|
|
|
|
|
|
self.d2c_log.append(msg)
|
2026-04-21 20:26:21 +08:00
|
|
|
|
|
2026-04-21 20:52:41 +08:00
|
|
|
|
# ─────────────────────────────────
|
|
|
|
|
|
# Keyboard & Resize
|
|
|
|
|
|
# ─────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def keyPressEvent(self, event):
|
|
|
|
|
|
if event.key() == Qt.Key_S:
|
|
|
|
|
|
focused = QApplication.focusWidget()
|
|
|
|
|
|
if not isinstance(focused, (QLineEdit, QTextEdit)) and self.capture_btn.isEnabled():
|
|
|
|
|
|
self._on_capture()
|
|
|
|
|
|
return
|
|
|
|
|
|
super().keyPressEvent(event)
|
|
|
|
|
|
|
|
|
|
|
|
def resizeEvent(self, event):
|
|
|
|
|
|
super().resizeEvent(event)
|
|
|
|
|
|
self._toast.reposition()
|
|
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
# ─────────────────────────────────
|
|
|
|
|
|
# Close
|
|
|
|
|
|
# ─────────────────────────────────
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
|
|
|
|
|
def closeEvent(self, event):
|
2026-04-21 20:42:56 +08:00
|
|
|
|
self._stop_stream()
|
2026-04-21 20:26:16 +08:00
|
|
|
|
super().closeEvent(event)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
# Entry Point
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
|
app = QApplication(sys.argv)
|
|
|
|
|
|
app.setStyle("Fusion")
|
|
|
|
|
|
|
2026-04-22 10:36:05 +08:00
|
|
|
|
global _UI_SCALE
|
|
|
|
|
|
_cfg = AppSettings()
|
|
|
|
|
|
_UI_SCALE = _cfg.get_scale_factor()
|
|
|
|
|
|
if _UI_SCALE != 1.0:
|
|
|
|
|
|
base = app.font()
|
|
|
|
|
|
base.setPointSizeF(base.pointSizeF() * _UI_SCALE)
|
|
|
|
|
|
app.setFont(base)
|
|
|
|
|
|
|
2026-04-21 20:26:16 +08:00
|
|
|
|
from PyQt5.QtGui import QPalette, QColor
|
2026-04-21 20:42:56 +08:00
|
|
|
|
pal = QPalette()
|
|
|
|
|
|
pal.setColor(QPalette.Window, QColor(45, 45, 45))
|
|
|
|
|
|
pal.setColor(QPalette.WindowText, QColor(220, 220, 220))
|
|
|
|
|
|
pal.setColor(QPalette.Base, QColor(30, 30, 30))
|
|
|
|
|
|
pal.setColor(QPalette.AlternateBase, QColor(50, 50, 50))
|
|
|
|
|
|
pal.setColor(QPalette.ToolTipBase, QColor(255, 255, 220))
|
|
|
|
|
|
pal.setColor(QPalette.ToolTipText, QColor(0, 0, 0))
|
|
|
|
|
|
pal.setColor(QPalette.Text, QColor(220, 220, 220))
|
|
|
|
|
|
pal.setColor(QPalette.Button, QColor(60, 60, 60))
|
|
|
|
|
|
pal.setColor(QPalette.ButtonText, QColor(220, 220, 220))
|
|
|
|
|
|
pal.setColor(QPalette.BrightText, QColor(255, 0, 0))
|
|
|
|
|
|
pal.setColor(QPalette.Link, QColor(42, 130, 218))
|
|
|
|
|
|
pal.setColor(QPalette.Highlight, QColor(42, 130, 218))
|
|
|
|
|
|
pal.setColor(QPalette.HighlightedText, QColor(0, 0, 0))
|
|
|
|
|
|
app.setPalette(pal)
|
2026-04-21 20:26:16 +08:00
|
|
|
|
|
2026-04-21 20:42:56 +08:00
|
|
|
|
win = MainWindow()
|
|
|
|
|
|
win.show()
|
2026-04-21 20:26:16 +08:00
|
|
|
|
sys.exit(app.exec_())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|