提交
This commit is contained in:
+2
-1
@@ -2,4 +2,5 @@ test/
|
|||||||
Log/
|
Log/
|
||||||
.vscode/
|
.vscode/
|
||||||
camera_params.json
|
camera_params.json
|
||||||
**.pyc**
|
**.pyc**
|
||||||
|
data/
|
||||||
@@ -20,6 +20,22 @@
|
|||||||
|
|
||||||
多个深度点映射到同一彩色像素时,保留最近点(小 Z 值覆盖大 Z 值)。
|
多个深度点映射到同一彩色像素时,保留最近点(小 Z 值覆盖大 Z 值)。
|
||||||
|
|
||||||
|
### 前向映射的间隙问题与 3×3 Splat 修复
|
||||||
|
|
||||||
|
前向映射(forward scatter)存在固有缺陷:每个深度像素只写入 color 平面的**一个像素**。由于深度相机与彩色相机的视角差异,经外参变换 + 透视投影后,相邻深度像素在 color 平面上的落点会出现跳跃,中间留下空洞,输出图像呈现**离散小方块**而非连续深度面。
|
||||||
|
|
||||||
|
**修复方案:3×3 邻域扩散(splat)**
|
||||||
|
|
||||||
|
对每个投影后的深度像素,同时写入以其为中心的 3×3 邻域,而不是单个像素。遍历顺序仍保持**远→近**,因此当多个 splat 重叠时,Z 值较小(较近)的深度最后写入并覆盖,遮挡关系依然正确:
|
||||||
|
|
||||||
|
```
|
||||||
|
for dv in -1, 0, +1:
|
||||||
|
for du in -1, 0, +1:
|
||||||
|
aligned[v + dv, u + du] = z # 远→近顺序,近处覆盖远处
|
||||||
|
```
|
||||||
|
|
||||||
|
如果局部仍有残留空洞(如深度图有效范围边缘),可将 3×3 扩大为 5×5(`range(-2, 3)`)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 环境依赖
|
## 环境依赖
|
||||||
@@ -124,6 +140,18 @@ python d2c_batch.py --convert
|
|||||||
|
|
||||||
使用已有的 `camera_params.json`,直接输入深度图目录开始转换。适合相机参数已获取、需要反复处理不同数据集的场景。
|
使用已有的 `camera_params.json`,直接输入深度图目录开始转换。适合相机参数已获取、需要反复处理不同数据集的场景。
|
||||||
|
|
||||||
|
### 关闭 3×3 Splat 填充
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python d2c_batch.py --no-splat
|
||||||
|
```
|
||||||
|
|
||||||
|
默认开启 splat(前向映射后用 3×3 邻域扩散填补间隙)。加 `--no-splat` 可关闭,每个深度像素仅写入 color 平面的单个像素,适合对原始投影结果做对比分析。可与其他参数组合使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python d2c_batch.py --convert --no-splat
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 交互示例
|
## 交互示例
|
||||||
|
|||||||
+33
-6
@@ -24,6 +24,12 @@ import numpy as np
|
|||||||
import cv2
|
import cv2
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image as _PIL_Image
|
||||||
|
_HAS_PIL = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_PIL = False
|
||||||
|
|
||||||
PARAMS_FILE = "camera_params.json"
|
PARAMS_FILE = "camera_params.json"
|
||||||
|
|
||||||
|
|
||||||
@@ -219,7 +225,7 @@ def _undistort_points(u, v, intr, dist):
|
|||||||
return x_u, y_u
|
return x_u, y_u
|
||||||
|
|
||||||
|
|
||||||
def software_d2c(depth_img, params):
|
def software_d2c(depth_img, params, splat=True):
|
||||||
"""
|
"""
|
||||||
Transform a uint16 depth image (depth camera space) to an aligned uint16
|
Transform a uint16 depth image (depth camera space) to an aligned uint16
|
||||||
depth image in color camera space.
|
depth image in color camera space.
|
||||||
@@ -294,7 +300,19 @@ def software_d2c(depth_img, params):
|
|||||||
z_vals = z_vals[order]
|
z_vals = z_vals[order]
|
||||||
|
|
||||||
aligned = np.zeros((color_h, color_w), dtype=np.uint16)
|
aligned = np.zeros((color_h, color_w), dtype=np.uint16)
|
||||||
aligned[v_vals, u_vals] = z_vals
|
|
||||||
|
if splat:
|
||||||
|
# Splat each depth pixel to a 3×3 neighbourhood to fill sub-pixel gaps
|
||||||
|
# that arise from forward projection. Far→near order is preserved so
|
||||||
|
# the nearest surface always wins when splats overlap.
|
||||||
|
for dv in range(-1, 2):
|
||||||
|
for du in range(-1, 2):
|
||||||
|
us = np.clip(u_vals + du, 0, color_w - 1)
|
||||||
|
vs = np.clip(v_vals + dv, 0, color_h - 1)
|
||||||
|
aligned[vs, us] = z_vals
|
||||||
|
else:
|
||||||
|
aligned[v_vals, u_vals] = z_vals
|
||||||
|
|
||||||
return aligned
|
return aligned
|
||||||
|
|
||||||
|
|
||||||
@@ -315,7 +333,7 @@ def depth_to_colormap(aligned, min_depth_mm=200, max_depth_mm=5000, depth_scale=
|
|||||||
return colormap
|
return colormap
|
||||||
|
|
||||||
|
|
||||||
def batch_convert(params, depth_dir_str):
|
def batch_convert(params, depth_dir_str, splat=True):
|
||||||
depth_dir = Path(depth_dir_str.strip().strip('"\''))
|
depth_dir = Path(depth_dir_str.strip().strip('"\''))
|
||||||
if not depth_dir.exists():
|
if not depth_dir.exists():
|
||||||
print(f" Directory not found: {depth_dir}")
|
print(f" Directory not found: {depth_dir}")
|
||||||
@@ -343,12 +361,20 @@ def batch_convert(params, depth_dir_str):
|
|||||||
print(f" Files : {len(png_files)}")
|
print(f" Files : {len(png_files)}")
|
||||||
print(f" Depth scale : {depth_scale:.6f} mm/unit")
|
print(f" Depth scale : {depth_scale:.6f} mm/unit")
|
||||||
print(f" Output size : {color_w}x{color_h} (color resolution)")
|
print(f" Output size : {color_w}x{color_h} (color resolution)")
|
||||||
|
print(f" Splat (3×3) : {'on' if splat else 'off'}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
ok = 0
|
ok = 0
|
||||||
for i, fpath in enumerate(png_files, 1):
|
for i, fpath in enumerate(png_files, 1):
|
||||||
depth_img = cv2.imread(str(fpath), cv2.IMREAD_UNCHANGED)
|
depth_img = None
|
||||||
|
if _HAS_PIL:
|
||||||
|
try:
|
||||||
|
depth_img = np.array(_PIL_Image.open(str(fpath)))
|
||||||
|
except Exception:
|
||||||
|
depth_img = None
|
||||||
|
if depth_img is None:
|
||||||
|
depth_img = cv2.imread(str(fpath), cv2.IMREAD_UNCHANGED)
|
||||||
if depth_img is None:
|
if depth_img is None:
|
||||||
print(f" [{i:>4}/{len(png_files)}] SKIP (unreadable): {fpath.name}")
|
print(f" [{i:>4}/{len(png_files)}] SKIP (unreadable): {fpath.name}")
|
||||||
continue
|
continue
|
||||||
@@ -356,7 +382,7 @@ def batch_convert(params, depth_dir_str):
|
|||||||
depth_img = depth_img[:, :, 0]
|
depth_img = depth_img[:, :, 0]
|
||||||
depth_img = depth_img.astype(np.uint16)
|
depth_img = depth_img.astype(np.uint16)
|
||||||
|
|
||||||
aligned = software_d2c(depth_img, params)
|
aligned = software_d2c(depth_img, params, splat=splat)
|
||||||
|
|
||||||
# Save aligned uint16 depth
|
# Save aligned uint16 depth
|
||||||
cv2.imwrite(str(out_dir / fpath.name), aligned)
|
cv2.imwrite(str(out_dir / fpath.name), aligned)
|
||||||
@@ -405,6 +431,7 @@ def print_params_summary(params):
|
|||||||
def main():
|
def main():
|
||||||
only_params = "--params" in sys.argv
|
only_params = "--params" in sys.argv
|
||||||
only_convert = "--convert" in sys.argv
|
only_convert = "--convert" in sys.argv
|
||||||
|
no_splat = "--no-splat" in sys.argv
|
||||||
|
|
||||||
params = None
|
params = None
|
||||||
|
|
||||||
@@ -448,7 +475,7 @@ def main():
|
|||||||
print("No directory entered. Exiting.")
|
print("No directory entered. Exiting.")
|
||||||
return
|
return
|
||||||
|
|
||||||
batch_convert(params, depth_dir)
|
batch_convert(params, depth_dir, splat=not no_splat)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import sys
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
import contextlib
|
import contextlib
|
||||||
import io
|
import io
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -57,6 +59,26 @@ def ndarray_to_pixmap(img_bgr: np.ndarray, max_w: int, max_h: int) -> QPixmap:
|
|||||||
return QPixmap.fromImage(qimg)
|
return QPixmap.fromImage(qimg)
|
||||||
|
|
||||||
|
|
||||||
|
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_ply = os.path.join(tmpdir, "pc.ply")
|
||||||
|
save_func(tmp_ply, pc_frame)
|
||||||
|
shutil.move(tmp_ply, target_path)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
def depth_to_vis(depth_uint16: np.ndarray, scale: float) -> np.ndarray:
|
def depth_to_vis(depth_uint16: np.ndarray, scale: float) -> np.ndarray:
|
||||||
"""uint16 深度图 → JET 伪彩 BGR 图(无效像素为黑色)。"""
|
"""uint16 深度图 → JET 伪彩 BGR 图(无效像素为黑色)。"""
|
||||||
depth_mm = depth_uint16.astype(np.float32) * scale
|
depth_mm = depth_uint16.astype(np.float32) * scale
|
||||||
@@ -178,25 +200,31 @@ class ProfileDialog(QDialog):
|
|||||||
# Camera Worker Thread
|
# Camera Worker Thread
|
||||||
# ─────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_DISCONNECT_TIMEOUTS = 6 # 6 × 500 ms = 3 s 无帧视为断开
|
||||||
|
|
||||||
class CameraWorker(QThread):
|
class CameraWorker(QThread):
|
||||||
preview_ready = pyqtSignal(object, object) # color_bgr, depth_vis
|
preview_ready = pyqtSignal(object, object) # color_bgr, depth_vis
|
||||||
capture_done = pyqtSignal(str) # save_dir
|
capture_done = pyqtSignal(str) # save_dir
|
||||||
params_ready = pyqtSignal(dict)
|
params_ready = pyqtSignal(dict)
|
||||||
log_msg = pyqtSignal(str)
|
log_msg = pyqtSignal(str)
|
||||||
|
disconnected = pyqtSignal()
|
||||||
|
|
||||||
def __init__(self, pipeline, color_profile, depth_profile):
|
def __init__(self, pipeline, color_profile, depth_profile):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._pipeline = pipeline
|
self._pipeline = pipeline
|
||||||
self._color_profile = color_profile
|
self._color_profile = color_profile
|
||||||
self._depth_profile = depth_profile
|
self._depth_profile = depth_profile
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._running = True
|
self._running = True
|
||||||
self._d2c = False
|
self._d2c = False
|
||||||
self._do_capture = False
|
self._do_capture = False
|
||||||
self._capture_dir = ""
|
self._capture_dir = ""
|
||||||
self._camera_params = None
|
self._camera_params = None
|
||||||
self._has_dual_ir = False
|
self._has_dual_ir = False
|
||||||
self._last_emit_ts = 0.0
|
self._last_emit_ts = 0.0
|
||||||
|
self._serial_number = "unknown"
|
||||||
|
self._consec_timeouts = 0
|
||||||
|
self._disconnect_emitted = False
|
||||||
|
|
||||||
# ── Public controls ──
|
# ── Public controls ──
|
||||||
|
|
||||||
@@ -231,9 +259,13 @@ class CameraWorker(QThread):
|
|||||||
config.enable_stream(self._color_profile)
|
config.enable_stream(self._color_profile)
|
||||||
config.enable_stream(self._depth_profile)
|
config.enable_stream(self._depth_profile)
|
||||||
|
|
||||||
# 探测 IR 传感器类型
|
# 获取设备 SN + 探测 IR 传感器类型
|
||||||
try:
|
try:
|
||||||
device = self._pipeline.get_device()
|
device = self._pipeline.get_device()
|
||||||
|
try:
|
||||||
|
self._serial_number = device.get_device_info().get_serial_number()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
sensor_list = device.get_sensor_list()
|
sensor_list = device.get_sensor_list()
|
||||||
for i in range(len(sensor_list)):
|
for i in range(len(sensor_list)):
|
||||||
st = sensor_list[i].get_type()
|
st = sensor_list[i].get_type()
|
||||||
@@ -260,7 +292,14 @@ class CameraWorker(QThread):
|
|||||||
try:
|
try:
|
||||||
frames = self._pipeline.wait_for_frames(500)
|
frames = self._pipeline.wait_for_frames(500)
|
||||||
if frames is None:
|
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
|
continue
|
||||||
|
self._consec_timeouts = 0
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
d2c = self._d2c
|
d2c = self._d2c
|
||||||
@@ -269,13 +308,15 @@ class CameraWorker(QThread):
|
|||||||
if do_cap:
|
if do_cap:
|
||||||
self._do_capture = False
|
self._do_capture = False
|
||||||
|
|
||||||
# 首帧提取相机参数
|
# 首帧提取相机参数(含 SN)
|
||||||
if self._camera_params is None:
|
if self._camera_params is None:
|
||||||
cf = frames.get_color_frame()
|
cf = frames.get_color_frame()
|
||||||
df = frames.get_depth_frame()
|
df = frames.get_depth_frame()
|
||||||
if cf and df:
|
if cf and df:
|
||||||
try:
|
try:
|
||||||
self._camera_params = self._extract_params(cf, df)
|
params = self._extract_params(cf, df)
|
||||||
|
params['device'] = {'serial_number': self._serial_number}
|
||||||
|
self._camera_params = params
|
||||||
self.params_ready.emit(self._camera_params)
|
self.params_ready.emit(self._camera_params)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -322,6 +363,12 @@ class CameraWorker(QThread):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if self._running:
|
if self._running:
|
||||||
self.log_msg.emit(f"相机错误:{exc}")
|
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)
|
time.sleep(0.1)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -340,11 +387,13 @@ class CameraWorker(QThread):
|
|||||||
cf = frames.get_color_frame()
|
cf = frames.get_color_frame()
|
||||||
df = frames.get_depth_frame()
|
df = frames.get_depth_frame()
|
||||||
|
|
||||||
|
base = Path(save_dir)
|
||||||
|
|
||||||
# ① 彩色图
|
# ① 彩色图
|
||||||
if cf:
|
if cf:
|
||||||
bgr = frame_to_bgr_image(cf)
|
bgr = frame_to_bgr_image(cf)
|
||||||
if bgr is not None:
|
if bgr is not None:
|
||||||
cv2.imwrite(str(Path(save_dir) / "color.png"), bgr)
|
imwrite_utf8(str(base / "color.png"), bgr)
|
||||||
|
|
||||||
# ② 红外图
|
# ② 红外图
|
||||||
if self._has_dual_ir:
|
if self._has_dual_ir:
|
||||||
@@ -352,21 +401,21 @@ class CameraWorker(QThread):
|
|||||||
rir = frames.get_frame(OBFrameType.RIGHT_IR_FRAME)
|
rir = frames.get_frame(OBFrameType.RIGHT_IR_FRAME)
|
||||||
img = process_ir_frame(lir)
|
img = process_ir_frame(lir)
|
||||||
if img is not None:
|
if img is not None:
|
||||||
cv2.imwrite(str(Path(save_dir) / "ir_left.png"), img)
|
imwrite_utf8(str(base / "ir_left.png"), img)
|
||||||
img = process_ir_frame(rir)
|
img = process_ir_frame(rir)
|
||||||
if img is not None:
|
if img is not None:
|
||||||
cv2.imwrite(str(Path(save_dir) / "ir_right.png"), img)
|
imwrite_utf8(str(base / "ir_right.png"), img)
|
||||||
else:
|
else:
|
||||||
ir = frames.get_frame(OBFrameType.IR_FRAME)
|
ir = frames.get_frame(OBFrameType.IR_FRAME)
|
||||||
img = process_ir_frame(ir)
|
img = process_ir_frame(ir)
|
||||||
if img is not None:
|
if img is not None:
|
||||||
cv2.imwrite(str(Path(save_dir) / "ir.png"), img)
|
imwrite_utf8(str(base / "ir.png"), img)
|
||||||
|
|
||||||
# ③ 原始深度图
|
# ③ 原始深度图
|
||||||
if df:
|
if df:
|
||||||
raw = np.frombuffer(df.get_data(), dtype=np.uint16).reshape(
|
raw = np.frombuffer(df.get_data(), dtype=np.uint16).reshape(
|
||||||
df.get_height(), df.get_width())
|
df.get_height(), df.get_width())
|
||||||
cv2.imwrite(str(Path(save_dir) / "depth.png"), raw)
|
imwrite_utf8(str(base / "depth.png"), raw)
|
||||||
|
|
||||||
# ④ D2C 深度图 + 伪彩 + 点云
|
# ④ D2C 深度图 + 伪彩 + 点云
|
||||||
aligned = align_filter.process(frames)
|
aligned = align_filter.process(frames)
|
||||||
@@ -376,15 +425,16 @@ class CameraWorker(QThread):
|
|||||||
if adf:
|
if adf:
|
||||||
ad = np.frombuffer(adf.get_data(), dtype=np.uint16).reshape(
|
ad = np.frombuffer(adf.get_data(), dtype=np.uint16).reshape(
|
||||||
adf.get_height(), adf.get_width())
|
adf.get_height(), adf.get_width())
|
||||||
cv2.imwrite(str(Path(save_dir) / "depth_d2c.png"), ad)
|
imwrite_utf8(str(base / "depth_d2c.png"), ad)
|
||||||
vis = depth_to_vis(ad, adf.get_depth_scale())
|
vis = depth_to_vis(ad, adf.get_depth_scale())
|
||||||
cv2.imwrite(str(Path(save_dir) / "depth_d2c_vis.png"), vis)
|
imwrite_utf8(str(base / "depth_d2c_vis.png"), vis)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
point_cloud_filter.set_create_point_format(OBFormat.POINT)
|
point_cloud_filter.set_create_point_format(OBFormat.POINT)
|
||||||
pc = point_cloud_filter.process(aligned)
|
pc = point_cloud_filter.process(aligned)
|
||||||
if pc:
|
if pc:
|
||||||
save_point_cloud_to_ply(str(Path(save_dir) / "point_cloud.ply"), pc)
|
save_ply_utf8(str(base / "point_cloud.ply"), pc,
|
||||||
|
save_point_cloud_to_ply)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.log_msg.emit(f"点云警告:{exc}")
|
self.log_msg.emit(f"点云警告:{exc}")
|
||||||
|
|
||||||
@@ -392,15 +442,11 @@ class CameraWorker(QThread):
|
|||||||
point_cloud_filter.set_create_point_format(OBFormat.RGB_POINT)
|
point_cloud_filter.set_create_point_format(OBFormat.RGB_POINT)
|
||||||
pc = point_cloud_filter.process(aligned)
|
pc = point_cloud_filter.process(aligned)
|
||||||
if pc:
|
if pc:
|
||||||
save_point_cloud_to_ply(str(Path(save_dir) / "point_cloud_color.ply"), pc)
|
save_ply_utf8(str(base / "point_cloud_color.ply"), pc,
|
||||||
|
save_point_cloud_to_ply)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.log_msg.emit(f"彩色点云警告:{exc}")
|
self.log_msg.emit(f"彩色点云警告:{exc}")
|
||||||
|
|
||||||
# ⑤ 相机参数
|
|
||||||
if self._camera_params:
|
|
||||||
with open(str(Path(save_dir) / "camera_params.json"), "w") as fh:
|
|
||||||
json.dump(self._camera_params, fh, indent=2)
|
|
||||||
|
|
||||||
self.capture_done.emit(save_dir)
|
self.capture_done.emit(save_dir)
|
||||||
|
|
||||||
# ── Static helpers ──
|
# ── Static helpers ──
|
||||||
@@ -455,10 +501,11 @@ class BatchD2CWorker(QThread):
|
|||||||
log = pyqtSignal(str)
|
log = pyqtSignal(str)
|
||||||
done = pyqtSignal()
|
done = pyqtSignal()
|
||||||
|
|
||||||
def __init__(self, params: dict, depth_dir: str):
|
def __init__(self, params: dict, depth_dir: str, splat: bool = True):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._params = params
|
self._params = params
|
||||||
self._depth_dir = depth_dir
|
self._depth_dir = depth_dir
|
||||||
|
self._splat = splat
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
class _LineCapture(io.StringIO):
|
class _LineCapture(io.StringIO):
|
||||||
@@ -473,7 +520,7 @@ class BatchD2CWorker(QThread):
|
|||||||
cap = _LineCapture(self.log)
|
cap = _LineCapture(self.log)
|
||||||
with contextlib.redirect_stdout(cap):
|
with contextlib.redirect_stdout(cap):
|
||||||
try:
|
try:
|
||||||
batch_convert(self._params, self._depth_dir)
|
batch_convert(self._params, self._depth_dir, splat=self._splat)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.log.emit(f"Error: {exc}")
|
self.log.emit(f"Error: {exc}")
|
||||||
self.done.emit()
|
self.done.emit()
|
||||||
@@ -484,6 +531,8 @@ class BatchD2CWorker(QThread):
|
|||||||
# ─────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
|
back_to_profiles = pyqtSignal()
|
||||||
|
|
||||||
def __init__(self, pipeline, color_profile, depth_profile):
|
def __init__(self, pipeline, color_profile, depth_profile):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setWindowTitle("Orbbec 数据采集 & D2C")
|
self.setWindowTitle("Orbbec 数据采集 & D2C")
|
||||||
@@ -491,12 +540,27 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self._session_dir: Path | None = None
|
self._session_dir: Path | None = None
|
||||||
self._batch_worker: BatchD2CWorker | None = None
|
self._batch_worker: BatchD2CWorker | None = None
|
||||||
|
self._params_filename = "camera_params.json" # 由 SN+分辨率确定后更新
|
||||||
|
self._restart_mode = False # 正在重启,不触发 app.quit
|
||||||
|
|
||||||
|
# 保存选中的流参数,供断线重连时匹配
|
||||||
|
self._saved_color_info = {
|
||||||
|
"width": color_profile.get_width(),
|
||||||
|
"height": color_profile.get_height(),
|
||||||
|
"fps": color_profile.get_fps(),
|
||||||
|
}
|
||||||
|
self._saved_depth_info = {
|
||||||
|
"width": depth_profile.get_width(),
|
||||||
|
"height": depth_profile.get_height(),
|
||||||
|
"fps": depth_profile.get_fps(),
|
||||||
|
}
|
||||||
|
|
||||||
self._worker = CameraWorker(pipeline, color_profile, depth_profile)
|
self._worker = CameraWorker(pipeline, color_profile, depth_profile)
|
||||||
self._worker.preview_ready.connect(self._update_preview)
|
self._worker.preview_ready.connect(self._update_preview)
|
||||||
self._worker.capture_done.connect(self._on_capture_done)
|
self._worker.capture_done.connect(self._on_capture_done)
|
||||||
self._worker.params_ready.connect(self._on_params_ready)
|
self._worker.params_ready.connect(self._on_params_ready)
|
||||||
self._worker.log_msg.connect(self._append_log)
|
self._worker.log_msg.connect(self._append_log)
|
||||||
|
self._worker.disconnected.connect(self._on_camera_disconnected)
|
||||||
|
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._worker.start()
|
self._worker.start()
|
||||||
@@ -630,6 +694,17 @@ class MainWindow(QMainWindow):
|
|||||||
row2.addWidget(br2)
|
row2.addWidget(br2)
|
||||||
layout.addLayout(row2)
|
layout.addLayout(row2)
|
||||||
|
|
||||||
|
# Splat 选项
|
||||||
|
opt_row = QHBoxLayout()
|
||||||
|
self.splat_check = QCheckBox("Splat 3×3 填充(减少正向投影空洞,默认开启)")
|
||||||
|
self.splat_check.setChecked(True)
|
||||||
|
self.splat_check.setToolTip(
|
||||||
|
"对应 d2c_batch.py 的 splat 参数:将每个深度像素扩散到 3×3 邻域,"
|
||||||
|
"有效减少软件 D2C 正向投影产生的空洞。")
|
||||||
|
opt_row.addWidget(self.splat_check)
|
||||||
|
opt_row.addStretch()
|
||||||
|
layout.addLayout(opt_row)
|
||||||
|
|
||||||
# 运行按钮
|
# 运行按钮
|
||||||
run_row = QHBoxLayout()
|
run_row = QHBoxLayout()
|
||||||
run_row.addStretch()
|
run_row.addStretch()
|
||||||
@@ -680,14 +755,23 @@ class MainWindow(QMainWindow):
|
|||||||
self.session_status.setStyleSheet("color: #2a9a2a; font-size: 12px;")
|
self.session_status.setStyleSheet("color: #2a9a2a; font-size: 12px;")
|
||||||
|
|
||||||
def _on_params_ready(self, params: dict):
|
def _on_params_ready(self, params: dict):
|
||||||
|
# 根据 SN + 分辨率/帧率构建参数文件名
|
||||||
|
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():
|
if self._session_dir and self._session_dir.exists():
|
||||||
self._save_params_to_session(params)
|
self._save_params_to_session(params)
|
||||||
|
|
||||||
def _save_params_to_session(self, params: dict):
|
def _save_params_to_session(self, params: dict):
|
||||||
try:
|
try:
|
||||||
p = self._session_dir / "camera_params.json"
|
p = self._session_dir / self._params_filename
|
||||||
with open(p, "w") as fh:
|
p.write_text(json.dumps(params, indent=2, ensure_ascii=False),
|
||||||
json.dump(params, fh, indent=2)
|
encoding="utf-8")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -734,19 +818,26 @@ class MainWindow(QMainWindow):
|
|||||||
edit.setText(f)
|
edit.setText(f)
|
||||||
|
|
||||||
def _resolve_params_file(self) -> str | None:
|
def _resolve_params_file(self) -> str | None:
|
||||||
"""返回有效的 camera_params.json 路径,找不到返回 None。"""
|
"""返回有效的相机参数 JSON 路径,找不到返回 None。"""
|
||||||
explicit = self.params_path_edit.text().strip()
|
explicit = self.params_path_edit.text().strip()
|
||||||
if explicit and Path(explicit).is_file():
|
if explicit and Path(explicit).is_file():
|
||||||
return explicit
|
return explicit
|
||||||
|
|
||||||
# 默认路径
|
# 优先在 session 目录查找 SN 命名的文件
|
||||||
for candidate in [
|
if self._session_dir and self._session_dir.exists():
|
||||||
Path(PARAMS_FILE),
|
sn_files = sorted(self._session_dir.glob("SN*.json"))
|
||||||
*(([self._session_dir / "camera_params.json"]
|
if sn_files:
|
||||||
if self._session_dir else [])),
|
return str(sn_files[0])
|
||||||
]:
|
fallback = self._session_dir / "camera_params.json"
|
||||||
if candidate.is_file():
|
if fallback.is_file():
|
||||||
return str(candidate)
|
return str(fallback)
|
||||||
|
|
||||||
|
# 工作目录里的 SN 命名文件或旧版 camera_params.json
|
||||||
|
sn_files = sorted(Path(".").glob("SN*.json"))
|
||||||
|
if sn_files:
|
||||||
|
return str(sn_files[0])
|
||||||
|
if Path(PARAMS_FILE).is_file():
|
||||||
|
return PARAMS_FILE
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _run_batch_d2c(self):
|
def _run_batch_d2c(self):
|
||||||
@@ -771,7 +862,8 @@ class MainWindow(QMainWindow):
|
|||||||
self.d2c_log.append(f"[深度图目录] {depth_dir}\n")
|
self.d2c_log.append(f"[深度图目录] {depth_dir}\n")
|
||||||
self.run_d2c_btn.setEnabled(False)
|
self.run_d2c_btn.setEnabled(False)
|
||||||
|
|
||||||
self._batch_worker = BatchD2CWorker(params, depth_dir)
|
splat = self.splat_check.isChecked()
|
||||||
|
self._batch_worker = BatchD2CWorker(params, depth_dir, splat=splat)
|
||||||
self._batch_worker.log.connect(self.d2c_log.append)
|
self._batch_worker.log.connect(self.d2c_log.append)
|
||||||
self._batch_worker.done.connect(self._on_batch_done)
|
self._batch_worker.done.connect(self._on_batch_done)
|
||||||
self._batch_worker.start()
|
self._batch_worker.start()
|
||||||
@@ -780,10 +872,94 @@ class MainWindow(QMainWindow):
|
|||||||
self.run_d2c_btn.setEnabled(True)
|
self.run_d2c_btn.setEnabled(True)
|
||||||
self.d2c_log.append("\n[ 转换完成 ]")
|
self.d2c_log.append("\n[ 转换完成 ]")
|
||||||
|
|
||||||
|
# ── Camera disconnect / reconnect ──
|
||||||
|
|
||||||
|
def _on_camera_disconnected(self):
|
||||||
|
self.capture_btn.setEnabled(False)
|
||||||
|
|
||||||
|
dlg = QMessageBox(self)
|
||||||
|
dlg.setWindowTitle("相机断开连接")
|
||||||
|
dlg.setText("检测到相机已断开连接,请选择操作:")
|
||||||
|
dlg.setIcon(QMessageBox.Warning)
|
||||||
|
reconnect_btn = dlg.addButton("重新连接相机", QMessageBox.AcceptRole)
|
||||||
|
back_btn = dlg.addButton("返回分辨率选择", QMessageBox.RejectRole)
|
||||||
|
exit_btn = dlg.addButton("退出程序", QMessageBox.DestructiveRole)
|
||||||
|
dlg.exec_()
|
||||||
|
|
||||||
|
clicked = dlg.clickedButton()
|
||||||
|
if clicked == reconnect_btn:
|
||||||
|
self._do_reconnect()
|
||||||
|
elif clicked == back_btn:
|
||||||
|
self._restart_mode = True
|
||||||
|
self.hide()
|
||||||
|
self.back_to_profiles.emit() # → _open_session → 新窗口
|
||||||
|
self._worker.stop()
|
||||||
|
self.close()
|
||||||
|
else:
|
||||||
|
QApplication.quit()
|
||||||
|
|
||||||
|
def _do_reconnect(self):
|
||||||
|
"""用相同的分辨率配置重新连接相机。"""
|
||||||
|
prog = QMessageBox(QMessageBox.Information, "重新连接",
|
||||||
|
"正在重新连接相机,请稍候…",
|
||||||
|
QMessageBox.NoButton, self)
|
||||||
|
prog.setWindowModality(Qt.ApplicationModal)
|
||||||
|
prog.show()
|
||||||
|
QApplication.processEvents()
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pyorbbecsdk import Pipeline, OBSensorType
|
||||||
|
|
||||||
|
new_pipeline = Pipeline()
|
||||||
|
|
||||||
|
def _best_profile(profile_list, target: 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() == target["width"]
|
||||||
|
and vp.get_height() == target["height"]
|
||||||
|
and vp.get_fps() == target["fps"]):
|
||||||
|
return vp
|
||||||
|
# 没找到精确匹配时用第一个
|
||||||
|
p = profile_list.get_stream_profile_by_index(0)
|
||||||
|
return p.as_video_stream_profile()
|
||||||
|
|
||||||
|
new_color = _best_profile(
|
||||||
|
new_pipeline.get_stream_profile_list(OBSensorType.COLOR_SENSOR),
|
||||||
|
self._saved_color_info)
|
||||||
|
new_depth = _best_profile(
|
||||||
|
new_pipeline.get_stream_profile_list(OBSensorType.DEPTH_SENSOR),
|
||||||
|
self._saved_depth_info)
|
||||||
|
|
||||||
|
prog.accept()
|
||||||
|
|
||||||
|
old_worker = self._worker
|
||||||
|
self._worker = CameraWorker(new_pipeline, new_color, new_depth)
|
||||||
|
self._worker.preview_ready.connect(self._update_preview)
|
||||||
|
self._worker.capture_done.connect(self._on_capture_done)
|
||||||
|
self._worker.params_ready.connect(self._on_params_ready)
|
||||||
|
self._worker.log_msg.connect(self._append_log)
|
||||||
|
self._worker.disconnected.connect(self._on_camera_disconnected)
|
||||||
|
self._worker.start()
|
||||||
|
|
||||||
|
self.capture_btn.setEnabled(True)
|
||||||
|
old_worker.deleteLater()
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
prog.accept()
|
||||||
|
QMessageBox.critical(self, "重新连接失败",
|
||||||
|
f"无法重新连接相机:{exc}\n\n请检查连接后重试。")
|
||||||
|
# 再次弹出选项
|
||||||
|
self._on_camera_disconnected()
|
||||||
|
|
||||||
# ── Window close ──
|
# ── Window close ──
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
self._worker.stop()
|
self._worker.stop()
|
||||||
|
# _restart_mode 时由 _open_session 保持事件循环,不退出 app
|
||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
|
||||||
@@ -791,11 +967,28 @@ class MainWindow(QMainWindow):
|
|||||||
# Entry Point
|
# Entry Point
|
||||||
# ─────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _open_session(app: QApplication) -> None:
|
||||||
|
"""显示分辨率选择对话框,成功后打开主窗口;取消则退出 app。"""
|
||||||
|
dlg = ProfileDialog()
|
||||||
|
if dlg.exec_() != QDialog.Accepted:
|
||||||
|
app.quit()
|
||||||
|
return
|
||||||
|
|
||||||
|
if dlg.pipeline is None or dlg.selected_color is None or dlg.selected_depth is None:
|
||||||
|
QMessageBox.critical(None, "错误", "相机初始化失败,请检查设备连接后重启程序。")
|
||||||
|
app.quit()
|
||||||
|
return
|
||||||
|
|
||||||
|
win = MainWindow(dlg.pipeline, dlg.selected_color, dlg.selected_depth)
|
||||||
|
# 断线→返回选择界面时,递归调用本函数
|
||||||
|
win.back_to_profiles.connect(lambda: _open_session(app))
|
||||||
|
win.show()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
app.setStyle("Fusion")
|
app.setStyle("Fusion")
|
||||||
|
|
||||||
# 深色调色板(可选,让界面更现代)
|
|
||||||
from PyQt5.QtGui import QPalette, QColor
|
from PyQt5.QtGui import QPalette, QColor
|
||||||
palette = QPalette()
|
palette = QPalette()
|
||||||
palette.setColor(QPalette.Window, QColor(45, 45, 45))
|
palette.setColor(QPalette.Window, QColor(45, 45, 45))
|
||||||
@@ -813,17 +1006,7 @@ def main():
|
|||||||
palette.setColor(QPalette.HighlightedText, QColor(0, 0, 0))
|
palette.setColor(QPalette.HighlightedText, QColor(0, 0, 0))
|
||||||
app.setPalette(palette)
|
app.setPalette(palette)
|
||||||
|
|
||||||
# 分辨率选择
|
_open_session(app)
|
||||||
dlg = ProfileDialog()
|
|
||||||
if dlg.exec_() != QDialog.Accepted:
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
if dlg.pipeline is None or dlg.selected_color is None or dlg.selected_depth is None:
|
|
||||||
QMessageBox.critical(None, "错误", "相机初始化失败,请检查设备连接后重启程序。")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
win = MainWindow(dlg.pipeline, dlg.selected_color, dlg.selected_depth)
|
|
||||||
win.show()
|
|
||||||
sys.exit(app.exec_())
|
sys.exit(app.exec_())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user