coordinator.py 89 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154
  1. """
  2. 联动控制器
  3. 协调全景摄像头和球机的工作
  4. """
  5. import time
  6. import threading
  7. import queue
  8. import logging
  9. import math
  10. from typing import Optional, List, Dict, Tuple, Callable
  11. from dataclasses import dataclass, field
  12. from enum import Enum
  13. import numpy as np
  14. import cv2
  15. from config import COORDINATOR_CONFIG, SYSTEM_CONFIG, PTZ_CONFIG, DETECTION_CONFIG
  16. from panorama_camera import PanoramaCamera, ObjectDetector, DetectedObject
  17. from ptz_camera import PTZCamera, PTZController
  18. from ptz_person_tracker import PTZPersonDetector, PTZAutoZoomController
  19. from paired_image_saver import PairedImageSaver, get_paired_saver, PersonInfo
  20. logger = logging.getLogger(__name__)
  21. class TrackingState(Enum):
  22. """跟踪状态"""
  23. IDLE = 0 # 空闲
  24. SEARCHING = 1 # 搜索目标
  25. TRACKING = 2 # 跟踪中
  26. ZOOMING = 3 # 变焦中
  27. @dataclass
  28. class TrackingTarget:
  29. """跟踪目标"""
  30. track_id: int # 跟踪ID
  31. position: Tuple[float, float] # 位置比例 (x_ratio, y_ratio)
  32. last_update: float # 最后更新时间
  33. person_info: Optional[dict] = None # 人员信息
  34. priority: int = 0 # 优先级
  35. area: int = 0 # 目标面积(像素²)
  36. confidence: float = 0.0 # 置信度
  37. center_distance: float = 1.0 # 到画面中心的距离比例(0-1)
  38. score: float = 0.0 # 综合得分
  39. class TargetSelector:
  40. """
  41. 目标选择策略类
  42. 支持按面积、置信度、混合模式排序,支持优先级切换
  43. """
  44. def __init__(self, config: Dict = None):
  45. """
  46. 初始化目标选择器
  47. Args:
  48. config: 目标选择配置
  49. """
  50. self.config = config or {
  51. 'strategy': 'area',
  52. 'area_weight': 0.6,
  53. 'confidence_weight': 0.4,
  54. 'min_area_threshold': 5000,
  55. 'prefer_center': True,
  56. 'center_weight': 0.2,
  57. 'switch_on_lost': True,
  58. 'stickiness': 0.3,
  59. }
  60. self.current_target_id: Optional[int] = None
  61. self.current_target_score: float = 0.0
  62. def calculate_score(self, target: TrackingTarget, frame_size: Tuple[int, int] = None) -> float:
  63. """
  64. 计算目标综合得分
  65. Args:
  66. target: 跟踪目标
  67. frame_size: 帧尺寸(w, h),用于计算中心距离
  68. Returns:
  69. 综合得分(0-1)
  70. """
  71. strategy = self.config.get('strategy', 'area')
  72. area_weight = self.config.get('area_weight', 0.6)
  73. conf_weight = self.config.get('confidence_weight', 0.4)
  74. min_area = self.config.get('min_area_threshold', 5000)
  75. prefer_center = self.config.get('prefer_center', False)
  76. center_weight = self.config.get('center_weight', 0.2)
  77. # 归一化面积得分 (对数缩放,避免大目标得分过高)
  78. import math
  79. area_score = min(1.0, math.log10(max(target.area, 1)) / 5.0) # 100000像素² ≈ 1.0
  80. # 小面积惩罚
  81. if target.area < min_area:
  82. area_score *= 0.5
  83. # 置信度得分直接使用
  84. conf_score = target.confidence
  85. # 中心距离得分 (距离中心越近得分越高)
  86. center_score = 1.0 - target.center_distance
  87. # 根据策略计算综合得分
  88. if strategy == 'area':
  89. score = area_score * 0.8 + conf_score * 0.2
  90. elif strategy == 'confidence':
  91. score = conf_score * 0.8 + area_score * 0.2
  92. else: # hybrid
  93. score = area_score * area_weight + conf_score * conf_weight
  94. # 加入中心距离权重
  95. if prefer_center:
  96. score = score * (1 - center_weight) + center_score * center_weight
  97. return score
  98. def select_target(self, targets: Dict[int, TrackingTarget],
  99. frame_size: Tuple[int, int] = None) -> Optional[TrackingTarget]:
  100. """
  101. 从多个目标中选择最优目标
  102. Args:
  103. targets: 目标字典 {track_id: TrackingTarget}
  104. frame_size: 帧尺寸
  105. Returns:
  106. 最优目标
  107. """
  108. if not targets:
  109. self.current_target_id = None
  110. return None
  111. stickiness = self.config.get('stickiness', 0.3)
  112. switch_on_lost = self.config.get('switch_on_lost', True)
  113. # 计算所有目标得分
  114. scored_targets = []
  115. for track_id, target in targets.items():
  116. target.score = self.calculate_score(target, frame_size)
  117. scored_targets.append((track_id, target, target.score))
  118. # 按得分排序
  119. scored_targets.sort(key=lambda x: x[2], reverse=True)
  120. # 检查当前目标是否仍在列表中
  121. if self.current_target_id is not None:
  122. current_exists = self.current_target_id in targets
  123. if current_exists:
  124. # 应用粘性:当前目标得分需要显著低于最优目标才切换
  125. best_id, best_target, best_score = scored_targets[0]
  126. current_target = targets[self.current_target_id]
  127. # 粘性阈值: 当前目标得分 > 最优得分 * (1 - stickiness) 时保持
  128. stickiness_threshold = best_score * (1 - stickiness)
  129. if current_target.score > stickiness_threshold:
  130. return current_target
  131. # 选择得分最高的目标
  132. best_id, best_target, best_score = scored_targets[0]
  133. self.current_target_id = best_id
  134. self.current_target_score = best_score
  135. logger.debug(
  136. f"[目标选择] 选择目标ID={best_id} 得分={best_score:.3f} "
  137. f"面积={best_target.area} 置信度={best_target.confidence:.2f}"
  138. )
  139. return best_target
  140. def get_sorted_targets(self, targets: Dict[int, TrackingTarget],
  141. frame_size: Tuple[int, int] = None) -> List[Tuple[TrackingTarget, float]]:
  142. """
  143. 获取按得分排序的目标列表
  144. Args:
  145. targets: 目标字典
  146. frame_size: 帧尺寸
  147. Returns:
  148. 排序后的目标列表 [(target, score), ...]
  149. """
  150. scored = []
  151. for target in targets.values():
  152. target.score = self.calculate_score(target, frame_size)
  153. scored.append((target, target.score))
  154. scored.sort(key=lambda x: x[1], reverse=True)
  155. return scored
  156. def set_strategy(self, strategy: str):
  157. """设置选择策略"""
  158. self.config['strategy'] = strategy
  159. logger.info(f"[目标选择] 策略已切换为: {strategy}")
  160. def set_stickiness(self, stickiness: float):
  161. """设置目标粘性"""
  162. self.config['stickiness'] = max(0.0, min(1.0, stickiness))
  163. logger.info(f"[目标选择] 粘性已设置为: {self.config['stickiness']}")
  164. class Coordinator:
  165. """
  166. 联动控制器
  167. 协调全景摄像头和球机实现联动抓拍
  168. """
  169. def __init__(self, panorama_camera: PanoramaCamera,
  170. ptz_camera: PTZCamera,
  171. detector: ObjectDetector = None,
  172. calibrator = None):
  173. """
  174. 初始化联动控制器
  175. Args:
  176. panorama_camera: 全景摄像头
  177. ptz_camera: 球机
  178. detector: 物体检测器
  179. calibrator: 校准器 (用于坐标转换)
  180. """
  181. self.panorama = panorama_camera
  182. self.ptz = ptz_camera
  183. self.detector = detector
  184. self.calibrator = calibrator
  185. self.config = COORDINATOR_CONFIG
  186. # 功能开关 - 从 SYSTEM_CONFIG 读取
  187. self.enable_ptz_camera = SYSTEM_CONFIG.get('enable_ptz_camera', True)
  188. self.enable_ptz_tracking = SYSTEM_CONFIG.get('enable_ptz_tracking', True)
  189. self.enable_calibration = SYSTEM_CONFIG.get('enable_calibration', True)
  190. self.enable_detection = SYSTEM_CONFIG.get('enable_detection', True)
  191. # 球机端人体检测与自动对焦
  192. self.enable_ptz_detection = PTZ_CONFIG.get('enable_ptz_detection', False)
  193. self.auto_zoom_config = PTZ_CONFIG.get('auto_zoom', {})
  194. self.ptz_detector = None
  195. self.auto_zoom_controller = None
  196. # 状态
  197. self.state = TrackingState.IDLE
  198. self.state_lock = threading.Lock()
  199. # 跟踪目标
  200. self.tracking_targets: Dict[int, TrackingTarget] = {}
  201. self.targets_lock = threading.Lock()
  202. # 当前跟踪目标
  203. self.current_target: Optional[TrackingTarget] = None
  204. # 回调函数
  205. self.on_person_detected: Optional[Callable] = None
  206. self.on_tracking_started: Optional[Callable] = None
  207. self.on_tracking_stopped: Optional[Callable] = None
  208. # 控制标志
  209. self.running = False
  210. self._paused = False
  211. self._paused_event = threading.Event()
  212. self._paused_event.set() # 默认非暂停状态
  213. self.coordinator_thread = None
  214. # PTZ优化 - 避免频繁发送相同位置的命令
  215. self.last_ptz_position = None
  216. self.ptz_position_threshold = self.config.get('ptz_position_threshold', 0.03)
  217. # 目标选择器
  218. self.target_selector = TargetSelector(
  219. self.config.get('target_selection', {})
  220. )
  221. # 结果队列
  222. self.result_queue = queue.Queue()
  223. # ByteTrack 跟踪器(基于 Ultralytics BYTETracker)
  224. self.byte_tracker = None
  225. self._byte_tracker_init_lock = threading.Lock()
  226. # 跨帧跟踪:全局track_id计数器
  227. self._next_track_id = 1
  228. self._track_id_lock = threading.Lock()
  229. # 性能统计
  230. self.stats = {
  231. 'frames_processed': 0,
  232. 'persons_detected': 0,
  233. 'start_time': None,
  234. 'last_frame_time': None,
  235. }
  236. self.stats_lock = threading.Lock()
  237. def set_calibrator(self, calibrator):
  238. """设置校准器"""
  239. self.calibrator = calibrator
  240. def pause_detection(self):
  241. """暂停检测(校准时使用,线程不退出,仅跳过检测逻辑)"""
  242. self._paused = True
  243. self._paused_event.clear()
  244. logger.info("[协调器] 检测已暂停")
  245. def resume_detection(self):
  246. """恢复检测(校准完成后恢复)"""
  247. self._paused = False
  248. self._paused_event.set()
  249. logger.info("[协调器] 检测已恢复")
  250. def is_paused(self) -> bool:
  251. """检测是否暂停"""
  252. return self._paused
  253. def _transform_position(self, x_ratio: float, y_ratio: float) -> Tuple[float, float, int]:
  254. """
  255. 将全景坐标转换为PTZ角度
  256. Args:
  257. x_ratio: X方向比例
  258. y_ratio: Y方向比例
  259. Returns:
  260. (pan, tilt, zoom)
  261. """
  262. if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
  263. # 使用校准结果进行转换(tilt偏移已在calibrator.transform中应用)
  264. pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
  265. zoom = 8 # 默认变倍
  266. else:
  267. # 使用默认估算
  268. pan, tilt, zoom = self.ptz.calculate_ptz_position(x_ratio, y_ratio)
  269. return (pan, tilt, zoom)
  270. def start(self) -> bool:
  271. """
  272. 启动联动系统
  273. Returns:
  274. 是否成功
  275. """
  276. # 连接全景摄像头
  277. if not self.panorama.connect():
  278. print("连接全景摄像头失败")
  279. return False
  280. # 连接 PTZ 球机 (可选)
  281. if self.enable_ptz_camera:
  282. if not self.ptz.connect():
  283. print("连接球机失败")
  284. self.panorama.disconnect()
  285. return False
  286. else:
  287. print("PTZ 球机功能已禁用")
  288. # 启动视频流(优先RTSP,SDK回调不可用时回退)
  289. if not self.panorama.start_stream_rtsp():
  290. print("RTSP视频流启动失败,尝试SDK方式...")
  291. if not self.panorama.start_stream():
  292. print("启动视频流失败")
  293. self.panorama.disconnect()
  294. if self.enable_ptz_camera:
  295. self.ptz.disconnect()
  296. return False
  297. # 启动联动线程
  298. self.running = True
  299. self.coordinator_thread = threading.Thread(target=self._coordinator_worker, daemon=True)
  300. self.coordinator_thread.start()
  301. print("联动系统已启动")
  302. return True
  303. def stop(self):
  304. """停止联动系统"""
  305. self.running = False
  306. if self.coordinator_thread:
  307. self.coordinator_thread.join(timeout=3)
  308. self.panorama.disconnect()
  309. if self.enable_ptz_camera:
  310. self.ptz.disconnect()
  311. # 清理 BYTETracker
  312. self.byte_tracker = None
  313. # 打印统计信息
  314. self._print_stats()
  315. print("联动系统已停止")
  316. def _update_stats(self, key: str, value: int = 1):
  317. """更新统计信息"""
  318. with self.stats_lock:
  319. if key in self.stats:
  320. self.stats[key] += value
  321. def _print_stats(self):
  322. """打印统计信息"""
  323. with self.stats_lock:
  324. if self.stats['start_time'] and self.stats['frames_processed'] > 0:
  325. elapsed = time.time() - self.stats['start_time']
  326. fps = self.stats['frames_processed'] / elapsed
  327. print("\n=== 性能统计 ===")
  328. print(f"运行时长: {elapsed:.1f}秒")
  329. print(f"处理帧数: {self.stats['frames_processed']}")
  330. print(f"平均帧率: {fps:.1f} fps")
  331. print(f"检测人体: {self.stats['persons_detected']}次")
  332. print("================\n")
  333. def get_stats(self) -> dict:
  334. """获取统计信息"""
  335. with self.stats_lock:
  336. return self.stats.copy()
  337. def _coordinator_worker(self):
  338. """联动工作线程"""
  339. # 暂停时阻塞等待恢复,不消耗CPU
  340. self._paused_event.wait()
  341. last_detection_time = 0
  342. # 从 DETECTION_CONFIG 获取检测帧率,默认每秒2帧
  343. detection_fps = self.config.get('detection_fps', DETECTION_CONFIG.get('detection_fps', 2))
  344. detection_interval = 1.0 / detection_fps # 根据FPS计算间隔
  345. # 初始化统计
  346. with self.stats_lock:
  347. self.stats['start_time'] = time.time()
  348. while self.running:
  349. try:
  350. current_time = time.time()
  351. # 获取当前帧
  352. frame = self.panorama.get_frame()
  353. if frame is None:
  354. time.sleep(0.01)
  355. continue
  356. # 更新帧统计
  357. self._update_stats('frames_processed')
  358. frame_size = (frame.shape[1], frame.shape[0])
  359. # 周期性检测(暂停时跳过)
  360. if not self._paused and current_time - last_detection_time >= detection_interval:
  361. last_detection_time = current_time
  362. # 检测人体
  363. detections = self._detect_persons(frame)
  364. # 使用 BYTETracker 进行跟踪(失败时回退到位置匹配)
  365. detections = self._update_with_bytetrack(detections, frame, frame_size)
  366. # 更新检测统计
  367. if detections:
  368. self._update_stats('persons_detected', len(detections))
  369. # 处理检测结果
  370. if detections:
  371. self._process_detections(detections, frame, frame_size)
  372. # 处理当前跟踪目标(暂停时跳过PTZ控制)
  373. if not self._paused:
  374. self._process_current_target(frame, frame_size)
  375. # 清理过期目标
  376. self._cleanup_expired_targets()
  377. time.sleep(0.01)
  378. except Exception as e:
  379. print(f"联动处理错误: {e}")
  380. time.sleep(0.1)
  381. def _init_byte_tracker(self):
  382. """初始化 BYTETracker"""
  383. with self._byte_tracker_init_lock:
  384. if self.byte_tracker is not None:
  385. return
  386. try:
  387. from ultralytics.trackers.byte_tracker import BYTETracker
  388. import types
  389. self._bt_args = types.SimpleNamespace(
  390. track_high_thresh=0.5,
  391. track_low_thresh=0.1,
  392. new_track_thresh=0.3,
  393. match_thresh=0.8,
  394. fuse_score=False,
  395. track_buffer=30,
  396. mot20=False,
  397. )
  398. self.byte_tracker = BYTETracker(args=self._bt_args)
  399. logger.info("[跟踪] BYTETracker 初始化成功")
  400. except Exception as e:
  401. logger.warning(f"[跟踪] BYTETracker 初始化失败: {e},将使用简化位置匹配跟踪")
  402. self.byte_tracker = None
  403. def _update_with_bytetrack(self, detections: List[DetectedObject],
  404. frame: np.ndarray,
  405. frame_size: Tuple[int, int]) -> List[DetectedObject]:
  406. """
  407. 使用 ObjectDetector + BYTETracker 进行跟踪
  408. ByteTrack 失败时回退到旧位置匹配
  409. """
  410. conf_thr = DETECTION_CONFIG.get('confidence_threshold', 0.35)
  411. person_dets = [d for d in detections if d.class_name == 'person'
  412. and d.confidence >= conf_thr]
  413. self._init_byte_tracker()
  414. if self.byte_tracker is not None and person_dets:
  415. try:
  416. import torch
  417. dets_t = torch.tensor([[d.bbox[0], d.bbox[1], d.bbox[0]+d.bbox[2], d.bbox[1]+d.bbox[3], d.confidence, 0] for d in person_dets], dtype=torch.float32)
  418. class _R:
  419. def __init__(s, x):
  420. s._raw = x
  421. s.xywh = torch.stack([(x[:,0]+x[:,2])/2,(x[:,1]+x[:,3])/2,x[:,2]-x[:,0],x[:,3]-x[:,1]], dim=-1)
  422. s.conf = x[:, 4]; s.cls = x[:, 5].long()
  423. def __getitem__(s, i): return _R(s._raw[i])
  424. def __len__(s): return len(s.conf)
  425. tracks = self.byte_tracker.update(_R(dets_t), None)
  426. if tracks is not None and len(tracks) > 0:
  427. frame_w, frame_h = frame_size
  428. cx_, cy_ = frame_w / 2, frame_h / 2
  429. now = time.time()
  430. with self.targets_lock:
  431. self.tracking_targets.clear()
  432. for tr in tracks:
  433. tx1, ty1, tx2, ty2, tid, tsc = int(tr[0]), int(tr[1]), int(tr[2]), int(tr[3]), int(tr[4]), float(tr[5])
  434. cxc, cyc = (tx1+tx2)//2, (ty1+ty2)//2
  435. self.tracking_targets[tid] = TrackingTarget(
  436. track_id=tid, position=(cxc/frame_w, cyc/frame_h),
  437. last_update=now, area=(tx2-tx1)*(ty2-ty1),
  438. confidence=tsc, center_distance=(abs(cxc-cx_)/cx_ + abs(cyc-cy_)/cy_)/2 if cx_>0 else 0)
  439. # IOU match
  440. import numpy as np
  441. for tr in np.array(tracks):
  442. tx1, ty1, tx2, ty2, tid = int(tr[0]), int(tr[1]), int(tr[2]), int(tr[3]), int(tr[4])
  443. best = None
  444. for d in person_dets:
  445. dx1, dy1, dw, dh = d.bbox; dx2, dy2 = dx1+dw, dy1+dh
  446. ix1, iy1 = max(tx1,dx1), max(ty1,dy1); ix2, iy2 = min(tx2,dx2), min(ty2,dy2)
  447. if ix1<ix2 and iy1<iy2 and ((ix2-ix1)*(iy2-iy1))/((tx2-tx1)*(ty2-ty1)+dw*dh-(ix2-ix1)*(iy2-iy1)+1e-6) > 0.3:
  448. best = d
  449. if best is not None:
  450. best.track_id = tid
  451. return [d for d in person_dets if d.track_id is not None]
  452. except Exception as e:
  453. logger.warning(f"[跟踪] ByteTrack 执行异常: {e}")
  454. # ByteTrack 不可用/无结果 → 回退旧位置匹配
  455. self._update_tracking_targets(detections, frame_size)
  456. return detections
  457. def _detect_persons(self, frame: np.ndarray) -> List[DetectedObject]:
  458. """检测人体"""
  459. if not self.enable_detection or self.detector is None:
  460. return []
  461. return self.detector.detect_persons(frame)
  462. def _update_tracking_targets(self, detections: List[DetectedObject],
  463. frame_size: Tuple[int, int]):
  464. """更新跟踪目标(跨帧匹配,支持粘性跟踪)
  465. 改进:不再每轮清空目标,而是使用位置匹配关联连续帧的目标
  466. """
  467. current_time = time.time()
  468. frame_w, frame_h = frame_size
  469. center_x, center_y = frame_w / 2, frame_h / 2
  470. # 获取人员置信度阈值
  471. person_threshold = DETECTION_CONFIG.get('person_threshold', 0.8)
  472. # 过滤有效人员
  473. valid_detections = []
  474. low_conf_count = 0
  475. for det in detections:
  476. if det.class_name != 'person':
  477. continue
  478. if det.confidence < person_threshold:
  479. low_conf_count += 1
  480. continue
  481. valid_detections.append(det)
  482. # 【调试日志】显示过滤结果
  483. if detections:
  484. logger.info(f"[跟踪] 检测到 {len(detections)} 个目标, 置信度>={person_threshold} 的有 {len(valid_detections)} 个 (过滤掉 {low_conf_count} 个)")
  485. if not valid_detections:
  486. return
  487. with self.targets_lock:
  488. # 匹配阈值:位置距离小于此值认为是同一目标
  489. MATCH_THRESHOLD = 0.15 # 画面比例
  490. # 已匹配的检测索引
  491. matched_det_indices = set()
  492. # 步骤1:尝试匹配现有目标
  493. for track_id, target in list(self.tracking_targets.items()):
  494. best_match_idx = None
  495. best_match_dist = MATCH_THRESHOLD
  496. for idx, det in enumerate(valid_detections):
  497. if idx in matched_det_indices:
  498. continue
  499. det_x = det.center[0] / frame_w
  500. det_y = det.center[1] / frame_h
  501. # 计算位置距离
  502. dist = math.sqrt(
  503. (det_x - target.position[0]) ** 2 +
  504. (det_y - target.position[1]) ** 2
  505. )
  506. if dist < best_match_dist:
  507. best_match_dist = dist
  508. best_match_idx = idx
  509. if best_match_idx is not None:
  510. # 找到匹配,更新目标
  511. det = valid_detections[best_match_idx]
  512. matched_det_indices.add(best_match_idx)
  513. x_ratio = det.center[0] / frame_w
  514. y_ratio = det.center[1] / frame_h
  515. _, _, width, height = det.bbox
  516. area = width * height
  517. dx = abs(det.center[0] - center_x) / center_x
  518. dy = abs(det.center[1] - center_y) / center_y
  519. center_distance = (dx + dy) / 2
  520. # 更新目标属性
  521. self.tracking_targets[track_id] = TrackingTarget(
  522. track_id=track_id,
  523. position=(x_ratio, y_ratio),
  524. last_update=current_time,
  525. area=area,
  526. confidence=det.confidence,
  527. center_distance=center_distance,
  528. person_info=target.person_info # 保留之前识别的信息
  529. )
  530. # 步骤2:为未匹配的检测创建新目标
  531. for idx, det in enumerate(valid_detections):
  532. if idx in matched_det_indices:
  533. continue
  534. x_ratio = det.center[0] / frame_w
  535. y_ratio = det.center[1] / frame_h
  536. _, _, width, height = det.bbox
  537. area = width * height
  538. dx = abs(det.center[0] - center_x) / center_x
  539. dy = abs(det.center[1] - center_y) / center_y
  540. center_distance = (dx + dy) / 2
  541. # 分配全局唯一track_id
  542. with self._track_id_lock:
  543. new_track_id = self._next_track_id
  544. self._next_track_id += 1
  545. det.track_id = new_track_id # 更新检测对象的track_id
  546. self.tracking_targets[new_track_id] = TrackingTarget(
  547. track_id=new_track_id,
  548. position=(x_ratio, y_ratio),
  549. last_update=current_time,
  550. area=area,
  551. confidence=det.confidence,
  552. center_distance=center_distance
  553. )
  554. def _process_detections(self, detections: List[DetectedObject],
  555. frame: np.ndarray, frame_size: Tuple[int, int]):
  556. """处理检测结果"""
  557. if self.on_person_detected:
  558. for det in detections:
  559. self.on_person_detected(det, frame)
  560. def _process_current_target(self, frame: np.ndarray, frame_size: Tuple[int, int]):
  561. """处理当前跟踪目标"""
  562. with self.targets_lock:
  563. if not self.tracking_targets:
  564. self._set_state(TrackingState.IDLE)
  565. self.current_target = None
  566. return
  567. # 使用目标选择器选择最优目标
  568. self.current_target = self.target_selector.select_target(
  569. self.tracking_targets, frame_size
  570. )
  571. if self.current_target:
  572. # 移动球机到目标位置 (仅在 PTZ 跟踪启用时)
  573. if self.enable_ptz_tracking and self.enable_ptz_camera:
  574. self._set_state(TrackingState.TRACKING)
  575. x_ratio, y_ratio = self.current_target.position
  576. # 检查位置是否变化超过阈值
  577. should_move = True
  578. if self.last_ptz_position is not None:
  579. last_x, last_y = self.last_ptz_position
  580. if (abs(x_ratio - last_x) < self.ptz_position_threshold and
  581. abs(y_ratio - last_y) < self.ptz_position_threshold):
  582. should_move = False
  583. if should_move:
  584. if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
  585. # 校准器返回的是可直接发送给球机的真实 PTZ 角度,不再应用 pan_flip
  586. pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
  587. zoom = self.ptz.ptz_config.get('default_zoom', 8)
  588. self.ptz.goto_exact_position(pan, tilt, zoom)
  589. else:
  590. self.ptz.track_target(x_ratio, y_ratio)
  591. self.last_ptz_position = (x_ratio, y_ratio)
  592. def _cleanup_expired_targets(self):
  593. """清理过期目标"""
  594. current_time = time.time()
  595. timeout = self.config['tracking_timeout']
  596. with self.targets_lock:
  597. expired_ids = [
  598. target_id for target_id, target in self.tracking_targets.items()
  599. if current_time - target.last_update > timeout
  600. ]
  601. for target_id in expired_ids:
  602. del self.tracking_targets[target_id]
  603. if self.current_target and self.current_target.track_id == target_id:
  604. self.current_target = None
  605. def _set_state(self, state: TrackingState):
  606. """设置状态"""
  607. with self.state_lock:
  608. self.state = state
  609. def get_state(self) -> TrackingState:
  610. """获取状态"""
  611. with self.state_lock:
  612. return self.state
  613. def get_results(self) -> List[PersonInfo]:
  614. """
  615. 获取识别结果
  616. Returns:
  617. 人员信息列表
  618. """
  619. results = []
  620. while not self.result_queue.empty():
  621. try:
  622. results.append(self.result_queue.get_nowait())
  623. except queue.Empty:
  624. break
  625. return results
  626. def get_tracking_targets(self) -> List[TrackingTarget]:
  627. """获取当前跟踪目标"""
  628. with self.targets_lock:
  629. return list(self.tracking_targets.values())
  630. def force_track_position(self, x_ratio: float, y_ratio: float, zoom: int = None):
  631. """
  632. 强制跟踪指定位置
  633. Args:
  634. x_ratio: X方向比例
  635. y_ratio: Y方向比例
  636. zoom: 变倍
  637. """
  638. if self.enable_ptz_tracking and self.enable_ptz_camera:
  639. if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
  640. # 校准器返回的是可直接发送给球机的真实 PTZ 角度,不再应用 pan_flip
  641. pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
  642. self.ptz.goto_exact_position(pan, tilt, zoom or self.ptz.ptz_config.get('default_zoom', 8))
  643. else:
  644. self.ptz.move_to_target(x_ratio, y_ratio, zoom)
  645. def capture_snapshot(self) -> Optional[np.ndarray]:
  646. """
  647. 抓拍快照
  648. Returns:
  649. 快照图像
  650. """
  651. return self.panorama.get_frame()
  652. class EventDrivenCoordinator(Coordinator):
  653. """事件驱动联动控制器,当全景摄像头检测到事件时触发联动"""
  654. def __init__(self, *args, **kwargs):
  655. super().__init__(*args, **kwargs)
  656. self.event_types = {
  657. 'intruder': True,
  658. 'crossline': True,
  659. 'motion': True,
  660. }
  661. self.event_queue = queue.Queue()
  662. def on_event(self, event_type: str, event_data: dict):
  663. if not self.event_types.get(event_type, False):
  664. return
  665. self.event_queue.put({'type': event_type, 'data': event_data, 'time': time.time()})
  666. def _coordinator_worker(self):
  667. while self.running:
  668. try:
  669. try:
  670. event = self.event_queue.get(timeout=0.1)
  671. self._process_event(event)
  672. except queue.Empty:
  673. pass
  674. frame = self.panorama.get_frame()
  675. if frame is not None:
  676. frame_size = (frame.shape[1], frame.shape[0])
  677. detections = self._detect_persons(frame)
  678. if detections:
  679. # 更新跟踪目标(track_id 在此方法内分配)
  680. self._update_tracking_targets(detections, frame_size)
  681. self._process_current_target(frame, frame_size)
  682. self._cleanup_expired_targets()
  683. except Exception as e:
  684. print(f"事件处理错误: {e}")
  685. time.sleep(0.1)
  686. def _process_event(self, event: dict):
  687. event_type = event['type']
  688. event_data = event['data']
  689. print(f"处理事件: {event_type}")
  690. if event_type == 'intruder' and 'position' in event_data:
  691. x_ratio, y_ratio = event_data['position']
  692. self.force_track_position(x_ratio, y_ratio)
  693. @dataclass
  694. class PTZCommand:
  695. """PTZ控制命令"""
  696. pan: float
  697. tilt: float
  698. zoom: int
  699. x_ratio: float = 0.0
  700. y_ratio: float = 0.0
  701. use_calibration: bool = True
  702. track_id: Optional[int] = None # 跟踪目标ID(用于配对图片保存)
  703. batch_id: Optional[str] = None # 批次ID(用于配对图片保存)
  704. person_index: int = -1 # 人员在批次中的序号(用于配对图片保存)
  705. class AsyncCoordinator(Coordinator):
  706. """
  707. 异步联动控制器 — 检测线程与PTZ控制线程分离
  708. 改进:
  709. 1. 检测线程:持续读取全景帧 + YOLO推理
  710. 2. PTZ控制线程:通过命令队列接收目标位置,独立控制球机
  711. 3. 两线程通过 queue 通信,互不阻塞
  712. 4. PTZ位置确认:移动后等待球机到位并验证帧
  713. """
  714. PTZ_CONFIRM_WAIT = 0.3 # PTZ命令后等待稳定的秒数
  715. PTZ_CONFIRM_TIMEOUT = 2.0 # PTZ位置确认超时
  716. PTZ_COMMAND_COOLDOWN = 0.15 # PTZ命令最小间隔秒数
  717. def __init__(self, *args, **kwargs):
  718. super().__init__(*args, **kwargs)
  719. # PTZ命令队列(检测→PTZ)
  720. self._ptz_queue: queue.Queue = queue.Queue(maxsize=10)
  721. # 线程
  722. self._detection_thread = None
  723. self._ptz_thread = None
  724. # PTZ确认回调
  725. self._on_ptz_confirmed: Optional[Callable] = None
  726. # 上次PTZ命令时间(添加线程锁保护)
  727. self._last_ptz_time = 0.0
  728. self._last_ptz_time_lock = threading.Lock()
  729. # 帧获取配置
  730. self._frame_config = {
  731. 'wait_interval': PTZ_CONFIG.get('frame_wait_interval', 0.2),
  732. 'max_attempts': PTZ_CONFIG.get('frame_max_attempts', 8),
  733. 'min_clarity': PTZ_CONFIG.get('min_clarity', 200),
  734. }
  735. # 配对图片保存器
  736. self._enable_paired_saving = DETECTION_CONFIG.get('enable_paired_saving', False)
  737. self._paired_saver: Optional[PairedImageSaver] = None
  738. self._current_batch_id: Optional[str] = None
  739. self._person_ptz_index: Dict[int, int] = {} # track_id -> person_index
  740. if self._enable_paired_saving:
  741. save_dir = DETECTION_CONFIG.get('paired_image_dir', '/home/admin/dsh/paired_images')
  742. time_window = DETECTION_CONFIG.get('paired_time_window', 5.0)
  743. self._paired_saver = get_paired_saver(base_dir=save_dir, time_window=time_window)
  744. logger.info(f"[AsyncCoordinator] 配对图片保存已启用: 目录={save_dir}, 时间窗口={time_window}s")
  745. def start(self) -> bool:
  746. """启动联动(覆盖父类,启动双线程)"""
  747. if not self.panorama.connect():
  748. print("连接全景摄像头失败")
  749. return False
  750. if self.enable_ptz_camera:
  751. if not self.ptz.connect():
  752. print("连接球机失败")
  753. self.panorama.disconnect()
  754. return False
  755. # 启动球机RTSP流(用于球机端人体检测)
  756. if self.enable_ptz_detection:
  757. if not self.ptz.start_stream_rtsp():
  758. print("球机RTSP流启动失败,禁用球机端检测功能")
  759. self.enable_ptz_detection = False
  760. else:
  761. # 初始化球机端人体检测器
  762. self._init_ptz_detector()
  763. else:
  764. print("PTZ球机功能已禁用")
  765. if not self.panorama.start_stream_rtsp():
  766. print("RTSP视频流启动失败,尝试SDK方式...")
  767. if not self.panorama.start_stream():
  768. print("启动视频流失败")
  769. self.panorama.disconnect()
  770. if self.enable_ptz_camera:
  771. self.ptz.disconnect()
  772. return False
  773. self.running = True
  774. # 启动检测线程
  775. self._detection_thread = threading.Thread(
  776. target=self._detection_worker, name="detection-worker", daemon=True)
  777. self._detection_thread.start()
  778. # 启动PTZ控制线程
  779. if self.enable_ptz_camera and self.enable_ptz_tracking:
  780. self._ptz_thread = threading.Thread(
  781. target=self._ptz_worker, name="ptz-worker", daemon=True)
  782. self._ptz_thread.start()
  783. print("异步联动系统已启动 (检测线程 + PTZ控制线程)")
  784. return True
  785. def stop(self):
  786. """停止联动"""
  787. self.running = False
  788. # 清空PTZ队列,让工作线程退出
  789. while not self._ptz_queue.empty():
  790. try:
  791. self._ptz_queue.get_nowait()
  792. except queue.Empty:
  793. break
  794. if self._detection_thread:
  795. self._detection_thread.join(timeout=3)
  796. if self._ptz_thread:
  797. self._ptz_thread.join(timeout=3)
  798. # 停止父类线程(如果有的话)
  799. if self.coordinator_thread:
  800. self.coordinator_thread.join(timeout=1)
  801. # 关闭配对保存器
  802. if self._paired_saver is not None:
  803. self._paired_saver.close()
  804. self._paired_saver = None
  805. # 清理 BYTETracker
  806. self.byte_tracker = None
  807. self.panorama.disconnect()
  808. if self.enable_ptz_camera:
  809. self.ptz.disconnect()
  810. self._print_stats()
  811. print("异步联动系统已停止")
  812. def _detection_worker(self):
  813. """检测线程:持续读帧 + YOLO推理 + 发送PTZ命令 + 打印检测日志"""
  814. # 暂停时阻塞等待恢复
  815. self._paused_event.wait()
  816. last_detection_time = 0
  817. # 从 DETECTION_CONFIG 获取检测帧率,默认每秒2帧
  818. detection_fps = self.config.get('detection_fps', DETECTION_CONFIG.get('detection_fps', 2))
  819. detection_interval = 1.0 / detection_fps # 根据FPS计算间隔
  820. ptz_cooldown = self.config.get('ptz_command_cooldown', 0.5)
  821. ptz_threshold = self.config.get('ptz_position_threshold', 0.03)
  822. frame_count = 0
  823. last_log_time = time.time()
  824. log_interval = 5.0 # 每5秒打印一次帧率统计
  825. detection_run_count = 0
  826. detection_person_count = 0
  827. detection_last_seen = 0
  828. last_no_detect_log_time = 0
  829. no_detect_log_interval = 30.0
  830. with self.stats_lock:
  831. self.stats['start_time'] = time.time()
  832. if self.detector is None:
  833. logger.warning("[检测线程] ⚠️ 人体检测器未初始化! 检测功能不可用, 请检查 YOLO 模型是否正确加载")
  834. elif not self.enable_detection:
  835. logger.warning("[检测线程] ⚠️ 人体检测已禁用 (enable_detection=False)")
  836. else:
  837. logger.info(f"[检测线程] ✓ 人体检测器已就绪, 检测帧率={detection_fps}fps(间隔={detection_interval:.2f}s), PTZ冷却={ptz_cooldown}s")
  838. while self.running:
  839. try:
  840. current_time = time.time()
  841. frame = self.panorama.get_frame()
  842. if frame is None:
  843. time.sleep(0.01)
  844. continue
  845. frame_count += 1
  846. self._update_stats('frames_processed')
  847. frame_size = (frame.shape[1], frame.shape[0])
  848. if current_time - last_log_time >= log_interval:
  849. elapsed = current_time - last_log_time
  850. fps = frame_count / elapsed if elapsed > 0 else 0
  851. state_str = self.state.name if hasattr(self.state, 'name') else str(self.state)
  852. stats_parts = [f"帧率={fps:.1f}fps", f"处理帧={frame_count}", f"状态={state_str}"]
  853. if self.detector is None:
  854. stats_parts.append("检测器=未加载")
  855. elif not self.enable_detection:
  856. stats_parts.append("检测=已禁用")
  857. else:
  858. if detection_last_seen > 0:
  859. ago = int(current_time - detection_last_seen)
  860. stats_parts.append(f"检测轮次={detection_run_count}(最后有人={ago}s前)")
  861. else:
  862. stats_parts.append(f"检测轮次={detection_run_count}(未检出)")
  863. with self.targets_lock:
  864. target_count = len(self.tracking_targets)
  865. stats_parts.append(f"跟踪目标={target_count}")
  866. logger.info(f"[检测线程] {', '.join(stats_parts)}")
  867. frame_count = 0
  868. last_log_time = current_time
  869. # 周期性检测(暂停时跳过检测和PTZ命令)
  870. if not self._paused and current_time - last_detection_time >= detection_interval:
  871. last_detection_time = current_time
  872. detection_run_count += 1
  873. # YOLO 人体检测
  874. detections = self._detect_persons(frame)
  875. # 使用 BYTETracker 进行跟踪(失败时回退到位置匹配)
  876. detections = self._update_with_bytetrack(detections, frame, frame_size)
  877. if detections:
  878. self._update_stats('persons_detected', len(detections))
  879. detection_person_count += 1
  880. detection_last_seen = current_time
  881. # 配对图片保存:创建新批次
  882. if detections and self._enable_paired_saving and self._paired_saver is not None:
  883. self._create_detection_batch(frame, detections, frame_size)
  884. # 打印检测日志(使用连续序号,与图片标记一致)
  885. if detections:
  886. person_threshold = DETECTION_CONFIG.get('person_threshold', 0.8)
  887. person_idx = 0
  888. for t in detections:
  889. # detections 是 DetectedObject,使用 center 计算位置
  890. x_ratio = t.center[0] / frame_size[0]
  891. y_ratio = t.center[1] / frame_size[1]
  892. _, _, w, h = t.bbox
  893. area = w * h
  894. # 只对达到阈值的人员打印日志并分配序号
  895. if t.class_name == 'person' and t.confidence >= person_threshold:
  896. logger.info(
  897. f"[检测] ✓ person_{person_idx} "
  898. f"位置=({x_ratio:.3f}, {y_ratio:.3f}) "
  899. f"面积={area} 置信度={t.confidence:.2f}"
  900. )
  901. person_idx += 1
  902. else:
  903. logger.debug(
  904. f"[检测] · 目标ID={t.track_id}({t.class_name}) "
  905. f"位置=({x_ratio:.3f}, {y_ratio:.3f}) "
  906. f"置信度={t.confidence:.2f}(低于阈值{person_threshold})"
  907. )
  908. else:
  909. if current_time - last_no_detect_log_time >= no_detect_log_interval:
  910. logger.info(
  911. f"[检测] · YOLO检测运行正常, 本轮未检测到人员 "
  912. f"(累计检测{detection_run_count}轮, 检测到人{detection_person_count}轮)"
  913. )
  914. last_no_detect_log_time = current_time
  915. if detections:
  916. self._process_detections(detections, frame, frame_size)
  917. # 为每个检测到的人发送PTZ命令(不再只选一个)
  918. if self.enable_ptz_tracking and self.enable_ptz_camera:
  919. targets = self._get_all_valid_targets()
  920. for target in targets:
  921. self._send_ptz_command_with_log(target, frame_size)
  922. elif not detections and self.current_target:
  923. # 目标消失,切回IDLE
  924. self._set_state(TrackingState.IDLE)
  925. logger.info("[检测] 目标丢失,球机进入IDLE状态")
  926. self.current_target = None
  927. self._cleanup_expired_targets()
  928. time.sleep(0.01)
  929. except Exception as e:
  930. logger.error(f"检测线程错误: {e}")
  931. time.sleep(0.1)
  932. def _init_ptz_detector(self):
  933. """初始化球机端人体检测器"""
  934. try:
  935. model_path = DETECTION_CONFIG.get('model_path')
  936. model_type = DETECTION_CONFIG.get('model_type', 'auto')
  937. conf_threshold = DETECTION_CONFIG.get('person_threshold', 0.5)
  938. if model_path:
  939. self.ptz_detector = PTZPersonDetector(
  940. model_path=model_path,
  941. model_type=model_type,
  942. confidence_threshold=conf_threshold
  943. )
  944. self.auto_zoom_controller = PTZAutoZoomController(
  945. ptz_camera=self.ptz,
  946. detector=self.ptz_detector,
  947. config=self.auto_zoom_config
  948. )
  949. print(f"[AsyncCoordinator] 球机端人体检测器初始化成功")
  950. else:
  951. print("[AsyncCoordinator] 未配置球机检测模型路径,禁用球机端检测")
  952. self.enable_ptz_detection = False
  953. except Exception as e:
  954. print(f"[AsyncCoordinator] 球机端检测器初始化失败: {e}")
  955. self.enable_ptz_detection = False
  956. def _deduplicate_detections(self, detections: List[DetectedObject],
  957. frame_size: Tuple[int, int]) -> List[DetectedObject]:
  958. """
  959. 去重检测结果(按位置合并重叠的检测框)
  960. Args:
  961. detections: 检测列表
  962. frame_size: 帧尺寸
  963. Returns:
  964. 去重后的人员检测列表
  965. """
  966. # 过滤有效人员
  967. person_threshold = DETECTION_CONFIG.get('person_threshold', 0.5)
  968. valid_persons = [d for d in detections
  969. if d.class_name == 'person' and d.confidence >= person_threshold]
  970. if not valid_persons:
  971. return []
  972. # 去重:按位置合并重叠的检测框
  973. DEDUP_DISTANCE = 0.05 # 画面比例 5%
  974. dedup_persons = []
  975. for det in valid_persons:
  976. det_x = det.center[0] / frame_size[0]
  977. det_y = det.center[1] / frame_size[1]
  978. # 检查是否与已有人员重叠
  979. is_duplicate = False
  980. for i, existing in enumerate(dedup_persons):
  981. ex_x = existing.center[0] / frame_size[0]
  982. ex_y = existing.center[1] / frame_size[1]
  983. dist = math.sqrt((det_x - ex_x)**2 + (det_y - ex_y)**2)
  984. if dist < DEDUP_DISTANCE:
  985. # 重叠,保留置信度更高的
  986. is_duplicate = True
  987. if det.confidence > existing.confidence:
  988. dedup_persons[i] = det
  989. break
  990. if not is_duplicate:
  991. dedup_persons.append(det)
  992. return dedup_persons
  993. def _create_detection_batch(self, frame: np.ndarray,
  994. detections: List[DetectedObject],
  995. frame_size: Tuple[int, int]) -> List[DetectedObject]:
  996. """
  997. 创建检测批次,用于配对图片保存
  998. Args:
  999. frame: 全景帧
  1000. detections: 检测到的人员列表
  1001. frame_size: 帧尺寸
  1002. Returns:
  1003. 去重后的人员检测列表
  1004. """
  1005. if self._paired_saver is None:
  1006. return []
  1007. # 过滤有效人员(必须是 person 且置信度 >= 阈值)
  1008. person_threshold = DETECTION_CONFIG.get('person_threshold', 0.8)
  1009. valid_persons = []
  1010. for det in detections:
  1011. # 只处理 class_name 为 person 的目标,排除安全帽、反光衣等
  1012. if det.class_name == 'person' and det.confidence >= person_threshold:
  1013. valid_persons.append(det)
  1014. # 【关键修复】去重:按位置合并重叠的检测框
  1015. # 如果两个检测框的中心距离小于阈值,只保留置信度更高的
  1016. DEDUP_DISTANCE = 0.05 # 画面比例 5%
  1017. dedup_persons = []
  1018. for det in valid_persons:
  1019. det_x = det.center[0] / frame_size[0]
  1020. det_y = det.center[1] / frame_size[1]
  1021. # 检查是否与已有人员重叠
  1022. is_duplicate = False
  1023. for i, existing in enumerate(dedup_persons):
  1024. ex_x = existing.center[0] / frame_size[0]
  1025. ex_y = existing.center[1] / frame_size[1]
  1026. dist = math.sqrt((det_x - ex_x)**2 + (det_y - ex_y)**2)
  1027. if dist < DEDUP_DISTANCE:
  1028. # 重叠,保留置信度更高的
  1029. is_duplicate = True
  1030. if det.confidence > existing.confidence:
  1031. dedup_persons[i] = det
  1032. break
  1033. if not is_duplicate:
  1034. dedup_persons.append(det)
  1035. if not dedup_persons:
  1036. logger.debug(f"[配对保存] 无有效人员(阈值={person_threshold}),跳过批次创建")
  1037. return []
  1038. logger.info(f"[配对保存] 检测结果去重: {len(valid_persons)} -> {len(dedup_persons)} 个人员")
  1039. # 构建人员信息列表(只包含去重后的人员)
  1040. persons = []
  1041. self._person_ptz_index = {} # 重置索引映射
  1042. for i, det in enumerate(dedup_persons):
  1043. x_ratio = det.center[0] / frame_size[0]
  1044. y_ratio = det.center[1] / frame_size[1]
  1045. person_info = {
  1046. 'track_id': det.track_id,
  1047. 'position': (x_ratio, y_ratio),
  1048. 'bbox': (det.bbox[0], det.bbox[1],
  1049. det.bbox[0] + det.bbox[2],
  1050. det.bbox[1] + det.bbox[3]),
  1051. 'confidence': det.confidence
  1052. }
  1053. persons.append(person_info)
  1054. self._person_ptz_index[det.track_id] = i
  1055. # 创建新批次
  1056. batch_id = self._paired_saver.start_new_batch(frame, persons)
  1057. if batch_id:
  1058. self._current_batch_id = batch_id
  1059. logger.info(f"[配对保存] 创建批次: {batch_id}, 有效人员={len(persons)}/{len(detections)}")
  1060. return dedup_persons
  1061. def _save_ptz_image_for_person(self, track_id: int,
  1062. ptz_frame: np.ndarray,
  1063. ptz_position: Tuple[float, float, int]):
  1064. """
  1065. 保存球机聚焦图片到对应批次
  1066. Args:
  1067. track_id: 人员跟踪ID
  1068. ptz_frame: 球机帧
  1069. ptz_position: PTZ位置 (pan, tilt, zoom)
  1070. """
  1071. if (self._paired_saver is None or
  1072. self._current_batch_id is None or
  1073. track_id not in self._person_ptz_index):
  1074. return
  1075. person_index = self._person_ptz_index[track_id]
  1076. self._paired_saver.save_ptz_image(
  1077. batch_id=self._current_batch_id,
  1078. person_index=person_index,
  1079. ptz_frame=ptz_frame,
  1080. ptz_position=ptz_position,
  1081. ptz_bbox=getattr(self, '_last_ptz_bbox', None)
  1082. )
  1083. def _save_ptz_image_for_person_batch(self, batch_id: str, person_index: int,
  1084. ptz_frame: np.ndarray,
  1085. ptz_position: Tuple[float, float, int],
  1086. ptz_frame_marked: np.ndarray = None):
  1087. """
  1088. 保存球机聚焦图片到指定批次(直接使用 batch_id,不依赖当前批次)
  1089. Args:
  1090. batch_id: 批次ID
  1091. person_index: 人员序号
  1092. ptz_frame: 球机原始帧
  1093. ptz_position: PTZ位置 (pan, tilt, zoom)
  1094. ptz_frame_marked: 球机标记帧(可选,不传则在内部标记)
  1095. """
  1096. if self._paired_saver is None:
  1097. return
  1098. self._paired_saver.save_ptz_image(
  1099. batch_id=batch_id,
  1100. person_index=person_index,
  1101. ptz_frame=ptz_frame,
  1102. ptz_position=ptz_position,
  1103. ptz_bbox=getattr(self, '_last_ptz_bbox', None),
  1104. ptz_frame_marked=ptz_frame_marked
  1105. )
  1106. def _ptz_worker(self):
  1107. """PTZ控制线程:从队列接收命令并控制球机"""
  1108. while self.running:
  1109. try:
  1110. # 暂停时等待恢复
  1111. if self._paused:
  1112. self._paused_event.wait()
  1113. continue
  1114. try:
  1115. cmd = self._ptz_queue.get(timeout=0.1)
  1116. except queue.Empty:
  1117. continue
  1118. # 执行PTZ命令(batch_id 和 person_index 已在命令中)
  1119. self._execute_ptz_command(cmd)
  1120. except Exception as e:
  1121. print(f"PTZ控制线程错误: {e}")
  1122. time.sleep(0.05)
  1123. def _select_tracking_target(self) -> Optional[TrackingTarget]:
  1124. """选择当前跟踪目标"""
  1125. with self.targets_lock:
  1126. if not self.tracking_targets:
  1127. self._set_state(TrackingState.IDLE)
  1128. self.current_target = None
  1129. return None
  1130. # 使用目标选择器选择最优目标
  1131. self.current_target = self.target_selector.select_target(
  1132. self.tracking_targets
  1133. )
  1134. return self.current_target
  1135. def _get_all_valid_targets(self) -> List[TrackingTarget]:
  1136. """
  1137. 获取所有有效的检测目标(用于多目标PTZ定位)
  1138. 返回按优先级排序的目标列表
  1139. """
  1140. with self.targets_lock:
  1141. if not self.tracking_targets:
  1142. self._set_state(TrackingState.IDLE)
  1143. self.current_target = None
  1144. return []
  1145. # 按得分排序所有目标
  1146. targets = list(self.tracking_targets.values())
  1147. targets.sort(key=lambda t: t.score, reverse=True)
  1148. if targets:
  1149. self._set_state(TrackingState.TRACKING)
  1150. self.current_target = targets[0] # 第一个作为当前目标
  1151. return targets
  1152. def _send_ptz_command(self, target: TrackingTarget, frame_size: Tuple[int, int]):
  1153. """将跟踪目标转化为PTZ命令放入队列"""
  1154. x_ratio, y_ratio = target.position
  1155. # 检查位置变化是否超过阈值
  1156. if self.last_ptz_position is not None:
  1157. last_x, last_y = self.last_ptz_position
  1158. if abs(x_ratio - last_x) < self.ptz_position_threshold and \
  1159. abs(y_ratio - last_y) < self.ptz_position_threshold:
  1160. return
  1161. # 冷却检查(线程安全)
  1162. current_time = time.time()
  1163. with self._last_ptz_time_lock:
  1164. if current_time - self._last_ptz_time < self.PTZ_COMMAND_COOLDOWN:
  1165. return
  1166. cmd = PTZCommand(
  1167. pan=0, tilt=0, zoom=0,
  1168. x_ratio=x_ratio, y_ratio=y_ratio,
  1169. use_calibration=self.enable_calibration
  1170. )
  1171. try:
  1172. self._ptz_queue.put_nowait(cmd)
  1173. self.last_ptz_position = (x_ratio, y_ratio)
  1174. except queue.Full:
  1175. pass # 丢弃命令,下一个检测周期会重发
  1176. def _send_ptz_command_with_log(self, target: TrackingTarget, frame_size: Tuple[int, int]):
  1177. """发送PTZ命令并打印日志"""
  1178. x_ratio, y_ratio = target.position
  1179. # 冷却检查(线程安全)
  1180. current_time = time.time()
  1181. with self._last_ptz_time_lock:
  1182. if current_time - self._last_ptz_time < self.PTZ_COMMAND_COOLDOWN:
  1183. return
  1184. # 位置变化阈值检查
  1185. if self.last_ptz_position is not None:
  1186. last_x, last_y = self.last_ptz_position
  1187. if abs(x_ratio - last_x) < self.ptz_position_threshold and \
  1188. abs(y_ratio - last_y) < self.ptz_position_threshold:
  1189. return
  1190. # 计算PTZ角度(用于日志)
  1191. if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
  1192. pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
  1193. zoom = self.ptz.ptz_config.get('default_zoom', 8)
  1194. coord_type = "校准坐标"
  1195. else:
  1196. pan, tilt, zoom = self.ptz.calculate_ptz_position(x_ratio, y_ratio)
  1197. coord_type = "估算坐标"
  1198. # 获取当前批次信息和人员序号
  1199. batch_id = self._current_batch_id if self._enable_paired_saving else None
  1200. person_index = self._person_ptz_index.get(target.track_id, -1) if self._enable_paired_saving else -1
  1201. cmd = PTZCommand(
  1202. pan=0, tilt=0, zoom=0,
  1203. x_ratio=x_ratio, y_ratio=y_ratio,
  1204. use_calibration=self.enable_calibration,
  1205. track_id=target.track_id, # 传递跟踪ID
  1206. batch_id=batch_id, # 传递批次ID
  1207. person_index=person_index # 传递人员序号
  1208. )
  1209. try:
  1210. self._ptz_queue.put_nowait(cmd)
  1211. self.last_ptz_position = (x_ratio, y_ratio) # 更新位置记录
  1212. self._update_stats('ptz_commands_sent' if 'ptz_commands_sent' in self.stats else 'persons_detected')
  1213. logger.info(
  1214. f"[PTZ] 命令已发送: 目标ID={target.track_id} "
  1215. f"全景位置=({x_ratio:.3f}, {y_ratio:.3f}) → "
  1216. f"PTZ角度=(pan={pan:.1f}°, tilt={tilt:.1f}°, zoom={zoom}) [{coord_type}]"
  1217. )
  1218. except queue.Full:
  1219. logger.warning("[PTZ] 命令队列满,丢弃本次命令")
  1220. def _execute_ptz_command(self, cmd: PTZCommand):
  1221. """
  1222. 执行PTZ命令(在PTZ线程中)
  1223. Args:
  1224. cmd: PTZ命令(包含 batch_id, person_index, track_id 用于配对保存)
  1225. """
  1226. # 更新最后执行时间(线程安全)
  1227. with self._last_ptz_time_lock:
  1228. self._last_ptz_time = time.time()
  1229. # 从命令中提取配对保存相关信息
  1230. track_id = cmd.track_id
  1231. batch_id = cmd.batch_id
  1232. person_index = cmd.person_index
  1233. if cmd.use_calibration and self.calibrator and self.calibrator.is_calibrated():
  1234. # 校准器返回的是可直接发送给球机的真实 PTZ 角度,不再应用 pan_flip
  1235. pan, tilt = self.calibrator.transform(cmd.x_ratio, cmd.y_ratio)
  1236. zoom = self.ptz.ptz_config.get('default_zoom', 8)
  1237. else:
  1238. pan, tilt, zoom = self.ptz.calculate_ptz_position(cmd.x_ratio, cmd.y_ratio)
  1239. self._set_state(TrackingState.TRACKING)
  1240. logger.info(
  1241. f"[PTZ] 执行: pan={pan:.1f}° tilt={tilt:.1f}° zoom={zoom} "
  1242. f"(全景位置=({cmd.x_ratio:.3f}, {cmd.y_ratio:.3f}), "
  1243. f"batch={batch_id}, person={person_index})"
  1244. )
  1245. success = self.ptz.goto_exact_position(pan, tilt, zoom)
  1246. if success:
  1247. # 等待球机物理移动到位(增加额外等待确保画面清晰)
  1248. time.sleep(self.PTZ_CONFIRM_WAIT)
  1249. # 球机端人体检测与自动对焦
  1250. final_pan, final_tilt, final_zoom = pan, tilt, zoom
  1251. if self.enable_ptz_detection and self.auto_zoom_config.get('enabled', False):
  1252. auto_zoom_result = self._auto_zoom_person(pan, tilt, zoom)
  1253. if auto_zoom_result != zoom:
  1254. final_zoom = auto_zoom_result
  1255. # 自动变焦后再次等待画面稳定
  1256. time.sleep(0.5)
  1257. # 获取清晰的球机画面(尝试多次获取最新帧)
  1258. ptz_frame = self._get_clear_ptz_frame()
  1259. # 保存球机图片到配对批次(使用命令中的 batch_id 和 person_index)
  1260. if self._enable_paired_saving and batch_id is not None and person_index >= 0 and ptz_frame is not None:
  1261. # 使用球机端检测器检测人体并标记(用于标记图)
  1262. ptz_frame_marked = self._mark_ptz_frame_with_detection(ptz_frame, person_index=person_index)
  1263. # 传入原始帧保存为原图,标记帧保存为标记图
  1264. self._save_ptz_image_for_person_batch(batch_id, person_index, ptz_frame, (final_pan, final_tilt, final_zoom), ptz_frame_marked=ptz_frame_marked)
  1265. elif self._enable_paired_saving:
  1266. logger.warning(f"[配对保存] 跳过球机图保存: batch_id={batch_id}, person_index={person_index}, frame={ptz_frame is not None}")
  1267. logger.info(f"[PTZ] 到位确认完成: pan={final_pan:.1f}° tilt={final_tilt:.1f}° zoom={final_zoom}")
  1268. else:
  1269. logger.warning(f"[PTZ] 命令执行失败: pan={pan:.1f}° tilt={tilt:.1f}° zoom={zoom}")
  1270. def _auto_zoom_person(self, initial_pan: float, initial_tilt: float, initial_zoom: int) -> int:
  1271. """
  1272. 自动对焦人体
  1273. 在球机画面中检测人体,自动调整zoom使人体居中且大小合适
  1274. Returns:
  1275. 最终的 zoom 值
  1276. """
  1277. if self.auto_zoom_controller is None:
  1278. return initial_zoom
  1279. logger.info("[AutoZoom] 开始自动对焦...")
  1280. try:
  1281. success, final_zoom = self.auto_zoom_controller.auto_focus_loop(
  1282. get_frame_func=self.ptz.get_frame,
  1283. max_attempts=self.auto_zoom_config.get('max_adjust_attempts', 3)
  1284. )
  1285. if success:
  1286. logger.info(f"[AutoZoom] 自动对焦成功: zoom={final_zoom}")
  1287. return final_zoom
  1288. else:
  1289. logger.warning("[AutoZoom] 自动对焦未能定位人体")
  1290. return initial_zoom
  1291. except Exception as e:
  1292. logger.error(f"[AutoZoom] 自动对焦异常: {e}")
  1293. return initial_zoom
  1294. def _get_clear_ptz_frame(self, max_attempts: int = None, wait_interval: float = None) -> Optional[np.ndarray]:
  1295. """
  1296. 获取清晰的球机画面
  1297. 尝试多次获取,丢弃模糊/过渡帧
  1298. Args:
  1299. max_attempts: 最大尝试次数(默认从配置读取)
  1300. wait_interval: 每次等待间隔(默认从配置读取)
  1301. Returns:
  1302. 清晰的球机帧或 None
  1303. """
  1304. # 使用配置值或默认值
  1305. cfg = self._frame_config
  1306. max_attempts = max_attempts or cfg.get('max_attempts', 8)
  1307. wait_interval = wait_interval or cfg.get('wait_interval', 0.2)
  1308. min_clarity = cfg.get('min_clarity', 200)
  1309. best_frame = None
  1310. best_score = -1
  1311. # 先刷新缓冲区,丢弃旧帧
  1312. logger.debug("[帧获取] 刷新RTSP缓冲区...")
  1313. for _ in range(5):
  1314. self.ptz.get_frame()
  1315. time.sleep(0.05)
  1316. # 尝试获取清晰帧
  1317. for i in range(max_attempts):
  1318. frame = self.ptz.get_frame()
  1319. if frame is not None:
  1320. # 立即复制帧,防止 RTSP 流更新导致帧被覆盖
  1321. frame_copy = frame.copy()
  1322. # 使用拉普拉斯算子评估图像清晰度
  1323. gray = cv2.cvtColor(frame_copy, cv2.COLOR_BGR2GRAY)
  1324. laplacian_var = cv2.Laplacian(gray, cv2.CV_64F).var()
  1325. logger.debug(f"[帧获取] 尝试 {i+1}/{max_attempts}: 清晰度={laplacian_var:.1f}")
  1326. if laplacian_var > best_score:
  1327. best_score = laplacian_var
  1328. best_frame = frame_copy
  1329. # 如果清晰度足够高,直接返回
  1330. if laplacian_var > min_clarity:
  1331. logger.info(f"[帧获取] 获取清晰帧: 尝试 {i+1} 次, 清晰度={laplacian_var:.1f}")
  1332. return frame_copy
  1333. time.sleep(wait_interval)
  1334. if best_frame is not None:
  1335. logger.info(f"[帧获取] 返回最佳帧: 清晰度={best_score:.1f}")
  1336. else:
  1337. logger.warning("[帧获取] 未能获取有效帧")
  1338. return best_frame
  1339. def _mark_ptz_frame_with_detection(self, frame: np.ndarray, person_index: int) -> np.ndarray:
  1340. """
  1341. 在球机帧上标记检测到的人体
  1342. Args:
  1343. frame: 球机帧
  1344. person_index: 人员序号
  1345. Returns:
  1346. 标记后的帧
  1347. """
  1348. marked_frame = frame.copy()
  1349. h, w = marked_frame.shape[:2]
  1350. # 重置保存的bbox
  1351. self._last_ptz_bbox = None
  1352. # 使用球机端检测器检测人体
  1353. if self.ptz_detector is not None:
  1354. try:
  1355. persons = self.ptz_detector.detect(frame)
  1356. if persons:
  1357. # 找到最大的人体(假设是目标)
  1358. largest_person = max(persons, key=lambda p: p.area)
  1359. x1, y1, x2, y2 = largest_person.bbox
  1360. # 保存bbox供后续使用
  1361. self._last_ptz_bbox = (x1, y1, x2, y2)
  1362. # 绘制边界框(红色,区别于全景的绿色)
  1363. cv2.rectangle(marked_frame, (x1, y1), (x2, y2), (0, 0, 255), 2)
  1364. # 绘制标签
  1365. label = f"person_{person_index} ({largest_person.confidence:.2f})"
  1366. (label_w, label_h), _ = cv2.getTextSize(
  1367. label, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2
  1368. )
  1369. # 标签背景(红色)
  1370. cv2.rectangle(
  1371. marked_frame,
  1372. (x1, y1 - label_h - 8),
  1373. (x1 + label_w, y1),
  1374. (0, 0, 255),
  1375. -1
  1376. )
  1377. # 标签文字(白色)
  1378. cv2.putText(
  1379. marked_frame, label,
  1380. (x1, y1 - 4),
  1381. cv2.FONT_HERSHEY_SIMPLEX, 0.7,
  1382. (255, 255, 255), 2
  1383. )
  1384. logger.info(f"[配对保存] 球机图标记: person_{person_index}, "
  1385. f"位置=({x1},{y1},{x2},{y2}), 置信度={largest_person.confidence:.2f}")
  1386. else:
  1387. # 未检测到人体,在画面中心添加提示
  1388. cv2.putText(
  1389. marked_frame, f"person_{person_index} (no detection)",
  1390. (w // 2 - 100, h // 2),
  1391. cv2.FONT_HERSHEY_SIMPLEX, 0.8,
  1392. (0, 0, 255), 2
  1393. )
  1394. except Exception as e:
  1395. logger.error(f"[配对保存] 球机图检测标记失败: {e}")
  1396. return marked_frame
  1397. def _confirm_ptz_position(self, x_ratio: float, y_ratio: float):
  1398. """PTZ位置确认:读取球机帧验证目标是否可见"""
  1399. if not hasattr(self.ptz, 'get_frame') or self.ptz.get_frame() is None:
  1400. return
  1401. ptz_frame = self.ptz.get_frame()
  1402. if ptz_frame is None:
  1403. return
  1404. # 未来可以在这里添加球机帧目标验证逻辑
  1405. # 例如:在球机帧中检测目标是否在画面中心附近
  1406. def on_ptz_confirmed(self, callback: Callable):
  1407. """注册PTZ位置确认回调"""
  1408. self._on_ptz_confirmed = callback
  1409. class SequentialCoordinator(AsyncCoordinator):
  1410. """
  1411. 顺序联动控制器 — 全景检测→逐个PTZ抓拍→回到全景的循环模式
  1412. 工作流程:
  1413. 1. 全景摄像头检测人员
  1414. 2. 检测到人员后,暂停全景检测
  1415. 3. 球机依次对每个检测到的人员进行PTZ定位+变焦抓拍
  1416. 4. 所有人员抓拍完成后,球机回到默认位置
  1417. 5. 恢复全景检测,进入下一轮循环
  1418. 适用于需要高质量抓拍的场景,确保每个目标都能被球机清晰拍摄
  1419. """
  1420. def __init__(self, *args, **kwargs):
  1421. super().__init__(*args, **kwargs)
  1422. # 顺序抓拍状态
  1423. self._capture_state = 'idle' # 'idle', 'detecting', 'capturing', 'returning'
  1424. self._capture_state_lock = threading.Lock()
  1425. # 当前批次检测到的目标
  1426. self._batch_targets: List[TrackingTarget] = []
  1427. self._batch_targets_lock = threading.Lock()
  1428. self._current_capture_index = 0
  1429. # 【关键修复】保存当前批次的完整信息,防止抓拍过程中被覆盖
  1430. self._capture_batch_id: Optional[str] = None
  1431. self._capture_batch_size: int = 0
  1432. # 抓拍完成事件(用于同步)
  1433. self._capture_done_event = threading.Event()
  1434. # 配置参数 - 从 PTZ_CONFIG 读取
  1435. ptz_capture_config = PTZ_CONFIG.get('capture', {})
  1436. self._capture_config = {
  1437. 'ptz_stabilize_time': ptz_capture_config.get('stabilize_time', 3.0), # PTZ到位后稳定等待时间(秒)
  1438. 'capture_wait_time': 0.5, # 抓拍等待时间
  1439. 'auto_zoom_wait_time': 1.5, # AutoZoom变焦后额外等待时间(秒),增加以确保对焦完成
  1440. 'return_to_panorama': True, # 完成后是否回到全景默认位置
  1441. 'default_pan': 0.0, # 默认pan角度
  1442. 'default_tilt': 0.0, # 默认tilt角度
  1443. 'default_zoom': 1, # 默认zoom(广角)
  1444. }
  1445. # 帧获取配置(覆盖父类默认值)
  1446. self._frame_config.update({
  1447. 'wait_interval': ptz_capture_config.get('frame_wait_interval', 0.2),
  1448. 'max_attempts': ptz_capture_config.get('frame_max_attempts', 8),
  1449. 'min_clarity': ptz_capture_config.get('min_clarity', 200),
  1450. })
  1451. # 覆盖父类的PTZ冷却时间(顺序模式下可以更短)
  1452. self.PTZ_COMMAND_COOLDOWN = 0.1
  1453. logger.info("[SequentialCoordinator] 顺序联动控制器初始化完成")
  1454. def _detection_worker(self):
  1455. """检测线程:顺序模式下的检测逻辑"""
  1456. # 从 DETECTION_CONFIG 获取检测帧率,默认每秒2帧
  1457. detection_fps = self.config.get('detection_fps', DETECTION_CONFIG.get('detection_fps', 2))
  1458. detection_interval = 1.0 / detection_fps
  1459. last_detection_time = 0
  1460. frame_count = 0
  1461. last_log_time = time.time()
  1462. log_interval = 5.0
  1463. detection_run_count = 0
  1464. detection_person_count = 0
  1465. detection_last_seen = 0
  1466. last_no_detect_log_time = 0
  1467. no_detect_log_interval = 30.0
  1468. with self.stats_lock:
  1469. self.stats['start_time'] = time.time()
  1470. if self.detector is None:
  1471. logger.warning("[检测线程] ⚠️ 人体检测器未初始化!")
  1472. else:
  1473. logger.info(f"[检测线程] ✓ 顺序模式已就绪, 检测帧率={detection_fps}fps")
  1474. while self.running:
  1475. try:
  1476. current_time = time.time()
  1477. # 获取当前帧
  1478. frame = self.panorama.get_frame()
  1479. if frame is None:
  1480. time.sleep(0.01)
  1481. continue
  1482. frame_count += 1
  1483. self._update_stats('frames_processed')
  1484. frame_size = (frame.shape[1], frame.shape[0])
  1485. # 日志输出
  1486. if current_time - last_log_time >= log_interval:
  1487. elapsed = current_time - last_log_time
  1488. fps = frame_count / elapsed if elapsed > 0 else 0
  1489. state_str = self._get_capture_state()
  1490. if detection_last_seen > 0:
  1491. ago = int(current_time - detection_last_seen)
  1492. person_info = f"最后有人={ago}s前"
  1493. else:
  1494. person_info = "未检出"
  1495. logger.info(f"[检测线程] 帧率={fps:.1f}fps, 状态={state_str}, "
  1496. f"检测轮次={detection_run_count}({person_info})")
  1497. frame_count = 0
  1498. last_log_time = current_time
  1499. # 状态机处理
  1500. state = self._get_capture_state()
  1501. if state == 'idle':
  1502. # 【关键修复】每轮检测开始前清空跟踪目标,防止跨帧累积
  1503. with self.targets_lock:
  1504. if self.tracking_targets:
  1505. logger.debug(f"[顺序模式] 清空上一轮跟踪目标: {len(self.tracking_targets)} 个")
  1506. self.tracking_targets.clear()
  1507. # 空闲状态:周期性检测(暂停时跳过)
  1508. if not self._paused and current_time - last_detection_time >= detection_interval:
  1509. last_detection_time = current_time
  1510. detection_run_count += 1
  1511. # 执行检测
  1512. detections = self._detect_persons(frame)
  1513. # 使用 BYTETracker 进行跟踪(失败时回退到位置匹配)
  1514. detections = self._update_with_bytetrack(detections, frame, frame_size)
  1515. if detections:
  1516. self._update_stats('persons_detected', len(detections))
  1517. detection_last_seen = current_time
  1518. detection_person_count += 1
  1519. # 【调试日志】检查跟踪目标数量
  1520. with self.targets_lock:
  1521. tracking_count = len(self.tracking_targets)
  1522. logger.info(f"[顺序模式] 检测到 {len(detections)} 个目标, 跟踪列表 {tracking_count} 个")
  1523. # 【关键修复】先创建配对批次并获取去重后的人员列表
  1524. dedup_persons = []
  1525. if self._enable_paired_saving and self._paired_saver is not None:
  1526. dedup_persons = self._create_detection_batch(frame, detections, frame_size)
  1527. else:
  1528. # 如果未启用配对保存,也需要去重
  1529. dedup_persons = self._deduplicate_detections(detections, frame_size)
  1530. # 【调试日志】显示去重后目标数量
  1531. logger.info(f"[顺序模式] 去重后有效目标数量: {len(dedup_persons)}")
  1532. if dedup_persons:
  1533. # 将去重后的检测结果转换为抓拍目标
  1534. capture_targets = []
  1535. for i, det in enumerate(dedup_persons):
  1536. x_ratio = det.center[0] / frame_size[0]
  1537. y_ratio = det.center[1] / frame_size[1]
  1538. target = TrackingTarget(
  1539. track_id=det.track_id,
  1540. position=(x_ratio, y_ratio),
  1541. last_update=current_time,
  1542. area=det.bbox[2] * det.bbox[3],
  1543. confidence=det.confidence
  1544. )
  1545. capture_targets.append(target)
  1546. logger.info(f"[顺序模式] 检测到 {len(capture_targets)} 个目标,开始顺序抓拍")
  1547. # 【关键修复】切换到抓拍状态后立即清空 tracking_targets
  1548. # 防止后续检测再次获取到同一批目标
  1549. with self.targets_lock:
  1550. self.tracking_targets.clear()
  1551. logger.info("[顺序模式] 已清空跟踪目标,防止重复抓拍")
  1552. # 切换到抓拍状态(使用去重后的目标)
  1553. self._start_capture_sequence(capture_targets)
  1554. else:
  1555. logger.warning(f"[顺序模式] 去重后无有效目标,跳过抓拍")
  1556. else:
  1557. # 未检测到人员
  1558. if current_time - last_no_detect_log_time >= no_detect_log_interval:
  1559. logger.info(f"[检测] 本轮未检测到人员 (累计{detection_run_count}轮)")
  1560. last_no_detect_log_time = current_time
  1561. elif state == 'capturing':
  1562. # 抓拍状态中:检测线程等待,不执行新检测
  1563. # 等待PTZ线程完成当前批次
  1564. pass
  1565. elif state == 'returning':
  1566. # 球机回到默认位置中
  1567. pass
  1568. # 清理过期目标
  1569. self._cleanup_expired_targets()
  1570. time.sleep(0.01)
  1571. except Exception as e:
  1572. logger.error(f"[检测线程] 错误: {e}")
  1573. time.sleep(0.1)
  1574. def _ptz_worker(self):
  1575. """PTZ控制线程:顺序模式下的PTZ控制逻辑"""
  1576. logger.info("[PTZ线程] 顺序模式PTZ控制线程启动")
  1577. while self.running:
  1578. try:
  1579. # 暂停时等待恢复
  1580. if self._paused:
  1581. self._paused_event.wait()
  1582. time.sleep(0.05)
  1583. continue
  1584. state = self._get_capture_state()
  1585. if state == 'capturing':
  1586. # 执行顺序抓拍(阻塞直到当前目标抓拍完成)
  1587. self._execute_sequential_capture()
  1588. elif state == 'returning':
  1589. # 回到默认位置
  1590. self._return_to_default_position()
  1591. # idle 状态不做任何操作,让检测线程控制
  1592. time.sleep(0.05)
  1593. except Exception as e:
  1594. logger.error(f"[PTZ线程] 错误: {e}")
  1595. time.sleep(0.1)
  1596. def _get_capture_state(self) -> str:
  1597. """获取当前抓拍状态"""
  1598. with self._capture_state_lock:
  1599. return self._capture_state
  1600. def _set_capture_state(self, state: str):
  1601. """设置抓拍状态"""
  1602. with self._capture_state_lock:
  1603. old_state = self._capture_state
  1604. self._capture_state = state
  1605. logger.info(f"[顺序模式] 状态切换: {old_state} -> {state}")
  1606. def _start_capture_sequence(self, targets: List[TrackingTarget]):
  1607. """开始顺序抓拍序列"""
  1608. with self._batch_targets_lock:
  1609. # 【关键修复】先清空再赋值,防止累积
  1610. old_count = len(self._batch_targets)
  1611. self._batch_targets = targets.copy() # 直接替换,不追加
  1612. self._current_capture_index = 0
  1613. # 【关键修复】保存批次信息,防止抓拍过程中被新批次覆盖
  1614. self._capture_batch_id = self._current_batch_id
  1615. self._capture_batch_size = len(targets)
  1616. logger.info(f"[顺序模式] 开始抓拍序列: {old_count} -> {len(self._batch_targets)} 个目标, "
  1617. f"批次ID={self._capture_batch_id}")
  1618. self._set_capture_state('capturing')
  1619. def _execute_sequential_capture(self):
  1620. """执行顺序抓拍(依次对每个目标进行PTZ定位和抓拍)"""
  1621. with self._batch_targets_lock:
  1622. targets = self._batch_targets.copy()
  1623. current_idx = self._current_capture_index
  1624. batch_size = self._capture_batch_size
  1625. batch_id = self._capture_batch_id
  1626. # 【调试日志】详细输出抓拍状态
  1627. logger.info(f"[顺序模式] 执行抓拍: idx={current_idx}, batch_size={batch_size}, "
  1628. f"targets_len={len(targets)}, batch_id={batch_id}")
  1629. # 使用保存的批次大小进行检查,而不是 len(targets)
  1630. if current_idx >= batch_size:
  1631. # 所有目标已抓拍完成
  1632. logger.info(f"[顺序模式] 所有目标抓拍完成: {current_idx}/{batch_size}")
  1633. self._set_capture_state('returning')
  1634. return
  1635. # 【安全检查】确保 targets 有足够的数据
  1636. if current_idx >= len(targets):
  1637. logger.warning(f"[顺序模式] 索引越界: idx={current_idx} >= targets_len={len(targets)}, "
  1638. f"batch_size={batch_size}。可能批次信息不一致!")
  1639. self._set_capture_state('returning')
  1640. return
  1641. # 获取当前目标
  1642. target = targets[current_idx]
  1643. x_ratio, y_ratio = target.position
  1644. # 计算PTZ角度
  1645. if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
  1646. # 校准器返回的是可直接发送给球机的真实 PTZ 角度,不再应用 pan_flip
  1647. pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
  1648. zoom = self.ptz.ptz_config.get('default_zoom', 8)
  1649. coord_type = "校准坐标"
  1650. else:
  1651. pan, tilt, zoom = self.ptz.calculate_ptz_position(x_ratio, y_ratio)
  1652. coord_type = "估算坐标"
  1653. # 获取批次信息(使用保存的批次ID,防止被新批次覆盖)
  1654. with self._batch_targets_lock:
  1655. batch_id = self._capture_batch_id if self._enable_paired_saving else None
  1656. batch_size = self._capture_batch_size
  1657. person_index = current_idx # 使用当前索引作为人员序号
  1658. logger.info(f"[顺序模式] 抓拍目标 {current_idx + 1}/{batch_size}: "
  1659. f"位置=({x_ratio:.3f}, {y_ratio:.3f}) -> "
  1660. f"PTZ=({pan:.1f}°, {tilt:.1f}°, zoom={zoom}) [{coord_type}], 批次={batch_id}")
  1661. # 【关键修复】先递增索引,防止 PTZ 线程重复进入时重复执行
  1662. with self._batch_targets_lock:
  1663. self._current_capture_index += 1
  1664. # 执行PTZ移动
  1665. self._set_state(TrackingState.TRACKING)
  1666. # 【调试】记录移动前的 PTZ 位置
  1667. try:
  1668. current_pos = self.ptz.get_current_position()
  1669. logger.info(f"[顺序模式] PTZ移动前: pan={current_pos.pan:.1f}° tilt={current_pos.tilt:.1f}° zoom={current_pos.zoom}")
  1670. except Exception as e:
  1671. logger.warning(f"[顺序模式] 获取当前PTZ位置失败: {e}")
  1672. current_pos = None
  1673. success = self.ptz.goto_exact_position(pan, tilt, zoom)
  1674. if success:
  1675. # 等待球机物理移动到位
  1676. stabilize_time = self._capture_config['ptz_stabilize_time']
  1677. logger.info(f"[顺序模式] 等待球机稳定 {stabilize_time}s...")
  1678. # 【调试】记录移动后的 PTZ 位置
  1679. time.sleep(0.5) # 短暂等待后检查
  1680. try:
  1681. after_pos = self.ptz.get_current_position()
  1682. logger.info(f"[顺序模式] PTZ移动后: pan={after_pos.pan:.1f}° tilt={after_pos.tilt:.1f}° zoom={after_pos.zoom}")
  1683. except Exception as e:
  1684. logger.warning(f"[顺序模式] 获取PTZ位置失败: {e}")
  1685. time.sleep(stabilize_time)
  1686. # 【关键修复】清空RTSP缓冲区,确保获取的是新位置的帧
  1687. logger.debug("[顺序模式] 清空RTSP缓冲区...")
  1688. for _ in range(5):
  1689. self.ptz.get_frame()
  1690. time.sleep(0.05)
  1691. # 自动变焦(如果启用)
  1692. final_zoom = zoom
  1693. if self.enable_ptz_detection and self.auto_zoom_config.get('enabled', False):
  1694. auto_zoom_result = self._auto_zoom_person(pan, tilt, zoom)
  1695. if auto_zoom_result != zoom:
  1696. final_zoom = auto_zoom_result
  1697. # 变焦后等待镜头对焦
  1698. auto_zoom_wait = self._capture_config.get('auto_zoom_wait_time', 1.0)
  1699. logger.info(f"[顺序模式] 变焦完成,等待镜头对焦 {auto_zoom_wait}s...")
  1700. time.sleep(auto_zoom_wait)
  1701. # 变焦后再次清空缓冲区
  1702. for _ in range(3):
  1703. self.ptz.get_frame()
  1704. time.sleep(0.05)
  1705. # 获取清晰的球机画面
  1706. ptz_frame = self._get_clear_ptz_frame()
  1707. if ptz_frame is not None:
  1708. # 【调试】记录抓拍的图像信息
  1709. frame_h, frame_w = ptz_frame.shape[:2]
  1710. logger.info(f"[顺序模式] 抓拍帧: {frame_w}x{frame_h}, 目标序号={person_index}, PTZ=({pan:.1f}°, {tilt:.1f}°, zoom={final_zoom})")
  1711. # 保存球机图片
  1712. if self._enable_paired_saving and batch_id is not None:
  1713. # 使用球机端检测器检测人体并标记(用于标记图)
  1714. ptz_frame_marked = self._mark_ptz_frame_with_detection(ptz_frame, person_index=person_index)
  1715. # 传入原始帧保存为原图,标记帧保存为标记图
  1716. self._save_ptz_image_for_person_batch(
  1717. batch_id, person_index, ptz_frame,
  1718. (pan, tilt, final_zoom), ptz_frame_marked=ptz_frame_marked
  1719. )
  1720. logger.info(f"[顺序模式] 目标 {current_idx + 1} 抓拍完成")
  1721. else:
  1722. logger.warning(f"[顺序模式] 获取球机画面失败")
  1723. else:
  1724. logger.warning(f"[顺序模式] PTZ移动失败")
  1725. # 抓拍间隔
  1726. time.sleep(self._capture_config['capture_wait_time'])
  1727. def _return_to_default_position(self):
  1728. """球机回到默认位置(广角全景)"""
  1729. if not self._capture_config['return_to_panorama']:
  1730. # 不回到默认位置,直接回到空闲状态
  1731. self._set_capture_state('idle')
  1732. return
  1733. default_pan = self._capture_config['default_pan']
  1734. default_tilt = self._capture_config['default_tilt']
  1735. default_zoom = self._capture_config['default_zoom']
  1736. # 【关键修复】等待一小段时间,确保最后的帧已经被读取和保存
  1737. # 因为 _get_clear_ptz_frame 是异步获取帧,可能在抓拍后立刻触发返回默认位置
  1738. time.sleep(0.5)
  1739. logger.info(f"[顺序模式] 球机回到默认位置: ({default_pan}°, {default_tilt}°, zoom={default_zoom})")
  1740. success = self.ptz.goto_exact_position(default_pan, default_tilt, default_zoom)
  1741. if success:
  1742. # 等待到位
  1743. time.sleep(self._capture_config['ptz_stabilize_time'])
  1744. logger.info("[顺序模式] 球机已回到默认位置")
  1745. else:
  1746. logger.warning("[顺序模式] 球机回到默认位置失败")
  1747. # 回到空闲状态,恢复全景检测
  1748. self._set_capture_state('idle')
  1749. # 清空批次目标
  1750. with self._batch_targets_lock:
  1751. self._batch_targets = []
  1752. self._current_capture_index = 0
  1753. # 【关键修复】清空保存的批次信息
  1754. self._capture_batch_id = None
  1755. self._capture_batch_size = 0
  1756. # 【关键修复】清空跟踪目标,防止跨帧累积
  1757. with self.targets_lock:
  1758. self.tracking_targets.clear()
  1759. logger.info("[顺序模式] 已清空跟踪目标列表")
  1760. def set_capture_config(self, **kwargs):
  1761. """设置抓拍配置"""
  1762. self._capture_config.update(kwargs)
  1763. logger.info(f"[顺序模式] 配置已更新: {kwargs}")
  1764. def get_capture_status(self) -> dict:
  1765. """获取当前抓拍状态"""
  1766. with self._batch_targets_lock:
  1767. total = len(self._batch_targets)
  1768. current = self._current_capture_index
  1769. return {
  1770. 'state': self._get_capture_state(),
  1771. 'total_targets': total,
  1772. 'current_index': current,
  1773. 'remaining': max(0, total - current)
  1774. }