This commit is contained in:
xugo
2026-01-08 00:33:27 +08:00
parent 16b1e8477a
commit 246497401d
41 changed files with 3841 additions and 50 deletions
+17
View File
@@ -31,6 +31,7 @@ init:
go install github.com/divan/expvarmon@latest
go install github.com/rakyll/hey@latest
go install mvdan.cc/gofumpt@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
## wire: 生成依赖注入代码
wire:
@@ -50,6 +51,22 @@ expva/db:
# 发起 100 次请求,每次并发 50
# hey -n 100 -c 50 http://localhost:9999/healthcheck
# --go-grpc_out=
protobuf:
@protoc \
--go_out=. \
--go-grpc_out=. \
./protos/*.proto
@python -m grpc_tools.protoc \
-I./protos \
--python_out=./analysis \
--grpc_python_out=./analysis \
--pyi_out=./analysis \
./protos/*.proto
python/init:
@pip install -r python/requirements.txt
# ==================================================================================== #
# QUALITY CONTROL
+19
View File
@@ -113,6 +113,25 @@ proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
> How to use other databases?
In the `configs/config.toml` configuration file, modify `database.dsn`
[Recommended] SQLite should be a local disk path, default is `configs/data.db`
[Recommended] PostgreSQL format: `postgres://postgres:123456@127.0.0.1:5432/gb28181?sslmode=disable`
MySQL format: `mysql://root:123456@127.0.0.1:5432/gb28181?sslmode=disable`
PostgreSQL and MySQL format pattern:
`<db_type>://<username>:<password>@<ip>:<port>/<db_name>?sslmode=disable`
> How to disable AI?
AI detection is enabled by default, detecting 5 frames per second.
You can disable AI detection by setting `disabledAI = true` in `configs/config.toml`
## Documentation
GoWVP [Online API Documentation](https://apifox.com/apidoc/shared-7b67c918-5f72-4f64-b71d-0593d7427b93)
+20
View File
@@ -116,6 +116,26 @@ proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
> 如何使用其它数据库?
在 configs/config.toml 配置文件中,修改 database.dsn
[推荐] sqlite 应该为本地磁盘路径,建议默认 configs/data.db
[推荐] postgres 参考格式 `postgres://postgres:123456@127.0.0.1:5432/gb28181?sslmode=disable`
mysql 参考格式 `mysql://root:123456@127.0.0.1:5432/gb28181?sslmode=disable`
postgres 和 mysql 的格式即:
`<db_type>://<username>:<password>@<ip>:<port>/<db_name>?sslmode=disable`
> 如何关闭 AI?
ai 默认是开启状态,1 秒检测 5 帧
可以在 `configs/config.toml` 中修改 `disabledAI = true` 关闭 ai 检测
## 文档
View File
+5
View File
@@ -0,0 +1,5 @@
## 日志
输出位置 `configs/logs/analysis.log`
每天一个文件,默认 INFO 级别,支持 debug/info/error 三个级别
默认保留 3 天,自动删除旧文件
+61
View File
@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: analysis.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
'analysis.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0e\x61nalysis.proto\x12\x08\x61nalysis\"\xdd\x01\n\x12StartCameraRequest\x12\x11\n\tcamera_id\x18\x01 \x01(\t\x12\x13\n\x0b\x63\x61mera_name\x18\x02 \x01(\t\x12\x10\n\x08rtsp_url\x18\x03 \x01(\t\x12\x12\n\ndetect_fps\x18\x04 \x01(\x05\x12\x0e\n\x06labels\x18\x05 \x03(\t\x12\x11\n\tthreshold\x18\x06 \x01(\x02\x12\x12\n\nroi_points\x18\x07 \x03(\x02\x12\x13\n\x0bretry_limit\x18\x08 \x01(\x05\x12\x14\n\x0c\x63\x61llback_url\x18\n \x01(\t\x12\x17\n\x0f\x63\x61llback_secret\x18\x0b \x01(\t\"x\n\x13StartCameraResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x14\n\x0csource_width\x18\x03 \x01(\x05\x12\x15\n\rsource_height\x18\x04 \x01(\x05\x12\x12\n\nsource_fps\x18\x05 \x01(\x02\"&\n\x11StopCameraRequest\x12\x11\n\tcamera_id\x18\x01 \x01(\t\"6\n\x12StopCameraResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\x0f\n\rStatusRequest\"q\n\x0eStatusResponse\x12\x10\n\x08is_ready\x18\x01 \x01(\x08\x12\'\n\x07\x63\x61meras\x18\x02 \x03(\x0b\x32\x16.analysis.CameraStatus\x12$\n\x05stats\x18\x03 \x01(\x0b\x32\x15.analysis.GlobalStats\"t\n\x0c\x43\x61meraStatus\x12\x11\n\tcamera_id\x18\x01 \x01(\t\x12\x0e\n\x06status\x18\x02 \x01(\t\x12\x18\n\x10\x66rames_processed\x18\x03 \x01(\x03\x12\x12\n\nlast_error\x18\x04 \x01(\t\x12\x13\n\x0bretry_count\x18\x05 \x01(\x05\"W\n\x0bGlobalStats\x12\x16\n\x0e\x61\x63tive_streams\x18\x01 \x01(\x05\x12\x18\n\x10total_detections\x18\x02 \x01(\x03\x12\x16\n\x0euptime_seconds\x18\x03 \x01(\x03\"\x14\n\x12HealthCheckRequest\"\x8e\x01\n\x13HealthCheckResponse\x12;\n\x06status\x18\x01 \x01(\x0e\x32+.analysis.HealthCheckResponse.ServingStatus\":\n\rServingStatus\x12\x0b\n\x07UNKNOWN\x10\x00\x12\x0b\n\x07SERVING\x10\x01\x12\x0f\n\x0bNOT_SERVING\x10\x02\x32\xe6\x01\n\x0f\x41nalysisService\x12J\n\x0bStartCamera\x12\x1c.analysis.StartCameraRequest\x1a\x1d.analysis.StartCameraResponse\x12G\n\nStopCamera\x12\x1b.analysis.StopCameraRequest\x1a\x1c.analysis.StopCameraResponse\x12>\n\tGetStatus\x12\x17.analysis.StatusRequest\x1a\x18.analysis.StatusResponse2N\n\x06Health\x12\x44\n\x05\x43heck\x12\x1c.analysis.HealthCheckRequest\x1a\x1d.analysis.HealthCheckResponseB\nZ\x08./protosb\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'analysis_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
_globals['DESCRIPTOR']._loaded_options = None
_globals['DESCRIPTOR']._serialized_options = b'Z\010./protos'
_globals['_STARTCAMERAREQUEST']._serialized_start=29
_globals['_STARTCAMERAREQUEST']._serialized_end=250
_globals['_STARTCAMERARESPONSE']._serialized_start=252
_globals['_STARTCAMERARESPONSE']._serialized_end=372
_globals['_STOPCAMERAREQUEST']._serialized_start=374
_globals['_STOPCAMERAREQUEST']._serialized_end=412
_globals['_STOPCAMERARESPONSE']._serialized_start=414
_globals['_STOPCAMERARESPONSE']._serialized_end=468
_globals['_STATUSREQUEST']._serialized_start=470
_globals['_STATUSREQUEST']._serialized_end=485
_globals['_STATUSRESPONSE']._serialized_start=487
_globals['_STATUSRESPONSE']._serialized_end=600
_globals['_CAMERASTATUS']._serialized_start=602
_globals['_CAMERASTATUS']._serialized_end=718
_globals['_GLOBALSTATS']._serialized_start=720
_globals['_GLOBALSTATS']._serialized_end=807
_globals['_HEALTHCHECKREQUEST']._serialized_start=809
_globals['_HEALTHCHECKREQUEST']._serialized_end=829
_globals['_HEALTHCHECKRESPONSE']._serialized_start=832
_globals['_HEALTHCHECKRESPONSE']._serialized_end=974
_globals['_HEALTHCHECKRESPONSE_SERVINGSTATUS']._serialized_start=916
_globals['_HEALTHCHECKRESPONSE_SERVINGSTATUS']._serialized_end=974
_globals['_ANALYSISSERVICE']._serialized_start=977
_globals['_ANALYSISSERVICE']._serialized_end=1207
_globals['_HEALTH']._serialized_start=1209
_globals['_HEALTH']._serialized_end=1287
# @@protoc_insertion_point(module_scope)
+116
View File
@@ -0,0 +1,116 @@
from google.protobuf.internal import containers as _containers
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from collections.abc import Iterable as _Iterable, Mapping as _Mapping
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
DESCRIPTOR: _descriptor.FileDescriptor
class StartCameraRequest(_message.Message):
__slots__ = ("camera_id", "camera_name", "rtsp_url", "detect_fps", "labels", "threshold", "roi_points", "retry_limit", "callback_url", "callback_secret")
CAMERA_ID_FIELD_NUMBER: _ClassVar[int]
CAMERA_NAME_FIELD_NUMBER: _ClassVar[int]
RTSP_URL_FIELD_NUMBER: _ClassVar[int]
DETECT_FPS_FIELD_NUMBER: _ClassVar[int]
LABELS_FIELD_NUMBER: _ClassVar[int]
THRESHOLD_FIELD_NUMBER: _ClassVar[int]
ROI_POINTS_FIELD_NUMBER: _ClassVar[int]
RETRY_LIMIT_FIELD_NUMBER: _ClassVar[int]
CALLBACK_URL_FIELD_NUMBER: _ClassVar[int]
CALLBACK_SECRET_FIELD_NUMBER: _ClassVar[int]
camera_id: str
camera_name: str
rtsp_url: str
detect_fps: int
labels: _containers.RepeatedScalarFieldContainer[str]
threshold: float
roi_points: _containers.RepeatedScalarFieldContainer[float]
retry_limit: int
callback_url: str
callback_secret: str
def __init__(self, camera_id: _Optional[str] = ..., camera_name: _Optional[str] = ..., rtsp_url: _Optional[str] = ..., detect_fps: _Optional[int] = ..., labels: _Optional[_Iterable[str]] = ..., threshold: _Optional[float] = ..., roi_points: _Optional[_Iterable[float]] = ..., retry_limit: _Optional[int] = ..., callback_url: _Optional[str] = ..., callback_secret: _Optional[str] = ...) -> None: ...
class StartCameraResponse(_message.Message):
__slots__ = ("success", "message", "source_width", "source_height", "source_fps")
SUCCESS_FIELD_NUMBER: _ClassVar[int]
MESSAGE_FIELD_NUMBER: _ClassVar[int]
SOURCE_WIDTH_FIELD_NUMBER: _ClassVar[int]
SOURCE_HEIGHT_FIELD_NUMBER: _ClassVar[int]
SOURCE_FPS_FIELD_NUMBER: _ClassVar[int]
success: bool
message: str
source_width: int
source_height: int
source_fps: float
def __init__(self, success: bool = ..., message: _Optional[str] = ..., source_width: _Optional[int] = ..., source_height: _Optional[int] = ..., source_fps: _Optional[float] = ...) -> None: ...
class StopCameraRequest(_message.Message):
__slots__ = ("camera_id",)
CAMERA_ID_FIELD_NUMBER: _ClassVar[int]
camera_id: str
def __init__(self, camera_id: _Optional[str] = ...) -> None: ...
class StopCameraResponse(_message.Message):
__slots__ = ("success", "message")
SUCCESS_FIELD_NUMBER: _ClassVar[int]
MESSAGE_FIELD_NUMBER: _ClassVar[int]
success: bool
message: str
def __init__(self, success: bool = ..., message: _Optional[str] = ...) -> None: ...
class StatusRequest(_message.Message):
__slots__ = ()
def __init__(self) -> None: ...
class StatusResponse(_message.Message):
__slots__ = ("is_ready", "cameras", "stats")
IS_READY_FIELD_NUMBER: _ClassVar[int]
CAMERAS_FIELD_NUMBER: _ClassVar[int]
STATS_FIELD_NUMBER: _ClassVar[int]
is_ready: bool
cameras: _containers.RepeatedCompositeFieldContainer[CameraStatus]
stats: GlobalStats
def __init__(self, is_ready: bool = ..., cameras: _Optional[_Iterable[_Union[CameraStatus, _Mapping]]] = ..., stats: _Optional[_Union[GlobalStats, _Mapping]] = ...) -> None: ...
class CameraStatus(_message.Message):
__slots__ = ("camera_id", "status", "frames_processed", "last_error", "retry_count")
CAMERA_ID_FIELD_NUMBER: _ClassVar[int]
STATUS_FIELD_NUMBER: _ClassVar[int]
FRAMES_PROCESSED_FIELD_NUMBER: _ClassVar[int]
LAST_ERROR_FIELD_NUMBER: _ClassVar[int]
RETRY_COUNT_FIELD_NUMBER: _ClassVar[int]
camera_id: str
status: str
frames_processed: int
last_error: str
retry_count: int
def __init__(self, camera_id: _Optional[str] = ..., status: _Optional[str] = ..., frames_processed: _Optional[int] = ..., last_error: _Optional[str] = ..., retry_count: _Optional[int] = ...) -> None: ...
class GlobalStats(_message.Message):
__slots__ = ("active_streams", "total_detections", "uptime_seconds")
ACTIVE_STREAMS_FIELD_NUMBER: _ClassVar[int]
TOTAL_DETECTIONS_FIELD_NUMBER: _ClassVar[int]
UPTIME_SECONDS_FIELD_NUMBER: _ClassVar[int]
active_streams: int
total_detections: int
uptime_seconds: int
def __init__(self, active_streams: _Optional[int] = ..., total_detections: _Optional[int] = ..., uptime_seconds: _Optional[int] = ...) -> None: ...
class HealthCheckRequest(_message.Message):
__slots__ = ()
def __init__(self) -> None: ...
class HealthCheckResponse(_message.Message):
__slots__ = ("status",)
class ServingStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
UNKNOWN: _ClassVar[HealthCheckResponse.ServingStatus]
SERVING: _ClassVar[HealthCheckResponse.ServingStatus]
NOT_SERVING: _ClassVar[HealthCheckResponse.ServingStatus]
UNKNOWN: HealthCheckResponse.ServingStatus
SERVING: HealthCheckResponse.ServingStatus
NOT_SERVING: HealthCheckResponse.ServingStatus
STATUS_FIELD_NUMBER: _ClassVar[int]
status: HealthCheckResponse.ServingStatus
def __init__(self, status: _Optional[_Union[HealthCheckResponse.ServingStatus, str]] = ...) -> None: ...
+258
View File
@@ -0,0 +1,258 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import warnings
import analysis_pb2 as analysis__pb2
GRPC_GENERATED_VERSION = '1.76.0'
GRPC_VERSION = grpc.__version__
_version_not_supported = False
try:
from grpc._utilities import first_version_is_lower
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
except ImportError:
_version_not_supported = True
if _version_not_supported:
raise RuntimeError(
f'The grpc package installed is at version {GRPC_VERSION},'
+ ' but the generated code in analysis_pb2_grpc.py depends on'
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
)
class AnalysisServiceStub(object):
"""Missing associated documentation comment in .proto file."""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.StartCamera = channel.unary_unary(
'/analysis.AnalysisService/StartCamera',
request_serializer=analysis__pb2.StartCameraRequest.SerializeToString,
response_deserializer=analysis__pb2.StartCameraResponse.FromString,
_registered_method=True)
self.StopCamera = channel.unary_unary(
'/analysis.AnalysisService/StopCamera',
request_serializer=analysis__pb2.StopCameraRequest.SerializeToString,
response_deserializer=analysis__pb2.StopCameraResponse.FromString,
_registered_method=True)
self.GetStatus = channel.unary_unary(
'/analysis.AnalysisService/GetStatus',
request_serializer=analysis__pb2.StatusRequest.SerializeToString,
response_deserializer=analysis__pb2.StatusResponse.FromString,
_registered_method=True)
class AnalysisServiceServicer(object):
"""Missing associated documentation comment in .proto file."""
def StartCamera(self, request, context):
"""启动摄像头分析
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def StopCamera(self, request, context):
"""停止摄像头分析
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GetStatus(self, request, context):
"""获取服务状态
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_AnalysisServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'StartCamera': grpc.unary_unary_rpc_method_handler(
servicer.StartCamera,
request_deserializer=analysis__pb2.StartCameraRequest.FromString,
response_serializer=analysis__pb2.StartCameraResponse.SerializeToString,
),
'StopCamera': grpc.unary_unary_rpc_method_handler(
servicer.StopCamera,
request_deserializer=analysis__pb2.StopCameraRequest.FromString,
response_serializer=analysis__pb2.StopCameraResponse.SerializeToString,
),
'GetStatus': grpc.unary_unary_rpc_method_handler(
servicer.GetStatus,
request_deserializer=analysis__pb2.StatusRequest.FromString,
response_serializer=analysis__pb2.StatusResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'analysis.AnalysisService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('analysis.AnalysisService', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class AnalysisService(object):
"""Missing associated documentation comment in .proto file."""
@staticmethod
def StartCamera(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/analysis.AnalysisService/StartCamera',
analysis__pb2.StartCameraRequest.SerializeToString,
analysis__pb2.StartCameraResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def StopCamera(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/analysis.AnalysisService/StopCamera',
analysis__pb2.StopCameraRequest.SerializeToString,
analysis__pb2.StopCameraResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def GetStatus(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/analysis.AnalysisService/GetStatus',
analysis__pb2.StatusRequest.SerializeToString,
analysis__pb2.StatusResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
class HealthStub(object):
"""Missing associated documentation comment in .proto file."""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.Check = channel.unary_unary(
'/analysis.Health/Check',
request_serializer=analysis__pb2.HealthCheckRequest.SerializeToString,
response_deserializer=analysis__pb2.HealthCheckResponse.FromString,
_registered_method=True)
class HealthServicer(object):
"""Missing associated documentation comment in .proto file."""
def Check(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_HealthServicer_to_server(servicer, server):
rpc_method_handlers = {
'Check': grpc.unary_unary_rpc_method_handler(
servicer.Check,
request_deserializer=analysis__pb2.HealthCheckRequest.FromString,
response_serializer=analysis__pb2.HealthCheckResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'analysis.Health', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('analysis.Health', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class Health(object):
"""Missing associated documentation comment in .proto file."""
@staticmethod
def Check(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/analysis.Health/Check',
analysis__pb2.HealthCheckRequest.SerializeToString,
analysis__pb2.HealthCheckResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
+197
View File
@@ -0,0 +1,197 @@
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
slog = logging.getLogger("Detector")
class ObjectDetector:
def __init__(self, model_path: str = "yolo11n.pt", device: str = "auto"):
self.model_path = model_path
self.device = device
self.model: YOLO | None = None
self._is_ready = False
def load_model(self) -> bool:
try:
slog.info(f"加载模型: {self.model_path} ...")
start_time = time.time()
self.model = YOLO(self.model_path)
if self.device != "auto":
self.model.to(self.device)
# 预热模型
dummy_img = np.zeros((640, 640, 3), dtype=np.uint8)
self.model.predict(dummy_img, verbose=False)
elapsed = time.time() - start_time
slog.info(f"模型加载完成 (耗时: {elapsed:.2f}s)")
self._is_ready = True
return True
except Exception as e:
slog.error(f"加载模型失败: {e}")
return False
return True
def is_ready(self) -> bool:
return self._is_ready and self.model is not None
def detect(
self,
image: np.ndarray,
threshold: float = 0.5,
label_filter: list[str] | None = None,
regions: list[tuple[int, int, int, int]] | None = None,
) -> tuple[list[dict], float]:
if not self.is_ready:
raise RuntimeError("模型未加载")
start_time = time.time()
detections = []
if regions and len(regions) > 0:
for region in regions:
x_min, y_min, x_max, y_max = region
h, w = image.shape[:2]
x_min = max(0, x_max)
y_min = max(0, y_min)
x_max = min(w, x_max)
y_max = min(h, y_max)
if x_max <= x_min or y_max <= y_min:
continue
cropped = image[y_min:y_max, x_min:x_max]
if cropped.size == 0:
continue
region_detections = self._detect_single(
cropped, threshold, label_filter
)
for det in region_detections:
det["box"]["x_min"] += x_min
det["box"]["y_min"] += y_min
det["box"]["x_max"] += x_min
det["box"]["y_max"] += y_min
detections.append(det)
else:
detections = self._detect_single(image, threshold, label_filter)
inference_time_ms = (time.time() - start_time) * 1000
return detections, inference_time_ms
def _detect_single(
self, image: np.ndarray, threshold: float, label_filter: list[str] | None = None
) -> list[dict[str, Any]]:
if not self.model:
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])
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)
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
class MotionDetector:
def __init__(self):
self.backgrounds: dict[str, np.ndarray] = {}
self.motion_threshold = 25
self.min_contour_area = 500
def detect(
self,
image: np.ndarray,
camera_name: str,
roi_points: list[float] | None = None,
) -> tuple[list[dict[str, Any]], bool]:
h, w = image.shape[:2]
if len(image.shape) == 3:
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:
self.backgrounds[camera_name] = gray.astype(np.float32)
return [], False
cv2.accumulateWeighted(gray, self.backgrounds[camera_name], 0.1)
frame_delta = cv2.absdiff(
gray, cv2.convertScaleAbs(self.backgrounds[camera_name])
)
thresh = cv2.threshold(
frame_delta, self.motion_threshold, 255, cv2.THRESH_BINARY
)[1]
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)
thresh = cv2.dilate(thresh, None, iterations=2)
contours, _ = cv2.findContours(
thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
motion_boxes = []
for contour in contours:
if cv2.contourArea(contour) < self.min_contour_area:
continue
x, y, w, h = cv2.boundingRect(contour)
motion_boxes.append(
{"y_min": y, "x_min": x, "y_max": y + h, "x_max": x + w}
)
has_motion = len(motion_boxes) > 0
return motion_boxes, has_motion
+227
View File
@@ -0,0 +1,227 @@
from collections import deque
import logging
import os
import queue
import subprocess
import threading
import time
from typing import Deque, Optional
import numpy as np
slog = logging.getLogger("Capture")
class LogPipe(threading.Thread):
def __init__(self, log_name: str):
super().__init__(daemon=True)
self.logger = logging.getLogger(log_name)
self.deque: Deque[str] = deque(maxlen=100)
self.fd_read, self.fd_write = os.pipe()
self.pipe_reader = os.fdopen(self.fd_read)
self.start()
def fileno(self):
return self.fd_write
def run(self):
# 使用 iter() 包装 self.pipe_reader.readline 方法和空字符串""作为哨兵,使其不断读取管道内容。
# iter(self.pipe_reader.readline, "") 会不断调用 readline(),直到返回空字符串(代表 EOF),循环终止。
for line in iter(self.pipe_reader.readline, ""):
self.deque.append(line)
self.pipe_reader.close()
def dump(self):
while len(self.deque) > 0:
self.logger.error(self.deque.popleft())
def close(self):
os.close(self.fd_read)
class FrameCapture:
def __init__(self, rtsp_url: str, output_queue: queue.Queue, detect_fps: int = 5):
self.rtsp_url = rtsp_url
self.output_queue = output_queue
self.target_fps = detect_fps
self._stop_event = threading.Event()
self._thread: Optional[threading.Thread] = None
self._proccess: Optional[subprocess.Popen] = None
# 流信息
self.width = 0
self.height = 0
self.fps = 0.0
def start(self):
if self._thread is not None and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._capture_loop, daemon=True)
self._thread.start()
slog.info(f"FrameCapture started for {self.rtsp_url}")
def stop(self):
# 设置停止事件
self._stop_event.set()
# 终止进程
self._terminate_process()
# 等待线程结束
if self._thread:
self._thread.join(timeout=2)
slog.info(f"FrameCapture stopped for {self.rtsp_url}")
def _get_stream_info(self) -> bool:
slog.debug(f"正在探测流信息... {self.rtsp_url}")
ffprobe_cmd = [
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,r_frame_rate",
"-of",
"csv=p=0",
"-rtsp_transport",
"tcp", # 强制 TCP 更稳定
self.rtsp_url,
]
try:
# 执行一个外部命令(比如系统命令、shell 脚本、其他可执行程序),并直接返回该命令在标准输出(stdout)中打印的内容。
output = (
subprocess.check_output(ffprobe_cmd, timeout=15).decode("utf-8").strip()
)
parts = output.split(",")
if len(parts) >= 2:
self.width = int(parts[0])
self.height = int(parts[1])
if len(parts) >= 3 and "/" in parts[2]:
num, den = parts[2].split("/")
self.fps = float(num) / float(den)
else:
self.fps = 25.0
slog.info(
f"ffprobe 探测成功: {self.width}x{self.height} @ {self.fps:.2f}fps"
)
return True
except Exception as e:
slog.error(f"探测流信息失败: {e}")
return False
def _capture_loop(self):
log_pipe: Optional[LogPipe] = None
while not self._stop_event.is_set():
if self.width == 0 or self.height == 0:
if not self._get_stream_info():
time.sleep(3)
continue
if log_pipe:
log_pipe.close()
log_pipe = LogPipe(f"ffmpeg.{self.rtsp_url}")
ffmpeg_cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"warning", # 只输出 warning 以上,减少 IO
"-rtsp_transport",
"tcp",
"-i",
self.rtsp_url,
"-f",
"rawvideo",
"-pix_fmt",
"bgr24", # 直接输出 OpenCV 友好的 BGR 格式
"-r",
str(self.target_fps), # 降低帧率
"pipe:1",
]
slog.info(f"启动 ffmpeg 进程: {' '.join(ffmpeg_cmd[:-2])} ...")
try:
self._proccess = subprocess.Popen(
ffmpeg_cmd,
stdout=subprocess.PIPE,
stderr=log_pipe.fileno(),
bufsize=10**7,
)
except Exception as e:
slog.error(f"启动 ffmpeg 进程失败: {e}")
if log_pipe:
log_pipe.dump()
time.sleep(3)
continue
frame_size = self.width * self.height * 3
slog.info(f"开始读取帧 (size={frame_size})...")
while not self._stop_event.is_set():
try:
if self._proccess.poll() is not None:
slog.error("FFmpeg 进程意外退出")
log_pipe.dump()
break
if self._proccess.stdout is None:
slog.error("FFmpeg 进程 stdout 为空")
break
raw_bytes = self._proccess.stdout.read(frame_size)
if len(raw_bytes) != frame_size:
slog.warning("读取到不完整的帧 (流中断?)")
log_pipe.dump() # 可能有网络错误
break
image = np.frombuffer(raw_bytes, dtype=np.uint8).reshape(
self.height, self.width, 3
)
try:
while not self.output_queue.empty():
try:
self.output_queue.get_nowait()
except queue.Empty:
break
self.output_queue.put_nowait(image)
except Exception as e:
pass
except Exception as e:
slog.error(f"读取帧失败: {e}")
log_pipe.dump()
break
self._terminate_process()
if log_pipe:
log_pipe.close()
if self._stop_event.is_set():
break
time.sleep(2)
def _terminate_process(self):
if self._proccess:
if self._proccess.poll() is None:
self._proccess.terminate()
try:
self._proccess.wait(timeout=2)
except subprocess.TimeoutExpired:
self._proccess.kill()
self._proccess = None
def _hide_password(self, url):
"""隐藏 URL 中的密码"""
try:
if "@" in url:
parts = url.split("@")
if "//" in parts[0]:
protocol_auth = parts[0].split("//")
if ":" in protocol_auth[1]:
user = protocol_auth[1].split(":")[0]
return f"{protocol_auth[0]}//{user}:***@{parts[1]}"
return url
except:
return url
def get_stream_info(self):
"""返回流的基本信息"""
return self.width, self.height, self.fps
+69
View File
@@ -0,0 +1,69 @@
"""
统一日志管理模块
功能:
1. 根据命令行参数设置日志级别。
2. 支持日志文件按天轮转 (Daily Rotation)。
3. 支持自动清理旧日志 (Retention)。
4. 同时输出到控制台 (Console) 和文件 (File)。
"""
import logging
import logging.handlers
import os
import sys
LOG_DIR = "../configs/logs"
LOG_FILE = "analysis.log"
def setup_logging(level_str: str = "INFO", retention_days: int = 3):
# 1. 转换级别
level = getattr(logging, level_str.upper(), logging.INFO)
# 2. 确保日志目录存在
if not os.path.exists(LOG_DIR):
os.makedirs(LOG_DIR)
log_path = os.path.join(LOG_DIR, LOG_FILE)
# 3. 创建 Root Logger
root_logger = logging.getLogger()
root_logger.setLevel(level)
# 清除已有的 handlers (避免重复打印)
root_logger.handlers = []
# 4. 格式化器
formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)-8s | %(process)d:%(threadName)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# 5. Handler 1: 控制台输出
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
root_logger.addHandler(console_handler)
# 6. Handler 2: 文件输出 (按天轮转)
# TimedRotatingFileHandler:
# - when='midnight': 每天午夜切分
# - interval=1: 每1天
# - backupCount=retention_days: 保留几天,超出的会被删除
# - encoding='utf-8': 防止中文乱码
file_handler = logging.handlers.TimedRotatingFileHandler(
filename=log_path,
when="midnight",
interval=1,
backupCount=retention_days,
encoding="utf-8",
)
# 设置后缀格式,例如 app.log.2023-12-31
file_handler.suffix = "%Y-%m-%d"
file_handler.setFormatter(formatter)
root_logger.addHandler(file_handler)
# 打印一条初始化日志
logging.info(
f"日志系统已初始化: Level={level_str}, Path={log_path}, Retention={retention_days} days"
)
+8
View File
@@ -0,0 +1,8 @@
import logging
from logger import setup_logging
if __name__ == "__main__":
setup_logging()
logging.info("test")
+509
View File
@@ -0,0 +1,509 @@
import os
import signal
# 解决 macOS 上 OpenMP 库冲突问题,必须在导入 torch/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
import threading
import time
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__)))
import logger
from detect import MotionDetector, ObjectDetector
from frame_capture import FrameCapture
import cv2
# 导入生成的 proto 代码
try:
import analysis_pb2
import analysis_pb2_grpc
except ImportError:
pass
slog = logging.getLogger("AI")
# 全局配置
GLOBAL_CONFIG = {
"callback_url": "",
"callback_secret": "",
}
# 保存父进程 PID,用于检测父进程是否退出
_PARENT_PID = os.getppid()
def _watch_parent_process():
"""
监控父进程是否存活。当 Go 父进程退出后,Python 子进程应该自动退出,
避免成为孤儿进程持续占用端口和资源。
"""
while True:
time.sleep(3)
# 检查父进程是否还存在
# 如果父进程退出,当前进程的 ppid 会变成 1 (init/launchd) 或其他进程
current_ppid = os.getppid()
if current_ppid != _PARENT_PID:
slog.warning(
f"父进程已退出 (原 PID: {_PARENT_PID}, 当前 PPID: {current_ppid})Python 进程退出"
)
os._exit(0)
class CameraTask:
def __init__(
self,
camera_id: str,
rtsp_url: str,
config: dict[str, Any],
detector: ObjectDetector,
motion_detector: MotionDetector,
) -> None:
self.camera_id = camera_id
self.rtsp_url = rtsp_url
self.config = config
self.detector = detector
self.motion_detector = motion_detector
self.status = "initializing"
self.frames_processed = 0
self.retry_count = 0
self.last_error = ""
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self.frame_queue = queue.Queue(maxsize=1)
self.capture = FrameCapture(
rtsp_url, self.frame_queue, config.get("detect_fps", 5)
)
def start(self):
self.status = "running"
self.capture.start()
self._stop_event.clear()
self._thread = threading.Thread(target=self._analysis_loop, daemon=True)
self._thread.start()
slog.info(f"CameraTask started for {self.camera_id}")
def stop(self):
self.status = "stopping"
self._stop_event.set()
self.capture.stop()
if self._thread:
self._thread.join(timeout=2)
slog.info(f"CameraTask stopped for {self.camera_id}")
def _analysis_loop(self):
error_streak = 0
retry_limit = int(self.config.get("retry_limit", 10))
while not self._stop_event.is_set():
try:
try:
frame = self.frame_queue.get(timeout=2.0)
except queue.Empty:
slog.debug("CameraTask frame queue empty, skipping")
continue
error_streak = 0
self.frames_processed += 1
roi_points = self.config.get("roi_points")
motion_boxes, has_motion = self.motion_detector.detect(
frame, self.camera_id, roi_points
)
if not has_motion:
continue
try:
labels = self.config.get("labels")
if labels and isinstance(labels, list):
safe_labels = [str(l) for l in labels]
else:
safe_labels = None
detections, _ = self.detector.detect(
frame,
threshold=self.config.get("threshold", 0.5),
label_filter=safe_labels,
# 暂时只支持全图检测,未来优化可以只检测 motion_boxes 区域
regions=None,
)
except Exception as e:
slog.error(f"CameraTask labels error: {e}")
continue
if not detections:
continue
self._send_detection_callback(detections, frame)
except Exception as e:
slog.error(f"CameraTask analysis loop error: {e}")
error_streak += 1
self.last_error = str(e)
if error_streak >= retry_limit:
self.status = "error"
self._send_stopped_callback("error", self.last_error)
self.capture.stop()
break
# 防止 cpu 在异常里空转
time.sleep(1)
def _send_detection_callback(self, detections, frame):
timestamp = int(time.time() * 1000)
draw_frame = frame.copy()
for det in detections:
box = det["box"]
label = f"{det['label']} {det['confidence']:.2f}"
# 坐标
p1 = (box["x_min"], box["y_min"])
p2 = (box["x_max"], box["y_max"])
# 画矩形框 (红色,线宽2)
cv2.rectangle(draw_frame, p1, p2, (0, 0, 255), 2)
# 画文字背景条,防止文字看不清
t_size = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)[0]
p2_text = (p1[0] + t_size[0], p1[1] - t_size[1] - 3)
cv2.rectangle(draw_frame, p1, p2_text, (0, 0, 255), -1) # -1 表示实心填充
# 画文字 (白色)
cv2.putText(
draw_frame,
label,
(p1[0], p1[1] - 2),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
(255, 255, 255),
1,
)
success, buffer = cv2.imencode(".jpg", draw_frame)
snapshot_b64 = ""
if success:
snapshot_b64 = base64.b64encode(buffer).decode("utf-8")
payload = {
"camera_id": self.camera_id,
"timestamp": timestamp,
"detections": detections,
"snapshot": snapshot_b64,
"snapshot_width": frame.shape[1],
"snapshot_height": frame.shape[0],
}
send_callback(self.config, "/events", payload)
def _send_stopped_callback(self, reason, message):
payload = {
"camera_id": self.camera_id,
"timestamp": int(time.time() * 1000),
"reason": reason,
"message": message,
}
send_callback(self.config, "/stopped", payload)
class HealthServicer(analysis_pb2_grpc.HealthServicer):
def __init__(self, servicer):
self._servicer = servicer
def Check(self, request, context):
if not self._servicer.is_ready:
return analysis_pb2.HealthCheckResponse(
status=analysis_pb2.HealthCheckResponse.NOT_SERVING
)
return analysis_pb2.HealthCheckResponse(
status=analysis_pb2.HealthCheckResponse.SERVING
)
class AnalysisServiceServicer(analysis_pb2_grpc.AnalysisServiceServicer):
def __init__(self, model_path):
self._camera_tasks: dict[str, CameraTask] = {}
self._lock = threading.Lock()
self._is_ready = False
self._start_time = time.time()
self.object_detector = ObjectDetector(model_path)
self.motion_detector = MotionDetector()
def is_ready(self) -> bool:
return self._is_ready
def initialize(self):
slog.info("AnalysisService initializing...")
success = self.object_detector.load_model()
self._is_ready = success
if not success:
slog.error("AnalysisService initialization failed")
return
slog.info("AnalysisService initialized")
threading.Thread(target=send_started_callback).start()
def StartCamera(self, request, context):
if not self._is_ready:
context.set_details("model loadding")
context.set_code(grpc.StatusCode.UNAVAILABLE)
return analysis_pb2.StartCameraResponse(
success=False, message="model loadding"
)
camera_id = request.camera_id
with self._lock:
if camera_id in self._camera_tasks:
slog.info(
f"Camera {camera_id} already exists, status: {self._camera_tasks[camera_id].status}"
)
return analysis_pb2.StartCameraResponse(
success=True, message="任务已运行"
)
cb_url = request.callback_url or GLOBAL_CONFIG["callback_url"]
cb_secret = request.callback_secret or GLOBAL_CONFIG["callback_secret"]
if not cb_url:
context.set_details("callback url is required")
context.set_code(grpc.StatusCode.INVALID_ARGUMENT)
return analysis_pb2.StartCameraResponse(
success=False, message="callback url is required"
)
config = {
"detect_fps": request.detect_fps,
"labels": list(request.labels),
"threshold": request.threshold,
"roi_points": list(request.roi_points),
"retry_limit": request.retry_limit,
"callback_url": cb_url,
"callback_secret": cb_secret,
}
task = CameraTask(
camera_id,
rtsp_url=request.rtsp_url,
config=config,
detector=self.object_detector,
motion_detector=self.motion_detector,
)
task.start()
self._camera_tasks[camera_id] = task
timeout = 5.0
start = time.time()
w, h, fps = 0, 0, 0.0
while time.time() - start < timeout:
w, h, fps = task.capture.get_stream_info()
if w > 0:
break
time.sleep(0.5)
return analysis_pb2.StartCameraResponse(
success=True,
message="任务已启动",
source_width=w,
source_height=h,
source_fps=fps,
)
def StopCamera(self, request, context):
camera_id = request.camera_id
with self._lock:
if camera_id not in self._camera_tasks:
return analysis_pb2.StopCameraResponse(
success=False, message="Camera not found"
)
task = self._camera_tasks.pop(camera_id)
task.stop()
return analysis_pb2.StopCameraResponse(success=True, message="任务已停止")
def GetStatus(self, request, context):
response = analysis_pb2.StatusResponse()
response.is_ready = self._is_ready
response.stats.active_streams = len(self._camera_tasks)
response.stats.uptime_seconds = int(time.time() - self._start_time)
with self._lock:
for cid, task in self._camera_tasks.items():
cam_status = analysis_pb2.CameraStatus(
camera_id=cid,
status=task.status,
frames_processed=task.frames_processed,
retry_count=task.retry_count,
last_error=task.last_error,
)
response.cameras.append(cam_status)
return response
def send_callback(config: dict, path: str, payload: dict):
"""
发送回调到指定路径,路径会拼接到 callback_url 后面。
例如: callback_url=http://127.0.0.1:15123, path=/events
最终请求: POST http://127.0.0.1:15123/events
"""
url = config.get("callback_url", "")
secret = config.get("callback_secret", "")
if not url:
return
full_url = url.rstrip("/") + path
headers = {"Content-Type": "application/json"}
if secret:
headers["Authorization"] = secret
try:
threading.Thread(
target=requests.post,
args=(full_url,),
kwargs={
"json": payload,
"headers": headers,
"timeout": 5.0,
},
).start()
except Exception as e:
slog.error(f"Failed to send callback to {path}: {e}")
def send_started_callback():
"""
向 Go 服务发送启动通知,用于确认 Python 进程与 Go 服务的连接是否正常。
如果 Go 服务返回 404,说明回调接口不存在,Python 进程应该退出,避免成为孤儿进程。
"""
url = GLOBAL_CONFIG["callback_url"]
secret = GLOBAL_CONFIG["callback_secret"]
if not url:
return
full_url = url.rstrip("/") + "/started"
headers = {"Content-Type": "application/json"}
if secret:
headers["Authorization"] = secret
payload = {
"timestamp": int(time.time() * 1000),
"message": "AI Analysis Service Started",
}
max_retries = 3
retry_interval = 2
for attempt in range(1, max_retries + 1):
slog.info(f"Sending started callback (attempt {attempt}/{max_retries})...")
try:
resp = requests.post(full_url, json=payload, headers=headers, timeout=5)
if resp.status_code == 404 and attempt == max_retries - 1:
slog.error(f"回调接口返回 404,Go 服务可能已停止,退出 Python 进程")
os._exit(1)
if resp.ok:
slog.info("启动通知发送成功")
return
slog.warning(f"启动通知返回非成功状态: {resp.status_code}")
except requests.exceptions.ConnectionError as e:
slog.warning(f"发送启动通知失败 (连接错误): {e}")
except Exception as e:
slog.error(f"发送启动通知失败: {e}")
if attempt < max_retries:
time.sleep(retry_interval)
slog.error(f"启动通知发送失败,已重试 {max_retries}")
def send_keepalive_callback(stats: dict):
"""
发送心跳回调,用于定期向 Go 服务报告 AI 服务状态。
"""
url = GLOBAL_CONFIG["callback_url"]
secret = GLOBAL_CONFIG["callback_secret"]
if not url:
return
full_url = url.rstrip("/") + "/keepalive"
headers = {"Content-Type": "application/json"}
if secret:
headers["Authorization"] = secret
payload = {
"timestamp": int(time.time() * 1000),
"stats": stats,
"message": "Service running normally",
}
try:
requests.post(full_url, json=payload, headers=headers, timeout=5)
except Exception as e:
slog.debug(f"Failed to send keepalive callback: {e}")
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()
server = grpc.server(futures.ThreadPoolExecutor(max_workers=20))
servicer = AnalysisServiceServicer(model_path)
analysis_pb2_grpc.add_AnalysisServiceServicer_to_server(servicer, server)
health_servicer = HealthServicer(servicer)
analysis_pb2_grpc.add_HealthServicer_to_server(health_servicer, server)
server.add_insecure_port(f"[::]:{port}")
server.start()
slog.info(f"AnalysisService started: 0.0.0.0:{port}")
threading.Thread(target=servicer.initialize).start()
try:
server.wait_for_termination()
except KeyboardInterrupt:
server.stop(0)
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(
"--callback-url",
type=str,
default="http://127.0.0.1:15123",
help="回调基础URL,各回调路由会自动拼接",
)
parser.add_argument("--callback-secret", type=str, default="", help="回调秘钥")
parser.add_argument(
"--log-level",
type=str,
default="INFO",
help="日志级别 (DEBUG/INFO/ERROR)",
)
args = parser.parse_args()
logger.setup_logging(level_str=args.log_level)
GLOBAL_CONFIG["callback_url"] = args.callback_url
GLOBAL_CONFIG["callback_secret"] = args.callback_secret
slog.debug(
f"log level: {args.log_level}, model: {args.model}, callback url: {args.callback_url}, callback secret: {args.callback_secret}"
)
serve(args.port, args.model)
if __name__ == "__main__":
main()
+16
View File
@@ -0,0 +1,16 @@
# gRPC 框架
grpcio==1.76.0
grpcio-tools==1.76.0
# 图像处理
opencv-python-headless>=4.8.0
numpy>=2.3.5
# YOLO 模型
ultralytics>=8.0.0
# 科学计算
scipy>=1.16.3
# HTTP 客户端 (用于回调)
requests>=2.31.0
+281
View File
@@ -0,0 +1,281 @@
openapi: 3.1.0
info:
title: GOWVP AI 分析服务回调 API
version: 2.0.0
description: |
AI 分析服务发出的 Webhook 回调 API 规范。
主服务 (Go) 或其他客户端应实现此 API 以接收 AI 事件。
回调地址格式: `{callback_url}/{path}`
例如: callback_url=http://127.0.0.1:15123,则心跳回调为 http://127.0.0.1:15123/keepalive
servers:
- url: "{callback_url}"
description: AI 回调服务地址
variables:
callback_url:
default: http://127.0.0.1:15123
description: 回调基础 URL
paths:
/keepalive:
post:
summary: 心跳回调
description: |
AI 分析服务定期发送心跳,用于确认服务存活状态。
主服务可以通过此接口监控 AI 服务的运行状态。
security:
- basicAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/KeepaliveEvent"
example:
timestamp: 1678886400000
stats:
active_streams: 5
total_detections: 1200
uptime_seconds: 3600
message: "Service running normally"
responses:
"200":
description: 成功接收心跳
"401":
description: 未授权 (认证失败)
/started:
post:
summary: 服务启动回调
description: |
AI 分析服务启动完成后发送此回调,用于确认服务已就绪。
主服务收到此回调后可以开始向 AI 服务发送分析任务。
security:
- basicAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/StartedEvent"
example:
timestamp: 1678886400000
message: "AI Analysis Service Started"
responses:
"200":
description: 成功接收启动通知
"401":
description: 未授权 (认证失败)
/events:
post:
summary: 检测事件回调
description: |
当 AI 检测到目标对象时发送此回调。
包含检测结果和带有标注的快照图片。
security:
- basicAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/DetectionEvent"
example:
camera_id: "cam_office_01"
timestamp: 1678886400000
detections:
- label: "person"
confidence: 0.95
box: { x_min: 100, y_min: 100, x_max: 200, y_max: 300 }
area: 20000
snapshot: "/9j/4AAQSkZJRg..."
snapshot_width: 1920
snapshot_height: 1080
responses:
"200":
description: 成功接收检测事件
"401":
description: 未授权 (认证失败)
"400":
description: 数据格式无效
/stopped:
post:
summary: 任务停止回调
description: |
当某个摄像头的分析任务停止时发送此回调。
可能是用户主动停止,也可能是因为错误导致任务终止。
security:
- basicAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/StoppedEvent"
example:
camera_id: "cam_office_01"
timestamp: 1678886400000
reason: "error"
message: "RTSP stream connection timed out"
responses:
"200":
description: 成功接收停止通知
"401":
description: 未授权 (认证失败)
components:
securitySchemes:
basicAuth:
type: http
scheme: basic
description: |
使用用户名 'gowvp' 和配置的密钥 (secret) 进行 Basic Authentication。
示例 Header: `Authorization: Basic Z293dnA6YOUR_SECRET`
schemas:
KeepaliveEvent:
type: object
required:
- timestamp
properties:
timestamp:
type: integer
format: int64
description: 事件发生的 Unix 时间戳 (毫秒)。
stats:
$ref: "#/components/schemas/GlobalStats"
message:
type: string
description: 附加消息。
StartedEvent:
type: object
required:
- timestamp
properties:
timestamp:
type: integer
format: int64
description: 服务启动的 Unix 时间戳 (毫秒)。
message:
type: string
description: 启动消息。
DetectionEvent:
type: object
required:
- camera_id
- timestamp
- detections
properties:
camera_id:
type: string
description: 生成事件的摄像头 ID。
timestamp:
type: integer
format: int64
description: 检测发生的 Unix 时间戳 (毫秒)。
detections:
type: array
items:
$ref: "#/components/schemas/DetectionObject"
snapshot:
type: string
description: 带有检测标注的帧画面 Base64 编码 (JPEG 格式)。
snapshot_width:
type: integer
description: 快照图片的宽度。
snapshot_height:
type: integer
description: 快照图片的高度。
StoppedEvent:
type: object
required:
- camera_id
- timestamp
- reason
properties:
camera_id:
type: string
description: 停止任务的摄像头 ID。
timestamp:
type: integer
format: int64
description: 停止发生的 Unix 时间戳 (毫秒)。
reason:
type: string
description: 停止原因 (例如 'user_requested', 'error')。
message:
type: string
description: 包含具体原因的详细信息。
DetectionObject:
type: object
required:
- label
- confidence
- box
properties:
label:
type: string
description: 检测到的物体类别 (例如 'person', 'car')。
confidence:
type: number
format: float
description: 置信度分数 (0.0 - 1.0)。
box:
$ref: "#/components/schemas/BoundingBox"
area:
type: integer
description: 边界框的像素面积。
norm_box:
$ref: "#/components/schemas/NormalizedBoundingBox"
BoundingBox:
type: object
description: 像素坐标边界框
properties:
x_min:
type: integer
y_min:
type: integer
x_max:
type: integer
y_max:
type: integer
NormalizedBoundingBox:
type: object
description: 归一化边界框 (0.0-1.0 范围,相对于图像宽高)
properties:
x:
type: number
description: 中心点 X 坐标
y:
type: number
description: 中心点 Y 坐标
w:
type: number
description: 宽度
h:
type: number
description: 高度
GlobalStats:
type: object
properties:
active_streams:
type: integer
description: 当前活跃的 RTSP 流数量。
total_detections:
type: integer
format: int64
description: 服务启动以来的总检测次数。
uptime_seconds:
type: integer
format: int64
description: 服务已运行的时间 (秒)。
+4 -4
View File
@@ -1,6 +1,6 @@
module github.com/gowvp/gb28181
go 1.25
go 1.26
require (
github.com/DATA-DOG/go-sqlmock v1.5.2
@@ -15,6 +15,7 @@ require (
github.com/jinzhu/copier v0.4.0
github.com/pelletier/go-toml/v2 v2.2.4
github.com/shirou/gopsutil/v4 v4.25.7
google.golang.org/grpc v1.78.0
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.30.3
@@ -35,6 +36,7 @@ require (
github.com/quic-go/quic-go v0.55.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect
)
require (
@@ -87,11 +89,9 @@ require (
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0
golang.org/x/time v0.12.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
google.golang.org/protobuf v1.36.10
modernc.org/libc v1.61.5 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.34.4 // indirect
)
// replace github.com/gowvp/onvif => ../onvif
+24
View File
@@ -35,6 +35,10 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -55,6 +59,8 @@ github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -157,6 +163,18 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -191,6 +209,12 @@ golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+62
View File
@@ -33,6 +33,7 @@ func Run(bc *conf.Bootstrap) {
defer clean()
go setupZLM(ctx, bc.ConfigDir)
go setupAIClient(ctx, "http://127.0.0.1:15123/ai", bc.Debug)
// 如果需要执行表迁移,递增此版本号和表更新说明
versionapi.DBVersion = "0.0.18"
@@ -125,3 +126,64 @@ func setupZLM(ctx context.Context, dir string) {
}
}
}
func findPythonPath() string {
candidates := []string{
"/opt/homebrew/Caskroom/miniconda/base/bin/python", // macOS Homebrew Miniconda
"/opt/homebrew/anaconda3/bin/python", // macOS Homebrew Anaconda
"/usr/local/anaconda3/bin/python", // Linux Anaconda
"/usr/local/miniconda3/bin/python", // Linux Miniconda
"/root/miniconda3/bin/python", // Linux root Miniconda
}
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
return p
}
}
return "python"
}
func setupAIClient(ctx context.Context, callback string, debug bool) {
workDir := filepath.Join(system.Getwd(), "analysis")
if _, err := os.Stat(filepath.Join(workDir, "main.py")); err != nil && os.IsNotExist(err) {
slog.Info("main.py 文件不存在,跳过启动 ai", "path", filepath.Join(workDir, "main.py"))
return
}
pythonPath := findPythonPath()
slog.Info("使用 Python 路径", "path", pythonPath)
args := []string{"main.py"}
if callback != "" {
args = append(args, "--callback-url", callback)
}
if debug {
args = append(args, "--log-level", "DEBUG")
}
for range 100 {
select {
case <-ctx.Done():
slog.Info("收到退出信号,停止重启 ai")
return
default:
slog.Info("ai 启动中...")
cmd := exec.CommandContext(ctx, pythonPath, args...)
cmd.Dir = workDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
// 启动命令 - 正常情况下会阻塞在这里
if err := cmd.Run(); err != nil {
slog.Error("ai 运行失败", "err", err)
} else {
slog.Info("ai 退出,将重新启动")
}
// 等待后重启(不管是正常退出还是异常退出)
time.Sleep(2 * time.Second)
}
}
}
-1
View File
@@ -1,5 +1,4 @@
//go:build wireinject
// +build wireinject
package app
+17 -14
View File
@@ -7,13 +7,14 @@
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:
@@ -41,19 +42,21 @@ 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)
usecase := &api.Usecase{
Conf: bc,
DB: db,
Version: versionapiAPI,
SMSAPI: smsAPI,
WebHookAPI: webHookAPI,
UniqueID: uniqueidCore,
MediaAPI: pushAPI,
GB28181API: ipcapi,
ProxyAPI: proxyAPI,
ConfigAPI: configAPI,
SipServer: server,
UserAPI: userAPI,
Conf: bc,
DB: db,
Version: versionapiAPI,
SMSAPI: smsAPI,
WebHookAPI: webHookAPI,
UniqueID: uniqueidCore,
MediaAPI: pushAPI,
GB28181API: ipcapi,
ProxyAPI: proxyAPI,
ConfigAPI: configAPI,
SipServer: server,
UserAPI: userAPI,
AIWebhookAPI: aiWebhookAPI,
}
handler := api.NewHTTPHandler(usecase)
return handler, func() {
+3 -2
View File
@@ -19,8 +19,9 @@ type Server struct {
Debug bool
RTMPSecret string `comment:"rtmp 推流秘钥"`
Username string `comment:"登录用户名"`
Password string `comment:"登录密码"`
Username string `comment:"登录用户名"`
Password string `comment:"登录密码"`
DisabledAI bool `comment:"是否禁用 ai 分析服务"`
HTTP ServerHTTP `comment:"对外提供的服务,建议由 nginx 代理"` // HTTP服务器
}
+39 -2
View File
@@ -4,6 +4,7 @@ package ipc
import (
"context"
"log/slog"
"slices"
"strconv"
"strings"
@@ -19,7 +20,7 @@ type ChannelStorer interface {
Find(context.Context, *[]*Channel, orm.Pager, ...orm.QueryOption) (int64, error)
Get(context.Context, *Channel, ...orm.QueryOption) error
Add(context.Context, *Channel) error
Edit(context.Context, *Channel, func(*Channel), ...orm.QueryOption) error
Edit(context.Context, *Channel, func(*Channel) error, ...orm.QueryOption) error
Del(context.Context, *Channel, ...orm.QueryOption) error
BatchEdit(context.Context, string, any, ...orm.QueryOption) error // 批量更新一个字段
@@ -87,10 +88,11 @@ func (c *Core) AddChannel(ctx context.Context, in *AddChannelInput) (*Channel, e
func (c *Core) EditChannel(ctx context.Context, in *EditChannelInput, id string) (*Channel, error) {
// TODO: 修改 onvif 的账号/密码 后需要重新连接设备
var out Channel
if err := c.store.Channel().Edit(ctx, &out, func(b *Channel) {
if err := c.store.Channel().Edit(ctx, &out, func(b *Channel) error {
if err := copier.Copy(b, in); err != nil {
slog.ErrorContext(ctx, "Copy", "err", err)
}
return nil
}, orm.Where("id=?", id)); err != nil {
return nil, reason.ErrDB.Withf(`Edit err[%s]`, err.Error())
}
@@ -105,3 +107,38 @@ func (c *Core) DelChannel(ctx context.Context, id string) (*Channel, error) {
}
return &out, nil
}
func (c *Core) AddZone(ctx context.Context, in *AddZoneInput, channelID string) (*Zone, error) {
newZone := Zone{
Name: in.Name,
Coordinates: in.Coordinates,
Color: in.Color,
Labels: in.Labels,
}
var out Channel
if err := c.store.Channel().Edit(ctx, &out, func(b *Channel) error {
if slices.ContainsFunc(b.Ext.Zones, func(z Zone) bool {
return z.Name == in.Name
}) {
return reason.ErrBadRequest.SetMsg("存在同名区域")
}
b.Ext.Zones = append(b.Ext.Zones, newZone)
return nil
}, orm.Where("id=?", channelID)); err != nil {
if reason.IsCustomError(err) {
return nil, err
}
return nil, reason.ErrDB.Withf(`Edit err[%s]`, err.Error())
}
return &newZone, nil
}
func (c *Core) GetZones(ctx context.Context, channelID string) ([]Zone, error) {
var out Channel
if err := c.store.Channel().Get(ctx, &out, orm.Where("id=?", channelID)); err != nil {
return nil, reason.ErrDB.Withf(`Get err[%s]`, err.Error())
}
return out.Ext.Zones, nil
}
+8
View File
@@ -28,3 +28,11 @@ type AddChannelInput struct {
IsOnline bool `json:"is_online"` // 是否在线
Ext DeviceExt `json:"ext"`
}
type AddZoneInput struct {
Name string `json:"name"` // 区域名称
Coordinates []float32 `json:"coordinates"` // 坐标
Color string `json:"color"` // 颜色,支持 hex 颜色值,如 #FF0000
Labels []string `json:"labels"` // 标签
ChannelID string `json:"-"` // 通道 id
}
+6 -3
View File
@@ -83,8 +83,9 @@ func (g Adapter) Edit(deviceID string, changeFn func(*Device)) error {
func (g Adapter) EditPlayingByID(ctx context.Context, id string, playing bool) error {
var ch Channel
if err := g.store.Channel().Edit(ctx, &ch, func(c *Channel) {
if err := g.store.Channel().Edit(ctx, &ch, func(c *Channel) error {
c.IsPlaying = playing
return nil
}, orm.Where("id=?", id)); err != nil {
return err
}
@@ -93,8 +94,9 @@ func (g Adapter) EditPlayingByID(ctx context.Context, id string, playing bool) e
func (g Adapter) EditPlaying(ctx context.Context, deviceID, channelID string, playing bool) error {
var ch Channel
if err := g.store.Channel().Edit(ctx, &ch, func(c *Channel) {
if err := g.store.Channel().Edit(ctx, &ch, func(c *Channel) error {
c.IsPlaying = playing
return nil
}, orm.Where("device_id = ? AND channel_id = ?", deviceID, channelID)); err != nil {
return err
}
@@ -145,10 +147,11 @@ func (g Adapter) SaveChannels(channels []*Channel) error {
if existing, ok := existingMap[channel.ChannelID]; ok {
// 通道已存在,更新信息
_ = g.store.Channel().Edit(ctx, existing, func(c *Channel) {
_ = g.store.Channel().Edit(ctx, existing, func(c *Channel) error {
c.Name = channel.Name
c.IsOnline = channel.IsOnline
c.Ext = channel.Ext
return nil
}, orm.Where("id=?", existing.ID))
} else {
// 通道不存在,新增
+1 -1
View File
@@ -40,7 +40,7 @@ func (c *Channel) Del(ctx context.Context, ch *ipc.Channel, opts ...orm.QueryOpt
}
// Edit implements ipc.ChannelStorer.
func (c *Channel) Edit(ctx context.Context, ch *ipc.Channel, changeFn func(*ipc.Channel), opts ...orm.QueryOption) error {
func (c *Channel) Edit(ctx context.Context, ch *ipc.Channel, changeFn func(*ipc.Channel) error, opts ...orm.QueryOption) error {
return c.Storer.Channel().Edit(ctx, ch, changeFn, opts...)
}
+2 -2
View File
@@ -47,8 +47,8 @@ func (d Channel) Add(ctx context.Context, model *ipc.Channel) error {
}
// Edit implements ipc.ChannelStorer.
func (d Channel) Edit(ctx context.Context, model *ipc.Channel, changeFn func(*ipc.Channel), opts ...orm.QueryOption) error {
return orm.UpdateWithContext(ctx, d.db, model, changeFn, opts...)
func (d Channel) Edit(ctx context.Context, model *ipc.Channel, changeFn func(*ipc.Channel) error, opts ...orm.QueryOption) error {
return orm.UpdateWithContext2(ctx, d.db, model, changeFn, opts...)
}
// Del implements ipc.ChannelStorer.
+10 -1
View File
@@ -17,6 +17,8 @@ import (
"github.com/ixugo/goddd/pkg/web"
)
const KeepaliveInterval = 2 * 15 * time.Second
type WarpMediaServer struct {
IsOnline bool
LastUpdatedAt time.Time
@@ -71,7 +73,6 @@ func (n *NodeManager) tickCheck() {
case <-n.quit:
return
case <-ticker.C:
const KeepaliveInterval = 2 * 15 * time.Second
n.cacheServers.Range(func(_ string, ms *WarpMediaServer) bool {
if time.Since(ms.LastUpdatedAt) < KeepaliveInterval {
ms.IsOnline = true
@@ -234,6 +235,14 @@ func (n *NodeManager) Keepalive(serverID string) {
value.LastUpdatedAt = time.Now()
}
func (n *NodeManager) IsOnline(serverID string) bool {
value, ok := n.cacheServers.Load(serverID)
if !ok {
return false
}
return value.IsOnline
}
// findMediaServer Paginated search
func (n *NodeManager) findMediaServer(ctx context.Context, in *FindMediaServerInput) ([]*MediaServer, int64, error) {
items := make([]*MediaServer, 0)
+67
View File
@@ -0,0 +1,67 @@
package rpc
import (
"context"
"log/slog"
"github.com/gowvp/gb28181/protos"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
var _ protos.AnalysisServiceClient = (*AIClient)(nil)
// AIClient 封装 gRPC 检测服务客户端,提供统一的 AI 检测调用入口
type AIClient struct {
cli protos.AnalysisServiceClient
}
// NewAIClient 创建 AI 检测客户端实例
func NewAIClient(addr string) *AIClient {
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
slog.Error("NewAiClient", "err", err)
return nil
}
go func() {
p := protos.NewHealthClient(conn)
resp, err := p.Check(context.Background(), &protos.HealthCheckRequest{})
if err != nil {
slog.Error("HealthCheck", "err", err)
return
}
if resp.GetStatus() == protos.HealthCheckResponse_SERVING {
slog.Info("HealthCheck OK", "resp", resp)
} else {
slog.Error("HealthCheck", "resp", resp)
}
}()
cli := protos.NewAnalysisServiceClient(conn)
return &AIClient{cli: cli}
}
// GetStatus implements [protos.AnalysisServiceClient].
func (a *AIClient) GetStatus(ctx context.Context, in *protos.StatusRequest, opts ...grpc.CallOption) (*protos.StatusResponse, error) {
return a.cli.GetStatus(ctx, in, opts...)
}
// StartCamera implements [protos.AnalysisServiceClient].
func (a *AIClient) StartCamera(ctx context.Context, in *protos.StartCameraRequest, opts ...grpc.CallOption) (*protos.StartCameraResponse, error) {
if in.GetDetectFps() == 0 {
in.DetectFps = 5
}
if in.GetThreshold() == 0 {
in.Threshold = 0.5
}
if in.GetRetryLimit() == 0 {
in.RetryLimit = 10
}
return a.cli.StartCamera(ctx, in, opts...)
}
// StopCamera implements [protos.AnalysisServiceClient].
func (a *AIClient) StopCamera(ctx context.Context, in *protos.StopCameraRequest, opts ...grpc.CallOption) (*protos.StopCameraResponse, error) {
return a.cli.StopCamera(ctx, in, opts...)
}
+25
View File
@@ -0,0 +1,25 @@
package rpc
import (
"context"
"log/slog"
"testing"
"github.com/gowvp/gb28181/protos"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestHealthCheck(t *testing.T) {
addr := "localhost:50051"
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatal(err)
}
cli := protos.NewHealthClient(conn)
resp, err := cli.Check(context.Background(), &protos.HealthCheckRequest{})
if err != nil {
t.Fatal(err)
}
slog.Info("HealthCheck", "resp", resp)
}
+134
View File
@@ -0,0 +1,134 @@
package api
import (
"encoding/base64"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"github.com/gowvp/gb28181/internal/conf"
"github.com/gowvp/gb28181/internal/rpc"
"github.com/ixugo/goddd/pkg/conc"
"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
}
// NewAIWebhookAPI 创建 AI Webhook API 实例
func NewAIWebhookAPI(conf *conf.Bootstrap) AIWebhookAPI {
return AIWebhookAPI{
log: slog.With("hook", "ai"),
conf: conf,
ai: rpc.NewAIClient("127.0.0.1:50051"),
aiTasks: conc.NewMap[string, struct{}](),
}
}
// registerAIWebhookAPI 注册 AI 回调路由,接收来自 Python AI 服务的各类事件通知
func registerAIWebhookAPI(r gin.IRouter, api AIWebhookAPI, handler ...gin.HandlerFunc) {
group := r.Group("/ai", handler...)
group.POST("/keepalive", web.WrapH(api.onKeepalive))
group.POST("/started", web.WrapH(api.onStarted))
group.POST("/events", web.WrapH(api.onEvents))
group.POST("/stopped", web.WrapH(api.onStopped))
}
// onKeepalive 接收 AI 服务心跳,用于监控 AI 服务存活状态
func (a AIWebhookAPI) onKeepalive(c *gin.Context, in *AIKeepaliveInput) (AIWebhookOutput, error) {
var activeStreams int
var uptimeSeconds int64
if in.Stats != nil {
activeStreams = in.Stats.ActiveStreams
uptimeSeconds = in.Stats.UptimeSeconds
}
a.log.InfoContext(c.Request.Context(), "ai keepalive",
"timestamp", in.Timestamp,
"message", in.Message,
"active_streams", activeStreams,
"uptime_seconds", uptimeSeconds,
)
return newAIWebhookOutputOK(), nil
}
// onStarted 接收 AI 服务启动通知,确认 AI 服务已就绪
func (a AIWebhookAPI) onStarted(c *gin.Context, in *AIStartedInput) (AIWebhookOutput, error) {
a.log.InfoContext(c.Request.Context(), "ai started",
"timestamp", in.Timestamp,
"message", in.Message,
)
return newAIWebhookOutputOK(), nil
}
// onEvents 接收 AI 检测事件,将快照保存到临时目录
func (a AIWebhookAPI) onEvents(c *gin.Context, in *AIDetectionInput) (AIWebhookOutput, error) {
a.log.InfoContext(c.Request.Context(), "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),
)
for i, det := range in.Detections {
a.log.InfoContext(c.Request.Context(), "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)
}
}
return newAIWebhookOutputOK(), nil
}
// onStopped 接收 AI 任务停止通知,记录停止原因
func (a AIWebhookAPI) onStopped(c *gin.Context, in *AIStoppedInput) (AIWebhookOutput, error) {
a.log.InfoContext(c.Request.Context(), "ai task stopped",
"camera_id", in.CameraID,
"timestamp", in.Timestamp,
"reason", in.Reason,
"message", in.Message,
)
a.aiTasks.Delete(in.CameraID)
return newAIWebhookOutputOK(), nil
}
// saveSnapshot 将 Base64 编码的快照保存到临时目录
func saveSnapshot(cameraID string, timestamp int64, snapshotB64 string) error {
tmpDir := filepath.Join(system.Getwd(), "configs", "demo")
data, err := base64.StdEncoding.DecodeString(snapshotB64)
if err != nil {
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)
}
if err := os.WriteFile(filePath, data, 0o644); err != nil {
return fmt.Errorf("write file: %w", err)
}
slog.Info("snapshot saved", "path", filePath, "size", len(data))
return nil
}
+74
View File
@@ -0,0 +1,74 @@
package api
// AIKeepaliveInput 心跳回调请求体
type AIKeepaliveInput struct {
Timestamp int64 `json:"timestamp"` // Unix 时间戳 (毫秒)
Stats *AIGlobalStats `json:"stats"` // 全局统计信息
Message string `json:"message"` // 附加消息
}
// AIStartedInput 服务启动回调请求体
type AIStartedInput struct {
Timestamp int64 `json:"timestamp"` // Unix 时间戳 (毫秒)
Message string `json:"message"` // 启动消息
}
// AIDetectionInput 检测事件回调请求体
type AIDetectionInput struct {
CameraID string `json:"camera_id"` // 摄像头 ID
Timestamp int64 `json:"timestamp"` // Unix 时间戳 (毫秒)
Detections []AIDetection `json:"detections"` // 检测结果列表
Snapshot string `json:"snapshot"` // Base64 编码的快照 (JPEG)
SnapshotWidth int `json:"snapshot_width"` // 快照宽度
SnapshotHeight int `json:"snapshot_height"` // 快照高度
}
// 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"` // 详细信息
}
// AIDetection 检测对象
type AIDetection struct {
Label string `json:"label"` // 物体类别
Confidence float64 `json:"confidence"` // 置信度 (0.0 - 1.0)
Box AIBoundingBox `json:"box"` // 像素坐标边界框
Area int `json:"area"` // 边界框像素面积
NormBox *AINormBox `json:"norm_box"` // 归一化边界框
}
// AIBoundingBox 像素坐标边界框
type AIBoundingBox struct {
XMin int `json:"x_min"`
YMin int `json:"y_min"`
XMax int `json:"x_max"`
YMax int `json:"y_max"`
}
// AINormBox 归一化边界框
type AINormBox struct {
X float64 `json:"x"` // 中心点 X 坐标
Y float64 `json:"y"` // 中心点 Y 坐标
W float64 `json:"w"` // 宽度
H float64 `json:"h"` // 高度
}
// AIGlobalStats 全局统计信息
type AIGlobalStats struct {
ActiveStreams int `json:"active_streams"` // 活跃流数量
TotalDetections int64 `json:"total_detections"` // 总检测次数
UptimeSeconds int64 `json:"uptime_seconds"` // 运行时间 (秒)
}
// AIWebhookOutput 通用响应体
type AIWebhookOutput struct {
Code int `json:"code"` // 错误代码,0 表示成功
Msg string `json:"msg"` // 消息
}
func newAIWebhookOutputOK() AIWebhookOutput {
return AIWebhookOutput{Code: 0, Msg: "success"}
}
+3
View File
@@ -105,6 +105,9 @@ func setupRouter(r *gin.Engine, uc *Usecase) {
// 反向代理流媒体数据
r.Any("/proxy/sms/*path", uc.proxySMS)
// 注册 AI 分析服务回调接口
registerAIWebhookAPI(r, uc.AIWebhookAPI)
}
type playOutput struct {
+33 -1
View File
@@ -2,6 +2,7 @@
package api
import (
"context"
"fmt"
"io"
"log/slog"
@@ -21,6 +22,7 @@ import (
"github.com/gowvp/gb28181/internal/core/push"
"github.com/gowvp/gb28181/internal/core/sms"
"github.com/gowvp/gb28181/pkg/zlm"
"github.com/gowvp/gb28181/protos"
"github.com/ixugo/goddd/domain/uniqueid"
"github.com/ixugo/goddd/pkg/hook"
"github.com/ixugo/goddd/pkg/orm"
@@ -252,6 +254,9 @@ func (a IPCAPI) play(c *gin.Context, _ *struct{}) (*playOutput, error) {
return nil, reason.ErrNotFound.SetMsg("不支持的播放通道")
}
if !a.uc.SMSAPI.smsCore.IsOnline(mediaServerID) {
return nil, reason.ErrNotFound.SetMsg("Oops! 流媒体服务离线或IP有误")
}
svr, err := a.uc.SMSAPI.smsCore.GetMediaServer(c.Request.Context(), mediaServerID)
if err != nil {
return nil, err
@@ -284,9 +289,9 @@ func (a IPCAPI) play(c *gin.Context, _ *struct{}) (*playOutput, error) {
// 取一张快照
go func() {
rtsp := fmt.Sprintf("rtsp://%s:%d/%s", "127.0.0.1", svr.Ports.RTSP, stream) + "?" + session
for range 2 {
time.Sleep(3 * time.Second)
rtsp := fmt.Sprintf("rtsp://%s:%d/%s", "127.0.0.1", svr.Ports.RTSP, stream) + "?" + session
body, err := a.uc.SMSAPI.smsCore.GetSnapshot(svr, sms.GetSnapRequest{
GetSnapRequest: zlm.GetSnapRequest{
@@ -305,6 +310,33 @@ func (a IPCAPI) play(c *gin.Context, _ *struct{}) (*playOutput, error) {
}
break
}
if a.uc.Conf.Server.DisabledAI || a.uc.AIWebhookAPI.ai == nil {
return
}
if _, ok := a.uc.AIWebhookAPI.aiTasks.LoadOrStore(channelID, struct{}{}); !ok {
resp, err := a.uc.AIWebhookAPI.ai.StartCamera(context.Background(), &protos.StartCameraRequest{
CameraId: appStream,
CameraName: appStream,
RtspUrl: rtsp,
DetectFps: 5,
Labels: []string{"person", "car", "cat", "dog"},
Threshold: 0.65,
RetryLimit: 10,
CallbackUrl: fmt.Sprintf("http://127.0.0.1:%d/ai", a.uc.Conf.Server.HTTP.Port),
CallbackSecret: "Basic 1234567890",
})
if err != nil {
slog.Error("start camera", "err", err)
return
}
slog.Debug("start camera", "resp", resp,
"msg", resp.GetMessage(),
"source_width", resp.GetSourceWidth(),
"source_height", resp.GetSourceHeight(),
"source_fps", resp.GetSourceFps(),
)
}
}()
return &out, nil
}
+8 -9
View File
@@ -40,6 +40,7 @@ var (
NewProxyAPI, NewProxyCore,
NewConfigAPI,
NewUserAPI,
NewAIWebhookAPI,
)
)
@@ -55,25 +56,23 @@ type Usecase struct {
ProxyAPI ProxyAPI
ConfigAPI ConfigAPI
SipServer *gbs.Server
UserAPI UserAPI
SipServer *gbs.Server
UserAPI UserAPI
AIWebhookAPI AIWebhookAPI
}
// NewHTTPHandler 生成Gin框架路由内容
func NewHTTPHandler(uc *Usecase) http.Handler {
cfg := uc.Conf.Server
// 检查是否设置了 JWT 密钥,如果未设置,则生成一个长度为 32 的随机字符串作为密钥
if cfg.HTTP.JwtSecret == "" {
uc.Conf.Server.HTTP.JwtSecret = orm.GenerateRandomString(32) // 生成一个长度为 32 的随机字符串作为密钥
uc.Conf.Server.HTTP.JwtSecret = orm.GenerateRandomString(32)
}
// 如果不处于调试模式,将 Gin 设置为发布模式
if !cfg.Debug {
gin.SetMode(gin.ReleaseMode) // 将 Gin 设置为发布模式
gin.SetMode(gin.ReleaseMode)
}
g := gin.New() // 创建一个新的 Gin 实例
// 处理未找到路由的情况,返回 JSON 格式的 404 错误信息
g := gin.New()
g.NoRoute(func(c *gin.Context) {
c.JSON(404, "来到了无人的荒漠") // 返回 JSON 格式的 404 错误信息
c.JSON(404, "来到了无人的荒漠")
})
// 如果启用了 Pprof,设置 Pprof 监控
if cfg.HTTP.PProf.Enabled {
+291
View File
@@ -0,0 +1,291 @@
package ffwork
import (
"bufio"
"context"
"fmt"
"io"
"os/exec"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/ixugo/goddd/pkg/queue"
)
type (
Config struct {
Width, Height int
FPS int
RTSPURL string
Transport string
UseWallClock bool
HWAccel string
OnFrame func(frame *FrameData)
Name string
}
FrameData struct {
FrameNum uint64
Timestamp time.Time
Data []byte
}
FrameCapture struct {
Name string
config Config
frameSize int
FrameCh chan *FrameData
errCh chan error
ctx context.Context
cancel context.CancelFunc
m sync.Mutex
started bool
cmd *exec.Cmd
lastFrame time.Time
wg sync.WaitGroup
ffmpegLog *queue.CirQueue[string]
frameCount, skipCount uint64
OnFrame func(frame *FrameData)
}
Stats struct {
Name string
FrameCount, SkipCount uint64
LastFrame time.Time
FrameSize int
IsRunning bool
}
)
func NewFrameCapture(cfg Config) (*FrameCapture, error) {
if cfg.Width <= 0 || cfg.Height <= 0 {
return nil, fmt.Errorf("invalid resolution: %dx%d", cfg.Width, cfg.Height)
}
if cfg.FPS <= 0 {
return nil, fmt.Errorf("invalid fps: %d", cfg.FPS)
}
if cfg.RTSPURL == "" {
return nil, fmt.Errorf("resp url is required")
}
if cfg.Transport == "" {
cfg.Transport = "tcp"
}
frameSize := cfg.Width * cfg.Height * 3 / 2
ctx, cancel := context.WithCancel(context.Background())
return &FrameCapture{
config: cfg,
frameSize: frameSize,
FrameCh: make(chan *FrameData, 10),
errCh: make(chan error, 1),
ctx: ctx,
cancel: cancel,
ffmpegLog: queue.NewCirQueue[string](100),
OnFrame: cfg.OnFrame,
}, nil
}
func (fc *FrameCapture) FrameSize() int {
return fc.frameSize
}
func (fc *FrameCapture) buildFFmpegArgs() []string {
args := []string{
"-hide_banner",
"-loglevel", "warning",
"-threads", "2",
}
args = append(args, "-user_agent", "FFmpeg GoWVP")
args = append(args, "-avoid_negative_ts", "make_zero",
"-fflags", "+genpts+discardcorrupt",
"-rtsp_transport", fc.config.Transport,
"-timeout", "10000000",
)
if fc.config.UseWallClock {
args = append(args, "-use_wallclock_as_timestamps", "1")
}
if fc.config.HWAccel != "" {
args = append(args, "-hwaccel", fc.config.HWAccel)
}
args = append(args, "-i", fc.config.RTSPURL)
args = append(args,
"-f", "rawvideo",
"-pix_fmt", "yuv420p",
"-r", strconv.Itoa(fc.config.FPS),
"-vf", fmt.Sprintf("fps=%d,scale=%d:%d", fc.config.FPS, fc.config.Width, fc.config.Height),
"pipe:1",
)
return args
}
func (fc *FrameCapture) Start() error {
fc.m.Lock()
defer fc.m.Unlock()
if fc.started {
return fmt.Errorf("frame capture already started")
}
args := fc.buildFFmpegArgs()
fc.cmd = exec.CommandContext(fc.ctx, "ffmpeg", args...)
stdout, err := fc.cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to get stdout pipe: %w", err)
}
stderr, err := fc.cmd.StderrPipe()
if err != nil {
return fmt.Errorf("failed to get stderr pipe: %w", err)
}
if err := fc.cmd.Start(); err != nil {
return fmt.Errorf("failed to start ffmpeg: %w", err)
}
fc.started = true
fc.lastFrame = time.Now()
fc.wg.Go(func() { fc.captureLoop(stdout) })
fc.wg.Go(func() { fc.readStderr(stderr) })
return nil
}
// captureLoop 从 ffmpeg 的 stdout 读取原始视频帧数据
// ffmpeg 输出的是固定大小的 YUV420P 格式帧,需要按帧大小读取
func (fc *FrameCapture) captureLoop(stdout io.Reader) {
defer close(fc.FrameCh)
reader := bufio.NewReaderSize(stdout, fc.frameSize*10)
for {
select {
case <-fc.ctx.Done():
return
default:
}
frameBytes := make([]byte, fc.frameSize)
n, err := io.ReadFull(reader, frameBytes)
if err != nil {
if err == io.EOF || err == io.ErrUnexpectedEOF {
select {
case fc.errCh <- fmt.Errorf("ffmpeg stream ended: %w", err):
default:
}
return
}
select {
case fc.errCh <- fmt.Errorf("failed to read frame: %w", err):
default:
return
}
}
if n != fc.frameSize {
select {
case fc.errCh <- fmt.Errorf("incomplete frame: %d != %d", n, fc.frameSize):
default:
}
return
}
frameNum := atomic.AddUint64(&fc.frameCount, 1)
now := time.Now()
fc.m.Lock()
fc.lastFrame = now
fc.m.Unlock()
frame := FrameData{
FrameNum: frameNum,
Timestamp: now,
Data: frameBytes,
}
if fc.OnFrame != nil {
fc.OnFrame(&frame)
}
select {
case fc.FrameCh <- &frame:
case <-fc.ctx.Done():
return
default:
atomic.AddUint64(&fc.skipCount, 1)
}
}
}
// readStderr 读取 ffmpeg 的 stderr 输出用于日志记录
// ffmpeg 的警告和错误信息都会输出到 stderr
func (fc *FrameCapture) readStderr(stderr io.Reader) {
scan := bufio.NewScanner(stderr)
for scan.Scan() {
line := scan.Text()
fc.ffmpegLog.Push(line)
}
}
func (fc *FrameCapture) Frames() <-chan *FrameData {
return fc.FrameCh
}
func (fc *FrameCapture) Error() <-chan error {
return fc.errCh
}
func (fc *FrameCapture) Log() []string {
return fc.ffmpegLog.Range()
}
func (fc *FrameCapture) GetFrame(timeout time.Duration) (*FrameData, error) {
select {
case frame, ok := <-fc.FrameCh:
if !ok {
return nil, fmt.Errorf("frame channel closed")
}
return frame, nil
case err := <-fc.errCh:
return nil, err
case <-fc.ctx.Done():
return nil, fc.ctx.Err()
case <-time.After(timeout):
return nil, fmt.Errorf("timeout")
}
}
func (fc *FrameCapture) Stop() error {
fc.m.Lock()
if !fc.started {
fc.m.Unlock()
return nil
}
fc.m.Unlock()
if cancel := fc.cancel; cancel != nil {
cancel()
}
fc.wg.Wait()
if fc.cmd != nil && fc.cmd.Process != nil {
done := make(chan error, 1)
defer close(done)
go func() {
done <- fc.cmd.Wait()
}()
select {
case <-time.After(5 * time.Second):
if err := fc.cmd.Process.Kill(); err != nil {
return fmt.Errorf("failed to kill ffmpeg: %w", err)
}
<-done
case <-done:
}
}
return nil
}
func (fc *FrameCapture) GetStats() Stats {
fc.m.Lock()
defer fc.m.Unlock()
return Stats{
Name: fc.config.Name,
FrameCount: atomic.LoadUint64(&fc.frameCount),
SkipCount: atomic.LoadUint64(&fc.skipCount),
LastFrame: fc.lastFrame,
FrameSize: fc.frameSize,
IsRunning: fc.started,
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ func TestGetSnapshot(t *testing.T) {
if len(imageData) >= 8 {
pngHeader := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
isPNG := true
for i := 0; i < 8; i++ {
for i := range 8 {
if imageData[i] != pngHeader[i] {
isPNG = false
break
-8
View File
@@ -184,14 +184,6 @@ type GetServerConfigData struct {
SrtTimeoutSec string `json:"srt.timeoutSec"`
}
func NewString(s string) *string {
return &s
}
func NewBool(b bool) *bool {
return &b
}
// SetServerConfigRequest
// https://github.com/zlmediakit/ZLMediaKit/wiki/MediaServer%E6%94%AF%E6%8C%81%E7%9A%84HTTP-HOOK-API
type SetServerConfigRequest struct {
+1 -1
View File
@@ -65,7 +65,7 @@ func (c *CircleQueue) Range() []PercentData {
idx = c.idx
}
data := make([]PercentData, 0, size)
for i := 0; i < size; i++ {
for range size {
data = append(data, c.array[idx])
idx = (idx + 1) % c.maxSize
}
+818
View File
@@ -0,0 +1,818 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v6.33.0
// source: protos/analysis.proto
package protos
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type HealthCheckResponse_ServingStatus int32
const (
HealthCheckResponse_UNKNOWN HealthCheckResponse_ServingStatus = 0
HealthCheckResponse_SERVING HealthCheckResponse_ServingStatus = 1 // 服务正常
HealthCheckResponse_NOT_SERVING HealthCheckResponse_ServingStatus = 2 // 服务不可用 (加载模型中)
)
// Enum value maps for HealthCheckResponse_ServingStatus.
var (
HealthCheckResponse_ServingStatus_name = map[int32]string{
0: "UNKNOWN",
1: "SERVING",
2: "NOT_SERVING",
}
HealthCheckResponse_ServingStatus_value = map[string]int32{
"UNKNOWN": 0,
"SERVING": 1,
"NOT_SERVING": 2,
}
)
func (x HealthCheckResponse_ServingStatus) Enum() *HealthCheckResponse_ServingStatus {
p := new(HealthCheckResponse_ServingStatus)
*p = x
return p
}
func (x HealthCheckResponse_ServingStatus) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (HealthCheckResponse_ServingStatus) Descriptor() protoreflect.EnumDescriptor {
return file_protos_analysis_proto_enumTypes[0].Descriptor()
}
func (HealthCheckResponse_ServingStatus) Type() protoreflect.EnumType {
return &file_protos_analysis_proto_enumTypes[0]
}
func (x HealthCheckResponse_ServingStatus) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use HealthCheckResponse_ServingStatus.Descriptor instead.
func (HealthCheckResponse_ServingStatus) EnumDescriptor() ([]byte, []int) {
return file_protos_analysis_proto_rawDescGZIP(), []int{9, 0}
}
// 启动摄像头分析请求
type StartCameraRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// === 基础信息 ===
CameraId string `protobuf:"bytes,1,opt,name=camera_id,json=cameraId,proto3" json:"camera_id,omitempty"` // 摄像头唯一标识 (必填)
CameraName string `protobuf:"bytes,2,opt,name=camera_name,json=cameraName,proto3" json:"camera_name,omitempty"` // 摄像头名称,用于日志 (可选)
RtspUrl string `protobuf:"bytes,3,opt,name=rtsp_url,json=rtspUrl,proto3" json:"rtsp_url,omitempty"` // RTSP 子码流地址 (必填)
// === 检测配置 ===
DetectFps int32 `protobuf:"varint,4,opt,name=detect_fps,json=detectFps,proto3" json:"detect_fps,omitempty"` // 检测帧率,默认 5
Labels []string `protobuf:"bytes,5,rep,name=labels,proto3" json:"labels,omitempty"` // 要检测的标签,如 ["person", "car"],空则检测全部
Threshold float32 `protobuf:"fixed32,6,opt,name=threshold,proto3" json:"threshold,omitempty"` // 置信度阈值,默认 0.5
// === ROI 区域 (多边形点位) ===
// n 个坐标组合成的多边形,例如 [0.1, 0.2, 0.12, 0.22, 0.1, 0.3...]
// 归一化坐标 (x1, y1, x2, y2, ...)
RoiPoints []float32 `protobuf:"fixed32,7,rep,packed,name=roi_points,json=roiPoints,proto3" json:"roi_points,omitempty"`
// 错误处理
RetryLimit int32 `protobuf:"varint,8,opt,name=retry_limit,json=retryLimit,proto3" json:"retry_limit,omitempty"` // 遇到错误的自动重试次数,默认 10
// === 回调配置 ===
// 优先级高于服务启动时的默认配置
CallbackUrl string `protobuf:"bytes,10,opt,name=callback_url,json=callbackUrl,proto3" json:"callback_url,omitempty"` // HTTP 回调地址 (必填)
CallbackSecret string `protobuf:"bytes,11,opt,name=callback_secret,json=callbackSecret,proto3" json:"callback_secret,omitempty"` // 回调签名密钥 (可选,用于验证)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StartCameraRequest) Reset() {
*x = StartCameraRequest{}
mi := &file_protos_analysis_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StartCameraRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StartCameraRequest) ProtoMessage() {}
func (x *StartCameraRequest) ProtoReflect() protoreflect.Message {
mi := &file_protos_analysis_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StartCameraRequest.ProtoReflect.Descriptor instead.
func (*StartCameraRequest) Descriptor() ([]byte, []int) {
return file_protos_analysis_proto_rawDescGZIP(), []int{0}
}
func (x *StartCameraRequest) GetCameraId() string {
if x != nil {
return x.CameraId
}
return ""
}
func (x *StartCameraRequest) GetCameraName() string {
if x != nil {
return x.CameraName
}
return ""
}
func (x *StartCameraRequest) GetRtspUrl() string {
if x != nil {
return x.RtspUrl
}
return ""
}
func (x *StartCameraRequest) GetDetectFps() int32 {
if x != nil {
return x.DetectFps
}
return 0
}
func (x *StartCameraRequest) GetLabels() []string {
if x != nil {
return x.Labels
}
return nil
}
func (x *StartCameraRequest) GetThreshold() float32 {
if x != nil {
return x.Threshold
}
return 0
}
func (x *StartCameraRequest) GetRoiPoints() []float32 {
if x != nil {
return x.RoiPoints
}
return nil
}
func (x *StartCameraRequest) GetRetryLimit() int32 {
if x != nil {
return x.RetryLimit
}
return 0
}
func (x *StartCameraRequest) GetCallbackUrl() string {
if x != nil {
return x.CallbackUrl
}
return ""
}
func (x *StartCameraRequest) GetCallbackSecret() string {
if x != nil {
return x.CallbackSecret
}
return ""
}
type StartCameraResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
// 流信息 (通过 ffprobe 探测返回)
SourceWidth int32 `protobuf:"varint,3,opt,name=source_width,json=sourceWidth,proto3" json:"source_width,omitempty"` // 原始分辨率宽度
SourceHeight int32 `protobuf:"varint,4,opt,name=source_height,json=sourceHeight,proto3" json:"source_height,omitempty"` // 原始分辨率高度
SourceFps float32 `protobuf:"fixed32,5,opt,name=source_fps,json=sourceFps,proto3" json:"source_fps,omitempty"` // 原始帧率
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StartCameraResponse) Reset() {
*x = StartCameraResponse{}
mi := &file_protos_analysis_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StartCameraResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StartCameraResponse) ProtoMessage() {}
func (x *StartCameraResponse) ProtoReflect() protoreflect.Message {
mi := &file_protos_analysis_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StartCameraResponse.ProtoReflect.Descriptor instead.
func (*StartCameraResponse) Descriptor() ([]byte, []int) {
return file_protos_analysis_proto_rawDescGZIP(), []int{1}
}
func (x *StartCameraResponse) GetSuccess() bool {
if x != nil {
return x.Success
}
return false
}
func (x *StartCameraResponse) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
func (x *StartCameraResponse) GetSourceWidth() int32 {
if x != nil {
return x.SourceWidth
}
return 0
}
func (x *StartCameraResponse) GetSourceHeight() int32 {
if x != nil {
return x.SourceHeight
}
return 0
}
func (x *StartCameraResponse) GetSourceFps() float32 {
if x != nil {
return x.SourceFps
}
return 0
}
type StopCameraRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
CameraId string `protobuf:"bytes,1,opt,name=camera_id,json=cameraId,proto3" json:"camera_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StopCameraRequest) Reset() {
*x = StopCameraRequest{}
mi := &file_protos_analysis_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StopCameraRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StopCameraRequest) ProtoMessage() {}
func (x *StopCameraRequest) ProtoReflect() protoreflect.Message {
mi := &file_protos_analysis_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StopCameraRequest.ProtoReflect.Descriptor instead.
func (*StopCameraRequest) Descriptor() ([]byte, []int) {
return file_protos_analysis_proto_rawDescGZIP(), []int{2}
}
func (x *StopCameraRequest) GetCameraId() string {
if x != nil {
return x.CameraId
}
return ""
}
type StopCameraResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StopCameraResponse) Reset() {
*x = StopCameraResponse{}
mi := &file_protos_analysis_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StopCameraResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StopCameraResponse) ProtoMessage() {}
func (x *StopCameraResponse) ProtoReflect() protoreflect.Message {
mi := &file_protos_analysis_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StopCameraResponse.ProtoReflect.Descriptor instead.
func (*StopCameraResponse) Descriptor() ([]byte, []int) {
return file_protos_analysis_proto_rawDescGZIP(), []int{3}
}
func (x *StopCameraResponse) GetSuccess() bool {
if x != nil {
return x.Success
}
return false
}
func (x *StopCameraResponse) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
type StatusRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StatusRequest) Reset() {
*x = StatusRequest{}
mi := &file_protos_analysis_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StatusRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StatusRequest) ProtoMessage() {}
func (x *StatusRequest) ProtoReflect() protoreflect.Message {
mi := &file_protos_analysis_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StatusRequest.ProtoReflect.Descriptor instead.
func (*StatusRequest) Descriptor() ([]byte, []int) {
return file_protos_analysis_proto_rawDescGZIP(), []int{4}
}
type StatusResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
IsReady bool `protobuf:"varint,1,opt,name=is_ready,json=isReady,proto3" json:"is_ready,omitempty"` // AI 模型是否加载完成
Cameras []*CameraStatus `protobuf:"bytes,2,rep,name=cameras,proto3" json:"cameras,omitempty"` // 摄像头状态列表
Stats *GlobalStats `protobuf:"bytes,3,opt,name=stats,proto3" json:"stats,omitempty"` // 全局统计信息
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StatusResponse) Reset() {
*x = StatusResponse{}
mi := &file_protos_analysis_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StatusResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StatusResponse) ProtoMessage() {}
func (x *StatusResponse) ProtoReflect() protoreflect.Message {
mi := &file_protos_analysis_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StatusResponse.ProtoReflect.Descriptor instead.
func (*StatusResponse) Descriptor() ([]byte, []int) {
return file_protos_analysis_proto_rawDescGZIP(), []int{5}
}
func (x *StatusResponse) GetIsReady() bool {
if x != nil {
return x.IsReady
}
return false
}
func (x *StatusResponse) GetCameras() []*CameraStatus {
if x != nil {
return x.Cameras
}
return nil
}
func (x *StatusResponse) GetStats() *GlobalStats {
if x != nil {
return x.Stats
}
return nil
}
type CameraStatus struct {
state protoimpl.MessageState `protogen:"open.v1"`
CameraId string `protobuf:"bytes,1,opt,name=camera_id,json=cameraId,proto3" json:"camera_id,omitempty"`
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` // "running", "error", "stopped"
FramesProcessed int64 `protobuf:"varint,3,opt,name=frames_processed,json=framesProcessed,proto3" json:"frames_processed,omitempty"` // 已处理帧数
LastError string `protobuf:"bytes,4,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` // 最后一次错误信息
RetryCount int32 `protobuf:"varint,5,opt,name=retry_count,json=retryCount,proto3" json:"retry_count,omitempty"` // 当前重试次数
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CameraStatus) Reset() {
*x = CameraStatus{}
mi := &file_protos_analysis_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CameraStatus) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CameraStatus) ProtoMessage() {}
func (x *CameraStatus) ProtoReflect() protoreflect.Message {
mi := &file_protos_analysis_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CameraStatus.ProtoReflect.Descriptor instead.
func (*CameraStatus) Descriptor() ([]byte, []int) {
return file_protos_analysis_proto_rawDescGZIP(), []int{6}
}
func (x *CameraStatus) GetCameraId() string {
if x != nil {
return x.CameraId
}
return ""
}
func (x *CameraStatus) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
func (x *CameraStatus) GetFramesProcessed() int64 {
if x != nil {
return x.FramesProcessed
}
return 0
}
func (x *CameraStatus) GetLastError() string {
if x != nil {
return x.LastError
}
return ""
}
func (x *CameraStatus) GetRetryCount() int32 {
if x != nil {
return x.RetryCount
}
return 0
}
type GlobalStats struct {
state protoimpl.MessageState `protogen:"open.v1"`
ActiveStreams int32 `protobuf:"varint,1,opt,name=active_streams,json=activeStreams,proto3" json:"active_streams,omitempty"` // 当前活跃 RTSP 流数量
TotalDetections int64 `protobuf:"varint,2,opt,name=total_detections,json=totalDetections,proto3" json:"total_detections,omitempty"` // 服务启动以来的总检测次数
UptimeSeconds int64 `protobuf:"varint,3,opt,name=uptime_seconds,json=uptimeSeconds,proto3" json:"uptime_seconds,omitempty"` // 运行时间
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GlobalStats) Reset() {
*x = GlobalStats{}
mi := &file_protos_analysis_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GlobalStats) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GlobalStats) ProtoMessage() {}
func (x *GlobalStats) ProtoReflect() protoreflect.Message {
mi := &file_protos_analysis_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GlobalStats.ProtoReflect.Descriptor instead.
func (*GlobalStats) Descriptor() ([]byte, []int) {
return file_protos_analysis_proto_rawDescGZIP(), []int{7}
}
func (x *GlobalStats) GetActiveStreams() int32 {
if x != nil {
return x.ActiveStreams
}
return 0
}
func (x *GlobalStats) GetTotalDetections() int64 {
if x != nil {
return x.TotalDetections
}
return 0
}
func (x *GlobalStats) GetUptimeSeconds() int64 {
if x != nil {
return x.UptimeSeconds
}
return 0
}
type HealthCheckRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HealthCheckRequest) Reset() {
*x = HealthCheckRequest{}
mi := &file_protos_analysis_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HealthCheckRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HealthCheckRequest) ProtoMessage() {}
func (x *HealthCheckRequest) ProtoReflect() protoreflect.Message {
mi := &file_protos_analysis_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HealthCheckRequest.ProtoReflect.Descriptor instead.
func (*HealthCheckRequest) Descriptor() ([]byte, []int) {
return file_protos_analysis_proto_rawDescGZIP(), []int{8}
}
type HealthCheckResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Status HealthCheckResponse_ServingStatus `protobuf:"varint,1,opt,name=status,proto3,enum=analysis.HealthCheckResponse_ServingStatus" json:"status,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HealthCheckResponse) Reset() {
*x = HealthCheckResponse{}
mi := &file_protos_analysis_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HealthCheckResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HealthCheckResponse) ProtoMessage() {}
func (x *HealthCheckResponse) ProtoReflect() protoreflect.Message {
mi := &file_protos_analysis_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HealthCheckResponse.ProtoReflect.Descriptor instead.
func (*HealthCheckResponse) Descriptor() ([]byte, []int) {
return file_protos_analysis_proto_rawDescGZIP(), []int{9}
}
func (x *HealthCheckResponse) GetStatus() HealthCheckResponse_ServingStatus {
if x != nil {
return x.Status
}
return HealthCheckResponse_UNKNOWN
}
var File_protos_analysis_proto protoreflect.FileDescriptor
const file_protos_analysis_proto_rawDesc = "" +
"\n" +
"\x15protos/analysis.proto\x12\banalysis\"\xce\x02\n" +
"\x12StartCameraRequest\x12\x1b\n" +
"\tcamera_id\x18\x01 \x01(\tR\bcameraId\x12\x1f\n" +
"\vcamera_name\x18\x02 \x01(\tR\n" +
"cameraName\x12\x19\n" +
"\brtsp_url\x18\x03 \x01(\tR\artspUrl\x12\x1d\n" +
"\n" +
"detect_fps\x18\x04 \x01(\x05R\tdetectFps\x12\x16\n" +
"\x06labels\x18\x05 \x03(\tR\x06labels\x12\x1c\n" +
"\tthreshold\x18\x06 \x01(\x02R\tthreshold\x12\x1d\n" +
"\n" +
"roi_points\x18\a \x03(\x02R\troiPoints\x12\x1f\n" +
"\vretry_limit\x18\b \x01(\x05R\n" +
"retryLimit\x12!\n" +
"\fcallback_url\x18\n" +
" \x01(\tR\vcallbackUrl\x12'\n" +
"\x0fcallback_secret\x18\v \x01(\tR\x0ecallbackSecret\"\xb0\x01\n" +
"\x13StartCameraResponse\x12\x18\n" +
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage\x12!\n" +
"\fsource_width\x18\x03 \x01(\x05R\vsourceWidth\x12#\n" +
"\rsource_height\x18\x04 \x01(\x05R\fsourceHeight\x12\x1d\n" +
"\n" +
"source_fps\x18\x05 \x01(\x02R\tsourceFps\"0\n" +
"\x11StopCameraRequest\x12\x1b\n" +
"\tcamera_id\x18\x01 \x01(\tR\bcameraId\"H\n" +
"\x12StopCameraResponse\x12\x18\n" +
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage\"\x0f\n" +
"\rStatusRequest\"\x8a\x01\n" +
"\x0eStatusResponse\x12\x19\n" +
"\bis_ready\x18\x01 \x01(\bR\aisReady\x120\n" +
"\acameras\x18\x02 \x03(\v2\x16.analysis.CameraStatusR\acameras\x12+\n" +
"\x05stats\x18\x03 \x01(\v2\x15.analysis.GlobalStatsR\x05stats\"\xae\x01\n" +
"\fCameraStatus\x12\x1b\n" +
"\tcamera_id\x18\x01 \x01(\tR\bcameraId\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\x12)\n" +
"\x10frames_processed\x18\x03 \x01(\x03R\x0fframesProcessed\x12\x1d\n" +
"\n" +
"last_error\x18\x04 \x01(\tR\tlastError\x12\x1f\n" +
"\vretry_count\x18\x05 \x01(\x05R\n" +
"retryCount\"\x86\x01\n" +
"\vGlobalStats\x12%\n" +
"\x0eactive_streams\x18\x01 \x01(\x05R\ractiveStreams\x12)\n" +
"\x10total_detections\x18\x02 \x01(\x03R\x0ftotalDetections\x12%\n" +
"\x0euptime_seconds\x18\x03 \x01(\x03R\ruptimeSeconds\"\x14\n" +
"\x12HealthCheckRequest\"\x96\x01\n" +
"\x13HealthCheckResponse\x12C\n" +
"\x06status\x18\x01 \x01(\x0e2+.analysis.HealthCheckResponse.ServingStatusR\x06status\":\n" +
"\rServingStatus\x12\v\n" +
"\aUNKNOWN\x10\x00\x12\v\n" +
"\aSERVING\x10\x01\x12\x0f\n" +
"\vNOT_SERVING\x10\x022\xe6\x01\n" +
"\x0fAnalysisService\x12J\n" +
"\vStartCamera\x12\x1c.analysis.StartCameraRequest\x1a\x1d.analysis.StartCameraResponse\x12G\n" +
"\n" +
"StopCamera\x12\x1b.analysis.StopCameraRequest\x1a\x1c.analysis.StopCameraResponse\x12>\n" +
"\tGetStatus\x12\x17.analysis.StatusRequest\x1a\x18.analysis.StatusResponse2N\n" +
"\x06Health\x12D\n" +
"\x05Check\x12\x1c.analysis.HealthCheckRequest\x1a\x1d.analysis.HealthCheckResponseB\n" +
"Z\b./protosb\x06proto3"
var (
file_protos_analysis_proto_rawDescOnce sync.Once
file_protos_analysis_proto_rawDescData []byte
)
func file_protos_analysis_proto_rawDescGZIP() []byte {
file_protos_analysis_proto_rawDescOnce.Do(func() {
file_protos_analysis_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_protos_analysis_proto_rawDesc), len(file_protos_analysis_proto_rawDesc)))
})
return file_protos_analysis_proto_rawDescData
}
var file_protos_analysis_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_protos_analysis_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
var file_protos_analysis_proto_goTypes = []any{
(HealthCheckResponse_ServingStatus)(0), // 0: analysis.HealthCheckResponse.ServingStatus
(*StartCameraRequest)(nil), // 1: analysis.StartCameraRequest
(*StartCameraResponse)(nil), // 2: analysis.StartCameraResponse
(*StopCameraRequest)(nil), // 3: analysis.StopCameraRequest
(*StopCameraResponse)(nil), // 4: analysis.StopCameraResponse
(*StatusRequest)(nil), // 5: analysis.StatusRequest
(*StatusResponse)(nil), // 6: analysis.StatusResponse
(*CameraStatus)(nil), // 7: analysis.CameraStatus
(*GlobalStats)(nil), // 8: analysis.GlobalStats
(*HealthCheckRequest)(nil), // 9: analysis.HealthCheckRequest
(*HealthCheckResponse)(nil), // 10: analysis.HealthCheckResponse
}
var file_protos_analysis_proto_depIdxs = []int32{
7, // 0: analysis.StatusResponse.cameras:type_name -> analysis.CameraStatus
8, // 1: analysis.StatusResponse.stats:type_name -> analysis.GlobalStats
0, // 2: analysis.HealthCheckResponse.status:type_name -> analysis.HealthCheckResponse.ServingStatus
1, // 3: analysis.AnalysisService.StartCamera:input_type -> analysis.StartCameraRequest
3, // 4: analysis.AnalysisService.StopCamera:input_type -> analysis.StopCameraRequest
5, // 5: analysis.AnalysisService.GetStatus:input_type -> analysis.StatusRequest
9, // 6: analysis.Health.Check:input_type -> analysis.HealthCheckRequest
2, // 7: analysis.AnalysisService.StartCamera:output_type -> analysis.StartCameraResponse
4, // 8: analysis.AnalysisService.StopCamera:output_type -> analysis.StopCameraResponse
6, // 9: analysis.AnalysisService.GetStatus:output_type -> analysis.StatusResponse
10, // 10: analysis.Health.Check:output_type -> analysis.HealthCheckResponse
7, // [7:11] is the sub-list for method output_type
3, // [3:7] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name
}
func init() { file_protos_analysis_proto_init() }
func file_protos_analysis_proto_init() {
if File_protos_analysis_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_protos_analysis_proto_rawDesc), len(file_protos_analysis_proto_rawDesc)),
NumEnums: 1,
NumMessages: 10,
NumExtensions: 0,
NumServices: 2,
},
GoTypes: file_protos_analysis_proto_goTypes,
DependencyIndexes: file_protos_analysis_proto_depIdxs,
EnumInfos: file_protos_analysis_proto_enumTypes,
MessageInfos: file_protos_analysis_proto_msgTypes,
}.Build()
File_protos_analysis_proto = out.File
file_protos_analysis_proto_goTypes = nil
file_protos_analysis_proto_depIdxs = nil
}
+102
View File
@@ -0,0 +1,102 @@
syntax = "proto3";
package analysis;
option go_package = "./protos";
service AnalysisService {
//
rpc StartCamera(StartCameraRequest) returns (StartCameraResponse);
//
rpc StopCamera(StopCameraRequest) returns (StopCameraResponse);
//
rpc GetStatus(StatusRequest) returns (StatusResponse);
}
service Health {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
}
//
message StartCameraRequest {
// === ===
string camera_id = 1; // ()
string camera_name = 2; // ()
string rtsp_url = 3; // RTSP ()
// === ===
int32 detect_fps = 4; // 5
repeated string labels = 5; // ["person", "car"]
float threshold = 6; // 0.5
// === ROI () ===
// n [0.1, 0.2, 0.12, 0.22, 0.1, 0.3...]
// (x1, y1, x2, y2, ...)
repeated float roi_points = 7;
//
int32 retry_limit = 8; // 10
// === ===
//
string callback_url = 10; // HTTP ()
string callback_secret = 11; // ()
}
message StartCameraResponse {
bool success = 1;
string message = 2;
// ( ffprobe )
int32 source_width = 3; //
int32 source_height = 4; //
float source_fps = 5; //
}
message StopCameraRequest {
string camera_id = 1;
}
message StopCameraResponse {
bool success = 1;
string message = 2;
}
message StatusRequest {}
message StatusResponse {
bool is_ready = 1; // AI
repeated CameraStatus cameras = 2; //
GlobalStats stats = 3; //
}
message CameraStatus {
string camera_id = 1;
string status = 2; // "running", "error", "stopped"
int64 frames_processed = 3; //
string last_error = 4; //
int32 retry_count = 5; //
}
message GlobalStats {
int32 active_streams = 1; // RTSP
int64 total_detections = 2; //
int64 uptime_seconds = 3; //
}
// =============================================================================
//
// =============================================================================
message HealthCheckRequest {}
message HealthCheckResponse {
enum ServingStatus {
UNKNOWN = 0;
SERVING = 1; //
NOT_SERVING = 2;// ()
}
ServingStatus status = 1;
}
+305
View File
@@ -0,0 +1,305 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.0
// - protoc v6.33.0
// source: protos/analysis.proto
package protos
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
AnalysisService_StartCamera_FullMethodName = "/analysis.AnalysisService/StartCamera"
AnalysisService_StopCamera_FullMethodName = "/analysis.AnalysisService/StopCamera"
AnalysisService_GetStatus_FullMethodName = "/analysis.AnalysisService/GetStatus"
)
// AnalysisServiceClient is the client API for AnalysisService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type AnalysisServiceClient interface {
// 启动摄像头分析
StartCamera(ctx context.Context, in *StartCameraRequest, opts ...grpc.CallOption) (*StartCameraResponse, error)
// 停止摄像头分析
StopCamera(ctx context.Context, in *StopCameraRequest, opts ...grpc.CallOption) (*StopCameraResponse, error)
// 获取服务状态
GetStatus(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (*StatusResponse, error)
}
type analysisServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAnalysisServiceClient(cc grpc.ClientConnInterface) AnalysisServiceClient {
return &analysisServiceClient{cc}
}
func (c *analysisServiceClient) StartCamera(ctx context.Context, in *StartCameraRequest, opts ...grpc.CallOption) (*StartCameraResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(StartCameraResponse)
err := c.cc.Invoke(ctx, AnalysisService_StartCamera_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *analysisServiceClient) StopCamera(ctx context.Context, in *StopCameraRequest, opts ...grpc.CallOption) (*StopCameraResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(StopCameraResponse)
err := c.cc.Invoke(ctx, AnalysisService_StopCamera_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *analysisServiceClient) GetStatus(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (*StatusResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(StatusResponse)
err := c.cc.Invoke(ctx, AnalysisService_GetStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AnalysisServiceServer is the server API for AnalysisService service.
// All implementations must embed UnimplementedAnalysisServiceServer
// for forward compatibility.
type AnalysisServiceServer interface {
// 启动摄像头分析
StartCamera(context.Context, *StartCameraRequest) (*StartCameraResponse, error)
// 停止摄像头分析
StopCamera(context.Context, *StopCameraRequest) (*StopCameraResponse, error)
// 获取服务状态
GetStatus(context.Context, *StatusRequest) (*StatusResponse, error)
mustEmbedUnimplementedAnalysisServiceServer()
}
// UnimplementedAnalysisServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAnalysisServiceServer struct{}
func (UnimplementedAnalysisServiceServer) StartCamera(context.Context, *StartCameraRequest) (*StartCameraResponse, error) {
return nil, status.Error(codes.Unimplemented, "method StartCamera not implemented")
}
func (UnimplementedAnalysisServiceServer) StopCamera(context.Context, *StopCameraRequest) (*StopCameraResponse, error) {
return nil, status.Error(codes.Unimplemented, "method StopCamera not implemented")
}
func (UnimplementedAnalysisServiceServer) GetStatus(context.Context, *StatusRequest) (*StatusResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetStatus not implemented")
}
func (UnimplementedAnalysisServiceServer) mustEmbedUnimplementedAnalysisServiceServer() {}
func (UnimplementedAnalysisServiceServer) testEmbeddedByValue() {}
// UnsafeAnalysisServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AnalysisServiceServer will
// result in compilation errors.
type UnsafeAnalysisServiceServer interface {
mustEmbedUnimplementedAnalysisServiceServer()
}
func RegisterAnalysisServiceServer(s grpc.ServiceRegistrar, srv AnalysisServiceServer) {
// If the following call panics, it indicates UnimplementedAnalysisServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AnalysisService_ServiceDesc, srv)
}
func _AnalysisService_StartCamera_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StartCameraRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AnalysisServiceServer).StartCamera(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AnalysisService_StartCamera_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AnalysisServiceServer).StartCamera(ctx, req.(*StartCameraRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AnalysisService_StopCamera_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StopCameraRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AnalysisServiceServer).StopCamera(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AnalysisService_StopCamera_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AnalysisServiceServer).StopCamera(ctx, req.(*StopCameraRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AnalysisService_GetStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AnalysisServiceServer).GetStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AnalysisService_GetStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AnalysisServiceServer).GetStatus(ctx, req.(*StatusRequest))
}
return interceptor(ctx, in, info, handler)
}
// AnalysisService_ServiceDesc is the grpc.ServiceDesc for AnalysisService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AnalysisService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "analysis.AnalysisService",
HandlerType: (*AnalysisServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "StartCamera",
Handler: _AnalysisService_StartCamera_Handler,
},
{
MethodName: "StopCamera",
Handler: _AnalysisService_StopCamera_Handler,
},
{
MethodName: "GetStatus",
Handler: _AnalysisService_GetStatus_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "protos/analysis.proto",
}
const (
Health_Check_FullMethodName = "/analysis.Health/Check"
)
// HealthClient is the client API for Health service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type HealthClient interface {
Check(ctx context.Context, in *HealthCheckRequest, opts ...grpc.CallOption) (*HealthCheckResponse, error)
}
type healthClient struct {
cc grpc.ClientConnInterface
}
func NewHealthClient(cc grpc.ClientConnInterface) HealthClient {
return &healthClient{cc}
}
func (c *healthClient) Check(ctx context.Context, in *HealthCheckRequest, opts ...grpc.CallOption) (*HealthCheckResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HealthCheckResponse)
err := c.cc.Invoke(ctx, Health_Check_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// HealthServer is the server API for Health service.
// All implementations must embed UnimplementedHealthServer
// for forward compatibility.
type HealthServer interface {
Check(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error)
mustEmbedUnimplementedHealthServer()
}
// UnimplementedHealthServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedHealthServer struct{}
func (UnimplementedHealthServer) Check(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Check not implemented")
}
func (UnimplementedHealthServer) mustEmbedUnimplementedHealthServer() {}
func (UnimplementedHealthServer) testEmbeddedByValue() {}
// UnsafeHealthServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to HealthServer will
// result in compilation errors.
type UnsafeHealthServer interface {
mustEmbedUnimplementedHealthServer()
}
func RegisterHealthServer(s grpc.ServiceRegistrar, srv HealthServer) {
// If the following call panics, it indicates UnimplementedHealthServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&Health_ServiceDesc, srv)
}
func _Health_Check_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HealthCheckRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(HealthServer).Check(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Health_Check_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(HealthServer).Check(ctx, req.(*HealthCheckRequest))
}
return interceptor(ctx, in, info, handler)
}
// Health_ServiceDesc is the grpc.ServiceDesc for Health service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var Health_ServiceDesc = grpc.ServiceDesc{
ServiceName: "analysis.Health",
HandlerType: (*HealthServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Check",
Handler: _Health_Check_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "protos/analysis.proto",
}