// ==UserScript== // @name OD 2D框超出图片范围校验 // @namespace https://github.com/swj/od-box-check // @version 1.3.0 // @description 校验标注图中 2D 框是否超出图片范围(越界 / 被裁剪),提示并精准定位到具体是哪一个框越界 // @author swj // @match *://*/* // @run-at document-start // @grant unsafeWindow // @grant GM_addStyle // ==/UserScript== /** * OD 2D框超出图片范围校验 author: swj * =========================================================================== * 【v1.3.0 重要变更:数据源换成平台运行时 store,并补齐"分辨率"这一环】 * * 为什么 v1.2.0 在实际页面上"校验不出东西": * ① 框的图片分辨率【并不写在每条 2D 框上】,而是放在【相机配置】里 * (frame.slave_info[cameraKey][0].resolution)。平台里手工画的矩形框 * 只有 { id, type, camera, points, visual_type, attrs }, * 根本没有 resolution 字段 —— 于是 v1.2.0 把每一个框都当成"缺分辨率"跳过, * 面板却显示"已校验全部 N 个 2D 框:无越界",看起来就像校验没生效。 * ② 抓接口响应要靠 URL 关键字匹配(pack_mark_url 由服务端下发,命名随部署变化), * 抓不到就只能退化到"像素兜底",而平台默认只画选中的框。 * 现在的做法: * · 首选【直接读平台运行时数据】:window.instance.StoreManager.marksState().marks * —— 页面自己渲染用的就是这份数据,最全、最实时(含未保存的改动); * · 分辨率按 mark.resolution → 相机配置(frame.slave_info)→ 页面图片 逐级兜底; * · 接口响应捕获保留为备选(改为按【内容】识别,不再依赖 URL 关键字); * · 像素兜底保留为最后手段。 * 全程【只读】:只读 state 与 getter,不提交任何 mutation,不改平台数据。 * * 数据结构(来自平台前端产物): * marks[trackId] = { class, "2d": { frameId: [ { id, type, camera, points, visual_type } ] } } * points = rect 为 [left, top, right, bottom];cuboid_2d 为 8 点;line/polygon 为 N 点 * 单位都是【原图像素】 * 分辨率 = 相机配置 frame.slave_info[相机][0].resolution,形如 "1920*1080" * 判定:left < 0 或 top < 0 或 right > 宽 或 bottom > 高 → 越界(给出精确越界像素数) * =========================================================================== * 【像素兜底的原理】 * 平台把 2D 框的数据点(points = [left, top, right, bottom])以「原图像素」为单位保存, * 绘制时把图片按比例居中贴进画布(letterbox),框按同一比例画上去,超出部分被裁掉。 * 于是有两条不变量: * 1) 图片矩形在画布内一定撑满一个方向 —— 要么左右顶到画布边,要么上下顶到画布边; * 2) 框的坐标只要 < 0 或 > 图片宽高,画出来必然被裁断、并贴死在【图片边界】上。 * 所以「框线贴死在图片边界」等价于「该框超出图片范围」,被裁掉的像素数就是越界量。 * * 脚本按这个不变量工作,全程只读画布像素,不改动平台数据: * ① 先量出图片矩形(letterbox 黑边宽度); * ② 再只扫图片边界内外各几个像素的窄条,找出贴边的框线; * ③ 把线段还原成框,逐个报出越界方向与越界像素数,并在画布上闪烁定位。 * * 【为什么读像素而不是读接口】 * 接口数据是 gzip 包、坐标语义随类型与版本变化;画布像素是最终事实 —— * 框只要超出图片,画布上一定看得见它被切断的那条边。 * * 【画布类型:2D 与 WebGL 都要能读,但绝不能"试探"类型】 * 平台的标注画布可能是 2D canvas(getImageData 可读), * 也可能是 three.js 的 WebGL canvas —— WebGL 画布没有 getImageData, * 必须用 gl.readPixels 读;而且它默认 preserveDrawingBuffer=false, * 合成后缓冲会被清空。因此脚本在 document-start 给 HTMLCanvasElement.getContext * 打补丁、强制 WebGL 上下文开启 preserveDrawingBuffer,并留下类型标记。 * * ⚠ 绝对不能用 getContext('webgl') 去"试探"画布类型: * 画布一旦创建了某种上下文就无法再创建另一种,在 2D 画布上试探 WebGL * 会永久占用它,导致平台自己的渲染器报 * THREE.WebGLRenderer: A WebGL context could not be created. * 把整个标注界面搞挂(v1.1.0 踩过这个坑,v1.1.2 已修)。 * 现在只读补丁留下的标记,或退回 getContext('2d')(在 WebGL 画布上只返回 null,不占画布)。 * * 【能测什么、测不到什么(务必先读)】 * 能可靠检出: * · 框被图片边界裁断的情况 —— 包括单侧越界、角部两向越界、被裁到只剩一条边的框; * · 并给出「哪一侧被裁断」「越界的是哪种颜色的框」「在哪个画布上」,可直接定位。 * 测不到: * · ② 框的竖边整体落在图片内部、只有一条平行边越界的框(例如只越过下边缘、 * 而左右竖边都在图片中间)—— 此时画布上只剩一条贴边的短线段, * 没有闭合矩形的证据,任何像素法都无法与图片自身的边缘纹理区分; * · ① 整体跑到图片外的框(画布上完全没有像素)。 * · 另外,被裁掉【多少】像素在渲染后不可恢复(钳制是有损的,坐标为 -40 与 0 * 的框画出来一样),所以脚本只报“哪一侧被裁断”,不编造越界像素数。 * · 上述两种情况会在面板上如实提示,请配合人工复核。 */ (function () { 'use strict' /* ======================== 关键前置补丁(必须在页面脚本之前执行) ======================== */ /** * 给 HTMLCanvasElement.getContext 打一个"只观察、不干预"的补丁: * * ① 记录每块画布【真正被创建过】的上下文类型(__odboxType), * 从此不再需要靠 getContext('webgl') 去"试探" —— 那是致命写法, * 画布一旦创建了某种上下文就不能再创建另一种,试探会把画布占死, * 导致平台自己的渲染器报 "Canvas has an existing context of a different type"。 * ② 给 WebGL 上下文补上 preserveDrawingBuffer=true:three.js 默认不保留绘制缓冲, * 合成后缓冲被清空,readPixels 只能读到一片空白。 * * 补丁自身绝不允许干扰页面:任何异常都吞掉并原样透传给原生 getContext。 */ const CANVAS_CTX_LOG = [] // 诊断用:记录每块画布创建过的上下文类型 function patchGetContext() { try { const proto = typeof HTMLCanvasElement !== 'undefined' && HTMLCanvasElement.prototype if (!proto || proto.__odboxPatched) return const raw = proto.getContext proto.getContext = function (type) { const args = Array.prototype.slice.call(arguments) try { const t = String(type || '').toLowerCase() if (t === 'webgl' || t === 'webgl2' || t === 'experimental-webgl') { const opts = Object.assign({}, args[1] || {}, { preserveDrawingBuffer: true }) args[1] = opts } const ctx = raw.apply(this, args) try { if (ctx) { this.__odboxType = t if (CANVAS_CTX_LOG.length < 60) { CANVAS_CTX_LOG.push({ t: t, w: this.width, h: this.height, id: this.id || '', cls: String(this.className || '').slice(0, 40), }) } } } catch (e) { /* 记录失败不影响返回值 */ } return ctx } catch (e) { // 理论上不会走到;真走到了也必须把原生调用原样再来一次 try { return raw.apply(this, Array.prototype.slice.call(arguments)) } catch (e2) { return null } } } proto.__odboxPatched = true } catch (e) { // 补丁失败不影响脚本其余部分 } } patchGetContext() /** * 再打一个"只观察、不干预"的补丁:CanvasRenderingContext2D.drawImage。 * * 为什么需要它:平台把相机图片按 * ctx.drawImage(img, x, y, width, height) * 画到相机画布上(DragImg.paint),而"原图坐标 → 画布坐标"的换算就是 * canvasX = x + px * (width / 图片宽) * 这里的 (x, y, width, height) 会随着【图片放大/缩小/拖动】变化 * (DragImg.resize:x、y、width、height 一起改), * 所以缩放之后再用"等比贴合画布"去推算位置就会偏。 * * 直接把平台自己用的这组数抓下来,缩放多少都跟得上一模一样。 * 补丁自身不改任何绘制行为,异常一律吞掉。 */ const IMG_DRAW = new WeakMap() // 相机画布 -> 最近一次底图绘制几何 { x, y, w, h } function patchDrawImage() { try { const proto = typeof CanvasRenderingContext2D !== 'undefined' && CanvasRenderingContext2D.prototype if (!proto || proto.__odboxPatchedDrawImage) return const raw = proto.drawImage if (typeof raw !== 'function') return proto.drawImage = function () { try { const canvas = this.canvas if (canvas && canvas.classList && canvas.classList.contains('camera-canvas')) { const a = arguments const src = a[0] const n = a.length let dx let dy let dw let dh if (n <= 3) { // drawImage(img, dx, dy) dx = Number(a[1]) || 0 dy = Number(a[2]) || 0 dw = (src && (src.naturalWidth || src.width)) || 0 dh = (src && (src.naturalHeight || src.height)) || 0 } else if (n <= 5) { // drawImage(img, dx, dy, dw, dh) ← 平台用的就是这种 dx = Number(a[1]) || 0 dy = Number(a[2]) || 0 dw = Number(a[3]) || 0 dh = Number(a[4]) || 0 } else { // drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh) dx = Number(a[5]) || 0 dy = Number(a[6]) || 0 dw = Number(a[7]) || 0 dh = Number(a[8]) || 0 } // 只认"底图级别"的绘制:面积够大,避免把图标、纹理也记进来 if (dw > 0 && dh > 0 && dw * dh >= canvas.width * canvas.height * 0.2) { IMG_DRAW.set(canvas, { x: dx, y: dy, w: dw, h: dh, at: Date.now() }) } } } catch (e) { /* 观察失败不影响绘制 */ } return raw.apply(this, arguments) } proto.__odboxPatchedDrawImage = true } catch (e) { /* ignore */ } } patchDrawImage() /* ======================== 用户配置区 ======================== */ const CONFIG = { // 贴着图片边界扫描的窄条宽度(像素)。 // 取 6 而不是 4:边界定位有 1~2 像素的正常误差, // 而"框线被裁断后紧贴边界"的位置正好落在边界附近,带子太窄会漏检。 edgeBand: 6, // 从画布边缘往里找图片边界时最多看这么深(够覆盖常见 letterbox 黑边) maxContentInset: 80, // 判定为实心黑边条带所需的最小厚度(像素) minPadDepth: 2, // 定位图片边界时,认定的「底色 → 图片内容」最小色差跳变(三通道之和) boundaryJump: 30, // 定位图片边界时,每条边采样多少条线来投票 boundaryLines: 10, // 判定「条带之外是图片内容」所需的相邻像素平均色差下限 innerVarianceMin: 8, // 单条框线的最大笔画宽度(像素):比这更粗的一整片同色区域不算框线 maxStrokeWidth: 8, // 认定为一条有效框线的最小长度(像素):过滤选中点、十字准星等小图元 minBoxSide: 10, // 颜色匹配容差(0-255) colorTolerance: 26, // 更严的「框线色」容差:框线是纯色直线,颜色必须基本恒定 strokeColorTolerance: 12, // 单条框线上允许的断口长度(像素),用于跨越文字标签遮挡 maxGap: 4, // 拾取框线颜色时,每条边最多尝试多少个候选色 maxSeedColors: 64, // 每条边统计候选色时最多保留多少个不同颜色(防止噪声图把内存撑爆) maxTallyColors: 600, // 底色排除:某个颜色几乎铺满整条边时视为背景,不作为框线 maxEdgeCoverage: 0.82, // 自动巡检间隔(毫秒),0 表示只手动检查 autoScanInterval: 2500, // 自动巡检默认是否开启 autoScanDefault: true, // 定位高亮持续时间(毫秒) highlightDuration: 3000, // 优先读平台运行时数据(window.instance.StoreManager)。关掉就只靠接口响应 / 画布像素。 usePlatformStore: true, // 默认只看当前帧(标注/验收时按帧走,越界框跟着当前帧走); // false = 列出整包的越界框。面板底部随时可切换。 currentFrameOnly: true, // 是否把"预测框"(virtual_type=2 的虚拟框)也算进越界校验 includeVirtual: true, // 越界判定容差(像素):小于它不算越界,避免 0.0001 这类浮点噪声 overEpsilon: 0.01, } /* ============================================================ */ const LOG_PREFIX = '[OD框校验]' const VERSION = '1.3.0' const PANEL_ID = 'odboxcheck-panel' const SCAN_DEBOUNCE = 260 const state = { results: [], autoScan: CONFIG.autoScanDefault, timer: null, scanTimer: null, scanning: false, panelEl: null, listEl: null, summaryEl: null, statEl: null, vtextEl: null, lastScan: null, // 从接口响应里抓到的标注数据(全部框,不依赖是否选中) apiMarks: null, apiMarksAt: null, apiMarksUrl: '', marksReason: '', // 本次巡检用的数据源与统计 dataSource: '', storeSeen: false, currentFrameId: null, // 面板上的过滤开关(只看当前帧) currentFrameOnly: CONFIG.currentFrameOnly, highlightTimers: new Map(), } /* ================= 数据源 ①:平台运行时 store(首选,最全最实时) ================= */ /** * 页面 window。油猴 / ScriptCat 带 @grant 时脚本跑在沙箱里, * 必须用 unsafeWindow 才摸得到平台挂在 window 上的运行时句柄(window.instance)。 */ function pageWin() { try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow) return unsafeWindow } catch (e) { /* ignore */ } return window } /** * 取平台的 StoreManager(只读)。两条路: * ① window.instance.StoreManager —— 平台自己的运行时句柄(Instance 构造时就挂上了); * ② 兜底:Vuex 挂在 Vue3 应用实例上的 $store * (#app.__vue_app__.config.globalProperties.$store)。 * 返回 null = 这一页没有平台 store(不是标注页,或平台还没初始化完)。 */ function platformStore() { try { const w = pageWin() const inst = w && w.instance const sm = inst && inst.StoreManager if (sm && typeof sm.marksState === 'function') return sm } catch (e) { /* ignore */ } try { const el = document.getElementById('app') const app = el && el.__vue_app__ const st = app && app.config && app.config.globalProperties && app.config.globalProperties.$store if (st && st.state) { return { __vuex: st, marksState: function () { return st.state.marks }, configState: function () { return st.state.config }, statusState: function () { return st.state.status }, } } } catch (e) { /* ignore */ } return null } /** 平台数据里全部 marks(只读);拿不到返回 null */ function platformMarks(sm) { try { const s = sm && sm.marksState() if (!s || typeof s !== 'object') return null const m = s.marks return m && typeof m === 'object' ? m : null } catch (e) { return null } } function storeCount(obj) { if (!obj) return 0 let n = 0 for (const k in obj) { void k n++ } return n } /** 当前帧号(status.curFrameId),拿不到返回 null */ function currentFrameId(sm) { try { const s = sm && sm.statusState() const id = s && s.curFrameId return id === undefined || id === null ? null : String(id) } catch (e) { return null } } /** * 平台是否已经把标注数据加载完(status.isMarksObtained)。 * 用来区分"整包确实没有框"和"数据还在路上"—— 后者不能报成"无越界"。 */ function marksObtained(sm) { try { const s = sm && sm.statusState() return s && s.isMarksObtained ? true : false } catch (e) { return null } } /** * 该帧的各相机配置:frame.slave_info = { 相机: [ { resolution: "1920*1080", url, ... } ] }。 * ★ 分辨率就在这里 —— 2D 框本身不带 resolution,必须从这里取。 */ function camerasOfFrame(sm, frameId) { if (!sm || frameId === undefined || frameId === null) return null try { const cfg = sm.configState() if (!cfg) return null const frames = cfg.frames || [] const ids = cfg.frameIds || {} let frame = null if (ids[frameId] !== undefined) frame = frames[ids[frameId]] if (!frame) { for (let i = 0; i < frames.length; i++) { const f = frames[i] if (f && String(f.task_id) === String(frameId)) { frame = f break } } } return frame ? frame.slave_info || null : null } catch (e) { return null } } /** 相机显示名:config.params.camera[相机] */ function cameraNameOf(sm, cameraKey) { if (!sm || !cameraKey) return '' try { const cfg = sm.configState() const p = cfg && cfg.params const c = p && p.camera return (c && c[cameraKey]) || '' } catch (e) { return '' } } /* ================= 数据源 ②:接口响应(按内容识别,不依赖 URL) ================= */ /** * 为什么不只按 URL 关键字抓:标注数据的下载地址(pack_mark_url)由服务端下发, * 命名随部署变化,靠关键字过滤必然有漏网的。 * 这里改成【按内容识别】:响应体里出现 "marks" / '"2d"' 这类字段名才尝试解析 —— * 既不会误吞无关响应,也不会因为命名不同而漏掉。 * URL 关键字只用来决定"解析失败时要不要记录原因"。 */ const DATA_URL_HINT = /pcl-mark-data|mark-data|marks|pack_mark/i const MAX_SNIFF_BYTES = 64 * 1024 * 1024 function looksLikeMarks(v) { if (!v || typeof v !== 'object') return false if (Array.isArray(v)) return false // marks 形如 { trackId: { class, "2d": { frameId: [ {points, camera} ] } } } const keys = Object.keys(v) if (!keys.length || keys.length > 20000) return false for (let i = 0; i < Math.min(keys.length, 12); i++) { const item = v[keys[i]] if (item && typeof item === 'object' && (item['2d'] || item['3d'])) return true } return false } /** 从一个对象里挖出 marks(兼容 {marks}、{data:{marks}}、{mark_data:{marks}} 等包装) */ function pickMarks(obj) { if (!obj || typeof obj !== 'object') return null if (looksLikeMarks(obj)) return obj const wraps = ['marks', 'mark_data', 'data', 'result', 'payload'] for (let i = 0; i < wraps.length; i++) { const v = obj[wraps[i]] if (!v || typeof v !== 'object') continue if (looksLikeMarks(v)) return v const inner = v.marks if (looksLikeMarks(inner)) return inner const inner2 = v.mark_data && v.mark_data.marks if (looksLikeMarks(inner2)) return inner2 } return null } function absorbMarks(marks, url) { if (!marks) return false state.apiMarks = marks state.apiMarksAt = new Date() state.apiMarksUrl = url || '' state.marksReason = '' return true } /** 响应文本 → marks。返回是否吸收成功 */ function absorbResponseText(text, url) { if (!text || typeof text !== 'string' || text.length < 20) return false // 先做一次廉价的字段名嗅探,避免把无关的大 JSON 也拿去 JSON.parse if (text.indexOf('"marks"') < 0 && text.indexOf('"mark_data"') < 0 && text.indexOf('"2d"') < 0) return false let obj = null try { obj = JSON.parse(text) } catch (e) { if (url && DATA_URL_HINT.test(url)) state.marksReason = '响应不是 JSON,无法解析(' + url + ')' return false } const ok = absorbMarks(pickMarks(obj), url) if (!ok && url && DATA_URL_HINT.test(url)) { const payload = obj && obj.data !== undefined ? obj.data : obj state.marksReason = '响应里没找到 marks 结构(顶层字段:' + Object.keys(payload || {}).slice(0, 12).join(',') + ')' } return ok } /** * 响应体 → 文本。 * * ★ 判据只看【字节里的 gzip 魔数】,不能看 Content-Encoding 头: * 服务器带 Content-Encoding: gzip 时,浏览器已经自动解压过一次, * response/responseText 拿到的就是明文 —— 再按包头解压必然失败 * (这正是接口兜底路径抓不到数据的常见原因)。 * 优先用原生 DecompressionStream,退化到页面自带的 pako。 */ async function decodeBody(buf, encoding, url) { const enc = String(encoding || '').toLowerCase() void enc const isGzip = buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b if (!isGzip) { try { return new TextDecoder('utf-8').decode(buf) } catch (e) { return '' } } try { if (typeof DecompressionStream === 'function') { const ds = new DecompressionStream('gzip') const stream = new Blob([buf]).stream().pipeThrough(ds) return await new Response(stream).text() } } catch (e) { /* 落到 pako */ } try { const w = pageWin() const pako = w.pako || (w.JSZip && w.JSZip.pako) if (pako && typeof pako.ungzip === 'function') { return pako.ungzip(new Uint8Array(buf), { to: 'string' }) } } catch (e) { /* ignore */ } state.marksReason = '响应是 gzip,但本环境无法解压(' + url + ')' return '' } /** 把一份响应(各种 responseType)喂给解析器 */ function feedXhrResponse(xhr) { const url = xhr.__odboxUrl || '' try { const rt = xhr.responseType if (rt === 'json') { absorbMarks(pickMarks(xhr.response), url) return } if (rt === 'blob' && xhr.response) { const fr = new FileReader() fr.onload = function () { decodeBody(fr.result, xhr.getResponseHeader('Content-Encoding'), url).then((text) => absorbResponseText(text, url) ) } fr.readAsArrayBuffer(xhr.response) return } if (rt === 'arraybuffer' && xhr.response) { const body = xhr.response if (body.byteLength > MAX_SNIFF_BYTES) return decodeBody(body, xhr.getResponseHeader('Content-Encoding'), url).then((text) => absorbResponseText(text, url) ) return } // rt === '' / 'text' / 'document' const text = xhr.responseText if (text && text.length > MAX_SNIFF_BYTES) return absorbResponseText(text, url) } catch (e) { if (DATA_URL_HINT.test(url)) state.marksReason = '解析响应失败:' + ((e && e.message) || e) } } function installDataHook() { try { const XO = pageWin().XMLHttpRequest || XMLHttpRequest if (XO && XO.prototype && !XO.prototype.__odboxHooked) { const rawOpen = XO.prototype.open const rawSend = XO.prototype.send XO.prototype.open = function (method, url) { try { this.__odboxUrl = String(url || '') } catch (e) { /* ignore */ } return rawOpen.apply(this, arguments) } XO.prototype.send = function () { try { const self = this self.addEventListener('load', function () { feedXhrResponse(self) }) } catch (e) { /* ignore */ } return rawSend.apply(this, arguments) } XO.prototype.__odboxHooked = true } } catch (e) { /* ignore */ } // 有些部署用 fetch 拉标注数据,一并挂上 try { const w = pageWin() const rawFetch = w.fetch if (typeof rawFetch === 'function' && !w.__odboxFetchHooked) { w.__odboxFetchHooked = true w.fetch = function (input, init) { const url = (input && (input.url || input)) || '' const p = rawFetch.apply(this, arguments) try { p.then((res) => { try { const ct = String((res.headers && res.headers.get('content-type')) || '') if (ct && ct.indexOf('json') < 0 && ct.indexOf('text') < 0 && ct.indexOf('gzip') < 0) return res .clone() .text() .then((text) => absorbResponseText(text, String(url))) .catch(() => null) } catch (e) { /* ignore */ } }) } catch (e) { /* ignore */ } return p } } } catch (e) { /* ignore */ } } installDataHook() /* ======================== 通用小工具 ======================== */ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) /** 把 RGB 压成一个整数,便于做 Map 的键 */ function rgbKey(r, g, b) { return (r << 16) | (g << 8) | b } function keyToCss(key) { return 'rgb(' + ((key >> 16) & 255) + ',' + ((key >> 8) & 255) + ',' + (key & 255) + ')' } /** 分通道近似比较颜色 */ function colorNear(key, r, g, b, tol) { return ( Math.abs(((key >> 16) & 255) - r) <= tol && Math.abs(((key >> 8) & 255) - g) <= tol && Math.abs((key & 255) - b) <= tol ) } /** 把已排序的数字数组切成连续区间,允许一定断口 */ function toRuns(sorted, maxGap) { const runs = [] let cur = null for (let i = 0; i < sorted.length; i++) { const v = sorted[i] if (cur === null) cur = { start: v, end: v, count: 1 } else if (v - cur.end <= maxGap + 1) { cur.end = v cur.count++ } else { runs.push(cur) cur = { start: v, end: v, count: 1 } } } if (cur) runs.push(cur) return runs } /* ======================== 画布像素读取 ======================== */ /* ======================== 画布像素读取(2D / WebGL 双通道) ======================== */ /** * 读取画布像素。这一步是整个校验的地基,必须同时支持两种画布: * · 2D 画布(平台用 DrawImg 把图片和 2D 框画在 2D canvas 上)→ getImageData; * · WebGL 画布(three.js 渲染的点云/图像画面)→ gl.readPixels。 * WebGL 画布没有 getImageData,用 2D 接口去读会直接失败, * 这正是「画布存在但像素不可读」的根因。 * * 另外 WebGL 的绘制缓冲默认在合成后被清空,脚本会在 document-start 给 * getContext 打补丁、强制 preserveDrawingBuffer=true,保证 readPixels 拿得到当帧内容。 */ const readDiag = { detail: '' } function makePixels(w, h, data, source) { return { w, h, data, source: source || '2d' } } /** * 用 WebGL 接口读(three.js 渲染的画布)。 * 只有在【已经确定画布就是 WebGL】时才会走到这里, * 因此这次 getContext('webgl'/'webgl2') 只是取回页面自己已创建的上下文,不会新占用画布。 */ function readViaWebGL(canvas, w, h) { let gl = null try { gl = canvas.getContext('webgl2') || canvas.getContext('webgl') || canvas.getContext('experimental-webgl') } catch (e) { readDiag.detail = '取 WebGL 上下文异常:' + (e && e.message) return null } if (!gl || typeof gl.readPixels !== 'function') { readDiag.detail = '没有 WebGL 上下文' return null } try { const buf = new Uint8Array(w * h * 4) gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf) // WebGL 的原点在左下角,这里翻成与 2D 一致的“左上角原点” const out = new Uint8ClampedArray(w * h * 4) const rowBytes = w * 4 for (let y = 0; y < h; y++) { const src = (h - 1 - y) * rowBytes out.set(buf.subarray(src, src + rowBytes), y * rowBytes) } // 绘制缓冲被清空时读到的会是一整片全透明,视为不可读 let ink = 0 for (let i = 3; i < out.length; i += 4 * 97) if (out[i] > 8) ink++ if (!ink) { readDiag.detail = 'WebGL readPixels 返回空(绘制缓冲已被清空)' return null } return makePixels(w, h, out, 'webgl') } catch (e) { readDiag.detail = 'WebGL readPixels 失败:' + (e && e.name) + '(跨域污染或上下文丢失)' return null } } /** * 统一入口:按画布【实际已经创建过的】上下文类型来读像素。 * * ⚠ 绝对不能用 getContext('webgl') 去"试探"画布类型: * 画布一旦创建了某种上下文就不能再创建另一种,试探会把画布占死, * 平台自己的渲染器随即报 * THREE.WebGLRenderer: A WebGL context could not be created. * Reason: Canvas has an existing context of a different type * * 类型来源(按可信度排序): * ① canvas.__odboxType —— document-start 补丁记录的真实创建类型,最可靠; * ② canvas.__odboxIsWebGL —— 兼容早期版本留下的标记; * ③ 退回 getContext('2d'):在 WebGL 画布上只返回 null,不占画布,是安全操作。 */ function canvasKind(canvas) { try { if (canvas.__odboxType) return canvas.__odboxType if (canvas.__odboxIsWebGL === true) return 'webgl' if (canvas.__odboxIsWebGL === false) return '2d' } catch (e) { /* ignore */ } return '' } function getCanvasPixels(canvas) { const w = canvas.width | 0 const h = canvas.height | 0 readDiag.detail = '' if (w < 40 || h < 40) { readDiag.detail = '画布太小(' + w + '×' + h + ')' return null } const kind = canvasKind(canvas) // 已知是 WebGL:只能走 readPixels if (kind === 'webgl' || kind === 'webgl2' || kind === 'experimental-webgl') { const glPix = readViaWebGL(canvas, w, h) if (glPix) return glPix return null } // 已知是 2D,或类型未知:先试 2D(安全,且在 WebGL 画布上只会返回 null) let ctx2d = null try { ctx2d = canvas.getContext('2d', { willReadFrequently: true }) } catch (e) { ctx2d = null readDiag.detail = '取 2D 上下文异常:' + (e && e.message) } if (ctx2d) { const pix = readVia2DContext(ctx2d, w, h) if (pix) return pix return null } // 2D 拿不到 → 这块画布只能是 WebGL(或上下文已丢失) const glPix = readViaWebGL(canvas, w, h) if (glPix) return glPix if (!readDiag.detail) readDiag.detail = '画布既没有 2D 上下文也取不到 WebGL 上下文' return null } /** 用已拿到的 2D 上下文读像素 */ function readVia2DContext(ctx, w, h) { if (!ctx) { readDiag.detail = '没有 2D 上下文' return null } try { const img = ctx.getImageData(0, 0, w, h) return makePixels(w, h, img.data, '2d') } catch (e) { readDiag.detail = '2D getImageData 失败:' + (e && e.name) + '(多为跨域图片污染画布)' return null } } /* ======================== 图片边界(letterbox)定位 ======================== */ /** * 判断某条边上是否存在 letterbox 黑边。 * * 判据分两步: * ① 从画布最外侧起有一段「实心纯色条带」—— 条带内每条线几乎全部同色; * ② 条带之内存在明显的相邻色差跳变 —— 说明跨过了「实心条带 → 图片内容」的边界。 * 若条带之内仍是连续同色,那这段纯色本身就是图片内容(例如一面纯色墙), * 不能当成黑边,否则扫描区间会跑偏、贴边的框线全部漏检。 * * 返回 { padded, color, depth } */ function edgePadding(pix, edge) { const { w, h, data } = pix const horizontal = edge === 'top' || edge === 'bottom' const span = horizontal ? w : h const max = Math.min(CONFIG.maxContentInset, Math.floor((horizontal ? h : w) / 3)) /** 某条线是否「几乎全部同色」,并返回它的主色 */ const lineUniform = (pos) => { let first = -2 let same = 0 let opaque = 0 for (let p = 0; p < span; p++) { const x = horizontal ? p : pos const y = horizontal ? pos : p const i = (y * w + x) * 4 if (data[i + 3] < 40) { same++ opaque++ continue } if (first === -2) { first = rgbKey(data[i], data[i + 1], data[i + 2]) same = 1 opaque = 1 } else if (colorNear(first, data[i], data[i + 1], data[i + 2], CONFIG.colorTolerance)) { same++ opaque++ } else { opaque++ } } return { uniform: opaque > 0 && same / opaque >= 0.9, color: first } } // ① 量实心条带厚度 let color = -1 let depth = 0 for (let d = 0; d < max; d++) { const pos = edge === 'top' ? d : edge === 'bottom' ? h - 1 - d : edge === 'left' ? d : w - 1 - d const r = lineUniform(pos) if (!r.uniform) break if (color < 0) color = r.color else if (!colorNear(color, (r.color >> 16) & 255, (r.color >> 8) & 255, r.color & 255, CONFIG.colorTolerance)) break depth++ } if (depth < CONFIG.minPadDepth) return { padded: false, color: -1, depth: 0 } // ② 逐条线算「相邻像素平均色差」:实心黑边必然是 0,图片内容(哪怕很淡的纹理)会明显更大 const step = horizontal ? 4 : w * 4 const lineDiff = (pos) => { const i0 = horizontal ? pos * w * 4 : pos * 4 let d = 0 for (let p = 1; p < span; p++) { const a = i0 + (p - 1) * step const b = i0 + p * step d += Math.abs(data[a] - data[b]) + Math.abs(data[a + 1] - data[b + 1]) + Math.abs(data[a + 2] - data[b + 2]) } return d / Math.max(1, span - 1) } const inner = [] const checkCount = Math.min(4, Math.max(0, max - depth)) for (let k = 0; k < checkCount; k++) { const pos = edge === 'top' ? depth + k : edge === 'bottom' ? h - 1 - depth - k : edge === 'left' ? depth + k : w - 1 - depth - k inner.push(lineDiff(pos)) } if (!inner.length) return { padded: false, color: -1, depth: 0 } const sorted = inner.slice().sort((a, b) => a - b) const median = sorted[Math.floor(sorted.length / 2)] const best = sorted[sorted.length - 1] if (best < CONFIG.innerVarianceMin && median < CONFIG.innerVarianceMin / 2) { return { padded: false, color: -1, depth: 0 } } return { padded: true, color, depth } } /** 该方向是否留了黑边 */ function edgeIsPadded(pix, edge) { return edgePadding(pix, edge).padded } /** * 定位图片内容相对画布的边距(letterbox 黑边/底色条的宽度)。 * * 做法:沿画布四条边,逐条线往里找「底色 → 图片内容」的那一次最大色差跳变, * 取多数线一致的跳变位置作为图片边界。 * * 为什么不用「整行同色」判据:图片自身内容(比如一片天空、一面墙、 * 甚至画面里本来就有的白色/灰色条带)也会让整行看起来同色, * 那样会把画布边误判成黑边,导致扫描区间整体偏移、越界框全部漏检。 * 用「跳变」判据则只认真正的分界线。 * * 返回 { top, bottom, left, right, padded, bgColor } */ function detectContentInsets(pix) { const { w, h, data } = pix const px = (x, y) => (y * w + x) * 4 const diff = (a, b) => Math.abs(data[a] - data[b]) + Math.abs(data[a + 1] - data[b + 1]) + Math.abs(data[a + 2] - data[b + 2]) const maxDepth = Math.max(4, Math.min(CONFIG.maxContentInset, Math.floor(Math.min(w, h) / 3))) const lineStep = Math.max(1, Math.floor((CONFIG.boundaryLines ? Math.max(w, h) / CONFIG.boundaryLines : 8))) /** * 单条线:从画布边缘往里,返回每一步的色差剖面。 * 步长必须与方向一致:像素索引 = (y*w+x)*4, * 沿 x 走一步是 4 字节,沿 y 走一步是 w*4 字节。 * 写错会让扫描方向整体转 90°,等于永远扫不到真正的边界。 */ const lineProfile = (i0, step) => { const arr = new Array(maxDepth) for (let k = 1; k <= maxDepth; k++) { arr[k - 1] = diff(i0 + (k - 1) * step, i0 + k * step) } return arr } /** * 统计一个方向。 * * 判据:把多条采样线的色差剖面逐深度取中位数,真实的「底色条带 → 图片内容」 * 分界线会在某一个深度形成**突出且孤立**的尖峰: * 条带内部各深度色差接近 0,越过分界线之后回落到内容自身的梯度水平。 * 而"图片内容本身顶到画布边"时,各深度的中位数彼此接近,不存在尖峰。 * * 这比"平均色差"判据稳得多:照片内容(树木、栏杆、建筑)处处都有梯度, * 用平均值会把它们误判成分界线;用"尖峰"只看那一次跃变。 * * positions : 采样线在沿边方向上的坐标 * i0Of : 由该坐标取到边缘上那一点的字节索引 * stepOf : 由边缘往里深入的字节步长(垂直于边) * alongStep : 沿边方向的字节步长(平行于边,用于取底色) */ const scanSide = (edge, positions, i0Of, stepOf, alongStep) => { const n = positions.length if (!n) return { inset: 0, padded: false, color: -1 } const profiles = [] for (const p of positions) profiles.push(lineProfile(i0Of(p), stepOf)) // 逐深度取中位数,得到整个方向的"色差剖面" const med = new Array(maxDepth) for (let k = 0; k < maxDepth; k++) { const col = new Array(n) for (let i = 0; i < n; i++) col[i] = profiles[i][k] col.sort((a, b) => a - b) med[k] = col[Math.floor(n / 2)] } let peakDepth = 1 let peak = -1 for (let k = 0; k < maxDepth; k++) { if (med[k] > peak) { peak = med[k] peakDepth = k + 1 } } const medianOf = (arr) => { if (!arr.length) return 0 const s = arr.slice().sort((a, b) => a - b) return s[Math.floor(s.length / 2)] } const beforeAll = med.slice(0, Math.max(1, peakDepth - 1)) const afterAll = med.slice(peakDepth) const baseBefore = medianOf(beforeAll) const baseAfter = medianOf(afterAll) const maxBefore = beforeAll.length ? Math.max.apply(null, beforeAll) : 0 const maxAfter = afterAll.length ? Math.max.apply(null, afterAll) : 0 // 尖峰判据: // ① 峰值足够强; // ② 峰值明显高于分界线之后的内容梯度水平(内容梯度是"背景噪声"); // ③ 分界线之前整体是平静的(中位数意义上)。 // 注意不能要求"峰值高于之前的一切":贴着图片边界被裁断的框线 // 会在条带内侧再制造一次很强的跳变,那样会把真实黑边整个否掉。 // 真正兜底的是下面那条"条带必须实心"的硬判据。 const isPeak = peak >= CONFIG.boundaryJump && peak >= (baseBefore + 1) * 3 && peak >= (baseAfter + 1) * 1.5 && peak >= (maxAfter + 1) * 1.2 if (!isPeak) return { inset: 0, padded: false, color: -1 } if (peakDepth < CONFIG.minPadDepth) return { inset: 0, padded: false, color: -1 } /** * 追加一条硬判据:跳变之外的那一段必须真的是【实心底色条带】。 * 仅靠"色差剖面有尖峰"还不够 —— 照片内容里偶尔也会出现一次特别大的 * 相邻色差(比如树枝、栏杆边缘),会被误判成黑边。 * 真实 letterbox 条带是机器填的纯色,用"条带内 ≥90% 像素同色"这一条就能卡死。 */ const bgR = data[i0Of(positions[0])] const bgG = data[i0Of(positions[0]) + 1] const bgB = data[i0Of(positions[0]) + 2] let same = 0 let total = 0 for (const p of positions) { const base = i0Of(p) for (let d = 0; d < peakDepth; d++) { const i = base + d * stepOf if (i < 0 || i + 2 >= data.length) continue if (data[i + 3] < 40) continue total++ if (colorNear(rgbKey(bgR, bgG, bgB), data[i], data[i + 1], data[i + 2], CONFIG.colorTolerance)) same++ } } // 阈值取 0.85 而不是 0.95:贴着图片边界的框线会占掉条带最内侧的 1~2 像素, // 把比例硬压下去,卡太死会把真实黑边否掉(那就整个方向都漏检了)。 if (!total || same / total < 0.85) return { inset: 0, padded: false, color: -1 } // 底色:必须取【画布最外缘】那条线上的颜色(letterbox 底色一定铺在画布边沿)。 // 不能取"内容边界"那一列 —— 那里正好是贴边被裁断的框线, // 取错位置会把框线颜色误当底色排除掉,导致整个框检测不出来。 const edgeI0 = edge === 'top' ? px(positions[0], 0) : edge === 'bottom' ? px(positions[0], h - 1) : edge === 'left' ? px(0, positions[0]) : px(w - 1, positions[0]) let r0 = 0 let g0 = 0 let b0 = 0 let cnt = 0 for (let k = 0; k < 6; k++) { const i = edgeI0 + k * alongStep if (data[i + 3] < 40) continue r0 += data[i] g0 += data[i + 1] b0 += data[i + 2] cnt++ } const color = cnt ? rgbKey(Math.round(r0 / cnt), Math.round(g0 / cnt), Math.round(b0 / cnt)) : -1 return { inset: Math.min(peakDepth, maxDepth), padded: true, color } } // 采样线的位置(避开正中间,两侧各采样若干条,避免单点偶然性) const xPositions = [] const yPositions = [] for (let p = 0; p < Math.max(w, h); p += Math.max(1, Math.floor(Math.max(w, h) / 10))) { if (p < w) xPositions.push(p) if (p < h) yPositions.push(p) } if (!xPositions.length) xPositions.push(0) if (!yPositions.length) yPositions.push(0) void lineStep // 四个方向的扫描。字节步长必须与"往里走"的方向一致: // 像素索引 = (y*w + x) * 4 // 沿 x 走一步 → +4 字节;沿 y 走一步 → +w*4 字节。 // 参数顺序:往里深入的步长、沿边方向的步长 const top = scanSide('top', xPositions, (x) => px(x, 0), w * 4, 4) const bottom = scanSide('bottom', xPositions, (x) => px(x, h - 1), -w * 4, 4) const left = scanSide('left', yPositions, (y) => px(0, y), 4, w * 4) const right = scanSide('right', yPositions, (y) => px(w - 1, y), -4, w * 4) return { top: top.inset, bottom: bottom.inset, left: left.inset, right: right.inset, padded: { top: top.padded, bottom: bottom.padded, left: left.padded, right: right.padded, }, colors: { top: top.color, bottom: bottom.color, left: left.color, right: right.color }, } } /* ======================== 框线颜色拾取 ======================== */ /** * 在四条边的窄条上找出「框线颜色」。 * * 三条判据缺一不可: * ① 沿边方向能形成一条【精确同色】的长线段 —— 框线是实色直线,笔画内部 RGB 完全相同; * 照片内容有噪声,同色像素只会形成很短的小段; * ② 在垂直方向上也有一定长度(≥ minBoxSide)—— 框是一个闭合矩形, * 它的水平边和垂直边必然同时出现在边界条带里; * 而 letterbox 黑边只会在另一条边上留下一小截端点,靠这条判据排除; * ③ 不能是黑边本身的颜色。 * * 采样范围包含两圈:画布最外缘一圈、以及图片内容边界一圈 * (有黑边时框线停在图片边界,而不是画布边缘)。 */ function pickBorderColors(pix, band) { const { w, h, data } = pix const insets = detectContentInsets(pix) // edge -> along -> 该位置所有候选像素的索引 const bands = { top: new Map(), bottom: new Map(), left: new Map(), right: new Map() } const put = (edge, along, idx) => { const m = bands[edge] if (!m.has(along)) m.set(along, []) m.get(along).push(idx) } const idxOf = (x, y) => (y * w + x) * 4 for (let d = 0; d < band; d++) { // 两圈都要采样:画布最外缘一圈、以及图片内容边界一圈。 // 框线停在哪个边界上取决于图片是否留了黑边,两个都要覆盖。 const ty = Math.min(h - 1, insets.top + d) const by = Math.max(0, h - 1 - insets.bottom - d) const lx = Math.min(w - 1, insets.left + d) const rx = Math.max(0, w - 1 - insets.right - d) for (let x = 0; x < w; x++) { let i = idxOf(x, d) if (data[i + 3] >= 40) put('top', x, i) i = idxOf(x, ty) if (data[i + 3] >= 40) put('top', x, i) i = idxOf(x, h - 1 - d) if (data[i + 3] >= 40) put('bottom', x, i) i = idxOf(x, by) if (data[i + 3] >= 40) put('bottom', x, i) } for (let y = 0; y < h; y++) { let i = idxOf(d, y) if (data[i + 3] >= 40) put('left', y, i) i = idxOf(lx, y) if (data[i + 3] >= 40) put('left', y, i) i = idxOf(w - 1 - d, y) if (data[i + 3] >= 40) put('right', y, i) i = idxOf(rx, y) if (data[i + 3] >= 40) put('right', y, i) } } /** 某位置上与给定颜色【完全一致】的像素 */ const exactAt = (edge, along, key) => { const list = bands[edge].get(along) if (!list) return false for (let k = 0; k < list.length; k++) { const idx = list[k] if (rgbKey(data[idx], data[idx + 1], data[idx + 2]) === key) return true } return false } /** * 某颜色在一条边上的「最长连续覆盖长度」。 * 注意不能要求“每个位置都命中”:框线往往有 2~3 像素宽(抗锯齿), * 其中某一列可能恰好落在两个色阶之间而未被精确命中。 * 因此这里先在命中位置之间按 maxGap 连线,再取最长的一段覆盖范围。 */ const longestRun = (edge, alongs, key) => { const hits = [] for (let k = 0; k < alongs.length; k++) if (exactAt(edge, alongs[k], key)) hits.push(k) if (!hits.length) return 0 let longest = 1 let startK = 0 for (let k = 1; k < hits.length; k++) { if (alongs[hits[k]] - alongs[hits[k - 1]] <= CONFIG.maxGap + 1) continue const len = alongs[hits[k - 1]] - alongs[hits[startK]] + 1 if (len > longest) longest = len startK = k } const tailLen = alongs[hits[hits.length - 1]] - alongs[hits[startK]] + 1 return Math.max(longest, tailLen) } const edges = ['top', 'right', 'bottom', 'left'] const spanOf = { top: w, right: h, bottom: w, left: h } const candidates = new Map() // key -> { key, count, edge } for (const edge of edges) { const alongs = Array.from(bands[edge].keys()).sort((a, b) => a - b) if (!alongs.length) continue // 候选种子色:该边窄条上出现最多的若干颜色。 // 这里不能只取前几个:铺满条带的底色会占据绝大部分位置, // 框线颜色(一条细线)在计数上永远排不到前面。 const tally = new Map() for (const along of alongs) { const list = bands[edge].get(along) for (let k = 0; k < list.length; k += 2) { const idx = list[k] const key = rgbKey(data[idx], data[idx + 1], data[idx + 2]) tally.set(key, (tally.get(key) || 0) + 1) if (tally.size > CONFIG.maxTallyColors) break } if (tally.size > CONFIG.maxTallyColors) break } const seeds = Array.from(tally.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, CONFIG.maxSeedColors) .map((x) => x[0]) for (const key of seeds) { const len = longestRun(edge, alongs, key) if (len < CONFIG.minBoxSide) continue if (len >= spanOf[edge] * CONFIG.maxEdgeCoverage) continue // 铺满整条边 → 底色 const prev = candidates.get(key) if (!prev) candidates.set(key, { key, count: len, edge, horizontal: edge === 'top' || edge === 'bottom' }) else if (len > prev.count) { prev.count = len prev.edge = edge prev.horizontal = edge === 'top' || edge === 'bottom' } } } // 判据②:两个方向都要有该颜色的足迹 —— 框是闭合矩形,水平边与垂直边必然同时存在。 // 水平方向:在边界条带里的最长线段(框线被裁断后贴边的那一段)。 // 垂直方向:只要"有足迹"即可(≥ minBoxSide 个位置)。 // 竖边常常落在图片内部、并不在扫描带里:它可能只是穿过条带的 2px 端点, // 也可能被裁到画布条带之外,所以这里只做存在性判断,不要求成段。 // 这条判据用来排除 letterbox 黑边 —— 黑边只在一条边上铺满,另一个方向只有很薄的一条。 // 判据③:不能与黑边底色相同。 const picked = [] candidates.forEach((cand) => { const dirOf = (e) => (e === 'top' || e === 'bottom' ? 'h' : 'v') const runIn = { h: 0, v: 0 } const hitsIn = { h: 0, v: 0 } for (const e of edges) { const alongs = Array.from(bands[e].keys()).sort((a, b) => a - b) if (!alongs.length) continue const d = dirOf(e) const len = longestRun(e, alongs, cand.key) if (len > runIn[d]) runIn[d] = len for (let k = 0; k < alongs.length; k++) { if (exactAt(e, alongs[k], cand.key)) hitsIn[d]++ } } if (runIn.h < CONFIG.minBoxSide && runIn.v < CONFIG.minBoxSide) return // 另一个方向只要求"有足迹",阈值放宽到 minBoxSide/3: // 竖边通常不在扫描带里,能落在带内的往往只有很短的一截(甚至只有一两个像素)。 // 真正区分黑边靠的是下面第③条底色排除,以及上面"至少一个方向成段"的要求。 const presence = Math.max(2, Math.floor(CONFIG.minBoxSide / 3)) if (hitsIn.h < presence || hitsIn.v < presence) return // 判据③:底色排除。letterbox 的底色条带自己也会形成很长的一段同色, // 必须用「实测到的底色」把它挡掉,否则整条黑边会被当成一个框报出来。 for (const e of edges) { const bg = insets.colors ? insets.colors[e] : -1 if (bg === undefined || bg < 0) continue if (!colorNear(cand.key, (bg >> 16) & 255, (bg >> 8) & 255, bg & 255, CONFIG.strokeColorTolerance)) continue // 与底色同色,且在该方向上长到接近铺满 → 判定为底色本身 if (runIn[dirOf(e)] >= spanOf[e] * 0.9) return if (cand.count >= spanOf[e] * 0.9) return } picked.push({ key: cand.key, count: Math.max(runIn.h, runIn.v) }) }) picked.sort((a, b) => b.count - a.count) return picked.slice(0, 4) } /* ======================== 贴边线段扫描 ======================== */ /** * 为某个颜色,在四条边上收集「贴着图片边界被裁掉」的框线线段。 * 扫描区间(以左边为例): * 无黑边(inset=0) → [0, edgeBand) * 有黑边(inset>0) → [inset, inset+edgeBand),跨过黑边、紧贴图片边界 * 无论图片是否居中留边,被裁掉的框线都一定落在扫描区间内。 * * 线段:{ edge, at, start, end, depth, colorKey } * at : 该边坐标(top/bottom 为 y,left/right 为 x),取区间内最外侧的命中位置 * start/end : 沿边方向的起止 */ function collectEdgeSegments(pix, colorSet, insets) { const { w, h, data } = pix const band = CONFIG.edgeBand const tol = CONFIG.colorTolerance const inset = insets || { top: 0, bottom: 0, left: 0, right: 0 } const out = { top: [], bottom: [], left: [], right: [] } const hitKey = (x, y) => { const i = (y * w + x) * 4 if (data[i + 3] < 40) return -1 const r = data[i] const g = data[i + 1] const b = data[i + 2] for (let k = 0; k < colorSet.length; k++) { if (colorNear(colorSet[k].key, r, g, b, tol)) return colorSet[k].key } return -1 } /** * 扫描区间:以「图片内容边界」为基准,向外让 1 像素、向内覆盖 band 像素。 * * 为什么要多让这 1 像素:边界定位本身有 1~2 像素的正常误差, * 而"框线被裁断"时它正好压在边界上 —— 带子起点算高了就会把框线漏在外面。 * 宁可多扫 1 个像素(底色会被后面的底色排除掉),也不能漏掉框线。 */ const pad = Math.min(2, Math.max(0, inset.top)) // 防止 inset 很小时越界 void pad const scanEdge = (edge) => { const horizontal = edge === 'top' || edge === 'bottom' const span = horizontal ? w : h // 向外让 2 像素:边界定位有 1~2 像素的正常误差, // 而"框线被裁断"时它正好压在边界上,起点算高了就会把框线漏在外面。 const slack = 2 const start0 = horizontal ? edge === 'top' ? Math.max(0, inset.top - slack) : h - inset.bottom - band + slack : edge === 'left' ? Math.max(0, inset.left - slack) : w - inset.right - band + slack const byColor = new Map() // colorKey -> Map const push = (key, pos, depth) => { if (key < 0 || depth < 0 || depth >= band) return let m = byColor.get(key) if (!m) { m = new Map() byColor.set(key, m) } const prev = m.get(pos) if (prev === undefined || depth < prev) m.set(pos, depth) } for (let d = 0; d < band; d++) { for (let p = 0; p < span; p++) { if (horizontal) { const y = edge === 'top' ? start0 + d : start0 + (band - 1 - d) push(hitKey(p, y), p, d) } else { const x = edge === 'left' ? start0 + d : start0 + (band - 1 - d) push(hitKey(x, p), p, d) } } } byColor.forEach((posMap, key) => { const positions = Array.from(posMap.keys()).sort((a, b) => a - b) const runs = toRuns(positions, CONFIG.maxGap) for (const run of runs) { if (run.end - run.start + 1 < CONFIG.minBoxSide) continue let minDepth = band for (let p = run.start; p <= run.end; p++) { const d = posMap.get(p) if (d !== undefined && d < minDepth) minDepth = d } if (minDepth >= CONFIG.maxStrokeWidth) continue out[edge].push({ edge, at: edge === 'bottom' ? start0 + (band - 1 - minDepth) : start0 + minDepth, start: run.start, end: run.end, depth: minDepth, colorKey: key, }) } }) } scanEdge('top') scanEdge('bottom') scanEdge('left') scanEdge('right') return out } /* ======================== 线段合并成框 ======================== */ const isHorizontalSeg = (s) => s.edge === 'top' || s.edge === 'bottom' function overlaps(a1, a2, b1, b2, slack) { const s = slack == null ? 2 : slack return Math.max(a1, b1) <= Math.min(a2, b2) + s } /** * 同一个框在画布上会被拆成若干贴边线段,这里并回同一个框: * ① 水平边 × 垂直边:端点真的碰到一起 → 同一个矩形; * ② 同一条边上的两条线段:同色、位置相差 ≤3px、沿边方向有重叠 * → 同一个矩形被文字标签遮挡后断开的同一段边框。 */ function groupIntoBoxes(segments) { const all = [].concat(segments.top, segments.bottom, segments.left, segments.right) const n = all.length const parent = new Array(n) for (let i = 0; i < n; i++) parent[i] = i const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]] i = parent[i] } return i } const union = (i, j) => { const a = find(i) const b = find(j) if (a !== b) parent[b] = a } for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { const a = all[i] const b = all[j] if (a.edge === b.edge) { if ( a.colorKey === b.colorKey && Math.abs(a.at - b.at) <= 3 && overlaps(a.start, a.end, b.start, b.end, CONFIG.maxGap + 2) ) { union(i, j) } continue } if (isHorizontalSeg(a) === isHorizontalSeg(b)) continue const hSeg = isHorizontalSeg(a) ? a : b const vSeg = isHorizontalSeg(a) ? b : a if ( hSeg.start - 2 <= vSeg.at && vSeg.at <= hSeg.end + 2 && vSeg.start - 2 <= hSeg.at && hSeg.at <= vSeg.end + 2 ) { union(i, j) } } } const groups = new Map() for (let i = 0; i < n; i++) { const root = find(i) if (!groups.has(root)) groups.set(root, []) groups.get(root).push(all[i]) } return Array.from(groups.values()) } /** * 由线段组还原矩形,并判断哪些边被【图片边界】裁断。 * * 坐标换算:画布 X = inset.left + 原图 X(inset 为 letterbox 黑边宽度)。 * * 关于越界量:框线落在图片边界上(X 恰为 0 / 图片宽-1)时, * 说明该框在这一侧被裁掉了 —— 它的原图坐标必然 ≤0 或 ≥图片宽。 * 但【被裁掉多少像素在渲染后是不可能还原的】(钳制操作有损: * 坐标为 -40 与坐标为 0 的框,画出来一样)。因此这里如实标记 * “该侧越界/被裁断”,并给出可见像素尺寸,而不是编造一个精确越界量。 */ function boxFromGroup(group, pix, insets) { const { w, h } = pix const inset = insets || { top: 0, bottom: 0, left: 0, right: 0 } const edges = { top: [], bottom: [], left: [], right: [] } for (const s of group) edges[s.edge].push(s) const pickAt = (list, cmp) => { if (!list.length) return null let best = list[0] for (const s of list) if (cmp(s.at, best.at)) best = s return best } const top = pickAt(edges.top, (a, b) => a < b) const bottom = pickAt(edges.bottom, (a, b) => a > b) const left = pickAt(edges.left, (a, b) => a < b) const right = pickAt(edges.right, (a, b) => a > b) const hSegs = group.filter((s) => s.edge === 'top' || s.edge === 'bottom') const vSegs = group.filter((s) => s.edge === 'left' || s.edge === 'right') const minOf = (list, get) => (list.length ? Math.min.apply(null, list.map(get)) : NaN) const maxOf = (list, get) => (list.length ? Math.max.apply(null, list.map(get)) : NaN) const x1 = left ? left.at : minOf(hSegs, (s) => s.start) const x2 = right ? right.at : maxOf(hSegs, (s) => s.end) const y1 = top ? top.at : minOf(vSegs, (s) => s.start) const y2 = bottom ? bottom.at : maxOf(vSegs, (s) => s.end) if (!isFinite(x1) || !isFinite(x2) || !isFinite(y1) || !isFinite(y2)) return null const bw = x2 - x1 const bh = y2 - y1 if (bw < CONFIG.minBoxSide || bh < CONFIG.minBoxSide) return null // 可见范围的边界 = 图片矩形的四条边(letterbox 时它才是框线能贴到的极限), // 同时兜住画布边界 —— 图片若被渲染器裁掉、没有留黑边,框会贴死在画布边缘上。 const imgW = Math.max(1, w - inset.left - inset.right) const imgH = Math.max(1, h - inset.top - inset.bottom) const imgRight = inset.left + imgW - 1 const imgBottom = inset.top + imgH - 1 // 判定「该侧被裁断」。 // 被裁断的框线会停在可见边界上,但同一根框线有 1~3px 宽、 // 检测到的坐标又是笔画中心并取过整,因此在边界附近留 1px 容差。 const slack = 1 const atLeft = (!!edges.left.length && x1 <= inset.left + slack) || hSegs.some((s) => s.start <= inset.left + slack || s.end <= inset.left + slack) const atTop = (!!edges.top.length && y1 <= inset.top + slack) || vSegs.some((s) => s.start <= inset.top + slack || s.end <= inset.top + slack) const atRight = (!!edges.right.length && x2 >= imgRight - slack) || hSegs.some((s) => s.end >= imgRight - slack || s.start >= imgRight - slack) const atBottom = (!!edges.bottom.length && y2 >= imgBottom - slack) || vSegs.some((s) => s.end >= imgBottom - slack || s.start >= imgBottom - slack) const touching = [] if (atTop) touching.push('上') if (atBottom) touching.push('下') if (atLeft) touching.push('左') if (atRight) touching.push('右') return { x1: Math.round(x1), y1: Math.round(y1), x2: Math.round(x2), y2: Math.round(y2), w: Math.round(bw), h: Math.round(bh), // 被裁断的边(true 表示该侧越界) clipped: { left: atLeft, top: atTop, right: atRight, bottom: atBottom }, touching, inset, imgW: Math.max(0, w - inset.left - inset.right), imgH: Math.max(0, h - inset.top - inset.bottom), colorKey: group[0].colorKey, } } /** a 是否基本包住 b */ function coversBox(a, b) { const pad = 3 return a.x1 <= b.x1 + pad && a.y1 <= b.y1 + pad && a.x2 >= b.x2 - pad && a.y2 >= b.y2 - pad } /** 剔除被别的框包住、且没有额外信息的重复框 */ function dropContained(boxes) { return boxes.filter( (box) => !boxes.some((other) => { if (other === box) return false if (!coversBox(other, box)) return false const outerArea = (other.x2 - other.x1) * (other.y2 - other.y1) const innerArea = (box.x2 - box.x1) * (box.y2 - box.y1) return outerArea >= innerArea * 1.35 }) ) } /* ======================== 数据校验:全部 2D 框逐个查越界 ======================== */ /** * 从平台数据里挑出全部 2D 框,逐个校验是否超出图片范围。 * * 数据结构(来自平台前端产物): * marks[trackId] = { class, "2d": { frameId: [ { id, type, camera, points, visual_type } ] } } * points = rect 为 [left, top, right, bottom];cuboid_2d 8 点;line/polygon N 点,单位原图像素 * ★ 分辨率不在框上,在相机配置里:frame.slave_info[相机][0].resolution * * 判定:left < 0 或 top < 0 或 right > 宽 或 bottom > 高 → 越界(给出精确越界像素数)。 * 之所以右/下用「> 宽/高」而不是「> 宽-1」:座标里 right=宽 表示正好贴到图片右边, * 并没有超出;平台自己(FusionTools 裁剪 cuboid_2d)也是按 [0, 宽] 钳制的。 */ function parseResolution(res) { if (!res) return null if (typeof res === 'string') { const m = res.split(/[*x×,\s]+/) const w = parseInt(m[0], 10) const h = parseInt(m[1], 10) return w > 0 && h > 0 ? { w: w, h: h } : null } if (Array.isArray(res) && res.length >= 2) { const w = parseInt(res[0], 10) const h = parseInt(res[1], 10) return w > 0 && h > 0 ? { w: w, h: h } : null } return null } /** 兜底:从页面上的图片元素猜分辨率(数据里没带 resolution 时用) */ function resolutionFromPage() { try { const imgs = document.querySelectorAll('img') for (let i = 0; i < imgs.length; i++) { const nw = imgs[i].naturalWidth const nh = imgs[i].naturalHeight if (nw > 200 && nh > 200) return { w: nw, h: nh, from: '页面图片 naturalSize' } } } catch (e) { /* ignore */ } return null } const TOOL_RECT_2D = 'rect' const TOOL_CUBE_2D = 'cuboid_2d' const VISUAL_TYPE_VIRTUAL = 2 /** 框类型的人话名字 */ function typeText(type, pointCount) { if (type === TOOL_RECT_2D) return '矩形' if (type === TOOL_CUBE_2D) return '2D立体框' if (type === 'line') return '折线' if (type === 'polygon') return '多边形' if (type) return String(type) return pointCount === 4 ? '矩形' : pointCount + '点' } /** * 平台里的 points 有两种写法,必须先统一: * rect → 扁平数字数组 [left, top, right, bottom] * cuboid_2d / line / polygon → 坐标对数组 [[x, y], [x, y], ...] * 统一成坐标对数组,后面的几何计算才不会两种写法各写一份。 */ function normalizePoints(points) { const out = [] if (!points || !points.length) return out if (typeof points[0] === 'number') { for (let i = 0; i + 1 < points.length; i += 2) { const x = Number(points[i]) const y = Number(points[i + 1]) if (isFinite(x) && isFinite(y)) out.push([x, y]) } return out } for (let i = 0; i < points.length; i++) { const p = points[i] if (!p) continue const x = Number(p[0]) const y = Number(p[1]) if (isFinite(x) && isFinite(y)) out.push([x, y]) } return out } /** * 取一组坐标对的外接矩形。 * rect 换算出来的两个角点取 min/max 与 [l, t, r, b] 完全一致; * 对 cuboid_2d / line / polygon 则是"这些点是否超出图片"的合理判据。 */ function pointsBounds(pts) { let x1 = Infinity let y1 = Infinity let x2 = -Infinity let y2 = -Infinity let used = 0 for (let i = 0; i < pts.length; i++) { const p = pts[i] const x = p[0] const y = p[1] used++ if (x < x1) x1 = x if (y < y1) y1 = y if (x > x2) x2 = x if (y > y2) y2 = y } if (!used) return null return { x1: x1, y1: y1, x2: x2, y2: y2, count: used } } /** 该帧该相机的图片分辨率(相机配置里),拿不到返回 null */ function resolutionOfCamera(sm, frameId, cameraKey) { return resolutionInCameras(camerasOfFrame(sm, frameId), cameraKey) } /** 在「该帧的相机配置」里找某台相机的分辨率 */ function resolutionInCameras(cams, cameraKey) { if (!cams) return null let list = cams[cameraKey] if ((!list || !list.length) && cameraKey) { // 相机名偶尔带前后缀,退化成模糊匹配 for (const k in cams) { if (k === cameraKey || k.indexOf(cameraKey) >= 0 || cameraKey.indexOf(k) >= 0) { list = cams[k] break } } } if (!list || !list.length) return null return parseResolution(list[0] && list[0].resolution) } /** * 定数据源:平台运行时 store 优先,接口响应兜底。 * store 为空(页面还没加载完/本任务确实没有框)而接口有数据时,用接口的。 */ function pickDataSource() { const sm = CONFIG.usePlatformStore ? platformStore() : null const storeMarks = sm ? platformMarks(sm) : null const storeTracks = storeCount(storeMarks) const apiTracks = storeCount(state.apiMarks) if (storeTracks) return { marks: storeMarks, source: 'store', sm: sm } if (apiTracks) return { marks: state.apiMarks, source: 'api', sm: sm } if (storeMarks) return { marks: storeMarks, source: 'store', sm: sm } return { marks: null, source: sm ? 'store' : '', sm: sm } } function validateAllBoxes() { const src = pickDataSource() const sm = src.sm state.storeSeen = !!sm state.currentFrameId = currentFrameId(sm) state.marksObtained = marksObtained(sm) state.dataSource = src.source const marks = src.marks if (!marks) { return { status: 'nodata', boxes: [], total2d: 0, checked: 0, unknownRes: [], source: src.source, marksObtained: state.marksObtained, reason: state.marksReason || (sm ? '平台数据还没加载完(store 里暂时没有框)' : '还没找到平台运行时数据,也没抓到接口响应'), } } const cur = state.currentFrameId const pageRes = resolutionFromPage() const boxes = [] const unknownRes = [] const skipped = { noPoints: 0, badNum: 0 } let total2d = 0 let virtualCount = 0 let checked = 0 // 当前帧的口径(默认只看当前帧,所以这几项是面板真正要显示的) let curTotal = 0 let curChecked = 0 let curUnknown = 0 // 一帧的相机配置只取一次(整包可能有几千个框,避免每个框都去翻一遍配置) const camCache = new Map() const camsOf = (frameId) => { const key = String(frameId) if (!camCache.has(key)) camCache.set(key, camerasOfFrame(sm, frameId)) return camCache.get(key) } for (const trackId in marks) { const track = marks[trackId] if (!track || !track['2d']) continue const cls = track.class const byFrame = track['2d'] for (const frameId in byFrame) { const list = byFrame[frameId] if (!list || !list.length) continue for (let k = 0; k < list.length; k++) { const mark = list[k] if (!mark) continue const isVirtual = mark.visual_type === VISUAL_TYPE_VIRTUAL if (isVirtual) virtualCount++ if (isVirtual && !CONFIG.includeVirtual) continue if (!mark.points || !mark.points.length) { skipped.noPoints++ continue } total2d++ const inCurFrame = cur !== null && String(frameId) === cur if (inCurFrame) curTotal++ // 老数据里 type 可能缺失:扁平 4 个数字就是矩形,坐标对数组才算多点多边形 const flat4 = typeof mark.points[0] === 'number' && mark.points.length === 4 const rawType = mark.type || (flat4 ? TOOL_RECT_2D : '') const pts = normalizePoints(mark.points) const b = pointsBounds(pts) if (!b) { skipped.badNum++ continue } // 分辨率:框自带 → 相机配置(主) → 页面图片 let res = parseResolution(mark.resolution) let resFrom = res ? '框自带' : '' if (!res && sm) { res = resolutionInCameras(camsOf(frameId), mark.camera) if (res) resFrom = '相机配置' } if (!res) { res = pageRes if (res) resFrom = res.from } if (!res) { unknownRes.push({ trackId: trackId, frameId: frameId, camera: mark.camera || '', cameraName: cameraNameOf(sm, mark.camera), }) if (inCurFrame) curUnknown++ continue } checked++ if (inCurFrame) curChecked++ const eps = CONFIG.overEpsilon const over = { left: Math.max(0, -b.x1), top: Math.max(0, -b.y1), right: Math.max(0, b.x2 - res.w), bottom: Math.max(0, b.y2 - res.h), } if (over.left <= eps && over.top <= eps && over.right <= eps && over.bottom <= eps) continue const round1 = (v) => Math.round(v * 10) / 10 boxes.push({ source: 'data', trackId: trackId, classId: cls, frameId: String(frameId), camera: mark.camera || '', cameraName: cameraNameOf(sm, mark.camera), type: rawType, typeText: typeText(rawType, b.count), virtual: isVirtual, // cur 拿不到(没有平台句柄)时给 null:既不能说是当前帧,也不能说不是 isCurrentFrame: cur === null ? null : String(frameId) === cur, resolution: res, resFrom: resFrom, rawPoints: pts.slice(0, 8), bounds: { x1: round1(b.x1), y1: round1(b.y1), x2: round1(b.x2), y2: round1(b.y2) }, pointCount: b.count, over: over, overTotal: over.left + over.top + over.right + over.bottom, }) } } } // 当前帧优先,其次按越界量从大到小 boxes.sort((a, b) => { if (a.isCurrentFrame !== b.isCurrentFrame) return a.isCurrentFrame ? -1 : 1 return b.overTotal - a.overTotal }) return { status: 'ok', boxes: boxes, total2d: total2d, checked: checked, virtualCount: virtualCount, skipped: skipped, unknownRes: unknownRes, source: src.source, currentFrameId: cur, marksObtained: state.marksObtained, // 当前帧口径:面板默认只看当前帧,用这份数 curFrame: cur === null ? null : { id: cur, total2d: curTotal, checked: curChecked, unknownRes: curUnknown, boxes: boxes.filter((b) => b.isCurrentFrame === true).length, }, } } /* ======================== 单张画布像素检测(辅助) ======================== */ function analyzeCanvas(canvas) { const pix = getCanvasPixels(canvas) if (!pix) return { status: 'unreadable', boxes: [], reason: readDiag.detail } const insets = detectContentInsets(pix) const colors = pickBorderColors(pix, CONFIG.edgeBand) if (!colors.length) return { status: 'empty', boxes: [], insets, pix } const boxes = [] for (const color of colors) { const segments = collectEdgeSegments(pix, [color], insets) const groups = groupIntoBoxes(segments) for (const group of groups) { const box = boxFromGroup(group, pix, insets) if (!box) continue if (!box.touching.length) continue // 必须真的贴到图片边界 const dup = boxes.some( (b) => Math.abs(b.x1 - box.x1) <= 3 && Math.abs(b.y1 - box.y1) <= 3 && Math.abs(b.x2 - box.x2) <= 3 && Math.abs(b.y2 - box.y2) <= 3 ) if (!dup) boxes.push(box) } } return { status: 'ok', boxes: dropContained(boxes), pix, insets } } /* ======================== 页面定位标注 ======================== */ /** * 画布上的常驻角标("框越界 ×N")按需求去掉了:数量与明细只显示在右上角面板里。 * 这里只做旧版本残留节点的清理(升级后不刷新也看不到老角标)。 */ function cleanupLegacyNodes() { try { const nodes = document.querySelectorAll('.odboxcheck-badge, #odboxcheck-pill') for (let i = 0; i < nodes.length; i++) nodes[i].remove() } catch (e) { /* ignore */ } } /* ------------------------ 定位高亮:独立覆盖层(3 秒后自动消失) ------------------------ */ /** * 高亮不在平台的画布上画,而是叠一层同样大小的独立 canvas 盖在上面: * ① 消失是确定的 —— 到点把这一层删掉就行,不用等平台重绘; * ② 完全不碰平台画布像素,跨域污染的画布也能用(不需要 getImageData); * ③ 和平台自己的重绘互不干扰,不会把框"擦掉"或者把框留在画面上。 */ function showHighlight(canvas, drawFn, duration) { const parent = canvas.parentElement if (!parent) return false try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative' } catch (e) { /* ignore */ } // 同一块画布上最多留一个高亮;再点一次直接换新的 const prev = state.highlightTimers.get(canvas) if (prev) { if (prev.t1) clearTimeout(prev.t1) if (prev.loop) clearInterval(prev.loop) if (prev.el && prev.el.parentNode) prev.el.parentNode.removeChild(prev.el) state.highlightTimers.delete(canvas) } const el = document.createElement('canvas') el.className = 'odboxcheck-hl' el.width = canvas.width el.height = canvas.height parent.appendChild(el) /** 覆盖层对齐目标画布(画布被拖动/换尺寸时每帧都要重新对齐) */ const sync = () => { try { const cr = canvas.getBoundingClientRect() const pr = parent.getBoundingClientRect() el.width = canvas.width el.height = canvas.height el.style.left = Math.round(cr.left - pr.left) + 'px' el.style.top = Math.round(cr.top - pr.top) + 'px' el.style.width = Math.round(cr.width) + 'px' el.style.height = Math.round(cr.height) + 'px' } catch (e) { /* ignore */ } } sync() let ctx = null try { ctx = el.getContext('2d') } catch (e) { ctx = null } if (!ctx) { el.remove() return false } /** * ★ 高亮期间持续重画(不是画一次就完): * 平台放大/缩小/拖动图片时,底图几何一直在变, * 每 80ms 重新按最新几何画一遍,高亮就会跟着图片走,不会脱开。 */ const stop = () => { if (el.parentNode) el.parentNode.removeChild(el) state.highlightTimers.delete(canvas) } const repaint = () => { try { ctx.clearRect(0, 0, el.width, el.height) drawFn(ctx, canvas) } catch (e) { /* 画不出来也要留下可见反馈 */ } } repaint() const ms = duration || CONFIG.highlightDuration const rec = { el: el, at: Date.now() } rec.t1 = setTimeout(() => { try { el.style.opacity = '0' } catch (e) { /* ignore */ } }, Math.max(0, ms - 420)) rec.t2 = setTimeout(() => { if (rec.loop) clearInterval(rec.loop) stop() }, ms) rec.loop = setInterval(() => { if (Date.now() - rec.at > ms) { clearInterval(rec.loop) return } sync() repaint() }, 80) state.highlightTimers.set(canvas, rec) return true } /** 高亮的实际画法:虚线框 + 四角加粗 + 越界方向箭头(画在覆盖层上,坐标系与平台画布一致) */ function paintHighlight(ctx, canvas, geo, over) { const dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1)) const lw = Math.max(2, Math.round(2 * dpr)) let x1 = Math.min(geo.x1, geo.x2) let y1 = Math.min(geo.y1, geo.y2) let w = Math.max(3, Math.abs(geo.x2 - geo.x1)) let h = Math.max(3, Math.abs(geo.y2 - geo.y1)) // 越界框常常只剩贴着图片边缘的一小条,太小就看不见了: // 往画面内侧补到至少 18px,保证一眼能找到(只是高亮,不改任何数据)。 const minSide = 18 if (w < minSide) { const grow = minSide - w x1 = Math.max(0, x1 - grow) w = minSide } if (h < minSide) { const grow = minSide - h y1 = Math.max(0, y1 - grow) h = minSide } ctx.save() // 淡淡的底色,在照片上更显眼 ctx.fillStyle = 'rgba(255, 107, 107, 0.10)' ctx.fillRect(x1, y1, w, h) ctx.shadowColor = 'rgba(255, 90, 90, 0.55)' ctx.shadowBlur = 8 * dpr ctx.setLineDash([7 * dpr, 5 * dpr]) ctx.lineWidth = lw ctx.strokeStyle = '#ff6b6b' ctx.strokeRect(x1, y1, w, h) ctx.setLineDash([]) // 四角:让框在花花绿绿的图片上也能一眼看到 const corner = Math.max(10, Math.min(28, Math.round(Math.min(w, h) * 0.22))) ctx.lineWidth = lw + 1 ctx.strokeStyle = '#ffe3e3' const corners = [ [x1, y1, 1, 1], [x1 + w, y1, -1, 1], [x1, y1 + h, 1, -1], [x1 + w, y1 + h, -1, -1], ] for (let i = 0; i < corners.length; i++) { const c = corners[i] ctx.beginPath() ctx.moveTo(c[0], c[1]) ctx.lineTo(c[0] + corner * c[2], c[1]) ctx.moveTo(c[0], c[1]) ctx.lineTo(c[0], c[1] + corner * c[3]) ctx.stroke() } // 越界方向箭头 const arrows = [] if (over && over.left) arrows.push({ x: x1, y: y1 + h / 2, dx: -1, dy: 0 }) if (over && over.right) arrows.push({ x: x1 + w, y: y1 + h / 2, dx: 1, dy: 0 }) if (over && over.top) arrows.push({ x: x1 + w / 2, y: y1, dx: 0, dy: -1 }) if (over && over.bottom) arrows.push({ x: x1 + w / 2, y: y1 + h, dx: 0, dy: 1 }) ctx.lineWidth = lw ctx.strokeStyle = '#ff6b6b' for (let i = 0; i < arrows.length; i++) { const a = arrows[i] const ax = Math.max(1, Math.min(canvas.width - 1, a.x)) const ay = Math.max(1, Math.min(canvas.height - 1, a.y)) const len = 26 * dpr const ex = ax + a.dx * len const ey = ay + a.dy * len const head = 9 * dpr ctx.beginPath() ctx.moveTo(ax + a.dx * 2, ay + a.dy * 2) ctx.lineTo(ex, ey) if (a.dx !== 0) { ctx.moveTo(ex, ey) ctx.lineTo(ex - a.dx * head, ey - head) ctx.moveTo(ex, ey) ctx.lineTo(ex - a.dx * head, ey + head) } else { ctx.moveTo(ex, ey) ctx.lineTo(ex - head, ey - a.dy * head) ctx.moveTo(ex, ey) ctx.lineTo(ex + head, ey - a.dy * head) } ctx.stroke() } ctx.restore() } /** 像素兜底模式:直接描已读到的框(画布像素坐标) */ function flashBox(canvas, box, duration) { return showHighlight( canvas, function (ctx) { paintHighlight(ctx, canvas, { x1: box.x1, y1: box.y1, x2: box.x2, y2: box.y2 }, box.clipped) }, duration ) } /* ======================== 扫描调度 ======================== */ function canvasLabel(canvas, index) { let node = canvas for (let i = 0; i < 4 && node; i++) { if (node.getAttribute) { const text = node.getAttribute('data-camera') || node.getAttribute('title') || '' if (text) return text.trim().slice(0, 24) } node = node.parentElement } return '画布 #' + index } /** * 平台 2D 视图里每台相机一块画布:。 * 有了这张映射,就能把"数据里的框"精准落到它该在的那块画布上(不用猜、不用选中)。 */ function cameraCanvasMap() { const map = new Map() try { const list = document.querySelectorAll('canvas.camera-canvas') for (let i = 0; i < list.length; i++) { const c = list[i] const key = c.getAttribute && (c.getAttribute('data-value') || c.getAttribute('data-camera')) if (key) map.set(String(key), c) } } catch (e) { /* ignore */ } return map } /* ------------------------ 视角切换(点平台的相机页签) ------------------------ */ /** * 平台的 2D 视图一次只显示一路相机:底部有一排页码 * (1 … 选中的那块带 camera-page-select)。 * 点页码就是"切视角"——前端产物里它是 onPageClick → Cn(index) → BUS_CAMERA_SWITCH。 * * 相机画布是 ,与页码同序渲染, * 所以「相机 → 第几页」可以直接从画布顺序推出来,不必猜平台内部状态。 */ function cameraPageInfo() { const info = { keys: [], pages: [], selectIndex: -1 } try { const canvases = document.querySelectorAll('canvas.camera-canvas') for (let i = 0; i < canvases.length; i++) { const k = canvases[i].getAttribute && canvases[i].getAttribute('data-value') info.keys.push(k === null || k === undefined ? '' : String(k)) } const pages = document.querySelectorAll('.camera-page') for (let i = 0; i < pages.length; i++) { info.pages.push(pages[i]) if (pages[i].classList && pages[i].classList.contains('camera-page-select')) info.selectIndex = i } } catch (e) { /* ignore */ } return info } /** 该相机在第几页;找不到返回 -1(没有页签的部署返回 -1,退化成只滚动定位) */ function cameraPageIndex(cameraKey) { if (!cameraKey) return -1 const info = cameraPageInfo() let i = info.keys.indexOf(String(cameraKey)) if (i >= 0 && i < info.pages.length) return i // 兜底:按该帧相机配置的顺序找 try { const cams = camerasOfFrame(platformStore(), state.currentFrameId) if (cams) { i = Object.keys(cams).indexOf(String(cameraKey)) if (i >= 0 && i < info.pages.length) return i } } catch (e) { /* ignore */ } return -1 } /** * 切到指定相机视角(只点平台的页码,不改任何标注数据)。 * 返回 true 表示确实点了页签(界面会在下一帧重绘)。 */ function switchCameraView(cameraKey) { const idx = cameraPageIndex(cameraKey) if (idx < 0) return false const info = cameraPageInfo() if (info.selectIndex === idx) return false try { info.pages[idx].click() return true } catch (e) { return false } } /** * 把「原图像素坐标」换算成「画布的绘制坐标」。 * * ① 首选:用平台自己画底图时的几何(挂钩 drawImage 拿到的 x/y/宽/高)。 * 平台上"原图 → 画布"就是 canvasX = x + px * (宽 / 图片宽), * 而 x/y/宽/高 会随【图片放大缩小、拖动】变化 —— 用这组数换算, * 缩放多少都跟平台画出来的一模一样。 * ② 兜底:抓不到绘制记录时(例如还没重绘过),按 DragImg._fitCanvas 的 * "等比缩放 + 居中留边"推算:ratio = max(图宽/画布宽, 图高/画布高)。 * 返回值里的 inside 表示该点是否落在画布可见范围内。 */ function imageToCanvas(canvas, res, x, y) { const cw = canvas.width const ch = canvas.height if (!res || !res.w || !res.h || !cw || !ch) return null const clampX = (v) => Math.max(0, Math.min(cw - 1, v)) const clampY = (v) => Math.max(0, Math.min(ch - 1, v)) const live = IMG_DRAW.get(canvas) if (live && live.w > 4 && live.h > 4) { const sx = live.w / res.w const sy = live.h / res.h const lxp = live.x + x * sx const lyp = live.y + y * sy return { x: clampX(lxp), y: clampY(lyp), rawX: lxp, rawY: lyp, inside: lxp >= -0.5 && lxp <= cw + 0.5 && lyp >= -0.5 && lyp <= ch + 0.5, } } const ratio = Math.max(res.w / cw, res.h / ch) const dx = (cw - res.w / ratio) / 2 const dy = (ch - res.h / ratio) / 2 const cxp = dx + x / ratio const cyp = dy + y / ratio return { x: clampX(cxp), y: clampY(cyp), rawX: cxp, rawY: cyp, inside: cxp >= -0.5 && cxp <= cw + 0.5 && cyp >= -0.5 && cyp <= ch + 0.5, } } async function scanAll(reason) { if (state.scanning) return state.scanning = true try { // ① 首选:直接校验标注数据(全部框,不依赖是否被选中) const data = validateAllBoxes() const dataOk = data.status === 'ok' // ② 像素扫描:只在【拿不到数据】时作为兜底。 // 平台默认只画选中的框,像素结果天然不完整,绝不能当主判据; // 数据可用时完全不读画布像素(省掉整屏 getImageData 的开销)。 const all = document.querySelectorAll('canvas') const canvases = [] if (!dataOk) { for (let i = 0; i < all.length; i++) { const c = all[i] let r = null try { r = c.getBoundingClientRect() } catch (e) { r = null } if (!r) continue if (r.width >= 80 && r.height >= 60 && r.bottom > 0 && r.top < window.innerHeight * 2) canvases.push(c) } } const results = [] let index = 0 let readable = 0 let unreadable = 0 let empty = 0 const unreadableReasons = [] const sources = {} const byCamera = {} for (const canvas of canvases) { index++ await sleep(0) // 让出主线程,避免大画布扫描卡住页面 let res try { res = analyzeCanvas(canvas) } catch (e) { res = { status: 'error', boxes: [], reason: String((e && e.message) || e) } } if (res.status === 'unreadable' || res.status === 'error') { unreadable++ if (res.reason && unreadableReasons.indexOf(res.reason) < 0) unreadableReasons.push(res.reason) } else if (res.status === 'empty') { empty++ } else { readable++ if (res.pix && res.pix.source) sources[res.pix.source] = (sources[res.pix.source] || 0) + 1 } const label = canvasLabel(canvas, index) if (!byCamera[label]) byCamera[label] = canvas if (res.status !== 'ok' || !res.boxes.length) continue for (let i = 0; i < res.boxes.length; i++) { results.push({ source: 'pixel', canvas, canvasIndex: index, label, box: res.boxes[i], order: i + 1, canvasW: res.pix.w, canvasH: res.pix.h, }) } } let mode = 'pixel' if (dataOk && data.boxes.length) { mode = 'data' } else if (dataOk) { mode = 'data-clean' } if (mode === 'data' || mode === 'data-clean') { // 数据模式:结果全部来自数据;画布按「相机 → camera-canvas」精准对应, // 只有当前帧的框才画得出来(其它帧图片都没加载)。 const byCameraCanvas = cameraCanvasMap() for (const b of data.boxes) { // 拿不到当前帧(isCurrentFrame === null)时不过滤,否则会把所有框都藏掉 if (state.currentFrameOnly && b.isCurrentFrame === false) continue const canvas = (b.camera && byCameraCanvas.get(String(b.camera))) || null results.push({ source: 'data', canvas: canvas, label: b.cameraName || b.camera || '画布', box: b, order: results.length + 1, canvasW: 0, canvasH: 0, canDraw: !!canvas && !!b.isCurrentFrame, }) } } state.results = mode === 'data' || mode === 'data-clean' ? results.filter((r) => r.source === 'data') : results // 面板上的统计口径跟着范围开关走:只看当前帧时,只算当前帧的框 const curOnly = state.currentFrameOnly && !!data.curFrame const scope = curOnly ? data.curFrame : { total2d: data.total2d || 0, checked: data.checked || 0, unknownRes: (data.unknownRes || []).length, boxes: (data.boxes || []).length, } state.lastScan = { at: new Date(), mode: mode, source: data.source || state.dataSource || '', canvases: canvases.length, readable, unreadable, empty, boxes: state.results.length, total2d: scope.total2d || 0, checked: scope.checked || 0, virtualCount: data.virtualCount || 0, skipped: data.skipped || null, unknownRes: scope.unknownRes || 0, // 整包口径(只看当前帧时也留着,用来提示"别的帧还有几个越界") allTotal2d: data.total2d || 0, allChecked: data.checked || 0, allUnknownRes: (data.unknownRes || []).length, allBoxes: (data.boxes || []).length, currentFrameId: data.currentFrameId !== undefined ? data.currentFrameId : state.currentFrameId, currentFrameBoxes: state.results.filter((r) => r.box && r.box.isCurrentFrame === true).length, currentFrameKnown: data.currentFrameId !== null && data.currentFrameId !== undefined, currentFrameOnly: state.currentFrameOnly, marksAt: state.apiMarksAt, marksReason: data.reason || state.marksReason || '', unreadableReasons, sources, } cleanupLegacyNodes() renderPanel(reason) } finally { state.scanning = false } } function scheduleScan(reason) { if (state.scanTimer) clearTimeout(state.scanTimer) state.scanTimer = setTimeout(() => { state.scanTimer = null scanAll(reason) }, SCAN_DEBOUNCE) } /* ======================== 结果面板 ======================== */ /** * 描述一条结果。两种来源的措辞必须区分开: * data —— 直接读标注数据,越界量是精确像素数; * pixel —— 读画布像素,只能判断"哪一侧被裁断",不能给出精确越界量。 */ function describeClipped(box) { if (box.source === 'data' || box.over) { const o = box.over || {} const parts = [] if (o.left) parts.push('左 ' + fmtPx(o.left) + 'px') if (o.right) parts.push('右 ' + fmtPx(o.right) + 'px') if (o.top) parts.push('上 ' + fmtPx(o.top) + 'px') if (o.bottom) parts.push('下 ' + fmtPx(o.bottom) + 'px') return parts.join(' / ') || '-' } const parts = [] if (box.clipped && box.clipped.top) parts.push('上') if (box.clipped && box.clipped.bottom) parts.push('下') if (box.clipped && box.clipped.left) parts.push('左') if (box.clipped && box.clipped.right) parts.push('右') return parts.length ? parts.join('、') + ' 侧被裁断' : '-' } /** 像素数格式化:整数不带小数点,小数最多一位 */ function fmtPx(v) { const n = Number(v) if (!isFinite(n)) return String(v) return Math.abs(n - Math.round(n)) < 0.05 ? String(Math.round(n)) : n.toFixed(1) } /** 数据来源标注(面板用) */ function sourceText(src) { if (src === 'store') return '平台数据' if (src === 'api') return '接口数据' return '像素兜底' } function renderPanel(reason) { if (!state.panelEl || !state.listEl || !state.summaryEl) return const list = state.listEl const n = state.results.length const s0 = state.lastScan const dataMode = s0 && (s0.mode === 'data' || s0.mode === 'data-clean') // 结论条:发现越界(红)/ 校验没做成(黄)/ 干净(绿) const checked = (s0 && s0.checked) || 0 const unknownRes = (s0 && s0.unknownRes) || 0 const curOnly = !!(s0 && s0.currentFrameOnly) const scopeText = curOnly ? '当前帧' : '全部帧' // 只看当前帧时,顺手提示"别的帧还有几个越界",免得以为整包干净 const otherFrames = curOnly && s0 && s0.allBoxes > n ? '(其它帧还有 ' + (s0.allBoxes - n) + ' 个,可切到"全部帧")' : '' if (n) { const curN = (s0 && s0.currentFrameBoxes) || 0 setVerdict( 'bad', (curOnly ? '当前帧发现 ' : '发现 ') + n + ' 个 2D 框超出图片范围' + (!s0 || curOnly || !s0.currentFrameKnown ? '' : '(其中当前帧 ' + curN + ' 个)') + otherFrames ) } else if (!dataMode) { setVerdict('warn', '没读到平台标注数据,暂用画布像素兜底(只能看见选中的框)') } else if (!(s0 && s0.total2d)) { if (s0 && s0.marksObtained === false) setVerdict('warn', '平台标注数据还在加载(store 里暂时没有框)') else setVerdict('good', curOnly ? '当前帧没有 2D 框' : '整包没有 2D 框(0 个)') } else if (!checked) { setVerdict( 'warn', scopeText + '读到 ' + (s0.total2d || 0) + ' 个 2D 框,但一个都没能判定(缺图片分辨率)' ) } else if (unknownRes) { setVerdict( 'warn', scopeText + '已校验 ' + checked + ' 个 2D 框无越界,另有 ' + unknownRes + ' 个缺分辨率没判定' + otherFrames ) } else { setVerdict('good', '已校验' + scopeText + ' ' + checked + ' 个 2D 框:无越界' + otherFrames) } // 面板底部统计 if (state.statEl) { const s = state.lastScan if (!s) { state.statEl.textContent = 'v' + VERSION + ' 初始化…' } else { const t = ('0' + s.at.getHours()).slice(-2) + ':' + ('0' + s.at.getMinutes()).slice(-2) let txt = 'v' + VERSION + ' · ' + t + ' · ' + sourceText(s.source) txt += ' · 判定 ' + (s.checked || 0) + '/' + (s.total2d || 0) if (s.unknownRes) txt += '(缺分辨率 ' + s.unknownRes + ')' if (s.currentFrameId !== null && s.currentFrameId !== undefined && s.currentFrameId !== '') { txt += ' · 帧 ' + s.currentFrameId } if (s.currentFrameOnly && s.allBoxes > (s.boxes || 0)) { txt += ' · 其它帧 ' + (s.allBoxes - (s.boxes || 0)) + ' 个越界' } state.statEl.textContent = txt } } list.innerHTML = '' if (!n) { const tip = document.createElement('div') tip.className = 'odboxcheck-tip' const s = state.lastScan if (!dataMode) { tip.textContent = '还没读到平台标注数据(' + ((s && s.marksReason) || 'store 未就绪,接口响应也没出现') + ')。此模式只能看见已绘制的框 —— 请点「诊断」把信息发我。' } else if (!(s && s.total2d)) { tip.textContent = s && s.marksObtained === false ? '平台标注数据还没加载完,稍等片刻会自动重查。' : (s && s.currentFrameOnly ? '当前帧没有 2D 框。' : '整包没有 2D 框。') + (reason === 'manual' ? '(已重新检查)' : '') } else if (!checked) { tip.textContent = (s && s.currentFrameOnly ? '当前帧读到 ' : '读到 ') + (s.total2d || 0) + ' 个 2D 框,但一个都没能判定:拿不到图片分辨率(' + (s.unknownRes || 0) + ' 个)。请点「诊断」把内容发我。' } else { tip.textContent = (s && s.currentFrameOnly ? '当前帧的 2D 框都在图片范围内。' : '已检查的 2D 框都在图片范围内。') + (s.unknownRes ? '(另有 ' + s.unknownRes + ' 个缺分辨率,未判定)' : '') + (s && s.currentFrameOnly && s.allBoxes > 0 ? '(其它帧还有 ' + s.allBoxes + ' 个越界框,可切到"全部帧")' : '') + (reason === 'manual' ? ' 已重新检查' : '') } list.appendChild(tip) return } for (let i = 0; i < state.results.length; i++) { const r = state.results[i] const row = document.createElement('div') row.className = 'odboxcheck-row' row.title = '点击定位到该框' row.onclick = (function (item) { return function (e) { if (e && e.target && e.target.tagName === 'BUTTON') return focusResult(item) } })(r) const title = document.createElement('div') title.className = 'odboxcheck-rowtitle' const dot = document.createElement('i') dot.className = 'odboxcheck-dot' dot.style.background = r.box.colorKey !== undefined ? keyToCss(r.box.colorKey) : '#ff3d3d' title.appendChild(dot) const head = r.source === 'data' ? '越界 ' + describeClipped(r.box) + ' · 轨迹 ' + r.box.trackId + (r.box.cameraName || r.box.camera ? ' · ' + (r.box.cameraName || r.box.camera) : '') + (r.box.isCurrentFrame === false ? ' · 帧 ' + r.box.frameId + '(非当前帧)' : '') : describeClipped(r.box) + ' · ' + r.label title.appendChild(document.createTextNode(head)) row.appendChild(title) const detail = document.createElement('div') detail.className = 'odboxcheck-detail' if (r.source === 'data') { const b = r.box const coordText = b.type === TOOL_RECT_2D ? '左 ' + fmtPx(b.bounds.x1) + ',上 ' + fmtPx(b.bounds.y1) + ',右 ' + fmtPx(b.bounds.x2) + ',下 ' + fmtPx(b.bounds.y2) : '外接框:左 ' + fmtPx(b.bounds.x1) + ',上 ' + fmtPx(b.bounds.y1) + ',右 ' + fmtPx(b.bounds.x2) + ',下 ' + fmtPx(b.bounds.y2) + '(共 ' + b.pointCount + ' 点)' detail.innerHTML = '越界量:' + describeClipped(r.box) + '
' + '框坐标(原图):' + coordText + '
' + '图片 ' + b.resolution.w + '×' + b.resolution.h + '(' + b.resFrom + ')' + '
' + '相机 ' + (b.cameraName ? b.cameraName + '(' + b.camera + ')' : b.camera || '未知') + ' · 帧 ' + b.frameId + ' · 类别 ' + b.classId + ' · 类型 ' + b.typeText + (b.virtual ? ' · 预测框' : '') + (b.isCurrentFrame ? ' · 当前帧' : '') } else { const hasInset = r.box.inset && (r.box.inset.left || r.box.inset.top || r.box.inset.right || r.box.inset.bottom) detail.innerHTML = '判定:' + describeClipped(r.box) + '
' + '可见框(画布像素):左 ' + r.box.x1 + ',上 ' + r.box.y1 + ',右 ' + r.box.x2 + ',下 ' + r.box.y2 + ',尺寸 ' + r.box.w + '×' + r.box.h + '
' + '画布 ' + r.canvasW + '×' + r.canvasH + (hasInset ? ',图片区 ' + r.box.imgW + '×' + r.box.imgH : '') } row.appendChild(detail) const btn = document.createElement('button') btn.className = 'odboxcheck-locate' btn.textContent = '定位该框' btn.onclick = (function (item) { return function (e) { e.stopPropagation() focusResult(item) } })(r) row.appendChild(btn) list.appendChild(row) } } /** * 定位到某条越界结果: * ① 把对应画布滚到视口中央(含滚动容器兜底); * ② 在画布上把框画出来闪烁(数据模式按图片分辨率换算,像素模式直接描已读到的框); * ③ 在画布旁贴一个红标签说明越界量。 * 数据模式下若该框不在当前帧、或页面上没有对应相机的画布,就如实提示怎么去看。 */ /** 把画布滚进视口:先 scrollIntoView,没生效再逐级找滚动容器兜底 */ function scrollCanvasIntoView(canvas) { try { canvas.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }) } catch (e) { try { canvas.scrollIntoView() } catch (e2) { /* ignore */ } } try { const cr = canvas.getBoundingClientRect() if (cr.top < 0 || cr.bottom > window.innerHeight || cr.left < 0 || cr.right > window.innerWidth) { let node = canvas.parentElement let guard = 0 while (node && guard++ < 12) { const st = getComputedStyle(node) const scrollable = /(auto|scroll|overlay)/.test(st.overflowY + st.overflowX) && (node.scrollHeight > node.clientHeight || node.scrollWidth > node.clientWidth) if (scrollable) { node.scrollTop += cr.top + cr.height / 2 - node.clientHeight / 2 node.scrollLeft += cr.left + cr.width / 2 - node.clientWidth / 2 break } node = node.parentElement } } } catch (e) { /* ignore */ } } /** 画布上的红标签(越界说明),和高亮同一时长,到点自动消失;text 可以是函数(会持续刷新) */ function tagCanvas(canvas, text) { try { const parent = canvas.parentElement if (!parent) return const old = parent.querySelector('.odboxcheck-focus') if (old) old.remove() const tag = document.createElement('div') tag.className = 'odboxcheck-focus' const apply = () => { try { tag.textContent = typeof text === 'function' ? text() : text } catch (e) { /* ignore */ } } apply() parent.appendChild(tag) const loop = setInterval(apply, 200) setTimeout(() => { clearInterval(loop) tag.remove() }, CONFIG.highlightDuration) } catch (e) { /* ignore */ } } /** * 数据模式下高亮这条越界框: * 用「图片分辨率 + 画布尺寸」把原图坐标换算成画布坐标(与平台 DragImg 的 * letterbox 规则一致),越界部分会贴到画布边缘,并标出越界方向。 * 画在覆盖层上,CONFIG.highlightDuration(默认 3 秒)后整层消失。 */ function drawDataBox(canvas, box, duration) { const res = box.resolution if (!imageToCanvas(canvas, res, box.bounds.x1, box.bounds.y1)) return false return showHighlight( canvas, function (ctx) { // ★ 每次重画都重新换算:这样高亮期间用户放大/缩小/拖动,框会跟着走 const a = imageToCanvas(canvas, res, box.bounds.x1, box.bounds.y1) const b = imageToCanvas(canvas, res, box.bounds.x2, box.bounds.y2) if (!a || !b) return paintHighlight(ctx, canvas, { x1: a.x, y1: a.y, x2: b.x, y2: b.y }, box.over || {}) }, duration ) } function focusResult(r) { const box = r.box if (r.source === 'data') { if (box.isCurrentFrame === false) { showToast( '越界框在帧 ' + box.frameId + '(当前帧 ' + (state.currentFrameId === null || state.currentFrameId === undefined ? '?' : state.currentFrameId) + '):先在左侧切到该帧,再点定位。越界 ' + describeClipped(box) + ',相机 ' + (box.cameraName || box.camera || '未知'), 'danger', 8000 ) return } const camName = box.cameraName || box.camera || '未知' /** * 高亮这一步要"等平台把视角切过去之后再画": * 平台一次只显示一路相机,切视角是下一帧才重绘的, * 立刻取到的画布尺寸/位置可能还是旧的,所以延后一拍再滚 + 画。 */ const paint = () => { const cv = cameraCanvasMap().get(String(box.camera || '')) || r.canvas if (!cv) { showToast( '这一帧上找不到「' + camName + '」的画布(可能标题/相机不同名):越界 ' + describeClipped(box) + ',轨迹 ' + box.trackId, 'danger', 8000 ) return } scrollCanvasIntoView(cv) drawDataBox(cv, box, CONFIG.highlightDuration) /** * 标签文本做成函数:高亮期间每 200ms 重算一次, * 这样你在高亮期间放大/缩小,标签里的"是否跑出可见范围"也跟着变。 */ const tagText = () => { let zoomNote = '' try { const q1 = imageToCanvas(cv, box.resolution, box.bounds.x1, box.bounds.y1) const q2 = imageToCanvas(cv, box.resolution, box.bounds.x2, box.bounds.y2) if (q1 && q2 && !q1.inside && !q2.inside) zoomNote = ' · 该框在当前缩放范围外(已贴边指示)' } catch (e) { /* ignore */ } return ( '★ 越界框(轨迹 ' + (box.trackId || '?') + '):' + describeClipped(box) + (box.virtual ? ' · 预测框' : '') + (switched ? ' · 已切到「' + camName + '」' : '') + zoomNote ) } tagCanvas( cv, tagText ) } // 框在别的视角 → 先点平台的相机页签切过去(只切显示,不改数据) const switched = switchCameraView(box.camera) if (switched) setTimeout(paint, 360) else paint() return } // 像素兜底模式 const canvas = r.canvas if (!canvas) { showToast( '越界框(轨迹 ' + (box.trackId || '?') + ',' + r.label + '):' + describeClipped(box) + ',请在平台上切到该相机查看', 'danger', 6000 ) return } scrollCanvasIntoView(canvas) flashBox(canvas, box, CONFIG.highlightDuration) tagCanvas(canvas, '★ 越界框' + (box.trackId ? '(轨迹 ' + box.trackId + ')' : '') + ':' + describeClipped(box)) } function buildPanel() { if (document.getElementById(PANEL_ID)) return const panel = document.createElement('div') panel.id = PANEL_ID panel.innerHTML = '
' + '' + '2D 框越界校验' + 'v' + VERSION + ' · swj' + '' + '
' + '
' + '
' + '' + '正在检查…' + '
' + '
' + '' + '' + '' + '
' + '
' + '
' + '初始化…' + '' + '范围: · ' + '自动巡检:' + '' + '
' + '
' document.body.appendChild(panel) state.panelEl = panel state.listEl = panel.querySelector('.odboxcheck-list') state.summaryEl = panel.querySelector('.odboxcheck-verdict') state.vtextEl = panel.querySelector('.odboxcheck-vtext') state.statEl = panel.querySelector('.odboxcheck-stat') panel.querySelector('.odboxcheck-min').onclick = function () { panel.classList.toggle('is-collapsed') } panel.querySelector('.odboxcheck-recheck').onclick = function () { setVerdict('idle', '正在检查…') scanAll('manual') } panel.querySelector('.odboxcheck-locateall').onclick = function () { // 每个框之间留出"切视角 + 重绘"的时间,避免连续点击互相打断 const step = state.lastScan && state.lastScan.source === 'store' ? 900 : 240 state.results.forEach(function (r, i) { setTimeout(function () { focusResult(r) }, i * step) }) } const autoBtn = panel.querySelector('.odboxcheck-auto') const syncAutoBtn = function () { autoBtn.textContent = state.autoScan ? '开' : '关' autoBtn.classList.toggle('is-on', state.autoScan) } autoBtn.onclick = function () { state.autoScan = !state.autoScan syncAutoBtn() startAuto() } syncAutoBtn() const scopeBtn = panel.querySelector('.odboxcheck-scope') const syncScopeBtn = function () { scopeBtn.textContent = state.currentFrameOnly ? '只看当前帧' : '全部帧' scopeBtn.classList.toggle('is-on', state.currentFrameOnly) } scopeBtn.onclick = function () { state.currentFrameOnly = !state.currentFrameOnly syncScopeBtn() scheduleScan('scope') } syncScopeBtn() panel.querySelector('.odboxcheck-diag').onclick = function () { showDiagnostics() } makeDraggable(panel, panel.querySelector('.odboxcheck-head')) } /* ======================== 诊断(把真实情况打印出来,便于排查) ======================== */ /** * 逐块画布列出:真实上下文类型、尺寸、像素能否读出、读不出时的确切原因。 * 这段输出是排查"校验不出结果"的唯一可靠依据 —— 不靠猜。 */ function collectDiagnostics() { const lines = [] lines.push('=== OD框校验 诊断 v' + VERSION + ' ===') lines.push('页面:' + location.href) lines.push('位置:' + (window.top === window.self ? '主页面' : 'iframe 内')) // —— 数据源 —— const sm = platformStore() const storeMarks = sm ? platformMarks(sm) : null lines.push( '平台运行时句柄:window.instance ' + (pageWin().instance ? '存在' : '不存在') + ';StoreManager ' + (sm ? '可用' + (sm.__vuex ? '(走 $store 兜底)' : '') : '不可用') ) if (sm) { const cfg = (function () { try { return sm.configState() } catch (e) { return null } })() const cams = camerasOfFrame(sm, currentFrameId(sm)) lines.push( '平台数据:轨迹 ' + storeCount(storeMarks) + ' 个 · 帧 ' + ((cfg && cfg.frames && cfg.frames.length) || 0) + ' 个 · 当前帧 ' + currentFrameId(sm) + ' · 当前帧相机 ' + (cams ? Object.keys(cams).length : 0) + ' 个' + (cams ? ':' + Object.keys(cams).slice(0, 8).join(',') : '') ) } lines.push( '接口响应捕获:' + (state.apiMarks ? '有,轨迹 ' + storeCount(state.apiMarks) + ' 个(' + (state.apiMarksUrl || '未知地址') + ')' : '无') ) if (state.marksReason) lines.push('数据捕获说明:' + state.marksReason) lines.push('补丁:' + (HTMLCanvasElement && HTMLCanvasElement.prototype.__odboxPatched ? '已生效' : '未生效')) if (CANVAS_CTX_LOG.length) { lines.push('页面创建过的上下文:') CANVAS_CTX_LOG.forEach((c, i) => { lines.push(' [' + i + '] ' + c.t + ' ' + c.w + 'x' + c.h + (c.id ? ' #' + c.id : '') + (c.cls ? ' .' + c.cls : '')) }) } const camMap = cameraCanvasMap() lines.push('相机画布(canvas.camera-canvas):' + (camMap.size ? Array.from(camMap.keys()).join(',') : '(无)')) lines.push('当前画布:') const all = document.querySelectorAll('canvas') for (let i = 0; i < all.length; i++) { const c = all[i] const kind = canvasKind(c) || '(未知)' let rect = null try { rect = c.getBoundingClientRect() } catch (e) { rect = null } const pix = getCanvasPixels(c) const size = c.width + 'x' + c.height const css = rect ? Math.round(rect.width) + 'x' + Math.round(rect.height) : '?' const draw = IMG_DRAW.get(c) lines.push( ' [' + (i + 1) + '] 内部 ' + size + ' / CSS ' + css + ' / 上下文 ' + kind + ' / 底图 ' + (draw ? 'x=' + Math.round(draw.x) + ' y=' + Math.round(draw.y) + ' w=' + Math.round(draw.w) + ' h=' + Math.round(draw.h) : '(还没抓到绘制,按等比贴合推算)') + ' / ' + (pix ? '可读(' + pix.source + ')' : '不可读:' + (readDiag.detail || '未知原因')) ) } if (!all.length) lines.push(' (页面上没有任何 canvas)') lines.push('最近一次巡检:' + JSON.stringify(state.lastScan || null)) const v = validateAllBoxes() if (v.status === 'ok') { lines.push( '校验:数据源 ' + sourceText(v.source) + ' · 2D 框 ' + (v.total2d || 0) + ' 个(预测框 ' + (v.virtualCount || 0) + ')· 实际判定 ' + (v.checked || 0) + ' 个 · 越界 ' + v.boxes.length + ' 个 · 缺分辨率 ' + ((v.unknownRes || []).length) + ' 个 · 无点/非法点 ' + ((v.skipped && (v.skipped.noPoints + v.skipped.badNum)) || 0) + ' 个' ) v.boxes.slice(0, 12).forEach((b) => { lines.push( ' 轨迹 ' + b.trackId + ' 帧 ' + b.frameId + ' ' + (b.cameraName ? b.cameraName + '(' + b.camera + ')' : b.camera || '') + ' ' + b.typeText + ' 坐标[' + b.rawPoints.join(',') + '] 图片 ' + b.resolution.w + 'x' + b.resolution.h + '(' + b.resFrom + ')' + (b.isCurrentFrame ? ' [当前帧]' : '') + ' → ' + describeClipped(b) ) }) // 打印一条原始样例,便于核对字段 let sample = null const marksForSample = v.source === 'store' ? storeMarks : state.apiMarks for (const t in marksForSample) { const tr = marksForSample[t] if (tr && tr['2d']) { for (const f in tr['2d']) { if (tr['2d'][f] && tr['2d'][f][0]) { sample = { trackId: t, class: tr.class, frameId: f, mark: tr['2d'][f][0] } break } } } if (sample) break } if (sample) lines.push('样例(首条 2D 框):' + JSON.stringify(sample).slice(0, 600)) } else { lines.push('校验:没做成 —— ' + (v.reason || '未知原因')) } return lines.join('\n') } function showDiagnostics() { const text = collectDiagnostics() try { console.log('[OD框校验] 诊断\n' + text) } catch (e) { /* ignore */ } // 面板里直接展示,用户可截图给我 const body = state.panelEl && state.panelEl.querySelector('.odboxcheck-body') if (!body) return const old = body.querySelector('.odboxcheck-diagbox') if (old) old.remove() const box = document.createElement('pre') box.className = 'odboxcheck-diagbox' box.textContent = text box.onclick = function () { try { const r = document.createRange() r.selectNodeContents(box) const sel = window.getSelection() sel.removeAllRanges() sel.addRange(r) } catch (e) { /* ignore */ } } body.appendChild(box) } /** 结论条:idle / good / bad 三态 */ function setVerdict(kind, text) { const el = state.summaryEl if (!el) return el.className = 'odboxcheck-verdict is-' + kind if (state.vtextEl) state.vtextEl.textContent = text } function makeDraggable(panel, handle) { let sx = 0 let sy = 0 let ox = 0 let oy = 0 let dragging = false handle.addEventListener('mousedown', function (e) { if (e.target && e.target.tagName === 'BUTTON') return dragging = true sx = e.clientX sy = e.clientY const rect = panel.getBoundingClientRect() ox = rect.left oy = rect.top panel.style.right = 'auto' panel.style.bottom = 'auto' e.preventDefault() }) window.addEventListener('mousemove', function (e) { if (!dragging) return panel.style.left = ox + (e.clientX - sx) + 'px' panel.style.top = oy + (e.clientY - sy) + 'px' }) window.addEventListener('mouseup', function () { dragging = false }) } function startAuto() { if (state.timer) { clearInterval(state.timer) state.timer = null } if (!state.autoScan || !CONFIG.autoScanInterval) return state.timer = setInterval(function () { // 只看当前帧时,切帧要立刻重算,而不是等下一轮 const cur = currentFrameId(platformStore()) const changed = cur !== null && (!state.lastScan || state.lastScan.currentFrameId !== cur) scheduleScan(changed ? 'frame-changed' : 'auto') }, CONFIG.autoScanInterval) } /* ======================== 样式 ======================== */ const CSS = '#' + PANEL_ID + '{position:fixed;right:16px;top:16px;width:404px;max-width:calc(100vw - 32px);z-index:2147483000;' + 'background:linear-gradient(180deg,#1a1e26,#14171d);color:#e9edf5;' + 'border:1px solid rgba(255,255,255,.08);border-radius:14px;' + 'box-shadow:0 18px 48px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.04);' + 'font:14px/1.75 "PingFang SC","Microsoft YaHei",system-ui,-apple-system,sans-serif;' + 'backdrop-filter:blur(12px);overflow:hidden;user-select:none}' + '#' + PANEL_ID + '.is-collapsed .odboxcheck-body{display:none}' + '#' + PANEL_ID + ' *{box-sizing:border-box}' + // 标题栏 '#' + PANEL_ID + ' .odboxcheck-head{display:flex;align-items:center;gap:9px;padding:11px 14px;cursor:move;' + 'background:linear-gradient(90deg,rgba(110,168,254,.14),rgba(110,168,254,0) 72%);' + 'border-bottom:1px solid rgba(255,255,255,.07)}' + '#' + PANEL_ID + ' .odboxcheck-logo{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;' + 'border-radius:9px;background:linear-gradient(135deg,#8fc0ff,#5b8cff);color:#0c1320;' + 'font-weight:800;font-size:12.5px;flex:0 0 auto;box-shadow:0 4px 12px rgba(91,140,255,.28)}' + '#' + PANEL_ID + ' .odboxcheck-title{font-weight:700;font-size:15.5px;flex:1;letter-spacing:.2px;color:#f3f6fc}' + '#' + PANEL_ID + ' .odboxcheck-author{opacity:.5;font-size:12px;white-space:nowrap;font-variant-numeric:tabular-nums}' + '#' + PANEL_ID + ' .odboxcheck-min{width:26px;height:26px;line-height:1;border:0;border-radius:8px;cursor:pointer;' + 'background:rgba(255,255,255,.06);color:#cbd5e6;font-size:15px;flex:0 0 auto;transition:background .15s}' + '#' + PANEL_ID + ' .odboxcheck-min:hover{background:rgba(255,255,255,.14)}' + // 内容区 '#' + PANEL_ID + ' .odboxcheck-body{padding:14px}' + // 结论条 '#' + PANEL_ID + ' .odboxcheck-verdict{display:flex;align-items:center;gap:9px;padding:10px 12px;border-radius:10px;' + 'font-size:14.5px;font-weight:650;line-height:1.6;margin-bottom:12px;border:1px solid transparent}' + '#' + PANEL_ID + ' .odboxcheck-vdot{width:9px;height:9px;border-radius:50%;flex:0 0 auto;background:currentColor;' + 'box-shadow:0 0 0 3px rgba(255,255,255,.07)}' + '#' + PANEL_ID + ' .odboxcheck-verdict.is-idle{background:rgba(255,255,255,.05);color:#aab3c2;border-color:rgba(255,255,255,.08)}' + '#' + PANEL_ID + ' .odboxcheck-verdict.is-good{background:rgba(88,214,141,.12);color:#6ee7a8;border-color:rgba(110,231,168,.28)}' + '#' + PANEL_ID + ' .odboxcheck-verdict.is-bad{background:rgba(229,72,77,.14);color:#ff9d9d;border-color:rgba(255,157,157,.3)}' + '#' + PANEL_ID + ' .odboxcheck-verdict.is-warn{background:rgba(240,180,41,.13);color:#f2c879;border-color:rgba(242,200,121,.3)}' + // 按钮 '#' + PANEL_ID + ' .odboxcheck-actions{display:flex;gap:10px;margin-bottom:12px}' + '#' + PANEL_ID + ' .odboxcheck-btn{flex:1;padding:10px 12px;border:1px solid rgba(255,255,255,.1);border-radius:10px;' + 'background:rgba(255,255,255,.05);color:#e9edf5;cursor:pointer;font-size:14px;font-weight:600;' + 'font-family:inherit;transition:background .15s,border-color .15s}' + '#' + PANEL_ID + ' .odboxcheck-btn:hover{background:rgba(255,255,255,.1);border-color:rgba(255,255,255,.18)}' + '#' + PANEL_ID + ' .odboxcheck-btn.is-primary{background:linear-gradient(180deg,#8fc0ff,#5b8cff);border-color:transparent;' + 'color:#0b1220;box-shadow:0 6px 16px rgba(91,140,255,.24)}' + '#' + PANEL_ID + ' .odboxcheck-btn.is-primary:hover{filter:brightness(1.07)}' + // 结果列表 '#' + PANEL_ID + ' .odboxcheck-list{max-height:44vh;overflow:auto}' + '#' + PANEL_ID + ' .odboxcheck-row{padding:12px 13px 12px 11px;margin-bottom:10px;border-radius:12px;cursor:pointer;' + 'background:#1b1f27;border:1px solid rgba(255,255,255,.07);border-left:3px solid #e5484d;' + 'transition:background .15s,border-color .15s}' + '#' + PANEL_ID + ' .odboxcheck-row:hover{background:#212734}' + '#' + PANEL_ID + ' .odboxcheck-rowtitle{font-weight:650;font-size:14.5px;display:flex;align-items:center;gap:8px;color:#f2f5fb}' + '#' + PANEL_ID + ' .odboxcheck-dot{width:11px;height:11px;border-radius:3px;flex:0 0 auto;' + 'box-shadow:0 0 0 1px rgba(255,255,255,.35)}' + '#' + PANEL_ID + ' .odboxcheck-detail{font-size:13px;line-height:1.75;margin:6px 0 10px;color:#c2cad9}' + '#' + PANEL_ID + ' .odboxcheck-detail b{color:#ffb1b1;font-size:13.5px}' + '#' + PANEL_ID + ' .odboxcheck-locate{width:100%;padding:9px;border-radius:10px;cursor:pointer;font-size:14px;' + 'font-weight:600;font-family:inherit;border:1px solid rgba(110,168,254,.35);' + 'background:rgba(110,168,254,.12);color:#cfe0ff;transition:background .15s,color .15s}' + '#' + PANEL_ID + ' .odboxcheck-locate:hover{background:#5f8ff9;border-color:#5f8ff9;color:#0b1220}' + '#' + PANEL_ID + ' .odboxcheck-tip{font-size:13.5px;line-height:1.85;color:#b6bfce}' + // 页脚 '#' + PANEL_ID + ' .odboxcheck-foot{margin-top:12px;padding-top:10px;font-size:12px;color:#7f8899;' + 'border-top:1px solid rgba(255,255,255,.07);display:flex;justify-content:space-between;gap:10px;' + 'font-variant-numeric:tabular-nums}' + '#' + PANEL_ID + ' .odboxcheck-link{border:0;background:none;color:#8bb8ff;cursor:pointer;font-size:12px;' + 'font-family:inherit;padding:0;text-decoration:underline;text-underline-offset:2px}' + '#' + PANEL_ID + ' .odboxcheck-link:hover{color:#b9d4ff}' + '#' + PANEL_ID + ' .odboxcheck-link.is-on{color:#6ee7a8}' + '#' + PANEL_ID + ' .odboxcheck-diagbox{margin-top:10px;max-height:230px;overflow:auto;white-space:pre-wrap;' + 'font:11px/1.65 Consolas,Menlo,monospace;color:#cfe0ff;background:rgba(8,10,14,.6);' + 'border:1px solid rgba(255,255,255,.1);border-radius:9px;padding:10px;user-select:text;cursor:text}' + // 高亮覆盖层 + 越界标签 '.odboxcheck-hl{position:absolute;z-index:2147481900;pointer-events:none;transition:opacity .4s ease}' + '.odboxcheck-focus{position:absolute;left:8px;bottom:8px;z-index:2147482000;pointer-events:none;' + 'padding:4px 10px;border-radius:8px;background:rgba(18,20,26,.94);color:#ffd7d7;' + 'border:1px solid rgba(229,72,77,.55);backdrop-filter:blur(4px);' + 'font:600 13px/1.7 "PingFang SC","Microsoft YaHei",sans-serif;box-shadow:0 6px 18px rgba(0,0,0,.55)}' + // 提示气泡与状态条 '.odboxcheck-toast{position:fixed;right:16px;bottom:16px;z-index:2147483001;max-width:380px;' + 'padding:11px 15px;border-radius:12px;background:rgba(18,20,26,.97);color:#e9edf5;' + 'border:1px solid rgba(110,168,254,.3);' + 'font:14px/1.7 "PingFang SC","Microsoft YaHei",sans-serif;' + 'box-shadow:0 12px 30px rgba(0,0,0,.5);pointer-events:none}' + '.odboxcheck-toast.is-danger{border-color:rgba(229,72,77,.5);color:#ffd7d7}' + // 旧版本残留在画布上的角标 / 状态条:直接藏掉(数量只显示在面板里) '.odboxcheck-badge{display:none!important}' + '#odboxcheck-pill{display:none!important}' function injectStyle() { if (typeof GM_addStyle === 'function') { try { GM_addStyle(CSS) return 'gm' } catch (e) { /* 落到下面的