提交修改代码
This commit is contained in:
parent
731a3f8290
commit
9b2948e6c7
12
.gitignore
vendored
12
.gitignore
vendored
@ -37,7 +37,6 @@ monitor.spec
|
|||||||
static/storage/*
|
static/storage/*
|
||||||
framework/__pycache__/*
|
framework/__pycache__/*
|
||||||
app/__pycache__/*
|
app/__pycache__/*
|
||||||
app/migrations/*
|
|
||||||
app/migrations/__pycache__/*
|
app/migrations/__pycache__/*
|
||||||
app/comms/__pycache__/*
|
app/comms/__pycache__/*
|
||||||
app/utils/__pycache__/*
|
app/utils/__pycache__/*
|
||||||
@ -49,3 +48,14 @@ app/recording/__pycache__/*
|
|||||||
# logs / docs / external services
|
# logs / docs / external services
|
||||||
docs/*
|
docs/*
|
||||||
log/*
|
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
|
||||||
|
|||||||
10
README.md
10
README.md
@ -1,3 +1,5 @@
|
|||||||
|
Windows EXE 安装、离线授权与可重复构建见 [部署说明](deploy/windows/README.md)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
多路视频接入与智能布控分析平台。支持 GB28181 / RTSP、YOLO 小模型检测、OpenAI 兼容大模型复核、多边形布控与结构化报警。
|
多路视频接入与智能布控分析平台。支持 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
|
pip install -r requirements-linux.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
NVIDIA RTX 显卡可在基础依赖安装完成后启用本地 CUDA 推理:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File scripts/enable-gpu.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
启用后在“小模型管理”中将 YOLO PyTorch 模型的推理设备设为 `CUDA`。共享推理 worker 默认设为 1,避免同一模型重复占用显存。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|||||||
19
Ultralytics/settings.json
Normal file
19
Ultralytics/settings.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"settings_version": "0.0.8",
|
||||||
|
"datasets_dir": "D:\\00_Workspace\\python\\datasets",
|
||||||
|
"weights_dir": "D:\\00_Workspace\\python\\video_monitor\\weights",
|
||||||
|
"runs_dir": "D:\\00_Workspace\\python\\video_monitor\\runs",
|
||||||
|
"uuid": "5a5a041af8f3f74887c6bd745669f28e7ad18f074277d0c9a5abd22add25caf9",
|
||||||
|
"sync": true,
|
||||||
|
"api_key": "",
|
||||||
|
"openai_api_key": "",
|
||||||
|
"clearml": true,
|
||||||
|
"comet": true,
|
||||||
|
"dvc": true,
|
||||||
|
"mlflow": true,
|
||||||
|
"raytune": true,
|
||||||
|
"tensorboard": false,
|
||||||
|
"wandb": false,
|
||||||
|
"vscode_msg": true,
|
||||||
|
"openvino_msg": true
|
||||||
|
}
|
||||||
@ -40,7 +40,7 @@ class BaseEngine(object):
|
|||||||
def __init__(self, model_file=None, labels=None, input_size=(640, 640),
|
def __init__(self, model_file=None, labels=None, input_size=(640, 640),
|
||||||
conf_threshold=0.4, iou_threshold=0.5, providers=None,
|
conf_threshold=0.4, iou_threshold=0.5, providers=None,
|
||||||
algorithm_type="yolo", algorithm_version="",
|
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.model_file = model_file or ""
|
||||||
self.labels = labels or []
|
self.labels = labels or []
|
||||||
self.input_size = input_size
|
self.input_size = input_size
|
||||||
@ -51,6 +51,7 @@ class BaseEngine(object):
|
|||||||
self.algorithm_version = algorithm_version
|
self.algorithm_version = algorithm_version
|
||||||
self.task_type = (task_type or "detect").lower()
|
self.task_type = (task_type or "detect").lower()
|
||||||
self.device = device or "cpu"
|
self.device = device or "cpu"
|
||||||
|
self.target_labels = list(target_labels or [])
|
||||||
self._loaded = False
|
self._loaded = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -70,6 +70,8 @@ class YoloPytorchEngine(BaseEngine):
|
|||||||
self._model = None
|
self._model = None
|
||||||
self.task_type = (kwargs.get("task_type") or "detect").lower()
|
self.task_type = (kwargs.get("task_type") or "detect").lower()
|
||||||
self.device = kwargs.get("device") or "cpu"
|
self.device = kwargs.get("device") or "cpu"
|
||||||
|
self._target_classes = None
|
||||||
|
self._effective_device = "cpu"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_available():
|
def is_available():
|
||||||
@ -108,6 +110,12 @@ class YoloPytorchEngine(BaseEngine):
|
|||||||
self.labels = self._resolve_labels(self.model_file)
|
self.labels = self._resolve_labels(self.model_file)
|
||||||
try:
|
try:
|
||||||
self._model = _UltralyticsYOLO(self.model_file, task=self.task_type)
|
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
|
# 推断 input_size
|
||||||
try:
|
try:
|
||||||
cfg = getattr(self._model, "overrides", {}) or {}
|
cfg = getattr(self._model, "overrides", {}) or {}
|
||||||
@ -122,9 +130,18 @@ class YoloPytorchEngine(BaseEngine):
|
|||||||
# 预热(小尺寸 dummy),让模型迁移到目标设备
|
# 预热(小尺寸 dummy),让模型迁移到目标设备
|
||||||
try:
|
try:
|
||||||
dev = _normalize_device(self.device, self.algorithm_type)
|
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)
|
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,
|
self._model.predict(dummy, imgsz=max(self.input_size), device=dev,
|
||||||
conf=self.conf_threshold, iou=self.iou_threshold,
|
conf=self.conf_threshold, iou=self.iou_threshold,
|
||||||
|
classes=self._target_classes,
|
||||||
|
quantize=16 if dev != "cpu" else None,
|
||||||
verbose=False, save=False)
|
verbose=False, save=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("YoloPytorchEngine 预热失败(忽略): %s", 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:
|
if not self.ready() or frame_bgr is None or self._model is None:
|
||||||
return []
|
return []
|
||||||
try:
|
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
|
iw, ih = self.input_size
|
||||||
results = self._model.predict(frame_bgr, imgsz=max(iw, ih), device=dev,
|
results = self._model.predict(frame_bgr, imgsz=max(iw, ih), device=dev,
|
||||||
conf=self.conf_threshold, iou=self.iou_threshold,
|
conf=self.conf_threshold, iou=self.iou_threshold,
|
||||||
|
classes=self._target_classes,
|
||||||
|
quantize=16 if dev != "cpu" else None,
|
||||||
verbose=False, save=False)
|
verbose=False, save=False)
|
||||||
return self._parse_results(results)
|
return self._parse_results(results)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -237,7 +256,7 @@ class YoloPytorchEngine(BaseEngine):
|
|||||||
d["version"] = self.version()
|
d["version"] = self.version()
|
||||||
d["ultralytics_version"] = self.ultralytics_version()
|
d["ultralytics_version"] = self.ultralytics_version()
|
||||||
d["task_type"] = self.task_type
|
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())
|
d["cuda_available"] = bool(_TORCH_AVAILABLE and torch.cuda.is_available())
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ class AnalysisEventBridge(object):
|
|||||||
self._running = False
|
self._running = False
|
||||||
self._queues = []
|
self._queues = []
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
self._latest = {}
|
||||||
|
|
||||||
def register_queue(self, q):
|
def register_queue(self, q):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@ -59,6 +60,8 @@ class AnalysisEventBridge(object):
|
|||||||
self._on_event(payload)
|
self._on_event(payload)
|
||||||
elif kind == "touch":
|
elif kind == "touch":
|
||||||
self._on_touch(payload)
|
self._on_touch(payload)
|
||||||
|
elif kind == "preview":
|
||||||
|
self._on_preview(payload)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("事件桥处理失败: %s", e)
|
logger.exception("事件桥处理失败: %s", e)
|
||||||
|
|
||||||
@ -72,6 +75,22 @@ class AnalysisEventBridge(object):
|
|||||||
# 已停用:不再写追踪快照
|
# 已停用:不再写追踪快照
|
||||||
pass
|
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():
|
def get_event_bridge():
|
||||||
global _BRIDGE
|
global _BRIDGE
|
||||||
|
|||||||
@ -67,6 +67,7 @@ def _inference_worker_loop(req_queue, resp_queue, worker_id):
|
|||||||
int(algo.get("input_height", 640)),
|
int(algo.get("input_height", 640)),
|
||||||
algo.get("task_type", "detect"),
|
algo.get("task_type", "detect"),
|
||||||
algo.get("device", "cpu"),
|
algo.get("device", "cpu"),
|
||||||
|
tuple(sorted(algo.get("target_labels") or [])),
|
||||||
)
|
)
|
||||||
eng = engines.get(key)
|
eng = engines.get(key)
|
||||||
if eng is None:
|
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"),
|
algorithm_type=algo.get("algorithm_type", "yolo8"),
|
||||||
task_type=algo.get("task_type", "detect"),
|
task_type=algo.get("task_type", "detect"),
|
||||||
device=algo.get("device", "cpu"),
|
device=algo.get("device", "cpu"),
|
||||||
|
target_labels=algo.get("target_labels") or [],
|
||||||
)
|
)
|
||||||
if not eng.load():
|
if not eng.load():
|
||||||
resp_queue.put({"req_id": req_id, "ok": False, "error": "engine load failed"})
|
resp_queue.put({"req_id": req_id, "ok": False, "error": "engine load failed"})
|
||||||
@ -190,13 +192,15 @@ class InferenceProcessPool(object):
|
|||||||
self.stop()
|
self.stop()
|
||||||
self.start()
|
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,
|
"""同步推理:直接传已编码的 JPEG bytes,跳过主进程 imencode/imdecode,
|
||||||
消除 _inference_forwarder_loop 中的双重编解码与主进程 GIL 占用。"""
|
消除 _inference_forwarder_loop 中的双重编解码与主进程 GIL 占用。"""
|
||||||
self._ensure_workers_alive()
|
self._ensure_workers_alive()
|
||||||
if not self._running:
|
if not self._running:
|
||||||
self.start()
|
self.start()
|
||||||
if not jpeg:
|
if not jpeg:
|
||||||
|
if raise_errors:
|
||||||
|
raise ValueError("no frame")
|
||||||
return []
|
return []
|
||||||
req_id = str(uuid.uuid4())
|
req_id = str(uuid.uuid4())
|
||||||
evt = {"event": threading.Event(), "resp": None}
|
evt = {"event": threading.Event(), "resp": None}
|
||||||
@ -210,6 +214,8 @@ class InferenceProcessPool(object):
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._pending.pop(req_id, None)
|
self._pending.pop(req_id, None)
|
||||||
logger.warning("推理请求入队失败: %s", e)
|
logger.warning("推理请求入队失败: %s", e)
|
||||||
|
if raise_errors:
|
||||||
|
raise RuntimeError("推理请求入队失败: %s" % e) from e
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if not evt["event"].wait(timeout=timeout):
|
if not evt["event"].wait(timeout=timeout):
|
||||||
@ -218,10 +224,14 @@ class InferenceProcessPool(object):
|
|||||||
self._timeout_count += 1
|
self._timeout_count += 1
|
||||||
self._last_timeout_ts = time.time()
|
self._last_timeout_ts = time.time()
|
||||||
logger.warning("推理超时 req_id=%s (累计 %d)", req_id, self._timeout_count)
|
logger.warning("推理超时 req_id=%s (累计 %d)", req_id, self._timeout_count)
|
||||||
|
if raise_errors:
|
||||||
|
raise TimeoutError("模型推理超时(%ss)" % timeout)
|
||||||
return []
|
return []
|
||||||
resp = evt.get("resp") or {}
|
resp = evt.get("resp") or {}
|
||||||
if not resp.get("ok"):
|
if not resp.get("ok"):
|
||||||
logger.warning("推理失败: %s", resp.get("error"))
|
logger.warning("推理失败: %s", resp.get("error"))
|
||||||
|
if raise_errors:
|
||||||
|
raise RuntimeError(resp.get("error") or "模型推理失败")
|
||||||
return []
|
return []
|
||||||
return resp.get("detections") or []
|
return resp.get("detections") or []
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import logging
|
|||||||
import multiprocessing as mp
|
import multiprocessing as mp
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
from app.analysis.pipeline import CameraPipeline
|
from app.analysis.pipeline import CameraPipeline
|
||||||
from app.analysis.motion import MotionDetector
|
from app.analysis.motion import MotionDetector
|
||||||
@ -28,7 +29,7 @@ def _snapshot_storage_paths():
|
|||||||
return alarm_dir, static_dir
|
return alarm_dir, static_dir
|
||||||
|
|
||||||
|
|
||||||
def _algorithm_to_spec(a):
|
def _algorithm_to_spec(a, target_labels=None):
|
||||||
labels = a.labels
|
labels = a.labels
|
||||||
if isinstance(labels, str):
|
if isinstance(labels, str):
|
||||||
try:
|
try:
|
||||||
@ -48,6 +49,7 @@ def _algorithm_to_spec(a):
|
|||||||
"algorithm_type": a.algorithm_type,
|
"algorithm_type": a.algorithm_type,
|
||||||
"task_type": getattr(a, "task_type", "detect"),
|
"task_type": getattr(a, "task_type", "detect"),
|
||||||
"device": getattr(a, "device", "cpu"),
|
"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_manager = self._mp_ctx.Manager()
|
||||||
self._status_dict = self._status_manager.dict()
|
self._status_dict = self._status_manager.dict()
|
||||||
self._infer_req_q = self._mp_ctx.Queue(maxsize=128)
|
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_running = True
|
||||||
self._infer_forwarder = threading.Thread(
|
self._infer_forwarder = threading.Thread(
|
||||||
target=self._inference_forwarder_loop, name="infer-forwarder", daemon=True)
|
target=self._inference_forwarder_loop, name="infer-forwarder", daemon=True)
|
||||||
self._infer_forwarder.start()
|
self._infer_forwarder.start()
|
||||||
self._disabled_algos = set() # 禁用实例化的业务算法 ID 集合(内存,重启丢失)
|
|
||||||
get_event_bridge()
|
get_event_bridge()
|
||||||
self._configure_from_settings()
|
self._configure_from_settings()
|
||||||
|
|
||||||
@ -252,7 +257,6 @@ class AnalysisManager(object):
|
|||||||
def _inference_forwarder_loop(self):
|
def _inference_forwarder_loop(self):
|
||||||
from app.analysis.inference_pool import get_inference_pool
|
from app.analysis.inference_pool import get_inference_pool
|
||||||
import queue as _q
|
import queue as _q
|
||||||
pool = get_inference_pool()
|
|
||||||
while self._infer_forwarder_running:
|
while self._infer_forwarder_running:
|
||||||
try:
|
try:
|
||||||
msg = self._infer_req_q.get(timeout=0.5)
|
msg = self._infer_req_q.get(timeout=0.5)
|
||||||
@ -261,6 +265,10 @@ class AnalysisManager(object):
|
|||||||
if msg is None:
|
if msg is None:
|
||||||
break
|
break
|
||||||
req_id = msg.get("req_id")
|
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:
|
try:
|
||||||
jpeg = msg.get("jpeg")
|
jpeg = msg.get("jpeg")
|
||||||
algo = msg.get("algorithm") or {}
|
algo = msg.get("algorithm") or {}
|
||||||
@ -268,21 +276,35 @@ class AnalysisManager(object):
|
|||||||
algo_id = algo.get("id", 0)
|
algo_id = algo.get("id", 0)
|
||||||
try:
|
try:
|
||||||
if algo_id and int(algo_id) in self._disabled_algos:
|
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
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# 直接透传 JPEG bytes 给推理池,避免主进程 imdecode + imencode 双重编解码,
|
# 直接透传 JPEG bytes 给推理池,避免主进程 imdecode + imencode 双重编解码,
|
||||||
# 消除主进程 GIL 占用(解码在 worker 子进程内完成)。
|
# 消除主进程 GIL 占用(解码在 worker 子进程内完成)。
|
||||||
dets = pool.detect_jpeg(jpeg, algo, timeout=30.0)
|
pool = get_inference_pool() # Config changes may replace the pool.
|
||||||
self._infer_resp_q.put({"req_id": req_id, "ok": True, "detections": dets})
|
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:
|
except Exception as e:
|
||||||
logger.warning("推理转发失败: %s", e)
|
logger.warning("推理转发失败: %s", e)
|
||||||
try:
|
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:
|
except Exception:
|
||||||
pass
|
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):
|
def _configure_from_settings(self):
|
||||||
try:
|
try:
|
||||||
from app.models import AlgorithmModel
|
from app.models import AlgorithmModel
|
||||||
@ -342,10 +364,15 @@ class AnalysisManager(object):
|
|||||||
return 1.0
|
return 1.0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _load_zones(stream_id):
|
def _load_zones(stream_id, active_only=True, zone_id=None):
|
||||||
try:
|
try:
|
||||||
from app.models import ZoneModel
|
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')
|
'algorithms', 'algorithms__small_model', 'algorithms__detector_model', 'algorithms__llm')
|
||||||
zones = []
|
zones = []
|
||||||
for z in qs:
|
for z in qs:
|
||||||
@ -386,6 +413,8 @@ class AnalysisManager(object):
|
|||||||
"loiter_threshold": z.loiter_threshold,
|
"loiter_threshold": z.loiter_threshold,
|
||||||
"detect_interval_sec": interval,
|
"detect_interval_sec": interval,
|
||||||
"detect_frames": frames,
|
"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_a": line_a,
|
||||||
"line_b": line_b,
|
"line_b": line_b,
|
||||||
"density_threshold": int(getattr(z, "density_threshold", 0) or 0),
|
"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))
|
logger.warning("config fallback engine err: %s" % str(e))
|
||||||
return None
|
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
|
sid = stream.id
|
||||||
|
self._status_dict.pop(str(sid), None)
|
||||||
event_queue = self._mp_ctx.Queue(maxsize=256)
|
event_queue = self._mp_ctx.Queue(maxsize=256)
|
||||||
cmd_queue = self._mp_ctx.Queue(maxsize=16)
|
cmd_queue = self._mp_ctx.Queue(maxsize=16)
|
||||||
bridge = get_event_bridge()
|
bridge = get_event_bridge()
|
||||||
bridge.register_queue(event_queue)
|
bridge.register_queue(event_queue)
|
||||||
|
|
||||||
algo_specs = [_algorithm_to_spec(a) for a in algos]
|
target_by_model = {}
|
||||||
analyze_fps = self._compute_analyze_fps(sid, fallback=self._target_fps)
|
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()
|
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 = {
|
config = {
|
||||||
"stream_id": sid,
|
"stream_id": sid,
|
||||||
"stream_code": getattr(stream, "code", str(sid)),
|
"stream_code": getattr(stream, "code", str(sid)),
|
||||||
@ -468,15 +510,22 @@ class AnalysisManager(object):
|
|||||||
"use_shared_inference": self._use_shared_inference(),
|
"use_shared_inference": self._use_shared_inference(),
|
||||||
"storage_alarm_dir": storage_alarm_dir,
|
"storage_alarm_dir": storage_alarm_dir,
|
||||||
"static_dir": static_dir,
|
"static_dir": static_dir,
|
||||||
|
"response_channel": response_channel,
|
||||||
|
"preview_only": bool(preview_only),
|
||||||
}
|
}
|
||||||
proc = self._mp_ctx.Process(
|
proc = self._mp_ctx.Process(
|
||||||
target=pipeline_process_main,
|
target=pipeline_process_main,
|
||||||
args=(config, event_queue, cmd_queue, self._status_dict,
|
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,
|
name="pipeline-%s" % sid,
|
||||||
daemon=True,
|
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)
|
handle = PipelineProcessHandle(sid, proc, event_queue, cmd_queue, self._status_dict)
|
||||||
self._pipelines[sid] = {
|
self._pipelines[sid] = {
|
||||||
"handle": handle,
|
"handle": handle,
|
||||||
@ -487,17 +536,29 @@ class AnalysisManager(object):
|
|||||||
"pipeline": None,
|
"pipeline": None,
|
||||||
"thread": None,
|
"thread": None,
|
||||||
"algorithm_ids": sorted([a.id for a in algos]),
|
"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)"
|
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
|
sid = stream.id
|
||||||
detectors = []
|
detectors = []
|
||||||
algo_names = []
|
algo_names = []
|
||||||
for a in algos:
|
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:
|
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)
|
algo_names.append(a.name)
|
||||||
if not algos:
|
if not algos:
|
||||||
eng = self._fallback_engine_from_config()
|
eng = self._fallback_engine_from_config()
|
||||||
@ -506,7 +567,8 @@ class AnalysisManager(object):
|
|||||||
algo_names.append("config-fallback")
|
algo_names.append("config-fallback")
|
||||||
|
|
||||||
motion = MotionDetector()
|
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()
|
storage_alarm_dir, static_dir = _snapshot_storage_paths()
|
||||||
pipeline = CameraPipeline(
|
pipeline = CameraPipeline(
|
||||||
stream_id=sid,
|
stream_id=sid,
|
||||||
@ -518,6 +580,8 @@ class AnalysisManager(object):
|
|||||||
analyze_fps=analyze_fps,
|
analyze_fps=analyze_fps,
|
||||||
on_event=self._on_event,
|
on_event=self._on_event,
|
||||||
on_track_snapshot=self._on_track_snapshot,
|
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,
|
zone_polygons=zones,
|
||||||
storage_alarm_dir=storage_alarm_dir,
|
storage_alarm_dir=storage_alarm_dir,
|
||||||
static_dir=static_dir,
|
static_dir=static_dir,
|
||||||
@ -530,11 +594,15 @@ class AnalysisManager(object):
|
|||||||
"running": True,
|
"running": True,
|
||||||
"mode": "thread",
|
"mode": "thread",
|
||||||
"algorithm_ids": sorted([a.id for a in algos]),
|
"algorithm_ids": sorted([a.id for a in algos]),
|
||||||
|
"preview_only": bool(preview_only),
|
||||||
|
"analyze_fps": analyze_fps,
|
||||||
}
|
}
|
||||||
t.start()
|
t.start()
|
||||||
return True, "started (thread)"
|
return True, "started (thread)"
|
||||||
|
|
||||||
def start(self, stream):
|
def start(self, stream):
|
||||||
|
from monitor_runtime.licensing import require_license
|
||||||
|
require_license()
|
||||||
sid = stream.id
|
sid = stream.id
|
||||||
with self._lock:
|
with self._lock:
|
||||||
item = self._pipelines.get(sid)
|
item = self._pipelines.get(sid)
|
||||||
@ -547,10 +615,15 @@ class AnalysisManager(object):
|
|||||||
th = item.get("thread")
|
th = item.get("thread")
|
||||||
alive = th is not None and th.is_alive()
|
alive = th is not None and th.is_alive()
|
||||||
if alive:
|
if alive:
|
||||||
return True, "already running"
|
if not item.get("preview_only"):
|
||||||
|
return True, "already running"
|
||||||
|
# 正式启动接管由预览创建的临时管线。
|
||||||
|
self.stop(sid)
|
||||||
|
item = None
|
||||||
# 僵尸条目:进程/线程已退出但未清理
|
# 僵尸条目:进程/线程已退出但未清理
|
||||||
try:
|
try:
|
||||||
if item.get("mode") == "process":
|
if item.get("mode") == "process":
|
||||||
|
self._close_inference_channel(item)
|
||||||
eq = item.get("event_queue")
|
eq = item.get("event_queue")
|
||||||
if eq:
|
if eq:
|
||||||
get_event_bridge().unregister_queue(eq)
|
get_event_bridge().unregister_queue(eq)
|
||||||
@ -577,6 +650,78 @@ class AnalysisManager(object):
|
|||||||
return False, "analysis subprocess exited (check OpenCV / RTSP / log)"
|
return False, "analysis subprocess exited (check OpenCV / RTSP / log)"
|
||||||
return ok, msg
|
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):
|
def stop(self, stream_id):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
item = self._pipelines.get(stream_id)
|
item = self._pipelines.get(stream_id)
|
||||||
@ -586,6 +731,7 @@ class AnalysisManager(object):
|
|||||||
handle = item.get("handle")
|
handle = item.get("handle")
|
||||||
if handle:
|
if handle:
|
||||||
handle.stop()
|
handle.stop()
|
||||||
|
self._close_inference_channel(item)
|
||||||
eq = item.get("event_queue")
|
eq = item.get("event_queue")
|
||||||
if eq:
|
if eq:
|
||||||
get_event_bridge().unregister_queue(eq)
|
get_event_bridge().unregister_queue(eq)
|
||||||
@ -630,6 +776,7 @@ class AnalysisManager(object):
|
|||||||
handle.stop(timeout=1)
|
handle.stop(timeout=1)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
self._close_inference_channel(item)
|
||||||
else:
|
else:
|
||||||
pipe = item.get("pipeline")
|
pipe = item.get("pipeline")
|
||||||
if pipe:
|
if pipe:
|
||||||
@ -644,9 +791,9 @@ class AnalysisManager(object):
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
alive = []
|
alive = []
|
||||||
for sid, item in list(self._pipelines.items()):
|
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)
|
alive.append(sid)
|
||||||
else:
|
elif not self._is_pipeline_alive(item):
|
||||||
self._purge_pipeline(sid)
|
self._purge_pipeline(sid)
|
||||||
return alive
|
return alive
|
||||||
|
|
||||||
@ -667,7 +814,8 @@ class AnalysisManager(object):
|
|||||||
if health == "ok" and fps <= 0 and stalled >= 20:
|
if health == "ok" and fps <= 0 and stalled >= 20:
|
||||||
info["stream_health"] = "stalled"
|
info["stream_health"] = "stalled"
|
||||||
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):
|
if not info.get("active_zone_ids") and self.is_running(stream_id):
|
||||||
try:
|
try:
|
||||||
zones = self._load_zones(stream_id)
|
zones = self._load_zones(stream_id)
|
||||||
@ -785,3 +933,17 @@ class AnalysisManager(object):
|
|||||||
if t in ("entered_zone", "left_zone", "object_start"):
|
if t in ("entered_zone", "left_zone", "object_start"):
|
||||||
return 2
|
return 2
|
||||||
return 3
|
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
|
||||||
|
|||||||
@ -44,6 +44,8 @@ class CameraPipeline(object):
|
|||||||
analyze_fps=None,
|
analyze_fps=None,
|
||||||
on_event=None,
|
on_event=None,
|
||||||
on_track_snapshot=None,
|
on_track_snapshot=None,
|
||||||
|
on_preview=None,
|
||||||
|
alarm_enabled=True,
|
||||||
zone_polygons=None,
|
zone_polygons=None,
|
||||||
storage_alarm_dir=None,
|
storage_alarm_dir=None,
|
||||||
static_dir=None,
|
static_dir=None,
|
||||||
@ -75,6 +77,8 @@ class CameraPipeline(object):
|
|||||||
self._frame_source = FrameSource(rtsp_url, target_fps=target_fps)
|
self._frame_source = FrameSource(rtsp_url, target_fps=target_fps)
|
||||||
self._on_event = on_event or (lambda *a, **kw: None)
|
self._on_event = on_event or (lambda *a, **kw: None)
|
||||||
self._on_track_snapshot = on_track_snapshot 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 []
|
self.zone_polygons = zone_polygons or []
|
||||||
|
|
||||||
# 解码/分析分离
|
# 解码/分析分离
|
||||||
@ -96,7 +100,11 @@ class CameraPipeline(object):
|
|||||||
self._last_analyze_ts = 0.0
|
self._last_analyze_ts = 0.0
|
||||||
|
|
||||||
self._running = False
|
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._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._track_enter_ts = {} # (track_id, zone_id) -> ts(滞留计时)
|
||||||
self._last_w = 0
|
self._last_w = 0
|
||||||
self._last_h = 0
|
self._last_h = 0
|
||||||
@ -118,6 +126,7 @@ class CameraPipeline(object):
|
|||||||
self._zone_config_warned = set()
|
self._zone_config_warned = set()
|
||||||
self._start_ts = 0.0
|
self._start_ts = 0.0
|
||||||
self._last_frame_ts = 0.0
|
self._last_frame_ts = 0.0
|
||||||
|
self._current_capture_ts = 0.0
|
||||||
self._update_detector_policy()
|
self._update_detector_policy()
|
||||||
|
|
||||||
def _update_detector_policy(self):
|
def _update_detector_policy(self):
|
||||||
@ -155,6 +164,7 @@ class CameraPipeline(object):
|
|||||||
def reset_zone_runtime_state(self):
|
def reset_zone_runtime_state(self):
|
||||||
"""热更新/重启布控后清空运行时状态,使进入区域报警可重新触发。"""
|
"""热更新/重启布控后清空运行时状态,使进入区域报警可重新触发。"""
|
||||||
self._last_zone_state = {}
|
self._last_zone_state = {}
|
||||||
|
self._outside_zone_hits = {}
|
||||||
self._track_enter_ts = {}
|
self._track_enter_ts = {}
|
||||||
self._llm_zone_last_ts = {}
|
self._llm_zone_last_ts = {}
|
||||||
self._llm_track_last_ts = {}
|
self._llm_track_last_ts = {}
|
||||||
@ -221,7 +231,7 @@ class CameraPipeline(object):
|
|||||||
with self._queue_lock:
|
with self._queue_lock:
|
||||||
if len(self._frame_queue) >= self._frame_queue.maxlen:
|
if len(self._frame_queue) >= self._frame_queue.maxlen:
|
||||||
self._dropped_count += 1
|
self._dropped_count += 1
|
||||||
self._frame_queue.append(frame)
|
self._frame_queue.append((frame, self._last_frame_ts))
|
||||||
self._decoded_count += 1
|
self._decoded_count += 1
|
||||||
self._fps_window_decoded += 1
|
self._fps_window_decoded += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -249,6 +259,7 @@ class CameraPipeline(object):
|
|||||||
self._running = True
|
self._running = True
|
||||||
self._start_ts = time.time()
|
self._start_ts = time.time()
|
||||||
self._decode_running = True
|
self._decode_running = True
|
||||||
|
self._started_event.set()
|
||||||
self._decode_thread = threading.Thread(
|
self._decode_thread = threading.Thread(
|
||||||
target=self._decode_loop, name="decode-%s" % self.stream_id, daemon=True)
|
target=self._decode_loop, name="decode-%s" % self.stream_id, daemon=True)
|
||||||
self._decode_thread.start()
|
self._decode_thread.start()
|
||||||
@ -256,26 +267,34 @@ class CameraPipeline(object):
|
|||||||
% (self.stream_code, self.analyze_fps, self._analyze_interval, len(self._detectors)))
|
% (self.stream_code, self.analyze_fps, self._analyze_interval, len(self._detectors)))
|
||||||
try:
|
try:
|
||||||
while self._running:
|
while self._running:
|
||||||
frame = self._pop_latest_frame()
|
packet = self._pop_latest_frame()
|
||||||
if frame is None:
|
if packet is None:
|
||||||
time.sleep(0.02)
|
time.sleep(0.02)
|
||||||
continue
|
continue
|
||||||
|
if isinstance(packet, tuple) and len(packet) == 2:
|
||||||
|
frame, capture_ts = packet
|
||||||
|
else: # 兼容测试和旧调用
|
||||||
|
frame, capture_ts = packet, time.time()
|
||||||
# 跳帧节流:未到分析间隔则丢弃此帧
|
# 跳帧节流:未到分析间隔则丢弃此帧
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - self._last_analyze_ts < self._analyze_interval:
|
if now - self._last_analyze_ts < self._analyze_interval:
|
||||||
continue
|
continue
|
||||||
self._last_analyze_ts = now
|
self._last_analyze_ts = now
|
||||||
self._frame_index += 1
|
self._frame_index += 1
|
||||||
self._analyzed_count += 1
|
|
||||||
self._fps_window_analyzed += 1
|
|
||||||
try:
|
try:
|
||||||
self._last_h, self._last_w = frame.shape[:2]
|
self._last_h, self._last_w = frame.shape[:2]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._last_frame = frame
|
self._last_frame = frame
|
||||||
|
self._current_capture_ts = capture_ts
|
||||||
try:
|
try:
|
||||||
self._process_frame(frame)
|
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:
|
except Exception as e:
|
||||||
|
self._analysis_error = str(e)
|
||||||
logger.warning("pipeline[%s] 处理帧异常: %s" % (self.stream_code, str(e)))
|
logger.warning("pipeline[%s] 处理帧异常: %s" % (self.stream_code, str(e)))
|
||||||
self._refresh_fps()
|
self._refresh_fps()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -301,7 +320,7 @@ class CameraPipeline(object):
|
|||||||
self._fps_window_decoded = 0
|
self._fps_window_decoded = 0
|
||||||
self._fps_window_ts = now
|
self._fps_window_ts = now
|
||||||
|
|
||||||
def _process_frame(self, frame):
|
def _process_frame(self, frame, capture_ts=None):
|
||||||
motion_boxes = self._motion.detect(frame)
|
motion_boxes = self._motion.detect(frame)
|
||||||
has_motion = len(motion_boxes) > 0
|
has_motion = len(motion_boxes) > 0
|
||||||
run_detect = has_motion or self._force_detect
|
run_detect = has_motion or self._force_detect
|
||||||
@ -313,22 +332,29 @@ class CameraPipeline(object):
|
|||||||
for d in self._detectors:
|
for d in self._detectors:
|
||||||
eng = d.get("engine")
|
eng = d.get("engine")
|
||||||
if not eng or not eng.ready():
|
if not eng or not eng.ready():
|
||||||
continue
|
raise RuntimeError("检测器 %s 未就绪" % d.get("algorithm_name"))
|
||||||
try:
|
try:
|
||||||
res = eng.detect(frame)
|
res = eng.detect(frame)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("pipeline[%s] 检测器 %s 异常: %s"
|
logger.warning("pipeline[%s] 检测器 %s 异常: %s"
|
||||||
% (self.stream_code, d.get("algorithm_name"), str(e)))
|
% (self.stream_code, d.get("algorithm_name"), str(e)))
|
||||||
continue
|
raise
|
||||||
algo_id = d.get("algorithm_id")
|
algo_id = d.get("algorithm_id")
|
||||||
algo_name = d.get("algorithm_name")
|
algo_name = d.get("algorithm_name")
|
||||||
|
allowed = set(d.get("target_labels") or [])
|
||||||
for r in res:
|
for r in res:
|
||||||
|
if allowed and r.get("label") not in allowed:
|
||||||
|
continue
|
||||||
r["algorithm_id"] = algo_id
|
r["algorithm_id"] = algo_id
|
||||||
r["algorithm_name"] = algo_name
|
r["algorithm_name"] = algo_name
|
||||||
detections.extend(res)
|
detections.append(r)
|
||||||
# 无小模型检测器时不伪造 motion 目标;流程2 仍由 _check_llm_zones 按业务规则报警
|
# 无小模型检测器时不伪造 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:
|
for tid in new_tracks:
|
||||||
tr = next((t for t in active if t["track_id"] == tid), None)
|
tr = next((t for t in active if t["track_id"] == tid), None)
|
||||||
@ -360,7 +386,10 @@ class CameraPipeline(object):
|
|||||||
"timestamp": time.time(),
|
"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:
|
if has_motion:
|
||||||
self._check_llm_zones(frame, motion_boxes)
|
self._check_llm_zones(frame, motion_boxes)
|
||||||
@ -369,6 +398,8 @@ class CameraPipeline(object):
|
|||||||
"""仅业务算法规则命中时:截图、生成描述、上报报警事件。"""
|
"""仅业务算法规则命中时:截图、生成描述、上报报警事件。"""
|
||||||
if not biz_rule:
|
if not biz_rule:
|
||||||
return False
|
return False
|
||||||
|
if not self._alarm_enabled:
|
||||||
|
return False
|
||||||
snap_path = self._save_alarm_snapshot(frame, tr, zone_cfg, event_type)
|
snap_path = self._save_alarm_snapshot(frame, tr, zone_cfg, event_type)
|
||||||
evt = {
|
evt = {
|
||||||
"stream_id": self.stream_id,
|
"stream_id": self.stream_id,
|
||||||
@ -379,6 +410,9 @@ class CameraPipeline(object):
|
|||||||
"label": (tr or {}).get("label", ""),
|
"label": (tr or {}).get("label", ""),
|
||||||
"timestamp": now,
|
"timestamp": now,
|
||||||
"box": box,
|
"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,
|
"snapshot_path": snap_path,
|
||||||
}
|
}
|
||||||
evt.update(extra)
|
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)
|
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):
|
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)
|
matched_rules = self._matched_rules(tr, zone_cfg)
|
||||||
if not matched_rules:
|
if not matched_rules:
|
||||||
return False
|
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
|
is_new = zid not in prev_zones
|
||||||
fired_any = False
|
fired_any = False
|
||||||
flow3_rule = self._flow3_needs_llm(zone_cfg, tr)
|
flow3_rule = self._flow3_needs_llm(zone_cfg, tr)
|
||||||
@ -587,7 +621,7 @@ class CameraPipeline(object):
|
|||||||
def _should_fire(biz_id):
|
def _should_fire(biz_id):
|
||||||
key = (tid, zid, biz_id)
|
key = (tid, zid, biz_id)
|
||||||
last = self._area_alarm_last_ts.get(key, 0)
|
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):
|
def _mark_fired(biz_id):
|
||||||
self._area_alarm_last_ts[(tid, zid, biz_id)] = now
|
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):
|
def _check_zones(self, active, frame_index, frame):
|
||||||
now = time.time()
|
now = time.time()
|
||||||
h, w = self._frame_size(frame_index)
|
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 后处理)
|
# 统计每个区域内的目标数(用于 DENSITY 后处理)
|
||||||
zone_density_count = {} # zone_id -> count
|
zone_density_count = {} # zone_id -> count
|
||||||
|
|
||||||
@ -638,7 +676,8 @@ class CameraPipeline(object):
|
|||||||
tid = tr["track_id"]
|
tid = tr["track_id"]
|
||||||
box = tr["box"]
|
box = tr["box"]
|
||||||
cx = (box[0] + box[2]) / 2
|
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()
|
physical_in = set()
|
||||||
for z in self.zone_polygons:
|
for z in self.zone_polygons:
|
||||||
coords = self._scale_zone(z.get("coords", []), w, h)
|
coords = self._scale_zone(z.get("coords", []), w, h)
|
||||||
@ -649,6 +688,7 @@ class CameraPipeline(object):
|
|||||||
confirmed = set(prev & physical_in)
|
confirmed = set(prev & physical_in)
|
||||||
|
|
||||||
for zid in 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)
|
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):
|
if not self._should_alarm_track_in_zone(tr, zone_cfg):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@ -661,6 +701,13 @@ class CameraPipeline(object):
|
|||||||
confirmed.add(zid)
|
confirmed.add(zid)
|
||||||
|
|
||||||
for zid in prev - physical_in:
|
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({
|
self._on_event({
|
||||||
"stream_id": self.stream_id, "stream_code": self.stream_code,
|
"stream_id": self.stream_id, "stream_code": self.stream_code,
|
||||||
"type": "left_zone", "track_id": tid, "zone_id": zid,
|
"type": "left_zone", "track_id": tid, "zone_id": zid,
|
||||||
@ -807,6 +854,55 @@ class CameraPipeline(object):
|
|||||||
self._direction_fired.discard(fk)
|
self._direction_fired.discard(fk)
|
||||||
self._last_zone_state = cur_state
|
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):
|
def _cross_line(self, prev_pt, cur_pt, line_a, line_b):
|
||||||
from app.analysis.biz_rules import cross_line_segment
|
from app.analysis.biz_rules import cross_line_segment
|
||||||
return cross_line_segment(prev_pt, cur_pt, line_a, line_b)
|
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()
|
health, stalled_sec = self._compute_stream_health()
|
||||||
src = self._frame_source.health_snapshot() if self._frame_source else {}
|
src = self._frame_source.health_snapshot() if self._frame_source else {}
|
||||||
effective_running = self._running and health in ("ok", "reconnecting", "connecting")
|
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 {
|
return {
|
||||||
"stream_id": self.stream_id,
|
"stream_id": self.stream_id,
|
||||||
"running": effective_running,
|
"running": effective_running,
|
||||||
"stream_health": health,
|
"stream_health": health,
|
||||||
|
"analysis_health": analysis_health,
|
||||||
|
"analysis_error": self._analysis_error,
|
||||||
"stalled_sec": round(stalled_sec, 1),
|
"stalled_sec": round(stalled_sec, 1),
|
||||||
"reconnect_fail_count": src.get("reconnect_fail_count", 0),
|
"reconnect_fail_count": src.get("reconnect_fail_count", 0),
|
||||||
"total_reconnects": src.get("total_reconnects", 0),
|
"total_reconnects": src.get("total_reconnects", 0),
|
||||||
@ -989,7 +1095,7 @@ class CameraPipeline(object):
|
|||||||
"decoded_count": self._decoded_count,
|
"decoded_count": self._decoded_count,
|
||||||
"analyzed_count": self._analyzed_count,
|
"analyzed_count": self._analyzed_count,
|
||||||
"dropped_count": self._dropped_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),
|
"decode_fps": round(self._decode_fps, 1),
|
||||||
"analyze_fps_target": self.analyze_fps,
|
"analyze_fps_target": self.analyze_fps,
|
||||||
"active_zone_ids": sorted([
|
"active_zone_ids": sorted([
|
||||||
|
|||||||
139
app/analysis/preview_sessions.py
Normal file
139
app/analysis/preview_sessions.py
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
"""实时 YOLO 预览会话(进程内、短轮询、只保留最新结果)。"""
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.analysis.event_bridge import get_event_bridge
|
||||||
|
from app.analysis.manager import AnalysisManager
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewSessionRegistry(object):
|
||||||
|
TTL_SEC = 15.0
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._sessions = {}
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._running = True
|
||||||
|
threading.Thread(target=self._cleanup_loop, name="preview-session-cleanup", daemon=True).start()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _zone_payload(zone):
|
||||||
|
try:
|
||||||
|
coords = json.loads(zone.coordinates or "[]")
|
||||||
|
except Exception:
|
||||||
|
coords = []
|
||||||
|
return {"id": zone.id, "name": zone.name, "color": zone.color,
|
||||||
|
"coords": coords if isinstance(coords, list) else []}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _point_in_polygon(point, polygon):
|
||||||
|
if len(polygon or []) < 3:
|
||||||
|
return False
|
||||||
|
x, y, inside, j = point[0], point[1], False, len(polygon) - 1
|
||||||
|
for i, current in enumerate(polygon):
|
||||||
|
previous = polygon[j]
|
||||||
|
if ((current[1] > y) != (previous[1] > y) and
|
||||||
|
x < (previous[0] - current[0]) * (y - current[1]) /
|
||||||
|
(previous[1] - current[1] + 1e-12) + current[0]):
|
||||||
|
inside = not inside
|
||||||
|
j = i
|
||||||
|
return inside
|
||||||
|
|
||||||
|
def start(self, owner, zone):
|
||||||
|
ok, mode = AnalysisManager().start_preview(zone)
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError(mode)
|
||||||
|
session_id = uuid.uuid4().hex
|
||||||
|
now = time.time()
|
||||||
|
algorithms = []
|
||||||
|
target_labels = set()
|
||||||
|
for ba in zone.algorithms.filter(state=1).select_related("small_model", "detector_model"):
|
||||||
|
model = ba.detector_model if int(ba.flow_type or 0) == 4 else ba.small_model
|
||||||
|
if model:
|
||||||
|
algorithms.append({"id": model.id, "name": model.name})
|
||||||
|
try:
|
||||||
|
labels = json.loads(ba.target_labels or "[]")
|
||||||
|
except Exception:
|
||||||
|
labels = []
|
||||||
|
target_labels.update(labels or [])
|
||||||
|
session = {
|
||||||
|
"session_id": session_id, "owner": owner, "stream_id": zone.stream_id,
|
||||||
|
"zone": self._zone_payload(zone), "last_heartbeat": now,
|
||||||
|
"created_at": now, "algorithms": algorithms,
|
||||||
|
"target_labels": sorted(target_labels),
|
||||||
|
}
|
||||||
|
with self._lock:
|
||||||
|
self._sessions[session_id] = session
|
||||||
|
stream = zone.stream
|
||||||
|
return {
|
||||||
|
"session_id": session_id, "mode": AnalysisManager().preview_mode(zone.stream_id),
|
||||||
|
"stream_id": zone.stream_id, "app": stream.app, "name": stream.name,
|
||||||
|
"models": algorithms, "zone": session["zone"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def data(self, owner, session_id, since=None):
|
||||||
|
with self._lock:
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session or session["owner"] != owner:
|
||||||
|
raise PermissionError("预览会话不存在或不属于当前登录会话")
|
||||||
|
session["last_heartbeat"] = time.time()
|
||||||
|
stream_id = session["stream_id"]
|
||||||
|
zone = dict(session["zone"])
|
||||||
|
latest = get_event_bridge().latest_preview(stream_id)
|
||||||
|
mode = AnalysisManager().preview_mode(stream_id)
|
||||||
|
if not latest:
|
||||||
|
return {"changed": False, "sequence": 0, "mode": mode,
|
||||||
|
"zone": zone, "models": session["algorithms"]}
|
||||||
|
sequence = int(latest.get("sequence") or 0)
|
||||||
|
changed = since is None or sequence > int(since or 0)
|
||||||
|
if not changed:
|
||||||
|
return {"changed": False, "sequence": sequence, "mode": mode,
|
||||||
|
"timestamp": latest.get("timestamp")}
|
||||||
|
payload = dict(latest)
|
||||||
|
payload["tracks"] = [dict(track) for track in latest.get("tracks", [])]
|
||||||
|
payload.update({"changed": True, "mode": mode, "zone": zone})
|
||||||
|
# 叠框仅显示该布控目标类别;人员入侵不会再显示车辆和交通灯。
|
||||||
|
target_labels = set(session.get("target_labels") or [])
|
||||||
|
if target_labels:
|
||||||
|
payload["tracks"] = [t for t in payload.get("tracks", []) if t.get("label") in target_labels]
|
||||||
|
for track in payload.get("tracks", []):
|
||||||
|
box = track.get("box") or []
|
||||||
|
if len(box) != 4:
|
||||||
|
continue
|
||||||
|
foot = ((box[0] + box[2]) * .5,
|
||||||
|
box[3] - (box[3] - box[1]) * .05 if track.get("label") == "person"
|
||||||
|
else (box[1] + box[3]) * .5)
|
||||||
|
track["inside_zone_ids"] = ([zone["id"]] if self._point_in_polygon(foot, zone["coords"]) else [])
|
||||||
|
payload["zones"] = [zone]
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def stop(self, owner, session_id):
|
||||||
|
with self._lock:
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session or session["owner"] != owner:
|
||||||
|
return False
|
||||||
|
stream_id = session["stream_id"]
|
||||||
|
self._sessions.pop(session_id, None)
|
||||||
|
has_viewers = any(s["stream_id"] == stream_id for s in self._sessions.values())
|
||||||
|
if not has_viewers:
|
||||||
|
AnalysisManager().stop_preview(stream_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _cleanup_loop(self):
|
||||||
|
while self._running:
|
||||||
|
now = time.time()
|
||||||
|
expired = []
|
||||||
|
with self._lock:
|
||||||
|
expired = [(sid, s["owner"]) for sid, s in self._sessions.items()
|
||||||
|
if now - s["last_heartbeat"] > self.TTL_SEC]
|
||||||
|
for sid, owner in expired:
|
||||||
|
self.stop(owner, sid)
|
||||||
|
threading.Event().wait(2.0)
|
||||||
|
|
||||||
|
|
||||||
|
_REGISTRY = PreviewSessionRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
def get_preview_registry():
|
||||||
|
return _REGISTRY
|
||||||
@ -19,7 +19,8 @@ def _algorithm_spec_from_dict(d):
|
|||||||
return 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 则走主进程共享推理池"""
|
"""在子进程中构造检测器;若提供 infer_req_q/infer_resp_q 则走主进程共享推理池"""
|
||||||
detectors = []
|
detectors = []
|
||||||
if infer_req_q is not None and infer_resp_q is not None:
|
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({
|
detectors.append({
|
||||||
"algorithm_id": spec.get("id", 0),
|
"algorithm_id": spec.get("id", 0),
|
||||||
"algorithm_name": spec.get("name", ""),
|
"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:
|
else:
|
||||||
from app.analysis.worker_pool import DetectorWorkerPool
|
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_id": spec.get("id", 0),
|
||||||
"algorithm_name": spec.get("name", ""),
|
"algorithm_name": spec.get("name", ""),
|
||||||
"engine": eng,
|
"engine": eng,
|
||||||
|
"target_labels": spec.get("target_labels") or [],
|
||||||
|
"device": spec.get("device") or "cpu",
|
||||||
})
|
})
|
||||||
return detectors
|
return detectors
|
||||||
|
|
||||||
@ -88,10 +94,17 @@ def pipeline_process_main(config, event_queue, cmd_queue, status_dict,
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def on_preview(payload):
|
||||||
|
try:
|
||||||
|
event_queue.put(("preview", payload), timeout=0.2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
detectors = _build_detectors_in_process(
|
detectors = _build_detectors_in_process(
|
||||||
algorithm_specs,
|
algorithm_specs,
|
||||||
infer_req_q if use_shared_inference else None,
|
infer_req_q if use_shared_inference else None,
|
||||||
infer_resp_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:
|
if not detectors and not algorithm_specs:
|
||||||
log.info("pipeline[%s] 无算法,仅运动检测", stream_code)
|
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,
|
analyze_fps=analyze_fps,
|
||||||
on_event=on_event,
|
on_event=on_event,
|
||||||
on_track_snapshot=on_track_snapshot,
|
on_track_snapshot=on_track_snapshot,
|
||||||
|
on_preview=on_preview,
|
||||||
|
alarm_enabled=not bool(config.get("preview_only", False)),
|
||||||
zone_polygons=zones,
|
zone_polygons=zones,
|
||||||
storage_alarm_dir=storage_alarm_dir,
|
storage_alarm_dir=storage_alarm_dir,
|
||||||
static_dir=static_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"
|
pipeline._algorithm_name = ", ".join(d.get("name", "") for d in algorithm_specs) or "motion-only"
|
||||||
|
|
||||||
import threading
|
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():
|
def status_reporter():
|
||||||
# 等待 pipeline.run() 启动(_running 在 run() 里才置 True,
|
# 等待 pipeline.run() 启动(_running 在 run() 里才置 True,
|
||||||
# 否则 while pipeline._running 条件不满足会立即退出,导致 status_dict 永远为空)
|
# 否则 while pipeline._running 条件不满足会立即退出,导致 status_dict 永远为空)
|
||||||
_wait = 0
|
if not wait_started():
|
||||||
while not pipeline._running and _wait < 100:
|
return
|
||||||
time.sleep(0.1)
|
while not finished.is_set():
|
||||||
_wait += 1
|
|
||||||
while pipeline._running:
|
|
||||||
try:
|
try:
|
||||||
st = pipeline.status()
|
st = pipeline.status()
|
||||||
status_dict[str(stream_id)] = st
|
status_dict[str(stream_id)] = st
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
log.exception("pipeline[%s] 状态上报失败", stream_code)
|
||||||
time.sleep(1.0)
|
finished.wait(1.0)
|
||||||
|
|
||||||
reporter = threading.Thread(target=status_reporter, name="status-%s" % stream_id, daemon=True)
|
reporter = threading.Thread(target=status_reporter, name="status-%s" % stream_id, daemon=True)
|
||||||
reporter.start()
|
reporter.start()
|
||||||
|
|
||||||
def cmd_listener():
|
def cmd_listener():
|
||||||
while pipeline._running:
|
if not wait_started():
|
||||||
|
return
|
||||||
|
while not finished.is_set():
|
||||||
try:
|
try:
|
||||||
cmd = cmd_queue.get(timeout=0.5)
|
cmd = cmd_queue.get(timeout=0.5)
|
||||||
except queue.Empty:
|
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 [])
|
pipeline.set_zone_polygons(cmd.get("zones") or [])
|
||||||
if cmd.get("analyze_fps") is not None:
|
if cmd.get("analyze_fps") is not None:
|
||||||
pipeline.set_analyze_fps(cmd.get("analyze_fps"))
|
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:
|
except Exception as e:
|
||||||
log.exception("pipeline[%s] 命令处理失败: %s", stream_code, 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:
|
try:
|
||||||
pipeline.run()
|
pipeline.run()
|
||||||
finally:
|
finally:
|
||||||
|
finished.set()
|
||||||
|
reporter.join(timeout=2)
|
||||||
|
cmd_thread.join(timeout=1)
|
||||||
try:
|
try:
|
||||||
status_dict.pop(str(stream_id), None)
|
status_dict.pop(str(stream_id), None)
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -186,6 +213,7 @@ class PipelineProcessHandle(object):
|
|||||||
try:
|
try:
|
||||||
self.process.join(timeout=timeout)
|
self.process.join(timeout=timeout)
|
||||||
if self.process.is_alive():
|
if self.process.is_alive():
|
||||||
|
logger.warning("pipeline[%s] 未在 %ss 内停止,强制结束子进程", self.stream_id, timeout)
|
||||||
self.process.terminate()
|
self.process.terminate()
|
||||||
self.process.join(timeout=2)
|
self.process.join(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -200,6 +228,12 @@ class PipelineProcessHandle(object):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("reload_zones 发送失败: %s", 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):
|
def status(self):
|
||||||
try:
|
try:
|
||||||
return self.status_dict.get(str(self.stream_id))
|
return self.status_dict.get(str(self.stream_id))
|
||||||
|
|||||||
@ -67,11 +67,13 @@ def _get_drainer(resp_queue):
|
|||||||
class RemoteDetector(object):
|
class RemoteDetector(object):
|
||||||
ENGINE_NAME = "remote_pool"
|
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._spec = algorithm_spec
|
||||||
self._req_q = req_queue
|
self._req_q = req_queue
|
||||||
self._resp_q = resp_queue
|
self._resp_q = resp_queue
|
||||||
self._timeout = timeout
|
self._timeout = timeout
|
||||||
|
self._response_channel = response_channel
|
||||||
self._drainer = _get_drainer(resp_queue)
|
self._drainer = _get_drainer(resp_queue)
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
@ -82,17 +84,21 @@ class RemoteDetector(object):
|
|||||||
|
|
||||||
def detect(self, frame):
|
def detect(self, frame):
|
||||||
if not self.ready():
|
if not self.ready():
|
||||||
return []
|
raise RuntimeError("共享推理队列未就绪")
|
||||||
self._drainer.ensure_started()
|
self._drainer.ensure_started()
|
||||||
try:
|
try:
|
||||||
import cv2
|
import cv2
|
||||||
frame = self._maybe_downscale(frame)
|
orig_h, orig_w = frame.shape[:2]
|
||||||
ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
|
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:
|
if not ok:
|
||||||
return []
|
raise RuntimeError("推理帧编码失败")
|
||||||
jpeg = buf.tobytes()
|
jpeg = buf.tobytes()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
return []
|
raise RuntimeError("推理帧编码失败: %s" % e) from e
|
||||||
|
|
||||||
req_id = str(uuid.uuid4())
|
req_id = str(uuid.uuid4())
|
||||||
evt = self._drainer.register(req_id)
|
evt = self._drainer.register(req_id)
|
||||||
@ -101,19 +107,62 @@ class RemoteDetector(object):
|
|||||||
"req_id": req_id,
|
"req_id": req_id,
|
||||||
"algorithm": self._spec,
|
"algorithm": self._spec,
|
||||||
"jpeg": jpeg,
|
"jpeg": jpeg,
|
||||||
|
"response_channel": self._response_channel,
|
||||||
}, timeout=2.0)
|
}, timeout=2.0)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
self._drainer.unregister(req_id)
|
self._drainer.unregister(req_id)
|
||||||
return []
|
raise RuntimeError("推理请求入队失败: %s" % e) from e
|
||||||
|
|
||||||
if not evt["event"].wait(timeout=self._timeout):
|
if not evt["event"].wait(timeout=self._timeout):
|
||||||
self._drainer.unregister(req_id)
|
self._drainer.unregister(req_id)
|
||||||
logger.warning("RemoteDetector 推理超时 algo=%s", self._spec.get("name"))
|
logger.warning("RemoteDetector 推理超时 algo=%s", self._spec.get("name"))
|
||||||
return []
|
raise TimeoutError("共享推理响应超时(%ss)" % self._timeout)
|
||||||
resp = evt.get("resp") or {}
|
resp = evt.get("resp") or {}
|
||||||
if not resp.get("ok"):
|
if not resp.get("ok"):
|
||||||
return []
|
raise RuntimeError(resp.get("error") or "共享推理失败")
|
||||||
return resp.get("detections") 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):
|
def _maybe_downscale(self, frame):
|
||||||
try:
|
try:
|
||||||
@ -123,7 +172,7 @@ class RemoteDetector(object):
|
|||||||
int(self._spec.get("input_width", 640) or 640),
|
int(self._spec.get("input_width", 640) or 640),
|
||||||
int(self._spec.get("input_height", 640) or 640),
|
int(self._spec.get("input_height", 640) or 640),
|
||||||
640,
|
640,
|
||||||
) * 2
|
) * 4
|
||||||
longest = max(h, w)
|
longest = max(h, w)
|
||||||
if longest <= max_side:
|
if longest <= max_side:
|
||||||
return frame
|
return frame
|
||||||
|
|||||||
@ -1,102 +1,182 @@
|
|||||||
"""单摄像头目标跟踪(轻量 IoU 关联)
|
"""低帧率视频目标跟踪。
|
||||||
|
|
||||||
设计:参考 Frigate/Norfair 的追踪思路,但用最小依赖实现一个 IoU 关联器,
|
采用同类别全局一对一匹配,并用时间而不是丢帧数保留轨迹。这样在 CPU
|
||||||
避免强制引入 norfair。后续可平滑替换为 norfair 或 DeepSORT/ByteTrack 的特征关联。
|
推理只有约 0.3 FPS 时,短暂缺检不会不断生成新的 track_id。
|
||||||
|
|
||||||
输出:为每个检测框分配 track_id,并维护其在场状态/累计帧数/最近一帧框。
|
|
||||||
"""
|
"""
|
||||||
import logging
|
import math
|
||||||
|
import time
|
||||||
logger = logging.getLogger("analysis.tracker")
|
|
||||||
|
|
||||||
try:
|
|
||||||
import numpy as np # reserved for future vectorized IoU; not required for operation
|
|
||||||
_ = np
|
|
||||||
except Exception:
|
|
||||||
np = None
|
|
||||||
|
|
||||||
|
|
||||||
def _iou(a, b):
|
def _iou(a, b):
|
||||||
ax1, ay1, ax2, ay2 = a
|
ax1, ay1, ax2, ay2 = a
|
||||||
bx1, by1, bx2, by2 = b
|
bx1, by1, bx2, by2 = b
|
||||||
ix1 = max(ax1, bx1); iy1 = max(ay1, by1)
|
ix1, iy1 = max(ax1, bx1), max(ay1, by1)
|
||||||
ix2 = min(ax2, bx2); iy2 = min(ay2, by2)
|
ix2, iy2 = min(ax2, bx2), min(ay2, by2)
|
||||||
iw = max(0, ix2 - ix1); ih = max(0, iy2 - iy1)
|
inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
|
||||||
inter = iw * ih
|
area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1)
|
||||||
a_area = max(0, ax2 - ax1) * max(0, ay2 - ay1)
|
area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1)
|
||||||
b_area = max(0, bx2 - bx1) * max(0, by2 - by1)
|
union = area_a + area_b - inter
|
||||||
union = a_area + b_area - inter
|
return float(inter / union) if union > 0 else 0.0
|
||||||
if union <= 0:
|
|
||||||
return 0.0
|
|
||||||
return float(inter) / float(union)
|
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):
|
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.track_id = track_id
|
||||||
self.label = label
|
self.label = detection.get("label", "unknown")
|
||||||
self.box = box
|
self.box = list(detection.get("box") or [0, 0, 0, 0])
|
||||||
self.score = score
|
self.score = float(detection.get("score", 0) or 0)
|
||||||
self.missed = 0
|
self.missed = 0
|
||||||
self.hits = 1
|
self.hits = 1
|
||||||
self.born = born
|
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):
|
class IoUTracker(object):
|
||||||
"""按类别维护轨迹,IoU 匹配;max_missed 后判定目标消失。"""
|
"""低 FPS 下的全局一对一框关联器。"""
|
||||||
|
|
||||||
def __init__(self, iou_threshold=0.3, max_missed=8):
|
def __init__(self, iou_threshold=0.2, max_missed=8):
|
||||||
self.iou_threshold = iou_threshold
|
self.iou_threshold = float(iou_threshold)
|
||||||
self.max_missed = max_missed
|
self.max_missed = max_missed # 兼容旧构造参数;实际淘汰按时间
|
||||||
self._tracks = {} # track_id -> Track
|
self._tracks = {}
|
||||||
self._next_id = 1
|
self._next_id = 1
|
||||||
|
self._last_update_ts = None
|
||||||
|
self._recent_period = 1.0
|
||||||
|
|
||||||
def update(self, detections, frame_index):
|
@property
|
||||||
"""detections: list[dict(box, label, score)]
|
def retention_sec(self):
|
||||||
返回 list[dict(track_id, label, box, score)] 当前帧仍在场的轨迹"""
|
return max(2.0, min(15.0, 3.0 * self._recent_period))
|
||||||
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)
|
|
||||||
|
|
||||||
# 未匹配的轨迹累计 missed
|
def update(self, detections, frame_index, timestamp=None, max_age_sec=None):
|
||||||
ended = []
|
timestamp = float(timestamp if timestamp is not None else time.time())
|
||||||
for tid, tr in self._tracks.items():
|
if self._last_update_ts is not None:
|
||||||
if tid in active:
|
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
|
continue
|
||||||
tr.missed += 1
|
for tid, track in self._tracks.items():
|
||||||
if tr.missed >= self.max_missed:
|
if track.label != det.get("label"):
|
||||||
ended.append(tid)
|
continue
|
||||||
for tid in ended:
|
predicted = track.predicted_box(timestamp)
|
||||||
del self._tracks[tid]
|
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}
|
used_tracks, used_detections = set(), set()
|
||||||
for t in active.values()], ended, new_tracks, frame_index
|
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):
|
def all_active(self):
|
||||||
return list(self._tracks.values())
|
return list(self._tracks.values())
|
||||||
@ -104,3 +184,5 @@ class IoUTracker(object):
|
|||||||
def reset(self):
|
def reset(self):
|
||||||
self._tracks.clear()
|
self._tracks.clear()
|
||||||
self._next_id = 1
|
self._next_id = 1
|
||||||
|
self._last_update_ts = None
|
||||||
|
self._recent_period = 1.0
|
||||||
|
|||||||
@ -45,6 +45,7 @@ class DetectorWorkerPool(object):
|
|||||||
algo_type = algorithm.get("algorithm_type", "yolo8")
|
algo_type = algorithm.get("algorithm_type", "yolo8")
|
||||||
task_type = algorithm.get("task_type", "detect")
|
task_type = algorithm.get("task_type", "detect")
|
||||||
device = algorithm.get("device", "cpu")
|
device = algorithm.get("device", "cpu")
|
||||||
|
target_labels = algorithm.get("target_labels", [])
|
||||||
else:
|
else:
|
||||||
algo_id = getattr(algorithm, "id", None)
|
algo_id = getattr(algorithm, "id", None)
|
||||||
engine_name = algorithm.inference_engine
|
engine_name = algorithm.inference_engine
|
||||||
@ -61,8 +62,10 @@ class DetectorWorkerPool(object):
|
|||||||
algo_type = algorithm.algorithm_type
|
algo_type = algorithm.algorithm_type
|
||||||
task_type = getattr(algorithm, "task_type", "detect")
|
task_type = getattr(algorithm, "task_type", "detect")
|
||||||
device = getattr(algorithm, "device", "cpu")
|
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:
|
with self._lock:
|
||||||
det = self._engines.get(key)
|
det = self._engines.get(key)
|
||||||
if det is not None:
|
if det is not None:
|
||||||
@ -77,6 +80,7 @@ class DetectorWorkerPool(object):
|
|||||||
algorithm_type=algo_type,
|
algorithm_type=algo_type,
|
||||||
task_type=task_type,
|
task_type=task_type,
|
||||||
device=device)
|
device=device)
|
||||||
|
det.target_labels = list(target_labels or [])
|
||||||
if not det.load():
|
if not det.load():
|
||||||
logger.warning("DetectorWorkerPool: 引擎 load 失败 algo=%s engine=%s", algo_id, engine_name)
|
logger.warning("DetectorWorkerPool: 引擎 load 失败 algo=%s engine=%s", algo_id, engine_name)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@ -26,6 +26,10 @@ class AppConfig(AppConfig):
|
|||||||
|
|
||||||
connection_created.connect(_setup_sqlite_pragma)
|
connection_created.connect(_setup_sqlite_pragma)
|
||||||
|
|
||||||
|
from monitor_runtime.paths import DESKTOP
|
||||||
|
if DESKTOP:
|
||||||
|
return # Desktop bootstrap migrates before accessing business tables.
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.utils.schema_upgrade import ensure_biz_algorithm_line_count_columns
|
from app.utils.schema_upgrade import ensure_biz_algorithm_line_count_columns
|
||||||
ensure_biz_algorithm_line_count_columns()
|
ensure_biz_algorithm_line_count_columns()
|
||||||
|
|||||||
@ -23,6 +23,8 @@ def _required_role(path, method):
|
|||||||
return ROLE_OPERATOR
|
return ROLE_OPERATOR
|
||||||
if path.startswith(("/analysis/openStart", "/analysis/openStop", "/analysis/openReload")):
|
if path.startswith(("/analysis/openStart", "/analysis/openStop", "/analysis/openReload")):
|
||||||
return ROLE_OPERATOR
|
return ROLE_OPERATOR
|
||||||
|
if path.startswith(("/analysis/openPreviewStart", "/analysis/openPreviewStop")):
|
||||||
|
return ROLE_OPERATOR
|
||||||
if path.startswith(("/analysis/openUpdate", "/analysis/openToggle", "/analysis/openRestart")):
|
if path.startswith(("/analysis/openUpdate", "/analysis/openToggle", "/analysis/openRestart")):
|
||||||
return ROLE_ALGORITHM_ADMIN
|
return ROLE_ALGORITHM_ADMIN
|
||||||
return ""
|
return ""
|
||||||
@ -45,6 +47,11 @@ class SimpleMiddleware(MiddlewareMixin):
|
|||||||
def process_request(self, request):
|
def process_request(self, request):
|
||||||
path = request.path_info
|
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 path.startswith('/inner/'):
|
||||||
if verify_internal_request(request):
|
if verify_internal_request(request):
|
||||||
return None
|
return None
|
||||||
|
|||||||
221
app/migrations/0001_initial.py
Normal file
221
app/migrations/0001_initial.py
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
# Generated by Django 5.0.4 on 2026-08-30 23:53
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AlgorithmModel',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, verbose_name='算法名称')),
|
||||||
|
('algorithm_type', models.CharField(choices=[('yolo5', 'YOLOv5'), ('yolo8', 'YOLOv8'), ('yolo11', 'YOLOv11'), ('yolo26', 'YOLO26'), ('osnet', 'OSNet ReID')], default='yolo8', max_length=30, verbose_name='算法类型')),
|
||||||
|
('task_type', models.CharField(choices=[('detect', 'Detect'), ('segment', 'Segment'), ('classify', 'Classify'), ('pose', 'Pose'), ('obb', 'OBB'), ('reid', 'ReID')], default='detect', max_length=20, verbose_name='任务类型')),
|
||||||
|
('inference_engine', models.CharField(choices=[('yolo_pytorch', 'Yolo-PyTorch'), ('onnxruntime', 'OnnxRuntime'), ('openvino', 'OpenVINO')], default='yolo_pytorch', max_length=20, verbose_name='推理引擎')),
|
||||||
|
('device', models.CharField(choices=[('cpu', 'CPU'), ('cuda', 'CUDA'), ('gpu', 'GPU')], default='cpu', max_length=20, verbose_name='推理设备')),
|
||||||
|
('model_file', models.CharField(default='', max_length=300, verbose_name='模型文件相对路径')),
|
||||||
|
('model_file_size', models.IntegerField(default=0, verbose_name='模型文件大小(字节)')),
|
||||||
|
('input_width', models.IntegerField(default=640, verbose_name='输入宽度')),
|
||||||
|
('input_height', models.IntegerField(default=640, verbose_name='输入高度')),
|
||||||
|
('conf_threshold', models.FloatField(default=0.4, verbose_name='置信度阈值')),
|
||||||
|
('iou_threshold', models.FloatField(default=0.5, verbose_name='NMS IoU 阈值')),
|
||||||
|
('labels', models.TextField(default='[]', verbose_name='支持类别JSON数组')),
|
||||||
|
('is_default', models.IntegerField(default=0, verbose_name='是否默认算法')),
|
||||||
|
('state', models.IntegerField(default=1, verbose_name='状态')),
|
||||||
|
('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('last_update_time', models.DateTimeField(auto_now_add=True, verbose_name='更新时间')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '小模型',
|
||||||
|
'verbose_name_plural': '小模型',
|
||||||
|
'db_table': 'av_algorithm',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='LLMModel',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('user_id', models.IntegerField(verbose_name='用户')),
|
||||||
|
('sort', models.IntegerField(default=0, verbose_name='排序')),
|
||||||
|
('code', models.CharField(max_length=50, verbose_name='编号')),
|
||||||
|
('name', models.CharField(default='', max_length=50, verbose_name='名称')),
|
||||||
|
('model_name', models.CharField(max_length=200, verbose_name='模型名称')),
|
||||||
|
('api_url', models.CharField(max_length=500, verbose_name='API地址')),
|
||||||
|
('api_key', models.CharField(default='', max_length=512, verbose_name='API密钥')),
|
||||||
|
('timeout', models.IntegerField(default=30, verbose_name='超时时间(秒)')),
|
||||||
|
('inference_tool', models.CharField(default='OpenAI', max_length=100, verbose_name='推理工具')),
|
||||||
|
('remark', models.TextField(default='', verbose_name='备注')),
|
||||||
|
('state', models.IntegerField(default=1, verbose_name='状态')),
|
||||||
|
('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('last_update_time', models.DateTimeField(auto_now_add=True, verbose_name='更新时间')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '大模型',
|
||||||
|
'verbose_name_plural': '大模型',
|
||||||
|
'db_table': 'av_llm',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='LogModel',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('user_id', models.IntegerField(verbose_name='用户ID')),
|
||||||
|
('log_type', models.IntegerField(verbose_name='日志类型')),
|
||||||
|
('content', models.CharField(max_length=200, verbose_name='日志内容')),
|
||||||
|
('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('state', models.IntegerField(verbose_name='状态')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '管理员日志',
|
||||||
|
'verbose_name_plural': '管理员日志',
|
||||||
|
'db_table': 'av_log',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BizAlgorithmModel',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, verbose_name='算法名称')),
|
||||||
|
('flow_type', models.IntegerField(choices=[(1, '小模型+后处理'), (2, '大模型+后处理'), (3, '小模型+大模型+后处理'), (4, '检测+ReID+后处理')], default=1, verbose_name='流程类型')),
|
||||||
|
('target_labels', models.TextField(default='[]', verbose_name='目标类别JSON')),
|
||||||
|
('llm_prompt', models.TextField(default='', verbose_name='大模型提示词')),
|
||||||
|
('llm_validate', models.TextField(default='', verbose_name='提示词校验值')),
|
||||||
|
('post_process', models.CharField(choices=[('AREA', '区域入侵'), ('LINE_CROSS', '越线检测'), ('LINE_COUNT', '越线计数'), ('DIRECTION', '方向入侵'), ('DENSITY', '密度报警'), ('DWELL', '滞留报警')], default='AREA', max_length=30, verbose_name='后处理逻辑')),
|
||||||
|
('ref_angle', models.FloatField(default=90.0, verbose_name='方向参考角度')),
|
||||||
|
('angle_tolerance', models.FloatField(default=45.0, verbose_name='方向容差(度)')),
|
||||||
|
('forward_count_threshold', models.IntegerField(default=0, verbose_name='正向计数报警阈值')),
|
||||||
|
('reverse_count_threshold', models.IntegerField(default=0, verbose_name='逆向计数报警阈值')),
|
||||||
|
('state', models.IntegerField(default=1, verbose_name='状态')),
|
||||||
|
('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('last_update_time', models.DateTimeField(auto_now_add=True, verbose_name='更新时间')),
|
||||||
|
('detector_model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='biz_algorithms_as_detector', to='app.algorithmmodel', verbose_name='检测小模型(YOLO)')),
|
||||||
|
('small_model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='biz_algorithms', to='app.algorithmmodel', verbose_name='小模型')),
|
||||||
|
('llm', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='biz_algorithms', to='app.llmmodel', verbose_name='大模型')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '业务算法',
|
||||||
|
'verbose_name_plural': '业务算法',
|
||||||
|
'db_table': 'av_biz_algorithm',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='StreamModel',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('user_id', models.IntegerField(verbose_name='用户')),
|
||||||
|
('sort', models.IntegerField(verbose_name='排序')),
|
||||||
|
('code', models.CharField(max_length=50, verbose_name='编号')),
|
||||||
|
('app', models.CharField(max_length=50, verbose_name='流分组')),
|
||||||
|
('name', models.CharField(max_length=50, verbose_name='流名称')),
|
||||||
|
('pull_stream_url', models.CharField(max_length=300, verbose_name='视频流源地址')),
|
||||||
|
('pull_stream_type', models.IntegerField(verbose_name='视频流来源类型')),
|
||||||
|
('pull_stream_transfer_mode', models.IntegerField(verbose_name='视频流传输模式')),
|
||||||
|
('pull_stream_ip', models.CharField(max_length=50, verbose_name='拉流IP')),
|
||||||
|
('pull_stream_port', models.IntegerField(verbose_name='拉流端口')),
|
||||||
|
('pull_stream_username', models.CharField(max_length=512, verbose_name='拉流用户名')),
|
||||||
|
('pull_stream_password', models.CharField(max_length=512, verbose_name='拉流密码')),
|
||||||
|
('nickname', models.CharField(max_length=200, verbose_name='视频流昵称')),
|
||||||
|
('remark', models.CharField(max_length=200, verbose_name='备注')),
|
||||||
|
('forward_state', models.IntegerField(verbose_name='转发状态')),
|
||||||
|
('is_audio', models.IntegerField(default=0, verbose_name='音频传输类型')),
|
||||||
|
('snap_filepath', models.CharField(max_length=200, verbose_name='快照文件路径')),
|
||||||
|
('snap_time', models.DateTimeField(auto_now_add=True, verbose_name='快照时间')),
|
||||||
|
('camera_sum_num', models.IntegerField(default=0, verbose_name='通道总数')),
|
||||||
|
('camera_name', models.CharField(max_length=100, verbose_name='摄像头名称')),
|
||||||
|
('camera_manufacturer', models.CharField(max_length=100, verbose_name='摄像头厂商')),
|
||||||
|
('camera_owner', models.CharField(max_length=50, verbose_name='摄像头所属者')),
|
||||||
|
('camera_model', models.CharField(max_length=50, verbose_name='摄像头型号')),
|
||||||
|
('camera_device_id', models.CharField(max_length=50, verbose_name='GB28181设备ID')),
|
||||||
|
('camera_parent_id', models.CharField(max_length=50, verbose_name='GB28181父设备ID')),
|
||||||
|
('camera_civilcode', models.CharField(max_length=50, verbose_name='行政区划码')),
|
||||||
|
('camera_last_keepalive_time', models.DateTimeField(auto_now_add=True, verbose_name='最近一次心跳时间')),
|
||||||
|
('camera_last_register_time', models.DateTimeField(auto_now_add=True, verbose_name='最近一次注册时间')),
|
||||||
|
('cascade_device_id', models.CharField(default='', max_length=50, verbose_name='向上级联国标编号')),
|
||||||
|
('cascade_enable', models.IntegerField(default=0, verbose_name='是否启用向上级联')),
|
||||||
|
('record_enable', models.IntegerField(default=0, verbose_name='启用24/7录像')),
|
||||||
|
('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('last_update_time', models.DateTimeField(auto_now_add=True, verbose_name='更新时间')),
|
||||||
|
('add_type', models.IntegerField(default=0, verbose_name='添加类型')),
|
||||||
|
('state', models.IntegerField(default=0, verbose_name='状态')),
|
||||||
|
('algorithm', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='streams', to='app.algorithmmodel', verbose_name='分析算法')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '视频流',
|
||||||
|
'verbose_name_plural': '视频流',
|
||||||
|
'db_table': 'av_stream',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ZoneModel',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, verbose_name='区域名称')),
|
||||||
|
('coordinates', models.TextField(verbose_name='多边形坐标')),
|
||||||
|
('is_required', models.IntegerField(default=1, verbose_name='是否必需区域')),
|
||||||
|
('loiter_threshold', models.IntegerField(default=0, verbose_name='滞留阈值(秒)')),
|
||||||
|
('detect_interval_sec', models.FloatField(default=1.0, verbose_name='检测间隔(秒)')),
|
||||||
|
('detect_frames', models.IntegerField(default=1, verbose_name='检测帧数')),
|
||||||
|
('color', models.CharField(default='#169F85', max_length=20, verbose_name='显示颜色')),
|
||||||
|
('line_a', models.TextField(default='', verbose_name='警戒线端点A')),
|
||||||
|
('line_b', models.TextField(default='', verbose_name='警戒线端点B')),
|
||||||
|
('density_threshold', models.IntegerField(default=0, verbose_name='密度阈值')),
|
||||||
|
('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('last_update_time', models.DateTimeField(auto_now_add=True, verbose_name='更新时间')),
|
||||||
|
('state', models.IntegerField(default=1, verbose_name='状态')),
|
||||||
|
('algorithms', models.ManyToManyField(blank=True, related_name='zones', to='app.bizalgorithmmodel', verbose_name='分析算法')),
|
||||||
|
('stream', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.streammodel', verbose_name='所属摄像头')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '区域',
|
||||||
|
'verbose_name_plural': '区域',
|
||||||
|
'db_table': 'av_zone',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='RecordingModel',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('file_path', models.CharField(max_length=500, verbose_name='文件路径')),
|
||||||
|
('start_time', models.DateTimeField(verbose_name='开始时间')),
|
||||||
|
('end_time', models.DateTimeField(verbose_name='结束时间')),
|
||||||
|
('duration', models.FloatField(default=0, verbose_name='时长(秒)')),
|
||||||
|
('file_size', models.BigIntegerField(default=0, verbose_name='文件大小(字节)')),
|
||||||
|
('has_motion', models.IntegerField(default=0, verbose_name='含运动')),
|
||||||
|
('has_object', models.IntegerField(default=0, verbose_name='含目标')),
|
||||||
|
('create_time', models.DateTimeField(auto_now_add=True, verbose_name='入库时间')),
|
||||||
|
('stream', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.streammodel', verbose_name='摄像头')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '录像分段',
|
||||||
|
'verbose_name_plural': '录像分段',
|
||||||
|
'db_table': 'av_recording',
|
||||||
|
'indexes': [models.Index(fields=['stream', 'start_time'], name='av_recording_st_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AlarmModel',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('event_type', models.CharField(default='entered_zone', max_length=32, verbose_name='报警类型')),
|
||||||
|
('description', models.CharField(default='', max_length=300, verbose_name='描述')),
|
||||||
|
('timestamp', models.DateTimeField(verbose_name='发生时间')),
|
||||||
|
('metadata', models.TextField(default='{}', verbose_name='元数据JSON')),
|
||||||
|
('create_time', models.DateTimeField(auto_now_add=True, verbose_name='入库时间')),
|
||||||
|
('stream', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='app.streammodel', verbose_name='摄像头')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '报警',
|
||||||
|
'verbose_name_plural': '报警',
|
||||||
|
'db_table': 'av_alarm',
|
||||||
|
'indexes': [models.Index(fields=['-timestamp'], name='av_alarm_ts_idx'), models.Index(fields=['stream', 'timestamp'], name='av_alarm_st_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
13
app/migrations/0002_zonemodel_alarm_repeat_sec.py
Normal file
13
app/migrations/0002_zonemodel_alarm_repeat_sec.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("app", "0001_initial")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="zonemodel",
|
||||||
|
name="alarm_repeat_sec",
|
||||||
|
field=models.FloatField(default=30.0, verbose_name="重复报警间隔(秒)"),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
app/migrations/__init__.py
Normal file
0
app/migrations/__init__.py
Normal file
@ -263,6 +263,7 @@ class ZoneModel(models.Model):
|
|||||||
loiter_threshold = models.IntegerField(default=0, verbose_name='滞留阈值(秒)') # 0=不检测滞留
|
loiter_threshold = models.IntegerField(default=0, verbose_name='滞留阈值(秒)') # 0=不检测滞留
|
||||||
detect_interval_sec = models.FloatField(default=1.0, verbose_name='检测间隔(秒)') # 每 N 秒
|
detect_interval_sec = models.FloatField(default=1.0, verbose_name='检测间隔(秒)') # 每 N 秒
|
||||||
detect_frames = models.IntegerField(default=1, verbose_name='检测帧数') # 分析 M 帧,频率=M/N fps
|
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='显示颜色')
|
color = models.CharField(max_length=20, default='#169F85', verbose_name='显示颜色')
|
||||||
# LINE_CROSS 后处理:警戒线段两端点(归一化坐标0~1),JSON: [x,y]
|
# LINE_CROSS 后处理:警戒线段两端点(归一化坐标0~1),JSON: [x,y]
|
||||||
line_a = models.TextField(default='', verbose_name='警戒线端点A') # JSON: [x,y] 归一化
|
line_a = models.TextField(default='', verbose_name='警戒线端点A') # JSON: [x,y] 归一化
|
||||||
|
|||||||
@ -26,7 +26,9 @@ class RecordingManager(object):
|
|||||||
self._retention_thread = threading.Thread(
|
self._retention_thread = threading.Thread(
|
||||||
target=self._retention_loop, name="recording-retention", daemon=True)
|
target=self._retention_loop, name="recording-retention", daemon=True)
|
||||||
self._retention_thread.start()
|
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 已启动")
|
logger.info("RecordingManager 已启动")
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
@ -41,14 +43,35 @@ class RecordingManager(object):
|
|||||||
self._retention_thread.join(timeout=5)
|
self._retention_thread.join(timeout=5)
|
||||||
logger.info("RecordingManager 已停止")
|
logger.info("RecordingManager 已停止")
|
||||||
|
|
||||||
def _auto_start_loop(self):
|
def _sync_loop(self):
|
||||||
time.sleep(5)
|
"""周期性把 DB 中的 record_enable 标记同步为实际 FFmpeg 录制进程。
|
||||||
try:
|
|
||||||
from app.models import StreamModel
|
分进程部署下 Web 请求只负责持久化 record_enable,真正的录像进程
|
||||||
for s in StreamModel.objects.filter(record_enable=1, forward_state=1):
|
由持有录制服务的领导者进程通过本循环异步启动/停止。
|
||||||
self.start_stream(s)
|
"""
|
||||||
except Exception as e:
|
time.sleep(3)
|
||||||
logger.warning("录像自动启动失败: %s", e)
|
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):
|
def _segment_seconds(self):
|
||||||
try:
|
try:
|
||||||
@ -72,6 +95,8 @@ class RecordingManager(object):
|
|||||||
return AnalysisManager.build_rtsp_url(stream)
|
return AnalysisManager.build_rtsp_url(stream)
|
||||||
|
|
||||||
def start_stream(self, stream):
|
def start_stream(self, stream):
|
||||||
|
from monitor_runtime.licensing import require_license
|
||||||
|
require_license()
|
||||||
if not self._running:
|
if not self._running:
|
||||||
return False, "recording service is not running in this process"
|
return False, "recording service is not running in this process"
|
||||||
sid = stream.id
|
sid = stream.id
|
||||||
@ -94,14 +119,17 @@ class RecordingManager(object):
|
|||||||
cmd = [
|
cmd = [
|
||||||
ffmpeg, "-loglevel", "warning", "-rtsp_transport", "tcp",
|
ffmpeg, "-loglevel", "warning", "-rtsp_transport", "tcp",
|
||||||
"-i", url,
|
"-i", url,
|
||||||
"-c", "copy", "-f", "segment",
|
# MP4 容器不支持摄像头常见的 pcm_alaw 音频,只复制视频流。
|
||||||
|
"-map", "0:v", "-c", "copy", "-f", "segment",
|
||||||
"-segment_time", str(seg),
|
"-segment_time", str(seg),
|
||||||
"-reset_timestamps", "1",
|
"-reset_timestamps", "1",
|
||||||
"-strftime", "1",
|
"-strftime", "1",
|
||||||
pattern,
|
pattern,
|
||||||
]
|
]
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
self._processes[sid] = {"proc": proc, "path": out_dir, "stream": stream}
|
self._processes[sid] = {"proc": proc, "path": out_dir, "stream": stream}
|
||||||
@ -116,8 +144,21 @@ class RecordingManager(object):
|
|||||||
proc = item.get("proc")
|
proc = item.get("proc")
|
||||||
if proc:
|
if proc:
|
||||||
try:
|
try:
|
||||||
proc.terminate()
|
# 先优雅退出(向 stdin 发 'q'),让 FFmpeg 正常收尾写出 mp4 的
|
||||||
proc.wait(timeout=5)
|
# 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:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
|
|||||||
@ -41,7 +41,8 @@ def write_alarm(event):
|
|||||||
except Exception:
|
except Exception:
|
||||||
stream = None
|
stream = None
|
||||||
meta = event.get("metadata") or {}
|
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"):
|
"global_track_id", "biz_algorithm_id", "biz_algorithm_name", "alarm_reason", "zone_name"):
|
||||||
if k in event:
|
if k in event:
|
||||||
meta[k] = event[k]
|
meta[k] = event[k]
|
||||||
|
|||||||
@ -13,7 +13,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("app.services.lifecycle")
|
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"
|
DEFAULT_LOCK_PATH = PROJECT_ROOT / ".runtime" / "service-leader.lock"
|
||||||
|
|
||||||
|
|
||||||
@ -125,6 +126,8 @@ class ServiceManager:
|
|||||||
return self.lock.acquired
|
return self.lock.acquired
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
from monitor_runtime.licensing import require_license
|
||||||
|
require_license()
|
||||||
with self._guard:
|
with self._guard:
|
||||||
if self._started:
|
if self._started:
|
||||||
return True
|
return True
|
||||||
@ -137,6 +140,8 @@ class ServiceManager:
|
|||||||
from app.utils.MediaServerManager import get_media_server_manager
|
from app.utils.MediaServerManager import get_media_server_manager
|
||||||
ok, info = get_media_server_manager().start()
|
ok, info = get_media_server_manager().start()
|
||||||
logger.info("ZLM explicit start: ok=%s %s", ok, info)
|
logger.info("ZLM explicit start: ok=%s %s", ok, info)
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError("ZLM startup failed: " + str(info))
|
||||||
g_gb28181SipServer.start()
|
g_gb28181SipServer.start()
|
||||||
if getattr(g_config, "recordingEnabled", False):
|
if getattr(g_config, "recordingEnabled", False):
|
||||||
from app.recording.manager import get_recording_manager
|
from app.recording.manager import get_recording_manager
|
||||||
@ -167,6 +172,11 @@ class ServiceManager:
|
|||||||
if not self.is_leader:
|
if not self.is_leader:
|
||||||
return
|
return
|
||||||
self.coordinator.stop()
|
self.coordinator.stop()
|
||||||
|
try:
|
||||||
|
from app.analysis.manager import shutdown_analysis
|
||||||
|
shutdown_analysis()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("analysis shutdown failed")
|
||||||
try:
|
try:
|
||||||
from app.recording.manager import get_recording_manager
|
from app.recording.manager import get_recording_manager
|
||||||
get_recording_manager().stop()
|
get_recording_manager().stop()
|
||||||
|
|||||||
@ -125,6 +125,9 @@ urlpatterns = [
|
|||||||
path('alarm/openClearAlarms', AnalysisView.alarm_openClearAlarms),
|
path('alarm/openClearAlarms', AnalysisView.alarm_openClearAlarms),
|
||||||
|
|
||||||
path('analysis/openStatus', AnalysisView.analysis_openStatus),
|
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/openStart', AnalysisView.analysis_openStart),
|
||||||
path('analysis/openStop', AnalysisView.analysis_openStop),
|
path('analysis/openStop', AnalysisView.analysis_openStop),
|
||||||
path('analysis/openReloadZones', AnalysisView.analysis_openReloadZones),
|
path('analysis/openReloadZones', AnalysisView.analysis_openReloadZones),
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import os
|
|||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from framework.settings import BASE_DIR
|
from framework.settings import BASE_DIR
|
||||||
|
from monitor_runtime.paths import RESOURCE_ROOT, DESKTOP
|
||||||
from app.utils.Secrets import get_runtime_secret
|
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.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.uploadDir = _resolve_path(config_data.get("uploadDir", "static/upload"), base)
|
||||||
self.storageDir = _resolve_path(config_data.get("storageDir", "static/storage"), 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.mediaRtmpPort = _int(config_data.get("mediaRtmpPort"), 10935)
|
||||||
self.isEnableMediaProxyRtmp = _bool(config_data.get("isEnableMediaProxyRtmp"), False)
|
self.isEnableMediaProxyRtmp = _bool(config_data.get("isEnableMediaProxyRtmp"), False)
|
||||||
self.mediaSecret = get_runtime_secret("media_secret")
|
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.mediaStartConfigPath = _resolve_path(config_data.get("mediaStartConfigPath", ""), base)
|
||||||
self.autoStartMedia = _bool(config_data.get("autoStartMedia"), False)
|
self.autoStartMedia = _bool(config_data.get("autoStartMedia"), False)
|
||||||
|
if DESKTOP:
|
||||||
|
self.autoStartMedia = True
|
||||||
|
|
||||||
self.adminHost = "http://" + self.internalHost + ":" + str(self.adminPort)
|
self.adminHost = "http://" + self.internalHost + ":" + str(self.adminPort)
|
||||||
self.mediaHttpHost = "http://" + self.internalHost + ":" + str(self.mediaHttpPort)
|
self.mediaHttpHost = "http://" + self.internalHost + ":" + str(self.mediaHttpPort)
|
||||||
@ -107,7 +114,7 @@ class Config:
|
|||||||
self.analysisConfThreshold = _float(config_data.get("analysisConfThreshold"), 0.4)
|
self.analysisConfThreshold = _float(config_data.get("analysisConfThreshold"), 0.4)
|
||||||
self.analysisProcessMode = _int(config_data.get("analysisProcessMode"), 1)
|
self.analysisProcessMode = _int(config_data.get("analysisProcessMode"), 1)
|
||||||
self.analysisSharedInference = _bool(config_data.get("analysisSharedInference"), True)
|
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)
|
self.recordingEnabled = _bool(config_data.get("recordingEnabled"), False)
|
||||||
|
|||||||
@ -286,6 +286,8 @@ class GB28181SipServer:
|
|||||||
self.running = False
|
self.running = False
|
||||||
self.devices: Dict[str, Device] = {}
|
self.devices: Dict[str, Device] = {}
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
|
# 分进程模式下(web进程无实时注册表)外发控制指令用的事件序号兜底
|
||||||
|
self._fallback_sn = 0
|
||||||
|
|
||||||
# TCP连接池(SIP信令TCP模式使用)
|
# TCP连接池(SIP信令TCP模式使用)
|
||||||
self.tcp_connections: Dict[tuple, socket.socket] = {} # {(ip, port): client_socket}
|
self.tcp_connections: Dict[tuple, socket.socket] = {} # {(ip, port): client_socket}
|
||||||
@ -371,7 +373,13 @@ class GB28181SipServer:
|
|||||||
else:
|
else:
|
||||||
# UDP模式:直接sendto
|
# UDP模式:直接sendto
|
||||||
try:
|
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
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[GSS] UDP发送失败 {addr}: {e}")
|
self.logger.error(f"[GSS] UDP发送失败 {addr}: {e}")
|
||||||
@ -1223,7 +1231,11 @@ class GB28181SipServer:
|
|||||||
to_tag = to_header.split('tag=')[-1].strip()
|
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
|
# 发送ACK
|
||||||
self._send_ack(message, addr)
|
self._send_ack(message, addr)
|
||||||
@ -1414,9 +1426,11 @@ class GB28181SipServer:
|
|||||||
"""
|
"""
|
||||||
根据Call-ID释放通道资源
|
根据Call-ID释放通道资源
|
||||||
|
|
||||||
Args:
|
Returns:
|
||||||
call_id: INVITE会话的Call-ID
|
被释放的 Channel 对象;未找到时返回 None
|
||||||
"""
|
"""
|
||||||
|
released_channel = None
|
||||||
|
|
||||||
# 清理pending invite
|
# 清理pending invite
|
||||||
channel_id_to_clean = None
|
channel_id_to_clean = None
|
||||||
with self.pending_invites_lock:
|
with self.pending_invites_lock:
|
||||||
@ -1449,44 +1463,51 @@ class GB28181SipServer:
|
|||||||
channel.streaming = False
|
channel.streaming = False
|
||||||
channel.forward_state = 0
|
channel.forward_state = 0
|
||||||
channel.inviting = False
|
channel.inviting = False
|
||||||
|
released_channel = channel
|
||||||
self.logger.debug(f"[GSS] ✓ 已重置通道状态: {channel_id_to_clean}")
|
self.logger.debug(f"[GSS] ✓ 已重置通道状态: {channel_id_to_clean}")
|
||||||
|
|
||||||
with self.lock:
|
if released_channel is None:
|
||||||
for device in self.devices.values():
|
with self.lock:
|
||||||
for channel in device.channels:
|
for device in self.devices.values():
|
||||||
if channel.call_id == call_id:
|
for channel in device.channels:
|
||||||
channel_id = channel.channel_id
|
if channel.call_id == call_id:
|
||||||
rtp_port = channel.allocated_rtp_port # 使用分配的端口
|
channel_id = channel.channel_id
|
||||||
|
rtp_port = channel.allocated_rtp_port # 使用分配的端口
|
||||||
# 释放RTP端口
|
|
||||||
if rtp_port > 0:
|
# 释放RTP端口
|
||||||
self.rtp_port_mgr.release(rtp_port)
|
if rtp_port > 0:
|
||||||
|
self.rtp_port_mgr.release(rtp_port)
|
||||||
# 关闭ZLM RTP服务器
|
|
||||||
if channel_id:
|
# 关闭ZLM RTP服务器
|
||||||
self._close_rtp_server(channel_id)
|
if channel_id:
|
||||||
|
self._close_rtp_server(channel_id)
|
||||||
# 重置通道状态
|
|
||||||
channel.rtp_port = 0
|
# 重置通道状态
|
||||||
channel.allocated_rtp_port = 0
|
channel.rtp_port = 0
|
||||||
channel.call_id = ""
|
channel.allocated_rtp_port = 0
|
||||||
channel.from_tag = ""
|
channel.call_id = ""
|
||||||
channel.to_tag = ""
|
channel.from_tag = ""
|
||||||
channel.streaming = False
|
channel.to_tag = ""
|
||||||
channel.forward_state = 0
|
channel.streaming = False
|
||||||
channel.inviting = False # 清除inviting标志
|
channel.forward_state = 0
|
||||||
return
|
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):
|
def _update_channel_streaming_by_callid(self, call_id, streaming=False, forward_state=0, inviting=None, to_tag=None):
|
||||||
"""
|
"""
|
||||||
根据Call-ID更新通道推流状态
|
根据Call-ID更新通道推流状态
|
||||||
|
|
||||||
Args:
|
Returns:
|
||||||
call_id: INVITE会话的Call-ID
|
更新后的 Channel 对象;未找到时返回 None
|
||||||
streaming: 是否正在推流
|
|
||||||
forward_state: 转发状态 0:未转发 1:转发中
|
|
||||||
inviting: 是否正在INVITE中(None表示不修改此状态)
|
|
||||||
to_tag: INVITE 200 OK中的To tag(None表示不修改)
|
|
||||||
"""
|
"""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
for device in self.devices.values():
|
for device in self.devices.values():
|
||||||
@ -1498,7 +1519,8 @@ class GB28181SipServer:
|
|||||||
channel.inviting = inviting
|
channel.inviting = inviting
|
||||||
if to_tag is not None:
|
if to_tag is not None:
|
||||||
channel.to_tag = to_tag
|
channel.to_tag = to_tag
|
||||||
return
|
return channel
|
||||||
|
return None
|
||||||
|
|
||||||
def _send_401_unauthorized(self, request, addr):
|
def _send_401_unauthorized(self, request, addr):
|
||||||
"""发送401 Unauthorized响应"""
|
"""发送401 Unauthorized响应"""
|
||||||
@ -2301,18 +2323,25 @@ class GB28181SipServer:
|
|||||||
self.catalog_locks[device_id] = threading.Lock()
|
self.catalog_locks[device_id] = threading.Lock()
|
||||||
return self.catalog_locks[device_id]
|
return self.catalog_locks[device_id]
|
||||||
|
|
||||||
def remove_channel(self, channel_id):
|
def remove_channel(self, channel_id, device_id=None):
|
||||||
"""
|
"""
|
||||||
从内存中删除指定通道(当用户在UI删除流时调用)。
|
从内存中删除指定通道(当用户在UI删除流时调用)。
|
||||||
防止parse_catalog_response的old_channel_map缓存抑制update_admin,
|
防止parse_catalog_response的old_channel_map缓存抑制update_admin,
|
||||||
导致下级重新注册后上级看不到通道数据。
|
导致下级重新注册后上级看不到通道数据。
|
||||||
幂等操作,通道不存在时不报错。
|
幂等操作,通道不存在时不报错。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel_id: 通道ID(GB28181 channelId)
|
||||||
|
device_id: 可选,指定从哪台设备下删除该通道。
|
||||||
|
当多台设备共享同一 channel_id(非法但会发生)时,
|
||||||
|
仅删指定 device_id 下的通道,不影响其他设备的同名通道。
|
||||||
"""
|
"""
|
||||||
if not channel_id:
|
if not channel_id:
|
||||||
return
|
return
|
||||||
removed_count = 0
|
removed_count = 0
|
||||||
with self.lock:
|
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):
|
def _remove_from_list(channels):
|
||||||
nonlocal removed_count
|
nonlocal removed_count
|
||||||
@ -2327,7 +2356,7 @@ class GB28181SipServer:
|
|||||||
return kept
|
return kept
|
||||||
device.channels = _remove_from_list(device.channels)
|
device.channels = _remove_from_list(device.channels)
|
||||||
if removed_count > 0:
|
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=""):
|
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}]")
|
self.logger.debug(f"[GSS] {type_icon} 通道: {ch_id} ({name}) [{status}]")
|
||||||
|
|
||||||
# 自动同步到rebekah_admin(与C++版本逻辑一致)
|
# 自动同步到rebekah_admin(与C++版本逻辑一致)
|
||||||
# 【关键修复】只在通道首次出现时调update_admin,防止多次Catalog响应并发导致重复插入
|
# 【关键修复 v3】
|
||||||
if self.admin_host and ch_id not in old_channel_map:
|
# 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(
|
threading.Thread(
|
||||||
target=channel.update_admin,
|
target=channel.update_admin,
|
||||||
args=(self,),
|
args=(self,),
|
||||||
@ -3021,15 +3063,20 @@ class GB28181SipServer:
|
|||||||
self.logger.error(f"[GSS] ❌ 清理BYE资源异常: {cleanup_e}")
|
self.logger.error(f"[GSS] ❌ 清理BYE资源异常: {cleanup_e}")
|
||||||
return False, f"bye error: {str(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云台控制指令
|
发送PTZ云台控制指令
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client_id: 设备ID
|
client_id: 设备ID
|
||||||
channel_id: 通道ID
|
channel_id: 通道ID
|
||||||
ptz_type: PTZ类型(0-停止, 1-右转, 3-上转, 5-左转, 7-下转, 9-变焦, 10-光圈, 11-聚焦)
|
ptz_type: PTZ类型(0-停止, 1-上, 2-下, 3-左, 4-右,
|
||||||
val: 速度/值
|
5-左上, 6-右上, 7-左下, 8-右下,
|
||||||
|
9-变倍+/放大, 10-变倍-/缩小,
|
||||||
|
11-聚焦+调远, 12-聚焦-调近, 13-光圈+放大, 14-光圈-缩小)
|
||||||
|
val: 速度/值 (0-255,方向取水平/垂直速度;变倍按1-15档折算)
|
||||||
|
device_ip: 设备IP(可选;分进程模式下本进程无实时注册表时由调用方传入)
|
||||||
|
device_port: 设备SIP端口(可选,配合 device_ip)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple: (success: bool, msg: str)
|
tuple: (success: bool, msg: str)
|
||||||
@ -3038,20 +3085,27 @@ class GB28181SipServer:
|
|||||||
# 查找设备和通道,并在锁内更新SN(避免竞态条件)
|
# 查找设备和通道,并在锁内更新SN(避免竞态条件)
|
||||||
with self.lock:
|
with self.lock:
|
||||||
device = self.devices.get(client_id)
|
device = self.devices.get(client_id)
|
||||||
if not device:
|
if device:
|
||||||
return False, "client not registered"
|
channel = self._find_channel(device.channels, channel_id)
|
||||||
|
if not channel:
|
||||||
|
return False, "channel not found"
|
||||||
|
|
||||||
channel = self._find_channel(device.channels, channel_id)
|
# 在锁内更新SN
|
||||||
if not channel:
|
channel.sn += 1
|
||||||
return False, "channel not found"
|
sn = channel.sn
|
||||||
|
|
||||||
# 在锁内更新SN
|
# 保存设备信息
|
||||||
channel.sn += 1
|
if device.ip:
|
||||||
sn = channel.sn
|
device_ip = device.ip
|
||||||
|
if device.port:
|
||||||
# 保存设备信息
|
device_port = device.port
|
||||||
device_ip = device.ip
|
else:
|
||||||
device_port = device.port
|
# 分进程兜底:本进程无实时注册表(如 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指令(GB28181标准Hex编码)
|
||||||
ptz_cmd = self._build_ptz_command(ptz_type, val)
|
ptz_cmd = self._build_ptz_command(ptz_type, val)
|
||||||
@ -3089,13 +3143,17 @@ class GB28181SipServer:
|
|||||||
|
|
||||||
# 发送PTZ指令
|
# 发送PTZ指令
|
||||||
addr = (device_ip, device_port)
|
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}')
|
ptz_name = ptz_names.get(ptz_type, f'类型{ptz_type}')
|
||||||
self.logger.debug(f"[GSS] 🎮 已发送PTZ指令: {channel_id} - {ptz_name} (val={val})")
|
if send_ok:
|
||||||
|
self.logger.debug(f"[GSS] 🎮 已发送PTZ指令: {channel_id} - {ptz_name} (val={val}) ptzCmd={ptz_cmd} -> {addr[0]}:{addr[1]}")
|
||||||
return True, "success"
|
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:
|
except Exception as e:
|
||||||
self.logger.error(f"[GSS] PTZ请求异常: {e}")
|
self.logger.error(f"[GSS] PTZ请求异常: {e}")
|
||||||
@ -3103,44 +3161,57 @@ class GB28181SipServer:
|
|||||||
|
|
||||||
def _build_ptz_command(self, ptz_type, val):
|
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:
|
Args:
|
||||||
ptz_type: PTZ类型
|
ptz_type: PTZ类型(与 request_ptz 编号一致)
|
||||||
val: 速度/值 (0-255)
|
val: 速度/值 (0-255)
|
||||||
|
|
||||||
Returns:
|
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} # 聚焦+/聚焦-/光圈+/光圈-
|
||||||
|
|
||||||
# 命令字节映射
|
if ptz_type == 0:
|
||||||
cmd_map = {
|
cmd_byte, pan_speed, tilt_speed, zoom_byte = 0x00, 0, 0, 0x00
|
||||||
0: 0x00, # 停止
|
elif ptz_type in dir_cmd:
|
||||||
1: 0x02, # 右转
|
cmd_byte = dir_cmd[ptz_type]
|
||||||
2: 0x0A, # 右上
|
pan_speed = speed if cmd_byte & 0x03 else 0 # 含左右方向才给水平速度
|
||||||
3: 0x08, # 上转
|
tilt_speed = speed if cmd_byte & 0x0C else 0 # 含上下方向才给垂直速度
|
||||||
4: 0x09, # 左上
|
zoom_byte = 0x00
|
||||||
5: 0x04, # 左转
|
elif ptz_type in zoom_cmd:
|
||||||
6: 0x0C, # 左下
|
cmd_byte = zoom_cmd[ptz_type]
|
||||||
7: 0x10, # 下转
|
pan_speed, tilt_speed = 0, 0
|
||||||
8: 0x06, # 右下
|
zoom_byte = zoom_level << 4
|
||||||
9: 0x20, # 变焦
|
elif ptz_type in fi_cmd:
|
||||||
10: 0x40, # 光圈
|
cmd_byte = fi_cmd[ptz_type]
|
||||||
11: 0x10 # 聚焦
|
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] [校验]
|
# 转换为大写Hex字符串
|
||||||
ptz_bytes = bytes([0xA5, 0x0F, 0x01, cmd_byte, speed, speed, 0x00])
|
|
||||||
|
|
||||||
# 计算校验和
|
|
||||||
checksum = sum(ptz_bytes) & 0xFF
|
|
||||||
ptz_bytes += bytes([checksum])
|
|
||||||
|
|
||||||
# 转换为Hex字符串
|
|
||||||
return ptz_bytes.hex().upper()
|
return ptz_bytes.hex().upper()
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from framework.settings import BASE_DIR
|
from framework.settings import BASE_DIR
|
||||||
|
from monitor_runtime.paths import RESOURCE_ROOT
|
||||||
|
|
||||||
LANG_UI_DICT = {}
|
LANG_UI_DICT = {}
|
||||||
LANG_UI_JSON_CACHE = {}
|
LANG_UI_JSON_CACHE = {}
|
||||||
@ -34,7 +35,7 @@ def _lang_files_mtime():
|
|||||||
filename = language_config.get("filename", "")
|
filename = language_config.get("filename", "")
|
||||||
if not filename:
|
if not filename:
|
||||||
continue
|
continue
|
||||||
filepath = os.path.join(BASE_DIR, filename)
|
filepath = os.path.join(RESOURCE_ROOT, filename)
|
||||||
if os.path.exists(filepath):
|
if os.path.exists(filepath):
|
||||||
mtimes.append(os.path.getmtime(filepath))
|
mtimes.append(os.path.getmtime(filepath))
|
||||||
settings_path = os.path.join(BASE_DIR, "settings.json")
|
settings_path = os.path.join(BASE_DIR, "settings.json")
|
||||||
@ -61,7 +62,7 @@ def reload_lang_dict(force=False):
|
|||||||
filename = language_config.get("filename", "")
|
filename = language_config.get("filename", "")
|
||||||
if not filename:
|
if not filename:
|
||||||
continue
|
continue
|
||||||
filepath_language = os.path.join(BASE_DIR, filename)
|
filepath_language = os.path.join(RESOURCE_ROOT, filename)
|
||||||
try:
|
try:
|
||||||
language_data = _load_json_file(filepath_language)
|
language_data = _load_json_file(filepath_language)
|
||||||
lang_ui_dict = _flatten_lang_dict(language_data)
|
lang_ui_dict = _flatten_lang_dict(language_data)
|
||||||
|
|||||||
@ -66,10 +66,18 @@ class MediaServerManager(object):
|
|||||||
if not cfg or not os.path.isfile(cfg):
|
if not cfg or not os.path.isfile(cfg):
|
||||||
return False, "mediaStartConfigPath 无效或文件不存在: %s" % (cfg or "(空)")
|
return False, "mediaStartConfigPath 无效或文件不存在: %s" % (cfg or "(空)")
|
||||||
if self.api_alive():
|
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]
|
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))
|
logger.info("启动 ZLM: %s", " ".join(cmd))
|
||||||
try:
|
try:
|
||||||
kwargs = {"cwd": work_dir, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
|
kwargs = {"cwd": work_dir, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
|
||||||
@ -88,24 +96,6 @@ class MediaServerManager(object):
|
|||||||
logger.exception("启动 ZLM 失败")
|
logger.exception("启动 ZLM 失败")
|
||||||
return False, str(e)
|
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):
|
def stop(self):
|
||||||
from app.services.lifecycle import is_service_leader
|
from app.services.lifecycle import is_service_leader
|
||||||
if not is_service_leader():
|
if not is_service_leader():
|
||||||
@ -124,10 +114,6 @@ class MediaServerManager(object):
|
|||||||
pass
|
pass
|
||||||
self._proc = None
|
self._proc = None
|
||||||
|
|
||||||
if self.api_alive():
|
|
||||||
self._kill_by_image(exe)
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
if self.api_alive():
|
if self.api_alive():
|
||||||
return False, "流媒体服务仍在运行,请检查是否有其他进程占用"
|
return False, "流媒体服务仍在运行,请检查是否有其他进程占用"
|
||||||
return True, "流媒体服务已停止"
|
return True, "流媒体服务已停止"
|
||||||
|
|||||||
@ -5,7 +5,8 @@ import os
|
|||||||
from pathlib import Path
|
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(
|
MANIFEST_PATH = Path(
|
||||||
os.environ.get("MONITOR_TRUSTED_MODEL_MANIFEST", PROJECT_ROOT / ".trusted-models.json")
|
os.environ.get("MONITOR_TRUSTED_MODEL_MANIFEST", PROJECT_ROOT / ".trusted-models.json")
|
||||||
).resolve()
|
).resolve()
|
||||||
|
|||||||
@ -12,7 +12,8 @@ import base64
|
|||||||
from pathlib import Path
|
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(
|
RUNTIME_SECRETS_FILE = Path(
|
||||||
os.environ.get("MONITOR_RUNTIME_SECRETS_FILE", PROJECT_ROOT / ".runtime-secrets.json")
|
os.environ.get("MONITOR_RUNTIME_SECRETS_FILE", PROJECT_ROOT / ".runtime-secrets.json")
|
||||||
).resolve()
|
).resolve()
|
||||||
|
|||||||
@ -16,6 +16,12 @@ from datetime import datetime
|
|||||||
from app.models import StreamModel, ZoneModel, AlarmModel
|
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):
|
def _parse_page_params(request, default_ps=12):
|
||||||
page = request.GET.get('p', 1)
|
page = request.GET.get('p', 1)
|
||||||
page_size = request.GET.get('ps', default_ps)
|
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})
|
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):
|
def analysis_openStart(request):
|
||||||
ret = False
|
ret = False
|
||||||
msg = LANG_VIEWS_T(request, "msg_unknown_error")
|
msg = LANG_VIEWS_T(request, "msg_unknown_error")
|
||||||
|
|||||||
@ -109,6 +109,14 @@ def _parse_control_detect_rate(params):
|
|||||||
return interval, frames
|
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):
|
def _control_to_dict(z):
|
||||||
algos = []
|
algos = []
|
||||||
try:
|
try:
|
||||||
@ -128,6 +136,7 @@ def _control_to_dict(z):
|
|||||||
"loiter_threshold": z.loiter_threshold,
|
"loiter_threshold": z.loiter_threshold,
|
||||||
"detect_interval_sec": interval,
|
"detect_interval_sec": interval,
|
||||||
"detect_frames": frames,
|
"detect_frames": frames,
|
||||||
|
"alarm_repeat_sec": max(0.0, float(getattr(z, "alarm_repeat_sec", 30) or 0)),
|
||||||
"color": z.color,
|
"color": z.color,
|
||||||
"line_a": getattr(z, "line_a", "") or "",
|
"line_a": getattr(z, "line_a", "") or "",
|
||||||
"line_b": getattr(z, "line_b", "") or "",
|
"line_b": getattr(z, "line_b", "") or "",
|
||||||
@ -399,6 +408,7 @@ def control_openAdd(request):
|
|||||||
loiter_threshold=int(params.get("loiter_threshold", 0)),
|
loiter_threshold=int(params.get("loiter_threshold", 0)),
|
||||||
detect_interval_sec=detect_interval_sec,
|
detect_interval_sec=detect_interval_sec,
|
||||||
detect_frames=detect_frames,
|
detect_frames=detect_frames,
|
||||||
|
alarm_repeat_sec=_parse_alarm_repeat(params),
|
||||||
color=params.get("color", "#169F85"),
|
color=params.get("color", "#169F85"),
|
||||||
line_a=params.get("line_a", ""),
|
line_a=params.get("line_a", ""),
|
||||||
line_b=params.get("line_b", ""),
|
line_b=params.get("line_b", ""),
|
||||||
@ -457,6 +467,8 @@ def control_openEdit(request):
|
|||||||
interval, frames = _parse_control_detect_rate(params)
|
interval, frames = _parse_control_detect_rate(params)
|
||||||
z.detect_interval_sec = interval
|
z.detect_interval_sec = interval
|
||||||
z.detect_frames = frames
|
z.detect_frames = frames
|
||||||
|
if "alarm_repeat_sec" in params:
|
||||||
|
z.alarm_repeat_sec = _parse_alarm_repeat(params)
|
||||||
if "color" in params:
|
if "color" in params:
|
||||||
z.color = params["color"]
|
z.color = params["color"]
|
||||||
if "line_a" in params:
|
if "line_a" in params:
|
||||||
|
|||||||
@ -82,14 +82,17 @@ def api_on_media_update_stream(request):
|
|||||||
|
|
||||||
now_date = datetime.now()
|
now_date = datetime.now()
|
||||||
|
|
||||||
# 【关键修复】按code的细粒度锁,彻底防止并发插入重复编号
|
# 【关键修复 v2】GB28181 多设备共享同一 channel code 时,
|
||||||
# 不同code完全并发,同一code串行化
|
# 以 (code + camera_device_id) 复合键查找/锁:
|
||||||
# 注意:SQLite写锁是数据库级的,不能用transaction.atomic(),否则不同code会互相阻塞报"database is locked"
|
# - 不同 device_id 的同名通道各自独立创建/更新记录
|
||||||
code_lock = _get_code_lock(name)
|
# - 非 GB28181 场景 camera_device_id 为空字符串,退化为纯 code 匹配
|
||||||
|
# 注意:SQLite写锁是数据库级的,不能用transaction.atomic(),否则不同复合键会互相阻塞
|
||||||
|
compound_key = f"{name}::{clientId}"
|
||||||
|
code_lock = _get_code_lock(compound_key)
|
||||||
with code_lock:
|
with code_lock:
|
||||||
stream = StreamModel.objects.filter(code=name).first()
|
stream = StreamModel.objects.filter(code=name, camera_device_id=clientId).first()
|
||||||
if stream:
|
if stream:
|
||||||
# 编辑(不修改nickname,nickname允许用户自定义)
|
# 编辑:不修改 nickname(用户自定义)
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
# 新增
|
# 新增
|
||||||
@ -100,7 +103,7 @@ def api_on_media_update_stream(request):
|
|||||||
stream.app = app
|
stream.app = app
|
||||||
stream.name = name
|
stream.name = name
|
||||||
stream.create_time = now_date
|
stream.create_time = now_date
|
||||||
stream.add_type = 0
|
stream.add_type = 21 if pullStreamType == 21 else 0 # 21=GB28181 自动添加标记
|
||||||
stream.state = 0
|
stream.state = 0
|
||||||
stream.nickname = cameraName
|
stream.nickname = cameraName
|
||||||
stream.remark = ""
|
stream.remark = ""
|
||||||
@ -161,10 +164,17 @@ def api_on_media_delete_stream(request):
|
|||||||
if not code:
|
if not code:
|
||||||
raise Exception("The parameter code is invalid")
|
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:
|
if stream:
|
||||||
stream.delete()
|
stream.delete()
|
||||||
g_gb28181SipServer.remove_channel(code)
|
g_gb28181SipServer.remove_channel(code, device_id=camera_device_id or None)
|
||||||
ret = True
|
ret = True
|
||||||
msg = "success"
|
msg = "success"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -99,9 +99,9 @@ def api_openStartRecordVideo(request):
|
|||||||
return f_responseJson({"code": 0, "msg": "stream not found"})
|
return f_responseJson({"code": 0, "msg": "stream not found"})
|
||||||
stream.record_enable = 1
|
stream.record_enable = 1
|
||||||
stream.save(update_fields=["record_enable"])
|
stream.save(update_fields=["record_enable"])
|
||||||
from app.recording.manager import get_recording_manager
|
# 同进程(嵌入式)立即同步启动;分进程则由领导者进程的同步循环异步接管
|
||||||
ok, info = get_recording_manager().start_stream(stream)
|
_sync_stream_recording(stream)
|
||||||
return f_responseJson({"code": 1000 if ok else 0, "msg": info})
|
return f_responseJson({"code": 1000, "msg": "ok"})
|
||||||
|
|
||||||
|
|
||||||
def api_openStopRecordVideo(request):
|
def api_openStopRecordVideo(request):
|
||||||
@ -116,9 +116,8 @@ def api_openStopRecordVideo(request):
|
|||||||
return f_responseJson({"code": 0, "msg": "stream not found"})
|
return f_responseJson({"code": 0, "msg": "stream not found"})
|
||||||
stream.record_enable = 0
|
stream.record_enable = 0
|
||||||
stream.save(update_fields=["record_enable"])
|
stream.save(update_fields=["record_enable"])
|
||||||
from app.recording.manager import get_recording_manager
|
_sync_stream_recording(stream)
|
||||||
ok, info = get_recording_manager().stop_stream(stream.id)
|
return f_responseJson({"code": 1000, "msg": "ok"})
|
||||||
return f_responseJson({"code": 1000 if ok else 0, "msg": info})
|
|
||||||
|
|
||||||
|
|
||||||
def _scan_disk_recordings(stream_id=None):
|
def _scan_disk_recordings(stream_id=None):
|
||||||
|
|||||||
@ -891,6 +891,11 @@ def api_openDel(request):
|
|||||||
if stream:
|
if stream:
|
||||||
__ret, __msg = GlobalUtils.delStreamProxy(stream, lang=lang)
|
__ret, __msg = GlobalUtils.delStreamProxy(stream, lang=lang)
|
||||||
if stream.delete():
|
if stream.delete():
|
||||||
|
# 同步清理内存里的该通道,避免 Catalog 刷新后自动恢复
|
||||||
|
g_gb28181SipServer.remove_channel(
|
||||||
|
stream_code,
|
||||||
|
device_id=stream.camera_device_id or None,
|
||||||
|
)
|
||||||
ret = True
|
ret = True
|
||||||
msg = LANG_VIEWS_T(request, "msg_success")
|
msg = LANG_VIEWS_T(request, "msg_success")
|
||||||
else:
|
else:
|
||||||
@ -904,7 +909,7 @@ def api_openDel(request):
|
|||||||
for stream in streams:
|
for stream in streams:
|
||||||
__ret, __msg = GlobalUtils.delStreamProxy(stream, lang=lang)
|
__ret, __msg = GlobalUtils.delStreamProxy(stream, lang=lang)
|
||||||
if stream.delete():
|
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
|
success_count += 1
|
||||||
else:
|
else:
|
||||||
error_count += 1
|
error_count += 1
|
||||||
@ -1042,24 +1047,39 @@ def api_openPtz(request):
|
|||||||
g_logger.info("StreamView.openPtz() params:%s" % str(params))
|
g_logger.info("StreamView.openPtz() params:%s" % str(params))
|
||||||
|
|
||||||
code = params.get("code", "").strip()
|
code = params.get("code", "").strip()
|
||||||
ptzType = int(params.get("ptzType", 0))
|
try:
|
||||||
val = int(params.get("val", 0))
|
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:
|
if ptzType < 0 or ptzType > 14:
|
||||||
raise Exception(LANG_VIEWS_T(request, "ptz_type_range_error"))
|
msg = 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")
|
|
||||||
else:
|
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:
|
else:
|
||||||
msg = __check_msg
|
msg = __check_msg
|
||||||
else:
|
else:
|
||||||
|
|||||||
10
config.json
10
config.json
@ -8,7 +8,7 @@
|
|||||||
"mediaStartPath": "zlm\\bin.x86.windows10\\monitor_zlm.exe",
|
"mediaStartPath": "zlm\\bin.x86.windows10\\monitor_zlm.exe",
|
||||||
"mediaStartConfigPath": "zlm\\bin.x86.windows10\\config.ini",
|
"mediaStartConfigPath": "zlm\\bin.x86.windows10\\config.ini",
|
||||||
"autoStartMedia": true,
|
"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",
|
"fontPath": "static\\fonts\\tsimhei.ttf",
|
||||||
"uploadDir": "static\\upload",
|
"uploadDir": "static\\upload",
|
||||||
"storageDir": "static\\storage",
|
"storageDir": "static\\storage",
|
||||||
@ -23,16 +23,16 @@
|
|||||||
"analysisConfThreshold": 0.4,
|
"analysisConfThreshold": 0.4,
|
||||||
"analysisProcessMode": 1,
|
"analysisProcessMode": 1,
|
||||||
"analysisSharedInference": true,
|
"analysisSharedInference": true,
|
||||||
"analysisInferenceWorkers": 2,
|
"analysisInferenceWorkers": 1,
|
||||||
"recordingEnabled": false,
|
"recordingEnabled": true,
|
||||||
"recordingSegmentSeconds": 600,
|
"recordingSegmentSeconds": 600,
|
||||||
"recordingRetainDays": 7,
|
"recordingRetainDays": 7,
|
||||||
"recordingRetainGb": 0.0,
|
"recordingRetainGb": 0.0,
|
||||||
"sipServer": {
|
"sipServer": {
|
||||||
"sipServerIp": "192.168.1.7",
|
"sipServerIp": "192.168.1.60",
|
||||||
"sipServerPort": 15060,
|
"sipServerPort": 15060,
|
||||||
"sipTransferMode": 0,
|
"sipTransferMode": 0,
|
||||||
"sipServerId": "34020000009000009999",
|
"sipServerId": "34020000002000000001",
|
||||||
"sipServerRealm": "3402000000",
|
"sipServerRealm": "3402000000",
|
||||||
"sipServerTimeout": 300,
|
"sipServerTimeout": 300,
|
||||||
"sipServerExpiry": 1800,
|
"sipServerExpiry": 1800,
|
||||||
|
|||||||
24
deploy/windows/DEPLOYMENT.md
Normal file
24
deploy/windows/DEPLOYMENT.md
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Monitor Windows 安装说明
|
||||||
|
|
||||||
|
适用 Windows 10/11 x64、CPU 推理。安装程序包含所需 Python 运行环境、FFmpeg 和 ZLM,无需另外安装这些工具。程序未做代码签名,Windows 可能显示未知发布者提示。
|
||||||
|
|
||||||
|
1. 使用管理员权限运行 Monitor-Setup-x64.exe。默认程序目录为 Program Files\Monitor。安装时填写实际日常使用的 Windows 账号,只有该账号、管理员和 SYSTEM 获准访问运行数据。
|
||||||
|
2. 使用上述日常账号双击桌面 Monitor。首次启动自动打开本机初始化网页;不要分享地址中的一次性凭证。
|
||||||
|
3. 导出机器申请文件交给厂商,在本机网页导入厂商签发的授权。设置管理员用户名和至少 12 位密码,选择接入摄像头/局域网的网卡 IP。没有固定默认密码。
|
||||||
|
4. 服务自动重启后,本机通过 http://127.0.0.1:10001 登录;同一局域网的其他电脑访问 http://部署电脑IP:10001。视频分析需登录后手动启动。
|
||||||
|
|
||||||
|
没有互联网连接也能安装和授权;局域网网卡仍需有效 IPv4 地址。安装防火墙规则只允许专用/域网络及本地子网。不要将管理端映射到公网。改变端口后由管理员同步调整防火墙。
|
||||||
|
|
||||||
|
关闭浏览器不会停止程序。右键托盘 Monitor,可打开网页、查看状态、打开日志、重启或退出。重复双击只打开已有实例。退出会停止本程序的 Web、分析、录像、SIP 和 ZLM;不会按名称结束其他软件的流媒体进程。首次启动失败时检查 ProgramData\Monitor\log。
|
||||||
|
|
||||||
|
运行数据位于 ProgramData\Monitor,包括数据库、配置、凭据密钥、授权、模型和录像。升级前退出程序并备份整个数据目录;数据库结构升级前还会自动备份。卸载默认保留运行数据。不要单独复制数据库而丢弃加密密钥。
|
||||||
|
|
||||||
|
安装包不附带模型、摄像头配置或开发数据。导入 .pt 模型前应确认来源;受信任模型登记示例(无需 Python):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& 'C:\Program Files\Monitor\Monitor.exe' --trust-model 'D:\Models\reviewed.pt' --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
机器授权不绑定 IP/MAC;换硬件或重装系统后可能需要重新申请。授权过期、错误或明显时间回拨会停止业务,保留本机登录和 /license 续期入口。禁止通过修改系统时间绕过授权。
|
||||||
|
|
||||||
|
本包通过开发机上的冻结 EXE 自检和隔离服务测试。干净 Windows 10/11、普通用户安装部署、第二台局域网电脑及真实 RTSP/GB28181 摄像头、录像和实际模型仍需现场验收。商业分发前须由发行方核实第三方软件和模型的许可。
|
||||||
167
deploy/windows/Monitor.iss
Normal file
167
deploy/windows/Monitor.iss
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
#ifndef Payload
|
||||||
|
#error Payload is required
|
||||||
|
#endif
|
||||||
|
#ifndef Output
|
||||||
|
#error Output is required
|
||||||
|
#endif
|
||||||
|
[Setup]
|
||||||
|
AppId={{65B3E62C-937C-4FAA-B1F0-2F1C3D2B9955}
|
||||||
|
AppName=Monitor
|
||||||
|
AppVersion=1.003.1
|
||||||
|
DefaultDirName={autopf}\Monitor
|
||||||
|
DefaultGroupName=Monitor
|
||||||
|
OutputDir={#Output}
|
||||||
|
OutputBaseFilename=Monitor-Setup-x64
|
||||||
|
ArchitecturesAllowed=x64compatible
|
||||||
|
ArchitecturesInstallIn64BitMode=x64compatible
|
||||||
|
MinVersion=10.0
|
||||||
|
PrivilegesRequired=admin
|
||||||
|
Compression=zip/7
|
||||||
|
SolidCompression=no
|
||||||
|
WizardStyle=modern
|
||||||
|
UninstallDisplayIcon={app}\Monitor.exe
|
||||||
|
CloseApplications=no
|
||||||
|
RestartApplications=no
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
Source: "{#Payload}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
|
|
||||||
|
[Dirs]
|
||||||
|
Name: "{commonappdata}\Monitor"
|
||||||
|
|
||||||
|
[Icons]
|
||||||
|
Name: "{autodesktop}\Monitor"; Filename: "{app}\Monitor.exe"; WorkingDir: "{app}"
|
||||||
|
Name: "{group}\Monitor"; Filename: "{app}\Monitor.exe"; WorkingDir: "{app}"
|
||||||
|
|
||||||
|
[Run]
|
||||||
|
Filename: "{app}\Monitor.exe"; Description: "Start Monitor"; Flags: nowait postinstall skipifsilent runasoriginaluser
|
||||||
|
|
||||||
|
[Code]
|
||||||
|
var OwnerPage: TInputQueryWizardPage;
|
||||||
|
|
||||||
|
procedure InitializeWizard();
|
||||||
|
begin
|
||||||
|
OwnerPage := CreateInputQueryPage(wpSelectDir, 'Monitor Windows account',
|
||||||
|
'Choose the Windows account that will run Monitor',
|
||||||
|
'Only this account, Administrators and SYSTEM can access the database and keys. Use DOMAIN\username or COMPUTER\username.');
|
||||||
|
OwnerPage.Add('Runtime account:', False);
|
||||||
|
OwnerPage.Values[0] := GetPreviousData('MonitorOwner', GetEnv('USERDOMAIN') + '\' + GetUserNameString);
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure RegisterPreviousData(PreviousDataKey: Integer);
|
||||||
|
begin
|
||||||
|
SetPreviousData(PreviousDataKey, 'MonitorOwner', OwnerPage.Values[0]);
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure ConfigureDataAcl();
|
||||||
|
var Code: Integer; UserName: String;
|
||||||
|
begin
|
||||||
|
UserName := Trim(OwnerPage.Values[0]);
|
||||||
|
if UserName = '' then RaiseException('A runtime Windows account is required');
|
||||||
|
if Pos('"', UserName) > 0 then RaiseException('Unsupported Windows user name');
|
||||||
|
if not Exec(ExpandConstant('{sys}\icacls.exe'),
|
||||||
|
'"' + ExpandConstant('{commonappdata}\Monitor') + '" /inheritance:r /grant:r "' +
|
||||||
|
UserName + ':(OI)(CI)M" "*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F"',
|
||||||
|
'', SW_HIDE, ewWaitUntilTerminated, Code) or (Code <> 0) then
|
||||||
|
RaiseException('Unable to restrict Monitor data directory permissions');
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure AddFirewallRule(Arguments: String);
|
||||||
|
var Code: Integer;
|
||||||
|
begin
|
||||||
|
if not Exec(ExpandConstant('{sys}\netsh.exe'), Arguments, '', SW_HIDE, ewWaitUntilTerminated, Code) or (Code <> 0) then
|
||||||
|
RaiseException('Unable to configure Monitor firewall rules. Check Windows Firewall and the installation log.');
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure Firewall(AddRules: Boolean);
|
||||||
|
var Code: Integer; ExePath: String;
|
||||||
|
begin
|
||||||
|
Exec(ExpandConstant('{sys}\netsh.exe'),'advfirewall firewall delete rule name="Monitor Web SIP"','',SW_HIDE,ewWaitUntilTerminated,Code);
|
||||||
|
Exec(ExpandConstant('{sys}\netsh.exe'),'advfirewall firewall delete rule name="Monitor ZLM TCP"','',SW_HIDE,ewWaitUntilTerminated,Code);
|
||||||
|
Exec(ExpandConstant('{sys}\netsh.exe'),'advfirewall firewall delete rule name="Monitor ZLM UDP"','',SW_HIDE,ewWaitUntilTerminated,Code);
|
||||||
|
if not AddRules then Exit;
|
||||||
|
ExePath := ExpandConstant('{app}\Monitor.exe');
|
||||||
|
AddFirewallRule(
|
||||||
|
'advfirewall firewall add rule name="Monitor Web SIP" dir=in action=allow program="' + ExePath +
|
||||||
|
'" protocol=TCP localport=10001,15060 profile=private,domain remoteip=localsubnet');
|
||||||
|
AddFirewallRule(
|
||||||
|
'advfirewall firewall add rule name="Monitor Web SIP" dir=in action=allow program="' + ExePath +
|
||||||
|
'" protocol=UDP localport=15060 profile=private,domain remoteip=localsubnet');
|
||||||
|
ExePath := ExpandConstant('{app}\_internal\zlm\monitor_zlm.exe');
|
||||||
|
AddFirewallRule(
|
||||||
|
'advfirewall firewall add rule name="Monitor ZLM TCP" dir=in action=allow program="' + ExePath +
|
||||||
|
'" protocol=TCP localport=10002,10554,10935,20002-40000 profile=private,domain remoteip=localsubnet');
|
||||||
|
AddFirewallRule(
|
||||||
|
'advfirewall firewall add rule name="Monitor ZLM UDP" dir=in action=allow program="' + ExePath +
|
||||||
|
'" protocol=UDP localport=20002-40000 profile=private,domain remoteip=localsubnet');
|
||||||
|
end;
|
||||||
|
|
||||||
|
function BackupRuntimeData(): String;
|
||||||
|
var Root, Backup, Base: String; Files: TArrayOfString; I, Counter: Integer;
|
||||||
|
begin
|
||||||
|
Result := '';
|
||||||
|
Root := ExpandConstant('{commonappdata}\Monitor');
|
||||||
|
if not DirExists(Root) then Exit;
|
||||||
|
if not FileExists(Root + '\monitor.sqlite3') then Exit;
|
||||||
|
Base := Root + '\backups\installer-' + GetDateTimeString('yyyymmdd-hhnnss', '-', ':');
|
||||||
|
Backup := Base;
|
||||||
|
Counter := 0;
|
||||||
|
while DirExists(Backup) do begin
|
||||||
|
Counter := Counter + 1;
|
||||||
|
Backup := Base + '-' + IntToStr(Counter);
|
||||||
|
end;
|
||||||
|
if not ForceDirectories(Backup) then begin
|
||||||
|
Result := 'Cannot create the pre-upgrade data backup directory.';
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
SetArrayLength(Files, 11);
|
||||||
|
Files[0] := 'monitor.sqlite3';
|
||||||
|
Files[1] := 'monitor.sqlite3-wal';
|
||||||
|
Files[2] := 'monitor.sqlite3-shm';
|
||||||
|
Files[3] := 'config.json';
|
||||||
|
Files[4] := 'settings.json';
|
||||||
|
Files[5] := 'network.json';
|
||||||
|
Files[6] := '.runtime-secrets.json';
|
||||||
|
Files[7] := '.trusted-models.json';
|
||||||
|
Files[8] := 'license.json';
|
||||||
|
Files[9] := 'license-clock.json';
|
||||||
|
Files[10] := 'zlm-template.ini';
|
||||||
|
for I := 0 to GetArrayLength(Files)-1 do
|
||||||
|
if FileExists(Root + '\' + Files[I]) then
|
||||||
|
if not CopyFile(Root + '\' + Files[I], Backup + '\' + Files[I], True) then begin
|
||||||
|
Result := 'Pre-upgrade backup failed for ' + Files[I] + '. Installation stopped to protect existing data.';
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
function PrepareToInstall(var NeedsRestart: Boolean): String;
|
||||||
|
var Code: Integer;
|
||||||
|
begin
|
||||||
|
Result := '';
|
||||||
|
if FileExists(ExpandConstant('{app}\Monitor.exe')) then begin
|
||||||
|
if not Exec(ExpandConstant('{app}\Monitor.exe'),'--stop','',SW_HIDE,ewWaitUntilTerminated,Code) or (Code <> 0) then begin
|
||||||
|
Result := 'Please exit Monitor before upgrading.';
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
Result := BackupRuntimeData();
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure CurStepChanged(CurStep: TSetupStep);
|
||||||
|
begin
|
||||||
|
if CurStep = ssPostInstall then begin
|
||||||
|
ConfigureDataAcl();
|
||||||
|
Firewall(True);
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
function InitializeUninstall(): Boolean;
|
||||||
|
var Code: Integer;
|
||||||
|
begin
|
||||||
|
Result := Exec(ExpandConstant('{app}\Monitor.exe'),'--stop','',SW_HIDE,ewWaitUntilTerminated,Code) and (Code = 0);
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||||
|
begin
|
||||||
|
if CurUninstallStep = usUninstall then Firewall(False);
|
||||||
|
end;
|
||||||
82
deploy/windows/README.md
Normal file
82
deploy/windows/README.md
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
# Monitor Windows 部署
|
||||||
|
|
||||||
|
## 客户安装与使用
|
||||||
|
|
||||||
|
支持 Windows 10/11 x64 的 CPU 部署。运行 `Monitor-Setup-x64.exe` 安装,随后双击桌面 Monitor。安装时需要管理员权限,安装器会要求指定日常运行的 Windows 账号(默认当前安装账号);如果使用了另一个管理员账号授权安装,应填写实际操作员的账号。日常启动使用该指定账号。首次启动只监听本机:导出机器申请、导入厂商签名授权、设置至少 12 位管理员密码并选择局域网网卡。完成后自动重启服务,浏览器访问 `http://127.0.0.1:10001/login`;其他电脑使用部署电脑的 IP。
|
||||||
|
|
||||||
|
程序目录在 `Program Files\Monitor`,运行数据在 `ProgramData\Monitor`。多 Windows 用户共享同一安装需要系统管理员明确授予数据目录权限,安装器不向所有本机用户开放密钥。不要把程序目录当作数据备份。
|
||||||
|
|
||||||
|
托盘可访问网页、查看状态、打开日志、重启和退出。关闭浏览器不会停止服务;退出托盘或注销 Windows 用户会结束服务。本版不是 Windows 系统服务,不自动开机启动。视频分析需登录后手动开始,已配置的录像和视频代理由后台启动。
|
||||||
|
|
||||||
|
初始防火墙规则仅作用于专用/域网络、本地子网与本程序。默认端口:Web TCP 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 推理,仅验证执行链路,不代表模型准确率验收。
|
||||||
20
deploy/windows/VALIDATION.md
Normal file
20
deploy/windows/VALIDATION.md
Normal file
@ -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 或系统临时目录,没有迁移、打包或修改现有开发数据库与摄像头数据。
|
||||||
4
deploy/windows/build-tools.lock.txt
Normal file
4
deploy/windows/build-tools.lock.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
Cython==3.1.3
|
||||||
|
pystray==0.19.5
|
||||||
|
whitenoise==6.9.0
|
||||||
|
wheel==0.45.1
|
||||||
34
deploy/windows/build.ps1
Normal file
34
deploy/windows/build.ps1
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)][string]$PublicKey,
|
||||||
|
[string]$Iscc,
|
||||||
|
[string]$Vcvars,
|
||||||
|
[switch]$PrepareEnvironment
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
|
||||||
|
$buildPython = Join-Path $projectRoot '.build-venv/Scripts/python.exe'
|
||||||
|
if ($PrepareEnvironment) {
|
||||||
|
$sourcePython = Join-Path $projectRoot '.venv/Scripts/python.exe'
|
||||||
|
if (-not (Test-Path -LiteralPath $sourcePython)) {
|
||||||
|
throw 'Create the CPU project environment from requirements-windows-cpu.lock.txt first.'
|
||||||
|
}
|
||||||
|
if (-not (Test-Path -LiteralPath $buildPython)) {
|
||||||
|
& $sourcePython -m venv (Join-Path $projectRoot '.build-venv')
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Build venv creation failed' }
|
||||||
|
}
|
||||||
|
# Copy a pinned runtime snapshot; no symlinks and no changes to the developer environment.
|
||||||
|
$sourceLib = Join-Path $projectRoot '.venv/Lib/site-packages'
|
||||||
|
$buildLib = Join-Path $projectRoot '.build-venv/Lib/site-packages'
|
||||||
|
& $sourcePython -c 'import shutil,sys;shutil.copytree(sys.argv[1],sys.argv[2],dirs_exist_ok=True)' $sourceLib $buildLib
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Runtime snapshot failed' }
|
||||||
|
& $buildPython -m pip install --no-deps -r (Join-Path $PSScriptRoot 'build-tools.lock.txt')
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Build tools installation failed' }
|
||||||
|
}
|
||||||
|
if (-not (Test-Path -LiteralPath $buildPython)) { throw 'Use -PrepareEnvironment for the first build.' }
|
||||||
|
& $buildPython -m pip check
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Dependency check failed' }
|
||||||
|
$buildArgs = @((Join-Path $PSScriptRoot 'build.py'),'--public-key',(Resolve-Path -LiteralPath $PublicKey).Path)
|
||||||
|
if ($Iscc) { $buildArgs += @('--iscc',$Iscc) }
|
||||||
|
if ($Vcvars) { $buildArgs += @('--vcvars',$Vcvars) }
|
||||||
|
& $buildPython @buildArgs
|
||||||
|
exit $LASTEXITCODE
|
||||||
337
deploy/windows/build.py
Normal file
337
deploy/windows/build.py
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
"""Reproducible Windows release build. Run with the independent .build-venv Python."""
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import hashlib
|
||||||
|
import importlib.metadata
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
WORK = ROOT / '.windows-build'
|
||||||
|
STAGE = WORK / 'stage'
|
||||||
|
OWN = ('app', 'framework', 'monitor_runtime')
|
||||||
|
|
||||||
|
def run(args, **kwargs):
|
||||||
|
print('RUN', subprocess.list2cmdline([str(x) for x in args]), flush=True)
|
||||||
|
subprocess.run([str(x) for x in args], check=True, **kwargs)
|
||||||
|
|
||||||
|
def clean(directory):
|
||||||
|
directory = directory.resolve()
|
||||||
|
if not directory.is_relative_to(WORK.resolve()) or directory == WORK.resolve():
|
||||||
|
raise ValueError('Refusing to remove a path outside build staging')
|
||||||
|
if directory.exists():
|
||||||
|
shutil.rmtree(directory)
|
||||||
|
directory.mkdir(parents=True)
|
||||||
|
|
||||||
|
def compiler_environment(vcvars):
|
||||||
|
if not vcvars:
|
||||||
|
candidates = list(Path('C:/Program Files (x86)/Microsoft Visual Studio').glob('*/BuildTools/VC/Auxiliary/Build/vcvars64.bat'))
|
||||||
|
candidates += list(Path('C:/Program Files/Microsoft Visual Studio').glob('*/*/VC/Auxiliary/Build/vcvars64.bat'))
|
||||||
|
if not candidates:
|
||||||
|
raise RuntimeError('Install Visual Studio C++ Build Tools and Windows SDK, or pass --vcvars')
|
||||||
|
vcvars = str(sorted(candidates)[-1])
|
||||||
|
# Generated batch contains only validated local paths, never arbitrary shell input.
|
||||||
|
if any(c in vcvars for c in '"\r\n%'):
|
||||||
|
raise ValueError('Unsupported vcvars path')
|
||||||
|
script = WORK / 'compiler-env.cmd'
|
||||||
|
script.write_text('@echo off\r\ncall "' + vcvars + '" >nul\r\nif errorlevel 1 exit /b 1\r\nset\r\n', encoding='utf-8')
|
||||||
|
raw = subprocess.check_output(['cmd.exe', '/d', '/c', str(script)])
|
||||||
|
env = {k.upper(): v for k, v in os.environ.items()}
|
||||||
|
for line in raw.decode('mbcs', errors='replace').splitlines():
|
||||||
|
if '=' in line:
|
||||||
|
key, value = line.split('=', 1)
|
||||||
|
env[key.upper()] = value
|
||||||
|
compiler_bin = Path(env['VCTOOLSINSTALLDIR']) / 'bin' / 'Hostx64' / 'x64'
|
||||||
|
sdk_bin = Path(env['WINDOWSSDKVERBINPATH']) / 'x64'
|
||||||
|
env['PATH'] = str(compiler_bin) + ';' + str(sdk_bin) + ';' + env['PATH']
|
||||||
|
if not (compiler_bin / 'cl.exe').exists():
|
||||||
|
raise RuntimeError('C++ compiler is missing: ' + str(compiler_bin))
|
||||||
|
env['DISTUTILS_USE_SDK'] = '1'
|
||||||
|
env['MSSdk'] = '1'
|
||||||
|
return env
|
||||||
|
|
||||||
|
def verify_environment():
|
||||||
|
if sys.version_info[:3] != (3, 12, 13):
|
||||||
|
raise RuntimeError('This release is pinned to Python 3.12.13 x64')
|
||||||
|
for lock in (ROOT/'requirements-windows-cpu.lock.txt',ROOT/'deploy/windows/build-tools.lock.txt'):
|
||||||
|
for line in lock.read_text(encoding='utf-8-sig').splitlines():
|
||||||
|
line=line.strip()
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
continue
|
||||||
|
name,expected=line.split('==',1)
|
||||||
|
actual=importlib.metadata.version(name)
|
||||||
|
if actual != expected:
|
||||||
|
raise RuntimeError(f'Locked dependency mismatch: {name}: {actual} != {expected}')
|
||||||
|
if '+cpu' not in importlib.metadata.version('torch'):
|
||||||
|
raise RuntimeError('Only the locked CPU torch build is supported')
|
||||||
|
|
||||||
|
def stage(public_key):
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||||
|
if not isinstance(serialization.load_pem_public_key(public_key.read_bytes()), Ed25519PublicKey):
|
||||||
|
raise ValueError('An Ed25519 PUBLIC key is required')
|
||||||
|
clean(STAGE)
|
||||||
|
source = WORK / 'sources'
|
||||||
|
clean(source)
|
||||||
|
modules = []
|
||||||
|
glue = []
|
||||||
|
external = set()
|
||||||
|
for package in OWN:
|
||||||
|
for path in sorted((ROOT / package).rglob('*.py')):
|
||||||
|
if '__pycache__' in path.parts or path.name == 'tests.py':
|
||||||
|
continue
|
||||||
|
relative = path.relative_to(ROOT)
|
||||||
|
if not path.stem.isidentifier():
|
||||||
|
# Django's numbered migration names need an import-only glue module.
|
||||||
|
stub = STAGE / relative
|
||||||
|
stub.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
private_name = '_m' + path.stem
|
||||||
|
stub.write_text('from .' + private_name + ' import Migration\n', encoding='utf-8')
|
||||||
|
glue.append('.'.join(relative.with_suffix('').parts))
|
||||||
|
relative = relative.with_name(private_name + '.py')
|
||||||
|
body = path.read_text(encoding='utf-8-sig')
|
||||||
|
if relative.as_posix() == 'monitor_runtime/release.py':
|
||||||
|
body = 'RELEASE_BUILD = True\n'
|
||||||
|
tree = ast.parse(body)
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
external.update(alias.name for alias in node.names if alias.name.split('.')[0] not in OWN)
|
||||||
|
if isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
||||||
|
if node.module.split('.')[0] not in OWN:
|
||||||
|
external.add(node.module)
|
||||||
|
if path.name == '__init__.py':
|
||||||
|
meaningful = [n for n in tree.body if not (isinstance(n, ast.Expr) and isinstance(n.value, ast.Constant))]
|
||||||
|
output = STAGE / relative
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
glue.append('.'.join(relative.parent.parts))
|
||||||
|
output.write_text('from ._package import *\n' if meaningful else '', encoding='utf-8')
|
||||||
|
if not meaningful:
|
||||||
|
continue
|
||||||
|
relative = relative.with_name('_package.py')
|
||||||
|
output = source / relative
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output.write_text(body, encoding='utf-8')
|
||||||
|
modules.append(('.'.join(relative.with_suffix('').parts), str(output)))
|
||||||
|
manifest = {'modules': modules, 'glue': glue, 'external': sorted(external)}
|
||||||
|
(WORK / 'modules.json').write_text(json.dumps(manifest, indent=2), encoding='utf-8')
|
||||||
|
for folder in ('templates',):
|
||||||
|
shutil.copytree(ROOT / folder, STAGE / folder)
|
||||||
|
shutil.copytree(ROOT / 'static', STAGE / 'static', ignore=shutil.ignore_patterns('upload', 'storage', '__pycache__'))
|
||||||
|
shutil.copytree(STAGE / 'static', STAGE / 'public-static')
|
||||||
|
shutil.copytree(ROOT / 'deploy/windows', STAGE / 'deploy/windows',
|
||||||
|
ignore=shutil.ignore_patterns('*.py', '*.ps1', '*.iss', '*.md', '__pycache__'))
|
||||||
|
for path in ROOT.glob('language-*.json'):
|
||||||
|
shutil.copyfile(path, STAGE / path.name)
|
||||||
|
shutil.copyfile(public_key, STAGE / 'license-public.pem')
|
||||||
|
(STAGE / 'zlm').mkdir()
|
||||||
|
shutil.copyfile(ROOT / 'zlm/bin.x86.windows10/monitor_zlm.exe', STAGE / 'zlm/monitor_zlm.exe')
|
||||||
|
import imageio_ffmpeg
|
||||||
|
(STAGE / 'tools').mkdir()
|
||||||
|
shutil.copyfile(imageio_ffmpeg.get_ffmpeg_exe(), STAGE / 'tools/ffmpeg.exe')
|
||||||
|
shutil.copyfile(ROOT / 'monitor_entry.py', STAGE / 'monitor_entry.py')
|
||||||
|
versions = {d.metadata['Name']: d.version for d in importlib.metadata.distributions() if d.metadata['Name']}
|
||||||
|
(STAGE / 'dependency-versions.json').write_text(json.dumps(versions, indent=2), encoding='utf-8')
|
||||||
|
# Include installed dependency license notices without copying package source trees.
|
||||||
|
notices = STAGE / 'third-party-notices'
|
||||||
|
notices.mkdir()
|
||||||
|
for distribution in importlib.metadata.distributions():
|
||||||
|
name = distribution.metadata['Name']
|
||||||
|
for item in distribution.files or []:
|
||||||
|
if '.dist-info' not in str(item):
|
||||||
|
continue
|
||||||
|
if Path(item).name.lower().startswith(('license', 'copying', 'notice')):
|
||||||
|
target = notices / name / Path(item).name
|
||||||
|
target.parent.mkdir(exist_ok=True)
|
||||||
|
origin = distribution.locate_file(item)
|
||||||
|
if origin.is_file():
|
||||||
|
shutil.copyfile(origin, target)
|
||||||
|
|
||||||
|
def compile_modules(vcvars):
|
||||||
|
script = WORK / 'compile_extensions.py'
|
||||||
|
script.write_text("""import json
|
||||||
|
from pathlib import Path
|
||||||
|
from setuptools import setup, Extension
|
||||||
|
from Cython.Build import cythonize
|
||||||
|
work=Path(__file__).resolve().parent
|
||||||
|
modules=json.loads((work/'modules.json').read_text())['modules']
|
||||||
|
extensions=[Extension(name,[source]) for name,source in modules]
|
||||||
|
setup(name='monitor-private', ext_modules=cythonize(extensions,
|
||||||
|
build_dir=str(work/'cython'), compiler_directives={
|
||||||
|
'language_level':3,'binding':True,'annotation_typing':False,
|
||||||
|
'infer_types':False,'embedsignature':False}, nthreads=0),
|
||||||
|
script_args=['build_ext','--build-lib',str(work/'stage'),'--build-temp',str(work/'objects'),'-j','2'])
|
||||||
|
""", encoding='utf-8')
|
||||||
|
run([sys.executable, script], env=compiler_environment(vcvars), cwd=WORK)
|
||||||
|
|
||||||
|
def bundle():
|
||||||
|
manifest = json.loads((WORK / 'modules.json').read_text())
|
||||||
|
spec = WORK / 'monitor.spec'
|
||||||
|
spec.write_text("""import json
|
||||||
|
from pathlib import Path
|
||||||
|
from PyInstaller.utils.hooks import collect_all, collect_submodules, copy_metadata
|
||||||
|
work=Path(SPECPATH)
|
||||||
|
stage=work/'stage'
|
||||||
|
manifest=json.loads((work/'modules.json').read_text())
|
||||||
|
datas=[]
|
||||||
|
binaries=[]
|
||||||
|
hidden=[name for name,_ in manifest['modules']]+manifest['glue']+manifest['external']
|
||||||
|
hidden += ['pystray._win32','django.db.backends.sqlite3','django.contrib.auth.hashers',
|
||||||
|
'django.contrib.sessions.backends.db','django.template.backends.django']
|
||||||
|
for package in ['django','ultralytics','torch','torchvision','onnxruntime','openvino','onvif','pystray','whitenoise']:
|
||||||
|
d,b,h=collect_all(package)
|
||||||
|
datas+=d;binaries+=b;hidden+=h
|
||||||
|
for name in ['templates','static','public-static','deploy','zlm','tools',
|
||||||
|
'license-public.pem','dependency-versions.json','third-party-notices']:
|
||||||
|
child=stage/name
|
||||||
|
datas.append((str(child),child.name if child.is_dir() else '.'))
|
||||||
|
for child in stage.glob('language-*.json'):
|
||||||
|
datas.append((str(child),'.'))
|
||||||
|
a=Analysis([str(stage/'monitor_entry.py')],pathex=[str(stage)],
|
||||||
|
binaries=binaries,datas=datas,hiddenimports=sorted(set(hidden)),
|
||||||
|
excludes=['pytest','IPython','notebook','tkinter','matplotlib.tests'],
|
||||||
|
noarchive=False)
|
||||||
|
own=('app','framework','monitor_runtime')
|
||||||
|
unexpected=[name for name,_,_ in a.pure if name.split('.')[0] in own and name not in manifest['glue']]
|
||||||
|
if unexpected:
|
||||||
|
raise RuntimeError('Uncompiled private modules: '+repr(unexpected))
|
||||||
|
pyz=PYZ(a.pure)
|
||||||
|
exe=EXE(pyz,a.scripts,[],exclude_binaries=True,name='Monitor',console=False,
|
||||||
|
debug=False,upx=False,icon=str(work/'monitor.ico'))
|
||||||
|
coll=COLLECT(exe,a.binaries,a.datas,strip=False,upx=False,name='Monitor')
|
||||||
|
""", encoding='utf-8')
|
||||||
|
from PIL import Image
|
||||||
|
Image.open(ROOT / 'static/images/logo.png').save(WORK / 'monitor.ico', sizes=[(16,16),(32,32),(48,48),(128,128),(256,256)])
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['PYTHONPATH'] = str(STAGE)
|
||||||
|
env['DJANGO_SETTINGS_MODULE'] = 'framework.settings'
|
||||||
|
env['MONITOR_DATA_DIR'] = str(WORK / 'build-data')
|
||||||
|
env['MONITOR_DESKTOP'] = '1'
|
||||||
|
env['MONITOR_SERVICE_MODE'] = 'disabled'
|
||||||
|
env['MONITOR_BOOTSTRAP_SERVICES'] = 'false'
|
||||||
|
env['CI'] = 'true'
|
||||||
|
env['YOLO_OFFLINE'] = 'true'
|
||||||
|
env['YOLO_AUTOINSTALL'] = 'false'
|
||||||
|
(WORK / 'yolo/Ultralytics').mkdir(parents=True, exist_ok=True)
|
||||||
|
env['YOLO_CONFIG_DIR'] = str(WORK / 'yolo')
|
||||||
|
env['PYINSTALLER_CONFIG_DIR'] = str(WORK / 'pyinstaller-cache')
|
||||||
|
run([sys.executable, '-m', 'PyInstaller', '--noconfirm', '--clean', '--distpath', WORK / 'dist',
|
||||||
|
'--workpath', WORK / 'pyinstaller', spec], env=env, cwd=STAGE)
|
||||||
|
finalize_payload()
|
||||||
|
|
||||||
|
def finalize_payload():
|
||||||
|
payload = WORK / 'dist/Monitor'
|
||||||
|
if not (payload/'Monitor.exe').exists():
|
||||||
|
raise RuntimeError('Bundle must succeed before payload finalization')
|
||||||
|
internal = payload/'_internal'
|
||||||
|
# Extension modules are immutable release code, refreshed after incremental compilation.
|
||||||
|
for package in OWN:
|
||||||
|
for compiled in (STAGE/package).rglob('*.pyd'):
|
||||||
|
target=internal/compiled.relative_to(STAGE)
|
||||||
|
target.parent.mkdir(parents=True,exist_ok=True)
|
||||||
|
shutil.copyfile(compiled,target)
|
||||||
|
# onvif-zeep installs WSDL beside its package, outside collect_all('onvif').
|
||||||
|
import onvif
|
||||||
|
wsdl=Path(onvif.__file__).resolve().parent.parent/'wsdl'
|
||||||
|
if not (wsdl/'devicemgmt.wsdl').is_file():
|
||||||
|
raise RuntimeError('ONVIF WSDL resources are missing from the build environment')
|
||||||
|
shutil.copytree(wsdl,internal/'wsdl',dirs_exist_ok=True)
|
||||||
|
audit(payload)
|
||||||
|
|
||||||
|
def audit(folder):
|
||||||
|
forbidden = {'.runtime-secrets.json','.trusted-models.json','monitor.sqlite3','config.json',
|
||||||
|
'license.json','license-clock.json'}
|
||||||
|
for path in folder.rglob('*'):
|
||||||
|
relative = path.relative_to(folder)
|
||||||
|
parts = relative.parts[1:] if relative.parts[0] == '_internal' else relative.parts
|
||||||
|
if parts and parts[0] in OWN:
|
||||||
|
if path.suffix.lower() in ('.py','.pyc','.pyo','.c','.cpp','.html'):
|
||||||
|
raise RuntimeError('Private source/code artifact: '+str(path))
|
||||||
|
if path.suffix.lower() == '.log':
|
||||||
|
raise RuntimeError('Runtime log in release: '+str(path))
|
||||||
|
if path.name in forbidden or 'private' in path.name.lower() and path.suffix == '.pem':
|
||||||
|
raise RuntimeError('Runtime data in release: '+str(path))
|
||||||
|
digest = lambda p: hashlib.file_digest(p.open('rb'), 'sha256').hexdigest()
|
||||||
|
manifest = {str(p.relative_to(folder)):digest(p) for p in folder.rglob('*') if p.is_file()}
|
||||||
|
(WORK / 'release-files.sha256.json').write_text(json.dumps(manifest, indent=2), encoding='utf-8')
|
||||||
|
print('Release payload audit passed:', len(manifest), 'files')
|
||||||
|
|
||||||
|
def validate_payload():
|
||||||
|
import datetime
|
||||||
|
payload=WORK/'dist/Monitor'
|
||||||
|
test_data=WORK/('release-self-test-'+datetime.datetime.now().strftime('%Y%m%d-%H%M%S-%f'))
|
||||||
|
env=os.environ.copy()
|
||||||
|
env['MONITOR_DATA_DIR']=str(test_data)
|
||||||
|
env.pop('PYTHONPATH',None);env.pop('PYTHONHOME',None)
|
||||||
|
windows=env['SYSTEMROOT']
|
||||||
|
env['PATH']=windows+'/System32;'+windows+';'+windows+'/System32/WindowsPowerShell/v1.0'
|
||||||
|
if not (payload/'_internal/wsdl/devicemgmt.wsdl').is_file():
|
||||||
|
raise RuntimeError('Packaged ONVIF resources are missing')
|
||||||
|
run([payload/'Monitor.exe','--self-test'],env=env,cwd=WORK,timeout=180)
|
||||||
|
report=test_data/'.runtime/self-test.json'
|
||||||
|
result=json.loads(report.read_text(encoding='utf-8'))
|
||||||
|
if result.get('status') != 'passed':
|
||||||
|
raise RuntimeError('Frozen dependency self-test failed')
|
||||||
|
shutil.copyfile(report,WORK/'frozen-self-test.json')
|
||||||
|
|
||||||
|
|
||||||
|
def installer(iscc, sign_thumbprint):
|
||||||
|
finalize_payload()
|
||||||
|
validate_payload()
|
||||||
|
payload = WORK / 'dist/Monitor'
|
||||||
|
if not iscc:
|
||||||
|
candidates = [WORK/'tools/Inno/ISCC.exe',Path('C:/Program Files (x86)/Inno Setup 6/ISCC.exe')]
|
||||||
|
iscc = next((p for p in candidates if p.exists()), None)
|
||||||
|
if not iscc:
|
||||||
|
raise RuntimeError('Inno Setup ISCC.exe is required; pass --iscc')
|
||||||
|
if sign_thumbprint:
|
||||||
|
run(['signtool.exe','sign','/sha1',sign_thumbprint,'/fd','SHA256',payload/'Monitor.exe'])
|
||||||
|
manifest_path=WORK/'release-files.sha256.json'
|
||||||
|
manifest=json.loads(manifest_path.read_text(encoding='utf-8'))
|
||||||
|
with (payload/'Monitor.exe').open('rb') as handle:
|
||||||
|
manifest['Monitor.exe']=hashlib.file_digest(handle,'sha256').hexdigest()
|
||||||
|
manifest_path.write_text(json.dumps(manifest,indent=2),encoding='utf-8')
|
||||||
|
output = ROOT / 'dist/windows'
|
||||||
|
output.mkdir(parents=True,exist_ok=True)
|
||||||
|
run([iscc, '/DPayload='+str(payload), '/DOutput='+str(output), ROOT/'deploy/windows/Monitor.iss'])
|
||||||
|
exe = output / 'Monitor-Setup-x64.exe'
|
||||||
|
if sign_thumbprint:
|
||||||
|
run(['signtool.exe','sign','/sha1',sign_thumbprint,'/fd','SHA256',exe])
|
||||||
|
with exe.open('rb') as handle:
|
||||||
|
checksum = hashlib.file_digest(handle,'sha256').hexdigest()
|
||||||
|
(output/'SHA256SUMS.txt').write_text(checksum+' '+exe.name+'\n',encoding='ascii')
|
||||||
|
shutil.copyfile(WORK/'release-files.sha256.json',output/'release-files.sha256.json')
|
||||||
|
shutil.copyfile(payload/'_internal/dependency-versions.json',output/'dependency-versions.json')
|
||||||
|
shutil.copyfile(ROOT/'deploy/windows/DEPLOYMENT.md',output/'DEPLOYMENT.md')
|
||||||
|
shutil.copyfile(WORK/'frozen-self-test.json',output/'frozen-self-test.json')
|
||||||
|
print('INSTALLER:', exe)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser=argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--public-key',type=Path,required=True)
|
||||||
|
parser.add_argument('--phase',choices=['all','stage','compile','bundle','finalize','validate','installer'],default='all')
|
||||||
|
parser.add_argument('--vcvars')
|
||||||
|
parser.add_argument('--iscc')
|
||||||
|
parser.add_argument('--sign-thumbprint')
|
||||||
|
args=parser.parse_args()
|
||||||
|
if os.name != 'nt' or sys.maxsize <= 2**32:
|
||||||
|
parser.error('Windows x64 Python is required')
|
||||||
|
if Path(sys.prefix).resolve() != (ROOT/'.build-venv').resolve():
|
||||||
|
parser.error('Run with .build-venv/Scripts/python.exe; do not modify the development environment')
|
||||||
|
WORK.mkdir(exist_ok=True)
|
||||||
|
verify_environment()
|
||||||
|
if args.phase not in ('all','stage'):
|
||||||
|
if args.public_key.read_bytes() != (STAGE/'license-public.pem').read_bytes():
|
||||||
|
parser.error('Public key differs from staged release; run the stage and compile phases first')
|
||||||
|
if args.phase in ('all','stage'):stage(args.public_key.resolve())
|
||||||
|
if args.phase in ('all','compile'):compile_modules(args.vcvars)
|
||||||
|
if args.phase in ('all','bundle'):bundle()
|
||||||
|
if args.phase == 'finalize':finalize_payload()
|
||||||
|
if args.phase == 'validate':validate_payload()
|
||||||
|
if args.phase in ('all','installer'):installer(args.iscc,args.sign_thumbprint)
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
main()
|
||||||
32
deploy/windows/default-config.json
Normal file
32
deploy/windows/default-config.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"adminPort": 10001,
|
||||||
|
"logDebug": false,
|
||||||
|
"isEnableLoginCaptcha": true,
|
||||||
|
"isEnableUpdatePopup": false,
|
||||||
|
"telemetryEnabled": false,
|
||||||
|
"updateCheckEnabled": false,
|
||||||
|
"autoAddStreamProxy": true,
|
||||||
|
"autoAddStreamProxySleep": 15,
|
||||||
|
"fontPath": "static/fonts/tsimhei.ttf",
|
||||||
|
"uploadDir": "static/upload",
|
||||||
|
"storageDir": "static/storage",
|
||||||
|
"mediaHttpPort": 10002,
|
||||||
|
"mediaRtspPort": 10554,
|
||||||
|
"mediaRtmpPort": 10935,
|
||||||
|
"mediaStartConfigPath": "zlm-template.ini",
|
||||||
|
"autoStartMedia": true,
|
||||||
|
"analysisProcessMode": 1,
|
||||||
|
"analysisSharedInference": true,
|
||||||
|
"analysisInferenceWorkers": 1,
|
||||||
|
"analysisTargetFps": 5,
|
||||||
|
"recordingEnabled": false,
|
||||||
|
"sipServer": {
|
||||||
|
"sipServerIp": "127.0.0.1",
|
||||||
|
"sipServerPort": 15060,
|
||||||
|
"sipTransferMode": 0,
|
||||||
|
"sipServerId": "34020000002000000001",
|
||||||
|
"sipServerRealm": "3402000000",
|
||||||
|
"rtpTransferMode": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
20
deploy/windows/default-settings.json
Normal file
20
deploy/windows/default-settings.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"lang_default": "zh",
|
||||||
|
"languages": {
|
||||||
|
"zh": {
|
||||||
|
"code": "zh",
|
||||||
|
"name": "简体中文",
|
||||||
|
"filename": "language-zh.json",
|
||||||
|
"oem": {
|
||||||
|
"name": "视频监控平台",
|
||||||
|
"welcome": "欢迎",
|
||||||
|
"logo_url": "/static/images/logo.png",
|
||||||
|
"bottom_name": "Monitor",
|
||||||
|
"is_show_author": false,
|
||||||
|
"author": "",
|
||||||
|
"author_link": "",
|
||||||
|
"check_version_download_url": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
deploy/windows/issue_license.py
Normal file
75
deploy/windows/issue_license.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"""Vendor-only offline issuer. Never include this tool or its private key in a customer installer."""
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import getpass
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
sys.path.insert(0,str(Path(__file__).resolve().parents[2]))
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
from monitor_runtime.licensing import canonical, verify_document, timestamp
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser=argparse.ArgumentParser()
|
||||||
|
sub=parser.add_subparsers(dest='command',required=True)
|
||||||
|
keygen=sub.add_parser('keygen')
|
||||||
|
keygen.add_argument('--private-key',type=Path,required=True)
|
||||||
|
keygen.add_argument('--public-key',type=Path,required=True)
|
||||||
|
keygen.add_argument('--dpapi',action='store_true',help='Encrypt private key for current Windows account')
|
||||||
|
issue=sub.add_parser('issue')
|
||||||
|
issue.add_argument('--private-key',type=Path,required=True)
|
||||||
|
issue.add_argument('--request',type=Path,required=True)
|
||||||
|
issue.add_argument('--customer',required=True)
|
||||||
|
issue.add_argument('--expires',help='ISO 8601 time with timezone; omitted means perpetual')
|
||||||
|
issue.add_argument('--output',type=Path,required=True)
|
||||||
|
args=parser.parse_args()
|
||||||
|
if args.command=='keygen':
|
||||||
|
if args.private_key.exists() or args.public_key.exists():
|
||||||
|
parser.error('Refusing to overwrite existing issuer keys')
|
||||||
|
key=Ed25519PrivateKey.generate()
|
||||||
|
if args.dpapi:
|
||||||
|
from monitor_runtime.windows import protect
|
||||||
|
raw=key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption())
|
||||||
|
encoded=b'MONITOR-DPAPI-V1\n'+base64.b64encode(protect(raw))
|
||||||
|
else:
|
||||||
|
password=getpass.getpass('Private-key password (minimum 12 characters): ')
|
||||||
|
if len(password)<12 or password!=getpass.getpass('Confirm password: '):
|
||||||
|
parser.error('Password too short or confirmation mismatch')
|
||||||
|
encoded=key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,
|
||||||
|
serialization.BestAvailableEncryption(password.encode()))
|
||||||
|
args.private_key.parent.mkdir(parents=True,exist_ok=True)
|
||||||
|
args.public_key.parent.mkdir(parents=True,exist_ok=True)
|
||||||
|
with args.private_key.open('xb') as f:f.write(encoded)
|
||||||
|
with args.public_key.open('xb') as f:
|
||||||
|
f.write(key.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo))
|
||||||
|
print('Issuer keypair created. Keep the private key offline and backed up.')
|
||||||
|
return
|
||||||
|
raw=args.private_key.read_bytes()
|
||||||
|
if raw.startswith(b'MONITOR-DPAPI-V1\n'):
|
||||||
|
from monitor_runtime.windows import protect
|
||||||
|
raw=protect(base64.b64decode(raw.split(b'\n',1)[1]),decrypt=True)
|
||||||
|
key=serialization.load_pem_private_key(raw,password=None)
|
||||||
|
else:
|
||||||
|
key=serialization.load_pem_private_key(raw,password=getpass.getpass('Private-key password: ').encode())
|
||||||
|
request=json.loads(args.request.read_text(encoding='utf-8'))
|
||||||
|
if request.get('product')!='monitor' or request.get('schema_version')!=1:
|
||||||
|
parser.error('Unsupported machine request')
|
||||||
|
fingerprint=request.get('fingerprint','')
|
||||||
|
if not fingerprint.startswith('v1:') or len(fingerprint)!=67 or any(c not in '0123456789abcdef' for c in fingerprint[3:]):
|
||||||
|
parser.error('Invalid machine fingerprint')
|
||||||
|
now=datetime.now(timezone.utc)
|
||||||
|
payload={'schema_version':1,'product':'monitor','license_id':str(uuid.uuid4()),'customer':args.customer,
|
||||||
|
'machine_fingerprint':fingerprint,'issued_at':now.isoformat(),
|
||||||
|
'expires_at':args.expires}
|
||||||
|
document={'payload':payload,'signature':base64.b64encode(key.sign(canonical(payload))).decode('ascii')}
|
||||||
|
public=key.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||||
|
verify_document(document,public,fingerprint)
|
||||||
|
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||||
|
with args.output.open('x',encoding='utf-8') as f:json.dump(document,f,ensure_ascii=False,indent=2)
|
||||||
|
print('License signed:',args.output)
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
main()
|
||||||
192
deploy/windows/zlm-template.ini
Normal file
192
deploy/windows/zlm-template.ini
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
; auto-generated by mINI class {
|
||||||
|
|
||||||
|
[api]
|
||||||
|
apiDebug=0
|
||||||
|
defaultSnap=./www/logo.png
|
||||||
|
downloadRoot=./www
|
||||||
|
secret=INJECTED_AT_RUNTIME
|
||||||
|
snapRoot=./www/snap/
|
||||||
|
|
||||||
|
[cluster]
|
||||||
|
origin_url=
|
||||||
|
retry_count=3
|
||||||
|
timeout_sec=15
|
||||||
|
|
||||||
|
[ffmpeg]
|
||||||
|
bin=ffmpeg
|
||||||
|
cmd=%s -re -i %s -c:a aac -strict -2 -ar 44100 -ab 48k -c:v libx264 -f flv %s
|
||||||
|
log=./ffmpeg/ffmpeg.log
|
||||||
|
restart_sec=0
|
||||||
|
snap=%s -i %s -y -f mjpeg -frames:v 1 %s
|
||||||
|
|
||||||
|
[general]
|
||||||
|
check_nvidia_dev=1
|
||||||
|
enableVhost=0
|
||||||
|
enable_ffmpeg_log=0
|
||||||
|
flowThreshold=1024
|
||||||
|
maxStreamWaitMS=15000
|
||||||
|
mediaServerId=niBEjPnzIrQIS6qU
|
||||||
|
mergeWriteMS=0
|
||||||
|
resetWhenRePlay=1
|
||||||
|
streamNoneReaderDelayMS=20000
|
||||||
|
unready_frame_cache=100
|
||||||
|
wait_add_track_ms=3000
|
||||||
|
wait_track_ready_ms=10000
|
||||||
|
|
||||||
|
[hls]
|
||||||
|
broadcastRecordTs=0
|
||||||
|
deleteDelaySec=10
|
||||||
|
fastRegister=0
|
||||||
|
fileBufSize=65536
|
||||||
|
segDur=2
|
||||||
|
segKeep=0
|
||||||
|
segNum=3
|
||||||
|
segRetain=5
|
||||||
|
|
||||||
|
[hook]
|
||||||
|
alive_interval=30.000000
|
||||||
|
enable=1
|
||||||
|
on_flow_report=
|
||||||
|
on_http_access=
|
||||||
|
on_play=
|
||||||
|
on_publish=
|
||||||
|
on_record_mp4=
|
||||||
|
on_record_ts=
|
||||||
|
on_rtp_server_timeout=
|
||||||
|
on_rtsp_auth=
|
||||||
|
on_rtsp_realm=
|
||||||
|
on_send_rtp_stopped=
|
||||||
|
on_server_exited=
|
||||||
|
on_server_keepalive=
|
||||||
|
on_server_started=
|
||||||
|
on_shell_login=
|
||||||
|
on_stream_changed=
|
||||||
|
on_stream_none_reader=
|
||||||
|
on_stream_not_found=
|
||||||
|
retry=1
|
||||||
|
retry_delay=3.000000
|
||||||
|
stream_changed_schemas=rtsp/rtmp/fmp4/ts/hls/hls.fmp4
|
||||||
|
timeoutSec=10
|
||||||
|
|
||||||
|
[http]
|
||||||
|
allow_cross_domains=1
|
||||||
|
allow_ip_range=::1,127.0.0.1,172.16.0.0-172.31.255.255,192.168.0.0-192.168.255.255,10.0.0.0-10.255.255.255
|
||||||
|
charSet=gb2312
|
||||||
|
dirMenu=1
|
||||||
|
forbidCacheSuffix=
|
||||||
|
forwarded_ip_header=
|
||||||
|
keepAliveSecond=15
|
||||||
|
maxReqSize=40960
|
||||||
|
notFound=<html><head><title>404 Not Found</title></head><body bgcolor="white"><center><h1>您访问的资源不存在!</h1></center><hr><center>ZLMediaKit(git hash:/,branch:,build time:2025-05-11T18:28:47)</center></body></html>
|
||||||
|
port=10002
|
||||||
|
rootPath=./www
|
||||||
|
sendBufSize=65536
|
||||||
|
sslport=0
|
||||||
|
virtualPath=
|
||||||
|
|
||||||
|
[multicast]
|
||||||
|
addrMax=239.255.255.255
|
||||||
|
addrMin=239.0.0.0
|
||||||
|
udpTTL=64
|
||||||
|
|
||||||
|
[protocol]
|
||||||
|
add_mute_audio=1
|
||||||
|
auto_close=0
|
||||||
|
continue_push_ms=15000
|
||||||
|
enable_audio=1
|
||||||
|
enable_fmp4=1
|
||||||
|
enable_hls=0
|
||||||
|
enable_hls_fmp4=0
|
||||||
|
enable_mp4=0
|
||||||
|
enable_rtmp=1
|
||||||
|
enable_rtsp=1
|
||||||
|
enable_ts=0
|
||||||
|
fmp4_demand=0
|
||||||
|
hls_demand=1
|
||||||
|
hls_save_path=./www
|
||||||
|
modify_stamp=2
|
||||||
|
mp4_as_player=0
|
||||||
|
mp4_max_second=3600
|
||||||
|
mp4_save_path=./www
|
||||||
|
paced_sender_ms=0
|
||||||
|
rtmp_demand=1
|
||||||
|
rtsp_demand=0
|
||||||
|
ts_demand=1
|
||||||
|
|
||||||
|
[record]
|
||||||
|
appName=record
|
||||||
|
enableFmp4=0
|
||||||
|
fastStart=0
|
||||||
|
fileBufSize=65536
|
||||||
|
fileRepeat=0
|
||||||
|
sampleMS=500
|
||||||
|
|
||||||
|
[rtc]
|
||||||
|
bfilter=0
|
||||||
|
datachannel_echo=1
|
||||||
|
externIP=
|
||||||
|
maxRtpCacheMS=5000
|
||||||
|
maxRtpCacheSize=2048
|
||||||
|
max_bitrate=0
|
||||||
|
min_bitrate=0
|
||||||
|
nackIntervalRatio=1.0
|
||||||
|
nackMaxCount=15
|
||||||
|
nackMaxMS=3000
|
||||||
|
nackMaxSize=2048
|
||||||
|
nackRtpSize=8
|
||||||
|
port=0
|
||||||
|
preferredCodecA=PCMA,PCMU,opus,mpeg4-generic
|
||||||
|
preferredCodecV=H264,H265,AV1,VP9,VP8
|
||||||
|
rembBitRate=0
|
||||||
|
start_bitrate=0
|
||||||
|
tcpPort=0
|
||||||
|
timeoutSec=15
|
||||||
|
|
||||||
|
[rtmp]
|
||||||
|
directProxy=1
|
||||||
|
enhanced=0
|
||||||
|
handshakeSecond=15
|
||||||
|
keepAliveSecond=15
|
||||||
|
port=10935
|
||||||
|
sslport=0
|
||||||
|
|
||||||
|
[rtp]
|
||||||
|
audioMtuSize=600
|
||||||
|
h264_stap_a=1
|
||||||
|
lowLatency=0
|
||||||
|
rtpMaxSize=10
|
||||||
|
videoMtuSize=1400
|
||||||
|
|
||||||
|
[rtp_proxy]
|
||||||
|
dumpDir=
|
||||||
|
gop_cache=1
|
||||||
|
h264_pt=98
|
||||||
|
h265_pt=99
|
||||||
|
opus_pt=100
|
||||||
|
port=30001
|
||||||
|
port_range=30002-40000
|
||||||
|
ps_pt=96
|
||||||
|
timeoutSec=15
|
||||||
|
|
||||||
|
[rtsp]
|
||||||
|
authBasic=0
|
||||||
|
directProxy=1
|
||||||
|
handshakeSecond=15
|
||||||
|
keepAliveSecond=15
|
||||||
|
lowLatency=0
|
||||||
|
port=10554
|
||||||
|
rtpTransportType=-1
|
||||||
|
sslport=0
|
||||||
|
|
||||||
|
[shell]
|
||||||
|
maxReqSize=1024
|
||||||
|
port=0
|
||||||
|
|
||||||
|
[srt]
|
||||||
|
latencyMul=4
|
||||||
|
passPhrase=
|
||||||
|
pktBufSize=8192
|
||||||
|
port=0
|
||||||
|
timeoutSec=5
|
||||||
|
|
||||||
|
; } ---
|
||||||
@ -14,7 +14,8 @@ from pathlib import Path
|
|||||||
import os
|
import os
|
||||||
from app.utils.Secrets import get_runtime_secret
|
from app.utils.Secrets import get_runtime_secret
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# 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_UA = "monitor"
|
||||||
PROJECT_BUILT = "monitor built on 2026/08/06"
|
PROJECT_BUILT = "monitor built on 2026/08/06"
|
||||||
PROJECT_VERSION = "1.003"
|
PROJECT_VERSION = "1.003"
|
||||||
@ -42,8 +43,15 @@ def env_list(name, default=""):
|
|||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
# Production-safe by default. Local development must opt in explicitly.
|
# Production-safe by default. Local development must opt in explicitly.
|
||||||
DEBUG = env_bool("MONITOR_DEBUG", False)
|
DEBUG = env_bool("MONITOR_DEBUG", False)
|
||||||
|
if DESKTOP:
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
ALLOWED_HOSTS = env_list("MONITOR_ALLOWED_HOSTS", "127.0.0.1,localhost")
|
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
|
# Application definition
|
||||||
|
|
||||||
@ -66,6 +74,7 @@ MIDDLEWARE = [
|
|||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
'monitor_runtime.web.LicenseMiddleware',
|
||||||
"app.middleware.SimpleMiddleware", # 拦截器
|
"app.middleware.SimpleMiddleware", # 拦截器
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -75,7 +84,7 @@ TEMPLATES = [
|
|||||||
{
|
{
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
'DIRS': [
|
'DIRS': [
|
||||||
os.path.join(BASE_DIR,'templates')
|
os.path.join(RESOURCE_ROOT,'templates')
|
||||||
],
|
],
|
||||||
'APP_DIRS': True,
|
'APP_DIRS': True,
|
||||||
'OPTIONS': {
|
'OPTIONS': {
|
||||||
@ -144,7 +153,7 @@ TIME_ZONE = 'Asia/Shanghai'
|
|||||||
|
|
||||||
""" 静态资源(DEBUG=True:staticfiles 从 STATICFILES_DIRS 直接提供) """
|
""" 静态资源(DEBUG=True:staticfiles 从 STATICFILES_DIRS 直接提供) """
|
||||||
STATICFILES_DIRS = (
|
STATICFILES_DIRS = (
|
||||||
os.path.join(BASE_DIR, "static"),
|
os.path.join(RESOURCE_ROOT, "static"),
|
||||||
)
|
)
|
||||||
|
|
||||||
SESSION_COOKIE_NAME = 'MonitorSessionID'
|
SESSION_COOKIE_NAME = 'MonitorSessionID'
|
||||||
|
|||||||
@ -33,3 +33,16 @@ if settings.DEBUG:
|
|||||||
{'document_root': os.path.join(settings.BASE_DIR, 'static', 'upload')}),
|
{'document_root': os.path.join(settings.BASE_DIR, 'static', 'upload')}),
|
||||||
]
|
]
|
||||||
urlpatterns += staticfiles_urlpatterns()
|
urlpatterns += staticfiles_urlpatterns()
|
||||||
|
|
||||||
|
# Mutable media is never served by WhiteNoise or the development static handler.
|
||||||
|
from monitor_runtime import web
|
||||||
|
urlpatterns = [
|
||||||
|
path('setup', web.setup),
|
||||||
|
path('license', web.license_page),
|
||||||
|
path('license/status', web.license_status),
|
||||||
|
path('license/request', web.license_request),
|
||||||
|
path('license/import', web.license_import),
|
||||||
|
path('static/storage/<path:path>', web.private_file),
|
||||||
|
path('static/upload/audio/<path:path>', web.private_file, {'area': 'audio'}),
|
||||||
|
path('upload/audio/<path:path>', web.private_file, {'area': 'audio'}),
|
||||||
|
] + urlpatterns
|
||||||
|
|||||||
@ -56,7 +56,7 @@
|
|||||||
"player_copied": "已复制",
|
"player_copied": "已复制",
|
||||||
"player_copy_fail": "复制失败",
|
"player_copy_fail": "复制失败",
|
||||||
"port_already_used": "端口 %d 已被占用!",
|
"port_already_used": "端口 %d 已被占用!",
|
||||||
"ptz_type_range_error": "ptzType 必须在 0-11 之间",
|
"ptz_type_range_error": "ptzType 必须在 0-14 之间",
|
||||||
"stream_analyzer": "算法流",
|
"stream_analyzer": "算法流",
|
||||||
"stream_app_invalid": "应用不存在",
|
"stream_app_invalid": "应用不存在",
|
||||||
"stream_app_name_required": "应用和名称是必填项",
|
"stream_app_name_required": "应用和名称是必填项",
|
||||||
|
|||||||
20
monitor_entry.py
Normal file
20
monitor_entry.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
"""Minimal executable bootstrap; business logic is compiled separately."""
|
||||||
|
import multiprocessing
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
multiprocessing.freeze_support()
|
||||||
|
os.environ['MONITOR_DESKTOP'] = '1'
|
||||||
|
try:
|
||||||
|
from monitor_runtime.launcher import main
|
||||||
|
raise SystemExit(main())
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
import logging
|
||||||
|
logging.exception('Monitor failed')
|
||||||
|
if os.name == 'nt' and '--worker' not in sys.argv and '--self-test' not in sys.argv:
|
||||||
|
from monitor_runtime.windows import message
|
||||||
|
message(str(exc), True)
|
||||||
|
raise SystemExit(1)
|
||||||
1
monitor_runtime/__init__.py
Normal file
1
monitor_runtime/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Windows desktop deployment support. No service starts on import."""
|
||||||
94
monitor_runtime/bootstrap.py
Normal file
94
monitor_runtime/bootstrap.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
"""Fresh-install initialization and non-destructive version upgrades."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from monitor_runtime.paths import data, resource, atomic_json
|
||||||
|
|
||||||
|
def prepare_files():
|
||||||
|
data('').mkdir(parents=True, exist_ok=True)
|
||||||
|
for name in ('.runtime', 'log', 'backups', 'static/upload/weight', 'static/upload/audio',
|
||||||
|
'static/storage', '.runtime/zlm', '.runtime/ultralytics/Ultralytics', '.runtime/matplotlib'):
|
||||||
|
data(name).mkdir(parents=True, exist_ok=True)
|
||||||
|
for target, source in (('config.json', 'deploy/windows/default-config.json'),
|
||||||
|
('settings.json', 'deploy/windows/default-settings.json'),
|
||||||
|
('zlm-template.ini', 'deploy/windows/zlm-template.ini')):
|
||||||
|
if not data(target).exists():
|
||||||
|
shutil.copyfile(resource(source), data(target))
|
||||||
|
os.environ['YOLO_CONFIG_DIR'] = str(data('.runtime/ultralytics'))
|
||||||
|
os.environ['MPLCONFIGDIR'] = str(data('.runtime/matplotlib'))
|
||||||
|
os.environ['YOLO_OFFLINE'] = 'true'
|
||||||
|
os.environ['YOLO_AUTOINSTALL'] = 'false'
|
||||||
|
os.environ['CI'] = 'true'
|
||||||
|
|
||||||
|
def initialize_database():
|
||||||
|
import django
|
||||||
|
os.environ['DJANGO_SETTINGS_MODULE'] = 'framework.settings'
|
||||||
|
os.environ['MONITOR_SERVICE_MODE'] = 'disabled'
|
||||||
|
os.environ['MONITOR_BOOTSTRAP_SERVICES'] = 'false'
|
||||||
|
django.setup()
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.db import connection
|
||||||
|
from django.db.migrations.executor import MigrationExecutor
|
||||||
|
executor = MigrationExecutor(connection)
|
||||||
|
plan = executor.migration_plan(executor.loader.graph.leaf_nodes())
|
||||||
|
db = data('monitor.sqlite3')
|
||||||
|
if plan and db.exists() and db.stat().st_size:
|
||||||
|
backup = data('backups') / datetime.now().strftime('%Y%m%d-%H%M%S-%f')
|
||||||
|
backup.mkdir()
|
||||||
|
with sqlite3.connect(str(db)) as src, sqlite3.connect(str(backup / db.name)) as dst:
|
||||||
|
src.backup(dst)
|
||||||
|
for name in ('config.json', 'settings.json', 'network.json', '.runtime-secrets.json', 'license.json'):
|
||||||
|
if data(name).exists():
|
||||||
|
shutil.copyfile(data(name), backup / name)
|
||||||
|
call_command('migrate', interactive=False, verbosity=0)
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
for role in ('system_admin', 'algorithm_admin', 'operator', 'viewer'):
|
||||||
|
Group.objects.get_or_create(name=role)
|
||||||
|
from app.utils.Credentials import migrate_existing_credentials
|
||||||
|
migrate_existing_credentials()
|
||||||
|
|
||||||
|
def has_admin():
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
marker = data('.runtime/initialized.json')
|
||||||
|
if marker.exists():
|
||||||
|
return True
|
||||||
|
exists = get_user_model().objects.filter(is_superuser=True).exists()
|
||||||
|
if exists:
|
||||||
|
atomic_json(marker, {'schema_version': 1, 'initialized': True})
|
||||||
|
return exists
|
||||||
|
|
||||||
|
def local_addresses():
|
||||||
|
import socket
|
||||||
|
import psutil
|
||||||
|
return sorted({a.address for values in psutil.net_if_addrs().values() for a in values
|
||||||
|
if a.family == socket.AF_INET and not a.address.startswith(('127.', '169.254.'))
|
||||||
|
and a.address != '0.0.0.0'})
|
||||||
|
|
||||||
|
def finish_setup(username, password, address):
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.password_validation import validate_password
|
||||||
|
from django.db import transaction
|
||||||
|
from monitor_runtime.licensing import require_license
|
||||||
|
require_license()
|
||||||
|
if address not in local_addresses():
|
||||||
|
raise ValueError('请选择本机有效的局域网 IPv4 地址')
|
||||||
|
if len(password) < 12:
|
||||||
|
raise ValueError('管理员密码至少 12 位')
|
||||||
|
username = username.strip()
|
||||||
|
if not username or len(username) > 150:
|
||||||
|
raise ValueError('管理员用户名无效')
|
||||||
|
user = get_user_model()(username=username)
|
||||||
|
validate_password(password, user)
|
||||||
|
with transaction.atomic():
|
||||||
|
if has_admin():
|
||||||
|
raise ValueError('初始化已完成')
|
||||||
|
cfg = json.loads(data('config.json').read_text(encoding='utf-8'))
|
||||||
|
cfg['host'] = address
|
||||||
|
cfg.setdefault('sipServer', {})['sipServerIp'] = address
|
||||||
|
atomic_json(data('config.json'), cfg)
|
||||||
|
atomic_json(data('network.json'), {'allowed_hosts': [address], 'address': address})
|
||||||
|
get_user_model().objects.create_superuser(username=username, password=password)
|
||||||
|
transaction.on_commit(lambda: atomic_json(data('.runtime/initialized.json'),
|
||||||
|
{'schema_version': 1, 'initialized': True}))
|
||||||
67
monitor_runtime/diagnostics.py
Normal file
67
monitor_runtime/diagnostics.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"""Offline dependency and frozen-spawn smoke test, with no camera/model access."""
|
||||||
|
import json
|
||||||
|
import multiprocessing
|
||||||
|
from monitor_runtime.paths import data, resource, atomic_json
|
||||||
|
|
||||||
|
def spawn_probe(queue):
|
||||||
|
import numpy as np
|
||||||
|
import cv2
|
||||||
|
queue.put({'resized': list(cv2.resize(np.zeros((4,4,3),dtype=np.uint8),(2,2)).shape)})
|
||||||
|
|
||||||
|
def run():
|
||||||
|
from monitor_runtime.bootstrap import prepare_files, initialize_database
|
||||||
|
prepare_files()
|
||||||
|
initialize_database()
|
||||||
|
import django
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
import torchvision
|
||||||
|
import onnxruntime
|
||||||
|
import openvino as ov
|
||||||
|
from ultralytics import YOLO
|
||||||
|
from ultralytics.utils import USER_CONFIG_DIR
|
||||||
|
assert USER_CONFIG_DIR.resolve().is_relative_to(data('').resolve())
|
||||||
|
import pystray
|
||||||
|
import whitenoise
|
||||||
|
result = {'django':django.get_version(),'opencv':cv2.__version__,'torch':torch.__version__,
|
||||||
|
'torchvision':torchvision.__version__,'onnxruntime':onnxruntime.__version__,
|
||||||
|
'openvino':ov.__version__}
|
||||||
|
assert torch.version.cuda is None
|
||||||
|
assert (torch.ones(2,2) @ torch.ones(2,2)).tolist() == [[2.,2.],[2.,2.]]
|
||||||
|
assert 'CPUExecutionProvider' in onnxruntime.get_available_providers()
|
||||||
|
# Architecture-only random weights: no model download, no customer model or pickle.
|
||||||
|
detector = YOLO('yolo26n.yaml', task='detect')
|
||||||
|
predictions = detector.predict(np.zeros((64,64,3), dtype=np.uint8), imgsz=64, device='cpu', verbose=False)
|
||||||
|
assert len(predictions) == 1 and predictions[0].orig_shape == (64,64)
|
||||||
|
result['yolo_cpu_inference'] = 'passed (random weights; no accuracy claim)'
|
||||||
|
parameter = ov.opset13.parameter([1],np.float32)
|
||||||
|
model = ov.Model([ov.opset13.relu(parameter)],[parameter])
|
||||||
|
compiled = ov.Core().compile_model(model,'CPU')
|
||||||
|
assert float(compiled([np.array([-1],dtype=np.float32)])[0][0]) == 0
|
||||||
|
ctx=multiprocessing.get_context('spawn')
|
||||||
|
queue=ctx.Queue()
|
||||||
|
process=ctx.Process(target=spawn_probe,args=(queue,))
|
||||||
|
process.start()
|
||||||
|
try:
|
||||||
|
assert queue.get(timeout=60)['resized']==[2,2,3]
|
||||||
|
process.join(timeout=20)
|
||||||
|
assert process.exitcode==0
|
||||||
|
finally:
|
||||||
|
if process.is_alive():
|
||||||
|
process.terminate()
|
||||||
|
process.join(timeout=10)
|
||||||
|
queue.close()
|
||||||
|
from django.test import Client
|
||||||
|
client=Client(HTTP_HOST='localhost')
|
||||||
|
response=client.get('/setup',REMOTE_ADDR='127.0.0.1')
|
||||||
|
assert response.status_code in (200,403)
|
||||||
|
assert resource('public-static/lib/js/app.js').is_file()
|
||||||
|
assert resource('zlm/monitor_zlm.exe').is_file()
|
||||||
|
assert resource('tools/ffmpeg.exe').is_file()
|
||||||
|
from monitor_runtime.licensing import machine_identity
|
||||||
|
assert machine_identity()['fingerprint'].startswith('v1:')
|
||||||
|
result.update(status='passed',spawn='passed',cpu='passed',resources='passed',
|
||||||
|
writable_caches='ProgramData/test data root')
|
||||||
|
atomic_json(data('.runtime/self-test.json'),result)
|
||||||
|
return 0
|
||||||
286
monitor_runtime/launcher.py
Normal file
286
monitor_runtime/launcher.py
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
"""Tray supervisor with authenticated local control and owned process cleanup."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import multiprocessing as mp
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import webbrowser
|
||||||
|
from monitor_runtime.paths import data, resource, atomic_json
|
||||||
|
|
||||||
|
def worker(address, auth):
|
||||||
|
from multiprocessing.connection import Client
|
||||||
|
connection = Client(address, family='AF_PIPE', authkey=bytes.fromhex(auth))
|
||||||
|
try:
|
||||||
|
if connection.recv() != 'run':
|
||||||
|
return 1
|
||||||
|
from monitor_runtime.service import run
|
||||||
|
return run(connection)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--worker', nargs=2, metavar=('PIPE', 'AUTH'))
|
||||||
|
parser.add_argument('--stop', action='store_true')
|
||||||
|
parser.add_argument('--self-test', action='store_true')
|
||||||
|
parser.add_argument('--trust-model')
|
||||||
|
parser.add_argument('--yes', action='store_true')
|
||||||
|
args = parser.parse_args()
|
||||||
|
if os.name != 'nt':
|
||||||
|
raise RuntimeError('Monitor desktop requires Windows')
|
||||||
|
data('.runtime').mkdir(parents=True, exist_ok=True)
|
||||||
|
data('log').mkdir(parents=True, exist_ok=True)
|
||||||
|
logging.basicConfig(filename=str(data('log/desktop.log')), level=logging.INFO,
|
||||||
|
format='%(asctime)s %(levelname)s %(message)s')
|
||||||
|
if args.self_test:
|
||||||
|
from monitor_runtime.diagnostics import run
|
||||||
|
return run()
|
||||||
|
if args.worker:
|
||||||
|
return worker(*args.worker)
|
||||||
|
if args.trust_model:
|
||||||
|
return trust_model(args.trust_model, args.yes)
|
||||||
|
from app.services.lifecycle import ServiceLeaderLock
|
||||||
|
# Separate from the service lock; held for the entire supervisor lifetime.
|
||||||
|
lock = ServiceLeaderLock(data('.runtime/desktop.lock'))
|
||||||
|
if not lock.acquire():
|
||||||
|
return control_existing('stop' if args.stop else 'open')
|
||||||
|
if args.stop:
|
||||||
|
lock.release()
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return Supervisor().run()
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
def control_existing(command):
|
||||||
|
from multiprocessing.connection import Client
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
state = json.loads(data('.runtime/desktop.json').read_text(encoding='utf-8'))
|
||||||
|
with Client(state['pipe'], family='AF_PIPE', authkey=bytes.fromhex(state['auth'])) as connection:
|
||||||
|
connection.send(command)
|
||||||
|
response = connection.recv()
|
||||||
|
break
|
||||||
|
except (OSError, EOFError, json.JSONDecodeError):
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise RuntimeError('已有实例的控制接口不可用,请查看日志')
|
||||||
|
time.sleep(.2)
|
||||||
|
if command == 'stop':
|
||||||
|
deadline = time.monotonic() + 40
|
||||||
|
from app.services.lifecycle import ServiceLeaderLock
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
lock = ServiceLeaderLock(data('.runtime/desktop.lock'))
|
||||||
|
if lock.acquire():
|
||||||
|
lock.release()
|
||||||
|
return 0
|
||||||
|
time.sleep(.3)
|
||||||
|
raise RuntimeError('Monitor 未能在 40 秒内退出,请查看日志')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def trust_model(filename, confirmed):
|
||||||
|
from pathlib import Path
|
||||||
|
from app.utils.ModelTrust import sha256_file
|
||||||
|
from monitor_runtime.windows import message
|
||||||
|
model = Path(filename).resolve()
|
||||||
|
if model.suffix.lower() != '.pt' or not model.is_file():
|
||||||
|
raise ValueError('请选择存在的 .pt 模型文件')
|
||||||
|
digest = sha256_file(model)
|
||||||
|
if not confirmed:
|
||||||
|
message('模型 SHA-256: ' + digest + '\n确认来源可信后使用 --yes 重新执行。')
|
||||||
|
return 2
|
||||||
|
path = data('.trusted-models.json')
|
||||||
|
doc = json.loads(path.read_text(encoding='utf-8')) if path.exists() else {'trusted_sha256': []}
|
||||||
|
doc['trusted_sha256'] = sorted(set(doc['trusted_sha256']) | {digest})
|
||||||
|
atomic_json(path, doc)
|
||||||
|
message('已登记可信模型 SHA-256:' + digest)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
class Supervisor:
|
||||||
|
def __init__(self):
|
||||||
|
self.stop = threading.Event()
|
||||||
|
self.restart = threading.Event()
|
||||||
|
self.url = ''
|
||||||
|
self.status = '正在启动'
|
||||||
|
self.icon = None
|
||||||
|
self.child = None
|
||||||
|
self.pipe = None
|
||||||
|
self.job = None
|
||||||
|
|
||||||
|
def open_web(self, *_):
|
||||||
|
if self.url:
|
||||||
|
webbrowser.open(self.url)
|
||||||
|
|
||||||
|
def exit(self, *_):
|
||||||
|
self.stop.set()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
import pystray
|
||||||
|
from PIL import Image
|
||||||
|
from multiprocessing.connection import Listener
|
||||||
|
auth = secrets.token_bytes(32)
|
||||||
|
address = r'\\.\pipe\MonitorControl-' + secrets.token_hex(16)
|
||||||
|
self.control = Listener(address, family='AF_PIPE', authkey=auth)
|
||||||
|
atomic_json(data('.runtime/desktop.json'), {'pipe': address, 'auth': auth.hex()})
|
||||||
|
threading.Thread(target=self.control_loop, daemon=True).start()
|
||||||
|
menu = pystray.Menu(
|
||||||
|
pystray.MenuItem('打开管理网页', self.open_web, default=True),
|
||||||
|
pystray.MenuItem(lambda _: self.status, lambda *_: None, enabled=False),
|
||||||
|
pystray.MenuItem('查看状态', lambda *_: self.show_status()),
|
||||||
|
pystray.MenuItem('打开日志目录', lambda *_: os.startfile(str(data('log')))),
|
||||||
|
pystray.MenuItem('重启服务', lambda *_: self.restart.set()),
|
||||||
|
pystray.MenuItem('退出', self.exit))
|
||||||
|
self.icon = pystray.Icon('Monitor', Image.open(resource('static/images/logo.png')), 'Monitor', menu)
|
||||||
|
threading.Thread(target=self.supervise, daemon=True).start()
|
||||||
|
try:
|
||||||
|
self.icon.run()
|
||||||
|
finally:
|
||||||
|
self.stop.set()
|
||||||
|
self.control.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def show_status(self):
|
||||||
|
from monitor_runtime.windows import message
|
||||||
|
message(self.status + '\n' + self.url.split('#')[0])
|
||||||
|
|
||||||
|
def control_loop(self):
|
||||||
|
while not self.stop.is_set():
|
||||||
|
try:
|
||||||
|
with self.control.accept() as conn:
|
||||||
|
command = conn.recv()
|
||||||
|
if command == 'stop':
|
||||||
|
self.stop.set()
|
||||||
|
elif command == 'open':
|
||||||
|
self.open_web()
|
||||||
|
conn.send('ok')
|
||||||
|
except (OSError, EOFError):
|
||||||
|
return
|
||||||
|
|
||||||
|
def start_child(self):
|
||||||
|
from multiprocessing.connection import Listener
|
||||||
|
from monitor_runtime.windows import Job
|
||||||
|
auth = secrets.token_bytes(32)
|
||||||
|
address = r'\\.\pipe\MonitorWorker-' + secrets.token_hex(16)
|
||||||
|
listener = Listener(address, family='AF_PIPE', authkey=auth)
|
||||||
|
args = [sys.executable]
|
||||||
|
if not getattr(sys, 'frozen', False):
|
||||||
|
args.append(str(resource('monitor_entry.py')))
|
||||||
|
args += ['--worker', address, auth.hex()]
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['MONITOR_DESKTOP'] = '1'
|
||||||
|
env['MONITOR_SERVICE_MODE'] = 'disabled'
|
||||||
|
env['MONITOR_BOOTSTRAP_SERVICES'] = 'false'
|
||||||
|
env['MONITOR_DEBUG'] = 'false'
|
||||||
|
env['MONITOR_DATA_DIR'] = str(data(''))
|
||||||
|
self.job = Job()
|
||||||
|
log = open(data('log/service.log'), 'ab', buffering=0)
|
||||||
|
try:
|
||||||
|
self.child = subprocess.Popen(args, cwd=str(data('')), env=env, stdin=subprocess.DEVNULL,
|
||||||
|
stdout=log, stderr=log, creationflags=subprocess.CREATE_NO_WINDOW)
|
||||||
|
self.job.assign(self.child)
|
||||||
|
finally:
|
||||||
|
log.close()
|
||||||
|
# Child waits for run; Job assignment therefore precedes any child spawn.
|
||||||
|
accepted = []
|
||||||
|
def accept():
|
||||||
|
try:
|
||||||
|
accepted.append(listener.accept())
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
thread = threading.Thread(target=accept, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
deadline = time.monotonic() + 30
|
||||||
|
while not accepted:
|
||||||
|
if self.child.poll() is not None or time.monotonic() > deadline or self.stop.wait(.1):
|
||||||
|
listener.close()
|
||||||
|
raise RuntimeError('服务进程未能建立控制连接,请查看 service.log')
|
||||||
|
self.pipe = accepted[0]
|
||||||
|
listener.close()
|
||||||
|
self.pipe.send('run')
|
||||||
|
|
||||||
|
def stop_child(self):
|
||||||
|
if self.child and self.child.poll() is None:
|
||||||
|
try:
|
||||||
|
self.pipe.send('stop')
|
||||||
|
except (AttributeError, EOFError, OSError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self.child.wait(timeout=25)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logging.error('Graceful shutdown timed out; closing owned job')
|
||||||
|
if self.pipe:
|
||||||
|
self.pipe.close()
|
||||||
|
self.pipe = None
|
||||||
|
if self.job:
|
||||||
|
self.job.close()
|
||||||
|
self.job = None
|
||||||
|
self.child = None
|
||||||
|
|
||||||
|
def supervise(self):
|
||||||
|
try:
|
||||||
|
while not self.stop.is_set():
|
||||||
|
self.restart.clear()
|
||||||
|
self.status = '正在启动'
|
||||||
|
self.start_child()
|
||||||
|
deadline = time.monotonic() + 180
|
||||||
|
last_heartbeat = time.monotonic()
|
||||||
|
opened = False
|
||||||
|
auto_restart = False
|
||||||
|
try:
|
||||||
|
while not self.stop.is_set() and not self.restart.is_set():
|
||||||
|
if self.pipe.poll(.3):
|
||||||
|
message = self.pipe.recv()
|
||||||
|
last_heartbeat = time.monotonic()
|
||||||
|
phase = message.get('phase')
|
||||||
|
if phase == 'failed':
|
||||||
|
raise RuntimeError(message.get('reason', '服务启动失败'))
|
||||||
|
self.status = {'ready': '服务运行中', 'setup': '等待首次初始化',
|
||||||
|
'license': '等待有效授权'}.get(phase, phase)
|
||||||
|
if message.get('url'):
|
||||||
|
self.url = message['url']
|
||||||
|
if not opened:
|
||||||
|
self.open_web()
|
||||||
|
opened = True
|
||||||
|
self.icon.update_menu()
|
||||||
|
code = self.child.poll()
|
||||||
|
if code is not None:
|
||||||
|
if code == 20:
|
||||||
|
auto_restart = True
|
||||||
|
break
|
||||||
|
raise RuntimeError('服务进程退出,代码 %s' % code)
|
||||||
|
if not opened and time.monotonic() > deadline:
|
||||||
|
raise RuntimeError('启动超过 180 秒,请查看日志')
|
||||||
|
if opened and time.monotonic() - last_heartbeat > 35:
|
||||||
|
raise RuntimeError('服务健康状态超时')
|
||||||
|
except EOFError:
|
||||||
|
code = self.child.wait(timeout=30)
|
||||||
|
if code == 20:
|
||||||
|
auto_restart = True
|
||||||
|
else:
|
||||||
|
raise RuntimeError('服务控制连接断开,代码 %s' % code)
|
||||||
|
finally:
|
||||||
|
self.stop_child()
|
||||||
|
if not auto_restart and not self.restart.is_set():
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
logging.exception('Supervisor failure')
|
||||||
|
self.status = '启动失败:' + str(exc)
|
||||||
|
self.url = ''
|
||||||
|
self.icon.update_menu()
|
||||||
|
from monitor_runtime.windows import message
|
||||||
|
message(self.status + '\n日志:' + str(data('log')), True)
|
||||||
|
self.stop_child()
|
||||||
|
# Keep tray available; retries are explicit, never an infinite crash loop.
|
||||||
|
while not self.stop.wait(.3):
|
||||||
|
if self.restart.is_set():
|
||||||
|
self.supervise()
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
self.stop_child()
|
||||||
|
if self.stop.is_set():
|
||||||
|
self.icon.stop()
|
||||||
144
monitor_runtime/licensing.py
Normal file
144
monitor_runtime/licensing.py
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
"""Offline signed licenses. Private signing material is never used here."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||||
|
from monitor_runtime.paths import DESKTOP, data, public_key_path, atomic_json
|
||||||
|
|
||||||
|
PRODUCT = 'monitor'
|
||||||
|
MAX_LICENSE_BYTES = 32768
|
||||||
|
_GUARD = threading.RLock()
|
||||||
|
|
||||||
|
class LicenseError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def canonical(payload):
|
||||||
|
return json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode('utf-8')
|
||||||
|
|
||||||
|
def timestamp(value):
|
||||||
|
dt = datetime.fromisoformat(value.replace('Z', '+00:00'))
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
raise LicenseError('授权时间必须包含时区')
|
||||||
|
return dt.timestamp()
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def machine_identity():
|
||||||
|
if os.name != 'nt':
|
||||||
|
raise LicenseError('机器授权仅支持 Windows')
|
||||||
|
import winreg
|
||||||
|
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r'SOFTWARE\Microsoft\Cryptography',
|
||||||
|
0, winreg.KEY_READ | winreg.KEY_WOW64_64KEY) as key:
|
||||||
|
guid = str(winreg.QueryValueEx(key, 'MachineGuid')[0]).strip().lower()
|
||||||
|
result = subprocess.run(
|
||||||
|
['powershell.exe', '-NoProfile', '-NonInteractive', '-Command',
|
||||||
|
'(Get-CimInstance Win32_ComputerSystemProduct).UUID'],
|
||||||
|
capture_output=True, timeout=20, creationflags=subprocess.CREATE_NO_WINDOW, check=True)
|
||||||
|
uuid = result.stdout.decode('utf-8', errors='replace').strip().lower()
|
||||||
|
import uuid as uuid_module
|
||||||
|
try:
|
||||||
|
uuid = str(uuid_module.UUID(uuid))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise LicenseError('无法读取系统 UUID;请检查 Windows CIM 服务') from exc
|
||||||
|
weak = uuid in ('00000000-0000-0000-0000-000000000000', 'ffffffff-ffff-ffff-ffff-ffffffffffff')
|
||||||
|
if not guid:
|
||||||
|
raise LicenseError('无法读取 Windows MachineGuid')
|
||||||
|
digest = hashlib.sha256(('monitor-machine-v1|' + uuid + '|' + guid).encode()).hexdigest()
|
||||||
|
return {'fingerprint': 'v1:' + digest, 'weak_uuid': weak}
|
||||||
|
|
||||||
|
def request_document():
|
||||||
|
return {'schema_version': 1, 'product': PRODUCT, **machine_identity()}
|
||||||
|
|
||||||
|
def verify_document(document, public_pem, fingerprint, now=None):
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
try:
|
||||||
|
if set(document) != {'payload', 'signature'}:
|
||||||
|
raise LicenseError('授权文件结构无效')
|
||||||
|
payload = document['payload']
|
||||||
|
required = {'schema_version', 'product', 'license_id', 'customer', 'machine_fingerprint',
|
||||||
|
'issued_at', 'expires_at'}
|
||||||
|
if not isinstance(payload, dict) or set(payload) != required:
|
||||||
|
raise LicenseError('授权字段无效')
|
||||||
|
key = serialization.load_pem_public_key(public_pem)
|
||||||
|
if not isinstance(key, Ed25519PublicKey):
|
||||||
|
raise LicenseError('发行公钥类型无效')
|
||||||
|
key.verify(base64.b64decode(document['signature'], validate=True), canonical(payload))
|
||||||
|
if payload['schema_version'] != 1 or payload['product'] != PRODUCT:
|
||||||
|
raise LicenseError('授权版本或产品不匹配')
|
||||||
|
if payload['machine_fingerprint'] != fingerprint:
|
||||||
|
raise LicenseError('授权与本机不匹配')
|
||||||
|
if not all(isinstance(payload[k], str) and 0 < len(payload[k]) <= 200 for k in ('customer', 'license_id')):
|
||||||
|
raise LicenseError('客户或授权编号无效')
|
||||||
|
if timestamp(payload['issued_at']) > now + 300:
|
||||||
|
raise LicenseError('授权尚未生效,或系统时间不正确')
|
||||||
|
if payload['expires_at'] is not None:
|
||||||
|
expiry = timestamp(payload['expires_at'])
|
||||||
|
if expiry <= timestamp(payload['issued_at']) or now >= expiry:
|
||||||
|
raise LicenseError('授权已过期')
|
||||||
|
return payload
|
||||||
|
except LicenseError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise LicenseError('授权签名或格式无效') from exc
|
||||||
|
|
||||||
|
def _clock(now):
|
||||||
|
path = data('license-clock.json')
|
||||||
|
previous = 0
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
from monitor_runtime.windows import protect
|
||||||
|
encoded = json.loads(path.read_text(encoding='utf-8'))['protected']
|
||||||
|
previous = float(protect(base64.b64decode(encoded), decrypt=True).decode('ascii'))
|
||||||
|
except Exception as exc:
|
||||||
|
raise LicenseError('本地授权时间记录损坏,请恢复备份并检查系统时间') from exc
|
||||||
|
if now + 300 < previous:
|
||||||
|
raise LicenseError('检测到系统时间回拨,请校准系统时间')
|
||||||
|
if now > previous + 60:
|
||||||
|
from monitor_runtime.windows import protect
|
||||||
|
encoded = base64.b64encode(protect(str(now).encode('ascii'), machine=True)).decode('ascii')
|
||||||
|
atomic_json(path, {'protected': encoded})
|
||||||
|
|
||||||
|
def check_license():
|
||||||
|
if not DESKTOP:
|
||||||
|
return {'valid': True, 'reason': 'source development'}
|
||||||
|
with _GUARD:
|
||||||
|
try:
|
||||||
|
path = data('license.json')
|
||||||
|
if not path.exists():
|
||||||
|
raise LicenseError('尚未导入授权')
|
||||||
|
if path.stat().st_size > MAX_LICENSE_BYTES:
|
||||||
|
raise LicenseError('授权文件过大')
|
||||||
|
pem = public_key_path().read_bytes()
|
||||||
|
payload = verify_document(json.loads(path.read_text(encoding='utf-8')),
|
||||||
|
pem, machine_identity()['fingerprint'])
|
||||||
|
_clock(time.time())
|
||||||
|
return {'valid': True, 'reason': '授权有效', 'license': payload}
|
||||||
|
except Exception as exc:
|
||||||
|
return {'valid': False, 'reason': str(exc) if isinstance(exc, LicenseError) else '无法读取发行公钥或授权文件'}
|
||||||
|
|
||||||
|
def require_license():
|
||||||
|
result = check_license()
|
||||||
|
if not result['valid']:
|
||||||
|
raise LicenseError(result['reason'])
|
||||||
|
|
||||||
|
def import_license(raw):
|
||||||
|
if len(raw) > MAX_LICENSE_BYTES:
|
||||||
|
raise LicenseError('授权文件过大')
|
||||||
|
with _GUARD:
|
||||||
|
try:
|
||||||
|
document = json.loads(raw)
|
||||||
|
payload = verify_document(document, public_key_path().read_bytes(), machine_identity()['fingerprint'])
|
||||||
|
_clock(time.time())
|
||||||
|
except LicenseError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise LicenseError('无法读取授权文件或发行公钥') from exc
|
||||||
|
atomic_json(data('license.json'), document)
|
||||||
|
return payload
|
||||||
46
monitor_runtime/paths.py
Normal file
46
monitor_runtime/paths.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"""Separate immutable resources from installation-owned mutable data."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from monitor_runtime.release import RELEASE_BUILD
|
||||||
|
|
||||||
|
RESOURCE_ROOT = Path(getattr(sys, '_MEIPASS', Path(__file__).resolve().parents[1])).resolve()
|
||||||
|
if not RELEASE_BUILD and not getattr(sys, 'frozen', False) and os.environ.get('MONITOR_DEV_RESOURCE_ROOT'):
|
||||||
|
RESOURCE_ROOT = Path(os.environ['MONITOR_DEV_RESOURCE_ROOT']).resolve()
|
||||||
|
DESKTOP = RELEASE_BUILD or bool(getattr(sys, 'frozen', False)) or os.environ.get('MONITOR_DESKTOP') == '1'
|
||||||
|
DATA_ROOT = Path(os.environ.get('MONITOR_DATA_DIR') or (
|
||||||
|
Path(os.environ.get('PROGRAMDATA', 'C:/ProgramData')) / 'Monitor' if DESKTOP else RESOURCE_ROOT
|
||||||
|
)).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def resource(name):
|
||||||
|
return RESOURCE_ROOT / name
|
||||||
|
|
||||||
|
|
||||||
|
def data(name):
|
||||||
|
return DATA_ROOT / name
|
||||||
|
|
||||||
|
|
||||||
|
def public_key_path():
|
||||||
|
if not RELEASE_BUILD and not getattr(sys, 'frozen', False):
|
||||||
|
override = os.environ.get('MONITOR_DEV_PUBLIC_KEY')
|
||||||
|
if override:
|
||||||
|
return Path(override)
|
||||||
|
return resource('license-public.pem')
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_json(path, value):
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
path = Path(path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, temporary = tempfile.mkstemp(prefix=path.name, suffix='.tmp', dir=path.parent)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
|
||||||
|
json.dump(value, handle, ensure_ascii=False, indent=2)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temporary, path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temporary):
|
||||||
|
os.unlink(temporary)
|
||||||
2
monitor_runtime/release.py
Normal file
2
monitor_runtime/release.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
"""Replaced with a compiled constant by the release builder."""
|
||||||
|
RELEASE_BUILD = False
|
||||||
85
monitor_runtime/service.py
Normal file
85
monitor_runtime/service.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"""One Waitress process owns the background lifecycle and analysis state."""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from monitor_runtime.paths import data, resource, atomic_json
|
||||||
|
from monitor_runtime.bootstrap import prepare_files, initialize_database, has_admin
|
||||||
|
from monitor_runtime.licensing import check_license
|
||||||
|
|
||||||
|
logger = logging.getLogger('monitor.runtime')
|
||||||
|
|
||||||
|
def run(connection):
|
||||||
|
server = None
|
||||||
|
manager = None
|
||||||
|
ready = False
|
||||||
|
try:
|
||||||
|
prepare_files()
|
||||||
|
initialize_database()
|
||||||
|
from monitor_runtime import web
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
from whitenoise import WhiteNoise
|
||||||
|
from waitress import create_server
|
||||||
|
from app.utils.GlobalUtils import g_config
|
||||||
|
from app.services.lifecycle import get_service_manager
|
||||||
|
manager = get_service_manager()
|
||||||
|
licensed = check_license()['valid']
|
||||||
|
initialized = has_admin()
|
||||||
|
port = g_config.adminPort
|
||||||
|
app = WhiteNoise(get_wsgi_application(), root=str(resource('public-static')), prefix='static/')
|
||||||
|
address = '0.0.0.0' if initialized and licensed else '127.0.0.1'
|
||||||
|
server = create_server(app, host=address, port=port, threads=8, channel_timeout=60,
|
||||||
|
max_request_body_size=536870912)
|
||||||
|
thread = threading.Thread(target=server.run, name='web-server', daemon=True)
|
||||||
|
thread.start()
|
||||||
|
if licensed and initialized:
|
||||||
|
if not manager.start():
|
||||||
|
raise RuntimeError('后台服务已被其他进程占用')
|
||||||
|
phase = 'ready' if licensed and initialized else 'setup' if not initialized else 'license'
|
||||||
|
web.SERVICE_STATE.update(phase=phase, reason=check_license()['reason'])
|
||||||
|
url = 'http://127.0.0.1:%d/%s' % (port, 'login' if phase == 'ready' else phase)
|
||||||
|
if phase == 'setup':
|
||||||
|
url += '#' + web.SETUP_TOKEN
|
||||||
|
connection.send({'phase': phase, 'url': url, 'reason': web.SERVICE_STATE['reason']})
|
||||||
|
ready = True
|
||||||
|
last_check = time.monotonic()
|
||||||
|
failures = 0
|
||||||
|
while thread.is_alive():
|
||||||
|
if connection.poll(1):
|
||||||
|
message = connection.recv()
|
||||||
|
if message == 'stop':
|
||||||
|
return 0
|
||||||
|
if web.RESTART_REQUESTED.is_set():
|
||||||
|
return 20
|
||||||
|
if time.monotonic() - last_check >= 5:
|
||||||
|
last_check = time.monotonic()
|
||||||
|
status = check_license()
|
||||||
|
if not status['valid'] and manager.is_leader:
|
||||||
|
web.SERVICE_STATE.update(phase='license', reason=status['reason'])
|
||||||
|
connection.send({'phase': 'license', 'url': 'http://127.0.0.1:%d/license' % port,
|
||||||
|
'reason': status['reason']})
|
||||||
|
# Restart loopback-only for renewal and reliably discard all worker descendants.
|
||||||
|
return 20
|
||||||
|
if manager.is_leader:
|
||||||
|
from app.utils.MediaServerManager import get_media_server_manager
|
||||||
|
media = get_media_server_manager()
|
||||||
|
failures = 0 if media.managed_pid() and media.api_alive() else failures + 1
|
||||||
|
if failures >= 3:
|
||||||
|
raise RuntimeError('ZLM 健康检查失败;请查看日志并从托盘重启')
|
||||||
|
connection.send({'phase': web.SERVICE_STATE['phase'], 'heartbeat': True})
|
||||||
|
raise RuntimeError('Web 服务意外退出')
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception('Desktop service failed')
|
||||||
|
try:
|
||||||
|
connection.send({'phase': 'failed', 'reason': str(exc)})
|
||||||
|
except (EOFError, OSError):
|
||||||
|
pass
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
if manager:
|
||||||
|
manager.stop()
|
||||||
|
if server:
|
||||||
|
server.close()
|
||||||
|
connection.close()
|
||||||
162
monitor_runtime/web.py
Normal file
162
monitor_runtime/web.py
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
"""Local bootstrap and administrator-only license management."""
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import threading
|
||||||
|
from django.http import HttpResponse, JsonResponse, HttpResponseRedirect, FileResponse
|
||||||
|
from django.template import engines
|
||||||
|
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
from monitor_runtime.paths import data, resource, DESKTOP
|
||||||
|
from monitor_runtime.licensing import check_license, import_license, request_document
|
||||||
|
from monitor_runtime.bootstrap import has_admin, finish_setup, local_addresses
|
||||||
|
|
||||||
|
SETUP_TOKEN = secrets.token_urlsafe(32)
|
||||||
|
SETUP_LOCK = threading.Lock()
|
||||||
|
RESTART_REQUESTED = threading.Event()
|
||||||
|
SERVICE_STATE = {'phase': 'starting', 'reason': ''}
|
||||||
|
|
||||||
|
PAGE = """<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||||||
|
<title>Monitor 安装与授权</title><style>
|
||||||
|
body{font:16px/1.7 "Microsoft YaHei",sans-serif;background:#eef2f6;color:#182635;margin:0;padding:32px}
|
||||||
|
main{max-width:720px;margin:auto;background:white;border-radius:16px;padding:32px;box-shadow:0 4px 30px #17334815}
|
||||||
|
h1{font-size:26px;margin-top:0}label{display:block;margin-top:14px}input,select,button{font:inherit;padding:9px;border:1px solid #cad4de;border-radius:7px}
|
||||||
|
input:not([type=file]),select{width:95%}button{background:#125cc8;color:white;cursor:pointer;margin-top:20px}
|
||||||
|
.notice{padding:14px;background:#eef4ff;border-radius:8px;overflow-wrap:anywhere}.error{color:#b32828}
|
||||||
|
small{display:block;color:#667482}a{color:#125cc8}</style><main>
|
||||||
|
<h1>{% if setup %}首次安装设置{% else %}授权管理{% endif %}</h1>
|
||||||
|
<p class="notice">{{ status.reason }}{% if status.license %}<br>客户:{{ status.license.customer }}<br>
|
||||||
|
有效期:{{ status.license.expires_at|default:"永久" }}{% endif %}</p>
|
||||||
|
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||||||
|
<p>导出本机申请文件,交给发行方签发,再导入签名授权文件。授权绑定当前电脑。</p>
|
||||||
|
<form method="post" action="/license/request">{% csrf_token %}<input type="hidden" name="setup_token">
|
||||||
|
<button>导出机器申请文件</button></form>
|
||||||
|
<form method="post" action="/license/import" enctype="multipart/form-data">{% csrf_token %}
|
||||||
|
<input type="hidden" name="setup_token"><label>签名授权文件 <input type="file" name="license" accept=".json" required></label>
|
||||||
|
<button>导入授权{% if not setup %}并重启服务{% endif %}</button></form>
|
||||||
|
{% if setup %}<form method="post" action="/setup">{% csrf_token %}<input type="hidden" name="setup_token">
|
||||||
|
<label>管理员用户名<input name="username" autocomplete="username" required maxlength="150"></label>
|
||||||
|
<label>密码(至少 12 位)<input type="password" name="password" autocomplete="new-password" required minlength="12"></label>
|
||||||
|
<label>确认密码<input type="password" name="confirm" autocomplete="new-password" required></label>
|
||||||
|
<label>局域网网卡<select name="address" required>{% for ip in addresses %}<option>{{ ip }}</option>{% endfor %}</select></label>
|
||||||
|
<small>请先导入授权。初始化只允许从安装电脑完成;完成后局域网用户才能登录。</small>
|
||||||
|
<button>创建管理员并启动</button></form>{% else %}<p><a href="/login">返回登录</a> · <a href="/">进入系统</a></p>{% endif %}
|
||||||
|
<script>
|
||||||
|
const key='monitor-setup-token'; if(location.hash){sessionStorage.setItem(key,location.hash.slice(1));history.replaceState(null,'',location.pathname);}
|
||||||
|
document.querySelectorAll('[name=setup_token]').forEach(e=>e.value=sessionStorage.getItem(key)||'');
|
||||||
|
{% if not setup %}sessionStorage.removeItem(key);{% endif %}
|
||||||
|
</script></main></html>"""
|
||||||
|
|
||||||
|
def is_local(request):
|
||||||
|
return request.META.get('REMOTE_ADDR') in ('127.0.0.1', '::1')
|
||||||
|
|
||||||
|
def admin_allowed(request):
|
||||||
|
user = getattr(request, 'user', None)
|
||||||
|
return bool(user and user.is_authenticated and (
|
||||||
|
user.is_superuser or user.groups.filter(name='system_admin').exists()))
|
||||||
|
|
||||||
|
def authorized(request, write=False):
|
||||||
|
if not has_admin():
|
||||||
|
if not is_local(request):
|
||||||
|
return False
|
||||||
|
return not write or secrets.compare_digest(request.POST.get('setup_token', ''), SETUP_TOKEN)
|
||||||
|
return admin_allowed(request)
|
||||||
|
|
||||||
|
def page(request, error='', code=200):
|
||||||
|
html = engines['django'].from_string(PAGE).render({
|
||||||
|
'setup': not has_admin(), 'status': check_license(), 'error': error,
|
||||||
|
'addresses': local_addresses() if not has_admin() else [],
|
||||||
|
}, request)
|
||||||
|
response = HttpResponse(html, status=code)
|
||||||
|
response['Cache-Control'] = 'no-store'
|
||||||
|
response['Referrer-Policy'] = 'no-referrer'
|
||||||
|
return response
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@ensure_csrf_cookie
|
||||||
|
@require_http_methods(['GET', 'POST'])
|
||||||
|
def setup(request):
|
||||||
|
if has_admin() or not authorized(request, request.method == 'POST'):
|
||||||
|
return HttpResponse('初始化入口不可用', status=403)
|
||||||
|
if request.method == 'POST':
|
||||||
|
try:
|
||||||
|
with SETUP_LOCK:
|
||||||
|
if request.POST.get('password') != request.POST.get('confirm'):
|
||||||
|
raise ValueError('两次密码输入不一致')
|
||||||
|
finish_setup(request.POST.get('username', ''), request.POST.get('password', ''),
|
||||||
|
request.POST.get('address', ''))
|
||||||
|
RESTART_REQUESTED.set()
|
||||||
|
return HttpResponse('<meta charset="utf-8"><p>设置完成,服务正在重启。稍后重新打开桌面快捷方式进入登录页面。</p>')
|
||||||
|
except Exception as exc:
|
||||||
|
return page(request, str(exc), 400)
|
||||||
|
return page(request)
|
||||||
|
|
||||||
|
@ensure_csrf_cookie
|
||||||
|
def license_page(request):
|
||||||
|
if not authorized(request):
|
||||||
|
return HttpResponseRedirect('/login') if not getattr(request.user, 'is_authenticated', False) else HttpResponse(status=403)
|
||||||
|
return page(request)
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def license_request(request):
|
||||||
|
if not authorized(request, True):
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
response = JsonResponse(request_document(), json_dumps_params={'indent': 2})
|
||||||
|
response['Content-Disposition'] = 'attachment; filename="monitor-license-request.json"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def license_import(request):
|
||||||
|
if not authorized(request, True):
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
try:
|
||||||
|
uploaded = request.FILES.get('license')
|
||||||
|
if not uploaded or uploaded.size > 32768:
|
||||||
|
raise ValueError('请选择小于 32KB 的授权文件')
|
||||||
|
import_license(uploaded.read())
|
||||||
|
if has_admin():
|
||||||
|
RESTART_REQUESTED.set()
|
||||||
|
return page(request)
|
||||||
|
except Exception as exc:
|
||||||
|
return page(request, str(exc), 400)
|
||||||
|
|
||||||
|
def license_status(request):
|
||||||
|
if not authorized(request):
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
return JsonResponse({**check_license(), 'service': SERVICE_STATE})
|
||||||
|
|
||||||
|
def private_file(request, path, area='storage'):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
root = data('static/storage' if area == 'storage' else 'static/upload/audio').resolve()
|
||||||
|
filename = (root / path).resolve()
|
||||||
|
allowed = {'.jpg', '.jpeg', '.png', '.mp4', '.wav', '.mp3', '.ogg'}
|
||||||
|
if not filename.is_relative_to(root) or not filename.is_file() or filename.suffix.lower() not in allowed:
|
||||||
|
return HttpResponse(status=404)
|
||||||
|
response = FileResponse(filename.open('rb'))
|
||||||
|
response['Cache-Control'] = 'private, no-store'
|
||||||
|
response['X-Content-Type-Options'] = 'nosniff'
|
||||||
|
return response
|
||||||
|
|
||||||
|
class LicenseMiddleware:
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
if not DESKTOP:
|
||||||
|
return self.get_response(request)
|
||||||
|
path = request.path_info
|
||||||
|
# WhiteNoise only serves immutable build-time assets, never mutable data.
|
||||||
|
public_asset = path.startswith('/static/') and not path.startswith(('/static/storage/', '/static/upload/'))
|
||||||
|
exceptions = {'/setup', '/license', '/license/status', '/license/request', '/license/import',
|
||||||
|
'/login', '/logout', '/user/openCaptcha'}
|
||||||
|
if not has_admin() and not public_asset and path not in {'/setup', '/license', '/license/request', '/license/import', '/license/status'}:
|
||||||
|
return HttpResponseRedirect('/setup') if is_local(request) else HttpResponse(status=403)
|
||||||
|
if path not in exceptions and not public_asset:
|
||||||
|
status = check_license()
|
||||||
|
if not status['valid']:
|
||||||
|
if '/open' in path or path.startswith('/inner/'):
|
||||||
|
return JsonResponse({'code': 0, 'msg': status['reason'], 'error': 'license_invalid'}, status=403)
|
||||||
|
return HttpResponseRedirect('/license')
|
||||||
|
return self.get_response(request)
|
||||||
72
monitor_runtime/windows.py
Normal file
72
monitor_runtime/windows.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
"""Small Win32 helpers, imported lazily on Windows."""
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
import os
|
||||||
|
|
||||||
|
class Blob(ctypes.Structure):
|
||||||
|
_fields_ = [('size', wintypes.DWORD), ('ptr', ctypes.POINTER(ctypes.c_byte))]
|
||||||
|
|
||||||
|
def protect(raw, decrypt=False, machine=False):
|
||||||
|
if os.name != 'nt':
|
||||||
|
raise RuntimeError('Windows DPAPI is required')
|
||||||
|
buffer = ctypes.create_string_buffer(raw)
|
||||||
|
src = Blob(len(raw), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_byte)))
|
||||||
|
dst = Blob()
|
||||||
|
crypt = ctypes.WinDLL('crypt32', use_last_error=True)
|
||||||
|
if decrypt:
|
||||||
|
ok = crypt.CryptUnprotectData(ctypes.byref(src), None, None, None, None, 1, ctypes.byref(dst))
|
||||||
|
else:
|
||||||
|
ok = crypt.CryptProtectData(ctypes.byref(src), 'Monitor', None, None, None,
|
||||||
|
1 | (4 if machine else 0), ctypes.byref(dst))
|
||||||
|
if not ok:
|
||||||
|
raise ctypes.WinError(ctypes.get_last_error())
|
||||||
|
try:
|
||||||
|
return ctypes.string_at(dst.ptr, dst.size)
|
||||||
|
finally:
|
||||||
|
kernel = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||||
|
kernel.LocalFree.argtypes = [ctypes.c_void_p]
|
||||||
|
kernel.LocalFree(dst.ptr)
|
||||||
|
|
||||||
|
def message(text, error=False):
|
||||||
|
ctypes.windll.user32.MessageBoxW(None, str(text), 'Monitor', 0x10 if error else 0x40)
|
||||||
|
|
||||||
|
class Job:
|
||||||
|
"""Kill all owned descendants when the supervisor exits."""
|
||||||
|
def __init__(self):
|
||||||
|
class Basic(ctypes.Structure):
|
||||||
|
_fields_ = [('process_time', ctypes.c_int64), ('job_time', ctypes.c_int64),
|
||||||
|
('flags', wintypes.DWORD), ('min_ws', ctypes.c_size_t),
|
||||||
|
('max_ws', ctypes.c_size_t), ('active', wintypes.DWORD),
|
||||||
|
('affinity', ctypes.c_size_t), ('priority', wintypes.DWORD),
|
||||||
|
('scheduling', wintypes.DWORD)]
|
||||||
|
class IO(ctypes.Structure):
|
||||||
|
_fields_ = [(n, ctypes.c_uint64) for n in ('ro','wo','oo','rb','wb','ob')]
|
||||||
|
class Extended(ctypes.Structure):
|
||||||
|
_fields_ = [('basic', Basic), ('io', IO), ('process_memory', ctypes.c_size_t),
|
||||||
|
('job_memory', ctypes.c_size_t), ('peak_process', ctypes.c_size_t),
|
||||||
|
('peak_job', ctypes.c_size_t)]
|
||||||
|
self.kernel = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||||
|
self.kernel.CreateJobObjectW.restype = wintypes.HANDLE
|
||||||
|
self.kernel.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
|
||||||
|
self.kernel.SetInformationJobObject.argtypes = [wintypes.HANDLE, ctypes.c_int,
|
||||||
|
ctypes.c_void_p, wintypes.DWORD]
|
||||||
|
self.kernel.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
|
||||||
|
self.kernel.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||||
|
self.handle = self.kernel.CreateJobObjectW(None, None)
|
||||||
|
if not self.handle:
|
||||||
|
raise ctypes.WinError(ctypes.get_last_error())
|
||||||
|
info = Extended()
|
||||||
|
info.basic.flags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||||
|
if not self.kernel.SetInformationJobObject(self.handle, 9, ctypes.byref(info), ctypes.sizeof(info)):
|
||||||
|
self.close()
|
||||||
|
raise ctypes.WinError(ctypes.get_last_error())
|
||||||
|
|
||||||
|
def assign(self, process):
|
||||||
|
if not self.kernel.AssignProcessToJobObject(self.handle, wintypes.HANDLE(int(process._handle))):
|
||||||
|
process.kill()
|
||||||
|
raise ctypes.WinError(ctypes.get_last_error())
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.handle:
|
||||||
|
self.kernel.CloseHandle(self.handle)
|
||||||
|
self.handle = None
|
||||||
6
requirements-windows-gpu.txt
Normal file
6
requirements-windows-gpu.txt
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Optional local NVIDIA GPU runtime for Windows / Python 3.12.
|
||||||
|
# Install after requirements-windows.txt with:
|
||||||
|
# python -m pip install -r requirements-windows-gpu.txt
|
||||||
|
--index-url https://download.pytorch.org/whl/cu129
|
||||||
|
torch==2.8.0+cu129
|
||||||
|
torchvision==0.23.0+cu129
|
||||||
13
scripts/enable-gpu.ps1
Normal file
13
scripts/enable-gpu.ps1
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
$python = Join-Path $root '.venv\Scripts\python.exe'
|
||||||
|
if (-not (Test-Path -LiteralPath $python)) {
|
||||||
|
throw "Python virtual environment not found: $python"
|
||||||
|
}
|
||||||
|
& $python -m pip install --upgrade --force-reinstall --no-deps `
|
||||||
|
torch==2.8.0+cu129 torchvision==0.23.0+cu129 `
|
||||||
|
--index-url https://download.pytorch.org/whl/cu129
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'CUDA PyTorch installation failed' }
|
||||||
|
& $python -c "import torch; assert torch.cuda.is_available(), 'CUDA is unavailable'; print(torch.__version__, torch.version.cuda, torch.cuda.get_device_name(0))"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'CUDA verification failed' }
|
||||||
|
Write-Host 'GPU inference runtime is ready. Restart Monitor and select CUDA for the YOLO model.' -ForegroundColor Green
|
||||||
@ -1,8 +1,4 @@
|
|||||||
"""Explicit Windows webcam service: loopback only, video only, no disk recording.
|
"""Open the local webcam and register it without starting ZLMediaKit or FFmpeg."""
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
import argparse
|
import argparse
|
||||||
import configparser
|
import configparser
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@ -32,9 +28,10 @@ STATUS = WORK / 'status.json'
|
|||||||
STOP = WORK / 'stop.request'
|
STOP = WORK / 'stop.request'
|
||||||
STREAM = 'laptop_cam'
|
STREAM = 'laptop_cam'
|
||||||
DEVICE = 'Integrated Camera'
|
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}
|
PORTS = {'http': 10002, 'rtsp': 10554, 'rtmp': 10935}
|
||||||
PUSH_URL = f'rtmp://127.0.0.1:{PORTS["rtmp"]}/live/{STREAM}'
|
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):
|
def write_json(path, data):
|
||||||
@ -167,26 +164,31 @@ def terminate(child):
|
|||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
|
"""Keep the physical camera open and register an inventory-only stream."""
|
||||||
os.environ.update(local_environment())
|
os.environ.update(local_environment())
|
||||||
from app.services.lifecycle import ServiceLeaderLock
|
camera = row = None
|
||||||
lock = ServiceLeaderLock()
|
StreamModel = None
|
||||||
if not lock.acquire():
|
|
||||||
raise RuntimeError('Another background-service leader exists; refusing to start')
|
|
||||||
media = camera = hook = None
|
|
||||||
row = None
|
|
||||||
try:
|
try:
|
||||||
web = web_process()
|
web = web_process()
|
||||||
if not web:
|
if not web:
|
||||||
raise RuntimeError('Start the local web server first')
|
raise RuntimeError('Start the local web server first')
|
||||||
for conn in psutil.net_connections('inet'):
|
import cv2
|
||||||
# TIME_WAIT sockets left by a terminated ZLM have no owning
|
camera = cv2.VideoCapture(CAMERA_INDEX, cv2.CAP_DSHOW)
|
||||||
# process (pid 0/None on Windows); only a live owner blocks start.
|
if not camera.isOpened():
|
||||||
if conn.laddr and conn.laddr.port in PORTS.values() and conn.pid:
|
camera.release()
|
||||||
raise RuntimeError('Media port occupied; no unrelated process was stopped')
|
camera = cv2.VideoCapture(CAMERA_INDEX)
|
||||||
config_data = json.loads((ROOT / 'config.json').read_text(encoding='utf-8'))
|
if not camera.isOpened():
|
||||||
for key, section in [('mediaHttpPort', 'http'), ('mediaRtspPort', 'rtsp'), ('mediaRtmpPort', 'rtmp')]:
|
raise RuntimeError(f'Camera open failed: index={CAMERA_INDEX}; check Windows camera permission/device availability')
|
||||||
if int(config_data[key]) != PORTS[section]:
|
camera.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
|
||||||
raise RuntimeError('Configured media ports changed; review local webcam settings first')
|
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
|
from django.conf import settings
|
||||||
database = Path(settings.DATABASES['default']['NAME'])
|
database = Path(settings.DATABASES['default']['NAME'])
|
||||||
backup_dir = WORK / ('backup-' + datetime.now().strftime('%Y%m%d-%H%M%S-%f'))
|
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:
|
with sqlite3.connect(backup_dir / database.name) as target:
|
||||||
source.backup(target)
|
source.backup(target)
|
||||||
import django
|
import django
|
||||||
django.setup() # Services explicitly disabled; normal existing schema migration only.
|
django.setup()
|
||||||
from app.models import StreamModel
|
from app.models import StreamModel
|
||||||
existing = StreamModel.objects.filter(code=STREAM).first()
|
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')
|
raise RuntimeError('laptop_cam belongs to another stream; refusing to overwrite')
|
||||||
from app.utils.Secrets import get_runtime_secret
|
camera_url = f'camera://index/{CAMERA_INDEX}'
|
||||||
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')
|
|
||||||
row, _ = StreamModel.objects.get_or_create(code=STREAM, defaults={
|
row, _ = StreamModel.objects.get_or_create(code=STREAM, defaults={
|
||||||
'user_id': 0, 'sort': 0, 'app': 'live', 'name': STREAM, 'nickname': '笔记本内置摄像头',
|
'user_id': 0, 'sort': 0, 'app': 'live', 'name': STREAM, 'nickname': '笔记本内置摄像头',
|
||||||
'remark': MARKER, 'pull_stream_type': 32, 'pull_stream_transfer_mode': 0,
|
'remark': MARKER, 'pull_stream_type': 0, 'pull_stream_transfer_mode': 0,
|
||||||
'pull_stream_url': PUSH_URL, 'pull_stream_ip': '127.0.0.1',
|
'pull_stream_url': camera_url, 'pull_stream_ip': '127.0.0.1', 'pull_stream_port': 0,
|
||||||
'pull_stream_port': PORTS['rtmp'], 'forward_state': 1, 'is_audio': 0,
|
'forward_state': 0, 'is_audio': 0, 'record_enable': 0, 'state': 0,
|
||||||
'record_enable': 0, 'state': 0, 'camera_name': DEVICE, 'camera_device_id': 'local-webcam'})
|
'camera_name': DEVICE, 'camera_device_id': 'local-webcam'})
|
||||||
if row.remark != MARKER:
|
if not (row.remark or '').startswith('Managed by scripts/local_webcam.py;'):
|
||||||
raise RuntimeError('Stream ownership changed')
|
raise RuntimeError('Stream ownership changed')
|
||||||
StreamModel.objects.filter(pk=row.pk).update(forward_state=1, record_enable=0, is_audio=0)
|
StreamModel.objects.filter(pk=row.pk).update(
|
||||||
print('Ready: laptop_cam (Integrated Camera, 640x480/15fps, video only)', flush=True)
|
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():
|
while not STOP.exists():
|
||||||
if not web.is_running() or media.poll() is not None or camera.poll() is not None:
|
if not web.is_running():
|
||||||
raise RuntimeError('Web/camera/media process exited; shutting down capture')
|
raise RuntimeError('Local web server exited; stopping camera')
|
||||||
managed = [psutil.Process(os.getpid()), psutil.Process(media.pid), psutil.Process(camera.pid)]
|
ok, frame = camera.read()
|
||||||
listeners = check_connections(managed, {*PORTS.values(), hook.server_port})
|
if not ok or frame is None:
|
||||||
info = api('getMediaInfo', schema='rtmp', vhost='__defaultVhost__', app='live', stream=STREAM)
|
raise RuntimeError('Camera frame read failed')
|
||||||
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')
|
|
||||||
write_json(STATUS, {'ready': True, 'stream_id': row.pk, 'stream': STREAM, 'device': DEVICE,
|
write_json(STATUS, {'ready': True, 'stream_id': row.pk, 'stream': STREAM, 'device': DEVICE,
|
||||||
'media_pid': media.pid, 'camera_pid': camera.pid,
|
'camera_index': CAMERA_INDEX, 'width': width, 'height': height, 'fps': fps,
|
||||||
'loopback_ports': sorted(listeners), 'tracks': info['tracks'],
|
'media_started': False, 'zlm_started': False, 'audio': False, 'recording': False,
|
||||||
'audio': False, 'recording': False, 'sip': False,
|
'sip': False, 'checked_at': datetime.now().isoformat(timespec='seconds')})
|
||||||
'checked_at': datetime.now().isoformat(timespec='seconds')})
|
time.sleep(0.2)
|
||||||
time.sleep(2)
|
|
||||||
finally:
|
finally:
|
||||||
terminate(camera)
|
if camera is not None:
|
||||||
terminate(media)
|
camera.release()
|
||||||
if hook:
|
if row is not None and StreamModel is not None:
|
||||||
hook.shutdown()
|
|
||||||
hook.server_close()
|
|
||||||
if row is not None:
|
|
||||||
StreamModel.objects.filter(pk=row.pk, remark=MARKER).update(forward_state=0)
|
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')})
|
write_json(STATUS, {'ready': False, 'stopped_at': datetime.now().isoformat(timespec='seconds')})
|
||||||
|
|
||||||
|
|
||||||
@ -307,7 +253,7 @@ def start():
|
|||||||
if child.poll() is not None:
|
if child.poll() is not None:
|
||||||
raise RuntimeError('Webcam start failed; inspect .runtime/local-webcam/supervisor.log')
|
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'):
|
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
|
return
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
STOP.touch()
|
STOP.touch()
|
||||||
@ -325,7 +271,7 @@ def stop():
|
|||||||
except psutil.TimeoutExpired:
|
except psutil.TimeoutExpired:
|
||||||
raise RuntimeError('Graceful stop timed out; inspect owned processes before retrying')
|
raise RuntimeError('Graceful stop timed out; inspect owned processes before retrying')
|
||||||
PID_FILE.unlink(missing_ok=True)
|
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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
74
scripts/start-services.ps1
Normal file
74
scripts/start-services.ps1
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
$py = "$projectRoot\.venv\Scripts\python.exe"
|
||||||
|
$runservicesLog = "$projectRoot\.runtime\runservices.stdout.log"
|
||||||
|
$runservicesErr = "$projectRoot\.runtime\runservices.stderr.log"
|
||||||
|
$pidFile = "$projectRoot\.runtime\runservices.json"
|
||||||
|
$action = 'start'
|
||||||
|
if ($args.Count -gt 0) { $action = $args[0] }
|
||||||
|
|
||||||
|
function Get-ProjectServices {
|
||||||
|
Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
|
||||||
|
Where-Object { $_.CommandLine -like '*manage.py runservices*' -and $_.CommandLine -like '*video_monitor*' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-Descendants([int]$ParentId) {
|
||||||
|
$children = @()
|
||||||
|
Get-CimInstance Win32_Process -Filter "ParentProcessId=$ParentId" -ErrorAction SilentlyContinue | ForEach-Object {
|
||||||
|
$children += $_
|
||||||
|
$children += Get-Descendants $_.ProcessId
|
||||||
|
}
|
||||||
|
$children
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
'start' {
|
||||||
|
$existing = Get-ProjectServices
|
||||||
|
if ($existing) {
|
||||||
|
Write-Host "runservices already running: PIDs $($existing.ProcessId -join ', ')"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Force -Path "$projectRoot\.runtime" | Out-Null
|
||||||
|
$p = Start-Process -FilePath $py -ArgumentList @('manage.py','runservices') `
|
||||||
|
-WorkingDirectory $projectRoot -WindowStyle Hidden `
|
||||||
|
-RedirectStandardOutput $runservicesLog -RedirectStandardError $runservicesErr -PassThru
|
||||||
|
@{ pid = $p.Id; created = (Get-Date).ToString('o') } | ConvertTo-Json | Set-Content $pidFile -Encoding UTF8
|
||||||
|
Write-Host "runservices started PID=$($p.Id)"
|
||||||
|
Start-Sleep -Seconds 20
|
||||||
|
$zlm = Get-CimInstance Win32_Process -Filter "Name='monitor_zlm.exe'" -ErrorAction SilentlyContinue
|
||||||
|
$sip = Get-NetUDPEndpoint -LocalPort 15060 -ErrorAction SilentlyContinue
|
||||||
|
if ($zlm) { Write-Host "ZLM up PID=$($zlm.ProcessId -join ', ')" } else { Write-Host 'ZLM not up yet; inspect log' }
|
||||||
|
if ($sip) { Write-Host 'SIP 15060 listening' } else { Write-Host 'SIP 15060 not listening yet' }
|
||||||
|
}
|
||||||
|
'stop' {
|
||||||
|
$procs = Get-ProjectServices
|
||||||
|
if (-not $procs) {
|
||||||
|
Write-Host 'No runservices process running'
|
||||||
|
Remove-Item $pidFile -ErrorAction SilentlyContinue
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
foreach ($proc in $procs) {
|
||||||
|
$desc = Get-Descendants $proc.ProcessId
|
||||||
|
foreach ($child in $desc) {
|
||||||
|
Stop-Process -Id $child.ProcessId -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue
|
||||||
|
Write-Host "stopped runservices PID=$($proc.ProcessId) (children: $($desc.ProcessId -join ', '))"
|
||||||
|
}
|
||||||
|
Remove-Item $pidFile -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
'status' {
|
||||||
|
$procs = Get-ProjectServices
|
||||||
|
$zlm = Get-CimInstance Win32_Process -Filter "Name='monitor_zlm.exe'" -ErrorAction SilentlyContinue
|
||||||
|
$sip = Get-NetUDPEndpoint -LocalPort 15060 -ErrorAction SilentlyContinue
|
||||||
|
[pscustomobject]@{
|
||||||
|
runservices = ($procs | ForEach-Object { $_.ProcessId }) -join ', '
|
||||||
|
zlm = ($zlm | ForEach-Object { $_.ProcessId }) -join ', '
|
||||||
|
sip_15060 = [bool]$sip
|
||||||
|
} | Format-List
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
Write-Host "Usage: .\scripts\start-services.ps1 [start|stop|status]"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
BIN
static/upload/weight/20260831110138_20260831_yolo26x.pt
Normal file
BIN
static/upload/weight/20260831110138_20260831_yolo26x.pt
Normal file
Binary file not shown.
BIN
static/upload/weight/20260831_yolo26x.pt
Normal file
BIN
static/upload/weight/20260831_yolo26x.pt
Normal file
Binary file not shown.
@ -429,6 +429,22 @@
|
|||||||
.em-empty{text-align:center;padding:40px 20px;color:#9ca3af;font-size:13px;
|
.em-empty{text-align:center;padding:40px 20px;color:#9ca3af;font-size:13px;
|
||||||
background:#f9fafb;border-radius:10px;border:1px dashed #e5e7eb;}
|
background:#f9fafb;border-radius:10px;border:1px dashed #e5e7eb;}
|
||||||
.em-empty .ic{display:block;font-size:32px;margin-bottom:8px;opacity:.4;}
|
.em-empty .ic{display:block;font-size:32px;margin-bottom:8px;opacity:.4;}
|
||||||
|
|
||||||
|
/* 实时 YOLO 预览 */
|
||||||
|
.yolo-preview-modal{width:min(1180px,96vw);background:#0f172a;border-radius:12px;overflow:hidden;
|
||||||
|
box-shadow:0 24px 70px rgba(0,0,0,.35);color:#e2e8f0;display:flex;flex-direction:column;}
|
||||||
|
.yp-head{height:54px;padding:0 18px;background:#fff;color:#1f2937;display:flex;align-items:center;justify-content:space-between;}
|
||||||
|
.yp-title{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:600;}
|
||||||
|
.yp-live-dot{width:9px;height:9px;border-radius:50%;background:#10b981;box-shadow:0 0 0 4px rgba(16,185,129,.15);}
|
||||||
|
.yp-stage{position:relative;width:100%;aspect-ratio:16/9;background:#020617;overflow:hidden;}
|
||||||
|
#yoloPreviewPlayer,#yoloPreviewPlayer>*{width:100%;height:100%;}
|
||||||
|
#yoloPreviewCanvas{position:absolute;inset:0;width:100%;height:100%;z-index:5;pointer-events:none;}
|
||||||
|
.yp-loading{position:absolute;inset:0;z-index:6;display:flex;align-items:center;justify-content:center;background:rgba(2,6,23,.72);color:#cbd5e1;}
|
||||||
|
.yp-foot{min-height:52px;padding:10px 16px;background:#111827;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
|
||||||
|
.yp-stat{font-size:12px;color:#cbd5e1;background:#1f2937;border:1px solid #334155;border-radius:14px;padding:4px 10px;}
|
||||||
|
.yp-mode{font-size:12px;font-weight:600;border-radius:14px;padding:4px 10px;background:#064e3b;color:#a7f3d0;}
|
||||||
|
.yp-mode.preview{background:#78350f;color:#fde68a;}
|
||||||
|
.act-btn.act-preview{color:#0f766e}.act-btn.act-preview:hover{background:#ecfdf5;color:#059669;}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -577,6 +593,12 @@
|
|||||||
<div class="zm-detect-preview" id="detectPreview"></div>
|
<div class="zm-detect-preview" id="detectPreview"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="zm-field">
|
||||||
|
<label>重复报警间隔(秒)</label>
|
||||||
|
<input id="f_alarm_repeat" type="number" value="30" min="0" max="86400" step="1"
|
||||||
|
title="0 表示同一次停留不重复报警;离开后重新进入仍会立即报警">
|
||||||
|
<div class="biz-hint" style="margin-top:5px;font-size:11px;color:#64748b;">首次进入立即报警;0 表示同一次停留只报一次</div>
|
||||||
|
</div>
|
||||||
<div class="zm-field">
|
<div class="zm-field">
|
||||||
<label>{{ T.zone_algorithms|default:'业务算法' }} <span style="font-weight:400;color:#9ca3af;font-size:11px;">({{ T.zone_algorithms_hint|default:'可多选' }})</span></label>
|
<label>{{ T.zone_algorithms|default:'业务算法' }} <span style="font-weight:400;color:#9ca3af;font-size:11px;">({{ T.zone_algorithms_hint|default:'可多选' }})</span></label>
|
||||||
<div class="zm-algo-wrap">
|
<div class="zm-algo-wrap">
|
||||||
@ -601,6 +623,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 实时 YOLO 检测弹框 -->
|
||||||
|
<div class="engine-modal-overlay" id="yoloPreviewModal" onclick="if(event.target===this)closeYoloPreview()">
|
||||||
|
<div class="yolo-preview-modal" role="dialog" aria-modal="true" aria-label="实时 YOLO 检测">
|
||||||
|
<div class="yp-head">
|
||||||
|
<div class="yp-title"><span class="yp-live-dot"></span><span id="ypTitle">实时 YOLO 人员检测</span></div>
|
||||||
|
<button type="button" class="engine-modal-close" onclick="closeYoloPreview()" aria-label="close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="yp-stage" id="yoloPreviewStage">
|
||||||
|
<div id="yoloPreviewPlayer"></div>
|
||||||
|
<canvas id="yoloPreviewCanvas"></canvas>
|
||||||
|
<div class="yp-loading" id="ypLoading">正在启动 YOLO 分析并连接视频流…</div>
|
||||||
|
</div>
|
||||||
|
<div class="yp-foot">
|
||||||
|
<span class="yp-mode preview" id="ypMode">预览模式,不产生报警</span>
|
||||||
|
<span class="yp-stat" id="ypModel">模型:—</span>
|
||||||
|
<span class="yp-stat" id="ypFps">分析 FPS:0.00</span>
|
||||||
|
<span class="yp-stat" id="ypAge">结果延迟:—</span>
|
||||||
|
<span class="yp-stat" id="ypCount">当前人员:0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 布控实例管理弹框 -->
|
<!-- 布控实例管理弹框 -->
|
||||||
<div class="engine-modal-overlay" id="engineModal" onclick="if(event.target===this)closeEngineModal()">
|
<div class="engine-modal-overlay" id="engineModal" onclick="if(event.target===this)closeEngineModal()">
|
||||||
<div class="engine-modal" role="dialog">
|
<div class="engine-modal" role="dialog">
|
||||||
@ -684,7 +728,9 @@ function refreshAnalysisUI(){
|
|||||||
var badge = document.getElementById('analysisStatusBadge');
|
var badge = document.getElementById('analysisStatusBadge');
|
||||||
if(!sid){
|
if(!sid){
|
||||||
badge.style.display = 'inline-flex';
|
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.className = 'analysis-badge ' + (n>0?'running':'stopped');
|
||||||
badge.textContent = _t('zone_analysis_running_count','分析中:{n}').replace('{n}', n);
|
badge.textContent = _t('zone_analysis_running_count','分析中:{n}').replace('{n}', n);
|
||||||
return;
|
return;
|
||||||
@ -704,6 +750,13 @@ function refreshAnalysisUI(){
|
|||||||
fpsStr = ' · ' + (info.analysis_fps||0).toFixed(1) + ' fps';
|
fpsStr = ' · ' + (info.analysis_fps||0).toFixed(1) + ' fps';
|
||||||
}
|
}
|
||||||
var healthStr = running ? (' · ' + streamHealthLabel(health)) : '';
|
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){
|
if(g_lastAnalysisData && g_lastAnalysisData.inference_degraded && running){
|
||||||
healthStr += ' · ' + _t('zone_inference_degraded','推理异常');
|
healthStr += ' · ' + _t('zone_inference_degraded','推理异常');
|
||||||
if(health === 'ok') badge.className = 'analysis-badge stalled';
|
if(health === 'ok') badge.className = 'analysis-badge stalled';
|
||||||
@ -724,6 +777,7 @@ function refreshAnalysisUI(){
|
|||||||
|
|
||||||
var SVG_ANALYZE_START = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none"/></svg>';
|
var SVG_ANALYZE_START = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none"/></svg>';
|
||||||
var SVG_ANALYZE_STOP = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="6" width="12" height="12" rx="1"/></svg>';
|
var SVG_ANALYZE_STOP = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="6" width="12" height="12" rx="1"/></svg>';
|
||||||
|
var SVG_PREVIEW = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M8 20v2m8-2v2"/></svg>';
|
||||||
var SVG_EDIT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
|
var SVG_EDIT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
|
||||||
var SVG_DEL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>';
|
var SVG_DEL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>';
|
||||||
var SVG_LOADING = '<svg class="act-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round"/></svg>';
|
var SVG_LOADING = '<svg class="act-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round"/></svg>';
|
||||||
@ -754,9 +808,12 @@ function buildZoneStateTag(z){
|
|||||||
return '<span class="tag tag-off">'+_t('zone_status_stopped','已停止')+'</span>';
|
return '<span class="tag tag-off">'+_t('zone_status_stopped','已停止')+'</span>';
|
||||||
}
|
}
|
||||||
var info = getStreamStatusInfo(z.stream_id);
|
var info = getStreamStatusInfo(z.stream_id);
|
||||||
if(!info || info.running === false){
|
if(!info){
|
||||||
return '<span class="tag tag-warn">'+_t('zone_status_starting','启动中')+'</span>';
|
return '<span class="tag tag-warn">'+_t('zone_status_starting','启动中')+'</span>';
|
||||||
}
|
}
|
||||||
|
if(info.analysis_health === 'error' || info.analysis_health === 'stalled'){
|
||||||
|
return '<span class="tag tag-err">'+_t('zone_status_inference_error','推理异常')+'</span>';
|
||||||
|
}
|
||||||
var health = getStreamHealth(z.stream_id);
|
var health = getStreamHealth(z.stream_id);
|
||||||
var fps = info.analysis_fps != null ? info.analysis_fps : 0;
|
var fps = info.analysis_fps != null ? info.analysis_fps : 0;
|
||||||
if(health === 'stalled' || health === 'disconnected'){
|
if(health === 'stalled' || health === 'disconnected'){
|
||||||
@ -765,7 +822,8 @@ function buildZoneStateTag(z){
|
|||||||
if(health === 'reconnecting' || health === 'connecting'){
|
if(health === 'reconnecting' || health === 'connecting'){
|
||||||
return '<span class="tag tag-warn">'+streamHealthLabel(health)+'</span>';
|
return '<span class="tag tag-warn">'+streamHealthLabel(health)+'</span>';
|
||||||
}
|
}
|
||||||
if(health === 'ok' && fps > 0.05){
|
if(info.running !== false && health === 'ok' &&
|
||||||
|
(info.analysis_health === 'running' || (!info.analysis_health && fps > 0.05))){
|
||||||
return '<span class="tag tag-on">'+_t('zone_status_running','分析中')+'</span>';
|
return '<span class="tag tag-on">'+_t('zone_status_running','分析中')+'</span>';
|
||||||
}
|
}
|
||||||
return '<span class="tag tag-warn">'+_t('zone_status_starting','启动中')+'</span>';
|
return '<span class="tag tag-warn">'+_t('zone_status_starting','启动中')+'</span>';
|
||||||
@ -1251,6 +1309,7 @@ function renderZoneTable(zones){
|
|||||||
var nm='<div style="display:flex;align-items:center;gap:6px;"><span style="width:9px;height:9px;border-radius:50%;background:'+escapeHtml(z.color||'#169F85')+';flex-shrink:0;"></span>'+escapeHtml(z.name)+'</div>';
|
var nm='<div style="display:flex;align-items:center;gap:6px;"><span style="width:9px;height:9px;border-radius:50%;background:'+escapeHtml(z.color||'#169F85')+';flex-shrink:0;"></span>'+escapeHtml(z.name)+'</div>';
|
||||||
var actions = '<div class="zone-actions">'
|
var actions = '<div class="zone-actions">'
|
||||||
+'<span class="zone-run-btn">'+buildZoneRunBtn(z)+'</span>'
|
+'<span class="zone-run-btn">'+buildZoneRunBtn(z)+'</span>'
|
||||||
|
+'<button type="button" class="act-btn act-preview" onclick="openYoloPreview('+z.id+')" title="实时 YOLO 检测">'+SVG_PREVIEW+'</button>'
|
||||||
+'<button type="button" class="act-btn" onclick="editZone('+z.id+')" title="'+escapeHtml(_t('btn_edit','编辑'))+'">'+SVG_EDIT+'</button>'
|
+'<button type="button" class="act-btn" onclick="editZone('+z.id+')" title="'+escapeHtml(_t('btn_edit','编辑'))+'">'+SVG_EDIT+'</button>'
|
||||||
+'<button type="button" class="act-btn act-danger" onclick="delZone('+z.id+')" title="'+escapeHtml(_t('btn_del','删除'))+'">'+SVG_DEL+'</button>'
|
+'<button type="button" class="act-btn act-danger" onclick="delZone('+z.id+')" title="'+escapeHtml(_t('btn_del','删除'))+'">'+SVG_DEL+'</button>'
|
||||||
+'</div>';
|
+'</div>';
|
||||||
@ -1333,6 +1392,7 @@ function editZone(id){
|
|||||||
document.getElementById('f_loiter').value=z.loiter_threshold;
|
document.getElementById('f_loiter').value=z.loiter_threshold;
|
||||||
document.getElementById('f_detect_interval').value=z.detect_interval_sec||1;
|
document.getElementById('f_detect_interval').value=z.detect_interval_sec||1;
|
||||||
document.getElementById('f_detect_frames').value=z.detect_frames||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();
|
updateDetectPreview();
|
||||||
document.getElementById('f_color').value=z.color;
|
document.getElementById('f_color').value=z.color;
|
||||||
syncColorSwatches(z.color);
|
syncColorSwatches(z.color);
|
||||||
@ -1357,6 +1417,7 @@ function openZoneModal(){
|
|||||||
document.getElementById('f_loiter').value=0;
|
document.getElementById('f_loiter').value=0;
|
||||||
document.getElementById('f_detect_interval').value=1;
|
document.getElementById('f_detect_interval').value=1;
|
||||||
document.getElementById('f_detect_frames').value=1;
|
document.getElementById('f_detect_frames').value=1;
|
||||||
|
document.getElementById('f_alarm_repeat').value=30;
|
||||||
updateDetectPreview();
|
updateDetectPreview();
|
||||||
document.getElementById('f_color').value='#169F85';
|
document.getElementById('f_color').value='#169F85';
|
||||||
syncColorSwatches('#169F85');
|
syncColorSwatches('#169F85');
|
||||||
@ -1408,6 +1469,7 @@ function setToggleValue(groupId, val, hiddenId){
|
|||||||
|
|
||||||
document.addEventListener('keydown', function(e){
|
document.addEventListener('keydown', function(e){
|
||||||
if(e.key==='Escape' && document.getElementById('zoneModal').classList.contains('show')) closeZoneModal();
|
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,
|
loiter_threshold:parseInt(document.getElementById('f_loiter').value,10)||0,
|
||||||
detect_interval_sec:getDetectRatePayload().detect_interval_sec,
|
detect_interval_sec:getDetectRatePayload().detect_interval_sec,
|
||||||
detect_frames:getDetectRatePayload().detect_frames,
|
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(),
|
color:document.getElementById('f_color').value.trim(),
|
||||||
is_required:parseInt(document.getElementById('f_required').value,10),
|
is_required:parseInt(document.getElementById('f_required').value,10),
|
||||||
algorithm_ids:getSelectedAlgoIds()
|
algorithm_ids:getSelectedAlgoIds()
|
||||||
@ -2132,5 +2195,180 @@ function restartInferencePool(btn){
|
|||||||
if(btn){ btn.disabled = false; btn.textContent = '重启推理池'; }
|
if(btn){ btn.disabled = false; btn.textContent = '重启推理池'; }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 实时 YOLO 人员检测 ---------- */
|
||||||
|
var g_yp = {sessionId:'', sequence:0, player:null, pollTimer:null, renderTimer:null,
|
||||||
|
resizeObserver:null, data:null, zone:null, frameW:0, frameH:0, opening:false,
|
||||||
|
animationFrame:null, displayBoxes:{}, lastDrawMs:0};
|
||||||
|
|
||||||
|
function computeContainRect(containerW, containerH, mediaW, mediaH){
|
||||||
|
mediaW = mediaW || 16; mediaH = mediaH || 9;
|
||||||
|
var scale = Math.min(containerW / mediaW, containerH / mediaH);
|
||||||
|
var width = mediaW * scale, height = mediaH * scale;
|
||||||
|
return {x:(containerW-width)/2, y:(containerH-height)/2, width:width, height:height};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeYoloCanvas(){
|
||||||
|
var canvas=document.getElementById('yoloPreviewCanvas');
|
||||||
|
var stage=document.getElementById('yoloPreviewStage');
|
||||||
|
if(!canvas||!stage) return;
|
||||||
|
var dpr=window.devicePixelRatio||1;
|
||||||
|
var w=Math.max(1,stage.clientWidth), h=Math.max(1,stage.clientHeight);
|
||||||
|
canvas.width=Math.round(w*dpr); canvas.height=Math.round(h*dpr);
|
||||||
|
canvas.style.width=w+'px'; canvas.style.height=h+'px';
|
||||||
|
var ctx=canvas.getContext('2d'); ctx.setTransform(dpr,0,0,dpr,0,0);
|
||||||
|
drawYoloOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectTrackBox(track, resultTimestamp, nowSec){
|
||||||
|
var box=(track.box||[]).slice();
|
||||||
|
if(box.length!==4 || !track.observed) return box;
|
||||||
|
var velocity=track.velocity||[0,0,0,0];
|
||||||
|
var lead=Math.max(0,Math.min(1.2,(nowSec||Date.now()/1000)-(resultTimestamp||nowSec)));
|
||||||
|
var projected=box.map(function(v,i){return v+(Number(velocity[i])||0)*lead;});
|
||||||
|
var bw=Math.max(.001,box[2]-box[0]), bh=Math.max(.001,box[3]-box[1]);
|
||||||
|
var dx=((projected[0]+projected[2])-(box[0]+box[2]))*.5;
|
||||||
|
var dy=((projected[1]+projected[3])-(box[1]+box[3]))*.5;
|
||||||
|
var distance=Math.sqrt(dx*dx+dy*dy), maxMove=Math.max(bw,bh)*.9;
|
||||||
|
if(distance>maxMove){
|
||||||
|
var ratio=maxMove/distance;
|
||||||
|
projected=projected.map(function(v,i){return box[i]+(v-box[i])*ratio;});
|
||||||
|
}
|
||||||
|
projected[0]=Math.max(0,Math.min(1,projected[0]));
|
||||||
|
projected[1]=Math.max(0,Math.min(1,projected[1]));
|
||||||
|
projected[2]=Math.max(projected[0],Math.min(1,projected[2]));
|
||||||
|
projected[3]=Math.max(projected[1],Math.min(1,projected[3]));
|
||||||
|
return projected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothTrackBox(track, target, nowMs){
|
||||||
|
var key=String(track.track_id), state=g_yp.displayBoxes[key];
|
||||||
|
if(!state){state={box:target.slice(),last:nowMs};g_yp.displayBoxes[key]=state;return state.box;}
|
||||||
|
var dt=Math.max(1,Math.min(100,nowMs-state.last)), alpha=1-Math.exp(-dt/90);
|
||||||
|
for(var i=0;i<4;i++)state.box[i]+=((target[i]||0)-state.box[i])*alpha;
|
||||||
|
state.last=nowMs;return state.box;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawYoloOverlay(){
|
||||||
|
var canvas=document.getElementById('yoloPreviewCanvas');
|
||||||
|
var stage=document.getElementById('yoloPreviewStage');
|
||||||
|
if(!canvas||!stage) return;
|
||||||
|
var ctx=canvas.getContext('2d'), w=stage.clientWidth, h=stage.clientHeight;
|
||||||
|
ctx.clearRect(0,0,w,h);
|
||||||
|
var data=g_yp.data||{}, fw=data.frame_width||g_yp.frameW||16, fh=data.frame_height||g_yp.frameH||9;
|
||||||
|
var rect=computeContainRect(w,h,fw,fh);
|
||||||
|
var zone=(data.zones&&data.zones[0])||g_yp.zone;
|
||||||
|
if(zone && zone.coords && zone.coords.length>=3){
|
||||||
|
ctx.save(); ctx.beginPath();
|
||||||
|
zone.coords.forEach(function(p,i){
|
||||||
|
var x=rect.x+p[0]*rect.width, y=rect.y+p[1]*rect.height;
|
||||||
|
if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);
|
||||||
|
});
|
||||||
|
ctx.closePath(); ctx.fillStyle=hexToRgba(zone.color||'#169F85',.16);ctx.fill();
|
||||||
|
ctx.strokeStyle=zone.color||'#169F85';ctx.lineWidth=2;ctx.stroke();ctx.restore();
|
||||||
|
}
|
||||||
|
var drawNow=Date.now(), liveIds={};
|
||||||
|
(data.tracks||[]).forEach(function(t){
|
||||||
|
if(t.label!=='person' || !t.box || t.box.length<4) return;
|
||||||
|
liveIds[String(t.track_id)]=true;
|
||||||
|
var target=projectTrackBox(t,data.timestamp,drawNow/1000);
|
||||||
|
var displayBox=smoothTrackBox(t,target,drawNow);
|
||||||
|
var x=rect.x+displayBox[0]*rect.width, y=rect.y+displayBox[1]*rect.height;
|
||||||
|
var bw=(displayBox[2]-displayBox[0])*rect.width, bh=(displayBox[3]-displayBox[1])*rect.height;
|
||||||
|
var inside=(t.inside_zone_ids||[]).indexOf(zone&&zone.id)>=0;
|
||||||
|
var color=inside?'#ef4444':'#a855f7';
|
||||||
|
ctx.save();ctx.globalAlpha=t.opacity==null?1:t.opacity;ctx.strokeStyle=color;ctx.lineWidth=2;
|
||||||
|
if(!t.observed)ctx.setLineDash([7,5]);ctx.strokeRect(x,y,bw,bh);ctx.setLineDash([]);
|
||||||
|
var label='person · ID '+t.track_id+' · '+Math.round((t.score||0)*100)+'%'+(inside?' · 区域内':'');
|
||||||
|
ctx.font='12px sans-serif';var tw=ctx.measureText(label).width+10;
|
||||||
|
ctx.fillStyle=color;ctx.fillRect(x,Math.max(rect.y,y-22),tw,20);
|
||||||
|
ctx.fillStyle='#fff';ctx.fillText(label,x+5,Math.max(rect.y+14,y-7));ctx.restore();
|
||||||
|
});
|
||||||
|
Object.keys(g_yp.displayBoxes).forEach(function(key){if(!liveIds[key])delete g_yp.displayBoxes[key];});
|
||||||
|
g_yp.lastDrawMs=drawNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateYoloOverlay(){
|
||||||
|
if(!g_yp.sessionId)return;
|
||||||
|
drawYoloOverlay();
|
||||||
|
g_yp.animationFrame=requestAnimationFrame(animateYoloOverlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateYoloStats(){
|
||||||
|
var d=g_yp.data||{};
|
||||||
|
var mode=d.mode||'preview', modeEl=document.getElementById('ypMode');
|
||||||
|
if(modeEl){modeEl.className='yp-mode '+(mode==='formal'?'':'preview');modeEl.textContent=mode==='formal'?'正式分析中':'预览模式,不产生报警';}
|
||||||
|
var names=d.model_names||[];
|
||||||
|
var devices=(d.model_devices||[]).map(function(v){return String(v).toUpperCase();});
|
||||||
|
document.getElementById('ypModel').textContent='模型:'+(names.join('、')||'—')+(devices.length?' · '+devices.join('/'):'');
|
||||||
|
document.getElementById('ypFps').textContent='分析 FPS:'+Number(d.analysis_fps||0).toFixed(2);
|
||||||
|
var age=d.timestamp ? Math.max(0,Date.now()/1000-d.timestamp) : null;
|
||||||
|
var ageEl=document.getElementById('ypAge');
|
||||||
|
ageEl.textContent='结果延迟:'+(age==null?'—':age.toFixed(1)+' 秒');
|
||||||
|
ageEl.style.color=age!=null&&age>10?'#fca5a5':'#cbd5e1';
|
||||||
|
var count=(d.tracks||[]).filter(function(t){return t.label==='person'&&t.observed;}).length;
|
||||||
|
document.getElementById('ypCount').textContent='当前人员:'+count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollYoloPreview(){
|
||||||
|
if(!g_yp.sessionId)return;
|
||||||
|
Api.get('/analysis/openPreviewData',{session_id:g_yp.sessionId,since:g_yp.sequence}).then(function(res){
|
||||||
|
if(!g_yp.sessionId)return;
|
||||||
|
if(!res||res.code!==1000){showToast((res&&res.msg)||'预览会话已失效','error');closeYoloPreview();return;}
|
||||||
|
var d=res.data||{};
|
||||||
|
if(d.changed){g_yp.data=d;g_yp.sequence=d.sequence||g_yp.sequence;g_yp.frameW=d.frame_width||g_yp.frameW;g_yp.frameH=d.frame_height||g_yp.frameH;drawYoloOverlay();}
|
||||||
|
else if(g_yp.data){g_yp.data.mode=d.mode||g_yp.data.mode;}
|
||||||
|
updateYoloStats();
|
||||||
|
}).catch(function(){});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startYoloPlayer(app,name){
|
||||||
|
Api.get('/stream/openPlayer',{app:app,name:name}).then(function(res){
|
||||||
|
if(!g_yp.sessionId)return;
|
||||||
|
var s=res&&res.info&&res.info.stream;
|
||||||
|
if(!s||parseInt(s.is_online,10)!==1){document.getElementById('ypLoading').textContent='视频流离线,但 YOLO 分析会继续等待视频';return;}
|
||||||
|
g_yp.frameW=s.video_width||g_yp.frameW;g_yp.frameH=s.video_height||g_yp.frameH;
|
||||||
|
var url=s.wsMp4Url||s.httpMp4Url||s.wsFlvUrl||s.httpFlvUrl;
|
||||||
|
if(!url){document.getElementById('ypLoading').textContent='无可用播放地址';return;}
|
||||||
|
try{
|
||||||
|
g_yp.player=new EasyPlayerPro({container:document.getElementById('yoloPreviewPlayer'),videoBuffer:.1,
|
||||||
|
decoder:'/static/lib/easyPlayer/js/decoder-pro.js',isResize:true,debug:false,useMSE:true,useWCS:true,
|
||||||
|
hasAudio:false,isNotMute:true,showBandwidth:false,operateBtns:{fullscreen:true,screenshot:false,play:true,audio:false,ptz:false},
|
||||||
|
timeout:10,forceNoOffscreen:true,heartTimeout:10});
|
||||||
|
g_yp.player.play(url);document.getElementById('ypLoading').style.display='none';
|
||||||
|
}catch(e){document.getElementById('ypLoading').textContent='视频播放器启动失败';}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openYoloPreview(zoneId){
|
||||||
|
if(g_yp.opening)return;g_yp.opening=true;
|
||||||
|
var zone=g_zoneCache.find(function(z){return parseInt(z.id,10)===parseInt(zoneId,10);});
|
||||||
|
document.getElementById('yoloPreviewModal').classList.add('open');
|
||||||
|
document.getElementById('ypLoading').style.display='flex';
|
||||||
|
document.getElementById('ypLoading').textContent='正在启动 YOLO 分析并连接视频流…';
|
||||||
|
document.getElementById('ypTitle').textContent=(zone?zone.stream_name+' · '+zone.name:'')+' 实时 YOLO 人员检测';
|
||||||
|
Api.post('/analysis/openPreviewStart',{zone_id:zoneId}).then(function(res){
|
||||||
|
g_yp.opening=false;
|
||||||
|
if(!res||res.code!==1000){showToast((res&&res.msg)||'启动预览失败','error');closeYoloPreview();return;}
|
||||||
|
var d=res.data||{};g_yp.sessionId=d.session_id;g_yp.sequence=0;g_yp.zone=d.zone;g_yp.data={mode:d.mode,zones:[d.zone],tracks:[],model_names:(d.models||[]).map(function(m){return m.name;})};
|
||||||
|
updateYoloStats();resizeYoloCanvas();startYoloPlayer(d.app,d.name);
|
||||||
|
g_yp.pollTimer=setInterval(pollYoloPreview,250);g_yp.renderTimer=setInterval(updateYoloStats,500);
|
||||||
|
g_yp.animationFrame=requestAnimationFrame(animateYoloOverlay);
|
||||||
|
if(window.ResizeObserver){g_yp.resizeObserver=new ResizeObserver(resizeYoloCanvas);g_yp.resizeObserver.observe(document.getElementById('yoloPreviewStage'));}
|
||||||
|
}).catch(function(){g_yp.opening=false;showToast('启动预览失败','error');closeYoloPreview();});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeYoloPreview(){
|
||||||
|
var sid=g_yp.sessionId;g_yp.sessionId='';g_yp.opening=false;
|
||||||
|
if(g_yp.pollTimer)clearInterval(g_yp.pollTimer);if(g_yp.renderTimer)clearInterval(g_yp.renderTimer);
|
||||||
|
if(g_yp.animationFrame)cancelAnimationFrame(g_yp.animationFrame);
|
||||||
|
g_yp.pollTimer=null;g_yp.renderTimer=null;
|
||||||
|
if(g_yp.resizeObserver){try{g_yp.resizeObserver.disconnect();}catch(e){}g_yp.resizeObserver=null;}
|
||||||
|
if(g_yp.player){try{g_yp.player.destroy();}catch(e){}g_yp.player=null;}
|
||||||
|
if(sid)Api.post('/analysis/openPreviewStop',{session_id:sid}).catch(function(){});
|
||||||
|
g_yp.data=null;g_yp.zone=null;g_yp.sequence=0;g_yp.animationFrame=null;g_yp.displayBoxes={};
|
||||||
|
document.getElementById('yoloPreviewPlayer').innerHTML='';
|
||||||
|
document.getElementById('yoloPreviewModal').classList.remove('open');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -51,6 +51,7 @@
|
|||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px">
|
||||||
<button class="btn btn-sm" onclick="stopPlay()">停止</button>
|
<button class="btn btn-sm" onclick="stopPlay()">停止</button>
|
||||||
<button class="btn btn-sm btn-primary" onclick="startPlay()">播放</button>
|
<button class="btn btn-sm btn-primary" onclick="startPlay()">播放</button>
|
||||||
|
<button class="btn btn-sm" id="btnRecord" onclick="toggleRecord()">录制</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -72,27 +73,27 @@
|
|||||||
<div class="card-header"><span class="card-title">云台控制</span></div>
|
<div class="card-header"><span class="card-title">云台控制</span></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="ptz-pad">
|
<div class="ptz-pad">
|
||||||
<div class="ptz-btn" onclick="ptzControl(2)">↖</div>
|
<div class="ptz-btn" data-ptz="5">↖</div>
|
||||||
<div class="ptz-btn" onclick="ptzControl(1)">↑</div>
|
<div class="ptz-btn" data-ptz="1">↑</div>
|
||||||
<div class="ptz-btn" onclick="ptzControl(3)">↗</div>
|
<div class="ptz-btn" data-ptz="6">↗</div>
|
||||||
<div class="ptz-btn" onclick="ptzControl(4)">←</div>
|
<div class="ptz-btn" data-ptz="3">←</div>
|
||||||
<div class="ptz-btn ptz-center" onclick="ptzControl(9)">●</div>
|
<div class="ptz-btn ptz-center" data-ptz="0">●</div>
|
||||||
<div class="ptz-btn" onclick="ptzControl(6)">→</div>
|
<div class="ptz-btn" data-ptz="4">→</div>
|
||||||
<div class="ptz-btn" onclick="ptzControl(7)">↙</div>
|
<div class="ptz-btn" data-ptz="7">↙</div>
|
||||||
<div class="ptz-btn" onclick="ptzControl(5)">↓</div>
|
<div class="ptz-btn" data-ptz="2">↓</div>
|
||||||
<div class="ptz-btn" onclick="ptzControl(8)">↘</div>
|
<div class="ptz-btn" data-ptz="8">↘</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;justify-content:center;gap:8px;margin-top:14px">
|
<div style="display:flex;justify-content:center;gap:8px;margin-top:14px">
|
||||||
<button class="btn btn-sm" onclick="ptzControl(10)">调焦+</button>
|
<button type="button" class="btn btn-sm" data-ptz="9">变倍+</button>
|
||||||
<button class="btn btn-sm" onclick="ptzControl(11)">调焦-</button>
|
<button type="button" class="btn btn-sm" data-ptz="10">变倍-</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;justify-content:center;gap:8px;margin-top:6px">
|
<div style="display:flex;justify-content:center;gap:8px;margin-top:6px">
|
||||||
<button class="btn btn-sm" onclick="ptzControl(12)">聚焦+</button>
|
<button type="button" class="btn btn-sm" data-ptz="11">聚焦+</button>
|
||||||
<button class="btn btn-sm" onclick="ptzControl(13)">聚焦-</button>
|
<button type="button" class="btn btn-sm" data-ptz="12">聚焦-</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;justify-content:center;gap:8px;margin-top:6px">
|
<div style="display:flex;justify-content:center;gap:8px;margin-top:6px">
|
||||||
<button class="btn btn-sm" onclick="ptzControl(14)">光圈+</button>
|
<button type="button" class="btn btn-sm" data-ptz="13">光圈+</button>
|
||||||
<button class="btn btn-sm" onclick="ptzControl(15)">光圈-</button>
|
<button type="button" class="btn btn-sm" data-ptz="14">光圈-</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -164,6 +165,9 @@ function initPlayer() {
|
|||||||
document.getElementById('ptzCard').style.display = 'block';
|
document.getElementById('ptzCard').style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同步当前录制状态
|
||||||
|
refreshRecordState();
|
||||||
|
|
||||||
// 自动播放
|
// 自动播放
|
||||||
if (s.is_online == 1) {
|
if (s.is_online == 1) {
|
||||||
startPlay();
|
startPlay();
|
||||||
@ -230,21 +234,115 @@ function stopPlay() {
|
|||||||
wrap.innerHTML = '<div id="video_player" style="width:100%;height:100%;background:#000;min-height:400px"></div><div class="player-overlay" id="playerOverlay"><div style="font-size:32px">■</div><div>已停止</div></div>';
|
wrap.innerHTML = '<div id="video_player" style="width:100%;height:100%;background:#000;min-height:400px"></div><div class="player-overlay" id="playerOverlay"><div style="font-size:32px">■</div><div>已停止</div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var g_recording = false;
|
||||||
|
|
||||||
|
function toggleRecord() {
|
||||||
|
var s = streamInfo;
|
||||||
|
if (!s || !s.app || !s.name) {
|
||||||
|
showToast('视频流信息无效', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (g_recording) {
|
||||||
|
stopRecord();
|
||||||
|
} else {
|
||||||
|
startRecord();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRecord() {
|
||||||
|
var s = streamInfo;
|
||||||
|
Api.post('/nvr/openStartRecordVideo', { app: s.app, name: s.name }).then(function(res) {
|
||||||
|
if (res.code === 1000) {
|
||||||
|
g_recording = true;
|
||||||
|
updateRecordBtn();
|
||||||
|
showToast('已开始录制');
|
||||||
|
} else {
|
||||||
|
showToast(res.msg || '录制启动失败', 'error');
|
||||||
|
}
|
||||||
|
}).catch(function() {
|
||||||
|
showToast('录制启动失败', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecord() {
|
||||||
|
var s = streamInfo;
|
||||||
|
Api.post('/nvr/openStopRecordVideo', { app: s.app, name: s.name }).then(function(res) {
|
||||||
|
if (res.code === 1000) {
|
||||||
|
g_recording = false;
|
||||||
|
updateRecordBtn();
|
||||||
|
showToast('已停止录制');
|
||||||
|
} else {
|
||||||
|
showToast(res.msg || '停止录制失败', 'error');
|
||||||
|
}
|
||||||
|
}).catch(function() {
|
||||||
|
showToast('停止录制失败', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRecordBtn() {
|
||||||
|
var btn = document.getElementById('btnRecord');
|
||||||
|
if (!btn) return;
|
||||||
|
if (g_recording) {
|
||||||
|
btn.textContent = '停止录制';
|
||||||
|
btn.classList.remove('btn-primary');
|
||||||
|
btn.classList.add('btn-danger');
|
||||||
|
} else {
|
||||||
|
btn.textContent = '录制';
|
||||||
|
btn.classList.remove('btn-danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshRecordState() {
|
||||||
|
var s = streamInfo;
|
||||||
|
if (!s || !s.app || !s.name) return;
|
||||||
|
Api.get('/nvr/openVideoIsRecording', { app: s.app, name: s.name }).then(function(res) {
|
||||||
|
if (res.code === 1000) {
|
||||||
|
g_recording = !!res.is_recording;
|
||||||
|
updateRecordBtn();
|
||||||
|
}
|
||||||
|
}).catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
function ptzControl(ptzType) {
|
function ptzControl(ptzType) {
|
||||||
var s = streamInfo;
|
var s = streamInfo;
|
||||||
if (!s.code) return;
|
if (!s || !s.code) return;
|
||||||
|
|
||||||
Api.post('/stream/openPtz', {
|
Api.post('/stream/openPtz', {
|
||||||
code: s.code,
|
code: s.code,
|
||||||
ptzType: ptzType,
|
ptzType: ptzType,
|
||||||
ptzSpeed: 4
|
ptzSpeed: 32
|
||||||
}).then(function(res) {
|
}).then(function(res) {
|
||||||
if (res.code !== 1000) {
|
if (res.code !== 1000) {
|
||||||
showToast(res.msg, 'error');
|
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;
|
var prevIsOnline = streamInfo.is_online == 1;
|
||||||
setInterval(function() {
|
setInterval(function() {
|
||||||
|
|||||||
336
tests/test_analysis_runtime.py
Normal file
336
tests/test_analysis_runtime.py
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
"""Regression coverage for camera restart, response routing and inference health."""
|
||||||
|
import multiprocessing as mp
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from app.analysis.manager import AnalysisManager
|
||||||
|
from app.analysis.pipeline import CameraPipeline
|
||||||
|
from app.analysis.process_worker import pipeline_process_main
|
||||||
|
from app.analysis.remote_detector import RemoteDetector
|
||||||
|
from app.analysis.tracker import IoUTracker
|
||||||
|
from app.analysis.preview_sessions import PreviewSessionRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class CommandPipeline:
|
||||||
|
"""Delay run() so control threads must survive the pre-start window."""
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self._running = False
|
||||||
|
self._started_event = threading.Event()
|
||||||
|
self.zones = []
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
time.sleep(0.2)
|
||||||
|
self._running = True
|
||||||
|
self._started_event.set()
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
while self._running and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.01)
|
||||||
|
assert not self._running, 'stop command was lost before run()'
|
||||||
|
assert self.zones == [{'id': 12}], 'reload command was lost'
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
return {'running': self._running}
|
||||||
|
|
||||||
|
def set_zone_polygons(self, zones):
|
||||||
|
self.zones = zones
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
|
||||||
|
def command_process(commands):
|
||||||
|
with patch('app.analysis.pipeline.CameraPipeline', CommandPipeline):
|
||||||
|
pipeline_process_main({'stream_id': 1, 'rtsp_url': 'test'},
|
||||||
|
queue.Queue(), commands, {})
|
||||||
|
|
||||||
|
|
||||||
|
def wait_response(responses, entered):
|
||||||
|
entered.set()
|
||||||
|
responses.get(timeout=60)
|
||||||
|
|
||||||
|
|
||||||
|
class AnalysisRuntimeTests(unittest.TestCase):
|
||||||
|
def make_router(self):
|
||||||
|
manager = object.__new__(AnalysisManager)
|
||||||
|
manager._infer_routes = {}
|
||||||
|
manager._infer_routes_lock = threading.Lock()
|
||||||
|
return manager
|
||||||
|
|
||||||
|
def test_stop_and_reload_commands_survive_startup(self):
|
||||||
|
ctx = mp.get_context('spawn')
|
||||||
|
commands = ctx.Queue()
|
||||||
|
commands.put({'cmd': 'reload_zones', 'zones': [{'id': 12}]})
|
||||||
|
commands.put({'cmd': 'stop'})
|
||||||
|
proc = ctx.Process(target=command_process, args=(commands,))
|
||||||
|
proc.start()
|
||||||
|
try:
|
||||||
|
proc.join(10)
|
||||||
|
self.assertEqual(proc.exitcode, 0)
|
||||||
|
finally:
|
||||||
|
if proc.is_alive():
|
||||||
|
proc.terminate()
|
||||||
|
proc.join(2)
|
||||||
|
commands.cancel_join_thread()
|
||||||
|
commands.close()
|
||||||
|
|
||||||
|
def test_restart_uses_new_channel_after_consumer_is_killed(self):
|
||||||
|
ctx = mp.get_context('spawn')
|
||||||
|
router = self.make_router()
|
||||||
|
old, fresh, other = ctx.Queue(), ctx.Queue(), ctx.Queue()
|
||||||
|
router._infer_routes.update(old=old, fresh=fresh, other=other)
|
||||||
|
entered = ctx.Event()
|
||||||
|
proc = ctx.Process(target=wait_response, args=(old, entered))
|
||||||
|
proc.start()
|
||||||
|
try:
|
||||||
|
self.assertTrue(entered.wait(10))
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if not old._rlock.acquire(False):
|
||||||
|
break
|
||||||
|
old._rlock.release()
|
||||||
|
time.sleep(0.01)
|
||||||
|
else:
|
||||||
|
self.fail('consumer did not acquire response queue lock')
|
||||||
|
proc.terminate()
|
||||||
|
proc.join(3)
|
||||||
|
old.put({'req_id': 'unreadable'})
|
||||||
|
with self.assertRaises(queue.Empty):
|
||||||
|
old.get(timeout=0.1)
|
||||||
|
router._close_inference_channel({'response_channel': 'old'})
|
||||||
|
router._send_inference_response('old', {'req_id': 'late'})
|
||||||
|
router._send_inference_response('fresh', {'req_id': 'new'})
|
||||||
|
router._send_inference_response('other', {'req_id': 'camera-2'})
|
||||||
|
self.assertEqual(fresh.get(timeout=2)['req_id'], 'new')
|
||||||
|
self.assertEqual(other.get(timeout=2)['req_id'], 'camera-2')
|
||||||
|
finally:
|
||||||
|
if proc.is_alive():
|
||||||
|
proc.terminate()
|
||||||
|
proc.join(2)
|
||||||
|
for key in list(router._infer_routes):
|
||||||
|
router._close_inference_channel({'response_channel': key})
|
||||||
|
|
||||||
|
def test_remote_timeout_is_not_an_empty_success(self):
|
||||||
|
detector = RemoteDetector({'id': 5}, queue.Queue(), queue.Queue(), timeout=0.03)
|
||||||
|
with self.assertRaises(TimeoutError):
|
||||||
|
detector.detect(np.zeros((32, 32, 3), dtype=np.uint8))
|
||||||
|
|
||||||
|
def test_downscaled_inference_coordinates_are_restored(self):
|
||||||
|
restored = RemoteDetector._restore_coordinates([{
|
||||||
|
'box': [10, 20, 100, 200],
|
||||||
|
'keypoints': [[15, 25, .9]],
|
||||||
|
'polygon': [[10, 20], [100, 200]],
|
||||||
|
}], 2.0, 2.0)[0]
|
||||||
|
self.assertEqual(restored['box'], [20.0, 40.0, 200.0, 400.0])
|
||||||
|
self.assertEqual(restored['keypoints'][0][:2], [30.0, 50.0])
|
||||||
|
self.assertEqual(restored['polygon'][1], [200.0, 400.0])
|
||||||
|
|
||||||
|
def test_tracker_is_one_to_one_and_confirms_medium_score_twice(self):
|
||||||
|
tracker = IoUTracker()
|
||||||
|
detections = [
|
||||||
|
{'label': 'person', 'score': .45, 'box': [0, 0, 20, 40]},
|
||||||
|
{'label': 'person', 'score': .8, 'box': [30, 0, 50, 40]},
|
||||||
|
]
|
||||||
|
first, _, _, _ = tracker.update(detections, 1, timestamp=1.0)
|
||||||
|
self.assertEqual(len({t['track_id'] for t in first}), 2)
|
||||||
|
self.assertFalse(next(t for t in first if t['score'] == .45)['confirmed'])
|
||||||
|
second, _, new_ids, _ = tracker.update([
|
||||||
|
{'label': 'person', 'score': .5, 'box': [2, 0, 22, 40]},
|
||||||
|
{'label': 'person', 'score': .82, 'box': [28, 0, 48, 40]},
|
||||||
|
], 2, timestamp=4.0)
|
||||||
|
self.assertEqual(new_ids, [])
|
||||||
|
self.assertEqual(len({t['track_id'] for t in second}), 2)
|
||||||
|
self.assertTrue(all(t['confirmed'] for t in second))
|
||||||
|
|
||||||
|
def test_low_fps_track_survives_short_miss(self):
|
||||||
|
tracker = IoUTracker()
|
||||||
|
active, _, _, _ = tracker.update([
|
||||||
|
{'label': 'person', 'score': .8, 'box': [0, 0, 20, 40]},
|
||||||
|
], 1, timestamp=1.0)
|
||||||
|
tid = active[0]['track_id']
|
||||||
|
tracker.update([], 2, timestamp=4.0)
|
||||||
|
active, ended, new_ids, _ = tracker.update([
|
||||||
|
{'label': 'person', 'score': .8, 'box': [2, 0, 22, 40]},
|
||||||
|
], 3, timestamp=7.0)
|
||||||
|
self.assertNotIn(tid, ended)
|
||||||
|
self.assertEqual(new_ids, [])
|
||||||
|
self.assertEqual(active[0]['track_id'], tid)
|
||||||
|
|
||||||
|
def test_preview_uses_capture_timestamp_and_normalized_velocity(self):
|
||||||
|
snapshots = []
|
||||||
|
pipe = CameraPipeline(1, 'test', 'test', on_preview=snapshots.append)
|
||||||
|
pipe._last_w, pipe._last_h = 100, 200
|
||||||
|
pipe._frame_index = 2
|
||||||
|
pipe._tracker.update([
|
||||||
|
{'label': 'person', 'score': .8, 'box': [10, 20, 30, 80]},
|
||||||
|
], 1, timestamp=10.0)
|
||||||
|
pipe._tracker.update([
|
||||||
|
{'label': 'person', 'score': .8, 'box': [20, 30, 40, 90]},
|
||||||
|
], 2, timestamp=11.0)
|
||||||
|
pipe._publish_preview(11.0, processed_ts=11.25)
|
||||||
|
snapshot = snapshots[-1]
|
||||||
|
self.assertEqual(snapshot['timestamp'], 11.0)
|
||||||
|
self.assertEqual(snapshot['processed_timestamp'], 11.25)
|
||||||
|
self.assertAlmostEqual(snapshot['inference_latency'], .25)
|
||||||
|
self.assertEqual(snapshot['tracks'][0]['box'], [.2, .15, .4, .45])
|
||||||
|
self.assertEqual(snapshot['tracks'][0]['velocity'], [.05, .025, .05, .025])
|
||||||
|
|
||||||
|
def test_formal_preview_temporarily_boosts_and_restores_analysis_fps(self):
|
||||||
|
class Handle:
|
||||||
|
def __init__(self): self.calls = []
|
||||||
|
def set_analyze_fps(self, fps): self.calls.append(float(fps))
|
||||||
|
class Process:
|
||||||
|
@staticmethod
|
||||||
|
def is_alive(): return True
|
||||||
|
handle = Handle()
|
||||||
|
manager = object.__new__(AnalysisManager)
|
||||||
|
manager._lock = threading.RLock()
|
||||||
|
manager._pipelines = {87: {
|
||||||
|
'running': True, 'mode': 'process', 'process': Process(),
|
||||||
|
'handle': handle, 'preview_only': False, 'analyze_fps': .67,
|
||||||
|
}}
|
||||||
|
zone = SimpleNamespace(stream=SimpleNamespace(id=87))
|
||||||
|
with patch('monitor_runtime.licensing.require_license'):
|
||||||
|
self.assertEqual(manager.start_preview(zone), (True, 'formal'))
|
||||||
|
self.assertEqual(handle.calls, [5.0])
|
||||||
|
self.assertEqual(manager._pipelines[87]['preview_restore_fps'], .67)
|
||||||
|
self.assertFalse(manager.stop_preview(87))
|
||||||
|
self.assertEqual(handle.calls, [5.0, .67])
|
||||||
|
self.assertNotIn('preview_restore_fps', manager._pipelines[87])
|
||||||
|
|
||||||
|
def test_medium_score_confirmation_requires_consecutive_hits(self):
|
||||||
|
tracker = IoUTracker()
|
||||||
|
tracker.update([{'label': 'person', 'score': .4, 'box': [0, 0, 20, 40]}], 1, timestamp=1)
|
||||||
|
tracker.update([], 2, timestamp=2)
|
||||||
|
active, _, _, _ = tracker.update([
|
||||||
|
{'label': 'person', 'score': .5, 'box': [1, 0, 21, 40]},
|
||||||
|
], 3, timestamp=3)
|
||||||
|
self.assertFalse(active[0]['confirmed'])
|
||||||
|
|
||||||
|
def test_person_foot_point_and_two_outside_hits(self):
|
||||||
|
events = []
|
||||||
|
zone = {'id': 12, 'name': 'office', 'coords': [[0, .8], [1, .8], [1, 1], [0, 1]],
|
||||||
|
'alarm_repeat_sec': 30, 'biz_algorithms': [{
|
||||||
|
'id': 1, 'name': 'person intrusion', 'flow_type': 1,
|
||||||
|
'small_model_id': 5, 'target_labels': ['person'], 'post_process': 'AREA'}]}
|
||||||
|
pipe = CameraPipeline(1, 'test', 'test', zone_polygons=[zone], on_event=events.append)
|
||||||
|
pipe._last_w, pipe._last_h = 100, 100
|
||||||
|
inside = {'track_id': 7, 'label': 'person', 'score': .8, 'box': [40, 40, 60, 90],
|
||||||
|
'algorithm_id': 5, 'confirmed': True, 'observed': True}
|
||||||
|
with patch.object(pipe, '_emit_biz_alarm', return_value=True) as alarm:
|
||||||
|
pipe._check_zones([inside], 1, np.zeros((100, 100, 3), dtype=np.uint8))
|
||||||
|
self.assertTrue(alarm.called) # 中心 y=65 在外,脚点 y=87.5 在区域内
|
||||||
|
outside = dict(inside, box=[40, 20, 60, 50])
|
||||||
|
pipe._check_zones([outside], 2, np.zeros((100, 100, 3), dtype=np.uint8))
|
||||||
|
self.assertEqual(pipe._last_zone_state[7], {12})
|
||||||
|
pipe._check_zones([outside], 3, np.zeros((100, 100, 3), dtype=np.uint8))
|
||||||
|
self.assertEqual(pipe._last_zone_state[7], set())
|
||||||
|
|
||||||
|
def test_unobserved_retained_track_does_not_leave_or_reenter(self):
|
||||||
|
pipe = CameraPipeline(1, 'test', 'test')
|
||||||
|
active, _, _, _ = pipe._tracker.update([
|
||||||
|
{'label': 'person', 'score': .8, 'box': [10, 10, 20, 30]},
|
||||||
|
], 1, timestamp=time.time())
|
||||||
|
tid = active[0]['track_id']
|
||||||
|
pipe._last_zone_state = {tid: {12}}
|
||||||
|
pipe._track_enter_ts = {(tid, 12): 10.0}
|
||||||
|
pipe._check_zones([], 2, np.zeros((20, 20, 3), dtype=np.uint8))
|
||||||
|
self.assertEqual(pipe._last_zone_state[tid], {12})
|
||||||
|
self.assertEqual(pipe._track_enter_ts[(tid, 12)], 10.0)
|
||||||
|
|
||||||
|
def test_area_alarm_repeat_is_independent_from_detection_rate(self):
|
||||||
|
pipe = CameraPipeline(1, 'test', 'test')
|
||||||
|
rule = {'id': 1, 'post_process': 'AREA', 'target_labels': ['person'],
|
||||||
|
'flow_type': 1, 'small_model_id': 5}
|
||||||
|
zone = {'id': 12, 'detect_interval_sec': 1, 'alarm_repeat_sec': 30,
|
||||||
|
'biz_algorithms': [rule]}
|
||||||
|
track = {'track_id': 7, 'label': 'person', 'score': .8, 'algorithm_id': 5,
|
||||||
|
'box': [0, 0, 10, 20]}
|
||||||
|
with patch.object(pipe, '_emit_biz_alarm', return_value=True) as alarm:
|
||||||
|
pipe._fire_area_alarms(track, 12, zone, set(), None, track['box'], 100.0, 7)
|
||||||
|
pipe._fire_area_alarms(track, 12, zone, {12}, None, track['box'], 101.0, 7)
|
||||||
|
pipe._fire_area_alarms(track, 12, zone, {12}, None, track['box'], 130.0, 7)
|
||||||
|
self.assertEqual(alarm.call_count, 2)
|
||||||
|
|
||||||
|
def test_preview_session_owner_and_last_viewer_cleanup(self):
|
||||||
|
class Related(list):
|
||||||
|
def filter(self, **_kwargs): return self
|
||||||
|
def select_related(self, *_args): return self
|
||||||
|
class Model:
|
||||||
|
id, name, state = 5, 'YOLO26x', 1
|
||||||
|
class Rule:
|
||||||
|
flow_type, detector_model, small_model = 1, None, Model()
|
||||||
|
target_labels = '["person"]'
|
||||||
|
class Stream:
|
||||||
|
id, app, name = 87, 'live', 'camera01'
|
||||||
|
class Zone:
|
||||||
|
id, stream_id, stream, name, color = 12, 87, Stream(), 'office', '#169F85'
|
||||||
|
coordinates = '[[0,0],[1,0],[1,1]]'
|
||||||
|
algorithms = Related([Rule()])
|
||||||
|
registry = PreviewSessionRegistry()
|
||||||
|
with patch('app.analysis.preview_sessions.AnalysisManager') as manager_cls:
|
||||||
|
manager = manager_cls.return_value
|
||||||
|
manager.start_preview.return_value = (True, 'preview')
|
||||||
|
manager.preview_mode.return_value = 'preview'
|
||||||
|
first = registry.start('owner-a', Zone())
|
||||||
|
second = registry.start('owner-b', Zone())
|
||||||
|
with self.assertRaises(PermissionError):
|
||||||
|
registry.data('owner-b', first['session_id'])
|
||||||
|
self.assertTrue(registry.stop('owner-a', first['session_id']))
|
||||||
|
manager.stop_preview.assert_not_called()
|
||||||
|
self.assertTrue(registry.stop('owner-b', second['session_id']))
|
||||||
|
manager.stop_preview.assert_called_once_with(87)
|
||||||
|
registry._running = False
|
||||||
|
|
||||||
|
def test_worker_errors_are_preserved(self):
|
||||||
|
requests, responses = queue.Queue(), queue.Queue()
|
||||||
|
detector = RemoteDetector({'id': 5}, requests, responses, timeout=2,
|
||||||
|
response_channel='camera-1')
|
||||||
|
def reply():
|
||||||
|
msg = requests.get(timeout=2)
|
||||||
|
self.assertEqual(msg['response_channel'], 'camera-1')
|
||||||
|
responses.put({'req_id': msg['req_id'], 'ok': False, 'error': 'engine load failed'})
|
||||||
|
worker = threading.Thread(target=reply)
|
||||||
|
worker.start()
|
||||||
|
try:
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'engine load failed'):
|
||||||
|
detector.detect(np.zeros((32, 32, 3), dtype=np.uint8))
|
||||||
|
finally:
|
||||||
|
worker.join(3)
|
||||||
|
|
||||||
|
def test_failed_frame_is_not_counted_and_success_recovers(self):
|
||||||
|
pipe = CameraPipeline(1, 'test', 'test')
|
||||||
|
frame = np.zeros((32, 32, 3), dtype=np.uint8)
|
||||||
|
def process(_):
|
||||||
|
pipe.stop()
|
||||||
|
raise TimeoutError('response timeout')
|
||||||
|
with patch.object(pipe, '_decode_loop'), \
|
||||||
|
patch.object(pipe, '_pop_latest_frame', return_value=frame), \
|
||||||
|
patch.object(pipe, '_process_frame', side_effect=process):
|
||||||
|
pipe.run()
|
||||||
|
self.assertEqual(pipe.status()['analyzed_count'], 0)
|
||||||
|
self.assertEqual(pipe.status()['analysis_health'], 'error')
|
||||||
|
pipe._last_analyze_ts = 0
|
||||||
|
with patch.object(pipe, '_decode_loop'), \
|
||||||
|
patch.object(pipe, '_pop_latest_frame', return_value=frame), \
|
||||||
|
patch.object(pipe, '_process_frame', side_effect=lambda _: pipe.stop()):
|
||||||
|
pipe.run()
|
||||||
|
self.assertEqual(pipe.status()['analyzed_count'], 1)
|
||||||
|
self.assertEqual(pipe.status()['analysis_health'], 'running')
|
||||||
|
self.assertEqual(pipe.status()['analysis_error'], '')
|
||||||
|
|
||||||
|
def test_low_frequency_success_keeps_nonzero_fps(self):
|
||||||
|
pipe = CameraPipeline(1, 'test', 'test', analyze_fps=1 / 60)
|
||||||
|
pipe._last_analysis_ts = time.time()
|
||||||
|
pipe._analysis_fps = 1 / 60
|
||||||
|
self.assertEqual(pipe.status()['analysis_health'], 'running')
|
||||||
|
self.assertGreater(pipe.status()['analysis_fps'], 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
46
tests/test_control_preview.cjs
Normal file
46
tests/test_control_preview.cjs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
const template = fs.readFileSync(path.join(__dirname, '../templates/app/control/index.html'), 'utf8');
|
||||||
|
assert.match(template, /openYoloPreview\('\+z\.id\+'\)/);
|
||||||
|
assert.match(template, /id="yoloPreviewCanvas"/);
|
||||||
|
assert.match(template, /openPreviewStart/);
|
||||||
|
assert.match(template, /openPreviewData/);
|
||||||
|
assert.match(template, /openPreviewStop/);
|
||||||
|
assert.match(template, /ResizeObserver/);
|
||||||
|
assert.match(template, /requestAnimationFrame\(animateYoloOverlay\)/);
|
||||||
|
|
||||||
|
const start = template.indexOf('function computeContainRect(');
|
||||||
|
const end = template.indexOf('function resizeYoloCanvas(');
|
||||||
|
const context = {};
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(template.slice(start, end), context);
|
||||||
|
const wide = context.computeContainRect(1000, 1000, 16, 9);
|
||||||
|
assert.equal(wide.width, 1000);
|
||||||
|
assert.equal(Math.round(wide.height), 563);
|
||||||
|
assert.equal(Math.round(wide.y), 219);
|
||||||
|
const tall = context.computeContainRect(1000, 500, 4, 3);
|
||||||
|
assert.equal(Math.round(tall.height), 500);
|
||||||
|
assert.equal(Math.round(tall.width), 667);
|
||||||
|
assert.equal(Math.round(tall.x), 167);
|
||||||
|
|
||||||
|
const projectStart = template.indexOf('function projectTrackBox(');
|
||||||
|
const projectEnd = template.indexOf('function smoothTrackBox(');
|
||||||
|
vm.runInContext(template.slice(projectStart, projectEnd), context);
|
||||||
|
const moving = context.projectTrackBox({
|
||||||
|
box: [.1, .2, .2, .4], velocity: [.1, 0, .1, 0], observed: true,
|
||||||
|
}, 10, 10.5);
|
||||||
|
assert.ok(Math.abs(moving[0] - .15) < 1e-9);
|
||||||
|
assert.ok(Math.abs(moving[2] - .25) < 1e-9);
|
||||||
|
const runaway = context.projectTrackBox({
|
||||||
|
box: [.1, .2, .2, .4], velocity: [10, 10, 10, 10], observed: true,
|
||||||
|
}, 10, 20);
|
||||||
|
assert.ok(runaway.every(v => v >= 0 && v <= 1));
|
||||||
|
assert.ok(runaway[0] < .3, 'prediction displacement must be capped');
|
||||||
|
const retained = context.projectTrackBox({
|
||||||
|
box: [.1, .2, .2, .4], velocity: [1, 1, 1, 1], observed: false,
|
||||||
|
}, 10, 11);
|
||||||
|
assert.deepEqual(Array.from(retained), [.1, .2, .2, .4]);
|
||||||
|
console.log('PASS: preview lifecycle, contain coordinates and motion projection');
|
||||||
31
tests/test_control_status.cjs
Normal file
31
tests/test_control_status.cjs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const template = fs.readFileSync(path.join(__dirname, '../templates/app/control/index.html'), 'utf8');
|
||||||
|
const code = template.slice(template.indexOf('function buildZoneStateTag('),
|
||||||
|
template.indexOf('function buildZoneRunBtn('));
|
||||||
|
let info, active = true;
|
||||||
|
const context = {
|
||||||
|
zoneIsActiveInPipeline: () => active,
|
||||||
|
getStreamStatusInfo: () => info,
|
||||||
|
getStreamHealth: () => info.stream_health,
|
||||||
|
streamHealthLabel: health => health,
|
||||||
|
_t: (_, fallback) => fallback,
|
||||||
|
};
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(code, context);
|
||||||
|
const tag = () => context.buildZoneStateTag({stream_id: 87});
|
||||||
|
info = {running: true, stream_health: 'ok', analysis_health: 'starting', analysis_fps: 0};
|
||||||
|
assert.match(tag(), /启动中/);
|
||||||
|
info.analysis_health = 'error';
|
||||||
|
assert.match(tag(), /推理异常/);
|
||||||
|
info.analysis_health = 'running';
|
||||||
|
info.analysis_fps = 0.017;
|
||||||
|
assert.match(tag(), /分析中/);
|
||||||
|
info.running = false;
|
||||||
|
info.stream_health = 'disconnected';
|
||||||
|
assert.match(tag(), /disconnected/);
|
||||||
|
active = false;
|
||||||
|
assert.match(tag(), /已停止/);
|
||||||
|
console.log('PASS: starting, inference error, low-FPS success, disconnected, stopped');
|
||||||
51
tests/test_windows_license.py
Normal file
51
tests/test_windows_license.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import base64
|
||||||
|
import copy
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
from monitor_runtime.licensing import canonical, verify_document, LicenseError
|
||||||
|
|
||||||
|
class OfflineLicenseTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.key = Ed25519PrivateKey.generate()
|
||||||
|
self.public = self.key.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||||
|
self.now = datetime(2026,8,30,tzinfo=timezone.utc).timestamp()
|
||||||
|
self.payload = dict(schema_version=1,product='monitor',license_id='test-id',customer='test',
|
||||||
|
machine_fingerprint='v1:'+'a'*64,issued_at='2026-08-29T00:00:00Z',expires_at=None)
|
||||||
|
|
||||||
|
def sign(self, payload=None):
|
||||||
|
payload = payload or self.payload
|
||||||
|
return {'payload':payload,'signature':base64.b64encode(self.key.sign(canonical(payload))).decode()}
|
||||||
|
|
||||||
|
def verify(self, document, **kwargs):
|
||||||
|
return verify_document(document,kwargs.get('public',self.public),
|
||||||
|
kwargs.get('fingerprint',self.payload['machine_fingerprint']),kwargs.get('now',self.now))
|
||||||
|
|
||||||
|
def test_perpetual_and_dated(self):
|
||||||
|
self.assertEqual(self.verify(self.sign())['customer'],'test')
|
||||||
|
self.payload['expires_at']='2026-08-31T00:00:00Z'
|
||||||
|
self.verify(self.sign())
|
||||||
|
|
||||||
|
def test_expiry_boundary_and_future_issue(self):
|
||||||
|
self.payload['expires_at']='2026-08-30T00:00:00Z'
|
||||||
|
with self.assertRaises(LicenseError):self.verify(self.sign())
|
||||||
|
self.payload['expires_at']=None
|
||||||
|
self.payload['issued_at']='2026-09-01T00:00:00Z'
|
||||||
|
with self.assertRaises(LicenseError):self.verify(self.sign())
|
||||||
|
|
||||||
|
def test_machine_signature_and_key_mismatch(self):
|
||||||
|
doc=self.sign()
|
||||||
|
with self.assertRaises(LicenseError):self.verify(doc,fingerprint='another')
|
||||||
|
bad=copy.deepcopy(doc);bad['payload']['customer']='tampered'
|
||||||
|
with self.assertRaises(LicenseError):self.verify(bad)
|
||||||
|
other=Ed25519PrivateKey.generate().public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||||
|
with self.assertRaises(LicenseError):self.verify(doc,public=other)
|
||||||
|
|
||||||
|
def test_product_version_structure_and_timezone(self):
|
||||||
|
for field,value in [('product','other'),('schema_version',2),('issued_at','2026-08-29'),('customer','')]:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
payload=dict(self.payload);payload[field]=value
|
||||||
|
with self.assertRaises(LicenseError):self.verify(self.sign(payload))
|
||||||
|
bad=self.sign();bad['unexpected']=1
|
||||||
|
with self.assertRaises(LicenseError):self.verify(bad)
|
||||||
74
tests/validate_yolo_replay.py
Normal file
74
tests/validate_yolo_replay.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
"""Camera 01 录像:直接 YOLO 与共享实时缩图管线的一致性抽样验证。
|
||||||
|
|
||||||
|
手动运行:python tests/validate_yolo_replay.py <video> <weights> [sample_count] [device]
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
from ultralytics import YOLO
|
||||||
|
|
||||||
|
|
||||||
|
def iou(a, b):
|
||||||
|
x1, y1, x2, y2 = max(a[0], b[0]), max(a[1], b[1]), min(a[2], b[2]), min(a[3], b[3])
|
||||||
|
inter = max(0, x2 - x1) * max(0, y2 - y1)
|
||||||
|
union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
|
||||||
|
return inter / union if union > 0 else 0
|
||||||
|
|
||||||
|
|
||||||
|
def boxes(result):
|
||||||
|
if result.boxes is None:
|
||||||
|
return []
|
||||||
|
return [list(map(float, row)) for row in result.boxes.xyxy.cpu().numpy()]
|
||||||
|
|
||||||
|
|
||||||
|
def main(video_path, weights_path, sample_count=5, device="cpu"):
|
||||||
|
model = YOLO(weights_path)
|
||||||
|
names = model.names if isinstance(model.names, dict) else dict(enumerate(model.names))
|
||||||
|
person_ids = [cid for cid, name in names.items() if name == "person"]
|
||||||
|
cap = cv2.VideoCapture(video_path)
|
||||||
|
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||||
|
indices = [int((i + 1) * total / (sample_count + 1)) for i in range(sample_count)]
|
||||||
|
direct_count = matched_count = 0
|
||||||
|
matched_ious = []
|
||||||
|
for index in indices:
|
||||||
|
cap.set(cv2.CAP_PROP_POS_FRAMES, index)
|
||||||
|
ok, frame = cap.read()
|
||||||
|
if not ok:
|
||||||
|
continue
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
precision = 16 if str(device).lower() != "cpu" else None
|
||||||
|
direct = boxes(model.predict(frame, imgsz=640, conf=.3, iou=.5,
|
||||||
|
classes=person_ids, device=device,
|
||||||
|
quantize=precision, verbose=False)[0])
|
||||||
|
scale = min(1.0, 2560.0 / max(w, h))
|
||||||
|
small = (frame if scale == 1.0 else
|
||||||
|
cv2.resize(frame, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA))
|
||||||
|
ok, encoded = cv2.imencode(".jpg", small, [cv2.IMWRITE_JPEG_QUALITY, 95])
|
||||||
|
realtime = boxes(model.predict(cv2.imdecode(encoded, cv2.IMREAD_COLOR), imgsz=640,
|
||||||
|
conf=.3, iou=.5, classes=person_ids,
|
||||||
|
device=device, quantize=precision,
|
||||||
|
verbose=False)[0]) if ok else []
|
||||||
|
realtime = [[v / scale for v in box] for box in realtime]
|
||||||
|
unused = set(range(len(realtime)))
|
||||||
|
for box in direct:
|
||||||
|
direct_count += 1
|
||||||
|
candidates = [(iou(box, realtime[j]), j) for j in unused]
|
||||||
|
best = max(candidates, default=(0, None))
|
||||||
|
if best[0] >= .8:
|
||||||
|
matched_count += 1
|
||||||
|
matched_ious.append(best[0])
|
||||||
|
unused.remove(best[1])
|
||||||
|
print("frame=%d direct_person=%d realtime_person=%d" % (index, len(direct), len(realtime)))
|
||||||
|
cap.release()
|
||||||
|
recall = matched_count / direct_count if direct_count else 1.0
|
||||||
|
mean_iou = sum(matched_ious) / len(matched_ious) if matched_ious else 0.0
|
||||||
|
print("matched=%d/%d recall=%.3f mean_iou=%.3f" %
|
||||||
|
(matched_count, direct_count, recall, mean_iou))
|
||||||
|
return 0 if recall >= .95 and (not matched_ious or min(matched_ious) >= .8) else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
raise SystemExit("usage: validate_yolo_replay.py VIDEO WEIGHTS [SAMPLES] [DEVICE]")
|
||||||
|
raise SystemExit(main(sys.argv[1], sys.argv[2], int(sys.argv[3]) if len(sys.argv) > 3 else 5,
|
||||||
|
sys.argv[4] if len(sys.argv) > 4 else "cpu"))
|
||||||
103
tests/windows_integration.py
Normal file
103
tests/windows_integration.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
"""Run separately: all database, keys and configuration live in a temporary directory."""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
ROOT=Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0,str(ROOT))
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with tempfile.TemporaryDirectory(prefix='monitor-部署-test-') as tmp:
|
||||||
|
os.environ.update(MONITOR_DESKTOP='1',MONITOR_DATA_DIR=tmp,
|
||||||
|
DJANGO_SETTINGS_MODULE='framework.settings',
|
||||||
|
MONITOR_DEV_PUBLIC_KEY=str(Path(tmp)/'public.pem'))
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
key=Ed25519PrivateKey.generate()
|
||||||
|
Path(tmp,'public.pem').write_bytes(key.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo))
|
||||||
|
from monitor_runtime.bootstrap import prepare_files,initialize_database
|
||||||
|
prepare_files()
|
||||||
|
initialize_database()
|
||||||
|
from django.test import Client
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from monitor_runtime import web, licensing
|
||||||
|
from monitor_runtime.paths import data,atomic_json
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
fingerprint='v1:'+'b'*64
|
||||||
|
checks=[]
|
||||||
|
with patch.object(licensing,'machine_identity',return_value={'fingerprint':fingerprint,'weak_uuid':False}):
|
||||||
|
client=Client(enforce_csrf_checks=True,HTTP_HOST='localhost')
|
||||||
|
local={'REMOTE_ADDR':'127.0.0.1'}
|
||||||
|
remote={'REMOTE_ADDR':'192.168.1.5'}
|
||||||
|
assert client.get('/setup',**remote).status_code==403
|
||||||
|
assert client.get('/setup',**local).status_code==200
|
||||||
|
csrf=client.cookies['csrftoken'].value
|
||||||
|
assert client.post('/license/request',{'setup_token':web.SETUP_TOKEN},**local).status_code==403
|
||||||
|
assert client.post('/license/request',{'setup_token':'wrong'},HTTP_X_CSRFTOKEN=csrf,**local).status_code==403
|
||||||
|
result=client.post('/license/request',{'setup_token':web.SETUP_TOKEN},HTTP_X_CSRFTOKEN=csrf,**local)
|
||||||
|
assert result.status_code==200 and result.json()['fingerprint']==fingerprint
|
||||||
|
checks.append('local-only bootstrap, one-time token and CSRF')
|
||||||
|
from datetime import datetime,timezone,timedelta
|
||||||
|
payload={'schema_version':1,'product':'monitor','license_id':'integration','customer':'integration-test',
|
||||||
|
'machine_fingerprint':fingerprint,'issued_at':datetime.now(timezone.utc).isoformat(),'expires_at':None}
|
||||||
|
document={'payload':payload,'signature':base64.b64encode(key.sign(licensing.canonical(payload))).decode()}
|
||||||
|
uploaded=SimpleUploadedFile('license.json',json.dumps(document).encode(),content_type='application/json')
|
||||||
|
result=client.post('/license/import',{'setup_token':web.SETUP_TOKEN,'license':uploaded},HTTP_X_CSRFTOKEN=csrf,**local)
|
||||||
|
assert result.status_code==200,result.content[:200]
|
||||||
|
assert licensing.check_license()['valid']
|
||||||
|
checks.append('signed import and DPAPI clock persistence')
|
||||||
|
with patch('monitor_runtime.bootstrap.local_addresses',return_value=['192.168.1.10']):
|
||||||
|
result=client.post('/setup',{'setup_token':web.SETUP_TOKEN,'username':'owner',
|
||||||
|
'password':'Different!7826Secure','confirm':'Different!7826Secure','address':'192.168.1.10'},
|
||||||
|
HTTP_X_CSRFTOKEN=csrf,**local)
|
||||||
|
assert result.status_code==200,result.content[:200]
|
||||||
|
assert get_user_model().objects.filter(is_superuser=True).count()==1
|
||||||
|
assert client.get('/setup',**local).status_code==403
|
||||||
|
assert client.post('/license/request',{'setup_token':web.SETUP_TOKEN},HTTP_X_CSRFTOKEN=csrf,**local).status_code==403
|
||||||
|
checks.append('first administrator and permanently closed setup')
|
||||||
|
snapshot=data('static/storage/alarm/test.jpg');snapshot.parent.mkdir(parents=True,exist_ok=True);snapshot.write_bytes(b'image')
|
||||||
|
assert client.get('/static/storage/alarm/test.jpg',**local).status_code==403
|
||||||
|
client.force_login(get_user_model().objects.get(username='owner'))
|
||||||
|
response=client.get('/static/storage/alarm/test.jpg',**local)
|
||||||
|
assert response.status_code==200
|
||||||
|
response.close()
|
||||||
|
assert client.get('/static/storage/../../.runtime-secrets.json',**local).status_code in (403,404)
|
||||||
|
assert client.get('/license/status',**local).status_code==200
|
||||||
|
checks.append('authenticated private files and administrator license status')
|
||||||
|
old_secret=data('.runtime-secrets.json').read_bytes()
|
||||||
|
initialize_database()
|
||||||
|
assert data('.runtime-secrets.json').read_bytes()==old_secret
|
||||||
|
assert get_user_model().objects.filter(username='owner').count()==1
|
||||||
|
checks.append('idempotent database initialization preserves users and encryption key')
|
||||||
|
payload['expires_at']=(datetime.now(timezone.utc)-timedelta(seconds=1)).isoformat()
|
||||||
|
document={'payload':payload,'signature':base64.b64encode(key.sign(licensing.canonical(payload))).decode()}
|
||||||
|
atomic_json(data('license.json'),document)
|
||||||
|
assert not licensing.check_license()['valid']
|
||||||
|
assert client.get('/analysis/openStatus',**local).status_code==403
|
||||||
|
assert client.get('/license/status',**local).status_code==200
|
||||||
|
checks.append('expiry blocks APIs and leaves renewal available')
|
||||||
|
payload['expires_at']=None
|
||||||
|
document={'payload':payload,'signature':base64.b64encode(key.sign(licensing.canonical(payload))).decode()}
|
||||||
|
licensing.import_license(json.dumps(document).encode())
|
||||||
|
with patch.object(licensing.time,'time',return_value=time.time()-1000):
|
||||||
|
assert not licensing.check_license()['valid']
|
||||||
|
checks.append('renewal and clock rollback detection')
|
||||||
|
get_user_model().objects.all().delete()
|
||||||
|
assert client.get('/setup',**local).status_code==403
|
||||||
|
checks.append('initialization stays closed even after administrator deletion')
|
||||||
|
from django.db import connections
|
||||||
|
connections.close_all()
|
||||||
|
import logging
|
||||||
|
logging.shutdown()
|
||||||
|
for handler in list(logging.getLogger().handlers):
|
||||||
|
logging.getLogger().removeHandler(handler)
|
||||||
|
handler.close()
|
||||||
|
print(json.dumps({'passed':checks},ensure_ascii=False,indent=2))
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
main()
|
||||||
192
tests/windows_lifecycle.py
Normal file
192
tests/windows_lifecycle.py
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
"""Real Windows worker lifecycle validation. Requires a vendor DPAPI key; never shipped."""
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import configparser
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import secrets
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
ROOT=Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0,str(ROOT))
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser=argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--executable',type=Path,required=True)
|
||||||
|
parser.add_argument('--resources',type=Path,required=True)
|
||||||
|
parser.add_argument('--private-key',type=Path,required=True)
|
||||||
|
parser.add_argument('--data',type=Path,required=True)
|
||||||
|
args=parser.parse_args()
|
||||||
|
target=args.data.resolve()
|
||||||
|
if target.exists():
|
||||||
|
raise RuntimeError('Use a fresh test data directory')
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
os.environ['MONITOR_DATA_DIR']=str(target)
|
||||||
|
os.environ['MONITOR_DESKTOP']='1'
|
||||||
|
import psutil
|
||||||
|
import requests
|
||||||
|
from multiprocessing.connection import Listener
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from monitor_runtime.licensing import canonical
|
||||||
|
from monitor_runtime.windows import protect,Job
|
||||||
|
from monitor_runtime.bootstrap import local_addresses
|
||||||
|
cfg=json.loads((args.resources/'deploy/windows/default-config.json').read_text(encoding='utf-8'))
|
||||||
|
def free_port():
|
||||||
|
with socket.socket() as sock:
|
||||||
|
sock.bind(('127.0.0.1',0))
|
||||||
|
return sock.getsockname()[1]
|
||||||
|
ports=[free_port() for _ in range(5)]
|
||||||
|
cfg.update(adminPort=ports[0],mediaHttpPort=ports[1],mediaRtspPort=ports[2],mediaRtmpPort=ports[3],
|
||||||
|
isEnableLoginCaptcha=False,autoAddStreamProxy=False)
|
||||||
|
cfg['sipServer']['sipServerPort']=ports[4]
|
||||||
|
(target/'config.json').write_text(json.dumps(cfg),encoding='utf-8')
|
||||||
|
ini=configparser.ConfigParser(interpolation=None)
|
||||||
|
ini.optionxform=str
|
||||||
|
ini.read(args.resources/'deploy/windows/zlm-template.ini',encoding='utf-8')
|
||||||
|
for section,port in [('http',ports[1]),('rtsp',ports[2]),('rtmp',ports[3]),('rtp_proxy',0)]:
|
||||||
|
ini[section]['port']=str(port)
|
||||||
|
with (target/'zlm-template.ini').open('w',encoding='utf-8') as stream:ini.write(stream)
|
||||||
|
raw=args.private_key.read_bytes()
|
||||||
|
assert raw.startswith(b'MONITOR-DPAPI-V1\n')
|
||||||
|
key=serialization.load_pem_private_key(protect(base64.b64decode(raw.split(b'\n',1)[1]),decrypt=True),None)
|
||||||
|
command=[str(args.executable.resolve())]
|
||||||
|
if args.executable.suffix=='.py':command=[sys.executable]+command
|
||||||
|
env=os.environ.copy()
|
||||||
|
env['MONITOR_SERVICE_MODE']='disabled'
|
||||||
|
if args.executable.suffix.lower()=='.exe':
|
||||||
|
env.pop('PYTHONPATH',None);env.pop('PYTHONHOME',None)
|
||||||
|
windows=env['SYSTEMROOT']
|
||||||
|
env['PATH']=windows+'/System32;'+windows+';'+windows+'/System32/WindowsPowerShell/v1.0'
|
||||||
|
session=requests.Session()
|
||||||
|
session.trust_env=False
|
||||||
|
base='http://127.0.0.1:'+str(ports[0])
|
||||||
|
checks=[]
|
||||||
|
def start():
|
||||||
|
auth=secrets.token_bytes(32)
|
||||||
|
address=r'\\.\pipe\MonitorValidation-'+secrets.token_hex(12)
|
||||||
|
listener=Listener(address,family='AF_PIPE',authkey=auth)
|
||||||
|
job=Job()
|
||||||
|
log=(target/'worker.log').open('ab',buffering=0)
|
||||||
|
child=subprocess.Popen(command+['--worker',address,auth.hex()],env=env,cwd=target,
|
||||||
|
stdout=log,stderr=log,creationflags=subprocess.CREATE_NO_WINDOW)
|
||||||
|
log.close()
|
||||||
|
job.assign(child)
|
||||||
|
accepted=[]
|
||||||
|
threading.Thread(target=lambda:accepted.append(listener.accept()),daemon=True).start()
|
||||||
|
deadline=time.monotonic()+60
|
||||||
|
while not accepted:
|
||||||
|
if child.poll() is not None or time.monotonic()>deadline:
|
||||||
|
job.close();listener.close()
|
||||||
|
raise RuntimeError('Worker failed before pipe connection')
|
||||||
|
time.sleep(.1)
|
||||||
|
conn=accepted[0];listener.close();conn.send('run')
|
||||||
|
if not conn.poll(180):
|
||||||
|
job.close();raise RuntimeError('Worker startup timeout')
|
||||||
|
message=conn.recv()
|
||||||
|
return child,conn,job,message
|
||||||
|
def finish(child,conn,job,expected=0,stop=True):
|
||||||
|
descendants=psutil.Process(child.pid).children(recursive=True) if child.poll() is None else []
|
||||||
|
if stop:conn.send('stop')
|
||||||
|
try:
|
||||||
|
code=child.wait(timeout=40)
|
||||||
|
assert code==expected,(code,expected)
|
||||||
|
finally:
|
||||||
|
conn.close();job.close()
|
||||||
|
gone,alive=psutil.wait_procs(descendants,timeout=10)
|
||||||
|
assert not alive,[p.pid for p in alive]
|
||||||
|
child=conn=job=None
|
||||||
|
try:
|
||||||
|
child,conn,job,message=start()
|
||||||
|
assert message['phase']=='setup',message
|
||||||
|
assert session.get(base+'/setup',timeout=10).status_code==200
|
||||||
|
token=urlsplit(message['url']).fragment
|
||||||
|
csrf=session.cookies['csrftoken']
|
||||||
|
session.headers['X-CSRFToken']=csrf
|
||||||
|
assert session.post(base+'/license/request',data={'setup_token':'wrong'},timeout=10).status_code==403
|
||||||
|
request=session.post(base+'/license/request',data={'setup_token':token},timeout=30).json()
|
||||||
|
payload={'schema_version':1,'product':'monitor','license_id':'lifecycle-validation',
|
||||||
|
'customer':'local build validation only','machine_fingerprint':request['fingerprint'],
|
||||||
|
'issued_at':datetime.now(timezone.utc).isoformat(),
|
||||||
|
'expires_at':(datetime.now(timezone.utc)+timedelta(hours=2)).isoformat()}
|
||||||
|
doc={'payload':payload,'signature':base64.b64encode(key.sign(canonical(payload))).decode()}
|
||||||
|
response=session.post(base+'/license/import',data={'setup_token':token},
|
||||||
|
files={'license':('test.json',json.dumps(doc),'application/json')},timeout=15)
|
||||||
|
assert response.status_code==200,response.text[:200]
|
||||||
|
address=local_addresses()[0]
|
||||||
|
response=session.post(base+'/setup',data={'setup_token':token,'username':'validation-admin',
|
||||||
|
'password':'Test-setup!'+secrets.token_hex(12),'address':address,'confirm':'unused'},timeout=10)
|
||||||
|
assert response.status_code==400
|
||||||
|
password='Test-setup!'+secrets.token_hex(12)
|
||||||
|
response=session.post(base+'/setup',data={'setup_token':token,'username':'validation-admin',
|
||||||
|
'password':password,'confirm':password,'address':address},timeout=10)
|
||||||
|
assert response.status_code==200,response.text[:200]
|
||||||
|
finish(child,conn,job,expected=20,stop=False);child=conn=job=None
|
||||||
|
checks.append('real HTTP bootstrap, CSRF, signed license, fresh admin and restart request')
|
||||||
|
child,conn,job,message=start()
|
||||||
|
assert message['phase']=='ready',message
|
||||||
|
assert (target/'log/zlm').is_dir()
|
||||||
|
assert not (args.resources/'zlm/log').exists(), 'ZLM wrote into program resources'
|
||||||
|
assert session.get(base+'/login',timeout=15).status_code==200
|
||||||
|
assert session.get(base+'/static/lib/js/app.js',timeout=15).status_code==200
|
||||||
|
assert session.get(base+'/setup',timeout=15).status_code==403
|
||||||
|
assert session.get(base+'/static/storage/private.jpg',timeout=15).status_code==403
|
||||||
|
with socket.create_connection(('127.0.0.1',ports[1]),timeout=5):pass
|
||||||
|
listeners=psutil.net_connections(kind='udp')
|
||||||
|
owned={child.pid}|{p.pid for p in psutil.Process(child.pid).children(recursive=True)}
|
||||||
|
assert any(c.pid in owned and c.laddr.port==ports[4] for c in listeners)
|
||||||
|
children=psutil.Process(child.pid).children(recursive=True)
|
||||||
|
assert any(p.name().lower()=='monitor_zlm.exe' for p in children)
|
||||||
|
assert not any('spawn_main' in ' '.join(p.cmdline()) for p in children)
|
||||||
|
response=session.post(base+'/login',data={'username':'validation-admin','password':password},timeout=15)
|
||||||
|
assert response.status_code==200,response.text[:200]
|
||||||
|
assert session.get(base+'/license/status',timeout=15).status_code==200,response.text[:200]
|
||||||
|
checks.append('Waitress login/static; ZLM and SIP ready; analysis not auto-started')
|
||||||
|
expiry_payload=dict(payload,expires_at=(datetime.now(timezone.utc)+timedelta(seconds=7)).isoformat())
|
||||||
|
expiry_doc={'payload':expiry_payload,'signature':base64.b64encode(key.sign(canonical(expiry_payload))).decode()}
|
||||||
|
(target/'license.json').write_text(json.dumps(expiry_doc),encoding='utf-8')
|
||||||
|
finish(child,conn,job,expected=20,stop=False);child=conn=job=None
|
||||||
|
child,conn,job,message=start()
|
||||||
|
assert message['phase']=='license',message
|
||||||
|
assert not any(p.name().lower()=='monitor_zlm.exe' for p in psutil.Process(child.pid).children(recursive=True))
|
||||||
|
assert session.get(base+'/license/status',timeout=10).json()['valid'] is False
|
||||||
|
session.headers['X-CSRFToken']=session.cookies['csrftoken']
|
||||||
|
response=session.post(base+'/license/import',files={'license':('renew.json',json.dumps(doc),'application/json')},timeout=15)
|
||||||
|
assert response.status_code==200,response.text[:200]
|
||||||
|
finish(child,conn,job,expected=20,stop=False);child=conn=job=None
|
||||||
|
child,conn,job,message=start()
|
||||||
|
assert message['phase']=='ready',message
|
||||||
|
checks.append('runtime expiry stops services; authenticated renewal restores service')
|
||||||
|
finish(child,conn,job);child=conn=job=None
|
||||||
|
checks.append('graceful shutdown and owned descendant cleanup')
|
||||||
|
with socket.socket() as occupied:
|
||||||
|
occupied.bind(('0.0.0.0',ports[0]));occupied.listen()
|
||||||
|
child,conn,job,message=start()
|
||||||
|
assert message['phase']=='failed',message
|
||||||
|
finish(child,conn,job,expected=1,stop=False);child=conn=job=None
|
||||||
|
assert occupied.getsockname()[1]==ports[0]
|
||||||
|
checks.append('port conflict fails without killing the listener')
|
||||||
|
child,conn,job,message=start()
|
||||||
|
assert message['phase']=='ready',message
|
||||||
|
children=psutil.Process(child.pid).children(recursive=True)
|
||||||
|
job.close();job=None
|
||||||
|
child.wait(timeout=15)
|
||||||
|
_,alive=psutil.wait_procs(children,timeout=10)
|
||||||
|
assert not alive
|
||||||
|
conn.close();child=conn=None
|
||||||
|
checks.append('Job Object kills owned children on forced shutdown')
|
||||||
|
(target/'validation.json').write_text(json.dumps({'status':'passed','checks':checks},indent=2),encoding='utf-8')
|
||||||
|
print(json.dumps(checks,indent=2))
|
||||||
|
finally:
|
||||||
|
if child and child.poll() is None:
|
||||||
|
if conn:
|
||||||
|
try:conn.send('stop');child.wait(timeout=30)
|
||||||
|
except (OSError,subprocess.TimeoutExpired):pass
|
||||||
|
if job:job.close()
|
||||||
|
if conn:conn.close()
|
||||||
|
if __name__=='__main__':main()
|
||||||
43
tests/windows_tray.py
Normal file
43
tests/windows_tray.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"""Desktop supervisor smoke test; opens the normal local setup browser."""
|
||||||
|
import json,os,secrets,socket,subprocess,sys,time
|
||||||
|
from pathlib import Path
|
||||||
|
import psutil,requests
|
||||||
|
exe=Path(sys.argv[1]).resolve()
|
||||||
|
data=Path(sys.argv[2]).resolve()
|
||||||
|
data.mkdir(parents=True,exist_ok=False)
|
||||||
|
with socket.socket() as sock:
|
||||||
|
sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
|
||||||
|
(data/'config.json').write_text(json.dumps({'host':'127.0.0.1','adminPort':port}),encoding='utf-8')
|
||||||
|
env=os.environ.copy();env['MONITOR_DATA_DIR']=str(data)
|
||||||
|
env.pop('PYTHONPATH',None);env.pop('PYTHONHOME',None)
|
||||||
|
root=env['SYSTEMROOT'];env['PATH']=root+'/System32;'+root+';'+root+'/System32/WindowsPowerShell/v1.0'
|
||||||
|
client=requests.Session();client.trust_env=False
|
||||||
|
base='http://127.0.0.1:'+str(port)
|
||||||
|
child=subprocess.Popen([str(exe)],env=env,cwd=data,creationflags=subprocess.CREATE_NO_WINDOW)
|
||||||
|
try:
|
||||||
|
duplicate=subprocess.Popen([str(exe)],env=env,cwd=data,creationflags=subprocess.CREATE_NO_WINDOW)
|
||||||
|
assert duplicate.wait(timeout=35)==0
|
||||||
|
deadline=time.monotonic()+120
|
||||||
|
while time.monotonic()<deadline:
|
||||||
|
assert child.poll() is None,'Supervisor exited early'
|
||||||
|
try:
|
||||||
|
if client.get(base+'/setup',timeout=2).status_code==200:break
|
||||||
|
except requests.RequestException:pass
|
||||||
|
time.sleep(.5)
|
||||||
|
else:raise RuntimeError('Setup page not ready')
|
||||||
|
descendants=psutil.Process(child.pid).children(recursive=True)
|
||||||
|
workers=[p for p in descendants if p.name().lower()=='monitor.exe']
|
||||||
|
assert len(workers)==1,[(p.pid,p.name()) for p in descendants]
|
||||||
|
stop=subprocess.Popen([str(exe),'--stop'],env=env,cwd=data,creationflags=subprocess.CREATE_NO_WINDOW)
|
||||||
|
assert stop.wait(timeout=50)==0
|
||||||
|
assert child.wait(timeout=10)==0
|
||||||
|
_,alive=psutil.wait_procs(workers,timeout=10)
|
||||||
|
assert not alive
|
||||||
|
result={'status':'passed','single_instance':'passed (simultaneous double launch)',
|
||||||
|
'setup_http':'passed','tray_supervisor':'passed','stop_command':'passed',
|
||||||
|
'owned_workers_stopped':'passed','external_python_path':'removed'}
|
||||||
|
(data/'tray-validation.json').write_text(json.dumps(result,indent=2),encoding='utf-8')
|
||||||
|
print(json.dumps(result,indent=2))
|
||||||
|
finally:
|
||||||
|
if child.poll() is None:
|
||||||
|
child.kill();child.wait(timeout=15)
|
||||||
957
tools/build_deployment_performance_report.py
Normal file
957
tools/build_deployment_performance_report.py
Normal file
@ -0,0 +1,957 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from docx import Document
|
||||||
|
from docx.enum.section import WD_SECTION
|
||||||
|
from docx.enum.table import WD_ALIGN_VERTICAL, WD_CELL_VERTICAL_ALIGNMENT, WD_TABLE_ALIGNMENT
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_LINE_SPACING
|
||||||
|
from docx.oxml import OxmlElement
|
||||||
|
from docx.oxml.ns import qn
|
||||||
|
from docx.shared import Cm, Inches, Pt, RGBColor
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
OUT = ROOT / "系统部署性能分析评估报告.docx"
|
||||||
|
ASSET_DIR = ROOT / "report_assets"
|
||||||
|
CHART = ASSET_DIR / "capacity_requirement.png"
|
||||||
|
|
||||||
|
FONT_REGULAR = Path(r"C:\Windows\Fonts\msyh.ttc")
|
||||||
|
FONT_BOLD = Path(r"C:\Windows\Fonts\msyhbd.ttc")
|
||||||
|
|
||||||
|
INK = "1F2937"
|
||||||
|
MUTED = "5B6472"
|
||||||
|
BLUE = "1F4E78"
|
||||||
|
BLUE_LIGHT = "DCE6F1"
|
||||||
|
PALE_BLUE = "F3F7FB"
|
||||||
|
GOLD = "C58A19"
|
||||||
|
PALE_GOLD = "FFF5D6"
|
||||||
|
RED = "A33A3A"
|
||||||
|
PALE_RED = "FBEAEA"
|
||||||
|
GREEN = "2F6B57"
|
||||||
|
PALE_GREEN = "E8F2ED"
|
||||||
|
GRID = "D9D9D9"
|
||||||
|
WHITE = "FFFFFF"
|
||||||
|
|
||||||
|
|
||||||
|
def font(size: int, bold: bool = False):
|
||||||
|
path = FONT_BOLD if bold else FONT_REGULAR
|
||||||
|
return ImageFont.truetype(str(path), size=size)
|
||||||
|
|
||||||
|
|
||||||
|
def make_capacity_chart(path: Path):
|
||||||
|
"""Static report chart contract.
|
||||||
|
|
||||||
|
Question: What aggregate single-model throughput is required for 50 cameras?
|
||||||
|
Takeaway: Requirement scales from 50 infer/s at 1 fps to 250 infer/s at 5 fps;
|
||||||
|
the measured serial PyTorch reference is below even the 1 fps workload.
|
||||||
|
Family: comparison, horizontal bar. Palette: one blue root plus a gold reference.
|
||||||
|
"""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
width, height = 1800, 980
|
||||||
|
img = Image.new("RGB", (width, height), "#FFFFFF")
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
title_f = font(48, True)
|
||||||
|
sub_f = font(27, False)
|
||||||
|
label_f = font(29, False)
|
||||||
|
value_f = font(30, True)
|
||||||
|
axis_f = font(23, False)
|
||||||
|
note_f = font(23, False)
|
||||||
|
|
||||||
|
d.text((90, 55), "五十路单模型推理吞吐需求", fill="#111827", font=title_f)
|
||||||
|
d.text((90, 125), "单位 推理次数每秒 公式 摄像头数乘每路分析帧率", fill="#5B6472", font=sub_f)
|
||||||
|
|
||||||
|
labels = [
|
||||||
|
"五十路各一帧每秒",
|
||||||
|
"四十路一帧加十路五帧",
|
||||||
|
"五十路各两帧每秒",
|
||||||
|
"五十路各五帧每秒",
|
||||||
|
]
|
||||||
|
values = [50, 90, 100, 250]
|
||||||
|
x0, x1 = 620, 1650
|
||||||
|
y0 = 250
|
||||||
|
row_h = 135
|
||||||
|
max_v = 260
|
||||||
|
|
||||||
|
for tick in [0, 50, 100, 150, 200, 250]:
|
||||||
|
x = x0 + (x1 - x0) * tick / max_v
|
||||||
|
d.line((x, y0 - 30, x, y0 + row_h * len(values) - 25), fill="#E5E7EB", width=2)
|
||||||
|
txt = str(tick)
|
||||||
|
bb = d.textbbox((0, 0), txt, font=axis_f)
|
||||||
|
d.text((x - (bb[2] - bb[0]) / 2, y0 - 70), txt, fill="#6B7280", font=axis_f)
|
||||||
|
|
||||||
|
for i, (lab, val) in enumerate(zip(labels, values)):
|
||||||
|
y = y0 + i * row_h
|
||||||
|
d.text((90, y + 20), lab, fill="#1F2937", font=label_f)
|
||||||
|
bar_end = x0 + (x1 - x0) * val / max_v
|
||||||
|
d.rounded_rectangle((x0, y + 10, bar_end, y + 72), radius=14, fill="#2F6EA3", outline="#1F4E78", width=2)
|
||||||
|
d.text((bar_end + 18, y + 20), str(val), fill="#1F2937", font=value_f)
|
||||||
|
|
||||||
|
ref = 20.4
|
||||||
|
rx = x0 + (x1 - x0) * ref / max_v
|
||||||
|
d.line((rx, y0 - 25, rx, y0 + row_h * len(values) - 20), fill="#C58A19", width=5)
|
||||||
|
d.text((rx + 12, y0 + row_h * len(values) - 8), "当前开发机串行参考 20.4", fill="#8A5E12", font=note_f)
|
||||||
|
d.text((90, 850), "说明 参考值来自 RTX 5060 Laptop GPU 上 YOLO26x 640 PyTorch FP16 的二十次本地单图测试", fill="#5B6472", font=note_f)
|
||||||
|
d.text((90, 892), "不含 RTSP 解码 进程间传输 JPEG 编解码和告警处理 因此只能视为吞吐上限参考", fill="#5B6472", font=note_f)
|
||||||
|
img.save(path, quality=95)
|
||||||
|
|
||||||
|
|
||||||
|
def set_cell_shading(cell, fill: str):
|
||||||
|
tc_pr = cell._tc.get_or_add_tcPr()
|
||||||
|
shd = tc_pr.find(qn("w:shd"))
|
||||||
|
if shd is None:
|
||||||
|
shd = OxmlElement("w:shd")
|
||||||
|
tc_pr.append(shd)
|
||||||
|
shd.set(qn("w:fill"), fill)
|
||||||
|
|
||||||
|
|
||||||
|
def set_cell_margins(cell, top=110, start=120, bottom=110, end=120):
|
||||||
|
tc = cell._tc
|
||||||
|
tc_pr = tc.get_or_add_tcPr()
|
||||||
|
tc_mar = tc_pr.first_child_found_in("w:tcMar")
|
||||||
|
if tc_mar is None:
|
||||||
|
tc_mar = OxmlElement("w:tcMar")
|
||||||
|
tc_pr.append(tc_mar)
|
||||||
|
for m, v in (("top", top), ("start", start), ("bottom", bottom), ("end", end)):
|
||||||
|
node = tc_mar.find(qn(f"w:{m}"))
|
||||||
|
if node is None:
|
||||||
|
node = OxmlElement(f"w:{m}")
|
||||||
|
tc_mar.append(node)
|
||||||
|
node.set(qn("w:w"), str(v))
|
||||||
|
node.set(qn("w:type"), "dxa")
|
||||||
|
|
||||||
|
|
||||||
|
def set_table_borders(table, color=GRID, size="6"):
|
||||||
|
tbl_pr = table._tbl.tblPr
|
||||||
|
borders = tbl_pr.first_child_found_in("w:tblBorders")
|
||||||
|
if borders is None:
|
||||||
|
borders = OxmlElement("w:tblBorders")
|
||||||
|
tbl_pr.append(borders)
|
||||||
|
for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
|
||||||
|
tag = qn(f"w:{edge}")
|
||||||
|
el = borders.find(tag)
|
||||||
|
if el is None:
|
||||||
|
el = OxmlElement(f"w:{edge}")
|
||||||
|
borders.append(el)
|
||||||
|
el.set(qn("w:val"), "single")
|
||||||
|
el.set(qn("w:sz"), size)
|
||||||
|
el.set(qn("w:space"), "0")
|
||||||
|
el.set(qn("w:color"), color)
|
||||||
|
|
||||||
|
|
||||||
|
def set_repeat_table_header(row):
|
||||||
|
tr_pr = row._tr.get_or_add_trPr()
|
||||||
|
tbl_header = OxmlElement("w:tblHeader")
|
||||||
|
tbl_header.set(qn("w:val"), "true")
|
||||||
|
tr_pr.append(tbl_header)
|
||||||
|
|
||||||
|
|
||||||
|
def prevent_row_split(row):
|
||||||
|
tr_pr = row._tr.get_or_add_trPr()
|
||||||
|
cant = OxmlElement("w:cantSplit")
|
||||||
|
tr_pr.append(cant)
|
||||||
|
|
||||||
|
|
||||||
|
def set_cell_width(cell, width_cm: float):
|
||||||
|
tc_pr = cell._tc.get_or_add_tcPr()
|
||||||
|
tc_w = tc_pr.find(qn("w:tcW"))
|
||||||
|
if tc_w is None:
|
||||||
|
tc_w = OxmlElement("w:tcW")
|
||||||
|
tc_pr.append(tc_w)
|
||||||
|
tc_w.set(qn("w:w"), str(int(Cm(width_cm).emu / 635)))
|
||||||
|
tc_w.set(qn("w:type"), "dxa")
|
||||||
|
|
||||||
|
|
||||||
|
def set_run_font(run, name="Microsoft YaHei", size=None, bold=None, color=None):
|
||||||
|
run.font.name = name
|
||||||
|
rpr = run._element.get_or_add_rPr()
|
||||||
|
rfonts = rpr.rFonts
|
||||||
|
if rfonts is None:
|
||||||
|
rfonts = OxmlElement("w:rFonts")
|
||||||
|
rpr.append(rfonts)
|
||||||
|
for attr in ("ascii", "hAnsi", "eastAsia", "cs"):
|
||||||
|
rfonts.set(qn(f"w:{attr}"), name)
|
||||||
|
if size is not None:
|
||||||
|
run.font.size = Pt(size)
|
||||||
|
if bold is not None:
|
||||||
|
run.bold = bold
|
||||||
|
if color is not None:
|
||||||
|
run.font.color.rgb = RGBColor.from_string(color)
|
||||||
|
|
||||||
|
|
||||||
|
def add_page_number(paragraph):
|
||||||
|
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
run = paragraph.add_run()
|
||||||
|
fld_char1 = OxmlElement("w:fldChar")
|
||||||
|
fld_char1.set(qn("w:fldCharType"), "begin")
|
||||||
|
instr_text = OxmlElement("w:instrText")
|
||||||
|
instr_text.set(qn("xml:space"), "preserve")
|
||||||
|
instr_text.text = " PAGE "
|
||||||
|
fld_char2 = OxmlElement("w:fldChar")
|
||||||
|
fld_char2.set(qn("w:fldCharType"), "end")
|
||||||
|
run._r.extend([fld_char1, instr_text, fld_char2])
|
||||||
|
set_run_font(run, size=9, color="7A7A7A")
|
||||||
|
|
||||||
|
|
||||||
|
def add_hyperlink(paragraph, text: str, url: str):
|
||||||
|
part = paragraph.part
|
||||||
|
rel_id = part.relate_to(url, "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", is_external=True)
|
||||||
|
hyperlink = OxmlElement("w:hyperlink")
|
||||||
|
hyperlink.set(qn("r:id"), rel_id)
|
||||||
|
new_run = OxmlElement("w:r")
|
||||||
|
r_pr = OxmlElement("w:rPr")
|
||||||
|
color = OxmlElement("w:color")
|
||||||
|
color.set(qn("w:val"), "1F4E78")
|
||||||
|
r_pr.append(color)
|
||||||
|
underline = OxmlElement("w:u")
|
||||||
|
underline.set(qn("w:val"), "single")
|
||||||
|
r_pr.append(underline)
|
||||||
|
r_fonts = OxmlElement("w:rFonts")
|
||||||
|
for attr in ("ascii", "hAnsi", "eastAsia"):
|
||||||
|
r_fonts.set(qn(f"w:{attr}"), "Microsoft YaHei")
|
||||||
|
r_pr.append(r_fonts)
|
||||||
|
new_run.append(r_pr)
|
||||||
|
t = OxmlElement("w:t")
|
||||||
|
t.text = text
|
||||||
|
new_run.append(t)
|
||||||
|
hyperlink.append(new_run)
|
||||||
|
paragraph._p.append(hyperlink)
|
||||||
|
|
||||||
|
|
||||||
|
def add_table(doc, headers, rows, widths=None, font_size=8.7, header_fill=BLUE):
|
||||||
|
table = doc.add_table(rows=1, cols=len(headers))
|
||||||
|
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||||
|
table.autofit = False
|
||||||
|
set_table_borders(table)
|
||||||
|
hdr = table.rows[0]
|
||||||
|
set_repeat_table_header(hdr)
|
||||||
|
prevent_row_split(hdr)
|
||||||
|
for i, text in enumerate(headers):
|
||||||
|
cell = hdr.cells[i]
|
||||||
|
set_cell_shading(cell, header_fill)
|
||||||
|
set_cell_margins(cell)
|
||||||
|
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
|
||||||
|
if widths:
|
||||||
|
set_cell_width(cell, widths[i])
|
||||||
|
p = cell.paragraphs[0]
|
||||||
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
p.paragraph_format.space_after = Pt(0)
|
||||||
|
r = p.add_run(str(text))
|
||||||
|
set_run_font(r, size=font_size, bold=True, color=WHITE)
|
||||||
|
for ridx, row in enumerate(rows):
|
||||||
|
cells = table.add_row().cells
|
||||||
|
prevent_row_split(table.rows[-1])
|
||||||
|
for i, value in enumerate(row):
|
||||||
|
cell = cells[i]
|
||||||
|
set_cell_margins(cell)
|
||||||
|
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
|
||||||
|
if widths:
|
||||||
|
set_cell_width(cell, widths[i])
|
||||||
|
if ridx % 2 == 1:
|
||||||
|
set_cell_shading(cell, PALE_BLUE)
|
||||||
|
p = cell.paragraphs[0]
|
||||||
|
p.paragraph_format.space_after = Pt(0)
|
||||||
|
p.paragraph_format.line_spacing = 1.05
|
||||||
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER if i == 0 and len(str(value)) < 18 else WD_ALIGN_PARAGRAPH.LEFT
|
||||||
|
r = p.add_run(str(value))
|
||||||
|
set_run_font(r, size=font_size, color=INK)
|
||||||
|
doc.add_paragraph().paragraph_format.space_after = Pt(1)
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def add_caption(doc, text):
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
p.paragraph_format.keep_with_next = True
|
||||||
|
p.paragraph_format.space_before = Pt(2)
|
||||||
|
p.paragraph_format.space_after = Pt(5)
|
||||||
|
r = p.add_run(text)
|
||||||
|
set_run_font(r, size=9, color=MUTED)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def add_bullet(doc, text, level=0, lead=None):
|
||||||
|
p = doc.add_paragraph(style="List Bullet" if level == 0 else "List Bullet 2")
|
||||||
|
p.paragraph_format.space_after = Pt(3)
|
||||||
|
p.paragraph_format.left_indent = Cm(0.65 + 0.55 * level)
|
||||||
|
p.paragraph_format.first_line_indent = Cm(-0.35)
|
||||||
|
if lead and text.startswith(lead):
|
||||||
|
r1 = p.add_run(lead)
|
||||||
|
set_run_font(r1, bold=True, size=10.2, color=INK)
|
||||||
|
r2 = p.add_run(text[len(lead):])
|
||||||
|
set_run_font(r2, size=10.2, color=INK)
|
||||||
|
else:
|
||||||
|
r = p.add_run(text)
|
||||||
|
set_run_font(r, size=10.2, color=INK)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def add_numbered(doc, text):
|
||||||
|
p = doc.add_paragraph(style="List Number")
|
||||||
|
p.paragraph_format.space_after = Pt(3)
|
||||||
|
r = p.add_run(text)
|
||||||
|
set_run_font(r, size=10.2, color=INK)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def add_manual_numbered(doc, number, text):
|
||||||
|
"""Add a visually numbered paragraph without inheriting Word list state."""
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.paragraph_format.space_after = Pt(3)
|
||||||
|
p.paragraph_format.left_indent = Cm(0.65)
|
||||||
|
p.paragraph_format.first_line_indent = Cm(-0.35)
|
||||||
|
r1 = p.add_run(f"{number}. ")
|
||||||
|
set_run_font(r1, size=10.2, bold=True, color=INK)
|
||||||
|
r2 = p.add_run(text)
|
||||||
|
set_run_font(r2, size=10.2, color=INK)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def add_para(doc, text="", bold_lead=None, style=None, align=None):
|
||||||
|
p = doc.add_paragraph(style=style)
|
||||||
|
p.paragraph_format.space_after = Pt(6)
|
||||||
|
p.paragraph_format.line_spacing = 1.28
|
||||||
|
if align is not None:
|
||||||
|
p.alignment = align
|
||||||
|
if bold_lead and text.startswith(bold_lead):
|
||||||
|
a = p.add_run(bold_lead)
|
||||||
|
set_run_font(a, size=10.5, bold=True, color=INK)
|
||||||
|
b = p.add_run(text[len(bold_lead):])
|
||||||
|
set_run_font(b, size=10.5, color=INK)
|
||||||
|
else:
|
||||||
|
r = p.add_run(text)
|
||||||
|
set_run_font(r, size=10.5, color=INK)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def add_heading(doc, text, level=1):
|
||||||
|
p = doc.add_paragraph(text, style=f"Heading {level}")
|
||||||
|
p.paragraph_format.keep_with_next = True
|
||||||
|
p.paragraph_format.keep_together = True
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def configure_styles(doc):
|
||||||
|
styles = doc.styles
|
||||||
|
normal = styles["Normal"]
|
||||||
|
normal.font.name = "Microsoft YaHei"
|
||||||
|
normal.font.size = Pt(10.5)
|
||||||
|
normal.font.color.rgb = RGBColor.from_string(INK)
|
||||||
|
normal._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
|
||||||
|
normal.paragraph_format.line_spacing = 1.28
|
||||||
|
normal.paragraph_format.space_after = Pt(6)
|
||||||
|
|
||||||
|
title = styles["Title"]
|
||||||
|
title.font.name = "Microsoft YaHei"
|
||||||
|
title._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
|
||||||
|
title.font.size = Pt(27)
|
||||||
|
title.font.bold = True
|
||||||
|
title.font.color.rgb = RGBColor(0, 0, 0)
|
||||||
|
title.paragraph_format.space_after = Pt(16)
|
||||||
|
# Remove the built-in Title style's decorative bottom border so the cover
|
||||||
|
# remains clean and does not render as an unintended horizontal rule.
|
||||||
|
ppr = title._element.get_or_add_pPr()
|
||||||
|
pbd = ppr.find(qn("w:pBdr"))
|
||||||
|
if pbd is not None:
|
||||||
|
ppr.remove(pbd)
|
||||||
|
|
||||||
|
for name, size, before, after in (("Heading 1", 17, 18, 9), ("Heading 2", 13, 13, 6), ("Heading 3", 11, 9, 4)):
|
||||||
|
st = styles[name]
|
||||||
|
st.font.name = "Microsoft YaHei"
|
||||||
|
st._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
|
||||||
|
st.font.size = Pt(size)
|
||||||
|
st.font.bold = True
|
||||||
|
st.font.color.rgb = RGBColor(0, 0, 0)
|
||||||
|
st.paragraph_format.space_before = Pt(before)
|
||||||
|
st.paragraph_format.space_after = Pt(after)
|
||||||
|
st.paragraph_format.keep_with_next = True
|
||||||
|
|
||||||
|
for name in ("List Bullet", "List Bullet 2", "List Number"):
|
||||||
|
st = styles[name]
|
||||||
|
st.font.name = "Microsoft YaHei"
|
||||||
|
st._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
|
||||||
|
st.font.size = Pt(10.2)
|
||||||
|
st.font.color.rgb = RGBColor.from_string(INK)
|
||||||
|
|
||||||
|
|
||||||
|
def set_core_properties(doc):
|
||||||
|
cp = doc.core_properties
|
||||||
|
cp.title = "视频监控平台五十路摄像头系统部署性能分析评估报告"
|
||||||
|
cp.subject = "区域检测 目标检测与告警场景部署容量和性能评估"
|
||||||
|
cp.author = "视频监控平台项目组"
|
||||||
|
cp.keywords = "视频监控, YOLO, 50路摄像头, GPU, 部署, 性能评估"
|
||||||
|
cp.comments = "基于代码审查 本地参考测试和公开产品资料形成"
|
||||||
|
|
||||||
|
|
||||||
|
def build_document():
|
||||||
|
make_capacity_chart(CHART)
|
||||||
|
doc = Document()
|
||||||
|
configure_styles(doc)
|
||||||
|
set_core_properties(doc)
|
||||||
|
|
||||||
|
sec = doc.sections[0]
|
||||||
|
sec.page_width = Cm(21.0)
|
||||||
|
sec.page_height = Cm(29.7)
|
||||||
|
sec.top_margin = Cm(2.0)
|
||||||
|
sec.bottom_margin = Cm(1.8)
|
||||||
|
sec.left_margin = Cm(2.15)
|
||||||
|
sec.right_margin = Cm(2.15)
|
||||||
|
sec.header_distance = Cm(0.8)
|
||||||
|
sec.footer_distance = Cm(0.8)
|
||||||
|
add_page_number(sec.footer.paragraphs[0])
|
||||||
|
|
||||||
|
# Cover
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.paragraph_format.space_before = Pt(70)
|
||||||
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
r = p.add_run("视频监控平台五十路摄像头\n系统部署性能分析评估报告")
|
||||||
|
set_run_font(r, size=27, bold=True, color="000000")
|
||||||
|
p.style = doc.styles["Title"]
|
||||||
|
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
p.paragraph_format.space_after = Pt(38)
|
||||||
|
r = p.add_run("区域检测 目标检测与告警场景")
|
||||||
|
set_run_font(r, size=15, color="000000")
|
||||||
|
|
||||||
|
add_table(
|
||||||
|
doc,
|
||||||
|
["评估对象", "评估基准", "报告日期"],
|
||||||
|
[["视频监控平台版本 1.003", "五十路网络摄像头", "二零二六年九月四日"]],
|
||||||
|
widths=[5.4, 5.4, 5.4],
|
||||||
|
font_size=10,
|
||||||
|
header_fill=BLUE,
|
||||||
|
)
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.paragraph_format.space_before = Pt(26)
|
||||||
|
p.paragraph_format.space_after = Pt(10)
|
||||||
|
r = p.add_run("核心结论")
|
||||||
|
set_run_font(r, size=13, bold=True, color="000000")
|
||||||
|
add_para(
|
||||||
|
doc,
|
||||||
|
"当前系统可以完成多路接入和区域目标告警,但不宜在不改造的情况下直接承诺五十路持续目标检测。"
|
||||||
|
"主要限制不是摄像头接入带宽,而是共享推理链路仍由单个同步转发线程串行处理,且每路摄像头独立使用 OpenCV 进行软件解码。"
|
||||||
|
"若按启用布控的默认值每路每秒分析一帧,五十路需要至少五十次推理每秒;当前开发机参考测试仅达到二十点四次每秒,且该结果尚未计入 RTSP 解码和进程间传输开销。",
|
||||||
|
)
|
||||||
|
add_para(
|
||||||
|
doc,
|
||||||
|
"建议将部署分为两个门槛。第一门槛先完成推理并发调度 硬件解码 子码流和异步告警落库改造;第二门槛在目标服务器上通过五十路七十二小时压力与故障恢复验收。"
|
||||||
|
"生产硬件建议从二十四至三十二个物理核心 一百二十八 GB ECC 内存 双 NVIDIA L4 二十四 GB 或经实测等效的单张高端专业 GPU 双十 GbE 和独立 NVMe 系统盘起步。"
|
||||||
|
"录像容量必须按现场码率另行配置,五十路四 Mbps 保存七天约需十八点一 TB 可用空间,已含百分之二十余量。",
|
||||||
|
)
|
||||||
|
|
||||||
|
doc.add_page_break()
|
||||||
|
|
||||||
|
add_heading(doc, "目录", 1)
|
||||||
|
toc_items = [
|
||||||
|
"一 评估结论和部署判定",
|
||||||
|
"二 评估范围和容量假设",
|
||||||
|
"三 现有系统架构和代码证据",
|
||||||
|
"四 五十路负载容量模型",
|
||||||
|
"五 操作系统和软件环境要求",
|
||||||
|
"六 硬件配置建议",
|
||||||
|
"七 主要性能风险和解决方案",
|
||||||
|
"八 推荐部署架构",
|
||||||
|
"九 现场测试和验收方案",
|
||||||
|
"十 实施顺序和采购门槛",
|
||||||
|
"十一 限制条件和待确认问题",
|
||||||
|
"附录 计算方法 测试记录和参考资料",
|
||||||
|
]
|
||||||
|
for item in toc_items:
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.paragraph_format.space_after = Pt(4)
|
||||||
|
r = p.add_run(item)
|
||||||
|
set_run_font(r, size=10.5, color=INK)
|
||||||
|
|
||||||
|
add_heading(doc, "一 评估结论和部署判定", 1)
|
||||||
|
add_heading(doc, "现状判定", 2)
|
||||||
|
add_para(
|
||||||
|
doc,
|
||||||
|
"现状结论 平台具备五十路视频接入的功能基础,但当前代码路径尚不具备可证明的五十路持续目标检测生产容量。"
|
||||||
|
"ZLMediaKit 只做代理和协议转换时,五十路二百至三百 Mbps 的典型入口流量不会成为首要瓶颈;真正的瓶颈位于取帧解码 共享推理调度 以及告警写入。",
|
||||||
|
bold_lead="现状结论 ",
|
||||||
|
)
|
||||||
|
add_para(
|
||||||
|
doc,
|
||||||
|
"当前区域入侵 越线 密度 滞留和方向规则会设置持续检测标志,使目标检测不再受运动门控保护。"
|
||||||
|
"所以一旦五十路都启用这些规则,系统必须稳定承担所有路的设定分析帧率,不能用现场静止画面比例来乐观折算。",
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "建议判定", 2)
|
||||||
|
add_table(
|
||||||
|
doc,
|
||||||
|
["场景", "当前版本直接部署", "完成关键改造后", "判定"],
|
||||||
|
[
|
||||||
|
["五十路接入和网页预览", "可进行现场验证", "可", "低风险"],
|
||||||
|
["五十路区域目标检测 每路一帧每秒", "不建议承诺", "可作为生产目标", "需改造和压测"],
|
||||||
|
["十路五帧加四十路一帧每秒", "不可证明", "推荐的分级分析目标", "需批处理和硬件解码"],
|
||||||
|
["五十路各五帧每秒", "不可行", "需要多 GPU 和更小模型", "高成本专项方案"],
|
||||||
|
["五十路全天录像七天", "功能可用但未做规模验收", "可", "必须独立存储和故障测试"],
|
||||||
|
],
|
||||||
|
widths=[4.0, 4.2, 4.4, 3.6],
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "采购前必须满足的三个条件", 2)
|
||||||
|
add_numbered(doc, "先修复单线程同步推理转发,使多个 GPU worker 能真正并发,并加入有界最新帧队列和公平调度。")
|
||||||
|
add_numbered(doc, "在实际目标模型 实际摄像头子码流和目标服务器上测得持续吞吐,生产目标至少保留百分之四十容量余量。")
|
||||||
|
add_numbered(doc, "完成五十路七十二小时稳定性 压力 告警风暴 断流重连和磁盘写满保护测试后再签署容量结论。")
|
||||||
|
|
||||||
|
add_heading(doc, "二 评估范围和容量假设", 1)
|
||||||
|
add_heading(doc, "业务范围", 2)
|
||||||
|
add_para(
|
||||||
|
doc,
|
||||||
|
"本报告覆盖五十路 RTSP 或 GB28181 摄像头接入 ZLMediaKit 转发 OpenCV 取帧 YOLO 目标检测 多边形区域和越线等后处理 告警截图和数据库记录,以及可选的全天录像。"
|
||||||
|
"不包括大规模人脸库检索 跨摄像头 ReID 的最终生产容量 云端大模型费用和带宽,或外部告警平台的接口容量。",
|
||||||
|
)
|
||||||
|
add_heading(doc, "基准假设", 2)
|
||||||
|
add_table(
|
||||||
|
doc,
|
||||||
|
["参数", "基准值", "敏感范围", "说明"],
|
||||||
|
[
|
||||||
|
["摄像头数量", "50 路", "固定", "全部可同时在线"],
|
||||||
|
["主码流", "1080p H264 或 H265", "2 至 8 Mbps", "用于预览和录像"],
|
||||||
|
["分析子码流", "640p 至 960p", "10 至 15 fps", "建议单独启用 分析输入仍缩放至 640"],
|
||||||
|
["目标检测模型", "一个 YOLO26x 640", "YOLO26s m x", "当前数据库默认模型文件约 118.7 MB"],
|
||||||
|
["区域规则分析频率", "每路 1 fps", "0.25 至 5 fps", "Zone 默认一秒一帧"],
|
||||||
|
["告警重复间隔", "30 秒", "按规则调整", "持续入侵时按目标和规则重报"],
|
||||||
|
["录像保存", "7 天", "0 至 30 天", "默认配置已启用录像服务 每路还需 record_enable"],
|
||||||
|
["可用性目标", "单机生产", "建议双机热备或冷备", "当前平台仍是单节点架构"],
|
||||||
|
],
|
||||||
|
widths=[3.4, 4.1, 3.8, 5.9],
|
||||||
|
)
|
||||||
|
add_para(
|
||||||
|
doc,
|
||||||
|
"公式 总推理需求等于摄像头数乘每路分析帧率乘每帧模型数乘检测占空比。"
|
||||||
|
"对于区域 越线 密度 滞留和方向规则,当前实现会强制持续检测,检测占空比按一计算。"
|
||||||
|
"同一路绑定两个不同小模型时,总推理次数近似翻倍。",
|
||||||
|
bold_lead="公式 ",
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "三 现有系统架构和代码证据", 1)
|
||||||
|
add_heading(doc, "视频和分析处理链路", 2)
|
||||||
|
add_table(
|
||||||
|
doc,
|
||||||
|
["处理阶段", "当前实现", "扩展到五十路的含义"],
|
||||||
|
[
|
||||||
|
["视频接入", "ZLMediaKit 代理 RTSP RTMP GB28181", "直通转发不转码时 CPU 压力相对可控"],
|
||||||
|
["摄像头执行单元", "每路一个独立分析子进程和一个解码线程", "约五十个 Python 子进程及大量队列和句柄"],
|
||||||
|
["取帧", "OpenCV VideoCapture 通过 FFmpeg 抓帧", "未显式启用 NVDEC 或 QSV 五十路解码主要压在 CPU"],
|
||||||
|
["帧队列", "每路 deque 最大长度二 取最新帧并丢弃旧帧", "能限制延迟和内存 但丢帧数量会持续增长"],
|
||||||
|
["检测触发", "运动门控或规则强制持续检测", "常用区域规则使运动门控失去降载作用"],
|
||||||
|
["推理 IPC", "子进程 JPEG 质量九十五编码后进入共享队列", "增加 CPU 编码 内存拷贝和延迟"],
|
||||||
|
["共享推理", "主进程单转发线程同步等待推理池返回", "同一时刻仅一个请求在途 多 worker 无法充分并行"],
|
||||||
|
["告警", "子进程同步保存 JPEG 主进程单事件桥写 SQLite", "告警风暴下磁盘和数据库会形成串行背压"],
|
||||||
|
["录像", "每路一个 FFmpeg 进程 码流复制后十分钟分段", "五十进程可接受但需要独立磁盘和句柄监控"],
|
||||||
|
],
|
||||||
|
widths=[3.0, 6.2, 7.0],
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "对容量影响最大的代码事实", 2)
|
||||||
|
add_bullet(doc, "共享推理入口队列最大一百二十八 推理池请求和响应队列各六十四 每路响应队列最大一百二十八。")
|
||||||
|
add_bullet(doc, "主进程 inference forwarder 逐个调用 detect jpeg 并同步等待最多三十秒 因而当前吞吐上限由单请求完整耗时决定。")
|
||||||
|
add_bullet(doc, "配置虽然允许一至三十二个 inference worker 但当前串行转发无法持续提交多个并行任务。")
|
||||||
|
add_bullet(doc, "布控分析频率取同一路所有启用区域中的最高值 不会对不同区域分别调度。")
|
||||||
|
add_bullet(doc, "每个分析子进程先把原帧 JPEG 编码 推理 worker 再解码 多路高频时会产生明显 CPU 和内存带宽开销。")
|
||||||
|
add_bullet(doc, "SQLite 使用 DELETE journal 和单线程告警写入 已关闭每帧轨迹写库 这是正确优化 但仍不适合高并发告警和多进程查询混合负载。")
|
||||||
|
add_bullet(doc, "告警截图每路每分钟最多六十张 但未看到报警截图按天数或容量自动清理策略 极端上限达到三千张每分钟。")
|
||||||
|
add_bullet(doc, "服务重启后需要人工重新启动分析 不满足无人值守现场的自动恢复要求。")
|
||||||
|
|
||||||
|
add_heading(doc, "已有的正向设计", 2)
|
||||||
|
add_para(
|
||||||
|
doc,
|
||||||
|
"现有实现并非完全按帧堆积。每路帧队列只保留最新两帧,分析循环取最新帧后清空中间帧;模型在共享 worker 内按算法配置缓存;每帧轨迹快照写库已经停用;录像采用码流复制而不是重新编码。"
|
||||||
|
"这些设计有利于控制延迟 显存重复占用和数据库写入,应该在后续重构中保留。",
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "四 五十路负载容量模型", 1)
|
||||||
|
add_heading(doc, "推理吞吐需求", 2)
|
||||||
|
add_caption(doc, "图一 五十路在不同分析策略下的单模型吞吐需求")
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
r = p.add_run()
|
||||||
|
r.add_picture(str(CHART), width=Cm(16.2))
|
||||||
|
# Provide a meaningful description for screen readers and document QA.
|
||||||
|
inline = r._element.xpath('.//wp:inline')
|
||||||
|
if inline:
|
||||||
|
doc_pr = inline[0].find(qn('wp:docPr'))
|
||||||
|
if doc_pr is not None:
|
||||||
|
doc_pr.set('descr', '柱状图:五十路在零点二五、一、混合九十、二和五帧每秒策略下的单模型推理需求,并标出当前开发机二十点四次每秒参考线。')
|
||||||
|
doc_pr.set('title', '五十路单模型推理吞吐需求')
|
||||||
|
p.paragraph_format.space_after = Pt(6)
|
||||||
|
add_para(
|
||||||
|
doc,
|
||||||
|
"图中二十点四次每秒是本项目开发机上的参考测试,不是目标服务器承诺值。"
|
||||||
|
"测试使用 RTX 5060 Laptop GPU 八 GB YOLO26x 640 PyTorch FP16,三次预热后二十次推理平均四十九点一四 ms,P95 为五十三点一五 ms。"
|
||||||
|
"测试只使用内存中的零图像,没有包含 RTSP 解码 JPEG 进程间传输 业务规则 告警截图和多路竞争,因此现场完整链路只会更慢。",
|
||||||
|
)
|
||||||
|
add_table(
|
||||||
|
doc,
|
||||||
|
["分析策略", "总推理需求", "建议验收吞吐", "适用规则"],
|
||||||
|
[
|
||||||
|
["五十路各 0.25 fps", "12.5 次每秒", "至少 20 次每秒", "低变化区域巡检"],
|
||||||
|
["五十路各 1 fps", "50 次每秒", "至少 75 次每秒", "区域入侵和滞留基础方案"],
|
||||||
|
["四十路 1 fps 加十路 5 fps", "90 次每秒", "至少 135 次每秒", "普通区域加关键越线方向"],
|
||||||
|
["五十路各 2 fps", "100 次每秒", "至少 150 次每秒", "更快目标或更短告警延迟"],
|
||||||
|
["五十路各 5 fps", "250 次每秒", "至少 375 次每秒", "全路高频跟踪 需多 GPU"],
|
||||||
|
],
|
||||||
|
widths=[5.2, 3.2, 3.6, 4.2],
|
||||||
|
)
|
||||||
|
add_para(
|
||||||
|
doc,
|
||||||
|
"验收吞吐按目标需求除以零点六七计算,约保留百分之五十峰值余量。"
|
||||||
|
"如果同一路要顺序运行两个模型,上表总需求和验收吞吐都应乘二。"
|
||||||
|
"在硬件采购时应以端到端吞吐和 P95 延迟为约束,不使用 CUDA 核心数或理论 TOPS 直接换算路数。",
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "网络容量", 2)
|
||||||
|
add_table(
|
||||||
|
doc,
|
||||||
|
["单路平均码率", "五十路入口流量", "含百分之三十余量", "七天录像原始容量", "含百分之二十余量"],
|
||||||
|
[
|
||||||
|
["2 Mbps", "100 Mbps", "130 Mbps", "7.56 TB", "9.07 TB"],
|
||||||
|
["4 Mbps", "200 Mbps", "260 Mbps", "15.12 TB", "18.14 TB"],
|
||||||
|
["6 Mbps", "300 Mbps", "390 Mbps", "22.68 TB", "27.22 TB"],
|
||||||
|
["8 Mbps", "400 Mbps", "520 Mbps", "30.24 TB", "36.29 TB"],
|
||||||
|
],
|
||||||
|
widths=[3.2, 3.2, 3.4, 3.6, 3.8],
|
||||||
|
)
|
||||||
|
add_para(
|
||||||
|
doc,
|
||||||
|
"一 GbE 从纯带宽看可承载五十路典型码流,但管理访问 录像写入回放和故障重传会占用余量。"
|
||||||
|
"生产建议双十 GbE 分别连接摄像头网和管理存储网 即使现场交换机暂时只有千兆也为后续扩展和链路隔离保留空间。",
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "当前版本容量边界", 2)
|
||||||
|
add_para(
|
||||||
|
doc,
|
||||||
|
"在不改造单线程同步推理转发和软件解码的前提下,当前开发机实测吞吐不足以覆盖五十路每路一帧每秒的基础需求。"
|
||||||
|
"因此采购与项目承诺应以改造后的目标服务器实测结果为准,并至少保留百分之四十持续吞吐余量;本节容量表只用于网络和存储初算,不构成整机路数承诺。",
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "内存和进程规模", 2)
|
||||||
|
add_para(
|
||||||
|
doc,
|
||||||
|
"一帧 1080p BGR 图像约六 MB。每路保留帧队列 最近帧和处理副本时,仅像素数据即可达到约十八至三十 MB;再叠加 Python OpenCV 背景模型 JPEG 缓冲 队列和解释器,五十个分析进程通常需要数十 GB 内存。"
|
||||||
|
"因此六十四 GB 只能作为功能验证下限,生产建议一百二十八 GB ECC,并将常驻内存百分之八十设为告警阈值。",
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "五 操作系统和软件环境要求", 1)
|
||||||
|
add_heading(doc, "不改代码的近期部署路径", 2)
|
||||||
|
add_para(
|
||||||
|
doc,
|
||||||
|
"若以当前安装包和现有运行方式为准,建议使用 Windows 11 Pro 或 Enterprise 64 位。"
|
||||||
|
"项目部署说明明确覆盖 Windows 10 和 Windows 11 x64,GPU 依赖锁定为 PyTorch 2.8 CUDA 12.9。"
|
||||||
|
"现场必须固定经过验证的 NVIDIA 驱动版本 关闭自动睡眠和休眠 采用专用服务账号,并将 ProgramData 中的数据库 配置 密钥 模型和录像纳入备份。"
|
||||||
|
"不建议未经验证直接使用 Windows Server,因为当前文档未声明其安装和托盘服务路径已通过验证。",
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "完成服务化后的生产推荐路径", 2)
|
||||||
|
add_para(
|
||||||
|
doc,
|
||||||
|
"完成容器化或 systemd 服务化以及 ZLMediaKit 重新编译后,建议使用 Ubuntu Server 24.04 LTS x86 64。"
|
||||||
|
"Ubuntu 24.04 的标准安全维护到二零二九年,适合长期现场运行。"
|
||||||
|
"仓库当前 Linux 二进制依赖 OpenSSL 1.1,并在说明中给出了安装 Ubuntu 20.04 libssl1.1 包的兼容方法;该方法不宜作为新的二零二六年生产基线。"
|
||||||
|
"应在目标 LTS 上重新构建 ZLMediaKit 并验证 FFmpeg OpenCV NVIDIA 驱动 CUDA TensorRT 和 Python 依赖。",
|
||||||
|
)
|
||||||
|
add_table(
|
||||||
|
doc,
|
||||||
|
["层次", "要求", "现场控制点"],
|
||||||
|
[
|
||||||
|
["操作系统", "Windows 11 x64 现状路径 或 Ubuntu Server 24.04 LTS 改造路径", "统一补丁窗口 禁止日常办公软件共用"],
|
||||||
|
["GPU 驱动", "与 CUDA 12.9 或目标 TensorRT 版本匹配", "锁定版本并保留离线安装包"],
|
||||||
|
["Python", "3.10 以上 当前 Windows 包为 Python 3.12 依赖", "使用项目私有环境 禁止在线漂移升级"],
|
||||||
|
["推理运行时", "PyTorch 仅作兼容基线 生产优先 TensorRT FP16 或 INT8", "模型导出后做精度回归"],
|
||||||
|
["数据库", "验证期可 SQLite 生产建议 PostgreSQL", "独立备份 WAL 和连接池监控"],
|
||||||
|
["反向代理", "HTTPS Nginx 或等效组件", "管理面不暴露公网 IP 白名单"],
|
||||||
|
["时间同步", "NTP 统一时钟", "摄像头 服务器 存储保持一致"],
|
||||||
|
["服务管理", "开机自启 自动恢复 健康检查", "分析服务不再依赖人工点击启动"],
|
||||||
|
],
|
||||||
|
widths=[3.1, 7.0, 6.1],
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "六 硬件配置建议", 1)
|
||||||
|
add_heading(doc, "配置分级", 2)
|
||||||
|
add_table(
|
||||||
|
doc,
|
||||||
|
["部件", "功能验证配置", "五十路生产推荐配置", "高频或双模型配置"],
|
||||||
|
[
|
||||||
|
["适用负载", "五十路接入 总分析不超过 20 次每秒 不录或少量录像", "五十路 混合 90 次每秒目标 全量告警", "五十路 250 次每秒或双模型"],
|
||||||
|
["CPU", "16 物理核心 32 线程 高频 x86 64", "24 至 32 物理核心 48 至 64 线程 服务器级", "32 至 48 物理核心 双路或高端单路"],
|
||||||
|
["内存", "64 GB", "128 GB ECC", "128 至 256 GB ECC"],
|
||||||
|
["GPU", "16 GB 以上 CUDA GPU 以实测为准", "双 NVIDIA L4 24 GB 或单张 48 至 96 GB 专业 GPU 经实测达标", "双高端 48 GB 以上或四张 L4 按通道分片"],
|
||||||
|
["系统盘", "1 TB NVMe", "2 块 1.92 TB 企业 NVMe RAID1", "2 块 3.84 TB 企业 NVMe RAID1"],
|
||||||
|
["告警和数据库盘", "与系统盘共用 仅验证", "独立 1.92 至 3.84 TB 企业 NVMe", "独立 NVMe RAID1"],
|
||||||
|
["录像盘", "按需", "六块 8 TB 企业盘 RAID6 名义可用 32 TB", "八块 8 TB 以上 RAID6 或独立存储阵列"],
|
||||||
|
["网络", "双 1 GbE", "双 10 GbE 摄像头网和管理存储网隔离", "双 10 或 25 GbE"],
|
||||||
|
["电源散热", "按 GPU 额定功耗留余量", "冗余电源 机架风道 UPS", "按多 GPU 峰值功耗设计 建议 3 kVA 以上 UPS"],
|
||||||
|
],
|
||||||
|
widths=[2.5, 4.5, 5.1, 4.1],
|
||||||
|
font_size=8.3,
|
||||||
|
)
|
||||||
|
add_para(
|
||||||
|
doc,
|
||||||
|
"GPU 选择说明 NVIDIA L4 提供二十四 GB 显存 四个 NVDEC 和七十二 W TDP,适合机架式边缘视频推理。"
|
||||||
|
"RTX PRO 6000 Blackwell 提供九十六 GB ECC 显存,适合单卡需要较大模型余量的服务器或工作站,但功耗为四百至六百 W。"
|
||||||
|
"两类设备都不能仅凭规格表确认本平台路数,必须用目标模型和完整链路压测。",
|
||||||
|
bold_lead="GPU 选择说明 ",
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "推荐的五十路采购基线", 2)
|
||||||
|
add_para(
|
||||||
|
doc,
|
||||||
|
"在完成软件并发改造并采用分析子码流的前提下,建议采购基线为单路二十四至三十二物理核心 CPU 一百二十八 GB ECC 内存 双 L4 二十四 GB 双一 TB 企业 NVMe RAID1 另加独立告警数据库 NVMe 双十 GbE 和六块八 TB RAID6 录像盘。"
|
||||||
|
"双 GPU 不仅用于吞吐,还可将通道固定分片为二十五加二十五,单卡故障时保留降级分析能力。"
|
||||||
|
"若机柜不支持双卡或被动散热,则选择单张高端专业 GPU,但仍建议保留一台可接管的备用服务器。",
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "七 主要性能风险和解决方案", 1)
|
||||||
|
add_table(
|
||||||
|
doc,
|
||||||
|
["优先级", "风险", "现场表现", "根因", "解决方案"],
|
||||||
|
[
|
||||||
|
["P0", "共享推理串行", "分析 FPS 明显低于目标 延迟和超时持续上升", "单转发线程同步等待每个请求", "改为异步多请求调度 按 GPU 分片 批量 1 4 8 动态组批"],
|
||||||
|
["P0", "CPU 解码饱和", "CPU 高 GPU 低 断流和画面卡顿", "五十路 OpenCV FFmpeg 软件解码", "使用子码流 NVDEC QSV 或 GStreamer 硬件解码 解码与推理解耦"],
|
||||||
|
["P0", "分析服务不能无人恢复", "重启后无告警 需人工点击", "分析启动状态未持久化自动恢复", "服务管理器开机恢复已启用通道 加 watchdog 和退避重连"],
|
||||||
|
["P1", "无批处理", "GPU 利用率波动 单帧开销高", "每帧单独 JPEG 和单张推理", "TensorRT FP16 或 INT8 动态 batch 同尺寸输入 限制组批等待"],
|
||||||
|
["P1", "JPEG IPC 成本", "多核 CPU 和内存带宽上升", "每路质量九十五编码 worker 再解码", "共享内存环形缓冲 传 NV12 或预处理张量 避免重复压缩"],
|
||||||
|
["P1", "SQLite 写锁", "报警列表卡顿 database locked", "DELETE journal 单写者和同步 ORM", "异步告警队列 PostgreSQL 批量写 分离查询和归档"],
|
||||||
|
["P1", "截图磁盘无界增长", "磁盘写满导致服务异常", "每路每分钟最多六十张但没有全局保留策略", "按天数和容量双阈值清理 磁盘水位保护 缩略图与原图分层"],
|
||||||
|
["P1", "大模型同步阻塞", "单路分析停顿数秒至三十秒", "LLM 调用在相机处理循环内同步执行", "异步复核队列 超时熔断 并发上限 不阻塞小模型告警"],
|
||||||
|
["P1", "多模型顺序执行", "同一路绑定模型后吞吐近似倍增", "pipeline 对 detector 逐个 detect", "合并类别 模型蒸馏 按 GPU 分组 必要时并行"],
|
||||||
|
["P2", "录像进程和磁盘扫描", "句柄多 每小时扫描阻塞机械盘", "每路 FFmpeg 进程和全目录 os walk", "录像独立服务 增量索引 分层目录 低峰清理"],
|
||||||
|
["P2", "监控指标不足", "故障只能看到平均 FPS", "缺少队列深度 P95 延迟和单路公平性", "增加 Prometheus 指标和容量告警 保存每路时序"],
|
||||||
|
],
|
||||||
|
widths=[1.3, 2.7, 3.9, 4.0, 4.3],
|
||||||
|
font_size=7.7,
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "推理并发改造要点", 2)
|
||||||
|
add_manual_numbered(doc, 1, "把当前单个 inference forwarder 改为非阻塞请求路由,使每个 GPU worker 至少始终有一个请求在途。")
|
||||||
|
add_manual_numbered(doc, 2, "按 stream id 做一致性分片,确保同一路结果顺序稳定;每路只保留最新待分析帧,超过时直接覆盖而不是排队。")
|
||||||
|
add_manual_numbered(doc, 3, "对相同模型和输入尺寸使用一到八张的小批次,组批等待建议从五到十五 ms 起测,防止吞吐提升换来不可接受的告警延迟。")
|
||||||
|
add_manual_numbered(doc, 4, "导出 TensorRT FP16 后做目标类别召回率和框偏移回归;INT8 只有在现场校准集验证通过后启用。")
|
||||||
|
add_manual_numbered(doc, 5, "将每张 GPU 的模型实例数固定,避免每个 worker 重复加载全部模型造成显存浪费。")
|
||||||
|
|
||||||
|
add_heading(doc, "分析频率分级", 2)
|
||||||
|
add_table(
|
||||||
|
doc,
|
||||||
|
["规则类型", "建议分析频率", "原因"],
|
||||||
|
[
|
||||||
|
["区域入侵和滞留", "0.5 至 1 fps", "目标停留时间长 重点保证召回和稳定"],
|
||||||
|
["密度", "1 至 2 fps", "人群变化通常慢于逐帧速率"],
|
||||||
|
["越线和方向", "3 至 5 fps", "轨迹跨线需要相邻观测 低帧率容易直接跳过线段"],
|
||||||
|
["关键高速通道", "5 至 10 fps", "车辆或快速人员 需要专项模型和多 GPU"],
|
||||||
|
["仅运动告警", "按运动门控", "可显著降载 但不能替代目标区域规则"],
|
||||||
|
],
|
||||||
|
widths=[4.4, 4.0, 7.8],
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "八 推荐部署架构", 1)
|
||||||
|
add_heading(doc, "单机生产架构", 2)
|
||||||
|
add_table(
|
||||||
|
doc,
|
||||||
|
["输入层", "媒体层", "分析层", "业务层", "数据层"],
|
||||||
|
[[
|
||||||
|
"摄像头主码流用于预览和录像\n分析子码流用于算法",
|
||||||
|
"ZLMediaKit 直通转发\n摄像头网独立 VLAN",
|
||||||
|
"硬件解码池\n最新帧调度\n双 GPU 批推理",
|
||||||
|
"跟踪和区域规则\n异步告警和 LLM 复核",
|
||||||
|
"PostgreSQL\n告警 NVMe\n录像 RAID6",
|
||||||
|
]],
|
||||||
|
widths=[3.3, 3.3, 3.4, 3.4, 3.2],
|
||||||
|
font_size=8.5,
|
||||||
|
)
|
||||||
|
add_para(
|
||||||
|
doc,
|
||||||
|
"主码流和分析子码流必须解耦。录像继续复制主码流,不因算法降分辨率而降低取证质量;分析层只拉低分辨率子码流,减少软件解码和 JPEG 传输成本。"
|
||||||
|
"每张 GPU 固定处理二十五路,并允许故障时把非关键通道降到零点二五 fps,将关键通道转移到剩余 GPU。",
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "可靠性设计", 2)
|
||||||
|
add_bullet(doc, "服务器和摄像头网络使用静态地址 VLAN 和访问控制 管理面通过 HTTPS 反向代理访问。")
|
||||||
|
add_bullet(doc, "配置 模型 数据库 密钥和授权作为一组备份 不单独复制 SQLite 数据库。")
|
||||||
|
add_bullet(doc, "录像盘与系统告警盘分离 录像盘写满不得影响数据库和操作系统。")
|
||||||
|
add_bullet(doc, "UPS 至少支撑有序关机 监控机柜温度 风扇 电源 GPU ECC 或 Xid 错误。")
|
||||||
|
add_bullet(doc, "生产建议准备第二台可接管服务器 定期验证恢复时间和摄像头重新注册流程。")
|
||||||
|
|
||||||
|
add_heading(doc, "九 现场测试和验收方案", 1)
|
||||||
|
add_heading(doc, "测试阶段", 2)
|
||||||
|
add_table(
|
||||||
|
doc,
|
||||||
|
["阶段", "负载", "持续时间", "目的"],
|
||||||
|
[
|
||||||
|
["A 媒体基线", "五十路在线 不启动分析和录像", "4 小时", "确认网络 ZLM 和解码前基线"],
|
||||||
|
["B 解码基线", "五十路分析子码流 仅运动检测", "8 小时", "测 CPU 解码和断流率"],
|
||||||
|
["C 标准分析", "五十路各 1 fps 单模型", "24 小时", "验证至少 75 次每秒的系统余量"],
|
||||||
|
["D 分级分析", "四十路 1 fps 加十路 5 fps", "24 小时", "验证推荐运行模式"],
|
||||||
|
["E 告警风暴", "多目标反复进出 每路模拟告警", "2 小时", "验证截图 数据库和队列"],
|
||||||
|
["F 录像叠加", "五十路全天录像加分级分析", "24 小时", "验证磁盘和 FFmpeg 进程"],
|
||||||
|
["G 故障恢复", "断开十路 重启 ZLM 重启服务 单 GPU 故障", "按场景执行", "验证自动恢复和降级"],
|
||||||
|
["H 稳定性", "实际生产配置全开", "72 小时", "最终验收"],
|
||||||
|
],
|
||||||
|
widths=[3.3, 6.1, 2.7, 4.1],
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "建议验收指标", 2)
|
||||||
|
add_table(
|
||||||
|
doc,
|
||||||
|
["指标", "通过标准", "采集方式"],
|
||||||
|
[
|
||||||
|
["在线路数", "五十路持续在线 短断流可自动恢复", "ZLM 媒体列表和应用状态"],
|
||||||
|
["每路实际分析 FPS", "P95 通道达到设定值百分之九十五以上", "逐通道一分钟窗口"],
|
||||||
|
["端到端分析延迟", "1 fps 场景 P95 小于 1.5 秒 关键通道 P95 小于 0.8 秒", "采集时间到结果时间"],
|
||||||
|
["推理超时", "七十二小时为零", "inference timeout count"],
|
||||||
|
["GPU 利用率", "持续均值低于百分之八十 显存低于百分之八十五", "nvidia smi 或 DCGM"],
|
||||||
|
["CPU 和内存", "CPU 持续低于百分之七十五 内存低于百分之八十", "系统监控"],
|
||||||
|
["告警完整性", "测试事件无丢失 重复率符合规则", "事件脚本与数据库对账"],
|
||||||
|
["数据库", "无 locked 错误 查询 P95 小于一秒", "日志和接口压测"],
|
||||||
|
["录像", "随机抽取每路分段均可播放 时间连续", "播放器和 ffprobe"],
|
||||||
|
["恢复", "单路断流三十秒内恢复 服务重启后自动恢复分析", "故障注入记录"],
|
||||||
|
["磁盘保护", "达到告警水位后自动清理或停止非关键写入 系统不崩溃", "容量阈值模拟"],
|
||||||
|
],
|
||||||
|
widths=[3.5, 8.0, 4.7],
|
||||||
|
font_size=8.3,
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "必须新增的监控指标", 2)
|
||||||
|
add_bullet(doc, "每路 decode fps analysis fps 目标 fps dropped count stalled seconds reconnect count。")
|
||||||
|
add_bullet(doc, "全局和每 GPU 队列深度 等待时间 推理时间 端到端时间 P50 P95 P99 超时数。")
|
||||||
|
add_bullet(doc, "GPU 利用率 显存 功耗 温度 解码器利用率和错误码。")
|
||||||
|
add_bullet(doc, "告警队列长度 每秒写入数 SQLite 或 PostgreSQL 延迟 数据库错误数 截图写入延迟。")
|
||||||
|
add_bullet(doc, "录像进程数 磁盘吞吐 延迟 可用容量 保留清理耗时和不可播放分段数。")
|
||||||
|
|
||||||
|
add_heading(doc, "十 实施顺序和采购门槛", 1)
|
||||||
|
add_table(
|
||||||
|
doc,
|
||||||
|
["顺序", "工作", "完成标志", "是否阻断采购"],
|
||||||
|
[
|
||||||
|
["1", "建立单路和五十路可重复压测工具", "输出吞吐 P95 延迟 CPU GPU 和精度报告", "是"],
|
||||||
|
["2", "异步推理调度和 GPU 分片", "多个 worker 同时在途且保持结果路由正确", "是"],
|
||||||
|
["3", "分析子码流和硬件解码", "五十路解码 CPU 持续低于百分之六十", "是"],
|
||||||
|
["4", "TensorRT FP16 和小批次", "吞吐达到推荐负载的一点五倍 精度回归通过", "是"],
|
||||||
|
["5", "异步告警 PostgreSQL 和截图保留", "告警风暴无丢失 磁盘有水位保护", "生产阻断"],
|
||||||
|
["6", "自动启动 故障恢复和监控", "重启后无人干预恢复五十路", "生产阻断"],
|
||||||
|
["7", "目标硬件七十二小时验收", "全部验收指标通过", "最终门槛"],
|
||||||
|
],
|
||||||
|
widths=[1.5, 6.2, 6.0, 2.5],
|
||||||
|
)
|
||||||
|
add_para(
|
||||||
|
doc,
|
||||||
|
"采购建议 在第二步和第三步完成前,只能先借用或租用候选 GPU 进行测试,不宜一次性确定最终卡型和数量。"
|
||||||
|
"最终采购技术条款应写成完整链路的最低吞吐 P95 延迟 路数和七十二小时稳定性,而不是只写某个 GPU 型号。",
|
||||||
|
bold_lead="采购建议 ",
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "十一 限制条件和待确认问题", 1)
|
||||||
|
add_heading(doc, "本评估的限制", 2)
|
||||||
|
add_para(
|
||||||
|
doc,
|
||||||
|
"本报告基于当前工作区代码 配置 数据库结构 一次开发机参考测试和公开硬件资料。"
|
||||||
|
"没有取得客户现场摄像头的实际码率 编码参数 网络丢包率 目标密度 规则组合和目标服务器,因此硬件型号属于采购候选基线,不构成未经压测的路数保证。"
|
||||||
|
"本地测试使用零图像,目标数量越多时后处理和告警开销通常更高。",
|
||||||
|
)
|
||||||
|
add_heading(doc, "现场方案冻结前需要回答的问题", 2)
|
||||||
|
questions = [
|
||||||
|
"五十路摄像头的分辨率 帧率 编码 H264 或 H265 平均和峰值码率是多少。",
|
||||||
|
"是否能为每台摄像头配置独立分析子码流 子码流的最小目标像素尺寸是否仍满足检测。",
|
||||||
|
"每路会绑定几个小模型 是否使用 YOLO26x 还是可用现场微调后的 s 或 m 模型。",
|
||||||
|
"哪些通道需要越线或方向五 fps 哪些通道只需区域一 fps。",
|
||||||
|
"是否启用全天录像 保存几天 是否有集中存储或只能使用本机磁盘。",
|
||||||
|
"是否启用云端或本地大模型复核 允许的告警延迟和外网依赖是什么。",
|
||||||
|
"允许单机故障中断多久 是否要求双机冗余和录像连续性。",
|
||||||
|
"报警截图和报警记录的保存年限 合规审计和删除要求是什么。",
|
||||||
|
]
|
||||||
|
for q in questions:
|
||||||
|
add_bullet(doc, q)
|
||||||
|
|
||||||
|
add_heading(doc, "附录 计算方法 测试记录和参考资料", 1)
|
||||||
|
add_heading(doc, "容量计算方法", 2)
|
||||||
|
add_table(
|
||||||
|
doc,
|
||||||
|
["项目", "计算式", "五十路四 Mbps 示例"],
|
||||||
|
[
|
||||||
|
["入口带宽", "路数乘单路码率", "50 乘 4 等于 200 Mbps"],
|
||||||
|
["每日录像", "路数乘码率乘 86400 除 8", "2.16 TB 每日"],
|
||||||
|
["七天录像", "每日录像乘 7", "15.12 TB"],
|
||||||
|
["规划容量", "七天录像乘 1.2", "18.14 TB 可用空间"],
|
||||||
|
["推理需求", "路数乘分析 fps 乘模型数", "50 乘 1 乘 1 等于 50 次每秒"],
|
||||||
|
["验收吞吐", "推理需求除 0.67", "约 75 次每秒"],
|
||||||
|
],
|
||||||
|
widths=[3.8, 6.8, 5.6],
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "开发机参考测试记录", 2)
|
||||||
|
add_table(
|
||||||
|
doc,
|
||||||
|
["项目", "值"],
|
||||||
|
[
|
||||||
|
["GPU", "NVIDIA GeForce RTX 5060 Laptop GPU 8151 MiB"],
|
||||||
|
["驱动", "577.05"],
|
||||||
|
["PyTorch 和 CUDA", "2.8.0+cu129 CUDA 12.9"],
|
||||||
|
["模型", "当前数据库默认 YOLO26x detect 640 CUDA 模型文件约 118.7 MB"],
|
||||||
|
["测试输入", "内存中 1080p 零图像 三次预热 二十次计时"],
|
||||||
|
["平均耗时", "49.14 ms"],
|
||||||
|
["中位耗时", "49.03 ms"],
|
||||||
|
["P95 耗时", "53.15 ms"],
|
||||||
|
["单请求吞吐", "20.4 次每秒"],
|
||||||
|
["解释", "不含 RTSP JPEG IPC 和业务处理 属于吞吐上限参考 不是客户现场保证"],
|
||||||
|
],
|
||||||
|
widths=[4.2, 12.0],
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "代码证据索引", 2)
|
||||||
|
add_table(
|
||||||
|
doc,
|
||||||
|
["证据", "位置"],
|
||||||
|
[
|
||||||
|
["默认目标分析帧率五 共享推理和一个 worker", "config.json"],
|
||||||
|
["每路分析子进程和共享推理路由", "app analysis manager.py"],
|
||||||
|
["单线程同步推理转发", "app analysis manager.py inference forwarder loop"],
|
||||||
|
["推理池队列和 worker 模型缓存", "app analysis inference_pool.py"],
|
||||||
|
["每路 JPEG 质量九十五编码和同步等待", "app analysis remote_detector.py"],
|
||||||
|
["帧队列最大二 取最新帧 规则强制持续检测", "app analysis pipeline.py"],
|
||||||
|
["OpenCV VideoCapture FFmpeg 取帧", "app analysis frames.py"],
|
||||||
|
["SQLite DELETE journal", "app apps.py 和 framework settings.py"],
|
||||||
|
["每路 FFmpeg 码流复制录像和保留扫描", "app recording manager.py"],
|
||||||
|
["系统版本和部署约束", "README.md 和 deploy windows DEPLOYMENT.md"],
|
||||||
|
],
|
||||||
|
widths=[8.0, 8.2],
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "公开参考资料", 2)
|
||||||
|
refs = [
|
||||||
|
("Ultralytics YOLO26 模型说明和性能范围", "https://docs.ultralytics.com/models/yolo26"),
|
||||||
|
("NVIDIA TensorRT 性能优化和动态批处理建议", "https://docs.nvidia.com/deeplearning/tensorrt/latest/performance/optimization.html"),
|
||||||
|
("NVIDIA L4 产品规格", "https://www.nvidia.com/en-gb/data-center/l4/"),
|
||||||
|
("NVIDIA RTX PRO 6000 Blackwell 产品规格", "https://www.nvidia.com/en-us/products/workstations/professional-desktop-gpus/rtx-pro-6000-family/"),
|
||||||
|
("Ubuntu 版本支持周期", "https://ubuntu.com/about/release-cycle"),
|
||||||
|
]
|
||||||
|
for i, (name, url) in enumerate(refs, 1):
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.paragraph_format.space_after = Pt(4)
|
||||||
|
r = p.add_run(f"{i} {name} ")
|
||||||
|
set_run_font(r, size=9.5, color=INK)
|
||||||
|
add_hyperlink(p, url, url)
|
||||||
|
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.paragraph_format.space_before = Pt(16)
|
||||||
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
r = p.add_run("报告结束")
|
||||||
|
set_run_font(r, size=10, bold=True, color=MUTED)
|
||||||
|
|
||||||
|
# Keep the final output deterministic and ensure paragraphs do not orphan after headings.
|
||||||
|
for paragraph in doc.paragraphs:
|
||||||
|
if paragraph.style and paragraph.style.name.startswith("Heading"):
|
||||||
|
paragraph.paragraph_format.keep_with_next = True
|
||||||
|
for run in paragraph.runs:
|
||||||
|
if run.font.name is None:
|
||||||
|
set_run_font(run)
|
||||||
|
|
||||||
|
doc.save(OUT)
|
||||||
|
print(str(OUT))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
build_document()
|
||||||
Loading…
Reference in New Issue
Block a user