main.py 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033
  1. """
  2. 双摄像头联动抓拍系统 - 主程序
  3. 系统功能:
  4. 1. 全景摄像头实时监控和物体检测
  5. 2. 检测到人体后,球机自动变焦定位
  6. """
  7. # 必须在import cv2之前设置,否则FFmpeg多线程解码会导致
  8. # "Assertion fctx->async_lock failed at pthread_frame.c:167" 崩溃
  9. import os
  10. os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'rtsp_transport;tcp|threads;1'
  11. import sys
  12. import time
  13. import glob
  14. import argparse
  15. import logging
  16. import threading
  17. import signal
  18. from typing import Optional
  19. import cv2
  20. import numpy as np
  21. # 添加项目路径
  22. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
  23. from config import (
  24. PANORAMA_CAMERA, PTZ_CAMERA, SDK_PATH,
  25. DETECTION_CONFIG, PTZ_CONFIG, COORDINATOR_CONFIG,
  26. CALIBRATION_CONFIG, LOG_CONFIG, SYSTEM_CONFIG,
  27. CAMERA_GROUPS, get_enabled_groups
  28. )
  29. from dahua_sdk import DahuaSDK
  30. from panorama_camera import PanoramaCamera, ObjectDetector, DetectedObject
  31. from ptz_camera import PTZCamera, PTZController
  32. from coordinator import Coordinator, EventDrivenCoordinator, AsyncCoordinator, SequentialCoordinator
  33. # 配置日志 - 使用LOG_CONFIG
  34. def _cleanup_old_logs(log_file: str, retention_days: int):
  35. """清理超过保留天数的日志文件"""
  36. import glob
  37. if not log_file:
  38. return
  39. log_dir = os.path.dirname(log_file) or '.'
  40. log_basename = os.path.basename(log_file)
  41. # 匹配所有轮转的日志文件:app.log, app.log.1, app.log.2, ...
  42. patterns = [
  43. log_basename,
  44. f"{log_basename}.*",
  45. f"{os.path.splitext(log_basename)[0]}.*", # 处理没有扩展名的情况
  46. ]
  47. now = time.time()
  48. cutoff = now - (retention_days * 86400)
  49. for pattern in patterns:
  50. full_pattern = os.path.join(log_dir, pattern)
  51. for log_path in glob.glob(full_pattern):
  52. try:
  53. if os.path.isfile(log_path):
  54. mtime = os.path.getmtime(log_path)
  55. if mtime < cutoff:
  56. os.remove(log_path)
  57. print(f"[日志清理] 已删除过期日志: {log_path}")
  58. except Exception as e:
  59. pass # 忽略删除失败的日志文件
  60. def _log_cleanup_worker(retention_days: int, interval_hours: int = 6):
  61. """日志清理后台线程"""
  62. log_file = LOG_CONFIG.get('file')
  63. if not log_file:
  64. return
  65. while True:
  66. _cleanup_old_logs(log_file, retention_days)
  67. time.sleep(interval_hours * 3600)
  68. def setup_logging():
  69. """设置日志配置"""
  70. log_level = getattr(logging, LOG_CONFIG.get('level', 'INFO'), logging.INFO)
  71. log_format = LOG_CONFIG.get('format', '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
  72. log_file = LOG_CONFIG.get('file')
  73. retention_days = LOG_CONFIG.get('retention_days', 7)
  74. handlers = [logging.StreamHandler()]
  75. if log_file:
  76. # 确保日志目录存在
  77. log_dir = os.path.dirname(log_file)
  78. if log_dir:
  79. os.makedirs(log_dir, exist_ok=True)
  80. from logging.handlers import RotatingFileHandler
  81. file_handler = RotatingFileHandler(
  82. log_file,
  83. maxBytes=LOG_CONFIG.get('max_bytes', 10*1024*1024),
  84. backupCount=LOG_CONFIG.get('backup_count', 5)
  85. )
  86. file_handler.setFormatter(logging.Formatter(log_format))
  87. handlers.append(file_handler)
  88. # 启动日志清理后台线程
  89. cleanup_thread = threading.Thread(
  90. target=_log_cleanup_worker,
  91. args=(retention_days, 6),
  92. daemon=True
  93. )
  94. cleanup_thread.start()
  95. logging.basicConfig(
  96. level=log_level,
  97. format=log_format,
  98. handlers=handlers
  99. )
  100. setup_logging()
  101. logger = logging.getLogger(__name__)
  102. # 全局停止标志(用于信号处理)
  103. _shutdown_requested = False
  104. def _signal_handler(signum, frame):
  105. """信号处理函数"""
  106. global _shutdown_requested
  107. sig_name = signal.Signals(signum).name
  108. logger.info(f"接收到信号 {sig_name},准备优雅退出...")
  109. print(f"\n[信号] 接收到 {sig_name},准备停止...")
  110. _shutdown_requested = True
  111. class DualCameraSystem:
  112. """
  113. 双摄像头联动抓拍系统
  114. """
  115. def __init__(self, config_override: dict = None):
  116. """
  117. 初始化系统
  118. Args:
  119. config_override: 配置覆盖
  120. """
  121. self.config = config_override or {}
  122. # SDK
  123. self.sdk = None
  124. # 摄像头
  125. self.panorama_camera = None
  126. self.ptz_camera = None
  127. # 检测器
  128. # 联动控制器
  129. self.coordinator = None
  130. # 校准器
  131. self.calibrator = None
  132. self.calibration_manager = None
  133. # 定时校准
  134. self.calibration_interval = CALIBRATION_CONFIG.get('interval', 24 * 60 * 60) # 默认24小时
  135. self.daily_calibration_time = CALIBRATION_CONFIG.get('daily_calibration_time', '08:00') # 每日校准时间
  136. self.force_daily_recalibration = CALIBRATION_CONFIG.get('force_daily_recalibration', True) # 强制重新校准
  137. self.calibration_thread = None
  138. self.calibration_running = False
  139. self.last_calibration_time = 0
  140. # 运行标志
  141. self.running = False
  142. # 检测暂停状态(校准时暂停检测)
  143. self._detection_was_running = False
  144. # Captures 目录清理配置
  145. self.captures_cleanup_enabled = False # 已禁用 captures,不再需要定时清理
  146. self.captures_dir = '/home/admin/dsh/captures'
  147. self.captures_cleanup_hour = 3 # 每天凌晨3点清理
  148. def initialize(self, skip_calibration: bool = False) -> bool:
  149. """
  150. 初始化系统组件
  151. Args:
  152. skip_calibration: 是否跳过校准
  153. Returns:
  154. 是否成功
  155. """
  156. logger.info("初始化双摄像头联动系统...")
  157. # 先初始化检测器(YOLO/PyTorch),再加载大华SDK
  158. # 大华SDK与PyTorch共享进程空间时可能导致内存冲突,
  159. # 先加载PyTorch可避免SDK的内存映射覆盖PyTorch运行时
  160. # 初始化检测器 (YOLO11/RKNN/ONNX)
  161. try:
  162. from config import DETECTION_CONFIG
  163. self.detector = ObjectDetector(
  164. model_path=self.config.get('model_path', DETECTION_CONFIG.get('model_path')),
  165. use_gpu=self.config.get('use_gpu', DETECTION_CONFIG.get('use_gpu', True)),
  166. model_size=self.config.get('model_size', 'n'),
  167. model_type=self.config.get('model_type', DETECTION_CONFIG.get('model_type', 'auto'))
  168. )
  169. logger.info("检测器初始化成功")
  170. except Exception as e:
  171. logger.warning(f"检测器初始化失败: {e}")
  172. # 初始化SDK(在检测器之后,避免SDK内存映射与PyTorch冲突)
  173. sdk_path = os.path.join(
  174. self.config.get('sdk_path', SDK_PATH['lib_path']),
  175. self.config.get('netsdk', SDK_PATH['netsdk'])
  176. )
  177. try:
  178. self.sdk = DahuaSDK(sdk_path)
  179. if not self.sdk.init():
  180. logger.error("SDK初始化失败")
  181. return False
  182. logger.info("SDK初始化成功")
  183. except Exception as e:
  184. logger.error(f"SDK加载失败: {e}")
  185. return False
  186. # 初始化摄像头
  187. panorama_config = self.config.get('panorama_camera', PANORAMA_CAMERA)
  188. self.panorama_camera = PanoramaCamera(self.sdk, panorama_config)
  189. ptz_config = self.config.get('ptz_camera', PTZ_CAMERA)
  190. self.ptz_camera = PTZCamera(self.sdk, ptz_config)
  191. # 初始化联动控制器
  192. # 根据配置选择顺序模式或异步模式
  193. sequential_mode = COORDINATOR_CONFIG.get('sequential_mode', {}).get('enabled', False)
  194. if sequential_mode:
  195. logger.info("使用顺序联动控制器 (SequentialCoordinator)")
  196. self.coordinator = SequentialCoordinator(
  197. self.panorama_camera,
  198. self.ptz_camera,
  199. self.detector
  200. )
  201. # 应用顺序模式配置
  202. seq_config = COORDINATOR_CONFIG.get('sequential_mode', {})
  203. self.coordinator.set_capture_config(
  204. ptz_stabilize_time=seq_config.get('ptz_stabilize_time', 1.0),
  205. capture_wait_time=seq_config.get('capture_wait_time', 0.5),
  206. return_to_panorama=seq_config.get('return_to_panorama', True),
  207. default_pan=seq_config.get('default_pan', 0.0),
  208. default_tilt=seq_config.get('default_tilt', 0.0),
  209. default_zoom=seq_config.get('default_zoom', 1)
  210. )
  211. else:
  212. logger.info("使用异步联动控制器 (AsyncCoordinator)")
  213. self.coordinator = AsyncCoordinator(
  214. self.panorama_camera,
  215. self.ptz_camera,
  216. self.detector
  217. )
  218. # 设置回调
  219. self._setup_callbacks()
  220. logger.info("系统初始化完成")
  221. # 执行自动校准(受配置开关和参数双重控制)
  222. should_calibrate = SYSTEM_CONFIG.get('enable_calibration', True)
  223. if not should_calibrate:
  224. logger.info("自动校准已禁用 (enable_calibration=False)")
  225. self._load_existing_calibration()
  226. elif skip_calibration:
  227. logger.info("自动校准已跳过 (--skip-calibration)")
  228. self._load_existing_calibration()
  229. else:
  230. if not self._auto_calibrate():
  231. logger.error("自动校准失败!")
  232. return False
  233. return True
  234. def _load_existing_calibration(self):
  235. """加载已有的校准数据并传递给联动控制器(跳过校准时使用)"""
  236. try:
  237. from calibration import CameraCalibrator, CalibrationManager
  238. from config import CALIBRATION_CONFIG
  239. calibrator = CameraCalibrator(
  240. ptz_camera=self.ptz_camera,
  241. get_frame_func=self.panorama_camera.get_frame,
  242. ptz_capture_func=self._capture_ptz_frame
  243. )
  244. calibration_file = CALIBRATION_CONFIG.get('calibration_file', '/home/admin/dsh/calibration.json')
  245. if calibrator.load_calibration(calibration_file):
  246. logger.info(f"已加载已有校准数据: {calibration_file}")
  247. if self.coordinator and calibrator.is_calibrated():
  248. self.coordinator.set_calibrator(calibrator)
  249. logger.info("校准器已传递给联动控制器")
  250. self.calibrator = calibrator
  251. else:
  252. logger.warning("无已有校准数据,跟踪将使用默认线性映射")
  253. except Exception as e:
  254. logger.warning(f"加载校准数据失败: {e},跟踪将使用默认线性映射")
  255. def _pause_detection(self):
  256. """暂停检测线程(校准时使用,避免检测与校准争抢PTZ控制权)"""
  257. if self.coordinator is None:
  258. return
  259. if hasattr(self.coordinator, 'pause_detection') and self.coordinator.running:
  260. self._detection_was_running = True
  261. self.coordinator.pause_detection()
  262. logger.info("已暂停检测,校准期间不执行检测")
  263. else:
  264. self._detection_was_running = False
  265. def _resume_detection(self):
  266. """恢复检测线程(校准完成后恢复)"""
  267. if self.coordinator is None:
  268. return
  269. if self._detection_was_running and hasattr(self.coordinator, 'resume_detection'):
  270. self.coordinator.resume_detection()
  271. logger.info("已恢复检测")
  272. def _auto_calibrate(self, force: bool = False) -> bool:
  273. """
  274. 执行自动校准
  275. 校准期间会暂停检测线程,避免检测与校准争抢PTZ控制权。
  276. 校准完成(或失败)后恢复检测。
  277. Args:
  278. force: 是否强制重新校准(不使用已有数据)
  279. Returns:
  280. 是否成功
  281. """
  282. # 校准前暂停检测,避免检测线程与校准争抢PTZ
  283. self._pause_detection()
  284. try:
  285. return self._do_auto_calibrate(force)
  286. finally:
  287. # 校准完成(无论成功失败)后恢复检测
  288. self._resume_detection()
  289. def _do_auto_calibrate(self, force: bool = False) -> bool:
  290. """自动校准的实际执行逻辑"""
  291. from calibration import CameraCalibrator, CalibrationManager
  292. logger.info("=" * 50)
  293. logger.info("开始自动校准...")
  294. logger.info("=" * 50)
  295. # 连接摄像头
  296. if not self.panorama_camera.connect():
  297. logger.error("连接全景摄像头失败,无法进行校准")
  298. return False
  299. if not self.ptz_camera.connect():
  300. logger.error("连接球机失败,无法进行校准")
  301. self.panorama_camera.disconnect()
  302. return False
  303. # 启动视频流获取帧数据
  304. if not self.panorama_camera.start_stream_rtsp():
  305. logger.warning("RTSP视频流启动失败,尝试SDK方式...")
  306. if not self.panorama_camera.start_stream():
  307. logger.error("无法启动视频流,校准可能无法获取画面")
  308. # 启动球机视频流(校准需要球机画面做特征匹配)
  309. if not self.ptz_camera.start_stream_rtsp():
  310. logger.warning("球机RTSP视频流启动失败,校准将无法进行特征匹配")
  311. # 等待视频流稳定,确保帧数据可用
  312. logger.info("等待视频流稳定...")
  313. max_wait = 15
  314. for i in range(max_wait):
  315. pan_frame = self.panorama_camera.get_frame()
  316. ptz_frame = self.ptz_camera.get_frame() if self.ptz_camera else None
  317. if pan_frame is not None:
  318. logger.info(f"全景帧就绪 ({pan_frame.shape}), 等待中...")
  319. break
  320. time.sleep(1)
  321. if (i + 1) % 3 == 0:
  322. logger.info(f"等待全景帧... ({i + 1}/{max_wait}秒)")
  323. # 再等几秒让流完全稳定
  324. ptz_ready = False
  325. for i in range(10):
  326. ptz_frame = self.ptz_camera.get_frame() if self.ptz_camera else None
  327. if ptz_frame is not None:
  328. ptz_ready = True
  329. logger.info(f"球机帧就绪 ({ptz_frame.shape})")
  330. break
  331. time.sleep(1)
  332. if not ptz_ready:
  333. logger.warning("球机帧未就绪,校准可能仅依赖运动检测")
  334. final_frame = self.panorama_camera.get_frame()
  335. if final_frame is None:
  336. logger.error("5秒后仍无法获取全景帧,校准可能失败")
  337. # 创建校准器 - 支持视野重叠发现
  338. self.calibrator = CameraCalibrator(
  339. ptz_camera=self.ptz_camera,
  340. get_frame_func=self.panorama_camera.get_frame,
  341. detect_marker_func=None,
  342. ptz_capture_func=self._capture_ptz_frame
  343. )
  344. # 配置重叠发现参数
  345. overlap_cfg = CALIBRATION_CONFIG.get('overlap_discovery', {})
  346. self.calibrator.overlap_pan_range = overlap_cfg.get('pan_range', (0, 360))
  347. self.calibrator.overlap_tilt_range = overlap_cfg.get('tilt_range', (-20, 50))
  348. self.calibrator.overlap_pan_step = overlap_cfg.get('pan_step', 20)
  349. self.calibrator.overlap_tilt_step = overlap_cfg.get('tilt_step', 15)
  350. self.calibrator.stabilize_time = overlap_cfg.get('stabilize_time', 2.0)
  351. self.calibrator.max_overlap_ranges = overlap_cfg.get('max_overlap_ranges', 3)
  352. self.calibrator.min_positions_per_range = overlap_cfg.get('min_positions_per_range', 3)
  353. # 校准进度回调
  354. def on_progress(current: int, total: int, message: str):
  355. logger.info(f"校准进度: {current}/{total} - {message}")
  356. # 校准完成回调
  357. def on_complete(result):
  358. if result.success:
  359. logger.info(f"校准完成! RMS误差: {result.rms_error:.4f}")
  360. else:
  361. logger.error(f"校准失败: {result.error_message}")
  362. self.calibrator.on_progress = on_progress
  363. self.calibrator.on_complete = on_complete
  364. # 创建校准管理器
  365. self.calibration_manager = CalibrationManager(self.calibrator)
  366. # 执行视觉校准(根据参数决定是否强制重新校准)
  367. result = self.calibration_manager.auto_calibrate(
  368. force=force,
  369. fallback_on_failure=True # 校准失败时回退使用已有数据
  370. )
  371. if not result.success:
  372. logger.error("=" * 50)
  373. logger.error("校准失败!")
  374. logger.error(f"原因: {result.error_message}")
  375. logger.error("=" * 50)
  376. logger.error("")
  377. logger.error("请检查以下问题:")
  378. logger.error("1. 全景摄像头和球机是否正确连接")
  379. logger.error("2. 球机PTZ控制是否正常")
  380. logger.error("3. 两台摄像头的视野是否有重叠区域")
  381. logger.error("4. 场景是否有足够的纹理/特征用于匹配")
  382. logger.error("5. 球机RTSP视频流是否正常(特征匹配需要球机画面)")
  383. logger.error("")
  384. logger.error("您可以尝试:")
  385. logger.error("- 检查摄像头连接和网络")
  386. logger.error("- 手动移动球机确认PTZ控制正常")
  387. logger.error("- 确保场景有足够的纹理/特征")
  388. logger.error("- 使用 --skip-calibration 跳过校准")
  389. logger.error("=" * 50)
  390. # 释放资源
  391. self.panorama_camera.disconnect()
  392. self.ptz_camera.stop_stream()
  393. self.ptz_camera.disconnect()
  394. return False
  395. logger.info("=" * 50)
  396. logger.info("校准成功!")
  397. logger.info(f"有效校准点: {len(result.points)}")
  398. logger.info(f"RMS误差: {result.rms_error:.4f} 度")
  399. logger.info("=" * 50)
  400. # 将校准器传递给联动控制器,使其使用校准查找表而非线性映射
  401. if self.coordinator and self.calibrator and self.calibrator.is_calibrated():
  402. self.coordinator.set_calibrator(self.calibrator)
  403. logger.info("校准器已传递给联动控制器,跟踪将使用校准查找表")
  404. return True
  405. def _capture_ptz_frame(self) -> Optional[np.ndarray]:
  406. """
  407. 从球机抓拍一帧图像
  408. 用于校准时特征匹配
  409. """
  410. if self.ptz_camera is None:
  411. return None
  412. try:
  413. return self.ptz_camera.get_frame()
  414. except Exception as e:
  415. logger.error(f"球机抓拍失败: {e}")
  416. return None
  417. def _setup_callbacks(self):
  418. """设置回调函数"""
  419. def on_person_detected(person: DetectedObject, frame: np.ndarray):
  420. """人体检测回调"""
  421. logger.info(f"检测到人体: 位置={person.center}, 置信度={person.confidence:.2f}")
  422. self.coordinator.on_person_detected = on_person_detected
  423. def start(self) -> bool:
  424. """
  425. 启动系统
  426. Returns:
  427. 是否成功
  428. """
  429. if self.running:
  430. logger.warning("系统已在运行")
  431. return True
  432. logger.info("启动联动系统...")
  433. if not self.coordinator.start():
  434. logger.error("联动系统启动失败")
  435. return False
  436. self.running = True
  437. logger.info("联动系统启动成功")
  438. # 启动定时校准
  439. self._start_periodic_calibration()
  440. # 启动 captures 目录定时清理
  441. self._start_captures_cleanup()
  442. return True
  443. def stop(self):
  444. """停止系统"""
  445. if not self.running:
  446. return
  447. logger.info("停止联动系统...")
  448. # 停止定时校准
  449. self._stop_periodic_calibration()
  450. self.coordinator.stop()
  451. self.running = False
  452. logger.info("联动系统已停止")
  453. def get_results(self):
  454. """获取识别结果"""
  455. if self.coordinator:
  456. return self.coordinator.get_results()
  457. return []
  458. def _start_periodic_calibration(self):
  459. """启动定时校准"""
  460. if not SYSTEM_CONFIG.get('enable_calibration', True):
  461. logger.info("定时校准已禁用 (enable_calibration=False)")
  462. return
  463. if self.calibration_running:
  464. return
  465. self.calibration_running = True
  466. self.calibration_thread = threading.Thread(
  467. target=self._periodic_calibration_worker,
  468. daemon=True
  469. )
  470. self.calibration_thread.start()
  471. logger.info(f"定时校准已启动 (每日 {self.daily_calibration_time} 自动校准)")
  472. def _stop_periodic_calibration(self):
  473. """停止定时校准"""
  474. self.calibration_running = False
  475. if self.calibration_thread:
  476. self.calibration_thread.join(timeout=2)
  477. self.calibration_thread = None
  478. logger.info("定时校准已停止")
  479. def _start_captures_cleanup(self):
  480. """启动 captures 目录定时清理"""
  481. if not self.captures_cleanup_enabled:
  482. return
  483. def cleanup_worker():
  484. while self.running:
  485. try:
  486. from datetime import datetime
  487. now = datetime.now()
  488. # 计算到凌晨清理时间的秒数
  489. target_hour = self.captures_cleanup_hour
  490. target_time = now.replace(hour=target_hour, minute=0, second=0, microsecond=0)
  491. if now.hour >= target_hour:
  492. target_time = target_time.replace(day=now.day + 1)
  493. wait_seconds = int((target_time - now).total_seconds())
  494. logger.info(f"[Captures清理] 下次清理时间: {target_time.strftime('%Y-%m-%d %H:%M')} (等待 {wait_seconds // 3600} 小时)")
  495. # 每小时检查一次是否到达清理时间
  496. for _ in range(3600):
  497. if not self.running:
  498. return
  499. time.sleep(1)
  500. # 检查是否到达清理时间(每小时的0分)
  501. current = datetime.now()
  502. if current.hour == target_hour and current.minute == 0:
  503. self._cleanup_captures()
  504. break
  505. except Exception as e:
  506. logger.error(f"[Captures清理] 清理任务异常: {e}")
  507. time.sleep(60)
  508. cleanup_thread = threading.Thread(target=cleanup_worker, daemon=True)
  509. cleanup_thread.start()
  510. logger.info(f"[Captures清理] 定时清理已启��� (每日 {self.captures_cleanup_hour}:00)")
  511. def _cleanup_captures(self):
  512. """清理 captures 目录"""
  513. try:
  514. import os
  515. if not os.path.exists(self.captures_dir):
  516. return
  517. files = [f for f in os.listdir(self.captures_dir) if f.startswith('capture_')]
  518. if not files:
  519. logger.info("[Captures清理] 目录为空,无需清理")
  520. return
  521. deleted = 0
  522. for filename in files:
  523. filepath = os.path.join(self.captures_dir, filename)
  524. try:
  525. os.remove(filepath)
  526. deleted += 1
  527. except Exception as e:
  528. logger.warning(f"[Captures清理] 删除失败: {filepath}, {e}")
  529. logger.info(f"[Captures清理] 已清理 {deleted} 个文件")
  530. except Exception as e:
  531. logger.error(f"[Captures清理] 清理失败: {e}")
  532. def _get_seconds_until_target_time(self, target_time_str: str) -> int:
  533. """
  534. 计算到目标时间的秒数
  535. Args:
  536. target_time_str: 目标时间字符串 (HH:MM格式)
  537. Returns:
  538. 到目标时间的秒数,如果已过今天的目标时间则返回到明天目标时间的秒数
  539. """
  540. from datetime import datetime, timedelta
  541. now = datetime.now()
  542. target_hour, target_minute = map(int, target_time_str.split(':'))
  543. target_time = now.replace(hour=target_hour, minute=target_minute, second=0, microsecond=0)
  544. # 如果已过今天的目标时间,则计算到明天的目标时间
  545. if now >= target_time:
  546. target_time += timedelta(days=1)
  547. return int((target_time - now).total_seconds())
  548. def _periodic_calibration_worker(self):
  549. """定时校准工作线程 - 每日指定时间执行校准"""
  550. from datetime import datetime
  551. while self.calibration_running:
  552. try:
  553. # 计算到下一个校准时间的等待秒数
  554. wait_seconds = self._get_seconds_until_target_time(self.daily_calibration_time)
  555. next_time = datetime.now().replace(
  556. hour=int(self.daily_calibration_time.split(':')[0]),
  557. minute=int(self.daily_calibration_time.split(':')[1])
  558. )
  559. if datetime.now() >= next_time:
  560. from datetime import timedelta
  561. next_time += timedelta(days=1)
  562. logger.info(f"下次校准时间: {next_time.strftime('%Y-%m-%d %H:%M:%S')} (等待 {wait_seconds // 3600}小时{(wait_seconds % 3600) // 60}分钟)")
  563. # 等待到校准时间,每分钟检查一次是否需要停止
  564. for i in range(wait_seconds):
  565. if not self.calibration_running:
  566. return
  567. time.sleep(1)
  568. if not self.calibration_running:
  569. return
  570. # 执行校准
  571. logger.info("=" * 50)
  572. logger.info(f"执行每日定时校准 (时间: {self.daily_calibration_time})...")
  573. logger.info("=" * 50)
  574. # 每日校准强制重新校准(不使用已有数据),失败时可回退
  575. result = self._auto_calibrate(force=self.force_daily_recalibration)
  576. if result:
  577. logger.info("每日定时校准成功!")
  578. else:
  579. logger.warning("每日定时校准失败!")
  580. except Exception as e:
  581. logger.error(f"定时校准错误: {e}")
  582. time.sleep(60) # 出错后等待1分钟再重试
  583. def manual_calibrate(self) -> bool:
  584. """
  585. 手动触发校准
  586. Returns:
  587. 是否成功
  588. """
  589. logger.info("手动触发校准...")
  590. return self._auto_calibrate()
  591. def cleanup(self):
  592. """清理资源"""
  593. self.stop()
  594. # 确保定时校准停止
  595. self._stop_periodic_calibration()
  596. if self.sdk:
  597. self.sdk.cleanup()
  598. logger.info("系统资源已清理")
  599. def run_interactive(system: DualCameraSystem):
  600. """
  601. 交互模式运行
  602. Args:
  603. system: 系统实例
  604. """
  605. print("\n双摄像头联动系统 - 交互模式")
  606. print("=" * 50)
  607. print("命令:")
  608. print(" s - 开始/停止联动")
  609. print(" r - 获取识别结果")
  610. print(" t - 手动跟踪 (输入 x y)")
  611. print(" c - 抓拍快照")
  612. print(" b - 手动校准")
  613. print(" q - 退出")
  614. print("=" * 50)
  615. running = False
  616. while True:
  617. try:
  618. cmd = input("\n> ").strip().lower()
  619. if cmd == 'q':
  620. break
  621. elif cmd == 's':
  622. if running:
  623. system.stop()
  624. running = False
  625. print("联动已停止")
  626. else:
  627. if system.start():
  628. running = True
  629. print("联动已启动")
  630. elif cmd == 'r':
  631. results = system.get_results()
  632. if results:
  633. print(f"获取到 {len(results)} 个识别结果")
  634. else:
  635. print("暂无识别结果")
  636. elif cmd == 't':
  637. try:
  638. coords = input("输入坐标 (x y, 范围0-1): ").strip().split()
  639. x, y = float(coords[0]), float(coords[1])
  640. system.coordinator.force_track_position(x, y)
  641. print(f"已移动到位置 ({x:.2f}, {y:.2f})")
  642. except Exception as e:
  643. print(f"输入错误: {e}")
  644. elif cmd == 'c':
  645. frame = system.coordinator.capture_snapshot()
  646. if frame is not None:
  647. filename = f"snapshot_{int(time.time())}.jpg"
  648. cv2.imwrite(filename, frame)
  649. print(f"快照已保存: {filename}")
  650. else:
  651. print("抓拍失败")
  652. elif cmd == 'b':
  653. print("开始手动校准...")
  654. if system.manual_calibrate():
  655. print("校准成功!")
  656. else:
  657. print("校准失败!")
  658. else:
  659. print("未知命令")
  660. except KeyboardInterrupt:
  661. break
  662. except Exception as e:
  663. print(f"错误: {e}")
  664. print("退出交互模式")
  665. def main():
  666. """主函数"""
  667. parser = argparse.ArgumentParser(description='双摄像头联动抓拍系统')
  668. # 多组模式参数
  669. parser.add_argument('--multi-group', action='store_true', help='启用多组摄像头模式')
  670. # 单组模式参数(兼容旧参数)
  671. parser.add_argument('--panorama-ip', type=str, help='全景摄像头IP')
  672. parser.add_argument('--ptz-ip', type=str, help='球机IP')
  673. parser.add_argument('--username', type=str, default='admin', help='用户名')
  674. parser.add_argument('--password', type=str, default='admin123', help='密码')
  675. parser.add_argument('--model', type=str, help='检测模型路径 (默认使用YOLO11n)')
  676. parser.add_argument('--model-size', type=str, default='n',
  677. choices=['n', 's', 'm', 'l', 'x'],
  678. help='YOLO11模型尺寸 (n/s/m/l/x)')
  679. parser.add_argument('--no-gpu', action='store_true', help='不使用GPU')
  680. parser.add_argument('--interactive', action='store_true', help='交互模式')
  681. parser.add_argument('--demo', action='store_true', help='演示模式(不连接实际摄像头)')
  682. parser.add_argument('--skip-calibration', action='store_true', help='跳过自动校准')
  683. parser.add_argument('--force-calibration', action='store_true', help='强制重新校准')
  684. args = parser.parse_args()
  685. # 演示模式
  686. if args.demo:
  687. print("演示模式: 使用模拟数据")
  688. run_demo()
  689. return
  690. # 检查是否启用多组模式
  691. enabled_groups = get_enabled_groups()
  692. use_multi_group = args.multi_group or len(enabled_groups) > 1
  693. if use_multi_group:
  694. # 多组模式
  695. return run_multi_group_mode(args)
  696. else:
  697. # 单组模式(保持向后兼容)
  698. return run_single_group_mode(args)
  699. def run_multi_group_mode(args):
  700. """多组摄像头模式"""
  701. global _shutdown_requested
  702. from multi_group_system import MultiGroupSystem
  703. # 注册信号处理
  704. signal.signal(signal.SIGINT, _signal_handler)
  705. signal.signal(signal.SIGTERM, _signal_handler)
  706. _shutdown_requested = False
  707. print("\n" + "=" * 60)
  708. print("多组摄像头联动抓拍系统")
  709. print("=" * 60)
  710. enabled_groups = get_enabled_groups()
  711. print(f"启用的摄像头组: {len(enabled_groups)} 个")
  712. for g in enabled_groups:
  713. print(f" - {g.get('name', g.get('group_id'))}")
  714. print()
  715. # 构建配置
  716. config = {
  717. 'model_size': args.model_size,
  718. 'use_gpu': not args.no_gpu,
  719. }
  720. if args.model:
  721. config['model_path'] = args.model
  722. # 创建多组系统
  723. system = MultiGroupSystem(config)
  724. try:
  725. # 初始化
  726. if not system.initialize(skip_calibration=args.skip_calibration):
  727. print("\n系统初始化失败!")
  728. return 1
  729. # 启动
  730. print("\n启动多组联动系统...")
  731. if not system.start():
  732. print("启动失败")
  733. return 1
  734. print(f"\n多组摄像头系统运行中 ({len(system.groups)} 个组)")
  735. print("按 Ctrl+C 停止\n")
  736. # 等待(检查停止标志)
  737. while system.running and not _shutdown_requested:
  738. time.sleep(1)
  739. except KeyboardInterrupt:
  740. print("\n接收到停止信号")
  741. finally:
  742. print("正在停止系统...")
  743. system.stop()
  744. return 0
  745. def run_single_group_mode(args):
  746. """单组摄像头模式(保持向后兼容)"""
  747. global _shutdown_requested
  748. # 注册信号处理
  749. signal.signal(signal.SIGINT, _signal_handler)
  750. signal.signal(signal.SIGTERM, _signal_handler)
  751. _shutdown_requested = False
  752. # 构建配置
  753. config = {}
  754. if args.panorama_ip:
  755. config['panorama_camera'] = {
  756. **PANORAMA_CAMERA,
  757. 'ip': args.panorama_ip,
  758. 'username': args.username,
  759. 'password': args.password,
  760. }
  761. if args.ptz_ip:
  762. config['ptz_camera'] = {
  763. **PTZ_CAMERA,
  764. 'ip': args.ptz_ip,
  765. 'username': args.username,
  766. 'password': args.password,
  767. }
  768. if args.model:
  769. config['model_path'] = args.model
  770. config['model_size'] = args.model_size
  771. config['use_gpu'] = not args.no_gpu
  772. # 创建系统实例
  773. system = DualCameraSystem(config)
  774. try:
  775. # 初始化 (包含自动校准)
  776. if not system.initialize(skip_calibration=args.skip_calibration):
  777. print("\n系统初始化失败!")
  778. if not args.skip_calibration:
  779. print("提示: 可使用 --skip-calibration 跳过校准")
  780. return 1
  781. # 运行
  782. if args.interactive:
  783. run_interactive(system)
  784. else:
  785. # 自动模式
  786. print("启动联动系统...")
  787. if not system.start():
  788. print("启动失败")
  789. return 1
  790. print("系统运行中,按Ctrl+C停止")
  791. while not _shutdown_requested:
  792. time.sleep(1)
  793. results = system.get_results()
  794. if results:
  795. print(f"[识别] 获取到 {len(results)} 个结果")
  796. except KeyboardInterrupt:
  797. print("\n接收到停止信号")
  798. finally:
  799. print("正在停止系统...")
  800. system.cleanup()
  801. return 0
  802. def run_demo():
  803. """演示模式"""
  804. print("\n演示模式 - 双摄像头联动系统")
  805. print("=" * 60)
  806. print("""
  807. 系统架构:
  808. ┌─────────────────────────────────────────────────────────────┐
  809. │ 全景摄像头 (Panorama) │
  810. │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
  811. │ │ 视频流 │ -> │ 人体检测 │ -> │ 位置计算 │ │
  812. │ └─────────┘ └─────────┘ └─────────┘ │
  813. └─────────────────────────────────────────────────────────────┘
  814. ▼ 检测到人体位置 (x_ratio, y_ratio)
  815. ┌─────────────────────────────────────────────────────────────┐
  816. │ 球机 (PTZ Camera) │
  817. │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
  818. │ │ PTZ控制 │ -> │ 精确定位 │ -> │ 变焦放大 │ │
  819. │ └─────────┘ └─────────┘ └─────────┘ │
  820. └─────────────────────────────────────────────────────────────┘
  821. 工作流程:
  822. 1. 全景摄像头实时获取视频流
  823. 2. 使用YOLO11检测画面中的人体
  824. 3. 计算人体在画面中的相对位置
  825. 4. 控制球机PTZ移动到对应位置
  826. 5. 球机变焦放大人体区域
  827. 主要组件:
  828. - dahua_sdk.py: 大华SDK封装
  829. - panorama_camera.py: 全景摄像头和人体检测
  830. - ptz_camera.py: 球机PTZ控制
  831. - coordinator.py: 联动控制逻辑
  832. """)
  833. print("=" * 60)
  834. print("\n使用方法:")
  835. print(" python main.py --panorama-ip 192.168.1.100 --ptz-ip 192.168.1.101")
  836. print(" python main.py --interactive # 交互模式")
  837. print(" python main.py --demo # 演示说明")
  838. if __name__ == '__main__':
  839. sys.exit(main() or 0)