coordinator.py 56 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445
  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. import cv2
  14. from config import COORDINATOR_CONFIG, SYSTEM_CONFIG, PTZ_CONFIG, DETECTION_CONFIG
  15. from panorama_camera import PanoramaCamera, ObjectDetector, PersonTracker, DetectedObject
  16. from ptz_camera import PTZCamera, PTZController
  17. from ocr_recognizer import NumberDetector, PersonInfo
  18. from ptz_person_tracker import PTZPersonDetector, PTZAutoZoomController
  19. from paired_image_saver import PairedImageSaver, get_paired_saver
  20. logger = logging.getLogger(__name__)
  21. class TrackingState(Enum):
  22. """跟踪状态"""
  23. IDLE = 0 # 空闲
  24. SEARCHING = 1 # 搜索目标
  25. TRACKING = 2 # 跟踪中
  26. ZOOMING = 3 # 变焦中
  27. OCR_PROCESSING = 4 # OCR处理中
  28. @dataclass
  29. class TrackingTarget:
  30. """跟踪目标"""
  31. track_id: int # 跟踪ID
  32. position: Tuple[float, float] # 位置比例 (x_ratio, y_ratio)
  33. last_update: float # 最后更新时间
  34. person_info: Optional[PersonInfo] = None # 人员信息
  35. priority: int = 0 # 优先级
  36. area: int = 0 # 目标面积(像素²)
  37. confidence: float = 0.0 # 置信度
  38. center_distance: float = 1.0 # 到画面中心的距离比例(0-1)
  39. score: float = 0.0 # 综合得分
  40. class TargetSelector:
  41. """
  42. 目标选择策略类
  43. 支持按面积、置信度、混合模式排序,支持优先级切换
  44. """
  45. def __init__(self, config: Dict = None):
  46. """
  47. 初始化目标选择器
  48. Args:
  49. config: 目标选择配置
  50. """
  51. self.config = config or {
  52. 'strategy': 'area',
  53. 'area_weight': 0.6,
  54. 'confidence_weight': 0.4,
  55. 'min_area_threshold': 5000,
  56. 'prefer_center': True,
  57. 'center_weight': 0.2,
  58. 'switch_on_lost': True,
  59. 'stickiness': 0.3,
  60. }
  61. self.current_target_id: Optional[int] = None
  62. self.current_target_score: float = 0.0
  63. def calculate_score(self, target: TrackingTarget, frame_size: Tuple[int, int] = None) -> float:
  64. """
  65. 计算目标综合得分
  66. Args:
  67. target: 跟踪目标
  68. frame_size: 帧尺寸(w, h),用于计算中心距离
  69. Returns:
  70. 综合得分(0-1)
  71. """
  72. strategy = self.config.get('strategy', 'area')
  73. area_weight = self.config.get('area_weight', 0.6)
  74. conf_weight = self.config.get('confidence_weight', 0.4)
  75. min_area = self.config.get('min_area_threshold', 5000)
  76. prefer_center = self.config.get('prefer_center', False)
  77. center_weight = self.config.get('center_weight', 0.2)
  78. # 归一化面积得分 (对数缩放,避免大目标得分过高)
  79. import math
  80. area_score = min(1.0, math.log10(max(target.area, 1)) / 5.0) # 100000像素² ≈ 1.0
  81. # 小面积惩罚
  82. if target.area < min_area:
  83. area_score *= 0.5
  84. # 置信度得分直接使用
  85. conf_score = target.confidence
  86. # 中心距离得分 (距离中心越近得分越高)
  87. center_score = 1.0 - target.center_distance
  88. # 根据策略计算综合得分
  89. if strategy == 'area':
  90. score = area_score * 0.8 + conf_score * 0.2
  91. elif strategy == 'confidence':
  92. score = conf_score * 0.8 + area_score * 0.2
  93. else: # hybrid
  94. score = area_score * area_weight + conf_score * conf_weight
  95. # 加入中心距离权重
  96. if prefer_center:
  97. score = score * (1 - center_weight) + center_score * center_weight
  98. return score
  99. def select_target(self, targets: Dict[int, TrackingTarget],
  100. frame_size: Tuple[int, int] = None) -> Optional[TrackingTarget]:
  101. """
  102. 从多个目标中选择最优目标
  103. Args:
  104. targets: 目标字典 {track_id: TrackingTarget}
  105. frame_size: 帧尺寸
  106. Returns:
  107. 最优目标
  108. """
  109. if not targets:
  110. self.current_target_id = None
  111. return None
  112. stickiness = self.config.get('stickiness', 0.3)
  113. switch_on_lost = self.config.get('switch_on_lost', True)
  114. # 计算所有目标得分
  115. scored_targets = []
  116. for track_id, target in targets.items():
  117. target.score = self.calculate_score(target, frame_size)
  118. scored_targets.append((track_id, target, target.score))
  119. # 按得分排序
  120. scored_targets.sort(key=lambda x: x[2], reverse=True)
  121. # 检查当前目标是否仍在列表中
  122. if self.current_target_id is not None:
  123. current_exists = self.current_target_id in targets
  124. if current_exists:
  125. # 应用粘性:当前目标得分需要显著低于最优目标才切换
  126. best_id, best_target, best_score = scored_targets[0]
  127. current_target = targets[self.current_target_id]
  128. # 粘性阈值: 当前目标得分 > 最优得分 * (1 - stickiness) 时保持
  129. stickiness_threshold = best_score * (1 - stickiness)
  130. if current_target.score > stickiness_threshold:
  131. return current_target
  132. # 选择得分最高的目标
  133. best_id, best_target, best_score = scored_targets[0]
  134. self.current_target_id = best_id
  135. self.current_target_score = best_score
  136. logger.debug(
  137. f"[目标选择] 选择目标ID={best_id} 得分={best_score:.3f} "
  138. f"面积={best_target.area} 置信度={best_target.confidence:.2f}"
  139. )
  140. return best_target
  141. def get_sorted_targets(self, targets: Dict[int, TrackingTarget],
  142. frame_size: Tuple[int, int] = None) -> List[Tuple[TrackingTarget, float]]:
  143. """
  144. 获取按得分排序的目标列表
  145. Args:
  146. targets: 目标字典
  147. frame_size: 帧尺寸
  148. Returns:
  149. 排序后的目标列表 [(target, score), ...]
  150. """
  151. scored = []
  152. for target in targets.values():
  153. target.score = self.calculate_score(target, frame_size)
  154. scored.append((target, target.score))
  155. scored.sort(key=lambda x: x[1], reverse=True)
  156. return scored
  157. def set_strategy(self, strategy: str):
  158. """设置选择策略"""
  159. self.config['strategy'] = strategy
  160. logger.info(f"[目标选择] 策略已切换为: {strategy}")
  161. def set_stickiness(self, stickiness: float):
  162. """设置目标粘性"""
  163. self.config['stickiness'] = max(0.0, min(1.0, stickiness))
  164. logger.info(f"[目标选择] 粘性已设置为: {self.config['stickiness']}")
  165. class Coordinator:
  166. """
  167. 联动控制器
  168. 协调全景摄像头和球机实现联动抓拍
  169. """
  170. def __init__(self, panorama_camera: PanoramaCamera,
  171. ptz_camera: PTZCamera,
  172. detector: ObjectDetector = None,
  173. number_detector: NumberDetector = None,
  174. calibrator = None):
  175. """
  176. 初始化联动控制器
  177. Args:
  178. panorama_camera: 全景摄像头
  179. ptz_camera: 球机
  180. detector: 物体检测器
  181. number_detector: 编号检测器
  182. calibrator: 校准器 (用于坐标转换)
  183. """
  184. self.panorama = panorama_camera
  185. self.ptz = ptz_camera
  186. self.detector = detector
  187. self.number_detector = number_detector
  188. self.calibrator = calibrator
  189. self.config = COORDINATOR_CONFIG
  190. # 功能开关 - 从 SYSTEM_CONFIG 读取
  191. self.enable_ptz_camera = SYSTEM_CONFIG.get('enable_ptz_camera', True)
  192. self.enable_ptz_tracking = SYSTEM_CONFIG.get('enable_ptz_tracking', True)
  193. self.enable_calibration = SYSTEM_CONFIG.get('enable_calibration', True)
  194. self.enable_detection = SYSTEM_CONFIG.get('enable_detection', True)
  195. self.enable_ocr = SYSTEM_CONFIG.get('enable_ocr', True)
  196. # 球机端人体检测与自动对焦
  197. self.enable_ptz_detection = PTZ_CONFIG.get('enable_ptz_detection', False)
  198. self.auto_zoom_config = PTZ_CONFIG.get('auto_zoom', {})
  199. self.ptz_detector = None
  200. self.auto_zoom_controller = None
  201. # 跟踪器
  202. self.tracker = PersonTracker()
  203. # 状态
  204. self.state = TrackingState.IDLE
  205. self.state_lock = threading.Lock()
  206. # 跟踪目标
  207. self.tracking_targets: Dict[int, TrackingTarget] = {}
  208. self.targets_lock = threading.Lock()
  209. # 当前跟踪目标
  210. self.current_target: Optional[TrackingTarget] = None
  211. # 回调函数
  212. self.on_person_detected: Optional[Callable] = None
  213. self.on_number_recognized: Optional[Callable] = None
  214. self.on_tracking_started: Optional[Callable] = None
  215. self.on_tracking_stopped: Optional[Callable] = None
  216. # 控制标志
  217. self.running = False
  218. self.coordinator_thread = None
  219. # OCR频率控制
  220. self.last_ocr_time = 0
  221. self.ocr_interval = 1.0 # OCR间隔(秒),避免过于频繁调用API
  222. # PTZ优化 - 避免频繁发送相同位置的命令
  223. self.last_ptz_position = None
  224. self.ptz_position_threshold = self.config.get('ptz_position_threshold', 0.03)
  225. # 目标选择器
  226. self.target_selector = TargetSelector(
  227. self.config.get('target_selection', {})
  228. )
  229. # 结果队列
  230. self.result_queue = queue.Queue()
  231. # 性能统计
  232. self.stats = {
  233. 'frames_processed': 0,
  234. 'persons_detected': 0,
  235. 'ocr_attempts': 0,
  236. 'ocr_success': 0,
  237. 'start_time': None,
  238. 'last_frame_time': None,
  239. }
  240. self.stats_lock = threading.Lock()
  241. def set_calibrator(self, calibrator):
  242. """设置校准器"""
  243. self.calibrator = calibrator
  244. def _transform_position(self, x_ratio: float, y_ratio: float) -> Tuple[float, float, int]:
  245. """
  246. 将全景坐标转换为PTZ角度
  247. Args:
  248. x_ratio: X方向比例
  249. y_ratio: Y方向比例
  250. Returns:
  251. (pan, tilt, zoom)
  252. """
  253. if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
  254. # 使用校准结果进行转换
  255. pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
  256. zoom = 8 # 默认变倍
  257. else:
  258. # 使用默认估算
  259. pan, tilt, zoom = self.ptz.calculate_ptz_position(x_ratio, y_ratio)
  260. return (pan, tilt, zoom)
  261. def start(self) -> bool:
  262. """
  263. 启动联动系统
  264. Returns:
  265. 是否成功
  266. """
  267. # 连接全景摄像头
  268. if not self.panorama.connect():
  269. print("连接全景摄像头失败")
  270. return False
  271. # 连接 PTZ 球机 (可选)
  272. if self.enable_ptz_camera:
  273. if not self.ptz.connect():
  274. print("连接球机失败")
  275. self.panorama.disconnect()
  276. return False
  277. else:
  278. print("PTZ 球机功能已禁用")
  279. # 启动视频流(优先RTSP,SDK回调不可用时回退)
  280. if not self.panorama.start_stream_rtsp():
  281. print("RTSP视频流启动失败,尝试SDK方式...")
  282. if not self.panorama.start_stream():
  283. print("启动视频流失败")
  284. self.panorama.disconnect()
  285. if self.enable_ptz_camera:
  286. self.ptz.disconnect()
  287. return False
  288. # 启动联动线程
  289. self.running = True
  290. self.coordinator_thread = threading.Thread(target=self._coordinator_worker, daemon=True)
  291. self.coordinator_thread.start()
  292. print("联动系统已启动")
  293. return True
  294. def stop(self):
  295. """停止联动系统"""
  296. self.running = False
  297. if self.coordinator_thread:
  298. self.coordinator_thread.join(timeout=3)
  299. self.panorama.disconnect()
  300. if self.enable_ptz_camera:
  301. self.ptz.disconnect()
  302. # 打印统计信息
  303. self._print_stats()
  304. print("联动系统已停止")
  305. def _update_stats(self, key: str, value: int = 1):
  306. """更新统计信息"""
  307. with self.stats_lock:
  308. if key in self.stats:
  309. self.stats[key] += value
  310. def _print_stats(self):
  311. """打印统计信息"""
  312. with self.stats_lock:
  313. if self.stats['start_time'] and self.stats['frames_processed'] > 0:
  314. elapsed = time.time() - self.stats['start_time']
  315. fps = self.stats['frames_processed'] / elapsed
  316. print("\n=== 性能统计 ===")
  317. print(f"运行时长: {elapsed:.1f}秒")
  318. print(f"处理帧数: {self.stats['frames_processed']}")
  319. print(f"平均帧率: {fps:.1f} fps")
  320. print(f"检测人体: {self.stats['persons_detected']}次")
  321. print(f"OCR尝试: {self.stats['ocr_attempts']}次")
  322. print(f"OCR成功: {self.stats['ocr_success']}次")
  323. print("================\n")
  324. def get_stats(self) -> dict:
  325. """获取统计信息"""
  326. with self.stats_lock:
  327. return self.stats.copy()
  328. def _coordinator_worker(self):
  329. """联动工作线程"""
  330. last_detection_time = 0
  331. # 优先使用 detection_fps,默认每秒2帧
  332. detection_fps = self.config.get('detection_fps', 2)
  333. detection_interval = 1.0 / detection_fps # 根据FPS计算间隔
  334. # 初始化统计
  335. with self.stats_lock:
  336. self.stats['start_time'] = time.time()
  337. while self.running:
  338. try:
  339. current_time = time.time()
  340. # 获取当前帧
  341. frame = self.panorama.get_frame()
  342. if frame is None:
  343. time.sleep(0.01)
  344. continue
  345. # 更新帧统计
  346. self._update_stats('frames_processed')
  347. frame_size = (frame.shape[1], frame.shape[0])
  348. # 周期性检测
  349. if current_time - last_detection_time >= detection_interval:
  350. last_detection_time = current_time
  351. # 检测人体
  352. detections = self._detect_persons(frame)
  353. # 更新检测统计
  354. if detections:
  355. self._update_stats('persons_detected', len(detections))
  356. # 更新跟踪
  357. tracked = self.tracker.update(detections)
  358. # 更新跟踪目标
  359. self._update_tracking_targets(tracked, frame_size)
  360. # 处理检测结果
  361. if tracked:
  362. self._process_detections(tracked, frame, frame_size)
  363. # 处理当前跟踪目标
  364. self._process_current_target(frame, frame_size)
  365. # 清理过期目标
  366. self._cleanup_expired_targets()
  367. time.sleep(0.01)
  368. except Exception as e:
  369. print(f"联动处理错误: {e}")
  370. time.sleep(0.1)
  371. def _detect_persons(self, frame: np.ndarray) -> List[DetectedObject]:
  372. """检测人体"""
  373. if not self.enable_detection or self.detector is None:
  374. return []
  375. return self.detector.detect_persons(frame)
  376. def _update_tracking_targets(self, detections: List[DetectedObject],
  377. frame_size: Tuple[int, int]):
  378. """更新跟踪目标"""
  379. current_time = time.time()
  380. frame_w, frame_h = frame_size
  381. center_x, center_y = frame_w / 2, frame_h / 2
  382. with self.targets_lock:
  383. # 更新现有目标
  384. for det in detections:
  385. if det.track_id is None:
  386. continue
  387. x_ratio = det.center[0] / frame_w
  388. y_ratio = det.center[1] / frame_h
  389. # 计算面积
  390. _, _, width, height = det.bbox
  391. area = width * height
  392. # 计算到画面中心的距离比例
  393. dx = abs(det.center[0] - center_x) / center_x
  394. dy = abs(det.center[1] - center_y) / center_y
  395. center_distance = (dx + dy) / 2 # 归一化到0-1
  396. if det.track_id in self.tracking_targets:
  397. # 更新位置
  398. target = self.tracking_targets[det.track_id]
  399. target.position = (x_ratio, y_ratio)
  400. target.last_update = current_time
  401. target.area = area
  402. target.confidence = det.confidence
  403. target.center_distance = center_distance
  404. else:
  405. # 新目标
  406. if len(self.tracking_targets) < self.config['max_tracking_targets']:
  407. self.tracking_targets[det.track_id] = TrackingTarget(
  408. track_id=det.track_id,
  409. position=(x_ratio, y_ratio),
  410. last_update=current_time,
  411. area=area,
  412. confidence=det.confidence,
  413. center_distance=center_distance
  414. )
  415. def _process_detections(self, detections: List[DetectedObject],
  416. frame: np.ndarray, frame_size: Tuple[int, int]):
  417. """处理检测结果"""
  418. if self.on_person_detected:
  419. for det in detections:
  420. self.on_person_detected(det, frame)
  421. def _process_current_target(self, frame: np.ndarray, frame_size: Tuple[int, int]):
  422. """处理当前跟踪目标"""
  423. with self.targets_lock:
  424. if not self.tracking_targets:
  425. self._set_state(TrackingState.IDLE)
  426. self.current_target = None
  427. return
  428. # 使用目标选择器选择最优目标
  429. self.current_target = self.target_selector.select_target(
  430. self.tracking_targets, frame_size
  431. )
  432. if self.current_target:
  433. # 移动球机到目标位置 (仅在 PTZ 跟踪启用时)
  434. if self.enable_ptz_tracking and self.enable_ptz_camera:
  435. self._set_state(TrackingState.TRACKING)
  436. x_ratio, y_ratio = self.current_target.position
  437. # 检查位置是否变化超过阈值
  438. should_move = True
  439. if self.last_ptz_position is not None:
  440. last_x, last_y = self.last_ptz_position
  441. if (abs(x_ratio - last_x) < self.ptz_position_threshold and
  442. abs(y_ratio - last_y) < self.ptz_position_threshold):
  443. should_move = False
  444. if should_move:
  445. if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
  446. pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
  447. if self.ptz.ptz_config.get('pan_flip', False):
  448. pan = (pan + 180) % 360
  449. zoom = self.ptz.ptz_config.get('default_zoom', 8)
  450. self.ptz.goto_exact_position(pan, tilt, zoom)
  451. else:
  452. self.ptz.track_target(x_ratio, y_ratio)
  453. self.last_ptz_position = (x_ratio, y_ratio)
  454. # 执行OCR识别 (仅在 OCR 启用时)
  455. if self.enable_ocr:
  456. self._perform_ocr(frame, self.current_target)
  457. def _perform_ocr(self, frame: np.ndarray, target: TrackingTarget):
  458. """执行OCR识别"""
  459. if not self.enable_ocr or self.number_detector is None:
  460. return
  461. # 频率控制 - 避免过于频繁调用OCR API
  462. current_time = time.time()
  463. if current_time - self.last_ocr_time < self.ocr_interval:
  464. return
  465. self.last_ocr_time = current_time
  466. # 更新OCR尝试统计
  467. self._update_stats('ocr_attempts')
  468. # 计算人体边界框 (基于位置估算)
  469. frame_h, frame_w = frame.shape[:2]
  470. # 人体占画面比例 (可配置,默认宽20%、高40%)
  471. person_width_ratio = self.config.get('person_width_ratio', 0.2)
  472. person_height_ratio = self.config.get('person_height_ratio', 0.4)
  473. person_width = int(frame_w * person_width_ratio)
  474. person_height = int(frame_h * person_height_ratio)
  475. x_ratio, y_ratio = target.position
  476. center_x = int(x_ratio * frame_w)
  477. center_y = int(y_ratio * frame_h)
  478. # 计算边界框,确保不超出画面范围
  479. x1 = max(0, center_x - person_width // 2)
  480. y1 = max(0, center_y - person_height // 2)
  481. x2 = min(frame_w, x1 + person_width)
  482. y2 = min(frame_h, y1 + person_height)
  483. # 更新实际宽高 (可能因边界裁剪而变小)
  484. actual_width = x2 - x1
  485. actual_height = y2 - y1
  486. person_bbox = (x1, y1, actual_width, actual_height)
  487. # 检测编号
  488. self._set_state(TrackingState.OCR_PROCESSING)
  489. person_info = self.number_detector.detect_number(frame, person_bbox)
  490. person_info.person_id = target.track_id
  491. # 更新OCR成功统计
  492. if person_info.number_text:
  493. self._update_stats('ocr_success')
  494. # 更新目标信息
  495. with self.targets_lock:
  496. if target.track_id in self.tracking_targets:
  497. self.tracking_targets[target.track_id].person_info = person_info
  498. # 回调
  499. if self.on_number_recognized and person_info.number_text:
  500. self.on_number_recognized(person_info)
  501. # 放入结果队列
  502. self.result_queue.put(person_info)
  503. def _cleanup_expired_targets(self):
  504. """清理过期目标"""
  505. current_time = time.time()
  506. timeout = self.config['tracking_timeout']
  507. with self.targets_lock:
  508. expired_ids = [
  509. target_id for target_id, target in self.tracking_targets.items()
  510. if current_time - target.last_update > timeout
  511. ]
  512. for target_id in expired_ids:
  513. del self.tracking_targets[target_id]
  514. if self.current_target and self.current_target.track_id == target_id:
  515. self.current_target = None
  516. def _set_state(self, state: TrackingState):
  517. """设置状态"""
  518. with self.state_lock:
  519. self.state = state
  520. def get_state(self) -> TrackingState:
  521. """获取状态"""
  522. with self.state_lock:
  523. return self.state
  524. def get_results(self) -> List[PersonInfo]:
  525. """
  526. 获取识别结果
  527. Returns:
  528. 人员信息列表
  529. """
  530. results = []
  531. while not self.result_queue.empty():
  532. try:
  533. results.append(self.result_queue.get_nowait())
  534. except queue.Empty:
  535. break
  536. return results
  537. def get_tracking_targets(self) -> List[TrackingTarget]:
  538. """获取当前跟踪目标"""
  539. with self.targets_lock:
  540. return list(self.tracking_targets.values())
  541. def force_track_position(self, x_ratio: float, y_ratio: float, zoom: int = None):
  542. """
  543. 强制跟踪指定位置
  544. Args:
  545. x_ratio: X方向比例
  546. y_ratio: Y方向比例
  547. zoom: 变倍
  548. """
  549. if self.enable_ptz_tracking and self.enable_ptz_camera:
  550. if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
  551. pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
  552. if self.ptz.ptz_config.get('pan_flip', False):
  553. pan = (pan + 180) % 360
  554. self.ptz.goto_exact_position(pan, tilt, zoom or self.ptz.ptz_config.get('default_zoom', 8))
  555. else:
  556. self.ptz.move_to_target(x_ratio, y_ratio, zoom)
  557. def capture_snapshot(self) -> Optional[np.ndarray]:
  558. """
  559. 抓拍快照
  560. Returns:
  561. 快照图像
  562. """
  563. return self.panorama.get_frame()
  564. class EventDrivenCoordinator(Coordinator):
  565. """事件驱动联动控制器,当全景摄像头检测到事件时触发联动"""
  566. def __init__(self, *args, **kwargs):
  567. super().__init__(*args, **kwargs)
  568. self.event_types = {
  569. 'intruder': True,
  570. 'crossline': True,
  571. 'motion': True,
  572. }
  573. self.event_queue = queue.Queue()
  574. def on_event(self, event_type: str, event_data: dict):
  575. if not self.event_types.get(event_type, False):
  576. return
  577. self.event_queue.put({'type': event_type, 'data': event_data, 'time': time.time()})
  578. def _coordinator_worker(self):
  579. while self.running:
  580. try:
  581. try:
  582. event = self.event_queue.get(timeout=0.1)
  583. self._process_event(event)
  584. except queue.Empty:
  585. pass
  586. frame = self.panorama.get_frame()
  587. if frame is not None:
  588. frame_size = (frame.shape[1], frame.shape[0])
  589. detections = self._detect_persons(frame)
  590. if detections:
  591. tracked = self.tracker.update(detections)
  592. self._update_tracking_targets(tracked, frame_size)
  593. self._process_current_target(frame, frame_size)
  594. self._cleanup_expired_targets()
  595. except Exception as e:
  596. print(f"事件处理错误: {e}")
  597. time.sleep(0.1)
  598. def _process_event(self, event: dict):
  599. event_type = event['type']
  600. event_data = event['data']
  601. print(f"处理事件: {event_type}")
  602. if event_type == 'intruder' and 'position' in event_data:
  603. x_ratio, y_ratio = event_data['position']
  604. self.force_track_position(x_ratio, y_ratio)
  605. @dataclass
  606. class PTZCommand:
  607. """PTZ控制命令"""
  608. pan: float
  609. tilt: float
  610. zoom: int
  611. x_ratio: float = 0.0
  612. y_ratio: float = 0.0
  613. use_calibration: bool = True
  614. track_id: Optional[int] = None # 跟踪目标ID(用于配对图片保存)
  615. batch_id: Optional[str] = None # 批次ID(用于配对图片保存)
  616. person_index: int = -1 # 人员在批次中的序号(用于配对图片保存)
  617. class AsyncCoordinator(Coordinator):
  618. """
  619. 异步联动控制器 — 检测线程与PTZ控制线程分离
  620. 改进:
  621. 1. 检测线程:持续读取全景帧 + YOLO推理
  622. 2. PTZ控制线程:通过命令队列接收目标位置,独立控制球机
  623. 3. 两线程通过 queue 通信,互不阻塞
  624. 4. PTZ位置确认:移动后等待球机到位并验证帧
  625. """
  626. PTZ_CONFIRM_WAIT = 0.3 # PTZ命令后等待稳定的秒数
  627. PTZ_CONFIRM_TIMEOUT = 2.0 # PTZ位置确认超时
  628. PTZ_COMMAND_COOLDOWN = 0.15 # PTZ命令最小间隔秒数
  629. def __init__(self, *args, **kwargs):
  630. super().__init__(*args, **kwargs)
  631. # PTZ命令队列(检测→PTZ)
  632. self._ptz_queue: queue.Queue = queue.Queue(maxsize=10)
  633. # 线程
  634. self._detection_thread = None
  635. self._ptz_thread = None
  636. # PTZ确认回调
  637. self._on_ptz_confirmed: Optional[Callable] = None
  638. # 上次PTZ命令时间
  639. self._last_ptz_time = 0.0
  640. # 配对图片保存器
  641. self._enable_paired_saving = DETECTION_CONFIG.get('enable_paired_saving', False)
  642. self._paired_saver: Optional[PairedImageSaver] = None
  643. self._current_batch_id: Optional[str] = None
  644. self._person_ptz_index: Dict[int, int] = {} # track_id -> person_index
  645. if self._enable_paired_saving:
  646. save_dir = DETECTION_CONFIG.get('paired_image_dir', '/home/admin/dsh/paired_images')
  647. time_window = DETECTION_CONFIG.get('paired_time_window', 5.0)
  648. self._paired_saver = get_paired_saver(base_dir=save_dir, time_window=time_window)
  649. logger.info(f"[AsyncCoordinator] 配对图片保存已启用: 目录={save_dir}, 时间窗口={time_window}s")
  650. def start(self) -> bool:
  651. """启动联动(覆盖父类,启动双线程)"""
  652. if not self.panorama.connect():
  653. print("连接全景摄像头失败")
  654. return False
  655. if self.enable_ptz_camera:
  656. if not self.ptz.connect():
  657. print("连接球机失败")
  658. self.panorama.disconnect()
  659. return False
  660. # 启动球机RTSP流(用于球机端人体检测)
  661. if self.enable_ptz_detection:
  662. if not self.ptz.start_stream_rtsp():
  663. print("球机RTSP流启动失败,禁用球机端检测功能")
  664. self.enable_ptz_detection = False
  665. else:
  666. # 初始化球机端人体检测器
  667. self._init_ptz_detector()
  668. else:
  669. print("PTZ球机功能已禁用")
  670. if not self.panorama.start_stream_rtsp():
  671. print("RTSP视频流启动失败,尝试SDK方式...")
  672. if not self.panorama.start_stream():
  673. print("启动视频流失败")
  674. self.panorama.disconnect()
  675. if self.enable_ptz_camera:
  676. self.ptz.disconnect()
  677. return False
  678. self.running = True
  679. # 启动检测线程
  680. self._detection_thread = threading.Thread(
  681. target=self._detection_worker, name="detection-worker", daemon=True)
  682. self._detection_thread.start()
  683. # 启动PTZ控制线程
  684. if self.enable_ptz_camera and self.enable_ptz_tracking:
  685. self._ptz_thread = threading.Thread(
  686. target=self._ptz_worker, name="ptz-worker", daemon=True)
  687. self._ptz_thread.start()
  688. print("异步联动系统已启动 (检测线程 + PTZ控制线程)")
  689. return True
  690. def stop(self):
  691. """停止联动"""
  692. self.running = False
  693. # 清空PTZ队列,让工作线程退出
  694. while not self._ptz_queue.empty():
  695. try:
  696. self._ptz_queue.get_nowait()
  697. except queue.Empty:
  698. break
  699. if self._detection_thread:
  700. self._detection_thread.join(timeout=3)
  701. if self._ptz_thread:
  702. self._ptz_thread.join(timeout=3)
  703. # 停止父类线程(如果有的话)
  704. if self.coordinator_thread:
  705. self.coordinator_thread.join(timeout=1)
  706. # 关闭配对保存器
  707. if self._paired_saver is not None:
  708. self._paired_saver.close()
  709. self._paired_saver = None
  710. self.panorama.disconnect()
  711. if self.enable_ptz_camera:
  712. self.ptz.disconnect()
  713. self._print_stats()
  714. print("异步联动系统已停止")
  715. def _detection_worker(self):
  716. """检测线程:持续读帧 + YOLO推理 + 发送PTZ命令 + 打印检测日志"""
  717. last_detection_time = 0
  718. # 优先使用 detection_fps,默认每秒2帧
  719. detection_fps = self.config.get('detection_fps', 2)
  720. detection_interval = 1.0 / detection_fps # 根据FPS计算间隔
  721. ptz_cooldown = self.config.get('ptz_command_cooldown', 0.5)
  722. ptz_threshold = self.config.get('ptz_position_threshold', 0.03)
  723. frame_count = 0
  724. last_log_time = time.time()
  725. log_interval = 5.0 # 每5秒打印一次帧率统计
  726. detection_run_count = 0
  727. detection_person_count = 0
  728. last_no_detect_log_time = 0
  729. no_detect_log_interval = 30.0
  730. with self.stats_lock:
  731. self.stats['start_time'] = time.time()
  732. if self.detector is None:
  733. logger.warning("[检测线程] ⚠️ 人体检测器未初始化! 检测功能不可用, 请检查 YOLO 模型是否正确加载")
  734. elif not self.enable_detection:
  735. logger.warning("[检测线程] ⚠️ 人体检测已禁用 (enable_detection=False)")
  736. else:
  737. logger.info(f"[检测线程] ✓ 人体检测器已就绪, 检测帧率={detection_fps}fps(间隔={detection_interval:.2f}s), PTZ冷却={ptz_cooldown}s")
  738. while self.running:
  739. try:
  740. current_time = time.time()
  741. frame = self.panorama.get_frame()
  742. if frame is None:
  743. time.sleep(0.01)
  744. continue
  745. frame_count += 1
  746. self._update_stats('frames_processed')
  747. frame_size = (frame.shape[1], frame.shape[0])
  748. if current_time - last_log_time >= log_interval:
  749. elapsed = current_time - last_log_time
  750. fps = frame_count / elapsed if elapsed > 0 else 0
  751. state_str = self.state.name if hasattr(self.state, 'name') else str(self.state)
  752. stats_parts = [f"帧率={fps:.1f}fps", f"处理帧={frame_count}", f"状态={state_str}"]
  753. if self.detector is None:
  754. stats_parts.append("检测器=未加载")
  755. elif not self.enable_detection:
  756. stats_parts.append("检测=已禁用")
  757. else:
  758. stats_parts.append(f"检测轮次={detection_run_count}(有人={detection_person_count})")
  759. with self.targets_lock:
  760. target_count = len(self.tracking_targets)
  761. stats_parts.append(f"跟踪目标={target_count}")
  762. logger.info(f"[检测线程] {', '.join(stats_parts)}")
  763. frame_count = 0
  764. last_log_time = current_time
  765. # 周期性检测(约1次/秒)
  766. if current_time - last_detection_time >= detection_interval:
  767. last_detection_time = current_time
  768. detection_run_count += 1
  769. # YOLO 人体检测
  770. detections = self._detect_persons(frame)
  771. if detections:
  772. self._update_stats('persons_detected', len(detections))
  773. detection_person_count += 1
  774. # 更新跟踪
  775. tracked = self.tracker.update(detections)
  776. self._update_tracking_targets(tracked, frame_size)
  777. # 配对图片保存:创建新批次
  778. if tracked and self._enable_paired_saving and self._paired_saver is not None:
  779. self._create_detection_batch(frame, tracked, frame_size)
  780. # 打印检测日志
  781. if tracked:
  782. for t in tracked:
  783. # tracked 是 DetectedObject,使用 center 计算位置
  784. x_ratio = t.center[0] / frame_size[0]
  785. y_ratio = t.center[1] / frame_size[1]
  786. _, _, w, h = t.bbox
  787. area = w * h
  788. logger.info(
  789. f"[检测] ✓ 目标ID={t.track_id} "
  790. f"位置=({x_ratio:.3f}, {y_ratio:.3f}) "
  791. f"面积={area} 置信度={t.confidence:.2f}"
  792. )
  793. elif detections:
  794. # 有检测但没跟踪上
  795. for d in detections:
  796. logger.debug(f"[检测] 未跟踪: {d.class_name} @ {d.center}")
  797. else:
  798. if current_time - last_no_detect_log_time >= no_detect_log_interval:
  799. logger.info(
  800. f"[检测] · YOLO检测运行正常, 本轮未检测到人员 "
  801. f"(累计检测{detection_run_count}轮, 检测到人{detection_person_count}轮)"
  802. )
  803. last_no_detect_log_time = current_time
  804. if tracked:
  805. self._process_detections(tracked, frame, frame_size)
  806. # 选择跟踪目标并发送PTZ命令
  807. target = self._select_tracking_target()
  808. if target and self.enable_ptz_tracking and self.enable_ptz_camera:
  809. self._send_ptz_command_with_log(target, frame_size)
  810. elif not tracked and self.current_target:
  811. # 目标消失,切回IDLE
  812. self._set_state(TrackingState.IDLE)
  813. logger.info("[检测] 目标丢失,球机进入IDLE状态")
  814. self.current_target = None
  815. self._cleanup_expired_targets()
  816. time.sleep(0.01)
  817. except Exception as e:
  818. logger.error(f"检测线程错误: {e}")
  819. time.sleep(0.1)
  820. def _init_ptz_detector(self):
  821. """初始化球机端人体检测器"""
  822. try:
  823. model_path = DETECTION_CONFIG.get('model_path')
  824. model_type = DETECTION_CONFIG.get('model_type', 'auto')
  825. conf_threshold = DETECTION_CONFIG.get('person_threshold', 0.5)
  826. if model_path:
  827. self.ptz_detector = PTZPersonDetector(
  828. model_path=model_path,
  829. model_type=model_type,
  830. confidence_threshold=conf_threshold
  831. )
  832. self.auto_zoom_controller = PTZAutoZoomController(
  833. ptz_camera=self.ptz,
  834. detector=self.ptz_detector,
  835. config=self.auto_zoom_config
  836. )
  837. print(f"[AsyncCoordinator] 球机端人体检测器初始化成功")
  838. else:
  839. print("[AsyncCoordinator] 未配置球机检测模型路径,禁用球机端检测")
  840. self.enable_ptz_detection = False
  841. except Exception as e:
  842. print(f"[AsyncCoordinator] 球机端检测器初始化失败: {e}")
  843. self.enable_ptz_detection = False
  844. def _create_detection_batch(self, frame: np.ndarray,
  845. tracked: List[DetectedObject],
  846. frame_size: Tuple[int, int]):
  847. """
  848. 创建检测批次,用于配对图片保存
  849. Args:
  850. frame: 全景帧
  851. tracked: 跟踪到的人员列表
  852. frame_size: 帧尺寸
  853. """
  854. if self._paired_saver is None:
  855. return
  856. # 过滤有效人员(置信度 >= 阈值)
  857. person_threshold = DETECTION_CONFIG.get('person_threshold', 0.8)
  858. valid_persons = []
  859. for det in tracked:
  860. if det.confidence >= person_threshold:
  861. valid_persons.append(det)
  862. if not valid_persons:
  863. logger.debug(f"[配对保存] 无有效人员(阈值={person_threshold}),跳过批次创建")
  864. return
  865. # 构建人员信息列表(只包含有效人员)
  866. persons = []
  867. self._person_ptz_index = {} # 重置索引映射
  868. for i, det in enumerate(valid_persons):
  869. x_ratio = det.center[0] / frame_size[0]
  870. y_ratio = det.center[1] / frame_size[1]
  871. person_info = {
  872. 'track_id': det.track_id,
  873. 'position': (x_ratio, y_ratio),
  874. 'bbox': (det.bbox[0], det.bbox[1],
  875. det.bbox[0] + det.bbox[2],
  876. det.bbox[1] + det.bbox[3]),
  877. 'confidence': det.confidence
  878. }
  879. persons.append(person_info)
  880. self._person_ptz_index[det.track_id] = i
  881. # 创建新批次
  882. batch_id = self._paired_saver.start_new_batch(frame, persons)
  883. if batch_id:
  884. self._current_batch_id = batch_id
  885. logger.info(f"[配对保存] 创建批次: {batch_id}, 有效人员={len(persons)}/{len(tracked)}")
  886. def _save_ptz_image_for_person(self, track_id: int,
  887. ptz_frame: np.ndarray,
  888. ptz_position: Tuple[float, float, int]):
  889. """
  890. 保存球机聚焦图片到对应批次
  891. Args:
  892. track_id: 人员跟踪ID
  893. ptz_frame: 球机帧
  894. ptz_position: PTZ位置 (pan, tilt, zoom)
  895. """
  896. if (self._paired_saver is None or
  897. self._current_batch_id is None or
  898. track_id not in self._person_ptz_index):
  899. return
  900. person_index = self._person_ptz_index[track_id]
  901. self._paired_saver.save_ptz_image(
  902. batch_id=self._current_batch_id,
  903. person_index=person_index,
  904. ptz_frame=ptz_frame,
  905. ptz_position=ptz_position,
  906. ptz_bbox=getattr(self, '_last_ptz_bbox', None)
  907. )
  908. def _save_ptz_image_for_person_batch(self, batch_id: str, person_index: int,
  909. ptz_frame: np.ndarray,
  910. ptz_position: Tuple[float, float, int]):
  911. """
  912. 保存球机聚焦图片到指定批次(直接使用 batch_id,不依赖当前批次)
  913. Args:
  914. batch_id: 批次ID
  915. person_index: 人员序号
  916. ptz_frame: 球机帧
  917. ptz_position: PTZ位置 (pan, tilt, zoom)
  918. """
  919. if self._paired_saver is None:
  920. return
  921. self._paired_saver.save_ptz_image(
  922. batch_id=batch_id,
  923. person_index=person_index,
  924. ptz_frame=ptz_frame,
  925. ptz_position=ptz_position,
  926. ptz_bbox=getattr(self, '_last_ptz_bbox', None)
  927. )
  928. def _ptz_worker(self):
  929. """PTZ控制线程:从队列接收命令并控制球机"""
  930. while self.running:
  931. try:
  932. try:
  933. cmd = self._ptz_queue.get(timeout=0.1)
  934. except queue.Empty:
  935. continue
  936. # 执行PTZ命令(batch_id 和 person_index 已在命令中)
  937. self._execute_ptz_command(cmd)
  938. except Exception as e:
  939. print(f"PTZ控制线程错误: {e}")
  940. time.sleep(0.05)
  941. def _select_tracking_target(self) -> Optional[TrackingTarget]:
  942. """选择当前跟踪目标"""
  943. with self.targets_lock:
  944. if not self.tracking_targets:
  945. self._set_state(TrackingState.IDLE)
  946. self.current_target = None
  947. return None
  948. # 使用目标选择器选择最优目标
  949. self.current_target = self.target_selector.select_target(
  950. self.tracking_targets
  951. )
  952. return self.current_target
  953. def _send_ptz_command(self, target: TrackingTarget, frame_size: Tuple[int, int]):
  954. """将跟踪目标转化为PTZ命令放入队列"""
  955. x_ratio, y_ratio = target.position
  956. # 检查位置变化是否超过阈值
  957. if self.last_ptz_position is not None:
  958. last_x, last_y = self.last_ptz_position
  959. if abs(x_ratio - last_x) < self.ptz_position_threshold and \
  960. abs(y_ratio - last_y) < self.ptz_position_threshold:
  961. return
  962. # 冷却检查
  963. current_time = time.time()
  964. if current_time - self._last_ptz_time < self.PTZ_COMMAND_COOLDOWN:
  965. return
  966. cmd = PTZCommand(
  967. pan=0, tilt=0, zoom=0,
  968. x_ratio=x_ratio, y_ratio=y_ratio,
  969. use_calibration=self.enable_calibration
  970. )
  971. try:
  972. self._ptz_queue.put_nowait(cmd)
  973. self.last_ptz_position = (x_ratio, y_ratio)
  974. except queue.Full:
  975. pass # 丢弃命令,下一个检测周期会重发
  976. def _send_ptz_command_with_log(self, target: TrackingTarget, frame_size: Tuple[int, int]):
  977. """发送PTZ命令并打印日志"""
  978. x_ratio, y_ratio = target.position
  979. # 检查位置变化是否超过阈值
  980. ptz_threshold = self.config.get('ptz_position_threshold', 0.03)
  981. if self.last_ptz_position is not None:
  982. last_x, last_y = self.last_ptz_position
  983. dx = abs(x_ratio - last_x)
  984. dy = abs(y_ratio - last_y)
  985. if dx < ptz_threshold and dy < ptz_threshold:
  986. logger.debug(f"[PTZ] 位置变化太小(dx={dx:.4f}, dy={dy:.4f}),跳过")
  987. return
  988. # 冷却检查
  989. current_time = time.time()
  990. ptz_cooldown = self.config.get('ptz_command_cooldown', 0.5)
  991. if current_time - self._last_ptz_time < ptz_cooldown:
  992. logger.debug(f"[PTZ] 冷却中,跳过 (间隔={current_time - self._last_ptz_time:.2f}s < {ptz_cooldown}s)")
  993. return
  994. # 计算PTZ角度(用于日志)
  995. if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
  996. pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
  997. zoom = self.ptz.ptz_config.get('default_zoom', 8)
  998. coord_type = "校准坐标"
  999. else:
  1000. pan, tilt, zoom = self.ptz.calculate_ptz_position(x_ratio, y_ratio)
  1001. coord_type = "估算坐标"
  1002. # 获取当前批次信息和人员序号
  1003. batch_id = self._current_batch_id if self._enable_paired_saving else None
  1004. person_index = self._person_ptz_index.get(target.track_id, -1) if self._enable_paired_saving else -1
  1005. cmd = PTZCommand(
  1006. pan=0, tilt=0, zoom=0,
  1007. x_ratio=x_ratio, y_ratio=y_ratio,
  1008. use_calibration=self.enable_calibration,
  1009. track_id=target.track_id, # 传递跟踪ID
  1010. batch_id=batch_id, # 传递批次ID
  1011. person_index=person_index # 传递人员序号
  1012. )
  1013. try:
  1014. self._ptz_queue.put_nowait(cmd)
  1015. self.last_ptz_position = (x_ratio, y_ratio)
  1016. self._update_stats('ptz_commands_sent' if 'ptz_commands_sent' in self.stats else 'persons_detected')
  1017. logger.info(
  1018. f"[PTZ] 命令已发送: 目标ID={target.track_id} "
  1019. f"全景位置=({x_ratio:.3f}, {y_ratio:.3f}) → "
  1020. f"PTZ角度=(pan={pan:.1f}°, tilt={tilt:.1f}°, zoom={zoom}) [{coord_type}]"
  1021. )
  1022. except queue.Full:
  1023. logger.warning("[PTZ] 命令队列满,丢弃本次命令")
  1024. def _execute_ptz_command(self, cmd: PTZCommand):
  1025. """
  1026. 执行PTZ命令(在PTZ线程中)
  1027. Args:
  1028. cmd: PTZ命令(包含 batch_id, person_index, track_id 用于配对保存)
  1029. """
  1030. self._last_ptz_time = time.time()
  1031. # 从命令中提取配对保存相关信息
  1032. track_id = cmd.track_id
  1033. batch_id = cmd.batch_id
  1034. person_index = cmd.person_index
  1035. if cmd.use_calibration and self.calibrator and self.calibrator.is_calibrated():
  1036. pan, tilt = self.calibrator.transform(cmd.x_ratio, cmd.y_ratio)
  1037. if self.ptz.ptz_config.get('pan_flip', False):
  1038. pan = (pan + 180) % 360
  1039. zoom = self.ptz.ptz_config.get('default_zoom', 8)
  1040. else:
  1041. pan, tilt, zoom = self.ptz.calculate_ptz_position(cmd.x_ratio, cmd.y_ratio)
  1042. self._set_state(TrackingState.TRACKING)
  1043. logger.info(
  1044. f"[PTZ] 执行: pan={pan:.1f}° tilt={tilt:.1f}° zoom={zoom} "
  1045. f"(全景位置=({cmd.x_ratio:.3f}, {cmd.y_ratio:.3f}), "
  1046. f"batch={batch_id}, person={person_index})"
  1047. )
  1048. success = self.ptz.goto_exact_position(pan, tilt, zoom)
  1049. if success:
  1050. # 等待球机物理移动到位(增加额外等待确保画面清晰)
  1051. time.sleep(self.PTZ_CONFIRM_WAIT)
  1052. # 球机端人体检测与自动对焦
  1053. final_pan, final_tilt, final_zoom = pan, tilt, zoom
  1054. if self.enable_ptz_detection and self.auto_zoom_config.get('enabled', False):
  1055. auto_zoom_result = self._auto_zoom_person(pan, tilt, zoom)
  1056. if auto_zoom_result != zoom:
  1057. final_zoom = auto_zoom_result
  1058. # 自动变焦后再次等待画面稳定
  1059. time.sleep(0.5)
  1060. # 获取清晰的球机画面(尝试多次获取最新帧)
  1061. ptz_frame = self._get_clear_ptz_frame()
  1062. # 保存球机图片到配对批次(使用命令中的 batch_id 和 person_index)
  1063. if self._enable_paired_saving and batch_id is not None and person_index >= 0 and ptz_frame is not None:
  1064. # 使用球机端检测器检测人体并标记
  1065. ptz_frame_marked = self._mark_ptz_frame_with_detection(ptz_frame, person_index=person_index)
  1066. self._save_ptz_image_for_person_batch(batch_id, person_index, ptz_frame_marked, (final_pan, final_tilt, final_zoom))
  1067. elif self._enable_paired_saving:
  1068. logger.warning(f"[配对保存] 跳过球机图保存: batch_id={batch_id}, person_index={person_index}, frame={ptz_frame is not None}")
  1069. logger.info(f"[PTZ] 到位确认完成: pan={final_pan:.1f}° tilt={final_tilt:.1f}° zoom={final_zoom}")
  1070. else:
  1071. logger.warning(f"[PTZ] 命令执行失败: pan={pan:.1f}° tilt={tilt:.1f}° zoom={zoom}")
  1072. def _auto_zoom_person(self, initial_pan: float, initial_tilt: float, initial_zoom: int) -> int:
  1073. """
  1074. 自动对焦人体
  1075. 在球机画面中检测人体,自动调整zoom使人体居中且大小合适
  1076. Returns:
  1077. 最终的 zoom 值
  1078. """
  1079. if self.auto_zoom_controller is None:
  1080. return initial_zoom
  1081. logger.info("[AutoZoom] 开始自动对焦...")
  1082. try:
  1083. success, final_zoom = self.auto_zoom_controller.auto_focus_loop(
  1084. get_frame_func=self.ptz.get_frame,
  1085. max_attempts=self.auto_zoom_config.get('max_adjust_attempts', 3)
  1086. )
  1087. if success:
  1088. logger.info(f"[AutoZoom] 自动对焦成功: zoom={final_zoom}")
  1089. return final_zoom
  1090. else:
  1091. logger.warning("[AutoZoom] 自动对焦未能定位人体")
  1092. return initial_zoom
  1093. except Exception as e:
  1094. logger.error(f"[AutoZoom] 自动对焦异常: {e}")
  1095. return initial_zoom
  1096. def _get_clear_ptz_frame(self, max_attempts: int = 5, wait_interval: float = 0.1) -> Optional[np.ndarray]:
  1097. """
  1098. 获取清晰的球机画面
  1099. 尝试多次获取,丢弃模糊/过渡帧
  1100. Args:
  1101. max_attempts: 最大尝试次数
  1102. wait_interval: 每次等待间隔
  1103. Returns:
  1104. 清晰的球机帧或 None
  1105. """
  1106. best_frame = None
  1107. best_score = -1
  1108. for i in range(max_attempts):
  1109. frame = self.ptz.get_frame()
  1110. if frame is not None:
  1111. # 使用拉普拉斯算子评估图像清晰度
  1112. gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
  1113. laplacian_var = cv2.Laplacian(gray, cv2.CV_64F).var()
  1114. if laplacian_var > best_score:
  1115. best_score = laplacian_var
  1116. best_frame = frame.copy()
  1117. # 如果清晰度足够高,直接返回
  1118. if laplacian_var > 100: # 清晰度阈值
  1119. return frame
  1120. time.sleep(wait_interval)
  1121. return best_frame
  1122. def _mark_ptz_frame_with_detection(self, frame: np.ndarray, person_index: int) -> np.ndarray:
  1123. """
  1124. 在球机帧上标记检测到的人体
  1125. Args:
  1126. frame: 球机帧
  1127. person_index: 人员序号
  1128. Returns:
  1129. 标记后的帧
  1130. """
  1131. marked_frame = frame.copy()
  1132. h, w = marked_frame.shape[:2]
  1133. # 重置保存的bbox
  1134. self._last_ptz_bbox = None
  1135. # 使用球机端检测器检测人体
  1136. if self.ptz_detector is not None:
  1137. try:
  1138. persons = self.ptz_detector.detect(frame)
  1139. if persons:
  1140. # 找到最大的人体(假设是目标)
  1141. largest_person = max(persons, key=lambda p: p.area)
  1142. x1, y1, x2, y2 = largest_person.bbox
  1143. # 保存bbox供后续使用
  1144. self._last_ptz_bbox = (x1, y1, x2, y2)
  1145. # 绘制边界框(红色,区别于全景的绿色)
  1146. cv2.rectangle(marked_frame, (x1, y1), (x2, y2), (0, 0, 255), 2)
  1147. # 绘制标签
  1148. label = f"person_{person_index} ({largest_person.confidence:.2f})"
  1149. (label_w, label_h), _ = cv2.getTextSize(
  1150. label, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2
  1151. )
  1152. # 标签背景(红色)
  1153. cv2.rectangle(
  1154. marked_frame,
  1155. (x1, y1 - label_h - 8),
  1156. (x1 + label_w, y1),
  1157. (0, 0, 255),
  1158. -1
  1159. )
  1160. # 标签文字(白色)
  1161. cv2.putText(
  1162. marked_frame, label,
  1163. (x1, y1 - 4),
  1164. cv2.FONT_HERSHEY_SIMPLEX, 0.7,
  1165. (255, 255, 255), 2
  1166. )
  1167. logger.info(f"[配对保存] 球机图标记: person_{person_index}, "
  1168. f"位置=({x1},{y1},{x2},{y2}), 置信度={largest_person.confidence:.2f}")
  1169. else:
  1170. # 未检测到人体,在画面中心添加提示
  1171. cv2.putText(
  1172. marked_frame, f"person_{person_index} (no detection)",
  1173. (w // 2 - 100, h // 2),
  1174. cv2.FONT_HERSHEY_SIMPLEX, 0.8,
  1175. (0, 0, 255), 2
  1176. )
  1177. except Exception as e:
  1178. logger.error(f"[配对保存] 球机图检测标记失败: {e}")
  1179. return marked_frame
  1180. def _confirm_ptz_position(self, x_ratio: float, y_ratio: float):
  1181. """PTZ位置确认:读取球机帧验证目标是否可见"""
  1182. if not hasattr(self.ptz, 'get_frame') or self.ptz.get_frame() is None:
  1183. return
  1184. ptz_frame = self.ptz.get_frame()
  1185. if ptz_frame is None:
  1186. return
  1187. # 未来可以在这里添加球机帧目标验证逻辑
  1188. # 例如:在球机帧中检测目标是否在画面中心附近
  1189. def on_ptz_confirmed(self, callback: Callable):
  1190. """注册PTZ位置确认回调"""
  1191. self._on_ptz_confirmed = callback