coordinator.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924
  1. """
  2. 联动控制器
  3. 协调全景摄像头和球机的工作
  4. """
  5. import time
  6. import threading
  7. import queue
  8. import logging
  9. from typing import Optional, List, Dict, Tuple, Callable
  10. from dataclasses import dataclass, field
  11. from enum import Enum
  12. import numpy as np
  13. from config import COORDINATOR_CONFIG, SYSTEM_CONFIG
  14. from panorama_camera import PanoramaCamera, ObjectDetector, PersonTracker, DetectedObject
  15. from ptz_camera import PTZCamera, PTZController
  16. from ocr_recognizer import NumberDetector, PersonInfo
  17. logger = logging.getLogger(__name__)
  18. class TrackingState(Enum):
  19. """跟踪状态"""
  20. IDLE = 0 # 空闲
  21. SEARCHING = 1 # 搜索目标
  22. TRACKING = 2 # 跟踪中
  23. ZOOMING = 3 # 变焦中
  24. OCR_PROCESSING = 4 # OCR处理中
  25. @dataclass
  26. class TrackingTarget:
  27. """跟踪目标"""
  28. track_id: int # 跟踪ID
  29. position: Tuple[float, float] # 位置比例 (x_ratio, y_ratio)
  30. last_update: float # 最后更新时间
  31. person_info: Optional[PersonInfo] = None # 人员信息
  32. priority: int = 0 # 优先级
  33. class Coordinator:
  34. """
  35. 联动控制器
  36. 协调全景摄像头和球机实现联动抓拍
  37. """
  38. def __init__(self, panorama_camera: PanoramaCamera,
  39. ptz_camera: PTZCamera,
  40. detector: ObjectDetector = None,
  41. number_detector: NumberDetector = None,
  42. calibrator = None):
  43. """
  44. 初始化联动控制器
  45. Args:
  46. panorama_camera: 全景摄像头
  47. ptz_camera: 球机
  48. detector: 物体检测器
  49. number_detector: 编号检测器
  50. calibrator: 校准器 (用于坐标转换)
  51. """
  52. self.panorama = panorama_camera
  53. self.ptz = ptz_camera
  54. self.detector = detector
  55. self.number_detector = number_detector
  56. self.calibrator = calibrator
  57. self.config = COORDINATOR_CONFIG
  58. # 功能开关 - 从 SYSTEM_CONFIG 读取
  59. self.enable_ptz_camera = SYSTEM_CONFIG.get('enable_ptz_camera', True)
  60. self.enable_ptz_tracking = SYSTEM_CONFIG.get('enable_ptz_tracking', True)
  61. self.enable_calibration = SYSTEM_CONFIG.get('enable_calibration', True)
  62. self.enable_detection = SYSTEM_CONFIG.get('enable_detection', True)
  63. self.enable_ocr = SYSTEM_CONFIG.get('enable_ocr', True)
  64. # 跟踪器
  65. self.tracker = PersonTracker()
  66. # 状态
  67. self.state = TrackingState.IDLE
  68. self.state_lock = threading.Lock()
  69. # 跟踪目标
  70. self.tracking_targets: Dict[int, TrackingTarget] = {}
  71. self.targets_lock = threading.Lock()
  72. # 当前跟踪目标
  73. self.current_target: Optional[TrackingTarget] = None
  74. # 回调函数
  75. self.on_person_detected: Optional[Callable] = None
  76. self.on_number_recognized: Optional[Callable] = None
  77. self.on_tracking_started: Optional[Callable] = None
  78. self.on_tracking_stopped: Optional[Callable] = None
  79. # 控制标志
  80. self.running = False
  81. self.coordinator_thread = None
  82. # OCR频率控制
  83. self.last_ocr_time = 0
  84. self.ocr_interval = 1.0 # OCR间隔(秒),避免过于频繁调用API
  85. # PTZ优化 - 避免频繁发送相同位置的命令
  86. self.last_ptz_position = None
  87. self.ptz_position_threshold = self.config.get('ptz_position_threshold', 0.03)
  88. # 结果队列
  89. self.result_queue = queue.Queue()
  90. # 性能统计
  91. self.stats = {
  92. 'frames_processed': 0,
  93. 'persons_detected': 0,
  94. 'ocr_attempts': 0,
  95. 'ocr_success': 0,
  96. 'start_time': None,
  97. 'last_frame_time': None,
  98. }
  99. self.stats_lock = threading.Lock()
  100. def set_calibrator(self, calibrator):
  101. """设置校准器"""
  102. self.calibrator = calibrator
  103. def _transform_position(self, x_ratio: float, y_ratio: float) -> Tuple[float, float, int]:
  104. """
  105. 将全景坐标转换为PTZ角度
  106. Args:
  107. x_ratio: X方向比例
  108. y_ratio: Y方向比例
  109. Returns:
  110. (pan, tilt, zoom)
  111. """
  112. if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
  113. # 使用校准结果进行转换
  114. pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
  115. zoom = 8 # 默认变倍
  116. else:
  117. # 使用默认估算
  118. pan, tilt, zoom = self.ptz.calculate_ptz_position(x_ratio, y_ratio)
  119. return (pan, tilt, zoom)
  120. def start(self) -> bool:
  121. """
  122. 启动联动系统
  123. Returns:
  124. 是否成功
  125. """
  126. # 连接全景摄像头
  127. if not self.panorama.connect():
  128. print("连接全景摄像头失败")
  129. return False
  130. # 连接 PTZ 球机 (可选)
  131. if self.enable_ptz_camera:
  132. if not self.ptz.connect():
  133. print("连接球机失败")
  134. self.panorama.disconnect()
  135. return False
  136. else:
  137. print("PTZ 球机功能已禁用")
  138. # 启动视频流(优先RTSP,SDK回调不可用时回退)
  139. if not self.panorama.start_stream_rtsp():
  140. print("RTSP视频流启动失败,尝试SDK方式...")
  141. if not self.panorama.start_stream():
  142. print("启动视频流失败")
  143. self.panorama.disconnect()
  144. if self.enable_ptz_camera:
  145. self.ptz.disconnect()
  146. return False
  147. # 启动联动线程
  148. self.running = True
  149. self.coordinator_thread = threading.Thread(target=self._coordinator_worker, daemon=True)
  150. self.coordinator_thread.start()
  151. print("联动系统已启动")
  152. return True
  153. def stop(self):
  154. """停止联动系统"""
  155. self.running = False
  156. if self.coordinator_thread:
  157. self.coordinator_thread.join(timeout=3)
  158. self.panorama.disconnect()
  159. if self.enable_ptz_camera:
  160. self.ptz.disconnect()
  161. # 打印统计信息
  162. self._print_stats()
  163. print("联动系统已停止")
  164. def _update_stats(self, key: str, value: int = 1):
  165. """更新统计信息"""
  166. with self.stats_lock:
  167. if key in self.stats:
  168. self.stats[key] += value
  169. def _print_stats(self):
  170. """打印统计信息"""
  171. with self.stats_lock:
  172. if self.stats['start_time'] and self.stats['frames_processed'] > 0:
  173. elapsed = time.time() - self.stats['start_time']
  174. fps = self.stats['frames_processed'] / elapsed
  175. print("\n=== 性能统计 ===")
  176. print(f"运行时长: {elapsed:.1f}秒")
  177. print(f"处理帧数: {self.stats['frames_processed']}")
  178. print(f"平均帧率: {fps:.1f} fps")
  179. print(f"检测人体: {self.stats['persons_detected']}次")
  180. print(f"OCR尝试: {self.stats['ocr_attempts']}次")
  181. print(f"OCR成功: {self.stats['ocr_success']}次")
  182. print("================\n")
  183. def get_stats(self) -> dict:
  184. """获取统计信息"""
  185. with self.stats_lock:
  186. return self.stats.copy()
  187. def _coordinator_worker(self):
  188. """联动工作线程"""
  189. last_detection_time = 0
  190. detection_interval = self.config.get('detection_interval', 1.0)
  191. # 初始化统计
  192. with self.stats_lock:
  193. self.stats['start_time'] = time.time()
  194. while self.running:
  195. try:
  196. current_time = time.time()
  197. # 获取当前帧
  198. frame = self.panorama.get_frame()
  199. if frame is None:
  200. time.sleep(0.01)
  201. continue
  202. # 更新帧统计
  203. self._update_stats('frames_processed')
  204. frame_size = (frame.shape[1], frame.shape[0])
  205. # 周期性检测
  206. if current_time - last_detection_time >= detection_interval:
  207. last_detection_time = current_time
  208. # 检测人体
  209. detections = self._detect_persons(frame)
  210. # 更新检测统计
  211. if detections:
  212. self._update_stats('persons_detected', len(detections))
  213. # 更新跟踪
  214. tracked = self.tracker.update(detections)
  215. # 更新跟踪目标
  216. self._update_tracking_targets(tracked, frame_size)
  217. # 处理检测结果
  218. if tracked:
  219. self._process_detections(tracked, frame, frame_size)
  220. # 处理当前跟踪目标
  221. self._process_current_target(frame, frame_size)
  222. # 清理过期目标
  223. self._cleanup_expired_targets()
  224. time.sleep(0.01)
  225. except Exception as e:
  226. print(f"联动处理错误: {e}")
  227. time.sleep(0.1)
  228. def _detect_persons(self, frame: np.ndarray) -> List[DetectedObject]:
  229. """检测人体"""
  230. if not self.enable_detection or self.detector is None:
  231. return []
  232. return self.detector.detect_persons(frame)
  233. def _update_tracking_targets(self, detections: List[DetectedObject],
  234. frame_size: Tuple[int, int]):
  235. """更新跟踪目标"""
  236. current_time = time.time()
  237. with self.targets_lock:
  238. # 更新现有目标
  239. for det in detections:
  240. if det.track_id is None:
  241. continue
  242. x_ratio = det.center[0] / frame_size[0]
  243. y_ratio = det.center[1] / frame_size[1]
  244. if det.track_id in self.tracking_targets:
  245. # 更新位置
  246. target = self.tracking_targets[det.track_id]
  247. target.position = (x_ratio, y_ratio)
  248. target.last_update = current_time
  249. else:
  250. # 新目标
  251. if len(self.tracking_targets) < self.config['max_tracking_targets']:
  252. self.tracking_targets[det.track_id] = TrackingTarget(
  253. track_id=det.track_id,
  254. position=(x_ratio, y_ratio),
  255. last_update=current_time
  256. )
  257. def _process_detections(self, detections: List[DetectedObject],
  258. frame: np.ndarray, frame_size: Tuple[int, int]):
  259. """处理检测结果"""
  260. if self.on_person_detected:
  261. for det in detections:
  262. self.on_person_detected(det, frame)
  263. def _process_current_target(self, frame: np.ndarray, frame_size: Tuple[int, int]):
  264. """处理当前跟踪目标"""
  265. with self.targets_lock:
  266. if not self.tracking_targets:
  267. self._set_state(TrackingState.IDLE)
  268. self.current_target = None
  269. return
  270. # 选择优先级最高的目标(这里选择最新的)
  271. if self.current_target is None or \
  272. self.current_target.track_id not in self.tracking_targets:
  273. # 选择一个新目标
  274. target_id = list(self.tracking_targets.keys())[0]
  275. self.current_target = self.tracking_targets[target_id]
  276. if self.current_target:
  277. # 移动球机到目标位置 (仅在 PTZ 跟踪启用时)
  278. if self.enable_ptz_tracking and self.enable_ptz_camera:
  279. self._set_state(TrackingState.TRACKING)
  280. x_ratio, y_ratio = self.current_target.position
  281. # 检查位置是否变化超过阈值
  282. should_move = True
  283. if self.last_ptz_position is not None:
  284. last_x, last_y = self.last_ptz_position
  285. if (abs(x_ratio - last_x) < self.ptz_position_threshold and
  286. abs(y_ratio - last_y) < self.ptz_position_threshold):
  287. should_move = False
  288. if should_move:
  289. if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
  290. pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
  291. if self.ptz.ptz_config.get('pan_flip', False):
  292. pan = (pan + 180) % 360
  293. zoom = self.ptz.ptz_config.get('default_zoom', 8)
  294. self.ptz.goto_exact_position(pan, tilt, zoom)
  295. else:
  296. self.ptz.track_target(x_ratio, y_ratio)
  297. self.last_ptz_position = (x_ratio, y_ratio)
  298. # 执行OCR识别 (仅在 OCR 启用时)
  299. if self.enable_ocr:
  300. self._perform_ocr(frame, self.current_target)
  301. def _perform_ocr(self, frame: np.ndarray, target: TrackingTarget):
  302. """执行OCR识别"""
  303. if not self.enable_ocr or self.number_detector is None:
  304. return
  305. # 频率控制 - 避免过于频繁调用OCR API
  306. current_time = time.time()
  307. if current_time - self.last_ocr_time < self.ocr_interval:
  308. return
  309. self.last_ocr_time = current_time
  310. # 更新OCR尝试统计
  311. self._update_stats('ocr_attempts')
  312. # 计算人体边界框 (基于位置估算)
  313. frame_h, frame_w = frame.shape[:2]
  314. # 人体占画面比例 (可配置,默认宽20%、高40%)
  315. person_width_ratio = self.config.get('person_width_ratio', 0.2)
  316. person_height_ratio = self.config.get('person_height_ratio', 0.4)
  317. person_width = int(frame_w * person_width_ratio)
  318. person_height = int(frame_h * person_height_ratio)
  319. x_ratio, y_ratio = target.position
  320. center_x = int(x_ratio * frame_w)
  321. center_y = int(y_ratio * frame_h)
  322. # 计算边界框,确保不超出画面范围
  323. x1 = max(0, center_x - person_width // 2)
  324. y1 = max(0, center_y - person_height // 2)
  325. x2 = min(frame_w, x1 + person_width)
  326. y2 = min(frame_h, y1 + person_height)
  327. # 更新实际宽高 (可能因边界裁剪而变小)
  328. actual_width = x2 - x1
  329. actual_height = y2 - y1
  330. person_bbox = (x1, y1, actual_width, actual_height)
  331. # 检测编号
  332. self._set_state(TrackingState.OCR_PROCESSING)
  333. person_info = self.number_detector.detect_number(frame, person_bbox)
  334. person_info.person_id = target.track_id
  335. # 更新OCR成功统计
  336. if person_info.number_text:
  337. self._update_stats('ocr_success')
  338. # 更新目标信息
  339. with self.targets_lock:
  340. if target.track_id in self.tracking_targets:
  341. self.tracking_targets[target.track_id].person_info = person_info
  342. # 回调
  343. if self.on_number_recognized and person_info.number_text:
  344. self.on_number_recognized(person_info)
  345. # 放入结果队列
  346. self.result_queue.put(person_info)
  347. def _cleanup_expired_targets(self):
  348. """清理过期目标"""
  349. current_time = time.time()
  350. timeout = self.config['tracking_timeout']
  351. with self.targets_lock:
  352. expired_ids = [
  353. target_id for target_id, target in self.tracking_targets.items()
  354. if current_time - target.last_update > timeout
  355. ]
  356. for target_id in expired_ids:
  357. del self.tracking_targets[target_id]
  358. if self.current_target and self.current_target.track_id == target_id:
  359. self.current_target = None
  360. def _set_state(self, state: TrackingState):
  361. """设置状态"""
  362. with self.state_lock:
  363. self.state = state
  364. def get_state(self) -> TrackingState:
  365. """获取状态"""
  366. with self.state_lock:
  367. return self.state
  368. def get_results(self) -> List[PersonInfo]:
  369. """
  370. 获取识别结果
  371. Returns:
  372. 人员信息列表
  373. """
  374. results = []
  375. while not self.result_queue.empty():
  376. try:
  377. results.append(self.result_queue.get_nowait())
  378. except queue.Empty:
  379. break
  380. return results
  381. def get_tracking_targets(self) -> List[TrackingTarget]:
  382. """获取当前跟踪目标"""
  383. with self.targets_lock:
  384. return list(self.tracking_targets.values())
  385. def force_track_position(self, x_ratio: float, y_ratio: float, zoom: int = None):
  386. """
  387. 强制跟踪指定位置
  388. Args:
  389. x_ratio: X方向比例
  390. y_ratio: Y方向比例
  391. zoom: 变倍
  392. """
  393. if self.enable_ptz_tracking and self.enable_ptz_camera:
  394. if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
  395. pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
  396. if self.ptz.ptz_config.get('pan_flip', False):
  397. pan = (pan + 180) % 360
  398. self.ptz.goto_exact_position(pan, tilt, zoom or self.ptz.ptz_config.get('default_zoom', 8))
  399. else:
  400. self.ptz.move_to_target(x_ratio, y_ratio, zoom)
  401. def capture_snapshot(self) -> Optional[np.ndarray]:
  402. """
  403. 抓拍快照
  404. Returns:
  405. 快照图像
  406. """
  407. return self.panorama.get_frame()
  408. class EventDrivenCoordinator(Coordinator):
  409. """事件驱动联动控制器,当全景摄像头检测到事件时触发联动"""
  410. def __init__(self, *args, **kwargs):
  411. super().__init__(*args, **kwargs)
  412. self.event_types = {
  413. 'intruder': True,
  414. 'crossline': True,
  415. 'motion': True,
  416. }
  417. self.event_queue = queue.Queue()
  418. def on_event(self, event_type: str, event_data: dict):
  419. if not self.event_types.get(event_type, False):
  420. return
  421. self.event_queue.put({'type': event_type, 'data': event_data, 'time': time.time()})
  422. def _coordinator_worker(self):
  423. while self.running:
  424. try:
  425. try:
  426. event = self.event_queue.get(timeout=0.1)
  427. self._process_event(event)
  428. except queue.Empty:
  429. pass
  430. frame = self.panorama.get_frame()
  431. if frame is not None:
  432. frame_size = (frame.shape[1], frame.shape[0])
  433. detections = self._detect_persons(frame)
  434. if detections:
  435. tracked = self.tracker.update(detections)
  436. self._update_tracking_targets(tracked, frame_size)
  437. self._process_current_target(frame, frame_size)
  438. self._cleanup_expired_targets()
  439. except Exception as e:
  440. print(f"事件处理错误: {e}")
  441. time.sleep(0.1)
  442. def _process_event(self, event: dict):
  443. event_type = event['type']
  444. event_data = event['data']
  445. print(f"处理事件: {event_type}")
  446. if event_type == 'intruder' and 'position' in event_data:
  447. x_ratio, y_ratio = event_data['position']
  448. self.force_track_position(x_ratio, y_ratio)
  449. @dataclass
  450. class PTZCommand:
  451. """PTZ控制命令"""
  452. pan: float
  453. tilt: float
  454. zoom: int
  455. x_ratio: float = 0.0
  456. y_ratio: float = 0.0
  457. use_calibration: bool = True
  458. class AsyncCoordinator(Coordinator):
  459. """
  460. 异步联动控制器 — 检测线程与PTZ控制线程分离
  461. 改进:
  462. 1. 检测线程:持续读取全景帧 + YOLO推理
  463. 2. PTZ控制线程:通过命令队列接收目标位置,独立控制球机
  464. 3. 两线程通过 queue 通信,互不阻塞
  465. 4. PTZ位置确认:移动后等待球机到位并验证帧
  466. """
  467. PTZ_CONFIRM_WAIT = 0.3 # PTZ命令后等待稳定的秒数
  468. PTZ_CONFIRM_TIMEOUT = 2.0 # PTZ位置确认超时
  469. PTZ_COMMAND_COOLDOWN = 0.15 # PTZ命令最小间隔秒数
  470. def __init__(self, *args, **kwargs):
  471. super().__init__(*args, **kwargs)
  472. # PTZ命令队列(检测→PTZ)
  473. self._ptz_queue: queue.Queue = queue.Queue(maxsize=10)
  474. # 线程
  475. self._detection_thread = None
  476. self._ptz_thread = None
  477. # PTZ确认回调
  478. self._on_ptz_confirmed: Optional[Callable] = None
  479. # 上次PTZ命令时间
  480. self._last_ptz_time = 0.0
  481. def start(self) -> bool:
  482. """启动联动(覆盖父类,启动双线程)"""
  483. if not self.panorama.connect():
  484. print("连接全景摄像头失败")
  485. return False
  486. if self.enable_ptz_camera:
  487. if not self.ptz.connect():
  488. print("连接球机失败")
  489. self.panorama.disconnect()
  490. return False
  491. else:
  492. print("PTZ球机功能已禁用")
  493. if not self.panorama.start_stream_rtsp():
  494. print("RTSP视频流启动失败,尝试SDK方式...")
  495. if not self.panorama.start_stream():
  496. print("启动视频流失败")
  497. self.panorama.disconnect()
  498. if self.enable_ptz_camera:
  499. self.ptz.disconnect()
  500. return False
  501. self.running = True
  502. # 启动检测线程
  503. self._detection_thread = threading.Thread(
  504. target=self._detection_worker, name="detection-worker", daemon=True)
  505. self._detection_thread.start()
  506. # 启动PTZ控制线程
  507. if self.enable_ptz_camera and self.enable_ptz_tracking:
  508. self._ptz_thread = threading.Thread(
  509. target=self._ptz_worker, name="ptz-worker", daemon=True)
  510. self._ptz_thread.start()
  511. print("异步联动系统已启动 (检测线程 + PTZ控制线程)")
  512. return True
  513. def stop(self):
  514. """停止联动"""
  515. self.running = False
  516. # 清空PTZ队列,让工作线程退出
  517. while not self._ptz_queue.empty():
  518. try:
  519. self._ptz_queue.get_nowait()
  520. except queue.Empty:
  521. break
  522. if self._detection_thread:
  523. self._detection_thread.join(timeout=3)
  524. if self._ptz_thread:
  525. self._ptz_thread.join(timeout=3)
  526. # 停止父类线程(如果有的话)
  527. if self.coordinator_thread:
  528. self.coordinator_thread.join(timeout=1)
  529. self.panorama.disconnect()
  530. if self.enable_ptz_camera:
  531. self.ptz.disconnect()
  532. self._print_stats()
  533. print("异步联动系统已停止")
  534. def _detection_worker(self):
  535. """检测线程:持续读帧 + YOLO推理 + 发送PTZ命令 + 打印检测日志"""
  536. last_detection_time = 0
  537. detection_interval = self.config.get('detection_interval', 1.0)
  538. ptz_cooldown = self.config.get('ptz_command_cooldown', 0.5)
  539. ptz_threshold = self.config.get('ptz_position_threshold', 0.03)
  540. frame_count = 0
  541. last_log_time = time.time()
  542. log_interval = 5.0 # 每5秒打印一次帧率统计
  543. detection_run_count = 0
  544. detection_person_count = 0
  545. last_no_detect_log_time = 0
  546. no_detect_log_interval = 30.0
  547. with self.stats_lock:
  548. self.stats['start_time'] = time.time()
  549. if self.detector is None:
  550. logger.warning("[检测线程] ⚠️ 人体检测器未初始化! 检测功能不可用, 请检查 YOLO 模型是否正确加载")
  551. elif not self.enable_detection:
  552. logger.warning("[检测线程] ⚠️ 人体检测已禁用 (enable_detection=False)")
  553. else:
  554. logger.info(f"[检测线程] ✓ 人体检测器已就绪, 检测间隔={detection_interval}s, PTZ冷却={ptz_cooldown}s")
  555. while self.running:
  556. try:
  557. current_time = time.time()
  558. frame = self.panorama.get_frame()
  559. if frame is None:
  560. time.sleep(0.01)
  561. continue
  562. frame_count += 1
  563. self._update_stats('frames_processed')
  564. frame_size = (frame.shape[1], frame.shape[0])
  565. if current_time - last_log_time >= log_interval:
  566. elapsed = current_time - last_log_time
  567. fps = frame_count / elapsed if elapsed > 0 else 0
  568. state_str = self.state.name if hasattr(self.state, 'name') else str(self.state)
  569. stats_parts = [f"帧率={fps:.1f}fps", f"处理帧={frame_count}", f"状态={state_str}"]
  570. if self.detector is None:
  571. stats_parts.append("检测器=未加载")
  572. elif not self.enable_detection:
  573. stats_parts.append("检测=已禁用")
  574. else:
  575. stats_parts.append(f"检测轮次={detection_run_count}(有人={detection_person_count})")
  576. with self.targets_lock:
  577. target_count = len(self.tracking_targets)
  578. stats_parts.append(f"跟踪目标={target_count}")
  579. logger.info(f"[检测线程] {', '.join(stats_parts)}")
  580. frame_count = 0
  581. last_log_time = current_time
  582. # 周期性检测(约1次/秒)
  583. if current_time - last_detection_time >= detection_interval:
  584. last_detection_time = current_time
  585. detection_run_count += 1
  586. # YOLO 人体检测
  587. detections = self._detect_persons(frame)
  588. if detections:
  589. self._update_stats('persons_detected', len(detections))
  590. detection_person_count += 1
  591. # 更新跟踪
  592. tracked = self.tracker.update(detections)
  593. self._update_tracking_targets(tracked, frame_size)
  594. # 打印检测日志
  595. if tracked:
  596. for t in tracked:
  597. x_ratio, y_ratio = t.position
  598. logger.info(
  599. f"[检测] ✓ 目标ID={t.track_id} "
  600. f"位置=({x_ratio:.3f}, {y_ratio:.3f}) "
  601. f"置信度={getattr(t, 'confidence', 0):.2f}"
  602. )
  603. elif detections:
  604. # 有检测但没跟踪上
  605. for d in detections:
  606. logger.debug(f"[检测] 未跟踪: {d.class_name} @ {d.center}")
  607. else:
  608. if current_time - last_no_detect_log_time >= no_detect_log_interval:
  609. logger.info(
  610. f"[检测] · YOLO检测运行正常, 本轮未检测到人员 "
  611. f"(累计检测{detection_run_count}轮, 检测到人{detection_person_count}轮)"
  612. )
  613. last_no_detect_log_time = current_time
  614. if tracked:
  615. self._process_detections(tracked, frame, frame_size)
  616. # 选择跟踪目标并发送PTZ命令
  617. target = self._select_tracking_target()
  618. if target and self.enable_ptz_tracking and self.enable_ptz_camera:
  619. self._send_ptz_command_with_log(target, frame_size)
  620. elif not tracked and self.current_target:
  621. # 目标消失,切回IDLE
  622. self._set_state(TrackingState.IDLE)
  623. logger.info("[检测] 目标丢失,球机进入IDLE状态")
  624. self.current_target = None
  625. self._cleanup_expired_targets()
  626. time.sleep(0.01)
  627. except Exception as e:
  628. logger.error(f"检测线程错误: {e}")
  629. time.sleep(0.1)
  630. def _ptz_worker(self):
  631. """PTZ控制线程:从队列接收命令并控制球机"""
  632. while self.running:
  633. try:
  634. try:
  635. cmd = self._ptz_queue.get(timeout=0.1)
  636. except queue.Empty:
  637. continue
  638. self._execute_ptz_command(cmd)
  639. except Exception as e:
  640. print(f"PTZ控制线程错误: {e}")
  641. time.sleep(0.05)
  642. def _select_tracking_target(self) -> Optional[TrackingTarget]:
  643. """选择当前跟踪目标"""
  644. with self.targets_lock:
  645. if not self.tracking_targets:
  646. self._set_state(TrackingState.IDLE)
  647. self.current_target = None
  648. return None
  649. if self.current_target is None or \
  650. self.current_target.track_id not in self.tracking_targets:
  651. target_id = list(self.tracking_targets.keys())[0]
  652. self.current_target = self.tracking_targets[target_id]
  653. return self.current_target
  654. def _send_ptz_command(self, target: TrackingTarget, frame_size: Tuple[int, int]):
  655. """将跟踪目标转化为PTZ命令放入队列"""
  656. x_ratio, y_ratio = target.position
  657. # 检查位置变化是否超过阈值
  658. if self.last_ptz_position is not None:
  659. last_x, last_y = self.last_ptz_position
  660. if abs(x_ratio - last_x) < self.ptz_position_threshold and \
  661. abs(y_ratio - last_y) < self.ptz_position_threshold:
  662. return
  663. # 冷却检查
  664. current_time = time.time()
  665. if current_time - self._last_ptz_time < self.PTZ_COMMAND_COOLDOWN:
  666. return
  667. cmd = PTZCommand(
  668. pan=0, tilt=0, zoom=0,
  669. x_ratio=x_ratio, y_ratio=y_ratio,
  670. use_calibration=self.enable_calibration
  671. )
  672. try:
  673. self._ptz_queue.put_nowait(cmd)
  674. self.last_ptz_position = (x_ratio, y_ratio)
  675. except queue.Full:
  676. pass # 丢弃命令,下一个检测周期会重发
  677. def _send_ptz_command_with_log(self, target: TrackingTarget, frame_size: Tuple[int, int]):
  678. """发送PTZ命令并打印日志"""
  679. x_ratio, y_ratio = target.position
  680. # 计算PTZ角度(用于日志)
  681. if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
  682. pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
  683. zoom = self.ptz.ptz_config.get('default_zoom', 8)
  684. coord_type = "校准坐标"
  685. else:
  686. pan, tilt, zoom = self.ptz.calculate_ptz_position(x_ratio, y_ratio)
  687. coord_type = "估算坐标"
  688. logger.info(
  689. f"[PTZ] 发送命令: 目标ID={target.track_id} "
  690. f"全景位置=({x_ratio:.3f}, {y_ratio:.3f}) → "
  691. f"PTZ角度=(pan={pan:.1f}°, tilt={tilt:.1f}°, zoom={zoom}) [{coord_type}]"
  692. )
  693. # 检查位置变化是否超过阈值
  694. ptz_threshold = self.config.get('ptz_position_threshold', 0.03)
  695. if self.last_ptz_position is not None:
  696. last_x, last_y = self.last_ptz_position
  697. dx = abs(x_ratio - last_x)
  698. dy = abs(y_ratio - last_y)
  699. if dx < ptz_threshold and dy < ptz_threshold:
  700. logger.debug(f"[PTZ] 位置变化太小(dx={dx:.4f}, dy={dy:.4f}),跳过")
  701. return
  702. # 冷却检查
  703. current_time = time.time()
  704. ptz_cooldown = self.config.get('ptz_command_cooldown', 0.5)
  705. if current_time - self._last_ptz_time < ptz_cooldown:
  706. logger.debug(f"[PTZ] 冷却中,跳过 (间隔={current_time - self._last_ptz_time:.2f}s < {ptz_cooldown}s)")
  707. return
  708. cmd = PTZCommand(
  709. pan=0, tilt=0, zoom=0,
  710. x_ratio=x_ratio, y_ratio=y_ratio,
  711. use_calibration=self.enable_calibration
  712. )
  713. try:
  714. self._ptz_queue.put_nowait(cmd)
  715. self.last_ptz_position = (x_ratio, y_ratio)
  716. self._update_stats('ptz_commands_sent' if 'ptz_commands_sent' in self.stats else 'persons_detected')
  717. except queue.Full:
  718. logger.warning("[PTZ] 命令队列满,丢弃本次命令")
  719. def _execute_ptz_command(self, cmd: PTZCommand):
  720. """执行PTZ命令(在PTZ线程中)"""
  721. self._last_ptz_time = time.time()
  722. if cmd.use_calibration and self.calibrator and self.calibrator.is_calibrated():
  723. pan, tilt = self.calibrator.transform(cmd.x_ratio, cmd.y_ratio)
  724. if self.ptz.ptz_config.get('pan_flip', False):
  725. pan = (pan + 180) % 360
  726. zoom = self.ptz.ptz_config.get('default_zoom', 8)
  727. else:
  728. pan, tilt, zoom = self.ptz.calculate_ptz_position(cmd.x_ratio, cmd.y_ratio)
  729. self._set_state(TrackingState.TRACKING)
  730. logger.info(
  731. f"[PTZ] 执行: pan={pan:.1f}° tilt={tilt:.1f}° zoom={zoom} "
  732. f"(全景位置=({cmd.x_ratio:.3f}, {cmd.y_ratio:.3f}))"
  733. )
  734. success = self.ptz.goto_exact_position(pan, tilt, zoom)
  735. if success:
  736. time.sleep(self.PTZ_CONFIRM_WAIT)
  737. self._confirm_ptz_position(cmd.x_ratio, cmd.y_ratio)
  738. logger.info(f"[PTZ] 到位确认完成: pan={pan:.1f}° tilt={tilt:.1f}°")
  739. else:
  740. logger.warning(f"[PTZ] 命令执行失败: pan={pan:.1f}° tilt={tilt:.1f}° zoom={zoom}")
  741. def _confirm_ptz_position(self, x_ratio: float, y_ratio: float):
  742. """PTZ位置确认:读取球机帧验证目标是否可见"""
  743. if not hasattr(self.ptz, 'get_frame') or self.ptz.get_frame() is None:
  744. return
  745. ptz_frame = self.ptz.get_frame()
  746. if ptz_frame is None:
  747. return
  748. # 未来可以在这里添加球机帧目标验证逻辑
  749. # 例如:在球机帧中检测目标是否在画面中心附近
  750. def on_ptz_confirmed(self, callback: Callable):
  751. """注册PTZ位置确认回调"""
  752. self._on_ptz_confirmed = callback