From 362544aa6b71a3339b22734232b578e0b88bd690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E9=BE=99?= Date: Thu, 24 Oct 2024 16:45:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=8B=AC=E7=AB=8B=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E4=B8=80=E7=89=88=EF=BC=8C=E9=9C=80=E8=A6=81=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=8A=A8=E6=80=81=E8=AE=BE=E7=BD=AE=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E8=BF=9B=E7=A8=8B=E8=83=BD=E5=A4=84=E7=90=86=E5=87=A0?= =?UTF-8?q?=E9=80=9A=E9=81=93=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.yaml | 6 +- core/ACLModelManager.py | 46 ++++ core/ChannelData.py | 27 +- core/ChannelManager.py | 2 + core/DataStruct.py | 3 +- core/ModelManager.py | 10 +- core/ModelNode.py | 214 +++++++++++----- core/WarnManager.py | 6 +- .../plugins/RYRQ_Model_ACL/RYRQ_Model_ACL.py | 54 ++-- web/API/channel.py | 5 +- web/API/viedo.py | 34 ++- web/API/warn.py | 69 ++++- .../resources/scripts/aiortc-client-new.js | 1 + .../static/resources/scripts/warn_manager.js | 242 ++++++++++++------ web/main/templates/login.html | 12 + web/main/templates/warn_manager.html | 103 +++++++- zfbox.db | Bin 73728 -> 106496 bytes 17 files changed, 595 insertions(+), 239 deletions(-) diff --git a/config.yaml b/config.yaml index d293428..a239cc6 100644 --- a/config.yaml +++ b/config.yaml @@ -30,7 +30,7 @@ RTSP_Check_Time : 600 #10分钟 -- 2024-7-8 取消使用 #max_channel_num max_channel_num : 8 #最大视频通道数量 -encode_param : 50 #无参数默认是95 +encode_param : 80 #无参数默认是95 mywidth: 640 myheight: 480 @@ -43,7 +43,7 @@ cap_sleep_time: 120 #单位秒 -- 5分钟 buffer_len: 100 #分析后画面缓冲区帧数 -- 可以与验证帧率结合确定缓冲区大小 RESET_INTERVAL : 100000 #帧数重置上限 frame_rate : 20 #帧率参考值 -- 后续作用主要基于verify_rate进行帧率控制 -verify_rate : 10 #验证帧率--- 也就是视频输出的帧率 +verify_rate : 6 #验证帧率--- 也就是视频输出的帧率 warn_video_path: /mnt/zfbox/model/warn/ warn_interval: 120 #报警间隔--单位秒 video_error_count: 10 #单位秒 ---根据验证帧率,判断10秒内都是空帧的话,视频源链接有问题。 @@ -54,5 +54,5 @@ wired_interface : eth0 wireless_interface : WLAN #独立模型线程相关 -workType : 1 # 1--一通道一线程。2--模型独立线程 +workType : 2 # 1--一通道一线程。2--模型独立线程 diff --git a/core/ACLModelManager.py b/core/ACLModelManager.py index 1f1a3db..82b6423 100644 --- a/core/ACLModelManager.py +++ b/core/ACLModelManager.py @@ -66,3 +66,49 @@ class ACLModeManger: return True + @staticmethod + def pro_init_acl(device_id): + ''' + 独立进程初始化acl资源 + :param device_id: + :return: + ''' + # '''acl初始化函数''' + ret = acl.init() # 0-成功,其它失败 + if ret: + raise RuntimeError(ret) + ret = acl.rt.set_device(device_id) # 指定当前进程或线程中用于运算的Device。可以进程或线程中指定。*多设备时可以放线程* + # 在某一进程中指定Device,该进程内的多个线程可共用此Device显式创建Context(acl.rt.create_context接口)。 + if ret: + raise RuntimeError(ret) + print('ACL init Device Successfully') + #显示创建context + context, ret = acl.rt.create_context(device_id) # 显式创建一个Context + if ret: + raise RuntimeError(ret) + print('Init TH-Context Successfully') + return context + + @staticmethod + def pro_del_acl(device_id,context): + ''' + 独立进程反初始化acl资源 + :param device_id: + :param context: + :return: + ''' + # 释放context + if context: + ret = acl.rt.destroy_context(context) # 释放 Context + if ret: + raise RuntimeError(ret) + #acl去初始化 + ret = acl.rt.reset_device(device_id) # 释放Device + if ret: + raise RuntimeError(ret) + + ret = acl.finalize() # 去初始化 0-成功,其它失败 --官方文档不建议放析构函数中执行 + if ret: + raise RuntimeError(ret) + print('ACL finalize Successfully') + return True \ No newline at end of file diff --git a/core/ChannelData.py b/core/ChannelData.py index 9cc7d3a..3d70a25 100644 --- a/core/ChannelData.py +++ b/core/ChannelData.py @@ -8,6 +8,7 @@ import cv2 import ffmpeg import subprocess import select +import multiprocessing from collections import deque from myutils.MyLogger_logger import LogHandler from core.CapManager import mCap @@ -52,7 +53,8 @@ class ChannelData: self.post_th = None #后处理线程句柄 self.post_status = False #后处理线程状态 self.model_node= None #模型对象 -- inmq,outmq - self.out_mq = MyDeque(30) #放通道里面 + + self.out_mq = MyDeque(30) #分析结果存放MQ #设置JPEG压缩基本 self.encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), myCongif.get_data("encode_param")] # 50 是压缩质量(0到100) @@ -279,9 +281,9 @@ class ChannelData: if not self.cap: self.logger.error("采集线程未正常启动,不进行工作") return - while self.model_node.model_th_status == 0: #避免模型没启动成功,模型线程在运行 + while self.model_node.m_p_status.value == 0: #避免模型没启动成功,模型线程在运行 time.sleep(1) - if self.model_node.model_th_status == 1: + if self.model_node.m_p_status.value == 1: # 开始循环处理业务 last_frame_time = time.time() # 初始化个读帧时间 self.per_status = True @@ -306,7 +308,7 @@ class ChannelData: #图片预处理 img,scale_ratio, pad_size = self.model_node.model.prework(frame) indata = ModelinData(self.channel_id,img,frame,scale_ratio, pad_size) - self.model_node.in_mq.myappend(indata) + self.model_node.pro_add_data(indata) #数据入队列 else:# 不在计划则不进行验证,直接返回图片 --存在问题是:result 漏数据 ret, frame_bgr_webp = cv2.imencode('.jpg', frame,self.encode_param) if not ret: @@ -341,10 +343,12 @@ class ChannelData: else: # 没有产生报警也需要记录,统一计算占比 result.append(0) #分析画面保存 - ret, frame_bgr_webp = cv2.imencode('.jpg', out_data.image,self.encode_param) - buffer_bgr_webp = None - if ret: - buffer_bgr_webp = frame_bgr_webp.tobytes() + # ret, frame_bgr_webp = cv2.imencode('.jpg', out_data.image,self.encode_param) + # buffer_bgr_webp = None + # if ret: + # buffer_bgr_webp = frame_bgr_webp.tobytes() + ret, buffer_bgr_webp = self._encode_frame(out_data.image) + # 分析图片放入缓冲区内存中 self.add_deque(out_data.image) # 缓冲区大小由maxlen控制 超上限后,删除最前的数据 # 分析画面一直更新最新帧,提供网页端显示 @@ -522,6 +526,7 @@ class ChannelData: print("线程结束!!!!") #2024-9-9 新增兼容独立model线程 根据self.model_node判断,None:1通道1线程,not None:独立线程 + #2024-10-14 model调整为独立子进程执行 def _start_model_th(self,model_data,schedule,type=1): verify_rate = myCongif.get_data("verify_rate") warn_interval = myCongif.get_data("warn_interval") @@ -532,8 +537,10 @@ class ChannelData: args=(model_data[3],model_data[4],verify_rate,warn_interval,model_data[7], model_data[1],model_data[2],model_data[8],model_data[9])) self.post_th.start() - #启动模型线程,若线程已启动,则+1 mq + + # 启动模型线程,若线程已启动,则+1 self.model_node.start_model_th(self.channel_id,self.out_mq) + #启动预处理线程 self.per_th = threading.Thread(target=self._pre_work_th,args=(schedule,)) self.per_th.start() @@ -557,7 +564,9 @@ class ChannelData: if self.post_th: self.post_th.join() self.post_th = None + #清空MQ self.out_mq.myclear()#清空后处理mq中未处理的数据 + else: if self.work_th: if self.b_model: diff --git a/core/ChannelManager.py b/core/ChannelManager.py index 969e078..e25e8fd 100644 --- a/core/ChannelManager.py +++ b/core/ChannelManager.py @@ -131,12 +131,14 @@ class ChannelManager: return img_base64 '''模型独立线程修改2024-9-9,要求是双模式兼容''' + '''2024-10-13修改独立线程为独立进程---acl初始化需要在子进程中初始化 -- 该方案无法兼容旧版本''' def CreateModelNode(self, model_id, model_path, channel_id): if model_id in self.model_list: modelN = self.model_list[model_id] else: modelN = ModelNode(self.device_id,model_path) self.model_list[model_id] = modelN + #modelN = ModelNode(self.device_id, model_path,channel_id) return modelN def delModelNode(self): #关于modelnodel :1.考虑modelnode是否可以不删除,清空inmq即可,2.mdel_list是否需要加锁。#? diff --git a/core/DataStruct.py b/core/DataStruct.py index 0a333e4..4113879 100644 --- a/core/DataStruct.py +++ b/core/DataStruct.py @@ -34,11 +34,12 @@ class ModelinData: pass class ModeloutData: - def __init__(self,image,scale_ratio, pad_size,outputs): + def __init__(self,image,scale_ratio, pad_size,outputs,channel_id): self.image = image #原图 self.outputs = outputs #模型推理后结果 self.scale_ratio = scale_ratio self.pad_size = pad_size + self.channel_id = channel_id def __del__(self): pass diff --git a/core/ModelManager.py b/core/ModelManager.py index 34e7a39..1113b5b 100644 --- a/core/ModelManager.py +++ b/core/ModelManager.py @@ -21,16 +21,18 @@ class ModelManager: # acl初始化 -- 一个进程一个 self.model_platform = myCongif.get_data("model_platform") if self.model_platform == "acl": - self.device_id = myCongif.get_data("device_id") - ACLModeManger.init_acl(self.device_id) #acl -- 全程序初始化 - #self.model_dic = {} # model_id model + if myCongif.get_data("workType") == 1: + self.device_id = myCongif.get_data("device_id") + ACLModeManger.init_acl(self.device_id) #acl -- 全程序初始化 + self.model_dic = {} # model_id model def __del__(self): self.logger.debug("释放资源") self.stop_work(0) #停止所有工作 del self.verify_list #应该需要深入的删除--待完善 if self.model_platform == "acl": #去初始化 - ACLModeManger.del_acl(self.device_id) #acl -- 全程序反初始化 需要确保在执行析构前,其它资源已释放 + if myCongif.get_data("workType") == 1: + ACLModeManger.del_acl(self.device_id) #acl -- 全程序反初始化 需要确保在执行析构前,其它资源已释放 def send_warn(self): '''发送报警信息''' diff --git a/core/ModelNode.py b/core/ModelNode.py index 86c8a99..0c55665 100644 --- a/core/ModelNode.py +++ b/core/ModelNode.py @@ -1,38 +1,104 @@ import threading import importlib.util import time -from myutils.MyDeque import MyDeque +import multiprocessing +from multiprocessing.managers import BaseManager from myutils.ConfigManager import myCongif from myutils.MyLogger_logger import LogHandler from core.ACLModelManager import ACLModeManger from core.DataStruct import ModelinData,ModeloutData from threading import Lock + +#2024-10-14model处理调整为独立子进程 +def model_process(device,model,model_platform,m_p_status,brun,in_mq,out_mq): + + # 初始化模型运行资源 + context = None + if model_platform == "acl": # ACL线程中初始化内容 + context = ACLModeManger.pro_init_acl(device) # 初始化acl资源,并创建context + # 初始化模型资源 -- 加载模型文件 + ret = model.init_acl_resource() # 加载和初始化离线模型文件--om文件 + if not ret: + print("初始化模型资源出错,退出线程!") + m_p_status.value = 2 + return + + #执行工作 + m_p_status.value = 1 + s_time = time.time() + icount = 0 + while brun.value: + try: + inData = in_mq.get(timeout=0.01) #空时-block,直到有值 #(self,channel_id,img,image,scale_ratio, pad_size): + except: + #print("in_mq_空") + continue + if inData: + outputs = model.execute([inData.img,])#创建input,执行模型,返回结果 --失败返回None + outdata = ModeloutData(inData.image,inData.scale_ratio,inData.pad_size,outputs,inData.channel_id) + del inData.img + #结果输出 + if out_mq.full(): + tmp = out_mq.get() + #print("model_输出mq满!") + del tmp + out_mq.put(outdata) # 需要确保out_mq只有在这里put + else: #正常情况不会执行到该条件 + time.sleep(0.05) + icount += 1 + if icount == 1000: + e_time = time.time() + use_time = (e_time - s_time) / 1000 + print(f"model_process耗时--{use_time}秒") + s_time = time.time() + icount = 0 + + #结束进程,释放资源 + m_p_status.value = 0 + while not in_mq.empty(): + try: + in_mq.get_nowait() # Get without blocking + except Exception as e: + break # In case of any unexpected errors + + # 反初始化 + if model_platform == "acl": + try: + model.release() # 释放模型资源资源 + # 删除模型对象 + del model + # 释放ACL资源 + ACLModeManger.pro_del_acl(device,context) + except Exception as e: + print(e) + + class ModelNode: - def __init__(self,device,model_path): + def __init__(self,device,model_path,channel_id): self.device = device self.model_path = model_path + self.channel_id = channel_id self.model = None #模型对象 - self.model_th = None #模型线程句柄 - self.brun = True #模型控制标识 - self.model_th_status = 0 #模型线程运行状态 0--初始状态,1-线程执行成功,2-线程退出 - self.in_mq = MyDeque(50) # - self.channel_list = {} #channel_id out_mq --需要线程安全 - self.clist_Lock = Lock() #channel_list的维护锁 self.ch_count = 0 #关联启动的通道数量 self.count_Lock = Lock() #count的维护锁 self.model_platform = myCongif.get_data("model_platform") self.logger = LogHandler().get_logger("ModelNode") + #分发线程相关 + self.model_out_th = None + self.channel_dict = {} + self.cdict_Lock = Lock() - + #独立进程方案--共享参数 + self.process = None + self.in_mq = multiprocessing.Queue(maxsize=30) + self.out_mq = multiprocessing.Queue(maxsize=30) #调整结构,多线程(预处理)-》in_mq-子进程-out_mq-》线程分发outdata->多线程(后处理) + self.brun = multiprocessing.Value('b',True) #brun.value = False,brun.value = True + self.m_p_status = multiprocessing.Value('i',0) def __del__(self): pass - def _reset(self): #重置数据 - #self.model_th_status = 0 # 模型线程运行状态 0--初始状态,1-线程执行成功,2-线程退出 - self.in_mq.myclear() - def _import_model(self,model_path,threshold=0.5,iou_thres=0.5): ''' 根据路径,动态导入模块 @@ -61,73 +127,79 @@ class ModelNode: print(f"An unexpected error occurred: {e}") return None - def _model_th(self): - # 加载自定义模型文件 - self.model = self._import_model(self.model_path) # 动态加载模型处理文件py - if not self.model: - self.logger.error("自定义模型文件加载失败,退出model线程") - self.model_th_status = 2 - return - # 初始化模型运行资源 - context = None - if self.model_platform == "acl": # ACL线程中初始化内容 - context = ACLModeManger.th_inti_acl(self.device) # 创建context - # 初始化模型资源 -- 加载模型文件 - ret = self.model.init_acl_resource() # 加载和初始化离线模型文件--om文件 - if not ret: - print("初始化模型资源出错,退出线程!") - self.model_th_status = 2 - return - #执行工作 - self.model_th_status = 1 - while self.brun: - inData = self.in_mq.mypopleft() #空时,返回None #(self,channel_id,img,image,scale_ratio, pad_size): - if inData: - outputs = self.model.execute([inData.img,])#创建input,执行模型,返回结果 --失败返回None - outdata = ModeloutData(inData.image,inData.scale_ratio,inData.pad_size,outputs) - del inData.img - with self.clist_Lock: - if inData.channel_id in self.channel_list: - self.channel_list[inData.channel_id].myappend(outdata) - else: - time.sleep(0.05) + def pro_add_data(self,data): + # try: + # self.in_mq.put(data,timeout=0.1) + # except multiprocessing.queues.Full: + # print("mdel_inmq输入满!") + # del data + if self.in_mq.full(): + tmp = self.in_mq.get() + #print("mdel_inmq输入满!") + del tmp + self.in_mq.put(data) # 需要确保out_mq只有在这里put - #结束线程,释放资源 - self.model_th_status = 0 - self._reset() - # 反初始化 - if self.model_platform == "acl": + def _modle_th(self): + '''根据channel_id分发out_data到out_mq''' + s_time = time.time() + icount = 0 + while self.brun.value: try: - self.model.release() # 释放模型资源资源 - # 删除模型对象 - del self.model - # 释放context - if context: # ACL线程中反初始化内容 -- 若线程异常退出,这些资源就不能正常释放了 - # 再释放context - ACLModeManger.th_del_acl(context) - except Exception as e: - print(e) + outdata = self.out_mq.get(timeout=1) + except: + continue + with self.cdict_Lock: + if outdata.channel_id in self.channel_dict: + self.channel_dict[outdata.channel_id].myappend(outdata) #后面就交给后处理线程了 + icount += 1 + if icount ==1000: + e_time = time.time() + use_time = (e_time-s_time) /1000 + print(f"{self.channel_id}_modle_th耗时--{use_time}秒") + s_time = time.time() + icount = 0 + + #2024-10-14调整为独立进程执行 -- 一个线程一个MQ MyDeque def start_model_th(self,channel_id,out_mq): with self.count_Lock: - with self.clist_Lock: - if channel_id in self.channel_list: - return #这个可以删除老的,新增新的 - self.channel_list[channel_id] = out_mq - if self.ch_count == 0: #第一次启动线程 - self.brun = True - self.model_th = threading.Thread(target=self._model_th) - self.model_th.start() + with self.cdict_Lock: + if channel_id in self.channel_dict: + return #这个可以删除老的,新增新的--后续验证,若需要则进行修改 + self.channel_dict[channel_id] = out_mq #增加一个记录 + + if self.ch_count == 0: #第一次启动--需要启动处理线程和进程 + #加载自定义模型文件 + self.model = self._import_model(self.model_path) # 动态加载模型处理文件py --置信阈值一直没使用 + if not self.model: + self.logger.error("自定义模型文件加载失败,不启动model子进程") + self.m_p_status.value = 2 + return + + self.brun.value = True + #创建outMQ的分发线程 + self.model_out_th = threading.Thread(target=self._modle_th) + self.model_out_th.start() + + # 创建子进程 + self.process = multiprocessing.Process(target=model_process, + args=(self.device,self.model,self.model_platform, + self.m_p_status,self.brun,self.in_mq,self.out_mq)) + self.process.start() self.ch_count += 1 #有通道调用一次就加一 def stop_model_th(self,channel_id): with self.count_Lock: - with self.clist_Lock: - if channel_id in self.channel_list: - del self.channel_list[channel_id] + with self.cdict_Lock: + if channel_id in self.channel_dict: + del self.channel_dict[channel_id] self.ch_count -= 1 if self.ch_count == 0: #所有通道结束 - self.brun = False - self.model_th.join() - self.model_th = None + self.brun.value = False + self.model_out_th.join() #等待线程结束 + self.model_out_th = None + + self.process.join() #等待子进程结束 + self.process = None + diff --git a/core/WarnManager.py b/core/WarnManager.py index b0af853..13a7027 100644 --- a/core/WarnManager.py +++ b/core/WarnManager.py @@ -42,7 +42,7 @@ class WarnManager: except queue.Empty: continue if warn_data: - self.save_warn(warn_data.model_name,warn_data.img_buffer,warn_data.width,warn_data.height, + ret = self.save_warn(warn_data.model_name,warn_data.img_buffer,warn_data.width,warn_data.height, warn_data.channel_id,self.FPS,self.fourcc,myDBM) self.send_warn() del warn_data.img_buffer @@ -80,7 +80,9 @@ class WarnManager: filename = f"{channnel_id}_{current_time_str}" save_path = myCongif.get_data("warn_video_path") # 保存视频 - video_writer = cv2.VideoWriter(f"{save_path}{filename}.mp4", fourcc, FPS, (width, height)) + str_video = f"{save_path}{filename}.mp4" + video_writer = cv2.VideoWriter(str_video, fourcc, FPS, (width, height)) + #print(f"File: {str_video}, FourCC: {fourcc}, FPS: {FPS}, Size: ({width}, {height})") if not video_writer.isOpened(): print(f"Failed to open video writer for model/warn/{filename}.mp4") return False diff --git a/model/plugins/RYRQ_Model_ACL/RYRQ_Model_ACL.py b/model/plugins/RYRQ_Model_ACL/RYRQ_Model_ACL.py index 22ba6ed..e9ea452 100644 --- a/model/plugins/RYRQ_Model_ACL/RYRQ_Model_ACL.py +++ b/model/plugins/RYRQ_Model_ACL/RYRQ_Model_ACL.py @@ -29,10 +29,10 @@ class Model(ModelBase): def prework(self,image): '''模型输入图片数据前处理 --- 针对每个模型特有的预处理内容 -''' - img, scale_ratio, pad_size = letterbox(image, new_shape=[640, 640]) # 对图像进行缩放与填充 + img, scale_ratio, dw,dh = letterbox(image, new_shape=[self.netw, self.neth]) # 对图像进行缩放与填充 img = img[:, :, ::-1].transpose(2, 0, 1) # BGR to RGB, HWC to CHW #图片在输入时已经做了转换 img = np.ascontiguousarray(img, dtype=np.float32) / 255.0 # 转换为内存连续存储的数组 - return img,scale_ratio, pad_size + return img,scale_ratio, (dw,dh) def postwork(self,image,outputs,scale_ratio,pad_size,check_area,polygon,conf_threshold,iou_thres): ''' @@ -47,6 +47,7 @@ class Model(ModelBase): :param iou_thres: :return: ''' + filtered_pred_all = None bwarn = False warn_text = "" # 是否有检测区域,有先绘制检测区域 由于在该函数生成了polygon对象,所有需要在检测区域前调用。 @@ -54,40 +55,31 @@ class Model(ModelBase): self.draw_polygon(image, polygon, (255, 0, 0)) if outputs: - output = outputs[0] # 只放了一张图片 -- #是否能批量验证? - # 后处理 -- boxout 是 tensor-list: [tensor([[],[].[]])] --[x1,y1,x2,y2,置信度,coco_index] - # 利用非极大值抑制处理模型输出,conf_thres 为置信度阈值,iou_thres 为iou阈值 - output_torch = torch.tensor(output) - boxout = nms(output_torch, conf_thres=conf_threshold, iou_thres=iou_thres) - del output_torch - pred_all = boxout[0].numpy() # 转换为numpy数组 -- [[],[],[]] --[x1,y1,x2,y2,置信度,coco_index] - # pred_all[:, :4] 取所有行的前4列,pred_all[:,1]--第一列 - scale_coords([640, 640], pred_all[:, :4], image.shape, ratio_pad=(scale_ratio, pad_size)) # 将推理结果缩放到原始图片大小 - - # 过滤掉不是目标标签的数据 -- 序号0-- person self.labels_dict --这个考虑下是否可以放到nms前面#? - filtered_pred_all = pred_all[pred_all[:, 5] == 0] - # 绘制检测结果 --- 也需要封装在类里, - for pred in filtered_pred_all: - x1, y1, x2, y2 = int(pred[0]), int(pred[1]), int(pred[2]), int(pred[3]) + output = np.squeeze(outputs[0]) # 移除张量为1的维度 --暂时不明白其具体意义 + dw,dh = pad_size + pred_all = non_max_suppression_v10(output, self.conf_threshold, scale_ratio, dw, dh) + for xmin, ymin, xmax, ymax, confidence, label in pred_all: # # 绘制目标识别的锚框 --已经在draw_bbox里处理 # cv2.rectangle(image, (x1, y1), (x2, y2), (0, 255, 0), 2) - if check_area == 1: # 指定了检测区域 - x_center = (x1 + x2) / 2 - y_center = (y1 + y2) / 2 - # 绘制中心点? - cv2.circle(image, (int(x_center), int(y_center)), 5, (0, 0, 255), -1) - # 判断是否区域点 - if not self.is_point_in_region((x_center, y_center)): - continue # 没产生报警-继续 - # 产生报警 -- 有一个符合即可 - bwarn = True - warn_text = "People Intruder detected!" - draw_bbox(filtered_pred_all, image, (0, 255, 0), 2, self.labels_dict) # 画出检测框、类别、概率 + draw_box(image, [xmin, ymin, xmax, ymax], confidence, label) # 画出检测框、类别、概率 + if label == 0: # person + # 判断是否产生告警 + x1, y1, x2, y2 = int(xmin), int(ymin), int(xmax), int(ymax) + if check_area == 1: # 指定了检测区域 + x_center = (x1 + x2) / 2 + y_center = (y1 + y2) / 2 + # 绘制中心点? + cv2.circle(image, (int(x_center), int(y_center)), 5, (0, 0, 255), -1) + # 判断是否区域点 + if not self.is_point_in_region((x_center, y_center)): + continue # 没产生报警-继续 + # 产生报警 -- 有一个符合即可 + bwarn = True + warn_text = "People Intruder detected!" # 清理内存 del outputs, output - del boxout del pred_all, filtered_pred_all - #图片绘制是在原图生效 image + # cv2.imwrite('img_res.png', img_dw) return bwarn, warn_text def verify(self,image,data,isdraw=1): diff --git a/web/API/channel.py b/web/API/channel.py index b048dbf..e1b7a7b 100644 --- a/web/API/channel.py +++ b/web/API/channel.py @@ -52,11 +52,12 @@ async def channel_add(): #新增通道 -- 2024-8-1修改为与修改通道用一 area = mDBM.do_select(strsql,1) if area: area_id = area[0] - strsql = f"select ID from channel where area_id={area_id} and channel_name='{cName}';" + #strsql = f"select ID from channel where area_id={area_id} and channel_name='{cName}';" + strsql = f"select ID from channel where channel_name='{cName}';" data = mDBM.do_select(strsql, 1) if data and data[0] != cid: #有值--代表重复 或者是它自己(只修改了RTSP地址) reStatus = 0 - reMsg = "同一区域内的通道名称不能相同!" + reMsg = "通道名称不能相同!" else: if cid == -1: max_count = myCongif.get_data("max_channel_num") diff --git a/web/API/viedo.py b/web/API/viedo.py index b69d168..e01ae1e 100644 --- a/web/API/viedo.py +++ b/web/API/viedo.py @@ -201,24 +201,22 @@ async def handle_channel(channel_id,websocket): await asyncio.sleep(sleep_time) # 等待视频重连时间 #----------输出时间----------- - frame_count += 1 - end_time = time.time() - # 计算时间差 - el_time = end_time - start_time - all_time = all_time + (end_time - current_time) - # 每隔一定时间(比如5秒)计算一次帧率 - if el_time >= 10: - fps = frame_count / el_time - print(f"{channel_id}当前帧率: {fps} FPS,循环次数:{frame_count},花费总耗时:{all_time}S,get耗时:{get_all_time},send耗时:{send_all_time}") - # 重置计数器和时间 - frame_count = 0 - all_time = 0 - get_all_time = 0 - send_all_time = 0 - start_time = time.time() - # print(f"get_frame:{round(get_etime-get_stime,5)}Sceond;" - # f"send_frame:{round(send_etime-send_stime,5)}Sceond;" - # f"All_time={round(end_time-current_time,5)}") + # frame_count += 1 + # end_time = time.time() + # # 计算时间差 + # el_time = end_time - start_time + # all_time = all_time + (end_time - current_time) + # # 每隔一定时间(比如5秒)计算一次帧率 + # if el_time >= 10: + # fps = frame_count / el_time + # print(f"{channel_id}当前帧率: {fps} FPS,循环次数:{frame_count},花费总耗时:{all_time}S,get耗时:{get_all_time},send耗时:{send_all_time}") + # # 重置计数器和时间 + # frame_count = 0 + # all_time = 0 + # get_all_time = 0 + # send_all_time = 0 + # start_time = time.time() + except asyncio.CancelledError: print(f"WebSocket connection for channel {channel_id} closed by client") raise diff --git a/web/API/warn.py b/web/API/warn.py index 4abacab..c98bf83 100644 --- a/web/API/warn.py +++ b/web/API/warn.py @@ -1,32 +1,33 @@ +import os from . import api from web.common.utils import login_required -from quart import jsonify, request +from quart import jsonify, request,send_file from core.DBManager import mDBM @api.route('/warn/search_warn',methods=['POST']) @login_required -async def warn_get(): #新增算法 +async def warn_search(): #查询报警 #获取查询参数 json_data = await request.get_json() s_count = json_data.get('s_count','') e_count = json_data.get('e_count','') model_name = json_data.get('model_name','') - channel_id = json_data.get('channel_id','') + channel_name = json_data.get('channel_name','') start_time = json_data.get('start_time','') end_time = json_data.get('end_time','') # 动态拼接 SQL 语句 - sql = "SELECT * FROM warn WHERE 1=1" + sql = "SELECT t1.*,t2.channel_name FROM warn t1 LEFT JOIN channel t2 ON t1.channel_id = t2.element_id WHERE 1=1" if model_name: - sql += f" AND model_name = {model_name}" - if channel_id: - sql += f" AND channel_id = {channel_id}" + sql += f" AND t1.model_name = {model_name}" + if channel_name: + sql += f" AND t2.channel_name = {channel_name}" if start_time and end_time: - sql += f" AND creat_time BETWEEN {start_time} AND {end_time}" + sql += f" AND t1.creat_time BETWEEN {start_time} AND {end_time}" # 增加倒序排列和分页 - sql += f" ORDER BY creat_time DESC LIMIT {e_count} OFFSET {s_count}" + sql += f" ORDER BY t1.creat_time DESC LIMIT {e_count} OFFSET {s_count}" # 使用SQLAlchemy执行查询 try: @@ -34,7 +35,55 @@ async def warn_get(): #新增算法 data = mDBM.do_select(sql) # 将数据转换为JSON格式返回给前端 warn_list = [{"ID": warn[0], "model_name": warn[1], "video_path": warn[2], "img_path": warn[3], - "creat_time": warn[4], "channel_id": warn[5]} for warn in data] + "creat_time": warn[4], "channel_name": warn[6]} for warn in data] return jsonify(warn_list) except Exception as e: return jsonify({"error": str(e)}), 500 + +@api.route('/warn/warn_img') +@login_required +async def warn_img(): + # 获取图片路径参数 + image_path = request.args.get('path') + if image_path and os.path.exists(image_path): + # 返回图片文件 + return await send_file(image_path) + else: + # 图片不存在时,返回 404 + return 'Image not found', 404 + +@api.route('/warn/warn_video') +@login_required +async def warn_video(): + # 获取视频路径参数 + video_path = request.args.get('path') + if video_path and os.path.exists(video_path): + # 返回视频文件流 + return await send_file(video_path, as_attachment=True) + else: + # 视频文件不存在时,返回 404 + return 'Video not found', 404 + +@api.route('/warn/warn_del',methods=['POST']) +@login_required +async def warn_del(): + # 获取请求体中的报警ID数组 + data = await request.get_json() + alarm_ids = data.get('alarmIds') + + if not alarm_ids: + return jsonify({'success': False, 'message': '没有提供报警ID'}), 400 + + try: + # 根据报警ID进行删除数据的操作,这里是假设数据库删除操作 + # 例如:delete_from_database(alarm_ids) + + # 模拟删除成功 + success = True + + if success: + return jsonify({'success': True, 'message': '删除成功'}) + else: + return jsonify({'success': False, 'message': '删除失败'}) + except Exception as e: + return jsonify({'success': False, 'message': f'发生异常: {str(e)}'}), 500 \ No newline at end of file diff --git a/web/main/static/resources/scripts/aiortc-client-new.js b/web/main/static/resources/scripts/aiortc-client-new.js index 2b42365..e03ede7 100644 --- a/web/main/static/resources/scripts/aiortc-client-new.js +++ b/web/main/static/resources/scripts/aiortc-client-new.js @@ -294,6 +294,7 @@ function connect(channel_id,element_id,imgcanvas,ctx,offscreenCtx,offscreenCanva //如有错误信息显示 -- 清除错误信息 if(berror_state_list[el_id]){ removeErrorMessage(imgcanvas); + console.log("清除错误信息!") berror_state_list[el_id] = false; } // 接收到 JPG 图像数据,转换为 Blob diff --git a/web/main/static/resources/scripts/warn_manager.js b/web/main/static/resources/scripts/warn_manager.js index a1b1a69..84f505a 100644 --- a/web/main/static/resources/scripts/warn_manager.js +++ b/web/main/static/resources/scripts/warn_manager.js @@ -1,23 +1,74 @@ let modelMap = []; //model_name model_id let channelMap = []; //channel_name channel_id - +let warn_data = []; //查询到的报警数据 +let page_data = []; +let currentEditingRow = null; +let currentPage = 1; +const rowsPerPage = 30; //页面加载初始化 document.addEventListener('DOMContentLoaded', function () { perWarnHtml() + + document.getElementById('delPageButton').addEventListener('click', function () { + delpageWarn(); + }); }); //搜索按钮点击 document.getElementById('searchMButton').addEventListener('click', function() { + shearchWarn(); +}); + +//查询数据 +async function shearchWarn(){ + //查询告警数据 + let modelName = document.getElementById('modelSelect').value; + let channelName = document.getElementById('channelSelect').value; const startTime = document.getElementById('startTime').value; const endTime = document.getElementById('endTime').value; + const sCount = 0; // 起始记录数从0开始 + const eCount = 5000; // 每页显示10条记录 - if (startTime && endTime) { - console.log(`开始时间: ${startTime}, 结束时间: ${endTime}`); - // 在这里执行其他逻辑,例如根据时间范围查询数据 - } else { - alert('请选择完整的时间区间'); + if(modelName == "请选择"){ + modelName = ""; + } + if(channelName == "请选择"){ + channelName = ""; } -}); + + // 构造请求体 + const requestData = { + model_name: modelName || "", // 如果为空,则传空字符串 + channel_name: channelName || "", + start_time: startTime || "", + end_time: endTime || "", + s_count: sCount, + e_count: eCount + }; + try{ + // 发送POST请求到后端 + const response = await fetch('/api/warn/search_warn', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestData) // 将数据转为JSON字符串 + }); + + // 检查响应是否成功 + if (response.ok) { + warn_data = await response.json(); + // 在这里处理查询结果,比如更新表格显示数据 + currentPage = 1; // 重置当前页为第一页 + renderTable(); //刷新表格 + renderPagination(); + } else { + console.error('查询失败:', response.status); + } + } catch (error) { + console.error('请求出错:', error); + } +} async function perWarnHtml() { //获取算法和通道列表,在下拉框显示 @@ -47,104 +98,50 @@ async function perWarnHtml() { channelMap[option.channel_name] = option.ID; }); set_select_data("channelSelect",channel_select_datas); - - //查询告警数据 - let modelName = document.getElementById('modelSelect').value; - let channelId = document.getElementById('channelSelect').value; - const startTime = document.getElementById('startTime').value; - const endTime = document.getElementById('endTime').value; - const sCount = 0; // 起始记录数从0开始 - const eCount = 100; // 每页显示10条记录 - - if(modelName == "请选择"){ - modelName = ""; - } - if(channelId == "请选择"){ - channelId = ""; - } - - // 构造请求体 - const requestData = { - model_name: modelName || "", // 如果为空,则传空字符串 - channel_id: channelId || "", - start_time: startTime || "", - end_time: endTime || "", - s_count: sCount, - e_count: eCount - }; - try{ - // 发送POST请求到后端 - const response = await fetch('/api/warn/search_warn', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(requestData) // 将数据转为JSON字符串 - }); - - // 检查响应是否成功 - if (response.ok) { - const data = await response.json(); - console.log('查询结果:', data); - // 在这里处理查询结果,比如更新表格显示数据 - //updateTableWithData(data); - } else { - console.error('查询失败:', response.status); - } - } catch (error) { - console.error('请求出错:', error); - } - + //查询数据 + shearchWarn() }catch (error) { console.error('Error fetching model data:', error); } - - //读取报警数据并进行显示--要分页显示 - // modelData_bak = modelData; - // currentPage = 1; // 重置当前页为第一页 - // renderTable(); //刷新表格 - // renderPagination(); - //操作-删除,图片,视频,审核(灰) } //刷新表单页面数据 function renderTable() { - const tableBody = document.getElementById('table-body-model'); + const tableBody = document.getElementById('table-body-warn'); tableBody.innerHTML = ''; //清空 const start = (currentPage - 1) * rowsPerPage; const end = start + rowsPerPage; - const pageData = modelData.slice(start, end); + pageData = warn_data.slice(start, end); const surplus_count = rowsPerPage - pageData.length; - pageData.forEach((model) => { + pageData.forEach((warn) => { const row = document.createElement('tr'); row.innerHTML = ` - ${model.ID} - ${model.name} - ${model.version} - ${model.duration_time} - ${model.proportion} + ${warn.ID} + ${warn.model_name} + ${warn.channel_name} + ${warn.creat_time} - - - + + + `; tableBody.appendChild(row); - row.querySelector('.modify-btn').addEventListener('click', () => modifyModel(row)); - row.querySelector('.algorithm-btn').addEventListener('click', () => configureModel(row)); - row.querySelector('.delete-btn').addEventListener('click', () => deleteModel(row)); + row.querySelector('.warn-show-btn').addEventListener('click', () => showWarn(row)); + row.querySelector('.warn-video-btn').addEventListener('click', () => videoWarn(row)); + row.querySelector('.warn-delete-btn').addEventListener('click', () => deleteWarn(row)); }); } //刷新分页标签 function renderPagination() { - const pagination = document.getElementById('pagination-model'); + const pagination = document.getElementById('pagination-warn'); pagination.innerHTML = ''; - const totalPages = Math.ceil(modelData.length / rowsPerPage); + const totalPages = Math.ceil(warn_data.length / rowsPerPage); for (let i = 1; i <= totalPages; i++) { const pageItem = document.createElement('li'); pageItem.className = 'page-item' + (i === currentPage ? ' active' : ''); @@ -157,4 +154,93 @@ function renderPagination() { }); pagination.appendChild(pageItem); } +} + +//显示报警信息详情 +function showWarn(row){ + currentEditingRow = row; + model_name = row.cells[1].innerText; + channel_name = row.cells[2].innerText; + create_time = row.cells[3].innerText; + img_path = pageData[row.rowIndex-1].img_path; + + document.getElementById('modelName').innerText = `${model_name}`; + document.getElementById('channleName').innerText = `${channel_name}`; + document.getElementById('warnTime').innerText = `${create_time}`; + document.getElementById('warnImg').src = `/api/warn/warn_img?path=${img_path}`; // 设置图片的 src + + $('#showWarn').modal('show'); +} + +//下载视频按钮 +function videoWarn(row){ + video_path = pageData[row.rowIndex-1].video_path; + // const videoPlayer = document.getElementById('videoPlayer'); + // videoPlayer.src = `/api/warn/warn_video?path=${encodeURIComponent(video_path)}`; + // videoPlayer.load(); // 确保重新加载视频 + // $('#showVideo').modal('show'); + // 创建下载链接 + const downloadUrl = `/api/warn/warn_video?path=${encodeURIComponent(video_path)}`; + const a = document.createElement('a'); + a.href = downloadUrl; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} + +//删除一条报警数据 +function deleteWarn(row){ + if (confirm('确定删除此报警吗?')) { + let alarmIds=[]; + warn_id = row.cells[0].innerText; + alarmIds.push(warn_id); + // 发送POST请求到后端 + fetch('/api/warn/warn_del', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ alarmIds: alarmIds }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert('删除成功'); + } else { + alert('删除失败'); + } + }) + .catch(error => { + console.error('Error:', error); + }); + } +} + +//删除当前页的报警数据 +function delpageWarn(){ + if (confirm('确定删除此页报警吗?')) { + let alarmIds=[]; + pageData.forEach((warn) => { + alarmIds.push(warn.ID) + }); + // 发送POST请求到后端 + fetch('/api/warn/warn_del', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ alarmIds: alarmIds }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert('删除成功'); + } else { + alert('删除失败'); + } + }) + .catch(error => { + console.error('Error:', error); + }); + } } \ No newline at end of file diff --git a/web/main/templates/login.html b/web/main/templates/login.html index 513e4a8..07b4e9e 100644 --- a/web/main/templates/login.html +++ b/web/main/templates/login.html @@ -80,6 +80,7 @@ {% endif %} {% endwith %} +

ZF-AI

@@ -105,6 +106,11 @@

© 2024–2025 ZFKJ All Rights Reserved

+ + + diff --git a/web/main/templates/warn_manager.html b/web/main/templates/warn_manager.html index d66f30c..52c40a0 100644 --- a/web/main/templates/warn_manager.html +++ b/web/main/templates/warn_manager.html @@ -5,19 +5,103 @@ {% block style %} .table-container { min-height: 400px; /* 设置最小高度,可以根据需要调整 */ + max-height: 650px; + overflow-y: auto; /* 启用垂直滚动条 */ + border: 1px solid #ddd; /* 可选:为容器添加边框 */ + } + table { + width: 100%; + border-collapse: collapse; /* 防止表格出现双线边框 */ + } + thead th { + position: sticky; + top: 0; + background-color: #f8f9fa; /* 表头背景色,防止滚动时透明 */ + z-index: 1; } /* 缩小表格行高 */ .table-sm th, .table-sm td { padding: 0.2rem; /* 调整这里的值来改变行高 */ } + + .form-label { + font-size: 1em; /* 标签的字体大小 */ + } + + .form-value { + font-size: 0.5em; /* 值的字体稍小 */ + } + #warnImg { + max-width: 100%; /* 图片宽度最多为模态框的宽度 */ + height: auto; /* 保持图片的宽高比 */ + display: block; /* 保证图片独占一行 */ + margin: 0 auto; /* 居中显示 */ + } + /* 使视频播放器适应模态框 */ + #videoPlayer { + width: 100%; + height: auto; /* 保持视频比例 */ + } + {% endblock %} {% block content %} + + + + + + -
+
@@ -34,12 +118,10 @@
- - + + + +
@@ -53,20 +135,21 @@ 操作 - + +
-
{% endblock %} {% block script %} + {% endblock %} \ No newline at end of file diff --git a/zfbox.db b/zfbox.db index 9fe0d5a04fe8e3469df69635727fcdabb062c53d..39e887d8357b390fca28f995e3c28833341dc5e3 100644 GIT binary patch literal 106496 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 L0d3&_!@&Oo2n9>P literal 73728 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