mirror of
https://github.com/gowvp/gb28181.git
synced 2026-04-22 15:07:10 +08:00
support detect
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.yaml
|
||||
*.pt
|
||||
@@ -41,3 +41,5 @@ cover/
|
||||
*.test
|
||||
*.jpg
|
||||
__pycache__/
|
||||
*.pt
|
||||
*.onnx
|
||||
@@ -0,0 +1,75 @@
|
||||
# 使用 Debian Bullseye 作为基础镜像,因为 ZLMediaKit 需要 libssl1.1
|
||||
FROM debian:trixie-slim
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONPATH=/opt/media/bin/analysis
|
||||
|
||||
WORKDIR /opt/media/bin/
|
||||
|
||||
# 配置 apt 源使用阿里云镜像 (Trixie 使用新格式 sources.list.d/debian.sources)
|
||||
RUN sed -i 's|http://deb.debian.org|http://mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|http://deb.debian.org|http://mirrors.aliyun.com|g; \
|
||||
s|http://security.debian.org|http://mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 安装运行时依赖
|
||||
# - python3/pip: AI 分析模块
|
||||
# - libgl1/libglib2.0-0: OpenCV 需要
|
||||
# 注意:Trixie 没有 libssl1.1,需要从 Bullseye 仓库安装
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
libstdc++6 \
|
||||
libgcc-s1 \
|
||||
libssl3 \
|
||||
python3 \
|
||||
python3-pip \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
|
||||
# 从 Debian Bullseye 下载并安装 libssl1.1(ZLMediaKit 需要)
|
||||
RUN wget -q http://mirrors.aliyun.com/debian/pool/main/o/openssl/libssl1.1_1.1.1w-0+deb11u1_amd64.deb -O /tmp/libssl1.1_amd64.deb \
|
||||
&& wget -q http://mirrors.aliyun.com/debian/pool/main/o/openssl/libssl1.1_1.1.1w-0+deb11u1_arm64.deb -O /tmp/libssl1.1_arm64.deb \
|
||||
&& dpkg -i /tmp/libssl1.1_$(dpkg --print-architecture).deb || true \
|
||||
&& rm -f /tmp/libssl1.1_*.deb
|
||||
|
||||
# 配置 pip 使用阿里云镜像
|
||||
RUN pip3 config set global.index-url https://mirrors.aliyun.com/pypi/simple/ \
|
||||
&& pip3 config set global.trusted-host mirrors.aliyun.com
|
||||
|
||||
# 安装 Python 依赖
|
||||
# --break-system-packages: Debian Trixie 使用 PEP 668 保护系统 Python
|
||||
COPY ./analysis/requirements.txt /tmp/requirements.txt
|
||||
RUN pip3 install --no-cache-dir --break-system-packages -r /tmp/requirements.txt \
|
||||
&& rm /tmp/requirements.txt \
|
||||
&& rm -rf /root/.cache/pip
|
||||
|
||||
# 从外部镜像复制 ZLMediaKit 和 ffmpeg
|
||||
COPY --from=zlmediakit/zlmediakit:master /opt/media/bin /opt/media/bin
|
||||
COPY --from=mwader/static-ffmpeg:6.1 /ffmpeg /usr/local/bin/ffmpeg
|
||||
COPY --from=mwader/static-ffmpeg:6.1 /ffprobe /usr/local/bin/ffprobe
|
||||
|
||||
# 复制应用文件
|
||||
ADD ./build/linux_amd64/bin ./gowvp
|
||||
ADD ./www ./www
|
||||
ADD ./analysis ./analysis
|
||||
|
||||
RUN mkdir -p configs
|
||||
|
||||
RUN ln -sf /usr/local/bin/ffmpeg /usr/bin/ffmpeg && \
|
||||
chmod +x /usr/local/bin/ffmpeg
|
||||
|
||||
RUN ln -sf /usr/local/bin/ffmpeg /opt/media/bin/ffmpeg
|
||||
|
||||
LABEL Name=gowvp \
|
||||
Version=0.1.0 \
|
||||
Maintainer="xx@golang.space" \
|
||||
Description="gowvp & zlmediakit & ai (ONNX Runtime)"
|
||||
|
||||
EXPOSE 15123 1935 8080 554 50051 20000-20100/udp 20000-20100
|
||||
|
||||
CMD ["./gowvp"]
|
||||
@@ -201,15 +201,19 @@ docker/push:
|
||||
@docker push $(IMAGE_NAME)
|
||||
|
||||
docker/build/test: build/clean build/linux
|
||||
@docker build --force-rm=true -t $(IMAGE_NAME) -f Dockerfile_full .
|
||||
@docker build --force-rm=true -t $(IMAGE_NAME) -f Dockerfile_zlm .
|
||||
|
||||
docker/build/zlm: build/clean build/linux
|
||||
#@docker build --force-rm=true --push --platform linux/amd64,linux/arm64 -t $(IMAGE_NAME) -f Dockerfile_zlm .
|
||||
@docker build --force-rm=true --push --platform linux/amd64,linux/arm64 -t registry.cn-shanghai.aliyuncs.com/ixugo/homenvr:latest -f Dockerfile_zlm .
|
||||
|
||||
docker/build/ai: build/clean build/linux
|
||||
@docker build --platform linux/amd64,linux/arm64 -t registry.cn-shanghai.aliyuncs.com/ixugo/homenvr:beta -f Dockerfile_ai .
|
||||
|
||||
docker/build/full: build/clean build/linux
|
||||
#@docker build --force-rm=true --push --platform linux/amd64,linux/arm64 -t $(IMAGE_NAME) -f Dockerfile_full .
|
||||
@docker build --force-rm=true --push --platform linux/amd64,linux/arm64 -t registry.cn-shanghai.aliyuncs.com/ixugo/homenvr:latest -f Dockerfile_full .
|
||||
|
||||
# 发布融合镜像到镜像仓库
|
||||
docker/publish: build/clean build/linux
|
||||
@docker build --force-rm=true --push --platform linux/amd64,linux/arm64 -t registry.cn-shanghai.aliyuncs.com/ixugo/homenvr:latest -t $(IMAGE_NAME) -f Dockerfile_full .
|
||||
@docker build --force-rm=true --push --platform linux/amd64,linux/arm64 -t registry.cn-shanghai.aliyuncs.com/ixugo/homenvr:latest -t $(IMAGE_NAME) -f Dockerfile_zlm .
|
||||
|
||||
# 构建 gowvp 独立镜像
|
||||
docker/build/gowvp: build/clean build/linux
|
||||
|
||||
+291
-63
@@ -1,47 +1,295 @@
|
||||
import logging
|
||||
from tabnanny import verbose
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from sympy import true
|
||||
from ultralytics import YOLO # type: ignore
|
||||
import numpy as np
|
||||
import cv2
|
||||
import onnxruntime as ort
|
||||
|
||||
|
||||
slog = logging.getLogger("Detector")
|
||||
|
||||
# COCO 数据集 80 类标签
|
||||
COCO_LABELS = [
|
||||
"person",
|
||||
"bicycle",
|
||||
"car",
|
||||
"motorcycle",
|
||||
"airplane",
|
||||
"bus",
|
||||
"train",
|
||||
"truck",
|
||||
"boat",
|
||||
"traffic light",
|
||||
"fire hydrant",
|
||||
"stop sign",
|
||||
"parking meter",
|
||||
"bench",
|
||||
"bird",
|
||||
"cat",
|
||||
"dog",
|
||||
"horse",
|
||||
"sheep",
|
||||
"cow",
|
||||
"elephant",
|
||||
"bear",
|
||||
"zebra",
|
||||
"giraffe",
|
||||
"backpack",
|
||||
"umbrella",
|
||||
"handbag",
|
||||
"tie",
|
||||
"suitcase",
|
||||
"frisbee",
|
||||
"skis",
|
||||
"snowboard",
|
||||
"sports ball",
|
||||
"kite",
|
||||
"baseball bat",
|
||||
"baseball glove",
|
||||
"skateboard",
|
||||
"surfboard",
|
||||
"tennis racket",
|
||||
"bottle",
|
||||
"wine glass",
|
||||
"cup",
|
||||
"fork",
|
||||
"knife",
|
||||
"spoon",
|
||||
"bowl",
|
||||
"banana",
|
||||
"apple",
|
||||
"sandwich",
|
||||
"orange",
|
||||
"broccoli",
|
||||
"carrot",
|
||||
"hot dog",
|
||||
"pizza",
|
||||
"donut",
|
||||
"cake",
|
||||
"chair",
|
||||
"couch",
|
||||
"potted plant",
|
||||
"bed",
|
||||
"dining table",
|
||||
"toilet",
|
||||
"tv",
|
||||
"laptop",
|
||||
"mouse",
|
||||
"remote",
|
||||
"keyboard",
|
||||
"cell phone",
|
||||
"microwave",
|
||||
"oven",
|
||||
"toaster",
|
||||
"sink",
|
||||
"refrigerator",
|
||||
"book",
|
||||
"clock",
|
||||
"vase",
|
||||
"scissors",
|
||||
"teddy bear",
|
||||
"hair drier",
|
||||
"toothbrush",
|
||||
]
|
||||
|
||||
|
||||
class ObjectDetector:
|
||||
def __init__(self, model_path: str = "yolo11n.pt", device: str = "auto"):
|
||||
|
||||
def __init__(self, model_path: str = "yolo11n.onnx"):
|
||||
self.model_path = model_path
|
||||
self.device = device
|
||||
self.model: YOLO | None = None
|
||||
self.session: ort.InferenceSession | None = None
|
||||
self.input_name: str = ""
|
||||
self.input_shape: tuple = (1, 3, 640, 640)
|
||||
self._is_ready = False
|
||||
self.names: dict[int, str] = {i: name for i, name in enumerate(COCO_LABELS)}
|
||||
|
||||
def load_model(self) -> bool:
|
||||
"""加载 ONNX 模型并初始化推理会话"""
|
||||
try:
|
||||
slog.info(f"加载模型: {self.model_path} ...")
|
||||
slog.info(f"加载 ONNX 模型: {self.model_path} ...")
|
||||
start_time = time.time()
|
||||
self.model = YOLO(self.model_path)
|
||||
if self.device != "auto":
|
||||
self.model.to(self.device)
|
||||
|
||||
# 配置 ONNX Runtime 会话选项
|
||||
sess_options = ort.SessionOptions()
|
||||
sess_options.graph_optimization_level = (
|
||||
ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||
)
|
||||
# 限制线程数,避免在容器中占用过多 CPU
|
||||
sess_options.intra_op_num_threads = 4
|
||||
sess_options.inter_op_num_threads = 2
|
||||
|
||||
# 优先使用 CPU 执行提供程序
|
||||
providers = ["CPUExecutionProvider"]
|
||||
|
||||
self.session = ort.InferenceSession(
|
||||
self.model_path, sess_options=sess_options, providers=providers
|
||||
)
|
||||
|
||||
# 获取输入信息
|
||||
input_info = self.session.get_inputs()[0]
|
||||
self.input_name = input_info.name
|
||||
self.input_shape = tuple(input_info.shape)
|
||||
|
||||
# 预热模型
|
||||
dummy_img = np.zeros((640, 640, 3), dtype=np.uint8)
|
||||
self.model.predict(dummy_img, verbose=False)
|
||||
self._preprocess(dummy_img)
|
||||
dummy_input = self._preprocess(dummy_img)
|
||||
self.session.run(None, {self.input_name: dummy_input})
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
slog.info(f"模型加载完成 (耗时: {elapsed:.2f}s)")
|
||||
slog.info(
|
||||
f"ONNX 模型加载完成 (耗时: {elapsed:.2f}s, 输入形状: {self.input_shape})"
|
||||
)
|
||||
self._is_ready = True
|
||||
return True
|
||||
except Exception as e:
|
||||
slog.error(f"加载模型失败: {e}")
|
||||
slog.error(f"加载 ONNX 模型失败: {e}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_ready(self) -> bool:
|
||||
return self._is_ready and self.model is not None
|
||||
return self._is_ready and self.session is not None
|
||||
|
||||
def _preprocess(self, image: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
预处理图像:调整大小、归一化、转换格式
|
||||
YOLO 期望输入格式: NCHW, float32, 归一化到 [0, 1]
|
||||
"""
|
||||
# 保持宽高比的 letterbox 缩放
|
||||
target_size = self.input_shape[2] # 640
|
||||
h, w = image.shape[:2]
|
||||
scale = min(target_size / h, target_size / w)
|
||||
new_h, new_w = int(h * scale), int(w * scale)
|
||||
|
||||
# 缩放图像
|
||||
resized = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
|
||||
|
||||
# 创建正方形画布并居中放置图像
|
||||
canvas = np.full((target_size, target_size, 3), 114, dtype=np.uint8)
|
||||
top = (target_size - new_h) // 2
|
||||
left = (target_size - new_w) // 2
|
||||
canvas[top : top + new_h, left : left + new_w] = resized
|
||||
|
||||
# BGR -> RGB, HWC -> CHW, 归一化
|
||||
blob = canvas[:, :, ::-1].transpose(2, 0, 1).astype(np.float32) / 255.0
|
||||
return np.expand_dims(blob, axis=0)
|
||||
|
||||
def _postprocess(
|
||||
self,
|
||||
outputs: np.ndarray,
|
||||
original_shape: tuple[int, int],
|
||||
threshold: float,
|
||||
label_filter: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
后处理 YOLO 输出:解析检测框、应用 NMS、坐标转换
|
||||
YOLO11 输出格式: (1, 84, 8400) -> 84 = 4 (bbox) + 80 (classes)
|
||||
"""
|
||||
# 转置为 (8400, 84) 便于处理
|
||||
predictions = outputs[0].T # (8400, 84)
|
||||
|
||||
# 提取边界框和类别分数
|
||||
boxes = predictions[:, :4] # x_center, y_center, width, height
|
||||
scores = predictions[:, 4:] # 80 个类别的分数
|
||||
|
||||
# 获取每个检测框的最高分数和对应类别
|
||||
class_ids = np.argmax(scores, axis=1)
|
||||
confidences = np.max(scores, axis=1)
|
||||
|
||||
# 过滤低置信度检测
|
||||
mask = confidences >= threshold
|
||||
boxes = boxes[mask]
|
||||
confidences = confidences[mask]
|
||||
class_ids = class_ids[mask]
|
||||
|
||||
if len(boxes) == 0:
|
||||
return []
|
||||
|
||||
# 转换坐标:center_x, center_y, w, h -> x1, y1, x2, y2
|
||||
x_center, y_center, w, h = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
|
||||
x1 = x_center - w / 2
|
||||
y1 = y_center - h / 2
|
||||
x2 = x_center + w / 2
|
||||
y2 = y_center + h / 2
|
||||
|
||||
# 缩放坐标到原始图像尺寸
|
||||
orig_h, orig_w = original_shape
|
||||
target_size = self.input_shape[2]
|
||||
scale = min(target_size / orig_h, target_size / orig_w)
|
||||
pad_h = (target_size - orig_h * scale) / 2
|
||||
pad_w = (target_size - orig_w * scale) / 2
|
||||
|
||||
x1 = (x1 - pad_w) / scale
|
||||
y1 = (y1 - pad_h) / scale
|
||||
x2 = (x2 - pad_w) / scale
|
||||
y2 = (y2 - pad_h) / scale
|
||||
|
||||
# 裁剪到图像边界
|
||||
x1 = np.clip(x1, 0, orig_w)
|
||||
y1 = np.clip(y1, 0, orig_h)
|
||||
x2 = np.clip(x2, 0, orig_w)
|
||||
y2 = np.clip(y2, 0, orig_h)
|
||||
|
||||
# NMS (非极大值抑制)
|
||||
boxes_for_nms = np.stack([x1, y1, x2, y2], axis=1)
|
||||
indices = cv2.dnn.NMSBoxes(
|
||||
boxes_for_nms.tolist(),
|
||||
confidences.tolist(),
|
||||
threshold,
|
||||
0.45, # NMS IoU 阈值
|
||||
)
|
||||
|
||||
detections = []
|
||||
|
||||
# 处理 NMSBoxes 返回值的不同格式
|
||||
# OpenCV 不同版本返回格式不同:可能是 list、tuple、ndarray
|
||||
if indices is None or len(indices) == 0:
|
||||
return detections
|
||||
|
||||
# 将 indices 转换为一维列表
|
||||
if isinstance(indices, np.ndarray):
|
||||
indices = indices.flatten().tolist()
|
||||
elif isinstance(indices, tuple):
|
||||
indices = list(indices)
|
||||
|
||||
for idx in indices:
|
||||
# 确保 idx 是整数
|
||||
idx = int(idx) if not isinstance(idx, int) else idx
|
||||
|
||||
cls_id = int(class_ids[idx])
|
||||
label = self.names.get(cls_id, f"class_{cls_id}")
|
||||
|
||||
# 标签过滤
|
||||
if label_filter and label not in label_filter:
|
||||
continue
|
||||
|
||||
x_min_val = int(x1[idx])
|
||||
y_min_val = int(y1[idx])
|
||||
x_max_val = int(x2[idx])
|
||||
y_max_val = int(y2[idx])
|
||||
area = (x_max_val - x_min_val) * (y_max_val - y_min_val)
|
||||
|
||||
detections.append(
|
||||
{
|
||||
"label": label,
|
||||
"confidence": float(confidences[idx]),
|
||||
"box": {
|
||||
"x_min": x_min_val,
|
||||
"y_min": y_min_val,
|
||||
"x_max": x_max_val,
|
||||
"y_max": y_max_val,
|
||||
},
|
||||
"area": area,
|
||||
"norm_box": {
|
||||
"x": (x_min_val + x_max_val) / 2 / orig_w,
|
||||
"y": (y_min_val + y_max_val) / 2 / orig_h,
|
||||
"w": (x_max_val - x_min_val) / orig_w,
|
||||
"h": (y_max_val - y_min_val) / orig_h,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return detections
|
||||
|
||||
def detect(
|
||||
self,
|
||||
@@ -50,8 +298,8 @@ class ObjectDetector:
|
||||
label_filter: list[str] | None = None,
|
||||
regions: list[tuple[int, int, int, int]] | None = None,
|
||||
) -> tuple[list[dict], float]:
|
||||
|
||||
if not self.is_ready:
|
||||
"""执行目标检测"""
|
||||
if not self.is_ready():
|
||||
raise RuntimeError("模型未加载")
|
||||
|
||||
start_time = time.time()
|
||||
@@ -61,7 +309,7 @@ class ObjectDetector:
|
||||
for region in regions:
|
||||
x_min, y_min, x_max, y_max = region
|
||||
h, w = image.shape[:2]
|
||||
x_min = max(0, x_max)
|
||||
x_min = max(0, x_min)
|
||||
y_min = max(0, y_min)
|
||||
x_max = min(w, x_max)
|
||||
y_max = min(h, y_max)
|
||||
@@ -92,50 +340,28 @@ class ObjectDetector:
|
||||
def _detect_single(
|
||||
self, image: np.ndarray, threshold: float, label_filter: list[str] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
if not self.model:
|
||||
"""对单张图像执行检测"""
|
||||
if not self.session:
|
||||
return []
|
||||
results = self.model.predict(image, conf=threshold, verbose=False)
|
||||
detections = []
|
||||
|
||||
for result in results:
|
||||
if result.boxes is None:
|
||||
continue
|
||||
for box in result.boxes:
|
||||
cls_id = int(box.cls[0])
|
||||
label = self.model.names[cls_id]
|
||||
confidence = float(box.conf[0])
|
||||
# 预处理
|
||||
input_tensor = self._preprocess(image)
|
||||
|
||||
if label_filter and label not in label_filter:
|
||||
continue
|
||||
x1, y1, x2, y2 = box.xyxy[0].tolist()
|
||||
x_min, y_min = int(x1), int(y1)
|
||||
x_max, y_max = int(x2), int(y2)
|
||||
# 推理
|
||||
outputs = self.session.run(None, {self.input_name: input_tensor})
|
||||
|
||||
area = (x_max - x_min) * (y_max - y_min)
|
||||
|
||||
detections.append(
|
||||
{
|
||||
"label": label,
|
||||
"confidence": confidence,
|
||||
"box": {
|
||||
"x_min": x_min,
|
||||
"y_min": y_min,
|
||||
"x_max": x_max,
|
||||
"y_max": y_max,
|
||||
},
|
||||
"area": area,
|
||||
"norm_box": {
|
||||
"x": (x1 + x2) / 2 / image.shape[1],
|
||||
"y": (y1 + y2) / 2 / image.shape[0],
|
||||
"w": (x2 - x1) / image.shape[1],
|
||||
"h": (y2 - y1) / image.shape[0],
|
||||
},
|
||||
}
|
||||
)
|
||||
return detections
|
||||
# 后处理
|
||||
original_shape = image.shape[:2]
|
||||
output: np.ndarray = np.asarray(outputs[0])
|
||||
return self._postprocess(output, original_shape, threshold, label_filter)
|
||||
|
||||
|
||||
class MotionDetector:
|
||||
"""
|
||||
运动检测器 - 基于背景差分法
|
||||
用于在目标检测前预筛选有运动的帧,减少不必要的 AI 推理
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.backgrounds: dict[str, np.ndarray] = {}
|
||||
self.motion_threshold = 25
|
||||
@@ -152,8 +378,8 @@ class MotionDetector:
|
||||
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||
else:
|
||||
gray = image.copy()
|
||||
# 模糊可以平滑噪点
|
||||
# (21,21) 模糊大小,必须是奇数
|
||||
|
||||
# 高斯模糊平滑噪点
|
||||
gray = cv2.GaussianBlur(gray, (21, 21), 0)
|
||||
|
||||
if camera_name not in self.backgrounds:
|
||||
@@ -169,17 +395,18 @@ class MotionDetector:
|
||||
frame_delta, self.motion_threshold, 255, cv2.THRESH_BINARY
|
||||
)[1]
|
||||
|
||||
# ROI 区域掩码
|
||||
if roi_points and len(roi_points) > 0:
|
||||
mask = np.zeros((h, w), dtype=np.uint8)
|
||||
|
||||
pts = []
|
||||
for i in range(0, len(roi_points), 2):
|
||||
pts.append((int(roi_points[i] * w), int(roi_points[i + 1] * h)))
|
||||
pts_np = np.array([pts], dtype=np.int32)
|
||||
cv2.fillPoly(mask, pts_np, 255)
|
||||
thresh = cv2.bitwise_and(thresh, mask=mask)
|
||||
cv2.fillPoly(mask, [pts_np], (255,)) # type: ignore
|
||||
thresh = cv2.bitwise_and(thresh, thresh, mask=mask)
|
||||
|
||||
thresh = cv2.dilate(thresh, None, iterations=2)
|
||||
kernel = np.ones((3, 3), np.uint8)
|
||||
thresh = cv2.dilate(thresh, kernel, iterations=2)
|
||||
|
||||
contours, _ = cv2.findContours(
|
||||
thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
||||
@@ -189,9 +416,10 @@ class MotionDetector:
|
||||
for contour in contours:
|
||||
if cv2.contourArea(contour) < self.min_contour_area:
|
||||
continue
|
||||
x, y, w, h = cv2.boundingRect(contour)
|
||||
x, y, cw, ch = cv2.boundingRect(contour)
|
||||
motion_boxes.append(
|
||||
{"y_min": y, "x_min": x, "y_max": y + h, "x_max": x + w}
|
||||
{"y_min": y, "x_min": x, "y_max": y + ch, "x_max": x + cw}
|
||||
)
|
||||
|
||||
has_motion = len(motion_boxes) > 0
|
||||
return motion_boxes, has_motion
|
||||
|
||||
@@ -40,10 +40,17 @@ class LogPipe(threading.Thread):
|
||||
|
||||
|
||||
class FrameCapture:
|
||||
def __init__(self, rtsp_url: str, output_queue: queue.Queue, detect_fps: int = 5):
|
||||
def __init__(
|
||||
self,
|
||||
rtsp_url: str,
|
||||
output_queue: queue.Queue,
|
||||
detect_fps: int = 5,
|
||||
retry_limit: int = 10,
|
||||
):
|
||||
self.rtsp_url = rtsp_url
|
||||
self.output_queue = output_queue
|
||||
self.target_fps = detect_fps
|
||||
self.retry_limit = retry_limit
|
||||
self._stop_event = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._proccess: Optional[subprocess.Popen] = None
|
||||
@@ -53,6 +60,11 @@ class FrameCapture:
|
||||
self.height = 0
|
||||
self.fps = 0.0
|
||||
|
||||
# 错误状态,供外部查询
|
||||
self.error_count = 0
|
||||
self.last_error = ""
|
||||
self.is_failed = False
|
||||
|
||||
def start(self):
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return
|
||||
@@ -116,8 +128,18 @@ class FrameCapture:
|
||||
while not self._stop_event.is_set():
|
||||
if self.width == 0 or self.height == 0:
|
||||
if not self._get_stream_info():
|
||||
self.error_count += 1
|
||||
if self.error_count >= self.retry_limit:
|
||||
self.is_failed = True
|
||||
self.last_error = (
|
||||
f"探测流信息失败,已重试 {self.error_count} 次"
|
||||
)
|
||||
slog.error(self.last_error)
|
||||
return
|
||||
time.sleep(3)
|
||||
continue
|
||||
# 成功获取流信息后重置错误计数
|
||||
self.error_count = 0
|
||||
if log_pipe:
|
||||
log_pipe.close()
|
||||
log_pipe = LogPipe(f"ffmpeg.{self.rtsp_url}")
|
||||
@@ -187,6 +209,7 @@ class FrameCapture:
|
||||
|
||||
except Exception as e:
|
||||
slog.error(f"读取帧失败: {e}")
|
||||
self.last_error = str(e)
|
||||
log_pipe.dump()
|
||||
break
|
||||
self._terminate_process()
|
||||
@@ -196,6 +219,14 @@ class FrameCapture:
|
||||
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# ffmpeg 进程异常退出也计入错误计数
|
||||
self.error_count += 1
|
||||
if self.error_count >= self.retry_limit:
|
||||
self.is_failed = True
|
||||
self.last_error = f"帧捕获失败,已重试 {self.error_count} 次"
|
||||
slog.error(self.last_error)
|
||||
return
|
||||
time.sleep(2)
|
||||
|
||||
def _terminate_process(self):
|
||||
|
||||
+20
-15
@@ -1,13 +1,12 @@
|
||||
import os
|
||||
import signal
|
||||
|
||||
# 解决 macOS 上 OpenMP 库冲突问题,必须在导入 torch/cv2 等库之前设置
|
||||
# 解决 macOS 上 OpenMP 库冲突问题,必须在导入 cv2 等库之前设置
|
||||
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
from concurrent import futures
|
||||
from concurrent.futures import thread
|
||||
import logging
|
||||
import queue
|
||||
import sys
|
||||
@@ -17,7 +16,6 @@ from typing import Any
|
||||
import requests
|
||||
|
||||
import grpc
|
||||
from torch.export.exported_program import PassType
|
||||
|
||||
# 添加当前目录到 path 以支持直接运行
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -28,11 +26,9 @@ from frame_capture import FrameCapture
|
||||
import cv2
|
||||
|
||||
# 导入生成的 proto 代码
|
||||
try:
|
||||
import analysis_pb2
|
||||
import analysis_pb2_grpc
|
||||
except ImportError:
|
||||
pass
|
||||
# 这些模块必须存在才能启动 gRPC 服务
|
||||
import analysis_pb2
|
||||
import analysis_pb2_grpc
|
||||
|
||||
|
||||
slog = logging.getLogger("AI")
|
||||
@@ -88,7 +84,10 @@ class CameraTask:
|
||||
|
||||
self.frame_queue = queue.Queue(maxsize=1)
|
||||
self.capture = FrameCapture(
|
||||
rtsp_url, self.frame_queue, config.get("detect_fps", 5)
|
||||
rtsp_url,
|
||||
self.frame_queue,
|
||||
config.get("detect_fps", 5),
|
||||
config.get("retry_limit", 10),
|
||||
)
|
||||
|
||||
def start(self):
|
||||
@@ -112,6 +111,16 @@ class CameraTask:
|
||||
retry_limit = int(self.config.get("retry_limit", 10))
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
# 检查 FrameCapture 是否已达到重试上限
|
||||
if self.capture.is_failed:
|
||||
self.status = "error"
|
||||
self.last_error = self.capture.last_error
|
||||
self._send_stopped_callback("capture_failed", self.last_error)
|
||||
slog.error(
|
||||
f"CameraTask {self.camera_id} 因帧捕获失败而停止: {self.last_error}"
|
||||
)
|
||||
break
|
||||
|
||||
try:
|
||||
try:
|
||||
frame = self.frame_queue.get(timeout=2.0)
|
||||
@@ -410,7 +419,7 @@ def send_started_callback():
|
||||
if resp.ok:
|
||||
slog.info("启动通知发送成功")
|
||||
return
|
||||
slog.warning(f"启动通知返回非成功状态: {resp.status_code}")
|
||||
slog.warning(f"启动通知返回非成功状态: {resp.status_code} {full_url}")
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
slog.warning(f"发送启动通知失败 (连接错误): {e}")
|
||||
except Exception as e:
|
||||
@@ -449,10 +458,6 @@ def send_keepalive_callback(stats: dict):
|
||||
|
||||
|
||||
def serve(port, model_path):
|
||||
if "analysis_pb2_grpc" not in sys.modules:
|
||||
slog.error("Proto 代码未加载,退出。")
|
||||
return
|
||||
|
||||
# 启动父进程监控线程,确保 Go 退出时 Python 也退出
|
||||
threading.Thread(target=_watch_parent_process, daemon=True).start()
|
||||
|
||||
@@ -478,7 +483,7 @@ def serve(port, model_path):
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", type=int, default=50051)
|
||||
parser.add_argument("--model", type=str, default="yolo11n.pt")
|
||||
parser.add_argument("--model", type=str, default="yolo11n.onnx")
|
||||
parser.add_argument(
|
||||
"--callback-url",
|
||||
type=str,
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
# ONNX Runtime - 替代 PyTorch,大幅减小镜像体积
|
||||
onnxruntime>=1.17.0
|
||||
|
||||
# gRPC 框架
|
||||
grpcio==1.76.0
|
||||
grpcio-tools==1.76.0
|
||||
grpcio>=1.60.0
|
||||
|
||||
# 图像处理
|
||||
# 图像处理 (headless 版本更小)
|
||||
opencv-python-headless>=4.8.0
|
||||
numpy>=2.3.5
|
||||
numpy>=1.24.0
|
||||
|
||||
# YOLO 模型
|
||||
ultralytics>=8.0.0
|
||||
|
||||
# 科学计算
|
||||
scipy>=1.16.3
|
||||
|
||||
# HTTP 客户端 (用于回调)
|
||||
# HTTP 客户端
|
||||
requests>=2.31.0
|
||||
|
||||
@@ -10,7 +10,7 @@ require (
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/google/wire v0.7.0
|
||||
github.com/gowvp/onvif v0.0.14
|
||||
github.com/ixugo/goddd v1.5.1
|
||||
github.com/ixugo/goddd v1.5.2
|
||||
github.com/ixugo/netpulse v0.1.3
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
|
||||
@@ -73,8 +73,8 @@ github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
|
||||
github.com/gowvp/onvif v0.0.14 h1:NNrFqzqBHf9Z9MEQOiDpunpagXUHQraRjFmyiXhUwr4=
|
||||
github.com/gowvp/onvif v0.0.14/go.mod h1:Dshr55Q/Xgwa9XMQBPBQBMOWj/2Sq+DxLhdNY35uoFc=
|
||||
github.com/ixugo/goddd v1.5.1 h1:1GSFcBMTNz84PSRaUuOin4/6GSda9jc9pAzy78IwRlw=
|
||||
github.com/ixugo/goddd v1.5.1/go.mod h1:FzEjEd6uWEWan1XWTh8VXdqGtyjMYGow/URNtBY8X7w=
|
||||
github.com/ixugo/goddd v1.5.2 h1:ATFqegcDQEK2mUgHXbBEzXhH2Uw7L6RYLu9uuN3QBUE=
|
||||
github.com/ixugo/goddd v1.5.2/go.mod h1:FzEjEd6uWEWan1XWTh8VXdqGtyjMYGow/URNtBY8X7w=
|
||||
github.com/ixugo/netpulse v0.1.3 h1:760mxad/boWr5hxY2nD0I0yfmQcoNDrlu8KKyk7jOs0=
|
||||
github.com/ixugo/netpulse v0.1.3/go.mod h1:vH0zFyVMxDkz8jVHtI9/2oEb7npi/5+eSIx5RzHkN4g=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
|
||||
+5
-3
@@ -33,10 +33,12 @@ func Run(bc *conf.Bootstrap) {
|
||||
defer clean()
|
||||
|
||||
go setupZLM(ctx, bc.ConfigDir)
|
||||
go setupAIClient(ctx, "http://127.0.0.1:15123/ai", bc.Debug)
|
||||
if !bc.Server.AI.Disabled {
|
||||
go setupAIClient(ctx, "http://127.0.0.1:15123/ai", bc.Debug)
|
||||
}
|
||||
|
||||
// 如果需要执行表迁移,递增此版本号和表更新说明
|
||||
versionapi.DBVersion = "0.0.18"
|
||||
versionapi.DBVersion = "0.0.19"
|
||||
versionapi.DBRemark = "onvif device support"
|
||||
|
||||
handler, cleanUp, err := wireApp(bc, log)
|
||||
@@ -141,7 +143,7 @@ func findPythonPath() string {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return "python"
|
||||
return "python3"
|
||||
}
|
||||
|
||||
func setupAIClient(ctx context.Context, callback string, debug bool) {
|
||||
|
||||
@@ -7,14 +7,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gowvp/gb28181/internal/conf"
|
||||
"github.com/gowvp/gb28181/internal/data"
|
||||
"github.com/gowvp/gb28181/internal/web/api"
|
||||
"github.com/gowvp/gb28181/pkg/gbs"
|
||||
"github.com/ixugo/goddd/domain/version/versionapi"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Injectors from wire.go:
|
||||
@@ -42,7 +41,9 @@ func wireApp(bc *conf.Bootstrap, log *slog.Logger) (http.Handler, func(), error)
|
||||
proxyAPI := api.NewProxyAPI(proxyCore)
|
||||
configAPI := api.NewConfigAPI(db, bc)
|
||||
userAPI := api.NewUserAPI(bc)
|
||||
aiWebhookAPI := api.NewAIWebhookAPI(bc)
|
||||
eventCore := api.NewEventCore(db, bc)
|
||||
aiWebhookAPI := api.NewAIWebhookAPIWithDeps(bc, eventCore, ipcCore)
|
||||
eventAPI := api.NewEventAPI(eventCore, bc)
|
||||
usecase := &api.Usecase{
|
||||
Conf: bc,
|
||||
DB: db,
|
||||
@@ -57,6 +58,7 @@ func wireApp(bc *conf.Bootstrap, log *slog.Logger) (http.Handler, func(), error)
|
||||
SipServer: server,
|
||||
UserAPI: userAPI,
|
||||
AIWebhookAPI: aiWebhookAPI,
|
||||
EventAPI: eventAPI,
|
||||
}
|
||||
handler := api.NewHTTPHandler(usecase)
|
||||
return handler, func() {
|
||||
|
||||
@@ -19,13 +19,18 @@ type Server struct {
|
||||
Debug bool
|
||||
RTMPSecret string `comment:"rtmp 推流秘钥"`
|
||||
|
||||
Username string `comment:"登录用户名"`
|
||||
Password string `comment:"登录密码"`
|
||||
DisabledAI bool `comment:"是否禁用 ai 分析服务"`
|
||||
Username string `comment:"登录用户名"`
|
||||
Password string `comment:"登录密码"`
|
||||
|
||||
AI ServerAI `comment:"ai 分析服务"`
|
||||
HTTP ServerHTTP `comment:"对外提供的服务,建议由 nginx 代理"` // HTTP服务器
|
||||
}
|
||||
|
||||
type ServerAI struct {
|
||||
Disabled bool `comment:"是否禁用 ai 分析服务"`
|
||||
RetainDays int `comment:"保留天数"`
|
||||
}
|
||||
|
||||
type ServerHTTP struct {
|
||||
Port int `comment:"http 端口"` // 服务器端口号
|
||||
Timeout Duration `comment:"请求超时时间"` // 请求超时时间
|
||||
|
||||
@@ -21,6 +21,10 @@ func DefaultConfig() Bootstrap {
|
||||
AccessIps: []string{"::1", "127.0.0.1"},
|
||||
},
|
||||
},
|
||||
AI: ServerAI{
|
||||
Disabled: false,
|
||||
RetainDays: 10,
|
||||
},
|
||||
},
|
||||
Data: Data{
|
||||
Database: Database{
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/ixugo/goddd/pkg/orm"
|
||||
"github.com/ixugo/goddd/pkg/system"
|
||||
"github.com/ixugo/goddd/pkg/web"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// StartCleanupWorker 启动定时清理协程,每天凌晨 3 点执行一次清理
|
||||
// days 参数指定保留的天数,超过该天数的事件将被删除
|
||||
func (c Core) StartCleanupWorker(days int) {
|
||||
if days <= 0 {
|
||||
slog.Info("event cleanup disabled", "days", days)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("event cleanup worker started", "retain_days", days)
|
||||
|
||||
// 启动时先执行一次清理
|
||||
c.cleanupExpiredEvents(days)
|
||||
|
||||
// 计算到下一个凌晨 3 点的时间
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
c.cleanupExpiredEvents(days)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupExpiredEvents 清理过期的事件,先删除本地图片文件,再删除数据库记录
|
||||
func (c Core) cleanupExpiredEvents(days int) {
|
||||
ctx := context.Background()
|
||||
cutoffTime := time.Now().AddDate(0, 0, -days)
|
||||
cutoffMs := cutoffTime.UnixMilli()
|
||||
|
||||
slog.Info("starting event cleanup", "cutoff_time", cutoffTime.Format(time.DateTime), "retain_days", days)
|
||||
|
||||
// 分批查询并删除,避免一次性加载过多数据
|
||||
batchSize := 100
|
||||
totalDeleted := 0
|
||||
totalFilesDeleted := 0
|
||||
|
||||
for {
|
||||
// 查询一批过期事件
|
||||
var events []*Event
|
||||
pager := web.PagerFilter{Page: 1, Size: batchSize}
|
||||
_, err := c.store.Event().Find(ctx, &events, &pager,
|
||||
orm.Where("started_at < ?", cutoffMs),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("failed to query expired events", "err", err)
|
||||
break
|
||||
}
|
||||
|
||||
if len(events) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// 收集需要删除的图片路径(去重)
|
||||
imagePaths := make(map[string]struct{})
|
||||
eventIDs := make([]int64, 0, len(events))
|
||||
for _, e := range events {
|
||||
eventIDs = append(eventIDs, e.ID)
|
||||
if e.ImagePath != "" {
|
||||
imagePaths[e.ImagePath] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// 先删除本地图片文件
|
||||
eventsDir := filepath.Join(system.Getwd(), "configs", "events")
|
||||
for imagePath := range imagePaths {
|
||||
fullPath := filepath.Join(eventsDir, imagePath)
|
||||
if err := os.Remove(fullPath); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
slog.Warn("failed to delete event image", "path", fullPath, "err", err)
|
||||
}
|
||||
} else {
|
||||
totalFilesDeleted++
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除数据库记录,使用 WHERE IN 一次性删除
|
||||
err = c.store.Event().Session(ctx, func(tx *gorm.DB) error {
|
||||
return tx.Where("id IN ?", eventIDs).Delete(&Event{}).Error
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("failed to batch delete events", "count", len(eventIDs), "err", err)
|
||||
} else {
|
||||
totalDeleted += len(eventIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// 清理空目录
|
||||
cleanupEmptyDirs(filepath.Join(system.Getwd(), "configs", "events"))
|
||||
|
||||
slog.Info("event cleanup completed",
|
||||
"events_deleted", totalDeleted,
|
||||
"files_deleted", totalFilesDeleted,
|
||||
)
|
||||
}
|
||||
|
||||
// cleanupEmptyDirs 递归删除空目录
|
||||
func cleanupEmptyDirs(dir string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
subDir := filepath.Join(dir, entry.Name())
|
||||
cleanupEmptyDirs(subDir)
|
||||
|
||||
// 检查子目录是否为空
|
||||
subEntries, err := os.ReadDir(subDir)
|
||||
if err == nil && len(subEntries) == 0 {
|
||||
if err := os.Remove(subDir); err == nil {
|
||||
slog.Debug("removed empty directory", "path", subDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
// Code generated by godddx, DO AVOID EDIT.
|
||||
package event
|
||||
|
||||
// Storer data persistence
|
||||
type Storer interface {
|
||||
Event() EventStorer
|
||||
}
|
||||
|
||||
// Core business domain
|
||||
type Core struct {
|
||||
store Storer
|
||||
}
|
||||
|
||||
// NewCore create business domain
|
||||
func NewCore(store Storer) Core {
|
||||
return Core{store: store}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Package event 事件领域,负责管理 AI 检测事件的存储和查询。
|
||||
//
|
||||
// 事件由 AI 分析服务产生,每个检测到的目标对象(如 person、car)生成一条独立的事件记录。
|
||||
// 事件关联到具体的设备 (DID) 和通道 (CID),并记录检测时间、置信度、边界框等信息。
|
||||
//
|
||||
// 主要功能:
|
||||
// - 事件的增删改查
|
||||
// - 按通道和时间范围查询事件
|
||||
// - 事件图片的存储路径管理
|
||||
package event
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
// Code generated by godddx, DO AVOID EDIT.
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/ixugo/goddd/pkg/orm"
|
||||
"github.com/ixugo/goddd/pkg/reason"
|
||||
"github.com/jinzhu/copier"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// EventStorer Instantiation interface
|
||||
type EventStorer interface {
|
||||
Find(context.Context, *[]*Event, orm.Pager, ...orm.QueryOption) (int64, error)
|
||||
Get(context.Context, *Event, ...orm.QueryOption) error
|
||||
Add(context.Context, *Event) error
|
||||
Edit(context.Context, *Event, func(*Event), ...orm.QueryOption) error
|
||||
Del(context.Context, *Event, ...orm.QueryOption) error
|
||||
Count(context.Context, ...orm.QueryOption) (int64, error)
|
||||
|
||||
Session(context.Context, ...func(*gorm.DB) error) error
|
||||
EditWithSession(*gorm.DB, *Event, func(b *Event) error, ...orm.QueryOption) error
|
||||
}
|
||||
|
||||
// FindEvents 分页查询事件列表,支持按 CID 和时间范围筛选
|
||||
func (c Core) FindEvents(ctx context.Context, in *FindEventInput) ([]*Event, int64, error) {
|
||||
query := orm.NewQuery(4).OrderBy("started_at DESC")
|
||||
|
||||
if in.CID != "" {
|
||||
query.Where("cid = ?", in.CID)
|
||||
}
|
||||
if in.DID != "" {
|
||||
query.Where("did = ?", in.DID)
|
||||
}
|
||||
if in.Label != "" {
|
||||
query.Where("label = ?", in.Label)
|
||||
}
|
||||
if in.StartMs > 0 && in.EndMs > 0 {
|
||||
query.Where("started_at >= ? AND started_at <= ?", in.StartAt(), in.EndAt())
|
||||
}
|
||||
|
||||
items := make([]*Event, 0, in.Limit())
|
||||
total, err := c.store.Event().Find(ctx, &items, in, query.Encode()...)
|
||||
if err != nil {
|
||||
return nil, 0, reason.ErrDB.Withf(`Find in[%+v] err[%s]`, in, err.Error())
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// GetEvent 根据 ID 查询单个事件
|
||||
func (c Core) GetEvent(ctx context.Context, id int64) (*Event, error) {
|
||||
out := Event{ID: id}
|
||||
if err := c.store.Event().Get(ctx, &out, orm.Where("id=?", id)); err != nil {
|
||||
if orm.IsErrRecordNotFound(err) {
|
||||
return nil, reason.ErrNotFound.Withf(`Get id[%v] err[%s]`, id, err.Error())
|
||||
}
|
||||
return nil, reason.ErrDB.Withf(`Get id[%v] err[%s]`, id, err.Error())
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// AddEvent 新增事件记录
|
||||
func (c Core) AddEvent(ctx context.Context, in *AddEventInput) (*Event, error) {
|
||||
var out Event
|
||||
if err := copier.Copy(&out, in); err != nil {
|
||||
slog.ErrorContext(ctx, "Copy", "err", err)
|
||||
}
|
||||
|
||||
if err := c.store.Event().Add(ctx, &out); err != nil {
|
||||
return nil, reason.ErrDB.Withf(`Add err[%s]`, err.Error())
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// EditEvent 更新事件信息
|
||||
func (c Core) EditEvent(ctx context.Context, in *EditEventInput, id int64) (*Event, error) {
|
||||
var out Event
|
||||
if err := c.store.Event().Edit(ctx, &out, func(b *Event) {
|
||||
if !in.EndedAt.IsZero() {
|
||||
b.EndedAt = in.EndedAt
|
||||
}
|
||||
}, orm.Where("id=?", id)); err != nil {
|
||||
return nil, reason.ErrDB.Withf(`Edit id[%v] err[%s]`, id, err.Error())
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// DelEvent 删除事件
|
||||
func (c Core) DelEvent(ctx context.Context, id int64) (*Event, error) {
|
||||
var out Event
|
||||
if err := c.store.Event().Del(ctx, &out, orm.Where("id=?", id)); err != nil {
|
||||
return nil, reason.ErrDB.Withf(`Del id[%v] err[%s]`, id, err.Error())
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
// Code generated by godddx, DO AVOID EDIT.
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ixugo/goddd/pkg/orm"
|
||||
)
|
||||
|
||||
// Event domain model
|
||||
type Event struct {
|
||||
ID int64 `gorm:"primaryKey" json:"id"` // 事件 ID
|
||||
DID string `gorm:"column:did;notNull;default:'';comment:设备 ID (device 表的 id)" json:"did"` // 设备 ID (device 表的 id)
|
||||
CID string `gorm:"column:cid;notNull;default:'';comment:通道 ID (channel 表的 id)" json:"cid"` // 通道 ID (channel 表的 id)
|
||||
StartedAt orm.Time `gorm:"column:started_at;notNull;default:CURRENT_TIMESTAMP;comment:事件开始时间" json:"started_at"` // 事件开始时间
|
||||
EndedAt orm.Time `gorm:"column:ended_at;notNull;default:CURRENT_TIMESTAMP;comment:事件结束时间" json:"ended_at"` // 事件结束时间
|
||||
Label string `gorm:"column:label;notNull;default:'';comment:检测标签 (person, car 等)" json:"label"` // 检测标签 (person, car 等)
|
||||
Score float32 `gorm:"column:score;notNull;default:0;comment:置信度 (0.0-1.0)" json:"score"` // 置信度 (0.0-1.0)
|
||||
Zones string `gorm:"column:zones;notNull;default:'';comment:检测区域,JSON 格式存储边界框信息" json:"zones"` // 检测区域,JSON 格式存储边界框信息
|
||||
ImagePath string `gorm:"column:image_path;notNull;default:'';comment:图片相对路径 (cid/年月日时分秒_随机6位.jpg)" json:"image_path"` // 图片相对路径 (cid/年月日时分秒_随机6位.jpg)
|
||||
Model string `gorm:"column:model;notNull;default:'';comment:分析模型名称" json:"model"` // 分析模型名称
|
||||
CreatedAt orm.Time `gorm:"column:created_at;notNull;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"` // 创建时间
|
||||
UpdatedAt orm.Time `gorm:"column:updated_at;notNull;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
// TableName database table name
|
||||
func (*Event) TableName() string {
|
||||
return "events"
|
||||
}
|
||||
|
||||
// CacheKey 缓存主键,必须唯一
|
||||
// godddx 生成缓存代码时,依赖的主键
|
||||
// 默认应该是 ID 字段,但也可以自定义
|
||||
func (e *Event) CacheKey() string {
|
||||
return fmt.Sprintf("%d", e.ID)
|
||||
}
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
// Code generated by godddx, DO AVOID EDIT.
|
||||
package event
|
||||
|
||||
import (
|
||||
"github.com/ixugo/goddd/pkg/orm"
|
||||
"github.com/ixugo/goddd/pkg/web"
|
||||
)
|
||||
|
||||
type FindEventInput struct {
|
||||
web.PagerFilter
|
||||
web.DateFilter
|
||||
DID string `form:"did"` // 设备 ID
|
||||
CID string `form:"cid"` // 通道 ID
|
||||
Label string `form:"label"` // 检测标签
|
||||
}
|
||||
|
||||
type EditEventInput struct {
|
||||
EndedAt orm.Time `json:"ended_at"` // 事件结束时间 (毫秒时间戳)
|
||||
}
|
||||
|
||||
type AddEventInput struct {
|
||||
DID string `json:"-"` // 设备 ID (API 层填充)
|
||||
CID string `json:"-"` // 通道 ID (API 层填充)
|
||||
StartedAt orm.Time `json:"started_at"` // 事件开始时间 (毫秒时间戳)
|
||||
EndedAt orm.Time `json:"ended_at"` // 事件结束时间 (毫秒时间戳)
|
||||
Label string `json:"label"` // 检测标签
|
||||
Score float32 `json:"score"` // 置信度
|
||||
Zones string `json:"zones"` // 检测区域 JSON
|
||||
ImagePath string `json:"image_path"` // 图片相对路径
|
||||
Model string `json:"model"` // 分析模型名称
|
||||
}
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
// Code generated by godddx, DO AVOID EDIT.
|
||||
package event
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
// Code generated by godddx, DO AVOID EDIT.
|
||||
package eventcache
|
||||
|
||||
import (
|
||||
"github.com/gowvp/gb28181/internal/core/event"
|
||||
"github.com/ixugo/goddd/pkg/conc"
|
||||
)
|
||||
|
||||
var _ event.Storer = (*Cache)(nil)
|
||||
|
||||
func NewCache(store event.Storer, cache conc.Cacher) *Cache {
|
||||
return &Cache{
|
||||
store: store,
|
||||
event: cache,
|
||||
}
|
||||
}
|
||||
|
||||
type Cache struct {
|
||||
store event.Storer
|
||||
event conc.Cacher
|
||||
}
|
||||
|
||||
// Event implements event.EventStorer
|
||||
func (c *Cache) Event() event.EventStorer {
|
||||
return (*Event)(c)
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
// Code generated by godddx, DO AVOID EDIT.
|
||||
package eventcache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gowvp/gb28181/internal/core/event"
|
||||
"github.com/ixugo/goddd/pkg/orm"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 若不需要实现缓存,可以注释
|
||||
var _ event.EventStorer = (*Event)(nil)
|
||||
|
||||
type Event Cache
|
||||
|
||||
func (c *Event) cacheKey(key any) string {
|
||||
return fmt.Sprintf("EVENT:%v", key)
|
||||
}
|
||||
|
||||
// Find implements event.EventStorer.
|
||||
func (c *Event) Find(ctx context.Context, bs *[]*event.Event, page orm.Pager, opts ...orm.QueryOption) (int64, error) {
|
||||
return c.store.Event().Find(ctx, bs, page, opts...)
|
||||
}
|
||||
|
||||
// Get implements event.EventStorer.
|
||||
// 注意: 若想走缓存,则 model 的 CacheKey 必须实现且必须存在值
|
||||
// 当 CacheKey 为 id 且出现 orm.Where("id=?",id) 时,查询条件会变成
|
||||
// SELECT * FROM `users` WHERE `id` = 1 AND `users`.`id` = 1
|
||||
// 两个值一样则不会受到影响
|
||||
func (c *Event) Get(ctx context.Context, model *event.Event, opts ...orm.QueryOption) error {
|
||||
if model.CacheKey() != "" {
|
||||
if err := c.event.Get(ctx, c.cacheKey(model.CacheKey()), model); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := c.store.Event().Get(ctx, model, opts...); err != nil {
|
||||
return err
|
||||
}
|
||||
c.event.SetNX(ctx, c.cacheKey(model.CacheKey()), model)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add implements event.EventStorer.
|
||||
func (c *Event) Add(ctx context.Context, model *event.Event) error {
|
||||
if err := c.store.Event().Add(ctx, model); err != nil {
|
||||
return err
|
||||
}
|
||||
c.event.Set(ctx, c.cacheKey(model.CacheKey()), model)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Edit implements event.EventStorer.
|
||||
func (c *Event) Edit(ctx context.Context, model *event.Event, changeFn func(*event.Event), opts ...orm.QueryOption) error {
|
||||
if err := c.store.Event().Edit(ctx, model, changeFn, opts...); err != nil {
|
||||
return err
|
||||
}
|
||||
c.event.Set(ctx, c.cacheKey(model.CacheKey()), model)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Del implements event.EventStorer.
|
||||
func (c *Event) Del(ctx context.Context, model *event.Event, opts ...orm.QueryOption) error {
|
||||
if err := c.store.Event().Del(ctx, model, opts...); err != nil {
|
||||
return err
|
||||
}
|
||||
c.event.Del(ctx, c.cacheKey(model.CacheKey()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count implements event.EventStorer.
|
||||
func (c *Event) Count(ctx context.Context, opts ...orm.QueryOption) (int64, error) {
|
||||
return c.store.Event().Count(ctx, opts...)
|
||||
}
|
||||
|
||||
// Session 事务组合
|
||||
func (c *Event) Session(ctx context.Context, changeFns ...func(*gorm.DB) error) error {
|
||||
// return c.store.Event().Session(ctx, changeFns...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// EditWithSession 修改事务
|
||||
func (c *Event) EditWithSession(tx *gorm.DB, model *event.Event, changeFn func(b *event.Event) error, opts ...orm.QueryOption) error {
|
||||
// if err := c.store.Event().EditWithSession(ctx,model, changeFn,opts...);err!=nil{
|
||||
// return err
|
||||
// }
|
||||
// c.event.Set(ctx, c.cacheKey(model.CacheKey() ), model)
|
||||
return nil
|
||||
}
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
// Code generated by godddx, DO AVOID EDIT.
|
||||
package eventdb
|
||||
|
||||
import (
|
||||
"github.com/gowvp/gb28181/internal/core/event"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var _ event.Storer = DB{}
|
||||
|
||||
// DB Related business namespaces
|
||||
type DB struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewDB instance object
|
||||
func NewDB(db *gorm.DB) DB {
|
||||
return DB{db: db}
|
||||
}
|
||||
|
||||
// Event Get business instance
|
||||
func (d DB) Event() event.EventStorer {
|
||||
return Event(d)
|
||||
}
|
||||
|
||||
// AutoMigrate sync database
|
||||
func (d DB) AutoMigrate(ok bool) DB {
|
||||
if !ok {
|
||||
return d
|
||||
}
|
||||
if err := d.db.AutoMigrate(
|
||||
new(event.Event),
|
||||
); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
// Code generated by godddx, DO AVOID EDIT.
|
||||
package eventdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gowvp/gb28181/internal/core/event"
|
||||
"github.com/ixugo/goddd/pkg/orm"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var _ event.EventStorer = Event{}
|
||||
|
||||
// Event Related business namespaces
|
||||
type Event DB
|
||||
|
||||
// NewEvent instance object
|
||||
func NewEvent(db *gorm.DB) Event {
|
||||
return Event{db: db}
|
||||
}
|
||||
|
||||
// Find implements event.EventStorer.
|
||||
func (d Event) Find(ctx context.Context, bs *[]*event.Event, page orm.Pager, opts ...orm.QueryOption) (int64, error) {
|
||||
return orm.FindWithContext(ctx, d.db, bs, page, opts...)
|
||||
}
|
||||
|
||||
// Get implements event.EventStorer.
|
||||
func (d Event) Get(ctx context.Context, model *event.Event, opts ...orm.QueryOption) error {
|
||||
return orm.FirstWithContext(ctx, d.db, model, opts...)
|
||||
}
|
||||
|
||||
// Add implements event.EventStorer.
|
||||
func (d Event) Add(ctx context.Context, model *event.Event) error {
|
||||
return d.db.WithContext(ctx).Create(model).Error
|
||||
}
|
||||
|
||||
// Edit implements event.EventStorer.
|
||||
func (d Event) Edit(ctx context.Context, model *event.Event, changeFn func(*event.Event), opts ...orm.QueryOption) error {
|
||||
return orm.UpdateWithContext(ctx, d.db, model, changeFn, opts...)
|
||||
}
|
||||
|
||||
// Del implements event.EventStorer.
|
||||
func (d Event) Del(ctx context.Context, model *event.Event, opts ...orm.QueryOption) error {
|
||||
return orm.DeleteWithContext(ctx, d.db, model, opts...)
|
||||
}
|
||||
|
||||
// Count implements event.EventStorer.
|
||||
func (d Event) Count(ctx context.Context, opts ...orm.QueryOption) (int64, error) {
|
||||
return orm.CountWithContext[event.Event](ctx, d.db, opts...)
|
||||
}
|
||||
|
||||
// Session 事务组合
|
||||
func (d Event) Session(ctx context.Context, changeFns ...func(*gorm.DB) error) error {
|
||||
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, fn := range changeFns {
|
||||
if err := fn(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// EditWithSession 修改事务
|
||||
func (d Event) EditWithSession(tx *gorm.DB, model *event.Event, changeFn func(b *event.Event) error, opts ...orm.QueryOption) error {
|
||||
return orm.UpdateWithSession(tx, model, changeFn, opts...)
|
||||
}
|
||||
@@ -2,35 +2,46 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gowvp/gb28181/internal/conf"
|
||||
"github.com/gowvp/gb28181/internal/core/event"
|
||||
"github.com/gowvp/gb28181/internal/core/ipc"
|
||||
"github.com/gowvp/gb28181/internal/rpc"
|
||||
"github.com/ixugo/goddd/pkg/conc"
|
||||
"github.com/ixugo/goddd/pkg/orm"
|
||||
"github.com/ixugo/goddd/pkg/system"
|
||||
"github.com/ixugo/goddd/pkg/web"
|
||||
)
|
||||
|
||||
// AIWebhookAPI 处理 AI 分析服务的回调请求
|
||||
type AIWebhookAPI struct {
|
||||
log *slog.Logger
|
||||
conf *conf.Bootstrap
|
||||
aiTasks *conc.Map[string, struct{}]
|
||||
ai *rpc.AIClient
|
||||
log *slog.Logger
|
||||
conf *conf.Bootstrap
|
||||
aiTasks *conc.Map[string, struct{}]
|
||||
limiter func(identifier string) bool
|
||||
ai *rpc.AIClient
|
||||
eventCore event.Core
|
||||
ipcCore ipc.Core
|
||||
}
|
||||
|
||||
// NewAIWebhookAPI 创建 AI Webhook API 实例
|
||||
func NewAIWebhookAPI(conf *conf.Bootstrap) AIWebhookAPI {
|
||||
func NewAIWebhookAPI(conf *conf.Bootstrap, eventCore event.Core, ipcCore ipc.Core) AIWebhookAPI {
|
||||
return AIWebhookAPI{
|
||||
log: slog.With("hook", "ai"),
|
||||
conf: conf,
|
||||
ai: rpc.NewAIClient("127.0.0.1:50051"),
|
||||
aiTasks: conc.NewMap[string, struct{}](),
|
||||
log: slog.With("hook", "ai"),
|
||||
conf: conf,
|
||||
ai: rpc.NewAIClient("127.0.0.1:50051"),
|
||||
aiTasks: conc.NewMap[string, struct{}](),
|
||||
eventCore: eventCore,
|
||||
ipcCore: ipcCore,
|
||||
limiter: web.IDRateLimiter(0.2, 1, 3*time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,28 +80,67 @@ func (a AIWebhookAPI) onStarted(c *gin.Context, in *AIStartedInput) (AIWebhookOu
|
||||
return newAIWebhookOutputOK(), nil
|
||||
}
|
||||
|
||||
// onEvents 接收 AI 检测事件,将快照保存到临时目录
|
||||
// onEvents 接收 AI 检测事件,按 label 分别存储到数据库,图片保存到 configs/events 目录
|
||||
func (a AIWebhookAPI) onEvents(c *gin.Context, in *AIDetectionInput) (AIWebhookOutput, error) {
|
||||
a.log.InfoContext(c.Request.Context(), "ai detection event",
|
||||
if !a.limiter(in.CameraID) {
|
||||
return newAIWebhookOutputOK(), nil
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
a.log.InfoContext(ctx, "ai detection event",
|
||||
"camera_id", in.CameraID,
|
||||
"timestamp", in.Timestamp,
|
||||
"detection_count", len(in.Detections),
|
||||
"snapshot_size", fmt.Sprintf("%dx%d", in.SnapshotWidth, in.SnapshotHeight),
|
||||
)
|
||||
|
||||
// 获取通道信息以确定 DID
|
||||
cid := in.CameraID
|
||||
var did string
|
||||
channel, err := a.ipcCore.GetChannel(ctx, cid)
|
||||
if err == nil && channel != nil {
|
||||
did = channel.DID
|
||||
}
|
||||
|
||||
// 保存图片并获取相对路径
|
||||
var imagePath string
|
||||
if in.Snapshot != "" {
|
||||
var err error
|
||||
imagePath, err = saveEventSnapshot(cid, in.Timestamp, in.Snapshot)
|
||||
if err != nil {
|
||||
a.log.ErrorContext(ctx, "save snapshot failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 按 label 分别存储事件,每个 label 是一个独立事件
|
||||
for i, det := range in.Detections {
|
||||
a.log.InfoContext(c.Request.Context(), "detection detail",
|
||||
a.log.InfoContext(ctx, "detection detail",
|
||||
"index", i,
|
||||
"label", det.Label,
|
||||
"confidence", det.Confidence,
|
||||
"box", fmt.Sprintf("(%d,%d)-(%d,%d)", det.Box.XMin, det.Box.YMin, det.Box.XMax, det.Box.YMax),
|
||||
"area", det.Area,
|
||||
)
|
||||
}
|
||||
|
||||
if in.Snapshot != "" {
|
||||
if err := saveSnapshot(in.CameraID, in.Timestamp, in.Snapshot); err != nil {
|
||||
a.log.ErrorContext(c.Request.Context(), "save snapshot failed", "err", err)
|
||||
zonesJSON, _ := json.Marshal(det.Box)
|
||||
|
||||
eventInput := &event.AddEventInput{
|
||||
DID: did,
|
||||
CID: cid,
|
||||
StartedAt: in.Timestamp,
|
||||
EndedAt: in.Timestamp,
|
||||
Label: det.Label,
|
||||
Score: float32(det.Confidence),
|
||||
Zones: string(zonesJSON),
|
||||
ImagePath: imagePath,
|
||||
Model: "yolo11n",
|
||||
}
|
||||
|
||||
if _, err := a.eventCore.AddEvent(ctx, eventInput); err != nil {
|
||||
a.log.ErrorContext(ctx, "save event failed",
|
||||
"label", det.Label,
|
||||
"err", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,26 +159,30 @@ func (a AIWebhookAPI) onStopped(c *gin.Context, in *AIStoppedInput) (AIWebhookOu
|
||||
return newAIWebhookOutputOK(), nil
|
||||
}
|
||||
|
||||
// saveSnapshot 将 Base64 编码的快照保存到临时目录
|
||||
func saveSnapshot(cameraID string, timestamp int64, snapshotB64 string) error {
|
||||
tmpDir := filepath.Join(system.Getwd(), "configs", "demo")
|
||||
// saveEventSnapshot 将 Base64 编码的快照保存到 configs/events/{cid}/ 目录
|
||||
// 返回相对路径: cid/年月日时分秒_随机6位.jpg
|
||||
func saveEventSnapshot(cid string, t orm.Time, snapshotB64 string) (string, error) {
|
||||
eventsDir := filepath.Join(system.Getwd(), "configs", "events")
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(snapshotB64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode base64: %w", err)
|
||||
return "", fmt.Errorf("decode base64: %w", err)
|
||||
}
|
||||
|
||||
t := time.UnixMilli(timestamp)
|
||||
filename := fmt.Sprintf("%s_%s.jpg", cameraID, t.Format("20060102_150405"))
|
||||
filePath := filepath.Join(tmpDir, filename)
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
|
||||
return fmt.Errorf("create tmp dir: %w", err)
|
||||
randomSuffix := fmt.Sprintf("%06d", rand.IntN(1000000))
|
||||
filename := fmt.Sprintf("%s_%s.jpg", t.Format("20060102150405"), randomSuffix)
|
||||
|
||||
relativePath := filepath.Join(cid, filename)
|
||||
fullPath := filepath.Join(eventsDir, relativePath)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
|
||||
return "", fmt.Errorf("create events dir: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filePath, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write file: %w", err)
|
||||
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
|
||||
return "", fmt.Errorf("write file: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("snapshot saved", "path", filePath, "size", len(data))
|
||||
return nil
|
||||
slog.Info("event snapshot saved", "path", fullPath, "size", len(data))
|
||||
return relativePath, nil
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package api
|
||||
|
||||
import "github.com/ixugo/goddd/pkg/orm"
|
||||
|
||||
// AIKeepaliveInput 心跳回调请求体
|
||||
type AIKeepaliveInput struct {
|
||||
Timestamp int64 `json:"timestamp"` // Unix 时间戳 (毫秒)
|
||||
@@ -16,7 +18,7 @@ type AIStartedInput struct {
|
||||
// AIDetectionInput 检测事件回调请求体
|
||||
type AIDetectionInput struct {
|
||||
CameraID string `json:"camera_id"` // 摄像头 ID
|
||||
Timestamp int64 `json:"timestamp"` // Unix 时间戳 (毫秒)
|
||||
Timestamp orm.Time `json:"timestamp"` // Unix 时间戳 (毫秒)
|
||||
Detections []AIDetection `json:"detections"` // 检测结果列表
|
||||
Snapshot string `json:"snapshot"` // Base64 编码的快照 (JPEG)
|
||||
SnapshotWidth int `json:"snapshot_width"` // 快照宽度
|
||||
@@ -25,10 +27,10 @@ type AIDetectionInput struct {
|
||||
|
||||
// AIStoppedInput 任务停止回调请求体
|
||||
type AIStoppedInput struct {
|
||||
CameraID string `json:"camera_id"` // 摄像头 ID
|
||||
Timestamp int64 `json:"timestamp"` // Unix 时间戳 (毫秒)
|
||||
Reason string `json:"reason"` // 停止原因 (user_requested, error)
|
||||
Message string `json:"message"` // 详细信息
|
||||
CameraID string `json:"camera_id"` // 摄像头 ID
|
||||
Timestamp orm.Time `json:"timestamp"` // Unix 时间戳 (毫秒)
|
||||
Reason string `json:"reason"` // 停止原因 (user_requested, error)
|
||||
Message string `json:"message"` // 详细信息
|
||||
}
|
||||
|
||||
// AIDetection 检测对象
|
||||
|
||||
@@ -46,10 +46,12 @@ func setupRouter(r *gin.Engine, uc *Usecase) {
|
||||
web.Metrics(),
|
||||
web.Logger(web.IgnorePrefix(staticPrefix),
|
||||
web.IgnoreMethod(http.MethodOptions),
|
||||
web.IgnorePrefix("/events/image"),
|
||||
),
|
||||
web.LoggerWithBody(web.DefaultBodyLimit,
|
||||
web.IgnoreBool(uc.Conf.Debug),
|
||||
web.IgnoreMethod(http.MethodOptions),
|
||||
web.IgnorePrefix("/events/image"),
|
||||
),
|
||||
)
|
||||
go web.CountGoroutines(10*time.Minute, 20)
|
||||
@@ -108,6 +110,8 @@ func setupRouter(r *gin.Engine, uc *Usecase) {
|
||||
|
||||
// 注册 AI 分析服务回调接口
|
||||
registerAIWebhookAPI(r, uc.AIWebhookAPI)
|
||||
// TODO: 待补充中间件
|
||||
RegisterEvent(r, uc.EventAPI)
|
||||
}
|
||||
|
||||
type playOutput struct {
|
||||
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
// Code generated by godddx, DO AVOID EDIT.
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gowvp/gb28181/internal/conf"
|
||||
"github.com/gowvp/gb28181/internal/core/event"
|
||||
"github.com/gowvp/gb28181/internal/core/event/store/eventdb"
|
||||
"github.com/ixugo/goddd/pkg/orm"
|
||||
"github.com/ixugo/goddd/pkg/reason"
|
||||
"github.com/ixugo/goddd/pkg/system"
|
||||
"github.com/ixugo/goddd/pkg/web"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// EventAPI 为 http 提供业务方法
|
||||
type EventAPI struct {
|
||||
eventCore event.Core
|
||||
conf *conf.Bootstrap
|
||||
}
|
||||
|
||||
func NewEventCore(db *gorm.DB, conf *conf.Bootstrap) event.Core {
|
||||
var store event.Storer
|
||||
store = eventdb.NewDB(db).AutoMigrate(orm.GetEnabledAutoMigrate())
|
||||
core := event.NewCore(store)
|
||||
|
||||
// 启动定时清理协程
|
||||
days := max(conf.Server.AI.RetainDays, 1)
|
||||
|
||||
go core.StartCleanupWorker(days)
|
||||
|
||||
return core
|
||||
}
|
||||
|
||||
func NewEventAPI(core event.Core, conf *conf.Bootstrap) EventAPI {
|
||||
return EventAPI{eventCore: core, conf: conf}
|
||||
}
|
||||
|
||||
func RegisterEvent(g gin.IRouter, api EventAPI, handler ...gin.HandlerFunc) {
|
||||
{
|
||||
group := g.Group("/events", handler...)
|
||||
group.GET("", web.WrapH(api.findEvents))
|
||||
group.GET("/:id", web.WrapH(api.getEvent))
|
||||
group.PUT("/:id", web.WrapH(api.editEvent))
|
||||
group.DELETE("/:id", web.WrapH(api.delEvent))
|
||||
}
|
||||
// 图片接口不需要认证中间件
|
||||
g.GET("/events/image/*path", api.getEventImage)
|
||||
}
|
||||
|
||||
// findEvents 分页查询事件列表
|
||||
func (a EventAPI) findEvents(c *gin.Context, in *event.FindEventInput) (any, error) {
|
||||
items, total, err := a.eventCore.FindEvents(c.Request.Context(), in)
|
||||
return gin.H{"items": items, "total": total}, err
|
||||
}
|
||||
|
||||
// getEvent 获取单个事件详情
|
||||
func (a EventAPI) getEvent(c *gin.Context, _ *struct{}) (*event.Event, error) {
|
||||
eventID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
return a.eventCore.GetEvent(c.Request.Context(), eventID)
|
||||
}
|
||||
|
||||
// editEvent 更新事件信息
|
||||
func (a EventAPI) editEvent(c *gin.Context, in *event.EditEventInput) (*event.Event, error) {
|
||||
eventID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
return a.eventCore.EditEvent(c.Request.Context(), in, eventID)
|
||||
}
|
||||
|
||||
// delEvent 删除事件
|
||||
func (a EventAPI) delEvent(c *gin.Context, _ *struct{}) (*event.Event, error) {
|
||||
eventID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
return a.eventCore.DelEvent(c.Request.Context(), eventID)
|
||||
}
|
||||
|
||||
// getEventImage 获取事件快照图片
|
||||
func (a EventAPI) getEventImage(c *gin.Context) {
|
||||
imagePath := c.Param("path")
|
||||
if imagePath == "" {
|
||||
web.Fail(c, reason.ErrNotFound.SetMsg("image path is required"))
|
||||
return
|
||||
}
|
||||
|
||||
// 去除开头的斜杠
|
||||
if len(imagePath) > 0 && imagePath[0] == '/' {
|
||||
imagePath = imagePath[1:]
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(system.Getwd(), "configs", "events", imagePath)
|
||||
|
||||
// 安全检查:防止路径遍历攻击
|
||||
eventsDir := filepath.Join(system.Getwd(), "configs", "events")
|
||||
absPath, err := filepath.Abs(fullPath)
|
||||
if err != nil || !filepath.HasPrefix(absPath, eventsDir) {
|
||||
web.Fail(c, reason.ErrNotFound.SetMsg("invalid path"))
|
||||
return
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
web.Fail(c, reason.ErrNotFound.SetMsg(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(200, "image/jpeg", body)
|
||||
}
|
||||
@@ -310,7 +310,7 @@ func (a IPCAPI) play(c *gin.Context, _ *struct{}) (*playOutput, error) {
|
||||
}
|
||||
break
|
||||
}
|
||||
if a.uc.Conf.Server.DisabledAI || a.uc.AIWebhookAPI.ai == nil {
|
||||
if a.uc.Conf.Server.AI.Disabled || a.uc.AIWebhookAPI.ai == nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/gowvp/gb28181/internal/adapter/onvifadapter"
|
||||
"github.com/gowvp/gb28181/internal/adapter/rtspadapter"
|
||||
"github.com/gowvp/gb28181/internal/conf"
|
||||
"github.com/gowvp/gb28181/internal/core/event"
|
||||
"github.com/gowvp/gb28181/internal/core/ipc"
|
||||
"github.com/gowvp/gb28181/internal/core/ipc/store/ipccache"
|
||||
"github.com/gowvp/gb28181/internal/core/ipc/store/ipcdb"
|
||||
@@ -40,7 +41,8 @@ var (
|
||||
NewProxyAPI, NewProxyCore,
|
||||
NewConfigAPI,
|
||||
NewUserAPI,
|
||||
NewAIWebhookAPI,
|
||||
NewAIWebhookAPIWithDeps,
|
||||
NewEventCore, NewEventAPI,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -59,6 +61,8 @@ type Usecase struct {
|
||||
SipServer *gbs.Server
|
||||
UserAPI UserAPI
|
||||
AIWebhookAPI AIWebhookAPI
|
||||
|
||||
EventAPI EventAPI
|
||||
}
|
||||
|
||||
// NewHTTPHandler 生成Gin框架路由内容
|
||||
@@ -112,3 +116,8 @@ func NewProtocols(adapter ipc.Adapter, sms sms.Core, proxyCore *proxy.Core, gbs
|
||||
protocols[ipc.TypeGB28181] = gbadapter.NewAdapter(adapter, gbs, sms)
|
||||
return protocols
|
||||
}
|
||||
|
||||
// NewAIWebhookAPIWithDeps 创建带依赖的 AI Webhook API
|
||||
func NewAIWebhookAPIWithDeps(conf *conf.Bootstrap, eventCore event.Core, ipcCore ipc.Core) AIWebhookAPI {
|
||||
return NewAIWebhookAPI(conf, eventCore, ipcCore)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"net"
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
package protos
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -8,6 +8,7 @@ package protos
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
|
||||
Reference in New Issue
Block a user