diff --git a/.gitignore b/.gitignore index eaf1aa1..da193eb 100644 --- a/.gitignore +++ b/.gitignore @@ -37,7 +37,6 @@ monitor.spec static/storage/* framework/__pycache__/* app/__pycache__/* -app/migrations/* app/migrations/__pycache__/* app/comms/__pycache__/* app/utils/__pycache__/* @@ -49,3 +48,14 @@ app/recording/__pycache__/* # logs / docs / external services docs/* log/* + +# Windows packaging and private issuer material +.build-venv/ +.windows-build/ +vendor-private/ +_apply_windows.py + +# offline report build artifacts & portable render runtime (regenerable / one-off) +report_assets/ +report_render_runtime/ +系统部署性能分析评估报告.docx diff --git a/README.md b/README.md index 9d35317..86b3ea4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +Windows EXE 安装、离线授权与可重复构建见 [部署说明](deploy/windows/README.md)。 + 多路视频接入与智能布控分析平台。支持 GB28181 / RTSP、YOLO 小模型检测、OpenAI 兼容大模型复核、多边形布控与结构化报警。 @@ -59,6 +61,14 @@ pip install -r requirements-linux.txt -i https://pypi.tuna.tsinghua.edu.cn/simpl pip install -r requirements-linux.txt ``` +NVIDIA RTX 显卡可在基础依赖安装完成后启用本地 CUDA 推理: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts/enable-gpu.ps1 +``` + +启用后在“小模型管理”中将 YOLO PyTorch 模型的推理设备设为 `CUDA`。共享推理 worker 默认设为 1,避免同一模型重复占用显存。 + --- ## 快速开始 diff --git a/Ultralytics/settings.json b/Ultralytics/settings.json new file mode 100644 index 0000000..6c39be6 --- /dev/null +++ b/Ultralytics/settings.json @@ -0,0 +1,19 @@ +{ + "settings_version": "0.0.8", + "datasets_dir": "D:\\00_Workspace\\python\\datasets", + "weights_dir": "D:\\00_Workspace\\python\\video_monitor\\weights", + "runs_dir": "D:\\00_Workspace\\python\\video_monitor\\runs", + "uuid": "5a5a041af8f3f74887c6bd745669f28e7ad18f074277d0c9a5abd22add25caf9", + "sync": true, + "api_key": "", + "openai_api_key": "", + "clearml": true, + "comet": true, + "dvc": true, + "mlflow": true, + "raytune": true, + "tensorboard": false, + "wandb": false, + "vscode_msg": true, + "openvino_msg": true +} \ No newline at end of file diff --git a/app/analysis/engines/base.py b/app/analysis/engines/base.py index fda5094..9c24090 100644 --- a/app/analysis/engines/base.py +++ b/app/analysis/engines/base.py @@ -40,7 +40,7 @@ class BaseEngine(object): def __init__(self, model_file=None, labels=None, input_size=(640, 640), conf_threshold=0.4, iou_threshold=0.5, providers=None, algorithm_type="yolo", algorithm_version="", - task_type="detect", device="cpu"): + task_type="detect", device="cpu", target_labels=None): self.model_file = model_file or "" self.labels = labels or [] self.input_size = input_size @@ -51,6 +51,7 @@ class BaseEngine(object): self.algorithm_version = algorithm_version self.task_type = (task_type or "detect").lower() self.device = device or "cpu" + self.target_labels = list(target_labels or []) self._loaded = False @staticmethod diff --git a/app/analysis/engines/yolo_pytorch_engine.py b/app/analysis/engines/yolo_pytorch_engine.py index 504a8c0..95e85f5 100644 --- a/app/analysis/engines/yolo_pytorch_engine.py +++ b/app/analysis/engines/yolo_pytorch_engine.py @@ -70,6 +70,8 @@ class YoloPytorchEngine(BaseEngine): self._model = None self.task_type = (kwargs.get("task_type") or "detect").lower() self.device = kwargs.get("device") or "cpu" + self._target_classes = None + self._effective_device = "cpu" @staticmethod def is_available(): @@ -108,6 +110,12 @@ class YoloPytorchEngine(BaseEngine): self.labels = self._resolve_labels(self.model_file) try: self._model = _UltralyticsYOLO(self.model_file, task=self.task_type) + if self.target_labels: + names = getattr(self._model, "names", {}) or {} + if isinstance(names, list): + names = dict(enumerate(names)) + wanted = set(str(x) for x in self.target_labels) + self._target_classes = [int(cid) for cid, name in names.items() if str(name) in wanted] # 推断 input_size try: cfg = getattr(self._model, "overrides", {}) or {} @@ -122,9 +130,18 @@ class YoloPytorchEngine(BaseEngine): # 预热(小尺寸 dummy),让模型迁移到目标设备 try: dev = _normalize_device(self.device, self.algorithm_type) + self._effective_device = dev + if dev != "cpu" and _TORCH_AVAILABLE: + torch.backends.cudnn.benchmark = True + try: + torch.set_float32_matmul_precision("high") + except Exception: + pass dummy = np.zeros((self.input_size[1], self.input_size[0], 3), dtype=np.uint8) self._model.predict(dummy, imgsz=max(self.input_size), device=dev, conf=self.conf_threshold, iou=self.iou_threshold, + classes=self._target_classes, + quantize=16 if dev != "cpu" else None, verbose=False, save=False) except Exception as e: logger.warning("YoloPytorchEngine 预热失败(忽略): %s", e) @@ -143,10 +160,12 @@ class YoloPytorchEngine(BaseEngine): if not self.ready() or frame_bgr is None or self._model is None: return [] try: - dev = _normalize_device(self.device, self.algorithm_type) + dev = self._effective_device or _normalize_device(self.device, self.algorithm_type) iw, ih = self.input_size results = self._model.predict(frame_bgr, imgsz=max(iw, ih), device=dev, conf=self.conf_threshold, iou=self.iou_threshold, + classes=self._target_classes, + quantize=16 if dev != "cpu" else None, verbose=False, save=False) return self._parse_results(results) except Exception as e: @@ -237,7 +256,7 @@ class YoloPytorchEngine(BaseEngine): d["version"] = self.version() d["ultralytics_version"] = self.ultralytics_version() d["task_type"] = self.task_type - d["device"] = _normalize_device(self.device, self.algorithm_type) + d["device"] = self._effective_device d["cuda_available"] = bool(_TORCH_AVAILABLE and torch.cuda.is_available()) return d diff --git a/app/analysis/event_bridge.py b/app/analysis/event_bridge.py index 662f13c..6a68162 100644 --- a/app/analysis/event_bridge.py +++ b/app/analysis/event_bridge.py @@ -15,6 +15,7 @@ class AnalysisEventBridge(object): self._running = False self._queues = [] self._lock = threading.Lock() + self._latest = {} def register_queue(self, q): with self._lock: @@ -59,6 +60,8 @@ class AnalysisEventBridge(object): self._on_event(payload) elif kind == "touch": self._on_touch(payload) + elif kind == "preview": + self._on_preview(payload) except Exception as e: logger.exception("事件桥处理失败: %s", e) @@ -72,6 +75,22 @@ class AnalysisEventBridge(object): # 已停用:不再写追踪快照 pass + def _on_preview(self, payload): + stream_id = payload.get("stream_id") + if stream_id is None: + return + with self._lock: + self._latest[int(stream_id)] = payload + + def latest_preview(self, stream_id): + with self._lock: + value = self._latest.get(int(stream_id)) + return dict(value) if value else None + + def clear_preview(self, stream_id): + with self._lock: + self._latest.pop(int(stream_id), None) + def get_event_bridge(): global _BRIDGE diff --git a/app/analysis/inference_pool.py b/app/analysis/inference_pool.py index 16b1f6b..4295e03 100644 --- a/app/analysis/inference_pool.py +++ b/app/analysis/inference_pool.py @@ -67,6 +67,7 @@ def _inference_worker_loop(req_queue, resp_queue, worker_id): int(algo.get("input_height", 640)), algo.get("task_type", "detect"), algo.get("device", "cpu"), + tuple(sorted(algo.get("target_labels") or [])), ) eng = engines.get(key) if eng is None: @@ -87,6 +88,7 @@ def _inference_worker_loop(req_queue, resp_queue, worker_id): algorithm_type=algo.get("algorithm_type", "yolo8"), task_type=algo.get("task_type", "detect"), device=algo.get("device", "cpu"), + target_labels=algo.get("target_labels") or [], ) if not eng.load(): resp_queue.put({"req_id": req_id, "ok": False, "error": "engine load failed"}) @@ -190,13 +192,15 @@ class InferenceProcessPool(object): self.stop() self.start() - def detect_jpeg(self, jpeg, algorithm, timeout=30.0): + def detect_jpeg(self, jpeg, algorithm, timeout=30.0, raise_errors=False): """同步推理:直接传已编码的 JPEG bytes,跳过主进程 imencode/imdecode, 消除 _inference_forwarder_loop 中的双重编解码与主进程 GIL 占用。""" self._ensure_workers_alive() if not self._running: self.start() if not jpeg: + if raise_errors: + raise ValueError("no frame") return [] req_id = str(uuid.uuid4()) evt = {"event": threading.Event(), "resp": None} @@ -210,6 +214,8 @@ class InferenceProcessPool(object): with self._lock: self._pending.pop(req_id, None) logger.warning("推理请求入队失败: %s", e) + if raise_errors: + raise RuntimeError("推理请求入队失败: %s" % e) from e return [] if not evt["event"].wait(timeout=timeout): @@ -218,10 +224,14 @@ class InferenceProcessPool(object): self._timeout_count += 1 self._last_timeout_ts = time.time() logger.warning("推理超时 req_id=%s (累计 %d)", req_id, self._timeout_count) + if raise_errors: + raise TimeoutError("模型推理超时(%ss)" % timeout) return [] resp = evt.get("resp") or {} if not resp.get("ok"): logger.warning("推理失败: %s", resp.get("error")) + if raise_errors: + raise RuntimeError(resp.get("error") or "模型推理失败") return [] return resp.get("detections") or [] diff --git a/app/analysis/manager.py b/app/analysis/manager.py index aa03662..67306da 100644 --- a/app/analysis/manager.py +++ b/app/analysis/manager.py @@ -8,6 +8,7 @@ import logging import multiprocessing as mp import threading import time +import uuid from app.analysis.pipeline import CameraPipeline from app.analysis.motion import MotionDetector @@ -28,7 +29,7 @@ def _snapshot_storage_paths(): return alarm_dir, static_dir -def _algorithm_to_spec(a): +def _algorithm_to_spec(a, target_labels=None): labels = a.labels if isinstance(labels, str): try: @@ -48,6 +49,7 @@ def _algorithm_to_spec(a): "algorithm_type": a.algorithm_type, "task_type": getattr(a, "task_type", "detect"), "device": getattr(a, "device", "cpu"), + "target_labels": sorted(set(target_labels or [])), } @@ -110,12 +112,15 @@ class AnalysisManager(object): self._status_manager = self._mp_ctx.Manager() self._status_dict = self._status_manager.dict() self._infer_req_q = self._mp_ctx.Queue(maxsize=128) - self._infer_resp_q = self._mp_ctx.Queue(maxsize=128) + # Never reuse a consumer queue after terminating a camera process: + # multiprocessing.Queue's read lock can remain acquired on Windows. + self._infer_routes = {} + self._infer_routes_lock = threading.Lock() + self._disabled_algos = set() self._infer_forwarder_running = True self._infer_forwarder = threading.Thread( target=self._inference_forwarder_loop, name="infer-forwarder", daemon=True) self._infer_forwarder.start() - self._disabled_algos = set() # 禁用实例化的业务算法 ID 集合(内存,重启丢失) get_event_bridge() self._configure_from_settings() @@ -252,7 +257,6 @@ class AnalysisManager(object): def _inference_forwarder_loop(self): from app.analysis.inference_pool import get_inference_pool import queue as _q - pool = get_inference_pool() while self._infer_forwarder_running: try: msg = self._infer_req_q.get(timeout=0.5) @@ -261,6 +265,10 @@ class AnalysisManager(object): if msg is None: break req_id = msg.get("req_id") + channel = msg.get("response_channel") + with self._infer_routes_lock: + if channel not in self._infer_routes: + continue # The originating pipeline has already stopped. try: jpeg = msg.get("jpeg") algo = msg.get("algorithm") or {} @@ -268,21 +276,35 @@ class AnalysisManager(object): algo_id = algo.get("id", 0) try: if algo_id and int(algo_id) in self._disabled_algos: - self._infer_resp_q.put({"req_id": req_id, "ok": True, "detections": []}) + self._send_inference_response(channel, {"req_id": req_id, "ok": True, "detections": []}) continue except Exception: pass # 直接透传 JPEG bytes 给推理池,避免主进程 imdecode + imencode 双重编解码, # 消除主进程 GIL 占用(解码在 worker 子进程内完成)。 - dets = pool.detect_jpeg(jpeg, algo, timeout=30.0) - self._infer_resp_q.put({"req_id": req_id, "ok": True, "detections": dets}) + pool = get_inference_pool() # Config changes may replace the pool. + dets = pool.detect_jpeg(jpeg, algo, timeout=30.0, raise_errors=True) + self._send_inference_response(channel, {"req_id": req_id, "ok": True, "detections": dets}) except Exception as e: logger.warning("推理转发失败: %s", e) try: - self._infer_resp_q.put({"req_id": req_id, "ok": False, "error": str(e)}) + self._send_inference_response(channel, {"req_id": req_id, "ok": False, "error": str(e)}) except Exception: pass + def _send_inference_response(self, channel, response): + with self._infer_routes_lock: + response_queue = self._infer_routes.get(channel) + if response_queue is not None: + response_queue.put(response, timeout=1.0) + + def _close_inference_channel(self, item): + with self._infer_routes_lock: + response_queue = self._infer_routes.pop(item.get("response_channel"), None) + if response_queue is not None: + response_queue.cancel_join_thread() + response_queue.close() + def _configure_from_settings(self): try: from app.models import AlgorithmModel @@ -342,10 +364,15 @@ class AnalysisManager(object): return 1.0 @staticmethod - def _load_zones(stream_id): + def _load_zones(stream_id, active_only=True, zone_id=None): try: from app.models import ZoneModel - qs = ZoneModel.objects.filter(stream_id=stream_id, state=1).prefetch_related( + qs = ZoneModel.objects.filter(stream_id=stream_id) + if active_only: + qs = qs.filter(state=1) + if zone_id is not None: + qs = qs.filter(id=int(zone_id)) + qs = qs.prefetch_related( 'algorithms', 'algorithms__small_model', 'algorithms__detector_model', 'algorithms__llm') zones = [] for z in qs: @@ -386,6 +413,8 @@ class AnalysisManager(object): "loiter_threshold": z.loiter_threshold, "detect_interval_sec": interval, "detect_frames": frames, + "alarm_repeat_sec": max(0.0, float(getattr(z, "alarm_repeat_sec", 30) or 0)), + "color": z.color or "#169F85", "line_a": line_a, "line_b": line_b, "density_threshold": int(getattr(z, "density_threshold", 0) or 0), @@ -447,16 +476,29 @@ class AnalysisManager(object): logger.warning("config fallback engine err: %s" % str(e)) return None - def _start_process(self, stream, url, zones, algos, detectors_legacy=None): + def _start_process(self, stream, url, zones, algos, detectors_legacy=None, + preview_only=False, analyze_fps_override=None): sid = stream.id + self._status_dict.pop(str(sid), None) event_queue = self._mp_ctx.Queue(maxsize=256) cmd_queue = self._mp_ctx.Queue(maxsize=16) bridge = get_event_bridge() bridge.register_queue(event_queue) - algo_specs = [_algorithm_to_spec(a) for a in algos] - analyze_fps = self._compute_analyze_fps(sid, fallback=self._target_fps) + target_by_model = {} + for zone in zones: + for rule in zone.get("biz_algorithms") or []: + model_id = rule.get("detector_model_id") if int(rule.get("flow_type") or 0) == 4 else rule.get("small_model_id") + if model_id: + target_by_model.setdefault(int(model_id), set()).update(rule.get("target_labels") or []) + algo_specs = [_algorithm_to_spec(a, target_by_model.get(int(a.id), set())) for a in algos] + analyze_fps = (float(analyze_fps_override) if analyze_fps_override is not None + else self._compute_analyze_fps(sid, fallback=self._target_fps)) storage_alarm_dir, static_dir = _snapshot_storage_paths() + response_channel = uuid.uuid4().hex + infer_resp_q = self._mp_ctx.Queue(maxsize=128) + with self._infer_routes_lock: + self._infer_routes[response_channel] = infer_resp_q config = { "stream_id": sid, "stream_code": getattr(stream, "code", str(sid)), @@ -468,15 +510,22 @@ class AnalysisManager(object): "use_shared_inference": self._use_shared_inference(), "storage_alarm_dir": storage_alarm_dir, "static_dir": static_dir, + "response_channel": response_channel, + "preview_only": bool(preview_only), } proc = self._mp_ctx.Process( target=pipeline_process_main, args=(config, event_queue, cmd_queue, self._status_dict, - self._infer_req_q, self._infer_resp_q), + self._infer_req_q, infer_resp_q), name="pipeline-%s" % sid, daemon=True, ) - proc.start() + try: + proc.start() + except Exception: + self._close_inference_channel({"response_channel": response_channel}) + bridge.unregister_queue(event_queue) + raise handle = PipelineProcessHandle(sid, proc, event_queue, cmd_queue, self._status_dict) self._pipelines[sid] = { "handle": handle, @@ -487,17 +536,29 @@ class AnalysisManager(object): "pipeline": None, "thread": None, "algorithm_ids": sorted([a.id for a in algos]), + "preview_only": bool(preview_only), + "response_channel": response_channel, + "analyze_fps": analyze_fps, } return True, "started (process)" - def _start_thread(self, stream, url, zones, algos): + def _start_thread(self, stream, url, zones, algos, preview_only=False, + analyze_fps_override=None): sid = stream.id detectors = [] algo_names = [] for a in algos: - eng = self._worker_pool.get_detector(a) + labels = set() + for z in zones: + for rule in z.get("biz_algorithms") or []: + model_id = (rule.get("detector_model_id") if int(rule.get("flow_type") or 0) == 4 + else rule.get("small_model_id")) + if int(model_id or 0) == int(a.id): + labels.update(rule.get("target_labels") or []) + eng = self._worker_pool.get_detector(_algorithm_to_spec(a, labels)) if eng: - detectors.append({"algorithm_id": a.id, "algorithm_name": a.name, "engine": eng}) + detectors.append({"algorithm_id": a.id, "algorithm_name": a.name, + "engine": eng, "target_labels": sorted(labels)}) algo_names.append(a.name) if not algos: eng = self._fallback_engine_from_config() @@ -506,7 +567,8 @@ class AnalysisManager(object): algo_names.append("config-fallback") motion = MotionDetector() - analyze_fps = self._compute_analyze_fps(sid, fallback=self._target_fps) + analyze_fps = (float(analyze_fps_override) if analyze_fps_override is not None + else self._compute_analyze_fps(sid, fallback=self._target_fps)) storage_alarm_dir, static_dir = _snapshot_storage_paths() pipeline = CameraPipeline( stream_id=sid, @@ -518,6 +580,8 @@ class AnalysisManager(object): analyze_fps=analyze_fps, on_event=self._on_event, on_track_snapshot=self._on_track_snapshot, + on_preview=lambda payload: get_event_bridge()._on_preview(payload), + alarm_enabled=not bool(preview_only), zone_polygons=zones, storage_alarm_dir=storage_alarm_dir, static_dir=static_dir, @@ -530,11 +594,15 @@ class AnalysisManager(object): "running": True, "mode": "thread", "algorithm_ids": sorted([a.id for a in algos]), + "preview_only": bool(preview_only), + "analyze_fps": analyze_fps, } t.start() return True, "started (thread)" def start(self, stream): + from monitor_runtime.licensing import require_license + require_license() sid = stream.id with self._lock: item = self._pipelines.get(sid) @@ -547,10 +615,15 @@ class AnalysisManager(object): th = item.get("thread") alive = th is not None and th.is_alive() if alive: - return True, "already running" + if not item.get("preview_only"): + return True, "already running" + # 正式启动接管由预览创建的临时管线。 + self.stop(sid) + item = None # 僵尸条目:进程/线程已退出但未清理 try: if item.get("mode") == "process": + self._close_inference_channel(item) eq = item.get("event_queue") if eq: get_event_bridge().unregister_queue(eq) @@ -577,6 +650,78 @@ class AnalysisManager(object): return False, "analysis subprocess exited (check OpenCV / RTSP / log)" return ok, msg + def start_preview(self, zone): + """确保所选布控有检测管线;停用布控使用不写报警的临时管线。""" + from monitor_runtime.licensing import require_license + require_license() + stream = zone.stream + sid = stream.id + with self._lock: + item = self._pipelines.get(sid) + if self._is_pipeline_alive(item): + if not item.get("preview_only") and "preview_restore_fps" not in item: + base_fps = float(item.get("analyze_fps") or self._compute_analyze_fps(sid, 1)) + item["preview_restore_fps"] = base_fps + boosted_fps = max(5.0, base_fps) + if item.get("mode") == "process" and item.get("handle"): + item["handle"].set_analyze_fps(boosted_fps) + elif item.get("pipeline"): + item["pipeline"].set_analyze_fps(boosted_fps) + return True, "preview" if item.get("preview_only") else "formal" + if item: + self._purge_pipeline(sid) + algos = [] + seen = set() + for ba in zone.algorithms.filter(state=1).select_related("small_model", "detector_model"): + model = ba.detector_model if int(ba.flow_type or 0) == 4 else ba.small_model + if model and model.state == 1 and model.id not in seen: + seen.add(model.id) + algos.append(model) + if not algos: + return False, "布控未绑定可用的小模型" + zones = self._load_zones(sid, active_only=False, zone_id=zone.id) + url = self.build_rtsp_url(stream) + if not url: + return False, "no rtsp url" + fps = max(5.0, self._zone_analyze_fps(zone.detect_interval_sec, zone.detect_frames)) + if self._use_multiprocess(): + ok, msg = self._start_process(stream, url, zones, algos, + preview_only=True, analyze_fps_override=fps) + else: + ok, msg = self._start_thread(stream, url, zones, algos, + preview_only=True, analyze_fps_override=fps) + if ok: + time.sleep(0.35) + if not self.is_running(sid): + self._purge_pipeline(sid) + return False, "预览分析子进程启动失败,请检查视频流或模型日志" + return ok, msg + + def stop_preview(self, stream_id): + """只释放预览创建的管线,不影响正式布控。""" + with self._lock: + item = self._pipelines.get(int(stream_id)) + if not item: + return False + if not item.get("preview_only"): + restore_fps = item.pop("preview_restore_fps", None) + if restore_fps is not None: + if item.get("mode") == "process" and item.get("handle"): + item["handle"].set_analyze_fps(restore_fps) + elif item.get("pipeline"): + item["pipeline"].set_analyze_fps(restore_fps) + return False + self.stop(int(stream_id)) + get_event_bridge().clear_preview(int(stream_id)) + return True + + def preview_mode(self, stream_id): + with self._lock: + item = self._pipelines.get(int(stream_id)) + if not self._is_pipeline_alive(item): + return "stopped" + return "preview" if item.get("preview_only") else "formal" + def stop(self, stream_id): with self._lock: item = self._pipelines.get(stream_id) @@ -586,6 +731,7 @@ class AnalysisManager(object): handle = item.get("handle") if handle: handle.stop() + self._close_inference_channel(item) eq = item.get("event_queue") if eq: get_event_bridge().unregister_queue(eq) @@ -630,6 +776,7 @@ class AnalysisManager(object): handle.stop(timeout=1) except Exception: pass + self._close_inference_channel(item) else: pipe = item.get("pipeline") if pipe: @@ -644,9 +791,9 @@ class AnalysisManager(object): with self._lock: alive = [] for sid, item in list(self._pipelines.items()): - if self._is_pipeline_alive(item): + if self._is_pipeline_alive(item) and not item.get("preview_only"): alive.append(sid) - else: + elif not self._is_pipeline_alive(item): self._purge_pipeline(sid) return alive @@ -667,7 +814,8 @@ class AnalysisManager(object): if health == "ok" and fps <= 0 and stalled >= 20: info["stream_health"] = "stalled" health = "stalled" - info["healthy"] = health == "ok" and fps > 0.05 + info["healthy"] = health == "ok" and ( + info.get("analysis_health") == "running" if "analysis_health" in info else fps > 0.05) if not info.get("active_zone_ids") and self.is_running(stream_id): try: zones = self._load_zones(stream_id) @@ -785,3 +933,17 @@ class AnalysisManager(object): if t in ("entered_zone", "left_zone", "object_start"): return 2 return 3 + +def shutdown_analysis(): + """Stop existing resources without constructing a new pool.""" + instance = AnalysisManager._instance + if instance and getattr(instance, '_initialized', False): + for sid in list(instance._pipelines): + instance.stop(sid) + instance._infer_forwarder_running = False + instance._infer_forwarder.join(timeout=3) + from app.analysis.inference_pool import shutdown_inference_pool + shutdown_inference_pool() + instance._status_manager.shutdown() + instance._initialized = False + AnalysisManager._instance = None diff --git a/app/analysis/pipeline.py b/app/analysis/pipeline.py index 2c64260..2817b2e 100644 --- a/app/analysis/pipeline.py +++ b/app/analysis/pipeline.py @@ -44,6 +44,8 @@ class CameraPipeline(object): analyze_fps=None, on_event=None, on_track_snapshot=None, + on_preview=None, + alarm_enabled=True, zone_polygons=None, storage_alarm_dir=None, static_dir=None, @@ -75,6 +77,8 @@ class CameraPipeline(object): self._frame_source = FrameSource(rtsp_url, target_fps=target_fps) self._on_event = on_event or (lambda *a, **kw: None) self._on_track_snapshot = on_track_snapshot or (lambda *a, **kw: None) + self._on_preview = on_preview or (lambda *a, **kw: None) + self._alarm_enabled = bool(alarm_enabled) self.zone_polygons = zone_polygons or [] # 解码/分析分离 @@ -96,7 +100,11 @@ class CameraPipeline(object): self._last_analyze_ts = 0.0 self._running = False + self._started_event = threading.Event() + self._analysis_error = "" + self._last_analysis_ts = 0.0 self._last_zone_state = {} # track_id -> set(zone_ids) + self._outside_zone_hits = {} # (track_id, zone_id) -> 连续明确在区域外次数 self._track_enter_ts = {} # (track_id, zone_id) -> ts(滞留计时) self._last_w = 0 self._last_h = 0 @@ -118,6 +126,7 @@ class CameraPipeline(object): self._zone_config_warned = set() self._start_ts = 0.0 self._last_frame_ts = 0.0 + self._current_capture_ts = 0.0 self._update_detector_policy() def _update_detector_policy(self): @@ -155,6 +164,7 @@ class CameraPipeline(object): def reset_zone_runtime_state(self): """热更新/重启布控后清空运行时状态,使进入区域报警可重新触发。""" self._last_zone_state = {} + self._outside_zone_hits = {} self._track_enter_ts = {} self._llm_zone_last_ts = {} self._llm_track_last_ts = {} @@ -221,7 +231,7 @@ class CameraPipeline(object): with self._queue_lock: if len(self._frame_queue) >= self._frame_queue.maxlen: self._dropped_count += 1 - self._frame_queue.append(frame) + self._frame_queue.append((frame, self._last_frame_ts)) self._decoded_count += 1 self._fps_window_decoded += 1 except Exception as e: @@ -249,6 +259,7 @@ class CameraPipeline(object): self._running = True self._start_ts = time.time() self._decode_running = True + self._started_event.set() self._decode_thread = threading.Thread( target=self._decode_loop, name="decode-%s" % self.stream_id, daemon=True) self._decode_thread.start() @@ -256,26 +267,34 @@ class CameraPipeline(object): % (self.stream_code, self.analyze_fps, self._analyze_interval, len(self._detectors))) try: while self._running: - frame = self._pop_latest_frame() - if frame is None: + packet = self._pop_latest_frame() + if packet is None: time.sleep(0.02) continue + if isinstance(packet, tuple) and len(packet) == 2: + frame, capture_ts = packet + else: # 兼容测试和旧调用 + frame, capture_ts = packet, time.time() # 跳帧节流:未到分析间隔则丢弃此帧 now = time.time() if now - self._last_analyze_ts < self._analyze_interval: continue self._last_analyze_ts = now self._frame_index += 1 - self._analyzed_count += 1 - self._fps_window_analyzed += 1 try: self._last_h, self._last_w = frame.shape[:2] except Exception: pass self._last_frame = frame + self._current_capture_ts = capture_ts try: self._process_frame(frame) + self._analysis_error = "" + self._last_analysis_ts = time.time() + self._analyzed_count += 1 + self._fps_window_analyzed += 1 except Exception as e: + self._analysis_error = str(e) logger.warning("pipeline[%s] 处理帧异常: %s" % (self.stream_code, str(e))) self._refresh_fps() except Exception as e: @@ -301,7 +320,7 @@ class CameraPipeline(object): self._fps_window_decoded = 0 self._fps_window_ts = now - def _process_frame(self, frame): + def _process_frame(self, frame, capture_ts=None): motion_boxes = self._motion.detect(frame) has_motion = len(motion_boxes) > 0 run_detect = has_motion or self._force_detect @@ -313,22 +332,29 @@ class CameraPipeline(object): for d in self._detectors: eng = d.get("engine") if not eng or not eng.ready(): - continue + raise RuntimeError("检测器 %s 未就绪" % d.get("algorithm_name")) try: res = eng.detect(frame) except Exception as e: logger.warning("pipeline[%s] 检测器 %s 异常: %s" % (self.stream_code, d.get("algorithm_name"), str(e))) - continue + raise algo_id = d.get("algorithm_id") algo_name = d.get("algorithm_name") + allowed = set(d.get("target_labels") or []) for r in res: + if allowed and r.get("label") not in allowed: + continue r["algorithm_id"] = algo_id r["algorithm_name"] = algo_name - detections.extend(res) + detections.append(r) # 无小模型检测器时不伪造 motion 目标;流程2 仍由 _check_llm_zones 按业务规则报警 - active, ended, new_tracks, frame_index = self._tracker.update(detections, self._frame_index) + processed_ts = time.time() + capture_ts = float(capture_ts or self._current_capture_ts or processed_ts) + active, ended, new_tracks, frame_index = self._tracker.update( + detections, self._frame_index, timestamp=capture_ts, + ) for tid in new_tracks: tr = next((t for t in active if t["track_id"] == tid), None) @@ -360,7 +386,10 @@ class CameraPipeline(object): "timestamp": time.time(), }) - self._check_zones(active, frame_index, frame) + # 未确认的中等置信度轨迹不会产生区域报警;第二次连续命中后才参与。 + alarm_tracks = [tr for tr in active if tr.get("observed") and tr.get("confirmed")] + self._check_zones(alarm_tracks, frame_index, frame) + self._publish_preview(capture_ts, processed_ts) if has_motion: self._check_llm_zones(frame, motion_boxes) @@ -369,6 +398,8 @@ class CameraPipeline(object): """仅业务算法规则命中时:截图、生成描述、上报报警事件。""" if not biz_rule: return False + if not self._alarm_enabled: + return False snap_path = self._save_alarm_snapshot(frame, tr, zone_cfg, event_type) evt = { "stream_id": self.stream_id, @@ -379,6 +410,9 @@ class CameraPipeline(object): "label": (tr or {}).get("label", ""), "timestamp": now, "box": box, + "score": float((tr or {}).get("score", 0) or 0), + "frame_width": int(self._last_w or 0), + "frame_height": int(self._last_h or 0), "snapshot_path": snap_path, } evt.update(extra) @@ -573,11 +607,11 @@ class CameraPipeline(object): self._maybe_alert_line_count(frame, tr, zone_cfg, cr, direction, counts, tid, zid, box, now) def _fire_area_alarms(self, tr, zid, zone_cfg, prev_zones, frame, box, now, tid): - """区域入侵(AREA):进入时报警,停留期间按 detect_interval_sec 持续重复报警。""" + """区域入侵:首次进入立即报警,持续停留按独立重复间隔重报。""" matched_rules = self._matched_rules(tr, zone_cfg) if not matched_rules: return False - interval = max(0.1, float((zone_cfg or {}).get("detect_interval_sec", 1) or 1)) + repeat_sec = max(0.0, float((zone_cfg or {}).get("alarm_repeat_sec", 30) or 0)) is_new = zid not in prev_zones fired_any = False flow3_rule = self._flow3_needs_llm(zone_cfg, tr) @@ -587,7 +621,7 @@ class CameraPipeline(object): def _should_fire(biz_id): key = (tid, zid, biz_id) last = self._area_alarm_last_ts.get(key, 0) - return is_new or (now - last >= interval) + return is_new or (repeat_sec > 0 and now - last >= repeat_sec) def _mark_fired(biz_id): self._area_alarm_last_ts[(tid, zid, biz_id)] = now @@ -630,7 +664,11 @@ class CameraPipeline(object): def _check_zones(self, active, frame_index, frame): now = time.time() h, w = self._frame_size(frame_index) - cur_state = {} + # 短暂缺检的轨迹保留原区域状态,但本帧不触发任何报警;只有重新观测到 + # 明确在区域外两次,或轨迹超时结束,才判定离开。 + retained_ids = {t.track_id for t in self._tracker.all_active()} + cur_state = {tid: set(zones) for tid, zones in self._last_zone_state.items() + if tid in retained_ids} # 统计每个区域内的目标数(用于 DENSITY 后处理) zone_density_count = {} # zone_id -> count @@ -638,7 +676,8 @@ class CameraPipeline(object): tid = tr["track_id"] box = tr["box"] cx = (box[0] + box[2]) / 2 - cy = (box[1] + box[3]) / 2 + # 人员用人体框底部向上内缩 5% 的落脚点判断入区,避免框中心落在区域外。 + cy = box[3] - (box[3] - box[1]) * 0.05 if tr.get("label") == "person" else (box[1] + box[3]) / 2 physical_in = set() for z in self.zone_polygons: coords = self._scale_zone(z.get("coords", []), w, h) @@ -649,6 +688,7 @@ class CameraPipeline(object): confirmed = set(prev & physical_in) for zid in physical_in: + self._outside_zone_hits.pop((tid, zid), None) zone_cfg = next((z for z in self.zone_polygons if z.get("id") == zid), None) if not self._should_alarm_track_in_zone(tr, zone_cfg): logger.debug( @@ -661,6 +701,13 @@ class CameraPipeline(object): confirmed.add(zid) for zid in prev - physical_in: + outside_key = (tid, zid) + outside_hits = self._outside_zone_hits.get(outside_key, 0) + 1 + self._outside_zone_hits[outside_key] = outside_hits + if outside_hits < 2: + confirmed.add(zid) + continue + self._outside_zone_hits.pop(outside_key, None) self._on_event({ "stream_id": self.stream_id, "stream_code": self.stream_code, "type": "left_zone", "track_id": tid, "zone_id": zid, @@ -807,6 +854,55 @@ class CameraPipeline(object): self._direction_fired.discard(fk) self._last_zone_state = cur_state + def _preview_inside_zones(self, tr): + h, w = self._last_h, self._last_w + box = tr.get("box") or [] + if len(box) != 4 or not w or not h: + return [] + x = (box[0] + box[2]) * 0.5 + y = box[3] - (box[3] - box[1]) * 0.05 if tr.get("label") == "person" else (box[1] + box[3]) * 0.5 + return [z.get("id") for z in self.zone_polygons + if self._point_in_polygon((x, y), self._scale_zone(z.get("coords", []), w, h))] + + def _publish_preview(self, capture_ts, processed_ts=None): + """只发布最新检测状态;事件桥覆盖旧快照,不落库、不累积帧。""" + if not self._last_w or not self._last_h: + return + tracks = [] + processed_ts = float(processed_ts or time.time()) + for tr in self._tracker.display_tracks(capture_ts): + box = tr.get("box") or [] + if len(box) != 4: + continue + item = dict(tr) + item["box"] = [max(0.0, min(1.0, box[0] / self._last_w)), + max(0.0, min(1.0, box[1] / self._last_h)), + max(0.0, min(1.0, box[2] / self._last_w)), + max(0.0, min(1.0, box[3] / self._last_h))] + velocity = item.get("velocity") or [0, 0, 0, 0] + item["velocity"] = [float(velocity[0]) / self._last_w, + float(velocity[1]) / self._last_h, + float(velocity[2]) / self._last_w, + float(velocity[3]) / self._last_h] + item["inside_zone_ids"] = self._preview_inside_zones(tr) + tracks.append(item) + self._on_preview({ + "stream_id": self.stream_id, + "sequence": self._frame_index, + "timestamp": capture_ts, + "processed_timestamp": processed_ts, + "inference_latency": max(0.0, processed_ts - capture_ts), + "frame_width": self._last_w, + "frame_height": self._last_h, + "analysis_fps": self._analysis_fps, + "model_names": [d.get("algorithm_name", "") for d in self._detectors], + "model_devices": [d.get("device", "cpu") for d in self._detectors], + "tracks": tracks, + "zones": [{"id": z.get("id"), "name": z.get("name", ""), + "color": z.get("color", "#16a085"), "coords": z.get("coords") or []} + for z in self.zone_polygons], + }) + def _cross_line(self, prev_pt, cur_pt, line_a, line_b): from app.analysis.biz_rules import cross_line_segment return cross_line_segment(prev_pt, cur_pt, line_a, line_b) @@ -972,10 +1068,20 @@ class CameraPipeline(object): health, stalled_sec = self._compute_stream_health() src = self._frame_source.health_snapshot() if self._frame_source else {} effective_running = self._running and health in ("ok", "reconnecting", "connecting") + if self._analysis_error: + analysis_health = "error" + elif not self._last_analysis_ts: + analysis_health = "starting" + elif time.time() - self._last_analysis_ts > max(30.0, self._analyze_interval * 2): + analysis_health = "stalled" + else: + analysis_health = "running" return { "stream_id": self.stream_id, "running": effective_running, "stream_health": health, + "analysis_health": analysis_health, + "analysis_error": self._analysis_error, "stalled_sec": round(stalled_sec, 1), "reconnect_fail_count": src.get("reconnect_fail_count", 0), "total_reconnects": src.get("total_reconnects", 0), @@ -989,7 +1095,7 @@ class CameraPipeline(object): "decoded_count": self._decoded_count, "analyzed_count": self._analyzed_count, "dropped_count": self._dropped_count, - "analysis_fps": round(self._analysis_fps, 1), + "analysis_fps": round(self._analysis_fps, 3), "decode_fps": round(self._decode_fps, 1), "analyze_fps_target": self.analyze_fps, "active_zone_ids": sorted([ diff --git a/app/analysis/preview_sessions.py b/app/analysis/preview_sessions.py new file mode 100644 index 0000000..32b04f6 --- /dev/null +++ b/app/analysis/preview_sessions.py @@ -0,0 +1,139 @@ +"""实时 YOLO 预览会话(进程内、短轮询、只保留最新结果)。""" +import json +import threading +import time +import uuid + +from app.analysis.event_bridge import get_event_bridge +from app.analysis.manager import AnalysisManager + + +class PreviewSessionRegistry(object): + TTL_SEC = 15.0 + + def __init__(self): + self._sessions = {} + self._lock = threading.RLock() + self._running = True + threading.Thread(target=self._cleanup_loop, name="preview-session-cleanup", daemon=True).start() + + @staticmethod + def _zone_payload(zone): + try: + coords = json.loads(zone.coordinates or "[]") + except Exception: + coords = [] + return {"id": zone.id, "name": zone.name, "color": zone.color, + "coords": coords if isinstance(coords, list) else []} + + @staticmethod + def _point_in_polygon(point, polygon): + if len(polygon or []) < 3: + return False + x, y, inside, j = point[0], point[1], False, len(polygon) - 1 + for i, current in enumerate(polygon): + previous = polygon[j] + if ((current[1] > y) != (previous[1] > y) and + x < (previous[0] - current[0]) * (y - current[1]) / + (previous[1] - current[1] + 1e-12) + current[0]): + inside = not inside + j = i + return inside + + def start(self, owner, zone): + ok, mode = AnalysisManager().start_preview(zone) + if not ok: + raise RuntimeError(mode) + session_id = uuid.uuid4().hex + now = time.time() + algorithms = [] + target_labels = set() + for ba in zone.algorithms.filter(state=1).select_related("small_model", "detector_model"): + model = ba.detector_model if int(ba.flow_type or 0) == 4 else ba.small_model + if model: + algorithms.append({"id": model.id, "name": model.name}) + try: + labels = json.loads(ba.target_labels or "[]") + except Exception: + labels = [] + target_labels.update(labels or []) + session = { + "session_id": session_id, "owner": owner, "stream_id": zone.stream_id, + "zone": self._zone_payload(zone), "last_heartbeat": now, + "created_at": now, "algorithms": algorithms, + "target_labels": sorted(target_labels), + } + with self._lock: + self._sessions[session_id] = session + stream = zone.stream + return { + "session_id": session_id, "mode": AnalysisManager().preview_mode(zone.stream_id), + "stream_id": zone.stream_id, "app": stream.app, "name": stream.name, + "models": algorithms, "zone": session["zone"], + } + + def data(self, owner, session_id, since=None): + with self._lock: + session = self._sessions.get(session_id) + if not session or session["owner"] != owner: + raise PermissionError("预览会话不存在或不属于当前登录会话") + session["last_heartbeat"] = time.time() + stream_id = session["stream_id"] + zone = dict(session["zone"]) + latest = get_event_bridge().latest_preview(stream_id) + mode = AnalysisManager().preview_mode(stream_id) + if not latest: + return {"changed": False, "sequence": 0, "mode": mode, + "zone": zone, "models": session["algorithms"]} + sequence = int(latest.get("sequence") or 0) + changed = since is None or sequence > int(since or 0) + if not changed: + return {"changed": False, "sequence": sequence, "mode": mode, + "timestamp": latest.get("timestamp")} + payload = dict(latest) + payload["tracks"] = [dict(track) for track in latest.get("tracks", [])] + payload.update({"changed": True, "mode": mode, "zone": zone}) + # 叠框仅显示该布控目标类别;人员入侵不会再显示车辆和交通灯。 + target_labels = set(session.get("target_labels") or []) + if target_labels: + payload["tracks"] = [t for t in payload.get("tracks", []) if t.get("label") in target_labels] + for track in payload.get("tracks", []): + box = track.get("box") or [] + if len(box) != 4: + continue + foot = ((box[0] + box[2]) * .5, + box[3] - (box[3] - box[1]) * .05 if track.get("label") == "person" + else (box[1] + box[3]) * .5) + track["inside_zone_ids"] = ([zone["id"]] if self._point_in_polygon(foot, zone["coords"]) else []) + payload["zones"] = [zone] + return payload + + def stop(self, owner, session_id): + with self._lock: + session = self._sessions.get(session_id) + if not session or session["owner"] != owner: + return False + stream_id = session["stream_id"] + self._sessions.pop(session_id, None) + has_viewers = any(s["stream_id"] == stream_id for s in self._sessions.values()) + if not has_viewers: + AnalysisManager().stop_preview(stream_id) + return True + + def _cleanup_loop(self): + while self._running: + now = time.time() + expired = [] + with self._lock: + expired = [(sid, s["owner"]) for sid, s in self._sessions.items() + if now - s["last_heartbeat"] > self.TTL_SEC] + for sid, owner in expired: + self.stop(owner, sid) + threading.Event().wait(2.0) + + +_REGISTRY = PreviewSessionRegistry() + + +def get_preview_registry(): + return _REGISTRY diff --git a/app/analysis/process_worker.py b/app/analysis/process_worker.py index 07480d7..0629d53 100644 --- a/app/analysis/process_worker.py +++ b/app/analysis/process_worker.py @@ -19,7 +19,8 @@ def _algorithm_spec_from_dict(d): return d -def _build_detectors_in_process(algorithm_specs, infer_req_q=None, infer_resp_q=None): +def _build_detectors_in_process(algorithm_specs, infer_req_q=None, infer_resp_q=None, + response_channel=None): """在子进程中构造检测器;若提供 infer_req_q/infer_resp_q 则走主进程共享推理池""" detectors = [] if infer_req_q is not None and infer_resp_q is not None: @@ -28,7 +29,10 @@ def _build_detectors_in_process(algorithm_specs, infer_req_q=None, infer_resp_q= detectors.append({ "algorithm_id": spec.get("id", 0), "algorithm_name": spec.get("name", ""), - "engine": RemoteDetector(spec, infer_req_q, infer_resp_q), + "engine": RemoteDetector(spec, infer_req_q, infer_resp_q, + response_channel=response_channel), + "target_labels": spec.get("target_labels") or [], + "device": spec.get("device") or "cpu", }) else: from app.analysis.worker_pool import DetectorWorkerPool @@ -47,6 +51,8 @@ def _build_detectors_in_process(algorithm_specs, infer_req_q=None, infer_resp_q= "algorithm_id": spec.get("id", 0), "algorithm_name": spec.get("name", ""), "engine": eng, + "target_labels": spec.get("target_labels") or [], + "device": spec.get("device") or "cpu", }) return detectors @@ -88,10 +94,17 @@ def pipeline_process_main(config, event_queue, cmd_queue, status_dict, except Exception: pass + def on_preview(payload): + try: + event_queue.put(("preview", payload), timeout=0.2) + except Exception: + pass + detectors = _build_detectors_in_process( algorithm_specs, infer_req_q if use_shared_inference else None, infer_resp_q if use_shared_inference else None, + config.get("response_channel"), ) if not detectors and not algorithm_specs: log.info("pipeline[%s] 无算法,仅运动检测", stream_code) @@ -107,6 +120,8 @@ def pipeline_process_main(config, event_queue, cmd_queue, status_dict, analyze_fps=analyze_fps, on_event=on_event, on_track_snapshot=on_track_snapshot, + on_preview=on_preview, + alarm_enabled=not bool(config.get("preview_only", False)), zone_polygons=zones, storage_alarm_dir=storage_alarm_dir, static_dir=static_dir, @@ -114,27 +129,34 @@ def pipeline_process_main(config, event_queue, cmd_queue, status_dict, pipeline._algorithm_name = ", ".join(d.get("name", "") for d in algorithm_specs) or "motion-only" import threading + finished = threading.Event() + + def wait_started(): + while not finished.is_set(): + if pipeline._started_event.wait(0.1): + return True + return False def status_reporter(): # 等待 pipeline.run() 启动(_running 在 run() 里才置 True, # 否则 while pipeline._running 条件不满足会立即退出,导致 status_dict 永远为空) - _wait = 0 - while not pipeline._running and _wait < 100: - time.sleep(0.1) - _wait += 1 - while pipeline._running: + if not wait_started(): + return + while not finished.is_set(): try: st = pipeline.status() status_dict[str(stream_id)] = st except Exception: - pass - time.sleep(1.0) + log.exception("pipeline[%s] 状态上报失败", stream_code) + finished.wait(1.0) reporter = threading.Thread(target=status_reporter, name="status-%s" % stream_id, daemon=True) reporter.start() def cmd_listener(): - while pipeline._running: + if not wait_started(): + return + while not finished.is_set(): try: cmd = cmd_queue.get(timeout=0.5) except queue.Empty: @@ -149,6 +171,8 @@ def pipeline_process_main(config, event_queue, cmd_queue, status_dict, pipeline.set_zone_polygons(cmd.get("zones") or []) if cmd.get("analyze_fps") is not None: pipeline.set_analyze_fps(cmd.get("analyze_fps")) + if cmd.get("cmd") == "set_analyze_fps": + pipeline.set_analyze_fps(cmd.get("analyze_fps") or 1) except Exception as e: log.exception("pipeline[%s] 命令处理失败: %s", stream_code, e) @@ -159,6 +183,9 @@ def pipeline_process_main(config, event_queue, cmd_queue, status_dict, try: pipeline.run() finally: + finished.set() + reporter.join(timeout=2) + cmd_thread.join(timeout=1) try: status_dict.pop(str(stream_id), None) except Exception: @@ -186,6 +213,7 @@ class PipelineProcessHandle(object): try: self.process.join(timeout=timeout) if self.process.is_alive(): + logger.warning("pipeline[%s] 未在 %ss 内停止,强制结束子进程", self.stream_id, timeout) self.process.terminate() self.process.join(timeout=2) except Exception: @@ -200,6 +228,12 @@ class PipelineProcessHandle(object): except Exception as e: logger.warning("reload_zones 发送失败: %s", e) + def set_analyze_fps(self, analyze_fps): + try: + self.cmd_queue.put({"cmd": "set_analyze_fps", "analyze_fps": float(analyze_fps)}, timeout=1.0) + except Exception as e: + logger.warning("set_analyze_fps 发送失败: %s", e) + def status(self): try: return self.status_dict.get(str(self.stream_id)) diff --git a/app/analysis/remote_detector.py b/app/analysis/remote_detector.py index 0c47b7e..77fdb00 100644 --- a/app/analysis/remote_detector.py +++ b/app/analysis/remote_detector.py @@ -67,11 +67,13 @@ def _get_drainer(resp_queue): class RemoteDetector(object): ENGINE_NAME = "remote_pool" - def __init__(self, algorithm_spec, req_queue, resp_queue, timeout=30.0): + def __init__(self, algorithm_spec, req_queue, resp_queue, timeout=35.0, + response_channel=None): self._spec = algorithm_spec self._req_q = req_queue self._resp_q = resp_queue self._timeout = timeout + self._response_channel = response_channel self._drainer = _get_drainer(resp_queue) def ready(self): @@ -82,17 +84,21 @@ class RemoteDetector(object): def detect(self, frame): if not self.ready(): - return [] + raise RuntimeError("共享推理队列未就绪") self._drainer.ensure_started() try: import cv2 - frame = self._maybe_downscale(frame) - ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 80]) + orig_h, orig_w = frame.shape[:2] + inference_frame = self._maybe_downscale(frame) + infer_h, infer_w = inference_frame.shape[:2] + # 远处人员框很小,低质量 JPEG 会在 YOLO 前抹掉轮廓;95 在当前单路 CPU + # 场景仍可控,并显著缩小与直接单图推理的差异。 + ok, buf = cv2.imencode(".jpg", inference_frame, [int(cv2.IMWRITE_JPEG_QUALITY), 95]) if not ok: - return [] + raise RuntimeError("推理帧编码失败") jpeg = buf.tobytes() - except Exception: - return [] + except Exception as e: + raise RuntimeError("推理帧编码失败: %s" % e) from e req_id = str(uuid.uuid4()) evt = self._drainer.register(req_id) @@ -101,19 +107,62 @@ class RemoteDetector(object): "req_id": req_id, "algorithm": self._spec, "jpeg": jpeg, + "response_channel": self._response_channel, }, timeout=2.0) - except Exception: + except Exception as e: self._drainer.unregister(req_id) - return [] + raise RuntimeError("推理请求入队失败: %s" % e) from e if not evt["event"].wait(timeout=self._timeout): self._drainer.unregister(req_id) logger.warning("RemoteDetector 推理超时 algo=%s", self._spec.get("name")) - return [] + raise TimeoutError("共享推理响应超时(%ss)" % self._timeout) resp = evt.get("resp") or {} if not resp.get("ok"): - return [] - return resp.get("detections") or [] + raise RuntimeError(resp.get("error") or "共享推理失败") + detections = resp.get("detections") or [] + if infer_w != orig_w or infer_h != orig_h: + detections = self._restore_coordinates( + detections, float(orig_w) / infer_w, float(orig_h) / infer_h, + ) + return detections + + @staticmethod + def _restore_coordinates(detections, scale_x, scale_y): + """把共享推理缩图坐标恢复到解码原帧坐标系。""" + result = [] + for source in detections or []: + item = dict(source) + box = item.get("box") + if isinstance(box, (list, tuple)) and len(box) >= 4: + item["box"] = [float(box[0]) * scale_x, float(box[1]) * scale_y, + float(box[2]) * scale_x, float(box[3]) * scale_y] + keypoints = item.get("keypoints") + if isinstance(keypoints, list): + item["keypoints"] = [ + [float(p[0]) * scale_x, float(p[1]) * scale_y] + list(p[2:]) + if isinstance(p, (list, tuple)) and len(p) >= 2 else p + for p in keypoints + ] + for polygon_key in ("polygon", "mask", "segments"): + polygon = item.get(polygon_key) + if isinstance(polygon, list): + item[polygon_key] = RemoteDetector._scale_polygon(polygon, scale_x, scale_y) + result.append(item) + return result + + @staticmethod + def _scale_polygon(value, scale_x, scale_y): + scaled = [] + for point in value: + if (isinstance(point, (list, tuple)) and len(point) >= 2 and + isinstance(point[0], (int, float)) and isinstance(point[1], (int, float))): + scaled.append([float(point[0]) * scale_x, float(point[1]) * scale_y] + list(point[2:])) + elif isinstance(point, list): + scaled.append(RemoteDetector._scale_polygon(point, scale_x, scale_y)) + else: + scaled.append(point) + return scaled def _maybe_downscale(self, frame): try: @@ -123,7 +172,7 @@ class RemoteDetector(object): int(self._spec.get("input_width", 640) or 640), int(self._spec.get("input_height", 640) or 640), 640, - ) * 2 + ) * 4 longest = max(h, w) if longest <= max_side: return frame diff --git a/app/analysis/tracker.py b/app/analysis/tracker.py index 271ac24..1c9ec01 100644 --- a/app/analysis/tracker.py +++ b/app/analysis/tracker.py @@ -1,102 +1,182 @@ -"""单摄像头目标跟踪(轻量 IoU 关联) +"""低帧率视频目标跟踪。 -设计:参考 Frigate/Norfair 的追踪思路,但用最小依赖实现一个 IoU 关联器, -避免强制引入 norfair。后续可平滑替换为 norfair 或 DeepSORT/ByteTrack 的特征关联。 - -输出:为每个检测框分配 track_id,并维护其在场状态/累计帧数/最近一帧框。 +采用同类别全局一对一匹配,并用时间而不是丢帧数保留轨迹。这样在 CPU +推理只有约 0.3 FPS 时,短暂缺检不会不断生成新的 track_id。 """ -import logging - -logger = logging.getLogger("analysis.tracker") - -try: - import numpy as np # reserved for future vectorized IoU; not required for operation - _ = np -except Exception: - np = None +import math +import time def _iou(a, b): ax1, ay1, ax2, ay2 = a bx1, by1, bx2, by2 = b - ix1 = max(ax1, bx1); iy1 = max(ay1, by1) - ix2 = min(ax2, bx2); iy2 = min(ay2, by2) - iw = max(0, ix2 - ix1); ih = max(0, iy2 - iy1) - inter = iw * ih - a_area = max(0, ax2 - ax1) * max(0, ay2 - ay1) - b_area = max(0, bx2 - bx1) * max(0, by2 - by1) - union = a_area + b_area - inter - if union <= 0: - return 0.0 - return float(inter) / float(union) + ix1, iy1 = max(ax1, bx1), max(ay1, by1) + ix2, iy2 = min(ax2, bx2), min(ay2, by2) + inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1) + area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1) + area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1) + union = area_a + area_b - inter + return float(inter / union) if union > 0 else 0.0 + + +def _center(box): + return ((box[0] + box[2]) * 0.5, (box[1] + box[3]) * 0.5) + + +def _center_distance(a, b): + ac, bc = _center(a), _center(b) + distance = math.hypot(ac[0] - bc[0], ac[1] - bc[1]) + diag = max(1.0, math.hypot(max(a[2] - a[0], b[2] - b[0]), + max(a[3] - a[1], b[3] - b[1]))) + return distance / diag class Track(object): - __slots__ = ("track_id", "label", "box", "score", "missed", "hits", "born") + __slots__ = ( + "track_id", "label", "box", "score", "missed", "hits", "born", + "last_seen", "last_update", "velocity", "confirmed", "observed", + "algorithm_id", "algorithm_name", + ) - def __init__(self, track_id, label, box, score, born): + def __init__(self, track_id, detection, born, timestamp): self.track_id = track_id - self.label = label - self.box = box - self.score = score + self.label = detection.get("label", "unknown") + self.box = list(detection.get("box") or [0, 0, 0, 0]) + self.score = float(detection.get("score", 0) or 0) self.missed = 0 self.hits = 1 self.born = born + self.last_seen = timestamp + self.last_update = timestamp + self.velocity = [0.0, 0.0, 0.0, 0.0] + self.confirmed = self.score >= 0.6 + self.observed = True + self.algorithm_id = detection.get("algorithm_id") + self.algorithm_name = detection.get("algorithm_name", "") + + def predicted_box(self, timestamp): + dt = max(0.0, min(5.0, timestamp - self.last_update)) + return [self.box[i] + self.velocity[i] * dt for i in range(4)] + + def as_dict(self): + return { + "track_id": self.track_id, + "label": self.label, + "box": list(self.box), + "score": self.score, + "hits": self.hits, + "confirmed": self.confirmed, + "observed": self.observed, + "last_seen": self.last_seen, + "algorithm_id": self.algorithm_id, + "algorithm_name": self.algorithm_name, + "velocity": list(self.velocity), + } class IoUTracker(object): - """按类别维护轨迹,IoU 匹配;max_missed 后判定目标消失。""" + """低 FPS 下的全局一对一框关联器。""" - def __init__(self, iou_threshold=0.3, max_missed=8): - self.iou_threshold = iou_threshold - self.max_missed = max_missed - self._tracks = {} # track_id -> Track + def __init__(self, iou_threshold=0.2, max_missed=8): + self.iou_threshold = float(iou_threshold) + self.max_missed = max_missed # 兼容旧构造参数;实际淘汰按时间 + self._tracks = {} self._next_id = 1 + self._last_update_ts = None + self._recent_period = 1.0 - def update(self, detections, frame_index): - """detections: list[dict(box, label, score)] - 返回 list[dict(track_id, label, box, score)] 当前帧仍在场的轨迹""" - active = {} - new_tracks = [] - # 贪心匹配 - for det in detections: - best_id = None - best_iou = self.iou_threshold - for tid, tr in self._tracks.items(): - if tr.label != det["label"]: - continue - v = _iou(tr.box, det["box"]) - if v > best_iou: - best_iou = v - best_id = tid - if best_id is not None: - tr = self._tracks[best_id] - tr.box = det["box"] - tr.score = det["score"] - tr.missed = 0 - tr.hits += 1 - active[best_id] = tr - else: - tid = self._next_id - self._next_id += 1 - tr = Track(tid, det["label"], det["box"], det["score"], frame_index) - self._tracks[tid] = tr - active[tid] = tr - new_tracks.append(tid) + @property + def retention_sec(self): + return max(2.0, min(15.0, 3.0 * self._recent_period)) - # 未匹配的轨迹累计 missed - ended = [] - for tid, tr in self._tracks.items(): - if tid in active: + def update(self, detections, frame_index, timestamp=None, max_age_sec=None): + timestamp = float(timestamp if timestamp is not None else time.time()) + if self._last_update_ts is not None: + period = timestamp - self._last_update_ts + if 0.05 <= period <= 60: + self._recent_period = self._recent_period * 0.7 + period * 0.3 + self._last_update_ts = timestamp + max_age = float(max_age_sec if max_age_sec is not None else self.retention_sec) + + for track in self._tracks.values(): + track.observed = False + + candidates = [] + for det_index, det in enumerate(detections or []): + box = det.get("box") or [] + if len(box) != 4: continue - tr.missed += 1 - if tr.missed >= self.max_missed: - ended.append(tid) - for tid in ended: - del self._tracks[tid] + for tid, track in self._tracks.items(): + if track.label != det.get("label"): + continue + predicted = track.predicted_box(timestamp) + overlap = _iou(predicted, box) + distance = _center_distance(predicted, box) + if overlap < self.iou_threshold and distance > 1.5: + continue + affinity = overlap * 0.72 + max(0.0, 1.0 - distance / 1.5) * 0.28 + candidates.append((affinity, overlap, -distance, tid, det_index)) + candidates.sort(reverse=True) - return [{"track_id": t.track_id, "label": t.label, "box": t.box, "score": t.score} - for t in active.values()], ended, new_tracks, frame_index + used_tracks, used_detections = set(), set() + for _affinity, _overlap, _distance, tid, det_index in candidates: + if tid in used_tracks or det_index in used_detections: + continue + track = self._tracks.get(tid) + if track is None: + continue + det = detections[det_index] + old_box = list(track.box) + dt = max(0.05, timestamp - track.last_update) + new_box = [float(x) for x in det["box"]] + instant_velocity = [(new_box[i] - old_box[i]) / dt for i in range(4)] + track.velocity = [track.velocity[i] * 0.5 + instant_velocity[i] * 0.5 for i in range(4)] + track.box = new_box + track.score = float(det.get("score", 0) or 0) + track.missed = 0 + track.hits += 1 + track.observed = True + track.last_seen = timestamp + track.last_update = timestamp + track.algorithm_id = det.get("algorithm_id") + track.algorithm_name = det.get("algorithm_name", "") + if track.score >= 0.6 or track.hits >= 2: + track.confirmed = True + used_tracks.add(tid) + used_detections.add(det_index) + + new_tracks = [] + for det_index, det in enumerate(detections or []): + if det_index in used_detections or len(det.get("box") or []) != 4: + continue + tid = self._next_id + self._next_id += 1 + self._tracks[tid] = Track(tid, det, frame_index, timestamp) + new_tracks.append(tid) + used_tracks.add(tid) + + ended = [] + for tid, track in list(self._tracks.items()): + if not track.observed: + track.missed += 1 + if not track.confirmed: + track.hits = 0 # 中等置信度必须连续两次命中 + if timestamp - track.last_seen > max_age: + ended.append(tid) + del self._tracks[tid] + + active = [t.as_dict() for t in self._tracks.values() if t.observed] + return active, ended, new_tracks, frame_index + + def display_tracks(self, timestamp=None): + now = float(timestamp if timestamp is not None else time.time()) + result = [] + for track in self._tracks.values(): + item = track.as_dict() + item["age_sec"] = max(0.0, now - track.last_seen) + item["opacity"] = 1.0 if track.observed else max(0.15, 1.0 - item["age_sec"] / self.retention_sec) + result.append(item) + return result def all_active(self): return list(self._tracks.values()) @@ -104,3 +184,5 @@ class IoUTracker(object): def reset(self): self._tracks.clear() self._next_id = 1 + self._last_update_ts = None + self._recent_period = 1.0 diff --git a/app/analysis/worker_pool.py b/app/analysis/worker_pool.py index 37c1d95..d32eddc 100644 --- a/app/analysis/worker_pool.py +++ b/app/analysis/worker_pool.py @@ -45,6 +45,7 @@ class DetectorWorkerPool(object): algo_type = algorithm.get("algorithm_type", "yolo8") task_type = algorithm.get("task_type", "detect") device = algorithm.get("device", "cpu") + target_labels = algorithm.get("target_labels", []) else: algo_id = getattr(algorithm, "id", None) engine_name = algorithm.inference_engine @@ -61,8 +62,10 @@ class DetectorWorkerPool(object): algo_type = algorithm.algorithm_type task_type = getattr(algorithm, "task_type", "detect") device = getattr(algorithm, "device", "cpu") + target_labels = getattr(algorithm, "target_labels", []) - key = (algo_id, engine_name, model_file, conf, iou, input_size, task_type, device) + key = (algo_id, engine_name, model_file, conf, iou, input_size, task_type, device, + tuple(sorted(target_labels or []))) with self._lock: det = self._engines.get(key) if det is not None: @@ -77,6 +80,7 @@ class DetectorWorkerPool(object): algorithm_type=algo_type, task_type=task_type, device=device) + det.target_labels = list(target_labels or []) if not det.load(): logger.warning("DetectorWorkerPool: 引擎 load 失败 algo=%s engine=%s", algo_id, engine_name) return None diff --git a/app/apps.py b/app/apps.py index 0df67d6..6e0ff37 100644 --- a/app/apps.py +++ b/app/apps.py @@ -26,6 +26,10 @@ class AppConfig(AppConfig): connection_created.connect(_setup_sqlite_pragma) + from monitor_runtime.paths import DESKTOP + if DESKTOP: + return # Desktop bootstrap migrates before accessing business tables. + try: from app.utils.schema_upgrade import ensure_biz_algorithm_line_count_columns ensure_biz_algorithm_line_count_columns() diff --git a/app/middleware.py b/app/middleware.py index 9abdc9c..23ea7c3 100644 --- a/app/middleware.py +++ b/app/middleware.py @@ -23,6 +23,8 @@ def _required_role(path, method): return ROLE_OPERATOR if path.startswith(("/analysis/openStart", "/analysis/openStop", "/analysis/openReload")): return ROLE_OPERATOR + if path.startswith(("/analysis/openPreviewStart", "/analysis/openPreviewStop")): + return ROLE_OPERATOR if path.startswith(("/analysis/openUpdate", "/analysis/openToggle", "/analysis/openRestart")): return ROLE_ALGORITHM_ADMIN return "" @@ -45,6 +47,11 @@ class SimpleMiddleware(MiddlewareMixin): def process_request(self, request): path = request.path_info + if path in ('/setup', '/license', '/license/status', '/license/request', '/license/import'): + from monitor_runtime.paths import DESKTOP + if DESKTOP: + return None # Dedicated views enforce local bootstrap/admin access. + if path.startswith('/inner/'): if verify_internal_request(request): return None diff --git a/app/migrations/0001_initial.py b/app/migrations/0001_initial.py new file mode 100644 index 0000000..9312f66 --- /dev/null +++ b/app/migrations/0001_initial.py @@ -0,0 +1,221 @@ +# Generated by Django 5.0.4 on 2026-08-30 23:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='AlgorithmModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='算法名称')), + ('algorithm_type', models.CharField(choices=[('yolo5', 'YOLOv5'), ('yolo8', 'YOLOv8'), ('yolo11', 'YOLOv11'), ('yolo26', 'YOLO26'), ('osnet', 'OSNet ReID')], default='yolo8', max_length=30, verbose_name='算法类型')), + ('task_type', models.CharField(choices=[('detect', 'Detect'), ('segment', 'Segment'), ('classify', 'Classify'), ('pose', 'Pose'), ('obb', 'OBB'), ('reid', 'ReID')], default='detect', max_length=20, verbose_name='任务类型')), + ('inference_engine', models.CharField(choices=[('yolo_pytorch', 'Yolo-PyTorch'), ('onnxruntime', 'OnnxRuntime'), ('openvino', 'OpenVINO')], default='yolo_pytorch', max_length=20, verbose_name='推理引擎')), + ('device', models.CharField(choices=[('cpu', 'CPU'), ('cuda', 'CUDA'), ('gpu', 'GPU')], default='cpu', max_length=20, verbose_name='推理设备')), + ('model_file', models.CharField(default='', max_length=300, verbose_name='模型文件相对路径')), + ('model_file_size', models.IntegerField(default=0, verbose_name='模型文件大小(字节)')), + ('input_width', models.IntegerField(default=640, verbose_name='输入宽度')), + ('input_height', models.IntegerField(default=640, verbose_name='输入高度')), + ('conf_threshold', models.FloatField(default=0.4, verbose_name='置信度阈值')), + ('iou_threshold', models.FloatField(default=0.5, verbose_name='NMS IoU 阈值')), + ('labels', models.TextField(default='[]', verbose_name='支持类别JSON数组')), + ('is_default', models.IntegerField(default=0, verbose_name='是否默认算法')), + ('state', models.IntegerField(default=1, verbose_name='状态')), + ('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('last_update_time', models.DateTimeField(auto_now_add=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '小模型', + 'verbose_name_plural': '小模型', + 'db_table': 'av_algorithm', + }, + ), + migrations.CreateModel( + name='LLMModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(verbose_name='用户')), + ('sort', models.IntegerField(default=0, verbose_name='排序')), + ('code', models.CharField(max_length=50, verbose_name='编号')), + ('name', models.CharField(default='', max_length=50, verbose_name='名称')), + ('model_name', models.CharField(max_length=200, verbose_name='模型名称')), + ('api_url', models.CharField(max_length=500, verbose_name='API地址')), + ('api_key', models.CharField(default='', max_length=512, verbose_name='API密钥')), + ('timeout', models.IntegerField(default=30, verbose_name='超时时间(秒)')), + ('inference_tool', models.CharField(default='OpenAI', max_length=100, verbose_name='推理工具')), + ('remark', models.TextField(default='', verbose_name='备注')), + ('state', models.IntegerField(default=1, verbose_name='状态')), + ('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('last_update_time', models.DateTimeField(auto_now_add=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '大模型', + 'verbose_name_plural': '大模型', + 'db_table': 'av_llm', + }, + ), + migrations.CreateModel( + name='LogModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(verbose_name='用户ID')), + ('log_type', models.IntegerField(verbose_name='日志类型')), + ('content', models.CharField(max_length=200, verbose_name='日志内容')), + ('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('state', models.IntegerField(verbose_name='状态')), + ], + options={ + 'verbose_name': '管理员日志', + 'verbose_name_plural': '管理员日志', + 'db_table': 'av_log', + }, + ), + migrations.CreateModel( + name='BizAlgorithmModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='算法名称')), + ('flow_type', models.IntegerField(choices=[(1, '小模型+后处理'), (2, '大模型+后处理'), (3, '小模型+大模型+后处理'), (4, '检测+ReID+后处理')], default=1, verbose_name='流程类型')), + ('target_labels', models.TextField(default='[]', verbose_name='目标类别JSON')), + ('llm_prompt', models.TextField(default='', verbose_name='大模型提示词')), + ('llm_validate', models.TextField(default='', verbose_name='提示词校验值')), + ('post_process', models.CharField(choices=[('AREA', '区域入侵'), ('LINE_CROSS', '越线检测'), ('LINE_COUNT', '越线计数'), ('DIRECTION', '方向入侵'), ('DENSITY', '密度报警'), ('DWELL', '滞留报警')], default='AREA', max_length=30, verbose_name='后处理逻辑')), + ('ref_angle', models.FloatField(default=90.0, verbose_name='方向参考角度')), + ('angle_tolerance', models.FloatField(default=45.0, verbose_name='方向容差(度)')), + ('forward_count_threshold', models.IntegerField(default=0, verbose_name='正向计数报警阈值')), + ('reverse_count_threshold', models.IntegerField(default=0, verbose_name='逆向计数报警阈值')), + ('state', models.IntegerField(default=1, verbose_name='状态')), + ('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('last_update_time', models.DateTimeField(auto_now_add=True, verbose_name='更新时间')), + ('detector_model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='biz_algorithms_as_detector', to='app.algorithmmodel', verbose_name='检测小模型(YOLO)')), + ('small_model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='biz_algorithms', to='app.algorithmmodel', verbose_name='小模型')), + ('llm', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='biz_algorithms', to='app.llmmodel', verbose_name='大模型')), + ], + options={ + 'verbose_name': '业务算法', + 'verbose_name_plural': '业务算法', + 'db_table': 'av_biz_algorithm', + }, + ), + migrations.CreateModel( + name='StreamModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(verbose_name='用户')), + ('sort', models.IntegerField(verbose_name='排序')), + ('code', models.CharField(max_length=50, verbose_name='编号')), + ('app', models.CharField(max_length=50, verbose_name='流分组')), + ('name', models.CharField(max_length=50, verbose_name='流名称')), + ('pull_stream_url', models.CharField(max_length=300, verbose_name='视频流源地址')), + ('pull_stream_type', models.IntegerField(verbose_name='视频流来源类型')), + ('pull_stream_transfer_mode', models.IntegerField(verbose_name='视频流传输模式')), + ('pull_stream_ip', models.CharField(max_length=50, verbose_name='拉流IP')), + ('pull_stream_port', models.IntegerField(verbose_name='拉流端口')), + ('pull_stream_username', models.CharField(max_length=512, verbose_name='拉流用户名')), + ('pull_stream_password', models.CharField(max_length=512, verbose_name='拉流密码')), + ('nickname', models.CharField(max_length=200, verbose_name='视频流昵称')), + ('remark', models.CharField(max_length=200, verbose_name='备注')), + ('forward_state', models.IntegerField(verbose_name='转发状态')), + ('is_audio', models.IntegerField(default=0, verbose_name='音频传输类型')), + ('snap_filepath', models.CharField(max_length=200, verbose_name='快照文件路径')), + ('snap_time', models.DateTimeField(auto_now_add=True, verbose_name='快照时间')), + ('camera_sum_num', models.IntegerField(default=0, verbose_name='通道总数')), + ('camera_name', models.CharField(max_length=100, verbose_name='摄像头名称')), + ('camera_manufacturer', models.CharField(max_length=100, verbose_name='摄像头厂商')), + ('camera_owner', models.CharField(max_length=50, verbose_name='摄像头所属者')), + ('camera_model', models.CharField(max_length=50, verbose_name='摄像头型号')), + ('camera_device_id', models.CharField(max_length=50, verbose_name='GB28181设备ID')), + ('camera_parent_id', models.CharField(max_length=50, verbose_name='GB28181父设备ID')), + ('camera_civilcode', models.CharField(max_length=50, verbose_name='行政区划码')), + ('camera_last_keepalive_time', models.DateTimeField(auto_now_add=True, verbose_name='最近一次心跳时间')), + ('camera_last_register_time', models.DateTimeField(auto_now_add=True, verbose_name='最近一次注册时间')), + ('cascade_device_id', models.CharField(default='', max_length=50, verbose_name='向上级联国标编号')), + ('cascade_enable', models.IntegerField(default=0, verbose_name='是否启用向上级联')), + ('record_enable', models.IntegerField(default=0, verbose_name='启用24/7录像')), + ('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('last_update_time', models.DateTimeField(auto_now_add=True, verbose_name='更新时间')), + ('add_type', models.IntegerField(default=0, verbose_name='添加类型')), + ('state', models.IntegerField(default=0, verbose_name='状态')), + ('algorithm', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='streams', to='app.algorithmmodel', verbose_name='分析算法')), + ], + options={ + 'verbose_name': '视频流', + 'verbose_name_plural': '视频流', + 'db_table': 'av_stream', + }, + ), + migrations.CreateModel( + name='ZoneModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='区域名称')), + ('coordinates', models.TextField(verbose_name='多边形坐标')), + ('is_required', models.IntegerField(default=1, verbose_name='是否必需区域')), + ('loiter_threshold', models.IntegerField(default=0, verbose_name='滞留阈值(秒)')), + ('detect_interval_sec', models.FloatField(default=1.0, verbose_name='检测间隔(秒)')), + ('detect_frames', models.IntegerField(default=1, verbose_name='检测帧数')), + ('color', models.CharField(default='#169F85', max_length=20, verbose_name='显示颜色')), + ('line_a', models.TextField(default='', verbose_name='警戒线端点A')), + ('line_b', models.TextField(default='', verbose_name='警戒线端点B')), + ('density_threshold', models.IntegerField(default=0, verbose_name='密度阈值')), + ('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('last_update_time', models.DateTimeField(auto_now_add=True, verbose_name='更新时间')), + ('state', models.IntegerField(default=1, verbose_name='状态')), + ('algorithms', models.ManyToManyField(blank=True, related_name='zones', to='app.bizalgorithmmodel', verbose_name='分析算法')), + ('stream', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.streammodel', verbose_name='所属摄像头')), + ], + options={ + 'verbose_name': '区域', + 'verbose_name_plural': '区域', + 'db_table': 'av_zone', + }, + ), + migrations.CreateModel( + name='RecordingModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file_path', models.CharField(max_length=500, verbose_name='文件路径')), + ('start_time', models.DateTimeField(verbose_name='开始时间')), + ('end_time', models.DateTimeField(verbose_name='结束时间')), + ('duration', models.FloatField(default=0, verbose_name='时长(秒)')), + ('file_size', models.BigIntegerField(default=0, verbose_name='文件大小(字节)')), + ('has_motion', models.IntegerField(default=0, verbose_name='含运动')), + ('has_object', models.IntegerField(default=0, verbose_name='含目标')), + ('create_time', models.DateTimeField(auto_now_add=True, verbose_name='入库时间')), + ('stream', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.streammodel', verbose_name='摄像头')), + ], + options={ + 'verbose_name': '录像分段', + 'verbose_name_plural': '录像分段', + 'db_table': 'av_recording', + 'indexes': [models.Index(fields=['stream', 'start_time'], name='av_recording_st_idx')], + }, + ), + migrations.CreateModel( + name='AlarmModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event_type', models.CharField(default='entered_zone', max_length=32, verbose_name='报警类型')), + ('description', models.CharField(default='', max_length=300, verbose_name='描述')), + ('timestamp', models.DateTimeField(verbose_name='发生时间')), + ('metadata', models.TextField(default='{}', verbose_name='元数据JSON')), + ('create_time', models.DateTimeField(auto_now_add=True, verbose_name='入库时间')), + ('stream', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='app.streammodel', verbose_name='摄像头')), + ], + options={ + 'verbose_name': '报警', + 'verbose_name_plural': '报警', + 'db_table': 'av_alarm', + 'indexes': [models.Index(fields=['-timestamp'], name='av_alarm_ts_idx'), models.Index(fields=['stream', 'timestamp'], name='av_alarm_st_idx')], + }, + ), + ] diff --git a/app/migrations/0002_zonemodel_alarm_repeat_sec.py b/app/migrations/0002_zonemodel_alarm_repeat_sec.py new file mode 100644 index 0000000..1d4f86e --- /dev/null +++ b/app/migrations/0002_zonemodel_alarm_repeat_sec.py @@ -0,0 +1,13 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("app", "0001_initial")] + + operations = [ + migrations.AddField( + model_name="zonemodel", + name="alarm_repeat_sec", + field=models.FloatField(default=30.0, verbose_name="重复报警间隔(秒)"), + ), + ] diff --git a/app/migrations/__init__.py b/app/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models.py b/app/models.py index 57596ec..fb04386 100644 --- a/app/models.py +++ b/app/models.py @@ -263,6 +263,7 @@ class ZoneModel(models.Model): loiter_threshold = models.IntegerField(default=0, verbose_name='滞留阈值(秒)') # 0=不检测滞留 detect_interval_sec = models.FloatField(default=1.0, verbose_name='检测间隔(秒)') # 每 N 秒 detect_frames = models.IntegerField(default=1, verbose_name='检测帧数') # 分析 M 帧,频率=M/N fps + alarm_repeat_sec = models.FloatField(default=30.0, verbose_name='重复报警间隔(秒)') # 0=同次停留不重复 color = models.CharField(max_length=20, default='#169F85', verbose_name='显示颜色') # LINE_CROSS 后处理:警戒线段两端点(归一化坐标0~1),JSON: [x,y] line_a = models.TextField(default='', verbose_name='警戒线端点A') # JSON: [x,y] 归一化 diff --git a/app/recording/manager.py b/app/recording/manager.py index 49452de..cc6de79 100644 --- a/app/recording/manager.py +++ b/app/recording/manager.py @@ -26,7 +26,9 @@ class RecordingManager(object): self._retention_thread = threading.Thread( target=self._retention_loop, name="recording-retention", daemon=True) self._retention_thread.start() - threading.Thread(target=self._auto_start_loop, name="recording-autostart", daemon=True).start() + self._sync_thread = threading.Thread( + target=self._sync_loop, name="recording-sync", daemon=True) + self._sync_thread.start() logger.info("RecordingManager 已启动") def stop(self): @@ -41,14 +43,35 @@ class RecordingManager(object): self._retention_thread.join(timeout=5) logger.info("RecordingManager 已停止") - def _auto_start_loop(self): - time.sleep(5) - try: - from app.models import StreamModel - for s in StreamModel.objects.filter(record_enable=1, forward_state=1): - self.start_stream(s) - except Exception as e: - logger.warning("录像自动启动失败: %s", e) + def _sync_loop(self): + """周期性把 DB 中的 record_enable 标记同步为实际 FFmpeg 录制进程。 + + 分进程部署下 Web 请求只负责持久化 record_enable,真正的录像进程 + 由持有录制服务的领导者进程通过本循环异步启动/停止。 + """ + time.sleep(3) + while self._running: + try: + self._run_sync() + except Exception as e: + logger.warning("录像同步异常: %s", e) + for _ in range(5): + if not self._running: + break + time.sleep(1) + + def _run_sync(self): + from app.models import StreamModel + streams = list(StreamModel.objects.filter(forward_state=1)) + wanted = {s.id for s in streams if int(s.record_enable or 0) == 1} + current = set(self.list_recording()) + for s in streams: + if s.id in wanted and s.id not in current: + ok, info = self.start_stream(s) + if not ok: + logger.warning("录像启动失败 stream=%s: %s", s.id, info) + elif s.id not in wanted and s.id in current: + self.stop_stream(s.id) def _segment_seconds(self): try: @@ -72,6 +95,8 @@ class RecordingManager(object): return AnalysisManager.build_rtsp_url(stream) def start_stream(self, stream): + from monitor_runtime.licensing import require_license + require_license() if not self._running: return False, "recording service is not running in this process" sid = stream.id @@ -94,14 +119,17 @@ class RecordingManager(object): cmd = [ ffmpeg, "-loglevel", "warning", "-rtsp_transport", "tcp", "-i", url, - "-c", "copy", "-f", "segment", + # MP4 容器不支持摄像头常见的 pcm_alaw 音频,只复制视频流。 + "-map", "0:v", "-c", "copy", "-f", "segment", "-segment_time", str(seg), "-reset_timestamps", "1", "-strftime", "1", pattern, ] try: - proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) except Exception as e: return False, str(e) self._processes[sid] = {"proc": proc, "path": out_dir, "stream": stream} @@ -116,8 +144,21 @@ class RecordingManager(object): proc = item.get("proc") if proc: try: - proc.terminate() - proc.wait(timeout=5) + # 先优雅退出(向 stdin 发 'q'),让 FFmpeg 正常收尾写出 mp4 的 + # moov 原子;Windows 上 TerminateProcess 硬杀会导致当前分段 + # 无 moov、文件无法播放。 + if proc.stdin: + try: + proc.stdin.write(b"q") + proc.stdin.flush() + proc.stdin.close() + except Exception: + pass + try: + proc.wait(timeout=8) + except Exception: + proc.terminate() + proc.wait(timeout=5) except Exception: try: proc.kill() diff --git a/app/services/alarm_service.py b/app/services/alarm_service.py index d674700..e5700ef 100644 --- a/app/services/alarm_service.py +++ b/app/services/alarm_service.py @@ -41,7 +41,8 @@ def write_alarm(event): except Exception: stream = None meta = event.get("metadata") or {} - for k in ("zone_id", "label", "duration", "track_id", "boxes", "box", "snapshot_path", + for k in ("zone_id", "label", "duration", "track_id", "score", "frame_width", "frame_height", + "boxes", "box", "snapshot_path", "global_track_id", "biz_algorithm_id", "biz_algorithm_name", "alarm_reason", "zone_name"): if k in event: meta[k] = event[k] diff --git a/app/services/lifecycle.py b/app/services/lifecycle.py index 91a2cf3..37314f3 100644 --- a/app/services/lifecycle.py +++ b/app/services/lifecycle.py @@ -13,7 +13,8 @@ from pathlib import Path logger = logging.getLogger("app.services.lifecycle") -PROJECT_ROOT = Path(__file__).resolve().parents[2] +from monitor_runtime.paths import DATA_ROOT +PROJECT_ROOT = DATA_ROOT DEFAULT_LOCK_PATH = PROJECT_ROOT / ".runtime" / "service-leader.lock" @@ -125,6 +126,8 @@ class ServiceManager: return self.lock.acquired def start(self): + from monitor_runtime.licensing import require_license + require_license() with self._guard: if self._started: return True @@ -137,6 +140,8 @@ class ServiceManager: from app.utils.MediaServerManager import get_media_server_manager ok, info = get_media_server_manager().start() logger.info("ZLM explicit start: ok=%s %s", ok, info) + if not ok: + raise RuntimeError("ZLM startup failed: " + str(info)) g_gb28181SipServer.start() if getattr(g_config, "recordingEnabled", False): from app.recording.manager import get_recording_manager @@ -167,6 +172,11 @@ class ServiceManager: if not self.is_leader: return self.coordinator.stop() + try: + from app.analysis.manager import shutdown_analysis + shutdown_analysis() + except Exception: + logger.exception("analysis shutdown failed") try: from app.recording.manager import get_recording_manager get_recording_manager().stop() diff --git a/app/urls.py b/app/urls.py index 0477386..b536770 100644 --- a/app/urls.py +++ b/app/urls.py @@ -125,6 +125,9 @@ urlpatterns = [ path('alarm/openClearAlarms', AnalysisView.alarm_openClearAlarms), path('analysis/openStatus', AnalysisView.analysis_openStatus), + path('analysis/openPreviewStart', AnalysisView.analysis_openPreviewStart), + path('analysis/openPreviewData', AnalysisView.analysis_openPreviewData), + path('analysis/openPreviewStop', AnalysisView.analysis_openPreviewStop), path('analysis/openStart', AnalysisView.analysis_openStart), path('analysis/openStop', AnalysisView.analysis_openStop), path('analysis/openReloadZones', AnalysisView.analysis_openReloadZones), diff --git a/app/utils/Config.py b/app/utils/Config.py index b689c07..633b04f 100644 --- a/app/utils/Config.py +++ b/app/utils/Config.py @@ -4,6 +4,7 @@ import os from urllib.parse import quote from framework.settings import BASE_DIR +from monitor_runtime.paths import RESOURCE_ROOT, DESKTOP from app.utils.Secrets import get_runtime_secret @@ -85,7 +86,9 @@ class Config: # —— 路径与工具 —— self.ffmpeg = str(os.environ.get("MONITOR_FFMPEG") or config_data.get("ffmpeg", "ffmpeg") or "ffmpeg").strip() - self.fontPath = _resolve_path(config_data.get("fontPath", ""), base) + if DESKTOP: + self.ffmpeg = str(RESOURCE_ROOT / "tools" / "ffmpeg.exe") + self.fontPath = _resolve_path(config_data.get("fontPath", ""), str(RESOURCE_ROOT)) self.uploadDir = _resolve_path(config_data.get("uploadDir", "static/upload"), base) self.storageDir = _resolve_path(config_data.get("storageDir", "static/storage"), base) @@ -95,9 +98,13 @@ class Config: self.mediaRtmpPort = _int(config_data.get("mediaRtmpPort"), 10935) self.isEnableMediaProxyRtmp = _bool(config_data.get("isEnableMediaProxyRtmp"), False) self.mediaSecret = get_runtime_secret("media_secret") - self.mediaStartPath = _resolve_path(config_data.get("mediaStartPath", ""), base) + self.mediaStartPath = _resolve_path(config_data.get("mediaStartPath", ""), str(RESOURCE_ROOT)) + if DESKTOP: + self.mediaStartPath = str(RESOURCE_ROOT / "zlm" / "monitor_zlm.exe") self.mediaStartConfigPath = _resolve_path(config_data.get("mediaStartConfigPath", ""), base) self.autoStartMedia = _bool(config_data.get("autoStartMedia"), False) + if DESKTOP: + self.autoStartMedia = True self.adminHost = "http://" + self.internalHost + ":" + str(self.adminPort) self.mediaHttpHost = "http://" + self.internalHost + ":" + str(self.mediaHttpPort) @@ -107,7 +114,7 @@ class Config: self.analysisConfThreshold = _float(config_data.get("analysisConfThreshold"), 0.4) self.analysisProcessMode = _int(config_data.get("analysisProcessMode"), 1) self.analysisSharedInference = _bool(config_data.get("analysisSharedInference"), True) - self.analysisInferenceWorkers = max(1, _int(config_data.get("analysisInferenceWorkers"), 2)) + self.analysisInferenceWorkers = max(1, _int(config_data.get("analysisInferenceWorkers"), 1)) # —— 录像 —— self.recordingEnabled = _bool(config_data.get("recordingEnabled"), False) diff --git a/app/utils/GB28181SipServer.py b/app/utils/GB28181SipServer.py index 8b73745..7aa98de 100644 --- a/app/utils/GB28181SipServer.py +++ b/app/utils/GB28181SipServer.py @@ -286,6 +286,8 @@ class GB28181SipServer: self.running = False self.devices: Dict[str, Device] = {} self.lock = threading.Lock() + # 分进程模式下(web进程无实时注册表)外发控制指令用的事件序号兜底 + self._fallback_sn = 0 # TCP连接池(SIP信令TCP模式使用) self.tcp_connections: Dict[tuple, socket.socket] = {} # {(ip, port): client_socket} @@ -371,7 +373,13 @@ class GB28181SipServer: else: # UDP模式:直接sendto try: - self.sock.sendto(encoded_data, addr) + if self.sock is None: + # 分进程模式:本进程未绑定SIP端口(如web进程), + # 为外发设备控制指令(MESSAGE)临时创建UDP socket + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as tmp_sock: + tmp_sock.sendto(encoded_data, addr) + else: + self.sock.sendto(encoded_data, addr) return True except Exception as e: self.logger.error(f"[GSS] UDP发送失败 {addr}: {e}") @@ -1223,7 +1231,11 @@ class GB28181SipServer: to_tag = to_header.split('tag=')[-1].strip() # 更新通道状态为推流中 - self._update_channel_streaming_by_callid(call_id, streaming=True, forward_state=1, inviting=False, to_tag=to_tag) + ch = self._update_channel_streaming_by_callid(call_id, streaming=True, forward_state=1, inviting=False, to_tag=to_tag) + + # 同步 forward_state=1 回 DB(前端播放按钮依赖此字段) + if ch and self.admin_host: + threading.Thread(target=ch.update_admin, args=(self,), daemon=True).start() # 发送ACK self._send_ack(message, addr) @@ -1414,9 +1426,11 @@ class GB28181SipServer: """ 根据Call-ID释放通道资源 - Args: - call_id: INVITE会话的Call-ID + Returns: + 被释放的 Channel 对象;未找到时返回 None """ + released_channel = None + # 清理pending invite channel_id_to_clean = None with self.pending_invites_lock: @@ -1449,44 +1463,51 @@ class GB28181SipServer: channel.streaming = False channel.forward_state = 0 channel.inviting = False + released_channel = channel self.logger.debug(f"[GSS] ✓ 已重置通道状态: {channel_id_to_clean}") - with self.lock: - for device in self.devices.values(): - for channel in device.channels: - if channel.call_id == call_id: - channel_id = channel.channel_id - rtp_port = channel.allocated_rtp_port # 使用分配的端口 - - # 释放RTP端口 - if rtp_port > 0: - self.rtp_port_mgr.release(rtp_port) - - # 关闭ZLM RTP服务器 - if channel_id: - self._close_rtp_server(channel_id) - - # 重置通道状态 - channel.rtp_port = 0 - channel.allocated_rtp_port = 0 - channel.call_id = "" - channel.from_tag = "" - channel.to_tag = "" - channel.streaming = False - channel.forward_state = 0 - channel.inviting = False # 清除inviting标志 - return + if released_channel is None: + with self.lock: + for device in self.devices.values(): + for channel in device.channels: + if channel.call_id == call_id: + channel_id = channel.channel_id + rtp_port = channel.allocated_rtp_port # 使用分配的端口 + + # 释放RTP端口 + if rtp_port > 0: + self.rtp_port_mgr.release(rtp_port) + + # 关闭ZLM RTP服务器 + if channel_id: + self._close_rtp_server(channel_id) + + # 重置通道状态 + channel.rtp_port = 0 + channel.allocated_rtp_port = 0 + channel.call_id = "" + channel.from_tag = "" + channel.to_tag = "" + channel.streaming = False + channel.forward_state = 0 + channel.inviting = False # 清除inviting标志 + released_channel = channel + break + if released_channel: + break + + # 同步 forward_state=0 回 DB(停止推流后前端播放按钮应恢复为未启用态) + if released_channel and self.admin_host: + threading.Thread(target=released_channel.update_admin, args=(self,), daemon=True).start() + + return released_channel def _update_channel_streaming_by_callid(self, call_id, streaming=False, forward_state=0, inviting=None, to_tag=None): """ 根据Call-ID更新通道推流状态 - Args: - call_id: INVITE会话的Call-ID - streaming: 是否正在推流 - forward_state: 转发状态 0:未转发 1:转发中 - inviting: 是否正在INVITE中(None表示不修改此状态) - to_tag: INVITE 200 OK中的To tag(None表示不修改) + Returns: + 更新后的 Channel 对象;未找到时返回 None """ with self.lock: for device in self.devices.values(): @@ -1498,7 +1519,8 @@ class GB28181SipServer: channel.inviting = inviting if to_tag is not None: channel.to_tag = to_tag - return + return channel + return None def _send_401_unauthorized(self, request, addr): """发送401 Unauthorized响应""" @@ -2301,18 +2323,25 @@ class GB28181SipServer: self.catalog_locks[device_id] = threading.Lock() return self.catalog_locks[device_id] - def remove_channel(self, channel_id): + def remove_channel(self, channel_id, device_id=None): """ 从内存中删除指定通道(当用户在UI删除流时调用)。 防止parse_catalog_response的old_channel_map缓存抑制update_admin, 导致下级重新注册后上级看不到通道数据。 幂等操作,通道不存在时不报错。 + + Args: + channel_id: 通道ID(GB28181 channelId) + device_id: 可选,指定从哪台设备下删除该通道。 + 当多台设备共享同一 channel_id(非法但会发生)时, + 仅删指定 device_id 下的通道,不影响其他设备的同名通道。 """ if not channel_id: return removed_count = 0 with self.lock: - for device in self.devices.values(): + target_devices = [self.devices[device_id]] if device_id and device_id in self.devices else list(self.devices.values()) + for device in target_devices: # 递归清理通道(含子目录) def _remove_from_list(channels): nonlocal removed_count @@ -2327,7 +2356,7 @@ class GB28181SipServer: return kept device.channels = _remove_from_list(device.channels) if removed_count > 0: - self.logger.debug(f"[GSS] 已从内存清理通道: {channel_id} (共{removed_count}处)") + self.logger.debug(f"[GSS] 已从内存清理通道: {channel_id}" + (f" (device={device_id})" if device_id else "") + f" (共{removed_count}处)") def parse_catalog_response(self, body, device_id, parent_id=""): """ @@ -2431,8 +2460,21 @@ class GB28181SipServer: self.logger.debug(f"[GSS] {type_icon} 通道: {ch_id} ({name}) [{status}]") # 自动同步到rebekah_admin(与C++版本逻辑一致) - # 【关键修复】只在通道首次出现时调update_admin,防止多次Catalog响应并发导致重复插入 - if self.admin_host and ch_id not in old_channel_map: + # 【关键修复 v3】 + # a) 已存在通道:保留旧的推流态(forward_state/streaming/to_tag),防止Catalog刷新把 DB 里"转发中"覆盖回 0 + # b) 每次 Catalog 都调 update_admin(幂等安全),让 DB 里的 forward_state 与 Channel 内存态一致 + existing_ch = old_channel_map.get(ch_id) + if existing_ch: + # 保留推流相关状态,不要被新 Channel 的 forward_state=0 覆盖 + channel.streaming = existing_ch.streaming + channel.forward_state = existing_ch.forward_state + channel.call_id = existing_ch.call_id + channel.from_tag = existing_ch.from_tag + channel.to_tag = existing_ch.to_tag + channel.inviting = existing_ch.inviting + channel.invite_error = getattr(existing_ch, 'invite_error', None) + channel.auto_invited = existing_ch.auto_invited + if self.admin_host: threading.Thread( target=channel.update_admin, args=(self,), @@ -3021,15 +3063,20 @@ class GB28181SipServer: self.logger.error(f"[GSS] ❌ 清理BYE资源异常: {cleanup_e}") return False, f"bye error: {str(e)}" - def request_ptz(self, client_id, channel_id, ptz_type, val): + def request_ptz(self, client_id, channel_id, ptz_type, val, device_ip=None, device_port=None): """ 发送PTZ云台控制指令 Args: client_id: 设备ID channel_id: 通道ID - ptz_type: PTZ类型(0-停止, 1-右转, 3-上转, 5-左转, 7-下转, 9-变焦, 10-光圈, 11-聚焦) - val: 速度/值 + ptz_type: PTZ类型(0-停止, 1-上, 2-下, 3-左, 4-右, + 5-左上, 6-右上, 7-左下, 8-右下, + 9-变倍+/放大, 10-变倍-/缩小, + 11-聚焦+调远, 12-聚焦-调近, 13-光圈+放大, 14-光圈-缩小) + val: 速度/值 (0-255,方向取水平/垂直速度;变倍按1-15档折算) + device_ip: 设备IP(可选;分进程模式下本进程无实时注册表时由调用方传入) + device_port: 设备SIP端口(可选,配合 device_ip) Returns: tuple: (success: bool, msg: str) @@ -3038,20 +3085,27 @@ class GB28181SipServer: # 查找设备和通道,并在锁内更新SN(避免竞态条件) with self.lock: device = self.devices.get(client_id) - if not device: - return False, "client not registered" + if device: + channel = self._find_channel(device.channels, channel_id) + if not channel: + return False, "channel not found" - channel = self._find_channel(device.channels, channel_id) - if not channel: - return False, "channel not found" + # 在锁内更新SN + channel.sn += 1 + sn = channel.sn - # 在锁内更新SN - channel.sn += 1 - sn = channel.sn - - # 保存设备信息 - device_ip = device.ip - device_port = device.port + # 保存设备信息 + if device.ip: + device_ip = device.ip + if device.port: + device_port = device.port + else: + # 分进程兜底:本进程无实时注册表(如 web 进程), + # 使用调用方从数据库传入的设备地址(pull_stream_ip/port) + if not device_ip or not device_port: + return False, "client not registered" + self._fallback_sn += 1 + sn = self._fallback_sn # 构建PTZ指令(GB28181标准Hex编码) ptz_cmd = self._build_ptz_command(ptz_type, val) @@ -3089,13 +3143,17 @@ class GB28181SipServer: # 发送PTZ指令 addr = (device_ip, device_port) - self._send_sip_response(msg, addr) + send_ok = self._send_sip_response(msg, addr) - ptz_names = {0: '停止', 1: '右转', 3: '上转', 5: '左转', 7: '下转', 9: '变焦', 10: '光圈', 11: '聚焦'} + ptz_names = {0: '停止', 1: '上', 2: '下', 3: '左', 4: '右', 5: '左上', + 6: '右上', 7: '左下', 8: '右下', 9: '放大/变倍+', 10: '缩小/变倍-', + 11: '聚焦+', 12: '聚焦-', 13: '光圈+', 14: '光圈-'} ptz_name = ptz_names.get(ptz_type, f'类型{ptz_type}') - self.logger.debug(f"[GSS] 🎮 已发送PTZ指令: {channel_id} - {ptz_name} (val={val})") - - return True, "success" + if send_ok: + self.logger.debug(f"[GSS] 🎮 已发送PTZ指令: {channel_id} - {ptz_name} (val={val}) ptzCmd={ptz_cmd} -> {addr[0]}:{addr[1]}") + return True, "success" + self.logger.error(f"[GSS] 🎮 PTZ指令发送失败: {channel_id} - {ptz_name} -> {addr[0]}:{addr[1]}") + return False, f"ptz message send failed: {channel_id}" except Exception as e: self.logger.error(f"[GSS] PTZ请求异常: {e}") @@ -3103,44 +3161,57 @@ class GB28181SipServer: def _build_ptz_command(self, ptz_type, val): """ - 构建PTZ控制指令(GB28181标准Hex格式) + 构建PTZ控制指令(GB/T 28181 PTZCmd 8字节Hex格式) + + 帧格式: A5 0F 01 <指令码> <水平速度> <垂直速度> <变倍速度> <校验码> + 校验码 = 第1~7字节累加后取低8位(含 0xA5) + 指令码(字节4): + 方向位: 右=0x01 左=0x02 下=0x04 上=0x08(斜向为位或组合) + 变倍: 放大=0x10 缩小=0x20 + 聚焦/光圈(0x40族, 是否响应取决于设备固件): + 聚焦+=0x44 聚焦-=0x48 光圈+=0x41 光圈-=0x42 + 速度: 字节5=水平速度(左右), 字节6=垂直速度(上下), + 字节7高4位=变倍速度(0~15), 无动作的方向速度填0 + 说明: 方向/变倍均为“持续动作”,需另发 停止(0) 指令结束。 Args: - ptz_type: PTZ类型 + ptz_type: PTZ类型(与 request_ptz 编号一致) val: 速度/值 (0-255) Returns: - str: Hex编码的PTZ指令 + str: 大写Hex编码的PTZ指令 """ - # GB28181 PTZ指令格式:A5 0F [命令字节] [参数] [校验] - # 简化版:使用标准位置控制 + speed = min(max(int(val or 0), 0), 255) # 水平/垂直速度 0-255 + # 变倍速度折算到高4位等级 1~15 + zoom_level = min(max((speed + 7) // 8, 1), 15) if speed > 0 else 0 - speed = min(max(val, 0), 255) # 限制0-255 + # 指令码映射 + dir_cmd = {1: 0x08, 2: 0x04, 3: 0x02, 4: 0x01, + 5: 0x0A, 6: 0x09, 7: 0x06, 8: 0x05} # 上/下/左/右/左上/右上/左下/右下 + zoom_cmd = {9: 0x10, 10: 0x20} # 放大/缩小 + fi_cmd = {11: 0x44, 12: 0x48, 13: 0x41, 14: 0x42} # 聚焦+/聚焦-/光圈+/光圈- - # 命令字节映射 - cmd_map = { - 0: 0x00, # 停止 - 1: 0x02, # 右转 - 2: 0x0A, # 右上 - 3: 0x08, # 上转 - 4: 0x09, # 左上 - 5: 0x04, # 左转 - 6: 0x0C, # 左下 - 7: 0x10, # 下转 - 8: 0x06, # 右下 - 9: 0x20, # 变焦 - 10: 0x40, # 光圈 - 11: 0x10 # 聚焦 - } + if ptz_type == 0: + cmd_byte, pan_speed, tilt_speed, zoom_byte = 0x00, 0, 0, 0x00 + elif ptz_type in dir_cmd: + cmd_byte = dir_cmd[ptz_type] + pan_speed = speed if cmd_byte & 0x03 else 0 # 含左右方向才给水平速度 + tilt_speed = speed if cmd_byte & 0x0C else 0 # 含上下方向才给垂直速度 + zoom_byte = 0x00 + elif ptz_type in zoom_cmd: + cmd_byte = zoom_cmd[ptz_type] + pan_speed, tilt_speed = 0, 0 + zoom_byte = zoom_level << 4 + elif ptz_type in fi_cmd: + cmd_byte = fi_cmd[ptz_type] + pan_speed, tilt_speed, zoom_byte = 0, 0, 0x00 + else: + # 未知类型按停止处理,避免误动作 + cmd_byte, pan_speed, tilt_speed, zoom_byte = 0x00, 0, 0, 0x00 - cmd_byte = cmd_map.get(ptz_type, 0x00) + ptz_bytes = bytes([0xA5, 0x0F, 0x01, cmd_byte, pan_speed, tilt_speed, zoom_byte]) + # 计算校验和(含帧头0xA5) + ptz_bytes += bytes([sum(ptz_bytes) & 0xFF]) - # 构建指令:A5 0F 01 [cmd] [speed1] [speed2] [00] [校验] - ptz_bytes = bytes([0xA5, 0x0F, 0x01, cmd_byte, speed, speed, 0x00]) - - # 计算校验和 - checksum = sum(ptz_bytes) & 0xFF - ptz_bytes += bytes([checksum]) - - # 转换为Hex字符串 + # 转换为大写Hex字符串 return ptz_bytes.hex().upper() diff --git a/app/utils/LanguageUtils.py b/app/utils/LanguageUtils.py index 4166add..b195c5e 100644 --- a/app/utils/LanguageUtils.py +++ b/app/utils/LanguageUtils.py @@ -1,6 +1,7 @@ import json import os from framework.settings import BASE_DIR +from monitor_runtime.paths import RESOURCE_ROOT LANG_UI_DICT = {} LANG_UI_JSON_CACHE = {} @@ -34,7 +35,7 @@ def _lang_files_mtime(): filename = language_config.get("filename", "") if not filename: continue - filepath = os.path.join(BASE_DIR, filename) + filepath = os.path.join(RESOURCE_ROOT, filename) if os.path.exists(filepath): mtimes.append(os.path.getmtime(filepath)) settings_path = os.path.join(BASE_DIR, "settings.json") @@ -61,7 +62,7 @@ def reload_lang_dict(force=False): filename = language_config.get("filename", "") if not filename: continue - filepath_language = os.path.join(BASE_DIR, filename) + filepath_language = os.path.join(RESOURCE_ROOT, filename) try: language_data = _load_json_file(filepath_language) lang_ui_dict = _flatten_lang_dict(language_data) diff --git a/app/utils/MediaServerManager.py b/app/utils/MediaServerManager.py index ae90c20..c8879dd 100644 --- a/app/utils/MediaServerManager.py +++ b/app/utils/MediaServerManager.py @@ -66,10 +66,18 @@ class MediaServerManager(object): if not cfg or not os.path.isfile(cfg): return False, "mediaStartConfigPath 无效或文件不存在: %s" % (cfg or "(空)") if self.api_alive(): - return True, "流媒体服务已在运行" + if self.managed_pid(): + return True, "流媒体服务已在运行" + return False, "流媒体端口被非本实例服务占用;不会接管或结束该进程" - work_dir = os.path.dirname(exe) or str(os.getcwd()) + from monitor_runtime.paths import DESKTOP, data + work_dir = str(data(".runtime/zlm")) if DESKTOP else os.path.dirname(exe) or str(os.getcwd()) + os.makedirs(work_dir, exist_ok=True) cmd = [exe, "-c", cfg] + if DESKTOP: + log_dir = data("log/zlm") + log_dir.mkdir(parents=True, exist_ok=True) + cmd += ["--log-dir", str(log_dir)] logger.info("启动 ZLM: %s", " ".join(cmd)) try: kwargs = {"cwd": work_dir, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL} @@ -88,24 +96,6 @@ class MediaServerManager(object): logger.exception("启动 ZLM 失败") return False, str(e) - def _kill_by_image(self, exe): - if not exe: - return - name = os.path.basename(exe) - try: - if sys.platform == "win32": - subprocess.run( - ["taskkill", "/F", "/IM", name], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, - ) - else: - subprocess.run( - ["pkill", "-f", exe], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, - ) - except Exception as e: - logger.warning("kill_by_image %s: %s", name, e) - def stop(self): from app.services.lifecycle import is_service_leader if not is_service_leader(): @@ -124,10 +114,6 @@ class MediaServerManager(object): pass self._proc = None - if self.api_alive(): - self._kill_by_image(exe) - time.sleep(0.5) - if self.api_alive(): return False, "流媒体服务仍在运行,请检查是否有其他进程占用" return True, "流媒体服务已停止" diff --git a/app/utils/ModelTrust.py b/app/utils/ModelTrust.py index b837813..150c302 100644 --- a/app/utils/ModelTrust.py +++ b/app/utils/ModelTrust.py @@ -5,7 +5,8 @@ import os from pathlib import Path -PROJECT_ROOT = Path(__file__).resolve().parents[2] +from monitor_runtime.paths import DATA_ROOT +PROJECT_ROOT = DATA_ROOT MANIFEST_PATH = Path( os.environ.get("MONITOR_TRUSTED_MODEL_MANIFEST", PROJECT_ROOT / ".trusted-models.json") ).resolve() diff --git a/app/utils/Secrets.py b/app/utils/Secrets.py index 210231c..f05bda9 100644 --- a/app/utils/Secrets.py +++ b/app/utils/Secrets.py @@ -12,7 +12,8 @@ import base64 from pathlib import Path -PROJECT_ROOT = Path(__file__).resolve().parents[2] +from monitor_runtime.paths import DATA_ROOT +PROJECT_ROOT = DATA_ROOT RUNTIME_SECRETS_FILE = Path( os.environ.get("MONITOR_RUNTIME_SECRETS_FILE", PROJECT_ROOT / ".runtime-secrets.json") ).resolve() diff --git a/app/views/AnalysisView.py b/app/views/AnalysisView.py index 35f39d4..c4a957b 100644 --- a/app/views/AnalysisView.py +++ b/app/views/AnalysisView.py @@ -16,6 +16,12 @@ from datetime import datetime from app.models import StreamModel, ZoneModel, AlarmModel +def _preview_owner(request): + if not request.session.session_key: + request.session.save() + return "%s:%s" % (request.user.pk, request.session.session_key) + + def _parse_page_params(request, default_ps=12): page = request.GET.get('p', 1) page_size = request.GET.get('ps', default_ps) @@ -256,6 +262,56 @@ def analysis_openStatus(request): return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data}) +def analysis_openPreviewStart(request): + if request.method != "POST": + return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "msg_method_not_supported")}) + ok, check_msg = f_checkRequestSafe(request) + if not ok: + return f_responseJson({"code": 0, "msg": check_msg}) + try: + params = f_parsePostParams(request) + zone = ZoneModel.objects.select_related("stream").prefetch_related( + "algorithms", "algorithms__small_model", "algorithms__detector_model" + ).get(id=int(params.get("zone_id", 0))) + from app.analysis.preview_sessions import get_preview_registry + data = get_preview_registry().start(_preview_owner(request), zone) + return f_responseJson({"code": 1000, "msg": "ok", "data": data}) + except Exception as e: + return f_responseJson({"code": 0, "msg": str(e)}) + + +def analysis_openPreviewData(request): + if request.method != "GET": + return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "msg_method_not_supported")}) + ok, check_msg = f_checkRequestSafe(request) + if not ok: + return f_responseJson({"code": 0, "msg": check_msg}) + try: + session_id = request.GET.get("session_id", "") + since = request.GET.get("since") + from app.analysis.preview_sessions import get_preview_registry + data = get_preview_registry().data(_preview_owner(request), session_id, since) + return f_responseJson({"code": 1000, "msg": "ok", "data": data}) + except Exception as e: + return f_responseJson({"code": 0, "msg": str(e)}) + + +def analysis_openPreviewStop(request): + if request.method != "POST": + return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "msg_method_not_supported")}) + ok, check_msg = f_checkRequestSafe(request) + if not ok: + return f_responseJson({"code": 0, "msg": check_msg}) + try: + params = f_parsePostParams(request) + from app.analysis.preview_sessions import get_preview_registry + stopped = get_preview_registry().stop(_preview_owner(request), params.get("session_id", "")) + return f_responseJson({"code": 1000 if stopped else 0, + "msg": "ok" if stopped else "预览会话不存在"}) + except Exception as e: + return f_responseJson({"code": 0, "msg": str(e)}) + + def analysis_openStart(request): ret = False msg = LANG_VIEWS_T(request, "msg_unknown_error") diff --git a/app/views/ControlView.py b/app/views/ControlView.py index bfcb57b..d4ae940 100644 --- a/app/views/ControlView.py +++ b/app/views/ControlView.py @@ -109,6 +109,14 @@ def _parse_control_detect_rate(params): return interval, frames +def _parse_alarm_repeat(params, default=30.0): + try: + value = float(params.get("alarm_repeat_sec", default)) + except (TypeError, ValueError): + value = default + return max(0.0, min(86400.0, value)) + + def _control_to_dict(z): algos = [] try: @@ -128,6 +136,7 @@ def _control_to_dict(z): "loiter_threshold": z.loiter_threshold, "detect_interval_sec": interval, "detect_frames": frames, + "alarm_repeat_sec": max(0.0, float(getattr(z, "alarm_repeat_sec", 30) or 0)), "color": z.color, "line_a": getattr(z, "line_a", "") or "", "line_b": getattr(z, "line_b", "") or "", @@ -399,6 +408,7 @@ def control_openAdd(request): loiter_threshold=int(params.get("loiter_threshold", 0)), detect_interval_sec=detect_interval_sec, detect_frames=detect_frames, + alarm_repeat_sec=_parse_alarm_repeat(params), color=params.get("color", "#169F85"), line_a=params.get("line_a", ""), line_b=params.get("line_b", ""), @@ -457,6 +467,8 @@ def control_openEdit(request): interval, frames = _parse_control_detect_rate(params) z.detect_interval_sec = interval z.detect_frames = frames + if "alarm_repeat_sec" in params: + z.alarm_repeat_sec = _parse_alarm_repeat(params) if "color" in params: z.color = params["color"] if "line_a" in params: diff --git a/app/views/InnerlView.py b/app/views/InnerlView.py index a5b8574..a437126 100644 --- a/app/views/InnerlView.py +++ b/app/views/InnerlView.py @@ -82,14 +82,17 @@ def api_on_media_update_stream(request): now_date = datetime.now() - # 【关键修复】按code的细粒度锁,彻底防止并发插入重复编号 - # 不同code完全并发,同一code串行化 - # 注意:SQLite写锁是数据库级的,不能用transaction.atomic(),否则不同code会互相阻塞报"database is locked" - code_lock = _get_code_lock(name) + # 【关键修复 v2】GB28181 多设备共享同一 channel code 时, + # 以 (code + camera_device_id) 复合键查找/锁: + # - 不同 device_id 的同名通道各自独立创建/更新记录 + # - 非 GB28181 场景 camera_device_id 为空字符串,退化为纯 code 匹配 + # 注意:SQLite写锁是数据库级的,不能用transaction.atomic(),否则不同复合键会互相阻塞 + compound_key = f"{name}::{clientId}" + code_lock = _get_code_lock(compound_key) with code_lock: - stream = StreamModel.objects.filter(code=name).first() + stream = StreamModel.objects.filter(code=name, camera_device_id=clientId).first() if stream: - # 编辑(不修改nickname,nickname允许用户自定义) + # 编辑:不修改 nickname(用户自定义) pass else: # 新增 @@ -100,7 +103,7 @@ def api_on_media_update_stream(request): stream.app = app stream.name = name stream.create_time = now_date - stream.add_type = 0 + stream.add_type = 21 if pullStreamType == 21 else 0 # 21=GB28181 自动添加标记 stream.state = 0 stream.nickname = cameraName stream.remark = "" @@ -161,10 +164,17 @@ def api_on_media_delete_stream(request): if not code: raise Exception("The parameter code is invalid") - stream = StreamModel.objects.filter(code=code).first() + # 支持可选 cameraDeviceId:GB28181 多设备共享 channel code 时 + # 按 (code, camera_device_id) 复合键精准删除,避免误删同名不同设备的记录 + camera_device_id = params.get("cameraDeviceId") or "" + if camera_device_id: + stream = StreamModel.objects.filter(code=code, camera_device_id=camera_device_id).first() + else: + stream = StreamModel.objects.filter(code=code).first() + if stream: stream.delete() - g_gb28181SipServer.remove_channel(code) + g_gb28181SipServer.remove_channel(code, device_id=camera_device_id or None) ret = True msg = "success" else: diff --git a/app/views/NvrView.py b/app/views/NvrView.py index e84b94b..c3246c1 100644 --- a/app/views/NvrView.py +++ b/app/views/NvrView.py @@ -99,9 +99,9 @@ def api_openStartRecordVideo(request): return f_responseJson({"code": 0, "msg": "stream not found"}) stream.record_enable = 1 stream.save(update_fields=["record_enable"]) - from app.recording.manager import get_recording_manager - ok, info = get_recording_manager().start_stream(stream) - return f_responseJson({"code": 1000 if ok else 0, "msg": info}) + # 同进程(嵌入式)立即同步启动;分进程则由领导者进程的同步循环异步接管 + _sync_stream_recording(stream) + return f_responseJson({"code": 1000, "msg": "ok"}) def api_openStopRecordVideo(request): @@ -116,9 +116,8 @@ def api_openStopRecordVideo(request): return f_responseJson({"code": 0, "msg": "stream not found"}) stream.record_enable = 0 stream.save(update_fields=["record_enable"]) - from app.recording.manager import get_recording_manager - ok, info = get_recording_manager().stop_stream(stream.id) - return f_responseJson({"code": 1000 if ok else 0, "msg": info}) + _sync_stream_recording(stream) + return f_responseJson({"code": 1000, "msg": "ok"}) def _scan_disk_recordings(stream_id=None): diff --git a/app/views/StreamView.py b/app/views/StreamView.py index 18654fb..d9edef8 100644 --- a/app/views/StreamView.py +++ b/app/views/StreamView.py @@ -891,6 +891,11 @@ def api_openDel(request): if stream: __ret, __msg = GlobalUtils.delStreamProxy(stream, lang=lang) if stream.delete(): + # 同步清理内存里的该通道,避免 Catalog 刷新后自动恢复 + g_gb28181SipServer.remove_channel( + stream_code, + device_id=stream.camera_device_id or None, + ) ret = True msg = LANG_VIEWS_T(request, "msg_success") else: @@ -904,7 +909,7 @@ def api_openDel(request): for stream in streams: __ret, __msg = GlobalUtils.delStreamProxy(stream, lang=lang) if stream.delete(): - g_gb28181SipServer.remove_channel(stream.code) + g_gb28181SipServer.remove_channel(stream.code, device_id=stream.camera_device_id or None) success_count += 1 else: error_count += 1 @@ -1042,24 +1047,39 @@ def api_openPtz(request): g_logger.info("StreamView.openPtz() params:%s" % str(params)) code = params.get("code", "").strip() - ptzType = int(params.get("ptzType", 0)) - val = int(params.get("val", 0)) + try: + ptzType = int(params.get("ptzType", 0)) + except (TypeError, ValueError): + ptzType = -1 + # 优先读取前端实际参数名 ptzSpeed,兼容旧字段 val + try: + val = int(params.get("ptzSpeed", params.get("val", 0)) or 0) + except (TypeError, ValueError): + val = 0 - if ptzType < 0 or ptzType > 11: - raise Exception(LANG_VIEWS_T(request, "ptz_type_range_error")) - - stream = StreamModel.objects.filter(code=code).first() - if stream: - if stream.pull_stream_type == 21: - - ret, msg = g_gb28181SipServer.request_ptz(client_id=stream.camera_device_id, - channel_id=stream.code, - ptz_type=ptzType, - val=val) - else: - msg = LANG_VIEWS_T(request, "stream_pull_type_not_supported") + if ptzType < 0 or ptzType > 14: + msg = LANG_VIEWS_T(request, "ptz_type_range_error") else: - msg = LANG_VIEWS_T(request, "stream_not_found") + # 除停止外均需非零速度才会动作;越界值钳制到0-255 + if ptzType != 0 and val < 1: + val = 32 + val = min(max(val, 0), 255) + + stream = StreamModel.objects.filter(code=code).first() + if stream: + if stream.pull_stream_type == 21: + ret, msg = g_gb28181SipServer.request_ptz( + client_id=stream.camera_device_id, + channel_id=stream.code, + ptz_type=ptzType, + val=val, + # 分进程模式:web进程无实时SIP注册表,从库中取设备地址外发 + device_ip=stream.pull_stream_ip or None, + device_port=stream.pull_stream_port or 0) + else: + msg = LANG_VIEWS_T(request, "stream_pull_type_not_supported") + else: + msg = LANG_VIEWS_T(request, "stream_not_found") else: msg = __check_msg else: diff --git a/config.json b/config.json index d58a5e2..c081632 100644 --- a/config.json +++ b/config.json @@ -8,7 +8,7 @@ "mediaStartPath": "zlm\\bin.x86.windows10\\monitor_zlm.exe", "mediaStartConfigPath": "zlm\\bin.x86.windows10\\config.ini", "autoStartMedia": true, - "ffmpeg": "ffmpeg", + "ffmpeg": "D:\\00_Workspace\\python\\video_monitor\\.venv\\Lib\\site-packages\\imageio_ffmpeg\\binaries\\ffmpeg-win-x86_64-v7.1.exe", "fontPath": "static\\fonts\\tsimhei.ttf", "uploadDir": "static\\upload", "storageDir": "static\\storage", @@ -23,16 +23,16 @@ "analysisConfThreshold": 0.4, "analysisProcessMode": 1, "analysisSharedInference": true, - "analysisInferenceWorkers": 2, - "recordingEnabled": false, + "analysisInferenceWorkers": 1, + "recordingEnabled": true, "recordingSegmentSeconds": 600, "recordingRetainDays": 7, "recordingRetainGb": 0.0, "sipServer": { - "sipServerIp": "192.168.1.7", + "sipServerIp": "192.168.1.60", "sipServerPort": 15060, "sipTransferMode": 0, - "sipServerId": "34020000009000009999", + "sipServerId": "34020000002000000001", "sipServerRealm": "3402000000", "sipServerTimeout": 300, "sipServerExpiry": 1800, diff --git a/deploy/windows/DEPLOYMENT.md b/deploy/windows/DEPLOYMENT.md new file mode 100644 index 0000000..f8a6a2a --- /dev/null +++ b/deploy/windows/DEPLOYMENT.md @@ -0,0 +1,24 @@ +# Monitor Windows 安装说明 + +适用 Windows 10/11 x64、CPU 推理。安装程序包含所需 Python 运行环境、FFmpeg 和 ZLM,无需另外安装这些工具。程序未做代码签名,Windows 可能显示未知发布者提示。 + +1. 使用管理员权限运行 Monitor-Setup-x64.exe。默认程序目录为 Program Files\Monitor。安装时填写实际日常使用的 Windows 账号,只有该账号、管理员和 SYSTEM 获准访问运行数据。 +2. 使用上述日常账号双击桌面 Monitor。首次启动自动打开本机初始化网页;不要分享地址中的一次性凭证。 +3. 导出机器申请文件交给厂商,在本机网页导入厂商签发的授权。设置管理员用户名和至少 12 位密码,选择接入摄像头/局域网的网卡 IP。没有固定默认密码。 +4. 服务自动重启后,本机通过 http://127.0.0.1:10001 登录;同一局域网的其他电脑访问 http://部署电脑IP:10001。视频分析需登录后手动启动。 + +没有互联网连接也能安装和授权;局域网网卡仍需有效 IPv4 地址。安装防火墙规则只允许专用/域网络及本地子网。不要将管理端映射到公网。改变端口后由管理员同步调整防火墙。 + +关闭浏览器不会停止程序。右键托盘 Monitor,可打开网页、查看状态、打开日志、重启或退出。重复双击只打开已有实例。退出会停止本程序的 Web、分析、录像、SIP 和 ZLM;不会按名称结束其他软件的流媒体进程。首次启动失败时检查 ProgramData\Monitor\log。 + +运行数据位于 ProgramData\Monitor,包括数据库、配置、凭据密钥、授权、模型和录像。升级前退出程序并备份整个数据目录;数据库结构升级前还会自动备份。卸载默认保留运行数据。不要单独复制数据库而丢弃加密密钥。 + +安装包不附带模型、摄像头配置或开发数据。导入 .pt 模型前应确认来源;受信任模型登记示例(无需 Python): + +```powershell +& 'C:\Program Files\Monitor\Monitor.exe' --trust-model 'D:\Models\reviewed.pt' --yes +``` + +机器授权不绑定 IP/MAC;换硬件或重装系统后可能需要重新申请。授权过期、错误或明显时间回拨会停止业务,保留本机登录和 /license 续期入口。禁止通过修改系统时间绕过授权。 + +本包通过开发机上的冻结 EXE 自检和隔离服务测试。干净 Windows 10/11、普通用户安装部署、第二台局域网电脑及真实 RTSP/GB28181 摄像头、录像和实际模型仍需现场验收。商业分发前须由发行方核实第三方软件和模型的许可。 diff --git a/deploy/windows/Monitor.iss b/deploy/windows/Monitor.iss new file mode 100644 index 0000000..3f0234e --- /dev/null +++ b/deploy/windows/Monitor.iss @@ -0,0 +1,167 @@ +#ifndef Payload + #error Payload is required +#endif +#ifndef Output + #error Output is required +#endif +[Setup] +AppId={{65B3E62C-937C-4FAA-B1F0-2F1C3D2B9955} +AppName=Monitor +AppVersion=1.003.1 +DefaultDirName={autopf}\Monitor +DefaultGroupName=Monitor +OutputDir={#Output} +OutputBaseFilename=Monitor-Setup-x64 +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +MinVersion=10.0 +PrivilegesRequired=admin +Compression=zip/7 +SolidCompression=no +WizardStyle=modern +UninstallDisplayIcon={app}\Monitor.exe +CloseApplications=no +RestartApplications=no + +[Files] +Source: "{#Payload}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Dirs] +Name: "{commonappdata}\Monitor" + +[Icons] +Name: "{autodesktop}\Monitor"; Filename: "{app}\Monitor.exe"; WorkingDir: "{app}" +Name: "{group}\Monitor"; Filename: "{app}\Monitor.exe"; WorkingDir: "{app}" + +[Run] +Filename: "{app}\Monitor.exe"; Description: "Start Monitor"; Flags: nowait postinstall skipifsilent runasoriginaluser + +[Code] +var OwnerPage: TInputQueryWizardPage; + +procedure InitializeWizard(); +begin + OwnerPage := CreateInputQueryPage(wpSelectDir, 'Monitor Windows account', + 'Choose the Windows account that will run Monitor', + 'Only this account, Administrators and SYSTEM can access the database and keys. Use DOMAIN\username or COMPUTER\username.'); + OwnerPage.Add('Runtime account:', False); + OwnerPage.Values[0] := GetPreviousData('MonitorOwner', GetEnv('USERDOMAIN') + '\' + GetUserNameString); +end; + +procedure RegisterPreviousData(PreviousDataKey: Integer); +begin + SetPreviousData(PreviousDataKey, 'MonitorOwner', OwnerPage.Values[0]); +end; + +procedure ConfigureDataAcl(); +var Code: Integer; UserName: String; +begin + UserName := Trim(OwnerPage.Values[0]); + if UserName = '' then RaiseException('A runtime Windows account is required'); + if Pos('"', UserName) > 0 then RaiseException('Unsupported Windows user name'); + if not Exec(ExpandConstant('{sys}\icacls.exe'), + '"' + ExpandConstant('{commonappdata}\Monitor') + '" /inheritance:r /grant:r "' + + UserName + ':(OI)(CI)M" "*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F"', + '', SW_HIDE, ewWaitUntilTerminated, Code) or (Code <> 0) then + RaiseException('Unable to restrict Monitor data directory permissions'); +end; + +procedure AddFirewallRule(Arguments: String); +var Code: Integer; +begin + if not Exec(ExpandConstant('{sys}\netsh.exe'), Arguments, '', SW_HIDE, ewWaitUntilTerminated, Code) or (Code <> 0) then + RaiseException('Unable to configure Monitor firewall rules. Check Windows Firewall and the installation log.'); +end; + +procedure Firewall(AddRules: Boolean); +var Code: Integer; ExePath: String; +begin + Exec(ExpandConstant('{sys}\netsh.exe'),'advfirewall firewall delete rule name="Monitor Web SIP"','',SW_HIDE,ewWaitUntilTerminated,Code); + Exec(ExpandConstant('{sys}\netsh.exe'),'advfirewall firewall delete rule name="Monitor ZLM TCP"','',SW_HIDE,ewWaitUntilTerminated,Code); + Exec(ExpandConstant('{sys}\netsh.exe'),'advfirewall firewall delete rule name="Monitor ZLM UDP"','',SW_HIDE,ewWaitUntilTerminated,Code); + if not AddRules then Exit; + ExePath := ExpandConstant('{app}\Monitor.exe'); + AddFirewallRule( + 'advfirewall firewall add rule name="Monitor Web SIP" dir=in action=allow program="' + ExePath + + '" protocol=TCP localport=10001,15060 profile=private,domain remoteip=localsubnet'); + AddFirewallRule( + 'advfirewall firewall add rule name="Monitor Web SIP" dir=in action=allow program="' + ExePath + + '" protocol=UDP localport=15060 profile=private,domain remoteip=localsubnet'); + ExePath := ExpandConstant('{app}\_internal\zlm\monitor_zlm.exe'); + AddFirewallRule( + 'advfirewall firewall add rule name="Monitor ZLM TCP" dir=in action=allow program="' + ExePath + + '" protocol=TCP localport=10002,10554,10935,20002-40000 profile=private,domain remoteip=localsubnet'); + AddFirewallRule( + 'advfirewall firewall add rule name="Monitor ZLM UDP" dir=in action=allow program="' + ExePath + + '" protocol=UDP localport=20002-40000 profile=private,domain remoteip=localsubnet'); +end; + +function BackupRuntimeData(): String; +var Root, Backup, Base: String; Files: TArrayOfString; I, Counter: Integer; +begin + Result := ''; + Root := ExpandConstant('{commonappdata}\Monitor'); + if not DirExists(Root) then Exit; + if not FileExists(Root + '\monitor.sqlite3') then Exit; + Base := Root + '\backups\installer-' + GetDateTimeString('yyyymmdd-hhnnss', '-', ':'); + Backup := Base; + Counter := 0; + while DirExists(Backup) do begin + Counter := Counter + 1; + Backup := Base + '-' + IntToStr(Counter); + end; + if not ForceDirectories(Backup) then begin + Result := 'Cannot create the pre-upgrade data backup directory.'; + Exit; + end; + SetArrayLength(Files, 11); + Files[0] := 'monitor.sqlite3'; + Files[1] := 'monitor.sqlite3-wal'; + Files[2] := 'monitor.sqlite3-shm'; + Files[3] := 'config.json'; + Files[4] := 'settings.json'; + Files[5] := 'network.json'; + Files[6] := '.runtime-secrets.json'; + Files[7] := '.trusted-models.json'; + Files[8] := 'license.json'; + Files[9] := 'license-clock.json'; + Files[10] := 'zlm-template.ini'; + for I := 0 to GetArrayLength(Files)-1 do + if FileExists(Root + '\' + Files[I]) then + if not CopyFile(Root + '\' + Files[I], Backup + '\' + Files[I], True) then begin + Result := 'Pre-upgrade backup failed for ' + Files[I] + '. Installation stopped to protect existing data.'; + Exit; + end; +end; + +function PrepareToInstall(var NeedsRestart: Boolean): String; +var Code: Integer; +begin + Result := ''; + if FileExists(ExpandConstant('{app}\Monitor.exe')) then begin + if not Exec(ExpandConstant('{app}\Monitor.exe'),'--stop','',SW_HIDE,ewWaitUntilTerminated,Code) or (Code <> 0) then begin + Result := 'Please exit Monitor before upgrading.'; + Exit; + end; + end; + Result := BackupRuntimeData(); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssPostInstall then begin + ConfigureDataAcl(); + Firewall(True); + end; +end; + +function InitializeUninstall(): Boolean; +var Code: Integer; +begin + Result := Exec(ExpandConstant('{app}\Monitor.exe'),'--stop','',SW_HIDE,ewWaitUntilTerminated,Code) and (Code = 0); +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usUninstall then Firewall(False); +end; diff --git a/deploy/windows/README.md b/deploy/windows/README.md new file mode 100644 index 0000000..ba14ef3 --- /dev/null +++ b/deploy/windows/README.md @@ -0,0 +1,82 @@ +# Monitor Windows 部署 + +## 客户安装与使用 + +支持 Windows 10/11 x64 的 CPU 部署。运行 `Monitor-Setup-x64.exe` 安装,随后双击桌面 Monitor。安装时需要管理员权限,安装器会要求指定日常运行的 Windows 账号(默认当前安装账号);如果使用了另一个管理员账号授权安装,应填写实际操作员的账号。日常启动使用该指定账号。首次启动只监听本机:导出机器申请、导入厂商签名授权、设置至少 12 位管理员密码并选择局域网网卡。完成后自动重启服务,浏览器访问 `http://127.0.0.1:10001/login`;其他电脑使用部署电脑的 IP。 + +程序目录在 `Program Files\Monitor`,运行数据在 `ProgramData\Monitor`。多 Windows 用户共享同一安装需要系统管理员明确授予数据目录权限,安装器不向所有本机用户开放密钥。不要把程序目录当作数据备份。 + +托盘可访问网页、查看状态、打开日志、重启和退出。关闭浏览器不会停止服务;退出托盘或注销 Windows 用户会结束服务。本版不是 Windows 系统服务,不自动开机启动。视频分析需登录后手动开始,已配置的录像和视频代理由后台启动。 + +初始防火墙规则仅作用于专用/域网络、本地子网与本程序。默认端口:Web TCP 10001;SIP TCP/UDP 15060;ZLM TCP 10002、10554、10935;RTP TCP/UDP 20002–40000(含 SIP 分配的 20002–30000 与 ZLM 的 30001–40000)。修改端口或网络网段后由管理员同步调整规则。不要把管理端暴露到公网;公网或不可信网络需要另行部署 HTTPS 与访问限制。 + +安装包不附带开发数据库、摄像头密码、模型、录像或发行私钥。模型通过后台上传;对会反序列化 Python 的 .pt 文件,须先验证来源,再在部署电脑执行: + +```powershell +& 'C:\Program Files\Monitor\Monitor.exe' --trust-model 'C:\ProgramData\Monitor\static\upload\weight\reviewed.pt' --yes +``` + +## 离线签发(仅厂商) + +厂商保留源仓库与签发工具;客户只收到安装包和授权文件。使用 Ed25519,公钥编译关联到发行包,客户程序中没有签发私钥。 + +本次工作目录内的 `vendor-private/issuer.dpapi` 用当前 Windows 用户 DPAPI 加密,配套公钥是 `vendor-private/license-public.pem`。**不得将 vendor-private 发给客户或提交 Git。** DPAPI 私钥只能在原 Windows 用户及可用的原 DPAPI 凭据下解密;仅复制该文件不能保证重装系统后的恢复。长期发行建议由厂商另外生成带口令的可备份 PEM 私钥,并用对应公钥重新构建。 + +生成新发行密钥(不会覆盖已有文件): + +```powershell +& .build-venv/Scripts/python.exe deploy/windows/issue_license.py keygen --private-key '厂商离线目录/issuer.pem' --public-key '厂商离线目录/public.pem' +``` + +签发有期限授权;省略 `--expires` 表示永久: + +```powershell +& .build-venv/Scripts/python.exe deploy/windows/issue_license.py issue --private-key vendor-private/issuer.dpapi --request '客户机器申请.json' --customer '客户名称' --expires '2027-12-31T23:59:59+08:00' --output '客户授权.json' +``` + +机器指纹不依赖 IP/MAC。硬件或系统重装可能需要重新签发。过期、签名错误、机器不符或明显时间回拨会禁止业务;仍可在本机登录并续期。运行中发现失效会停止后台业务并切换为本机授权模式。离线方案无法可靠阻止本机管理员修改程序或回滚整机快照。 + +## 构建 + +需要 Windows x64 Python 3.12、Visual Studio C++ Build Tools、Windows SDK、Inno Setup 6.7.3。依赖来源锁定在 `requirements-windows-cpu.lock.txt` 与 `build-tools.lock.txt`。构建脚本使用独立 `.build-venv`,首次可复制当前已验证的 CPU 环境,再安装独立构建依赖;不会修改 `.venv`。 + +```powershell +./deploy/windows/build.ps1 -PublicKey vendor-private/license-public.pem -PrepareEnvironment +``` + +如工具不在默认路径,提供 `-Vcvars` 和 `-Iscc`。也可分阶段执行: + +```powershell +& .build-venv/Scripts/python.exe deploy/windows/build.py --public-key vendor-private/license-public.pem --phase compile +& .build-venv/Scripts/python.exe deploy/windows/build.py --public-key vendor-private/license-public.pem --phase bundle +& .build-venv/Scripts/python.exe deploy/windows/build.py --public-key vendor-private/license-public.pem --phase installer +``` + +完整构建或 `--phase stage` 必须先执行。暂存区只会清理 `.windows-build` 下对应构建目录。业务模块及授权模块编译为 .pyd;Python 包与 Django 数字迁移保留自动生成的纯导入入口。构建会拒绝回退到未编译业务模块。模板及浏览器 JavaScript 必须随程序提供,不承诺隐藏客户端代码或绝对防逆向。 + +输出为 `dist/windows/Monitor-Setup-x64.exe`、SHA-256 校验文件与运行文件清单。没有代码签名证书时生成未签名安装包,Windows 可能提示未知发布者。可给 `build.py` 提供 `--sign-thumbprint` 使用证书库中的证书;生产签名的时间戳、证书权限由发行方管理。 + +## 数据保护、升级与诊断 + +安装器升级前要求原进程退出,并备份数据库(含存在的 WAL)、配置、密钥及授权;备份失败会中止覆盖安装。数据库迁移前也会自动备份数据库、配置与运行密钥。升级不重置用户、不重新生成已有加密密钥。卸载默认保留 ProgramData 数据。备份应包含整个数据目录,不能只备份 SQLite,否则摄像头密码等加密数据可能无法恢复。本版不自动导入旧开发目录数据库。 + +日志:`ProgramData\Monitor\log\desktop.log`、`service.log`、应用日志;ZLM 工作目录:`.runtime\zlm`。端口冲突不会自动杀死其他进程。服务失败会明确显示状态,不无限重启。 + +可在独立空数据目录运行离线自检(不会接入摄像头或加载客户模型): + +```powershell +$env:MONITOR_DATA_DIR = 'D:\Monitor-validation' +& 'C:\Program Files\Monitor\Monitor.exe' --self-test +``` + +结果写入指定数据目录的 `.runtime/self-test.json`,错误见日志。验证后删除该进程环境变量,避免日常误用测试数据目录。 + +发布前须在干净 Windows 10/11 主机验收安装、普通用户运行、第二台局域网电脑访问、真实 RTSP/GB28181 接入与录像。当前开发机测试不能替代这些环境验收。商业分发须核实 Ultralytics、FFmpeg、ZLM、播放器与模型的许可;编译不会改变第三方许可义务。 + +## 第三方发行检查 + +当前附带 FFmpeg 报告版本为 `7.1-essentials_build-www.gyan.dev`,构建含 `--enable-gpl --enable-version3`。ZLM 二进制随原仓库提供,版本文件标记 `2025/05/11 master`。第三方包的许可文本随运行环境收集在 `third-party-notices`;这些文本不能替代对应源码提供义务或商业授权。当前产物用于部署验证,商业交付前仍须核验所用具体二进制、模型和播放器的完整授权链。 + +参考:[Ultralytics 官方许可](https://www.ultralytics.com/license)、[FFmpeg 官方许可说明](https://ffmpeg.org/legal.html)、[PyInstaller 对 Cython 和源码保护的说明](https://pyinstaller.org/en/stable/operating-mode.html#hiding-the-source-code)。 + +本机自动验证脚本:`tests/test_windows_license.py`(签名与机器约束)、`tests/windows_integration.py`(隔离数据库、CSRF、授权与初始化)、`tests/windows_lifecycle.py`(真实服务、到期续期、端口冲突与 Job Object)。生命周期测试要求显式指定可解密的厂商 DPAPI 测试签发密钥,生成的短期测试授权不得放入安装包。`--self-test` 还执行随机权重 YOLO 的 CPU 推理,仅验证执行链路,不代表模型准确率验收。 diff --git a/deploy/windows/VALIDATION.md b/deploy/windows/VALIDATION.md new file mode 100644 index 0000000..23f87cb --- /dev/null +++ b/deploy/windows/VALIDATION.md @@ -0,0 +1,20 @@ +# Windows 发布验证记录 + +日期:2026-08-31。安装版本 1.003.1(业务版本 1.003),Windows 11 x64 开发机,CPU 构建,无代码签名。 + +已完成: + +- 85 个自有模块编译为 .pyd;18,799 个运行文件完成发布扫描和 SHA-256 清单生成。PYZ 中的 13 个自有入口均为包初始化或迁移纯导入入口,不包含业务实现字节码。 +- 27 项已有安全回归、4 项授权单元测试及 8 组隔离 Web/数据库验证通过。 +- 真正的 Monitor.exe 在移除外部 Python/开发工具 PATH 后通过 Django/CPU Torch/OpenVINO/YOLO 随机权重预测、冻结多进程和资源自检。中文及空格数据路径通过。 +- 真正的 EXE 通过 HTTP 首次设置、CSRF、签名授权、登录、Web/ZLM/SIP 启动、运行中授权到期停止、管理员续期恢复、端口冲突、自有进程正常退出和 Job Object 强制清理。 +- 真正的托盘程序通过同时双击单实例、打开本机页面、--stop 退出与子进程清理。没有自动恢复分析任务。 +- 推理库的实际配置目录断言位于运行数据目录内,预先创建配置与缓存目录,避免 Program Files 写入。 +- 安装器在覆盖前停止程序并备份数据库/WAL、配置、密钥和授权,复制失败则中止;仅编译验证,尚未实测完整安装升级。 +- ONVIF WSDL 单独收集;安装防火墙脚本覆盖 SIP 实际 RTP 分配范围。Inno Setup 6.7.3 安装脚本编译通过。 + +尚不能声明通过:干净 Windows 10/11 安装/升级/卸载、独立普通账号运行及安装 ACL、第二台局域网电脑、真实 RTSP/GB28181 摄像头/录像、客户模型效果。开发机已有组件,PATH 隔离测试不能替代干净系统测试。 + +当前包用于部署验收。商业分发需核实第三方许可;未提供代码签名证书。OpenVINO 扫描提示缺少 NPU 的 ze_loader.dll;CPU 自检通过,本版不承诺 NPU/GPU 支持。 + +详细机器可读结果随包提供 validation-record.json,版本和校验文件见 dist/windows。测试过程只使用 .windows-build 或系统临时目录,没有迁移、打包或修改现有开发数据库与摄像头数据。 diff --git a/deploy/windows/build-tools.lock.txt b/deploy/windows/build-tools.lock.txt new file mode 100644 index 0000000..5a33077 --- /dev/null +++ b/deploy/windows/build-tools.lock.txt @@ -0,0 +1,4 @@ +Cython==3.1.3 +pystray==0.19.5 +whitenoise==6.9.0 +wheel==0.45.1 diff --git a/deploy/windows/build.ps1 b/deploy/windows/build.ps1 new file mode 100644 index 0000000..c31f01c --- /dev/null +++ b/deploy/windows/build.ps1 @@ -0,0 +1,34 @@ +param( + [Parameter(Mandatory=$true)][string]$PublicKey, + [string]$Iscc, + [string]$Vcvars, + [switch]$PrepareEnvironment +) +$ErrorActionPreference = 'Stop' +$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path +$buildPython = Join-Path $projectRoot '.build-venv/Scripts/python.exe' +if ($PrepareEnvironment) { + $sourcePython = Join-Path $projectRoot '.venv/Scripts/python.exe' + if (-not (Test-Path -LiteralPath $sourcePython)) { + throw 'Create the CPU project environment from requirements-windows-cpu.lock.txt first.' + } + if (-not (Test-Path -LiteralPath $buildPython)) { + & $sourcePython -m venv (Join-Path $projectRoot '.build-venv') + if ($LASTEXITCODE -ne 0) { throw 'Build venv creation failed' } + } + # Copy a pinned runtime snapshot; no symlinks and no changes to the developer environment. + $sourceLib = Join-Path $projectRoot '.venv/Lib/site-packages' + $buildLib = Join-Path $projectRoot '.build-venv/Lib/site-packages' + & $sourcePython -c 'import shutil,sys;shutil.copytree(sys.argv[1],sys.argv[2],dirs_exist_ok=True)' $sourceLib $buildLib + if ($LASTEXITCODE -ne 0) { throw 'Runtime snapshot failed' } + & $buildPython -m pip install --no-deps -r (Join-Path $PSScriptRoot 'build-tools.lock.txt') + if ($LASTEXITCODE -ne 0) { throw 'Build tools installation failed' } +} +if (-not (Test-Path -LiteralPath $buildPython)) { throw 'Use -PrepareEnvironment for the first build.' } +& $buildPython -m pip check +if ($LASTEXITCODE -ne 0) { throw 'Dependency check failed' } +$buildArgs = @((Join-Path $PSScriptRoot 'build.py'),'--public-key',(Resolve-Path -LiteralPath $PublicKey).Path) +if ($Iscc) { $buildArgs += @('--iscc',$Iscc) } +if ($Vcvars) { $buildArgs += @('--vcvars',$Vcvars) } +& $buildPython @buildArgs +exit $LASTEXITCODE diff --git a/deploy/windows/build.py b/deploy/windows/build.py new file mode 100644 index 0000000..03fd184 --- /dev/null +++ b/deploy/windows/build.py @@ -0,0 +1,337 @@ +"""Reproducible Windows release build. Run with the independent .build-venv Python.""" +import argparse +import ast +import hashlib +import importlib.metadata +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[2] +WORK = ROOT / '.windows-build' +STAGE = WORK / 'stage' +OWN = ('app', 'framework', 'monitor_runtime') + +def run(args, **kwargs): + print('RUN', subprocess.list2cmdline([str(x) for x in args]), flush=True) + subprocess.run([str(x) for x in args], check=True, **kwargs) + +def clean(directory): + directory = directory.resolve() + if not directory.is_relative_to(WORK.resolve()) or directory == WORK.resolve(): + raise ValueError('Refusing to remove a path outside build staging') + if directory.exists(): + shutil.rmtree(directory) + directory.mkdir(parents=True) + +def compiler_environment(vcvars): + if not vcvars: + candidates = list(Path('C:/Program Files (x86)/Microsoft Visual Studio').glob('*/BuildTools/VC/Auxiliary/Build/vcvars64.bat')) + candidates += list(Path('C:/Program Files/Microsoft Visual Studio').glob('*/*/VC/Auxiliary/Build/vcvars64.bat')) + if not candidates: + raise RuntimeError('Install Visual Studio C++ Build Tools and Windows SDK, or pass --vcvars') + vcvars = str(sorted(candidates)[-1]) + # Generated batch contains only validated local paths, never arbitrary shell input. + if any(c in vcvars for c in '"\r\n%'): + raise ValueError('Unsupported vcvars path') + script = WORK / 'compiler-env.cmd' + script.write_text('@echo off\r\ncall "' + vcvars + '" >nul\r\nif errorlevel 1 exit /b 1\r\nset\r\n', encoding='utf-8') + raw = subprocess.check_output(['cmd.exe', '/d', '/c', str(script)]) + env = {k.upper(): v for k, v in os.environ.items()} + for line in raw.decode('mbcs', errors='replace').splitlines(): + if '=' in line: + key, value = line.split('=', 1) + env[key.upper()] = value + compiler_bin = Path(env['VCTOOLSINSTALLDIR']) / 'bin' / 'Hostx64' / 'x64' + sdk_bin = Path(env['WINDOWSSDKVERBINPATH']) / 'x64' + env['PATH'] = str(compiler_bin) + ';' + str(sdk_bin) + ';' + env['PATH'] + if not (compiler_bin / 'cl.exe').exists(): + raise RuntimeError('C++ compiler is missing: ' + str(compiler_bin)) + env['DISTUTILS_USE_SDK'] = '1' + env['MSSdk'] = '1' + return env + +def verify_environment(): + if sys.version_info[:3] != (3, 12, 13): + raise RuntimeError('This release is pinned to Python 3.12.13 x64') + for lock in (ROOT/'requirements-windows-cpu.lock.txt',ROOT/'deploy/windows/build-tools.lock.txt'): + for line in lock.read_text(encoding='utf-8-sig').splitlines(): + line=line.strip() + if not line or line.startswith('#'): + continue + name,expected=line.split('==',1) + actual=importlib.metadata.version(name) + if actual != expected: + raise RuntimeError(f'Locked dependency mismatch: {name}: {actual} != {expected}') + if '+cpu' not in importlib.metadata.version('torch'): + raise RuntimeError('Only the locked CPU torch build is supported') + +def stage(public_key): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + if not isinstance(serialization.load_pem_public_key(public_key.read_bytes()), Ed25519PublicKey): + raise ValueError('An Ed25519 PUBLIC key is required') + clean(STAGE) + source = WORK / 'sources' + clean(source) + modules = [] + glue = [] + external = set() + for package in OWN: + for path in sorted((ROOT / package).rglob('*.py')): + if '__pycache__' in path.parts or path.name == 'tests.py': + continue + relative = path.relative_to(ROOT) + if not path.stem.isidentifier(): + # Django's numbered migration names need an import-only glue module. + stub = STAGE / relative + stub.parent.mkdir(parents=True, exist_ok=True) + private_name = '_m' + path.stem + stub.write_text('from .' + private_name + ' import Migration\n', encoding='utf-8') + glue.append('.'.join(relative.with_suffix('').parts)) + relative = relative.with_name(private_name + '.py') + body = path.read_text(encoding='utf-8-sig') + if relative.as_posix() == 'monitor_runtime/release.py': + body = 'RELEASE_BUILD = True\n' + tree = ast.parse(body) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + external.update(alias.name for alias in node.names if alias.name.split('.')[0] not in OWN) + if isinstance(node, ast.ImportFrom) and node.module and node.level == 0: + if node.module.split('.')[0] not in OWN: + external.add(node.module) + if path.name == '__init__.py': + meaningful = [n for n in tree.body if not (isinstance(n, ast.Expr) and isinstance(n.value, ast.Constant))] + output = STAGE / relative + output.parent.mkdir(parents=True, exist_ok=True) + glue.append('.'.join(relative.parent.parts)) + output.write_text('from ._package import *\n' if meaningful else '', encoding='utf-8') + if not meaningful: + continue + relative = relative.with_name('_package.py') + output = source / relative + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(body, encoding='utf-8') + modules.append(('.'.join(relative.with_suffix('').parts), str(output))) + manifest = {'modules': modules, 'glue': glue, 'external': sorted(external)} + (WORK / 'modules.json').write_text(json.dumps(manifest, indent=2), encoding='utf-8') + for folder in ('templates',): + shutil.copytree(ROOT / folder, STAGE / folder) + shutil.copytree(ROOT / 'static', STAGE / 'static', ignore=shutil.ignore_patterns('upload', 'storage', '__pycache__')) + shutil.copytree(STAGE / 'static', STAGE / 'public-static') + shutil.copytree(ROOT / 'deploy/windows', STAGE / 'deploy/windows', + ignore=shutil.ignore_patterns('*.py', '*.ps1', '*.iss', '*.md', '__pycache__')) + for path in ROOT.glob('language-*.json'): + shutil.copyfile(path, STAGE / path.name) + shutil.copyfile(public_key, STAGE / 'license-public.pem') + (STAGE / 'zlm').mkdir() + shutil.copyfile(ROOT / 'zlm/bin.x86.windows10/monitor_zlm.exe', STAGE / 'zlm/monitor_zlm.exe') + import imageio_ffmpeg + (STAGE / 'tools').mkdir() + shutil.copyfile(imageio_ffmpeg.get_ffmpeg_exe(), STAGE / 'tools/ffmpeg.exe') + shutil.copyfile(ROOT / 'monitor_entry.py', STAGE / 'monitor_entry.py') + versions = {d.metadata['Name']: d.version for d in importlib.metadata.distributions() if d.metadata['Name']} + (STAGE / 'dependency-versions.json').write_text(json.dumps(versions, indent=2), encoding='utf-8') + # Include installed dependency license notices without copying package source trees. + notices = STAGE / 'third-party-notices' + notices.mkdir() + for distribution in importlib.metadata.distributions(): + name = distribution.metadata['Name'] + for item in distribution.files or []: + if '.dist-info' not in str(item): + continue + if Path(item).name.lower().startswith(('license', 'copying', 'notice')): + target = notices / name / Path(item).name + target.parent.mkdir(exist_ok=True) + origin = distribution.locate_file(item) + if origin.is_file(): + shutil.copyfile(origin, target) + +def compile_modules(vcvars): + script = WORK / 'compile_extensions.py' + script.write_text("""import json +from pathlib import Path +from setuptools import setup, Extension +from Cython.Build import cythonize +work=Path(__file__).resolve().parent +modules=json.loads((work/'modules.json').read_text())['modules'] +extensions=[Extension(name,[source]) for name,source in modules] +setup(name='monitor-private', ext_modules=cythonize(extensions, + build_dir=str(work/'cython'), compiler_directives={ + 'language_level':3,'binding':True,'annotation_typing':False, + 'infer_types':False,'embedsignature':False}, nthreads=0), + script_args=['build_ext','--build-lib',str(work/'stage'),'--build-temp',str(work/'objects'),'-j','2']) +""", encoding='utf-8') + run([sys.executable, script], env=compiler_environment(vcvars), cwd=WORK) + +def bundle(): + manifest = json.loads((WORK / 'modules.json').read_text()) + spec = WORK / 'monitor.spec' + spec.write_text("""import json +from pathlib import Path +from PyInstaller.utils.hooks import collect_all, collect_submodules, copy_metadata +work=Path(SPECPATH) +stage=work/'stage' +manifest=json.loads((work/'modules.json').read_text()) +datas=[] +binaries=[] +hidden=[name for name,_ in manifest['modules']]+manifest['glue']+manifest['external'] +hidden += ['pystray._win32','django.db.backends.sqlite3','django.contrib.auth.hashers', + 'django.contrib.sessions.backends.db','django.template.backends.django'] +for package in ['django','ultralytics','torch','torchvision','onnxruntime','openvino','onvif','pystray','whitenoise']: + d,b,h=collect_all(package) + datas+=d;binaries+=b;hidden+=h +for name in ['templates','static','public-static','deploy','zlm','tools', + 'license-public.pem','dependency-versions.json','third-party-notices']: + child=stage/name + datas.append((str(child),child.name if child.is_dir() else '.')) +for child in stage.glob('language-*.json'): + datas.append((str(child),'.')) +a=Analysis([str(stage/'monitor_entry.py')],pathex=[str(stage)], + binaries=binaries,datas=datas,hiddenimports=sorted(set(hidden)), + excludes=['pytest','IPython','notebook','tkinter','matplotlib.tests'], + noarchive=False) +own=('app','framework','monitor_runtime') +unexpected=[name for name,_,_ in a.pure if name.split('.')[0] in own and name not in manifest['glue']] +if unexpected: + raise RuntimeError('Uncompiled private modules: '+repr(unexpected)) +pyz=PYZ(a.pure) +exe=EXE(pyz,a.scripts,[],exclude_binaries=True,name='Monitor',console=False, + debug=False,upx=False,icon=str(work/'monitor.ico')) +coll=COLLECT(exe,a.binaries,a.datas,strip=False,upx=False,name='Monitor') +""", encoding='utf-8') + from PIL import Image + Image.open(ROOT / 'static/images/logo.png').save(WORK / 'monitor.ico', sizes=[(16,16),(32,32),(48,48),(128,128),(256,256)]) + env = os.environ.copy() + env['PYTHONPATH'] = str(STAGE) + env['DJANGO_SETTINGS_MODULE'] = 'framework.settings' + env['MONITOR_DATA_DIR'] = str(WORK / 'build-data') + env['MONITOR_DESKTOP'] = '1' + env['MONITOR_SERVICE_MODE'] = 'disabled' + env['MONITOR_BOOTSTRAP_SERVICES'] = 'false' + env['CI'] = 'true' + env['YOLO_OFFLINE'] = 'true' + env['YOLO_AUTOINSTALL'] = 'false' + (WORK / 'yolo/Ultralytics').mkdir(parents=True, exist_ok=True) + env['YOLO_CONFIG_DIR'] = str(WORK / 'yolo') + env['PYINSTALLER_CONFIG_DIR'] = str(WORK / 'pyinstaller-cache') + run([sys.executable, '-m', 'PyInstaller', '--noconfirm', '--clean', '--distpath', WORK / 'dist', + '--workpath', WORK / 'pyinstaller', spec], env=env, cwd=STAGE) + finalize_payload() + +def finalize_payload(): + payload = WORK / 'dist/Monitor' + if not (payload/'Monitor.exe').exists(): + raise RuntimeError('Bundle must succeed before payload finalization') + internal = payload/'_internal' + # Extension modules are immutable release code, refreshed after incremental compilation. + for package in OWN: + for compiled in (STAGE/package).rglob('*.pyd'): + target=internal/compiled.relative_to(STAGE) + target.parent.mkdir(parents=True,exist_ok=True) + shutil.copyfile(compiled,target) + # onvif-zeep installs WSDL beside its package, outside collect_all('onvif'). + import onvif + wsdl=Path(onvif.__file__).resolve().parent.parent/'wsdl' + if not (wsdl/'devicemgmt.wsdl').is_file(): + raise RuntimeError('ONVIF WSDL resources are missing from the build environment') + shutil.copytree(wsdl,internal/'wsdl',dirs_exist_ok=True) + audit(payload) + +def audit(folder): + forbidden = {'.runtime-secrets.json','.trusted-models.json','monitor.sqlite3','config.json', + 'license.json','license-clock.json'} + for path in folder.rglob('*'): + relative = path.relative_to(folder) + parts = relative.parts[1:] if relative.parts[0] == '_internal' else relative.parts + if parts and parts[0] in OWN: + if path.suffix.lower() in ('.py','.pyc','.pyo','.c','.cpp','.html'): + raise RuntimeError('Private source/code artifact: '+str(path)) + if path.suffix.lower() == '.log': + raise RuntimeError('Runtime log in release: '+str(path)) + if path.name in forbidden or 'private' in path.name.lower() and path.suffix == '.pem': + raise RuntimeError('Runtime data in release: '+str(path)) + digest = lambda p: hashlib.file_digest(p.open('rb'), 'sha256').hexdigest() + manifest = {str(p.relative_to(folder)):digest(p) for p in folder.rglob('*') if p.is_file()} + (WORK / 'release-files.sha256.json').write_text(json.dumps(manifest, indent=2), encoding='utf-8') + print('Release payload audit passed:', len(manifest), 'files') + +def validate_payload(): + import datetime + payload=WORK/'dist/Monitor' + test_data=WORK/('release-self-test-'+datetime.datetime.now().strftime('%Y%m%d-%H%M%S-%f')) + env=os.environ.copy() + env['MONITOR_DATA_DIR']=str(test_data) + env.pop('PYTHONPATH',None);env.pop('PYTHONHOME',None) + windows=env['SYSTEMROOT'] + env['PATH']=windows+'/System32;'+windows+';'+windows+'/System32/WindowsPowerShell/v1.0' + if not (payload/'_internal/wsdl/devicemgmt.wsdl').is_file(): + raise RuntimeError('Packaged ONVIF resources are missing') + run([payload/'Monitor.exe','--self-test'],env=env,cwd=WORK,timeout=180) + report=test_data/'.runtime/self-test.json' + result=json.loads(report.read_text(encoding='utf-8')) + if result.get('status') != 'passed': + raise RuntimeError('Frozen dependency self-test failed') + shutil.copyfile(report,WORK/'frozen-self-test.json') + + +def installer(iscc, sign_thumbprint): + finalize_payload() + validate_payload() + payload = WORK / 'dist/Monitor' + if not iscc: + candidates = [WORK/'tools/Inno/ISCC.exe',Path('C:/Program Files (x86)/Inno Setup 6/ISCC.exe')] + iscc = next((p for p in candidates if p.exists()), None) + if not iscc: + raise RuntimeError('Inno Setup ISCC.exe is required; pass --iscc') + if sign_thumbprint: + run(['signtool.exe','sign','/sha1',sign_thumbprint,'/fd','SHA256',payload/'Monitor.exe']) + manifest_path=WORK/'release-files.sha256.json' + manifest=json.loads(manifest_path.read_text(encoding='utf-8')) + with (payload/'Monitor.exe').open('rb') as handle: + manifest['Monitor.exe']=hashlib.file_digest(handle,'sha256').hexdigest() + manifest_path.write_text(json.dumps(manifest,indent=2),encoding='utf-8') + output = ROOT / 'dist/windows' + output.mkdir(parents=True,exist_ok=True) + run([iscc, '/DPayload='+str(payload), '/DOutput='+str(output), ROOT/'deploy/windows/Monitor.iss']) + exe = output / 'Monitor-Setup-x64.exe' + if sign_thumbprint: + run(['signtool.exe','sign','/sha1',sign_thumbprint,'/fd','SHA256',exe]) + with exe.open('rb') as handle: + checksum = hashlib.file_digest(handle,'sha256').hexdigest() + (output/'SHA256SUMS.txt').write_text(checksum+' '+exe.name+'\n',encoding='ascii') + shutil.copyfile(WORK/'release-files.sha256.json',output/'release-files.sha256.json') + shutil.copyfile(payload/'_internal/dependency-versions.json',output/'dependency-versions.json') + shutil.copyfile(ROOT/'deploy/windows/DEPLOYMENT.md',output/'DEPLOYMENT.md') + shutil.copyfile(WORK/'frozen-self-test.json',output/'frozen-self-test.json') + print('INSTALLER:', exe) + +def main(): + parser=argparse.ArgumentParser() + parser.add_argument('--public-key',type=Path,required=True) + parser.add_argument('--phase',choices=['all','stage','compile','bundle','finalize','validate','installer'],default='all') + parser.add_argument('--vcvars') + parser.add_argument('--iscc') + parser.add_argument('--sign-thumbprint') + args=parser.parse_args() + if os.name != 'nt' or sys.maxsize <= 2**32: + parser.error('Windows x64 Python is required') + if Path(sys.prefix).resolve() != (ROOT/'.build-venv').resolve(): + parser.error('Run with .build-venv/Scripts/python.exe; do not modify the development environment') + WORK.mkdir(exist_ok=True) + verify_environment() + if args.phase not in ('all','stage'): + if args.public_key.read_bytes() != (STAGE/'license-public.pem').read_bytes(): + parser.error('Public key differs from staged release; run the stage and compile phases first') + if args.phase in ('all','stage'):stage(args.public_key.resolve()) + if args.phase in ('all','compile'):compile_modules(args.vcvars) + if args.phase in ('all','bundle'):bundle() + if args.phase == 'finalize':finalize_payload() + if args.phase == 'validate':validate_payload() + if args.phase in ('all','installer'):installer(args.iscc,args.sign_thumbprint) + +if __name__=='__main__': + main() diff --git a/deploy/windows/default-config.json b/deploy/windows/default-config.json new file mode 100644 index 0000000..486ea94 --- /dev/null +++ b/deploy/windows/default-config.json @@ -0,0 +1,32 @@ +{ + "host": "127.0.0.1", + "adminPort": 10001, + "logDebug": false, + "isEnableLoginCaptcha": true, + "isEnableUpdatePopup": false, + "telemetryEnabled": false, + "updateCheckEnabled": false, + "autoAddStreamProxy": true, + "autoAddStreamProxySleep": 15, + "fontPath": "static/fonts/tsimhei.ttf", + "uploadDir": "static/upload", + "storageDir": "static/storage", + "mediaHttpPort": 10002, + "mediaRtspPort": 10554, + "mediaRtmpPort": 10935, + "mediaStartConfigPath": "zlm-template.ini", + "autoStartMedia": true, + "analysisProcessMode": 1, + "analysisSharedInference": true, + "analysisInferenceWorkers": 1, + "analysisTargetFps": 5, + "recordingEnabled": false, + "sipServer": { + "sipServerIp": "127.0.0.1", + "sipServerPort": 15060, + "sipTransferMode": 0, + "sipServerId": "34020000002000000001", + "sipServerRealm": "3402000000", + "rtpTransferMode": 0 + } +} diff --git a/deploy/windows/default-settings.json b/deploy/windows/default-settings.json new file mode 100644 index 0000000..110598a --- /dev/null +++ b/deploy/windows/default-settings.json @@ -0,0 +1,20 @@ +{ + "lang_default": "zh", + "languages": { + "zh": { + "code": "zh", + "name": "简体中文", + "filename": "language-zh.json", + "oem": { + "name": "视频监控平台", + "welcome": "欢迎", + "logo_url": "/static/images/logo.png", + "bottom_name": "Monitor", + "is_show_author": false, + "author": "", + "author_link": "", + "check_version_download_url": "" + } + } + } +} \ No newline at end of file diff --git a/deploy/windows/issue_license.py b/deploy/windows/issue_license.py new file mode 100644 index 0000000..11ba00d --- /dev/null +++ b/deploy/windows/issue_license.py @@ -0,0 +1,75 @@ +"""Vendor-only offline issuer. Never include this tool or its private key in a customer installer.""" +import argparse +import base64 +from datetime import datetime, timezone +import getpass +import json +from pathlib import Path +import sys +import uuid +sys.path.insert(0,str(Path(__file__).resolve().parents[2])) +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from monitor_runtime.licensing import canonical, verify_document, timestamp + +def main(): + parser=argparse.ArgumentParser() + sub=parser.add_subparsers(dest='command',required=True) + keygen=sub.add_parser('keygen') + keygen.add_argument('--private-key',type=Path,required=True) + keygen.add_argument('--public-key',type=Path,required=True) + keygen.add_argument('--dpapi',action='store_true',help='Encrypt private key for current Windows account') + issue=sub.add_parser('issue') + issue.add_argument('--private-key',type=Path,required=True) + issue.add_argument('--request',type=Path,required=True) + issue.add_argument('--customer',required=True) + issue.add_argument('--expires',help='ISO 8601 time with timezone; omitted means perpetual') + issue.add_argument('--output',type=Path,required=True) + args=parser.parse_args() + if args.command=='keygen': + if args.private_key.exists() or args.public_key.exists(): + parser.error('Refusing to overwrite existing issuer keys') + key=Ed25519PrivateKey.generate() + if args.dpapi: + from monitor_runtime.windows import protect + raw=key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()) + encoded=b'MONITOR-DPAPI-V1\n'+base64.b64encode(protect(raw)) + else: + password=getpass.getpass('Private-key password (minimum 12 characters): ') + if len(password)<12 or password!=getpass.getpass('Confirm password: '): + parser.error('Password too short or confirmation mismatch') + encoded=key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8, + serialization.BestAvailableEncryption(password.encode())) + args.private_key.parent.mkdir(parents=True,exist_ok=True) + args.public_key.parent.mkdir(parents=True,exist_ok=True) + with args.private_key.open('xb') as f:f.write(encoded) + with args.public_key.open('xb') as f: + f.write(key.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo)) + print('Issuer keypair created. Keep the private key offline and backed up.') + return + raw=args.private_key.read_bytes() + if raw.startswith(b'MONITOR-DPAPI-V1\n'): + from monitor_runtime.windows import protect + raw=protect(base64.b64decode(raw.split(b'\n',1)[1]),decrypt=True) + key=serialization.load_pem_private_key(raw,password=None) + else: + key=serialization.load_pem_private_key(raw,password=getpass.getpass('Private-key password: ').encode()) + request=json.loads(args.request.read_text(encoding='utf-8')) + if request.get('product')!='monitor' or request.get('schema_version')!=1: + parser.error('Unsupported machine request') + fingerprint=request.get('fingerprint','') + if not fingerprint.startswith('v1:') or len(fingerprint)!=67 or any(c not in '0123456789abcdef' for c in fingerprint[3:]): + parser.error('Invalid machine fingerprint') + now=datetime.now(timezone.utc) + payload={'schema_version':1,'product':'monitor','license_id':str(uuid.uuid4()),'customer':args.customer, + 'machine_fingerprint':fingerprint,'issued_at':now.isoformat(), + 'expires_at':args.expires} + document={'payload':payload,'signature':base64.b64encode(key.sign(canonical(payload))).decode('ascii')} + public=key.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo) + verify_document(document,public,fingerprint) + args.output.parent.mkdir(parents=True,exist_ok=True) + with args.output.open('x',encoding='utf-8') as f:json.dump(document,f,ensure_ascii=False,indent=2) + print('License signed:',args.output) + +if __name__=='__main__': + main() diff --git a/deploy/windows/zlm-template.ini b/deploy/windows/zlm-template.ini new file mode 100644 index 0000000..fdd1498 --- /dev/null +++ b/deploy/windows/zlm-template.ini @@ -0,0 +1,192 @@ +; auto-generated by mINI class { + +[api] +apiDebug=0 +defaultSnap=./www/logo.png +downloadRoot=./www +secret=INJECTED_AT_RUNTIME +snapRoot=./www/snap/ + +[cluster] +origin_url= +retry_count=3 +timeout_sec=15 + +[ffmpeg] +bin=ffmpeg +cmd=%s -re -i %s -c:a aac -strict -2 -ar 44100 -ab 48k -c:v libx264 -f flv %s +log=./ffmpeg/ffmpeg.log +restart_sec=0 +snap=%s -i %s -y -f mjpeg -frames:v 1 %s + +[general] +check_nvidia_dev=1 +enableVhost=0 +enable_ffmpeg_log=0 +flowThreshold=1024 +maxStreamWaitMS=15000 +mediaServerId=niBEjPnzIrQIS6qU +mergeWriteMS=0 +resetWhenRePlay=1 +streamNoneReaderDelayMS=20000 +unready_frame_cache=100 +wait_add_track_ms=3000 +wait_track_ready_ms=10000 + +[hls] +broadcastRecordTs=0 +deleteDelaySec=10 +fastRegister=0 +fileBufSize=65536 +segDur=2 +segKeep=0 +segNum=3 +segRetain=5 + +[hook] +alive_interval=30.000000 +enable=1 +on_flow_report= +on_http_access= +on_play= +on_publish= +on_record_mp4= +on_record_ts= +on_rtp_server_timeout= +on_rtsp_auth= +on_rtsp_realm= +on_send_rtp_stopped= +on_server_exited= +on_server_keepalive= +on_server_started= +on_shell_login= +on_stream_changed= +on_stream_none_reader= +on_stream_not_found= +retry=1 +retry_delay=3.000000 +stream_changed_schemas=rtsp/rtmp/fmp4/ts/hls/hls.fmp4 +timeoutSec=10 + +[http] +allow_cross_domains=1 +allow_ip_range=::1,127.0.0.1,172.16.0.0-172.31.255.255,192.168.0.0-192.168.255.255,10.0.0.0-10.255.255.255 +charSet=gb2312 +dirMenu=1 +forbidCacheSuffix= +forwarded_ip_header= +keepAliveSecond=15 +maxReqSize=40960 +notFound=404 Not Found

您访问的资源不存在!


ZLMediaKit(git hash:/,branch:,build time:2025-05-11T18:28:47)
+port=10002 +rootPath=./www +sendBufSize=65536 +sslport=0 +virtualPath= + +[multicast] +addrMax=239.255.255.255 +addrMin=239.0.0.0 +udpTTL=64 + +[protocol] +add_mute_audio=1 +auto_close=0 +continue_push_ms=15000 +enable_audio=1 +enable_fmp4=1 +enable_hls=0 +enable_hls_fmp4=0 +enable_mp4=0 +enable_rtmp=1 +enable_rtsp=1 +enable_ts=0 +fmp4_demand=0 +hls_demand=1 +hls_save_path=./www +modify_stamp=2 +mp4_as_player=0 +mp4_max_second=3600 +mp4_save_path=./www +paced_sender_ms=0 +rtmp_demand=1 +rtsp_demand=0 +ts_demand=1 + +[record] +appName=record +enableFmp4=0 +fastStart=0 +fileBufSize=65536 +fileRepeat=0 +sampleMS=500 + +[rtc] +bfilter=0 +datachannel_echo=1 +externIP= +maxRtpCacheMS=5000 +maxRtpCacheSize=2048 +max_bitrate=0 +min_bitrate=0 +nackIntervalRatio=1.0 +nackMaxCount=15 +nackMaxMS=3000 +nackMaxSize=2048 +nackRtpSize=8 +port=0 +preferredCodecA=PCMA,PCMU,opus,mpeg4-generic +preferredCodecV=H264,H265,AV1,VP9,VP8 +rembBitRate=0 +start_bitrate=0 +tcpPort=0 +timeoutSec=15 + +[rtmp] +directProxy=1 +enhanced=0 +handshakeSecond=15 +keepAliveSecond=15 +port=10935 +sslport=0 + +[rtp] +audioMtuSize=600 +h264_stap_a=1 +lowLatency=0 +rtpMaxSize=10 +videoMtuSize=1400 + +[rtp_proxy] +dumpDir= +gop_cache=1 +h264_pt=98 +h265_pt=99 +opus_pt=100 +port=30001 +port_range=30002-40000 +ps_pt=96 +timeoutSec=15 + +[rtsp] +authBasic=0 +directProxy=1 +handshakeSecond=15 +keepAliveSecond=15 +lowLatency=0 +port=10554 +rtpTransportType=-1 +sslport=0 + +[shell] +maxReqSize=1024 +port=0 + +[srt] +latencyMul=4 +passPhrase= +pktBufSize=8192 +port=0 +timeoutSec=5 + +; } --- diff --git a/framework/settings.py b/framework/settings.py index 5276b0c..20d5026 100644 --- a/framework/settings.py +++ b/framework/settings.py @@ -14,7 +14,8 @@ from pathlib import Path import os from app.utils.Secrets import get_runtime_secret # Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent +from monitor_runtime.paths import DATA_ROOT, RESOURCE_ROOT, DESKTOP +BASE_DIR = DATA_ROOT PROJECT_UA = "monitor" PROJECT_BUILT = "monitor built on 2026/08/06" PROJECT_VERSION = "1.003" @@ -42,8 +43,15 @@ def env_list(name, default=""): # SECURITY WARNING: don't run with debug turned on in production! # Production-safe by default. Local development must opt in explicitly. DEBUG = env_bool("MONITOR_DEBUG", False) +if DESKTOP: + DEBUG = False ALLOWED_HOSTS = env_list("MONITOR_ALLOWED_HOSTS", "127.0.0.1,localhost") +if DESKTOP: + import json + _network_file = BASE_DIR / 'network.json' + _network = json.loads(_network_file.read_text(encoding='utf-8')) if _network_file.exists() else {} + ALLOWED_HOSTS = ['127.0.0.1', 'localhost'] + _network.get('allowed_hosts', []) # Application definition @@ -66,6 +74,7 @@ MIDDLEWARE = [ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'monitor_runtime.web.LicenseMiddleware', "app.middleware.SimpleMiddleware", # 拦截器 ] @@ -75,7 +84,7 @@ TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [ - os.path.join(BASE_DIR,'templates') + os.path.join(RESOURCE_ROOT,'templates') ], 'APP_DIRS': True, 'OPTIONS': { @@ -144,7 +153,7 @@ TIME_ZONE = 'Asia/Shanghai' """ 静态资源(DEBUG=True:staticfiles 从 STATICFILES_DIRS 直接提供) """ STATICFILES_DIRS = ( - os.path.join(BASE_DIR, "static"), + os.path.join(RESOURCE_ROOT, "static"), ) SESSION_COOKIE_NAME = 'MonitorSessionID' diff --git a/framework/urls.py b/framework/urls.py index 043dda3..f5a6f99 100644 --- a/framework/urls.py +++ b/framework/urls.py @@ -33,3 +33,16 @@ if settings.DEBUG: {'document_root': os.path.join(settings.BASE_DIR, 'static', 'upload')}), ] urlpatterns += staticfiles_urlpatterns() + +# Mutable media is never served by WhiteNoise or the development static handler. +from monitor_runtime import web +urlpatterns = [ + path('setup', web.setup), + path('license', web.license_page), + path('license/status', web.license_status), + path('license/request', web.license_request), + path('license/import', web.license_import), + path('static/storage/', web.private_file), + path('static/upload/audio/', web.private_file, {'area': 'audio'}), + path('upload/audio/', web.private_file, {'area': 'audio'}), +] + urlpatterns diff --git a/language-zh.json b/language-zh.json index c7617cc..e779b43 100644 --- a/language-zh.json +++ b/language-zh.json @@ -56,7 +56,7 @@ "player_copied": "已复制", "player_copy_fail": "复制失败", "port_already_used": "端口 %d 已被占用!", - "ptz_type_range_error": "ptzType 必须在 0-11 之间", + "ptz_type_range_error": "ptzType 必须在 0-14 之间", "stream_analyzer": "算法流", "stream_app_invalid": "应用不存在", "stream_app_name_required": "应用和名称是必填项", diff --git a/monitor_entry.py b/monitor_entry.py new file mode 100644 index 0000000..4e74219 --- /dev/null +++ b/monitor_entry.py @@ -0,0 +1,20 @@ +"""Minimal executable bootstrap; business logic is compiled separately.""" +import multiprocessing +import os +import sys + +if __name__ == '__main__': + multiprocessing.freeze_support() + os.environ['MONITOR_DESKTOP'] = '1' + try: + from monitor_runtime.launcher import main + raise SystemExit(main()) + except SystemExit: + raise + except Exception as exc: + import logging + logging.exception('Monitor failed') + if os.name == 'nt' and '--worker' not in sys.argv and '--self-test' not in sys.argv: + from monitor_runtime.windows import message + message(str(exc), True) + raise SystemExit(1) diff --git a/monitor_runtime/__init__.py b/monitor_runtime/__init__.py new file mode 100644 index 0000000..35cc0e7 --- /dev/null +++ b/monitor_runtime/__init__.py @@ -0,0 +1 @@ +"""Windows desktop deployment support. No service starts on import.""" diff --git a/monitor_runtime/bootstrap.py b/monitor_runtime/bootstrap.py new file mode 100644 index 0000000..2285aa2 --- /dev/null +++ b/monitor_runtime/bootstrap.py @@ -0,0 +1,94 @@ +"""Fresh-install initialization and non-destructive version upgrades.""" +import json +import os +import shutil +import sqlite3 +from datetime import datetime +from monitor_runtime.paths import data, resource, atomic_json + +def prepare_files(): + data('').mkdir(parents=True, exist_ok=True) + for name in ('.runtime', 'log', 'backups', 'static/upload/weight', 'static/upload/audio', + 'static/storage', '.runtime/zlm', '.runtime/ultralytics/Ultralytics', '.runtime/matplotlib'): + data(name).mkdir(parents=True, exist_ok=True) + for target, source in (('config.json', 'deploy/windows/default-config.json'), + ('settings.json', 'deploy/windows/default-settings.json'), + ('zlm-template.ini', 'deploy/windows/zlm-template.ini')): + if not data(target).exists(): + shutil.copyfile(resource(source), data(target)) + os.environ['YOLO_CONFIG_DIR'] = str(data('.runtime/ultralytics')) + os.environ['MPLCONFIGDIR'] = str(data('.runtime/matplotlib')) + os.environ['YOLO_OFFLINE'] = 'true' + os.environ['YOLO_AUTOINSTALL'] = 'false' + os.environ['CI'] = 'true' + +def initialize_database(): + import django + os.environ['DJANGO_SETTINGS_MODULE'] = 'framework.settings' + os.environ['MONITOR_SERVICE_MODE'] = 'disabled' + os.environ['MONITOR_BOOTSTRAP_SERVICES'] = 'false' + django.setup() + from django.core.management import call_command + from django.db import connection + from django.db.migrations.executor import MigrationExecutor + executor = MigrationExecutor(connection) + plan = executor.migration_plan(executor.loader.graph.leaf_nodes()) + db = data('monitor.sqlite3') + if plan and db.exists() and db.stat().st_size: + backup = data('backups') / datetime.now().strftime('%Y%m%d-%H%M%S-%f') + backup.mkdir() + with sqlite3.connect(str(db)) as src, sqlite3.connect(str(backup / db.name)) as dst: + src.backup(dst) + for name in ('config.json', 'settings.json', 'network.json', '.runtime-secrets.json', 'license.json'): + if data(name).exists(): + shutil.copyfile(data(name), backup / name) + call_command('migrate', interactive=False, verbosity=0) + from django.contrib.auth.models import Group + for role in ('system_admin', 'algorithm_admin', 'operator', 'viewer'): + Group.objects.get_or_create(name=role) + from app.utils.Credentials import migrate_existing_credentials + migrate_existing_credentials() + +def has_admin(): + from django.contrib.auth import get_user_model + marker = data('.runtime/initialized.json') + if marker.exists(): + return True + exists = get_user_model().objects.filter(is_superuser=True).exists() + if exists: + atomic_json(marker, {'schema_version': 1, 'initialized': True}) + return exists + +def local_addresses(): + import socket + import psutil + return sorted({a.address for values in psutil.net_if_addrs().values() for a in values + if a.family == socket.AF_INET and not a.address.startswith(('127.', '169.254.')) + and a.address != '0.0.0.0'}) + +def finish_setup(username, password, address): + from django.contrib.auth import get_user_model + from django.contrib.auth.password_validation import validate_password + from django.db import transaction + from monitor_runtime.licensing import require_license + require_license() + if address not in local_addresses(): + raise ValueError('请选择本机有效的局域网 IPv4 地址') + if len(password) < 12: + raise ValueError('管理员密码至少 12 位') + username = username.strip() + if not username or len(username) > 150: + raise ValueError('管理员用户名无效') + user = get_user_model()(username=username) + validate_password(password, user) + with transaction.atomic(): + if has_admin(): + raise ValueError('初始化已完成') + cfg = json.loads(data('config.json').read_text(encoding='utf-8')) + cfg['host'] = address + cfg.setdefault('sipServer', {})['sipServerIp'] = address + atomic_json(data('config.json'), cfg) + atomic_json(data('network.json'), {'allowed_hosts': [address], 'address': address}) + get_user_model().objects.create_superuser(username=username, password=password) + transaction.on_commit(lambda: atomic_json(data('.runtime/initialized.json'), + {'schema_version': 1, 'initialized': True})) diff --git a/monitor_runtime/diagnostics.py b/monitor_runtime/diagnostics.py new file mode 100644 index 0000000..805ffc7 --- /dev/null +++ b/monitor_runtime/diagnostics.py @@ -0,0 +1,67 @@ +"""Offline dependency and frozen-spawn smoke test, with no camera/model access.""" +import json +import multiprocessing +from monitor_runtime.paths import data, resource, atomic_json + +def spawn_probe(queue): + import numpy as np + import cv2 + queue.put({'resized': list(cv2.resize(np.zeros((4,4,3),dtype=np.uint8),(2,2)).shape)}) + +def run(): + from monitor_runtime.bootstrap import prepare_files, initialize_database + prepare_files() + initialize_database() + import django + import cv2 + import numpy as np + import torch + import torchvision + import onnxruntime + import openvino as ov + from ultralytics import YOLO + from ultralytics.utils import USER_CONFIG_DIR + assert USER_CONFIG_DIR.resolve().is_relative_to(data('').resolve()) + import pystray + import whitenoise + result = {'django':django.get_version(),'opencv':cv2.__version__,'torch':torch.__version__, + 'torchvision':torchvision.__version__,'onnxruntime':onnxruntime.__version__, + 'openvino':ov.__version__} + assert torch.version.cuda is None + assert (torch.ones(2,2) @ torch.ones(2,2)).tolist() == [[2.,2.],[2.,2.]] + assert 'CPUExecutionProvider' in onnxruntime.get_available_providers() + # Architecture-only random weights: no model download, no customer model or pickle. + detector = YOLO('yolo26n.yaml', task='detect') + predictions = detector.predict(np.zeros((64,64,3), dtype=np.uint8), imgsz=64, device='cpu', verbose=False) + assert len(predictions) == 1 and predictions[0].orig_shape == (64,64) + result['yolo_cpu_inference'] = 'passed (random weights; no accuracy claim)' + parameter = ov.opset13.parameter([1],np.float32) + model = ov.Model([ov.opset13.relu(parameter)],[parameter]) + compiled = ov.Core().compile_model(model,'CPU') + assert float(compiled([np.array([-1],dtype=np.float32)])[0][0]) == 0 + ctx=multiprocessing.get_context('spawn') + queue=ctx.Queue() + process=ctx.Process(target=spawn_probe,args=(queue,)) + process.start() + try: + assert queue.get(timeout=60)['resized']==[2,2,3] + process.join(timeout=20) + assert process.exitcode==0 + finally: + if process.is_alive(): + process.terminate() + process.join(timeout=10) + queue.close() + from django.test import Client + client=Client(HTTP_HOST='localhost') + response=client.get('/setup',REMOTE_ADDR='127.0.0.1') + assert response.status_code in (200,403) + assert resource('public-static/lib/js/app.js').is_file() + assert resource('zlm/monitor_zlm.exe').is_file() + assert resource('tools/ffmpeg.exe').is_file() + from monitor_runtime.licensing import machine_identity + assert machine_identity()['fingerprint'].startswith('v1:') + result.update(status='passed',spawn='passed',cpu='passed',resources='passed', + writable_caches='ProgramData/test data root') + atomic_json(data('.runtime/self-test.json'),result) + return 0 diff --git a/monitor_runtime/launcher.py b/monitor_runtime/launcher.py new file mode 100644 index 0000000..b5c01fd --- /dev/null +++ b/monitor_runtime/launcher.py @@ -0,0 +1,286 @@ +"""Tray supervisor with authenticated local control and owned process cleanup.""" +import argparse +import json +import logging +import multiprocessing as mp +import os +import secrets +import subprocess +import sys +import threading +import time +import webbrowser +from monitor_runtime.paths import data, resource, atomic_json + +def worker(address, auth): + from multiprocessing.connection import Client + connection = Client(address, family='AF_PIPE', authkey=bytes.fromhex(auth)) + try: + if connection.recv() != 'run': + return 1 + from monitor_runtime.service import run + return run(connection) + finally: + connection.close() + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--worker', nargs=2, metavar=('PIPE', 'AUTH')) + parser.add_argument('--stop', action='store_true') + parser.add_argument('--self-test', action='store_true') + parser.add_argument('--trust-model') + parser.add_argument('--yes', action='store_true') + args = parser.parse_args() + if os.name != 'nt': + raise RuntimeError('Monitor desktop requires Windows') + data('.runtime').mkdir(parents=True, exist_ok=True) + data('log').mkdir(parents=True, exist_ok=True) + logging.basicConfig(filename=str(data('log/desktop.log')), level=logging.INFO, + format='%(asctime)s %(levelname)s %(message)s') + if args.self_test: + from monitor_runtime.diagnostics import run + return run() + if args.worker: + return worker(*args.worker) + if args.trust_model: + return trust_model(args.trust_model, args.yes) + from app.services.lifecycle import ServiceLeaderLock + # Separate from the service lock; held for the entire supervisor lifetime. + lock = ServiceLeaderLock(data('.runtime/desktop.lock')) + if not lock.acquire(): + return control_existing('stop' if args.stop else 'open') + if args.stop: + lock.release() + return 0 + try: + return Supervisor().run() + finally: + lock.release() + +def control_existing(command): + from multiprocessing.connection import Client + deadline = time.monotonic() + 10 + while True: + try: + state = json.loads(data('.runtime/desktop.json').read_text(encoding='utf-8')) + with Client(state['pipe'], family='AF_PIPE', authkey=bytes.fromhex(state['auth'])) as connection: + connection.send(command) + response = connection.recv() + break + except (OSError, EOFError, json.JSONDecodeError): + if time.monotonic() >= deadline: + raise RuntimeError('已有实例的控制接口不可用,请查看日志') + time.sleep(.2) + if command == 'stop': + deadline = time.monotonic() + 40 + from app.services.lifecycle import ServiceLeaderLock + while time.monotonic() < deadline: + lock = ServiceLeaderLock(data('.runtime/desktop.lock')) + if lock.acquire(): + lock.release() + return 0 + time.sleep(.3) + raise RuntimeError('Monitor 未能在 40 秒内退出,请查看日志') + return 0 + +def trust_model(filename, confirmed): + from pathlib import Path + from app.utils.ModelTrust import sha256_file + from monitor_runtime.windows import message + model = Path(filename).resolve() + if model.suffix.lower() != '.pt' or not model.is_file(): + raise ValueError('请选择存在的 .pt 模型文件') + digest = sha256_file(model) + if not confirmed: + message('模型 SHA-256: ' + digest + '\n确认来源可信后使用 --yes 重新执行。') + return 2 + path = data('.trusted-models.json') + doc = json.loads(path.read_text(encoding='utf-8')) if path.exists() else {'trusted_sha256': []} + doc['trusted_sha256'] = sorted(set(doc['trusted_sha256']) | {digest}) + atomic_json(path, doc) + message('已登记可信模型 SHA-256:' + digest) + return 0 + +class Supervisor: + def __init__(self): + self.stop = threading.Event() + self.restart = threading.Event() + self.url = '' + self.status = '正在启动' + self.icon = None + self.child = None + self.pipe = None + self.job = None + + def open_web(self, *_): + if self.url: + webbrowser.open(self.url) + + def exit(self, *_): + self.stop.set() + + def run(self): + import pystray + from PIL import Image + from multiprocessing.connection import Listener + auth = secrets.token_bytes(32) + address = r'\\.\pipe\MonitorControl-' + secrets.token_hex(16) + self.control = Listener(address, family='AF_PIPE', authkey=auth) + atomic_json(data('.runtime/desktop.json'), {'pipe': address, 'auth': auth.hex()}) + threading.Thread(target=self.control_loop, daemon=True).start() + menu = pystray.Menu( + pystray.MenuItem('打开管理网页', self.open_web, default=True), + pystray.MenuItem(lambda _: self.status, lambda *_: None, enabled=False), + pystray.MenuItem('查看状态', lambda *_: self.show_status()), + pystray.MenuItem('打开日志目录', lambda *_: os.startfile(str(data('log')))), + pystray.MenuItem('重启服务', lambda *_: self.restart.set()), + pystray.MenuItem('退出', self.exit)) + self.icon = pystray.Icon('Monitor', Image.open(resource('static/images/logo.png')), 'Monitor', menu) + threading.Thread(target=self.supervise, daemon=True).start() + try: + self.icon.run() + finally: + self.stop.set() + self.control.close() + return 0 + + def show_status(self): + from monitor_runtime.windows import message + message(self.status + '\n' + self.url.split('#')[0]) + + def control_loop(self): + while not self.stop.is_set(): + try: + with self.control.accept() as conn: + command = conn.recv() + if command == 'stop': + self.stop.set() + elif command == 'open': + self.open_web() + conn.send('ok') + except (OSError, EOFError): + return + + def start_child(self): + from multiprocessing.connection import Listener + from monitor_runtime.windows import Job + auth = secrets.token_bytes(32) + address = r'\\.\pipe\MonitorWorker-' + secrets.token_hex(16) + listener = Listener(address, family='AF_PIPE', authkey=auth) + args = [sys.executable] + if not getattr(sys, 'frozen', False): + args.append(str(resource('monitor_entry.py'))) + args += ['--worker', address, auth.hex()] + env = os.environ.copy() + env['MONITOR_DESKTOP'] = '1' + env['MONITOR_SERVICE_MODE'] = 'disabled' + env['MONITOR_BOOTSTRAP_SERVICES'] = 'false' + env['MONITOR_DEBUG'] = 'false' + env['MONITOR_DATA_DIR'] = str(data('')) + self.job = Job() + log = open(data('log/service.log'), 'ab', buffering=0) + try: + self.child = subprocess.Popen(args, cwd=str(data('')), env=env, stdin=subprocess.DEVNULL, + stdout=log, stderr=log, creationflags=subprocess.CREATE_NO_WINDOW) + self.job.assign(self.child) + finally: + log.close() + # Child waits for run; Job assignment therefore precedes any child spawn. + accepted = [] + def accept(): + try: + accepted.append(listener.accept()) + except OSError: + pass + thread = threading.Thread(target=accept, daemon=True) + thread.start() + deadline = time.monotonic() + 30 + while not accepted: + if self.child.poll() is not None or time.monotonic() > deadline or self.stop.wait(.1): + listener.close() + raise RuntimeError('服务进程未能建立控制连接,请查看 service.log') + self.pipe = accepted[0] + listener.close() + self.pipe.send('run') + + def stop_child(self): + if self.child and self.child.poll() is None: + try: + self.pipe.send('stop') + except (AttributeError, EOFError, OSError): + pass + try: + self.child.wait(timeout=25) + except subprocess.TimeoutExpired: + logging.error('Graceful shutdown timed out; closing owned job') + if self.pipe: + self.pipe.close() + self.pipe = None + if self.job: + self.job.close() + self.job = None + self.child = None + + def supervise(self): + try: + while not self.stop.is_set(): + self.restart.clear() + self.status = '正在启动' + self.start_child() + deadline = time.monotonic() + 180 + last_heartbeat = time.monotonic() + opened = False + auto_restart = False + try: + while not self.stop.is_set() and not self.restart.is_set(): + if self.pipe.poll(.3): + message = self.pipe.recv() + last_heartbeat = time.monotonic() + phase = message.get('phase') + if phase == 'failed': + raise RuntimeError(message.get('reason', '服务启动失败')) + self.status = {'ready': '服务运行中', 'setup': '等待首次初始化', + 'license': '等待有效授权'}.get(phase, phase) + if message.get('url'): + self.url = message['url'] + if not opened: + self.open_web() + opened = True + self.icon.update_menu() + code = self.child.poll() + if code is not None: + if code == 20: + auto_restart = True + break + raise RuntimeError('服务进程退出,代码 %s' % code) + if not opened and time.monotonic() > deadline: + raise RuntimeError('启动超过 180 秒,请查看日志') + if opened and time.monotonic() - last_heartbeat > 35: + raise RuntimeError('服务健康状态超时') + except EOFError: + code = self.child.wait(timeout=30) + if code == 20: + auto_restart = True + else: + raise RuntimeError('服务控制连接断开,代码 %s' % code) + finally: + self.stop_child() + if not auto_restart and not self.restart.is_set(): + break + except Exception as exc: + logging.exception('Supervisor failure') + self.status = '启动失败:' + str(exc) + self.url = '' + self.icon.update_menu() + from monitor_runtime.windows import message + message(self.status + '\n日志:' + str(data('log')), True) + self.stop_child() + # Keep tray available; retries are explicit, never an infinite crash loop. + while not self.stop.wait(.3): + if self.restart.is_set(): + self.supervise() + return + finally: + self.stop_child() + if self.stop.is_set(): + self.icon.stop() diff --git a/monitor_runtime/licensing.py b/monitor_runtime/licensing.py new file mode 100644 index 0000000..268e0af --- /dev/null +++ b/monitor_runtime/licensing.py @@ -0,0 +1,144 @@ +"""Offline signed licenses. Private signing material is never used here.""" +import base64 +import hashlib +import json +import os +import subprocess +import threading +import time +from datetime import datetime, timezone +from functools import lru_cache +from pathlib import Path +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from monitor_runtime.paths import DESKTOP, data, public_key_path, atomic_json + +PRODUCT = 'monitor' +MAX_LICENSE_BYTES = 32768 +_GUARD = threading.RLock() + +class LicenseError(ValueError): + pass + +def canonical(payload): + return json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode('utf-8') + +def timestamp(value): + dt = datetime.fromisoformat(value.replace('Z', '+00:00')) + if dt.tzinfo is None: + raise LicenseError('授权时间必须包含时区') + return dt.timestamp() + +@lru_cache(maxsize=1) +def machine_identity(): + if os.name != 'nt': + raise LicenseError('机器授权仅支持 Windows') + import winreg + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r'SOFTWARE\Microsoft\Cryptography', + 0, winreg.KEY_READ | winreg.KEY_WOW64_64KEY) as key: + guid = str(winreg.QueryValueEx(key, 'MachineGuid')[0]).strip().lower() + result = subprocess.run( + ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', + '(Get-CimInstance Win32_ComputerSystemProduct).UUID'], + capture_output=True, timeout=20, creationflags=subprocess.CREATE_NO_WINDOW, check=True) + uuid = result.stdout.decode('utf-8', errors='replace').strip().lower() + import uuid as uuid_module + try: + uuid = str(uuid_module.UUID(uuid)) + except ValueError as exc: + raise LicenseError('无法读取系统 UUID;请检查 Windows CIM 服务') from exc + weak = uuid in ('00000000-0000-0000-0000-000000000000', 'ffffffff-ffff-ffff-ffff-ffffffffffff') + if not guid: + raise LicenseError('无法读取 Windows MachineGuid') + digest = hashlib.sha256(('monitor-machine-v1|' + uuid + '|' + guid).encode()).hexdigest() + return {'fingerprint': 'v1:' + digest, 'weak_uuid': weak} + +def request_document(): + return {'schema_version': 1, 'product': PRODUCT, **machine_identity()} + +def verify_document(document, public_pem, fingerprint, now=None): + now = time.time() if now is None else now + try: + if set(document) != {'payload', 'signature'}: + raise LicenseError('授权文件结构无效') + payload = document['payload'] + required = {'schema_version', 'product', 'license_id', 'customer', 'machine_fingerprint', + 'issued_at', 'expires_at'} + if not isinstance(payload, dict) or set(payload) != required: + raise LicenseError('授权字段无效') + key = serialization.load_pem_public_key(public_pem) + if not isinstance(key, Ed25519PublicKey): + raise LicenseError('发行公钥类型无效') + key.verify(base64.b64decode(document['signature'], validate=True), canonical(payload)) + if payload['schema_version'] != 1 or payload['product'] != PRODUCT: + raise LicenseError('授权版本或产品不匹配') + if payload['machine_fingerprint'] != fingerprint: + raise LicenseError('授权与本机不匹配') + if not all(isinstance(payload[k], str) and 0 < len(payload[k]) <= 200 for k in ('customer', 'license_id')): + raise LicenseError('客户或授权编号无效') + if timestamp(payload['issued_at']) > now + 300: + raise LicenseError('授权尚未生效,或系统时间不正确') + if payload['expires_at'] is not None: + expiry = timestamp(payload['expires_at']) + if expiry <= timestamp(payload['issued_at']) or now >= expiry: + raise LicenseError('授权已过期') + return payload + except LicenseError: + raise + except Exception as exc: + raise LicenseError('授权签名或格式无效') from exc + +def _clock(now): + path = data('license-clock.json') + previous = 0 + if path.exists(): + try: + from monitor_runtime.windows import protect + encoded = json.loads(path.read_text(encoding='utf-8'))['protected'] + previous = float(protect(base64.b64decode(encoded), decrypt=True).decode('ascii')) + except Exception as exc: + raise LicenseError('本地授权时间记录损坏,请恢复备份并检查系统时间') from exc + if now + 300 < previous: + raise LicenseError('检测到系统时间回拨,请校准系统时间') + if now > previous + 60: + from monitor_runtime.windows import protect + encoded = base64.b64encode(protect(str(now).encode('ascii'), machine=True)).decode('ascii') + atomic_json(path, {'protected': encoded}) + +def check_license(): + if not DESKTOP: + return {'valid': True, 'reason': 'source development'} + with _GUARD: + try: + path = data('license.json') + if not path.exists(): + raise LicenseError('尚未导入授权') + if path.stat().st_size > MAX_LICENSE_BYTES: + raise LicenseError('授权文件过大') + pem = public_key_path().read_bytes() + payload = verify_document(json.loads(path.read_text(encoding='utf-8')), + pem, machine_identity()['fingerprint']) + _clock(time.time()) + return {'valid': True, 'reason': '授权有效', 'license': payload} + except Exception as exc: + return {'valid': False, 'reason': str(exc) if isinstance(exc, LicenseError) else '无法读取发行公钥或授权文件'} + +def require_license(): + result = check_license() + if not result['valid']: + raise LicenseError(result['reason']) + +def import_license(raw): + if len(raw) > MAX_LICENSE_BYTES: + raise LicenseError('授权文件过大') + with _GUARD: + try: + document = json.loads(raw) + payload = verify_document(document, public_key_path().read_bytes(), machine_identity()['fingerprint']) + _clock(time.time()) + except LicenseError: + raise + except Exception as exc: + raise LicenseError('无法读取授权文件或发行公钥') from exc + atomic_json(data('license.json'), document) + return payload diff --git a/monitor_runtime/paths.py b/monitor_runtime/paths.py new file mode 100644 index 0000000..9738722 --- /dev/null +++ b/monitor_runtime/paths.py @@ -0,0 +1,46 @@ +"""Separate immutable resources from installation-owned mutable data.""" +import os +import sys +from pathlib import Path +from monitor_runtime.release import RELEASE_BUILD + +RESOURCE_ROOT = Path(getattr(sys, '_MEIPASS', Path(__file__).resolve().parents[1])).resolve() +if not RELEASE_BUILD and not getattr(sys, 'frozen', False) and os.environ.get('MONITOR_DEV_RESOURCE_ROOT'): + RESOURCE_ROOT = Path(os.environ['MONITOR_DEV_RESOURCE_ROOT']).resolve() +DESKTOP = RELEASE_BUILD or bool(getattr(sys, 'frozen', False)) or os.environ.get('MONITOR_DESKTOP') == '1' +DATA_ROOT = Path(os.environ.get('MONITOR_DATA_DIR') or ( + Path(os.environ.get('PROGRAMDATA', 'C:/ProgramData')) / 'Monitor' if DESKTOP else RESOURCE_ROOT +)).resolve() + + +def resource(name): + return RESOURCE_ROOT / name + + +def data(name): + return DATA_ROOT / name + + +def public_key_path(): + if not RELEASE_BUILD and not getattr(sys, 'frozen', False): + override = os.environ.get('MONITOR_DEV_PUBLIC_KEY') + if override: + return Path(override) + return resource('license-public.pem') + + +def atomic_json(path, value): + import json + import tempfile + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix=path.name, suffix='.tmp', dir=path.parent) + try: + with os.fdopen(fd, 'w', encoding='utf-8') as handle: + json.dump(value, handle, ensure_ascii=False, indent=2) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) diff --git a/monitor_runtime/release.py b/monitor_runtime/release.py new file mode 100644 index 0000000..521de1e --- /dev/null +++ b/monitor_runtime/release.py @@ -0,0 +1,2 @@ +"""Replaced with a compiled constant by the release builder.""" +RELEASE_BUILD = False diff --git a/monitor_runtime/service.py b/monitor_runtime/service.py new file mode 100644 index 0000000..b441ad0 --- /dev/null +++ b/monitor_runtime/service.py @@ -0,0 +1,85 @@ +"""One Waitress process owns the background lifecycle and analysis state.""" +import json +import logging +import os +import socket +import threading +import time +from monitor_runtime.paths import data, resource, atomic_json +from monitor_runtime.bootstrap import prepare_files, initialize_database, has_admin +from monitor_runtime.licensing import check_license + +logger = logging.getLogger('monitor.runtime') + +def run(connection): + server = None + manager = None + ready = False + try: + prepare_files() + initialize_database() + from monitor_runtime import web + from django.core.wsgi import get_wsgi_application + from whitenoise import WhiteNoise + from waitress import create_server + from app.utils.GlobalUtils import g_config + from app.services.lifecycle import get_service_manager + manager = get_service_manager() + licensed = check_license()['valid'] + initialized = has_admin() + port = g_config.adminPort + app = WhiteNoise(get_wsgi_application(), root=str(resource('public-static')), prefix='static/') + address = '0.0.0.0' if initialized and licensed else '127.0.0.1' + server = create_server(app, host=address, port=port, threads=8, channel_timeout=60, + max_request_body_size=536870912) + thread = threading.Thread(target=server.run, name='web-server', daemon=True) + thread.start() + if licensed and initialized: + if not manager.start(): + raise RuntimeError('后台服务已被其他进程占用') + phase = 'ready' if licensed and initialized else 'setup' if not initialized else 'license' + web.SERVICE_STATE.update(phase=phase, reason=check_license()['reason']) + url = 'http://127.0.0.1:%d/%s' % (port, 'login' if phase == 'ready' else phase) + if phase == 'setup': + url += '#' + web.SETUP_TOKEN + connection.send({'phase': phase, 'url': url, 'reason': web.SERVICE_STATE['reason']}) + ready = True + last_check = time.monotonic() + failures = 0 + while thread.is_alive(): + if connection.poll(1): + message = connection.recv() + if message == 'stop': + return 0 + if web.RESTART_REQUESTED.is_set(): + return 20 + if time.monotonic() - last_check >= 5: + last_check = time.monotonic() + status = check_license() + if not status['valid'] and manager.is_leader: + web.SERVICE_STATE.update(phase='license', reason=status['reason']) + connection.send({'phase': 'license', 'url': 'http://127.0.0.1:%d/license' % port, + 'reason': status['reason']}) + # Restart loopback-only for renewal and reliably discard all worker descendants. + return 20 + if manager.is_leader: + from app.utils.MediaServerManager import get_media_server_manager + media = get_media_server_manager() + failures = 0 if media.managed_pid() and media.api_alive() else failures + 1 + if failures >= 3: + raise RuntimeError('ZLM 健康检查失败;请查看日志并从托盘重启') + connection.send({'phase': web.SERVICE_STATE['phase'], 'heartbeat': True}) + raise RuntimeError('Web 服务意外退出') + except Exception as exc: + logger.exception('Desktop service failed') + try: + connection.send({'phase': 'failed', 'reason': str(exc)}) + except (EOFError, OSError): + pass + return 1 + finally: + if manager: + manager.stop() + if server: + server.close() + connection.close() diff --git a/monitor_runtime/web.py b/monitor_runtime/web.py new file mode 100644 index 0000000..a96a7d1 --- /dev/null +++ b/monitor_runtime/web.py @@ -0,0 +1,162 @@ +"""Local bootstrap and administrator-only license management.""" +import json +import secrets +import threading +from django.http import HttpResponse, JsonResponse, HttpResponseRedirect, FileResponse +from django.template import engines +from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie +from django.views.decorators.http import require_http_methods +from monitor_runtime.paths import data, resource, DESKTOP +from monitor_runtime.licensing import check_license, import_license, request_document +from monitor_runtime.bootstrap import has_admin, finish_setup, local_addresses + +SETUP_TOKEN = secrets.token_urlsafe(32) +SETUP_LOCK = threading.Lock() +RESTART_REQUESTED = threading.Event() +SERVICE_STATE = {'phase': 'starting', 'reason': ''} + +PAGE = """ +Monitor 安装与授权
+

{% if setup %}首次安装设置{% else %}授权管理{% endif %}

+

{{ status.reason }}{% if status.license %}
客户:{{ status.license.customer }}
+有效期:{{ status.license.expires_at|default:"永久" }}{% endif %}

+{% if error %}

{{ error }}

{% endif %} +

导出本机申请文件,交给发行方签发,再导入签名授权文件。授权绑定当前电脑。

+
{% csrf_token %} +
+
{% csrf_token %} + +
+{% if setup %}
{% csrf_token %} + + + + +请先导入授权。初始化只允许从安装电脑完成;完成后局域网用户才能登录。 +
{% else %}

返回登录 · 进入系统

{% endif %} +
""" + +def is_local(request): + return request.META.get('REMOTE_ADDR') in ('127.0.0.1', '::1') + +def admin_allowed(request): + user = getattr(request, 'user', None) + return bool(user and user.is_authenticated and ( + user.is_superuser or user.groups.filter(name='system_admin').exists())) + +def authorized(request, write=False): + if not has_admin(): + if not is_local(request): + return False + return not write or secrets.compare_digest(request.POST.get('setup_token', ''), SETUP_TOKEN) + return admin_allowed(request) + +def page(request, error='', code=200): + html = engines['django'].from_string(PAGE).render({ + 'setup': not has_admin(), 'status': check_license(), 'error': error, + 'addresses': local_addresses() if not has_admin() else [], + }, request) + response = HttpResponse(html, status=code) + response['Cache-Control'] = 'no-store' + response['Referrer-Policy'] = 'no-referrer' + return response + +@csrf_exempt +@ensure_csrf_cookie +@require_http_methods(['GET', 'POST']) +def setup(request): + if has_admin() or not authorized(request, request.method == 'POST'): + return HttpResponse('初始化入口不可用', status=403) + if request.method == 'POST': + try: + with SETUP_LOCK: + if request.POST.get('password') != request.POST.get('confirm'): + raise ValueError('两次密码输入不一致') + finish_setup(request.POST.get('username', ''), request.POST.get('password', ''), + request.POST.get('address', '')) + RESTART_REQUESTED.set() + return HttpResponse('

设置完成,服务正在重启。稍后重新打开桌面快捷方式进入登录页面。

') + except Exception as exc: + return page(request, str(exc), 400) + return page(request) + +@ensure_csrf_cookie +def license_page(request): + if not authorized(request): + return HttpResponseRedirect('/login') if not getattr(request.user, 'is_authenticated', False) else HttpResponse(status=403) + return page(request) + +@csrf_exempt +@require_http_methods(['POST']) +def license_request(request): + if not authorized(request, True): + return HttpResponse(status=403) + response = JsonResponse(request_document(), json_dumps_params={'indent': 2}) + response['Content-Disposition'] = 'attachment; filename="monitor-license-request.json"' + return response + +@csrf_exempt +@require_http_methods(['POST']) +def license_import(request): + if not authorized(request, True): + return HttpResponse(status=403) + try: + uploaded = request.FILES.get('license') + if not uploaded or uploaded.size > 32768: + raise ValueError('请选择小于 32KB 的授权文件') + import_license(uploaded.read()) + if has_admin(): + RESTART_REQUESTED.set() + return page(request) + except Exception as exc: + return page(request, str(exc), 400) + +def license_status(request): + if not authorized(request): + return HttpResponse(status=403) + return JsonResponse({**check_license(), 'service': SERVICE_STATE}) + +def private_file(request, path, area='storage'): + if not request.user.is_authenticated: + return HttpResponse(status=403) + root = data('static/storage' if area == 'storage' else 'static/upload/audio').resolve() + filename = (root / path).resolve() + allowed = {'.jpg', '.jpeg', '.png', '.mp4', '.wav', '.mp3', '.ogg'} + if not filename.is_relative_to(root) or not filename.is_file() or filename.suffix.lower() not in allowed: + return HttpResponse(status=404) + response = FileResponse(filename.open('rb')) + response['Cache-Control'] = 'private, no-store' + response['X-Content-Type-Options'] = 'nosniff' + return response + +class LicenseMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if not DESKTOP: + return self.get_response(request) + path = request.path_info + # WhiteNoise only serves immutable build-time assets, never mutable data. + public_asset = path.startswith('/static/') and not path.startswith(('/static/storage/', '/static/upload/')) + exceptions = {'/setup', '/license', '/license/status', '/license/request', '/license/import', + '/login', '/logout', '/user/openCaptcha'} + if not has_admin() and not public_asset and path not in {'/setup', '/license', '/license/request', '/license/import', '/license/status'}: + return HttpResponseRedirect('/setup') if is_local(request) else HttpResponse(status=403) + if path not in exceptions and not public_asset: + status = check_license() + if not status['valid']: + if '/open' in path or path.startswith('/inner/'): + return JsonResponse({'code': 0, 'msg': status['reason'], 'error': 'license_invalid'}, status=403) + return HttpResponseRedirect('/license') + return self.get_response(request) diff --git a/monitor_runtime/windows.py b/monitor_runtime/windows.py new file mode 100644 index 0000000..8c3b0ba --- /dev/null +++ b/monitor_runtime/windows.py @@ -0,0 +1,72 @@ +"""Small Win32 helpers, imported lazily on Windows.""" +import ctypes +from ctypes import wintypes +import os + +class Blob(ctypes.Structure): + _fields_ = [('size', wintypes.DWORD), ('ptr', ctypes.POINTER(ctypes.c_byte))] + +def protect(raw, decrypt=False, machine=False): + if os.name != 'nt': + raise RuntimeError('Windows DPAPI is required') + buffer = ctypes.create_string_buffer(raw) + src = Blob(len(raw), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_byte))) + dst = Blob() + crypt = ctypes.WinDLL('crypt32', use_last_error=True) + if decrypt: + ok = crypt.CryptUnprotectData(ctypes.byref(src), None, None, None, None, 1, ctypes.byref(dst)) + else: + ok = crypt.CryptProtectData(ctypes.byref(src), 'Monitor', None, None, None, + 1 | (4 if machine else 0), ctypes.byref(dst)) + if not ok: + raise ctypes.WinError(ctypes.get_last_error()) + try: + return ctypes.string_at(dst.ptr, dst.size) + finally: + kernel = ctypes.WinDLL('kernel32', use_last_error=True) + kernel.LocalFree.argtypes = [ctypes.c_void_p] + kernel.LocalFree(dst.ptr) + +def message(text, error=False): + ctypes.windll.user32.MessageBoxW(None, str(text), 'Monitor', 0x10 if error else 0x40) + +class Job: + """Kill all owned descendants when the supervisor exits.""" + def __init__(self): + class Basic(ctypes.Structure): + _fields_ = [('process_time', ctypes.c_int64), ('job_time', ctypes.c_int64), + ('flags', wintypes.DWORD), ('min_ws', ctypes.c_size_t), + ('max_ws', ctypes.c_size_t), ('active', wintypes.DWORD), + ('affinity', ctypes.c_size_t), ('priority', wintypes.DWORD), + ('scheduling', wintypes.DWORD)] + class IO(ctypes.Structure): + _fields_ = [(n, ctypes.c_uint64) for n in ('ro','wo','oo','rb','wb','ob')] + class Extended(ctypes.Structure): + _fields_ = [('basic', Basic), ('io', IO), ('process_memory', ctypes.c_size_t), + ('job_memory', ctypes.c_size_t), ('peak_process', ctypes.c_size_t), + ('peak_job', ctypes.c_size_t)] + self.kernel = ctypes.WinDLL('kernel32', use_last_error=True) + self.kernel.CreateJobObjectW.restype = wintypes.HANDLE + self.kernel.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR] + self.kernel.SetInformationJobObject.argtypes = [wintypes.HANDLE, ctypes.c_int, + ctypes.c_void_p, wintypes.DWORD] + self.kernel.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE] + self.kernel.CloseHandle.argtypes = [wintypes.HANDLE] + self.handle = self.kernel.CreateJobObjectW(None, None) + if not self.handle: + raise ctypes.WinError(ctypes.get_last_error()) + info = Extended() + info.basic.flags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if not self.kernel.SetInformationJobObject(self.handle, 9, ctypes.byref(info), ctypes.sizeof(info)): + self.close() + raise ctypes.WinError(ctypes.get_last_error()) + + def assign(self, process): + if not self.kernel.AssignProcessToJobObject(self.handle, wintypes.HANDLE(int(process._handle))): + process.kill() + raise ctypes.WinError(ctypes.get_last_error()) + + def close(self): + if self.handle: + self.kernel.CloseHandle(self.handle) + self.handle = None diff --git a/requirements-windows-gpu.txt b/requirements-windows-gpu.txt new file mode 100644 index 0000000..0aab1db --- /dev/null +++ b/requirements-windows-gpu.txt @@ -0,0 +1,6 @@ +# Optional local NVIDIA GPU runtime for Windows / Python 3.12. +# Install after requirements-windows.txt with: +# python -m pip install -r requirements-windows-gpu.txt +--index-url https://download.pytorch.org/whl/cu129 +torch==2.8.0+cu129 +torchvision==0.23.0+cu129 diff --git a/scripts/enable-gpu.ps1 b/scripts/enable-gpu.ps1 new file mode 100644 index 0000000..607dfd3 --- /dev/null +++ b/scripts/enable-gpu.ps1 @@ -0,0 +1,13 @@ +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$python = Join-Path $root '.venv\Scripts\python.exe' +if (-not (Test-Path -LiteralPath $python)) { + throw "Python virtual environment not found: $python" +} +& $python -m pip install --upgrade --force-reinstall --no-deps ` + torch==2.8.0+cu129 torchvision==0.23.0+cu129 ` + --index-url https://download.pytorch.org/whl/cu129 +if ($LASTEXITCODE -ne 0) { throw 'CUDA PyTorch installation failed' } +& $python -c "import torch; assert torch.cuda.is_available(), 'CUDA is unavailable'; print(torch.__version__, torch.version.cuda, torch.cuda.get_device_name(0))" +if ($LASTEXITCODE -ne 0) { throw 'CUDA verification failed' } +Write-Host 'GPU inference runtime is ready. Restart Monitor and select CUDA for the YOLO model.' -ForegroundColor Green diff --git a/scripts/local_webcam.py b/scripts/local_webcam.py index 016698c..6f355a8 100644 --- a/scripts/local_webcam.py +++ b/scripts/local_webcam.py @@ -1,8 +1,4 @@ -"""Explicit Windows webcam service: loopback only, video only, no disk recording. - -Does not call ServiceManager.start(): SIP, auto-proxy, recording and telemetry -remain disabled. Holds the shared leader lock so an embedded service cannot race it. -""" +"""Open the local webcam and register it without starting ZLMediaKit or FFmpeg.""" import argparse import configparser from datetime import datetime @@ -32,9 +28,10 @@ STATUS = WORK / 'status.json' STOP = WORK / 'stop.request' STREAM = 'laptop_cam' DEVICE = 'Integrated Camera' -MARKER = 'Managed by scripts/local_webcam.py; loopback video only' +MARKER = 'Managed by scripts/local_webcam.py; camera registration only; no ZLM' PORTS = {'http': 10002, 'rtsp': 10554, 'rtmp': 10935} PUSH_URL = f'rtmp://127.0.0.1:{PORTS["rtmp"]}/live/{STREAM}' +CAMERA_INDEX = int(os.environ.get('MONITOR_WEBCAM_INDEX', '0')) def write_json(path, data): @@ -167,26 +164,31 @@ def terminate(child): def run(): + """Keep the physical camera open and register an inventory-only stream.""" os.environ.update(local_environment()) - from app.services.lifecycle import ServiceLeaderLock - lock = ServiceLeaderLock() - if not lock.acquire(): - raise RuntimeError('Another background-service leader exists; refusing to start') - media = camera = hook = None - row = None + camera = row = None + StreamModel = None try: web = web_process() if not web: raise RuntimeError('Start the local web server first') - for conn in psutil.net_connections('inet'): - # TIME_WAIT sockets left by a terminated ZLM have no owning - # process (pid 0/None on Windows); only a live owner blocks start. - if conn.laddr and conn.laddr.port in PORTS.values() and conn.pid: - raise RuntimeError('Media port occupied; no unrelated process was stopped') - config_data = json.loads((ROOT / 'config.json').read_text(encoding='utf-8')) - for key, section in [('mediaHttpPort', 'http'), ('mediaRtspPort', 'rtsp'), ('mediaRtmpPort', 'rtmp')]: - if int(config_data[key]) != PORTS[section]: - raise RuntimeError('Configured media ports changed; review local webcam settings first') + import cv2 + camera = cv2.VideoCapture(CAMERA_INDEX, cv2.CAP_DSHOW) + if not camera.isOpened(): + camera.release() + camera = cv2.VideoCapture(CAMERA_INDEX) + if not camera.isOpened(): + raise RuntimeError(f'Camera open failed: index={CAMERA_INDEX}; check Windows camera permission/device availability') + camera.set(cv2.CAP_PROP_FRAME_WIDTH, 640) + camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) + camera.set(cv2.CAP_PROP_FPS, 15) + ok, frame = camera.read() + if not ok or frame is None: + raise RuntimeError('Camera opened but no video frame was received') + width = int(camera.get(cv2.CAP_PROP_FRAME_WIDTH) or 640) + height = int(camera.get(cv2.CAP_PROP_FRAME_HEIGHT) or 480) + fps = float(camera.get(cv2.CAP_PROP_FPS) or 15) + from django.conf import settings database = Path(settings.DATABASES['default']['NAME']) backup_dir = WORK / ('backup-' + datetime.now().strftime('%Y%m%d-%H%M%S-%f')) @@ -195,97 +197,41 @@ def run(): with sqlite3.connect(backup_dir / database.name) as target: source.backup(target) import django - django.setup() # Services explicitly disabled; normal existing schema migration only. + django.setup() from app.models import StreamModel existing = StreamModel.objects.filter(code=STREAM).first() - if existing and (existing.remark != MARKER or existing.pull_stream_type != 32): + if existing and not (existing.remark or '').startswith('Managed by scripts/local_webcam.py;'): raise RuntimeError('laptop_cam belongs to another stream; refusing to overwrite') - from app.utils.Secrets import get_runtime_secret - api_secret = get_runtime_secret('media_secret') - exe = (ROOT / config_data['mediaStartPath']).resolve() - if not exe.is_relative_to(ROOT) or not exe.is_file() or b'listen_ip' not in exe.read_bytes(): - raise RuntimeError('Expected project ZLM binary with listen_ip support') - template = (ROOT / config_data['mediaStartConfigPath']).read_text(encoding='utf-8', errors='replace') - (WORK / 'www').mkdir(exist_ok=True) - hook, hook_url = hook_server() - ini = WORK / 'zlm.ini' - with ini.open('w', encoding='utf-8') as handle: - make_config(template, api_secret, hook_url).write(handle, space_around_delimiters=False) - with (WORK / 'zlm.log').open('ab') as log: - media = subprocess.Popen([str(exe), '-c', str(ini), '-l', '3', '-t', '2', - '--log-dir', str(WORK / 'zlm-logs')], cwd=WORK, - stdin=subprocess.DEVNULL, stdout=log, stderr=log, - creationflags=subprocess.CREATE_NO_WINDOW) - client = requests.Session() - client.trust_env = False - - def api(method, **params): - response = client.post(f'http://127.0.0.1:{PORTS["http"]}/index/api/{method}', - data={'secret': api_secret, **params}, timeout=3) - response.raise_for_status() - return response.json() - - for _ in range(30): - if media.poll() is not None: - raise RuntimeError('ZLM exited; see local-webcam/zlm.log') - listeners = check_connections([psutil.Process(media.pid)], set(PORTS.values())) - if listeners == set(PORTS.values()): - break - time.sleep(.3) - else: - raise RuntimeError('Loopback media listeners not ready') - if api('getThreadsLoad').get('code') != 0: - raise RuntimeError('Media API health check failed') - with (WORK / 'ffmpeg.log').open('ab') as log: - camera = subprocess.Popen(capture_command(os.environ['MONITOR_FFMPEG']), cwd=WORK, - stdin=subprocess.DEVNULL, stdout=log, stderr=log, - creationflags=subprocess.CREATE_NO_WINDOW) - for _ in range(40): - if camera.poll() is not None: - raise RuntimeError('Camera capture failed; see local-webcam/ffmpeg.log') - info = api('getMediaInfo', schema='rtmp', vhost='__defaultVhost__', app='live', stream=STREAM) - if info.get('code') == 0 and info.get('tracks'): - break - time.sleep(.5) - else: - raise RuntimeError('Camera stream readiness timeout') - if any(track.get('codec_type') != 0 for track in info['tracks']): - raise RuntimeError('Unexpected non-video track') + camera_url = f'camera://index/{CAMERA_INDEX}' row, _ = StreamModel.objects.get_or_create(code=STREAM, defaults={ 'user_id': 0, 'sort': 0, 'app': 'live', 'name': STREAM, 'nickname': '笔记本内置摄像头', - 'remark': MARKER, 'pull_stream_type': 32, 'pull_stream_transfer_mode': 0, - 'pull_stream_url': PUSH_URL, 'pull_stream_ip': '127.0.0.1', - 'pull_stream_port': PORTS['rtmp'], 'forward_state': 1, 'is_audio': 0, - 'record_enable': 0, 'state': 0, 'camera_name': DEVICE, 'camera_device_id': 'local-webcam'}) - if row.remark != MARKER: + 'remark': MARKER, 'pull_stream_type': 0, 'pull_stream_transfer_mode': 0, + 'pull_stream_url': camera_url, 'pull_stream_ip': '127.0.0.1', 'pull_stream_port': 0, + 'forward_state': 0, 'is_audio': 0, 'record_enable': 0, 'state': 0, + 'camera_name': DEVICE, 'camera_device_id': 'local-webcam'}) + if not (row.remark or '').startswith('Managed by scripts/local_webcam.py;'): raise RuntimeError('Stream ownership changed') - StreamModel.objects.filter(pk=row.pk).update(forward_state=1, record_enable=0, is_audio=0) - print('Ready: laptop_cam (Integrated Camera, 640x480/15fps, video only)', flush=True) + StreamModel.objects.filter(pk=row.pk).update( + remark=MARKER, pull_stream_type=0, pull_stream_url=camera_url, + pull_stream_ip='127.0.0.1', pull_stream_port=0, forward_state=0, + record_enable=0, is_audio=0, camera_name=DEVICE, camera_device_id='local-webcam') + print(f'Ready: laptop_cam ({DEVICE}, {width}x{height}/{fps:.0f}fps, camera only; ZLM not started)', flush=True) while not STOP.exists(): - if not web.is_running() or media.poll() is not None or camera.poll() is not None: - raise RuntimeError('Web/camera/media process exited; shutting down capture') - managed = [psutil.Process(os.getpid()), psutil.Process(media.pid), psutil.Process(camera.pid)] - listeners = check_connections(managed, {*PORTS.values(), hook.server_port}) - info = api('getMediaInfo', schema='rtmp', vhost='__defaultVhost__', app='live', stream=STREAM) - if info.get('code') != 0 or any(t.get('codec_type') != 0 for t in info.get('tracks', [])): - raise RuntimeError('Video stream health check failed') - if info.get('isRecordingMP4') or info.get('isRecordingHLS'): - raise RuntimeError('Recording detected; stopping camera') + if not web.is_running(): + raise RuntimeError('Local web server exited; stopping camera') + ok, frame = camera.read() + if not ok or frame is None: + raise RuntimeError('Camera frame read failed') write_json(STATUS, {'ready': True, 'stream_id': row.pk, 'stream': STREAM, 'device': DEVICE, - 'media_pid': media.pid, 'camera_pid': camera.pid, - 'loopback_ports': sorted(listeners), 'tracks': info['tracks'], - 'audio': False, 'recording': False, 'sip': False, - 'checked_at': datetime.now().isoformat(timespec='seconds')}) - time.sleep(2) + 'camera_index': CAMERA_INDEX, 'width': width, 'height': height, 'fps': fps, + 'media_started': False, 'zlm_started': False, 'audio': False, 'recording': False, + 'sip': False, 'checked_at': datetime.now().isoformat(timespec='seconds')}) + time.sleep(0.2) finally: - terminate(camera) - terminate(media) - if hook: - hook.shutdown() - hook.server_close() - if row is not None: + if camera is not None: + camera.release() + if row is not None and StreamModel is not None: StreamModel.objects.filter(pk=row.pk, remark=MARKER).update(forward_state=0) - lock.release() write_json(STATUS, {'ready': False, 'stopped_at': datetime.now().isoformat(timespec='seconds')}) @@ -307,7 +253,7 @@ def start(): if child.poll() is not None: raise RuntimeError('Webcam start failed; inspect .runtime/local-webcam/supervisor.log') if STATUS.exists() and json.loads(STATUS.read_text(encoding='utf-8')).get('ready'): - print('Ready: http://127.0.0.1:10001/stream/index | laptop_cam | video only') + print('Ready: http://127.0.0.1:10001/stream/index | laptop_cam | camera only; ZLM not started') return time.sleep(1) STOP.touch() @@ -325,7 +271,7 @@ def stop(): except psutil.TimeoutExpired: raise RuntimeError('Graceful stop timed out; inspect owned processes before retrying') PID_FILE.unlink(missing_ok=True) - print('Webcam and local media stopped; web server and saved stream record retained') + print('Webcam stopped; web server and registered camera record retained') if __name__ == '__main__': diff --git a/scripts/start-services.ps1 b/scripts/start-services.ps1 new file mode 100644 index 0000000..3cf0642 --- /dev/null +++ b/scripts/start-services.ps1 @@ -0,0 +1,74 @@ +$ErrorActionPreference = 'Stop' +$projectRoot = Split-Path -Parent $PSScriptRoot +$py = "$projectRoot\.venv\Scripts\python.exe" +$runservicesLog = "$projectRoot\.runtime\runservices.stdout.log" +$runservicesErr = "$projectRoot\.runtime\runservices.stderr.log" +$pidFile = "$projectRoot\.runtime\runservices.json" +$action = 'start' +if ($args.Count -gt 0) { $action = $args[0] } + +function Get-ProjectServices { + Get-CimInstance Win32_Process -Filter "Name='python.exe'" | + Where-Object { $_.CommandLine -like '*manage.py runservices*' -and $_.CommandLine -like '*video_monitor*' } +} + +function Get-Descendants([int]$ParentId) { + $children = @() + Get-CimInstance Win32_Process -Filter "ParentProcessId=$ParentId" -ErrorAction SilentlyContinue | ForEach-Object { + $children += $_ + $children += Get-Descendants $_.ProcessId + } + $children +} + +switch ($action) { + 'start' { + $existing = Get-ProjectServices + if ($existing) { + Write-Host "runservices already running: PIDs $($existing.ProcessId -join ', ')" + exit 0 + } + New-Item -ItemType Directory -Force -Path "$projectRoot\.runtime" | Out-Null + $p = Start-Process -FilePath $py -ArgumentList @('manage.py','runservices') ` + -WorkingDirectory $projectRoot -WindowStyle Hidden ` + -RedirectStandardOutput $runservicesLog -RedirectStandardError $runservicesErr -PassThru + @{ pid = $p.Id; created = (Get-Date).ToString('o') } | ConvertTo-Json | Set-Content $pidFile -Encoding UTF8 + Write-Host "runservices started PID=$($p.Id)" + Start-Sleep -Seconds 20 + $zlm = Get-CimInstance Win32_Process -Filter "Name='monitor_zlm.exe'" -ErrorAction SilentlyContinue + $sip = Get-NetUDPEndpoint -LocalPort 15060 -ErrorAction SilentlyContinue + if ($zlm) { Write-Host "ZLM up PID=$($zlm.ProcessId -join ', ')" } else { Write-Host 'ZLM not up yet; inspect log' } + if ($sip) { Write-Host 'SIP 15060 listening' } else { Write-Host 'SIP 15060 not listening yet' } + } + 'stop' { + $procs = Get-ProjectServices + if (-not $procs) { + Write-Host 'No runservices process running' + Remove-Item $pidFile -ErrorAction SilentlyContinue + exit 0 + } + foreach ($proc in $procs) { + $desc = Get-Descendants $proc.ProcessId + foreach ($child in $desc) { + Stop-Process -Id $child.ProcessId -Force -ErrorAction SilentlyContinue + } + Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue + Write-Host "stopped runservices PID=$($proc.ProcessId) (children: $($desc.ProcessId -join ', '))" + } + Remove-Item $pidFile -ErrorAction SilentlyContinue + } + 'status' { + $procs = Get-ProjectServices + $zlm = Get-CimInstance Win32_Process -Filter "Name='monitor_zlm.exe'" -ErrorAction SilentlyContinue + $sip = Get-NetUDPEndpoint -LocalPort 15060 -ErrorAction SilentlyContinue + [pscustomobject]@{ + runservices = ($procs | ForEach-Object { $_.ProcessId }) -join ', ' + zlm = ($zlm | ForEach-Object { $_.ProcessId }) -join ', ' + sip_15060 = [bool]$sip + } | Format-List + } + default { + Write-Host "Usage: .\scripts\start-services.ps1 [start|stop|status]" + exit 1 + } +} diff --git a/static/upload/weight/20260705205352_yolo11n.bin b/static/upload/weight/20260705205352_yolo11n.bin deleted file mode 100644 index 262139e..0000000 Binary files a/static/upload/weight/20260705205352_yolo11n.bin and /dev/null differ diff --git a/static/upload/weight/20260831110138_20260831_yolo26x.pt b/static/upload/weight/20260831110138_20260831_yolo26x.pt new file mode 100644 index 0000000..ea5840c Binary files /dev/null and b/static/upload/weight/20260831110138_20260831_yolo26x.pt differ diff --git a/static/upload/weight/20260831_yolo26x.pt b/static/upload/weight/20260831_yolo26x.pt new file mode 100644 index 0000000..ea5840c Binary files /dev/null and b/static/upload/weight/20260831_yolo26x.pt differ diff --git a/templates/app/control/index.html b/templates/app/control/index.html index bc4b4b1..2105580 100644 --- a/templates/app/control/index.html +++ b/templates/app/control/index.html @@ -429,6 +429,22 @@ .em-empty{text-align:center;padding:40px 20px;color:#9ca3af;font-size:13px; background:#f9fafb;border-radius:10px;border:1px dashed #e5e7eb;} .em-empty .ic{display:block;font-size:32px;margin-bottom:8px;opacity:.4;} + +/* 实时 YOLO 预览 */ +.yolo-preview-modal{width:min(1180px,96vw);background:#0f172a;border-radius:12px;overflow:hidden; + box-shadow:0 24px 70px rgba(0,0,0,.35);color:#e2e8f0;display:flex;flex-direction:column;} +.yp-head{height:54px;padding:0 18px;background:#fff;color:#1f2937;display:flex;align-items:center;justify-content:space-between;} +.yp-title{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:600;} +.yp-live-dot{width:9px;height:9px;border-radius:50%;background:#10b981;box-shadow:0 0 0 4px rgba(16,185,129,.15);} +.yp-stage{position:relative;width:100%;aspect-ratio:16/9;background:#020617;overflow:hidden;} +#yoloPreviewPlayer,#yoloPreviewPlayer>*{width:100%;height:100%;} +#yoloPreviewCanvas{position:absolute;inset:0;width:100%;height:100%;z-index:5;pointer-events:none;} +.yp-loading{position:absolute;inset:0;z-index:6;display:flex;align-items:center;justify-content:center;background:rgba(2,6,23,.72);color:#cbd5e1;} +.yp-foot{min-height:52px;padding:10px 16px;background:#111827;display:flex;gap:10px;align-items:center;flex-wrap:wrap;} +.yp-stat{font-size:12px;color:#cbd5e1;background:#1f2937;border:1px solid #334155;border-radius:14px;padding:4px 10px;} +.yp-mode{font-size:12px;font-weight:600;border-radius:14px;padding:4px 10px;background:#064e3b;color:#a7f3d0;} +.yp-mode.preview{background:#78350f;color:#fde68a;} +.act-btn.act-preview{color:#0f766e}.act-btn.act-preview:hover{background:#ecfdf5;color:#059669;} {% endblock %} @@ -577,6 +593,12 @@
+
+ + +
首次进入立即报警;0 表示同一次停留只报一次
+
@@ -601,6 +623,28 @@
+ +
+ +
+
@@ -72,27 +73,27 @@
云台控制
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
- - + +
- - + +
- - + +
@@ -164,6 +165,9 @@ function initPlayer() { document.getElementById('ptzCard').style.display = 'block'; } + // 同步当前录制状态 + refreshRecordState(); + // 自动播放 if (s.is_online == 1) { startPlay(); @@ -230,21 +234,115 @@ function stopPlay() { wrap.innerHTML = '
已停止
'; } +var g_recording = false; + +function toggleRecord() { + var s = streamInfo; + if (!s || !s.app || !s.name) { + showToast('视频流信息无效', 'error'); + return; + } + if (g_recording) { + stopRecord(); + } else { + startRecord(); + } +} + +function startRecord() { + var s = streamInfo; + Api.post('/nvr/openStartRecordVideo', { app: s.app, name: s.name }).then(function(res) { + if (res.code === 1000) { + g_recording = true; + updateRecordBtn(); + showToast('已开始录制'); + } else { + showToast(res.msg || '录制启动失败', 'error'); + } + }).catch(function() { + showToast('录制启动失败', 'error'); + }); +} + +function stopRecord() { + var s = streamInfo; + Api.post('/nvr/openStopRecordVideo', { app: s.app, name: s.name }).then(function(res) { + if (res.code === 1000) { + g_recording = false; + updateRecordBtn(); + showToast('已停止录制'); + } else { + showToast(res.msg || '停止录制失败', 'error'); + } + }).catch(function() { + showToast('停止录制失败', 'error'); + }); +} + +function updateRecordBtn() { + var btn = document.getElementById('btnRecord'); + if (!btn) return; + if (g_recording) { + btn.textContent = '停止录制'; + btn.classList.remove('btn-primary'); + btn.classList.add('btn-danger'); + } else { + btn.textContent = '录制'; + btn.classList.remove('btn-danger'); + } +} + +function refreshRecordState() { + var s = streamInfo; + if (!s || !s.app || !s.name) return; + Api.get('/nvr/openVideoIsRecording', { app: s.app, name: s.name }).then(function(res) { + if (res.code === 1000) { + g_recording = !!res.is_recording; + updateRecordBtn(); + } + }).catch(function() {}); +} + function ptzControl(ptzType) { var s = streamInfo; - if (!s.code) return; + if (!s || !s.code) return; Api.post('/stream/openPtz', { code: s.code, ptzType: ptzType, - ptzSpeed: 4 + ptzSpeed: 32 }).then(function(res) { if (res.code !== 1000) { showToast(res.msg, 'error'); } + }).catch(function() { + showToast('云台控制请求失败', 'error'); }); } +// 按住方向/变倍/聚焦/光圈按钮持续动作,松开或移出按钮自动发送停止 +function initPtzButtons() { + var elems = document.querySelectorAll('[data-ptz]'); + for (var i = 0; i < elems.length; i++) { + var el = elems[i]; + el.addEventListener('mousedown', function(e) { + e.preventDefault(); + ptzControl(parseInt(this.getAttribute('data-ptz'), 10)); + }); + el.addEventListener('mouseup', function() { + if (parseInt(this.getAttribute('data-ptz'), 10) !== 0) { + ptzControl(0); // 松开即停止 + } + }); + el.addEventListener('mouseleave', function() { + if (parseInt(this.getAttribute('data-ptz'), 10) !== 0) { + ptzControl(0); // 移出按钮即停止 + } + }); + } +} +initPtzButtons(); + // 定时刷新信息 var prevIsOnline = streamInfo.is_online == 1; setInterval(function() { diff --git a/tests/test_analysis_runtime.py b/tests/test_analysis_runtime.py new file mode 100644 index 0000000..779e8df --- /dev/null +++ b/tests/test_analysis_runtime.py @@ -0,0 +1,336 @@ +"""Regression coverage for camera restart, response routing and inference health.""" +import multiprocessing as mp +import queue +import threading +import time +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +import numpy as np + +from app.analysis.manager import AnalysisManager +from app.analysis.pipeline import CameraPipeline +from app.analysis.process_worker import pipeline_process_main +from app.analysis.remote_detector import RemoteDetector +from app.analysis.tracker import IoUTracker +from app.analysis.preview_sessions import PreviewSessionRegistry + + +class CommandPipeline: + """Delay run() so control threads must survive the pre-start window.""" + def __init__(self, **kwargs): + self._running = False + self._started_event = threading.Event() + self.zones = [] + + def run(self): + time.sleep(0.2) + self._running = True + self._started_event.set() + deadline = time.monotonic() + 3 + while self._running and time.monotonic() < deadline: + time.sleep(0.01) + assert not self._running, 'stop command was lost before run()' + assert self.zones == [{'id': 12}], 'reload command was lost' + + def status(self): + return {'running': self._running} + + def set_zone_polygons(self, zones): + self.zones = zones + + def stop(self): + self._running = False + + +def command_process(commands): + with patch('app.analysis.pipeline.CameraPipeline', CommandPipeline): + pipeline_process_main({'stream_id': 1, 'rtsp_url': 'test'}, + queue.Queue(), commands, {}) + + +def wait_response(responses, entered): + entered.set() + responses.get(timeout=60) + + +class AnalysisRuntimeTests(unittest.TestCase): + def make_router(self): + manager = object.__new__(AnalysisManager) + manager._infer_routes = {} + manager._infer_routes_lock = threading.Lock() + return manager + + def test_stop_and_reload_commands_survive_startup(self): + ctx = mp.get_context('spawn') + commands = ctx.Queue() + commands.put({'cmd': 'reload_zones', 'zones': [{'id': 12}]}) + commands.put({'cmd': 'stop'}) + proc = ctx.Process(target=command_process, args=(commands,)) + proc.start() + try: + proc.join(10) + self.assertEqual(proc.exitcode, 0) + finally: + if proc.is_alive(): + proc.terminate() + proc.join(2) + commands.cancel_join_thread() + commands.close() + + def test_restart_uses_new_channel_after_consumer_is_killed(self): + ctx = mp.get_context('spawn') + router = self.make_router() + old, fresh, other = ctx.Queue(), ctx.Queue(), ctx.Queue() + router._infer_routes.update(old=old, fresh=fresh, other=other) + entered = ctx.Event() + proc = ctx.Process(target=wait_response, args=(old, entered)) + proc.start() + try: + self.assertTrue(entered.wait(10)) + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + if not old._rlock.acquire(False): + break + old._rlock.release() + time.sleep(0.01) + else: + self.fail('consumer did not acquire response queue lock') + proc.terminate() + proc.join(3) + old.put({'req_id': 'unreadable'}) + with self.assertRaises(queue.Empty): + old.get(timeout=0.1) + router._close_inference_channel({'response_channel': 'old'}) + router._send_inference_response('old', {'req_id': 'late'}) + router._send_inference_response('fresh', {'req_id': 'new'}) + router._send_inference_response('other', {'req_id': 'camera-2'}) + self.assertEqual(fresh.get(timeout=2)['req_id'], 'new') + self.assertEqual(other.get(timeout=2)['req_id'], 'camera-2') + finally: + if proc.is_alive(): + proc.terminate() + proc.join(2) + for key in list(router._infer_routes): + router._close_inference_channel({'response_channel': key}) + + def test_remote_timeout_is_not_an_empty_success(self): + detector = RemoteDetector({'id': 5}, queue.Queue(), queue.Queue(), timeout=0.03) + with self.assertRaises(TimeoutError): + detector.detect(np.zeros((32, 32, 3), dtype=np.uint8)) + + def test_downscaled_inference_coordinates_are_restored(self): + restored = RemoteDetector._restore_coordinates([{ + 'box': [10, 20, 100, 200], + 'keypoints': [[15, 25, .9]], + 'polygon': [[10, 20], [100, 200]], + }], 2.0, 2.0)[0] + self.assertEqual(restored['box'], [20.0, 40.0, 200.0, 400.0]) + self.assertEqual(restored['keypoints'][0][:2], [30.0, 50.0]) + self.assertEqual(restored['polygon'][1], [200.0, 400.0]) + + def test_tracker_is_one_to_one_and_confirms_medium_score_twice(self): + tracker = IoUTracker() + detections = [ + {'label': 'person', 'score': .45, 'box': [0, 0, 20, 40]}, + {'label': 'person', 'score': .8, 'box': [30, 0, 50, 40]}, + ] + first, _, _, _ = tracker.update(detections, 1, timestamp=1.0) + self.assertEqual(len({t['track_id'] for t in first}), 2) + self.assertFalse(next(t for t in first if t['score'] == .45)['confirmed']) + second, _, new_ids, _ = tracker.update([ + {'label': 'person', 'score': .5, 'box': [2, 0, 22, 40]}, + {'label': 'person', 'score': .82, 'box': [28, 0, 48, 40]}, + ], 2, timestamp=4.0) + self.assertEqual(new_ids, []) + self.assertEqual(len({t['track_id'] for t in second}), 2) + self.assertTrue(all(t['confirmed'] for t in second)) + + def test_low_fps_track_survives_short_miss(self): + tracker = IoUTracker() + active, _, _, _ = tracker.update([ + {'label': 'person', 'score': .8, 'box': [0, 0, 20, 40]}, + ], 1, timestamp=1.0) + tid = active[0]['track_id'] + tracker.update([], 2, timestamp=4.0) + active, ended, new_ids, _ = tracker.update([ + {'label': 'person', 'score': .8, 'box': [2, 0, 22, 40]}, + ], 3, timestamp=7.0) + self.assertNotIn(tid, ended) + self.assertEqual(new_ids, []) + self.assertEqual(active[0]['track_id'], tid) + + def test_preview_uses_capture_timestamp_and_normalized_velocity(self): + snapshots = [] + pipe = CameraPipeline(1, 'test', 'test', on_preview=snapshots.append) + pipe._last_w, pipe._last_h = 100, 200 + pipe._frame_index = 2 + pipe._tracker.update([ + {'label': 'person', 'score': .8, 'box': [10, 20, 30, 80]}, + ], 1, timestamp=10.0) + pipe._tracker.update([ + {'label': 'person', 'score': .8, 'box': [20, 30, 40, 90]}, + ], 2, timestamp=11.0) + pipe._publish_preview(11.0, processed_ts=11.25) + snapshot = snapshots[-1] + self.assertEqual(snapshot['timestamp'], 11.0) + self.assertEqual(snapshot['processed_timestamp'], 11.25) + self.assertAlmostEqual(snapshot['inference_latency'], .25) + self.assertEqual(snapshot['tracks'][0]['box'], [.2, .15, .4, .45]) + self.assertEqual(snapshot['tracks'][0]['velocity'], [.05, .025, .05, .025]) + + def test_formal_preview_temporarily_boosts_and_restores_analysis_fps(self): + class Handle: + def __init__(self): self.calls = [] + def set_analyze_fps(self, fps): self.calls.append(float(fps)) + class Process: + @staticmethod + def is_alive(): return True + handle = Handle() + manager = object.__new__(AnalysisManager) + manager._lock = threading.RLock() + manager._pipelines = {87: { + 'running': True, 'mode': 'process', 'process': Process(), + 'handle': handle, 'preview_only': False, 'analyze_fps': .67, + }} + zone = SimpleNamespace(stream=SimpleNamespace(id=87)) + with patch('monitor_runtime.licensing.require_license'): + self.assertEqual(manager.start_preview(zone), (True, 'formal')) + self.assertEqual(handle.calls, [5.0]) + self.assertEqual(manager._pipelines[87]['preview_restore_fps'], .67) + self.assertFalse(manager.stop_preview(87)) + self.assertEqual(handle.calls, [5.0, .67]) + self.assertNotIn('preview_restore_fps', manager._pipelines[87]) + + def test_medium_score_confirmation_requires_consecutive_hits(self): + tracker = IoUTracker() + tracker.update([{'label': 'person', 'score': .4, 'box': [0, 0, 20, 40]}], 1, timestamp=1) + tracker.update([], 2, timestamp=2) + active, _, _, _ = tracker.update([ + {'label': 'person', 'score': .5, 'box': [1, 0, 21, 40]}, + ], 3, timestamp=3) + self.assertFalse(active[0]['confirmed']) + + def test_person_foot_point_and_two_outside_hits(self): + events = [] + zone = {'id': 12, 'name': 'office', 'coords': [[0, .8], [1, .8], [1, 1], [0, 1]], + 'alarm_repeat_sec': 30, 'biz_algorithms': [{ + 'id': 1, 'name': 'person intrusion', 'flow_type': 1, + 'small_model_id': 5, 'target_labels': ['person'], 'post_process': 'AREA'}]} + pipe = CameraPipeline(1, 'test', 'test', zone_polygons=[zone], on_event=events.append) + pipe._last_w, pipe._last_h = 100, 100 + inside = {'track_id': 7, 'label': 'person', 'score': .8, 'box': [40, 40, 60, 90], + 'algorithm_id': 5, 'confirmed': True, 'observed': True} + with patch.object(pipe, '_emit_biz_alarm', return_value=True) as alarm: + pipe._check_zones([inside], 1, np.zeros((100, 100, 3), dtype=np.uint8)) + self.assertTrue(alarm.called) # 中心 y=65 在外,脚点 y=87.5 在区域内 + outside = dict(inside, box=[40, 20, 60, 50]) + pipe._check_zones([outside], 2, np.zeros((100, 100, 3), dtype=np.uint8)) + self.assertEqual(pipe._last_zone_state[7], {12}) + pipe._check_zones([outside], 3, np.zeros((100, 100, 3), dtype=np.uint8)) + self.assertEqual(pipe._last_zone_state[7], set()) + + def test_unobserved_retained_track_does_not_leave_or_reenter(self): + pipe = CameraPipeline(1, 'test', 'test') + active, _, _, _ = pipe._tracker.update([ + {'label': 'person', 'score': .8, 'box': [10, 10, 20, 30]}, + ], 1, timestamp=time.time()) + tid = active[0]['track_id'] + pipe._last_zone_state = {tid: {12}} + pipe._track_enter_ts = {(tid, 12): 10.0} + pipe._check_zones([], 2, np.zeros((20, 20, 3), dtype=np.uint8)) + self.assertEqual(pipe._last_zone_state[tid], {12}) + self.assertEqual(pipe._track_enter_ts[(tid, 12)], 10.0) + + def test_area_alarm_repeat_is_independent_from_detection_rate(self): + pipe = CameraPipeline(1, 'test', 'test') + rule = {'id': 1, 'post_process': 'AREA', 'target_labels': ['person'], + 'flow_type': 1, 'small_model_id': 5} + zone = {'id': 12, 'detect_interval_sec': 1, 'alarm_repeat_sec': 30, + 'biz_algorithms': [rule]} + track = {'track_id': 7, 'label': 'person', 'score': .8, 'algorithm_id': 5, + 'box': [0, 0, 10, 20]} + with patch.object(pipe, '_emit_biz_alarm', return_value=True) as alarm: + pipe._fire_area_alarms(track, 12, zone, set(), None, track['box'], 100.0, 7) + pipe._fire_area_alarms(track, 12, zone, {12}, None, track['box'], 101.0, 7) + pipe._fire_area_alarms(track, 12, zone, {12}, None, track['box'], 130.0, 7) + self.assertEqual(alarm.call_count, 2) + + def test_preview_session_owner_and_last_viewer_cleanup(self): + class Related(list): + def filter(self, **_kwargs): return self + def select_related(self, *_args): return self + class Model: + id, name, state = 5, 'YOLO26x', 1 + class Rule: + flow_type, detector_model, small_model = 1, None, Model() + target_labels = '["person"]' + class Stream: + id, app, name = 87, 'live', 'camera01' + class Zone: + id, stream_id, stream, name, color = 12, 87, Stream(), 'office', '#169F85' + coordinates = '[[0,0],[1,0],[1,1]]' + algorithms = Related([Rule()]) + registry = PreviewSessionRegistry() + with patch('app.analysis.preview_sessions.AnalysisManager') as manager_cls: + manager = manager_cls.return_value + manager.start_preview.return_value = (True, 'preview') + manager.preview_mode.return_value = 'preview' + first = registry.start('owner-a', Zone()) + second = registry.start('owner-b', Zone()) + with self.assertRaises(PermissionError): + registry.data('owner-b', first['session_id']) + self.assertTrue(registry.stop('owner-a', first['session_id'])) + manager.stop_preview.assert_not_called() + self.assertTrue(registry.stop('owner-b', second['session_id'])) + manager.stop_preview.assert_called_once_with(87) + registry._running = False + + def test_worker_errors_are_preserved(self): + requests, responses = queue.Queue(), queue.Queue() + detector = RemoteDetector({'id': 5}, requests, responses, timeout=2, + response_channel='camera-1') + def reply(): + msg = requests.get(timeout=2) + self.assertEqual(msg['response_channel'], 'camera-1') + responses.put({'req_id': msg['req_id'], 'ok': False, 'error': 'engine load failed'}) + worker = threading.Thread(target=reply) + worker.start() + try: + with self.assertRaisesRegex(RuntimeError, 'engine load failed'): + detector.detect(np.zeros((32, 32, 3), dtype=np.uint8)) + finally: + worker.join(3) + + def test_failed_frame_is_not_counted_and_success_recovers(self): + pipe = CameraPipeline(1, 'test', 'test') + frame = np.zeros((32, 32, 3), dtype=np.uint8) + def process(_): + pipe.stop() + raise TimeoutError('response timeout') + with patch.object(pipe, '_decode_loop'), \ + patch.object(pipe, '_pop_latest_frame', return_value=frame), \ + patch.object(pipe, '_process_frame', side_effect=process): + pipe.run() + self.assertEqual(pipe.status()['analyzed_count'], 0) + self.assertEqual(pipe.status()['analysis_health'], 'error') + pipe._last_analyze_ts = 0 + with patch.object(pipe, '_decode_loop'), \ + patch.object(pipe, '_pop_latest_frame', return_value=frame), \ + patch.object(pipe, '_process_frame', side_effect=lambda _: pipe.stop()): + pipe.run() + self.assertEqual(pipe.status()['analyzed_count'], 1) + self.assertEqual(pipe.status()['analysis_health'], 'running') + self.assertEqual(pipe.status()['analysis_error'], '') + + def test_low_frequency_success_keeps_nonzero_fps(self): + pipe = CameraPipeline(1, 'test', 'test', analyze_fps=1 / 60) + pipe._last_analysis_ts = time.time() + pipe._analysis_fps = 1 / 60 + self.assertEqual(pipe.status()['analysis_health'], 'running') + self.assertGreater(pipe.status()['analysis_fps'], 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_control_preview.cjs b/tests/test_control_preview.cjs new file mode 100644 index 0000000..af5ca2b --- /dev/null +++ b/tests/test_control_preview.cjs @@ -0,0 +1,46 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const template = fs.readFileSync(path.join(__dirname, '../templates/app/control/index.html'), 'utf8'); +assert.match(template, /openYoloPreview\('\+z\.id\+'\)/); +assert.match(template, /id="yoloPreviewCanvas"/); +assert.match(template, /openPreviewStart/); +assert.match(template, /openPreviewData/); +assert.match(template, /openPreviewStop/); +assert.match(template, /ResizeObserver/); +assert.match(template, /requestAnimationFrame\(animateYoloOverlay\)/); + +const start = template.indexOf('function computeContainRect('); +const end = template.indexOf('function resizeYoloCanvas('); +const context = {}; +vm.createContext(context); +vm.runInContext(template.slice(start, end), context); +const wide = context.computeContainRect(1000, 1000, 16, 9); +assert.equal(wide.width, 1000); +assert.equal(Math.round(wide.height), 563); +assert.equal(Math.round(wide.y), 219); +const tall = context.computeContainRect(1000, 500, 4, 3); +assert.equal(Math.round(tall.height), 500); +assert.equal(Math.round(tall.width), 667); +assert.equal(Math.round(tall.x), 167); + +const projectStart = template.indexOf('function projectTrackBox('); +const projectEnd = template.indexOf('function smoothTrackBox('); +vm.runInContext(template.slice(projectStart, projectEnd), context); +const moving = context.projectTrackBox({ + box: [.1, .2, .2, .4], velocity: [.1, 0, .1, 0], observed: true, +}, 10, 10.5); +assert.ok(Math.abs(moving[0] - .15) < 1e-9); +assert.ok(Math.abs(moving[2] - .25) < 1e-9); +const runaway = context.projectTrackBox({ + box: [.1, .2, .2, .4], velocity: [10, 10, 10, 10], observed: true, +}, 10, 20); +assert.ok(runaway.every(v => v >= 0 && v <= 1)); +assert.ok(runaway[0] < .3, 'prediction displacement must be capped'); +const retained = context.projectTrackBox({ + box: [.1, .2, .2, .4], velocity: [1, 1, 1, 1], observed: false, +}, 10, 11); +assert.deepEqual(Array.from(retained), [.1, .2, .2, .4]); +console.log('PASS: preview lifecycle, contain coordinates and motion projection'); diff --git a/tests/test_control_status.cjs b/tests/test_control_status.cjs new file mode 100644 index 0000000..68085f1 --- /dev/null +++ b/tests/test_control_status.cjs @@ -0,0 +1,31 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const template = fs.readFileSync(path.join(__dirname, '../templates/app/control/index.html'), 'utf8'); +const code = template.slice(template.indexOf('function buildZoneStateTag('), + template.indexOf('function buildZoneRunBtn(')); +let info, active = true; +const context = { + zoneIsActiveInPipeline: () => active, + getStreamStatusInfo: () => info, + getStreamHealth: () => info.stream_health, + streamHealthLabel: health => health, + _t: (_, fallback) => fallback, +}; +vm.createContext(context); +vm.runInContext(code, context); +const tag = () => context.buildZoneStateTag({stream_id: 87}); +info = {running: true, stream_health: 'ok', analysis_health: 'starting', analysis_fps: 0}; +assert.match(tag(), /启动中/); +info.analysis_health = 'error'; +assert.match(tag(), /推理异常/); +info.analysis_health = 'running'; +info.analysis_fps = 0.017; +assert.match(tag(), /分析中/); +info.running = false; +info.stream_health = 'disconnected'; +assert.match(tag(), /disconnected/); +active = false; +assert.match(tag(), /已停止/); +console.log('PASS: starting, inference error, low-FPS success, disconnected, stopped'); diff --git a/tests/test_windows_license.py b/tests/test_windows_license.py new file mode 100644 index 0000000..9cc172f --- /dev/null +++ b/tests/test_windows_license.py @@ -0,0 +1,51 @@ +import base64 +import copy +import unittest +from datetime import datetime, timezone +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from monitor_runtime.licensing import canonical, verify_document, LicenseError + +class OfflineLicenseTests(unittest.TestCase): + def setUp(self): + self.key = Ed25519PrivateKey.generate() + self.public = self.key.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo) + self.now = datetime(2026,8,30,tzinfo=timezone.utc).timestamp() + self.payload = dict(schema_version=1,product='monitor',license_id='test-id',customer='test', + machine_fingerprint='v1:'+'a'*64,issued_at='2026-08-29T00:00:00Z',expires_at=None) + + def sign(self, payload=None): + payload = payload or self.payload + return {'payload':payload,'signature':base64.b64encode(self.key.sign(canonical(payload))).decode()} + + def verify(self, document, **kwargs): + return verify_document(document,kwargs.get('public',self.public), + kwargs.get('fingerprint',self.payload['machine_fingerprint']),kwargs.get('now',self.now)) + + def test_perpetual_and_dated(self): + self.assertEqual(self.verify(self.sign())['customer'],'test') + self.payload['expires_at']='2026-08-31T00:00:00Z' + self.verify(self.sign()) + + def test_expiry_boundary_and_future_issue(self): + self.payload['expires_at']='2026-08-30T00:00:00Z' + with self.assertRaises(LicenseError):self.verify(self.sign()) + self.payload['expires_at']=None + self.payload['issued_at']='2026-09-01T00:00:00Z' + with self.assertRaises(LicenseError):self.verify(self.sign()) + + def test_machine_signature_and_key_mismatch(self): + doc=self.sign() + with self.assertRaises(LicenseError):self.verify(doc,fingerprint='another') + bad=copy.deepcopy(doc);bad['payload']['customer']='tampered' + with self.assertRaises(LicenseError):self.verify(bad) + other=Ed25519PrivateKey.generate().public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo) + with self.assertRaises(LicenseError):self.verify(doc,public=other) + + def test_product_version_structure_and_timezone(self): + for field,value in [('product','other'),('schema_version',2),('issued_at','2026-08-29'),('customer','')]: + with self.subTest(field=field): + payload=dict(self.payload);payload[field]=value + with self.assertRaises(LicenseError):self.verify(self.sign(payload)) + bad=self.sign();bad['unexpected']=1 + with self.assertRaises(LicenseError):self.verify(bad) diff --git a/tests/validate_yolo_replay.py b/tests/validate_yolo_replay.py new file mode 100644 index 0000000..e28d5e2 --- /dev/null +++ b/tests/validate_yolo_replay.py @@ -0,0 +1,74 @@ +"""Camera 01 录像:直接 YOLO 与共享实时缩图管线的一致性抽样验证。 + +手动运行:python tests/validate_yolo_replay.py