Compare commits

...

10 Commits

Author SHA1 Message Date
zqc
a488ee812f 完成初版选点提取坐标 2026-04-24 10:03:46 +08:00
zqc
a38b27a78d indoor_biz增加帧回溯(未测试) 2026-04-17 10:17:31 +08:00
zqc
a327dd0339 新增base_detector,将帧回溯放入其中 2026-04-17 10:06:36 +08:00
1f00f8f3f7 删除报警相关逻辑 2026-04-14 11:20:02 +08:00
f2e2569b7c 更新跟踪框匹配逻辑 2026-04-13 20:04:16 +08:00
e7e2b86cd7 添加我的代码 2026-04-10 21:43:08 +08:00
zqc
4259774365 indoor biz增加todo 2026-04-10 15:58:48 +08:00
zqc
aa4f167840 完成视频浏览 2026-04-02 13:17:30 +08:00
zqc
68d6849120 完成final接口适配 2026-04-02 12:30:58 +08:00
zqc
bfbe69eeb5 完成获取子节点接口适配 2026-04-02 12:19:20 +08:00
7 changed files with 1022 additions and 188 deletions

62
biz/base_detector.py Normal file
View 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

View File

@@ -1,16 +1,14 @@
import cv2 import cv2
import numpy as np import numpy as np
from typing import Dict, Any from typing import Dict, Any
import threading
import queue
from collections import deque
from biz.base_frame_processor import BaseFrameProcessorWorker from biz.base_frame_processor import BaseFrameProcessorWorker
from biz.base_detector import BaseDetector
# -------------------------- Kadian 检测相关导入 -------------------------- # -------------------------- 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 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 yolox.tracker.byte_tracker import BYTETracker
from utils.logger import get_logger from utils.logger import get_logger
@@ -55,8 +53,9 @@ PERSON_CAR_INPUT_SIZE = 640
RTSP_TARGET_FPS = 10.0 RTSP_TARGET_FPS = 10.0
# ========================= Kadian TrafficMonitor精简版专为服务设计 ========================= # ========================= Kadian TrafficMonitor精简版专为服务设计 =========================
class KadianDetector: class KadianDetector(BaseDetector):
def __init__(self, params=None): def __init__(self, params=None):
super().__init__()
# 摄像头额外参数 # 摄像头额外参数
self.params = params if params is not None else {} self.params = params if params is not None else {}
@@ -147,8 +146,8 @@ class KadianDetector:
self.nobody_frames = 0 # 累计无人在场帧数 self.nobody_frames = 0 # 累计无人在场帧数
self.only_one_frames = 0 # 累计单人在场帧数 self.only_one_frames = 0 # 累计单人在场帧数
self.max_car_frames = int((15.0 + self.TIME_TOLERANCE_CAR) * self.fps) # buffer_seconds = 15.0 + self.TIME_TOLERANCE_CAR
self.frame_buffer_ignore_untrunk = deque(maxlen=self.max_car_frames) self.init_frame_buffer(buffer_seconds, self.fps)
self.untrunk_rollback_time = 12.0 # 未检查后备箱需要回溯的时间 self.untrunk_rollback_time = 12.0 # 未检查后备箱需要回溯的时间
self.ignored_rollback_time = 12.0 # 漏检需要回溯的时间 self.ignored_rollback_time = 12.0 # 漏检需要回溯的时间
@@ -219,21 +218,6 @@ class KadianDetector:
x1, y1, x2, y2 = box x1, y1, x2, y2 = box
return x1 < px < x2 and y1 < py < y2 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['timestamp'] - 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]: def process_frame(self, frame, camera_id: int, timestamp: float) -> Dict[str, Any]:
h, w = frame.shape[:2] h, w = frame.shape[:2]
self.width, self.height = w, h self.width, self.height = w, h
@@ -402,11 +386,7 @@ class KadianDetector:
cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
# 每帧保存到缓存(移到循环外,确保每帧只写入一次) # 每帧保存到缓存(移到循环外,确保每帧只写入一次)
self.frame_buffer_ignore_untrunk.append({ self.append_frame(frame, current_time_sec)
'frame_idx': self.current_frame_idx,
'timestamp': current_time_sec,
'frame': frame.copy(),
})
# ========================================== # ==========================================
# 关联分析: 哪个后备箱属于哪辆车? # 关联分析: 哪个后备箱属于哪辆车?

View File

@@ -1,7 +1,10 @@
import cv2 import cv2
import numpy as np import numpy as np
import time import time
import requests # import requests
from collections import deque
from biz.base_detector import BaseDetector
from biz.base_frame_processor import BaseFrameProcessorWorker from biz.base_frame_processor import BaseFrameProcessorWorker
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 yolox.tracker.byte_tracker import BYTETracker
@@ -12,21 +15,26 @@ DETECT_MODEL_PATH = 'YOLO_Weight/kanshousuo.onnx' # 犯人检测onnx模型路
INPUT_SIZE = 640 # 模型输入尺寸 INPUT_SIZE = 640 # 模型输入尺寸
RTSP_FPS = 10 # 视频流目标FPS RTSP_FPS = 10 # 视频流目标FPS
ALERT_PUSH_INTERVAL = 5 # 相同报警5秒内仅推送1次 ALERT_PUSH_INTERVAL = 5 # 相同报警5秒内仅推送1次
ALERT_PUSH_URL = "http://123.57.151.210:10000/picenter/websocket/test/process" # ALERT_PUSH_URL = "http://123.57.151.210:10000/picenter/websocket/test/process"
# 消失判定中心点在ROI内消失后持续无检测的帧数1.0秒,可微调) # 消失判定中心点在ROI内消失后持续无检测的帧数1.0秒,可微调)
ROI_LOST_FRAMES_THRESH = int(0.5 * RTSP_FPS) ROI_LOST_FRAMES_THRESH = int(0.5 * RTSP_FPS) # todo: 从frame改为时间
# ========================= 默认ROI区域配置当config.yaml未配置时使用 ========================= # ========================= 默认ROI区域配置当config.yaml未配置时使用 =========================
DEFAULT_DOOR_ROIS = { DEFAULT_DOOR_ROIS = {
"left_door_1": { "left": {
"points": [[0.195, 0.242], [0.265, 0.17], [0.3, 0.63], [0.248, 0.8]], "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] "color": [255, 0, 0]
} }
} }
# ================================================================================== # ==================================================================================
class PrisonerDoorDetector: class PrisonerDoorDetector(BaseDetector):
def __init__(self, params=None): def __init__(self, params=None):
super().__init__()
self.params = params or {} self.params = params or {}
# 0. 从params解析ROI配置无则使用默认值 # 0. 从params解析ROI配置无则使用默认值
@@ -45,26 +53,48 @@ class PrisonerDoorDetector:
self.detector = YOLOv8_ONNX( self.detector = YOLOv8_ONNX(
full_model_path, full_model_path,
conf_threshold=0.5, # 置信度阈值,可根据模型精度调整 conf_threshold=0.7, # 置信度阈值,可根据模型精度调整
iou_threshold=0.45, # IOU阈值 iou_threshold=0.4, # IOU阈值
input_size=INPUT_SIZE input_size=INPUT_SIZE
) )
# 2. 初始化ByteTracker跟踪器适配走廊单/多犯人跟踪) # 2. 初始化ByteTracker跟踪器适配走廊单/多犯人跟踪)
class TrackerArgs: class TrackerArgs:
track_thresh = 0.25 track_thresh = 0.65
track_buffer = 20 # 减小缓冲避免跟踪漂移 track_buffer = 60 # 减小缓冲避免跟踪漂移
match_thresh = 0.75 match_thresh = 0.5
mot20 = False mot20 = False
self.tracker = BYTETracker(TrackerArgs(), frame_rate=RTSP_FPS) self.tracker = BYTETracker(TrackerArgs(), frame_rate=RTSP_FPS)
# 3. 状态变量初始化 # 3. 状态变量初始化
self.last_alert_time = 0.0 # 最后报警时间(防重复推送) # self.last_alert_time = 0.0 # 最后报警时间(防重复推送)
# 犯人跟踪信息:{track_id: {'is_cx_in_roi': 中心点是否在ROI, 'lost_frames': 消失帧数, 'lost_roi': 消失的ROI名称, 'last_cxcy': 最后中心点坐标}} # 犯人跟踪信息:{track_id: {'is_cx_in_roi': 中心点是否在ROI, 'lost_frames': 消失帧数, 'lost_roi': 消失的ROI名称, 'last_cxcy': 最后中心点坐标}}
self.prisoner_track_info = {} self.prisoner_track_info = {}
self.frame_width = 0 # 帧宽度(动态获取) self.frame_width = 0 # 帧宽度(动态获取)
self.frame_height = 0 # 帧高度(动态获取) self.frame_height = 0 # 帧高度(动态获取)
self.roi_abs_cache = {} # ROI绝对坐标缓存{roi_name: np.int32数组} 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): def compute_iou(self, boxA, boxB):
"""IOU计算匹配跟踪框与犯人检测框过滤非犯人目标""" """IOU计算匹配跟踪框与犯人检测框过滤非犯人目标"""
@@ -96,32 +126,81 @@ class PrisonerDoorDetector:
return (True, roi_name) return (True, roi_name)
return (False, "outside") return (False, "outside")
# def push_alert(self, camera_id, track_id, lost_roi, last_cxcy, timestamp): def match_detection_to_target(self, detection_box, detection_conf):
# """报警推送带频率限制携带消失ROI、最后中心点坐标""" """
【核心】将检测框匹配到已有目标
返回: (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() # current_time = time.time()
# if current_time - self.last_alert_time < ALERT_PUSH_INTERVAL: # if current_time - self.last_alert_time < ALERT_PUSH_INTERVAL:
# return False # return False
# # 构造报警信息(可根据平台要求扩展字段) #
# _, frame_encoded = cv2.imencode('.jpg', entry_frame)
# frame_base64 = frame_encoded.tobytes()
#
# alert_info = { # alert_info = {
# "camera_id": camera_id, # "camera_id": camera_id,
# "alert_type": "prisoner_cx_disappear_in_roi", # "alert_type": "prisoner_cx_disappear_in_roi",
# "prisoner_track_id": track_id, # "prisoner_track_id": target_id,
# "disappear_roi": lost_roi, # "disappear_roi": lost_roi,
# "last_cx": round(last_cxcy[0], 2), # "last_cx": round(last_cxcy[0], 2),
# "last_cy": round(last_cxcy[1], 2), # "last_cy": round(last_cxcy[1], 2),
# "timestamp": timestamp, # "timestamp": timestamp,
# "details": f"犯人框中心点在{lost_roi}区域内消失,触发报警" # "entry_frame_base64": frame_base64,
# "details": f"犯人框中心点在{lost_roi}区域内消失"
# } # }
# # 推送报警请求 #
# try: # try:
# requests.post(ALERT_PUSH_URL, json=alert_info, timeout=3) # requests.post(ALERT_PUSH_URL, json=alert_info, timeout=3)
# print(f"[报警成功] {alert_info}") # print(f"[报警成功] target_id={target_id}, roi={lost_roi}")
# self.last_alert_time = current_time # self.last_alert_time = current_time
# return True # return True
# except Exception as e: # except Exception as e:
# print(f"[报警失败] 原因:{str(e)}") # print(f"[报警失败] {str(e)}")
# return False # return False
def process_frame(self, frame, camera_id: int, timestamp: float) -> dict: def process_frame(self, frame, camera_id: int, timestamp: float) -> dict:
""" """
核心帧处理: 核心帧处理:
@@ -130,6 +209,8 @@ class PrisonerDoorDetector:
""" """
self.frame_height, self.frame_width = frame.shape[:2] self.frame_height, self.frame_width = frame.shape[:2]
current_frame_alerts = [] # 本帧报警信息 current_frame_alerts = [] # 本帧报警信息
frame_copy = frame.copy()
current_time = time.time()
# ========================= 1. 初始化ROI绝对坐标并绘制ROI ========================= # ========================= 1. 初始化ROI绝对坐标并绘制ROI =========================
self.roi_abs_cache.clear() self.roi_abs_cache.clear()
@@ -147,98 +228,260 @@ class PrisonerDoorDetector:
# ========================= 2. 模型推理:仅提取犯人检测框 ========================= # ========================= 2. 模型推理:仅提取犯人检测框 =========================
detect_results = self.detector(frame) detect_results = self.detector(frame)
prisoner_dets_xyxy = [] # 仅存犯人检测框 [x1,y1,x2,y2] prisoner_detections = []
dets_for_tracker = [] # 跟踪器输入 [x1,y1,x2,y2,conf]
if detect_results: if detect_results:
for det in detect_results: for det in detect_results:
x1, y1, x2, y2, conf, cls_id = det x1, y1, x2, y2, conf, cls_id = det
dets_for_tracker.append([x1, y1, x2, y2, conf]) # 确保坐标在图像范围内
# 替换为你模型中「犯人」的实际类别ID此处默认cls_id=1 x1 = max(0, min(x1, self.frame_width - 1))
if cls_id == 1: y1 = max(0, min(y1, self.frame_height - 1))
prisoner_dets_xyxy.append([x1, y1, x2, y2]) x2 = max(0, min(x2, self.frame_width - 1))
y2 = max(0, min(y2, self.frame_height - 1))
# ========================= 3. 目标跟踪:更新犯人跟踪结果 ========================= if cls_id == 1 and x2 > x1 and y2 > y1 and (x2 - x1) * (y2 - y1) > 100: # 过滤太小的框
dets_np = np.array(dets_for_tracker, dtype=np.float32) if dets_for_tracker else np.empty((0, 5)) prisoner_detections.append([x1, y1, x2, y2, conf, cls_id])
track_results = self.tracker.update(dets_np, [self.frame_height, self.frame_width],
[self.frame_height, self.frame_width]) # ========================= 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()
# ========================= 4. 遍历跟踪结果判定犯人中心点是否在ROI =========================
current_prisoner_tids = set() # 本帧存在的犯人track_id
for track in track_results: for track in track_results:
track_id = track.track_id track_id = track.track_id
track_box = list(map(float, track.tlbr)) # 跟踪框 [x1,y1,x2,y2] t_box = [float(x) for x in track.tlbr]
# IOU匹配过滤非犯人目标仅保留真正的犯人
is_prisoner = False # 寻找匹配的检测框
for p_box in prisoner_dets_xyxy: best_iou = 0.0 # 最低阈值
if self.compute_iou(track_box, p_box) > 0.3: best_det_idx = -1
is_prisoner = True
break for det_idx, det in enumerate(prisoner_detections):
if not is_prisoner: if det_idx in used_det_indices:
continue 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:
cx = (track_box[0] + track_box[2]) / 2 # 跟踪框有对应的检测框,使用检测框(更准确)
cy = (track_box[1] + track_box[3]) / 2 tracked_detections[f"track_{track_id}"] = {
# 判定中心点是否在ROI内返回(是否在ROI, 所在ROI名称) 'box': prisoner_detections[best_det_idx][:4],
is_cx_in_roi, current_roi = self.is_cxcy_in_roi(cx, cy) 'conf': prisoner_detections[best_det_idx][4],
# 更新犯人跟踪信息记录中心点状态、所在ROI、最后坐标重置消失帧数 'source': 'tracked'
self.prisoner_track_info[track_id] = { }
"is_cx_in_roi": is_cx_in_roi, used_det_indices.add(best_det_idx)
"lost_frames": 0, else:
"lost_roi": current_roi, # 跟踪框没有对应的检测框,但仍保留跟踪框
"last_cxcy": (cx, cy) tracked_detections[f"track_{track_id}"] = {
'box': t_box,
'conf': 0.5, # 给个中等置信度
'source': 'track_only'
} }
current_prisoner_tids.add(track_id)
# 绘制犯人框+中心点+状态标签(可视化调试) # 4.2 处理未被跟踪的检测框
x1, y1, x2, y2 = map(int, track_box) for det_idx, det in enumerate(prisoner_detections):
cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 0, 255), 2) # 红色犯人框 if det_idx not in used_det_indices:
cv2.circle(frame, (int(cx), int(cy)), 5, (0, 255, 255), -1) # 黄色中心点 tracked_detections[f"det_{det_idx}"] = {
cv2.putText(frame, f"Prisoner_{track_id}({current_roi})", (x1, y1 - 10), 'box': det[:4],
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2) 'conf': det[4],
'source': 'det_only'
}
# ========================= 5. 核心判定中心点在ROI内消失则报警 ========================= # ========================= 5. 匹配到已有目标 =========================
for track_id in list(self.prisoner_track_info.keys()): current_target_ids = set()
if track_id not in current_prisoner_tids: matched_det_keys = set()
# 犯人本帧消失,获取其最后状态
track_info = self.prisoner_track_info[track_id] for det_key, det_info in tracked_detections.items():
# 仅处理「**中心点原本在ROI内**」的消失情况 det_box = det_info['box']
if track_info["is_cx_in_roi"]: det_conf = det_info['conf']
track_info["lost_frames"] += 1 # 累计消失帧数
# 消失帧数达到阈值,触发报警 # 计算中心点
if track_info["lost_frames"] >= ROI_LOST_FRAMES_THRESH: 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( # self.push_alert(
# camera_id=camera_id, # camera_id=camera_id,
# track_id=track_id, # target_id=target_id,
# lost_roi=track_info["lost_roi"], # lost_roi=target_info['current_roi'],
# last_cxcy=track_info["last_cxcy"], # last_cxcy=target_info['last_cxcy'],
# timestamp=timestamp # timestamp=timestamp,
# entry_frame=entry_frame
# ) # )
# 记录本帧报警信息 alert_frame = self.find_target_frame(timestamp - self.detect_rollback_time)
current_frame_alerts.append({ current_frame_alerts.append({
"time": timestamp, "time": timestamp,
"camera_id": camera_id, "camera_id": camera_id,
"action": "Indoor Violation", "action": "Indoor Violation",
"prisoner_track_id": track_id, 'image': alert_frame,
"disappear_roi": track_info["lost_roi"], "prisoner_track_id": target_id,
"last_cx": round(track_info["last_cxcy"][0], 2), "disappear_roi": target_info['current_roi'],
"last_cy": round(track_info["last_cxcy"][1], 2) "last_cx": round(target_info['last_cxcy'][0], 2),
"last_cy": round(target_info['last_cxcy'][1], 2)
}) })
del self.prisoner_track_info[track_id] # 报警后清除状态,避免重复触发
else:
del self.prisoner_track_info[track_id] # 中心点不在ROI的消失直接清除
# ========================= 6. 绘制辅助信息摄像头ID、在押犯人数 ========================= # 清理
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.putText(frame, f"Camera: {camera_id}", (20, self.frame_height - 20),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
cv2.putText(frame, f"Prisoners: {len(current_prisoner_tids)}", (20, self.frame_height - 50), 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) cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 0), 2)
self.append_frame(frame, timestamp)
return {"image": frame, "alerts": current_frame_alerts} return {"image": frame, "alerts": current_frame_alerts}
# ========================= 帧处理线程(对接原有框架,直接复用) =========================
# ========================= 帧处理线程 =========================
class FrameProcessorWorker(BaseFrameProcessorWorker): class FrameProcessorWorker(BaseFrameProcessorWorker):
"""看守所走廊犯人检测 - 5ROI+中心点消失判定""" """看守所走廊犯人检测 - 增强跟踪版"""
DETECTOR_FACTORY = lambda params: PrisonerDoorDetector(params) DETECTOR_FACTORY = lambda params: PrisonerDoorDetector(params)
POST_TYPE = 3 # 与原有业务区分,自定义即可 POST_TYPE = 3
TARGET_FPS = RTSP_FPS TARGET_FPS = RTSP_FPS

View File

@@ -150,6 +150,63 @@
overflow: hidden; 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 { #video-container {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -196,9 +253,28 @@
</div> </div>
</div> </div>
<div class="video-area"> <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 id="video-container">
<div class="placeholder">👈 从左侧选择一个直播源</div> <div class="placeholder">👈 从左侧选择一个直播源</div>
</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>
</div> </div>
@@ -209,6 +285,7 @@
// 全局状态 // 全局状态
let currentVideoNode = null; // 当前播放的节点对象 let currentVideoNode = null; // 当前播放的节点对象
let hls = null; // HLS 实例 let hls = null; // HLS 实例
let currentStreamType = 0; // 当前码流类型
// 缓存已加载的子节点数据: { parentId: [childrenNodes] } // 缓存已加载的子节点数据: { parentId: [childrenNodes] }
const childrenCache = new Map(); const childrenCache = new Map();
@@ -243,13 +320,12 @@
} }
} }
// 获取节点详情(这里主要用来获取视频流地址,但也可以直接用子节点数据,不过为符合 API 设计,单独调用 stream 接口) // 获取视频流地址
async function fetchStreamUrl(nodeId) { async function fetchStreamUrl(nodeId, streamType = 0) {
try { try {
const res = await fetch(`${API_BASE}/stream/${nodeId}`); const res = await fetch(`${API_BASE}/stream/${nodeId}?stream_type=${streamType}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json(); return await res.json();
return data.url;
} catch (err) { } catch (err) {
console.error(`获取节点 ${nodeId} 的视频地址失败:`, err); console.error(`获取节点 ${nodeId} 的视频地址失败:`, err);
return null; return null;
@@ -257,20 +333,26 @@
} }
// 播放视频 // 播放视频
async function playVideo(node) { async function playVideo(node, streamType = 0) {
if (!node || !node.is_leaf) return; if (!node || !node.is_leaf) return;
// 显示加载占位 // 显示加载占位
const container = document.getElementById('video-container'); const container = document.getElementById('video-container');
container.innerHTML = '<div class="placeholder">📡 正在加载直播流...</div>'; container.innerHTML = '<div class="placeholder">📡 正在加载直播流...</div>';
// 隐藏信息和控制栏
document.getElementById('video-controls').style.display = 'none';
document.getElementById('video-info').style.display = 'none';
// 获取流地址 // 获取流地址
const streamUrl = await fetchStreamUrl(node.id); const streamData = await fetchStreamUrl(node.id, streamType);
if (!streamUrl) { if (!streamData || !streamData.url) {
container.innerHTML = '<div class="placeholder">❌ 无法获取视频地址,请稍后重试</div>'; container.innerHTML = '<div class="placeholder">❌ 无法获取视频地址,请稍后重试</div>';
return; return;
} }
const streamUrl = streamData.url;
// 清理旧播放器 // 清理旧播放器
if (hls) { if (hls) {
hls.destroy(); hls.destroy();
@@ -313,6 +395,14 @@
}); });
} }
// 更新并显示视频信息
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; currentVideoNode = node;
} }
@@ -388,7 +478,7 @@
// 高亮当前选中的节点 // 高亮当前选中的节点
clearActiveHighlight(); clearActiveHighlight();
contentDiv.classList.add('active'); contentDiv.classList.add('active');
await playVideo(node); await playVideo(node, currentStreamType);
return; return;
} }
@@ -442,7 +532,14 @@
// 初始化视频区域(可选:尝试自动播放需用户交互) // 初始化视频区域(可选:尝试自动播放需用户交互)
function initVideoArea() { function initVideoArea() {
// 预留 // 监听码流类型切换
document.getElementById('stream-type').addEventListener('change', async (e) => {
currentStreamType = parseInt(e.target.value);
// 如果当前有播放的视频,重新加载
if (currentVideoNode) {
await playVideo(currentVideoNode, currentStreamType);
}
});
} }
// 启动 // 启动

View File

@@ -3,48 +3,88 @@ import urllib.parse
import socket import socket
import json import json
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler 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"
# {
# "id": 1,
# "name": "河北省",
# "parent_id": None, # None 表示根节点
# "is_leaf": False,
# "stream_url": None # 叶子节点才会有值
# }
nodes = { def transform_org_node(item, parent_id=None):
1: {"id": 1, "name": "河北省", "parent_id": None, "is_leaf": False, "stream_url": None}, """将海康威视组织节点转换为前端期望的格式"""
2: {"id": 2, "name": "河南省", "parent_id": None, "is_leaf": False, "stream_url": None}, return {
3: {"id": 3, "name": "石家庄市", "parent_id": 1, "is_leaf": False, "stream_url": None}, "id": item["indexCode"],
4: {"id": 4, "name": "保定市", "parent_id": 1, "is_leaf": False, "stream_url": None}, "name": item["name"],
5: {"id": 5, "name": "郑州市", "parent_id": 2, "is_leaf": False, "stream_url": None}, "parent_id": parent_id or item.get("parentIndexCode"),
6: {"id": 6, "name": "长安区", "parent_id": 3, "is_leaf": True, "is_leaf": False, # 组织机构节点不是叶子节点
"stream_url": "http://localhost:8355/stream.m3u8"}, "stream_url": None
7: {"id": 7, "name": "桥西区", "parent_id": 3, "is_leaf": True, }
"stream_url": "https://example.com/live/qiaoxi.m3u8"},
8: {"id": 8, "name": "竞秀区", "parent_id": 4, "is_leaf": True, def transform_camera_node(item, parent_id=None):
"stream_url": "https://example.com/live/jingxiu.m3u8"}, """将海康威视摄像头节点转换为前端期望的格式(叶子节点)"""
9: {"id": 9, "name": "莲池区", "parent_id": 4, "is_leaf": True, return {
"stream_url": "https://example.com/live/lianchi.m3u8"}, "id": item["cameraIndexCode"],
10: {"id": 10, "name": "中原区", "parent_id": 5, "is_leaf": True, "name": item["name"],
"stream_url": "https://example.com/live/zhongyuan.m3u8"}, "parent_id": parent_id,
"is_leaf": True, # 摄像头是叶子节点
"stream_url": None
} }
def get_children(parent_id): def get_children(parent_id):
"""返回父节点下的直接子节点列表""" """返回父节点下的直接子节点列表(从海康威视 API 获取)
return [node for node in nodes.values() if node["parent_id"] == parent_id]
def get_node(node_id): 逻辑:
"""根据 id 获取节点详情""" 1. 先调用 get_organization_list 获取子组织
return nodes.get(node_id) 2. 如果返回 list 为空,则调用 get_final_list 获取摄像头(叶子节点)
"""
if parent_id is None:
parent_id = ROOT_PARENT_INDEX_CODE
def get_stream_url(node_id): try:
"""获取叶子节点的视频流地址""" # 先尝试获取子组织
node = nodes.get(node_id) result = get_organization_list(parent_id)
if node and node["is_leaf"]: if result.get("code") != "0":
return node["stream_url"] 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 return None
# ========== HTTP 处理器 ========== # ========== HTTP 处理器 ==========
@@ -99,42 +139,36 @@ class APIHandler(SimpleHTTPRequestHandler):
return return
elif path.startswith('/api/children/'): elif path.startswith('/api/children/'):
# GET /api/children/3 # GET /api/children/21020000
try: node_id = path.split('/')[-1]
node_id = int(path.split('/')[-1]) if not node_id:
except (ValueError, IndexError):
self.send_error_json("Invalid node id", 400) self.send_error_json("Invalid node id", 400)
return return
children = get_children(node_id) children = get_children(node_id)
self.send_json_response(children) self.send_json_response(children)
return return
elif path.startswith('/api/node/'): elif path.startswith('/api/stream/'):
# GET /api/node/3 # GET /api/stream/21020000?stream_type=0
try: node_id = path.split('/')[-1]
node_id = int(path.split('/')[-1]) if not node_id:
except (ValueError, IndexError):
self.send_error_json("Invalid node id", 400) self.send_error_json("Invalid node id", 400)
return return
node = get_node(node_id)
if node is None:
self.send_error_json("Node not found", 404)
return
self.send_json_response(node)
return
elif path.startswith('/api/stream/'): # 解析 stream_type 参数
# GET /api/stream/6 params = urllib.parse.parse_qs(query)
try: stream_type = int(params.get('stream_type', ['0'])[0])
node_id = int(path.split('/')[-1])
except (ValueError, IndexError): url = get_stream_url(node_id, stream_type)
self.send_error_json("Invalid node id", 400)
return
url = get_stream_url(node_id)
if url is None: if url is None:
self.send_error_json("Stream not found or node is not a leaf", 404) self.send_error_json("Stream not found or node is not a leaf", 404)
return return
self.send_json_response({"url": url}) # 返回完整信息
self.send_json_response({
"cameraIndexCode": node_id,
"url": url,
"stream_type": stream_type
})
return return
# 静态文件服务(与原逻辑一致) # 静态文件服务(与原逻辑一致)
@@ -221,8 +255,7 @@ def run():
print(f'Server running on http://localhost:{port}') print(f'Server running on http://localhost:{port}')
print('API endpoints:') print('API endpoints:')
print(' GET /api/roots - 获取所有根节点') print(' GET /api/roots - 获取所有根节点')
print(' GET /api/children/<id> - 获取指定节点的子节点') print(' GET /api/children/<id> - 获取指定节点的子节点(自动判断组织/摄像头)')
print(' GET /api/node/<id> - 获取节点详情')
print(' GET /api/stream/<id> - 获取视频流地址') print(' GET /api/stream/<id> - 获取视频流地址')
print('静态文件服务: 访问 / 或 /index.html') print('静态文件服务: 访问 / 或 /index.html')
print('按 Ctrl+C 停止服务器') print('按 Ctrl+C 停止服务器')

416
web_page_2/coordinate.html Normal file
View 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> 标记点 &nbsp;
<kbd>Backspace</kbd> 撤销 &nbsp;
<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>

View File

@@ -48,6 +48,8 @@ class APIHandler(SimpleHTTPRequestHandler):
elif path == '/' or path == '/index.html': elif path == '/' or path == '/index.html':
# 默认访问使用 api=1 # 默认访问使用 api=1
self.serve_file('index.html', query='api=1') self.serve_file('index.html', query='api=1')
elif path == '/coords' or path == '/coordinate.html':
self.serve_file('coordinate.html')
else: else:
# 处理静态文件请求 # 处理静态文件请求
# 移除开头的 / # 移除开头的 /
@@ -130,6 +132,7 @@ def run():
httpd = ThreadingHTTPServer(server_address, APIHandler) httpd = ThreadingHTTPServer(server_address, APIHandler)
print(f'Server running on http://localhost:{port}') 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'支持的接口: /, /api/1, /api/2, /api/3, /api/4, /api/5, /api/6, /api/7, /api/11-16')
print(f'坐标提取工具: /coords')
print('按 Ctrl+C 停止服务器') print('按 Ctrl+C 停止服务器')
try: try: