Files
Orbbec_D2C/main.py
T
2026-04-22 14:52:21 +08:00

1904 lines
80 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Orbbec 数据采集 GUI
- 主界面直接打开,刷新后选择分辨率 + 开关流
- 数据采集:实时预览 + 一键拍照
- Orbbec Viewer 深度图 D2C:批量软件 D2C 转换
"""
import os
import sys
import json
import time
import threading
import tempfile
import shutil
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))
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", "")
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QLineEdit, QCheckBox, QTabWidget,
QComboBox, QFileDialog, QTextEdit, QFrame, QMessageBox,
QSizePolicy, QGroupBox, QDialog, QAction,
)
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer
from PyQt5.QtGui import QImage, QPixmap, QFont
from d2c_batch import batch_convert, software_d2c, software_d2c_inv
PARAMS_FILE = "camera_params.json"
MIN_DEPTH_MM, MAX_DEPTH_MM = 200, 5000
_DISCONNECT_TIMEOUTS = 6 # 6 × 500 ms = 3 s 无帧 → 断线
_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:
_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.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
self._load()
def _load(self):
if self._CONFIG_PATH.is_file():
try:
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))
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,
"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,
},
ensure_ascii=False, indent=2,
),
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("程序设置")
self.setFixedSize(_sz(520), _sz(360))
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)
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)
# ── 分隔 ──
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)
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)
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)
def _save_and_close(self):
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())
self._settings.save()
self.accept()
# ─────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────
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)
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)
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)
vis = cv2.applyColorMap((norm * 255).astype(np.uint8), cv2.COLORMAP_JET)
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
vf = ir_frame.as_video_frame()
data = np.asanyarray(vf.get_data())
w, h, fmt = vf.get_width(), vf.get_height(), vf.get_format()
if fmt == OBFormat.Y8:
arr = np.resize(data, (h, w)).astype(np.uint8)
elif fmt == OBFormat.MJPG:
arr = cv2.imdecode(data, cv2.IMREAD_GRAYSCALE)
if arr is None:
return None
else:
arr = np.frombuffer(data, dtype=np.uint16).reshape(h, w)
arr = cv2.normalize(arr, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
return cv2.cvtColor(arr, cv2.COLOR_GRAY2BGR)
except Exception:
return None
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()
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
# ─────────────────────────────────────────────────────────────────
# Camera Worker Thread
# ─────────────────────────────────────────────────────────────────
class CameraWorker(QThread):
preview_ready = pyqtSignal(object, object) # color_bgr, depth_vis
capture_done = pyqtSignal(str) # save_dir
params_ready = pyqtSignal(dict)
log_msg = pyqtSignal(str)
disconnected = pyqtSignal()
def __init__(self, color_cfg: dict, depth_cfg: dict, preset: str | None = None):
super().__init__()
self._color_cfg = color_cfg
self._depth_cfg = depth_cfg
self._preset = preset
self._lock = threading.Lock()
self._running = True
self._d2c = False
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
self._disconnect_emitted = False
self._sync_wait_count = 0 # 等待同步帧的计数器
self._splat_radius = 0
self._d2c_method = "sdk"
self._capture_opts: dict = {}
def set_d2c(self, enabled: bool):
with self._lock:
self._d2c = enabled
def set_d2c_method(self, method: str):
with self._lock:
self._d2c_method = method
def set_splat(self, radius: int):
with self._lock:
self._splat_radius = radius
def trigger_capture(self, save_dir: str, capture_opts: dict | None = None):
with self._lock:
self._capture_dir = save_dir
self._capture_opts = capture_opts or {}
self._do_capture = True
def stop(self):
self._running = False
self.wait(6000)
# ── Thread body ──
def run(self):
try:
from pyorbbecsdk import (
Pipeline, Config, AlignFilter, PointCloudFilter,
OBStreamType, OBSensorType, OBFrameType, OBFormat,
OBFrameAggregateOutputMode,
save_point_cloud_to_ply,
)
from utils import frame_to_bgr_image
except ImportError as exc:
self.log_msg.emit(f"导入 pyorbbecsdk 失败:{exc}")
return
pipeline = Pipeline()
config = Config()
# 获取 SN + 探测 IR 类型 + 应用 preset
try:
device = pipeline.get_device()
try:
self._serial_number = device.get_device_info().get_serial_number()
except Exception:
pass
sensor_list = device.get_sensor_list()
for i in range(len(sensor_list)):
st = sensor_list[i].get_type()
if st in (OBSensorType.LEFT_IR_SENSOR, OBSensorType.RIGHT_IR_SENSOR):
self._has_dual_ir = True
if self._preset:
try:
device.load_preset(self._preset)
self.log_msg.emit(f"已加载 Preset:{self._preset}")
except Exception as exc:
self.log_msg.emit(f"Preset 加载失败:{exc}")
except Exception as exc:
self.log_msg.emit(f"设备信息获取警告:{exc}")
# 找到匹配的流 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)
# 要求所有启用的流都到齐才输出帧集,避免不同时刻的帧被混合返回
try:
config.set_frame_aggregate_output_mode(
OBFrameAggregateOutputMode.FULL_FRAME_REQUIRE)
except Exception:
pass
# 启用与深度流分辨率/帧率一致的 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}")
try:
pipeline.start(config)
except Exception as exc:
self.log_msg.emit(f"Pipeline 启动失败:{exc}")
self.disconnected.emit()
return
try:
pipeline.enable_frame_sync()
except Exception:
pass # 部分固件版本不支持,忽略
align_filter = AlignFilter(align_to_stream=OBStreamType.COLOR_STREAM)
point_cloud_filter = PointCloudFilter()
while self._running:
try:
frames = pipeline.wait_for_frames(500)
if frames is None:
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()
continue
self._consec_timeouts = 0
with self._lock:
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
# 首帧提取相机参数
if self._camera_params is None:
cf = frames.get_color_frame()
df = frames.get_depth_frame()
if cf and df:
try:
p = self._extract_params(cf, df)
p['device'] = {'serial_number': self._serial_number}
self._camera_params = p
self.params_ready.emit(p)
except Exception:
pass
# 执行拍照(等待 RGB-D 时间戳对齐后再拍)
if do_cap and cap_dir:
ts_diff = self._frame_ts_diff(frames)
self._sync_wait_count += 1
threshold_ms = 1000 // max(self._depth_cfg.get("fps", 30), 1)
force = self._sync_wait_count >= 10
if ts_diff <= threshold_ms or force:
with self._lock:
self._do_capture = False
self._capture_dir = ""
self._sync_wait_count = 0
if force and ts_diff > threshold_ms:
self.log_msg.emit(
f"警告:RGB-D 时间差 {ts_diff}ms 仍未对齐,强制拍摄")
else:
self.log_msg.emit(f"帧同步良好(RGB-D 差 {ts_diff}ms),开始拍摄")
try:
self._run_capture(
frames, cap_dir, cap_opts,
align_filter, point_cloud_filter,
frame_to_bgr_image, save_point_cloud_to_ply,
OBFrameType, OBFormat,
)
except Exception as exc:
self.log_msg.emit(f"拍照错误:{exc}")
else:
self.log_msg.emit(
f"等待帧同步…RGB-D 差 {ts_diff}ms({self._sync_wait_count}/10)")
continue
# 预览
cf = frames.get_color_frame()
df = frames.get_depth_frame()
if not cf or not df:
continue
color_bgr = frame_to_bgr_image(cf)
if color_bgr is None:
continue
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)
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}")
self._consec_timeouts += _DISCONNECT_TIMEOUTS
if not self._disconnect_emitted:
self._disconnect_emitted = True
self._running = False
self.disconnected.emit()
time.sleep(0.1)
try:
pipeline.stop()
except Exception:
pass
# ── Capture ──
def _run_capture(self, frames, save_dir, capture_opts,
align_filter, point_cloud_filter,
frame_to_bgr_image, save_point_cloud_to_ply,
OBFrameType, OBFormat):
base = Path(save_dir)
base.mkdir(parents=True, exist_ok=True)
cf = frames.get_color_frame()
df = frames.get_depth_frame()
if capture_opts.get("rgb", True) and cf:
bgr = frame_to_bgr_image(cf)
if bgr is not None:
imwrite_utf8(str(base / "color.png"), bgr)
if self._ir_enabled:
if self._has_dual_ir:
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)
else:
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)
if capture_opts.get("depth", True) and df:
raw = np.frombuffer(df.get_data(), dtype=np.uint16).reshape(
df.get_height(), df.get_width())
imwrite_utf8(str(base / "depth.png"), raw)
imwrite_utf8(str(base / "depth_vis.png"),
depth_to_vis(raw, df.get_depth_scale()))
d2c_method = capture_opts.get("d2c_method", "sdk")
need_aligned = ((d2c_method == "sdk" and capture_opts.get("d2c", True)) or
capture_opts.get("pc", False) or
capture_opts.get("pc_color", False))
aset = None
if need_aligned:
aligned = align_filter.process(frames)
if aligned:
aset = aligned.as_frame_set()
# D2C 深度图保存
if capture_opts.get("d2c", True):
if d2c_method == "sdk" and aset:
adf = aset.get_depth_frame()
if adf:
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()))
elif self._camera_params is not None and d2c_method == "forward":
raw = np.frombuffer(df.get_data(), dtype=np.uint16).reshape(
df.get_height(), df.get_width())
splat_r = capture_opts.get("splat_radius", 1)
aligned_img = software_d2c(raw, self._camera_params, splat_radius=splat_r)
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()))
# 点云保存(始终使用 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}")
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}")
self.capture_done.emit(save_dir)
# ── Static helpers ──
@staticmethod
def _frame_ts_diff(frames) -> int:
"""返回彩色帧与深度帧时间戳差值(ms),获取失败返回 0。"""
try:
cf = frames.get_color_frame()
df = frames.get_depth_frame()
if cf and df:
return abs(int(cf.get_timestamp()) - int(df.get_timestamp()))
except Exception:
pass
return 0
@staticmethod
def _extract_params(color_frame, depth_frame) -> dict:
cvp = color_frame.get_stream_profile().as_video_stream_profile()
dvp = depth_frame.get_stream_profile().as_video_stream_profile()
def intr(i):
return {"fx": float(i.fx), "fy": float(i.fy),
"cx": float(i.cx), "cy": float(i.cy),
"width": int(i.width), "height": int(i.height)}
def dist(d):
return {"k1": float(d.k1), "k2": float(d.k2),
"k3": float(d.k3), "k4": float(d.k4),
"k5": float(d.k5), "k6": float(d.k6),
"p1": float(d.p1), "p2": float(d.p2)}
ext = dvp.get_extrinsic_to(cvp)
return {
"color": {
"width": color_frame.get_width(), "height": color_frame.get_height(),
"fps": cvp.get_fps(),
"format": str(cvp.get_format()).split(".")[-1],
"intrinsic": intr(cvp.get_intrinsic()),
"distortion": dist(cvp.get_distortion()),
},
"depth": {
"width": depth_frame.get_width(), "height": depth_frame.get_height(),
"fps": dvp.get_fps(),
"format": str(dvp.get_format()).split(".")[-1],
"scale": float(depth_frame.get_depth_scale()),
"intrinsic": intr(dvp.get_intrinsic()),
"distortion": dist(dvp.get_distortion()),
},
"extrinsic": {
"rot": ext.rot.tolist(),
"transform": ext.transform.tolist(),
},
}
# ─────────────────────────────────────────────────────────────────
# Batch D2C Worker Thread
# ─────────────────────────────────────────────────────────────────
class 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}
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"
)
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
class BatchD2CWorker(QThread):
log = pyqtSignal(str)
done = pyqtSignal()
def __init__(self, params: dict, depth_dir: str,
method: str = "inverse", splat_radius: int = 1):
super().__init__()
self._params = params
self._depth_dir = depth_dir
self._method = method
self._splat_radius = splat_radius
def run(self):
class _Cap(io.StringIO):
def __init__(self, sig):
super().__init__()
self._sig = sig
def write(self, s):
for line in s.splitlines():
if line.strip():
self._sig.emit(line)
with contextlib.redirect_stdout(_Cap(self.log)):
try:
batch_convert(self._params, self._depth_dir,
method=self._method,
splat_radius=self._splat_radius)
except Exception as exc:
self.log.emit(f"Error: {exc}")
self.done.emit()
# ─────────────────────────────────────────────────────────────────
# 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(
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;"
)
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,
p.height() - self.height() - _sz(70))
self.raise_()
self.show()
self._timer.start(ms)
def reposition(self):
if self.isVisible():
p = self.parent()
self.move((p.width() - self.width()) // 2,
p.height() - self.height() - _sz(70))
# ─────────────────────────────────────────────────────────────────
# Main Window
# ─────────────────────────────────────────────────────────────────
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
_title = "Orbbec 数据采集 & D2C"
if _COPYRIGHT:
_title += f" | © {_COPYRIGHT}"
self.setWindowTitle(_title)
self.resize(_sz(1300), _sz(860))
self._worker: CameraWorker | None = None
self._batch_worker: BatchD2CWorker | None = None
self._fetch_worker: FetchParamsWorker | None = None
self._session_dir: Path | None = None
self._params_filename = "camera_params.json"
self._color_profiles_data: list[dict] = []
self._depth_profiles_data: list[dict] = []
self._app_settings = AppSettings()
self._build_ui()
# toast 覆盖在 central widget 上
self._toast = _Toast(self.centralWidget())
# ─────────────────────────────────
# UI Construction
# ─────────────────────────────────
def _build_ui(self):
# 菜单栏
settings_menu = self.menuBar().addMenu("设置")
action_pref = QAction("程序设置…", self)
action_pref.triggered.connect(self._show_settings)
settings_menu.addAction(action_pref)
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)
central = QWidget()
self.setCentralWidget(central)
root = QVBoxLayout(central)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
tabs = QTabWidget()
tabs.addTab(self._build_capture_tab(), " 数据采集 ")
tabs.addTab(self._build_d2c_tab(), " Orbbec Viewer 深度图 D2C ")
root.addWidget(tabs)
def _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()
def _show_settings(self):
dlg = SettingsDialog(self._app_settings, self)
if dlg.exec_() == QDialog.Accepted:
# 同步 Splat 下拉框
idx = self.capture_splat_combo.findData(self._app_settings.splat_radius)
if idx >= 0:
self.capture_splat_combo.setCurrentIndex(idx)
# 立即更新当前流的 splat 半径
if self._worker:
self._worker.set_splat(self._app_settings.splat_radius)
# 同步根目录显示
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()))
def _on_capture_d2c_method_changed(self):
method = self.capture_d2c_method_combo.currentData()
if self._worker:
self._worker.set_d2c_method(method)
self.capture_splat_combo.setEnabled(method == "forward")
def _on_capture_splat_combo_changed(self):
radius = self.capture_splat_combo.currentData()
if self._worker:
self._worker.set_splat(radius)
def _on_d2c_method_changed(self):
method = self.d2c_method_combo.currentData()
self.d2c_splat_combo.setEnabled(method == "forward")
@staticmethod
def _step_group(num: int, title: str) -> QGroupBox:
grp = QGroupBox(f"第 {num} 步 · {title}")
grp.setStyleSheet(
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; }}"
)
return grp
def _build_capture_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
layout.setContentsMargins(12, 8, 12, 8)
layout.setSpacing(6)
COMBO_CSS = "QComboBox { combobox-popup: 0; }"
# ── 第 1 步:刷新设备 ──
grp1 = self._step_group(1, "刷新设备")
g1 = QHBoxLayout(grp1)
g1.setContentsMargins(8, 4, 8, 6)
refresh_btn = QPushButton("刷新设备")
refresh_btn.setFixedHeight(_sz(28))
refresh_btn.clicked.connect(self._refresh_devices)
g1.addWidget(refresh_btn)
self.device_status = QLabel("未连接设备,请点击「刷新设备」")
self.device_status.setStyleSheet(f"color:#aaa; font-size:{_fs(12)}px;")
g1.addWidget(self.device_status)
g1.addStretch()
layout.addWidget(grp1)
# ── 第 2 步:流配置 ──
grp2 = self._step_group(2, "流配置")
g2 = QHBoxLayout(grp2)
g2.setContentsMargins(8, 4, 8, 6)
g2.setSpacing(10)
g2.addWidget(QLabel("RGB:"))
self.color_combo = QComboBox()
self.color_combo.setMinimumWidth(_sz(340))
self.color_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
self.color_combo.setMaxVisibleItems(15)
self.color_combo.setStyleSheet(COMBO_CSS)
g2.addWidget(self.color_combo)
g2.addSpacing(12)
g2.addWidget(QLabel("深度:"))
self.depth_combo = QComboBox()
self.depth_combo.setMinimumWidth(_sz(340))
self.depth_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
self.depth_combo.setMaxVisibleItems(15)
self.depth_combo.setStyleSheet(COMBO_CSS)
g2.addWidget(self.depth_combo)
g2.addSpacing(12)
g2.addWidget(QLabel("Preset:"))
self.preset_combo = QComboBox()
self.preset_combo.setMinimumWidth(_sz(220))
self.preset_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
self.preset_combo.setMaxVisibleItems(12)
self.preset_combo.setStyleSheet(COMBO_CSS)
self.preset_combo.setEnabled(False)
g2.addWidget(self.preset_combo)
g2.addStretch()
self.stream_btn = QPushButton("开启")
self.stream_btn.setCheckable(True)
self.stream_btn.setFixedSize(_sz(88), _sz(30))
self.stream_btn.setStyleSheet(
"QPushButton{ border-radius:5px; border:none; font-weight:bold; }"
"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)
g2.addWidget(self.stream_btn)
layout.addWidget(grp2)
# ── 第 3 步:保存目录 & 采集内容 ──
grp3 = self._step_group(3, "保存目录 && 采集内容")
vg3 = QVBoxLayout(grp3)
vg3.setContentsMargins(8, 4, 8, 8)
vg3.setSpacing(5)
# 行 1:根目录选择
row_base = QHBoxLayout()
row_base.setSpacing(6)
lbl_base = QLabel("根目录:")
lbl_base.setFixedWidth(_sz(80))
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("子目录:")
lbl_ses.setFixedWidth(_sz(80))
row_ses.addWidget(lbl_ses)
today = datetime.now()
self.session_edit = QLineEdit(f"{today.year}年{today.month}月{today.day}日")
self.session_edit.setMinimumWidth(_sz(140))
row_ses.addWidget(self.session_edit)
create_btn = QPushButton("创建 / 载入")
create_btn.setFixedHeight(_sz(26))
create_btn.clicked.connect(self._create_session)
row_ses.addWidget(create_btn)
self.session_status = QLabel("(未创建)")
self.session_status.setStyleSheet(f"color:gray; font-size:{_fs(12)}px;")
row_ses.addWidget(self.session_status)
row_ses.addStretch()
self.d2c_check = QCheckBox("D2C 预览")
self.d2c_check.setToolTip("勾选后预览使用 D2C 对齐;拍照时始终保存全部选中的数据")
self.d2c_check.setChecked(True)
self.d2c_check.stateChanged.connect(
lambda s: self._worker and self._worker.set_d2c(s == Qt.Checked))
row_ses.addWidget(self.d2c_check)
vg3.addLayout(row_ses)
# 行 3:采集内容复选框
row_cap = QHBoxLayout()
row_cap.setSpacing(_sz(8))
lbl_cap = QLabel("采集内容:")
lbl_cap.setFixedWidth(_sz(80))
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)
# 行 4:D2C 转换方式
row_d2c_method = QHBoxLayout()
row_d2c_method.setSpacing(8)
lbl_d2c_method = QLabel("D2C 方式:")
lbl_d2c_method.setFixedWidth(_sz(80))
row_d2c_method.addWidget(lbl_d2c_method)
self.capture_d2c_method_combo = QComboBox()
self.capture_d2c_method_combo.setMinimumWidth(_sz(220))
self.capture_d2c_method_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
self.capture_d2c_method_combo.addItem("逆向投影(SDK 同等)", "sdk")
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)
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)
row_d2c_method.addStretch()
vg3.addLayout(row_d2c_method)
layout.addWidget(grp3)
# ── 预览区 ──
preview = QFrame()
preview.setFrameShape(QFrame.StyledPanel)
preview.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
pv = QHBoxLayout(preview)
pv.setSpacing(6)
pv.setContentsMargins(6, 6, 6, 6)
for attr, title in (("color_lbl", "彩色图"), ("depth_lbl", "深度伪彩图")):
box = QVBoxLayout()
hdr = QLabel(title)
hdr.setAlignment(Qt.AlignCenter)
hdr.setStyleSheet(f"font-weight:bold; font-size:{_fs(13)}px;")
box.addWidget(hdr)
lbl = QLabel("等待画面…")
lbl.setAlignment(Qt.AlignCenter)
lbl.setMinimumSize(_sz(540), _sz(360))
lbl.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
lbl.setStyleSheet("background:#111; border:1px solid #555; color:#666;")
setattr(self, attr, lbl)
box.addWidget(lbl)
pv.addLayout(box)
layout.addWidget(preview, stretch=1)
# ── 第 4 步:拍照 ──
step4_row = QHBoxLayout()
step4_lbl = QLabel("第 4 步 · 拍照")
step4_lbl.setStyleSheet(
f"color:#8ec8ff; font-weight:bold; font-size:{_fs(12)}px;")
step4_row.addWidget(step4_lbl)
step4_row.addStretch()
hint = QLabel("快捷键 S")
hint.setStyleSheet(f"color:#666; font-size:{_fs(11)}px;")
step4_row.addWidget(hint)
self.capture_btn = QPushButton("拍照")
self.capture_btn.setFixedSize(_sz(160), _sz(44))
self.capture_btn.setEnabled(False)
f = QFont(); f.setPointSize(_fs(13)); f.setBold(True)
self.capture_btn.setFont(f)
self.capture_btn.setStyleSheet(
"QPushButton{ background:#2a6ebb; color:white; border-radius:8px; border:none; }"
"QPushButton:hover{ background:#3a80dd; }"
"QPushButton:pressed{ background:#1a559e; }"
"QPushButton:disabled{ background:#444; color:#777; }"
)
self.capture_btn.clicked.connect(self._on_capture)
step4_row.addWidget(self.capture_btn)
layout.addLayout(step4_row)
return w
def _build_d2c_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
layout.setContentsMargins(12, 8, 12, 8)
layout.setSpacing(6)
COMBO_CSS = "QComboBox { combobox-popup: 0; }"
# ── 第 1 步:刷新设备 ──
grp1 = self._step_group(1, "刷新设备")
g1 = QHBoxLayout(grp1)
g1.setContentsMargins(8, 4, 8, 6)
d2c_refresh_btn = QPushButton("刷新设备")
d2c_refresh_btn.setFixedHeight(_sz(28))
d2c_refresh_btn.clicked.connect(self._refresh_devices)
g1.addWidget(d2c_refresh_btn)
self.d2c_device_status = QLabel("未连接设备,请点击「刷新设备」")
self.d2c_device_status.setStyleSheet(f"color:#aaa; font-size:{_fs(12)}px;")
g1.addWidget(self.d2c_device_status)
g1.addStretch()
layout.addWidget(grp1)
# ── 第 2 步:相机参数 ──
grp2 = self._step_group(2, "相机参数(用于软件 D2C 计算)")
pg = QVBoxLayout(grp2)
pg.setContentsMargins(8, 4, 8, 6)
pg.setSpacing(6)
combo_row = QHBoxLayout()
combo_row.addWidget(QLabel("RGB:"))
self.d2c_color_combo = QComboBox()
self.d2c_color_combo.setMinimumWidth(_sz(340))
self.d2c_color_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
self.d2c_color_combo.setMaxVisibleItems(15)
self.d2c_color_combo.setStyleSheet(COMBO_CSS)
combo_row.addWidget(self.d2c_color_combo)
combo_row.addSpacing(12)
combo_row.addWidget(QLabel("深度:"))
self.d2c_depth_combo = QComboBox()
self.d2c_depth_combo.setMinimumWidth(_sz(340))
self.d2c_depth_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
self.d2c_depth_combo.setMaxVisibleItems(15)
self.d2c_depth_combo.setStyleSheet(COMBO_CSS)
combo_row.addWidget(self.d2c_depth_combo)
combo_row.addSpacing(12)
fetch_btn = QPushButton("从设备获取参数")
fetch_btn.setFixedHeight(_sz(28))
fetch_btn.clicked.connect(self._fetch_params_from_device)
combo_row.addWidget(fetch_btn)
combo_row.addStretch()
pg.addLayout(combo_row)
sep = QLabel("── 或直接指定 JSON 文件 ──")
sep.setAlignment(Qt.AlignCenter)
sep.setStyleSheet(f"color:#666; font-size:{_fs(11)}px;")
pg.addWidget(sep)
json_row = QHBoxLayout()
self.params_path_edit = QLineEdit()
self.params_path_edit.setPlaceholderText("留空则使用上方自动查找结果…")
json_row.addWidget(self.params_path_edit)
br2 = QPushButton("浏览…"); br2.setFixedWidth(_sz(70))
br2.clicked.connect(lambda: self._browse_json(self.params_path_edit))
json_row.addWidget(br2)
pg.addLayout(json_row)
self.params_selected_lbl = QLabel("(尚未选定参数文件)")
self.params_selected_lbl.setStyleSheet(f"color:#888; font-size:{_fs(12)}px;")
pg.addWidget(self.params_selected_lbl)
layout.addWidget(grp2)
# ── 第 3 步:深度图目录 ──
grp3 = self._step_group(3, "深度图目录")
g3 = QHBoxLayout(grp3)
g3.setContentsMargins(8, 4, 8, 6)
g3.setSpacing(8)
self.d2c_dir_edit = QLineEdit()
self.d2c_dir_edit.setPlaceholderText("选择包含 PNG 深度图的文件夹…")
g3.addWidget(self.d2c_dir_edit)
br1 = QPushButton("浏览…"); br1.setFixedWidth(_sz(70))
br1.clicked.connect(lambda: self._browse_dir(self.d2c_dir_edit))
g3.addWidget(br1)
layout.addWidget(grp3)
# ── 第 4 步:开始转换 ──
step4_row = QHBoxLayout()
step4_lbl = QLabel("第 4 步 · 开始转换")
step4_lbl.setStyleSheet(f"color:#8ec8ff; font-weight:bold; font-size:{_fs(12)}px;")
step4_row.addWidget(step4_lbl)
self.d2c_method_combo = QComboBox()
self.d2c_method_combo.setMinimumWidth(_sz(220))
self.d2c_method_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
self.d2c_method_combo.addItem("正向投影 + Splat 填充", "forward")
self.d2c_method_combo.addItem("逆向投影(SDK 同等)", "sdk")
self.d2c_method_combo.currentIndexChanged.connect(
self._on_d2c_method_changed)
step4_row.addWidget(self.d2c_method_combo)
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)
step4_row.addStretch()
self.run_d2c_btn = QPushButton("▶ 开始批量 D2C 转换")
self.run_d2c_btn.setFixedHeight(_sz(36))
self.run_d2c_btn.setStyleSheet(
"QPushButton{ background:#2a7a2a; color:white; border-radius:6px; border:none; }"
"QPushButton:hover{ background:#3a9a3a; }"
"QPushButton:disabled{ background:#444; color:#777; }"
)
self.run_d2c_btn.clicked.connect(self._run_batch_d2c)
step4_row.addWidget(self.run_d2c_btn)
layout.addLayout(step4_row)
# ── 日志 ──
layout.addWidget(QLabel("输出日志:"))
self.d2c_log = QTextEdit()
self.d2c_log.setReadOnly(True)
self.d2c_log.setStyleSheet(
f"font-family: Consolas,'Courier New',monospace; font-size:{_fs(12)}px;")
layout.addWidget(self.d2c_log, stretch=1)
return w
# ─────────────────────────────────
# Device Refresh
# ─────────────────────────────────
def _refresh_devices(self):
"""连接相机,枚举并填充所有分辨率下拉框(Tab 1 + Tab 2)。"""
self.device_status.setText("正在扫描设备…")
QApplication.processEvents()
self.color_combo.clear()
self.depth_combo.clear()
self.preset_combo.clear()
self.d2c_color_combo.clear()
self.d2c_depth_combo.clear()
self._color_profiles_data.clear()
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)
# 枚举 preset
try:
preset_list = p.get_device().get_available_preset_list()
current_preset = p.get_device().get_current_preset_name()
if preset_list and len(preset_list) > 0:
self.preset_combo.addItem("── 不切换 ──", None)
for i in range(len(preset_list)):
name = preset_list[i]
self.preset_combo.addItem(name, name)
# 选中当前 preset
idx = self.preset_combo.findData(current_preset)
if idx >= 0:
self.preset_combo.setCurrentIndex(idx)
self.preset_combo.setEnabled(True)
else:
self.preset_combo.addItem("(设备不支持)", None)
self.preset_combo.setEnabled(False)
except Exception as exc:
self.preset_combo.addItem(f"(不支持: {exc})", None)
self.preset_combo.setEnabled(False)
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(f"color:#4aaa4a; font-size:{_fs(12)}px;")
self.d2c_device_status.setText(msg)
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(f"color:#cc6666; font-size:{_fs(12)}px;")
self.d2c_device_status.setText(msg)
self.d2c_device_status.setStyleSheet(f"color:#cc6666; font-size:{_fs(12)}px;")
# ─────────────────────────────────
# 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 已停止
preset = self.preset_combo.currentData() # None 表示不切换
self._worker = CameraWorker(color_cfg, depth_cfg, preset=preset)
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())
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("设备已断开连接,请重新插入后点击「刷新设备」")
self.device_status.setStyleSheet(f"color:#cc8844; font-size:{_fs(12)}px;")
# ─────────────────────────────────
# Preview
# ─────────────────────────────────
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))
# ─────────────────────────────────
# Session / Capture
# ─────────────────────────────────
def _create_session(self):
name = self.session_edit.text().strip()
if not name:
QMessageBox.warning(self, "提示", "请输入子目录名称。")
return
base = Path(self.base_dir_edit.text().strip() or ".")
session_dir = base / name
session_dir.mkdir(parents=True, exist_ok=True)
self._session_dir = session_dir
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(f"color:#2a9a2a; font-size:{_fs(12)}px;")
def _on_params_ready(self, params: dict):
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"]
self._params_filename = f"SN{sn}_RGB{cw}x{ch}@{cfps}_D{dw}x{dh}@{dfps}.json"
if self._session_dir and self._session_dir.exists():
self._save_params_to_session(params)
def _save_params_to_session(self, params: dict):
try:
(self._session_dir / self._params_filename).write_text(
json.dumps(params, indent=2, ensure_ascii=False), encoding="utf-8")
except Exception:
pass
def _on_capture(self):
if self._session_dir is None:
self._create_session()
if self._session_dir is None:
return
ts = datetime.now().strftime("%Y%m%d%H%M%S")
self.capture_btn.setEnabled(False)
self.capture_btn.setText("拍摄中…")
capture_opts = {k: cb.isChecked() for k, cb in self._capture_checks.items()}
capture_opts["d2c_method"] = self.capture_d2c_method_combo.currentData()
capture_opts["splat_radius"] = self.capture_splat_combo.currentData()
self._worker.trigger_capture(str(self._session_dir / ts), capture_opts)
def _on_capture_done(self, save_dir: str):
self.capture_btn.setEnabled(True)
self.capture_btn.setText("拍照")
self._toast.show_msg("拍摄完成!", 1000)
# ─────────────────────────────────
# Batch D2C Tab
# ─────────────────────────────────
def _fetch_params_from_device(self):
"""根据当前选中的 RGB + 深度配置,临时打开设备流并提取相机参数。"""
ci = self.d2c_color_combo.currentIndex()
di = self.d2c_depth_combo.currentIndex()
if ci < 0 or di < 0:
self.params_selected_lbl.setText("请先刷新设备并选择分辨率")
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(f"color:#cc6666; font-size:{_fs(12)}px;")
return
self.params_selected_lbl.setText("正在从设备获取参数…")
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)
self._fetch_worker.start()
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(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']}"
)
# 在日志区打印完整参数
c = params["color"]
d = params["depth"]
ci = c["intrinsic"]
di = d["intrinsic"]
cd = c["distortion"]
dd = d["distortion"]
ext = params["extrinsic"]
R = np.array(ext["rot"]).reshape(3, 3)
t = np.array(ext["transform"])
sn = params.get("device", {}).get("serial_number", "unknown")
self._append_d2c_log("--- Camera Parameters ---")
self._append_d2c_log(f" Device: SN {sn}")
self._append_d2c_log(f" RGB : {c['width']}x{c['height']} @ {c['fps']}fps {c['format']}")
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}")
self._append_d2c_log(f" Depth : {d['width']}x{d['height']} @ {d['fps']}fps {d['format']}")
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}")
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)")
self._fetch_worker = None
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
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)
self.params_selected_lbl.setText(f"✓ {f}")
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()
if not depth_dir:
QMessageBox.warning(self, "错误", "请先选择深度图目录。")
return
params_file = self._resolve_params_file()
if params_file is None:
QMessageBox.warning(
self, "错误",
"找不到相机参数文件。\n"
"请先开启摄像头流(自动生成)或手动选择 JSON 文件。")
return
with open(params_file, encoding="utf-8") as fh:
params = json.load(fh)
self.d2c_log.clear()
self.d2c_log.append(f"[参数文件] {params_file}")
self.d2c_log.append(f"[深度图目录] {depth_dir}\n")
self.run_d2c_btn.setEnabled(False)
method = self.d2c_method_combo.currentData()
splat_r = self.d2c_splat_combo.currentData() if method == "forward" else 1
self._batch_worker = BatchD2CWorker(params, depth_dir,
method=method, 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()
def _on_batch_done(self):
self.run_d2c_btn.setEnabled(True)
self.d2c_log.append("\n[ 转换完成 ]")
def _append_d2c_log(self, msg: str):
self.d2c_log.append(msg)
# ─────────────────────────────────
# 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()
# ─────────────────────────────────
# Close
# ─────────────────────────────────
def closeEvent(self, event):
self._stop_stream()
super().closeEvent(event)
# ─────────────────────────────────────────────────────────────────
# Entry Point
# ─────────────────────────────────────────────────────────────────
def main():
app = QApplication(sys.argv)
app.setStyle("Fusion")
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)
from PyQt5.QtGui import QPalette, QColor
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)
win = MainWindow()
win.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()