diff --git a/api/roi.py b/api/roi.py index 5a5cbc9..e2cc03c 100644 --- a/api/roi.py +++ b/api/roi.py @@ -14,11 +14,18 @@ from db.crud import ( ) from db.models import get_db from inference.pipeline import get_pipeline +from inference.roi.cache_manager import get_roi_cache from inference.roi.roi_filter import ROIFilter router = APIRouter(prefix="/api/camera", tags=["ROI管理"]) +def _invalidate_roi_cache(camera_id: int): + pipeline = get_pipeline() + pipeline.roi_filter.clear_cache(camera_id) + get_roi_cache().invalidate(camera_id) + + @router.get("/{camera_id}/rois", response_model=List[dict]) def list_rois(camera_id: int, db: Session = Depends(get_db)): roi_configs = get_all_rois(db, camera_id) @@ -92,8 +99,7 @@ def add_roi( return_sec=return_sec, ) - pipeline = get_pipeline() - pipeline.roi_filter.update_cache(camera_id, get_roi_points(db, camera_id)) + _invalidate_roi_cache(camera_id) return { "id": roi.id, @@ -137,8 +143,7 @@ def modify_roi( if not roi: raise HTTPException(status_code=404, detail="ROI不存在") - pipeline = get_pipeline() - pipeline.roi_filter.update_cache(camera_id, get_roi_points(db, camera_id)) + _invalidate_roi_cache(camera_id) return { "id": roi.id, @@ -156,8 +161,7 @@ def remove_roi(camera_id: int, roi_id: int, db: Session = Depends(get_db)): if not delete_roi(db, roi_id): raise HTTPException(status_code=404, detail="ROI不存在") - pipeline = get_pipeline() - pipeline.roi_filter.update_cache(camera_id, get_roi_points(db, camera_id)) + _invalidate_roi_cache(camera_id) return {"message": "删除成功"} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d0da010..4800571 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Layout, Menu, theme } from 'antd'; import { VideoCameraOutlined, @@ -18,6 +18,7 @@ const App: React.FC = () => { } = theme.useToken(); const navigate = useNavigate(); const location = useLocation(); + const [collapsed, setCollapsed] = useState(false); const menuItems = [ { @@ -49,18 +50,36 @@ const App: React.FC = () => { return ( - +
- 安保监控系统 + {collapsed ? '' : '安保监控系统'}
{ onClick={({ key }) => navigate(key)} /> - -
-
+ +
+
安保异常行为识别系统 v1.0
- -
- -
+ +
diff --git a/frontend/src/AppWrapper.tsx b/frontend/src/AppWrapper.tsx index fa96fb7..ee06bcb 100644 --- a/frontend/src/AppWrapper.tsx +++ b/frontend/src/AppWrapper.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; -import App from './App'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import Dashboard from './pages/Dashboard'; import CameraManagement from './pages/CameraManagement'; import ROIEditor from './pages/ROIEditor'; import AlertCenter from './pages/AlertCenter'; +import App from './App'; const Settings: React.FC = () => (
@@ -16,15 +16,16 @@ const Settings: React.FC = () => ( const AppWrapper: React.FC = () => { return ( - - - } /> - } /> - } /> - } /> - } /> - - + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + ); }; diff --git a/frontend/src/index.css b/frontend/src/index.css index 445d52b..c3887cc 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -6,6 +6,34 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: #f5f5f5; +} + +#root { + min-height: 100vh; +} + +.ant-layout-sider { + z-index: 100; +} + +.ant-layout { + z-index: 1; +} + +.ant-dropdown, +.ant-select-dropdown, +.ant-picker-dropdown, +.ant-popover { + z-index: 1050 !important; +} + +.ant-message { + z-index: 1060; +} + +.ant-notification { + z-index: 1070; } .camera-grid { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..a656d3f --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { ConfigProvider } from 'antd' +import AppWrapper from './AppWrapper' +import './index.css' +import zhCN from 'antd/locale/zh_CN' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git a/frontend/src/pages/CameraManagement.tsx b/frontend/src/pages/CameraManagement.tsx index c058ca6..cf58bbf 100644 --- a/frontend/src/pages/CameraManagement.tsx +++ b/frontend/src/pages/CameraManagement.tsx @@ -1,6 +1,12 @@ -import React, { useEffect, useState } from 'react'; -import { Table, Button, Modal, Form, Input, InputNumber, Switch, Space, Tag, message } from 'antd'; -import { PlusOutlined, DeleteOutlined, EditOutlined, PlayCircleOutlined, PauseCircleOutlined } from '@ant-design/icons'; +import React, { useEffect, useState, useMemo } from 'react'; +import { + Table, Button, Modal, Form, Input, InputNumber, Switch, Space, Tag, message, + Popconfirm, Tooltip, Badge +} from 'antd'; +import { + PlusOutlined, DeleteOutlined, EditOutlined, PlayCircleOutlined, + PauseCircleOutlined, SearchOutlined, SyncOutlined, CameraOutlined +} from '@ant-design/icons'; import axios from 'axios'; interface Camera { @@ -10,20 +16,34 @@ interface Camera { enabled: boolean; fps_limit: number; process_every_n_frames: number; + created_at: string; +} + +interface CameraStatus { + is_running: boolean; + fps: number; + error_message: string | null; + last_check_time: string; } const CameraManagement: React.FC = () => { const [cameras, setCameras] = useState([]); + const [cameraStatus, setCameraStatus] = useState>({}); const [loading, setLoading] = useState(false); const [modalVisible, setModalVisible] = useState(false); const [editingCamera, setEditingCamera] = useState(null); + const [searchText, setSearchText] = useState(''); const [form] = Form.useForm(); const fetchCameras = async () => { setLoading(true); try { - const res = await axios.get('/api/cameras?enabled_only=false'); - setCameras(res.data); + const [camerasRes, statusRes] = await Promise.all([ + axios.get('/api/cameras?enabled_only=false'), + axios.get('/api/pipeline/status') + ]); + setCameras(camerasRes.data); + setCameraStatus(statusRes.data.cameras || {}); } catch (err) { message.error('获取摄像头列表失败'); } finally { @@ -33,11 +53,50 @@ const CameraManagement: React.FC = () => { useEffect(() => { fetchCameras(); + const interval = setInterval(fetchCameras, 5000); + return () => clearInterval(interval); }, []); + const extractIP = (url: string): string => { + try { + const match = url.match(/:\/\/([^:]+):?(\d+)?\//); + return match ? match[1] : '未知'; + } catch { + return '未知'; + } + }; + + const getStatusInfo = (camera: Camera) => { + const status = cameraStatus[camera.id]; + if (!camera.enabled) { + return { color: '#8c8c8f', text: '已停用', status: 'default' }; + } + if (!status) { + return { color: '#fa8c16', text: '加载中...', status: 'processing' }; + } + if (status.is_running && status.fps > 0) { + return { color: '#52c41a', text: `运行中 (${status.fps.toFixed(1)} FPS)`, status: 'success' }; + } + if (status.error_message) { + return { color: '#f5222d', text: `连接失败`, status: 'error' }; + } + return { color: '#fa8c16', text: '初始化中...', status: 'processing' }; + }; + + const filteredCameras = useMemo(() => { + if (!searchText) return cameras; + const lowerSearch = searchText.toLowerCase(); + return cameras.filter(cam => + cam.name.toLowerCase().includes(lowerSearch) || + cam.rtsp_url.toLowerCase().includes(lowerSearch) || + extractIP(cam.rtsp_url).toLowerCase().includes(lowerSearch) + ); + }, [cameras, searchText]); + const handleAdd = () => { setEditingCamera(null); form.resetFields(); + form.setFieldsValue({ fps_limit: 30, process_every_n_frames: 3, enabled: true }); setModalVisible(true); }; @@ -88,51 +147,114 @@ const CameraManagement: React.FC = () => { title: 'ID', dataIndex: 'id', key: 'id', - width: 60, + width: 70, + sorter: (a: Camera, b: Camera) => a.id - b.id, }, { title: '名称', dataIndex: 'name', key: 'name', + sorter: (a: Camera, b: Camera) => a.name.localeCompare(b.name), + render: (name: string, record: Camera) => ( + + {name} + } style={{ fontSize: 12 }}> + #{record.id} + + + ), }, { - title: 'RTSP地址', - dataIndex: 'rtsp_url', - key: 'rtsp_url', - ellipsis: true, + title: 'IP地址', + key: 'ip', + width: 150, + render: (_: any, record: Camera) => { + const ip = extractIP(record.rtsp_url); + return ( + + {ip} + + ); + }, }, { title: '状态', + key: 'status', + width: 160, + sorter: (a: Camera, b: Camera) => { + const statusA = getStatusInfo(a).status; + const statusB = getStatusInfo(b).status; + return statusA.localeCompare(statusB); + }, + render: (_: any, record: Camera) => { + const { color, text, status } = getStatusInfo(record); + return ( + {text}} /> + ); + }, + }, + { + title: '启用', dataIndex: 'enabled', key: 'enabled', - render: (enabled: boolean) => ( - - {enabled ? '启用' : '停用'} - + width: 80, + render: (enabled: boolean, record: Camera) => ( + handleToggle(record)} + checkedChildren="启用" + unCheckedChildren="停用" + /> ), }, { title: 'FPS限制', dataIndex: 'fps_limit', key: 'fps_limit', + width: 100, + sorter: (a: Camera, b: Camera) => a.fps_limit - b.fps_limit, }, { title: '处理间隔', dataIndex: 'process_every_n_frames', key: 'process_every_n_frames', + width: 100, + render: (n: number) => `每 ${n} 帧`, }, { title: '操作', key: 'action', + width: 150, render: (_: any, record: Camera) => ( - -
+ `第 ${range[0]}-${range[1]} 条,共 ${total} 条`, + }} + scroll={{ x: 1200 }} /> + + {editingCamera ? : } + {editingCamera ? '编辑摄像头' : '新增摄像头'} + + } open={modalVisible} onCancel={() => setModalVisible(false)} onOk={() => form.submit()} + width={500} + okText="保存" + cancelText="取消" >
- - + + - - + + + - - - - - + + + + + + + + + + + + +
diff --git a/frontend/src/pages/ROIEditor.tsx b/frontend/src/pages/ROIEditor.tsx new file mode 100644 index 0000000..7c129c0 --- /dev/null +++ b/frontend/src/pages/ROIEditor.tsx @@ -0,0 +1,304 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { Card, Button, Space, Select, message, Modal, Form, Input, InputNumber, Drawer } 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; + return_sec: number; +} + +interface Camera { + id: number; + name: string; +} + +const ROIEditor: React.FC = () => { + const [cameras, setCameras] = useState([]); + 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 stageRef = useRef(null); + + const fetchCameras = async () => { + try { + const res = await axios.get('/api/cameras?enabled_only=true'); + setCameras(res.data); + if (res.data.length > 0 && !selectedCamera) { + setSelectedCamera(res.data[0].id); + } + } catch (err) { + message.error('获取摄像头列表失败'); + } + }; + + useEffect(() => { + fetchCameras(); + }, []); + + useEffect(() => { + if (selectedCamera) { + fetchSnapshot(); + fetchROIs(); + } + }, [selectedCamera]); + + const fetchSnapshot = async () => { + if (!selectedCamera) return; + try { + const res = await axios.get(`/api/camera/${selectedCamera}/snapshot/base64`); + setSnapshot(res.data.image); + 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 }); + }; + img.src = `data:image/jpeg;base64,${res.data.image}`; + } catch (err) { + message.error('获取截图失败'); + } + }; + + const fetchROIs = async () => { + if (!selectedCamera) return; + try { + const res = await axios.get(`/api/camera/${selectedCamera}/rois`); + setRois(res.data); + } catch (err) { + message.error('获取ROI列表失败'); + } + }; + + const handleSaveROI = async (values: any) => { + if (!selectedCamera || !selectedROI) return; + try { + await axios.put(`/api/camera/${selectedCamera}/roi/${selectedROI.id}`, values); + message.success('保存成功'); + setDrawerVisible(false); + fetchROIs(); + } catch (err) { + message.error('保存失败'); + } + }; + + 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 handleDeleteROI = async (roiId: number) => { + if (!selectedCamera) return; + try { + await axios.delete(`/api/camera/${selectedCamera}/roi/${roiId}`); + message.success('删除成功'); + fetchROIs(); + } catch (err) { + message.error('删除失败'); + } + }; + + const getROIStrokeColor = (rule: string) => { + return rule === 'intrusion' ? '#ff4d4f' : '#faad14'; + }; + + const renderROI = (roi: ROI) => { + const points = roi.points.flat(); + const color = getROIStrokeColor(roi.rule); + + 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 ( +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; + +export default ROIEditor; diff --git a/inference/pipeline.py b/inference/pipeline.py index a82a258..3ada530 100644 --- a/inference/pipeline.py +++ b/inference/pipeline.py @@ -12,11 +12,11 @@ import numpy as np from config import get_config from db.crud import ( create_alarm, - get_all_rois, update_camera_status, ) -from db.models import init_db +from db.models import init_db, get_session_factory from inference.engine import YOLOEngine +from inference.roi.cache_manager import get_roi_cache from inference.roi.roi_filter import ROIFilter from inference.rules.algorithms import AlgorithmManager from inference.stream import StreamManager @@ -34,6 +34,8 @@ class InferencePipeline: reconnect_delay=self.config.stream.reconnect_delay, ) self.roi_filter = ROIFilter() + self.roi_cache = get_roi_cache() + self.algo_manager = AlgorithmManager(working_hours=[ { "start": [wh.start[0], wh.start[1]], @@ -56,6 +58,8 @@ class InferencePipeline: if not self.db_initialized: init_db() self.db_initialized = True + self.roi_cache.initialize(get_session_factory(), refresh_interval=10.0) + self.roi_cache.start_background_refresh() def _get_db_session(self): from db.models import get_session_factory @@ -169,85 +173,65 @@ class InferencePipeline: def _process_frame(self, camera_id: int, frame: np.ndarray, camera): from ultralytics.engine.results import Results - db = self._get_db_session() - try: - rois = get_all_rois(db, camera_id) - roi_configs = [ + roi_configs = self.roi_cache.get_rois(camera_id) + + if roi_configs: + self.roi_filter.update_cache(camera_id, roi_configs) + + for roi_config in roi_configs: + roi_id = roi_config["roi_id"] + rule_type = roi_config["rule"] + + self.algo_manager.register_algorithm( + roi_id, + rule_type, { - "id": roi.id, - "roi_id": roi.roi_id, - "type": roi.roi_type, - "points": json.loads(roi.points), - "rule": roi.rule_type, - "direction": roi.direction, - "enabled": roi.enabled, - "threshold_sec": roi.threshold_sec, - "confirm_sec": roi.confirm_sec, - "return_sec": roi.return_sec, - } - for roi in rois - ] + "threshold_sec": roi_config.get("threshold_sec", 360), + "confirm_sec": roi_config.get("confirm_sec", 30), + "return_sec": roi_config.get("return_sec", 5), + }, + ) - if roi_configs: - self.roi_filter.update_cache(camera_id, roi_configs) + results = self.yolo_engine(frame, verbose=False, classes=[0]) - for roi_config in roi_configs: - roi_id = roi_config["roi_id"] - rule_type = roi_config["rule"] + if not results: + return - self.algo_manager.register_algorithm( + result = results[0] + detections = [] + if hasattr(result, "boxes") and result.boxes is not None: + boxes = result.boxes.xyxy.cpu().numpy() + confs = result.boxes.conf.cpu().numpy() + for i, box in enumerate(boxes): + detections.append({ + "bbox": box.tolist(), + "conf": float(confs[i]), + "cls": 0, + }) + + if roi_configs: + filtered_detections = self.roi_filter.filter_detections( + detections, roi_configs + ) + else: + filtered_detections = detections + + for detection in filtered_detections: + matched_rois = detection.get("matched_rois", []) + for roi_conf in matched_rois: + roi_id = roi_conf["roi_id"] + rule_type = roi_conf["rule"] + + alerts = self.algo_manager.process( roi_id, + str(camera_id), rule_type, - { - "threshold_sec": roi_config.get("threshold_sec", 360), - "confirm_sec": roi_config.get("confirm_sec", 30), - "return_sec": roi_config.get("return_sec", 5), - }, + [detection], + datetime.now(), ) - results = self.yolo_engine(frame, verbose=False, classes=[0]) - - if not results: - return - - result = results[0] - detections = [] - if hasattr(result, "boxes") and result.boxes is not None: - boxes = result.boxes.xyxy.cpu().numpy() - confs = result.boxes.conf.cpu().numpy() - for i, box in enumerate(boxes): - detections.append({ - "bbox": box.tolist(), - "conf": float(confs[i]), - "cls": 0, - }) - - if roi_configs: - filtered_detections = self.roi_filter.filter_detections( - detections, roi_configs - ) - else: - filtered_detections = detections - - for detection in filtered_detections: - matched_rois = detection.get("matched_rois", []) - for roi_conf in matched_rois: - roi_id = roi_conf["roi_id"] - rule_type = roi_conf["rule"] - - alerts = self.algo_manager.process( - roi_id, - str(camera_id), - rule_type, - [detection], - datetime.now(), - ) - - for alert in alerts: - self._handle_alert(camera_id, alert, frame, roi_conf) - - finally: - db.close() + for alert in alerts: + self._handle_alert(camera_id, alert, frame, roi_conf) def _handle_alert( self, diff --git a/logs/app.log b/logs/app.log index 5e3e77c..4f48c28 100644 --- a/logs/app.log +++ b/logs/app.log @@ -60,3 +60,16 @@ 2026-01-20 17:30:01,820 - security_monitor - INFO - 推理Pipeline启动,活跃摄像头数: 2 2026-01-20 17:31:10,482 - security_monitor - INFO - 正在关闭系统... 2026-01-20 17:31:10,612 - security_monitor - INFO - 系统已关闭 +2026-01-20 18:02:39,208 - security_monitor - INFO - 启动安保异常行为识别系统 +2026-01-20 18:02:39,221 - security_monitor - INFO - 数据库初始化完成 +2026-01-20 18:02:51,772 - security_monitor - INFO - 推理Pipeline启动,活跃摄像头数: 2 +2026-01-20 18:04:32,838 - security_monitor - INFO - 正在关闭系统... +2026-01-20 18:04:33,043 - security_monitor - INFO - 系统已关闭 +2026-01-20 18:04:47,234 - security_monitor - INFO - 启动安保异常行为识别系统 +2026-01-20 18:04:47,247 - security_monitor - INFO - 数据库初始化完成 +2026-01-20 18:04:59,775 - security_monitor - INFO - 推理Pipeline启动,活跃摄像头数: 2 +2026-01-20 18:23:13,953 - security_monitor - INFO - 正在关闭系统... +2026-01-20 18:23:14,097 - security_monitor - INFO - 系统已关闭 +2026-01-20 18:24:01,952 - security_monitor - INFO - 启动安保异常行为识别系统 +2026-01-20 18:24:01,963 - security_monitor - INFO - 数据库初始化完成 +2026-01-20 18:24:14,477 - security_monitor - INFO - 推理Pipeline启动,活跃摄像头数: 2 diff --git a/security_monitor.db b/security_monitor.db index e7aa754..8f46e24 100644 Binary files a/security_monitor.db and b/security_monitor.db differ