export_entrance_position.py 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 入户门位置导出脚本
  5. 功能:
  6. 1. 检测场景中的门并识别入户门
  7. 2. 输出入户门在世界坐标系中的位置
  8. 3. 如果未检测到门,基于点位信息估计入户门位置
  9. 4. 输出 JSON 格式结果
  10. """
  11. import os
  12. import sys
  13. import json
  14. import argparse
  15. from pathlib import Path
  16. from dataclasses import dataclass
  17. from typing import Dict, List, Optional, Tuple, Any
  18. import cv2
  19. import numpy as np
  20. import open3d as o3d
  21. from tqdm import tqdm
  22. from ultralytics import YOLOE
  23. from camera_spherical import Intrinsic_Spherical_NP
  24. from scipy.spatial import Delaunay
  25. # ============================================================================
  26. # 数据类
  27. # ============================================================================
  28. @dataclass
  29. class PoseData:
  30. """位姿数据"""
  31. uuid: str
  32. rotation: Dict[str, float] # w, x, y, z
  33. translation: Dict[str, float] # x, y, z
  34. pose_id: int # 点位 ID
  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
  43. # ============================================================================
  44. # 入户门检测器
  45. # ============================================================================
  46. class EntranceDoorDetector:
  47. """
  48. 入户门检测器:
  49. - 检测场景中的门
  50. - 识别入户门
  51. - 输出入户门位置 JSON
  52. """
  53. # 门检测类别
  54. DOOR_CLASSES = [
  55. "door", "indoor door", "exterior door",
  56. "wooden door", "metal door", "glass door", "double door",
  57. "single door", "open door", "closed door"
  58. ]
  59. def __init__(
  60. self,
  61. scene_folder: str,
  62. model_path: str = "yoloe-26x-seg.pt",
  63. conf: float = 0.35,
  64. iou: float = 0.45,
  65. voxel_size: float = 0.03,
  66. depth_scale: float = 256.0,
  67. depth_min: float = 0.02,
  68. # 地面/天花板拟合参数
  69. ground_ransac_dist: float = 0.05,
  70. ground_ransac_prob: float = 0.99,
  71. ceiling_percentile: float = 95.0,
  72. # 门过滤参数
  73. door_ground_dist: float = 0.1,
  74. door_height_min: float = 1.0,
  75. door_height_max: float = 3.0,
  76. door_width_min: float = 0.3,
  77. door_width_max: float = 3.0,
  78. door_thickness_max: float = 0.5,
  79. # 3D 门合并参数
  80. merge_iou_thresh: float = 0.1,
  81. merge_dist_thresh: float = 0.3,
  82. merge_z_overlap_thresh: float = 0.5,
  83. # YOLOE 图像尺寸参数
  84. imgsz: Tuple[int, int] = (1024, 2048), # (height, width)
  85. # 可视化参数
  86. vis_ply: bool = False,
  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: 天花板点分位数
  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. merge_z_overlap_thresh: 3D 门合并 Z 方向重叠度阈值
  108. imgsz: YOLOE 输入图像尺寸 (height, width),默认 (1024, 2048) 适配全景图
  109. vis_ply: 是否导出可视化 PLY 文件
  110. """
  111. self.scene_folder = Path(scene_folder)
  112. self.conf = conf
  113. self.iou = iou
  114. self.voxel_size = voxel_size
  115. self.depth_scale = depth_scale
  116. self.depth_min = depth_min
  117. # 地面/天花板参数
  118. self.ground_ransac_dist = ground_ransac_dist
  119. self.ground_ransac_prob = ground_ransac_prob
  120. self.ceiling_percentile = ceiling_percentile
  121. # 门过滤参数
  122. self.door_ground_dist = door_ground_dist
  123. self.door_height_min = door_height_min
  124. self.door_height_max = door_height_max
  125. self.door_width_min = door_width_min
  126. self.door_width_max = door_width_max
  127. self.door_thickness_max = door_thickness_max
  128. # 3D 门合并参数
  129. self.merge_iou_thresh = merge_iou_thresh
  130. self.merge_dist_thresh = merge_dist_thresh
  131. self.merge_z_overlap_thresh = merge_z_overlap_thresh
  132. # YOLOE 图像尺寸参数
  133. self.imgsz = imgsz # (height, width)
  134. # 可视化参数
  135. self.vis_ply = vis_ply
  136. # 地面参数
  137. self.ground_d = None
  138. self.ground_z_from_puck = None
  139. # 子目录
  140. self.rgb_folder = self.scene_folder / "pano_img"
  141. self.depth_folder = self.scene_folder / "depth_img"
  142. self.pose_file = self.scene_folder / "vision.txt"
  143. # 输出目录
  144. self.output_folder = self.scene_folder / "output"
  145. # 状态变量
  146. self.poses: Dict[str, PoseData] = {}
  147. self.puck_z_dict: Dict[str, float] = {}
  148. self.entrance_door: Optional[Door3D] = None
  149. self.estimated_entrance_position: Optional[np.ndarray] = None
  150. self.all_doors: List[Door3D] = []
  151. self.processing_info: Dict[str, Any] = {}
  152. # 加载位姿
  153. self._load_poses()
  154. # 加载 YOLOE 模型
  155. print(f"加载 YOLOE 模型:{model_path}")
  156. self.model = YOLOE(model_path)
  157. self.model.set_classes(self.DOOR_CLASSES)
  158. def _load_poses(self):
  159. """从 vision.txt 加载位姿信息"""
  160. if not self.pose_file.exists():
  161. raise FileNotFoundError(f"位姿文件不存在:{self.pose_file}")
  162. with open(self.pose_file, 'r') as f:
  163. data = json.load(f)
  164. for loc in data.get('sweepLocations', []):
  165. uuid = str(loc['uuid'])
  166. self.poses[uuid] = PoseData(
  167. uuid=uuid,
  168. rotation=loc['pose']['rotation'],
  169. translation=loc['pose']['translation'],
  170. pose_id=loc.get('id', int(uuid))
  171. )
  172. if 'puck' in loc and 'z' in loc['puck']:
  173. self.puck_z_dict[uuid] = loc['puck']['z']
  174. print(f"加载 {len(self.poses)} 个拍摄点位")
  175. # 计算整体地面 Z
  176. if self.puck_z_dict:
  177. puck_z_values = list(self.puck_z_dict.values())
  178. self.ground_z_from_puck = np.median(puck_z_values)
  179. print(f"从 puck 参数估计地面 Z (中位数): {self.ground_z_from_puck:.4f}m")
  180. def _build_pose_matrix(self, pose: PoseData) -> np.ndarray:
  181. """构建 4x4 位姿变换矩阵"""
  182. R = o3d.geometry.get_rotation_matrix_from_quaternion(
  183. np.array([pose.rotation['w'], pose.rotation['x'],
  184. pose.rotation['y'], pose.rotation['z']])
  185. )
  186. t = np.array([
  187. pose.translation['x'],
  188. pose.translation['y'],
  189. pose.translation['z']
  190. ])
  191. T = np.eye(4)
  192. T[:3, :3] = R
  193. T[:3, 3] = t
  194. return T
  195. def _mask_to_3d_points(
  196. self,
  197. mask: np.ndarray,
  198. depth: np.ndarray,
  199. pose_matrix: np.ndarray
  200. ) -> Optional[np.ndarray]:
  201. """将 2D mask 映射到世界坐标系 3D 点"""
  202. H, W = depth.shape
  203. sph = Intrinsic_Spherical_NP(W, H)
  204. ys, xs = np.where(mask > 0)
  205. if len(xs) == 0:
  206. return None
  207. valid = depth[ys, xs] > self.depth_min
  208. if not np.any(valid):
  209. return None
  210. xs, ys = xs[valid], ys[valid]
  211. depths = depth[ys, xs]
  212. bx, by, bz = sph.bearing([xs.astype(np.float64), ys.astype(np.float64)])
  213. bx, by, bz = np.array(bx), np.array(by), np.array(bz)
  214. pts_cam = np.stack([bx * depths, by * depths, bz * depths], axis=1)
  215. R_z180 = np.diag([-1.0, -1.0, 1.0])
  216. pts_cam = pts_cam @ R_z180.T
  217. pts_w = (pose_matrix[:3, :3] @ pts_cam.T).T + pose_matrix[:3, 3]
  218. return pts_w
  219. def _filter_outliers(self, points: np.ndarray, std_thresh: float = 2.0) -> np.ndarray:
  220. """过滤 3D 点云中的离群点"""
  221. if len(points) < 10:
  222. return points
  223. mean = np.mean(points, axis=0)
  224. std = np.std(points, axis=0)
  225. mask = np.all(np.abs(points - mean) < std_thresh * std, axis=1)
  226. filtered = points[mask]
  227. if len(filtered) < len(points) * 0.5:
  228. return points
  229. return filtered
  230. def _filter_door_points_by_depth(self, points: np.ndarray) -> np.ndarray:
  231. """根据深度一致性过滤门的 3D 点"""
  232. if len(points) < 50:
  233. return points
  234. centered = points - np.mean(points, axis=0)
  235. cov = np.cov(centered.T)
  236. eigenvalues, eigenvectors = np.linalg.eigh(cov)
  237. idx = np.argsort(eigenvalues)[::-1]
  238. eigenvalues = eigenvalues[idx]
  239. eigenvectors = eigenvectors[:, idx]
  240. normal = eigenvectors[:, 2]
  241. projected = np.dot(points, normal)
  242. mean_proj = np.mean(projected)
  243. std_proj = np.std(projected)
  244. mask = np.abs(projected - mean_proj) < 2.0 * std_proj
  245. filtered = points[mask]
  246. if len(filtered) < len(points) * 0.5:
  247. return points
  248. return filtered
  249. def _axis_aligned_bbox(self, points: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
  250. """计算轴对齐包围盒 (min, max)"""
  251. lo = np.min(points, axis=0)
  252. hi = np.max(points, axis=0)
  253. return lo, hi
  254. def _bbox_8corners(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
  255. """从 bbox min/max 获取 8 个角点"""
  256. cx, cy, cz = bbox_min
  257. ex, ey, ez = bbox_max
  258. return np.array([
  259. [cx, cy, cz], [ex, cy, cz], [ex, ey, cz], [cx, ey, cz],
  260. [cx, cy, ez], [ex, cy, ez], [ex, ey, ez], [cx, ey, ez],
  261. ])
  262. def _bbox_iou_3d(self, b1, b2) -> float:
  263. """3D IoU 计算"""
  264. lo = np.maximum(b1[0], b2[0])
  265. hi = np.minimum(b1[1], b2[1])
  266. inter = np.prod(np.maximum(hi - lo, 0))
  267. vol1 = np.prod(b1[1] - b1[0])
  268. vol2 = np.prod(b2[1] - b2[0])
  269. union = vol1 + vol2 - inter
  270. return inter / union if union > 0 else 0.0
  271. def _merge_3d_doors(self, door_candidates: List[Dict]) -> List[Door3D]:
  272. """使用并查集合并 3D 门"""
  273. if not door_candidates:
  274. return []
  275. n = len(door_candidates)
  276. if n == 1:
  277. d = door_candidates[0]
  278. return [Door3D(
  279. id=0,
  280. center=(d['bbox_min'] + d['bbox_max']) / 2,
  281. bbox_8points=self._bbox_8corners(d['bbox_min'], d['bbox_max']),
  282. source_detections=[d['source']],
  283. source_uuid=d.get('source_uuid')
  284. )]
  285. parent = list(range(n))
  286. def find(x):
  287. if parent[x] != x:
  288. parent[x] = find(parent[x])
  289. return parent[x]
  290. def union(x, y):
  291. px, py = find(x), find(y)
  292. if px != py:
  293. parent[px] = py
  294. for i in range(n):
  295. for j in range(i + 1, n):
  296. ci = (door_candidates[i]['bbox_min'] + door_candidates[i]['bbox_max']) / 2
  297. cj = (door_candidates[j]['bbox_min'] + door_candidates[j]['bbox_max']) / 2
  298. dist = np.linalg.norm(ci - cj)
  299. same_image = door_candidates[i]['source']['image'] == door_candidates[j]['source']['image']
  300. z_min_i, z_max_i = door_candidates[i]['bbox_min'][2], door_candidates[i]['bbox_max'][2]
  301. z_min_j, z_max_j = door_candidates[j]['bbox_min'][2], door_candidates[j]['bbox_max'][2]
  302. z_overlap_min = max(z_min_i, z_min_j)
  303. z_overlap_max = min(z_max_i, z_max_j)
  304. z_intersection = max(0, z_overlap_max - z_overlap_min)
  305. z_union = max(z_max_i, z_max_j) - min(z_min_i, z_min_j)
  306. z_overlap_ratio = z_intersection / z_union if z_union > 0 else 0
  307. iou = self._bbox_iou_3d(
  308. (door_candidates[i]['bbox_min'], door_candidates[i]['bbox_max']),
  309. (door_candidates[j]['bbox_min'], door_candidates[j]['bbox_max'])
  310. )
  311. if same_image:
  312. should_merge = (iou > 0.05 and z_overlap_ratio > 0.5)
  313. else:
  314. should_merge = (
  315. (dist < self.merge_dist_thresh or iou > self.merge_iou_thresh) and
  316. z_overlap_ratio > self.merge_z_overlap_thresh
  317. )
  318. if should_merge:
  319. union(i, j)
  320. from collections import defaultdict
  321. groups = defaultdict(list)
  322. for i in range(n):
  323. groups[find(i)].append(door_candidates[i])
  324. doors = []
  325. for door_id, members in enumerate(groups.values()):
  326. if not members:
  327. continue
  328. all_bbox_mins = [m['bbox_min'] for m in members]
  329. all_bbox_maxs = [m['bbox_max'] for m in members]
  330. merged_min_xy = np.min(all_bbox_mins, axis=0)[:2]
  331. merged_max_xy = np.max(all_bbox_maxs, axis=0)[:2]
  332. z_mins = [b[2] for b in all_bbox_mins]
  333. z_maxs = [b[2] for b in all_bbox_maxs]
  334. merged_z_min = np.min(z_mins)
  335. merged_z_max = np.max(z_maxs)
  336. bbox_min = np.array([merged_min_xy[0], merged_min_xy[1], merged_z_min])
  337. bbox_max = np.array([merged_max_xy[0], merged_max_xy[1], merged_z_max])
  338. sources = [m['source'] for m in members]
  339. source_uuid = members[0].get('source_uuid')
  340. doors.append(Door3D(
  341. id=door_id,
  342. center=(bbox_min + bbox_max) / 2,
  343. bbox_8points=self._bbox_8corners(bbox_min, bbox_max),
  344. source_detections=sources,
  345. source_uuid=source_uuid
  346. ))
  347. return doors
  348. def _fit_ground_plane_ransac(self, pc: o3d.geometry.PointCloud) -> Tuple[np.ndarray, float]:
  349. """使用 RANSAC 拟合地面平面"""
  350. points = np.asarray(pc.points)
  351. z_coords = points[:, 2]
  352. z_threshold = np.percentile(z_coords, 5)
  353. ground_candidates = points[z_coords <= z_threshold]
  354. if len(ground_candidates) < 100:
  355. plane_model, inliers = pc.segment_plane(
  356. distance_threshold=self.ground_ransac_dist,
  357. ransac_n=3,
  358. num_iterations=1000
  359. )
  360. else:
  361. centered = ground_candidates - np.mean(ground_candidates, axis=0)
  362. cov = np.cov(centered.T)
  363. eigenvalues, eigenvectors = np.linalg.eigh(cov)
  364. normal = eigenvectors[:, 0]
  365. mean_point = np.mean(ground_candidates, axis=0)
  366. a, b, c = normal
  367. d = -np.dot(normal, mean_point)
  368. plane_model = [a, b, c, d]
  369. a, b, c, d = plane_model
  370. normal = np.array([a, b, c])
  371. if normal[2] < 0:
  372. normal = -normal
  373. d = -d
  374. return normal, d
  375. def _fit_ceiling_plane(self, pc: o3d.geometry.PointCloud, ground_normal: np.ndarray, ground_d: float) -> float:
  376. """拟合天花板平面,返回地面到天花板距离"""
  377. points = np.asarray(pc.points)
  378. distances_to_ground = np.dot(points, ground_normal) + ground_d
  379. valid_mask = distances_to_ground < 5.0
  380. valid_points = points[valid_mask]
  381. valid_distances = distances_to_ground[valid_mask]
  382. if len(valid_points) < 1000:
  383. valid_points = points
  384. valid_distances = distances_to_ground
  385. height_percentiles = np.percentile(valid_distances, [85, 90, 95])
  386. ceiling_threshold = height_percentiles[1]
  387. ceiling_mask = valid_distances >= ceiling_threshold
  388. ceiling_points = valid_points[ceiling_mask]
  389. if len(ceiling_points) < 100:
  390. ceiling_threshold = height_percentiles[0]
  391. ceiling_mask = valid_distances >= ceiling_threshold
  392. ceiling_points = valid_points[ceiling_mask]
  393. floor_ceiling_dist = np.mean(valid_distances[ceiling_mask])
  394. return floor_ceiling_dist
  395. def _filter_door_by_properties(self, door: Door3D) -> Tuple[bool, List[str]]:
  396. """根据物理特性过滤门"""
  397. reasons = []
  398. size = door.bbox_8points.max(axis=0) - door.bbox_8points.min(axis=0)
  399. height = size[2]
  400. width = max(size[0], size[1])
  401. if height <= 0:
  402. reasons.append(f"高度无效 ({height:.2f}m)")
  403. if width <= 0:
  404. reasons.append(f"宽度无效 ({width:.2f}m)")
  405. return len(reasons) == 0, reasons
  406. def _filter_door_by_ground_puck(self, door: Door3D) -> Tuple[bool, str]:
  407. """基于 puck 参数检查门的地面距离"""
  408. door_bottom_z = door.bbox_8points[:, 2].min()
  409. puck_z = None
  410. if door.source_uuid and door.source_uuid in self.puck_z_dict:
  411. puck_z = self.puck_z_dict[door.source_uuid]
  412. elif self.ground_z_from_puck is not None:
  413. puck_z = self.ground_z_from_puck
  414. if puck_z is None:
  415. return True, ""
  416. dist = door_bottom_z - puck_z
  417. if abs(dist) > 0.2:
  418. return False, f"门底部距地面 {dist:.3f}m (puck_z={puck_z:.3f})"
  419. return True, ""
  420. def _denoise_xy_projection(
  421. self,
  422. points: np.ndarray,
  423. grid_size: float = 0.15,
  424. min_points_per_cell: int = 5
  425. ) -> np.ndarray:
  426. """投影到 XY 平面后进行网格滤波去噪"""
  427. if len(points) < 10:
  428. return points
  429. xy = points[:, :2]
  430. grid_coords = np.floor(xy / grid_size).astype(int)
  431. from collections import Counter
  432. cell_counts = Counter(map(tuple, grid_coords))
  433. valid_cells = {
  434. cell for cell, count in cell_counts.items()
  435. if count >= min_points_per_cell
  436. }
  437. mask = np.array([tuple(gc) in valid_cells for gc in grid_coords])
  438. if np.sum(mask) < len(points) * 0.3:
  439. valid_cells = {
  440. cell for cell, count in cell_counts.items()
  441. if count >= max(1, min_points_per_cell - 2)
  442. }
  443. mask = np.array([tuple(gc) in valid_cells for gc in grid_coords])
  444. return points[mask]
  445. def _compute_xy_contour(self, points: np.ndarray) -> Tuple[np.ndarray, Optional[Delaunay]]:
  446. """计算点云 XY 投影的轮廓(Alpha Shape)"""
  447. from scipy.spatial import Delaunay, ConvexHull
  448. if len(points) < 4:
  449. return points[:, :2], None
  450. xy = points[:, :2]
  451. alpha = 2.0
  452. tri = Delaunay(xy)
  453. centers = []
  454. for i in range(tri.npoints):
  455. idx = tri.simplices[i]
  456. p0, p1, p2 = xy[idx[0]], xy[idx[1]], xy[idx[2]]
  457. a = np.linalg.norm(p1 - p2)
  458. b = np.linalg.norm(p0 - p2)
  459. c = np.linalg.norm(p0 - p1)
  460. s = (a + b + c) / 2
  461. area = np.sqrt(max(0, s * (s - a) * (s - b) * (s - c)))
  462. if area < 1e-10:
  463. continue
  464. R = (a * b * c) / (4 * area)
  465. if R < alpha:
  466. center = (p0 + p1 + p2) / 3
  467. centers.append(center)
  468. if len(centers) < 3:
  469. print("⚠️ Alpha Shape 失败,使用凸包")
  470. hull = ConvexHull(xy)
  471. return xy[hull.vertices], tri
  472. centers = np.array(centers)
  473. hull = ConvexHull(centers)
  474. contour = centers[hull.vertices]
  475. return contour, tri
  476. def _point_to_line_segment_distance(
  477. self,
  478. point: np.ndarray,
  479. line_start: np.ndarray,
  480. line_end: np.ndarray
  481. ) -> float:
  482. """计算点到线段的最短距离"""
  483. p = np.array(point)
  484. a = np.array(line_start)
  485. b = np.array(line_end)
  486. ab = b - a
  487. ap = p - a
  488. t = np.dot(ap, ab) / np.dot(ab, ab)
  489. t = np.clip(t, 0, 1)
  490. proj = a + t * ab
  491. return np.linalg.norm(p - proj)
  492. def _compute_distance_to_contour_boundary(
  493. self,
  494. point_xy: np.ndarray,
  495. contour: np.ndarray
  496. ) -> float:
  497. """计算点到轮廓边界的最短距离"""
  498. min_dist = float('inf')
  499. n = len(contour)
  500. for i in range(n):
  501. p1 = contour[i]
  502. p2 = contour[(i + 1) % n]
  503. dist = self._point_to_line_segment_distance(point_xy, p1, p2)
  504. min_dist = min(min_dist, dist)
  505. return min_dist
  506. def _estimate_entrance_from_poses(
  507. self,
  508. pc: o3d.geometry.PointCloud,
  509. contour: Optional[np.ndarray] = None,
  510. tri = None
  511. ) -> Optional[np.ndarray]:
  512. """基于点位信息估计入户门位置"""
  513. if not self.poses:
  514. print("⚠️ 没有点位数据,无法估计入户门")
  515. return None
  516. print("\n=== 基于点位信息估计入户门 ===")
  517. # 重新加载 vision.txt 获取完整的点位信息
  518. vision_file = self.scene_folder / "vision.txt"
  519. if not vision_file.exists():
  520. print("⚠️ vision.txt 不存在,无法获取可见性信息")
  521. return None
  522. with open(vision_file, 'r') as f:
  523. vision_data = json.load(f)
  524. pose_lookup = {}
  525. for loc in vision_data.get('sweepLocations', []):
  526. uuid = str(loc['uuid'])
  527. pose_lookup[uuid] = {
  528. 'id': loc['id'],
  529. 'pose': loc['pose'],
  530. 'puck': loc.get('puck', {}),
  531. 'visibles': loc.get('visibles', []),
  532. 'position': np.array([
  533. loc['pose']['translation']['x'],
  534. loc['pose']['translation']['y'],
  535. loc['pose']['translation']['z']
  536. ])
  537. }
  538. pose_info = []
  539. for uuid, data in pose_lookup.items():
  540. pose_info.append({
  541. 'uuid': uuid,
  542. 'id': data['id'],
  543. 'position': data['position'],
  544. 'position_xy': data['position'][:2],
  545. 'visibles': set(data['visibles']),
  546. 'puck_z': data['puck'].get('z', 0)
  547. })
  548. # 计算轮廓
  549. if contour is None:
  550. points_xy_denoised = self._denoise_xy_projection(
  551. np.asarray(pc.points),
  552. grid_size=0.15,
  553. min_points_per_cell=5
  554. )
  555. contour, tri = self._compute_xy_contour(points_xy_denoised)
  556. print(f"轮廓点数量:{len(contour)}")
  557. # 可见性一致性过滤
  558. filtered_poses = []
  559. filtered_out = []
  560. for i, pose_a in enumerate(pose_info):
  561. is_indoor = False
  562. for j, pose_b in enumerate(pose_info):
  563. if i == j:
  564. continue
  565. b_id = pose_b['id']
  566. a_visible_to_b = b_id in pose_a['visibles']
  567. dist_ab = np.linalg.norm(pose_a['position_xy'] - pose_b['position_xy'])
  568. for k, pose_c in enumerate(pose_info):
  569. if k == i or k == j:
  570. continue
  571. c_id = pose_c['id']
  572. a_visible_to_c = c_id in pose_a['visibles']
  573. dist_ac = np.linalg.norm(pose_a['position_xy'] - pose_c['position_xy'])
  574. if a_visible_to_b and not a_visible_to_c and dist_ac < dist_ab:
  575. is_indoor = True
  576. filtered_out.append((pose_a, f"遮挡不一致:可见 B({b_id}) 不可见 C({c_id})"))
  577. break
  578. if is_indoor:
  579. break
  580. if not is_indoor:
  581. filtered_poses.append(pose_a)
  582. print(f"可见性过滤:{len(pose_info)} → {len(filtered_poses)} 个点位")
  583. if not filtered_poses:
  584. print("⚠️ 所有点位都被过滤,使用全部点位")
  585. filtered_poses = pose_info
  586. # 计算几何中心
  587. all_positions_xy = np.array([p['position_xy'] for p in filtered_poses])
  588. centroid = np.mean(all_positions_xy, axis=0)
  589. # 计算边界距离
  590. boundary_distances = []
  591. for p in filtered_poses:
  592. dist = self._compute_distance_to_contour_boundary(p['position_xy'], contour)
  593. boundary_distances.append(dist)
  594. # 计算中心距离
  595. center_distances = np.linalg.norm(all_positions_xy - centroid, axis=1)
  596. # 归一化
  597. max_boundary_dist = max(boundary_distances) if boundary_distances else 1.0
  598. max_center_dist = center_distances.max() if center_distances.max() > 0 else 1.0
  599. # 计算评分
  600. print("\n点位评分详情:")
  601. best_score = -float('inf')
  602. best_pose = None
  603. pose_scores = []
  604. for i, p in enumerate(filtered_poses):
  605. if max_boundary_dist > 0:
  606. boundary_score = (1 - boundary_distances[i] / max_boundary_dist) * 70
  607. else:
  608. boundary_score = 35
  609. if max_center_dist > 0:
  610. center_score = (center_distances[i] / max_center_dist) * 30
  611. else:
  612. center_score = 15
  613. total_score = boundary_score + center_score
  614. pose_scores.append({
  615. 'uuid': p['uuid'],
  616. 'id': p['id'],
  617. 'boundary_score': boundary_score,
  618. 'center_score': center_score,
  619. 'total_score': total_score,
  620. 'boundary_distance': boundary_distances[i],
  621. 'center_distance': center_distances[i]
  622. })
  623. print(f" 点位 {p['uuid']} (ID={p['id']}): 边界={boundary_score:.1f} + 中心距={center_score:.1f} = {total_score:.1f}")
  624. if total_score > best_score:
  625. best_score = total_score
  626. best_pose = p
  627. print(f"\n选择点位 {best_pose['uuid']} (综合评分={best_score:.1f})")
  628. self.processing_info['pose_estimation'] = {
  629. 'selected_pose_uuid': best_pose['uuid'],
  630. 'selected_pose_id': best_pose['id'],
  631. 'boundary_score': best_score,
  632. 'center_distance_score': pose_scores[0]['center_score'] if pose_scores else 0,
  633. 'total_score': best_score,
  634. 'all_poses_count': len(pose_info),
  635. 'valid_poses_count': len(filtered_poses),
  636. 'filtered_poses': [{'uuid': p['uuid'], 'reason': r} for p, r in filtered_out]
  637. }
  638. return best_pose['position']
  639. def _identify_entrance_door(
  640. self,
  641. doors: List[Door3D],
  642. pc: o3d.geometry.PointCloud
  643. ) -> int:
  644. """识别入户门 ID"""
  645. if len(doors) == 0:
  646. print("\n⚠️ 未检测到任何门,使用点位信息估计入户门位置")
  647. estimated_entrance = self._estimate_entrance_from_poses(pc)
  648. if estimated_entrance is not None:
  649. print(f"估计入户门位置:{estimated_entrance}")
  650. self.estimated_entrance_position = estimated_entrance
  651. return -1
  652. if len(doors) == 1:
  653. return 0
  654. # 计算轮廓
  655. points_xy_denoised = self._denoise_xy_projection(
  656. np.asarray(pc.points),
  657. grid_size=0.15,
  658. min_points_per_cell=5
  659. )
  660. contour, tri = self._compute_xy_contour(points_xy_denoised)
  661. print(f"轮廓点数量:{len(contour)}")
  662. # 计算每个门的评分
  663. all_centers = np.array([d.center for d in doors])
  664. best_score = -1
  665. best_idx = 0
  666. door_scores = []
  667. for i, door in enumerate(doors):
  668. size = door.bbox_8points.max(axis=0) - door.bbox_8points.min(axis=0)
  669. height = size[2]
  670. width = max(size[0], size[1])
  671. height_score = max(0, 15 - abs(height - 2.1) * 10)
  672. width_score = max(0, 15 - abs(width - 1.0) * 12)
  673. size_score = height_score + width_score
  674. dists_to_others = np.linalg.norm(all_centers - door.center, axis=1)
  675. avg_dist = dists_to_others.mean()
  676. edge_score = min(25, avg_dist * 10)
  677. source_count = len(door.source_detections)
  678. view_score = min(10, source_count * 3)
  679. # 边界评分
  680. door_xy = door.center[:2]
  681. min_boundary_dist = float('inf')
  682. n = len(contour)
  683. for j in range(n):
  684. p1 = contour[j]
  685. p2 = contour[(j + 1) % n]
  686. dist = self._point_to_line_segment_distance(door_xy, p1, p2)
  687. min_boundary_dist = min(min_boundary_dist, dist)
  688. max_boundary_dist = max([
  689. self._compute_distance_to_contour_boundary(d.center[:2], contour)
  690. for d in doors
  691. ])
  692. if max_boundary_dist > 0:
  693. boundary_score = (1 - min_boundary_dist / max_boundary_dist) * 30
  694. else:
  695. boundary_score = 15
  696. total = size_score + edge_score + view_score + boundary_score
  697. door_scores.append({
  698. 'door_id': i,
  699. 'size_score': size_score,
  700. 'edge_score': edge_score,
  701. 'view_score': view_score,
  702. 'boundary_score': boundary_score,
  703. 'total_score': total
  704. })
  705. print(f" 门{i}: 尺寸={size_score:.1f} + 边缘={edge_score:.1f} + 视角={view_score:.1f} + 边界={boundary_score:.1f} = {total:.1f}")
  706. if total > best_score:
  707. best_score = total
  708. best_idx = i
  709. print(f"\n入户门选择:门 {best_idx} (得分={best_score:.1f})")
  710. self.processing_info['door_scores'] = door_scores
  711. return best_idx
  712. def detect_and_identify(self):
  713. """检测门并识别入户门"""
  714. print("\n" + "=" * 50)
  715. print("开始检测门")
  716. print("=" * 50)
  717. rgb_files = sorted(
  718. self.rgb_folder.glob("*.jpg"),
  719. key=lambda x: int(x.stem)
  720. )
  721. if not rgb_files:
  722. raise FileNotFoundError(f"在 {self.rgb_folder} 中未找到 RGB 图像")
  723. print(f"找到 {len(rgb_files)} 张全景图")
  724. # 收集点云和门候选
  725. combined_pc = o3d.geometry.PointCloud()
  726. door_candidates = []
  727. for rgb_file in tqdm(rgb_files, desc="检测门"):
  728. idx = rgb_file.stem
  729. pose = self.poses.get(idx)
  730. if pose is None:
  731. continue
  732. depth_path = self.depth_folder / f"{idx}.png"
  733. if not depth_path.exists():
  734. continue
  735. rgb = cv2.cvtColor(cv2.imread(str(rgb_file)), cv2.COLOR_BGR2RGB)
  736. depth = cv2.imread(str(depth_path), cv2.IMREAD_UNCHANGED).astype(np.float32) / self.depth_scale
  737. H, W = depth.shape
  738. pose_matrix = self._build_pose_matrix(pose)
  739. # YOLOE 检测
  740. results = self.model.predict(
  741. str(rgb_file),
  742. imgsz=self.imgsz,
  743. conf=self.conf,
  744. iou=self.iou,
  745. max_det=50,
  746. augment=True,
  747. retina_masks=True,
  748. half=False,
  749. verbose=False,
  750. )
  751. result = results[0]
  752. if result.masks is not None:
  753. masks = result.masks.data.cpu().numpy()
  754. scores = result.boxes.conf.cpu().numpy().tolist()
  755. for i, mask_bin in enumerate(masks):
  756. mask_resized = cv2.resize(
  757. (mask_bin > 0.5).astype(np.uint8),
  758. (W, H),
  759. interpolation=cv2.INTER_NEAREST
  760. )
  761. pts_3d = self._mask_to_3d_points(mask_resized, depth, pose_matrix)
  762. if pts_3d is not None and len(pts_3d) > 10:
  763. pts_3d_filtered = self._filter_outliers(pts_3d, std_thresh=2.0)
  764. pts_3d_filtered = self._filter_door_points_by_depth(pts_3d_filtered)
  765. bbox_min, bbox_max = self._axis_aligned_bbox(pts_3d_filtered)
  766. door_candidates.append({
  767. 'bbox_min': bbox_min,
  768. 'bbox_max': bbox_max,
  769. 'points_3d': pts_3d_filtered,
  770. 'source': {
  771. 'image': rgb_file.name,
  772. 'score': scores[i] if i < len(scores) else 0.0,
  773. 'pose_uuid': idx
  774. },
  775. 'source_uuid': idx
  776. })
  777. # RGB-D 转点云
  778. sph = Intrinsic_Spherical_NP(W, H)
  779. px, py = np.meshgrid(np.arange(W), np.arange(H))
  780. px_flat = px.flatten().astype(np.float64)
  781. py_flat = py.flatten().astype(np.float64)
  782. bx, by, bz = sph.bearing([px_flat, py_flat])
  783. mask = depth.flatten() > self.depth_min
  784. d = depth.flatten()[mask]
  785. if len(d) > 0:
  786. pts_cam = np.stack([bx[mask] * d, by[mask] * d, bz[mask] * d], axis=1)
  787. R_z180 = np.diag([-1.0, -1.0, 1.0])
  788. pts_cam = pts_cam @ R_z180.T
  789. pts_w = (pose_matrix[:3, :3] @ pts_cam.T).T + pose_matrix[:3, 3]
  790. # 获取 RGB 颜色 - 需要 resize 到 depth 图像同样大小
  791. rgb_resized = cv2.resize(rgb, (W, H), interpolation=cv2.INTER_LINEAR)
  792. rgb_flat = rgb_resized.reshape(-1, 3) / 255.0
  793. colors = rgb_flat[mask]
  794. pc = o3d.geometry.PointCloud()
  795. pc.points = o3d.utility.Vector3dVector(pts_w)
  796. pc.colors = o3d.utility.Vector3dVector(colors)
  797. combined_pc += pc
  798. print(f"\n融合前点数:{len(combined_pc.points)}")
  799. combined_pc = combined_pc.voxel_down_sample(self.voxel_size)
  800. print(f"融合后点数:{len(combined_pc.points)}")
  801. # 地面和天花板拟合
  802. ground_normal, ground_d = self._fit_ground_plane_ransac(combined_pc)
  803. self.ground_d = ground_d
  804. floor_ceiling_dist = self._fit_ceiling_plane(combined_pc, ground_normal, ground_d)
  805. # 3D 门合并
  806. print(f"\n3D 门候选:{len(door_candidates)}")
  807. doors_3d = self._merge_3d_doors(door_candidates)
  808. print(f"合并后门数量:{len(doors_3d)}")
  809. # 过滤
  810. valid_doors = []
  811. filtered_doors = []
  812. for door in doors_3d:
  813. passed_prop, prop_reasons = self._filter_door_by_properties(door)
  814. if not passed_prop:
  815. filtered_doors.append((door, prop_reasons))
  816. continue
  817. valid_doors.append(door)
  818. print(f"通过物理特性过滤:{len(valid_doors)} 个门")
  819. # 地面距离过滤
  820. ground_valid_doors = []
  821. use_puck = self.ground_z_from_puck is not None
  822. for door in valid_doors:
  823. if use_puck:
  824. passed_ground, _ = self._filter_door_by_ground_puck(door)
  825. else:
  826. passed_ground = True # 不使用 RANSAC 过滤
  827. if passed_ground:
  828. ground_valid_doors.append(door)
  829. print(f"通过地面距离过滤:{len(ground_valid_doors)} 个门")
  830. # 识别入户门
  831. print("\n" + "=" * 40)
  832. print("入户门识别")
  833. print("=" * 40)
  834. entrance_idx = self._identify_entrance_door(ground_valid_doors, combined_pc)
  835. self.processing_info['total_candidates'] = len(door_candidates)
  836. self.processing_info['merged_doors'] = len(doors_3d)
  837. self.processing_info['valid_doors'] = len(valid_doors)
  838. self.processing_info['ground_valid_doors'] = len(ground_valid_doors)
  839. if entrance_idx >= 0:
  840. self.entrance_door = ground_valid_doors[entrance_idx]
  841. print(f"入户门:门 {entrance_idx}")
  842. else:
  843. print("未检测到入户门,使用点位估计")
  844. self.all_doors = ground_valid_doors
  845. self.combined_pc = combined_pc # 保存点云用于可视化
  846. return self.entrance_door is not None or self.estimated_entrance_position is not None
  847. def export_json(self, output_path: Optional[str] = None) -> str:
  848. """导出结果到 JSON 文件"""
  849. if output_path is None:
  850. self.output_folder.mkdir(parents=True, exist_ok=True)
  851. output_path = self.output_folder / "entrance_position.json"
  852. else:
  853. output_path = Path(output_path)
  854. output_path.parent.mkdir(parents=True, exist_ok=True)
  855. scene_name = self.scene_folder.name
  856. result: Dict[str, Any] = {
  857. "scene_name": scene_name,
  858. }
  859. if self.entrance_door is not None:
  860. # 从门检测确定
  861. door = self.entrance_door
  862. # 计算综合置信度(所有检测的平均值)
  863. detection_scores = [
  864. src.get('score', 0.0)
  865. for src in door.source_detections
  866. ]
  867. avg_confidence = np.mean(detection_scores) if detection_scores else 0.0
  868. result["entrance_position"] = {
  869. "x": float(door.center[0]),
  870. "y": float(door.center[1]),
  871. "z": float(door.center[2])
  872. }
  873. result["source"] = "door_detection"
  874. result["is_estimated"] = False
  875. size = door.bbox_8points.max(axis=0) - door.bbox_8points.min(axis=0)
  876. result["door_info"] = {
  877. "door_id": door.id,
  878. "confidence": float(avg_confidence),
  879. "dimensions": {
  880. "width": float(max(size[0], size[1])),
  881. "height": float(size[2]),
  882. "thickness": float(min(size[0], size[1]))
  883. },
  884. "center": {
  885. "x": float(door.center[0]),
  886. "y": float(door.center[1]),
  887. "z": float(door.center[2])
  888. },
  889. "bbox_min": {
  890. "x": float(door.bbox_8points[:, 0].min()),
  891. "y": float(door.bbox_8points[:, 1].min()),
  892. "z": float(door.bbox_8points[:, 2].min())
  893. },
  894. "bbox_max": {
  895. "x": float(door.bbox_8points[:, 0].max()),
  896. "y": float(door.bbox_8points[:, 1].max()),
  897. "z": float(door.bbox_8points[:, 2].max())
  898. }
  899. }
  900. result["source_detections"] = [
  901. {
  902. "pose_uuid": src.get('pose_uuid', 'unknown'),
  903. "detection_confidence": float(src.get('score', 0.0)),
  904. "image": src.get('image', 'unknown')
  905. }
  906. for src in door.source_detections
  907. ]
  908. result["used_poses_count"] = len(door.source_detections)
  909. elif self.estimated_entrance_position is not None:
  910. # 从点位估计
  911. pos = self.estimated_entrance_position
  912. result["entrance_position"] = {
  913. "x": float(pos[0]),
  914. "y": float(pos[1]),
  915. "z": float(pos[2])
  916. }
  917. result["source"] = "pose_estimation"
  918. result["is_estimated"] = True
  919. if 'pose_estimation' in self.processing_info:
  920. pe = self.processing_info['pose_estimation']
  921. result["pose_info"] = {
  922. "selected_pose_uuid": pe.get('selected_pose_uuid'),
  923. "selected_pose_id": pe.get('selected_pose_id'),
  924. "boundary_score": pe.get('boundary_score', 0),
  925. "center_distance_score": pe.get('center_distance_score', 0),
  926. "total_score": pe.get('total_score', 0)
  927. }
  928. result["filtered_poses"] = pe.get('filtered_poses', [])
  929. result["all_poses_count"] = len(self.poses)
  930. result["valid_poses_count"] = self.processing_info.get('pose_estimation', {}).get('valid_poses_count', len(self.poses))
  931. else:
  932. # 无法确定
  933. result["entrance_position"] = None
  934. result["source"] = "unknown"
  935. result["is_estimated"] = False
  936. result["error"] = "未检测到门且无法从点位估计"
  937. result["metadata"] = {
  938. "all_poses_count": len(self.poses),
  939. "processing_info": self.processing_info
  940. }
  941. with open(output_path, 'w', encoding='utf-8') as f:
  942. json.dump(result, f, indent=2, ensure_ascii=False)
  943. print(f"\n结果已导出:{output_path}")
  944. return str(output_path)
  945. def export_vis_ply(self, pc: o3d.geometry.PointCloud, output_path: Optional[str] = None):
  946. """导出入户门位置可视化 PLY 文件
  947. Args:
  948. pc: 场景点云(带颜色)
  949. output_path: 输出路径,默认为 scene/output/vis.ply
  950. """
  951. # 获取入户门位置
  952. position = None
  953. if self.entrance_door is not None:
  954. position = self.entrance_door.center
  955. elif self.estimated_entrance_position is not None:
  956. position = self.estimated_entrance_position
  957. if position is None:
  958. print("⚠️ 没有入户门位置,跳过可视化")
  959. return
  960. # 下采样场景点云(减少文件大小)
  961. pc_vis = pc.voxel_down_sample(0.05)
  962. # 获取场景点云颜色(已包含在点云中)
  963. if len(pc_vis.colors) == 0:
  964. # 如果没有颜色,使用灰色
  965. pc_colors = np.tile([0.5, 0.5, 0.5], (len(pc_vis.points), 1))
  966. else:
  967. pc_colors = np.asarray(pc_vis.colors)
  968. # 创建红色球体点云
  969. sphere = o3d.geometry.TriangleMesh.create_sphere(radius=0.2, resolution=30)
  970. sphere.translate(position)
  971. sphere_pc = sphere.sample_points_uniformly(number_of_points=2000)
  972. sphere_colors = np.tile([1.0, 0.0, 0.0], (len(sphere_pc.points), 1)) # 红色
  973. # 合并点云
  974. combined_points = np.vstack([np.asarray(pc_vis.points), np.asarray(sphere_pc.points)])
  975. combined_colors = np.vstack([pc_colors, sphere_colors])
  976. # 创建输出点云
  977. output_pc = o3d.geometry.PointCloud()
  978. output_pc.points = o3d.utility.Vector3dVector(combined_points)
  979. output_pc.colors = o3d.utility.Vector3dVector(combined_colors)
  980. # 输出路径
  981. if output_path is None:
  982. output_path = self.output_folder / "vis.ply"
  983. else:
  984. output_path = Path(output_path)
  985. output_path.parent.mkdir(parents=True, exist_ok=True)
  986. # 保存 PLY 文件(ASCII 格式确保 MeshLab 兼容性)
  987. o3d.io.write_point_cloud(str(output_path), output_pc, write_ascii=True, print_progress=False)
  988. print(f"可视化已导出:{output_path}")
  989. print(f" - 入户门位置:{position}")
  990. print(f" - 红色球体半径:0.2m")
  991. print(f" - 总点数:{len(output_pc.points)} (场景:{len(pc_vis.points)}, 球体:{len(sphere_pc.points)})")
  992. # ============================================================================
  993. # 主函数
  994. # ============================================================================
  995. def main():
  996. parser = argparse.ArgumentParser(
  997. description="入户门位置导出脚本",
  998. formatter_class=argparse.RawDescriptionHelpFormatter,
  999. epilog="""
  1000. 示例:
  1001. # 处理单个场景
  1002. python export_entrance_position.py -s scene0001
  1003. # 指定输出路径
  1004. python export_entrance_position.py -s scene0001 -o output/entrance.json
  1005. # 调整检测参数
  1006. python export_entrance_position.py -s scene0001 --conf 0.4 --iou 0.5
  1007. # 调整图像尺寸(高度 x 宽度)
  1008. python export_entrance_position.py -s scene0001 --imgsz 1024 2048
  1009. # 使用更小的图像尺寸(加快处理速度)
  1010. python export_entrance_position.py -s scene0001 --imgsz 640 1280
  1011. # 导出入户门位置可视化 PLY 文件(红色球体标记)
  1012. python export_entrance_position.py -s scene0001 --vis_ply
  1013. """
  1014. )
  1015. parser.add_argument("--scene", "-s", type=str, required=True,
  1016. help="场景文件夹")
  1017. parser.add_argument("--output", "-o", type=str, default=None,
  1018. help="输出 JSON 文件路径")
  1019. parser.add_argument("--model", "-m", type=str, default="yoloe-26x-seg.pt",
  1020. help="YOLOE 模型路径")
  1021. parser.add_argument("--conf", type=float, default=0.35,
  1022. help="置信度阈值")
  1023. parser.add_argument("--iou", type=float, default=0.45,
  1024. help="NMS IoU 阈值")
  1025. parser.add_argument("--voxel-size", type=float, default=0.03,
  1026. help="点云体素大小")
  1027. parser.add_argument("--imgsz", type=int, nargs=2, default=[1024, 2048],
  1028. metavar=("HEIGHT", "WIDTH"),
  1029. help="YOLOE 输入图像尺寸 (高度 宽度),默认 1024 2048")
  1030. parser.add_argument("--vis_ply", action="store_true", default=False,
  1031. help="是否导出入户门位置可视化 PLY 文件(红色球体)")
  1032. args = parser.parse_args()
  1033. scene_path = Path(args.scene)
  1034. if not scene_path.exists():
  1035. print(f"❌ 场景文件夹不存在:{scene_path}")
  1036. sys.exit(1)
  1037. detector = EntranceDoorDetector(
  1038. scene_folder=args.scene,
  1039. model_path=args.model,
  1040. conf=args.conf,
  1041. iou=args.iou,
  1042. voxel_size=args.voxel_size,
  1043. imgsz=(args.imgsz[0], args.imgsz[1]),
  1044. vis_ply=args.vis_ply,
  1045. )
  1046. success = detector.detect_and_identify()
  1047. if success:
  1048. detector.export_json(args.output)
  1049. if args.vis_ply:
  1050. detector.export_vis_ply(detector.combined_pc)
  1051. print("\n✓ 处理完成")
  1052. else:
  1053. print("\n⚠️ 处理失败:无法确定入户门位置")
  1054. sys.exit(1)
  1055. if __name__ == "__main__":
  1056. main()