增加了车间监控功能模块
This commit is contained in:
parent
25be2d5b12
commit
26dce29217
@ -21,6 +21,8 @@ def _required_role(path, method):
|
||||
return ROLE_ALGORITHM_ADMIN
|
||||
if method != "GET" and path.startswith(("/stream/", "/nvr/", "/control/", "/zone/", "/alarm/")):
|
||||
return ROLE_OPERATOR
|
||||
if method != "GET" and path.startswith("/workshop/"):
|
||||
return ROLE_OPERATOR
|
||||
if path.startswith(("/analysis/openStart", "/analysis/openStop", "/analysis/openReload")):
|
||||
return ROLE_OPERATOR
|
||||
if path.startswith(("/analysis/openPreviewStart", "/analysis/openPreviewStop")):
|
||||
|
||||
@ -62,7 +62,8 @@ INSTALLED_APPS = [
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'app'
|
||||
'app',
|
||||
'workshop_monitor',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
@ -24,6 +24,7 @@ import os
|
||||
urlpatterns = [
|
||||
# path('admin/', admin.site.urls),
|
||||
# path(r'app/', include('app.urls')),
|
||||
path(r'workshop/', include('workshop_monitor.urls')),
|
||||
path(r'', include('app.urls')),
|
||||
]
|
||||
|
||||
|
||||
@ -734,6 +734,7 @@
|
||||
"llm_no_image_provided": "未提供图像文件",
|
||||
"nav_zones": "布控管理",
|
||||
"nav_control": "布控管理",
|
||||
"nav_workshop": "车间监控",
|
||||
"zone_all_cameras": "全部摄像头",
|
||||
"zone_camera": "摄像头",
|
||||
"zone_name": "布控名称",
|
||||
|
||||
@ -58,6 +58,9 @@
|
||||
<a href="/control/index" class="nav-item {% block nav_control %}{% endblock %}" title="{{ T.nav_control|default:'布控管理' }}">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><polygon points="12 2 22 8.5 22 15.5 12 22 2 15.5 2 8.5"/><line x1="12" y1="22" x2="12" y2="15.5"/><polyline points="22 8.5 12 15.5 2 8.5"/></svg></span><span class="nav-label">{{ T.nav_control|default:'布控管理' }}</span>
|
||||
</a>
|
||||
<a href="/workshop/index" class="nav-item {% block nav_workshop %}{% endblock %}" title="{{ T.nav_workshop|default:'车间监控' }}">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><rect x="2" y="4" width="20" height="16" rx="1"/><path d="M2 9h20M7 4v16M17 4v16"/><circle cx="12" cy="14" r="2"/></svg></span><span class="nav-label">{{ T.nav_workshop|default:'车间监控' }}</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-group-title">{{ T.nav_insight|default:'分析与告警' }}</div>
|
||||
<a href="/alarm/index" class="nav-item {% block nav_alarm %}{% endblock %}" title="{{ T.nav_alarm|default:'报警管理' }}">
|
||||
|
||||
94
templates/workshop_monitor/index.html
Normal file
94
templates/workshop_monitor/index.html
Normal file
@ -0,0 +1,94 @@
|
||||
{% extends "app/base.html" %}
|
||||
{% load static %}
|
||||
{% block title %}车间监控{% endblock %}
|
||||
{% block nav_workshop %}active{% endblock %}
|
||||
{% block page_title %}车间监控{% endblock %}
|
||||
{% block extra_head %}
|
||||
<script src="{% static 'lib/easyPlayer/js/easyplayer-pro.js' %}"></script>
|
||||
<style>
|
||||
.wm-toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:14px}.wm-state{display:inline-flex;align-items:center;gap:7px;padding:7px 12px;border-radius:999px;background:#f1f5f9;color:#64748b;font-size:13px}.wm-state.on{background:#ecfdf5;color:#047857}.wm-dot{width:8px;height:8px;border-radius:50%;background:currentColor}
|
||||
.wm-layout{display:grid;grid-template-columns:minmax(520px,1.55fr) minmax(340px,1fr);gap:14px}.wm-panel{background:#fff;border:1px solid var(--c-border);border-radius:12px;box-shadow:var(--sh-sm);overflow:hidden}.wm-panel-head{display:flex;align-items:center;justify-content:space-between;padding:13px 16px;border-bottom:1px solid var(--c-border-light)}.wm-panel-title{font-size:14px;font-weight:650}.wm-muted{font-size:12px;color:#64748b}
|
||||
.wm-map-wrap{padding:16px;background:#f8fafc}.wm-map{display:block;width:100%;aspect-ratio:13/5;background:#fff;border:1px solid #cbd5e1;border-radius:6px}.wm-grid{stroke:#dbe4ee;stroke-width:.12}.wm-axis{fill:#64748b;font-size:1.8px}.wm-camera{cursor:pointer}.wm-camera circle{fill:#0f766e;stroke:#fff;stroke-width:.5}.wm-camera text{font-size:2px;fill:#0f172a}.wm-camera path{stroke:#0f766e;stroke-width:.65;fill:none}.wm-target circle{stroke:#fff;stroke-width:.55}.wm-target text{font-size:1.8px;font-weight:700;paint-order:stroke;stroke:#fff;stroke-width:.4}.wm-trail{fill:none;stroke:#2563eb;stroke-width:.45;opacity:.65}
|
||||
.wm-video-grid{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:#334155}.wm-video{position:relative;min-height:190px;background:#0f172a;overflow:hidden}.wm-player{position:absolute;inset:0}.wm-overlay{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}.wm-video-label{position:absolute;left:8px;top:8px;z-index:3;padding:4px 8px;border-radius:5px;background:rgba(15,23,42,.75);color:#fff;font-size:12px}.wm-video-health{position:absolute;right:8px;top:8px;z-index:3;color:#fff;font-size:11px;background:rgba(15,23,42,.72);padding:4px 7px;border-radius:5px}.wm-empty{display:flex;height:100%;align-items:center;justify-content:center;color:#94a3b8;font-size:13px}
|
||||
.wm-side{display:flex;flex-direction:column;gap:14px}.wm-target-list{max-height:270px;overflow:auto}.wm-target-row{display:grid;grid-template-columns:82px 1fr auto;gap:8px;padding:10px 14px;border-bottom:1px solid #eef2f7;font-size:12px}.wm-target-row:last-child{border-bottom:0}.wm-id{font-weight:700;color:#2563eb}.wm-quality{padding:2px 7px;border-radius:999px;background:#ecfdf5;color:#047857}.wm-quality.low{background:#fff7ed;color:#c2410c}
|
||||
.wm-form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}.wm-field label{display:block;font-size:12px;color:#64748b;margin-bottom:5px}.wm-field input,.wm-field select{width:100%;height:36px;border:1px solid #cbd5e1;border-radius:6px;padding:0 9px;background:#fff}.wm-camera-config{display:grid;grid-template-columns:38px 1.4fr 1fr repeat(5,.65fr);gap:7px;align-items:end;padding:10px 0;border-top:1px solid #eef2f7}.wm-camera-config .wm-field label{font-size:10px}.wm-camera-config input,.wm-camera-config select{height:32px;font-size:12px}
|
||||
.wm-modal{display:none;position:fixed;z-index:1100;inset:0;background:rgba(15,23,42,.56);align-items:center;justify-content:center;padding:20px}.wm-modal.show{display:flex}.wm-dialog{width:min(1180px,96vw);max-height:94vh;overflow:auto;background:#fff;border-radius:12px}.wm-dialog-head{position:sticky;top:0;z-index:5;background:#fff;display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border-bottom:1px solid #e2e8f0}.wm-dialog-body{padding:16px}.wm-cal-grid{display:grid;grid-template-columns:minmax(520px,1.45fr) minmax(330px,1fr);gap:14px}.wm-shot{position:relative;background:#0f172a;min-height:390px;display:flex;align-items:center;justify-content:center;overflow:hidden;border-radius:8px}.wm-shot img{display:block;max-width:100%;max-height:70vh;cursor:crosshair}.wm-pin{position:absolute;width:18px;height:18px;margin:-9px;border-radius:50%;background:#ef4444;color:#fff;font-size:10px;display:flex;align-items:center;justify-content:center;border:2px solid white;pointer-events:none}.wm-point-list{max-height:360px;overflow:auto}.wm-point-row{display:grid;grid-template-columns:28px 1fr 78px 26px;gap:6px;align-items:center;margin-bottom:6px;font-size:11px}.wm-point-row select{height:30px;border:1px solid #cbd5e1;border-radius:5px;width:100%}.wm-gcp-row{display:grid;grid-template-columns:1fr 70px 70px 28px;gap:5px;margin-bottom:5px}.wm-gcp-row input{height:30px;border:1px solid #cbd5e1;border-radius:5px;padding:0 6px;width:100%}
|
||||
@media(max-width:1100px){.wm-layout,.wm-cal-grid{grid-template-columns:1fr}.wm-video{min-height:220px}}@media(max-width:720px){.wm-video-grid{grid-template-columns:1fr}.wm-form-grid{grid-template-columns:1fr 1fr}.wm-camera-config{grid-template-columns:35px 1fr 1fr}.wm-camera-config .pose{grid-column:auto}}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-head"><div class="page-head-left"><div><div class="page-head-title">车间多摄像头实时定位</div><div class="page-head-sub">统一坐标系 · 左上角 (0,0) · X 向右 / Y 向下 · 单位:米</div></div></div></div>
|
||||
<div class="wm-toolbar">
|
||||
<button class="btn btn-primary" onclick="startMonitor()">启动监控</button><button class="btn" onclick="stopMonitor()">停止</button>
|
||||
<button class="btn" onclick="openSettings()">车间配置</button><button class="btn" onclick="openCalibration()">控制点与标定</button><button class="btn" onclick="loadConfig()">刷新</button>
|
||||
<span class="wm-state" id="runtimeState"><span class="wm-dot"></span><span>未启动</span></span><span class="wm-muted" id="runtimeMeta"></span>
|
||||
</div>
|
||||
<div class="wm-layout">
|
||||
<div class="wm-panel"><div class="wm-panel-head"><span class="wm-panel-title" id="mapTitle">主车间 · 130 × 50m</span><span class="wm-muted">目标坐标为算法估算值</span></div><div class="wm-map-wrap"><svg id="workshopMap" class="wm-map" viewBox="-5 -5 140 60" preserveAspectRatio="xMidYMid meet"></svg></div>
|
||||
<div class="wm-panel-head"><span class="wm-panel-title">四路实时视频</span><span class="wm-muted">检测框底边中点映射到地面</span></div><div class="wm-video-grid" id="videoGrid"></div>
|
||||
</div>
|
||||
<div class="wm-side">
|
||||
<div class="wm-panel"><div class="wm-panel-head"><span class="wm-panel-title">实时目标</span><span class="wm-muted" id="targetCount">0 个</span></div><div class="wm-target-list" id="targetList"><div class="wm-empty" style="height:120px">暂无目标</div></div></div>
|
||||
<div class="wm-panel"><div class="wm-panel-head"><span class="wm-panel-title">摄像头状态</span></div><div id="cameraStatus"></div></div>
|
||||
<div class="wm-panel"><div class="wm-panel-head"><span class="wm-panel-title">定位说明</span></div><div style="padding:14px;font-size:12px;line-height:1.8;color:#64748b">每台摄像头必须分别标定。控制点可以不同,但世界坐标必须使用同一个厂房原点。摄像头移动、转动或变焦后需重新标定。</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wm-modal" id="settingsModal"><div class="wm-dialog" style="max-width:1080px"><div class="wm-dialog-head"><b>车间与摄像头配置</b><button class="btn" onclick="closeModal('settingsModal')">关闭</button></div><div class="wm-dialog-body">
|
||||
<div class="wm-form-grid">
|
||||
<div class="wm-field"><label>车间名称</label><input id="sName"></div><div class="wm-field"><label>长度 X(米)</label><input id="sWidth" type="number"></div><div class="wm-field"><label>宽度 Y(米)</label><input id="sHeight" type="number"></div><div class="wm-field"><label>分析 FPS / 路</label><input id="sFps" type="number" step=".1"></div>
|
||||
<div class="wm-field"><label>人员检测模型</label><select id="sDetector"></select></div><div class="wm-field"><label>可选 ReID 模型</label><select id="sReid"></select></div><div class="wm-field"><label>目标类别(逗号分隔)</label><input id="sLabels"></div><div class="wm-field"><label>融合半径(米)</label><input id="sRadius" type="number" step=".1"></div>
|
||||
<div class="wm-field"><label>观测窗口(秒)</label><input id="sWindow" type="number" step=".1"></div><div class="wm-field"><label>最大速度(米/秒)</label><input id="sSpeed" type="number" step=".1"></div><div class="wm-field"><label>丢失保留(秒)</label><input id="sTtl" type="number"></div><div class="wm-field"><label>轨迹长度(秒)</label><input id="sTrail" type="number"></div>
|
||||
</div><h4 style="margin:18px 0 4px">摄像头安装位置</h4><div class="wm-muted">XYZ 为现场测量值;Yaw:0°向右、90°向下、180°向左、270°向上。</div><div id="cameraConfigRows"></div><div style="text-align:right;margin-top:16px"><button class="btn btn-primary" onclick="saveSettings()">保存配置</button></div>
|
||||
</div></div></div>
|
||||
|
||||
<div class="wm-modal" id="calibrationModal"><div class="wm-dialog"><div class="wm-dialog-head"><b>现场控制点标定</b><button class="btn" onclick="closeModal('calibrationModal')">关闭</button></div><div class="wm-dialog-body">
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:12px"><select id="calCamera" style="height:36px;min-width:220px" onchange="resetShot()"></select><button class="btn btn-primary" onclick="captureFrame()">抓取标定画面</button><button class="btn" onclick="solveCalibration()">计算并激活</button><span class="wm-muted" id="calResult"></span></div>
|
||||
<div class="wm-cal-grid"><div><div class="wm-shot" id="shotBox"><span class="wm-muted">请先选择摄像头并抓取画面</span></div><p class="wm-muted">点击画面添加像素点,然后在右侧选择对应的现场控制点;至少 4 个拟合点和 3 个独立验证点。</p></div>
|
||||
<div><div class="wm-panel" style="margin-bottom:12px"><div class="wm-panel-head"><b>本次像素观测</b><button class="btn" onclick="clearPins()">清空</button></div><div class="wm-dialog-body wm-point-list" id="pointRows"><div class="wm-muted">尚未点选</div></div></div>
|
||||
<div class="wm-panel"><div class="wm-panel-head"><b>厂房控制点</b><button class="btn" onclick="addGcpRow()">新增</button></div><div class="wm-dialog-body"><div id="gcpRows"></div><button class="btn btn-primary" onclick="saveGcps()">保存控制点</button></div></div></div></div>
|
||||
</div></div></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
var wm={cfg:null,state:null,players:{},pins:[],shot:null,poll:null,polling:false};
|
||||
function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]})}
|
||||
function toast(m,t){if(window.showToast)showToast(m,t||'success');else alert(m)}
|
||||
function closeModal(id){document.getElementById(id).classList.remove('show')}
|
||||
function apiOk(res){if(!res||res.code!==1000)throw new Error((res&&res.msg)||'请求失败');return res.data||{}}
|
||||
function loadConfig(){Api.get('/workshop/openConfig').then(apiOk).then(function(d){wm.cfg=d;renderAll();}).catch(function(e){toast(e.message,'error')})}
|
||||
function renderAll(){var s=wm.cfg.site;document.getElementById('mapTitle').textContent=s.name+' · '+s.width_m+' × '+s.height_m+'m';renderMap();renderVideos();renderCameraStatus();}
|
||||
function renderMap(){var s=wm.cfg.site,w=Number(s.width_m),h=Number(s.height_m),svg=document.getElementById('workshopMap');svg.setAttribute('viewBox',[-5,-5,w+10,h+10].join(' '));var out=['<rect x="0" y="0" width="'+w+'" height="'+h+'" fill="#fff" stroke="#64748b" stroke-width=".35"/>'];
|
||||
for(var x=10;x<w;x+=10)out.push('<line class="wm-grid" x1="'+x+'" y1="0" x2="'+x+'" y2="'+h+'"/><text class="wm-axis" x="'+x+'" y="-1">'+x+'</text>');for(var y=10;y<h;y+=10)out.push('<line class="wm-grid" x1="0" y1="'+y+'" x2="'+w+'" y2="'+y+'"/><text class="wm-axis" x="-4" y="'+(y+.6)+'">'+y+'</text>');out.push('<text class="wm-axis" x="-3.5" y="-1">(0,0)</text>');
|
||||
var targets=(wm.state&&wm.state.targets)||[];targets.forEach(function(t){if(t.trail&&t.trail.length>1)out.push('<polyline class="wm-trail" points="'+t.trail.map(function(p){return p[0]+','+p[1]}).join(' ')+'"/>')});
|
||||
wm.cfg.cameras.forEach(function(c){var rad=Number(c.yaw_deg)*Math.PI/180,dx=Math.cos(rad)*5,dy=Math.sin(rad)*5;out.push('<g class="wm-camera" onclick="openCalibration('+c.id+')"><path d="M'+c.install_x+','+c.install_y+' L'+(c.install_x+dx)+','+(c.install_y+dy)+'"/><circle cx="'+c.install_x+'" cy="'+c.install_y+'" r="1.5"/><text x="'+(Number(c.install_x)+2)+'" y="'+(Number(c.install_y)+.7)+'">C'+c.slot+'</text></g>')});
|
||||
targets.forEach(function(t){var color=t.state==='active'?'#2563eb':'#94a3b8';out.push('<g class="wm-target"><circle cx="'+t.x+'" cy="'+t.y+'" r="1.5" fill="'+color+'"/><text x="'+(Number(t.x)+2)+'" y="'+(Number(t.y)+.7)+'" fill="#1e3a8a">'+esc(t.global_id)+' '+Number(t.x).toFixed(1)+','+Number(t.y).toFixed(1)+'</text></g>')});svg.innerHTML=out.join('')}
|
||||
function renderVideos(){var grid=document.getElementById('videoGrid'),ids=wm.cfg.cameras.map(function(c){return c.id});Object.keys(wm.players).forEach(function(id){if(ids.indexOf(Number(id))<0){try{wm.players[id].destroy()}catch(e){}delete wm.players[id]}});grid.innerHTML=wm.cfg.cameras.map(function(c){return '<div class="wm-video" id="video-'+c.id+'"><div class="wm-player" id="player-'+c.id+'"><div class="wm-empty">'+(c.stream_id?'连接视频…':'未绑定视频流')+'</div></div><canvas class="wm-overlay" id="overlay-'+c.id+'"></canvas><div class="wm-video-label">C'+c.slot+' · '+esc(c.display_name||c.stream_nickname)+'</div><div class="wm-video-health" id="health-'+c.id+'">'+(c.calibration?'已标定':'待标定')+'</div></div>'}).join('');wm.cfg.cameras.forEach(startPlayer)}
|
||||
function startPlayer(c){if(!c.stream_id||wm.players[c.id])return;Api.get('/stream/openPlayer',{app:c.stream_app,name:c.stream_name}).then(function(res){var s=res&&res.info&&res.info.stream,url=s&&(s.wsMp4Url||s.httpMp4Url||s.wsFlvUrl||s.httpFlvUrl);if(!url)return;var el=document.getElementById('player-'+c.id);el.innerHTML='';try{var p=new EasyPlayerPro({container:el,videoBuffer:.1,decoder:'/static/lib/easyPlayer/js/decoder-pro.js',isResize:true,debug:false,useMSE:true,useWCS:true,hasAudio:false,isNotMute:true,showBandwidth:false,operateBtns:{fullscreen:true,screenshot:false,play:true,audio:false,ptz:false},timeout:10,forceNoOffscreen:true,heartTimeout:10});p.play(url);wm.players[c.id]=p}catch(e){el.innerHTML='<div class="wm-empty">播放器启动失败</div>'}})}
|
||||
function renderTargets(){var ts=(wm.state&&wm.state.targets)||[],box=document.getElementById('targetList');document.getElementById('targetCount').textContent=ts.length+' 个';box.innerHTML=ts.length?ts.map(function(t){return '<div class="wm-target-row"><span class="wm-id">'+esc(t.global_id)+'</span><span>X '+Number(t.x).toFixed(2)+'m · Y '+Number(t.y).toFixed(2)+'m<br><span class="wm-muted">来源 C'+t.source_camera_ids.join(', C')+' · '+Number(t.age_sec||0).toFixed(1)+'s</span></span><span class="wm-quality '+(t.fusion_confidence==='low'?'low':'')+'">'+esc(t.fusion_confidence)+'</span></div>'}).join(''):'<div class="wm-empty" style="height:120px">暂无目标</div>'}
|
||||
function renderCameraStatus(){var states={},rows=(wm.state&&wm.state.cameras)||[];rows.forEach(function(x){states[x.camera_id]=x});document.getElementById('cameraStatus').innerHTML=wm.cfg.cameras.map(function(c){var x=states[c.id]||{},cal=c.calibration,health=x.stream_health||'未运行';return '<div class="wm-target-row"><b>C'+c.slot+'</b><span>'+esc(c.display_name||c.stream_nickname||'未绑定')+'<br><span class="wm-muted">'+health+(x.analysis_error?' · '+esc(x.analysis_error):'')+'</span></span><span class="wm-quality '+(!cal?'low':'')+'">'+(cal?Number(cal.validation_mean_m).toFixed(2)+'m':'待标定')+'</span></div>'}).join('')}
|
||||
function drawOverlays(){if(!wm.cfg)return;wm.cfg.cameras.forEach(function(c){var canvas=document.getElementById('overlay-'+c.id);if(!canvas)return;var rect=canvas.getBoundingClientRect(),dpr=window.devicePixelRatio||1;canvas.width=rect.width*dpr;canvas.height=rect.height*dpr;var ctx=canvas.getContext('2d');ctx.scale(dpr,dpr);var cs=((wm.state&&wm.state.cameras)||[]).find(function(x){return x.camera_id===c.id}),obs=(cs&&cs.observations)||[];obs.forEach(function(o){var b=o.bbox,x=b[0]*rect.width,y=b[1]*rect.height,w=(b[2]-b[0])*rect.width,h=(b[3]-b[1])*rect.height;ctx.strokeStyle='#22d3ee';ctx.lineWidth=2;ctx.strokeRect(x,y,w,h);ctx.fillStyle='rgba(15,23,42,.78)';ctx.fillRect(x,Math.max(0,y-20),Math.max(130,w),20);ctx.fillStyle='#fff';ctx.font='12px sans-serif';ctx.fillText((o.global_id||'L'+o.local_track_id)+' '+Number(o.x).toFixed(1)+','+Number(o.y).toFixed(1)+'m',x+4,Math.max(13,y-6))})})}
|
||||
function pollState(){if(!wm.cfg||wm.polling)return;wm.polling=true;Api.get('/workshop/openState',{since:(wm.state&&wm.state.sequence)||0}).then(apiOk).then(function(d){var badge=document.getElementById('runtimeState');badge.classList.toggle('on',!!d.running);badge.lastElementChild.textContent=d.running?'运行中':'未启动';if(d.changed){wm.state=d;renderMap();renderTargets();renderCameraStatus();drawOverlays()}document.getElementById('runtimeMeta').textContent=d.error||((d.timestamp&&d.running)?'数据时间 '+new Date(d.timestamp*1000).toLocaleTimeString():'')}).catch(function(){}).then(function(){wm.polling=false})}
|
||||
function startMonitor(){Api.post('/workshop/openStart',{}).then(apiOk).then(function(d){wm.state=d;pollState();toast('车间定位服务已启动')}).catch(function(e){toast(e.message,'error')})}
|
||||
function stopMonitor(){Api.post('/workshop/openStop',{}).then(apiOk).then(function(d){wm.state=d;pollState();toast('车间定位服务已停止')}).catch(function(e){toast(e.message,'error')})}
|
||||
function options(rows,selected,filter){return rows.filter(filter||function(){return true}).map(function(x){var name=x.display_name||x.stream_nickname||x.nickname||x.name||x.code||('摄像头 '+x.id),extra=x.slot?'C'+x.slot+' · ':(x.code?' · '+x.code+(x.pull_stream_ip?' · '+x.pull_stream_ip:''):'');return '<option value="'+x.id+'" '+(Number(selected)===Number(x.id)?'selected':'')+'>'+esc(x.slot?extra+name:name+extra)+'</option>'}).join('')}
|
||||
function openSettings(){var s=wm.cfg.site;['Name','Width','Height','Fps','Labels','Radius','Window','Speed','Ttl','Trail'].forEach(function(k){var map={Name:s.name,Width:s.width_m,Height:s.height_m,Fps:s.analysis_fps,Labels:(s.target_labels||[]).join(','),Radius:s.fusion_radius_m,Window:s.observation_window_sec,Speed:s.max_speed_mps,Ttl:s.lost_ttl_sec,Trail:s.trail_sec};document.getElementById('s'+k).value=map[k]});document.getElementById('sDetector').innerHTML='<option value="">请选择</option>'+options(wm.cfg.algorithms,s.detector_id,function(x){return x.task_type!=='reid'});document.getElementById('sReid').innerHTML='<option value="">不启用</option>'+options(wm.cfg.algorithms,s.reid_model_id,function(x){return x.task_type==='reid'});document.getElementById('cameraConfigRows').innerHTML=wm.cfg.cameras.map(function(c){return '<div class="wm-camera-config" data-id="'+c.id+'"><div class="wm-field"><label>启用</label><input class="cc-enabled" type="checkbox" '+(c.enabled?'checked':'')+'></div><div class="wm-field"><label>视频流</label><select class="cc-stream"><option value="">未绑定</option>'+options(wm.cfg.streams,c.stream_id)+'</select></div><div class="wm-field"><label>显示名</label><input class="cc-name" value="'+esc(c.display_name)+'"></div>'+['install_x','install_y','install_z','yaw_deg','pitch_deg'].map(function(k){return '<div class="wm-field pose"><label>'+({install_x:'X',install_y:'Y',install_z:'Z',yaw_deg:'Yaw',pitch_deg:'Pitch'}[k])+'</label><input class="cc-'+k+'" type="number" step=".1" value="'+c[k]+'"></div>'}).join('')+'</div>'}).join('');document.getElementById('settingsModal').classList.add('show')}
|
||||
function saveSettings(){var p={name:sName.value,width_m:sWidth.value,height_m:sHeight.value,analysis_fps:sFps.value,target_labels:sLabels.value,fusion_radius_m:sRadius.value,observation_window_sec:sWindow.value,max_speed_mps:sSpeed.value,lost_ttl_sec:sTtl.value,trail_sec:sTrail.value,detector_id:sDetector.value,reid_model_id:sReid.value,cameras:[]};document.querySelectorAll('.wm-camera-config').forEach(function(r){var q=function(c){return r.querySelector(c)};p.cameras.push({id:r.dataset.id,enabled:q('.cc-enabled').checked,stream_id:q('.cc-stream').value,display_name:q('.cc-name').value,install_x:q('.cc-install_x').value,install_y:q('.cc-install_y').value,install_z:q('.cc-install_z').value,yaw_deg:q('.cc-yaw_deg').value,pitch_deg:q('.cc-pitch_deg').value})});Api.post('/workshop/openSaveConfig',p).then(apiOk).then(function(d){wm.cfg=d;closeModal('settingsModal');renderAll();toast('配置已保存')}).catch(function(e){toast(e.message,'error')})}
|
||||
function openCalibration(cameraId){document.getElementById('calCamera').innerHTML=options(wm.cfg.cameras,cameraId||wm.cfg.cameras[0].id);renderGcps();resetShot();document.getElementById('calibrationModal').classList.add('show')}
|
||||
function resetShot(){wm.pins=[];wm.shot=null;document.getElementById('shotBox').innerHTML='<span class="wm-muted">请抓取当前摄像头画面</span>';renderPinRows();document.getElementById('calResult').textContent=''}
|
||||
function renderGcps(){document.getElementById('gcpRows').innerHTML=wm.cfg.control_points.map(function(p){return gcpHtml(p)}).join('')||'<div class="wm-muted" id="gcpEmpty">请先新增现场控制点</div>'}
|
||||
function gcpHtml(p){p=p||{};return '<div class="wm-gcp-row" data-id="'+(p.id||'')+'"><input class="gp-name" placeholder="名称" value="'+esc(p.name||'')+'"><input class="gp-x" type="number" step=".01" placeholder="X" value="'+(p.x==null?'':p.x)+'"><input class="gp-y" type="number" step=".01" placeholder="Y" value="'+(p.y==null?'':p.y)+'"><button class="btn" onclick="deleteGcp(this)">×</button></div>'}
|
||||
function addGcpRow(){var e=document.getElementById('gcpEmpty');if(e)e.remove();document.getElementById('gcpRows').insertAdjacentHTML('beforeend',gcpHtml())}
|
||||
function saveGcps(){var rows=[].slice.call(document.querySelectorAll('.wm-gcp-row')),chain=Promise.resolve();rows.forEach(function(r){chain=chain.then(function(){return Api.post('/workshop/openControlPointSave',{id:r.dataset.id,name:r.querySelector('.gp-name').value,x:r.querySelector('.gp-x').value,y:r.querySelector('.gp-y').value}).then(apiOk)})});chain.then(function(){toast('控制点已保存');return Api.get('/workshop/openConfig')}).then(apiOk).then(function(d){wm.cfg=d;renderGcps();renderPinRows()}).catch(function(e){toast(e.message,'error')})}
|
||||
function deleteGcp(btn){var row=btn.parentNode,id=row.dataset.id;if(!id){row.remove();return}Api.post('/workshop/openControlPointDelete',{id:id}).then(apiOk).then(function(){wm.cfg.control_points=wm.cfg.control_points.filter(function(p){return Number(p.id)!==Number(id)});renderGcps();renderPinRows()}).catch(function(e){toast(e.message,'error')})}
|
||||
function captureFrame(){document.getElementById('calResult').textContent='正在抓帧…';Api.post('/workshop/openCapture',{camera_id:calCamera.value}).then(apiOk).then(function(d){wm.shot=d;wm.pins=[];var box=document.getElementById('shotBox');box.innerHTML='<img id="calImage" src="'+d.image+'" alt="标定截图">';document.getElementById('calImage').onclick=addPin;renderPinRows();document.getElementById('calResult').textContent=d.width+' × '+d.height}).catch(function(e){document.getElementById('calResult').textContent='';toast(e.message,'error')})}
|
||||
function addPin(ev){if(!wm.shot)return;var img=ev.currentTarget,r=img.getBoundingClientRect(),u=(ev.clientX-r.left)/r.width*wm.shot.width,v=(ev.clientY-r.top)/r.height*wm.shot.height;wm.pins.push({u:u,v:v,role:wm.pins.length<4?'fit':'verify',control_point_id:''});renderPins();renderPinRows()}
|
||||
function renderPins(){var box=document.getElementById('shotBox'),img=document.getElementById('calImage');box.querySelectorAll('.wm-pin').forEach(function(x){x.remove()});if(!img)return;var r=img.getBoundingClientRect(),br=box.getBoundingClientRect();wm.pins.forEach(function(p,i){var pin=document.createElement('span');pin.className='wm-pin';pin.textContent=i+1;pin.style.left=(r.left-br.left+p.u/wm.shot.width*r.width)+'px';pin.style.top=(r.top-br.top+p.v/wm.shot.height*r.height)+'px';box.appendChild(pin)})}
|
||||
function renderPinRows(){var box=document.getElementById('pointRows');if(!wm.pins.length){box.innerHTML='<div class="wm-muted">尚未点选</div>';return}box.innerHTML=wm.pins.map(function(p,i){return '<div class="wm-point-row"><b>'+(i+1)+'</b><select onchange="wm.pins['+i+'].control_point_id=this.value"><option value="">选择控制点</option>'+options(wm.cfg.control_points,p.control_point_id)+'</select><select onchange="wm.pins['+i+'].role=this.value"><option value="fit" '+(p.role==='fit'?'selected':'')+'>拟合</option><option value="verify" '+(p.role==='verify'?'selected':'')+'>验证</option></select><button class="btn" onclick="removePin('+i+')">×</button></div>'}).join('')}
|
||||
function removePin(i){wm.pins.splice(i,1);renderPins();renderPinRows()}function clearPins(){wm.pins=[];renderPins();renderPinRows()}
|
||||
function solveCalibration(){if(!wm.shot){toast('请先抓取画面','error');return}var map={};wm.cfg.control_points.forEach(function(p){map[p.id]=p});var observations=[];for(var i=0;i<wm.pins.length;i++){var p=wm.pins[i],g=map[p.control_point_id];if(!g){toast('第 '+(i+1)+' 个像素点尚未选择控制点','error');return}observations.push({u:p.u,v:p.v,x:g.x,y:g.y,role:p.role,control_point_id:g.id})}Api.post('/workshop/openCalibrate',{camera_id:calCamera.value,snapshot_token:wm.shot.token,frame_width:wm.shot.width,frame_height:wm.shot.height,observations:observations,activate:true}).then(apiOk).then(function(d){document.getElementById('calResult').textContent='平均误差 '+Number(d.validation_mean_m).toFixed(3)+'m,最大 '+Number(d.validation_max_m).toFixed(3)+'m';toast(d.is_valid?'标定合格并已激活':'标定未达到 1 米要求',d.is_valid?'success':'error');return Api.get('/workshop/openConfig')}).then(apiOk).then(function(d){wm.cfg=d;renderAll()}).catch(function(e){toast(e.message,'error')})}
|
||||
window.addEventListener('resize',function(){if(wm.shot)renderPins();drawOverlays()});loadConfig();wm.poll=setInterval(pollState,250);
|
||||
</script>
|
||||
{% endblock %}
|
||||
76
tests/test_workshop_algorithms.py
Normal file
76
tests/test_workshop_algorithms.py
Normal file
@ -0,0 +1,76 @@
|
||||
import unittest
|
||||
|
||||
from workshop_monitor.calibration import CalibrationError, foot_point_world, solve_planar_calibration
|
||||
from workshop_monitor.fusion import GlobalFusionTracker, minimum_cost_pairs
|
||||
|
||||
|
||||
class WorkshopCalibrationTests(unittest.TestCase):
|
||||
def observations(self):
|
||||
return [
|
||||
{"u": 0, "v": 0, "x": 0, "y": 0, "role": "fit"},
|
||||
{"u": 1300, "v": 0, "x": 130, "y": 0, "role": "fit"},
|
||||
{"u": 1300, "v": 500, "x": 130, "y": 50, "role": "fit"},
|
||||
{"u": 0, "v": 500, "x": 0, "y": 50, "role": "fit"},
|
||||
{"u": 260, "v": 100, "x": 26, "y": 10, "role": "verify"},
|
||||
{"u": 650, "v": 250, "x": 65, "y": 25, "role": "verify"},
|
||||
{"u": 1040, "v": 400, "x": 104, "y": 40, "role": "verify"},
|
||||
]
|
||||
|
||||
def test_homography_uses_independent_validation_points(self):
|
||||
result = solve_planar_calibration(self.observations(), 1300, 500)
|
||||
self.assertTrue(result["is_valid"])
|
||||
self.assertLess(result["validation_mean_m"], 1e-5)
|
||||
self.assertEqual(len(result["observations"]), 7)
|
||||
|
||||
def test_collinear_points_are_rejected(self):
|
||||
rows = [{"u": i * 10, "v": i * 10, "x": i, "y": i, "role": "fit"} for i in range(4)]
|
||||
rows += [{"u": i, "v": i + 1, "x": i, "y": i + 1, "role": "verify"} for i in range(3)]
|
||||
with self.assertRaises(CalibrationError):
|
||||
solve_planar_calibration(rows, 100, 100)
|
||||
|
||||
def test_person_foot_point_and_boundary(self):
|
||||
h = [[0.1, 0, 0], [0, 0.1, 0], [0, 0, 1]]
|
||||
self.assertEqual(foot_point_world([100, 100, 200, 300], h, 130, 50), (15.0, 30.0))
|
||||
self.assertIsNone(foot_point_world([2000, 100, 2200, 300], h, 130, 50))
|
||||
|
||||
|
||||
class WorkshopFusionTests(unittest.TestCase):
|
||||
def obs(self, camera, local_id, x, y, timestamp, embedding=None):
|
||||
return {"camera_id": camera, "stream_id": camera, "local_track_id": local_id,
|
||||
"class": "person", "score": .9, "x": x, "y": y,
|
||||
"timestamp": timestamp, "calibration_error_m": .5,
|
||||
"embedding": embedding}
|
||||
|
||||
def test_two_cameras_merge_same_person(self):
|
||||
fusion = GlobalFusionTracker(radius_m=1.5, time_window_sec=1, max_speed_mps=3)
|
||||
targets = fusion.update([self.obs(1, 1, 10, 10, 100), self.obs(2, 4, 10.4, 10.1, 100.1)], 100.1)
|
||||
self.assertEqual(len(targets), 1)
|
||||
self.assertEqual(targets[0]["source_camera_ids"], [1, 2])
|
||||
|
||||
def test_same_camera_people_never_merge(self):
|
||||
fusion = GlobalFusionTracker(radius_m=2, time_window_sec=1, max_speed_mps=3)
|
||||
targets = fusion.update([self.obs(1, 1, 10, 10, 100), self.obs(1, 2, 10.2, 10.1, 100)], 100)
|
||||
self.assertEqual(len(targets), 2)
|
||||
|
||||
def test_unreachable_jump_creates_new_global_id(self):
|
||||
fusion = GlobalFusionTracker(radius_m=1, time_window_sec=1, max_speed_mps=2)
|
||||
first = fusion.update([self.obs(1, 1, 1, 1, 100)], 100)[0]["global_id"]
|
||||
targets = fusion.update([self.obs(2, 9, 20, 20, 101)], 101)
|
||||
self.assertEqual(len(targets), 2)
|
||||
self.assertNotEqual(first, min(targets, key=lambda t: abs(t["x"] - 20))["global_id"])
|
||||
|
||||
def test_reid_rejects_conflicting_appearance(self):
|
||||
fusion = GlobalFusionTracker(radius_m=2, time_window_sec=1)
|
||||
targets = fusion.update([
|
||||
self.obs(1, 1, 10, 10, 100, [1, 0]),
|
||||
self.obs(2, 2, 10.1, 10.1, 100, [-1, 0]),
|
||||
], 100)
|
||||
self.assertEqual(len(targets), 2)
|
||||
|
||||
def test_exact_assignment(self):
|
||||
pairs = minimum_cost_pairs([[1, 2], [1.1, 100]], 10)
|
||||
self.assertEqual({(r, c) for r, c, _ in pairs}, {(0, 1), (1, 0)})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
31
workshop_monitor/README.md
Normal file
31
workshop_monitor/README.md
Normal file
@ -0,0 +1,31 @@
|
||||
# 车间监控现场标定指南
|
||||
|
||||
## 坐标约定
|
||||
|
||||
- 厂房平面尺寸为 130 米 × 50 米,左上角为 `(0, 0)`。
|
||||
- X 轴向右,范围 `0–130`;Y 轴向下,范围 `0–50`;地面为 `Z=0`。
|
||||
- 四台摄像头必须使用同一套世界坐标。摄像头安装位置 XYZ、朝向或焦距发生变化后,应重新标定该摄像头。
|
||||
|
||||
## 现场准备
|
||||
|
||||
1. 在现场选择清晰、固定且位于地面的特征点,例如地砖角、立柱脚、设备底座角或临时贴在地面的标记。
|
||||
2. 使用卷尺或激光测距仪测量每个点相对于厂房左上角原点的 X/Y 坐标。
|
||||
3. 建议准备 8–12 个用于拟合的点和至少 3 个独立验证点。控制点应覆盖画面的近、中、远区域及左右两侧,不能集中在一条直线上。
|
||||
4. 单独测量每台摄像头镜头中心的 X/Y/Z 和大致朝向。位置仅用于平面图展示和辅助诊断,不替代该相机自身的像素到地面标定。
|
||||
|
||||
## 系统操作
|
||||
|
||||
1. 进入“车间监控 → 车间配置”,确认四个视频流分别绑定到 C1–C4,并录入摄像头安装位置。
|
||||
2. 打开“控制点与标定”,先建立厂房控制点名称及测得的 X/Y 坐标。公共地面点只需建立一次,可供不同相机重复使用。
|
||||
3. 选择一台摄像头并抓取当前画面,在画面中依次点击特征点,再为每个像素点选择对应的厂房控制点。
|
||||
4. 将至少 4 个点设为“拟合”,至少 3 个未参与拟合的点设为“验证”。建议实际使用 8–12 个分布均匀的拟合点。
|
||||
5. 点击“计算并激活”。验证平均误差不超过 1 米才会激活;不合格结果会保留为草稿,可调整点位后重新计算。
|
||||
6. 对 C1、C2、C3、C4 分别重复第 3–5 步。每台相机看到的控制点可以不同,但所有点的世界坐标必须来自同一厂房坐标系。
|
||||
|
||||
## 验收建议
|
||||
|
||||
- 先在验证点位置站人,核对页面显示坐标与实测坐标的误差。
|
||||
- 再进行单摄移动、重叠区移动和跨视野移动,观察全局 ID 是否保持一致。
|
||||
- 两人交叉测试时,如未配置 ReID 模型,重点检查页面的低融合置信度提示。
|
||||
- 单路断流后确认其余三路、平面图和目标列表仍持续更新。
|
||||
|
||||
1
workshop_monitor/__init__.py
Normal file
1
workshop_monitor/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""车间多摄像头定位模块。"""
|
||||
7
workshop_monitor/apps.py
Normal file
7
workshop_monitor/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WorkshopMonitorConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "workshop_monitor"
|
||||
verbose_name = "车间监控"
|
||||
126
workshop_monitor/calibration.py
Normal file
126
workshop_monitor/calibration.py
Normal file
@ -0,0 +1,126 @@
|
||||
"""平面控制点标定和像素/世界坐标转换。"""
|
||||
import math
|
||||
|
||||
|
||||
class CalibrationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _cv():
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
return cv2, np
|
||||
except Exception as exc:
|
||||
raise CalibrationError("OpenCV/Numpy 不可用,无法执行标定") from exc
|
||||
|
||||
|
||||
def map_point(homography, u, v):
|
||||
"""使用 3x3 Homography 将像素点映射到世界平面。"""
|
||||
_cv2, np = _cv()
|
||||
matrix = np.asarray(homography, dtype=np.float64)
|
||||
if matrix.shape != (3, 3) or not np.isfinite(matrix).all():
|
||||
raise CalibrationError("Homography 格式无效")
|
||||
p = matrix.dot(np.asarray([float(u), float(v), 1.0], dtype=np.float64))
|
||||
if abs(float(p[2])) < 1e-10:
|
||||
raise CalibrationError("像素点无法投影到地面")
|
||||
return float(p[0] / p[2]), float(p[1] / p[2])
|
||||
|
||||
|
||||
def _area_ratio(points, frame_width, frame_height):
|
||||
cv2, np = _cv()
|
||||
hull = cv2.convexHull(np.asarray(points, dtype=np.float32))
|
||||
denom = max(1.0, float(frame_width) * float(frame_height))
|
||||
return float(cv2.contourArea(hull)) / denom
|
||||
|
||||
|
||||
def solve_planar_calibration(observations, frame_width, frame_height,
|
||||
max_validation_mean_m=1.0):
|
||||
"""根据拟合点求 H,并使用完全独立的验证点评价世界坐标误差。"""
|
||||
cv2, np = _cv()
|
||||
try:
|
||||
fw, fh = int(frame_width), int(frame_height)
|
||||
except Exception as exc:
|
||||
raise CalibrationError("图像尺寸无效") from exc
|
||||
if fw <= 0 or fh <= 0:
|
||||
raise CalibrationError("图像尺寸必须大于 0")
|
||||
|
||||
fit = [p for p in observations if p.get("role", "fit") == "fit"]
|
||||
verify = [p for p in observations if p.get("role") == "verify"]
|
||||
if len(fit) < 4:
|
||||
raise CalibrationError("至少需要 4 个拟合点")
|
||||
if len(verify) < 3:
|
||||
raise CalibrationError("至少需要 3 个独立验证点")
|
||||
|
||||
def pixels(rows):
|
||||
return np.asarray([[float(p["u"]), float(p["v"])] for p in rows], dtype=np.float64)
|
||||
|
||||
def worlds(rows):
|
||||
return np.asarray([[float(p["x"]), float(p["y"])] for p in rows], dtype=np.float64)
|
||||
|
||||
src, dst = pixels(fit), worlds(fit)
|
||||
if not np.isfinite(src).all() or not np.isfinite(dst).all():
|
||||
raise CalibrationError("控制点包含非有限数值")
|
||||
if _area_ratio(src, fw, fh) < 0.01:
|
||||
raise CalibrationError("拟合点共线或过度集中,请扩大点位分布")
|
||||
if float(cv2.contourArea(cv2.convexHull(dst.astype(np.float32)))) < 0.01:
|
||||
raise CalibrationError("世界坐标点共线或过度集中")
|
||||
|
||||
matrix, mask = cv2.findHomography(src, dst, cv2.RANSAC, 0.75)
|
||||
if matrix is None or not np.isfinite(matrix).all() or abs(float(np.linalg.det(matrix))) < 1e-12:
|
||||
raise CalibrationError("无法计算稳定的 Homography")
|
||||
|
||||
fit_projection = cv2.perspectiveTransform(src.reshape(-1, 1, 2), matrix).reshape(-1, 2)
|
||||
fit_errors = np.linalg.norm(fit_projection - dst, axis=1)
|
||||
verify_src, verify_dst = pixels(verify), worlds(verify)
|
||||
verify_projection = cv2.perspectiveTransform(verify_src.reshape(-1, 1, 2), matrix).reshape(-1, 2)
|
||||
verify_errors = np.linalg.norm(verify_projection - verify_dst, axis=1)
|
||||
|
||||
fit_rmse = math.sqrt(float(np.mean(np.square(fit_errors))))
|
||||
validation_mean = float(np.mean(verify_errors))
|
||||
validation_max = float(np.max(verify_errors))
|
||||
warnings = []
|
||||
if len(fit) < 8:
|
||||
warnings.append("拟合点少于建议的 8 个")
|
||||
coverage = _area_ratio(src, fw, fh)
|
||||
if coverage < 0.15:
|
||||
warnings.append("拟合点仅覆盖画面 %.1f%%,建议增加边缘和远端点" % (coverage * 100.0))
|
||||
inliers = int(mask.sum()) if mask is not None else len(fit)
|
||||
if inliers < len(fit):
|
||||
warnings.append("RANSAC 排除了 %d 个异常拟合点" % (len(fit) - inliers))
|
||||
|
||||
detail = []
|
||||
fit_index = verify_index = 0
|
||||
for p in observations:
|
||||
row = dict(p)
|
||||
if p.get("role", "fit") == "fit":
|
||||
row["error_m"] = float(fit_errors[fit_index])
|
||||
fit_index += 1
|
||||
else:
|
||||
row["error_m"] = float(verify_errors[verify_index])
|
||||
verify_index += 1
|
||||
detail.append(row)
|
||||
|
||||
return {
|
||||
"homography": matrix.tolist(),
|
||||
"fit_rmse_m": fit_rmse,
|
||||
"validation_mean_m": validation_mean,
|
||||
"validation_max_m": validation_max,
|
||||
"coverage_ratio": coverage,
|
||||
"inlier_count": inliers,
|
||||
"is_valid": validation_mean <= float(max_validation_mean_m),
|
||||
"warnings": warnings,
|
||||
"observations": detail,
|
||||
}
|
||||
|
||||
|
||||
def foot_point_world(box, homography, width_m, height_m, boundary_margin_m=1.0):
|
||||
if not box or len(box) != 4:
|
||||
return None
|
||||
u = (float(box[0]) + float(box[2])) * 0.5
|
||||
v = float(box[3])
|
||||
x, y = map_point(homography, u, v)
|
||||
margin = max(0.0, float(boundary_margin_m))
|
||||
if x < -margin or y < -margin or x > float(width_m) + margin or y > float(height_m) + margin:
|
||||
return None
|
||||
return max(0.0, min(float(width_m), x)), max(0.0, min(float(height_m), y))
|
||||
188
workshop_monitor/fusion.py
Normal file
188
workshop_monitor/fusion.py
Normal file
@ -0,0 +1,188 @@
|
||||
"""世界坐标上的轻量跨摄像头全局轨迹融合。"""
|
||||
from functools import lru_cache
|
||||
import math
|
||||
import time
|
||||
|
||||
|
||||
def _distance(a, b):
|
||||
return math.hypot(float(a[0]) - float(b[0]), float(a[1]) - float(b[1]))
|
||||
|
||||
|
||||
def _embedding_similarity(a, b):
|
||||
if a is None or b is None:
|
||||
return None
|
||||
try:
|
||||
import numpy as np
|
||||
av, bv = np.asarray(a, dtype=float), np.asarray(b, dtype=float)
|
||||
denom = float(np.linalg.norm(av) * np.linalg.norm(bv))
|
||||
return float(np.dot(av, bv) / denom) if denom > 1e-12 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def minimum_cost_pairs(costs, max_cost):
|
||||
"""小规模精确最小代价分配;目标较多时退化为确定性的全局贪心。"""
|
||||
rows = len(costs)
|
||||
cols = len(costs[0]) if rows else 0
|
||||
if not rows or not cols:
|
||||
return []
|
||||
if rows > 12 or cols > 12:
|
||||
candidates = sorted((float(costs[r][c]), r, c) for r in range(rows) for c in range(cols)
|
||||
if float(costs[r][c]) <= max_cost)
|
||||
used_r, used_c, result = set(), set(), []
|
||||
for cost, r, c in candidates:
|
||||
if r not in used_r and c not in used_c:
|
||||
used_r.add(r); used_c.add(c); result.append((r, c, cost))
|
||||
return result
|
||||
|
||||
unmatched = float(max_cost) + 0.001
|
||||
|
||||
@lru_cache(None)
|
||||
def solve(row, used_mask):
|
||||
if row >= rows:
|
||||
return 0.0, ()
|
||||
best_cost, best_pairs = solve(row + 1, used_mask)
|
||||
best_cost += unmatched
|
||||
for col in range(cols):
|
||||
cost = float(costs[row][col])
|
||||
if used_mask & (1 << col) or cost > max_cost:
|
||||
continue
|
||||
tail_cost, tail_pairs = solve(row + 1, used_mask | (1 << col))
|
||||
total = cost + tail_cost
|
||||
if total < best_cost:
|
||||
best_cost = total
|
||||
best_pairs = ((row, col, cost),) + tail_pairs
|
||||
return best_cost, best_pairs
|
||||
|
||||
return list(solve(0, 0)[1])
|
||||
|
||||
|
||||
def cluster_observations(observations, radius_m, time_window_sec):
|
||||
clusters = []
|
||||
ordered = sorted(observations or [], key=lambda x: (-float(x.get("score", 0)), int(x.get("camera_id", 0))))
|
||||
for obs in ordered:
|
||||
best = None
|
||||
for cluster in clusters:
|
||||
if any(int(x["camera_id"]) == int(obs["camera_id"]) for x in cluster):
|
||||
continue
|
||||
if max(abs(float(x["timestamp"]) - float(obs["timestamp"])) for x in cluster) > time_window_sec:
|
||||
continue
|
||||
cx = sum(float(x["x"]) for x in cluster) / len(cluster)
|
||||
cy = sum(float(x["y"]) for x in cluster) / len(cluster)
|
||||
dist = _distance((cx, cy), (obs["x"], obs["y"]))
|
||||
if dist > radius_m:
|
||||
continue
|
||||
similarities = [_embedding_similarity(x.get("embedding"), obs.get("embedding")) for x in cluster]
|
||||
known = [x for x in similarities if x is not None]
|
||||
if known and max(known) < 0.45:
|
||||
continue
|
||||
if best is None or dist < best[0]:
|
||||
best = (dist, cluster)
|
||||
if best:
|
||||
best[1].append(obs)
|
||||
else:
|
||||
clusters.append([obs])
|
||||
return clusters
|
||||
|
||||
|
||||
class GlobalFusionTracker:
|
||||
def __init__(self, radius_m=1.5, time_window_sec=1.0, max_speed_mps=3.0,
|
||||
lost_ttl_sec=30.0, trail_sec=30.0):
|
||||
self.radius_m = float(radius_m)
|
||||
self.time_window_sec = float(time_window_sec)
|
||||
self.max_speed_mps = float(max_speed_mps)
|
||||
self.lost_ttl_sec = float(lost_ttl_sec)
|
||||
self.trail_sec = float(trail_sec)
|
||||
self._tracks = {}
|
||||
self._next_id = 1
|
||||
|
||||
@staticmethod
|
||||
def _aggregate(cluster):
|
||||
weights = []
|
||||
for obs in cluster:
|
||||
calibration_error = max(0.25, float(obs.get("calibration_error_m") or 1.0))
|
||||
weights.append(max(0.05, float(obs.get("score", 0.5))) / (calibration_error ** 2))
|
||||
total = sum(weights) or 1.0
|
||||
x = sum(float(o["x"]) * w for o, w in zip(cluster, weights)) / total
|
||||
y = sum(float(o["y"]) * w for o, w in zip(cluster, weights)) / total
|
||||
latest = max(float(o["timestamp"]) for o in cluster)
|
||||
embeddings = [o.get("embedding") for o in cluster if o.get("embedding") is not None]
|
||||
embedding = None
|
||||
if embeddings:
|
||||
try:
|
||||
import numpy as np
|
||||
embedding = np.mean(np.asarray(embeddings, dtype=float), axis=0)
|
||||
norm = np.linalg.norm(embedding)
|
||||
if norm > 1e-12:
|
||||
embedding = embedding / norm
|
||||
except Exception:
|
||||
embedding = None
|
||||
return {
|
||||
"class": cluster[0].get("class", "person"), "x": x, "y": y,
|
||||
"timestamp": latest, "confidence": sum(float(o.get("score", 0)) for o in cluster) / len(cluster),
|
||||
"source_camera_ids": sorted({int(o["camera_id"]) for o in cluster}),
|
||||
"observations": cluster, "embedding": embedding,
|
||||
}
|
||||
|
||||
def update(self, observations, now=None):
|
||||
now = float(now if now is not None else time.time())
|
||||
fresh = [o for o in observations or [] if now - float(o.get("timestamp", 0)) <= self.time_window_sec]
|
||||
candidates = [self._aggregate(c) for c in cluster_observations(
|
||||
fresh, self.radius_m, self.time_window_sec)]
|
||||
existing = list(self._tracks.values())
|
||||
costs = []
|
||||
for candidate in candidates:
|
||||
row = []
|
||||
for track in existing:
|
||||
dt = max(0.0, candidate["timestamp"] - track["timestamp"])
|
||||
predicted = (track["x"] + track["vx"] * dt, track["y"] + track["vy"] * dt)
|
||||
dist = _distance(predicted, (candidate["x"], candidate["y"]))
|
||||
reachable = self.radius_m + self.max_speed_mps * dt
|
||||
if candidate["class"] != track["class"] or dt < -self.time_window_sec or dist > reachable:
|
||||
row.append(1e9); continue
|
||||
sim = _embedding_similarity(candidate.get("embedding"), track.get("embedding"))
|
||||
if sim is not None and sim < 0.35:
|
||||
row.append(1e9); continue
|
||||
row.append(dist + (0.0 if sim is None else (1.0 - sim) * self.radius_m * 0.5))
|
||||
costs.append(row)
|
||||
matched_candidates = set()
|
||||
for ci, ti, _cost in minimum_cost_pairs(costs, self.radius_m * 2.0):
|
||||
candidate, track = candidates[ci], existing[ti]
|
||||
dt = max(0.05, candidate["timestamp"] - track["timestamp"])
|
||||
vx = (candidate["x"] - track["x"]) / dt
|
||||
vy = (candidate["y"] - track["y"]) / dt
|
||||
track["vx"] = track["vx"] * 0.5 + vx * 0.5
|
||||
track["vy"] = track["vy"] * 0.5 + vy * 0.5
|
||||
track.update({k: candidate[k] for k in ("x", "y", "timestamp", "confidence", "source_camera_ids", "observations")})
|
||||
if candidate.get("embedding") is not None:
|
||||
track["embedding"] = candidate["embedding"]
|
||||
track["trail"].append([candidate["timestamp"], candidate["x"], candidate["y"]])
|
||||
matched_candidates.add(ci)
|
||||
for ci, candidate in enumerate(candidates):
|
||||
if ci in matched_candidates:
|
||||
continue
|
||||
gid = "G%06d" % self._next_id
|
||||
self._next_id += 1
|
||||
self._tracks[gid] = {
|
||||
"global_id": gid, **candidate, "vx": 0.0, "vy": 0.0,
|
||||
"trail": [[candidate["timestamp"], candidate["x"], candidate["y"]]],
|
||||
}
|
||||
for gid, track in list(self._tracks.items()):
|
||||
cutoff = now - self.trail_sec
|
||||
track["trail"] = [p for p in track["trail"] if p[0] >= cutoff]
|
||||
if now - track["timestamp"] > self.lost_ttl_sec:
|
||||
del self._tracks[gid]
|
||||
return self.snapshot(now)
|
||||
|
||||
def snapshot(self, now=None):
|
||||
now = float(now if now is not None else time.time())
|
||||
result = []
|
||||
for track in self._tracks.values():
|
||||
age = max(0.0, now - float(track["timestamp"]))
|
||||
item = {k: track[k] for k in ("global_id", "class", "x", "y", "confidence", "timestamp", "source_camera_ids")}
|
||||
item["state"] = "active" if age <= self.time_window_sec else "lost"
|
||||
item["fusion_confidence"] = "high" if len(track["source_camera_ids"]) > 1 else ("medium" if age <= self.time_window_sec else "low")
|
||||
item["trail"] = [[round(p[1], 3), round(p[2], 3)] for p in track["trail"]]
|
||||
item["age_sec"] = age
|
||||
result.append(item)
|
||||
return sorted(result, key=lambda x: x["global_id"])
|
||||
101
workshop_monitor/migrations/0001_initial.py
Normal file
101
workshop_monitor/migrations/0001_initial.py
Normal file
@ -0,0 +1,101 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
dependencies = [("app", "0002_zonemodel_alarm_repeat_sec")]
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="WorkshopSite",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("name", models.CharField(default="主车间", max_length=100)),
|
||||
("width_m", models.FloatField(default=130.0)),
|
||||
("height_m", models.FloatField(default=50.0)),
|
||||
("target_labels", models.JSONField(default=list)),
|
||||
("analysis_fps", models.FloatField(default=2.0)),
|
||||
("fusion_radius_m", models.FloatField(default=1.5)),
|
||||
("observation_window_sec", models.FloatField(default=1.0)),
|
||||
("max_speed_mps", models.FloatField(default=3.0)),
|
||||
("lost_ttl_sec", models.FloatField(default=30.0)),
|
||||
("trail_sec", models.FloatField(default=30.0)),
|
||||
("create_time", models.DateTimeField(auto_now_add=True)),
|
||||
("last_update_time", models.DateTimeField(auto_now=True)),
|
||||
("detector", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="workshop_detector_sites", to="app.algorithmmodel")),
|
||||
("reid_model", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="workshop_reid_sites", to="app.algorithmmodel")),
|
||||
],
|
||||
options={"db_table": "wm_site"},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WorkshopCamera",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("slot", models.PositiveIntegerField(default=1)),
|
||||
("display_name", models.CharField(default="", max_length=100)),
|
||||
("enabled", models.BooleanField(default=True)),
|
||||
("install_x", models.FloatField(default=0.0)),
|
||||
("install_y", models.FloatField(default=0.0)),
|
||||
("install_z", models.FloatField(default=0.0)),
|
||||
("yaw_deg", models.FloatField(default=0.0)),
|
||||
("pitch_deg", models.FloatField(default=0.0)),
|
||||
("create_time", models.DateTimeField(auto_now_add=True)),
|
||||
("last_update_time", models.DateTimeField(auto_now=True)),
|
||||
("site", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="cameras", to="workshop_monitor.workshopsite")),
|
||||
("stream", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="workshop_bindings", to="app.streammodel")),
|
||||
],
|
||||
options={"db_table": "wm_camera", "ordering": ("slot", "id")},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="GroundControlPoint",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("name", models.CharField(max_length=100)),
|
||||
("x", models.FloatField()),
|
||||
("y", models.FloatField()),
|
||||
("description", models.CharField(blank=True, default="", max_length=300)),
|
||||
("create_time", models.DateTimeField(auto_now_add=True)),
|
||||
("last_update_time", models.DateTimeField(auto_now=True)),
|
||||
("site", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="control_points", to="workshop_monitor.workshopsite")),
|
||||
],
|
||||
options={"db_table": "wm_ground_control_point", "ordering": ("name", "id")},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="CameraCalibration",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("snapshot_path", models.CharField(blank=True, default="", max_length=500)),
|
||||
("frame_width", models.PositiveIntegerField(default=0)),
|
||||
("frame_height", models.PositiveIntegerField(default=0)),
|
||||
("homography", models.JSONField(default=list)),
|
||||
("fit_rmse_m", models.FloatField(blank=True, null=True)),
|
||||
("validation_mean_m", models.FloatField(blank=True, null=True)),
|
||||
("validation_max_m", models.FloatField(blank=True, null=True)),
|
||||
("status", models.CharField(choices=[("draft", "草稿"), ("valid", "合格"), ("invalid", "不合格")], default="draft", max_length=16)),
|
||||
("is_active", models.BooleanField(default=False)),
|
||||
("warnings", models.JSONField(default=list)),
|
||||
("created_by", models.IntegerField(default=0)),
|
||||
("create_time", models.DateTimeField(auto_now_add=True)),
|
||||
("last_update_time", models.DateTimeField(auto_now=True)),
|
||||
("camera", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="calibrations", to="workshop_monitor.workshopcamera")),
|
||||
],
|
||||
options={"db_table": "wm_camera_calibration", "ordering": ("-id",)},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="CalibrationObservation",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("pixel_u", models.FloatField()),
|
||||
("pixel_v", models.FloatField()),
|
||||
("world_x", models.FloatField()),
|
||||
("world_y", models.FloatField()),
|
||||
("role", models.CharField(choices=[("fit", "拟合"), ("verify", "验证")], default="fit", max_length=10)),
|
||||
("error_m", models.FloatField(blank=True, null=True)),
|
||||
("calibration", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="observations", to="workshop_monitor.cameracalibration")),
|
||||
("control_point", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="camera_observations", to="workshop_monitor.groundcontrolpoint")),
|
||||
],
|
||||
options={"db_table": "wm_calibration_observation", "ordering": ("id",)},
|
||||
),
|
||||
migrations.AddConstraint(model_name="workshopcamera", constraint=models.UniqueConstraint(fields=("site", "slot"), name="wm_camera_site_slot_uniq")),
|
||||
migrations.AddConstraint(model_name="groundcontrolpoint", constraint=models.UniqueConstraint(fields=("site", "name"), name="wm_gcp_site_name_uniq")),
|
||||
]
|
||||
1
workshop_monitor/migrations/__init__.py
Normal file
1
workshop_monitor/migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
134
workshop_monitor/models.py
Normal file
134
workshop_monitor/models.py
Normal file
@ -0,0 +1,134 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class WorkshopSite(models.Model):
|
||||
name = models.CharField(max_length=100, default="主车间")
|
||||
width_m = models.FloatField(default=130.0)
|
||||
height_m = models.FloatField(default=50.0)
|
||||
detector = models.ForeignKey(
|
||||
"app.AlgorithmModel", null=True, blank=True, on_delete=models.SET_NULL,
|
||||
related_name="workshop_detector_sites",
|
||||
)
|
||||
reid_model = models.ForeignKey(
|
||||
"app.AlgorithmModel", null=True, blank=True, on_delete=models.SET_NULL,
|
||||
related_name="workshop_reid_sites",
|
||||
)
|
||||
target_labels = models.JSONField(default=list)
|
||||
analysis_fps = models.FloatField(default=2.0)
|
||||
fusion_radius_m = models.FloatField(default=1.5)
|
||||
observation_window_sec = models.FloatField(default=1.0)
|
||||
max_speed_mps = models.FloatField(default=3.0)
|
||||
lost_ttl_sec = models.FloatField(default=30.0)
|
||||
trail_sec = models.FloatField(default=30.0)
|
||||
create_time = models.DateTimeField(auto_now_add=True)
|
||||
last_update_time = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "wm_site"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class WorkshopCamera(models.Model):
|
||||
site = models.ForeignKey(WorkshopSite, on_delete=models.CASCADE, related_name="cameras")
|
||||
stream = models.ForeignKey(
|
||||
"app.StreamModel", null=True, blank=True, on_delete=models.SET_NULL,
|
||||
related_name="workshop_bindings",
|
||||
)
|
||||
slot = models.PositiveIntegerField(default=1)
|
||||
display_name = models.CharField(max_length=100, default="")
|
||||
enabled = models.BooleanField(default=True)
|
||||
install_x = models.FloatField(default=0.0)
|
||||
install_y = models.FloatField(default=0.0)
|
||||
install_z = models.FloatField(default=0.0)
|
||||
yaw_deg = models.FloatField(default=0.0)
|
||||
pitch_deg = models.FloatField(default=0.0)
|
||||
create_time = models.DateTimeField(auto_now_add=True)
|
||||
last_update_time = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "wm_camera"
|
||||
ordering = ("slot", "id")
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=("site", "slot"), name="wm_camera_site_slot_uniq"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.display_name or (self.stream.nickname if self.stream else "摄像头 %s" % self.slot)
|
||||
|
||||
@property
|
||||
def active_calibration(self):
|
||||
return self.calibrations.filter(is_active=True).order_by("-id").first()
|
||||
|
||||
|
||||
class GroundControlPoint(models.Model):
|
||||
site = models.ForeignKey(WorkshopSite, on_delete=models.CASCADE, related_name="control_points")
|
||||
name = models.CharField(max_length=100)
|
||||
x = models.FloatField()
|
||||
y = models.FloatField()
|
||||
description = models.CharField(max_length=300, default="", blank=True)
|
||||
create_time = models.DateTimeField(auto_now_add=True)
|
||||
last_update_time = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "wm_ground_control_point"
|
||||
ordering = ("name", "id")
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=("site", "name"), name="wm_gcp_site_name_uniq"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return "%s (%.2f, %.2f)" % (self.name, self.x, self.y)
|
||||
|
||||
|
||||
class CameraCalibration(models.Model):
|
||||
STATUS_DRAFT = "draft"
|
||||
STATUS_VALID = "valid"
|
||||
STATUS_INVALID = "invalid"
|
||||
STATUS_CHOICES = (
|
||||
(STATUS_DRAFT, "草稿"),
|
||||
(STATUS_VALID, "合格"),
|
||||
(STATUS_INVALID, "不合格"),
|
||||
)
|
||||
|
||||
camera = models.ForeignKey(WorkshopCamera, on_delete=models.CASCADE, related_name="calibrations")
|
||||
snapshot_path = models.CharField(max_length=500, default="", blank=True)
|
||||
frame_width = models.PositiveIntegerField(default=0)
|
||||
frame_height = models.PositiveIntegerField(default=0)
|
||||
homography = models.JSONField(default=list)
|
||||
fit_rmse_m = models.FloatField(null=True, blank=True)
|
||||
validation_mean_m = models.FloatField(null=True, blank=True)
|
||||
validation_max_m = models.FloatField(null=True, blank=True)
|
||||
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT)
|
||||
is_active = models.BooleanField(default=False)
|
||||
warnings = models.JSONField(default=list)
|
||||
created_by = models.IntegerField(default=0)
|
||||
create_time = models.DateTimeField(auto_now_add=True)
|
||||
last_update_time = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "wm_camera_calibration"
|
||||
ordering = ("-id",)
|
||||
|
||||
|
||||
class CalibrationObservation(models.Model):
|
||||
ROLE_FIT = "fit"
|
||||
ROLE_VERIFY = "verify"
|
||||
ROLE_CHOICES = ((ROLE_FIT, "拟合"), (ROLE_VERIFY, "验证"))
|
||||
|
||||
calibration = models.ForeignKey(CameraCalibration, on_delete=models.CASCADE, related_name="observations")
|
||||
control_point = models.ForeignKey(
|
||||
GroundControlPoint, null=True, blank=True, on_delete=models.SET_NULL,
|
||||
related_name="camera_observations",
|
||||
)
|
||||
pixel_u = models.FloatField()
|
||||
pixel_v = models.FloatField()
|
||||
world_x = models.FloatField()
|
||||
world_y = models.FloatField()
|
||||
role = models.CharField(max_length=10, choices=ROLE_CHOICES, default=ROLE_FIT)
|
||||
error_m = models.FloatField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "wm_calibration_observation"
|
||||
ordering = ("id",)
|
||||
263
workshop_monitor/runtime.py
Normal file
263
workshop_monitor/runtime.py
Normal file
@ -0,0 +1,263 @@
|
||||
"""独立于布控分析流水线的车间实时定位进程。"""
|
||||
import multiprocessing as mp
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
|
||||
from .calibration import foot_point_world
|
||||
from .fusion import GlobalFusionTracker
|
||||
|
||||
|
||||
class _CameraReader(threading.Thread):
|
||||
def __init__(self, config, target_fps):
|
||||
super().__init__(name="workshop-camera-%s" % config["camera_id"], daemon=True)
|
||||
self.config = config
|
||||
self.target_fps = target_fps
|
||||
self._lock = threading.Lock()
|
||||
self._running = True
|
||||
self._frame = None
|
||||
self._timestamp = 0.0
|
||||
self._sequence = 0
|
||||
self._health = {"stream_health": "connecting", "stalled_sec": 0.0}
|
||||
|
||||
def run(self):
|
||||
from app.analysis.frames import FrameSource
|
||||
source = FrameSource(self.config["rtsp_url"], target_fps=max(2, int(self.target_fps * 2)))
|
||||
try:
|
||||
while self._running:
|
||||
ok, frame = source.read()
|
||||
with self._lock:
|
||||
self._health = source.health_snapshot()
|
||||
if ok and frame is not None:
|
||||
self._frame = frame
|
||||
self._timestamp = time.time()
|
||||
self._sequence += 1
|
||||
if not ok:
|
||||
time.sleep(0.1)
|
||||
finally:
|
||||
source.close()
|
||||
|
||||
def latest(self):
|
||||
with self._lock:
|
||||
return self._sequence, self._timestamp, self._frame, dict(self._health)
|
||||
|
||||
def close(self):
|
||||
self._running = False
|
||||
|
||||
|
||||
def _build_engine(spec):
|
||||
from app.analysis.engines.factory import EngineFactory
|
||||
engine = EngineFactory.create(
|
||||
spec["inference_engine"], model_file=spec["model_file"], labels=spec.get("labels") or [],
|
||||
input_size=tuple(spec.get("input_size") or (640, 640)),
|
||||
conf_threshold=float(spec.get("conf_threshold", 0.4)),
|
||||
iou_threshold=float(spec.get("iou_threshold", 0.5)),
|
||||
algorithm_type=spec.get("algorithm_type", "yolo"),
|
||||
task_type=spec.get("task_type", "detect"), device=spec.get("device", "cpu"),
|
||||
target_labels=spec.get("target_labels") or [],
|
||||
)
|
||||
if not engine.load():
|
||||
raise RuntimeError("模型加载失败: %s" % spec.get("name", spec.get("model_file", "")))
|
||||
return engine
|
||||
|
||||
|
||||
def workshop_worker_main(config, state_queue, command_queue):
|
||||
readers = []
|
||||
try:
|
||||
detector = _build_engine(config["detector"])
|
||||
reid = _build_engine(config["reid_model"]) if config.get("reid_model") else None
|
||||
tracker_cls = __import__("app.analysis.tracker", fromlist=["IoUTracker"]).IoUTracker
|
||||
trackers = {int(c["camera_id"]): tracker_cls() for c in config["cameras"]}
|
||||
readers = [_CameraReader(c, config["analysis_fps"]) for c in config["cameras"]]
|
||||
for reader in readers:
|
||||
reader.start()
|
||||
fusion = GlobalFusionTracker(
|
||||
config["fusion_radius_m"], config["observation_window_sec"],
|
||||
config["max_speed_mps"], config["lost_ttl_sec"], config["trail_sec"],
|
||||
)
|
||||
last_seq = {int(c["camera_id"]): 0 for c in config["cameras"]}
|
||||
last_process = {int(c["camera_id"]): 0.0 for c in config["cameras"]}
|
||||
latest_observations = {int(c["camera_id"]): [] for c in config["cameras"]}
|
||||
frame_index = {int(c["camera_id"]): 0 for c in config["cameras"]}
|
||||
state_sequence = 0
|
||||
period = 1.0 / max(0.1, float(config["analysis_fps"]))
|
||||
running = True
|
||||
while running:
|
||||
try:
|
||||
while True:
|
||||
command = command_queue.get_nowait()
|
||||
if command.get("cmd") == "stop":
|
||||
running = False
|
||||
except queue.Empty:
|
||||
pass
|
||||
if not running:
|
||||
break
|
||||
changed = False
|
||||
camera_states = []
|
||||
now = time.time()
|
||||
for camera, reader in zip(config["cameras"], readers):
|
||||
cid = int(camera["camera_id"])
|
||||
sequence, captured_at, frame, health = reader.latest()
|
||||
if frame is not None and sequence != last_seq[cid] and now - last_process[cid] >= period:
|
||||
last_seq[cid] = sequence
|
||||
last_process[cid] = now
|
||||
frame_index[cid] += 1
|
||||
try:
|
||||
detections = [d for d in detector.detect(frame)
|
||||
if d.get("label") in config["target_labels"]]
|
||||
active, _ended, _new, _idx = trackers[cid].update(
|
||||
detections, frame_index[cid], timestamp=captured_at)
|
||||
confirmed = [t for t in active if t.get("confirmed") and t.get("observed")]
|
||||
embeddings = {}
|
||||
if reid and confirmed:
|
||||
valid, values = reid.extract_embeddings(frame, [t["box"] for t in confirmed])
|
||||
for output_index, track_index in enumerate(valid):
|
||||
embeddings[confirmed[track_index]["track_id"]] = values[output_index]
|
||||
h, w = frame.shape[:2]
|
||||
observations = []
|
||||
calibration = camera["calibration"]
|
||||
for track in confirmed:
|
||||
world = foot_point_world(track["box"], calibration["homography"],
|
||||
config["width_m"], config["height_m"])
|
||||
if world is None:
|
||||
continue
|
||||
box = [float(track["box"][0]) / w, float(track["box"][1]) / h,
|
||||
float(track["box"][2]) / w, float(track["box"][3]) / h]
|
||||
observations.append({
|
||||
"camera_id": cid, "stream_id": camera["stream_id"],
|
||||
"local_track_id": int(track["track_id"]), "class": track.get("label", "person"),
|
||||
"score": float(track.get("score", 0)), "bbox": box,
|
||||
"x": world[0], "y": world[1], "timestamp": captured_at,
|
||||
"calibration_error_m": calibration.get("validation_mean_m") or 1.0,
|
||||
"embedding": embeddings.get(track["track_id"]),
|
||||
})
|
||||
latest_observations[cid] = observations
|
||||
health["analysis_health"] = "running"
|
||||
health["analysis_error"] = ""
|
||||
changed = True
|
||||
except Exception as exc:
|
||||
health["analysis_health"] = "error"
|
||||
health["analysis_error"] = str(exc)
|
||||
fresh = [o for o in latest_observations[cid]
|
||||
if now - float(o.get("timestamp", 0)) <= config["observation_window_sec"]]
|
||||
camera_states.append({
|
||||
"camera_id": cid, "stream_id": camera["stream_id"], "slot": camera["slot"],
|
||||
"display_name": camera["display_name"], **health, "observations": fresh,
|
||||
})
|
||||
if changed or state_sequence == 0:
|
||||
all_observations = [o for rows in latest_observations.values() for o in rows]
|
||||
targets = fusion.update(all_observations, now)
|
||||
for obs in all_observations:
|
||||
choices = [t for t in targets if t["class"] == obs["class"] and
|
||||
obs["camera_id"] in t["source_camera_ids"]]
|
||||
if choices:
|
||||
obs["global_id"] = min(choices, key=lambda t: (t["x"] - obs["x"]) ** 2 +
|
||||
(t["y"] - obs["y"]) ** 2)["global_id"]
|
||||
obs.pop("embedding", None)
|
||||
state_sequence += 1
|
||||
payload = {"kind": "state", "sequence": state_sequence, "timestamp": now,
|
||||
"running": True, "cameras": camera_states, "targets": targets}
|
||||
try:
|
||||
while True:
|
||||
state_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
state_queue.put(payload)
|
||||
time.sleep(0.02)
|
||||
except Exception as exc:
|
||||
state_queue.put({"kind": "error", "running": False, "error": str(exc), "timestamp": time.time()})
|
||||
finally:
|
||||
for reader in readers:
|
||||
reader.close()
|
||||
for reader in readers:
|
||||
reader.join(timeout=2.0)
|
||||
state_queue.put({"kind": "stopped", "running": False, "timestamp": time.time()})
|
||||
|
||||
|
||||
class WorkshopRuntimeManager:
|
||||
_instance = None
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._instance_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self._initialized = True
|
||||
self._lock = threading.RLock()
|
||||
self._process = None
|
||||
self._state_queue = None
|
||||
self._command_queue = None
|
||||
self._listener = None
|
||||
self._state = {"running": False, "sequence": 0, "cameras": [], "targets": []}
|
||||
|
||||
def _listen(self):
|
||||
while self._process is not None:
|
||||
try:
|
||||
message = self._state_queue.get(timeout=0.5)
|
||||
with self._lock:
|
||||
self._state = message
|
||||
except queue.Empty:
|
||||
if self._process is not None and not self._process.is_alive():
|
||||
with self._lock:
|
||||
self._state = {**self._state, "running": False}
|
||||
if self._state.get("kind") != "stopped" and not self._state.get("error"):
|
||||
self._state["error"] = "车间定位进程已退出"
|
||||
break
|
||||
|
||||
def start(self, config):
|
||||
with self._lock:
|
||||
if self._process is not None and self._process.is_alive():
|
||||
return True, "already running"
|
||||
context = mp.get_context("spawn")
|
||||
self._state_queue = context.Queue(maxsize=4)
|
||||
self._command_queue = context.Queue(maxsize=8)
|
||||
self._process = context.Process(
|
||||
target=workshop_worker_main, args=(config, self._state_queue, self._command_queue),
|
||||
name="workshop-monitor", daemon=True,
|
||||
)
|
||||
self._state = {"running": True, "sequence": 0, "cameras": [], "targets": [],
|
||||
"timestamp": time.time()}
|
||||
self._process.start()
|
||||
self._listener = threading.Thread(target=self._listen, name="workshop-state-listener", daemon=True)
|
||||
self._listener.start()
|
||||
return True, "started"
|
||||
|
||||
def stop(self):
|
||||
with self._lock:
|
||||
process = self._process
|
||||
if process is None or not process.is_alive():
|
||||
self._state = {**self._state, "running": False}
|
||||
return True, "already stopped"
|
||||
try:
|
||||
self._command_queue.put({"cmd": "stop"}, timeout=1.0)
|
||||
except Exception:
|
||||
pass
|
||||
process.join(timeout=8.0)
|
||||
if process.is_alive():
|
||||
process.terminate()
|
||||
process.join(timeout=3.0)
|
||||
with self._lock:
|
||||
self._process = None
|
||||
self._state = {**self._state, "running": False}
|
||||
return True, "stopped"
|
||||
|
||||
def snapshot(self, since=None):
|
||||
with self._lock:
|
||||
state = dict(self._state)
|
||||
sequence = int(state.get("sequence") or 0)
|
||||
if since is not None and sequence <= int(since or 0):
|
||||
return {"changed": False, "sequence": sequence, "running": bool(state.get("running")),
|
||||
"timestamp": state.get("timestamp"), "error": state.get("error", "")}
|
||||
state["changed"] = True
|
||||
return state
|
||||
|
||||
|
||||
def get_runtime_manager():
|
||||
return WorkshopRuntimeManager()
|
||||
80
workshop_monitor/tests.py
Normal file
80
workshop_monitor/tests.py
Normal file
@ -0,0 +1,80 @@
|
||||
import json
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
|
||||
from app.models import AlgorithmModel, StreamModel
|
||||
from .models import CameraCalibration, WorkshopSite
|
||||
|
||||
|
||||
def make_stream(code, index):
|
||||
return StreamModel.objects.create(
|
||||
user_id=1, sort=index, code=code, app="live", name=code,
|
||||
pull_stream_url="", pull_stream_type=21, pull_stream_transfer_mode=0,
|
||||
pull_stream_ip="127.0.0.1", pull_stream_port=0,
|
||||
pull_stream_username="", pull_stream_password="", nickname="Camera %d" % index,
|
||||
remark="", forward_state=0, is_audio=0, snap_filepath="", camera_sum_num=1,
|
||||
camera_name="Camera %d" % index, camera_manufacturer="test", camera_owner="",
|
||||
camera_model="test", camera_device_id=code, camera_parent_id="", camera_civilcode="",
|
||||
state=0,
|
||||
)
|
||||
|
||||
|
||||
class WorkshopApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.admin = get_user_model().objects.create_superuser("wm-admin", "wm@example.com", "pass")
|
||||
self.client.force_login(self.admin)
|
||||
self.detector = AlgorithmModel.objects.create(
|
||||
name="detector", algorithm_type="yolo11", task_type="detect",
|
||||
inference_engine="onnxruntime", device="cpu", model_file="missing.onnx",
|
||||
labels='["person"]', state=1, is_default=1,
|
||||
)
|
||||
for i, code in enumerate((
|
||||
"34020000001320000001", "34020000001320000002",
|
||||
"34020000001320000003", "34020000001320000004",
|
||||
), start=1):
|
||||
make_stream(code, i)
|
||||
|
||||
def test_config_initializes_existing_four_streams(self):
|
||||
response = self.client.get("/workshop/openConfig")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["code"], 1000)
|
||||
self.assertEqual(len(payload["data"]["cameras"]), 4)
|
||||
self.assertTrue(all(c["stream_id"] for c in payload["data"]["cameras"]))
|
||||
self.assertEqual(payload["data"]["site"]["width_m"], 130.0)
|
||||
|
||||
def test_calibration_activation_requires_validation_under_one_meter(self):
|
||||
config = self.client.get("/workshop/openConfig").json()["data"]
|
||||
camera_id = config["cameras"][0]["id"]
|
||||
rows = [
|
||||
{"u": 0, "v": 0, "x": 0, "y": 0, "role": "fit"},
|
||||
{"u": 1300, "v": 0, "x": 130, "y": 0, "role": "fit"},
|
||||
{"u": 1300, "v": 500, "x": 130, "y": 50, "role": "fit"},
|
||||
{"u": 0, "v": 500, "x": 0, "y": 50, "role": "fit"},
|
||||
{"u": 200, "v": 100, "x": 20, "y": 10, "role": "verify"},
|
||||
{"u": 650, "v": 250, "x": 65, "y": 25, "role": "verify"},
|
||||
{"u": 1100, "v": 400, "x": 110, "y": 40, "role": "verify"},
|
||||
]
|
||||
response = self.client.post("/workshop/openCalibrate", data=json.dumps({
|
||||
"camera_id": camera_id, "frame_width": 1300, "frame_height": 500,
|
||||
"observations": rows, "activate": True,
|
||||
}), content_type="application/json")
|
||||
self.assertEqual(response.json()["code"], 1000)
|
||||
calibration = CameraCalibration.objects.get()
|
||||
self.assertTrue(calibration.is_active)
|
||||
self.assertEqual(calibration.status, CameraCalibration.STATUS_VALID)
|
||||
|
||||
def test_viewer_cannot_change_configuration(self):
|
||||
viewer = get_user_model().objects.create_user("wm-viewer", password="pass")
|
||||
self.client.force_login(viewer)
|
||||
response = self.client.post("/workshop/openSaveConfig", data="{}", content_type="application/json")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_workshop_tables_do_not_create_control_or_alarm_rows(self):
|
||||
self.client.get("/workshop/openConfig")
|
||||
site = WorkshopSite.objects.get()
|
||||
self.assertEqual(site.cameras.count(), 4)
|
||||
from app.models import AlarmModel, ZoneModel
|
||||
self.assertEqual(AlarmModel.objects.count(), 0)
|
||||
self.assertEqual(ZoneModel.objects.count(), 0)
|
||||
18
workshop_monitor/urls.py
Normal file
18
workshop_monitor/urls.py
Normal file
@ -0,0 +1,18 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("index", views.index),
|
||||
path("openConfig", views.open_config),
|
||||
path("openSaveConfig", views.open_save_config),
|
||||
path("openControlPointSave", views.open_control_point_save),
|
||||
path("openControlPointDelete", views.open_control_point_delete),
|
||||
path("openCapture", views.open_capture),
|
||||
path("openCalibrate", views.open_calibrate),
|
||||
path("openActivateCalibration", views.open_activate_calibration),
|
||||
path("openStart", views.open_start),
|
||||
path("openStop", views.open_stop),
|
||||
path("openState", views.open_state),
|
||||
]
|
||||
389
workshop_monitor/views.py
Normal file
389
workshop_monitor/views.py
Normal file
@ -0,0 +1,389 @@
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from django.db import transaction
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import render
|
||||
|
||||
from app.models import AlgorithmModel, StreamModel
|
||||
from monitor_runtime.paths import RESOURCE_ROOT
|
||||
|
||||
from .calibration import CalibrationError, solve_planar_calibration
|
||||
from .models import (
|
||||
CalibrationObservation, CameraCalibration, GroundControlPoint,
|
||||
WorkshopCamera, WorkshopSite,
|
||||
)
|
||||
from .runtime import get_runtime_manager
|
||||
|
||||
|
||||
DEFAULT_STREAM_CODES = (
|
||||
"34020000001320000001", "34020000001320000002",
|
||||
"34020000001320000003", "34020000001320000004",
|
||||
)
|
||||
DEFAULT_CAMERA_POSES = (
|
||||
(0.0, 0.0, 5.0, 45.0), (130.0, 0.0, 5.0, 135.0),
|
||||
(130.0, 50.0, 5.0, 225.0), (0.0, 50.0, 5.0, 315.0),
|
||||
)
|
||||
|
||||
|
||||
def _reply(ok, data=None, msg="成功", status=200):
|
||||
return JsonResponse({"code": 1000 if ok else 0, "msg": msg, "data": data or {}}, status=status)
|
||||
|
||||
|
||||
def f_checkRequestSafe(request):
|
||||
user = getattr(request, "user", None)
|
||||
return (True, "成功") if user is not None and user.is_authenticated else (False, "未登录")
|
||||
|
||||
|
||||
def f_parsePostParams(request):
|
||||
if request.POST:
|
||||
return {key: request.POST.get(key) for key in request.POST}
|
||||
try:
|
||||
return json.loads(request.body.decode("utf-8")) if request.body else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _site():
|
||||
site = WorkshopSite.objects.order_by("id").first()
|
||||
if site is None:
|
||||
detector = AlgorithmModel.objects.filter(is_default=1, state=1).first()
|
||||
site = WorkshopSite.objects.create(detector=detector, target_labels=["person"])
|
||||
existing_slots = set(site.cameras.values_list("slot", flat=True))
|
||||
streams = {s.code: s for s in StreamModel.objects.filter(code__in=DEFAULT_STREAM_CODES)}
|
||||
for slot, (code, pose) in enumerate(zip(DEFAULT_STREAM_CODES, DEFAULT_CAMERA_POSES), start=1):
|
||||
if slot not in existing_slots:
|
||||
stream = streams.get(code)
|
||||
WorkshopCamera.objects.create(
|
||||
site=site, slot=slot, stream=stream,
|
||||
display_name=(stream.nickname if stream else "摄像头 %d" % slot),
|
||||
install_x=pose[0], install_y=pose[1], install_z=pose[2], yaw_deg=pose[3],
|
||||
)
|
||||
return site
|
||||
|
||||
|
||||
def _camera_dict(camera):
|
||||
stream = camera.stream
|
||||
calibration = camera.active_calibration
|
||||
return {
|
||||
"id": camera.id, "slot": camera.slot, "display_name": camera.display_name,
|
||||
"enabled": camera.enabled, "stream_id": camera.stream_id,
|
||||
"stream_code": stream.code if stream else "", "stream_app": stream.app if stream else "",
|
||||
"stream_name": stream.name if stream else "", "stream_nickname": stream.nickname if stream else "",
|
||||
"install_x": camera.install_x, "install_y": camera.install_y, "install_z": camera.install_z,
|
||||
"yaw_deg": camera.yaw_deg, "pitch_deg": camera.pitch_deg,
|
||||
"calibration": ({
|
||||
"id": calibration.id, "status": calibration.status,
|
||||
"fit_rmse_m": calibration.fit_rmse_m,
|
||||
"validation_mean_m": calibration.validation_mean_m,
|
||||
"validation_max_m": calibration.validation_max_m,
|
||||
"frame_width": calibration.frame_width, "frame_height": calibration.frame_height,
|
||||
"create_time": calibration.create_time,
|
||||
} if calibration else None),
|
||||
}
|
||||
|
||||
|
||||
def _config_data(site):
|
||||
streams = [{"id": s.id, "code": s.code, "nickname": s.nickname, "app": s.app, "name": s.name,
|
||||
"pull_stream_ip": s.pull_stream_ip, "forward_state": s.forward_state}
|
||||
for s in StreamModel.objects.order_by("id")]
|
||||
detectors = [{"id": a.id, "name": a.name, "task_type": a.task_type,
|
||||
"device": a.device, "engine": a.inference_engine}
|
||||
for a in AlgorithmModel.objects.filter(state=1).order_by("id")]
|
||||
points = [{"id": p.id, "name": p.name, "x": p.x, "y": p.y,
|
||||
"description": p.description} for p in site.control_points.all()]
|
||||
return {
|
||||
"site": {"id": site.id, "name": site.name, "width_m": site.width_m,
|
||||
"height_m": site.height_m, "detector_id": site.detector_id,
|
||||
"reid_model_id": site.reid_model_id, "target_labels": site.target_labels or ["person"],
|
||||
"analysis_fps": site.analysis_fps, "fusion_radius_m": site.fusion_radius_m,
|
||||
"observation_window_sec": site.observation_window_sec,
|
||||
"max_speed_mps": site.max_speed_mps, "lost_ttl_sec": site.lost_ttl_sec,
|
||||
"trail_sec": site.trail_sec},
|
||||
"cameras": [_camera_dict(c) for c in site.cameras.select_related("stream")],
|
||||
"control_points": points, "streams": streams, "algorithms": detectors,
|
||||
"runtime": get_runtime_manager().snapshot(),
|
||||
}
|
||||
|
||||
|
||||
def index(request):
|
||||
return render(request, "workshop_monitor/index.html", {})
|
||||
|
||||
|
||||
def open_config(request):
|
||||
return _reply(True, _config_data(_site()))
|
||||
|
||||
|
||||
def _number(params, name, minimum=None, maximum=None):
|
||||
try:
|
||||
value = float(params[name])
|
||||
except Exception as exc:
|
||||
raise ValueError("%s 数值无效" % name) from exc
|
||||
if minimum is not None and value < minimum:
|
||||
raise ValueError("%s 不能小于 %s" % (name, minimum))
|
||||
if maximum is not None and value > maximum:
|
||||
raise ValueError("%s 不能大于 %s" % (name, maximum))
|
||||
return value
|
||||
|
||||
|
||||
def open_save_config(request):
|
||||
ok, msg = f_checkRequestSafe(request)
|
||||
if request.method != "POST" or not ok:
|
||||
return _reply(False, msg=msg if not ok else "仅支持 POST")
|
||||
params = f_parsePostParams(request)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
site = _site()
|
||||
site.name = str(params.get("name") or "主车间")[:100]
|
||||
site.width_m = _number(params, "width_m", 1, 10000)
|
||||
site.height_m = _number(params, "height_m", 1, 10000)
|
||||
site.analysis_fps = _number(params, "analysis_fps", 0.1, 30)
|
||||
site.fusion_radius_m = _number(params, "fusion_radius_m", 0.1, 20)
|
||||
site.observation_window_sec = _number(params, "observation_window_sec", 0.1, 10)
|
||||
site.max_speed_mps = _number(params, "max_speed_mps", 0.1, 50)
|
||||
site.lost_ttl_sec = _number(params, "lost_ttl_sec", 1, 600)
|
||||
site.trail_sec = _number(params, "trail_sec", 1, 600)
|
||||
labels = params.get("target_labels") or ["person"]
|
||||
if isinstance(labels, str):
|
||||
labels = [x.strip() for x in labels.split(",") if x.strip()]
|
||||
site.target_labels = labels or ["person"]
|
||||
site.detector = AlgorithmModel.objects.filter(id=int(params.get("detector_id") or 0), state=1).first()
|
||||
rid = int(params.get("reid_model_id") or 0)
|
||||
site.reid_model = AlgorithmModel.objects.filter(id=rid, state=1, task_type="reid").first() if rid else None
|
||||
if site.detector and site.detector.task_type == "reid":
|
||||
raise ValueError("检测模型不能是 ReID 模型")
|
||||
site.save()
|
||||
for row in params.get("cameras") or []:
|
||||
camera = site.cameras.get(id=int(row["id"]))
|
||||
camera.stream = StreamModel.objects.filter(id=int(row.get("stream_id") or 0)).first()
|
||||
camera.display_name = str(row.get("display_name") or "摄像头 %d" % camera.slot)[:100]
|
||||
camera.enabled = bool(row.get("enabled", True))
|
||||
camera.install_x = float(row.get("install_x", 0))
|
||||
camera.install_y = float(row.get("install_y", 0))
|
||||
camera.install_z = float(row.get("install_z", 0))
|
||||
camera.yaw_deg = float(row.get("yaw_deg", 0)) % 360.0
|
||||
camera.pitch_deg = max(-90.0, min(90.0, float(row.get("pitch_deg", 0))))
|
||||
if not (0 <= camera.install_x <= site.width_m and 0 <= camera.install_y <= site.height_m):
|
||||
raise ValueError("摄像头 %d 的安装 XY 超出厂房边界" % camera.slot)
|
||||
camera.save()
|
||||
return _reply(True, _config_data(site))
|
||||
except Exception as exc:
|
||||
return _reply(False, msg=str(exc))
|
||||
|
||||
|
||||
def open_control_point_save(request):
|
||||
ok, msg = f_checkRequestSafe(request)
|
||||
if request.method != "POST" or not ok:
|
||||
return _reply(False, msg=msg if not ok else "仅支持 POST")
|
||||
params = f_parsePostParams(request)
|
||||
try:
|
||||
site = _site()
|
||||
x, y = _number(params, "x", 0, site.width_m), _number(params, "y", 0, site.height_m)
|
||||
pid = int(params.get("id") or 0)
|
||||
point = site.control_points.filter(id=pid).first() if pid else GroundControlPoint(site=site)
|
||||
if point is None:
|
||||
raise ValueError("控制点不存在")
|
||||
point.name = str(params.get("name") or "").strip()[:100]
|
||||
if not point.name:
|
||||
raise ValueError("控制点名称不能为空")
|
||||
point.x, point.y = x, y
|
||||
point.description = str(params.get("description") or "")[:300]
|
||||
point.save()
|
||||
return _reply(True, {"id": point.id})
|
||||
except Exception as exc:
|
||||
return _reply(False, msg=str(exc))
|
||||
|
||||
|
||||
def open_control_point_delete(request):
|
||||
ok, msg = f_checkRequestSafe(request)
|
||||
if request.method != "POST" or not ok:
|
||||
return _reply(False, msg=msg if not ok else "仅支持 POST")
|
||||
try:
|
||||
point = _site().control_points.get(id=int(f_parsePostParams(request).get("id") or 0))
|
||||
point.delete()
|
||||
return _reply(True)
|
||||
except Exception as exc:
|
||||
return _reply(False, msg=str(exc))
|
||||
|
||||
|
||||
def open_capture(request):
|
||||
ok, msg = f_checkRequestSafe(request)
|
||||
if request.method != "POST" or not ok:
|
||||
return _reply(False, msg=msg if not ok else "仅支持 POST")
|
||||
cap = None
|
||||
try:
|
||||
import cv2
|
||||
camera = _site().cameras.select_related("stream").get(id=int(f_parsePostParams(request).get("camera_id") or 0))
|
||||
if not camera.stream:
|
||||
raise ValueError("摄像头尚未绑定视频流")
|
||||
from app.analysis.manager import AnalysisManager
|
||||
url = AnalysisManager.build_rtsp_url(camera.stream)
|
||||
if not url:
|
||||
raise ValueError("无法生成摄像头 RTSP 地址")
|
||||
cap = cv2.VideoCapture(url, cv2.CAP_FFMPEG)
|
||||
frame = None
|
||||
deadline = time.monotonic() + 8.0
|
||||
while time.monotonic() < deadline:
|
||||
ret, current = cap.read()
|
||||
if ret and current is not None:
|
||||
frame = current
|
||||
break
|
||||
if frame is None:
|
||||
raise ValueError("抓帧失败,请确认 GB28181 视频流在线")
|
||||
token = uuid.uuid4().hex
|
||||
directory = Path(RESOURCE_ROOT) / "static" / "storage" / "workshop" / "calibration"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / (token + ".jpg")
|
||||
if not cv2.imwrite(str(path), frame, [int(cv2.IMWRITE_JPEG_QUALITY), 92]):
|
||||
raise ValueError("标定截图保存失败")
|
||||
h, w = frame.shape[:2]
|
||||
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
return _reply(True, {"token": token, "width": w, "height": h,
|
||||
"image": "data:image/jpeg;base64," + encoded})
|
||||
except Exception as exc:
|
||||
return _reply(False, msg=str(exc))
|
||||
finally:
|
||||
if cap is not None:
|
||||
cap.release()
|
||||
|
||||
|
||||
def open_calibrate(request):
|
||||
ok, msg = f_checkRequestSafe(request)
|
||||
if request.method != "POST" or not ok:
|
||||
return _reply(False, msg=msg if not ok else "仅支持 POST")
|
||||
params = f_parsePostParams(request)
|
||||
try:
|
||||
site = _site()
|
||||
camera = site.cameras.get(id=int(params.get("camera_id") or 0))
|
||||
observations = params.get("observations") or []
|
||||
result = solve_planar_calibration(observations, params.get("frame_width"), params.get("frame_height"), 1.0)
|
||||
token = str(params.get("snapshot_token") or "")
|
||||
rel_path = "workshop/calibration/%s.jpg" % token if token else ""
|
||||
with transaction.atomic():
|
||||
calibration = CameraCalibration.objects.create(
|
||||
camera=camera, snapshot_path=rel_path,
|
||||
frame_width=int(params.get("frame_width")), frame_height=int(params.get("frame_height")),
|
||||
homography=result["homography"], fit_rmse_m=result["fit_rmse_m"],
|
||||
validation_mean_m=result["validation_mean_m"], validation_max_m=result["validation_max_m"],
|
||||
status=CameraCalibration.STATUS_VALID if result["is_valid"] else CameraCalibration.STATUS_INVALID,
|
||||
is_active=False, warnings=result["warnings"],
|
||||
created_by=getattr(request.user, "id", 0) or 0,
|
||||
)
|
||||
point_map = {p.id: p for p in site.control_points.all()}
|
||||
for row in result["observations"]:
|
||||
pid = int(row.get("control_point_id") or 0)
|
||||
CalibrationObservation.objects.create(
|
||||
calibration=calibration, control_point=point_map.get(pid),
|
||||
pixel_u=float(row["u"]), pixel_v=float(row["v"]),
|
||||
world_x=float(row["x"]), world_y=float(row["y"]),
|
||||
role=row.get("role", "fit"), error_m=float(row["error_m"]),
|
||||
)
|
||||
if result["is_valid"] and bool(params.get("activate", True)):
|
||||
camera.calibrations.filter(is_active=True).update(is_active=False)
|
||||
calibration.is_active = True
|
||||
calibration.save(update_fields=("is_active", "last_update_time"))
|
||||
return _reply(True, {"calibration_id": calibration.id, **result})
|
||||
except (CalibrationError, ValueError, KeyError, TypeError) as exc:
|
||||
return _reply(False, msg=str(exc))
|
||||
|
||||
|
||||
def open_activate_calibration(request):
|
||||
ok, msg = f_checkRequestSafe(request)
|
||||
if request.method != "POST" or not ok:
|
||||
return _reply(False, msg=msg if not ok else "仅支持 POST")
|
||||
try:
|
||||
calibration = CameraCalibration.objects.select_related("camera").get(
|
||||
id=int(f_parsePostParams(request).get("calibration_id") or 0))
|
||||
if calibration.status != CameraCalibration.STATUS_VALID or calibration.validation_mean_m is None or calibration.validation_mean_m > 1.0:
|
||||
raise ValueError("只有验证平均误差不超过 1 米的标定才能激活")
|
||||
with transaction.atomic():
|
||||
calibration.camera.calibrations.filter(is_active=True).update(is_active=False)
|
||||
calibration.is_active = True
|
||||
calibration.save(update_fields=("is_active", "last_update_time"))
|
||||
return _reply(True)
|
||||
except Exception as exc:
|
||||
return _reply(False, msg=str(exc))
|
||||
|
||||
|
||||
def _algorithm_spec(algorithm, targets=None):
|
||||
labels = algorithm.labels
|
||||
if isinstance(labels, str):
|
||||
try:
|
||||
labels = json.loads(labels)
|
||||
except Exception:
|
||||
labels = []
|
||||
model_path = Path(algorithm.model_file)
|
||||
if not model_path.is_absolute():
|
||||
model_path = Path(RESOURCE_ROOT) / "static" / "upload" / "weight" / model_path
|
||||
if not model_path.is_file():
|
||||
raise ValueError("模型文件不存在: %s" % algorithm.model_file)
|
||||
return {
|
||||
"id": algorithm.id, "name": algorithm.name, "model_file": str(model_path),
|
||||
"labels": labels, "input_size": [algorithm.input_width, algorithm.input_height],
|
||||
"conf_threshold": algorithm.conf_threshold, "iou_threshold": algorithm.iou_threshold,
|
||||
"algorithm_type": algorithm.algorithm_type, "task_type": algorithm.task_type,
|
||||
"inference_engine": algorithm.inference_engine, "device": algorithm.device,
|
||||
"target_labels": targets or [],
|
||||
}
|
||||
|
||||
|
||||
def _runtime_config(site):
|
||||
if not site.detector or site.detector.state != 1:
|
||||
raise ValueError("请先选择可用的人员检测模型")
|
||||
cameras = []
|
||||
from app.analysis.manager import AnalysisManager
|
||||
for camera in site.cameras.filter(enabled=True).select_related("stream"):
|
||||
calibration = camera.active_calibration
|
||||
if not camera.stream or not calibration:
|
||||
continue
|
||||
url = AnalysisManager.build_rtsp_url(camera.stream)
|
||||
if not url:
|
||||
continue
|
||||
cameras.append({
|
||||
"camera_id": camera.id, "stream_id": camera.stream_id, "slot": camera.slot,
|
||||
"display_name": camera.display_name or str(camera), "rtsp_url": url,
|
||||
"calibration": {"homography": calibration.homography,
|
||||
"validation_mean_m": calibration.validation_mean_m},
|
||||
})
|
||||
if len(cameras) < 3:
|
||||
raise ValueError("至少需要 3 台已绑定且完成合格标定的摄像头")
|
||||
return {
|
||||
"width_m": site.width_m, "height_m": site.height_m,
|
||||
"analysis_fps": site.analysis_fps, "fusion_radius_m": site.fusion_radius_m,
|
||||
"observation_window_sec": site.observation_window_sec,
|
||||
"max_speed_mps": site.max_speed_mps, "lost_ttl_sec": site.lost_ttl_sec,
|
||||
"trail_sec": site.trail_sec, "target_labels": site.target_labels or ["person"],
|
||||
"detector": _algorithm_spec(site.detector, site.target_labels or ["person"]),
|
||||
"reid_model": _algorithm_spec(site.reid_model) if site.reid_model else None,
|
||||
"cameras": cameras,
|
||||
}
|
||||
|
||||
|
||||
def open_start(request):
|
||||
ok, msg = f_checkRequestSafe(request)
|
||||
if request.method != "POST" or not ok:
|
||||
return _reply(False, msg=msg if not ok else "仅支持 POST")
|
||||
try:
|
||||
ok, info = get_runtime_manager().start(_runtime_config(_site()))
|
||||
return _reply(ok, get_runtime_manager().snapshot(), info)
|
||||
except Exception as exc:
|
||||
return _reply(False, msg=str(exc))
|
||||
|
||||
|
||||
def open_stop(request):
|
||||
ok, msg = f_checkRequestSafe(request)
|
||||
if request.method != "POST" or not ok:
|
||||
return _reply(False, msg=msg if not ok else "仅支持 POST")
|
||||
ok, info = get_runtime_manager().stop()
|
||||
return _reply(ok, get_runtime_manager().snapshot(), info)
|
||||
|
||||
|
||||
def open_state(request):
|
||||
try:
|
||||
since = request.GET.get("since")
|
||||
return _reply(True, get_runtime_manager().snapshot(since))
|
||||
except Exception as exc:
|
||||
return _reply(False, msg=str(exc))
|
||||
Loading…
Reference in New Issue
Block a user