提交修改代码

This commit is contained in:
zhengsl 2026-09-04 18:16:14 +08:00
parent 731a3f8290
commit 9b2948e6c7
79 changed files with 5771 additions and 446 deletions

12
.gitignore vendored
View File

@ -37,7 +37,6 @@ monitor.spec
static/storage/*
framework/__pycache__/*
app/__pycache__/*
app/migrations/*
app/migrations/__pycache__/*
app/comms/__pycache__/*
app/utils/__pycache__/*
@ -49,3 +48,14 @@ app/recording/__pycache__/*
# logs / docs / external services
docs/*
log/*
# Windows packaging and private issuer material
.build-venv/
.windows-build/
vendor-private/
_apply_windows.py
# offline report build artifacts & portable render runtime (regenerable / one-off)
report_assets/
report_render_runtime/
系统部署性能分析评估报告.docx

View File

@ -1,3 +1,5 @@
Windows EXE 安装、离线授权与可重复构建见 [部署说明](deploy/windows/README.md)。
多路视频接入与智能布控分析平台。支持 GB28181 / RTSP、YOLO 小模型检测、OpenAI 兼容大模型复核、多边形布控与结构化报警。
@ -59,6 +61,14 @@ pip install -r requirements-linux.txt -i https://pypi.tuna.tsinghua.edu.cn/simpl
pip install -r requirements-linux.txt
```
NVIDIA RTX 显卡可在基础依赖安装完成后启用本地 CUDA 推理:
```powershell
powershell -ExecutionPolicy Bypass -File scripts/enable-gpu.ps1
```
启用后在“小模型管理”中将 YOLO PyTorch 模型的推理设备设为 `CUDA`。共享推理 worker 默认设为 1避免同一模型重复占用显存。
---
## 快速开始

19
Ultralytics/settings.json Normal file
View File

@ -0,0 +1,19 @@
{
"settings_version": "0.0.8",
"datasets_dir": "D:\\00_Workspace\\python\\datasets",
"weights_dir": "D:\\00_Workspace\\python\\video_monitor\\weights",
"runs_dir": "D:\\00_Workspace\\python\\video_monitor\\runs",
"uuid": "5a5a041af8f3f74887c6bd745669f28e7ad18f074277d0c9a5abd22add25caf9",
"sync": true,
"api_key": "",
"openai_api_key": "",
"clearml": true,
"comet": true,
"dvc": true,
"mlflow": true,
"raytune": true,
"tensorboard": false,
"wandb": false,
"vscode_msg": true,
"openvino_msg": true
}

View File

@ -40,7 +40,7 @@ class BaseEngine(object):
def __init__(self, model_file=None, labels=None, input_size=(640, 640),
conf_threshold=0.4, iou_threshold=0.5, providers=None,
algorithm_type="yolo", algorithm_version="",
task_type="detect", device="cpu"):
task_type="detect", device="cpu", target_labels=None):
self.model_file = model_file or ""
self.labels = labels or []
self.input_size = input_size
@ -51,6 +51,7 @@ class BaseEngine(object):
self.algorithm_version = algorithm_version
self.task_type = (task_type or "detect").lower()
self.device = device or "cpu"
self.target_labels = list(target_labels or [])
self._loaded = False
@staticmethod

View File

@ -70,6 +70,8 @@ class YoloPytorchEngine(BaseEngine):
self._model = None
self.task_type = (kwargs.get("task_type") or "detect").lower()
self.device = kwargs.get("device") or "cpu"
self._target_classes = None
self._effective_device = "cpu"
@staticmethod
def is_available():
@ -108,6 +110,12 @@ class YoloPytorchEngine(BaseEngine):
self.labels = self._resolve_labels(self.model_file)
try:
self._model = _UltralyticsYOLO(self.model_file, task=self.task_type)
if self.target_labels:
names = getattr(self._model, "names", {}) or {}
if isinstance(names, list):
names = dict(enumerate(names))
wanted = set(str(x) for x in self.target_labels)
self._target_classes = [int(cid) for cid, name in names.items() if str(name) in wanted]
# 推断 input_size
try:
cfg = getattr(self._model, "overrides", {}) or {}
@ -122,9 +130,18 @@ class YoloPytorchEngine(BaseEngine):
# 预热(小尺寸 dummy让模型迁移到目标设备
try:
dev = _normalize_device(self.device, self.algorithm_type)
self._effective_device = dev
if dev != "cpu" and _TORCH_AVAILABLE:
torch.backends.cudnn.benchmark = True
try:
torch.set_float32_matmul_precision("high")
except Exception:
pass
dummy = np.zeros((self.input_size[1], self.input_size[0], 3), dtype=np.uint8)
self._model.predict(dummy, imgsz=max(self.input_size), device=dev,
conf=self.conf_threshold, iou=self.iou_threshold,
classes=self._target_classes,
quantize=16 if dev != "cpu" else None,
verbose=False, save=False)
except Exception as e:
logger.warning("YoloPytorchEngine 预热失败(忽略): %s", e)
@ -143,10 +160,12 @@ class YoloPytorchEngine(BaseEngine):
if not self.ready() or frame_bgr is None or self._model is None:
return []
try:
dev = _normalize_device(self.device, self.algorithm_type)
dev = self._effective_device or _normalize_device(self.device, self.algorithm_type)
iw, ih = self.input_size
results = self._model.predict(frame_bgr, imgsz=max(iw, ih), device=dev,
conf=self.conf_threshold, iou=self.iou_threshold,
classes=self._target_classes,
quantize=16 if dev != "cpu" else None,
verbose=False, save=False)
return self._parse_results(results)
except Exception as e:
@ -237,7 +256,7 @@ class YoloPytorchEngine(BaseEngine):
d["version"] = self.version()
d["ultralytics_version"] = self.ultralytics_version()
d["task_type"] = self.task_type
d["device"] = _normalize_device(self.device, self.algorithm_type)
d["device"] = self._effective_device
d["cuda_available"] = bool(_TORCH_AVAILABLE and torch.cuda.is_available())
return d

View File

@ -15,6 +15,7 @@ class AnalysisEventBridge(object):
self._running = False
self._queues = []
self._lock = threading.Lock()
self._latest = {}
def register_queue(self, q):
with self._lock:
@ -59,6 +60,8 @@ class AnalysisEventBridge(object):
self._on_event(payload)
elif kind == "touch":
self._on_touch(payload)
elif kind == "preview":
self._on_preview(payload)
except Exception as e:
logger.exception("事件桥处理失败: %s", e)
@ -72,6 +75,22 @@ class AnalysisEventBridge(object):
# 已停用:不再写追踪快照
pass
def _on_preview(self, payload):
stream_id = payload.get("stream_id")
if stream_id is None:
return
with self._lock:
self._latest[int(stream_id)] = payload
def latest_preview(self, stream_id):
with self._lock:
value = self._latest.get(int(stream_id))
return dict(value) if value else None
def clear_preview(self, stream_id):
with self._lock:
self._latest.pop(int(stream_id), None)
def get_event_bridge():
global _BRIDGE

View File

@ -67,6 +67,7 @@ def _inference_worker_loop(req_queue, resp_queue, worker_id):
int(algo.get("input_height", 640)),
algo.get("task_type", "detect"),
algo.get("device", "cpu"),
tuple(sorted(algo.get("target_labels") or [])),
)
eng = engines.get(key)
if eng is None:
@ -87,6 +88,7 @@ def _inference_worker_loop(req_queue, resp_queue, worker_id):
algorithm_type=algo.get("algorithm_type", "yolo8"),
task_type=algo.get("task_type", "detect"),
device=algo.get("device", "cpu"),
target_labels=algo.get("target_labels") or [],
)
if not eng.load():
resp_queue.put({"req_id": req_id, "ok": False, "error": "engine load failed"})
@ -190,13 +192,15 @@ class InferenceProcessPool(object):
self.stop()
self.start()
def detect_jpeg(self, jpeg, algorithm, timeout=30.0):
def detect_jpeg(self, jpeg, algorithm, timeout=30.0, raise_errors=False):
"""同步推理:直接传已编码的 JPEG bytes跳过主进程 imencode/imdecode
消除 _inference_forwarder_loop 中的双重编解码与主进程 GIL 占用"""
self._ensure_workers_alive()
if not self._running:
self.start()
if not jpeg:
if raise_errors:
raise ValueError("no frame")
return []
req_id = str(uuid.uuid4())
evt = {"event": threading.Event(), "resp": None}
@ -210,6 +214,8 @@ class InferenceProcessPool(object):
with self._lock:
self._pending.pop(req_id, None)
logger.warning("推理请求入队失败: %s", e)
if raise_errors:
raise RuntimeError("推理请求入队失败: %s" % e) from e
return []
if not evt["event"].wait(timeout=timeout):
@ -218,10 +224,14 @@ class InferenceProcessPool(object):
self._timeout_count += 1
self._last_timeout_ts = time.time()
logger.warning("推理超时 req_id=%s (累计 %d)", req_id, self._timeout_count)
if raise_errors:
raise TimeoutError("模型推理超时(%ss" % timeout)
return []
resp = evt.get("resp") or {}
if not resp.get("ok"):
logger.warning("推理失败: %s", resp.get("error"))
if raise_errors:
raise RuntimeError(resp.get("error") or "模型推理失败")
return []
return resp.get("detections") or []

View File

@ -8,6 +8,7 @@ import logging
import multiprocessing as mp
import threading
import time
import uuid
from app.analysis.pipeline import CameraPipeline
from app.analysis.motion import MotionDetector
@ -28,7 +29,7 @@ def _snapshot_storage_paths():
return alarm_dir, static_dir
def _algorithm_to_spec(a):
def _algorithm_to_spec(a, target_labels=None):
labels = a.labels
if isinstance(labels, str):
try:
@ -48,6 +49,7 @@ def _algorithm_to_spec(a):
"algorithm_type": a.algorithm_type,
"task_type": getattr(a, "task_type", "detect"),
"device": getattr(a, "device", "cpu"),
"target_labels": sorted(set(target_labels or [])),
}
@ -110,12 +112,15 @@ class AnalysisManager(object):
self._status_manager = self._mp_ctx.Manager()
self._status_dict = self._status_manager.dict()
self._infer_req_q = self._mp_ctx.Queue(maxsize=128)
self._infer_resp_q = self._mp_ctx.Queue(maxsize=128)
# Never reuse a consumer queue after terminating a camera process:
# multiprocessing.Queue's read lock can remain acquired on Windows.
self._infer_routes = {}
self._infer_routes_lock = threading.Lock()
self._disabled_algos = set()
self._infer_forwarder_running = True
self._infer_forwarder = threading.Thread(
target=self._inference_forwarder_loop, name="infer-forwarder", daemon=True)
self._infer_forwarder.start()
self._disabled_algos = set() # 禁用实例化的业务算法 ID 集合(内存,重启丢失)
get_event_bridge()
self._configure_from_settings()
@ -252,7 +257,6 @@ class AnalysisManager(object):
def _inference_forwarder_loop(self):
from app.analysis.inference_pool import get_inference_pool
import queue as _q
pool = get_inference_pool()
while self._infer_forwarder_running:
try:
msg = self._infer_req_q.get(timeout=0.5)
@ -261,6 +265,10 @@ class AnalysisManager(object):
if msg is None:
break
req_id = msg.get("req_id")
channel = msg.get("response_channel")
with self._infer_routes_lock:
if channel not in self._infer_routes:
continue # The originating pipeline has already stopped.
try:
jpeg = msg.get("jpeg")
algo = msg.get("algorithm") or {}
@ -268,21 +276,35 @@ class AnalysisManager(object):
algo_id = algo.get("id", 0)
try:
if algo_id and int(algo_id) in self._disabled_algos:
self._infer_resp_q.put({"req_id": req_id, "ok": True, "detections": []})
self._send_inference_response(channel, {"req_id": req_id, "ok": True, "detections": []})
continue
except Exception:
pass
# 直接透传 JPEG bytes 给推理池,避免主进程 imdecode + imencode 双重编解码,
# 消除主进程 GIL 占用(解码在 worker 子进程内完成)。
dets = pool.detect_jpeg(jpeg, algo, timeout=30.0)
self._infer_resp_q.put({"req_id": req_id, "ok": True, "detections": dets})
pool = get_inference_pool() # Config changes may replace the pool.
dets = pool.detect_jpeg(jpeg, algo, timeout=30.0, raise_errors=True)
self._send_inference_response(channel, {"req_id": req_id, "ok": True, "detections": dets})
except Exception as e:
logger.warning("推理转发失败: %s", e)
try:
self._infer_resp_q.put({"req_id": req_id, "ok": False, "error": str(e)})
self._send_inference_response(channel, {"req_id": req_id, "ok": False, "error": str(e)})
except Exception:
pass
def _send_inference_response(self, channel, response):
with self._infer_routes_lock:
response_queue = self._infer_routes.get(channel)
if response_queue is not None:
response_queue.put(response, timeout=1.0)
def _close_inference_channel(self, item):
with self._infer_routes_lock:
response_queue = self._infer_routes.pop(item.get("response_channel"), None)
if response_queue is not None:
response_queue.cancel_join_thread()
response_queue.close()
def _configure_from_settings(self):
try:
from app.models import AlgorithmModel
@ -342,10 +364,15 @@ class AnalysisManager(object):
return 1.0
@staticmethod
def _load_zones(stream_id):
def _load_zones(stream_id, active_only=True, zone_id=None):
try:
from app.models import ZoneModel
qs = ZoneModel.objects.filter(stream_id=stream_id, state=1).prefetch_related(
qs = ZoneModel.objects.filter(stream_id=stream_id)
if active_only:
qs = qs.filter(state=1)
if zone_id is not None:
qs = qs.filter(id=int(zone_id))
qs = qs.prefetch_related(
'algorithms', 'algorithms__small_model', 'algorithms__detector_model', 'algorithms__llm')
zones = []
for z in qs:
@ -386,6 +413,8 @@ class AnalysisManager(object):
"loiter_threshold": z.loiter_threshold,
"detect_interval_sec": interval,
"detect_frames": frames,
"alarm_repeat_sec": max(0.0, float(getattr(z, "alarm_repeat_sec", 30) or 0)),
"color": z.color or "#169F85",
"line_a": line_a,
"line_b": line_b,
"density_threshold": int(getattr(z, "density_threshold", 0) or 0),
@ -447,16 +476,29 @@ class AnalysisManager(object):
logger.warning("config fallback engine err: %s" % str(e))
return None
def _start_process(self, stream, url, zones, algos, detectors_legacy=None):
def _start_process(self, stream, url, zones, algos, detectors_legacy=None,
preview_only=False, analyze_fps_override=None):
sid = stream.id
self._status_dict.pop(str(sid), None)
event_queue = self._mp_ctx.Queue(maxsize=256)
cmd_queue = self._mp_ctx.Queue(maxsize=16)
bridge = get_event_bridge()
bridge.register_queue(event_queue)
algo_specs = [_algorithm_to_spec(a) for a in algos]
analyze_fps = self._compute_analyze_fps(sid, fallback=self._target_fps)
target_by_model = {}
for zone in zones:
for rule in zone.get("biz_algorithms") or []:
model_id = rule.get("detector_model_id") if int(rule.get("flow_type") or 0) == 4 else rule.get("small_model_id")
if model_id:
target_by_model.setdefault(int(model_id), set()).update(rule.get("target_labels") or [])
algo_specs = [_algorithm_to_spec(a, target_by_model.get(int(a.id), set())) for a in algos]
analyze_fps = (float(analyze_fps_override) if analyze_fps_override is not None
else self._compute_analyze_fps(sid, fallback=self._target_fps))
storage_alarm_dir, static_dir = _snapshot_storage_paths()
response_channel = uuid.uuid4().hex
infer_resp_q = self._mp_ctx.Queue(maxsize=128)
with self._infer_routes_lock:
self._infer_routes[response_channel] = infer_resp_q
config = {
"stream_id": sid,
"stream_code": getattr(stream, "code", str(sid)),
@ -468,15 +510,22 @@ class AnalysisManager(object):
"use_shared_inference": self._use_shared_inference(),
"storage_alarm_dir": storage_alarm_dir,
"static_dir": static_dir,
"response_channel": response_channel,
"preview_only": bool(preview_only),
}
proc = self._mp_ctx.Process(
target=pipeline_process_main,
args=(config, event_queue, cmd_queue, self._status_dict,
self._infer_req_q, self._infer_resp_q),
self._infer_req_q, infer_resp_q),
name="pipeline-%s" % sid,
daemon=True,
)
try:
proc.start()
except Exception:
self._close_inference_channel({"response_channel": response_channel})
bridge.unregister_queue(event_queue)
raise
handle = PipelineProcessHandle(sid, proc, event_queue, cmd_queue, self._status_dict)
self._pipelines[sid] = {
"handle": handle,
@ -487,17 +536,29 @@ class AnalysisManager(object):
"pipeline": None,
"thread": None,
"algorithm_ids": sorted([a.id for a in algos]),
"preview_only": bool(preview_only),
"response_channel": response_channel,
"analyze_fps": analyze_fps,
}
return True, "started (process)"
def _start_thread(self, stream, url, zones, algos):
def _start_thread(self, stream, url, zones, algos, preview_only=False,
analyze_fps_override=None):
sid = stream.id
detectors = []
algo_names = []
for a in algos:
eng = self._worker_pool.get_detector(a)
labels = set()
for z in zones:
for rule in z.get("biz_algorithms") or []:
model_id = (rule.get("detector_model_id") if int(rule.get("flow_type") or 0) == 4
else rule.get("small_model_id"))
if int(model_id or 0) == int(a.id):
labels.update(rule.get("target_labels") or [])
eng = self._worker_pool.get_detector(_algorithm_to_spec(a, labels))
if eng:
detectors.append({"algorithm_id": a.id, "algorithm_name": a.name, "engine": eng})
detectors.append({"algorithm_id": a.id, "algorithm_name": a.name,
"engine": eng, "target_labels": sorted(labels)})
algo_names.append(a.name)
if not algos:
eng = self._fallback_engine_from_config()
@ -506,7 +567,8 @@ class AnalysisManager(object):
algo_names.append("config-fallback")
motion = MotionDetector()
analyze_fps = self._compute_analyze_fps(sid, fallback=self._target_fps)
analyze_fps = (float(analyze_fps_override) if analyze_fps_override is not None
else self._compute_analyze_fps(sid, fallback=self._target_fps))
storage_alarm_dir, static_dir = _snapshot_storage_paths()
pipeline = CameraPipeline(
stream_id=sid,
@ -518,6 +580,8 @@ class AnalysisManager(object):
analyze_fps=analyze_fps,
on_event=self._on_event,
on_track_snapshot=self._on_track_snapshot,
on_preview=lambda payload: get_event_bridge()._on_preview(payload),
alarm_enabled=not bool(preview_only),
zone_polygons=zones,
storage_alarm_dir=storage_alarm_dir,
static_dir=static_dir,
@ -530,11 +594,15 @@ class AnalysisManager(object):
"running": True,
"mode": "thread",
"algorithm_ids": sorted([a.id for a in algos]),
"preview_only": bool(preview_only),
"analyze_fps": analyze_fps,
}
t.start()
return True, "started (thread)"
def start(self, stream):
from monitor_runtime.licensing import require_license
require_license()
sid = stream.id
with self._lock:
item = self._pipelines.get(sid)
@ -547,10 +615,15 @@ class AnalysisManager(object):
th = item.get("thread")
alive = th is not None and th.is_alive()
if alive:
if not item.get("preview_only"):
return True, "already running"
# 正式启动接管由预览创建的临时管线。
self.stop(sid)
item = None
# 僵尸条目:进程/线程已退出但未清理
try:
if item.get("mode") == "process":
self._close_inference_channel(item)
eq = item.get("event_queue")
if eq:
get_event_bridge().unregister_queue(eq)
@ -577,6 +650,78 @@ class AnalysisManager(object):
return False, "analysis subprocess exited (check OpenCV / RTSP / log)"
return ok, msg
def start_preview(self, zone):
"""确保所选布控有检测管线;停用布控使用不写报警的临时管线。"""
from monitor_runtime.licensing import require_license
require_license()
stream = zone.stream
sid = stream.id
with self._lock:
item = self._pipelines.get(sid)
if self._is_pipeline_alive(item):
if not item.get("preview_only") and "preview_restore_fps" not in item:
base_fps = float(item.get("analyze_fps") or self._compute_analyze_fps(sid, 1))
item["preview_restore_fps"] = base_fps
boosted_fps = max(5.0, base_fps)
if item.get("mode") == "process" and item.get("handle"):
item["handle"].set_analyze_fps(boosted_fps)
elif item.get("pipeline"):
item["pipeline"].set_analyze_fps(boosted_fps)
return True, "preview" if item.get("preview_only") else "formal"
if item:
self._purge_pipeline(sid)
algos = []
seen = set()
for ba in zone.algorithms.filter(state=1).select_related("small_model", "detector_model"):
model = ba.detector_model if int(ba.flow_type or 0) == 4 else ba.small_model
if model and model.state == 1 and model.id not in seen:
seen.add(model.id)
algos.append(model)
if not algos:
return False, "布控未绑定可用的小模型"
zones = self._load_zones(sid, active_only=False, zone_id=zone.id)
url = self.build_rtsp_url(stream)
if not url:
return False, "no rtsp url"
fps = max(5.0, self._zone_analyze_fps(zone.detect_interval_sec, zone.detect_frames))
if self._use_multiprocess():
ok, msg = self._start_process(stream, url, zones, algos,
preview_only=True, analyze_fps_override=fps)
else:
ok, msg = self._start_thread(stream, url, zones, algos,
preview_only=True, analyze_fps_override=fps)
if ok:
time.sleep(0.35)
if not self.is_running(sid):
self._purge_pipeline(sid)
return False, "预览分析子进程启动失败,请检查视频流或模型日志"
return ok, msg
def stop_preview(self, stream_id):
"""只释放预览创建的管线,不影响正式布控。"""
with self._lock:
item = self._pipelines.get(int(stream_id))
if not item:
return False
if not item.get("preview_only"):
restore_fps = item.pop("preview_restore_fps", None)
if restore_fps is not None:
if item.get("mode") == "process" and item.get("handle"):
item["handle"].set_analyze_fps(restore_fps)
elif item.get("pipeline"):
item["pipeline"].set_analyze_fps(restore_fps)
return False
self.stop(int(stream_id))
get_event_bridge().clear_preview(int(stream_id))
return True
def preview_mode(self, stream_id):
with self._lock:
item = self._pipelines.get(int(stream_id))
if not self._is_pipeline_alive(item):
return "stopped"
return "preview" if item.get("preview_only") else "formal"
def stop(self, stream_id):
with self._lock:
item = self._pipelines.get(stream_id)
@ -586,6 +731,7 @@ class AnalysisManager(object):
handle = item.get("handle")
if handle:
handle.stop()
self._close_inference_channel(item)
eq = item.get("event_queue")
if eq:
get_event_bridge().unregister_queue(eq)
@ -630,6 +776,7 @@ class AnalysisManager(object):
handle.stop(timeout=1)
except Exception:
pass
self._close_inference_channel(item)
else:
pipe = item.get("pipeline")
if pipe:
@ -644,9 +791,9 @@ class AnalysisManager(object):
with self._lock:
alive = []
for sid, item in list(self._pipelines.items()):
if self._is_pipeline_alive(item):
if self._is_pipeline_alive(item) and not item.get("preview_only"):
alive.append(sid)
else:
elif not self._is_pipeline_alive(item):
self._purge_pipeline(sid)
return alive
@ -667,7 +814,8 @@ class AnalysisManager(object):
if health == "ok" and fps <= 0 and stalled >= 20:
info["stream_health"] = "stalled"
health = "stalled"
info["healthy"] = health == "ok" and fps > 0.05
info["healthy"] = health == "ok" and (
info.get("analysis_health") == "running" if "analysis_health" in info else fps > 0.05)
if not info.get("active_zone_ids") and self.is_running(stream_id):
try:
zones = self._load_zones(stream_id)
@ -785,3 +933,17 @@ class AnalysisManager(object):
if t in ("entered_zone", "left_zone", "object_start"):
return 2
return 3
def shutdown_analysis():
"""Stop existing resources without constructing a new pool."""
instance = AnalysisManager._instance
if instance and getattr(instance, '_initialized', False):
for sid in list(instance._pipelines):
instance.stop(sid)
instance._infer_forwarder_running = False
instance._infer_forwarder.join(timeout=3)
from app.analysis.inference_pool import shutdown_inference_pool
shutdown_inference_pool()
instance._status_manager.shutdown()
instance._initialized = False
AnalysisManager._instance = None

View File

@ -44,6 +44,8 @@ class CameraPipeline(object):
analyze_fps=None,
on_event=None,
on_track_snapshot=None,
on_preview=None,
alarm_enabled=True,
zone_polygons=None,
storage_alarm_dir=None,
static_dir=None,
@ -75,6 +77,8 @@ class CameraPipeline(object):
self._frame_source = FrameSource(rtsp_url, target_fps=target_fps)
self._on_event = on_event or (lambda *a, **kw: None)
self._on_track_snapshot = on_track_snapshot or (lambda *a, **kw: None)
self._on_preview = on_preview or (lambda *a, **kw: None)
self._alarm_enabled = bool(alarm_enabled)
self.zone_polygons = zone_polygons or []
# 解码/分析分离
@ -96,7 +100,11 @@ class CameraPipeline(object):
self._last_analyze_ts = 0.0
self._running = False
self._started_event = threading.Event()
self._analysis_error = ""
self._last_analysis_ts = 0.0
self._last_zone_state = {} # track_id -> set(zone_ids)
self._outside_zone_hits = {} # (track_id, zone_id) -> 连续明确在区域外次数
self._track_enter_ts = {} # (track_id, zone_id) -> ts滞留计时
self._last_w = 0
self._last_h = 0
@ -118,6 +126,7 @@ class CameraPipeline(object):
self._zone_config_warned = set()
self._start_ts = 0.0
self._last_frame_ts = 0.0
self._current_capture_ts = 0.0
self._update_detector_policy()
def _update_detector_policy(self):
@ -155,6 +164,7 @@ class CameraPipeline(object):
def reset_zone_runtime_state(self):
"""热更新/重启布控后清空运行时状态,使进入区域报警可重新触发。"""
self._last_zone_state = {}
self._outside_zone_hits = {}
self._track_enter_ts = {}
self._llm_zone_last_ts = {}
self._llm_track_last_ts = {}
@ -221,7 +231,7 @@ class CameraPipeline(object):
with self._queue_lock:
if len(self._frame_queue) >= self._frame_queue.maxlen:
self._dropped_count += 1
self._frame_queue.append(frame)
self._frame_queue.append((frame, self._last_frame_ts))
self._decoded_count += 1
self._fps_window_decoded += 1
except Exception as e:
@ -249,6 +259,7 @@ class CameraPipeline(object):
self._running = True
self._start_ts = time.time()
self._decode_running = True
self._started_event.set()
self._decode_thread = threading.Thread(
target=self._decode_loop, name="decode-%s" % self.stream_id, daemon=True)
self._decode_thread.start()
@ -256,26 +267,34 @@ class CameraPipeline(object):
% (self.stream_code, self.analyze_fps, self._analyze_interval, len(self._detectors)))
try:
while self._running:
frame = self._pop_latest_frame()
if frame is None:
packet = self._pop_latest_frame()
if packet is None:
time.sleep(0.02)
continue
if isinstance(packet, tuple) and len(packet) == 2:
frame, capture_ts = packet
else: # 兼容测试和旧调用
frame, capture_ts = packet, time.time()
# 跳帧节流:未到分析间隔则丢弃此帧
now = time.time()
if now - self._last_analyze_ts < self._analyze_interval:
continue
self._last_analyze_ts = now
self._frame_index += 1
self._analyzed_count += 1
self._fps_window_analyzed += 1
try:
self._last_h, self._last_w = frame.shape[:2]
except Exception:
pass
self._last_frame = frame
self._current_capture_ts = capture_ts
try:
self._process_frame(frame)
self._analysis_error = ""
self._last_analysis_ts = time.time()
self._analyzed_count += 1
self._fps_window_analyzed += 1
except Exception as e:
self._analysis_error = str(e)
logger.warning("pipeline[%s] 处理帧异常: %s" % (self.stream_code, str(e)))
self._refresh_fps()
except Exception as e:
@ -301,7 +320,7 @@ class CameraPipeline(object):
self._fps_window_decoded = 0
self._fps_window_ts = now
def _process_frame(self, frame):
def _process_frame(self, frame, capture_ts=None):
motion_boxes = self._motion.detect(frame)
has_motion = len(motion_boxes) > 0
run_detect = has_motion or self._force_detect
@ -313,22 +332,29 @@ class CameraPipeline(object):
for d in self._detectors:
eng = d.get("engine")
if not eng or not eng.ready():
continue
raise RuntimeError("检测器 %s 未就绪" % d.get("algorithm_name"))
try:
res = eng.detect(frame)
except Exception as e:
logger.warning("pipeline[%s] 检测器 %s 异常: %s"
% (self.stream_code, d.get("algorithm_name"), str(e)))
continue
raise
algo_id = d.get("algorithm_id")
algo_name = d.get("algorithm_name")
allowed = set(d.get("target_labels") or [])
for r in res:
if allowed and r.get("label") not in allowed:
continue
r["algorithm_id"] = algo_id
r["algorithm_name"] = algo_name
detections.extend(res)
detections.append(r)
# 无小模型检测器时不伪造 motion 目标流程2 仍由 _check_llm_zones 按业务规则报警
active, ended, new_tracks, frame_index = self._tracker.update(detections, self._frame_index)
processed_ts = time.time()
capture_ts = float(capture_ts or self._current_capture_ts or processed_ts)
active, ended, new_tracks, frame_index = self._tracker.update(
detections, self._frame_index, timestamp=capture_ts,
)
for tid in new_tracks:
tr = next((t for t in active if t["track_id"] == tid), None)
@ -360,7 +386,10 @@ class CameraPipeline(object):
"timestamp": time.time(),
})
self._check_zones(active, frame_index, frame)
# 未确认的中等置信度轨迹不会产生区域报警;第二次连续命中后才参与。
alarm_tracks = [tr for tr in active if tr.get("observed") and tr.get("confirmed")]
self._check_zones(alarm_tracks, frame_index, frame)
self._publish_preview(capture_ts, processed_ts)
if has_motion:
self._check_llm_zones(frame, motion_boxes)
@ -369,6 +398,8 @@ class CameraPipeline(object):
"""仅业务算法规则命中时:截图、生成描述、上报报警事件。"""
if not biz_rule:
return False
if not self._alarm_enabled:
return False
snap_path = self._save_alarm_snapshot(frame, tr, zone_cfg, event_type)
evt = {
"stream_id": self.stream_id,
@ -379,6 +410,9 @@ class CameraPipeline(object):
"label": (tr or {}).get("label", ""),
"timestamp": now,
"box": box,
"score": float((tr or {}).get("score", 0) or 0),
"frame_width": int(self._last_w or 0),
"frame_height": int(self._last_h or 0),
"snapshot_path": snap_path,
}
evt.update(extra)
@ -573,11 +607,11 @@ class CameraPipeline(object):
self._maybe_alert_line_count(frame, tr, zone_cfg, cr, direction, counts, tid, zid, box, now)
def _fire_area_alarms(self, tr, zid, zone_cfg, prev_zones, frame, box, now, tid):
"""区域入侵(AREA):进入时报警,停留期间按 detect_interval_sec 持续重复报警"""
"""区域入侵:首次进入立即报警,持续停留按独立重复间隔重报"""
matched_rules = self._matched_rules(tr, zone_cfg)
if not matched_rules:
return False
interval = max(0.1, float((zone_cfg or {}).get("detect_interval_sec", 1) or 1))
repeat_sec = max(0.0, float((zone_cfg or {}).get("alarm_repeat_sec", 30) or 0))
is_new = zid not in prev_zones
fired_any = False
flow3_rule = self._flow3_needs_llm(zone_cfg, tr)
@ -587,7 +621,7 @@ class CameraPipeline(object):
def _should_fire(biz_id):
key = (tid, zid, biz_id)
last = self._area_alarm_last_ts.get(key, 0)
return is_new or (now - last >= interval)
return is_new or (repeat_sec > 0 and now - last >= repeat_sec)
def _mark_fired(biz_id):
self._area_alarm_last_ts[(tid, zid, biz_id)] = now
@ -630,7 +664,11 @@ class CameraPipeline(object):
def _check_zones(self, active, frame_index, frame):
now = time.time()
h, w = self._frame_size(frame_index)
cur_state = {}
# 短暂缺检的轨迹保留原区域状态,但本帧不触发任何报警;只有重新观测到
# 明确在区域外两次,或轨迹超时结束,才判定离开。
retained_ids = {t.track_id for t in self._tracker.all_active()}
cur_state = {tid: set(zones) for tid, zones in self._last_zone_state.items()
if tid in retained_ids}
# 统计每个区域内的目标数(用于 DENSITY 后处理)
zone_density_count = {} # zone_id -> count
@ -638,7 +676,8 @@ class CameraPipeline(object):
tid = tr["track_id"]
box = tr["box"]
cx = (box[0] + box[2]) / 2
cy = (box[1] + box[3]) / 2
# 人员用人体框底部向上内缩 5% 的落脚点判断入区,避免框中心落在区域外。
cy = box[3] - (box[3] - box[1]) * 0.05 if tr.get("label") == "person" else (box[1] + box[3]) / 2
physical_in = set()
for z in self.zone_polygons:
coords = self._scale_zone(z.get("coords", []), w, h)
@ -649,6 +688,7 @@ class CameraPipeline(object):
confirmed = set(prev & physical_in)
for zid in physical_in:
self._outside_zone_hits.pop((tid, zid), None)
zone_cfg = next((z for z in self.zone_polygons if z.get("id") == zid), None)
if not self._should_alarm_track_in_zone(tr, zone_cfg):
logger.debug(
@ -661,6 +701,13 @@ class CameraPipeline(object):
confirmed.add(zid)
for zid in prev - physical_in:
outside_key = (tid, zid)
outside_hits = self._outside_zone_hits.get(outside_key, 0) + 1
self._outside_zone_hits[outside_key] = outside_hits
if outside_hits < 2:
confirmed.add(zid)
continue
self._outside_zone_hits.pop(outside_key, None)
self._on_event({
"stream_id": self.stream_id, "stream_code": self.stream_code,
"type": "left_zone", "track_id": tid, "zone_id": zid,
@ -807,6 +854,55 @@ class CameraPipeline(object):
self._direction_fired.discard(fk)
self._last_zone_state = cur_state
def _preview_inside_zones(self, tr):
h, w = self._last_h, self._last_w
box = tr.get("box") or []
if len(box) != 4 or not w or not h:
return []
x = (box[0] + box[2]) * 0.5
y = box[3] - (box[3] - box[1]) * 0.05 if tr.get("label") == "person" else (box[1] + box[3]) * 0.5
return [z.get("id") for z in self.zone_polygons
if self._point_in_polygon((x, y), self._scale_zone(z.get("coords", []), w, h))]
def _publish_preview(self, capture_ts, processed_ts=None):
"""只发布最新检测状态;事件桥覆盖旧快照,不落库、不累积帧。"""
if not self._last_w or not self._last_h:
return
tracks = []
processed_ts = float(processed_ts or time.time())
for tr in self._tracker.display_tracks(capture_ts):
box = tr.get("box") or []
if len(box) != 4:
continue
item = dict(tr)
item["box"] = [max(0.0, min(1.0, box[0] / self._last_w)),
max(0.0, min(1.0, box[1] / self._last_h)),
max(0.0, min(1.0, box[2] / self._last_w)),
max(0.0, min(1.0, box[3] / self._last_h))]
velocity = item.get("velocity") or [0, 0, 0, 0]
item["velocity"] = [float(velocity[0]) / self._last_w,
float(velocity[1]) / self._last_h,
float(velocity[2]) / self._last_w,
float(velocity[3]) / self._last_h]
item["inside_zone_ids"] = self._preview_inside_zones(tr)
tracks.append(item)
self._on_preview({
"stream_id": self.stream_id,
"sequence": self._frame_index,
"timestamp": capture_ts,
"processed_timestamp": processed_ts,
"inference_latency": max(0.0, processed_ts - capture_ts),
"frame_width": self._last_w,
"frame_height": self._last_h,
"analysis_fps": self._analysis_fps,
"model_names": [d.get("algorithm_name", "") for d in self._detectors],
"model_devices": [d.get("device", "cpu") for d in self._detectors],
"tracks": tracks,
"zones": [{"id": z.get("id"), "name": z.get("name", ""),
"color": z.get("color", "#16a085"), "coords": z.get("coords") or []}
for z in self.zone_polygons],
})
def _cross_line(self, prev_pt, cur_pt, line_a, line_b):
from app.analysis.biz_rules import cross_line_segment
return cross_line_segment(prev_pt, cur_pt, line_a, line_b)
@ -972,10 +1068,20 @@ class CameraPipeline(object):
health, stalled_sec = self._compute_stream_health()
src = self._frame_source.health_snapshot() if self._frame_source else {}
effective_running = self._running and health in ("ok", "reconnecting", "connecting")
if self._analysis_error:
analysis_health = "error"
elif not self._last_analysis_ts:
analysis_health = "starting"
elif time.time() - self._last_analysis_ts > max(30.0, self._analyze_interval * 2):
analysis_health = "stalled"
else:
analysis_health = "running"
return {
"stream_id": self.stream_id,
"running": effective_running,
"stream_health": health,
"analysis_health": analysis_health,
"analysis_error": self._analysis_error,
"stalled_sec": round(stalled_sec, 1),
"reconnect_fail_count": src.get("reconnect_fail_count", 0),
"total_reconnects": src.get("total_reconnects", 0),
@ -989,7 +1095,7 @@ class CameraPipeline(object):
"decoded_count": self._decoded_count,
"analyzed_count": self._analyzed_count,
"dropped_count": self._dropped_count,
"analysis_fps": round(self._analysis_fps, 1),
"analysis_fps": round(self._analysis_fps, 3),
"decode_fps": round(self._decode_fps, 1),
"analyze_fps_target": self.analyze_fps,
"active_zone_ids": sorted([

View File

@ -0,0 +1,139 @@
"""实时 YOLO 预览会话(进程内、短轮询、只保留最新结果)。"""
import json
import threading
import time
import uuid
from app.analysis.event_bridge import get_event_bridge
from app.analysis.manager import AnalysisManager
class PreviewSessionRegistry(object):
TTL_SEC = 15.0
def __init__(self):
self._sessions = {}
self._lock = threading.RLock()
self._running = True
threading.Thread(target=self._cleanup_loop, name="preview-session-cleanup", daemon=True).start()
@staticmethod
def _zone_payload(zone):
try:
coords = json.loads(zone.coordinates or "[]")
except Exception:
coords = []
return {"id": zone.id, "name": zone.name, "color": zone.color,
"coords": coords if isinstance(coords, list) else []}
@staticmethod
def _point_in_polygon(point, polygon):
if len(polygon or []) < 3:
return False
x, y, inside, j = point[0], point[1], False, len(polygon) - 1
for i, current in enumerate(polygon):
previous = polygon[j]
if ((current[1] > y) != (previous[1] > y) and
x < (previous[0] - current[0]) * (y - current[1]) /
(previous[1] - current[1] + 1e-12) + current[0]):
inside = not inside
j = i
return inside
def start(self, owner, zone):
ok, mode = AnalysisManager().start_preview(zone)
if not ok:
raise RuntimeError(mode)
session_id = uuid.uuid4().hex
now = time.time()
algorithms = []
target_labels = set()
for ba in zone.algorithms.filter(state=1).select_related("small_model", "detector_model"):
model = ba.detector_model if int(ba.flow_type or 0) == 4 else ba.small_model
if model:
algorithms.append({"id": model.id, "name": model.name})
try:
labels = json.loads(ba.target_labels or "[]")
except Exception:
labels = []
target_labels.update(labels or [])
session = {
"session_id": session_id, "owner": owner, "stream_id": zone.stream_id,
"zone": self._zone_payload(zone), "last_heartbeat": now,
"created_at": now, "algorithms": algorithms,
"target_labels": sorted(target_labels),
}
with self._lock:
self._sessions[session_id] = session
stream = zone.stream
return {
"session_id": session_id, "mode": AnalysisManager().preview_mode(zone.stream_id),
"stream_id": zone.stream_id, "app": stream.app, "name": stream.name,
"models": algorithms, "zone": session["zone"],
}
def data(self, owner, session_id, since=None):
with self._lock:
session = self._sessions.get(session_id)
if not session or session["owner"] != owner:
raise PermissionError("预览会话不存在或不属于当前登录会话")
session["last_heartbeat"] = time.time()
stream_id = session["stream_id"]
zone = dict(session["zone"])
latest = get_event_bridge().latest_preview(stream_id)
mode = AnalysisManager().preview_mode(stream_id)
if not latest:
return {"changed": False, "sequence": 0, "mode": mode,
"zone": zone, "models": session["algorithms"]}
sequence = int(latest.get("sequence") or 0)
changed = since is None or sequence > int(since or 0)
if not changed:
return {"changed": False, "sequence": sequence, "mode": mode,
"timestamp": latest.get("timestamp")}
payload = dict(latest)
payload["tracks"] = [dict(track) for track in latest.get("tracks", [])]
payload.update({"changed": True, "mode": mode, "zone": zone})
# 叠框仅显示该布控目标类别;人员入侵不会再显示车辆和交通灯。
target_labels = set(session.get("target_labels") or [])
if target_labels:
payload["tracks"] = [t for t in payload.get("tracks", []) if t.get("label") in target_labels]
for track in payload.get("tracks", []):
box = track.get("box") or []
if len(box) != 4:
continue
foot = ((box[0] + box[2]) * .5,
box[3] - (box[3] - box[1]) * .05 if track.get("label") == "person"
else (box[1] + box[3]) * .5)
track["inside_zone_ids"] = ([zone["id"]] if self._point_in_polygon(foot, zone["coords"]) else [])
payload["zones"] = [zone]
return payload
def stop(self, owner, session_id):
with self._lock:
session = self._sessions.get(session_id)
if not session or session["owner"] != owner:
return False
stream_id = session["stream_id"]
self._sessions.pop(session_id, None)
has_viewers = any(s["stream_id"] == stream_id for s in self._sessions.values())
if not has_viewers:
AnalysisManager().stop_preview(stream_id)
return True
def _cleanup_loop(self):
while self._running:
now = time.time()
expired = []
with self._lock:
expired = [(sid, s["owner"]) for sid, s in self._sessions.items()
if now - s["last_heartbeat"] > self.TTL_SEC]
for sid, owner in expired:
self.stop(owner, sid)
threading.Event().wait(2.0)
_REGISTRY = PreviewSessionRegistry()
def get_preview_registry():
return _REGISTRY

View File

@ -19,7 +19,8 @@ def _algorithm_spec_from_dict(d):
return d
def _build_detectors_in_process(algorithm_specs, infer_req_q=None, infer_resp_q=None):
def _build_detectors_in_process(algorithm_specs, infer_req_q=None, infer_resp_q=None,
response_channel=None):
"""在子进程中构造检测器;若提供 infer_req_q/infer_resp_q 则走主进程共享推理池"""
detectors = []
if infer_req_q is not None and infer_resp_q is not None:
@ -28,7 +29,10 @@ def _build_detectors_in_process(algorithm_specs, infer_req_q=None, infer_resp_q=
detectors.append({
"algorithm_id": spec.get("id", 0),
"algorithm_name": spec.get("name", ""),
"engine": RemoteDetector(spec, infer_req_q, infer_resp_q),
"engine": RemoteDetector(spec, infer_req_q, infer_resp_q,
response_channel=response_channel),
"target_labels": spec.get("target_labels") or [],
"device": spec.get("device") or "cpu",
})
else:
from app.analysis.worker_pool import DetectorWorkerPool
@ -47,6 +51,8 @@ def _build_detectors_in_process(algorithm_specs, infer_req_q=None, infer_resp_q=
"algorithm_id": spec.get("id", 0),
"algorithm_name": spec.get("name", ""),
"engine": eng,
"target_labels": spec.get("target_labels") or [],
"device": spec.get("device") or "cpu",
})
return detectors
@ -88,10 +94,17 @@ def pipeline_process_main(config, event_queue, cmd_queue, status_dict,
except Exception:
pass
def on_preview(payload):
try:
event_queue.put(("preview", payload), timeout=0.2)
except Exception:
pass
detectors = _build_detectors_in_process(
algorithm_specs,
infer_req_q if use_shared_inference else None,
infer_resp_q if use_shared_inference else None,
config.get("response_channel"),
)
if not detectors and not algorithm_specs:
log.info("pipeline[%s] 无算法,仅运动检测", stream_code)
@ -107,6 +120,8 @@ def pipeline_process_main(config, event_queue, cmd_queue, status_dict,
analyze_fps=analyze_fps,
on_event=on_event,
on_track_snapshot=on_track_snapshot,
on_preview=on_preview,
alarm_enabled=not bool(config.get("preview_only", False)),
zone_polygons=zones,
storage_alarm_dir=storage_alarm_dir,
static_dir=static_dir,
@ -114,27 +129,34 @@ def pipeline_process_main(config, event_queue, cmd_queue, status_dict,
pipeline._algorithm_name = ", ".join(d.get("name", "") for d in algorithm_specs) or "motion-only"
import threading
finished = threading.Event()
def wait_started():
while not finished.is_set():
if pipeline._started_event.wait(0.1):
return True
return False
def status_reporter():
# 等待 pipeline.run() 启动_running 在 run() 里才置 True
# 否则 while pipeline._running 条件不满足会立即退出,导致 status_dict 永远为空)
_wait = 0
while not pipeline._running and _wait < 100:
time.sleep(0.1)
_wait += 1
while pipeline._running:
if not wait_started():
return
while not finished.is_set():
try:
st = pipeline.status()
status_dict[str(stream_id)] = st
except Exception:
pass
time.sleep(1.0)
log.exception("pipeline[%s] 状态上报失败", stream_code)
finished.wait(1.0)
reporter = threading.Thread(target=status_reporter, name="status-%s" % stream_id, daemon=True)
reporter.start()
def cmd_listener():
while pipeline._running:
if not wait_started():
return
while not finished.is_set():
try:
cmd = cmd_queue.get(timeout=0.5)
except queue.Empty:
@ -149,6 +171,8 @@ def pipeline_process_main(config, event_queue, cmd_queue, status_dict,
pipeline.set_zone_polygons(cmd.get("zones") or [])
if cmd.get("analyze_fps") is not None:
pipeline.set_analyze_fps(cmd.get("analyze_fps"))
if cmd.get("cmd") == "set_analyze_fps":
pipeline.set_analyze_fps(cmd.get("analyze_fps") or 1)
except Exception as e:
log.exception("pipeline[%s] 命令处理失败: %s", stream_code, e)
@ -159,6 +183,9 @@ def pipeline_process_main(config, event_queue, cmd_queue, status_dict,
try:
pipeline.run()
finally:
finished.set()
reporter.join(timeout=2)
cmd_thread.join(timeout=1)
try:
status_dict.pop(str(stream_id), None)
except Exception:
@ -186,6 +213,7 @@ class PipelineProcessHandle(object):
try:
self.process.join(timeout=timeout)
if self.process.is_alive():
logger.warning("pipeline[%s] 未在 %ss 内停止,强制结束子进程", self.stream_id, timeout)
self.process.terminate()
self.process.join(timeout=2)
except Exception:
@ -200,6 +228,12 @@ class PipelineProcessHandle(object):
except Exception as e:
logger.warning("reload_zones 发送失败: %s", e)
def set_analyze_fps(self, analyze_fps):
try:
self.cmd_queue.put({"cmd": "set_analyze_fps", "analyze_fps": float(analyze_fps)}, timeout=1.0)
except Exception as e:
logger.warning("set_analyze_fps 发送失败: %s", e)
def status(self):
try:
return self.status_dict.get(str(self.stream_id))

View File

@ -67,11 +67,13 @@ def _get_drainer(resp_queue):
class RemoteDetector(object):
ENGINE_NAME = "remote_pool"
def __init__(self, algorithm_spec, req_queue, resp_queue, timeout=30.0):
def __init__(self, algorithm_spec, req_queue, resp_queue, timeout=35.0,
response_channel=None):
self._spec = algorithm_spec
self._req_q = req_queue
self._resp_q = resp_queue
self._timeout = timeout
self._response_channel = response_channel
self._drainer = _get_drainer(resp_queue)
def ready(self):
@ -82,17 +84,21 @@ class RemoteDetector(object):
def detect(self, frame):
if not self.ready():
return []
raise RuntimeError("共享推理队列未就绪")
self._drainer.ensure_started()
try:
import cv2
frame = self._maybe_downscale(frame)
ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
orig_h, orig_w = frame.shape[:2]
inference_frame = self._maybe_downscale(frame)
infer_h, infer_w = inference_frame.shape[:2]
# 远处人员框很小,低质量 JPEG 会在 YOLO 前抹掉轮廓95 在当前单路 CPU
# 场景仍可控,并显著缩小与直接单图推理的差异。
ok, buf = cv2.imencode(".jpg", inference_frame, [int(cv2.IMWRITE_JPEG_QUALITY), 95])
if not ok:
return []
raise RuntimeError("推理帧编码失败")
jpeg = buf.tobytes()
except Exception:
return []
except Exception as e:
raise RuntimeError("推理帧编码失败: %s" % e) from e
req_id = str(uuid.uuid4())
evt = self._drainer.register(req_id)
@ -101,19 +107,62 @@ class RemoteDetector(object):
"req_id": req_id,
"algorithm": self._spec,
"jpeg": jpeg,
"response_channel": self._response_channel,
}, timeout=2.0)
except Exception:
except Exception as e:
self._drainer.unregister(req_id)
return []
raise RuntimeError("推理请求入队失败: %s" % e) from e
if not evt["event"].wait(timeout=self._timeout):
self._drainer.unregister(req_id)
logger.warning("RemoteDetector 推理超时 algo=%s", self._spec.get("name"))
return []
raise TimeoutError("共享推理响应超时(%ss" % self._timeout)
resp = evt.get("resp") or {}
if not resp.get("ok"):
return []
return resp.get("detections") or []
raise RuntimeError(resp.get("error") or "共享推理失败")
detections = resp.get("detections") or []
if infer_w != orig_w or infer_h != orig_h:
detections = self._restore_coordinates(
detections, float(orig_w) / infer_w, float(orig_h) / infer_h,
)
return detections
@staticmethod
def _restore_coordinates(detections, scale_x, scale_y):
"""把共享推理缩图坐标恢复到解码原帧坐标系。"""
result = []
for source in detections or []:
item = dict(source)
box = item.get("box")
if isinstance(box, (list, tuple)) and len(box) >= 4:
item["box"] = [float(box[0]) * scale_x, float(box[1]) * scale_y,
float(box[2]) * scale_x, float(box[3]) * scale_y]
keypoints = item.get("keypoints")
if isinstance(keypoints, list):
item["keypoints"] = [
[float(p[0]) * scale_x, float(p[1]) * scale_y] + list(p[2:])
if isinstance(p, (list, tuple)) and len(p) >= 2 else p
for p in keypoints
]
for polygon_key in ("polygon", "mask", "segments"):
polygon = item.get(polygon_key)
if isinstance(polygon, list):
item[polygon_key] = RemoteDetector._scale_polygon(polygon, scale_x, scale_y)
result.append(item)
return result
@staticmethod
def _scale_polygon(value, scale_x, scale_y):
scaled = []
for point in value:
if (isinstance(point, (list, tuple)) and len(point) >= 2 and
isinstance(point[0], (int, float)) and isinstance(point[1], (int, float))):
scaled.append([float(point[0]) * scale_x, float(point[1]) * scale_y] + list(point[2:]))
elif isinstance(point, list):
scaled.append(RemoteDetector._scale_polygon(point, scale_x, scale_y))
else:
scaled.append(point)
return scaled
def _maybe_downscale(self, frame):
try:
@ -123,7 +172,7 @@ class RemoteDetector(object):
int(self._spec.get("input_width", 640) or 640),
int(self._spec.get("input_height", 640) or 640),
640,
) * 2
) * 4
longest = max(h, w)
if longest <= max_side:
return frame

View File

@ -1,102 +1,182 @@
"""单摄像头目标跟踪(轻量 IoU 关联)
"""低帧率视频目标跟踪。
设计参考 Frigate/Norfair 的追踪思路但用最小依赖实现一个 IoU 关联器
避免强制引入 norfair后续可平滑替换为 norfair DeepSORT/ByteTrack 的特征关联
输出为每个检测框分配 track_id并维护其在场状态/累计帧数/最近一帧框
采用同类别全局一对一匹配并用时间而不是丢帧数保留轨迹这样在 CPU
推理只有约 0.3 FPS 短暂缺检不会不断生成新的 track_id
"""
import logging
logger = logging.getLogger("analysis.tracker")
try:
import numpy as np # reserved for future vectorized IoU; not required for operation
_ = np
except Exception:
np = None
import math
import time
def _iou(a, b):
ax1, ay1, ax2, ay2 = a
bx1, by1, bx2, by2 = b
ix1 = max(ax1, bx1); iy1 = max(ay1, by1)
ix2 = min(ax2, bx2); iy2 = min(ay2, by2)
iw = max(0, ix2 - ix1); ih = max(0, iy2 - iy1)
inter = iw * ih
a_area = max(0, ax2 - ax1) * max(0, ay2 - ay1)
b_area = max(0, bx2 - bx1) * max(0, by2 - by1)
union = a_area + b_area - inter
if union <= 0:
return 0.0
return float(inter) / float(union)
ix1, iy1 = max(ax1, bx1), max(ay1, by1)
ix2, iy2 = min(ax2, bx2), min(ay2, by2)
inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1)
area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1)
union = area_a + area_b - inter
return float(inter / union) if union > 0 else 0.0
def _center(box):
return ((box[0] + box[2]) * 0.5, (box[1] + box[3]) * 0.5)
def _center_distance(a, b):
ac, bc = _center(a), _center(b)
distance = math.hypot(ac[0] - bc[0], ac[1] - bc[1])
diag = max(1.0, math.hypot(max(a[2] - a[0], b[2] - b[0]),
max(a[3] - a[1], b[3] - b[1])))
return distance / diag
class Track(object):
__slots__ = ("track_id", "label", "box", "score", "missed", "hits", "born")
__slots__ = (
"track_id", "label", "box", "score", "missed", "hits", "born",
"last_seen", "last_update", "velocity", "confirmed", "observed",
"algorithm_id", "algorithm_name",
)
def __init__(self, track_id, label, box, score, born):
def __init__(self, track_id, detection, born, timestamp):
self.track_id = track_id
self.label = label
self.box = box
self.score = score
self.label = detection.get("label", "unknown")
self.box = list(detection.get("box") or [0, 0, 0, 0])
self.score = float(detection.get("score", 0) or 0)
self.missed = 0
self.hits = 1
self.born = born
self.last_seen = timestamp
self.last_update = timestamp
self.velocity = [0.0, 0.0, 0.0, 0.0]
self.confirmed = self.score >= 0.6
self.observed = True
self.algorithm_id = detection.get("algorithm_id")
self.algorithm_name = detection.get("algorithm_name", "")
def predicted_box(self, timestamp):
dt = max(0.0, min(5.0, timestamp - self.last_update))
return [self.box[i] + self.velocity[i] * dt for i in range(4)]
def as_dict(self):
return {
"track_id": self.track_id,
"label": self.label,
"box": list(self.box),
"score": self.score,
"hits": self.hits,
"confirmed": self.confirmed,
"observed": self.observed,
"last_seen": self.last_seen,
"algorithm_id": self.algorithm_id,
"algorithm_name": self.algorithm_name,
"velocity": list(self.velocity),
}
class IoUTracker(object):
"""按类别维护轨迹IoU 匹配max_missed 后判定目标消失。"""
"""低 FPS 下的全局一对一框关联器"""
def __init__(self, iou_threshold=0.3, max_missed=8):
self.iou_threshold = iou_threshold
self.max_missed = max_missed
self._tracks = {} # track_id -> Track
def __init__(self, iou_threshold=0.2, max_missed=8):
self.iou_threshold = float(iou_threshold)
self.max_missed = max_missed # 兼容旧构造参数;实际淘汰按时间
self._tracks = {}
self._next_id = 1
self._last_update_ts = None
self._recent_period = 1.0
def update(self, detections, frame_index):
"""detections: list[dict(box, label, score)]
返回 list[dict(track_id, label, box, score)] 当前帧仍在场的轨迹"""
active = {}
new_tracks = []
# 贪心匹配
for det in detections:
best_id = None
best_iou = self.iou_threshold
for tid, tr in self._tracks.items():
if tr.label != det["label"]:
@property
def retention_sec(self):
return max(2.0, min(15.0, 3.0 * self._recent_period))
def update(self, detections, frame_index, timestamp=None, max_age_sec=None):
timestamp = float(timestamp if timestamp is not None else time.time())
if self._last_update_ts is not None:
period = timestamp - self._last_update_ts
if 0.05 <= period <= 60:
self._recent_period = self._recent_period * 0.7 + period * 0.3
self._last_update_ts = timestamp
max_age = float(max_age_sec if max_age_sec is not None else self.retention_sec)
for track in self._tracks.values():
track.observed = False
candidates = []
for det_index, det in enumerate(detections or []):
box = det.get("box") or []
if len(box) != 4:
continue
for tid, track in self._tracks.items():
if track.label != det.get("label"):
continue
predicted = track.predicted_box(timestamp)
overlap = _iou(predicted, box)
distance = _center_distance(predicted, box)
if overlap < self.iou_threshold and distance > 1.5:
continue
affinity = overlap * 0.72 + max(0.0, 1.0 - distance / 1.5) * 0.28
candidates.append((affinity, overlap, -distance, tid, det_index))
candidates.sort(reverse=True)
used_tracks, used_detections = set(), set()
for _affinity, _overlap, _distance, tid, det_index in candidates:
if tid in used_tracks or det_index in used_detections:
continue
track = self._tracks.get(tid)
if track is None:
continue
det = detections[det_index]
old_box = list(track.box)
dt = max(0.05, timestamp - track.last_update)
new_box = [float(x) for x in det["box"]]
instant_velocity = [(new_box[i] - old_box[i]) / dt for i in range(4)]
track.velocity = [track.velocity[i] * 0.5 + instant_velocity[i] * 0.5 for i in range(4)]
track.box = new_box
track.score = float(det.get("score", 0) or 0)
track.missed = 0
track.hits += 1
track.observed = True
track.last_seen = timestamp
track.last_update = timestamp
track.algorithm_id = det.get("algorithm_id")
track.algorithm_name = det.get("algorithm_name", "")
if track.score >= 0.6 or track.hits >= 2:
track.confirmed = True
used_tracks.add(tid)
used_detections.add(det_index)
new_tracks = []
for det_index, det in enumerate(detections or []):
if det_index in used_detections or len(det.get("box") or []) != 4:
continue
v = _iou(tr.box, det["box"])
if v > best_iou:
best_iou = v
best_id = tid
if best_id is not None:
tr = self._tracks[best_id]
tr.box = det["box"]
tr.score = det["score"]
tr.missed = 0
tr.hits += 1
active[best_id] = tr
else:
tid = self._next_id
self._next_id += 1
tr = Track(tid, det["label"], det["box"], det["score"], frame_index)
self._tracks[tid] = tr
active[tid] = tr
self._tracks[tid] = Track(tid, det, frame_index, timestamp)
new_tracks.append(tid)
used_tracks.add(tid)
# 未匹配的轨迹累计 missed
ended = []
for tid, tr in self._tracks.items():
if tid in active:
continue
tr.missed += 1
if tr.missed >= self.max_missed:
for tid, track in list(self._tracks.items()):
if not track.observed:
track.missed += 1
if not track.confirmed:
track.hits = 0 # 中等置信度必须连续两次命中
if timestamp - track.last_seen > max_age:
ended.append(tid)
for tid in ended:
del self._tracks[tid]
return [{"track_id": t.track_id, "label": t.label, "box": t.box, "score": t.score}
for t in active.values()], ended, new_tracks, frame_index
active = [t.as_dict() for t in self._tracks.values() if t.observed]
return active, ended, new_tracks, frame_index
def display_tracks(self, timestamp=None):
now = float(timestamp if timestamp is not None else time.time())
result = []
for track in self._tracks.values():
item = track.as_dict()
item["age_sec"] = max(0.0, now - track.last_seen)
item["opacity"] = 1.0 if track.observed else max(0.15, 1.0 - item["age_sec"] / self.retention_sec)
result.append(item)
return result
def all_active(self):
return list(self._tracks.values())
@ -104,3 +184,5 @@ class IoUTracker(object):
def reset(self):
self._tracks.clear()
self._next_id = 1
self._last_update_ts = None
self._recent_period = 1.0

View File

@ -45,6 +45,7 @@ class DetectorWorkerPool(object):
algo_type = algorithm.get("algorithm_type", "yolo8")
task_type = algorithm.get("task_type", "detect")
device = algorithm.get("device", "cpu")
target_labels = algorithm.get("target_labels", [])
else:
algo_id = getattr(algorithm, "id", None)
engine_name = algorithm.inference_engine
@ -61,8 +62,10 @@ class DetectorWorkerPool(object):
algo_type = algorithm.algorithm_type
task_type = getattr(algorithm, "task_type", "detect")
device = getattr(algorithm, "device", "cpu")
target_labels = getattr(algorithm, "target_labels", [])
key = (algo_id, engine_name, model_file, conf, iou, input_size, task_type, device)
key = (algo_id, engine_name, model_file, conf, iou, input_size, task_type, device,
tuple(sorted(target_labels or [])))
with self._lock:
det = self._engines.get(key)
if det is not None:
@ -77,6 +80,7 @@ class DetectorWorkerPool(object):
algorithm_type=algo_type,
task_type=task_type,
device=device)
det.target_labels = list(target_labels or [])
if not det.load():
logger.warning("DetectorWorkerPool: 引擎 load 失败 algo=%s engine=%s", algo_id, engine_name)
return None

View File

@ -26,6 +26,10 @@ class AppConfig(AppConfig):
connection_created.connect(_setup_sqlite_pragma)
from monitor_runtime.paths import DESKTOP
if DESKTOP:
return # Desktop bootstrap migrates before accessing business tables.
try:
from app.utils.schema_upgrade import ensure_biz_algorithm_line_count_columns
ensure_biz_algorithm_line_count_columns()

View File

@ -23,6 +23,8 @@ def _required_role(path, method):
return ROLE_OPERATOR
if path.startswith(("/analysis/openStart", "/analysis/openStop", "/analysis/openReload")):
return ROLE_OPERATOR
if path.startswith(("/analysis/openPreviewStart", "/analysis/openPreviewStop")):
return ROLE_OPERATOR
if path.startswith(("/analysis/openUpdate", "/analysis/openToggle", "/analysis/openRestart")):
return ROLE_ALGORITHM_ADMIN
return ""
@ -45,6 +47,11 @@ class SimpleMiddleware(MiddlewareMixin):
def process_request(self, request):
path = request.path_info
if path in ('/setup', '/license', '/license/status', '/license/request', '/license/import'):
from monitor_runtime.paths import DESKTOP
if DESKTOP:
return None # Dedicated views enforce local bootstrap/admin access.
if path.startswith('/inner/'):
if verify_internal_request(request):
return None

View File

@ -0,0 +1,221 @@
# Generated by Django 5.0.4 on 2026-08-30 23:53
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='AlgorithmModel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='算法名称')),
('algorithm_type', models.CharField(choices=[('yolo5', 'YOLOv5'), ('yolo8', 'YOLOv8'), ('yolo11', 'YOLOv11'), ('yolo26', 'YOLO26'), ('osnet', 'OSNet ReID')], default='yolo8', max_length=30, verbose_name='算法类型')),
('task_type', models.CharField(choices=[('detect', 'Detect'), ('segment', 'Segment'), ('classify', 'Classify'), ('pose', 'Pose'), ('obb', 'OBB'), ('reid', 'ReID')], default='detect', max_length=20, verbose_name='任务类型')),
('inference_engine', models.CharField(choices=[('yolo_pytorch', 'Yolo-PyTorch'), ('onnxruntime', 'OnnxRuntime'), ('openvino', 'OpenVINO')], default='yolo_pytorch', max_length=20, verbose_name='推理引擎')),
('device', models.CharField(choices=[('cpu', 'CPU'), ('cuda', 'CUDA'), ('gpu', 'GPU')], default='cpu', max_length=20, verbose_name='推理设备')),
('model_file', models.CharField(default='', max_length=300, verbose_name='模型文件相对路径')),
('model_file_size', models.IntegerField(default=0, verbose_name='模型文件大小(字节)')),
('input_width', models.IntegerField(default=640, verbose_name='输入宽度')),
('input_height', models.IntegerField(default=640, verbose_name='输入高度')),
('conf_threshold', models.FloatField(default=0.4, verbose_name='置信度阈值')),
('iou_threshold', models.FloatField(default=0.5, verbose_name='NMS IoU 阈值')),
('labels', models.TextField(default='[]', verbose_name='支持类别JSON数组')),
('is_default', models.IntegerField(default=0, verbose_name='是否默认算法')),
('state', models.IntegerField(default=1, verbose_name='状态')),
('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('last_update_time', models.DateTimeField(auto_now_add=True, verbose_name='更新时间')),
],
options={
'verbose_name': '小模型',
'verbose_name_plural': '小模型',
'db_table': 'av_algorithm',
},
),
migrations.CreateModel(
name='LLMModel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(verbose_name='用户')),
('sort', models.IntegerField(default=0, verbose_name='排序')),
('code', models.CharField(max_length=50, verbose_name='编号')),
('name', models.CharField(default='', max_length=50, verbose_name='名称')),
('model_name', models.CharField(max_length=200, verbose_name='模型名称')),
('api_url', models.CharField(max_length=500, verbose_name='API地址')),
('api_key', models.CharField(default='', max_length=512, verbose_name='API密钥')),
('timeout', models.IntegerField(default=30, verbose_name='超时时间(秒)')),
('inference_tool', models.CharField(default='OpenAI', max_length=100, verbose_name='推理工具')),
('remark', models.TextField(default='', verbose_name='备注')),
('state', models.IntegerField(default=1, verbose_name='状态')),
('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('last_update_time', models.DateTimeField(auto_now_add=True, verbose_name='更新时间')),
],
options={
'verbose_name': '大模型',
'verbose_name_plural': '大模型',
'db_table': 'av_llm',
},
),
migrations.CreateModel(
name='LogModel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(verbose_name='用户ID')),
('log_type', models.IntegerField(verbose_name='日志类型')),
('content', models.CharField(max_length=200, verbose_name='日志内容')),
('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('state', models.IntegerField(verbose_name='状态')),
],
options={
'verbose_name': '管理员日志',
'verbose_name_plural': '管理员日志',
'db_table': 'av_log',
},
),
migrations.CreateModel(
name='BizAlgorithmModel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='算法名称')),
('flow_type', models.IntegerField(choices=[(1, '小模型+后处理'), (2, '大模型+后处理'), (3, '小模型+大模型+后处理'), (4, '检测+ReID+后处理')], default=1, verbose_name='流程类型')),
('target_labels', models.TextField(default='[]', verbose_name='目标类别JSON')),
('llm_prompt', models.TextField(default='', verbose_name='大模型提示词')),
('llm_validate', models.TextField(default='', verbose_name='提示词校验值')),
('post_process', models.CharField(choices=[('AREA', '区域入侵'), ('LINE_CROSS', '越线检测'), ('LINE_COUNT', '越线计数'), ('DIRECTION', '方向入侵'), ('DENSITY', '密度报警'), ('DWELL', '滞留报警')], default='AREA', max_length=30, verbose_name='后处理逻辑')),
('ref_angle', models.FloatField(default=90.0, verbose_name='方向参考角度')),
('angle_tolerance', models.FloatField(default=45.0, verbose_name='方向容差(度)')),
('forward_count_threshold', models.IntegerField(default=0, verbose_name='正向计数报警阈值')),
('reverse_count_threshold', models.IntegerField(default=0, verbose_name='逆向计数报警阈值')),
('state', models.IntegerField(default=1, verbose_name='状态')),
('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('last_update_time', models.DateTimeField(auto_now_add=True, verbose_name='更新时间')),
('detector_model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='biz_algorithms_as_detector', to='app.algorithmmodel', verbose_name='检测小模型(YOLO)')),
('small_model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='biz_algorithms', to='app.algorithmmodel', verbose_name='小模型')),
('llm', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='biz_algorithms', to='app.llmmodel', verbose_name='大模型')),
],
options={
'verbose_name': '业务算法',
'verbose_name_plural': '业务算法',
'db_table': 'av_biz_algorithm',
},
),
migrations.CreateModel(
name='StreamModel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(verbose_name='用户')),
('sort', models.IntegerField(verbose_name='排序')),
('code', models.CharField(max_length=50, verbose_name='编号')),
('app', models.CharField(max_length=50, verbose_name='流分组')),
('name', models.CharField(max_length=50, verbose_name='流名称')),
('pull_stream_url', models.CharField(max_length=300, verbose_name='视频流源地址')),
('pull_stream_type', models.IntegerField(verbose_name='视频流来源类型')),
('pull_stream_transfer_mode', models.IntegerField(verbose_name='视频流传输模式')),
('pull_stream_ip', models.CharField(max_length=50, verbose_name='拉流IP')),
('pull_stream_port', models.IntegerField(verbose_name='拉流端口')),
('pull_stream_username', models.CharField(max_length=512, verbose_name='拉流用户名')),
('pull_stream_password', models.CharField(max_length=512, verbose_name='拉流密码')),
('nickname', models.CharField(max_length=200, verbose_name='视频流昵称')),
('remark', models.CharField(max_length=200, verbose_name='备注')),
('forward_state', models.IntegerField(verbose_name='转发状态')),
('is_audio', models.IntegerField(default=0, verbose_name='音频传输类型')),
('snap_filepath', models.CharField(max_length=200, verbose_name='快照文件路径')),
('snap_time', models.DateTimeField(auto_now_add=True, verbose_name='快照时间')),
('camera_sum_num', models.IntegerField(default=0, verbose_name='通道总数')),
('camera_name', models.CharField(max_length=100, verbose_name='摄像头名称')),
('camera_manufacturer', models.CharField(max_length=100, verbose_name='摄像头厂商')),
('camera_owner', models.CharField(max_length=50, verbose_name='摄像头所属者')),
('camera_model', models.CharField(max_length=50, verbose_name='摄像头型号')),
('camera_device_id', models.CharField(max_length=50, verbose_name='GB28181设备ID')),
('camera_parent_id', models.CharField(max_length=50, verbose_name='GB28181父设备ID')),
('camera_civilcode', models.CharField(max_length=50, verbose_name='行政区划码')),
('camera_last_keepalive_time', models.DateTimeField(auto_now_add=True, verbose_name='最近一次心跳时间')),
('camera_last_register_time', models.DateTimeField(auto_now_add=True, verbose_name='最近一次注册时间')),
('cascade_device_id', models.CharField(default='', max_length=50, verbose_name='向上级联国标编号')),
('cascade_enable', models.IntegerField(default=0, verbose_name='是否启用向上级联')),
('record_enable', models.IntegerField(default=0, verbose_name='启用24/7录像')),
('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('last_update_time', models.DateTimeField(auto_now_add=True, verbose_name='更新时间')),
('add_type', models.IntegerField(default=0, verbose_name='添加类型')),
('state', models.IntegerField(default=0, verbose_name='状态')),
('algorithm', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='streams', to='app.algorithmmodel', verbose_name='分析算法')),
],
options={
'verbose_name': '视频流',
'verbose_name_plural': '视频流',
'db_table': 'av_stream',
},
),
migrations.CreateModel(
name='ZoneModel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='区域名称')),
('coordinates', models.TextField(verbose_name='多边形坐标')),
('is_required', models.IntegerField(default=1, verbose_name='是否必需区域')),
('loiter_threshold', models.IntegerField(default=0, verbose_name='滞留阈值(秒)')),
('detect_interval_sec', models.FloatField(default=1.0, verbose_name='检测间隔(秒)')),
('detect_frames', models.IntegerField(default=1, verbose_name='检测帧数')),
('color', models.CharField(default='#169F85', max_length=20, verbose_name='显示颜色')),
('line_a', models.TextField(default='', verbose_name='警戒线端点A')),
('line_b', models.TextField(default='', verbose_name='警戒线端点B')),
('density_threshold', models.IntegerField(default=0, verbose_name='密度阈值')),
('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('last_update_time', models.DateTimeField(auto_now_add=True, verbose_name='更新时间')),
('state', models.IntegerField(default=1, verbose_name='状态')),
('algorithms', models.ManyToManyField(blank=True, related_name='zones', to='app.bizalgorithmmodel', verbose_name='分析算法')),
('stream', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.streammodel', verbose_name='所属摄像头')),
],
options={
'verbose_name': '区域',
'verbose_name_plural': '区域',
'db_table': 'av_zone',
},
),
migrations.CreateModel(
name='RecordingModel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file_path', models.CharField(max_length=500, verbose_name='文件路径')),
('start_time', models.DateTimeField(verbose_name='开始时间')),
('end_time', models.DateTimeField(verbose_name='结束时间')),
('duration', models.FloatField(default=0, verbose_name='时长(秒)')),
('file_size', models.BigIntegerField(default=0, verbose_name='文件大小(字节)')),
('has_motion', models.IntegerField(default=0, verbose_name='含运动')),
('has_object', models.IntegerField(default=0, verbose_name='含目标')),
('create_time', models.DateTimeField(auto_now_add=True, verbose_name='入库时间')),
('stream', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.streammodel', verbose_name='摄像头')),
],
options={
'verbose_name': '录像分段',
'verbose_name_plural': '录像分段',
'db_table': 'av_recording',
'indexes': [models.Index(fields=['stream', 'start_time'], name='av_recording_st_idx')],
},
),
migrations.CreateModel(
name='AlarmModel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('event_type', models.CharField(default='entered_zone', max_length=32, verbose_name='报警类型')),
('description', models.CharField(default='', max_length=300, verbose_name='描述')),
('timestamp', models.DateTimeField(verbose_name='发生时间')),
('metadata', models.TextField(default='{}', verbose_name='元数据JSON')),
('create_time', models.DateTimeField(auto_now_add=True, verbose_name='入库时间')),
('stream', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='app.streammodel', verbose_name='摄像头')),
],
options={
'verbose_name': '报警',
'verbose_name_plural': '报警',
'db_table': 'av_alarm',
'indexes': [models.Index(fields=['-timestamp'], name='av_alarm_ts_idx'), models.Index(fields=['stream', 'timestamp'], name='av_alarm_st_idx')],
},
),
]

View File

@ -0,0 +1,13 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("app", "0001_initial")]
operations = [
migrations.AddField(
model_name="zonemodel",
name="alarm_repeat_sec",
field=models.FloatField(default=30.0, verbose_name="重复报警间隔(秒)"),
),
]

View File

View File

@ -263,6 +263,7 @@ class ZoneModel(models.Model):
loiter_threshold = models.IntegerField(default=0, verbose_name='滞留阈值(秒)') # 0=不检测滞留
detect_interval_sec = models.FloatField(default=1.0, verbose_name='检测间隔(秒)') # 每 N 秒
detect_frames = models.IntegerField(default=1, verbose_name='检测帧数') # 分析 M 帧,频率=M/N fps
alarm_repeat_sec = models.FloatField(default=30.0, verbose_name='重复报警间隔(秒)') # 0=同次停留不重复
color = models.CharField(max_length=20, default='#169F85', verbose_name='显示颜色')
# LINE_CROSS 后处理:警戒线段两端点(归一化坐标0~1)JSON: [x,y]
line_a = models.TextField(default='', verbose_name='警戒线端点A') # JSON: [x,y] 归一化

View File

@ -26,7 +26,9 @@ class RecordingManager(object):
self._retention_thread = threading.Thread(
target=self._retention_loop, name="recording-retention", daemon=True)
self._retention_thread.start()
threading.Thread(target=self._auto_start_loop, name="recording-autostart", daemon=True).start()
self._sync_thread = threading.Thread(
target=self._sync_loop, name="recording-sync", daemon=True)
self._sync_thread.start()
logger.info("RecordingManager 已启动")
def stop(self):
@ -41,14 +43,35 @@ class RecordingManager(object):
self._retention_thread.join(timeout=5)
logger.info("RecordingManager 已停止")
def _auto_start_loop(self):
time.sleep(5)
def _sync_loop(self):
"""周期性把 DB 中的 record_enable 标记同步为实际 FFmpeg 录制进程。
分进程部署下 Web 请求只负责持久化 record_enable真正的录像进程
由持有录制服务的领导者进程通过本循环异步启动/停止
"""
time.sleep(3)
while self._running:
try:
from app.models import StreamModel
for s in StreamModel.objects.filter(record_enable=1, forward_state=1):
self.start_stream(s)
self._run_sync()
except Exception as e:
logger.warning("录像自动启动失败: %s", e)
logger.warning("录像同步异常: %s", e)
for _ in range(5):
if not self._running:
break
time.sleep(1)
def _run_sync(self):
from app.models import StreamModel
streams = list(StreamModel.objects.filter(forward_state=1))
wanted = {s.id for s in streams if int(s.record_enable or 0) == 1}
current = set(self.list_recording())
for s in streams:
if s.id in wanted and s.id not in current:
ok, info = self.start_stream(s)
if not ok:
logger.warning("录像启动失败 stream=%s: %s", s.id, info)
elif s.id not in wanted and s.id in current:
self.stop_stream(s.id)
def _segment_seconds(self):
try:
@ -72,6 +95,8 @@ class RecordingManager(object):
return AnalysisManager.build_rtsp_url(stream)
def start_stream(self, stream):
from monitor_runtime.licensing import require_license
require_license()
if not self._running:
return False, "recording service is not running in this process"
sid = stream.id
@ -94,14 +119,17 @@ class RecordingManager(object):
cmd = [
ffmpeg, "-loglevel", "warning", "-rtsp_transport", "tcp",
"-i", url,
"-c", "copy", "-f", "segment",
# MP4 容器不支持摄像头常见的 pcm_alaw 音频,只复制视频流。
"-map", "0:v", "-c", "copy", "-f", "segment",
"-segment_time", str(seg),
"-reset_timestamps", "1",
"-strftime", "1",
pattern,
]
try:
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
except Exception as e:
return False, str(e)
self._processes[sid] = {"proc": proc, "path": out_dir, "stream": stream}
@ -116,6 +144,19 @@ class RecordingManager(object):
proc = item.get("proc")
if proc:
try:
# 先优雅退出(向 stdin 发 'q'),让 FFmpeg 正常收尾写出 mp4 的
# moov 原子Windows 上 TerminateProcess 硬杀会导致当前分段
# 无 moov、文件无法播放。
if proc.stdin:
try:
proc.stdin.write(b"q")
proc.stdin.flush()
proc.stdin.close()
except Exception:
pass
try:
proc.wait(timeout=8)
except Exception:
proc.terminate()
proc.wait(timeout=5)
except Exception:

View File

@ -41,7 +41,8 @@ def write_alarm(event):
except Exception:
stream = None
meta = event.get("metadata") or {}
for k in ("zone_id", "label", "duration", "track_id", "boxes", "box", "snapshot_path",
for k in ("zone_id", "label", "duration", "track_id", "score", "frame_width", "frame_height",
"boxes", "box", "snapshot_path",
"global_track_id", "biz_algorithm_id", "biz_algorithm_name", "alarm_reason", "zone_name"):
if k in event:
meta[k] = event[k]

View File

@ -13,7 +13,8 @@ from pathlib import Path
logger = logging.getLogger("app.services.lifecycle")
PROJECT_ROOT = Path(__file__).resolve().parents[2]
from monitor_runtime.paths import DATA_ROOT
PROJECT_ROOT = DATA_ROOT
DEFAULT_LOCK_PATH = PROJECT_ROOT / ".runtime" / "service-leader.lock"
@ -125,6 +126,8 @@ class ServiceManager:
return self.lock.acquired
def start(self):
from monitor_runtime.licensing import require_license
require_license()
with self._guard:
if self._started:
return True
@ -137,6 +140,8 @@ class ServiceManager:
from app.utils.MediaServerManager import get_media_server_manager
ok, info = get_media_server_manager().start()
logger.info("ZLM explicit start: ok=%s %s", ok, info)
if not ok:
raise RuntimeError("ZLM startup failed: " + str(info))
g_gb28181SipServer.start()
if getattr(g_config, "recordingEnabled", False):
from app.recording.manager import get_recording_manager
@ -167,6 +172,11 @@ class ServiceManager:
if not self.is_leader:
return
self.coordinator.stop()
try:
from app.analysis.manager import shutdown_analysis
shutdown_analysis()
except Exception:
logger.exception("analysis shutdown failed")
try:
from app.recording.manager import get_recording_manager
get_recording_manager().stop()

View File

@ -125,6 +125,9 @@ urlpatterns = [
path('alarm/openClearAlarms', AnalysisView.alarm_openClearAlarms),
path('analysis/openStatus', AnalysisView.analysis_openStatus),
path('analysis/openPreviewStart', AnalysisView.analysis_openPreviewStart),
path('analysis/openPreviewData', AnalysisView.analysis_openPreviewData),
path('analysis/openPreviewStop', AnalysisView.analysis_openPreviewStop),
path('analysis/openStart', AnalysisView.analysis_openStart),
path('analysis/openStop', AnalysisView.analysis_openStop),
path('analysis/openReloadZones', AnalysisView.analysis_openReloadZones),

View File

@ -4,6 +4,7 @@ import os
from urllib.parse import quote
from framework.settings import BASE_DIR
from monitor_runtime.paths import RESOURCE_ROOT, DESKTOP
from app.utils.Secrets import get_runtime_secret
@ -85,7 +86,9 @@ class Config:
# —— 路径与工具 ——
self.ffmpeg = str(os.environ.get("MONITOR_FFMPEG") or config_data.get("ffmpeg", "ffmpeg") or "ffmpeg").strip()
self.fontPath = _resolve_path(config_data.get("fontPath", ""), base)
if DESKTOP:
self.ffmpeg = str(RESOURCE_ROOT / "tools" / "ffmpeg.exe")
self.fontPath = _resolve_path(config_data.get("fontPath", ""), str(RESOURCE_ROOT))
self.uploadDir = _resolve_path(config_data.get("uploadDir", "static/upload"), base)
self.storageDir = _resolve_path(config_data.get("storageDir", "static/storage"), base)
@ -95,9 +98,13 @@ class Config:
self.mediaRtmpPort = _int(config_data.get("mediaRtmpPort"), 10935)
self.isEnableMediaProxyRtmp = _bool(config_data.get("isEnableMediaProxyRtmp"), False)
self.mediaSecret = get_runtime_secret("media_secret")
self.mediaStartPath = _resolve_path(config_data.get("mediaStartPath", ""), base)
self.mediaStartPath = _resolve_path(config_data.get("mediaStartPath", ""), str(RESOURCE_ROOT))
if DESKTOP:
self.mediaStartPath = str(RESOURCE_ROOT / "zlm" / "monitor_zlm.exe")
self.mediaStartConfigPath = _resolve_path(config_data.get("mediaStartConfigPath", ""), base)
self.autoStartMedia = _bool(config_data.get("autoStartMedia"), False)
if DESKTOP:
self.autoStartMedia = True
self.adminHost = "http://" + self.internalHost + ":" + str(self.adminPort)
self.mediaHttpHost = "http://" + self.internalHost + ":" + str(self.mediaHttpPort)
@ -107,7 +114,7 @@ class Config:
self.analysisConfThreshold = _float(config_data.get("analysisConfThreshold"), 0.4)
self.analysisProcessMode = _int(config_data.get("analysisProcessMode"), 1)
self.analysisSharedInference = _bool(config_data.get("analysisSharedInference"), True)
self.analysisInferenceWorkers = max(1, _int(config_data.get("analysisInferenceWorkers"), 2))
self.analysisInferenceWorkers = max(1, _int(config_data.get("analysisInferenceWorkers"), 1))
# —— 录像 ——
self.recordingEnabled = _bool(config_data.get("recordingEnabled"), False)

View File

@ -286,6 +286,8 @@ class GB28181SipServer:
self.running = False
self.devices: Dict[str, Device] = {}
self.lock = threading.Lock()
# 分进程模式下web进程无实时注册表外发控制指令用的事件序号兜底
self._fallback_sn = 0
# TCP连接池SIP信令TCP模式使用
self.tcp_connections: Dict[tuple, socket.socket] = {} # {(ip, port): client_socket}
@ -371,6 +373,12 @@ class GB28181SipServer:
else:
# UDP模式直接sendto
try:
if self.sock is None:
# 分进程模式本进程未绑定SIP端口如web进程
# 为外发设备控制指令(MESSAGE)临时创建UDP socket
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as tmp_sock:
tmp_sock.sendto(encoded_data, addr)
else:
self.sock.sendto(encoded_data, addr)
return True
except Exception as e:
@ -1223,7 +1231,11 @@ class GB28181SipServer:
to_tag = to_header.split('tag=')[-1].strip()
# 更新通道状态为推流中
self._update_channel_streaming_by_callid(call_id, streaming=True, forward_state=1, inviting=False, to_tag=to_tag)
ch = self._update_channel_streaming_by_callid(call_id, streaming=True, forward_state=1, inviting=False, to_tag=to_tag)
# 同步 forward_state=1 回 DB前端播放按钮依赖此字段
if ch and self.admin_host:
threading.Thread(target=ch.update_admin, args=(self,), daemon=True).start()
# 发送ACK
self._send_ack(message, addr)
@ -1414,9 +1426,11 @@ class GB28181SipServer:
"""
根据Call-ID释放通道资源
Args:
call_id: INVITE会话的Call-ID
Returns:
被释放的 Channel 对象未找到时返回 None
"""
released_channel = None
# 清理pending invite
channel_id_to_clean = None
with self.pending_invites_lock:
@ -1449,8 +1463,10 @@ class GB28181SipServer:
channel.streaming = False
channel.forward_state = 0
channel.inviting = False
released_channel = channel
self.logger.debug(f"[GSS] ✓ 已重置通道状态: {channel_id_to_clean}")
if released_channel is None:
with self.lock:
for device in self.devices.values():
for channel in device.channels:
@ -1475,18 +1491,23 @@ class GB28181SipServer:
channel.streaming = False
channel.forward_state = 0
channel.inviting = False # 清除inviting标志
return
released_channel = channel
break
if released_channel:
break
# 同步 forward_state=0 回 DB停止推流后前端播放按钮应恢复为未启用态
if released_channel and self.admin_host:
threading.Thread(target=released_channel.update_admin, args=(self,), daemon=True).start()
return released_channel
def _update_channel_streaming_by_callid(self, call_id, streaming=False, forward_state=0, inviting=None, to_tag=None):
"""
根据Call-ID更新通道推流状态
Args:
call_id: INVITE会话的Call-ID
streaming: 是否正在推流
forward_state: 转发状态 0:未转发 1:转发中
inviting: 是否正在INVITE中None表示不修改此状态
to_tag: INVITE 200 OK中的To tagNone表示不修改
Returns:
更新后的 Channel 对象未找到时返回 None
"""
with self.lock:
for device in self.devices.values():
@ -1498,7 +1519,8 @@ class GB28181SipServer:
channel.inviting = inviting
if to_tag is not None:
channel.to_tag = to_tag
return
return channel
return None
def _send_401_unauthorized(self, request, addr):
"""发送401 Unauthorized响应"""
@ -2301,18 +2323,25 @@ class GB28181SipServer:
self.catalog_locks[device_id] = threading.Lock()
return self.catalog_locks[device_id]
def remove_channel(self, channel_id):
def remove_channel(self, channel_id, device_id=None):
"""
从内存中删除指定通道当用户在UI删除流时调用
防止parse_catalog_response的old_channel_map缓存抑制update_admin
导致下级重新注册后上级看不到通道数据
幂等操作通道不存在时不报错
Args:
channel_id: 通道IDGB28181 channelId
device_id: 可选指定从哪台设备下删除该通道
当多台设备共享同一 channel_id非法但会发生
仅删指定 device_id 下的通道不影响其他设备的同名通道
"""
if not channel_id:
return
removed_count = 0
with self.lock:
for device in self.devices.values():
target_devices = [self.devices[device_id]] if device_id and device_id in self.devices else list(self.devices.values())
for device in target_devices:
# 递归清理通道(含子目录)
def _remove_from_list(channels):
nonlocal removed_count
@ -2327,7 +2356,7 @@ class GB28181SipServer:
return kept
device.channels = _remove_from_list(device.channels)
if removed_count > 0:
self.logger.debug(f"[GSS] 已从内存清理通道: {channel_id} (共{removed_count}处)")
self.logger.debug(f"[GSS] 已从内存清理通道: {channel_id}" + (f" (device={device_id})" if device_id else "") + f" (共{removed_count}处)")
def parse_catalog_response(self, body, device_id, parent_id=""):
"""
@ -2431,8 +2460,21 @@ class GB28181SipServer:
self.logger.debug(f"[GSS] {type_icon} 通道: {ch_id} ({name}) [{status}]")
# 自动同步到rebekah_admin与C++版本逻辑一致)
# 【关键修复】只在通道首次出现时调update_admin防止多次Catalog响应并发导致重复插入
if self.admin_host and ch_id not in old_channel_map:
# 【关键修复 v3】
# a) 已存在通道:保留旧的推流态(forward_state/streaming/to_tag)防止Catalog刷新把 DB 里"转发中"覆盖回 0
# b) 每次 Catalog 都调 update_admin幂等安全让 DB 里的 forward_state 与 Channel 内存态一致
existing_ch = old_channel_map.get(ch_id)
if existing_ch:
# 保留推流相关状态,不要被新 Channel 的 forward_state=0 覆盖
channel.streaming = existing_ch.streaming
channel.forward_state = existing_ch.forward_state
channel.call_id = existing_ch.call_id
channel.from_tag = existing_ch.from_tag
channel.to_tag = existing_ch.to_tag
channel.inviting = existing_ch.inviting
channel.invite_error = getattr(existing_ch, 'invite_error', None)
channel.auto_invited = existing_ch.auto_invited
if self.admin_host:
threading.Thread(
target=channel.update_admin,
args=(self,),
@ -3021,15 +3063,20 @@ class GB28181SipServer:
self.logger.error(f"[GSS] ❌ 清理BYE资源异常: {cleanup_e}")
return False, f"bye error: {str(e)}"
def request_ptz(self, client_id, channel_id, ptz_type, val):
def request_ptz(self, client_id, channel_id, ptz_type, val, device_ip=None, device_port=None):
"""
发送PTZ云台控制指令
Args:
client_id: 设备ID
channel_id: 通道ID
ptz_type: PTZ类型0-停止, 1-右转, 3-上转, 5-左转, 7-下转, 9-变焦, 10-光圈, 11-聚焦
val: 速度/
ptz_type: PTZ类型0-停止, 1-, 2-, 3-, 4-,
5-左上, 6-右上, 7-左下, 8-右下,
9-变倍+/放大, 10-变倍-/缩小,
11-聚焦+调远, 12-聚焦-调近, 13-光圈+放大, 14-光圈-缩小
val: 速度/ (0-255方向取水平/垂直速度变倍按1-15档折算)
device_ip: 设备IP可选分进程模式下本进程无实时注册表时由调用方传入
device_port: 设备SIP端口可选配合 device_ip
Returns:
tuple: (success: bool, msg: str)
@ -3038,9 +3085,7 @@ class GB28181SipServer:
# 查找设备和通道并在锁内更新SN避免竞态条件
with self.lock:
device = self.devices.get(client_id)
if not device:
return False, "client not registered"
if device:
channel = self._find_channel(device.channels, channel_id)
if not channel:
return False, "channel not found"
@ -3050,8 +3095,17 @@ class GB28181SipServer:
sn = channel.sn
# 保存设备信息
if device.ip:
device_ip = device.ip
if device.port:
device_port = device.port
else:
# 分进程兜底:本进程无实时注册表(如 web 进程),
# 使用调用方从数据库传入的设备地址pull_stream_ip/port
if not device_ip or not device_port:
return False, "client not registered"
self._fallback_sn += 1
sn = self._fallback_sn
# 构建PTZ指令GB28181标准Hex编码
ptz_cmd = self._build_ptz_command(ptz_type, val)
@ -3089,13 +3143,17 @@ class GB28181SipServer:
# 发送PTZ指令
addr = (device_ip, device_port)
self._send_sip_response(msg, addr)
send_ok = self._send_sip_response(msg, addr)
ptz_names = {0: '停止', 1: '右转', 3: '上转', 5: '左转', 7: '下转', 9: '变焦', 10: '光圈', 11: '聚焦'}
ptz_names = {0: '停止', 1: '', 2: '', 3: '', 4: '', 5: '左上',
6: '右上', 7: '左下', 8: '右下', 9: '放大/变倍+', 10: '缩小/变倍-',
11: '聚焦+', 12: '聚焦-', 13: '光圈+', 14: '光圈-'}
ptz_name = ptz_names.get(ptz_type, f'类型{ptz_type}')
self.logger.debug(f"[GSS] 🎮 已发送PTZ指令: {channel_id} - {ptz_name} (val={val})")
if send_ok:
self.logger.debug(f"[GSS] 🎮 已发送PTZ指令: {channel_id} - {ptz_name} (val={val}) ptzCmd={ptz_cmd} -> {addr[0]}:{addr[1]}")
return True, "success"
self.logger.error(f"[GSS] 🎮 PTZ指令发送失败: {channel_id} - {ptz_name} -> {addr[0]}:{addr[1]}")
return False, f"ptz message send failed: {channel_id}"
except Exception as e:
self.logger.error(f"[GSS] PTZ请求异常: {e}")
@ -3103,44 +3161,57 @@ class GB28181SipServer:
def _build_ptz_command(self, ptz_type, val):
"""
构建PTZ控制指令GB28181标准Hex格式
构建PTZ控制指令GB/T 28181 PTZCmd 8字节Hex格式
帧格式: A5 0F 01 <指令码> <水平速度> <垂直速度> <变倍速度> <校验码>
校验码 = 第1~7字节累加后取低8位 0xA5
指令码(字节4):
方向位: =0x01 =0x02 =0x04 =0x08斜向为位或组合
变倍: 放大=0x10 缩小=0x20
聚焦/光圈(0x40, 是否响应取决于设备固件):
聚焦+=0x44 聚焦-=0x48 光圈+=0x41 光圈-=0x42
速度: 字节5=水平速度(左右), 字节6=垂直速度(上下),
字节7高4位=变倍速度(0~15), 无动作的方向速度填0
说明: 方向/变倍均为持续动作需另发 停止(0) 指令结束
Args:
ptz_type: PTZ类型
ptz_type: PTZ类型 request_ptz 编号一致
val: 速度/ (0-255)
Returns:
str: Hex编码的PTZ指令
str: 大写Hex编码的PTZ指令
"""
# GB28181 PTZ指令格式A5 0F [命令字节] [参数] [校验]
# 简化版:使用标准位置控制
speed = min(max(int(val or 0), 0), 255) # 水平/垂直速度 0-255
# 变倍速度折算到高4位等级 1~15
zoom_level = min(max((speed + 7) // 8, 1), 15) if speed > 0 else 0
speed = min(max(val, 0), 255) # 限制0-255
# 指令码映射
dir_cmd = {1: 0x08, 2: 0x04, 3: 0x02, 4: 0x01,
5: 0x0A, 6: 0x09, 7: 0x06, 8: 0x05} # 上/下/左/右/左上/右上/左下/右下
zoom_cmd = {9: 0x10, 10: 0x20} # 放大/缩小
fi_cmd = {11: 0x44, 12: 0x48, 13: 0x41, 14: 0x42} # 聚焦+/聚焦-/光圈+/光圈-
# 命令字节映射
cmd_map = {
0: 0x00, # 停止
1: 0x02, # 右转
2: 0x0A, # 右上
3: 0x08, # 上转
4: 0x09, # 左上
5: 0x04, # 左转
6: 0x0C, # 左下
7: 0x10, # 下转
8: 0x06, # 右下
9: 0x20, # 变焦
10: 0x40, # 光圈
11: 0x10 # 聚焦
}
if ptz_type == 0:
cmd_byte, pan_speed, tilt_speed, zoom_byte = 0x00, 0, 0, 0x00
elif ptz_type in dir_cmd:
cmd_byte = dir_cmd[ptz_type]
pan_speed = speed if cmd_byte & 0x03 else 0 # 含左右方向才给水平速度
tilt_speed = speed if cmd_byte & 0x0C else 0 # 含上下方向才给垂直速度
zoom_byte = 0x00
elif ptz_type in zoom_cmd:
cmd_byte = zoom_cmd[ptz_type]
pan_speed, tilt_speed = 0, 0
zoom_byte = zoom_level << 4
elif ptz_type in fi_cmd:
cmd_byte = fi_cmd[ptz_type]
pan_speed, tilt_speed, zoom_byte = 0, 0, 0x00
else:
# 未知类型按停止处理,避免误动作
cmd_byte, pan_speed, tilt_speed, zoom_byte = 0x00, 0, 0, 0x00
cmd_byte = cmd_map.get(ptz_type, 0x00)
ptz_bytes = bytes([0xA5, 0x0F, 0x01, cmd_byte, pan_speed, tilt_speed, zoom_byte])
# 计算校验和含帧头0xA5
ptz_bytes += bytes([sum(ptz_bytes) & 0xFF])
# 构建指令A5 0F 01 [cmd] [speed1] [speed2] [00] [校验]
ptz_bytes = bytes([0xA5, 0x0F, 0x01, cmd_byte, speed, speed, 0x00])
# 计算校验和
checksum = sum(ptz_bytes) & 0xFF
ptz_bytes += bytes([checksum])
# 转换为Hex字符串
# 转换为大写Hex字符串
return ptz_bytes.hex().upper()

View File

@ -1,6 +1,7 @@
import json
import os
from framework.settings import BASE_DIR
from monitor_runtime.paths import RESOURCE_ROOT
LANG_UI_DICT = {}
LANG_UI_JSON_CACHE = {}
@ -34,7 +35,7 @@ def _lang_files_mtime():
filename = language_config.get("filename", "")
if not filename:
continue
filepath = os.path.join(BASE_DIR, filename)
filepath = os.path.join(RESOURCE_ROOT, filename)
if os.path.exists(filepath):
mtimes.append(os.path.getmtime(filepath))
settings_path = os.path.join(BASE_DIR, "settings.json")
@ -61,7 +62,7 @@ def reload_lang_dict(force=False):
filename = language_config.get("filename", "")
if not filename:
continue
filepath_language = os.path.join(BASE_DIR, filename)
filepath_language = os.path.join(RESOURCE_ROOT, filename)
try:
language_data = _load_json_file(filepath_language)
lang_ui_dict = _flatten_lang_dict(language_data)

View File

@ -66,10 +66,18 @@ class MediaServerManager(object):
if not cfg or not os.path.isfile(cfg):
return False, "mediaStartConfigPath 无效或文件不存在: %s" % (cfg or "(空)")
if self.api_alive():
if self.managed_pid():
return True, "流媒体服务已在运行"
return False, "流媒体端口被非本实例服务占用;不会接管或结束该进程"
work_dir = os.path.dirname(exe) or str(os.getcwd())
from monitor_runtime.paths import DESKTOP, data
work_dir = str(data(".runtime/zlm")) if DESKTOP else os.path.dirname(exe) or str(os.getcwd())
os.makedirs(work_dir, exist_ok=True)
cmd = [exe, "-c", cfg]
if DESKTOP:
log_dir = data("log/zlm")
log_dir.mkdir(parents=True, exist_ok=True)
cmd += ["--log-dir", str(log_dir)]
logger.info("启动 ZLM: %s", " ".join(cmd))
try:
kwargs = {"cwd": work_dir, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
@ -88,24 +96,6 @@ class MediaServerManager(object):
logger.exception("启动 ZLM 失败")
return False, str(e)
def _kill_by_image(self, exe):
if not exe:
return
name = os.path.basename(exe)
try:
if sys.platform == "win32":
subprocess.run(
["taskkill", "/F", "/IM", name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10,
)
else:
subprocess.run(
["pkill", "-f", exe],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10,
)
except Exception as e:
logger.warning("kill_by_image %s: %s", name, e)
def stop(self):
from app.services.lifecycle import is_service_leader
if not is_service_leader():
@ -124,10 +114,6 @@ class MediaServerManager(object):
pass
self._proc = None
if self.api_alive():
self._kill_by_image(exe)
time.sleep(0.5)
if self.api_alive():
return False, "流媒体服务仍在运行,请检查是否有其他进程占用"
return True, "流媒体服务已停止"

View File

@ -5,7 +5,8 @@ import os
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
from monitor_runtime.paths import DATA_ROOT
PROJECT_ROOT = DATA_ROOT
MANIFEST_PATH = Path(
os.environ.get("MONITOR_TRUSTED_MODEL_MANIFEST", PROJECT_ROOT / ".trusted-models.json")
).resolve()

View File

@ -12,7 +12,8 @@ import base64
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
from monitor_runtime.paths import DATA_ROOT
PROJECT_ROOT = DATA_ROOT
RUNTIME_SECRETS_FILE = Path(
os.environ.get("MONITOR_RUNTIME_SECRETS_FILE", PROJECT_ROOT / ".runtime-secrets.json")
).resolve()

View File

@ -16,6 +16,12 @@ from datetime import datetime
from app.models import StreamModel, ZoneModel, AlarmModel
def _preview_owner(request):
if not request.session.session_key:
request.session.save()
return "%s:%s" % (request.user.pk, request.session.session_key)
def _parse_page_params(request, default_ps=12):
page = request.GET.get('p', 1)
page_size = request.GET.get('ps', default_ps)
@ -256,6 +262,56 @@ def analysis_openStatus(request):
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def analysis_openPreviewStart(request):
if request.method != "POST":
return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "msg_method_not_supported")})
ok, check_msg = f_checkRequestSafe(request)
if not ok:
return f_responseJson({"code": 0, "msg": check_msg})
try:
params = f_parsePostParams(request)
zone = ZoneModel.objects.select_related("stream").prefetch_related(
"algorithms", "algorithms__small_model", "algorithms__detector_model"
).get(id=int(params.get("zone_id", 0)))
from app.analysis.preview_sessions import get_preview_registry
data = get_preview_registry().start(_preview_owner(request), zone)
return f_responseJson({"code": 1000, "msg": "ok", "data": data})
except Exception as e:
return f_responseJson({"code": 0, "msg": str(e)})
def analysis_openPreviewData(request):
if request.method != "GET":
return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "msg_method_not_supported")})
ok, check_msg = f_checkRequestSafe(request)
if not ok:
return f_responseJson({"code": 0, "msg": check_msg})
try:
session_id = request.GET.get("session_id", "")
since = request.GET.get("since")
from app.analysis.preview_sessions import get_preview_registry
data = get_preview_registry().data(_preview_owner(request), session_id, since)
return f_responseJson({"code": 1000, "msg": "ok", "data": data})
except Exception as e:
return f_responseJson({"code": 0, "msg": str(e)})
def analysis_openPreviewStop(request):
if request.method != "POST":
return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "msg_method_not_supported")})
ok, check_msg = f_checkRequestSafe(request)
if not ok:
return f_responseJson({"code": 0, "msg": check_msg})
try:
params = f_parsePostParams(request)
from app.analysis.preview_sessions import get_preview_registry
stopped = get_preview_registry().stop(_preview_owner(request), params.get("session_id", ""))
return f_responseJson({"code": 1000 if stopped else 0,
"msg": "ok" if stopped else "预览会话不存在"})
except Exception as e:
return f_responseJson({"code": 0, "msg": str(e)})
def analysis_openStart(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")

View File

@ -109,6 +109,14 @@ def _parse_control_detect_rate(params):
return interval, frames
def _parse_alarm_repeat(params, default=30.0):
try:
value = float(params.get("alarm_repeat_sec", default))
except (TypeError, ValueError):
value = default
return max(0.0, min(86400.0, value))
def _control_to_dict(z):
algos = []
try:
@ -128,6 +136,7 @@ def _control_to_dict(z):
"loiter_threshold": z.loiter_threshold,
"detect_interval_sec": interval,
"detect_frames": frames,
"alarm_repeat_sec": max(0.0, float(getattr(z, "alarm_repeat_sec", 30) or 0)),
"color": z.color,
"line_a": getattr(z, "line_a", "") or "",
"line_b": getattr(z, "line_b", "") or "",
@ -399,6 +408,7 @@ def control_openAdd(request):
loiter_threshold=int(params.get("loiter_threshold", 0)),
detect_interval_sec=detect_interval_sec,
detect_frames=detect_frames,
alarm_repeat_sec=_parse_alarm_repeat(params),
color=params.get("color", "#169F85"),
line_a=params.get("line_a", ""),
line_b=params.get("line_b", ""),
@ -457,6 +467,8 @@ def control_openEdit(request):
interval, frames = _parse_control_detect_rate(params)
z.detect_interval_sec = interval
z.detect_frames = frames
if "alarm_repeat_sec" in params:
z.alarm_repeat_sec = _parse_alarm_repeat(params)
if "color" in params:
z.color = params["color"]
if "line_a" in params:

View File

@ -82,14 +82,17 @@ def api_on_media_update_stream(request):
now_date = datetime.now()
# 【关键修复】按code的细粒度锁彻底防止并发插入重复编号
# 不同code完全并发同一code串行化
# 注意SQLite写锁是数据库级的不能用transaction.atomic()否则不同code会互相阻塞报"database is locked"
code_lock = _get_code_lock(name)
# 【关键修复 v2】GB28181 多设备共享同一 channel code 时,
# 以 (code + camera_device_id) 复合键查找/锁:
# - 不同 device_id 的同名通道各自独立创建/更新记录
# - 非 GB28181 场景 camera_device_id 为空字符串,退化为纯 code 匹配
# 注意SQLite写锁是数据库级的不能用transaction.atomic(),否则不同复合键会互相阻塞
compound_key = f"{name}::{clientId}"
code_lock = _get_code_lock(compound_key)
with code_lock:
stream = StreamModel.objects.filter(code=name).first()
stream = StreamModel.objects.filter(code=name, camera_device_id=clientId).first()
if stream:
# 编辑不修改nicknamenickname允许用户自定义)
# 编辑:不修改 nickname用户自定义)
pass
else:
# 新增
@ -100,7 +103,7 @@ def api_on_media_update_stream(request):
stream.app = app
stream.name = name
stream.create_time = now_date
stream.add_type = 0
stream.add_type = 21 if pullStreamType == 21 else 0 # 21=GB28181 自动添加标记
stream.state = 0
stream.nickname = cameraName
stream.remark = ""
@ -161,10 +164,17 @@ def api_on_media_delete_stream(request):
if not code:
raise Exception("The parameter code is invalid")
# 支持可选 cameraDeviceIdGB28181 多设备共享 channel code 时
# 按 (code, camera_device_id) 复合键精准删除,避免误删同名不同设备的记录
camera_device_id = params.get("cameraDeviceId") or ""
if camera_device_id:
stream = StreamModel.objects.filter(code=code, camera_device_id=camera_device_id).first()
else:
stream = StreamModel.objects.filter(code=code).first()
if stream:
stream.delete()
g_gb28181SipServer.remove_channel(code)
g_gb28181SipServer.remove_channel(code, device_id=camera_device_id or None)
ret = True
msg = "success"
else:

View File

@ -99,9 +99,9 @@ def api_openStartRecordVideo(request):
return f_responseJson({"code": 0, "msg": "stream not found"})
stream.record_enable = 1
stream.save(update_fields=["record_enable"])
from app.recording.manager import get_recording_manager
ok, info = get_recording_manager().start_stream(stream)
return f_responseJson({"code": 1000 if ok else 0, "msg": info})
# 同进程(嵌入式)立即同步启动;分进程则由领导者进程的同步循环异步接管
_sync_stream_recording(stream)
return f_responseJson({"code": 1000, "msg": "ok"})
def api_openStopRecordVideo(request):
@ -116,9 +116,8 @@ def api_openStopRecordVideo(request):
return f_responseJson({"code": 0, "msg": "stream not found"})
stream.record_enable = 0
stream.save(update_fields=["record_enable"])
from app.recording.manager import get_recording_manager
ok, info = get_recording_manager().stop_stream(stream.id)
return f_responseJson({"code": 1000 if ok else 0, "msg": info})
_sync_stream_recording(stream)
return f_responseJson({"code": 1000, "msg": "ok"})
def _scan_disk_recordings(stream_id=None):

View File

@ -891,6 +891,11 @@ def api_openDel(request):
if stream:
__ret, __msg = GlobalUtils.delStreamProxy(stream, lang=lang)
if stream.delete():
# 同步清理内存里的该通道,避免 Catalog 刷新后自动恢复
g_gb28181SipServer.remove_channel(
stream_code,
device_id=stream.camera_device_id or None,
)
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
else:
@ -904,7 +909,7 @@ def api_openDel(request):
for stream in streams:
__ret, __msg = GlobalUtils.delStreamProxy(stream, lang=lang)
if stream.delete():
g_gb28181SipServer.remove_channel(stream.code)
g_gb28181SipServer.remove_channel(stream.code, device_id=stream.camera_device_id or None)
success_count += 1
else:
error_count += 1
@ -1042,20 +1047,35 @@ def api_openPtz(request):
g_logger.info("StreamView.openPtz() params:%s" % str(params))
code = params.get("code", "").strip()
try:
ptzType = int(params.get("ptzType", 0))
val = int(params.get("val", 0))
except (TypeError, ValueError):
ptzType = -1
# 优先读取前端实际参数名 ptzSpeed兼容旧字段 val
try:
val = int(params.get("ptzSpeed", params.get("val", 0)) or 0)
except (TypeError, ValueError):
val = 0
if ptzType < 0 or ptzType > 11:
raise Exception(LANG_VIEWS_T(request, "ptz_type_range_error"))
if ptzType < 0 or ptzType > 14:
msg = LANG_VIEWS_T(request, "ptz_type_range_error")
else:
# 除停止外均需非零速度才会动作越界值钳制到0-255
if ptzType != 0 and val < 1:
val = 32
val = min(max(val, 0), 255)
stream = StreamModel.objects.filter(code=code).first()
if stream:
if stream.pull_stream_type == 21:
ret, msg = g_gb28181SipServer.request_ptz(client_id=stream.camera_device_id,
ret, msg = g_gb28181SipServer.request_ptz(
client_id=stream.camera_device_id,
channel_id=stream.code,
ptz_type=ptzType,
val=val)
val=val,
# 分进程模式web进程无实时SIP注册表从库中取设备地址外发
device_ip=stream.pull_stream_ip or None,
device_port=stream.pull_stream_port or 0)
else:
msg = LANG_VIEWS_T(request, "stream_pull_type_not_supported")
else:

View File

@ -8,7 +8,7 @@
"mediaStartPath": "zlm\\bin.x86.windows10\\monitor_zlm.exe",
"mediaStartConfigPath": "zlm\\bin.x86.windows10\\config.ini",
"autoStartMedia": true,
"ffmpeg": "ffmpeg",
"ffmpeg": "D:\\00_Workspace\\python\\video_monitor\\.venv\\Lib\\site-packages\\imageio_ffmpeg\\binaries\\ffmpeg-win-x86_64-v7.1.exe",
"fontPath": "static\\fonts\\tsimhei.ttf",
"uploadDir": "static\\upload",
"storageDir": "static\\storage",
@ -23,16 +23,16 @@
"analysisConfThreshold": 0.4,
"analysisProcessMode": 1,
"analysisSharedInference": true,
"analysisInferenceWorkers": 2,
"recordingEnabled": false,
"analysisInferenceWorkers": 1,
"recordingEnabled": true,
"recordingSegmentSeconds": 600,
"recordingRetainDays": 7,
"recordingRetainGb": 0.0,
"sipServer": {
"sipServerIp": "192.168.1.7",
"sipServerIp": "192.168.1.60",
"sipServerPort": 15060,
"sipTransferMode": 0,
"sipServerId": "34020000009000009999",
"sipServerId": "34020000002000000001",
"sipServerRealm": "3402000000",
"sipServerTimeout": 300,
"sipServerExpiry": 1800,

View File

@ -0,0 +1,24 @@
# Monitor Windows 安装说明
适用 Windows 10/11 x64、CPU 推理。安装程序包含所需 Python 运行环境、FFmpeg 和 ZLM无需另外安装这些工具。程序未做代码签名Windows 可能显示未知发布者提示。
1. 使用管理员权限运行 Monitor-Setup-x64.exe。默认程序目录为 Program Files\Monitor。安装时填写实际日常使用的 Windows 账号,只有该账号、管理员和 SYSTEM 获准访问运行数据。
2. 使用上述日常账号双击桌面 Monitor。首次启动自动打开本机初始化网页不要分享地址中的一次性凭证。
3. 导出机器申请文件交给厂商,在本机网页导入厂商签发的授权。设置管理员用户名和至少 12 位密码,选择接入摄像头/局域网的网卡 IP。没有固定默认密码。
4. 服务自动重启后,本机通过 http://127.0.0.1:10001 登录;同一局域网的其他电脑访问 http://部署电脑IP:10001。视频分析需登录后手动启动。
没有互联网连接也能安装和授权;局域网网卡仍需有效 IPv4 地址。安装防火墙规则只允许专用/域网络及本地子网。不要将管理端映射到公网。改变端口后由管理员同步调整防火墙。
关闭浏览器不会停止程序。右键托盘 Monitor可打开网页、查看状态、打开日志、重启或退出。重复双击只打开已有实例。退出会停止本程序的 Web、分析、录像、SIP 和 ZLM不会按名称结束其他软件的流媒体进程。首次启动失败时检查 ProgramData\Monitor\log。
运行数据位于 ProgramData\Monitor包括数据库、配置、凭据密钥、授权、模型和录像。升级前退出程序并备份整个数据目录数据库结构升级前还会自动备份。卸载默认保留运行数据。不要单独复制数据库而丢弃加密密钥。
安装包不附带模型、摄像头配置或开发数据。导入 .pt 模型前应确认来源;受信任模型登记示例(无需 Python
```powershell
& 'C:\Program Files\Monitor\Monitor.exe' --trust-model 'D:\Models\reviewed.pt' --yes
```
机器授权不绑定 IP/MAC换硬件或重装系统后可能需要重新申请。授权过期、错误或明显时间回拨会停止业务保留本机登录和 /license 续期入口。禁止通过修改系统时间绕过授权。
本包通过开发机上的冻结 EXE 自检和隔离服务测试。干净 Windows 10/11、普通用户安装部署、第二台局域网电脑及真实 RTSP/GB28181 摄像头、录像和实际模型仍需现场验收。商业分发前须由发行方核实第三方软件和模型的许可。

167
deploy/windows/Monitor.iss Normal file
View File

@ -0,0 +1,167 @@
#ifndef Payload
#error Payload is required
#endif
#ifndef Output
#error Output is required
#endif
[Setup]
AppId={{65B3E62C-937C-4FAA-B1F0-2F1C3D2B9955}
AppName=Monitor
AppVersion=1.003.1
DefaultDirName={autopf}\Monitor
DefaultGroupName=Monitor
OutputDir={#Output}
OutputBaseFilename=Monitor-Setup-x64
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
MinVersion=10.0
PrivilegesRequired=admin
Compression=zip/7
SolidCompression=no
WizardStyle=modern
UninstallDisplayIcon={app}\Monitor.exe
CloseApplications=no
RestartApplications=no
[Files]
Source: "{#Payload}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Dirs]
Name: "{commonappdata}\Monitor"
[Icons]
Name: "{autodesktop}\Monitor"; Filename: "{app}\Monitor.exe"; WorkingDir: "{app}"
Name: "{group}\Monitor"; Filename: "{app}\Monitor.exe"; WorkingDir: "{app}"
[Run]
Filename: "{app}\Monitor.exe"; Description: "Start Monitor"; Flags: nowait postinstall skipifsilent runasoriginaluser
[Code]
var OwnerPage: TInputQueryWizardPage;
procedure InitializeWizard();
begin
OwnerPage := CreateInputQueryPage(wpSelectDir, 'Monitor Windows account',
'Choose the Windows account that will run Monitor',
'Only this account, Administrators and SYSTEM can access the database and keys. Use DOMAIN\username or COMPUTER\username.');
OwnerPage.Add('Runtime account:', False);
OwnerPage.Values[0] := GetPreviousData('MonitorOwner', GetEnv('USERDOMAIN') + '\' + GetUserNameString);
end;
procedure RegisterPreviousData(PreviousDataKey: Integer);
begin
SetPreviousData(PreviousDataKey, 'MonitorOwner', OwnerPage.Values[0]);
end;
procedure ConfigureDataAcl();
var Code: Integer; UserName: String;
begin
UserName := Trim(OwnerPage.Values[0]);
if UserName = '' then RaiseException('A runtime Windows account is required');
if Pos('"', UserName) > 0 then RaiseException('Unsupported Windows user name');
if not Exec(ExpandConstant('{sys}\icacls.exe'),
'"' + ExpandConstant('{commonappdata}\Monitor') + '" /inheritance:r /grant:r "' +
UserName + ':(OI)(CI)M" "*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F"',
'', SW_HIDE, ewWaitUntilTerminated, Code) or (Code <> 0) then
RaiseException('Unable to restrict Monitor data directory permissions');
end;
procedure AddFirewallRule(Arguments: String);
var Code: Integer;
begin
if not Exec(ExpandConstant('{sys}\netsh.exe'), Arguments, '', SW_HIDE, ewWaitUntilTerminated, Code) or (Code <> 0) then
RaiseException('Unable to configure Monitor firewall rules. Check Windows Firewall and the installation log.');
end;
procedure Firewall(AddRules: Boolean);
var Code: Integer; ExePath: String;
begin
Exec(ExpandConstant('{sys}\netsh.exe'),'advfirewall firewall delete rule name="Monitor Web SIP"','',SW_HIDE,ewWaitUntilTerminated,Code);
Exec(ExpandConstant('{sys}\netsh.exe'),'advfirewall firewall delete rule name="Monitor ZLM TCP"','',SW_HIDE,ewWaitUntilTerminated,Code);
Exec(ExpandConstant('{sys}\netsh.exe'),'advfirewall firewall delete rule name="Monitor ZLM UDP"','',SW_HIDE,ewWaitUntilTerminated,Code);
if not AddRules then Exit;
ExePath := ExpandConstant('{app}\Monitor.exe');
AddFirewallRule(
'advfirewall firewall add rule name="Monitor Web SIP" dir=in action=allow program="' + ExePath +
'" protocol=TCP localport=10001,15060 profile=private,domain remoteip=localsubnet');
AddFirewallRule(
'advfirewall firewall add rule name="Monitor Web SIP" dir=in action=allow program="' + ExePath +
'" protocol=UDP localport=15060 profile=private,domain remoteip=localsubnet');
ExePath := ExpandConstant('{app}\_internal\zlm\monitor_zlm.exe');
AddFirewallRule(
'advfirewall firewall add rule name="Monitor ZLM TCP" dir=in action=allow program="' + ExePath +
'" protocol=TCP localport=10002,10554,10935,20002-40000 profile=private,domain remoteip=localsubnet');
AddFirewallRule(
'advfirewall firewall add rule name="Monitor ZLM UDP" dir=in action=allow program="' + ExePath +
'" protocol=UDP localport=20002-40000 profile=private,domain remoteip=localsubnet');
end;
function BackupRuntimeData(): String;
var Root, Backup, Base: String; Files: TArrayOfString; I, Counter: Integer;
begin
Result := '';
Root := ExpandConstant('{commonappdata}\Monitor');
if not DirExists(Root) then Exit;
if not FileExists(Root + '\monitor.sqlite3') then Exit;
Base := Root + '\backups\installer-' + GetDateTimeString('yyyymmdd-hhnnss', '-', ':');
Backup := Base;
Counter := 0;
while DirExists(Backup) do begin
Counter := Counter + 1;
Backup := Base + '-' + IntToStr(Counter);
end;
if not ForceDirectories(Backup) then begin
Result := 'Cannot create the pre-upgrade data backup directory.';
Exit;
end;
SetArrayLength(Files, 11);
Files[0] := 'monitor.sqlite3';
Files[1] := 'monitor.sqlite3-wal';
Files[2] := 'monitor.sqlite3-shm';
Files[3] := 'config.json';
Files[4] := 'settings.json';
Files[5] := 'network.json';
Files[6] := '.runtime-secrets.json';
Files[7] := '.trusted-models.json';
Files[8] := 'license.json';
Files[9] := 'license-clock.json';
Files[10] := 'zlm-template.ini';
for I := 0 to GetArrayLength(Files)-1 do
if FileExists(Root + '\' + Files[I]) then
if not CopyFile(Root + '\' + Files[I], Backup + '\' + Files[I], True) then begin
Result := 'Pre-upgrade backup failed for ' + Files[I] + '. Installation stopped to protect existing data.';
Exit;
end;
end;
function PrepareToInstall(var NeedsRestart: Boolean): String;
var Code: Integer;
begin
Result := '';
if FileExists(ExpandConstant('{app}\Monitor.exe')) then begin
if not Exec(ExpandConstant('{app}\Monitor.exe'),'--stop','',SW_HIDE,ewWaitUntilTerminated,Code) or (Code <> 0) then begin
Result := 'Please exit Monitor before upgrading.';
Exit;
end;
end;
Result := BackupRuntimeData();
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssPostInstall then begin
ConfigureDataAcl();
Firewall(True);
end;
end;
function InitializeUninstall(): Boolean;
var Code: Integer;
begin
Result := Exec(ExpandConstant('{app}\Monitor.exe'),'--stop','',SW_HIDE,ewWaitUntilTerminated,Code) and (Code = 0);
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
begin
if CurUninstallStep = usUninstall then Firewall(False);
end;

82
deploy/windows/README.md Normal file
View File

@ -0,0 +1,82 @@
# Monitor Windows 部署
## 客户安装与使用
支持 Windows 10/11 x64 的 CPU 部署。运行 `Monitor-Setup-x64.exe` 安装,随后双击桌面 Monitor。安装时需要管理员权限安装器会要求指定日常运行的 Windows 账号(默认当前安装账号);如果使用了另一个管理员账号授权安装,应填写实际操作员的账号。日常启动使用该指定账号。首次启动只监听本机:导出机器申请、导入厂商签名授权、设置至少 12 位管理员密码并选择局域网网卡。完成后自动重启服务,浏览器访问 `http://127.0.0.1:10001/login`;其他电脑使用部署电脑的 IP。
程序目录在 `Program Files\Monitor`,运行数据在 `ProgramData\Monitor`。多 Windows 用户共享同一安装需要系统管理员明确授予数据目录权限,安装器不向所有本机用户开放密钥。不要把程序目录当作数据备份。
托盘可访问网页、查看状态、打开日志、重启和退出。关闭浏览器不会停止服务;退出托盘或注销 Windows 用户会结束服务。本版不是 Windows 系统服务,不自动开机启动。视频分析需登录后手动开始,已配置的录像和视频代理由后台启动。
初始防火墙规则仅作用于专用/域网络、本地子网与本程序。默认端口Web TCP 10001SIP TCP/UDP 15060ZLM TCP 10002、10554、10935RTP TCP/UDP 2000240000含 SIP 分配的 2000230000 与 ZLM 的 3000140000。修改端口或网络网段后由管理员同步调整规则。不要把管理端暴露到公网公网或不可信网络需要另行部署 HTTPS 与访问限制。
安装包不附带开发数据库、摄像头密码、模型、录像或发行私钥。模型通过后台上传;对会反序列化 Python 的 .pt 文件,须先验证来源,再在部署电脑执行:
```powershell
& 'C:\Program Files\Monitor\Monitor.exe' --trust-model 'C:\ProgramData\Monitor\static\upload\weight\reviewed.pt' --yes
```
## 离线签发(仅厂商)
厂商保留源仓库与签发工具;客户只收到安装包和授权文件。使用 Ed25519公钥编译关联到发行包客户程序中没有签发私钥。
本次工作目录内的 `vendor-private/issuer.dpapi` 用当前 Windows 用户 DPAPI 加密,配套公钥是 `vendor-private/license-public.pem`。**不得将 vendor-private 发给客户或提交 Git。** DPAPI 私钥只能在原 Windows 用户及可用的原 DPAPI 凭据下解密;仅复制该文件不能保证重装系统后的恢复。长期发行建议由厂商另外生成带口令的可备份 PEM 私钥,并用对应公钥重新构建。
生成新发行密钥(不会覆盖已有文件):
```powershell
& .build-venv/Scripts/python.exe deploy/windows/issue_license.py keygen --private-key '厂商离线目录/issuer.pem' --public-key '厂商离线目录/public.pem'
```
签发有期限授权;省略 `--expires` 表示永久:
```powershell
& .build-venv/Scripts/python.exe deploy/windows/issue_license.py issue --private-key vendor-private/issuer.dpapi --request '客户机器申请.json' --customer '客户名称' --expires '2027-12-31T23:59:59+08:00' --output '客户授权.json'
```
机器指纹不依赖 IP/MAC。硬件或系统重装可能需要重新签发。过期、签名错误、机器不符或明显时间回拨会禁止业务仍可在本机登录并续期。运行中发现失效会停止后台业务并切换为本机授权模式。离线方案无法可靠阻止本机管理员修改程序或回滚整机快照。
## 构建
需要 Windows x64 Python 3.12、Visual Studio C++ Build Tools、Windows SDK、Inno Setup 6.7.3。依赖来源锁定在 `requirements-windows-cpu.lock.txt``build-tools.lock.txt`。构建脚本使用独立 `.build-venv`,首次可复制当前已验证的 CPU 环境,再安装独立构建依赖;不会修改 `.venv`
```powershell
./deploy/windows/build.ps1 -PublicKey vendor-private/license-public.pem -PrepareEnvironment
```
如工具不在默认路径,提供 `-Vcvars``-Iscc`。也可分阶段执行:
```powershell
& .build-venv/Scripts/python.exe deploy/windows/build.py --public-key vendor-private/license-public.pem --phase compile
& .build-venv/Scripts/python.exe deploy/windows/build.py --public-key vendor-private/license-public.pem --phase bundle
& .build-venv/Scripts/python.exe deploy/windows/build.py --public-key vendor-private/license-public.pem --phase installer
```
完整构建或 `--phase stage` 必须先执行。暂存区只会清理 `.windows-build` 下对应构建目录。业务模块及授权模块编译为 .pydPython 包与 Django 数字迁移保留自动生成的纯导入入口。构建会拒绝回退到未编译业务模块。模板及浏览器 JavaScript 必须随程序提供,不承诺隐藏客户端代码或绝对防逆向。
输出为 `dist/windows/Monitor-Setup-x64.exe`、SHA-256 校验文件与运行文件清单。没有代码签名证书时生成未签名安装包Windows 可能提示未知发布者。可给 `build.py` 提供 `--sign-thumbprint` 使用证书库中的证书;生产签名的时间戳、证书权限由发行方管理。
## 数据保护、升级与诊断
安装器升级前要求原进程退出,并备份数据库(含存在的 WAL、配置、密钥及授权备份失败会中止覆盖安装。数据库迁移前也会自动备份数据库、配置与运行密钥。升级不重置用户、不重新生成已有加密密钥。卸载默认保留 ProgramData 数据。备份应包含整个数据目录,不能只备份 SQLite否则摄像头密码等加密数据可能无法恢复。本版不自动导入旧开发目录数据库。
日志:`ProgramData\Monitor\log\desktop.log`、`service.log`、应用日志ZLM 工作目录:`.runtime\zlm`。端口冲突不会自动杀死其他进程。服务失败会明确显示状态,不无限重启。
可在独立空数据目录运行离线自检(不会接入摄像头或加载客户模型):
```powershell
$env:MONITOR_DATA_DIR = 'D:\Monitor-validation'
& 'C:\Program Files\Monitor\Monitor.exe' --self-test
```
结果写入指定数据目录的 `.runtime/self-test.json`,错误见日志。验证后删除该进程环境变量,避免日常误用测试数据目录。
发布前须在干净 Windows 10/11 主机验收安装、普通用户运行、第二台局域网电脑访问、真实 RTSP/GB28181 接入与录像。当前开发机测试不能替代这些环境验收。商业分发须核实 Ultralytics、FFmpeg、ZLM、播放器与模型的许可编译不会改变第三方许可义务。
## 第三方发行检查
当前附带 FFmpeg 报告版本为 `7.1-essentials_build-www.gyan.dev`,构建含 `--enable-gpl --enable-version3`。ZLM 二进制随原仓库提供,版本文件标记 `2025/05/11 master`。第三方包的许可文本随运行环境收集在 `third-party-notices`;这些文本不能替代对应源码提供义务或商业授权。当前产物用于部署验证,商业交付前仍须核验所用具体二进制、模型和播放器的完整授权链。
参考:[Ultralytics 官方许可](https://www.ultralytics.com/license)、[FFmpeg 官方许可说明](https://ffmpeg.org/legal.html)、[PyInstaller 对 Cython 和源码保护的说明](https://pyinstaller.org/en/stable/operating-mode.html#hiding-the-source-code)。
本机自动验证脚本:`tests/test_windows_license.py`(签名与机器约束)、`tests/windows_integration.py`隔离数据库、CSRF、授权与初始化、`tests/windows_lifecycle.py`(真实服务、到期续期、端口冲突与 Job Object。生命周期测试要求显式指定可解密的厂商 DPAPI 测试签发密钥,生成的短期测试授权不得放入安装包。`--self-test` 还执行随机权重 YOLO 的 CPU 推理,仅验证执行链路,不代表模型准确率验收。

View File

@ -0,0 +1,20 @@
# Windows 发布验证记录
日期2026-08-31。安装版本 1.003.1(业务版本 1.003Windows 11 x64 开发机CPU 构建,无代码签名。
已完成:
- 85 个自有模块编译为 .pyd18,799 个运行文件完成发布扫描和 SHA-256 清单生成。PYZ 中的 13 个自有入口均为包初始化或迁移纯导入入口,不包含业务实现字节码。
- 27 项已有安全回归、4 项授权单元测试及 8 组隔离 Web/数据库验证通过。
- 真正的 Monitor.exe 在移除外部 Python/开发工具 PATH 后通过 Django/CPU Torch/OpenVINO/YOLO 随机权重预测、冻结多进程和资源自检。中文及空格数据路径通过。
- 真正的 EXE 通过 HTTP 首次设置、CSRF、签名授权、登录、Web/ZLM/SIP 启动、运行中授权到期停止、管理员续期恢复、端口冲突、自有进程正常退出和 Job Object 强制清理。
- 真正的托盘程序通过同时双击单实例、打开本机页面、--stop 退出与子进程清理。没有自动恢复分析任务。
- 推理库的实际配置目录断言位于运行数据目录内,预先创建配置与缓存目录,避免 Program Files 写入。
- 安装器在覆盖前停止程序并备份数据库/WAL、配置、密钥和授权复制失败则中止仅编译验证尚未实测完整安装升级。
- ONVIF WSDL 单独收集;安装防火墙脚本覆盖 SIP 实际 RTP 分配范围。Inno Setup 6.7.3 安装脚本编译通过。
尚不能声明通过:干净 Windows 10/11 安装/升级/卸载、独立普通账号运行及安装 ACL、第二台局域网电脑、真实 RTSP/GB28181 摄像头/录像、客户模型效果。开发机已有组件PATH 隔离测试不能替代干净系统测试。
当前包用于部署验收。商业分发需核实第三方许可未提供代码签名证书。OpenVINO 扫描提示缺少 NPU 的 ze_loader.dllCPU 自检通过,本版不承诺 NPU/GPU 支持。
详细机器可读结果随包提供 validation-record.json版本和校验文件见 dist/windows。测试过程只使用 .windows-build 或系统临时目录,没有迁移、打包或修改现有开发数据库与摄像头数据。

View File

@ -0,0 +1,4 @@
Cython==3.1.3
pystray==0.19.5
whitenoise==6.9.0
wheel==0.45.1

34
deploy/windows/build.ps1 Normal file
View File

@ -0,0 +1,34 @@
param(
[Parameter(Mandatory=$true)][string]$PublicKey,
[string]$Iscc,
[string]$Vcvars,
[switch]$PrepareEnvironment
)
$ErrorActionPreference = 'Stop'
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
$buildPython = Join-Path $projectRoot '.build-venv/Scripts/python.exe'
if ($PrepareEnvironment) {
$sourcePython = Join-Path $projectRoot '.venv/Scripts/python.exe'
if (-not (Test-Path -LiteralPath $sourcePython)) {
throw 'Create the CPU project environment from requirements-windows-cpu.lock.txt first.'
}
if (-not (Test-Path -LiteralPath $buildPython)) {
& $sourcePython -m venv (Join-Path $projectRoot '.build-venv')
if ($LASTEXITCODE -ne 0) { throw 'Build venv creation failed' }
}
# Copy a pinned runtime snapshot; no symlinks and no changes to the developer environment.
$sourceLib = Join-Path $projectRoot '.venv/Lib/site-packages'
$buildLib = Join-Path $projectRoot '.build-venv/Lib/site-packages'
& $sourcePython -c 'import shutil,sys;shutil.copytree(sys.argv[1],sys.argv[2],dirs_exist_ok=True)' $sourceLib $buildLib
if ($LASTEXITCODE -ne 0) { throw 'Runtime snapshot failed' }
& $buildPython -m pip install --no-deps -r (Join-Path $PSScriptRoot 'build-tools.lock.txt')
if ($LASTEXITCODE -ne 0) { throw 'Build tools installation failed' }
}
if (-not (Test-Path -LiteralPath $buildPython)) { throw 'Use -PrepareEnvironment for the first build.' }
& $buildPython -m pip check
if ($LASTEXITCODE -ne 0) { throw 'Dependency check failed' }
$buildArgs = @((Join-Path $PSScriptRoot 'build.py'),'--public-key',(Resolve-Path -LiteralPath $PublicKey).Path)
if ($Iscc) { $buildArgs += @('--iscc',$Iscc) }
if ($Vcvars) { $buildArgs += @('--vcvars',$Vcvars) }
& $buildPython @buildArgs
exit $LASTEXITCODE

337
deploy/windows/build.py Normal file
View File

@ -0,0 +1,337 @@
"""Reproducible Windows release build. Run with the independent .build-venv Python."""
import argparse
import ast
import hashlib
import importlib.metadata
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[2]
WORK = ROOT / '.windows-build'
STAGE = WORK / 'stage'
OWN = ('app', 'framework', 'monitor_runtime')
def run(args, **kwargs):
print('RUN', subprocess.list2cmdline([str(x) for x in args]), flush=True)
subprocess.run([str(x) for x in args], check=True, **kwargs)
def clean(directory):
directory = directory.resolve()
if not directory.is_relative_to(WORK.resolve()) or directory == WORK.resolve():
raise ValueError('Refusing to remove a path outside build staging')
if directory.exists():
shutil.rmtree(directory)
directory.mkdir(parents=True)
def compiler_environment(vcvars):
if not vcvars:
candidates = list(Path('C:/Program Files (x86)/Microsoft Visual Studio').glob('*/BuildTools/VC/Auxiliary/Build/vcvars64.bat'))
candidates += list(Path('C:/Program Files/Microsoft Visual Studio').glob('*/*/VC/Auxiliary/Build/vcvars64.bat'))
if not candidates:
raise RuntimeError('Install Visual Studio C++ Build Tools and Windows SDK, or pass --vcvars')
vcvars = str(sorted(candidates)[-1])
# Generated batch contains only validated local paths, never arbitrary shell input.
if any(c in vcvars for c in '"\r\n%'):
raise ValueError('Unsupported vcvars path')
script = WORK / 'compiler-env.cmd'
script.write_text('@echo off\r\ncall "' + vcvars + '" >nul\r\nif errorlevel 1 exit /b 1\r\nset\r\n', encoding='utf-8')
raw = subprocess.check_output(['cmd.exe', '/d', '/c', str(script)])
env = {k.upper(): v for k, v in os.environ.items()}
for line in raw.decode('mbcs', errors='replace').splitlines():
if '=' in line:
key, value = line.split('=', 1)
env[key.upper()] = value
compiler_bin = Path(env['VCTOOLSINSTALLDIR']) / 'bin' / 'Hostx64' / 'x64'
sdk_bin = Path(env['WINDOWSSDKVERBINPATH']) / 'x64'
env['PATH'] = str(compiler_bin) + ';' + str(sdk_bin) + ';' + env['PATH']
if not (compiler_bin / 'cl.exe').exists():
raise RuntimeError('C++ compiler is missing: ' + str(compiler_bin))
env['DISTUTILS_USE_SDK'] = '1'
env['MSSdk'] = '1'
return env
def verify_environment():
if sys.version_info[:3] != (3, 12, 13):
raise RuntimeError('This release is pinned to Python 3.12.13 x64')
for lock in (ROOT/'requirements-windows-cpu.lock.txt',ROOT/'deploy/windows/build-tools.lock.txt'):
for line in lock.read_text(encoding='utf-8-sig').splitlines():
line=line.strip()
if not line or line.startswith('#'):
continue
name,expected=line.split('==',1)
actual=importlib.metadata.version(name)
if actual != expected:
raise RuntimeError(f'Locked dependency mismatch: {name}: {actual} != {expected}')
if '+cpu' not in importlib.metadata.version('torch'):
raise RuntimeError('Only the locked CPU torch build is supported')
def stage(public_key):
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
if not isinstance(serialization.load_pem_public_key(public_key.read_bytes()), Ed25519PublicKey):
raise ValueError('An Ed25519 PUBLIC key is required')
clean(STAGE)
source = WORK / 'sources'
clean(source)
modules = []
glue = []
external = set()
for package in OWN:
for path in sorted((ROOT / package).rglob('*.py')):
if '__pycache__' in path.parts or path.name == 'tests.py':
continue
relative = path.relative_to(ROOT)
if not path.stem.isidentifier():
# Django's numbered migration names need an import-only glue module.
stub = STAGE / relative
stub.parent.mkdir(parents=True, exist_ok=True)
private_name = '_m' + path.stem
stub.write_text('from .' + private_name + ' import Migration\n', encoding='utf-8')
glue.append('.'.join(relative.with_suffix('').parts))
relative = relative.with_name(private_name + '.py')
body = path.read_text(encoding='utf-8-sig')
if relative.as_posix() == 'monitor_runtime/release.py':
body = 'RELEASE_BUILD = True\n'
tree = ast.parse(body)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
external.update(alias.name for alias in node.names if alias.name.split('.')[0] not in OWN)
if isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
if node.module.split('.')[0] not in OWN:
external.add(node.module)
if path.name == '__init__.py':
meaningful = [n for n in tree.body if not (isinstance(n, ast.Expr) and isinstance(n.value, ast.Constant))]
output = STAGE / relative
output.parent.mkdir(parents=True, exist_ok=True)
glue.append('.'.join(relative.parent.parts))
output.write_text('from ._package import *\n' if meaningful else '', encoding='utf-8')
if not meaningful:
continue
relative = relative.with_name('_package.py')
output = source / relative
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(body, encoding='utf-8')
modules.append(('.'.join(relative.with_suffix('').parts), str(output)))
manifest = {'modules': modules, 'glue': glue, 'external': sorted(external)}
(WORK / 'modules.json').write_text(json.dumps(manifest, indent=2), encoding='utf-8')
for folder in ('templates',):
shutil.copytree(ROOT / folder, STAGE / folder)
shutil.copytree(ROOT / 'static', STAGE / 'static', ignore=shutil.ignore_patterns('upload', 'storage', '__pycache__'))
shutil.copytree(STAGE / 'static', STAGE / 'public-static')
shutil.copytree(ROOT / 'deploy/windows', STAGE / 'deploy/windows',
ignore=shutil.ignore_patterns('*.py', '*.ps1', '*.iss', '*.md', '__pycache__'))
for path in ROOT.glob('language-*.json'):
shutil.copyfile(path, STAGE / path.name)
shutil.copyfile(public_key, STAGE / 'license-public.pem')
(STAGE / 'zlm').mkdir()
shutil.copyfile(ROOT / 'zlm/bin.x86.windows10/monitor_zlm.exe', STAGE / 'zlm/monitor_zlm.exe')
import imageio_ffmpeg
(STAGE / 'tools').mkdir()
shutil.copyfile(imageio_ffmpeg.get_ffmpeg_exe(), STAGE / 'tools/ffmpeg.exe')
shutil.copyfile(ROOT / 'monitor_entry.py', STAGE / 'monitor_entry.py')
versions = {d.metadata['Name']: d.version for d in importlib.metadata.distributions() if d.metadata['Name']}
(STAGE / 'dependency-versions.json').write_text(json.dumps(versions, indent=2), encoding='utf-8')
# Include installed dependency license notices without copying package source trees.
notices = STAGE / 'third-party-notices'
notices.mkdir()
for distribution in importlib.metadata.distributions():
name = distribution.metadata['Name']
for item in distribution.files or []:
if '.dist-info' not in str(item):
continue
if Path(item).name.lower().startswith(('license', 'copying', 'notice')):
target = notices / name / Path(item).name
target.parent.mkdir(exist_ok=True)
origin = distribution.locate_file(item)
if origin.is_file():
shutil.copyfile(origin, target)
def compile_modules(vcvars):
script = WORK / 'compile_extensions.py'
script.write_text("""import json
from pathlib import Path
from setuptools import setup, Extension
from Cython.Build import cythonize
work=Path(__file__).resolve().parent
modules=json.loads((work/'modules.json').read_text())['modules']
extensions=[Extension(name,[source]) for name,source in modules]
setup(name='monitor-private', ext_modules=cythonize(extensions,
build_dir=str(work/'cython'), compiler_directives={
'language_level':3,'binding':True,'annotation_typing':False,
'infer_types':False,'embedsignature':False}, nthreads=0),
script_args=['build_ext','--build-lib',str(work/'stage'),'--build-temp',str(work/'objects'),'-j','2'])
""", encoding='utf-8')
run([sys.executable, script], env=compiler_environment(vcvars), cwd=WORK)
def bundle():
manifest = json.loads((WORK / 'modules.json').read_text())
spec = WORK / 'monitor.spec'
spec.write_text("""import json
from pathlib import Path
from PyInstaller.utils.hooks import collect_all, collect_submodules, copy_metadata
work=Path(SPECPATH)
stage=work/'stage'
manifest=json.loads((work/'modules.json').read_text())
datas=[]
binaries=[]
hidden=[name for name,_ in manifest['modules']]+manifest['glue']+manifest['external']
hidden += ['pystray._win32','django.db.backends.sqlite3','django.contrib.auth.hashers',
'django.contrib.sessions.backends.db','django.template.backends.django']
for package in ['django','ultralytics','torch','torchvision','onnxruntime','openvino','onvif','pystray','whitenoise']:
d,b,h=collect_all(package)
datas+=d;binaries+=b;hidden+=h
for name in ['templates','static','public-static','deploy','zlm','tools',
'license-public.pem','dependency-versions.json','third-party-notices']:
child=stage/name
datas.append((str(child),child.name if child.is_dir() else '.'))
for child in stage.glob('language-*.json'):
datas.append((str(child),'.'))
a=Analysis([str(stage/'monitor_entry.py')],pathex=[str(stage)],
binaries=binaries,datas=datas,hiddenimports=sorted(set(hidden)),
excludes=['pytest','IPython','notebook','tkinter','matplotlib.tests'],
noarchive=False)
own=('app','framework','monitor_runtime')
unexpected=[name for name,_,_ in a.pure if name.split('.')[0] in own and name not in manifest['glue']]
if unexpected:
raise RuntimeError('Uncompiled private modules: '+repr(unexpected))
pyz=PYZ(a.pure)
exe=EXE(pyz,a.scripts,[],exclude_binaries=True,name='Monitor',console=False,
debug=False,upx=False,icon=str(work/'monitor.ico'))
coll=COLLECT(exe,a.binaries,a.datas,strip=False,upx=False,name='Monitor')
""", encoding='utf-8')
from PIL import Image
Image.open(ROOT / 'static/images/logo.png').save(WORK / 'monitor.ico', sizes=[(16,16),(32,32),(48,48),(128,128),(256,256)])
env = os.environ.copy()
env['PYTHONPATH'] = str(STAGE)
env['DJANGO_SETTINGS_MODULE'] = 'framework.settings'
env['MONITOR_DATA_DIR'] = str(WORK / 'build-data')
env['MONITOR_DESKTOP'] = '1'
env['MONITOR_SERVICE_MODE'] = 'disabled'
env['MONITOR_BOOTSTRAP_SERVICES'] = 'false'
env['CI'] = 'true'
env['YOLO_OFFLINE'] = 'true'
env['YOLO_AUTOINSTALL'] = 'false'
(WORK / 'yolo/Ultralytics').mkdir(parents=True, exist_ok=True)
env['YOLO_CONFIG_DIR'] = str(WORK / 'yolo')
env['PYINSTALLER_CONFIG_DIR'] = str(WORK / 'pyinstaller-cache')
run([sys.executable, '-m', 'PyInstaller', '--noconfirm', '--clean', '--distpath', WORK / 'dist',
'--workpath', WORK / 'pyinstaller', spec], env=env, cwd=STAGE)
finalize_payload()
def finalize_payload():
payload = WORK / 'dist/Monitor'
if not (payload/'Monitor.exe').exists():
raise RuntimeError('Bundle must succeed before payload finalization')
internal = payload/'_internal'
# Extension modules are immutable release code, refreshed after incremental compilation.
for package in OWN:
for compiled in (STAGE/package).rglob('*.pyd'):
target=internal/compiled.relative_to(STAGE)
target.parent.mkdir(parents=True,exist_ok=True)
shutil.copyfile(compiled,target)
# onvif-zeep installs WSDL beside its package, outside collect_all('onvif').
import onvif
wsdl=Path(onvif.__file__).resolve().parent.parent/'wsdl'
if not (wsdl/'devicemgmt.wsdl').is_file():
raise RuntimeError('ONVIF WSDL resources are missing from the build environment')
shutil.copytree(wsdl,internal/'wsdl',dirs_exist_ok=True)
audit(payload)
def audit(folder):
forbidden = {'.runtime-secrets.json','.trusted-models.json','monitor.sqlite3','config.json',
'license.json','license-clock.json'}
for path in folder.rglob('*'):
relative = path.relative_to(folder)
parts = relative.parts[1:] if relative.parts[0] == '_internal' else relative.parts
if parts and parts[0] in OWN:
if path.suffix.lower() in ('.py','.pyc','.pyo','.c','.cpp','.html'):
raise RuntimeError('Private source/code artifact: '+str(path))
if path.suffix.lower() == '.log':
raise RuntimeError('Runtime log in release: '+str(path))
if path.name in forbidden or 'private' in path.name.lower() and path.suffix == '.pem':
raise RuntimeError('Runtime data in release: '+str(path))
digest = lambda p: hashlib.file_digest(p.open('rb'), 'sha256').hexdigest()
manifest = {str(p.relative_to(folder)):digest(p) for p in folder.rglob('*') if p.is_file()}
(WORK / 'release-files.sha256.json').write_text(json.dumps(manifest, indent=2), encoding='utf-8')
print('Release payload audit passed:', len(manifest), 'files')
def validate_payload():
import datetime
payload=WORK/'dist/Monitor'
test_data=WORK/('release-self-test-'+datetime.datetime.now().strftime('%Y%m%d-%H%M%S-%f'))
env=os.environ.copy()
env['MONITOR_DATA_DIR']=str(test_data)
env.pop('PYTHONPATH',None);env.pop('PYTHONHOME',None)
windows=env['SYSTEMROOT']
env['PATH']=windows+'/System32;'+windows+';'+windows+'/System32/WindowsPowerShell/v1.0'
if not (payload/'_internal/wsdl/devicemgmt.wsdl').is_file():
raise RuntimeError('Packaged ONVIF resources are missing')
run([payload/'Monitor.exe','--self-test'],env=env,cwd=WORK,timeout=180)
report=test_data/'.runtime/self-test.json'
result=json.loads(report.read_text(encoding='utf-8'))
if result.get('status') != 'passed':
raise RuntimeError('Frozen dependency self-test failed')
shutil.copyfile(report,WORK/'frozen-self-test.json')
def installer(iscc, sign_thumbprint):
finalize_payload()
validate_payload()
payload = WORK / 'dist/Monitor'
if not iscc:
candidates = [WORK/'tools/Inno/ISCC.exe',Path('C:/Program Files (x86)/Inno Setup 6/ISCC.exe')]
iscc = next((p for p in candidates if p.exists()), None)
if not iscc:
raise RuntimeError('Inno Setup ISCC.exe is required; pass --iscc')
if sign_thumbprint:
run(['signtool.exe','sign','/sha1',sign_thumbprint,'/fd','SHA256',payload/'Monitor.exe'])
manifest_path=WORK/'release-files.sha256.json'
manifest=json.loads(manifest_path.read_text(encoding='utf-8'))
with (payload/'Monitor.exe').open('rb') as handle:
manifest['Monitor.exe']=hashlib.file_digest(handle,'sha256').hexdigest()
manifest_path.write_text(json.dumps(manifest,indent=2),encoding='utf-8')
output = ROOT / 'dist/windows'
output.mkdir(parents=True,exist_ok=True)
run([iscc, '/DPayload='+str(payload), '/DOutput='+str(output), ROOT/'deploy/windows/Monitor.iss'])
exe = output / 'Monitor-Setup-x64.exe'
if sign_thumbprint:
run(['signtool.exe','sign','/sha1',sign_thumbprint,'/fd','SHA256',exe])
with exe.open('rb') as handle:
checksum = hashlib.file_digest(handle,'sha256').hexdigest()
(output/'SHA256SUMS.txt').write_text(checksum+' '+exe.name+'\n',encoding='ascii')
shutil.copyfile(WORK/'release-files.sha256.json',output/'release-files.sha256.json')
shutil.copyfile(payload/'_internal/dependency-versions.json',output/'dependency-versions.json')
shutil.copyfile(ROOT/'deploy/windows/DEPLOYMENT.md',output/'DEPLOYMENT.md')
shutil.copyfile(WORK/'frozen-self-test.json',output/'frozen-self-test.json')
print('INSTALLER:', exe)
def main():
parser=argparse.ArgumentParser()
parser.add_argument('--public-key',type=Path,required=True)
parser.add_argument('--phase',choices=['all','stage','compile','bundle','finalize','validate','installer'],default='all')
parser.add_argument('--vcvars')
parser.add_argument('--iscc')
parser.add_argument('--sign-thumbprint')
args=parser.parse_args()
if os.name != 'nt' or sys.maxsize <= 2**32:
parser.error('Windows x64 Python is required')
if Path(sys.prefix).resolve() != (ROOT/'.build-venv').resolve():
parser.error('Run with .build-venv/Scripts/python.exe; do not modify the development environment')
WORK.mkdir(exist_ok=True)
verify_environment()
if args.phase not in ('all','stage'):
if args.public_key.read_bytes() != (STAGE/'license-public.pem').read_bytes():
parser.error('Public key differs from staged release; run the stage and compile phases first')
if args.phase in ('all','stage'):stage(args.public_key.resolve())
if args.phase in ('all','compile'):compile_modules(args.vcvars)
if args.phase in ('all','bundle'):bundle()
if args.phase == 'finalize':finalize_payload()
if args.phase == 'validate':validate_payload()
if args.phase in ('all','installer'):installer(args.iscc,args.sign_thumbprint)
if __name__=='__main__':
main()

View File

@ -0,0 +1,32 @@
{
"host": "127.0.0.1",
"adminPort": 10001,
"logDebug": false,
"isEnableLoginCaptcha": true,
"isEnableUpdatePopup": false,
"telemetryEnabled": false,
"updateCheckEnabled": false,
"autoAddStreamProxy": true,
"autoAddStreamProxySleep": 15,
"fontPath": "static/fonts/tsimhei.ttf",
"uploadDir": "static/upload",
"storageDir": "static/storage",
"mediaHttpPort": 10002,
"mediaRtspPort": 10554,
"mediaRtmpPort": 10935,
"mediaStartConfigPath": "zlm-template.ini",
"autoStartMedia": true,
"analysisProcessMode": 1,
"analysisSharedInference": true,
"analysisInferenceWorkers": 1,
"analysisTargetFps": 5,
"recordingEnabled": false,
"sipServer": {
"sipServerIp": "127.0.0.1",
"sipServerPort": 15060,
"sipTransferMode": 0,
"sipServerId": "34020000002000000001",
"sipServerRealm": "3402000000",
"rtpTransferMode": 0
}
}

View File

@ -0,0 +1,20 @@
{
"lang_default": "zh",
"languages": {
"zh": {
"code": "zh",
"name": "简体中文",
"filename": "language-zh.json",
"oem": {
"name": "视频监控平台",
"welcome": "欢迎",
"logo_url": "/static/images/logo.png",
"bottom_name": "Monitor",
"is_show_author": false,
"author": "",
"author_link": "",
"check_version_download_url": ""
}
}
}
}

View File

@ -0,0 +1,75 @@
"""Vendor-only offline issuer. Never include this tool or its private key in a customer installer."""
import argparse
import base64
from datetime import datetime, timezone
import getpass
import json
from pathlib import Path
import sys
import uuid
sys.path.insert(0,str(Path(__file__).resolve().parents[2]))
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from monitor_runtime.licensing import canonical, verify_document, timestamp
def main():
parser=argparse.ArgumentParser()
sub=parser.add_subparsers(dest='command',required=True)
keygen=sub.add_parser('keygen')
keygen.add_argument('--private-key',type=Path,required=True)
keygen.add_argument('--public-key',type=Path,required=True)
keygen.add_argument('--dpapi',action='store_true',help='Encrypt private key for current Windows account')
issue=sub.add_parser('issue')
issue.add_argument('--private-key',type=Path,required=True)
issue.add_argument('--request',type=Path,required=True)
issue.add_argument('--customer',required=True)
issue.add_argument('--expires',help='ISO 8601 time with timezone; omitted means perpetual')
issue.add_argument('--output',type=Path,required=True)
args=parser.parse_args()
if args.command=='keygen':
if args.private_key.exists() or args.public_key.exists():
parser.error('Refusing to overwrite existing issuer keys')
key=Ed25519PrivateKey.generate()
if args.dpapi:
from monitor_runtime.windows import protect
raw=key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption())
encoded=b'MONITOR-DPAPI-V1\n'+base64.b64encode(protect(raw))
else:
password=getpass.getpass('Private-key password (minimum 12 characters): ')
if len(password)<12 or password!=getpass.getpass('Confirm password: '):
parser.error('Password too short or confirmation mismatch')
encoded=key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,
serialization.BestAvailableEncryption(password.encode()))
args.private_key.parent.mkdir(parents=True,exist_ok=True)
args.public_key.parent.mkdir(parents=True,exist_ok=True)
with args.private_key.open('xb') as f:f.write(encoded)
with args.public_key.open('xb') as f:
f.write(key.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo))
print('Issuer keypair created. Keep the private key offline and backed up.')
return
raw=args.private_key.read_bytes()
if raw.startswith(b'MONITOR-DPAPI-V1\n'):
from monitor_runtime.windows import protect
raw=protect(base64.b64decode(raw.split(b'\n',1)[1]),decrypt=True)
key=serialization.load_pem_private_key(raw,password=None)
else:
key=serialization.load_pem_private_key(raw,password=getpass.getpass('Private-key password: ').encode())
request=json.loads(args.request.read_text(encoding='utf-8'))
if request.get('product')!='monitor' or request.get('schema_version')!=1:
parser.error('Unsupported machine request')
fingerprint=request.get('fingerprint','')
if not fingerprint.startswith('v1:') or len(fingerprint)!=67 or any(c not in '0123456789abcdef' for c in fingerprint[3:]):
parser.error('Invalid machine fingerprint')
now=datetime.now(timezone.utc)
payload={'schema_version':1,'product':'monitor','license_id':str(uuid.uuid4()),'customer':args.customer,
'machine_fingerprint':fingerprint,'issued_at':now.isoformat(),
'expires_at':args.expires}
document={'payload':payload,'signature':base64.b64encode(key.sign(canonical(payload))).decode('ascii')}
public=key.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo)
verify_document(document,public,fingerprint)
args.output.parent.mkdir(parents=True,exist_ok=True)
with args.output.open('x',encoding='utf-8') as f:json.dump(document,f,ensure_ascii=False,indent=2)
print('License signed:',args.output)
if __name__=='__main__':
main()

View File

@ -0,0 +1,192 @@
; auto-generated by mINI class {
[api]
apiDebug=0
defaultSnap=./www/logo.png
downloadRoot=./www
secret=INJECTED_AT_RUNTIME
snapRoot=./www/snap/
[cluster]
origin_url=
retry_count=3
timeout_sec=15
[ffmpeg]
bin=ffmpeg
cmd=%s -re -i %s -c:a aac -strict -2 -ar 44100 -ab 48k -c:v libx264 -f flv %s
log=./ffmpeg/ffmpeg.log
restart_sec=0
snap=%s -i %s -y -f mjpeg -frames:v 1 %s
[general]
check_nvidia_dev=1
enableVhost=0
enable_ffmpeg_log=0
flowThreshold=1024
maxStreamWaitMS=15000
mediaServerId=niBEjPnzIrQIS6qU
mergeWriteMS=0
resetWhenRePlay=1
streamNoneReaderDelayMS=20000
unready_frame_cache=100
wait_add_track_ms=3000
wait_track_ready_ms=10000
[hls]
broadcastRecordTs=0
deleteDelaySec=10
fastRegister=0
fileBufSize=65536
segDur=2
segKeep=0
segNum=3
segRetain=5
[hook]
alive_interval=30.000000
enable=1
on_flow_report=
on_http_access=
on_play=
on_publish=
on_record_mp4=
on_record_ts=
on_rtp_server_timeout=
on_rtsp_auth=
on_rtsp_realm=
on_send_rtp_stopped=
on_server_exited=
on_server_keepalive=
on_server_started=
on_shell_login=
on_stream_changed=
on_stream_none_reader=
on_stream_not_found=
retry=1
retry_delay=3.000000
stream_changed_schemas=rtsp/rtmp/fmp4/ts/hls/hls.fmp4
timeoutSec=10
[http]
allow_cross_domains=1
allow_ip_range=::1,127.0.0.1,172.16.0.0-172.31.255.255,192.168.0.0-192.168.255.255,10.0.0.0-10.255.255.255
charSet=gb2312
dirMenu=1
forbidCacheSuffix=
forwarded_ip_header=
keepAliveSecond=15
maxReqSize=40960
notFound=<html><head><title>404 Not Found</title></head><body bgcolor="white"><center><h1>您访问的资源不存在!</h1></center><hr><center>ZLMediaKit(git hash:/,branch:,build time:2025-05-11T18:28:47)</center></body></html>
port=10002
rootPath=./www
sendBufSize=65536
sslport=0
virtualPath=
[multicast]
addrMax=239.255.255.255
addrMin=239.0.0.0
udpTTL=64
[protocol]
add_mute_audio=1
auto_close=0
continue_push_ms=15000
enable_audio=1
enable_fmp4=1
enable_hls=0
enable_hls_fmp4=0
enable_mp4=0
enable_rtmp=1
enable_rtsp=1
enable_ts=0
fmp4_demand=0
hls_demand=1
hls_save_path=./www
modify_stamp=2
mp4_as_player=0
mp4_max_second=3600
mp4_save_path=./www
paced_sender_ms=0
rtmp_demand=1
rtsp_demand=0
ts_demand=1
[record]
appName=record
enableFmp4=0
fastStart=0
fileBufSize=65536
fileRepeat=0
sampleMS=500
[rtc]
bfilter=0
datachannel_echo=1
externIP=
maxRtpCacheMS=5000
maxRtpCacheSize=2048
max_bitrate=0
min_bitrate=0
nackIntervalRatio=1.0
nackMaxCount=15
nackMaxMS=3000
nackMaxSize=2048
nackRtpSize=8
port=0
preferredCodecA=PCMA,PCMU,opus,mpeg4-generic
preferredCodecV=H264,H265,AV1,VP9,VP8
rembBitRate=0
start_bitrate=0
tcpPort=0
timeoutSec=15
[rtmp]
directProxy=1
enhanced=0
handshakeSecond=15
keepAliveSecond=15
port=10935
sslport=0
[rtp]
audioMtuSize=600
h264_stap_a=1
lowLatency=0
rtpMaxSize=10
videoMtuSize=1400
[rtp_proxy]
dumpDir=
gop_cache=1
h264_pt=98
h265_pt=99
opus_pt=100
port=30001
port_range=30002-40000
ps_pt=96
timeoutSec=15
[rtsp]
authBasic=0
directProxy=1
handshakeSecond=15
keepAliveSecond=15
lowLatency=0
port=10554
rtpTransportType=-1
sslport=0
[shell]
maxReqSize=1024
port=0
[srt]
latencyMul=4
passPhrase=
pktBufSize=8192
port=0
timeoutSec=5
; } ---

View File

@ -14,7 +14,8 @@ from pathlib import Path
import os
from app.utils.Secrets import get_runtime_secret
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
from monitor_runtime.paths import DATA_ROOT, RESOURCE_ROOT, DESKTOP
BASE_DIR = DATA_ROOT
PROJECT_UA = "monitor"
PROJECT_BUILT = "monitor built on 2026/08/06"
PROJECT_VERSION = "1.003"
@ -42,8 +43,15 @@ def env_list(name, default=""):
# SECURITY WARNING: don't run with debug turned on in production!
# Production-safe by default. Local development must opt in explicitly.
DEBUG = env_bool("MONITOR_DEBUG", False)
if DESKTOP:
DEBUG = False
ALLOWED_HOSTS = env_list("MONITOR_ALLOWED_HOSTS", "127.0.0.1,localhost")
if DESKTOP:
import json
_network_file = BASE_DIR / 'network.json'
_network = json.loads(_network_file.read_text(encoding='utf-8')) if _network_file.exists() else {}
ALLOWED_HOSTS = ['127.0.0.1', 'localhost'] + _network.get('allowed_hosts', [])
# Application definition
@ -66,6 +74,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'monitor_runtime.web.LicenseMiddleware',
"app.middleware.SimpleMiddleware", # 拦截器
]
@ -75,7 +84,7 @@ TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
os.path.join(BASE_DIR,'templates')
os.path.join(RESOURCE_ROOT,'templates')
],
'APP_DIRS': True,
'OPTIONS': {
@ -144,7 +153,7 @@ TIME_ZONE = 'Asia/Shanghai'
""" 静态资源DEBUG=Truestaticfiles 从 STATICFILES_DIRS 直接提供) """
STATICFILES_DIRS = (
os.path.join(BASE_DIR, "static"),
os.path.join(RESOURCE_ROOT, "static"),
)
SESSION_COOKIE_NAME = 'MonitorSessionID'

View File

@ -33,3 +33,16 @@ if settings.DEBUG:
{'document_root': os.path.join(settings.BASE_DIR, 'static', 'upload')}),
]
urlpatterns += staticfiles_urlpatterns()
# Mutable media is never served by WhiteNoise or the development static handler.
from monitor_runtime import web
urlpatterns = [
path('setup', web.setup),
path('license', web.license_page),
path('license/status', web.license_status),
path('license/request', web.license_request),
path('license/import', web.license_import),
path('static/storage/<path:path>', web.private_file),
path('static/upload/audio/<path:path>', web.private_file, {'area': 'audio'}),
path('upload/audio/<path:path>', web.private_file, {'area': 'audio'}),
] + urlpatterns

View File

@ -56,7 +56,7 @@
"player_copied": "已复制",
"player_copy_fail": "复制失败",
"port_already_used": "端口 %d 已被占用!",
"ptz_type_range_error": "ptzType 必须在 0-11 之间",
"ptz_type_range_error": "ptzType 必须在 0-14 之间",
"stream_analyzer": "算法流",
"stream_app_invalid": "应用不存在",
"stream_app_name_required": "应用和名称是必填项",

20
monitor_entry.py Normal file
View File

@ -0,0 +1,20 @@
"""Minimal executable bootstrap; business logic is compiled separately."""
import multiprocessing
import os
import sys
if __name__ == '__main__':
multiprocessing.freeze_support()
os.environ['MONITOR_DESKTOP'] = '1'
try:
from monitor_runtime.launcher import main
raise SystemExit(main())
except SystemExit:
raise
except Exception as exc:
import logging
logging.exception('Monitor failed')
if os.name == 'nt' and '--worker' not in sys.argv and '--self-test' not in sys.argv:
from monitor_runtime.windows import message
message(str(exc), True)
raise SystemExit(1)

View File

@ -0,0 +1 @@
"""Windows desktop deployment support. No service starts on import."""

View File

@ -0,0 +1,94 @@
"""Fresh-install initialization and non-destructive version upgrades."""
import json
import os
import shutil
import sqlite3
from datetime import datetime
from monitor_runtime.paths import data, resource, atomic_json
def prepare_files():
data('').mkdir(parents=True, exist_ok=True)
for name in ('.runtime', 'log', 'backups', 'static/upload/weight', 'static/upload/audio',
'static/storage', '.runtime/zlm', '.runtime/ultralytics/Ultralytics', '.runtime/matplotlib'):
data(name).mkdir(parents=True, exist_ok=True)
for target, source in (('config.json', 'deploy/windows/default-config.json'),
('settings.json', 'deploy/windows/default-settings.json'),
('zlm-template.ini', 'deploy/windows/zlm-template.ini')):
if not data(target).exists():
shutil.copyfile(resource(source), data(target))
os.environ['YOLO_CONFIG_DIR'] = str(data('.runtime/ultralytics'))
os.environ['MPLCONFIGDIR'] = str(data('.runtime/matplotlib'))
os.environ['YOLO_OFFLINE'] = 'true'
os.environ['YOLO_AUTOINSTALL'] = 'false'
os.environ['CI'] = 'true'
def initialize_database():
import django
os.environ['DJANGO_SETTINGS_MODULE'] = 'framework.settings'
os.environ['MONITOR_SERVICE_MODE'] = 'disabled'
os.environ['MONITOR_BOOTSTRAP_SERVICES'] = 'false'
django.setup()
from django.core.management import call_command
from django.db import connection
from django.db.migrations.executor import MigrationExecutor
executor = MigrationExecutor(connection)
plan = executor.migration_plan(executor.loader.graph.leaf_nodes())
db = data('monitor.sqlite3')
if plan and db.exists() and db.stat().st_size:
backup = data('backups') / datetime.now().strftime('%Y%m%d-%H%M%S-%f')
backup.mkdir()
with sqlite3.connect(str(db)) as src, sqlite3.connect(str(backup / db.name)) as dst:
src.backup(dst)
for name in ('config.json', 'settings.json', 'network.json', '.runtime-secrets.json', 'license.json'):
if data(name).exists():
shutil.copyfile(data(name), backup / name)
call_command('migrate', interactive=False, verbosity=0)
from django.contrib.auth.models import Group
for role in ('system_admin', 'algorithm_admin', 'operator', 'viewer'):
Group.objects.get_or_create(name=role)
from app.utils.Credentials import migrate_existing_credentials
migrate_existing_credentials()
def has_admin():
from django.contrib.auth import get_user_model
marker = data('.runtime/initialized.json')
if marker.exists():
return True
exists = get_user_model().objects.filter(is_superuser=True).exists()
if exists:
atomic_json(marker, {'schema_version': 1, 'initialized': True})
return exists
def local_addresses():
import socket
import psutil
return sorted({a.address for values in psutil.net_if_addrs().values() for a in values
if a.family == socket.AF_INET and not a.address.startswith(('127.', '169.254.'))
and a.address != '0.0.0.0'})
def finish_setup(username, password, address):
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.db import transaction
from monitor_runtime.licensing import require_license
require_license()
if address not in local_addresses():
raise ValueError('请选择本机有效的局域网 IPv4 地址')
if len(password) < 12:
raise ValueError('管理员密码至少 12 位')
username = username.strip()
if not username or len(username) > 150:
raise ValueError('管理员用户名无效')
user = get_user_model()(username=username)
validate_password(password, user)
with transaction.atomic():
if has_admin():
raise ValueError('初始化已完成')
cfg = json.loads(data('config.json').read_text(encoding='utf-8'))
cfg['host'] = address
cfg.setdefault('sipServer', {})['sipServerIp'] = address
atomic_json(data('config.json'), cfg)
atomic_json(data('network.json'), {'allowed_hosts': [address], 'address': address})
get_user_model().objects.create_superuser(username=username, password=password)
transaction.on_commit(lambda: atomic_json(data('.runtime/initialized.json'),
{'schema_version': 1, 'initialized': True}))

View File

@ -0,0 +1,67 @@
"""Offline dependency and frozen-spawn smoke test, with no camera/model access."""
import json
import multiprocessing
from monitor_runtime.paths import data, resource, atomic_json
def spawn_probe(queue):
import numpy as np
import cv2
queue.put({'resized': list(cv2.resize(np.zeros((4,4,3),dtype=np.uint8),(2,2)).shape)})
def run():
from monitor_runtime.bootstrap import prepare_files, initialize_database
prepare_files()
initialize_database()
import django
import cv2
import numpy as np
import torch
import torchvision
import onnxruntime
import openvino as ov
from ultralytics import YOLO
from ultralytics.utils import USER_CONFIG_DIR
assert USER_CONFIG_DIR.resolve().is_relative_to(data('').resolve())
import pystray
import whitenoise
result = {'django':django.get_version(),'opencv':cv2.__version__,'torch':torch.__version__,
'torchvision':torchvision.__version__,'onnxruntime':onnxruntime.__version__,
'openvino':ov.__version__}
assert torch.version.cuda is None
assert (torch.ones(2,2) @ torch.ones(2,2)).tolist() == [[2.,2.],[2.,2.]]
assert 'CPUExecutionProvider' in onnxruntime.get_available_providers()
# Architecture-only random weights: no model download, no customer model or pickle.
detector = YOLO('yolo26n.yaml', task='detect')
predictions = detector.predict(np.zeros((64,64,3), dtype=np.uint8), imgsz=64, device='cpu', verbose=False)
assert len(predictions) == 1 and predictions[0].orig_shape == (64,64)
result['yolo_cpu_inference'] = 'passed (random weights; no accuracy claim)'
parameter = ov.opset13.parameter([1],np.float32)
model = ov.Model([ov.opset13.relu(parameter)],[parameter])
compiled = ov.Core().compile_model(model,'CPU')
assert float(compiled([np.array([-1],dtype=np.float32)])[0][0]) == 0
ctx=multiprocessing.get_context('spawn')
queue=ctx.Queue()
process=ctx.Process(target=spawn_probe,args=(queue,))
process.start()
try:
assert queue.get(timeout=60)['resized']==[2,2,3]
process.join(timeout=20)
assert process.exitcode==0
finally:
if process.is_alive():
process.terminate()
process.join(timeout=10)
queue.close()
from django.test import Client
client=Client(HTTP_HOST='localhost')
response=client.get('/setup',REMOTE_ADDR='127.0.0.1')
assert response.status_code in (200,403)
assert resource('public-static/lib/js/app.js').is_file()
assert resource('zlm/monitor_zlm.exe').is_file()
assert resource('tools/ffmpeg.exe').is_file()
from monitor_runtime.licensing import machine_identity
assert machine_identity()['fingerprint'].startswith('v1:')
result.update(status='passed',spawn='passed',cpu='passed',resources='passed',
writable_caches='ProgramData/test data root')
atomic_json(data('.runtime/self-test.json'),result)
return 0

286
monitor_runtime/launcher.py Normal file
View File

@ -0,0 +1,286 @@
"""Tray supervisor with authenticated local control and owned process cleanup."""
import argparse
import json
import logging
import multiprocessing as mp
import os
import secrets
import subprocess
import sys
import threading
import time
import webbrowser
from monitor_runtime.paths import data, resource, atomic_json
def worker(address, auth):
from multiprocessing.connection import Client
connection = Client(address, family='AF_PIPE', authkey=bytes.fromhex(auth))
try:
if connection.recv() != 'run':
return 1
from monitor_runtime.service import run
return run(connection)
finally:
connection.close()
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--worker', nargs=2, metavar=('PIPE', 'AUTH'))
parser.add_argument('--stop', action='store_true')
parser.add_argument('--self-test', action='store_true')
parser.add_argument('--trust-model')
parser.add_argument('--yes', action='store_true')
args = parser.parse_args()
if os.name != 'nt':
raise RuntimeError('Monitor desktop requires Windows')
data('.runtime').mkdir(parents=True, exist_ok=True)
data('log').mkdir(parents=True, exist_ok=True)
logging.basicConfig(filename=str(data('log/desktop.log')), level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s')
if args.self_test:
from monitor_runtime.diagnostics import run
return run()
if args.worker:
return worker(*args.worker)
if args.trust_model:
return trust_model(args.trust_model, args.yes)
from app.services.lifecycle import ServiceLeaderLock
# Separate from the service lock; held for the entire supervisor lifetime.
lock = ServiceLeaderLock(data('.runtime/desktop.lock'))
if not lock.acquire():
return control_existing('stop' if args.stop else 'open')
if args.stop:
lock.release()
return 0
try:
return Supervisor().run()
finally:
lock.release()
def control_existing(command):
from multiprocessing.connection import Client
deadline = time.monotonic() + 10
while True:
try:
state = json.loads(data('.runtime/desktop.json').read_text(encoding='utf-8'))
with Client(state['pipe'], family='AF_PIPE', authkey=bytes.fromhex(state['auth'])) as connection:
connection.send(command)
response = connection.recv()
break
except (OSError, EOFError, json.JSONDecodeError):
if time.monotonic() >= deadline:
raise RuntimeError('已有实例的控制接口不可用,请查看日志')
time.sleep(.2)
if command == 'stop':
deadline = time.monotonic() + 40
from app.services.lifecycle import ServiceLeaderLock
while time.monotonic() < deadline:
lock = ServiceLeaderLock(data('.runtime/desktop.lock'))
if lock.acquire():
lock.release()
return 0
time.sleep(.3)
raise RuntimeError('Monitor 未能在 40 秒内退出,请查看日志')
return 0
def trust_model(filename, confirmed):
from pathlib import Path
from app.utils.ModelTrust import sha256_file
from monitor_runtime.windows import message
model = Path(filename).resolve()
if model.suffix.lower() != '.pt' or not model.is_file():
raise ValueError('请选择存在的 .pt 模型文件')
digest = sha256_file(model)
if not confirmed:
message('模型 SHA-256: ' + digest + '\n确认来源可信后使用 --yes 重新执行。')
return 2
path = data('.trusted-models.json')
doc = json.loads(path.read_text(encoding='utf-8')) if path.exists() else {'trusted_sha256': []}
doc['trusted_sha256'] = sorted(set(doc['trusted_sha256']) | {digest})
atomic_json(path, doc)
message('已登记可信模型 SHA-256' + digest)
return 0
class Supervisor:
def __init__(self):
self.stop = threading.Event()
self.restart = threading.Event()
self.url = ''
self.status = '正在启动'
self.icon = None
self.child = None
self.pipe = None
self.job = None
def open_web(self, *_):
if self.url:
webbrowser.open(self.url)
def exit(self, *_):
self.stop.set()
def run(self):
import pystray
from PIL import Image
from multiprocessing.connection import Listener
auth = secrets.token_bytes(32)
address = r'\\.\pipe\MonitorControl-' + secrets.token_hex(16)
self.control = Listener(address, family='AF_PIPE', authkey=auth)
atomic_json(data('.runtime/desktop.json'), {'pipe': address, 'auth': auth.hex()})
threading.Thread(target=self.control_loop, daemon=True).start()
menu = pystray.Menu(
pystray.MenuItem('打开管理网页', self.open_web, default=True),
pystray.MenuItem(lambda _: self.status, lambda *_: None, enabled=False),
pystray.MenuItem('查看状态', lambda *_: self.show_status()),
pystray.MenuItem('打开日志目录', lambda *_: os.startfile(str(data('log')))),
pystray.MenuItem('重启服务', lambda *_: self.restart.set()),
pystray.MenuItem('退出', self.exit))
self.icon = pystray.Icon('Monitor', Image.open(resource('static/images/logo.png')), 'Monitor', menu)
threading.Thread(target=self.supervise, daemon=True).start()
try:
self.icon.run()
finally:
self.stop.set()
self.control.close()
return 0
def show_status(self):
from monitor_runtime.windows import message
message(self.status + '\n' + self.url.split('#')[0])
def control_loop(self):
while not self.stop.is_set():
try:
with self.control.accept() as conn:
command = conn.recv()
if command == 'stop':
self.stop.set()
elif command == 'open':
self.open_web()
conn.send('ok')
except (OSError, EOFError):
return
def start_child(self):
from multiprocessing.connection import Listener
from monitor_runtime.windows import Job
auth = secrets.token_bytes(32)
address = r'\\.\pipe\MonitorWorker-' + secrets.token_hex(16)
listener = Listener(address, family='AF_PIPE', authkey=auth)
args = [sys.executable]
if not getattr(sys, 'frozen', False):
args.append(str(resource('monitor_entry.py')))
args += ['--worker', address, auth.hex()]
env = os.environ.copy()
env['MONITOR_DESKTOP'] = '1'
env['MONITOR_SERVICE_MODE'] = 'disabled'
env['MONITOR_BOOTSTRAP_SERVICES'] = 'false'
env['MONITOR_DEBUG'] = 'false'
env['MONITOR_DATA_DIR'] = str(data(''))
self.job = Job()
log = open(data('log/service.log'), 'ab', buffering=0)
try:
self.child = subprocess.Popen(args, cwd=str(data('')), env=env, stdin=subprocess.DEVNULL,
stdout=log, stderr=log, creationflags=subprocess.CREATE_NO_WINDOW)
self.job.assign(self.child)
finally:
log.close()
# Child waits for run; Job assignment therefore precedes any child spawn.
accepted = []
def accept():
try:
accepted.append(listener.accept())
except OSError:
pass
thread = threading.Thread(target=accept, daemon=True)
thread.start()
deadline = time.monotonic() + 30
while not accepted:
if self.child.poll() is not None or time.monotonic() > deadline or self.stop.wait(.1):
listener.close()
raise RuntimeError('服务进程未能建立控制连接,请查看 service.log')
self.pipe = accepted[0]
listener.close()
self.pipe.send('run')
def stop_child(self):
if self.child and self.child.poll() is None:
try:
self.pipe.send('stop')
except (AttributeError, EOFError, OSError):
pass
try:
self.child.wait(timeout=25)
except subprocess.TimeoutExpired:
logging.error('Graceful shutdown timed out; closing owned job')
if self.pipe:
self.pipe.close()
self.pipe = None
if self.job:
self.job.close()
self.job = None
self.child = None
def supervise(self):
try:
while not self.stop.is_set():
self.restart.clear()
self.status = '正在启动'
self.start_child()
deadline = time.monotonic() + 180
last_heartbeat = time.monotonic()
opened = False
auto_restart = False
try:
while not self.stop.is_set() and not self.restart.is_set():
if self.pipe.poll(.3):
message = self.pipe.recv()
last_heartbeat = time.monotonic()
phase = message.get('phase')
if phase == 'failed':
raise RuntimeError(message.get('reason', '服务启动失败'))
self.status = {'ready': '服务运行中', 'setup': '等待首次初始化',
'license': '等待有效授权'}.get(phase, phase)
if message.get('url'):
self.url = message['url']
if not opened:
self.open_web()
opened = True
self.icon.update_menu()
code = self.child.poll()
if code is not None:
if code == 20:
auto_restart = True
break
raise RuntimeError('服务进程退出,代码 %s' % code)
if not opened and time.monotonic() > deadline:
raise RuntimeError('启动超过 180 秒,请查看日志')
if opened and time.monotonic() - last_heartbeat > 35:
raise RuntimeError('服务健康状态超时')
except EOFError:
code = self.child.wait(timeout=30)
if code == 20:
auto_restart = True
else:
raise RuntimeError('服务控制连接断开,代码 %s' % code)
finally:
self.stop_child()
if not auto_restart and not self.restart.is_set():
break
except Exception as exc:
logging.exception('Supervisor failure')
self.status = '启动失败:' + str(exc)
self.url = ''
self.icon.update_menu()
from monitor_runtime.windows import message
message(self.status + '\n日志:' + str(data('log')), True)
self.stop_child()
# Keep tray available; retries are explicit, never an infinite crash loop.
while not self.stop.wait(.3):
if self.restart.is_set():
self.supervise()
return
finally:
self.stop_child()
if self.stop.is_set():
self.icon.stop()

View File

@ -0,0 +1,144 @@
"""Offline signed licenses. Private signing material is never used here."""
import base64
import hashlib
import json
import os
import subprocess
import threading
import time
from datetime import datetime, timezone
from functools import lru_cache
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from monitor_runtime.paths import DESKTOP, data, public_key_path, atomic_json
PRODUCT = 'monitor'
MAX_LICENSE_BYTES = 32768
_GUARD = threading.RLock()
class LicenseError(ValueError):
pass
def canonical(payload):
return json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode('utf-8')
def timestamp(value):
dt = datetime.fromisoformat(value.replace('Z', '+00:00'))
if dt.tzinfo is None:
raise LicenseError('授权时间必须包含时区')
return dt.timestamp()
@lru_cache(maxsize=1)
def machine_identity():
if os.name != 'nt':
raise LicenseError('机器授权仅支持 Windows')
import winreg
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r'SOFTWARE\Microsoft\Cryptography',
0, winreg.KEY_READ | winreg.KEY_WOW64_64KEY) as key:
guid = str(winreg.QueryValueEx(key, 'MachineGuid')[0]).strip().lower()
result = subprocess.run(
['powershell.exe', '-NoProfile', '-NonInteractive', '-Command',
'(Get-CimInstance Win32_ComputerSystemProduct).UUID'],
capture_output=True, timeout=20, creationflags=subprocess.CREATE_NO_WINDOW, check=True)
uuid = result.stdout.decode('utf-8', errors='replace').strip().lower()
import uuid as uuid_module
try:
uuid = str(uuid_module.UUID(uuid))
except ValueError as exc:
raise LicenseError('无法读取系统 UUID请检查 Windows CIM 服务') from exc
weak = uuid in ('00000000-0000-0000-0000-000000000000', 'ffffffff-ffff-ffff-ffff-ffffffffffff')
if not guid:
raise LicenseError('无法读取 Windows MachineGuid')
digest = hashlib.sha256(('monitor-machine-v1|' + uuid + '|' + guid).encode()).hexdigest()
return {'fingerprint': 'v1:' + digest, 'weak_uuid': weak}
def request_document():
return {'schema_version': 1, 'product': PRODUCT, **machine_identity()}
def verify_document(document, public_pem, fingerprint, now=None):
now = time.time() if now is None else now
try:
if set(document) != {'payload', 'signature'}:
raise LicenseError('授权文件结构无效')
payload = document['payload']
required = {'schema_version', 'product', 'license_id', 'customer', 'machine_fingerprint',
'issued_at', 'expires_at'}
if not isinstance(payload, dict) or set(payload) != required:
raise LicenseError('授权字段无效')
key = serialization.load_pem_public_key(public_pem)
if not isinstance(key, Ed25519PublicKey):
raise LicenseError('发行公钥类型无效')
key.verify(base64.b64decode(document['signature'], validate=True), canonical(payload))
if payload['schema_version'] != 1 or payload['product'] != PRODUCT:
raise LicenseError('授权版本或产品不匹配')
if payload['machine_fingerprint'] != fingerprint:
raise LicenseError('授权与本机不匹配')
if not all(isinstance(payload[k], str) and 0 < len(payload[k]) <= 200 for k in ('customer', 'license_id')):
raise LicenseError('客户或授权编号无效')
if timestamp(payload['issued_at']) > now + 300:
raise LicenseError('授权尚未生效,或系统时间不正确')
if payload['expires_at'] is not None:
expiry = timestamp(payload['expires_at'])
if expiry <= timestamp(payload['issued_at']) or now >= expiry:
raise LicenseError('授权已过期')
return payload
except LicenseError:
raise
except Exception as exc:
raise LicenseError('授权签名或格式无效') from exc
def _clock(now):
path = data('license-clock.json')
previous = 0
if path.exists():
try:
from monitor_runtime.windows import protect
encoded = json.loads(path.read_text(encoding='utf-8'))['protected']
previous = float(protect(base64.b64decode(encoded), decrypt=True).decode('ascii'))
except Exception as exc:
raise LicenseError('本地授权时间记录损坏,请恢复备份并检查系统时间') from exc
if now + 300 < previous:
raise LicenseError('检测到系统时间回拨,请校准系统时间')
if now > previous + 60:
from monitor_runtime.windows import protect
encoded = base64.b64encode(protect(str(now).encode('ascii'), machine=True)).decode('ascii')
atomic_json(path, {'protected': encoded})
def check_license():
if not DESKTOP:
return {'valid': True, 'reason': 'source development'}
with _GUARD:
try:
path = data('license.json')
if not path.exists():
raise LicenseError('尚未导入授权')
if path.stat().st_size > MAX_LICENSE_BYTES:
raise LicenseError('授权文件过大')
pem = public_key_path().read_bytes()
payload = verify_document(json.loads(path.read_text(encoding='utf-8')),
pem, machine_identity()['fingerprint'])
_clock(time.time())
return {'valid': True, 'reason': '授权有效', 'license': payload}
except Exception as exc:
return {'valid': False, 'reason': str(exc) if isinstance(exc, LicenseError) else '无法读取发行公钥或授权文件'}
def require_license():
result = check_license()
if not result['valid']:
raise LicenseError(result['reason'])
def import_license(raw):
if len(raw) > MAX_LICENSE_BYTES:
raise LicenseError('授权文件过大')
with _GUARD:
try:
document = json.loads(raw)
payload = verify_document(document, public_key_path().read_bytes(), machine_identity()['fingerprint'])
_clock(time.time())
except LicenseError:
raise
except Exception as exc:
raise LicenseError('无法读取授权文件或发行公钥') from exc
atomic_json(data('license.json'), document)
return payload

46
monitor_runtime/paths.py Normal file
View File

@ -0,0 +1,46 @@
"""Separate immutable resources from installation-owned mutable data."""
import os
import sys
from pathlib import Path
from monitor_runtime.release import RELEASE_BUILD
RESOURCE_ROOT = Path(getattr(sys, '_MEIPASS', Path(__file__).resolve().parents[1])).resolve()
if not RELEASE_BUILD and not getattr(sys, 'frozen', False) and os.environ.get('MONITOR_DEV_RESOURCE_ROOT'):
RESOURCE_ROOT = Path(os.environ['MONITOR_DEV_RESOURCE_ROOT']).resolve()
DESKTOP = RELEASE_BUILD or bool(getattr(sys, 'frozen', False)) or os.environ.get('MONITOR_DESKTOP') == '1'
DATA_ROOT = Path(os.environ.get('MONITOR_DATA_DIR') or (
Path(os.environ.get('PROGRAMDATA', 'C:/ProgramData')) / 'Monitor' if DESKTOP else RESOURCE_ROOT
)).resolve()
def resource(name):
return RESOURCE_ROOT / name
def data(name):
return DATA_ROOT / name
def public_key_path():
if not RELEASE_BUILD and not getattr(sys, 'frozen', False):
override = os.environ.get('MONITOR_DEV_PUBLIC_KEY')
if override:
return Path(override)
return resource('license-public.pem')
def atomic_json(path, value):
import json
import tempfile
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=path.name, suffix='.tmp', dir=path.parent)
try:
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
json.dump(value, handle, ensure_ascii=False, indent=2)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)

View File

@ -0,0 +1,2 @@
"""Replaced with a compiled constant by the release builder."""
RELEASE_BUILD = False

View File

@ -0,0 +1,85 @@
"""One Waitress process owns the background lifecycle and analysis state."""
import json
import logging
import os
import socket
import threading
import time
from monitor_runtime.paths import data, resource, atomic_json
from monitor_runtime.bootstrap import prepare_files, initialize_database, has_admin
from monitor_runtime.licensing import check_license
logger = logging.getLogger('monitor.runtime')
def run(connection):
server = None
manager = None
ready = False
try:
prepare_files()
initialize_database()
from monitor_runtime import web
from django.core.wsgi import get_wsgi_application
from whitenoise import WhiteNoise
from waitress import create_server
from app.utils.GlobalUtils import g_config
from app.services.lifecycle import get_service_manager
manager = get_service_manager()
licensed = check_license()['valid']
initialized = has_admin()
port = g_config.adminPort
app = WhiteNoise(get_wsgi_application(), root=str(resource('public-static')), prefix='static/')
address = '0.0.0.0' if initialized and licensed else '127.0.0.1'
server = create_server(app, host=address, port=port, threads=8, channel_timeout=60,
max_request_body_size=536870912)
thread = threading.Thread(target=server.run, name='web-server', daemon=True)
thread.start()
if licensed and initialized:
if not manager.start():
raise RuntimeError('后台服务已被其他进程占用')
phase = 'ready' if licensed and initialized else 'setup' if not initialized else 'license'
web.SERVICE_STATE.update(phase=phase, reason=check_license()['reason'])
url = 'http://127.0.0.1:%d/%s' % (port, 'login' if phase == 'ready' else phase)
if phase == 'setup':
url += '#' + web.SETUP_TOKEN
connection.send({'phase': phase, 'url': url, 'reason': web.SERVICE_STATE['reason']})
ready = True
last_check = time.monotonic()
failures = 0
while thread.is_alive():
if connection.poll(1):
message = connection.recv()
if message == 'stop':
return 0
if web.RESTART_REQUESTED.is_set():
return 20
if time.monotonic() - last_check >= 5:
last_check = time.monotonic()
status = check_license()
if not status['valid'] and manager.is_leader:
web.SERVICE_STATE.update(phase='license', reason=status['reason'])
connection.send({'phase': 'license', 'url': 'http://127.0.0.1:%d/license' % port,
'reason': status['reason']})
# Restart loopback-only for renewal and reliably discard all worker descendants.
return 20
if manager.is_leader:
from app.utils.MediaServerManager import get_media_server_manager
media = get_media_server_manager()
failures = 0 if media.managed_pid() and media.api_alive() else failures + 1
if failures >= 3:
raise RuntimeError('ZLM 健康检查失败;请查看日志并从托盘重启')
connection.send({'phase': web.SERVICE_STATE['phase'], 'heartbeat': True})
raise RuntimeError('Web 服务意外退出')
except Exception as exc:
logger.exception('Desktop service failed')
try:
connection.send({'phase': 'failed', 'reason': str(exc)})
except (EOFError, OSError):
pass
return 1
finally:
if manager:
manager.stop()
if server:
server.close()
connection.close()

162
monitor_runtime/web.py Normal file
View File

@ -0,0 +1,162 @@
"""Local bootstrap and administrator-only license management."""
import json
import secrets
import threading
from django.http import HttpResponse, JsonResponse, HttpResponseRedirect, FileResponse
from django.template import engines
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
from django.views.decorators.http import require_http_methods
from monitor_runtime.paths import data, resource, DESKTOP
from monitor_runtime.licensing import check_license, import_license, request_document
from monitor_runtime.bootstrap import has_admin, finish_setup, local_addresses
SETUP_TOKEN = secrets.token_urlsafe(32)
SETUP_LOCK = threading.Lock()
RESTART_REQUESTED = threading.Event()
SERVICE_STATE = {'phase': 'starting', 'reason': ''}
PAGE = """<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>Monitor 安装与授权</title><style>
body{font:16px/1.7 "Microsoft YaHei",sans-serif;background:#eef2f6;color:#182635;margin:0;padding:32px}
main{max-width:720px;margin:auto;background:white;border-radius:16px;padding:32px;box-shadow:0 4px 30px #17334815}
h1{font-size:26px;margin-top:0}label{display:block;margin-top:14px}input,select,button{font:inherit;padding:9px;border:1px solid #cad4de;border-radius:7px}
input:not([type=file]),select{width:95%}button{background:#125cc8;color:white;cursor:pointer;margin-top:20px}
.notice{padding:14px;background:#eef4ff;border-radius:8px;overflow-wrap:anywhere}.error{color:#b32828}
small{display:block;color:#667482}a{color:#125cc8}</style><main>
<h1>{% if setup %}首次安装设置{% else %}授权管理{% endif %}</h1>
<p class="notice">{{ status.reason }}{% if status.license %}<br>客户{{ status.license.customer }}<br>
有效期{{ status.license.expires_at|default:"永久" }}{% endif %}</p>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<p>导出本机申请文件交给发行方签发再导入签名授权文件授权绑定当前电脑</p>
<form method="post" action="/license/request">{% csrf_token %}<input type="hidden" name="setup_token">
<button>导出机器申请文件</button></form>
<form method="post" action="/license/import" enctype="multipart/form-data">{% csrf_token %}
<input type="hidden" name="setup_token"><label>签名授权文件 <input type="file" name="license" accept=".json" required></label>
<button>导入授权{% if not setup %}并重启服务{% endif %}</button></form>
{% if setup %}<form method="post" action="/setup">{% csrf_token %}<input type="hidden" name="setup_token">
<label>管理员用户名<input name="username" autocomplete="username" required maxlength="150"></label>
<label>密码至少 12 <input type="password" name="password" autocomplete="new-password" required minlength="12"></label>
<label>确认密码<input type="password" name="confirm" autocomplete="new-password" required></label>
<label>局域网网卡<select name="address" required>{% for ip in addresses %}<option>{{ ip }}</option>{% endfor %}</select></label>
<small>请先导入授权初始化只允许从安装电脑完成完成后局域网用户才能登录</small>
<button>创建管理员并启动</button></form>{% else %}<p><a href="/login">返回登录</a> · <a href="/">进入系统</a></p>{% endif %}
<script>
const key='monitor-setup-token'; if(location.hash){sessionStorage.setItem(key,location.hash.slice(1));history.replaceState(null,'',location.pathname);}
document.querySelectorAll('[name=setup_token]').forEach(e=>e.value=sessionStorage.getItem(key)||'');
{% if not setup %}sessionStorage.removeItem(key);{% endif %}
</script></main></html>"""
def is_local(request):
return request.META.get('REMOTE_ADDR') in ('127.0.0.1', '::1')
def admin_allowed(request):
user = getattr(request, 'user', None)
return bool(user and user.is_authenticated and (
user.is_superuser or user.groups.filter(name='system_admin').exists()))
def authorized(request, write=False):
if not has_admin():
if not is_local(request):
return False
return not write or secrets.compare_digest(request.POST.get('setup_token', ''), SETUP_TOKEN)
return admin_allowed(request)
def page(request, error='', code=200):
html = engines['django'].from_string(PAGE).render({
'setup': not has_admin(), 'status': check_license(), 'error': error,
'addresses': local_addresses() if not has_admin() else [],
}, request)
response = HttpResponse(html, status=code)
response['Cache-Control'] = 'no-store'
response['Referrer-Policy'] = 'no-referrer'
return response
@csrf_exempt
@ensure_csrf_cookie
@require_http_methods(['GET', 'POST'])
def setup(request):
if has_admin() or not authorized(request, request.method == 'POST'):
return HttpResponse('初始化入口不可用', status=403)
if request.method == 'POST':
try:
with SETUP_LOCK:
if request.POST.get('password') != request.POST.get('confirm'):
raise ValueError('两次密码输入不一致')
finish_setup(request.POST.get('username', ''), request.POST.get('password', ''),
request.POST.get('address', ''))
RESTART_REQUESTED.set()
return HttpResponse('<meta charset="utf-8"><p>设置完成,服务正在重启。稍后重新打开桌面快捷方式进入登录页面。</p>')
except Exception as exc:
return page(request, str(exc), 400)
return page(request)
@ensure_csrf_cookie
def license_page(request):
if not authorized(request):
return HttpResponseRedirect('/login') if not getattr(request.user, 'is_authenticated', False) else HttpResponse(status=403)
return page(request)
@csrf_exempt
@require_http_methods(['POST'])
def license_request(request):
if not authorized(request, True):
return HttpResponse(status=403)
response = JsonResponse(request_document(), json_dumps_params={'indent': 2})
response['Content-Disposition'] = 'attachment; filename="monitor-license-request.json"'
return response
@csrf_exempt
@require_http_methods(['POST'])
def license_import(request):
if not authorized(request, True):
return HttpResponse(status=403)
try:
uploaded = request.FILES.get('license')
if not uploaded or uploaded.size > 32768:
raise ValueError('请选择小于 32KB 的授权文件')
import_license(uploaded.read())
if has_admin():
RESTART_REQUESTED.set()
return page(request)
except Exception as exc:
return page(request, str(exc), 400)
def license_status(request):
if not authorized(request):
return HttpResponse(status=403)
return JsonResponse({**check_license(), 'service': SERVICE_STATE})
def private_file(request, path, area='storage'):
if not request.user.is_authenticated:
return HttpResponse(status=403)
root = data('static/storage' if area == 'storage' else 'static/upload/audio').resolve()
filename = (root / path).resolve()
allowed = {'.jpg', '.jpeg', '.png', '.mp4', '.wav', '.mp3', '.ogg'}
if not filename.is_relative_to(root) or not filename.is_file() or filename.suffix.lower() not in allowed:
return HttpResponse(status=404)
response = FileResponse(filename.open('rb'))
response['Cache-Control'] = 'private, no-store'
response['X-Content-Type-Options'] = 'nosniff'
return response
class LicenseMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not DESKTOP:
return self.get_response(request)
path = request.path_info
# WhiteNoise only serves immutable build-time assets, never mutable data.
public_asset = path.startswith('/static/') and not path.startswith(('/static/storage/', '/static/upload/'))
exceptions = {'/setup', '/license', '/license/status', '/license/request', '/license/import',
'/login', '/logout', '/user/openCaptcha'}
if not has_admin() and not public_asset and path not in {'/setup', '/license', '/license/request', '/license/import', '/license/status'}:
return HttpResponseRedirect('/setup') if is_local(request) else HttpResponse(status=403)
if path not in exceptions and not public_asset:
status = check_license()
if not status['valid']:
if '/open' in path or path.startswith('/inner/'):
return JsonResponse({'code': 0, 'msg': status['reason'], 'error': 'license_invalid'}, status=403)
return HttpResponseRedirect('/license')
return self.get_response(request)

View File

@ -0,0 +1,72 @@
"""Small Win32 helpers, imported lazily on Windows."""
import ctypes
from ctypes import wintypes
import os
class Blob(ctypes.Structure):
_fields_ = [('size', wintypes.DWORD), ('ptr', ctypes.POINTER(ctypes.c_byte))]
def protect(raw, decrypt=False, machine=False):
if os.name != 'nt':
raise RuntimeError('Windows DPAPI is required')
buffer = ctypes.create_string_buffer(raw)
src = Blob(len(raw), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_byte)))
dst = Blob()
crypt = ctypes.WinDLL('crypt32', use_last_error=True)
if decrypt:
ok = crypt.CryptUnprotectData(ctypes.byref(src), None, None, None, None, 1, ctypes.byref(dst))
else:
ok = crypt.CryptProtectData(ctypes.byref(src), 'Monitor', None, None, None,
1 | (4 if machine else 0), ctypes.byref(dst))
if not ok:
raise ctypes.WinError(ctypes.get_last_error())
try:
return ctypes.string_at(dst.ptr, dst.size)
finally:
kernel = ctypes.WinDLL('kernel32', use_last_error=True)
kernel.LocalFree.argtypes = [ctypes.c_void_p]
kernel.LocalFree(dst.ptr)
def message(text, error=False):
ctypes.windll.user32.MessageBoxW(None, str(text), 'Monitor', 0x10 if error else 0x40)
class Job:
"""Kill all owned descendants when the supervisor exits."""
def __init__(self):
class Basic(ctypes.Structure):
_fields_ = [('process_time', ctypes.c_int64), ('job_time', ctypes.c_int64),
('flags', wintypes.DWORD), ('min_ws', ctypes.c_size_t),
('max_ws', ctypes.c_size_t), ('active', wintypes.DWORD),
('affinity', ctypes.c_size_t), ('priority', wintypes.DWORD),
('scheduling', wintypes.DWORD)]
class IO(ctypes.Structure):
_fields_ = [(n, ctypes.c_uint64) for n in ('ro','wo','oo','rb','wb','ob')]
class Extended(ctypes.Structure):
_fields_ = [('basic', Basic), ('io', IO), ('process_memory', ctypes.c_size_t),
('job_memory', ctypes.c_size_t), ('peak_process', ctypes.c_size_t),
('peak_job', ctypes.c_size_t)]
self.kernel = ctypes.WinDLL('kernel32', use_last_error=True)
self.kernel.CreateJobObjectW.restype = wintypes.HANDLE
self.kernel.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
self.kernel.SetInformationJobObject.argtypes = [wintypes.HANDLE, ctypes.c_int,
ctypes.c_void_p, wintypes.DWORD]
self.kernel.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
self.kernel.CloseHandle.argtypes = [wintypes.HANDLE]
self.handle = self.kernel.CreateJobObjectW(None, None)
if not self.handle:
raise ctypes.WinError(ctypes.get_last_error())
info = Extended()
info.basic.flags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
if not self.kernel.SetInformationJobObject(self.handle, 9, ctypes.byref(info), ctypes.sizeof(info)):
self.close()
raise ctypes.WinError(ctypes.get_last_error())
def assign(self, process):
if not self.kernel.AssignProcessToJobObject(self.handle, wintypes.HANDLE(int(process._handle))):
process.kill()
raise ctypes.WinError(ctypes.get_last_error())
def close(self):
if self.handle:
self.kernel.CloseHandle(self.handle)
self.handle = None

View File

@ -0,0 +1,6 @@
# Optional local NVIDIA GPU runtime for Windows / Python 3.12.
# Install after requirements-windows.txt with:
# python -m pip install -r requirements-windows-gpu.txt
--index-url https://download.pytorch.org/whl/cu129
torch==2.8.0+cu129
torchvision==0.23.0+cu129

13
scripts/enable-gpu.ps1 Normal file
View File

@ -0,0 +1,13 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$python = Join-Path $root '.venv\Scripts\python.exe'
if (-not (Test-Path -LiteralPath $python)) {
throw "Python virtual environment not found: $python"
}
& $python -m pip install --upgrade --force-reinstall --no-deps `
torch==2.8.0+cu129 torchvision==0.23.0+cu129 `
--index-url https://download.pytorch.org/whl/cu129
if ($LASTEXITCODE -ne 0) { throw 'CUDA PyTorch installation failed' }
& $python -c "import torch; assert torch.cuda.is_available(), 'CUDA is unavailable'; print(torch.__version__, torch.version.cuda, torch.cuda.get_device_name(0))"
if ($LASTEXITCODE -ne 0) { throw 'CUDA verification failed' }
Write-Host 'GPU inference runtime is ready. Restart Monitor and select CUDA for the YOLO model.' -ForegroundColor Green

View File

@ -1,8 +1,4 @@
"""Explicit Windows webcam service: loopback only, video only, no disk recording.
Does not call ServiceManager.start(): SIP, auto-proxy, recording and telemetry
remain disabled. Holds the shared leader lock so an embedded service cannot race it.
"""
"""Open the local webcam and register it without starting ZLMediaKit or FFmpeg."""
import argparse
import configparser
from datetime import datetime
@ -32,9 +28,10 @@ STATUS = WORK / 'status.json'
STOP = WORK / 'stop.request'
STREAM = 'laptop_cam'
DEVICE = 'Integrated Camera'
MARKER = 'Managed by scripts/local_webcam.py; loopback video only'
MARKER = 'Managed by scripts/local_webcam.py; camera registration only; no ZLM'
PORTS = {'http': 10002, 'rtsp': 10554, 'rtmp': 10935}
PUSH_URL = f'rtmp://127.0.0.1:{PORTS["rtmp"]}/live/{STREAM}'
CAMERA_INDEX = int(os.environ.get('MONITOR_WEBCAM_INDEX', '0'))
def write_json(path, data):
@ -167,26 +164,31 @@ def terminate(child):
def run():
"""Keep the physical camera open and register an inventory-only stream."""
os.environ.update(local_environment())
from app.services.lifecycle import ServiceLeaderLock
lock = ServiceLeaderLock()
if not lock.acquire():
raise RuntimeError('Another background-service leader exists; refusing to start')
media = camera = hook = None
row = None
camera = row = None
StreamModel = None
try:
web = web_process()
if not web:
raise RuntimeError('Start the local web server first')
for conn in psutil.net_connections('inet'):
# TIME_WAIT sockets left by a terminated ZLM have no owning
# process (pid 0/None on Windows); only a live owner blocks start.
if conn.laddr and conn.laddr.port in PORTS.values() and conn.pid:
raise RuntimeError('Media port occupied; no unrelated process was stopped')
config_data = json.loads((ROOT / 'config.json').read_text(encoding='utf-8'))
for key, section in [('mediaHttpPort', 'http'), ('mediaRtspPort', 'rtsp'), ('mediaRtmpPort', 'rtmp')]:
if int(config_data[key]) != PORTS[section]:
raise RuntimeError('Configured media ports changed; review local webcam settings first')
import cv2
camera = cv2.VideoCapture(CAMERA_INDEX, cv2.CAP_DSHOW)
if not camera.isOpened():
camera.release()
camera = cv2.VideoCapture(CAMERA_INDEX)
if not camera.isOpened():
raise RuntimeError(f'Camera open failed: index={CAMERA_INDEX}; check Windows camera permission/device availability')
camera.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
camera.set(cv2.CAP_PROP_FPS, 15)
ok, frame = camera.read()
if not ok or frame is None:
raise RuntimeError('Camera opened but no video frame was received')
width = int(camera.get(cv2.CAP_PROP_FRAME_WIDTH) or 640)
height = int(camera.get(cv2.CAP_PROP_FRAME_HEIGHT) or 480)
fps = float(camera.get(cv2.CAP_PROP_FPS) or 15)
from django.conf import settings
database = Path(settings.DATABASES['default']['NAME'])
backup_dir = WORK / ('backup-' + datetime.now().strftime('%Y%m%d-%H%M%S-%f'))
@ -195,97 +197,41 @@ def run():
with sqlite3.connect(backup_dir / database.name) as target:
source.backup(target)
import django
django.setup() # Services explicitly disabled; normal existing schema migration only.
django.setup()
from app.models import StreamModel
existing = StreamModel.objects.filter(code=STREAM).first()
if existing and (existing.remark != MARKER or existing.pull_stream_type != 32):
if existing and not (existing.remark or '').startswith('Managed by scripts/local_webcam.py;'):
raise RuntimeError('laptop_cam belongs to another stream; refusing to overwrite')
from app.utils.Secrets import get_runtime_secret
api_secret = get_runtime_secret('media_secret')
exe = (ROOT / config_data['mediaStartPath']).resolve()
if not exe.is_relative_to(ROOT) or not exe.is_file() or b'listen_ip' not in exe.read_bytes():
raise RuntimeError('Expected project ZLM binary with listen_ip support')
template = (ROOT / config_data['mediaStartConfigPath']).read_text(encoding='utf-8', errors='replace')
(WORK / 'www').mkdir(exist_ok=True)
hook, hook_url = hook_server()
ini = WORK / 'zlm.ini'
with ini.open('w', encoding='utf-8') as handle:
make_config(template, api_secret, hook_url).write(handle, space_around_delimiters=False)
with (WORK / 'zlm.log').open('ab') as log:
media = subprocess.Popen([str(exe), '-c', str(ini), '-l', '3', '-t', '2',
'--log-dir', str(WORK / 'zlm-logs')], cwd=WORK,
stdin=subprocess.DEVNULL, stdout=log, stderr=log,
creationflags=subprocess.CREATE_NO_WINDOW)
client = requests.Session()
client.trust_env = False
def api(method, **params):
response = client.post(f'http://127.0.0.1:{PORTS["http"]}/index/api/{method}',
data={'secret': api_secret, **params}, timeout=3)
response.raise_for_status()
return response.json()
for _ in range(30):
if media.poll() is not None:
raise RuntimeError('ZLM exited; see local-webcam/zlm.log')
listeners = check_connections([psutil.Process(media.pid)], set(PORTS.values()))
if listeners == set(PORTS.values()):
break
time.sleep(.3)
else:
raise RuntimeError('Loopback media listeners not ready')
if api('getThreadsLoad').get('code') != 0:
raise RuntimeError('Media API health check failed')
with (WORK / 'ffmpeg.log').open('ab') as log:
camera = subprocess.Popen(capture_command(os.environ['MONITOR_FFMPEG']), cwd=WORK,
stdin=subprocess.DEVNULL, stdout=log, stderr=log,
creationflags=subprocess.CREATE_NO_WINDOW)
for _ in range(40):
if camera.poll() is not None:
raise RuntimeError('Camera capture failed; see local-webcam/ffmpeg.log')
info = api('getMediaInfo', schema='rtmp', vhost='__defaultVhost__', app='live', stream=STREAM)
if info.get('code') == 0 and info.get('tracks'):
break
time.sleep(.5)
else:
raise RuntimeError('Camera stream readiness timeout')
if any(track.get('codec_type') != 0 for track in info['tracks']):
raise RuntimeError('Unexpected non-video track')
camera_url = f'camera://index/{CAMERA_INDEX}'
row, _ = StreamModel.objects.get_or_create(code=STREAM, defaults={
'user_id': 0, 'sort': 0, 'app': 'live', 'name': STREAM, 'nickname': '笔记本内置摄像头',
'remark': MARKER, 'pull_stream_type': 32, 'pull_stream_transfer_mode': 0,
'pull_stream_url': PUSH_URL, 'pull_stream_ip': '127.0.0.1',
'pull_stream_port': PORTS['rtmp'], 'forward_state': 1, 'is_audio': 0,
'record_enable': 0, 'state': 0, 'camera_name': DEVICE, 'camera_device_id': 'local-webcam'})
if row.remark != MARKER:
'remark': MARKER, 'pull_stream_type': 0, 'pull_stream_transfer_mode': 0,
'pull_stream_url': camera_url, 'pull_stream_ip': '127.0.0.1', 'pull_stream_port': 0,
'forward_state': 0, 'is_audio': 0, 'record_enable': 0, 'state': 0,
'camera_name': DEVICE, 'camera_device_id': 'local-webcam'})
if not (row.remark or '').startswith('Managed by scripts/local_webcam.py;'):
raise RuntimeError('Stream ownership changed')
StreamModel.objects.filter(pk=row.pk).update(forward_state=1, record_enable=0, is_audio=0)
print('Ready: laptop_cam (Integrated Camera, 640x480/15fps, video only)', flush=True)
StreamModel.objects.filter(pk=row.pk).update(
remark=MARKER, pull_stream_type=0, pull_stream_url=camera_url,
pull_stream_ip='127.0.0.1', pull_stream_port=0, forward_state=0,
record_enable=0, is_audio=0, camera_name=DEVICE, camera_device_id='local-webcam')
print(f'Ready: laptop_cam ({DEVICE}, {width}x{height}/{fps:.0f}fps, camera only; ZLM not started)', flush=True)
while not STOP.exists():
if not web.is_running() or media.poll() is not None or camera.poll() is not None:
raise RuntimeError('Web/camera/media process exited; shutting down capture')
managed = [psutil.Process(os.getpid()), psutil.Process(media.pid), psutil.Process(camera.pid)]
listeners = check_connections(managed, {*PORTS.values(), hook.server_port})
info = api('getMediaInfo', schema='rtmp', vhost='__defaultVhost__', app='live', stream=STREAM)
if info.get('code') != 0 or any(t.get('codec_type') != 0 for t in info.get('tracks', [])):
raise RuntimeError('Video stream health check failed')
if info.get('isRecordingMP4') or info.get('isRecordingHLS'):
raise RuntimeError('Recording detected; stopping camera')
if not web.is_running():
raise RuntimeError('Local web server exited; stopping camera')
ok, frame = camera.read()
if not ok or frame is None:
raise RuntimeError('Camera frame read failed')
write_json(STATUS, {'ready': True, 'stream_id': row.pk, 'stream': STREAM, 'device': DEVICE,
'media_pid': media.pid, 'camera_pid': camera.pid,
'loopback_ports': sorted(listeners), 'tracks': info['tracks'],
'audio': False, 'recording': False, 'sip': False,
'checked_at': datetime.now().isoformat(timespec='seconds')})
time.sleep(2)
'camera_index': CAMERA_INDEX, 'width': width, 'height': height, 'fps': fps,
'media_started': False, 'zlm_started': False, 'audio': False, 'recording': False,
'sip': False, 'checked_at': datetime.now().isoformat(timespec='seconds')})
time.sleep(0.2)
finally:
terminate(camera)
terminate(media)
if hook:
hook.shutdown()
hook.server_close()
if row is not None:
if camera is not None:
camera.release()
if row is not None and StreamModel is not None:
StreamModel.objects.filter(pk=row.pk, remark=MARKER).update(forward_state=0)
lock.release()
write_json(STATUS, {'ready': False, 'stopped_at': datetime.now().isoformat(timespec='seconds')})
@ -307,7 +253,7 @@ def start():
if child.poll() is not None:
raise RuntimeError('Webcam start failed; inspect .runtime/local-webcam/supervisor.log')
if STATUS.exists() and json.loads(STATUS.read_text(encoding='utf-8')).get('ready'):
print('Ready: http://127.0.0.1:10001/stream/index | laptop_cam | video only')
print('Ready: http://127.0.0.1:10001/stream/index | laptop_cam | camera only; ZLM not started')
return
time.sleep(1)
STOP.touch()
@ -325,7 +271,7 @@ def stop():
except psutil.TimeoutExpired:
raise RuntimeError('Graceful stop timed out; inspect owned processes before retrying')
PID_FILE.unlink(missing_ok=True)
print('Webcam and local media stopped; web server and saved stream record retained')
print('Webcam stopped; web server and registered camera record retained')
if __name__ == '__main__':

View File

@ -0,0 +1,74 @@
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
$py = "$projectRoot\.venv\Scripts\python.exe"
$runservicesLog = "$projectRoot\.runtime\runservices.stdout.log"
$runservicesErr = "$projectRoot\.runtime\runservices.stderr.log"
$pidFile = "$projectRoot\.runtime\runservices.json"
$action = 'start'
if ($args.Count -gt 0) { $action = $args[0] }
function Get-ProjectServices {
Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -like '*manage.py runservices*' -and $_.CommandLine -like '*video_monitor*' }
}
function Get-Descendants([int]$ParentId) {
$children = @()
Get-CimInstance Win32_Process -Filter "ParentProcessId=$ParentId" -ErrorAction SilentlyContinue | ForEach-Object {
$children += $_
$children += Get-Descendants $_.ProcessId
}
$children
}
switch ($action) {
'start' {
$existing = Get-ProjectServices
if ($existing) {
Write-Host "runservices already running: PIDs $($existing.ProcessId -join ', ')"
exit 0
}
New-Item -ItemType Directory -Force -Path "$projectRoot\.runtime" | Out-Null
$p = Start-Process -FilePath $py -ArgumentList @('manage.py','runservices') `
-WorkingDirectory $projectRoot -WindowStyle Hidden `
-RedirectStandardOutput $runservicesLog -RedirectStandardError $runservicesErr -PassThru
@{ pid = $p.Id; created = (Get-Date).ToString('o') } | ConvertTo-Json | Set-Content $pidFile -Encoding UTF8
Write-Host "runservices started PID=$($p.Id)"
Start-Sleep -Seconds 20
$zlm = Get-CimInstance Win32_Process -Filter "Name='monitor_zlm.exe'" -ErrorAction SilentlyContinue
$sip = Get-NetUDPEndpoint -LocalPort 15060 -ErrorAction SilentlyContinue
if ($zlm) { Write-Host "ZLM up PID=$($zlm.ProcessId -join ', ')" } else { Write-Host 'ZLM not up yet; inspect log' }
if ($sip) { Write-Host 'SIP 15060 listening' } else { Write-Host 'SIP 15060 not listening yet' }
}
'stop' {
$procs = Get-ProjectServices
if (-not $procs) {
Write-Host 'No runservices process running'
Remove-Item $pidFile -ErrorAction SilentlyContinue
exit 0
}
foreach ($proc in $procs) {
$desc = Get-Descendants $proc.ProcessId
foreach ($child in $desc) {
Stop-Process -Id $child.ProcessId -Force -ErrorAction SilentlyContinue
}
Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue
Write-Host "stopped runservices PID=$($proc.ProcessId) (children: $($desc.ProcessId -join ', '))"
}
Remove-Item $pidFile -ErrorAction SilentlyContinue
}
'status' {
$procs = Get-ProjectServices
$zlm = Get-CimInstance Win32_Process -Filter "Name='monitor_zlm.exe'" -ErrorAction SilentlyContinue
$sip = Get-NetUDPEndpoint -LocalPort 15060 -ErrorAction SilentlyContinue
[pscustomobject]@{
runservices = ($procs | ForEach-Object { $_.ProcessId }) -join ', '
zlm = ($zlm | ForEach-Object { $_.ProcessId }) -join ', '
sip_15060 = [bool]$sip
} | Format-List
}
default {
Write-Host "Usage: .\scripts\start-services.ps1 [start|stop|status]"
exit 1
}
}

Binary file not shown.

View File

@ -429,6 +429,22 @@
.em-empty{text-align:center;padding:40px 20px;color:#9ca3af;font-size:13px;
background:#f9fafb;border-radius:10px;border:1px dashed #e5e7eb;}
.em-empty .ic{display:block;font-size:32px;margin-bottom:8px;opacity:.4;}
/* 实时 YOLO 预览 */
.yolo-preview-modal{width:min(1180px,96vw);background:#0f172a;border-radius:12px;overflow:hidden;
box-shadow:0 24px 70px rgba(0,0,0,.35);color:#e2e8f0;display:flex;flex-direction:column;}
.yp-head{height:54px;padding:0 18px;background:#fff;color:#1f2937;display:flex;align-items:center;justify-content:space-between;}
.yp-title{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:600;}
.yp-live-dot{width:9px;height:9px;border-radius:50%;background:#10b981;box-shadow:0 0 0 4px rgba(16,185,129,.15);}
.yp-stage{position:relative;width:100%;aspect-ratio:16/9;background:#020617;overflow:hidden;}
#yoloPreviewPlayer,#yoloPreviewPlayer>*{width:100%;height:100%;}
#yoloPreviewCanvas{position:absolute;inset:0;width:100%;height:100%;z-index:5;pointer-events:none;}
.yp-loading{position:absolute;inset:0;z-index:6;display:flex;align-items:center;justify-content:center;background:rgba(2,6,23,.72);color:#cbd5e1;}
.yp-foot{min-height:52px;padding:10px 16px;background:#111827;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
.yp-stat{font-size:12px;color:#cbd5e1;background:#1f2937;border:1px solid #334155;border-radius:14px;padding:4px 10px;}
.yp-mode{font-size:12px;font-weight:600;border-radius:14px;padding:4px 10px;background:#064e3b;color:#a7f3d0;}
.yp-mode.preview{background:#78350f;color:#fde68a;}
.act-btn.act-preview{color:#0f766e}.act-btn.act-preview:hover{background:#ecfdf5;color:#059669;}
</style>
{% endblock %}
@ -577,6 +593,12 @@
<div class="zm-detect-preview" id="detectPreview"></div>
</div>
</div>
<div class="zm-field">
<label>重复报警间隔(秒)</label>
<input id="f_alarm_repeat" type="number" value="30" min="0" max="86400" step="1"
title="0 表示同一次停留不重复报警;离开后重新进入仍会立即报警">
<div class="biz-hint" style="margin-top:5px;font-size:11px;color:#64748b;">首次进入立即报警0 表示同一次停留只报一次</div>
</div>
<div class="zm-field">
<label>{{ T.zone_algorithms|default:'业务算法' }} <span style="font-weight:400;color:#9ca3af;font-size:11px;">({{ T.zone_algorithms_hint|default:'可多选' }})</span></label>
<div class="zm-algo-wrap">
@ -601,6 +623,28 @@
</div>
</div>
<!-- 实时 YOLO 检测弹框 -->
<div class="engine-modal-overlay" id="yoloPreviewModal" onclick="if(event.target===this)closeYoloPreview()">
<div class="yolo-preview-modal" role="dialog" aria-modal="true" aria-label="实时 YOLO 检测">
<div class="yp-head">
<div class="yp-title"><span class="yp-live-dot"></span><span id="ypTitle">实时 YOLO 人员检测</span></div>
<button type="button" class="engine-modal-close" onclick="closeYoloPreview()" aria-label="close">&times;</button>
</div>
<div class="yp-stage" id="yoloPreviewStage">
<div id="yoloPreviewPlayer"></div>
<canvas id="yoloPreviewCanvas"></canvas>
<div class="yp-loading" id="ypLoading">正在启动 YOLO 分析并连接视频流…</div>
</div>
<div class="yp-foot">
<span class="yp-mode preview" id="ypMode">预览模式,不产生报警</span>
<span class="yp-stat" id="ypModel">模型:—</span>
<span class="yp-stat" id="ypFps">分析 FPS0.00</span>
<span class="yp-stat" id="ypAge">结果延迟:—</span>
<span class="yp-stat" id="ypCount">当前人员0</span>
</div>
</div>
</div>
<!-- 布控实例管理弹框 -->
<div class="engine-modal-overlay" id="engineModal" onclick="if(event.target===this)closeEngineModal()">
<div class="engine-modal" role="dialog">
@ -684,7 +728,9 @@ function refreshAnalysisUI(){
var badge = document.getElementById('analysisStatusBadge');
if(!sid){
badge.style.display = 'inline-flex';
var n = g_runningStreams.length;
var n = g_runningStreams.filter(function(r){
return typeof r === 'object' && r.running !== false && r.healthy;
}).length;
badge.className = 'analysis-badge ' + (n>0?'running':'stopped');
badge.textContent = _t('zone_analysis_running_count','分析中:{n}').replace('{n}', n);
return;
@ -704,6 +750,13 @@ function refreshAnalysisUI(){
fpsStr = ' · ' + (info.analysis_fps||0).toFixed(1) + ' fps';
}
var healthStr = running ? (' · ' + streamHealthLabel(health)) : '';
if(info && (info.analysis_health === 'error' || info.analysis_health === 'stalled')){
healthStr = ' · ' + _t('zone_status_inference_error','推理异常');
badge.className = 'analysis-badge stalled';
} else if(info && info.analysis_health === 'starting'){
healthStr = ' · ' + _t('zone_status_starting','启动中');
badge.className = 'analysis-badge reconnecting';
}
if(g_lastAnalysisData && g_lastAnalysisData.inference_degraded && running){
healthStr += ' · ' + _t('zone_inference_degraded','推理异常');
if(health === 'ok') badge.className = 'analysis-badge stalled';
@ -724,6 +777,7 @@ function refreshAnalysisUI(){
var SVG_ANALYZE_START = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none"/></svg>';
var SVG_ANALYZE_STOP = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="6" width="12" height="12" rx="1"/></svg>';
var SVG_PREVIEW = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M8 20v2m8-2v2"/></svg>';
var SVG_EDIT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
var SVG_DEL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>';
var SVG_LOADING = '<svg class="act-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round"/></svg>';
@ -754,9 +808,12 @@ function buildZoneStateTag(z){
return '<span class="tag tag-off">'+_t('zone_status_stopped','已停止')+'</span>';
}
var info = getStreamStatusInfo(z.stream_id);
if(!info || info.running === false){
if(!info){
return '<span class="tag tag-warn">'+_t('zone_status_starting','启动中')+'</span>';
}
if(info.analysis_health === 'error' || info.analysis_health === 'stalled'){
return '<span class="tag tag-err">'+_t('zone_status_inference_error','推理异常')+'</span>';
}
var health = getStreamHealth(z.stream_id);
var fps = info.analysis_fps != null ? info.analysis_fps : 0;
if(health === 'stalled' || health === 'disconnected'){
@ -765,7 +822,8 @@ function buildZoneStateTag(z){
if(health === 'reconnecting' || health === 'connecting'){
return '<span class="tag tag-warn">'+streamHealthLabel(health)+'</span>';
}
if(health === 'ok' && fps > 0.05){
if(info.running !== false && health === 'ok' &&
(info.analysis_health === 'running' || (!info.analysis_health && fps > 0.05))){
return '<span class="tag tag-on">'+_t('zone_status_running','分析中')+'</span>';
}
return '<span class="tag tag-warn">'+_t('zone_status_starting','启动中')+'</span>';
@ -1251,6 +1309,7 @@ function renderZoneTable(zones){
var nm='<div style="display:flex;align-items:center;gap:6px;"><span style="width:9px;height:9px;border-radius:50%;background:'+escapeHtml(z.color||'#169F85')+';flex-shrink:0;"></span>'+escapeHtml(z.name)+'</div>';
var actions = '<div class="zone-actions">'
+'<span class="zone-run-btn">'+buildZoneRunBtn(z)+'</span>'
+'<button type="button" class="act-btn act-preview" onclick="openYoloPreview('+z.id+')" title="实时 YOLO 检测">'+SVG_PREVIEW+'</button>'
+'<button type="button" class="act-btn" onclick="editZone('+z.id+')" title="'+escapeHtml(_t('btn_edit','编辑'))+'">'+SVG_EDIT+'</button>'
+'<button type="button" class="act-btn act-danger" onclick="delZone('+z.id+')" title="'+escapeHtml(_t('btn_del','删除'))+'">'+SVG_DEL+'</button>'
+'</div>';
@ -1333,6 +1392,7 @@ function editZone(id){
document.getElementById('f_loiter').value=z.loiter_threshold;
document.getElementById('f_detect_interval').value=z.detect_interval_sec||1;
document.getElementById('f_detect_frames').value=z.detect_frames||1;
document.getElementById('f_alarm_repeat').value=z.alarm_repeat_sec == null ? 30 : z.alarm_repeat_sec;
updateDetectPreview();
document.getElementById('f_color').value=z.color;
syncColorSwatches(z.color);
@ -1357,6 +1417,7 @@ function openZoneModal(){
document.getElementById('f_loiter').value=0;
document.getElementById('f_detect_interval').value=1;
document.getElementById('f_detect_frames').value=1;
document.getElementById('f_alarm_repeat').value=30;
updateDetectPreview();
document.getElementById('f_color').value='#169F85';
syncColorSwatches('#169F85');
@ -1408,6 +1469,7 @@ function setToggleValue(groupId, val, hiddenId){
document.addEventListener('keydown', function(e){
if(e.key==='Escape' && document.getElementById('zoneModal').classList.contains('show')) closeZoneModal();
if(e.key==='Escape' && document.getElementById('yoloPreviewModal').classList.contains('open')) closeYoloPreview();
});
/* ---------- 摄像头画面绘制 ---------- */
@ -1880,6 +1942,7 @@ function submitZone(){
loiter_threshold:parseInt(document.getElementById('f_loiter').value,10)||0,
detect_interval_sec:getDetectRatePayload().detect_interval_sec,
detect_frames:getDetectRatePayload().detect_frames,
alarm_repeat_sec:Math.max(0, parseFloat(document.getElementById('f_alarm_repeat').value)||0),
color:document.getElementById('f_color').value.trim(),
is_required:parseInt(document.getElementById('f_required').value,10),
algorithm_ids:getSelectedAlgoIds()
@ -2132,5 +2195,180 @@ function restartInferencePool(btn){
if(btn){ btn.disabled = false; btn.textContent = '重启推理池'; }
});
}
/* ---------- 实时 YOLO 人员检测 ---------- */
var g_yp = {sessionId:'', sequence:0, player:null, pollTimer:null, renderTimer:null,
resizeObserver:null, data:null, zone:null, frameW:0, frameH:0, opening:false,
animationFrame:null, displayBoxes:{}, lastDrawMs:0};
function computeContainRect(containerW, containerH, mediaW, mediaH){
mediaW = mediaW || 16; mediaH = mediaH || 9;
var scale = Math.min(containerW / mediaW, containerH / mediaH);
var width = mediaW * scale, height = mediaH * scale;
return {x:(containerW-width)/2, y:(containerH-height)/2, width:width, height:height};
}
function resizeYoloCanvas(){
var canvas=document.getElementById('yoloPreviewCanvas');
var stage=document.getElementById('yoloPreviewStage');
if(!canvas||!stage) return;
var dpr=window.devicePixelRatio||1;
var w=Math.max(1,stage.clientWidth), h=Math.max(1,stage.clientHeight);
canvas.width=Math.round(w*dpr); canvas.height=Math.round(h*dpr);
canvas.style.width=w+'px'; canvas.style.height=h+'px';
var ctx=canvas.getContext('2d'); ctx.setTransform(dpr,0,0,dpr,0,0);
drawYoloOverlay();
}
function projectTrackBox(track, resultTimestamp, nowSec){
var box=(track.box||[]).slice();
if(box.length!==4 || !track.observed) return box;
var velocity=track.velocity||[0,0,0,0];
var lead=Math.max(0,Math.min(1.2,(nowSec||Date.now()/1000)-(resultTimestamp||nowSec)));
var projected=box.map(function(v,i){return v+(Number(velocity[i])||0)*lead;});
var bw=Math.max(.001,box[2]-box[0]), bh=Math.max(.001,box[3]-box[1]);
var dx=((projected[0]+projected[2])-(box[0]+box[2]))*.5;
var dy=((projected[1]+projected[3])-(box[1]+box[3]))*.5;
var distance=Math.sqrt(dx*dx+dy*dy), maxMove=Math.max(bw,bh)*.9;
if(distance>maxMove){
var ratio=maxMove/distance;
projected=projected.map(function(v,i){return box[i]+(v-box[i])*ratio;});
}
projected[0]=Math.max(0,Math.min(1,projected[0]));
projected[1]=Math.max(0,Math.min(1,projected[1]));
projected[2]=Math.max(projected[0],Math.min(1,projected[2]));
projected[3]=Math.max(projected[1],Math.min(1,projected[3]));
return projected;
}
function smoothTrackBox(track, target, nowMs){
var key=String(track.track_id), state=g_yp.displayBoxes[key];
if(!state){state={box:target.slice(),last:nowMs};g_yp.displayBoxes[key]=state;return state.box;}
var dt=Math.max(1,Math.min(100,nowMs-state.last)), alpha=1-Math.exp(-dt/90);
for(var i=0;i<4;i++)state.box[i]+=((target[i]||0)-state.box[i])*alpha;
state.last=nowMs;return state.box;
}
function drawYoloOverlay(){
var canvas=document.getElementById('yoloPreviewCanvas');
var stage=document.getElementById('yoloPreviewStage');
if(!canvas||!stage) return;
var ctx=canvas.getContext('2d'), w=stage.clientWidth, h=stage.clientHeight;
ctx.clearRect(0,0,w,h);
var data=g_yp.data||{}, fw=data.frame_width||g_yp.frameW||16, fh=data.frame_height||g_yp.frameH||9;
var rect=computeContainRect(w,h,fw,fh);
var zone=(data.zones&&data.zones[0])||g_yp.zone;
if(zone && zone.coords && zone.coords.length>=3){
ctx.save(); ctx.beginPath();
zone.coords.forEach(function(p,i){
var x=rect.x+p[0]*rect.width, y=rect.y+p[1]*rect.height;
if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);
});
ctx.closePath(); ctx.fillStyle=hexToRgba(zone.color||'#169F85',.16);ctx.fill();
ctx.strokeStyle=zone.color||'#169F85';ctx.lineWidth=2;ctx.stroke();ctx.restore();
}
var drawNow=Date.now(), liveIds={};
(data.tracks||[]).forEach(function(t){
if(t.label!=='person' || !t.box || t.box.length<4) return;
liveIds[String(t.track_id)]=true;
var target=projectTrackBox(t,data.timestamp,drawNow/1000);
var displayBox=smoothTrackBox(t,target,drawNow);
var x=rect.x+displayBox[0]*rect.width, y=rect.y+displayBox[1]*rect.height;
var bw=(displayBox[2]-displayBox[0])*rect.width, bh=(displayBox[3]-displayBox[1])*rect.height;
var inside=(t.inside_zone_ids||[]).indexOf(zone&&zone.id)>=0;
var color=inside?'#ef4444':'#a855f7';
ctx.save();ctx.globalAlpha=t.opacity==null?1:t.opacity;ctx.strokeStyle=color;ctx.lineWidth=2;
if(!t.observed)ctx.setLineDash([7,5]);ctx.strokeRect(x,y,bw,bh);ctx.setLineDash([]);
var label='person · ID '+t.track_id+' · '+Math.round((t.score||0)*100)+'%'+(inside?' · 区域内':'');
ctx.font='12px sans-serif';var tw=ctx.measureText(label).width+10;
ctx.fillStyle=color;ctx.fillRect(x,Math.max(rect.y,y-22),tw,20);
ctx.fillStyle='#fff';ctx.fillText(label,x+5,Math.max(rect.y+14,y-7));ctx.restore();
});
Object.keys(g_yp.displayBoxes).forEach(function(key){if(!liveIds[key])delete g_yp.displayBoxes[key];});
g_yp.lastDrawMs=drawNow;
}
function animateYoloOverlay(){
if(!g_yp.sessionId)return;
drawYoloOverlay();
g_yp.animationFrame=requestAnimationFrame(animateYoloOverlay);
}
function updateYoloStats(){
var d=g_yp.data||{};
var mode=d.mode||'preview', modeEl=document.getElementById('ypMode');
if(modeEl){modeEl.className='yp-mode '+(mode==='formal'?'':'preview');modeEl.textContent=mode==='formal'?'正式分析中':'预览模式,不产生报警';}
var names=d.model_names||[];
var devices=(d.model_devices||[]).map(function(v){return String(v).toUpperCase();});
document.getElementById('ypModel').textContent='模型:'+(names.join('、')||'—')+(devices.length?' · '+devices.join('/'):'');
document.getElementById('ypFps').textContent='分析 FPS'+Number(d.analysis_fps||0).toFixed(2);
var age=d.timestamp ? Math.max(0,Date.now()/1000-d.timestamp) : null;
var ageEl=document.getElementById('ypAge');
ageEl.textContent='结果延迟:'+(age==null?'—':age.toFixed(1)+' 秒');
ageEl.style.color=age!=null&&age>10?'#fca5a5':'#cbd5e1';
var count=(d.tracks||[]).filter(function(t){return t.label==='person'&&t.observed;}).length;
document.getElementById('ypCount').textContent='当前人员:'+count;
}
function pollYoloPreview(){
if(!g_yp.sessionId)return;
Api.get('/analysis/openPreviewData',{session_id:g_yp.sessionId,since:g_yp.sequence}).then(function(res){
if(!g_yp.sessionId)return;
if(!res||res.code!==1000){showToast((res&&res.msg)||'预览会话已失效','error');closeYoloPreview();return;}
var d=res.data||{};
if(d.changed){g_yp.data=d;g_yp.sequence=d.sequence||g_yp.sequence;g_yp.frameW=d.frame_width||g_yp.frameW;g_yp.frameH=d.frame_height||g_yp.frameH;drawYoloOverlay();}
else if(g_yp.data){g_yp.data.mode=d.mode||g_yp.data.mode;}
updateYoloStats();
}).catch(function(){});
}
function startYoloPlayer(app,name){
Api.get('/stream/openPlayer',{app:app,name:name}).then(function(res){
if(!g_yp.sessionId)return;
var s=res&&res.info&&res.info.stream;
if(!s||parseInt(s.is_online,10)!==1){document.getElementById('ypLoading').textContent='视频流离线,但 YOLO 分析会继续等待视频';return;}
g_yp.frameW=s.video_width||g_yp.frameW;g_yp.frameH=s.video_height||g_yp.frameH;
var url=s.wsMp4Url||s.httpMp4Url||s.wsFlvUrl||s.httpFlvUrl;
if(!url){document.getElementById('ypLoading').textContent='无可用播放地址';return;}
try{
g_yp.player=new EasyPlayerPro({container:document.getElementById('yoloPreviewPlayer'),videoBuffer:.1,
decoder:'/static/lib/easyPlayer/js/decoder-pro.js',isResize:true,debug:false,useMSE:true,useWCS:true,
hasAudio:false,isNotMute:true,showBandwidth:false,operateBtns:{fullscreen:true,screenshot:false,play:true,audio:false,ptz:false},
timeout:10,forceNoOffscreen:true,heartTimeout:10});
g_yp.player.play(url);document.getElementById('ypLoading').style.display='none';
}catch(e){document.getElementById('ypLoading').textContent='视频播放器启动失败';}
});
}
function openYoloPreview(zoneId){
if(g_yp.opening)return;g_yp.opening=true;
var zone=g_zoneCache.find(function(z){return parseInt(z.id,10)===parseInt(zoneId,10);});
document.getElementById('yoloPreviewModal').classList.add('open');
document.getElementById('ypLoading').style.display='flex';
document.getElementById('ypLoading').textContent='正在启动 YOLO 分析并连接视频流…';
document.getElementById('ypTitle').textContent=(zone?zone.stream_name+' · '+zone.name:'')+' 实时 YOLO 人员检测';
Api.post('/analysis/openPreviewStart',{zone_id:zoneId}).then(function(res){
g_yp.opening=false;
if(!res||res.code!==1000){showToast((res&&res.msg)||'启动预览失败','error');closeYoloPreview();return;}
var d=res.data||{};g_yp.sessionId=d.session_id;g_yp.sequence=0;g_yp.zone=d.zone;g_yp.data={mode:d.mode,zones:[d.zone],tracks:[],model_names:(d.models||[]).map(function(m){return m.name;})};
updateYoloStats();resizeYoloCanvas();startYoloPlayer(d.app,d.name);
g_yp.pollTimer=setInterval(pollYoloPreview,250);g_yp.renderTimer=setInterval(updateYoloStats,500);
g_yp.animationFrame=requestAnimationFrame(animateYoloOverlay);
if(window.ResizeObserver){g_yp.resizeObserver=new ResizeObserver(resizeYoloCanvas);g_yp.resizeObserver.observe(document.getElementById('yoloPreviewStage'));}
}).catch(function(){g_yp.opening=false;showToast('启动预览失败','error');closeYoloPreview();});
}
function closeYoloPreview(){
var sid=g_yp.sessionId;g_yp.sessionId='';g_yp.opening=false;
if(g_yp.pollTimer)clearInterval(g_yp.pollTimer);if(g_yp.renderTimer)clearInterval(g_yp.renderTimer);
if(g_yp.animationFrame)cancelAnimationFrame(g_yp.animationFrame);
g_yp.pollTimer=null;g_yp.renderTimer=null;
if(g_yp.resizeObserver){try{g_yp.resizeObserver.disconnect();}catch(e){}g_yp.resizeObserver=null;}
if(g_yp.player){try{g_yp.player.destroy();}catch(e){}g_yp.player=null;}
if(sid)Api.post('/analysis/openPreviewStop',{session_id:sid}).catch(function(){});
g_yp.data=null;g_yp.zone=null;g_yp.sequence=0;g_yp.animationFrame=null;g_yp.displayBoxes={};
document.getElementById('yoloPreviewPlayer').innerHTML='';
document.getElementById('yoloPreviewModal').classList.remove('open');
}
</script>
{% endblock %}

View File

@ -51,6 +51,7 @@
<div style="display:flex;gap:8px">
<button class="btn btn-sm" onclick="stopPlay()">停止</button>
<button class="btn btn-sm btn-primary" onclick="startPlay()">播放</button>
<button class="btn btn-sm" id="btnRecord" onclick="toggleRecord()">录制</button>
</div>
</div>
</div>
@ -72,27 +73,27 @@
<div class="card-header"><span class="card-title">云台控制</span></div>
<div class="card-body">
<div class="ptz-pad">
<div class="ptz-btn" onclick="ptzControl(2)"></div>
<div class="ptz-btn" onclick="ptzControl(1)"></div>
<div class="ptz-btn" onclick="ptzControl(3)"></div>
<div class="ptz-btn" onclick="ptzControl(4)"></div>
<div class="ptz-btn ptz-center" onclick="ptzControl(9)"></div>
<div class="ptz-btn" onclick="ptzControl(6)"></div>
<div class="ptz-btn" onclick="ptzControl(7)"></div>
<div class="ptz-btn" onclick="ptzControl(5)"></div>
<div class="ptz-btn" onclick="ptzControl(8)"></div>
<div class="ptz-btn" data-ptz="5"></div>
<div class="ptz-btn" data-ptz="1"></div>
<div class="ptz-btn" data-ptz="6"></div>
<div class="ptz-btn" data-ptz="3"></div>
<div class="ptz-btn ptz-center" data-ptz="0"></div>
<div class="ptz-btn" data-ptz="4"></div>
<div class="ptz-btn" data-ptz="7"></div>
<div class="ptz-btn" data-ptz="2"></div>
<div class="ptz-btn" data-ptz="8"></div>
</div>
<div style="display:flex;justify-content:center;gap:8px;margin-top:14px">
<button class="btn btn-sm" onclick="ptzControl(10)">调焦+</button>
<button class="btn btn-sm" onclick="ptzControl(11)">调焦-</button>
<button type="button" class="btn btn-sm" data-ptz="9">变倍+</button>
<button type="button" class="btn btn-sm" data-ptz="10">变倍-</button>
</div>
<div style="display:flex;justify-content:center;gap:8px;margin-top:6px">
<button class="btn btn-sm" onclick="ptzControl(12)">聚焦+</button>
<button class="btn btn-sm" onclick="ptzControl(13)">聚焦-</button>
<button type="button" class="btn btn-sm" data-ptz="11">聚焦+</button>
<button type="button" class="btn btn-sm" data-ptz="12">聚焦-</button>
</div>
<div style="display:flex;justify-content:center;gap:8px;margin-top:6px">
<button class="btn btn-sm" onclick="ptzControl(14)">光圈+</button>
<button class="btn btn-sm" onclick="ptzControl(15)">光圈-</button>
<button type="button" class="btn btn-sm" data-ptz="13">光圈+</button>
<button type="button" class="btn btn-sm" data-ptz="14">光圈-</button>
</div>
</div>
</div>
@ -164,6 +165,9 @@ function initPlayer() {
document.getElementById('ptzCard').style.display = 'block';
}
// 同步当前录制状态
refreshRecordState();
// 自动播放
if (s.is_online == 1) {
startPlay();
@ -230,21 +234,115 @@ function stopPlay() {
wrap.innerHTML = '<div id="video_player" style="width:100%;height:100%;background:#000;min-height:400px"></div><div class="player-overlay" id="playerOverlay"><div style="font-size:32px"></div><div>已停止</div></div>';
}
var g_recording = false;
function toggleRecord() {
var s = streamInfo;
if (!s || !s.app || !s.name) {
showToast('视频流信息无效', 'error');
return;
}
if (g_recording) {
stopRecord();
} else {
startRecord();
}
}
function startRecord() {
var s = streamInfo;
Api.post('/nvr/openStartRecordVideo', { app: s.app, name: s.name }).then(function(res) {
if (res.code === 1000) {
g_recording = true;
updateRecordBtn();
showToast('已开始录制');
} else {
showToast(res.msg || '录制启动失败', 'error');
}
}).catch(function() {
showToast('录制启动失败', 'error');
});
}
function stopRecord() {
var s = streamInfo;
Api.post('/nvr/openStopRecordVideo', { app: s.app, name: s.name }).then(function(res) {
if (res.code === 1000) {
g_recording = false;
updateRecordBtn();
showToast('已停止录制');
} else {
showToast(res.msg || '停止录制失败', 'error');
}
}).catch(function() {
showToast('停止录制失败', 'error');
});
}
function updateRecordBtn() {
var btn = document.getElementById('btnRecord');
if (!btn) return;
if (g_recording) {
btn.textContent = '停止录制';
btn.classList.remove('btn-primary');
btn.classList.add('btn-danger');
} else {
btn.textContent = '录制';
btn.classList.remove('btn-danger');
}
}
function refreshRecordState() {
var s = streamInfo;
if (!s || !s.app || !s.name) return;
Api.get('/nvr/openVideoIsRecording', { app: s.app, name: s.name }).then(function(res) {
if (res.code === 1000) {
g_recording = !!res.is_recording;
updateRecordBtn();
}
}).catch(function() {});
}
function ptzControl(ptzType) {
var s = streamInfo;
if (!s.code) return;
if (!s || !s.code) return;
Api.post('/stream/openPtz', {
code: s.code,
ptzType: ptzType,
ptzSpeed: 4
ptzSpeed: 32
}).then(function(res) {
if (res.code !== 1000) {
showToast(res.msg, 'error');
}
}).catch(function() {
showToast('云台控制请求失败', 'error');
});
}
// 按住方向/变倍/聚焦/光圈按钮持续动作,松开或移出按钮自动发送停止
function initPtzButtons() {
var elems = document.querySelectorAll('[data-ptz]');
for (var i = 0; i < elems.length; i++) {
var el = elems[i];
el.addEventListener('mousedown', function(e) {
e.preventDefault();
ptzControl(parseInt(this.getAttribute('data-ptz'), 10));
});
el.addEventListener('mouseup', function() {
if (parseInt(this.getAttribute('data-ptz'), 10) !== 0) {
ptzControl(0); // 松开即停止
}
});
el.addEventListener('mouseleave', function() {
if (parseInt(this.getAttribute('data-ptz'), 10) !== 0) {
ptzControl(0); // 移出按钮即停止
}
});
}
}
initPtzButtons();
// 定时刷新信息
var prevIsOnline = streamInfo.is_online == 1;
setInterval(function() {

View File

@ -0,0 +1,336 @@
"""Regression coverage for camera restart, response routing and inference health."""
import multiprocessing as mp
import queue
import threading
import time
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
from app.analysis.manager import AnalysisManager
from app.analysis.pipeline import CameraPipeline
from app.analysis.process_worker import pipeline_process_main
from app.analysis.remote_detector import RemoteDetector
from app.analysis.tracker import IoUTracker
from app.analysis.preview_sessions import PreviewSessionRegistry
class CommandPipeline:
"""Delay run() so control threads must survive the pre-start window."""
def __init__(self, **kwargs):
self._running = False
self._started_event = threading.Event()
self.zones = []
def run(self):
time.sleep(0.2)
self._running = True
self._started_event.set()
deadline = time.monotonic() + 3
while self._running and time.monotonic() < deadline:
time.sleep(0.01)
assert not self._running, 'stop command was lost before run()'
assert self.zones == [{'id': 12}], 'reload command was lost'
def status(self):
return {'running': self._running}
def set_zone_polygons(self, zones):
self.zones = zones
def stop(self):
self._running = False
def command_process(commands):
with patch('app.analysis.pipeline.CameraPipeline', CommandPipeline):
pipeline_process_main({'stream_id': 1, 'rtsp_url': 'test'},
queue.Queue(), commands, {})
def wait_response(responses, entered):
entered.set()
responses.get(timeout=60)
class AnalysisRuntimeTests(unittest.TestCase):
def make_router(self):
manager = object.__new__(AnalysisManager)
manager._infer_routes = {}
manager._infer_routes_lock = threading.Lock()
return manager
def test_stop_and_reload_commands_survive_startup(self):
ctx = mp.get_context('spawn')
commands = ctx.Queue()
commands.put({'cmd': 'reload_zones', 'zones': [{'id': 12}]})
commands.put({'cmd': 'stop'})
proc = ctx.Process(target=command_process, args=(commands,))
proc.start()
try:
proc.join(10)
self.assertEqual(proc.exitcode, 0)
finally:
if proc.is_alive():
proc.terminate()
proc.join(2)
commands.cancel_join_thread()
commands.close()
def test_restart_uses_new_channel_after_consumer_is_killed(self):
ctx = mp.get_context('spawn')
router = self.make_router()
old, fresh, other = ctx.Queue(), ctx.Queue(), ctx.Queue()
router._infer_routes.update(old=old, fresh=fresh, other=other)
entered = ctx.Event()
proc = ctx.Process(target=wait_response, args=(old, entered))
proc.start()
try:
self.assertTrue(entered.wait(10))
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
if not old._rlock.acquire(False):
break
old._rlock.release()
time.sleep(0.01)
else:
self.fail('consumer did not acquire response queue lock')
proc.terminate()
proc.join(3)
old.put({'req_id': 'unreadable'})
with self.assertRaises(queue.Empty):
old.get(timeout=0.1)
router._close_inference_channel({'response_channel': 'old'})
router._send_inference_response('old', {'req_id': 'late'})
router._send_inference_response('fresh', {'req_id': 'new'})
router._send_inference_response('other', {'req_id': 'camera-2'})
self.assertEqual(fresh.get(timeout=2)['req_id'], 'new')
self.assertEqual(other.get(timeout=2)['req_id'], 'camera-2')
finally:
if proc.is_alive():
proc.terminate()
proc.join(2)
for key in list(router._infer_routes):
router._close_inference_channel({'response_channel': key})
def test_remote_timeout_is_not_an_empty_success(self):
detector = RemoteDetector({'id': 5}, queue.Queue(), queue.Queue(), timeout=0.03)
with self.assertRaises(TimeoutError):
detector.detect(np.zeros((32, 32, 3), dtype=np.uint8))
def test_downscaled_inference_coordinates_are_restored(self):
restored = RemoteDetector._restore_coordinates([{
'box': [10, 20, 100, 200],
'keypoints': [[15, 25, .9]],
'polygon': [[10, 20], [100, 200]],
}], 2.0, 2.0)[0]
self.assertEqual(restored['box'], [20.0, 40.0, 200.0, 400.0])
self.assertEqual(restored['keypoints'][0][:2], [30.0, 50.0])
self.assertEqual(restored['polygon'][1], [200.0, 400.0])
def test_tracker_is_one_to_one_and_confirms_medium_score_twice(self):
tracker = IoUTracker()
detections = [
{'label': 'person', 'score': .45, 'box': [0, 0, 20, 40]},
{'label': 'person', 'score': .8, 'box': [30, 0, 50, 40]},
]
first, _, _, _ = tracker.update(detections, 1, timestamp=1.0)
self.assertEqual(len({t['track_id'] for t in first}), 2)
self.assertFalse(next(t for t in first if t['score'] == .45)['confirmed'])
second, _, new_ids, _ = tracker.update([
{'label': 'person', 'score': .5, 'box': [2, 0, 22, 40]},
{'label': 'person', 'score': .82, 'box': [28, 0, 48, 40]},
], 2, timestamp=4.0)
self.assertEqual(new_ids, [])
self.assertEqual(len({t['track_id'] for t in second}), 2)
self.assertTrue(all(t['confirmed'] for t in second))
def test_low_fps_track_survives_short_miss(self):
tracker = IoUTracker()
active, _, _, _ = tracker.update([
{'label': 'person', 'score': .8, 'box': [0, 0, 20, 40]},
], 1, timestamp=1.0)
tid = active[0]['track_id']
tracker.update([], 2, timestamp=4.0)
active, ended, new_ids, _ = tracker.update([
{'label': 'person', 'score': .8, 'box': [2, 0, 22, 40]},
], 3, timestamp=7.0)
self.assertNotIn(tid, ended)
self.assertEqual(new_ids, [])
self.assertEqual(active[0]['track_id'], tid)
def test_preview_uses_capture_timestamp_and_normalized_velocity(self):
snapshots = []
pipe = CameraPipeline(1, 'test', 'test', on_preview=snapshots.append)
pipe._last_w, pipe._last_h = 100, 200
pipe._frame_index = 2
pipe._tracker.update([
{'label': 'person', 'score': .8, 'box': [10, 20, 30, 80]},
], 1, timestamp=10.0)
pipe._tracker.update([
{'label': 'person', 'score': .8, 'box': [20, 30, 40, 90]},
], 2, timestamp=11.0)
pipe._publish_preview(11.0, processed_ts=11.25)
snapshot = snapshots[-1]
self.assertEqual(snapshot['timestamp'], 11.0)
self.assertEqual(snapshot['processed_timestamp'], 11.25)
self.assertAlmostEqual(snapshot['inference_latency'], .25)
self.assertEqual(snapshot['tracks'][0]['box'], [.2, .15, .4, .45])
self.assertEqual(snapshot['tracks'][0]['velocity'], [.05, .025, .05, .025])
def test_formal_preview_temporarily_boosts_and_restores_analysis_fps(self):
class Handle:
def __init__(self): self.calls = []
def set_analyze_fps(self, fps): self.calls.append(float(fps))
class Process:
@staticmethod
def is_alive(): return True
handle = Handle()
manager = object.__new__(AnalysisManager)
manager._lock = threading.RLock()
manager._pipelines = {87: {
'running': True, 'mode': 'process', 'process': Process(),
'handle': handle, 'preview_only': False, 'analyze_fps': .67,
}}
zone = SimpleNamespace(stream=SimpleNamespace(id=87))
with patch('monitor_runtime.licensing.require_license'):
self.assertEqual(manager.start_preview(zone), (True, 'formal'))
self.assertEqual(handle.calls, [5.0])
self.assertEqual(manager._pipelines[87]['preview_restore_fps'], .67)
self.assertFalse(manager.stop_preview(87))
self.assertEqual(handle.calls, [5.0, .67])
self.assertNotIn('preview_restore_fps', manager._pipelines[87])
def test_medium_score_confirmation_requires_consecutive_hits(self):
tracker = IoUTracker()
tracker.update([{'label': 'person', 'score': .4, 'box': [0, 0, 20, 40]}], 1, timestamp=1)
tracker.update([], 2, timestamp=2)
active, _, _, _ = tracker.update([
{'label': 'person', 'score': .5, 'box': [1, 0, 21, 40]},
], 3, timestamp=3)
self.assertFalse(active[0]['confirmed'])
def test_person_foot_point_and_two_outside_hits(self):
events = []
zone = {'id': 12, 'name': 'office', 'coords': [[0, .8], [1, .8], [1, 1], [0, 1]],
'alarm_repeat_sec': 30, 'biz_algorithms': [{
'id': 1, 'name': 'person intrusion', 'flow_type': 1,
'small_model_id': 5, 'target_labels': ['person'], 'post_process': 'AREA'}]}
pipe = CameraPipeline(1, 'test', 'test', zone_polygons=[zone], on_event=events.append)
pipe._last_w, pipe._last_h = 100, 100
inside = {'track_id': 7, 'label': 'person', 'score': .8, 'box': [40, 40, 60, 90],
'algorithm_id': 5, 'confirmed': True, 'observed': True}
with patch.object(pipe, '_emit_biz_alarm', return_value=True) as alarm:
pipe._check_zones([inside], 1, np.zeros((100, 100, 3), dtype=np.uint8))
self.assertTrue(alarm.called) # 中心 y=65 在外,脚点 y=87.5 在区域内
outside = dict(inside, box=[40, 20, 60, 50])
pipe._check_zones([outside], 2, np.zeros((100, 100, 3), dtype=np.uint8))
self.assertEqual(pipe._last_zone_state[7], {12})
pipe._check_zones([outside], 3, np.zeros((100, 100, 3), dtype=np.uint8))
self.assertEqual(pipe._last_zone_state[7], set())
def test_unobserved_retained_track_does_not_leave_or_reenter(self):
pipe = CameraPipeline(1, 'test', 'test')
active, _, _, _ = pipe._tracker.update([
{'label': 'person', 'score': .8, 'box': [10, 10, 20, 30]},
], 1, timestamp=time.time())
tid = active[0]['track_id']
pipe._last_zone_state = {tid: {12}}
pipe._track_enter_ts = {(tid, 12): 10.0}
pipe._check_zones([], 2, np.zeros((20, 20, 3), dtype=np.uint8))
self.assertEqual(pipe._last_zone_state[tid], {12})
self.assertEqual(pipe._track_enter_ts[(tid, 12)], 10.0)
def test_area_alarm_repeat_is_independent_from_detection_rate(self):
pipe = CameraPipeline(1, 'test', 'test')
rule = {'id': 1, 'post_process': 'AREA', 'target_labels': ['person'],
'flow_type': 1, 'small_model_id': 5}
zone = {'id': 12, 'detect_interval_sec': 1, 'alarm_repeat_sec': 30,
'biz_algorithms': [rule]}
track = {'track_id': 7, 'label': 'person', 'score': .8, 'algorithm_id': 5,
'box': [0, 0, 10, 20]}
with patch.object(pipe, '_emit_biz_alarm', return_value=True) as alarm:
pipe._fire_area_alarms(track, 12, zone, set(), None, track['box'], 100.0, 7)
pipe._fire_area_alarms(track, 12, zone, {12}, None, track['box'], 101.0, 7)
pipe._fire_area_alarms(track, 12, zone, {12}, None, track['box'], 130.0, 7)
self.assertEqual(alarm.call_count, 2)
def test_preview_session_owner_and_last_viewer_cleanup(self):
class Related(list):
def filter(self, **_kwargs): return self
def select_related(self, *_args): return self
class Model:
id, name, state = 5, 'YOLO26x', 1
class Rule:
flow_type, detector_model, small_model = 1, None, Model()
target_labels = '["person"]'
class Stream:
id, app, name = 87, 'live', 'camera01'
class Zone:
id, stream_id, stream, name, color = 12, 87, Stream(), 'office', '#169F85'
coordinates = '[[0,0],[1,0],[1,1]]'
algorithms = Related([Rule()])
registry = PreviewSessionRegistry()
with patch('app.analysis.preview_sessions.AnalysisManager') as manager_cls:
manager = manager_cls.return_value
manager.start_preview.return_value = (True, 'preview')
manager.preview_mode.return_value = 'preview'
first = registry.start('owner-a', Zone())
second = registry.start('owner-b', Zone())
with self.assertRaises(PermissionError):
registry.data('owner-b', first['session_id'])
self.assertTrue(registry.stop('owner-a', first['session_id']))
manager.stop_preview.assert_not_called()
self.assertTrue(registry.stop('owner-b', second['session_id']))
manager.stop_preview.assert_called_once_with(87)
registry._running = False
def test_worker_errors_are_preserved(self):
requests, responses = queue.Queue(), queue.Queue()
detector = RemoteDetector({'id': 5}, requests, responses, timeout=2,
response_channel='camera-1')
def reply():
msg = requests.get(timeout=2)
self.assertEqual(msg['response_channel'], 'camera-1')
responses.put({'req_id': msg['req_id'], 'ok': False, 'error': 'engine load failed'})
worker = threading.Thread(target=reply)
worker.start()
try:
with self.assertRaisesRegex(RuntimeError, 'engine load failed'):
detector.detect(np.zeros((32, 32, 3), dtype=np.uint8))
finally:
worker.join(3)
def test_failed_frame_is_not_counted_and_success_recovers(self):
pipe = CameraPipeline(1, 'test', 'test')
frame = np.zeros((32, 32, 3), dtype=np.uint8)
def process(_):
pipe.stop()
raise TimeoutError('response timeout')
with patch.object(pipe, '_decode_loop'), \
patch.object(pipe, '_pop_latest_frame', return_value=frame), \
patch.object(pipe, '_process_frame', side_effect=process):
pipe.run()
self.assertEqual(pipe.status()['analyzed_count'], 0)
self.assertEqual(pipe.status()['analysis_health'], 'error')
pipe._last_analyze_ts = 0
with patch.object(pipe, '_decode_loop'), \
patch.object(pipe, '_pop_latest_frame', return_value=frame), \
patch.object(pipe, '_process_frame', side_effect=lambda _: pipe.stop()):
pipe.run()
self.assertEqual(pipe.status()['analyzed_count'], 1)
self.assertEqual(pipe.status()['analysis_health'], 'running')
self.assertEqual(pipe.status()['analysis_error'], '')
def test_low_frequency_success_keeps_nonzero_fps(self):
pipe = CameraPipeline(1, 'test', 'test', analyze_fps=1 / 60)
pipe._last_analysis_ts = time.time()
pipe._analysis_fps = 1 / 60
self.assertEqual(pipe.status()['analysis_health'], 'running')
self.assertGreater(pipe.status()['analysis_fps'], 0)
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,46 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const template = fs.readFileSync(path.join(__dirname, '../templates/app/control/index.html'), 'utf8');
assert.match(template, /openYoloPreview\('\+z\.id\+'\)/);
assert.match(template, /id="yoloPreviewCanvas"/);
assert.match(template, /openPreviewStart/);
assert.match(template, /openPreviewData/);
assert.match(template, /openPreviewStop/);
assert.match(template, /ResizeObserver/);
assert.match(template, /requestAnimationFrame\(animateYoloOverlay\)/);
const start = template.indexOf('function computeContainRect(');
const end = template.indexOf('function resizeYoloCanvas(');
const context = {};
vm.createContext(context);
vm.runInContext(template.slice(start, end), context);
const wide = context.computeContainRect(1000, 1000, 16, 9);
assert.equal(wide.width, 1000);
assert.equal(Math.round(wide.height), 563);
assert.equal(Math.round(wide.y), 219);
const tall = context.computeContainRect(1000, 500, 4, 3);
assert.equal(Math.round(tall.height), 500);
assert.equal(Math.round(tall.width), 667);
assert.equal(Math.round(tall.x), 167);
const projectStart = template.indexOf('function projectTrackBox(');
const projectEnd = template.indexOf('function smoothTrackBox(');
vm.runInContext(template.slice(projectStart, projectEnd), context);
const moving = context.projectTrackBox({
box: [.1, .2, .2, .4], velocity: [.1, 0, .1, 0], observed: true,
}, 10, 10.5);
assert.ok(Math.abs(moving[0] - .15) < 1e-9);
assert.ok(Math.abs(moving[2] - .25) < 1e-9);
const runaway = context.projectTrackBox({
box: [.1, .2, .2, .4], velocity: [10, 10, 10, 10], observed: true,
}, 10, 20);
assert.ok(runaway.every(v => v >= 0 && v <= 1));
assert.ok(runaway[0] < .3, 'prediction displacement must be capped');
const retained = context.projectTrackBox({
box: [.1, .2, .2, .4], velocity: [1, 1, 1, 1], observed: false,
}, 10, 11);
assert.deepEqual(Array.from(retained), [.1, .2, .2, .4]);
console.log('PASS: preview lifecycle, contain coordinates and motion projection');

View File

@ -0,0 +1,31 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const template = fs.readFileSync(path.join(__dirname, '../templates/app/control/index.html'), 'utf8');
const code = template.slice(template.indexOf('function buildZoneStateTag('),
template.indexOf('function buildZoneRunBtn('));
let info, active = true;
const context = {
zoneIsActiveInPipeline: () => active,
getStreamStatusInfo: () => info,
getStreamHealth: () => info.stream_health,
streamHealthLabel: health => health,
_t: (_, fallback) => fallback,
};
vm.createContext(context);
vm.runInContext(code, context);
const tag = () => context.buildZoneStateTag({stream_id: 87});
info = {running: true, stream_health: 'ok', analysis_health: 'starting', analysis_fps: 0};
assert.match(tag(), /启动中/);
info.analysis_health = 'error';
assert.match(tag(), /推理异常/);
info.analysis_health = 'running';
info.analysis_fps = 0.017;
assert.match(tag(), /分析中/);
info.running = false;
info.stream_health = 'disconnected';
assert.match(tag(), /disconnected/);
active = false;
assert.match(tag(), /已停止/);
console.log('PASS: starting, inference error, low-FPS success, disconnected, stopped');

View File

@ -0,0 +1,51 @@
import base64
import copy
import unittest
from datetime import datetime, timezone
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from monitor_runtime.licensing import canonical, verify_document, LicenseError
class OfflineLicenseTests(unittest.TestCase):
def setUp(self):
self.key = Ed25519PrivateKey.generate()
self.public = self.key.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo)
self.now = datetime(2026,8,30,tzinfo=timezone.utc).timestamp()
self.payload = dict(schema_version=1,product='monitor',license_id='test-id',customer='test',
machine_fingerprint='v1:'+'a'*64,issued_at='2026-08-29T00:00:00Z',expires_at=None)
def sign(self, payload=None):
payload = payload or self.payload
return {'payload':payload,'signature':base64.b64encode(self.key.sign(canonical(payload))).decode()}
def verify(self, document, **kwargs):
return verify_document(document,kwargs.get('public',self.public),
kwargs.get('fingerprint',self.payload['machine_fingerprint']),kwargs.get('now',self.now))
def test_perpetual_and_dated(self):
self.assertEqual(self.verify(self.sign())['customer'],'test')
self.payload['expires_at']='2026-08-31T00:00:00Z'
self.verify(self.sign())
def test_expiry_boundary_and_future_issue(self):
self.payload['expires_at']='2026-08-30T00:00:00Z'
with self.assertRaises(LicenseError):self.verify(self.sign())
self.payload['expires_at']=None
self.payload['issued_at']='2026-09-01T00:00:00Z'
with self.assertRaises(LicenseError):self.verify(self.sign())
def test_machine_signature_and_key_mismatch(self):
doc=self.sign()
with self.assertRaises(LicenseError):self.verify(doc,fingerprint='another')
bad=copy.deepcopy(doc);bad['payload']['customer']='tampered'
with self.assertRaises(LicenseError):self.verify(bad)
other=Ed25519PrivateKey.generate().public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo)
with self.assertRaises(LicenseError):self.verify(doc,public=other)
def test_product_version_structure_and_timezone(self):
for field,value in [('product','other'),('schema_version',2),('issued_at','2026-08-29'),('customer','')]:
with self.subTest(field=field):
payload=dict(self.payload);payload[field]=value
with self.assertRaises(LicenseError):self.verify(self.sign(payload))
bad=self.sign();bad['unexpected']=1
with self.assertRaises(LicenseError):self.verify(bad)

View File

@ -0,0 +1,74 @@
"""Camera 01 录像:直接 YOLO 与共享实时缩图管线的一致性抽样验证。
手动运行python tests/validate_yolo_replay.py <video> <weights> [sample_count] [device]
"""
import sys
import cv2
from ultralytics import YOLO
def iou(a, b):
x1, y1, x2, y2 = max(a[0], b[0]), max(a[1], b[1]), min(a[2], b[2]), min(a[3], b[3])
inter = max(0, x2 - x1) * max(0, y2 - y1)
union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
return inter / union if union > 0 else 0
def boxes(result):
if result.boxes is None:
return []
return [list(map(float, row)) for row in result.boxes.xyxy.cpu().numpy()]
def main(video_path, weights_path, sample_count=5, device="cpu"):
model = YOLO(weights_path)
names = model.names if isinstance(model.names, dict) else dict(enumerate(model.names))
person_ids = [cid for cid, name in names.items() if name == "person"]
cap = cv2.VideoCapture(video_path)
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
indices = [int((i + 1) * total / (sample_count + 1)) for i in range(sample_count)]
direct_count = matched_count = 0
matched_ious = []
for index in indices:
cap.set(cv2.CAP_PROP_POS_FRAMES, index)
ok, frame = cap.read()
if not ok:
continue
h, w = frame.shape[:2]
precision = 16 if str(device).lower() != "cpu" else None
direct = boxes(model.predict(frame, imgsz=640, conf=.3, iou=.5,
classes=person_ids, device=device,
quantize=precision, verbose=False)[0])
scale = min(1.0, 2560.0 / max(w, h))
small = (frame if scale == 1.0 else
cv2.resize(frame, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA))
ok, encoded = cv2.imencode(".jpg", small, [cv2.IMWRITE_JPEG_QUALITY, 95])
realtime = boxes(model.predict(cv2.imdecode(encoded, cv2.IMREAD_COLOR), imgsz=640,
conf=.3, iou=.5, classes=person_ids,
device=device, quantize=precision,
verbose=False)[0]) if ok else []
realtime = [[v / scale for v in box] for box in realtime]
unused = set(range(len(realtime)))
for box in direct:
direct_count += 1
candidates = [(iou(box, realtime[j]), j) for j in unused]
best = max(candidates, default=(0, None))
if best[0] >= .8:
matched_count += 1
matched_ious.append(best[0])
unused.remove(best[1])
print("frame=%d direct_person=%d realtime_person=%d" % (index, len(direct), len(realtime)))
cap.release()
recall = matched_count / direct_count if direct_count else 1.0
mean_iou = sum(matched_ious) / len(matched_ious) if matched_ious else 0.0
print("matched=%d/%d recall=%.3f mean_iou=%.3f" %
(matched_count, direct_count, recall, mean_iou))
return 0 if recall >= .95 and (not matched_ious or min(matched_ious) >= .8) else 1
if __name__ == "__main__":
if len(sys.argv) < 3:
raise SystemExit("usage: validate_yolo_replay.py VIDEO WEIGHTS [SAMPLES] [DEVICE]")
raise SystemExit(main(sys.argv[1], sys.argv[2], int(sys.argv[3]) if len(sys.argv) > 3 else 5,
sys.argv[4] if len(sys.argv) > 4 else "cpu"))

View File

@ -0,0 +1,103 @@
"""Run separately: all database, keys and configuration live in a temporary directory."""
import base64
import json
import os
from pathlib import Path
import sys
import tempfile
import time
from unittest.mock import patch
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT))
def main():
with tempfile.TemporaryDirectory(prefix='monitor-部署-test-') as tmp:
os.environ.update(MONITOR_DESKTOP='1',MONITOR_DATA_DIR=tmp,
DJANGO_SETTINGS_MODULE='framework.settings',
MONITOR_DEV_PUBLIC_KEY=str(Path(tmp)/'public.pem'))
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
key=Ed25519PrivateKey.generate()
Path(tmp,'public.pem').write_bytes(key.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo))
from monitor_runtime.bootstrap import prepare_files,initialize_database
prepare_files()
initialize_database()
from django.test import Client
from django.contrib.auth import get_user_model
from monitor_runtime import web, licensing
from monitor_runtime.paths import data,atomic_json
from django.core.files.uploadedfile import SimpleUploadedFile
fingerprint='v1:'+'b'*64
checks=[]
with patch.object(licensing,'machine_identity',return_value={'fingerprint':fingerprint,'weak_uuid':False}):
client=Client(enforce_csrf_checks=True,HTTP_HOST='localhost')
local={'REMOTE_ADDR':'127.0.0.1'}
remote={'REMOTE_ADDR':'192.168.1.5'}
assert client.get('/setup',**remote).status_code==403
assert client.get('/setup',**local).status_code==200
csrf=client.cookies['csrftoken'].value
assert client.post('/license/request',{'setup_token':web.SETUP_TOKEN},**local).status_code==403
assert client.post('/license/request',{'setup_token':'wrong'},HTTP_X_CSRFTOKEN=csrf,**local).status_code==403
result=client.post('/license/request',{'setup_token':web.SETUP_TOKEN},HTTP_X_CSRFTOKEN=csrf,**local)
assert result.status_code==200 and result.json()['fingerprint']==fingerprint
checks.append('local-only bootstrap, one-time token and CSRF')
from datetime import datetime,timezone,timedelta
payload={'schema_version':1,'product':'monitor','license_id':'integration','customer':'integration-test',
'machine_fingerprint':fingerprint,'issued_at':datetime.now(timezone.utc).isoformat(),'expires_at':None}
document={'payload':payload,'signature':base64.b64encode(key.sign(licensing.canonical(payload))).decode()}
uploaded=SimpleUploadedFile('license.json',json.dumps(document).encode(),content_type='application/json')
result=client.post('/license/import',{'setup_token':web.SETUP_TOKEN,'license':uploaded},HTTP_X_CSRFTOKEN=csrf,**local)
assert result.status_code==200,result.content[:200]
assert licensing.check_license()['valid']
checks.append('signed import and DPAPI clock persistence')
with patch('monitor_runtime.bootstrap.local_addresses',return_value=['192.168.1.10']):
result=client.post('/setup',{'setup_token':web.SETUP_TOKEN,'username':'owner',
'password':'Different!7826Secure','confirm':'Different!7826Secure','address':'192.168.1.10'},
HTTP_X_CSRFTOKEN=csrf,**local)
assert result.status_code==200,result.content[:200]
assert get_user_model().objects.filter(is_superuser=True).count()==1
assert client.get('/setup',**local).status_code==403
assert client.post('/license/request',{'setup_token':web.SETUP_TOKEN},HTTP_X_CSRFTOKEN=csrf,**local).status_code==403
checks.append('first administrator and permanently closed setup')
snapshot=data('static/storage/alarm/test.jpg');snapshot.parent.mkdir(parents=True,exist_ok=True);snapshot.write_bytes(b'image')
assert client.get('/static/storage/alarm/test.jpg',**local).status_code==403
client.force_login(get_user_model().objects.get(username='owner'))
response=client.get('/static/storage/alarm/test.jpg',**local)
assert response.status_code==200
response.close()
assert client.get('/static/storage/../../.runtime-secrets.json',**local).status_code in (403,404)
assert client.get('/license/status',**local).status_code==200
checks.append('authenticated private files and administrator license status')
old_secret=data('.runtime-secrets.json').read_bytes()
initialize_database()
assert data('.runtime-secrets.json').read_bytes()==old_secret
assert get_user_model().objects.filter(username='owner').count()==1
checks.append('idempotent database initialization preserves users and encryption key')
payload['expires_at']=(datetime.now(timezone.utc)-timedelta(seconds=1)).isoformat()
document={'payload':payload,'signature':base64.b64encode(key.sign(licensing.canonical(payload))).decode()}
atomic_json(data('license.json'),document)
assert not licensing.check_license()['valid']
assert client.get('/analysis/openStatus',**local).status_code==403
assert client.get('/license/status',**local).status_code==200
checks.append('expiry blocks APIs and leaves renewal available')
payload['expires_at']=None
document={'payload':payload,'signature':base64.b64encode(key.sign(licensing.canonical(payload))).decode()}
licensing.import_license(json.dumps(document).encode())
with patch.object(licensing.time,'time',return_value=time.time()-1000):
assert not licensing.check_license()['valid']
checks.append('renewal and clock rollback detection')
get_user_model().objects.all().delete()
assert client.get('/setup',**local).status_code==403
checks.append('initialization stays closed even after administrator deletion')
from django.db import connections
connections.close_all()
import logging
logging.shutdown()
for handler in list(logging.getLogger().handlers):
logging.getLogger().removeHandler(handler)
handler.close()
print(json.dumps({'passed':checks},ensure_ascii=False,indent=2))
if __name__=='__main__':
main()

192
tests/windows_lifecycle.py Normal file
View File

@ -0,0 +1,192 @@
"""Real Windows worker lifecycle validation. Requires a vendor DPAPI key; never shipped."""
import argparse
import base64
import configparser
from datetime import datetime, timezone, timedelta
import json
import os
from pathlib import Path
import secrets
import socket
import subprocess
import sys
import threading
import time
from urllib.parse import urlsplit
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT))
def main():
parser=argparse.ArgumentParser()
parser.add_argument('--executable',type=Path,required=True)
parser.add_argument('--resources',type=Path,required=True)
parser.add_argument('--private-key',type=Path,required=True)
parser.add_argument('--data',type=Path,required=True)
args=parser.parse_args()
target=args.data.resolve()
if target.exists():
raise RuntimeError('Use a fresh test data directory')
target.mkdir(parents=True)
os.environ['MONITOR_DATA_DIR']=str(target)
os.environ['MONITOR_DESKTOP']='1'
import psutil
import requests
from multiprocessing.connection import Listener
from cryptography.hazmat.primitives import serialization
from monitor_runtime.licensing import canonical
from monitor_runtime.windows import protect,Job
from monitor_runtime.bootstrap import local_addresses
cfg=json.loads((args.resources/'deploy/windows/default-config.json').read_text(encoding='utf-8'))
def free_port():
with socket.socket() as sock:
sock.bind(('127.0.0.1',0))
return sock.getsockname()[1]
ports=[free_port() for _ in range(5)]
cfg.update(adminPort=ports[0],mediaHttpPort=ports[1],mediaRtspPort=ports[2],mediaRtmpPort=ports[3],
isEnableLoginCaptcha=False,autoAddStreamProxy=False)
cfg['sipServer']['sipServerPort']=ports[4]
(target/'config.json').write_text(json.dumps(cfg),encoding='utf-8')
ini=configparser.ConfigParser(interpolation=None)
ini.optionxform=str
ini.read(args.resources/'deploy/windows/zlm-template.ini',encoding='utf-8')
for section,port in [('http',ports[1]),('rtsp',ports[2]),('rtmp',ports[3]),('rtp_proxy',0)]:
ini[section]['port']=str(port)
with (target/'zlm-template.ini').open('w',encoding='utf-8') as stream:ini.write(stream)
raw=args.private_key.read_bytes()
assert raw.startswith(b'MONITOR-DPAPI-V1\n')
key=serialization.load_pem_private_key(protect(base64.b64decode(raw.split(b'\n',1)[1]),decrypt=True),None)
command=[str(args.executable.resolve())]
if args.executable.suffix=='.py':command=[sys.executable]+command
env=os.environ.copy()
env['MONITOR_SERVICE_MODE']='disabled'
if args.executable.suffix.lower()=='.exe':
env.pop('PYTHONPATH',None);env.pop('PYTHONHOME',None)
windows=env['SYSTEMROOT']
env['PATH']=windows+'/System32;'+windows+';'+windows+'/System32/WindowsPowerShell/v1.0'
session=requests.Session()
session.trust_env=False
base='http://127.0.0.1:'+str(ports[0])
checks=[]
def start():
auth=secrets.token_bytes(32)
address=r'\\.\pipe\MonitorValidation-'+secrets.token_hex(12)
listener=Listener(address,family='AF_PIPE',authkey=auth)
job=Job()
log=(target/'worker.log').open('ab',buffering=0)
child=subprocess.Popen(command+['--worker',address,auth.hex()],env=env,cwd=target,
stdout=log,stderr=log,creationflags=subprocess.CREATE_NO_WINDOW)
log.close()
job.assign(child)
accepted=[]
threading.Thread(target=lambda:accepted.append(listener.accept()),daemon=True).start()
deadline=time.monotonic()+60
while not accepted:
if child.poll() is not None or time.monotonic()>deadline:
job.close();listener.close()
raise RuntimeError('Worker failed before pipe connection')
time.sleep(.1)
conn=accepted[0];listener.close();conn.send('run')
if not conn.poll(180):
job.close();raise RuntimeError('Worker startup timeout')
message=conn.recv()
return child,conn,job,message
def finish(child,conn,job,expected=0,stop=True):
descendants=psutil.Process(child.pid).children(recursive=True) if child.poll() is None else []
if stop:conn.send('stop')
try:
code=child.wait(timeout=40)
assert code==expected,(code,expected)
finally:
conn.close();job.close()
gone,alive=psutil.wait_procs(descendants,timeout=10)
assert not alive,[p.pid for p in alive]
child=conn=job=None
try:
child,conn,job,message=start()
assert message['phase']=='setup',message
assert session.get(base+'/setup',timeout=10).status_code==200
token=urlsplit(message['url']).fragment
csrf=session.cookies['csrftoken']
session.headers['X-CSRFToken']=csrf
assert session.post(base+'/license/request',data={'setup_token':'wrong'},timeout=10).status_code==403
request=session.post(base+'/license/request',data={'setup_token':token},timeout=30).json()
payload={'schema_version':1,'product':'monitor','license_id':'lifecycle-validation',
'customer':'local build validation only','machine_fingerprint':request['fingerprint'],
'issued_at':datetime.now(timezone.utc).isoformat(),
'expires_at':(datetime.now(timezone.utc)+timedelta(hours=2)).isoformat()}
doc={'payload':payload,'signature':base64.b64encode(key.sign(canonical(payload))).decode()}
response=session.post(base+'/license/import',data={'setup_token':token},
files={'license':('test.json',json.dumps(doc),'application/json')},timeout=15)
assert response.status_code==200,response.text[:200]
address=local_addresses()[0]
response=session.post(base+'/setup',data={'setup_token':token,'username':'validation-admin',
'password':'Test-setup!'+secrets.token_hex(12),'address':address,'confirm':'unused'},timeout=10)
assert response.status_code==400
password='Test-setup!'+secrets.token_hex(12)
response=session.post(base+'/setup',data={'setup_token':token,'username':'validation-admin',
'password':password,'confirm':password,'address':address},timeout=10)
assert response.status_code==200,response.text[:200]
finish(child,conn,job,expected=20,stop=False);child=conn=job=None
checks.append('real HTTP bootstrap, CSRF, signed license, fresh admin and restart request')
child,conn,job,message=start()
assert message['phase']=='ready',message
assert (target/'log/zlm').is_dir()
assert not (args.resources/'zlm/log').exists(), 'ZLM wrote into program resources'
assert session.get(base+'/login',timeout=15).status_code==200
assert session.get(base+'/static/lib/js/app.js',timeout=15).status_code==200
assert session.get(base+'/setup',timeout=15).status_code==403
assert session.get(base+'/static/storage/private.jpg',timeout=15).status_code==403
with socket.create_connection(('127.0.0.1',ports[1]),timeout=5):pass
listeners=psutil.net_connections(kind='udp')
owned={child.pid}|{p.pid for p in psutil.Process(child.pid).children(recursive=True)}
assert any(c.pid in owned and c.laddr.port==ports[4] for c in listeners)
children=psutil.Process(child.pid).children(recursive=True)
assert any(p.name().lower()=='monitor_zlm.exe' for p in children)
assert not any('spawn_main' in ' '.join(p.cmdline()) for p in children)
response=session.post(base+'/login',data={'username':'validation-admin','password':password},timeout=15)
assert response.status_code==200,response.text[:200]
assert session.get(base+'/license/status',timeout=15).status_code==200,response.text[:200]
checks.append('Waitress login/static; ZLM and SIP ready; analysis not auto-started')
expiry_payload=dict(payload,expires_at=(datetime.now(timezone.utc)+timedelta(seconds=7)).isoformat())
expiry_doc={'payload':expiry_payload,'signature':base64.b64encode(key.sign(canonical(expiry_payload))).decode()}
(target/'license.json').write_text(json.dumps(expiry_doc),encoding='utf-8')
finish(child,conn,job,expected=20,stop=False);child=conn=job=None
child,conn,job,message=start()
assert message['phase']=='license',message
assert not any(p.name().lower()=='monitor_zlm.exe' for p in psutil.Process(child.pid).children(recursive=True))
assert session.get(base+'/license/status',timeout=10).json()['valid'] is False
session.headers['X-CSRFToken']=session.cookies['csrftoken']
response=session.post(base+'/license/import',files={'license':('renew.json',json.dumps(doc),'application/json')},timeout=15)
assert response.status_code==200,response.text[:200]
finish(child,conn,job,expected=20,stop=False);child=conn=job=None
child,conn,job,message=start()
assert message['phase']=='ready',message
checks.append('runtime expiry stops services; authenticated renewal restores service')
finish(child,conn,job);child=conn=job=None
checks.append('graceful shutdown and owned descendant cleanup')
with socket.socket() as occupied:
occupied.bind(('0.0.0.0',ports[0]));occupied.listen()
child,conn,job,message=start()
assert message['phase']=='failed',message
finish(child,conn,job,expected=1,stop=False);child=conn=job=None
assert occupied.getsockname()[1]==ports[0]
checks.append('port conflict fails without killing the listener')
child,conn,job,message=start()
assert message['phase']=='ready',message
children=psutil.Process(child.pid).children(recursive=True)
job.close();job=None
child.wait(timeout=15)
_,alive=psutil.wait_procs(children,timeout=10)
assert not alive
conn.close();child=conn=None
checks.append('Job Object kills owned children on forced shutdown')
(target/'validation.json').write_text(json.dumps({'status':'passed','checks':checks},indent=2),encoding='utf-8')
print(json.dumps(checks,indent=2))
finally:
if child and child.poll() is None:
if conn:
try:conn.send('stop');child.wait(timeout=30)
except (OSError,subprocess.TimeoutExpired):pass
if job:job.close()
if conn:conn.close()
if __name__=='__main__':main()

43
tests/windows_tray.py Normal file
View File

@ -0,0 +1,43 @@
"""Desktop supervisor smoke test; opens the normal local setup browser."""
import json,os,secrets,socket,subprocess,sys,time
from pathlib import Path
import psutil,requests
exe=Path(sys.argv[1]).resolve()
data=Path(sys.argv[2]).resolve()
data.mkdir(parents=True,exist_ok=False)
with socket.socket() as sock:
sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
(data/'config.json').write_text(json.dumps({'host':'127.0.0.1','adminPort':port}),encoding='utf-8')
env=os.environ.copy();env['MONITOR_DATA_DIR']=str(data)
env.pop('PYTHONPATH',None);env.pop('PYTHONHOME',None)
root=env['SYSTEMROOT'];env['PATH']=root+'/System32;'+root+';'+root+'/System32/WindowsPowerShell/v1.0'
client=requests.Session();client.trust_env=False
base='http://127.0.0.1:'+str(port)
child=subprocess.Popen([str(exe)],env=env,cwd=data,creationflags=subprocess.CREATE_NO_WINDOW)
try:
duplicate=subprocess.Popen([str(exe)],env=env,cwd=data,creationflags=subprocess.CREATE_NO_WINDOW)
assert duplicate.wait(timeout=35)==0
deadline=time.monotonic()+120
while time.monotonic()<deadline:
assert child.poll() is None,'Supervisor exited early'
try:
if client.get(base+'/setup',timeout=2).status_code==200:break
except requests.RequestException:pass
time.sleep(.5)
else:raise RuntimeError('Setup page not ready')
descendants=psutil.Process(child.pid).children(recursive=True)
workers=[p for p in descendants if p.name().lower()=='monitor.exe']
assert len(workers)==1,[(p.pid,p.name()) for p in descendants]
stop=subprocess.Popen([str(exe),'--stop'],env=env,cwd=data,creationflags=subprocess.CREATE_NO_WINDOW)
assert stop.wait(timeout=50)==0
assert child.wait(timeout=10)==0
_,alive=psutil.wait_procs(workers,timeout=10)
assert not alive
result={'status':'passed','single_instance':'passed (simultaneous double launch)',
'setup_http':'passed','tray_supervisor':'passed','stop_command':'passed',
'owned_workers_stopped':'passed','external_python_path':'removed'}
(data/'tray-validation.json').write_text(json.dumps(result,indent=2),encoding='utf-8')
print(json.dumps(result,indent=2))
finally:
if child.poll() is None:
child.kill();child.wait(timeout=15)

View File

@ -0,0 +1,957 @@
from __future__ import annotations
import math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from docx import Document
from docx.enum.section import WD_SECTION
from docx.enum.table import WD_ALIGN_VERTICAL, WD_CELL_VERTICAL_ALIGNMENT, WD_TABLE_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_LINE_SPACING
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Inches, Pt, RGBColor
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "系统部署性能分析评估报告.docx"
ASSET_DIR = ROOT / "report_assets"
CHART = ASSET_DIR / "capacity_requirement.png"
FONT_REGULAR = Path(r"C:\Windows\Fonts\msyh.ttc")
FONT_BOLD = Path(r"C:\Windows\Fonts\msyhbd.ttc")
INK = "1F2937"
MUTED = "5B6472"
BLUE = "1F4E78"
BLUE_LIGHT = "DCE6F1"
PALE_BLUE = "F3F7FB"
GOLD = "C58A19"
PALE_GOLD = "FFF5D6"
RED = "A33A3A"
PALE_RED = "FBEAEA"
GREEN = "2F6B57"
PALE_GREEN = "E8F2ED"
GRID = "D9D9D9"
WHITE = "FFFFFF"
def font(size: int, bold: bool = False):
path = FONT_BOLD if bold else FONT_REGULAR
return ImageFont.truetype(str(path), size=size)
def make_capacity_chart(path: Path):
"""Static report chart contract.
Question: What aggregate single-model throughput is required for 50 cameras?
Takeaway: Requirement scales from 50 infer/s at 1 fps to 250 infer/s at 5 fps;
the measured serial PyTorch reference is below even the 1 fps workload.
Family: comparison, horizontal bar. Palette: one blue root plus a gold reference.
"""
path.parent.mkdir(parents=True, exist_ok=True)
width, height = 1800, 980
img = Image.new("RGB", (width, height), "#FFFFFF")
d = ImageDraw.Draw(img)
title_f = font(48, True)
sub_f = font(27, False)
label_f = font(29, False)
value_f = font(30, True)
axis_f = font(23, False)
note_f = font(23, False)
d.text((90, 55), "五十路单模型推理吞吐需求", fill="#111827", font=title_f)
d.text((90, 125), "单位 推理次数每秒 公式 摄像头数乘每路分析帧率", fill="#5B6472", font=sub_f)
labels = [
"五十路各一帧每秒",
"四十路一帧加十路五帧",
"五十路各两帧每秒",
"五十路各五帧每秒",
]
values = [50, 90, 100, 250]
x0, x1 = 620, 1650
y0 = 250
row_h = 135
max_v = 260
for tick in [0, 50, 100, 150, 200, 250]:
x = x0 + (x1 - x0) * tick / max_v
d.line((x, y0 - 30, x, y0 + row_h * len(values) - 25), fill="#E5E7EB", width=2)
txt = str(tick)
bb = d.textbbox((0, 0), txt, font=axis_f)
d.text((x - (bb[2] - bb[0]) / 2, y0 - 70), txt, fill="#6B7280", font=axis_f)
for i, (lab, val) in enumerate(zip(labels, values)):
y = y0 + i * row_h
d.text((90, y + 20), lab, fill="#1F2937", font=label_f)
bar_end = x0 + (x1 - x0) * val / max_v
d.rounded_rectangle((x0, y + 10, bar_end, y + 72), radius=14, fill="#2F6EA3", outline="#1F4E78", width=2)
d.text((bar_end + 18, y + 20), str(val), fill="#1F2937", font=value_f)
ref = 20.4
rx = x0 + (x1 - x0) * ref / max_v
d.line((rx, y0 - 25, rx, y0 + row_h * len(values) - 20), fill="#C58A19", width=5)
d.text((rx + 12, y0 + row_h * len(values) - 8), "当前开发机串行参考 20.4", fill="#8A5E12", font=note_f)
d.text((90, 850), "说明 参考值来自 RTX 5060 Laptop GPU 上 YOLO26x 640 PyTorch FP16 的二十次本地单图测试", fill="#5B6472", font=note_f)
d.text((90, 892), "不含 RTSP 解码 进程间传输 JPEG 编解码和告警处理 因此只能视为吞吐上限参考", fill="#5B6472", font=note_f)
img.save(path, quality=95)
def set_cell_shading(cell, fill: str):
tc_pr = cell._tc.get_or_add_tcPr()
shd = tc_pr.find(qn("w:shd"))
if shd is None:
shd = OxmlElement("w:shd")
tc_pr.append(shd)
shd.set(qn("w:fill"), fill)
def set_cell_margins(cell, top=110, start=120, bottom=110, end=120):
tc = cell._tc
tc_pr = tc.get_or_add_tcPr()
tc_mar = tc_pr.first_child_found_in("w:tcMar")
if tc_mar is None:
tc_mar = OxmlElement("w:tcMar")
tc_pr.append(tc_mar)
for m, v in (("top", top), ("start", start), ("bottom", bottom), ("end", end)):
node = tc_mar.find(qn(f"w:{m}"))
if node is None:
node = OxmlElement(f"w:{m}")
tc_mar.append(node)
node.set(qn("w:w"), str(v))
node.set(qn("w:type"), "dxa")
def set_table_borders(table, color=GRID, size="6"):
tbl_pr = table._tbl.tblPr
borders = tbl_pr.first_child_found_in("w:tblBorders")
if borders is None:
borders = OxmlElement("w:tblBorders")
tbl_pr.append(borders)
for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
tag = qn(f"w:{edge}")
el = borders.find(tag)
if el is None:
el = OxmlElement(f"w:{edge}")
borders.append(el)
el.set(qn("w:val"), "single")
el.set(qn("w:sz"), size)
el.set(qn("w:space"), "0")
el.set(qn("w:color"), color)
def set_repeat_table_header(row):
tr_pr = row._tr.get_or_add_trPr()
tbl_header = OxmlElement("w:tblHeader")
tbl_header.set(qn("w:val"), "true")
tr_pr.append(tbl_header)
def prevent_row_split(row):
tr_pr = row._tr.get_or_add_trPr()
cant = OxmlElement("w:cantSplit")
tr_pr.append(cant)
def set_cell_width(cell, width_cm: float):
tc_pr = cell._tc.get_or_add_tcPr()
tc_w = tc_pr.find(qn("w:tcW"))
if tc_w is None:
tc_w = OxmlElement("w:tcW")
tc_pr.append(tc_w)
tc_w.set(qn("w:w"), str(int(Cm(width_cm).emu / 635)))
tc_w.set(qn("w:type"), "dxa")
def set_run_font(run, name="Microsoft YaHei", size=None, bold=None, color=None):
run.font.name = name
rpr = run._element.get_or_add_rPr()
rfonts = rpr.rFonts
if rfonts is None:
rfonts = OxmlElement("w:rFonts")
rpr.append(rfonts)
for attr in ("ascii", "hAnsi", "eastAsia", "cs"):
rfonts.set(qn(f"w:{attr}"), name)
if size is not None:
run.font.size = Pt(size)
if bold is not None:
run.bold = bold
if color is not None:
run.font.color.rgb = RGBColor.from_string(color)
def add_page_number(paragraph):
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = paragraph.add_run()
fld_char1 = OxmlElement("w:fldChar")
fld_char1.set(qn("w:fldCharType"), "begin")
instr_text = OxmlElement("w:instrText")
instr_text.set(qn("xml:space"), "preserve")
instr_text.text = " PAGE "
fld_char2 = OxmlElement("w:fldChar")
fld_char2.set(qn("w:fldCharType"), "end")
run._r.extend([fld_char1, instr_text, fld_char2])
set_run_font(run, size=9, color="7A7A7A")
def add_hyperlink(paragraph, text: str, url: str):
part = paragraph.part
rel_id = part.relate_to(url, "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", is_external=True)
hyperlink = OxmlElement("w:hyperlink")
hyperlink.set(qn("r:id"), rel_id)
new_run = OxmlElement("w:r")
r_pr = OxmlElement("w:rPr")
color = OxmlElement("w:color")
color.set(qn("w:val"), "1F4E78")
r_pr.append(color)
underline = OxmlElement("w:u")
underline.set(qn("w:val"), "single")
r_pr.append(underline)
r_fonts = OxmlElement("w:rFonts")
for attr in ("ascii", "hAnsi", "eastAsia"):
r_fonts.set(qn(f"w:{attr}"), "Microsoft YaHei")
r_pr.append(r_fonts)
new_run.append(r_pr)
t = OxmlElement("w:t")
t.text = text
new_run.append(t)
hyperlink.append(new_run)
paragraph._p.append(hyperlink)
def add_table(doc, headers, rows, widths=None, font_size=8.7, header_fill=BLUE):
table = doc.add_table(rows=1, cols=len(headers))
table.alignment = WD_TABLE_ALIGNMENT.CENTER
table.autofit = False
set_table_borders(table)
hdr = table.rows[0]
set_repeat_table_header(hdr)
prevent_row_split(hdr)
for i, text in enumerate(headers):
cell = hdr.cells[i]
set_cell_shading(cell, header_fill)
set_cell_margins(cell)
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
if widths:
set_cell_width(cell, widths[i])
p = cell.paragraphs[0]
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.space_after = Pt(0)
r = p.add_run(str(text))
set_run_font(r, size=font_size, bold=True, color=WHITE)
for ridx, row in enumerate(rows):
cells = table.add_row().cells
prevent_row_split(table.rows[-1])
for i, value in enumerate(row):
cell = cells[i]
set_cell_margins(cell)
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
if widths:
set_cell_width(cell, widths[i])
if ridx % 2 == 1:
set_cell_shading(cell, PALE_BLUE)
p = cell.paragraphs[0]
p.paragraph_format.space_after = Pt(0)
p.paragraph_format.line_spacing = 1.05
p.alignment = WD_ALIGN_PARAGRAPH.CENTER if i == 0 and len(str(value)) < 18 else WD_ALIGN_PARAGRAPH.LEFT
r = p.add_run(str(value))
set_run_font(r, size=font_size, color=INK)
doc.add_paragraph().paragraph_format.space_after = Pt(1)
return table
def add_caption(doc, text):
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.keep_with_next = True
p.paragraph_format.space_before = Pt(2)
p.paragraph_format.space_after = Pt(5)
r = p.add_run(text)
set_run_font(r, size=9, color=MUTED)
return p
def add_bullet(doc, text, level=0, lead=None):
p = doc.add_paragraph(style="List Bullet" if level == 0 else "List Bullet 2")
p.paragraph_format.space_after = Pt(3)
p.paragraph_format.left_indent = Cm(0.65 + 0.55 * level)
p.paragraph_format.first_line_indent = Cm(-0.35)
if lead and text.startswith(lead):
r1 = p.add_run(lead)
set_run_font(r1, bold=True, size=10.2, color=INK)
r2 = p.add_run(text[len(lead):])
set_run_font(r2, size=10.2, color=INK)
else:
r = p.add_run(text)
set_run_font(r, size=10.2, color=INK)
return p
def add_numbered(doc, text):
p = doc.add_paragraph(style="List Number")
p.paragraph_format.space_after = Pt(3)
r = p.add_run(text)
set_run_font(r, size=10.2, color=INK)
return p
def add_manual_numbered(doc, number, text):
"""Add a visually numbered paragraph without inheriting Word list state."""
p = doc.add_paragraph()
p.paragraph_format.space_after = Pt(3)
p.paragraph_format.left_indent = Cm(0.65)
p.paragraph_format.first_line_indent = Cm(-0.35)
r1 = p.add_run(f"{number}. ")
set_run_font(r1, size=10.2, bold=True, color=INK)
r2 = p.add_run(text)
set_run_font(r2, size=10.2, color=INK)
return p
def add_para(doc, text="", bold_lead=None, style=None, align=None):
p = doc.add_paragraph(style=style)
p.paragraph_format.space_after = Pt(6)
p.paragraph_format.line_spacing = 1.28
if align is not None:
p.alignment = align
if bold_lead and text.startswith(bold_lead):
a = p.add_run(bold_lead)
set_run_font(a, size=10.5, bold=True, color=INK)
b = p.add_run(text[len(bold_lead):])
set_run_font(b, size=10.5, color=INK)
else:
r = p.add_run(text)
set_run_font(r, size=10.5, color=INK)
return p
def add_heading(doc, text, level=1):
p = doc.add_paragraph(text, style=f"Heading {level}")
p.paragraph_format.keep_with_next = True
p.paragraph_format.keep_together = True
return p
def configure_styles(doc):
styles = doc.styles
normal = styles["Normal"]
normal.font.name = "Microsoft YaHei"
normal.font.size = Pt(10.5)
normal.font.color.rgb = RGBColor.from_string(INK)
normal._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
normal.paragraph_format.line_spacing = 1.28
normal.paragraph_format.space_after = Pt(6)
title = styles["Title"]
title.font.name = "Microsoft YaHei"
title._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
title.font.size = Pt(27)
title.font.bold = True
title.font.color.rgb = RGBColor(0, 0, 0)
title.paragraph_format.space_after = Pt(16)
# Remove the built-in Title style's decorative bottom border so the cover
# remains clean and does not render as an unintended horizontal rule.
ppr = title._element.get_or_add_pPr()
pbd = ppr.find(qn("w:pBdr"))
if pbd is not None:
ppr.remove(pbd)
for name, size, before, after in (("Heading 1", 17, 18, 9), ("Heading 2", 13, 13, 6), ("Heading 3", 11, 9, 4)):
st = styles[name]
st.font.name = "Microsoft YaHei"
st._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
st.font.size = Pt(size)
st.font.bold = True
st.font.color.rgb = RGBColor(0, 0, 0)
st.paragraph_format.space_before = Pt(before)
st.paragraph_format.space_after = Pt(after)
st.paragraph_format.keep_with_next = True
for name in ("List Bullet", "List Bullet 2", "List Number"):
st = styles[name]
st.font.name = "Microsoft YaHei"
st._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
st.font.size = Pt(10.2)
st.font.color.rgb = RGBColor.from_string(INK)
def set_core_properties(doc):
cp = doc.core_properties
cp.title = "视频监控平台五十路摄像头系统部署性能分析评估报告"
cp.subject = "区域检测 目标检测与告警场景部署容量和性能评估"
cp.author = "视频监控平台项目组"
cp.keywords = "视频监控, YOLO, 50路摄像头, GPU, 部署, 性能评估"
cp.comments = "基于代码审查 本地参考测试和公开产品资料形成"
def build_document():
make_capacity_chart(CHART)
doc = Document()
configure_styles(doc)
set_core_properties(doc)
sec = doc.sections[0]
sec.page_width = Cm(21.0)
sec.page_height = Cm(29.7)
sec.top_margin = Cm(2.0)
sec.bottom_margin = Cm(1.8)
sec.left_margin = Cm(2.15)
sec.right_margin = Cm(2.15)
sec.header_distance = Cm(0.8)
sec.footer_distance = Cm(0.8)
add_page_number(sec.footer.paragraphs[0])
# Cover
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(70)
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
r = p.add_run("视频监控平台五十路摄像头\n系统部署性能分析评估报告")
set_run_font(r, size=27, bold=True, color="000000")
p.style = doc.styles["Title"]
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.space_after = Pt(38)
r = p.add_run("区域检测 目标检测与告警场景")
set_run_font(r, size=15, color="000000")
add_table(
doc,
["评估对象", "评估基准", "报告日期"],
[["视频监控平台版本 1.003", "五十路网络摄像头", "二零二六年九月四日"]],
widths=[5.4, 5.4, 5.4],
font_size=10,
header_fill=BLUE,
)
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(26)
p.paragraph_format.space_after = Pt(10)
r = p.add_run("核心结论")
set_run_font(r, size=13, bold=True, color="000000")
add_para(
doc,
"当前系统可以完成多路接入和区域目标告警,但不宜在不改造的情况下直接承诺五十路持续目标检测。"
"主要限制不是摄像头接入带宽,而是共享推理链路仍由单个同步转发线程串行处理,且每路摄像头独立使用 OpenCV 进行软件解码。"
"若按启用布控的默认值每路每秒分析一帧,五十路需要至少五十次推理每秒;当前开发机参考测试仅达到二十点四次每秒,且该结果尚未计入 RTSP 解码和进程间传输开销。",
)
add_para(
doc,
"建议将部署分为两个门槛。第一门槛先完成推理并发调度 硬件解码 子码流和异步告警落库改造;第二门槛在目标服务器上通过五十路七十二小时压力与故障恢复验收。"
"生产硬件建议从二十四至三十二个物理核心 一百二十八 GB ECC 内存 双 NVIDIA L4 二十四 GB 或经实测等效的单张高端专业 GPU 双十 GbE 和独立 NVMe 系统盘起步。"
"录像容量必须按现场码率另行配置,五十路四 Mbps 保存七天约需十八点一 TB 可用空间,已含百分之二十余量。",
)
doc.add_page_break()
add_heading(doc, "目录", 1)
toc_items = [
"一 评估结论和部署判定",
"二 评估范围和容量假设",
"三 现有系统架构和代码证据",
"四 五十路负载容量模型",
"五 操作系统和软件环境要求",
"六 硬件配置建议",
"七 主要性能风险和解决方案",
"八 推荐部署架构",
"九 现场测试和验收方案",
"十 实施顺序和采购门槛",
"十一 限制条件和待确认问题",
"附录 计算方法 测试记录和参考资料",
]
for item in toc_items:
p = doc.add_paragraph()
p.paragraph_format.space_after = Pt(4)
r = p.add_run(item)
set_run_font(r, size=10.5, color=INK)
add_heading(doc, "一 评估结论和部署判定", 1)
add_heading(doc, "现状判定", 2)
add_para(
doc,
"现状结论 平台具备五十路视频接入的功能基础,但当前代码路径尚不具备可证明的五十路持续目标检测生产容量。"
"ZLMediaKit 只做代理和协议转换时,五十路二百至三百 Mbps 的典型入口流量不会成为首要瓶颈;真正的瓶颈位于取帧解码 共享推理调度 以及告警写入。",
bold_lead="现状结论 ",
)
add_para(
doc,
"当前区域入侵 越线 密度 滞留和方向规则会设置持续检测标志,使目标检测不再受运动门控保护。"
"所以一旦五十路都启用这些规则,系统必须稳定承担所有路的设定分析帧率,不能用现场静止画面比例来乐观折算。",
)
add_heading(doc, "建议判定", 2)
add_table(
doc,
["场景", "当前版本直接部署", "完成关键改造后", "判定"],
[
["五十路接入和网页预览", "可进行现场验证", "", "低风险"],
["五十路区域目标检测 每路一帧每秒", "不建议承诺", "可作为生产目标", "需改造和压测"],
["十路五帧加四十路一帧每秒", "不可证明", "推荐的分级分析目标", "需批处理和硬件解码"],
["五十路各五帧每秒", "不可行", "需要多 GPU 和更小模型", "高成本专项方案"],
["五十路全天录像七天", "功能可用但未做规模验收", "", "必须独立存储和故障测试"],
],
widths=[4.0, 4.2, 4.4, 3.6],
)
add_heading(doc, "采购前必须满足的三个条件", 2)
add_numbered(doc, "先修复单线程同步推理转发,使多个 GPU worker 能真正并发,并加入有界最新帧队列和公平调度。")
add_numbered(doc, "在实际目标模型 实际摄像头子码流和目标服务器上测得持续吞吐,生产目标至少保留百分之四十容量余量。")
add_numbered(doc, "完成五十路七十二小时稳定性 压力 告警风暴 断流重连和磁盘写满保护测试后再签署容量结论。")
add_heading(doc, "二 评估范围和容量假设", 1)
add_heading(doc, "业务范围", 2)
add_para(
doc,
"本报告覆盖五十路 RTSP 或 GB28181 摄像头接入 ZLMediaKit 转发 OpenCV 取帧 YOLO 目标检测 多边形区域和越线等后处理 告警截图和数据库记录,以及可选的全天录像。"
"不包括大规模人脸库检索 跨摄像头 ReID 的最终生产容量 云端大模型费用和带宽,或外部告警平台的接口容量。",
)
add_heading(doc, "基准假设", 2)
add_table(
doc,
["参数", "基准值", "敏感范围", "说明"],
[
["摄像头数量", "50 路", "固定", "全部可同时在线"],
["主码流", "1080p H264 或 H265", "2 至 8 Mbps", "用于预览和录像"],
["分析子码流", "640p 至 960p", "10 至 15 fps", "建议单独启用 分析输入仍缩放至 640"],
["目标检测模型", "一个 YOLO26x 640", "YOLO26s m x", "当前数据库默认模型文件约 118.7 MB"],
["区域规则分析频率", "每路 1 fps", "0.25 至 5 fps", "Zone 默认一秒一帧"],
["告警重复间隔", "30 秒", "按规则调整", "持续入侵时按目标和规则重报"],
["录像保存", "7 天", "0 至 30 天", "默认配置已启用录像服务 每路还需 record_enable"],
["可用性目标", "单机生产", "建议双机热备或冷备", "当前平台仍是单节点架构"],
],
widths=[3.4, 4.1, 3.8, 5.9],
)
add_para(
doc,
"公式 总推理需求等于摄像头数乘每路分析帧率乘每帧模型数乘检测占空比。"
"对于区域 越线 密度 滞留和方向规则,当前实现会强制持续检测,检测占空比按一计算。"
"同一路绑定两个不同小模型时,总推理次数近似翻倍。",
bold_lead="公式 ",
)
add_heading(doc, "三 现有系统架构和代码证据", 1)
add_heading(doc, "视频和分析处理链路", 2)
add_table(
doc,
["处理阶段", "当前实现", "扩展到五十路的含义"],
[
["视频接入", "ZLMediaKit 代理 RTSP RTMP GB28181", "直通转发不转码时 CPU 压力相对可控"],
["摄像头执行单元", "每路一个独立分析子进程和一个解码线程", "约五十个 Python 子进程及大量队列和句柄"],
["取帧", "OpenCV VideoCapture 通过 FFmpeg 抓帧", "未显式启用 NVDEC 或 QSV 五十路解码主要压在 CPU"],
["帧队列", "每路 deque 最大长度二 取最新帧并丢弃旧帧", "能限制延迟和内存 但丢帧数量会持续增长"],
["检测触发", "运动门控或规则强制持续检测", "常用区域规则使运动门控失去降载作用"],
["推理 IPC", "子进程 JPEG 质量九十五编码后进入共享队列", "增加 CPU 编码 内存拷贝和延迟"],
["共享推理", "主进程单转发线程同步等待推理池返回", "同一时刻仅一个请求在途 多 worker 无法充分并行"],
["告警", "子进程同步保存 JPEG 主进程单事件桥写 SQLite", "告警风暴下磁盘和数据库会形成串行背压"],
["录像", "每路一个 FFmpeg 进程 码流复制后十分钟分段", "五十进程可接受但需要独立磁盘和句柄监控"],
],
widths=[3.0, 6.2, 7.0],
)
add_heading(doc, "对容量影响最大的代码事实", 2)
add_bullet(doc, "共享推理入口队列最大一百二十八 推理池请求和响应队列各六十四 每路响应队列最大一百二十八。")
add_bullet(doc, "主进程 inference forwarder 逐个调用 detect jpeg 并同步等待最多三十秒 因而当前吞吐上限由单请求完整耗时决定。")
add_bullet(doc, "配置虽然允许一至三十二个 inference worker 但当前串行转发无法持续提交多个并行任务。")
add_bullet(doc, "布控分析频率取同一路所有启用区域中的最高值 不会对不同区域分别调度。")
add_bullet(doc, "每个分析子进程先把原帧 JPEG 编码 推理 worker 再解码 多路高频时会产生明显 CPU 和内存带宽开销。")
add_bullet(doc, "SQLite 使用 DELETE journal 和单线程告警写入 已关闭每帧轨迹写库 这是正确优化 但仍不适合高并发告警和多进程查询混合负载。")
add_bullet(doc, "告警截图每路每分钟最多六十张 但未看到报警截图按天数或容量自动清理策略 极端上限达到三千张每分钟。")
add_bullet(doc, "服务重启后需要人工重新启动分析 不满足无人值守现场的自动恢复要求。")
add_heading(doc, "已有的正向设计", 2)
add_para(
doc,
"现有实现并非完全按帧堆积。每路帧队列只保留最新两帧,分析循环取最新帧后清空中间帧;模型在共享 worker 内按算法配置缓存;每帧轨迹快照写库已经停用;录像采用码流复制而不是重新编码。"
"这些设计有利于控制延迟 显存重复占用和数据库写入,应该在后续重构中保留。",
)
add_heading(doc, "四 五十路负载容量模型", 1)
add_heading(doc, "推理吞吐需求", 2)
add_caption(doc, "图一 五十路在不同分析策略下的单模型吞吐需求")
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
r = p.add_run()
r.add_picture(str(CHART), width=Cm(16.2))
# Provide a meaningful description for screen readers and document QA.
inline = r._element.xpath('.//wp:inline')
if inline:
doc_pr = inline[0].find(qn('wp:docPr'))
if doc_pr is not None:
doc_pr.set('descr', '柱状图:五十路在零点二五、一、混合九十、二和五帧每秒策略下的单模型推理需求,并标出当前开发机二十点四次每秒参考线。')
doc_pr.set('title', '五十路单模型推理吞吐需求')
p.paragraph_format.space_after = Pt(6)
add_para(
doc,
"图中二十点四次每秒是本项目开发机上的参考测试,不是目标服务器承诺值。"
"测试使用 RTX 5060 Laptop GPU 八 GB YOLO26x 640 PyTorch FP16三次预热后二十次推理平均四十九点一四 msP95 为五十三点一五 ms。"
"测试只使用内存中的零图像,没有包含 RTSP 解码 JPEG 进程间传输 业务规则 告警截图和多路竞争,因此现场完整链路只会更慢。",
)
add_table(
doc,
["分析策略", "总推理需求", "建议验收吞吐", "适用规则"],
[
["五十路各 0.25 fps", "12.5 次每秒", "至少 20 次每秒", "低变化区域巡检"],
["五十路各 1 fps", "50 次每秒", "至少 75 次每秒", "区域入侵和滞留基础方案"],
["四十路 1 fps 加十路 5 fps", "90 次每秒", "至少 135 次每秒", "普通区域加关键越线方向"],
["五十路各 2 fps", "100 次每秒", "至少 150 次每秒", "更快目标或更短告警延迟"],
["五十路各 5 fps", "250 次每秒", "至少 375 次每秒", "全路高频跟踪 需多 GPU"],
],
widths=[5.2, 3.2, 3.6, 4.2],
)
add_para(
doc,
"验收吞吐按目标需求除以零点六七计算,约保留百分之五十峰值余量。"
"如果同一路要顺序运行两个模型,上表总需求和验收吞吐都应乘二。"
"在硬件采购时应以端到端吞吐和 P95 延迟为约束,不使用 CUDA 核心数或理论 TOPS 直接换算路数。",
)
add_heading(doc, "网络容量", 2)
add_table(
doc,
["单路平均码率", "五十路入口流量", "含百分之三十余量", "七天录像原始容量", "含百分之二十余量"],
[
["2 Mbps", "100 Mbps", "130 Mbps", "7.56 TB", "9.07 TB"],
["4 Mbps", "200 Mbps", "260 Mbps", "15.12 TB", "18.14 TB"],
["6 Mbps", "300 Mbps", "390 Mbps", "22.68 TB", "27.22 TB"],
["8 Mbps", "400 Mbps", "520 Mbps", "30.24 TB", "36.29 TB"],
],
widths=[3.2, 3.2, 3.4, 3.6, 3.8],
)
add_para(
doc,
"一 GbE 从纯带宽看可承载五十路典型码流,但管理访问 录像写入回放和故障重传会占用余量。"
"生产建议双十 GbE 分别连接摄像头网和管理存储网 即使现场交换机暂时只有千兆也为后续扩展和链路隔离保留空间。",
)
add_heading(doc, "当前版本容量边界", 2)
add_para(
doc,
"在不改造单线程同步推理转发和软件解码的前提下,当前开发机实测吞吐不足以覆盖五十路每路一帧每秒的基础需求。"
"因此采购与项目承诺应以改造后的目标服务器实测结果为准,并至少保留百分之四十持续吞吐余量;本节容量表只用于网络和存储初算,不构成整机路数承诺。",
)
add_heading(doc, "内存和进程规模", 2)
add_para(
doc,
"一帧 1080p BGR 图像约六 MB。每路保留帧队列 最近帧和处理副本时,仅像素数据即可达到约十八至三十 MB再叠加 Python OpenCV 背景模型 JPEG 缓冲 队列和解释器,五十个分析进程通常需要数十 GB 内存。"
"因此六十四 GB 只能作为功能验证下限,生产建议一百二十八 GB ECC并将常驻内存百分之八十设为告警阈值。",
)
add_heading(doc, "五 操作系统和软件环境要求", 1)
add_heading(doc, "不改代码的近期部署路径", 2)
add_para(
doc,
"若以当前安装包和现有运行方式为准,建议使用 Windows 11 Pro 或 Enterprise 64 位。"
"项目部署说明明确覆盖 Windows 10 和 Windows 11 x64GPU 依赖锁定为 PyTorch 2.8 CUDA 12.9。"
"现场必须固定经过验证的 NVIDIA 驱动版本 关闭自动睡眠和休眠 采用专用服务账号,并将 ProgramData 中的数据库 配置 密钥 模型和录像纳入备份。"
"不建议未经验证直接使用 Windows Server因为当前文档未声明其安装和托盘服务路径已通过验证。",
)
add_heading(doc, "完成服务化后的生产推荐路径", 2)
add_para(
doc,
"完成容器化或 systemd 服务化以及 ZLMediaKit 重新编译后,建议使用 Ubuntu Server 24.04 LTS x86 64。"
"Ubuntu 24.04 的标准安全维护到二零二九年,适合长期现场运行。"
"仓库当前 Linux 二进制依赖 OpenSSL 1.1,并在说明中给出了安装 Ubuntu 20.04 libssl1.1 包的兼容方法;该方法不宜作为新的二零二六年生产基线。"
"应在目标 LTS 上重新构建 ZLMediaKit 并验证 FFmpeg OpenCV NVIDIA 驱动 CUDA TensorRT 和 Python 依赖。",
)
add_table(
doc,
["层次", "要求", "现场控制点"],
[
["操作系统", "Windows 11 x64 现状路径 或 Ubuntu Server 24.04 LTS 改造路径", "统一补丁窗口 禁止日常办公软件共用"],
["GPU 驱动", "与 CUDA 12.9 或目标 TensorRT 版本匹配", "锁定版本并保留离线安装包"],
["Python", "3.10 以上 当前 Windows 包为 Python 3.12 依赖", "使用项目私有环境 禁止在线漂移升级"],
["推理运行时", "PyTorch 仅作兼容基线 生产优先 TensorRT FP16 或 INT8", "模型导出后做精度回归"],
["数据库", "验证期可 SQLite 生产建议 PostgreSQL", "独立备份 WAL 和连接池监控"],
["反向代理", "HTTPS Nginx 或等效组件", "管理面不暴露公网 IP 白名单"],
["时间同步", "NTP 统一时钟", "摄像头 服务器 存储保持一致"],
["服务管理", "开机自启 自动恢复 健康检查", "分析服务不再依赖人工点击启动"],
],
widths=[3.1, 7.0, 6.1],
)
add_heading(doc, "六 硬件配置建议", 1)
add_heading(doc, "配置分级", 2)
add_table(
doc,
["部件", "功能验证配置", "五十路生产推荐配置", "高频或双模型配置"],
[
["适用负载", "五十路接入 总分析不超过 20 次每秒 不录或少量录像", "五十路 混合 90 次每秒目标 全量告警", "五十路 250 次每秒或双模型"],
["CPU", "16 物理核心 32 线程 高频 x86 64", "24 至 32 物理核心 48 至 64 线程 服务器级", "32 至 48 物理核心 双路或高端单路"],
["内存", "64 GB", "128 GB ECC", "128 至 256 GB ECC"],
["GPU", "16 GB 以上 CUDA GPU 以实测为准", "双 NVIDIA L4 24 GB 或单张 48 至 96 GB 专业 GPU 经实测达标", "双高端 48 GB 以上或四张 L4 按通道分片"],
["系统盘", "1 TB NVMe", "2 块 1.92 TB 企业 NVMe RAID1", "2 块 3.84 TB 企业 NVMe RAID1"],
["告警和数据库盘", "与系统盘共用 仅验证", "独立 1.92 至 3.84 TB 企业 NVMe", "独立 NVMe RAID1"],
["录像盘", "按需", "六块 8 TB 企业盘 RAID6 名义可用 32 TB", "八块 8 TB 以上 RAID6 或独立存储阵列"],
["网络", "双 1 GbE", "双 10 GbE 摄像头网和管理存储网隔离", "双 10 或 25 GbE"],
["电源散热", "按 GPU 额定功耗留余量", "冗余电源 机架风道 UPS", "按多 GPU 峰值功耗设计 建议 3 kVA 以上 UPS"],
],
widths=[2.5, 4.5, 5.1, 4.1],
font_size=8.3,
)
add_para(
doc,
"GPU 选择说明 NVIDIA L4 提供二十四 GB 显存 四个 NVDEC 和七十二 W TDP适合机架式边缘视频推理。"
"RTX PRO 6000 Blackwell 提供九十六 GB ECC 显存,适合单卡需要较大模型余量的服务器或工作站,但功耗为四百至六百 W。"
"两类设备都不能仅凭规格表确认本平台路数,必须用目标模型和完整链路压测。",
bold_lead="GPU 选择说明 ",
)
add_heading(doc, "推荐的五十路采购基线", 2)
add_para(
doc,
"在完成软件并发改造并采用分析子码流的前提下,建议采购基线为单路二十四至三十二物理核心 CPU 一百二十八 GB ECC 内存 双 L4 二十四 GB 双一 TB 企业 NVMe RAID1 另加独立告警数据库 NVMe 双十 GbE 和六块八 TB RAID6 录像盘。"
"双 GPU 不仅用于吞吐,还可将通道固定分片为二十五加二十五,单卡故障时保留降级分析能力。"
"若机柜不支持双卡或被动散热,则选择单张高端专业 GPU但仍建议保留一台可接管的备用服务器。",
)
add_heading(doc, "七 主要性能风险和解决方案", 1)
add_table(
doc,
["优先级", "风险", "现场表现", "根因", "解决方案"],
[
["P0", "共享推理串行", "分析 FPS 明显低于目标 延迟和超时持续上升", "单转发线程同步等待每个请求", "改为异步多请求调度 按 GPU 分片 批量 1 4 8 动态组批"],
["P0", "CPU 解码饱和", "CPU 高 GPU 低 断流和画面卡顿", "五十路 OpenCV FFmpeg 软件解码", "使用子码流 NVDEC QSV 或 GStreamer 硬件解码 解码与推理解耦"],
["P0", "分析服务不能无人恢复", "重启后无告警 需人工点击", "分析启动状态未持久化自动恢复", "服务管理器开机恢复已启用通道 加 watchdog 和退避重连"],
["P1", "无批处理", "GPU 利用率波动 单帧开销高", "每帧单独 JPEG 和单张推理", "TensorRT FP16 或 INT8 动态 batch 同尺寸输入 限制组批等待"],
["P1", "JPEG IPC 成本", "多核 CPU 和内存带宽上升", "每路质量九十五编码 worker 再解码", "共享内存环形缓冲 传 NV12 或预处理张量 避免重复压缩"],
["P1", "SQLite 写锁", "报警列表卡顿 database locked", "DELETE journal 单写者和同步 ORM", "异步告警队列 PostgreSQL 批量写 分离查询和归档"],
["P1", "截图磁盘无界增长", "磁盘写满导致服务异常", "每路每分钟最多六十张但没有全局保留策略", "按天数和容量双阈值清理 磁盘水位保护 缩略图与原图分层"],
["P1", "大模型同步阻塞", "单路分析停顿数秒至三十秒", "LLM 调用在相机处理循环内同步执行", "异步复核队列 超时熔断 并发上限 不阻塞小模型告警"],
["P1", "多模型顺序执行", "同一路绑定模型后吞吐近似倍增", "pipeline 对 detector 逐个 detect", "合并类别 模型蒸馏 按 GPU 分组 必要时并行"],
["P2", "录像进程和磁盘扫描", "句柄多 每小时扫描阻塞机械盘", "每路 FFmpeg 进程和全目录 os walk", "录像独立服务 增量索引 分层目录 低峰清理"],
["P2", "监控指标不足", "故障只能看到平均 FPS", "缺少队列深度 P95 延迟和单路公平性", "增加 Prometheus 指标和容量告警 保存每路时序"],
],
widths=[1.3, 2.7, 3.9, 4.0, 4.3],
font_size=7.7,
)
add_heading(doc, "推理并发改造要点", 2)
add_manual_numbered(doc, 1, "把当前单个 inference forwarder 改为非阻塞请求路由,使每个 GPU worker 至少始终有一个请求在途。")
add_manual_numbered(doc, 2, "按 stream id 做一致性分片,确保同一路结果顺序稳定;每路只保留最新待分析帧,超过时直接覆盖而不是排队。")
add_manual_numbered(doc, 3, "对相同模型和输入尺寸使用一到八张的小批次,组批等待建议从五到十五 ms 起测,防止吞吐提升换来不可接受的告警延迟。")
add_manual_numbered(doc, 4, "导出 TensorRT FP16 后做目标类别召回率和框偏移回归INT8 只有在现场校准集验证通过后启用。")
add_manual_numbered(doc, 5, "将每张 GPU 的模型实例数固定,避免每个 worker 重复加载全部模型造成显存浪费。")
add_heading(doc, "分析频率分级", 2)
add_table(
doc,
["规则类型", "建议分析频率", "原因"],
[
["区域入侵和滞留", "0.5 至 1 fps", "目标停留时间长 重点保证召回和稳定"],
["密度", "1 至 2 fps", "人群变化通常慢于逐帧速率"],
["越线和方向", "3 至 5 fps", "轨迹跨线需要相邻观测 低帧率容易直接跳过线段"],
["关键高速通道", "5 至 10 fps", "车辆或快速人员 需要专项模型和多 GPU"],
["仅运动告警", "按运动门控", "可显著降载 但不能替代目标区域规则"],
],
widths=[4.4, 4.0, 7.8],
)
add_heading(doc, "八 推荐部署架构", 1)
add_heading(doc, "单机生产架构", 2)
add_table(
doc,
["输入层", "媒体层", "分析层", "业务层", "数据层"],
[[
"摄像头主码流用于预览和录像\n分析子码流用于算法",
"ZLMediaKit 直通转发\n摄像头网独立 VLAN",
"硬件解码池\n最新帧调度\n双 GPU 批推理",
"跟踪和区域规则\n异步告警和 LLM 复核",
"PostgreSQL\n告警 NVMe\n录像 RAID6",
]],
widths=[3.3, 3.3, 3.4, 3.4, 3.2],
font_size=8.5,
)
add_para(
doc,
"主码流和分析子码流必须解耦。录像继续复制主码流,不因算法降分辨率而降低取证质量;分析层只拉低分辨率子码流,减少软件解码和 JPEG 传输成本。"
"每张 GPU 固定处理二十五路,并允许故障时把非关键通道降到零点二五 fps将关键通道转移到剩余 GPU。",
)
add_heading(doc, "可靠性设计", 2)
add_bullet(doc, "服务器和摄像头网络使用静态地址 VLAN 和访问控制 管理面通过 HTTPS 反向代理访问。")
add_bullet(doc, "配置 模型 数据库 密钥和授权作为一组备份 不单独复制 SQLite 数据库。")
add_bullet(doc, "录像盘与系统告警盘分离 录像盘写满不得影响数据库和操作系统。")
add_bullet(doc, "UPS 至少支撑有序关机 监控机柜温度 风扇 电源 GPU ECC 或 Xid 错误。")
add_bullet(doc, "生产建议准备第二台可接管服务器 定期验证恢复时间和摄像头重新注册流程。")
add_heading(doc, "九 现场测试和验收方案", 1)
add_heading(doc, "测试阶段", 2)
add_table(
doc,
["阶段", "负载", "持续时间", "目的"],
[
["A 媒体基线", "五十路在线 不启动分析和录像", "4 小时", "确认网络 ZLM 和解码前基线"],
["B 解码基线", "五十路分析子码流 仅运动检测", "8 小时", "测 CPU 解码和断流率"],
["C 标准分析", "五十路各 1 fps 单模型", "24 小时", "验证至少 75 次每秒的系统余量"],
["D 分级分析", "四十路 1 fps 加十路 5 fps", "24 小时", "验证推荐运行模式"],
["E 告警风暴", "多目标反复进出 每路模拟告警", "2 小时", "验证截图 数据库和队列"],
["F 录像叠加", "五十路全天录像加分级分析", "24 小时", "验证磁盘和 FFmpeg 进程"],
["G 故障恢复", "断开十路 重启 ZLM 重启服务 单 GPU 故障", "按场景执行", "验证自动恢复和降级"],
["H 稳定性", "实际生产配置全开", "72 小时", "最终验收"],
],
widths=[3.3, 6.1, 2.7, 4.1],
)
add_heading(doc, "建议验收指标", 2)
add_table(
doc,
["指标", "通过标准", "采集方式"],
[
["在线路数", "五十路持续在线 短断流可自动恢复", "ZLM 媒体列表和应用状态"],
["每路实际分析 FPS", "P95 通道达到设定值百分之九十五以上", "逐通道一分钟窗口"],
["端到端分析延迟", "1 fps 场景 P95 小于 1.5 秒 关键通道 P95 小于 0.8 秒", "采集时间到结果时间"],
["推理超时", "七十二小时为零", "inference timeout count"],
["GPU 利用率", "持续均值低于百分之八十 显存低于百分之八十五", "nvidia smi 或 DCGM"],
["CPU 和内存", "CPU 持续低于百分之七十五 内存低于百分之八十", "系统监控"],
["告警完整性", "测试事件无丢失 重复率符合规则", "事件脚本与数据库对账"],
["数据库", "无 locked 错误 查询 P95 小于一秒", "日志和接口压测"],
["录像", "随机抽取每路分段均可播放 时间连续", "播放器和 ffprobe"],
["恢复", "单路断流三十秒内恢复 服务重启后自动恢复分析", "故障注入记录"],
["磁盘保护", "达到告警水位后自动清理或停止非关键写入 系统不崩溃", "容量阈值模拟"],
],
widths=[3.5, 8.0, 4.7],
font_size=8.3,
)
add_heading(doc, "必须新增的监控指标", 2)
add_bullet(doc, "每路 decode fps analysis fps 目标 fps dropped count stalled seconds reconnect count。")
add_bullet(doc, "全局和每 GPU 队列深度 等待时间 推理时间 端到端时间 P50 P95 P99 超时数。")
add_bullet(doc, "GPU 利用率 显存 功耗 温度 解码器利用率和错误码。")
add_bullet(doc, "告警队列长度 每秒写入数 SQLite 或 PostgreSQL 延迟 数据库错误数 截图写入延迟。")
add_bullet(doc, "录像进程数 磁盘吞吐 延迟 可用容量 保留清理耗时和不可播放分段数。")
add_heading(doc, "十 实施顺序和采购门槛", 1)
add_table(
doc,
["顺序", "工作", "完成标志", "是否阻断采购"],
[
["1", "建立单路和五十路可重复压测工具", "输出吞吐 P95 延迟 CPU GPU 和精度报告", ""],
["2", "异步推理调度和 GPU 分片", "多个 worker 同时在途且保持结果路由正确", ""],
["3", "分析子码流和硬件解码", "五十路解码 CPU 持续低于百分之六十", ""],
["4", "TensorRT FP16 和小批次", "吞吐达到推荐负载的一点五倍 精度回归通过", ""],
["5", "异步告警 PostgreSQL 和截图保留", "告警风暴无丢失 磁盘有水位保护", "生产阻断"],
["6", "自动启动 故障恢复和监控", "重启后无人干预恢复五十路", "生产阻断"],
["7", "目标硬件七十二小时验收", "全部验收指标通过", "最终门槛"],
],
widths=[1.5, 6.2, 6.0, 2.5],
)
add_para(
doc,
"采购建议 在第二步和第三步完成前,只能先借用或租用候选 GPU 进行测试,不宜一次性确定最终卡型和数量。"
"最终采购技术条款应写成完整链路的最低吞吐 P95 延迟 路数和七十二小时稳定性,而不是只写某个 GPU 型号。",
bold_lead="采购建议 ",
)
add_heading(doc, "十一 限制条件和待确认问题", 1)
add_heading(doc, "本评估的限制", 2)
add_para(
doc,
"本报告基于当前工作区代码 配置 数据库结构 一次开发机参考测试和公开硬件资料。"
"没有取得客户现场摄像头的实际码率 编码参数 网络丢包率 目标密度 规则组合和目标服务器,因此硬件型号属于采购候选基线,不构成未经压测的路数保证。"
"本地测试使用零图像,目标数量越多时后处理和告警开销通常更高。",
)
add_heading(doc, "现场方案冻结前需要回答的问题", 2)
questions = [
"五十路摄像头的分辨率 帧率 编码 H264 或 H265 平均和峰值码率是多少。",
"是否能为每台摄像头配置独立分析子码流 子码流的最小目标像素尺寸是否仍满足检测。",
"每路会绑定几个小模型 是否使用 YOLO26x 还是可用现场微调后的 s 或 m 模型。",
"哪些通道需要越线或方向五 fps 哪些通道只需区域一 fps。",
"是否启用全天录像 保存几天 是否有集中存储或只能使用本机磁盘。",
"是否启用云端或本地大模型复核 允许的告警延迟和外网依赖是什么。",
"允许单机故障中断多久 是否要求双机冗余和录像连续性。",
"报警截图和报警记录的保存年限 合规审计和删除要求是什么。",
]
for q in questions:
add_bullet(doc, q)
add_heading(doc, "附录 计算方法 测试记录和参考资料", 1)
add_heading(doc, "容量计算方法", 2)
add_table(
doc,
["项目", "计算式", "五十路四 Mbps 示例"],
[
["入口带宽", "路数乘单路码率", "50 乘 4 等于 200 Mbps"],
["每日录像", "路数乘码率乘 86400 除 8", "2.16 TB 每日"],
["七天录像", "每日录像乘 7", "15.12 TB"],
["规划容量", "七天录像乘 1.2", "18.14 TB 可用空间"],
["推理需求", "路数乘分析 fps 乘模型数", "50 乘 1 乘 1 等于 50 次每秒"],
["验收吞吐", "推理需求除 0.67", "约 75 次每秒"],
],
widths=[3.8, 6.8, 5.6],
)
add_heading(doc, "开发机参考测试记录", 2)
add_table(
doc,
["项目", ""],
[
["GPU", "NVIDIA GeForce RTX 5060 Laptop GPU 8151 MiB"],
["驱动", "577.05"],
["PyTorch 和 CUDA", "2.8.0+cu129 CUDA 12.9"],
["模型", "当前数据库默认 YOLO26x detect 640 CUDA 模型文件约 118.7 MB"],
["测试输入", "内存中 1080p 零图像 三次预热 二十次计时"],
["平均耗时", "49.14 ms"],
["中位耗时", "49.03 ms"],
["P95 耗时", "53.15 ms"],
["单请求吞吐", "20.4 次每秒"],
["解释", "不含 RTSP JPEG IPC 和业务处理 属于吞吐上限参考 不是客户现场保证"],
],
widths=[4.2, 12.0],
)
add_heading(doc, "代码证据索引", 2)
add_table(
doc,
["证据", "位置"],
[
["默认目标分析帧率五 共享推理和一个 worker", "config.json"],
["每路分析子进程和共享推理路由", "app analysis manager.py"],
["单线程同步推理转发", "app analysis manager.py inference forwarder loop"],
["推理池队列和 worker 模型缓存", "app analysis inference_pool.py"],
["每路 JPEG 质量九十五编码和同步等待", "app analysis remote_detector.py"],
["帧队列最大二 取最新帧 规则强制持续检测", "app analysis pipeline.py"],
["OpenCV VideoCapture FFmpeg 取帧", "app analysis frames.py"],
["SQLite DELETE journal", "app apps.py 和 framework settings.py"],
["每路 FFmpeg 码流复制录像和保留扫描", "app recording manager.py"],
["系统版本和部署约束", "README.md 和 deploy windows DEPLOYMENT.md"],
],
widths=[8.0, 8.2],
)
add_heading(doc, "公开参考资料", 2)
refs = [
("Ultralytics YOLO26 模型说明和性能范围", "https://docs.ultralytics.com/models/yolo26"),
("NVIDIA TensorRT 性能优化和动态批处理建议", "https://docs.nvidia.com/deeplearning/tensorrt/latest/performance/optimization.html"),
("NVIDIA L4 产品规格", "https://www.nvidia.com/en-gb/data-center/l4/"),
("NVIDIA RTX PRO 6000 Blackwell 产品规格", "https://www.nvidia.com/en-us/products/workstations/professional-desktop-gpus/rtx-pro-6000-family/"),
("Ubuntu 版本支持周期", "https://ubuntu.com/about/release-cycle"),
]
for i, (name, url) in enumerate(refs, 1):
p = doc.add_paragraph()
p.paragraph_format.space_after = Pt(4)
r = p.add_run(f"{i} {name} ")
set_run_font(r, size=9.5, color=INK)
add_hyperlink(p, url, url)
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(16)
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
r = p.add_run("报告结束")
set_run_font(r, size=10, bold=True, color=MUTED)
# Keep the final output deterministic and ensure paragraphs do not orphan after headings.
for paragraph in doc.paragraphs:
if paragraph.style and paragraph.style.name.startswith("Heading"):
paragraph.paragraph_format.keep_with_next = True
for run in paragraph.runs:
if run.font.name is None:
set_run_font(run)
doc.save(OUT)
print(str(OUT))
if __name__ == "__main__":
build_document()