diff --git a/.gitignore b/.gitignore
index eaf1aa1..da193eb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/README.md b/README.md
index 9d35317..86b3ea4 100644
--- a/README.md
+++ b/README.md
@@ -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,避免同一模型重复占用显存。
+
---
## 快速开始
diff --git a/Ultralytics/settings.json b/Ultralytics/settings.json
new file mode 100644
index 0000000..6c39be6
--- /dev/null
+++ b/Ultralytics/settings.json
@@ -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
+}
\ No newline at end of file
diff --git a/app/analysis/engines/base.py b/app/analysis/engines/base.py
index fda5094..9c24090 100644
--- a/app/analysis/engines/base.py
+++ b/app/analysis/engines/base.py
@@ -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
diff --git a/app/analysis/engines/yolo_pytorch_engine.py b/app/analysis/engines/yolo_pytorch_engine.py
index 504a8c0..95e85f5 100644
--- a/app/analysis/engines/yolo_pytorch_engine.py
+++ b/app/analysis/engines/yolo_pytorch_engine.py
@@ -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
diff --git a/app/analysis/event_bridge.py b/app/analysis/event_bridge.py
index 662f13c..6a68162 100644
--- a/app/analysis/event_bridge.py
+++ b/app/analysis/event_bridge.py
@@ -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
diff --git a/app/analysis/inference_pool.py b/app/analysis/inference_pool.py
index 16b1f6b..4295e03 100644
--- a/app/analysis/inference_pool.py
+++ b/app/analysis/inference_pool.py
@@ -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 []
diff --git a/app/analysis/manager.py b/app/analysis/manager.py
index aa03662..67306da 100644
--- a/app/analysis/manager.py
+++ b/app/analysis/manager.py
@@ -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,
)
- proc.start()
+ 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:
- return True, "already running"
+ 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
diff --git a/app/analysis/pipeline.py b/app/analysis/pipeline.py
index 2c64260..2817b2e 100644
--- a/app/analysis/pipeline.py
+++ b/app/analysis/pipeline.py
@@ -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([
diff --git a/app/analysis/preview_sessions.py b/app/analysis/preview_sessions.py
new file mode 100644
index 0000000..32b04f6
--- /dev/null
+++ b/app/analysis/preview_sessions.py
@@ -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
diff --git a/app/analysis/process_worker.py b/app/analysis/process_worker.py
index 07480d7..0629d53 100644
--- a/app/analysis/process_worker.py
+++ b/app/analysis/process_worker.py
@@ -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))
diff --git a/app/analysis/remote_detector.py b/app/analysis/remote_detector.py
index 0c47b7e..77fdb00 100644
--- a/app/analysis/remote_detector.py
+++ b/app/analysis/remote_detector.py
@@ -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
diff --git a/app/analysis/tracker.py b/app/analysis/tracker.py
index 271ac24..1c9ec01 100644
--- a/app/analysis/tracker.py
+++ b/app/analysis/tracker.py
@@ -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"]:
- 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
- new_tracks.append(tid)
+ @property
+ def retention_sec(self):
+ return max(2.0, min(15.0, 3.0 * self._recent_period))
- # 未匹配的轨迹累计 missed
- ended = []
- for tid, tr in self._tracks.items():
- if tid in active:
+ 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
- tr.missed += 1
- if tr.missed >= self.max_missed:
- ended.append(tid)
- for tid in ended:
- del self._tracks[tid]
+ 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)
- 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
+ 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
+ tid = self._next_id
+ self._next_id += 1
+ self._tracks[tid] = Track(tid, det, frame_index, timestamp)
+ new_tracks.append(tid)
+ used_tracks.add(tid)
+
+ ended = []
+ 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)
+ del self._tracks[tid]
+
+ 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
diff --git a/app/analysis/worker_pool.py b/app/analysis/worker_pool.py
index 37c1d95..d32eddc 100644
--- a/app/analysis/worker_pool.py
+++ b/app/analysis/worker_pool.py
@@ -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
diff --git a/app/apps.py b/app/apps.py
index 0df67d6..6e0ff37 100644
--- a/app/apps.py
+++ b/app/apps.py
@@ -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()
diff --git a/app/middleware.py b/app/middleware.py
index 9abdc9c..23ea7c3 100644
--- a/app/middleware.py
+++ b/app/middleware.py
@@ -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
diff --git a/app/migrations/0001_initial.py b/app/migrations/0001_initial.py
new file mode 100644
index 0000000..9312f66
--- /dev/null
+++ b/app/migrations/0001_initial.py
@@ -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')],
+ },
+ ),
+ ]
diff --git a/app/migrations/0002_zonemodel_alarm_repeat_sec.py b/app/migrations/0002_zonemodel_alarm_repeat_sec.py
new file mode 100644
index 0000000..1d4f86e
--- /dev/null
+++ b/app/migrations/0002_zonemodel_alarm_repeat_sec.py
@@ -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="重复报警间隔(秒)"),
+ ),
+ ]
diff --git a/app/migrations/__init__.py b/app/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/models.py b/app/models.py
index 57596ec..fb04386 100644
--- a/app/models.py
+++ b/app/models.py
@@ -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] 归一化
diff --git a/app/recording/manager.py b/app/recording/manager.py
index 49452de..cc6de79 100644
--- a/app/recording/manager.py
+++ b/app/recording/manager.py
@@ -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)
- try:
- from app.models import StreamModel
- for s in StreamModel.objects.filter(record_enable=1, forward_state=1):
- self.start_stream(s)
- except Exception as e:
- logger.warning("录像自动启动失败: %s", e)
+ def _sync_loop(self):
+ """周期性把 DB 中的 record_enable 标记同步为实际 FFmpeg 录制进程。
+
+ 分进程部署下 Web 请求只负责持久化 record_enable,真正的录像进程
+ 由持有录制服务的领导者进程通过本循环异步启动/停止。
+ """
+ time.sleep(3)
+ while self._running:
+ try:
+ self._run_sync()
+ except Exception as 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,8 +144,21 @@ class RecordingManager(object):
proc = item.get("proc")
if proc:
try:
- proc.terminate()
- proc.wait(timeout=5)
+ # 先优雅退出(向 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:
try:
proc.kill()
diff --git a/app/services/alarm_service.py b/app/services/alarm_service.py
index d674700..e5700ef 100644
--- a/app/services/alarm_service.py
+++ b/app/services/alarm_service.py
@@ -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]
diff --git a/app/services/lifecycle.py b/app/services/lifecycle.py
index 91a2cf3..37314f3 100644
--- a/app/services/lifecycle.py
+++ b/app/services/lifecycle.py
@@ -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()
diff --git a/app/urls.py b/app/urls.py
index 0477386..b536770 100644
--- a/app/urls.py
+++ b/app/urls.py
@@ -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),
diff --git a/app/utils/Config.py b/app/utils/Config.py
index b689c07..633b04f 100644
--- a/app/utils/Config.py
+++ b/app/utils/Config.py
@@ -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)
diff --git a/app/utils/GB28181SipServer.py b/app/utils/GB28181SipServer.py
index 8b73745..7aa98de 100644
--- a/app/utils/GB28181SipServer.py
+++ b/app/utils/GB28181SipServer.py
@@ -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,7 +373,13 @@ class GB28181SipServer:
else:
# UDP模式:直接sendto
try:
- self.sock.sendto(encoded_data, addr)
+ 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:
self.logger.error(f"[GSS] UDP发送失败 {addr}: {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,44 +1463,51 @@ class GB28181SipServer:
channel.streaming = False
channel.forward_state = 0
channel.inviting = False
+ released_channel = channel
self.logger.debug(f"[GSS] ✓ 已重置通道状态: {channel_id_to_clean}")
- with self.lock:
- for device in self.devices.values():
- for channel in device.channels:
- if channel.call_id == call_id:
- channel_id = channel.channel_id
- rtp_port = channel.allocated_rtp_port # 使用分配的端口
-
- # 释放RTP端口
- if rtp_port > 0:
- self.rtp_port_mgr.release(rtp_port)
-
- # 关闭ZLM RTP服务器
- if channel_id:
- self._close_rtp_server(channel_id)
-
- # 重置通道状态
- channel.rtp_port = 0
- channel.allocated_rtp_port = 0
- channel.call_id = ""
- channel.from_tag = ""
- channel.to_tag = ""
- channel.streaming = False
- channel.forward_state = 0
- channel.inviting = False # 清除inviting标志
- return
+ if released_channel is None:
+ with self.lock:
+ for device in self.devices.values():
+ for channel in device.channels:
+ if channel.call_id == call_id:
+ channel_id = channel.channel_id
+ rtp_port = channel.allocated_rtp_port # 使用分配的端口
+
+ # 释放RTP端口
+ if rtp_port > 0:
+ self.rtp_port_mgr.release(rtp_port)
+
+ # 关闭ZLM RTP服务器
+ if channel_id:
+ self._close_rtp_server(channel_id)
+
+ # 重置通道状态
+ channel.rtp_port = 0
+ channel.allocated_rtp_port = 0
+ channel.call_id = ""
+ channel.from_tag = ""
+ channel.to_tag = ""
+ channel.streaming = False
+ channel.forward_state = 0
+ channel.inviting = False # 清除inviting标志
+ 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 tag(None表示不修改)
+ 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: 通道ID(GB28181 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,20 +3085,27 @@ 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"
- channel = self._find_channel(device.channels, channel_id)
- if not channel:
- return False, "channel not found"
+ # 在锁内更新SN
+ channel.sn += 1
+ sn = channel.sn
- # 在锁内更新SN
- channel.sn += 1
- sn = channel.sn
-
- # 保存设备信息
- device_ip = device.ip
- device_port = device.port
+ # 保存设备信息
+ 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})")
-
- return True, "success"
+ 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()
diff --git a/app/utils/LanguageUtils.py b/app/utils/LanguageUtils.py
index 4166add..b195c5e 100644
--- a/app/utils/LanguageUtils.py
+++ b/app/utils/LanguageUtils.py
@@ -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)
diff --git a/app/utils/MediaServerManager.py b/app/utils/MediaServerManager.py
index ae90c20..c8879dd 100644
--- a/app/utils/MediaServerManager.py
+++ b/app/utils/MediaServerManager.py
@@ -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():
- return True, "流媒体服务已在运行"
+ 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, "流媒体服务已停止"
diff --git a/app/utils/ModelTrust.py b/app/utils/ModelTrust.py
index b837813..150c302 100644
--- a/app/utils/ModelTrust.py
+++ b/app/utils/ModelTrust.py
@@ -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()
diff --git a/app/utils/Secrets.py b/app/utils/Secrets.py
index 210231c..f05bda9 100644
--- a/app/utils/Secrets.py
+++ b/app/utils/Secrets.py
@@ -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()
diff --git a/app/views/AnalysisView.py b/app/views/AnalysisView.py
index 35f39d4..c4a957b 100644
--- a/app/views/AnalysisView.py
+++ b/app/views/AnalysisView.py
@@ -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")
diff --git a/app/views/ControlView.py b/app/views/ControlView.py
index bfcb57b..d4ae940 100644
--- a/app/views/ControlView.py
+++ b/app/views/ControlView.py
@@ -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:
diff --git a/app/views/InnerlView.py b/app/views/InnerlView.py
index a5b8574..a437126 100644
--- a/app/views/InnerlView.py
+++ b/app/views/InnerlView.py
@@ -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:
- # 编辑(不修改nickname,nickname允许用户自定义)
+ # 编辑:不修改 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")
- stream = StreamModel.objects.filter(code=code).first()
+ # 支持可选 cameraDeviceId:GB28181 多设备共享 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:
diff --git a/app/views/NvrView.py b/app/views/NvrView.py
index e84b94b..c3246c1 100644
--- a/app/views/NvrView.py
+++ b/app/views/NvrView.py
@@ -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):
diff --git a/app/views/StreamView.py b/app/views/StreamView.py
index 18654fb..d9edef8 100644
--- a/app/views/StreamView.py
+++ b/app/views/StreamView.py
@@ -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,24 +1047,39 @@ def api_openPtz(request):
g_logger.info("StreamView.openPtz() params:%s" % str(params))
code = params.get("code", "").strip()
- ptzType = int(params.get("ptzType", 0))
- val = int(params.get("val", 0))
+ try:
+ ptzType = int(params.get("ptzType", 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"))
-
- 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,
- channel_id=stream.code,
- ptz_type=ptzType,
- val=val)
- else:
- msg = LANG_VIEWS_T(request, "stream_pull_type_not_supported")
+ if ptzType < 0 or ptzType > 14:
+ msg = LANG_VIEWS_T(request, "ptz_type_range_error")
else:
- msg = LANG_VIEWS_T(request, "stream_not_found")
+ # 除停止外均需非零速度才会动作;越界值钳制到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,
+ channel_id=stream.code,
+ ptz_type=ptzType,
+ 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:
+ msg = LANG_VIEWS_T(request, "stream_not_found")
else:
msg = __check_msg
else:
diff --git a/config.json b/config.json
index d58a5e2..c081632 100644
--- a/config.json
+++ b/config.json
@@ -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,
diff --git a/deploy/windows/DEPLOYMENT.md b/deploy/windows/DEPLOYMENT.md
new file mode 100644
index 0000000..f8a6a2a
--- /dev/null
+++ b/deploy/windows/DEPLOYMENT.md
@@ -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 摄像头、录像和实际模型仍需现场验收。商业分发前须由发行方核实第三方软件和模型的许可。
diff --git a/deploy/windows/Monitor.iss b/deploy/windows/Monitor.iss
new file mode 100644
index 0000000..3f0234e
--- /dev/null
+++ b/deploy/windows/Monitor.iss
@@ -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;
diff --git a/deploy/windows/README.md b/deploy/windows/README.md
new file mode 100644
index 0000000..ba14ef3
--- /dev/null
+++ b/deploy/windows/README.md
@@ -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 10001;SIP TCP/UDP 15060;ZLM TCP 10002、10554、10935;RTP TCP/UDP 20002–40000(含 SIP 分配的 20002–30000 与 ZLM 的 30001–40000)。修改端口或网络网段后由管理员同步调整规则。不要把管理端暴露到公网;公网或不可信网络需要另行部署 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` 下对应构建目录。业务模块及授权模块编译为 .pyd;Python 包与 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 推理,仅验证执行链路,不代表模型准确率验收。
diff --git a/deploy/windows/VALIDATION.md b/deploy/windows/VALIDATION.md
new file mode 100644
index 0000000..23f87cb
--- /dev/null
+++ b/deploy/windows/VALIDATION.md
@@ -0,0 +1,20 @@
+# Windows 发布验证记录
+
+日期:2026-08-31。安装版本 1.003.1(业务版本 1.003),Windows 11 x64 开发机,CPU 构建,无代码签名。
+
+已完成:
+
+- 85 个自有模块编译为 .pyd;18,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.dll;CPU 自检通过,本版不承诺 NPU/GPU 支持。
+
+详细机器可读结果随包提供 validation-record.json,版本和校验文件见 dist/windows。测试过程只使用 .windows-build 或系统临时目录,没有迁移、打包或修改现有开发数据库与摄像头数据。
diff --git a/deploy/windows/build-tools.lock.txt b/deploy/windows/build-tools.lock.txt
new file mode 100644
index 0000000..5a33077
--- /dev/null
+++ b/deploy/windows/build-tools.lock.txt
@@ -0,0 +1,4 @@
+Cython==3.1.3
+pystray==0.19.5
+whitenoise==6.9.0
+wheel==0.45.1
diff --git a/deploy/windows/build.ps1 b/deploy/windows/build.ps1
new file mode 100644
index 0000000..c31f01c
--- /dev/null
+++ b/deploy/windows/build.ps1
@@ -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
diff --git a/deploy/windows/build.py b/deploy/windows/build.py
new file mode 100644
index 0000000..03fd184
--- /dev/null
+++ b/deploy/windows/build.py
@@ -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()
diff --git a/deploy/windows/default-config.json b/deploy/windows/default-config.json
new file mode 100644
index 0000000..486ea94
--- /dev/null
+++ b/deploy/windows/default-config.json
@@ -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
+ }
+}
diff --git a/deploy/windows/default-settings.json b/deploy/windows/default-settings.json
new file mode 100644
index 0000000..110598a
--- /dev/null
+++ b/deploy/windows/default-settings.json
@@ -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": ""
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/deploy/windows/issue_license.py b/deploy/windows/issue_license.py
new file mode 100644
index 0000000..11ba00d
--- /dev/null
+++ b/deploy/windows/issue_license.py
@@ -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()
diff --git a/deploy/windows/zlm-template.ini b/deploy/windows/zlm-template.ini
new file mode 100644
index 0000000..fdd1498
--- /dev/null
+++ b/deploy/windows/zlm-template.ini
@@ -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=
404 Not Found您访问的资源不存在!
ZLMediaKit(git hash:/,branch:,build time:2025-05-11T18:28:47)
+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
+
+; } ---
diff --git a/framework/settings.py b/framework/settings.py
index 5276b0c..20d5026 100644
--- a/framework/settings.py
+++ b/framework/settings.py
@@ -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=True:staticfiles 从 STATICFILES_DIRS 直接提供) """
STATICFILES_DIRS = (
- os.path.join(BASE_DIR, "static"),
+ os.path.join(RESOURCE_ROOT, "static"),
)
SESSION_COOKIE_NAME = 'MonitorSessionID'
diff --git a/framework/urls.py b/framework/urls.py
index 043dda3..f5a6f99 100644
--- a/framework/urls.py
+++ b/framework/urls.py
@@ -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/', web.private_file),
+ path('static/upload/audio/', web.private_file, {'area': 'audio'}),
+ path('upload/audio/', web.private_file, {'area': 'audio'}),
+] + urlpatterns
diff --git a/language-zh.json b/language-zh.json
index c7617cc..e779b43 100644
--- a/language-zh.json
+++ b/language-zh.json
@@ -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": "应用和名称是必填项",
diff --git a/monitor_entry.py b/monitor_entry.py
new file mode 100644
index 0000000..4e74219
--- /dev/null
+++ b/monitor_entry.py
@@ -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)
diff --git a/monitor_runtime/__init__.py b/monitor_runtime/__init__.py
new file mode 100644
index 0000000..35cc0e7
--- /dev/null
+++ b/monitor_runtime/__init__.py
@@ -0,0 +1 @@
+"""Windows desktop deployment support. No service starts on import."""
diff --git a/monitor_runtime/bootstrap.py b/monitor_runtime/bootstrap.py
new file mode 100644
index 0000000..2285aa2
--- /dev/null
+++ b/monitor_runtime/bootstrap.py
@@ -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}))
diff --git a/monitor_runtime/diagnostics.py b/monitor_runtime/diagnostics.py
new file mode 100644
index 0000000..805ffc7
--- /dev/null
+++ b/monitor_runtime/diagnostics.py
@@ -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
diff --git a/monitor_runtime/launcher.py b/monitor_runtime/launcher.py
new file mode 100644
index 0000000..b5c01fd
--- /dev/null
+++ b/monitor_runtime/launcher.py
@@ -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()
diff --git a/monitor_runtime/licensing.py b/monitor_runtime/licensing.py
new file mode 100644
index 0000000..268e0af
--- /dev/null
+++ b/monitor_runtime/licensing.py
@@ -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
diff --git a/monitor_runtime/paths.py b/monitor_runtime/paths.py
new file mode 100644
index 0000000..9738722
--- /dev/null
+++ b/monitor_runtime/paths.py
@@ -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)
diff --git a/monitor_runtime/release.py b/monitor_runtime/release.py
new file mode 100644
index 0000000..521de1e
--- /dev/null
+++ b/monitor_runtime/release.py
@@ -0,0 +1,2 @@
+"""Replaced with a compiled constant by the release builder."""
+RELEASE_BUILD = False
diff --git a/monitor_runtime/service.py b/monitor_runtime/service.py
new file mode 100644
index 0000000..b441ad0
--- /dev/null
+++ b/monitor_runtime/service.py
@@ -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()
diff --git a/monitor_runtime/web.py b/monitor_runtime/web.py
new file mode 100644
index 0000000..a96a7d1
--- /dev/null
+++ b/monitor_runtime/web.py
@@ -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 = """
+Monitor 安装与授权
+{% if setup %}首次安装设置{% else %}授权管理{% endif %}
+{{ status.reason }}{% if status.license %}
客户:{{ status.license.customer }}
+有效期:{{ status.license.expires_at|default:"永久" }}{% endif %}
+{% if error %}{{ error }}
{% endif %}
+导出本机申请文件,交给发行方签发,再导入签名授权文件。授权绑定当前电脑。
+
+
+{% if setup %}{% else %}返回登录 · 进入系统
{% endif %}
+"""
+
+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('设置完成,服务正在重启。稍后重新打开桌面快捷方式进入登录页面。
')
+ 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)
diff --git a/monitor_runtime/windows.py b/monitor_runtime/windows.py
new file mode 100644
index 0000000..8c3b0ba
--- /dev/null
+++ b/monitor_runtime/windows.py
@@ -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
diff --git a/requirements-windows-gpu.txt b/requirements-windows-gpu.txt
new file mode 100644
index 0000000..0aab1db
--- /dev/null
+++ b/requirements-windows-gpu.txt
@@ -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
diff --git a/scripts/enable-gpu.ps1 b/scripts/enable-gpu.ps1
new file mode 100644
index 0000000..607dfd3
--- /dev/null
+++ b/scripts/enable-gpu.ps1
@@ -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
diff --git a/scripts/local_webcam.py b/scripts/local_webcam.py
index 016698c..6f355a8 100644
--- a/scripts/local_webcam.py
+++ b/scripts/local_webcam.py
@@ -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__':
diff --git a/scripts/start-services.ps1 b/scripts/start-services.ps1
new file mode 100644
index 0000000..3cf0642
--- /dev/null
+++ b/scripts/start-services.ps1
@@ -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
+ }
+}
diff --git a/static/upload/weight/20260705205352_yolo11n.bin b/static/upload/weight/20260705205352_yolo11n.bin
deleted file mode 100644
index 262139e..0000000
Binary files a/static/upload/weight/20260705205352_yolo11n.bin and /dev/null differ
diff --git a/static/upload/weight/20260831110138_20260831_yolo26x.pt b/static/upload/weight/20260831110138_20260831_yolo26x.pt
new file mode 100644
index 0000000..ea5840c
Binary files /dev/null and b/static/upload/weight/20260831110138_20260831_yolo26x.pt differ
diff --git a/static/upload/weight/20260831_yolo26x.pt b/static/upload/weight/20260831_yolo26x.pt
new file mode 100644
index 0000000..ea5840c
Binary files /dev/null and b/static/upload/weight/20260831_yolo26x.pt differ
diff --git a/templates/app/control/index.html b/templates/app/control/index.html
index bc4b4b1..2105580 100644
--- a/templates/app/control/index.html
+++ b/templates/app/control/index.html
@@ -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;}
{% endblock %}
@@ -577,6 +593,12 @@
+
+
+
+
首次进入立即报警;0 表示同一次停留只报一次
+
@@ -601,6 +623,28 @@
+
+
+
+
+
+
+
+
正在启动 YOLO 分析并连接视频流…
+
+
+
+
+
@@ -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 = '
';
var SVG_ANALYZE_STOP = '
';
+var SVG_PREVIEW = '
';
var SVG_EDIT = '
';
var SVG_DEL = '
';
var SVG_LOADING = '
';
@@ -754,9 +808,12 @@ function buildZoneStateTag(z){
return '
'+_t('zone_status_stopped','已停止')+'';
}
var info = getStreamStatusInfo(z.stream_id);
- if(!info || info.running === false){
+ if(!info){
return '
'+_t('zone_status_starting','启动中')+'';
}
+ if(info.analysis_health === 'error' || info.analysis_health === 'stalled'){
+ return '
'+_t('zone_status_inference_error','推理异常')+'';
+ }
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 '
'+streamHealthLabel(health)+'';
}
- if(health === 'ok' && fps > 0.05){
+ if(info.running !== false && health === 'ok' &&
+ (info.analysis_health === 'running' || (!info.analysis_health && fps > 0.05))){
return '
'+_t('zone_status_running','分析中')+'';
}
return '
'+_t('zone_status_starting','启动中')+'';
@@ -1251,6 +1309,7 @@ function renderZoneTable(zones){
var nm='
'+escapeHtml(z.name)+'
';
var actions = '
'
+''+buildZoneRunBtn(z)+''
+ +''
+''
+''
+'
';
@@ -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');
+}
{% endblock %}
diff --git a/templates/app/stream/player.html b/templates/app/stream/player.html
index 1856fe2..7133d90 100644
--- a/templates/app/stream/player.html
+++ b/templates/app/stream/player.html
@@ -51,6 +51,7 @@
+
@@ -72,27 +73,27 @@
-
↖
-
↑
-
↗
-
←
-
●
-
→
-
↙
-
↓
-
↘
+
↖
+
↑
+
↗
+
←
+
●
+
→
+
↙
+
↓
+
↘
-
-
+
+
-
-
+
+
-
-
+
+
@@ -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 = '';
}
+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() {
diff --git a/tests/test_analysis_runtime.py b/tests/test_analysis_runtime.py
new file mode 100644
index 0000000..779e8df
--- /dev/null
+++ b/tests/test_analysis_runtime.py
@@ -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()
diff --git a/tests/test_control_preview.cjs b/tests/test_control_preview.cjs
new file mode 100644
index 0000000..af5ca2b
--- /dev/null
+++ b/tests/test_control_preview.cjs
@@ -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');
diff --git a/tests/test_control_status.cjs b/tests/test_control_status.cjs
new file mode 100644
index 0000000..68085f1
--- /dev/null
+++ b/tests/test_control_status.cjs
@@ -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');
diff --git a/tests/test_windows_license.py b/tests/test_windows_license.py
new file mode 100644
index 0000000..9cc172f
--- /dev/null
+++ b/tests/test_windows_license.py
@@ -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)
diff --git a/tests/validate_yolo_replay.py b/tests/validate_yolo_replay.py
new file mode 100644
index 0000000..e28d5e2
--- /dev/null
+++ b/tests/validate_yolo_replay.py
@@ -0,0 +1,74 @@
+"""Camera 01 录像:直接 YOLO 与共享实时缩图管线的一致性抽样验证。
+
+手动运行:python tests/validate_yolo_replay.py