From 127bdd2da26137f95289159f6bcd0f98c2374b6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E9=BE=99?= Date: Wed, 17 Jul 2024 12:48:52 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=A8=A1=E5=9E=8B=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E7=BA=BF=E7=A8=8B=E5=90=8E=E7=89=88=E6=9C=AC=EF=BC=8C?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=8D=A0=E7=94=A8=E6=9C=89=E7=82=B9=E9=AB=98?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.yaml | 3 +- core/ChannelManager.py | 253 +++++++++++++++-- core/ModelManager.py | 335 ++++++----------------- core/WarnManager.py | 102 +++++++ model/plugins/ModelBase.py | 306 +++++++++++++++++++-- model/plugins/RYRQ_ACL/RYRQ_Model_ACL.py | 90 +++--- run.py | 35 ++- web/API/viedo.py | 34 ++- web/main/routes.py | 1 - zfbox.db | Bin 53248 -> 53248 bytes 流程说明.docx | Bin 18942 -> 19609 bytes 11 files changed, 810 insertions(+), 349 deletions(-) create mode 100644 core/WarnManager.py diff --git a/config.yaml b/config.yaml index 219e12b..f1fa27f 100644 --- a/config.yaml +++ b/config.yaml @@ -36,6 +36,7 @@ cap_sleep_time: 120 #5分钟 buffer_len: 100 #分析后画面缓冲区帧数 -- 可以与验证帧率结合确定缓冲区大小 RESET_INTERVAL : 100000 #帧数重置上限 frame_rate : 20 #帧率参考值 -- 后续作用主要基于verify_rate进行帧率控制 -verify_rate : 10 #验证帧率--- 也就是视频输出的帧率 +verify_rate : 8 #验证帧率--- 也就是视频输出的帧率 warn_video_path: /mnt/zfbox/model/warn/ warn_interval: 120 #报警间隔--单位秒 +q_size : 30 #线程队列对长度 \ No newline at end of file diff --git a/core/ChannelManager.py b/core/ChannelManager.py index e92cb59..6cb3dae 100644 --- a/core/ChannelManager.py +++ b/core/ChannelManager.py @@ -4,19 +4,72 @@ import numpy as np import time import copy import queue +import cv2 +from datetime import datetime, timedelta +from myutils.ConfigManager import myCongif +from core.WarnManager import WarnData +from myutils.MyLogger_logger import LogHandler +#--2024-7-12调整规则,一个视频通道只允许配置一个算法模型,一个算法模型可以配置给多路通道 class ChannelData: - def __init__(self, str_url, int_type, bool_run, deque_length,icount_max): + def __init__(self, channel_id,str_url, int_type, bool_run, deque_length,icount_max): + self.channel_id = channel_id self.str_url = str_url #视频源地址 self.int_type = int_type #视频源类型,0-usb,1-rtsp,2-hksdk self.bool_run = bool_run #线程运行标识 - self.deque_frame = deque(maxlen=deque_length) - self.last_frame = None # 保存图片数据 - self.frame_queue = queue.Queue(maxsize=1) - self.counter = 0 #帧序列号--保存报警录像使用 - self.icount_max = icount_max #帧序列号上限 - self.lock = threading.RLock() # 用于保证线程安全 + self.bModel = False #该通道是否有模型标识 + self.frame_interval = 1.0 / int(myCongif.get_data("verify_rate")) + self.mylogger = LogHandler().get_logger("ChannelData") + #通道-模型推理输入数据 + self.cap = None + self.in_check_area = None + self.in_polygon = None + self.last_frame_time = time.time() + self.preimg_q = queue.Queue(maxsize=myCongif.get_data("q_size")) #这是预处理后的图片--模型推理使用 + self.img_q = queue.Queue(maxsize=myCongif.get_data("q_size")) # 原图-后处理使用 + #图片的缩放比例 + self.cale_ratio = None + self.pad_size = None + self.schedule = None # 布放计划 + self.result = None # 结果记录 + self.proportion = None #报警占比 + self.warn_save_count = 0 #保存录像的最新帧初始化为0 + self.model_name = None #关联模型的名字 + self.warn_last_time = time.time() + + #通道-模型推理数据 + self.last_infer_time = time.time() + self.infer_img_q = queue.Queue(maxsize=myCongif.get_data("q_size")) # 原图-后处理使用 + + #通道-模型推理输出数据 + self.last_out_time = time.time() + self.output_q = queue.Queue(maxsize=myCongif.get_data("q_size")) + self.deque_frame = deque(maxlen=deque_length) #推理后的缓冲区数据 + self.counter = 0 # 帧序列号--保存报警录像使用 + self.icount_max = icount_max # 帧序列号上限 + self.last_frame = None # 保存图片数据 方案一 + self.frame_queue = queue.Queue(maxsize=1) #保持图片数据 方案二 + self.lock = threading.RLock() # 用于保证线程安全 -- 输出数据锁 + self.mMM = None #ModelManager实例对象 + + def __del__(self): + self.cap.release() #停止视频采集线程 + + def cleardata(self): #清理数据 + pass + + '''输入数据相关函数''' + def set_in_data(self,in_check_area,in_polygon): + self.in_check_area = in_check_area + self.in_polygon = in_polygon + + def set_in_cale_ratio(self,cale_ratio,pad_size): + if self.cale_ratio is None: + self.cale_ratio = cale_ratio + self.pad_size = pad_size + + '''输出数据相关函数''' #添加一帧图片 def add_deque(self, value): self.deque_frame.append(value) #deque 满了以后会把前面的数据移除 @@ -27,26 +80,37 @@ class ChannelData: #获取最后一帧图片 def get_last_frame(self): - with self.lock: - frame = self.last_frame + if self.bModel: + #print("取画面") + # with self.lock: + # frame = self.last_frame + if not self.frame_queue.empty(): + return self.frame_queue.get() + else: + return None + else: + ret,img = self.cap.read() + if not ret: + return None + #img_bgr_ndarray = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) + # 在线程里面完成应该可以减少网页端处理时间 + ret, frame_bgr_webp = cv2.imencode('.jpg', img) + if not ret: + frame = None + else: + frame = frame_bgr_webp.tobytes() return frame - # if not self.frame_queue.empty(): - # return self.frame_queue.get() - # else: - # return None - def update_last_frame(self,buffer): if buffer: - with self.lock: - self.last_frame = None - self.last_frame = buffer - # if not self.frame_queue.full(): - # self.frame_queue.put(buffer) - # else: - # self.frame_queue.get() # 丢弃最旧的帧 - # self.frame_queue.put(buffer) - + # with self.lock: + # self.last_frame = None + # self.last_frame = buffer + if not self.frame_queue.full(): + self.frame_queue.put(buffer) + else: + self.frame_queue.get() # 丢弃最旧的帧 + self.frame_queue.put(buffer) #帧序列号自增 一个线程中处理,不用加锁 def increment_counter(self): @@ -68,6 +132,145 @@ class ChannelData: def stop_run(self): self.bool_run = False + def th_sleep(self,frame_interval,last_time): + # 控制帧率 -- 推理帧率必须所有模型一致,若模型推理耗时一致,该方案还算可以。 + current_time = time.time() + elapsed_time = current_time - last_time + if elapsed_time < frame_interval: + time.sleep(frame_interval - elapsed_time) # 若小于间隔时间则休眠 + + def is_in_schedule(self): + '''判断当前时间是否在该通道的工作计划时间内''' + # 验证检测计划,是否在布防时间内 + now = datetime.now() # 获取当前日期和时间 + weekday = now.weekday() # 获取星期几,星期一是0,星期天是6 + hour = now.hour + if self.schedule[weekday][hour] == 1: # 不在计划则不进行验证,直接返回图片 + return 0 + else: + next_hour = (now + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0) + seconds_until_next_hour = (next_hour - now).seconds + return seconds_until_next_hour + + def th_prework(self,model): + last_pre_time = time.time() + while self.bool_run: + #start_do_time = time.time() + # 控制帧率 -- 推理帧率必须所有模型一致, + self.th_sleep(self.frame_interval, last_pre_time) + last_pre_time = time.time() + #判断是否在布防计划内 + sleep_time = self.is_in_schedule() + if sleep_time == 0: #判断是否工作计划内-- 后来判断下是否推理和后处理线程需要休眠 + self.bModel = True + else: + self.bModel = False + time.sleep(sleep_time) #工作计划以小时为单位,休息到该小时结束 + continue + # 读取图片进行推理 + ret, img = self.cap.read() + if not ret: + continue + #img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) #这个要确认下什么模式输入进预处理 #? + preimg,scale_ratio, pad_size = model.prework(img) #子类实现-根据每个模型的需要进行预处理 + if not self.preimg_q.full(): + self.preimg_q.put(preimg) + self.img_q.put(img) + self.set_in_cale_ratio(scale_ratio, pad_size) + else: + self.mylogger.debug("preimg_q--预处理队列满了! infer线程处理过慢!") + #end_do_time = time.time() + # # 计算执行时间(秒) + # execution_time = end_do_time - start_do_time + # # 输出执行时间 + # print(f"预处理代码执行时间为:{execution_time:.6f} 秒") + + def th_postwork(self,model): + warn_interval = int(myCongif.get_data("warn_interval")) + last_out_time = time.time() + while self.bool_run: + #do_strat_time = time.time() + # # 控制帧率 , + # self.th_sleep(self.frame_interval, last_out_time) + # last_out_time = time.time() + #执行后处理 + output = self.output_q.get()#为空时会阻塞在get + img = self.infer_img_q.get() + # 子类实现--具体的报警逻辑 + filtered_pred_all, bwarn, warn_text = model.postwork(img, output, self.in_check_area,self.in_polygon, + self.cale_ratio,self.pad_size) + # img的修改应该在原内存空间修改的 + self.result.pop(0) # 先把最早的结果推出数组,保障结果数组定长 + if bwarn: + # 绘制报警文本 + #cv2.putText(img, warn_text, (50, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2) + self.result.append(1) + else: # 没有产生报警也需要记录,统一计算占比 + self.result.append(0) + + # 在线程里面完成应该可以减少网页端处理时间 + ret, frame_bgr_webp = cv2.imencode('.jpg', img) + if not ret: + buffer_bgr_webp = None + else: + buffer_bgr_webp = frame_bgr_webp.tobytes() + + # 分析图片放入内存中 + self.add_deque(img) + self.increment_counter() # 帧序列加一 + # 一直更新最新帧,提供网页端显示 + self.update_last_frame(buffer_bgr_webp) + # print(f"{channel_id}--Frame updated at:",time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())) + if bwarn: + count_one = float(sum(self.result)) # 1,0 把1累加的和就是1的数量 + ratio_of_ones = count_one / len(self.result) + # self.logger.debug(result) + if ratio_of_ones >= self.proportion: # 触发报警 + # 基于时间间隔判断 + current_time = time.time() + elapsed_time = current_time - self.warn_last_time + if elapsed_time < warn_interval: + continue + self.warn_last_time = current_time + # 处理报警 + warn_data = WarnData() + warn_data.model_name = self.model_name + warn_data.warn_text = warn_text + warn_data.img_buffer = self.copy_deque() #深度复制缓冲区 + warn_data.width = self.cap.width + warn_data.height = self.cap.height + warn_data.channel_id = self.channel_id + self.mMM.add_warm_data(warn_data) + # model_name = self.model_name + # w_s_count = self.warn_save_count # 上次保存的缓冲帧序号 + # buffer_count = self.get_counter() + # # 线程? + # self.mMM.save_warn(model_name, w_s_count, buffer_count, self.copy_deque(), + # self.cap.width, self.cap.height, self.channel_id, + # self.mMM.FPS, self.mMM.fourcc) + # self.mMM.send_warn() + # # 更新帧序列号 + # self.warn_save_count = buffer_count + # 结果记录要清空 + for i in range(len(self.result)): + self.result[i] = 0 + + # do_end_time = time.time() + # # 计算执行时间(秒) + # execution_time = do_end_time - do_strat_time + # # 输出执行时间 + # print(f"*************************************后处理代码执行时间为:{execution_time:.6f} 秒") + + + #执行预和后处理线程,每个通道一个 + def start_channel_thread(self,mMM,model): + self.mMM = mMM + th_pre = threading.Thread(target=self.th_prework, args=(model,)) # 一个视频通道一个线程,线程句柄暂时部保留 + th_pre.start() + + th_post = threading.Thread(target=self.th_postwork,args=(model,)) # 一个视频通道一个线程,线程句柄暂时部保留 + th_post.start() + class ChannelManager: def __init__(self): @@ -80,7 +283,9 @@ class ChannelManager: if channel_id in self.channels: #若已经有数据,先删除后再增加 self.channels[channel_id].clear() # 手动清理资源 del self.channels[channel_id] - self.channels[channel_id] = ChannelData(str_url, int_type, bool_run, deque_length,icount_max) + ch_data = ChannelData(channel_id,str_url, int_type, bool_run, deque_length,icount_max) + self.channels[channel_id] = ch_data + return ch_data #删除节点 def delete_channel(self, channel_id): diff --git a/core/ModelManager.py b/core/ModelManager.py index 3ca55c2..c206507 100644 --- a/core/ModelManager.py +++ b/core/ModelManager.py @@ -16,6 +16,7 @@ from myutils.ConfigManager import myCongif from model.plugins.ModelBase import ModelBase from core.ChannelManager import ChannelManager from core.ACLModelManager import ACLModeManger +from core.WarnManager import WarnManager from PIL import Image @@ -25,7 +26,11 @@ class VideoCaptureWithFPS: self.source = source self.width = None self.height = None - self.cap = cv2.VideoCapture(self.source) + #GStreamer + rtsp_stream = f"rtspsrc location={self.source} ! decodebin ! videoconvert ! appsink" + self.cap = cv2.VideoCapture(rtsp_stream, cv2.CAP_GSTREAMER) + #opencv + #self.cap = cv2.VideoCapture(self.source) if self.cap.isOpened(): #若没有打开成功,在读取画面的时候,已有判断和处理 -- 这里也要检查下内存的释放情况 self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH)) self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) @@ -43,7 +48,10 @@ class VideoCaptureWithFPS: def update(self): icount = 0 while self.running: + #start_time = time.time() ret, frame = self.cap.read() + # end_time = time.time() + # print(f"read()耗时:{(end_time-start_time):.6f}") if not ret: icount += 1 if icount > 5: #重连 @@ -57,7 +65,8 @@ class VideoCaptureWithFPS: self.cap.get(cv2.CAP_PROP_FPS) / float(myCongif.get_data("verify_rate"))) # 向上取整。 icount = 0 else: - time.sleep(1) + print(f"{self.source}视频流,将于5分钟后重连!") + time.sleep(myCongif.get_data("cap_sleep_time")) continue #resized_frame = cv2.resize(frame, (int(self.width / 2), int(self.height / 2))) with self.read_lock: @@ -74,6 +83,7 @@ class VideoCaptureWithFPS: def read(self): with self.read_lock: frame = self.frame.copy() if self.frame is not None else None + #frame = self.frame if frame is not None: return True, frame else: @@ -99,16 +109,17 @@ class ModelManager: #self.buflen = myCongif.get_data("buffer_len") self.icout_max = myCongif.get_data("RESET_INTERVAL") #跟视频帧序用一个变量 self.frame_rate = myCongif.get_data("frame_rate") - self.frame_interval = 1.0 / int(myCongif.get_data("verify_rate")) - #保存视频相关内容 - self.FPS = myCongif.get_data("verify_rate") # 视频帧率--是否能实现动态帧率 - self.fourcc = cv2.VideoWriter_fourcc(*'mp4v') # 使用 mp4 编码 + #基于模型运行环境进行相应初始化工作 self.model_platform = myCongif.get_data("model_platform") self.device_id = myCongif.get_data("device_id") - # acl初始化 -- 一个线程一个 -- 需要验证 + # acl资源初始化 if self.model_platform == "acl": ACLModeManger.init_acl(self.device_id) #acl -- 全程序初始化 + self.model_dic = {} #model_id model + #报警处理线程-全进程独立一个线程处理 + self.warnM = None + def __del__(self): self.logger.debug("释放资源") @@ -140,7 +151,7 @@ class ModelManager: return None module = importlib.util.module_from_spec(module_spec) module_spec.loader.exec_module(module) - md = getattr(module, "Model")(model_path,threshold) #实例化类 + md = getattr(module, "Model")(model_path,self,threshold) #实例化Model if not isinstance(md, ModelBase): self.logger.error("{} not zf_model".format(md)) return None @@ -199,269 +210,89 @@ class ModelManager: def set_last_img(self,): pass - def verify(self,frame,myModle_list,myModle_data,channel_id,schedule_list,result_list,isdraw=1): - '''验证执行主函数,实现遍历通道关联的模型,调用对应模型执行验证,模型文件遍历执行''' - img = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - #img = np.ascontiguousarray(img, dtype=np.float32) / 255.0 # 转换为内存连续存储的数组 --该函数可以待定下是不是所有模型都可以做 - # img = frame.to_ndarray(format="bgr24") - #img = frame - # 使用 模型 进行目标检测 - i_warn_count = 0 #报警标签 - #isverify = False - for i in range(len(myModle_list)): # 遍历通道关联的算法进行检测,若不控制模型数量,有可能需要考虑多线程执行。 - model = myModle_list[i] - data = myModle_data[i] - schedule = schedule_list[i] - result = result_list[i] - #验证检测计划,是否在布防时间内 - now = datetime.datetime.now() # 获取当前日期和时间 - weekday = now.weekday() # 获取星期几,星期一是0,星期天是6 - hour = now.hour - result.pop(0) # 保障结果数组定长 --先把最早的结果推出数组 - if schedule[weekday][hour] == 1: #不在计划则不进行验证,直接返回图片 - # 调用模型,进行检测,model是动态加载的,具体的判断标准由模型内执行 ---- ********* - #isverify = True - detections, bwarn, warntext = model.verify(img, data,isdraw) #****************重要 - # 对识别结果要部要进行处理 - if bwarn: # 整个识别有产生报警 - #根据模型设定的时间和占比判断是否 - # 绘制报警文本 - cv2.putText(img, 'Intruder detected!', (50, (i_warn_count + 1) * 50), - cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2) - i_warn_count += 1 - result.append(1) #要验证数组修改,是地址修改吗? - else: #没有产生报警也需要记录,统一计算占比 - result.append(0) - else: - result.append(0) - # if not isverify: #没做处理,直接返回的,需要控制下帧率,太快读取没有意义。 --2024-7-5 取消休眠,帧率控制在dowork_thread完成 - # time.sleep(1.0/self.frame_rate) #给个默认帧率,不超过30帧,---若经过模型计算,CPU下单模型也就12帧这样 - - # 将检测结果图像转换为帧--暂时用不到AVFrame--2024-7-5 - # new_frame_rgb_avframe = av.VideoFrame.from_ndarray(img, format="rgb24") # AVFrame - # new_frame_rgb_avframe.pts = None # 添加此行确保有pts属性 - - # if isinstance(img, np.ndarray): -- 留个纪念 - #处理完的图片后返回-bgr模式 - img_bgr_ndarray = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) - # 将检查结果转换为WebP格式图片 --在线程里面完成应该可以减少网页端处理时间 - ret,frame_bgr_webp=cv2.imencode('.jpg', img_bgr_ndarray) - if not ret: - buffer_bgr_webp = None - else: - buffer_bgr_webp = frame_bgr_webp.tobytes() - return buffer_bgr_webp,img_bgr_ndarray - - - def dowork_thread(self,channel_id): - '''一个通道一个线程,关联的模型在一个线程检测,局部变量都是一个通道独有''' - channel_data = self.verify_list.get_channel(channel_id) #是对ChannelData 对象的引用 - context = None - # 线程ACL初始化 - if self.model_platform == "acl": # ACL线程中初始化内容 - context = ACLModeManger.th_inti_acl(self.device_id) + def start_model_thread(self,channel_id): #通道ID会重吗? + '''实例化模型组件,启动模型推理线程''' + channel_data = self.verify_list.get_channel(channel_id) # 是对ChannelData 对象的引用 #查询关联的模型 --- 在循环运行前把基础数据都准备好 - myDBM = DBManager() - myDBM.connect() strsql = (f"select t1.model_id,t1.check_area,t1.polygon ,t2.duration_time,t2.proportion,t2.model_path,t1.ID," f"t2.model_name,t1.conf_threshold " f"from channel2model t1 left join model t2 on t1.model_id = t2.ID where t1.channel_id ={channel_id};") - #print(strsql) - myModels = myDBM.do_select(strsql) - #加载模型 --- 是不是要做个限制,一个视频通道关联算法模块的上限 --- 关联多了一个线程执行耗时较多,造成帧率太低,或者再多线程并发 #? - - myModle_list = [] #存放模型对象List 一个模型一个 - myModle_data = [] #存放检测参数 一个模型一个 - schedule_list = [] #布防策略 -一个模型一个 - result_list = [] #检测结果记录 -一个模型一个 - warn_last_time =[] #最新的报警时间记录 -一个模型一个 - proportion_list = []#占比设定 -一个模型一个 - warn_save_count = []#没个模型触发报警后,保存录像的最新帧序号 -一个模型一个 - - #获取视频通道的模型相关数据-list - for model in myModels: - #基于基类实例化模块类 - m = self._import_model("",model[5],model[8]) #动态加载模型处理文件py --需要验证模型文件是否能加载 - #m = None - if m: - myModle_list.append(m) #没有成功加载的模型原画输出 - myModle_data.append(model) - #model[6] -- c2m_id --布防计划 0-周一,6-周日 - schedule_list.append(self.getschedule(model[6],myDBM)) - result = [0 for _ in range(model[3] * myCongif.get_data("verify_rate"))] #初始化时间*验证帧率数量的结果list - result_list.append(result) - warn_last_time.append(time.time()) - proportion_list.append(model[4]) #判断是否报警的占比 - warn_save_count.append(0) #保存录像的最新帧初始化为0 - - #开始拉取画面循环检测 - cap = None - #iread_count =0 #失败读取的次数 - last_frame_time = time.time() #初始化个读帧时间 - cap_sleep_time = myCongif.get_data("cap_sleep_time") - #可以释放数据库资源 - del myDBM - warn_interval = myCongif.get_data("warn_interval") - while channel_data.bool_run: #基于tag 作为运行标识。 线程里只是读,住线程更新,最多晚一轮,应该不用线程锁。需验证 - # 帧率控制帧率 - current_time = time.time() - elapsed_time = current_time - last_frame_time - if elapsed_time < self.frame_interval: - time.sleep(self.frame_interval - elapsed_time) #若小于间隔时间则休眠 - last_frame_time = time.time() - #*********取画面************* - if not cap: #第一次需要打开视频流 - try: - cap = self._open_view(channel_data.str_url,channel_data.int_type) #创建子线程读画面 - except: - self.logger.error("打开视频参数错误,终止线程!") - return - ret,frame = cap.read() #除了第一帧,其它应该都是有画面的 - if not ret: - # if iread_count > 30: #2024-7-8 重连接机制放VideoCaptureWithFPS - # self.logger.warning(f"通道-{channel_id}:view disconnected. Reconnecting...") - # cap.release() - # cap = None - # time.sleep(cap_sleep_time) - # else: - # iread_count += 1 - continue #没读到画面继续 - #执行图片推理 -- 如何没有模型或不在工作时间,返回的是原画,要不要控制下帧率? -- 在verify中做了sleep - buffer_bgr_webp,img_bgr_ndarray = self.verify(frame,myModle_list,myModle_data,channel_id,schedule_list,result_list) - - #分析图片放入内存中 - channel_data.add_deque(img_bgr_ndarray) # 缓冲区大小由maxlen控制 超上限后,删除最前的数据 - channel_data.increment_counter() #帧序列加一 - # 一直更新最新帧,提供网页端显示 - channel_data.update_last_frame(buffer_bgr_webp) - #print(f"{channel_id}--Frame updated at:",time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())) - #验证result_list -是否触发报警要求 --遍历每个模型执行的result - for i in range(len(result_list)): - result = result_list[i] - proportion = proportion_list[i] - count_one = float(sum(result)) #1,0 把1累加的和就是1的数量 - ratio_of_ones = count_one / len(result) - #self.logger.debug(result) - if ratio_of_ones >= proportion: #触发报警 - # 基于时间间隔判断 - current_time = time.time() - elapsed_time = current_time - warn_last_time[i] - if elapsed_time < warn_interval: - continue - warn_last_time[i] = current_time - model_name = myModle_data[i][7] - w_s_count = warn_save_count[i] - buffer_count = channel_data.get_counter() - self.save_warn(model_name,w_s_count,buffer_count,channel_data.copy_deque(), - cap.width,cap.height,channel_id,None,self.FPS,self.fourcc) - self.send_warn() - #更新帧序列号 - warn_save_count[i] = buffer_count - #结果记录要清空 - for i in range(len(result)): - result[i] = 0 - - # end_time = time.time() # 结束时间 - # print(f"Processing time: {end_time - start_time} seconds") - # 本地显示---测试使用 - # if channel_id == 2: - # cv2.imshow(str(channel_id), img) - # if cv2.waitKey(1) & 0xFF == ord('q'): - # break - #结束线程 - - cap.release() #视频采集线程结束 - if context:#ACL线程中反初始化内容 -- 若线程异常退出,这些资源就不能正常释放了 - #先释放每个模型资源 - for model in myModle_list: - del model - #再释放context - ACLModeManger.th_del_acl(context) - #cv2.destroyAllWindows() - - def save_warn(self,model_name,w_s_count,buffer_count,buffer,width,height,channnel_id,myDBM,FPS,fourcc): - ''' - 保存报警信息 --- 涉及到I/O操作可以通过线程取执行 -- 避免主线程阻塞 --还未验证-2024-7-6 - :param model_name: 模型名称,如人员入侵 - :param w_s_count: 报警已存储的最新帧序列 - :param buffer_count: 当前视频缓冲区的最新帧序列 - :param buffer: 视频缓存区 - :param width: 视频画面的width - :param height: 视频画面的height - :param channnel_id: 视频通道ID - :return: ret 数据库操作记录 - ''' - return - - def save_warn_th(model_name,w_s_count,buffer_count,buffer,width,height,channnel_id,myDBM,FPS,fourcc): - now = datetime.datetime.now() # 获取当前日期和时间 - current_time_str = now.strftime("%Y-%m-%d_%H-%M-%S") - 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)) - if not video_writer.isOpened(): - print(f"Failed to open video writer for model/warn/{filename}.mp4") - return False - ilen = len(buffer) - istart = 0; - iend = ilen - if buffer_count < w_s_count or (buffer_count-w_s_count) > ilen: #buffer_count重置过 - #buffer区,都保存为视频 - istart = 0 - else:#只取差异的缓冲区大小 - istart = ilen - (buffer_count-w_s_count) - for i in range(istart,iend): - video_writer.write(buffer[i]) - video_writer.release() - #保存图片 - ret = cv2.imwrite(f"model/warn/{filename}.png",buffer[-1]) - #buffer使用完后删除 - del buffer - if not ret: - print("保存图片失败") - return False - #保存数据库 - myDBM = DBManager() - myDBM.connect() - strsql = (f"INSERT INTO warn (model_name ,video_path ,img_path ,creat_time,channel_id ) " - f"Values ('{model_name}','model/warn/{filename}.mp4','model/warn/{filename}.png'," - f"'{current_time_str}','{channnel_id}');") - ret = myDBM.do_sql(strsql) - del myDBM #释放数据库连接资源 - return ret - - th_chn = threading.Thread(target=save_warn_th, - args=(model_name,w_s_count,buffer_count,buffer,width,height,channnel_id,None,FPS,fourcc,)) # 一个视频通道一个线程,线程句柄暂时部保留 - th_chn.start() - - def send_warn(self): - '''发送报警信息''' - pass + # print(strsql) + model = mDBM.do_select(strsql,1) #2024-7-12调整规则,一个通道只关联一个模型,表结构暂时不动 + if model: + strMID = str(model[0]) + m = None + if strMID in self.model_dic:#该模型线程已启动 + m = self.model_dic[strMID] + else: + # 基于基类实例化模块类 + m = self._import_model("", model[5], model[8]) # 动态加载模型处理文件py --需要验证模型文件是否能加载 + if m: + # 开始工作线程---推理线程需不需要全进程唯一 + m.run = True + m.start_th() #模型里跑两个线程 + #添加模型对象到字典中 + self.model_dic[strMID] = m + else: + self.logger.error(f"{model[5]}没有实例化成功") + return #模型没加载成功--原画输出 + # 更新该模型对应视频通道的数据 + channel_data.set_in_data(model[1], model[2]) #chack_area,ploygon + channel_data.schedule = self.getschedule(model[6], mDBM) # 布放计划-c_m_id + channel_data.result = [0 for _ in + range(model[3] * myCongif.get_data("verify_rate"))] # 初始化时间*验证帧率数量的结果list + channel_data.proportion = model[4] # 报警占比 + channel_data.warn_last_time = time.time() # 最后次触发报警的时间 + channel_data.model_name = model[7] + # 添加通道 + m.addChannel(channel_id, channel_data) #删除时不能手动清空内存 2024-7-14 + #启动视频通道预处理和后处理线程 + channel_data.start_channel_thread(self,m) #一个视频通道,一个预和后处理线程 + + #删除还没完善 del self.model_dic[strMID] + else: #没有模型数据--channel_data.bModel = Flase ,不需要添加数据,直接原画输出 + return + + def stop_model_thread(self,channel_id,model_id): + '''某个视频通道结束工作''' + channel_data = self.verify_list.get_channel(channel_id) # 是对ChannelData 对象的引用 + m = self.model_dic.get(model_id) + if m: + m.strop_th() + channel_data.cap.release() def save_frame_to_video(self): '''把缓冲区中的画面保存为录像''' pass - def start_work(self,channel_id=0): + def start_work(self,channel_id=0): #还涉及单通道对开启和关闭 '''算法模型是在后台根据画面实时分析的 1.布防开关需要触发通道关闭和开启 2.布防策略的调整也需要关闭和重启工作 ''' + #pre-thread if channel_id ==0: strsql = "select id,ulr,type from channel where is_work = 1;" #执行所有通道 else: strsql = f"select id,ulr,type from channel where is_work = 1 and id = {channel_id};" #单通道启动检测线程 datas = mDBM.do_select(strsql) for data in datas: - # img_buffer = deque(maxlen=myCongif.get_data("buffer_len")) #创建个定长的视频buffer - # img = None - # icout = 0 #跟img_buffer对应,记录进入缓冲区的帧序列号 - # run_data = [data[1],data[2],True,img_buffer,img,icout] - # self.verify_list[data[0]] = run_data #需要验证重复情况#? channel_id, str_url, int_type, bool_run, deque_length - self.verify_list.add_channel(data[0],data[1],data[2],True,myCongif.get_data("buffer_len"),myCongif.get_data("RESET_INTERVAL")) - th_chn = threading.Thread(target=self.dowork_thread, args=(data[0],)) #一个视频通道一个线程,线程句柄暂时部保留 - th_chn.start() + # 创建channel_data + ch_data = self.verify_list.add_channel(data[0],data[1],data[2],True, + myCongif.get_data("buffer_len"),myCongif.get_data("RESET_INTERVAL")) + + #启动该通道的视频捕获线程 + ch_data.cap = self._open_view(ch_data.str_url,ch_data.int_type) #创建子线程读画面-把cap给模型就行-- + ch_data.mMM = self + + #目前一个模型两个线程 + self.start_model_thread(data[0]) + #启动告警线程 + self.warnM = WarnManager() + self.warnM.start_warnmanager_th() + + def add_warm_data(self,warn_data): + self.warnM.add_warn_data(warn_data) def stop_work(self,channel_id=0): '''停止工作线程,0-停止所有,非0停止对应通道ID的线程''' diff --git a/core/WarnManager.py b/core/WarnManager.py new file mode 100644 index 0000000..b1af851 --- /dev/null +++ b/core/WarnManager.py @@ -0,0 +1,102 @@ +import threading +import queue +import datetime +import cv2 +from core.DBManager import DBManager +from myutils.ConfigManager import myCongif + +class WarnData: + def __init__(self): + self.width = None #视频画面的width + self.height = None #视频画面的height + self.channel_id = None + self.model_name = None #模型名称,如人员入侵 + self.img_buffer = None #视频缓冲区 赋值时要拷贝一个备份 + + self.warn_text = None + self.channel_name = None + + + +class WarnManager: + def __init__(self): + self.warn_q = queue.Queue() #线程安全 + self.brun = True + # 保存视频相关内容 + self.FPS = myCongif.get_data("verify_rate") # 视频帧率--是否能实现动态帧率 + self.fourcc = cv2.VideoWriter_fourcc(*'mp4v') # 使用 mp4 编码 + + def __del__(self): + pass + + def add_warn_data(self,warn_data): + self.warn_q.put(warn_data) + + def th_warnmanager(self): + myDBM = DBManager() + myDBM.connect() + while self.brun: + warn_data = self.warn_q.get() + 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 + del warn_data + + + def start_warnmanager_th(self): + th_warn = threading.Thread(target=self.th_warnmanager) # 一个视频通道一个线程,线程句柄暂时部保留 + th_warn.start() + + def stop_warnmanager_th(self): + self.brun = False + del self.warn_q + + def send_warn(self): + '''发送报警信息''' + pass + + def save_warn(self,model_name,buffer,width,height,channnel_id,FPS,fourcc,myDBM): + ''' + 保存报警信息 --- 涉及到I/O操作可以通过线程取执行 -- 避免主线程阻塞 --还未验证-2024-7-6 + :param model_name: 模型名称,如人员入侵 + :param w_s_count: 报警已存储的最新帧序列 + :param buffer_count: 当前视频缓冲区的最新帧序列 + :param buffer: 视频缓存区 + :param width: 视频画面的width + :param height: 视频画面的height + :param channnel_id: 视频通道ID + :return: ret 数据库操作记录 + ''' + now = datetime.datetime.now() # 获取当前日期和时间 + current_time_str = now.strftime("%Y-%m-%d_%H-%M-%S") + 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)) + if not video_writer.isOpened(): + print(f"Failed to open video writer for model/warn/{filename}.mp4") + return False + ilen = len(buffer) + istart = 0; + iend = ilen + + for i in range(len(buffer)): + video_writer.write(buffer[i]) + video_writer.release() + # 保存图片 + ret = cv2.imwrite(f"model/warn/{filename}.png", buffer[-1]) + # buffer使用完后删除 + del buffer + if not ret: + print("保存图片失败") + return False + # 保存数据库 + + strsql = (f"INSERT INTO warn (model_name ,video_path ,img_path ,creat_time,channel_id ) " + f"Values ('{model_name}','model/warn/{filename}.mp4','model/warn/{filename}.png'," + f"'{current_time_str}','{channnel_id}');") + ret = myDBM.do_sql(strsql) + del myDBM # 释放数据库连接资源 + return ret + diff --git a/model/plugins/ModelBase.py b/model/plugins/ModelBase.py index e88da01..02e147c 100644 --- a/model/plugins/ModelBase.py +++ b/model/plugins/ModelBase.py @@ -4,6 +4,10 @@ from myutils.ConfigManager import myCongif from myutils.MyLogger_logger import LogHandler import numpy as np import cv2 +import time +import queue +from datetime import datetime, timedelta +import threading import ast if myCongif.get_data("model_platform") == "acl": import acl @@ -14,30 +18,293 @@ SUCCESS = 0 # 成功状态值 FAILED = 1 # 失败状态值 ACL_MEM_MALLOC_NORMAL_ONLY = 2 # 申请内存策略, 仅申请普通页 +class ModelRunData: + def __init__(self): + self.channel_id = None + + class ModelBase(ABC): - def __init__(self,path): + def __init__(self,path,mMM): ''' 模型类实例化 :param path: 模型文件本身的路径 :param threshold: 模型的置信阈值 ''' + self.model_path = path # 模型路径 + self.mMM = mMM # ModelManager + self.mylogger = LogHandler().get_logger("ModelManager") self.name = None #基于name来查询,用户对模型的配置参数,代表着模型名称需要唯一 2024-6-18 -逻辑还需要完善和验证 self.version = None self.model_type = None # 模型类型 1-图像分类,2-目标检测(yolov5),3-分割模型,4-关键点 self.system = myCongif.get_data("model_platform") #platform.system() #获取系统平台 - self.do_map = { # 定义插件的入口函数 -- - # POCType.POC: self.do_verify, - # POCType.SNIFFER: self.do_sniffer, - # POCType.BRUTE: self.do_brute - } - self.model_path = path # 模型路径 + #--2024-7-12调整规则,一个视频通道只允许配置一个算法模型,一个算法模型可以配置给多路通道 + self.channel_list = [] #该模型需要处理的视频通道 + self.cid_copy_list = [] #基于channel_list的备份,提供给遍历线程 + self.channel_data_list =[] #该模型,针对每个通道配置的执行参数 ChannelData --包含了输入和输出数据 + self.cda_copy_list = [] #基于channel_data_list的备份,提供给遍历线程 + self.frame_interval = 1.0 / int(myCongif.get_data("verify_rate")) self.init_ok = False + #启动推理线程 -- 实例化后是否启动工作线程 -- acl这么搞 + self.run = False + #创建线程锁 + self.list_lock = threading.Lock() #list修改锁 + self.copy_lock = threading.Lock() #副本拷贝锁 + self.read_lock = threading.Lock() #遍历线程锁 + self.readers = 0 #并发读个数 def __del__(self): print("资源释放") + def addChannel(self,channel_id,channel_data): #这两个参数有点重复,后续考虑优化 + bfind = False + with self.list_lock: + for cid in self.channel_list: + if cid == channel_id: + bfind = True + if not bfind: + self.channel_data_list.append(channel_data) + self.channel_list.append(channel_id) + #复制备份 + self.set_copy_list() + + def delChannel(self,channel_id):#调用删除通道的地方,若channel为空,则停止线程,删除该模型对象 + with self.list_lock: + for i,cid in enumerate(self.channel_list): + if cid == channel_id: + self.channel_list.remove(channel_id) + #self.channel_data_list[i].cleardata() #释放内存 + self.channel_data_list.pop(i) + # 复制备份 + self.set_copy_list() + + def set_copy_list(self): #把list拷贝一个副本 + with self.copy_lock: + self.cid_copy_list = self.channel_list.copy() + self.cda_copy_list = self.channel_data_list.copy() + + # copy使用读写锁,这样能避免三个遍历线程间相互竞争 + def acquire_read(self): + with self.read_lock: + self.readers += 1 + if self.readers == 1: + self.copy_lock.acquire() + + def release_read(self): + with self.read_lock: + self.readers -= 1 + if self.readers == 0: + self.copy_lock.release() + + def get_copy_list(self): #线程中拷贝一个本地副本 其实读线程里面执行的时间复杂度也不高,直接用copy锁一样 + self.acquire_read() + local_id = self.cid_copy_list.copy() + local_data = self.cda_copy_list.copy() + self.release_read() + return local_id,local_data + + + def strop_th(self): + '''停止该模型的工作线程''' + self.run = False + time.sleep(1) #确认下在哪执行 + #删除list + del self.channel_list + + def start_th(self): + #要确保三个线程对channel_data的读取和修改是线程安全的,或是独立分开的。 + #预处理 + # th_pre = threading.Thread(target=self.th_prework) # 一个视频通道一个线程,线程句柄暂时部保留 + # th_pre.start() + + #推理 + th_infer = threading.Thread(target=self.th_startwork) # 一个视频通道一个线程,线程句柄暂时部保留 + th_infer.start() + + #后处理 + # th_post = threading.Thread(target=self.th_postwork) # 一个视频通道一个线程,线程句柄暂时部保留 + # th_post.start() + + def th_sleep(self,frame_interval,last_time): + # 控制帧率 -- 推理帧率必须所有模型一致,若模型推理耗时一致,该方案还算可以。 + current_time = time.time() + elapsed_time = current_time - last_time + if elapsed_time < frame_interval: + time.sleep(frame_interval - elapsed_time) # 若小于间隔时间则休眠 + + def is_in_schedule(self,channel_data): + '''判断当前时间是否在该通道的工作计划时间内''' + # 验证检测计划,是否在布防时间内 + now = datetime.now() # 获取当前日期和时间 + weekday = now.weekday() # 获取星期几,星期一是0,星期天是6 + hour = now.hour + if channel_data.schedule[weekday][hour] == 1: # 不在计划则不进行验证,直接返回图片 + return 0 + else: + next_hour = (now + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0) + seconds_until_next_hour = (next_hour - now).seconds + return seconds_until_next_hour + + def th_prework(self): + last_pre_time = time.time() + while self.run: + #start_do_time = time.time() + # 控制帧率 -- 推理帧率必须所有模型一致, + self.th_sleep(self.frame_interval, last_pre_time) + last_pre_time = time.time() + #拷贝副本到线程本地 + with self.copy_lock: + #local_cid = self.cid_copy_list.copy() + local_cdata = self.cda_copy_list.copy() + for channel_data in local_cdata: # 如果没有视频通道结束线程 + #判断是否在布防计划内 + sleep_time = self.is_in_schedule(channel_data) + if sleep_time == 0: #判断是否工作计划内-- 后来判断下是否推理和后处理线程需要休眠 + channel_data.bModel = True + else: + channel_data.bModel = False + time.sleep(sleep_time) + continue + # 读取图片进行推理 + ret, img = channel_data.cap.read() + if not ret: + continue + #img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) #这个要确认下什么模式输入进预处理 #? + preimg,scale_ratio, pad_size = self.prework(img) #子类实现-根据每个模型的需要进行预处理 + if not channel_data.preimg_q.full(): + channel_data.preimg_q.put(preimg) + channel_data.img_q.put(img) + channel_data.set_in_cale_ratio(scale_ratio, pad_size) + else: + self.mylogger.debug("preimg_q--预处理队列满了! infer线程处理过慢!") + #end_do_time = time.time() + # # 计算执行时间(秒) + # execution_time = end_do_time - start_do_time + # # 输出执行时间 + # print(f"预处理代码执行时间为:{execution_time:.6f} 秒") + + def th_startwork(self): + '''模型工作线程 由于有多输入通道,需要将执行任务降低到最少''' + self._init_acl() #创建context + if not self._init_resource(): #加载模型文件 -- 正常来说这里不应该失败--上层函数暂时都认为是成功的!需完善 + self.mylogger.error("模型文件初始化加载失败!") + return + last_infer_time = time.time() + #开始工作--- 尽量精简! + while self.run: + #start_do_time = time.time() + # 控制帧率 -- 推理帧率必须所有模型一致, + self.th_sleep(self.frame_interval, last_infer_time) + last_infer_time = time.time() + # 拷贝副本到线程本地 + with self.copy_lock: + # local_cid = self.cid_copy_list.copy() + local_cdata = self.cda_copy_list.copy() + + for channel_data in local_cdata: #如果没有视频通道可以结束线程#? + if channel_data.preimg_q.empty(): + continue + img = channel_data.preimg_q.get() + src_img = channel_data.img_q.get() + #就执行推理,针对结果的逻辑判断交给后处理线程处理。-- 需要确认除目标识别外其他模型的执行方式 + output = self.execute([img,])[0] # 执行推理 + if len(output) > 0: + if not channel_data.output_q.full(): + channel_data.output_q.put(output) + channel_data.infer_img_q.put(src_img) #原图交给后处理线程 + else: + self.mylogger.debug("output_q--后处理队列满了!后处理过慢!") + # end_do_time= time.time() + # # 计算执行时间(秒) + # execution_time = end_do_time - start_do_time + # # 输出执行时间 + # print(f"****************推理代码执行时间为:{execution_time:.6f} 秒") + #结束工作-开始释放资源 + self.release() + self._del_acl() + + def th_postwork(self): + warn_interval = int(myCongif.get_data("warn_interval")) + last_out_time = time.time() + while self.run: + # 控制帧率 -- 推理帧率必须所有模型一致, + self.th_sleep(self.frame_interval, last_out_time) + last_out_time = time.time() + # 拷贝副本到线程本地 + with self.copy_lock: + local_cid = self.cid_copy_list.copy() + local_cdata = self.cda_copy_list.copy() + for i, channel_data in enumerate(local_cdata): + # 控制帧率 -- 推理帧率必须所有模型一致,若模型推理耗时一致,该方案还算可以。 + if channel_data.output_q.empty(): + continue + #执行后处理 + output = channel_data.output_q.get() + img = channel_data.infer_img_q.get() + cale_ratio = channel_data.cale_ratio + pad_size = channel_data.pad_size + + if len(output) <1: + continue + filtered_pred_all, bwarn, warn_text = self.postwork(img,output,channel_data.in_check_area, + channel_data.in_polygon,cale_ratio,pad_size) #子类实现--具体的报警逻辑 + #img的修改应该在原内存空间修改的 + channel_data.result.pop(0) #先把最早的结果推出数组,保障结果数组定长 + if bwarn: # 整个识别有产生报警 + #根据模型设定的时间和占比判断是否 + # 绘制报警文本 + cv2.putText(img, warn_text, (50,50),cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2) + channel_data.result.append(1) + else: #没有产生报警也需要记录,统一计算占比 + channel_data.result.append(0) + + # 处理完的图片后返回-bgr模式 --一头一尾是不是抵消了,可以不做处理#? + #img_bgr_ndarray = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) + # 在线程里面完成应该可以减少网页端处理时间 + ret, frame_bgr_webp = cv2.imencode('.jpg', img) + if not ret: + buffer_bgr_webp = None + else: + buffer_bgr_webp = frame_bgr_webp.tobytes() + + # 分析图片放入内存中 + #channel_data.add_deque(img_bgr_ndarray) # 缓冲区大小由maxlen控制 超上限后,删除最前的数据 + channel_data.add_deque(img) + channel_data.increment_counter() # 帧序列加一 + # 一直更新最新帧,提供网页端显示 + channel_data.update_last_frame(buffer_bgr_webp) + # print(f"{channel_id}--Frame updated at:",time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())) + # 验证result_list -是否触发报警要求 --遍历每个模型执行的result + + if bwarn: + result = channel_data.result #最近的检测记录 + proportion = channel_data.proportion #判断报警的占比 + count_one = float(sum(result)) # 1,0 把1累加的和就是1的数量 + ratio_of_ones = count_one / len(result) + # self.logger.debug(result) + if ratio_of_ones >= proportion: # 触发报警 + # 基于时间间隔判断 + current_time = time.time() + elapsed_time = current_time - channel_data.warn_last_time + if elapsed_time < warn_interval: + continue + #处理报警 + channel_data.warn_last_time = current_time + model_name = channel_data.model_name + w_s_count = channel_data.warn_save_count #上次保存的缓冲帧序号 + buffer_count = channel_data.get_counter() + #线程? + self.mMM.save_warn(model_name, w_s_count, buffer_count, channel_data.copy_deque(), + channel_data.cap.width, channel_data.cap.height, local_cid[i], + self.mMM.FPS, self.mMM.fourcc) + self.mMM.send_warn() + # 更新帧序列号 + channel_data.warn_save_count = buffer_count + # 结果记录要清空 + for i in range(len(result)): + result[i] = 0 + def draw_polygon(self, img, polygon_points,color=(0, 255, 0)): self.polygon = Polygon(ast.literal_eval(polygon_points)) @@ -52,8 +319,7 @@ class ModelBase(ABC): return False #acl ----- 相关----- - def _init_acl(self): - device_id = 0 + def _init_acl(self,device_id=0): self.context, ret = acl.rt.create_context(device_id) # 显式创建一个Context if ret: raise RuntimeError(ret) @@ -66,10 +332,8 @@ class ModelBase(ABC): if ret: raise RuntimeError(ret) print('Deinit TH-Context Successfully') - print('ACL finalize Successfully') def _init_resource(self): - #self._init_acl() #测试使用 ''' 初始化模型、输出相关资源。相关数据类型: aclmdlDesc aclDataBuffer aclmdlDataset''' print("Init model resource") # 加载模型文件 @@ -212,17 +476,25 @@ class ModelBase(ABC): ret = acl.destroy_data_buffer(data_buf) # 释放buffer ret = acl.mdl.destroy_dataset(dataset) # 销毁数据集 - # @abstractmethod - # def infer(self, inputs): # 保留接口, 子类必须重写 - # pass - + @abstractmethod + def prework(self, image): # 预处理 + pass @abstractmethod - def verify(self,image,data,isdraw=1): + def verify(self,image,data,isdraw=1): # ''' :param image: 需要验证的图片 :param data: select t1.model_id,t1.check_area,t1.polygon ,t2.duration_time,t2.proportion,t2.model_path :param isdraw: 是否需要绘制线框:0-不绘制,1-绘制 :return: detections,bwarn,warntext bwarn:0-没有识别到符合要求的目标,1-没有识别到符合要求的目标。 ''' - pass \ No newline at end of file + pass + + @abstractmethod + def postwork(self,image,output,check_area,polygon,scale_ratio, pad_size): # 后处理 + pass + + + +if __name__ =="__main__": + pass \ No newline at end of file diff --git a/model/plugins/RYRQ_ACL/RYRQ_Model_ACL.py b/model/plugins/RYRQ_ACL/RYRQ_Model_ACL.py index 93b28ab..1f9fedf 100644 --- a/model/plugins/RYRQ_ACL/RYRQ_Model_ACL.py +++ b/model/plugins/RYRQ_ACL/RYRQ_Model_ACL.py @@ -8,12 +8,12 @@ import torch # 深度学习运算框架,此处主要用来处理数据 from core.ACLModelManager import ACLModeManger class Model(ModelBase): - def __init__(self,path,threshold=0.5): + def __init__(self,path,mMM,threshold=0.5): # 找pt模型路径 -- 一个约束py文件和模型文件的路径关系需要固定, -- 上传模型时,要解压好路径 dirpath, filename = os.path.split(path) self.model_file = os.path.join(dirpath, "yolov5s_bs1.om") # 目前约束模型文件和py文件在同一目录 self.coco_file = os.path.join(dirpath, "coco_names.txt") - super().__init__(self.model_file) #acl环境初始化基类负责类的实例化 + super().__init__(self.model_file,mMM) #acl环境初始化基类负责类的实例化 self.model_id = None # 模型 id self.input_dataset = None # 输入数据结构 self.output_dataset = None # 输出数据结构 @@ -30,6 +30,7 @@ class Model(ModelBase): self.netw = 640 # 缩放的目标宽度, 也即模型的输入宽度 self.conf_threshold = threshold # 置信度阈值 + #加载ACL模型文件---模型加载、模型执行、模型卸载的操作必须在同一个Context下 if self._init_resource(): #加载离线模型,创建输出缓冲区 print("加载模型文件成功!") @@ -41,54 +42,61 @@ class Model(ModelBase): if self.init_ok: self.release() - - def verify(self,image,data,isdraw=1): - labels_dict = get_labels_from_txt('/mnt/zfbox/model/plugins/RYRQ_ACL/coco_names.txt') # 得到类别信息,返回序号与类别对应的字典 + #针对推理图片的前处理 + def prework(self,image): # 数据前处理 img, scale_ratio, pad_size = letterbox(image, new_shape=[640, 640]) # 对图像进行缩放与填充 - img = img[:, :, ::-1].transpose(2, 0, 1) # BGR to RGB, HWC to CHW #图片在输入时已经做了转换 + 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 + + #针对推理结果的后处理 + def postwork(self,image,output,check_area,polygon,scale_ratio, pad_size): + labels_dict = get_labels_from_txt('/mnt/zfbox/model/plugins/RYRQ_ACL/coco_names.txt') # 得到类别信息,返回序号与类别对应的字典 + # 后处理 -- boxout 是 tensor-list: [tensor([[],[].[]])] --[x1,y1,x2,y2,置信度,coco_index] + boxout = nms(torch.tensor(output), conf_thres=0.3, + iou_thres=0.5) # 利用非极大值抑制处理模型输出,conf_thres 为置信度阈值,iou_thres 为iou阈值 + 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)) # 将推理结果缩放到原始图片大小 - # 模型推理, 得到模型输出 - outputs = None - #outputs = self.execute([img,])#创建input,执行模型,返回结果 --失败返回None + # 是否有检测区域,有先绘制检测区域 由于在该函数生成了polygon对象,所有需要在检测区域前调用。 + if check_area == 1: + self.draw_polygon(image, polygon, (0, 0, 255)) + # 过滤掉不是目标标签的数据 -- 序号0-- person + filtered_pred_all = pred_all[pred_all[:, 5] == 0] + bwarn = False + warn_text = "" + # 绘制检测结果 --- 也需要封装在类里, + for pred in filtered_pred_all: + x1, y1, x2, y2 = int(pred[0]), int(pred[1]), int(pred[2]), int(pred[3]) + # # 绘制目标识别的锚框 --已经在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 = "Intruder detected!" + img_dw = draw_bbox(filtered_pred_all, image, (0, 255, 0), 2, labels_dict) # 画出检测框、类别、概率 + # cv2.imwrite('img_res.png', img_dw) + return filtered_pred_all, bwarn, warn_text + + def verify(self,image,outputs,isdraw=1): + labels_dict = get_labels_from_txt('/mnt/zfbox/model/plugins/RYRQ_ACL/coco_names.txt') # 得到类别信息,返回序号与类别对应的字典 + + #后处理部分了 filtered_pred_all = None bwarn = False warn_text = "" - # 是否有检测区域,有先绘制检测区域 由于在该函数生成了polygon对象,所有需要在检测区域前调用。 - if data[1] == 1: - self.draw_polygon(image, data[2], (255, 0, 0)) - if outputs: - output = outputs[0] #只放了一张图片 - # 后处理 -- boxout 是 tensor-list: [tensor([[],[].[]])] --[x1,y1,x2,y2,置信度,coco_index] - boxout = nms(torch.tensor(output), conf_thres=0.3, - iou_thres=0.5) # 利用非极大值抑制处理模型输出,conf_thres 为置信度阈值,iou_thres 为iou阈值 - 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 - 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]) - # # 绘制目标识别的锚框 --已经在draw_bbox里处理 - # cv2.rectangle(image, (x1, y1), (x2, y2), (0, 255, 0), 2) - if data[1] == 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 = "Intruder detected!" - img_dw = draw_bbox(filtered_pred_all, image, (0, 255, 0), 2, labels_dict) # 画出检测框、类别、概率 - #cv2.imwrite('img_res.png', img_dw) - return filtered_pred_all, bwarn, warn_text + def testRun(self): print("1111") \ No newline at end of file diff --git a/run.py b/run.py index 02be082..484905f 100644 --- a/run.py +++ b/run.py @@ -4,11 +4,41 @@ from core.ModelManager import mMM import os import platform import shutil - +import queue +import time +import asyncio +import threading +from hypercorn.asyncio import serve +from hypercorn.config import Config print(f"Current working directory (run.py): {os.getcwd()}") + +def test(): + test_q = queue.Queue(maxsize=10) + test_12 = queue.Queue(maxsize=10) + test_q.put("11") + test_q.put("22") + test_12.put("aa") + test_12.put("bb") + q_list = [] + q_list.append(test_q) + q_list.append(test_12) + while True: + for i,q in enumerate(q_list): + if q.empty(): + continue + print(q.get()) + print("执行一次") + time.sleep(1) + web = create_app() +async def run_quart_app(): + config = Config() + config.bind = ["0.0.0.0:5001"] + await serve(web, config) + + if __name__ == '__main__': system = platform.system() if system == "Windows": @@ -22,5 +52,6 @@ if __name__ == '__main__': print(free/(1024*1024)) mMM.start_work() # 启动所有通道的处理 #mVManager.start_check_rtsp() #线程更新视频在线情况 - web.run(debug=True,port=5001,host="0.0.0.0") + asyncio.run(run_quart_app()) + diff --git a/web/API/viedo.py b/web/API/viedo.py index 6e045bc..640f389 100644 --- a/web/API/viedo.py +++ b/web/API/viedo.py @@ -5,6 +5,7 @@ from core.ModelManager import mMM from core.DBManager import mDBM from myutils.ConfigManager import myCongif import logging +import time # 配置日志 logging.basicConfig(level=logging.INFO) @@ -144,17 +145,28 @@ async def get_stats(peer_connection): @api.websocket('/ws/video_feed/') async def ws_video_feed(channel_id): channel_data = mMM.verify_list.get_channel(channel_id) - frame_rate = myCongif.get_data("frame_rate") - while channel_data.bool_run: #这里的多线程并发,还需要验证检查 - frame = channel_data.get_last_frame() - if frame is not None: - #img = frame.to_ndarray(format="bgr24") - # ret, buffer = cv2.imencode('.jpg', frame) - # if not ret: - # continue - # frame = buffer.tobytes() - await websocket.send(frame) - await asyncio.sleep(1.0 / frame_rate) # Adjust based on frame rate + last_time=time.time() + frame_interval = 1.0 / int(myCongif.get_data("verify_rate")) + + if channel_data is not None: + verify_rate = myCongif.get_data("verify_rate") + while channel_data.bool_run: #这里的多协程并发,还需要验证检查 + #控制帧率 + current_time = time.time() + elapsed_time = current_time - last_time + if elapsed_time < frame_interval: + await asyncio.sleep(frame_interval - elapsed_time) # 若小于间隔时间则休眠 + last_time = time.time() + #读取最新的一帧发送 + frame = channel_data.get_last_frame() + if frame is not None: + #img = frame.to_ndarray(format="bgr24") + # ret, buffer = cv2.imencode('.jpg', frame) + # if not ret: + # continue + # frame = buffer.tobytes() + await websocket.send(frame) + #await asyncio.sleep(1.0 / verify_rate) # Adjust based on frame rate @api.route('/shutdown', methods=['POST']) async def shutdown():#这是全关 --需要修改 diff --git a/web/main/routes.py b/web/main/routes.py index a0aa799..162edc4 100644 --- a/web/main/routes.py +++ b/web/main/routes.py @@ -22,7 +22,6 @@ def login_required(f): @main.route('/') async def index(): - print("index") #error = request.args.get('error') return await render_template('实时预览.html') #return await render_template('登录.html',error=error) diff --git a/zfbox.db b/zfbox.db index 920cb4b8812b5e8b9f234f39c8051ab6cea31d78..781ccf2a64ae33465aaef51be49f35aa9d0194b4 100644 GIT binary patch delta 19 bcmZozz}&Ead4e=!+e8^>#Ft delta 19 bcmZozz}&Ead4e=!)kGO*#;T183(f-oL>vcL diff --git a/流程说明.docx b/流程说明.docx index 5336c49fd22cfbd9536c0621ed5eae5860c21254..4b4cbaa772e225af82961eca1f232eb7623e92f1 100644 GIT binary patch delta 13544 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

(JIB-qbu^ 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 a_v-=$0RDRO|0B&yCN9a4LI%qGqx(PUCkmnf delta 12900 zcma)j1yCkUlO{g656T_n-atuxUr_f})>^wpH>1P&O^@e&dwekDlg;&CbR_md($XFmemvhO;Nievft zGsX}nsME6*@AmqhDA{=n_kmW_z#MFXk2OilVEiAm2wXV{^;UY=1lj6;cVa30KHFjs z2bHYqQk8E9kxAp1h7MszZgv_B(Fcu~_|nvz>iE;gNZC}?OQL5A(<95~=F$pLGn5-u zqPiW%Td6IqlxAEtatHxO=BB>OL@B(uF;??PK#bKHqrD-14O}EeQ1nw8A{4`$M&EYamJ9E zz*r#;3d;CHN+F!4>|VXepYoPb1(vW1*C$v}Fv>$iVN6lPj@&V-1hQ7ghJl0{ai#n+ zlygo@*0})Gz#-3sszjMqy{4Ab`RSY~V2zJj=({AUQGGtb(6sUdeP*3qF~r781whgB z`}BkL3ufS@FNSkxv?`7Ns7i^bY|s_<9b_qgN3Kad1^jXWk1OQp-mVaEzCGYn()?C| zQFJl7B|HA&_nJA%EsG=kegDvYJ8ywSX?IN|?j3;cP#G~=sDnz95Ct{NkzW}Wk7V0s zLgc>5QN&zB2HE<-HEov4afPm1=)U!_Y*Cb~+-gH=ZA0V9b|+I_*RFUq->SMgLtlW6 zp@Uu*aG8f+X&t)F)s0%Hz7%!$yRq6ADT4#|C|>&AIW3W!;wKXuoAs+Ex2mm#rs(4I zqa=W>qIYyZKQDjnLymzW+UxC%}8#$P)1YLzj#c1q5=EX4+&gFhtF2*DAjKpg0`Defx@9oyTV zAtlG0G>WfGv%mKE4`oW*UIB*t_cEq z91g8*s2>XsCs#?PD!mQGfX;SP7afUsl8TJ|7E_Q(=Ps$6?E>e4Pk33X4dZ`(Dlme^ zw^>s=F;86B3vd+)bFau=;7u!Y@Y{`1Zd$r|vYb`2YlRmO6_R<1G51Mbq63SMf3a7< z%V%=4vGSvj0ueFtxo^m-QU#*d%kBXDsC$m_MK@11C9#E^F}qkWWIHG?!=b+_ro7jC zyWcF`_rl&o)?SaQSC;&KSE#Ho&qgE|WGTM+V`n`Y#Co@N?7O#Qha>B}mN+$)&My#b z&>AbQ$!Kr*Isf>jc_9%;ItWpo_dTfUwe7HxKyYF_&OAoudr3(f6Ja2V4W2Q8k@eSs zl?^A9h92p$`~31~hW)7gZ;xQO!|WLFpMjjPnc5+9c$0CwDE%GEMm$j|y!5xM47>aX z(@~TOq&ULm@#csl-P4CkNKm0R-f6LuDe4UDLfE7baT0>tJl~12j6?I?OF=6=lk}$S zNe~KE5(%=|XVZyG@*75j=BL*H7ovA7N+&O4w-<-5;-&hxCm0nbsic7}-Nw{Z{fp<~p$ zjfI;}jB0$uA}=SGcF6~qwCUZ>ly80EV zJQ7M;9xYibM{$KCaMI|46T&JE`Ma!^mzDR8ESkgF=;k%)1?xM&Ok_t&>+(P8t5&HtW^p+51O zV3~VE8({NDIl(O$pYIt^XA*6(Jh5=Qy7Wmcrhr2_KFZijsfah!vl^$9lmW z%XUm>07kG3(M|?-W(sfVzD7ONH|tf#L*U$x(M9cx zE75tk!A%$`@E-d;o`kzF190Apux)H!i!2&j8ovnXFy}cekS@yT(vWi=T3=-a=roFF z8WyllZ8TD;bZV^%JPx)yEF3BEr9ua0hL#lqSF&ekNt^*(q>Bc$BaWE_?Lq1ojh44a zj`Owg8Qug4iv;)DR9 z?}-yY0E!MuWXb8X4p7*bq9XhoZF{63Df$zJo~p67WVeN=Elp<4V?jhrLeqVz3tykPZph^1^TeEF^XDcsqn}KAr-XOXV<$) z#T3{uPlWtqW7FN;#(%4UB`ZKg3L+;^l3O%n4_pA+qkax~julxOd8A#GVf7z>{HZv+ z9q?D|F<`P#Bqg0)#XUj=hekbj;bh=9LMLGwwsAG`koE$WEMr&EsXtGS&ED>Ha9AH^ zy&m3zTl`^s8k9yCW9FBX%tWOm^mwl(338<^`}0)lvNtqaP2l98Qu;DvvvB&FLLJ9Yu*OD&@xO%5&WL~&Z4uei zE3sPlY0bNE(?b7SGhm;^)Yz>V4Dzs6T5M@^L=tUgvgp+t>bbLUY0c_f*XHR6FtoNm z%D7#Hqj+g|v~)j9nI4umh9`rroH``MpwIxs->%{@a>iLjPntU0OWOPQgAViX9E_z1 z4G(}w8j=1$U}=U0Srd%$2Z_UQkm`S5YX!I7$Hu~hvYORaDj6BYbQ|7lw>hI=!pNAP zpGqo30X9V0sn}H(h#Q62IFbXGjE6q`-hO|khDq+y7R6$~U(N0r$R;R|clY}iOcDYF zCBWns)w(-z{}h|(a@p#yDutkgOFO{GT*J(>2}8C(k@X-$+##*IFR|q}mx_>Bz}5); z&Wy~}Ae_nk9Yi4#DnH#>c@oEoNhhHccvAo(SpySu+mt+b#OWce#feEZ<8KPRg$?2F zuSs#0VP;(-R?$nzWyUFPlMt}+2dxHByl&l(7!&27Zptv&2!%BnCiiTS{ymRMk$Y#B zL+l!C@{bU4KY5A)>P`BLHvZO*Z^s`L;;}V8I7o_3Py)eZ3`Sqit$DjZ}AC3-jn z=j~{hq3qW1?VcZ=H=^ly_Jyn&e%M^v(ete3vHwo`!MmybIjN(|j(L?PKRuhhF+p*_ z*3dvf&EC|rgKhdlvjEF6l*3CF5Y3J&1m(uT{+O+^{&|V(eKR`K-xHoKGxpq`z?T)# zFvYn1ZUHu=X@IYBgqVZ{bF|yw&RE7%CIO9&Qc@k>m-@sCkJDh-ADS`C18utzO3WCD zmnROrO>v-B0TdD`(d%s2XnoycL?)TZ7rE_>!r!C(sgUoElF#sNnSIg%;3Np{dtaoG zeUf)Q=>lP!?rX!M``&1i%DyhJqq|*;8Ya27!3DAh&Md*#7TEvGC4*~KvWgz>)E&~? ziKXNMC^LN!FQY{FpdUB~1D^vsBe;i$Ad-Afhf2aI*AM+mkT(>%R|{VBfe^83XekJ` zb13w@rLEzQ)uylArnY|ofTP?2f@b3lQtY6}ep1`oQg2myXd+47z8SOjD-_dQ?L1iB z&Q{&;QOL`zvI=3r8bfBH!E99`ODcdxJ8$k85Q{2hky%UR*Om(@oVtC&q9fv#m4j~X zkVq(b2u3CP`I*{va>qY8Lq}j|CZndZ{oYGR-K9)0t6Hg@B)jYcSexz4?Md+$Sx8wa zWh;8f#!qP!Mn&Lk|6O&b$W5shaH9xUcs-~}>flJpq+^Q2lzvn<1dEn4uC2bHgEt%( zN-*KQ6o-%6M{~^n8-w#VR+IMNQSU5TMW?k*q4j3HtXp*bs?g_Q{aRZWw|AG;f*U3KPHG0t;jU~2n&zsnGtxz5 zv+hVMJ7!J++axA{IfY$Oc++FoZ|i~KXkuxJ2ycyV>T+=5U0QuYT>XN0jiB%1t_PIL z5dB~+52J-O5C5vZVL#*6oGFz2P5Gk`Ysj&Ot&2dRfNyLL;6~ywK0m94qOK^8<7CPE zE*aI@v!T~@fEVm`6ZmXo_*qeMI9l~CO~e1~LZWm~P!XtwBk5p()^e%*p6irr=BD2j zU`rC*HfO|34`!X-S#kLh5Uba?)B}$=ciL!hbr-d~ZIVr&b;`(zbpDg;N9H$nkV#Mn zG3R<=ufml!z>$-K!vcuyCf_Q2cd>JgrOU%2h*>KF%e1Ruz5qztrsPaUoEZp97K^Dk z%Va$6RCdPi-$>oXB?jQn45c_PN%wP_d@LZDo#1%kbl^HCsZ@8TUD9XP7N7(5Yd=1W zdg_%(w^36YjrevM-$5r(R854>)jt*^1LA21lu?aZ07+P#MVwVlun!jTE~4%6D>@=UG(9iz#QA|30QpE&PLC~%nshcx4OKsr2SxZ%#ijz1hMK=$ca9OK1{L@O+- zYPBf8izc;nwf#h}GgB(-vf+kuoMVi47l!sMDxAfty_jj|_+iAixZu5JDz;~Fgm;g& zppq3CBE6Za^6deWK?$MafcLh6+Fv-8(yt}!#uy<+awE8CpWo^ov7KOGVGgdBO(8)* z(vg4{#N+@2+%@Flh9CsHS;xgWvse2c-d|I}}?n(*0K%UWxT}VhJX}sGfM< zF=&X|I89jW?di?v1qu6^Fir8JCX?*A340G?TGvPC)-r0e_EI{M9eb(9u>3Vr=Mr?Z z+Rqg>AeXcwALe1pDf6fJI!D+;e2jXTwAp*v%%xmowa5;FWQ~Xkh(M;R|VMfKcfhM_a;!N~I#!d2paq_dbqh~5#y+8ezq z=#RcdNu$>-cSKEY&SJ=LGGw>KVah+yo_8aFzlfMUL21vcv~RdkpoAvxQFuM{YBBG(fRA z;aqJ7Hd)fxGwX|~s~N#okiI_nR(j*;ZGcjwJ<)$knTlG$4$(=jUl_NXRD>?-P;HgB z5v)qvg*B-)5v05!dTy$OA;rHDdX7ona2zJ=hZnPr3Yw^H5Zb6llu~i)G0FYVBtrq{ zx;9ytPKsL_mPL0Ky~j71H{9XxQicJW)`n^z&F8Jkw8ryPd~C{f*jtI4+*&0nl0bwr z89$x}`%x8D4mcs{e-#u2OD&t2-G`t%nfs{3clKDsk|b`y!Nxk4d4)sadJUn>+%SSw zb@-L$%@y+0#pl!nx0VvAyv;#-3R44oLPp#uepm3^d%?$ckGw$PYm!Xq$jO=qf~%GG z9|o7$Igi0s+BNL#KR@TfuhQ_uPBh~(h4brxEg}}*N)TBj$&8TbsSWUOLV}8+?dU&l znaA`aj&9_7r>tNFG+_b4X z72QRaTZ<$Gj%i-KR~lxt(6Mb1(oi_;@)vp&?afJ3A6e194_`^-8C(PkSSA3xzqW{d|G#Dj`NP|6ZA(dIJb&Es2zd~F%*QMLeX?Nv}$Nka0z z?z>9(Kkk~RE3AWlaF)xa)lyeqH76Ga{!rBVV^KG+rOFtTDLDNkaQ-Jo88f?2M`-ob z$v3DKF}h;rNQI16Oo$#*Fmh+l4!w)xW{OH)wP{aVWvST0MBAOIuw3@fs7&u~u#ekg zf|W&UCVAuRp1(+m6@7sKx3*)G?4+y{*-D$)5g(d69!&#NK?=AkT9rEd1{lQpNxpf8 zHkDh1f<$}=$Az7(2pQyl#03viF(MDgap=_*3HS%+HxOeS`tV>t0vnYSgmTxJhu2@r z#p zsgtFh`9E3d_T)9YHBRKvE6ingWQcQ72GmoN6^v7KS1pFRWBOn0uOJN~+9D!Kgj58Z zeqr3txvX)CA@(w9na`X8T%<>PCE#O~`0>ABJ8L$)hnk>cMTMT-3_oAnTQgRYX91}c zoM@S}=Ol^`C1G2D*Opk1o<)(sJV^NJeBU1o=PGBsLJ|X1h8i0zIMR9jVp~IjM71l^ zskt3-Ft>P1R9#L~M)~LtKbW=*O_y4prel*O+P|5D!z*MOm5uE5JA4x+w}>&*jpG1= zfaPnBy!}=nWRidCAE-zeUM`#-MFQv`rpFV%Z7n?EOxPlu7>wsW>2HOhcj`5CAR+C; z`52`$(&$8g#`kxwJV`fGAqY4LMu1ZOU?wKMwt^X#he9h3j|evLn^oxVr6-NG`fOX= z3jU$`;X3CPSGm=|xPccRG?{4UW17=gvECKlVi0`hv=B#%U0JYAUF7M_Qv;}F#gf4t zzN~^=uC1(HJF=T9c&MnjkZwevFvz(+>oC^*{=6o#bW)oEHRJUcX2)rg92E-NC*5Oy zHj}P;?e5kRMDH8&VBwMR45Jwv_&~{=EoZ|~trh1&H5MWtUFF&xVmsm?d>ncZa`slg zADZdgfRe!;cjAnmSw-?Uiy)kKP=Ds!oNCq; zWe!ropLv8C>ZQ*ppq$xPHMVL9gk9NNh4^!RY*a*H+oUK5oURqN^|n;`U;yYG>bFS1T4KsRA2zQHH9kRZ_Tqm}rx@gmVB zqm#%ICn|1VU)o8Hgp7=|7=Gz!M@ycOlaKF_TEKe*#xBJL*kdGl6tW2|3CtkV<>Wo_ zp}-@eYL}gr{YH@`fbC2tW1r$u-y?Z6HKYSqpFy_a<1q{A*HrBu9Qb>#h{K%+|zPZnD!soNLHI3wEk2?w_sS!{{!!&_%~lxpKxMsUhWPf3wzSx z4>w+dIj7#3r9t=V!L1Ko_LYzM;rR8@JCZ&<@fD=(Y}`Ot$A@C%-5VkZ1>7G7tEDKVyT5ZgmUz=7EL#~f<+|CWEt!*zmP;xc5W`*|eiSXfX4A#lQ zcR3-FnBiTQAN0!6ZW~Y+>0~|3hWxV1ezTF3L)vkS^ad{^APvy(!9_O79YgW}u|^3Y z$GeZ}w}$XV5N}R@P!O!wzhsEU`iAEYW6W|`+YW<}a#)ED=Qzq>&}x+7JqpdjZzzXk zm8`>oT!?bc4J0;_Vuz$zDJWEz5Rb4e86nq?yyPW&h&qWXN2utn>d8i+|s_DfG&)6c16v&+AgbY9nLj(rwNgDbur+f$u;-p zi(UC*n_UTE?#PkQ2SMexoB$qJ{;R>p3M4o~gzT&_g3=avK`PUnP-MA|@BuM{O#Mta zL&(L@nNiQhl(dkq)5sV1Efc=M_;$`I24QRK;LAJg6h1ubfWtStu(iy|3x|yQ<}a{r zaPLrrH%(Df-9L;Yhyb?E|y$QZWdG~dqqWj9eaY}*4sH) zB7XJpD6Mpfm;KIc=YTrT@+frf2{L*)!HX1{Gh4Vj*sP*|8zm!E?l_8^^`UI6+wB`p z2-E%Ug3&2}kUeF878@fPeCTp6TrNQfW!fZZB1A&yn_vf_UvDc%G>ZB2j%BLbV-m>_w=|qr;R5jRm1FfJ?0K@%}nkCGTqUp=v=&sEjer|lMByh&4=jwh8l}_XM-6LjSb(;%SBVb9_sKh6g7Rn-a zW1i)x!cvV&D`H-%;fPf00DSe*ibV4fS{rVgR7T01xir)jxo4{1L2dek_^wKOrNaamd6eg8$8mx}2`Rt`sDP-NNX!uB5Z zvZ1`ea>$j2{bG?c@+{6ZpR{ zr6vG9x+HqLv9VFdzoUH-!WVr*9j5}__&rS(;Myfq0T-D--zV7YoeS5Gf$Ff)khj@3GC`x1P(^RMf(XR2sq7GmG6Q`ea(7k!X@(ag+{H zL~;q|hWg-%{t05*NrqiF5URsclCYo)yvgq)IkRQ;#W1uf7fy^MQu*Ob;?CA*h~ipy zdTom)%sNmPcD=B{^X&~%xkw^&pwhqY_dqnuUfL+{0IPsqcnGZ2D8^O#=Q+O zY26z8R5pT|(IaEuBgv!{DumtzArACS8Kxgi9LXphhP+nndahmD5uf+Xhr*t1Law?8{rNsY+7bMJ8eaZ;{h2;X~OA-COC{;TT*q|%G zve;yuBX!C2EP>0#Q8$JjTL@{%^e}u(b1RcXDQqeYHxp( z0%md?O;$`E<7$GLn8jE&b{<`{83cNc7#44>86|6<86{77-gu458r(vZe-RF`h9ZK& zd0D8!qKfbrVrbN_gn;seQkb&@0=_SR3}#zFo652U*A?KnnQD|1fhAjo`xmtuhuH+z zmf#3!DkJ2bmxc!M&@Lc@O0$V{e`!~iLNPvkS+g!DO2l8tl@e3_$D%OAmq^bqD-dJ( zFKfj(LJNv?=8Ej4VoW;BLkPW6p)H7iOv0rn$%G@V%1mrE>5`BEv=wB-Nr!mqbV-sd zBn_rhQPas{EMULnN#L_JSkTiAS-$MISe5;<^3F^DaUr+S?{|fJw7g3kWN6GfGPVsU~vZ>H2OUg-kJ{zv? z^Vf}BYevJcMEjnf^X%1^XzIKmV%by>vphYRuj)Hpet%Z8pI03Gx`&ia@VbYcGOg~X&tR$n*P$6^ca%A(eFG&1ZZxls6kv``Jd=y~23uhi%e5;+l zr8hQFvKwKnQi$ehj*(`{%5{EG5nA(>CMU0Noj(4AWjZZpaDyXq8&w8i$RTvm31(#b-s@}G*loM zD}nfBXJA@^EX)c1V9mzZ!X%2wbtYC7{zRh-$kZ^CbdubQoPfg|HqCzgL&b&XnO(c} zG0LTarm^yP(fG|h%?|t0KFyBo4??SJTjlZO)|HlMGqqyX@ghLIcuTT%0)7m7h=q6J zQ(zKlImup|oI;FzK_%cj@I~s;Eg-Xw^@$iNiok7+w7s*a^uQOgO z>9opXelM5jCkd(ap!raLE1ef&)L%+*tr=&Bl#`i07;rw>y`5%}9D$>^=TptS;<7y5 z?&2M`ulm!xjw}F;=TObeh`X*j#39>a&+wqQ)DoY4g?03KpCG%R*s6MgXZ;Qeib1vI zb}QBrJuAV-zKQh;XC@8$!J3JHEQiGN?9uW=3ZlT5_-Ha3geN0<&d?T=8gk5G+|d6~7?x9^B2jG_z4UGec< zD9HkT=BqDWHh=H2&aBzG>iP8L?>?K^WoH*l z&z7&b|-nYH63_~v!&boGA`Cy*kQu5-B? za5?)D*LglRa8z3;d3f;AQ-KNIiyv&VY#qv}LR14xsa1*CM5BVdeCynuvATS%;H`oy zq6S{0d7x)G3r{LEkrD~_7KlHQ#ja<8yW)h}j-b8x>J}yN@cE^`#V*#WxS@6a_}sSv zG)Gr7+>g6Gzm?ze3>FAJIV3Ry#=XBS;0}d@v-Q@VhgSTi>>c4rYztN0yPK(gS_)-Y zf(TLeDG*-40?&Tbt-abyT%cb@1$q9tN6Ur2d8fbm+-6f38~D7IGu>n zW`>(5FTlUK1UE(n-b?n5<@TTrom%|P#abv~pX(TqCL5kVj+$H<<`@i-RjlEK-QV)* zkiAT~{i*Pq>O*H-?iUg-Z}4c5MxintMg1>8(jN{VQj~$yaf42LyM65&I;8Xjk%yr= zHm5wsPjdH@^n|TbR`l4+HNoeXNP+f`UG1@TUR&6H9tt(#L)a)IU4bOzl-9qn5Y(=+ zcgB?Vg>a~%LnbGK90s)HVMa|S7dHbZ%G)fpj(6|ZN&GzE3ORJU_>B>Ev7E#|6&C24R3cn(eAF<4iQ;1ry%czR{|2LkWeI zjQ4poB90dKk9%A#ZtT;Z7R%W806$pc=sp~+)vb*zRxa1a?cBVo_m3VsVdK{z-wqw4 zbyLn6nvOwTu|1<7LgHhsNO5h40*edM>ps`7_g9~HR{}69y7@`XBXyPSJ@S$Eb*={^ zKqE^n`{luWg=z&uB#6&<^{(furLE47+YI%sHvqUrEeNDi9EK5MYfo2H-q>%07b;edr4KARw}?ZL^o7I47mMV5!tBr`^m0N}&NB zRWrx>er8T6dD1GJ#=7v009D{eyM6Zi(=yh<`EDMy`C}cM)KVmLcAy3MnaokS9z#BX z`w|6jI(EjQr`D+!eSQ|}%)s-jyZPF)8hw|v^+uA}p^6pD?>bQx4g9t7y=9Y-N+suV zT+KEh&4yZ;YuE*M_3Xn^G1mz2h93h|9SALe4_L}6$NO8ZvLKEft`DtBZQVFS<9*6q zKUJFIw1fOtO_d)e&Jyf61Y}~rj}@VFDO`77L;PUQeiIyF4`AoMA&jH&*0kGb}GM7QdGe4i|6;=nOOCC z4_wTqy_N3UfNx1g%A~YyRrSYp8!P=?Q~kNJ%*A2IFn;RLW$UEHtjy(wOv>s`rd{O^ z(osdTIpUNl=5&m;7;k1oE}5> z61g;C64web-Qh@44q^eXgviJ@peVguk`Vixgu4jDSZLjxfs};1;KYf-&7lg(5F+h@ z!DPtFLKYgL?LYgIf?8nU;91mu_D4=YhrugSV!=mw^gu(RlUoRwLW;E;1RKeLgaKSM z*7~lyxJWW#5k)-84p2y-nW&Mw4e@URQJ^WsMHqi`9q%s(d)K%HpDNj(Q2!+D>-`1G!627U7)*TY8AM1yQKJ%M<@y+rLmnm8iEjC8 zfgjL7Q&D1oZcWCG_nqnOxyehq`6glmN=RldBjGUk2rT`+Rp2Ei%O{xUpwft?nnY?Y z9~P_Z)D&v5k(x!zLI{{GB-T`XVMJPbZet_$;BpPy{yfAaQC17Bi(HlUJuJ;Ay9*>I zu}PG3O`@{2BjjIIaglGN=J-msu4>?%eQ|<_vp9GFC?b9@y5sdsywW87NuA}6K;WX< z`TB1$m%%#PASVUwjSnKf%&ql>u2VQo(*cko81vfi>_MzOL$j)8uWw-E>_fVHR>c9U zLM2-{_M1yX-c`-!s;jumDItf0vJc$mdj_c(qpdtPEgj%TlclxJ?q$-TZ%%->e*H2h z>*WaHQ@+BTnkE-tCyC}2xRxWOhzxk`149Sbj?_F>;+bg*V*z3~<{0t4)VLC>#?3dB zR08%VB*z+Pe7kvyCV%p*k1-jbJvIgm+R<*97bpnGbp5Fq5hy@bzK;>UgXD&we6zVh zI1t8D&;o6@JEfs6tDxVOWR_H_x8dDe2ev32muqF}VeC$N`*1n!+@HwGT-}q4;TFEi zMQY6~sY}hh-ZXoFv=VhtQ|Evp>KQ>mASh*u)r+O%Y=YIh+TbjEI2%NTu#k2pKoKSpOr2zV88h2d+LzSI348|LvvyT1tmt|8r_Vf*>GYhd~sa>>ZpLjP0F(pfca_{&VGhiGOA9 T|F^~=0(6uihB^}aXY79gw|UT;