visualize_scene_3d.py 84 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 3D 场景可视化脚本
  5. 功能:
  6. 1. RANSAC 平面拟合地面和天花板
  7. 2. 基于地面拟合结果过滤门(门底部距地面>0.1 米过滤)
  8. 3. 将门和拍摄点位平移到天花板高度进行可视化
  9. 4. 入户门用特殊颜色标记
  10. 5. 输出 PLY 文件,支持在 3D 查看器中查看
  11. """
  12. import os
  13. import sys
  14. import json
  15. import argparse
  16. from pathlib import Path
  17. from dataclasses import dataclass
  18. from typing import Dict, List, Optional, Tuple
  19. import cv2
  20. import numpy as np
  21. import open3d as o3d
  22. from tqdm import tqdm
  23. from ultralytics import YOLOE
  24. from camera_spherical import Intrinsic_Spherical_NP
  25. from scipy.spatial import Delaunay
  26. # ============================================================================
  27. # 数据类
  28. # ============================================================================
  29. @dataclass
  30. class PoseData:
  31. """位姿数据"""
  32. uuid: str
  33. rotation: Dict[str, float] # w, x, y, z
  34. translation: Dict[str, float] # x, y, z
  35. @dataclass
  36. class Door3D:
  37. """3D 门实例"""
  38. id: int
  39. center: np.ndarray # 中心坐标 [x, y, z]
  40. bbox_8points: np.ndarray # 8 个角点 [[x,y,z], ...] (8x3)
  41. source_detections: List[Dict] # 来源检测信息
  42. source_uuid: Optional[str] = None # 来源点位的 UUID(用于获取 puck_z)
  43. # ============================================================================
  44. # 场景可视化处理器
  45. # ============================================================================
  46. class SceneVisualizer:
  47. """
  48. 场景 3D 可视化处理器:
  49. - RANSAC 平面拟合地面/天花板
  50. - 门过滤(基于地面距离)
  51. - 门和点位平移到天花板高度
  52. - 输出可视化 PLY 文件
  53. """
  54. # 门检测类别
  55. DOOR_CLASSES = [
  56. "door", "indoor door", "exterior door",
  57. "wooden door", "metal door", "glass door", "double door",
  58. "single door", "open door", "closed door"
  59. ]
  60. def __init__(
  61. self,
  62. scene_folder: str,
  63. model_path: str = "yoloe-26x-seg.pt",
  64. conf: float = 0.35,
  65. iou: float = 0.45,
  66. voxel_size: float = 0.03,
  67. depth_scale: float = 256.0,
  68. depth_min: float = 0.02,
  69. # 地面/天花板拟合参数
  70. ground_ransac_dist: float = 0.05, # RANSAC 内点距离阈值
  71. ground_ransac_prob: float = 0.99, # RANSAC 置信度
  72. ceiling_percentile: float = 95.0, # 天花板点分位数
  73. # 门过滤参数
  74. door_ground_dist: float = 0.1, # 门底部距地面最大距离
  75. door_height_min: float = 1.0,
  76. door_height_max: float = 3.0,
  77. door_width_min: float = 0.3,
  78. door_width_max: float = 3.0,
  79. door_thickness_max: float = 0.5,
  80. # 3D 门合并参数
  81. merge_iou_thresh: float = 0.1, # 降低 IoU 阈值,增加合并敏感度
  82. merge_dist_thresh: float = 0.3, # 中心距离阈值 (米) - 同一物理门的检测应该很近
  83. merge_z_overlap_thresh: float = 0.5, # Z 方向重叠度阈值 (0-1,0=不重叠,1=完全重叠)
  84. # 入户门参数
  85. entrance_method: str = "score", # "score" 或 "json"
  86. entrance_json_path: Optional[str] = None,
  87. ):
  88. """
  89. 初始化场景可视化处理器
  90. Args:
  91. scene_folder: 场景文件夹路径
  92. model_path: YOLOE 模型路径
  93. conf: 检测置信度阈值
  94. iou: NMS IoU 阈值
  95. voxel_size: 点云体素下采样尺寸
  96. depth_scale: 深度图缩放因子
  97. depth_min: 最小有效深度
  98. ground_ransac_dist: RANSAC 地面拟合距离阈值
  99. ground_ransac_prob: RANSAC 置信度
  100. ceiling_percentile: 天花板点分位数(取最高的 x% 点拟合天花板)
  101. door_ground_dist: 门底部距地面最大距离(超过则过滤)
  102. door_height_min/max: 门高度范围
  103. door_width_min/max: 门宽度范围
  104. door_thickness_max: 门最大厚度
  105. merge_iou_thresh: 3D 门合并 IoU 阈值
  106. merge_dist_thresh: 3D 门合并中心距离阈值
  107. entrance_method: 入户门判定方法 ("score" 或 "json")
  108. entrance_json_path: 入户门 JSON 路径(当 method=json 时使用)
  109. """
  110. self.scene_folder = Path(scene_folder)
  111. self.conf = conf
  112. self.iou = iou
  113. self.voxel_size = voxel_size
  114. self.depth_scale = depth_scale
  115. self.depth_min = depth_min
  116. # 地面/天花板参数
  117. self.ground_ransac_dist = ground_ransac_dist
  118. self.ground_ransac_prob = ground_ransac_prob
  119. self.ceiling_percentile = ceiling_percentile
  120. # 门过滤参数
  121. self.door_ground_dist = door_ground_dist
  122. self.door_height_min = door_height_min
  123. self.door_height_max = door_height_max
  124. self.door_width_min = door_width_min
  125. self.door_width_max = door_width_max
  126. self.door_thickness_max = door_thickness_max
  127. # 3D 门合并参数
  128. self.merge_iou_thresh = merge_iou_thresh
  129. self.merge_dist_thresh = merge_dist_thresh
  130. self.merge_z_overlap_thresh = merge_z_overlap_thresh
  131. # 地面 Z 值向下兼容:合并时取最低的地面 Z 值
  132. self.ground_z_fallback_low = True
  133. # 入户门参数
  134. self.entrance_method = entrance_method
  135. self.entrance_json_path = entrance_json_path
  136. self.entrance_door_id = None
  137. self.estimated_entrance_position = None # 当没有检测到门时的估计位置
  138. # 地面参数(在拟合时设置)
  139. self.ground_d = None
  140. self.ground_z_from_puck = None # 从 puck 参数估计的地面 Z
  141. # 子目录
  142. self.rgb_folder = self.scene_folder / "pano_img"
  143. self.depth_folder = self.scene_folder / "depth_img"
  144. self.pose_file = self.scene_folder / "vision.txt"
  145. # 输出目录
  146. self.output_folder = self.scene_folder / "output"
  147. # 加载位姿
  148. self.poses = self._load_poses()
  149. # 加载 YOLOE 模型
  150. print(f"加载 YOLOE 模型:{model_path}")
  151. self.model = YOLOE(model_path)
  152. self.model.set_classes(self.DOOR_CLASSES)
  153. print(f"检测类别:{self.DOOR_CLASSES}")
  154. def _load_poses(self) -> Dict[str, PoseData]:
  155. """从 vision.txt 加载位姿信息"""
  156. if not self.pose_file.exists():
  157. raise FileNotFoundError(f"位姿文件不存在:{self.pose_file}")
  158. with open(self.pose_file, 'r') as f:
  159. data = json.load(f)
  160. poses = {}
  161. self.puck_z_dict = {} # 保存每个点位的 puck_z
  162. for loc in data.get('sweepLocations', []):
  163. uuid = str(loc['uuid'])
  164. poses[uuid] = PoseData(
  165. uuid=uuid,
  166. rotation=loc['pose']['rotation'],
  167. translation=loc['pose']['translation']
  168. )
  169. # 保存每个点位的 puck_z
  170. if 'puck' in loc and 'z' in loc['puck']:
  171. self.puck_z_dict[uuid] = loc['puck']['z']
  172. print(f"加载 {len(poses)} 个拍摄点位")
  173. # 计算整体地面 Z(用于没有 puck 数据时的回退)
  174. if self.puck_z_dict:
  175. puck_z_values = list(self.puck_z_dict.values())
  176. self.ground_z_from_puck = np.median(puck_z_values)
  177. print(f"从 puck 参数估计地面 Z (中位数): {self.ground_z_from_puck:.4f}m")
  178. print(f"puck_z 范围:[{min(puck_z_values):.4f}, {max(puck_z_values):.4f}]")
  179. else:
  180. self.ground_z_from_puck = None
  181. print("⚠️ 未找到 puck 参数,将使用 RANSAC 拟合地面")
  182. return poses
  183. def _build_pose_matrix(self, pose: PoseData) -> np.ndarray:
  184. """构建 4x4 位姿变换矩阵"""
  185. R = o3d.geometry.get_rotation_matrix_from_quaternion(
  186. np.array([pose.rotation['w'], pose.rotation['x'],
  187. pose.rotation['y'], pose.rotation['z']])
  188. )
  189. t = np.array([
  190. pose.translation['x'],
  191. pose.translation['y'],
  192. pose.translation['z']
  193. ])
  194. T = np.eye(4)
  195. T[:3, :3] = R
  196. T[:3, 3] = t
  197. return T
  198. def _mask_to_3d_points(
  199. self,
  200. mask: np.ndarray,
  201. depth: np.ndarray,
  202. pose_matrix: np.ndarray
  203. ) -> Optional[np.ndarray]:
  204. """
  205. 将 2D mask 映射到世界坐标系 3D 点
  206. Args:
  207. mask: 二值 mask (H, W)
  208. depth: 深度图 (H, W)
  209. pose_matrix: 4x4 位姿矩阵
  210. Returns:
  211. 世界坐标系下的 3D 点 (N, 3)
  212. """
  213. H, W = depth.shape
  214. sph = Intrinsic_Spherical_NP(W, H)
  215. # 获取 mask 内的像素
  216. ys, xs = np.where(mask > 0)
  217. if len(xs) == 0:
  218. return None
  219. # 有效深度掩码
  220. valid = depth[ys, xs] > self.depth_min
  221. if not np.any(valid):
  222. return None
  223. xs, ys = xs[valid], ys[valid]
  224. depths = depth[ys, xs]
  225. # 计算方向向量
  226. bx, by, bz = sph.bearing([xs.astype(np.float64), ys.astype(np.float64)])
  227. bx, by, bz = np.array(bx), np.array(by), np.array(bz)
  228. # 相机坐标系
  229. pts_cam = np.stack([bx * depths, by * depths, bz * depths], axis=1)
  230. # Z 轴 180 度翻转
  231. R_z180 = np.diag([-1.0, -1.0, 1.0])
  232. pts_cam = pts_cam @ R_z180.T
  233. # 世界坐标系
  234. pts_w = (pose_matrix[:3, :3] @ pts_cam.T).T + pose_matrix[:3, 3]
  235. return pts_w
  236. def _extract_mask_contours(self, masks) -> Tuple[List[List[List[float]]], List[np.ndarray]]:
  237. """
  238. 从 YOLOE mask 结果提取轮廓
  239. Args:
  240. masks: YOLOE masks (H, W, N)
  241. Returns:
  242. (轮廓列表,对应 mask 数组)
  243. """
  244. contours = []
  245. mask_arrays = []
  246. if masks is None:
  247. return contours, mask_arrays
  248. masks_np = masks.cpu().numpy()
  249. for i in range(masks_np.shape[0]):
  250. mask = masks_np[i]
  251. # 二值化
  252. mask_bin = (mask > 0.5).astype(np.uint8) * 255
  253. # 提取轮廓
  254. cnts, _ = cv2.findContours(mask_bin, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
  255. if not cnts:
  256. continue
  257. # 只保留面积最大的轮廓
  258. largest = max(cnts, key=cv2.contourArea)
  259. if len(largest) >= 3:
  260. # 简化轮廓
  261. epsilon = 0.02 * cv2.arcLength(largest, True)
  262. approx = cv2.approxPolyDP(largest, epsilon, True)
  263. contour = approx.reshape(-1, 2).astype(float).tolist()
  264. contours.append(contour)
  265. mask_arrays.append((mask_bin > 0).astype(np.uint8))
  266. else:
  267. mask_arrays.append((mask_bin > 0).astype(np.uint8))
  268. return contours, mask_arrays
  269. def detect_single_image(
  270. self,
  271. img_path: str,
  272. depth: np.ndarray,
  273. pose_matrix: np.ndarray,
  274. save_path: Optional[str] = None
  275. ) -> Tuple[List[np.ndarray], List[float]]:
  276. """
  277. 检测单张图像并提取 mask 3D 点
  278. Returns:
  279. (mask_3d_points 列表,scores 列表)
  280. """
  281. results = self.model.predict(
  282. img_path,
  283. imgsz=(1024, 2048),
  284. conf=self.conf,
  285. iou=self.iou,
  286. max_det=50,
  287. augment=True,
  288. retina_masks=True,
  289. half=False,
  290. verbose=False,
  291. )
  292. result = results[0]
  293. mask_3d_points = []
  294. scores = []
  295. if result.masks is not None:
  296. masks = result.masks.data
  297. contours, mask_arrays = self._extract_mask_contours(masks)
  298. scores = result.boxes.conf.cpu().numpy().tolist()
  299. H, W = depth.shape
  300. for mask_bin in mask_arrays:
  301. mask_resized = cv2.resize(mask_bin, (W, H), interpolation=cv2.INTER_NEAREST)
  302. pts_3d = self._mask_to_3d_points(mask_resized, depth, pose_matrix)
  303. if pts_3d is not None and len(pts_3d) > 10:
  304. mask_3d_points.append(pts_3d)
  305. if save_path:
  306. os.makedirs(os.path.dirname(save_path), exist_ok=True)
  307. result.save(save_path)
  308. return mask_3d_points, scores
  309. def _axis_aligned_bbox(self, points: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
  310. """计算轴对齐包围盒 (min, max)"""
  311. lo = np.min(points, axis=0)
  312. hi = np.max(points, axis=0)
  313. return lo, hi
  314. def _filter_outliers(self, points: np.ndarray, std_thresh: float = 3.0) -> np.ndarray:
  315. """
  316. 过滤 3D 点云中的离群点
  317. Args:
  318. points: 3D 点 (N, 3)
  319. std_thresh: 标准差阈值(默认 3 倍标准差)
  320. Returns:
  321. 过滤后的点
  322. """
  323. if len(points) < 10:
  324. return points
  325. # 计算每个维度的均值和标准差
  326. mean = np.mean(points, axis=0)
  327. std = np.std(points, axis=0)
  328. # 保留在 3 倍标准差内的点
  329. mask = np.all(np.abs(points - mean) < std_thresh * std, axis=1)
  330. filtered = points[mask]
  331. if len(filtered) < len(points) * 0.5:
  332. # 如果过滤掉的点超过一半,返回原始点
  333. return points
  334. return filtered
  335. def _filter_door_points_by_depth(self, points: np.ndarray) -> np.ndarray:
  336. """
  337. 根据深度一致性过滤门的 3D 点
  338. 门的特点是:在门平面法向量方向上很薄
  339. 使用 PCA 找出最薄的方向,然后过滤掉超出阈值的点
  340. Args:
  341. points: 3D 点 (N, 3)
  342. Returns:
  343. 过滤后的点
  344. """
  345. if len(points) < 50:
  346. return points
  347. # PCA 分析
  348. centered = points - np.mean(points, axis=0)
  349. cov = np.cov(centered.T)
  350. eigenvalues, eigenvectors = np.linalg.eigh(cov)
  351. # 特征值从大到小排序
  352. idx = np.argsort(eigenvalues)[::-1]
  353. eigenvalues = eigenvalues[idx]
  354. eigenvectors = eigenvectors[:, idx]
  355. # 最小特征值对应的特征向量是门的法向量(厚度方向)
  356. normal = eigenvectors[:, 2] # 最小特征值对应的方向
  357. # 将点投影到法向量方向
  358. projected = np.dot(points, normal)
  359. # 过滤掉超出 2 倍标准差的点
  360. mean_proj = np.mean(projected)
  361. std_proj = np.std(projected)
  362. mask = np.abs(projected - mean_proj) < 2.0 * std_proj
  363. filtered = points[mask]
  364. if len(filtered) < len(points) * 0.5:
  365. return points
  366. return filtered
  367. def _compute_door_dimensions_pca(self, points: np.ndarray) -> Tuple[float, float, float]:
  368. """
  369. 使用 PCA 计算门的实际尺寸
  370. 门的特点是:在一个方向上很薄(厚度),在另外两个方向上较大(高度和宽度)
  371. Args:
  372. points: 3D 点 (N, 3)
  373. Returns:
  374. (width, thickness, height) 门的宽度、厚度、高度
  375. """
  376. if len(points) < 10:
  377. size = points.max(axis=0) - points.min(axis=0)
  378. return size[0], size[1], size[2]
  379. # PCA 分析
  380. centered = points - np.mean(points, axis=0)
  381. cov = np.cov(centered.T)
  382. eigenvalues, eigenvectors = np.linalg.eigh(cov)
  383. # 特征值从大到小排序
  384. idx = np.argsort(eigenvalues)[::-1]
  385. eigenvalues = eigenvalues[idx]
  386. eigenvectors = eigenvectors[:, idx]
  387. # 将点投影到主成分方向
  388. projected = centered @ eigenvectors
  389. # 计算每个方向的尺寸
  390. dims = projected.max(axis=0) - projected.min(axis=0)
  391. # 门的特点:厚度方向应该是最小的维度
  392. # 假设门的厚度是最小的维度
  393. thickness = dims.min()
  394. height = dims.max() # 高度是最大的维度
  395. width = dims.sum() - thickness - height # 宽度是中间的维度
  396. return width, thickness, height
  397. def _bbox_8corners(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
  398. """从 bbox min/max 获取 8 个角点"""
  399. cx, cy, cz = bbox_min
  400. ex, ey, ez = bbox_max
  401. return np.array([
  402. [cx, cy, cz], [ex, cy, cz], [ex, ey, cz], [cx, ey, cz],
  403. [cx, cy, ez], [ex, cy, ez], [ex, ey, ez], [cx, ey, ez],
  404. ])
  405. def _bbox_iou_3d(self, b1, b2) -> float:
  406. """3D IoU 计算"""
  407. lo = np.maximum(b1[0], b2[0])
  408. hi = np.minimum(b1[1], b2[1])
  409. inter = np.prod(np.maximum(hi - lo, 0))
  410. vol1 = np.prod(b1[1] - b1[0])
  411. vol2 = np.prod(b2[1] - b2[0])
  412. union = vol1 + vol2 - inter
  413. return inter / union if union > 0 else 0.0
  414. def _merge_3d_doors(self, door_candidates: List[Dict]) -> List[Door3D]:
  415. """
  416. 使用并查集合并 3D 门
  417. 合并策略:
  418. 1. 来自不同点位 Detection 才考虑合并(同一图像的可能是多个门)
  419. 2. 中心距离接近(同一物理门在不同视角下应该位置相近)
  420. 3. Z 方向高度重叠(同一门的高度范围应该相近)
  421. 4. IoU 或距离满足其一即可(放宽条件)
  422. 地面 Z 值向下兼容:
  423. - 对于不同点位的重叠或相邻很近的门,合并后地面的 Z 值以最低的为准
  424. - 例如:a 点位地面 z=-1.45, b 点位地面 z=-1.5,合并后以 -1.5 为准
  425. """
  426. if not door_candidates:
  427. return []
  428. n = len(door_candidates)
  429. if n == 1:
  430. d = door_candidates[0]
  431. return [Door3D(
  432. id=0,
  433. center=(d['bbox_min'] + d['bbox_max']) / 2,
  434. bbox_8points=self._bbox_8corners(d['bbox_min'], d['bbox_max']),
  435. source_detections=[d['source']],
  436. source_uuid=d.get('source_uuid')
  437. )]
  438. # 并查集
  439. parent = list(range(n))
  440. def find(x):
  441. if parent[x] != x:
  442. parent[x] = find(parent[x])
  443. return parent[x]
  444. def union(x, y):
  445. px, py = find(x), find(y)
  446. if px != py:
  447. parent[px] = py
  448. # 构建连通关系
  449. for i in range(n):
  450. for j in range(i + 1, n):
  451. ci = (door_candidates[i]['bbox_min'] + door_candidates[i]['bbox_max']) / 2
  452. cj = (door_candidates[j]['bbox_min'] + door_candidates[j]['bbox_max']) / 2
  453. dist = np.linalg.norm(ci - cj)
  454. # 检查是否来自同一张图像
  455. same_image = door_candidates[i]['source']['image'] == door_candidates[j]['source']['image']
  456. # 计算 Z 方向重叠度
  457. z_min_i, z_max_i = door_candidates[i]['bbox_min'][2], door_candidates[i]['bbox_max'][2]
  458. z_min_j, z_max_j = door_candidates[j]['bbox_min'][2], door_candidates[j]['bbox_max'][2]
  459. z_overlap_min = max(z_min_i, z_min_j)
  460. z_overlap_max = min(z_max_i, z_max_j)
  461. z_intersection = max(0, z_overlap_max - z_overlap_min)
  462. z_union = max(z_max_i, z_max_j) - min(z_min_i, z_min_j)
  463. z_overlap_ratio = z_intersection / z_union if z_union > 0 else 0
  464. # 计算 3D IoU
  465. iou = self._bbox_iou_3d(
  466. (door_candidates[i]['bbox_min'], door_candidates[i]['bbox_max']),
  467. (door_candidates[j]['bbox_min'], door_candidates[j]['bbox_max'])
  468. )
  469. # 合并条件:
  470. # 1. 来自不同图像:距离近 OR IoU 高,且 Z 重叠
  471. # 2. 来自同一图像:IoU 高且 Z 重叠(可能是同一个门在不同位置被检测到)
  472. if same_image:
  473. # 同一图像:IoU 高且 Z 重叠
  474. should_merge = (iou > 0.05 and z_overlap_ratio > 0.5)
  475. else:
  476. # 不同图像:(距离近 OR IoU 高) 且 Z 重叠
  477. should_merge = (
  478. (dist < self.merge_dist_thresh or iou > self.merge_iou_thresh) and
  479. z_overlap_ratio > self.merge_z_overlap_thresh
  480. )
  481. if should_merge:
  482. union(i, j)
  483. # 按连通分量分组
  484. from collections import defaultdict
  485. groups = defaultdict(list)
  486. for i in range(n):
  487. groups[find(i)].append(door_candidates[i])
  488. # 合并每个组
  489. doors = []
  490. for door_id, members in enumerate(groups.values()):
  491. if not members:
  492. continue
  493. # 原始合并逻辑:取 min 和 max
  494. # bbox_min = np.min([m['bbox_min'] for m in members], axis=0)
  495. # bbox_max = np.max([m['bbox_max'] for m in members], axis=0)
  496. # 地面 Z 值向下兼容:
  497. # 1. X, Y 方向保持不变(取最小/最大)
  498. # 2. Z 方向(地面)取最低值(最负的),门顶保持最高值
  499. all_bbox_mins = [m['bbox_min'] for m in members]
  500. all_bbox_maxs = [m['bbox_max'] for m in members]
  501. # X, Y 方向:取最小/最大
  502. merged_min_xy = np.min(all_bbox_mins, axis=0)[:2] # [x_min, y_min]
  503. merged_max_xy = np.max(all_bbox_maxs, axis=0)[:2] # [x_max, y_max]
  504. # Z 方向:地面 Z 向下兼容(取最小值/最负值),门顶取最大值
  505. z_mins = [b[2] for b in all_bbox_mins] # 所有门的底部 Z
  506. z_maxs = [b[2] for b in all_bbox_maxs] # 所有门的顶部 Z
  507. # 地面 Z 值向下兼容:取最低的 Z 值(最负的值)
  508. merged_z_min = np.min(z_mins)
  509. merged_z_max = np.max(z_maxs)
  510. bbox_min = np.array([merged_min_xy[0], merged_min_xy[1], merged_z_min])
  511. bbox_max = np.array([merged_max_xy[0], merged_max_xy[1], merged_z_max])
  512. sources = [m['source'] for m in members]
  513. # 使用第一个成员的 source_uuid(同一门的多个检测来自相近位置)
  514. source_uuid = members[0].get('source_uuid')
  515. doors.append(Door3D(
  516. id=door_id,
  517. center=(bbox_min + bbox_max) / 2,
  518. bbox_8points=self._bbox_8corners(bbox_min, bbox_max),
  519. source_detections=sources,
  520. source_uuid=source_uuid
  521. ))
  522. return doors
  523. def _fit_ground_plane_ransac(self, pc: o3d.geometry.PointCloud) -> Tuple[np.ndarray, float]:
  524. """
  525. 使用 RANSAC 拟合地面平面
  526. 策略:
  527. 1. 先用 Z 坐标最低的 5% 的点作为初始地面点
  528. 2. 对这些点使用 RANSAC 平面拟合
  529. 3. 确保法向量朝上(Z 轴正方向)
  530. Args:
  531. pc: 点云
  532. Returns:
  533. (平面法向量,平面到原点距离 d,满足 ax+by+cz+d=0)
  534. """
  535. print("\n=== RANSAC 地面拟合 ===")
  536. points = np.asarray(pc.points)
  537. # 方法 1:先用 Z 坐标最低的点作为初始种子
  538. z_coords = points[:, 2]
  539. z_threshold = np.percentile(z_coords, 5) # 最低的 5%
  540. ground_candidates = points[z_coords <= z_threshold]
  541. print(f"地面候选点数量:{len(ground_candidates)} (Z <= {z_threshold:.3f}m)")
  542. if len(ground_candidates) < 100:
  543. print("⚠️ 地面候选点过少,使用全局 RANSAC")
  544. # 回退到全局 RANSAC
  545. plane_model, inliers = pc.segment_plane(
  546. distance_threshold=self.ground_ransac_dist,
  547. ransac_n=3,
  548. num_iterations=1000
  549. )
  550. else:
  551. # 对地面候选点使用 PCA 拟合平面
  552. centered = ground_candidates - np.mean(ground_candidates, axis=0)
  553. cov = np.cov(centered.T)
  554. eigenvalues, eigenvectors = np.linalg.eigh(cov)
  555. # 最小特征值对应的特征向量是法向量
  556. normal = eigenvectors[:, 0] # 最小特征值
  557. mean_point = np.mean(ground_candidates, axis=0)
  558. # 平面方程:a(x-x0) + b(y-y0) + c(z-z0) = 0
  559. # 即:ax + by + cz + d = 0, d = -(ax0 + by0 + cz0)
  560. a, b, c = normal
  561. d = -np.dot(normal, mean_point)
  562. plane_model = [a, b, c, d]
  563. inliers = list(range(len(ground_candidates)))
  564. a, b, c, d = plane_model
  565. normal = np.array([a, b, c])
  566. # 确保法向量朝上(Z 轴正方向)
  567. if normal[2] < 0:
  568. normal = -normal
  569. d = -d
  570. inlier_ratio = len(inliers) / len(pc.points)
  571. print(f"地面平面方程:{normal[0]:.4f}x + {normal[1]:.4f}y + {normal[2]:.4f}z + {d:.4f} = 0")
  572. print(f"地面法向量:[{normal[0]:.4f}, {normal[1]:.4f}, {normal[2]:.4f}]")
  573. print(f"内点数量:{len(inliers)} / {len(pc.points)} ({inlier_ratio:.2%})")
  574. return normal, d
  575. def _fit_ceiling_plane(self, pc: o3d.geometry.PointCloud, ground_normal: np.ndarray, ground_d: float) -> Tuple[np.ndarray, float, float]:
  576. """
  577. 拟合天花板平面
  578. Args:
  579. pc: 点云
  580. ground_normal: 地面法向量
  581. ground_d: 地面平面方程的 d 参数
  582. Returns:
  583. (天花板平面法向量,平面到原点距离 d, 地面到天花板距离)
  584. """
  585. print("\n=== 天花板平面拟合 ===")
  586. points = np.asarray(pc.points)
  587. # 计算每个点沿法向量方向到地面的距离
  588. # distance = p·n + d_ground
  589. distances_to_ground = np.dot(points, ground_normal) + ground_d
  590. # 过滤掉异常高点(高于 5 米的点可能是噪声)
  591. # 正常房间高度在 2.5-4 米之间
  592. valid_mask = distances_to_ground < 5.0
  593. valid_points = points[valid_mask]
  594. valid_distances = distances_to_ground[valid_mask]
  595. if len(valid_points) < 1000:
  596. print(f"⚠️ 有效点过少 ({len(valid_points)} 个),使用所有点")
  597. valid_points = points
  598. valid_distances = distances_to_ground
  599. # 取最高的点拟合天花板(85%-95% 分位数之间,避免噪声)
  600. height_percentiles = np.percentile(valid_distances, [85, 90, 95])
  601. print(f"高度分位数:85%={height_percentiles[0]:.3f}m, 90%={height_percentiles[1]:.3f}m, 95%={height_percentiles[2]:.3f}m")
  602. # 使用 90%-95% 之间的点拟合天花板
  603. ceiling_threshold = height_percentiles[1] # 90% 分位数
  604. ceiling_mask = valid_distances >= ceiling_threshold
  605. ceiling_points = valid_points[ceiling_mask]
  606. if len(ceiling_points) < 100:
  607. print(f"⚠️ 天花板点过少 ({len(ceiling_points)} 个),降低阈值到 85%")
  608. ceiling_threshold = height_percentiles[0]
  609. ceiling_mask = valid_distances >= ceiling_threshold
  610. ceiling_points = valid_points[ceiling_mask]
  611. # 天花板法向量与地面相同(平行平面)
  612. ceiling_normal = ground_normal.copy()
  613. # 计算天花板到原点的距离
  614. # d_ceiling = -mean(p·n) for ceiling points
  615. d_ceiling = -np.mean(np.dot(ceiling_points, ceiling_normal))
  616. # 计算地面到天花板的距离(用天花板点的平均高度)
  617. floor_ceiling_dist = np.mean(valid_distances[ceiling_mask])
  618. ceiling_cloud = o3d.geometry.PointCloud()
  619. ceiling_cloud.points = o3d.utility.Vector3dVector(ceiling_points)
  620. print(f"天花板点数量:{len(ceiling_points)}")
  621. print(f"天花板平面方程:{ceiling_normal[0]:.4f}x + {ceiling_normal[1]:.4f}y + {ceiling_normal[2]:.4f}z + {d_ceiling:.4f} = 0")
  622. print(f"地面到天花板距离:{floor_ceiling_dist:.3f} 米")
  623. return ceiling_normal, d_ceiling, floor_ceiling_dist
  624. def _point_to_plane_distance(self, point: np.ndarray, normal: np.ndarray, d: float) -> float:
  625. """计算点到平面的距离"""
  626. return abs(np.dot(point, normal) + d)
  627. def _denoise_xy_projection(
  628. self,
  629. points: np.ndarray,
  630. grid_size: float = 0.15,
  631. min_points_per_cell: int = 5
  632. ) -> np.ndarray:
  633. """
  634. 投影到 XY 平面后进行网格滤波去噪
  635. 原理:
  636. 1. 将点云投影到 XY 平面
  637. 2. 划分网格,统计每个网格内的点数
  638. 3. 只保留点数足够的网格中的点
  639. 4. 这样可以去除孤立的噪声点,同时保留建筑轮廓
  640. Args:
  641. points: 3D 点 (N, 3)
  642. grid_size: 网格尺寸(米),默认 0.15m
  643. min_points_per_cell: 每个网格最少点数,低于此值的网格视为噪声
  644. Returns:
  645. 去噪后的点 (M, 3)
  646. """
  647. if len(points) < 10:
  648. return points
  649. xy = points[:, :2]
  650. z = points[:, 2]
  651. # 网格化
  652. grid_coords = np.floor(xy / grid_size).astype(int)
  653. # 统计每个网格的点数
  654. from collections import Counter, defaultdict
  655. cell_counts = Counter(map(tuple, grid_coords))
  656. # 保留点数足够的网格
  657. valid_cells = {
  658. cell for cell, count in cell_counts.items()
  659. if count >= min_points_per_cell
  660. }
  661. # 创建掩码
  662. mask = np.array([tuple(gc) in valid_cells for gc in grid_coords])
  663. if np.sum(mask) < len(points) * 0.3:
  664. # 如果过滤掉的点超过 70%,降低阈值
  665. print(f"⚠️ 去噪过滤过多 ({np.sum(mask)}/{len(points)}),降低阈值重试")
  666. valid_cells = {
  667. cell for cell, count in cell_counts.items()
  668. if count >= max(1, min_points_per_cell - 2)
  669. }
  670. mask = np.array([tuple(gc) in valid_cells for gc in grid_coords])
  671. filtered = points[mask]
  672. print(f"XY 投影去噪:{len(points)} → {len(filtered)} 点 ({len(filtered)/len(points)*100:.1f}%)")
  673. return filtered
  674. def _compute_xy_contour(self, points: np.ndarray) -> Tuple[np.ndarray, Optional[Delaunay]]:
  675. """
  676. 计算点云 XY 投影的轮廓(凹包/Alpha Shape)
  677. Args:
  678. points: 3D 点 (N, 3),应该已经去过噪
  679. Returns:
  680. (轮廓点集,Delaunay 三角网)
  681. 轮廓点集形状 (K, 2),Delaunay 三角网用于点在多边形内判断
  682. """
  683. from scipy.spatial import Delaunay
  684. if len(points) < 4:
  685. return points[:, :2], None
  686. xy = points[:, :2]
  687. # 使用 Alpha Shape 提取轮廓
  688. alpha = 2.0 # alpha 半径,越大越接近凸包
  689. # 计算 Delaunay 三角网
  690. tri = Delaunay(xy)
  691. # 提取 Alpha Shape 边界
  692. # 策略:移除边长超过阈值的三角形
  693. centers = []
  694. for i in range(tri.npoints):
  695. # 获取第 i 个三角形的三个顶点
  696. idx = tri.simplices[i]
  697. p0, p1, p2 = xy[idx[0]], xy[idx[1]], xy[idx[2]]
  698. # 计算三角形外接圆半径
  699. a = np.linalg.norm(p1 - p2)
  700. b = np.linalg.norm(p0 - p2)
  701. c = np.linalg.norm(p0 - p1)
  702. s = (a + b + c) / 2
  703. area = np.sqrt(max(0, s * (s - a) * (s - b) * (s - c)))
  704. if area < 1e-10:
  705. continue
  706. R = (a * b * c) / (4 * area)
  707. if R < alpha:
  708. center = (p0 + p1 + p2) / 3
  709. centers.append(center)
  710. if len(centers) < 3:
  711. # Alpha Shape 失败,回退到凸包
  712. print("⚠️ Alpha Shape 失败,使用凸包")
  713. from scipy.spatial import ConvexHull
  714. hull = ConvexHull(xy)
  715. return xy[hull.vertices], tri
  716. # 从中心点集提取边界
  717. centers = np.array(centers)
  718. from scipy.spatial import ConvexHull
  719. hull = ConvexHull(centers)
  720. contour = centers[hull.vertices]
  721. return contour, tri
  722. def _point_in_contour(self, point: np.ndarray, contour: np.ndarray, tri: Optional[Delaunay]) -> bool:
  723. """
  724. 判断点是否在轮廓内
  725. Args:
  726. point: 2D 点 [x, y]
  727. contour: 轮廓点集 (K, 2)
  728. tri: Delaunay 三角网(用于判断)
  729. Returns:
  730. 是否在轮廓内
  731. """
  732. if tri is None:
  733. # 没有三角网,使用射线法判断
  734. from matplotlib.path import Path
  735. path = Path(contour)
  736. return path.contains_point(point)
  737. # 使用 Delaunay 三角网判断
  738. # 如果点在凸包内,simplex 返回 >= 0
  739. simplex = tri.find_simplex(point)
  740. return simplex >= 0
  741. def _compute_door_boundary_score(
  742. self,
  743. door: Door3D,
  744. contour: np.ndarray,
  745. tri
  746. ) -> float:
  747. """
  748. 计算门到轮廓边界的距离评分
  749. 入户门应该在建筑轮廓的边缘,距离边界越近评分越高
  750. Args:
  751. door: 3D 门对象
  752. contour: 轮廓点集 (K, 2)
  753. tri: Delaunay 三角网
  754. Returns:
  755. 边界评分 (0-30 分)
  756. """
  757. door_xy = door.center[:2]
  758. # 计算门中心到轮廓边界的最短距离
  759. # 遍历轮廓的每条边
  760. min_dist = float('inf')
  761. n = len(contour)
  762. for i in range(n):
  763. p1 = contour[i]
  764. p2 = contour[(i + 1) % n]
  765. dist = self._point_to_line_segment_distance(door_xy, p1, p2)
  766. min_dist = min(min_dist, dist)
  767. if min_dist == float('inf'):
  768. return 0.0
  769. # 距离转评分(越近分数越高)
  770. # 0 米 = 30 分,2 米 = 0 分
  771. score = max(0, 30 - min_dist * 15)
  772. return score
  773. def _point_to_line_segment_distance(
  774. self,
  775. point: np.ndarray,
  776. line_start: np.ndarray,
  777. line_end: np.ndarray
  778. ) -> float:
  779. """
  780. 计算点到线段的最短距离
  781. Args:
  782. point: 2D 点 [x, y]
  783. line_start: 线段起点 [x1, y1]
  784. line_end: 线段终点 [x2, y2]
  785. Returns:
  786. 最短距离
  787. """
  788. p = np.array(point)
  789. a = np.array(line_start)
  790. b = np.array(line_end)
  791. ab = b - a
  792. ap = p - a
  793. # 投影长度
  794. t = np.dot(ap, ab) / np.dot(ab, ab)
  795. t = np.clip(t, 0, 1)
  796. # 投影点
  797. proj = a + t * ab
  798. return np.linalg.norm(p - proj)
  799. def _filter_door_by_ground(self, door: Door3D, ground_normal: np.ndarray, ground_d: float) -> Tuple[bool, str]:
  800. """
  801. 基于地面距离过滤门
  802. Args:
  803. door: 3D 门对象
  804. ground_normal: 地面法向量
  805. ground_d: 地面平面方程的 d 参数
  806. Returns:
  807. (是否通过,拒绝原因)
  808. """
  809. # 计算门底部到地面的距离
  810. # 门的底部是 bbox 中沿法向量方向最低的点
  811. points = door.bbox_8points
  812. # 计算每个点到地面的距离
  813. distances = np.array([self._point_to_plane_distance(p, ground_normal, ground_d) for p in points])
  814. # 门底部距离 = 最小距离(最接近地面的点)
  815. door_bottom_dist = distances.min()
  816. if door_bottom_dist > self.door_ground_dist:
  817. return False, f"门底部距地面 {door_bottom_dist:.3f}m > {self.door_ground_dist}m"
  818. return True, ""
  819. def _filter_door_by_ground_puck(self, door: Door3D) -> Tuple[bool, str]:
  820. """
  821. 基于 puck 参数检查门的地面距离
  822. 使用门对应来源点位的 puck_z
  823. Args:
  824. door: 3D 门对象
  825. Returns:
  826. (是否通过,拒绝原因)
  827. """
  828. # 门底部 Z 坐标(bbox 中 Z 最小的点)
  829. door_bottom_z = door.bbox_8points[:, 2].min()
  830. # 获取门对应来源点位的 puck_z
  831. puck_z = None
  832. if door.source_uuid and door.source_uuid in self.puck_z_dict:
  833. puck_z = self.puck_z_dict[door.source_uuid]
  834. elif self.ground_z_from_puck is not None:
  835. # 回退到中位数
  836. puck_z = self.ground_z_from_puck
  837. if puck_z is None:
  838. return True, "" # 没有 puck 数据,不检查
  839. # 门底部到 puck 地面的距离
  840. dist = door_bottom_z - puck_z
  841. # 允许 0.2m 的误差(门可能离地有一点距离,或者有门槛)
  842. if abs(dist) > 0.2:
  843. return False, f"门底部距地面 {dist:.3f}m (puck_z={puck_z:.3f})"
  844. return True, ""
  845. def _filter_door_by_properties(self, door: Door3D) -> Tuple[bool, List[str]]:
  846. """根据物理特性过滤门
  847. 注意:Z 轴是高度方向(向上)
  848. 当前只检查尺寸是否为空,不对长宽高具体数值做限制
  849. """
  850. reasons = []
  851. size = door.bbox_8points.max(axis=0) - door.bbox_8points.min(axis=0)
  852. height = size[2] # Z 方向是高度
  853. width = max(size[0], size[1]) # 宽度是 X/Y 中较大的(门的平面方向)
  854. thickness = min(size[0], size[1]) # 厚度是 X/Y 中较小的(门的深度方向)
  855. # 只检查尺寸是否有效,不对具体数值做限制
  856. if height <= 0:
  857. reasons.append(f"高度无效 ({height:.2f}m)")
  858. if width <= 0:
  859. reasons.append(f"宽度无效 ({width:.2f}m)")
  860. return len(reasons) == 0, reasons
  861. def _estimate_entrance_from_poses(
  862. self,
  863. pc: o3d.geometry.PointCloud,
  864. contour: Optional[np.ndarray] = None,
  865. tri = None
  866. ) -> Optional[np.ndarray]:
  867. """
  868. 当没有检测到门时,基于点位信息估计入户门位置
  869. 核心假设:
  870. 1. 入户门在建筑边缘 → 点位到轮廓边界的距离越近越可能是入户门
  871. 2. 室内点位被墙壁遮挡 → 可见性不一致的点位可能在室内
  872. 3. 入户门通常在建筑一端 → 距离中心远的点位更可能是入户方向
  873. 评分策略:
  874. - 边界距离评分 (70%): 点位 XY 投影到轮廓边界的距离
  875. - 可见性一致性评分 (过滤): 过滤掉明显在室内的点位
  876. - 距离中心评分 (30%): 点位到几何中心的距离
  877. Args:
  878. pc: 场景点云
  879. contour: 轮廓点集 (可选,None 则内部计算)
  880. tri: Delaunay 三角网 (可选)
  881. Returns:
  882. 估计的入户门位置 (x, y, z),无法估计时返回 None
  883. """
  884. if not self.poses:
  885. print("⚠️ 没有点位数据,无法估计入户门")
  886. return None
  887. print("\n=== 基于点位信息估计入户门 ===")
  888. # ========== 步骤 1:准备数据 ==========
  889. # 重新加载 vision.txt 获取完整的点位信息(包括 visibles)
  890. vision_file = self.scene_folder / "vision.txt"
  891. if not vision_file.exists():
  892. print("⚠️ vision.txt 不存在,无法获取可见性信息")
  893. return None
  894. with open(vision_file, 'r') as f:
  895. vision_data = json.load(f)
  896. # 构建点位查找表
  897. pose_lookup = {}
  898. for loc in vision_data.get('sweepLocations', []):
  899. uuid = str(loc['uuid'])
  900. pose_lookup[uuid] = {
  901. 'id': loc['id'],
  902. 'pose': loc['pose'],
  903. 'puck': loc.get('puck', {}),
  904. 'visibles': loc.get('visibles', []),
  905. 'position': np.array([
  906. loc['pose']['translation']['x'],
  907. loc['pose']['translation']['y'],
  908. loc['pose']['translation']['z']
  909. ])
  910. }
  911. # 收集所有点位信息
  912. pose_info = []
  913. for uuid, data in pose_lookup.items():
  914. pose_info.append({
  915. 'uuid': uuid,
  916. 'id': data['id'],
  917. 'position': data['position'],
  918. 'position_xy': data['position'][:2],
  919. 'visibles': set(data['visibles']),
  920. 'puck_z': data['puck'].get('z', 0)
  921. })
  922. # ========== 步骤 2:计算轮廓(用于边界距离评分) ==========
  923. if contour is None:
  924. # 使用 XY 投影去噪后的点云计算轮廓
  925. points_xy_denoised = self._denoise_xy_projection(
  926. np.asarray(pc.points),
  927. grid_size=0.15,
  928. min_points_per_cell=5
  929. )
  930. contour, tri = self._compute_xy_contour(points_xy_denoised)
  931. print(f"轮廓点数量:{len(contour)}")
  932. # ========== 步骤 3:可见性一致性过滤 ==========
  933. # 过滤掉明显在室内的点位
  934. # 规则:如果 A 对 B 可见,A 对 C 不可见,但 AC 距离 < AB 距离 → A 在室内(被墙遮挡)
  935. filtered_poses = []
  936. filtered_out = []
  937. for i, pose_a in enumerate(pose_info):
  938. is_indoor = False
  939. for j, pose_b in enumerate(pose_info):
  940. if i == j:
  941. continue
  942. # 检查 A 对 B 是否可见
  943. b_id = pose_b['id']
  944. a_visible_to_b = b_id in pose_a['visibles']
  945. # 计算 AB 距离
  946. dist_ab = np.linalg.norm(pose_a['position_xy'] - pose_b['position_xy'])
  947. # 检查其他点位 C
  948. for k, pose_c in enumerate(pose_info):
  949. if k == i or k == j:
  950. continue
  951. c_id = pose_c['id']
  952. a_visible_to_c = c_id in pose_a['visibles']
  953. dist_ac = np.linalg.norm(pose_a['position_xy'] - pose_c['position_xy'])
  954. # 规则:A 对 B 可见,A 对 C 不可见,但 AC < AB → A 在室内
  955. if a_visible_to_b and not a_visible_to_c and dist_ac < dist_ab:
  956. is_indoor = True
  957. filtered_out.append((pose_a, f"遮挡不一致:可见 B({b_id}) 不可见 C({c_id}), 但 AC({dist_ac:.2f}) < AB({dist_ab:.2f})"))
  958. break
  959. if is_indoor:
  960. break
  961. if not is_indoor:
  962. filtered_poses.append(pose_a)
  963. print(f"\n可见性过滤:{len(pose_info)} → {len(filtered_poses)} 个点位")
  964. if filtered_out:
  965. print("被过滤点位 (可能在室内):")
  966. for p, reason in filtered_out[:5]: # 只显示前 5 个
  967. print(f" 点位 {p['uuid']}: {reason}")
  968. # 如果没有点位通过过滤,回退到所有点位
  969. if not filtered_poses:
  970. print("⚠️ 所有点位都被过滤,使用全部点位")
  971. filtered_poses = pose_info
  972. # ========== 步骤 4:计算几何中心 ==========
  973. all_positions_xy = np.array([p['position_xy'] for p in filtered_poses])
  974. centroid = np.mean(all_positions_xy, axis=0)
  975. # ========== 步骤 5:计算每个点位的评分 ==========
  976. # 边界距离评分 (70%) + 距离中心评分 (30%)
  977. # 计算每个点到轮廓边界的距离
  978. boundary_distances = []
  979. for p in filtered_poses:
  980. dist = self._compute_distance_to_contour_boundary(p['position_xy'], contour)
  981. boundary_distances.append(dist)
  982. # 计算每个点到中心的距离
  983. center_distances = np.linalg.norm(all_positions_xy - centroid, axis=1)
  984. # 归一化
  985. max_boundary_dist = max(boundary_distances) if boundary_distances else 1.0
  986. max_center_dist = center_distances.max() if center_distances.max() > 0 else 1.0
  987. # 计算评分
  988. print("\n点位评分详情:")
  989. best_score = -float('inf')
  990. best_pose = None
  991. for i, p in enumerate(filtered_poses):
  992. # 边界距离评分 (70%): 越靠近边界分越高
  993. if max_boundary_dist > 0:
  994. boundary_score = (1 - boundary_distances[i] / max_boundary_dist) * 70
  995. else:
  996. boundary_score = 35 # 所有点都在边界上
  997. # 距离中心评分 (30%): 越远离中心分越高
  998. if max_center_dist > 0:
  999. center_score = (center_distances[i] / max_center_dist) * 30
  1000. else:
  1001. center_score = 15
  1002. total_score = boundary_score + center_score
  1003. print(f" 点位 {p['uuid']} (ID={p['id']}): 边界={boundary_score:.1f} + 中心距={center_score:.1f} = {total_score:.1f} "
  1004. f"(边界距离={boundary_distances[i]:.2f}m, 中心距={center_distances[i]:.2f}m)")
  1005. if total_score > best_score:
  1006. best_score = total_score
  1007. best_pose = p
  1008. print(f"\n选择点位 {best_pose['uuid']} (综合评分={best_score:.1f})")
  1009. return best_pose['position']
  1010. def _compute_distance_to_contour_boundary(
  1011. self,
  1012. point_xy: np.ndarray,
  1013. contour: np.ndarray
  1014. ) -> float:
  1015. """
  1016. 计算点到轮廓边界的最短距离
  1017. Args:
  1018. point_xy: 2D 点 [x, y]
  1019. contour: 轮廓点集 (K, 2)
  1020. Returns:
  1021. 最短距离
  1022. """
  1023. min_dist = float('inf')
  1024. n = len(contour)
  1025. for i in range(n):
  1026. p1 = contour[i]
  1027. p2 = contour[(i + 1) % n]
  1028. dist = self._point_to_line_segment_distance(point_xy, p1, p2)
  1029. min_dist = min(min_dist, dist)
  1030. return min_dist
  1031. def _compute_distance_to_pointcloud_boundary(
  1032. self,
  1033. point: np.ndarray,
  1034. pc: o3d.geometry.PointCloud
  1035. ) -> float:
  1036. """
  1037. 计算点到点云边界的距离
  1038. Args:
  1039. point: 3D 点 [x, y, z]
  1040. pc: 点云
  1041. Returns:
  1042. 到边界的距离
  1043. """
  1044. # 投影到 XY 平面
  1045. points_xy = np.asarray(pc.points)[:, :2]
  1046. point_xy = point[:2]
  1047. # 计算点云边界(凸包)
  1048. from scipy.spatial import ConvexHull
  1049. hull = ConvexHull(points_xy)
  1050. hull_indices = hull.vertices
  1051. hull_points = points_xy[hull_indices]
  1052. # 计算点到凸包边界的最短距离
  1053. min_dist = float('inf')
  1054. n = len(hull_points)
  1055. for i in range(n):
  1056. p1 = hull_points[i]
  1057. p2 = hull_points[(i + 1) % n]
  1058. dist = self._point_to_line_segment_distance(point_xy, p1, p2)
  1059. min_dist = min(min_dist, dist)
  1060. return min_dist
  1061. def _identify_entrance_door(
  1062. self,
  1063. doors: List[Door3D],
  1064. pc: o3d.geometry.PointCloud,
  1065. contour: Optional[np.ndarray] = None,
  1066. tri = None
  1067. ) -> int:
  1068. """
  1069. 识别入户门 ID
  1070. Args:
  1071. doors: 门列表
  1072. pc: 场景点云
  1073. contour: 点云轮廓(可选,用于边界评分)
  1074. tri: Delaunay 三角网(可选,用于点在多边形内判断)
  1075. Returns:
  1076. 入户门在 doors 列表中的索引,-1 表示无法确定
  1077. """
  1078. if self.entrance_method == "json":
  1079. # 从 JSON 文件读取入户门 ID
  1080. if self.entrance_json_path is None:
  1081. print("⚠️ entrance_json_path 未指定,无法使用 json 方法")
  1082. return -1
  1083. with open(self.entrance_json_path, 'r') as f:
  1084. data = json.load(f)
  1085. entrance_door_id = data.get('entrance_door_id')
  1086. if entrance_door_id is not None:
  1087. self.entrance_door_id = entrance_door_id
  1088. print(f"\n从 JSON 读取入户门 ID: {entrance_door_id}")
  1089. # 找到对应的门(通过中心坐标匹配)
  1090. if entrance_door_id is not None:
  1091. # 尝试匹配 door_id
  1092. for i, door in enumerate(doors):
  1093. # 检查是否有 source 包含 door_id 信息
  1094. for src in door.source_detections:
  1095. if src.get('door_id') == entrance_door_id:
  1096. print(f"匹配到门 {i}")
  1097. return i
  1098. # 如果找不到,返回中心最接近 world_position 的门
  1099. world_position = data.get('world_position')
  1100. if world_position is not None:
  1101. wp = np.array(world_position)
  1102. min_dist = float('inf')
  1103. best_idx = -1
  1104. for i, door in enumerate(doors):
  1105. dist = np.linalg.norm(door.center - wp)
  1106. if dist < min_dist:
  1107. min_dist = dist
  1108. best_idx = i
  1109. if best_idx >= 0:
  1110. print(f"通过 world_position 匹配到门 {best_idx} (距离={min_dist:.3f}m)")
  1111. return best_idx
  1112. return -1
  1113. else:
  1114. # 使用评分方法
  1115. if len(doors) == 0:
  1116. # ========== 没有检测到门时的回退策略 ==========
  1117. print("\n⚠️ 未检测到任何门,使用点位信息估计入户门位置")
  1118. estimated_entrance = self._estimate_entrance_from_poses(
  1119. pc=pc,
  1120. contour=contour,
  1121. tri=tri
  1122. )
  1123. if estimated_entrance is not None:
  1124. print(f"估计入户门位置:{estimated_entrance}")
  1125. # 创建一个虚拟的入户门标记(不加入 doors 列表,仅用于可视化)
  1126. self.estimated_entrance_position = estimated_entrance
  1127. return -1
  1128. if len(doors) == 1:
  1129. return 0
  1130. # 计算轮廓(用于边界评分)
  1131. use_contour = False
  1132. if contour is None:
  1133. # 使用 XY 投影去噪后的点云计算轮廓
  1134. points_xy_denoised = self._denoise_xy_projection(
  1135. np.asarray(pc.points),
  1136. grid_size=0.15,
  1137. min_points_per_cell=5
  1138. )
  1139. contour, tri = self._compute_xy_contour(points_xy_denoised)
  1140. print(f"轮廓点数量:{len(contour)}")
  1141. use_contour = True
  1142. # 计算每个门的评分
  1143. all_centers = np.array([d.center for d in doors])
  1144. best_score = -1
  1145. best_idx = 0
  1146. print("\n入户门评分详情:")
  1147. for i, door in enumerate(doors):
  1148. size = door.bbox_8points.max(axis=0) - door.bbox_8points.min(axis=0)
  1149. height = size[2] # Z 方向是高度
  1150. width = max(size[0], size[1]) # X/Y 中较大的是宽度
  1151. # 尺寸评分(理想高 2.1m 宽 1.0m)
  1152. height_score = max(0, 15 - abs(height - 2.1) * 10)
  1153. width_score = max(0, 15 - abs(width - 1.0) * 12)
  1154. size_score = height_score + width_score
  1155. # 边缘位置评分(基于到其他门的距离)
  1156. dists_to_others = np.linalg.norm(all_centers - door.center, axis=1)
  1157. avg_dist = dists_to_others.mean()
  1158. edge_score = min(25, avg_dist * 10)
  1159. # 多视角支持
  1160. source_count = len(door.source_detections)
  1161. view_score = min(10, source_count * 3)
  1162. # 边界评分(新增:入户门应该在建筑轮廓边缘)
  1163. boundary_score = 0.0
  1164. if use_contour and contour is not None:
  1165. boundary_score = self._compute_door_boundary_score(door, contour, tri)
  1166. total = size_score + edge_score + view_score + boundary_score
  1167. # 打印每个门的评分详情
  1168. print(f" 门{i}: 尺寸={size_score:.1f} + 边缘={edge_score:.1f} + 视角={view_score:.1f} + 边界={boundary_score:.1f} = {total:.1f}")
  1169. if total > best_score:
  1170. best_score = total
  1171. best_idx = i
  1172. print(f"\n入户门选择:门 {best_idx} (得分={best_score:.1f})")
  1173. return best_idx
  1174. def _rgb_depth_to_pointcloud(
  1175. self,
  1176. rgb: np.ndarray,
  1177. depth: np.ndarray,
  1178. pose_matrix: np.ndarray
  1179. ) -> o3d.geometry.PointCloud:
  1180. """将 RGB-D 转换为世界坐标系点云"""
  1181. H, W = depth.shape
  1182. sph = Intrinsic_Spherical_NP(W, H)
  1183. px, py = np.meshgrid(np.arange(W), np.arange(H))
  1184. px_flat = px.flatten().astype(np.float64)
  1185. py_flat = py.flatten().astype(np.float64)
  1186. bx, by, bz = sph.bearing([px_flat, py_flat])
  1187. bx, by, bz = np.array(bx), np.array(by), np.array(bz)
  1188. mask = depth.flatten() > self.depth_min
  1189. d = depth.flatten()[mask]
  1190. if len(d) == 0:
  1191. return o3d.geometry.PointCloud()
  1192. pts_cam = np.stack([bx[mask] * d, by[mask] * d, bz[mask] * d], axis=1)
  1193. R_z180 = np.diag([-1.0, -1.0, 1.0])
  1194. pts_cam = pts_cam @ R_z180.T
  1195. pts_w = (pose_matrix[:3, :3] @ pts_cam.T).T + pose_matrix[:3, 3]
  1196. if rgb.shape[:2] != depth.shape:
  1197. rgb_d = cv2.resize(rgb, (W, H), interpolation=cv2.INTER_LINEAR)
  1198. else:
  1199. rgb_d = rgb
  1200. colors = rgb_d.reshape(-1, 3)[mask].astype(np.float64) / 255.0
  1201. pc = o3d.geometry.PointCloud()
  1202. pc.points = o3d.utility.Vector3dVector(pts_w)
  1203. pc.colors = o3d.utility.Vector3dVector(colors)
  1204. return pc
  1205. def _translate_point_to_ceiling(
  1206. self,
  1207. point: np.ndarray,
  1208. floor_ceiling_dist: float,
  1209. ground_normal: np.ndarray,
  1210. ground_d: float
  1211. ) -> np.ndarray:
  1212. """
  1213. 将点沿法向量方向平移,平移距离 = 地面到天花板的距离
  1214. Args:
  1215. point: 原始点
  1216. floor_ceiling_dist: 地面到天花板的距离
  1217. ground_normal: 地面法向量(单位向量)
  1218. ground_d: 地面平面方程的 d
  1219. Returns:
  1220. 平移后的点
  1221. """
  1222. # 平移向量 = floor_ceiling_dist * normal
  1223. # 这样门整体会向上移动层高的距离
  1224. # 门的底部会从地面移到天花板高度
  1225. # 门的顶部会在天花板以上(保持门的原始高度)
  1226. translation = floor_ceiling_dist * ground_normal
  1227. return point + translation
  1228. def _create_door_visualization(
  1229. self,
  1230. door: Door3D,
  1231. ground_normal: np.ndarray,
  1232. ground_d: float,
  1233. floor_ceiling_dist: float,
  1234. is_entrance: bool = False,
  1235. translate_to_ceiling: bool = True
  1236. ) -> o3d.geometry.PointCloud:
  1237. """
  1238. 创建门的可视化点云(带边框)
  1239. Args:
  1240. door: 3D 门对象
  1241. ground_normal: 地面法向量
  1242. ground_d: 地面平面方程的 d
  1243. floor_ceiling_dist: 地面到天花板的距离
  1244. is_entrance: 是否入户门
  1245. translate_to_ceiling: 是否平移到天花板
  1246. Returns:
  1247. 门点云(带颜色)
  1248. """
  1249. # 门是平面矩形,bbox_8points 中:
  1250. # - 索引 0-3:底面 4 个点(Z 最小,靠近地面)
  1251. # - 索引 4-7:顶面 4 个点(Z 最大,靠近门顶)
  1252. # 投影到天花板只需要 4 个点,取底面 4 个点或顶面 4 个点即可
  1253. # 直接取底面 4 个点(索引 0-3)
  1254. rect_points = door.bbox_8points[:4] # 底面 4 个点构成矩形
  1255. # 生成矩形 4 条边上的点
  1256. edge_points = []
  1257. edges = [(0, 1), (1, 2), (2, 3), (3, 0)] # 4 条边
  1258. for i, j in edges:
  1259. p_start = rect_points[i]
  1260. p_end = rect_points[j]
  1261. # 在边上生成 15 个点(更密集的轮廓)
  1262. for t in np.linspace(0, 1, 15):
  1263. edge_points.append(p_start + t * (p_end - p_start))
  1264. points = np.array(edge_points)
  1265. if translate_to_ceiling:
  1266. # 将每个点平移到天花板高度
  1267. points = np.array([
  1268. self._translate_point_to_ceiling(p, floor_ceiling_dist, ground_normal, ground_d)
  1269. for p in points
  1270. ])
  1271. # 创建点云
  1272. pc = o3d.geometry.PointCloud()
  1273. pc.points = o3d.utility.Vector3dVector(points)
  1274. # 设置颜色:入户门红色,其他门绿色
  1275. if is_entrance:
  1276. colors = np.array([[1.0, 0.0, 0.0]] * len(points)) # 红色
  1277. else:
  1278. colors = np.array([[0.0, 1.0, 0.0]] * len(points)) # 绿色
  1279. pc.colors = o3d.utility.Vector3dVector(colors)
  1280. return pc
  1281. def _create_door_visualization_with_color(
  1282. self,
  1283. door: Door3D,
  1284. ground_normal: np.ndarray,
  1285. ground_d: float,
  1286. floor_ceiling_dist: float,
  1287. color: np.ndarray,
  1288. translate_to_ceiling: bool = True
  1289. ) -> o3d.geometry.PointCloud:
  1290. """
  1291. 创建门的可视化点云(带边框)- 指定颜色版本
  1292. Args:
  1293. door: 3D 门对象
  1294. ground_normal: 地面法向量
  1295. ground_d: 地面平面方程的 d
  1296. floor_ceiling_dist: 地面到天花板的距离
  1297. color: RGB 颜色 [R, G, B]
  1298. translate_to_ceiling: 是否平移到天花板
  1299. Returns:
  1300. 门点云(带颜色)
  1301. """
  1302. # 门是平面矩形,bbox_8points 中:
  1303. # - 索引 0-3:底面 4 个点(Z 最小,靠近地面)
  1304. # - 索引 4-7:顶面 4 个点(Z 最大,靠近门顶)
  1305. # 投影到天花板只需要 4 个点,取底面 4 个点或顶面 4 个点即可
  1306. # 直接取底面 4 个点(索引 0-3)
  1307. rect_points = door.bbox_8points[:4] # 底面 4 个点构成矩形
  1308. # 生成矩形 4 条边上的点
  1309. edge_points = []
  1310. edges = [(0, 1), (1, 2), (2, 3), (3, 0)] # 4 条边
  1311. for i, j in edges:
  1312. p_start = rect_points[i]
  1313. p_end = rect_points[j]
  1314. # 在边上生成 15 个点(更密集的轮廓)
  1315. for t in np.linspace(0, 1, 15):
  1316. edge_points.append(p_start + t * (p_end - p_start))
  1317. points = np.array(edge_points)
  1318. if translate_to_ceiling:
  1319. # 将每个点平移到天花板高度
  1320. points = np.array([
  1321. self._translate_point_to_ceiling(p, floor_ceiling_dist, ground_normal, ground_d)
  1322. for p in points
  1323. ])
  1324. # 创建点云
  1325. pc = o3d.geometry.PointCloud()
  1326. pc.points = o3d.utility.Vector3dVector(points)
  1327. # 设置颜色
  1328. colors = np.array([color] * len(points))
  1329. pc.colors = o3d.utility.Vector3dVector(colors)
  1330. return pc
  1331. def _create_pose_visualization(
  1332. self,
  1333. pose: PoseData,
  1334. ground_normal: np.ndarray,
  1335. ground_d: float,
  1336. floor_ceiling_dist: float,
  1337. translate_to_ceiling: bool = True
  1338. ) -> o3d.geometry.PointCloud:
  1339. """
  1340. 创建拍摄点位的可视化
  1341. Args:
  1342. pose: 位姿数据
  1343. ground_normal: 地面法向量
  1344. ground_d: 地面平面方程的 d
  1345. floor_ceiling_dist: 地面到天花板的距离
  1346. translate_to_ceiling: 是否平移到天花板
  1347. Returns:
  1348. 点位点云(蓝色)
  1349. """
  1350. point = np.array([
  1351. pose.translation['x'],
  1352. pose.translation['y'],
  1353. pose.translation['z']
  1354. ])
  1355. if translate_to_ceiling:
  1356. point = self._translate_point_to_ceiling(point, floor_ceiling_dist, ground_normal, ground_d)
  1357. # 创建点云(单个点)
  1358. pc = o3d.geometry.PointCloud()
  1359. pc.points = o3d.utility.Vector3dVector([point])
  1360. pc.colors = o3d.utility.Vector3dVector([[0.0, 0.0, 1.0]]) # 蓝色
  1361. return pc
  1362. def process_and_visualize(
  1363. self,
  1364. output_ply_path: str,
  1365. translate_to_ceiling: bool = True
  1366. ):
  1367. """
  1368. 处理场景并生成可视化 PLY 文件
  1369. Args:
  1370. output_ply_path: 输出 PLY 文件路径
  1371. translate_to_ceiling: 是否将门和点位平移到天花板
  1372. """
  1373. # 创建输出目录
  1374. self.output_folder.mkdir(parents=True, exist_ok=True)
  1375. rgb_files = sorted(
  1376. self.rgb_folder.glob("*.jpg"),
  1377. key=lambda x: int(x.stem)
  1378. )
  1379. if not rgb_files:
  1380. raise FileNotFoundError(f"在 {self.rgb_folder} 中未找到 RGB 图像")
  1381. print(f"找到 {len(rgb_files)} 张全景图,开始处理...")
  1382. # ========== 第一步:收集所有点云和门检测 ==========
  1383. combined_pc = o3d.geometry.PointCloud()
  1384. door_candidates = []
  1385. for rgb_file in tqdm(rgb_files, desc="检测门"):
  1386. idx = rgb_file.stem
  1387. pose = self.poses.get(idx)
  1388. if pose is None:
  1389. continue
  1390. depth_path = self.depth_folder / f"{idx}.png"
  1391. if not depth_path.exists():
  1392. continue
  1393. rgb = cv2.cvtColor(cv2.imread(str(rgb_file)), cv2.COLOR_BGR2RGB)
  1394. depth = cv2.imread(str(depth_path), cv2.IMREAD_UNCHANGED).astype(np.float32) / self.depth_scale
  1395. pose_matrix = self._build_pose_matrix(pose)
  1396. # YOLOE 检测
  1397. mask_3d_points, scores = self.detect_single_image(
  1398. str(rgb_file), depth, pose_matrix
  1399. )
  1400. # 收集 3D 门候选
  1401. for i, pts_3d in enumerate(mask_3d_points):
  1402. if len(pts_3d) > 10:
  1403. # 先过滤离群点
  1404. pts_3d_filtered = self._filter_outliers(pts_3d, std_thresh=2.0)
  1405. # 再根据深度一致性过滤
  1406. pts_3d_filtered = self._filter_door_points_by_depth(pts_3d_filtered)
  1407. bbox_min, bbox_max = self._axis_aligned_bbox(pts_3d_filtered)
  1408. door_candidates.append({
  1409. 'bbox_min': bbox_min,
  1410. 'bbox_max': bbox_max,
  1411. 'points_3d': pts_3d_filtered,
  1412. 'source': {
  1413. 'image': rgb_file.name,
  1414. 'score': scores[i] if i < len(scores) else 0.0
  1415. },
  1416. 'source_uuid': idx # 保存来源点位 UUID
  1417. })
  1418. # RGB-D 转点云
  1419. pc = self._rgb_depth_to_pointcloud(rgb, depth, pose_matrix)
  1420. combined_pc += pc
  1421. # 下采样
  1422. print(f"\n融合前点数:{len(combined_pc.points)}")
  1423. combined_pc = combined_pc.voxel_down_sample(self.voxel_size)
  1424. print(f"融合后点数:{len(combined_pc.points)}")
  1425. # ========== 第二步:地面和天花板拟合 ==========
  1426. ground_normal, ground_d = self._fit_ground_plane_ransac(combined_pc)
  1427. self.ground_d = ground_d # 保存用于后续计算
  1428. ceiling_normal, ceiling_d, floor_ceiling_dist = self._fit_ceiling_plane(
  1429. combined_pc, ground_normal, ground_d
  1430. )
  1431. # ========== 第三步:3D 门合并和过滤 ==========
  1432. print(f"\n3D 门候选:{len(door_candidates)}")
  1433. # 打印每个候选的尺寸
  1434. for i, c in enumerate(door_candidates):
  1435. size = c['bbox_max'] - c['bbox_min']
  1436. print(f" 候选 {i}: 尺寸=[{size[0]:.3f}, {size[1]:.3f}, {size[2]:.3f}], 中心={((c['bbox_min']+c['bbox_max'])/2).round(3)}")
  1437. doors_3d = self._merge_3d_doors(door_candidates)
  1438. print(f"合并后门数量:{len(doors_3d)}")
  1439. # 过滤 - 只检查物理特性,不检查地面距离
  1440. # 地面距离只用于入户门候选筛选
  1441. print("\n过滤 3D 门 (只检查物理特性)...")
  1442. valid_doors = [] # 通过物理特性过滤的门
  1443. filtered_doors = [] # 未通过物理特性过滤的门
  1444. for door in doors_3d:
  1445. # 物理特性过滤(高度、宽度、厚度)
  1446. passed_prop, prop_reasons = self._filter_door_by_properties(door)
  1447. if not passed_prop:
  1448. filtered_doors.append((door, prop_reasons))
  1449. continue
  1450. valid_doors.append(door)
  1451. print(f"通过物理特性过滤:{len(valid_doors)} 个门")
  1452. if len(filtered_doors) > 0:
  1453. print(f"被过滤(物理特性):{len(filtered_doors)} 个门")
  1454. for door, reasons in filtered_doors:
  1455. print(f" 门{door.id} (中心={door.center.round(2)}):")
  1456. for reason in reasons:
  1457. print(f" - {reason}")
  1458. # 检查地面距离,用于入户门候选筛选
  1459. print("\n检查地面距离 (用于入户门候选筛选)...")
  1460. ground_valid_doors = [] # 通过地面距离过滤的门(入户门候选)
  1461. # 优先使用 puck 参数进行地面距离检查
  1462. use_puck = (self.ground_z_from_puck is not None)
  1463. for door in valid_doors:
  1464. if use_puck:
  1465. passed_ground, ground_reason = self._filter_door_by_ground_puck(door)
  1466. else:
  1467. passed_ground, ground_reason = self._filter_door_by_ground(
  1468. door, ground_normal, ground_d
  1469. )
  1470. if passed_ground:
  1471. ground_valid_doors.append(door)
  1472. else:
  1473. print(f" 门{door.id} (中心={door.center.round(2)}): {ground_reason}")
  1474. if use_puck:
  1475. print(f"通过地面距离过滤(puck_z={self.ground_z_from_puck:.3f}):{len(ground_valid_doors)} 个门")
  1476. # 调试输出:显示每个门使用的 puck_z
  1477. for door in valid_doors:
  1478. puck_z_used = None
  1479. if door.source_uuid and door.source_uuid in self.puck_z_dict:
  1480. puck_z_used = self.puck_z_dict[door.source_uuid]
  1481. print(f" 门{door.id}: source_uuid={door.source_uuid}, puck_z={puck_z_used:.3f}")
  1482. else:
  1483. print(f" 门{door.id}: 使用中位数 puck_z={self.ground_z_from_puck:.3f}")
  1484. else:
  1485. print(f"通过地面距离过滤(RANSAC):{len(ground_valid_doors)} 个门")
  1486. # ========== 第四步:识别入户门 ==========
  1487. print("\n" + "=" * 40)
  1488. print("入户门识别")
  1489. print("=" * 40)
  1490. # 入户门候选只从通过地面距离过滤的门中选择
  1491. entrance_idx = self._identify_entrance_door(
  1492. ground_valid_doors,
  1493. combined_pc,
  1494. contour=None, # None 表示在方法内部计算
  1495. tri=None
  1496. )
  1497. # ========== 第五步:创建可视化 ==========
  1498. print("\n创建可视化点云...")
  1499. # 1. 原始场景点云(降采样用于可视化)
  1500. vis_pc = combined_pc.voxel_down_sample(0.05)
  1501. # 2. 门的可视化点云 - 显示所有门(包括被过滤的)
  1502. door_vis_points = []
  1503. door_vis_colors = []
  1504. # 颜色定义
  1505. COLOR_ENTRANCE = np.array([1.0, 0.0, 0.0]) # 红色 - 入户门
  1506. COLOR_VALID = np.array([0.0, 1.0, 0.0]) # 绿色 - 有效门(通过物理特性过滤)
  1507. COLOR_FILTERED = np.array([1.0, 1.0, 0.0]) # 黄色 - 被过滤的门
  1508. print(f"\n可视化门:")
  1509. # 找到入户门在 valid_doors 中的索引
  1510. entrance_door_in_valid = None
  1511. if entrance_idx >= 0 and entrance_idx < len(ground_valid_doors):
  1512. entrance_door = ground_valid_doors[entrance_idx]
  1513. # 找到这个门在 valid_doors 中的索引
  1514. for i, door in enumerate(valid_doors):
  1515. if door.id == entrance_door.id:
  1516. entrance_door_in_valid = i
  1517. break
  1518. # 显示有效门
  1519. for i, door in enumerate(valid_doors):
  1520. is_entrance = (i == entrance_door_in_valid)
  1521. if is_entrance:
  1522. color = COLOR_ENTRANCE
  1523. label = f"入户门 (红色)"
  1524. else:
  1525. color = COLOR_VALID
  1526. label = f"有效门 (绿色)"
  1527. door_pc = self._create_door_visualization_with_color(
  1528. door, ground_normal, ground_d, floor_ceiling_dist, color,
  1529. translate_to_ceiling=translate_to_ceiling
  1530. )
  1531. pts = np.asarray(door_pc.points)
  1532. door_vis_points.append(pts)
  1533. door_vis_colors.append(np.asarray(door_pc.colors))
  1534. print(f" {label}: 中心={door.center.round(3)}, 点数={len(pts)}")
  1535. # 显示被过滤的门(黄色)
  1536. for door, reasons in filtered_doors:
  1537. door_pc = self._create_door_visualization_with_color(
  1538. door, ground_normal, ground_d, floor_ceiling_dist, COLOR_FILTERED,
  1539. translate_to_ceiling=translate_to_ceiling
  1540. )
  1541. pts = np.asarray(door_pc.points)
  1542. door_vis_points.append(pts)
  1543. door_vis_colors.append(np.asarray(door_pc.colors))
  1544. print(f" 被过滤的门 (黄色): 中心={door.center.round(3)}, 点数={len(pts)}")
  1545. for reason in reasons:
  1546. print(f" - {reason}")
  1547. if door_vis_points:
  1548. door_vis_pc = o3d.geometry.PointCloud()
  1549. door_vis_pc.points = o3d.utility.Vector3dVector(np.vstack(door_vis_points))
  1550. door_vis_pc.colors = o3d.utility.Vector3dVector(np.vstack(door_vis_colors))
  1551. # 3. 拍摄点位可视化
  1552. pose_vis_points = []
  1553. pose_vis_colors = []
  1554. for pose in self.poses.values():
  1555. pose_pc = self._create_pose_visualization(
  1556. pose, ground_normal, ground_d, floor_ceiling_dist,
  1557. translate_to_ceiling=translate_to_ceiling
  1558. )
  1559. pose_vis_points.append(np.asarray(pose_pc.points))
  1560. pose_vis_colors.append(np.asarray(pose_pc.colors))
  1561. if pose_vis_points:
  1562. pose_vis_pc = o3d.geometry.PointCloud()
  1563. pose_vis_pc.points = o3d.utility.Vector3dVector(np.vstack(pose_vis_points))
  1564. pose_vis_pc.colors = o3d.utility.Vector3dVector(np.vstack(pose_vis_colors))
  1565. # 4. 估计的入户门位置可视化(当没有检测到门时)
  1566. entrance_vis_pc = None
  1567. if self.estimated_entrance_position is not None:
  1568. print("\n可视化估计的入户门位置...")
  1569. entrance_point = self.estimated_entrance_position.copy()
  1570. # 平移到天花板高度
  1571. if translate_to_ceiling:
  1572. entrance_point = self._translate_point_to_ceiling(
  1573. entrance_point, floor_ceiling_dist, ground_normal, ground_d
  1574. )
  1575. # 创建一个较大的红色球体标记
  1576. from scipy.spatial import ConvexHull
  1577. # 生成球面点
  1578. phi = np.linspace(0, np.pi, 10)
  1579. theta = np.linspace(0, 2 * np.pi, 20)
  1580. phi, theta = np.meshgrid(phi, theta)
  1581. r = 0.15 # 球体半径
  1582. x = entrance_point[0] + r * np.sin(phi) * np.cos(theta)
  1583. y = entrance_point[1] + r * np.sin(phi) * np.sin(theta)
  1584. z = entrance_point[2] + r * np.cos(phi)
  1585. sphere_points = np.column_stack([x.flatten(), y.flatten(), z.flatten()])
  1586. entrance_vis_pc = o3d.geometry.PointCloud()
  1587. entrance_vis_pc.points = o3d.utility.Vector3dVector(sphere_points)
  1588. entrance_colors = np.array([[1.0, 0.0, 0.0]] * len(sphere_points)) # 红色
  1589. entrance_vis_pc.colors = o3d.utility.Vector3dVector(entrance_colors)
  1590. print(f" 估计入户门位置 (红色球体): {entrance_point.round(3)}")
  1591. # ========== 第六步:合并并保存 ==========
  1592. # 创建一个完整的场景点云
  1593. full_scene = o3d.geometry.PointCloud()
  1594. # 添加原始点云(灰色)
  1595. full_scene += vis_pc
  1596. # 添加门点云
  1597. if door_vis_points:
  1598. full_scene += door_vis_pc
  1599. # 添加拍摄点位点云
  1600. if pose_vis_points:
  1601. full_scene += pose_vis_pc
  1602. # 添加估计的入户门位置可视化
  1603. if entrance_vis_pc is not None:
  1604. full_scene += entrance_vis_pc
  1605. # 保存 PLY 文件
  1606. # 创建输出目录
  1607. os.makedirs(os.path.dirname(output_ply_path), exist_ok=True)
  1608. o3d.io.write_point_cloud(output_ply_path, full_scene)
  1609. print(f"\n可视化结果已保存:{output_ply_path}")
  1610. # ========== 打印汇总 ==========
  1611. print("\n" + "=" * 50)
  1612. print("可视化汇总")
  1613. print("=" * 50)
  1614. print(f"原始点云点数:{len(vis_pc.points)}")
  1615. print(f"有效门数量:{len(valid_doors)}")
  1616. if entrance_idx >= 0:
  1617. print(f"入户门索引:{entrance_idx}")
  1618. elif self.estimated_entrance_position is not None:
  1619. print(f"估计入户门位置:{self.estimated_entrance_position.round(3)} (红色球体标记)")
  1620. else:
  1621. print("入户门:未确定")
  1622. print(f"拍摄点位数量:{len(self.poses)}")
  1623. print(f"门点云点数:{sum(len(p) for p in door_vis_points) if door_vis_points else 0}")
  1624. print(f"点位点云点数:{sum(len(p) for p in pose_vis_points) if pose_vis_points else 0}")
  1625. if entrance_vis_pc is not None:
  1626. print(f"估计入户门标记点数:{len(entrance_vis_pc.points)}")
  1627. print(f"总点数:{len(full_scene.points)}")
  1628. if translate_to_ceiling:
  1629. print(f"\n门和点位已平移到天花板高度 (平移距离={floor_ceiling_dist:.3f}m)")
  1630. else:
  1631. print("\n门和点位保持原始位置")
  1632. print("\n颜色说明:")
  1633. print(" - 原始点云:RGB 颜色")
  1634. print(" - 入户门:红色(门边框 / 估计位置球体)")
  1635. print(" - 有效门:绿色(通过物理特性过滤)")
  1636. print(" - 被过滤的门:黄色(物理特性不符合)")
  1637. print(" - 拍摄点位:蓝色")
  1638. print("=" * 50)
  1639. return full_scene, valid_doors, entrance_idx
  1640. # ============================================================================
  1641. # 主函数
  1642. # ============================================================================
  1643. def main():
  1644. parser = argparse.ArgumentParser(
  1645. description="3D 场景可视化 - RANSAC 地面拟合 + 门平移到天花板",
  1646. formatter_class=argparse.RawDescriptionHelpFormatter,
  1647. epilog="""
  1648. 示例:
  1649. # 处理单个场景
  1650. python visualize_scene_3d.py -s scene0001 --vis-ply
  1651. # 指定入户门 JSON 文件
  1652. python visualize_scene_3d.py -s scene0001 --vis-ply \\
  1653. --entrance-method json \\
  1654. --entrance-json /path/to/entrance.json
  1655. # 调整地面拟合参数
  1656. python visualize_scene_3d.py -s scene0001 --vis-ply \\
  1657. --ground-ransac-dist 0.03 \\
  1658. --ceiling-percentile 98
  1659. # 不平移到天花板(保持原始位置)
  1660. python visualize_scene_3d.py -s scene0001 --vis-ply --no-translate
  1661. # 调整门过滤阈值
  1662. python visualize_scene_3d.py -s scene0001 --vis-ply \\
  1663. --door-ground-dist 0.15 \\
  1664. --door-height-min 1.2
  1665. # ========== 批处理模式 ==========
  1666. # 处理 ten_pano_demo 目录下所有场景
  1667. python visualize_scene_3d.py --batch-base /path/to/ten_pano_demo --vis-ply
  1668. # 指定批处理输出目录
  1669. python visualize_scene_3d.py --batch-base /path/to/ten_pano_demo \\
  1670. --batch-output /path/to/output --vis-ply
  1671. # 批处理 + 不平移
  1672. python visualize_scene_3d.py --batch-base /path/to/ten_pano_demo \\
  1673. --vis-ply --no-translate
  1674. """
  1675. )
  1676. parser.add_argument("--scene", "-s", type=str, default="scene0001",
  1677. help="场景文件夹")
  1678. parser.add_argument("--model", "-m", type=str, default="yoloe-26x-seg.pt",
  1679. help="YOLOE 模型路径")
  1680. parser.add_argument("--conf", type=float, default=0.35,
  1681. help="置信度阈值")
  1682. parser.add_argument("--iou", type=float, default=0.45,
  1683. help="NMS IoU 阈值")
  1684. parser.add_argument("--voxel-size", type=float, default=0.03,
  1685. help="点云体素大小")
  1686. parser.add_argument("--depth-scale", type=float, default=256.0,
  1687. help="深度图缩放因子")
  1688. # 地面/天花板参数
  1689. parser.add_argument("--ground-ransac-dist", type=float, default=0.05,
  1690. help="RANSAC 地面拟合距离阈值")
  1691. parser.add_argument("--ceiling-percentile", type=float, default=95.0,
  1692. help="天花板点分位数")
  1693. # 门过滤参数
  1694. parser.add_argument("--door-ground-dist", type=float, default=0.1,
  1695. help="门底部距地面最大距离")
  1696. parser.add_argument("--door-height-min", type=float, default=1.0,
  1697. help="门最小高度")
  1698. parser.add_argument("--door-height-max", type=float, default=3.0,
  1699. help="门最大高度")
  1700. parser.add_argument("--door-width-min", type=float, default=0.3,
  1701. help="门最小宽度")
  1702. parser.add_argument("--door-width-max", type=float, default=3.0,
  1703. help="门最大宽度")
  1704. parser.add_argument("--door-thickness-max", type=float, default=0.5,
  1705. help="门最大厚度")
  1706. # 3D 门合并参数
  1707. parser.add_argument("--merge-iou-thresh", type=float, default=0.1,
  1708. help="门合并 IoU 阈值(默认 0.1,降低敏感度)")
  1709. parser.add_argument("--merge-dist-thresh", type=float, default=0.3,
  1710. help="门合并中心距离阈值(默认 0.3 米)")
  1711. parser.add_argument("--merge-z-overlap-thresh", type=float, default=0.5,
  1712. help="门合并 Z 方向重叠度阈值(默认 0.5,0-1 范围)")
  1713. # 入户门参数
  1714. parser.add_argument("--entrance-method", type=str, default="score",
  1715. choices=["score", "json"],
  1716. help="入户门判定方法")
  1717. parser.add_argument("--entrance-json", type=str, default=None,
  1718. help="入户门 JSON 文件路径")
  1719. # 可视化参数
  1720. parser.add_argument("--vis-ply", action="store_true",
  1721. help="保存可视化 PLY 文件")
  1722. parser.add_argument("--vis-output", type=str, default=None,
  1723. help="输出 PLY 文件路径")
  1724. parser.add_argument("--no-translate", action="store_true",
  1725. help="不平移到天花板,保持原始位置")
  1726. # 批处理参数
  1727. parser.add_argument("--batch-base", type=str, default=None,
  1728. help="批处理基础目录(如 ten_pano_demo),自动处理所有子场景")
  1729. parser.add_argument("--batch-output", type=str, default=None,
  1730. help="批处理输出基础目录,默认在基础目录下创建 output 文件夹")
  1731. args = parser.parse_args()
  1732. # ========== 批处理模式 ==========
  1733. if args.batch_base:
  1734. process_all_scenes(args)
  1735. return
  1736. # ========== 单场景模式 ==========
  1737. if not Path(args.scene).exists():
  1738. print(f"❌ 场景文件夹不存在:{args.scene}")
  1739. return
  1740. # 确定输出路径
  1741. if args.vis_output is None:
  1742. output_ply = Path(args.scene) / "output" / "visualization.ply"
  1743. else:
  1744. output_ply = Path(args.vis_output)
  1745. processor = SceneVisualizer(
  1746. scene_folder=args.scene,
  1747. model_path=args.model,
  1748. conf=args.conf,
  1749. iou=args.iou,
  1750. voxel_size=args.voxel_size,
  1751. depth_scale=args.depth_scale,
  1752. ground_ransac_dist=args.ground_ransac_dist,
  1753. ceiling_percentile=args.ceiling_percentile,
  1754. door_ground_dist=args.door_ground_dist,
  1755. door_height_min=args.door_height_min,
  1756. door_height_max=args.door_height_max,
  1757. door_width_min=args.door_width_min,
  1758. door_width_max=args.door_width_max,
  1759. door_thickness_max=args.door_thickness_max,
  1760. merge_iou_thresh=args.merge_iou_thresh,
  1761. merge_dist_thresh=args.merge_dist_thresh,
  1762. merge_z_overlap_thresh=args.merge_z_overlap_thresh,
  1763. entrance_method=args.entrance_method,
  1764. entrance_json_path=args.entrance_json,
  1765. )
  1766. if args.vis_ply:
  1767. processor.process_and_visualize(
  1768. output_ply_path=str(output_ply),
  1769. translate_to_ceiling=not args.no_translate
  1770. )
  1771. else:
  1772. print("⚠️ 未指定 --vis-ply,不进行可视化")
  1773. print("请使用 --vis-ply 参数来生成可视化 PLY 文件")
  1774. def process_all_scenes(args):
  1775. """
  1776. 批处理模式:处理基础目录下的所有场景
  1777. 目录结构假设:
  1778. base_dir/
  1779. ├── depth/
  1780. │ ├── scene1/
  1781. │ │ └── depthmap/
  1782. │ └── scene2/
  1783. ├── rgb/
  1784. │ ├── scene1/
  1785. │ └── scene2/
  1786. └── vision/
  1787. ├── scene1/
  1788. └── scene2/
  1789. """
  1790. from pathlib import Path
  1791. from tqdm import tqdm
  1792. base_dir = Path(args.batch_base)
  1793. if not base_dir.exists():
  1794. print(f"❌ 批处理基础目录不存在:{base_dir}")
  1795. return
  1796. # 确定输出目录
  1797. if args.batch_output:
  1798. output_base = Path(args.batch_output)
  1799. else:
  1800. output_base = base_dir / "output"
  1801. # 获取所有场景(从 depth 目录获取)
  1802. depth_dir = base_dir / "depth"
  1803. if not depth_dir.exists():
  1804. print(f"❌ depth 目录不存在:{depth_dir}")
  1805. return
  1806. scene_names = sorted([d.name for d in depth_dir.iterdir() if d.is_dir()])
  1807. if not scene_names:
  1808. print(f"⚠️ 未找到任何场景")
  1809. return
  1810. print(f"找到 {len(scene_names)} 个场景:{scene_names}")
  1811. print(f"输出目录:{output_base}")
  1812. print("=" * 60)
  1813. # 创建输出基础目录
  1814. output_base.mkdir(parents=True, exist_ok=True)
  1815. for i, scene_name in enumerate(tqdm(scene_names, desc="总体进度")):
  1816. print(f"\n[{i+1}/{len(scene_names)}] 处理场景:{scene_name}")
  1817. print("-" * 40)
  1818. # 构建各路径
  1819. scene_rgb_dir = base_dir / "rgb" / scene_name
  1820. scene_depth_dir = base_dir / "depth" / scene_name / "depthmap"
  1821. scene_vision_dir = base_dir / "vision" / scene_name
  1822. scene_vision_file = scene_vision_dir / "vision.txt"
  1823. # 检查路径是否存在
  1824. if not scene_rgb_dir.exists():
  1825. print(f"⚠️ 跳过 {scene_name}: rgb 目录不存在")
  1826. continue
  1827. if not scene_depth_dir.exists():
  1828. print(f"⚠️ 跳过 {scene_name}: depth 目录不存在")
  1829. continue
  1830. if not scene_vision_file.exists():
  1831. print(f"⚠️ 跳过 {scene_name}: vision.txt 不存在")
  1832. continue
  1833. # 输出路径
  1834. scene_output_dir = output_base / scene_name
  1835. output_ply = scene_output_dir / "visualization.ply"
  1836. # SceneVisualizer 期望的目录结构:
  1837. # scene_folder/
  1838. # pano_img/*.jpg
  1839. # depth_img/depthmap/*.png
  1840. # vision.txt
  1841. # 所以需要创建一个临时结构或直接传递 scene_depth_dir 的父目录的父目录
  1842. # 但实际上我们应该使用 scene_depth_dir 作为 depth 目录
  1843. # 修改策略:传递 depth/scene_name 目录,因为它包含 depthmap
  1844. # 而 rgb 和 vision 需要单独指定 - 但 SceneVisualizer 不支持
  1845. # 解决方案:使用符号链接或者修改 SceneVisualizer 支持自定义子目录名
  1846. # 简单方案:创建一个临时目录结构
  1847. # 创建临时目录结构
  1848. import tempfile
  1849. import shutil
  1850. temp_dir = tempfile.mkdtemp(prefix=f"scene_{scene_name}_")
  1851. temp_scene = Path(temp_dir) / scene_name
  1852. temp_scene.mkdir(parents=True, exist_ok=True)
  1853. # 创建符号链接
  1854. # SceneVisualizer 期望的结构:
  1855. # scene_folder/pano_img/*.jpg
  1856. # scene_folder/depth_img/{idx}.png
  1857. # scene_folder/vision.txt
  1858. (temp_scene / "pano_img").symlink_to(scene_rgb_dir)
  1859. (temp_scene / "depth_img").symlink_to(scene_depth_dir) # 直接指向 depthmap 目录
  1860. (temp_scene / "vision.txt").symlink_to(scene_vision_file)
  1861. try:
  1862. # 创建处理器
  1863. processor = SceneVisualizer(
  1864. scene_folder=str(temp_scene),
  1865. model_path=args.model,
  1866. conf=args.conf,
  1867. iou=args.iou,
  1868. voxel_size=args.voxel_size,
  1869. depth_scale=args.depth_scale,
  1870. ground_ransac_dist=args.ground_ransac_dist,
  1871. ceiling_percentile=args.ceiling_percentile,
  1872. door_ground_dist=args.door_ground_dist,
  1873. door_height_min=args.door_height_min,
  1874. door_height_max=args.door_height_max,
  1875. door_width_min=args.door_width_min,
  1876. door_width_max=args.door_width_max,
  1877. door_thickness_max=args.door_thickness_max,
  1878. merge_iou_thresh=args.merge_iou_thresh,
  1879. merge_dist_thresh=args.merge_dist_thresh,
  1880. merge_z_overlap_thresh=args.merge_z_overlap_thresh,
  1881. entrance_method=args.entrance_method,
  1882. entrance_json_path=args.entrance_json,
  1883. )
  1884. # 处理并可视化
  1885. if args.vis_ply:
  1886. try:
  1887. processor.process_and_visualize(
  1888. output_ply_path=str(output_ply),
  1889. translate_to_ceiling=not args.no_translate
  1890. )
  1891. print(f"✓ 保存:{output_ply}")
  1892. except Exception as e:
  1893. print(f"❌ 处理失败:{e}")
  1894. else:
  1895. print("⚠️ 未指定 --vis-ply,跳过可视化")
  1896. finally:
  1897. # 清理临时目录
  1898. import shutil
  1899. shutil.rmtree(temp_dir, ignore_errors=True)
  1900. if __name__ == "__main__":
  1901. main()