增加了车间监控功能模块

This commit is contained in:
zhengsl 2026-09-06 22:26:35 +08:00
parent 25be2d5b12
commit 26dce29217
19 changed files with 1518 additions and 1 deletions

View File

@ -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")):

View File

@ -62,7 +62,8 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'app'
'app',
'workshop_monitor',
]
MIDDLEWARE = [

View File

@ -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')),
]

View File

@ -734,6 +734,7 @@
"llm_no_image_provided": "未提供图像文件",
"nav_zones": "布控管理",
"nav_control": "布控管理",
"nav_workshop": "车间监控",
"zone_all_cameras": "全部摄像头",
"zone_camera": "摄像头",
"zone_name": "布控名称",

View File

@ -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:'报警管理' }}">

View 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 为现场测量值Yaw0°向右、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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 %}

View 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()

View File

@ -0,0 +1,31 @@
# 车间监控现场标定指南
## 坐标约定
- 厂房平面尺寸为 130 米 × 50 米,左上角为 `(0, 0)`
- X 轴向右,范围 `0130`Y 轴向下,范围 `050`;地面为 `Z=0`
- 四台摄像头必须使用同一套世界坐标。摄像头安装位置 XYZ、朝向或焦距发生变化后应重新标定该摄像头。
## 现场准备
1. 在现场选择清晰、固定且位于地面的特征点,例如地砖角、立柱脚、设备底座角或临时贴在地面的标记。
2. 使用卷尺或激光测距仪测量每个点相对于厂房左上角原点的 X/Y 坐标。
3. 建议准备 812 个用于拟合的点和至少 3 个独立验证点。控制点应覆盖画面的近、中、远区域及左右两侧,不能集中在一条直线上。
4. 单独测量每台摄像头镜头中心的 X/Y/Z 和大致朝向。位置仅用于平面图展示和辅助诊断,不替代该相机自身的像素到地面标定。
## 系统操作
1. 进入“车间监控 → 车间配置”,确认四个视频流分别绑定到 C1C4并录入摄像头安装位置。
2. 打开“控制点与标定”,先建立厂房控制点名称及测得的 X/Y 坐标。公共地面点只需建立一次,可供不同相机重复使用。
3. 选择一台摄像头并抓取当前画面,在画面中依次点击特征点,再为每个像素点选择对应的厂房控制点。
4. 将至少 4 个点设为“拟合”,至少 3 个未参与拟合的点设为“验证”。建议实际使用 812 个分布均匀的拟合点。
5. 点击“计算并激活”。验证平均误差不超过 1 米才会激活;不合格结果会保留为草稿,可调整点位后重新计算。
6. 对 C1、C2、C3、C4 分别重复第 35 步。每台相机看到的控制点可以不同,但所有点的世界坐标必须来自同一厂房坐标系。
## 验收建议
- 先在验证点位置站人,核对页面显示坐标与实测坐标的误差。
- 再进行单摄移动、重叠区移动和跨视野移动,观察全局 ID 是否保持一致。
- 两人交叉测试时,如未配置 ReID 模型,重点检查页面的低融合置信度提示。
- 单路断流后确认其余三路、平面图和目标列表仍持续更新。

View File

@ -0,0 +1 @@
"""车间多摄像头定位模块。"""

7
workshop_monitor/apps.py Normal file
View 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 = "车间监控"

View 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
View 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"])

View 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")),
]

View File

@ -0,0 +1 @@

134
workshop_monitor/models.py Normal file
View 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
View 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
View 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
View 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
View 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))