support detect

This commit is contained in:
xugo
2026-01-08 00:34:09 +08:00
parent 246497401d
commit 4cd40cbebb
35 changed files with 1227 additions and 147 deletions
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.yaml
*.pt
+2
View File
@@ -41,3 +41,5 @@ cover/
*.test
*.jpg
__pycache__/
*.pt
*.onnx
+75
View File
@@ -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.1ZLMediaKit 需要)
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"]
View File
+9 -5
View File
@@ -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
View File
@@ -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
+32 -1
View File
@@ -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
View File
@@ -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,
+7 -11
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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) {
+6 -4
View File
@@ -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() {
+8 -3
View File
@@ -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:"请求超时时间"` // 请求超时时间
+4
View File
@@ -21,6 +21,10 @@ func DefaultConfig() Bootstrap {
AccessIps: []string{"::1", "127.0.0.1"},
},
},
AI: ServerAI{
Disabled: false,
RetainDays: 10,
},
},
Data: Data{
Database: Database{
+131
View File
@@ -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)
}
}
}
}
}
+17
View File
@@ -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}
}
+10
View File
@@ -0,0 +1,10 @@
// Package event 事件领域,负责管理 AI 检测事件的存储和查询。
//
// 事件由 AI 分析服务产生,每个检测到的目标对象(如 person、car)生成一条独立的事件记录。
// 事件关联到具体的设备 (DID) 和通道 (CID),并记录检测时间、置信度、边界框等信息。
//
// 主要功能:
// - 事件的增删改查
// - 按通道和时间范围查询事件
// - 事件图片的存储路径管理
package event
+97
View File
@@ -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
}
+36
View File
@@ -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)
}
+31
View File
@@ -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"` // 分析模型名称
}
+2
View File
@@ -0,0 +1,2 @@
// Code generated by godddx, DO AVOID EDIT.
package event
+26
View File
@@ -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
View File
@@ -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
}
+37
View File
@@ -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
}
+67
View File
@@ -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...)
}
+83 -29
View File
@@ -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
}
+7 -5
View File
@@ -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 检测对象
+4
View File
@@ -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 {
+109
View File
@@ -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)
}
+1 -1
View File
@@ -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
}
+10 -1
View File
@@ -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)
}
-1
View File
@@ -8,7 +8,6 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"math/rand"
"net"
+3 -2
View File
@@ -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 (
+1
View File
@@ -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"