Compare commits
30 Commits
7b1825d9f5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a488ee812f | |||
| a38b27a78d | |||
| a327dd0339 | |||
| 1f00f8f3f7 | |||
| f2e2569b7c | |||
| e7e2b86cd7 | |||
| 4259774365 | |||
| aa4f167840 | |||
| 68d6849120 | |||
| bfbe69eeb5 | |||
| 1aecfba186 | |||
| 8598098d32 | |||
| 59ab409d52 | |||
| 54a906211a | |||
| c22ff36194 | |||
| 7c9055c3b2 | |||
| 164d9e4744 | |||
| af8a6f7822 | |||
| 4f398a6593 | |||
| e80e88b22a | |||
| 3cf386fbef | |||
| 7c7e83eda8 | |||
| 092e6178a2 | |||
| 2fd67fc656 | |||
| e7ec268b24 | |||
| 42932ea1c8 | |||
| 03caa122ec | |||
| d87e960e8b | |||
| 0c50dfff30 | |||
| b4f95eb43d |
62
biz/base_detector.py
Normal file
62
biz/base_detector.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from collections import deque
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
|
||||
|
||||
class BaseDetector:
|
||||
"""
|
||||
检测器基类
|
||||
提供通用的帧回溯缓存功能,子类可按需使用
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# 帧回溯缓存(子类需要时调用 init_frame_buffer 初始化)
|
||||
self._frame_buffer: Optional[deque] = None
|
||||
|
||||
def init_frame_buffer(self, buffer_seconds: float, fps: float):
|
||||
"""
|
||||
初始化帧回溯缓存队列
|
||||
|
||||
Args:
|
||||
buffer_seconds: 需要缓存的时间长度(秒)
|
||||
fps: 视频帧率
|
||||
"""
|
||||
maxlen = int(buffer_seconds * fps)
|
||||
self._frame_buffer = deque(maxlen=maxlen)
|
||||
|
||||
def append_frame(self, frame: np.ndarray, timestamp: float):
|
||||
"""
|
||||
将当前帧入队缓存
|
||||
|
||||
Args:
|
||||
frame: 当前帧图像
|
||||
timestamp: 当前帧的时间戳
|
||||
"""
|
||||
if self._frame_buffer is not None:
|
||||
self._frame_buffer.append({
|
||||
'timestamp': timestamp,
|
||||
'frame': frame.copy(),
|
||||
})
|
||||
|
||||
def find_target_frame(self, target_time_sec: float) -> Optional[np.ndarray]:
|
||||
"""
|
||||
在帧缓存中找到最接近目标时间的帧
|
||||
|
||||
Args:
|
||||
target_time_sec: 目标时间戳
|
||||
|
||||
Returns:
|
||||
最接近目标时间的帧图像,缓存为空则返回 None
|
||||
"""
|
||||
if self._frame_buffer is None or len(self._frame_buffer) == 0:
|
||||
return None
|
||||
|
||||
target_frame = None
|
||||
min_time_diff = float('inf')
|
||||
for buffered in self._frame_buffer:
|
||||
time_diff = abs(buffered['timestamp'] - target_time_sec)
|
||||
if time_diff < min_time_diff:
|
||||
min_time_diff = time_diff
|
||||
target_frame = buffered['frame']
|
||||
|
||||
return target_frame
|
||||
@@ -81,7 +81,16 @@ class BaseFrameProcessorWorker(threading.Thread):
|
||||
|
||||
def _encode_image_to_base64(self, img) -> str:
|
||||
"""图像编码为 Base64"""
|
||||
ok, buf = cv2.imencode(".jpg", img)
|
||||
# 检查并调整图片尺寸
|
||||
h, w = img.shape[:2]
|
||||
if h > 720:
|
||||
new_h = 720
|
||||
new_w = int(w * (720 / h))
|
||||
img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
||||
|
||||
# 设置JPEG质量参数
|
||||
params = [int(cv2.IMWRITE_JPEG_QUALITY), 80] # 质量0.8
|
||||
ok, buf = cv2.imencode(".jpg", img, params)
|
||||
if not ok:
|
||||
raise RuntimeError("Failed to encode image to JPEG")
|
||||
return base64.b64encode(buf.tobytes()).decode("ascii")
|
||||
@@ -161,13 +170,13 @@ class BaseFrameProcessorWorker(threading.Thread):
|
||||
'video': f,
|
||||
'metadata': (None, json.dumps(msg))
|
||||
}
|
||||
response = requests.post(constants.ALERT_PUSH_URL, files=files, timeout=10.0)
|
||||
response = requests.post(constants.ALERT_PUSH_URL, files=files, timeout=60.0)
|
||||
else:
|
||||
# 无视频,也使用 multipart/form-data
|
||||
files = {
|
||||
'metadata': (None, json.dumps(msg))
|
||||
}
|
||||
response = requests.post(constants.ALERT_PUSH_URL, files=files, timeout=5.0)
|
||||
response = requests.post(constants.ALERT_PUSH_URL, files=files, timeout=30.0)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"[INFO] POST alert sent successfully for actions: {msg.get('result_type')}")
|
||||
@@ -367,7 +376,7 @@ class BaseFrameProcessorWorker(threading.Thread):
|
||||
|
||||
def _filter_duplicate_alerts(self, cam_id: int, alerts: list, current_time: float) -> list:
|
||||
"""
|
||||
过滤5秒内重复的告警
|
||||
过滤重复告警,支持不同 action 使用不同频率
|
||||
|
||||
Args:
|
||||
cam_id: 摄像头ID
|
||||
@@ -383,9 +392,15 @@ class BaseFrameProcessorWorker(threading.Thread):
|
||||
push_actions = []
|
||||
for alert in alerts:
|
||||
action = alert['action']
|
||||
|
||||
# 获取该 action 的推送间隔
|
||||
# 优先从配置中查找,没有则使用默认的 ALERT_PUSH_INTERVAL
|
||||
push_interval = constants.ALERT_PUSH_INTERVALS.get(action, ALERT_PUSH_INTERVAL)
|
||||
|
||||
last_push = self.last_alert_push_time[cam_id].get(action, 0)
|
||||
# 检查是否超过推送间隔
|
||||
if current_time - last_push >= ALERT_PUSH_INTERVAL:
|
||||
|
||||
# 使用各自的推送间隔进行检查
|
||||
if current_time - last_push >= push_interval:
|
||||
push_actions.append(action)
|
||||
# 更新该action的最后推送时间
|
||||
self.last_alert_push_time[cam_id][action] = current_time
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import cv2
|
||||
import numpy as np
|
||||
from typing import Dict, Any
|
||||
import threading
|
||||
import queue
|
||||
from collections import deque
|
||||
|
||||
|
||||
from biz.base_frame_processor import BaseFrameProcessorWorker
|
||||
from biz.base_detector import BaseDetector
|
||||
|
||||
# -------------------------- Kadian 检测相关导入 --------------------------
|
||||
from algorithm.common.npu_yolo_onnx_person_car_phone import YOLOv8_ONNX # 主检测模型(人/车/后备箱/手机)
|
||||
from algorithm.common.npu_yolo_pose_onnx import YOLOv8_Pose_ONNX # Pose 专用模型
|
||||
# from algorithm.common.npu_yolo_pose_onnx import YOLOv8_Pose_ONNX # Pose 专用模型
|
||||
from yolox.tracker.byte_tracker import BYTETracker
|
||||
|
||||
from utils.logger import get_logger
|
||||
from common.constants import MODEL_ROOT_PATH
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DETECT_MODEL_PATH = 'D:\Python_Save\PoliceProject\Yolo_Weight\Kadian\Kadian_sanshijiazi.onnx'
|
||||
DETECT_MODEL_PATH = 'YOLO_Weight/Kadian.onnx'
|
||||
|
||||
# 默认相对ROI(与原文件一致)
|
||||
#ROI_RELATIVE = np.array([
|
||||
@@ -52,11 +50,12 @@ PERSON_CAR_INPUT_SIZE = 640
|
||||
#POSE_INPUT_SIZE = 640
|
||||
|
||||
|
||||
RTSP_TARGET_FPS = 30.0
|
||||
RTSP_TARGET_FPS = 10.0
|
||||
|
||||
# ========================= Kadian TrafficMonitor(精简版,专为服务设计) =========================
|
||||
class KadianDetector:
|
||||
class KadianDetector(BaseDetector):
|
||||
def __init__(self, params=None):
|
||||
super().__init__()
|
||||
# 摄像头额外参数
|
||||
self.params = params if params is not None else {}
|
||||
|
||||
@@ -99,6 +98,8 @@ class KadianDetector:
|
||||
self.TIME_THRESHOLD_CAR_MIN_DURATION = 3.0
|
||||
# Car 丢帧/ID维持缓冲
|
||||
self.TIME_TOLERANCE_CAR = 2.0
|
||||
# 车辆进入ROI确认阈值(需要连续在ROI内出现这么多帧才正式注册)
|
||||
self.TIME_THRESHOLD_CAR_ENTER = 1.0
|
||||
|
||||
# police丢失阈值
|
||||
self.TIME_TOLERANCE_POLICE = 3.0
|
||||
@@ -110,6 +111,7 @@ class KadianDetector:
|
||||
self.frame_thresh_trunk_valid = int(self.TIME_THRESHOLD_TRUNK_OPEN * self.fps)
|
||||
self.frame_thresh_car_min_duration = int(self.TIME_THRESHOLD_CAR_MIN_DURATION * self.fps)
|
||||
self.frame_buffer_limit_car = int(self.TIME_TOLERANCE_CAR * self.fps)
|
||||
self.frame_thresh_car_enter = int(self.TIME_THRESHOLD_CAR_ENTER * self.fps)
|
||||
self.frame_buffer_limit_police = int(self.TIME_TOLERANCE_POLICE * self.fps)
|
||||
self.frame_thresh_nobody = int(self.TIME_THRESHOLD_NOBODY * self.fps)
|
||||
self.frame_thresh_only_one = int(self.TIME_THRESHOLD_ONLY_ONE * self.fps)
|
||||
@@ -127,8 +129,10 @@ class KadianDetector:
|
||||
|
||||
|
||||
|
||||
# 车辆注册表 (字典)
|
||||
# 车辆注册表 (字典) - 已确认进入ROI的车辆
|
||||
self.roi_car_registry = {}
|
||||
# 车辆进入确认等待区 - 刚进入ROI但还未确认的车辆
|
||||
self.car_enter_pending = {} # {tid: {'first_seen': frame_idx, 'frames_count': count}}
|
||||
# 违规车辆记录
|
||||
self.unchecked_trunk_alerts = {} # 后备箱未检
|
||||
self.fast_pass_alerts = {} # 通过过快
|
||||
@@ -142,11 +146,11 @@ class KadianDetector:
|
||||
self.nobody_frames = 0 # 累计无人在场帧数
|
||||
self.only_one_frames = 0 # 累计单人在场帧数
|
||||
|
||||
self.max_car_frames = int((3.0 + self.TIME_TOLERANCE_CAR) * self.fps) # 50帧
|
||||
self.frame_buffer_ignore_untrunk = deque(maxlen=self.max_car_frames)
|
||||
buffer_seconds = 15.0 + self.TIME_TOLERANCE_CAR
|
||||
self.init_frame_buffer(buffer_seconds, self.fps)
|
||||
|
||||
self.untrunk_rollback_time = 3.0 # 未检查后备箱需要回溯的时间
|
||||
self.ignored_rollback_time = 1.0 # 漏检需要回溯的时间
|
||||
self.untrunk_rollback_time = 12.0 # 未检查后备箱需要回溯的时间
|
||||
self.ignored_rollback_time = 12.0 # 漏检需要回溯的时间
|
||||
|
||||
|
||||
def _get_roi_points(self, frame_width: int, frame_height: int):
|
||||
@@ -214,21 +218,6 @@ class KadianDetector:
|
||||
x1, y1, x2, y2 = box
|
||||
return x1 < px < x2 and y1 < py < y2
|
||||
|
||||
def find_target_frame(self, target_time_sec):
|
||||
|
||||
target_frame = None
|
||||
min_time_diff = float('inf')
|
||||
for buffered in self.frame_buffer_ignore_untrunk:
|
||||
time_diff = abs(buffered['frame_idx'] - target_time_sec)
|
||||
if time_diff < min_time_diff:
|
||||
min_time_diff = time_diff
|
||||
target_frame = buffered['frame']
|
||||
# 如果没找到,返回最早的帧
|
||||
if target_frame is None and len(self.frame_buffer_ignore_untrunk) > 0:
|
||||
target_frame = self.frame_buffer_ignore_untrunk[0]['frame']
|
||||
|
||||
return target_frame
|
||||
|
||||
def process_frame(self, frame, camera_id: int, timestamp: float) -> Dict[str, Any]:
|
||||
h, w = frame.shape[:2]
|
||||
self.width, self.height = w, h
|
||||
@@ -240,7 +229,7 @@ class KadianDetector:
|
||||
|
||||
current_time_sec = timestamp
|
||||
|
||||
print('current_time_sec:', current_time_sec)
|
||||
# print('current_time_sec:', current_time_sec)
|
||||
|
||||
# ========= 主检测(删除pose检测)=========
|
||||
detections = self.detector(frame)
|
||||
@@ -322,21 +311,45 @@ class KadianDetector:
|
||||
# 仅处理ROI内的车辆
|
||||
if self.check_point_in_roi(roi_points_int32, (cx, cy)):
|
||||
current_cars.append({'id': tid, 'box': [x1, y1, x2, y2]})
|
||||
# 车辆注册表初始化
|
||||
if tid not in self.roi_car_registry:
|
||||
self.roi_car_registry[tid] = {
|
||||
|
||||
# 检查是否在等待确认区
|
||||
if tid in self.car_enter_pending:
|
||||
# 已在等待区,更新计数
|
||||
self.car_enter_pending[tid]['frames_count'] += 1
|
||||
self.car_enter_pending[tid]['last_seen'] = self.current_frame_idx
|
||||
self.car_enter_pending[tid]['last_box'] = [x1, y1, x2, y2]
|
||||
|
||||
# 检查是否达到确认阈值
|
||||
if self.car_enter_pending[tid]['frames_count'] >= self.frame_thresh_car_enter:
|
||||
# 从等待区移到正式注册
|
||||
self.roi_car_registry[tid] = {
|
||||
'first_seen': self.car_enter_pending[tid]['first_seen'],
|
||||
'last_seen': self.current_frame_idx,
|
||||
'last_seen_time': current_time_sec,
|
||||
'trunk_frames': 0,
|
||||
'is_checked': False,
|
||||
'last_box': [x1, y1, x2, y2],
|
||||
}
|
||||
del self.car_enter_pending[tid]
|
||||
label += " IN"
|
||||
else:
|
||||
label += " PENDING"
|
||||
elif tid not in self.roi_car_registry:
|
||||
# 新发现的车辆,加入等待确认区
|
||||
self.car_enter_pending[tid] = {
|
||||
'first_seen': self.current_frame_idx,
|
||||
'last_seen': self.current_frame_idx,
|
||||
# 'first_seen': current_time_sec,
|
||||
# 'last_seen': current_time_sec,
|
||||
'trunk_frames': 0,
|
||||
'is_checked': False,
|
||||
#'frame_buffer': deque(maxlen=self.max_car_frames), # 新增
|
||||
'last_seen_time': current_time_sec,
|
||||
'frames_count': 1,
|
||||
'last_box': [x1, y1, x2, y2],
|
||||
}
|
||||
label += " PENDING"
|
||||
else:
|
||||
# 已在正式注册表中
|
||||
self.roi_car_registry[tid]['last_seen'] = self.current_frame_idx
|
||||
#self.roi_car_registry[tid]['last_seen'] = current_time_sec
|
||||
label += " IN"
|
||||
self.roi_car_registry[tid]['last_seen_time'] = current_time_sec
|
||||
self.roi_car_registry[tid]['last_box'] = [x1, y1, x2, y2]
|
||||
label += " IN"
|
||||
elif role == "opentrunk":
|
||||
color = (255, 165, 0) # 橙色
|
||||
label = "OpenTrunk"
|
||||
@@ -372,16 +385,8 @@ class KadianDetector:
|
||||
cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2)
|
||||
cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
|
||||
|
||||
# 每帧保存到缓存
|
||||
# self.roi_car_registry[tid]['frame_buffer'].append({
|
||||
# 'frame_idx': current_time_sec,
|
||||
# 'frame': frame.copy(),
|
||||
# })
|
||||
self.frame_buffer_ignore_untrunk.append({
|
||||
#'frame_idx': current_time_sec,
|
||||
'frame_idx': self.current_frame_idx,
|
||||
'frame': frame.copy(),
|
||||
})
|
||||
# 每帧保存到缓存(移到循环外,确保每帧只写入一次)
|
||||
self.append_frame(frame, current_time_sec)
|
||||
|
||||
# ==========================================
|
||||
# 关联分析: 哪个后备箱属于哪辆车?
|
||||
@@ -401,6 +406,17 @@ class KadianDetector:
|
||||
if self.roi_car_registry[c_id]['trunk_frames'] >= self.frame_thresh_trunk_valid:
|
||||
self.roi_car_registry[c_id]['is_checked'] = True
|
||||
|
||||
# ==========================================
|
||||
# 清理等待确认区的车辆(如果离开ROI超过缓冲帧数)
|
||||
# ==========================================
|
||||
pending_to_remove = []
|
||||
for car_id, info in self.car_enter_pending.items():
|
||||
if (self.current_frame_idx - info['last_seen']) > self.frame_buffer_limit_car:
|
||||
pending_to_remove.append(car_id)
|
||||
|
||||
for car_id in pending_to_remove:
|
||||
del self.car_enter_pending[car_id]
|
||||
|
||||
# ==========================================
|
||||
# 维护车辆注册表 & 生成离场报警
|
||||
# ==========================================
|
||||
@@ -431,7 +447,7 @@ class KadianDetector:
|
||||
self.fast_pass_alerts[car_id] = self.current_frame_idx + int(self.ignore_show_seconds * self.fps)
|
||||
#self.fast_pass_alerts[car_id] = current_time_sec + int(self.ignore_show_seconds * self.fps)
|
||||
|
||||
target_time_sec = car_info['last_seen'] - self.ignored_rollback_time
|
||||
target_time_sec = car_info['last_seen_time'] - self.ignored_rollback_time
|
||||
ignored_trunk_frame = self.find_target_frame(target_time_sec)
|
||||
|
||||
|
||||
@@ -442,7 +458,7 @@ class KadianDetector:
|
||||
self.unchecked_trunk_alerts[car_id] = self.current_frame_idx + int(self.openTrunk_show_seconds * self.fps)
|
||||
#self.unchecked_trunk_alerts[car_id] = current_time_sec + int(self.openTrunk_show_seconds * self.fps)
|
||||
|
||||
target_time_sec = car_info['last_seen'] - self.untrunk_rollback_time
|
||||
target_time_sec = car_info['last_seen_time'] - self.untrunk_rollback_time
|
||||
unchecked_trunk_frame = self.find_target_frame(target_time_sec)
|
||||
|
||||
|
||||
|
||||
487
biz/prison/indoor_biz.py
Normal file
487
biz/prison/indoor_biz.py
Normal file
@@ -0,0 +1,487 @@
|
||||
import cv2
|
||||
import numpy as np
|
||||
import time
|
||||
# import requests
|
||||
from collections import deque
|
||||
|
||||
from biz.base_detector import BaseDetector
|
||||
from biz.base_frame_processor import BaseFrameProcessorWorker
|
||||
from algorithm.common.npu_yolo_onnx_person_car_phone import YOLOv8_ONNX
|
||||
from yolox.tracker.byte_tracker import BYTETracker
|
||||
from common.constants import MODEL_ROOT_PATH
|
||||
|
||||
# ========================= 走廊场景专属配置 =========================
|
||||
DETECT_MODEL_PATH = 'YOLO_Weight/kanshousuo.onnx' # 犯人检测onnx模型路径
|
||||
INPUT_SIZE = 640 # 模型输入尺寸
|
||||
RTSP_FPS = 10 # 视频流目标FPS
|
||||
ALERT_PUSH_INTERVAL = 5 # 相同报警5秒内仅推送1次
|
||||
# ALERT_PUSH_URL = "http://123.57.151.210:10000/picenter/websocket/test/process"
|
||||
# 消失判定:中心点在ROI内消失后,持续无检测的帧数(1.0秒,可微调)
|
||||
ROI_LOST_FRAMES_THRESH = int(0.5 * RTSP_FPS) # todo: 从frame改为时间
|
||||
|
||||
# ========================= 默认ROI区域配置(当config.yaml未配置时使用) =========================
|
||||
DEFAULT_DOOR_ROIS = {
|
||||
"left": {
|
||||
"points": [[0.195, 0.245], [0.42, 0], [0.421, 0.185], [0.248, 0.8]],
|
||||
"color": [255, 0, 0]
|
||||
},
|
||||
"right": {
|
||||
"points": [[0.575, 0.], [0.81, 0.22], [0.78, 0.8], [0.575, 0.185]],
|
||||
"color": [255, 0, 0]
|
||||
}
|
||||
}
|
||||
|
||||
# ==================================================================================
|
||||
class PrisonerDoorDetector(BaseDetector):
|
||||
def __init__(self, params=None):
|
||||
super().__init__()
|
||||
self.params = params or {}
|
||||
|
||||
# 0. 从params解析ROI配置,无则使用默认值
|
||||
door_rois_config = self.params.get('door_rois', DEFAULT_DOOR_ROIS)
|
||||
self.roi_config = {}
|
||||
self.roi_colors = {}
|
||||
for door_name, door_cfg in door_rois_config.items():
|
||||
self.roi_config[door_name] = door_cfg['points']
|
||||
self.roi_colors[door_name] = tuple(door_cfg['color'])
|
||||
|
||||
model_path = self.params.get('model_path')
|
||||
if model_path:
|
||||
full_model_path = f"{MODEL_ROOT_PATH}/{model_path}"
|
||||
else:
|
||||
full_model_path = DETECT_MODEL_PATH
|
||||
|
||||
self.detector = YOLOv8_ONNX(
|
||||
full_model_path,
|
||||
conf_threshold=0.7, # 置信度阈值,可根据模型精度调整
|
||||
iou_threshold=0.4, # IOU阈值
|
||||
input_size=INPUT_SIZE
|
||||
)
|
||||
|
||||
# 2. 初始化ByteTracker跟踪器(适配走廊单/多犯人跟踪)
|
||||
class TrackerArgs:
|
||||
track_thresh = 0.65
|
||||
track_buffer = 60 # 减小缓冲避免跟踪漂移
|
||||
match_thresh = 0.5
|
||||
mot20 = False
|
||||
self.tracker = BYTETracker(TrackerArgs(), frame_rate=RTSP_FPS)
|
||||
|
||||
# 3. 状态变量初始化
|
||||
# self.last_alert_time = 0.0 # 最后报警时间(防重复推送)
|
||||
# 犯人跟踪信息:{track_id: {'is_cx_in_roi': 中心点是否在ROI, 'lost_frames': 消失帧数, 'lost_roi': 消失的ROI名称, 'last_cxcy': 最后中心点坐标}}
|
||||
self.prisoner_track_info = {}
|
||||
self.frame_width = 0 # 帧宽度(动态获取)
|
||||
self.frame_height = 0 # 帧高度(动态获取)
|
||||
self.roi_abs_cache = {} # ROI绝对坐标缓存:{roi_name: np.int32数组}
|
||||
self.entry_frame_cache = {}
|
||||
|
||||
# 基于位置的跟踪状态管理
|
||||
self.active_targets = {} # {target_id: {...}}
|
||||
self.next_target_id = 0
|
||||
self.position_history = {} # {target_id: deque of positions}
|
||||
|
||||
# 距离阈值(用于匹配检测框和已有目标)
|
||||
self.distance_threshold = 100 # 像素距离
|
||||
|
||||
buffer_seconds = 3 # 最大回溯3秒
|
||||
self.init_frame_buffer(buffer_seconds, RTSP_FPS)
|
||||
self.detect_rollback_time = 0.9 # 警报帧回溯时间(秒)
|
||||
|
||||
|
||||
def compute_center_distance(self, box1, box2):
|
||||
"""计算两个框中心点的欧氏距离"""
|
||||
cx1 = (box1[0] + box1[2]) / 2
|
||||
cy1 = (box1[1] + box1[3]) / 2
|
||||
cx2 = (box2[0] + box2[2]) / 2
|
||||
cy2 = (box2[1] + box2[3]) / 2
|
||||
return np.sqrt((cx1 - cx2) ** 2 + (cy1 - cy2) ** 2)
|
||||
|
||||
def compute_iou(self, boxA, boxB):
|
||||
"""IOU计算:匹配跟踪框与犯人检测框,过滤非犯人目标"""
|
||||
xA = max(boxA[0], boxB[0])
|
||||
yA = max(boxA[1], boxB[1])
|
||||
xB = min(boxA[2], boxB[2])
|
||||
yB = min(boxA[3], boxB[3])
|
||||
interW = max(0, xB - xA)
|
||||
interH = max(0, yB - yA)
|
||||
interArea = interW * interH
|
||||
boxAArea = (boxA[2] - boxA[0]) * (boxA[3] - boxA[1])
|
||||
boxBArea = (boxB[2] - boxB[0]) * (boxB[3] - boxB[1])
|
||||
unionArea = boxAArea + boxBArea - interArea
|
||||
return interArea / unionArea if unionArea > 0 else 0.0
|
||||
|
||||
def _get_roi_abs(self, roi_name):
|
||||
"""相对坐标转绝对像素坐标(适配当前帧分辨率,OpenCV要求int32)"""
|
||||
if roi_name not in self.roi_config:
|
||||
return None
|
||||
roi_rel = np.array(self.roi_config[roi_name], dtype=np.float64)
|
||||
roi_abs = roi_rel * np.array([self.frame_width, self.frame_height])
|
||||
return roi_abs.astype(np.int32)
|
||||
|
||||
def is_cxcy_in_roi(self, cx, cy):
|
||||
"""判断犯人框**中心点(cx,cy)** 是否在任意ROI内,返回:(是否在ROI, 所在ROI名称)"""
|
||||
for roi_name, roi_abs in self.roi_abs_cache.items():
|
||||
# OpenCV点在多边形内判定:>=0 表示在内部/边上
|
||||
if cv2.pointPolygonTest(roi_abs, (cx, cy), False) >= 0:
|
||||
return (True, roi_name)
|
||||
return (False, "outside")
|
||||
|
||||
def match_detection_to_target(self, detection_box, detection_conf):
|
||||
"""
|
||||
【核心】将检测框匹配到已有目标
|
||||
返回: (matched_target_id, match_score)
|
||||
"""
|
||||
best_match_id = None
|
||||
best_match_score = 0
|
||||
|
||||
det_center = np.array([(detection_box[0] + detection_box[2]) / 2,
|
||||
(detection_box[1] + detection_box[3]) / 2])
|
||||
|
||||
for target_id, target_info in self.active_targets.items():
|
||||
# 计算与目标最后已知位置的距离
|
||||
last_box = target_info['last_box']
|
||||
last_center = np.array([(last_box[0] + last_box[2]) / 2,
|
||||
(last_box[1] + last_box[3]) / 2])
|
||||
|
||||
distance = np.linalg.norm(det_center - last_center)
|
||||
|
||||
# 计算IOU(如果目标最近刚更新)
|
||||
time_since_update = time.time() - target_info['last_update_time']
|
||||
iou_score = self.compute_iou(detection_box, last_box) if time_since_update < 1.0 else 0
|
||||
|
||||
# 综合评分:距离近 + IOU高
|
||||
distance_score = max(0, 1 - distance / self.distance_threshold)
|
||||
match_score = 0.3 * distance_score + 0.7 * iou_score
|
||||
|
||||
# 考虑位置预测(如果目标在移动中)
|
||||
if target_id in self.position_history and len(self.position_history[target_id]) >= 2:
|
||||
# 简单的线性预测
|
||||
hist = list(self.position_history[target_id])
|
||||
if len(hist) >= 2:
|
||||
velocity = hist[-1] - hist[-2]
|
||||
predicted_pos = last_center + velocity
|
||||
pred_distance = np.linalg.norm(det_center - predicted_pos)
|
||||
pred_score = max(0, 1 - pred_distance / self.distance_threshold)
|
||||
match_score = 0.7 * match_score + 0.3 * pred_score
|
||||
|
||||
if match_score > best_match_score and match_score > 0.3: # 阈值可调
|
||||
best_match_score = match_score
|
||||
best_match_id = target_id
|
||||
|
||||
return best_match_id, best_match_score
|
||||
|
||||
# def push_alert(self, camera_id, target_id, lost_roi, last_cxcy, timestamp, entry_frame):
|
||||
# """报警推送"""
|
||||
# current_time = time.time()
|
||||
# if current_time - self.last_alert_time < ALERT_PUSH_INTERVAL:
|
||||
# return False
|
||||
#
|
||||
# _, frame_encoded = cv2.imencode('.jpg', entry_frame)
|
||||
# frame_base64 = frame_encoded.tobytes()
|
||||
#
|
||||
# alert_info = {
|
||||
# "camera_id": camera_id,
|
||||
# "alert_type": "prisoner_cx_disappear_in_roi",
|
||||
# "prisoner_track_id": target_id,
|
||||
# "disappear_roi": lost_roi,
|
||||
# "last_cx": round(last_cxcy[0], 2),
|
||||
# "last_cy": round(last_cxcy[1], 2),
|
||||
# "timestamp": timestamp,
|
||||
# "entry_frame_base64": frame_base64,
|
||||
# "details": f"犯人框中心点在{lost_roi}区域内消失"
|
||||
# }
|
||||
#
|
||||
# try:
|
||||
# requests.post(ALERT_PUSH_URL, json=alert_info, timeout=3)
|
||||
# print(f"[报警成功] target_id={target_id}, roi={lost_roi}")
|
||||
# self.last_alert_time = current_time
|
||||
# return True
|
||||
# except Exception as e:
|
||||
# print(f"[报警失败] {str(e)}")
|
||||
# return False
|
||||
|
||||
|
||||
def process_frame(self, frame, camera_id: int, timestamp: float) -> dict:
|
||||
"""
|
||||
核心帧处理:
|
||||
1. 绘制5个ROI区域 2. 检测+跟踪犯人 3. 判定中心点是否在ROI内
|
||||
4. 中心点在ROI内消失则累计帧数,达到阈值触发报警
|
||||
"""
|
||||
self.frame_height, self.frame_width = frame.shape[:2]
|
||||
current_frame_alerts = [] # 本帧报警信息
|
||||
frame_copy = frame.copy()
|
||||
current_time = time.time()
|
||||
|
||||
# ========================= 1. 初始化ROI绝对坐标并绘制ROI =========================
|
||||
self.roi_abs_cache.clear()
|
||||
for roi_name in self.roi_config:
|
||||
roi_abs = self._get_roi_abs(roi_name)
|
||||
if roi_abs is None:
|
||||
continue
|
||||
self.roi_abs_cache[roi_name] = roi_abs
|
||||
# 绘制ROI多边形(闭合)+ ROI名称标签
|
||||
roi_draw = roi_abs.reshape((-1, 1, 2)) # OpenCV绘制要求形状 (n,1,2)
|
||||
color = self.roi_colors.get(roi_name, (255, 255, 255))
|
||||
cv2.polylines(frame, [roi_draw], isClosed=True, color=color, thickness=2)
|
||||
cv2.putText(frame, roi_name, (roi_abs[0][0], roi_abs[0][1] - 5),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2)
|
||||
|
||||
# ========================= 2. 模型推理:仅提取犯人检测框 =========================
|
||||
detect_results = self.detector(frame)
|
||||
prisoner_detections = []
|
||||
|
||||
if detect_results:
|
||||
for det in detect_results:
|
||||
x1, y1, x2, y2, conf, cls_id = det
|
||||
# 确保坐标在图像范围内
|
||||
x1 = max(0, min(x1, self.frame_width - 1))
|
||||
y1 = max(0, min(y1, self.frame_height - 1))
|
||||
x2 = max(0, min(x2, self.frame_width - 1))
|
||||
y2 = max(0, min(y2, self.frame_height - 1))
|
||||
|
||||
if cls_id == 1 and x2 > x1 and y2 > y1 and (x2 - x1) * (y2 - y1) > 100: # 过滤太小的框
|
||||
prisoner_detections.append([x1, y1, x2, y2, conf, cls_id])
|
||||
|
||||
# ========================= 3. ByteTracker跟踪 =========================
|
||||
prisoner_det_boxes = np.array(
|
||||
[[x1, y1, x2, y2, conf] for x1, y1, x2, y2, conf, cls_id in prisoner_detections],
|
||||
dtype=np.float32) if prisoner_detections else np.empty((0, 5))
|
||||
|
||||
if len(prisoner_det_boxes) > 0:
|
||||
track_results = self.tracker.update(
|
||||
prisoner_det_boxes,
|
||||
[self.frame_height, self.frame_width],
|
||||
[self.frame_height, self.frame_width]
|
||||
)
|
||||
else:
|
||||
track_results = []
|
||||
|
||||
|
||||
# ========================= 4. 【核心改进】融合跟踪和检测 =========================
|
||||
# 4.1 先处理跟踪结果
|
||||
tracked_detections = {} # {track_id: detection_box}
|
||||
used_det_indices = set()
|
||||
|
||||
for track in track_results:
|
||||
track_id = track.track_id
|
||||
t_box = [float(x) for x in track.tlbr]
|
||||
|
||||
# 寻找匹配的检测框
|
||||
best_iou = 0.0 # 最低阈值
|
||||
best_det_idx = -1
|
||||
|
||||
for det_idx, det in enumerate(prisoner_detections):
|
||||
if det_idx in used_det_indices:
|
||||
continue
|
||||
iou = self.compute_iou(t_box, det[:4])
|
||||
if iou > best_iou:
|
||||
best_iou = iou
|
||||
best_det_idx = det_idx
|
||||
|
||||
if best_det_idx != -1:
|
||||
# 跟踪框有对应的检测框,使用检测框(更准确)
|
||||
tracked_detections[f"track_{track_id}"] = {
|
||||
'box': prisoner_detections[best_det_idx][:4],
|
||||
'conf': prisoner_detections[best_det_idx][4],
|
||||
'source': 'tracked'
|
||||
}
|
||||
used_det_indices.add(best_det_idx)
|
||||
else:
|
||||
# 跟踪框没有对应的检测框,但仍保留跟踪框
|
||||
tracked_detections[f"track_{track_id}"] = {
|
||||
'box': t_box,
|
||||
'conf': 0.5, # 给个中等置信度
|
||||
'source': 'track_only'
|
||||
}
|
||||
|
||||
# 4.2 处理未被跟踪的检测框
|
||||
for det_idx, det in enumerate(prisoner_detections):
|
||||
if det_idx not in used_det_indices:
|
||||
tracked_detections[f"det_{det_idx}"] = {
|
||||
'box': det[:4],
|
||||
'conf': det[4],
|
||||
'source': 'det_only'
|
||||
}
|
||||
|
||||
# ========================= 5. 匹配到已有目标 =========================
|
||||
current_target_ids = set()
|
||||
matched_det_keys = set()
|
||||
|
||||
for det_key, det_info in tracked_detections.items():
|
||||
det_box = det_info['box']
|
||||
det_conf = det_info['conf']
|
||||
|
||||
# 计算中心点
|
||||
cx = (det_box[0] + det_box[2]) / 2
|
||||
cy = (det_box[1] + det_box[3]) / 2
|
||||
|
||||
# 匹配到已有目标
|
||||
matched_target_id, match_score = self.match_detection_to_target(det_box, det_conf)
|
||||
|
||||
if matched_target_id is not None and match_score > 0.3:
|
||||
# 更新已有目标
|
||||
target_id = matched_target_id
|
||||
target_info = self.active_targets[target_id]
|
||||
|
||||
# 更新位置历史
|
||||
if target_id not in self.position_history:
|
||||
self.position_history[target_id] = deque(maxlen=10)
|
||||
self.position_history[target_id].append(np.array([cx, cy]))
|
||||
|
||||
# 判断是否在ROI内
|
||||
is_cx_in_roi, current_roi = self.is_cxcy_in_roi(cx, cy)
|
||||
|
||||
# 首次进入ROI缓存帧
|
||||
if not target_info.get('in_roi', False) and is_cx_in_roi:
|
||||
self.entry_frame_cache[target_id] = frame_copy.copy()
|
||||
target_info['lost_frames'] = 0
|
||||
|
||||
# 更新目标信息
|
||||
target_info.update({
|
||||
'last_box': det_box,
|
||||
'last_cxcy': (cx, cy),
|
||||
'last_conf': det_conf,
|
||||
'last_update_time': current_time,
|
||||
'in_roi': is_cx_in_roi,
|
||||
'current_roi': current_roi if is_cx_in_roi else target_info.get('current_roi', 'outside'),
|
||||
'detection_source': det_info['source']
|
||||
})
|
||||
|
||||
current_target_ids.add(target_id)
|
||||
matched_det_keys.add(det_key)
|
||||
|
||||
else:
|
||||
# 创建新目标
|
||||
target_id = self.next_target_id
|
||||
self.next_target_id += 1
|
||||
|
||||
is_cx_in_roi, current_roi = self.is_cxcy_in_roi(cx, cy)
|
||||
|
||||
self.active_targets[target_id] = {
|
||||
'first_seen': current_time,
|
||||
'last_box': det_box,
|
||||
'last_cxcy': (cx, cy),
|
||||
'last_conf': det_conf,
|
||||
'last_update_time': current_time,
|
||||
'in_roi': is_cx_in_roi,
|
||||
'current_roi': current_roi if is_cx_in_roi else 'outside',
|
||||
'lost_frames': 0,
|
||||
'detection_source': det_info['source']
|
||||
}
|
||||
|
||||
self.position_history[target_id] = deque(maxlen=10)
|
||||
self.position_history[target_id].append(np.array([cx, cy]))
|
||||
|
||||
if is_cx_in_roi:
|
||||
self.entry_frame_cache[target_id] = frame_copy.copy()
|
||||
|
||||
current_target_ids.add(target_id)
|
||||
matched_det_keys.add(det_key)
|
||||
|
||||
# ========================= 6. 处理消失和报警 =========================
|
||||
for target_id in list(self.active_targets.keys()):
|
||||
target_info = self.active_targets[target_id]
|
||||
|
||||
if target_id not in current_target_ids:
|
||||
# 目标在当前帧未出现
|
||||
if target_info['in_roi']:
|
||||
# 在ROI内消失
|
||||
target_info['lost_frames'] += 1
|
||||
|
||||
if target_info['lost_frames'] >= ROI_LOST_FRAMES_THRESH:
|
||||
# 触发报警
|
||||
# entry_frame = self.entry_frame_cache.get(target_id, frame_copy)
|
||||
# self.push_alert(
|
||||
# camera_id=camera_id,
|
||||
# target_id=target_id,
|
||||
# lost_roi=target_info['current_roi'],
|
||||
# last_cxcy=target_info['last_cxcy'],
|
||||
# timestamp=timestamp,
|
||||
# entry_frame=entry_frame
|
||||
# )
|
||||
alert_frame = self.find_target_frame(timestamp - self.detect_rollback_time)
|
||||
|
||||
current_frame_alerts.append({
|
||||
"time": timestamp,
|
||||
"camera_id": camera_id,
|
||||
"action": "Indoor Violation",
|
||||
'image': alert_frame,
|
||||
"prisoner_track_id": target_id,
|
||||
"disappear_roi": target_info['current_roi'],
|
||||
"last_cx": round(target_info['last_cxcy'][0], 2),
|
||||
"last_cy": round(target_info['last_cxcy'][1], 2)
|
||||
})
|
||||
|
||||
# 清理
|
||||
del self.active_targets[target_id]
|
||||
if target_id in self.position_history:
|
||||
del self.position_history[target_id]
|
||||
if target_id in self.entry_frame_cache:
|
||||
del self.entry_frame_cache[target_id]
|
||||
else:
|
||||
# 不在ROI内消失,直接清理
|
||||
del self.active_targets[target_id]
|
||||
if target_id in self.position_history:
|
||||
del self.position_history[target_id]
|
||||
if target_id in self.entry_frame_cache:
|
||||
del self.entry_frame_cache[target_id]
|
||||
else:
|
||||
# 目标仍在,但可能已离开ROI
|
||||
if not target_info['in_roi']:
|
||||
target_info['lost_frames'] = 0
|
||||
|
||||
# ========================= 7. 清理超时目标 =========================
|
||||
timeout_threshold = 5.0 # 5秒无更新就清理
|
||||
for target_id in list(self.active_targets.keys()):
|
||||
if current_time - self.active_targets[target_id]['last_update_time'] > timeout_threshold:
|
||||
del self.active_targets[target_id]
|
||||
if target_id in self.position_history:
|
||||
del self.position_history[target_id]
|
||||
if target_id in self.entry_frame_cache:
|
||||
del self.entry_frame_cache[target_id]
|
||||
|
||||
# ========================= 8. 绘制可视化 =========================
|
||||
for target_id, target_info in self.active_targets.items():
|
||||
box = target_info['last_box']
|
||||
cx, cy = target_info['last_cxcy']
|
||||
in_roi = target_info['in_roi']
|
||||
current_roi = target_info['current_roi']
|
||||
source = target_info.get('detection_source', 'unknown')
|
||||
|
||||
# 根据状态选择颜色
|
||||
if in_roi:
|
||||
color = (0, 0, 255) # 红色:在ROI内
|
||||
else:
|
||||
color = (0, 255, 0) # 绿色:不在ROI内
|
||||
|
||||
# 根据来源选择线型
|
||||
thickness = 3 if source == 'tracked' else 2
|
||||
|
||||
cv2.rectangle(frame, (int(box[0]), int(box[1])),
|
||||
(int(box[2]), int(box[3])), color, thickness)
|
||||
cv2.circle(frame, (int(cx), int(cy)), 5, color, -1)
|
||||
|
||||
status = f"T{target_id}_{current_roi[:2]}"
|
||||
if source == 'det_only':
|
||||
status += "_DET"
|
||||
cv2.putText(frame, status, (int(box[0]), int(box[1]) - 10),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2)
|
||||
|
||||
# ========================= 9. 统计信息 =========================
|
||||
cv2.putText(frame, f"Camera: {camera_id}", (20, self.frame_height - 20),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
|
||||
cv2.putText(frame, f"Active Targets: {len(self.active_targets)}",
|
||||
(20, self.frame_height - 50),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 0), 2)
|
||||
|
||||
self.append_frame(frame, timestamp)
|
||||
|
||||
return {"image": frame, "alerts": current_frame_alerts}
|
||||
|
||||
|
||||
# ========================= 帧处理线程 =========================
|
||||
class FrameProcessorWorker(BaseFrameProcessorWorker):
|
||||
"""看守所走廊犯人检测 - 增强跟踪版"""
|
||||
DETECTOR_FACTORY = lambda params: PrisonerDoorDetector(params)
|
||||
POST_TYPE = 3
|
||||
TARGET_FPS = RTSP_FPS
|
||||
@@ -15,114 +15,70 @@ from typing import Dict, Any, Tuple, List
|
||||
from biz.base_frame_processor import BaseFrameProcessorWorker
|
||||
|
||||
# -------------------------- Kadian 检测相关导入 --------------------------
|
||||
from algorithm.common.npu_yolo_onnx_person_car_phone import YOLOv8_ONNX # 主检测模型(人/车/后备箱/手机)
|
||||
from algorithm.common.npu_yolo_onnx_person_car_phone import YOLOv8_ONNX # 主检测模型(人/车/后备箱/手机)
|
||||
|
||||
from yolox.tracker.byte_tracker import BYTETracker
|
||||
|
||||
from rtsp_service_ws_prison import TrackerArgs
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ========================= 配置区 =========================
|
||||
Person_Phone_Model = r'YOLO_Weight/person_phone_model.onnx' # 人和手机的检测模型
|
||||
Smoke_Model = r'YOLO_Weight/smoke_model.onnx' # 抽烟检测模型
|
||||
|
||||
person_phone_input_size = 1280 # 模型输入尺寸,与训练时的模型一致
|
||||
smoke_input_size = 1280 # 模型输入尺寸,与训练时的模型一致
|
||||
|
||||
# RTSP 服务配置
|
||||
RTSP_TARGET_FPS = 5.0
|
||||
|
||||
RTSP_TARGET_FPS = 10.0
|
||||
|
||||
# 新增:告警推送频率限制(秒)
|
||||
ALERT_PUSH_INTERVAL = 5.0 # 相同action 5秒内仅推送一次
|
||||
|
||||
Model_Path = 'YOLO_Weight/zhihuishi.onnx'
|
||||
|
||||
Model_size = 640 # yolo模型尺寸
|
||||
|
||||
Label_Map = {
|
||||
-1: 'Unknown',
|
||||
0: 'Police'
|
||||
}
|
||||
|
||||
Color_Map = {
|
||||
-1: (255, 255, 255), # 白
|
||||
0: (0, 255, 0) # 绿
|
||||
}
|
||||
|
||||
NOBODY_THRESHOLD = 5.0 * RTSP_TARGET_FPS # 当屏幕中的人消失了开始计数,如果累计够时长判定Nobody,如果中间又检测到了人,则立即清空计数
|
||||
|
||||
|
||||
class ZhihuishiDetector:
|
||||
def __init__(self, params=None):
|
||||
# 模型加载
|
||||
|
||||
# 人和手机检测模型
|
||||
print(f"加载人和手机检测模型: {Person_Phone_Model}")
|
||||
self.person_phone_detector = YOLOv8_ONNX(Person_Phone_Model, conf_threshold=0.6, iou_threshold=0.45,
|
||||
input_size=person_phone_input_size)
|
||||
|
||||
# 抽烟检测模型
|
||||
print(f"加载抽烟检测模型: {Smoke_Model}")
|
||||
self.smoke_detector = YOLOv8_ONNX(Smoke_Model, conf_threshold=0.4, iou_threshold=0.65,
|
||||
input_size=smoke_input_size)
|
||||
# person检测模型
|
||||
self.detector = YOLOv8_ONNX(Model_Path, conf_threshold=0.6, iou_threshold=0.6, input_size=Model_size)
|
||||
|
||||
# ByteTracker
|
||||
class TrackerArgs:
|
||||
track_thresh = 0.25
|
||||
track_buffer = 30
|
||||
track_thresh = 0.61
|
||||
track_buffer = RTSP_TARGET_FPS * 3 # 3 秒未见该目标,则判定该目标消失,在字典中删除
|
||||
match_thresh = 0.8
|
||||
mot20 = False
|
||||
|
||||
self.fps = RTSP_TARGET_FPS
|
||||
|
||||
self.person_phone_tracker = BYTETracker(TrackerArgs(), frame_rate=self.fps)
|
||||
self.smoke_tracker = BYTETracker(TrackerArgs(), frame_rate=self.fps)
|
||||
# 当前帧的ID
|
||||
self.current_frame_idx = 0
|
||||
|
||||
self.person_phone_track_role = {}
|
||||
self.smoke_track_role = {}
|
||||
# 设置 ByteTrack跟踪器
|
||||
self.ByteTracker = BYTETracker(TrackerArgs(), frame_rate=self.fps)
|
||||
|
||||
# 用来保存历史跟踪目标的字典,当目标消失了之后,就在该字典中清除该目标
|
||||
self.track_role = {}
|
||||
|
||||
# ==========================================
|
||||
# 超参数设置 (Hyperparameters)
|
||||
# ==========================================
|
||||
|
||||
# 1. 业务判定时间阈值
|
||||
self.TIME_THRESHOLD_NOBODY = 2.0 # 无人在场判定时长
|
||||
self.TIME_TOLERANCE_NOBODY = 2.0 # 人丢失缓冲时间
|
||||
|
||||
self.TIME_THRESHOLD_SMOKE = 1.0 # 抽烟判定时长
|
||||
self.TIME_TOLERANCE_SMOKE = 0.5 # 烟丢失缓冲时间(防抖动)
|
||||
|
||||
self.TIME_THRESHOLD_PHONE = 1.0 # 玩手机判定时长
|
||||
self.TIME_TOLERANCE_PHONE = 0.5 # 手机丢失缓冲时间(防抖动)
|
||||
|
||||
# 无人在场帧数阈值
|
||||
self.frame_thresh_nobody = int(self.TIME_THRESHOLD_NOBODY * self.fps)
|
||||
self.frame_buffer_nobody = int(self.TIME_TOLERANCE_NOBODY * self.fps)
|
||||
|
||||
# 抽烟检测帧数阈值
|
||||
self.frame_thresh_smoke = int(self.TIME_THRESHOLD_SMOKE * self.fps)
|
||||
self.frame_buffer_smoke = int(self.TIME_TOLERANCE_SMOKE * self.fps)
|
||||
|
||||
# 手机检测帧数阈值
|
||||
self.frame_thresh_phone = int(self.TIME_THRESHOLD_PHONE * self.fps)
|
||||
self.frame_buffer_phone = int(self.TIME_TOLERANCE_PHONE * self.fps)
|
||||
# 记录无人的帧数
|
||||
self.nobody_frames = 0
|
||||
|
||||
print(f"\n超参数设置:")
|
||||
print(f" FPS: {self.fps:.2f}")
|
||||
print(f" 判定 'Nobody' 需连续: {self.frame_thresh_nobody} 帧")
|
||||
print(f" 判定 'Smoke Detected' 需累计检测: {self.frame_thresh_smoke} 帧")
|
||||
print(f" 抽烟丢失缓冲帧数: {self.frame_buffer_smoke} 帧")
|
||||
print(f" 判定 'Phone Detected' 需累计检测: {self.frame_thresh_phone} 帧")
|
||||
print(f" 手机丢失缓冲帧数: {self.frame_buffer_phone} 帧")
|
||||
print(f" 判定 'Nobody' 需连续: {NOBODY_THRESHOLD} 帧")
|
||||
|
||||
# ==========================================
|
||||
# 状态变量初始化
|
||||
# ==========================================
|
||||
|
||||
self.current_frame_idx = 0
|
||||
|
||||
# 无人在场检测状态变量
|
||||
self.nobody_detection_frames = 0
|
||||
self.nobody_missing_frames = 0 # 连续未检测到手机的帧数
|
||||
self.nobody_alert_active = False # 手机报警是否激活
|
||||
|
||||
# 手机检测状态变量
|
||||
self.phone_detection_frames = 0 # 连续检测到手机的帧数
|
||||
self.phone_missing_frames = 0 # 连续未检测到手机的帧数
|
||||
self.phone_alert_active = False # 手机报警是否激活
|
||||
|
||||
# 抽烟检测状态变量
|
||||
self.smoke_detection_frames = 0 # 连续检测到手机的帧数
|
||||
self.smoke_missing_frames = 0 # 连续未检测到手机的帧数
|
||||
self.smoke_alert_active = False # 手机报警是否激活
|
||||
|
||||
|
||||
def compute_iou(self,boxA, boxB):
|
||||
def compute_iou(self, boxA, boxB):
|
||||
# box = [x1, y1, x2, y2]
|
||||
xA = max(boxA[0], boxB[0])
|
||||
yA = max(boxA[1], boxB[1])
|
||||
@@ -142,7 +98,7 @@ class ZhihuishiDetector:
|
||||
|
||||
return interArea / unionArea
|
||||
|
||||
def draw_alert(self, frame, text, color=(0, 0, 255), sub_text=None, offset_y=0):
|
||||
def draw_alert(self, frame, text, color=(0, 0, 255), offset_y=0):
|
||||
"""在右上角绘制警告文字 (支持垂直偏移,防止文字重叠)"""
|
||||
font_scale = 1.5
|
||||
thickness = 3
|
||||
@@ -155,347 +111,114 @@ class ZhihuishiDetector:
|
||||
cv2.rectangle(frame, (x - 10, y - text_h - 10), (x + text_w + 10, y + 10), (0, 0, 0), -1)
|
||||
cv2.putText(frame, text, (x, y), font, font_scale, color, thickness)
|
||||
|
||||
if sub_text:
|
||||
cv2.putText(frame, sub_text, (x, y + 40), font, 0.7, (200, 200, 200), 2)
|
||||
|
||||
def process_frame(self, frame, camera_id: int, timestamp: float) -> Dict[str, Any]:
|
||||
|
||||
# =================================== 收集检测结果 ===================================
|
||||
h, w = frame.shape[:2]
|
||||
self.width, self.height = w, h
|
||||
|
||||
self.current_frame_idx += 1
|
||||
current_time_sec = timestamp # 当前时间戳
|
||||
|
||||
current_time_sec = timestamp
|
||||
# yolo 的检测结果
|
||||
detect_results = self.detector(frame)
|
||||
|
||||
# ========= 人和手机检测 =========
|
||||
person_phone_results = self.person_phone_detector(frame)
|
||||
detect_xyxy = [] # 存储 yolo检测出来的所有检测框的角点坐标,x1, y1, x2, y2为角点坐标,x1 y1为左上角,x2 y2为右下角
|
||||
detect_roles = [] # 存储 yolo检测出来的所有检测框的标签类别,用id的形式保存
|
||||
detect_bytetrack = [] # 从 yolo的检测结果中提取出来用于ByteTrack追踪检测框所需的信息,保存在这里面
|
||||
|
||||
# ========= 抽烟检测 =========
|
||||
smoke_results = self.smoke_detector(frame)
|
||||
# 累计在当前帧里每个标签类别被检测到的次数,存储格式为 类别id:次数
|
||||
current_labels_count = {id: 0 for id in Label_Map}
|
||||
|
||||
person_phone_dets_xyxy = []
|
||||
person_phone_dets_roles = []
|
||||
person_phone_dets_for_tracker = []
|
||||
|
||||
smoke_dets_xyxy = []
|
||||
smoke_dets_roles = []
|
||||
smoke_dets_for_tracker = []
|
||||
|
||||
# ========= 当前帧所有警告列表(关键改动)==========
|
||||
# ========= 存储当前帧所有警告 ==========
|
||||
current_frame_alerts = [] # 每帧清空,重新收集
|
||||
|
||||
# 收集 人和手机的检测结果
|
||||
if person_phone_results:
|
||||
for det in person_phone_results:
|
||||
x1, y1, x2, y2, conf, cls_id = det # x1, y1, x2, y2为角点坐标,x1 y1为左上角,x2 y2为右下角
|
||||
person_phone_dets_xyxy.append([x1, y1, x2, y2])
|
||||
person_phone_dets_for_tracker.append([x1, y1, x2, y2, conf])
|
||||
if cls_id == 0:
|
||||
person_phone_dets_roles.append("phone")
|
||||
elif cls_id == 1:
|
||||
person_phone_dets_roles.append("police")
|
||||
# 遍历 yolo 的检测结果,对 detect_xyxy detect_roles detect_bytetrack 进行填充
|
||||
if detect_results:
|
||||
for result in detect_results: # yolo检测结果返回 x1, y1, x2, y2, conf, cls_id
|
||||
detect_xyxy.append(result[:-2])
|
||||
detect_roles.append(result[-1])
|
||||
detect_bytetrack.append(result[:-1])
|
||||
|
||||
person_phone_dets = np.array(person_phone_dets_for_tracker, dtype=np.float32) if len(
|
||||
person_phone_dets_for_tracker) else np.empty((0, 5))
|
||||
|
||||
person_phone_tracks = self.person_phone_tracker.update(
|
||||
person_phone_dets,
|
||||
# 根据收集到的 detect_bytetrack 确定追踪的检测框目标
|
||||
tracks = self.ByteTracker.update(
|
||||
np.array(detect_bytetrack, dtype=np.float32) if len(detect_bytetrack) else np.empty((0, 5)),
|
||||
# np.empty((0,5)) 表示一个 0 行、5 列 的二维空数组
|
||||
[self.height, self.width],
|
||||
[self.height, self.width]
|
||||
)
|
||||
|
||||
# 收集 抽烟的检测结果
|
||||
if smoke_results:
|
||||
for det in smoke_results:
|
||||
x1, y1, x2, y2, conf, cls_id = det
|
||||
smoke_dets_xyxy.append([x1, y1, x2, y2])
|
||||
smoke_dets_for_tracker.append([x1, y1, x2, y2, conf])
|
||||
if cls_id == 0:
|
||||
smoke_dets_roles.append("smoke")
|
||||
# 匹配每个跟踪目标的正确类别
|
||||
# 为什么要用track的结果来统计标签类别的出现次数以及绘制检测框,而不是仅用yolo的检测结果来统计及绘制,是因为yolo的检测结果是针对单帧,而bytetrack可以实现跨帧处理,bytetrack的track_id会给每个目标设置一个唯一的id
|
||||
current_track_ids = []
|
||||
for track in tracks:
|
||||
track_id = track.track_id
|
||||
current_track_ids.append(track_id)
|
||||
|
||||
smoke_dets = np.array(smoke_dets_for_tracker, dtype=np.float32) if len(
|
||||
smoke_dets_for_tracker) else np.empty((0, 5))
|
||||
reIdentify_frame_interval = 10 # 重新匹配每个跟踪目标的类别的帧间隔
|
||||
if (current_time_sec % reIdentify_frame_interval == 0) or track_id not in self.track_role:
|
||||
best_iou = 0.0
|
||||
best_role = -1
|
||||
|
||||
smoke_tracks = self.smoke_tracker.update(
|
||||
smoke_dets,
|
||||
[self.height, self.width],
|
||||
[self.height, self.width]
|
||||
)
|
||||
track_box = list(map(float, track.tlbr))
|
||||
|
||||
# ========= 单帧统计变量 =========
|
||||
current_person_count = 0
|
||||
current_phone_count = 0
|
||||
current_smoke_count = 0
|
||||
for i, box in enumerate(detect_xyxy):
|
||||
iou = self.compute_iou(track_box, box)
|
||||
if iou > best_iou:
|
||||
best_iou = iou
|
||||
best_role = detect_roles[i]
|
||||
|
||||
# ========= 人和手机检测 =========
|
||||
for t in person_phone_tracks:
|
||||
# print("t: {}".format(t))
|
||||
tid = t.track_id
|
||||
# cls_id = -1
|
||||
self.track_role[track_id] = best_role
|
||||
|
||||
# IoU 匹配角色
|
||||
# IoU匹配跟踪ID和类别
|
||||
REVALIDATE_FRAME_INTERVAL = 10
|
||||
if (self.current_frame_idx % REVALIDATE_FRAME_INTERVAL == 0) or (tid not in self.person_phone_track_role):
|
||||
#if tid not in self.person_phone_track_role:
|
||||
best_iou = 0
|
||||
best_role = "unknown"
|
||||
role = self.track_role[track_id]
|
||||
|
||||
t_box = list(map(float, t.tlbr)) # [x1,y1,x2,y2]
|
||||
current_labels_count[role] += 1
|
||||
|
||||
for i, box in enumerate(person_phone_dets_xyxy):
|
||||
iou_val = self.compute_iou(t_box, box)
|
||||
if iou_val > best_iou:
|
||||
best_iou = iou_val
|
||||
best_role = person_phone_dets_roles[i]
|
||||
if best_iou > 0.1:
|
||||
self.person_phone_track_role[tid] = best_role
|
||||
else:
|
||||
self.person_phone_track_role[tid] = "unknown"
|
||||
# 当 role 不等于 unknown 的时候,绘制检测框
|
||||
if role != -1:
|
||||
x1, y1, x2, y2 = map(int, track.tlbr)
|
||||
cv2.rectangle(frame, (x1, y1), (x2, y2), Color_Map[role], 2)
|
||||
cv2.putText(frame, Label_Map[role], (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, Color_Map[role], 2)
|
||||
|
||||
role = self.person_phone_track_role.get(tid, "unknown")
|
||||
cls_id = -1
|
||||
if role == "phone":
|
||||
cls_id = 0
|
||||
elif role == "police":
|
||||
cls_id = 1
|
||||
# print("tid: {}, role: {}, cls: {}".format(tid, role,cls_id))
|
||||
# 处理过期的 track_role 里的role,如果 track_role 里包含 tracks 里没有的 role ,直接删了即可
|
||||
for role in list(self.track_role.keys()): # 遍历字典的时候不能直接删元素,用 list() 先复制一份 key,再遍历删除,才安全
|
||||
if role not in current_track_ids:
|
||||
del self.track_role[role]
|
||||
|
||||
x1, y1, x2, y2 = map(int, t.tlbr)
|
||||
# ========================= 业务逻辑判断 ===========================
|
||||
nobody_alter_flag = False # 无人在场业务逻辑是否成立
|
||||
|
||||
cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
|
||||
|
||||
color = None
|
||||
label = None
|
||||
# Nobody 业务逻辑判断
|
||||
if current_labels_count[0] == 0:
|
||||
self.nobody_frames += 1
|
||||
|
||||
if cls_id == 0: # Person
|
||||
current_phone_count += 1
|
||||
color = (255, 0, 255)
|
||||
label = "Phone"
|
||||
|
||||
elif cls_id == 1: # Phone(主模型已支持)
|
||||
current_person_count += 1
|
||||
color = (0, 0, 139)
|
||||
label = "Person"
|
||||
|
||||
else:
|
||||
color = (255, 255, 255)
|
||||
label = "Unknown"
|
||||
|
||||
# label = f"ID:{tid} IN"
|
||||
|
||||
cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2)
|
||||
cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
|
||||
|
||||
# ========= 抽烟检测 =========
|
||||
for t in smoke_tracks:
|
||||
# print("t: {}".format(t))
|
||||
tid = t.track_id
|
||||
# cls_id = -1
|
||||
|
||||
# IoU 匹配角色
|
||||
# IoU匹配跟踪ID和类别
|
||||
REVALIDATE_FRAME_INTERVAL = 10
|
||||
if (self.current_frame_idx % REVALIDATE_FRAME_INTERVAL == 0) or (tid not in self.smoke_track_role):
|
||||
#if tid not in self.smoke_track_role:
|
||||
best_iou = 0
|
||||
best_role = "unknown"
|
||||
|
||||
t_box = list(map(float, t.tlbr)) # [x1,y1,x2,y2]
|
||||
|
||||
for i, box in enumerate(smoke_dets_xyxy):
|
||||
iou_val = self.compute_iou(t_box, box)
|
||||
if iou_val > best_iou:
|
||||
best_iou = iou_val
|
||||
best_role = smoke_dets_roles[i]
|
||||
# self.smoke_track_role[tid] = best_role
|
||||
if best_iou > 0.1:
|
||||
self.smoke_track_role[tid] = best_role
|
||||
else:
|
||||
self.smoke_track_role[tid] = "unknown"
|
||||
|
||||
role = self.smoke_track_role.get(tid, "unknown")
|
||||
cls_id = -1
|
||||
if role == "smoke":
|
||||
cls_id = 0
|
||||
|
||||
x1, y1, x2, y2 = map(int, t.tlbr)
|
||||
|
||||
cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
|
||||
|
||||
color = None
|
||||
label = None
|
||||
|
||||
if cls_id == 0: # 抽烟
|
||||
current_smoke_count += 1
|
||||
color = (255, 255, 0)
|
||||
label = "Smoke"
|
||||
|
||||
else:
|
||||
color = (255, 255, 255)
|
||||
label = "Unknown"
|
||||
|
||||
# label = f"ID:{tid} IN"
|
||||
|
||||
cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2)
|
||||
cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
|
||||
|
||||
# ==========================================
|
||||
# 手机检测
|
||||
# ==========================================
|
||||
if current_phone_count > 0:
|
||||
# 检测到手机框
|
||||
self.phone_detection_frames += 1
|
||||
self.phone_missing_frames = 0 # 重置丢失计数器
|
||||
|
||||
# 当检测累计达到阈值时,激活报警
|
||||
if self.phone_detection_frames >= self.frame_thresh_phone:
|
||||
self.phone_alert_active = True
|
||||
if self.nobody_frames >= NOBODY_THRESHOLD:
|
||||
nobody_alter_flag = True
|
||||
else:
|
||||
# 未检测到手机框
|
||||
self.phone_missing_frames += 1
|
||||
|
||||
# 如果之前检测到手机,重置检测计数器
|
||||
if self.phone_detection_frames > 0:
|
||||
# 只有在连续丢失超过缓冲帧数时才重置
|
||||
if self.phone_missing_frames >= self.frame_buffer_phone:
|
||||
self.phone_detection_frames = 0
|
||||
self.phone_alert_active = False
|
||||
else:
|
||||
# 从未检测到手机,保持状态
|
||||
pass
|
||||
|
||||
# ==========================================
|
||||
# 抽烟检测
|
||||
# ==========================================
|
||||
if current_smoke_count > 0:
|
||||
# 检测到抽烟框
|
||||
self.smoke_detection_frames += 1
|
||||
self.smoke_missing_frames = 0 # 重置丢失计数器
|
||||
|
||||
# 当检测累计达到阈值时,激活报警
|
||||
if self.smoke_detection_frames >= self.frame_thresh_smoke:
|
||||
self.smoke_alert_active = True
|
||||
else:
|
||||
# 未检测到抽烟框
|
||||
self.smoke_missing_frames += 1
|
||||
|
||||
# 如果之前检测到抽烟,重置检测计数器
|
||||
if self.smoke_detection_frames > 0:
|
||||
# 只有在连续丢失超过缓冲帧数时才重置
|
||||
if self.smoke_missing_frames >= self.frame_buffer_smoke:
|
||||
self.smoke_detection_frames = 0
|
||||
self.smoke_alert_active = False
|
||||
else:
|
||||
# 从未检测到抽烟,保持状态
|
||||
pass
|
||||
|
||||
# ==========================================
|
||||
# 9. 业务逻辑判定 (Only One / Nobody)
|
||||
# ==========================================
|
||||
status_text = ""
|
||||
|
||||
if current_person_count == 0:
|
||||
self.nobody_detection_frames += 1
|
||||
self.nobody_missing_frames = 0
|
||||
|
||||
if self.nobody_detection_frames >= self.frame_thresh_nobody:
|
||||
self.nobody_alert_active = True
|
||||
else:
|
||||
self.nobody_missing_frames += 1
|
||||
|
||||
if self.nobody_detection_frames > 0:
|
||||
if self.nobody_missing_frames >= self.frame_buffer_nobody:
|
||||
self.nobody_detection_frames = 0
|
||||
self.nobody_alert_active = False
|
||||
else:
|
||||
pass
|
||||
self.nobody_frames = 0
|
||||
|
||||
|
||||
# if current_person_count == 0:
|
||||
# self.cnt_frame_nobody += 1
|
||||
# else:
|
||||
# self.cnt_frame_nobody = 0
|
||||
|
||||
# ==========================================
|
||||
# 10. 收集并生成结构化警告(核心改动)
|
||||
# ==========================================
|
||||
|
||||
alert_offset = 0
|
||||
|
||||
# A. Playing Phone
|
||||
if self.phone_alert_active:
|
||||
duration_seconds = self.phone_detection_frames / self.fps
|
||||
if nobody_alter_flag:
|
||||
action_text = 'Nobody Checking'
|
||||
current_frame_alerts.append(
|
||||
{
|
||||
'time': current_time_sec,
|
||||
'action': 'Playing Phone',
|
||||
'confidence': 1.0, # 固定为1.0(规则判定)
|
||||
'details': f"Detected for {duration_seconds:.1f}s"
|
||||
'action': action_text,
|
||||
}
|
||||
)
|
||||
|
||||
# A. Playing Phone
|
||||
if self.smoke_alert_active:
|
||||
duration_seconds = self.smoke_detection_frames / self.fps
|
||||
current_frame_alerts.append(
|
||||
{
|
||||
'time': current_time_sec,
|
||||
'action': 'Smoke',
|
||||
'confidence': 1.0, # 固定为1.0(规则判定)
|
||||
'details': f"Detected for {duration_seconds:.1f}s"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# D. Nobody Checking
|
||||
if self.nobody_alert_active:
|
||||
duration_seconds = self.nobody_detection_frames / self.fps
|
||||
current_frame_alerts.append({
|
||||
'time': current_time_sec,
|
||||
'action': 'Nobody Checking',
|
||||
'confidence': 1.0,
|
||||
'details': f"Detected for {duration_seconds:.1f}s"
|
||||
})
|
||||
|
||||
# ==========================================
|
||||
# 11. 统一显示当前帧所有警告(可替换原分层显示)
|
||||
# ==========================================
|
||||
debug_info = f"Person: {current_person_count} | Phone: {current_phone_count} | Smoke: {current_smoke_count}"
|
||||
cv2.putText(frame, debug_info, (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
|
||||
|
||||
# 统一警告显示区
|
||||
alert_y_start = 150
|
||||
for i, alert in enumerate(current_frame_alerts):
|
||||
action = alert['action']
|
||||
details = alert.get('details', '')
|
||||
color = (0, 0, 255) # 默认红色警告
|
||||
|
||||
if action == 'Nobody Checking':
|
||||
color = (255, 255, 255)
|
||||
elif action == 'Smoke':
|
||||
color = (0, 0, 255)
|
||||
elif action == 'Playing Phone':
|
||||
color = (255, 0, 0)
|
||||
|
||||
main_text = action
|
||||
if details:
|
||||
main_text += f" ({details})"
|
||||
|
||||
y_pos = alert_y_start + i * 50
|
||||
cv2.rectangle(frame, (20, y_pos - 40), (900, y_pos + 10), (0, 0, 0), -1)
|
||||
cv2.putText(frame, main_text, (30, y_pos), cv2.FONT_HERSHEY_SIMPLEX, 1.0, color, 2)
|
||||
self.draw_alert(frame, action_text, offset_y=0)
|
||||
|
||||
return {
|
||||
"image": frame,
|
||||
|
||||
"alerts":current_frame_alerts
|
||||
"alerts": current_frame_alerts
|
||||
}
|
||||
|
||||
|
||||
# ========================= 帧处理线程 =========================
|
||||
class FrameProcessorWorker(BaseFrameProcessorWorker):
|
||||
"""监控室检测帧处理线程"""
|
||||
|
||||
|
||||
# 子类配置
|
||||
DETECTOR_FACTORY = lambda params: ZhihuishiDetector(params)
|
||||
POST_TYPE = 2
|
||||
|
||||
@@ -11,6 +11,12 @@ HLS_ROOT_PATH = ""
|
||||
|
||||
HLS_SEGMENT_PATTERN = "segment_%09d.ts" # TS文件命名模式
|
||||
|
||||
# 默认告警推送间隔(秒)
|
||||
ALERT_PUSH_INTERVAL = 5.0
|
||||
|
||||
# 各 action 的推送间隔配置 {action_code: interval_seconds}
|
||||
ALERT_PUSH_INTERVALS = {}
|
||||
|
||||
# 视频剪辑配置
|
||||
VIDEO_CLIP_OUTPUT_DIR = ""
|
||||
VIDEO_CLIP_DURATION_SECONDS = 30
|
||||
@@ -27,6 +33,7 @@ def init_config(config_path: str = "config.yaml"):
|
||||
"""
|
||||
global ALERT_PUSH_URL, HLS_ROOT_PATH
|
||||
global VIDEO_CLIP_OUTPUT_DIR, VIDEO_CLIP_DURATION_SECONDS, VIDEO_CLIP_RETENTION_SECONDS, VIDEO_CLIP_DEFAULT_SEGMENT_DURATION
|
||||
global ALERT_PUSH_INTERVALS
|
||||
|
||||
try:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
@@ -41,6 +48,9 @@ def init_config(config_path: str = "config.yaml"):
|
||||
VIDEO_CLIP_RETENTION_SECONDS = cfg.get("video_clip_retention_seconds", 3600)
|
||||
VIDEO_CLIP_DEFAULT_SEGMENT_DURATION = cfg.get("video_clip_default_segment_duration", 2)
|
||||
|
||||
# 告警推送间隔配置
|
||||
ALERT_PUSH_INTERVALS = cfg.get("alert_push_intervals", {})
|
||||
|
||||
logger.info(f"[INFO] Config initialized from {config_path}, alert_push_url={ALERT_PUSH_URL}")
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -3,6 +3,7 @@ from biz.prison.trajectory02_biz import FrameProcessorWorker as TrajectoryWorker
|
||||
from biz.prison.supervision_room_biz import FrameProcessorWorker as SupervisionWorker
|
||||
from biz.prison.ab_biz import FrameProcessorWorker as AbWorker
|
||||
from biz.prison.prison_biz import FrameProcessorWorker as CorridorWorker
|
||||
from biz.prison.indoor_biz import FrameProcessorWorker as IndoorWorker
|
||||
|
||||
# ... 其他导入
|
||||
|
||||
@@ -11,7 +12,8 @@ PROCESSOR_MAP = {
|
||||
"trajectory": TrajectoryWorker,
|
||||
"supervision_room": SupervisionWorker,
|
||||
"ab": AbWorker,
|
||||
"corridor": CorridorWorker
|
||||
"corridor": CorridorWorker,
|
||||
"indoor": IndoorWorker
|
||||
}
|
||||
|
||||
def get_processor(processor_type: str):
|
||||
|
||||
76
config.yaml
76
config.yaml
@@ -48,22 +48,69 @@ video_clip_retention_seconds: 3600 # 视频文件
|
||||
video_clip_default_segment_duration: 2 # 默认分片时长fallback(秒)
|
||||
|
||||
service_groups:
|
||||
- name: "kadian_group" # 服务组名称
|
||||
video_source_type: "rtsp"
|
||||
#- name: "kadian_group" # 服务组名称
|
||||
# video_source_type: "hls"
|
||||
# ws_host: "0.0.0.0" # WebSocket 服务地址
|
||||
# ws_port: 8765 # WebSocket 服务端口
|
||||
# algorithm: "checkpoint" # 算法类型
|
||||
# cameras: # 该组下的摄像头列表
|
||||
# - id: 8
|
||||
# index: "12345"
|
||||
# name: Entrance
|
||||
# params:
|
||||
# model_path: "Kadian_sanshijiazi.onnx"
|
||||
# roi_points:
|
||||
# - [0.15, 0.001]
|
||||
# - [0.5, 0.001]
|
||||
# - [1.0, 0.8]
|
||||
# - [0.35, 1.0]
|
||||
- name: "indoor_group" # 服务组名称
|
||||
video_source_type: "hls"
|
||||
ws_host: "0.0.0.0" # WebSocket 服务地址
|
||||
ws_port: 8765 # WebSocket 服务端口
|
||||
algorithm: "corridor" # 算法类型
|
||||
algorithm: "indoor" # 算法类型
|
||||
cameras: # 该组下的摄像头列表
|
||||
- id: 8
|
||||
index: "12345"
|
||||
name: Entrance
|
||||
params:
|
||||
model_path: "Kadian.onnx"
|
||||
roi_points:
|
||||
- [0.15, 0.001]
|
||||
- [0.5, 0.001]
|
||||
- [1.0, 0.8]
|
||||
- [0.35, 1.0]
|
||||
model_path: "kanshousuo.onnx"
|
||||
door_rois:
|
||||
left_door_1:
|
||||
points:
|
||||
- [0.195, 0.242]
|
||||
- [0.265, 0.17]
|
||||
- [0.3, 0.63]
|
||||
- [0.248, 0.8]
|
||||
color: [255, 0, 0]
|
||||
left_door_2:
|
||||
points:
|
||||
- [0.3, 0.1]
|
||||
- [0.34, 0.08]
|
||||
- [0.35, 0.43]
|
||||
- [0.322, 0.52]
|
||||
color: [0, 255, 0]
|
||||
left_door_3:
|
||||
points:
|
||||
- [0.355, 0.06]
|
||||
- [0.42, 0.0]
|
||||
- [0.42, 0.18]
|
||||
- [0.362, 0.36]
|
||||
color: [0, 0, 255]
|
||||
right_door_1:
|
||||
points:
|
||||
- [0.735, 0.142]
|
||||
- [0.81, 0.22]
|
||||
- [0.78, 0.8]
|
||||
- [0.715, 0.65]
|
||||
color: [255, 255, 0]
|
||||
right_door_2:
|
||||
points:
|
||||
- [0.65, 0.06]
|
||||
- [0.7, 0.09]
|
||||
- [0.69, 0.5]
|
||||
- [0.65, 0.4]
|
||||
color: [255, 165, 0]
|
||||
#- name: "prison_group" # 服务组名称
|
||||
# video_source_type: "hls"
|
||||
# ws_host: "0.0.0.0" # WebSocket 服务地址
|
||||
@@ -91,8 +138,19 @@ alert_types:
|
||||
# 监狱检测 (prison)
|
||||
"prisoner": "带出犯人"
|
||||
"violation": "路线违规"
|
||||
"Indoor Violation": "违规进入区域"
|
||||
|
||||
|
||||
# 监控室检测 (supervision_room)
|
||||
"Playing Phone": "玩手机"
|
||||
"Smoke": "吸烟"
|
||||
"Nobody Checking": "无人在场"
|
||||
|
||||
# 告警推送频率配置(可选,单位:秒)
|
||||
# 如果某个 action 配置了,则使用配置的时间;否则使用默认的 ALERT_PUSH_INTERVAL(5秒)
|
||||
alert_push_intervals:
|
||||
"Unchecked Trunk": 30 # 未检查后备箱 - 10秒
|
||||
"Ignore": 30 # 漏检 - 3秒
|
||||
"Nobody": 600 # 无人在场 - 5秒
|
||||
"Only One": 120
|
||||
|
||||
|
||||
552
live_catalog/index.html
Normal file
552
live_catalog/index.html
Normal file
@@ -0,0 +1,552 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>Live Catalog - 树形直播目录</title>
|
||||
<!-- HLS.js 用于播放 .m3u8 流 -->
|
||||
<script src="/static/js/hls.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #f0f2f5;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 主布局 */
|
||||
.app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 左侧树形菜单 */
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 2px 0 8px rgba(0,0,0,0.02);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px 20px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #1f2f3d;
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
/* 树形结构样式 */
|
||||
.tree {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tree-node-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px 8px 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-size: 14px;
|
||||
color: #2c3e50;
|
||||
border-radius: 4px;
|
||||
margin: 2px 8px;
|
||||
}
|
||||
|
||||
.tree-node-content:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.tree-node-content.active {
|
||||
background-color: #e6f7ff;
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 展开/折叠图标 */
|
||||
.expand-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 8px;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.expand-icon.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.node-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 加载中指示器 */
|
||||
.loading-icon {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 8px;
|
||||
border: 2px solid #ddd;
|
||||
border-top-color: #1890ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 子节点容器 */
|
||||
.children {
|
||||
list-style: none;
|
||||
padding-left: 28px;
|
||||
margin: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.children.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 右侧视频区域 */
|
||||
.video-area {
|
||||
flex: 1;
|
||||
background: #141414;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 视频控制栏 */
|
||||
.video-controls {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(to bottom, rgba(0,0,0,0.7), transparent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.video-controls label {
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.video-controls select {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #444;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 视频信息栏 */
|
||||
.video-info {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
z-index: 10;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.video-info .info-row {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.video-info .info-label {
|
||||
color: #888;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.video-info .info-value {
|
||||
color: #0f0;
|
||||
}
|
||||
|
||||
#video-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
background: black;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: #ccc;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 响应式小屏 */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
📺 直播目录
|
||||
</div>
|
||||
<div class="tree-container">
|
||||
<ul class="tree" id="tree-root">
|
||||
<!-- 根节点会动态渲染到这里 -->
|
||||
<li class="tree-node loading-placeholder" style="padding: 12px; text-align: center; color: #999;">加载中...</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-area">
|
||||
<!-- 视频控制栏 -->
|
||||
<div class="video-controls" id="video-controls" style="display: none;">
|
||||
<label>码流类型:</label>
|
||||
<select id="stream-type">
|
||||
<option value="0">主码流(高清)</option>
|
||||
<option value="1">子码流(流畅)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="video-container">
|
||||
<div class="placeholder">👈 从左侧选择一个直播源</div>
|
||||
</div>
|
||||
<!-- 视频信息栏 -->
|
||||
<div class="video-info" id="video-info" style="display: none;">
|
||||
<div class="info-row">
|
||||
<span class="info-label">摄像头ID:</span>
|
||||
<span class="info-value" id="info-camera-id">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">播放地址:</span>
|
||||
<span class="info-value" id="info-url">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// API 基础路径(与后端同源)
|
||||
const API_BASE = '/api';
|
||||
|
||||
// 全局状态
|
||||
let currentVideoNode = null; // 当前播放的节点对象
|
||||
let hls = null; // HLS 实例
|
||||
let currentStreamType = 0; // 当前码流类型
|
||||
|
||||
// 缓存已加载的子节点数据: { parentId: [childrenNodes] }
|
||||
const childrenCache = new Map();
|
||||
|
||||
// 获取根节点
|
||||
async function fetchRoots() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/roots`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
console.error('获取根节点失败:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 获取指定节点的子节点
|
||||
async function fetchChildren(parentId) {
|
||||
// 检查缓存
|
||||
if (childrenCache.has(parentId)) {
|
||||
return childrenCache.get(parentId);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/children/${parentId}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const children = await res.json();
|
||||
childrenCache.set(parentId, children);
|
||||
return children;
|
||||
} catch (err) {
|
||||
console.error(`获取节点 ${parentId} 的子节点失败:`, err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 获取视频流地址
|
||||
async function fetchStreamUrl(nodeId, streamType = 0) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/stream/${nodeId}?stream_type=${streamType}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
console.error(`获取节点 ${nodeId} 的视频地址失败:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 播放视频
|
||||
async function playVideo(node, streamType = 0) {
|
||||
if (!node || !node.is_leaf) return;
|
||||
|
||||
// 显示加载占位
|
||||
const container = document.getElementById('video-container');
|
||||
container.innerHTML = '<div class="placeholder">📡 正在加载直播流...</div>';
|
||||
|
||||
// 隐藏信息和控制栏
|
||||
document.getElementById('video-controls').style.display = 'none';
|
||||
document.getElementById('video-info').style.display = 'none';
|
||||
|
||||
// 获取流地址
|
||||
const streamData = await fetchStreamUrl(node.id, streamType);
|
||||
if (!streamData || !streamData.url) {
|
||||
container.innerHTML = '<div class="placeholder">❌ 无法获取视频地址,请稍后重试</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const streamUrl = streamData.url;
|
||||
|
||||
// 清理旧播放器
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
hls = null;
|
||||
}
|
||||
|
||||
// 创建 video 元素
|
||||
const video = document.createElement('video');
|
||||
video.controls = true;
|
||||
video.autoplay = true;
|
||||
video.style.width = '100%';
|
||||
video.style.height = '100%';
|
||||
video.style.objectFit = 'contain';
|
||||
container.innerHTML = '';
|
||||
container.appendChild(video);
|
||||
|
||||
// 判断是否 HLS 流
|
||||
if (Hls.isSupported() && streamUrl.includes('.m3u8')) {
|
||||
hls = new Hls();
|
||||
hls.loadSource(streamUrl);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
video.play().catch(e => console.warn('自动播放被阻止:', e));
|
||||
});
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
console.error('HLS 错误:', data);
|
||||
container.innerHTML = '<div class="placeholder">⚠️ 直播流播放失败,请检查地址或网络</div>';
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl') && streamUrl.includes('.m3u8')) {
|
||||
// 原生支持 HLS(Safari)
|
||||
video.src = streamUrl;
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
video.play().catch(e => console.warn('自动播放被阻止:', e));
|
||||
});
|
||||
} else {
|
||||
// 非 HLS 直接播放(如 mp4)
|
||||
video.src = streamUrl;
|
||||
video.addEventListener('error', () => {
|
||||
container.innerHTML = '<div class="placeholder">❌ 视频无法播放,格式可能不支持</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// 更新并显示视频信息
|
||||
document.getElementById('info-camera-id').textContent = streamData.cameraIndexCode;
|
||||
document.getElementById('info-url').textContent = streamUrl;
|
||||
document.getElementById('video-info').style.display = 'block';
|
||||
|
||||
// 显示控制栏
|
||||
document.getElementById('video-controls').style.display = 'flex';
|
||||
|
||||
currentVideoNode = node;
|
||||
}
|
||||
|
||||
// 渲染树节点(递归方式,但为了动态添加子节点,我们只渲染当前层的 ul)
|
||||
// 核心思路:为每个节点生成一个 <li>,内部包含 .tree-node-content 和一个 .children 容器
|
||||
// 点击节点时,如果已经有 .children 内容则切换显示;否则加载子节点并填充
|
||||
async function renderTree() {
|
||||
const rootContainer = document.getElementById('tree-root');
|
||||
const roots = await fetchRoots();
|
||||
if (!roots.length) {
|
||||
rootContainer.innerHTML = '<li class="tree-node" style="padding: 12px; text-align: center; color: #999;">暂无数据</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 清空并重新构建根节点
|
||||
rootContainer.innerHTML = '';
|
||||
for (const node of roots) {
|
||||
const li = createTreeNodeElement(node);
|
||||
rootContainer.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单个树节点的 DOM 元素(不含子节点,子节点容器为空)
|
||||
function createTreeNodeElement(node) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'tree-node';
|
||||
li.dataset.id = node.id;
|
||||
li.dataset.isLeaf = node.is_leaf;
|
||||
|
||||
// 内容包装器
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.className = 'tree-node-content';
|
||||
if (!node.is_leaf) {
|
||||
// 非叶子节点:带展开图标
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'expand-icon';
|
||||
iconSpan.innerHTML = '▶'; // 初始向右,展开时旋转
|
||||
iconSpan.style.display = 'inline-block';
|
||||
contentDiv.appendChild(iconSpan);
|
||||
} else {
|
||||
// 叶子节点占位空白
|
||||
const placeholder = document.createElement('span');
|
||||
placeholder.style.width = '28px';
|
||||
placeholder.style.display = 'inline-block';
|
||||
contentDiv.appendChild(placeholder);
|
||||
}
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'node-name';
|
||||
nameSpan.textContent = node.name;
|
||||
contentDiv.appendChild(nameSpan);
|
||||
|
||||
li.appendChild(contentDiv);
|
||||
|
||||
// 子节点容器
|
||||
const childrenUl = document.createElement('ul');
|
||||
childrenUl.className = 'children';
|
||||
li.appendChild(childrenUl);
|
||||
|
||||
// 绑定点击事件(在父容器上委托)
|
||||
contentDiv.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
onNodeClick(node, li, childrenUl, contentDiv);
|
||||
});
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
// 处理节点点击
|
||||
async function onNodeClick(node, li, childrenUl, contentDiv) {
|
||||
// 如果是叶子节点:播放视频
|
||||
if (node.is_leaf) {
|
||||
// 高亮当前选中的节点
|
||||
clearActiveHighlight();
|
||||
contentDiv.classList.add('active');
|
||||
await playVideo(node, currentStreamType);
|
||||
return;
|
||||
}
|
||||
|
||||
// 非叶子节点:展开/折叠逻辑
|
||||
const isExpanded = childrenUl.classList.contains('show');
|
||||
if (isExpanded) {
|
||||
// 折叠
|
||||
childrenUl.classList.remove('show');
|
||||
const icon = contentDiv.querySelector('.expand-icon');
|
||||
if (icon) icon.classList.remove('expanded');
|
||||
} else {
|
||||
// 展开:检查是否已加载子节点
|
||||
if (childrenUl.children.length === 0) {
|
||||
// 显示加载动画
|
||||
const loadingSpan = document.createElement('span');
|
||||
loadingSpan.className = 'loading-icon';
|
||||
loadingSpan.style.marginLeft = '8px';
|
||||
contentDiv.appendChild(loadingSpan);
|
||||
|
||||
const children = await fetchChildren(node.id);
|
||||
contentDiv.removeChild(loadingSpan);
|
||||
|
||||
if (children && children.length) {
|
||||
for (const child of children) {
|
||||
const childLi = createTreeNodeElement(child);
|
||||
childrenUl.appendChild(childLi);
|
||||
}
|
||||
} else {
|
||||
// 无子节点:可显示“暂无子节点”提示
|
||||
const emptyMsg = document.createElement('li');
|
||||
emptyMsg.textContent = '(暂无下级)';
|
||||
emptyMsg.style.padding = '6px 12px';
|
||||
emptyMsg.style.color = '#999';
|
||||
emptyMsg.style.fontSize = '12px';
|
||||
childrenUl.appendChild(emptyMsg);
|
||||
}
|
||||
}
|
||||
// 展开并旋转图标
|
||||
childrenUl.classList.add('show');
|
||||
const icon = contentDiv.querySelector('.expand-icon');
|
||||
if (icon) icon.classList.add('expanded');
|
||||
}
|
||||
}
|
||||
|
||||
// 清除所有高亮
|
||||
function clearActiveHighlight() {
|
||||
document.querySelectorAll('.tree-node-content.active').forEach(el => {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化视频区域(可选:尝试自动播放需用户交互)
|
||||
function initVideoArea() {
|
||||
// 监听码流类型切换
|
||||
document.getElementById('stream-type').addEventListener('change', async (e) => {
|
||||
currentStreamType = parseInt(e.target.value);
|
||||
// 如果当前有播放的视频,重新加载
|
||||
if (currentVideoNode) {
|
||||
await playVideo(currentVideoNode, currentStreamType);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 启动
|
||||
(async function init() {
|
||||
await renderTree();
|
||||
initVideoArea();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
270
live_catalog/live_catalog_backend.py
Normal file
270
live_catalog/live_catalog_backend.py
Normal file
@@ -0,0 +1,270 @@
|
||||
import os
|
||||
import urllib.parse
|
||||
import socket
|
||||
import json
|
||||
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from utils.hikvision_cam_utils import get_organization_list, get_final_list, get_camera_preview_url
|
||||
|
||||
# ========== 海康威视 API 配置 ==========
|
||||
ROOT_PARENT_INDEX_CODE = "4fa15af07b6b400f94af1e35d8235c30"
|
||||
|
||||
def transform_org_node(item, parent_id=None):
|
||||
"""将海康威视组织节点转换为前端期望的格式"""
|
||||
return {
|
||||
"id": item["indexCode"],
|
||||
"name": item["name"],
|
||||
"parent_id": parent_id or item.get("parentIndexCode"),
|
||||
"is_leaf": False, # 组织机构节点不是叶子节点
|
||||
"stream_url": None
|
||||
}
|
||||
|
||||
def transform_camera_node(item, parent_id=None):
|
||||
"""将海康威视摄像头节点转换为前端期望的格式(叶子节点)"""
|
||||
return {
|
||||
"id": item["cameraIndexCode"],
|
||||
"name": item["name"],
|
||||
"parent_id": parent_id,
|
||||
"is_leaf": True, # 摄像头是叶子节点
|
||||
"stream_url": None
|
||||
}
|
||||
|
||||
def get_children(parent_id):
|
||||
"""返回父节点下的直接子节点列表(从海康威视 API 获取)
|
||||
|
||||
逻辑:
|
||||
1. 先调用 get_organization_list 获取子组织
|
||||
2. 如果返回 list 为空,则调用 get_final_list 获取摄像头(叶子节点)
|
||||
"""
|
||||
if parent_id is None:
|
||||
parent_id = ROOT_PARENT_INDEX_CODE
|
||||
|
||||
try:
|
||||
# 先尝试获取子组织
|
||||
result = get_organization_list(parent_id)
|
||||
if result.get("code") != "0":
|
||||
print(f"海康威视 API 返回错误: {result.get('msg')}")
|
||||
return []
|
||||
|
||||
items = result.get("data", {}).get("list", [])
|
||||
|
||||
# 如果有子组织,返回组织节点
|
||||
if items:
|
||||
return [transform_org_node(item, parent_id) for item in items]
|
||||
|
||||
# 如果没有子组织,尝试获取摄像头列表(叶子节点)
|
||||
print(f"组织 {parent_id} 无下级组织,尝试获取摄像头列表...")
|
||||
final_result = get_final_list(parent_id)
|
||||
if final_result.get("code") != "0":
|
||||
print(f"获取摄像头列表失败: {final_result.get('msg')}")
|
||||
return []
|
||||
|
||||
camera_items = final_result.get("data", {}).get("list", [])
|
||||
print(f"获取到 {len(camera_items)} 个摄像头")
|
||||
return [transform_camera_node(item, parent_id) for item in camera_items]
|
||||
|
||||
except Exception as e:
|
||||
print(f"调用海康威视 API 失败: {e}")
|
||||
return []
|
||||
|
||||
def get_stream_url(node_id, stream_type=0):
|
||||
"""获取摄像头的视频流地址
|
||||
|
||||
Args:
|
||||
node_id: 摄像头的 cameraIndexCode
|
||||
stream_type: 码流类型,0=主码流,1=子码流
|
||||
"""
|
||||
try:
|
||||
result = get_camera_preview_url(node_id, stream_type)
|
||||
if result.get("code") != "0":
|
||||
print(f"获取视频流地址失败: {result.get('msg')}")
|
||||
return None
|
||||
|
||||
url = result.get("data", {}).get("url")
|
||||
return url
|
||||
except Exception as e:
|
||||
print(f"调用 get_camera_preview_url 失败: {e}")
|
||||
return None
|
||||
|
||||
# ========== HTTP 处理器 ==========
|
||||
class APIHandler(SimpleHTTPRequestHandler):
|
||||
# 设置超时
|
||||
timeout = 30
|
||||
|
||||
# MIME 类型映射
|
||||
MIME_TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.mp4': 'video/mp4',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
}
|
||||
|
||||
def log_message(self, format, *args):
|
||||
print(f"[{self.log_date_time_string()}] {self.address_string()} - {format % args}")
|
||||
|
||||
def send_json_response(self, data, status=200):
|
||||
"""统一返回 JSON 格式"""
|
||||
self.send_response(status)
|
||||
self.send_header('Content-type', 'application/json')
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8'))
|
||||
|
||||
def send_error_json(self, message, status=400):
|
||||
"""返回错误 JSON"""
|
||||
self.send_json_response({"error": message}, status)
|
||||
|
||||
def do_GET(self):
|
||||
try:
|
||||
parsed_path = urllib.parse.urlparse(self.path)
|
||||
path = parsed_path.path
|
||||
query = parsed_path.query
|
||||
|
||||
# 新 API 路由 (RESTful)
|
||||
if path.startswith('/api/roots'):
|
||||
# GET /api/roots
|
||||
roots = get_children(None)
|
||||
self.send_json_response(roots)
|
||||
return
|
||||
|
||||
elif path.startswith('/api/children/'):
|
||||
# GET /api/children/21020000
|
||||
node_id = path.split('/')[-1]
|
||||
if not node_id:
|
||||
self.send_error_json("Invalid node id", 400)
|
||||
return
|
||||
children = get_children(node_id)
|
||||
self.send_json_response(children)
|
||||
return
|
||||
|
||||
elif path.startswith('/api/stream/'):
|
||||
# GET /api/stream/21020000?stream_type=0
|
||||
node_id = path.split('/')[-1]
|
||||
if not node_id:
|
||||
self.send_error_json("Invalid node id", 400)
|
||||
return
|
||||
|
||||
# 解析 stream_type 参数
|
||||
params = urllib.parse.parse_qs(query)
|
||||
stream_type = int(params.get('stream_type', ['0'])[0])
|
||||
|
||||
url = get_stream_url(node_id, stream_type)
|
||||
if url is None:
|
||||
self.send_error_json("Stream not found or node is not a leaf", 404)
|
||||
return
|
||||
# 返回完整信息
|
||||
self.send_json_response({
|
||||
"cameraIndexCode": node_id,
|
||||
"url": url,
|
||||
"stream_type": stream_type
|
||||
})
|
||||
return
|
||||
|
||||
# 静态文件服务(与原逻辑一致)
|
||||
if path == '/' or path == '/index.html':
|
||||
self.serve_file('index.html', query='api=1')
|
||||
return
|
||||
else:
|
||||
filename = path.lstrip('/')
|
||||
if os.path.exists(filename):
|
||||
self.serve_static_file(filename)
|
||||
else:
|
||||
self.send_error(404, 'Not Found')
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error handling request: {e}")
|
||||
self.send_error(500, 'Internal Server Error')
|
||||
|
||||
def serve_file(self, filename, query=None):
|
||||
"""与原代码相同的文件服务(保留原逻辑)"""
|
||||
try:
|
||||
file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), filename)
|
||||
if os.path.exists(file_path):
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'text/html; charset=utf-8')
|
||||
self.end_headers()
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
content = f.read()
|
||||
|
||||
if query:
|
||||
try:
|
||||
content = content.decode('utf-8')
|
||||
# 替换原有 js 中的 apiParam 赋值语句(兼容旧前端)
|
||||
content = content.replace(
|
||||
"const apiParam = urlParams.get('api') || '1';",
|
||||
f"const apiParam = '{query.split('=')[1]}';"
|
||||
)
|
||||
content = content.encode('utf-8')
|
||||
except Exception as e:
|
||||
print(f"Error modifying HTML content: {e}")
|
||||
|
||||
self.wfile.write(content)
|
||||
else:
|
||||
self.send_error(404, f'{filename} not found')
|
||||
except Exception as e:
|
||||
print(f"Error serving file: {e}")
|
||||
raise
|
||||
|
||||
def serve_static_file(self, filename):
|
||||
"""与原代码相同的静态文件服务"""
|
||||
try:
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
content_type = self.MIME_TYPES.get(ext, 'application/octet-stream')
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', content_type)
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.end_headers()
|
||||
|
||||
with open(filename, 'rb') as f:
|
||||
self.wfile.write(f.read())
|
||||
except Exception as e:
|
||||
print(f"Error serving static file: {e}")
|
||||
self.send_error(500, 'Internal Server Error')
|
||||
|
||||
def check_port_available(port):
|
||||
"""检查端口是否可用"""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
try:
|
||||
s.bind(('', port))
|
||||
return True
|
||||
except socket.error:
|
||||
return False
|
||||
|
||||
def run():
|
||||
port = 18369
|
||||
if not check_port_available(port):
|
||||
print(f"错误: 端口 {port} 已被占用")
|
||||
return
|
||||
|
||||
server_address = ('', port)
|
||||
httpd = ThreadingHTTPServer(server_address, APIHandler)
|
||||
print(f'Server running on http://localhost:{port}')
|
||||
print('API endpoints:')
|
||||
print(' GET /api/roots - 获取所有根节点')
|
||||
print(' GET /api/children/<id> - 获取指定节点的子节点(自动判断组织/摄像头)')
|
||||
print(' GET /api/stream/<id> - 获取视频流地址')
|
||||
print('静态文件服务: 访问 / 或 /index.html')
|
||||
print('按 Ctrl+C 停止服务器')
|
||||
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print('\n服务器已停止')
|
||||
httpd.server_close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
2
live_catalog/static/js/hls.min.js
vendored
Normal file
2
live_catalog/static/js/hls.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,6 +1,5 @@
|
||||
import requests
|
||||
import urllib3
|
||||
import yaml
|
||||
from hikvision_openapi_signer import HikvisionOpenAPISigner
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
@@ -13,12 +12,12 @@ signer = HikvisionOpenAPISigner(
|
||||
headers={'tagId': '0'} # 根据平台要求设置
|
||||
)
|
||||
|
||||
def get_camera_preview_url(camera_index_code):
|
||||
def get_organization_list(parentIndexCode):
|
||||
# 签名一个请求
|
||||
request = signer.sign(
|
||||
'POST',
|
||||
'/api/video/v1/cameras/previewURLs',
|
||||
jsons={'cameraIndexCode': camera_index_code, 'protocol': 'rtsp', 'expand': 'streamform=rtp'},
|
||||
'/api/resource/v1/regions/subRegions',
|
||||
jsons={'parentIndexCode': parentIndexCode, 'treeCode': '0'},
|
||||
accept='application/json'
|
||||
)
|
||||
method, url, headers, body = request
|
||||
@@ -26,28 +25,28 @@ def get_camera_preview_url(camera_index_code):
|
||||
response = requests.request(method, url, headers=headers, data=body, verify=False)
|
||||
return response.json()
|
||||
|
||||
# 读取 config.yaml
|
||||
with open('../config.yaml', 'r', encoding='utf-8') as f:
|
||||
config = yaml.safe_load(f)
|
||||
def get_final_list(regionIndexCode):
|
||||
# 签名一个请求
|
||||
request = signer.sign(
|
||||
'POST',
|
||||
'/api/resource/v1/regions/regionIndexCode/cameras',
|
||||
jsons={'pageNo': 1, 'pageSize': 100, 'regionIndexCode': regionIndexCode, 'treeCode': '0'},
|
||||
accept='application/json'
|
||||
)
|
||||
method, url, headers, body = request
|
||||
# 发送请求(注意离线环境下verify=False可能必要,但需知安全风险)
|
||||
response = requests.request(method, url, headers=headers, data=body, verify=False)
|
||||
return response.json()
|
||||
|
||||
# 遍历所有摄像头
|
||||
for camera in config['cameras']:
|
||||
if 'index' in camera:
|
||||
index = camera['index']
|
||||
print(f"正在获取摄像头 {camera['name']} (index: {index}) 的预览地址...")
|
||||
result = get_camera_preview_url(index)
|
||||
print(f"API返回结果: {result}")
|
||||
|
||||
# 提取 url 并更新到 config
|
||||
if 'data' in result and 'url' in result['data']:
|
||||
rtsp_url = result['data']['url']
|
||||
camera['rtsp_url'] = rtsp_url
|
||||
print(f"更新 rtsp_url: {rtsp_url}")
|
||||
else:
|
||||
print(f"未找到 url 在返回结果中")
|
||||
|
||||
# 保存更新后的 config.yaml
|
||||
with open('../config.yaml', 'w', encoding='utf-8') as f:
|
||||
yaml.dump(config, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
print("config.yaml 已更新")
|
||||
def get_camera_preview_url(camera_index_code, stream_type = 0):
|
||||
# 签名一个请求
|
||||
request = signer.sign(
|
||||
'POST',
|
||||
'/api/video/v1/cameras/previewURLs',
|
||||
jsons={'cameraIndexCode': camera_index_code, 'protocol': 'hls', 'streamType': stream_type, 'expand': 'transcode=1'},
|
||||
accept='application/json'
|
||||
)
|
||||
method, url, headers, body = request
|
||||
# 发送请求
|
||||
response = requests.request(method, url, headers=headers, data=body, verify=False)
|
||||
return response.json()
|
||||
@@ -352,25 +352,25 @@
|
||||
result_type: msg.result_type
|
||||
};
|
||||
|
||||
const now = msg.timestamp;
|
||||
const timeThreshold = now - 10;
|
||||
|
||||
const existingAlert = alerts.find(alert =>
|
||||
JSON.stringify(alert.result_type) === JSON.stringify(alertMsg.result_type) &&
|
||||
alert.timestamp > timeThreshold
|
||||
);
|
||||
|
||||
if (!existingAlert) {
|
||||
// const now = msg.timestamp;
|
||||
// const timeThreshold = now - 10;
|
||||
|
||||
// const existingAlert = alerts.find(alert =>
|
||||
// JSON.stringify(alert.result_type) === JSON.stringify(alertMsg.result_type) &&
|
||||
// alert.timestamp > timeThreshold
|
||||
// );
|
||||
|
||||
// if (!existingAlert) {
|
||||
alerts.push(alertMsg);
|
||||
// console.log('添加新的异常告警:', alertMsg);
|
||||
// console.log('当前alerts数组长度:', alerts.length);
|
||||
|
||||
|
||||
renderMessages();
|
||||
// console.log('渲染消息列表完成');
|
||||
|
||||
|
||||
const alertTypes = alertMsg.result_type.join(', ');
|
||||
addLog(`检测到异常: ${alertTypes}`, 'warning');
|
||||
}
|
||||
// }
|
||||
}
|
||||
} else if (msg.msg_type === 'take_out') {
|
||||
// console.log('处理take_out类型消息');
|
||||
|
||||
416
web_page_2/coordinate.html
Normal file
416
web_page_2/coordinate.html
Normal file
@@ -0,0 +1,416 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>坐标提取工具</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #111827; color: #e5e7eb; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: flex; }
|
||||
|
||||
.left-panel {
|
||||
width: 320px; min-width: 320px; background: #020617; border-right: 1px solid #1f2937;
|
||||
display: flex; flex-direction: column; height: 100vh;
|
||||
}
|
||||
.left-header {
|
||||
padding: 12px 16px; border-bottom: 1px solid #1f2937; font-size: 14px; color: #9ca3af;
|
||||
font-weight: 500; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
|
||||
}
|
||||
.coord-list {
|
||||
flex: 1; overflow-y: auto; padding: 8px;
|
||||
}
|
||||
.coord-group {
|
||||
margin-bottom: 10px; background: #0f172a; border: 1px solid #1f2937; border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.coord-group-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 12px; border-bottom: 1px solid #1f2937; font-size: 13px;
|
||||
}
|
||||
.coord-group-color {
|
||||
display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 8px;
|
||||
}
|
||||
.coord-group-actions { display: flex; gap: 6px; }
|
||||
.coord-group-actions button {
|
||||
background: none; border: none; cursor: pointer; font-size: 13px; padding: 2px 4px;
|
||||
border-radius: 3px; transition: background 0.15s;
|
||||
}
|
||||
.coord-group-actions .copy-btn { color: #60a5fa; }
|
||||
.coord-group-actions .copy-btn:hover { background: rgba(96,165,250,0.15); }
|
||||
.coord-group-actions .del-btn { color: #f87171; }
|
||||
.coord-group-actions .del-btn:hover { background: rgba(248,113,113,0.15); }
|
||||
.coord-group pre {
|
||||
margin: 0; padding: 8px 12px; font-family: 'Courier New', monospace; font-size: 12px;
|
||||
color: #d1d5db; white-space: pre; overflow-x: auto; line-height: 1.6;
|
||||
}
|
||||
|
||||
.main-area {
|
||||
flex: 1; display: flex; flex-direction: column; min-width: 0;
|
||||
}
|
||||
.toolbar {
|
||||
padding: 10px 16px; background: #0f172a; border-bottom: 1px solid #1f2937;
|
||||
display: flex; align-items: center; gap: 12px; flex-shrink: 0;
|
||||
}
|
||||
.upload-btn {
|
||||
padding: 6px 16px; background: #3b82f6; color: #fff; border: none; border-radius: 4px;
|
||||
font-size: 13px; cursor: pointer; transition: background 0.2s;
|
||||
}
|
||||
.upload-btn:hover { background: #2563eb; }
|
||||
.toolbar-info { font-size: 12px; color: #9ca3af; }
|
||||
.toolbar-hint {
|
||||
margin-left: auto; font-size: 12px; color: #6b7280;
|
||||
}
|
||||
.toolbar-hint kbd {
|
||||
padding: 1px 5px; background: #1f2937; border: 1px solid #374151;
|
||||
border-radius: 3px; font-size: 11px; font-family: inherit;
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
flex: 1; position: relative; overflow: hidden; background: #000;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
#imageCanvas { cursor: crosshair; }
|
||||
.upload-placeholder {
|
||||
position: absolute; display: flex; flex-direction: column; align-items: center;
|
||||
gap: 12px; color: #6b7280; font-size: 14px;
|
||||
}
|
||||
.upload-placeholder svg { opacity: 0.3; }
|
||||
|
||||
.toast {
|
||||
position: fixed; top: 20px; right: 20px; padding: 10px 18px;
|
||||
background: #10b981; color: #fff; border-radius: 6px; font-size: 13px;
|
||||
opacity: 0; transition: opacity 0.3s; pointer-events: none; z-index: 999;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
|
||||
#fileInput { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<aside class="left-panel">
|
||||
<div class="left-header">
|
||||
<span>坐标数据</span>
|
||||
<button id="copyAllBtn" class="copy-btn" style="background:none;border:none;color:#60a5fa;cursor:pointer;font-size:12px;">复制全部</button>
|
||||
</div>
|
||||
<div id="coordList" class="coord-list"></div>
|
||||
</aside>
|
||||
|
||||
<div class="main-area">
|
||||
<div class="toolbar">
|
||||
<button class="upload-btn" id="uploadBtn">上传图片</button>
|
||||
<input type="file" id="fileInput" accept=".jpg,.jpeg,.png" />
|
||||
<span class="toolbar-info" id="imageInfo"></span>
|
||||
<span class="toolbar-hint">
|
||||
<kbd>点击</kbd> 标记点
|
||||
<kbd>Backspace</kbd> 撤销
|
||||
<kbd>Enter</kbd> 完成当前组
|
||||
</span>
|
||||
</div>
|
||||
<div class="canvas-area" id="canvasArea">
|
||||
<canvas id="imageCanvas"></canvas>
|
||||
<div class="upload-placeholder" id="placeholder">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
<span>点击「上传图片」开始</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const GROUP_COLORS = [
|
||||
'#ef4444', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6',
|
||||
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6'
|
||||
];
|
||||
|
||||
let groups = []; // [{points: [[x,y], ...]}, ...]
|
||||
let currentPoints = []; // current group being edited
|
||||
let image = null; // HTMLImageElement
|
||||
let canvasScale = 1;
|
||||
let canvasOffsetX = 0;
|
||||
let canvasOffsetY = 0;
|
||||
|
||||
const canvas = document.getElementById('imageCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const canvasArea = document.getElementById('canvasArea');
|
||||
const placeholder = document.getElementById('placeholder');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const imageInfo = document.getElementById('imageInfo');
|
||||
const coordList = document.getElementById('coordList');
|
||||
const toast = document.getElementById('toast');
|
||||
|
||||
// --- Upload ---
|
||||
document.getElementById('uploadBtn').addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const ext = file.name.toLowerCase().split('.').pop();
|
||||
if (!['jpg', 'jpeg', 'png'].includes(ext)) {
|
||||
showToast('仅支持 JPG/PNG 格式', true);
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
image = new Image();
|
||||
image.onload = () => {
|
||||
imageInfo.textContent = `${image.width} x ${image.height}`;
|
||||
placeholder.style.display = 'none';
|
||||
fitCanvas();
|
||||
redraw();
|
||||
};
|
||||
image.src = ev.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
// Reset groups on new image
|
||||
groups = [];
|
||||
currentPoints = [];
|
||||
renderCoordList();
|
||||
});
|
||||
|
||||
// --- Canvas sizing ---
|
||||
function fitCanvas() {
|
||||
if (!image) return;
|
||||
const areaW = canvasArea.clientWidth;
|
||||
const areaH = canvasArea.clientHeight;
|
||||
const imgAspect = image.width / image.height;
|
||||
const areaAspect = areaW / areaH;
|
||||
|
||||
let drawW, drawH;
|
||||
if (imgAspect > areaAspect) {
|
||||
drawW = areaW;
|
||||
drawH = areaW / imgAspect;
|
||||
} else {
|
||||
drawH = areaH;
|
||||
drawW = areaH * imgAspect;
|
||||
}
|
||||
canvasScale = drawW / image.width;
|
||||
canvasOffsetX = (areaW - drawW) / 2;
|
||||
canvasOffsetY = (areaH - drawH) / 2;
|
||||
|
||||
canvas.width = areaW;
|
||||
canvas.height = areaH;
|
||||
canvas.style.width = areaW + 'px';
|
||||
canvas.style.height = areaH + 'px';
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => { fitCanvas(); redraw(); });
|
||||
|
||||
// --- Drawing ---
|
||||
function redraw() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (!image) return;
|
||||
|
||||
// Draw image
|
||||
ctx.drawImage(image, canvasOffsetX, canvasOffsetY, image.width * canvasScale, image.height * canvasScale);
|
||||
|
||||
// Draw completed groups
|
||||
groups.forEach((group, gi) => {
|
||||
const color = GROUP_COLORS[gi % GROUP_COLORS.length];
|
||||
drawGroup(group.points, color);
|
||||
});
|
||||
|
||||
// Draw current group
|
||||
if (currentPoints.length > 0) {
|
||||
const color = GROUP_COLORS[groups.length % GROUP_COLORS.length];
|
||||
drawGroup(currentPoints, color, true);
|
||||
}
|
||||
}
|
||||
|
||||
function drawGroup(points, color, isCurrent) {
|
||||
if (points.length === 0) return;
|
||||
|
||||
// Draw lines
|
||||
if (points.length > 1) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
const p0 = toCanvas(points[0]);
|
||||
ctx.moveTo(p0.x, p0.y);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const p = toCanvas(points[i]);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw points
|
||||
points.forEach((pt, i) => {
|
||||
const p = toCanvas(pt);
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.fillText((i + 1).toString(), p.x + 7, p.y - 5);
|
||||
});
|
||||
|
||||
// "editing" indicator for current group
|
||||
if (isCurrent) {
|
||||
const last = toCanvas(points[points.length - 1]);
|
||||
ctx.beginPath();
|
||||
ctx.arc(last.x, last.y, 10, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
|
||||
function toCanvas(pt) {
|
||||
return {
|
||||
x: pt[0] * image.width * canvasScale + canvasOffsetX,
|
||||
y: pt[1] * image.height * canvasScale + canvasOffsetY
|
||||
};
|
||||
}
|
||||
|
||||
function toNorm(cx, cy) {
|
||||
return [
|
||||
Math.round(((cx - canvasOffsetX) / (image.width * canvasScale)) * 1000) / 1000,
|
||||
Math.round(((cy - canvasOffsetY) / (image.height * canvasScale)) * 1000) / 1000
|
||||
];
|
||||
}
|
||||
|
||||
// --- Click ---
|
||||
canvas.addEventListener('click', (e) => {
|
||||
if (!image) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left;
|
||||
const cy = e.clientY - rect.top;
|
||||
|
||||
// Check within image bounds
|
||||
const nx = (cx - canvasOffsetX) / (image.width * canvasScale);
|
||||
const ny = (cy - canvasOffsetY) / (image.height * canvasScale);
|
||||
if (nx < 0 || nx > 1 || ny < 0 || ny > 1) return;
|
||||
|
||||
const pt = toNorm(cx, cy);
|
||||
currentPoints.push(pt);
|
||||
redraw();
|
||||
renderCoordList();
|
||||
});
|
||||
|
||||
// --- Keyboard ---
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (currentPoints.length === 0) return;
|
||||
groups.push({ points: [...currentPoints] });
|
||||
currentPoints = [];
|
||||
redraw();
|
||||
renderCoordList();
|
||||
showToast(`第 ${groups.length} 组坐标已完成`);
|
||||
} else if (e.key === 'Backspace') {
|
||||
if (currentPoints.length > 0) {
|
||||
currentPoints.pop();
|
||||
redraw();
|
||||
renderCoordList();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Coord list ---
|
||||
function renderCoordList() {
|
||||
coordList.innerHTML = '';
|
||||
if (groups.length === 0 && currentPoints.length === 0) {
|
||||
coordList.innerHTML = '<div style="padding:16px;color:#4b5563;font-size:13px;text-align:center;">暂无坐标数据</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
groups.forEach((group, gi) => {
|
||||
const color = GROUP_COLORS[gi % GROUP_COLORS.length];
|
||||
const div = document.createElement('div');
|
||||
div.className = 'coord-group';
|
||||
div.innerHTML = `
|
||||
<div class="coord-group-header">
|
||||
<span><span class="coord-group-color" style="background:${color}"></span>第 ${gi + 1} 组 (${group.points.length} 点)</span>
|
||||
<div class="coord-group-actions">
|
||||
<button class="copy-btn" data-group="${gi}" title="复制">复制</button>
|
||||
<button class="del-btn" data-group="${gi}" title="删除">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre>${formatYAML(group.points)}</pre>
|
||||
`;
|
||||
coordList.appendChild(div);
|
||||
});
|
||||
|
||||
// Current editing group
|
||||
if (currentPoints.length > 0) {
|
||||
const gi = groups.length;
|
||||
const color = GROUP_COLORS[gi % GROUP_COLORS.length];
|
||||
const div = document.createElement('div');
|
||||
div.className = 'coord-group';
|
||||
div.style.borderColor = color;
|
||||
div.innerHTML = `
|
||||
<div class="coord-group-header">
|
||||
<span><span class="coord-group-color" style="background:${color}"></span>第 ${gi + 1} 组 (编辑中, ${currentPoints.length} 点)</span>
|
||||
</div>
|
||||
<pre>${formatYAML(currentPoints)}</pre>
|
||||
`;
|
||||
coordList.appendChild(div);
|
||||
}
|
||||
|
||||
// Bind copy/delete
|
||||
coordList.querySelectorAll('.copy-btn[data-group]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const gi = parseInt(btn.dataset.group);
|
||||
copyText(formatYAML(groups[gi].points));
|
||||
showToast('已复制到剪贴板');
|
||||
});
|
||||
});
|
||||
coordList.querySelectorAll('.del-btn[data-group]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const gi = parseInt(btn.dataset.group);
|
||||
groups.splice(gi, 1);
|
||||
redraw();
|
||||
renderCoordList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function formatYAML(points) {
|
||||
return points.map(pt => `- [${pt[0]}, ${pt[1]}]`).join('\n');
|
||||
}
|
||||
|
||||
// --- Copy all ---
|
||||
document.getElementById('copyAllBtn').addEventListener('click', () => {
|
||||
if (groups.length === 0) { showToast('暂无数据', true); return; }
|
||||
const all = groups.map((g, i) => `# 第 ${i + 1} 组\n${formatYAML(g.points)}`).join('\n\n');
|
||||
copyText(all);
|
||||
showToast('已复制全部坐标');
|
||||
});
|
||||
|
||||
// --- Utils ---
|
||||
function copyText(text) {
|
||||
navigator.clipboard.writeText(text).catch(() => {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
});
|
||||
}
|
||||
|
||||
let toastTimer = null;
|
||||
function showToast(msg, isError) {
|
||||
toast.textContent = msg;
|
||||
toast.style.background = isError ? '#ef4444' : '#10b981';
|
||||
toast.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => toast.classList.remove('show'), 1800);
|
||||
}
|
||||
|
||||
// Initial render
|
||||
renderCoordList();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
145
web_page_2/http_server.py
Normal file
145
web_page_2/http_server.py
Normal file
@@ -0,0 +1,145 @@
|
||||
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
|
||||
import os
|
||||
import urllib.parse
|
||||
import socket
|
||||
|
||||
class APIHandler(SimpleHTTPRequestHandler):
|
||||
# 设置超时,避免长时间占用连接
|
||||
timeout = 30
|
||||
|
||||
# MIME 类型映射
|
||||
MIME_TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.mp4': 'video/mp4',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
}
|
||||
|
||||
def log_message(self, format, *args):
|
||||
# 自定义日志格式
|
||||
print(f"[{self.log_date_time_string()}] {self.address_string()} - {format % args}")
|
||||
|
||||
def do_GET(self):
|
||||
try:
|
||||
# 解析路径和查询参数
|
||||
parsed_path = urllib.parse.urlparse(self.path)
|
||||
path = parsed_path.path
|
||||
query = parsed_path.query
|
||||
|
||||
# 检查是否是API接口
|
||||
api_param = None
|
||||
if path.startswith('/api/'):
|
||||
api_param = path[5:] # 提取 /api/ 后面的数字
|
||||
|
||||
# 生成带参数的index.html URL
|
||||
if api_param:
|
||||
# 使用HTML文件并附加api参数
|
||||
self.serve_file('index.html', query=f'api={api_param}')
|
||||
elif path == '/' or path == '/index.html':
|
||||
# 默认访问使用 api=1
|
||||
self.serve_file('index.html', query='api=1')
|
||||
elif path == '/coords' or path == '/coordinate.html':
|
||||
self.serve_file('coordinate.html')
|
||||
else:
|
||||
# 处理静态文件请求
|
||||
# 移除开头的 /
|
||||
filename = path.lstrip('/')
|
||||
if os.path.exists(filename):
|
||||
self.serve_static_file(filename)
|
||||
else:
|
||||
self.send_error(404, 'Not Found')
|
||||
except Exception as e:
|
||||
print(f"Error handling request: {e}")
|
||||
self.send_error(500, 'Internal Server Error')
|
||||
|
||||
def serve_file(self, filename, query=None):
|
||||
try:
|
||||
file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), filename)
|
||||
if os.path.exists(file_path):
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'text/html; charset=utf-8')
|
||||
self.end_headers()
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
content = f.read()
|
||||
|
||||
# 如果有查询参数,修改HTML中的URL参数解析部分
|
||||
if query:
|
||||
try:
|
||||
content = content.decode('utf-8')
|
||||
# 修改URL参数行,替换默认值为实际参数
|
||||
content = content.replace(
|
||||
"const apiParam = urlParams.get('api') || '1';",
|
||||
f"const apiParam = '{query.split('=')[1]}';"
|
||||
)
|
||||
content = content.encode('utf-8')
|
||||
except Exception as e:
|
||||
print(f"Error modifying HTML content: {e}")
|
||||
|
||||
self.wfile.write(content)
|
||||
else:
|
||||
self.send_error(404, f'{filename} not found')
|
||||
except Exception as e:
|
||||
print(f"Error serving file: {e}")
|
||||
raise
|
||||
|
||||
def serve_static_file(self, filename):
|
||||
"""提供静态文件服务"""
|
||||
try:
|
||||
# 获取文件扩展名
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
content_type = self.MIME_TYPES.get(ext, 'application/octet-stream')
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', content_type)
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.end_headers()
|
||||
|
||||
with open(filename, 'rb') as f:
|
||||
content = f.read()
|
||||
|
||||
self.wfile.write(content)
|
||||
except Exception as e:
|
||||
print(f"Error serving static file: {e}")
|
||||
self.send_error(500, 'Internal Server Error')
|
||||
|
||||
def check_port_available(port):
|
||||
"""检查端口是否可用"""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
try:
|
||||
s.bind(('', port))
|
||||
return True
|
||||
except socket.error:
|
||||
return False
|
||||
|
||||
def run():
|
||||
port = 8086
|
||||
if not check_port_available(port):
|
||||
print(f"错误: 端口 {port} 已被占用")
|
||||
return
|
||||
|
||||
server_address = ('', port)
|
||||
httpd = ThreadingHTTPServer(server_address, APIHandler)
|
||||
print(f'Server running on http://localhost:{port}')
|
||||
print(f'支持的接口: /, /api/1, /api/2, /api/3, /api/4, /api/5, /api/6, /api/7, /api/11-16')
|
||||
print(f'坐标提取工具: /coords')
|
||||
print('按 Ctrl+C 停止服务器')
|
||||
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print('\n服务器已停止')
|
||||
httpd.server_close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
446
web_page_2/index.html
Normal file
446
web_page_2/index.html
Normal file
@@ -0,0 +1,446 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>AI督察系统</title>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
color: #e5e7eb;
|
||||
overflow: hidden;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.app-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #111827;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.log-panel {
|
||||
height: 50%;
|
||||
background: #020617;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.log-header {
|
||||
padding: 8px 12px;
|
||||
background: #0f172a;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.log-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
.log-entry {
|
||||
margin-bottom: 6px;
|
||||
padding: 4px 6px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.log-info {
|
||||
color: #60a5fa;
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
}
|
||||
.log-success {
|
||||
color: #34d399;
|
||||
background: rgba(52, 211, 153, 0.1);
|
||||
}
|
||||
.log-warning {
|
||||
color: #fbbf24;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
}
|
||||
.log-error {
|
||||
color: #f87171;
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
}
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.left-panel {
|
||||
width: 320px;
|
||||
border-right: 1px solid #1f2937;
|
||||
background: #020617;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar {
|
||||
height: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-header {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.message-item {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #111827;
|
||||
cursor: pointer;
|
||||
}
|
||||
.message-item:hover {
|
||||
background: #111827;
|
||||
}
|
||||
.message-item.active {
|
||||
background: #1f2937;
|
||||
}
|
||||
.message-title {
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.message-meta {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #000;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
#liveImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
.status-bar {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
bottom: 12px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
font-size: 12px;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.action-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
margin-right: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.action-action {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
.action-face {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #60a5fa;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
.enable-sound-btn {
|
||||
padding: 4px 12px;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.enable-sound-btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
.enable-sound-btn:disabled {
|
||||
background: #1f2937;
|
||||
color: #6b7280;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<main>
|
||||
<aside class="left-panel">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header" style="display: flex; align-items: center; justify-content: space-between;">
|
||||
异常消息
|
||||
<button id="enableSoundBtn" class="enable-sound-btn">🔔 启用提示音</button>
|
||||
</div>
|
||||
<div id="messageList" class="message-list"></div>
|
||||
</div>
|
||||
<div class="log-panel">
|
||||
<div class="log-header">系统日志</div>
|
||||
<div id="logContent" class="log-content"></div>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="content">
|
||||
<img id="liveImage" alt="live" style="display: none;" />
|
||||
<div id="status" class="status-bar">连接中...</div>
|
||||
<div id="waitingPlaceholder" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #6b7280; font-size: 16px; text-align: center;">
|
||||
<div>正在连接 WebSocket...</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 从URL参数获取配置,默认使用 api=1 的配置
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const apiParam = urlParams.get('api') || '1';
|
||||
|
||||
// 配置映射表
|
||||
const configMap = {
|
||||
'1': { port: 8765 },
|
||||
'2': { port: 8767 },
|
||||
'3': { port: 8769 },
|
||||
'4': { port: 8771 },
|
||||
'5': { port: 8767 },
|
||||
'11': { port: 8501 },
|
||||
'12': { port: 8502 },
|
||||
'13': { port: 8503 },
|
||||
'14': { port: 8504 },
|
||||
'15': { port: 8505 },
|
||||
'16': { port: 8506 },
|
||||
'17': { port: 8511 },
|
||||
'18': { port: 8512 }
|
||||
};
|
||||
|
||||
// 获取当前配置,如果找不到则使用默认配置
|
||||
const config = configMap[apiParam] || { port: 8765 };
|
||||
|
||||
const WS_PORT = config.port;
|
||||
const WS_HOST = '29.1.70.11';
|
||||
<!-- const WS_HOST = '127.0.0.1'; -->
|
||||
|
||||
const liveImage = document.getElementById('liveImage');
|
||||
const statusBar = document.getElementById('status');
|
||||
const messageListEl = document.getElementById('messageList');
|
||||
const logContent = document.getElementById('logContent');
|
||||
|
||||
let alerts = [];
|
||||
let ws = null;
|
||||
let wsConnected = false;
|
||||
let currentDetectedActions = [];
|
||||
let audioEnabled = false;
|
||||
let alertAudio = null;
|
||||
|
||||
function setMode(newMode) {
|
||||
// 模式切换功能已禁用
|
||||
}
|
||||
|
||||
function enableSound() {
|
||||
alertAudio = new Audio('/sfx_alert.mp3');
|
||||
alertAudio.volume = 0.5;
|
||||
|
||||
alertAudio.play().then(() => {
|
||||
audioEnabled = true;
|
||||
const btn = document.getElementById('enableSoundBtn');
|
||||
btn.textContent = '🔔 提示音已启用';
|
||||
btn.disabled = true;
|
||||
addLog('提示音已启用', 'success');
|
||||
|
||||
alertAudio.currentTime = 0;
|
||||
alertAudio.pause();
|
||||
}).catch(err => {
|
||||
console.error('音频播放失败:', err);
|
||||
addLog('提示音启用失败,请检查音频文件', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function playAlertSound() {
|
||||
if (audioEnabled && alertAudio) {
|
||||
alertAudio.currentTime = 0;
|
||||
alertAudio.play().catch(err => {
|
||||
console.error('音频播放失败:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderMessages() {
|
||||
messageListEl.innerHTML = '';
|
||||
for (const msg of alerts.slice().reverse()) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message-item';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'message-title';
|
||||
title.textContent = `检测到异常: `;
|
||||
|
||||
msg.result_type.forEach(action => {
|
||||
const tag = document.createElement('span');
|
||||
tag.className = 'action-tag';
|
||||
tag.classList.add('action-action');
|
||||
const actionMap = {
|
||||
'face': '检测到黑名单',
|
||||
'Slap': '扇巴掌',
|
||||
'LeavingPost': '离岗',
|
||||
'Collision': '撞击',
|
||||
'Push': '推搡',
|
||||
'Lyingdown': '倒下',
|
||||
'Hanging': '自缢',
|
||||
'violation': '路线违规',
|
||||
'prisoner': '检测到犯人',
|
||||
'Only One': '单人单检',
|
||||
'Nobody': '无人检查',
|
||||
'Trunk Checked': '检查后备箱',
|
||||
'Playing Phone': '玩手机',
|
||||
'Unvaild Uniform!!': '违规着装',
|
||||
'Unchecked Trunk': '未检查后备箱',
|
||||
'Ignore': '漏检',
|
||||
'Indoor Violation': '违规进入区域'
|
||||
};
|
||||
tag.textContent = actionMap[action] || action;
|
||||
title.appendChild(tag);
|
||||
});
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'message-meta';
|
||||
const date = new Date(msg.timestamp * 1000);
|
||||
meta.textContent = date.toLocaleString();
|
||||
|
||||
div.appendChild(title);
|
||||
div.appendChild(meta);
|
||||
|
||||
messageListEl.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
function addLog(message, type = 'info') {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.className = `log-entry log-${type}`;
|
||||
logEntry.textContent = `[${timestamp}] ${message}`;
|
||||
|
||||
logContent.appendChild(logEntry);
|
||||
logContent.scrollTop = logContent.scrollHeight;
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const wsUrl = `ws://${WS_HOST}:${WS_PORT}`;
|
||||
statusBar.textContent = '连接中...';
|
||||
|
||||
addLog(`正在连接WebSocket: ${wsUrl}`, 'info');
|
||||
addLog(`当前配置: api=${apiParam}, port=${WS_PORT}`, 'info');
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
wsConnected = true;
|
||||
statusBar.textContent = '直播中';
|
||||
addLog('WebSocket连接成功', 'success');
|
||||
|
||||
const placeholder = document.getElementById('waitingPlaceholder');
|
||||
if (placeholder) {
|
||||
placeholder.remove();
|
||||
}
|
||||
liveImage.style.display = 'block';
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
wsConnected = false;
|
||||
statusBar.textContent = '连接断开,重试中...';
|
||||
addLog('WebSocket连接断开,2秒后重试', 'warning');
|
||||
setTimeout(() => {
|
||||
connectWebSocket();
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.error('WebSocket error', err);
|
||||
addLog('WebSocket连接错误', 'error');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
if (msg.msg_type === 'frame') {
|
||||
if (msg.image_base64) {
|
||||
liveImage.src = `data:image/jpeg;base64,${msg.image_base64}`;
|
||||
}
|
||||
|
||||
currentDetectedActions = msg.result_type || [];
|
||||
|
||||
if (currentDetectedActions.length > 0) {
|
||||
const actionText = currentDetectedActions.join(', ');
|
||||
statusBar.textContent = `直播中 | 检测到: ${actionText}`;
|
||||
} else {
|
||||
statusBar.textContent = '直播中';
|
||||
}
|
||||
|
||||
if (msg.result_type && msg.result_type.length > 0) {
|
||||
const alertMsg = {
|
||||
camera_id: msg.camera_id,
|
||||
timestamp: msg.timestamp,
|
||||
result_type: msg.result_type
|
||||
};
|
||||
|
||||
<!-- const now = msg.timestamp;-->
|
||||
<!-- const timeThreshold = now - 10;-->
|
||||
|
||||
<!-- const existingAlert = alerts.find(alert =>-->
|
||||
<!-- JSON.stringify(alert.result_type) === JSON.stringify(alertMsg.result_type) &&-->
|
||||
<!-- alert.timestamp > timeThreshold-->
|
||||
<!-- );-->
|
||||
|
||||
<!-- if (!existingAlert) {-->
|
||||
alerts.push(alertMsg);
|
||||
playAlertSound();
|
||||
renderMessages();
|
||||
<!-- }-->
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('invalid ws message', e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 初始化
|
||||
document.getElementById('enableSoundBtn').addEventListener('click', enableSound);
|
||||
|
||||
addLog('AI督察系统启动', 'info');
|
||||
addLog('界面初始化完成', 'success');
|
||||
addLog(`接口参数: api=${apiParam}`, 'info');
|
||||
|
||||
setTimeout(() => {
|
||||
connectWebSocket();
|
||||
}, 500);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
web_page_2/sfx_alert.mp3
Normal file
BIN
web_page_2/sfx_alert.mp3
Normal file
Binary file not shown.
Reference in New Issue
Block a user