app.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. """FastAPI 应用工厂与全局服务初始化."""
  2. import logging
  3. import os
  4. import tempfile
  5. import threading
  6. import time
  7. from contextlib import asynccontextmanager
  8. import cv2
  9. from fastapi import FastAPI
  10. from config import CAMERA_GROUPS, SDK_PATH, SYSTEM_CONFIG, STORAGE_CONFIG
  11. from dahua_sdk import DahuaSDK
  12. from ptz_camera import PTZCamera
  13. from third_party_pusher import get_third_party_pusher
  14. from core.stream_manager import StreamManager
  15. from core.scan_point_store import ScanPointStore
  16. from core.spatial_scanner import SpatialScanner
  17. from core.polling_scheduler import PollingScheduler
  18. from core.capture_uploader import CaptureUploader
  19. from core.detector_service import DetectorService
  20. from core.group_state import group_state
  21. from core.oss_uploader import OSSUploader
  22. from core.file_cleanup import make_cleanup_workers, CleanupWorker
  23. from web.routes import router
  24. from web.state import WebState
  25. import web.state as _web_state_module
  26. logger = logging.getLogger(__name__)
  27. def build_rtsp_url(camera_config: dict) -> str:
  28. if camera_config.get("rtsp_url"):
  29. return camera_config["rtsp_url"]
  30. ip = camera_config["ip"]
  31. port = camera_config.get("rtsp_port", 554)
  32. username = camera_config["username"]
  33. password = camera_config["password"]
  34. channel = camera_config.get("rtsp_channel") or camera_config.get("channel", 1)
  35. subtype = camera_config.get("subtype", 0)
  36. return f"rtsp://{username}:{password}@{ip}:{port}/cam/realmonitor?channel={channel}&subtype={subtype}"
  37. def create_app(test_mode: bool = False) -> FastAPI:
  38. @asynccontextmanager
  39. async def lifespan(app: FastAPI):
  40. # 重置全局状态,允许同一进程内反复创建应用(测试场景)
  41. group_state.reset()
  42. stream_manager = StreamManager()
  43. store_path = os.path.join(tempfile.mkdtemp(), "scan_models.json") if test_mode else "data/scan_models.json"
  44. scan_store = ScanPointStore(store_path)
  45. scanners: dict = {}
  46. schedulers: dict = {}
  47. ptz_cameras: dict = {}
  48. threads: list = []
  49. stop_event = threading.Event()
  50. pusher = None
  51. sdk = None
  52. detector_service = None
  53. oss_uploader = None
  54. cleanup_workers: list = []
  55. if not test_mode:
  56. if SYSTEM_CONFIG.get("enable_detection", True):
  57. detector_service = DetectorService()
  58. if SYSTEM_CONFIG.get("enable_ptz_camera", True):
  59. try:
  60. sdk_path = os.path.join(SDK_PATH["lib_path"], SDK_PATH.get("netsdk", "libdhnetsdk.so"))
  61. sdk = DahuaSDK(sdk_path)
  62. sdk.init()
  63. except Exception as exc:
  64. print(f"[lifespan] SDK init failed: {exc}")
  65. sdk = None
  66. if SYSTEM_CONFIG.get("enable_event_push", False):
  67. try:
  68. oss_uploader = OSSUploader()
  69. except Exception as exc:
  70. print(f"[lifespan] OSS uploader init failed: {exc}")
  71. oss_uploader = None
  72. try:
  73. pusher = get_third_party_pusher()
  74. if pusher and not pusher.running:
  75. pusher.start()
  76. except Exception as exc:
  77. print(f"[lifespan] Pusher start failed: {exc}")
  78. pusher = None
  79. # 上报成功后,根据配置删除本地抓拍图片
  80. if pusher is not None:
  81. def _delete_captures_on_success(report):
  82. if STORAGE_CONFIG.get("captures", {}).get("keep_local_copy", False):
  83. return
  84. for path in report.batch_info.get("image_paths") or []:
  85. try:
  86. if path and os.path.exists(path):
  87. os.remove(path)
  88. logger.info("[cleanup] 上报成功后删除本地图片: %s", path)
  89. except Exception as exc:
  90. logger.warning("[cleanup] 删除本地图片失败: %s, %s", path, exc)
  91. try:
  92. pusher.set_callbacks(on_success=_delete_captures_on_success)
  93. except Exception as exc:
  94. print(f"[lifespan] set pusher success callback failed: {exc}")
  95. # 启动 captures / previews 定期清理 worker
  96. try:
  97. group_ids = [
  98. g.get("group_id", g.get("id"))
  99. for g in CAMERA_GROUPS
  100. if g.get("enabled", True)
  101. ]
  102. cleanup_workers = make_cleanup_workers(STORAGE_CONFIG, group_ids)
  103. for w in cleanup_workers:
  104. w.start()
  105. except Exception as exc:
  106. print(f"[lifespan] cleanup worker start failed: {exc}")
  107. for group in CAMERA_GROUPS:
  108. if not group.get("enabled", True):
  109. continue
  110. gid = group.get("group_id", group.get("id"))
  111. try:
  112. ptz_cfg = group["ptz"]
  113. pano_cfg = group["panorama"]
  114. pano_url = build_rtsp_url(pano_cfg)
  115. ptz_url = build_rtsp_url(ptz_cfg)
  116. group_state.init_group(gid, pano_url, ptz_url, ptz_cfg)
  117. group_state.update(gid, "ptz_connected", False)
  118. scan_store.ensure_group(gid, {
  119. "ptz_name": ptz_cfg.get("name", gid),
  120. "panorama_name": pano_cfg.get("name", gid),
  121. })
  122. if test_mode:
  123. continue
  124. # 注册枪机 RTSP 流
  125. if SYSTEM_CONFIG.get("enable_panorama_camera", True):
  126. stream_manager.register(f"{gid}_panorama", pano_url)
  127. ptz = None
  128. if SYSTEM_CONFIG.get("enable_ptz_camera", True) and sdk is not None:
  129. # 连接球机,并把组级别的校准文件路径透传给球机
  130. ptz_cfg_with_calib = dict(ptz_cfg)
  131. ptz_cfg_with_calib['calibration_file'] = group.get('calibration_file')
  132. ptz = PTZCamera(sdk, ptz_cfg_with_calib)
  133. try:
  134. ptz_connected = ptz.connect()
  135. except Exception as exc:
  136. print(f"[lifespan] PTZ connect raised for group {gid}: {exc}")
  137. ptz_connected = False
  138. if ptz_connected:
  139. ptz_cameras[gid] = ptz
  140. group_state.update(gid, "ptz_connected", True)
  141. # 注册球机 RTSP 流
  142. stream_manager.register(f"{gid}_ptz", ptz_url)
  143. scanners[gid] = SpatialScanner(
  144. gid, ptz, lambda g=gid: stream_manager.get(f"{g}_ptz").get_frame()
  145. )
  146. def on_arrived(point, g=gid, ptz=ptz):
  147. group_state.update(g, "ptz_position", {
  148. "pan": point["pan"], "tilt": point["tilt"], "zoom": point.get("zoom", 1)
  149. })
  150. schedulers[gid] = PollingScheduler(
  151. gid, ptz,
  152. get_points=lambda g=gid: scan_store.list_enabled_points(g),
  153. on_arrived=on_arrived,
  154. default_dwell=3.0,
  155. )
  156. else:
  157. print(f"[lifespan] PTZ connect failed for group {gid}; skipping PTZ features")
  158. # 创建上传器(即使球机关闭,枪机检测仍可能使用)
  159. uploader = CaptureUploader(
  160. gid,
  161. upload_callback=pusher.report_batch if pusher else None,
  162. oss_uploader=oss_uploader,
  163. )
  164. if SYSTEM_CONFIG.get("enable_detection", True) and detector_service is not None:
  165. def panorama_detect_loop(g=gid, uploader=uploader, detector=detector_service):
  166. interval = 0.5
  167. while not stop_event.is_set():
  168. try:
  169. stream = stream_manager.get(f"{g}_panorama")
  170. frame = stream.get_frame() if stream else None
  171. if frame is not None:
  172. dets = detector.detect(frame)
  173. marked = frame.copy()
  174. if dets:
  175. det_dicts = [{
  176. "bbox": [d.bbox[0], d.bbox[1], d.bbox[0]+d.bbox[2], d.bbox[1]+d.bbox[3]],
  177. "confidence": d.confidence,
  178. } for d in dets]
  179. uploader.handle_detection("panorama", frame, det_dicts)
  180. for d in det_dicts:
  181. x1, y1, x2, y2 = d["bbox"]
  182. cv2.rectangle(marked, (x1, y1), (x2, y2), (0, 255, 0), 2)
  183. stream.set_marked_frame(marked)
  184. except Exception as exc:
  185. print(f"[panorama_detect_loop {g}] error: {exc}")
  186. time.sleep(interval)
  187. t_panorama = threading.Thread(target=panorama_detect_loop, daemon=True)
  188. t_panorama.start()
  189. threads.append(t_panorama)
  190. if ptz is not None:
  191. def ptz_detect_loop(g=gid, uploader=uploader, detector=detector_service):
  192. interval = 0.2
  193. while not stop_event.is_set():
  194. try:
  195. stream = stream_manager.get(f"{g}_ptz")
  196. frame = stream.get_frame() if stream else None
  197. if frame is not None:
  198. dets = detector.detect(frame)
  199. marked = frame.copy()
  200. if dets:
  201. det_dicts = [{
  202. "bbox": [d.bbox[0], d.bbox[1], d.bbox[0]+d.bbox[2], d.bbox[1]+d.bbox[3]],
  203. "confidence": d.confidence,
  204. } for d in dets]
  205. pos = group_state.get(g).get("ptz_position")
  206. uploader.handle_detection("ptz", frame, det_dicts, pos)
  207. for d in det_dicts:
  208. x1, y1, x2, y2 = d["bbox"]
  209. cv2.rectangle(marked, (x1, y1), (x2, y2), (0, 255, 0), 2)
  210. stream.set_marked_frame(marked)
  211. except Exception as exc:
  212. print(f"[ptz_detect_loop {g}] error: {exc}")
  213. time.sleep(interval)
  214. t_ptz = threading.Thread(target=ptz_detect_loop, daemon=True)
  215. t_ptz.start()
  216. threads.append(t_ptz)
  217. except Exception as exc:
  218. print(f"[lifespan] Group {gid} setup failed: {exc}")
  219. continue
  220. _web_state_module.web_state = WebState(
  221. group_state, stream_manager, scan_store, scanners, schedulers, ptz_cameras
  222. )
  223. yield
  224. # 清理
  225. stop_event.set()
  226. for t in threads:
  227. try:
  228. t.join(timeout=2.0)
  229. except Exception as exc:
  230. print(f"[lifespan] Thread join error: {exc}")
  231. for s in schedulers.values():
  232. try:
  233. s.stop()
  234. except Exception as exc:
  235. print(f"[lifespan] Scheduler stop error: {exc}")
  236. for gid, ptz in ptz_cameras.items():
  237. try:
  238. ptz.disconnect()
  239. stream = stream_manager.get(f"{gid}_ptz")
  240. if stream:
  241. stream.stop()
  242. except Exception as exc:
  243. print(f"[lifespan] PTZ cleanup error for {gid}: {exc}")
  244. stream_manager.stop_all()
  245. if pusher and getattr(pusher, "running", False):
  246. try:
  247. pusher.stop()
  248. except Exception as exc:
  249. print(f"[lifespan] Pusher stop error: {exc}")
  250. if sdk:
  251. try:
  252. sdk.cleanup()
  253. except Exception as exc:
  254. print(f"[lifespan] SDK cleanup error: {exc}")
  255. for w in cleanup_workers:
  256. try:
  257. w.stop()
  258. except Exception as exc:
  259. print(f"[lifespan] cleanup worker stop error: {exc}")
  260. if test_mode:
  261. # 在测试模式下预先初始化共享状态(不依赖 lifespan,兼容非上下文管理器的 TestClient)
  262. group_state.reset()
  263. test_stream_manager = StreamManager()
  264. test_store_path = os.path.join(tempfile.mkdtemp(), "scan_models.json")
  265. test_scan_store = ScanPointStore(test_store_path)
  266. for group in CAMERA_GROUPS:
  267. if not group.get("enabled", True):
  268. continue
  269. gid = group.get("group_id", group.get("id"))
  270. try:
  271. ptz_cfg = group["ptz"]
  272. pano_cfg = group["panorama"]
  273. pano_url = build_rtsp_url(pano_cfg)
  274. ptz_url = build_rtsp_url(ptz_cfg)
  275. group_state.init_group(gid, pano_url, ptz_url, ptz_cfg)
  276. group_state.update(gid, "ptz_connected", False)
  277. test_scan_store.ensure_group(gid, {
  278. "ptz_name": ptz_cfg.get("name", gid),
  279. "panorama_name": pano_cfg.get("name", gid),
  280. })
  281. except Exception as exc:
  282. print(f"[create_app] Test group {gid} setup failed: {exc}")
  283. _web_state_module.web_state = WebState(
  284. group_state, test_stream_manager, test_scan_store, {}, {}, {}
  285. )
  286. app = FastAPI(lifespan=lifespan)
  287. app.include_router(router)
  288. @app.get("/")
  289. async def root():
  290. return {"status": "running", "service": "dual-camera detection"}
  291. return app