seed_video_platform.py 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090
  1. """工业视频平台模拟演示数据。
  2. 视频与图像取自 ``settings.video_media_root``(默认 D:/workspace/js/gate_analysis_system,
  3. 即风门/局扇/主扇三个识别演示系统的公开资源目录),数据库只保存磁盘路径与业务属性。
  4. """
  5. from __future__ import annotations
  6. from datetime import datetime, time, timedelta, timezone
  7. from sqlalchemy import func, select
  8. from sqlalchemy.orm import Session
  9. from app.core.config import settings
  10. from app.db.base import new_id
  11. from app.db.models.mine import Mine
  12. from app.db.models.monitoring import DataSource
  13. from app.db.models.video import (
  14. VIDEO_MODULE_DOOR,
  15. VIDEO_MODULE_LOCAL_FAN,
  16. VIDEO_MODULE_MAIN_FAN,
  17. VideoAnalysisEvent,
  18. VideoChannel,
  19. VideoRecord,
  20. VideoSnapshot,
  21. )
  22. def _media_path(*parts: str) -> str:
  23. """拼出媒体文件的磁盘绝对路径(不要求文件真实存在)。"""
  24. return str(settings.video_media_path.joinpath(*parts))
  25. def _file_size(path: str) -> int:
  26. try:
  27. from pathlib import Path
  28. file_path = Path(path)
  29. if file_path.is_file():
  30. return file_path.stat().st_size
  31. except OSError:
  32. return 0
  33. return 0
  34. # (code, name, module, area_group, location, device_type, status,
  35. # video_relative_path, resolution, preview_relative_path, extra_properties)
  36. CHANNEL_SPECS: list[tuple] = [
  37. (
  38. "VC-DOOR-01", "五盘区402辅运大巷风门", VIDEO_MODULE_DOOR, "风门区域",
  39. "五盘区402辅运大巷", "door", "online",
  40. ("public", "videos", "v1.mp4"), "1920x1080",
  41. ("public", "images", "open.jpg"), {"door_side": "前门"},
  42. ),
  43. (
  44. "VC-DOOR-02", "六盘区501胶带运输巷风门", VIDEO_MODULE_DOOR, "风门区域",
  45. "六盘区501胶带运输巷", "door", "online",
  46. ("public", "videos", "v2.mp4"), "1920x1080",
  47. ("public", "images", "open1.jpg"), {"door_side": "后门"},
  48. ),
  49. (
  50. "VC-DOOR-03", "七盘区602回风联络巷风门", VIDEO_MODULE_DOOR, "风门区域",
  51. "七盘区602回风联络巷", "door", "online",
  52. ("public", "videos", "v1.mp4"), "1920x1080",
  53. ("public", "images", "gap1.jpg"), {"door_side": "前门"},
  54. ),
  55. (
  56. "VC-DOOR-04", "中央变电所进风通道风门", VIDEO_MODULE_DOOR, "风门区域",
  57. "中央变电所", "door", "online",
  58. ("public", "videos", "v2.mp4"), "1920x1080",
  59. ("public", "images", "person1.jpg"), {"door_side": "后门"},
  60. ),
  61. (
  62. "VC-DOOR-05", "采区水泵房专用通道风门", VIDEO_MODULE_DOOR, "风门区域",
  63. "采区水泵房", "door", "online",
  64. ("public", "videos", "si.mp4"), "2560x1440",
  65. ("public", "images", "car1.jpg"), {"door_side": "前门", "ai_anti_clip": True},
  66. ),
  67. (
  68. "VC-DOOR-06", "南风井绕道处风门", VIDEO_MODULE_DOOR, "风门区域",
  69. "南风井绕道", "door", "online",
  70. ("public", "videos", "b1.mp4"), "1920x1080",
  71. ("public", "images", "20260315_192656.jpg"), {"door_side": "前门"},
  72. ),
  73. (
  74. "VC-DOOR-07", "12煤四盘区回风入口风门", VIDEO_MODULE_DOOR, "风门区域",
  75. "12煤四盘区", "door", "fault",
  76. ("public", "videos", "b2.mp4"), "1920x1080",
  77. ("public", "images", "gap17.jpg"), {"fault_reason": "门体闭合反馈异常"},
  78. ),
  79. (
  80. "VC-DOOR-08", "22107胶运绕道行车风门", VIDEO_MODULE_DOOR, "风门区域",
  81. "22107胶运绕道", "door", "offline",
  82. ("public", "videos", "4.mp4"), "1920x1080",
  83. ("public", "images", "open15.jpg"), {"offline_reason": "网络中断"},
  84. ),
  85. (
  86. "VC-LFAN-01", "1号掘进工作面局部风机-变频器区", VIDEO_MODULE_LOCAL_FAN, "局扇区域",
  87. "1号掘进工作面", "local_fan", "online",
  88. ("public", "videos", "fan-main.mp4"), "1920x1080",
  89. ("public", "images", "fan1.png"), {"area_name": "变频器区"},
  90. ),
  91. (
  92. "VC-LFAN-02", "1号掘进工作面局部风机-配电柜区", VIDEO_MODULE_LOCAL_FAN, "局扇区域",
  93. "1号掘进工作面", "local_fan", "online",
  94. ("public", "videos", "fan-main.mp4"), "1920x1080",
  95. ("public", "images", "fan2.png"), {"area_name": "配电柜区"},
  96. ),
  97. (
  98. "VC-LFAN-03", "2号掘进工作面局部风机", VIDEO_MODULE_LOCAL_FAN, "局扇区域",
  99. "2号掘进工作面", "local_fan", "online",
  100. ("public", "videos", "fan-main.mp4"), "1920x1080",
  101. ("public", "images", "fan3.png"), {"area_name": "风机本体区"},
  102. ),
  103. (
  104. "VC-LFAN-04", "3号掘进工作面局部风机", VIDEO_MODULE_LOCAL_FAN, "局扇区域",
  105. "3号掘进工作面", "local_fan", "online",
  106. ("public", "videos", "fan-main.mp4"), "1920x1080",
  107. ("public", "images", "fan4.png"), {"area_name": "接线盒区"},
  108. ),
  109. (
  110. "VC-LFAN-05", "4号掘进工作面局部风机", VIDEO_MODULE_LOCAL_FAN, "局扇区域",
  111. "4号掘进工作面", "local_fan", "fault",
  112. ("public", "videos", "fan-main.mp4"), "1920x1080",
  113. ("public", "images", "fan5.png"), {"area_name": "散热区", "fault_reason": "风机振动超限"},
  114. ),
  115. (
  116. "VC-MFAN-01", "一号主风机-风机主体", VIDEO_MODULE_MAIN_FAN, "主扇区域",
  117. "一号主通风机", "main_fan", "online",
  118. ("public", "videos", "f.mp4"), "1920x1080",
  119. ("public", "images", "f1.png"), {"run_state": "running", "air_volume": 8600},
  120. ),
  121. (
  122. "VC-MFAN-02", "一号主风机-配电室", VIDEO_MODULE_MAIN_FAN, "主扇区域",
  123. "一号主风机配电室", "distribution_room", "online",
  124. ("public", "videos", "p.mp4"), "1920x1080",
  125. ("public", "images", "20260315_211358.jpg"), {"key_device": "PLC 配电柜"},
  126. ),
  127. (
  128. "VC-MFAN-03", "二号主风机-风机主体", VIDEO_MODULE_MAIN_FAN, "主扇区域",
  129. "二号主通风机", "main_fan", "online",
  130. ("public", "videos", "f.mp4"), "1920x1080",
  131. ("public", "images", "f2.png"), {"run_state": "stopped", "air_volume": 0},
  132. ),
  133. (
  134. "VC-MFAN-04", "主扇配电室", VIDEO_MODULE_MAIN_FAN, "主扇区域",
  135. "主通风机配电室", "distribution_room", "online",
  136. ("public", "videos", "p.mp4"), "1920x1080",
  137. ("public", "images", "20260315_213411.jpg"), {"key_device": "主扇变频柜"},
  138. ),
  139. (
  140. "VC-MFAN-05", "主扇风硐监测点", VIDEO_MODULE_MAIN_FAN, "主扇区域",
  141. "主通风机风硐", "main_fan", "online",
  142. ("public", "videos", "s.mp4"), "1920x1080",
  143. ("public", "images", "20260315_220400.jpg"), {"monitor_point": "风硐"},
  144. ),
  145. ]
  146. def _spot(now: datetime, minutes: int) -> datetime:
  147. """now 往前 minutes 分钟,并保证落在“本地今天”之内(凌晨播种时自动收敛)。"""
  148. local_now = now.astimezone()
  149. local_start = datetime.combine(local_now.date(), time.min, tzinfo=local_now.tzinfo)
  150. since_midnight = int((local_now - local_start).total_seconds() // 60)
  151. capped = min(minutes, max(since_midnight - 2, 0))
  152. return now - timedelta(minutes=capped)
  153. def seed_video_platform_demo(db: Session) -> dict:
  154. """写入工业视频平台演示数据,已存在通道时跳过。"""
  155. mine = db.scalar(
  156. select(Mine).where(Mine.code == settings.default_mine_code)
  157. ) or db.scalar(select(Mine).order_by(Mine.created_at).limit(1))
  158. if mine is None:
  159. return {
  160. "created": False,
  161. "reason": "mine_not_found",
  162. "channels": 0,
  163. "snapshots": 0,
  164. "records": 0,
  165. "events": 0,
  166. }
  167. existing = int(
  168. db.scalar(
  169. select(func.count(VideoChannel.id)).where(VideoChannel.mine_id == mine.id)
  170. )
  171. or 0
  172. )
  173. if existing > 0:
  174. return {
  175. "created": False,
  176. "reason": "already_seeded",
  177. "channels": existing,
  178. "snapshots": int(
  179. db.scalar(
  180. select(func.count(VideoSnapshot.id)).where(
  181. VideoSnapshot.mine_id == mine.id
  182. )
  183. )
  184. or 0
  185. ),
  186. "records": int(
  187. db.scalar(
  188. select(func.count(VideoRecord.id)).where(
  189. VideoRecord.mine_id == mine.id
  190. )
  191. )
  192. or 0
  193. ),
  194. "events": int(
  195. db.scalar(
  196. select(func.count(VideoAnalysisEvent.id)).where(
  197. VideoAnalysisEvent.mine_id == mine.id
  198. )
  199. )
  200. or 0
  201. ),
  202. }
  203. now = datetime.now(timezone.utc)
  204. data_source = _ensure_video_source(db, mine)
  205. channels = _seed_channels(db, mine, data_source, now)
  206. snapshots = _seed_snapshots(db, mine, channels, now)
  207. records = _seed_records(db, mine, channels, snapshots, now)
  208. events = _seed_events(db, mine, channels, snapshots, now)
  209. db.commit()
  210. return {
  211. "created": True,
  212. "mine_code": mine.code,
  213. "channels": len(channels),
  214. "snapshots": len(snapshots),
  215. "records": len(records),
  216. "events": len(events),
  217. "media_root": str(settings.video_media_path),
  218. }
  219. def _ensure_video_source(db: Session, mine: Mine) -> DataSource | None:
  220. source = db.scalar(
  221. select(DataSource).where(
  222. DataSource.mine_id == mine.id, DataSource.source_type == "video"
  223. )
  224. )
  225. if source is not None:
  226. return source
  227. source = db.scalar(
  228. select(DataSource).where(
  229. DataSource.mine_id == mine.id, DataSource.code == "VIDEO"
  230. )
  231. )
  232. if source is not None:
  233. return source
  234. source = DataSource(
  235. id=new_id(),
  236. mine_id=mine.id,
  237. code="VIDEO",
  238. name="工业视频平台",
  239. source_type="video",
  240. protocol="GB/T 28181",
  241. endpoint="10.20.18.10:5060",
  242. status="online",
  243. last_sync_at=datetime.now(timezone.utc),
  244. point_count=218,
  245. latency_ms=820,
  246. config={"channels": 218, "media_root": str(settings.video_media_path)},
  247. )
  248. db.add(source)
  249. db.flush()
  250. return source
  251. def _seed_channels(
  252. db: Session,
  253. mine: Mine,
  254. data_source: DataSource | None,
  255. now: datetime,
  256. ) -> dict[str, VideoChannel]:
  257. channels: dict[str, VideoChannel] = {}
  258. for index, spec in enumerate(CHANNEL_SPECS, start=1):
  259. (
  260. code, name, module, area_group, location, device_type, status,
  261. video_parts, resolution, preview_parts, extra,
  262. ) = spec
  263. file_path = _media_path(*video_parts)
  264. channel = VideoChannel(
  265. id=new_id(),
  266. mine_id=mine.id,
  267. data_source_id=data_source.id if data_source else None,
  268. code=code,
  269. name=name,
  270. module=module,
  271. area_group=area_group,
  272. location=location,
  273. device_type=device_type,
  274. point_code=f"3402000000132002{index:04d}",
  275. protocol="GB/T 28181",
  276. stream_url=f"rtsp://10.20.18.10:554/Streaming/Channels/{index:02d}01",
  277. status=status,
  278. file_path=file_path,
  279. file_name=video_parts[-1],
  280. file_size=_file_size(file_path),
  281. duration_seconds=None,
  282. resolution=resolution,
  283. frame_rate=25,
  284. preview_image_path=_media_path(*preview_parts),
  285. is_enabled=True,
  286. sort_order=index,
  287. last_online_at=now - timedelta(seconds=30 * index)
  288. if status == "online"
  289. else now - timedelta(days=2),
  290. properties=dict(extra),
  291. )
  292. db.add(channel)
  293. channels[code] = channel
  294. db.flush()
  295. return channels
  296. def _snapshot(
  297. *,
  298. mine_id: str,
  299. channel: VideoChannel,
  300. snapshot_type: str,
  301. event_type: str,
  302. level: str,
  303. image_parts: tuple[str, ...],
  304. captured_at: datetime,
  305. title: str = "",
  306. plate_no: str = "",
  307. person_name: str = "",
  308. person_count: int = 0,
  309. team_name: str = "",
  310. gap_mm: float | None = None,
  311. confidence: float | None = None,
  312. properties: dict | None = None,
  313. ) -> VideoSnapshot:
  314. file_path = _media_path(*image_parts)
  315. return VideoSnapshot(
  316. id=new_id(),
  317. mine_id=mine_id,
  318. channel_id=channel.id,
  319. module=channel.module,
  320. snapshot_type=snapshot_type,
  321. event_type=event_type,
  322. level=level,
  323. title=title or event_type,
  324. file_path=file_path,
  325. file_name=image_parts[-1],
  326. file_size=_file_size(file_path),
  327. width=None,
  328. height=None,
  329. thumbnail_path="",
  330. plate_no=plate_no,
  331. person_name=person_name,
  332. person_count=person_count,
  333. team_name=team_name,
  334. gap_mm=gap_mm,
  335. confidence=confidence,
  336. captured_at=captured_at,
  337. properties=properties or {},
  338. )
  339. def _seed_snapshots(
  340. db: Session,
  341. mine: Mine,
  342. channels: dict[str, VideoChannel],
  343. now: datetime,
  344. ) -> dict[str, VideoSnapshot]:
  345. rows: list[VideoSnapshot] = []
  346. by_key: dict[str, VideoSnapshot] = {}
  347. door_codes = [f"VC-DOOR-{index:02d}" for index in range(1, 6)]
  348. local_fan_codes = [f"VC-LFAN-{index:02d}" for index in range(1, 6)]
  349. main_fan_codes = ["VC-MFAN-01", "VC-MFAN-02", "VC-MFAN-03", "VC-MFAN-04"]
  350. # 1. 风门:车辆通行抓拍
  351. vehicle_samples = [
  352. ("car1.jpg", "蒙K3571", 12), ("car2.jpg", "蒙K6829", 29),
  353. ("car3.jpg", "蒙K1122", 46), ("car4.jpg", "蒙K3344", 63),
  354. ("car5.jpg", "蒙K5566", 80), ("car6.jpg", "蒙K7719", 97),
  355. ("car7.jpg", "蒙K2288", 114), ("car8.jpg", "蒙K9966", 131),
  356. ("car9.jpg", "蒙K4408", 148), ("car10.jpg", "蒙K5533", 165),
  357. ("car11.jpg", "蒙K8842", 182), ("car12.jpg", "蒙K6317", 199),
  358. ]
  359. for index, (image, plate, minutes) in enumerate(vehicle_samples):
  360. channel = channels[door_codes[index % len(door_codes)]]
  361. snapshot = _snapshot(
  362. mine_id=mine.id,
  363. channel=channel,
  364. snapshot_type="vehicle_passage",
  365. event_type="车辆通行",
  366. level="info",
  367. image_parts=("public", "images", image),
  368. captured_at=_spot(now, minutes),
  369. title=f"{channel.location} 车辆通行 {plate}",
  370. plate_no=plate,
  371. confidence=0.96,
  372. )
  373. rows.append(snapshot)
  374. by_key[f"vehicle-{index + 1}"] = snapshot
  375. # 2. 风门:人员通行抓拍
  376. person_samples = [
  377. ("person1.jpg", "张三、李四", 2, "掘进一队", 20),
  378. ("person2.jpg", "王五", 1, "机电队", 38),
  379. ("person3.jpg", "赵六、陈七", 2, "回采一队", 55),
  380. ("open1.jpg", "周八", 1, "机电队", 72),
  381. ("open11.jpg", "吴九", 1, "维修队", 90),
  382. ("open12.jpg", "郑十、刘一", 2, "掘进二队", 108),
  383. ("open13.jpg", "孙十一", 1, "通风队", 126),
  384. ("open14.jpg", "马十二", 1, "维修队", 145),
  385. ]
  386. for index, (image, person, count, team, minutes) in enumerate(person_samples):
  387. channel = channels[door_codes[index % len(door_codes)]]
  388. snapshot = _snapshot(
  389. mine_id=mine.id,
  390. channel=channel,
  391. snapshot_type="person_passage",
  392. event_type="人员通行",
  393. level="info",
  394. image_parts=("public", "images", image),
  395. captured_at=_spot(now, minutes),
  396. title=f"{channel.location} 人员通行 {person}",
  397. person_name=person,
  398. person_count=count,
  399. team_name=team,
  400. confidence=0.94,
  401. )
  402. rows.append(snapshot)
  403. by_key[f"person-{index + 1}"] = snapshot
  404. # 3. 风门:未关严抓拍
  405. gap_samples = [
  406. ("gap1.jpg", 15, 5), ("gap2.jpg", 10, 24), ("gap3.jpg", 12, 43),
  407. ("gap4.jpg", 8, 60), ("gap5.jpg", 9, 78), ("gap6.jpg", 11, 96),
  408. ("gap7.jpg", 14, 115), ("gap8.jpg", 7, 133), ("gap10.jpg", 16, 152),
  409. ]
  410. for index, (image, gap, minutes) in enumerate(gap_samples):
  411. channel = channels[door_codes[index % len(door_codes)]]
  412. snapshot = _snapshot(
  413. mine_id=mine.id,
  414. channel=channel,
  415. snapshot_type="door_unclosed",
  416. event_type="风门未关严",
  417. level="danger" if gap >= 15 else "warning",
  418. image_parts=("public", "images", image),
  419. captured_at=_spot(now, minutes),
  420. title=f"{channel.location} 门板闭合缝隙{gap}mm",
  421. gap_mm=float(gap),
  422. confidence=0.92,
  423. properties={"threshold_mm": 10, "over_mm": max(gap - 10, 0)},
  424. )
  425. rows.append(snapshot)
  426. by_key[f"unclosed-{index + 1}"] = snapshot
  427. # 4. 风门:AI 分析类抓拍(来自 20260315 现场图像与门体照片)
  428. analysis_samples = [
  429. ("20260315_192656.jpg", "anti_clip", "AI防夹人预警", "danger", 8, {"area": "危险区域"}),
  430. ("20260315_193111.jpg", "anti_clip", "AI防夹人预警", "danger", 34, {"area": "门体闭合区"}),
  431. ("20260315_194758.jpg", "foreign_object", "异物卡堵预警", "warning", 50, {"object": "石块"}),
  432. ("20260315_195803.jpg", "foreign_object", "异物卡堵预警", "warning", 86, {"object": "轨道杂物"}),
  433. ("20260315_202803.jpg", "seal_failure", "密封失效故障", "warning", 104, {"pressure": "异常"}),
  434. ("20260315_210425.jpg", "seal_failure", "密封失效故障", "warning", 128, {"pressure": "偏低"}),
  435. ("20260315_211358.jpg", "illegal_stay", "违规停留预警", "info", 150, {"duration_minutes": 3}),
  436. ("20260315_212805.jpg", "illegal_stay", "违规停留预警", "info", 170, {"duration_minutes": 2}),
  437. ("20260315_213411.jpg", "vehicle_crash", "车辆撞击故障", "warning", 188, {"deform": "轻微"}),
  438. ("20260315_220400.jpg", "patrol", "门体状态巡检", "info", 210, {"result": "门体正常"}),
  439. ("open2.jpg", "patrol", "门体状态巡检", "info", 232, {"result": "闭锁装置正常"}),
  440. ]
  441. for index, (image, snapshot_type, event_type, level, minutes, extra) in enumerate(
  442. analysis_samples
  443. ):
  444. channel = channels[door_codes[index % len(door_codes)]]
  445. snapshot = _snapshot(
  446. mine_id=mine.id,
  447. channel=channel,
  448. snapshot_type=snapshot_type,
  449. event_type=event_type,
  450. level=level,
  451. image_parts=("public", "images", image),
  452. captured_at=_spot(now, minutes),
  453. title=f"{channel.location} {event_type}",
  454. confidence=0.9,
  455. properties=dict(extra),
  456. )
  457. rows.append(snapshot)
  458. by_key[f"analysis-{index + 1}"] = snapshot
  459. # 5. 局部风机:人员轨迹抓拍
  460. track_samples = [
  461. ("fan1.png", "张三", "机电队", 1, 12),
  462. ("fan2.png", "李四", "维修队", 2, 30),
  463. ("fan3.png", "王五", "掘进一队", 3, 48),
  464. ("fan4.png", "赵六", "掘进二队", 4, 66),
  465. ("fan5.png", "陈七", "回采一队", 5, 84),
  466. ("fan6.png", "周八", "机电队", 1, 102),
  467. ]
  468. for index, (image, person, team, channel_index, minutes) in enumerate(track_samples):
  469. channel = channels[local_fan_codes[channel_index - 1]]
  470. snapshot = _snapshot(
  471. mine_id=mine.id,
  472. channel=channel,
  473. snapshot_type="person_track",
  474. event_type="人员检测",
  475. level="info",
  476. image_parts=("public", "images", image),
  477. captured_at=_spot(now, minutes),
  478. title=f"{channel.name} 人员检测 {person}",
  479. person_name=person,
  480. person_count=1,
  481. team_name=team,
  482. confidence=0.95,
  483. )
  484. rows.append(snapshot)
  485. by_key[f"track-{index + 1}"] = snapshot
  486. # 6. 局部风机:预警抓拍
  487. warn_samples = [
  488. ("fan7.png", "人员逗留", "张三", 4, {"area": "变频器区", "minutes": 9}),
  489. ("fan3.png", "非法闯入", "外来人员", 26, {"area": "配电柜区"}),
  490. ]
  491. for index, (image, warn_type, person, minutes, extra) in enumerate(warn_samples):
  492. channel = channels[local_fan_codes[index]]
  493. snapshot = _snapshot(
  494. mine_id=mine.id,
  495. channel=channel,
  496. snapshot_type="person_track",
  497. event_type=warn_type,
  498. level="danger" if warn_type == "非法闯入" else "warning",
  499. image_parts=("public", "images", image),
  500. captured_at=_spot(now, minutes),
  501. title=f"{channel.name} {warn_type} {person}",
  502. person_name=person,
  503. person_count=1,
  504. confidence=0.93,
  505. properties=dict(extra),
  506. )
  507. rows.append(snapshot)
  508. by_key[f"fan-warn-{index + 1}"] = snapshot
  509. # 7. 局部风机:设备操作抓拍
  510. fan_operations = [
  511. ("风机调频", "张三", "人员准入匹配"),
  512. ("切换主备", "未知人员", "未检测到人员"),
  513. ("参数调整", "李四", "人员准入匹配"),
  514. ("复位操作", "王五", "人员准入匹配"),
  515. ("风机调频", "赵六", "人员准入匹配"),
  516. ("参数调整", "外来人员", "未检测到人员"),
  517. ("切换主备", "陈七", "人员准入匹配"),
  518. ("风机调频", "周八", "人员准入匹配"),
  519. ]
  520. for index, (operation, operator, compare) in enumerate(fan_operations):
  521. channel = channels[local_fan_codes[index % len(local_fan_codes)]]
  522. snapshot = _snapshot(
  523. mine_id=mine.id,
  524. channel=channel,
  525. snapshot_type="device_operation",
  526. event_type=operation,
  527. level="info" if compare == "人员准入匹配" else "warning",
  528. image_parts=("public", "images", f"fan{index % 7 + 1}.png"),
  529. captured_at=_spot(now, 16 + index * 15),
  530. title=f"{channel.name} {operation} {operator}",
  531. person_name=operator if compare == "人员准入匹配" else "",
  532. confidence=0.97,
  533. properties={"operator": operator, "compare_result": compare},
  534. )
  535. rows.append(snapshot)
  536. by_key[f"fan-op-{index + 1}"] = snapshot
  537. # 8. 主风机:人员检测抓拍
  538. detect_samples = [
  539. ("f1.png", "VC-MFAN-01", "风机主体", "检测到人员", "正常", 10),
  540. ("f2.png", "VC-MFAN-02", "设备操作区", "检测到人员", "正常", 18),
  541. ("f3.png", "VC-MFAN-03", "风机主体", "检测到人员", "异常", 35),
  542. ("f1.png", "VC-MFAN-01", "风机主体", "检测到人员", "正常", 52),
  543. ("f2.png", "VC-MFAN-02", "设备操作区", "检测到人员", "正常", 70),
  544. ("f3.png", "VC-MFAN-04", "配电柜", "未检测到人员", "异常", 88),
  545. ]
  546. for index, (image, code, spot, result, status, minutes) in enumerate(detect_samples):
  547. channel = channels[code]
  548. snapshot = _snapshot(
  549. mine_id=mine.id,
  550. channel=channel,
  551. snapshot_type="person_detect",
  552. event_type="人员检测",
  553. level="info" if status == "正常" else "warning",
  554. image_parts=("public", "images", image),
  555. captured_at=_spot(now, minutes),
  556. title=f"{channel.name} {result}",
  557. confidence=0.94,
  558. properties={"location": spot, "detect_result": result, "device_status": status},
  559. )
  560. rows.append(snapshot)
  561. by_key[f"detect-{index + 1}"] = snapshot
  562. # 9. 主风机:设备操作与预警抓拍
  563. main_operations = [
  564. ("启动风机", "一号主风机", "PLC操作匹配", 22),
  565. ("参数调整", "主扇配电室", "PLC操作匹配", 44),
  566. ("停止风机", "二号主风机", "未检测到人员", 76),
  567. ("巡检操作", "主扇风硐监测点", "PLC操作匹配", 98),
  568. ]
  569. for index, (operation, device, compare, minutes) in enumerate(main_operations):
  570. channel = channels[main_fan_codes[index % len(main_fan_codes)]]
  571. snapshot = _snapshot(
  572. mine_id=mine.id,
  573. channel=channel,
  574. snapshot_type="device_operation",
  575. event_type=operation,
  576. level="info" if compare == "PLC操作匹配" else "warning",
  577. image_parts=("public", "images", f"f{index % 3 + 1}.png"),
  578. captured_at=_spot(now, minutes),
  579. title=f"{device} {operation}",
  580. confidence=0.96,
  581. properties={"device": device, "compare_result": compare},
  582. )
  583. rows.append(snapshot)
  584. by_key[f"main-op-{index + 1}"] = snapshot
  585. main_warns = [
  586. ("f2.png", "VC-MFAN-01", "设备断网", 40),
  587. ("f3.png", "VC-MFAN-02", "设备断网", 92),
  588. ]
  589. for index, (image, code, warn_type, minutes) in enumerate(main_warns):
  590. channel = channels[code]
  591. snapshot = _snapshot(
  592. mine_id=mine.id,
  593. channel=channel,
  594. snapshot_type="device_warn",
  595. event_type=warn_type,
  596. level="warning",
  597. image_parts=("public", "images", image),
  598. captured_at=_spot(now, minutes),
  599. title=f"{channel.name} {warn_type}",
  600. confidence=0.9,
  601. properties={"warn_type": warn_type},
  602. )
  603. rows.append(snapshot)
  604. by_key[f"main-warn-{index + 1}"] = snapshot
  605. db.add_all(rows)
  606. db.flush()
  607. return by_key
  608. def _seed_records(
  609. db: Session,
  610. mine: Mine,
  611. channels: dict[str, VideoChannel],
  612. snapshots: dict[str, VideoSnapshot],
  613. now: datetime,
  614. ) -> list[VideoRecord]:
  615. rows: list[VideoRecord] = []
  616. door_codes = [f"VC-DOOR-{index:02d}" for index in range(1, 6)]
  617. # 车辆通行记录
  618. vehicle_plates = [
  619. ("蒙K3571", 12), ("蒙K6829", 29), ("蒙K1122", 46), ("蒙K3344", 63),
  620. ("蒙K5566", 80), ("蒙K7719", 97), ("蒙K2288", 114), ("蒙K9966", 131),
  621. ("蒙K4408", 148), ("蒙K5533", 165), ("蒙K8842", 182), ("蒙K6317", 199),
  622. ]
  623. for index, (plate, minutes) in enumerate(vehicle_plates):
  624. channel = channels[door_codes[index % len(door_codes)]]
  625. snapshot = snapshots.get(f"vehicle-{index + 1}")
  626. rows.append(
  627. VideoRecord(
  628. id=new_id(),
  629. mine_id=mine.id,
  630. channel_id=channel.id,
  631. snapshot_id=snapshot.id if snapshot else None,
  632. module=VIDEO_MODULE_DOOR,
  633. record_type="vehicle_passage",
  634. device_name=channel.name,
  635. location=channel.location,
  636. plate_no=plate,
  637. person_count=0,
  638. result="通行成功",
  639. device_status="正常",
  640. occurred_at=_spot(now, minutes),
  641. properties={},
  642. )
  643. )
  644. # 人员通行记录
  645. person_rows = [
  646. ("张三、李四", 2, "掘进一队", 20, "正常通行"),
  647. ("王五", 1, "机电队", 38, "正常通行"),
  648. ("赵六、陈七", 2, "回采一队", 55, "正常通行"),
  649. ("周八", 1, "机电队", 72, "正常通行"),
  650. ("吴九", 1, "维修队", 90, "正常通行"),
  651. ("郑十、刘一", 2, "掘进二队", 108, "正常通行"),
  652. ("孙十一", 1, "通风队", 126, "正常通行"),
  653. ("马十二", 1, "维修队", 145, "正常通行"),
  654. ]
  655. for index, (person, count, team, minutes, result) in enumerate(person_rows):
  656. channel = channels[door_codes[index % len(door_codes)]]
  657. snapshot = snapshots.get(f"person-{index + 1}")
  658. rows.append(
  659. VideoRecord(
  660. id=new_id(),
  661. mine_id=mine.id,
  662. channel_id=channel.id,
  663. snapshot_id=snapshot.id if snapshot else None,
  664. module=VIDEO_MODULE_DOOR,
  665. record_type="person_passage",
  666. device_name=channel.name,
  667. location=channel.location,
  668. person_name=person,
  669. person_count=count,
  670. team_name=team,
  671. result=result,
  672. device_status="正常",
  673. occurred_at=_spot(now, minutes),
  674. properties={},
  675. )
  676. )
  677. # 风门未关严记录
  678. gap_rows = [
  679. (15, 5), (10, 24), (12, 43), (8, 60), (9, 78), (11, 96), (14, 115), (7, 133), (16, 152)
  680. ]
  681. for index, (gap, minutes) in enumerate(gap_rows):
  682. channel = channels[door_codes[index % len(door_codes)]]
  683. snapshot = snapshots.get(f"unclosed-{index + 1}")
  684. rows.append(
  685. VideoRecord(
  686. id=new_id(),
  687. mine_id=mine.id,
  688. channel_id=channel.id,
  689. snapshot_id=snapshot.id if snapshot else None,
  690. module=VIDEO_MODULE_DOOR,
  691. record_type="door_unclosed",
  692. device_name=channel.name,
  693. location=channel.location,
  694. gap_mm=float(gap),
  695. level="严重" if gap >= 15 else "一般",
  696. device_status="异常",
  697. occurred_at=_spot(now, minutes),
  698. properties={"threshold_mm": 10},
  699. )
  700. )
  701. # 局部风机:人员轨迹(最后两条未离开,用于“实时人数”)
  702. local_fan_codes = [f"VC-LFAN-{index:02d}" for index in range(1, 6)]
  703. track_rows = [
  704. ("张三", "机电队", "变频器区", 96, 96 - 8, 500, None),
  705. ("李四", "维修队", "配电柜区", 84, 84 - 5, 310, None),
  706. ("王五", "掘进一队", "风机本体区", 210, 200, 600, None),
  707. ("赵六", "掘进二队", "接线盒区", 260, 250, 480, None),
  708. ("陈七", "回采一队", "散热区", 330, 322, 420, None),
  709. ("周八", "机电队", "变频器区", 380, 372, 360, None),
  710. ("洪七", "维修队", "配电柜区", 24, None, None, "onsite"),
  711. ("孙九", "机电队", "风机本体区", 14, None, None, "onsite"),
  712. ]
  713. for index, (person, team, area, enter_minutes, leave_minutes, stay, onsite) in enumerate(
  714. track_rows
  715. ):
  716. channel = channels[local_fan_codes[index % len(local_fan_codes)]]
  717. snapshot = snapshots.get(f"track-{index + 1}")
  718. rows.append(
  719. VideoRecord(
  720. id=new_id(),
  721. mine_id=mine.id,
  722. channel_id=channel.id,
  723. snapshot_id=snapshot.id if snapshot else None,
  724. module=VIDEO_MODULE_LOCAL_FAN,
  725. record_type="person_track",
  726. device_name=channel.name,
  727. area_name=area,
  728. location=channel.location,
  729. person_name=person,
  730. person_count=1,
  731. team_name=team,
  732. enter_time=_spot(now, enter_minutes),
  733. leave_time=_spot(now, leave_minutes) if leave_minutes else None,
  734. stay_seconds=stay,
  735. device_status="在场" if onsite else "已离开",
  736. occurred_at=_spot(now, enter_minutes),
  737. properties={"stay_text": _stay_text(stay)},
  738. )
  739. )
  740. # 局部风机:区域停留统计
  741. area_rows = [
  742. ("变频器区", 2, "8分20秒", "3分45秒", "6分02秒", "12分05秒"),
  743. ("配电柜区", 1, "5分10秒", "5分10秒", "5分10秒", "5分10秒"),
  744. ("风机本体区", 0, "0秒", "0秒", "0秒", "0秒"),
  745. ("接线盒区", 0, "0秒", "0秒", "0秒", "0秒"),
  746. ("散热区", 0, "0秒", "0秒", "0秒", "0秒"),
  747. ]
  748. for index, (area, count, max_s, min_s, avg_s, total_s) in enumerate(area_rows):
  749. channel = channels[local_fan_codes[index % len(local_fan_codes)]]
  750. rows.append(
  751. VideoRecord(
  752. id=new_id(),
  753. mine_id=mine.id,
  754. channel_id=channel.id,
  755. snapshot_id=None,
  756. module=VIDEO_MODULE_LOCAL_FAN,
  757. record_type="area_stay",
  758. device_name=channel.name,
  759. area_name=area,
  760. location=channel.location,
  761. person_count=count,
  762. occurred_at=_spot(now, 60),
  763. properties={
  764. "max_stay": max_s,
  765. "min_stay": min_s,
  766. "avg_stay": avg_s,
  767. "total_stay": total_s,
  768. },
  769. )
  770. )
  771. # 局部风机:预警记录
  772. fan_warn_rows = [
  773. ("person_warn", "张三", "变频器区", "人员逗留", 30, "处理中"),
  774. ("person_warn", "外来人员", "配电柜区", "非法闯入", 26, "未处理"),
  775. ("person_warn", "李四", "配电柜区", "人员逗留", 120, "已处理"),
  776. ]
  777. for index, (record_type, person, area, warn_type, minutes, handle) in enumerate(
  778. fan_warn_rows
  779. ):
  780. channel = channels[local_fan_codes[index % len(local_fan_codes)]]
  781. snapshot = snapshots.get(f"fan-warn-{index + 1}")
  782. rows.append(
  783. VideoRecord(
  784. id=new_id(),
  785. mine_id=mine.id,
  786. channel_id=channel.id,
  787. snapshot_id=snapshot.id if snapshot else None,
  788. module=VIDEO_MODULE_LOCAL_FAN,
  789. record_type=record_type,
  790. device_name=channel.name,
  791. area_name=area,
  792. location=channel.location,
  793. person_name=person,
  794. person_count=1,
  795. warn_type=warn_type,
  796. handle_status=handle,
  797. occurred_at=_spot(now, minutes),
  798. properties={"region": area},
  799. )
  800. )
  801. # 主风机:人员检测记录
  802. detect_rows = [
  803. ("VC-MFAN-01", "风机主体", "检测到人员", "正常", 10),
  804. ("VC-MFAN-02", "设备操作区", "检测到人员", "正常", 18),
  805. ("VC-MFAN-03", "风机主体", "检测到人员", "异常", 35),
  806. ("VC-MFAN-01", "风机主体", "检测到人员", "正常", 52),
  807. ("VC-MFAN-02", "设备操作区", "检测到人员", "正常", 70),
  808. ("VC-MFAN-04", "配电柜", "未检测到人员", "异常", 88),
  809. ]
  810. for index, (code, spot, result, device_status, minutes) in enumerate(detect_rows):
  811. channel = channels[code]
  812. snapshot = snapshots.get(f"detect-{index + 1}")
  813. rows.append(
  814. VideoRecord(
  815. id=new_id(),
  816. mine_id=mine.id,
  817. channel_id=channel.id,
  818. snapshot_id=snapshot.id if snapshot else None,
  819. module=VIDEO_MODULE_MAIN_FAN,
  820. record_type="person_detect",
  821. device_name=channel.name,
  822. location=spot,
  823. result=result,
  824. device_status=device_status,
  825. occurred_at=_spot(now, minutes),
  826. properties={},
  827. )
  828. )
  829. # 主风机:设备操作日志
  830. main_op_rows = [
  831. ("VC-MFAN-01", "一号主风机", "启动风机", "PLC操作匹配", "正常", 22),
  832. ("VC-MFAN-02", "主扇配电室", "参数调整", "PLC操作匹配", "正常", 44),
  833. ("VC-MFAN-03", "二号主风机", "停止风机", "未检测到人员", "异常", 76),
  834. ("VC-MFAN-05", "主扇风硐监测点", "巡检操作", "PLC操作匹配", "正常", 98),
  835. ("VC-MFAN-01", "一号主风机", "参数调整", "PLC操作匹配", "正常", 150),
  836. ("VC-MFAN-04", "主扇配电室", "巡检操作", "PLC操作匹配", "正常", 180),
  837. ]
  838. for index, (code, device, operation, compare, status, minutes) in enumerate(
  839. main_op_rows
  840. ):
  841. channel = channels[code]
  842. snapshot = snapshots.get(f"main-op-{index + 1}")
  843. rows.append(
  844. VideoRecord(
  845. id=new_id(),
  846. mine_id=mine.id,
  847. channel_id=channel.id,
  848. snapshot_id=snapshot.id if snapshot else None,
  849. module=VIDEO_MODULE_MAIN_FAN,
  850. record_type="device_operation",
  851. device_name=device,
  852. location=channel.location,
  853. operation_type=operation,
  854. compare_result=compare,
  855. device_status=status,
  856. occurred_at=_spot(now, minutes),
  857. properties={},
  858. )
  859. )
  860. # 主风机:设备预警记录
  861. main_warn_rows = [
  862. ("VC-MFAN-01", "风机主体", "设备断网", 40, "已处理"),
  863. ("VC-MFAN-02", "配电柜", "设备断网", 92, "处理中"),
  864. ("VC-MFAN-01", "风机主体", "轴温异常", 130, "未处理"),
  865. ("VC-MFAN-05", "风硐", "巡检缺失", 200, "已处理"),
  866. ]
  867. for index, (code, spot, warn_type, minutes, handle) in enumerate(main_warn_rows):
  868. channel = channels[code]
  869. snapshot = snapshots.get(f"main-warn-{index + 1}") if index < 2 else None
  870. rows.append(
  871. VideoRecord(
  872. id=new_id(),
  873. mine_id=mine.id,
  874. channel_id=channel.id,
  875. snapshot_id=snapshot.id if snapshot else None,
  876. module=VIDEO_MODULE_MAIN_FAN,
  877. record_type="device_warn",
  878. device_name=channel.name,
  879. location=spot,
  880. warn_type=warn_type,
  881. handle_status=handle,
  882. occurred_at=_spot(now, minutes),
  883. properties={},
  884. )
  885. )
  886. db.add_all(rows)
  887. db.flush()
  888. return rows
  889. def _seed_events(
  890. db: Session,
  891. mine: Mine,
  892. channels: dict[str, VideoChannel],
  893. snapshots: dict[str, VideoSnapshot],
  894. now: datetime,
  895. ) -> list[VideoAnalysisEvent]:
  896. rows: list[VideoAnalysisEvent] = []
  897. door_events = [
  898. ("VC-DOOR-02", "AI防夹人预警", "danger", "危险区域有人闯入,已触发PLC停止", 6, "pending", "analysis-1"),
  899. ("VC-DOOR-04", "风门未关严", "warning", "门板闭合缝隙15mm,超标10mm", 22, "processing", "unclosed-1"),
  900. ("VC-DOOR-01", "违规停留预警", "info", "禁停区有车辆滞留3分钟,未及时驶离", 41, "handled", "analysis-7"),
  901. ("VC-DOOR-03", "密封失效故障", "warning", "疑似风门密封胶条脱落,存在漏风风险", 58, "pending", "analysis-5"),
  902. ("VC-DOOR-05", "车辆撞击故障", "warning", "风门门体疑似受到轻微撞击,需检查变形", 75, "processing", "analysis-9"),
  903. ("VC-DOOR-06", "异物卡堵预警", "warning", "门体通道检测到石块异物,影响闭合", 94, "pending", "analysis-3"),
  904. ("VC-DOOR-07", "AI防夹人预警", "danger", "风门闭合区检测到人员肢体,紧急止停", 118, "handled", "analysis-2"),
  905. ("VC-DOOR-03", "风门未关严", "warning", "门板闭合缝隙16mm,超标11mm", 140, "pending", "unclosed-9"),
  906. ("VC-DOOR-08", "违规停留预警", "info", "禁停区有人员逗留,已语音提醒", 165, "ignored", "analysis-8"),
  907. ("VC-DOOR-06", "异物卡堵预警", "warning", "风门轨道有杂物卡滞", 190, "handled", "analysis-4"),
  908. ]
  909. for code, event_type, level, description, minutes, status, snap_key in door_events:
  910. channel = channels[code]
  911. snapshot = snapshots.get(snap_key)
  912. rows.append(
  913. _event(
  914. mine=mine,
  915. channel=channel,
  916. snapshot=snapshot,
  917. event_type=event_type,
  918. category="风门",
  919. level=level,
  920. title=f"{channel.name} {event_type}",
  921. description=description,
  922. status=status,
  923. minutes=minutes,
  924. now=now,
  925. confidence=0.93,
  926. )
  927. )
  928. fan_events = [
  929. ("VC-LFAN-01", "人员逗留预警", "warning", "张三维修队变频器区停留超8分钟", 30, "processing", "fan-warn-1"),
  930. ("VC-LFAN-02", "非法闯入预警", "danger", "外来人员进入配电柜区", 26, "pending", "fan-warn-2"),
  931. ("VC-LFAN-04", "未检测到人员", "danger", "3号风机切换主备未检测到操作人员", 70, "pending", "fan-op-2"),
  932. ("VC-LFAN-05", "风机离线预警", "info", "4号局部风机通讯中断", 210, "handled", "fan-op-6"),
  933. ("VC-LFAN-03", "人员超时逗留", "warning", "人员配电柜区停留时间超限", 122, "handled", "track-3"),
  934. ]
  935. for code, event_type, level, description, minutes, status, snap_key in fan_events:
  936. channel = channels[code]
  937. snapshot = snapshots.get(snap_key)
  938. rows.append(
  939. _event(
  940. mine=mine,
  941. channel=channel,
  942. snapshot=snapshot,
  943. event_type=event_type,
  944. category="局部通风",
  945. level=level,
  946. title=f"{channel.name} {event_type}",
  947. description=description,
  948. status=status,
  949. minutes=minutes,
  950. now=now,
  951. confidence=0.91,
  952. )
  953. )
  954. main_events = [
  955. ("VC-MFAN-01", "设备断网", "warning", "一号主风机通讯中断5分钟", 40, "handled", "main-warn-1"),
  956. ("VC-MFAN-03", "未检测到人员", "danger", "二号主风机停止风机操作未检测到人员", 76, "pending", "main-op-3"),
  957. ("VC-MFAN-04", "设备断网", "warning", "主扇配电室网络抖动", 92, "processing", "main-warn-2"),
  958. ("VC-MFAN-01", "轴温异常", "warning", "一号主风机轴承温度 78℃ 超限", 130, "pending", "detect-3"),
  959. ("VC-MFAN-05", "巡检缺失", "info", "风硐监测点巡检记录缺失", 200, "ignored", None),
  960. ]
  961. for code, event_type, level, description, minutes, status, snap_key in main_events:
  962. channel = channels[code]
  963. snapshot = snapshots.get(snap_key or "")
  964. rows.append(
  965. _event(
  966. mine=mine,
  967. channel=channel,
  968. snapshot=snapshot,
  969. event_type=event_type,
  970. category="主通风机",
  971. level=level,
  972. title=f"{channel.name} {event_type}",
  973. description=description,
  974. status=status,
  975. minutes=minutes,
  976. now=now,
  977. confidence=0.92,
  978. )
  979. )
  980. db.add_all(rows)
  981. db.flush()
  982. return rows
  983. def _event(
  984. *,
  985. mine: Mine,
  986. channel: VideoChannel,
  987. snapshot: VideoSnapshot | None,
  988. event_type: str,
  989. category: str,
  990. level: str,
  991. title: str,
  992. description: str,
  993. status: str,
  994. minutes: int,
  995. now: datetime,
  996. confidence: float,
  997. ) -> VideoAnalysisEvent:
  998. occurred_at = _spot(now, minutes)
  999. handled_at = None
  1000. handled_by = ""
  1001. if status in {"handled", "ignored"}:
  1002. handled_at = min(occurred_at + timedelta(minutes=8), now)
  1003. handled_by = "通防科值班员"
  1004. return VideoAnalysisEvent(
  1005. id=new_id(),
  1006. mine_id=mine.id,
  1007. channel_id=channel.id,
  1008. snapshot_id=snapshot.id if snapshot else None,
  1009. module=channel.module,
  1010. event_type=event_type,
  1011. category=category,
  1012. level=level,
  1013. title=title,
  1014. description=description,
  1015. confidence=confidence,
  1016. status=status,
  1017. occurred_at=occurred_at,
  1018. handled_at=handled_at,
  1019. handled_by=handled_by,
  1020. handle_remark="已通知现场复核" if handled_at else "",
  1021. properties={"source": "video_ai"},
  1022. )
  1023. def _stay_text(stay_seconds: int | None) -> str:
  1024. if not stay_seconds:
  1025. return ""
  1026. minutes, seconds = divmod(int(stay_seconds), 60)
  1027. return f"{minutes}分{seconds:02d}秒"