paired_image_saver.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798
  1. """
  2. 配对图片保存管理器
  3. 将全景检测图片和对应的球机聚焦图片保存到同一目录
  4. 支持 OSS 上传和 batch_info.json 格式
  5. """
  6. import os
  7. import cv2
  8. import time
  9. import json
  10. import logging
  11. import threading
  12. from pathlib import Path
  13. from datetime import datetime
  14. from typing import Optional, List, Dict, Tuple, Callable
  15. from dataclasses import dataclass, field, asdict
  16. logger = logging.getLogger(__name__)
  17. @dataclass
  18. class PersonInfo:
  19. """人员信息(轨迹追踪已禁用)"""
  20. person_index: int # 人员序号(0-based)
  21. position: Tuple[float, float] # (x_ratio, y_ratio)
  22. bbox: Tuple[int, int, int, int] # (x1, y1, x2, y2)
  23. confidence: float
  24. ptz_position: Optional[Tuple[float, float, int]] = None # (pan, tilt, zoom)
  25. ptz_bbox: Optional[Tuple[int, int, int, int]] = None # 球机图中检测到的bbox (x1, y1, x2, y2)
  26. ptz_image_saved: bool = False
  27. ptz_image_path: Optional[str] = None
  28. ptz_oss_url: Optional[str] = None # 球机图 OSS URL
  29. @dataclass
  30. class DetectionBatch:
  31. """一批检测记录"""
  32. batch_id: str
  33. timestamp: float
  34. panorama_image: Optional[object] = None # numpy array
  35. panorama_path: Optional[str] = None
  36. panorama_oss_url: Optional[str] = None # 全景图 OSS URL
  37. persons: List[PersonInfo] = field(default_factory=list)
  38. total_persons: int = 0
  39. ptz_images_count: int = 0
  40. completed: bool = False
  41. device_id: str = '' # 设备编号
  42. project_id: str = '' # 项目编号
  43. class PairedImageSaver:
  44. """
  45. 配对图片保存管理器
  46. 功能:
  47. 1. 为每次全景检测创建批次目录
  48. 2. 保存全景标记图到批次目录
  49. 3. 为每个人员保存对应的球机聚焦图到同一目录
  50. 4. 支持时间窗口内的批量保存
  51. 5. 支持 OSS 上传
  52. 6. 生成 batch_info.json
  53. """
  54. def __init__(self, base_dir: str = '/home/admin/dsh/paired_images',
  55. time_window: float = 5.0, # 时间窗口(秒)
  56. max_batches: int = 100,
  57. enable_oss: bool = False,
  58. oss_uploader = None,
  59. device_config: Dict = None):
  60. """
  61. 初始化
  62. Args:
  63. base_dir: 基础保存目录
  64. time_window: 批次时间窗口(秒),同一窗口内的检测归为一批
  65. max_batches: 最大保留批次数量
  66. enable_oss: 是否启用 OSS 上传
  67. oss_uploader: OSS 上传器实例
  68. device_config: 设备配置字典
  69. """
  70. self.base_dir = Path(base_dir)
  71. self.time_window = time_window
  72. self.max_batches = max_batches
  73. # 从配置模块读取 OSS 和设备配置(确保即使外部不传也能正确配置)
  74. try:
  75. from config import S3_COMPATIBLE_CONFIG, DEVICE_CONFIG
  76. # OSS 配置:优先使用传入参数,否则从配置模块读取
  77. oss_enabled_in_config = S3_COMPATIBLE_CONFIG.get('enabled', False)
  78. if enable_oss or (not enable_oss and oss_enabled_in_config):
  79. self.enable_oss = True
  80. else:
  81. self.enable_oss = enable_oss
  82. logger.info(f"[配对保存] OSS配置: enable_oss={enable_oss}, config_enabled={oss_enabled_in_config}, 最终启用={self.enable_oss}")
  83. # OSS 上传器:优先使用传入的实例,否则从全局获取
  84. if oss_uploader is not None:
  85. self.oss_uploader = oss_uploader
  86. logger.info("[配对保存] 使用传入的 OSS 上传器")
  87. elif self.enable_oss:
  88. try:
  89. from oss_uploader import get_oss_uploader
  90. self.oss_uploader = get_oss_uploader()
  91. logger.info(f"[配对保存] 获取 OSS 上传器: enabled={self.oss_uploader.enabled}, running={getattr(self.oss_uploader, 'running', False)}")
  92. if not self.oss_uploader.running:
  93. self.oss_uploader.start()
  94. logger.info("[配对保存] OSS 上传器已启动")
  95. except Exception as e:
  96. logger.warning(f"[配对保存] OSS 上传器初始化失败: {e}")
  97. self.oss_uploader = None
  98. self.enable_oss = False
  99. else:
  100. self.oss_uploader = None
  101. # 设备配置:合并传入参数和配置模块
  102. self.device_config = DEVICE_CONFIG.copy()
  103. if device_config:
  104. self.device_config.update(device_config)
  105. except ImportError as e:
  106. # 配置模块不可用时使用传入参数
  107. logger.warning(f"[配对保存] 配置模块导入失败: {e}")
  108. self.enable_oss = enable_oss
  109. self.oss_uploader = oss_uploader
  110. self.device_config = device_config or {}
  111. self._current_batch: Optional[DetectionBatch] = None
  112. self._batch_lock = threading.Lock()
  113. self._last_batch_time = 0.0
  114. # 上传状态追踪
  115. self._upload_status: Dict[str, Dict] = {} # batch_id -> {panorama: bool, ptz: Dict}
  116. self._upload_callback: Optional[Callable] = None
  117. # 统计信息
  118. self._stats = {
  119. 'total_batches': 0,
  120. 'total_persons': 0,
  121. 'total_ptz_images': 0,
  122. 'oss_upload_success': 0,
  123. 'oss_upload_failed': 0,
  124. }
  125. self._stats_lock = threading.Lock()
  126. # 确保目录存在
  127. self._ensure_base_dir()
  128. logger.info(f"[配对保存] 初始化完成: 目录={base_dir}, 时间窗口={time_window}s, OSS={enable_oss}")
  129. def _ensure_base_dir(self):
  130. """确保基础目录存在"""
  131. try:
  132. self.base_dir.mkdir(parents=True, exist_ok=True)
  133. except Exception as e:
  134. logger.error(f"[配对保存] 创建目录失败: {e}")
  135. def _generate_batch_id(self) -> str:
  136. """生成批次ID"""
  137. return datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
  138. def _create_batch_dir(self, batch_id: str) -> Path:
  139. """创建批次目录"""
  140. batch_dir = self.base_dir / f"batch_{batch_id}"
  141. try:
  142. batch_dir.mkdir(parents=True, exist_ok=True)
  143. return batch_dir
  144. except Exception as e:
  145. logger.error(f"[配对保存] 创建批次目录失败: {e}")
  146. return self.base_dir
  147. def start_new_batch(self, panorama_frame, persons: List[Dict]) -> Optional[str]:
  148. """
  149. 开始新批次
  150. Args:
  151. panorama_frame: 全景帧图像
  152. persons: 人员列表,每项包含 track_id, position, bbox, confidence
  153. Returns:
  154. batch_id: 批次ID,失败返回 None
  155. """
  156. with self._batch_lock:
  157. current_time = time.time()
  158. # 完成上一批次(如果有)
  159. # 注意:每次检测都创建独立批次,不复用,确保 batch_info 与实际检测一致
  160. if self._current_batch is not None:
  161. self._finalize_batch(self._current_batch)
  162. # 创建新批次
  163. batch_id = self._generate_batch_id()
  164. batch_dir = self._create_batch_dir(batch_id)
  165. # 保存全景图片
  166. panorama_path = None
  167. if panorama_frame is not None:
  168. panorama_path = self._save_panorama_image(
  169. batch_dir, batch_id, panorama_frame, persons
  170. )
  171. # 创建人员信息(轨迹追踪已禁用,使用序号代替track_id)
  172. person_infos = []
  173. for i, p in enumerate(persons):
  174. info = PersonInfo(
  175. person_index=i,
  176. position=p.get('position', (0, 0)),
  177. bbox=p.get('bbox', (0, 0, 0, 0)),
  178. confidence=p.get('confidence', 0.0)
  179. )
  180. person_infos.append(info)
  181. # 获取设备信息
  182. device_id = self.device_config.get('device_id', 'UNKNOWN')
  183. project_id = self.device_config.get('project_id', 'UNKNOWN')
  184. # 创建批次记录
  185. self._current_batch = DetectionBatch(
  186. batch_id=batch_id,
  187. timestamp=current_time,
  188. panorama_image=panorama_frame,
  189. panorama_path=panorama_path,
  190. persons=person_infos,
  191. total_persons=len(persons),
  192. device_id=device_id,
  193. project_id=project_id
  194. )
  195. # 初始化上传状态
  196. self._upload_status[batch_id] = {
  197. 'panorama': False,
  198. 'panorama_url': None,
  199. 'ptz': {},
  200. 'completed': False
  201. }
  202. self._last_batch_time = current_time
  203. with self._stats_lock:
  204. self._stats['total_batches'] += 1
  205. self._stats['total_persons'] += len(persons)
  206. logger.info(
  207. f"[配对保存] 新批次创建: {batch_id}, "
  208. f"人员={len(persons)}, 目录={batch_dir}"
  209. )
  210. # 上传全景图到 OSS
  211. logger.info(f"[配对保存] 开始新批次: enable_oss={self.enable_oss}, uploader={self.oss_uploader}, path={panorama_path}")
  212. if self.enable_oss and panorama_path and self.oss_uploader:
  213. logger.info(f"[配对保存] 准备上传全景图到 OSS: {panorama_path}")
  214. self._upload_panorama_to_oss(batch_id, panorama_path)
  215. else:
  216. logger.warning(f"[配对保存] OSS未启用或上传器不可用: enable_oss={self.enable_oss}, uploader={self.oss_uploader}")
  217. return batch_id
  218. def _save_panorama_image(self, batch_dir: Path, batch_id: str,
  219. frame, persons: List[Dict]) -> Optional[str]:
  220. """
  221. 保存全景标记图片
  222. Args:
  223. batch_dir: 批次目录
  224. batch_id: 批次ID
  225. frame: 全景帧
  226. persons: 人员列表(已由调用方过滤,此处不再过滤)
  227. Returns:
  228. 保存路径或 None
  229. """
  230. try:
  231. # 复制图像避免修改原图
  232. marked_frame = frame.copy()
  233. # 绘制每个人员的标记(使用连续的序号)
  234. # 注意:persons 已由调用方(coordinator)过滤,置信度均 >= 阈值
  235. for i, person in enumerate(persons):
  236. bbox = person.get('bbox', (0, 0, 0, 0))
  237. x1, y1, x2, y2 = bbox
  238. conf = person.get('confidence', 0.0)
  239. # 绘制边界框(绿色)
  240. cv2.rectangle(marked_frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
  241. # 绘制序号标签(带置信度)
  242. label = f"person_{i}({conf:.2f})"
  243. (label_w, label_h), baseline = cv2.getTextSize(
  244. label, cv2.FONT_HERSHEY_SIMPLEX, 0.8, 2
  245. )
  246. # 标签背景
  247. cv2.rectangle(
  248. marked_frame,
  249. (x1, y1 - label_h - 8),
  250. (x1 + label_w, y1),
  251. (0, 255, 0),
  252. -1
  253. )
  254. # 标签文字(黑色)
  255. cv2.putText(
  256. marked_frame, label,
  257. (x1, y1 - 4),
  258. cv2.FONT_HERSHEY_SIMPLEX, 0.8,
  259. (0, 0, 0), 2
  260. )
  261. # 保存图片(使用人员数量)
  262. filename = f"00_panorama_n{len(persons)}.png"
  263. filepath = batch_dir / filename
  264. # 保存全景图(PNG无损格式,不压缩)
  265. cv2.imwrite(str(filepath), marked_frame)
  266. logger.info(f"[配对保存] 全景图已保存: {filepath},人员数量 {len(persons)}")
  267. return str(filepath)
  268. except Exception as e:
  269. logger.error(f"[配对保存] 保存全景图失败: {e}")
  270. return None
  271. def save_ptz_image(self, batch_id: str, person_index: int,
  272. ptz_frame, ptz_position: Tuple[float, float, int],
  273. ptz_bbox: Tuple[int, int, int, int] = None,
  274. person_info: Dict = None) -> Optional[str]:
  275. """
  276. 保存球机聚焦图片
  277. Args:
  278. batch_id: 批次ID
  279. person_index: 人员序号(0-based)
  280. ptz_frame: 球机帧
  281. ptz_position: PTZ位置 (pan, tilt, zoom)
  282. ptz_bbox: 球机图中检测到的bbox (x1, y1, x2, y2)
  283. person_info: 额外人员信息
  284. Returns:
  285. 保存路径或 None
  286. """
  287. # 【调试】记录传入的参数
  288. logger.info(f"[配对保存] save_ptz_image: batch={batch_id}, person={person_index}, "
  289. f"PTZ=({ptz_position[0]:.1f}°, {ptz_position[1]:.1f}°, zoom={ptz_position[2]})")
  290. with self._batch_lock:
  291. if self._current_batch is None or self._current_batch.batch_id != batch_id:
  292. logger.warning(f"[配对保存] 批次不存在或已过期: {batch_id}, current={self._current_batch.batch_id if self._current_batch else None}")
  293. return None
  294. batch_dir = self.base_dir / f"batch_{batch_id}"
  295. try:
  296. # 复制图像
  297. marked_frame = ptz_frame.copy() if ptz_frame is not None else None
  298. if marked_frame is not None:
  299. # 在球机图上添加标记(如果有检测框)
  300. h, w = marked_frame.shape[:2]
  301. # 添加PTZ位置信息到图片
  302. pan, tilt, zoom = ptz_position
  303. info_text = f"PTZ: P={pan:.1f} T={tilt:.1f} Z={zoom}"
  304. cv2.putText(
  305. marked_frame, info_text,
  306. (10, 30),
  307. cv2.FONT_HERSHEY_SIMPLEX, 0.7,
  308. (0, 255, 0), 2
  309. )
  310. # 添加人员序号
  311. person_text = f"person_{person_index}"
  312. cv2.putText(
  313. marked_frame, person_text,
  314. (10, 60),
  315. cv2.FONT_HERSHEY_SIMPLEX, 0.7,
  316. (0, 255, 0), 2
  317. )
  318. # 绘制PTZ检测到的bbox(红色)
  319. if ptz_bbox is not None:
  320. x1, y1, x2, y2 = ptz_bbox
  321. cv2.rectangle(marked_frame, (x1, y1), (x2, y2), (0, 0, 255), 2)
  322. bbox_text = f"PTZ_BBox: ({x1},{y1},{x2},{y2})"
  323. cv2.putText(
  324. marked_frame, bbox_text,
  325. (10, 90),
  326. cv2.FONT_HERSHEY_SIMPLEX, 0.6,
  327. (0, 0, 255), 2
  328. )
  329. # 保存图片
  330. filename = f"01_ptz_person{person_index}_p{int(ptz_position[0])}_t{int(ptz_position[1])}_z{int(ptz_position[2])}.jpg"
  331. filepath = batch_dir / filename
  332. if marked_frame is not None:
  333. cv2.imwrite(str(filepath), marked_frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
  334. # 更新批次信息
  335. # 【关键修复】只有当人员索引有效时才更新和计数
  336. if person_index < len(self._current_batch.persons):
  337. self._current_batch.persons[person_index].ptz_position = ptz_position
  338. self._current_batch.persons[person_index].ptz_bbox = ptz_bbox
  339. self._current_batch.persons[person_index].ptz_image_saved = True
  340. self._current_batch.persons[person_index].ptz_image_path = str(filepath)
  341. self._current_batch.ptz_images_count += 1
  342. with self._stats_lock:
  343. self._stats['total_ptz_images'] += 1
  344. logger.info(f"[配对保存] 球机图已保存: {filepath}, BBox={ptz_bbox}")
  345. # 上传球机图到 OSS
  346. logger.info(f"[配对保存] 准备上传球机图到 OSS: enable_oss={self.enable_oss}, uploader={self.oss_uploader}")
  347. if self.enable_oss and self.oss_uploader:
  348. self._upload_ptz_to_oss(batch_id, person_index, str(filepath))
  349. else:
  350. # 人员索引超出范围,说明批次信息不一致,跳过保存
  351. logger.warning(f"[配对保存] 人员索引 {person_index} 超出批次范围 {len(self._current_batch.persons)},跳过计数")
  352. return str(filepath)
  353. except Exception as e:
  354. logger.error(f"[配对保存] 保存球机图失败: {e}")
  355. return None
  356. def _upload_panorama_to_oss(self, batch_id: str, panorama_path: str):
  357. """上传全景图到 OSS"""
  358. logger.info(f"[OSS] _upload_panorama_to_oss 被调用: batch_id={batch_id}, path={panorama_path}")
  359. def on_upload_complete(result):
  360. logger.info(f"[OSS] 全景图上传完成回调: success={result.success}, url={result.oss_url}")
  361. # 更新上传状态(即使 _current_batch 已切换也能正确记录)
  362. self._upload_status[batch_id]['panorama'] = True
  363. self._upload_status[batch_id]['panorama_url'] = result.oss_url
  364. if self._current_batch and self._current_batch.batch_id == batch_id:
  365. self._current_batch.panorama_oss_url = result.oss_url
  366. with self._stats_lock:
  367. self._stats['oss_upload_success'] += 1
  368. logger.info(f"[OSS] 全景图上传成功: {result.oss_url}")
  369. def on_upload_error(result):
  370. logger.error(f"[OSS] 全景图上传失败回调: {result.error}")
  371. self._upload_status[batch_id]['panorama'] = False
  372. self._upload_status[batch_id]['panorama_url'] = None
  373. with self._stats_lock:
  374. self._stats['oss_upload_failed'] += 1
  375. logger.error(f"[OSS] 全景图上传失败: {result.error}")
  376. # 包装回调,同时处理成功和失败
  377. def on_upload_done(result):
  378. logger.info(f"[OSS] 全景图上传结果: success={result.success}")
  379. if result.success:
  380. on_upload_complete(result)
  381. else:
  382. on_upload_error(result)
  383. try:
  384. oss_key = self.oss_uploader.upload_image(
  385. local_path=panorama_path,
  386. batch_id=batch_id,
  387. image_type='panorama',
  388. callback=on_upload_done
  389. )
  390. logger.info(f"[OSS] 全景图已加入上传队列: oss_key={oss_key}")
  391. except Exception as e:
  392. logger.error(f"[OSS] 全景图上传异常: {e}")
  393. def _upload_ptz_to_oss(self, batch_id: str, person_index: int, ptz_path: str):
  394. """上传球机图到 OSS"""
  395. logger.info(f"[OSS] _upload_ptz_to_oss 被调用: batch_id={batch_id}, person={person_index}, path={ptz_path}")
  396. def on_upload_complete(result):
  397. logger.info(f"[OSS] 球机图上传完成回调: person={person_index}, success={result.success}, url={result.oss_url}")
  398. # 更新上传状态(即使 _current_batch 已切换也能正确记录)
  399. self._upload_status[batch_id]['ptz'][person_index] = result.oss_url
  400. if self._current_batch and self._current_batch.batch_id == batch_id:
  401. if person_index < len(self._current_batch.persons):
  402. self._current_batch.persons[person_index].ptz_oss_url = result.oss_url
  403. with self._stats_lock:
  404. self._stats['oss_upload_success'] += 1
  405. logger.info(f"[OSS] 球机图上传成功 (person_{person_index}): {result.oss_url}")
  406. def on_upload_error(result):
  407. logger.error(f"[OSS] 球机图上传失败回调: person={person_index}, error={result.error}")
  408. self._upload_status[batch_id]['ptz'][person_index] = None
  409. with self._stats_lock:
  410. self._stats['oss_upload_failed'] += 1
  411. logger.error(f"[OSS] 球机图上传失败 (person_{person_index}): {result.error}")
  412. # 包装回调,同时处理成功和失败
  413. def on_upload_done(result):
  414. logger.info(f"[OSS] 球机图上传结果: person={person_index}, success={result.success}")
  415. if result.success:
  416. on_upload_complete(result)
  417. else:
  418. on_upload_error(result)
  419. try:
  420. oss_key = self.oss_uploader.upload_image(
  421. local_path=ptz_path,
  422. batch_id=batch_id,
  423. image_type='ptz',
  424. person_index=person_index,
  425. callback=on_upload_done
  426. )
  427. logger.info(f"[OSS] 球机图已加入上传队列: person={person_index}, oss_key={oss_key}")
  428. except Exception as e:
  429. logger.error(f"[OSS] 球机图上传异常: {e}")
  430. def _finalize_batch(self, batch: DetectionBatch):
  431. """完成批次处理"""
  432. batch.completed = True
  433. # 等待 OSS 上传完成(最多等待10秒)
  434. if self.enable_oss and batch.batch_id in self._upload_status:
  435. wait_start = time.time()
  436. max_wait = 10.0 # 增加等待时间
  437. while time.time() - wait_start < max_wait:
  438. status = self._upload_status[batch.batch_id]
  439. # 检查全景图是否上传完成(优先检查 _upload_status 中的状态)
  440. panorama_url = status.get('panorama_url')
  441. panorama_done = panorama_url is not None or not batch.panorama_path
  442. # 检查所有球机图是否上传完成
  443. ptz_status = status.get('ptz', {})
  444. ptz_done = all(
  445. ptz_status.get(idx) is not None
  446. for idx, person in enumerate(batch.persons)
  447. if person.ptz_image_saved
  448. )
  449. if panorama_done and ptz_done:
  450. break
  451. time.sleep(0.2)
  452. # 创建 batch_info.json 文件
  453. try:
  454. batch_dir = self.base_dir / f"batch_{batch.batch_id}"
  455. # 构建 JSON 数据
  456. batch_info = self._build_batch_info_json(batch)
  457. # 保存为 JSON 文件
  458. json_path = batch_dir / "batch_info.json"
  459. with open(json_path, 'w', encoding='utf-8') as f:
  460. json.dump(batch_info, f, ensure_ascii=False, indent=2)
  461. logger.info(f"[配对保存] batch_info.json 已保存: {json_path}")
  462. # 同时保留 txt 格式用于兼容(可选)
  463. txt_path = batch_dir / "batch_info.txt"
  464. self._save_batch_info_txt(batch, txt_path)
  465. # 标记上传完成
  466. if batch.batch_id in self._upload_status:
  467. self._upload_status[batch.batch_id]['completed'] = True
  468. # 触发回调
  469. if self._upload_callback:
  470. try:
  471. self._upload_callback(batch_info)
  472. except Exception as e:
  473. logger.error(f"[配对保存] 回调执行错误: {e}")
  474. logger.info(f"[配对保存] 批次完成: {batch.batch_id}, "
  475. f"人员={batch.total_persons}, 球机图={batch.ptz_images_count}")
  476. except Exception as e:
  477. logger.error(f"[配对保存] 保存批次信息失败: {e}")
  478. # 清理旧批次
  479. self._cleanup_old_batches()
  480. def _build_batch_info_json(self, batch: DetectionBatch) -> Dict:
  481. """
  482. 构建 batch_info.json 数据结构
  483. Returns:
  484. Dict: 批次信息字典
  485. """
  486. # 从上传状态获取最新的 OSS URL(异步回调可能已更新)
  487. upload_status = self._upload_status.get(batch.batch_id, {})
  488. # 获取全景图 OSS URL(优先使用回调更新的状态)
  489. panorama_oss_url = upload_status.get('panorama_url', batch.panorama_oss_url)
  490. # 人员信息列表
  491. persons_list = []
  492. for person in batch.persons:
  493. # 获取球机图 OSS URL(优先使用回调更新的状态)
  494. ptz_oss_url = upload_status.get('ptz', {}).get(person.person_index, person.ptz_oss_url)
  495. person_data = {
  496. 'person_index': person.person_index,
  497. 'position': {
  498. 'x': round(person.position[0], 4),
  499. 'y': round(person.position[1], 4)
  500. },
  501. 'bbox': {
  502. 'x1': person.bbox[0],
  503. 'y1': person.bbox[1],
  504. 'x2': person.bbox[2],
  505. 'y2': person.bbox[3]
  506. },
  507. 'confidence': round(person.confidence, 4),
  508. 'ptz_position': {
  509. 'pan': round(person.ptz_position[0], 2) if person.ptz_position else None,
  510. 'tilt': round(person.ptz_position[1], 2) if person.ptz_position else None,
  511. 'zoom': person.ptz_position[2] if person.ptz_position else None
  512. } if person.ptz_position else None,
  513. 'ptz_bbox': {
  514. 'x1': person.ptz_bbox[0],
  515. 'y1': person.ptz_bbox[1],
  516. 'x2': person.ptz_bbox[2],
  517. 'y2': person.ptz_bbox[3]
  518. } if person.ptz_bbox else None,
  519. 'ptz_image_saved': person.ptz_image_saved,
  520. 'ptz_image_path': person.ptz_image_path,
  521. 'ptz_oss_url': ptz_oss_url
  522. }
  523. persons_list.append(person_data)
  524. # 构建完整批次信息
  525. batch_info = {
  526. 'batch_id': batch.batch_id,
  527. 'device_id': batch.device_id,
  528. 'project_id': batch.project_id,
  529. 'timestamp': batch.timestamp,
  530. 'datetime': datetime.fromtimestamp(batch.timestamp).isoformat(),
  531. 'total_persons': batch.total_persons,
  532. 'ptz_images_count': batch.ptz_images_count,
  533. 'panorama': {
  534. 'local_path': batch.panorama_path,
  535. 'oss_url': panorama_oss_url
  536. },
  537. 'persons': persons_list,
  538. 'upload_status': {
  539. 'panorama_uploaded': panorama_oss_url is not None,
  540. 'all_ptz_uploaded': all(
  541. upload_status.get('ptz', {}).get(p.person_index) is not None
  542. for p in batch.persons if p.ptz_image_saved
  543. )
  544. }
  545. }
  546. return batch_info
  547. def _save_batch_info_txt(self, batch: DetectionBatch, txt_path: Path):
  548. """保存批次信息为 TXT 格式(兼容旧版本)"""
  549. try:
  550. with open(txt_path, 'w', encoding='utf-8') as f:
  551. f.write(f"批次ID: {batch.batch_id}\n")
  552. f.write(f"设备ID: {batch.device_id}\n")
  553. f.write(f"项目ID: {batch.project_id}\n")
  554. f.write(f"时间戳: {datetime.fromtimestamp(batch.timestamp)}\n")
  555. f.write(f"总人数: {batch.total_persons}\n")
  556. f.write(f"球机图数量: {batch.ptz_images_count}\n")
  557. f.write(f"全景图: {batch.panorama_path}\n")
  558. f.write(f"全景图OSS: {batch.panorama_oss_url}\n")
  559. f.write("\n人员详情:\n")
  560. for i, person in enumerate(batch.persons):
  561. f.write(f"\n Person {i}:\n")
  562. f.write(f" Person Index: {person.person_index}\n")
  563. f.write(f" Position: ({person.position[0]:.3f}, {person.position[1]:.3f})\n")
  564. f.write(f" BBox: ({person.bbox[0]}, {person.bbox[1]}, {person.bbox[2]}, {person.bbox[3]})\n")
  565. f.write(f" Confidence: {person.confidence:.2f}\n")
  566. f.write(f" PTZ Position: {person.ptz_position}\n")
  567. if person.ptz_bbox:
  568. f.write(f" PTZ BBox: ({person.ptz_bbox[0]}, {person.ptz_bbox[1]}, {person.ptz_bbox[2]}, {person.ptz_bbox[3]})\n")
  569. else:
  570. f.write(f" PTZ BBox: None\n")
  571. f.write(f" PTZ Image: {person.ptz_image_path}\n")
  572. f.write(f" PTZ OSS URL: {person.ptz_oss_url}\n")
  573. except Exception as e:
  574. logger.error(f"[配对保存] 保存 TXT 批次信息失败: {e}")
  575. def _cleanup_old_batches(self):
  576. """清理旧批次目录"""
  577. try:
  578. batch_dirs = sorted(
  579. [d for d in self.base_dir.iterdir() if d.is_dir() and d.name.startswith('batch_')],
  580. key=lambda x: x.stat().st_mtime
  581. )
  582. if len(batch_dirs) > self.max_batches:
  583. to_delete = batch_dirs[:len(batch_dirs) - self.max_batches]
  584. for d in to_delete:
  585. import shutil
  586. shutil.rmtree(d)
  587. logger.info(f"[配对保存] 清理旧批次: {d.name}")
  588. except Exception as e:
  589. logger.error(f"[配对保存] 清理旧批次失败: {e}")
  590. def get_current_batch_id(self) -> Optional[str]:
  591. """获取当前批次ID"""
  592. with self._batch_lock:
  593. return self._current_batch.batch_id if self._current_batch else None
  594. def get_stats(self) -> Dict:
  595. """获取统计信息"""
  596. with self._stats_lock:
  597. return self._stats.copy()
  598. def set_upload_callback(self, callback: Callable):
  599. """
  600. 设置批次完成回调函数
  601. Args:
  602. callback: 回调函数,接收 batch_info_dict 参数
  603. """
  604. self._upload_callback = callback
  605. def get_batch_info(self, batch_id: str) -> Optional[Dict]:
  606. """
  607. 获取指定批次的 batch_info.json 数据
  608. Args:
  609. batch_id: 批次ID
  610. Returns:
  611. Dict 或 None
  612. """
  613. try:
  614. batch_dir = self.base_dir / f"batch_{batch_id}"
  615. json_path = batch_dir / "batch_info.json"
  616. if json_path.exists():
  617. with open(json_path, 'r', encoding='utf-8') as f:
  618. return json.load(f)
  619. except Exception as e:
  620. logger.error(f"[配对保存] 读取 batch_info.json 失败: {e}")
  621. return None
  622. def close(self):
  623. """关闭管理器,完成当前批次"""
  624. with self._batch_lock:
  625. if self._current_batch is not None:
  626. self._finalize_batch(self._current_batch)
  627. self._current_batch = None
  628. logger.info("[配对保存] 管理器已关闭")
  629. # 全局单例实例
  630. _paired_saver_instance: Optional[PairedImageSaver] = None
  631. def get_paired_saver(base_dir: str = None, time_window: float = 5.0,
  632. enable_oss: bool = False, oss_uploader = None,
  633. device_config: Dict = None) -> PairedImageSaver:
  634. """
  635. 获取配对保存管理器实例(单例模式)
  636. 如果实例已存在但缺少 OSS/设备配置,会自动从配置模块更新
  637. Args:
  638. base_dir: 基础保存目录
  639. time_window: 时间窗口
  640. enable_oss: 是否启用 OSS 上传
  641. oss_uploader: OSS 上传器实例
  642. device_config: 设备配置字典
  643. Returns:
  644. PairedImageSaver 实例
  645. """
  646. global _paired_saver_instance
  647. if _paired_saver_instance is None:
  648. _paired_saver_instance = PairedImageSaver(
  649. base_dir=base_dir or '/home/admin/dsh/paired_images',
  650. time_window=time_window,
  651. enable_oss=enable_oss,
  652. oss_uploader=oss_uploader,
  653. device_config=device_config
  654. )
  655. else:
  656. # 单例已存在,检查是否需要更新配置
  657. # PairedImageSaver.__init__ 已从配置模块自动读取,
  658. # 这里只处理外部显式传入的参数覆盖
  659. if oss_uploader is not None and _paired_saver_instance.oss_uploader is None:
  660. _paired_saver_instance.oss_uploader = oss_uploader
  661. _paired_saver_instance.enable_oss = True
  662. logger.info("[配对保存] 更新 OSS 上传器配置")
  663. if device_config is not None:
  664. _paired_saver_instance.device_config.update(device_config)
  665. logger.info(f"[配对保存] 更新设备配置: {device_config}")
  666. return _paired_saver_instance
  667. def reset_paired_saver():
  668. """重置单例实例(用于测试)"""
  669. global _paired_saver_instance
  670. if _paired_saver_instance is not None:
  671. _paired_saver_instance.close()
  672. _paired_saver_instance = None