app.py 15 KB

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