Initial commit: video monitor project

This commit is contained in:
zhengsl 2026-08-30 22:22:11 +08:00
commit 731a3f8290
183 changed files with 46256 additions and 0 deletions

51
.gitignore vendored Normal file
View File

@ -0,0 +1,51 @@
# IDE
.idea/
.vs/
.vscode/
.qoder
.qtcreator
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# env / build
venv/*
.venv/
dist/*
build/*
# Django
# runtime artifacts
db.sqlite3
monitor.sqlite3
.allowed_hosts
.user_settings
.license
.runtime-secrets.json
.runtime-secrets.json.backup-*
.runtime/
.trusted-models.json
# spec files (PyInstaller build artifacts)
monitor.spec
# uploads / storage
static/storage/*
framework/__pycache__/*
app/__pycache__/*
app/migrations/*
app/migrations/__pycache__/*
app/comms/__pycache__/*
app/utils/__pycache__/*
app/views/__pycache__/*
app/services/__pycache__/*
app/analysis/__pycache__/*
app/analysis/engines/__pycache__/*
app/recording/__pycache__/*
# logs / docs / external services
docs/*
log/*

130
README.md Normal file
View File

@ -0,0 +1,130 @@
多路视频接入与智能布控分析平台。支持 GB28181 / RTSP、YOLO 小模型检测、OpenAI 兼容大模型复核、多边形布控与结构化报警。
---
## 功能
- 视频接入RTSP / GB28181 拉流ZLMediaKit 转发ONVIF 发现
- 智能分析YOLO-PyTorch / ONNX / OpenVINO 小模型 + 可选大模型复核
- 布控报警多边形区域、5 种后处理规则(入侵/越线/方向/密度/滞留)
- 运维控制面板监控、流媒体启停、录像、多语言7 种)
---
## 环境要求
- Python 3.10+
- FFmpegPATH 或 `config.json` 配置)
- ZLMediaKit流媒体端口与 `config.json` 一致)
- GPU 可选
```bash
如果是Linux系统需要手动进入到zlm/bin.x86.gcc9.4 或 zlm/bin.arm.gcc9.4 ,确保可以正确执行 ./monitor_zlm
如果执行./monitor_zlm失败了可以参考下面的两种方式解决安装环境问题
1解决方式一
sudo chmod -R a+x *
echo "export LD_LIBRARY_PATH=\"$(pwd):\$LD_LIBRARY_PATH\"" >> ~/.bashrc && source ~/.bashrc
2解决方式二
sudo apt update
sudo apt install -y libsrtp2-1
//下载ubuntu20的libssl1.1包
wget http://security.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.1f-1ubuntu2.24_amd64.deb
sudo dpkg -i libssl1.1_1.1.1f-1ubuntu2.24_amd64.deb
//修复依赖
sudo apt -f install
```
**安装依赖:**
```bash
# Windows
pip install -r requirements-windows.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install -r requirements-windows.txt
# Linux
pip install -r requirements-linux.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install -r requirements-linux.txt
```
---
## 快速开始
```bash
# 仅限本机开发环境PowerShell 还需设置:
# $env:MONITOR_DEBUG="true"; $env:MONITOR_SERVICE_MODE="embedded"
export MONITOR_DEBUG=true
export MONITOR_SERVICE_MODE=embedded
python manage.py runserver 127.0.0.1:10001
```
浏览器访问 `http://127.0.0.1:10001/`。首次使用前先停止服务并执行
`python scripts/secure_initialize.py --admin-username admin`,交互式创建唯一管理员并轮换运行密钥。
管理端不得直接暴露到不可信网络;远程访问请使用 HTTPS 反向代理、IP 白名单和主机防火墙。
生产环境变量、RBAC、模型白名单和反向代理示例见
`docs/PHASE2_DATA_AND_CONTROL_PLANE_REMEDIATION.md`
`docs/PHASE3_LIFECYCLE_AND_TELEMETRY_REMEDIATION.md`
首次部署请编辑 `config.json`端口、ZLM、FFmpeg 等)和 `settings.json`(界面品牌)。启动配置页保存后多数项热更新生效;改管理端口或调试日志需重启服务。
---
## 使用顺序
```
视频管理 → 小模型 → 大模型 → 业务算法 → 布控管理 → 启动分析 → 报警管理
```
1. 添加摄像头并确认拉流正常
2. 上传/配置小模型(流程 1/3和大模型流程 2/3
3. 创建业务算法,在布控页画区域并绑定算法
4. 点击「启动分析」(**重启服务后需手动再点**
5. 在报警管理查看结果
> 只有业务算法规则命中才会报警;单纯检测到目标或画面运动不会产生报警记录。
---
## 常见问题
| 问题 | 处理 |
|------|------|
| 没有报警 | 确认拉流正常、布控已绑算法、已启动分析、检测类别匹配 |
| 改配置不生效 | 布控/算法可热更新;换小模型需重启分析;改端口需重启服务 |
| 端口占用 | 结束残留 `python.exe` 后重新启动 |
日志目录:`log/`。版本号见 `framework/settings.py`
---
## 更新日志
### v1.003
- **业务算法选择大模型相关修复(重要)**
- 大模型配置新增「名称」字段,可留空;新增 / 编辑时若名称为空,自动以「模型名称」兜底(`name = model_name`),避免大模型配置出现空名称。
- 业务算法列表 / 编辑中,绑定大模型的显示名现在会回退到「模型名称」(`llm_name` 兜底 `name or model_name`),不再显示空白。
- **修复业务算法编辑时,绑定的大模型若已被禁用,下拉框无法回显、看似「配置丢失 / 选不中」的问题**:现在自动补一个带「[禁用]」标记的回显项,且每次打开编辑会清理上一次的回显项。
- 业务算法「大模型」下拉选项 label 改为 `name || model_name || #id`,确保只填了模型名称时也能正确显示。
- **新增「统计看板」(报警态势总览)**
- 报警管理新增「统计看板」页面(`/alarm/dashboard`),一站式掌握报警态势。
- 4 张核心统计卡:今日报警、近 7 天报警、累计报警、涉及摄像头数(均可点击下钻到对应列表)。
- 报警趋势图:支持近 7 天 / 近 30 天切换。
- 报警类型分布、报警 TOP 摄像头排行。
- 新增接口 `alarm/openStats`,左侧导航新增「统计看板」入口。
- **国际化补全**
- 补齐统计看板相关 16 个翻译键至全部 7 种语言es / ko / ru / vi / zh-hk / zh / en修复此前仅 zh / en 有译、其余语言界面显示原始 key 的问题。
> 版本号见 `framework/settings.py``PROJECT_VERSION`

0
app/__init__.py Normal file
View File

3
app/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

30
app/analysis/__init__.py Normal file
View File

@ -0,0 +1,30 @@
"""
Monitor · 视频分析层阶段1
模块构成
- frames ZLMediaKit 输出的 RTSP 流取帧OpenCV VideoCapture
- motion 运动检测OpenCV 背景减除
- detector 目标检测ONNX Runtime缺模型时优雅降级
- tracker 单摄像头目标跟踪轻量 IoU 关联
- pipeline 单摄像头流水线取帧 运动 检测 跟踪 事件
- worker_pool 多路共享的检测器进程池
- manager 全局分析管理器启动/停止每路 pipeline单例
设计原则
- 全部用 Python 实现不引入 C++ 服务
- 运动门控仅在有运动的区域跑检测显著降低 CPU/GPU 占用
- ZLMediaKit 解耦通过 ZLM 输出的 RTSP URL 取帧ZLM 仅负责协议接入
- 优雅降级未安装 opencv/numpy/onnxruntime 或未配置模型时Web Zone/Review/
Timeline/TrackedObject仍可正常使用"启动分析"会提示依赖不可用
为避免在 Django 启动期就拉起 OpenCV/ONNX 等较重依赖本包不在此处 eager 导入
AnalysisManager请通过 get_manager() 按需获取
"""
def get_manager():
"""懒加载并返回全局 AnalysisManager 单例"""
from app.analysis.manager import AnalysisManager
return AnalysisManager()
__all__ = ["get_manager"]

299
app/analysis/biz_rules.py Normal file
View File

@ -0,0 +1,299 @@
# 作者:北小菜
"""业务算法后处理 — 区域入侵(AREA) / 越线(LINE_CROSS) / 方向(DIRECTION) / 密度(DENSITY) / 滞留(DWELL)"""
import logging
import math
logger = logging.getLogger("analysis.biz_rules")
# 后处理类型常量(与 models.BizAlgorithmModel.POST_* 保持一致)
POST_AREA = "AREA"
POST_LINE_CROSS = "LINE_CROSS"
POST_LINE_COUNT = "LINE_COUNT"
POST_DIRECTION = "DIRECTION"
POST_DENSITY = "DENSITY"
POST_DWELL = "DWELL"
# 支持小模型流程flow_type 1/3/4的后处理白名单
SMALL_FLOW_POSTS = (POST_AREA, POST_LINE_CROSS, POST_LINE_COUNT, POST_DIRECTION, POST_DENSITY, POST_DWELL)
# 支持大模型流程flow_type 2的后处理白名单大模型主要做语义判断几何类后处理意义有限
LLM_FLOW_POSTS = (POST_AREA,)
def _norm_label(label):
return (label or "").strip().lower()
def _small_flow_types():
return (1, 3, 4)
def _targets_hit(track, biz_rule):
"""目标类别命中 + 小模型来源匹配(所有后处理通用前置条件)"""
targets = biz_rule.get("target_labels") or []
if not targets:
return False
label = _norm_label(track.get("label"))
target_set = {_norm_label(t) for t in targets}
if label not in target_set:
return False
flow = int(biz_rule.get("flow_type") or 1)
sm_id = biz_rule.get("detector_model_id") if flow == 4 else biz_rule.get("small_model_id")
track_algo = track.get("algorithm_id")
if sm_id and track_algo is not None and int(track_algo) != int(sm_id):
return False
return True
def track_matches_area_rule(track, biz_rule):
"""小模型流程:目标类别命中 + AREA 后处理"""
if not biz_rule or biz_rule.get("post_process") != POST_AREA:
return False
flow = int(biz_rule.get("flow_type") or 1)
if flow not in _small_flow_types():
return False
return _targets_hit(track, biz_rule)
def track_matches_line_cross_rule(track, biz_rule):
"""越线检测:目标类别命中 + LINE_CROSS 后处理
真正的跨线判断由 pipeline 维护目标轨迹历史后调用 cross_line_segment 完成
此处仅做"该目标是否参与越线后处理"的静态筛选
"""
if not biz_rule or biz_rule.get("post_process") != POST_LINE_CROSS:
return False
flow = int(biz_rule.get("flow_type") or 1)
if flow not in _small_flow_types():
return False
return _targets_hit(track, biz_rule)
def track_matches_line_count_rule(track, biz_rule):
"""越线计数:目标类别命中 + LINE_COUNT 后处理"""
if not biz_rule or biz_rule.get("post_process") != POST_LINE_COUNT:
return False
flow = int(biz_rule.get("flow_type") or 1)
if flow not in _small_flow_types():
return False
return _targets_hit(track, biz_rule)
def track_matches_direction_rule(track, biz_rule):
"""方向入侵:目标类别命中 + DIRECTION 后处理
实际方向判断由 pipeline 计算目标位移向量后调用 direction_match 完成
"""
if not biz_rule or biz_rule.get("post_process") != POST_DIRECTION:
return False
flow = int(biz_rule.get("flow_type") or 1)
if flow not in _small_flow_types():
return False
return _targets_hit(track, biz_rule)
def track_matches_density_rule(track, biz_rule):
"""密度报警DENSITY 后处理
密度统计是"区域级"而非"目标级"pipeline _check_zones 中独立处理
此函数仅用于过滤目标类别参与计数的类别需命中 target_labels
"""
if not biz_rule or biz_rule.get("post_process") != POST_DENSITY:
return False
flow = int(biz_rule.get("flow_type") or 1)
if flow not in _small_flow_types():
return False
return _targets_hit(track, biz_rule)
def track_matches_dwell_rule(track, biz_rule):
"""滞留报警DWELL 后处理(与 AREA 滞留类似,但作为独立后处理类型)"""
if not biz_rule or biz_rule.get("post_process") != POST_DWELL:
return False
flow = int(biz_rule.get("flow_type") or 1)
if flow not in _small_flow_types():
return False
return _targets_hit(track, biz_rule)
# ---------- 几何辅助 ----------
def cross_line_segment(prev_pt, cur_pt, line_a, line_b):
"""判断线段 prev_pt→cur_pt 是否跨过有向线段 line_a→line_b含方向判定
返回: True 表示正向跨过从左侧到右侧沿 line_aline_b 方向看
"""
return cross_line_direction(prev_pt, cur_pt, line_a, line_b) == "forward"
def cross_line_direction(prev_pt, cur_pt, line_a, line_b):
"""判断轨迹是否跨过计数线,返回 None / 'forward' / 'reverse'
forward沿 line_aline_b 方向看从左侧跨到右侧正向
reverse从右侧跨到左侧逆向
"""
if not prev_pt or not cur_pt or not line_a or not line_b:
return None
try:
x1, y1 = float(prev_pt[0]), float(prev_pt[1])
x2, y2 = float(cur_pt[0]), float(cur_pt[1])
ax, ay = float(line_a[0]), float(line_a[1])
bx, by = float(line_b[0]), float(line_b[1])
except (TypeError, ValueError, IndexError):
return None
def cross(ox, oy, px, py, qx, qy):
return (px - ox) * (qy - oy) - (py - oy) * (qx - ox)
c1 = cross(ax, ay, bx, by, x1, y1)
c2 = cross(ax, ay, bx, by, x2, y2)
if c1 == 0 or c2 == 0 or c1 * c2 > 0:
return None
if c1 > 0 > c2:
return "forward"
if c1 < 0 < c2:
return "reverse"
return None
def direction_match(dx, dy, ref_angle_deg, tolerance_deg=45.0):
"""判断位移向量 (dx,dy) 的方向是否落在 [ref_angle-tol, ref_angle+tol] 内
角度约定0°=向右()90°=向下(图像坐标系)180°=向左(西)270°=向上()
"""
if dx == 0 and dy == 0:
return False
ang = math.degrees(math.atan2(dy, dx)) % 360
lo = (ref_angle_deg - tolerance_deg) % 360
hi = (ref_angle_deg + tolerance_deg) % 360
if lo <= hi:
return lo <= ang <= hi
return ang >= lo or ang <= hi
def zone_has_llm_flow(zone_cfg):
for ba in (zone_cfg or {}).get("biz_algorithms") or []:
if int(ba.get("flow_type") or 0) in (2, 3):
return True
return False
def llm_rules_for_zone(zone_cfg):
"""流程2返回该区域内使用大模型 + AREA 后处理的规则"""
rules = []
for ba in (zone_cfg or {}).get("biz_algorithms") or []:
if int(ba.get("flow_type") or 0) == 2 and ba.get("post_process") == POST_AREA:
if ba.get("llm") and ba.get("llm_prompt"):
rules.append(ba)
return rules
def matched_area_rules(track, zone_cfg):
"""返回与当前目标匹配的 AREA 业务算法"""
rules = (zone_cfg or {}).get("biz_algorithms") or []
if not rules:
return []
area_rules = [r for r in rules if r.get("post_process") == POST_AREA
and int(r.get("flow_type") or 0) in _small_flow_types()]
return [r for r in area_rules if track_matches_area_rule(track, r)]
def matched_rules_for_track(track, zone_cfg):
"""统一调度:返回与当前目标匹配的所有业务算法(含 AREA/LINE_CROSS/DIRECTION/DENSITY/DWELL
pipeline 在目标进入区域时调用此函数获取命中的业务算法
"""
rules = (zone_cfg or {}).get("biz_algorithms") or []
if not rules:
return []
matched = []
for r in rules:
post = r.get("post_process")
flow = int(r.get("flow_type") or 0)
if flow not in _small_flow_types():
continue
if post == POST_AREA and track_matches_area_rule(track, r):
matched.append(r)
elif post == POST_LINE_CROSS and track_matches_line_cross_rule(track, r):
matched.append(r)
elif post == POST_LINE_COUNT and track_matches_line_count_rule(track, r):
matched.append(r)
elif post == POST_DIRECTION and track_matches_direction_rule(track, r):
matched.append(r)
elif post == POST_DENSITY and track_matches_density_rule(track, r):
matched.append(r)
elif post == POST_DWELL and track_matches_dwell_rule(track, r):
matched.append(r)
return matched
def build_alarm_context(event_type, track, zone_cfg, biz_rule=None):
"""生成报警元数据:所属业务算法、报警原因等"""
zone_name = (zone_cfg or {}).get("name") or ""
label = (track or {}).get("label") or ""
biz_name = (biz_rule or {}).get("name") or ""
biz_id = (biz_rule or {}).get("id")
flow_type = int((biz_rule or {}).get("flow_type") or 0)
post = (biz_rule or {}).get("post_process") or POST_AREA
post_label_map = {
POST_AREA: "区域入侵",
POST_LINE_CROSS: "越线检测",
POST_LINE_COUNT: "越线计数",
POST_DIRECTION: "方向入侵",
POST_DENSITY: "密度报警",
POST_DWELL: "滞留报警",
}
post_label = post_label_map.get(post, post)
if event_type == "entered_zone":
if flow_type == 2:
reason = "大模型区域分析:在布控「%s」检测到异常" % (zone_name or "")
elif flow_type == 3:
reason = "小模型+大模型:目标「%s」进入「%s」,大模型校验通过" % (label or "", zone_name or "")
elif biz_rule:
targets = "".join(biz_rule.get("target_labels") or []) or ""
reason = "%s:目标「%s」进入「%s」(检测目标:%s" % (post_label, label or "", zone_name or "", targets)
else:
reason = "目标「%s」进入布控「%s" % (label or "", zone_name or "")
elif event_type == "loiter" or event_type == "dwell":
threshold = int((zone_cfg or {}).get("loiter_threshold") or 0)
dur = (track or {}).get("duration")
dur_txt = (",已停留 %.0f" % dur) if dur else (",阈值 %d" % threshold if threshold else "")
if biz_rule:
reason = "%s:目标「%s」在「%s」超时%s" % (post_label, label or "", zone_name or "", dur_txt)
else:
reason = "%s:目标「%s」在「%s%s" % (post_label, label or "", zone_name or "", dur_txt.strip(""))
elif event_type == "line_cross":
if biz_rule:
reason = "%s:目标「%s」跨过布控「%s」的警戒线" % (post_label, label or "", zone_name or "")
else:
reason = "目标「%s」越线" % (label or "")
elif event_type == "line_count":
direction = (track or {}).get("line_count_direction") or ""
fwd = int((track or {}).get("forward_count") or 0)
rev = int((track or {}).get("reverse_count") or 0)
dir_txt = "正向" if direction == "forward" else ("逆向" if direction == "reverse" else direction)
if biz_rule:
reason = "%s:「%s%s过线,累计 正向 %d / 逆向 %d" % (
post_label, zone_name or "", dir_txt, fwd, rev)
else:
reason = "%s过线计数 正向 %d / 逆向 %d" % (dir_txt, fwd, rev)
elif event_type == "direction":
if biz_rule:
reason = "%s:目标「%s」在「%s」按设定方向移动" % (post_label, label or "", zone_name or "")
else:
reason = "目标「%s」方向匹配" % (label or "")
elif event_type == "density":
count = (track or {}).get("density_count") or 0
threshold = int((zone_cfg or {}).get("density_threshold") or 0)
if biz_rule:
reason = "%s:「%s」目标数 %d ≥ 阈值 %d" % (post_label, zone_name or "", count, threshold)
else:
reason = "密度告警:%d 个目标" % count
elif event_type == "motion":
return {}
else:
reason = event_type or ""
if not biz_name and biz_rule:
biz_name = "业务算法#%s" % biz_id if biz_id else ""
return {
"zone_name": zone_name,
"biz_algorithm_id": biz_id,
"biz_algorithm_name": biz_name,
"alarm_reason": reason,
}

30
app/analysis/detector.py Normal file
View File

@ -0,0 +1,30 @@
"""向后兼容 shim保留 ObjectDetector 名称,内部委托给 OnnxEngine
旧代码 `from app.analysis.detector import ObjectDetector` 仍可工作
新代码请直接使用 `app.analysis.engines.EngineFactory`
"""
import logging
from app.analysis.engines.onnx_engine import OnnxEngine
logger = logging.getLogger("analysis.detector")
class ObjectDetector(OnnxEngine):
"""已废弃:等价于 OnnxEnginedetect 任务)。保留以兼容旧 import。"""
def __init__(self, model_path=None, labels=None, input_size=(640, 640),
conf_threshold=0.4, iou_threshold=0.5, providers=None):
super(ObjectDetector, self).__init__(
model_file=model_path or "",
labels=labels,
input_size=input_size,
conf_threshold=conf_threshold,
iou_threshold=iou_threshold,
providers=providers,
task_type="detect",
device="cpu",
algorithm_type="yolo8",
)
if model_path:
self.load()

View File

@ -0,0 +1,21 @@
"""统一推理引擎抽象层
每个引擎实现 BaseEngine 接口 EngineFactory inference_engine 名分发
所有引擎对外的 detect() 输入为 BGR frame输出为 list[DetectionResult]
CameraPipeline 兼容
支持引擎
- yolo_pytorch (Yolo-PyTorch主引擎)
- onnxruntime
- openvino
"""
from app.analysis.engines.base import BaseEngine, EngineNotAvailableError, DetectionResult
from app.analysis.engines.factory import EngineFactory
__all__ = [
"BaseEngine",
"EngineNotAvailableError",
"DetectionResult",
"EngineFactory",
]

View File

@ -0,0 +1,108 @@
"""引擎抽象基类与公共数据结构"""
import logging
logger = logging.getLogger("analysis.engines")
class EngineNotAvailableError(RuntimeError):
"""引擎依赖未安装"""
class DetectionResult(dict):
"""单条检测结果:{box:[x1,y1,x2,y2], label:str, score:float}"""
@property
def box(self):
return self.get("box", [0, 0, 0, 0])
@property
def label(self):
return self.get("label", "")
@property
def score(self):
return self.get("score", 0.0)
class BaseEngine(object):
"""所有推理引擎的统一接口。
子类需实现
- is_available() @staticmethod 该引擎依赖是否已安装
- load() 加载模型成功返回 True
- detect(frame_bgr) 输入 BGR frame输出 list[DetectionResult]
- info() 返回引擎元数据 dict
"""
ENGINE_NAME = "base"
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"):
self.model_file = model_file or ""
self.labels = labels or []
self.input_size = input_size
self.conf_threshold = conf_threshold
self.iou_threshold = iou_threshold
self.providers = providers or []
self.algorithm_type = algorithm_type
self.algorithm_version = algorithm_version
self.task_type = (task_type or "detect").lower()
self.device = device or "cpu"
self._loaded = False
@staticmethod
def is_available():
raise NotImplementedError
def load(self):
raise NotImplementedError
def ready(self):
return self._loaded
def detect(self, frame_bgr):
raise NotImplementedError
def info(self):
return {
"engine": self.ENGINE_NAME,
"model_file": self.model_file,
"input_size": list(self.input_size),
"labels_count": len(self.labels),
"conf_threshold": self.conf_threshold,
"iou_threshold": self.iou_threshold,
"loaded": self._loaded,
}
def _resolve_labels(self, model_file):
"""从 sidecar .labels / .yaml/.names 文件推断标签"""
import os
if not model_file:
return []
base, _ = os.path.splitext(model_file)
for ext in (".labels", ".names"):
p = base + ext
if os.path.exists(p):
try:
with open(p, "r", encoding="utf-8") as f:
return [ln.strip() for ln in f if ln.strip()]
except Exception as e:
logger.warning("%s: 读取 %s 失败: %s", self.ENGINE_NAME, p, e)
# YOLOv5/v8 yaml
yaml_p = base + ".yaml"
if os.path.exists(yaml_p):
try:
import yaml
with open(yaml_p, "r", encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
names = cfg.get("names") or []
if isinstance(names, list):
return [str(x) for x in names]
if isinstance(names, dict):
return [str(names[k]) for k in sorted(names.keys())]
except Exception as e:
logger.warning("%s: 解析 %s 失败: %s", self.ENGINE_NAME, yaml_p, e)
return []

View File

@ -0,0 +1,110 @@
"""引擎工厂:按 inference_engine 名分发实例
注册引擎
- yolo_pytorch : Yolo-PyTorchultralytics 原生全版本全任务主引擎
- onnxruntime : OnnxRuntime
- openvino : OpenVINO
"""
import logging
from app.analysis.engines.base import BaseEngine, EngineNotAvailableError
from app.analysis.engines.yolo_pytorch_engine import YoloPytorchEngine
from app.analysis.engines.onnx_engine import OnnxEngine
from app.analysis.engines.openvino_engine import OpenVinoEngine
from app.analysis.engines.reid_onnx_engine import ReidOnnxEngine
logger = logging.getLogger("analysis.engines.factory")
_ENGINE_REGISTRY = {
"yolo_pytorch": YoloPytorchEngine,
"yolopytorch": YoloPytorchEngine, # 别名
"pytorch": YoloPytorchEngine, # 兼容旧名
"onnxruntime": OnnxEngine,
"onnx": OnnxEngine,
"openvino": OpenVinoEngine,
}
# 设备选项按引擎分组(供前端动态下拉使用)
_DEVICE_OPTIONS = {
"yolo_pytorch": [
{"value": "cpu", "label": "CPU"},
{"value": "cuda", "label": "CUDA (GPU)"},
],
"onnxruntime": [
{"value": "cpu", "label": "CPU"},
{"value": "cuda", "label": "CUDA (GPU)"},
],
"openvino": [
{"value": "cpu", "label": "CPU"},
{"value": "gpu", "label": "GPU (Intel iGPU/dGPU)"},
],
}
def list_engines():
"""返回所有注册引擎的可用性信息"""
out = []
seen = set()
for name, cls in _ENGINE_REGISTRY.items():
if name in ("yolopytorch", "pytorch", "onnx"):
continue
if name in seen:
continue
seen.add(name)
try:
available = cls.is_available()
version = cls.version() if available else None
except Exception as e:
available = False
version = None
logger.warning("list_engines %s err: %s", name, e)
# 主引擎额外提供 ultralytics 版本与 CUDA 可用性
item = {"name": name, "available": available, "version": version,
"devices": _DEVICE_OPTIONS.get(name, _DEVICE_OPTIONS["yolo_pytorch"])}
if name == "yolo_pytorch" and available:
try:
item["ultralytics_version"] = YoloPytorchEngine.ultralytics_version()
import torch
item["cuda_available"] = bool(torch.cuda.is_available())
except Exception:
pass
out.append(item)
return out
def device_options(engine_name):
return _DEVICE_OPTIONS.get((engine_name or "").lower(), _DEVICE_OPTIONS["yolo_pytorch"])
class EngineFactory(object):
@staticmethod
def create(engine_name, **kwargs):
task_type = (kwargs.get("task_type") or "detect").lower()
if task_type == "reid":
eng = (engine_name or "").lower()
if eng not in ("onnxruntime", "onnx"):
raise EngineNotAvailableError("ReID models only support onnxruntime")
if not ReidOnnxEngine.is_available():
raise EngineNotAvailableError("reid onnxruntime not installed")
return ReidOnnxEngine(**kwargs)
cls = _ENGINE_REGISTRY.get((engine_name or "").lower())
if cls is None:
raise EngineNotAvailableError("unknown engine: %s" % engine_name)
if not cls.is_available():
raise EngineNotAvailableError("engine %s not installed" % engine_name)
return cls(**kwargs)
@staticmethod
def is_available(engine_name):
cls = _ENGINE_REGISTRY.get((engine_name or "").lower())
return bool(cls and cls.is_available())
@staticmethod
def list_engines():
return list_engines()
@staticmethod
def device_options(engine_name):
return device_options(engine_name)

View File

@ -0,0 +1,157 @@
"""OnnxRuntime 引擎实现 —— YOLO 5/8/11/26 + 全任务 + 设备支持
依赖onnxruntime, opencv-python, numpy
推理设备
cpu -> CPUExecutionProvider
cuda/gpu -> CUDAExecutionProvider不可用回退 CPU
"""
import logging
import os
from app.analysis.engines.base import BaseEngine, DetectionResult
logger = logging.getLogger("analysis.engines.onnx")
try:
import numpy as np
_NP_AVAILABLE = True
except Exception:
np = None
_NP_AVAILABLE = False
try:
import cv2
_CV2_AVAILABLE = True
except Exception:
cv2 = None
_CV2_AVAILABLE = False
try:
import onnxruntime as ort
_ORT_AVAILABLE = True
except Exception:
ort = None
_ORT_AVAILABLE = False
def _providers_for_device(device):
d = (device or "cpu").lower()
if d in ("cuda", "gpu", "0") and _ORT_AVAILABLE:
return ["CUDAExecutionProvider", "CPUExecutionProvider"]
return ["CPUExecutionProvider"]
class OnnxEngine(BaseEngine):
ENGINE_NAME = "onnxruntime"
def __init__(self, **kwargs):
super(OnnxEngine, self).__init__(**kwargs)
self._session = None
self._input_name = None
self._output_names = None
self.task_type = (kwargs.get("task_type") or "detect").lower()
self.device = kwargs.get("device") or "cpu"
if not self.providers:
self.providers = _providers_for_device(self.device)
@staticmethod
def is_available():
return _ORT_AVAILABLE and _CV2_AVAILABLE and _NP_AVAILABLE
@staticmethod
def version():
if not _ORT_AVAILABLE:
return None
try:
return getattr(ort, "__version__", "unknown")
except Exception:
return "unknown"
def load(self):
if not self.is_available():
logger.warning("OnnxEngine: 依赖未安装 (ort=%s cv2=%s np=%s)", _ORT_AVAILABLE, _CV2_AVAILABLE, _NP_AVAILABLE)
return False
if not self.model_file or not os.path.exists(self.model_file):
logger.warning("OnnxEngine: 模型文件不存在: %s", self.model_file)
return False
if not self.labels:
self.labels = self._resolve_labels(self.model_file)
try:
so = ort.SessionOptions()
so.log_severity_level = 3
self._session = ort.InferenceSession(self.model_file, sess_options=so, providers=self.providers)
self._input_name = self._session.get_inputs()[0].name
self._output_names = [o.name for o in self._session.get_outputs()]
self._loaded = True
logger.info("OnnxEngine: 已加载 %s, task=%s, labels=%d, providers=%s",
self.model_file, self.task_type, len(self.labels), self._session.get_providers())
return True
except Exception as e:
logger.error("OnnxEngine: 加载失败: %s", e)
self._loaded = False
self._session = None
return False
def _preprocess(self, frame_bgr):
iw, ih = self.input_size
resized = cv2.resize(frame_bgr, (iw, ih))
rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)
blob = rgb.astype(np.float32) / 255.0
blob = np.transpose(blob, (2, 0, 1))[None, ...]
return blob
def detect(self, frame_bgr):
if not self.ready() or frame_bgr is None:
return []
try:
h, w = frame_bgr.shape[:2]
blob = self._preprocess(frame_bgr)
outputs = self._session.run(self._output_names, {self._input_name: blob})
from app.analysis.engines.yolo_postprocess import decode_outputs
results = decode_outputs(
outputs=outputs,
algorithm_type=self.algorithm_type,
task_type=self.task_type,
labels=self.labels,
input_size=self.input_size,
conf_threshold=self.conf_threshold,
iou_threshold=self.iou_threshold,
orig_size=(w, h),
)
return [DetectionResult(**r) for r in results]
except Exception as e:
logger.warning("OnnxEngine.detect() err: %s", e)
return []
def info(self):
d = super(OnnxEngine, self).info()
d["version"] = self.version()
d["task_type"] = self.task_type
d["device"] = self.device
d["providers"] = self._session.get_providers() if self._session else []
d["cuda_available"] = ("CUDAExecutionProvider" in (self._session.get_providers() if self._session else []))
return d
def probe(self):
info = {"engine": self.ENGINE_NAME, "available": self.is_available(),
"version": self.version(), "input_shape": None, "output_shape": None,
"labels": self.labels, "model_file": self.model_file,
"task_type": self.task_type, "device": self.device}
if not self.is_available() or not self.model_file or not os.path.exists(self.model_file):
return info
try:
so = ort.SessionOptions()
so.log_severity_level = 3
sess = ort.InferenceSession(self.model_file, sess_options=so, providers=["CPUExecutionProvider"])
inputs = sess.get_inputs()
outputs = sess.get_outputs()
info["input_shape"] = list(inputs[0].shape) if inputs else None
info["output_shape"] = [list(o.shape) for o in outputs] if outputs else None
if inputs and len(inputs[0].shape) >= 4:
info["input_size_inferred"] = (int(inputs[0].shape[-1]), int(inputs[0].shape[-2]))
if not self.labels:
self.labels = self._resolve_labels(self.model_file)
info["labels"] = self.labels
except Exception as e:
info["error"] = str(e)
return info

View File

@ -0,0 +1,202 @@
"""OpenVINO 引擎实现 —— YOLO 5/8/11/26 + 全任务 + 设备支持
依赖openvino, opencv-python, numpy
推理设备
cpu -> CPU
gpu -> GPU
cuda -> 不适用回退 CPUOpenVINO 不走 CUDA
"""
import logging
import os
from app.analysis.engines.base import BaseEngine, DetectionResult
logger = logging.getLogger("analysis.engines.openvino")
try:
import numpy as np
_NP_AVAILABLE = True
except Exception:
np = None
_NP_AVAILABLE = False
try:
import cv2
_CV2_AVAILABLE = True
except Exception:
cv2 = None
_CV2_AVAILABLE = False
try:
from openvino import Core
_OV_AVAILABLE = True
except Exception:
try:
from openvino.runtime import Core
_OV_AVAILABLE = True
except Exception:
Core = None
_OV_AVAILABLE = False
def _device_for(device):
d = (device or "cpu").lower()
if d in ("gpu", "cuda", "0"):
return "GPU"
return "CPU"
class OpenVinoEngine(BaseEngine):
ENGINE_NAME = "openvino"
def __init__(self, **kwargs):
super(OpenVinoEngine, self).__init__(**kwargs)
self._compiled = None
self._infer_req = None
self._input_key = None
self._output_keys = None
self.task_type = (kwargs.get("task_type") or "detect").lower()
self.device = kwargs.get("device") or "cpu"
@staticmethod
def is_available():
return _OV_AVAILABLE and _CV2_AVAILABLE and _NP_AVAILABLE
@staticmethod
def version():
if not _OV_AVAILABLE:
return None
try:
from openvino.runtime import get_version
return get_version()
except Exception:
pass
try:
import openvino
return getattr(openvino, "__version__", "unknown")
except Exception:
return "unknown"
def _resolve_model_path(self):
if not self.model_file:
return None
if self.model_file.lower().endswith(".onnx"):
return self.model_file
base, ext = os.path.splitext(self.model_file)
if ext.lower() == ".xml":
return self.model_file
xml_p = base + ".xml"
if os.path.exists(xml_p):
return xml_p
return None
def load(self):
if not self.is_available():
logger.warning("OpenVinoEngine: 依赖未安装")
return False
path = self._resolve_model_path()
if not path or not os.path.exists(path):
logger.warning("OpenVinoEngine: 模型文件不可用: %s", self.model_file)
return False
if not self.labels:
self.labels = self._resolve_labels(self.model_file)
try:
core = Core()
model = core.read_model(path)
dev = _device_for(self.device)
# GPU 不可用时回退 CPU
try:
self._compiled = core.compile_model(model, dev)
except Exception as e:
logger.warning("OpenVinoEngine: 设备 %s 编译失败,回退 CPU: %s", dev, e)
self._compiled = core.compile_model(model, "CPU")
dev = "CPU"
self._infer_req = self._compiled.create_infer_request()
self._input_key = list(self._compiled.inputs)[0]
self._output_keys = list(self._compiled.outputs)
self._loaded = True
logger.info("OpenVinoEngine: 已加载 %s, task=%s, device=%s, labels=%d",
path, self.task_type, dev, len(self.labels))
return True
except Exception as e:
logger.error("OpenVinoEngine: 加载失败: %s", e)
self._loaded = False
return False
def _preprocess(self, frame_bgr):
iw, ih = self.input_size
resized = cv2.resize(frame_bgr, (iw, ih))
rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)
blob = rgb.astype(np.float32) / 255.0
blob = np.transpose(blob, (2, 0, 1))[None, ...]
return blob
def detect(self, frame_bgr):
if not self.ready() or frame_bgr is None:
return []
try:
h, w = frame_bgr.shape[:2]
blob = self._preprocess(frame_bgr)
self._infer_req.infer({self._input_key: blob})
outputs = []
out_count = len(self._output_keys)
for i in range(out_count):
try:
outputs.append(self._infer_req.get_output_tensor(i).data)
except Exception:
try:
outputs.append(self._infer_req.get_output_tensor().data)
except Exception as e:
logger.warning("OpenVinoEngine: 读取输出 tensor[%d] 失败: %s", i, e)
if not outputs:
return []
from app.analysis.engines.yolo_postprocess import decode_outputs
results = decode_outputs(
outputs=outputs,
algorithm_type=self.algorithm_type,
task_type=self.task_type,
labels=self.labels,
input_size=self.input_size,
conf_threshold=self.conf_threshold,
iou_threshold=self.iou_threshold,
orig_size=(w, h),
)
return [DetectionResult(**r) for r in results]
except Exception as e:
logger.warning("OpenVinoEngine.detect() err: %s", e)
return []
def info(self):
d = super(OpenVinoEngine, self).info()
d["version"] = self.version()
d["task_type"] = self.task_type
d["device"] = _device_for(self.device)
return d
def probe(self):
info = {"engine": self.ENGINE_NAME, "available": self.is_available(),
"version": self.version(), "input_shape": None, "output_shape": None,
"labels": self.labels, "model_file": self.model_file,
"task_type": self.task_type, "device": _device_for(self.device)}
if not self.is_available() or not self.model_file:
return info
path = self._resolve_model_path()
if not path or not os.path.exists(path):
info["error"] = "model file not resolvable"
return info
try:
core = Core()
model = core.read_model(path)
if model.inputs:
shape = list(model.inputs[0].shape)
info["input_shape"] = shape
if len(shape) >= 4:
info["input_size_inferred"] = (int(shape[-1]), int(shape[-2]))
if model.outputs:
info["output_shape"] = [list(o.shape) for o in model.outputs]
if not self.labels:
self.labels = self._resolve_labels(self.model_file)
info["labels"] = self.labels
except Exception as e:
info["error"] = str(e)
return info

View File

@ -0,0 +1,187 @@
"""PyTorch 引擎实现
支持加载 ultralytics YOLOv8/v5/v7 .pt 模型依赖 ultralytics
或原生 torch.hub YOLOv5 custom 加载
依赖torch, ultralytics推荐
"""
import logging
import os
from app.analysis.engines.base import BaseEngine, DetectionResult
logger = logging.getLogger("analysis.engines.pytorch")
try:
import numpy as np
_NP_AVAILABLE = True
except Exception:
np = None
_NP_AVAILABLE = False
try:
import cv2
_CV2_AVAILABLE = True
except Exception:
cv2 = None
_CV2_AVAILABLE = False
try:
import torch
_TORCH_AVAILABLE = True
except Exception:
torch = None
_TORCH_AVAILABLE = False
try:
from ultralytics import YOLO as _UltralyticsYOLO
_ULTRALYTICS_AVAILABLE = True
except Exception:
_UltralyticsYOLO = None
_ULTRALYTICS_AVAILABLE = False
class PyTorchEngine(BaseEngine):
ENGINE_NAME = "pytorch"
def __init__(self, **kwargs):
super(PyTorchEngine, self).__init__(**kwargs)
self._model = None
self._kind = None # "ultralytics" / "torchhub"
@staticmethod
def is_available():
return _TORCH_AVAILABLE and _CV2_AVAILABLE and _NP_AVAILABLE
@staticmethod
def version():
if not _TORCH_AVAILABLE:
return None
try:
return getattr(torch, "__version__", "unknown")
except Exception:
return "unknown"
@staticmethod
def ultralytics_available():
return _ULTRALYTICS_AVAILABLE
def load(self):
if not self.is_available():
logger.warning("PyTorchEngine: 依赖未安装 (torch=%s)", _TORCH_AVAILABLE)
return False
if not self.model_file or not os.path.exists(self.model_file):
logger.warning("PyTorchEngine: 模型文件不存在: %s", self.model_file)
return False
from app.utils.ModelTrust import require_trusted_model
require_trusted_model(self.model_file)
if not self.labels:
self.labels = self._resolve_labels(self.model_file)
# 优先用 ultralytics 加载 .pt / .engine / .onnx
if _ULTRALYTICS_AVAILABLE:
try:
self._model = _UltralyticsYOLO(self.model_file)
self._kind = "ultralytics"
self._loaded = True
# 推断 input_size
try:
cfg = getattr(self._model, "overrides", {}) or {}
imgsz = cfg.get("imgsz", None)
if isinstance(imgsz, int) and imgsz > 0:
self.input_size = (int(imgsz), int(imgsz))
except Exception:
pass
logger.info("PyTorchEngine(ultralytics): 已加载 %s, labels=%d", self.model_file, len(self.labels))
return True
except Exception as e:
logger.warning("PyTorchEngine(ultralytics) 加载失败,尝试 torch.hub: %s", e)
# 退化torch.hub YOLOv5 custom
try:
self._model = torch.hub.load("ultralytics/yolov5", "custom", path=self.model_file, trust_repo=True)
self._kind = "torchhub"
self._loaded = True
logger.info("PyTorchEngine(torchhub): 已加载 %s", self.model_file)
return True
except Exception as e:
logger.error("PyTorchEngine: 加载失败: %s", e)
self._loaded = False
self._model = None
return False
def detect(self, frame_bgr):
if not self.ready() or frame_bgr is None or self._model is None:
return []
try:
if self._kind == "ultralytics":
iw, ih = self.input_size
res = self._model.predict(frame_bgr, imgsz=max(iw, ih), conf=self.conf_threshold,
iou=self.iou_threshold, verbose=False)
return self._parse_ultralytics(res, frame_bgr.shape[:2])
else:
# torchhub yolov5
res = self._model(frame_bgr, size=max(self.input_size))
return self._parse_torchhub(res, frame_bgr.shape[:2])
except Exception as e:
logger.warning("PyTorchEngine.detect() err: %s", e)
return []
def _parse_ultralytics(self, results, orig_shape):
out = []
try:
r = results[0]
if hasattr(r, "boxes") and r.boxes is not None:
boxes = r.boxes.xyxy.cpu().numpy().astype(int)
confs = r.boxes.conf.cpu().numpy()
cls_ids = r.boxes.cls.cpu().numpy().astype(int)
names = getattr(r, "names", {}) or {}
for (x1, y1, x2, y2), s, cid in zip(boxes, confs, cls_ids):
if s < self.conf_threshold:
continue
label = names.get(int(cid), str(int(cid))) if isinstance(names, dict) else (
self.labels[int(cid)] if 0 <= int(cid) < len(self.labels) else str(int(cid)))
out.append(DetectionResult(box=[int(x1), int(y1), int(x2), int(y2)],
label=str(label), score=float(s)))
except Exception as e:
logger.warning("PyTorchEngine._parse_ultralytics err: %s", e)
return out
def _parse_torchhub(self, results, orig_shape):
out = []
try:
df = results.pandas().xyxy[0]
for _, row in df.iterrows():
s = float(row["confidence"])
if s < self.conf_threshold:
continue
out.append(DetectionResult(box=[int(row["xmin"]), int(row["ymin"]), int(row["xmax"]), int(row["ymax"])],
label=str(row["name"]), score=s))
except Exception as e:
logger.warning("PyTorchEngine._parse_torchhub err: %s", e)
return out
def info(self):
d = super(PyTorchEngine, self).info()
d["version"] = self.version()
d["ultralytics_available"] = _ULTRALYTICS_AVAILABLE
d["kind"] = self._kind
d["device"] = "cuda" if (_TORCH_AVAILABLE and torch.cuda.is_available()) else "cpu"
return d
def probe(self):
info = {"engine": self.ENGINE_NAME, "available": self.is_available(),
"version": self.version(), "ultralytics_available": _ULTRALYTICS_AVAILABLE,
"input_shape": None, "output_shape": None,
"labels": self.labels, "model_file": self.model_file}
if not self.is_available() or not self.model_file or not os.path.exists(self.model_file):
return info
try:
# 仅读文件大小不实际加载torch 模型加载慢且占内存)
info["model_file_size"] = os.path.getsize(self.model_file)
if not self.labels:
self.labels = self._resolve_labels(self.model_file)
info["labels"] = self.labels
except Exception as e:
info["error"] = str(e)
return info

View File

@ -0,0 +1,198 @@
# 作者:北小菜
"""ReID 特征提取引擎 — OSNet 系列 ONNX仅 OnnxRuntime
输入人体 crop BGR resize ImageNet 归一化 embedding 向量
输出512 L2 归一化特征默认 OSNet-AIN x1.0
"""
import logging
import os
from app.analysis.engines.base import BaseEngine, EngineNotAvailableError
logger = logging.getLogger("analysis.engines.reid_onnx")
try:
import numpy as np
_NP = True
except Exception:
np = None
_NP = False
try:
import cv2
_CV2 = True
except Exception:
cv2 = None
_CV2 = False
try:
import onnxruntime as ort
_ORT = True
except Exception:
ort = None
_ORT = False
REID_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) if _NP else None
REID_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32) if _NP else None
def _providers_for_device(device):
d = (device or "cpu").lower()
if d in ("cuda", "gpu", "0") and _ORT:
avail = ort.get_available_providers()
if "CUDAExecutionProvider" in avail:
return ["CUDAExecutionProvider", "CPUExecutionProvider"]
return ["CPUExecutionProvider"]
class ReidOnnxEngine(BaseEngine):
"""ReID embedding 引擎batch=1 静态 ONNX"""
ENGINE_NAME = "reid_onnx"
def __init__(self, **kwargs):
super(ReidOnnxEngine, self).__init__(**kwargs)
self.task_type = "reid"
self._session = None
self._input_name = None
self._output_name = None
self._embedding_dim = 512
if not self.providers:
self.providers = _providers_for_device(self.device)
@staticmethod
def is_available():
return _ORT and _CV2 and _NP
@staticmethod
def version():
if not _ORT:
return None
try:
return getattr(ort, "__version__", "unknown")
except Exception:
return "unknown"
def load(self):
if not self.is_available():
logger.warning("ReidOnnxEngine: 依赖未安装")
return False
if not self.model_file or not os.path.exists(self.model_file):
logger.warning("ReidOnnxEngine: 模型不存在 %s", self.model_file)
return False
try:
so = ort.SessionOptions()
so.log_severity_level = 3
self._session = ort.InferenceSession(
self.model_file, sess_options=so, providers=self.providers)
self._input_name = self._session.get_inputs()[0].name
self._output_name = self._session.get_outputs()[0].name
out_shape = self._session.get_outputs()[0].shape
if out_shape and len(out_shape) >= 2 and out_shape[-1]:
try:
self._embedding_dim = int(out_shape[-1])
except Exception:
pass
self._loaded = True
logger.info("ReidOnnxEngine: loaded %s providers=%s dim=%d",
self.model_file, self._session.get_providers(), self._embedding_dim)
return True
except Exception as e:
logger.error("ReidOnnxEngine load failed: %s", e)
self._session = None
self._loaded = False
return False
@property
def session(self):
return self._session
def _preprocess_crop(self, frame_bgr, box):
iw = int(self.input_size[0] or 128)
ih = int(self.input_size[1] or 256)
x1, y1, x2, y2 = [int(v) for v in box]
h, w = frame_bgr.shape[:2]
x1 = max(0, min(x1, w - 1))
x2 = max(0, min(x2, w))
y1 = max(0, min(y1, h - 1))
y2 = max(0, min(y2, h))
if x2 <= x1 or y2 <= y1:
return None
crop = frame_bgr[y1:y2, x1:x2]
if crop.size == 0:
return None
rgb = cv2.cvtColor(crop, cv2.COLOR_BGR2RGB)
resized = cv2.resize(rgb, (iw, ih), interpolation=cv2.INTER_LINEAR)
arr = resized.astype(np.float32) / 255.0
arr = (arr - REID_MEAN) / REID_STD
return np.transpose(arr, (2, 0, 1))[None, ...].astype(np.float32)
def extract_embeddings(self, frame_bgr, boxes):
"""对多个 bbox 提取 embedding返回与 boxes 对齐的 (valid_idx, embeddings)。"""
if not self.ready() or frame_bgr is None:
return [], np.zeros((0, self._embedding_dim), dtype=np.float32)
valid_idx = []
rows = []
for i, box in enumerate(boxes or []):
blob = self._preprocess_crop(frame_bgr, box)
if blob is None:
continue
out = self._session.run([self._output_name], {self._input_name: blob})[0]
vec = np.asarray(out, dtype=np.float32).reshape(-1)
norm = np.linalg.norm(vec)
if norm > 1e-12:
vec = vec / norm
valid_idx.append(i)
rows.append(vec)
if not rows:
return [], np.zeros((0, self._embedding_dim), dtype=np.float32)
return valid_idx, np.stack(rows, axis=0)
def detect(self, frame_bgr):
"""兼容 BaseEngine 接口ReID 单模型无法对全图直接检测,返回空列表。"""
return []
def probe(self):
info = {
"engine": self.ENGINE_NAME,
"available": self.is_available(),
"version": self.version(),
"input_shape": None,
"output_shape": None,
"labels": [],
"model_file": self.model_file,
"task_type": "reid",
"algorithm_type": self.algorithm_type,
"device": self.device,
"embedding_dim": self._embedding_dim,
}
if not self.is_available() or not self.model_file or not os.path.exists(self.model_file):
return info
try:
so = ort.SessionOptions()
so.log_severity_level = 3
sess = ort.InferenceSession(self.model_file, sess_options=so, providers=["CPUExecutionProvider"])
inputs = sess.get_inputs()
outputs = sess.get_outputs()
info["input_shape"] = list(inputs[0].shape) if inputs else None
info["output_shape"] = [list(o.shape) for o in outputs] if outputs else None
if inputs and len(inputs[0].shape) >= 4:
# ONNX NCHW: [N,C,H,W] → width=shape[-1], height=shape[-2]
info["input_size_inferred"] = (int(inputs[0].shape[-1]), int(inputs[0].shape[-2]))
if outputs and outputs[0].shape:
sh = outputs[0].shape
if len(sh) >= 2 and sh[-1]:
info["embedding_dim"] = int(sh[-1])
except Exception as e:
info["error"] = str(e)
if self.model_file and os.path.isfile(self.model_file):
info["model_file_size"] = os.path.getsize(self.model_file)
return info
def info(self):
d = super(ReidOnnxEngine, self).info()
d["version"] = self.version()
d["task_type"] = "reid"
d["embedding_dim"] = self._embedding_dim
d["providers"] = self._session.get_providers() if self._session else []
return d

View File

@ -0,0 +1,358 @@
"""YOLO 共享后处理 —— 按 (algorithm_type, task_type) 分发
支持版本yolo5 / yolo8 / yolo11 / yolo26
支持任务detect / segment / classify / pose / obb
输出结构差异
- yolo5 detect: output [1, N, 5+nc] 行格式 [cx, cy, w, h, obj, cls_scores...]
- yolo8/11/26 detect: output [1, nc+4, N] 列格式 [cx, cy, w, h, cls_score_0, cls_score_1, ...] obj
- yolo5 segment: outputs[0] [1, N, 5+nc+nm], outputs[1] [1, nm, mh, mw] (protos)
- yolo8/11/26 segment: outputs[0] [1, nc+4+nm, N], outputs[1] [1, nm, mh, mw]
- classify (all): output [1, nc] top-1 logits
- yolo8/11/26 pose: output [1, nc+4+nk*3, N] (nk 通常 17)
- yolo8/11/26 obb: output [1, nc+4+1, N] 末列是角度 (radians)
本模块仅依赖 numpy + opencv OnnxEngine / OpenVinoEngine 复用
YoloPytorchEngine 直接用 ultralytics Results 对象不走本模块
"""
import logging
import numpy as np
logger = logging.getLogger("analysis.engines.yolo_post")
try:
import cv2
_CV2 = True
except Exception:
cv2 = None
_CV2 = False
def decode_outputs(outputs, algorithm_type, task_type, labels, input_size,
conf_threshold, iou_threshold, orig_size, num_classes=None):
"""主入口:返回 list[dict],每条至少含 box/label/score按任务附加上下文。
outputs: list[np.ndarray] 原始模型输出顺序与 onnx session.get_outputs() 一致
"""
try:
if task_type == "classify":
return _decode_classify(outputs, labels)
if task_type == "detect":
return _decode_detect(outputs, algorithm_type, labels, input_size,
conf_threshold, iou_threshold, orig_size, num_classes)
if task_type == "segment":
return _decode_segment(outputs, algorithm_type, labels, input_size,
conf_threshold, iou_threshold, orig_size, num_classes)
if task_type == "pose":
return _decode_pose(outputs, algorithm_type, labels, input_size,
conf_threshold, iou_threshold, orig_size, num_classes)
if task_type == "obb":
return _decode_obb(outputs, algorithm_type, labels, input_size,
conf_threshold, iou_threshold, orig_size, num_classes)
logger.warning("yolo_post: 未知任务类型 %s", task_type)
return []
except Exception as e:
logger.warning("yolo_post decode err: %s", e)
return []
def _is_v5(algorithm_type):
return algorithm_type in ("yolo5", "yolov5", "v5")
def _xywh2xyxy(xywh):
x, y, w, h = xywh[..., 0], xywh[..., 1], xywh[..., 2], xywh[..., 3]
return np.stack([x - w / 2, y - h / 2, x + w / 2, y + h / 2], axis=-1)
def _nms(boxes, scores, iou_threshold):
if len(boxes) == 0:
return []
boxes_list = boxes.tolist() if hasattr(boxes, "tolist") else [list(b) for b in boxes]
scores_list = scores.tolist() if hasattr(scores, "tolist") else list(scores)
if _CV2:
try:
idx = cv2.dnn.NMSBoxes(boxes_list, scores_list, 0.0, float(iou_threshold))
if idx is None or len(idx) == 0:
return []
if hasattr(idx, "flatten"):
idx = idx.flatten()
return [int(i) for i in idx]
except Exception as e:
logger.debug("cv2 NMS fallback: %s", e)
# OpenCV 不可用或 NMS 失败时的朴素实现
order = np.argsort(-np.asarray(scores_list, dtype=np.float32))
suppressed = np.zeros(len(scores_list), dtype=bool)
keep = []
boxes_arr = np.asarray(boxes_list, dtype=np.float32)
for i in order:
if suppressed[i]:
continue
keep.append(int(i))
for j in order:
if j == i or suppressed[j]:
continue
if _iou(boxes_arr[i], boxes_arr[j]) > float(iou_threshold):
suppressed[j] = True
return keep
def _iou(a, b):
xa, ya = max(a[0], b[0]), max(a[1], b[1])
xb, yb = min(a[2], b[2]), min(a[3], b[3])
iw, ih = max(0.0, xb - xa), max(0.0, yb - ya)
inter = iw * ih
area_a = max(0.0, a[2] - a[0]) * max(0.0, a[3] - a[1])
area_b = max(0.0, b[2] - b[0]) * max(0.0, b[3] - b[1])
union = area_a + area_b - inter
return inter / union if union > 0 else 0.0
def _scale_box(box, sx, sy, orig_w, orig_h):
x1 = max(0, min(orig_w - 1, box[0] * sx))
y1 = max(0, min(orig_h - 1, box[1] * sy))
x2 = max(0, min(orig_w - 1, box[2] * sx))
y2 = max(0, min(orig_h - 1, box[3] * sy))
return [int(x1), int(y1), int(x2), int(y2)]
# ===================== classify =====================
def _decode_classify(outputs, labels):
out = np.asarray(outputs[0])
if out.ndim == 2 and out.shape[0] == 1:
out = out[0]
if out.ndim != 1:
# 某些导出会带 batch 维 + 多余维度
out = out.reshape(-1)
cid = int(np.argmax(out))
score = float(out[cid])
label = labels[cid] if 0 <= cid < len(labels) else str(cid)
return [{"box": [0, 0, 0, 0], "label": str(label), "score": score, "task": "classify"}]
# ===================== detect =====================
def _decode_detect(outputs, algorithm_type, labels, input_size, conf, iou, orig_size, nc=None):
arr = np.asarray(outputs[0])
iw, ih = input_size
orig_w, orig_h = orig_size
sx, sy = float(orig_w) / iw, float(orig_h) / ih
if _is_v5(algorithm_type):
# [1, N, 5+nc] 或 [N, 5+nc]
if arr.ndim == 3 and arr.shape[0] == 1:
arr = arr[0]
if arr.ndim != 2 or arr.shape[1] < 6:
return []
nc = arr.shape[1] - 5 if nc is None else nc
if nc < 1:
nc = 1
objs = arr[:, 4]
cls_scores = arr[:, 5:5 + nc]
# score = obj * cls
scores_all = objs[:, None] * cls_scores # [N, nc]
best_cls = np.argmax(scores_all, axis=1)
best_score = scores_all[np.arange(len(arr)), best_cls]
mask = best_score >= conf
if not np.any(mask):
return []
xywh = arr[mask, :4]
boxes_raw = _xywh2xyxy(xywh)
scores = best_score[mask]
clses = best_cls[mask]
else:
# yolo8/11/26: [1, nc+4, N] 需转置
if arr.ndim == 3 and arr.shape[0] == 1:
arr = arr[0]
if arr.ndim != 2:
return []
# 行格式可能是 [4+nc, N] —— 转成 [N, 4+nc]
if arr.shape[0] < arr.shape[1] and arr.shape[0] >= 4:
arr = arr.T
if arr.ndim != 2 or arr.shape[1] < 5:
return []
nc = arr.shape[1] - 4 if nc is None else nc
if nc < 1:
nc = 1
boxes_raw = _xywh2xyxy(arr[:, :4])
cls_scores = arr[:, 4:4 + nc]
best_cls = np.argmax(cls_scores, axis=1)
best_score = cls_scores[np.arange(len(arr)), best_cls]
mask = best_score >= conf
if not np.any(mask):
return []
boxes_raw = boxes_raw[mask]
scores = best_score[mask]
clses = best_cls[mask]
boxes = [_scale_box(b, sx, sy, orig_w, orig_h) for b in boxes_raw]
keep = _nms(boxes, scores, iou)
out = []
for i in keep:
label = labels[int(clses[i])] if 0 <= int(clses[i]) < len(labels) else str(int(clses[i]))
out.append({"box": boxes[i], "label": str(label), "score": float(scores[i]), "task": "detect"})
return out
# ===================== segment =====================
def _decode_segment(outputs, algorithm_type, labels, input_size, conf, iou, orig_size, nc=None):
arr = np.asarray(outputs[0])
proto = np.asarray(outputs[1]) if len(outputs) > 1 else None
iw, ih = input_size
orig_w, orig_h = orig_size
sx, sy = float(orig_w) / iw, float(orig_h) / ih
if _is_v5(algorithm_type):
if arr.ndim == 3 and arr.shape[0] == 1:
arr = arr[0]
if arr.ndim != 2 or arr.shape[1] < 6:
return []
nm = arr.shape[1] - 5 - (nc or (arr.shape[1] - 5))
# 推断 nm若 nc 已知
if nc is None:
# 退化:用 arr.shape[1]-5 / 2 估计(不可靠),优先按 nc 推断
nm = 32
nc = arr.shape[1] - 5 - nm
else:
nm = arr.shape[1] - 5 - nc
if nm < 1:
nm = 32
objs = arr[:, 4]
cls_scores = arr[:, 5:5 + nc]
scores_all = objs[:, None] * cls_scores
best_cls = np.argmax(scores_all, axis=1)
best_score = scores_all[np.arange(len(arr)), best_cls]
mask = best_score >= conf
if not np.any(mask):
return []
xywh = arr[mask, :4]
boxes_raw = _xywh2xyxy(xywh)
coeffs = arr[mask, 5 + nc:5 + nc + nm]
scores = best_score[mask]
clses = best_cls[mask]
else:
if arr.ndim == 3 and arr.shape[0] == 1:
arr = arr[0]
if arr.ndim != 2:
return []
if arr.shape[0] < arr.shape[1] and arr.shape[0] >= 4:
arr = arr.T
if arr.ndim != 2 or arr.shape[1] < 5:
return []
if nc is None:
# 含 mask 系数:列数 = 4 + nc + nmnm 一般 32
nm = 32
nc = arr.shape[1] - 4 - nm
else:
nm = arr.shape[1] - 4 - nc
if nm < 1:
nm = 32
nc = arr.shape[1] - 4 - nm
boxes_raw = _xywh2xyxy(arr[:, :4])
cls_scores = arr[:, 4:4 + nc]
coeffs = arr[:, 4 + nc:4 + nc + nm]
best_cls = np.argmax(cls_scores, axis=1)
best_score = cls_scores[np.arange(len(arr)), best_cls]
mask = best_score >= conf
if not np.any(mask):
return []
boxes_raw = boxes_raw[mask]
coeffs = coeffs[mask]
scores = best_score[mask]
clses = best_cls[mask]
boxes = [_scale_box(b, sx, sy, orig_w, orig_h) for b in boxes_raw]
keep = _nms(boxes, scores, iou)
out = []
for i in keep:
label = labels[int(clses[i])] if 0 <= int(clses[i]) < len(labels) else str(int(clses[i]))
item = {"box": boxes[i], "label": str(label), "score": float(scores[i]), "task": "segment"}
# mask 系数(简化:仅返回系数数组,由上层按需合成;不强制合成完整 mask 避免性能开销)
try:
item["mask_coeffs"] = [float(x) for x in coeffs[i]]
except Exception:
pass
out.append(item)
return out
# ===================== pose =====================
def _decode_pose(outputs, algorithm_type, labels, input_size, conf, iou, orig_size, nc=None):
arr = np.asarray(outputs[0])
iw, ih = input_size
orig_w, orig_h = orig_size
sx, sy = float(orig_w) / iw, float(orig_h) / ih
# 仅 yolo8/11/26 pose 普遍为 [1, 4+1+nk*3, N]4 box + 1 conf + nk*3 kpt
if arr.ndim == 3 and arr.shape[0] == 1:
arr = arr[0]
if arr.ndim != 2:
return []
if arr.shape[0] < arr.shape[1] and arr.shape[0] >= 5:
arr = arr.T
if arr.ndim != 2 or arr.shape[1] < 6:
return []
# 列布局:[cx, cy, w, h, conf, kpt_x, kpt_y, kpt_conf, ...] —— 标准 ultralytics pose 导出
confs = arr[:, 4]
mask = confs >= conf
if not np.any(mask):
return []
arr = arr[mask]
boxes_raw = _xywh2xyxy(arr[:, :4])
confs = arr[:, 4]
# 关键点:剩余列按 [x, y, conf] 三元组
kpt_cols = arr.shape[1] - 5
nk = kpt_cols // 3
kpts = arr[:, 5:5 + nk * 3].reshape(-1, nk, 3) if nk > 0 else None
boxes = [_scale_box(b, sx, sy, orig_w, orig_h) for b in boxes_raw]
keep = _nms(boxes, confs, iou)
out = []
for i in keep:
label = labels[0] if labels else "person"
item = {"box": boxes[i], "label": str(label), "score": float(confs[i]), "task": "pose"}
if kpts is not None:
kp = kpts[i]
# 缩放关键点 xy
item["keypoints"] = [
[float(kp[j, 0] * sx), float(kp[j, 1] * sy), float(kp[j, 2])]
for j in range(nk)
]
out.append(item)
return out
# ===================== obb =====================
def _decode_obb(outputs, algorithm_type, labels, input_size, conf, iou, orig_size, nc=None):
arr = np.asarray(outputs[0])
iw, ih = input_size
orig_w, orig_h = orig_size
sx, sy = float(orig_w) / iw, float(orig_h) / ih
# yolo8/11/26 obb: [1, 4+nc+1, N] 末列角度
if arr.ndim == 3 and arr.shape[0] == 1:
arr = arr[0]
if arr.ndim != 2:
return []
if arr.shape[0] < arr.shape[1] and arr.shape[0] >= 5:
arr = arr.T
if arr.ndim != 2 or arr.shape[1] < 6:
return []
nc = arr.shape[1] - 5 if nc is None else nc
if nc < 1:
nc = 1
boxes_raw = _xywh2xyxy(arr[:, :4])
cls_scores = arr[:, 4:4 + nc]
angles = arr[:, 4 + nc]
best_cls = np.argmax(cls_scores, axis=1)
best_score = cls_scores[np.arange(len(arr)), best_cls]
mask = best_score >= conf
if not np.any(mask):
return []
boxes_raw = boxes_raw[mask]
scores = best_score[mask]
clses = best_cls[mask]
angles = angles[mask]
boxes = [_scale_box(b, sx, sy, orig_w, orig_h) for b in boxes_raw]
keep = _nms(boxes, scores, iou)
out = []
for i in keep:
label = labels[int(clses[i])] if 0 <= int(clses[i]) < len(labels) else str(int(clses[i]))
out.append({"box": boxes[i], "label": str(label), "score": float(scores[i]),
"task": "obb", "angle": float(angles[i])})
return out

View File

@ -0,0 +1,276 @@
"""Yolo-PyTorch 引擎(主引擎,最高准确率)
基于 ultralytics 原生支持 YOLOv5/v8/v11/YOLO26 全部任务
detect / segment / classify / pose / obb
依赖torch, ultralytics, opencv-python, numpy
推理设备cpu / cuda / cuda:0 / 0 / gpu
"""
import logging
import os
from app.analysis.engines.base import BaseEngine, DetectionResult
logger = logging.getLogger("analysis.engines.yolo_pytorch")
try:
import numpy as np
_NP_AVAILABLE = True
except Exception:
np = None
_NP_AVAILABLE = False
try:
import cv2
_CV2_AVAILABLE = True
except Exception:
cv2 = None
_CV2_AVAILABLE = False
try:
import torch
_TORCH_AVAILABLE = True
except Exception:
torch = None
_TORCH_AVAILABLE = False
try:
from ultralytics import YOLO as _UltralyticsYOLO
_ULTRALYTICS_AVAILABLE = True
except Exception:
_UltralyticsYOLO = None
_ULTRALYTICS_AVAILABLE = False
def _normalize_device(device, algorithm_type):
"""把模型字段 device 归一化为 ultralytics 可接受的 device 字符串。
cpu -> 'cpu'
cuda -> 'cuda'如可用否则回退 'cpu'
gpu -> 'cuda'OpenVINO 风格命名兼容
"""
if not device:
return "cpu"
d = str(device).lower().strip()
if d in ("cpu", ""):
return "cpu"
if d in ("cuda", "gpu", "cuda:0", "0"):
if _TORCH_AVAILABLE and torch.cuda.is_available():
return "cuda:0" if d in ("gpu", "0") else "cuda"
logger.warning("YoloPytorchEngine: CUDA 不可用,回退 CPU")
return "cpu"
return d
class YoloPytorchEngine(BaseEngine):
ENGINE_NAME = "yolo_pytorch"
def __init__(self, **kwargs):
super(YoloPytorchEngine, self).__init__(**kwargs)
self._model = None
self.task_type = (kwargs.get("task_type") or "detect").lower()
self.device = kwargs.get("device") or "cpu"
@staticmethod
def is_available():
return _TORCH_AVAILABLE and _ULTRALYTICS_AVAILABLE and _CV2_AVAILABLE and _NP_AVAILABLE
@staticmethod
def version():
if not _TORCH_AVAILABLE:
return None
try:
return getattr(torch, "__version__", "unknown")
except Exception:
return "unknown"
@staticmethod
def ultralytics_version():
if not _ULTRALYTICS_AVAILABLE:
return None
try:
import ultralytics
return getattr(ultralytics, "__version__", "unknown")
except Exception:
return "unknown"
def load(self):
if not self.is_available():
logger.warning("YoloPytorchEngine: 依赖未安装 (torch=%s ultralytics=%s cv2=%s np=%s)",
_TORCH_AVAILABLE, _ULTRALYTICS_AVAILABLE, _CV2_AVAILABLE, _NP_AVAILABLE)
return False
if not self.model_file or not os.path.exists(self.model_file):
logger.warning("YoloPytorchEngine: 模型文件不存在: %s", self.model_file)
return False
from app.utils.ModelTrust import require_trusted_model
require_trusted_model(self.model_file)
if not self.labels:
self.labels = self._resolve_labels(self.model_file)
try:
self._model = _UltralyticsYOLO(self.model_file, task=self.task_type)
# 推断 input_size
try:
cfg = getattr(self._model, "overrides", {}) or {}
imgsz = cfg.get("imgsz", None)
if isinstance(imgsz, int) and imgsz > 0:
self.input_size = (int(imgsz), int(imgsz))
elif isinstance(imgsz, (list, tuple)) and len(imgsz) >= 1:
s = int(imgsz[0])
self.input_size = (s, s)
except Exception:
pass
# 预热(小尺寸 dummy让模型迁移到目标设备
try:
dev = _normalize_device(self.device, self.algorithm_type)
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,
verbose=False, save=False)
except Exception as e:
logger.warning("YoloPytorchEngine 预热失败(忽略): %s", e)
self._loaded = True
logger.info("YoloPytorchEngine: 已加载 %s task=%s device=%s labels=%d",
self.model_file, self.task_type, _normalize_device(self.device, self.algorithm_type),
len(self.labels))
return True
except Exception as e:
logger.error("YoloPytorchEngine: 加载失败: %s", e)
self._loaded = False
self._model = None
return False
def detect(self, frame_bgr):
if not self.ready() or frame_bgr is None or self._model is None:
return []
try:
dev = _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,
verbose=False, save=False)
return self._parse_results(results)
except Exception as e:
logger.warning("YoloPytorchEngine.detect() err: %s", e)
return []
def _parse_results(self, results):
out = []
try:
r = results[0]
task = self.task_type
if task == "classify":
probs = getattr(r, "probs", None)
if probs is not None:
cid = int(probs.top1)
score = float(probs.top1conf)
names = getattr(r, "names", {}) or {}
label = names.get(cid, str(cid)) if isinstance(names, dict) else (
self.labels[cid] if 0 <= cid < len(self.labels) else str(cid))
out.append(DetectionResult(box=[0, 0, 0, 0], label=str(label), score=score, task="classify"))
return out
# 其余任务都有 boxes
boxes_obj = getattr(r, "boxes", None)
names = getattr(r, "names", {}) or {}
if boxes_obj is not None:
try:
xyxy = boxes_obj.xyxy.cpu().numpy()
confs = boxes_obj.conf.cpu().numpy()
cls_ids = boxes_obj.cls.cpu().numpy().astype(int)
for (b, s, cid) in zip(xyxy, confs, cls_ids):
if s < self.conf_threshold:
continue
label = names.get(int(cid), str(int(cid))) if isinstance(names, dict) else (
self.labels[int(cid)] if 0 <= int(cid) < len(self.labels) else str(int(cid)))
item = DetectionResult(box=[int(b[0]), int(b[1]), int(b[2]), int(b[3])],
label=str(label), score=float(s), task=task)
out.append(item)
except Exception as e:
logger.warning("YoloPytorchEngine parse boxes err: %s", e)
# segment: masks
if task == "segment":
masks_obj = getattr(r, "masks", None)
if masks_obj is not None and out:
try:
# masks_obj.xy 是 list of (N,2) 多边形点(原图坐标)
polys = masks_obj.xy
for i, p in enumerate(polys):
if i < len(out):
out[i]["mask_polygon"] = [[float(x), float(y)] for x, y in p]
except Exception as e:
logger.warning("YoloPytorchEngine parse masks err: %s", e)
# pose: keypoints
if task == "pose":
kp_obj = getattr(r, "keypoints", None)
if kp_obj is not None and out:
try:
kpts = kp_obj.xy.cpu().numpy() # [N, nk, 2]
confs = kp_obj.conf.cpu().numpy() # [N, nk]
for i in range(min(len(kpts), len(out))):
kp = kpts[i]
kc = confs[i]
out[i]["keypoints"] = [
[float(kp[j, 0]), float(kp[j, 1]), float(kc[j])]
for j in range(len(kp))
]
except Exception as e:
logger.warning("YoloPytorchEngine parse keypoints err: %s", e)
# obb: rotated boxes + angle
if task == "obb":
obb_obj = getattr(r, "obb", None)
if obb_obj is not None and out:
try:
# obb.theta: [N] radians
thetas = obb_obj.theta.cpu().numpy()
for i in range(min(len(thetas), len(out))):
out[i]["angle"] = float(thetas[i])
except Exception as e:
logger.warning("YoloPytorchEngine parse obb err: %s", e)
except Exception as e:
logger.warning("YoloPytorchEngine._parse_results err: %s", e)
return out
def info(self):
d = super(YoloPytorchEngine, self).info()
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["cuda_available"] = bool(_TORCH_AVAILABLE and torch.cuda.is_available())
return d
def probe(self):
info = {"engine": self.ENGINE_NAME, "available": self.is_available(),
"version": self.version(), "ultralytics_version": self.ultralytics_version(),
"cuda_available": bool(_TORCH_AVAILABLE and torch.cuda.is_available()),
"input_shape": None, "output_shape": None,
"labels": self.labels, "model_file": self.model_file,
"task_type": self.task_type, "device": self.device}
if not self.is_available() or not self.model_file or not os.path.exists(self.model_file):
return info
try:
info["model_file_size"] = os.path.getsize(self.model_file)
if not self.labels:
self.labels = self._resolve_labels(self.model_file)
info["labels"] = self.labels
# 尝试读取 ultralytics yaml 的 imgsz 与 task
base, _ = os.path.splitext(self.model_file)
for p in (base + ".yaml", os.path.join(os.path.dirname(self.model_file), "model.yaml")):
if os.path.exists(p):
try:
import yaml
with open(p, "r", encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
if "imgsz" in cfg:
s = int(cfg["imgsz"]) if not isinstance(cfg["imgsz"], (list, tuple)) else int(cfg["imgsz"][0])
info["input_size_inferred"] = (s, s)
if "task" in cfg and not self.task_type:
info["task_type"] = str(cfg["task"])
except Exception:
pass
break
except Exception as e:
info["error"] = str(e)
return info

View File

@ -0,0 +1,82 @@
"""分析事件桥 — 消费子进程 event_queue在主进程写库"""
import logging
import queue
import threading
logger = logging.getLogger("analysis.event_bridge")
_BRIDGE = None
_BRIDGE_LOCK = threading.Lock()
class AnalysisEventBridge(object):
def __init__(self):
self._thread = None
self._running = False
self._queues = []
self._lock = threading.Lock()
def register_queue(self, q):
with self._lock:
if q not in self._queues:
self._queues.append(q)
def unregister_queue(self, q):
with self._lock:
if q in self._queues:
self._queues.remove(q)
def start(self):
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._loop, name="analysis-event-bridge", daemon=True)
self._thread.start()
logger.info("AnalysisEventBridge 已启动")
def _loop(self):
while self._running:
handled = False
with self._lock:
queues = list(self._queues)
for q in queues:
try:
while True:
msg = q.get_nowait()
self._handle(msg)
handled = True
except queue.Empty:
continue
if not handled:
threading.Event().wait(0.05)
def _handle(self, msg):
if not msg or len(msg) < 2:
return
kind, payload = msg[0], msg[1]
try:
if kind == "event":
self._on_event(payload)
elif kind == "touch":
self._on_touch(payload)
except Exception as e:
logger.exception("事件桥处理失败: %s", e)
def _on_event(self, event):
from app.services.alarm_service import write_alarm, ALARM_EVENT_TYPES
etype = event.get("type", "")
if etype in ALARM_EVENT_TYPES:
write_alarm(event)
def _on_touch(self, payload):
# 已停用:不再写追踪快照
pass
def get_event_bridge():
global _BRIDGE
with _BRIDGE_LOCK:
if _BRIDGE is None:
_BRIDGE = AnalysisEventBridge()
_BRIDGE.start()
return _BRIDGE

140
app/analysis/frames.py Normal file
View File

@ -0,0 +1,140 @@
"""帧源:从 ZLMediaKit 输出的 RTSP 流取帧(纯 Python · OpenCV"""
import logging
import time
logger = logging.getLogger("analysis.frames")
try:
import cv2
_CV2_AVAILABLE = True
except Exception as e:
logger.warning("frames: OpenCV 未安装,取帧能力不可用: %s" % str(e))
cv2 = None
_CV2_AVAILABLE = False
class FrameSource(object):
"""从 RTSP URL 持续读取帧;失败时按间隔自动重连。"""
HEALTH_CONNECTING = "connecting"
HEALTH_OK = "ok"
HEALTH_RECONNECTING = "reconnecting"
HEALTH_DISCONNECTED = "disconnected"
def __init__(self, rtsp_url, target_fps=5, reconnect_interval=3.0):
self.rtsp_url = rtsp_url
self.target_fps = max(1, int(target_fps))
self.reconnect_interval = max(1.0, float(reconnect_interval))
self._cap = None
self._src_fps = 25
self._frame_skip = 0
self._closed = False
self._health = self.HEALTH_CONNECTING
self._last_ok_ts = 0.0
self._last_reconnect_ts = 0.0
self._reconnect_fail_count = 0
self._total_reconnects = 0
def health_snapshot(self):
stalled_sec = 0.0
if self._last_ok_ts > 0:
stalled_sec = max(0.0, time.time() - self._last_ok_ts)
return {
"stream_health": self._health,
"last_frame_ts": self._last_ok_ts,
"stalled_sec": round(stalled_sec, 1),
"reconnect_fail_count": self._reconnect_fail_count,
"total_reconnects": self._total_reconnects,
}
def open(self):
if not _CV2_AVAILABLE:
raise RuntimeError("OpenCV 不可用,无法取帧")
if self._cap:
try:
self._cap.release()
except Exception:
pass
self._cap = None
self._cap = cv2.VideoCapture(self.rtsp_url, cv2.CAP_FFMPEG)
if not self._cap or not self._cap.isOpened():
self._health = self.HEALTH_DISCONNECTED
raise RuntimeError("打开流失败: %s" % self.rtsp_url)
self._src_fps = float(self._cap.get(cv2.CAP_PROP_FPS)) or 25.0
self._frame_skip = max(0, int(self._src_fps / self.target_fps) - 1)
self._health = self.HEALTH_OK
logger.info(
"FrameSource.open() url=%s src_fps=%.1f target_fps=%d skip=%d",
self.rtsp_url, self._src_fps, self.target_fps, self._frame_skip,
)
return True
def read(self):
"""返回 (ok, frame_bgr);失败时进入重连流程"""
if self._closed:
return False, None
if self._cap is None or not self._cap.isOpened():
return self._reconnect_and_read()
try:
ok = True
for _ in range(self._frame_skip + 1):
ok = self._cap.grab()
if not ok:
break
if not ok:
return self._reconnect_and_read()
ret, frame = self._cap.retrieve()
if not ret or frame is None:
return self._reconnect_and_read()
self._last_ok_ts = time.time()
self._health = self.HEALTH_OK
self._reconnect_fail_count = 0
return True, frame
except Exception as e:
logger.warning("FrameSource.read() error: %s" % str(e))
return self._reconnect_and_read()
def _reconnect_and_read(self):
now = time.time()
self._health = self.HEALTH_RECONNECTING
if now - self._last_reconnect_ts < self.reconnect_interval:
return False, None
self._last_reconnect_ts = now
self._reconnect_fail_count += 1
try:
if self._cap:
self._cap.release()
self._cap = None
self.open()
self._total_reconnects += 1
logger.info(
"FrameSource 重连成功 url=%s (累计重连 %d 次)",
self.rtsp_url, self._total_reconnects,
)
ret, frame = self._cap.read()
if ret and frame is not None:
self._last_ok_ts = time.time()
self._health = self.HEALTH_OK
self._reconnect_fail_count = 0
return True, frame
except Exception as e:
logger.warning(
"FrameSource 重连失败 #%d url=%s: %s",
self._reconnect_fail_count, self.rtsp_url, str(e),
)
self._health = self.HEALTH_DISCONNECTED
return False, None
def close(self):
self._closed = True
self._health = self.HEALTH_DISCONNECTED
try:
if self._cap:
self._cap.release()
except Exception:
pass
self._cap = None
@staticmethod
def is_available():
return _CV2_AVAILABLE

View File

@ -0,0 +1,309 @@
"""共享推理子进程池 — 多路摄像头复用同一组 YOLO 引擎,避免每路重复加载模型。
阶段2架构
- 主进程启动 N InferenceWorker 子进程默认 1可配置 analysisInferenceWorkers
- 子进程内按 algorithm_id 缓存引擎实例
- 请求/响应通过 multiprocessing.Queue 传递帧以 JPEG 压缩跨进程体积可控
"""
import json
import logging
import multiprocessing as mp
import os
import queue
import threading
import time
import uuid
logger = logging.getLogger("analysis.inference_pool")
_POOL = None
_POOL_LOCK = threading.Lock()
def _resolve_model_path(model_file):
from app.analysis.worker_pool import resolve_model_path
return resolve_model_path(model_file)
def _inference_worker_loop(req_queue, resp_queue, worker_id):
"""子进程推理循环(不 import Django ORM"""
from app.utils.Logger import LOG_FORMAT
if not logging.getLogger().handlers:
logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
engines = {}
log = logging.getLogger("analysis.inference_worker.%s" % worker_id)
log.info("推理 worker 启动")
while True:
try:
msg = req_queue.get(timeout=1.0)
except queue.Empty:
continue
if msg is None or msg.get("cmd") == "stop":
break
req_id = msg.get("req_id")
try:
algo = msg.get("algorithm") or {}
jpeg = msg.get("jpeg")
if not jpeg:
resp_queue.put({"req_id": req_id, "ok": False, "error": "no frame"})
continue
import cv2
import numpy as np
arr = np.frombuffer(jpeg, dtype=np.uint8)
frame = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if frame is None:
resp_queue.put({"req_id": req_id, "ok": False, "error": "decode failed"})
continue
algo_id = algo.get("id", 0)
key = (
algo_id,
algo.get("inference_engine", ""),
algo.get("model_file", ""),
float(algo.get("conf_threshold", 0.4)),
float(algo.get("iou_threshold", 0.5)),
int(algo.get("input_width", 640)),
int(algo.get("input_height", 640)),
algo.get("task_type", "detect"),
algo.get("device", "cpu"),
)
eng = engines.get(key)
if eng is None:
from app.analysis.engines.factory import EngineFactory
labels = algo.get("labels", [])
if isinstance(labels, str):
try:
labels = json.loads(labels)
except Exception:
labels = []
eng = EngineFactory.create(
algo.get("inference_engine", "yolo_pytorch"),
model_file=_resolve_model_path(algo.get("model_file", "")),
labels=labels,
input_size=(int(algo.get("input_width", 640)), int(algo.get("input_height", 640))),
conf_threshold=float(algo.get("conf_threshold", 0.4)),
iou_threshold=float(algo.get("iou_threshold", 0.5)),
algorithm_type=algo.get("algorithm_type", "yolo8"),
task_type=algo.get("task_type", "detect"),
device=algo.get("device", "cpu"),
)
if not eng.load():
resp_queue.put({"req_id": req_id, "ok": False, "error": "engine load failed"})
continue
engines[key] = eng
results = eng.detect(frame)
for r in results:
r["algorithm_id"] = algo_id
r["algorithm_name"] = algo.get("name", "")
resp_queue.put({"req_id": req_id, "ok": True, "detections": results})
except Exception as e:
log.exception("推理异常: %s", e)
resp_queue.put({"req_id": req_id, "ok": False, "error": str(e)})
for eng in engines.values():
try:
if hasattr(eng, "unload"):
eng.unload()
except Exception:
pass
log.info("推理 worker 退出")
class InferenceProcessPool(object):
"""主进程侧推理池客户端"""
def __init__(self, num_workers=1):
self.num_workers = max(1, int(num_workers))
self._req_queue = mp.Queue(maxsize=64)
self._resp_queue = mp.Queue(maxsize=64)
self._workers = []
self._pending = {}
self._lock = threading.Lock()
self._running = False
self._drain_thread = None
self._timeout_count = 0
self._last_timeout_ts = 0.0
def start(self):
if self._running:
alive = sum(1 for p in self._workers if p.is_alive())
if alive > 0:
return
logger.warning("InferenceProcessPool 标记运行中但 worker 已死,重新启动")
self.stop()
ctx = mp.get_context("spawn")
self._req_queue = ctx.Queue(maxsize=64)
self._resp_queue = ctx.Queue(maxsize=64)
for i in range(self.num_workers):
p = ctx.Process(
target=_inference_worker_loop,
args=(self._req_queue, self._resp_queue, i),
name="inference-worker-%d" % i,
daemon=True,
)
p.start()
self._workers.append(p)
self._running = True
self._drain_thread = threading.Thread(target=self._drain_responses, name="inference-drain", daemon=True)
self._drain_thread.start()
logger.info("InferenceProcessPool 已启动 workers=%d", self.num_workers)
def _drain_responses(self):
while self._running:
try:
resp = self._resp_queue.get(timeout=0.5)
except queue.Empty:
continue
req_id = resp.get("req_id")
with self._lock:
evt = self._pending.pop(req_id, None)
if evt:
evt["resp"] = resp
evt["event"].set()
def detect(self, frame, algorithm, timeout=30.0):
"""同步推理:将 frame + algorithm dict 发往子进程,等待结果"""
if not self._running:
self.start()
try:
import cv2
ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 85])
if not ok:
return []
jpeg = buf.tobytes()
except Exception as e:
logger.warning("帧编码失败: %s", e)
return []
return self.detect_jpeg(jpeg, algorithm, timeout=timeout)
def _ensure_workers_alive(self):
if not self._workers:
if self._running:
self.stop()
self.start()
return
alive = sum(1 for p in self._workers if p.is_alive())
if alive == 0:
logger.warning("推理 worker 已全部退出,正在重启 InferenceProcessPool")
self.stop()
self.start()
def detect_jpeg(self, jpeg, algorithm, timeout=30.0):
"""同步推理:直接传已编码的 JPEG bytes跳过主进程 imencode/imdecode
消除 _inference_forwarder_loop 中的双重编解码与主进程 GIL 占用"""
self._ensure_workers_alive()
if not self._running:
self.start()
if not jpeg:
return []
req_id = str(uuid.uuid4())
evt = {"event": threading.Event(), "resp": None}
with self._lock:
self._pending[req_id] = evt
algo_payload = algorithm if isinstance(algorithm, dict) else _algorithm_to_dict(algorithm)
try:
self._req_queue.put({"req_id": req_id, "algorithm": algo_payload, "jpeg": jpeg}, timeout=2.0)
except Exception as e:
with self._lock:
self._pending.pop(req_id, None)
logger.warning("推理请求入队失败: %s", e)
return []
if not evt["event"].wait(timeout=timeout):
with self._lock:
self._pending.pop(req_id, None)
self._timeout_count += 1
self._last_timeout_ts = time.time()
logger.warning("推理超时 req_id=%s (累计 %d)", req_id, self._timeout_count)
return []
resp = evt.get("resp") or {}
if not resp.get("ok"):
logger.warning("推理失败: %s", resp.get("error"))
return []
return resp.get("detections") or []
def status(self):
self._ensure_workers_alive()
alive = sum(1 for p in self._workers if p.is_alive())
with self._lock:
tc = self._timeout_count
lts = self._last_timeout_ts
degraded = alive > 0 and tc >= 3 and (time.time() - lts) < 120
return {
"workers": self.num_workers,
"workers_alive": alive,
"running": self._running,
"timeout_count": tc,
"inference_degraded": degraded,
}
def instance_count(self):
"""共享推理 worker 进程数(模型在 worker 内按需加载,非 worker_pool 计数)。"""
return sum(1 for p in self._workers if p.is_alive())
def stop(self):
self._running = False
for _ in self._workers:
try:
self._req_queue.put({"cmd": "stop"}, timeout=1.0)
except Exception:
pass
for p in self._workers:
try:
p.join(timeout=3)
if p.is_alive():
p.terminate()
except Exception:
pass
self._workers = []
logger.info("InferenceProcessPool 已停止")
def _algorithm_to_dict(a):
labels = a.labels
if isinstance(labels, str):
try:
labels = json.loads(labels)
except Exception:
labels = []
return {
"id": a.id,
"name": a.name,
"inference_engine": a.inference_engine,
"model_file": a.model_file,
"labels": labels,
"input_width": a.input_width,
"input_height": a.input_height,
"conf_threshold": a.conf_threshold,
"iou_threshold": a.iou_threshold,
"algorithm_type": a.algorithm_type,
"task_type": getattr(a, "task_type", "detect"),
"device": getattr(a, "device", "cpu"),
}
def get_inference_pool(num_workers=None):
global _POOL
with _POOL_LOCK:
if _POOL is None:
n = num_workers
if n is None:
try:
from app.utils.GlobalUtils import g_config
n = int(getattr(g_config, "analysisInferenceWorkers", 1))
except Exception:
n = 1
_POOL = InferenceProcessPool(num_workers=n)
_POOL.start()
return _POOL
def shutdown_inference_pool():
global _POOL
with _POOL_LOCK:
if _POOL:
_POOL.stop()
_POOL = None

787
app/analysis/manager.py Normal file
View File

@ -0,0 +1,787 @@
"""全局分析管理器(单例)
阶段2每路摄像头在独立子进程中运行 CameraPipelineYOLO 推理可选走
主进程 InferenceProcessPool共享 GPU/模型内存事件经 Queue EventBridge 写库
"""
import json
import logging
import multiprocessing as mp
import threading
import time
from app.analysis.pipeline import CameraPipeline
from app.analysis.motion import MotionDetector
from app.analysis.worker_pool import DetectorWorkerPool
from app.analysis.process_worker import pipeline_process_main, PipelineProcessHandle
from app.analysis.event_bridge import get_event_bridge
logger = logging.getLogger("analysis.manager")
def _snapshot_storage_paths():
"""主进程解析报警快照目录,注入子进程(子进程不可 import GlobalUtils"""
import os
from framework.settings import BASE_DIR
from app.utils.GlobalUtils import g_config
static_dir = os.path.join(str(BASE_DIR), "static")
alarm_dir = getattr(g_config, "storageAlarmDir", "") or os.path.join(static_dir, "storage", "alarm")
return alarm_dir, static_dir
def _algorithm_to_spec(a):
labels = a.labels
if isinstance(labels, str):
try:
labels = json.loads(labels)
except Exception:
labels = []
return {
"id": a.id,
"name": a.name,
"inference_engine": a.inference_engine,
"model_file": a.model_file,
"labels": labels,
"input_width": a.input_width,
"input_height": a.input_height,
"conf_threshold": a.conf_threshold,
"iou_threshold": a.iou_threshold,
"algorithm_type": a.algorithm_type,
"task_type": getattr(a, "task_type", "detect"),
"device": getattr(a, "device", "cpu"),
}
def _biz_algo_to_zone_dict(ba):
from app.utils.Credentials import decrypt_credential
labels = ba.target_labels or '[]'
try:
labels_list = json.loads(labels) if isinstance(labels, str) else labels
except Exception:
labels_list = []
llm_cfg = None
if ba.llm_id and ba.llm:
llm_cfg = {
"id": ba.llm_id,
"api_url": ba.llm.api_url,
"api_key": decrypt_credential(ba.llm.api_key),
"model_name": ba.llm.model_name,
"timeout": ba.llm.timeout,
"inference_tool": ba.llm.inference_tool or "OpenAI",
}
return {
"id": ba.id,
"name": ba.name or "",
"flow_type": ba.flow_type,
"small_model_id": ba.small_model_id,
"detector_model_id": ba.detector_model_id,
"target_labels": labels_list,
"llm_id": ba.llm_id,
"llm_prompt": ba.llm_prompt or "",
"llm_validate": ba.llm_validate or "",
"post_process": ba.post_process or "AREA",
"ref_angle": float(getattr(ba, "ref_angle", 90.0) or 90.0),
"angle_tolerance": float(getattr(ba, "angle_tolerance", 45.0) or 45.0),
"forward_count_threshold": int(getattr(ba, "forward_count_threshold", 0) or 0),
"reverse_count_threshold": int(getattr(ba, "reverse_count_threshold", 0) or 0),
"llm": llm_cfg,
}
class AnalysisManager(object):
_instance = None
_instance_lock = threading.Lock()
def __new__(cls, *args, **kwargs):
if cls._instance is None:
with cls._instance_lock:
if cls._instance is None:
cls._instance = super(AnalysisManager, cls).__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if getattr(self, "_initialized", False):
return
self._initialized = True
self._pipelines = {} # stream_id -> handle dict
self._lock = threading.RLock()
self._worker_pool = DetectorWorkerPool()
self._mp_ctx = mp.get_context("spawn")
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)
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()
def _use_multiprocess(self):
try:
from app.utils.GlobalUtils import g_config
mode = int(getattr(g_config, "analysisProcessMode", 1))
return mode >= 1
except Exception:
return True
def _use_shared_inference(self):
try:
from app.utils.GlobalUtils import g_config
return bool(getattr(g_config, "analysisSharedInference", True))
except Exception:
return True
def set_inference_config(self, shared=None, workers=None):
"""热更新推理配置(不持久化)。
- shared: 切换共享推理开关切换后需重启所有运行中的 pipeline
- workers: 调整共享推理 worker 调整后重启 inference_pool
返回 (ok, msg)
"""
from app.utils.GlobalUtils import g_config
old_shared = self._use_shared_inference()
old_workers = int(getattr(g_config, "analysisInferenceWorkers", 2))
shared_changed = False
workers_changed = False
if shared is not None:
try:
new_shared = bool(int(shared))
except Exception:
new_shared = old_shared
if new_shared != old_shared:
g_config.analysisSharedInference = new_shared
shared_changed = True
if workers is not None:
try:
new_workers = max(1, min(32, int(workers)))
except Exception:
new_workers = old_workers
if new_workers != old_workers:
g_config.analysisInferenceWorkers = new_workers
workers_changed = True
# 重启推理池worker 数变了,或从非共享切到共享)
if workers_changed or (shared_changed and self._use_shared_inference()):
try:
from app.analysis.inference_pool import shutdown_inference_pool, get_inference_pool
shutdown_inference_pool()
if self._use_shared_inference():
get_inference_pool() # 会按新 worker 数重建
except Exception as e:
logger.warning("重启推理池失败: %s" % str(e))
# shared 切换后重启所有运行中的 pipeline让新模式生效
if shared_changed:
with self._lock:
sids = list(self._pipelines.keys())
for sid in sids:
try:
from app.models import StreamModel as _SM
s = _SM.objects.get(id=sid)
self.stop(sid)
self.start(s)
except Exception as e:
logger.warning("shared 切换重启 pipeline sid=%s 失败: %s" % (sid, str(e)))
if not shared_changed and not workers_changed:
return True, "配置未变化"
return True, "配置已热生效"
def set_algo_instance_enabled(self, algo_id, enabled):
"""设置业务算法的实例化开关(内存,重启丢失)。立即生效,无需重启 pipeline。"""
try:
aid = int(algo_id)
except Exception:
return False, "invalid algorithm_id"
with self._lock:
if enabled:
self._disabled_algos.discard(aid)
else:
self._disabled_algos.add(aid)
return True, "ok"
def is_algo_instance_enabled(self, algo_id):
try:
aid = int(algo_id)
except Exception:
return True
return aid not in self._disabled_algos
def get_disabled_algos(self):
with self._lock:
return set(self._disabled_algos)
def restart_algo_instance(self, algo_id):
"""重启使用指定算法的所有 pipeline重新加载引擎
algo_id 是小模型 AlgorithmModel.id
"""
try:
aid = int(algo_id)
except Exception:
return False, "invalid algorithm_id"
with self._lock:
sids = []
for sid, item in list(self._pipelines.items()):
algo_ids = item.get("algorithm_ids") or []
if aid in algo_ids:
sids.append(sid)
if not sids:
return True, "没有运行中的 pipeline 使用该算法"
restarted = 0
for sid in sids:
try:
from app.models import StreamModel as _SM
s = _SM.objects.get(id=sid)
self.stop(sid)
self.start(s)
restarted += 1
except Exception as e:
logger.warning("restart_algo_instance sid=%s 失败: %s" % (sid, str(e)))
return True, "已重启 %d 路 pipeline" % restarted
def restart_inference_pool(self):
"""重启整个推理池(清除所有 worker 子进程内的引擎缓存)。"""
try:
from app.analysis.inference_pool import shutdown_inference_pool, get_inference_pool
shutdown_inference_pool()
if self._use_shared_inference():
get_inference_pool()
return True, "推理池已重启,所有引擎缓存已清除"
except Exception as e:
return False, str(e)
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)
except _q.Empty:
continue
if msg is None:
break
req_id = msg.get("req_id")
try:
jpeg = msg.get("jpeg")
algo = msg.get("algorithm") or {}
# 禁用实例化的算法直接返回空结果,跳过推理
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": []})
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})
except Exception as e:
logger.warning("推理转发失败: %s", e)
try:
self._infer_resp_q.put({"req_id": req_id, "ok": False, "error": str(e)})
except Exception:
pass
def _configure_from_settings(self):
try:
from app.models import AlgorithmModel
default = AlgorithmModel.objects.filter(is_default=1, state=1).first()
if default:
self._default_algorithm = default
self._target_fps = 5
return
except Exception as e:
logger.warning("AnalysisManager 读取默认 AlgorithmModel 失败: %s" % str(e))
try:
from app.utils.GlobalUtils import g_config
self._target_fps = int(getattr(g_config, "analysisTargetFps", 5))
except Exception:
self._target_fps = 5
self._default_algorithm = None
@staticmethod
def build_rtsp_url(stream):
try:
from app.utils.GlobalUtils import g_config
ip = getattr(g_config, "externalHost", "127.0.0.1") or "127.0.0.1"
if ip == "0.0.0.0":
ip = "127.0.0.1"
port = getattr(g_config, "mediaRtspPort", 10554)
app = getattr(stream, "app", "live") or "live"
name = getattr(stream, "name", getattr(stream, "code", "stream")) or "stream"
return "rtsp://%s:%s/%s/%s" % (ip, int(port), app, name)
except Exception as e:
logger.warning("build_rtsp_url 失败: %s" % str(e))
return ""
@staticmethod
def _zone_analyze_fps(interval_sec, detect_frames):
interval = max(0.1, float(interval_sec or 1))
frames = max(1, int(detect_frames or 1))
return float(frames) / interval
@staticmethod
def _compute_analyze_fps(stream_id, fallback=None):
"""取该摄像头所有启用布控中最高的算法分析频率(帧/秒)"""
try:
from app.models import ZoneModel
qs = ZoneModel.objects.filter(stream_id=stream_id, state=1)
max_fps = 0.0
for z in qs:
max_fps = max(max_fps, AnalysisManager._zone_analyze_fps(
getattr(z, "detect_interval_sec", 1),
getattr(z, "detect_frames", 1),
))
if max_fps > 0:
return max_fps
except Exception as e:
logger.warning("_compute_analyze_fps err stream=%s: %s" % (stream_id, str(e)))
if fallback is not None and fallback > 0:
return float(fallback)
return 1.0
@staticmethod
def _load_zones(stream_id):
try:
from app.models import ZoneModel
qs = ZoneModel.objects.filter(stream_id=stream_id, state=1).prefetch_related(
'algorithms', 'algorithms__small_model', 'algorithms__detector_model', 'algorithms__llm')
zones = []
for z in qs:
try:
coords = json.loads(z.coordinates)
except Exception:
coords = []
# LINE_CROSS 警戒线端点(归一化坐标 JSON
line_a = None
line_b = None
try:
la = getattr(z, "line_a", "") or ""
if la:
line_a = json.loads(la)
lb = getattr(z, "line_b", "") or ""
if lb:
line_b = json.loads(lb)
except Exception:
line_a = line_b = None
biz_list = []
biz_ids = []
small_ids = set()
for ba in z.algorithms.filter(state=1):
biz_ids.append(ba.id)
biz_list.append(_biz_algo_to_zone_dict(ba))
if int(ba.flow_type or 0) == 4:
if ba.detector_model_id:
small_ids.add(ba.detector_model_id)
elif ba.small_model_id:
small_ids.add(ba.small_model_id)
interval = max(0.1, float(getattr(z, "detect_interval_sec", 1) or 1))
frames = max(1, int(getattr(z, "detect_frames", 1) or 1))
zones.append({
"id": z.id,
"name": z.name,
"coords": coords,
"is_required": z.is_required,
"loiter_threshold": z.loiter_threshold,
"detect_interval_sec": interval,
"detect_frames": frames,
"line_a": line_a,
"line_b": line_b,
"density_threshold": int(getattr(z, "density_threshold", 0) or 0),
"algorithm_ids": biz_ids,
"biz_algorithms": biz_list,
"small_model_ids": sorted(small_ids),
})
return zones
except Exception as e:
logger.warning("加载 Zone 失败 stream=%s: %s" % (stream_id, str(e)))
return []
def _resolve_algorithms_for_stream(self, stream):
try:
from app.models import ZoneModel, AlgorithmModel
algos = []
seen = set()
for z in ZoneModel.objects.filter(stream_id=stream.id, state=1).prefetch_related(
'algorithms__small_model', 'algorithms__detector_model'):
for ba in z.algorithms.filter(state=1):
if int(ba.flow_type or 0) == 4:
det = ba.detector_model
if det and det.state == 1 and det.id not in seen:
seen.add(det.id)
algos.append(det)
continue
sm = ba.small_model
if sm and sm.state == 1 and sm.id not in seen:
seen.add(sm.id)
algos.append(sm)
sa = getattr(stream, "algorithm", None)
if sa is not None and sa.state == 1 and sa.id not in seen:
seen.add(sa.id)
algos.append(sa)
if not algos:
d = AlgorithmModel.objects.filter(is_default=1, state=1).first()
if d:
algos.append(d)
return algos
except Exception as e:
logger.warning("_resolve_algorithms_for_stream err: %s" % str(e))
return []
def _fallback_engine_from_config(self):
try:
from app.utils.GlobalUtils import g_config
model_path = getattr(g_config, "analysisDetectorModel", "") or ""
if not model_path:
return None
labels = getattr(g_config, "analysisDetectorLabels", [])
if isinstance(labels, str):
labels = [x.strip() for x in labels.split(",") if x.strip()]
conf = float(getattr(g_config, "analysisConfThreshold", 0.4))
from app.analysis.engines.onnx_engine import OnnxEngine
eng = OnnxEngine(model_path=model_path, labels=labels, conf_threshold=conf)
if eng.load():
return eng
except Exception as e:
logger.warning("config fallback engine err: %s" % str(e))
return None
def _start_process(self, stream, url, zones, algos, detectors_legacy=None):
sid = stream.id
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)
storage_alarm_dir, static_dir = _snapshot_storage_paths()
config = {
"stream_id": sid,
"stream_code": getattr(stream, "code", str(sid)),
"rtsp_url": url,
"target_fps": self._target_fps,
"analyze_fps": analyze_fps,
"zones": zones,
"algorithms": algo_specs,
"use_shared_inference": self._use_shared_inference(),
"storage_alarm_dir": storage_alarm_dir,
"static_dir": static_dir,
}
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),
name="pipeline-%s" % sid,
daemon=True,
)
proc.start()
handle = PipelineProcessHandle(sid, proc, event_queue, cmd_queue, self._status_dict)
self._pipelines[sid] = {
"handle": handle,
"process": proc,
"event_queue": event_queue,
"mode": "process",
"running": True,
"pipeline": None,
"thread": None,
"algorithm_ids": sorted([a.id for a in algos]),
}
return True, "started (process)"
def _start_thread(self, stream, url, zones, algos):
sid = stream.id
detectors = []
algo_names = []
for a in algos:
eng = self._worker_pool.get_detector(a)
if eng:
detectors.append({"algorithm_id": a.id, "algorithm_name": a.name, "engine": eng})
algo_names.append(a.name)
if not algos:
eng = self._fallback_engine_from_config()
if eng:
detectors.append({"algorithm_id": 0, "algorithm_name": "config-fallback", "engine": eng})
algo_names.append("config-fallback")
motion = MotionDetector()
analyze_fps = self._compute_analyze_fps(sid, fallback=self._target_fps)
storage_alarm_dir, static_dir = _snapshot_storage_paths()
pipeline = CameraPipeline(
stream_id=sid,
stream_code=getattr(stream, "code", str(sid)),
rtsp_url=url,
detectors=detectors,
motion=motion,
target_fps=self._target_fps,
analyze_fps=analyze_fps,
on_event=self._on_event,
on_track_snapshot=self._on_track_snapshot,
zone_polygons=zones,
storage_alarm_dir=storage_alarm_dir,
static_dir=static_dir,
)
pipeline._algorithm_name = ", ".join(algo_names) if algo_names else "motion-only"
t = threading.Thread(target=pipeline.run, name="pipeline-%s" % sid, daemon=True)
self._pipelines[sid] = {
"pipeline": pipeline,
"thread": t,
"running": True,
"mode": "thread",
"algorithm_ids": sorted([a.id for a in algos]),
}
t.start()
return True, "started (thread)"
def start(self, stream):
sid = stream.id
with self._lock:
item = self._pipelines.get(sid)
if item and item.get("running"):
alive = True
if item.get("mode") == "process":
proc = item.get("process")
alive = proc is not None and proc.is_alive()
else:
th = item.get("thread")
alive = th is not None and th.is_alive()
if alive:
return True, "already running"
# 僵尸条目:进程/线程已退出但未清理
try:
if item.get("mode") == "process":
eq = item.get("event_queue")
if eq:
get_event_bridge().unregister_queue(eq)
else:
pipe = item.get("pipeline")
if pipe:
pipe.stop()
except Exception:
pass
self._pipelines.pop(sid, None)
url = self.build_rtsp_url(stream)
if not url:
return False, "no rtsp url"
algos = self._resolve_algorithms_for_stream(stream)
zones = self._load_zones(sid)
if self._use_multiprocess():
ok, msg = self._start_process(stream, url, zones, algos)
else:
ok, msg = self._start_thread(stream, url, zones, algos)
if ok:
time.sleep(0.35)
if not self.is_running(sid):
self._purge_pipeline(sid)
return False, "analysis subprocess exited (check OpenCV / RTSP / log)"
return ok, msg
def stop(self, stream_id):
with self._lock:
item = self._pipelines.get(stream_id)
if not item:
return False, "not running"
if item.get("mode") == "process":
handle = item.get("handle")
if handle:
handle.stop()
eq = item.get("event_queue")
if eq:
get_event_bridge().unregister_queue(eq)
else:
item["pipeline"].stop()
item["thread"].join(timeout=3)
item["running"] = False
self._pipelines.pop(stream_id, None)
return True, "stopped"
def is_running(self, stream_id):
with self._lock:
item = self._pipelines.get(stream_id)
return self._is_pipeline_alive(item)
def _is_pipeline_alive(self, item):
if not item or not item.get("running"):
return False
if item.get("mode") == "process":
proc = item.get("process")
return proc is not None and proc.is_alive()
th = item.get("thread")
if th is not None and not th.is_alive():
return False
pipe = item.get("pipeline")
if pipe is not None and not getattr(pipe, "_running", False):
return False
return True
def _purge_pipeline(self, stream_id):
item = self._pipelines.pop(stream_id, None)
if not item:
return
try:
if item.get("mode") == "process":
eq = item.get("event_queue")
if eq:
get_event_bridge().unregister_queue(eq)
handle = item.get("handle")
if handle:
try:
handle.stop(timeout=1)
except Exception:
pass
else:
pipe = item.get("pipeline")
if pipe:
try:
pipe.stop()
except Exception:
pass
except Exception:
pass
def list_running(self):
with self._lock:
alive = []
for sid, item in list(self._pipelines.items()):
if self._is_pipeline_alive(item):
alive.append(sid)
else:
self._purge_pipeline(sid)
return alive
def _enrich_pipeline_status(self, stream_id, info):
"""补充流健康状态与摄像头名称(不自动启停分析)。"""
if not info:
return info
try:
from app.models import StreamModel
s = StreamModel.objects.filter(id=stream_id).first()
if s:
info["stream_name"] = s.nickname or s.name or ("#%s" % stream_id)
except Exception:
pass
health = info.get("stream_health") or "ok"
stalled = float(info.get("stalled_sec") or 0)
fps = float(info.get("analysis_fps") or 0)
if health == "ok" and fps <= 0 and stalled >= 20:
info["stream_health"] = "stalled"
health = "stalled"
info["healthy"] = health == "ok" and fps > 0.05
if not info.get("active_zone_ids") and self.is_running(stream_id):
try:
zones = self._load_zones(stream_id)
info["active_zone_ids"] = sorted([int(z["id"]) for z in zones if z.get("id") is not None])
except Exception:
info["active_zone_ids"] = []
return info
def get_pipeline_info(self, stream_id):
with self._lock:
item = self._pipelines.get(stream_id)
if not item:
return None
if item.get("mode") == "process":
handle = item.get("handle")
if handle:
st = handle.status()
if st:
return self._enrich_pipeline_status(stream_id, st)
alive = self.is_running(stream_id)
return self._enrich_pipeline_status(stream_id, {
"stream_id": stream_id,
"running": alive,
"stream_health": "connecting" if alive else "stopped",
"analysis_fps": 0.0,
"stalled_sec": 0,
})
pipe = item.get("pipeline")
if not pipe:
return None
try:
return self._enrich_pipeline_status(stream_id, pipe.status())
except Exception as e:
logger.warning("get_pipeline_info err: %s" % str(e))
return self._enrich_pipeline_status(stream_id, {
"stream_id": stream_id,
"running": False,
"stream_health": "stalled",
"analysis_fps": 0.0,
"stalled_sec": 0,
})
def reload_zones(self, stream_id):
with self._lock:
item = self._pipelines.get(stream_id)
if not item:
return False
zones = self._load_zones(stream_id)
analyze_fps = self._compute_analyze_fps(stream_id, fallback=self._target_fps)
new_small_ids = sorted({sid for z in zones for sid in z.get("small_model_ids", []) if sid})
cur_algo_ids = sorted(item.get("algorithm_ids") or [])
if item.get("mode") == "process":
if new_small_ids != cur_algo_ids:
handle = item.get("handle")
if handle:
handle.stop()
eq = item.get("event_queue")
if eq:
get_event_bridge().unregister_queue(eq)
self._pipelines.pop(stream_id, None)
try:
from app.models import StreamModel as _SM
s = _SM.objects.get(id=stream_id)
self.start(s)
except Exception as e:
logger.warning("reload_zones 重启失败 stream=%s: %s" % (stream_id, str(e)))
else:
handle = item.get("handle")
if handle:
handle.reload_zones(zones, analyze_fps=analyze_fps)
return True
pipe = item.get("pipeline")
if not pipe:
return False
if hasattr(pipe, "set_zone_polygons"):
pipe.set_zone_polygons(zones)
else:
pipe.zone_polygons = zones
pipe.set_analyze_fps(analyze_fps)
pipe.reset_zone_runtime_state()
if new_small_ids != cur_algo_ids:
pipe.stop()
item["running"] = False
try:
item["thread"].join(timeout=3)
except Exception:
pass
self._pipelines.pop(stream_id, None)
try:
from app.models import StreamModel as _SM
s = _SM.objects.get(id=stream_id)
self.start(s)
except Exception as e:
logger.warning("reload_zones 重启失败 stream=%s: %s" % (stream_id, str(e)))
return True
def _on_event(self, event):
try:
from app.services.alarm_service import write_alarm, ALARM_EVENT_TYPES
etype = event.get("type", "")
if etype in ALARM_EVENT_TYPES:
write_alarm(event)
except Exception as e:
logger.exception("事件处理失败: %s ev=%s" % (str(e), str(event)[:200]))
def _on_track_snapshot(self, stream_id, frame_index, active, has_motion):
# 已停用:不再写追踪快照
pass
@staticmethod
def _severity_for(event):
t = event.get("type")
if t in ("loiter", "cross_camera"):
return 1
if t in ("entered_zone", "left_zone", "object_start"):
return 2
return 3

90
app/analysis/motion.py Normal file
View File

@ -0,0 +1,90 @@
"""运动检测OpenCV 背景减除 + 形态学优化)
参考 Frigate 的运动门控思路先做轻量运动检测只在有运动的区域跑目标检测
"""
import logging
logger = logging.getLogger("analysis.motion")
try:
import numpy as np
_NP_AVAILABLE = True
except Exception:
np = None
_NP_AVAILABLE = False
try:
import cv2
_CV2_AVAILABLE = True
except Exception:
cv2 = None
_CV2_AVAILABLE = False
class MotionDetector(object):
"""基于 MOG2 背景减除的运动检测器,输出运动框列表"""
def __init__(self,
frame_width=320,
frame_height=180,
min_area=80,
max_area_ratio=0.6,
variance_threshold=25,
history=100,
contrast_threshold=0.4):
self.frame_width = frame_width
self.frame_height = frame_height
self.min_area = min_area
self.max_area_ratio = max_area_ratio
self.contrast_threshold = contrast_threshold
self._bg = None
if _CV2_AVAILABLE:
self._bg = cv2.createBackgroundSubtractorMOG2(
history=history, varThreshold=variance_threshold, detectShadows=False)
self._frame_area = frame_width * frame_height
self._max_area = self._frame_area * max_area_ratio
def detect(self, frame_bgr):
"""返回 list[dict(box=[x1,y1,x2,y2], area=int)](坐标基于原始 frame 尺寸)"""
if not _CV2_AVAILABLE or not _NP_AVAILABLE or frame_bgr is None:
return []
try:
h, w = frame_bgr.shape[:2]
# 缩放降低计算量
small = cv2.resize(frame_bgr, (self.frame_width, self.frame_height), interpolation=cv2.INTER_AREA)
# 对比度过低帧跳过(避免夜视噪声误报)
gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
if cv2.mean(gray)[0] < 8 or self._low_contrast(gray):
return []
mask = self._bg.apply(small)
mask = cv2.threshold(mask, 200, 255, cv2.THRESH_BINARY)[1]
mask = cv2.dilate(mask, None, iterations=2)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, None, iterations=1)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
sx = float(w) / self.frame_width
sy = float(h) / self.frame_height
boxes = []
for c in contours:
a = cv2.contourArea(c)
if a < self.min_area or a > self._max_area:
continue
x, y, bw, bh = cv2.boundingRect(c)
x1 = int(x * sx); y1 = int(y * sy)
x2 = int((x + bw) * sx); y2 = int((y + bh) * sy)
boxes.append({"box": [x1, y1, x2, y2], "area": int(a)})
return boxes
except Exception as e:
logger.warning("MotionDetector.detect() error: %s" % str(e))
return []
def _low_contrast(self, gray):
try:
if float(np.std(gray)) < self.contrast_threshold:
return True
except Exception:
return False
return False
@staticmethod
def is_available():
return _CV2_AVAILABLE and _NP_AVAILABLE

1003
app/analysis/pipeline.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,207 @@
"""Monitor 分析子进程入口
每路摄像头在独立进程中运行 CameraPipeline绕过 GIL与主进程通过 Queue 通信
- event_queue: 上报检测/区域/追踪事件
- cmd_queue: stop / reload_zones
- status_dict: 共享状态Manager.dict openStatus 读取 FPS
"""
import json
import logging
import multiprocessing as mp
import os
import queue
import time
logger = logging.getLogger("analysis.process_worker")
def _algorithm_spec_from_dict(d):
return d
def _build_detectors_in_process(algorithm_specs, infer_req_q=None, infer_resp_q=None):
"""在子进程中构造检测器;若提供 infer_req_q/infer_resp_q 则走主进程共享推理池"""
detectors = []
if infer_req_q is not None and infer_resp_q is not None:
from app.analysis.remote_detector import RemoteDetector
for spec in algorithm_specs:
detectors.append({
"algorithm_id": spec.get("id", 0),
"algorithm_name": spec.get("name", ""),
"engine": RemoteDetector(spec, infer_req_q, infer_resp_q),
})
else:
from app.analysis.worker_pool import DetectorWorkerPool
pool = DetectorWorkerPool()
class _AlgoObj(object):
pass
for spec in algorithm_specs:
o = _AlgoObj()
for k, v in spec.items():
setattr(o, k, v)
eng = pool.get_detector(o)
if eng:
detectors.append({
"algorithm_id": spec.get("id", 0),
"algorithm_name": spec.get("name", ""),
"engine": eng,
})
return detectors
def pipeline_process_main(config, event_queue, cmd_queue, status_dict,
infer_req_q=None, infer_resp_q=None):
"""子进程主函数spawn 入口config 必须为纯 dict"""
from app.utils.Logger import LOG_FORMAT
logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
log = logging.getLogger("analysis.process_worker")
from app.analysis.pipeline import CameraPipeline
from app.analysis.motion import MotionDetector
stream_id = config["stream_id"]
stream_code = config.get("stream_code", str(stream_id))
rtsp_url = config["rtsp_url"]
target_fps = config.get("target_fps", 5)
analyze_fps = config.get("analyze_fps", target_fps)
zones = config.get("zones") or []
algorithm_specs = config.get("algorithms") or []
use_shared_inference = bool(config.get("use_shared_inference", True))
storage_alarm_dir = config.get("storage_alarm_dir") or ""
static_dir = config.get("static_dir") or ""
def on_event(ev):
try:
event_queue.put(("event", ev), timeout=2.0)
except Exception as e:
log.warning("事件入队失败: %s", e)
def on_track_snapshot(sid, frame_index, active, has_motion):
try:
event_queue.put(("touch", {
"stream_id": sid,
"active": active,
"has_motion": has_motion,
}), timeout=1.0)
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,
)
if not detectors and not algorithm_specs:
log.info("pipeline[%s] 无算法,仅运动检测", stream_code)
motion = MotionDetector()
pipeline = CameraPipeline(
stream_id=stream_id,
stream_code=stream_code,
rtsp_url=rtsp_url,
detectors=detectors,
motion=motion,
target_fps=target_fps,
analyze_fps=analyze_fps,
on_event=on_event,
on_track_snapshot=on_track_snapshot,
zone_polygons=zones,
storage_alarm_dir=storage_alarm_dir,
static_dir=static_dir,
)
pipeline._algorithm_name = ", ".join(d.get("name", "") for d in algorithm_specs) or "motion-only"
import threading
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:
try:
st = pipeline.status()
status_dict[str(stream_id)] = st
except Exception:
pass
time.sleep(1.0)
reporter = threading.Thread(target=status_reporter, name="status-%s" % stream_id, daemon=True)
reporter.start()
def cmd_listener():
while pipeline._running:
try:
cmd = cmd_queue.get(timeout=0.5)
except queue.Empty:
continue
if not cmd:
continue
try:
if cmd.get("cmd") == "stop":
pipeline.stop()
break
if cmd.get("cmd") == "reload_zones":
pipeline.set_zone_polygons(cmd.get("zones") or [])
if cmd.get("analyze_fps") is not None:
pipeline.set_analyze_fps(cmd.get("analyze_fps"))
except Exception as e:
log.exception("pipeline[%s] 命令处理失败: %s", stream_code, e)
cmd_thread = threading.Thread(target=cmd_listener, name="cmd-%s" % stream_id, daemon=True)
cmd_thread.start()
log.info("pipeline 子进程启动 stream=%s url=%s", stream_code, rtsp_url)
try:
pipeline.run()
finally:
try:
status_dict.pop(str(stream_id), None)
except Exception:
pass
log.info("pipeline 子进程退出 stream=%s", stream_code)
class PipelineProcessHandle(object):
"""主进程侧对子进程的封装"""
def __init__(self, stream_id, process, event_queue, cmd_queue, status_dict):
self.stream_id = stream_id
self.process = process
self.event_queue = event_queue
self.cmd_queue = cmd_queue
self.status_dict = status_dict
self.running = True
def stop(self, timeout=5):
self.running = False
try:
self.cmd_queue.put({"cmd": "stop"}, timeout=1.0)
except Exception:
pass
try:
self.process.join(timeout=timeout)
if self.process.is_alive():
self.process.terminate()
self.process.join(timeout=2)
except Exception:
pass
def reload_zones(self, zones, analyze_fps=None):
try:
payload = {"cmd": "reload_zones", "zones": zones}
if analyze_fps is not None:
payload["analyze_fps"] = analyze_fps
self.cmd_queue.put(payload, timeout=1.0)
except Exception as e:
logger.warning("reload_zones 发送失败: %s", e)
def status(self):
try:
return self.status_dict.get(str(self.stream_id))
except Exception:
return None

View File

@ -0,0 +1,134 @@
"""远程推理代理 — 摄像头子进程通过 Queue 向主进程 InferenceProcessPool 发起推理"""
import logging
import threading
import uuid
logger = logging.getLogger("analysis.remote_detector")
# 同一 resp_queue 只能有一个 drain 线程,否则多 RemoteDetector 会抢响应导致丢包
_drainers = {}
_drainers_lock = threading.Lock()
class _SharedResponseDrainer(object):
def __init__(self, resp_queue):
self._resp_q = resp_queue
self._pending = {}
self._lock = threading.Lock()
self._started = False
def ensure_started(self):
if self._started:
return
self._started = True
threading.Thread(
target=self._loop, name="remote-det-drain-%s" % id(self._resp_q), daemon=True,
).start()
def register(self, req_id):
evt = {"event": threading.Event(), "resp": None}
with self._lock:
self._pending[req_id] = evt
return evt
def unregister(self, req_id):
with self._lock:
self._pending.pop(req_id, None)
def _loop(self):
import queue as _q
while True:
try:
msg = self._resp_q.get(timeout=1.0)
except _q.Empty:
continue
if not msg:
continue
req_id = msg.get("req_id")
with self._lock:
item = self._pending.pop(req_id, None)
if item:
item["resp"] = msg
item["event"].set()
elif req_id:
logger.debug("remote_detector: 无匹配 pending req_id=%s", req_id)
def _get_drainer(resp_queue):
key = id(resp_queue)
with _drainers_lock:
drainer = _drainers.get(key)
if drainer is None:
drainer = _SharedResponseDrainer(resp_queue)
_drainers[key] = drainer
return drainer
class RemoteDetector(object):
ENGINE_NAME = "remote_pool"
def __init__(self, algorithm_spec, req_queue, resp_queue, timeout=30.0):
self._spec = algorithm_spec
self._req_q = req_queue
self._resp_q = resp_queue
self._timeout = timeout
self._drainer = _get_drainer(resp_queue)
def ready(self):
return self._req_q is not None and self._resp_q is not None
def load(self):
return True
def detect(self, frame):
if not self.ready():
return []
self._drainer.ensure_started()
try:
import cv2
frame = self._maybe_downscale(frame)
ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
if not ok:
return []
jpeg = buf.tobytes()
except Exception:
return []
req_id = str(uuid.uuid4())
evt = self._drainer.register(req_id)
try:
self._req_q.put({
"req_id": req_id,
"algorithm": self._spec,
"jpeg": jpeg,
}, timeout=2.0)
except Exception:
self._drainer.unregister(req_id)
return []
if not evt["event"].wait(timeout=self._timeout):
self._drainer.unregister(req_id)
logger.warning("RemoteDetector 推理超时 algo=%s", self._spec.get("name"))
return []
resp = evt.get("resp") or {}
if not resp.get("ok"):
return []
return resp.get("detections") or []
def _maybe_downscale(self, frame):
try:
import cv2
h, w = frame.shape[:2]
max_side = max(
int(self._spec.get("input_width", 640) or 640),
int(self._spec.get("input_height", 640) or 640),
640,
) * 2
longest = max(h, w)
if longest <= max_side:
return frame
scale = float(max_side) / float(longest)
nw, nh = max(1, int(w * scale)), max(1, int(h * scale))
return cv2.resize(frame, (nw, nh), interpolation=cv2.INTER_AREA)
except Exception:
return frame

106
app/analysis/tracker.py Normal file
View File

@ -0,0 +1,106 @@
"""单摄像头目标跟踪(轻量 IoU 关联)
设计参考 Frigate/Norfair 的追踪思路但用最小依赖实现一个 IoU 关联器
避免强制引入 norfair后续可平滑替换为 norfair DeepSORT/ByteTrack 的特征关联
输出为每个检测框分配 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
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)
class Track(object):
__slots__ = ("track_id", "label", "box", "score", "missed", "hits", "born")
def __init__(self, track_id, label, box, score, born):
self.track_id = track_id
self.label = label
self.box = box
self.score = score
self.missed = 0
self.hits = 1
self.born = born
class IoUTracker(object):
"""按类别维护轨迹IoU 匹配max_missed 后判定目标消失。"""
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
self._next_id = 1
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)
# 未匹配的轨迹累计 missed
ended = []
for tid, tr in self._tracks.items():
if tid in active:
continue
tr.missed += 1
if tr.missed >= self.max_missed:
ended.append(tid)
for tid in ended:
del self._tracks[tid]
return [{"track_id": t.track_id, "label": t.label, "box": t.box, "score": t.score}
for t in active.values()], ended, new_tracks, frame_index
def all_active(self):
return list(self._tracks.values())
def reset(self):
self._tracks.clear()
self._next_id = 1

199
app/analysis/worker_pool.py Normal file
View File

@ -0,0 +1,199 @@
"""检测器缓存池(按算法 ID 缓存引擎实例)
阶段1每路 pipeline 持有 engine 实例线程安全由各引擎保证
阶段2演进将推理请求路由到独立的推理子进程池通过 ZeroMQ 回传结果
"""
import json
import logging
import os
import threading
logger = logging.getLogger("analysis.worker_pool")
class DetectorWorkerPool(object):
"""按 algorithm_id 缓存 BaseEngine 实例。"""
def __init__(self):
self._lock = threading.Lock()
self._engines = {} # algorithm_id -> BaseEngine
def get_detector(self, algorithm):
"""传入 AlgorithmModel 实例(或 dict返回对应引擎实例已 load
缓存命中返回已有实例否则用 EngineFactory 创建并 load失败返回 None
"""
from app.analysis.engines.factory import EngineFactory
from app.analysis.engines.base import EngineNotAvailableError
if algorithm is None:
return None
# 兼容 dict
if isinstance(algorithm, dict):
algo_id = algorithm.get("id")
engine_name = algorithm.get("inference_engine", "yolo_pytorch")
model_file = algorithm.get("model_file", "")
labels = algorithm.get("labels", [])
if isinstance(labels, str):
try:
labels = json.loads(labels)
except Exception:
labels = []
input_size = (int(algorithm.get("input_width", 640)), int(algorithm.get("input_height", 640)))
conf = float(algorithm.get("conf_threshold", 0.4))
iou = float(algorithm.get("iou_threshold", 0.5))
algo_type = algorithm.get("algorithm_type", "yolo8")
task_type = algorithm.get("task_type", "detect")
device = algorithm.get("device", "cpu")
else:
algo_id = getattr(algorithm, "id", None)
engine_name = algorithm.inference_engine
model_file = algorithm.model_file
labels = algorithm.labels
if isinstance(labels, str):
try:
labels = json.loads(labels)
except Exception:
labels = []
input_size = (algorithm.input_width, algorithm.input_height)
conf = algorithm.conf_threshold
iou = algorithm.iou_threshold
algo_type = algorithm.algorithm_type
task_type = getattr(algorithm, "task_type", "detect")
device = getattr(algorithm, "device", "cpu")
key = (algo_id, engine_name, model_file, conf, iou, input_size, task_type, device)
with self._lock:
det = self._engines.get(key)
if det is not None:
return det
try:
det = EngineFactory.create(engine_name,
model_file=resolve_model_path(model_file),
labels=labels,
input_size=input_size,
conf_threshold=conf,
iou_threshold=iou,
algorithm_type=algo_type,
task_type=task_type,
device=device)
if not det.load():
logger.warning("DetectorWorkerPool: 引擎 load 失败 algo=%s engine=%s", algo_id, engine_name)
return None
self._engines[key] = det
return det
except EngineNotAvailableError as e:
logger.warning("DetectorWorkerPool: %s", e)
return None
except Exception as e:
logger.exception("DetectorWorkerPool: 创建引擎异常: %s", e)
return None
def clear(self):
with self._lock:
self._engines.clear()
def instance_info(self):
"""返回当前缓存的引擎实例列表"""
with self._lock:
out = []
for key, eng in self._engines.items():
try:
out.append({
"algorithm_id": key[0],
"engine": eng.ENGINE_NAME,
"input_size": list(eng.input_size),
"task_type": getattr(eng, "task_type", "detect"),
"device": getattr(eng, "device", "cpu"),
"ready": eng.ready(),
})
except Exception:
pass
return out
@property
def instance_count(self):
with self._lock:
return len(self._engines)
# 模型文件路径解析:仅 uploadDir/weight
def resolve_model_path(model_file):
if not model_file:
return ""
mf = str(model_file).strip()
if os.path.isabs(mf) and os.path.isfile(mf):
return mf
weight_dir = get_weight_dir()
if not weight_dir:
return ""
for name in (mf, os.path.basename(mf)):
cand = os.path.join(weight_dir, name)
if os.path.isfile(cand):
return cand
return ""
def get_weight_dir():
"""返回 uploadDir/weight 绝对路径(不存在则创建)。"""
d = _get_upload_weight_dir(_get_project_base_dir())
if d:
try:
os.makedirs(d, exist_ok=True)
except Exception:
pass
return d or ""
def _norm_path(path, base):
if not path:
return ""
p = str(path).strip()
if not p:
return ""
if os.path.isabs(p):
return os.path.normpath(p)
return os.path.normpath(os.path.join(base, p.replace("\\", "/")))
def _get_project_base_dir():
"""获取项目根目录(不依赖 GlobalUtils子进程安全"""
try:
from django.conf import settings
base = getattr(settings, "BASE_DIR", None)
if base:
return str(base)
except Exception:
pass
# 兜底worker_pool.py 位于 <base>/app/analysis/,向上两级
try:
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
except Exception:
return ""
def _get_upload_weight_dir(base):
"""获取 uploadDir/weight 绝对路径(子进程安全,不依赖 GlobalUtils 单例)。"""
try:
from app.utils.GlobalUtils import g_config
d = getattr(g_config, "uploadAlgorithmWeightDir", None)
if d:
return os.path.normpath(str(d))
except Exception:
pass
if not base:
base = _get_project_base_dir()
try:
import json
cfg_path = os.path.join(base, "config.json")
if os.path.exists(cfg_path):
with open(cfg_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
upload_dir = cfg.get("uploadDir")
if upload_dir:
return os.path.normpath(os.path.join(_norm_path(upload_dir, base), "weight"))
except Exception:
pass
if base:
return os.path.normpath(os.path.join(base, "static", "upload", "weight"))
return ""

57
app/apps.py Normal file
View File

@ -0,0 +1,57 @@
import os
from django.apps import AppConfig
class AppConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'app'
def ready(self):
# SQLite 优化:每个新连接执行 PRAGMA
# - journal_mode=DELETE传统 rollback journal 模式,只产生一个 monitor.sqlite3 文件
# - busy_timeout=5000写冲突等 5s 而非立即报错
# - cache_size=-6553664MB 页缓存
# - temp_store=MEMORY临时表用内存
from django.db.backends.signals import connection_created
def _setup_sqlite_pragma(sender, connection, **kwargs):
if connection.vendor != 'sqlite':
return
with connection.cursor() as cur:
cur.execute('PRAGMA journal_mode=DELETE;')
cur.execute('PRAGMA busy_timeout=5000;')
cur.execute('PRAGMA cache_size=-65536;')
cur.execute('PRAGMA temp_store=MEMORY;')
connection_created.connect(_setup_sqlite_pragma)
try:
from app.utils.schema_upgrade import ensure_biz_algorithm_line_count_columns
ensure_biz_algorithm_line_count_columns()
except Exception as e:
import logging
logging.getLogger("app.bootstrap").warning("schema upgrade: %s", e)
try:
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
changed = migrate_existing_credentials()
if changed:
import logging
logging.getLogger("app.bootstrap").info("encrypted credentials in %d database rows", changed)
except Exception as e:
import logging
logging.getLogger("app.bootstrap").warning("security data upgrade: %s", e)
mode = os.environ.get("MONITOR_SERVICE_MODE", "disabled").strip().lower()
# Backward-compatible opt-in, still protected by the cross-process lock.
if mode == "disabled" and os.environ.get("MONITOR_BOOTSTRAP_SERVICES", "").lower() in (
"1", "true", "yes", "on"
):
mode = "embedded"
if mode == "embedded":
from app.services.lifecycle import get_service_manager
get_service_manager().start()

25
app/context_processors.py Normal file
View File

@ -0,0 +1,25 @@
from app.utils.LanguageUtils import LANG_UI_DICT, LANG_UI_JSON_CACHE, f_parse_request_lang, GSettingsLanguages
from app.utils.GlobalUtils import g_session_key_user
def lang_processor(request):
from app.utils.LanguageUtils import reload_lang_dict
reload_lang_dict()
lang = f_parse_request_lang(request)
# 从 GSettingsLanguages 将字典转换为列表供模板遍历
languages_list = list(GSettingsLanguages.values())
# 从 GSettingsLanguages 字典中直接获取当前语言的 OEM 配置
oem_settings = GSettingsLanguages.get(lang,{}).get('oem', {})
# 获取当前登录用户信息
current_user = request.session.get(g_session_key_user, {})
return {
'T': LANG_UI_DICT.get(lang),
'T_JSON': LANG_UI_JSON_CACHE.get(lang, '{}'),
'T_SELECTED_LANG': lang,
'T_SETTINGS_LANGUAGES': languages_list,
'settings': oem_settings,
'current_user': current_user
}

View File

@ -0,0 +1 @@
"""Django management package."""

View File

@ -0,0 +1 @@
"""Application management commands."""

View File

@ -0,0 +1,27 @@
import signal
import threading
from django.core.management.base import BaseCommand, CommandError
class Command(BaseCommand):
help = "Run SIP, ZLM ownership, recording, auto-proxy, and opt-in heartbeat as one leader"
def handle(self, *args, **options):
from app.services.lifecycle import get_service_manager
manager = get_service_manager()
if not manager.start():
raise CommandError("another process already owns the background-service leader lock")
stop = threading.Event()
def _shutdown(*_args):
stop.set()
for signum in (signal.SIGINT, signal.SIGTERM):
try:
signal.signal(signum, _shutdown)
except (ValueError, OSError):
pass
self.stdout.write(self.style.SUCCESS("background services running as leader"))
stop.wait()
manager.stop()

69
app/middleware.py Normal file
View File

@ -0,0 +1,69 @@
from django.http import HttpResponseRedirect, JsonResponse
from app.security import verify_internal_request
try:
from django.utils.deprecation import MiddlewareMixin
except ImportError:
MiddlewareMixin = object
PUBLIC_EXACT_PATHS = frozenset(('/login', '/logout', '/user/openCaptcha'))
PUBLIC_PREFIXES = ('/static/',)
ROLE_SYSTEM_ADMIN = "system_admin"
ROLE_ALGORITHM_ADMIN = "algorithm_admin"
ROLE_OPERATOR = "operator"
def _required_role(path, method):
if path.startswith(("/system/", "/user/")) or path == "/index/openMediaControl":
return ROLE_SYSTEM_ADMIN
if path.startswith(("/smallmodel/", "/algorithm/", "/llm/")):
return ROLE_ALGORITHM_ADMIN
if method != "GET" and path.startswith(("/stream/", "/nvr/", "/control/", "/zone/", "/alarm/")):
return ROLE_OPERATOR
if path.startswith(("/analysis/openStart", "/analysis/openStop", "/analysis/openReload")):
return ROLE_OPERATOR
if path.startswith(("/analysis/openUpdate", "/analysis/openToggle", "/analysis/openRestart")):
return ROLE_ALGORITHM_ADMIN
return ""
def _has_role(user, required):
if user.is_superuser:
return True
roles = set(user.groups.values_list("name", flat=True))
if ROLE_SYSTEM_ADMIN in roles:
return True
if required == ROLE_ALGORITHM_ADMIN:
return ROLE_ALGORITHM_ADMIN in roles
if required == ROLE_OPERATOR:
return ROLE_OPERATOR in roles or ROLE_ALGORITHM_ADMIN in roles
return True
class SimpleMiddleware(MiddlewareMixin):
def process_request(self, request):
path = request.path_info
if path.startswith('/inner/'):
if verify_internal_request(request):
return None
return JsonResponse({"code": 0, "msg": "forbidden"}, status=403)
if path in PUBLIC_EXACT_PATHS or any(path.startswith(prefix) for prefix in PUBLIC_PREFIXES):
return None
if getattr(request, "user", None) is not None and request.user.is_authenticated:
if path.startswith("/login"):
return HttpResponseRedirect("/")
required = _required_role(path, request.method)
if required and not _has_role(request.user, required):
if "/open" in path:
return JsonResponse({"code": 0, "msg": "forbidden"}, status=403)
return HttpResponseRedirect("/forbidden")
return None
return HttpResponseRedirect("/login")
def process_response(self, request, response):
return response

427
app/models.py Normal file
View File

@ -0,0 +1,427 @@
from django.db import models
from app.utils.Database import g_dbLock
import json
class ThreadSafetyManager(models.Manager):
def get_queryset(self):
with g_dbLock:
ret = super(ThreadSafetyManager, self).get_queryset()
return ret
class StreamModel(models.Model):
"""视频流模型(摄像头管理)"""
objects = ThreadSafetyManager()
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='视频流来源类型') # 0:未知,1:RTSP,2:RTMP,3:FLV,4:HLS,21:GB28181,31:被动RTSP,32:被动RTMP
pull_stream_transfer_mode = models.IntegerField(verbose_name='视频流传输模式') # 0:UDP,1:TCP被动,2:TCP主动
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='转发状态') # 0:未转发 1:转发中
is_audio = models.IntegerField(default=0, verbose_name='音频传输类型') # 0:静音 1:原始声音
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') # gb28181注册的client_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='最近一次注册时间')
# 向上级联国标编号字段v1.0新增start
cascade_device_id = models.CharField(max_length=50, default='', verbose_name='向上级联国标编号') # 自定义向上级联的国标编号为空则使用camera_device_id
cascade_enable = models.IntegerField(default=0, verbose_name='是否启用向上级联') # 0:不启用 1:启用
# 向上级联国标编号字段 end
# 视频分析字段v1.0新增start
algorithm = models.ForeignKey('AlgorithmModel', on_delete=models.SET_NULL, null=True, blank=True,
related_name='streams', verbose_name='分析算法') # null=走默认算法
record_enable = models.IntegerField(default=0, verbose_name='启用24/7录像') # 0:否 1:是
# 视频分析字段 end
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='添加类型') # 0:手动添加 1:批量导入 10:接口添加 21:GB28181自动添加
state = models.IntegerField(default=0, verbose_name='状态')
def __repr__(self):
return self.nickname
def __str__(self):
return self.nickname
def delete(self, using=None, keep_parents=False):
with g_dbLock:
ret = super(StreamModel, self).delete(using, keep_parents)
return ret
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
with g_dbLock:
ret = super(StreamModel, self).save(force_insert, force_update, using, update_fields)
return ret
class Meta:
db_table = 'av_stream'
verbose_name = '视频流'
verbose_name_plural = '视频流'
class AlgorithmModel(models.Model):
"""算法模型 — 检测算法的元数据与运行时参数(每路摄像头可独立选择)"""
objects = ThreadSafetyManager()
ENGINE_YOLO_PYTORCH = 'yolo_pytorch'
ENGINE_ONNXRUNTIME = 'onnxruntime'
ENGINE_OPENVINO = 'openvino'
ENGINE_CHOICES = (
(ENGINE_YOLO_PYTORCH, 'Yolo-PyTorch'),
(ENGINE_ONNXRUNTIME, 'OnnxRuntime'),
(ENGINE_OPENVINO, 'OpenVINO'),
)
# 算法类型YOLO 检测系列 + ReID 特征系列
ALGO_TYPE_YOLO5 = 'yolo5'
ALGO_TYPE_YOLO8 = 'yolo8'
ALGO_TYPE_YOLO11 = 'yolo11'
ALGO_TYPE_YOLO26 = 'yolo26'
ALGO_TYPE_OSNET = 'osnet'
ALGO_TYPE_CHOICES = (
(ALGO_TYPE_YOLO5, 'YOLOv5'),
(ALGO_TYPE_YOLO8, 'YOLOv8'),
(ALGO_TYPE_YOLO11, 'YOLOv11'),
(ALGO_TYPE_YOLO26, 'YOLO26'),
(ALGO_TYPE_OSNET, 'OSNet ReID'),
)
# 任务类型
TASK_DETECT = 'detect'
TASK_SEGMENT = 'segment'
TASK_CLASSIFY = 'classify'
TASK_POSE = 'pose'
TASK_OBB = 'obb'
TASK_REID = 'reid'
TASK_CHOICES = (
(TASK_DETECT, 'Detect'),
(TASK_SEGMENT, 'Segment'),
(TASK_CLASSIFY, 'Classify'),
(TASK_POSE, 'Pose'),
(TASK_OBB, 'OBB'),
(TASK_REID, 'ReID'),
)
# 推理设备
DEVICE_CPU = 'cpu'
DEVICE_CUDA = 'cuda'
DEVICE_GPU = 'gpu'
DEVICE_CHOICES = (
(DEVICE_CPU, 'CPU'),
(DEVICE_CUDA, 'CUDA'),
(DEVICE_GPU, 'GPU'),
)
name = models.CharField(max_length=100, verbose_name='算法名称')
algorithm_type = models.CharField(max_length=30, default='yolo8', choices=ALGO_TYPE_CHOICES, verbose_name='算法类型')
task_type = models.CharField(max_length=20, default=TASK_DETECT, choices=TASK_CHOICES, verbose_name='任务类型')
inference_engine = models.CharField(max_length=20, default=ENGINE_YOLO_PYTORCH, choices=ENGINE_CHOICES, verbose_name='推理引擎')
device = models.CharField(max_length=20, default=DEVICE_CPU, choices=DEVICE_CHOICES, verbose_name='推理设备')
model_file = models.CharField(max_length=300, default='', verbose_name='模型文件相对路径') # 相对 uploadDir/weight/
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数组') # ["person","car",...]
is_default = models.IntegerField(default=0, verbose_name='是否默认算法') # 1=全局兜底
state = models.IntegerField(default=1, verbose_name='状态') # 0=禁用 1=启用
create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
last_update_time = models.DateTimeField(auto_now_add=True, verbose_name='更新时间')
def __repr__(self):
return self.name
def __str__(self):
return self.name
def delete(self, using=None, keep_parents=False):
with g_dbLock:
ret = super(AlgorithmModel, self).delete(using, keep_parents)
return ret
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
with g_dbLock:
ret = super(AlgorithmModel, self).save(force_insert, force_update, using, update_fields)
return ret
class Meta:
db_table = 'av_algorithm'
verbose_name = '小模型'
verbose_name_plural = '小模型'
class BizAlgorithmModel(models.Model):
"""业务算法 — 小模型/大模型推理 + 后处理业务逻辑(布控绑定此表)"""
objects = ThreadSafetyManager()
FLOW_SMALL = 1
FLOW_LLM = 2
FLOW_BOTH = 3
FLOW_DETECT_REID = 4
FLOW_CHOICES = (
(FLOW_SMALL, '小模型+后处理'),
(FLOW_LLM, '大模型+后处理'),
(FLOW_BOTH, '小模型+大模型+后处理'),
(FLOW_DETECT_REID, '检测+ReID+后处理'),
)
POST_AREA = 'AREA' # 区域入侵:目标中心在多边形内
POST_LINE_CROSS = 'LINE_CROSS' # 越线检测:轨迹跨过有向线段
POST_LINE_COUNT = 'LINE_COUNT' # 越线计数:正向/逆向分别累计,超阈值报警
POST_DIRECTION = 'DIRECTION' # 方向入侵:移动方向匹配设定方向
POST_DENSITY = 'DENSITY' # 密度报警:区域内目标数 >= 阈值
POST_DWELL = 'DWELL' # 滞留报警:在区域内停留 >= 阈值秒
POST_CHOICES = (
(POST_AREA, '区域入侵'),
(POST_LINE_CROSS, '越线检测'),
(POST_LINE_COUNT, '越线计数'),
(POST_DIRECTION, '方向入侵'),
(POST_DENSITY, '密度报警'),
(POST_DWELL, '滞留报警'),
)
name = models.CharField(max_length=100, verbose_name='算法名称')
flow_type = models.IntegerField(default=FLOW_SMALL, choices=FLOW_CHOICES, verbose_name='流程类型')
small_model = models.ForeignKey(
'AlgorithmModel', on_delete=models.SET_NULL, null=True, blank=True,
related_name='biz_algorithms', verbose_name='小模型',
)
detector_model = models.ForeignKey(
'AlgorithmModel', on_delete=models.SET_NULL, null=True, blank=True,
related_name='biz_algorithms_as_detector', verbose_name='检测小模型(YOLO)',
)
target_labels = models.TextField(default='[]', verbose_name='目标类别JSON') # ["person","car"]
llm = models.ForeignKey(
'LLMModel', on_delete=models.SET_NULL, null=True, blank=True,
related_name='biz_algorithms', verbose_name='大模型',
)
llm_prompt = models.TextField(default='', verbose_name='大模型提示词')
llm_validate = models.TextField(default='', verbose_name='提示词校验值') # 逗号分隔关键词
post_process = models.CharField(max_length=30, default=POST_AREA, choices=POST_CHOICES, verbose_name='后处理逻辑')
# DIRECTION 后处理参数:参考角度(0°=右,90°=下,180°=左,270°=上) 与容差
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='正向计数报警阈值') # 0=不报警
reverse_count_threshold = models.IntegerField(default=0, verbose_name='逆向计数报警阈值') # 0=不报警
state = models.IntegerField(default=1, verbose_name='状态') # 0=禁用 1=启用
create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
last_update_time = models.DateTimeField(auto_now_add=True, verbose_name='更新时间')
def __repr__(self):
return self.name
def __str__(self):
return self.name
def delete(self, using=None, keep_parents=False):
with g_dbLock:
ret = super(BizAlgorithmModel, self).delete(using, keep_parents)
return ret
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
with g_dbLock:
ret = super(BizAlgorithmModel, self).save(force_insert, force_update, using, update_fields)
return ret
class Meta:
db_table = 'av_biz_algorithm'
verbose_name = '业务算法'
verbose_name_plural = '业务算法'
class ZoneModel(models.Model):
"""摄像头区域(多边形)— 跨摄像头追踪/告警规则的区域定义"""
objects = ThreadSafetyManager()
stream = models.ForeignKey(StreamModel, on_delete=models.CASCADE, verbose_name='所属摄像头')
name = models.CharField(max_length=100, verbose_name='区域名称')
coordinates = models.TextField(verbose_name='多边形坐标') # JSON: [[x1,y1],[x2,y2],...]
is_required = models.IntegerField(default=1, verbose_name='是否必需区域') # 1:目标必须在区域内才触发区域类后处理
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
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] 归一化
line_b = models.TextField(default='', verbose_name='警戒线端点B') # JSON: [x,y] 归一化
# DENSITY 后处理:密度报警阈值(区域内目标数)
density_threshold = models.IntegerField(default=0, verbose_name='密度阈值') # 0=不检测密度
algorithms = models.ManyToManyField('BizAlgorithmModel', blank=True, related_name='zones', 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='状态') # 1:启用 0:禁用
def __repr__(self):
return self.name
def __str__(self):
return self.name
def delete(self, using=None, keep_parents=False):
with g_dbLock:
ret = super(ZoneModel, self).delete(using, keep_parents)
return ret
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
with g_dbLock:
ret = super(ZoneModel, self).save(force_insert, force_update, using, update_fields)
return ret
class Meta:
db_table = 'av_zone'
verbose_name = '区域'
verbose_name_plural = '区域'
class AlarmModel(models.Model):
"""报警记录 — 布控分析触发的报警事件"""
objects = ThreadSafetyManager()
EVENT_TYPES = (
('entered_zone', '进入区域'),
('loiter', '滞留告警'),
)
stream = models.ForeignKey(StreamModel, null=True, on_delete=models.CASCADE, verbose_name='摄像头')
event_type = models.CharField(max_length=32, default='entered_zone', verbose_name='报警类型')
description = models.CharField(max_length=300, default='', verbose_name='描述')
timestamp = models.DateTimeField(verbose_name='发生时间')
metadata = models.TextField(default='{}', verbose_name='元数据JSON')
create_time = models.DateTimeField(auto_now_add=True, verbose_name='入库时间')
def __repr__(self):
return self.event_type
def __str__(self):
return self.event_type
def delete(self, using=None, keep_parents=False):
with g_dbLock:
ret = super(AlarmModel, self).delete(using, keep_parents)
return ret
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
with g_dbLock:
ret = super(AlarmModel, self).save(force_insert, force_update, using, update_fields)
return ret
class Meta:
db_table = 'av_alarm'
verbose_name = '报警'
verbose_name_plural = '报警'
indexes = [
models.Index(fields=['-timestamp'], name='av_alarm_ts_idx'),
models.Index(fields=['stream', 'timestamp'], name='av_alarm_st_idx'),
]
class RecordingModel(models.Model):
"""24/7 录像分段索引"""
objects = ThreadSafetyManager()
stream = models.ForeignKey(StreamModel, on_delete=models.CASCADE, verbose_name='摄像头')
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='入库时间')
class Meta:
db_table = 'av_recording'
verbose_name = '录像分段'
verbose_name_plural = '录像分段'
indexes = [
models.Index(fields=['stream', 'start_time'], name='av_recording_st_idx'),
]
class LLMModel(models.Model):
"""大模型配置OpenAI 兼容 API"""
objects = ThreadSafetyManager()
user_id = models.IntegerField(verbose_name='用户')
sort = models.IntegerField(default=0, verbose_name='排序')
code = models.CharField(max_length=50, verbose_name='编号')
name = models.CharField(max_length=50, default='', 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(max_length=512, default='', verbose_name='API密钥')
timeout = models.IntegerField(default=30, verbose_name='超时时间(秒)')
inference_tool = models.CharField(max_length=100, default='OpenAI', verbose_name='推理工具')
remark = models.TextField(default='', verbose_name='备注')
state = models.IntegerField(default=1, verbose_name='状态') # 0=禁用 1=启用
create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
last_update_time = models.DateTimeField(auto_now_add=True, verbose_name='更新时间')
def delete(self, using=None, keep_parents=False):
with g_dbLock:
ret = super(LLMModel, self).delete(using, keep_parents)
return ret
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
with g_dbLock:
ret = super(LLMModel, self).save(force_insert, force_update, using, update_fields)
return ret
class Meta:
db_table = 'av_llm'
verbose_name = '大模型'
verbose_name_plural = '大模型'
class LogModel(models.Model):
"""管理员操作日志"""
objects = ThreadSafetyManager()
user_id = models.IntegerField(verbose_name='用户ID')
log_type = models.IntegerField(verbose_name='日志类型') # 1:添加 2:编辑 3:删除 10:系统操作 100:系统重置
content = models.CharField(max_length=200, verbose_name='日志内容')
create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
state = models.IntegerField(verbose_name='状态') # 1:成功 0:失败
def __repr__(self):
return self.content
def __str__(self):
return self.content
def delete(self, using=None, keep_parents=False):
with g_dbLock:
ret = super(LogModel, self).delete(using, keep_parents)
return ret
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
with g_dbLock:
ret = super(LogModel, self).save(force_insert, force_update, using, update_fields)
return ret
class Meta:
db_table = 'av_log'
verbose_name = '管理员日志'
verbose_name_plural = '管理员日志'

View File

@ -0,0 +1 @@
"""录像模块"""

243
app/recording/manager.py Normal file
View File

@ -0,0 +1,243 @@
"""24/7 录像管理 — FFmpeg 分段录制 + retention 清理"""
import logging
import os
import subprocess
import threading
import time
from datetime import datetime, timedelta
logger = logging.getLogger("recording.manager")
_MANAGER = None
_MANAGER_LOCK = threading.Lock()
class RecordingManager(object):
def __init__(self):
self._lock = threading.RLock()
self._processes = {} # stream_id -> {"proc": Popen, "path": str}
self._retention_thread = None
self._running = False
def start(self):
if self._running:
return
self._running = True
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()
logger.info("RecordingManager 已启动")
def stop(self):
if not self._running and not self._processes:
return
self._running = False
with self._lock:
stream_ids = list(self._processes)
for stream_id in stream_ids:
self.stop_stream(stream_id)
if self._retention_thread and self._retention_thread.is_alive():
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 _segment_seconds(self):
try:
from app.utils.GlobalUtils import g_config
return int(getattr(g_config, "recordingSegmentSeconds", 600))
except Exception:
return 600
def _record_dir(self, stream):
from app.utils.GlobalUtils import g_config
base = getattr(g_config, "storageRecordDir", "") or os.path.join(
getattr(g_config, "storageDir", ""), "record")
code = "".join(c for c in str(stream.code or stream.id) if c.isalnum() or c in "_-")
day = datetime.now().strftime("%Y%m%d")
d = os.path.join(base, code, day)
os.makedirs(d, exist_ok=True)
return d
def _rtsp_url(self, stream):
from app.analysis.manager import AnalysisManager
return AnalysisManager.build_rtsp_url(stream)
def start_stream(self, stream):
if not self._running:
return False, "recording service is not running in this process"
sid = stream.id
with self._lock:
if sid in self._processes:
proc = self._processes[sid].get("proc")
if proc and proc.poll() is None:
return True, "already recording"
url = self._rtsp_url(stream)
if not url:
return False, "no rtsp url"
out_dir = self._record_dir(stream)
seg = self._segment_seconds()
pattern = os.path.join(out_dir, "%s_%%Y%%m%%d_%%H%%M%%S.mp4" % sid)
try:
from app.utils.GlobalUtils import g_config
ffmpeg = g_config.ffmpeg
except Exception:
ffmpeg = "ffmpeg"
cmd = [
ffmpeg, "-loglevel", "warning", "-rtsp_transport", "tcp",
"-i", url,
"-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)
except Exception as e:
return False, str(e)
self._processes[sid] = {"proc": proc, "path": out_dir, "stream": stream}
logger.info("录像启动 stream=%s dir=%s", sid, out_dir)
return True, "started"
def stop_stream(self, stream_id):
with self._lock:
item = self._processes.pop(stream_id, None)
if not item:
return False, "not recording"
proc = item.get("proc")
if proc:
try:
proc.terminate()
proc.wait(timeout=5)
except Exception:
try:
proc.kill()
except Exception:
pass
return True, "stopped"
def is_recording(self, stream_id):
with self._lock:
item = self._processes.get(stream_id)
if not item:
return False
proc = item.get("proc")
return proc is not None and proc.poll() is None
def list_recording(self):
with self._lock:
return [sid for sid, item in self._processes.items()
if item.get("proc") and item["proc"].poll() is None]
def _retain_days(self):
try:
from app.utils.GlobalUtils import g_config
return int(getattr(g_config, "recordingRetainDays", 7))
except Exception:
return 7
def _retain_gb(self):
try:
from app.utils.GlobalUtils import g_config
return float(getattr(g_config, "recordingRetainGb", 0))
except Exception:
return 0
def _retention_loop(self):
while self._running:
try:
self._run_retention()
except Exception as e:
logger.warning("retention 异常: %s", e)
for _ in range(3600):
if not self._running:
break
time.sleep(1)
def _run_retention(self):
from app.utils.GlobalUtils import g_config
base = getattr(g_config, "storageRecordDir", "")
if not base or not os.path.isdir(base):
return
days = self._retain_days()
cutoff = datetime.now() - timedelta(days=max(1, days))
deleted = 0
for root, _dirs, files in os.walk(base):
for fn in files:
if not fn.endswith((".mp4", ".ts", ".mkv")):
continue
fp = os.path.join(root, fn)
try:
mtime = datetime.fromtimestamp(os.path.getmtime(fp))
if mtime < cutoff:
os.remove(fp)
deleted += 1
self._delete_recording_row(fp)
except Exception:
pass
max_gb = self._retain_gb()
if max_gb > 0:
self._enforce_size_cap(base, max_gb)
if deleted:
logger.info("retention 删除 %d 个过期录像文件", deleted)
def _enforce_size_cap(self, base, max_gb):
files = []
for root, _d, fns in os.walk(base):
for fn in fns:
if fn.endswith((".mp4", ".ts", ".mkv")):
fp = os.path.join(root, fn)
try:
files.append((os.path.getmtime(fp), os.path.getsize(fp), fp))
except Exception:
pass
files.sort()
total = sum(x[1] for x in files)
limit = int(max_gb * (1024 ** 3))
while total > limit and files:
_mt, sz, fp = files.pop(0)
try:
os.remove(fp)
total -= sz
self._delete_recording_row(fp)
except Exception:
pass
def _delete_recording_row(self, filepath):
try:
from app.models import RecordingModel
RecordingModel.objects.filter(file_path=filepath).delete()
except Exception:
pass
def index_recording_file(self, stream_id, filepath, start_time, duration, file_size):
try:
from app.models import StreamModel, RecordingModel
stream = StreamModel.objects.get(id=stream_id)
RecordingModel.objects.create(
stream=stream,
file_path=filepath,
start_time=start_time,
end_time=start_time + timedelta(seconds=duration) if duration else start_time,
duration=duration or 0,
file_size=file_size or 0,
)
except Exception as e:
logger.debug("index recording: %s", e)
def get_recording_manager():
global _MANAGER
with _MANAGER_LOCK:
if _MANAGER is None:
_MANAGER = RecordingManager()
return _MANAGER

2
app/scheduler.py Normal file
View File

@ -0,0 +1,2 @@
# 计划任务功能已移除Monitor 不需要此功能)
# 原功能依赖 ScheduleTaskModel 和 ScheduleTaskLogModel这两个模型已从 models.py 中移除

64
app/security.py Normal file
View File

@ -0,0 +1,64 @@
"""Security helpers shared by middleware and trusted local services."""
import hashlib
import hmac
import ipaddress
import time
from urllib.parse import quote
from django.conf import settings
INTERNAL_SIGNATURE_TTL_SECONDS = 60
def _canonical_internal_request(timestamp, method, path, body):
body_hash = hashlib.sha256(body or b"").hexdigest()
return "%s\n%s\n%s\n%s" % (timestamp, method.upper(), path, body_hash)
def build_internal_auth_headers(method, path, body=b"", timestamp=None):
timestamp = str(int(timestamp or time.time()))
message = _canonical_internal_request(timestamp, method, path, body).encode("utf-8")
signature = hmac.new(
settings.MONITOR_INTERNAL_API_SECRET.encode("utf-8"), message, hashlib.sha256
).hexdigest()
return {
"X-Monitor-Timestamp": timestamp,
"X-Monitor-Signature": signature,
}
def internal_hook_token_query():
return quote(settings.MONITOR_INTERNAL_API_SECRET, safe="")
def _is_loopback(remote_addr):
try:
return ipaddress.ip_address((remote_addr or "").split("%", 1)[0]).is_loopback
except ValueError:
return False
def verify_internal_request(request, now=None):
"""Require a direct loopback peer and either HMAC auth or the ZLM hook token."""
if not _is_loopback(request.META.get("REMOTE_ADDR")):
return False
expected_secret = settings.MONITOR_INTERNAL_API_SECRET
token = request.GET.get("token", "")
if token and hmac.compare_digest(token, expected_secret):
return True
timestamp = request.headers.get("X-Monitor-Timestamp", "")
supplied = request.headers.get("X-Monitor-Signature", "")
try:
timestamp_int = int(timestamp)
except (TypeError, ValueError):
return False
current = int(now or time.time())
if abs(current - timestamp_int) > INTERNAL_SIGNATURE_TTL_SECONDS:
return False
expected = build_internal_auth_headers(
request.method, request.path_info, request.body, timestamp=timestamp_int
)["X-Monitor-Signature"]
return bool(supplied and hmac.compare_digest(supplied, expected))

1
app/services/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Monitor · 服务层"""

View File

@ -0,0 +1,56 @@
"""报警事件写入"""
import json
import logging
from datetime import datetime
logger = logging.getLogger("services.alarm")
# 报警事件类型(与 pipeline.py _emit_biz_alarm 产生的事件类型对齐)
ALARM_EVENT_TYPES = (
'entered_zone', # AREA/DWELL 进入即报
'loiter', # AREA 滞留
'dwell', # DWELL 滞留
'line_cross', # LINE_CROSS 越线
'line_count', # LINE_COUNT 越线计数超阈值
'direction', # DIRECTION 方向入侵
'density', # DENSITY 密度报警
)
def write_alarm(event):
"""将 pipeline 上报的报警事件写入 AlarmModel。
event 字段约定
stream_id, stream_code, type, track_id?, zone_id?, label?, timestamp(unix),
description?, metadata?
"""
try:
from app.models import AlarmModel, StreamModel
ts = event.get("timestamp")
if isinstance(ts, (int, float)):
dt = datetime.fromtimestamp(ts)
elif isinstance(ts, str):
dt = datetime.fromisoformat(ts)
else:
dt = datetime.now()
sid = event.get("stream_id")
stream = None
if sid:
try:
stream = StreamModel.objects.get(id=sid)
except Exception:
stream = None
meta = event.get("metadata") or {}
for k in ("zone_id", "label", "duration", "track_id", "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]
AlarmModel.objects.create(
stream=stream,
event_type=event.get("type", "alarm"),
description=event.get("description", ""),
timestamp=dt,
metadata=json.dumps(meta, ensure_ascii=False),
)
except Exception as e:
logger.warning("write_alarm 失败: %s" % str(e))

View File

@ -0,0 +1,787 @@
"""算法离线测试:异步任务 + 进度 + 渲染输出(临时文件存 storage/temp"""
import os
import re
import time
import uuid
import shutil
import threading
import logging
import subprocess
logger = logging.getLogger("services.algorithm_test")
try:
import cv2
_CV2 = True
except Exception:
cv2 = None
_CV2 = False
try:
import numpy as np
_NP = True
except Exception:
np = None
_NP = False
_TASKS = {}
_TASK_LOCK = threading.Lock()
_MAX_TASKS = 200
_MAX_FILE_BYTES = 100 * 1024 * 1024
_MAX_VIDEO_FRAMES = 600
_MAX_VIDEO_SECONDS = 60.0
_IMAGE_EXT = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tif", ".tiff"}
_VIDEO_EXT = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".m4v"}
def temp_root():
"""算法测试临时根目录:{storageTempDir}/algorithm_test"""
try:
from app.utils.GlobalUtils import g_config
root = os.path.join(g_config.storageTempDir, "algorithm_test")
except Exception:
from django.conf import settings
root = os.path.join(str(settings.BASE_DIR), "static", "storage", "temp", "algorithm_test")
try:
os.makedirs(root, exist_ok=True)
except Exception:
pass
return root
def upload_dir():
d = os.path.join(temp_root(), "_uploads")
os.makedirs(d, exist_ok=True)
return d
def task_dir(task_id):
if not re.fullmatch(r"[a-f0-9]{32}", task_id or ""):
raise ValueError("invalid task_id")
d = os.path.join(temp_root(), task_id)
os.makedirs(d, exist_ok=True)
return d
def _cleanup_tasks():
now = time.time()
with _TASK_LOCK:
stale = [k for k, v in _TASKS.items() if now - v.get("created_at", now) > 3600]
for k in stale:
_TASKS.pop(k, None)
if len(_TASKS) > _MAX_TASKS:
keys = sorted(_TASKS.keys(), key=lambda k: _TASKS[k].get("created_at", 0))
for k in keys[: len(_TASKS) - _MAX_TASKS]:
_TASKS.pop(k, None)
def _task_update(task_id, **kwargs):
with _TASK_LOCK:
t = _TASKS.get(task_id)
if t:
t.update(kwargs)
def get_task(task_id):
with _TASK_LOCK:
t = _TASKS.get(task_id)
return dict(t) if t else None
def output_url_for_task(task_id):
return "/smallmodel/openTestOutput?task_id=%s" % task_id
def resolve_output_file(task_id):
if not re.fullmatch(r"[a-f0-9]{32}", task_id or ""):
return None, None
base = os.path.join(temp_root(), task_id)
for name, ctype in (("output.mp4", "video/mp4"), ("output.jpg", "image/jpeg"), ("output.png", "image/png")):
fp = os.path.join(base, name)
if os.path.isfile(fp) and os.path.getsize(fp) > 0:
return fp, ctype
return None, None
def clear_temp_files():
"""清理算法测试产生的全部临时文件,并清空内存任务表。"""
roots = [temp_root()]
try:
from django.conf import settings
legacy = os.path.join(str(settings.BASE_DIR), "static", "storage", "test")
if os.path.isdir(legacy):
roots.append(legacy)
except Exception:
pass
removed = 0
bytes_freed = 0
for root in roots:
if not os.path.isdir(root):
continue
for name in os.listdir(root):
fp = os.path.join(root, name)
try:
if os.path.isfile(fp):
bytes_freed += os.path.getsize(fp)
os.remove(fp)
removed += 1
elif os.path.isdir(fp):
for dirpath, _, filenames in os.walk(fp):
for fn in filenames:
try:
fpath = os.path.join(dirpath, fn)
bytes_freed += os.path.getsize(fpath)
removed += 1
except Exception:
pass
shutil.rmtree(fp, ignore_errors=True)
except Exception as e:
logger.warning("clear temp remove %s err: %s", fp, e)
with _TASK_LOCK:
_TASKS.clear()
return {"files_removed": removed, "bytes_freed": bytes_freed}
def _ffmpeg_path():
try:
from app.utils.GlobalUtils import g_config
return getattr(g_config, "ffmpeg", None) or "ffmpeg"
except Exception:
return "ffmpeg"
def _encode_video_h264(raw_path, final_path):
"""将 OpenCV 输出的视频转码为浏览器可播的 H.264 MP4。"""
if not os.path.isfile(raw_path) or os.path.getsize(raw_path) <= 0:
return False
tmp = final_path + ".part.mp4"
cmd = [
_ffmpeg_path(), "-y", "-loglevel", "error",
"-i", raw_path,
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-pix_fmt", "yuv420p", "-movflags", "+faststart",
tmp,
]
try:
r = subprocess.run(cmd, capture_output=True, timeout=600)
if r.returncode == 0 and os.path.isfile(tmp) and os.path.getsize(tmp) > 0:
if os.path.isfile(final_path):
os.remove(final_path)
os.replace(tmp, final_path)
if os.path.abspath(raw_path) != os.path.abspath(final_path) and os.path.isfile(raw_path):
os.remove(raw_path)
return True
if r.stderr:
logger.warning("ffmpeg encode stderr: %s", r.stderr.decode("utf-8", errors="ignore")[:500])
except Exception as e:
logger.warning("ffmpeg encode failed: %s", e)
if os.path.isfile(tmp):
try:
os.remove(tmp)
except Exception:
pass
if os.path.isfile(raw_path) and not os.path.isfile(final_path):
try:
shutil.copy2(raw_path, final_path)
return os.path.getsize(final_path) > 0
except Exception:
pass
return os.path.isfile(final_path) and os.path.getsize(final_path) > 0
def _open_video_writer(out_path, fps, w, h):
for codec in ("avc1", "mp4v", "XVID"):
fourcc = cv2.VideoWriter_fourcc(*codec)
writer = cv2.VideoWriter(out_path, fourcc, fps, (w, h))
if writer.isOpened():
return writer
writer.release()
return None
def _color_for_label(label, idx=0):
palette = [
(22, 159, 133), (59, 130, 246), (234, 88, 12), (168, 85, 247),
(220, 38, 38), (14, 165, 233), (132, 204, 22), (236, 72, 153),
]
if label:
h = sum(ord(c) for c in str(label)) % len(palette)
return palette[h]
return palette[idx % len(palette)]
def draw_detections(frame_bgr, detections, task_type="detect"):
if not _CV2 or frame_bgr is None:
return frame_bgr
img = frame_bgr.copy()
h, w = img.shape[:2]
for i, det in enumerate(detections or []):
color = _color_for_label(det.get("label"), i)
task = (det.get("task") or task_type or "detect").lower()
if task == "classify":
continue
poly = det.get("mask_polygon")
if poly and len(poly) >= 3:
pts = np.array([(int(p[0]), int(p[1])) for p in poly], dtype=np.int32)
overlay = img.copy()
cv2.fillPoly(overlay, [pts], color)
cv2.addWeighted(overlay, 0.35, img, 0.65, 0, img)
cv2.polylines(img, [pts], True, color, 2)
box = det.get("box") or [0, 0, 0, 0]
x1, y1, x2, y2 = [int(v) for v in box[:4]]
if x2 > x1 and y2 > y1:
cv2.rectangle(img, (x1, y1), (x2, y2), color, 2)
label = str(det.get("label") or "")
score = det.get("score")
txt = label + (" %.2f" % score if isinstance(score, (int, float)) else "")
if txt.strip():
(tw, th), _ = cv2.getTextSize(txt, cv2.FONT_HERSHEY_SIMPLEX, 0.55, 1)
ty = max(0, y1 - 6)
cv2.rectangle(img, (x1, max(0, ty - th - 4)), (x1 + tw + 8, ty + 2), color, -1)
cv2.putText(img, txt, (x1 + 4, ty - 2), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 1, cv2.LINE_AA)
kpts = det.get("keypoints")
if kpts:
for kp in kpts:
if len(kp) >= 3 and kp[2] > 0.3:
cv2.circle(img, (int(kp[0]), int(kp[1])), 3, color, -1)
if task_type == "classify" and detections:
y = 28
for i, det in enumerate(detections[:8]):
txt = "%s %.2f" % (det.get("label", ""), float(det.get("score") or 0))
cv2.putText(img, txt, (12, y), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (255, 255, 255), 2, cv2.LINE_AA)
cv2.putText(img, txt, (12, y), cv2.FONT_HERSHEY_SIMPLEX, 0.65, _color_for_label(det.get("label"), i), 1, cv2.LINE_AA)
y += 26
cv2.rectangle(img, (w - 130, 6), (w - 6, 28), (22, 159, 133), -1)
cv2.putText(img, "ALGO TEST", (w - 122, 22), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 255, 255), 1, cv2.LINE_AA)
return img
def _summarize_detections(all_dets):
summary = {}
for det in all_dets:
lb = str(det.get("label") or "unknown")
sc = float(det.get("score") or 0)
if lb not in summary:
summary[lb] = {"label": lb, "count": 0, "max_score": sc, "score_sum": 0.0}
summary[lb]["count"] += 1
summary[lb]["max_score"] = max(summary[lb]["max_score"], sc)
summary[lb]["score_sum"] += sc
out = []
for lb, s in sorted(summary.items(), key=lambda x: -x[1]["count"]):
out.append({
"label": lb,
"count": s["count"],
"max_score": round(s["max_score"], 4),
"avg_score": round(s["score_sum"] / max(1, s["count"]), 4),
})
return out
def _resolve_model_abs(model_file):
"""与推理池一致:仅 uploadDir/weight。"""
if not model_file:
return ""
try:
from app.analysis.worker_pool import resolve_model_path
p = resolve_model_path(model_file)
if p and os.path.isfile(p):
return p
except Exception as e:
logger.warning("_resolve_model_abs: %s", e)
return ""
def _build_engine(algo, abs_model):
import json
from app.analysis.engines.factory import EngineFactory
labels = algo.labels or "[]"
try:
labels_list = json.loads(labels) if isinstance(labels, str) else (labels or [])
except Exception:
labels_list = []
return EngineFactory.create(
algo.inference_engine,
model_file=abs_model,
labels=labels_list,
input_size=(algo.input_width or 640, algo.input_height or 640),
conf_threshold=float(algo.conf_threshold or 0.4),
iou_threshold=float(algo.iou_threshold or 0.5),
algorithm_type=algo.algorithm_type or "yolo8",
task_type=algo.task_type or "detect",
device=algo.device or "cpu",
)
_PERSON_LABELS = frozenset({"person", "Person", "行人", "0"})
def _filter_person_detections(dets):
out = []
for d in dets or []:
lb = str(d.get("label") or "")
if lb in _PERSON_LABELS or lb.lower() == "person":
out.append(d)
return out
def _iou_box(a, b):
ax1, ay1, ax2, ay2 = a
bx1, by1, bx2, by2 = b
ix1, iy1 = max(ax1, bx1), max(ay1, by1)
ix2, iy2 = min(ax2, bx2), min(ay2, by2)
iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
inter = iw * ih
if inter <= 0:
return 0.0
area_a = max(0, ax2 - ax1) * max(0, ay2 - ay1)
area_b = max(0, bx2 - bx1) * max(0, by2 - by1)
union = area_a + area_b - inter
return float(inter / union) if union > 0 else 0.0
class _ReidTrack(object):
__slots__ = ("track_id", "box", "label", "score", "embedding", "embedding_hist", "missed", "hits", "_hist_max")
def __init__(self, track_id, box, label, score, embedding, hist_max=5):
self.track_id = track_id
self.box = box
self.label = label
self.score = score
self.embedding = embedding
self.embedding_hist = [embedding.copy()]
self.missed = 0
self.hits = 1
self._hist_max = hist_max
def update(self, box, score, emb):
self.box = box
self.score = score
self.embedding = emb
self.embedding_hist.append(emb.copy())
if len(self.embedding_hist) > self._hist_max:
self.embedding_hist.pop(0)
self.missed = 0
self.hits += 1
def mean_embedding(self):
if not self.embedding_hist:
return self.embedding
return np.mean(np.stack(self.embedding_hist, axis=0), axis=0)
class _SimpleReIDTracker(object):
def __init__(self, iou_thr=0.3, emb_thr=0.5, max_missed=8):
self.iou_thr = iou_thr
self.emb_thr = emb_thr
self.max_missed = max_missed
self.tracks = {}
self._next_id = 1
def update(self, detections, embeddings):
assigned = set()
active_ids = []
for det, emb in zip(detections, embeddings):
best_id, best_score = None, self.emb_thr
for tid, tr in self.tracks.items():
if tr.label != det.get("label"):
continue
iou = _iou_box(tr.box, det.get("box") or [0, 0, 0, 0])
if iou < self.iou_thr:
continue
sim = float(np.dot(tr.mean_embedding(), emb))
if sim > best_score:
best_score = sim
best_id = tid
if best_id is not None:
self.tracks[best_id].update(det.get("box"), det.get("score"), emb)
assigned.add(best_id)
active_ids.append(best_id)
else:
tid = self._next_id
self._next_id += 1
tr = _ReidTrack(tid, det.get("box"), det.get("label"), det.get("score"), emb)
self.tracks[tid] = tr
assigned.add(tid)
active_ids.append(tid)
for tid, tr in list(self.tracks.items()):
if tid in assigned:
continue
tr.missed += 1
if tr.missed >= self.max_missed:
del self.tracks[tid]
return active_ids
def _draw_reid_frame(frame_bgr, tracker, active_ids):
img = frame_bgr.copy()
for tid in active_ids:
tr = tracker.tracks.get(tid)
if not tr:
continue
x1, y1, x2, y2 = [int(v) for v in tr.box]
color = (0, 200, 80) if tr.hits >= 3 else (0, 180, 255)
cv2.rectangle(img, (x1, y1), (x2, y2), color, 2)
txt = "id=%d %s %.2f" % (tid, tr.label, float(tr.score or 0))
cv2.putText(img, txt, (x1, max(20, y1 - 8)), cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2, cv2.LINE_AA)
h, w = img.shape[:2]
cv2.rectangle(img, (w - 130, 6), (w - 6, 28), (22, 159, 133), -1)
cv2.putText(img, "REID TEST", (w - 122, 22), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 255, 255), 1, cv2.LINE_AA)
return img
def _process_reid_frame(detector_engine, reid_engine, tracker, frame_bgr, sim_samples):
dets = detector_engine.detect(frame_bgr)
persons = _filter_person_detections(dets)
boxes = [p.get("box") for p in persons if p.get("box")]
valid_idx, embeddings = reid_engine.extract_embeddings(frame_bgr, boxes)
matched_dets = [persons[i] for i in valid_idx]
emb_rows = [embeddings[j] for j in range(len(valid_idx))]
active_ids = tracker.update(matched_dets, emb_rows) if matched_dets else []
for tid in active_ids:
tr = tracker.tracks.get(tid)
if tr and tr.hits >= 2:
sim_samples.append(float(np.dot(tr.embedding, tr.mean_embedding())))
vis = _draw_reid_frame(frame_bgr, tracker, active_ids)
return vis, len(persons), len(valid_idx), active_ids
def _run_reid_test(task_id, algo, detector_algo, input_path, media_type, out_dir, t0):
abs_reid = _resolve_model_abs(algo.model_file)
abs_det = _resolve_model_abs(detector_algo.model_file)
if not abs_reid or not abs_det:
raise RuntimeError("model file not found")
reid_engine = _build_engine(algo, abs_reid)
det_engine = _build_engine(detector_algo, abs_det)
if not reid_engine.load() or not det_engine.load():
raise RuntimeError("engine load failed")
tracker = _SimpleReIDTracker()
sim_samples = []
infer_ms = 0.0
emb_total = 0
person_total = 0
track_peak = 0
all_dets = []
if media_type == "image":
_task_update(task_id, progress=15, message="processing image (detect+reid)")
frame = cv2.imread(input_path)
if frame is None:
raise RuntimeError("cannot read image")
h, w = frame.shape[:2]
t_inf = time.time()
vis, pc, ec, active_ids = _process_reid_frame(det_engine, reid_engine, tracker, frame, sim_samples)
infer_ms = (time.time() - t_inf) * 1000
person_total += pc
emb_total += ec
track_peak = max(track_peak, len(active_ids))
for tid in active_ids:
tr = tracker.tracks.get(tid)
if tr:
all_dets.append({
"label": tr.label,
"score": tr.score,
"box": tr.box,
"track_id": tr.track_id,
"task": "reid",
})
out_path = os.path.join(out_dir, "output.jpg")
if not cv2.imwrite(out_path, vis, [int(cv2.IMWRITE_JPEG_QUALITY), 92]):
raise RuntimeError("failed to write output image")
report = {
"media_type": "image",
"input_size": [w, h],
"frame_count": 1,
"processed_frames": 1,
"inference_ms_total": round(infer_ms, 2),
"inference_ms_avg": round(infer_ms, 2),
"detection_count": person_total,
"embedding_count": emb_total,
"track_count": track_peak,
"reid_sim_mean": round(float(np.mean(sim_samples)), 4) if sim_samples else None,
"detections_summary": _summarize_detections(all_dets),
"detections": all_dets[:100],
"engine": algo.inference_engine,
"device": algo.device,
"task_type": "reid",
"detector_id": detector_algo.id,
"detector_name": detector_algo.name,
"elapsed_ms": round((time.time() - t0) * 1000, 2),
}
_task_update(
task_id, status="done", progress=100, message="done",
report=report, output_url=output_url_for_task(task_id), output_type="image",
)
return
cap = cv2.VideoCapture(input_path)
if not cap.isOpened():
raise RuntimeError("cannot open video")
fps = float(cap.get(cv2.CAP_PROP_FPS) or 25.0)
if fps <= 0 or fps > 120:
fps = 25.0
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
if w <= 0 or h <= 0:
raise RuntimeError("invalid video dimensions")
if total <= 0:
total = int(fps * 10)
max_frames = min(total, _MAX_VIDEO_FRAMES, max(1, int(fps * _MAX_VIDEO_SECONDS)))
raw_path = os.path.join(out_dir, "output_raw.mp4")
final_path = os.path.join(out_dir, "output.mp4")
writer = _open_video_writer(raw_path, fps, w, h)
if writer is None:
raise RuntimeError("cannot create video writer")
idx = 0
processed = 0
while idx < max_frames:
ret, frame = cap.read()
if not ret or frame is None:
break
t_inf = time.time()
vis, pc, ec, active_ids = _process_reid_frame(det_engine, reid_engine, tracker, frame, sim_samples)
infer_ms += (time.time() - t_inf) * 1000
person_total += pc
emb_total += ec
track_peak = max(track_peak, len(active_ids))
for tid in active_ids:
tr = tracker.tracks.get(tid)
if tr:
all_dets.append({
"label": tr.label,
"score": tr.score,
"box": tr.box,
"track_id": tr.track_id,
"task": "reid",
})
writer.write(vis)
processed += 1
idx += 1
pct = 15 + int(70 * idx / max(1, max_frames))
_task_update(task_id, progress=min(85, pct), message="reid frame %d / %d" % (idx, max_frames))
cap.release()
writer.release()
if processed <= 0:
raise RuntimeError("no video frames processed")
if not os.path.isfile(raw_path) or os.path.getsize(raw_path) <= 0:
raise RuntimeError("raw video missing")
_task_update(task_id, progress=88, message="encoding video (H.264)")
if not _encode_video_h264(raw_path, final_path):
raise RuntimeError("video encode failed")
report = {
"media_type": "video",
"input_size": [w, h],
"frame_count": total,
"processed_frames": processed,
"fps": round(fps, 2),
"inference_ms_total": round(infer_ms, 2),
"inference_ms_avg": round(infer_ms / max(1, processed), 2),
"detection_count": person_total,
"embedding_count": emb_total,
"track_count": track_peak,
"reid_sim_mean": round(float(np.mean(sim_samples)), 4) if sim_samples else None,
"detections_summary": _summarize_detections(all_dets[:200]),
"detections": all_dets[:50],
"engine": algo.inference_engine,
"device": algo.device,
"task_type": "reid",
"detector_id": detector_algo.id,
"detector_name": detector_algo.name,
"elapsed_ms": round((time.time() - t0) * 1000, 2),
"output_video": True,
}
_task_update(
task_id, status="done", progress=100, message="done",
report=report, output_url=output_url_for_task(task_id), output_type="video",
)
def _run_test(task_id, algo, input_path, media_type, out_dir, detector_algo=None):
t0 = time.time()
try:
_task_update(task_id, status="running", progress=5, message="loading model")
task_type = (algo.task_type or "detect").lower()
if task_type == "reid":
if not detector_algo:
raise RuntimeError("ReID test requires detector model")
_run_reid_test(task_id, algo, detector_algo, input_path, media_type, out_dir, t0)
return
abs_model = _resolve_model_abs(algo.model_file)
if not abs_model:
raise RuntimeError("model file not found: %s" % (algo.model_file or ""))
engine = _build_engine(algo, abs_model)
if not engine.load():
raise RuntimeError("engine load failed")
task_type = (algo.task_type or "detect").lower()
all_dets = []
infer_ms = 0.0
processed = 0
if media_type == "image":
_task_update(task_id, progress=15, message="processing image")
frame = cv2.imread(input_path)
if frame is None:
raise RuntimeError("cannot read image")
h, w = frame.shape[:2]
t_inf = time.time()
dets = engine.detect(frame)
infer_ms = (time.time() - t_inf) * 1000
all_dets.extend([dict(d) for d in dets])
out_img = draw_detections(frame, dets, task_type)
out_path = os.path.join(out_dir, "output.jpg")
if not cv2.imwrite(out_path, out_img, [int(cv2.IMWRITE_JPEG_QUALITY), 92]):
raise RuntimeError("failed to write output image")
if not os.path.isfile(out_path) or os.path.getsize(out_path) <= 0:
raise RuntimeError("output image file missing")
processed = 1
report = {
"media_type": "image",
"input_size": [w, h],
"frame_count": 1,
"processed_frames": 1,
"inference_ms_total": round(infer_ms, 2),
"inference_ms_avg": round(infer_ms, 2),
"detection_count": len(all_dets),
"detections_summary": _summarize_detections(all_dets),
"detections": all_dets[:100],
"engine": algo.inference_engine,
"device": algo.device,
"task_type": task_type,
"elapsed_ms": round((time.time() - t0) * 1000, 2),
}
_task_update(
task_id, status="done", progress=100, message="done",
report=report, output_url=output_url_for_task(task_id), output_type="image",
)
return
# video逐帧推理渲染 → 合成视频 → ffmpeg 转 H.264
cap = cv2.VideoCapture(input_path)
if not cap.isOpened():
raise RuntimeError("cannot open video")
fps = float(cap.get(cv2.CAP_PROP_FPS) or 25.0)
if fps <= 0 or fps > 120:
fps = 25.0
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
if w <= 0 or h <= 0:
raise RuntimeError("invalid video dimensions")
if total <= 0:
total = int(fps * 10)
max_frames = min(total, _MAX_VIDEO_FRAMES, max(1, int(fps * _MAX_VIDEO_SECONDS)))
raw_path = os.path.join(out_dir, "output_raw.mp4")
final_path = os.path.join(out_dir, "output.mp4")
writer = _open_video_writer(raw_path, fps, w, h)
if writer is None:
raise RuntimeError("cannot create video writer")
idx = 0
while idx < max_frames:
ret, frame = cap.read()
if not ret or frame is None:
break
t_inf = time.time()
dets = engine.detect(frame)
infer_ms += (time.time() - t_inf) * 1000
all_dets.extend([dict(d) for d in dets])
vis = draw_detections(frame, dets, task_type)
writer.write(vis)
processed += 1
idx += 1
pct = 15 + int(70 * idx / max(1, max_frames))
_task_update(task_id, progress=min(85, pct), message="rendering frame %d / %d" % (idx, max_frames))
cap.release()
writer.release()
if processed <= 0:
raise RuntimeError("no video frames processed")
if not os.path.isfile(raw_path) or os.path.getsize(raw_path) <= 0:
raise RuntimeError("raw video missing")
_task_update(task_id, progress=88, message="encoding video (H.264)")
if not _encode_video_h264(raw_path, final_path):
raise RuntimeError("video encode failed")
_task_update(task_id, progress=95, message="saving result")
report = {
"media_type": "video",
"input_size": [w, h],
"frame_count": total,
"processed_frames": processed,
"fps": round(fps, 2),
"inference_ms_total": round(infer_ms, 2),
"inference_ms_avg": round(infer_ms / max(1, processed), 2),
"detection_count": len(all_dets),
"detections_summary": _summarize_detections(all_dets),
"detections": all_dets[:50],
"engine": algo.inference_engine,
"device": algo.device,
"task_type": task_type,
"elapsed_ms": round((time.time() - t0) * 1000, 2),
"output_video": True,
}
_task_update(
task_id, status="done", progress=100, message="done",
report=report, output_url=output_url_for_task(task_id), output_type="video",
)
except Exception as e:
logger.exception("algorithm test task %s failed", task_id)
_task_update(task_id, status="error", progress=100, message=str(e), error=str(e))
def start_test(algo, uploaded_path, original_name, detector_algo=None):
if not _CV2 or not _NP:
raise RuntimeError("opencv/numpy not available")
task_type = (algo.task_type or "detect").lower()
if task_type == "reid" and not detector_algo:
raise RuntimeError("ReID test requires detector model")
ext = os.path.splitext(original_name or uploaded_path)[1].lower()
if ext in _IMAGE_EXT:
media_type = "image"
elif ext in _VIDEO_EXT:
media_type = "video"
else:
raise RuntimeError("unsupported media type: %s" % ext)
size = os.path.getsize(uploaded_path)
if size > _MAX_FILE_BYTES:
raise RuntimeError("file too large (max %d MB)" % (_MAX_FILE_BYTES // (1024 * 1024)))
_cleanup_tasks()
task_id = uuid.uuid4().hex
out_dir = task_dir(task_id)
input_path = os.path.join(out_dir, "input" + ext)
try:
shutil.move(uploaded_path, input_path)
except Exception:
shutil.copy2(uploaded_path, input_path)
try:
os.remove(uploaded_path)
except Exception:
pass
with _TASK_LOCK:
_TASKS[task_id] = {
"id": task_id,
"status": "pending",
"progress": 0,
"message": "queued",
"report": None,
"output_url": "",
"output_type": "",
"error": "",
"algorithm_id": algo.id,
"created_at": time.time(),
}
th = threading.Thread(
target=_run_test,
args=(task_id, algo, input_path, media_type, out_dir, detector_algo),
daemon=True,
)
th.start()
return task_id

View File

@ -0,0 +1,62 @@
"""跨摄像头关联 — 基于「同类别 + 时间窗口 + 空间邻近摄像头」的轻量启发式匹配"""
import logging
import threading
import time
logger = logging.getLogger("services.cross_camera")
# 最近结束的目标list of dict(stream_id, track_id, label, global_track_id, ended_at)
_recent_ended = []
_lock = threading.Lock()
_WINDOW_SEC = 60.0
def register_ended(stream_id, track_id, label, global_track_id):
with _lock:
_recent_ended.append({
"stream_id": stream_id,
"track_id": track_id,
"label": label,
"global_track_id": global_track_id,
"ended_at": time.time(),
})
cutoff = time.time() - _WINDOW_SEC * 2
while _recent_ended and _recent_ended[0]["ended_at"] < cutoff:
_recent_ended.pop(0)
def try_link(stream_id, track_id, label):
"""新目标出现时尝试关联到其他摄像头刚消失的同类别目标。
返回 (global_track_id, linked_from) linked_from dict None
"""
now = time.time()
best = None
with _lock:
for item in reversed(_recent_ended):
if item["stream_id"] == stream_id:
continue
if item["label"] != label:
continue
if now - item["ended_at"] > _WINDOW_SEC:
continue
best = item
break
if not best:
return "cam%d-%d" % (stream_id, track_id), None
return best["global_track_id"], best
def make_cross_camera_event(from_info, to_stream_id, to_track_id, label):
return {
"type": "cross_camera",
"stream_id": to_stream_id,
"track_id": to_track_id,
"label": label,
"from_stream_id": from_info.get("stream_id"),
"from_track_id": from_info.get("track_id"),
"global_track_id": from_info.get("global_track_id"),
"timestamp": time.time(),
"description": "cross camera: %s -> stream#%s" % (
from_info.get("stream_id"), to_stream_id),
}

201
app/services/lifecycle.py Normal file
View File

@ -0,0 +1,201 @@
"""Explicit lifecycle for process-owning background services.
Only the process holding ``ServiceLeaderLock`` may bind SIP, manage ZLM,
spawn recording processes, auto-proxy streams, or emit telemetry.
"""
import atexit
import json
import logging
import os
import threading
import time
from pathlib import Path
logger = logging.getLogger("app.services.lifecycle")
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_LOCK_PATH = PROJECT_ROOT / ".runtime" / "service-leader.lock"
class ServiceLeaderLock:
def __init__(self, path=None):
self.path = Path(path or os.environ.get("MONITOR_SERVICE_LOCK", DEFAULT_LOCK_PATH)).resolve()
self._handle = None
@property
def acquired(self):
return self._handle is not None
def acquire(self):
if self.acquired:
return True
self.path.parent.mkdir(parents=True, exist_ok=True)
handle = open(self.path, "a+b")
try:
if os.name == "nt":
import msvcrt
handle.seek(0, os.SEEK_END)
if handle.tell() == 0:
handle.write(b"\0")
handle.flush()
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except (OSError, IOError):
handle.close()
return False
self._handle = handle
metadata = json.dumps({"pid": os.getpid(), "started_at": int(time.time())}).encode("utf-8")
handle.seek(0)
handle.truncate()
handle.write(metadata)
handle.flush()
return True
def release(self):
handle = self._handle
if handle is None:
return
try:
handle.seek(0)
if os.name == "nt":
import msvcrt
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
finally:
handle.close()
self._handle = None
class BackgroundCoordinator:
def __init__(self):
self._stop = threading.Event()
self._thread = None
def start(self):
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._run, name="service-coordinator", daemon=True)
self._thread.start()
def stop(self):
self._stop.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=5)
def _run(self):
from app.utils.GlobalUtils import GlobalUtils, g_config, g_logger
if getattr(g_config, "autoAddStreamProxy", False):
delay = max(0, int(getattr(g_config, "autoAddStreamProxySleep", 15)))
if not self._stop.wait(delay):
try:
ok, msg = GlobalUtils.addAllStreamProxy()
g_logger.info("autoAddStreamProxy ok=%s msg=%s", ok, msg)
except Exception as exc:
g_logger.warning("autoAddStreamProxy failed: %s", exc)
sequence = 0
interval = max(60, int(os.environ.get("MONITOR_TELEMETRY_INTERVAL", "4800")))
while not self._stop.wait(interval):
if not getattr(g_config, "telemetryEnabled", False):
continue
sequence += 1
try:
from app.services.telemetry import send_heartbeat
send_heartbeat(sequence)
except Exception as exc:
g_logger.warning("telemetry heartbeat failed: %s", exc)
class ServiceManager:
def __init__(self, lock=None):
self.lock = lock or ServiceLeaderLock()
self.coordinator = BackgroundCoordinator()
self._started = False
self._guard = threading.RLock()
@property
def is_leader(self):
return self.lock.acquired
def start(self):
with self._guard:
if self._started:
return True
if not self.lock.acquire():
logger.info("background services skipped: another process is leader")
return False
try:
from app.utils.GlobalUtils import g_config, g_gb28181SipServer
if getattr(g_config, "autoStartMedia", False):
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)
g_gb28181SipServer.start()
if getattr(g_config, "recordingEnabled", False):
from app.recording.manager import get_recording_manager
get_recording_manager().start()
self.coordinator.start()
self._started = True
logger.info("background services started as leader pid=%s", os.getpid())
return True
except Exception:
logger.exception("background service startup failed")
self.stop()
raise
def reconcile(self):
if not self.is_leader:
return False
from app.utils.GlobalUtils import g_config
from app.recording.manager import get_recording_manager
recording = get_recording_manager()
if getattr(g_config, "recordingEnabled", False):
recording.start()
else:
recording.stop()
return True
def stop(self):
with self._guard:
if not self.is_leader:
return
self.coordinator.stop()
try:
from app.recording.manager import get_recording_manager
get_recording_manager().stop()
except Exception:
logger.exception("recording shutdown failed")
try:
from app.utils.GlobalUtils import g_gb28181SipServer
g_gb28181SipServer.stop()
except Exception:
logger.exception("SIP shutdown failed")
try:
from app.utils.MediaServerManager import get_media_server_manager
media = get_media_server_manager()
if media.managed_pid():
media.stop()
except Exception:
logger.exception("ZLM shutdown failed")
self._started = False
self.lock.release()
logger.info("background services stopped")
_MANAGER = ServiceManager()
atexit.register(_MANAGER.stop)
def get_service_manager():
return _MANAGER
def is_service_leader():
return _MANAGER.is_leader

View File

@ -0,0 +1,111 @@
"""ONVIF WS-Discovery 设备发现"""
import logging
import socket
import struct
import uuid
from xml.etree import ElementTree as ET
logger = logging.getLogger("services.onvif")
WS_DISCOVERY_ADDR = "239.255.255.250"
WS_DISCOVERY_PORT = 3702
PROBE_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
<e:Envelope xmlns:e="http://www.w3.org/2003/05/soap-envelope"
xmlns:w="http://schemas.xmlsoap.org/ws/2004/08/addressing"
xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery"
xmlns:dn="http://www.onvif.org/ver10/network/wsdl">
<e:Header>
<w:MessageID>uuid:{msg_id}</w:MessageID>
<w:To e:mustUnderstand="true">urn:schemas-xmlsoap-org:ws:2005:04:discovery</w:To>
<w:Action a:mustUnderstand="true" xmlns:a="http://www.w3.org/2003/05/soap-envelope">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</w:Action>
</e:Header>
<e:Body>
<d:Probe>
<d:Types>dn:NetworkVideoTransmitter</d:Types>
</d:Probe>
</e:Body>
</e:Envelope>"""
def _parse_probe_match(data):
try:
root = ET.fromstring(data)
except Exception:
return None
ns = {
"s": "http://www.w3.org/2003/05/soap-envelope",
"wsa": "http://schemas.xmlsoap.org/ws/2004/08/addressing",
"d": "http://schemas.xmlsoap.org/ws/2005/04/discovery",
}
scopes = ""
xaddrs = ""
for el in root.iter():
tag = el.tag.split("}")[-1] if "}" in el.tag else el.tag
if tag == "Scopes" and el.text:
scopes = el.text.strip()
if tag == "XAddrs" and el.text:
xaddrs = el.text.strip()
if not xaddrs:
return None
url = xaddrs.split()[0]
name = scopes.split("/")[-1] if scopes else url
return {"name": name, "xaddr": url, "scopes": scopes}
def discover_onvif(timeout=3.0):
"""UDP 组播 WS-Discovery返回 [{name, xaddr, scopes, ip}]"""
results = []
seen = set()
msg = PROBE_TEMPLATE.format(msg_id=uuid.uuid4()).encode("utf-8")
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.settimeout(0.5)
ttl = struct.pack("b", 2)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl)
sock.sendto(msg, (WS_DISCOVERY_ADDR, WS_DISCOVERY_PORT))
import time
deadline = time.time() + timeout
while time.time() < deadline:
try:
data, addr = sock.recvfrom(65535)
except socket.timeout:
continue
ip = addr[0]
if ip in seen:
continue
item = _parse_probe_match(data)
if item:
item["ip"] = ip
seen.add(ip)
results.append(item)
except Exception as e:
logger.warning("ONVIF discovery 失败: %s", e)
finally:
sock.close()
return results
def get_rtsp_url_from_onvif(xaddr, username="", password=""):
"""尝试通过 ONVIF 获取 RTSP 主码流地址"""
try:
from onvif import ONVIFCamera
from urllib.parse import urlparse
p = urlparse(xaddr if "://" in xaddr else "http://" + xaddr)
host = p.hostname
port = p.port or 80
cam = ONVIFCamera(host, port, username or "admin", password or "admin")
media = cam.create_media_service()
profiles = media.GetProfiles()
if not profiles:
return ""
token = profiles[0].token
uri = media.GetStreamUri({
"StreamSetup": {"Stream": "RTP-Unicast", "Transport": {"Protocol": "RTSP"}},
"ProfileToken": token,
})
return uri.Uri if uri else ""
except Exception as e:
logger.debug("ONVIF get stream uri: %s", e)
return ""

63
app/services/telemetry.py Normal file
View File

@ -0,0 +1,63 @@
"""Opt-in, minimal, HTTPS-only telemetry and update checks."""
import os
from urllib.parse import urlparse
def _https_endpoint(env_name):
endpoint = os.environ.get(env_name, "").strip()
if not endpoint:
return ""
parsed = urlparse(endpoint)
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
raise ValueError("%s must be an HTTPS URL without embedded credentials" % env_name)
return endpoint
def heartbeat_payload(sequence):
from framework.settings import PROJECT_FLAG, PROJECT_VERSION
return {
"event": "heartbeat",
"product": PROJECT_FLAG,
"version": PROJECT_VERSION,
"sequence": int(sequence),
}
def send_heartbeat(sequence, session=None):
from app.utils.GlobalUtils import g_config
if not getattr(g_config, "telemetryEnabled", False):
return False, "telemetry disabled"
endpoint = _https_endpoint("MONITOR_TELEMETRY_ENDPOINT")
if not endpoint:
return False, "telemetry endpoint not configured"
if session is None:
import requests
session = requests
response = session.post(
endpoint, json=heartbeat_payload(sequence), timeout=10, allow_redirects=False
)
return response.status_code == 200, "status=%s" % response.status_code
def check_update(lang=None, session=None):
from app.utils.GlobalUtils import g_config
if not getattr(g_config, "updateCheckEnabled", False):
return False, False, "update check disabled", {}
endpoint = _https_endpoint("MONITOR_UPDATE_ENDPOINT")
if not endpoint:
return False, False, "update endpoint not configured", {}
from framework.settings import PROJECT_FLAG, PROJECT_VERSION
payload = {"product": PROJECT_FLAG, "version": PROJECT_VERSION, "lang": (lang or "")[:12]}
if session is None:
import requests
session = requests
try:
response = session.post(endpoint, json=payload, timeout=10, allow_redirects=False)
if response.status_code != 200:
return True, False, "status=%s" % response.status_code, {}
result = response.json()
if result.get("code") == 1000:
return True, True, str(result.get("msg", "ok")), result.get("data") or {}
return True, False, str(result.get("msg", "no update")), {}
except Exception as exc:
return False, False, str(exc), {}

3
app/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

175
app/urls.py Normal file
View File

@ -0,0 +1,175 @@
from django.urls import path
from django.views.generic import RedirectView
from django.views.decorators.csrf import csrf_exempt
from .views import UserView
from .views import IndexView
from .views import SystemView
from .views import StreamView
from .views import InnerlView
from .views import NvrView
from .views import StorageView
from .views import VersionView
from .views import AnalysisView
from .views import AlgorithmView
from .views import SmallModelView
from .views import LLMView
from .views import AlarmView
from .views import ControlView
app_name = 'app'
urlpatterns = [
# 主页功能
path('', IndexView.index),
path('setup/index', RedirectView.as_view(url='/stream/index', permanent=False)),
path('index/openIndex', IndexView.api_openIndex),
path('index/openGpuInfo', IndexView.api_openGpuInfo),
path('index/openMediaStatus', IndexView.api_openMediaStatus),
path('index/openMediaControl', IndexView.api_openMediaControl),
path('forbidden', IndexView.forbidden),
path('index/openSwitchLang', IndexView.api_openSwitchLang),
# 登陆退出
path('user/openCaptcha', UserView.api_openCaptcha),
path('login', UserView.login),
path('logout', UserView.logout),
# 用户管理
path('user/index', UserView.index),
path('user/openIndex', UserView.api_openIndex),
path('user/openAdd', UserView.api_openAdd),
path('user/openEdit', UserView.api_openEdit),
path('user/openInfo', UserView.api_openInfo),
path('user/openDel', UserView.api_openDel),
# 视频流功能
path('stream/online', StreamView.online),
path('stream/index', StreamView.index),
path('stream/openIndex', StreamView.api_openIndex),
path('stream/openHandleAllStreamProxy', StreamView.api_openHandleAllStreamProxy),
path('stream/openImportFile', StreamView.api_openImportFile),
path('stream/openExportFile', StreamView.api_openExportFile),
path('stream/openAddContext', StreamView.api_openAddContext),
path('stream/openAdd', StreamView.api_openAdd),
path('stream/openEditContext', StreamView.api_openEditContext),
path('stream/openStreamByAppAndName', StreamView.api_openStreamByAppAndName),
path('stream/openEdit', StreamView.api_openEdit),
path('stream/openDel', StreamView.api_openDel),
path('stream/openPtz', StreamView.api_openPtz),
path('stream/openPlayer', StreamView.api_openPlayer),
path('stream/player', StreamView.player),
path('stream/openAddStreamProxy', StreamView.api_openAddStreamProxy),
path('stream/openDelStreamProxy', StreamView.api_openDelStreamProxy),
path('open/getAllStreamData', StreamView.api_openGetAllStreamData),
path('open/getStatisticsStream', StreamView.api_openGetStatisticsStream),
path('version/openCheckVersion', VersionView.api_openCheckVersion),
path('version/index', VersionView.index),
# 被内部模块调用接口ZLMediaKit 回调CSRF 豁免)
path('inner/on_media_update_stream', csrf_exempt(InnerlView.api_on_media_update_stream)),
path('inner/on_media_delete_stream', csrf_exempt(InnerlView.api_on_media_delete_stream)),
path('inner/on_publish', csrf_exempt(InnerlView.api_on_publish)),
path('inner/on_stream_not_found', csrf_exempt(InnerlView.api_on_stream_not_found)),
# 系统功能
path('system/config', SystemView.config),
path('system/openConfig', SystemView.api_openConfig),
path('system/openSaveSettings', SystemView.api_openSaveSettings),
path('system/settings', RedirectView.as_view(url='/', permanent=False)),
path('system/openExportLogs', SystemView.api_openExportLogs),
# NVR/录像
path('record/index', NvrView.record_index),
path('nvr/openVideoIsRecording', NvrView.api_openVideoIsRecording),
path('nvr/openStartRecordVideo', NvrView.api_openStartRecordVideo),
path('nvr/openStopRecordVideo', NvrView.api_openStopRecordVideo),
path('nvr/openSnapShot', NvrView.api_openSnapShot),
path('nvr/openSnap', NvrView.api_openSnap),
path('nvr/openRecordIndex', NvrView.api_openRecordIndex),
path('nvr/openRecordFile', NvrView.api_openRecordFile),
path('nvr/openRecordDel', NvrView.api_openRecordDel),
path('stream/openOnvifDiscover', StreamView.api_openOnvifDiscover),
# 存储 存根接口(原 Storage 模块已移除,保留路由以兼容前端模板)
path('storage/openInfo', StorageView.api_openInfo),
path('storage/openDownload', StorageView.api_openDownload),
# 系统授权(已移除)
# 布控管理
path('control/index', ControlView.control_index),
path('control/openIndex', ControlView.control_openIndex),
path('control/openPageData', ControlView.control_openPageData),
path('control/openAdd', ControlView.control_openAdd),
path('control/openEdit', ControlView.control_openEdit),
path('control/openDel', ControlView.control_openDel),
path('control/openToggleZone', ControlView.control_openToggleZone),
path('control/openRecentAlarms', ControlView.control_openRecentAlarms),
path('zone/index', RedirectView.as_view(url='/control/index', permanent=False)),
# 旧 /zone/* API 别名POST 与带参 GET 不可仅用 RedirectView
path('zone/openIndex', ControlView.control_openIndex),
path('zone/openPageData', ControlView.control_openPageData),
path('zone/openAdd', ControlView.control_openAdd),
path('zone/openEdit', ControlView.control_openEdit),
path('zone/openDel', ControlView.control_openDel),
path('zone/openRecentAlarms', ControlView.control_openRecentAlarms),
# 报警管理
path('alarm/index', AlarmView.index),
path('alarm/dashboard', AlarmView.dashboard),
path('alarm/openStats', AlarmView.api_openStats),
path('alarm/openIndex', AnalysisView.alarm_openIndex),
path('alarm/openDel', AnalysisView.alarm_openDel),
path('alarm/openBatchDel', AnalysisView.alarm_openBatchDel),
path('alarm/openClearAlarms', AnalysisView.alarm_openClearAlarms),
path('analysis/openStatus', AnalysisView.analysis_openStatus),
path('analysis/openStart', AnalysisView.analysis_openStart),
path('analysis/openStop', AnalysisView.analysis_openStop),
path('analysis/openReloadZones', AnalysisView.analysis_openReloadZones),
path('analysis/openUpdateInferenceConfig', AnalysisView.analysis_openUpdateInferenceConfig),
path('analysis/openToggleAlgoInstance', AnalysisView.analysis_openToggleAlgoInstance),
path('analysis/openRestartAlgoInstance', AnalysisView.analysis_openRestartAlgoInstance),
path('analysis/openRestartInferencePool', AnalysisView.analysis_openRestartInferencePool),
# 小模型管理(原算法模型 CRUD
path('smallmodel/index', SmallModelView.smallmodel_index),
path('smallmodel/test', SmallModelView.smallmodel_test),
path('smallmodel/openIndex', SmallModelView.smallmodel_openIndex),
path('smallmodel/openDetail', SmallModelView.smallmodel_openDetail),
path('smallmodel/openTestStart', SmallModelView.smallmodel_openTestStart),
path('smallmodel/openTestStatus', SmallModelView.smallmodel_openTestStatus),
path('smallmodel/openTestOutput', SmallModelView.smallmodel_openTestOutput),
path('smallmodel/openTestClearTemp', SmallModelView.smallmodel_openTestClearTemp),
path('smallmodel/openAdd', SmallModelView.smallmodel_openAdd),
path('smallmodel/openEdit', SmallModelView.smallmodel_openEdit),
path('smallmodel/openDel', SmallModelView.smallmodel_openDel),
path('smallmodel/openUploadModel', SmallModelView.smallmodel_openUploadModel),
path('smallmodel/openProbe', SmallModelView.smallmodel_openProbe),
path('smallmodel/openEngines', SmallModelView.smallmodel_openEngines),
path('smallmodel/openDetectors', SmallModelView.smallmodel_openDetectors),
path('smallmodel/openSetActive', SmallModelView.smallmodel_openSetActive),
path('smallmodel/openAssignStreams', SmallModelView.smallmodel_openAssignStreams),
# 算法管理(业务逻辑:小模型/大模型 + 后处理)
path('algorithm/index', AlgorithmView.algorithm_index),
path('algorithm/openIndex', AlgorithmView.algorithm_openIndex),
path('algorithm/openCheckModels', AlgorithmView.algorithm_openCheckModels),
path('algorithm/openOptions', AlgorithmView.algorithm_openOptions),
path('algorithm/openAdd', AlgorithmView.algorithm_openAdd),
path('algorithm/openEdit', AlgorithmView.algorithm_openEdit),
path('algorithm/openDel', AlgorithmView.algorithm_openDel),
path('algorithm/openAssignContext', AlgorithmView.algorithm_openAssignContext),
path('algorithm/openAssignZones', AlgorithmView.algorithm_openAssignZones),
# 大模型管理
path('llm/index', LLMView.index),
path('llm/test', LLMView.test),
path('llm/openIndex', LLMView.api_openIndex),
path('llm/openAdd', LLMView.api_openAdd),
path('llm/openEdit', LLMView.api_openEdit),
path('llm/openInfo', LLMView.api_openInfo),
path('llm/openDel', LLMView.api_openDel),
path('llm/openTest', LLMView.api_openTest),
]

326
app/utils/Config.py Normal file
View File

@ -0,0 +1,326 @@
"""Monitor 启动配置 — 所有可配置项均来自 config.json后台「启动配置」页可编辑。"""
import json
import os
from urllib.parse import quote
from framework.settings import BASE_DIR
from app.utils.Secrets import get_runtime_secret
def _resolve_path(path, base=None):
"""相对路径基于项目根目录 BASE_DIR 解析为绝对路径。"""
base = base or str(BASE_DIR)
if not path:
return ""
p = str(path).strip()
if not p:
return ""
if os.path.isabs(p):
return os.path.normpath(p)
return os.path.normpath(os.path.join(base, p.replace("\\", "/")))
def _bool(v, default=False):
if v is None:
return default
if isinstance(v, bool):
return v
if isinstance(v, (int, float)):
return v != 0
return str(v).strip().lower() in ("1", "true", "yes", "on")
def _int(v, default=0):
try:
return int(v)
except (TypeError, ValueError):
return default
def _float(v, default=0.0):
try:
return float(v)
except (TypeError, ValueError):
return default
class Config:
def __init__(self, filepath):
self.__filepath = filepath
config_data = None
for encoding in ["utf-8", "gbk"]:
try:
with open(filepath, "r", encoding=encoding) as f:
config_data = json.loads(f.read())
break
except Exception as e:
print("Config.__init__() error:%s,encoding=%s|%s" % (str(e), encoding, str(filepath)))
if not config_data:
raise Exception("Config.__init__() read %s error" % str(filepath))
self.__config_data = config_data
self._apply(config_data)
def _apply(self, config_data):
self.__config_data_str = str(config_data)
base = str(BASE_DIR)
# —— 基础 ——
# Internal credentials are never sourced from the web-editable config.
self.internalApiSecret = get_runtime_secret("internal_api_secret")
self.internalHost = "127.0.0.1"
self.externalHost = config_data.get("host", "127.0.0.1")
self.adminPort = _int(config_data.get("adminPort"), 10001)
self.logDebug = _bool(config_data.get("logDebug"), False)
self.isEnableLoginCaptcha = _bool(config_data.get("isEnableLoginCaptcha"), True)
self.isEnableUpdatePopup = _bool(config_data.get("isEnableUpdatePopup"), False)
# Both flags require an explicit local opt-in. Endpoints are environment-only.
self.telemetryEnabled = _bool(config_data.get("telemetryEnabled"), False)
self.updateCheckEnabled = _bool(config_data.get("updateCheckEnabled"), False)
self.autoAddStreamProxy = _bool(config_data.get("autoAddStreamProxy"), True)
self.autoAddStreamProxySleep = _int(config_data.get("autoAddStreamProxySleep"), 15)
# —— 路径与工具 ——
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)
self.uploadDir = _resolve_path(config_data.get("uploadDir", "static/upload"), base)
self.storageDir = _resolve_path(config_data.get("storageDir", "static/storage"), base)
# —— 流媒体 ZLM ——
self.mediaHttpPort = _int(config_data.get("mediaHttpPort"), 10002)
self.mediaRtspPort = _int(config_data.get("mediaRtspPort"), 10554)
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.mediaStartConfigPath = _resolve_path(config_data.get("mediaStartConfigPath", ""), base)
self.autoStartMedia = _bool(config_data.get("autoStartMedia"), False)
self.adminHost = "http://" + self.internalHost + ":" + str(self.adminPort)
self.mediaHttpHost = "http://" + self.internalHost + ":" + str(self.mediaHttpPort)
# —— 视频分析 ——
self.analysisTargetFps = _int(config_data.get("analysisTargetFps"), 5)
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.recordingEnabled = _bool(config_data.get("recordingEnabled"), False)
self.recordingSegmentSeconds = max(60, _int(config_data.get("recordingSegmentSeconds"), 600))
self.recordingRetainDays = max(0, _int(config_data.get("recordingRetainDays"), 7))
self.recordingRetainGb = max(0.0, _float(config_data.get("recordingRetainGb"), 0.0))
# —— GB28181 SIP ——
__sip = config_data.get("sipServer") or {}
self.sipServer = {
"sipServerIp": str(__sip.get("sipServerIp", "127.0.0.1")).strip(),
"sipServerPort": _int(__sip.get("sipServerPort"), 15060),
"sipServerNonce": get_runtime_secret("sip_server_nonce"),
"sipTransferMode": _int(__sip.get("sipTransferMode"), 0),
"sipServerId": str(__sip.get("sipServerId", "34020000002000000001")).strip(),
"sipServerRealm": str(__sip.get("sipServerRealm", "3402000000")).strip(),
"sipServerPass": get_runtime_secret("sip_server_password"),
"sipServerTimeout": _int(__sip.get("sipServerTimeout"), 1800),
"sipServerExpiry": _int(__sip.get("sipServerExpiry"), 3600),
"rtpTransferMode": _int(__sip.get("rtpTransferMode"), 0),
"rtpTransferAudioType": _int(__sip.get("rtpTransferAudioType"), 0),
"autoInviteAfterRecCateLog": _bool(__sip.get("autoInviteAfterRecCateLog"), True),
}
self._ensure_storage_dirs()
self._sync_media_server_config()
def _sync_media_server_config(self):
"""Inject runtime-only secrets into the selected ZLM config.
ZLM supports a static hook URL but cannot produce our per-request HMAC,
so its two hooks carry the high-entropy token and are additionally
restricted to a direct loopback peer by Django middleware.
"""
source_path = self.mediaStartConfigPath
if not source_path or not os.path.isfile(source_path):
return
try:
lines = None
source_encoding = "utf-8"
for encoding in ("utf-8", "gbk"):
try:
with open(source_path, "r", encoding=encoding) as f:
lines = f.readlines()
source_encoding = encoding
break
except UnicodeDecodeError:
continue
if lines is None:
raise RuntimeError("unsupported config encoding")
section = ""
token = quote(self.internalApiSecret, safe="")
hook_urls = {
"on_publish": "http://127.0.0.1:%d/inner/on_publish?token=%s" % (self.adminPort, token),
"on_stream_not_found": "http://127.0.0.1:%d/inner/on_stream_not_found?token=%s" % (self.adminPort, token),
}
output = []
for line in lines:
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
section = stripped[1:-1].strip().lower()
if "=" in line and not stripped.startswith(";"):
key = line.split("=", 1)[0].strip()
if section == "api" and key == "secret":
newline = "secret=%s\n" % self.mediaSecret
line = newline
elif section == "hook" and key in hook_urls:
newline = "%s=%s\n" % (key, hook_urls[key])
line = newline
output.append(line)
runtime_dir = os.path.join(str(BASE_DIR), ".runtime")
os.makedirs(runtime_dir, exist_ok=True)
runtime_path = os.path.join(runtime_dir, "zlm-config.ini")
temp = runtime_path + ".tmp"
with open(temp, "w", encoding=source_encoding, newline="") as f:
f.writelines(output)
os.replace(temp, runtime_path)
self.mediaStartConfigPath = runtime_path
except Exception as e:
raise RuntimeError("failed to secure ZLM config %s: %s" % (source_path, e))
def _ensure_storage_dirs(self):
for d in (self.uploadDir, self.storageDir):
if d and not os.path.exists(d):
os.makedirs(d, exist_ok=True)
self.uploadAlgorithmWeightDir = os.path.join(self.uploadDir, "weight")
self.uploadAudioDir = os.path.join(self.uploadDir, "audio")
self.uploadAudioDir_www = "/upload/audio/"
self.storageTempDir = os.path.join(self.storageDir, "temp")
self.storageAlarmDir = os.path.join(self.storageDir, "alarm")
self.storageSnapshotsDir = os.path.join(self.storageDir, "snapshots")
self.storageRecordDir = os.path.join(self.storageDir, "record")
for d in (self.storageTempDir, self.storageAlarmDir, self.storageSnapshotsDir, self.storageRecordDir):
if not os.path.exists(d):
os.makedirs(d, exist_ok=True)
self.storageDir_www = "/storage/openAccess?filename="
def getStr(self):
return json.dumps(self.to_dict(), ensure_ascii=False)
def to_dict(self, include_secrets=False):
"""供启动配置页 / API 使用的完整配置快照。"""
d = dict(self.__config_data)
d.setdefault("sipServer", {})
d.pop("safe", None)
d.pop("internalApiSecret", None)
d.pop("mediaSecret", None)
# 布尔/int 与内存态保持一致
d["host"] = self.externalHost
d["autoAddStreamProxy"] = self.autoAddStreamProxy
d["autoAddStreamProxySleep"] = self.autoAddStreamProxySleep
d["isEnableLoginCaptcha"] = self.isEnableLoginCaptcha
d["logDebug"] = self.logDebug
d["isEnableUpdatePopup"] = self.isEnableUpdatePopup
d["telemetryEnabled"] = self.telemetryEnabled
d["updateCheckEnabled"] = self.updateCheckEnabled
d["isEnableMediaProxyRtmp"] = self.isEnableMediaProxyRtmp
d["autoStartMedia"] = self.autoStartMedia
d["analysisSharedInference"] = self.analysisSharedInference
d["recordingEnabled"] = self.recordingEnabled
d["sipServer"] = dict(self.sipServer)
d["sipServer"].pop("sipServerPass", None)
d["sipServer"].pop("sipServerNonce", None)
if include_secrets:
d["internalApiSecret"] = self.internalApiSecret
d["mediaSecret"] = self.mediaSecret
d["sipServer"]["sipServerPass"] = self.sipServer["sipServerPass"]
d["sipServer"]["sipServerNonce"] = self.sipServer["sipServerNonce"]
return d
def save_from_web(self, params):
"""合并 Web 表单并写回 config.json保留未在表单中的键"""
data = dict(self.__config_data)
p = params or {}
def _set(key, val):
data[key] = val
for key in ("ffmpeg",):
if key in p:
_set(key, str(p.get(key) or "").strip())
if "host" in p:
_set("host", str(p.get("host") or "").strip())
if "uploadDir" in p:
_set("uploadDir", str(p.get("uploadDir") or "").strip())
if "storageDir" in p:
_set("storageDir", str(p.get("storageDir") or "").strip())
if "fontPath" in p:
_set("fontPath", str(p.get("fontPath") or "").strip())
if "mediaStartPath" in p:
_set("mediaStartPath", str(p.get("mediaStartPath") or "").strip())
if "mediaStartConfigPath" in p:
_set("mediaStartConfigPath", str(p.get("mediaStartConfigPath") or "").strip())
int_keys = (
"adminPort", "mediaHttpPort", "mediaRtspPort", "mediaRtmpPort",
"autoAddStreamProxySleep",
"analysisTargetFps", "analysisProcessMode", "analysisInferenceWorkers",
"recordingSegmentSeconds", "recordingRetainDays",
)
for key in int_keys:
if key in p:
_set(key, _int(p.get(key), data.get(key)))
float_keys = ("analysisConfThreshold", "recordingRetainGb")
for key in float_keys:
if key in p:
_set(key, _float(p.get(key), data.get(key)))
bool_keys = (
"autoAddStreamProxy", "isEnableLoginCaptcha", "logDebug", "isEnableUpdatePopup",
"telemetryEnabled", "updateCheckEnabled",
"isEnableMediaProxyRtmp", "autoStartMedia",
"analysisSharedInference", "recordingEnabled",
)
for key in bool_keys:
if key in p:
_set(key, _bool(p.get(key), _bool(data.get(key))))
sip = dict(data.get("sipServer") or {})
sip_map = {
"sipServerIp": "sipServerIp",
"sipServerPort": ("sipServerPort", _int),
"sipTransferMode": ("sipTransferMode", _int),
"sipServerId": "sipServerId",
"sipServerRealm": "sipServerRealm",
"sipServerTimeout": ("sipServerTimeout", _int),
"sipServerExpiry": ("sipServerExpiry", _int),
"rtpTransferMode": ("rtpTransferMode", _int),
"rtpTransferAudioType": ("rtpTransferAudioType", _int),
"autoInviteAfterRecCateLog": ("autoInviteAfterRecCateLog", _bool),
}
for param_key, spec in sip_map.items():
if param_key not in p:
continue
if isinstance(spec, tuple):
sip_key, conv = spec
sip[sip_key] = conv(p.get(param_key), sip.get(sip_key))
else:
sip[spec] = str(p.get(param_key) or "").strip()
data["sipServer"] = sip
for _k in (
"install", "code", "name", "describe", "safe", "internalApiSecret",
"mediaSecret",
):
data.pop(_k, None)
for _k in ("sipServerPass", "sipServerNonce"):
data["sipServer"].pop(_k, None)
with open(self.__filepath, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
self.__config_data = data
self._apply(data)

78
app/utils/Credentials.py Normal file
View File

@ -0,0 +1,78 @@
"""Encryption and presentation helpers for credentials stored in the database."""
import re
from cryptography.fernet import Fernet, InvalidToken
from app.utils.Secrets import get_runtime_secret
PREFIX = "enc:v1:"
SENSITIVE_KEYS = re.compile(
r"(?:api[_-]?key|password|passwd|secret|token|authorization|credential)", re.I
)
def _fernet():
return Fernet(get_runtime_secret("credential_encryption_key").encode("ascii"))
def is_encrypted(value):
return isinstance(value, str) and value.startswith(PREFIX)
def encrypt_credential(value):
value = "" if value is None else str(value)
if not value or is_encrypted(value):
return value
return PREFIX + _fernet().encrypt(value.encode("utf-8")).decode("ascii")
def decrypt_credential(value):
value = "" if value is None else str(value)
if not is_encrypted(value):
return value
try:
return _fernet().decrypt(value[len(PREFIX):].encode("ascii")).decode("utf-8")
except (InvalidToken, ValueError) as exc:
raise RuntimeError("credential decryption failed; check the configured encryption key") from exc
def mask_credential(value):
plain = decrypt_credential(value)
if not plain:
return ""
if len(plain) <= 4:
return "****"
return plain[:2] + "****" + plain[-2:]
def redact_mapping(value):
if not isinstance(value, dict):
return value
return {key: ("[REDACTED]" if SENSITIVE_KEYS.search(str(key)) else val)
for key, val in value.items()}
def migrate_existing_credentials():
"""Idempotently encrypt legacy plaintext credentials in existing rows."""
from app.models import LLMModel, StreamModel
changed = 0
for item in LLMModel.objects.only("id", "api_key").iterator():
encrypted = encrypt_credential(item.api_key)
if encrypted != item.api_key:
LLMModel.objects.filter(pk=item.pk).update(api_key=encrypted)
changed += 1
for item in StreamModel.objects.only(
"id", "pull_stream_username", "pull_stream_password"
).iterator():
updates = {}
for field in ("pull_stream_username", "pull_stream_password"):
old_value = getattr(item, field)
new_value = encrypt_credential(old_value)
if new_value != old_value:
updates[field] = new_value
if updates:
StreamModel.objects.filter(pk=item.pk).update(**updates)
changed += 1
return changed

36
app/utils/Database.py Normal file
View File

@ -0,0 +1,36 @@
import threading
from django.db import connection
g_dbLock = threading.Lock()# 用于操作数据库的全局锁20240930新增由于sqlite不支持锁因此在程序中做锁控制
class Database(object):
def __init__(self, logger):
self.logger = logger
def select(self, sql, params=None):
data = []
with g_dbLock:
cursor = connection.cursor()
cursor.execute(sql, params or None)
try:
rawData = cursor.fetchall()
col_names = [desc[0] for desc in cursor.description]
for row in rawData:
d = {}
for index, value in enumerate(row):
d[col_names[index]] = value
data.append(d)
except Exception as e:
self.logger.error("Database.select() error:%s,sql:%s" % (str(e),sql))
return data
def execute(self, sql, params=None):
ret = False
with g_dbLock:
try:
cursor = connection.cursor()
cursor.execute(sql, params or None)
ret = True
except Exception as e:
self.logger.error("Database.execute() error:%s,sql:%s" % (str(e), sql))
return ret

File diff suppressed because it is too large Load Diff

298
app/utils/GlobalUtils.py Normal file
View File

@ -0,0 +1,298 @@
import os
import time
import json
from datetime import datetime
import sys
from framework.settings import BASE_DIR, PROJECT_UA, PROJECT_BUILT, PROJECT_VERSION, PROJECT_FLAG, PROJECT_ADMIN_START_TIMESTAMP
from app.utils.ZLMediaKitApi import ZLMediaKitApi
from app.utils.Config import Config
from app.utils.Logger import CreateLogger
from app.utils.Database import Database
from app.utils.GB28181SipServer import GB28181SipServer
# ========== 应用名常量 ==========
APP_NAME_LIVE = "live"
APP_NAME_RTP = "rtp"
from app.utils.LanguageUtils import LANG_UI_DICT, LANG_VIEWS_USE_LANG_T
from app.models import *
# BASE_DIR 是项目根目录(扁平化后 settings.py 的父父目录即根目录)
# BASE_PARENT_DIR = str(BASE_DIR) # 扁平化后 BASE_PARENT_DIR 与 BASE_DIR 相同,均在根目录
g_filepath_settings_json = os.path.join(BASE_DIR, "settings.json")
g_filepath_config_json = os.path.join(BASE_DIR, "config.json")
g_config = Config(filepath=g_filepath_config_json)
__log_dir = os.path.join(BASE_DIR, "log")
if not os.path.exists(__log_dir):
os.makedirs(__log_dir)
__log_name = "%s%s.log" % ("monitor", datetime.now().strftime("%Y%m%d-%H%M%S"))
g_logger = CreateLogger(filepath=os.path.join(__log_dir, __log_name),
is_show_console=False,
log_debug=g_config.logDebug)
g_logger.info("%s v%s,%s" % (PROJECT_UA, PROJECT_VERSION, PROJECT_FLAG))
g_logger.info(PROJECT_BUILT)
g_logger.info("g_filepath_config_json=%s" % g_filepath_config_json)
g_logger.info("config.json:%s" % g_config.getStr())
g_logger.info("logDebug=%d" % g_config.logDebug)
__argv_extend = sys.argv[1] if len(sys.argv) >= 2 else None
g_logger.info("argv_extend=%s" % str(__argv_extend))
g_zlm = ZLMediaKitApi(logger=g_logger, config=g_config)
g_database = Database(logger=g_logger)
__config_sip_server = g_config.sipServer
# SIP 对象仅在此构造;绑定端口由显式 ServiceManager 负责。
g_gb28181SipServer = GB28181SipServer(
server_ip=__config_sip_server.get("sipServerIp"),
server_port=__config_sip_server.get("sipServerPort"),
server_id=__config_sip_server.get("sipServerId"),
realm=__config_sip_server.get("sipServerRealm"),
password=__config_sip_server.get("sipServerPass"),
sip_server_timeout=__config_sip_server.get("sipServerTimeout"),
sip_server_expiry=__config_sip_server.get("sipServerExpiry"),
sip_transfer_mode=__config_sip_server.get("sipTransferMode"),
rtp_transfer_mode=__config_sip_server.get("rtpTransferMode"),
rtp_transfer_audio_type=__config_sip_server.get("rtpTransferAudioType"),
auto_invite_after_rec_cate_log=__config_sip_server.get("autoInviteAfterRecCateLog"),
admin_host=g_config.adminHost,
zlm=g_zlm,
logger=g_logger
)
g_pull_stream_types = [
{"id": 1, "name": "RTSP"},
{"id": 2, "name": "RTMP"},
{"id": 3, "name": "FLV"},
{"id": 4, "name": "HLS"},
{"id": 21, "name": "GB28181"},
{"id": 31, "name": "cRTSP"},
{"id": 32, "name": "cRTMP"}
]
def get_audio_types(lang='zh'):
"""返回音频类型"""
T = LANG_UI_DICT.get(lang, {})
result = []
__audio_types = [
{"type": 0, "name": "静音", "name_key": "audio_pull_mute"},
{"type": 1, "name": "原始音频", "name_key": "audio_pull_original"}
]
for audio_type in __audio_types:
result.append({
"type": audio_type["type"],
"name": T.get(audio_type["name_key"], audio_type["name"])
})
return result
g_session_key_user = "user"
g_session_key_captcha = "captcha"
def _bool_cfg(v):
if v is None:
return False
if isinstance(v, bool):
return v
if isinstance(v, (int, float)):
return v != 0
return str(v).strip().lower() in ("1", "true", "yes", "on")
class GlobalUtils(object):
@staticmethod
def addStreamProxy(stream, lang=None):
"""开启流代理拉流到ZLM"""
__ret = False
__msg = LANG_VIEWS_USE_LANG_T(lang, "msg_unknown_error")
if stream.pull_stream_type in [1, 2, 3, 4]:
enable_rtmp = 1 if g_config.isEnableMediaProxyRtmp else 0
add_key, add_msg = g_zlm.addStreamProxy(app=stream.app,
name=stream.name,
origin_url=stream.pull_stream_url,
is_audio=stream.is_audio,
enable_rtmp=enable_rtmp)
if add_key:
__ret = True
__msg = LANG_VIEWS_USE_LANG_T(lang, "stream_forward_enabled_success")
else:
__msg = add_msg
elif stream.pull_stream_type == 21:
__ret, __msg = g_gb28181SipServer.request_invite(client_id=stream.camera_device_id, channel_id=stream.name)
if __ret:
__msg = LANG_VIEWS_USE_LANG_T(lang, "stream_forward_enabled_success")
elif stream.pull_stream_type in [31, 32]:
__msg = LANG_VIEWS_USE_LANG_T(lang, "media_push_stream_hint")
else:
__msg = LANG_VIEWS_USE_LANG_T(lang, "media_protocol_not_supported")
return __ret, __msg
@staticmethod
def addAllStreamProxy(lang=None):
"""开启所有流代理"""
ret = False
msg = LANG_VIEWS_USE_LANG_T(lang, "msg_unknown_error")
online_streams = g_zlm.getMediaList()
online_stream_dict = {}
if len(online_streams) == 0:
g_database.execute("update av_stream set forward_state=0")
else:
for d in online_streams:
an = "{app}_{name}".format(app=d["app"], name=d["name"])
online_stream_dict[an] = d
success_count = 0
error_count = 0
streams = StreamModel.objects.all()
for stream in streams:
stream_an = "{app}_{name}".format(app=stream.app, name=stream.name)
if online_stream_dict.get(stream_an):
success_count += 1
else:
__add_ret, __add_msg = GlobalUtils.addStreamProxy(stream, lang=lang)
if __add_ret:
stream.forward_state = 1
stream.save()
success_count += 1
else:
error_count += 1
ret = True
msg = LANG_VIEWS_USE_LANG_T(lang, "msg_batch_result") % (success_count, error_count)
return ret, msg
@staticmethod
def delStreamProxy(stream, lang=None):
"""关闭流代理"""
__ret = False
__msg = LANG_VIEWS_USE_LANG_T(lang, "msg_unknown_error")
if stream.pull_stream_type in [1, 2, 3, 4]:
del_flag, del_msg = g_zlm.delStreamProxy(app=stream.app, name=stream.name)
if del_flag:
__ret = True
__msg = LANG_VIEWS_USE_LANG_T(lang, "media_forward_stop_success")
else:
__msg = del_msg
elif stream.pull_stream_type == 21:
__ret, __msg = g_gb28181SipServer.request_bye(client_id=stream.camera_device_id, channel_id=stream.name)
if __ret:
__msg = LANG_VIEWS_USE_LANG_T(lang, "media_forward_stop_success")
elif stream.pull_stream_type == 31:
__ret, __msg = g_zlm.close_streams(schema="rtsp", app=stream.app, name=stream.name)
if __ret:
__msg = LANG_VIEWS_USE_LANG_T(lang, "media_forward_stop_success")
elif stream.pull_stream_type == 32:
__ret, __msg = g_zlm.close_streams(schema="rtmp", app=stream.app, name=stream.name)
if __ret:
__msg = LANG_VIEWS_USE_LANG_T(lang, "media_forward_stop_success")
else:
__msg = LANG_VIEWS_USE_LANG_T(lang, "media_protocol_not_supported")
return __ret, __msg
@staticmethod
def delAllStreamProxy(lang=None):
"""关闭所有流代理"""
online_streams = g_zlm.getMediaList()
for d in online_streams:
stream = StreamModel.objects.filter(app=d["app"], name=d["name"]).first()
if stream:
__ret, __msg = GlobalUtils.delStreamProxy(stream, lang=lang)
g_database.execute("update av_stream set forward_state=0")
@staticmethod
def apply_runtime_config(before_cfg):
"""保存 config.json 后热更新运行时组件(不重启 Django 进程)。"""
before_cfg = before_cfg or {}
after_cfg = g_config.to_dict(include_secrets=True)
notes = []
def _sip_snapshot(cfg):
sip = dict((cfg or {}).get("sipServer") or {})
return (
sip.get("sipServerIp"), sip.get("sipServerPort"), sip.get("sipServerId"),
sip.get("sipServerRealm"), sip.get("sipServerPass"), sip.get("sipServerTimeout"),
sip.get("sipServerExpiry"), sip.get("sipTransferMode"), sip.get("rtpTransferMode"),
sip.get("rtpTransferAudioType"), sip.get("autoInviteAfterRecCateLog"),
)
if _sip_snapshot(before_cfg) != _sip_snapshot(after_cfg):
try:
from app.services.lifecycle import is_service_leader
if is_service_leader():
GlobalUtils._reload_gb28181_sip()
g_logger.info("apply_runtime_config: GB28181 SIP reloaded")
else:
notes.append("gb28181")
except Exception as e:
g_logger.warning("apply_runtime_config: GB28181 reload failed: %s", e)
notes.append("gb28181")
before_rec = _bool_cfg(before_cfg.get("recordingEnabled"))
after_rec = bool(g_config.recordingEnabled)
if after_rec != before_rec:
try:
from app.services.lifecycle import get_service_manager
get_service_manager().reconcile()
except Exception as e:
g_logger.warning("apply_runtime_config: recording manager: %s", e)
if before_cfg.get("adminPort") != after_cfg.get("adminPort"):
notes.append("adminPort")
if _bool_cfg(before_cfg.get("logDebug")) != bool(g_config.logDebug):
notes.append("logDebug")
media_keys = (
"mediaHttpPort", "mediaRtspPort", "mediaRtmpPort",
"mediaStartPath", "mediaStartConfigPath", "mediaSecret",
)
if any(before_cfg.get(k) != after_cfg.get(k) for k in media_keys):
notes.append("zlm")
return notes
@staticmethod
def _reload_gb28181_sip():
global g_gb28181SipServer
try:
g_gb28181SipServer.stop()
except Exception:
pass
sip = dict(g_config.sipServer or {})
g_gb28181SipServer = GB28181SipServer(
server_ip=sip.get("sipServerIp"),
server_port=sip.get("sipServerPort"),
server_id=sip.get("sipServerId"),
realm=sip.get("sipServerRealm"),
password=sip.get("sipServerPass"),
sip_server_timeout=sip.get("sipServerTimeout"),
sip_server_expiry=sip.get("sipServerExpiry"),
sip_transfer_mode=sip.get("sipTransferMode"),
rtp_transfer_mode=sip.get("rtpTransferMode"),
rtp_transfer_audio_type=sip.get("rtpTransferAudioType"),
auto_invite_after_rec_cate_log=sip.get("autoInviteAfterRecCateLog"),
admin_host=g_config.adminHost,
zlm=g_zlm,
logger=g_logger,
)
g_gb28181SipServer.start()
class CheckServerUtils():
@staticmethod
def checkVersion(request_ip, peer_ip, peer_port, lang=None):
# Network and host information parameters are intentionally ignored.
from app.services.telemetry import check_update
return check_update(lang=lang)
@staticmethod
def reportHeart(report_count, lang=None):
from app.services.telemetry import send_heartbeat
return send_heartbeat(report_count)

465
app/utils/GpuInfo.py Normal file
View File

@ -0,0 +1,465 @@
"""本机显卡信息采集(支持 NVIDIA / Intel / AMDWindows & Linux"""
import json
import os
import platform
import re
import shutil
import subprocess
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
_GPU_CACHE = {"ts": 0.0, "data": []}
_GPU_STATIC_CACHE = {"ts": 0.0, "adapters": []}
_GPU_CACHE_LOCK = threading.Lock()
_GPU_CACHE_TTL = 8.0
_GPU_STATIC_TTL = 120.0
def _byte_to_mb(val):
try:
v = float(val)
except (TypeError, ValueError):
return None
if v <= 0:
return None
return round(v / (1024 * 1024), 1)
def _run_cmd(cmd, timeout=8, shell=False):
try:
flags = 0
if os.name == 'nt' and hasattr(subprocess, 'CREATE_NO_WINDOW'):
flags = subprocess.CREATE_NO_WINDOW
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
shell=shell,
encoding='utf-8',
errors='ignore',
creationflags=flags,
)
if proc.returncode != 0:
return ''
return (proc.stdout or '').strip()
except Exception:
return ''
def _vendor_from_name(name):
n = (name or '').lower()
if 'nvidia' in n or 'geforce' in n or 'quadro' in n or 'tesla' in n or 'rtx' in n or 'gtx' in n:
return 'nvidia'
if 'intel' in n or 'iris' in n or 'uhd' in n:
return 'intel'
if 'amd' in n or 'radeon' in n:
return 'amd'
return 'other'
def _is_virtual_gpu(name):
n = (name or '').lower()
skip = (
'microsoft basic', 'remote desktop', 'virtual display', 'virtual adapter',
'meta virtual', 'spacedesk', 'parsec', 'vmware', 'virtualbox',
'oray', 'idd driver', 'sunlogin', 'toDesk',
)
return any(k in n for k in skip)
def _merge_gpu(existing, new):
"""按 index / 规范化名称合并,保留利用率更高的记录。"""
key = new.get('key') or str(new.get('index', new.get('name', '')))
old = existing.get(key)
if not old:
existing[key] = new
return
for field in ('util_percent', 'mem_used_mb', 'mem_total_mb', 'temperature_c'):
nv = new.get(field)
ov = old.get(field)
if nv is not None and (ov is None or (field == 'util_percent' and nv > ov)):
old[field] = nv
if len(new.get('name') or '') > len(old.get('name') or ''):
old['name'] = new['name']
if new.get('vendor') and new.get('vendor') != 'other':
old['vendor'] = new['vendor']
def _finalize_gpu(item):
util = item.get('util_percent')
mem_used = item.get('mem_used_mb')
mem_total = item.get('mem_total_mb')
mem_rate = None
if mem_used is not None and mem_total and mem_total > 0:
mem_rate = round(mem_used / mem_total, 3)
elif item.get('mem_util_percent') is not None:
mem_rate = round(float(item['mem_util_percent']) / 100, 3)
parts = []
if util is not None:
parts.append('%s%%' % round(float(util), 1))
if mem_used is not None and mem_total:
parts.append('%s / %s' % (_fmt_mb(mem_used), _fmt_mb(mem_total)))
elif mem_total:
parts.append(_fmt_mb(mem_total))
return {
'index': item.get('index', 0),
'name': item.get('name') or 'GPU',
'vendor': item.get('vendor') or 'other',
'util_percent': round(float(util), 1) if util is not None else None,
'mem_used_mb': mem_used,
'mem_total_mb': mem_total,
'mem_used_rate': mem_rate,
'temperature_c': item.get('temperature_c'),
'detail_str': ' / '.join(parts) if parts else '--',
}
def _fmt_mb(v):
if v is None:
return '--'
if v >= 1024:
return '%.2fGB' % (v / 1024)
if float(v).is_integer():
return '%dMB' % int(v)
return '%.1fMB' % float(v)
def _collect_nvidia_gpus():
if not shutil.which('nvidia-smi'):
return []
out = _run_cmd([
'nvidia-smi',
'--query-gpu=index,name,utilization.gpu,utilization.memory,memory.total,memory.used,temperature.gpu',
'--format=csv,noheader,nounits',
])
if not out:
return []
gpus = []
for line in out.splitlines():
line = line.strip()
if not line:
continue
parts = [p.strip() for p in line.split(',')]
if len(parts) < 6:
continue
try:
idx = int(parts[0])
except ValueError:
idx = len(gpus)
name = parts[1]
util_gpu = _safe_float(parts[2])
mem_util = _safe_float(parts[3])
mem_total = _safe_float(parts[4])
mem_used = _safe_float(parts[5])
temp = _safe_float(parts[6]) if len(parts) > 6 else None
gpus.append({
'key': 'nvidia:%s' % idx,
'index': idx,
'name': name,
'vendor': 'nvidia',
'util_percent': util_gpu,
'mem_util_percent': mem_util,
'mem_total_mb': mem_total,
'mem_used_mb': mem_used,
'temperature_c': temp,
})
return gpus
def _safe_float(s):
if s is None:
return None
s = str(s).strip().replace('[N/A]', '').replace('N/A', '')
if not s:
return None
try:
return float(s)
except ValueError:
return None
def _collect_windows_adapters():
"""WMI 读取显卡列表(较快,不含利用率)。"""
now = time.time()
with _GPU_CACHE_LOCK:
if _GPU_STATIC_CACHE['adapters'] and now - _GPU_STATIC_CACHE['ts'] < _GPU_STATIC_TTL:
return list(_GPU_STATIC_CACHE['adapters'])
ps_script = r"""
$ErrorActionPreference = 'SilentlyContinue'
$controllers = Get-CimInstance Win32_VideoController | Where-Object {
$_.Name -and ($_.Name -notmatch 'Microsoft Basic|Remote Desktop|Virtual Display|Virtual Adapter|Meta Virtual|Spacedesk|Parsec|VMware|VirtualBox|Oray|Idd Driver|Sunlogin|ToDesk')
}
$list = @()
$i = 0
foreach ($c in $controllers) {
$name = [string]$c.Name
$vendor = 'other'
if ($name -match 'NVIDIA|GeForce|Quadro|RTX|GTX|Tesla') { $vendor = 'nvidia' }
elseif ($name -match 'Intel|Iris|UHD|Arc') { $vendor = 'intel' }
elseif ($name -match 'AMD|Radeon') { $vendor = 'amd' }
$ramMb = $null
if ($c.AdapterRAM -and [double]$c.AdapterRAM -gt 0) {
$ramMb = [math]::Round([double]$c.AdapterRAM / 1MB, 0)
}
$list += [pscustomobject]@{
index = $i
name = $name
vendor = $vendor
mem_total_mb = $ramMb
}
$i++
}
$list | ConvertTo-Json -Compress
"""
out = _run_cmd(['powershell', '-NoProfile', '-NonInteractive', '-Command', ps_script], timeout=5)
if not out:
return []
try:
data = json.loads(out)
except json.JSONDecodeError:
return []
if isinstance(data, dict):
data = [data]
adapters = []
for item in data:
if not isinstance(item, dict):
continue
name = item.get('name') or ''
if _is_virtual_gpu(name):
continue
adapters.append({
'index': item.get('index', len(adapters)),
'name': name,
'vendor': item.get('vendor') or _vendor_from_name(name),
'mem_total_mb': item.get('mem_total_mb'),
})
with _GPU_CACHE_LOCK:
_GPU_STATIC_CACHE['adapters'] = list(adapters)
_GPU_STATIC_CACHE['ts'] = time.time()
return adapters
def _collect_windows_util():
"""读取 Windows GPU 利用率Get-Counter 较慢,单独调用并限制超时)。"""
ps_script = r"""
$ErrorActionPreference = 'SilentlyContinue'
$utilByPhys = @{}
try {
$samples = (Get-Counter '\GPU Engine(*)\Utilization Percentage' -SampleInterval 1 -MaxSamples 1).CounterSamples
foreach ($s in $samples) {
if ($s.InstanceName -match 'phys_(\d+)') {
$p = $matches[1]
$v = [double]$s.CookedValue
if (-not $utilByPhys.ContainsKey($p) -or $v -gt $utilByPhys[$p]) {
$utilByPhys[$p] = [math]::Round($v, 1)
}
}
}
} catch {}
$utilByPhys | ConvertTo-Json -Compress
"""
out = _run_cmd(['powershell', '-NoProfile', '-NonInteractive', '-Command', ps_script], timeout=4)
if not out:
return {}
try:
util_map = json.loads(out or '{}')
except json.JSONDecodeError:
return {}
return util_map if isinstance(util_map, dict) else {}
def _merge_windows_adapters_util(adapters, util_map):
gpus = []
for item in adapters:
idx = item.get('index', len(gpus))
name = item.get('name') or ''
util = _safe_float(util_map.get(str(idx)))
if util is None:
util = _safe_float(util_map.get(idx))
gpus.append({
'key': 'win:%s:%s' % (idx, _norm_name(name)),
'index': idx,
'name': name,
'vendor': item.get('vendor') or _vendor_from_name(name),
'util_percent': util,
'mem_total_mb': item.get('mem_total_mb'),
'mem_used_mb': None,
})
used_utils = {g.get('util_percent') for g in gpus if g.get('util_percent') is not None}
spare_utils = []
for v in (util_map or {}).values():
fv = _safe_float(v)
if fv is not None and fv not in used_utils:
spare_utils.append(fv)
spare_utils.sort(reverse=True)
for g in gpus:
if g.get('util_percent') is None and spare_utils:
g['util_percent'] = spare_utils.pop(0)
return gpus
def _collect_windows_gpus():
adapters = _collect_windows_adapters()
if not adapters:
return []
util_map = _collect_windows_util()
return _merge_windows_adapters_util(adapters, util_map)
def _norm_name(name):
return re.sub(r'\s+', ' ', (name or '').strip().lower())
def _collect_linux_gpus():
merged = {}
# NVIDIA
for g in _collect_nvidia_gpus():
_merge_gpu(merged, g)
# DRM 卡名称
drm_cards = []
drm_root = '/sys/class/drm'
if os.path.isdir(drm_root):
for entry in sorted(os.listdir(drm_root)):
if not re.match(r'^card\d+$', entry):
continue
card_path = os.path.join(drm_root, entry)
name = _read_first_line(os.path.join(card_path, 'device/vendor'))
prod = _read_first_line(os.path.join(card_path, 'device/device'))
label = entry
try:
for dev in os.listdir(card_path):
if dev.startswith('renderD') or re.match(r'^card\d+-', dev):
pass
except OSError:
pass
vendor_file = _read_first_line(os.path.join(card_path, 'device/vendor'))
device_file = _read_first_line(os.path.join(card_path, 'device/device'))
human = _linux_pci_name(vendor_file, device_file) or entry
idx_match = re.search(r'card(\d+)', entry)
idx = int(idx_match.group(1)) if idx_match else len(drm_cards)
drm_cards.append({'index': idx, 'name': human, 'vendor': _vendor_from_name(human), 'key': 'drm:%s' % entry})
# Intel GPU load (RK/Intel platforms)
intel_load = _read_first_line('/sys/kernel/debug/dri/0/i915_gem_objects') # not util
intel_util = _linux_intel_util()
for card in drm_cards:
util = intel_util if card['vendor'] == 'intel' and intel_util is not None else None
_merge_gpu(merged, {
'key': card['key'],
'index': card['index'],
'name': card['name'],
'vendor': card['vendor'],
'util_percent': util,
})
return list(merged.values())
def _read_first_line(path):
try:
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
return f.read().strip()
except OSError:
return ''
def _linux_pci_name(vendor, device):
vendor_map = {'0x8086': 'Intel', '0x10de': 'NVIDIA', '0x1002': 'AMD'}
v = vendor_map.get(vendor.lower() if vendor else '', '')
if v:
return '%s GPU (%s)' % (v, device or '')
return ''
def _linux_intel_util():
"""尝试读取 Intel iGPU 占用(部分内核提供 dri 调试节点)。"""
for path in (
'/sys/class/drm/card0/device/gt_busy_percent',
'/sys/class/drm/card1/device/gt_busy_percent',
):
val = _read_first_line(path)
if val:
f = _safe_float(val.replace('%', ''))
if f is not None:
return f
return None
def _merge_nvidia_into_windows(merged, nvidia_gpus):
for g in nvidia_gpus:
matched = False
norm = _norm_name(g.get('name'))
for item in merged.values():
if item.get('vendor') == 'nvidia' and (
_norm_name(item.get('name')) in norm or norm in _norm_name(item.get('name'))
):
item.update({
'util_percent': g.get('util_percent', item.get('util_percent')),
'mem_used_mb': g.get('mem_used_mb', item.get('mem_used_mb')),
'mem_total_mb': g.get('mem_total_mb', item.get('mem_total_mb')),
'mem_util_percent': g.get('mem_util_percent'),
'temperature_c': g.get('temperature_c'),
'name': g.get('name') or item.get('name'),
})
matched = True
break
if not matched:
_merge_gpu(merged, g)
def _collect_gpu_info_uncached():
merged = {}
system = platform.system()
if system == 'Windows':
adapters = _collect_windows_adapters()
nvidia_gpus = []
util_map = {}
with ThreadPoolExecutor(max_workers=2) as pool:
f_util = pool.submit(_collect_windows_util)
f_nv = pool.submit(_collect_nvidia_gpus)
try:
util_map = f_util.result(timeout=4.5) or {}
except Exception:
util_map = {}
try:
nvidia_gpus = f_nv.result(timeout=3) or []
except Exception:
nvidia_gpus = []
for g in _merge_windows_adapters_util(adapters, util_map):
_merge_gpu(merged, g)
_merge_nvidia_into_windows(merged, nvidia_gpus)
else:
for g in _collect_linux_gpus():
_merge_gpu(merged, g)
result = [_finalize_gpu(v) for v in merged.values()]
result.sort(key=lambda x: (x.get('index', 0), x.get('name', '')))
for i, g in enumerate(result):
g['index'] = i
return result
def get_gpu_info(force_refresh=False):
"""返回本机所有可用显卡列表(带短时缓存,避免频繁调用 PowerShell"""
now = time.time()
with _GPU_CACHE_LOCK:
if not force_refresh and _GPU_CACHE['data'] and now - _GPU_CACHE['ts'] < _GPU_CACHE_TTL:
return list(_GPU_CACHE['data'])
result = _collect_gpu_info_uncached()
with _GPU_CACHE_LOCK:
_GPU_CACHE['data'] = list(result)
_GPU_CACHE['ts'] = time.time()
return result

41
app/utils/LLMUtils.py Normal file
View File

@ -0,0 +1,41 @@
import base64
from openai import OpenAI
class LLMUtils:
"""大模型 API 工具OpenAI 兼容视觉推理 / 文本对话)"""
def __init__(self, api_url, api_key=None, timeout=15, inference_tool="OpenAI",
model="gpt-4o"):
self.api_url = api_url
self.api_key = api_key
self.timeout = timeout
self.inference_tool = inference_tool or "OpenAI"
self.model = model
def __client(self):
return OpenAI(api_key=self.api_key, base_url=self.api_url, timeout=self.timeout)
def infer(self, prompt, image_bytes):
if self.inference_tool != "OpenAI":
raise Exception(f"不支持的推理工具: {self.inference_tool}")
image_base64 = base64.b64encode(image_bytes).decode("utf-8")
response = self.__client().chat.completions.create(
model=self.model,
messages=[{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_base64}"}},
{"type": "text", "text": prompt},
],
}],
)
return response.choices[0].message.content
@staticmethod
def check_happen(result, happen_words):
if not result or not happen_words:
return False
words = [w.strip() for w in happen_words.split(',') if w.strip()]
return any(w in result for w in words)

154
app/utils/LanguageUtils.py Normal file
View File

@ -0,0 +1,154 @@
import json
import os
from framework.settings import BASE_DIR
LANG_UI_DICT = {}
LANG_UI_JSON_CACHE = {}
_LANG_FILES_MTIME = 0.0
def _load_json_file(filepath):
for encoding in ["utf-8", "gbk"]:
try:
with open(filepath, 'r', encoding=encoding) as f:
data = json.load(f)
return data
except Exception as e:
print("LanguageUtils: failed to load %s (encoding=%s): %s" % (filepath, encoding, str(e)))
raise RuntimeError("LanguageUtils: cannot load from %s" % filepath)
def _flatten_lang_dict(data):
merged = dict(data.get("LANG_UI_DICT") or {})
for k, v in data.items():
if k == "LANG_UI_DICT":
continue
if isinstance(v, str):
merged[k] = v
return merged
def _lang_files_mtime():
mtimes = []
for language_config in GSettingsLanguages.values():
filename = language_config.get("filename", "")
if not filename:
continue
filepath = os.path.join(BASE_DIR, filename)
if os.path.exists(filepath):
mtimes.append(os.path.getmtime(filepath))
settings_path = os.path.join(BASE_DIR, "settings.json")
if os.path.exists(settings_path):
mtimes.append(os.path.getmtime(settings_path))
return max(mtimes) if mtimes else 0.0
def reload_lang_dict(force=False):
"""Reload language files when changed on disk (dev-friendly, no restart needed)."""
global LANG_UI_DICT, LANG_UI_JSON_CACHE, _LANG_FILES_MTIME, GSettingsLanguages, GSettingsLangDefault
current_mtime = _lang_files_mtime()
if not force and current_mtime <= _LANG_FILES_MTIME and LANG_UI_DICT:
return
settings_path = os.path.join(BASE_DIR, "settings.json")
settings_data = _load_json_file(settings_path)
GSettingsLangDefault = settings_data.get("lang_default", "zh")
GSettingsLanguages = settings_data.get("languages", {})
new_dict = {}
for lang_code, language_config in GSettingsLanguages.items():
filename = language_config.get("filename", "")
if not filename:
continue
filepath_language = os.path.join(BASE_DIR, filename)
try:
language_data = _load_json_file(filepath_language)
lang_ui_dict = _flatten_lang_dict(language_data)
if lang_ui_dict:
new_dict[lang_code] = lang_ui_dict
except Exception as e:
print("LanguageUtils: failed to load %s: %s" % (filename, str(e)))
LANG_UI_DICT.clear()
LANG_UI_DICT.update(new_dict)
LANG_UI_JSON_CACHE.clear()
LANG_UI_JSON_CACHE.update({
lang: json.dumps(d, ensure_ascii=False)
for lang, d in LANG_UI_DICT.items()
})
_LANG_FILES_MTIME = current_mtime
__settings_json_filepath = os.path.join(BASE_DIR, "settings.json")
__settings_data = _load_json_file(__settings_json_filepath)
GSettingsLangDefault = __settings_data.get("lang_default", "zh")
GSettingsLanguages = __settings_data.get("languages", {})
reload_lang_dict(force=True)
def __parse_get_params(request):
params = {}
try:
for k in request.GET:
params.__setitem__(k, request.GET.get(k))
except Exception as e:
params = {}
return params
def __parse_post_params(request):
params = {}
for k in request.POST:
params.__setitem__(k, request.POST.get(k))
if not params:
try:
params = request.body.decode('utf-8')
params = json.loads(params)
except Exception:
params = {}
return params
def f_parse_request_lang(request):
reload_lang_dict()
request_lang = None
if request.method == 'GET':
params = __parse_get_params(request)
lang = params.get('lang', '').strip()
if lang:
request_lang = lang
elif request.method == 'POST':
params = __parse_post_params(request)
lang = params.get('lang', '').strip()
if lang:
request_lang = lang
if not request_lang:
if hasattr(request, 'session'):
request_lang = request.session.get('lang', GSettingsLangDefault)
if not request_lang:
request_lang = GSettingsLangDefault
return request_lang
def LANG_VIEWS_T(request, key):
"""翻译函数:根据当前请求的语言环境,从 LANG_UI_DICT 中获取翻译文本"""
reload_lang_dict()
lang = f_parse_request_lang(request)
return LANG_UI_DICT.get(lang, {}).get(key, key)
def LANG_VIEWS_USE_LANG_T(lang, key):
"""翻译函数:根据指定语言,从 LANG_UI_DICT 中获取翻译文本"""
reload_lang_dict()
if not lang:
lang = GSettingsLangDefault
return LANG_UI_DICT.get(lang, {}).get(key, key)

49
app/utils/LogUtils.py Normal file
View File

@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""管理员操作日志工具类"""
from datetime import datetime
from app.models import LogModel
from app.utils.LanguageUtils import LANG_VIEWS_USE_LANG_T
class LogUtils:
LOG_TYPE_ADD = 1
LOG_TYPE_EDIT = 2
LOG_TYPE_LOGIN = 20
LOG_TYPE_LOGOUT = 21
STATE_SUCCESS = 1
@staticmethod
def add_log(user_id, log_type, content, state=STATE_SUCCESS):
try:
log = LogModel()
log.user_id = user_id
log.log_type = log_type
log.content = content
log.state = state
log.create_time = datetime.now()
log.save()
return True
except Exception as e:
print(f"LogUtils.add_log() error: {str(e)}")
return False
@staticmethod
def _action_text(log_type, lang):
if log_type == LogUtils.LOG_TYPE_ADD:
return LANG_VIEWS_USE_LANG_T(lang, "log_type_add")
return LANG_VIEWS_USE_LANG_T(lang, "log_type_edit")
@staticmethod
def add_stream_log(user_id, stream_code, log_type, lang=None):
action = LogUtils._action_text(log_type, lang)
content = LANG_VIEWS_USE_LANG_T(lang, "log_content_stream").format(
action=action, stream_code=stream_code)
return LogUtils.add_log(user_id, log_type, content)
@staticmethod
def add_user_log(user_id, username, log_type, lang=None):
action = LogUtils._action_text(log_type, lang)
content = LANG_VIEWS_USE_LANG_T(lang, "log_content_user").format(
action=action, username=username)
return LogUtils.add_log(user_id, log_type, content)

66
app/utils/Logger.py Normal file
View File

@ -0,0 +1,66 @@
import logging
import re
from logging.handlers import TimedRotatingFileHandler
# 统一日志格式:含模块名(name)与源码行号,便于 grep 定位
LOG_FORMAT = '%(asctime)s %(name)s:%(lineno)d [%(levelname)s] %(message)s'
class SensitiveDataFilter(logging.Filter):
"""Redact common credentials even when callers log an entire request mapping."""
_assignments = re.compile(
r"(?i)(['\"]?(?:api[_-]?key|password|passwd|secret|token|authorization)['\"]?\s*[:=]\s*)"
r"(['\"][^'\"]*['\"]|[^,}\s]+)",
)
_url_userinfo = re.compile(r"(?i)\b((?:rtsp|rtmp|https?)://)[^/@\s]+@")
def filter(self, record):
try:
rendered = record.getMessage()
rendered = self._assignments.sub(r"\1[REDACTED]", rendered)
rendered = self._url_userinfo.sub(r"\1[REDACTED]@", rendered)
record.msg = rendered
record.args = ()
except Exception:
pass
return True
def CreateLogger(filepath, is_show_console=False,log_debug=False):
LOGGER_WHEN = 'd'
LOGFILE_BACKUPCOUNT = 3
if log_debug:
level = logging.DEBUG
else:
level = logging.INFO
logger = logging.getLogger()
logger.setLevel(level)
formatter = logging.Formatter(LOG_FORMAT)
# 最基础
# fileHandler = logging.FileHandler(filepath, encoding='utf-8') # 指定utf-8格式编码避免输出的日志文本乱码
# fileHandler.setLevel(level)
# fileHandler.setFormatter(formatter)
# logger.addHandler(fileHandler)
# 时间滚动切分
# when:备份的时间单位backupCount:备份保存的时间长度
timedRotatingFileHandler = TimedRotatingFileHandler(filepath,
when=LOGGER_WHEN,
backupCount=LOGFILE_BACKUPCOUNT,
encoding='utf-8')
timedRotatingFileHandler.setLevel(level)
timedRotatingFileHandler.setFormatter(formatter)
timedRotatingFileHandler.addFilter(SensitiveDataFilter())
logger.addHandler(timedRotatingFileHandler)
# 控制台打印
if is_show_console:
streamHandler = logging.StreamHandler()
streamHandler.setLevel(level)
streamHandler.setFormatter(formatter)
streamHandler.addFilter(SensitiveDataFilter())
logger.addHandler(streamHandler)
return logger

View File

@ -0,0 +1,148 @@
"""ZLMediaKit (monitor_zlm) 进程管理 — 启动/停止/重启/状态查询。"""
import logging
import os
import subprocess
import sys
import threading
import time
logger = logging.getLogger("utils.media_server")
_MANAGER = None
_LOCK = threading.Lock()
class MediaServerManager(object):
def __init__(self):
self._proc = None
self._lock = threading.RLock()
def _paths(self):
from app.utils.GlobalUtils import g_config
exe = getattr(g_config, "mediaStartPath", "") or ""
cfg = getattr(g_config, "mediaStartConfigPath", "") or ""
return exe, cfg
def api_alive(self):
try:
from app.utils.GlobalUtils import g_zlm
ok, _msg, _data = g_zlm.getThreadsLoad()
return bool(ok)
except Exception as e:
logger.debug("api_alive: %s", e)
return False
def managed_pid(self):
with self._lock:
if self._proc is None:
return None
if self._proc.poll() is None:
return self._proc.pid
self._proc = None
return None
def status(self):
exe, cfg = self._paths()
pid = self.managed_pid()
api_ok = self.api_alive()
return {
"running": api_ok,
"api_ok": api_ok,
"managed": pid is not None,
"pid": pid or 0,
"exe": exe,
"config": cfg,
"exe_exists": bool(exe and os.path.isfile(exe)),
"config_exists": bool(cfg and os.path.isfile(cfg)),
}
def start(self):
from app.services.lifecycle import is_service_leader
if not is_service_leader():
return False, "background services are owned by another process or disabled"
exe, cfg = self._paths()
if not exe or not os.path.isfile(exe):
return False, "mediaStartPath 无效或文件不存在: %s" % (exe or "(空)")
if not cfg or not os.path.isfile(cfg):
return False, "mediaStartConfigPath 无效或文件不存在: %s" % (cfg or "(空)")
if self.api_alive():
return True, "流媒体服务已在运行"
work_dir = os.path.dirname(exe) or str(os.getcwd())
cmd = [exe, "-c", cfg]
logger.info("启动 ZLM: %s", " ".join(cmd))
try:
kwargs = {"cwd": work_dir, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
if sys.platform == "win32":
kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
else:
kwargs["start_new_session"] = True
with self._lock:
self._proc = subprocess.Popen(cmd, **kwargs)
for _ in range(30):
time.sleep(0.2)
if self.api_alive():
return True, "流媒体服务已启动 (pid=%s)" % self._proc.pid
return False, "进程已拉起但 API 未响应,请检查端口与 config.ini"
except Exception as e:
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():
return False, "background services are owned by another process or disabled"
exe, _cfg = self._paths()
with self._lock:
proc = self._proc
if proc is not None and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=5)
except Exception:
try:
proc.kill()
except Exception:
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, "流媒体服务已停止"
def restart(self):
ok, msg = self.stop()
if not ok and self.api_alive():
return False, msg
time.sleep(0.8)
return self.start()
def get_media_server_manager():
global _MANAGER
with _LOCK:
if _MANAGER is None:
_MANAGER = MediaServerManager()
return _MANAGER

45
app/utils/ModelTrust.py Normal file
View File

@ -0,0 +1,45 @@
"""SHA-256 allowlist for model formats that may invoke Python deserialization."""
import hashlib
import json
import os
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
MANIFEST_PATH = Path(
os.environ.get("MONITOR_TRUSTED_MODEL_MANIFEST", PROJECT_ROOT / ".trusted-models.json")
).resolve()
def sha256_file(path):
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def trusted_hashes():
hashes = {
item.strip().lower()
for item in os.environ.get("MONITOR_TRUSTED_MODEL_SHA256", "").split(",")
if item.strip()
}
if MANIFEST_PATH.is_file():
with open(MANIFEST_PATH, "r", encoding="utf-8") as handle:
data = json.load(handle)
entries = data.get("trusted_sha256", []) if isinstance(data, dict) else []
hashes.update(str(item).strip().lower() for item in entries if item)
return {item for item in hashes if len(item) == 64 and all(c in "0123456789abcdef" for c in item)}
def require_trusted_model(path):
path = Path(path).resolve()
if path.suffix.lower() != ".pt":
return ""
actual = sha256_file(path)
if actual not in trusted_hashes():
raise PermissionError(
"untrusted PyTorch model (sha256=%s); approve it with scripts/trust_model.py first" % actual
)
return actual

427
app/utils/OSSystem.py Normal file
View File

@ -0,0 +1,427 @@
import psutil
import shutil
from datetime import datetime
import platform
import subprocess
import os
class OSSystem():
def __init__(self):
self.__system_name = platform.system() # 操作系统
self.__machine_node = str(platform.node()) # 机器名称
@staticmethod
def getDateFmtStr(spend_date, spend_date_fmt="%d%d小时%d分钟%d"): # type <class 'datetime.timedelta'>
spend_day = spend_date.days # 已运行的天数 int
spend_seconds = spend_date.seconds # 已运行的秒数 int
spend_hour = int(spend_seconds / 60 / 60) # 已运行小时 int
spend_seconds -= spend_hour * 60 * 60 # 已运行的秒数 int
spend_minute = int(spend_seconds / 60) # 已运行的分钟 int
spend_seconds -= spend_minute * 60 # 已运行的秒数 int
spend_date_str = spend_date_fmt % (spend_day, spend_hour, spend_minute, spend_seconds)
return spend_date_str
def __byteFormat(self, bytes, suffix="B"):
"""
Scale bytes to its proper format
e.g:
1253656 => '1.20MB'
1253656678 => '1.17GB'
"""
factor = 1024
for unit in ["", "K", "M", "G", "T", "P"]:
if bytes < factor:
return f"{bytes:.2f}{unit}{suffix}"
bytes /= factor
def getOSInfo(self, spend_date_fmt="%d%d小时%d分钟%d", include_gpu=False):
# 获取系统cpu比例 start
os_cpu_used = psutil.cpu_percent()
# os_cpu_physical_core = psutil.cpu_count(logical=False) # 物理核心数量
os_cpu_total_core = psutil.cpu_count(logical=True) # 逻辑核心数量
os_cpu_used_rate = round(os_cpu_used / 100, 3) # <class 'float'> 0.125
# 获取系统cpu比例 end
# 获取系统内存比例 start
os_virtual_mem = psutil.virtual_memory()
os_virtual_mem_total = os_virtual_mem.total
if os_virtual_mem.total == 0:
os_virtual_mem_used_rate = 0
else:
os_virtual_mem_used_rate = os_virtual_mem.used / os_virtual_mem.total
os_virtual_mem_used_rate = round(os_virtual_mem_used_rate, 3) # <class 'float'> 0.635
# 获取系统内存比例 end
# 获取系统磁盘比例 start
os_disk_total = 0
os_disk_used = 0
os_disk_free = 0
os_disk_partitions = psutil.disk_partitions()
for partition in os_disk_partitions:
try:
partition_usage = psutil.disk_usage(partition.mountpoint)
os_disk_total += partition_usage.total
os_disk_free += partition_usage.free
os_disk_used += partition_usage.used
except Exception as e:
pass
if os_disk_total == 0:
os_disk_used_rate = 0
else:
os_disk_used_rate = os_disk_used / os_disk_total
os_disk_used_rate = round(os_disk_used_rate, 3) # 当前系统磁盘占用比例
# 获取系统磁盘比例 end
# 获取系统开机时间 start
os_boot_timestamp = int(psutil.boot_time()) # <class 'float'> 1651904713.9067075
os_boot_date = datetime.fromtimestamp(os_boot_timestamp) # <class 'datetime.datetime'>
os_run_date = datetime.now() - os_boot_date # <class 'datetime.timedelta'>
os_run_date_str = self.getDateFmtStr(os_run_date, spend_date_fmt=spend_date_fmt)
# 获取系统开机时间 end
os_gpus = []
if include_gpu:
try:
from app.utils.GpuInfo import get_gpu_info
os_gpus = get_gpu_info()
except Exception:
os_gpus = []
os_info = {
"machine_node": str(platform.node()),
"system_name": self.getSystemName(),
"os_cpu_used_rate": os_cpu_used_rate, # cpu总占比
"os_virtual_mem_used_rate": os_virtual_mem_used_rate, # 内存总占比
"os_disk_used_rate": os_disk_used_rate,
"os_cpu_used_rate_str": str(round(os_cpu_used_rate * 100, 1)) + "% / " + str(os_cpu_total_core),
"os_virtual_mem_used_rate_str": str(round(os_virtual_mem_used_rate * 100, 1)) + "% / " + str(
self.__byteFormat(os_virtual_mem_total)),
"os_disk_used_rate_str": str(round(os_disk_used_rate * 100, 1)) + "% / " + str(
self.__byteFormat(os_disk_total)),
"os_run_date_str": os_run_date_str,
"os_gpus": os_gpus,
}
return os_info
def getSystemName(self):
return self.__system_name
def getMachineNode(self):
return self.__machine_node
def getMachineOsRelease(self):
# cat /etc/os-release
if self.getSystemName() == "Windows":
__str = "Windows"
else:
try:
result = os.popen("cat /etc/os-release")
__str = str(result.read()).strip()
except:
__str = "run error"
return __str
def getMachineLsCpu(self):
# lscpu
if self.getSystemName() == "Windows":
__str = "Windows"
else:
try:
result = os.popen("lscpu")
__str = str(result.read()).strip()
except:
__str = "run error"
return __str
def getMachineUnameA(self):
# uname -a
if self.getSystemName() == "Windows":
__str = "Windows"
else:
try:
result = os.popen("uname -a")
__str = str(result.read()).strip()
except:
__str = "run error"
return __str
def getMachineCpu(self):
"""
获取系统 CPU 信息
兼容 Windows 11 (PowerShell) Linux 系统
采用多种方法确保可靠获取
"""
system_name = self.getSystemName()
if system_name == "Windows":
machine_cpu = None
# --- 方案一PowerShell Get-CimInstance (推荐Win8+ 兼容) ---
try:
ps_command = [
"powershell",
"-Command",
"Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty Name"
]
output = subprocess.check_output(
ps_command,
stderr=subprocess.STDOUT,
timeout=10,
encoding='utf-8',
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, 'CREATE_NO_WINDOW') else 0
)
machine_cpu = output.strip()
# 验证结果是否有效
if machine_cpu and len(machine_cpu) > 3 and not machine_cpu.startswith("Get-CimInstance"):
return machine_cpu
except Exception as e:
pass
# --- 方案二WMIC 命令 (旧版 Windows 回退方案) ---
try:
output = subprocess.check_output(
"wmic cpu get Name",
shell=True,
timeout=10,
encoding='utf-8',
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, 'CREATE_NO_WINDOW') else 0
)
lines = [line.strip() for line in output.splitlines() if line.strip()]
# 解析输出,跳过标题行 "Name"
if len(lines) >= 2:
# 第一行是 "Name",第二行开始是 CPU 名称
machine_cpu = lines[1].strip()
if machine_cpu and len(machine_cpu) > 3:
return machine_cpu
except Exception as e:
pass
# --- 方案三PowerShell Get-WmiObject (备选,兼容旧系统) ---
try:
ps_command = [
"powershell",
"-Command",
"Get-WmiObject -Class Win32_Processor | Select-Object -ExpandProperty Name"
]
output = subprocess.check_output(
ps_command,
stderr=subprocess.STDOUT,
timeout=10,
encoding='utf-8',
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, 'CREATE_NO_WINDOW') else 0
)
machine_cpu = output.strip()
if machine_cpu and len(machine_cpu) > 3 and not machine_cpu.startswith("Get-WmiObject"):
return machine_cpu
except Exception as e:
pass
# --- 方案四:使用 psutil (如果可用) ---
try:
cpu_info = platform.processor()
if cpu_info and len(cpu_info) > 3:
return cpu_info
except Exception:
pass
return "run error"
else: # Linux / macOS / 其他
# --- 方案一:直接读取 /proc/cpuinfo (最可靠,不需要外部命令) ---
try:
with open('/proc/cpuinfo', 'r', encoding='utf-8') as f:
for line in f:
if line.startswith("model name"):
machine_cpu = line.split(":", 1)[1].strip()
if machine_cpu:
return machine_cpu
except Exception as e:
pass
# --- 方案二:使用 lscpu 命令 ---
try:
result = subprocess.run(
['lscpu'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=10,
universal_newlines=True
)
if result.returncode == 0:
for line in result.stdout.splitlines():
if line.startswith("Model name"):
machine_cpu = line.split(":", 1)[1].strip()
if machine_cpu:
return machine_cpu
except Exception as e:
pass
# --- 方案三:使用 uname -m 获取架构信息 ---
try:
result = subprocess.run(
['uname', '-m'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=10,
universal_newlines=True
)
if result.returncode == 0:
arch = result.stdout.strip()
if arch:
# 对于 ARM 设备,返回架构信息
return f"ARM Processor ({arch})"
except Exception as e:
pass
# --- 方案四:尝试读取设备树信息 (适用于嵌入式 ARM 设备) ---
try:
dt_paths = [
'/proc/device-tree/model',
'/proc/device-tree/compatible'
]
for path in dt_paths:
if os.path.exists(path):
with open(path, 'rb') as f:
value = f.read().rstrip(b'\x00').decode('utf-8', errors='ignore')
if value and len(value) > 3:
return value.replace('\n', ' ').strip()
except Exception as e:
pass
return "run error"
def getMachineNvidia(self):
# nvidia-smi
try:
if shutil.which("nvidia-smi"):
result = os.popen("nvidia-smi")
__str = str(result.read()).strip()
if len(__str) > 0:
lines = __str.split("\n")
lines_filter = []
for line in lines:
if line.find("NVIDIA-SMI") > -1:
lines_filter.append(line.strip())
elif line.find("Default") > -1:
lines_filter.append(line.strip())
elif line.find("NVIDIA") > -1:
lines_filter.append(line.strip())
__str = ",".join(lines_filter)
else:
__str = ""
else:
__str = "no"
except:
__str = "run error"
return __str
def getMachineAscend(self):
# npu-smi info
if self.getSystemName() == "Windows":
return ""
else:
# 2. 定义可能的 npu-smi 路径 (防止 PATH 未配置)
# 昇腾默认安装路径通常在 /usr/local/Ascend/driver/tools/
possible_paths = [
"npu-smi",
"/usr/local/Ascend/driver/tools/npu-smi",
"/usr/local/Ascend/bin/npu-smi"
]
cmd_path = None
# 优先查找环境变量中的命令,如果找不到则尝试硬编码路径
for path in possible_paths:
if shutil.which(path):
cmd_path = path
break
if not cmd_path:
return "no_cmd_found" # 明确区分是找不到命令还是执行失败
try:
# 3. 使用 subprocess.run 替代 os.popen
# capture_output=True: 同时捕获 stdout 和 stderr
# text=True: 直接返回字符串而非 bytes
# timeout=10: 防止命令卡死导致程序挂起
result = subprocess.run(
[cmd_path, "info"],
capture_output=True,
text=True,
timeout=10,
env=os.environ # 显式继承当前环境变量
)
# 4. 合并输出 (有些错误信息在 stderr有些正常输出在 stdout)
# 注意npu-smi 正常时 stderr 通常为空,但为了调试建议都看看
output = result.stdout.strip()
error = result.stderr.strip()
# 如果返回码不为 0说明执行出错 (可能是权限问题)
if result.returncode != 0:
# 如果是权限问题npu-smi 通常会提示 "Permission denied" 或类似信息
return f"exec_error(code={result.returncode}): {error or output}"
if not output:
return "empty_output"
# 5. 优化过滤逻辑 (保留原逻辑但增加容错)
lines = output.split("\n")
lines_filter = []
has_table = False
for line in lines:
# 只要包含 | 就认为是表格行
if "|" in line:
lines_filter.append(line.strip())
has_table = True
# 【改进】如果过滤后为空,但原始输出不为空,说明格式可能变了,不要直接丢弃,返回原始内容供调试
if not has_table:
# 可以选择返回原始输出,或者标记为格式未知
# return output
pass
final_str = ",".join(lines_filter)
return final_str if final_str else output
except subprocess.TimeoutExpired:
return "timeout"
except PermissionError:
return "permission_denied_need_root"
except Exception as e:
return f"run_exception: {str(e)}"
def getMachineRknpu(self):
# cat /sys/kernel/debug/rknpu/load
if self.getSystemName() == "Windows":
__str = ""
else:
try:
if os.path.exists("/sys/kernel/debug/rknpu/load"):
result = os.popen("cat /sys/kernel/debug/rknpu/load")
__str = str(result.read()).strip()
if len(__str) > 0:
lines = __str.split("\n")
__str = ",".join(lines)
else:
__str = ""
else:
__str = "no"
except:
__str = "run error"
return __str

139
app/utils/Secrets.py Normal file
View File

@ -0,0 +1,139 @@
"""Runtime secret management.
Secrets are loaded from environment variables first. For local/offline installs,
missing values are generated once into ``.runtime-secrets.json`` at the project
root. That file must never be committed or served by the web application.
"""
import json
import os
import secrets
import threading
import base64
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
RUNTIME_SECRETS_FILE = Path(
os.environ.get("MONITOR_RUNTIME_SECRETS_FILE", PROJECT_ROOT / ".runtime-secrets.json")
).resolve()
_ENV_NAMES = {
"django_secret_key": "MONITOR_DJANGO_SECRET_KEY",
"internal_api_secret": "MONITOR_INTERNAL_API_SECRET",
"media_secret": "MONITOR_MEDIA_SECRET",
"sip_server_password": "MONITOR_SIP_SERVER_PASSWORD",
"sip_server_nonce": "MONITOR_SIP_SERVER_NONCE",
"credential_encryption_key": "MONITOR_CREDENTIAL_ENCRYPTION_KEY",
}
_CACHE = None
_LOCK = threading.RLock()
def _new_secret_values():
return {
"django_secret_key": secrets.token_urlsafe(64),
"internal_api_secret": secrets.token_urlsafe(48),
"media_secret": secrets.token_urlsafe(32),
"sip_server_password": secrets.token_urlsafe(24),
"sip_server_nonce": secrets.token_hex(16),
"credential_encryption_key": base64.urlsafe_b64encode(os.urandom(32)).decode("ascii"),
}
def _read_file():
with open(RUNTIME_SECRETS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise RuntimeError("runtime secrets file must contain a JSON object")
return data
def _create_file_exclusive(data):
RUNTIME_SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True)
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
fd = os.open(str(RUNTIME_SECRETS_FILE), flags, 0o600)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
except Exception:
try:
os.unlink(RUNTIME_SECRETS_FILE)
except OSError:
pass
raise
try:
os.chmod(RUNTIME_SECRETS_FILE, 0o600)
except OSError:
pass
def _write_file_atomic(data):
RUNTIME_SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True)
temp_path = RUNTIME_SECRETS_FILE.with_suffix(RUNTIME_SECRETS_FILE.suffix + ".tmp")
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
fd = os.open(str(temp_path), flags, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
os.replace(temp_path, RUNTIME_SECRETS_FILE)
try:
os.chmod(RUNTIME_SECRETS_FILE, 0o600)
except OSError:
pass
def _load_or_create():
global _CACHE
with _LOCK:
if _CACHE is not None:
return dict(_CACHE)
if RUNTIME_SECRETS_FILE.exists():
data = _read_file()
else:
data = _new_secret_values()
try:
_create_file_exclusive(data)
except FileExistsError:
data = _read_file()
missing = [name for name in _ENV_NAMES if not data.get(name)]
if missing:
generated = _new_secret_values()
for name in missing:
data[name] = generated[name]
_write_file_atomic(data)
_CACHE = data
return dict(_CACHE)
def get_runtime_secret(name):
if name not in _ENV_NAMES:
raise KeyError("unknown runtime secret: %s" % name)
env_value = os.environ.get(_ENV_NAMES[name], "").strip()
if env_value:
return env_value
return str(_load_or_create()[name])
def rotate_runtime_secrets():
"""Rotate service/session secrets while preserving the data-encryption key."""
global _CACHE
with _LOCK:
backup = None
previous = {}
if RUNTIME_SECRETS_FILE.exists():
import shutil
from datetime import datetime
backup = RUNTIME_SECRETS_FILE.with_name(
RUNTIME_SECRETS_FILE.name + ".backup-" + datetime.now().strftime("%Y%m%d%H%M%S")
)
shutil.copy2(RUNTIME_SECRETS_FILE, backup)
previous = _read_file()
data = _new_secret_values()
# Rotating this value without decrypting and re-encrypting every database
# row would make stored third-party credentials unrecoverable.
if previous.get("credential_encryption_key"):
data["credential_encryption_key"] = previous["credential_encryption_key"]
_write_file_atomic(data)
_CACHE = data
return str(backup) if backup else ""

89
app/utils/UploadUtils.py Normal file
View File

@ -0,0 +1,89 @@
from datetime import datetime
import os
import shutil
import xlrd
from app.utils.LanguageUtils import LANG_VIEWS_USE_LANG_T
class UploadUtils():
"""文件上传工具类"""
# 上传摄像头Excel文件
def upload_camera_xlsx(self, file, upload_dir, lang=None):
__ret = False
__msg = LANG_VIEWS_USE_LANG_T(lang, "msg_unknown_error")
__data = []
file_name = file.name
file_content_type = file.content_type
if 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' == file_content_type and file_name.endswith(".xlsx"):
filename_dir = datetime.now().strftime("%Y%m%d%H%M%S") + "_" + file_name
abs_filedir = os.path.join(upload_dir, filename_dir)
if not os.path.exists(abs_filedir):
os.makedirs(abs_filedir)
abs_filepath = os.path.join(abs_filedir, file_name)
f = open(abs_filepath, 'wb')
f.write(file.read())
f.close()
# 读取excel
wb = xlrd.open_workbook(abs_filepath)
sheet = wb.sheet_by_index(0)
if sheet.ncols in (8, 9, 10): # 兼容8/9/10列
for row in range(sheet.nrows):
if row > 0:
try:
row_cols = sheet.row_values(row)
code = str(row_cols[0]).strip()
nickname = str(row_cols[1]).strip()
pull_stream_url = str(row_cols[2]).strip()
pull_stream_ip = str(row_cols[3]).strip()
try:
pull_stream_port = int(row_cols[4])
except:
pull_stream_port = 554
username = str(row_cols[5]).strip()
password = str(row_cols[6]).strip()
remark = str(row_cols[7]).strip()
try:
is_audio = int(row_cols[8])
except:
is_audio = 0
try:
camera_device_id = str(row_cols[9]).strip()
except:
camera_device_id = code
d = {
'code': code,
'nickname': nickname,
'pull_stream_url': pull_stream_url,
'pull_stream_ip': pull_stream_ip,
'pull_stream_port': pull_stream_port,
'username': username,
'password': password,
'remark': remark,
'is_audio': is_audio,
'camera_device_id': camera_device_id,
}
__data.append(d)
except:
pass
__ret = True
__msg = LANG_VIEWS_USE_LANG_T(lang, "msg_success")
else:
__msg = LANG_VIEWS_USE_LANG_T(lang, "upload_xlsx_cols_incorrect")
if os.path.exists(abs_filedir):
shutil.rmtree(abs_filedir)
else:
__msg = LANG_VIEWS_USE_LANG_T(lang, "upload_xlsx_format_incorrect")
return __ret, __msg, __data

107
app/utils/Utils.py Normal file
View File

@ -0,0 +1,107 @@
import random
import time
from app.utils.LanguageUtils import LANG_UI_DICT
import collections
def buildPageLabels(page, page_num, lang='zh'):
"""
:param page: 当前页面
:param page_num: 总页数
:param lang: 语言代码默认 'zh'
:return:
返回式例
[{'page': 1, 'name': 1, 'cur': True}, {'page': 2, 'name': 2, 'cur': False}, {'page': 2, 'name': '下一页'}]
"""
T = LANG_UI_DICT.get(lang, LANG_UI_DICT['zh'])
label_first = T.get('perm_first_page', '首页')
label_prev = T.get('perm_prev_page', '上一页')
label_next = T.get('perm_next_page', '下一页')
label_last = T.get('perm_last_page', '尾页')
pageLabels = []
if page > 1:
pageLabels.append({
"page": 1,
"name": label_first
})
pageLabels.append({
"page": page - 1, # 当前页点击时候触发的页数
"name": label_prev
})
if page == 1:
pageArray = [1, 2, 3, 4]
else:
pageArray = list(range(page - 1, page + 3)) # page-1,page,page+1,page+2
for p in pageArray:
if p <= page_num:
if page == p:
cur = 1
else:
cur = 0
pageLabels.append({
"page": p,
"name": p,
"cur": cur
})
if page + 1 <= page_num:
pageLabels.append({
"page": page + 1,
"name": label_next
})
if page_num > 0:
pageLabels.append({
"page": page_num,
"name": label_last
})
return pageLabels
def group_by_field(data, field):
"""
根据数据项中的field参数将一维列表分组为二维列表
Args:
data: list of dicts, 一维数据结构每个数据项为字典包含field键
Returns:
list of lists: 二维数据结构每个子列表包含相同field的数据项
"""
grouped_dict = collections.defaultdict(list)
for item in data:
# 获取stream_name作为分组键
value = item.get(field)
grouped_dict[value].append(item)
# 将字典中的值转换为二维列表
return list(grouped_dict.values())
class GB28181CodeUtils:
def __init__(self, default_area_code="34020000", default_industry="13"):
"""
初始化生成器
:param default_area_code: 默认行政区划码 (8)
:param default_industry: 默认行业编码 (2)
"""
self.default_area_code = default_area_code
self.default_industry = default_industry
def generate_by_time(self, area_code=None):
"""
基于当前时间生成编号常用于流水号场景
格式行政区(8) + 行业(2) + 年月日时分秒(10) + 随机(0-9)
注意这种格式总长度也是20位但逻辑不同
"""
area = area_code if area_code else self.default_area_code
# 截取时间的后10位数字作为序列号的一部分
time_str = time.strftime("%y%m%d%H%M%S") # 12位取后10位或者做处理
# 使用毫秒时间戳的后4位 + 3位随机数作为序列号确保快速连续调用也不重复
ms_part = f"{int(time.time() * 1000) % 10000:04d}"
rand_part = f"{random.randint(0, 999):03d}"
serial = ms_part + rand_part
return f"{area}{self.default_industry}200{serial}"

534
app/utils/ZLMediaKitApi.py Normal file
View File

@ -0,0 +1,534 @@
import requests
from framework.settings import PROJECT_UA
from app.utils import Utils
from app.utils.LanguageUtils import LANG_VIEWS_USE_LANG_T
class ZLMediaKitApi():
def __init__(self, logger, config):
self.__logger = logger
self.__config = config
self.default_stream_app = "live"
self.default_push_stream_app = "analyzer"
self.timeout = 30
self.__logger.info("ZLMediaKitApi.__init__()")
def __byteFormat(self, bytes, suffix="bps"):
factor = 1024
for unit in ["", "K", "M", "G"]:
if bytes < factor:
return f"{bytes:.2f}{unit}{suffix}"
bytes /= factor
def get_wsHost(self, request_ip=None):
__ip = self.__config.externalHost
if __ip == "0.0.0.0" and request_ip:
# v4.725新增) 使用实时请求ip代替逻辑
__ip = request_ip
__address = "ws://" + __ip + ":" + str(self.__config.mediaHttpPort)
return __address
def get_hlsUrl(self, app, name, request_ip=None):
__ip = self.__config.externalHost
if __ip == "0.0.0.0" and request_ip:
# v4.725新增) 使用实时请求ip代替逻辑
__ip = request_ip
__address = "http://" + __ip + ":" + str(self.__config.mediaHttpPort)
return "%s/%s/%s.hls.m3u8" % (__address , app, name)
def get_httpFlvUrl(self, app, name, request_ip=None):
__ip = self.__config.externalHost
if __ip == "0.0.0.0" and request_ip:
# v4.725新增) 使用实时请求ip代替逻辑
__ip = request_ip
__address = "http://" + __ip + ":" + str(self.__config.mediaHttpPort)
return "%s/%s/%s.live.flv" % (__address, app, name)
def get_rtspUrl(self, app, name, request_ip=None):
__ip = self.__config.externalHost
if __ip == "0.0.0.0" and request_ip:
# v4.725新增) 使用实时请求ip代替逻辑
__ip = request_ip
__address = "rtsp://" + __ip + ":" + str(self.__config.mediaRtspPort)
return "%s/%s/%s" % (__address, app, name)
def get_rtmpUrl(self, app, name, request_ip=None):
__ip = self.__config.externalHost
if __ip == "0.0.0.0" and request_ip:
# v5.017新增) 使用实时请求ip代替逻辑
__ip = request_ip
__address = "rtmp://" + __ip + ":" + str(self.__config.mediaRtmpPort)
return "%s/%s/%s" % (__address, app, name)
def get_wsMp4Url(self, app, name, request_ip=None):
__ip = self.__config.externalHost
if __ip == "0.0.0.0" and request_ip:
# v4.725新增) 使用实时请求ip代替逻辑
__ip = request_ip
__address = "ws://" + __ip + ":" + str(self.__config.mediaHttpPort)
return "%s/%s/%s.live.mp4" % (__address, app, name)
def get_wsFlvUrl(self, app, name, request_ip=None):
__ip = self.__config.externalHost
if __ip == "0.0.0.0" and request_ip:
# v4.725新增) 使用实时请求ip代替逻辑
__ip = request_ip
__address = "ws://" + __ip + ":" + str(self.__config.mediaHttpPort)
return "%s/%s/%s.live.flv" % (__address, app, name)
def get_httpMp4Url(self, app, name, request_ip=None):
__ip = self.__config.externalHost
if __ip == "0.0.0.0" and request_ip:
# v4.725新增) 使用实时请求ip代替逻辑
__ip = request_ip
__address = "http://" + __ip + ":" + str(self.__config.mediaHttpPort)
return "%s/%s/%s.live.mp4" % (__address, app, name)
def getThreadsLoad(self):
__ret = False
__msg = "zlm.getThreadsLoad()"
__data = []
try:
url = "{host}/index/api/getThreadsLoad".format(host=self.__config.mediaHttpHost)
params = {
"secret": self.__config.mediaSecret
}
res = requests.post(url, headers={
"User-Agent": PROJECT_UA
}, json=params, timeout=self.timeout)
if res.status_code == 200:
res_json = res.json()
# print(res_json)
if 0 == res_json["code"]:
__ret = True
__msg = "success"
__data = res_json["data"]
else:
raise Exception(str(res_json))
else:
raise Exception("status=%d" % res.status_code)
except Exception as e:
__msg += " e:%s"%str(e)
self.__logger.warning(__msg)
return __ret, __msg, __data
def addStreamProxy(self, app, name, origin_url, is_audio=0, vhost="__defaultVhost__",enable_rtmp=0):
__key = None # 添加成功返回的 "key" : "__defaultVhost__/proxy/0" 流的唯一标识
__msg = "zlm.addStreamProxy(app=%s,name=%s,origin_url=%s,is_audio=%s)" % (app, name, str(origin_url),str(is_audio))
try:
url = "{host}/index/api/addStreamProxy".format(host=self.__config.mediaHttpHost)
params = {
"secret": self.__config.mediaSecret,
'vhost': vhost,
'app': app,
'stream': name,
'url': origin_url
}
params["rtp_type"] = 0 # rtsp拉流时拉流方式0tcp1udp2组播
# params["timeout_sec"] = 1; # 拉流超时时间单位秒float类型
params["enable_hls"] = 0 # 是否转换成hls协议
params["enable_mp4"] = 0 # 是否允许mp4录制
# params["enable_rtsp"] = 1 # 是否转rtsp协议
if enable_rtmp == 0:
# 等于0表示不开启rtmp转发
params["enable_rtmp"] = 0 # 是否转rtmp / flv协议
params["enable_ts"] = 0 # 是否转http - ts / ws - ts协议
# params["enable_fmp4"] = 1 # 是否转http - fmp4 / ws - fmp4协议
params["enable_audio"] = is_audio # 转协议时是否开启音频
params["add_mute_audio"] = 0 # 转协议时无音频是否添加静音aac音频
# params["mp4_save_path"] = "" # mp4录制文件保存根目录置空使用默认
# params["mp4_max_second"] = 1 # mp4录制切片大小单位秒
# params["hls_save_path"] = "" # hls文件保存保存根目录置空使用默认
res = requests.post(url, headers={
"User-Agent": PROJECT_UA
}, json=params, timeout=self.timeout)
if res.status_code == 200:
res_json = res.json()
if 0 == res_json["code"]:
__key = res_json["data"]["key"]
__msg = "success"
else:
raise Exception(str(res_json))
except Exception as e:
__msg += " e:%s" % str(e)
self.__logger.warning(__msg)
return __key, __msg
def delStreamProxy(self, app, name, vhost="__defaultVhost__"):
__flag = False # "flag" : true 成功与否
__msg = "zlm.delStreamProxy(app=%s,name=%s)" % (app, name)
key = "{vhost}/{app}/{name}".format(vhost=vhost, app=app, name=name)
try:
url = "{host}/index/api/delStreamProxy?secret={secret}&key={key}".format(
host=self.__config.mediaHttpHost,
secret=self.__config.mediaSecret,
key=key
)
res = requests.get(url, headers={
"User-Agent": PROJECT_UA
}, timeout=self.timeout)
if res.status_code == 200:
res_json = res.json()
if 0 == res_json["code"]:
if res_json["data"]["flag"]:
__flag = True
__msg = "success"
else:
raise Exception(str(res_json))
else:
raise Exception(str(res_json))
else:
raise Exception("status=%d" % res.status_code)
except Exception as e:
__msg += " e:%s" % str(e)
self.__logger.warning(__msg)
return __flag, __msg
def close_streams(self, schema, app, name, vhost="__defaultVhost__"):
__ret = False
__msg = "zlm.close_streams(app=%s,name=%s)" % (app, name)
try:
url = "{host}/index/api/close_streams?secret={secret}&schema={schema}&vhost={vhost}&app={app}&stream={stream}&force=1".format(
host=self.__config.mediaHttpHost,
secret=self.__config.mediaSecret,
vhost=vhost,
schema=schema,
app=app,
stream=name,
)
res = requests.get(url, headers={
"User-Agent": PROJECT_UA
}, timeout=self.timeout)
if res.status_code == 200:
res_json = res.json()
if 0 == res_json["code"]:
count_hit = res_json.get("count_hit",0)
count_closed = res_json.get("count_closed",0)
if count_hit > 0:
__ret = True
__msg = "success"
else:
raise Exception(str(res_json))
else:
raise Exception(str(res_json))
else:
raise Exception("status=%d" % res.status_code)
except Exception as e:
__msg += " e:%s" % str(e)
self.__logger.warning(__msg)
return __ret, __msg
def openRtpServer(self, port, stream_id, tcp_mode=0):
"""
开启RTP服务器用于GB28181
Args:
port: RTP端口
stream_id: 流ID通常是channel_id
tcp_mode: TCP模式0=UDP, 1=TCP主动, 2=TCP被动
Returns:
tuple: (success, msg, actual_port)
success: 是否成功
msg: 消息
actual_port: 实际分配的端口可能与请求的不同
"""
__msg = f"zlm.openRtpServer(port={port}, stream_id={stream_id}, tcp_mode={tcp_mode})"
__actual_port = 0
try:
url = f"{self.__config.mediaHttpHost}/index/api/openRtpServer"
params = {
"port": port,
"tcp_mode": tcp_mode,
"stream_id": stream_id,
"secret": self.__config.mediaSecret
}
res = requests.post(url, json=params, timeout=self.timeout)
if res.status_code == 200:
res_json = res.json()
if 0 == res_json["code"]:
__actual_port = res_json.get("port", port)
__msg = f"success (actual_port={__actual_port})"
return True, __msg, __actual_port
else:
__msg = f"failed: {res_json.get('msg', 'unknown error')}"
return False, __msg, 0
else:
__msg = f"HTTP {res.status_code}"
return False, __msg, 0
except Exception as e:
__msg += f" e:{str(e)}"
self.__logger.warning(__msg)
return False, __msg, 0
def closeRtpServer(self, name):
__hit = 0
__msg = "zlm.closeRtpServer(name=%s)" % name
try:
url = "{host}/index/api/closeRtpServer?secret={secret}&stream_id={stream_id}".format(
host=self.__config.mediaHttpHost,
secret=self.__config.mediaSecret,
stream_id=name
)
res = requests.get(url, headers={
"User-Agent": PROJECT_UA
}, timeout=self.timeout)
if res.status_code == 200:
res_json = res.json()
if 0 == res_json["code"]:
__hit = res_json["hit"]
__msg = "success"
else:
raise Exception(str(res_json))
else:
raise Exception("status=%d" % res.status_code)
except Exception as e:
__msg += " e:%s" % str(e)
self.__logger.warning(__msg)
return __hit, __msg
def getMediaList(self,request_ip=None):
mediaList = []
try:
url = "{host}/index/api/getMediaList?secret={secret}".format(
host=self.__config.mediaHttpHost,
secret=self.__config.mediaSecret
)
res = requests.get(url, headers={
"User-Agent": PROJECT_UA
}, timeout=self.timeout)
if 200 == res.status_code:
res_json = res.json()
if 0 == res_json.get("code"):
data = res_json.get("data")
if data:
__data_group = {} # 视频流按照流名称进行分组
for d in data:
app = d.get("app") # 应用名
name = d.get("stream") # 流id
schema = d.get("schema") # 协议
app_name = "%s_%s" % (app, name)
v = __data_group.get(app_name)
if not v:
v = {}
v[schema] = d
__data_group[app_name] = v
for app_name, v in __data_group.items():
schema_clients = []
index = 0
d = None
for __schema, __d in v.items():
schema_clients.append({
"schema": __schema,
"readerCount": __d.get("readerCount")
})
if 0 == index:
d = __d
index += 1
if d:
video_str = ""
video_codec_name = None
video_width = 0
video_height = 0
audio_str = ""
tracks = d.get("tracks", None)
if tracks:
for track in tracks:
# codec_id = track.get("codec_id","")
codec_id_name = track.get("codec_id_name", "").lower()
codec_type = track.get("codec_type", -1) # Video = 0, Audio = 1
# ready = track.get("ready","")
if 0 == codec_type: # 视频类型
fps = track.get("fps")
video_height = int(track.get("height", 0))
video_width = int(track.get("width", 0))
video_codec_name = codec_id_name
video_str = "%s/%d/%dx%d" % (codec_id_name, fps, video_width, video_height)
elif 1 == codec_type: # 音频类型
channels = track.get("channels")
sample_bit = track.get("sample_bit")
sample_rate = track.get("sample_rate")
audio_str = "%s/%d/%d/%d" % (
codec_id_name, channels, sample_rate, sample_bit)
produce_speed = self.__byteFormat(d.get("bytesSpeed")) # 数据产生速度单位byte/s
app = d.get("app") # 应用名
name = d.get("stream") # 流id
mediaList.append({
"is_online": 1,
"code": app_name,
"an": app_name,
"app_name": app_name,
"app": app,
"name": name,
"produce_speed": produce_speed,
"video": video_str,
"video_codec_name": video_codec_name,
"video_width": video_width,
"video_height": video_height,
"audio": audio_str,
"originUrl": d.get("originUrl"), # 推流地址
"originType": d.get("originType"), # 推流地址采用的推流协议类型
"originTypeStr": d.get("originTypeStr"), # 推流地址采用的推流协议类型(字符串)
"clients": d.get("totalReaderCount"), # 客户端总数量
"schema_clients": schema_clients,
"videoUrl": self.get_wsMp4Url(app=app,name=name,request_ip=request_ip), # 默认播放地址(ws-fmp4)
"wsHost": self.get_wsHost(request_ip=request_ip),
"wsMp4Url": self.get_wsMp4Url(app=app,name=name,request_ip=request_ip)
})
else:
raise Exception(str(res_json))
else:
raise Exception("status=%d" % res.status_code)
except Exception as e:
self.__logger.warning("zlm.getMediaList(request_ip=%s) e:%s" % (request_ip,str(e)))
return mediaList
def getMediaInfo(self, app, name, schema="rtsp", vhost="__defaultVhost__",media_http_host=None,media_secret=None):
mediaInfo = {}
try:
if media_http_host is None:
media_http_host = self.__config.mediaHttpHost
if media_secret is None:
media_secret = self.__config.mediaSecret
url = "{host}/index/api/getMediaInfo?secret={secret}&schema={schema}&vhost={vhost}&app={app}&stream={name}".format(
host=media_http_host,
secret=media_secret,
schema=schema,
vhost=vhost,
app=app,
name=name
)
res = requests.get(url, headers={
"User-Agent": PROJECT_UA
}, timeout=self.timeout)
if 200 == res.status_code:
res_json = res.json()
if 0 == res_json["code"]:
"""res_json示例
{
'aliveSecond': 851,
'app': 'live',
'bytesSpeed': 116449,
'code': 0,
'createStamp': 1757481125,
'isRecordingHLS': False,
'isRecordingMP4': False,
'originSock': {
'identifier': 'class mediakit::RtspPlayerImp-23',
'local_ip': '192.168.1.106',
'local_port': 32527,
'peer_ip': '192.168.1.15',
'peer_port': 9554
},
'originType': 4,
'originTypeStr': 'pull',
'originUrl': 'rtsp://192.168.1.15:9554/live/cam77506144ae',
'params': '',
'readerCount': 2,
'schema': 'rtsp',
'stream': 'cam17306fb84c',
'totalBytes': 101651752,
'totalReaderCount': 3,
'tracks': [{
'codec_id': 0,
'codec_id_name': 'H264',
'codec_type': 0,
'duration': 852751,
'fps': 25.0,
'frames': 21302,
'gop_interval_ms': 970,
'gop_size': 25,
'height': 1080,
'key_frames': 853,
'loss': 0.0,
'ready': True,
'width': 1920
}],
'vhost': '__defaultVhost__'
}
"""
mediaInfo["aliveSecond"] = res_json.get("aliveSecond",0) # 存活时长
mediaInfo["totalReaderCount"] = res_json.get("totalReaderCount",0)
tracks = res_json.get("tracks", None)
if tracks:
if len(tracks) > 0:
for track in tracks:
codec_type = int(track.get("codec_type", -1)) # Video = 0, Audio = 1
if 0 == codec_type: # 视频类型
mediaInfo["codec_id"] = track.get("codec_id")
mediaInfo["video_codec_name"] = track.get("codec_id_name", "").lower()
mediaInfo["video_width"] = int(track.get("width", 0))
mediaInfo["video_height"] = int(track.get("height", 0))
mediaInfo["gop_size"] = int(track.get("gop_size", 0))
mediaInfo["success"] = True
break
if not mediaInfo.get("success"):
raise Exception(str(res_json))
else:
raise Exception(str(res_json))
else:
raise Exception("status=%d" % res.status_code)
except Exception as e:
self.__logger.warning("zlm.getMediaInfo(app=%s,name=%s,schema=%s) e:%s" % (app,name,schema,str(e)))
if mediaInfo.get("success"):
return mediaInfo
else:
return {}

0
app/utils/__init__.py Normal file
View File

View File

@ -0,0 +1,30 @@
# 作者:北小菜
"""轻量 SQLite 列升级(无 Django migrations 时使用)"""
import logging
logger = logging.getLogger("app.schema")
def _table_columns(cursor, table):
if table not in {"av_biz_algorithm"}:
raise ValueError("table is not allowed for schema upgrade")
cursor.execute('PRAGMA table_info("av_biz_algorithm")')
return {row[1] for row in cursor.fetchall()}
def ensure_biz_algorithm_line_count_columns():
from django.db import connection
table = "av_biz_algorithm"
adds = {
"forward_count_threshold": "ALTER TABLE av_biz_algorithm ADD COLUMN forward_count_threshold INTEGER NOT NULL DEFAULT 0",
"reverse_count_threshold": "ALTER TABLE av_biz_algorithm ADD COLUMN reverse_count_threshold INTEGER NOT NULL DEFAULT 0",
"detector_model_id": "ALTER TABLE av_biz_algorithm ADD COLUMN detector_model_id INTEGER NULL",
}
with connection.cursor() as cur:
existing = _table_columns(cur, table)
for col, sql in adds.items():
if col in existing:
continue
cur.execute(sql)
logger.info("schema upgrade: %s", sql)

118
app/views/AlarmView.py Normal file
View File

@ -0,0 +1,118 @@
from datetime import datetime, timedelta
from django.shortcuts import render
from django.db.models import Count
from django.db.models.functions import TruncDate
from app.views.ViewsBase import *
from app.models import AlarmModel, StreamModel
def index(request):
"""报警管理页面:集中展示与处理进入区域/滞留/运动等报警事件及快照"""
return render(request, 'app/alarm/index.html', {})
def dashboard(request):
"""报警统计看板页面"""
return render(request, 'app/alarm/dashboard.html', {})
# 报警事件类型中文名(与 services/alarm_service.ALARM_EVENT_TYPES 对齐)
_ALARM_TYPE_NAMES = {
'entered_zone': '区域入侵',
'loiter': '滞留',
'dwell': '滞留报警',
'line_cross': '越线检测',
'line_count': '越线计数',
'direction': '方向入侵',
'density': '密度报警',
'alarm': '报警',
}
def _stream_display_name(s):
return (getattr(s, 'nickname', '') or getattr(s, 'name', '') or ('#%s' % s.id))
def _build_alarm_stats(request):
"""聚合报警统计数据。days 仅支持 7 / 30。"""
try:
days = int(request.GET.get('days', 7))
except Exception:
days = 7
days = 30 if days >= 30 else 7
now = datetime.now()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_start = today_start - timedelta(days=6) # 近7天含今天
total = AlarmModel.objects.count()
today = AlarmModel.objects.filter(timestamp__gte=today_start).count()
week = AlarmModel.objects.filter(timestamp__gte=week_start).count()
stream_count = (AlarmModel.objects.exclude(stream_id__isnull=True)
.values('stream_id').distinct().count())
# 近 days 天每日趋势(无报警的日期补 0
trend_start = today_start - timedelta(days=days - 1)
tq = (AlarmModel.objects.filter(timestamp__gte=trend_start)
.annotate(d=TruncDate('timestamp'))
.values('d').annotate(c=Count('id')))
tmap = {}
for row in tq:
d = row['d']
key = d.strftime('%Y-%m-%d') if hasattr(d, 'strftime') else str(d)[:10]
tmap[key] = row['c']
trend = []
for i in range(days):
d = trend_start + timedelta(days=i)
key = d.strftime('%Y-%m-%d')
trend.append({"date": d.strftime('%m-%d'), "full": key, "count": tmap.get(key, 0)})
# 报警类型分布
ty = AlarmModel.objects.values('event_type').annotate(c=Count('id')).order_by('-c')
by_type = []
for r in ty:
et = r['event_type'] or 'unknown'
by_type.append({"type": et, "name": _ALARM_TYPE_NAMES.get(et, et), "count": r['c']})
# TOP 报警摄像头
top = (AlarmModel.objects.exclude(stream_id__isnull=True)
.values('stream_id').annotate(c=Count('id')).order_by('-c')[:8])
sids = [r['stream_id'] for r in top]
smap = {s.id: _stream_display_name(s) for s in StreamModel.objects.filter(id__in=sids)}
top_streams = [{"stream_id": r['stream_id'],
"name": smap.get(r['stream_id'], '#%s' % r['stream_id']),
"count": r['c']} for r in top]
return {
"today": today,
"week": week,
"total": total,
"stream_count": stream_count,
"days": days,
"trend": trend,
"by_type": by_type,
"top_streams": top_streams,
}
def api_openStats(request):
"""报警统计聚合接口"""
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = {}
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
try:
data = _build_alarm_stats(request)
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})

564
app/views/AlgorithmView.py Normal file
View File

@ -0,0 +1,564 @@
# 作者:北小菜
"""业务算法管理 — 小模型/大模型 + 后处理业务逻辑"""
import json
from app.views.ViewsBase import *
from django.shortcuts import render
from app.models import BizAlgorithmModel, AlgorithmModel, LLMModel, ZoneModel
def _parse_labels(raw):
if isinstance(raw, list):
return [str(x).strip() for x in raw if str(x).strip()]
if isinstance(raw, str):
try:
arr = json.loads(raw)
if isinstance(arr, list):
return [str(x).strip() for x in arr if str(x).strip()]
except Exception:
pass
return [s.strip() for s in raw.split(",") if s.strip()]
return []
def _resolve_model_abs_path(model_file):
"""返回模型文件的绝对路径(不存在则返回空串)"""
if not model_file:
return ""
try:
from app.analysis.worker_pool import resolve_model_path
p = resolve_model_path(model_file)
import os as _os
if p and _os.path.exists(p):
return p
except Exception:
pass
return ""
def _check_model_file_exists(model_file):
"""检查模型文件是否存在"""
return bool(_resolve_model_abs_path(model_file))
def _biz_to_dict(b, detail=False):
labels = _parse_labels(b.target_labels or '[]')
d = {
"id": b.id,
"name": b.name,
"flow_type": b.flow_type,
"small_model_id": b.small_model_id,
"small_model_name": b.small_model.name if b.small_model_id and b.small_model else "",
"detector_model_id": b.detector_model_id,
"detector_model_name": b.detector_model.name if b.detector_model_id and b.detector_model else "",
"target_labels": labels,
"llm_id": b.llm_id,
"llm_name": ((b.llm.name or b.llm.model_name) if b.llm_id and b.llm else ""),
"llm_prompt": b.llm_prompt or "",
"llm_validate": b.llm_validate or "",
"post_process": b.post_process or BizAlgorithmModel.POST_AREA,
"ref_angle": float(getattr(b, "ref_angle", 90.0) or 90.0),
"angle_tolerance": float(getattr(b, "angle_tolerance", 45.0) or 45.0),
"forward_count_threshold": int(getattr(b, "forward_count_threshold", 0) or 0),
"reverse_count_threshold": int(getattr(b, "reverse_count_threshold", 0) or 0),
"state": b.state,
"create_time": str(b.create_time),
"zone_count": b.zones.count(),
}
# 小模型文件状态
small = b.small_model if (b.small_model_id and b.small_model) else None
if small:
d["small_model_file"] = small.model_file or ""
d["small_model_engine"] = small.inference_engine or ""
d["small_model_file_exists"] = _check_model_file_exists(small.model_file or "")
d["small_model_file_path"] = _resolve_model_abs_path(small.model_file or "")
else:
d["small_model_file"] = ""
d["small_model_engine"] = ""
d["small_model_file_exists"] = False
d["small_model_file_path"] = ""
detector = b.detector_model if (b.detector_model_id and b.detector_model) else None
if detector:
d["detector_model_file"] = detector.model_file or ""
d["detector_model_engine"] = detector.inference_engine or ""
d["detector_model_file_exists"] = _check_model_file_exists(detector.model_file or "")
else:
d["detector_model_file"] = ""
d["detector_model_engine"] = ""
d["detector_model_file_exists"] = False
flow_names = {
BizAlgorithmModel.FLOW_SMALL: "小模型+后处理",
BizAlgorithmModel.FLOW_LLM: "大模型+后处理",
BizAlgorithmModel.FLOW_BOTH: "小模型+大模型+后处理",
BizAlgorithmModel.FLOW_DETECT_REID: "检测+ReID+后处理",
}
d["flow_type_name"] = flow_names.get(b.flow_type, str(b.flow_type))
post_names = {
BizAlgorithmModel.POST_AREA: "区域入侵",
BizAlgorithmModel.POST_LINE_CROSS: "越线检测",
BizAlgorithmModel.POST_LINE_COUNT: "越线计数",
BizAlgorithmModel.POST_DIRECTION: "方向入侵",
BizAlgorithmModel.POST_DENSITY: "密度报警",
BizAlgorithmModel.POST_DWELL: "滞留报警",
}
d["post_process_name"] = post_names.get(d["post_process"], d["post_process"])
return d
def _validate_biz_fields(params, biz_id=0):
name = (params.get("name") or "").strip()
if not name:
raise ValueError("算法名称不能为空")
try:
flow_type = int(params.get("flow_type", 1))
except Exception:
flow_type = 1
if flow_type not in (1, 2, 3, 4):
raise ValueError("无效的流程类型")
small_model_id = None
detector_model_id = None
llm_id = None
target_labels = []
llm_prompt = (params.get("llm_prompt") or "").strip()
llm_validate = (params.get("llm_validate") or "").strip()
post_process = (params.get("post_process") or BizAlgorithmModel.POST_AREA).strip()
if flow_type in (BizAlgorithmModel.FLOW_SMALL, BizAlgorithmModel.FLOW_BOTH):
try:
small_model_id = int(params.get("small_model_id", 0))
except Exception:
small_model_id = 0
if small_model_id <= 0:
raise ValueError("请选择小模型")
sm = AlgorithmModel.objects.filter(id=small_model_id, state=1).first()
if not sm:
raise ValueError("小模型不存在或已禁用")
if (getattr(sm, "task_type", "") or "detect").lower() == "reid":
raise ValueError("ReID 模型请使用「检测+ReID+后处理」流程,并同时选择 YOLO 检测小模型")
target_labels = _parse_labels(params.get("target_labels"))
if not target_labels:
raise ValueError("请至少选择一个检测目标")
if flow_type == BizAlgorithmModel.FLOW_DETECT_REID:
try:
detector_model_id = int(params.get("detector_model_id", 0))
except Exception:
detector_model_id = 0
try:
small_model_id = int(params.get("small_model_id", 0))
except Exception:
small_model_id = 0
if detector_model_id <= 0:
raise ValueError("请选择检测小模型 (YOLO)")
if small_model_id <= 0:
raise ValueError("请选择 ReID 小模型 (OSNet)")
if detector_model_id == small_model_id:
raise ValueError("检测小模型与 ReID 小模型不能相同")
det = AlgorithmModel.objects.filter(id=detector_model_id, state=1).first()
if not det:
raise ValueError("检测小模型不存在或已禁用")
if (getattr(det, "task_type", "") or "detect").lower() != "detect":
raise ValueError("检测小模型必须是 YOLO 检测模型 (task_type=detect)")
reid = AlgorithmModel.objects.filter(id=small_model_id, state=1).first()
if not reid:
raise ValueError("ReID 小模型不存在或已禁用")
if (getattr(reid, "task_type", "") or "").lower() != "reid":
raise ValueError("ReID 小模型必须是 OSNet ReID 模型 (task_type=reid)")
target_labels = _parse_labels(params.get("target_labels"))
if not target_labels:
raise ValueError("请至少选择一个检测目标")
det_labels = _parse_labels(det.labels or "[]")
invalid = [lb for lb in target_labels if lb not in det_labels]
if invalid:
raise ValueError("检测目标不在检测小模型标签列表中:%s" % "".join(invalid))
if flow_type in (BizAlgorithmModel.FLOW_LLM, BizAlgorithmModel.FLOW_BOTH):
try:
llm_id = int(params.get("llm_id", 0))
except Exception:
llm_id = 0
if llm_id <= 0:
raise ValueError("请选择大模型")
if not LLMModel.objects.filter(id=llm_id, state=1).exists():
raise ValueError("大模型不存在或已禁用")
if not llm_prompt:
raise ValueError("请输入大模型提示词")
if not llm_validate:
raise ValueError("请输入提示词校验值")
valid_posts = (
BizAlgorithmModel.POST_AREA,
BizAlgorithmModel.POST_LINE_CROSS,
BizAlgorithmModel.POST_LINE_COUNT,
BizAlgorithmModel.POST_DIRECTION,
BizAlgorithmModel.POST_DENSITY,
BizAlgorithmModel.POST_DWELL,
)
if post_process not in valid_posts:
raise ValueError("无效的后处理逻辑")
try:
forward_count_threshold = int(params.get("forward_count_threshold", 0) or 0)
except Exception:
forward_count_threshold = 0
try:
reverse_count_threshold = int(params.get("reverse_count_threshold", 0) or 0)
except Exception:
reverse_count_threshold = 0
forward_count_threshold = max(0, forward_count_threshold)
reverse_count_threshold = max(0, reverse_count_threshold)
if post_process == BizAlgorithmModel.POST_LINE_COUNT:
if forward_count_threshold <= 0 and reverse_count_threshold <= 0:
raise ValueError("越线计数至少设置一个方向的报警阈值(大于 0")
# DIRECTION 后处理参数
try:
ref_angle = float(params.get("ref_angle", 90.0))
except Exception:
ref_angle = 90.0
try:
angle_tolerance = float(params.get("angle_tolerance", 45.0))
except Exception:
angle_tolerance = 45.0
return {
"name": name,
"flow_type": flow_type,
"small_model_id": small_model_id,
"detector_model_id": detector_model_id,
"target_labels": json.dumps(target_labels, ensure_ascii=False),
"llm_id": llm_id,
"llm_prompt": llm_prompt,
"llm_validate": llm_validate,
"post_process": post_process,
"ref_angle": ref_angle,
"angle_tolerance": angle_tolerance,
"forward_count_threshold": forward_count_threshold,
"reverse_count_threshold": reverse_count_threshold,
"state": int(params.get("state", 1)),
}
def algorithm_index(request):
return render(request, 'app/algorithm/index.html', {})
def algorithm_openIndex(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = []
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
qs = BizAlgorithmModel.objects.select_related('small_model', 'detector_model', 'llm').order_by('-id')
state = request.GET.get('state', '').strip()
if state != '':
qs = qs.filter(state=int(state))
flow = request.GET.get('flow_type', '').strip()
if flow != '':
qs = qs.filter(flow_type=int(flow))
data = [_biz_to_dict(b) for b in qs]
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def algorithm_openCheckModels(request):
"""检查所有小模型的模型文件是否存在,返回就绪列表与缺失列表。
用于前端进入页面时全局告警提示便于排查具体哪个模型成功/失败
"""
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = {"missing": [], "ok_list": [], "total": 0, "ok_count": 0}
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
try:
qs = AlgorithmModel.objects.filter(state=1).order_by('id')
total = 0
ok = 0
missing = []
ok_list = []
for a in qs:
total += 1
mf = a.model_file or ""
exists = _check_model_file_exists(mf)
item = {
"id": a.id,
"name": a.name,
"model_file": mf,
"engine": a.inference_engine or "",
}
if exists:
ok += 1
item["hint"] = "模型文件就绪"
ok_list.append(item)
else:
item["hint"] = "模型文件未配置" if not mf else "模型文件不存在"
missing.append(item)
data = {"missing": missing, "ok_list": ok_list, "total": total, "ok_count": ok}
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def algorithm_openOptions(request):
"""表单下拉:小模型列表、大模型列表、后处理选项"""
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = {}
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
small_models = []
for a in AlgorithmModel.objects.filter(state=1).order_by('-is_default', 'name'):
labels = _parse_labels(a.labels or '[]')
small_models.append({
"id": a.id,
"name": a.name,
"labels": labels,
"algorithm_type": a.algorithm_type,
"task_type": a.task_type,
"model_file": a.model_file or "",
"model_file_exists": _check_model_file_exists(a.model_file or ""),
"engine": a.inference_engine or "",
})
llms = [{"id": x.id, "name": x.name, "model_name": x.model_name}
for x in LLMModel.objects.filter(state=1).order_by('sort', 'id')]
data = {
"small_models": small_models,
"llms": llms,
"post_processes": [
{"value": BizAlgorithmModel.POST_AREA, "label": "区域入侵"},
{"value": BizAlgorithmModel.POST_LINE_CROSS, "label": "越线检测"},
{"value": BizAlgorithmModel.POST_LINE_COUNT, "label": "越线计数"},
{"value": BizAlgorithmModel.POST_DIRECTION, "label": "方向入侵"},
{"value": BizAlgorithmModel.POST_DENSITY, "label": "密度报警"},
{"value": BizAlgorithmModel.POST_DWELL, "label": "滞留报警"},
],
"flow_types": [
{"value": 1, "label": "小模型 + 后处理"},
{"value": 2, "label": "大模型 + 后处理"},
{"value": 3, "label": "小模型 + 大模型 + 后处理"},
{"value": 4, "label": "检测小模型 + ReID小模型 + 后处理"},
],
}
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def algorithm_openAdd(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
fields = _validate_biz_fields(params)
BizAlgorithmModel.objects.create(**fields)
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def algorithm_openEdit(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
bid = int(params.get("id", 0))
b = BizAlgorithmModel.objects.get(id=bid)
fields = _validate_biz_fields(params, biz_id=bid)
for k, v in fields.items():
setattr(b, k, v)
b.save()
_reload_affected_pipelines(b)
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def algorithm_openDel(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = {"referenced_zones": []}
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
bid = int(params.get("id", 0))
b = BizAlgorithmModel.objects.get(id=bid)
ref_zones = list(
b.zones.select_related('stream').order_by('id')
)
if ref_zones:
# 列出引用该算法的布控名称,便于用户排查并先解除绑定
names = []
for z in ref_zones:
sname = z.stream.nickname if z.stream else ("#%s" % z.stream_id)
names.append("%s/%s" % (sname, z.name))
data["referenced_zones"] = names
raise ValueError("该算法已被 %d 个布控引用,请先解除绑定后再删除(%s"
% (len(ref_zones), "".join(names)))
b.delete()
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def _reload_affected_pipelines(biz):
try:
from app.analysis.manager import AnalysisManager
mgr = AnalysisManager()
stream_ids = set()
for z in biz.zones.select_related('stream').all():
if z.stream_id:
stream_ids.add(z.stream_id)
for sid in stream_ids:
if mgr.is_running(sid):
mgr.reload_zones(sid)
except Exception:
pass
def algorithm_openAssignContext(request):
"""分配布控:列出全部区域及是否已绑定该算法"""
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = {}
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parseGetParams(request)
try:
bid = int(params.get("biz_algorithm_id", 0) or params.get("id", 0))
biz = BizAlgorithmModel.objects.get(id=bid)
zones = []
for z in ZoneModel.objects.select_related('stream').order_by('stream_id', 'id'):
selected = z.algorithms.filter(id=bid).exists()
zones.append({
"id": z.id,
"stream_id": z.stream_id,
"stream_name": z.stream.nickname if z.stream else "",
"zone_name": z.name,
"state": z.state,
"selected": selected,
})
data = {
"biz_algorithm": _biz_to_dict(biz),
"zones": zones,
}
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def algorithm_openAssignZones(request):
"""将业务算法绑定到布控区域(增删仅影响本算法)"""
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
bid = int(params.get("biz_algorithm_id", 0) or params.get("id", 0))
biz = BizAlgorithmModel.objects.get(id=bid)
zone_ids = params.get("zone_ids") or []
if isinstance(zone_ids, str):
try:
zone_ids = json.loads(zone_ids)
except Exception:
zone_ids = [s for s in zone_ids.split(",") if s.strip()]
zone_ids = {int(x) for x in zone_ids if str(x).strip()}
affected_streams = set()
blocked_zones = []
to_remove = [] # 先收集待解绑区域,校验通过后统一执行
# 取消未选中的绑定
for z in ZoneModel.objects.filter(algorithms=biz).prefetch_related('algorithms'):
if z.id not in zone_ids:
if z.algorithms.count() <= 1:
blocked_zones.append(z.name or ("#%s" % z.id))
continue
to_remove.append(z)
affected_streams.add(z.stream_id)
if blocked_zones:
raise ValueError(
LANG_VIEWS_T(request, "zone_algo_required")
+ " (" + "".join(blocked_zones) + ")"
)
# 校验通过,统一执行解绑
for z in to_remove:
z.algorithms.remove(biz)
# 添加新绑定
if zone_ids:
for z in ZoneModel.objects.filter(id__in=zone_ids):
if not z.algorithms.filter(id=bid).exists():
z.algorithms.add(biz)
affected_streams.add(z.stream_id)
try:
from app.analysis.manager import AnalysisManager
mgr = AnalysisManager()
for sid in affected_streams:
if sid and mgr.is_running(sid):
mgr.reload_zones(sid)
except Exception:
pass
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})

458
app/views/AnalysisView.py Normal file
View File

@ -0,0 +1,458 @@
"""Monitor · 分析模块 Web 层
页面
- /control/index 布控管理 ControlView
API
- /alarm/openIndex/openDel/openBatchDel/openClearAlarms
- /analysis/openStatus/openStart/openStop/openReloadZones
"""
from app.views.ViewsBase import *
from app.utils.Utils import buildPageLabels
from django.shortcuts import render
import json
from datetime import datetime
from app.models import StreamModel, ZoneModel, AlarmModel
def _parse_page_params(request, default_ps=12):
page = request.GET.get('p', 1)
page_size = request.GET.get('ps', default_ps)
try:
page = int(page)
if page < 1:
page = 1
except Exception:
page = 1
try:
page_size = int(page_size)
if page_size < 1:
page_size = default_ps
elif page_size > 100:
page_size = 100
except Exception:
page_size = default_ps
return page, page_size
def _build_page_data(request, page, page_size, count):
page_num = int(count / page_size)
if count % page_size > 0:
page_num += 1
if page_num < 1:
page_num = 1
if page > page_num:
page = page_num
page_labels = buildPageLabels(page=page, page_num=page_num, lang=f_parseRequestLang(request))
return {
"page": page,
"page_size": page_size,
"page_num": page_num,
"count": count,
"pageLabels": page_labels,
}
def _alarm_abs_path(rel_path):
"""将 metadata 中的相对路径(相对 static/)转为绝对路径"""
if not rel_path:
return ""
try:
from django.conf import settings
base = str(getattr(settings, "BASE_DIR", ""))
if base:
return os.path.join(base, "static", rel_path)
except Exception:
pass
return ""
def _delete_snapshot_file(rel_path):
"""删除单个快照文件(相对 static/ 的路径)"""
p = _alarm_abs_path(rel_path)
if p and os.path.isfile(p):
try:
os.remove(p)
return True
except Exception:
return False
return False
# ===================== Alarm =====================
def _alarm_to_dict(a):
"""将 AlarmModel 转为前端字典"""
try:
meta = json.loads(a.metadata) if a.metadata else {}
except Exception:
meta = {}
if not isinstance(meta, dict):
meta = {}
reason = meta.get("alarm_reason") or a.description or ""
return {
"id": a.id,
"stream_id": a.stream_id,
"stream_name": (a.stream.nickname if a.stream else ""),
"event_type": a.event_type,
"timestamp": str(a.timestamp),
"metadata": a.metadata,
"biz_algorithm_id": meta.get("biz_algorithm_id"),
"biz_algorithm_name": meta.get("biz_algorithm_name") or "",
"alarm_reason": reason,
"zone_name": meta.get("zone_name") or "",
"snapshot_path": meta.get("snapshot_path") or "",
"description": reason or a.description or "",
}
def alarm_openIndex(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = []
page_data = {}
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
page, page_size = _parse_page_params(request, default_ps=20)
qs = AlarmModel.objects.all().order_by('-id')
stream_id = request.GET.get('stream_id')
if stream_id:
qs = qs.filter(stream_id=int(stream_id))
event_type = request.GET.get('event_type')
if event_type:
qs = qs.filter(event_type=event_type)
count = qs.count()
skip = (page - 1) * page_size
data = [_alarm_to_dict(a) for a in qs[skip:skip + page_size]]
page_data = _build_page_data(request, page, page_size, count)
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data, "pageData": page_data})
def alarm_openDel(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
aid = int(params.get("id", 0))
if aid <= 0:
raise ValueError(LANG_VIEWS_T(request, "msg_invalid_parameter"))
obj = AlarmModel.objects.filter(id=aid).first()
if not obj:
raise ValueError(LANG_VIEWS_T(request, "msg_not_found"))
try:
meta = json.loads(obj.metadata) if obj.metadata else {}
snap = meta.get("snapshot_path", "")
if snap:
_delete_snapshot_file(snap)
except Exception:
pass
obj.delete()
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def alarm_openBatchDel(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
deleted = 0
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
ids_raw = params.get("ids", "[]")
ids = json.loads(ids_raw) if isinstance(ids_raw, str) else ids_raw
if not isinstance(ids, list) or not ids:
raise ValueError(LANG_VIEWS_T(request, "msg_invalid_parameter"))
qs = AlarmModel.objects.filter(id__in=ids)
for obj in qs:
try:
meta = json.loads(obj.metadata) if obj.metadata else {}
snap = meta.get("snapshot_path", "")
if snap:
_delete_snapshot_file(snap)
except Exception:
pass
deleted, _ = qs.delete()
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": {"deleted": deleted}})
def alarm_openClearAlarms(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
deleted = 0
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
try:
qs = AlarmModel.objects.all()
for obj in qs:
try:
meta = json.loads(obj.metadata) if obj.metadata else {}
snap = meta.get("snapshot_path", "")
if snap:
_delete_snapshot_file(snap)
except Exception:
pass
deleted, _ = qs.delete()
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": {"deleted": deleted}})
# ===================== 分析控制 =====================
def analysis_openStatus(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = {}
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
try:
lite = str(request.GET.get("lite", "")).lower() in ("1", "true", "yes")
from app.views.ControlView import build_analysis_status_data
data = build_analysis_status_data(lite=lite)
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def analysis_openStart(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
sid = int(params.get("stream_id", 0))
if sid <= 0:
raise ValueError(LANG_VIEWS_T(request, "msg_invalid_parameter"))
enabled = ZoneModel.objects.filter(stream_id=sid, state=1).exists()
if not enabled:
raise ValueError(LANG_VIEWS_T(request, "zone_analysis_need_enabled"))
stream = StreamModel.objects.get(id=sid)
from app.analysis.manager import AnalysisManager
ok, info = AnalysisManager().start(stream)
ret = ok
msg = info
if ok:
from app.views.ControlView import invalidate_analysis_status_cache
invalidate_analysis_status_cache()
msg = LANG_VIEWS_T(request, "zone_analysis_started")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def analysis_openStop(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
sid = int(params.get("stream_id", 0))
from app.analysis.manager import AnalysisManager
ok, info = AnalysisManager().stop(sid)
ret = ok
msg = info if not ok else LANG_VIEWS_T(request, "zone_analysis_stopped")
if ok:
from app.views.ControlView import invalidate_analysis_status_cache
invalidate_analysis_status_cache()
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def analysis_openReloadZones(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
sid = int(params.get("stream_id", 0))
from app.analysis.manager import AnalysisManager
ok = AnalysisManager().reload_zones(sid)
ret = ok
msg = LANG_VIEWS_T(request, "msg_success") if ok else "pipeline not running"
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def analysis_openUpdateInferenceConfig(request):
"""热更新推理引擎配置(不持久化到 config.json
POST 参数
- shared: 0/1 切换共享推理开关
- workers: int 调整共享推理 worker
两者至少传一个切换 shared 会重启所有运行中的 pipeline
"""
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
shared = params.get("shared", None)
workers = params.get("workers", None)
if shared is None and workers is None:
raise ValueError("至少需要传 shared 或 workers 参数")
from app.analysis.manager import AnalysisManager
ok, info = AnalysisManager().set_inference_config(
shared=shared, workers=workers)
ret = ok
msg = info
if ok:
from app.views.ControlView import invalidate_analysis_status_cache
invalidate_analysis_status_cache()
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def analysis_openToggleAlgoInstance(request):
"""切换业务算法的实例化开关(内存,重启丢失,立即生效)。
POST 参数
- algorithm_id: int 业务算法绑定的小模型 ID
- enabled: 0/1
"""
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
algo_id = int(params.get("algorithm_id", 0))
enabled = int(params.get("enabled", 1)) == 1
if algo_id <= 0:
raise ValueError(LANG_VIEWS_T(request, "msg_invalid_parameter"))
from app.analysis.manager import AnalysisManager
ok, info = AnalysisManager().set_algo_instance_enabled(algo_id, enabled)
ret = ok
msg = info
if ok:
from app.views.ControlView import invalidate_analysis_status_cache
invalidate_analysis_status_cache()
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def analysis_openRestartAlgoInstance(request):
"""重启使用指定算法的所有 pipeline重新加载引擎
POST 参数
- algorithm_id: int 小模型 ID
"""
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
algo_id = int(params.get("algorithm_id", 0))
if algo_id <= 0:
raise ValueError(LANG_VIEWS_T(request, "msg_invalid_parameter"))
from app.analysis.manager import AnalysisManager
ok, info = AnalysisManager().restart_algo_instance(algo_id)
ret = ok
msg = info
if ok:
from app.views.ControlView import invalidate_analysis_status_cache
invalidate_analysis_status_cache()
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def analysis_openRestartInferencePool(request):
"""重启整个推理池(清除所有引擎缓存)。"""
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
try:
from app.analysis.manager import AnalysisManager
ok, info = AnalysisManager().restart_inference_pool()
ret = ok
msg = info
if ok:
from app.views.ControlView import invalidate_analysis_status_cache
invalidate_analysis_status_cache()
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})

653
app/views/ControlView.py Normal file
View File

@ -0,0 +1,653 @@
"""Monitor · 布控管理 Web 层
页面
- /control/index 布控管理按摄像头绘制多边形区域
API
- /control/openIndex/openAdd/openEdit/openDel/openPageData/openRecentAlarms
"""
from app.views.ViewsBase import *
from django.shortcuts import render
from app.models import StreamModel, ZoneModel, AlarmModel, BizAlgorithmModel
import json as _json
import time as _time
LINE_POSTS = (BizAlgorithmModel.POST_LINE_CROSS, BizAlgorithmModel.POST_LINE_COUNT)
REGION_POSTS = (
BizAlgorithmModel.POST_AREA,
BizAlgorithmModel.POST_DWELL,
BizAlgorithmModel.POST_DENSITY,
BizAlgorithmModel.POST_DIRECTION,
)
def _parse_zone_algo_ids(raw):
if isinstance(raw, list):
return [int(x) for x in raw if str(x).strip()]
if isinstance(raw, str):
try:
arr = _json.loads(raw)
if isinstance(arr, list):
return [int(x) for x in arr if str(x).strip()]
except Exception:
pass
return [int(s) for s in raw.split(",") if s.strip()]
return []
def _validate_zone_geometry(params, algo_ids=None):
"""校验布控绘制与所选业务算法是否匹配。"""
is_required = int(params.get("is_required", 1) or 0)
coords_raw = params.get("coordinates", "[]")
try:
coords = _json.loads(coords_raw) if isinstance(coords_raw, str) else (coords_raw or [])
except Exception:
coords = []
if not isinstance(coords, list):
coords = []
line_a = (params.get("line_a") or "").strip()
line_b = (params.get("line_b") or "").strip()
ids = algo_ids if algo_ids is not None else _parse_zone_algo_ids(params.get("algorithm_ids"))
posts = set()
if ids:
posts = set(
BizAlgorithmModel.objects.filter(id__in=ids, state=1).values_list("post_process", flat=True)
)
needs_line = bool(posts & set(LINE_POSTS))
needs_region = bool(is_required and (not posts or posts & set(REGION_POSTS) or needs_line))
if needs_line and (not line_a or not line_b):
raise ValueError("所选越线类算法需绘制方向线 A→B")
if needs_region and len(coords) < 3:
raise ValueError("必需区域已开启,请绘制布控区域(至少 3 个点)")
if not is_required and posts & set(REGION_POSTS):
raise ValueError("区域类后处理(区域入侵/滞留/密度/方向)需开启「必需区域」并绘制区域")
return True
CONTROL_ALARM_EVENT_TYPES = ('entered_zone', 'loiter')
# ---------- 状态接口内存缓存 ----------
# 布控页/控制面板高频轮询 openStatus原始实现每次都遍历所有运行实例 +
# 查 DB布控执行时叠加 SQLite 锁会导致页面卡顿。
# 这里加一个带 TTL 的进程内缓存,多个请求共享同一份快照。
_STATUS_CACHE = {"lite": {"t": 0, "data": None}, "full": {"t": 0, "data": None}}
_STATUS_CACHE_TTL = 2.0 # 秒缓存有效期2s 内重复请求直接返回缓存
def _get_cached_status(lite=False):
key = "lite" if lite else "full"
entry = _STATUS_CACHE.get(key)
now = _time.time()
if entry and entry["data"] is not None and (now - entry["t"]) < _STATUS_CACHE_TTL:
return entry["data"]
return None
def _set_cached_status(data, lite=False):
key = "lite" if lite else "full"
_STATUS_CACHE[key] = {"t": _time.time(), "data": data}
def _parse_control_detect_rate(params):
try:
interval = float(params.get("detect_interval_sec", 1) or 1)
except (TypeError, ValueError):
interval = 1.0
try:
frames = int(params.get("detect_frames", 1) or 1)
except (TypeError, ValueError):
frames = 1
interval = max(0.1, min(86400.0, interval))
frames = max(1, min(999, frames))
return interval, frames
def _control_to_dict(z):
algos = []
try:
algos = [{"id": a.id, "name": a.name} for a in z.algorithms.all().order_by('id')]
except Exception:
pass
stream = getattr(z, "stream", None)
interval = max(0.1, float(getattr(z, "detect_interval_sec", 1) or 1))
frames = max(1, int(getattr(z, "detect_frames", 1) or 1))
return {
"id": z.id,
"stream_id": z.stream_id,
"stream_name": (stream.nickname if stream else ""),
"name": z.name,
"coordinates": z.coordinates,
"is_required": z.is_required,
"loiter_threshold": z.loiter_threshold,
"detect_interval_sec": interval,
"detect_frames": frames,
"color": z.color,
"line_a": getattr(z, "line_a", "") or "",
"line_b": getattr(z, "line_b", "") or "",
"density_threshold": int(getattr(z, "density_threshold", 0) or 0),
"algorithms": algos,
"algorithm_ids": [a["id"] for a in algos],
"state": int(getattr(z, "state", 1) or 0),
"create_time": str(z.create_time),
}
def _control_queryset(stream_id=None):
qs = ZoneModel.objects.select_related("stream").prefetch_related("algorithms").order_by("-id")
if stream_id:
qs = qs.filter(stream_id=int(stream_id))
return qs
def build_analysis_status_data(lite=False):
"""汇总分析运行状态lite 模式跳过引擎探测等重操作,供布控页轮询。"""
# 命中缓存则直接返回,避免每次都遍历运行实例 + 查 DB
cached = _get_cached_status(lite=lite)
if cached is not None:
return cached
from app.analysis.manager import AnalysisManager
m = AnalysisManager()
shared_inference = m._use_shared_inference() and m._use_multiprocess()
multi_process = m._use_multiprocess()
running = []
for sid in m.list_running():
info = m.get_pipeline_info(sid)
if info:
running.append(info)
# 收集运行中 pipeline 的 stream_name 映射,供引擎实例详情使用
stream_name_map = {}
for r in running:
sid = r.get("stream_id")
if sid:
stream_name_map[sid] = r.get("stream_name") or r.get("algorithm_name") or ("#%s" % sid)
algo_usage = {}
for r in running:
sid = r.get("stream_id")
sname = stream_name_map.get(sid, "")
for d in (r.get("detectors") or []):
aid = d.get("algorithm_id")
if aid is None:
continue
u = algo_usage.setdefault(
aid,
{
"algorithm_id": aid,
"algorithm_name": d.get("algorithm_name", ""),
"engine": d.get("engine", ""),
"stream_count": 0,
"stream_names": [],
},
)
u["stream_count"] += 1
if sname and sname not in u["stream_names"]:
u["stream_names"].append(sname)
# 补充小模型详情 + 反查业务算法
if algo_usage:
try:
from app.models import AlgorithmModel, BizAlgorithmModel
sm_qs = AlgorithmModel.objects.filter(id__in=list(algo_usage.keys()))
sm_map = {sm.id: sm for sm in sm_qs}
# 反查业务算法(通过 small_model 外键)
biz_qs = BizAlgorithmModel.objects.filter(
small_model_id__in=list(algo_usage.keys()), state=1
).select_related("small_model")
biz_by_sm = {}
for ba in biz_qs:
biz_by_sm.setdefault(ba.small_model_id, []).append({
"id": ba.id, "name": ba.name, "flow_type": ba.flow_type,
})
for aid, u in algo_usage.items():
sm = sm_map.get(aid)
if sm:
u["model_file"] = sm.model_file
u["task_type"] = sm.task_type
u["device"] = sm.device
u["input_size"] = [sm.input_width, sm.input_height]
u["conf_threshold"] = sm.conf_threshold
u["iou_threshold"] = sm.iou_threshold
u["algorithm_type"] = sm.algorithm_type
u["small_model_state"] = sm.state
u["biz_algorithms"] = biz_by_sm.get(aid, [])
u["instance_enabled"] = m.is_algo_instance_enabled(aid)
except Exception as e:
import logging
logging.getLogger("app").warning("build_analysis_status_data 补充详情失败: %s" % str(e))
local_instances = m._worker_pool.instance_info()
inference_workers_alive = 0
inference_workers_config = 0
inference_degraded = False
inference_timeout_count = 0
if shared_inference:
try:
from app.analysis.inference_pool import get_inference_pool
pool = get_inference_pool()
inference_workers_alive = pool.instance_count()
inference_workers_config = pool.num_workers
pool_st = pool.status()
inference_degraded = bool(pool_st.get("inference_degraded"))
inference_timeout_count = int(pool_st.get("timeout_count") or 0)
except Exception:
inference_workers_alive = 0
inference_degraded = False
inference_timeout_count = 0
else:
try:
from app.utils.GlobalUtils import g_config
inference_workers_config = int(getattr(g_config, "analysisInferenceWorkers", 2))
except Exception:
inference_workers_config = 2
if shared_inference:
engine_total = len(algo_usage) if algo_usage else inference_workers_alive
else:
engine_total = len(local_instances)
# 所有业务算法的实例化开关状态
algo_instance_states = []
try:
from app.models import BizAlgorithmModel
disabled = m.get_disabled_algos()
for ba in BizAlgorithmModel.objects.filter(state=1).order_by("id"):
sm = ba.small_model
algo_instance_states.append({
"id": ba.id,
"name": ba.name,
"flow_type": ba.flow_type,
"small_model_id": sm.id if sm else None,
"small_model_name": sm.name if sm else "",
"engine": sm.inference_engine if sm else "",
"instance_enabled": (sm.id not in disabled) if sm else True,
})
except Exception:
pass
data = {
"running_streams": running,
"running_count": len(running),
"total_streams": StreamModel.objects.count(),
"engine_instances": list(algo_usage.values()),
"engine_instance_total": engine_total,
"engine_instances_local": len(local_instances),
"inference_shared": shared_inference,
"inference_workers_alive": inference_workers_alive,
"inference_degraded": inference_degraded if shared_inference else False,
"inference_timeout_count": inference_timeout_count if shared_inference else 0,
"analysis_fps_avg": (
round(sum(r.get("analysis_fps", 0) for r in running) / len(running), 1) if running else 0.0
),
"inference_config": {
"shared": bool(shared_inference),
"multi_process": bool(multi_process),
"workers": inference_workers_config,
"workers_alive": inference_workers_alive,
},
"algo_instance_states": algo_instance_states,
}
if not lite:
from app.analysis.engines.factory import EngineFactory
from app.analysis.motion import MotionDetector
data["engines"] = EngineFactory.list_engines()
data["motion_available"] = MotionDetector.is_available()
_set_cached_status(data, lite=lite)
return data
def invalidate_analysis_status_cache():
"""供启停分析、热更新后主动失效缓存,确保下次请求拿到最新状态。"""
_STATUS_CACHE["lite"]["data"] = None
_STATUS_CACHE["full"]["data"] = None
def control_index(request):
return render(request, 'app/control/index.html', {})
def control_openIndex(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = []
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
stream_id = request.GET.get('stream_id')
qs = _control_queryset(stream_id) if stream_id else _control_queryset()
data = [_control_to_dict(z) for z in qs]
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def control_openPageData(request):
"""布控页一次性加载:区域列表 + 摄像头下拉 + 分析概览"""
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = {}
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
try:
stream_id = request.GET.get('stream_id')
zones = [_control_to_dict(z) for z in _control_queryset(stream_id)]
streams = list(
StreamModel.objects.order_by("-id").values("id", "app", "name", "code", "nickname")
)
analysis = build_analysis_status_data(lite=True)
alarm_qs = AlarmModel.objects.filter(event_type__in=CONTROL_ALARM_EVENT_TYPES)
if stream_id:
alarm_qs = alarm_qs.filter(stream_id=int(stream_id))
data = {
"zones": zones,
"streams": streams,
"analysis": analysis,
"recent_alarm_count": alarm_qs.count(),
}
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def control_openAdd(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
stream_id = int(params.get("stream_id", 0))
if stream_id <= 0:
app = (params.get("stream_app") or params.get("app") or "").strip()
name = (params.get("stream_name") or params.get("name") or "").strip()
if app and name:
stream = StreamModel.objects.get(app=app, name=name)
stream_id = stream.id
else:
raise ValueError(LANG_VIEWS_T(request, "zone_form_incomplete"))
else:
stream = StreamModel.objects.get(id=stream_id)
algo_ids_req = params.get("algorithm_ids") or []
if isinstance(algo_ids_req, str):
try:
import json as _json2
algo_ids_req = _json2.loads(algo_ids_req)
except Exception:
algo_ids_req = [s for s in algo_ids_req.split(",") if s]
if not algo_ids_req:
raise ValueError(LANG_VIEWS_T(request, "zone_algo_required"))
algo_ids_int = [int(x) for x in algo_ids_req]
_validate_zone_geometry(params, algo_ids_int)
detect_interval_sec, detect_frames = _parse_control_detect_rate(params)
zone = ZoneModel(
stream=stream,
name=params.get("name", "").strip(),
coordinates=params.get("coordinates", "[]"),
is_required=int(params.get("is_required", 1)),
loiter_threshold=int(params.get("loiter_threshold", 0)),
detect_interval_sec=detect_interval_sec,
detect_frames=detect_frames,
color=params.get("color", "#169F85"),
line_a=params.get("line_a", ""),
line_b=params.get("line_b", ""),
density_threshold=int(params.get("density_threshold", 0) or 0),
state=0,
)
zone.save()
algo_ids = params.get("algorithm_ids") or []
if isinstance(algo_ids, str):
try:
import json as _json
algo_ids = _json.loads(algo_ids)
except Exception:
algo_ids = [s for s in algo_ids.split(",") if s]
if algo_ids:
from app.models import BizAlgorithmModel
qs = BizAlgorithmModel.objects.filter(id__in=[int(x) for x in algo_ids], state=1)
zone.algorithms.set(qs)
try:
from app.analysis.manager import AnalysisManager
m = AnalysisManager()
if stream_id in m.list_running():
m.reload_zones(stream_id)
except Exception:
pass
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def control_openEdit(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
zid = int(params.get("id", 0))
z = ZoneModel.objects.get(id=zid)
if "name" in params:
z.name = params["name"].strip()
if "coordinates" in params:
z.coordinates = params["coordinates"]
if "is_required" in params:
z.is_required = int(params["is_required"])
if "loiter_threshold" in params:
z.loiter_threshold = int(params["loiter_threshold"])
if "detect_interval_sec" in params or "detect_frames" in params:
interval, frames = _parse_control_detect_rate(params)
z.detect_interval_sec = interval
z.detect_frames = frames
if "color" in params:
z.color = params["color"]
if "line_a" in params:
z.line_a = params.get("line_a", "")
if "line_b" in params:
z.line_b = params.get("line_b", "")
if "density_threshold" in params:
z.density_threshold = int(params.get("density_threshold", 0) or 0)
z.save()
if "algorithm_ids" in params:
algo_ids = params.get("algorithm_ids") or []
if isinstance(algo_ids, str):
try:
import json as _json
algo_ids = _json.loads(algo_ids)
except Exception:
algo_ids = [s for s in algo_ids.split(",") if s]
if not algo_ids:
raise ValueError(LANG_VIEWS_T(request, "zone_algo_required"))
algo_ids_int = [int(x) for x in algo_ids]
_validate_zone_geometry(params, algo_ids_int)
from app.models import BizAlgorithmModel
qs = BizAlgorithmModel.objects.filter(id__in=algo_ids_int, state=1)
z.algorithms.set(qs)
elif any(k in params for k in ("coordinates", "line_a", "line_b", "is_required")):
algo_ids_int = list(z.algorithms.filter(state=1).values_list("id", flat=True))
_validate_zone_geometry(params, algo_ids_int)
try:
from app.analysis.manager import AnalysisManager
m = AnalysisManager()
if z.stream_id in m.list_running():
m.reload_zones(z.stream_id)
except Exception:
pass
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def control_openDel(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
zid = int(params.get("id", 0))
z = ZoneModel.objects.get(id=zid)
sid = z.stream_id
z.delete()
try:
from app.analysis.manager import AnalysisManager
from app.models import ZoneModel as _ZM
m = AnalysisManager()
if m.is_running(sid):
if _ZM.objects.filter(stream_id=sid, state=1).exists():
m.reload_zones(sid)
else:
m.stop(sid)
except Exception:
pass
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def control_openToggleZone(request):
"""单条布控手动启停:仅影响该布控,同摄像头其它布控互不干扰。"""
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = {}
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
zid = int(params.get("id", 0))
if zid <= 0:
raise ValueError(LANG_VIEWS_T(request, "msg_invalid_parameter"))
z = ZoneModel.objects.get(id=zid)
if "enabled" in params or "state" in params:
enabled = int(params.get("enabled", params.get("state", 0)))
else:
enabled = 0 if int(z.state or 0) == 1 else 1
from app.analysis.manager import AnalysisManager
from app.models import StreamModel
m = AnalysisManager()
sid = int(z.stream_id)
stream = StreamModel.objects.get(id=sid)
def _restart_stream_analysis():
"""重启分析流水线,确保布控列表与运行时状态一致(避免热更新失败导致无报警)。"""
if m.is_running(sid):
m.stop(sid)
ok, info = m.start(stream)
if not ok:
raise ValueError(info or LANG_VIEWS_T(request, "msg_unknown_error"))
return ok, info
if enabled:
z.state = 1
z.save()
if not m.is_running(sid):
ok, info = m.start(stream)
if not ok:
z.state = 0
z.save()
raise ValueError(info or LANG_VIEWS_T(request, "msg_unknown_error"))
else:
_restart_stream_analysis()
else:
z.state = 0
z.save()
if m.is_running(sid):
if ZoneModel.objects.filter(stream_id=sid, state=1).exists():
_restart_stream_analysis()
else:
m.stop(sid)
invalidate_analysis_status_cache()
ret = True
data = {"id": z.id, "state": z.state, "stream_id": z.stream_id}
msg = LANG_VIEWS_T(
request,
"zone_started" if z.state == 1 else "zone_stopped",
)
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def control_openRecentAlarms(request):
"""返回近期报警事件,供布控页展示"""
from app.views.AnalysisView import _timeline_alarm_fields
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = []
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
try:
import json as _json
stream_id = request.GET.get('stream_id')
limit = int(request.GET.get('limit', 12))
base_qs = AlarmModel.objects.all()
if stream_id:
base_qs = base_qs.filter(stream_id=int(stream_id))
zone_qs = base_qs.filter(event_type__in=CONTROL_ALARM_EVENT_TYPES).order_by('-timestamp')[:limit]
items = list(zone_qs)
out = []
for t in items[:limit]:
try:
meta = _json.loads(t.metadata) if t.metadata else {}
except Exception:
meta = {}
out.append({
"id": t.id,
"stream_id": t.stream_id,
"stream_name": (t.stream.nickname if t.stream else ""),
"event_type": t.event_type,
"label": meta.get("label", ""),
"zone_id": meta.get("zone_id"),
"box": meta.get("box"),
"duration": meta.get("duration"),
"snapshot_path": meta.get("snapshot_path", ""),
"timestamp": str(t.timestamp),
**_timeline_alarm_fields(t, meta),
})
data = out
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})

165
app/views/IndexView.py Normal file
View File

@ -0,0 +1,165 @@
from app.views.ViewsBase import *
from app.models import *
from django.shortcuts import render
from app.utils.OSSystem import OSSystem
def index(request):
context = {}
return render(request, 'app/index.html', context)
def api_openIndex(request):
# highcharts 例子 https://www.highcharts.com.cn/demo/highcharts/dynamic-update
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
appInfo = {}
osInfo = {}
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
# params = f_parseGetParams(request)
appInfo = {
"project_ua": PROJECT_UA,
"project_version": PROJECT_VERSION,
"project_flag": PROJECT_FLAG,
"project_built": PROJECT_BUILT,
"start_timestamp": PROJECT_ADMIN_START_TIMESTAMP
}
osSystem = OSSystem()
osInfo = osSystem.getOSInfo(
spend_date_fmt=LANG_VIEWS_T(request, "syscfg_spend_date_fmt"),
include_gpu=False,
)
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
res = {
"code": 1000 if ret else 0,
"msg": msg,
"osInfo": osInfo,
"appInfo": appInfo
}
return f_responseJson(res)
def api_openGpuInfo(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
os_gpus = []
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
try:
from app.utils.GpuInfo import get_gpu_info
os_gpus = get_gpu_info()
except Exception:
os_gpus = []
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({
"code": 1000 if ret else 0,
"msg": msg,
"os_gpus": os_gpus,
})
def api_openMediaStatus(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = {}
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
try:
from app.utils.MediaServerManager import get_media_server_manager
data = get_media_server_manager().status()
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def api_openMediaControl(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
action = str(params.get("action", "")).strip().lower()
try:
from app.utils.MediaServerManager import get_media_server_manager
mgr = get_media_server_manager()
if action == "start":
ret, msg = mgr.start()
elif action == "stop":
ret, msg = mgr.stop()
elif action == "restart":
ret, msg = mgr.restart()
else:
msg = LANG_VIEWS_T(request, "msg_invalid_parameter")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def forbidden(request):
context = {}
return render(request, 'app/forbidden.html', context)
def api_openSwitchLang(request):
from app.utils.LanguageUtils import LANG_UI_DICT
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parseGetParams(request)
lang = params.get('lang')
if not lang:
msg = LANG_VIEWS_T(request, "index_lang_param_required")
elif lang not in LANG_UI_DICT:
available = list(LANG_UI_DICT.keys())
msg = LANG_VIEWS_T(request, "index_lang_not_supported") % (lang, str(available))
else:
request.session['lang'] = lang
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
res = {
"code": 1000 if ret else 0,
"msg": msg
}
return f_responseJson(res)

312
app/views/InnerlView.py Normal file
View File

@ -0,0 +1,312 @@
import os
import time
import threading
import platform
from django.db import transaction
from app.views.ViewsBase import *
from app.utils.LanguageUtils import GSettingsLangDefault
from app.utils.Utils import GB28181CodeUtils
"""
内部服务调用的接口
"""
# 按code的细粒度锁不同code完全并发同一code串行化防止并发插入重复编号
_code_locks = {}
_code_locks_guard = threading.Lock()
def _get_code_lock(code):
"""获取指定code的锁对象"""
with _code_locks_guard:
if code not in _code_locks:
_code_locks[code] = threading.Lock()
return _code_locks[code]
# 内部调用无需国际化被monitor调用用于修改流主要是国标等协议
def api_on_media_update_stream(request):
ret = False
msg = "unknown error"
if request.method == 'POST':
params = f_parsePostParams(request)
lang = f_parseRequestLang(request)
try:
forward_state = int(params.get("forwardState", 0))
app = params.get("app") # 流app
name = params.get("name") # 流name视频通道channelId
ip = params.get("ip") # 设备IP
port = int(params.get("port", 0)) # 设备SIP通信端口
clientId = params.get("clientId") # gb28181注册的client_id
parentID = params.get("parentID", "") # 设备连接的sipServer-sipId
rtpServerPort = int(params.get("rtpServerPort", 0))
rtpPort = int(params.get("rtpPort", 0))
pullStreamType = int(params.get("pullStreamType", 0))
pullStreamUrl = params.get("pullStreamUrl")
cameraSumNum = int(params.get("cameraSumNum", 1))
cameraName = params.get("cameraName", "")
cameraManufacturer = params.get("cameraManufacturer", "")
cameraModel = params.get("cameraModel", "")
cameraOwner = params.get("cameraOwner", "")
cameraCivilCode = params.get("cameraCivilCode", "")
lastKeepaliveTime = int(params.get("lastKeepaliveTime", 0))
lastRegisterTime = int(params.get("lastRegisterTime", 0))
rtpTransferMode = int(params.get("rtpTransferMode", 0))
rtpTransferAudioType = int(params.get("rtpTransferAudioType", 0))
if app is None:
raise Exception("The parameter app is invalid")
else:
app = str(app)
if name is None:
raise Exception("The parameter name is invalid")
else:
name = str(name)
if ip is None:
raise Exception("The parameter ip is invalid")
else:
ip = str(ip)
if clientId is None:
raise Exception("The parameter clientId is invalid")
else:
clientId = str(clientId)
if pullStreamUrl is None:
raise Exception("The parameter pullStreamUrl is invalid")
if cameraName is None:
raise Exception("The parameter cameraName is invalid")
now_date = datetime.now()
# 【关键修复】按code的细粒度锁彻底防止并发插入重复编号
# 不同code完全并发同一code串行化
# 注意SQLite写锁是数据库级的不能用transaction.atomic()否则不同code会互相阻塞报"database is locked"
code_lock = _get_code_lock(name)
with code_lock:
stream = StreamModel.objects.filter(code=name).first()
if stream:
# 编辑不修改nicknamenickname允许用户自定义
pass
else:
# 新增
stream = StreamModel()
stream.user_id = 0
stream.sort = 0
stream.code = name
stream.app = app
stream.name = name
stream.create_time = now_date
stream.add_type = 0
stream.state = 0
stream.nickname = cameraName
stream.remark = ""
stream.forward_state = forward_state
stream.last_update_time = now_date
stream.pull_stream_type = pullStreamType
stream.pull_stream_url = pullStreamUrl
stream.pull_stream_ip = ip
stream.pull_stream_port = port
stream.camera_sum_num = cameraSumNum
stream.camera_name = cameraName
stream.camera_manufacturer = cameraManufacturer
stream.camera_owner = cameraOwner
stream.camera_model = cameraModel
stream.camera_civilcode = cameraCivilCode
stream.camera_device_id = clientId
stream.camera_parent_id = parentID
if lastKeepaliveTime > 0:
lastKeepaliveTime_date = datetime.fromtimestamp(int(lastKeepaliveTime / 1000))
stream.camera_last_keepalive_time = lastKeepaliveTime_date
if lastRegisterTime > 0:
lastRegisterTime_date = datetime.fromtimestamp(int(lastRegisterTime / 1000))
stream.camera_last_register_time = lastRegisterTime_date
stream.pull_stream_transfer_mode = rtpTransferMode
stream.is_audio = rtpTransferAudioType
stream.save()
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
res = {
"code": 1000 if ret else 0,
"msg": msg
}
if not ret:
g_logger.warning("InnerView.api_on_media_update_stream() res=%s" % str(res))
return f_responseJson(res)
# 内部调用无需国际化被GB28181SipServer调用用于删除回退通道的数据库记录
def api_on_media_delete_stream(request):
ret = False
msg = "unknown error"
if request.method == 'POST':
try:
params = f_parsePostParams(request)
code = params.get("code")
if not code:
raise Exception("The parameter code is invalid")
stream = StreamModel.objects.filter(code=code).first()
if stream:
stream.delete()
g_gb28181SipServer.remove_channel(code)
ret = True
msg = "success"
else:
msg = "stream not found"
except Exception as e:
msg = str(e)
else:
msg = "method not supported"
res = {
"code": 1000 if ret else 0,
"msg": msg
}
if not ret:
g_logger.warning("InnerView.api_on_media_delete_stream() res=%s" % str(res))
return f_responseJson(res)
# 内部调用无需国际化被monitor_zlm调用用于实时获得推流信息
def api_on_publish(request):
# ZLMediaKit Hook https://github.com/ZLMediaKit/ZLMediaKit/wiki/MediaServer%E6%94%AF%E6%8C%81%E7%9A%84HTTP-HOOK-API
ret = False
msg = "unknown error"
if request.method == 'POST':
try:
params = f_parsePostParams(request)
lang = f_parseRequestLang(request)
_app = params.get("app", "").strip()
_stream = params.get("stream", "").strip()
_tcp_id = params.get("id", "").strip()
_schema = params.get("schema", "").strip()
_ip = params.get("ip", "").strip()
_port = int(params.get("port", 0))
if _app == APP_NAME_LIVE:
# 被动推流RTSP/RTMP推流到ZLM的live应用
stream_code = _stream
stream = StreamModel.objects.filter(code=stream_code).first()
now_date = datetime.now()
if stream:
if stream.pull_stream_type == 31 or stream.pull_stream_type == 32:
pass
else:
raise Exception("This stream does not come from passive streaming")
else:
stream = StreamModel()
stream.user_id = 0
stream.sort = 0
stream.code = stream_code
stream.app = _app
stream.name = _stream
stream.create_time = now_date
stream.add_type = 0
stream.state = 0
stream.is_audio = 0 # 默认静音(与手动添加一致)
stream.pull_stream_transfer_mode = 0
stream.nickname = _stream
stream.remark = ""
stream.camera_name = _stream
stream.camera_manufacturer = _stream
stream.camera_device_id = "default" # 默认分组编号
if _schema == "rtsp":
pullStreamType = 31
elif _schema == "rtmp":
pullStreamType = 32
else:
raise Exception("unsupported schema type, schema=%s" % _schema)
pullStreamUrl = "%s://%s:%d/%s" % (_schema, _ip, _port, _tcp_id)
stream.forward_state = 1
stream.last_update_time = now_date
stream.pull_stream_type = pullStreamType
stream.pull_stream_url = pullStreamUrl
stream.pull_stream_ip = _ip
stream.pull_stream_port = _port
stream.save()
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
elif _app == APP_NAME_RTP:
# gb28181接入被动接收的推流默认通过
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
else:
raise Exception("unsupported app, app=%s" % _app)
except Exception as e:
msg = "error %s" % str(e)
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
res = {
"code": 0 if ret else -1, # 返回-1的请求推流将会被取消
"msg": msg
}
if not ret:
g_logger.warning("InnerView.api_on_publish() res=%s" % str(res))
return f_responseJson(res)
# 内部调用无需国际化被monitor_zlm调用用于实时获得not found的流信息
def api_on_stream_not_found(request):
# ZLMediaKit Hook https://github.com/ZLMediaKit/ZLMediaKit/wiki/MediaServer%E6%94%AF%E6%8C%81%E7%9A%84HTTP-HOOK-API
ret = False
msg = "unknown error"
params = None
if request.method == 'POST':
params = f_parsePostParams(request)
_app = params.get("app", "").strip()
_stream = params.get("stream", "").strip()
if _app == APP_NAME_RTP:
# gb28181 流不存在,尝试重新 invite
stream = StreamModel.objects.filter(code=_stream).first()
if stream and stream.pull_stream_type == 21:
__ret, __msg = g_gb28181SipServer.request_invite(
client_id=stream.camera_device_id, channel_id=stream.name)
if __ret:
ret = True
msg = "success"
else:
msg = __msg
else:
msg = "the stream does not exist"
else:
msg = "unsupported app"
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
if not ret:
g_logger.warning("InnerView.api_on_stream_not_found() params=%s,msg=%s" % (str(params), msg))
res = {
"code": 0,
"msg": "success"
}
return f_responseJson(res)

310
app/views/LLMView.py Normal file
View File

@ -0,0 +1,310 @@
from datetime import datetime
from django.shortcuts import render
from app.views.ViewsBase import *
from app.models import LLMModel
from app.utils.Utils import buildPageLabels
from app.utils.LLMUtils import LLMUtils
from app.utils.Credentials import decrypt_credential, encrypt_credential, mask_credential, redact_mapping
def index(request):
params = f_parseGetParams(request)
page = params.get('p', 1)
page_size = params.get('ps', 10)
try:
page = int(page)
if page < 1:
page = 1
except Exception:
page = 1
try:
page_size = int(page_size)
if page_size < 1:
page_size = 10
elif page_size > 100:
page_size = 100
except Exception:
page_size = 10
skip = (page - 1) * page_size
count_row = g_database.select("select count(id) as count from av_llm")
count = int(count_row[0]["count"]) if count_row else 0
data = []
if count > 0:
data = g_database.select(
"select * from av_llm order by id desc limit %s,%s", [skip, page_size])
for d in data:
d["api_key_masked"] = mask_credential(d.pop("api_key", ""))
d["has_api_key"] = bool(d["api_key_masked"])
if d.get("last_update_time"):
d["last_update_time"] = d["last_update_time"].strftime("%Y/%m/%d %H:%M")
page_num = count // page_size
if count % page_size > 0:
page_num += 1
page_labels = buildPageLabels(page=page, page_num=page_num, lang=f_parseRequestLang(request))
page_data = {
"page": page,
"page_size": page_size,
"page_num": page_num,
"count": count,
"pageLabels": page_labels,
}
return render(request, 'app/llm/index.html', {"data": data, "pageData": page_data})
def test(request):
return render(request, 'app/llm/test.html', {})
def api_openIndex(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = []
if request.method == "GET":
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
try:
for d in LLMModel.objects.all().order_by('-id'):
data.append({
'id': d.id,
'code': d.code,
'name': d.name,
'model_name': d.model_name,
'api_url': d.api_url,
'api_key_masked': mask_credential(d.api_key),
'has_api_key': bool(d.api_key),
'timeout': d.timeout,
'inference_tool': d.inference_tool,
'state': d.state,
'remark': d.remark,
'last_update_time': d.last_update_time.strftime("%Y/%m/%d %H:%M") if d.last_update_time else '',
})
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def api_openAdd(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == "POST":
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
g_logger.info("LLMView.openAdd() params:%s" % str(redact_mapping(params)))
code = params.get("code", "").strip()
name = (params.get("name") or "").strip()
model_name = params.get("model_name", "").strip()
if not name:
name = model_name
api_url = params.get("api_url", "").strip()
api_key = params.get("api_key", "").strip()
timeout = int(params.get("timeout", 30))
inference_tool = params.get("inference_tool", "OpenAI").strip() or "OpenAI"
if inference_tool != "OpenAI":
raise Exception(LANG_VIEWS_T(request, "llm_inference_tool_unsupported"))
state = int(params.get("state", 1))
remark = params.get("remark", "").strip()
try:
if LLMModel.objects.filter(code=code).first():
raise Exception(LANG_VIEWS_T(request, "msg_code_already_exists"))
if api_url and not (api_url.startswith("http://") or api_url.startswith("https://")):
raise Exception(LANG_VIEWS_T(request, "llm_api_url_format_error"))
if not model_name:
raise Exception(LANG_VIEWS_T(request, "llm_input_model_name"))
if not api_url:
raise Exception(LANG_VIEWS_T(request, "llm_input_api_url"))
if not api_key:
raise Exception(LANG_VIEWS_T(request, "llm_input_api_key"))
llm = LLMModel()
llm.user_id = f_sessionReadUserId(request)
llm.code = code
llm.name = name
llm.model_name = model_name
llm.api_url = api_url
llm.api_key = encrypt_credential(api_key)
llm.timeout = timeout
llm.inference_tool = inference_tool
llm.remark = remark
llm.sort = 0
llm.create_time = datetime.now()
llm.last_update_time = datetime.now()
llm.state = state
llm.save()
ret = True
msg = LANG_VIEWS_T(request, "msg_add_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def api_openEdit(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == "POST":
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
g_logger.info("LLMView.openEdit() params:%s" % str(redact_mapping(params)))
code = params.get("code", "").strip()
name = (params.get("name") or "").strip()
model_name = params.get("model_name", "").strip()
if not name:
name = model_name
api_url = params.get("api_url", "").strip()
api_key = params.get("api_key", "").strip()
timeout = int(params.get("timeout", 30))
inference_tool = params.get("inference_tool", "OpenAI").strip() or "OpenAI"
if inference_tool != "OpenAI":
raise Exception(LANG_VIEWS_T(request, "llm_inference_tool_unsupported"))
state = int(params.get("state", 1))
remark = params.get("remark", "").strip()
try:
if api_url and not (api_url.startswith("http://") or api_url.startswith("https://")):
raise Exception(LANG_VIEWS_T(request, "llm_api_url_format_error"))
llm = LLMModel.objects.filter(code=code).first()
if not llm:
raise Exception(LANG_VIEWS_T(request, "msg_data_not_exist"))
llm.name = name
llm.model_name = model_name
llm.api_url = api_url
if api_key:
llm.api_key = encrypt_credential(api_key)
llm.timeout = timeout
llm.inference_tool = inference_tool
llm.remark = remark
llm.last_update_time = datetime.now()
llm.state = state
llm.save()
ret = True
msg = LANG_VIEWS_T(request, "msg_edit_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def api_openInfo(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
info = {}
if request.method == "GET":
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
code = f_parseGetParams(request).get("code", "").strip()
if not code:
msg = LANG_VIEWS_T(request, "msg_invalid_parameter")
else:
try:
llm = LLMModel.objects.filter(code=code).first()
if llm:
info = {
"id": llm.id,
"code": llm.code,
"name": llm.name,
"model_name": llm.model_name,
"api_url": llm.api_url or "",
"api_key_masked": mask_credential(llm.api_key),
"has_api_key": bool(llm.api_key),
"timeout": llm.timeout,
"inference_tool": llm.inference_tool,
"state": llm.state,
"remark": llm.remark or "",
"create_time": llm.create_time.strftime("%Y-%m-%d %H:%M:%S") if llm.create_time else "",
"last_update_time": llm.last_update_time.strftime("%Y-%m-%d %H:%M:%S") if llm.last_update_time else "",
}
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
else:
msg = LANG_VIEWS_T(request, "llm_config_not_exist")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "info": info})
def api_openDel(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == "POST":
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
code = f_parsePostParams(request).get("code")
llm = LLMModel.objects.filter(code=code).first()
if llm:
if llm.delete():
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
else:
msg = LANG_VIEWS_T(request, "msg_failed_to_delete")
else:
msg = LANG_VIEWS_T(request, "msg_data_not_exist")
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def api_openTest(request):
try:
if request.method != "POST":
raise Exception(LANG_VIEWS_T(request, "msg_method_not_supported"))
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
raise Exception(__check_msg)
code = request.POST.get('code', '').strip()
prompt = request.POST.get('prompt', '请详细描述图片中的内容?').strip()
if code:
llm = LLMModel.objects.filter(code=code).first()
if not llm:
raise Exception(LANG_VIEWS_T(request, "llm_not_found"))
if llm.state != 1:
raise Exception(LANG_VIEWS_T(request, "llm_is_disabled"))
api_url = llm.api_url
api_key = decrypt_credential(llm.api_key)
timeout = llm.timeout
inference_tool = "OpenAI"
model = llm.model_name
else:
api_url = request.POST.get('api_url', 'https://api.openai.com/v1').strip()
api_key = request.POST.get('api_key', '').strip()
timeout = int(request.POST.get('timeout', 30))
inference_tool = request.POST.get('inferenceTool', 'OpenAI').strip() or 'OpenAI'
if inference_tool != 'OpenAI':
raise Exception(LANG_VIEWS_T(request, "llm_inference_tool_unsupported"))
model = request.POST.get('model', 'gpt-4o').strip()
file_content_base64 = request.POST.get('file_content', '').strip()
if file_content_base64:
image_bytes = base64.b64decode(file_content_base64)
elif 'image' in request.FILES:
image_bytes = request.FILES['image'].read()
else:
raise Exception(LANG_VIEWS_T(request, "llm_no_image_provided"))
result = LLMUtils(api_url, api_key, timeout, inference_tool, model).infer(prompt, image_bytes)
return f_responseJson({"code": 1000, "result": result})
except Exception as e:
return f_responseJson({"code": 0, "msg": str(e)})

365
app/views/NvrView.py Normal file
View File

@ -0,0 +1,365 @@
"""
NvrView 模块 24/7 录像 + FFmpeg 截图
"""
import os
import re
import time
import subprocess
from datetime import timedelta
from app.views.ViewsBase import *
from app.utils.Utils import buildPageLabels
from app.utils.LanguageUtils import LANG_VIEWS_T
from django.http import HttpResponse
from django.shortcuts import render
def record_index(request):
params = f_parseGetParams(request)
stream_id = int(params.get("stream_id", 0) or 0)
stream_name = ""
record_enable = 0
is_recording = False
if stream_id:
from app.models import StreamModel
stream = StreamModel.objects.filter(id=stream_id).first()
if stream:
stream_name = stream.nickname or stream.name or ("#" + str(stream_id))
record_enable = int(stream.record_enable or 0)
try:
from app.recording.manager import get_recording_manager
is_recording = get_recording_manager().is_recording(stream_id)
except Exception:
pass
else:
stream_id = 0
if not stream_id:
from django.shortcuts import redirect
return redirect("/stream/index")
return render(request, "app/record/index.html", {
"stream_id": stream_id,
"stream_name": stream_name,
"record_enable": record_enable,
"is_recording": is_recording,
})
def _sync_stream_recording(stream):
"""根据 record_enable 与转发状态同步录像进程(关闭录像时停止进程,保留历史文件)"""
try:
from app.recording.manager import get_recording_manager
mgr = get_recording_manager()
if int(stream.record_enable or 0) == 1 and int(stream.forward_state or 0) == 1:
mgr.start_stream(stream)
else:
mgr.stop_stream(stream.id)
except Exception as e:
g_logger.warning("sync recording stream=%s err: %s" % (stream.id, str(e)))
def _get_stream(params):
from app.models import StreamModel
sid = int(params.get("stream_id") or params.get("id") or 0)
app = (params.get("app") or "").strip()
name = (params.get("name") or "").strip()
if sid:
return StreamModel.objects.filter(id=sid).first()
if app and name:
return StreamModel.objects.filter(app=app, name=name).first()
return None
def api_openVideoIsRecording(request):
if request.method != 'GET':
return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "msg_method_not_supported")})
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
return f_responseJson({"code": 0, "msg": __check_msg})
params = f_parseGetParams(request)
stream = _get_stream(params)
if not stream:
return f_responseJson({"code": 0, "msg": "stream not found"})
from app.recording.manager import get_recording_manager
mgr = get_recording_manager()
return f_responseJson({
"code": 1000, "msg": "ok",
"is_recording": mgr.is_recording(stream.id),
"record_enable": stream.record_enable,
})
def api_openStartRecordVideo(request):
if request.method != 'POST':
return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "msg_method_not_supported")})
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
return f_responseJson({"code": 0, "msg": __check_msg})
params = f_parsePostParams(request)
stream = _get_stream(params)
if not stream:
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})
def api_openStopRecordVideo(request):
if request.method != 'POST':
return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "msg_method_not_supported")})
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
return f_responseJson({"code": 0, "msg": __check_msg})
params = f_parsePostParams(request)
stream = _get_stream(params)
if not stream:
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})
def _scan_disk_recordings(stream_id=None):
"""从磁盘扫描录像分段FFmpeg segment 输出)"""
items = []
try:
base = g_config.storageRecordDir
except Exception:
return items
if not base or not os.path.isdir(base):
return items
for root, _dirs, files in os.walk(base):
for fn in files:
if not fn.lower().endswith((".mp4", ".ts", ".mkv")):
continue
fp = os.path.join(root, fn)
try:
st = os.stat(fp)
except Exception:
continue
sid = 0
parts = fn.split("_", 1)
if parts and parts[0].isdigit():
sid = int(parts[0])
if stream_id and sid != int(stream_id):
continue
from datetime import datetime
start = datetime.fromtimestamp(st.st_mtime)
items.append({
"id": 0,
"stream_id": sid,
"stream_name": "",
"file_path": fp,
"start_time": str(start),
"end_time": str(start),
"duration": 0,
"file_size": st.st_size,
"file_exists": True,
})
items.sort(key=lambda x: x["start_time"], reverse=True)
return items
def api_openRecordIndex(request):
"""分页列出录像分段"""
if request.method != 'GET':
return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "msg_method_not_supported")})
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
return f_responseJson({"code": 0, "msg": __check_msg})
from app.models import RecordingModel, StreamModel
params = f_parseGetParams(request)
page = int(params.get('p', 1) or 1)
ps = int(params.get('ps', 20) or 20)
sid = params.get('stream_id')
stream_filter = int(sid) if sid else None
qs = RecordingModel.objects.all().order_by('-start_time')
if stream_filter:
qs = qs.filter(stream_id=stream_filter)
db_rows = list(qs.select_related('stream')[:500])
data = []
seen_paths = set()
for r in db_rows:
seen_paths.add(r.file_path)
data.append({
"id": r.id,
"stream_id": r.stream_id,
"stream_name": (r.stream.nickname if r.stream else ""),
"file_path": r.file_path,
"start_time": str(r.start_time),
"end_time": str(r.end_time),
"duration": r.duration,
"file_size": r.file_size,
"file_exists": os.path.isfile(r.file_path) if r.file_path else False,
})
for item in _scan_disk_recordings(stream_filter):
if item["file_path"] in seen_paths:
continue
if item["stream_id"]:
try:
s = StreamModel.objects.filter(id=item["stream_id"]).first()
if s:
item["stream_name"] = s.nickname or s.name
except Exception:
pass
data.append(item)
data.sort(key=lambda x: x.get("start_time", ""), reverse=True)
count = len(data)
skip = (page - 1) * ps
page_rows = data[skip:skip + ps]
page_num = int(count / ps) + (1 if count % ps else 0)
page_data = {
"page": page, "page_size": ps, "page_num": max(1, page_num), "count": count,
"pageLabels": buildPageLabels(page=page, page_num=max(1, page_num), lang=f_parseRequestLang(request)),
}
return f_responseJson({"code": 1000, "msg": "ok", "data": page_rows, "pageData": page_data})
def api_openRecordFile(request):
if request.method != 'GET':
return HttpResponse(b"method not allowed", status=405)
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
return HttpResponse(__check_msg.encode("utf-8"), status=403)
params = f_parseGetParams(request)
rid = int(params.get("id", 0) or 0)
fpath = (params.get("path") or "").strip()
fp = None
if rid:
from app.models import RecordingModel
try:
rec = RecordingModel.objects.get(id=rid)
fp = rec.file_path
except Exception:
return HttpResponse(b"not found", status=404)
elif fpath and os.path.isfile(fpath):
try:
base = g_config.storageRecordDir
if base and os.path.commonpath([os.path.abspath(fpath), os.path.abspath(base)]) == os.path.abspath(base):
fp = fpath
except Exception:
pass
if not fp or not os.path.isfile(fp):
return HttpResponse(b"file missing", status=404)
with open(fp, "rb") as f:
data = f.read()
resp = HttpResponse(data, content_type="video/mp4")
resp["Content-Disposition"] = 'inline; filename="%s"' % os.path.basename(fp)
return resp
def api_openRecordDel(request):
if request.method != 'POST':
return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "msg_method_not_supported")})
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
return f_responseJson({"code": 0, "msg": __check_msg})
params = f_parsePostParams(request)
rid = int(params.get("id", 0) or 0)
fpath = (params.get("path") or "").strip()
if not rid and not fpath:
return f_responseJson({"code": 0, "msg": "missing id or path"})
from app.models import RecordingModel
try:
if rid:
rec = RecordingModel.objects.get(id=rid)
fp = rec.file_path
rec.delete()
else:
fp = fpath
base = g_config.storageRecordDir
if not base or not os.path.isfile(fp):
return f_responseJson({"code": 0, "msg": "file missing"})
if os.path.commonpath([os.path.abspath(fp), os.path.abspath(base)]) != os.path.abspath(base):
return f_responseJson({"code": 0, "msg": "invalid path"})
RecordingModel.objects.filter(file_path=fp).delete()
if fp and os.path.isfile(fp):
os.remove(fp)
return f_responseJson({"code": 1000, "msg": LANG_VIEWS_T(request, "msg_success")})
except Exception as e:
return f_responseJson({"code": 0, "msg": str(e)})
def api_openSnapShot(request):
if request.method != 'POST':
return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "msg_method_not_supported")})
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
return f_responseJson({"code": 0, "msg": __check_msg})
return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "nvr_snapshot_removed")})
def _snap_http_response(data, status=200):
resp = HttpResponse(data, content_type="image/jpeg", status=status)
resp["Cache-Control"] = "no-store"
return resp
def _capture_snap_file(rtsp_url, snap_path, ffmpeg_cmd):
if os.path.exists(snap_path):
try:
os.remove(snap_path)
except Exception:
pass
command = [
ffmpeg_cmd, "-nostdin", "-loglevel", "quiet", "-rtsp_transport", "tcp",
"-stimeout", "5000000", "-i", rtsp_url, "-frames:v", "1", "-y", snap_path,
]
subprocess.run(command, shell=False, timeout=12, capture_output=True, check=False)
if os.path.exists(snap_path):
with open(snap_path, "rb") as f:
data = f.read()
if len(data) > 100:
return data
return None
def api_openSnap(request):
try:
if request.method != "GET":
return HttpResponse(b"method not allowed", status=405, content_type="text/plain")
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
return HttpResponse(b"forbidden", status=403, content_type="text/plain")
params = f_parseGetParams(request)
app = params.get("app", "").strip()
name = params.get("name", "").strip()
force = str(params.get("force", "0")).strip() == "1"
if not app or not name:
return HttpResponse(b"missing app or name", status=400, content_type="text/plain")
if not re.fullmatch(r"[A-Za-z0-9_.-]{1,50}", app) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,50}", name):
return HttpResponse(b"invalid app or name", status=400, content_type="text/plain")
from app.models import StreamModel
stream = StreamModel.objects.filter(app=app, name=name).only("app", "name").first()
if not stream:
return HttpResponse(b"stream not found", status=404, content_type="text/plain")
app, name = stream.app, stream.name
snap_dir = os.path.join(g_config.storageDir, "snapshots")
snap_path = os.path.join(snap_dir, "%s_%s.jpg" % (app, name))
if not force and os.path.exists(snap_path):
mtime = os.path.getmtime(snap_path)
if (time.time() - mtime) < 30:
with open(snap_path, "rb") as f:
data = f.read()
if len(data) > 100:
return _snap_http_response(data)
rtsp_url = g_zlm.get_rtspUrl(app=app, name=name, request_ip="127.0.0.1")
if not rtsp_url:
return HttpResponse(b"no stream url available", status=404, content_type="text/plain")
os.makedirs(snap_dir, exist_ok=True)
ffmpeg_cmd = g_config.ffmpeg
data = _capture_snap_file(rtsp_url, snap_path, ffmpeg_cmd)
if not data:
# RTSP 首帧可能较慢,短暂等待后重试一次
time.sleep(0.6)
data = _capture_snap_file(rtsp_url, snap_path, ffmpeg_cmd)
if data:
return _snap_http_response(data)
return HttpResponse(b"snap failed", status=404, content_type="text/plain")
except subprocess.TimeoutExpired:
return HttpResponse(b"snap timeout", status=404, content_type="text/plain")
except Exception as e:
return HttpResponse(("snap exception: " + str(e)).encode("utf-8"), status=500, content_type="text/plain")

789
app/views/SmallModelView.py Normal file
View File

@ -0,0 +1,789 @@
"""小模型管理 Web 层
页面/smallmodel/index
API
- /smallmodel/openIndex GET 列表
- /smallmodel/openAdd POST 新增
- /smallmodel/openEdit POST 编辑
- /smallmodel/openDel POST 删除
- /smallmodel/openUploadModel POST(multipart) 上传模型文件
- /smallmodel/openProbe POST 探测模型 shape/labels
- /smallmodel/openEngines GET 本机可用引擎列表
- /smallmodel/openDetectors GET ReID 测试可选检测小模型列表
- /smallmodel/openSetActive POST 设为默认算法
- /smallmodel/openAssignStreams POST 把算法分配给多个摄像头
"""
import os
import json
import uuid
from app.views.ViewsBase import *
from app.utils.Utils import buildPageLabels
from django.shortcuts import render
from django.conf import settings
from django.http import HttpResponse
from app.models import StreamModel, AlgorithmModel
def _algo_to_dict(a, include_streams=False):
labels = a.labels or '[]'
try:
labels_list = json.loads(labels) if isinstance(labels, str) else labels
except Exception:
labels_list = []
d = {
"id": a.id,
"name": a.name,
"algorithm_type": a.algorithm_type,
"task_type": a.task_type,
"inference_engine": a.inference_engine,
"device": a.device,
"model_file": a.model_file,
"model_file_size": a.model_file_size,
"input_width": a.input_width,
"input_height": a.input_height,
"conf_threshold": a.conf_threshold,
"iou_threshold": a.iou_threshold,
"labels": labels_list,
"is_default": a.is_default,
"state": a.state,
"create_time": str(a.create_time),
"stream_count": a.streams.count() if include_streams else 0,
}
return d
def _algo_parse_page_params(request, default_ps=10):
page = request.GET.get('p', 1)
page_size = request.GET.get('ps', default_ps)
try:
page = int(page)
if page < 1:
page = 1
except Exception:
page = 1
try:
page_size = int(page_size)
if page_size < 1:
page_size = default_ps
elif page_size > 100:
page_size = 100
except Exception:
page_size = default_ps
return page, page_size
def _algo_build_page_data(request, page, page_size, count):
page_num = int(count / page_size)
if count % page_size > 0:
page_num += 1
if page_num < 1:
page_num = 1
if page > page_num:
page = page_num
page_labels = buildPageLabels(page=page, page_num=page_num, lang=f_parseRequestLang(request))
return {
"page": page,
"page_size": page_size,
"page_num": page_num,
"count": count,
"pageLabels": page_labels,
}
def smallmodel_openIndex(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = []
page_data = {}
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parseGetParams(request)
page, page_size = _algo_parse_page_params(request, default_ps=10)
qs = AlgorithmModel.objects.all().order_by('-id')
engine = params.get('engine', '').strip()
if engine:
qs = qs.filter(inference_engine=engine)
state = params.get('state', '').strip()
if state != '':
qs = qs.filter(state=int(state))
count = qs.count()
skip = (page - 1) * page_size
data = [_algo_to_dict(a, include_streams=True) for a in qs[skip:skip + page_size]]
page_data = _algo_build_page_data(request, page, page_size, count)
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data, "pageData": page_data})
def _apply_algo_rules(fields):
"""按任务类型规范化字段ReID 仅 OnnxRuntime + OSNet"""
task = (fields.get("task_type") or "detect").lower()
if task == "reid":
fields["inference_engine"] = "onnxruntime"
fields["algorithm_type"] = "osnet"
fields["labels"] = "[]"
if not fields.get("input_width"):
fields["input_width"] = 128
if not fields.get("input_height"):
fields["input_height"] = 256
return fields
def _validate_algo_fields(request, fields):
task = (fields.get("task_type") or "detect").lower()
if task == "reid":
eng = (fields.get("inference_engine") or "").lower()
if eng not in ("onnxruntime", "onnx"):
return False, LANG_VIEWS_T(request, "alg_reid_onnx_only")
if (fields.get("algorithm_type") or "").lower() != "osnet":
return False, LANG_VIEWS_T(request, "alg_reid_osnet_only")
return True, ""
def _parse_algo_params(params):
"""从 POST 参数构造 AlgorithmModel 字段 dict"""
out = {}
if "name" in params:
out["name"] = (params.get("name") or "").strip()
if "algorithm_type" in params:
out["algorithm_type"] = (params.get("algorithm_type") or "yolo8").strip()
if "task_type" in params:
out["task_type"] = (params.get("task_type") or "detect").strip()
if "inference_engine" in params:
out["inference_engine"] = (params.get("inference_engine") or "yolo_pytorch").strip()
if "device" in params:
out["device"] = (params.get("device") or "cpu").strip()
if "model_file" in params:
out["model_file"] = (params.get("model_file") or "").strip()
if "input_width" in params:
try:
out["input_width"] = int(params.get("input_width", 640))
except Exception:
pass
if "input_height" in params:
try:
out["input_height"] = int(params.get("input_height", 640))
except Exception:
pass
if "conf_threshold" in params:
try:
out["conf_threshold"] = float(params.get("conf_threshold", 0.4))
except Exception:
pass
if "iou_threshold" in params:
try:
out["iou_threshold"] = float(params.get("iou_threshold", 0.5))
except Exception:
pass
if "labels" in params:
lb = params.get("labels")
if isinstance(lb, list):
out["labels"] = json.dumps([str(x).strip() for x in lb if str(x).strip()], ensure_ascii=False)
elif isinstance(lb, str):
# 英文逗号分隔:支持中文类别(只要用英文逗号隔开就是一个类别)
try:
arr = json.loads(lb)
if isinstance(arr, list):
out["labels"] = json.dumps([str(x).strip() for x in arr if str(x).strip()], ensure_ascii=False)
else:
out["labels"] = "[]"
except Exception:
items = [s.strip() for s in lb.split(",") if s.strip()]
if items:
out["labels"] = json.dumps(items, ensure_ascii=False)
else:
out["labels"] = "[]"
if "state" in params:
try:
out["state"] = int(params.get("state", 1))
except Exception:
pass
if "is_default" in params:
try:
out["is_default"] = int(params.get("is_default", 0))
except Exception:
pass
if "model_file_size" in params:
try:
out["model_file_size"] = int(params.get("model_file_size", 0))
except Exception:
pass
return out
def smallmodel_index(request):
return render(request, 'app/smallmodel/index.html', {})
def smallmodel_test(request):
return render(request, 'app/smallmodel/test.html', {})
def smallmodel_openDetail(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = {}
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parseGetParams(request)
try:
aid = int(params.get("id", 0))
a = AlgorithmModel.objects.get(id=aid)
data = _algo_to_dict(a, include_streams=True)
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def _test_upload_dir():
from app.services.algorithm_test_service import upload_dir
return upload_dir()
def smallmodel_openTestStart(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = {}
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
try:
aid = int(request.POST.get("algorithm_id", 0) or 0)
a = AlgorithmModel.objects.get(id=aid)
f = request.FILES.get("file")
if not f:
msg = LANG_VIEWS_T(request, "alg_no_file")
elif not a.model_file:
msg = LANG_VIEWS_T(request, "alg_no_model_file")
else:
ext = os.path.splitext(f.name)[1].lower()
allowed = (".jpg", ".jpeg", ".png", ".bmp", ".webp", ".mp4", ".avi", ".mov", ".mkv", ".webm", ".m4v")
if ext not in allowed:
msg = LANG_VIEWS_T(request, "alg_unsupported_ext") + ": " + ext
else:
detector_algo = None
task_type = (a.task_type or "detect").lower()
start_ok = True
if task_type == "reid":
detector_id = int(request.POST.get("detector_algorithm_id", 0) or 0)
if not detector_id:
start_ok = False
msg = LANG_VIEWS_T(request, "alg_reid_need_detector")
else:
try:
detector_algo = AlgorithmModel.objects.get(id=detector_id)
except AlgorithmModel.DoesNotExist:
start_ok = False
msg = LANG_VIEWS_T(request, "alg_reid_need_detector")
else:
if (detector_algo.task_type or "detect").lower() != "detect":
start_ok = False
msg = LANG_VIEWS_T(request, "alg_reid_detector_must_detect")
elif detector_algo.state != 1:
start_ok = False
msg = LANG_VIEWS_T(request, "alg_reid_detector_disabled")
elif not detector_algo.model_file:
start_ok = False
msg = LANG_VIEWS_T(request, "alg_no_model_file")
if start_ok:
fname = "%s_%s%s" % (uuid.uuid4().hex[:12], aid, ext)
dest = os.path.join(_test_upload_dir(), fname)
with open(dest, "wb") as out:
for chunk in f.chunks():
out.write(chunk)
from app.services.algorithm_test_service import start_test
task_id = start_test(a, dest, f.name, detector_algo=detector_algo)
data = {"task_id": task_id}
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def smallmodel_openTestStatus(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = {}
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parseGetParams(request)
task_id = (params.get("task_id") or "").strip()
if not task_id:
msg = "missing task_id"
else:
from app.services.algorithm_test_service import get_task
t = get_task(task_id)
if not t:
msg = "task not found"
else:
data = {
"task_id": t.get("id"),
"status": t.get("status"),
"progress": t.get("progress", 0),
"message": t.get("message", ""),
"report": t.get("report"),
"output_url": t.get("output_url", ""),
"output_type": t.get("output_type", ""),
"error": t.get("error", ""),
}
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def smallmodel_openTestOutput(request):
"""返回算法测试渲染结果(图片/视频),避免运行时生成的 static 文件无法通过 /static/ 访问。"""
if request.method != 'GET':
return HttpResponse(b"method not allowed", status=405)
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
return HttpResponse(__check_msg.encode("utf-8"), status=403)
params = f_parseGetParams(request)
task_id = (params.get("task_id") or "").strip()
from app.services.algorithm_test_service import resolve_output_file
fp, ctype = resolve_output_file(task_id)
if not fp:
return HttpResponse(b"not found", status=404)
try:
with open(fp, "rb") as f:
data = f.read()
except Exception:
return HttpResponse(b"read error", status=500)
resp = HttpResponse(data, content_type=ctype)
resp["Cache-Control"] = "no-store, no-cache, must-revalidate"
resp["Content-Disposition"] = 'inline; filename="%s"' % os.path.basename(fp)
return resp
def smallmodel_openTestClearTemp(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = {}
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
try:
from app.services.algorithm_test_service import clear_temp_files
data = clear_temp_files()
ret = True
msg = LANG_VIEWS_T(request, "alg_test_clear_ok")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def smallmodel_openAdd(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
fields = _parse_algo_params(params)
fields = _apply_algo_rules(fields)
ok, err = _validate_algo_fields(request, fields)
if not ok:
msg = err
elif not fields.get("name"):
msg = LANG_VIEWS_T(request, "alg_name_required")
else:
a = AlgorithmModel.objects.create(**fields)
if fields.get("is_default") == 1:
AlgorithmModel.objects.exclude(id=a.id).update(is_default=0)
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def smallmodel_openEdit(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
aid = int(params.get("id", 0))
a = AlgorithmModel.objects.get(id=aid)
fields = _parse_algo_params(params)
fields = _apply_algo_rules(fields)
ok, err = _validate_algo_fields(request, fields)
if not ok:
msg = err
else:
for k, v in fields.items():
setattr(a, k, v)
a.save()
if a.is_default == 1:
AlgorithmModel.objects.exclude(id=a.id).update(is_default=0)
# 热更新:若该算法被某路正在跑的摄像头使用,重载其 pipeline
try:
from app.analysis.manager import AnalysisManager
mgr = AnalysisManager()
for s in a.streams.all():
if mgr.is_running(s.id):
mgr.stop(s.id)
mgr.start(s)
except Exception:
pass
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def smallmodel_openDel(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
aid = int(params.get("id", 0))
a = AlgorithmModel.objects.get(id=aid)
# 先收集使用该算法的摄像头(解绑前查询,否则 update 后反向关系为空)
affected_streams = list(a.streams.values_list('id', flat=True))
# 检查是否有业务算法引用此小模型
from app.models import BizAlgorithmModel
from django.db.models import Q
ref_count = BizAlgorithmModel.objects.filter(
Q(small_model_id=aid) | Q(detector_model_id=aid)
).count()
if ref_count > 0:
raise ValueError(LANG_VIEWS_T(request, "smallmodel_in_use_by_biz"))
# 停止使用该算法的 pipeline必须在解绑前完成
try:
from app.analysis.manager import AnalysisManager
mgr = AnalysisManager()
for sid in affected_streams:
if mgr.is_running(sid):
mgr.stop(sid)
except Exception:
pass
# 解绑摄像头
StreamModel.objects.filter(algorithm_id=aid).update(algorithm=None)
a.delete()
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def _models_dir():
from app.analysis.worker_pool import get_weight_dir
return get_weight_dir()
def smallmodel_openUploadModel(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = {}
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
try:
f = request.FILES.get("file")
if not f:
msg = LANG_VIEWS_T(request, "alg_no_file")
else:
ext = os.path.splitext(f.name)[1].lower()
allowed = (".onnx", ".pt", ".xml", ".bin", ".engine", ".model", ".yaml", ".labels", ".names")
if ext and ext not in allowed:
msg = LANG_VIEWS_T(request, "alg_unsupported_ext") + ": " + ext
else:
# 文件名年月日时分秒_原文件名保留原名称前面拼时间戳避免冲突
from datetime import datetime
ts = datetime.now().strftime("%Y%m%d%H%M%S")
# 安全处理原文件名:去掉路径分隔符,保留扩展名
raw_name = os.path.basename(f.name)
# 限制总长度,避免文件名过长
name_part = os.path.splitext(raw_name)[0]
if len(name_part) > 60:
name_part = name_part[:60]
fname = "%s_%s%s" % (ts, name_part, ext)
dest = os.path.join(_models_dir(), fname)
with open(dest, "wb") as out:
for chunk in f.chunks():
out.write(chunk)
if ext == ".pt":
try:
from app.utils.ModelTrust import require_trusted_model
require_trusted_model(dest)
except Exception:
os.unlink(dest)
raise
size = os.path.getsize(dest)
# 清理旧模型文件:未被任何启用算法引用的文件
try:
_cleanup_unused_model_files(exclude=fname)
except Exception as e:
import logging
logging.getLogger("app").warning("清理旧模型文件失败: %s" % str(e))
# 相对路径
data = {
"model_file": fname,
"model_file_size": size,
"filename": f.name,
}
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def _cleanup_unused_model_files(exclude=None):
"""清理未被任何启用算法引用的模型文件(保留 exclude 指定的刚上传文件)。"""
models_dir = _models_dir()
if not os.path.isdir(models_dir):
return 0
# 收集所有算法(含禁用)引用的模型文件名,避免删除被禁用算法的模型文件
used_files = set()
for a in AlgorithmModel.objects.all():
if a.model_file:
used_files.add(os.path.basename(a.model_file))
removed = 0
allowed_ext = (".onnx", ".pt", ".xml", ".bin", ".engine", ".model", ".yaml", ".labels", ".names")
for fn in os.listdir(models_dir):
fp = os.path.join(models_dir, fn)
if not os.path.isfile(fp):
continue
ext = os.path.splitext(fn)[1].lower()
if ext not in allowed_ext:
continue
if exclude and fn == exclude:
continue
if fn in used_files:
continue
try:
os.remove(fp)
removed += 1
except Exception:
pass
return removed
def smallmodel_openProbe(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = {}
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
engine_name = (params.get("engine") or "onnxruntime").strip()
model_file = (params.get("model_file") or "").strip()
if not model_file:
msg = LANG_VIEWS_T(request, "alg_no_model_file")
else:
from app.analysis.worker_pool import resolve_model_path
abs_path = resolve_model_path(model_file)
if not abs_path:
msg = LANG_VIEWS_T(request, "alg_no_model_file")
else:
from app.analysis.engines.factory import EngineFactory, list_engines
from app.analysis.engines.base import EngineNotAvailableError
try:
task_type = (params.get("task_type") or "detect").strip().lower()
algorithm_type = (params.get("algorithm_type") or "yolo8").strip()
if task_type == "reid":
engine_name = "onnxruntime"
eng = EngineFactory.create(
engine_name,
model_file=abs_path,
task_type=task_type,
algorithm_type=algorithm_type,
)
data = eng.probe()
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except EngineNotAvailableError as e:
msg = LANG_VIEWS_T(request, "engine_not_installed") + ": " + str(e)
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def smallmodel_openEngines(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = []
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
try:
from app.analysis.engines.factory import list_engines, device_options
data = list_engines()
# 附带 device_options 便于前端直接渲染
for item in data:
item["device_options"] = device_options(item["name"])
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def smallmodel_openDetectors(request):
"""列出可用于 ReID 测试的检测小模型task_type=detect 且启用)。"""
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = []
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
try:
qs = AlgorithmModel.objects.filter(state=1, task_type="detect").order_by("-is_default", "-id")
data = [{
"id": a.id,
"name": a.name,
"model_file": a.model_file,
"inference_engine": a.inference_engine,
"algorithm_type": a.algorithm_type,
"is_default": a.is_default,
} for a in qs]
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg, "data": data})
def smallmodel_openSetActive(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
aid = int(params.get("id", 0))
a = AlgorithmModel.objects.get(id=aid)
AlgorithmModel.objects.exclude(id=aid).update(is_default=0)
a.is_default = 1
a.save()
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})
def smallmodel_openAssignStreams(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
aid = int(params.get("algorithm_id", 0))
a = AlgorithmModel.objects.get(id=aid)
stream_ids = params.get("stream_ids") or []
if isinstance(stream_ids, str):
try:
stream_ids = json.loads(stream_ids)
except Exception:
stream_ids = [s for s in stream_ids.split(",") if s]
# 先解绑所有当前使用该算法的摄像头
StreamModel.objects.filter(algorithm_id=aid).update(algorithm=None)
# 再绑新选的
restarted = []
for sid in stream_ids:
try:
s = StreamModel.objects.get(id=int(sid))
# 若该路正在跑,需重启以应用新算法
try:
from app.analysis.manager import AnalysisManager
if AnalysisManager().is_running(s.id):
AnalysisManager().stop(s.id)
restarted.append(s.id)
except Exception:
pass
s.algorithm = a
s.save()
except Exception:
pass
# 重启刚才停掉的
for sid in restarted:
try:
s = StreamModel.objects.get(id=sid)
AnalysisManager().start(s)
except Exception:
pass
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
return f_responseJson({"code": 1000 if ret else 0, "msg": msg})

85
app/views/StorageView.py Normal file
View File

@ -0,0 +1,85 @@
"""
StorageView 文件下载模块
提供文件下载功能导出日志导出配置等场景使用
"""
from app.views.ViewsBase import *
from app.utils.LanguageUtils import LANG_VIEWS_T
from django.core import signing
from django.utils.encoding import escape_uri_path
import os
DOWNLOAD_ID_SALT = "monitor.temp-download.v1"
DOWNLOAD_ID_MAX_AGE_SECONDS = 300
ALLOWED_DOWNLOAD_SUFFIXES = frozenset((
".mp4", ".wav", ".jpg", ".png", ".tar", ".xclogs", ".xcsettings", ".xcupdate", ".xcflow",
))
def _safe_temp_download_path(filename, require_exists=True):
if not filename or filename != os.path.basename(filename):
raise ValueError("invalid download file")
if os.path.splitext(filename)[1].lower() not in ALLOWED_DOWNLOAD_SUFFIXES:
raise ValueError("unsupported download file type")
base = os.path.realpath(g_config.storageTempDir)
path = os.path.realpath(os.path.join(base, filename))
if os.path.dirname(path) != base:
raise ValueError("invalid download path")
if require_exists and not os.path.isfile(path):
raise FileNotFoundError("download file not found")
return path
def issue_download_file_id(filename):
"""Return an opaque, short-lived identifier for a server-created temp file."""
_safe_temp_download_path(filename, require_exists=True)
return signing.dumps({"name": filename}, salt=DOWNLOAD_ID_SALT, compress=True)
def resolve_download_file_id(file_id):
data = signing.loads(file_id, salt=DOWNLOAD_ID_SALT, max_age=DOWNLOAD_ID_MAX_AGE_SECONDS)
if not isinstance(data, dict):
raise signing.BadSignature("invalid download id")
filename = str(data.get("name") or "")
return filename, _safe_temp_download_path(filename, require_exists=True)
def api_openInfo(request):
"""查询存储空间信息(原 Storage 模块,已移除)"""
if request.method != 'GET':
return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "msg_method_not_supported")})
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
return f_responseJson({"code": 0, "msg": __check_msg})
g_logger.info("StorageView.api_openInfo() ip:%s" % f_parseRequestIp(request))
return f_responseJson({
"code": 1000,
"msg": "ok",
"info": {
"alarmFolderSize": 0,
"recordFolderSize": 0
}
})
def api_openDownload(request):
"""Download a server-created temp file by a short-lived signed identifier."""
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
return f_responseJson({"code": 0, "msg": __check_msg})
params = f_parseGetParams(request)
file_id = params.get("file_id", "").strip()
try:
if not file_id:
raise signing.BadSignature("missing download id")
filename, filepath = resolve_download_file_id(file_id)
from django.http import FileResponse
response = FileResponse(open(filepath, mode="rb"), content_type="application/octet-stream")
response['Content-Disposition'] = "attachment; filename={};".format(escape_uri_path(filename))
response['Cache-Control'] = "no-store"
return response
except (signing.BadSignature, signing.SignatureExpired, ValueError, FileNotFoundError) as e:
g_logger.warning("StorageView.openDownload() rejected: %s" % str(e))
return f_responseJson({"code": 0, "msg": "invalid or expired download id"})

1211
app/views/StreamView.py Normal file

File diff suppressed because it is too large Load Diff

324
app/views/SystemView.py Normal file
View File

@ -0,0 +1,324 @@
import json
import shutil
import os
import threading
from app.views.ViewsBase import *
from django.shortcuts import render
from app.utils.OSSystem import OSSystem
from app.utils.GlobalUtils import g_filepath_settings_json, GlobalUtils
from framework.settings import PROJECT_VERSION, PROJECT_FLAG, PROJECT_UA, PROJECT_BUILT, PROJECT_ADMIN_START_TIMESTAMP
def f_readSettings(lang):
try:
for encoding in ["utf-8", "gbk"]:
try:
with open(g_filepath_settings_json, 'r', encoding=encoding) as f:
data = json.load(f)
# 从 languages 字典中直接获取对应语言的 oem 配置
languages = data.get("languages", {})
return languages.get(lang, {}).get("oem", {})
except Exception as e:
g_logger.error("f_readSettings() error: %s" % str(e))
return {}
except:
return {}
def f_writeSettings(lang, settings_data):
for encoding in ["utf-8", "gbk"]:
try:
with open(g_filepath_settings_json, 'r', encoding=encoding) as f:
data = json.load(f)
# 更新 languages 字典中对应语言的 oem 配置
languages = data.get("languages", {})
if lang in languages:
languages[lang]["oem"] = settings_data
with open(g_filepath_settings_json, 'w', encoding=encoding) as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return True
except Exception as e:
g_logger.error("f_writeSettings() error: %s"%str(e))
continue
def config(request):
lang = f_parseRequestLang(request)
branding = f_readSettings(lang)
cfg = g_config.to_dict()
context = {
"config": cfg,
"branding": branding
}
return render(request, 'app/system/config.html', context)
def _config_api_payload():
data = g_config.to_dict()
# 前端习惯 0/1 开关
for k in (
"autoAddStreamProxy", "isEnableLoginCaptcha", "logDebug", "isEnableUpdatePopup",
"telemetryEnabled", "updateCheckEnabled",
"isEnableMediaProxyRtmp", "autoStartMedia", "analysisSharedInference",
"recordingEnabled",
):
if k in data:
data[k] = 1 if _bool_web(data.get(k)) else 0
sip = data.get("sipServer") or {}
if "autoInviteAfterRecCateLog" in sip:
sip["autoInviteAfterRecCateLog"] = 1 if _bool_web(sip.get("autoInviteAfterRecCateLog")) else 0
data["sipServer"] = sip
return data
def _bool_web(v):
return v is True or v == 1 or str(v).strip().lower() in ("1", "true", "yes")
def api_openConfig(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'GET':
# 加载配置:同时返回基础配置 + OEM 品牌信息
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
try:
lang = f_parseRequestLang(request)
branding = f_readSettings(lang)
data = _config_api_payload()
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
res = {
"code": 1000,
"msg": msg,
"data": data,
"config": data,
"branding": branding
}
g_logger.info("SystemView.openConfig(GET) ok")
return f_responseJson(res)
except Exception as e:
msg = str(e)
else:
msg = __check_msg
elif request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
g_logger.info("SystemView.openConfig() requested")
try:
before = g_config.to_dict(include_secrets=True)
g_config.save_from_web(params)
notes = GlobalUtils.apply_runtime_config(before)
ret = True
msg = LANG_VIEWS_T(request, "syscfg_save_success")
hint_parts = []
if "adminPort" in notes:
hint_parts.append(LANG_VIEWS_T(request, "syscfg_hint_admin_port"))
if "logDebug" in notes:
hint_parts.append(LANG_VIEWS_T(request, "syscfg_hint_log_debug"))
if "zlm" in notes:
hint_parts.append(LANG_VIEWS_T(request, "syscfg_hint_zlm"))
if hint_parts:
msg = msg + " " + " ".join(hint_parts)
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
res = {
"code": 1000 if ret else 0,
"msg": msg
}
g_logger.info("SystemView.openConfig() res: %s" % str(res))
return f_responseJson(res)
def api_openSaveSettings(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
g_logger.info("SystemView.openSaveSettings() params: %s" % str(params))
try:
lang = f_parseRequestLang(request)
branding_name = str(params.get("name", "")).strip()
branding_welcome = str(params.get("welcome", "")).strip()
branding_logo_url = str(params.get("logo_url", "")).strip()
branding_bottom_name = str(params.get("bottom_name", "")).strip()
branding_author = str(params.get("author", "")).strip()
branding_author_link = str(params.get("author_link", "")).strip()
branding_check_version_download_url = str(params.get("check_version_download_url", "")).strip()
branding_is_show_author = params.get("is_show_author", False)
if isinstance(branding_is_show_author, str):
branding_is_show_author = branding_is_show_author.lower() in ['true', '1', 'yes']
settings_data = {
"name": branding_name,
"welcome": branding_welcome,
"logo_url": branding_logo_url,
"bottom_name": branding_bottom_name,
"is_show_author": branding_is_show_author,
"author": branding_author,
"author_link": branding_author_link,
"check_version_download_url": branding_check_version_download_url
}
f_writeSettings(lang, settings_data)
ret = True
msg = LANG_VIEWS_T(request, "syscfg_save_oem_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
res = {
"code": 1000 if ret else 0,
"msg": msg
}
g_logger.info("SystemView.openSaveSettings() res: %s" % str(res))
return f_responseJson(res)
def api_openExportLogs(request):
# 导出日志
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
info = {
}
request_ip = f_parseRequestIp(request)
lang = f_parseRequestLang(request)
export_dir = None
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
try:
params = f_parsePostParams(request)
g_logger.info("SystemView.openExportLogs() params:%s" % str(params))
g_gb28181SipServer.log_status()
run_errors = []
# 导出外层文件夹
export_dirname = "logs%s-%s-%s" % (PROJECT_VERSION, PROJECT_FLAG, datetime.now().strftime("%Y%m%d%H%M%S"))
export_dir = os.path.join(g_config.storageTempDir, export_dirname)
export_filename = "%s.xclogs" % export_dirname
export_filepath = os.path.join(g_config.storageTempDir, export_filename)
if not os.path.exists(export_dir):
os.makedirs(export_dir)
osSystem = OSSystem()
try:
# 压缩log文件夹->log.tar
log_dir = os.path.join(BASE_DIR, "log")
if os.path.exists(log_dir):
log_tar_filepath = os.path.join(export_dir, "log")
shutil.make_archive(log_tar_filepath, 'tar', log_dir)
except Exception as e:
run_errors.append("export log error:%s" % str(e))
# 写入config.json, settings.json
if os.path.exists(g_filepath_config_json):
dst = os.path.join(export_dir, "config.json")
shutil.copyfile(g_filepath_config_json, dst)
if os.path.exists(g_filepath_settings_json):
dst = os.path.join(export_dir, "settings.json")
shutil.copyfile(g_filepath_settings_json, dst)
if os.path.exists(g_config.mediaStartConfigPath):
dst = os.path.join(export_dir, "config.ini")
shutil.copyfile(g_config.mediaStartConfigPath, dst)
allowed_hosts_src = os.path.join(BASE_DIR, ".allowed_hosts")
if os.path.exists(allowed_hosts_src):
allowed_hosts_dst = os.path.join(export_dir, ".allowed_hosts")
shutil.copyfile(allowed_hosts_src, allowed_hosts_dst)
# 写入env.txt
export_filepath_env = os.path.join(export_dir, "env.txt")
env_f = open(export_filepath_env, 'w', encoding="utf-8")
env_f.write("name=%s\n" % PROJECT_UA)
env_f.write("built=%s\n" % PROJECT_BUILT)
env_f.write("version=%s\n" % PROJECT_VERSION)
env_f.write("flag=%s\n" % PROJECT_FLAG)
env_f.write("log_filename=%s\n" % export_filename)
env_f.write("app_start_date=%s\n" % datetime.fromtimestamp(PROJECT_ADMIN_START_TIMESTAMP).strftime('%Y-%m-%d %H:%M'))
env_f.write("current_date=%s\n" % datetime.now().strftime('%Y-%m-%d %H:%M'))
env_f.write("system=%s\n" % osSystem.getSystemName())
env_f.write("machine=%s\n" % osSystem.getMachineNode())
env_f.write("uname_a=%s\n" % osSystem.getMachineUnameA())
env_f.write("zlm.threadsLoad=%s\n" % str(g_zlm.getThreadsLoad()))
env_f.write("cpu=%s\n" % osSystem.getMachineCpu())
env_f.write("nvidia=%s\n" % osSystem.getMachineNvidia())
env_f.write("ascend=%s\n" % osSystem.getMachineAscend())
env_f.write("rknpu=%s\n" % osSystem.getMachineRknpu())
env_f.write("os=%s\n" % str(osSystem.getOSInfo()))
env_f.write("os_release=%s\n" % osSystem.getMachineOsRelease())
env_f.write("lscpu=%s\n" % osSystem.getMachineLsCpu())
env_f.close()
# 写入online.txt
export_filepath_online = os.path.join(export_dir, "online.txt")
online_f = open(export_filepath_online, 'w', encoding="utf-8")
run_info = {}
for k, v in run_info.items():
online_f.write("%s=%s\n" % (str(k), str(v)))
online_f.write("av_log=%s\n" % str(g_database.select("select * from av_log order by id desc limit 100")))
online_f.write("run_errors=%s\n" % str(run_errors))
online_f.close()
# 压缩导出文件夹
shutil.make_archive(export_filepath.replace(".xclogs", ""), 'tar', export_dir)
# 重命名为 .xclogs
tar_filepath = export_filepath.replace(".xclogs", ".tar")
if os.path.exists(tar_filepath):
shutil.move(tar_filepath, export_filepath)
from app.views.StorageView import issue_download_file_id
info["download_file_id"] = issue_download_file_id(export_filename)
info["download_filename"] = export_filename
ret = True
msg = LANG_VIEWS_T(request, "syscfg_export_log_success")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
g_logger.info("export_dir=%s" % str(export_dir))
if export_dir:
try:
if os.path.exists(export_dir):
shutil.rmtree(export_dir)
except Exception as e:
g_logger.error("e=%s" % str(e))
res = {
"code": 1000 if ret else 0,
"msg": msg,
"info": info
}
g_logger.info("SystemView.openExportLogs() res:%s" % str(res))
return f_responseJson(res)

581
app/views/UserView.py Normal file
View File

@ -0,0 +1,581 @@
import time
from app.views.ViewsBase import *
from app.utils.Utils import buildPageLabels
from django.shortcuts import render, redirect
from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout
from django.contrib.auth.models import Group, User
from app.utils.Credentials import redact_mapping
from app.utils.OSSystem import OSSystem
from io import BytesIO
from app.utils.LogUtils import LogUtils
from django.http import HttpResponse
import json
# 生成验证码start
import random
from PIL import Image, ImageDraw, ImageFont
def random_color(min_val=50, max_val=200):
"""生成随机RGB颜色"""
return (
random.randint(min_val, max_val),
random.randint(min_val, max_val),
random.randint(min_val, max_val)
)
def load_captcha_font(height):
"""跨平台字体加载优先Linux兼容字体"""
osSystem = OSSystem()
if osSystem.getSystemName() == "Windows":
font_paths = [
g_config.fontPath, # 项目内嵌字体
"C:\\Windows\\Fonts\\arial.ttf" # Windows
]
else:
font_paths = [
g_config.fontPath, # 项目内嵌字体
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" # Linux
]
for font_path in font_paths:
try:
if os.path.exists(font_path):
font_size = int(height * 0.7)
font = ImageFont.truetype(font_path, font_size)
return font,font_size
else:
raise Exception("file not exist")
except Exception as e:
g_logger.error("load_captcha_font() error,font_path=%s,e=%s"%(font_path,str(e)))
font_size = int(height * 2)
return ImageFont.load_default(),font_size # 保底方案
def generate_secure_captcha(length=4):
"""生成带干扰线的验证码图片"""
width = 120
height = 40
font,font_size = load_captcha_font(height)
image = Image.new('RGB', (width, height), (255, 255, 255))
draw = ImageDraw.Draw(image)
# 生成随机文本(排除易混淆字符)
chars = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'
captcha_text = ''.join(random.choices(chars, k=length))
# 绘制扭曲字符
x_offset = 10
for char in captcha_text:
angle = random.randint(-10, 10) # 随机旋转角度
char_img = Image.new('RGBA', (font_size, font_size), (0, 0, 0, 0))
char_draw = ImageDraw.Draw(char_img)
char_draw.text((0, 0), char, font=font, fill=random_color(0, 100))
rotated_char = char_img.rotate(angle, expand=True, resample=Image.BILINEAR)
image.paste(rotated_char, (x_offset, 5), rotated_char)
x_offset += rotated_char.width - random.randint(0, 8) # 随机间距
# 添加干扰线(核心防御)
for _ in range(4): # 干扰线数量
x1, y1 = random.randint(0, width), random.randint(0, height)
x2, y2 = random.randint(0, width), random.randint(0, height)
draw.line([x1, y1, x2, y2], fill=random_color(150, 220), width=random.choice([1, 2]))
# 添加噪点30个点
for _ in range(30):
x, y = random.randint(0, width), random.randint(0, height)
draw.point((x, y), fill=random_color(100, 200))
return captcha_text, image
# 生成验证码end
def index(request):
context = {}
return render(request, 'app/user/index.html', context)
def api_openIndex(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
data = []
pageData = {}
if request.method == 'GET':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parseGetParams(request)
page = params.get('p', 1)
page_size = params.get('ps', 10)
try:
page = int(page)
except:
page = 1
try:
page_size = int(page_size)
if page_size < 1:
page_size = 1
except:
page_size = 10
skip = (page - 1) * page_size
sql_data = ("select id,username,email,is_active,is_superuser,is_staff,date_joined,last_login "
"from auth_user order by id desc limit %s,%s")
sql_data_num = "select count(id) as count from auth_user "
count = g_database.select(sql_data_num)
if len(count) > 0:
count = int(count[0]["count"])
data = g_database.select(sql_data, [skip, page_size])
else:
count = 0
# 格式化日期字段
for d in data:
user_roles = Group.objects.filter(user__id=d["id"]).values_list("name", flat=True)
d["role"] = next(iter(user_roles), "viewer")
if d.get("date_joined"):
try:
d["date_joined"] = d["date_joined"].strftime("%Y-%m-%d %H:%M:%S")
except:
pass
else:
d["date_joined"] = ""
if d.get("last_login"):
try:
d["last_login"] = d["last_login"].strftime("%Y-%m-%d %H:%M:%S")
except:
pass
else:
d["last_login"] = ""
page_num = int(count / page_size)
if count % page_size > 0:
page_num += 1
pageLabels = buildPageLabels(page=page, page_num=page_num, lang=f_parseRequestLang(request))
pageData = {
"page": page,
"page_size": page_size,
"page_num": page_num,
"count": count,
"pageLabels": pageLabels
}
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
res = {
"code": 1000 if ret else 0,
"msg": msg,
"data": data,
"pageData": pageData
}
return f_responseJson(res)
def api_openAdd(request):
__ret = False
__msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
g_logger.info("UserView.openAdd() params:%s" % str(redact_mapping(params)))
try:
login_user = f_sessionReadUser(request)
if not login_user:
raise Exception(LANG_VIEWS_T(request, "msg_not_logged_in"))
username = params.get("username", "").strip()
email = params.get("email", "").strip()
password = params.get("password", "").strip()
is_active = params.get("is_active")
is_active = int(is_active)
role = (params.get("role") or "viewer").strip()
if role not in ("system_admin", "algorithm_admin", "operator", "viewer"):
role = "viewer"
if username == "":
raise Exception(LANG_VIEWS_T(request, "user_username_required"))
if email == "":
raise Exception(LANG_VIEWS_T(request, "user_email_required"))
if len(password) < 6 or len(password) > 16:
raise Exception(LANG_VIEWS_T(request, "user_password_length"))
if User.objects.filter(username=username).exists():
raise Exception(LANG_VIEWS_T(request, "user_username_exists"))
else:
now = datetime.now()
user = User()
user.username = username
user.set_password(password)
user.email = email
user.date_joined = now
user.is_superuser = 0 # 表单创建均为非超级管理员
user.is_staff = 1
user.is_active = is_active
user.save()
user.groups.set([Group.objects.get(name=role)])
if user.id > 0:
# 添加日志
lang = f_parseRequestLang(request)
LogUtils.add_user_log(login_user.get("id"), username, LogUtils.LOG_TYPE_ADD, lang=lang)
__ret = True
__msg = LANG_VIEWS_T(request, "msg_add_success")
else:
__msg = LANG_VIEWS_T(request, "msg_add_failed")
except Exception as e:
__msg = str(e)
else:
__msg = __check_msg
else:
__msg = LANG_VIEWS_T(request, "msg_method_not_supported")
res = {
"code": 1000 if __ret else 0,
"msg": __msg
}
g_logger.info("UserView.openAdd() res=%s" % str(res))
return f_responseJson(res)
def api_openEdit(request):
__ret = False
__msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
g_logger.info("UserView.openEdit() params:%s" % str(redact_mapping(params)))
try:
login_user = f_sessionReadUser(request)
if not login_user:
raise Exception(LANG_VIEWS_T(request, "msg_not_logged_in"))
user_id = params.get("id") # 被操作用户id
is_active = params.get("is_active")
username = params.get("username", "").strip()
email = params.get("email", "").strip()
new_password = params.get("new_password", "")
re_password = params.get("re_password", "")
user_id = int(user_id)
is_active = int(is_active)
role = (params.get("role") or "").strip()
if username == "":
raise Exception(LANG_VIEWS_T(request, "user_username_required"))
if email == "":
raise Exception(LANG_VIEWS_T(request, "user_email_required"))
if re_password == "" and new_password == "":
pass
# 未修改密码
else:
# 修改了密码
if new_password == "":
raise Exception(LANG_VIEWS_T(request, "user_new_password_required"))
if re_password == "":
raise Exception(LANG_VIEWS_T(request, "user_confirm_password_required"))
if new_password != re_password:
raise Exception(LANG_VIEWS_T(request, "user_password_mismatch"))
if len(new_password) < 6 or len(new_password) > 16:
raise Exception(LANG_VIEWS_T(request, "user_new_password_length"))
user = User.objects.filter(id=user_id).first()
if user:
# 验证要修改的用户名是否已经存在start
if user.username == username:
pass
# 用户名未做修改
else:
filter_username = g_database.select(
"select count(1) as count from auth_user where id!=%s and username=%s",
[user_id, username])
filter_username_count = int(filter_username[0]["count"])
if filter_username_count > 0:
raise Exception(LANG_VIEWS_T(request, "user_new_username_exists"))
user.username = username # 修改了用户名
# 验证要修改的用户名是否已经存在end
if re_password == "" and new_password == "":
pass
else:
user.set_password(new_password) # 修改了密码
user.email = email
user.is_active = is_active
user.save()
if role in ("system_admin", "algorithm_admin", "operator", "viewer"):
user.groups.set([Group.objects.get(name=role)])
# 添加日志
lang = f_parseRequestLang(request)
LogUtils.add_user_log(login_user.get("id"), username, LogUtils.LOG_TYPE_EDIT, lang=lang)
__ret = True
__msg = LANG_VIEWS_T(request, "msg_edit_success")
else:
raise Exception(LANG_VIEWS_T(request, "msg_data_not_exist"))
except Exception as e:
__msg = str(e)
else:
__msg = __check_msg
else:
__msg = LANG_VIEWS_T(request, "msg_method_not_supported")
res = {
"code": 1000 if __ret else 0,
"msg": __msg
}
g_logger.info("UserView.openEdit() res=%s" % str(res))
return f_responseJson(res)
def api_openDel(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
if request.method == 'POST':
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parsePostParams(request)
try:
login_user = f_sessionReadUser(request)
if not login_user:
raise Exception(LANG_VIEWS_T(request, "msg_not_logged_in"))
user_id = int(params.get("id"))
if not user_id:
raise Exception(LANG_VIEWS_T(request, "user_request_params_invalid"))
login_user_id = int(login_user.get("id"))
if login_user_id == user_id:
raise Exception(LANG_VIEWS_T(request, "user_super_admin_no_delete_self"))
user = User.objects.filter(id=user_id)
if len(user) > 0:
user = user[0]
if user.is_superuser == 1:
raise Exception(LANG_VIEWS_T(request, "user_super_admin_no_delete"))
else:
if user.delete():
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
else:
msg = LANG_VIEWS_T(request, "msg_failed_to_delete")
else:
raise Exception(LANG_VIEWS_T(request, "msg_data_not_exist"))
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
res = {
"code": 1000 if ret else 0,
"msg": msg
}
g_logger.info("UserView.openDel() res=%s" % str(res))
return f_responseJson(res)
def api_openInfo(request):
"""获取单条用户详情"""
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
info = {}
if request.method == "GET":
__check_ret, __check_msg = f_checkRequestSafe(request)
if __check_ret:
params = f_parseGetParams(request)
user_id = params.get("id", "")
if not user_id:
msg = LANG_VIEWS_T(request, "user_id_required")
else:
try:
user_id = int(user_id)
user = User.objects.filter(id=user_id).first()
if user:
info = {
"id": user.id,
"username": user.username,
"email": user.email,
"is_active": user.is_active,
"is_superuser": user.is_superuser,
"is_staff": user.is_staff,
"date_joined": user.date_joined.strftime("%Y-%m-%d %H:%M:%S") if user.date_joined else "",
"last_login": user.last_login.strftime("%Y-%m-%d %H:%M:%S") if user.last_login else ""
}
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
else:
msg = LANG_VIEWS_T(request, "user_not_exist")
except Exception as e:
msg = str(e)
else:
msg = __check_msg
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
res = {
"code": 1000 if ret else 0,
"msg": msg,
"info": info
}
g_logger.info("UserView.openInfo() res=%s" % str(res))
return f_responseJson(res)
def api_openCaptcha(request):
"""生成验证码图片视图"""
# 生成验证码
text,image = generate_secure_captcha()
# 存储到session
cur_timestamp = int(time.time())
request.session[g_session_key_captcha] = {
"captcha_text": text,
"captcha_create_timestamp": cur_timestamp, # 创建秒级时间戳
}
# 创建内存流输出
stream = BytesIO()
image.save(stream, 'PNG')
return HttpResponse(stream.getvalue(), content_type='image/png')
def login(request):
context = {
"projectVersion": PROJECT_VERSION,
"projectFlag": PROJECT_FLAG
}
if request.method == 'POST':
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
params = f_parsePostParams(request)
username = (params.get("username") or params.get("username_s") or "").strip()
password = (params.get("password") or params.get("password_s") or "").strip()
captcha = params.get("captcha", None)
try:
if g_config.isEnableLoginCaptcha:
if not captcha:
raise Exception(LANG_VIEWS_T(request, "user_captcha_missing"))
# 开启了登录验证码功能
session_captcha = request.session.get(g_session_key_captcha, None)
if not session_captcha:
raise Exception(LANG_VIEWS_T(request, "user_captcha_not_found"))
if session_captcha:
captcha_text = session_captcha.get("captcha_text", "")
captcha_create_timestamp = session_captcha.get("captcha_create_timestamp", 0)
cur_timestamp = int(time.time())
# 验证码过期判断
if (cur_timestamp - captcha_create_timestamp) > 300:
raise Exception(LANG_VIEWS_T(request, "user_captcha_expired"))
# 验证码相同判断
if captcha_text != captcha:
raise Exception(LANG_VIEWS_T(request, "user_captcha_incorrect"))
if username and password:
user = User.objects.filter(username=username).first()
if user:
if user.is_active:
authenticated_user = authenticate(request, username=username, password=password)
if authenticated_user is not None:
auth_login(request, authenticated_user)
user.first_name = "cec=0"
user.last_login = datetime.now()
user.save()
# 保留兼容旧模板的数据;鉴权和授权以 request.user 为准。
request.session[g_session_key_user] = {
"id": user.id,
"username": username,
"email": user.email,
"is_superuser": user.is_superuser,
"is_active": user.is_active,
"is_staff": user.is_staff,
"log_debug": 1 if g_config.logDebug else 0,
}
request.session.pop(g_session_key_captcha, None)
# 记录登录日志
LogUtils.add_log(
user_id=user.id,
log_type=LogUtils.LOG_TYPE_LOGIN,
content=f"用户登录[{username}]",
state=LogUtils.STATE_SUCCESS
)
ret = True
msg = LANG_VIEWS_T(request, "user_login_success")
else:
continuous_error_count = 0
try:
vals = user.first_name.split(",")
for val in vals:
array = val.split("=")
if len(array) == 2:
if array[0] == "cec":
continuous_error_count = int(array[1])
except:
pass
continuous_error_count += 1
if continuous_error_count > 6:
is_active = False
msg = LANG_VIEWS_T(request, "user_password_error_lock") % continuous_error_count
else:
is_active = True
msg = LANG_VIEWS_T(request, "user_password_error_count") % continuous_error_count
user.is_active = is_active
user.first_name = "cec=%d"%continuous_error_count
user.save()
else:
msg = LANG_VIEWS_T(request, "user_account_locked")
else:
msg = LANG_VIEWS_T(request, "user_not_registered")
else:
msg = LANG_VIEWS_T(request, "msg_invalid_parameter")
except Exception as e:
msg = str(e)
res = {
"code": 1000 if ret else 0,
"msg": msg
}
return f_responseJson(res)
else:
context["isEnableLoginCaptcha"] = 1 if g_config.isEnableLoginCaptcha else 0
return render(request, 'app/user/login.html', context)
def logout(request):
# 记录退出登录日志
if request.session.has_key(g_session_key_user):
user_info = request.session.get(g_session_key_user)
user_id = user_info.get('id', 0)
username = user_info.get('username', '未知用户')
# 记录日志
if user_id:
LogUtils.add_log(
user_id=user_id,
log_type=LogUtils.LOG_TYPE_LOGOUT,
content=f"用户退出[{username}]",
state=LogUtils.STATE_SUCCESS
)
del request.session[g_session_key_user]
if request.session.has_key(g_session_key_captcha):
del request.session[g_session_key_captcha]
auth_logout(request)
return redirect("/login")

50
app/views/VersionView.py Normal file
View File

@ -0,0 +1,50 @@
from app.views.ViewsBase import *
from django.shortcuts import render
from framework.settings import PROJECT_VERSION, PROJECT_FLAG
from app.utils.LanguageUtils import GSettingsLanguages
def index(request):
context = {
"project_version": PROJECT_VERSION,
"project_flag": PROJECT_FLAG,
}
return render(request, 'app/version/index.html', context)
def api_openCheckVersion(request):
ret = False
msg = LANG_VIEWS_T(request, "msg_unknown_error")
lang = f_parseRequestLang(request)
info = {
"historyVersionUrl": GSettingsLanguages.get(lang, {}).get("oem", {}).get('check_version_download_url', '')
}
if request.method == 'GET':
__online, __state, __msg, __info = CheckServerUtils.checkVersion(
request_ip=None, peer_ip=None, peer_port=None, lang=lang
)
if __online:
if __state:
info["version"] = __info.get("version")
info["pubdate"] = __info.get("pubdate")
info["updateContent"] = __info.get("updateContent", "").split("\\n")
info["historyVersionUrl"] = __info.get("historyVersionUrl", "")
info["url"] = __info.get("url", "")
ret = True
msg = LANG_VIEWS_T(request, "msg_success")
else:
msg = LANG_VIEWS_T(request, "version_no_new_version")
else:
msg = LANG_VIEWS_T(request, "version_check_failed")
else:
msg = LANG_VIEWS_T(request, "msg_method_not_supported")
res = {
"code": 1000 if ret else 0,
"msg": msg,
"info": info
}
return f_responseJson(res)

136
app/views/ViewsBase.py Normal file
View File

@ -0,0 +1,136 @@
from app.utils.GlobalUtils import *
from app.utils.LanguageUtils import LANG_VIEWS_T, GSettingsLangDefault
import json
from django.http import HttpResponse
def f_parseGetParams(request):
params = {}
try:
for k in request.GET:
params.__setitem__(k, request.GET.get(k))
except Exception as e:
params = {}
return params
def f_parsePostParams(request):
params = {}
for k in request.POST:
params.__setitem__(k, request.POST.get(k))
# 接收json方式上传的参数
if not params:
try:
params = request.body.decode('utf-8')
params = json.loads(params)
except:
params = {}
return params
def f_parseRequestLang(request):
# v5.006 新增
request_lang = None
# 1. 最高优先级获取GET或POST的lang参数
if request.method == 'GET':
params = f_parseGetParams(request)
lang = params.get('lang', '').strip()
if lang:
request_lang = lang
elif request.method == 'POST':
params = f_parsePostParams(request)
lang = params.get('lang', '').strip()
if lang:
request_lang = lang
if not request_lang:
# 2. 次优先级session中的语言设置
if hasattr(request, 'session'):
request_lang = request.session.get('lang', GSettingsLangDefault)
if not request_lang:
# 3. 最低优先级:系统默认语言
request_lang = GSettingsLangDefault
return request_lang
def f_parseRequestIp(request):
try:
if request.method == 'GET':
params = f_parseGetParams(request)
ip = params.get('request_ip', '').strip()
if ip:
return ip
elif request.method == 'POST':
params = f_parsePostParams(request)
ip = params.get('request_ip', '').strip()
if ip:
return ip
host = request.get_host()
ip = host.split(':')[0]
except Exception as e:
g_logger.error("f_parseRequestIp() error: %s"%str(e))
ip = "0.0.0.0"
return ip
def f_parsePeerIp(request):
try:
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR') # 备用方案
except Exception as e:
g_logger.error("f_parsePeerIp() error: %s"%str(e))
ip = "0.0.0.0"
return ip
def f_parsePeerPort(request):
try:
port = int(request.get_port())
except Exception as e:
g_logger.error("f_parsePeerPort() error: %s"%str(e))
port = 0
return port
def f_sessionReadUser(request):
auth_user = getattr(request, "user", None)
if auth_user is not None and auth_user.is_authenticated:
return {
"id": auth_user.id,
"username": auth_user.username,
"email": auth_user.email,
"is_superuser": auth_user.is_superuser,
"is_active": auth_user.is_active,
"is_staff": auth_user.is_staff,
}
user = request.session.get(g_session_key_user)
return user
def f_sessionReadUserId(request):
try:
user_id = f_sessionReadUser(request).get("id")
except:
user_id = 0
return user_id
def f_checkRequestSafe(request):
"""Browser management APIs require an authenticated session.
The historical ``Safe`` header bypass has intentionally been removed. The
function name is retained to avoid a broad, risky rewrite of every view.
"""
user_id = f_sessionReadUserId(request)
if user_id:
return True, LANG_VIEWS_T(request, "msg_success")
return False, LANG_VIEWS_T(request, "msg_safe_verify_error")
def f_responseJson(res):
def json_dumps_default(obj):
if hasattr(obj, 'isoformat'):
return obj.isoformat()
else:
raise TypeError
return HttpResponse(json.dumps(res, default=json_dumps_default), content_type="application/json")
def f_dbReadStreamData():
data = g_database.select("select * from av_stream order by id desc")
return data

0
app/views/__init__.py Normal file
View File

43
config.json Normal file
View File

@ -0,0 +1,43 @@
{
"host": "0.0.0.0",
"adminPort": 10001,
"mediaHttpPort": 10002,
"mediaRtspPort": 10554,
"mediaRtmpPort": 10935,
"isEnableMediaProxyRtmp": true,
"mediaStartPath": "zlm\\bin.x86.windows10\\monitor_zlm.exe",
"mediaStartConfigPath": "zlm\\bin.x86.windows10\\config.ini",
"autoStartMedia": true,
"ffmpeg": "ffmpeg",
"fontPath": "static\\fonts\\tsimhei.ttf",
"uploadDir": "static\\upload",
"storageDir": "static\\storage",
"autoAddStreamProxySleep": 16,
"autoAddStreamProxy": true,
"logDebug": true,
"isEnableLoginCaptcha": false,
"isEnableUpdatePopup": false,
"telemetryEnabled": false,
"updateCheckEnabled": false,
"analysisTargetFps": 5,
"analysisConfThreshold": 0.4,
"analysisProcessMode": 1,
"analysisSharedInference": true,
"analysisInferenceWorkers": 2,
"recordingEnabled": false,
"recordingSegmentSeconds": 600,
"recordingRetainDays": 7,
"recordingRetainGb": 0.0,
"sipServer": {
"sipServerIp": "192.168.1.7",
"sipServerPort": 15060,
"sipTransferMode": 0,
"sipServerId": "34020000009000009999",
"sipServerRealm": "3402000000",
"sipServerTimeout": 300,
"sipServerExpiry": 1800,
"rtpTransferMode": 0,
"rtpTransferAudioType": 0,
"autoInviteAfterRecCateLog": true
}
}

View File

@ -0,0 +1,38 @@
# Replace the host name and certificate paths before use.
upstream monitor_app {
server 127.0.0.1:10001;
}
server {
listen 443 ssl http2;
server_name monitor.example.invalid;
ssl_certificate /etc/ssl/monitor/fullchain.pem;
ssl_certificate_key /etc/ssl/monitor/privkey.pem;
client_max_body_size 1024m;
location /static/ {
alias /opt/monitor/staticfiles/;
access_log off;
}
# Model files and generated private artifacts are never served as static files.
location /upload/ {
deny all;
}
location / {
proxy_pass http://monitor_app;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
}
}
server {
listen 80;
server_name monitor.example.invalid;
return 301 https://$host$request_uri;
}

0
framework/__init__.py Normal file
View File

16
framework/asgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
ASGI config for framework project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'framework.settings')
application = get_asgi_application()

175
framework/settings.py Normal file
View File

@ -0,0 +1,175 @@
"""
Django settings for framework project.
Generated by 'django-admin startproject' using Django 4.2.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.2/ref/settings/
"""
import time
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
PROJECT_UA = "monitor"
PROJECT_BUILT = "monitor built on 2026/08/06"
PROJECT_VERSION = "1.003"
PROJECT_FLAG = "monitor" # monitor
PROJECT_ADMIN_START_TIMESTAMP = int(time.time()) # 软件启动时间戳(秒单位)
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = get_runtime_secret("django_secret_key")
MONITOR_INTERNAL_API_SECRET = get_runtime_secret("internal_api_secret")
def env_bool(name, default=False):
value = os.environ.get(name)
if value is None:
return default
return value.strip().lower() in ("1", "true", "yes", "on")
def env_list(name, default=""):
return [item.strip() for item in os.environ.get(name, default).split(",") if item.strip()]
# 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)
ALLOWED_HOSTS = env_list("MONITOR_ALLOWED_HOSTS", "127.0.0.1,localhost")
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'app'
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.middleware.gzip.GZipMiddleware', # 启用 Gzip 压缩,提升响应性能
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
"app.middleware.SimpleMiddleware", # 拦截器
]
ROOT_URLCONF = 'framework.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
os.path.join(BASE_DIR,'templates')
],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'django.template.context_processors.csrf',
'app.context_processors.lang_processor',
],
},
},
]
WSGI_APPLICATION = 'framework.wsgi.application'
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'monitor.sqlite3',
# SQLite 性能优化:见下方 connection_created 信号中的 PRAGMA 设置
'OPTIONS': {
'timeout': 20, # Python 端连接超时(秒)
},
}
}
# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/2.0/topics/i18n/
#LANGUAGE_CODE = 'zh-hans'
#LANGUAGE_CODE = 'en'
#TIME_ZONE = 'UTC'
USE_I18N = True # 启用国际化
# USE_L10N = True # 启用本地化(如日期、数字格式)
USE_TZ = False
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.0/howto/static-files/
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
""" 静态资源DEBUG=Truestaticfiles 从 STATICFILES_DIRS 直接提供) """
STATICFILES_DIRS = (
os.path.join(BASE_DIR, "static"),
)
SESSION_COOKIE_NAME = 'MonitorSessionID'
SESSION_EXPIRE_AT_BROWSER_CLOSE=False #会话cookie可以在用户浏览器中保持有效期 True关闭浏览器则Cookie失效。
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# 防止暴力破解
SESSION_COOKIE_AGE=7*24*60*60 # session过期单位 7天=7*24*60*601小时=1*60*60
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_SAMESITE = 'Lax'
CSRF_TRUSTED_ORIGINS = env_list("MONITOR_CSRF_TRUSTED_ORIGINS")
X_FRAME_OPTIONS = 'SAMEORIGIN'
MONITOR_HTTPS = env_bool("MONITOR_HTTPS", False)
SESSION_COOKIE_SECURE = MONITOR_HTTPS
CSRF_COOKIE_SECURE = MONITOR_HTTPS
SECURE_SSL_REDIRECT = MONITOR_HTTPS and env_bool("MONITOR_SSL_REDIRECT", True)
SECURE_HSTS_SECONDS = 31536000 if MONITOR_HTTPS else 0
SECURE_HSTS_INCLUDE_SUBDOMAINS = MONITOR_HTTPS
SECURE_HSTS_PRELOAD = MONITOR_HTTPS
SECURE_CONTENT_TYPE_NOSNIFF = True
if env_bool("MONITOR_TRUST_PROXY_HEADERS", False):
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Multipart model files are streamed to disk; ordinary request bodies stay bounded.
DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("MONITOR_MAX_REQUEST_MEMORY", 16 * 1024 * 1024))
FILE_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("MONITOR_FILE_MEMORY_THRESHOLD", 2 * 1024 * 1024))

35
framework/urls.py Normal file
View File

@ -0,0 +1,35 @@
"""
URL configuration for framework project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include, re_path
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.views.static import serve
from django.conf import settings
import os
urlpatterns = [
# path('admin/', admin.site.urls),
# path(r'app/', include('app.urls')),
path(r'', include('app.urls')),
]
if settings.DEBUG:
urlpatterns += [
re_path(r'^upload/(?P<path>.*)$', serve,
{'document_root': os.path.join(settings.BASE_DIR, 'static', 'upload')}),
]
urlpatterns += staticfiles_urlpatterns()

16
framework/wsgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
WSGI config for framework project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'framework.settings')
application = get_wsgi_application()

1069
language-zh.json Normal file

File diff suppressed because it is too large Load Diff

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

21
manage.py Normal file
View File

@ -0,0 +1,21 @@
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'framework.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

58
manage.spec Normal file
View File

@ -0,0 +1,58 @@
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(
['manage.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='monitor_admin',
icon='logo.png',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
contents_directory='.',
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='manage',
)

26
requirements-linux.txt Normal file
View File

@ -0,0 +1,26 @@
# · Linux 直接依赖(不含二级传递库)
# 安装: pip install -r requirements-linux.txt
Django==5.0.4
psutil==5.9.1
requests==2.28.2
pyinstaller==6.11.1
xlrd==1.2.0
openpyxl==3.1.2
onvif_zeep==0.2.12
pillow==9.5.0
cryptography==46.0.4
gunicorn>=23.0.0
# 视频分析
opencv-python==4.10.0.84
numpy==1.26.4
onnxruntime==1.19.2
ultralytics>=8.0.0
torch>=2.0.0
# 大模型OpenAI 兼容 API
openai>=2.0.0
# OpenVINO 推理引擎(小模型 inference_engine=openvino
openvino>=2024.0.0

View File

@ -0,0 +1,85 @@
# Python 3.12.13 / Windows x64. Install torch and torchvision from the official CPU index first.
# Captured after pip check. See docs/LOCAL_STARTUP.md for the two-step install command.
altgraph==0.17.5
annotated-types==0.8.0
anyio==4.14.2
asgiref==3.12.1
attrs==26.1.0
certifi==2026.7.22
cffi==2.1.1
charset-normalizer==3.5.1
coloredlogs==15.0.1
contourpy==1.3.3
cryptography==46.0.4
cycler==0.12.1
Django==5.0.4
et_xmlfile==2.0.0
filelock==3.32.3
flatbuffers==25.12.19
fonttools==4.63.0
fsspec==2026.7.0
h11==0.16.0
httpcore==1.0.9
httpcore2==2.12.0
httpx==0.28.1
httpx2==2.12.0
humanfriendly==10.0
idna==3.19
imageio-ffmpeg==0.6.0
isodate==0.7.2
Jinja2==3.1.6
jiter==0.16.0
kiwisolver==1.5.1
lxml==6.1.2
MarkupSafe==3.0.3
matplotlib==3.11.1
mpmath==1.3.0
networkx==3.6.1
numpy==1.26.4
nvidia-ml-py==13.610.43
onnxruntime==1.19.2
onvif_zeep==0.2.12
openai==3.6.0
opencv-python==4.10.0.84
openpyxl==3.1.2
openvino==2026.3.1
openvino-telemetry==2025.2.0
packaging==26.3
pefile==2023.2.7
pillow==10.4.0
platformdirs==4.11.5
polars==1.44.1
polars-runtime-32==1.44.1
protobuf==7.36.0
psutil==5.9.8
pycparser==3.0
pydantic==2.13.5
pydantic_core==2.46.5
pyinstaller==6.11.1
pyinstaller-hooks-contrib==2026.7
pyparsing==3.3.2
pyreadline3==3.5.6
python-dateutil==2.9.0.post0
pywin32-ctypes==0.2.3
PyYAML==6.0.3
requests==2.28.2
requests-file==3.0.1
requests-toolbelt==1.0.0
setuptools==78.1.0
six==1.17.0
sniffio==1.3.1
sqlparse==0.6.0
sympy==1.14.0
torch==2.13.0+cpu
torchvision==0.28.0+cpu
truststore==0.10.4
typing-inspection==0.4.4
typing_extensions==4.16.0
tzdata==2026.3
ultralytics==8.4.135
ultralytics-platform==0.1.20
ultralytics-thop==2.1.6
urllib3==1.26.20
waitress==3.0.2
xlrd==1.2.0
zeep==4.3.3

18
requirements-windows.txt Normal file
View File

@ -0,0 +1,18 @@
Django==5.0.4
psutil==5.9.8
requests==2.28.2
pyinstaller==6.11.1
xlrd==1.2.0
openpyxl==3.1.2
onvif_zeep==0.2.12
pillow==10.4.0
cryptography==46.0.4
waitress>=3.0.2
opencv-python==4.10.0.84
numpy==1.26.4
onnxruntime==1.19.2
ultralytics>=8.0.0
torch>=2.0.0
openai>=2.0.0
openvino>=2024.0.0
imageio-ffmpeg==0.6.0

49
scripts/check-local.py Normal file
View File

@ -0,0 +1,49 @@
"""Offline local checks. No real model, camera, credential or login is exercised."""
import os
import subprocess
from local_server import ROOT, PYTHON, backup, local_environment
def main():
os.chdir(ROOT)
env = local_environment()
subprocess.run([str(PYTHON), '-m', 'pip', 'check'], env=env, check=True)
# AppConfig.ready may migrate data even during manage.py check.
backup()
checks = [
['-c', '''import django, cv2, numpy as np, torch, torchvision, onnxruntime, openvino as ov, PIL, psutil
print('Django', django.get_version())
print('OpenCV', cv2.__version__, 'NumPy', np.__version__)
print('PyTorch', torch.__version__, 'torchvision', torchvision.__version__)
assert torch.version.cuda is None and not torch.cuda.is_available()
assert (torch.ones(2, 2) @ torch.ones(2, 2)).tolist() == [[2., 2.], [2., 2.]]
assert cv2.resize(np.zeros((4, 4, 3), dtype=np.uint8), (2, 2)).shape == (2, 2, 3)
print('ONNX Runtime', onnxruntime.__version__, onnxruntime.get_available_providers())
assert 'CPUExecutionProvider' in onnxruntime.get_available_providers()
core = ov.Core()
param = ov.opset13.parameter([1], np.float32)
model = ov.Model([ov.opset13.relu(param)], [param])
compiled = core.compile_model(model, 'CPU')
assert float(compiled([np.array([-1], dtype=np.float32)])[0][0]) == 0.0
print('OpenVINO', ov.__version__, 'CPU inference OK')
from openvino_telemetry import Telemetry
assert Telemetry().consent is False, 'OpenVINO telemetry must be disabled'
from ultralytics import YOLO
from ultralytics.utils import ONLINE, AUTOINSTALL
assert ONLINE is False and AUTOINSTALL is False
print('OpenVINO telemetry disabled; Ultralytics offline, auto-install disabled')
print('Pillow', PIL.__version__, 'psutil', psutil.__version__)
'''],
[str(ROOT / 'manage.py'), 'check'],
['-m', 'compileall', '-q', 'app', 'framework', 'scripts', 'tests', 'manage.py'],
['-m', 'unittest', 'discover', '-s', 'tests', '-v'],
]
for args in checks:
subprocess.run([str(PYTHON), *args], cwd=ROOT, env=env, check=True)
subprocess.run([env['MONITOR_FFMPEG'], '-version'], check=True)
print('All local checks passed (no camera or stored model loaded).')
if __name__ == '__main__':
main()

180
scripts/local_server.py Normal file
View File

@ -0,0 +1,180 @@
"""Start/stop the loopback-only development server without modifying saved config."""
import argparse
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import time
from datetime import datetime
from urllib.request import build_opener, ProxyHandler
import psutil
ROOT = Path(__file__).resolve().parents[1]
PYTHON = ROOT / '.venv' / 'Scripts' / 'python.exe'
MANAGE = ROOT / 'manage.py'
RUNTIME = ROOT / '.runtime'
PID_FILE = RUNTIME / 'local-server.json'
URL = 'http://127.0.0.1:10001/login'
def local_environment():
import imageio_ffmpeg
(RUNTIME / 'ultralytics').mkdir(parents=True, exist_ok=True)
ffmpeg = Path(imageio_ffmpeg.__file__).parent / 'binaries'
executables = list(ffmpeg.glob('ffmpeg*.exe'))
if len(executables) != 1 or not executables[0].resolve().is_relative_to(ROOT / '.venv'):
raise RuntimeError('Expected one FFmpeg executable bundled inside the project virtualenv')
env = os.environ.copy()
env.update({
'DJANGO_SETTINGS_MODULE': 'framework.settings',
'MONITOR_DEBUG': 'true',
'MONITOR_ALLOWED_HOSTS': '127.0.0.1,localhost',
'MONITOR_HTTPS': 'false',
'MONITOR_SERVICE_MODE': 'disabled',
'MONITOR_BOOTSTRAP_SERVICES': 'false',
'MONITOR_TELEMETRY_ENDPOINT': '',
'MONITOR_UPDATE_ENDPOINT': '',
'MONITOR_FFMPEG': str(executables[0].resolve()),
'PYTHONUNBUFFERED': '1',
'YOLO_CONFIG_DIR': str(RUNTIME / 'ultralytics'),
'YOLO_OFFLINE': 'true',
'YOLO_AUTOINSTALL': 'false',
# OpenVINO 2026 initializes import telemetry with disable_in_ci=True.
# Use its upstream unattended-validation opt-out in this child only.
'CI': 'true',
})
return env
def backup():
target = RUNTIME / 'backups' / datetime.now().strftime('%Y%m%d-%H%M%S-%f')
target.mkdir(parents=True)
for name in ('monitor.sqlite3', '.runtime-secrets.json', 'config.json', 'settings.json'):
source = ROOT / name
if source.is_file():
shutil.copy2(source, target / name)
print('Backup:', target)
return target
def is_project_process(proc):
try:
args = proc.cmdline()
return (len(args) >= 5 and Path(args[0]).resolve() == PYTHON.resolve()
and Path(args[1]).resolve() == MANAGE.resolve()
and args[2:] == ['runserver', '127.0.0.1:10001', '--noreload'])
except (psutil.Error, OSError):
return False
def recorded_process():
if not PID_FILE.exists():
return None
data = json.loads(PID_FILE.read_text(encoding='utf-8'))
if Path(data['root']).resolve() != ROOT:
raise RuntimeError('Recorded project path mismatch; refusing to manage this process')
try:
proc = psutil.Process(data['pid'])
if abs(proc.create_time() - data['create_time']) > 0.01:
return None
if not is_project_process(proc):
raise RuntimeError('PID ownership mismatch; refusing to manage this process')
return proc
except psutil.NoSuchProcess:
return None
def port_open():
with socket.socket() as sock:
sock.settimeout(1)
return sock.connect_ex(('127.0.0.1', 10001)) == 0
def save_process(proc):
PID_FILE.write_text(json.dumps({'pid': proc.pid, 'create_time': proc.create_time(),
'root': str(ROOT)}, indent=2), encoding='utf-8')
def discover_project_listener():
"""Recover ownership if the PID file was lost, including the Windows venv launcher."""
for conn in psutil.net_connections(kind='tcp'):
if (conn.status != psutil.CONN_LISTEN or not conn.pid
or conn.laddr.ip != '127.0.0.1' or conn.laddr.port != 10001):
continue
try:
listener = psutil.Process(conn.pid)
candidates = [listener, *listener.parents()]
for candidate in reversed(candidates):
if is_project_process(candidate):
return candidate
except psutil.Error:
continue
return None
def start():
RUNTIME.mkdir(exist_ok=True)
proc = recorded_process()
if proc and port_open():
print('Already running:', URL, 'PID:', proc.pid)
return
if port_open() and not proc:
owner = discover_project_listener()
if owner:
save_process(owner)
print('Reused project server:', URL, 'PID:', owner.pid)
return
if port_open() or proc:
raise RuntimeError('Port 10001 is occupied or recorded server is still starting; no process was stopped')
backup()
with open(RUNTIME / 'local-server.stdout.log', 'ab') as out, open(RUNTIME / 'local-server.stderr.log', 'ab') as err:
child = subprocess.Popen(
[str(PYTHON), str(MANAGE), 'runserver', '127.0.0.1:10001', '--noreload'],
cwd=str(ROOT), env=local_environment(), stdin=subprocess.DEVNULL,
stdout=out, stderr=err, creationflags=subprocess.CREATE_NO_WINDOW,
)
info = psutil.Process(child.pid)
save_process(info)
opener = build_opener(ProxyHandler({}))
for _ in range(60):
if child.poll() is not None:
raise RuntimeError('Server exited; inspect .runtime/local-server.stderr.log')
try:
with opener.open(URL, timeout=2) as response:
if response.status == 200:
print('Ready:', URL, 'PID:', child.pid)
return
except OSError:
pass
time.sleep(1)
raise RuntimeError('Server readiness timeout; process left running for diagnosis')
def stop():
proc = recorded_process()
if not proc:
print('No recorded project server is running')
return
children = proc.children(recursive=True)
proc.terminate()
for child in children:
try:
child.terminate()
except psutil.NoSuchProcess:
pass
_, remaining = psutil.wait_procs([proc] + children, timeout=10)
if remaining:
raise RuntimeError('Some owned processes did not stop; inspect before retrying')
PID_FILE.unlink(missing_ok=True)
print('Stopped project server')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('action', choices=('start', 'stop', 'backup'))
args = parser.parse_args()
{'start': start, 'stop': stop, 'backup': backup}[args.action]()

338
scripts/local_webcam.py Normal file
View File

@ -0,0 +1,338 @@
"""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.
"""
import argparse
import configparser
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import secrets
import socket
import sqlite3
import subprocess
import sys
import threading
import time
import psutil
import requests
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from scripts.local_server import PYTHON, local_environment, recorded_process as web_process
WORK = ROOT / '.runtime' / 'local-webcam'
SCRIPT = Path(__file__).resolve()
PID_FILE = WORK / 'process.json'
STATUS = WORK / 'status.json'
STOP = WORK / 'stop.request'
STREAM = 'laptop_cam'
DEVICE = 'Integrated Camera'
MARKER = 'Managed by scripts/local_webcam.py; loopback video only'
PORTS = {'http': 10002, 'rtsp': 10554, 'rtmp': 10935}
PUSH_URL = f'rtmp://127.0.0.1:{PORTS["rtmp"]}/live/{STREAM}'
def write_json(path, data):
temp = path.with_suffix('.tmp')
temp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
os.replace(temp, path)
def make_config(source, api_secret, hook_url):
config = configparser.ConfigParser(interpolation=None, strict=False)
config.optionxform = str
config.read_string(source)
changes = {
'general': {'listen_ip': '127.0.0.1', 'check_nvidia_dev': '0', 'enable_ffmpeg_log': '0',
'mediaServerId': 'local-webcam', 'wait_add_track_ms': '100', 'maxStreamWaitMS': '3000'},
'api': {'secret': api_secret, 'apiDebug': '0', 'downloadRoot': str(WORK / 'www'),
'snapRoot': str(WORK / 'www'), 'defaultSnap': ''},
'cluster': {'origin_url': ''},
'http': {'port': str(PORTS['http']), 'sslport': '0', 'rootPath': str(WORK / 'www'),
'virtualPath': '', 'dirMenu': '0', 'allow_ip_range': '127.0.0.1', 'allow_cross_domains': '0'},
'rtmp': {'port': str(PORTS['rtmp']), 'sslport': '0'},
'rtsp': {'port': str(PORTS['rtsp']), 'sslport': '0', 'rtpTransportType': '0'},
'rtc': {'port': '0', 'tcpPort': '0'}, 'rtp_proxy': {'port': '0', 'dumpDir': ''},
'srt': {'port': '0'}, 'shell': {'port': '0'}, 'onvif': {'port': '0'},
'protocol': {'enable_audio': '0', 'add_mute_audio': '0', 'enable_mp4': '0',
'enable_hls': '0', 'enable_hls_fmp4': '0', 'enable_ts': '0',
'enable_rtsp': '1', 'enable_rtmp': '1', 'enable_fmp4': '1',
'continue_push_ms': '0', 'auto_close': '0',
'mp4_save_path': str(WORK / 'www'), 'hls_save_path': str(WORK / 'www')},
'record': {'enableFmp4': '0'}, 'hls': {'broadcastRecordTs': '0', 'segKeep': '0'},
# ZLM must never launch another ffmpeg process or save a snapshot here.
'ffmpeg': {'bin': '', 'cmd': '', 'snap': '', 'log': ''},
}
for section, values in changes.items():
if not config.has_section(section):
config.add_section(section)
config[section].update(values)
if config.has_section('hook'):
config.remove_section('hook')
config['hook'] = {'enable': '1', 'on_publish': hook_url, 'timeoutSec': '5', 'retry': '0'}
return config
def capture_command(ffmpeg):
return [ffmpeg, '-hide_banner', '-loglevel', 'warning', '-f', 'dshow',
'-rtbufsize', '32M', '-video_size', '640x480', '-framerate', '15',
'-i', 'video=' + DEVICE, '-map', '0:v:0', '-an', '-sn', '-dn',
'-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency',
'-pix_fmt', 'yuv420p', '-g', '30', '-threads', '2', '-f', 'flv', PUSH_URL]
def allowed_publish(data):
return (data.get('app') == 'live' and data.get('stream') == STREAM
and data.get('schema') == 'rtmp' and data.get('ip') == '127.0.0.1')
def hook_server():
route = '/publish/' + secrets.token_urlsafe(32)
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass # Never log the per-run hook token or payload.
def do_POST(self):
accepted = False
try:
size = int(self.headers.get('Content-Length', '0'))
if self.path == route and self.client_address[0] == '127.0.0.1' and 0 < size <= 16384:
self.connection.settimeout(3)
accepted = allowed_publish(json.loads(self.rfile.read(size)))
except (ValueError, OSError, TypeError):
pass
data = {'code': 0 if accepted else -1, 'msg': 'video-only local publisher',
'enable_audio': False, 'add_mute_audio': False, 'enable_mp4': False,
'enable_hls': False, 'enable_hls_fmp4': False}
body = json.dumps(data).encode()
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
return server, f'http://127.0.0.1:{server.server_port}{route}'
def check_connections(processes, allowed_ports):
listeners = set()
for proc in processes:
for conn in proc.connections(kind='inet'):
if conn.type == socket.SOCK_DGRAM:
raise RuntimeError('Unexpected UDP socket; stopping webcam')
if conn.laddr and conn.laddr.ip not in ('127.0.0.1', '::1'):
raise RuntimeError(f'Non-loopback binding {conn.laddr} ({conn.status}); stopping webcam')
if conn.raddr and conn.raddr.ip not in ('127.0.0.1', '::1'):
raise RuntimeError('Non-loopback peer; stopping webcam')
if conn.status == psutil.CONN_LISTEN:
if conn.laddr.port not in allowed_ports:
raise RuntimeError('Unexpected listener; stopping webcam')
listeners.add(conn.laddr.port)
return listeners
def owned_process():
if not PID_FILE.exists():
return None
data = json.loads(PID_FILE.read_text(encoding='utf-8'))
try:
proc = psutil.Process(data['pid'])
if abs(proc.create_time() - data['created']) > .01:
return None
args = proc.cmdline()
if (data['root'] != str(ROOT) or len(args) != 3 or Path(args[0]) != PYTHON
or Path(args[1]) != SCRIPT or args[2] != 'run'):
raise RuntimeError('PID ownership mismatch; no process stopped')
return proc
except psutil.NoSuchProcess:
return None
def terminate(child):
if child and child.poll() is None:
child.terminate()
try:
child.wait(timeout=8)
except subprocess.TimeoutExpired:
child.kill()
child.wait(timeout=5)
def run():
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
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')
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'))
backup_dir.mkdir()
with sqlite3.connect(database.as_uri() + '?mode=ro', uri=True) as source:
with sqlite3.connect(backup_dir / database.name) as target:
source.backup(target)
import django
django.setup() # Services explicitly disabled; normal existing schema migration only.
from app.models import StreamModel
existing = StreamModel.objects.filter(code=STREAM).first()
if existing and (existing.remark != MARKER or existing.pull_stream_type != 32):
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')
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:
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)
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')
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)
finally:
terminate(camera)
terminate(media)
if hook:
hook.shutdown()
hook.server_close()
if row 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')})
def start():
WORK.mkdir(parents=True, exist_ok=True)
proc = owned_process()
if proc:
print('Webcam process already running:', proc.pid)
return
STOP.unlink(missing_ok=True)
STATUS.unlink(missing_ok=True)
with (WORK / 'supervisor.log').open('ab') as log:
child = subprocess.Popen([str(PYTHON), str(SCRIPT), 'run'], cwd=ROOT, env=local_environment(),
stdin=subprocess.DEVNULL, stdout=log, stderr=log,
creationflags=subprocess.CREATE_NO_WINDOW)
proc = psutil.Process(child.pid)
write_json(PID_FILE, {'pid': proc.pid, 'created': proc.create_time(), 'root': str(ROOT)})
for _ in range(60):
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')
return
time.sleep(1)
STOP.touch()
raise RuntimeError('Webcam readiness timeout; requested shutdown')
def stop():
proc = owned_process()
if not proc:
print('No owned webcam process running')
return
STOP.touch()
try:
proc.wait(timeout=20)
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')
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['start', 'stop', 'run', 'status'])
action = parser.parse_args().action
if action == 'status':
print(STATUS.read_text(encoding='utf-8') if STATUS.exists() else 'Not started')
else:
{'start': start, 'stop': stop, 'run': run}[action]()

View File

@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Secure a Monitor installation before first production use.
The script uses only the Python standard library. It backs up the SQLite
database, replaces all login users with one new administrator, clears sessions,
and rotates file-backed runtime secrets. Stop Monitor before running it.
"""
import argparse
import base64
import getpass
import hashlib
import os
import secrets
import shutil
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
BUSINESS_TABLES = (
"av_alarm", "av_zone_algorithms", "av_zone", "av_biz_algorithm", "av_llm",
"av_recording", "av_stream", "av_algorithm", "av_log",
)
def _password_hash(password):
iterations = 720000
salt = secrets.token_urlsafe(12)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), iterations)
return "pbkdf2_sha256$%d$%s$%s" % (
iterations, salt, base64.b64encode(digest).decode("ascii")
)
def _read_password(username):
password = getpass.getpass("New administrator password: ")
confirm = getpass.getpass("Confirm administrator password: ")
if password != confirm:
raise ValueError("password confirmation does not match")
if len(password) < 12:
raise ValueError("administrator password must be at least 12 characters")
if username.lower() in password.lower():
raise ValueError("administrator password must not contain the username")
return password
def main():
parser = argparse.ArgumentParser(description="Securely initialize a Monitor installation")
parser.add_argument("--database", default=str(ROOT / "monitor.sqlite3"))
parser.add_argument("--admin-username", default="admin")
parser.add_argument("--admin-email", default="")
parser.add_argument(
"--purge-business-data", action="store_true",
help="also remove cameras, models, rules, alarms, recordings and operation logs",
)
args = parser.parse_args()
username = args.admin_username.strip()
if not username or len(username) > 150:
raise ValueError("invalid administrator username")
password = _read_password(username)
database = Path(args.database).resolve()
if not database.is_file():
raise FileNotFoundError(database)
stamp = datetime.now().strftime("%Y%m%d%H%M%S")
backup = database.with_name(database.name + ".backup-" + stamp)
shutil.copy2(database, backup)
con = sqlite3.connect(str(database), timeout=20)
try:
con.execute("PRAGMA foreign_keys=ON")
con.execute("BEGIN IMMEDIATE")
con.execute("DELETE FROM auth_user_groups")
con.execute("DELETE FROM auth_user_user_permissions")
con.execute("DELETE FROM django_admin_log")
con.execute("DELETE FROM django_session")
con.execute("DELETE FROM auth_user")
if args.purge_business_data:
for table in BUSINESS_TABLES:
con.execute('DELETE FROM "%s"' % table)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
con.execute(
"""INSERT INTO auth_user
(password,last_login,is_superuser,username,last_name,email,is_staff,is_active,date_joined,first_name)
VALUES (?,NULL,1,?,'',?,1,1,?,'cec=0')""",
(_password_hash(password), username, args.admin_email.strip(), now),
)
con.commit()
except Exception:
con.rollback()
raise
finally:
con.close()
from app.utils.Secrets import rotate_runtime_secrets
secret_backup = rotate_runtime_secrets()
print("Database backup:", backup)
if secret_backup:
print("Runtime-secret backup:", secret_backup)
print("Secure initialization complete. Restart Monitor before signing in.")
if __name__ == "__main__":
main()

4
scripts/start-local.ps1 Normal file
View File

@ -0,0 +1,4 @@
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
& "$projectRoot\.venv\Scripts\python.exe" "$PSScriptRoot\local_server.py" start
exit $LASTEXITCODE

Some files were not shown because too many files have changed in this diff Show More