提交了车间管理模块功能

This commit is contained in:
zhengsl 2026-09-07 12:57:59 +08:00
parent 26dce29217
commit c6fdc7fe10
5 changed files with 111 additions and 15 deletions

View File

@ -9,10 +9,11 @@
.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-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-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-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-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,.wm-video-stream-health{position:absolute;right:8px;z-index:3;color:#fff;font-size:11px;background:rgba(15,23,42,.72);padding:4px 7px;border-radius:5px}.wm-video-health{top:8px}.wm-video-stream-health{bottom:8px}.wm-video-stream-health.on{background:rgba(4,120,87,.82)}.wm-video-stream-health.off{background:rgba(185,28,28,.82)}.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-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-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.25fr 1.25fr 1fr repeat(5,.58fr);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%} .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%}
.wm-modal.fs{padding:0}.wm-modal.fs .wm-dialog{width:100vw;height:100vh;max-height:100vh;border-radius:0;display:flex;flex-direction:column}.wm-modal.fs .wm-dialog-head{flex-shrink:0}.wm-modal.fs .wm-dialog-body{flex:1;min-height:0;overflow:auto}.wm-modal.fs .wm-cal-grid{grid-template-columns:minmax(0,1fr) 340px}.wm-modal.fs .wm-shot{height:calc(100vh - 150px);min-height:0}.wm-modal.fs .wm-shot img{max-height:100%}.wm-modal.fs .wm-point-list{max-height:none}
@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}} @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> </style>
{% endblock %} {% endblock %}
@ -26,7 +27,7 @@
</div> </div>
<div class="wm-layout"> <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"><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 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>
<div class="wm-side"> <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><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>
@ -40,11 +41,11 @@
<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><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><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 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><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></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 class="wm-modal" id="calibrationModal"><div class="wm-dialog"><div class="wm-dialog-head"><b>现场控制点标定</b><span style="display:flex;gap:8px"><button class="btn" id="calFullBtn" onclick="toggleCalFullscreen()">全屏</button><button class="btn" onclick="closeModal('calibrationModal')">关闭</button></span></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 style="display:flex;gap:8px;align-items:center;margin-bottom:12px"><select id="calCamera" style="height:36px;min-width:220px" onchange="resetShot();captureFrame()"></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 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><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 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>
@ -53,7 +54,7 @@
{% block extra_js %} {% block extra_js %}
<script> <script>
var wm={cfg:null,state:null,players:{},pins:[],shot:null,poll:null,polling:false}; var wm={cfg:null,state:null,players:{},previewChecks:{},previewOnline:{},retryTimers:{},destroying:{},previewGeneration:0,pins:[],shot:null,poll:null,previewPoll:null,polling:false};
function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]})} 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 toast(m,t){if(window.showToast)showToast(m,t||'success');else alert(m)}
function closeModal(id){document.getElementById(id).classList.remove('show')} function closeModal(id){document.getElementById(id).classList.remove('show')}
@ -65,8 +66,12 @@ function renderMap(){var s=wm.cfg.site,w=Number(s.width_m),h=Number(s.height_m),
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(' ')+'"/>')}); 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>')}); 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('')} 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 setPreviewStatus(id,text,kind){var el=document.getElementById('preview-health-'+id);if(el){el.textContent=text;el.className='wm-video-stream-health '+(kind||'')}}
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 destroyPlayer(id,message){var p=wm.players[id];wm.destroying[id]=true;if(p){try{var done=p.destroy();if(done&&done.catch)done.catch(function(){})}catch(e){}delete wm.players[id]}setTimeout(function(){delete wm.destroying[id]},300);var el=document.getElementById('player-'+id);if(el&&message)el.innerHTML='<div class="wm-empty">'+esc(message)+'</div>'}
function schedulePlayerRetry(c,message){if(wm.destroying[c.id]||wm.retryTimers[c.id])return;destroyPlayer(c.id,message||'子码流连接中断,等待重连…');setPreviewStatus(c.id,'等待重连','off');wm.retryTimers[c.id]=setTimeout(function(){delete wm.retryTimers[c.id];startPlayer(c)},2000)}
function renderVideos(){var grid=document.getElementById('videoGrid');wm.previewGeneration++;Object.keys(wm.retryTimers).forEach(function(id){clearTimeout(wm.retryTimers[id]);delete wm.retryTimers[id]});Object.keys(wm.players).forEach(function(id){destroyPlayer(id)});wm.previewChecks={};wm.previewOnline={};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.preview_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 class="wm-video-stream-health" id="preview-health-'+c.id+'">'+(c.preview_stream_id?'检测中':'未配置')+'</div></div>'}).join('');syncPreviewPlayers()}
function startPlayer(c){if(!c.preview_stream_id||wm.players[c.id]||wm.previewChecks[c.id])return;var generation=wm.previewGeneration;wm.previewChecks[c.id]=true;Api.get('/stream/openPlayer',{app:c.preview_stream_app,name:c.preview_stream_name}).then(function(res){if(generation!==wm.previewGeneration)return;var s=res&&res.info&&res.info.stream,url=s&&(s.wsMp4Url||s.httpMp4Url||s.wsFlvUrl||s.httpFlvUrl);wm.previewOnline[c.id]=!!(s&&Number(s.is_online)===1);if(!wm.previewOnline[c.id]||!url){destroyPlayer(c.id,'预览子码流离线,等待重连…');setPreviewStatus(c.id,'子码流离线','off');return}var el=document.getElementById('player-'+c.id);if(!el)return;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,loadingTimeoutReplay:true,loadingTimeoutReplayTimes:3,heartTimeoutReplay:true,heartTimeoutReplayTimes:3,networkDelayTimeoutReplay:true,websocket1006ErrorReplay:true,streamEndReplay:true});wm.players[c.id]=p;if(p.on){p.on('playing',function(){setPreviewStatus(c.id,'子码流在线','on')});['websocketClose','streamEnd','loadingTimeoutRetryEnd','delayTimeoutRetryEnd'].forEach(function(name){p.on(name,function(){schedulePlayerRetry(c)})})}var promise=p.play(url);if(promise&&promise.catch)promise.catch(function(){schedulePlayerRetry(c,'子码流播放失败,等待重连…')});setPreviewStatus(c.id,'连接子码流…','')}catch(e){schedulePlayerRetry(c,'播放器启动失败,等待重连…')}}).catch(function(){if(generation===wm.previewGeneration){destroyPlayer(c.id,'子码流状态检查失败,等待重连…');setPreviewStatus(c.id,'检查失败','off')}}).then(function(){delete wm.previewChecks[c.id]})}
function syncPreviewPlayers(){if(!wm.cfg)return;var generation=wm.previewGeneration;wm.cfg.cameras.forEach(function(c){if(!c.preview_stream_id){destroyPlayer(c.id,'未配置预览子码流');setPreviewStatus(c.id,'未配置','off');return}if(wm.previewChecks[c.id])return;Api.get('/stream/openPlayer',{app:c.preview_stream_app,name:c.preview_stream_name}).then(function(res){if(generation!==wm.previewGeneration)return;var s=res&&res.info&&res.info.stream,online=!!(s&&Number(s.is_online)===1),was=wm.previewOnline[c.id];wm.previewOnline[c.id]=online;if(!online){destroyPlayer(c.id,'预览子码流离线,等待重连…');setPreviewStatus(c.id,'子码流离线','off')}else if(!wm.players[c.id]){startPlayer(c)}else{setPreviewStatus(c.id,'子码流在线','on')}if(was===false&&online)setPreviewStatus(c.id,'正在重连…','')}).catch(function(){if(generation===wm.previewGeneration)setPreviewStatus(c.id,'状态检查失败','off')})})}
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 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 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 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))})})}
@ -74,21 +79,22 @@ function pollState(){if(!wm.cfg||wm.polling)return;wm.polling=true;Api.get('/wor
function startMonitor(){Api.post('/workshop/openStart',{}).then(apiOk).then(function(d){wm.state=d;pollState();toast('车间定位服务已启动')}).catch(function(e){toast(e.message,'error')})} 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 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 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 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><select class="cc-preview-stream"><option value="">未绑定</option>'+options(wm.cfg.streams,c.preview_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 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,preview_stream_id:q('.cc-preview-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 openCalibration(cameraId){document.getElementById('calCamera').innerHTML=options(wm.cfg.cameras,cameraId||wm.cfg.cameras[0].id);renderGcps();resetShot();document.getElementById('calibrationModal').classList.add('show');captureFrame()}
function toggleCalFullscreen(){var on=document.getElementById('calibrationModal').classList.toggle('fs');document.getElementById('calFullBtn').textContent=on?'退出全屏':'全屏';if(wm.shot)renderPins()}
function resetShot(){wm.pins=[];wm.shot=null;document.getElementById('shotBox').innerHTML='<span class="wm-muted">请抓取当前摄像头画面</span>';renderPinRows();document.getElementById('calResult').textContent=''} 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 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 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 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 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 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 captureFrame(){var req=(wm.capReq=(wm.capReq||0)+1),camId=calCamera.value;wm.pins=[];wm.shot=null;renderPinRows();document.getElementById('shotBox').innerHTML='<span class="wm-muted">正在抓帧,请稍候…</span>';document.getElementById('calResult').textContent='正在抓帧…';Api.post('/workshop/openCapture',{camera_id:camId}).then(apiOk).then(function(d){if(req!==wm.capReq)return;wm.shot=d;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){if(req!==wm.capReq)return;document.getElementById('shotBox').innerHTML='<span class="wm-muted">抓帧失败:'+esc(e.message||'')+'</span>';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 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 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 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 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')})} 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); window.addEventListener('resize',function(){if(wm.shot)renderPins();drawOverlays()});loadConfig();wm.poll=setInterval(pollState,250);wm.previewPoll=setInterval(syncPreviewPlayers,5000);
</script> </script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,20 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [("workshop_monitor", "0001_initial")]
operations = [
migrations.AddField(
model_name="workshopcamera",
name="preview_stream",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="workshop_preview_bindings",
to="app.streammodel",
),
),
]

View File

@ -36,6 +36,10 @@ class WorkshopCamera(models.Model):
"app.StreamModel", null=True, blank=True, on_delete=models.SET_NULL, "app.StreamModel", null=True, blank=True, on_delete=models.SET_NULL,
related_name="workshop_bindings", related_name="workshop_bindings",
) )
preview_stream = models.ForeignKey(
"app.StreamModel", null=True, blank=True, on_delete=models.SET_NULL,
related_name="workshop_preview_bindings",
)
slot = models.PositiveIntegerField(default=1) slot = models.PositiveIntegerField(default=1)
display_name = models.CharField(max_length=100, default="") display_name = models.CharField(max_length=100, default="")
enabled = models.BooleanField(default=True) enabled = models.BooleanField(default=True)

View File

@ -42,8 +42,47 @@ class WorkshopApiTests(TestCase):
self.assertEqual(payload["code"], 1000) self.assertEqual(payload["code"], 1000)
self.assertEqual(len(payload["data"]["cameras"]), 4) self.assertEqual(len(payload["data"]["cameras"]), 4)
self.assertTrue(all(c["stream_id"] for c in payload["data"]["cameras"])) self.assertTrue(all(c["stream_id"] for c in payload["data"]["cameras"]))
self.assertTrue(all(c["preview_stream_id"] is None for c in payload["data"]["cameras"]))
self.assertEqual(payload["data"]["site"]["width_m"], 130.0) self.assertEqual(payload["data"]["site"]["width_m"], 130.0)
def test_separate_preview_substream_is_persisted(self):
config = self.client.get("/workshop/openConfig").json()["data"]
preview = make_stream("34020000001320000102", 102)
site = config["site"]
camera = config["cameras"][0]
response = self.client.post("/workshop/openSaveConfig", data=json.dumps({
"name": site["name"], "width_m": site["width_m"], "height_m": site["height_m"],
"analysis_fps": site["analysis_fps"], "target_labels": site["target_labels"],
"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"], "detector_id": site["detector_id"],
"reid_model_id": site["reid_model_id"],
"cameras": [{**camera, "preview_stream_id": preview.id}],
}), content_type="application/json")
payload = response.json()
self.assertEqual(payload["code"], 1000)
saved = payload["data"]["cameras"][0]
self.assertEqual(saved["preview_stream_id"], preview.id)
self.assertEqual(saved["preview_stream_name"], preview.name)
def test_preview_substream_cannot_equal_analysis_stream(self):
config = self.client.get("/workshop/openConfig").json()["data"]
site = config["site"]
camera = config["cameras"][0]
response = self.client.post("/workshop/openSaveConfig", data=json.dumps({
"name": site["name"], "width_m": site["width_m"], "height_m": site["height_m"],
"analysis_fps": site["analysis_fps"], "target_labels": site["target_labels"],
"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"], "detector_id": site["detector_id"],
"reid_model_id": site["reid_model_id"],
"cameras": [{**camera, "preview_stream_id": camera["stream_id"]}],
}), content_type="application/json")
self.assertEqual(response.json()["code"], 0)
self.assertIn("不能与定位/标定流相同", response.json()["msg"])
def test_calibration_activation_requires_validation_under_one_meter(self): def test_calibration_activation_requires_validation_under_one_meter(self):
config = self.client.get("/workshop/openConfig").json()["data"] config = self.client.get("/workshop/openConfig").json()["data"]
camera_id = config["cameras"][0]["id"] camera_id = config["cameras"][0]["id"]

View File

@ -67,12 +67,19 @@ def _site():
def _camera_dict(camera): def _camera_dict(camera):
stream = camera.stream stream = camera.stream
preview = camera.preview_stream
calibration = camera.active_calibration calibration = camera.active_calibration
return { return {
"id": camera.id, "slot": camera.slot, "display_name": camera.display_name, "id": camera.id, "slot": camera.slot, "display_name": camera.display_name,
"enabled": camera.enabled, "stream_id": camera.stream_id, "enabled": camera.enabled, "stream_id": camera.stream_id,
"stream_code": stream.code if stream else "", "stream_app": stream.app if stream else "", "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 "", "stream_name": stream.name if stream else "", "stream_nickname": stream.nickname if stream else "",
"preview_stream_id": camera.preview_stream_id,
"preview_stream_code": preview.code if preview else "",
"preview_stream_app": preview.app if preview else "",
"preview_stream_name": preview.name if preview else "",
"preview_stream_nickname": preview.nickname if preview else "",
"preview_stream_forward_state": preview.forward_state if preview else 0,
"install_x": camera.install_x, "install_y": camera.install_y, "install_z": camera.install_z, "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, "yaw_deg": camera.yaw_deg, "pitch_deg": camera.pitch_deg,
"calibration": ({ "calibration": ({
@ -103,7 +110,7 @@ def _config_data(site):
"observation_window_sec": site.observation_window_sec, "observation_window_sec": site.observation_window_sec,
"max_speed_mps": site.max_speed_mps, "lost_ttl_sec": site.lost_ttl_sec, "max_speed_mps": site.max_speed_mps, "lost_ttl_sec": site.lost_ttl_sec,
"trail_sec": site.trail_sec}, "trail_sec": site.trail_sec},
"cameras": [_camera_dict(c) for c in site.cameras.select_related("stream")], "cameras": [_camera_dict(c) for c in site.cameras.select_related("stream", "preview_stream")],
"control_points": points, "streams": streams, "algorithms": detectors, "control_points": points, "streams": streams, "algorithms": detectors,
"runtime": get_runtime_manager().snapshot(), "runtime": get_runtime_manager().snapshot(),
} }
@ -159,6 +166,11 @@ def open_save_config(request):
for row in params.get("cameras") or []: for row in params.get("cameras") or []:
camera = site.cameras.get(id=int(row["id"])) camera = site.cameras.get(id=int(row["id"]))
camera.stream = StreamModel.objects.filter(id=int(row.get("stream_id") or 0)).first() camera.stream = StreamModel.objects.filter(id=int(row.get("stream_id") or 0)).first()
camera.preview_stream = StreamModel.objects.filter(
id=int(row.get("preview_stream_id") or 0)
).first()
if camera.stream_id and camera.preview_stream_id == camera.stream_id:
raise ValueError("摄像头 %d 的预览子码流不能与定位/标定流相同" % camera.slot)
camera.display_name = str(row.get("display_name") or "摄像头 %d" % camera.slot)[:100] camera.display_name = str(row.get("display_name") or "摄像头 %d" % camera.slot)[:100]
camera.enabled = bool(row.get("enabled", True)) camera.enabled = bool(row.get("enabled", True))
camera.install_x = float(row.get("install_x", 0)) camera.install_x = float(row.get("install_x", 0))
@ -209,6 +221,19 @@ def open_control_point_delete(request):
return _reply(False, msg=str(exc)) return _reply(False, msg=str(exc))
def _ensure_stream_online(stream, timeout=12.0):
"""标定抓帧前确保流在 ZLM 在线:未在线时触发拉流/GB28181 点播并轮询等待。"""
from app.utils.GlobalUtils import g_zlm, GlobalUtils
if not g_zlm.getMediaInfo(app=stream.app, name=stream.name):
GlobalUtils.addStreamProxy(stream)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if g_zlm.getMediaInfo(app=stream.app, name=stream.name):
return True
time.sleep(0.5)
return False
def open_capture(request): def open_capture(request):
ok, msg = f_checkRequestSafe(request) ok, msg = f_checkRequestSafe(request)
if request.method != "POST" or not ok: if request.method != "POST" or not ok:
@ -223,6 +248,8 @@ def open_capture(request):
url = AnalysisManager.build_rtsp_url(camera.stream) url = AnalysisManager.build_rtsp_url(camera.stream)
if not url: if not url:
raise ValueError("无法生成摄像头 RTSP 地址") raise ValueError("无法生成摄像头 RTSP 地址")
if not _ensure_stream_online(camera.stream):
raise ValueError("视频流拉起失败,请确认摄像头在线")
cap = cv2.VideoCapture(url, cv2.CAP_FFMPEG) cap = cv2.VideoCapture(url, cv2.CAP_FFMPEG)
frame = None frame = None
deadline = time.monotonic() + 8.0 deadline = time.monotonic() + 8.0