ROI选区01
This commit is contained in:
16
api/roi.py
16
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": "删除成功"}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider collapsible theme="dark">
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
theme="dark"
|
||||
style={{
|
||||
zIndex: 100,
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
overflow: 'auto',
|
||||
height: '100vh',
|
||||
}}
|
||||
width={200}
|
||||
collapsedWidth={80}
|
||||
>
|
||||
<div style={{
|
||||
height: 32,
|
||||
margin: 16,
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
height: 64,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 16,
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
安保监控系统
|
||||
{collapsed ? '' : '安保监控系统'}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
@@ -71,23 +90,31 @@ const App: React.FC = () => {
|
||||
onClick={({ key }) => navigate(key)}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header style={{ padding: 0, background: colorBgContainer }}>
|
||||
<div style={{ paddingLeft: 24, fontSize: 18, fontWeight: 'bold' }}>
|
||||
<Layout style={{ marginLeft: collapsed ? 80 : 200, transition: 'margin-left 0.2s' }}>
|
||||
<Header style={{
|
||||
padding: '0 24px',
|
||||
background: colorBgContainer,
|
||||
marginLeft: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 99,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
|
||||
}}>
|
||||
<div style={{ fontSize: 18, fontWeight: 'bold', color: '#333' }}>
|
||||
安保异常行为识别系统 v1.0
|
||||
</div>
|
||||
</Header>
|
||||
<Content style={{ margin: '16px' }}>
|
||||
<div
|
||||
style={{
|
||||
<Content style={{
|
||||
margin: 24,
|
||||
padding: 24,
|
||||
minHeight: 360,
|
||||
background: colorBgContainer,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
overflow: 'auto',
|
||||
}}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
@@ -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 = () => (
|
||||
<div>
|
||||
@@ -16,15 +16,16 @@ const Settings: React.FC = () => (
|
||||
const AppWrapper: React.FC = () => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<App>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/cameras" element={<CameraManagement />} />
|
||||
<Route path="/rois" element={<ROIEditor />} />
|
||||
<Route path="/alerts" element={<AlertCenter />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/" element={<App />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="cameras" element={<CameraManagement />} />
|
||||
<Route path="rois" element={<ROIEditor />} />
|
||||
<Route path="alerts" element={<AlertCenter />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</App>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
14
frontend/src/main.tsx
Normal file
14
frontend/src/main.tsx
Normal file
@@ -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(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<AppWrapper />
|
||||
</ConfigProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -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<Camera[]>([]);
|
||||
const [cameraStatus, setCameraStatus] = useState<Record<number, CameraStatus>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingCamera, setEditingCamera] = useState<Camera | null>(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) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<span style={{ fontWeight: 500 }}>{name}</span>
|
||||
<Tag icon={<CameraOutlined />} style={{ fontSize: 12 }}>
|
||||
#{record.id}
|
||||
</Tag>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<Tooltip title={record.rtsp_url}>
|
||||
<code style={{ fontSize: 12, color: '#666' }}>{ip}</code>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<Badge status={status as any} text={<span style={{ color }}>{text}</span>} />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
render: (enabled: boolean) => (
|
||||
<Tag color={enabled ? 'green' : 'red'}>
|
||||
{enabled ? '启用' : '停用'}
|
||||
</Tag>
|
||||
width: 80,
|
||||
render: (enabled: boolean, record: Camera) => (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={enabled}
|
||||
onChange={() => 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) => (
|
||||
<Space>
|
||||
<Space size="small">
|
||||
<Tooltip title={record.enabled ? '停用' : '启用'}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={record.enabled ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
size="small"
|
||||
icon={record.enabled ? <PauseCircleOutlined style={{ color: '#faad14' }} /> : <PlayCircleOutlined style={{ color: '#52c41a' }} />}
|
||||
onClick={() => handleToggle(record)}
|
||||
/>
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="编辑">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="确定删除此摄像头?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -140,35 +262,102 @@ const CameraManagement: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, gap: 16, flexWrap: 'wrap' }}>
|
||||
<Space>
|
||||
<Input
|
||||
placeholder="搜索摄像头名称、IP或RTSP地址"
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{ width: 300 }}
|
||||
allowClear
|
||||
/>
|
||||
<Tooltip title="刷新列表">
|
||||
<Button icon={<SyncOutlined spin={loading} />} onClick={fetchCameras} loading={loading} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
添加摄像头
|
||||
新增摄像头
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={cameras}
|
||||
dataSource={filteredCameras}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`,
|
||||
}}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingCamera ? '编辑摄像头' : '添加摄像头'}
|
||||
title={
|
||||
<Space>
|
||||
{editingCamera ? <EditOutlined /> : <PlusOutlined />}
|
||||
{editingCamera ? '编辑摄像头' : '新增摄像头'}
|
||||
</Space>
|
||||
}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
onOk={() => form.submit()}
|
||||
width={500}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="摄像头名称"
|
||||
rules={[{ required: true, message: '请输入摄像头名称' }]}
|
||||
tooltip="给摄像头起一个易于识别的名称"
|
||||
>
|
||||
<Input placeholder="例如:办公楼入口摄像头" />
|
||||
</Form.Item>
|
||||
<Form.Item name="rtsp_url" label="RTSP地址" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={2} />
|
||||
|
||||
<Form.Item
|
||||
name="rtsp_url"
|
||||
label="RTSP地址"
|
||||
rules={[
|
||||
{ required: true, message: '请输入RTSP地址' },
|
||||
{ type: 'url', message: '请输入有效的URL地址' }
|
||||
]}
|
||||
tooltip="RTSP流地址,格式如:rtsp://admin:password@192.168.1.100:554/stream"
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="rtsp://admin:password@IP地址:554/通道号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="fps_limit" label="FPS限制" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={60} style={{ width: '100%' }} />
|
||||
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item
|
||||
name="fps_limit"
|
||||
label="FPS限制"
|
||||
tooltip="摄像头最大处理帧率"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<InputNumber min={1} max={60} style={{ width: '100%' }} addonAfter="帧/秒" />
|
||||
</Form.Item>
|
||||
<Form.Item name="process_every_n_frames" label="处理间隔帧数" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={30} style={{ width: '100%' }} />
|
||||
|
||||
<Form.Item
|
||||
name="process_every_n_frames"
|
||||
label="处理间隔"
|
||||
tooltip="每N帧处理一次,用于降低CPU负载"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<InputNumber min={1} max={30} style={{ width: '100%' }} addonAfter="帧" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item
|
||||
name="enabled"
|
||||
label="启用状态"
|
||||
valuePropName="checked"
|
||||
tooltip="关闭后将停止该摄像头的检测任务"
|
||||
>
|
||||
<Switch checkedChildren="启用" unCheckedChildren="停用" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
304
frontend/src/pages/ROIEditor.tsx
Normal file
304
frontend/src/pages/ROIEditor.tsx
Normal file
@@ -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<Camera[]>([]);
|
||||
const [selectedCamera, setSelectedCamera] = useState<number | null>(null);
|
||||
const [rois, setRois] = useState<ROI[]>([]);
|
||||
const [snapshot, setSnapshot] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [imageDim, setImageDim] = useState({ width: 800, height: 600 });
|
||||
const [selectedROI, setSelectedROI] = useState<ROI | null>(null);
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const stageRef = useRef<any>(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 (
|
||||
<Line
|
||||
key={roi.id}
|
||||
points={points}
|
||||
closed
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
fill={`${color}33`}
|
||||
onClick={() => {
|
||||
setSelectedROI(roi);
|
||||
form.setFieldsValue(roi);
|
||||
setDrawerVisible(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (roi.type === 'line') {
|
||||
return (
|
||||
<Line
|
||||
key={roi.id}
|
||||
points={points}
|
||||
stroke={color}
|
||||
strokeWidth={3}
|
||||
onClick={() => {
|
||||
setSelectedROI(roi);
|
||||
form.setFieldsValue(roi);
|
||||
setDrawerVisible(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
placeholder="选择摄像头"
|
||||
value={selectedCamera}
|
||||
onChange={setSelectedCamera}
|
||||
style={{ width: 200 }}
|
||||
options={cameras.map((c) => ({ label: c.name, value: c.id }))}
|
||||
/>
|
||||
<Button type="primary" onClick={fetchSnapshot}>
|
||||
刷新截图
|
||||
</Button>
|
||||
<Button onClick={handleAddROI}>添加ROI</Button>
|
||||
</Space>
|
||||
|
||||
<div className="roi-editor-container" style={{ display: 'flex', gap: 16 }}>
|
||||
<div style={{ flex: 1, background: '#f0f0f0', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
{snapshot ? (
|
||||
<Stage width={imageDim.width} height={imageDim.height} ref={stageRef}>
|
||||
<Layer>
|
||||
<Rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={imageDim.width}
|
||||
height={imageDim.height}
|
||||
fillPatternImage={
|
||||
(() => {
|
||||
const img = new Image();
|
||||
img.src = `data:image/jpeg;base64,${snapshot}`;
|
||||
return img;
|
||||
})()
|
||||
}
|
||||
fillPatternOffset={{ x: 0, y: 0 }}
|
||||
fillPatternScale={{ x: 1, y: 1 }}
|
||||
/>
|
||||
{rois.map(renderROI)}
|
||||
</Layer>
|
||||
</Stage>
|
||||
) : (
|
||||
<div>加载中...</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ width: 300 }}>
|
||||
<Card title="ROI列表" size="small">
|
||||
{rois.map((roi) => (
|
||||
<div
|
||||
key={roi.id}
|
||||
style={{
|
||||
padding: 8,
|
||||
marginBottom: 8,
|
||||
background: '#fafafa',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
border: selectedROI?.id === roi.id ? '2px solid #1890ff' : '1px solid #d9d9d9',
|
||||
}}
|
||||
onClick={() => {
|
||||
setSelectedROI(roi);
|
||||
form.setFieldsValue(roi);
|
||||
setDrawerVisible(true);
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 'bold' }}>{roi.name}</div>
|
||||
<div style={{ fontSize: 12, color: '#666' }}>
|
||||
类型: {roi.type} | 规则: {roi.rule}
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteROI(roi.id);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
title="编辑ROI"
|
||||
open={drawerVisible}
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
width={400}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSaveROI}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="roi_type" label="类型">
|
||||
<Select options={[{ label: '多边形', value: 'polygon' }, { label: '线段', value: 'line' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="rule_type" label="规则">
|
||||
<Select
|
||||
options={[
|
||||
{ label: '离岗检测', value: 'leave_post' },
|
||||
{ label: '周界入侵', value: 'intrusion' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="threshold_sec" label="超时时间(秒)">
|
||||
<InputNumber min={60} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="confirm_sec" label="确认时间(秒)">
|
||||
<InputNumber min={5} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<input type="checkbox" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存
|
||||
</Button>
|
||||
<Button onClick={() => setDrawerVisible(false)}>取消</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ROIEditor;
|
||||
@@ -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,24 +173,7 @@ 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 = [
|
||||
{
|
||||
"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
|
||||
]
|
||||
roi_configs = self.roi_cache.get_rois(camera_id)
|
||||
|
||||
if roi_configs:
|
||||
self.roi_filter.update_cache(camera_id, roi_configs)
|
||||
@@ -246,9 +233,6 @@ class InferencePipeline:
|
||||
for alert in alerts:
|
||||
self._handle_alert(camera_id, alert, frame, roi_conf)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _handle_alert(
|
||||
self,
|
||||
camera_id: int,
|
||||
|
||||
13
logs/app.log
13
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
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user