// ==UserScript== // @name BLY预标注验收 // @namespace https://label.bilinyun.net // @version 1.0 // @description 比邻云预标注验收统计:一键复制整行「包号 / 领取日期(空) / 问题框-总框 / 问题点-总点 / 问题帧-总帧 / 框准确率 / 点准确率 / 帧准确率 / 问题描述」 // @author CC // @match https://label.bilinyun.net/* // @grant none // @run-at document-start // @license CC // ==/UserScript== (function () { 'use strict'; const API_INSPECT = '/api/inspect'; const API_STATIC = '/api/mark/contents/static'; const API_MARK = '/api/mark/image'; /* ==================== 鉴权捕获 ==================== */ let AUTH = null; function grabAuth(headers) { try { if (headers instanceof Headers) { if (headers.has('Blade-Auth')) { AUTH = { 'Blade-Auth': headers.get('Blade-Auth'), 'Tenant-Id': localStorage.getItem('tenantId') || '' }; } } else if (headers && typeof headers === 'object') { const v = headers['Blade-Auth'] || headers['blade-auth']; if (v) AUTH = { 'Blade-Auth': v, 'Tenant-Id': localStorage.getItem('tenantId') || '' }; } } catch (e) { } } const origSetHeader = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.setRequestHeader = function (k, v) { if (!AUTH && /^blade-auth$/i.test(k)) { AUTH = { 'Blade-Auth': v, 'Tenant-Id': localStorage.getItem('tenantId') || '' }; } return origSetHeader.apply(this, arguments); }; const origFetch = window.fetch; if (origFetch) { window.fetch = function (input, init) { if (!AUTH && init && init.headers) grabAuth(init.headers); return origFetch.apply(this, arguments); }; } /* ==================== 工具 ==================== */ function qs(name) { const m = new RegExp('[?&]' + name + '=([^&]+)').exec(location.search || ''); return m ? m[1] : null; } function fallbackQuery(key) { try { const q = window.editor && window.editor.bsState && window.editor.bsState.query; return q ? q[key] : null; } catch (e) { return null; } } function stripHtml(s) { return String(s == null ? '' : s).replace(/<[^>]*>/g, '').replace(/ /g, ' '); } function pct(bad, total) { if (!total) return '-'; return (((total - bad) / total) * 100).toFixed(2) + '%'; } async function apiGet(url) { if (!AUTH) throw new Error('未捕获到鉴权信息,请先在工作台切一次帧再点复制'); const r = await origFetch.call(window, url, { headers: AUTH }); const j = await r.json(); if (j.code !== 200 && j.code !== 0) throw new Error((j.msg || '请求失败') + ' [' + j.code + ']'); return j.data; } // 解析 projectClass:类目 id + 关键点名称映射 function parseClass(projectClass) { let pc = projectClass; if (typeof pc === 'string') { try { pc = JSON.parse(pc); } catch (e) { return null; } } if (!pc || !pc.id) return null; const out = { faceId: null, kpId: null, kpAttrId: null, kpNames: {} }; const ids = pc.id || [], exps = pc.exportName || [], attrs = pc.attributes || []; ids.forEach(function (id, i) { if (exps[i] === 'Face') out.faceId = id; if (exps[i] === 'face5') { out.kpId = id; (attrs[i] || []).forEach(function (a) { if (a.exportName === 'keypoint') { out.kpAttrId = a.id; (a.attributes || []).forEach(function (v) { out.kpNames[v.id] = v.name || v.exportName; }); } }); } }); return (out.faceId && out.kpId) ? out : null; } /* ==================== 核心计算 ==================== */ let RESULT = null; async function compute() { const tid = qs('recordId') || fallbackQuery('recordId'); const pid = qs('projectId') || fallbackQuery('projectId'); if (!tid) throw new Error('URL 里没有 recordId,请在题包工作台使用'); // 1) 帧列表 + 包号 + 类目 const st = await apiGet(API_STATIC + '?task_id=' + tid); const frames = (st.image2dList || []).map(function (f) { return f.frameId; }); const taskNo = (st.projectInfo && st.projectInfo.taskNo) || '未知包号'; const cmap = parseClass(st.projectInfo && st.projectInfo.projectClass); if (!cmap) throw new Error('未取到项目类目配置'); if (!pid) throw new Error('URL 里没有 projectId'); // 2) 批注列表(不带 pcdId 才返回全包) const ins = await apiGet(API_INSPECT + '?projectId=' + pid + '&taskId=' + tid + '&statuses[]=1&statuses[]=2¤t=0&size=500'); const remarks = ins.records || []; // 3) 逐帧取标注,建立 (帧#labelId) -> {kind, name},并统计总框/总点 const objMap = {}; let totalFace = 0, totalKp = 0; for (let i = 0; i < frames.length; i++) { const d = await apiGet(API_MARK + '?frame_id=' + frames[i]); (d.shapeList || []).forEach(function (s) { let a; try { a = JSON.parse(s.attrJson || '{}'); } catch (e) { return; } if (a.type_id === cmap.faceId) { objMap[frames[i] + '#' + s.labelId] = { kind: 'face', name: '人脸框' }; totalFace++; } else if (a.type_id === cmap.kpId) { const kv = (a.attributes || {})[cmap.kpAttrId]; objMap[frames[i] + '#' + s.labelId] = { kind: 'kp', name: cmap.kpNames[kv] || '关键点' }; totalKp++; } }); } // 4) 统计问题对象 / 问题帧(均按对象、按帧去重) const badFace = new Set(), badKp = new Set(), badFrame = new Set(); const descItems = []; remarks.forEach(function (r) { const fid = String(r.pcdId); const key = fid + '#' + r.boxId; const info = objMap[key]; if (info) { if (info.kind === 'face') badFace.add(key); else if (info.kind === 'kp') badKp.add(key); } badFrame.add(fid); descItems.push({ frameNo: frames.indexOf(fid) + 1, name: info ? info.name : '未知对象', ts: r.createTime || '', text: stripHtml(r.remark) }); }); // 5) 问题描述:按帧号升序、同帧按创建时间升序 descItems.sort(function (a, b) { if (a.frameNo !== b.frameNo) return a.frameNo - b.frameNo; return String(a.ts).localeCompare(String(b.ts)); }); // 每条一行(同一单元格内换行):非末条以「;」结尾,末条以「。」结尾 const desc = descItems.map(function (d, i) { const isLast = (i === descItems.length - 1); return (i + 1) + '.第' + d.frameNo + '帧' + d.name + ':' + d.text + (isLast ? '。' : ';'); }).join('\n'); // 表格字段:含换行必须用双引号包裹,Excel / 腾讯文档才会认作「同一单元格内的换行」 const descCell = desc ? '"' + desc.replace(/"/g, '""') + '"' : ''; const totalFrame = frames.length; RESULT = { taskNo: taskNo, badFace: badFace.size, totalFace: totalFace, badKp: badKp.size, totalKp: totalKp, badFrame: badFrame.size, totalFrame: totalFrame, accFace: pct(badFace.size, totalFace), accKp: pct(badKp.size, totalKp), accFrame: pct(badFrame.size, totalFrame), desc: desc, remarkCount: remarks.length }; RESULT.line = [ RESULT.taskNo, '', // 领取日期留空 RESULT.badFace + '/' + RESULT.totalFace, RESULT.badKp + '/' + RESULT.totalKp, RESULT.badFrame + '/' + RESULT.totalFrame, RESULT.accFace, RESULT.accKp, RESULT.accFrame, descCell ].join('\t'); return RESULT; } /* ==================== 悬浮窗 ==================== */ let panel = null, bodyEl = null, statusEl = null; function createPanel() { if (panel) return; panel = document.createElement('div'); panel.id = '_bly_chk_panel'; panel.style.cssText = 'position:fixed;z-index:999996;width:360px;background:#fff;border:1px solid #d1d5db;' + 'border-radius:10px;box-shadow:0 4px 20px rgba(0,0,0,0.15);overflow:hidden;left:420px;top:60px;' + 'font-family:"Microsoft YaHei","PingFang SC",Arial,sans-serif;font-size:12px;color:#1f2937;user-select:none;'; const bar = document.createElement('div'); bar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:8px 12px;' + 'background:#f3f4f6;border-bottom:1px solid #d1d5db;cursor:move;'; bar.innerHTML = '📊 BLY预标注验收'; const closeBtn = document.createElement('span'); closeBtn.textContent = '✕'; closeBtn.style.cssText = 'width:24px;height:24px;display:flex;align-items:center;justify-content:center;' + 'border-radius:6px;cursor:pointer;font-weight:bold;background:#fca5a5;color:#374151;'; closeBtn.addEventListener('click', function () { panel.remove(); panel = null; }); bar.appendChild(closeBtn); panel.appendChild(bar); // 拖动 let dragging = false, ox = 0, oy = 0; bar.addEventListener('mousedown', function (e) { if (e.button !== 0) return; dragging = true; const r = panel.getBoundingClientRect(); ox = e.clientX - r.left; oy = e.clientY - r.top; e.preventDefault(); }); document.addEventListener('mousemove', function (e) { if (!dragging) return; panel.style.left = Math.max(0, e.clientX - ox) + 'px'; panel.style.top = Math.max(0, e.clientY - oy) + 'px'; }); document.addEventListener('mouseup', function () { dragging = false; }); bodyEl = document.createElement('div'); bodyEl.style.cssText = 'padding:10px 12px;'; panel.appendChild(bodyEl); document.body.appendChild(panel); renderIdle(); } function renderIdle() { if (!bodyEl) return; let h = '