From 2c00b5afe3a59f431e07b7ea7f574d8cc21f8ff4 Mon Sep 17 00:00:00 2001 From: 16337 <1633794139@qq.com> Date: Wed, 21 Jan 2026 13:29:39 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=9F=E6=88=90=E6=96=B0engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/models.py | 12 + frontend/src/pages/Dashboard.tsx | 2 +- frontend/src/pages/ROIEditor.tsx | 441 +++++++++++++++++++++---------- inference/engine.py | 269 ++++++++++++++++--- main.py | 4 + security_monitor.db | Bin 24576 -> 24576 bytes 6 files changed, 547 insertions(+), 181 deletions(-) diff --git a/db/models.py b/db/models.py index 5f07b1f..e8038ac 100644 --- a/db/models.py +++ b/db/models.py @@ -33,11 +33,15 @@ class Camera(Base): __tablename__ = "cameras" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + cloud_id: Mapped[Optional[int]] = mapped_column(Integer, unique=True, nullable=True) name: Mapped[str] = mapped_column(String(64), nullable=False) rtsp_url: Mapped[str] = mapped_column(Text, nullable=False) enabled: Mapped[bool] = mapped_column(Boolean, default=True) fps_limit: Mapped[int] = mapped_column(Integer, default=30) process_every_n_frames: Mapped[int] = mapped_column(Integer, default=3) + pending_sync: Mapped[bool] = mapped_column(Boolean, default=False) + sync_failed_at: Mapped[Optional[datetime]] = mapped_column(DateTime) + sync_retry_count: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, onupdate=datetime.utcnow @@ -74,6 +78,7 @@ class ROI(Base): __tablename__ = "rois" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + cloud_id: Mapped[Optional[int]] = mapped_column(Integer, unique=True, nullable=True) camera_id: Mapped[int] = mapped_column( Integer, ForeignKey("cameras.id"), nullable=False ) @@ -88,6 +93,8 @@ class ROI(Base): threshold_sec: Mapped[int] = mapped_column(Integer, default=360) confirm_sec: Mapped[int] = mapped_column(Integer, default=30) return_sec: Mapped[int] = mapped_column(Integer, default=5) + pending_sync: Mapped[bool] = mapped_column(Boolean, default=False) + sync_version: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, onupdate=datetime.utcnow @@ -100,6 +107,7 @@ class Alarm(Base): __tablename__ = "alarms" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + cloud_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) camera_id: Mapped[int] = mapped_column( Integer, ForeignKey("cameras.id"), nullable=False ) @@ -107,6 +115,10 @@ class Alarm(Base): event_type: Mapped[str] = mapped_column(String(32), nullable=False) confidence: Mapped[float] = mapped_column(Float, default=0.0) snapshot_path: Mapped[Optional[str]] = mapped_column(Text) + region_data: Mapped[Optional[str]] = mapped_column(Text) + upload_status: Mapped[str] = mapped_column(String(32), default='pending_upload') + upload_retry_count: Mapped[int] = mapped_column(Integer, default=0) + error_message: Mapped[Optional[str]] = mapped_column(Text) llm_checked: Mapped[bool] = mapped_column(Boolean, default=False) llm_result: Mapped[Optional[str]] = mapped_column(Text) processed: Mapped[bool] = mapped_column(Boolean, default=False) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 35a892b..e3c939e 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Card, Row, Col, Statistic, List, Tag, Button, Space, Timeline } from 'antd'; +import { Card, Row, Col, Statistic, List, Tag, Button, Space } from 'antd'; import { AlertOutlined, VideoCameraOutlined, ClockCircleOutlined } from '@ant-design/icons'; import axios from 'axios'; diff --git a/frontend/src/pages/ROIEditor.tsx b/frontend/src/pages/ROIEditor.tsx index 7c129c0..190cb9d 100644 --- a/frontend/src/pages/ROIEditor.tsx +++ b/frontend/src/pages/ROIEditor.tsx @@ -1,16 +1,14 @@ import React, { useEffect, useState, useRef } from 'react'; -import { Card, Button, Space, Select, message, Modal, Form, Input, InputNumber, Drawer } from 'antd'; +import { Card, Button, Space, Select, message, Drawer, Form, Input, InputNumber, Switch } from 'antd'; import { Stage, Layer, Rect, Line, Circle, Text as KonvaText } from 'react-konva'; import axios from 'axios'; interface ROI { id: number; - roi_id: string; name: string; type: string; points: number[][]; rule: string; - direction: string | null; enabled: boolean; threshold_sec: number; confirm_sec: number; @@ -27,12 +25,14 @@ const ROIEditor: React.FC = () => { const [selectedCamera, setSelectedCamera] = useState(null); const [rois, setRois] = useState([]); const [snapshot, setSnapshot] = useState(''); - const [loading, setLoading] = useState(false); const [imageDim, setImageDim] = useState({ width: 800, height: 600 }); const [selectedROI, setSelectedROI] = useState(null); const [drawerVisible, setDrawerVisible] = useState(false); const [form] = Form.useForm(); + const [isDrawing, setIsDrawing] = useState(false); + const [tempPoints, setTempPoints] = useState([]); + const [backgroundImage, setBackgroundImage] = useState(null); const stageRef = useRef(null); const fetchCameras = async () => { @@ -58,19 +58,25 @@ const ROIEditor: React.FC = () => { } }, [selectedCamera]); - const fetchSnapshot = async () => { - if (!selectedCamera) return; - try { - const res = await axios.get(`/api/camera/${selectedCamera}/snapshot/base64`); - setSnapshot(res.data.image); + useEffect(() => { + if (snapshot) { const img = new Image(); img.onload = () => { const maxWidth = 800; const maxHeight = 600; const scale = Math.min(maxWidth / img.width, maxHeight / img.height); setImageDim({ width: img.width * scale, height: img.height * scale }); + setBackgroundImage(img); }; - img.src = `data:image/jpeg;base64,${res.data.image}`; + img.src = `data:image/jpeg;base64,${snapshot}`; + } + }, [snapshot]); + + const fetchSnapshot = async () => { + if (!selectedCamera) return; + try { + const res = await axios.get(`/api/camera/${selectedCamera}/snapshot/base64`); + setSnapshot(res.data.image); } catch (err) { message.error('获取截图失败'); } @@ -89,34 +95,79 @@ const ROIEditor: React.FC = () => { const handleSaveROI = async (values: any) => { if (!selectedCamera || !selectedROI) return; try { - await axios.put(`/api/camera/${selectedCamera}/roi/${selectedROI.id}`, values); + await axios.put(`/api/camera/${selectedCamera}/roi/${selectedROI.id}`, { + name: values.name, + roi_type: values.roi_type, + rule_type: values.rule_type, + threshold_sec: values.threshold_sec, + confirm_sec: values.confirm_sec, + enabled: values.enabled, + }); message.success('保存成功'); setDrawerVisible(false); fetchROIs(); - } catch (err) { - message.error('保存失败'); + } catch (err: any) { + message.error(`保存失败: ${err.response?.data?.detail || '未知错误'}`); } }; - const handleAddROI = async () => { - if (!selectedCamera) return; - const roi_id = `roi_${Date.now()}`; - try { - await axios.post(`/api/camera/${selectedCamera}/roi`, { - roi_id, - name: '新区域', - roi_type: 'polygon', - points: [[100, 100], [300, 100], [300, 300], [100, 300]], - rule_type: 'leave_post', - threshold_sec: 360, - confirm_sec: 30, - return_sec: 5, - }); - message.success('添加成功'); - fetchROIs(); - } catch (err) { - message.error('添加失败'); + const handleAddROI = () => { + if (!selectedCamera) { + message.warning('请先选择摄像头'); + return; } + setIsDrawing(true); + setTempPoints([]); + setSelectedROI(null); + message.info('点击画布绘制ROI区域,双击完成绘制'); + }; + + const handleStageClick = (e: any) => { + if (!isDrawing) return; + + const stage = e.target.getStage(); + const pos = stage.getPointerPosition(); + if (pos) { + setTempPoints(prev => [...prev, [pos.x, pos.y]]); + } + }; + + const handleStageDblClick = () => { + if (!isDrawing || tempPoints.length < 3) { + if (tempPoints.length > 0 && tempPoints.length < 3) { + message.warning('至少需要3个点才能形成多边形'); + } + return; + } + + const roi_id = `roi_${Date.now()}`; + axios.post(`/api/camera/${selectedCamera}/roi`, { + roi_id, + name: `区域${rois.length + 1}`, + roi_type: 'polygon', + points: tempPoints, + rule_type: 'intrusion', + threshold_sec: 60, + confirm_sec: 5, + return_sec: 5, + }) + .then(() => { + message.success('ROI添加成功'); + setIsDrawing(false); + setTempPoints([]); + fetchROIs(); + }) + .catch((err) => { + message.error(`添加失败: ${err.response?.data?.detail || '未知错误'}`); + setIsDrawing(false); + setTempPoints([]); + }); + }; + + const handleCancelDrawing = () => { + setIsDrawing(false); + setTempPoints([]); + message.info('已取消绘制'); }; const handleDeleteROI = async (roiId: number) => { @@ -124,9 +175,13 @@ const ROIEditor: React.FC = () => { try { await axios.delete(`/api/camera/${selectedCamera}/roi/${roiId}`); message.success('删除成功'); + if (selectedROI?.id === roiId) { + setSelectedROI(null); + setDrawerVisible(false); + } fetchROIs(); - } catch (err) { - message.error('删除失败'); + } catch (err: any) { + message.error(`删除失败: ${err.response?.data?.detail || '未知错误'}`); } }; @@ -137,162 +192,264 @@ const ROIEditor: React.FC = () => { const renderROI = (roi: ROI) => { const points = roi.points.flat(); const color = getROIStrokeColor(roi.rule); + const isSelected = selectedROI?.id === roi.id; - if (roi.type === 'polygon') { - return ( - { - setSelectedROI(roi); - form.setFieldsValue(roi); - setDrawerVisible(true); - }} - /> - ); - } else if (roi.type === 'line') { - return ( - { - setSelectedROI(roi); - form.setFieldsValue(roi); - setDrawerVisible(true); - }} - /> - ); - } - return null; + return ( + { + setSelectedROI(roi); + form.setFieldsValue({ + name: roi.name, + roi_type: roi.type, + rule_type: roi.rule, + threshold_sec: roi.threshold_sec, + confirm_sec: roi.confirm_sec, + enabled: roi.enabled, + }); + setDrawerVisible(true); + }} + onMouseEnter={(e) => { + const container = e.target.getStage()?.container(); + if (container) { + container.style.cursor = 'pointer'; + } + }} + onMouseLeave={(e) => { + const container = e.target.getStage()?.container(); + if (container) { + container.style.cursor = 'default'; + } + }} + /> + ); }; return (
- + + + - - - - + + + {selectedROI?.rule === 'leave_post' && ( + <> + + + + + + + + )} + + - + diff --git a/inference/engine.py b/inference/engine.py index 1b83982..e125f90 100644 --- a/inference/engine.py +++ b/inference/engine.py @@ -1,4 +1,7 @@ import os + +os.environ["TENSORRT_DISABLE_MYELIN"] = "1" + import time from typing import Any, Dict, List, Optional, Tuple @@ -6,12 +9,146 @@ import cv2 import numpy as np import tensorrt as trt import torch +import onnxruntime as ort from ultralytics import YOLO -from ultralytics.engine.results import Results +from ultralytics.engine.results import Results, Boxes as UltralyticsBoxes from config import get_config +class ONNXEngine: + def __init__(self, onnx_path: Optional[str] = None, device: int = 0): + config = get_config() + self.onnx_path = onnx_path or config.model.onnx_path + self.device = device + self.imgsz = tuple(config.model.imgsz) + self.conf_thresh = config.model.conf_threshold + self.iou_thresh = config.model.iou_threshold + + self.session = None + self.input_names = None + self.output_names = None + self.load_model() + + def load_model(self): + if not os.path.exists(self.onnx_path): + raise FileNotFoundError(f"ONNX模型文件不存在: {self.onnx_path}") + + providers = ['CUDAExecutionProvider', 'CPUExecutionProvider'] if self.device >= 0 else ['CPUExecutionProvider'] + self.session = ort.InferenceSession(self.onnx_path, providers=providers) + + self.input_names = [inp.name for inp in self.session.get_inputs()] + self.output_names = [out.name for out in self.session.get_outputs()] + + def preprocess(self, frame: np.ndarray) -> np.ndarray: + img = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + img = cv2.resize(img, self.imgsz) + + img = img.transpose(2, 0, 1).astype(np.float32) / 255.0 + + return img + + def postprocess(self, output: np.ndarray, orig_img: np.ndarray) -> List[Results]: + c, n = output.shape + output = output.T + + boxes = output[:, :4] + scores = output[:, 4] + classes = output[:, 5:].argmax(axis=1) if output.shape[1] > 5 else np.zeros(len(output), dtype=np.int32) + + mask = scores > self.conf_thresh + boxes = boxes[mask] + scores = scores[mask] + classes = classes[mask] + + if len(boxes) == 0: + return [Results(orig_img=orig_img, path="", names={0: "person"})] + + indices = cv2.dnn.NMSBoxes( + boxes.tolist(), + scores.tolist(), + self.conf_thresh, + self.iou_thresh, + ) + + orig_h, orig_w = orig_img.shape[:2] + scale_x, scale_y = orig_w / self.imgsz[1], orig_h / self.imgsz[0] + + filtered_boxes = [] + for idx in indices: + if idx >= len(boxes): + continue + box = boxes[idx] + x1, y1, x2, y2 = box + w, h = x2 - x1, y2 - y1 + filtered_boxes.append([ + int(x1 * scale_x), + int(y1 * scale_y), + int(w * scale_x), + int(h * scale_y), + float(scores[idx]), + int(classes[idx]) + ]) + + from ultralytics.engine.results import Boxes as BoxesObj + if filtered_boxes: + box_tensor = torch.tensor(filtered_boxes) + boxes_obj = BoxesObj( + box_tensor, + orig_shape=(orig_h, orig_w) + ) + result = Results( + orig_img=orig_img, + path="", + names={0: "person"}, + boxes=boxes_obj + ) + return [result] + + return [Results(orig_img=orig_img, path="", names={0: "person"})] + + def inference(self, images: List[np.ndarray]) -> List[Results]: + if not images: + return [] + + batch_imgs = [] + for frame in images: + img = self.preprocess(frame) + batch_imgs.append(img) + + batch = np.stack(batch_imgs, axis=0) + + inputs = {self.input_names[0]: batch} + outputs = self.session.run(self.output_names, inputs) + + results = [] + output = outputs[0] + if output.shape[0] == 1: + result = self.postprocess(output[0], images[0]) + results.extend(result) + else: + for i in range(output.shape[0]): + result = self.postprocess(output[i], images[i]) + results.extend(result) + + return results + + def inference_single(self, frame: np.ndarray) -> List[Results]: + return self.inference([frame]) + + def warmup(self, num_warmup: int = 10): + dummy_frame = np.zeros((640, 640, 3), dtype=np.uint8) + for _ in range(num_warmup): + self.inference_single(dummy_frame) + + def __del__(self): + if self.session: + try: + self.session.end_profiling() + except Exception: + pass + + class TensorRTEngine: def __init__(self, engine_path: Optional[str] = None, device: int = 0): config = get_config() @@ -25,9 +162,11 @@ class TensorRTEngine: self.logger = trt.Logger(trt.Logger.INFO) self.engine = None self.context = None - self.stream = None + self.stream = torch.cuda.Stream(device=self.device) self.input_buffer = None self.output_buffers = [] + self.input_name = None + self.output_name = None self._load_engine() @@ -44,29 +183,39 @@ class TensorRTEngine: self.context = self.engine.create_execution_context() self.stream = torch.cuda.Stream(device=self.device) + self.batch_size = 1 for i in range(self.engine.num_io_tensors): name = self.engine.get_tensor_name(i) dtype = self.engine.get_tensor_dtype(name) - shape = self.engine.get_tensor_shape(name) + shape = list(self.engine.get_tensor_shape(name)) if self.engine.get_tensor_mode(name) == trt.TensorIOMode.INPUT: - self.context.set_tensor_address(name, None) + if -1 in shape: + shape = [self.batch_size if d == -1 else d for d in shape] + if dtype == trt.float16: + buffer = torch.zeros(shape, dtype=torch.float16, device=self.device) + else: + buffer = torch.zeros(shape, dtype=torch.float32, device=self.device) + self.input_buffer = buffer + self.input_name = name else: + if -1 in shape: + shape = [self.batch_size if d == -1 else d for d in shape] if dtype == trt.float16: buffer = torch.zeros(shape, dtype=torch.float16, device=self.device) else: buffer = torch.zeros(shape, dtype=torch.float32, device=self.device) self.output_buffers.append(buffer) - self.context.set_tensor_address(name, buffer.data_ptr()) + if self.output_name is None: + self.output_name = name - self.context.set_optimization_profile_async(0, self.stream) + self.context.set_tensor_address(name, buffer.data_ptr()) - self.input_buffer = torch.zeros( - (1, 3, self.imgsz[0], self.imgsz[1]), - dtype=torch.float16 if self.half else torch.float32, - device=self.device, - ) + stream_handle = torch.cuda.current_stream(self.device).cuda_stream + self.context.set_optimization_profile_async(0, stream_handle) + + self.batch_size = 1 def preprocess(self, frame: np.ndarray) -> torch.Tensor: img = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) @@ -95,16 +244,20 @@ class TensorRTEngine: ) self.context.set_tensor_address( - "input", input_tensor.contiguous().data_ptr() + self.input_name, input_tensor.contiguous().data_ptr() ) + input_shape = list(input_tensor.shape) + self.context.set_input_shape(self.input_name, input_shape) + torch.cuda.synchronize(self.stream) - self.context.execute_async_v3(self.stream.handle) + self.context.execute_async_v3(self.stream.cuda_stream) torch.cuda.synchronize(self.stream) results = [] for i in range(batch_size): pred = self.output_buffers[0][i].cpu().numpy() + pred = pred.T # 转置: (8400, 84) boxes = pred[:, :4] scores = pred[:, 4] classes = pred[:, 5].astype(np.int32) @@ -142,7 +295,7 @@ class TensorRTEngine: orig_img=images[i], path="", names={0: "person"}, - boxes=Boxes( + boxes=UltralyticsBoxes( torch.tensor([box_orig + [conf, cls]]), orig_shape=(orig_h, orig_w), ), @@ -161,9 +314,15 @@ class TensorRTEngine: def __del__(self): if self.context: - self.context.synchronize() + try: + self.context.synchronize() + except Exception: + pass if self.stream: - self.stream.synchronize() + try: + self.stream.synchronize() + except Exception: + pass class Boxes: @@ -196,6 +355,15 @@ class Boxes: return self.data[:, 5] +def _check_pt_file_valid(pt_path: str) -> bool: + try: + with open(pt_path, 'rb') as f: + header = f.read(10) + return len(header) == 10 + except Exception: + return False + + class YOLOEngine: def __init__( self, @@ -203,38 +371,61 @@ class YOLOEngine: device: int = 0, use_trt: bool = True, ): - self.use_trt = use_trt - self.device = device + self.use_trt = False + self.onnx_engine = None self.trt_engine = None + self.device = device + config = get_config() - if not use_trt: - if model_path: - pt_path = model_path - elif hasattr(get_config().model, 'pt_model_path'): - pt_path = get_config().model.pt_model_path - else: - pt_path = get_config().model.engine_path.replace(".engine", ".pt") - self.model = YOLO(pt_path) - self.model.to(device) - else: + if use_trt: try: self.trt_engine = TensorRTEngine(device=device) self.trt_engine.warmup() + self.use_trt = True + print("TensorRT引擎加载成功") + return except Exception as e: - print(f"TensorRT加载失败,回退到PyTorch: {e}") - self.use_trt = False - if model_path: - pt_path = model_path - elif hasattr(get_config().model, 'pt_model_path'): - pt_path = get_config().model.pt_model_path - else: - pt_path = get_config().model.engine_path.replace(".engine", ".pt") + print(f"TensorRT加载失败: {e}") + + try: + onnx_path = config.model.onnx_path + if os.path.exists(onnx_path): + self.onnx_engine = ONNXEngine(device=device) + self.onnx_engine.warmup() + print("ONNX引擎加载成功") + return + else: + print(f"ONNX模型不存在: {onnx_path}") + except Exception as e: + print(f"ONNX加载失败: {e}") + + try: + pt_path = model_path or config.model.pt_model_path + if os.path.exists(pt_path) and _check_pt_file_valid(pt_path): self.model = YOLO(pt_path) self.model.to(device) + print(f"PyTorch模型加载成功: {pt_path}") + else: + raise FileNotFoundError(f"PT文件无效或不存在: {pt_path}") + except Exception as e: + print(f"PyTorch加载失败: {e}") + raise RuntimeError("所有模型加载方式均失败") def __call__(self, frame: np.ndarray, **kwargs) -> List[Results]: - if self.use_trt: - return self.trt_engine.inference_single(frame) + if self.use_trt and self.trt_engine: + try: + return self.trt_engine.inference_single(frame) + except Exception as e: + print(f"TensorRT推理失败,切换到ONNX: {e}") + self.use_trt = False + if self.onnx_engine: + return self.onnx_engine.inference_single(frame) + elif self.model: + return self.model(frame, imgsz=get_config().model.imgsz, **kwargs) + else: + return [] + elif self.onnx_engine: + return self.onnx_engine.inference_single(frame) else: results = self.model(frame, imgsz=get_config().model.imgsz, **kwargs) return results @@ -242,3 +433,5 @@ class YOLOEngine: def __del__(self): if self.trt_engine: del self.trt_engine + if self.onnx_engine: + del self.onnx_engine diff --git a/main.py b/main.py index a1d393f..378e680 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,8 @@ from contextlib import asynccontextmanager from datetime import datetime from typing import Optional +os.environ["TENSORRT_DISABLE_MYELIN"] = "1" + import cv2 import numpy as np from fastapi import FastAPI, HTTPException @@ -19,6 +21,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from api.alarm import router as alarm_router from api.camera import router as camera_router from api.roi import router as roi_router +from api.sync import router as sync_router from config import get_config, load_config from db.models import init_db from inference.pipeline import get_pipeline, start_pipeline, stop_pipeline @@ -81,6 +84,7 @@ app.add_middleware( app.include_router(camera_router) app.include_router(roi_router) app.include_router(alarm_router) +app.include_router(sync_router) @app.get("/") diff --git a/security_monitor.db b/security_monitor.db index 8f46e245a12088aa9b837082471695ad2d179cc1..0fb71b6cb937ded882f22f4c2592ee7cb5c99fd4 100644 GIT binary patch delta 728 zcmZoTz}Rqrae}m95d#AQ8xX^Q;6xo`@gfGj-XFZ2xeTnVoDA%Tc;)$;SUI^>Ha1RX z;c7KwVHXz_W$g0b#C?;oJ~=19G$lSWMZwcA#MRw3NJpU{H7_MIFFn4vGA~)d$=~0{ z)zMGE#nsI*)F(v2Kt};29-o$&nUk6lpID+0;_2%e9OCF30F^09Eh(yuPtGsRD}kAb zp}Q=#s5mn}4=B$MwiKaY@;WXLu4WBJc5!KG#y0iM=lNvm??x`pfFMs_$Dl|BZ`Vi# z4bIFIO)gEoy^Py87jm{TvheL?(wzK@ON*=kx!A40YJ_hYZNlr#aM@I$@29C-0@?w+s$%#!~ zD6hrHH2I9YvXY^KfvJ^|p_Qqjo`r>}si9e;6eCEJGzXfdR8g2x69YX%GXoQ2V_+hj zu{lRRUceMAWoT(-V4-JXVP=-7F~Z zor9&Bjg@_KA!jQiON$x{`{Z9-TAR&Tw=z!Nz~#1Co_7Hw69dQQgM5yRTqX=me6tz& zX7guk7F6ix<7gCPHb9J7%@qbL*C<{bHW0aF77Ljx-#6Dva#Jri>iGa~~fMh*r} cP6iGJMQW~LU#7K;oF08H06KL7v#