Initial commit: video monitor project
This commit is contained in:
commit
731a3f8290
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal 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
130
README.md
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
|
||||||
|
|
||||||
|
多路视频接入与智能布控分析平台。支持 GB28181 / RTSP、YOLO 小模型检测、OpenAI 兼容大模型复核、多边形布控与结构化报警。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 视频接入:RTSP / GB28181 拉流,ZLMediaKit 转发,ONVIF 发现
|
||||||
|
- 智能分析:YOLO-PyTorch / ONNX / OpenVINO 小模型 + 可选大模型复核
|
||||||
|
- 布控报警:多边形区域、5 种后处理规则(入侵/越线/方向/密度/滞留)
|
||||||
|
- 运维:控制面板监控、流媒体启停、录像、多语言(7 种)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- FFmpeg(PATH 或 `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
0
app/__init__.py
Normal file
3
app/admin.py
Normal file
3
app/admin.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
30
app/analysis/__init__.py
Normal file
30
app/analysis/__init__.py
Normal 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
299
app/analysis/biz_rules.py
Normal 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_a→line_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_a→line_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
30
app/analysis/detector.py
Normal 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):
|
||||||
|
"""已废弃:等价于 OnnxEngine(detect 任务)。保留以兼容旧 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()
|
||||||
21
app/analysis/engines/__init__.py
Normal file
21
app/analysis/engines/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
108
app/analysis/engines/base.py
Normal file
108
app/analysis/engines/base.py
Normal 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 []
|
||||||
110
app/analysis/engines/factory.py
Normal file
110
app/analysis/engines/factory.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
"""引擎工厂:按 inference_engine 名分发实例
|
||||||
|
|
||||||
|
注册引擎:
|
||||||
|
- yolo_pytorch : Yolo-PyTorch(ultralytics 原生,全版本全任务,主引擎)
|
||||||
|
- 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)
|
||||||
157
app/analysis/engines/onnx_engine.py
Normal file
157
app/analysis/engines/onnx_engine.py
Normal 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
|
||||||
202
app/analysis/engines/openvino_engine.py
Normal file
202
app/analysis/engines/openvino_engine.py
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
"""OpenVINO 引擎实现 —— YOLO 5/8/11/26 + 全任务 + 设备支持
|
||||||
|
|
||||||
|
依赖:openvino, opencv-python, numpy
|
||||||
|
推理设备:
|
||||||
|
cpu -> CPU
|
||||||
|
gpu -> GPU
|
||||||
|
cuda -> 不适用,回退 CPU(OpenVINO 不走 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
|
||||||
187
app/analysis/engines/pytorch_engine.py
Normal file
187
app/analysis/engines/pytorch_engine.py
Normal 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
|
||||||
198
app/analysis/engines/reid_onnx_engine.py
Normal file
198
app/analysis/engines/reid_onnx_engine.py
Normal 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
|
||||||
358
app/analysis/engines/yolo_postprocess.py
Normal file
358
app/analysis/engines/yolo_postprocess.py
Normal 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 + nm,nm 一般 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
|
||||||
276
app/analysis/engines/yolo_pytorch_engine.py
Normal file
276
app/analysis/engines/yolo_pytorch_engine.py
Normal 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
|
||||||
82
app/analysis/event_bridge.py
Normal file
82
app/analysis/event_bridge.py
Normal 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
140
app/analysis/frames.py
Normal 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
|
||||||
309
app/analysis/inference_pool.py
Normal file
309
app/analysis/inference_pool.py
Normal 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
787
app/analysis/manager.py
Normal file
@ -0,0 +1,787 @@
|
|||||||
|
"""全局分析管理器(单例)
|
||||||
|
|
||||||
|
阶段2:每路摄像头在独立子进程中运行 CameraPipeline;YOLO 推理可选走
|
||||||
|
主进程 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
90
app/analysis/motion.py
Normal 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
1003
app/analysis/pipeline.py
Normal file
File diff suppressed because it is too large
Load Diff
207
app/analysis/process_worker.py
Normal file
207
app/analysis/process_worker.py
Normal 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
|
||||||
134
app/analysis/remote_detector.py
Normal file
134
app/analysis/remote_detector.py
Normal 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
106
app/analysis/tracker.py
Normal 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
199
app/analysis/worker_pool.py
Normal 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
57
app/apps.py
Normal 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=-65536:64MB 页缓存
|
||||||
|
# - 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
25
app/context_processors.py
Normal 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
|
||||||
|
}
|
||||||
1
app/management/__init__.py
Normal file
1
app/management/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Django management package."""
|
||||||
1
app/management/commands/__init__.py
Normal file
1
app/management/commands/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Application management commands."""
|
||||||
27
app/management/commands/runservices.py
Normal file
27
app/management/commands/runservices.py
Normal 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
69
app/middleware.py
Normal 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
427
app/models.py
Normal 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 = '管理员日志'
|
||||||
1
app/recording/__init__.py
Normal file
1
app/recording/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""录像模块"""
|
||||||
243
app/recording/manager.py
Normal file
243
app/recording/manager.py
Normal 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
2
app/scheduler.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# 计划任务功能已移除(Monitor 不需要此功能)
|
||||||
|
# 原功能依赖 ScheduleTaskModel 和 ScheduleTaskLogModel,这两个模型已从 models.py 中移除
|
||||||
64
app/security.py
Normal file
64
app/security.py
Normal 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
1
app/services/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Monitor · 服务层"""
|
||||||
56
app/services/alarm_service.py
Normal file
56
app/services/alarm_service.py
Normal 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))
|
||||||
787
app/services/algorithm_test_service.py
Normal file
787
app/services/algorithm_test_service.py
Normal 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
|
||||||
62
app/services/cross_camera_service.py
Normal file
62
app/services/cross_camera_service.py
Normal 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
201
app/services/lifecycle.py
Normal 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
|
||||||
111
app/services/onvif_discovery.py
Normal file
111
app/services/onvif_discovery.py
Normal 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
63
app/services/telemetry.py
Normal 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
3
app/tests.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
175
app/urls.py
Normal file
175
app/urls.py
Normal 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
326
app/utils/Config.py
Normal 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
78
app/utils/Credentials.py
Normal 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
36
app/utils/Database.py
Normal 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
|
||||||
3146
app/utils/GB28181SipServer.py
Normal file
3146
app/utils/GB28181SipServer.py
Normal file
File diff suppressed because it is too large
Load Diff
298
app/utils/GlobalUtils.py
Normal file
298
app/utils/GlobalUtils.py
Normal 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
465
app/utils/GpuInfo.py
Normal file
@ -0,0 +1,465 @@
|
|||||||
|
"""本机显卡信息采集(支持 NVIDIA / Intel / AMD,Windows & 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
41
app/utils/LLMUtils.py
Normal 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
154
app/utils/LanguageUtils.py
Normal 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
49
app/utils/LogUtils.py
Normal 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
66
app/utils/Logger.py
Normal 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
|
||||||
148
app/utils/MediaServerManager.py
Normal file
148
app/utils/MediaServerManager.py
Normal 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
45
app/utils/ModelTrust.py
Normal 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
427
app/utils/OSSystem.py
Normal 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
139
app/utils/Secrets.py
Normal 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
89
app/utils/UploadUtils.py
Normal 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
107
app/utils/Utils.py
Normal 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
534
app/utils/ZLMediaKitApi.py
Normal 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拉流时,拉流方式,0:tcp,1:udp,2:组播
|
||||||
|
# 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
0
app/utils/__init__.py
Normal file
30
app/utils/schema_upgrade.py
Normal file
30
app/utils/schema_upgrade.py
Normal 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
118
app/views/AlarmView.py
Normal 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
564
app/views/AlgorithmView.py
Normal 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
458
app/views/AnalysisView.py
Normal 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
653
app/views/ControlView.py
Normal 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
165
app/views/IndexView.py
Normal 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
312
app/views/InnerlView.py
Normal 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:
|
||||||
|
# 编辑(不修改nickname,nickname允许用户自定义)
|
||||||
|
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
310
app/views/LLMView.py
Normal 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
365
app/views/NvrView.py
Normal 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
789
app/views/SmallModelView.py
Normal 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
85
app/views/StorageView.py
Normal 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
1211
app/views/StreamView.py
Normal file
File diff suppressed because it is too large
Load Diff
324
app/views/SystemView.py
Normal file
324
app/views/SystemView.py
Normal 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
581
app/views/UserView.py
Normal 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
50
app/views/VersionView.py
Normal 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
136
app/views/ViewsBase.py
Normal 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
0
app/views/__init__.py
Normal file
43
config.json
Normal file
43
config.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
38
deploy/nginx/rebucca.conf.example
Normal file
38
deploy/nginx/rebucca.conf.example
Normal 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
0
framework/__init__.py
Normal file
16
framework/asgi.py
Normal file
16
framework/asgi.py
Normal 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
175
framework/settings.py
Normal 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=True:staticfiles 从 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*60,1小时=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
35
framework/urls.py
Normal 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
16
framework/wsgi.py
Normal 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
1069
language-zh.json
Normal file
File diff suppressed because it is too large
Load Diff
21
manage.py
Normal file
21
manage.py
Normal 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
58
manage.spec
Normal 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
26
requirements-linux.txt
Normal 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
|
||||||
85
requirements-windows-cpu.lock.txt
Normal file
85
requirements-windows-cpu.lock.txt
Normal 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
18
requirements-windows.txt
Normal 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
49
scripts/check-local.py
Normal 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
180
scripts/local_server.py
Normal 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
338
scripts/local_webcam.py
Normal 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]()
|
||||||
110
scripts/secure_initialize.py
Normal file
110
scripts/secure_initialize.py
Normal 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
4
scripts/start-local.ps1
Normal 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
Loading…
Reference in New Issue
Block a user