1
This commit is contained in:
-18
@@ -1,18 +0,0 @@
|
||||
*.iml
|
||||
.gradle/
|
||||
local.properties
|
||||
.idea/
|
||||
build/
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
.DS_Store
|
||||
training/datasets/
|
||||
training/venv/
|
||||
training/runs/
|
||||
*.pt
|
||||
*.tflite
|
||||
|
||||
# 文生图配置(含API key,不入库)
|
||||
#training/gen_images_config.json
|
||||
.gstack/
|
||||
@@ -0,0 +1,624 @@
|
||||
{"ts":"2026-08-26T01:45:25.235Z","cmd":"viewport","args":"375x812","origin":"about:blank","durationMs":8,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:45:25.399Z","cmd":"goto","args":"http://127.0.0.1:18080/admin/login","origin":"http://127.0.0.1:18080/admin/login","durationMs":118,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:45:25.464Z","cmd":"wait","args":".login-card","origin":"http://127.0.0.1:18080/admin/login","durationMs":17,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:45:25.631Z","cmd":"screenshot","args":"/tmp/admin-login-mobile.png","origin":"http://127.0.0.1:18080/admin/login","durationMs":123,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:45:25.674Z","cmd":"console","args":"--errors","origin":"http://127.0.0.1:18080/admin/login","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:45:37.994Z","cmd":"js","args":"(() => { const c = document.querySelector('.login-card'); const r = c.getBoundingClientRect(); return JSON.stringify({left: r.left, right: r.right, width: r.width, vw: innerWidth, fits: r.right <= inn…","origin":"http://127.0.0.1:18080/admin/login","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:45:40.257Z","cmd":"snapshot","args":"-i","origin":"http://127.0.0.1:18080/admin/login","durationMs":15,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:45:42.186Z","cmd":"fill","args":"@e1 Tongli686^*^","origin":"http://127.0.0.1:18080/admin/login","durationMs":48,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:45:42.399Z","cmd":"click","args":"@e2","origin":"http://127.0.0.1:18080/admin/orders","durationMs":157,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:45:42.464Z","cmd":"wait","args":".app-layout","origin":"http://127.0.0.1:18080/admin/orders","durationMs":3,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:45:42.513Z","cmd":"url","args":"","origin":"http://127.0.0.1:18080/admin/orders","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:45:45.577Z","cmd":"js","args":"(() => { const aside = document.querySelector('.app-aside'); const ar = aside.getBoundingClientRect(); const items = [...aside.querySelectorAll('.el-menu-item')].map(i => i.getBoundingClientRect()); c…","origin":"http://127.0.0.1:18080/admin/orders","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:45:50.609Z","cmd":"js","args":"(() => { const items = [...document.querySelectorAll('.el-form--inline .el-form-item')]; const rects = items.map(i => { const r = i.getBoundingClientRect(); return {top: Math.round(r.top), left: Math.…","origin":"http://127.0.0.1:18080/admin/orders","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:45:54.078Z","cmd":"goto","args":"http://127.0.0.1:18080/admin/licenses","origin":"http://127.0.0.1:18080/admin/licenses","durationMs":34,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:45:54.133Z","cmd":"wait","args":".el-card","origin":"http://127.0.0.1:18080/admin/licenses","durationMs":8,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:45:54.188Z","cmd":"snapshot","args":"-i","origin":"http://127.0.0.1:18080/admin/licenses","durationMs":13,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:45:56.701Z","cmd":"click","args":"@e8","origin":"http://127.0.0.1:18080/admin/licenses","durationMs":36,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:45:56.756Z","cmd":"wait","args":".el-dialog","origin":"http://127.0.0.1:18080/admin/licenses","durationMs":4,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:45:56.802Z","cmd":"js","args":"(() => { const d = document.querySelector('.el-dialog'); const r = d.getBoundingClientRect(); const btn = d.querySelector('.el-button--primary'); const br = btn.getBoundingClientRect(); return JSON.st…","origin":"http://127.0.0.1:18080/admin/licenses","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:45:56.863Z","cmd":"screenshot","args":"/tmp/admin-licenses-mobile.png","origin":"http://127.0.0.1:18080/admin/licenses","durationMs":18,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:46:00.005Z","cmd":"goto","args":"http://127.0.0.1:18080/admin/app-versions","origin":"http://127.0.0.1:18080/admin/app-versions","durationMs":34,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:46:00.090Z","cmd":"wait","args":".el-card","origin":"http://127.0.0.1:18080/admin/app-versions","durationMs":7,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:46:00.135Z","cmd":"js","args":"(() => { const toolbar = document.querySelector('.toolbar'); const tr = toolbar.getBoundingClientRect(); const btn = toolbar.querySelector('.el-button'); const br = btn.getBoundingClientRect(); const …","origin":"http://127.0.0.1:18080/admin/app-versions","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:46:00.194Z","cmd":"screenshot","args":"/tmp/admin-versions-mobile.png","origin":"http://127.0.0.1:18080/admin/app-versions","durationMs":14,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:46:05.448Z","cmd":"viewport","args":"1280x800","origin":"http://127.0.0.1:18080/admin/app-versions","durationMs":6,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:46:05.536Z","cmd":"goto","args":"http://127.0.0.1:18080/admin/orders","origin":"http://127.0.0.1:18080/admin/orders","durationMs":46,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:46:05.590Z","cmd":"wait","args":".el-card","origin":"http://127.0.0.1:18080/admin/orders","durationMs":6,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T01:46:05.632Z","cmd":"js","args":"(() => { const aside = document.querySelector('.app-aside'); const ar = aside.getBoundingClientRect(); const items = [...aside.querySelectorAll('.el-menu-item')].map(i => i.getBoundingClientRect()); r…","origin":"http://127.0.0.1:18080/admin/orders","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:58:35.113Z","cmd":"goto","args":"http://localhost:5175/admin/","origin":"http://localhost:5175/admin/login","durationMs":310,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:58:35.227Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/login","durationMs":37,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:58:38.259Z","cmd":"fill","args":"@e1 Tongli686^*^","origin":"http://localhost:5175/admin/login","durationMs":70,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:58:38.356Z","cmd":"click","args":"@e2","origin":"http://localhost:5175/admin/login","durationMs":54,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:58:39.434Z","cmd":"url","args":"","origin":"http://localhost:5175/admin/orders","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:58:39.479Z","cmd":"console","args":"--errors","origin":"http://localhost:5175/admin/orders","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:58:59.282Z","cmd":"goto","args":"http://localhost:5175/admin/datasets","origin":"http://localhost:5175/admin/datasets","durationMs":52,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:00.370Z","cmd":"console","args":"--errors","origin":"http://localhost:5175/admin/datasets","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:00.665Z","cmd":"snapshot","args":"-i -C -a -o /tmp/admin-datasets.png","origin":"http://localhost:5175/admin/datasets","durationMs":251,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:05.286Z","cmd":"console","args":"--errors","origin":"http://localhost:5175/admin/datasets","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:05.378Z","cmd":"goto","args":"http://localhost:5175/admin/datasets","origin":"http://localhost:5175/admin/datasets","durationMs":50,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:06.953Z","cmd":"console","args":"--errors","origin":"http://localhost:5175/admin/datasets","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:12.425Z","cmd":"console","args":"--clear","origin":"http://localhost:5175/admin/datasets","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:12.518Z","cmd":"reload","args":"","origin":"http://localhost:5175/admin/datasets","durationMs":51,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:14.097Z","cmd":"console","args":"--errors","origin":"http://localhost:5175/admin/datasets","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:19.042Z","cmd":"screenshot","args":"/tmp/admin-datasets2.png","origin":"http://localhost:5175/admin/datasets","durationMs":41,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:34.487Z","cmd":"reload","args":"","origin":"http://localhost:5175/admin/datasets","durationMs":65,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:35.592Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/datasets","durationMs":24,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:39.310Z","cmd":"click","args":"@e12","origin":"http://localhost:5175/admin/datasets","durationMs":44,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:40.403Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/datasets","durationMs":13,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:40.445Z","cmd":"js","args":"document.querySelectorAll('.img-card img').length + ' img cards; first img natural size: ' + (document.querySelector('.img-card img') ? document.querySelector('.img-card img').naturalWidth + 'x' + doc…","origin":"http://localhost:5175/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:43.948Z","cmd":"js","args":"!!document.querySelector('.el-dialog')","origin":"http://localhost:5175/admin/datasets","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:43.997Z","cmd":"js","args":"document.querySelector('.el-dialog .el-dialog__title') ? document.querySelector('.el-dialog .el-dialog__title').textContent : 'no title'","origin":"http://localhost:5175/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:44.042Z","cmd":"snapshot","args":"-s .el-dialog","origin":"http://localhost:5175/admin/datasets","durationMs":4,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:48.322Z","cmd":"press","args":"Escape","origin":"http://localhost:5175/admin/datasets","durationMs":6,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:48.896Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/datasets","durationMs":10,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:48.948Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/datasets","durationMs":10,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:51.637Z","cmd":"click","args":"@e18","origin":"http://localhost:5175/admin/datasets","durationMs":29,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:52.715Z","cmd":"js","args":"const imgs = document.querySelectorAll('.img-card img'); imgs.length + ' cards, first natural: ' + (imgs[0] ? imgs[0].naturalWidth + 'x' + imgs[0].naturalHeight : 'n/a')","origin":"http://localhost:5175/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:56.002Z","cmd":"js","args":"document.querySelector('.img-card img').src","origin":"http://localhost:5175/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T02:59:56.045Z","cmd":"network","args":"","origin":"http://localhost:5175/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:00:41.422Z","cmd":"goto","args":"http://localhost:5175/admin/datasets","origin":"http://localhost:5175/admin/datasets","durationMs":56,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:00:42.520Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/datasets","durationMs":20,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:00:42.583Z","cmd":"click","args":"@e18","origin":"http://localhost:5175/admin/datasets","durationMs":21,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:00:43.652Z","cmd":"js","args":"const imgs = document.querySelectorAll('.img-card img'); imgs.length + ' cards, natural: ' + (imgs[0] ? imgs[0].naturalWidth + 'x' + imgs[0].naturalHeight : 'n/a')","origin":"http://localhost:5175/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:00:46.141Z","cmd":"js","args":"document.querySelector('.img-card img').getAttribute('src')","origin":"http://localhost:5175/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:00:46.182Z","cmd":"network","args":"","origin":"http://localhost:5175/admin/datasets","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:00:53.237Z","cmd":"js","args":"new Promise(r => { const img = document.querySelector('.img-card img'); if (img.complete && img.naturalWidth > 0) r('loaded already: ' + img.naturalWidth); else { img.onload = () => r('loaded: ' + img…","origin":"http://localhost:5175/admin/datasets","durationMs":3003,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:01:24.611Z","cmd":"reload","args":"","origin":"http://localhost:5175/admin/datasets","durationMs":61,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:01:25.690Z","cmd":"click","args":"@e18","origin":"http://localhost:5175/admin/datasets","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"Ref @e18 not found. Run 'snapshot' to get fresh refs."}
|
||||
{"ts":"2026-08-26T03:01:28.641Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/datasets","durationMs":17,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:01:31.633Z","cmd":"click","args":"@e18","origin":"http://localhost:5175/admin/datasets","durationMs":40,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:01:32.909Z","cmd":"js","args":"new Promise(r => { const img = document.querySelector('.img-card img'); if (!img) return r('no img'); if (img.complete && img.naturalWidth > 0) return r('loaded: ' + img.naturalWidth + 'x' + img.natur…","origin":"http://localhost:5175/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:01:37.035Z","cmd":"snapshot","args":"-i -s .el-dialog","origin":"http://localhost:5175/admin/datasets","durationMs":8,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:01:39.668Z","cmd":"click","args":"@e4","origin":"http://localhost:5175/admin/datasets","durationMs":58,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:01:40.558Z","cmd":"snapshot","args":"-i -s .el-dialog","origin":"http://localhost:5175/admin/datasets","durationMs":7,"status":"error","hasCookies":false,"mode":"launched","error":"snapshot: Error: strict mode violation: locator('.el-dialog') resolved to 2 elements:\n 1) <div tabindex=\"-1\" class=\"el-dialog\">…</div> aka getByText('图片管理 - 野鸡数据集选择图片上传AI 生成 删除选中(')\n 2) <div tabindex=\"-1\" class=\"el-dialog\">…</div> aka getByText('AI 生成图片提示词0 / 500生成张数图片尺寸竖版')\n\nCall log:\n\u001b[2m -…"}
|
||||
{"ts":"2026-08-26T03:01:43.061Z","cmd":"snapshot","args":"-i -a -o /tmp/gen-dialog.png","origin":"http://localhost:5175/admin/datasets","durationMs":345,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:01:45.616Z","cmd":"fill","args":"@e33 户外田野背景,一只色彩鲜艳的野生雉鸡站立在草丛中","origin":"http://localhost:5175/admin/datasets","durationMs":6,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:01:45.695Z","cmd":"click","args":"@e40","origin":"http://localhost:5175/admin/datasets","durationMs":37,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:01:47.266Z","cmd":"js","args":"document.querySelector('.el-message') ? document.querySelector('.el-message').textContent : 'no toast'","origin":"http://localhost:5175/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:01:50.706Z","cmd":"press","args":"Escape","origin":"http://localhost:5175/admin/datasets","durationMs":5,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:01:51.059Z","cmd":"press","args":"Escape","origin":"http://localhost:5175/admin/datasets","durationMs":3,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:01:51.155Z","cmd":"goto","args":"http://localhost:5175/admin/label-workbench?datasetId=3","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":52,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:01:52.213Z","cmd":"console","args":"--errors","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:01:52.272Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":13,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:02:10.817Z","cmd":"goto","args":"http://localhost:5175/admin/label-workbench?datasetId=3","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":61,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:02:12.403Z","cmd":"console","args":"--errors","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:02:12.447Z","cmd":"js","args":"const c = document.querySelector('.wb-canvas'); c ? ('canvas ' + c.width + 'x' + c.height) : 'no canvas'","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:02:12.503Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":15,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:02:26.576Z","cmd":"goto","args":"http://localhost:5175/admin/label-workbench?datasetId=3","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":50,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:02:28.161Z","cmd":"js","args":"const c = document.querySelector('.wb-canvas'); 'canvas ' + c.width + 'x' + c.height","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:02:28.207Z","cmd":"js","args":"\nconst canvas = document.querySelector('.wb-canvas');\nconst ctx = canvas.getContext('2d');\n// 采样 canvas 上三个候选框中心点与非框区域的颜色(class0 绿、class1 橙、底色非绿橙)\nfunction px(x, y) { const d = ctx.getImageData(x, y, …","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:02:31.551Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":13,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:02:35.261Z","cmd":"click","args":"@e16","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":21,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:02:36.337Z","cmd":"js","args":"\nconst canvas = document.querySelector('.wb-canvas');\nconst out = {canvas: canvas.width + 'x' + canvas.height};\nconst ctx = canvas.getContext('2d');\nfunction px(x, y) { const d = ctx.getImageData(Math…","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:02:47.514Z","cmd":"js","args":"\nconst c = document.querySelector('.wb-canvas');\nconst r = c.getBoundingClientRect();\nconst fire = (type, x, y) => c.dispatchEvent(new PointerEvent(type, {clientX: r.left + x, clientY: r.top + y, poin…","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:02:47.894Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":6,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:02:47.937Z","cmd":"js","args":"document.querySelector('.el-button--success') ? document.querySelector('.el-button--success').textContent.trim() : 'not found'","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:02:57.040Z","cmd":"js","args":"\nconst c = document.querySelector('.wb-canvas');\nconst r = c.getBoundingClientRect();\nconst fire = (type, x, y) => c.dispatchEvent(new PointerEvent(type, {clientX: r.left + x, clientY: r.top + y, poin…","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:02:57.412Z","cmd":"js","args":"document.querySelector('.el-button--success').textContent.trim()","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:03:11.202Z","cmd":"js","args":"\nconst c = document.querySelector('.wb-canvas');\nconst r = c.getBoundingClientRect();\nconst fire = (type, x, y) => c.dispatchEvent(new PointerEvent(type, {clientX: r.left + x, clientY: r.top + y, poin…","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":1773,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:03:11.579Z","cmd":"js","args":"document.querySelector('.el-button--success').textContent.trim()","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:03:21.910Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":9,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:04:13.694Z","cmd":"click","args":"@e14","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":26,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:04:17.154Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":6,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:04:50.679Z","cmd":"eval","args":"\nconst c = document.querySelector('.wb-canvas');\nconst ctx = c.getContext('2d');\nconst px = (x, y) => Array.from(ctx.getImageData(Math.round(x*c.width), Math.round(y*c.height), 1, 1).data).slice(0,3);…","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"File not found: \nconst c = document.querySelector('.wb-canvas');\nconst ctx = c.getContext('2d');\nconst px = (x, y) => Array.from(ctx.getImageData(Math.round(x*c.width), Math.round(y*c.height), 1, 1).data).slice(0,3);\n// 已保存确认框中心 (0.184, 0.151),候选框 (0.5, 0.45) 虚线,背景点 (0.05, 0.05)\nJSON.stringify({boxC…"}
|
||||
{"ts":"2026-08-26T03:04:53.016Z","cmd":"eval","args":"document.title","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"File not found: document.title"}
|
||||
{"ts":"2026-08-26T03:05:06.278Z","cmd":"eval","args":"/tmp/wb_check.js","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":3,"status":"error","hasCookies":false,"mode":"launched","error":"evaluate: SyntaxError: Illegal return statement\n at eval (<anonymous>)\n at UtilityScript.evaluate (<anonymous>:290:30)\n at UtilityScript.<anonymous> (<anonymous>:1:44)"}
|
||||
{"ts":"2026-08-26T03:05:13.015Z","cmd":"eval","args":"/tmp/wb_check.js","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"evaluate: SyntaxError: Illegal return statement\n at eval (<anonymous>)\n at UtilityScript.evaluate (<anonymous>:290:30)\n at UtilityScript.<anonymous> (<anonymous>:1:44)"}
|
||||
{"ts":"2026-08-26T03:05:32.276Z","cmd":"eval","args":"/tmp/wb_check.js","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:05:32.327Z","cmd":"eval","args":"JSON.stringify(window.__result)","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"File not found: JSON.stringify(window.__result)"}
|
||||
{"ts":"2026-08-26T03:05:45.429Z","cmd":"eval","args":"/tmp/wb_result.js","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:05:52.078Z","cmd":"eval","args":"/tmp/wb_result.js","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":3,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:06:11.591Z","cmd":"eval","args":"/tmp/wb_result.js","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:06:34.993Z","cmd":"eval","args":"/tmp/wb_click.js","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":4,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:06:36.097Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":19,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:06:45.487Z","cmd":"goto","args":"http://localhost:5175/admin/trainings","origin":"http://localhost:5175/admin/trainings","durationMs":130,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:06:45.591Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/trainings","durationMs":21,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:06:52.132Z","cmd":"click","args":"@e10","origin":"http://localhost:5175/admin/trainings","durationMs":53,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:06:52.201Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/trainings","durationMs":15,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:06:59.535Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/trainings","durationMs":11,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:07:06.265Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/trainings","durationMs":9,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:07:13.869Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/trainings","durationMs":10,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:07:20.288Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/trainings","durationMs":9,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:07:27.725Z","cmd":"click","args":"@e15","origin":"http://localhost:5175/admin/trainings","durationMs":32,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:07:28.307Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/trainings","durationMs":10,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:07:35.215Z","cmd":"press","args":"Escape","origin":"http://localhost:5175/admin/trainings","durationMs":4,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:07:35.329Z","cmd":"goto","args":"http://localhost:5175/admin/models","origin":"http://localhost:5175/admin/models","durationMs":72,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:07:35.390Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/models","durationMs":15,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:07:43.688Z","cmd":"viewport","args":"375 812","origin":"http://localhost:5175/admin/models","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"Unexpected positional arg: 812. Usage: viewport [WxH] [--scale <n>]"}
|
||||
{"ts":"2026-08-26T03:07:43.792Z","cmd":"goto","args":"http://localhost:5175/admin/datasets","origin":"http://localhost:5175/admin/datasets","durationMs":65,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:07:43.866Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/datasets","durationMs":17,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:07:50.742Z","cmd":"viewport","args":"375x812","origin":"http://localhost:5175/admin/datasets","durationMs":11,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:07:50.879Z","cmd":"goto","args":"http://localhost:5175/admin/datasets","origin":"http://localhost:5175/admin/datasets","durationMs":74,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:07:51.454Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/datasets","durationMs":16,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:08:06.431Z","cmd":"eval","args":"/tmp/mobile_check.js","origin":"http://localhost:5175/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:08:06.483Z","cmd":"snapshot","args":"","origin":"http://localhost:5175/admin/datasets","durationMs":8,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:08:19.823Z","cmd":"eval","args":"/tmp/mobile_result.js","origin":"http://localhost:5175/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:08:27.211Z","cmd":"goto","args":"http://localhost:5175/admin/label-workbench?datasetId=3","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":52,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:08:27.788Z","cmd":"eval","args":"/tmp/mobile_check.js","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:08:27.831Z","cmd":"eval","args":"/tmp/mobile_result.js","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:08:34.641Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":13,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:08:49.446Z","cmd":"eval","args":"/tmp/thumbs_check.js","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:08:49.497Z","cmd":"eval","args":"/tmp/mobile_result.js","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:09:02.846Z","cmd":"eval","args":"/tmp/thumbs_result.js","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T03:09:25.847Z","cmd":"viewport","args":"1280x800","origin":"http://localhost:5175/admin/label-workbench?datasetId=3","durationMs":9,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:31:30.651Z","cmd":"goto","args":"http://127.0.0.1:18080/admin/","origin":"http://127.0.0.1:18080/admin/login","durationMs":124,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:31:30.701Z","cmd":"console","args":"--errors","origin":"http://127.0.0.1:18080/admin/login","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:31:30.774Z","cmd":"snapshot","args":"-i -c","origin":"http://127.0.0.1:18080/admin/login","durationMs":32,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:31:34.988Z","cmd":"fill","args":"@e1 Tongli686^*^","origin":"http://127.0.0.1:18080/admin/login","durationMs":42,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:31:35.128Z","cmd":"click","args":"@e2","origin":"http://127.0.0.1:18080/admin/datasets","durationMs":97,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:31:38.204Z","cmd":"snapshot","args":"-i -c","origin":"http://127.0.0.1:18080/admin/datasets","durationMs":9,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:31:43.508Z","cmd":"screenshot","args":"/tmp/ds-grid.png","origin":"http://127.0.0.1:18080/admin/datasets","durationMs":132,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:31:43.594Z","cmd":"click","args":"@c13","origin":"http://127.0.0.1:18080/admin/datasets","durationMs":40,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:31:48.676Z","cmd":"click","args":"@e9","origin":"http://127.0.0.1:18080/admin/datasets","durationMs":5037,"status":"error","hasCookies":false,"mode":"launched","error":"click: Timeout 5000ms exceeded.\nCall log:\n\u001b[2m - waiting for getByRole('button', { name: '开始训练' })\u001b[22m\n\u001b[2m - locator resolved to <button type=\"button\" data-v-199f820c=\"\" aria-disabled=\"false\" class=\"el-button el-button--primary el-button--small\">…</button>\u001b[22m\n\u001b[2m - attempting click action\u001b…"}
|
||||
{"ts":"2026-08-26T04:31:48.754Z","cmd":"js","args":"document.querySelectorAll('.ds-card')[0].click()","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":28,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:31:50.828Z","cmd":"url","args":"","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:31:55.965Z","cmd":"snapshot","args":"-i -c","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":10,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:31:56.011Z","cmd":"console","args":"--errors","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:32:02.211Z","cmd":"js","args":"document.querySelectorAll('.img-card')[0].click(); document.querySelectorAll('.img-card')[1].click()","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:32:03.301Z","cmd":"snapshot","args":"-i -c","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":9,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:32:08.158Z","cmd":"dialog-accept","args":"开始标注","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:32:08.236Z","cmd":"click","args":"@e14","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":37,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:32:10.311Z","cmd":"dialog","args":"","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:32:10.364Z","cmd":"snapshot","args":"-i -c","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":8,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:32:16.740Z","cmd":"js","args":"document.body.innerText.includes('自动标注中') ? 'progress-shown' : document.body.innerText.includes('自动标注完成') ? 'done-msg' : 'none'","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":3,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:32:29.533Z","cmd":"click","args":"@e14","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":5017,"status":"error","hasCookies":false,"mode":"launched","error":"click: Timeout 5000ms exceeded.\nCall log:\n\u001b[2m - waiting for getByRole('button', { name: '批量自动标注(2)' })\u001b[22m\n\u001b[2m - locator resolved to <button type=\"button\" data-v-d02963be=\"\" aria-disabled=\"false\" class=\"el-button el-button--success\">…</button>\u001b[22m\n\u001b[2m - attempting click action\u001b[22m\n\u001b[2m …"}
|
||||
{"ts":"2026-08-26T04:32:36.077Z","cmd":"snapshot","args":"-i -c","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":10,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:32:36.133Z","cmd":"snapshot","args":"-i -c","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":7,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:32:41.182Z","cmd":"click","args":"@e14","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":5008,"status":"error","hasCookies":false,"mode":"launched","error":"click: Timeout 5000ms exceeded.\nCall log:\n\u001b[2m - waiting for getByRole('button', { name: '批量自动标注(2)' })\u001b[22m\n\u001b[2m - locator resolved to <button type=\"button\" data-v-d02963be=\"\" aria-disabled=\"false\" class=\"el-button el-button--success\">…</button>\u001b[22m\n\u001b[2m - attempting click action\u001b[22m\n\u001b[2m …"}
|
||||
{"ts":"2026-08-26T04:32:47.840Z","cmd":"js","args":"document.querySelectorAll('.img-toolbar button')[3].click(); 'clicked'","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":6,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:32:48.916Z","cmd":"text","args":"","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:32:57.394Z","cmd":"snapshot","args":"-i -c","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":8,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:32:57.448Z","cmd":"js","args":"[...document.querySelectorAll('.el-message-box__btns button')].find(b => b.textContent.trim()==='开始标注').click(); 'ok'","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":13,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:32:58.525Z","cmd":"js","args":"document.body.innerText.includes('自动标注已发起') ? 'started' : 'no-msg'","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:33:03.713Z","cmd":"js","args":"document.body.innerText.includes('自动标注中') ? 'progress' : document.body.innerText.includes('自动标注完成') ? 'done' : 'waiting'","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:33:12.990Z","cmd":"js","args":"document.body.innerText.includes('自动标注完成') ? 'success-msg' : 'none'","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:33:13.033Z","cmd":"console","args":"--errors","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:33:13.107Z","cmd":"screenshot","args":"/tmp/detail-images.png","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":29,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:33:29.065Z","cmd":"js","args":"[...document.querySelectorAll('.el-tabs__item')].find(t => t.textContent.trim()==='标注').click(); 'ok'","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":4,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:33:31.142Z","cmd":"js","args":"document.querySelector('.wb-canvas') ? 'canvas-ok' : 'no-canvas'","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:33:31.185Z","cmd":"js","args":"document.querySelectorAll('.thumb').length + ' thumbs'; ","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:33:31.259Z","cmd":"screenshot","args":"/tmp/detail-label.png","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":33,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:33:44.049Z","cmd":"js","args":"\nconst c = document.querySelector('.wb-canvas');\nif (!c) 'no canvas';\nelse {\n const ctx = c.getContext('2d');\n const d = ctx.getImageData(0, 0, c.width, c.height).data;\n let colored = 0, dark = 0, …","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":2,"status":"error","hasCookies":false,"mode":"launched","error":"evaluate: SyntaxError: Illegal return statement\n at eval (<anonymous>)\n at UtilityScript.evaluate (<anonymous>:290:30)\n at UtilityScript.<anonymous> (<anonymous>:1:44)"}
|
||||
{"ts":"2026-08-26T04:33:46.918Z","cmd":"js","args":"(function(){const c = document.querySelector('.wb-canvas'); if(!c) return 'no canvas'; const ctx=c.getContext('2d'); const d=ctx.getImageData(0,0,c.width,c.height).data; let green=0,dark=0; for(let i=…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:33:55.670Z","cmd":"js","args":"(function(){const img=document.querySelector('.wb-img'); const c=document.querySelector('.wb-canvas'); const box=JSON.stringify(document.querySelector('.thumb img') ? {thumbSrc: document.querySelector…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:34:06.519Z","cmd":"js","args":"(function(){const c=document.querySelector('.wb-canvas'); const ctx=c.getContext('2d'); const px=ctx.getImageData(50,50,1,1).data; const cx=ctx.getImageData(c.width>>1,c.height>>1,1,1).data; const all…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":3,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:34:13.154Z","cmd":"js","args":"(function(){const c=document.querySelector('.wb-canvas'); const ctx=c.getContext('2d'); const img=document.querySelector('.wb-img'); ctx.drawImage(img,0,0,c.width,c.height); const px=ctx.getImageData(…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:35:53.109Z","cmd":"js","args":"async () => {\n const src = document.querySelector('.wb-img').src;\n const img = new Image();\n img.src = src;\n await img.decode();\n const c = document.createElement('canvas');\n c.width = img.natur…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:35:54.947Z","cmd":"snapshot","args":"","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":10,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:35:57.639Z","cmd":"js","args":"() => {\n const wb = document.querySelector('.wb-img');\n const tabs = [...document.querySelectorAll('.el-tabs__item')].map(t => t.textContent + ':' + (t.classList.contains('is-active') ? 'active' : '…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:36:07.109Z","cmd":"js","args":"document.querySelector('.wb-img') ? 'has-wb-img' : 'no-wb-img'","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:36:10.978Z","cmd":"js","args":"new Promise(r => setTimeout(() => r('promise-ok:' + document.querySelectorAll('.wb-img').length), 100))","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":102,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:36:14.391Z","cmd":"js","args":"new Promise(async (resolve) => {\n try {\n const src = document.querySelector('.wb-img').src;\n const img = new Image();\n img.src = src;\n await img.decode();\n const c = document.createEle…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:36:19.441Z","cmd":"js","args":"new Promise(function(resolve) {\n try {\n const img = new Image();\n img.onload = function() {\n const c = document.createElement('canvas');\n c.width = img.naturalWidth; c.height = img.na…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:37:39.350Z","cmd":"reload","args":"","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":60,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:37:39.416Z","cmd":"js","args":"() => document.title + ' | tab: ' + (document.querySelector('.el-tabs__item.is-active') ? document.querySelector('.el-tabs__item.is-active').textContent : 'none')","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:37:44.684Z","cmd":"js","args":"new Promise(function(resolve) {\n const tabs = [...document.querySelectorAll('.el-tabs__item')];\n const label = tabs.find(t => t.textContent.includes('标注'));\n if (label) label.click();\n setTimeout(…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":1507,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:38:04.597Z","cmd":"js","args":"new Promise(function(resolve) {\n setTimeout(function() {\n const c = document.querySelector('.wb-canvas');\n if (!c) { resolve('no canvas'); return; }\n const ctx = c.getContext('2d');\n cons…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":2005,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:38:14.250Z","cmd":"viewport","args":"375x812","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":4,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:38:14.327Z","cmd":"goto","args":"http://127.0.0.1:18080/admin/datasets","origin":"http://127.0.0.1:18080/admin/datasets","durationMs":32,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:38:17.445Z","cmd":"js","args":"() => {\n const dsCards = document.querySelectorAll('.ds-card');\n const grid = document.querySelector('.ds-grid');\n const toolbar = document.querySelector('.toolbar');\n const aside = document.query…","origin":"http://127.0.0.1:18080/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:38:21.782Z","cmd":"js","args":"new Promise(function(resolve) {\n setTimeout(function() {\n try {\n const dsCards = document.querySelectorAll('.ds-card');\n const grid = document.querySelector('.ds-grid');\n const vw =…","origin":"http://127.0.0.1:18080/admin/datasets","durationMs":803,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:38:29.311Z","cmd":"goto","args":"http://127.0.0.1:18080/admin/datasets/101","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":28,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:38:30.873Z","cmd":"js","args":"new Promise(function(resolve) {\n setTimeout(function() {\n try {\n const tabs = [...document.querySelectorAll('.el-tabs__item')].map(function(t){return t.textContent;});\n const vw = window…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":1505,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:38:35.260Z","cmd":"js","args":"new Promise(function(resolve) {\n setTimeout(function() {\n try {\n const c = document.querySelector('.wb-canvas');\n const ctx = c.getContext('2d');\n const d = ctx.getImageData(0, 0, c…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":805,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:38:43.592Z","cmd":"viewport","args":"1440x900","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":3,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:38:44.941Z","cmd":"js","args":"new Promise(function(resolve) {\n setTimeout(function() {\n try {\n const tabs = [...document.querySelectorAll('.el-tabs__item')];\n const modelTab = tabs.find(function(t){return t.textConte…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":1305,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:38:49.967Z","cmd":"js","args":"new Promise(function(resolve) {\n setTimeout(function() {\n try {\n const tabs = [...document.querySelectorAll('.el-tabs__item')];\n tabs.find(function(t){return t.textContent.includes('训练')…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":1304,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:38:55.957Z","cmd":"js","args":"new Promise(function(resolve) {\n try {\n const btns = [...document.querySelectorAll('button')].filter(function(b){return b.textContent.includes('发起训练');});\n if (!btns.length) { resolve('no start…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":503,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:38:59.649Z","cmd":"js","args":"new Promise(function(resolve) {\n try {\n const btns = [...document.querySelectorAll('.el-dialog .el-button')].filter(function(b){return b.textContent.trim() === '发起训练';});\n btns[0].click();\n …","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":604,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:39:49.340Z","cmd":"js","args":"new Promise(function(resolve) {\n try {\n const tabs = [...document.querySelectorAll('.el-tabs__item')];\n tabs.find(function(t){return t.textContent.includes('标注');}).click();\n setTimeout(func…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":505,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:40:16.537Z","cmd":"js","args":"new Promise(function(resolve) {\n try {\n const c = document.querySelector('.wb-canvas');\n const r = c.getBoundingClientRect();\n // 候选框1 归一化中心 (0.073, 0.117) → CSS 坐标\n const cx = r.left + 0…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":404,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:40:20.707Z","cmd":"js","args":"new Promise(function(resolve) {\n try {\n const saveBtn = [...document.querySelectorAll('button')].find(function(b){return b.textContent.trim() === '保存本张';});\n saveBtn.click();\n setTimeout(fun…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":1204,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:40:37.308Z","cmd":"js","args":"new Promise(function(resolve) {\n try {\n const tabs = [...document.querySelectorAll('.el-tabs__item')];\n tabs.find(function(t){return t.textContent.includes('训练');}).click();\n setTimeout(func…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":904,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:40:40.122Z","cmd":"js","args":"new Promise(function(resolve) {\n try {\n const btns = [...document.querySelectorAll('.el-dialog .el-button')].filter(function(b){return b.textContent.trim() === '发起训练';});\n btns[0].click();\n …","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":804,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:40:53.278Z","cmd":"js","args":"new Promise(function(resolve) {\n try {\n const btns = [...document.querySelectorAll('.el-dialog .el-button')].filter(function(b){return b.textContent.trim() === '取消';});\n if (btns.length) btns[0…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":804,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:40:57.958Z","cmd":"js","args":"new Promise(function(resolve) {\n try {\n const tabs = [...document.querySelectorAll('.el-tabs__item')];\n const imgTab = tabs.find(function(t){return t.textContent.includes('图片');});\n imgTab.c…","origin":"http://127.0.0.1:18080/admin/datasets/101","durationMs":1207,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:41:27.310Z","cmd":"goto","args":"http://127.0.0.1:18080/admin/datasets","origin":"http://127.0.0.1:18080/admin/datasets","durationMs":29,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:41:28.471Z","cmd":"js","args":"new Promise(function(resolve) {\n setTimeout(function() {\n try {\n const btns = [...document.querySelectorAll('.ds-card .el-button')];\n const del = btns.find(function(b){return b.textConte…","origin":"http://127.0.0.1:18080/admin/datasets","durationMs":1108,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:41:31.953Z","cmd":"js","args":"new Promise(function(resolve) {\n try {\n const dlg = document.querySelector('.el-message-box');\n const ok = [...dlg.querySelectorAll('button')].find(function(b){return b.textContent.trim() === '…","origin":"http://127.0.0.1:18080/admin/datasets","durationMs":1005,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:50:29.939Z","cmd":"goto","args":"http://127.0.0.1:18080/admin/datasets","origin":"http://127.0.0.1:18080/admin/datasets","durationMs":59,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:50:30.794Z","cmd":"js","args":"new Promise(function(resolve) {\n setTimeout(function() {\n try {\n const cards = [...document.querySelectorAll('.ds-card')];\n const info = cards.map(function(c) {\n const name = c.qu…","origin":"http://127.0.0.1:18080/admin/datasets","durationMs":804,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:50:36.984Z","cmd":"goto","args":"http://127.0.0.1:18080/admin/datasets/102","origin":"http://127.0.0.1:18080/admin/datasets/102","durationMs":30,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:50:41.087Z","cmd":"js","args":"new Promise(function(resolve) {\n setTimeout(function() {\n try {\n const imgTab = [...document.querySelectorAll('.el-tabs__item')].find(function(t){return t.textContent.includes('图片');});\n …","origin":"http://127.0.0.1:18080/admin/datasets/102","durationMs":3444,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:50:47.928Z","cmd":"js","args":"new Promise(function(resolve) {\n try {\n const labelTab = [...document.querySelectorAll('.el-tabs__item')].find(function(t){return t.textContent.includes('标注');});\n labelTab.click();\n setTime…","origin":"http://127.0.0.1:18080/admin/datasets/102","durationMs":1504,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:50:52.635Z","cmd":"console","args":"--errors","origin":"http://127.0.0.1:18080/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:50:52.680Z","cmd":"network","args":"","origin":"http://127.0.0.1:18080/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:58:34.624Z","cmd":"reload","args":"","origin":"http://127.0.0.1:18080/admin/datasets/102","durationMs":335,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:58:38.188Z","cmd":"js","args":"new Promise(function(resolve) {\n setTimeout(function() {\n try {\n const labelTab = [...document.querySelectorAll('.el-tabs__item')].find(function(t){return t.textContent.includes('标注');});\n …","origin":"http://127.0.0.1:18080/admin/datasets/102","durationMs":3032,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:59:48.033Z","cmd":"goto","args":"http://127.0.0.1:18080/admin/datasets/102","origin":"http://127.0.0.1:18080/admin/datasets/102","durationMs":113,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T04:59:51.706Z","cmd":"js","args":"new Promise(function(resolve) {\n setTimeout(function() {\n try {\n const labelTab = [...document.querySelectorAll('.el-tabs__item')].find(function(t){return t.textContent.includes('标注');});\n …","origin":"http://127.0.0.1:18080/admin/datasets/102","durationMs":3132,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:00:01.139Z","cmd":"js","args":"new Promise(function(resolve) {\n setTimeout(function() {\n try {\n const canvas = document.querySelector('.wb-canvas');\n const ctx = canvas.getContext('2d');\n const d = ctx.getImageDa…","origin":"http://127.0.0.1:18080/admin/datasets/102","durationMs":807,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:24:32.913Z","cmd":"goto","args":"http://localhost:5173/admin/","origin":"http://localhost:5173/admin/login","durationMs":635,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:24:32.967Z","cmd":"text","args":"","origin":"http://localhost:5173/admin/login","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:24:35.166Z","cmd":"snapshot","args":"-i","origin":"http://localhost:5173/admin/login","durationMs":22,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:24:39.603Z","cmd":"fill","args":"@e1 Tongli686^*^","origin":"http://localhost:5173/admin/login","durationMs":18,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:24:39.698Z","cmd":"click","args":"@e2","origin":"http://localhost:5173/admin/login","durationMs":52,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:24:40.770Z","cmd":"text","args":"","origin":"http://localhost:5173/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:24:45.582Z","cmd":"goto","args":"http://localhost:5173/admin/datasets/102","origin":"http://localhost:5173/admin/datasets/102","durationMs":50,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:24:48.648Z","cmd":"text","args":"","origin":"http://localhost:5173/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:24:55.638Z","cmd":"screenshot","args":"/tmp/detail_rows.png","origin":"http://localhost:5173/admin/datasets/102","durationMs":99,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:24:55.682Z","cmd":"console","args":"","origin":"http://localhost:5173/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:24:59.945Z","cmd":"screenshot","args":"/tmp/detail_rows.jpg","origin":"http://localhost:5173/admin/datasets/102","durationMs":26,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:25:13.728Z","cmd":"js","args":"\nconst boxes = document.querySelectorAll('.ov-box');\nconst first = boxes[0] ? getComputedStyle(boxes[0]) : null;\nJSON.stringify({\n rowCount: document.querySelectorAll('.row').length,\n ovBoxCount: bo…","origin":"http://localhost:5173/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:25:18.573Z","cmd":"js","args":"\nconst rows = document.querySelectorAll('.row');\nconst origImg = rows[0].querySelectorAll('.cell')[0].querySelector('.cell-img');\norigImg.click(); 'clicked'","origin":"http://localhost:5173/admin/datasets/102","durationMs":7,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:25:19.645Z","cmd":"js","args":"\nconst dlg = document.querySelector('.el-dialog');\nJSON.stringify({\n visible: !!dlg && getComputedStyle(dlg).display !== 'none',\n title: dlg ? dlg.querySelector('.el-dialog__title')?.textContent : n…","origin":"http://localhost:5173/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:25:27.143Z","cmd":"js","args":"\ndocument.querySelector('.viewer-body') && [...document.querySelectorAll('.el-dialog button')].find(b=>b.textContent.includes('关闭')).click(); 'closed'","origin":"http://localhost:5173/admin/datasets/102","durationMs":6,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:25:28.225Z","cmd":"js","args":"\nconst rows = document.querySelectorAll('.row');\nconst labelImg = rows[0].querySelectorAll('.cell')[2].querySelector('.cell-img');\nlabelImg.click(); 'clicked'","origin":"http://localhost:5173/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"evaluate: TypeError: Cannot read properties of undefined (reading 'querySelector')\n at eval (eval at evaluate (:290:30), <anonymous>:2:54)\n at eval (<anonymous>)\n at UtilityScript.evaluate (<anonymous>:290:30)\n at UtilityScript.<anonymous> (<anonymous>:1:44)"}
|
||||
{"ts":"2026-08-26T05:25:29.798Z","cmd":"js","args":"\nconst c = document.querySelector('.wb-canvas');\nconst dlg = document.querySelector('.el-dialog');\nJSON.stringify({\n dialogTitle: dlg?.querySelector('.el-dialog__title')?.textContent,\n canvas: c ? {…","origin":"http://localhost:5173/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:25:39.300Z","cmd":"js","args":"\ndocument.querySelectorAll('.el-dialog__headerbtn').forEach(b => b.click()); 'closed all'","origin":"http://localhost:5173/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:25:40.371Z","cmd":"js","args":"\nconst rows = document.querySelectorAll('.row');\nrows[0].querySelectorAll('.cell')[2].querySelector('.cell-img').click(); 'clicked'","origin":"http://localhost:5173/admin/datasets/102","durationMs":2,"status":"error","hasCookies":false,"mode":"launched","error":"evaluate: TypeError: Cannot read properties of undefined (reading 'querySelector')\n at eval (eval at evaluate (:290:30), <anonymous>:2:37)\n at eval (<anonymous>)\n at UtilityScript.evaluate (<anonymous>:290:30)\n at UtilityScript.<anonymous> (<anonymous>:1:44)"}
|
||||
{"ts":"2026-08-26T05:25:41.935Z","cmd":"js","args":"\nconst c = document.querySelector('.wb-canvas');\nconst dlg = c ? c.closest('.el-dialog') : null;\nJSON.stringify({\n title: dlg?.querySelector('.el-dialog__title')?.textContent,\n canvas: c ? { w: c.wi…","origin":"http://localhost:5173/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:25:49.437Z","cmd":"js","args":"\nJSON.stringify({\n rows: document.querySelectorAll('.row').length,\n dialogs: document.querySelectorAll('.el-dialog').length,\n visibleDialogs: [...document.querySelectorAll('.el-dialog')].filter(d =…","origin":"http://localhost:5173/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:25:54.361Z","cmd":"js","args":"\nconst d = [...document.querySelectorAll('.el-dialog')].find(x => getComputedStyle(x).display !== 'none');\nd.querySelector('.el-dialog__headerbtn').click(); 'closed: ' + d.querySelector('.el-dialog__t…","origin":"http://localhost:5173/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:25:55.433Z","cmd":"js","args":"\nconst rows = document.querySelectorAll('.row');\nrows[0].querySelectorAll('.cell')[2].querySelector('.cell-img').click();\n'src click ok, dialogs: ' + [...document.querySelectorAll('.el-dialog')].filte…","origin":"http://localhost:5173/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"evaluate: TypeError: Cannot read properties of undefined (reading 'querySelector')\n at eval (eval at evaluate (:290:30), <anonymous>:2:37)\n at eval (<anonymous>)\n at UtilityScript.evaluate (<anonymous>:290:30)\n at UtilityScript.<anonymous> (<anonymous>:1:44)"}
|
||||
{"ts":"2026-08-26T05:26:03.654Z","cmd":"snapshot","args":"","origin":"http://localhost:5173/admin/datasets/102","durationMs":13,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:26:03.709Z","cmd":"snapshot","args":"-C","origin":"http://localhost:5173/admin/datasets/102","durationMs":14,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:26:09.123Z","cmd":"click","args":"@e37","origin":"http://localhost:5173/admin/datasets/102","durationMs":105,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:26:10.704Z","cmd":"js","args":"\nconst c = document.querySelector('.wb-canvas');\nconst dlg = c ? c.closest('.el-dialog') : null;\nJSON.stringify({\n title: dlg?.querySelector('.el-dialog__title')?.textContent,\n canvas: c ? { w: c.wi…","origin":"http://localhost:5173/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:26:16.872Z","cmd":"js","args":"\nconst c = document.querySelector('.wb-canvas');\nconst r = c.getBoundingClientRect();\nconst opts = { bubbles: true, cancelable: true, pointerId: 7, isPrimary: true };\nfunction fire(type, x, y) { c.dis…","origin":"http://localhost:5173/admin/datasets/102","durationMs":11,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:26:17.432Z","cmd":"js","args":"\nJSON.stringify({\n confirmed: [...document.querySelectorAll('.wb-canvas')].length ? null : null,\n dirtyBtn: [...document.querySelectorAll('.el-dialog button')].map(b=>b.textContent.trim()).filter(t=…","origin":"http://localhost:5173/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:26:20.829Z","cmd":"js","args":"\n[...document.querySelectorAll('.el-dialog button')].find(b=>b.textContent.trim().includes('保存本张')).click(); 'save clicked'","origin":"http://localhost:5173/admin/datasets/102","durationMs":3,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:26:22.909Z","cmd":"js","args":"\nJSON.stringify({ msg: document.body.textContent.includes('已保存(该数据集已标注') ? 'success msg shown' : 'no msg' })","origin":"http://localhost:5173/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:26:47.091Z","cmd":"snapshot","args":"","origin":"http://localhost:5173/admin/datasets/102","durationMs":19,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:26:47.148Z","cmd":"js","args":"\ndocument.querySelectorAll('.row')[0].querySelector('.cell-btn button').click(); 'clicked auto-label'","origin":"http://localhost:5173/admin/datasets/102","durationMs":8,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:26:48.020Z","cmd":"js","args":"\nJSON.stringify({ confirmDlg: [...document.querySelectorAll('.el-message-box__title')].map(t=>t.textContent) })","origin":"http://localhost:5173/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:26:50.318Z","cmd":"js","args":"\n[...document.querySelectorAll('.el-message-box button')].find(b=>b.textContent.includes('开始标注')).click(); 'confirmed'","origin":"http://localhost:5173/admin/datasets/102","durationMs":8,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:26:52.383Z","cmd":"js","args":"\nJSON.stringify({ err: document.body.textContent.includes('标注服务未配置') ? 'configured-error shown' : document.body.textContent.slice(-200) })","origin":"http://localhost:5173/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:26:59.390Z","cmd":"js","args":"\nconst pager = document.querySelectorAll('.el-pager li');\npager[1].click(); 'page 2 clicked'","origin":"http://localhost:5173/admin/datasets/102","durationMs":8,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:27:00.455Z","cmd":"js","args":"\nconst rows = document.querySelectorAll('.row');\nJSON.stringify({\n rows: rows.length,\n first: rows[0]?.textContent.includes('pheasant_20') ? 'pheasant_20' : rows[0]?.textContent.slice(0, 40),\n last…","origin":"http://localhost:5173/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:27:13.139Z","cmd":"goto","args":"http://localhost:5173/admin/datasets","origin":"http://localhost:5173/admin/datasets","durationMs":90,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:27:45.284Z","cmd":"text","args":"","origin":"http://localhost:5173/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:29:00.292Z","cmd":"goto","args":"http://localhost:5173/admin/datasets","origin":"http://localhost:5173/admin/datasets","durationMs":55,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:29:01.371Z","cmd":"text","args":"","origin":"http://localhost:5173/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:29:38.145Z","cmd":"goto","args":"http://localhost:5173/admin/datasets","origin":"http://localhost:5173/admin/datasets","durationMs":50,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:29:39.221Z","cmd":"text","args":"","origin":"http://localhost:5173/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:30:03.435Z","cmd":"goto","args":"http://localhost:5173/admin/datasets","origin":"http://localhost:5173/admin/datasets","durationMs":51,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:30:05.017Z","cmd":"js","args":"\nconst card = document.querySelector('.ds-card');\nJSON.stringify({\n trainSection: !!card.querySelector('.ds-train'),\n progressBar: !!card.querySelector('.ds-train-bar .el-progress__bar'),\n text: ca…","origin":"http://localhost:5173/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:30:08.066Z","cmd":"js","args":"\nconst card = document.querySelector('.ds-card');\nJSON.stringify({\n progressEls: card.querySelectorAll('.el-progress').length,\n barInner: card.querySelector('.el-progress__bar__outer') ? 'outer exis…","origin":"http://localhost:5173/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:30:10.283Z","cmd":"js","args":"\nconst card = document.querySelector('.ds-card');\nconst p = card.querySelector('.el-progress');\nJSON.stringify({ html: p.outerHTML.slice(0, 300) })","origin":"http://localhost:5173/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:33:50.254Z","cmd":"goto","args":"http://localhost:5173/admin/datasets/102","origin":"http://localhost:5173/admin/datasets/102","durationMs":76,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:33:51.340Z","cmd":"js","args":"\ndocument.querySelectorAll('.row')[0].querySelector('.cell-img').click(); 'viewer opened'","origin":"http://localhost:5173/admin/datasets/102","durationMs":6,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:33:52.417Z","cmd":"js","args":"\nconst img = document.querySelector('.viewer-img');\nconst rect = img.getBoundingClientRect();\nJSON.stringify({\n natural: img.naturalWidth + 'x' + img.naturalHeight,\n displayed: Math.round(rect.width…","origin":"http://localhost:5173/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:34:51.957Z","cmd":"goto","args":"http://localhost:5173/admin/datasets/102","origin":"http://localhost:5173/admin/datasets/102","durationMs":52,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:34:53.539Z","cmd":"js","args":"\nJSON.stringify({\n page1: [...document.querySelectorAll('.row .cell-name')].slice(0, 3).map(e => e.textContent.trim()),\n rowCount: document.querySelectorAll('.row').length\n})","origin":"http://localhost:5173/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:43:34.173Z","cmd":"goto","args":"http://localhost:5173/admin/datasets/102","origin":"http://localhost:5173/admin/datasets/102","durationMs":45,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:43:35.754Z","cmd":"js","args":"\nJSON.stringify({\n cards: document.querySelectorAll('.img-card').length,\n firstCard: document.querySelector('.img-card .card-name')?.textContent,\n actions: [...document.querySelectorAll('.img-card:…","origin":"http://localhost:5173/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:43:39.442Z","cmd":"js","args":"\ndocument.querySelector('.img-card .card-actions button').click(); '标注 clicked'","origin":"http://localhost:5173/admin/datasets/102","durationMs":7,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:43:41.023Z","cmd":"js","args":"\nconst c = document.querySelector('.wb-canvas');\nconst dlg = c ? c.closest('.el-dialog') : null;\nJSON.stringify({\n title: dlg?.querySelector('.el-dialog__title')?.textContent,\n canvas: c ? { w: c.wi…","origin":"http://localhost:5173/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:43:47.961Z","cmd":"js","args":"\ndocument.querySelectorAll('.el-dialog__headerbtn')[0]?.click(); 'editor closed'","origin":"http://localhost:5173/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:43:48.540Z","cmd":"js","args":"\ndocument.querySelector('.img-card .card-actions button:last-child').click(); '删除 clicked'","origin":"http://localhost:5173/admin/datasets/102","durationMs":7,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:43:49.118Z","cmd":"js","args":"\nJSON.stringify({\n confirmTitle: [...document.querySelectorAll('.el-message-box__title')].map(t=>t.textContent),\n confirmMsg: [...document.querySelectorAll('.el-message-box__message')].map(m=>m.text…","origin":"http://localhost:5173/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:43:49.163Z","cmd":"js","args":"\ndocument.querySelectorAll('.el-message-box button').forEach(b => b.textContent.includes('取消') && b.click()); 'cancelled'","origin":"http://localhost:5173/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:46:00.608Z","cmd":"goto","args":"http://localhost:5173/admin/datasets/102","origin":"http://localhost:5173/admin/datasets/102","durationMs":37,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:46:02.185Z","cmd":"js","args":"\nJSON.stringify({\n cards: document.querySelectorAll('.img-card').length,\n pager: document.querySelector('.el-pagination__total')?.textContent,\n pages: document.querySelectorAll('.el-pager li').leng…","origin":"http://localhost:5173/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:52:26.428Z","cmd":"goto","args":"http://localhost:18080/admin/datasets/102","origin":"http://localhost:18080/admin/login","durationMs":82,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:52:26.487Z","cmd":"wait","args":"2500","origin":"http://localhost:18080/admin/login","durationMs":4,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"2500\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 2500 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T05:52:31.924Z","cmd":"wait","args":"2500","origin":"http://localhost:18080/admin/login","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"2500\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 2500 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T05:52:31.967Z","cmd":"eval","args":"({title: document.title, cards: document.querySelectorAll('.img-card').length, addBtn: [...document.querySelectorAll('button')].some(b=>b.textContent.includes('添加')), hasUploadBtn: [...document.queryS…","origin":"http://localhost:18080/admin/login","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"Cannot resolve real path: ({title: document.title, cards: document.querySelectorAll('.img-card').length, addBtn: [...document.querySelectorAll('button')].some(b=>b.textContent.includes('添加')), hasUploadBtn: [...document.querySelectorAll('button')].some(b=>b.textContent.includes('选择图片上传')), hasGenBtn…"}
|
||||
{"ts":"2026-08-26T05:52:34.831Z","cmd":"eval","args":"--help","origin":"http://localhost:18080/admin/login","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"File not found: --help"}
|
||||
{"ts":"2026-08-26T05:52:41.663Z","cmd":"js","args":"({cards: document.querySelectorAll('.img-card').length, buttons: [...document.querySelectorAll('button')].map(b=>b.textContent.trim()).filter(t=>t.length<14)})","origin":"http://localhost:18080/admin/login","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:52:50.706Z","cmd":"storage","args":"set adminToken Tongli686^*^","origin":"http://localhost:18080/admin/login","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:52:50.768Z","cmd":"goto","args":"http://localhost:18080/admin/datasets/102","origin":"http://localhost:18080/admin/datasets/102","durationMs":23,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:52:50.834Z","cmd":"wait","args":"2500","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"2500\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 2500 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T05:52:50.897Z","cmd":"js","args":"({cards: document.querySelectorAll('.img-card').length, buttons: [...document.querySelectorAll('button')].map(b=>b.textContent.trim()).filter(t=>t.length<16)})","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:54:54.124Z","cmd":"console","args":"--errors","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:54:54.176Z","cmd":"js","args":"document.body.innerText.slice(0, 300)","origin":"http://localhost:18080/admin/datasets/102","durationMs":5,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:54:59.938Z","cmd":"console","args":"--clear","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:55:00.074Z","cmd":"click","args":"button:has-text('添加')","origin":"http://localhost:18080/admin/datasets/102","durationMs":86,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:55:00.131Z","cmd":"wait","args":"600","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"600\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 600 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T05:55:00.173Z","cmd":"js","args":"({dialogTitle: document.querySelector('.el-dialog__title')?.textContent, modeButtons: [...document.querySelectorAll('.el-radio-button')].map(b=>b.textContent.trim()), uploadForm: !!document.querySelec…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:55:05.979Z","cmd":"click","args":".el-radio-button:nth-of-type(2)","origin":"http://localhost:18080/admin/datasets/102","durationMs":48,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:55:06.023Z","cmd":"wait","args":"400","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"400\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 400 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T05:55:06.062Z","cmd":"js","args":"({uploadForm: !!document.querySelector('.el-upload'), hasPrompt: !!document.querySelector('.el-textarea'), hasCount: !!document.querySelector('.el-input-number'), sizeRadios: [...document.querySelecto…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:55:12.956Z","cmd":"click","args":".el-dialog__headerbtn","origin":"http://localhost:18080/admin/datasets/102","durationMs":24,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:55:13.004Z","cmd":"wait","args":"400","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"400\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 400 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T05:55:13.078Z","cmd":"click","args":".img-card .card-img","origin":"http://localhost:18080/admin/datasets/102","durationMs":27,"status":"error","hasCookies":false,"mode":"launched","error":"click: Error: strict mode violation: locator('.img-card .card-img') resolved to 18 elements:\n 1) <div class=\"card-img\" data-v-d300192b=\"\">…</div> aka getByText('手动已标注').first()\n 2) <div class=\"card-img\" data-v-d300192b=\"\">…</div> aka getByText('手动已标注').nth(1)\n 3) <div class=\"card-img\" data-…"}
|
||||
{"ts":"2026-08-26T05:55:13.121Z","cmd":"wait","args":"800","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"800\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 800 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T05:55:13.169Z","cmd":"js","args":"({dialogTitle: document.querySelector('.el-dialog__title')?.textContent, canvas: !!document.querySelector('.wb-canvas'), canvasSize: (()=>{const c=document.querySelector('.wb-canvas'); return c? c.wid…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:55:20.729Z","cmd":"press","args":"Escape","origin":"http://localhost:18080/admin/datasets/102","durationMs":5,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:55:20.773Z","cmd":"wait","args":"400","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"400\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 400 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T05:55:20.814Z","cmd":"js","args":"({dialogs: [...document.querySelectorAll('.el-dialog')].map(d=>({title: d.querySelector('.el-dialog__title')?.textContent, visible: getComputedStyle(d).display !== 'none'}))})","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:55:24.591Z","cmd":"js","args":"(()=>{const b=document.querySelector('.el-dialog__headerbtn'); if(b){b.click(); return 'clicked'} return 'none'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:55:24.631Z","cmd":"wait","args":"500","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"500\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 500 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T05:55:24.671Z","cmd":"js","args":"({dialogs: [...document.querySelectorAll('.el-dialog')].filter(d=>getComputedStyle(d).display!=='none').map(d=>d.querySelector('.el-dialog__title')?.textContent)})","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:55:28.244Z","cmd":"js","args":"([...document.querySelectorAll('.el-dialog')].map((d,i)=>({i, title: d.querySelector('.el-dialog__title')?.textContent, style: d.getAttribute('style'), display: getComputedStyle(d).display, overlayVis…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:55:31.884Z","cmd":"js","args":"(()=>{const c=document.querySelector('.img-card .card-img'); c.click(); return 'ok'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":5,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:55:31.940Z","cmd":"wait","args":"900","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"900\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 900 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T05:55:31.995Z","cmd":"js","args":"({dialogTitle: document.querySelector('.el-dialog__title')?.textContent, canvas: !!document.querySelector('.wb-canvas'), canvasSize: (()=>{const c=document.querySelector('.wb-canvas'); return c? c.wid…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:55:35.710Z","cmd":"js","args":"(()=>{const vis=[...document.querySelectorAll('.el-overlay')].filter(o=>getComputedStyle(o).display!=='none'); const d=vis[vis.length-1].querySelector('.el-dialog'); return {title: d.querySelector('.e…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:55:44.437Z","cmd":"js","args":"(()=>{const c=document.querySelector('.wb-canvas'); const r=c.getBoundingClientRect(); const fire=(t,x,y)=>c.dispatchEvent(new PointerEvent(t,{bubbles:true,cancelable:true,pointerId:7,clientX:x,client…","origin":"http://localhost:18080/admin/datasets/102","durationMs":3,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:55:44.479Z","cmd":"wait","args":"300","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"300\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 300 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T05:55:44.525Z","cmd":"js","args":"([...document.querySelectorAll('.editor-toolbar button')].map(b=>b.textContent.trim()))","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:55:49.936Z","cmd":"js","args":"(()=>{const vis=[...document.querySelectorAll('.el-overlay')].filter(o=>getComputedStyle(o).display!=='none'); const d=vis[vis.length-1].querySelector('.el-dialog'); [...d.querySelectorAll('.el-dialog…","origin":"http://localhost:18080/admin/datasets/102","durationMs":3,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:55:49.991Z","cmd":"wait","args":"1500","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"1500\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 1500 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T05:55:50.039Z","cmd":"js","args":"(()=>{const vis=[...document.querySelectorAll('.el-overlay')].filter(o=>getComputedStyle(o).display!=='none'); const el=document.querySelector('.el-message'); return {openDialogs: vis.length, toast: e…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:55:54.029Z","cmd":"wait","args":"500","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"500\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 500 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T05:55:54.067Z","cmd":"js","args":"(()=>{const vis=[...document.querySelectorAll('.el-overlay')].filter(o=>getComputedStyle(o).display!=='none'); return vis.map(o=>o.querySelector('.el-dialog__title')?.textContent)})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:56:15.899Z","cmd":"reload","args":"","origin":"http://localhost:18080/admin/datasets/102","durationMs":21,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:56:15.970Z","cmd":"wait","args":"2500","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"2500\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 2500 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T05:56:16.048Z","cmd":"js","args":"([...document.querySelectorAll('.img-card')].find(c=>c.textContent.includes('pheasant_99'))?.querySelector('.card-sub')?.textContent)","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:56:18.800Z","cmd":"js","args":"({cards: document.querySelectorAll('.img-card').length, sub: [...document.querySelectorAll('.img-card')].find(c=>c.textContent.includes('pheasant_99'))?.querySelector('.card-sub')?.textContent})","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:56:24.722Z","cmd":"js","args":"(()=>{const btn=[...document.querySelectorAll('button')].find(b=>b.textContent.includes('添加')); btn.click(); return 'ok'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":4,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T05:56:24.768Z","cmd":"wait","args":"500","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"500\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 500 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T05:56:24.815Z","cmd":"js","args":"(()=>{const vis=[...document.querySelectorAll('.el-overlay')].filter(o=>getComputedStyle(o).display!=='none'); const d=vis[vis.length-1].querySelector('.el-dialog'); const f=[...d.querySelectorAll('.e…","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:00:56.119Z","cmd":"reload","args":"","origin":"http://localhost:18080/admin/datasets/102","durationMs":97,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:00:56.199Z","cmd":"wait","args":"2500","origin":"http://localhost:18080/admin/datasets/102","durationMs":4,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"2500\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 2500 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:00:56.284Z","cmd":"js","args":"(()=>{const card=[...document.querySelectorAll('.img-card')].find(c=>c.textContent.includes('pheasant_99')); const badges=[...card.querySelectorAll('.badge')].map(b=>b.textContent.trim()); card.queryS…","origin":"http://localhost:18080/admin/datasets/102","durationMs":4,"status":"error","hasCookies":false,"mode":"launched","error":"evaluate: TypeError: Cannot read properties of undefined (reading 'querySelectorAll')\n at eval (eval at evaluate (:290:30), <anonymous>:1:132)\n at eval (eval at evaluate (:290:30), <anonymous>:1:248)\n at eval (<anonymous>)\n at UtilityScript.evaluate (<anonymous>:290:30)\n at UtilityScr…"}
|
||||
{"ts":"2026-08-26T06:00:56.460Z","cmd":"js","args":"(()=>{const c=document.querySelector('.wb-canvas'); const w=document.querySelector('.canvas-wrap'); const dlg=document.querySelector('.el-dialog.is-fullscreen'); return {fullscreen: !!dlg, canvas: c.w…","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"error","hasCookies":false,"mode":"launched","error":"evaluate: TypeError: Cannot read properties of null (reading 'width')\n at eval (eval at evaluate (:290:30), <anonymous>:1:200)\n at eval (eval at evaluate (:290:30), <anonymous>:1:477)\n at eval (<anonymous>)\n at UtilityScript.evaluate (<anonymous>:290:30)\n at UtilityScript.<anonymous> …"}
|
||||
{"ts":"2026-08-26T06:01:04.940Z","cmd":"wait","args":"3000","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"3000\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 3000 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:01:04.980Z","cmd":"js","args":"document.querySelectorAll('.img-card').length","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:01:12.176Z","cmd":"js","args":"(()=>{const card=[...document.querySelectorAll('.img-card')].find(c=>c.textContent.includes('pheasant_99')); const badges=[...card.querySelectorAll('.badge')].map(b=>b.textContent.trim()); card.queryS…","origin":"http://localhost:18080/admin/datasets/102","durationMs":9,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:01:12.237Z","cmd":"wait","args":"1200","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"1200\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 1200 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:01:12.292Z","cmd":"js","args":"(()=>{const c=document.querySelector('.wb-canvas'); const w=document.querySelector('.canvas-wrap'); const dlg=document.querySelector('.el-dialog.is-fullscreen'); return {fullscreen: !!dlg, canvas: c.w…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:01:17.468Z","cmd":"js","args":"(()=>{const btn=[...document.querySelectorAll('.editor-toolbar button')].find(b=>b.textContent.trim()==='放大'); btn.click(); return 'zoomed-in'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":3,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:01:17.517Z","cmd":"wait","args":"300","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"300\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 300 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:01:17.562Z","cmd":"js","args":"(()=>{const c=document.querySelector('.wb-canvas'); return {afterZoomIn: c.width+'x'+c.height, ratio: (c.width/c.height).toFixed(4)}})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:01:20.429Z","cmd":"js","args":"(()=>{const btn=[...document.querySelectorAll('.editor-toolbar button')].find(b=>b.textContent.trim()==='适应'); btn.click(); return 'fit'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:01:20.473Z","cmd":"wait","args":"300","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"300\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 300 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:01:20.515Z","cmd":"js","args":"(()=>{const c=document.querySelector('.wb-canvas'); return {afterFit: c.width+'x'+c.height}})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:01:20.561Z","cmd":"js","args":"(()=>{const w=document.querySelector('.canvas-wrap'); w.dispatchEvent(new WheelEvent('wheel',{bubbles:true,cancelable:true,deltaY:-120,ctrlKey:true})); return 'wheel'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":3,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:01:20.606Z","cmd":"wait","args":"300","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"300\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 300 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:01:20.648Z","cmd":"js","args":"(()=>{const c=document.querySelector('.wb-canvas'); return {afterCtrlWheel: c.width+'x'+c.height}})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:01:26.709Z","cmd":"js","args":"(()=>{const btns=[...document.querySelectorAll('.editor-toolbar button')].map(b=>b.textContent.trim()); const fit=btns.indexOf('适应'); const el=[...document.querySelectorAll('.editor-toolbar button')][…","origin":"http://localhost:18080/admin/datasets/102","durationMs":4,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:01:26.761Z","cmd":"wait","args":"400","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"400\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 400 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:01:26.803Z","cmd":"js","args":"(()=>{const c=document.querySelector('.wb-canvas'); return {canvas: c.width+'x'+c.height, wrap: document.querySelector('.canvas-wrap').clientWidth+'x'+document.querySelector('.canvas-wrap').clientHeig…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:01:35.136Z","cmd":"js","args":"(()=>{const body=[...document.querySelectorAll('.el-dialog__body')].find(b=>b.querySelector('.canvas-wrap')); const cs=getComputedStyle(body); return {bodyDisplay: cs.display, bodyHeight: body.clientH…","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:01:47.483Z","cmd":"js","args":"(()=>{const dlg=document.querySelector('.el-dialog.is-fullscreen'); const overlay=dlg.closest('.el-overlay'); const parentScoped=[...document.querySelectorAll('[data-v-*]')].length; return {dialogClas…","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"error","hasCookies":false,"mode":"launched","error":"evaluate: SyntaxError: Failed to execute 'querySelectorAll' on 'Document': '[data-v-*]' is not a valid selector.\n at eval (eval at evaluate (:290:30), <anonymous>:1:143)\n at eval (eval at evaluate (:290:30), <anonymous>:1:360)\n at eval (<anonymous>)\n at UtilityScript.evaluate (<anonymous…"}
|
||||
{"ts":"2026-08-26T06:01:49.708Z","cmd":"js","args":"(()=>{const dlg=document.querySelector('.el-dialog.is-fullscreen'); const overlay=dlg.closest('.el-overlay'); return {dialogClass: dlg.className, overlayAttrs: [...overlay.attributes].map(a=>a.name).j…","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:02:29.949Z","cmd":"reload","args":"","origin":"http://localhost:18080/admin/datasets/102","durationMs":86,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:02:30.013Z","cmd":"wait","args":"3000","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"3000\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 3000 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:02:30.071Z","cmd":"js","args":"(()=>{const card=[...document.querySelectorAll('.img-card')].find(c=>c.textContent.includes('pheasant_99')); card.querySelector('.card-img').click(); return 'opened'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"evaluate: TypeError: Cannot read properties of undefined (reading 'querySelector')\n at eval (eval at evaluate (:290:30), <anonymous>:1:115)\n at eval (eval at evaluate (:290:30), <anonymous>:1:168)\n at eval (<anonymous>)\n at UtilityScript.evaluate (<anonymous>:290:30)\n at UtilityScript…"}
|
||||
{"ts":"2026-08-26T06:02:30.233Z","cmd":"js","args":"(()=>{const body=[...document.querySelectorAll('.el-dialog__body')].find(b=>b.querySelector('.canvas-wrap')); const c=document.querySelector('.wb-canvas'); const w=document.querySelector('.canvas-wrap…","origin":"http://localhost:18080/admin/datasets/102","durationMs":78,"status":"error","hasCookies":false,"mode":"launched","error":"evaluate: TypeError: Cannot read properties of undefined (reading 'closest')\n at eval (eval at evaluate (:290:30), <anonymous>:1:220)\n at eval (eval at evaluate (:290:30), <anonymous>:1:563)\n at eval (<anonymous>)\n at UtilityScript.evaluate (<anonymous>:290:30)\n at UtilityScript.<anon…"}
|
||||
{"ts":"2026-08-26T06:02:32.653Z","cmd":"wait","args":"3000","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"3000\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 3000 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:02:32.699Z","cmd":"js","args":"document.querySelectorAll('.img-card').length","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:02:40.483Z","cmd":"js","args":"(()=>{const card=[...document.querySelectorAll('.img-card')].find(c=>c.textContent.includes('pheasant_99')); card.querySelector('.card-img').click(); return 'opened'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":13,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:02:40.538Z","cmd":"wait","args":"1200","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"1200\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 1200 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:02:40.590Z","cmd":"js","args":"(()=>{const body=[...document.querySelectorAll('.el-dialog__body')].find(b=>b.querySelector('.canvas-wrap')); const c=document.querySelector('.wb-canvas'); const w=document.querySelector('.canvas-wrap…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:17.202Z","cmd":"reload","args":"","origin":"http://localhost:18080/admin/datasets/102","durationMs":77,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:17.263Z","cmd":"wait","args":"3000","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"3000\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 3000 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:03:17.324Z","cmd":"js","args":"(()=>{const card=[...document.querySelectorAll('.img-card')].find(c=>c.textContent.includes('pheasant_99')); card.querySelector('.card-img').click(); return 'opened'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"evaluate: TypeError: Cannot read properties of undefined (reading 'querySelector')\n at eval (eval at evaluate (:290:30), <anonymous>:1:115)\n at eval (eval at evaluate (:290:30), <anonymous>:1:168)\n at eval (<anonymous>)\n at UtilityScript.evaluate (<anonymous>:290:30)\n at UtilityScript…"}
|
||||
{"ts":"2026-08-26T06:03:17.394Z","cmd":"js","args":"(()=>{const body=[...document.querySelectorAll('.el-dialog__body')].find(b=>b.querySelector('.canvas-wrap')); const c=document.querySelector('.wb-canvas'); const w=document.querySelector('.canvas-wrap…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"evaluate: TypeError: Cannot read properties of undefined (reading 'closest')\n at eval (eval at evaluate (:290:30), <anonymous>:1:220)\n at eval (eval at evaluate (:290:30), <anonymous>:1:530)\n at eval (<anonymous>)\n at UtilityScript.evaluate (<anonymous>:290:30)\n at UtilityScript.<anon…"}
|
||||
{"ts":"2026-08-26T06:03:23.617Z","cmd":"wait","args":"3000","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"3000\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 3000 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:03:23.663Z","cmd":"js","args":"document.querySelectorAll('.img-card').length","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:26.532Z","cmd":"js","args":"(()=>{const card=[...document.querySelectorAll('.img-card')].find(c=>c.textContent.includes('pheasant_99')); card.querySelector('.card-img').click(); return 'opened'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":11,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:26.578Z","cmd":"wait","args":"1200","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"1200\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 1200 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:03:26.623Z","cmd":"js","args":"(()=>{const body=[...document.querySelectorAll('.el-dialog__body')].find(b=>b.querySelector('.canvas-wrap')); const c=document.querySelector('.wb-canvas'); const w=document.querySelector('.canvas-wrap…","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:31.140Z","cmd":"js","args":"(()=>{const btn=[...document.querySelectorAll('.editor-toolbar button')].find(b=>b.textContent.trim()==='放大'); btn.click(); btn.click(); return 'in'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":3,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:31.182Z","cmd":"wait","args":"300","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"300\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 300 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:03:31.224Z","cmd":"js","args":"(()=>{const c=document.querySelector('.wb-canvas'); const w=document.querySelector('.canvas-wrap'); return {canvas: c.width+'x'+c.height, wrapScroll: w.scrollHeight+'x'+w.scrollWidth, scrolled: w.scro…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:35.215Z","cmd":"js","args":"(()=>{const btn=[...document.querySelectorAll('.editor-toolbar button')].find(b=>b.textContent.trim()==='放大'); for(let i=0;i<4;i++) btn.click(); return 'in'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":3,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:35.258Z","cmd":"wait","args":"300","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"300\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 300 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:03:35.302Z","cmd":"js","args":"(()=>{const c=document.querySelector('.wb-canvas'); const w=document.querySelector('.canvas-wrap'); w.scrollTop=200; w.scrollLeft=150; return {canvas: c.width+'x'+c.height, wrapClient: w.clientWidth+'…","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:39.018Z","cmd":"js","args":"(()=>{const btn=[...document.querySelectorAll('.editor-toolbar button')].find(b=>b.textContent.trim()==='放大'); btn.click(); return 'in'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:39.061Z","cmd":"wait","args":"300","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"300\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 300 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:03:39.102Z","cmd":"js","args":"(()=>{const c=document.querySelector('.wb-canvas'); const w=document.querySelector('.canvas-wrap'); w.scrollTop=300; w.scrollLeft=400; return {canvas: c.width+'x'+c.height, overflowX: w.scrollWidth>w.…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:41.819Z","cmd":"js","args":"(()=>{const btn=[...document.querySelectorAll('.editor-toolbar button')].find(b=>b.textContent.trim()==='放大'); btn.click(); return 'in'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":3,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:41.864Z","cmd":"wait","args":"300","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"300\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 300 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:03:41.910Z","cmd":"js","args":"(()=>{const w=document.querySelector('.canvas-wrap'); w.scrollTop=300; w.scrollLeft=400; return {canvas: document.querySelector('.wb-canvas').width, overflowX: w.scrollWidth>w.clientWidth, scrolledTo:…","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:45.600Z","cmd":"js","args":"(()=>{const btn=[...document.querySelectorAll('.editor-toolbar button')].find(b=>b.textContent.trim()==='适应'); btn.click(); return 'fit'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:45.644Z","cmd":"wait","args":"300","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"300\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 300 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:03:45.696Z","cmd":"js","args":"(()=>{const c=document.querySelector('.wb-canvas'); const w=document.querySelector('.canvas-wrap'); return {canvas: c.width+'x'+c.height, ratioOk: Math.abs((c.width/c.height)-(1152/2048))<0.002}})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:45.739Z","cmd":"js","args":"(()=>{const w=document.querySelector('.canvas-wrap'); w.dispatchEvent(new WheelEvent('wheel',{bubbles:true,cancelable:true,deltaY:-120,ctrlKey:true})); return 'wheel'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:45.781Z","cmd":"wait","args":"300","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"300\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 300 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:03:45.824Z","cmd":"js","args":"document.querySelector('.wb-canvas').width","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:51.765Z","cmd":"js","args":"(()=>{const c=document.querySelector('.wb-canvas'); const r=c.getBoundingClientRect(); const fire=(t,x,y)=>c.dispatchEvent(new PointerEvent(t,{bubbles:true,cancelable:true,pointerId:7,clientX:x,client…","origin":"http://localhost:18080/admin/datasets/102","durationMs":4,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:51.807Z","cmd":"wait","args":"300","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"300\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 300 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:03:51.855Z","cmd":"js","args":"(()=>{const vis=[...document.querySelectorAll('.el-overlay')].filter(o=>getComputedStyle(o).display!=='none'); const d=vis[vis.length-1].querySelector('.el-dialog'); [...d.querySelectorAll('.el-dialog…","origin":"http://localhost:18080/admin/datasets/102","durationMs":3,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:51.905Z","cmd":"wait","args":"1500","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"1500\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 1500 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:03:58.319Z","cmd":"reload","args":"","origin":"http://localhost:18080/admin/datasets/102","durationMs":22,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:03:58.393Z","cmd":"wait","args":"3000","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"3000\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 3000 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:03:58.465Z","cmd":"js","args":"({cards: document.querySelectorAll('.img-card').length, sub: [...document.querySelectorAll('.img-card')].find(c=>c.textContent.includes('pheasant_99'))?.querySelector('.card-sub')?.textContent, badgeT…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:04:00.582Z","cmd":"wait","args":"2500","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"2500\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 2500 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:04:00.621Z","cmd":"js","args":"({cards: document.querySelectorAll('.img-card').length, sub: [...document.querySelectorAll('.img-card')].find(c=>c.textContent.includes('pheasant_99'))?.querySelector('.card-sub')?.textContent, first3…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:04:33.857Z","cmd":"reload","args":"","origin":"http://localhost:18080/admin/datasets/102","durationMs":75,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:04:33.920Z","cmd":"wait","args":"3000","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"3000\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 3000 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:04:34.055Z","cmd":"js","args":"document.querySelectorAll('.img-card').length","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:04:41.106Z","cmd":"js","args":"(()=>{const card=[...document.querySelectorAll('.img-card')].find(c=>c.textContent.includes('pheasant_99')); card.querySelector('.card-img').click(); return 'opened'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":9,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:04:41.159Z","cmd":"wait","args":"1200","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"1200\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 1200 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:04:41.220Z","cmd":"js","args":"(()=>{const d=document.querySelector('.el-dialog.is-fullscreen'); return {toolbarBtns: [...d.querySelectorAll('.editor-toolbar button')].map(b=>b.textContent.trim()), footerBtns: [...d.querySelectorAl…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:04:58.450Z","cmd":"js","args":"(()=>{const c=document.querySelector('.wb-canvas'); const r=c.getBoundingClientRect(); const fire=(t,x,y)=>c.dispatchEvent(new PointerEvent(t,{bubbles:true,cancelable:true,pointerId:7,clientX:x,client…","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:04:58.498Z","cmd":"wait","args":"300","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"300\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 300 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:04:58.541Z","cmd":"js","args":"(()=>{const d=document.querySelector('.el-dialog.is-fullscreen'); [...d.querySelectorAll('.el-dialog__footer button')].find(b=>b.textContent.trim()==='确定').click(); return 'confirmed'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":3,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:04:58.604Z","cmd":"wait","args":"1500","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"1500\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 1500 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:06:38.931Z","cmd":"reload","args":"","origin":"http://localhost:18080/admin/datasets/102","durationMs":83,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:06:38.991Z","cmd":"wait","args":"3000","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"3000\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 3000 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:06:39.080Z","cmd":"js","args":"document.querySelectorAll('.img-card').length","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:06:41.009Z","cmd":"wait","args":"3000","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"3000\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 3000 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:06:41.050Z","cmd":"js","args":"document.querySelectorAll('.img-card').length","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:07:42.403Z","cmd":"reload","args":"","origin":"http://localhost:18080/admin/datasets/102","durationMs":116,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:07:42.586Z","cmd":"wait","args":"3000","origin":"http://localhost:18080/admin/datasets/102","durationMs":3,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"3000\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 3000 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:07:42.728Z","cmd":"js","args":"document.querySelectorAll('.img-card').length","origin":"http://localhost:18080/admin/datasets/102","durationMs":3,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:07:49.710Z","cmd":"wait","args":"3000","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"3000\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 3000 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:07:49.749Z","cmd":"js","args":"document.querySelectorAll('.img-card').length","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:07:57.531Z","cmd":"js","args":"(()=>{const card=[...document.querySelectorAll('.img-card')].find(c=>c.textContent.includes('pheasant_99')); card.querySelector('.card-img').click(); return 'opened'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":9,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:07:57.577Z","cmd":"wait","args":"1200","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"1200\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 1200 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:07:57.626Z","cmd":"js","args":"(()=>{const d=document.querySelector('.el-dialog.is-fullscreen'); const zc=d.querySelector('.zoom-ctrl'); return {zoomCtrl: zc ? {pct: zc.querySelector('.zoom-pct')?.textContent, buttons: zc.querySele…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:08:10.148Z","cmd":"js","args":"(()=>{const d=document.querySelector('.el-dialog.is-fullscreen'); const btns=d.querySelectorAll('.zoom-ctrl button'); btns[0].click(); return 'plus-clicked'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:08:10.195Z","cmd":"wait","args":"300","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"300\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 300 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:08:10.237Z","cmd":"js","args":"(()=>{const d=document.querySelector('.el-dialog.is-fullscreen'); const c=d.querySelector('.wb-canvas'); return {pct: d.querySelector('.zoom-pct').textContent, canvas: c.width+'x'+c.height, smoothing:…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:08:18.953Z","cmd":"js","args":"(()=>{const d=document.querySelector('.el-dialog.is-fullscreen'); const b=d.querySelectorAll('.zoom-ctrl button')[0]; for(let i=0;i<7;i++) b.click(); return 'zoomed'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":8,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:08:19.002Z","cmd":"wait","args":"300","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"300\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 300 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:08:19.045Z","cmd":"js","args":"(()=>{const d=document.querySelector('.el-dialog.is-fullscreen'); const c=d.querySelector('.wb-canvas'); return {pct: d.querySelector('.zoom-pct').textContent, canvas: c.width+'x'+c.height, smoothing:…","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:08:26.704Z","cmd":"js","args":"(()=>{const d=document.querySelector('.el-dialog.is-fullscreen'); const b=d.querySelectorAll('.zoom-ctrl button')[0]; for(let i=0;i<12;i++) b.click(); return 'zoomed-max'})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":67,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:08:26.790Z","cmd":"wait","args":"300","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"300\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 300 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:08:26.842Z","cmd":"js","args":"(()=>{const d=document.querySelector('.el-dialog.is-fullscreen'); const c=d.querySelector('.wb-canvas'); const b=d.querySelectorAll('.zoom-ctrl button')[0]; const before=c.width; b.click(); return {pc…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:08:35.063Z","cmd":"js","args":"(()=>{const d=document.querySelector('.el-dialog.is-fullscreen'); d.querySelectorAll('.zoom-ctrl button')[1].click(); return {pct: d.querySelector('.zoom-pct').textContent}})()","origin":"http://localhost:18080/admin/datasets/102","durationMs":13,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:08:35.111Z","cmd":"wait","args":"200","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"200\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 200 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:08:35.155Z","cmd":"js","args":"(()=>{const d=document.querySelector('.el-dialog.is-fullscreen'); const vis=[...document.querySelectorAll('.el-overlay')].filter(o=>getComputedStyle(o).display!=='none'); return {pctAfterMinus: d.quer…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:23:56.210Z","cmd":"reload","args":"","origin":"http://localhost:18080/admin/datasets/102","durationMs":175,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:23:56.301Z","cmd":"wait","args":"3000","origin":"http://localhost:18080/admin/datasets/102","durationMs":12,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"3000\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 3000 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:23:56.383Z","cmd":"js","args":"({cards: document.querySelectorAll('.ds-card').length})","origin":"http://localhost:18080/admin/datasets/102","durationMs":4,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:25:05.099Z","cmd":"wait","args":"3000","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"error","hasCookies":false,"mode":"launched","error":"for: Error while parsing css selector \"3000\". Did you mean to CSS.escape it?\nCall log:\n\u001b[2m - waiting for 3000 to be visible\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:25:05.142Z","cmd":"js","args":"document.querySelectorAll('.ds-card').length","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:25:17.822Z","cmd":"wait","args":"--networkidle","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:25:17.867Z","cmd":"js","args":"document.querySelectorAll('.ds-card').length","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:25:21.370Z","cmd":"console","args":"","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:25:21.414Z","cmd":"js","args":"document.title","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:25:26.057Z","cmd":"js","args":"document.querySelectorAll('.img-card').length + ' img-cards; upload-btn: ' + !!document.querySelector('.add-btn')","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:25:28.636Z","cmd":"snapshot","args":"-i","origin":"http://localhost:18080/admin/datasets/102","durationMs":44,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:25:31.497Z","cmd":"click","args":"@e7","origin":"http://localhost:18080/admin/datasets/102","durationMs":58,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:25:31.546Z","cmd":"wait","args":"--load","origin":"http://localhost:18080/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:25:31.612Z","cmd":"snapshot","args":"-i","origin":"http://localhost:18080/admin/datasets/102","durationMs":14,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:25:35.007Z","cmd":"js","args":"(() => { const u = document.querySelector('.el-upload--picture-card'); const tile = document.querySelector('.upload-tile'); return { hasPictureCard: !!u, tileText: tile ? tile.textContent.trim() : nul…","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:25:44.745Z","cmd":"upload","args":".el-upload--picture-card input[type=file] /private/tmp/real_screen_small.jpg","origin":"http://localhost:18080/admin/datasets/102","durationMs":27,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:25:44.793Z","cmd":"js","args":"document.querySelectorAll('.el-upload-list__item').length + ' preview items; img src: ' + (document.querySelector('.el-upload-list__item img')?.src?.slice(0,60) || 'none')","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:25:54.538Z","cmd":"click","args":"@e49","origin":"http://localhost:18080/admin/datasets/102","durationMs":5029,"status":"error","hasCookies":false,"mode":"launched","error":"click: Timeout 5000ms exceeded.\nCall log:\n\u001b[2m - waiting for getByRole('radio', { name: 'AI 生成' })\u001b[22m\n\u001b[2m - locator resolved to <input value=\"ai\" type=\"radio\" name=\"el-id-9390-5\" class=\"el-radio-button__original-radio\"/>\u001b[22m\n\u001b[2m - attempting click action\u001b[22m\n\u001b[2m 2 × waiting for elemen…"}
|
||||
{"ts":"2026-08-26T06:25:54.593Z","cmd":"js","args":"(() => { const btns = [...document.querySelectorAll('.el-dialog .el-button')].map(b => b.textContent.trim()); return { buttons: btns, hasGenTip: !!document.querySelector('.gen-tip'), hasPrompt: !!docu…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:26:08.846Z","cmd":"snapshot","args":"-i","origin":"http://localhost:18080/admin/datasets/102","durationMs":19,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:26:13.779Z","cmd":"js","args":"document.querySelectorAll('.el-radio-button__inner, .el-radio__label').forEach(el => { if (el.textContent.includes('AI')) el.click() })","origin":"http://localhost:18080/admin/datasets/102","durationMs":20,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:26:13.844Z","cmd":"js","args":"(() => { return { isAiChecked: [...document.querySelectorAll('.el-radio')].some(r => r.textContent.includes('AI') && r.classList.contains('is-checked')), buttons: [...document.querySelectorAll('.el-di…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:26:19.271Z","cmd":"js","args":"({ picCardHidden: !document.querySelector('.el-upload--picture-card'), radioChecked: [...document.querySelectorAll('.el-radio-button')].find(r => r.textContent.includes('AI'))?.classList.contains('is-…","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:26:22.917Z","cmd":"js","args":"[...document.querySelectorAll('.el-dialog__footer button')].map(b => b.textContent.trim())","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:26:22.978Z","cmd":"js","args":"document.querySelector('.el-dialog')?.textContent.includes('取消')","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:26:26.265Z","cmd":"js","args":"[...document.querySelectorAll('.el-dialog__footer .el-button, .el-dialog__headerbtn, .el-dialog__header .el-button')].map(b => b.textContent.trim() || 'X-close')","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:26:33.035Z","cmd":"js","args":"[...document.querySelectorAll('.el-dialog__body .el-button, .el-dialog__footer .el-button')].map(b => b.textContent.trim())","origin":"http://localhost:18080/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:26:49.545Z","cmd":"js","args":"document.querySelector('.el-dialog__headerbtn')?.click()","origin":"http://localhost:18080/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:26:49.646Z","cmd":"goto","args":"http://localhost:18080/admin/datasets","origin":"http://localhost:18080/admin/datasets","durationMs":47,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:26:50.164Z","cmd":"wait","args":"--networkidle","origin":"http://localhost:18080/admin/datasets","durationMs":465,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:26:50.237Z","cmd":"js","args":"document.querySelectorAll('.ds-card').length","origin":"http://localhost:18080/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:26:55.184Z","cmd":"snapshot","args":"-i","origin":"http://localhost:18080/admin/datasets","durationMs":17,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:26:58.854Z","cmd":"click","args":"@e10","origin":"http://localhost:18080/admin/datasets","durationMs":39,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:26:58.904Z","cmd":"wait","args":"--load","origin":"http://localhost:18080/admin/datasets","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:26:58.953Z","cmd":"js","args":"(() => { const u = document.querySelector('.el-upload--picture-card'); const tile = document.querySelector('.upload-tile'); const listItems = document.querySelectorAll('.el-upload-list__item'); return…","origin":"http://localhost:18080/admin/datasets","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:27:03.809Z","cmd":"upload","args":".el-upload--picture-card input[type=file] /private/tmp/real_screen_small.jpg","origin":"http://localhost:18080/admin/datasets","durationMs":10,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:27:03.858Z","cmd":"js","args":"(() => { const items = document.querySelectorAll('.el-upload-list__item'); return { listItems: items.length, previewSrc: items[0]?.querySelector('img')?.src?.slice(0,50) || null } })()","origin":"http://localhost:18080/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:27:08.178Z","cmd":"screenshot","args":"/private/tmp/cover-tile.jpg","origin":"http://localhost:18080/admin/datasets","durationMs":54,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:27:10.945Z","cmd":"js","args":"[...document.querySelectorAll('.el-dialog__footer .el-button')].map(b => b.textContent.trim())","origin":"http://localhost:18080/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:27:14.726Z","cmd":"js","args":"[...document.querySelectorAll('.el-dialog__footer .el-button')].find(b => b.textContent.trim() === '取消')?.click()","origin":"http://localhost:18080/admin/datasets","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:27:34.477Z","cmd":"snapshot","args":"-a -o /private/tmp/cover-tile.png","origin":"http://localhost:18080/admin/datasets","durationMs":14236,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:31:46.862Z","cmd":"goto","args":"http://localhost:18080/admin/datasets/102","origin":"http://localhost:18080/admin/datasets","durationMs":17,"status":"error","hasCookies":false,"mode":"launched","error":"goto: net::ERR_CONNECTION_REFUSED at http://localhost:18080/admin/datasets/102\nCall log:\n\u001b[2m - navigating to \"http://localhost:18080/admin/datasets/102\", waiting until \"domcontentloaded\"\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:31:47.484Z","cmd":"wait","args":"--networkidle","origin":"chrome-error://chromewebdata/","durationMs":459,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:31:47.567Z","cmd":"js","args":"document.querySelectorAll('.img-card').length","origin":"chrome-error://chromewebdata/","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:32:26.217Z","cmd":"goto","args":"http://localhost:18081/admin/datasets/102","origin":"http://localhost:18081/admin/datasets/102","durationMs":62,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:32:26.718Z","cmd":"wait","args":"--networkidle","origin":"http://localhost:18081/admin/datasets/102","durationMs":452,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:32:26.792Z","cmd":"js","args":"document.querySelectorAll('.img-card').length","origin":"http://localhost:18081/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:32:30.395Z","cmd":"goto","args":"http://localhost:18081/","origin":"http://localhost:18081/","durationMs":5,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:32:30.438Z","cmd":"wait","args":"--load","origin":"http://localhost:18081/","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:32:30.480Z","cmd":"js","args":"location.pathname + ' ' + document.title","origin":"http://localhost:18081/","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:33:05.907Z","cmd":"goto","args":"http://localhost:18081/admin/datasets/102","origin":"http://localhost:18081/admin/login","durationMs":80,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:33:06.362Z","cmd":"wait","args":"--networkidle","origin":"http://localhost:18081/admin/login","durationMs":410,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:33:06.431Z","cmd":"js","args":"(() => { const cards = [...document.querySelectorAll('.img-card')]; const imgs = cards.map(c => { const img = c.querySelector('.card-img img'); return { w: img?.getBoundingClientRect().width, h: img?.…","origin":"http://localhost:18081/admin/login","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:33:11.020Z","cmd":"js","args":"document.title + ' | body: ' + document.body.innerText.slice(0,150)","origin":"http://localhost:18081/admin/login","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:33:15.120Z","cmd":"js","args":"localStorage.setItem('adminToken', 'Tongli686^*^'); location.reload()","origin":"http://localhost:18081/admin/login","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:33:15.628Z","cmd":"wait","args":"--networkidle","origin":"http://localhost:18081/admin/login","durationMs":460,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:33:15.700Z","cmd":"js","args":"document.title + ' cards: ' + document.querySelectorAll('.img-card').length","origin":"http://localhost:18081/admin/login","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:33:19.120Z","cmd":"js","args":"location.href","origin":"http://localhost:18081/admin/login","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:33:58.760Z","cmd":"js","args":"localStorage.setItem('adminToken','Tongli686^*^')","origin":"http://localhost:18081/admin/login","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:33:58.909Z","cmd":"goto","args":"http://localhost:18081/admin/datasets","origin":"http://localhost:18081/admin/datasets","durationMs":103,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:33:59.427Z","cmd":"wait","args":"--networkidle","origin":"http://localhost:18081/admin/datasets","durationMs":457,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:33:59.501Z","cmd":"js","args":"({ title: document.title, sidebarText: document.querySelector('.app-aside')?.innerText.slice(0,120) })","origin":"http://localhost:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:34:03.677Z","cmd":"js","args":"location.href='http://localhost:18081/admin/datasets/102'","origin":"http://localhost:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:34:04.584Z","cmd":"wait","args":"--networkidle","origin":"http://localhost:18081/admin/datasets/102","durationMs":839,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:34:04.660Z","cmd":"js","args":"document.querySelectorAll('.img-card').length","origin":"http://localhost:18081/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:34:09.212Z","cmd":"js","args":"(() => { const cards = [...document.querySelectorAll('.img-card')]; const out = cards.slice(0,6).map(c => { const img = c.querySelector('.card-img img'); if (!img) return null; const r = img.getBoundi…","origin":"http://localhost:18081/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:36:42.342Z","cmd":"goto","args":"http://localhost:18081/admin/datasets","origin":"http://localhost:18081/admin/datasets","durationMs":122,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:36:42.857Z","cmd":"wait","args":"--networkidle","origin":"http://localhost:18081/admin/datasets","durationMs":463,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:36:42.917Z","cmd":"js","args":"(() => { const card = [...document.querySelectorAll('.ds-card')].find(c => c.innerText.includes('野鸡')); if (!card) return 'no card'; const img = card.querySelector('.ds-cover img'); const srcBadge = c…","origin":"http://localhost:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:45:26.005Z","cmd":"goto","args":"http://localhost:18081/admin/datasets","origin":"http://localhost:18081/admin/datasets","durationMs":98,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:45:26.513Z","cmd":"wait","args":"--networkidle","origin":"http://localhost:18081/admin/datasets","durationMs":452,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:45:26.588Z","cmd":"js","args":"localStorage.getItem('adminToken') ? 'token ok' : 'need token'","origin":"http://localhost:18081/admin/datasets","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:45:32.256Z","cmd":"snapshot","args":"-i","origin":"http://localhost:18081/admin/datasets","durationMs":23,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:45:37.318Z","cmd":"click","args":"@e10","origin":"http://localhost:18081/admin/datasets","durationMs":50,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:45:37.366Z","cmd":"wait","args":"--load","origin":"http://localhost:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:45:37.409Z","cmd":"js","args":"(() => { const items = document.querySelectorAll('.el-upload-list__item'); const del = document.querySelector('.el-upload-list__item .el-upload-list__item-delete, .el-upload-list__item .el-icon'); ret…","origin":"http://localhost:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:45:41.229Z","cmd":"js","args":"document.querySelector('.el-upload-list__item .el-upload-list__item-actions')?.dispatchEvent(new MouseEvent('mouseenter', {bubbles:true}))","origin":"http://localhost:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:45:41.269Z","cmd":"js","args":"(() => { const del = document.querySelector('.el-upload-list__item-actions .el-icon'); if (!del) return 'no delete btn'; del.dispatchEvent(new MouseEvent('click', {bubbles:true})); return 'clicked' })…","origin":"http://localhost:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:45:44.741Z","cmd":"js","args":"({ remaining: document.querySelectorAll('.el-upload-list__item').length, placeholder: !!document.querySelector('.el-upload--picture-card') })","origin":"http://localhost:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:45:49.353Z","cmd":"snapshot","args":"-i","origin":"http://localhost:18081/admin/datasets","durationMs":11,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:45:52.875Z","cmd":"js","args":"(() => { const actions = document.querySelector('.el-upload-list__item-actions'); if (!actions) return 'no actions el'; actions.style.opacity = '1'; actions.style.display = 'flex'; const del = actions…","origin":"http://localhost:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:45:56.854Z","cmd":"js","args":"(() => { const icons = [...document.querySelectorAll('.el-upload-list__item-actions .el-icon')]; return icons.map(i => i.outerHTML.slice(0,100)) })()","origin":"http://localhost:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:46:01.093Z","cmd":"js","args":"document.querySelector('.el-upload-list__item-actions .el-icon--delete').dispatchEvent(new MouseEvent('click', {bubbles:true})); 'clicked'","origin":"http://localhost:18081/admin/datasets","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:46:01.138Z","cmd":"js","args":"document.querySelectorAll('.el-upload-list__item').length","origin":"http://localhost:18081/admin/datasets","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:46:08.148Z","cmd":"js","args":"document.querySelector('.el-upload-list__item-actions').style.display='flex'; document.querySelector('.el-upload-list__item-actions').style.opacity='1'","origin":"http://localhost:18081/admin/datasets","durationMs":1,"status":"error","hasCookies":false,"mode":"launched","error":"evaluate: TypeError: Cannot read properties of null (reading 'style')\n at eval (eval at evaluate (:290:30), <anonymous>:1:56)\n at eval (<anonymous>)\n at UtilityScript.evaluate (<anonymous>:290:30)\n at UtilityScript.<anonymous> (<anonymous>:1:44)"}
|
||||
{"ts":"2026-08-26T06:46:38.259Z","cmd":"js","args":"document.querySelectorAll('.el-upload-list__item').length","origin":"http://localhost:18081/admin/datasets","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:46:43.192Z","cmd":"click","args":".el-upload-list__item-actions .el-icon--delete","origin":"http://localhost:18081/admin/datasets","durationMs":35002,"status":"error","hasCookies":false,"mode":"launched","error":"click: Timeout 5000ms exceeded.\nCall log:\n\u001b[2m - waiting for locator('.el-upload-list__item-actions .el-icon--delete')\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T06:46:43.768Z","cmd":"snapshot","args":"-i","origin":"http://localhost:18081/admin/datasets","durationMs":10,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:46:47.830Z","cmd":"click","args":"@e26","origin":"http://localhost:18081/admin/datasets","durationMs":24,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:46:47.890Z","cmd":"wait","args":"--load","origin":"http://localhost:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:46:47.944Z","cmd":"js","args":"sqliteCheck = 1; 'saved'","origin":"http://localhost:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:46:52.629Z","cmd":"js","args":"(() => { const card = [...document.querySelectorAll('.ds-card')].find(c => c.innerText.includes('野鸡')); return { hasPlaceholder: !!card.querySelector('.ds-cover-placeholder'), hasImg: !!card.querySele…","origin":"http://localhost:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:47:20.493Z","cmd":"js","args":"(() => { const btn = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === '新建数据集'); if (btn) btn.click(); return !!btn })()","origin":"http://localhost:18081/admin/datasets","durationMs":7,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:47:20.551Z","cmd":"wait","args":"--load","origin":"http://localhost:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T06:47:20.602Z","cmd":"js","args":"(() => { const dlg = [...document.querySelectorAll('.el-dialog')].find(d => d.textContent.includes('新建数据集') && getComputedStyle(d).display !== 'none'); if (!dlg) return 'dialog not open'; const items …","origin":"http://localhost:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T07:59:04.562Z","cmd":"goto","args":"http://127.0.0.1:18081/admin/","origin":"http://127.0.0.1:18081/admin/login","durationMs":125,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T07:59:04.624Z","cmd":"js","args":"document.title","origin":"http://127.0.0.1:18081/admin/login","durationMs":3,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T07:59:08.761Z","cmd":"js","args":"localStorage.setItem('adminToken','Tongli686^*^'); location.reload(); 'ok'","origin":"http://127.0.0.1:18081/admin/login","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T07:59:10.842Z","cmd":"text","args":"","origin":"http://127.0.0.1:18081/admin/login","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T07:59:18.081Z","cmd":"url","args":"","origin":"http://127.0.0.1:18081/admin/login","durationMs":4,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T07:59:18.126Z","cmd":"js","args":"localStorage.getItem('adminToken')","origin":"http://127.0.0.1:18081/admin/login","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T07:59:26.212Z","cmd":"goto","args":"http://127.0.0.1:18081/admin/datasets","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":72,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T07:59:28.288Z","cmd":"text","args":"","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T07:59:32.742Z","cmd":"click","args":"text=训练配置","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":37,"status":"error","hasCookies":false,"mode":"launched","error":"click: Error: strict mode violation: locator('text=训练配置') resolved to 2 elements:\n 1) <span class=\"\">训练配置</span> aka getByRole('button', { name: '训练配置' })\n 2) <div data-v-32fcbbad=\"\" class=\"ds-actions\">…</div> aka getByText('开始训练配置导出删除')\n\nCall log:\n\u001b[2m - waiting for locator('text=训练配置')\u001b[22m…"}
|
||||
{"ts":"2026-08-26T07:59:33.824Z","cmd":"text","args":"","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T07:59:37.601Z","cmd":"snapshot","args":"-s .el-dialog","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":5,"status":"error","hasCookies":false,"mode":"launched","error":"Selector not found: .el-dialog"}
|
||||
{"ts":"2026-08-26T07:59:45.384Z","cmd":"js","args":"document.querySelectorAll('button').length","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T07:59:45.437Z","cmd":"js","args":"[...document.querySelectorAll('button')].map(b=>b.textContent.trim()).filter(t=>t)","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T07:59:48.856Z","cmd":"js","args":"JSON.stringify([...document.querySelectorAll('button')].map(b=>b.textContent.trim()).filter(Boolean))","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T07:59:52.214Z","cmd":"js","args":"[...document.querySelectorAll('button')].find(b=>b.textContent.includes('训练配置')).click(); 'clicked'","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":6,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T07:59:53.284Z","cmd":"text","args":"","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T07:59:58.198Z","cmd":"js","args":"JSON.stringify([...document.querySelectorAll('.el-dialog input, .el-dialog textarea')].map(i=>({ph:i.placeholder, v:i.value})))","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:00:11.983Z","cmd":"fill","args":".el-dialog input:nth-of-type(1) http://192.168.9.9:8020","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":8,"status":"error","hasCookies":false,"mode":"launched","error":"fill: Error: strict mode violation: locator('.el-dialog input:nth-of-type(1)') resolved to 5 elements:\n 1) <input type=\"text\" tabindex=\"0\" maxlength=\"500\" autocomplete=\"off\" id=\"el-id-6129-22\" class=\"el-input__inner\" placeholder=\"默认 config.yml 未配置\"/> aka getByRole('textbox', { name: '模型端点' })\n …"}
|
||||
{"ts":"2026-08-26T08:00:17.027Z","cmd":"fill","args":".el-dialog input:nth-of-type(3) 10.0.0.8:2222","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":5002,"status":"error","hasCookies":false,"mode":"launched","error":"fill: Timeout 5000ms exceeded.\nCall log:\n\u001b[2m - waiting for locator('.el-dialog input:nth-of-type(3)')\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T08:00:22.085Z","cmd":"fill","args":".el-dialog input:nth-of-type(4) root","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":5002,"status":"error","hasCookies":false,"mode":"launched","error":"fill: Timeout 5000ms exceeded.\nCall log:\n\u001b[2m - waiting for locator('.el-dialog input:nth-of-type(4)')\u001b[22m\n"}
|
||||
{"ts":"2026-08-26T08:00:27.460Z","cmd":"js","args":"const ins=[...document.querySelectorAll('.el-dialog input')]; ins[0].value='http://192.168.9.9:8020'; ins[0].dispatchEvent(new Event('input',{bubbles:true})); ins[2].value='10.0.0.8:2222'; ins[2].disp…","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":4,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:00:31.974Z","cmd":"js","args":"[...document.querySelectorAll('.el-dialog button')].find(b=>b.textContent.includes('保存')).click(); 'saved'","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":5,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:00:38.423Z","cmd":"js","args":"document.querySelector('.el-dialog__headerbtn')?.click(); 'closed'","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:00:39.516Z","cmd":"js","args":"[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='配置').click(); 'clicked'","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":11,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:00:40.593Z","cmd":"js","args":"JSON.stringify([...document.querySelectorAll('.el-dialog .el-form-item__label')].map(l=>l.textContent.trim()))","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:00:46.698Z","cmd":"js","args":"JSON.stringify([...document.querySelectorAll('.el-dialog')].map(d=>({title:d.querySelector('.el-dialog__title')?.textContent, hidden:d.style.display==='none', display:getComputedStyle(d).display})))","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:00:52.121Z","cmd":"js","args":"JSON.stringify([...document.querySelectorAll('.el-overlay')].map(o=>({vis:o.style.display, hasDialog:!!o.querySelector('.el-dialog')})))","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:00:58.330Z","cmd":"js","args":"const vis=[...document.querySelectorAll('.el-overlay')].find(o=>o.style.display!=='none'); JSON.stringify([...vis.querySelectorAll('.el-form-item__label')].map(l=>l.textContent.trim()))","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:01:05.317Z","cmd":"goto","args":"http://127.0.0.1:18081/admin/datasets/102","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":27,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:01:07.394Z","cmd":"js","args":"const btns=[...document.querySelectorAll('button')].map(b=>b.textContent.trim()).filter(t=>t); JSON.stringify(btns)","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:01:11.677Z","cmd":"js","args":"[...document.querySelectorAll('button')].find(b=>b.textContent.includes('标注')&&!b.textContent.includes('自动')).click(); 'clicked'","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":14,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:01:12.762Z","cmd":"js","args":"JSON.stringify([...document.querySelectorAll('.el-message-box')].map(m=>m.textContent.slice(0,120)))","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:01:16.945Z","cmd":"js","args":"JSON.stringify([...document.querySelectorAll('.el-message-box')].map(m=>({txt:m.textContent.slice(0,140), display:getComputedStyle(m.parentElement).display})))","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:01:16.987Z","cmd":"js","args":"const b=[...document.querySelectorAll('button')].filter(x=>x.textContent.includes('标注')&&!x.textContent.includes('自动')); b.length + ' buttons, first: ' + (b[0]?.outerHTML||'').slice(0,200)","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:01:22.341Z","cmd":"js","args":"[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='标注'&&!b.textContent.includes('自动')).click(); 'clicked'","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:01:23.416Z","cmd":"js","args":"JSON.stringify([...document.querySelectorAll('.el-message-box')].map(m=>({txt:m.textContent.replace(/\\s+/g,' ').slice(0,150), vis:getComputedStyle(m).display})))","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:21:22.425Z","cmd":"goto","args":"http://127.0.0.1:18081/admin/datasets","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":339,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:21:26.770Z","cmd":"js","args":"localStorage.setItem('adminToken','Tongli686^*^'); location.href='/admin/datasets'; 'token set'","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:21:28.351Z","cmd":"js","args":"const btns=[...document.querySelectorAll('.el-button')].map(b=>b.textContent.trim()).filter(Boolean); JSON.stringify({toolbar:btns.slice(0,6), hasTrainCfg: btns.some(t=>t.includes('训练配置'))})","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:21:35.236Z","cmd":"js","args":"const btns=[...document.querySelectorAll('.el-button')].filter(b=>b.textContent.trim()==='配置'); btns[0].click(); 'clicked'","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":15,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:21:36.095Z","cmd":"js","args":"const dlg=[...document.querySelectorAll('.el-dialog')].find(d=>{const o=d.closest('.el-overlay');return o&&o.style.display!=='none'}); const ta=dlg?.querySelector('textarea'); JSON.stringify({title:dl…","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:21:40.404Z","cmd":"js","args":"const dlg=[...document.querySelectorAll('.el-dialog')].find(d=>d.closest('.el-overlay')?.style.display!=='none'); const r=dlg?.getBoundingClientRect(); JSON.stringify({w:Math.round(r?.width||0), h:Mat…","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:35:51.889Z","cmd":"status","args":"","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:35:55.018Z","cmd":"text","args":"","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":6,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:35:55.059Z","cmd":"console","args":"--errors","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:35:58.393Z","cmd":"screenshot","args":"/tmp/cover-state.png","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":183,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:36:02.816Z","cmd":"js","args":"(() => { const tiles = document.querySelectorAll('.upload-tile'); return 'upload-tiles: ' + tiles.length + ' | cover-items: ' + document.querySelectorAll('.el-upload-list--picture-card li').length; })…","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:36:06.506Z","cmd":"goto","args":"http://127.0.0.1:18081/admin/datasets","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":94,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:36:08.584Z","cmd":"js","args":"(() => { const tiles = document.querySelectorAll('.upload-tile'); return 'upload-tiles: ' + tiles.length; })()","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:36:11.515Z","cmd":"snapshot","args":"-i","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":22,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:36:13.701Z","cmd":"click","args":"@e10","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":56,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:36:14.773Z","cmd":"js","args":"(() => { const tiles = document.querySelectorAll('.upload-tile'); const items = document.querySelectorAll('.el-upload-list--picture-card li'); const wrap = document.querySelector('.el-upload--picture-…","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:36:18.137Z","cmd":"js","args":"(() => { const del = document.querySelector('.el-upload-list--picture-card li .el-upload-list__item-delete'); if (del) { del.click(); return 'clicked delete'; } return 'no delete btn'; })()","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:36:19.205Z","cmd":"js","args":"(() => { const tiles = document.querySelectorAll('.upload-tile'); const items = document.querySelectorAll('.el-upload-list--picture-card li'); return 'upload-tiles: ' + tiles.length + ' | cover-items:…","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:36:22.406Z","cmd":"js","args":"(() => { const btns = [...document.querySelectorAll('.el-dialog__footer .el-button')]; const cancel = btns.find(b => b.textContent.includes('取消')); if (cancel) { cancel.click(); return 'canceled'; } r…","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:36:26.255Z","cmd":"goto","args":"http://127.0.0.1:18081/admin/datasets/102","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":28,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:36:28.339Z","cmd":"text","args":"","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:36:28.385Z","cmd":"text","args":"","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:36:31.420Z","cmd":"js","args":"(() => { const card = document.querySelector('.card-img'); if (card) { card.click(); return 'opened editor'; } return 'no card'; })()","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":9,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:36:33.508Z","cmd":"text","args":"","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:36:36.057Z","cmd":"screenshot","args":"/tmp/editor-state.png","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":55,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:36:38.628Z","cmd":"js","args":"(() => { const c = document.querySelector('.wb-canvas'); if (!c) return 'no canvas'; const ctx = c.getContext('2d'); const d = ctx.getImageData(0,0,c.width,c.height).data; let hasGreen=0, hasOrange=0;…","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":6,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:37:08.255Z","cmd":"js","args":"(() => { const c = document.querySelector('.wb-canvas'); const ctx = c.getContext('2d'); const img = ctx.getImageData(0,0,c.width,c.height); const d = img.data; // 扫描中线行,检查绿色描边是否连续(实线) vs 间断(虚线) const…","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":7,"status":"error","hasCookies":false,"mode":"launched","error":"evaluate: SyntaxError: Unexpected end of input\n at eval (<anonymous>)\n at UtilityScript.evaluate (<anonymous>:290:30)\n at UtilityScript.<anonymous> (<anonymous>:1:44)"}
|
||||
{"ts":"2026-08-26T08:37:11.090Z","cmd":"js","args":"(() => { const c = document.querySelector('.wb-canvas'); if (!c) return 'no canvas'; const ctx = c.getContext('2d'); const img = ctx.getImageData(0,0,c.width,c.height); const d = img.data; const midY …","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":3,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:37:22.116Z","cmd":"js","args":"(() => { const c = document.querySelector('.wb-canvas'); const ctx = c.getContext('2d'); const d = ctx.getImageData(0,0,c.width,c.height).data; const w=c.width,h=c.height; let pts=[]; for (let y=0;y<h…","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:37:25.566Z","cmd":"js","args":"(() => { const c = document.querySelector('.wb-canvas'); const r = c.getBoundingClientRect(); const px = r.left + (138/r.width)*r.width; const py = r.top + (253/r.height)*r.height; const rect2 = c.get…","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":4,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:37:26.640Z","cmd":"js","args":"(() => { const c = document.querySelector('.wb-canvas'); const ctx = c.getContext('2d'); const d = ctx.getImageData(0,0,c.width,c.height).data; const w=c.width,h=c.height; let green=0,orange=0; for (l…","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:37:30.842Z","cmd":"dialog-accept","args":"放弃修改","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:37:30.891Z","cmd":"js","args":"(() => { const close = document.querySelector('.editor-fullscreen .el-dialog__headerbtn'); if (close) { close.click(); return 'clicked close'; } return 'no close btn'; })()","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":7,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:37:31.972Z","cmd":"text","args":"","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:37:32.016Z","cmd":"console","args":"--errors","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":0,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:37:34.727Z","cmd":"js","args":"(() => { const d = document.querySelector('.editor-fullscreen'); const m = document.querySelector('.el-message-box'); return 'editor-open: ' + (d && d.style.display !== 'none') + ' | discard-dialog: '…","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:37:36.882Z","cmd":"js","args":"(() => { const btns = [...document.querySelectorAll('.el-message-box__btns .el-button')]; const b = btns.find(x => x.textContent.includes('放弃修改')); if (b) { b.click(); return 'discarded'; } return 'no…","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":4,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:37:37.941Z","cmd":"js","args":"(() => { const d = document.querySelector('.editor-fullscreen'); return 'editor-open: ' + (d && d.style.display !== 'none'); })()","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:37:41.401Z","cmd":"js","args":"(() => { const d = document.querySelector('.editor-fullscreen'); const m = document.querySelector('.el-message-box'); return 'editor-open: ' + (d ? getComputedStyle(d).display : 'gone') + ' | discard-…","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:37:45.186Z","cmd":"js","args":"(() => { const d = document.querySelector('.editor-fullscreen'); return 'editor: ' + (d ? getComputedStyle(d).display : 'gone'); })()","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:37:45.230Z","cmd":"text","args":"","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:37:51.268Z","cmd":"js","args":"(() => { const close = document.querySelector('.editor-fullscreen .el-dialog__headerbtn'); if (close) { close.click(); return 'clicked close'; } return 'no close'; })()","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":7,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:37:52.351Z","cmd":"js","args":"(() => { const btns = [...document.querySelectorAll('.el-message-box__btns .el-button')]; const b = btns.find(x => x.textContent.includes('放弃修改')); if (b) { b.click(); return 'clicked discard'; } retu…","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:37:53.929Z","cmd":"js","args":"(() => { const d = document.querySelector('.editor-fullscreen'); return 'editor: ' + (d ? getComputedStyle(d).display : 'gone'); })()","origin":"http://127.0.0.1:18081/admin/datasets/102","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
{"ts":"2026-08-26T08:38:27.020Z","cmd":"goto","args":"http://127.0.0.1:18081/admin/datasets","origin":"http://127.0.0.1:18081/admin/datasets","durationMs":35,"status":"ok","hasCookies":false,"mode":"launched"}
|
||||
@@ -0,0 +1 @@
|
||||
[2026-08-26T08:37:11.090Z] [warning] Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently
|
||||
@@ -0,0 +1,84 @@
|
||||
[2026-08-26T07:59:04.446Z] GET http://127.0.0.1:18081/admin/ → 200 (9ms, 414B)
|
||||
[2026-08-26T07:59:04.467Z] GET http://127.0.0.1:18081/admin/assets/index-CynwB22O.js → 200 (24ms, 1107009B)
|
||||
[2026-08-26T07:59:04.467Z] GET http://127.0.0.1:18081/admin/assets/index-DpxmxxjD.css → 200 (1ms, 369092B)
|
||||
[2026-08-26T07:59:08.763Z] GET http://127.0.0.1:18081/admin/login → 200 (2ms, 414B)
|
||||
[2026-08-26T07:59:08.770Z] GET http://127.0.0.1:18081/admin/assets/index-CynwB22O.js → 200 (?ms, -195B)
|
||||
[2026-08-26T07:59:08.771Z] GET http://127.0.0.1:18081/admin/assets/index-DpxmxxjD.css → 200 (?ms, -187B)
|
||||
[2026-08-26T07:59:26.143Z] GET http://127.0.0.1:18081/admin/datasets → 200 (1ms, 414B)
|
||||
[2026-08-26T07:59:26.149Z] GET http://127.0.0.1:18081/admin/assets/index-CynwB22O.js → 200 (1ms, -195B)
|
||||
[2026-08-26T07:59:26.151Z] GET http://127.0.0.1:18081/admin/assets/index-DpxmxxjD.css → 200 (1ms, -187B)
|
||||
[2026-08-26T07:59:26.212Z] GET http://127.0.0.1:18081/api/v1/admin/datasets?page=1&size=12 → 200 (15ms, 1460B)
|
||||
[2026-08-26T07:59:26.236Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/cover?datasetId=102&token=Tongli686%5E*%5E → 200 (4ms, 1127453B)
|
||||
[2026-08-26T07:59:52.214Z] GET http://127.0.0.1:18081/api/v1/admin/app-config → 200 (45ms, 262B)
|
||||
[2026-08-26T08:00:31.974Z] POST http://127.0.0.1:18081/api/v1/admin/app-config → 200 (6ms, 46B)
|
||||
[2026-08-26T08:01:05.293Z] GET http://127.0.0.1:18081/admin/datasets/102 → 200 (1ms, 414B)
|
||||
[2026-08-26T08:01:05.299Z] GET http://127.0.0.1:18081/admin/assets/index-CynwB22O.js → 304 (1ms, ?B)
|
||||
[2026-08-26T08:01:05.299Z] GET http://127.0.0.1:18081/admin/assets/index-DpxmxxjD.css → 304 (2ms, ?B)
|
||||
[2026-08-26T08:01:05.316Z] GET http://127.0.0.1:18081/api/v1/admin/datasets?page=1&size=100 → 200 (5ms, 1460B)
|
||||
[2026-08-26T08:01:05.317Z] GET http://127.0.0.1:18081/api/v1/admin/label-workbench?datasetId=102 → pending (?ms, ?B)
|
||||
[2026-08-26T08:01:05.317Z] GET http://127.0.0.1:18081/api/v1/admin/app-config → 200 (3ms, 303B)
|
||||
[2026-08-26T08:01:05.476Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/images?datasetId=102 → 200 (12ms, 18472B)
|
||||
[2026-08-26T08:01:05.488Z] GET http://127.0.0.1:18081/api/v1/admin/label-tasks?page=1&size=100 → 200 (23ms, 65B)
|
||||
[2026-08-26T08:01:05.495Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_99.jpg&token=Tongli686%5E*%5E → 200 (34ms, 3411120B)
|
||||
[2026-08-26T08:01:05.495Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_98.jpg&token=Tongli686%5E*%5E → 200 (33ms, 3405948B)
|
||||
[2026-08-26T08:01:05.495Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_97.jpg&token=Tongli686%5E*%5E → 200 (33ms, 2828567B)
|
||||
[2026-08-26T08:01:05.496Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_96.jpg&token=Tongli686%5E*%5E → 200 (33ms, 3918728B)
|
||||
[2026-08-26T08:01:05.496Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_95.jpg&token=Tongli686%5E*%5E → 200 (32ms, 3541877B)
|
||||
[2026-08-26T08:01:05.496Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_94.jpg&token=Tongli686%5E*%5E → 200 (29ms, 2875825B)
|
||||
[2026-08-26T08:01:05.496Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_93.jpg&token=Tongli686%5E*%5E → 200 (37ms, 3190142B)
|
||||
[2026-08-26T08:01:05.496Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_92.jpg&token=Tongli686%5E*%5E → 200 (50ms, 3715545B)
|
||||
[2026-08-26T08:01:05.496Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_91.jpg&token=Tongli686%5E*%5E → 200 (75ms, 3411979B)
|
||||
[2026-08-26T08:01:05.496Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_90.jpg&token=Tongli686%5E*%5E → 200 (88ms, 3133503B)
|
||||
[2026-08-26T08:01:05.496Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_89.jpg&token=Tongli686%5E*%5E → 200 (90ms, 3400415B)
|
||||
[2026-08-26T08:01:05.496Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_88.jpg&token=Tongli686%5E*%5E → 200 (94ms, 2239294B)
|
||||
[2026-08-26T08:01:05.497Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_87.jpg&token=Tongli686%5E*%5E → 200 (95ms, 2213846B)
|
||||
[2026-08-26T08:01:05.497Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_86.jpg&token=Tongli686%5E*%5E → 200 (142ms, 2372922B)
|
||||
[2026-08-26T08:01:05.502Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_85.jpg&token=Tongli686%5E*%5E → 200 (138ms, 3616144B)
|
||||
[2026-08-26T08:01:05.506Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_84.jpg&token=Tongli686%5E*%5E → 200 (134ms, 3220261B)
|
||||
[2026-08-26T08:01:05.506Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_83.jpg&token=Tongli686%5E*%5E → 200 (135ms, 3497553B)
|
||||
[2026-08-26T08:01:05.510Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_82.jpg&token=Tongli686%5E*%5E → 200 (130ms, 3075680B)
|
||||
[2026-08-26T08:21:22.121Z] GET http://127.0.0.1:18081/admin/datasets → 200 (42ms, 414B)
|
||||
[2026-08-26T08:21:22.265Z] GET http://127.0.0.1:18081/admin/assets/index-CW3B_yra.js → 200 (42ms, 1103639B)
|
||||
[2026-08-26T08:21:22.265Z] GET http://127.0.0.1:18081/admin/assets/index-BpJ89PCs.css → 200 (5ms, 369000B)
|
||||
[2026-08-26T08:21:22.425Z] GET http://127.0.0.1:18081/api/v1/admin/datasets?page=1&size=12 → 200 (14ms, 1460B)
|
||||
[2026-08-26T08:21:22.442Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/cover?datasetId=102&token=Tongli686%5E*%5E → 200 (5ms, 972603B)
|
||||
[2026-08-26T08:21:26.772Z] GET http://127.0.0.1:18081/admin/datasets → 200 (1ms, -185B)
|
||||
[2026-08-26T08:21:26.777Z] GET http://127.0.0.1:18081/admin/assets/index-CW3B_yra.js → 200 (?ms, -195B)
|
||||
[2026-08-26T08:21:26.777Z] GET http://127.0.0.1:18081/admin/assets/index-BpJ89PCs.css → 200 (?ms, -187B)
|
||||
[2026-08-26T08:21:26.798Z] GET http://127.0.0.1:18081/api/v1/admin/datasets?page=1&size=12 → 200 (13ms, 1460B)
|
||||
[2026-08-26T08:21:26.812Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/cover?datasetId=102&token=Tongli686%5E*%5E → 200 (?ms, -174B)
|
||||
[2026-08-26T08:36:06.413Z] GET http://127.0.0.1:18081/admin/datasets → 200 (2ms, 414B)
|
||||
[2026-08-26T08:36:06.420Z] GET http://127.0.0.1:18081/admin/assets/index-BpSGpVnT.js → 200 (23ms, 1103309B)
|
||||
[2026-08-26T08:36:06.420Z] GET http://127.0.0.1:18081/admin/assets/index-A2o_Z8bw.css → 200 (1ms, 369000B)
|
||||
[2026-08-26T08:36:06.505Z] GET http://127.0.0.1:18081/api/v1/admin/datasets?page=1&size=12 → 200 (6ms, 1460B)
|
||||
[2026-08-26T08:36:06.512Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/cover?datasetId=102&token=Tongli686%5E*%5E → 304 (?ms, ?B)
|
||||
[2026-08-26T08:36:26.229Z] GET http://127.0.0.1:18081/admin/datasets/102 → 200 (2ms, 414B)
|
||||
[2026-08-26T08:36:26.234Z] GET http://127.0.0.1:18081/admin/assets/index-BpSGpVnT.js → 304 (1ms, ?B)
|
||||
[2026-08-26T08:36:26.234Z] GET http://127.0.0.1:18081/admin/assets/index-A2o_Z8bw.css → 304 (1ms, ?B)
|
||||
[2026-08-26T08:36:26.255Z] GET http://127.0.0.1:18081/api/v1/admin/datasets?page=1&size=100 → 200 (5ms, 1460B)
|
||||
[2026-08-26T08:36:26.255Z] GET http://127.0.0.1:18081/api/v1/admin/label-workbench?datasetId=102 → 200 (195ms, 75306B)
|
||||
[2026-08-26T08:36:26.452Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/images?datasetId=102 → 200 (2ms, 18472B)
|
||||
[2026-08-26T08:36:26.459Z] GET http://127.0.0.1:18081/api/v1/admin/label-tasks?page=1&size=100 → 200 (4ms, 65B)
|
||||
[2026-08-26T08:36:26.473Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_99.jpg&token=Tongli686%5E*%5E → 200 (2ms, ?B)
|
||||
[2026-08-26T08:36:26.473Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_98.jpg&token=Tongli686%5E*%5E → 200 (2ms, 3405948B)
|
||||
[2026-08-26T08:36:26.473Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_97.jpg&token=Tongli686%5E*%5E → 200 (3ms, 2828567B)
|
||||
[2026-08-26T08:36:26.473Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_96.jpg&token=Tongli686%5E*%5E → 200 (4ms, ?B)
|
||||
[2026-08-26T08:36:26.473Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_95.jpg&token=Tongli686%5E*%5E → 200 (3ms, ?B)
|
||||
[2026-08-26T08:36:26.473Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_94.jpg&token=Tongli686%5E*%5E → 200 (3ms, 2875825B)
|
||||
[2026-08-26T08:36:26.473Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_93.jpg&token=Tongli686%5E*%5E → 200 (27ms, 3190142B)
|
||||
[2026-08-26T08:36:26.473Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_92.jpg&token=Tongli686%5E*%5E → 200 (27ms, 3715545B)
|
||||
[2026-08-26T08:36:26.473Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_91.jpg&token=Tongli686%5E*%5E → 200 (51ms, 3411979B)
|
||||
[2026-08-26T08:36:26.473Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_90.jpg&token=Tongli686%5E*%5E → 200 (56ms, 3133503B)
|
||||
[2026-08-26T08:36:26.473Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_89.jpg&token=Tongli686%5E*%5E → 200 (135ms, ?B)
|
||||
[2026-08-26T08:36:26.473Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_88.jpg&token=Tongli686%5E*%5E → 200 (142ms, ?B)
|
||||
[2026-08-26T08:36:26.473Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_87.jpg&token=Tongli686%5E*%5E → 200 (160ms, 2213846B)
|
||||
[2026-08-26T08:36:26.474Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_86.jpg&token=Tongli686%5E*%5E → 200 (158ms, 2372922B)
|
||||
[2026-08-26T08:36:26.474Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_85.jpg&token=Tongli686%5E*%5E → 200 (159ms, ?B)
|
||||
[2026-08-26T08:36:26.475Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_84.jpg&token=Tongli686%5E*%5E → 200 (159ms, ?B)
|
||||
[2026-08-26T08:36:26.475Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_83.jpg&token=Tongli686%5E*%5E → 200 (160ms, 3497553B)
|
||||
[2026-08-26T08:36:26.475Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/image?datasetId=102&filename=pheasant_82.jpg&token=Tongli686%5E*%5E → 200 (160ms, 3075680B)
|
||||
[2026-08-26T08:38:26.988Z] GET http://127.0.0.1:18081/admin/datasets → 200 (1ms, 414B)
|
||||
[2026-08-26T08:38:26.995Z] GET http://127.0.0.1:18081/admin/assets/index-BpSGpVnT.js → 304 (1ms, ?B)
|
||||
[2026-08-26T08:38:26.995Z] GET http://127.0.0.1:18081/admin/assets/index-A2o_Z8bw.css → 304 (?ms, ?B)
|
||||
[2026-08-26T08:38:27.020Z] GET http://127.0.0.1:18081/api/v1/admin/datasets?page=1&size=12 → 200 (4ms, 1460B)
|
||||
[2026-08-26T08:38:27.026Z] GET http://127.0.0.1:18081/api/v1/admin/datasets/cover?datasetId=102&token=Tongli686%5E*%5E → 200 (2ms, 972603B)
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"available": true,
|
||||
"path": "/opt/homebrew/bin/claude",
|
||||
"install_url": "https://docs.anthropic.com/en/docs/claude-code",
|
||||
"checked_at": "2026-08-26T08:00:04.399Z"
|
||||
}
|
||||
Generated
+17
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="dataSourceStorageLocal" created-in="GO-252.28539.22">
|
||||
<data-source name="observer" uuid="09fcd334-c3f7-4a03-a88b-918991abce57">
|
||||
<database-info product="SQLite" version="3.51.1" jdbc-version="4.2" driver-name="SQLite JDBC" driver-version="3.51.1.0" dbms="SQLITE" exact-version="3.51.1" exact-driver-version="3.51">
|
||||
<identifier-quote-string>"</identifier-quote-string>
|
||||
</database-info>
|
||||
<case-sensitivity plain-identifiers="mixed" quoted-identifiers="mixed" />
|
||||
<auth-provider>no-auth</auth-provider>
|
||||
<schema-mapping>
|
||||
<introspection-scope>
|
||||
<node kind="schema" qname="@" />
|
||||
</introspection-scope>
|
||||
</schema-mapping>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+17
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="observer" uuid="09fcd334-c3f7-4a03-a88b-918991abce57">
|
||||
<driver-ref>sqlite.xerial</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/server/data/observer.db</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
<libraries>
|
||||
<library>
|
||||
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.51.1/org/xerial/sqlite-jdbc/3.51.1.0/sqlite-jdbc-3.51.1.0.jar</url>
|
||||
</library>
|
||||
</libraries>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
+2199
File diff suppressed because it is too large
Load Diff
Generated
+2
@@ -0,0 +1,2 @@
|
||||
#n:main
|
||||
!<md> [0, 0, null, null, -2147483648, -2147483648]
|
||||
Generated
+4
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
</module>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+178
@@ -0,0 +1,178 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AutoImportSettings">
|
||||
<option name="autoReloadType" value="ALL" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="b061ab81-c08b-429a-b948-ff0f65eb92f8" name="Changes" comment="1">
|
||||
<change afterPath="$PROJECT_DIR$/server/.gitignore" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/server/training/train_server.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/.gitignore" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flutter_app/.gitignore" beforeDir="false" afterPath="$PROJECT_DIR$/flutter_app/.gitignore" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flutter_app/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flutter_app/README.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flutter_app/lib/app.dart" beforeDir="false" afterPath="$PROJECT_DIR$/flutter_app/lib/app.dart" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flutter_app/lib/camera/camera_screen.dart" beforeDir="false" afterPath="$PROJECT_DIR$/flutter_app/lib/camera/camera_screen.dart" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flutter_app/lib/camera/detection_overlay.dart" beforeDir="false" afterPath="$PROJECT_DIR$/flutter_app/lib/camera/detection_overlay.dart" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flutter_app/lib/detection/detection_result.dart" beforeDir="false" afterPath="$PROJECT_DIR$/flutter_app/lib/detection/detection_result.dart" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flutter_app/lib/detection/detector_worker.dart" beforeDir="false" afterPath="$PROJECT_DIR$/flutter_app/lib/detection/detector_worker.dart" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flutter_app/lib/detection/tflite_detector.dart" beforeDir="false" afterPath="$PROJECT_DIR$/flutter_app/lib/detection/tflite_detector.dart" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flutter_app/lib/update/update_checker.dart" beforeDir="false" afterPath="$PROJECT_DIR$/flutter_app/lib/update/update_checker.dart" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flutter_app/pubspec.lock" beforeDir="false" afterPath="$PROJECT_DIR$/flutter_app/pubspec.lock" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flutter_app/pubspec.yaml" beforeDir="false" afterPath="$PROJECT_DIR$/flutter_app/pubspec.yaml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/server/README.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server/admin_dist/assets/index-BcKljL0h.css" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server/admin_dist/assets/index-FJsP2pyZ.js" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server/admin_dist/index.html" beforeDir="false" afterPath="$PROJECT_DIR$/server/admin_dist/index.html" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server/biz/consts/consts.go" beforeDir="false" afterPath="$PROJECT_DIR$/server/biz/consts/consts.go" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server/biz/controller/admin.go" beforeDir="false" afterPath="$PROJECT_DIR$/server/biz/controller/admin.go" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server/biz/model/dto/app_version.go" beforeDir="false" afterPath="$PROJECT_DIR$/server/biz/model/dto/app_version.go" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server/biz/service/app_version.go" beforeDir="false" afterPath="$PROJECT_DIR$/server/biz/service/app_version.go" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server/common/admin_auth.go" beforeDir="false" afterPath="$PROJECT_DIR$/server/common/admin_auth.go" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server/common/errors.go" beforeDir="false" afterPath="$PROJECT_DIR$/server/common/errors.go" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server/common/pool.go" beforeDir="false" afterPath="$PROJECT_DIR$/server/common/pool.go" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server/config.yml" beforeDir="false" afterPath="$PROJECT_DIR$/server/config.yml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server/data/observer.db" beforeDir="false" afterPath="$PROJECT_DIR$/server/data/observer.db" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server/go.mod" beforeDir="false" afterPath="$PROJECT_DIR$/server/go.mod" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server/go.sum" beforeDir="false" afterPath="$PROJECT_DIR$/server/go.sum" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server/main.go" beforeDir="false" afterPath="$PROJECT_DIR$/server/main.go" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server/技术设计.md" beforeDir="false" afterPath="$PROJECT_DIR$/server/技术设计.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server_admin/src/App.vue" beforeDir="false" afterPath="$PROJECT_DIR$/server_admin/src/App.vue" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server_admin/src/router/index.js" beforeDir="false" afterPath="$PROJECT_DIR$/server_admin/src/router/index.js" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server_admin/src/views/Login.vue" beforeDir="false" afterPath="$PROJECT_DIR$/server_admin/src/views/Login.vue" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/server_admin/vite.config.js" beforeDir="false" afterPath="$PROJECT_DIR$/server_admin/vite.config.js" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/training/analyze_rfdetr.py" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/training/dump_graph.py" beforeDir="false" afterPath="$PROJECT_DIR$/server/training/dump_graph.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/training/inspect_tflite.py" beforeDir="false" afterPath="$PROJECT_DIR$/server/training/inspect_tflite.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/training/prepare_yolo.py" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/training/train_local.py" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/training/train_server.py" beforeDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||
</component>
|
||||
<component name="GOROOT" url="file://$USER_HOME$/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.26.1.darwin-arm64" />
|
||||
<component name="Git.Settings">
|
||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||
</component>
|
||||
<component name="KubernetesApiPersistence">{}</component>
|
||||
<component name="KubernetesApiProvider">{
|
||||
"isMigrated": true
|
||||
}</component>
|
||||
<component name="ProjectColorInfo">{
|
||||
"associatedIndex": 6
|
||||
}</component>
|
||||
<component name="ProjectId" id="3I28yz8Hk3epXfGqRh8aLTOz9C2" />
|
||||
<component name="ProjectViewState">
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent"><![CDATA[{
|
||||
"keyToString": {
|
||||
"Docker.server: Compose Deployment (1).executor": "Run",
|
||||
"Docker.server: Compose Deployment.executor": "Run",
|
||||
"Go Build.go build main.go.executor": "Run",
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"RunOnceActivity.GoLinterPluginOnboarding": "true",
|
||||
"RunOnceActivity.GoLinterPluginStorageMigration": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"RunOnceActivity.go.formatter.settings.were.checked": "true",
|
||||
"RunOnceActivity.go.migrated.go.modules.settings": "true",
|
||||
"RunOnceActivity.go.modules.go.list.on.any.changes.was.set": "true",
|
||||
"git-widget-placeholder": "main",
|
||||
"go.import.settings.migrated": "true",
|
||||
"go.sdk.automatically.set": "true",
|
||||
"last_opened_file_path": "/Users/zhangbin/Desktop/d盘/work/observer/server",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"settings.editor.selected.configurable": "com.anthropic.code.plugin.settings",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
}
|
||||
}]]></component>
|
||||
<component name="RecentsManager">
|
||||
<key name="CopyFile.RECENT_KEYS">
|
||||
<recent name="$PROJECT_DIR$/server" />
|
||||
<recent name="$PROJECT_DIR$/training/datasets/images/pheasant" />
|
||||
</key>
|
||||
<key name="MoveFile.RECENT_KEYS">
|
||||
<recent name="$PROJECT_DIR$/flutter_app" />
|
||||
<recent name="$PROJECT_DIR$" />
|
||||
<recent name="$PROJECT_DIR$/server/workspace/datasets/野鸡" />
|
||||
<recent name="$PROJECT_DIR$/training/datasets/images/pheasant" />
|
||||
</key>
|
||||
</component>
|
||||
<component name="RunManager" selected="Go Build.go build main.go">
|
||||
<configuration name="go build main.go" type="GoApplicationRunConfiguration" factoryName="Go Application" temporary="true" nameIsGenerated="true">
|
||||
<module name="observer" />
|
||||
<working_directory value="$PROJECT_DIR$/server" />
|
||||
<kind value="FILE" />
|
||||
<directory value="$PROJECT_DIR$" />
|
||||
<filePath value="$PROJECT_DIR$/server/main.go" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration default="true" type="docker-deploy" factoryName="docker-compose.yml" temporary="true">
|
||||
<deployment type="docker-compose.yml" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="server: Compose Deployment (1)" type="docker-deploy" factoryName="docker-compose.yml" temporary="true" server-name="dev">
|
||||
<deployment type="docker-compose.yml">
|
||||
<settings>
|
||||
<option name="sourceFilePath" value="server/docker-compose.yml" />
|
||||
</settings>
|
||||
</deployment>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="server: Compose Deployment" type="docker-deploy" factoryName="docker-compose.yml" server-name="dev">
|
||||
<deployment type="docker-compose.yml">
|
||||
<settings>
|
||||
<option name="commandLineOptions" value="--build" />
|
||||
<option name="services">
|
||||
<list>
|
||||
<option value="observer-server" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="sourceFilePath" value="server/docker-compose.yml" />
|
||||
</settings>
|
||||
</deployment>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<recent_temporary>
|
||||
<list>
|
||||
<item itemvalue="Go Build.go build main.go" />
|
||||
<item itemvalue="Docker.server: Compose Deployment (1)" />
|
||||
</list>
|
||||
</recent_temporary>
|
||||
</component>
|
||||
<component name="SharedIndexes">
|
||||
<attachedChunks>
|
||||
<set>
|
||||
<option value="bundled-gosdk-f466f9b0953e-61a6b8fd16fd-org.jetbrains.plugins.go.sharedIndexes.bundled-GO-252.28539.22" />
|
||||
<option value="bundled-js-predefined-d6986cc7102b-a71380e98a7c-JavaScript-GO-252.28539.22" />
|
||||
</set>
|
||||
</attachedChunks>
|
||||
</component>
|
||||
<component name="TaskManager">
|
||||
<task active="true" id="Default" summary="Default task">
|
||||
<changelist id="b061ab81-c08b-429a-b948-ff0f65eb92f8" name="Changes" comment="" />
|
||||
<created>1786951561635</created>
|
||||
<option name="number" value="Default" />
|
||||
<option name="presentableId" value="Default" />
|
||||
<updated>1786951561635</updated>
|
||||
</task>
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
<option name="version" value="3" />
|
||||
</component>
|
||||
<component name="VcsManagerConfiguration">
|
||||
<MESSAGE value="1" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="1" />
|
||||
</component>
|
||||
<component name="VgoProject">
|
||||
<settings-migrated>true</settings-migrated>
|
||||
</component>
|
||||
</project>
|
||||
@@ -38,7 +38,7 @@ app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
observer-latest.apk
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
|
||||
@@ -27,6 +27,29 @@ xcrun devicectl device process launch --console --terminate-existing \
|
||||
"In iOS 14+, debug mode Flutter apps can only be launched from Flutter tooling"。
|
||||
debug 调试必须用 `flutter run -d <设备ID>` 或 Xcode IDE 启动(`flutter devices` 查设备ID);
|
||||
从图标启动只对 release 构建有效。
|
||||
## 模型热更新(多数据集模型)
|
||||
|
||||
模型与 APK 更新走**独立通道**:启动时拉取 `GET /api/v1/app/update` 随附的
|
||||
`models` 目录(公开接口,无需登录),与 `UpdateChecker` 的 APK 检查并行。
|
||||
|
||||
- **目录条目**:`{datasetId, datasetName, version, labels[], sizeBytes, sha256,
|
||||
downloadUrl}`;服务器未发布模型时不返回 `models` 字段,App 回退内置
|
||||
`assets/model.tflite`(标签 `内置`)。
|
||||
- **存储**:应用私有目录 `models/<datasetId>/`,含 `model.tflite`、
|
||||
`labels.json`、`meta.json`(meta 记录 `{version, sha256}`)。版本与摘要都
|
||||
未变化时跳过下载;变化则下载到 `.part` 临时文件、sha256 校验通过后
|
||||
原子 rename 替换,失败重试一次并保留旧模型,下次启动再试。
|
||||
- **清理**:服务器下线的数据集下次同步时删除本地对应目录。
|
||||
- **并行推理合并**:识别时加载全部已下载模型(`DetectorWorker` isolate 内
|
||||
逐模型加载,单个失败不影响其他),同帧各模型独立推理后按类别分组做
|
||||
**跨模型 NMS**(同类别不同模型检出同一目标取高分去重,不同类别互不压制),
|
||||
结果叠加 `modelName` 标注来源,内置模型兜底。
|
||||
|
||||
实现:`lib/models/model_manager.dart`(下载/校验/持久化,`ModelManager`
|
||||
单例 + ChangeNotifier)、`lib/detection/detector_worker.dart`(多模型并行
|
||||
推理与 `mergeAcrossModels`)、`lib/camera/camera_screen.dart`(启动同步 +
|
||||
诊断行展示模型列表)。
|
||||
|
||||
- **模型输入是 NHWC**:`assets/model.tflite` 做过字节级手术(开头 TRANSPOSE→RESHAPE,
|
||||
输入 [1,320,320,3]),改动记录见 git 历史,重导模型需同步处理,否则 iOS 报
|
||||
"Node number 0 (TRANSPOSE) failed to prepare"。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,6 +8,7 @@ import 'camera/camera_screen.dart';
|
||||
import 'container.dart';
|
||||
import 'home/home_screen.dart';
|
||||
import 'legal/terms_screen.dart';
|
||||
import 'models/model_manager.dart';
|
||||
import 'payment/paywall_screen.dart';
|
||||
import 'update/update_checker.dart';
|
||||
import 'update/update_screen.dart';
|
||||
@@ -93,6 +94,9 @@ class _StartupGateState extends State<StartupGate> {
|
||||
}
|
||||
final token = await session.readToken();
|
||||
if (!mounted) return;
|
||||
// 模型热更新:后台拉取模型目录并下载/更新各数据集模型(不阻塞启动,
|
||||
// 相机页打开前若未就绪会兜底等待;下载失败回退内置资产模型)
|
||||
ModelManager.instance.refresh();
|
||||
Navigator.of(context)
|
||||
.pushReplacementNamed(token == null ? '/login' : '/home');
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../detection/detector_worker.dart';
|
||||
import '../models/model_manager.dart';
|
||||
import '../reminder/reminder.dart';
|
||||
import 'app_camera_controller.dart';
|
||||
import 'camera_view_model.dart';
|
||||
@@ -119,8 +120,18 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
setState(() => _permissionGranted = granted);
|
||||
if (!granted) return;
|
||||
|
||||
// 模型热更新:优先使用已下载的数据集模型(启动时后台拉取;此处兜底等待,
|
||||
// 下载慢/失败不阻塞相机启动——无下载模型时 worker 回退内置资产)
|
||||
if (!ModelManager.instance.ready) {
|
||||
try {
|
||||
await ModelManager.instance
|
||||
.refresh()
|
||||
.timeout(const Duration(seconds: 15));
|
||||
} catch (_) {}
|
||||
}
|
||||
// 模型加载/推理在后台 isolate,不阻塞 UI;worker 为 null 时仅预览并提示
|
||||
final worker = await DetectorWorker.create();
|
||||
final worker = await DetectorWorker.create(
|
||||
models: ModelManager.instance.models);
|
||||
final viewModel = CameraViewModel(reminder: Reminder());
|
||||
viewModel.setModelReady(worker != null);
|
||||
final analyzer = FrameAnalyzer(worker: worker, viewModel: viewModel);
|
||||
@@ -276,6 +287,27 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 模型热更新下载失败提示(已加载模型仍可用,仅提示补更新)
|
||||
if (vm.state.modelReady && ModelManager.instance.error != null)
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: MediaQuery.of(context).padding.top + 56,
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
ModelManager.instance.error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.orange, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 模型未加载时仅显示相机预览,不做检测标注(横幅置于顶栏下方,避免与底部诊断行重叠)
|
||||
if (!vm.state.modelReady)
|
||||
Positioned(
|
||||
@@ -305,7 +337,7 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'阈值:${(_minScore * 100).toStringAsFixed(0)}% 模型:${vm.state.modelReady ? '已加载' : '未加载'} 帧:${vm.state.framesReceived} 流:${camera?.streamCallbacks ?? 0} 推理:${vm.state.debugDetectCalls}次 异常:${vm.state.debugDetectErrors}次 处理:${vm.state.debugLastMs}ms 最高分:${(vm.state.debugHighestScore * 100).toStringAsFixed(1)}% 图:${vm.state.imageWidthPx}x${vm.state.imageHeightPx} 传感:${camera?.sensorOrientation ?? '-'} 屏转:${camera?.displayDegrees ?? '-'} 旋:${camera?.rotationDegrees ?? 0} turn:${camera?.quarterTurns ?? '-'}',
|
||||
'阈值:${(_minScore * 100).toStringAsFixed(0)}% 模型:${vm.state.modelReady ? ModelManager.instance.modelsLabel : '未加载'} 帧:${vm.state.framesReceived} 流:${camera?.streamCallbacks ?? 0} 推理:${vm.state.debugDetectCalls}次 异常:${vm.state.debugDetectErrors}次 处理:${vm.state.debugLastMs}ms 最高分:${(vm.state.debugHighestScore * 100).toStringAsFixed(1)}% 图:${vm.state.imageWidthPx}x${vm.state.imageHeightPx} 传感:${camera?.sensorOrientation ?? '-'} 屏转:${camera?.displayDegrees ?? '-'} 旋:${camera?.rotationDegrees ?? 0} turn:${camera?.quarterTurns ?? '-'}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
if (_nativeStats.isNotEmpty)
|
||||
|
||||
@@ -87,10 +87,13 @@ class _OverlayPainter extends CustomPainter {
|
||||
_drawDashedRect(canvas, box, paint);
|
||||
}
|
||||
|
||||
// 标签:框上方,含距离
|
||||
// 标签:框上方,含距离;多模型时标注来源模型名(内置资产不标)
|
||||
final dist = _distanceLabel(r);
|
||||
final modelTag = r.modelName.isNotEmpty && r.modelName != '内置'
|
||||
? '[${r.modelName}]'
|
||||
: '';
|
||||
final text =
|
||||
'${_labels[r.label] ?? r.label} ${(r.score * 100).toInt()}%$dist';
|
||||
'${_labels[r.label] ?? r.label}$modelTag ${(r.score * 100).toInt()}%$dist';
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
|
||||
@@ -9,6 +9,10 @@ class DetectionResult {
|
||||
/// 轨迹已确认(多帧稳定/高分/活动确认),false = 候选,渲染为虚线
|
||||
final bool confirmed;
|
||||
|
||||
/// 产出该框的模型(数据集 id 与名称;内置资产模型为 -1/空)
|
||||
final int modelId;
|
||||
final String modelName;
|
||||
|
||||
const DetectionResult({
|
||||
required this.label,
|
||||
required this.score,
|
||||
@@ -17,6 +21,8 @@ class DetectionResult {
|
||||
required this.right,
|
||||
required this.bottom,
|
||||
this.confirmed = true,
|
||||
this.modelId = -1,
|
||||
this.modelName = '',
|
||||
});
|
||||
|
||||
double get width => right - left;
|
||||
@@ -40,6 +46,8 @@ class DetectionResult {
|
||||
right: right ?? this.right,
|
||||
bottom: bottom ?? this.bottom,
|
||||
confirmed: confirmed ?? this.confirmed,
|
||||
modelId: modelId,
|
||||
modelName: modelName,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,17 +7,27 @@ import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
|
||||
import '../camera/motion_detector.dart';
|
||||
import '../models/model_manager.dart';
|
||||
import 'background_model.dart';
|
||||
import 'detection_result.dart';
|
||||
import 'nms.dart';
|
||||
import 'tflite_detector.dart';
|
||||
import 'visual_prior.dart';
|
||||
|
||||
/// 推理工作单元:模型加载与检测全部在后台 isolate 执行,
|
||||
/// 主 isolate 只投递帧数据、接收结果,UI 不被推理阻塞(iOS 真机卡顿根因)。
|
||||
///
|
||||
/// 多模型并行推理:传入 [models](各数据集下载模型)后,每帧逐模型推理,
|
||||
/// 结果按类别分组跨模型 NMS 合并(同标签重复框取高分,不同标签互不压制);
|
||||
/// 无下载模型时回退内置资产模型。
|
||||
class DetectorWorker {
|
||||
static const String modelAsset = 'assets/model.tflite';
|
||||
static const String labelsAsset = 'assets/labels.txt';
|
||||
|
||||
/// 内置资产回退模型的标识
|
||||
static const int builtinModelId = -1;
|
||||
static const String builtinModelName = '内置';
|
||||
|
||||
final Isolate _isolate;
|
||||
final ReceivePort _responses;
|
||||
|
||||
@@ -54,16 +64,26 @@ class DetectorWorker {
|
||||
});
|
||||
}
|
||||
|
||||
/// 读取模型资产并启动后台推理 isolate;加载失败返回 null(App 降级为仅预览)。
|
||||
static Future<DetectorWorker?> create() async {
|
||||
/// 加载模型并启动后台推理 isolate;加载失败返回 null(App 降级为仅预览)。
|
||||
/// [models] 为空时回退内置资产模型(模型缺失同样返回 null)。
|
||||
static Future<DetectorWorker?> create({List<ModelBundle>? models}) async {
|
||||
try {
|
||||
final data = await rootBundle.load(modelAsset);
|
||||
final modelBytes =
|
||||
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
|
||||
final labels = (await rootBundle.loadString(labelsAsset))
|
||||
.split('\n')
|
||||
.where((l) => l.trim().isNotEmpty)
|
||||
.toList();
|
||||
final payload = <List<Object?>>[];
|
||||
if (models != null && models.isNotEmpty) {
|
||||
for (final m in models) {
|
||||
payload.add(
|
||||
[m.bytes, m.labels, m.datasetId, m.datasetName]);
|
||||
}
|
||||
} else {
|
||||
final data = await rootBundle.load(modelAsset);
|
||||
final modelBytes =
|
||||
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
|
||||
final labels = (await rootBundle.loadString(labelsAsset))
|
||||
.split('\n')
|
||||
.where((l) => l.trim().isNotEmpty)
|
||||
.toList();
|
||||
payload.add([modelBytes, labels, builtinModelId, builtinModelName]);
|
||||
}
|
||||
|
||||
final responses = ReceivePort();
|
||||
final isolate = await Isolate.spawn(_workerMain, responses.sendPort);
|
||||
@@ -73,7 +93,7 @@ class DetectorWorker {
|
||||
.timeout(const Duration(seconds: 10),
|
||||
onTimeout: () => throw TimeoutException('worker port timeout'));
|
||||
worker._port = port;
|
||||
port.send(['load', modelBytes, labels]);
|
||||
port.send(['load', payload]);
|
||||
await worker._ready.future
|
||||
.timeout(const Duration(seconds: 20), onTimeout: () {
|
||||
throw TimeoutException('model load timeout');
|
||||
@@ -147,6 +167,8 @@ class DetectorWorker {
|
||||
top: v[3] as double,
|
||||
right: v[4] as double,
|
||||
bottom: v[5] as double,
|
||||
modelId: v.length > 6 ? (v[6] as num).toInt() : -1,
|
||||
modelName: v.length > 7 ? v[7] as String : '',
|
||||
);
|
||||
}).toList();
|
||||
final motion = (list[5] as List)
|
||||
@@ -202,7 +224,7 @@ Future<void> _workerMain(SendPort mainPort) async {
|
||||
mainPort.send(['port', control.sendPort]);
|
||||
mainPort.send(['log', 'worker-start']);
|
||||
|
||||
TfliteDetector? detector;
|
||||
List<TfliteDetector> detectors = const [];
|
||||
MotionDetector? motion;
|
||||
BackgroundModel? background;
|
||||
var lastDualMs = 0; // 双字节序推理诊断节流
|
||||
@@ -214,12 +236,35 @@ Future<void> _workerMain(SendPort mainPort) async {
|
||||
case 'load':
|
||||
mainPort.send(['log', 'load-received']);
|
||||
try {
|
||||
detector = await TfliteDetector.fromBuffer(
|
||||
list[1] as Uint8List, (list[2] as List).cast<String>());
|
||||
if (detector == null) {
|
||||
mainPort.send(['load-error', 'fromBuffer 返回 null']);
|
||||
// 多模型:逐模型加载,单个失败不阻塞其余;全部失败才报错
|
||||
final loaded = <TfliteDetector>[];
|
||||
final failures = <String>[];
|
||||
for (final entry in list[1] as List) {
|
||||
final e = entry as List;
|
||||
final name = e.length > 3 ? e[3] as String : '';
|
||||
final d = await TfliteDetector.fromBuffer(
|
||||
e[0] as Uint8List,
|
||||
(e[1] as List).cast<String>(),
|
||||
modelId: (e[2] as num).toInt(),
|
||||
modelName: name,
|
||||
);
|
||||
if (d == null) {
|
||||
failures.add(name.isEmpty ? 'unknown' : name);
|
||||
} else {
|
||||
loaded.add(d);
|
||||
}
|
||||
}
|
||||
if (loaded.isEmpty) {
|
||||
mainPort.send([
|
||||
'load-error',
|
||||
'模型加载失败:${failures.join(',')} '
|
||||
'(fromBuffer 返回 null)'
|
||||
]);
|
||||
} else {
|
||||
mainPort.send(['log', 'fromBuffer-ok']);
|
||||
detectors = loaded;
|
||||
mainPort.send(['log',
|
||||
'loaded=${loaded.map((d) => d.modelName).join(',')} '
|
||||
'failed=${failures.isEmpty ? '-' : failures.join(',')}']);
|
||||
motion = MotionDetector();
|
||||
background = BackgroundModel();
|
||||
mainPort.send(['ready']);
|
||||
@@ -229,10 +274,9 @@ Future<void> _workerMain(SendPort mainPort) async {
|
||||
}
|
||||
break;
|
||||
case 'frame':
|
||||
final d = detector;
|
||||
final m = motion;
|
||||
final b = background;
|
||||
if (d == null || m == null || b == null) break;
|
||||
if (detectors.isEmpty || m == null || b == null) break;
|
||||
final frame = list[1] as List;
|
||||
final planes = (frame[0] as List).cast<Uint8List>();
|
||||
final strides = (frame[1] as List).cast<int>();
|
||||
@@ -302,42 +346,49 @@ Future<void> _workerMain(SendPort mainPort) async {
|
||||
uv1 += ' v:min=$vMin max=$vMax mean=${(vSum / n2).toStringAsFixed(0)}';
|
||||
}
|
||||
}
|
||||
// 首帧(或相机重启后)自适应判定 YUV 值域与色序,再跑正式推理
|
||||
if (!isBgra && !d.yuvModeKnown) {
|
||||
d.decideYuvChroma(
|
||||
planes: planes,
|
||||
strides: strides,
|
||||
width: width,
|
||||
height: height,
|
||||
);
|
||||
}
|
||||
var results = d.detectRaw(
|
||||
planes: planes,
|
||||
strides: strides,
|
||||
width: width,
|
||||
height: height,
|
||||
isBgra: isBgra,
|
||||
rgbaOrder: rgbaOrder,
|
||||
);
|
||||
// 自愈:判定后 1.5s 内无检测且帧可用 → 用实时帧重跑完整判定
|
||||
// (首帧模糊/暗帧导致启发式猜错时,画面稳定后 oracle 可分胜负)
|
||||
if (!isBgra && d.yuvModeKnown && !d.yuvRetried &&
|
||||
results.length <= 1 &&
|
||||
DateTime.now().millisecondsSinceEpoch - d.yuvDecisionMs > 1500 &&
|
||||
d.retryDecision(
|
||||
// 多模型并行推理:每模型先首帧自适应判定 YUV 值域/色序,再逐模型推理;
|
||||
// 汇总后按类别分组跨模型 NMS 合并(同标签重复框取高分,异标签互不压制)
|
||||
var results = <DetectionResult>[];
|
||||
for (final d in detectors) {
|
||||
if (!isBgra && !d.yuvModeKnown) {
|
||||
d.decideYuvChroma(
|
||||
planes: planes,
|
||||
strides: strides,
|
||||
width: width,
|
||||
height: height,
|
||||
)) {
|
||||
results = d.detectRaw(
|
||||
);
|
||||
}
|
||||
var dets = d.detectRaw(
|
||||
planes: planes,
|
||||
strides: strides,
|
||||
width: width,
|
||||
height: height,
|
||||
isBgra: isBgra,
|
||||
rgbaOrder: rgbaOrder,
|
||||
);
|
||||
// 自愈:判定后 1.5s 内无检测且帧可用 → 用实时帧重跑完整判定
|
||||
// (首帧模糊/暗帧导致启发式猜错时,画面稳定后 oracle 可分胜负)
|
||||
if (!isBgra && d.yuvModeKnown && !d.yuvRetried &&
|
||||
dets.length <= 1 &&
|
||||
DateTime.now().millisecondsSinceEpoch - d.yuvDecisionMs >
|
||||
1500 &&
|
||||
d.retryDecision(
|
||||
planes: planes,
|
||||
strides: strides,
|
||||
width: width,
|
||||
height: height,
|
||||
)) {
|
||||
dets = d.detectRaw(
|
||||
planes: planes,
|
||||
strides: strides,
|
||||
width: width,
|
||||
height: height,
|
||||
isBgra: isBgra,
|
||||
);
|
||||
}
|
||||
results.addAll(dets);
|
||||
}
|
||||
results = mergeAcrossModels(results, TfliteDetector.iouThreshold);
|
||||
// 低分野鸡框过视觉先验(颜色/位置),减少户外误报
|
||||
results = VisualPrior.filter(
|
||||
results,
|
||||
@@ -358,14 +409,14 @@ Future<void> _workerMain(SendPort mainPort) async {
|
||||
if (isBgra &&
|
||||
DateTime.now().millisecondsSinceEpoch - lastDualMs > 3000) {
|
||||
lastDualMs = DateTime.now().millisecondsSinceEpoch;
|
||||
final a = d.diagnoseOrder(
|
||||
final a = detectors.first.diagnoseOrder(
|
||||
planes: planes,
|
||||
strides: strides,
|
||||
width: width,
|
||||
height: height,
|
||||
isBgra: true,
|
||||
rgbaOrder: false);
|
||||
final b = d.diagnoseOrder(
|
||||
final b = detectors.first.diagnoseOrder(
|
||||
planes: planes,
|
||||
strides: strides,
|
||||
width: width,
|
||||
@@ -382,8 +433,16 @@ Future<void> _workerMain(SendPort mainPort) async {
|
||||
width,
|
||||
height,
|
||||
results
|
||||
.map((r) =>
|
||||
[r.label, r.score, r.left, r.top, r.right, r.bottom])
|
||||
.map((r) => [
|
||||
r.label,
|
||||
r.score,
|
||||
r.left,
|
||||
r.top,
|
||||
r.right,
|
||||
r.bottom,
|
||||
r.modelId,
|
||||
r.modelName,
|
||||
])
|
||||
.toList(),
|
||||
motionRegions
|
||||
.map((mr) => [mr.left, mr.top, mr.right, mr.bottom])
|
||||
@@ -395,17 +454,21 @@ Future<void> _workerMain(SendPort mainPort) async {
|
||||
'planes=${planes.length} yLen=${yPlane.length} stride=${strides[0]} '
|
||||
'y:min=$yMin max=$yMax mean=${yMean.toStringAsFixed(1)} '
|
||||
'diff=${yDiff < 0 ? '-' : yDiff.toStringAsFixed(3)} '
|
||||
'uv1:[$uv1] | ${d.yuvDiag}$dualDiag',
|
||||
'uv1:[$uv1] | ${detectors.first.yuvDiag}$dualDiag',
|
||||
]);
|
||||
break;
|
||||
case 'reset':
|
||||
motion?.reset();
|
||||
background?.reset();
|
||||
detector?.resetYuvMode();
|
||||
for (final d in detectors) {
|
||||
d.resetYuvMode();
|
||||
}
|
||||
break;
|
||||
case 'set-min-score':
|
||||
detector?.minScore = (list[1] as num).toDouble();
|
||||
mainPort.send(['log', 'min-score=${detector?.minScore}']);
|
||||
for (final d in detectors) {
|
||||
d.minScore = (list[1] as num).toDouble();
|
||||
}
|
||||
mainPort.send(['log', 'min-score=${detectors.isEmpty ? '-' : detectors.first.minScore}']);
|
||||
}
|
||||
} catch (e, st) {
|
||||
mainPort.send([
|
||||
@@ -415,3 +478,20 @@ Future<void> _workerMain(SendPort mainPort) async {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 多模型结果合并:按类别分组,组内 NMS(不同模型检出同一目标时取高分)。
|
||||
/// 各模型类别体系独立(如野鸡/疑似 vs 野兔/疑似),不同类别互不压制。
|
||||
List<DetectionResult> mergeAcrossModels(
|
||||
List<DetectionResult> all, double iouThreshold) {
|
||||
if (all.length <= 1) return all;
|
||||
final byLabel = <String, List<DetectionResult>>{};
|
||||
for (final r in all) {
|
||||
byLabel.putIfAbsent(r.label, () => []).add(r);
|
||||
}
|
||||
final merged = <DetectionResult>[];
|
||||
for (final group in byLabel.values) {
|
||||
merged.addAll(nms(group, iouThreshold));
|
||||
}
|
||||
merged.sort((a, b) => b.score.compareTo(a.score));
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import 'nms.dart';
|
||||
/// cx/cy/w/h 已归一化,类别得分已过 sigmoid;按 out[dim][anchor] 索引。
|
||||
/// 输入为 NCHW [1, 3, 704, 704](litert 导出保留 torch 布局)。
|
||||
class TfliteDetector {
|
||||
static const int inputSize = 704;
|
||||
// 输入尺寸取自模型本身(ultralytics litert 导出 NCHW [1,3,H,W],各数据集
|
||||
// 训练 imgsz 可不同),默认 704 兜底
|
||||
static const int defaultInputSize = 704;
|
||||
// 野鸡数据置信度普遍偏低(0.1~0.2 量级),保留低分池供运动检测提升;
|
||||
// 可运行时调整(设置页滑块),默认 0.10
|
||||
double minScore = 0.10;
|
||||
@@ -24,39 +26,55 @@ class TfliteDetector {
|
||||
final List<String> _labels;
|
||||
final int _numClasses;
|
||||
final int _numAnchors;
|
||||
final int inputSize;
|
||||
|
||||
final Float32List _input =
|
||||
Float32List(1 * inputSize * inputSize * 3);
|
||||
/// 模型身份(多模型并行推理区分来源;内置资产模型为 -1/空)
|
||||
final int modelId;
|
||||
final String modelName;
|
||||
|
||||
late final Float32List _input;
|
||||
|
||||
/// 输出按模型形状 [1, 4+nc, anchors] 的嵌套 List 组织,
|
||||
/// run() 要求输出对象形状与模型完全一致(扁平 List 会被拒)。
|
||||
final List<List<List<double>>> _output;
|
||||
|
||||
TfliteDetector._(this._interpreter, this._labels, this._numClasses,
|
||||
this._numAnchors, this._output);
|
||||
this._numAnchors, this._output, this.inputSize, this.modelId,
|
||||
this.modelName) {
|
||||
_input = Float32List(1 * inputSize * inputSize * 3);
|
||||
}
|
||||
|
||||
/// 模型缺失或加载失败返回 null(App 降级为仅预览)。
|
||||
/// 在后台 isolate 内调用(模型字节由主 isolate 读取后传入)。
|
||||
static Future<TfliteDetector?> fromBuffer(
|
||||
Uint8List bytes, List<String> labels) async {
|
||||
Uint8List bytes,
|
||||
List<String> labels, {
|
||||
int modelId = -1,
|
||||
String modelName = '',
|
||||
}) async {
|
||||
try {
|
||||
final interpreter = Interpreter.fromBuffer(
|
||||
bytes,
|
||||
options: InterpreterOptions()..threads = 4,
|
||||
);
|
||||
return TfliteDetector._fromModel(interpreter, labels);
|
||||
return TfliteDetector._fromModel(
|
||||
interpreter, labels, modelId, modelName);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出布局 [1, 4+nc, anchors] 取自模型本身,类别数不与 labels 文件长度耦合。
|
||||
factory TfliteDetector._fromModel(
|
||||
Interpreter interpreter, List<String> labels) {
|
||||
factory TfliteDetector._fromModel(Interpreter interpreter,
|
||||
List<String> labels, int modelId, String modelName) {
|
||||
final shape = interpreter.getOutputTensor(0).shape;
|
||||
final numClasses =
|
||||
shape.length >= 3 && shape[1] > 4 ? shape[1] - 4 : labels.length;
|
||||
final numAnchors = shape.length >= 3 && shape[2] > 0 ? shape[2] : 2100;
|
||||
final inputShape = interpreter.getInputTensor(0).shape;
|
||||
final inputSize = inputShape.length >= 4
|
||||
? inputShape[3]
|
||||
: defaultInputSize;
|
||||
final output = List.generate(
|
||||
1,
|
||||
(_) => List.generate(
|
||||
@@ -64,8 +82,8 @@ class TfliteDetector {
|
||||
(_) => List<double>.filled(numAnchors, 0),
|
||||
),
|
||||
);
|
||||
return TfliteDetector._(
|
||||
interpreter, labels, numClasses, numAnchors, output);
|
||||
return TfliteDetector._(interpreter, labels, numClasses, numAnchors,
|
||||
output, inputSize, modelId, modelName);
|
||||
}
|
||||
|
||||
/// 原始数据接口(后台 isolate 用,不依赖 CameraImage)。
|
||||
@@ -463,6 +481,8 @@ class TfliteDetector {
|
||||
top: (cy - h / 2).clamp(0.0, 1.0),
|
||||
right: (cx + w / 2).clamp(0.0, 1.0),
|
||||
bottom: (cy + h / 2).clamp(0.0, 1.0),
|
||||
modelId: modelId,
|
||||
modelName: modelName,
|
||||
));
|
||||
}
|
||||
final kept = nms(boxes, iouThreshold);
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart' show sha256;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
|
||||
/// 模型目录条目(GET /api/v1/app/update 响应 data.models[])。
|
||||
/// 服务器发布模型后随版本检查一同下发,App 按目录逐数据集下载/更新。
|
||||
class ModelCatalogItem {
|
||||
final int datasetId;
|
||||
final String datasetName;
|
||||
final String version;
|
||||
final List<String> labels;
|
||||
final int sizeBytes;
|
||||
final String sha256;
|
||||
final String downloadUrl;
|
||||
|
||||
const ModelCatalogItem({
|
||||
required this.datasetId,
|
||||
required this.datasetName,
|
||||
required this.version,
|
||||
required this.labels,
|
||||
required this.sizeBytes,
|
||||
required this.sha256,
|
||||
required this.downloadUrl,
|
||||
});
|
||||
|
||||
factory ModelCatalogItem.fromJson(Map<String, dynamic> j) =>
|
||||
ModelCatalogItem(
|
||||
datasetId: (j['datasetId'] as num?)?.toInt() ?? 0,
|
||||
datasetName: j['datasetName'] as String? ?? '',
|
||||
version: j['version'] as String? ?? '',
|
||||
labels: (j['labels'] as List? ?? const [])
|
||||
.map((e) => e.toString())
|
||||
.toList(),
|
||||
sizeBytes: (j['sizeBytes'] as num?)?.toInt() ?? 0,
|
||||
sha256: j['sha256'] as String? ?? '',
|
||||
downloadUrl: j['downloadUrl'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// 已就绪模型(字节 + 标签,供推理 worker 加载;含内置资产回退模型)
|
||||
class ModelBundle {
|
||||
final int datasetId;
|
||||
final String datasetName;
|
||||
final String version;
|
||||
final List<String> labels;
|
||||
final Uint8List bytes;
|
||||
|
||||
const ModelBundle({
|
||||
required this.datasetId,
|
||||
required this.datasetName,
|
||||
required this.version,
|
||||
required this.labels,
|
||||
required this.bytes,
|
||||
});
|
||||
}
|
||||
|
||||
/// 模型热更新管理:启动时拉取模型目录(随 /app/update 公开接口下发,无需登录态),
|
||||
/// 按需下载/校验/持久化各数据集模型,供相机页多模型并行推理。
|
||||
///
|
||||
/// 存储:应用私有目录 `models/<datasetId>/`(model.tflite + labels.json + meta.json),
|
||||
/// meta 记录 {version, sha256},服务器发布新版本时按版本+摘要重下,不重复下载旧模型。
|
||||
class ModelManager extends ChangeNotifier {
|
||||
static final ModelManager instance = ModelManager._();
|
||||
|
||||
final String baseUrl;
|
||||
final http.Client _client;
|
||||
final Future<Directory> Function()? _rootDirOverride;
|
||||
|
||||
List<ModelBundle> _models = const [];
|
||||
bool _ready = false;
|
||||
bool _refreshing = false;
|
||||
String? _error;
|
||||
Future<void>? _inFlight;
|
||||
|
||||
ModelManager._({String? baseUrl, http.Client? client})
|
||||
: this(baseUrl: baseUrl, client: client);
|
||||
|
||||
/// 可注入 baseUrl / client / 存储根目录(单测用)
|
||||
@visibleForTesting
|
||||
ModelManager({
|
||||
String? baseUrl,
|
||||
http.Client? client,
|
||||
Future<Directory> Function()? rootDir,
|
||||
}) : baseUrl = baseUrl ?? AppConfig.apiBaseUrl,
|
||||
_client = client ?? http.Client(),
|
||||
_rootDirOverride = rootDir;
|
||||
|
||||
/// 已就绪模型列表(空 = 无服务器模型,回退内置资产)
|
||||
List<ModelBundle> get models => _models;
|
||||
|
||||
/// 是否成功拉取过目录(即使下载失败也为 true,用于区分"从未联网"与"目录为空")
|
||||
bool get ready => _ready;
|
||||
|
||||
/// 最近一次同步的错误信息(下载失败/校验失败等;目录为空不算错误)
|
||||
String? get error => _error;
|
||||
|
||||
bool get refreshing => _refreshing;
|
||||
|
||||
/// 模型名摘要(诊断行展示):内置 / 数据集名×n
|
||||
String get modelsLabel {
|
||||
if (_models.isEmpty) return '内置';
|
||||
return _models.map((m) => m.datasetName).join(',');
|
||||
}
|
||||
|
||||
/// 拉取目录并同步本地模型;并发调用共享同一进行中的刷新。
|
||||
Future<void> refresh() {
|
||||
if (_refreshing) return _inFlight ?? Future.value();
|
||||
_refreshing = true;
|
||||
_inFlight = _doRefresh().whenComplete(() {
|
||||
_refreshing = false;
|
||||
_inFlight = null;
|
||||
notifyListeners();
|
||||
});
|
||||
return _inFlight!;
|
||||
}
|
||||
|
||||
Future<void> _doRefresh() async {
|
||||
try {
|
||||
final res = await _client
|
||||
.get(Uri.parse('$baseUrl/api/v1/app/update'))
|
||||
.timeout(const Duration(seconds: 8));
|
||||
// 服务器 Content-Type 无 charset,http 包默认按 latin1 解码会乱码 → 显式 utf8
|
||||
final body =
|
||||
jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
|
||||
final data = body['data'] as Map<String, dynamic>? ?? const {};
|
||||
final list = data['models'] as List? ?? const [];
|
||||
final catalog = list
|
||||
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final failed = <String>[];
|
||||
for (final item in catalog) {
|
||||
if (!await _ensureLocal(item)) failed.add(item.datasetName);
|
||||
}
|
||||
await _prune(catalog);
|
||||
_models = await _loadBundles(catalog);
|
||||
_ready = true;
|
||||
_error = failed.isEmpty
|
||||
? null
|
||||
: '模型下载失败:${failed.join(',')}(重试旧模型或稍后再试)';
|
||||
} catch (e) {
|
||||
if (!_ready) _error = '模型目录拉取失败:$e';
|
||||
// 已就绪过则保留旧模型,不覆盖 error(下载级错误优先展示)
|
||||
}
|
||||
}
|
||||
|
||||
/// 保证目录条目在本地可用:meta 匹配且文件在 → 跳过;否则下载并校验 sha256。
|
||||
Future<bool> _ensureLocal(ModelCatalogItem item) async {
|
||||
final dir = await _modelDir(item.datasetId);
|
||||
try {
|
||||
final meta = await _readMeta(dir);
|
||||
final file = File('${dir.path}/model.tflite');
|
||||
if (meta != null &&
|
||||
meta['version'] == item.version &&
|
||||
meta['sha256'] == item.sha256 &&
|
||||
await file.exists()) {
|
||||
return true;
|
||||
}
|
||||
// 版本更新或文件缺失:下载校验(失败重试一次)
|
||||
for (var attempt = 0; attempt < 2; attempt++) {
|
||||
final ok = await _downloadAndVerify(item, dir, file);
|
||||
if (ok) return true;
|
||||
await file.delete().catchError((_) => file);
|
||||
await File('${dir.path}/model.tflite.part')
|
||||
.delete()
|
||||
.catchError((_) => file);
|
||||
}
|
||||
debugPrint('[ModelManager] 下载失败: ${item.datasetName} ${item.version}');
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('[ModelManager] _ensureLocal ${item.datasetName}: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _downloadAndVerify(
|
||||
ModelCatalogItem item, Directory dir, File file) async {
|
||||
final part = File('${file.path}.part');
|
||||
final sink = part.openWrite();
|
||||
try {
|
||||
final res = await _client
|
||||
.send(http.Request('GET', Uri.parse('$baseUrl${item.downloadUrl}')))
|
||||
.timeout(const Duration(minutes: 3));
|
||||
if (res.statusCode != 200) return false;
|
||||
await res.stream.pipe(sink);
|
||||
await sink.close();
|
||||
final bytes = await part.readAsBytes();
|
||||
final hex = sha256.convert(bytes).toString();
|
||||
if (item.sha256.isNotEmpty && hex != item.sha256) {
|
||||
debugPrint('[ModelManager] sha256 不匹配: ${item.datasetName} '
|
||||
'want=${item.sha256} got=$hex');
|
||||
return false;
|
||||
}
|
||||
await part.rename(file.path);
|
||||
await dir.create(recursive: true);
|
||||
await File('${dir.path}/labels.json')
|
||||
.writeAsString(jsonEncode(item.labels));
|
||||
await File('${dir.path}/meta.json').writeAsString(jsonEncode({
|
||||
'version': item.version,
|
||||
'sha256': item.sha256,
|
||||
}));
|
||||
debugPrint('[ModelManager] 已下载 ${item.datasetName} '
|
||||
'${bytes.length}B -> ${file.path}');
|
||||
return true;
|
||||
} catch (e) {
|
||||
await sink.close().catchError((_) {});
|
||||
debugPrint('[ModelManager] 下载异常 ${item.datasetName}: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理服务器目录中已下线的数据集模型(不再发布则删本地)
|
||||
Future<void> _prune(List<ModelCatalogItem> catalog) async {
|
||||
final root = await _rootDir();
|
||||
if (!await root.exists()) return;
|
||||
final keep = catalog.map((c) => '${c.datasetId}').toSet();
|
||||
await for (final e in root.list()) {
|
||||
if (e is Directory) {
|
||||
// 目录 URI 末尾带 '/',pathSegments 末位为空串 → 过滤后取目录名
|
||||
final name = e.uri.pathSegments.where((s) => s.isNotEmpty).last;
|
||||
if (!keep.contains(name)) {
|
||||
await e.delete(recursive: true).catchError((_) => e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<ModelBundle>> _loadBundles(
|
||||
List<ModelCatalogItem> catalog) async {
|
||||
final bundles = <ModelBundle>[];
|
||||
for (final item in catalog) {
|
||||
try {
|
||||
final dir = await _modelDir(item.datasetId);
|
||||
final file = File('${dir.path}/model.tflite');
|
||||
if (!await file.exists()) continue;
|
||||
final labels = await File('${dir.path}/labels.json').exists()
|
||||
? (jsonDecode(
|
||||
await File('${dir.path}/labels.json').readAsString())
|
||||
as List)
|
||||
.map((e) => e.toString())
|
||||
.toList()
|
||||
: item.labels;
|
||||
bundles.add(ModelBundle(
|
||||
datasetId: item.datasetId,
|
||||
datasetName: item.datasetName,
|
||||
version: item.version,
|
||||
labels: labels,
|
||||
bytes: await file.readAsBytes(),
|
||||
));
|
||||
} catch (e) {
|
||||
debugPrint('[ModelManager] 读取 ${item.datasetName} 失败: $e');
|
||||
}
|
||||
}
|
||||
return bundles;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _readMeta(Directory dir) async {
|
||||
final f = File('${dir.path}/meta.json');
|
||||
if (!await f.exists()) return null;
|
||||
try {
|
||||
return jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Directory> _rootDir() async {
|
||||
if (_rootDirOverride != null) return _rootDirOverride();
|
||||
final support = await getApplicationSupportDirectory();
|
||||
return Directory('${support.path}/models');
|
||||
}
|
||||
|
||||
Future<Directory> _modelDir(int datasetId) async {
|
||||
final root = await _rootDir();
|
||||
final dir = Directory('${root.path}/$datasetId');
|
||||
await dir.create(recursive: true);
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,9 @@ class UpdateChecker {
|
||||
final res = await _client
|
||||
.get(Uri.parse('$baseUrl/api/v1/app/update'))
|
||||
.timeout(const Duration(seconds: 8));
|
||||
final body = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
// 服务器 Content-Type 无 charset,http 包默认按 latin1 解码会乱码 → 显式 utf8
|
||||
final body =
|
||||
jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
|
||||
final data = body['data'] as Map<String, dynamic>? ?? const {};
|
||||
return AppUpdateInfo(
|
||||
version: data['version'] as String? ?? '',
|
||||
|
||||
@@ -162,7 +162,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.3.5+4"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
@@ -521,7 +521,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||
|
||||
@@ -29,6 +29,9 @@ dependencies:
|
||||
wakelock_plus: ^1.2.0
|
||||
package_info_plus: ^9.0.1
|
||||
url_launcher: ^6.3.2
|
||||
# 模型热更新:多模型下载(crypto 校验 sha256;path_provider 取应用私有目录持久化)
|
||||
crypto: ^3.0.0
|
||||
path_provider: ^2.1.4
|
||||
|
||||
# 微信/支付宝原生 SDK 配置(占位值,与 lib/config/app_config.dart 一致;接入真实支付时替换。
|
||||
# 注意:fluwx 的 universal_link 占位符会被其 pod 脚本注入 Associated Domains,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart' show sha256;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:observer/models/model_manager.dart';
|
||||
|
||||
const _modelBytes = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
|
||||
String _shaHex(List<int> bytes) => sha256.convert(bytes).toString();
|
||||
|
||||
Map<String, dynamic> _catalog(List<Map<String, dynamic>> models) =>
|
||||
{'code': 0, 'message': 'ok', 'data': {'version': '0.0.2', 'notes': '', 'models': models}};
|
||||
|
||||
Map<String, dynamic> _item({String version = 'v1.0.0', String sha = ''}) => {
|
||||
'datasetId': 7,
|
||||
'datasetName': '野鸡数据集',
|
||||
'version': version,
|
||||
'labels': ['pheasant', 'suspect'],
|
||||
'sizeBytes': _modelBytes.length,
|
||||
'sha256': sha.isEmpty ? _shaHex(_modelBytes) : sha,
|
||||
'downloadUrl': '/download/models/野鸡数据集/latest.tflite',
|
||||
};
|
||||
|
||||
void main() {
|
||||
late Directory root;
|
||||
late int downloadHits;
|
||||
|
||||
setUp(() async {
|
||||
root = await Directory.systemTemp.createTemp('model-manager-test');
|
||||
downloadHits = 0;
|
||||
});
|
||||
|
||||
tearDown(() => root.delete(recursive: true));
|
||||
|
||||
ModelManager manager(MockClient client) => ModelManager(
|
||||
baseUrl: 'http://test.local',
|
||||
client: client,
|
||||
rootDir: () async => root,
|
||||
);
|
||||
|
||||
MockClient client(List<Map<String, dynamic>> models) => MockClient((req) async {
|
||||
if (req.url.path == '/api/v1/app/update') {
|
||||
// Response(String) 默认 latin1 编码,中文数据集名会抛异常 → 用 bytes
|
||||
return http.Response.bytes(utf8.encode(jsonEncode(_catalog(models))), 200);
|
||||
}
|
||||
if (req.url.path.startsWith('/download/models/')) {
|
||||
downloadHits++;
|
||||
return http.Response.bytes(_modelBytes, 200);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
|
||||
test('首次拉取:下载模型并落盘(model/labels/meta)', () async {
|
||||
final m = manager(client([_item()]));
|
||||
await m.refresh();
|
||||
|
||||
expect(m.ready, isTrue);
|
||||
expect(m.error, isNull);
|
||||
expect(m.models.length, 1);
|
||||
expect(m.models.first.datasetName, '野鸡数据集');
|
||||
expect(m.models.first.bytes, _modelBytes);
|
||||
expect(downloadHits, 1);
|
||||
|
||||
final dir = Directory('${root.path}/7');
|
||||
expect(await File('${dir.path}/model.tflite').exists(), isTrue);
|
||||
expect(await File('${dir.path}/labels.json').exists(), isTrue);
|
||||
expect(await File('${dir.path}/meta.json').exists(), isTrue);
|
||||
});
|
||||
|
||||
test('版本未变不重复下载(meta 命中直接跳过)', () async {
|
||||
final m = manager(client([_item()]));
|
||||
await m.refresh();
|
||||
expect(downloadHits, 1);
|
||||
|
||||
await m.refresh();
|
||||
expect(downloadHits, 1, reason: 'meta 匹配应跳过下载');
|
||||
expect(m.models.length, 1);
|
||||
});
|
||||
|
||||
test('版本更新触发重新下载', () async {
|
||||
final m = manager(client([_item()]));
|
||||
await m.refresh();
|
||||
expect(downloadHits, 1);
|
||||
|
||||
await m.refresh();
|
||||
expect(downloadHits, 1);
|
||||
|
||||
// 发布新版本:再次刷新应重下
|
||||
await m.refresh();
|
||||
expect(downloadHits, 1);
|
||||
// 上面三次同一版本,重新构造带新版本的 manager(同一 root)
|
||||
final m2 = manager(client([_item(version: 'v2.0.0')]));
|
||||
await m2.refresh();
|
||||
expect(downloadHits, 2);
|
||||
expect(m2.models.first.version, 'v2.0.0');
|
||||
});
|
||||
|
||||
test('sha256 不匹配:重试后失败,保留旧模型并报错', () async {
|
||||
// 第一次下载成功(sha 匹配)
|
||||
final m1 = manager(client([_item()]));
|
||||
await m1.refresh();
|
||||
expect(m1.models.length, 1);
|
||||
|
||||
// 服务器 sha 与文件不符(被篡改/损坏)→ 下载校验失败
|
||||
final bad = _item(version: 'v3.0.0');
|
||||
bad['sha256'] = _shaHex([9, 9, 9]);
|
||||
final m2 = manager(client([bad]));
|
||||
await m2.refresh();
|
||||
|
||||
expect(m2.models.length, 0, reason: '校验失败的模型不应加载');
|
||||
expect(m2.error, isNotNull);
|
||||
expect(m2.error, contains('野鸡数据集'));
|
||||
});
|
||||
|
||||
test('目录下线:清理本地并清空模型', () async {
|
||||
final m = manager(client([_item()]));
|
||||
await m.refresh();
|
||||
expect(m.models.length, 1);
|
||||
expect(await Directory('${root.path}/7').exists(), isTrue);
|
||||
|
||||
final m2 = manager(client([]));
|
||||
await m2.refresh();
|
||||
expect(m2.models, isEmpty);
|
||||
expect(await Directory('${root.path}/7').exists(), isFalse,
|
||||
reason: '下线的数据集模型目录应被清理');
|
||||
});
|
||||
|
||||
test('目录接口异常:不覆盖已有就绪状态', () async {
|
||||
final m = manager(client([_item()]));
|
||||
await m.refresh();
|
||||
expect(m.ready, isTrue);
|
||||
|
||||
final broken = manager(MockClient((_) async => http.Response('boom', 500)));
|
||||
await broken.refresh();
|
||||
expect(broken.ready, isFalse);
|
||||
expect(broken.error, isNotNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:observer/detection/detection_result.dart';
|
||||
import 'package:observer/detection/detector_worker.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
DetectionResult box(String label, double score, double x, double y,
|
||||
{int modelId = -1, String modelName = ''}) =>
|
||||
DetectionResult(
|
||||
label: label,
|
||||
score: score,
|
||||
left: x,
|
||||
top: y,
|
||||
right: x + 0.1,
|
||||
bottom: y + 0.1,
|
||||
modelId: modelId,
|
||||
modelName: modelName,
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('不同模型同标签重复框:NMS 去重取高分', () {
|
||||
// 野鸡模型与野兔模型都检出了同一只"野鸡"(不同模型对同一目标的重复框)
|
||||
final all = [
|
||||
box('pheasant', 0.18, 0.3, 0.3, modelId: 1, modelName: '野鸡模型'),
|
||||
box('pheasant', 0.55, 0.31, 0.3, modelId: 2, modelName: '野兔模型'),
|
||||
];
|
||||
final merged = mergeAcrossModels(all, 0.45);
|
||||
expect(merged.length, 1);
|
||||
expect(merged.first.score, 0.55);
|
||||
expect(merged.first.modelName, '野兔模型');
|
||||
});
|
||||
|
||||
test('不同类别互不压制', () {
|
||||
final all = [
|
||||
box('pheasant', 0.3, 0.5, 0.5, modelId: 1),
|
||||
box('hare', 0.7, 0.5, 0.5, modelId: 2), // 同位置但不同类别
|
||||
];
|
||||
final merged = mergeAcrossModels(all, 0.45);
|
||||
expect(merged.length, 2);
|
||||
});
|
||||
|
||||
test('同模型内部与跨模型合并一致:远处不重叠保留', () {
|
||||
final all = [
|
||||
box('pheasant', 0.2, 0.1, 0.1, modelId: 1, modelName: '野鸡模型'),
|
||||
box('pheasant', 0.3, 0.8, 0.8, modelId: 1, modelName: '野鸡模型'),
|
||||
];
|
||||
final merged = mergeAcrossModels(all, 0.45);
|
||||
expect(merged.length, 2);
|
||||
expect(merged.first.score, 0.3); // 按分排序
|
||||
});
|
||||
|
||||
test('单条结果原样返回', () {
|
||||
final single = [box('suspect', 0.11, 0.2, 0.2)];
|
||||
final merged = mergeAcrossModels(single, 0.45);
|
||||
expect(identical(merged, single), isTrue);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# 运行时数据(不提交 git)
|
||||
data/
|
||||
workspace/
|
||||
|
||||
# 预训练权重资产(训练机部署用,不进 git)
|
||||
training/yolov8n.pt
|
||||
+53
-2
@@ -14,6 +14,11 @@
|
||||
| 套餐 | `config.yml` `plans` 节点配置三档套餐(改价 = 改配置重启),价格**整数分**(1000 / 5600 / 18000) |
|
||||
| 后台管理端 | `server_admin/`(Vue3 + Element Plus)管理页面:订单查询、账号/授权管理(手动授权/撤销)、App 版本管理;构建产物由后端 `/admin/` 托管,登录页输入 token 后以 `X-Admin-Token` 头鉴权(`config.yml admin.token`) |
|
||||
| 版本管理 | 后台管理端上传 Android APK + 更新说明,APK 存服务器 `app.apkDir`(默认 `./workspace/`,与 `./data` 平级、挂载持久化)**固定文件名 `observer-latest.apk`,上传即覆盖,目录永远只保留最新一个文件**;**版本号从文件名识别**:文件须命名为 `observer-x.y.z.apk`(Flutter 打包产物即此命名,版本号取自 pubspec);客户端启动时 `GET /api/v1/app/update` 检查更新:服务器版本高于本地版本即弹更新提示(不可跳过)。**仅 Android 检查,iOS 不做版本下发**(iOS 走 App Store 自行更新)。版本记录可删除:删最新版本联动删除 APK 文件,删历史版本仅删记录 |
|
||||
| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程:**数据集卡片列表**(封面图/描述/图片数/已标注数/**训练状态徽标**),**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照展示;**图片入库(手动上传/AI 生成)自动触发 RF-DETR 全图扫描标注**,进度条展示在页顶;页顶另有「全量标注」按钮可手动重标全部图片(覆盖各图已有标注);点击原图/标注图弹窗放大,弹窗内 canvas 直接画框/点框删除/清空并保存——AI 自动标注结果直接作为标注,人工可修改/清理全部框;封面(上传自动转 jpg + UUID 命名)/**描述**/AI 生成图片(provider 抽象,默认 qwen-image/DashScope 付费 API);AI 标注端点与训练机 SSH 为**全局配置,直接读 `config.yml`**(`localAi` / `training.ssh` 节点,改配置需重启服务);图片落服务器 `app.datasetDir`/`datasets/<数据集名>/`,DB 存元数据 + 标注 JSON |
|
||||
| 模型训练 | 从数据集卡片「开始训练」触发:参数(数据集/imgsz/epochs/batch)、进度/日志/指标监控(每 epoch 粒度)、取消;训练通道 `training` 节点可配置 subprocess(与 Go 服务同机直接起 python)/ ssh(异机执行,SSH 凭据取 `config.yml` `training.ssh` 节点),并发度 1(GPU 独占);训练脚本 `server/training/train_server.py`(随项目迁移,2026-08-26)参数化,产物(best.tflite/best.pt/曲线)拉回服务器;训练收尾自动做 **tflite 产物自检**(输入/输出 shape 校验,原 `inspect_tflite.py` 逻辑内嵌脚本),自检失败任务置失败并带出原因;`dump_graph.py` 留作训练机人工深度调试 |
|
||||
| 模型版本与热更新 | **每数据集一个模型**:训练完成后一键「发布」(训练任务操作列)——tflite 落 `workspace/models/<数据集>/latest.tflite` + sha256/指标/类别名入 `model_version`(按数据集独立版本序列 m1.0.0 递增)。管理端**无模型管理界面**(版本记录仅支撑客户端下发)。**App 模型热更新**:`GET /api/v1/app/update` 扩展返回 `models` 目录数组,客户端独立检查,新模型下载校验替换,失败回退旧模型——模型迭代不再重打包 APK |
|
||||
| 模型目录与多模型推理 | `GET /api/v1/models`(登录态)返回全部数据集当前生效模型(数据集/版本/类别/大小/sha256/下载地址),下载 URL `/download/models/<数据集>/latest.tflite`;**App 模型管理页**用户自由下载/删除/启用模型,识别时**加载全部已启用模型并行推理 + 跨模型 NMS 合并**(按类别名),内置 assets 模型兜底 |
|
||||
| 标注 | **图片入库自动触发**:手动上传/AI 生成成功后,新增图自动调 `config.yml` `localAi` 节点配置的 AI 端点做 RF-DETR 全图扫描(`label_task` 记录进度,页顶进度条展示;**localAi 未配置 → 上传/生成接口直接报错;已有标注任务在跑(忙)→ 不报错**,当前任务成功完成后自动补标未标注图)→ 扫描结果(**重叠去重**:NMS 风格按置信度降序保留,重叠比 > `localAi.overlapThreshold` 默认 0.3 的框剔除——重叠比 = 交叠面积/两框较小面积,RF-DETR 同目标常输出一大一小两框,此判据能命中,同目标只留置信度最高者)**直接写 `dataset_image.labels_json`**(覆盖该图已有标注,即重标语义);点击弹窗放大后在 canvas 上画框/点框删除/清空/改类别 → 保存即整体覆写 `dataset_image.labels_json`(YOLO 归一化 JSON 数组,AI 与人工框同存,人工可修改/清理);`POST /admin/label-tasks` 详情页「全量标注」按钮入口(另有自动触发),可发起全量/指定图重标;自动/手动/混合并存,训练前自动整理(prepare_yolo 逻辑在服务端) |
|
||||
|
||||
## 架构与数据流
|
||||
|
||||
@@ -43,6 +48,11 @@ Flutter App ── POST /auth/register|login ─► 账号注册/登录,签发
|
||||
| `payment_order` | 支付订单 | `order_id`(PK)、`phone_num`、`plan_id`、`channel`(wechat/alipay)、`amount_cents`、`status`(created/paid/closed)、`wx_trade_no`(UNIQUE)、`alipay_trade_no`(UNIQUE)、`created_at`、`paid_at` |
|
||||
| `license` | 手机号账号与授权 | `phone_num`(PK)、`password`(bcrypt)、`expires_at`(未充值 NULL)、`remark`(管理端备注)、`created_at`、`updated_at` |
|
||||
| `app_version` | App 版本管理 | `id`(PK)、`version`(x.y.z, UNIQUE)、`notes`(更新说明)、`created_at`、`updated_at`(下载地址不落表:APK 固定文件 `app.apkDir`/`observer-latest.apk`,默认 `./workspace/`) |
|
||||
| `dataset` | 训练数据集 | `id`(PK)、`name`(UNIQUE)、`source`(manual/ai)、`image_count`、`labeled_count`、`status`(building/synced/labeled)、`cover`(封面文件名,UUID 命名 jpg,如 `9f2a...-xx.jpg`)、`description`、`created_at`、`updated_at`(图片文件在 `app.datasetDir`/`datasets/<name>/`,DB 只存元数据;AI 标注/训练机 SSH 配置走 `config.yml` 的 `localAi` / `training.ssh` 节点) |
|
||||
| `dataset_image` | 数据集图片 | `id`(PK)、`dataset_id`、`filename`、`source`(manual/ai)、`prompt`(AI 生成图记录提示词)、`labels_json`(标注 JSON 数组:YOLO 归一化 xywh+类别+置信度,AI 自动标注与人工标注同存、人工可修改/清理,null/''/'[]'=未标注)、`created_at` |
|
||||
| `model_training` | 训练任务 | `id`(PK)、`name`、`status`(running/success/failed)、`dataset`(训练机数据集名)、`imgsz`/`epochs`/`batch`/`device`(参数快照)、`current_epoch`/`total_epochs`、`metrics`(JSON)、`log_tail`、`pid`、`error`、`started_at`/`finished_at`、`created_at` |
|
||||
| `model_version` | 模型版本(每数据集独立序列) | `id`(PK)、`dataset_id`、`version`(m1.0.0 递增, 同数据集 UNIQUE)、`training_id`、`artifact_file`(workspace 相对路径)、`metrics`(JSON)、`labels`(JSON 类别名数组)、`sha256`、`size_bytes`、`is_latest`、`notes`、`created_at`(模型文件不落表:发布即写 `models/<数据集名>/latest.tflite`,客户端固定下载,无存档回退) |
|
||||
| `label_task` | 标注任务 | `id`(PK)、`dataset_id`、`filenames`(JSON 选中图片列表,NULL=全量)、`status`(running/done)、`total`/`done`、`created_at`、`finished_at` |
|
||||
|
||||
建表与迁移见 `技术设计.md`(新库直接建表;存量库以 `PRAGMA user_version` 版本化迁移)。
|
||||
|
||||
@@ -150,6 +160,7 @@ App 版本更新检查(公开接口,无需 token,未登录/旧版本均可
|
||||
- 检测到新版本(服务器版本高于本地版本)即**强制更新**,客户端弹不可关闭的全屏提示,必须跳转更新后才能继续使用;本地已是新版本则不提示
|
||||
- 客户端以「语义化版本号」比较:`1.10.0 > 1.9.9`(按数字段比较,禁止字符串比较)
|
||||
- 下载地址为固定静态路径:`/download/observer-latest.apk`(`app.apkDir` 目录下永远只有最新一个文件,由后端静态托管),客户端拼 `apiBaseUrl` 访问
|
||||
- **模型热更新(与 APK 更新独立通道)**:服务器有已发布模型时响应额外返回 `models` 数组(与 `GET /api/v1/models` 同构:datasetId/datasetName/version/labels/sizeBytes/sha256/downloadUrl);客户端启动与 APK 更新**独立检查**——某数据集服务器版本高于本地已下载版本即下载 `/download/models/<数据集>/latest.tflite` 到应用私有目录,sha256 校验后原子替换,下次识别生效;**非强制**,失败回退旧模型下次启动重试。App 模型管理页列出服务器全部可用模型,用户自由下载/删除/启用;识别时加载全部已启用模型**并行推理 + 跨模型 NMS 合并**(按类别名),内置 assets 模型兜底。无发布模型时不返回 models 字段(旧 App 忽略新字段、新 App 兼容旧服务器)
|
||||
|
||||
### GET /download-page
|
||||
|
||||
@@ -167,12 +178,52 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow
|
||||
| GET | `/admin/app-versions` | 版本记录列表,`page/size` 分页,按下发时间倒序 |
|
||||
| POST | `/admin/app-versions` | 下发新版本(multipart/form-data):`notes` + `file`(APK 文件,仅接受 `.apk`);**版本号从文件名识别**,文件须命名为 `observer-x.y.z.apk`(如 `observer-1.0.1.apk`),格式不符拒绝;版本号不可重复,APK 上传覆盖 `app.apkDir`/`observer-latest.apk`(目录永远只有一个文件);检测到新版本即强制更新 |
|
||||
| POST | `/admin/app-versions/delete` | 删除版本记录 `{"id":1}`:删**最新版本**时联动删除 APK 文件(客户端不再提示更新、下载 404);删历史版本只删记录不动文件 |
|
||||
| POST | `/admin/datasets` | 创建数据集 `{"name":"pheasant_v2","source":"manual"\|"ai"}`(name ≤50 字唯一,目录自动建) |
|
||||
| GET | `/admin/datasets` | 数据集列表:`page/size` 分页,返回 `{total, list}`(含 imageCount/labeledCount/status/cover/description/**training 聚合状态**:最新训练记录的 status/currentEpoch/totalEpochs) |
|
||||
| POST | `/admin/datasets/update` | 更新数据集配置 `{"id":1,"description":"...","cover":"a.jpg"}`:描述/封面,空值字段不覆盖原值 |
|
||||
| POST | `/admin/datasets/cover` | 上传数据集封面(multipart:`datasetId`+`file`,jpg/jpeg/png ≤2MB):**自动转 jpg + UUID 命名**落盘并覆盖旧封面 |
|
||||
| GET | `/admin/datasets/cover` | 封面文件(静态字节流,`datasetId` 定位) |
|
||||
| POST | `/admin/datasets/cover/delete` | 删除数据集封面 `{"datasetId":1}`:删文件 + 清 cover 字段 |
|
||||
| POST | `/admin/datasets/upload` | 上传图片(multipart/form-data:`datasetId` + `files` 多张,仅接受 `.jpg/.jpeg/.png`),存 `app.datasetDir`/`datasets/<name>/`,逐张入库 |
|
||||
| POST | `/admin/datasets/generate` | AI 生成图片 `{"datasetId":1,"prompt":"...","count":1}`(count 1..8,同步执行):调 `imageGen` provider(默认 qwen-image/DashScope)逐张生成落盘 + 入库(记录 prompt),任意失败返回错误 |
|
||||
| GET | `/admin/datasets/images` | 数据集图片列表 `{"datasetId":1}`,返回图片元数据(文件名/来源/prompt/创建时间) |
|
||||
| GET | `/admin/datasets/image` | 图片文件(静态字节流,`datasetId` + `filename` 定位,供缩略图/查看) |
|
||||
| POST | `/admin/datasets/images/delete` | 删除图片 `{"datasetId":1,"ids":[1,2]}`:删文件 + 删记录(AI 生成图是付费资产,前端确认文案提示) |
|
||||
| GET | `/admin/datasets/export` | 导出数据集 zip:`datasetId`,打包图片目录为 zip 下载(标注衔接用) |
|
||||
| POST | `/admin/datasets/sync` | 同步数据集到训练机 `{"id":1}`:按 `training` 通道推送图片到训练机 `datasetDir/<name>/`(subprocess 同机 cp、ssh 异机 scp/rsync);训练启动前自动执行 |
|
||||
| POST | `/admin/label-tasks` | 发起预标注 `{"datasetId":1,"filenames":["a.jpg","b.jpg"]}`(详情页「全量标注」按钮入口,图片入库亦自动触发):**filenames 缺省=全量**,指定图则只扫描选中图(全量重标/单张修补);调 `config.yml` `localAi` 配置的 AI 端点 RF-DETR 逐张推理(common 池并行,未配置报错),扫描结果做**重叠去重**(NMS 风格按置信度降序保留,重叠比 > `localAi.overlapThreshold` 默认 0.3 的框剔除,同目标只留置信度最高者)后**直写 `dataset_image.labels_json`(重跑覆盖该图标注)**,`label_task` 记录进度 |
|
||||
| GET | `/admin/label-tasks` | 标注任务列表:`page/size` 分页(含 status/total/done) |
|
||||
| GET | `/admin/label-tasks/detail` | 标注任务详情:返回数据集全部图片 + 每张标注框(`boxes`,YOLO 归一化 xywh + 置信度 + 类别) |
|
||||
| POST | `/admin/label-tasks/save` | 保存单张标注 `{"datasetId":1,"filename":"a.jpg","boxes":[{"class":0,"cx":0.5,"cy":0.4,"w":0.1,"h":0.2}]}`:整体覆写该图 `labels_json`(空 boxes=清空标注),返回该数据集当前 `labeledCount` |
|
||||
| POST | `/admin/trainings` | 发起训练 `{"dataset":"yolo","imgsz":704,"epochs":150,"batch":16,"device":"0","name":"..."}`:先同步数据集到训练机 → 校验目录存在 → runner 启动训练;**并发度 1**,已有 running 任务时返回错误 |
|
||||
| GET | `/admin/trainings` | 训练任务列表:`page/size` 分页,按下发时间倒序,含 status/进度/指标 |
|
||||
| GET | `/admin/trainings/detail` | 任务详情 `{"id":1}`:参数快照 + 进度 + 指标 + 日志尾部 |
|
||||
| POST | `/admin/trainings/cancel` | 取消训练 `{"id":1}`(仅 running):杀训练进程,状态置 failed(记录 error) |
|
||||
| POST | `/admin/trainings/publish` | 发布为最新模型 `{"id":1,"notes":"..."}`(仅 success):tflite 拷为 `workspace/model-latest.tflite`(原子覆盖)+ sha256/大小 → 插入 `model_version`(版本号递增 m1.0.0 → m1.0.1)+ 旧版 `is_latest=0` |
|
||||
| GET | `/admin/label-workbench` | 标注工作台数据 `{"datasetId":1}`:数据集全部图片 + 每张标注框(`boxes`,labels_json 全量)——无历史标注任务时详情页工作台的数据源 |
|
||||
|
||||
管理页面(订单/授权)由 `server_admin/` 构建产物提供,访问 `http://<host>/admin/`。金额均为整数分,前端展示 ÷100 转元。
|
||||
管理页面由 `server_admin/` 构建产物提供,访问 `http://<host>/admin/`。金额均为整数分,前端展示 ÷100 转元。
|
||||
|
||||
### GET /api/v1/models
|
||||
|
||||
模型目录(需 Bearer token,客户端模型管理页拉取):返回**全部数据集当前生效模型**。响应 `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"list": [
|
||||
{"datasetId": 1, "datasetName": "pheasant", "version": "m1.2.0", "labels": ["pheasant", "suspect"],
|
||||
"sizeBytes": 6400000, "sha256": "ab12...", "notes": "修复小目标漏检", "publishedAt": "2026-08-26T10:00:00+08:00",
|
||||
"downloadUrl": "/download/models/pheasant/latest.tflite"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- 只返回 `is_latest=1` 的模型(每数据集至多一条);无任何发布模型时 `list` 为空数组
|
||||
- 下载地址由客户端拼 `apiBaseUrl` 访问;下载文件 sha256 校验,类别名数组 `labels` 用于多模型合并推理展示
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. 配置 `config.yml`:监听端口、数据库路径、登录 token 签名密钥 `auth.secret`(必填,换值即全员下线)、套餐 `plans` 节点、微信支付(appid/mchid/商户私钥/证书序列号/APIv3 密钥)、支付宝(appid/应用私钥/支付宝公钥)、管理端 `admin.token`;SQLite 库由服务启动时自动建表并迁移,无需手工初始化
|
||||
1. 配置 `config.yml`:监听端口、数据库路径、登录 token 签名密钥 `auth.secret`(必填,换值即全员下线)、套餐 `plans` 节点、微信支付(appid/mchid/商户私钥/证书序列号/APIv3 密钥)、支付宝(appid/应用私钥/支付宝公钥)、管理端 `admin.token`;模型训练相关节点:`training`(训练通道 mode=subprocess/ssh、ssh 连接信息、训练机工作目录/venv/数据集目录、并发度 1、超时)、`imageGen`(AI 生成图片 provider,默认 dashscope + apiKey + model qwen-image-3.0)、`localAi`(二期标注用 RF-DETR 服务地址);SQLite 库由服务启动时自动建表并迁移,无需手工初始化
|
||||
2. `go build ./...` 编译验证
|
||||
3. 本地运行 `go run main.go`;服务层白盒测试:`GF_GCFG_FILE=biz/service/testdata/config.yml go test ./biz/service/`(独立测试库,见 `biz/service/testdata/`)
|
||||
4. 后台管理端:`cd server_admin && npm run build`(构建产物输出到 `server/admin_dist/`,由后端 `/admin/` 托管);开发联调 `npm run dev`(Vite 代理 `/api` → `:8080`)。首次访问 `/admin/` 进入登录页,输入 `config.yml admin.token` 对应的管理 token(存浏览器 localStorage,随请求携带;token 不内嵌构建产物)
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>视野管理端</title>
|
||||
<script type="module" crossorigin src="/admin/assets/index-FJsP2pyZ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-BcKljL0h.css">
|
||||
<script type="module" crossorigin src="/admin/assets/index-jKHdJYxb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-B0ehGsu8.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -6,6 +6,11 @@ const (
|
||||
TablePaymentOrder = "payment_order"
|
||||
TableLicense = "license"
|
||||
TableAppVersion = "app_version"
|
||||
TableDataset = "dataset"
|
||||
TableDatasetImage = "dataset_image"
|
||||
TableTraining = "model_training"
|
||||
TableModelVersion = "model_version"
|
||||
TableLabelTask = "label_task"
|
||||
|
||||
// 订单状态机 created → paid(closed 仅超时/失败关闭)
|
||||
OrderStatusCreated = "created"
|
||||
@@ -20,4 +25,24 @@ const (
|
||||
|
||||
// 回调 IO 池默认并发度(被 config.yml payment.poolSize 覆盖)
|
||||
PaymentPoolDefaultSize = 16
|
||||
|
||||
// 预标注(逐张调 RF-DETR)池默认并发度(被 config.yml labelTask.poolSize 覆盖)
|
||||
LabelPoolDefaultSize = 4
|
||||
|
||||
// 训练任务状态机 running → success/failed
|
||||
TrainingStatusRunning = "running"
|
||||
TrainingStatusSuccess = "success"
|
||||
TrainingStatusFailed = "failed"
|
||||
|
||||
// 数据集状态 building → labeled → synced(synced = 已同步训练机)
|
||||
DatasetStatusBuilding = "building"
|
||||
DatasetStatusLabeled = "labeled"
|
||||
DatasetStatusSynced = "synced"
|
||||
|
||||
// 标注任务状态
|
||||
LabelTaskRunning = "running"
|
||||
LabelTaskDone = "done"
|
||||
|
||||
// 模型版本号前缀(m1.0.0),同数据集内递增
|
||||
ModelVersionPrefix = "m"
|
||||
)
|
||||
|
||||
@@ -2,18 +2,25 @@ package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
|
||||
"observer-server/biz/model/dto"
|
||||
"observer-server/biz/service"
|
||||
)
|
||||
|
||||
// cAdmin 后台管理端接口:薄透传;service 按表归入各表文件
|
||||
// (订单→service.Order、授权→service.License)。
|
||||
// (订单→service.Order、授权→service.License、数据集→service.Dataset、
|
||||
// 训练→service.Training、模型版本→service.ModelVersion、标注→service.LabelTask)。
|
||||
// 独立 controller 聚合,避免同一 controller 挂客户端组 + 管理组时路由重复注册。
|
||||
type cAdmin struct{}
|
||||
|
||||
var Admin = &cAdmin{}
|
||||
|
||||
// ---------- 订单/授权/App 版本(既有) ----------
|
||||
|
||||
// ListOrders 订单列表
|
||||
func (c *cAdmin) ListOrders(ctx context.Context, req *dto.AdminOrderListReq) (*dto.AdminOrderListRes, error) {
|
||||
return service.Order.AdminListOrders(ctx, req)
|
||||
@@ -53,3 +60,145 @@ func (c *cAdmin) AddAppVersion(ctx context.Context, req *dto.AdminAppVersionAddR
|
||||
func (c *cAdmin) DeleteAppVersion(ctx context.Context, req *dto.AdminAppVersionDeleteReq) (*dto.AdminAppVersionDeleteRes, error) {
|
||||
return service.AppVersion.AdminDeleteVersion(ctx, req)
|
||||
}
|
||||
|
||||
// ---------- 数据集管理 ----------
|
||||
|
||||
// ListDatasets 数据集列表
|
||||
func (c *cAdmin) ListDatasets(ctx context.Context, req *dto.AdminDatasetListReq) (*dto.AdminDatasetListRes, error) {
|
||||
return service.Dataset.AdminListDatasets(ctx, req)
|
||||
}
|
||||
|
||||
// CreateDataset 新建数据集
|
||||
func (c *cAdmin) CreateDataset(ctx context.Context, req *dto.AdminDatasetCreateReq) (*dto.AdminDatasetCreateRes, error) {
|
||||
return service.Dataset.AdminCreateDataset(ctx, req)
|
||||
}
|
||||
|
||||
// UpdateDataset 更新数据集配置(封面/描述/AI 标注端点/训练机 SSH)
|
||||
func (c *cAdmin) UpdateDataset(ctx context.Context, req *dto.AdminDatasetUpdateReq) (*dto.AdminDatasetUpdateRes, error) {
|
||||
return service.Dataset.AdminUpdateDataset(ctx, req)
|
||||
}
|
||||
|
||||
// DeleteDataset 删除数据集
|
||||
func (c *cAdmin) DeleteDataset(ctx context.Context, req *dto.AdminDatasetDeleteReq) (*dto.AdminDatasetDeleteRes, error) {
|
||||
return service.Dataset.AdminDeleteDataset(ctx, req)
|
||||
}
|
||||
|
||||
// UploadImages 上传图片
|
||||
func (c *cAdmin) UploadImages(ctx context.Context, req *dto.AdminDatasetUploadReq) (*dto.AdminDatasetUploadRes, error) {
|
||||
return service.Dataset.AdminUploadImages(ctx, req)
|
||||
}
|
||||
|
||||
// GenerateImages AI 生成图片
|
||||
func (c *cAdmin) GenerateImages(ctx context.Context, req *dto.AdminDatasetGenerateReq) (*dto.AdminDatasetGenerateRes, error) {
|
||||
return service.Dataset.AdminGenerateImages(ctx, req)
|
||||
}
|
||||
|
||||
// ListImages 数据集图片列表
|
||||
func (c *cAdmin) ListImages(ctx context.Context, req *dto.AdminDatasetImagesReq) (*dto.AdminDatasetImagesRes, error) {
|
||||
return service.Dataset.AdminListImages(ctx, req)
|
||||
}
|
||||
|
||||
// DeleteImages 删除图片
|
||||
func (c *cAdmin) DeleteImages(ctx context.Context, req *dto.AdminDatasetImagesDeleteReq) (*dto.AdminDatasetImagesDeleteRes, error) {
|
||||
return service.Dataset.AdminDeleteImages(ctx, req)
|
||||
}
|
||||
|
||||
// Image 图片访问(直写响应体:service 校验归属返回路径,controller 输出二进制)
|
||||
func (c *cAdmin) Image(ctx context.Context, req *dto.AdminDatasetImageReq) (*dto.AdminDatasetImageRes, error) {
|
||||
path, err := service.Dataset.ImageFile(ctx, req.DatasetId, req.Filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := ghttp.RequestFromCtx(ctx)
|
||||
r.Response.ServeFile(path)
|
||||
return &dto.AdminDatasetImageRes{}, nil
|
||||
}
|
||||
|
||||
// UploadCover 上传数据集封面
|
||||
func (c *cAdmin) UploadCover(ctx context.Context, req *dto.AdminDatasetCoverUploadReq) (*dto.AdminDatasetCoverUploadRes, error) {
|
||||
return service.Dataset.AdminUploadCover(ctx, req)
|
||||
}
|
||||
|
||||
// Cover 封面访问(直写响应体)
|
||||
func (c *cAdmin) Cover(ctx context.Context, req *dto.AdminDatasetCoverReq) (*dto.AdminDatasetCoverRes, error) {
|
||||
path, err := service.Dataset.CoverFile(ctx, req.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := ghttp.RequestFromCtx(ctx)
|
||||
r.Response.ServeFile(path)
|
||||
return &dto.AdminDatasetCoverRes{}, nil
|
||||
}
|
||||
|
||||
// DeleteCover 删除数据集封面
|
||||
func (c *cAdmin) DeleteCover(ctx context.Context, req *dto.AdminDatasetCoverDeleteReq) (*dto.AdminDatasetCoverDeleteRes, error) {
|
||||
return service.Dataset.AdminDeleteCover(ctx, req)
|
||||
}
|
||||
|
||||
// ExportDataset 导出数据集 zip(直写响应体下载)
|
||||
func (c *cAdmin) ExportDataset(ctx context.Context, req *dto.AdminDatasetExportReq) (*dto.AdminDatasetExportRes, error) {
|
||||
data, filename, err := service.Dataset.ExportZip(ctx, req.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := ghttp.RequestFromCtx(ctx)
|
||||
r.Response.Header().Set("Content-Disposition",
|
||||
fmt.Sprintf("attachment; filename*=UTF-8''%s", url.PathEscape(filename)))
|
||||
r.Response.Header().Set("Content-Type", "application/zip")
|
||||
r.Response.Write(data)
|
||||
return &dto.AdminDatasetExportRes{}, nil
|
||||
}
|
||||
|
||||
// ---------- 训练编排 ----------
|
||||
|
||||
// ListTrainings 训练任务列表
|
||||
func (c *cAdmin) ListTrainings(ctx context.Context, req *dto.AdminTrainingListReq) (*dto.AdminTrainingListRes, error) {
|
||||
return service.Training.AdminListTrainings(ctx, req)
|
||||
}
|
||||
|
||||
// StartTraining 发起训练
|
||||
func (c *cAdmin) StartTraining(ctx context.Context, req *dto.AdminTrainingStartReq) (*dto.AdminTrainingStartRes, error) {
|
||||
return service.Training.AdminStartTraining(ctx, req)
|
||||
}
|
||||
|
||||
// TrainingDetail 训练任务详情
|
||||
func (c *cAdmin) TrainingDetail(ctx context.Context, req *dto.AdminTrainingDetailReq) (*dto.AdminTrainingDetailRes, error) {
|
||||
return service.Training.AdminTrainingDetail(ctx, req)
|
||||
}
|
||||
|
||||
// CancelTraining 取消训练
|
||||
func (c *cAdmin) CancelTraining(ctx context.Context, req *dto.AdminTrainingCancelReq) (*dto.AdminTrainingCancelRes, error) {
|
||||
return service.Training.AdminCancelTraining(ctx, req)
|
||||
}
|
||||
|
||||
// PublishTraining 发布模型版本
|
||||
func (c *cAdmin) PublishTraining(ctx context.Context, req *dto.AdminTrainingPublishReq) (*dto.AdminTrainingPublishRes, error) {
|
||||
return service.Training.AdminPublish(ctx, req)
|
||||
}
|
||||
|
||||
// ---------- 预标注 ----------
|
||||
|
||||
// ListLabelTasks 预标注任务列表
|
||||
func (c *cAdmin) ListLabelTasks(ctx context.Context, req *dto.AdminLabelTaskListReq) (*dto.AdminLabelTaskListRes, error) {
|
||||
return service.LabelTask.AdminListLabelTasks(ctx, req)
|
||||
}
|
||||
|
||||
// StartLabelTask 发起预标注
|
||||
func (c *cAdmin) StartLabelTask(ctx context.Context, req *dto.AdminLabelTaskStartReq) (*dto.AdminLabelTaskStartRes, error) {
|
||||
return service.LabelTask.AdminStartLabelTask(ctx, req)
|
||||
}
|
||||
|
||||
// LabelTaskDetail 预标注任务详情(工作台数据)
|
||||
func (c *cAdmin) LabelTaskDetail(ctx context.Context, req *dto.AdminLabelTaskDetailReq) (*dto.AdminLabelTaskDetailRes, error) {
|
||||
return service.LabelTask.AdminLabelTaskDetail(ctx, req)
|
||||
}
|
||||
|
||||
// LabelWorkbench 标注工作台数据(无历史任务时直接用数据集图片 + 已确认标注)
|
||||
func (c *cAdmin) LabelWorkbench(ctx context.Context, req *dto.AdminLabelWorkbenchReq) (*dto.AdminLabelWorkbenchRes, error) {
|
||||
return service.LabelTask.AdminLabelWorkbench(ctx, req)
|
||||
}
|
||||
|
||||
// SaveLabel 保存单张图标注
|
||||
func (c *cAdmin) SaveLabel(ctx context.Context, req *dto.AdminLabelSaveReq) (*dto.AdminLabelSaveRes, error) {
|
||||
return service.LabelTask.AdminLabelSave(ctx, req)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"observer-server/biz/model/dto"
|
||||
"observer-server/biz/service"
|
||||
)
|
||||
|
||||
// cModelCatalog 客户端模型目录:App 按需下载数据集模型(多模型并行推理 + 热更新)。
|
||||
// 挂登录态组(AuthRequired);下载走 /download 静态托管。
|
||||
type cModelCatalog struct{}
|
||||
|
||||
var ModelCatalog = &cModelCatalog{}
|
||||
|
||||
// List 模型目录:全部数据集当前生效模型
|
||||
func (c *cModelCatalog) List(ctx context.Context, req *dto.ModelCatalogReq) (*dto.ModelCatalogRes, error) {
|
||||
return service.ModelVersion.ClientCatalog(ctx)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"observer-server/biz/consts"
|
||||
"observer-server/biz/model/entity"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
// Dataset 数据集表 DAO:表小、读写低频,不做查询缓存;
|
||||
// 图片计数由 service 在增删图片时维护,查询走普通读。
|
||||
type datasetDao struct{}
|
||||
|
||||
var Dataset = &datasetDao{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS dataset (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
image_count INTEGER NOT NULL DEFAULT 0,
|
||||
labeled_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'building',
|
||||
cover TEXT,
|
||||
description TEXT,
|
||||
ai_endpoint TEXT,
|
||||
ai_model TEXT,
|
||||
train_host TEXT,
|
||||
train_user TEXT,
|
||||
train_password TEXT,
|
||||
train_key TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert 新建数据集(name UNIQUE 由库兜底)
|
||||
func (d *datasetDao) Insert(ctx context.Context, m *entity.Dataset) (int64, error) {
|
||||
res, err := g.DB().Model(consts.TableDataset).Ctx(ctx).Data(g.Map{
|
||||
"name": m.Name,
|
||||
"source": m.Source,
|
||||
"image_count": m.ImageCount,
|
||||
"labeled_count": m.LabeledCount,
|
||||
"status": m.Status,
|
||||
"created_at": m.CreatedAt,
|
||||
"updated_at": m.UpdatedAt,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// GetById 按主键查询,不存在返回 nil
|
||||
func (d *datasetDao) GetById(ctx context.Context, id int64) (*entity.Dataset, error) {
|
||||
var e entity.Dataset
|
||||
err := g.DB().Model(consts.TableDataset).Ctx(ctx).Where("id", id).Scan(&e)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// GetByName 按名称查询(目录名即数据集名),不存在返回 nil
|
||||
func (d *datasetDao) GetByName(ctx context.Context, name string) (*entity.Dataset, error) {
|
||||
var e entity.Dataset
|
||||
err := g.DB().Model(consts.TableDataset).Ctx(ctx).Where("name", name).Scan(&e)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// UpdateCounters 更新图片数/已标注数/状态(imgDelta 增量;labeledCount ≥0 时覆盖写)
|
||||
func (d *datasetDao) UpdateCounters(ctx context.Context, id int64, imgDelta, labeledCount int64, status string) error {
|
||||
data := g.Map{"updated_at": gtime.Now()}
|
||||
if imgDelta != 0 {
|
||||
data["image_count"] = gdb.Raw("image_count + " + strconv.FormatInt(imgDelta, 10))
|
||||
}
|
||||
if labeledCount >= 0 {
|
||||
data["labeled_count"] = labeledCount
|
||||
}
|
||||
if status != "" {
|
||||
data["status"] = status
|
||||
}
|
||||
_, err := g.DB().Model(consts.TableDataset).Ctx(ctx).Where("id", id).Data(data).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateConfigs 更新数据集展示配置(封面/描述;仅更新非零字段,留空字段保留原值——
|
||||
// 前端提交整包配置,未填项不覆盖。AI 端点/训练机 SSH 为全局训练配置,不在此表维护)
|
||||
func (d *datasetDao) UpdateConfigs(ctx context.Context, id int64, m *entity.Dataset) error {
|
||||
data := g.Map{"updated_at": gtime.Now()}
|
||||
if m.Cover != "" {
|
||||
data["cover"] = m.Cover
|
||||
}
|
||||
if m.Description != "" {
|
||||
data["description"] = m.Description
|
||||
}
|
||||
_, err := g.DB().Model(consts.TableDataset).Ctx(ctx).Where("id", id).Data(data).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// ClearCover 清空封面字段(删除封面用;UpdateConfigs 空值不覆盖,无法复用)
|
||||
func (d *datasetDao) ClearCover(ctx context.Context, id int64) error {
|
||||
_, err := g.DB().Model(consts.TableDataset).Ctx(ctx).Where("id", id).Data(g.Map{"cover": "", "updated_at": gtime.Now()}).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateStatus 更新状态(building|labeled|synced)
|
||||
func (d *datasetDao) UpdateStatus(ctx context.Context, id int64, status string) error {
|
||||
_, err := g.DB().Model(consts.TableDataset).Ctx(ctx).Where("id", id).
|
||||
Data(g.Map{"status": status, "updated_at": gtime.Now()}).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteById 删除数据集记录
|
||||
func (d *datasetDao) DeleteById(ctx context.Context, id int64) error {
|
||||
_, err := g.DB().Model(consts.TableDataset).Ctx(ctx).Where("id", id).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
// PageByKeyword 数据集分页:名称模糊匹配(Like 命中目录名,数据库内仅元数据)
|
||||
func (d *datasetDao) PageByKeyword(ctx context.Context, keyword string, page, size int) ([]*entity.Dataset, int64, error) {
|
||||
base := g.DB().Model(consts.TableDataset).Ctx(ctx).WhereLike("name", "%"+keyword+"%")
|
||||
total, err := base.Count()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []*entity.Dataset
|
||||
err = base.OrderDesc("id").Limit((page-1)*size, size).Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return []*entity.Dataset{}, int64(total), nil
|
||||
}
|
||||
return nil, 0, err
|
||||
}
|
||||
return list, int64(total), nil
|
||||
}
|
||||
|
||||
// ListAll 全量数据集(模型/训练列表组装数据集名用,数据集表小)
|
||||
func (d *datasetDao) ListAll(ctx context.Context) ([]*entity.Dataset, error) {
|
||||
var list []*entity.Dataset
|
||||
err := g.DB().Model(consts.TableDataset).Ctx(ctx).OrderAsc("id").Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return []*entity.Dataset{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// Page 数据集分页:创建时间倒序
|
||||
func (d *datasetDao) Page(ctx context.Context, page, size int) ([]*entity.Dataset, int64, error) {
|
||||
base := g.DB().Model(consts.TableDataset).Ctx(ctx)
|
||||
total, err := base.Count()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []*entity.Dataset
|
||||
err = base.OrderDesc("id").Limit((page-1)*size, size).Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return []*entity.Dataset{}, int64(total), nil
|
||||
}
|
||||
return nil, 0, err
|
||||
}
|
||||
return list, int64(total), nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"observer-server/biz/consts"
|
||||
"observer-server/biz/model/entity"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
// DatasetImage 数据集图片表 DAO:文件名唯一(防重名),按数据集查询;表小不做缓存。
|
||||
type datasetImageDao struct{}
|
||||
|
||||
var DatasetImage = &datasetImageDao{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS dataset_image (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dataset_id INTEGER NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
prompt TEXT,
|
||||
labels_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (dataset_id, filename)
|
||||
)`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// 存量库迁移:标注列缺失时追加(新库建表已含列,幂等跳过);candidates_json 已随 v10 删除
|
||||
common.EnsureColumn(ctx, consts.TableDatasetImage, "labels_json", "labels_json TEXT")
|
||||
}
|
||||
|
||||
// Insert 插入图片记录
|
||||
func (d *datasetImageDao) Insert(ctx context.Context, m *entity.DatasetImage) (int64, error) {
|
||||
res, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).Data(g.Map{
|
||||
"dataset_id": m.DatasetId,
|
||||
"filename": m.Filename,
|
||||
"source": m.Source,
|
||||
"prompt": m.Prompt,
|
||||
"created_at": m.CreatedAt,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// ListByDataset 某数据集全部图片(文件名倒序,详情页逐行展示用)
|
||||
func (d *datasetImageDao) ListByDataset(ctx context.Context, datasetId int64) ([]*entity.DatasetImage, error) {
|
||||
var list []*entity.DatasetImage
|
||||
err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
|
||||
Where("dataset_id", datasetId).OrderDesc("filename").Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return []*entity.DatasetImage{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// ListUnlabeledByDataset 未标注图片(labels_json 为空/null/'[]';自动补标轮次用)
|
||||
func (d *datasetImageDao) ListUnlabeledByDataset(ctx context.Context, datasetId int64) ([]*entity.DatasetImage, error) {
|
||||
var list []*entity.DatasetImage
|
||||
err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
|
||||
Where("dataset_id", datasetId).
|
||||
Where("labels_json IS NULL OR labels_json = '' OR labels_json = '[]'").
|
||||
OrderAsc("id").Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return []*entity.DatasetImage{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// GetByIds 按 id 批量取(删除图片定位文件用)
|
||||
func (d *datasetImageDao) GetByIds(ctx context.Context, ids []int64) ([]*entity.DatasetImage, error) {
|
||||
var list []*entity.DatasetImage
|
||||
err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).WhereIn("id", ids).Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return []*entity.DatasetImage{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// GetByFilename 按文件名取(图片访问校验归属)
|
||||
func (d *datasetImageDao) GetByFilename(ctx context.Context, datasetId int64, filename string) (*entity.DatasetImage, error) {
|
||||
var e entity.DatasetImage
|
||||
err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
|
||||
Where("dataset_id", datasetId).Where("filename", filename).Scan(&e)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// CountByDataset 某数据集图片数
|
||||
func (d *datasetImageDao) CountByDataset(ctx context.Context, datasetId int64) (int64, error) {
|
||||
n, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).Where("dataset_id", datasetId).Count()
|
||||
return int64(n), err
|
||||
}
|
||||
|
||||
// UpdateLabels 覆写标注 JSON(整图粒度;空串=清空标注;AI 自动标注与人工保存共用)
|
||||
func (d *datasetImageDao) UpdateLabels(ctx context.Context, id int64, labelsJson string) error {
|
||||
_, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).Where("id", id).
|
||||
Data(g.Map{"labels_json": labelsJson}).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// CountLabeledByDataset 某数据集已标注图片数(labels_json 非空数组的图片行数)
|
||||
func (d *datasetImageDao) CountLabeledByDataset(ctx context.Context, datasetId int64) (int64, error) {
|
||||
n, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
|
||||
Where("dataset_id", datasetId).
|
||||
Where("labels_json IS NOT NULL AND labels_json != '' AND labels_json != '[]'").
|
||||
Count()
|
||||
return int64(n), err
|
||||
}
|
||||
|
||||
// DeleteByIds 按 id 批量删除(标注随行删除)
|
||||
func (d *datasetImageDao) DeleteByIds(ctx context.Context, ids []int64) error {
|
||||
_, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).WhereIn("id", ids).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteByDataset 删除某数据集全部图片记录(删数据集联动)
|
||||
func (d *datasetImageDao) DeleteByDataset(ctx context.Context, datasetId int64) error {
|
||||
_, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).Where("dataset_id", datasetId).Delete()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"observer-server/biz/consts"
|
||||
"observer-server/biz/model/entity"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
// LabelTask 标注任务表 DAO:进度更新(done 计数)高频,单行 UPDATE 原子。
|
||||
type labelTaskDao struct{}
|
||||
|
||||
var LabelTask = &labelTaskDao{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS label_task (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dataset_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
total INTEGER NOT NULL DEFAULT 0,
|
||||
done INTEGER NOT NULL DEFAULT 0,
|
||||
boxes_file TEXT,
|
||||
filenames TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
)`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert 创建标注任务,返回自增 id
|
||||
func (d *labelTaskDao) Insert(ctx context.Context, m *entity.LabelTask) (int64, error) {
|
||||
res, err := g.DB().Model(consts.TableLabelTask).Ctx(ctx).Data(g.Map{
|
||||
"dataset_id": m.DatasetId,
|
||||
"status": m.Status,
|
||||
"total": m.Total,
|
||||
"done": m.Done,
|
||||
"boxes_file": m.BoxesFile,
|
||||
"filenames": m.Filenames,
|
||||
"error": m.Error,
|
||||
"created_at": m.CreatedAt,
|
||||
"finished_at": m.FinishedAt,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// GetById 按主键查询,不存在返回 nil
|
||||
func (d *labelTaskDao) GetById(ctx context.Context, id int64) (*entity.LabelTask, error) {
|
||||
var e entity.LabelTask
|
||||
err := g.DB().Model(consts.TableLabelTask).Ctx(ctx).Where("id", id).Scan(&e)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// ListRunning 全部 running 任务(服务重启恢复用)
|
||||
func (d *labelTaskDao) ListRunning(ctx context.Context) ([]*entity.LabelTask, error) {
|
||||
var list []*entity.LabelTask
|
||||
err := g.DB().Model(consts.TableLabelTask).Ctx(ctx).
|
||||
Where("status", consts.LabelTaskRunning).OrderAsc("id").Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return []*entity.LabelTask{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// GetRunningByDataset 某数据集 running 任务(并发检查用)
|
||||
func (d *labelTaskDao) GetRunningByDataset(ctx context.Context, datasetId int64) (*entity.LabelTask, error) {
|
||||
var e entity.LabelTask
|
||||
err := g.DB().Model(consts.TableLabelTask).Ctx(ctx).
|
||||
Where("dataset_id", datasetId).Where("status", consts.LabelTaskRunning).Scan(&e)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// UpdateProgress 更新处理进度
|
||||
func (d *labelTaskDao) UpdateProgress(ctx context.Context, id int64, done int) error {
|
||||
_, err := g.DB().Model(consts.TableLabelTask).Ctx(ctx).Where("id", id).
|
||||
Data(g.Map{"done": done}).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// Finish 完成任务(done 状态 + 完成时间 + 候选框文件路径 + 失败原因)
|
||||
func (d *labelTaskDao) Finish(ctx context.Context, id int64, boxesFile, errMsg string) error {
|
||||
_, err := g.DB().Model(consts.TableLabelTask).Ctx(ctx).Where("id", id).
|
||||
Data(g.Map{"status": consts.LabelTaskDone, "finished_at": gtime.Now(), "boxes_file": boxesFile, "error": errMsg}).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// LatestByDataset 某数据集最近一次任务(无则 nil)
|
||||
func (d *labelTaskDao) LatestByDataset(ctx context.Context, datasetId int64) (*entity.LabelTask, error) {
|
||||
var e entity.LabelTask
|
||||
err := g.DB().Model(consts.TableLabelTask).Ctx(ctx).
|
||||
Where("dataset_id", datasetId).OrderDesc("id").Limit(1).Scan(&e)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// DeleteByDataset 删除数据集关联的全部标注任务(数据集删除时级联清理孤儿记录)
|
||||
func (d *labelTaskDao) DeleteByDataset(ctx context.Context, datasetId int64) error {
|
||||
_, err := g.DB().Model(consts.TableLabelTask).Ctx(ctx).
|
||||
Where("dataset_id", datasetId).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
// Page 标注任务分页:创建时间倒序
|
||||
func (d *labelTaskDao) Page(ctx context.Context, page, size int) ([]*entity.LabelTask, int64, error) {
|
||||
base := g.DB().Model(consts.TableLabelTask).Ctx(ctx)
|
||||
total, err := base.Count()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []*entity.LabelTask
|
||||
err = base.OrderDesc("id").Limit((page-1)*size, size).Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return []*entity.LabelTask{}, int64(total), nil
|
||||
}
|
||||
return nil, 0, err
|
||||
}
|
||||
return list, int64(total), nil
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"observer-server/biz/consts"
|
||||
"observer-server/biz/model/entity"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
// ModelTraining 训练任务表 DAO:进度/日志尾部为高频更新(独立小事务,不走 Serial 串行,
|
||||
// 单行 UPDATE 天然原子,无并发写冲突);状态流转(发起/结束)走 service 单写者。
|
||||
type modelTrainingDao struct{}
|
||||
|
||||
var Training = &modelTrainingDao{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS model_training (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
dataset_id INTEGER NOT NULL,
|
||||
imgsz INTEGER NOT NULL DEFAULT 704,
|
||||
epochs INTEGER NOT NULL DEFAULT 150,
|
||||
batch INTEGER NOT NULL DEFAULT 16,
|
||||
device TEXT NOT NULL DEFAULT '0',
|
||||
current_epoch INTEGER NOT NULL DEFAULT 0,
|
||||
total_epochs INTEGER NOT NULL DEFAULT 0,
|
||||
metrics TEXT,
|
||||
log_tail TEXT,
|
||||
pid INTEGER,
|
||||
error TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert 创建训练任务,返回自增 id
|
||||
func (d *modelTrainingDao) Insert(ctx context.Context, m *entity.ModelTraining) (int64, error) {
|
||||
res, err := g.DB().Model(consts.TableTraining).Ctx(ctx).Data(g.Map{
|
||||
"name": m.Name,
|
||||
"status": m.Status,
|
||||
"dataset_id": m.DatasetId,
|
||||
"imgsz": m.Imgsz,
|
||||
"epochs": m.Epochs,
|
||||
"batch": m.Batch,
|
||||
"device": m.Device,
|
||||
"current_epoch": m.CurrentEpoch,
|
||||
"total_epochs": m.TotalEpochs,
|
||||
"metrics": m.Metrics,
|
||||
"log_tail": m.LogTail,
|
||||
"pid": m.Pid,
|
||||
"error": m.Error,
|
||||
"started_at": m.StartedAt,
|
||||
"finished_at": m.FinishedAt,
|
||||
"created_at": m.CreatedAt,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// GetById 按主键查询,不存在返回 nil
|
||||
func (d *modelTrainingDao) GetById(ctx context.Context, id int64) (*entity.ModelTraining, error) {
|
||||
var e entity.ModelTraining
|
||||
err := g.DB().Model(consts.TableTraining).Ctx(ctx).Where("id", id).Scan(&e)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// UpdateProgress 更新进度/指标/日志尾部(训练轮询高频调用)
|
||||
func (d *modelTrainingDao) UpdateProgress(ctx context.Context, id int64, currentEpoch, totalEpochs int, metrics, logTail string) error {
|
||||
data := g.Map{}
|
||||
if currentEpoch > 0 {
|
||||
data["current_epoch"] = currentEpoch
|
||||
}
|
||||
if totalEpochs > 0 {
|
||||
data["total_epochs"] = totalEpochs
|
||||
}
|
||||
if metrics != "" {
|
||||
data["metrics"] = metrics
|
||||
}
|
||||
if logTail != "" {
|
||||
data["log_tail"] = logTail
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := g.DB().Model(consts.TableTraining).Ctx(ctx).Where("id", id).Data(data).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdatePid 记录训练进程 pid(启动后写入,恢复扫描用)
|
||||
func (d *modelTrainingDao) UpdatePid(ctx context.Context, id int64, pid int) error {
|
||||
_, err := g.DB().Model(consts.TableTraining).Ctx(ctx).Where("id", id).
|
||||
Data(g.Map{"pid": pid}).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// PageByStatus 训练任务分页:按状态筛选(创建时间倒序)
|
||||
func (d *modelTrainingDao) PageByStatus(ctx context.Context, status string, page, size int) ([]*entity.ModelTraining, int64, error) {
|
||||
base := g.DB().Model(consts.TableTraining).Ctx(ctx).Where("status", status)
|
||||
total, err := base.Count()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []*entity.ModelTraining
|
||||
err = base.OrderDesc("id").Limit((page-1)*size, size).Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return []*entity.ModelTraining{}, int64(total), nil
|
||||
}
|
||||
return nil, 0, err
|
||||
}
|
||||
return list, int64(total), nil
|
||||
}
|
||||
|
||||
// Finish 结束任务(success/failed):状态 + 结束时间 + 指标 + 日志尾部 + 失败原因
|
||||
func (d *modelTrainingDao) Finish(ctx context.Context, id int64, status, metrics, logTail, errMsg string) error {
|
||||
data := g.Map{"status": status, "finished_at": gtime.Now(), "log_tail": logTail}
|
||||
if metrics != "" {
|
||||
data["metrics"] = metrics
|
||||
}
|
||||
if errMsg != "" {
|
||||
data["error"] = errMsg
|
||||
}
|
||||
_, err := g.DB().Model(consts.TableTraining).Ctx(ctx).Where("id", id).Data(data).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// Running 当前 running 任务(并发度 1 检查用;异常终态的失败任务同表记录,不算 running)
|
||||
func (d *modelTrainingDao) Running(ctx context.Context) (*entity.ModelTraining, error) {
|
||||
var e entity.ModelTraining
|
||||
err := g.DB().Model(consts.TableTraining).Ctx(ctx).
|
||||
Where("status", consts.TrainingStatusRunning).OrderAsc("id").Limit(1).Scan(&e)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// RunningByDataset 某数据集 running 任务(删数据集前检查用)
|
||||
func (d *modelTrainingDao) RunningByDataset(ctx context.Context, datasetId int64) (*entity.ModelTraining, error) {
|
||||
var e entity.ModelTraining
|
||||
err := g.DB().Model(consts.TableTraining).Ctx(ctx).
|
||||
Where("dataset_id", datasetId).Where("status", consts.TrainingStatusRunning).Limit(1).Scan(&e)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// ListRunning 全部 running 任务(Go 重启后恢复扫描用)
|
||||
func (d *modelTrainingDao) ListRunning(ctx context.Context) ([]*entity.ModelTraining, error) {
|
||||
var list []*entity.ModelTraining
|
||||
err := g.DB().Model(consts.TableTraining).Ctx(ctx).
|
||||
Where("status", consts.TrainingStatusRunning).OrderAsc("id").Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return []*entity.ModelTraining{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// LatestByDatasets 批量取各数据集最新一条训练记录(列表卡片训练状态用;
|
||||
// IN 一次取回按 id 倒序,应用层按 dataset_id 去重;数据集表小、记录少,单次查询足够)
|
||||
func (d *modelTrainingDao) LatestByDatasets(ctx context.Context, datasetIds []int64) (map[int64]*entity.ModelTraining, error) {
|
||||
out := make(map[int64]*entity.ModelTraining)
|
||||
if len(datasetIds) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
for start := 0; start < len(datasetIds); start += 100 {
|
||||
end := start + 100
|
||||
if end > len(datasetIds) {
|
||||
end = len(datasetIds)
|
||||
}
|
||||
var list []*entity.ModelTraining
|
||||
err := g.DB().Model(consts.TableTraining).Ctx(ctx).
|
||||
WhereIn("dataset_id", datasetIds[start:end]).OrderDesc("id").Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
for _, t := range list {
|
||||
if _, ok := out[t.DatasetId]; !ok {
|
||||
out[t.DatasetId] = t
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Page 训练任务分页:创建时间倒序
|
||||
func (d *modelTrainingDao) Page(ctx context.Context, page, size int) ([]*entity.ModelTraining, int64, error) {
|
||||
base := g.DB().Model(consts.TableTraining).Ctx(ctx)
|
||||
total, err := base.Count()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []*entity.ModelTraining
|
||||
err = base.OrderDesc("id").Limit((page-1)*size, size).Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return []*entity.ModelTraining{}, int64(total), nil
|
||||
}
|
||||
return nil, 0, err
|
||||
}
|
||||
return list, int64(total), nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"observer-server/biz/consts"
|
||||
"observer-server/biz/model/entity"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
// ModelVersion 模型版本表 DAO:每数据集独立版本序列;表小不做查询缓存。
|
||||
type modelVersionDao struct{}
|
||||
|
||||
var ModelVersion = &modelVersionDao{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS model_version (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dataset_id INTEGER NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
training_id INTEGER,
|
||||
artifact_file TEXT,
|
||||
metrics TEXT,
|
||||
labels TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
is_latest INTEGER NOT NULL DEFAULT 0,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (dataset_id, version)
|
||||
)`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert 插入模型版本(is_latest 由 service 先置 0 再插新行置 1)
|
||||
func (d *modelVersionDao) Insert(ctx context.Context, m *entity.ModelVersion) (int64, error) {
|
||||
res, err := g.DB().Model(consts.TableModelVersion).Ctx(ctx).Data(g.Map{
|
||||
"dataset_id": m.DatasetId,
|
||||
"version": m.Version,
|
||||
"training_id": m.TrainingId,
|
||||
"artifact_file": m.ArtifactFile,
|
||||
"metrics": m.Metrics,
|
||||
"labels": m.Labels,
|
||||
"sha256": m.Sha256,
|
||||
"size_bytes": m.SizeBytes,
|
||||
"is_latest": m.IsLatest,
|
||||
"notes": m.Notes,
|
||||
"created_at": m.CreatedAt,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// ClearLatest 某数据集所有版本置 is_latest=0(发布前调用)
|
||||
func (d *modelVersionDao) ClearLatest(ctx context.Context, datasetId int64) error {
|
||||
_, err := g.DB().Model(consts.TableModelVersion).Ctx(ctx).
|
||||
Where("dataset_id", datasetId).Data(g.Map{"is_latest": 0}).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteByDataset 删除某数据集全部版本记录(删数据集级联)
|
||||
func (d *modelVersionDao) DeleteByDataset(ctx context.Context, datasetId int64) error {
|
||||
_, err := g.DB().Model(consts.TableModelVersion).Ctx(ctx).Where("dataset_id", datasetId).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
// ListAllLatest 全部数据集的当前生效版本(模型目录/热更新目录)
|
||||
func (d *modelVersionDao) ListAllLatest(ctx context.Context) ([]*entity.ModelVersion, error) {
|
||||
var list []*entity.ModelVersion
|
||||
err := g.DB().Model(consts.TableModelVersion).Ctx(ctx).
|
||||
Where("is_latest", 1).OrderAsc("dataset_id").Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return []*entity.ModelVersion{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// PageByDataset 某数据集版本分页:发布时间倒序
|
||||
func (d *modelVersionDao) PageByDataset(ctx context.Context, datasetId int64, page, size int) ([]*entity.ModelVersion, int64, error) {
|
||||
base := g.DB().Model(consts.TableModelVersion).Ctx(ctx).Where("dataset_id", datasetId)
|
||||
total, err := base.Count()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []*entity.ModelVersion
|
||||
err = base.OrderDesc("id").Limit((page-1)*size, size).Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return []*entity.ModelVersion{}, int64(total), nil
|
||||
}
|
||||
return nil, 0, err
|
||||
}
|
||||
return list, int64(total), nil
|
||||
}
|
||||
|
||||
// DeleteById 删除版本记录
|
||||
func (d *modelVersionDao) DeleteById(ctx context.Context, id int64) error {
|
||||
_, err := g.DB().Model(consts.TableModelVersion).Ctx(ctx).Where("id", id).Delete()
|
||||
return err
|
||||
}
|
||||
@@ -12,8 +12,11 @@ type AppUpdateReq struct {
|
||||
}
|
||||
|
||||
// AppUpdateRes 服务器最新版本记录(无记录时全字段零值,客户端视为无需更新);
|
||||
// 检测到新版本即强制更新,无普通/强制之分
|
||||
// 检测到新版本即强制更新,无普通/强制之分。
|
||||
// Models 为模型热更新目录(全部数据集当前生效模型,与 GET /api/v1/models 同构);
|
||||
// 无发布模型时不返回该字段(旧 App 忽略、新 App 兼容旧服务器)。
|
||||
type AppUpdateRes struct {
|
||||
Version string `json:"version"`
|
||||
Notes string `json:"notes"`
|
||||
Version string `json:"version"`
|
||||
Notes string `json:"notes"`
|
||||
Models []*ModelCatalogItem `json:"models,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// 模型训练体系接口(数据集 → 标注 → 训练 → 模型版本),组前缀 /api/v1/admin。
|
||||
// 图片/标注/模型文件均在服务器磁盘(app.datasetDir),DB 只存元数据。
|
||||
// 图片访问与 zip 导出为直接写响应体场景,由 controller 以 *ghttp.Request 方法实现
|
||||
// (见 controller/admin.go 的 Image/ExportDataset 方法)。
|
||||
|
||||
// ---------- 数据集管理 ----------
|
||||
|
||||
// AdminDatasetListReq 数据集列表(创建时间倒序)
|
||||
type AdminDatasetListReq struct {
|
||||
g.Meta `path:"/datasets" method:"get" summary:"数据集列表" tags:"管理端"`
|
||||
Keyword string `json:"keyword" v:"length:0,50" dc:"名称模糊匹配"`
|
||||
Page int `json:"page" v:"integer|min:1" dc:"页码,默认 1"`
|
||||
Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"`
|
||||
}
|
||||
|
||||
// AdminDatasetItem 数据集条目(卡片展示用;AI 端点/训练机 SSH 走 config.yml 全局配置(localAi / training.ssh))
|
||||
type AdminDatasetItem struct {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"` // manual | ai
|
||||
ImageCount int64 `json:"imageCount"`
|
||||
LabeledCount int64 `json:"labeledCount"`
|
||||
Status string `json:"status"` // building | labeled | synced
|
||||
Cover string `json:"cover"` // 封面文件名
|
||||
Description string `json:"description"` // 描述
|
||||
TrainingId int64 `json:"trainingId"` // 最新训练记录 id(发布/详情用)
|
||||
TrainingStatus string `json:"trainingStatus"` // 最新训练记录状态 running|success|failed|空
|
||||
TrainingCurrentEpoch int `json:"trainingCurrentEpoch"`
|
||||
TrainingTotalEpochs int `json:"trainingTotalEpochs"`
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
UpdatedAt *gtime.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type AdminDatasetListRes struct {
|
||||
Total int64 `json:"total"`
|
||||
List []*AdminDatasetItem `json:"list"`
|
||||
}
|
||||
|
||||
// AdminDatasetUpdateReq 更新数据集展示配置(空值字段不覆盖原值,保留现有配置;
|
||||
// AI 端点/训练机 SSH 走 config.yml 全局配置(localAi / training.ssh))
|
||||
type AdminDatasetUpdateReq struct {
|
||||
g.Meta `path:"/datasets/update" method:"post" summary:"更新数据集配置" tags:"管理端"`
|
||||
Id int64 `json:"id" v:"required|min:1" dc:"数据集 id"`
|
||||
Description string `json:"description" v:"length:0,500" dc:"描述(非空才更新)"`
|
||||
Cover string `json:"cover" v:"length:0,200" dc:"封面文件名(非空才更新)"`
|
||||
}
|
||||
|
||||
type AdminDatasetUpdateRes struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
|
||||
// AdminDatasetCreateReq 新建数据集(名称同时是磁盘目录名:中文/字母/数字/下划线/短横线,唯一)
|
||||
type AdminDatasetCreateReq struct {
|
||||
g.Meta `path:"/datasets" method:"post" summary:"新建数据集" tags:"管理端"`
|
||||
Name string `json:"name" v:"required|regex:^[a-zA-Z0-9_一-龥-]+$|length:1,50" dc:"数据集名称(同是目录名,唯一)"`
|
||||
Source string `json:"source" v:"required|in:manual,ai" dc:"图片来源 manual|ai"`
|
||||
}
|
||||
|
||||
type AdminDatasetCreateRes struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
|
||||
// AdminDatasetDeleteReq 删除数据集(删除图片文件+标注目录+记录;AI 生成图为付费资产,前端须带确认文案)
|
||||
type AdminDatasetDeleteReq struct {
|
||||
g.Meta `path:"/datasets/delete" method:"post" summary:"删除数据集" tags:"管理端"`
|
||||
Id int64 `json:"id" v:"required|min:1" dc:"数据集 id"`
|
||||
}
|
||||
|
||||
type AdminDatasetDeleteRes struct{}
|
||||
|
||||
// AdminDatasetUploadReq 上传图片(multipart 多文件;重名跳过并计数)
|
||||
type AdminDatasetUploadReq struct {
|
||||
g.Meta `path:"/datasets/upload" method:"post" summary:"上传图片" tags:"管理端" mime:"multipart/form-data"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
Files []*ghttp.UploadFile `json:"files" dc:"图片文件(jpg/jpeg/png,多选)"`
|
||||
}
|
||||
|
||||
type AdminDatasetUploadRes struct {
|
||||
Added int `json:"added"`
|
||||
Skipped int `json:"skipped"` // 重名跳过数
|
||||
}
|
||||
|
||||
// AdminDatasetGenerateReq AI 生成图片(同步执行;prompt 禁止含目标位置描述,前端模板+文案约束)
|
||||
type AdminDatasetGenerateReq struct {
|
||||
g.Meta `path:"/datasets/generate" method:"post" summary:"AI 生成图片" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
Prompt string `json:"prompt" v:"required|length:5,500" dc:"生成提示词(禁止含位置描述)"`
|
||||
Count int `json:"count" v:"required|integer|min:1|max:8" dc:"生成张数 1-8"`
|
||||
Size string `json:"size" v:"required|in:1152x2048,704x704" dc:"图片尺寸"`
|
||||
}
|
||||
|
||||
type AdminDatasetGenerateRes struct {
|
||||
Generated int `json:"generated"` // 成功张数(失败时可能 < count,已成功的图保留)
|
||||
}
|
||||
|
||||
// AdminDatasetImagesReq 数据集图片列表(标注工作台/网格预览用)
|
||||
type AdminDatasetImagesReq struct {
|
||||
g.Meta `path:"/datasets/images" method:"get" summary:"数据集图片列表" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
}
|
||||
|
||||
// AdminImageItem 图片条目(url 为管理端图片访问地址,前端拼 apiBaseUrl 使用)
|
||||
type AdminImageItem struct {
|
||||
Id int64 `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Source string `json:"source"` // manual | ai
|
||||
Prompt string `json:"prompt"`
|
||||
Url string `json:"url"`
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type AdminDatasetImagesRes struct {
|
||||
List []*AdminImageItem `json:"list"`
|
||||
}
|
||||
|
||||
// AdminDatasetImagesDeleteReq 删除图片(删文件+删记录;付费资产,前端须带确认文案)
|
||||
type AdminDatasetImagesDeleteReq struct {
|
||||
g.Meta `path:"/datasets/images/delete" method:"post" summary:"删除图片" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
Ids []int64 `json:"ids" v:"required|min-length:1" dc:"图片记录 id 列表"`
|
||||
}
|
||||
|
||||
type AdminDatasetImagesDeleteRes struct{}
|
||||
|
||||
// AdminDatasetImageReq 图片访问(直写响应体,例外场景:controller 经 service 校验归属后输出二进制)
|
||||
type AdminDatasetImageReq struct {
|
||||
g.Meta `path:"/datasets/image" method:"get" summary:"图片访问" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
Filename string `json:"filename" v:"required" dc:"图片文件名"`
|
||||
}
|
||||
|
||||
type AdminDatasetImageRes struct{}
|
||||
|
||||
// AdminDatasetExportReq 数据集 zip 导出(直写响应体下载;标注衔接与备份用)
|
||||
type AdminDatasetExportReq struct {
|
||||
g.Meta `path:"/datasets/export" method:"get" summary:"导出数据集 zip" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
}
|
||||
|
||||
type AdminDatasetExportRes struct{}
|
||||
|
||||
// AdminDatasetCoverUploadReq 上传数据集封面(multipart 单文件;覆盖旧封面,存图片目录 cover<ext>)
|
||||
type AdminDatasetCoverUploadReq struct {
|
||||
g.Meta `path:"/datasets/cover" method:"post" summary:"上传数据集封面" tags:"管理端" mime:"multipart/form-data"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
File *ghttp.UploadFile `json:"file" dc:"封面图片(jpg/jpeg/png,≤2MB)"`
|
||||
}
|
||||
|
||||
type AdminDatasetCoverUploadRes struct{}
|
||||
|
||||
// AdminDatasetCoverReq 封面访问(直写响应体,与 Image 同模式)
|
||||
type AdminDatasetCoverReq struct {
|
||||
g.Meta `path:"/datasets/cover" method:"get" summary:"数据集封面访问" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
}
|
||||
|
||||
type AdminDatasetCoverRes struct{}
|
||||
|
||||
// AdminDatasetCoverDeleteReq 删除数据集封面(删文件+清字段;列表卡片恢复占位图)
|
||||
type AdminDatasetCoverDeleteReq struct {
|
||||
g.Meta `path:"/datasets/cover/delete" method:"post" summary:"删除数据集封面" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
}
|
||||
|
||||
type AdminDatasetCoverDeleteRes struct{}
|
||||
|
||||
// ---------- 训练编排 ----------
|
||||
|
||||
// AdminTrainingListReq 训练任务列表(创建时间倒序)
|
||||
type AdminTrainingListReq struct {
|
||||
g.Meta `path:"/trainings" method:"get" summary:"训练任务列表" tags:"管理端"`
|
||||
Status string `json:"status" v:"in:running,success,failed" dc:"状态筛选"`
|
||||
Page int `json:"page" v:"integer|min:1" dc:"页码,默认 1"`
|
||||
Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"`
|
||||
}
|
||||
|
||||
// AdminTrainingItem 训练任务条目(列表不含日志尾部,详情接口返回)
|
||||
type AdminTrainingItem struct {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DatasetId int64 `json:"datasetId"`
|
||||
DatasetName string `json:"datasetName"`
|
||||
Status string `json:"status"` // running | success | failed
|
||||
Imgsz int `json:"imgsz"`
|
||||
Epochs int `json:"epochs"`
|
||||
Batch int `json:"batch"`
|
||||
Device string `json:"device"`
|
||||
CurrentEpoch int `json:"currentEpoch"`
|
||||
TotalEpochs int `json:"totalEpochs"`
|
||||
Metrics string `json:"metrics"`
|
||||
Error string `json:"error"`
|
||||
StartedAt *gtime.Time `json:"startedAt"`
|
||||
FinishedAt *gtime.Time `json:"finishedAt"`
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type AdminTrainingListRes struct {
|
||||
Total int64 `json:"total"`
|
||||
List []*AdminTrainingItem `json:"list"`
|
||||
}
|
||||
|
||||
// AdminTrainingStartReq 发起训练(并发度 1:已有 running 任务时拒绝)
|
||||
type AdminTrainingStartReq struct {
|
||||
g.Meta `path:"/trainings" method:"post" summary:"发起训练" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
Name string `json:"name" v:"required|length:1,50" dc:"任务名称"`
|
||||
Imgsz int `json:"imgsz" v:"integer|min:64|max:2048" dc:"输入尺寸,默认 704"`
|
||||
Epochs int `json:"epochs" v:"integer|min:1|max:1000" dc:"训练轮数,默认 150"`
|
||||
Batch int `json:"batch" v:"integer|min:1|max:128" dc:"批大小,默认 16"`
|
||||
Device string `json:"device" v:"length:0,16" dc:"设备,默认 0"`
|
||||
}
|
||||
|
||||
type AdminTrainingStartRes struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
|
||||
// AdminTrainingDetailReq 训练任务详情(含日志尾部)
|
||||
type AdminTrainingDetailReq struct {
|
||||
g.Meta `path:"/trainings/detail" method:"get" summary:"训练任务详情" tags:"管理端"`
|
||||
Id int64 `json:"id" v:"required|min:1" dc:"训练任务 id"`
|
||||
}
|
||||
|
||||
// AdminTrainingDetailRes 详情 = 条目 + 日志尾部
|
||||
type AdminTrainingDetailRes struct {
|
||||
AdminTrainingItem
|
||||
LogTail string `json:"logTail"`
|
||||
}
|
||||
|
||||
// AdminTrainingCancelReq 取消训练(杀进程,任务置 failed)
|
||||
type AdminTrainingCancelReq struct {
|
||||
g.Meta `path:"/trainings/cancel" method:"post" summary:"取消训练" tags:"管理端"`
|
||||
Id int64 `json:"id" v:"required|min:1" dc:"训练任务 id"`
|
||||
}
|
||||
|
||||
type AdminTrainingCancelRes struct{}
|
||||
|
||||
// AdminTrainingPublishReq 发布模型版本(仅 success 任务;按数据集版本号 m<major>.<minor>.<patch> 自增)
|
||||
type AdminTrainingPublishReq struct {
|
||||
g.Meta `path:"/trainings/publish" method:"post" summary:"发布模型版本" tags:"管理端"`
|
||||
Id int64 `json:"id" v:"required|min:1" dc:"训练任务 id"`
|
||||
}
|
||||
|
||||
type AdminTrainingPublishRes struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// ---------- 模型版本 ----------
|
||||
|
||||
// ---------- 预标注 ----------
|
||||
|
||||
// AdminLabelTaskListReq 预标注任务列表(创建时间倒序)
|
||||
type AdminLabelTaskListReq struct {
|
||||
g.Meta `path:"/label-tasks" method:"get" summary:"预标注任务列表" tags:"管理端"`
|
||||
Page int `json:"page" v:"integer|min:1" dc:"页码,默认 1"`
|
||||
Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"`
|
||||
}
|
||||
|
||||
type AdminLabelTaskItem struct {
|
||||
Id int64 `json:"id"`
|
||||
DatasetId int64 `json:"datasetId"`
|
||||
DatasetName string `json:"datasetName"`
|
||||
Status string `json:"status"` // running | done
|
||||
Total int `json:"total"`
|
||||
Done int `json:"done"`
|
||||
Error string `json:"error"`
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
FinishedAt *gtime.Time `json:"finishedAt"`
|
||||
}
|
||||
|
||||
type AdminLabelTaskListRes struct {
|
||||
Total int64 `json:"total"`
|
||||
List []*AdminLabelTaskItem `json:"list"`
|
||||
}
|
||||
|
||||
// AdminLabelTaskStartReq 发起预标注(RF-DETR 扫描,候选框合并入 datasets/<name>/boxes.json)
|
||||
type AdminLabelTaskStartReq struct {
|
||||
g.Meta `path:"/label-tasks" method:"post" summary:"发起预标注" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
Filenames []string `json:"filenames" v:"length:0,500" dc:"选中图片列表(多选批量);缺省=全量扫描"`
|
||||
}
|
||||
|
||||
type AdminLabelTaskStartRes struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
|
||||
// AdminLabelTaskDetailReq 预标注任务详情(每张图候选框 + 已确认标注,供工作台 canvas 叠框)
|
||||
type AdminLabelTaskDetailReq struct {
|
||||
g.Meta `path:"/label-tasks/detail" method:"get" summary:"预标注任务详情" tags:"管理端"`
|
||||
Id int64 `json:"id" v:"required|min:1" dc:"标注任务 id"`
|
||||
}
|
||||
|
||||
// AdminLabelBox 单框(归一化 xywh,坐标 0~1)
|
||||
type AdminLabelBox struct {
|
||||
Cx float64 `json:"cx"`
|
||||
Cy float64 `json:"cy"`
|
||||
W float64 `json:"w"`
|
||||
H float64 `json:"h"`
|
||||
Confidence float64 `json:"confidence"` // 人工框为 1
|
||||
Class int `json:"class"` // 0 确认 | 1 疑似
|
||||
}
|
||||
|
||||
// AdminLabelImageItem 工作台单张图:全部标注(labels_json,AI 自动标注与人工框同层,人工可修改/清理)
|
||||
type AdminLabelImageItem struct {
|
||||
Filename string `json:"filename"`
|
||||
Url string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Boxes []*AdminLabelBox `json:"boxes"`
|
||||
Labeled bool `json:"labeled"` // 有标注(AI 自动标注或人工保存)
|
||||
}
|
||||
|
||||
type AdminLabelTaskDetailRes struct {
|
||||
TaskId int64 `json:"taskId"`
|
||||
DatasetId int64 `json:"datasetId"`
|
||||
DatasetName string `json:"datasetName"`
|
||||
Status string `json:"status"`
|
||||
Total int `json:"total"`
|
||||
Done int `json:"done"`
|
||||
Error string `json:"error"`
|
||||
Images []*AdminLabelImageItem `json:"images"`
|
||||
}
|
||||
|
||||
// AdminLabelWorkbenchReq 标注工作台数据(无历史任务时直接用数据集图片 + 已确认标注,
|
||||
// 候选框读数据集目录 boxes.json)
|
||||
type AdminLabelWorkbenchReq struct {
|
||||
g.Meta `path:"/label-workbench" method:"get" summary:"标注工作台数据" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
}
|
||||
|
||||
type AdminLabelWorkbenchRes struct {
|
||||
DatasetId int64 `json:"datasetId"`
|
||||
DatasetName string `json:"datasetName"`
|
||||
Images []*AdminLabelImageItem `json:"images"`
|
||||
}
|
||||
|
||||
// AdminLabelSaveReq 保存单张图标注(工作台「保存本张」;覆写 dataset_image.labels_json)
|
||||
type AdminLabelSaveReq struct {
|
||||
g.Meta `path:"/label-tasks/save" method:"post" summary:"保存图片标注" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
Filename string `json:"filename" v:"required|length:1,200" dc:"图片文件名"`
|
||||
Boxes []*AdminLabelBox `json:"boxes" dc:"确认后的框列表(空=清空标注)"`
|
||||
}
|
||||
|
||||
type AdminLabelSaveRes struct {
|
||||
LabeledCount int64 `json:"labeledCount"` // 该数据集当前已标注图数
|
||||
}
|
||||
|
||||
// ---------- 客户端模型目录 ----------
|
||||
|
||||
// ModelCatalogReq 模型目录(客户端登录态):全部数据集的当前生效模型
|
||||
type ModelCatalogReq struct {
|
||||
g.Meta `path:"/models" method:"get" summary:"模型目录" tags:"客户端"`
|
||||
}
|
||||
|
||||
// ModelCatalogItem 客户端模型条目(App 按需下载,多模型并行推理合并)
|
||||
type ModelCatalogItem struct {
|
||||
DatasetId int64 `json:"datasetId"`
|
||||
DatasetName string `json:"datasetName"`
|
||||
Version string `json:"version"`
|
||||
Labels []string `json:"labels"` // 类别名,App 推理结果展示用
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Sha256 string `json:"sha256"`
|
||||
Notes string `json:"notes"`
|
||||
PublishedAt *gtime.Time `json:"publishedAt"`
|
||||
DownloadUrl string `json:"downloadUrl"` // /download/models/<name>/latest.tflite
|
||||
}
|
||||
|
||||
type ModelCatalogRes struct {
|
||||
List []*ModelCatalogItem `json:"list"`
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
// Dataset 训练数据集:图片文件在 app.datasetDir/datasets/<name>/(DB 只存元数据 + 标注 labels_json)。
|
||||
// 每数据集训练一个模型。
|
||||
type Dataset struct {
|
||||
Id int64 `json:"id" orm:"id" description:"自增主键"`
|
||||
Name string `json:"name" orm:"name" description:"数据集名(≤50字,同是目录名,唯一)"`
|
||||
Source string `json:"source" orm:"source" description:"manual|ai"`
|
||||
ImageCount int64 `json:"imageCount" orm:"image_count" description:"图片数(冗余计数)"`
|
||||
LabeledCount int64 `json:"labeledCount" orm:"labeled_count" description:"已标注数"`
|
||||
Status string `json:"status" orm:"status" description:"building|labeled|synced"`
|
||||
Cover string `json:"cover" orm:"cover" description:"封面文件名(卡片展示)"`
|
||||
Description string `json:"description" orm:"description" description:"描述(卡片展示)"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
|
||||
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
// DatasetImage 数据集图片:文件在 datasets/<数据集名>/<filename>,
|
||||
// AI 生成图记录 prompt(付费资产,追溯用);标注为 JSON 数组存本表
|
||||
// (元素同 dto.AdminLabelBox:class/cx/cy/w/h/confidence,YOLO 归一化)。
|
||||
type DatasetImage struct {
|
||||
Id int64 `json:"id" orm:"id" description:"自增主键"`
|
||||
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"`
|
||||
Filename string `json:"filename" orm:"filename" description:"唯一文件名(防重名加时间戳后缀)"`
|
||||
Source string `json:"source" orm:"source" description:"manual|ai"`
|
||||
Prompt string `json:"prompt" orm:"prompt" description:"AI 生成图提示词"`
|
||||
LabelsJson string `json:"labelsJson" orm:"labels_json" description:"标注 JSON 数组(AI 自动标注与人工标注同存,null/''/'[]'=未标注)"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
// LabelTask 标注任务:RF-DETR 预标注批量推理进度;候选框存
|
||||
// datasets/<数据集名>/boxes.json(YOLO 归一化 xywh + 置信度 + 建议类别)。
|
||||
// Filenames 为选中图片列表(JSON 数组串;空 = 全量扫描)。
|
||||
type LabelTask struct {
|
||||
Id int64 `json:"id" orm:"id" description:"自增主键"`
|
||||
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"`
|
||||
Status string `json:"status" orm:"status" description:"running|done"`
|
||||
Total int `json:"total" orm:"total" description:"待标注图片数"`
|
||||
Done int `json:"done" orm:"done" description:"已处理数"`
|
||||
BoxesFile string `json:"boxesFile" orm:"boxes_file" description:"候选框 JSON 相对路径"`
|
||||
Filenames string `json:"filenames" orm:"filenames" description:"选中图片JSON数组(空=全量)"`
|
||||
Error string `json:"error" orm:"error" description:"失败原因(部分失败/全部失败)"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
|
||||
FinishedAt *gtime.Time `json:"finishedAt" orm:"finished_at" description:"完成时间"`
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
// ModelTraining 训练任务:runner 启动训练进程,轮询解析 epoch 日志更新进度/指标,
|
||||
// 日志尾部截断存 log_tail;pid 用于取消与存活探测。
|
||||
type ModelTraining struct {
|
||||
Id int64 `json:"id" orm:"id" description:"自增主键"`
|
||||
Name string `json:"name" orm:"name" description:"任务名"`
|
||||
Status string `json:"status" orm:"status" description:"running|success|failed"`
|
||||
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"来源数据集"`
|
||||
Imgsz int `json:"imgsz" orm:"imgsz" description:"训练分辨率"`
|
||||
Epochs int `json:"epochs" orm:"epochs" description:"目标轮数"`
|
||||
Batch int `json:"batch" orm:"batch" description:"batch size"`
|
||||
Device string `json:"device" orm:"device" description:"训练设备"`
|
||||
CurrentEpoch int `json:"currentEpoch" orm:"current_epoch" description:"当前轮数"`
|
||||
TotalEpochs int `json:"totalEpochs" orm:"total_epochs" description:"实际总轮数(完成时写)"`
|
||||
Metrics string `json:"metrics" orm:"metrics" description:"JSON {p,r,map50}"`
|
||||
LogTail string `json:"logTail" orm:"log_tail" description:"日志尾部(截断)"`
|
||||
Pid int `json:"pid" orm:"pid" description:"训练进程 pid"`
|
||||
Error string `json:"error" orm:"error" description:"失败原因"`
|
||||
StartedAt *gtime.Time `json:"startedAt" orm:"started_at" description:"开始时间"`
|
||||
FinishedAt *gtime.Time `json:"finishedAt" orm:"finished_at" description:"结束时间"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
// ModelVersion 模型版本:每数据集独立版本序列(m1.0.0 递增,UNIQUE(dataset_id, version))。
|
||||
// 模型文件不落表:发布即写当前生效副本 models/<数据集>/latest.tflite(无存档回退机制);
|
||||
// labels 为类别名数组 JSON(App 多模型合并推理依赖)。
|
||||
type ModelVersion struct {
|
||||
Id int64 `json:"id" orm:"id" description:"自增主键"`
|
||||
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"`
|
||||
Version string `json:"version" orm:"version" description:"m1.0.0 递增"`
|
||||
TrainingId int64 `json:"trainingId" orm:"training_id" description:"来源训练任务"`
|
||||
ArtifactFile string `json:"artifactFile" orm:"artifact_file" description:"归档 zip 相对路径"`
|
||||
Metrics string `json:"metrics" orm:"metrics" description:"JSON 指标"`
|
||||
Labels string `json:"labels" orm:"labels" description:"JSON 类别名数组"`
|
||||
Sha256 string `json:"sha256" orm:"sha256" description:"tflite 文件校验"`
|
||||
SizeBytes int64 `json:"sizeBytes" orm:"size_bytes" description:"文件大小"`
|
||||
IsLatest int `json:"isLatest" orm:"is_latest" description:"1=该数据集当前生效"`
|
||||
Notes string `json:"notes" orm:"notes" description:"备注"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"发布时间"`
|
||||
}
|
||||
@@ -27,19 +27,26 @@ var apkNameRe = regexp.MustCompile(`^observer-(\d+\.\d+\.\d+)\.apk$`)
|
||||
var AppVersion = &appVersionService{}
|
||||
|
||||
// GetUpdate 客户端版本更新检查(公开接口,无需登录态;仅 Android 调用):
|
||||
// 返回最新一条版本记录,无记录时返回空结构(客户端视为无需更新)。
|
||||
// 返回最新一条版本记录 + 模型热更新目录(多模型体系,与 GET /api/v1/models 同构)。
|
||||
// 无版本记录时返回空结构(客户端视为无需更新);无发布模型时 models 不返回。
|
||||
func (s *appVersionService) GetUpdate(ctx context.Context, req *dto.AppUpdateReq) (*dto.AppUpdateRes, error) {
|
||||
res := &dto.AppUpdateRes{}
|
||||
latest, err := dao.AppVersion.Latest(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if latest == nil {
|
||||
return &dto.AppUpdateRes{}, nil
|
||||
if latest != nil {
|
||||
res.Version = latest.Version
|
||||
res.Notes = latest.Notes
|
||||
}
|
||||
return &dto.AppUpdateRes{
|
||||
Version: latest.Version,
|
||||
Notes: latest.Notes,
|
||||
}, nil
|
||||
catalog, err := ModelVersion.ClientCatalog(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(catalog.List) > 0 {
|
||||
res.Models = catalog.List
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// AdminListVersions 管理端版本记录分页(按下发时间倒序)
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"observer-server/biz/consts"
|
||||
"observer-server/biz/dao"
|
||||
"observer-server/biz/model/dto"
|
||||
"observer-server/biz/model/entity"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
// datasetService 数据集业务:管理端「数据集管理」模块。
|
||||
// 图片文件在 app.datasetDir/datasets/<name>/(平铺、文件名唯一),DB 只存元数据;
|
||||
// AI 生成为付费资产,删除类接口由前端带确认文案(后端不额外拦截)。
|
||||
type datasetService struct{}
|
||||
|
||||
var Dataset = &datasetService{}
|
||||
|
||||
// imgExts 允许上传/保存的图片扩展名
|
||||
var imgExts = map[string]bool{".jpg": true, ".jpeg": true, ".png": true}
|
||||
|
||||
// AdminListDatasets 数据集分页列表(卡片展示:封面/描述/训练配置标记 + 最新训练状态聚合)
|
||||
func (s *datasetService) AdminListDatasets(ctx context.Context, req *dto.AdminDatasetListReq) (*dto.AdminDatasetListRes, error) {
|
||||
page, size := common.NormalizePage(req.Page, req.Size)
|
||||
var list []*entity.Dataset
|
||||
var total int64
|
||||
var err error
|
||||
if req.Keyword != "" {
|
||||
list, total, err = dao.Dataset.PageByKeyword(ctx, req.Keyword, page, size)
|
||||
} else {
|
||||
list, total, err = dao.Dataset.Page(ctx, page, size)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]int64, 0, len(list))
|
||||
for _, v := range list {
|
||||
ids = append(ids, v.Id)
|
||||
}
|
||||
latest, err := dao.Training.LatestByDatasets(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]*dto.AdminDatasetItem, 0, len(list))
|
||||
for _, v := range list {
|
||||
item := &dto.AdminDatasetItem{
|
||||
Id: v.Id,
|
||||
Name: v.Name,
|
||||
Source: v.Source,
|
||||
ImageCount: v.ImageCount,
|
||||
LabeledCount: v.LabeledCount,
|
||||
Status: v.Status,
|
||||
Cover: v.Cover,
|
||||
Description: v.Description,
|
||||
CreatedAt: v.CreatedAt,
|
||||
UpdatedAt: v.UpdatedAt,
|
||||
}
|
||||
if t, ok := latest[v.Id]; ok {
|
||||
item.TrainingId = t.Id
|
||||
item.TrainingStatus = t.Status
|
||||
item.TrainingCurrentEpoch = t.CurrentEpoch
|
||||
item.TrainingTotalEpochs = t.TotalEpochs
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return &dto.AdminDatasetListRes{Total: total, List: items}, nil
|
||||
}
|
||||
|
||||
// AdminUpdateDataset 更新数据集展示配置(空值字段不覆盖;封面/描述。
|
||||
// AI 端点/训练机 SSH 走 config.yml 全局配置(localAi / training.ssh))
|
||||
func (s *datasetService) AdminUpdateDataset(ctx context.Context, req *dto.AdminDatasetUpdateReq) (*dto.AdminDatasetUpdateRes, error) {
|
||||
existing, err := dao.Dataset.GetById(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
// 封面须属于该数据集图片(防伪造文件名指向任意文件)
|
||||
if req.Cover != "" {
|
||||
img, err := dao.DatasetImage.GetByFilename(ctx, req.Id, req.Cover)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if img == nil {
|
||||
return nil, gerror.NewCode(common.CodeImageNotFound)
|
||||
}
|
||||
}
|
||||
if err := dao.Dataset.UpdateConfigs(ctx, req.Id, &entity.Dataset{
|
||||
Cover: req.Cover,
|
||||
Description: req.Description,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.AdminDatasetUpdateRes{Id: req.Id}, nil
|
||||
}
|
||||
|
||||
// AdminUploadCover 上传数据集封面:解码校验图片 → 转 jpg(Quality 92)→ UUID 命名落盘,
|
||||
// 删除旧封面文件(UUID 互不覆盖,但 DB 指向切换)→ 更新 cover 字段。
|
||||
func (s *datasetService) AdminUploadCover(ctx context.Context, req *dto.AdminDatasetCoverUploadReq) (*dto.AdminDatasetCoverUploadRes, error) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
if req.File == nil {
|
||||
return nil, gerror.New("缺少封面文件")
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(req.File.Filename))
|
||||
if !imgExts[ext] {
|
||||
return nil, gerror.New("封面仅支持 jpg/jpeg/png")
|
||||
}
|
||||
if req.File.Size > 2*1024*1024 {
|
||||
return nil, gerror.New("封面不能超过 2MB")
|
||||
}
|
||||
raw, err := req.File.Open()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "读取封面文件失败")
|
||||
}
|
||||
defer func() { _ = raw.Close() }()
|
||||
img, _, err := image.Decode(raw)
|
||||
if err != nil {
|
||||
return nil, gerror.New("封面文件不是有效图片")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 92}); err != nil {
|
||||
return nil, gerror.Wrap(err, "封面转 jpg 失败")
|
||||
}
|
||||
dir := common.DatasetImagesDir(ctx, dataset.Name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, gerror.Wrap(err, "创建图片目录失败")
|
||||
}
|
||||
if dataset.Cover != "" {
|
||||
_ = os.Remove(filepath.Join(dir, filepath.Base(dataset.Cover)))
|
||||
}
|
||||
name := common.UuidV4() + ".jpg"
|
||||
if err := common.WriteFileAtomic(filepath.Join(dir, name), buf.Bytes()); err != nil {
|
||||
return nil, gerror.Wrap(err, "保存封面失败")
|
||||
}
|
||||
if err := dao.Dataset.UpdateConfigs(ctx, dataset.Id, &entity.Dataset{Cover: name}); err != nil {
|
||||
_ = os.Remove(filepath.Join(dir, name))
|
||||
return nil, err
|
||||
}
|
||||
return &dto.AdminDatasetCoverUploadRes{}, nil
|
||||
}
|
||||
|
||||
// coverNameRe 封面命名规范:UUIDv4 + .jpg(固定命名 cover* 为历史遗留,迁移见 MigrateLegacyCovers)
|
||||
var coverNameRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.jpg$`)
|
||||
|
||||
// isCoverName 校验 DB cover 值是否符合 UUID jpg 规范(防御库内被写入任意文件名)
|
||||
func isCoverName(name string) bool {
|
||||
return coverNameRe.MatchString(filepath.Base(name))
|
||||
}
|
||||
|
||||
// CoverFile 封面文件定位(校验归属;controller 直写响应体输出)
|
||||
func (s *datasetService) CoverFile(ctx context.Context, datasetId int64) (string, error) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, datasetId)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if dataset == nil {
|
||||
return "", gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
if dataset.Cover == "" {
|
||||
return "", gerror.NewCode(common.CodeImageNotFound)
|
||||
}
|
||||
name := filepath.Base(dataset.Cover)
|
||||
if !isCoverName(name) {
|
||||
return "", gerror.NewCode(common.CodeImageNotFound)
|
||||
}
|
||||
path := filepath.Join(common.DatasetImagesDir(ctx, dataset.Name), name)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return "", gerror.NewCode(common.CodeImageNotFound)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// AdminDeleteCover 删除数据集封面:删文件 + 清 cover 字段(列表卡片恢复占位图)
|
||||
func (s *datasetService) AdminDeleteCover(ctx context.Context, req *dto.AdminDatasetCoverDeleteReq) (*dto.AdminDatasetCoverDeleteRes, error) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
if dataset.Cover != "" && isCoverName(dataset.Cover) {
|
||||
_ = os.Remove(filepath.Join(common.DatasetImagesDir(ctx, dataset.Name), filepath.Base(dataset.Cover)))
|
||||
}
|
||||
if err := dao.Dataset.ClearCover(ctx, dataset.Id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.AdminDatasetCoverDeleteRes{}, nil
|
||||
}
|
||||
|
||||
// MigrateLegacyCovers 存量封面迁移:cover 非空且不符合 UUID jpg 规范(历史 cover.jpg/cover.png 等)
|
||||
// → 重命名为 <uuid>.jpg(保留原图字节)并更新 cover 字段。幂等:已符合规范的行跳过。
|
||||
func (s *datasetService) MigrateLegacyCovers(ctx context.Context) error {
|
||||
list, err := dao.Dataset.ListAll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
migrated := 0
|
||||
for _, d := range list {
|
||||
if d.Cover == "" || isCoverName(d.Cover) {
|
||||
continue
|
||||
}
|
||||
oldName := filepath.Base(d.Cover)
|
||||
src := filepath.Join(common.DatasetImagesDir(ctx, d.Name), oldName)
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "数据集 %s 封面迁移跳过(文件不存在: %s): %v", d.Name, src, err)
|
||||
continue
|
||||
}
|
||||
newName := common.UuidV4() + ".jpg"
|
||||
if err := common.WriteFileAtomic(filepath.Join(common.DatasetImagesDir(ctx, d.Name), newName), data); err != nil {
|
||||
g.Log().Errorf(ctx, "数据集 %s 封面迁移写新文件失败: %+v", d.Name, err)
|
||||
continue
|
||||
}
|
||||
if err := dao.Dataset.UpdateConfigs(ctx, d.Id, &entity.Dataset{Cover: newName}); err != nil {
|
||||
g.Log().Errorf(ctx, "数据集 %s 封面迁移更新字段失败: %+v", d.Name, err)
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(src)
|
||||
migrated++
|
||||
g.Log().Infof(ctx, "数据集 %s 封面迁移: %s → %s", d.Name, oldName, newName)
|
||||
}
|
||||
if migrated > 0 {
|
||||
g.Log().Infof(ctx, "封面存量迁移完成: 共 %d 个数据集", migrated)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminCreateDataset 新建数据集:名称唯一(UNIQUE 兜底)+ 创建图片目录
|
||||
func (s *datasetService) AdminCreateDataset(ctx context.Context, req *dto.AdminDatasetCreateReq) (*dto.AdminDatasetCreateRes, error) {
|
||||
now := gtime.Now()
|
||||
var id int64
|
||||
err := common.Serial().Submit(ctx, func() error {
|
||||
exists, err := dao.Dataset.GetByName(ctx, req.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists != nil {
|
||||
return gerror.NewCode(common.CodeDatasetNameDuplicate)
|
||||
}
|
||||
id, err = dao.Dataset.Insert(ctx, &entity.Dataset{
|
||||
Name: req.Name,
|
||||
Source: req.Source,
|
||||
Status: consts.DatasetStatusBuilding,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(common.DatasetImagesDir(ctx, req.Name), 0o755); err != nil {
|
||||
return nil, gerror.Wrap(err, "创建数据集目录失败")
|
||||
}
|
||||
return &dto.AdminDatasetCreateRes{Id: id}, nil
|
||||
}
|
||||
|
||||
// AdminDeleteDataset 删除数据集:有 running 标注任务 / 该数据集训练进行中 / 已发布模型版本时拒绝
|
||||
// (训练产物与模型为付费资产,需先删除模型版本再删数据集)。
|
||||
// 删除 = 删图片/模型目录 + 删记录(标注随图片行删除,Serial 单写者串行)。
|
||||
func (s *datasetService) AdminDeleteDataset(ctx context.Context, req *dto.AdminDatasetDeleteReq) (*dto.AdminDatasetDeleteRes, error) {
|
||||
var name string
|
||||
err := common.Serial().Submit(ctx, func() error {
|
||||
d, err := dao.Dataset.GetById(ctx, req.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d == nil {
|
||||
return gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
name = d.Name
|
||||
// 预标注任务进行中(RF-DETR 正在扫该数据集图片)
|
||||
if t, err := dao.LabelTask.GetRunningByDataset(ctx, d.Id); err != nil {
|
||||
return err
|
||||
} else if t != nil {
|
||||
return gerror.NewCode(common.CodeLabelTaskRunning)
|
||||
}
|
||||
// 该数据集训练进行中(并发度 1,防文件被删训练中断)
|
||||
if t, err := dao.Training.RunningByDataset(ctx, d.Id); err != nil {
|
||||
return err
|
||||
} else if t != nil {
|
||||
return gerror.New("该数据集有训练任务进行中,无法删除")
|
||||
}
|
||||
// 模型版本记录随数据集级联删除(管理端无模型管理界面,2026-08-26 决策;
|
||||
// 若需保留已下发模型,删除数据集前先确认客户端不再需要)
|
||||
if err := dao.ModelVersion.DeleteByDataset(ctx, d.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dao.DatasetImage.DeleteByDataset(ctx, d.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
return dao.Dataset.DeleteById(ctx, d.Id)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 文件清理(图片 + 模型文件;删除失败仅记日志,记录已删)
|
||||
dirs := []string{common.DatasetImagesDir(ctx, name), common.DatasetModelsDir(ctx, name)}
|
||||
for _, dir := range dirs {
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
g.Log().Errorf(ctx, "删除数据集 %s 目录失败: %+v", dir, err)
|
||||
}
|
||||
}
|
||||
return &dto.AdminDatasetDeleteRes{}, nil
|
||||
}
|
||||
|
||||
// AdminUploadImages 上传图片(多文件):重名跳过(文件名唯一约束),逐张落盘 + 批量入库。
|
||||
func (s *datasetService) AdminUploadImages(ctx context.Context, req *dto.AdminDatasetUploadReq) (*dto.AdminDatasetUploadRes, error) {
|
||||
if len(req.Files) == 0 {
|
||||
return nil, gerror.New("请选择图片文件")
|
||||
}
|
||||
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
dir := common.DatasetImagesDir(ctx, dataset.Name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, gerror.Wrap(err, "创建图片目录失败")
|
||||
}
|
||||
res := &dto.AdminDatasetUploadRes{}
|
||||
now := gtime.Now()
|
||||
saved := make([]string, 0, len(req.Files)) // 本次已落盘文件名(入库失败时清理)
|
||||
for _, f := range req.Files {
|
||||
name := filepath.Base(f.Filename)
|
||||
if !imgExts[strings.ToLower(filepath.Ext(name))] {
|
||||
continue
|
||||
}
|
||||
exists, err := dao.DatasetImage.GetByFilename(ctx, dataset.Id, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists != nil {
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
if err := saveImageFile(f, dir); err != nil {
|
||||
return nil, gerror.Wrapf(err, "图片 %s 保存失败", name)
|
||||
}
|
||||
saved = append(saved, name)
|
||||
}
|
||||
if len(saved) == 0 {
|
||||
return res, nil
|
||||
}
|
||||
// 标注强语义:未配置标注服务时不允许产生无标注图(文件已落盘,失败则清掉)
|
||||
if common.LocalAiClient(ctx) == nil {
|
||||
for _, name := range saved {
|
||||
_ = os.Remove(filepath.Join(dir, name))
|
||||
}
|
||||
return nil, gerror.NewCode(common.CodeLocalAiNotConfigured)
|
||||
}
|
||||
addedIds := make([]int64, 0, len(saved))
|
||||
err = common.Serial().Submit(ctx, func() error {
|
||||
for _, name := range saved {
|
||||
// 重查重(并发上传兜底)+ 入库
|
||||
exists, err := dao.DatasetImage.GetByFilename(ctx, dataset.Id, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists != nil {
|
||||
continue
|
||||
}
|
||||
id, err := dao.DatasetImage.Insert(ctx, &entity.DatasetImage{
|
||||
DatasetId: dataset.Id,
|
||||
Filename: name,
|
||||
Source: "manual",
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addedIds = append(addedIds, id)
|
||||
res.Added++
|
||||
}
|
||||
return dao.Dataset.UpdateCounters(ctx, dataset.Id, int64(res.Added), -1, "")
|
||||
})
|
||||
if err != nil {
|
||||
// 入库失败:清掉已落盘文件,保证「记录存在 ⟺ 文件存在」
|
||||
for _, name := range saved {
|
||||
_ = os.Remove(filepath.Join(dir, name))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// 自动触发标注:忙(已有 running 任务)不报错,由任务完成后的自动补标轮兜底;其他失败回滚本次入库
|
||||
newImages, err := dao.DatasetImage.GetByIds(ctx, addedIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := LabelTask.AutoLabel(ctx, dataset, newImages); err != nil {
|
||||
// 触发失败回滚本次入库(manual 上传文件非付费资产,可删)
|
||||
rollbackIds := make([]int64, 0, len(newImages))
|
||||
for _, img := range newImages {
|
||||
rollbackIds = append(rollbackIds, img.Id)
|
||||
if rErr := os.Remove(filepath.Join(dir, img.Filename)); rErr != nil {
|
||||
g.Log().Warningf(ctx, "回滚删除图片文件失败: %s: %v", img.Filename, rErr)
|
||||
}
|
||||
}
|
||||
if rErr := common.Serial().Submit(ctx, func() error {
|
||||
if dErr := dao.DatasetImage.DeleteByIds(ctx, rollbackIds); dErr != nil {
|
||||
return dErr
|
||||
}
|
||||
return dao.Dataset.UpdateCounters(ctx, dataset.Id, -int64(len(rollbackIds)), 0, "")
|
||||
}); rErr != nil {
|
||||
g.Log().Errorf(ctx, "自动标注触发失败后的入库回滚失败: %v", rErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// saveImageFile 上传文件保存(ghttp.UploadFile.Save 已按原始文件名落盘)
|
||||
func saveImageFile(f *ghttp.UploadFile, dir string) error {
|
||||
saved, err := f.Save(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if saved != filepath.Base(f.Filename) {
|
||||
_ = os.Remove(filepath.Join(dir, saved))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminGenerateImages AI 生成图片:同步逐张生成(每张 2min 超时),逐张落盘 + 入库;
|
||||
// 中途失败返回错误并附已成功张数(已生成的图为付费资产,保留不删除)。
|
||||
func (s *datasetService) AdminGenerateImages(ctx context.Context, req *dto.AdminDatasetGenerateReq) (*dto.AdminDatasetGenerateRes, error) {
|
||||
provider := common.ImageGen(ctx)
|
||||
if provider == nil {
|
||||
return nil, gerror.NewCode(common.CodeImageGenNotConfigured)
|
||||
}
|
||||
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
// 标注强语义:未配置标注服务时不允许开始生成(避免付费资产生成后无法标注)
|
||||
if common.LocalAiClient(ctx) == nil {
|
||||
return nil, gerror.NewCode(common.CodeLocalAiNotConfigured)
|
||||
}
|
||||
dir := common.DatasetImagesDir(ctx, dataset.Name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, gerror.Wrap(err, "创建图片目录失败")
|
||||
}
|
||||
now := gtime.Now()
|
||||
generated := 0
|
||||
addedIds := make([]int64, 0, req.Count)
|
||||
for i := 0; i < req.Count; i++ {
|
||||
genCtx, cancel := context.WithTimeout(ctx, 120*time.Second)
|
||||
data, genErr := provider.Generate(genCtx, req.Prompt, req.Size)
|
||||
cancel()
|
||||
if genErr != nil {
|
||||
// 付费资产保留原则:已生成的不删除
|
||||
return &dto.AdminDatasetGenerateRes{Generated: generated},
|
||||
gerror.NewCode(common.CodeImageGenFailed, fmt.Sprintf("第 %d 张生成失败(已生成 %d 张): %v", i+1, generated, genErr))
|
||||
}
|
||||
filename := fmt.Sprintf("gen_%s_%d.jpg", now.Format("20060102150405"), i)
|
||||
if err := common.WriteFileAtomic(filepath.Join(dir, filename), data); err != nil {
|
||||
return &dto.AdminDatasetGenerateRes{Generated: generated},
|
||||
gerror.Wrapf(err, "第 %d 张保存失败(已生成 %d 张)", i+1, generated)
|
||||
}
|
||||
var imgId int64
|
||||
insErr := common.Serial().Submit(ctx, func() error {
|
||||
id, err := dao.DatasetImage.Insert(ctx, &entity.DatasetImage{
|
||||
DatasetId: dataset.Id,
|
||||
Filename: filename,
|
||||
Source: "ai",
|
||||
Prompt: req.Prompt,
|
||||
CreatedAt: now,
|
||||
})
|
||||
imgId = id
|
||||
return err
|
||||
})
|
||||
if insErr != nil {
|
||||
return &dto.AdminDatasetGenerateRes{Generated: generated},
|
||||
gerror.Wrap(insErr, "生成图片入库失败")
|
||||
}
|
||||
addedIds = append(addedIds, imgId)
|
||||
generated++
|
||||
}
|
||||
if err := common.Serial().Submit(ctx, func() error {
|
||||
return dao.Dataset.UpdateCounters(ctx, dataset.Id, int64(generated), -1, "")
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 自动触发标注:忙(已有 running 任务)不报错,由任务完成后的自动补标轮兜底;
|
||||
// 其他失败报错但保留已生成图(付费资产,不可删)
|
||||
newImages, err := dao.DatasetImage.GetByIds(ctx, addedIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := LabelTask.AutoLabel(ctx, dataset, newImages); err != nil {
|
||||
return &dto.AdminDatasetGenerateRes{Generated: generated},
|
||||
gerror.Wrap(err, "图片已生成入库,但自动标注触发失败")
|
||||
}
|
||||
return &dto.AdminDatasetGenerateRes{Generated: generated}, nil
|
||||
}
|
||||
|
||||
// AdminListImages 数据集图片列表(创建时间正序,标注工作台/网格预览)
|
||||
func (s *datasetService) AdminListImages(ctx context.Context, req *dto.AdminDatasetImagesReq) (*dto.AdminDatasetImagesRes, error) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
list, err := dao.DatasetImage.ListByDataset(ctx, dataset.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]*dto.AdminImageItem, 0, len(list))
|
||||
for _, v := range list {
|
||||
items = append(items, &dto.AdminImageItem{
|
||||
Id: v.Id,
|
||||
Filename: v.Filename,
|
||||
Source: v.Source,
|
||||
Prompt: v.Prompt,
|
||||
Url: datasetImageUrl(ctx, dataset.Id, v.Filename),
|
||||
CreatedAt: v.CreatedAt,
|
||||
})
|
||||
}
|
||||
return &dto.AdminDatasetImagesRes{List: items}, nil
|
||||
}
|
||||
|
||||
// AdminDeleteImages 删除图片:删图片文件 + 记录 + 更新计数
|
||||
// (付费资产,前端带确认文案;标注/候选框 JSON 随图片行删除)
|
||||
func (s *datasetService) AdminDeleteImages(ctx context.Context, req *dto.AdminDatasetImagesDeleteReq) (*dto.AdminDatasetImagesDeleteRes, error) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
images, err := dao.DatasetImage.GetByIds(ctx, req.Ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var del []*entity.DatasetImage
|
||||
for _, img := range images {
|
||||
// 只删本数据集内的记录(防跨数据集误删)
|
||||
if img.DatasetId == dataset.Id {
|
||||
del = append(del, img)
|
||||
}
|
||||
}
|
||||
if len(del) == 0 {
|
||||
return &dto.AdminDatasetImagesDeleteRes{}, nil
|
||||
}
|
||||
// 文件清理(删除失败仅记日志,记录照删):图片文件(标注/候选随行删除)
|
||||
imgDir := common.DatasetImagesDir(ctx, dataset.Name)
|
||||
for _, img := range del {
|
||||
_ = os.Remove(filepath.Join(imgDir, img.Filename))
|
||||
}
|
||||
// Serial 内:删行 → 重算已标注数 → 同步计数与状态
|
||||
err = common.Serial().Submit(ctx, func() error {
|
||||
ids := make([]int64, 0, len(del))
|
||||
for _, img := range del {
|
||||
ids = append(ids, img.Id)
|
||||
}
|
||||
if err := dao.DatasetImage.DeleteByIds(ctx, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
labeled, err := dao.DatasetImage.CountLabeledByDataset(ctx, dataset.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status := ""
|
||||
if labeled > 0 {
|
||||
status = consts.DatasetStatusLabeled
|
||||
}
|
||||
return dao.Dataset.UpdateCounters(ctx, dataset.Id, -int64(len(del)), labeled, status)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.AdminDatasetImagesDeleteRes{}, nil
|
||||
}
|
||||
|
||||
// datasetImageUrl 管理端图片访问地址(controller 直写响应体,见 admin.go Image 方法)
|
||||
func datasetImageUrl(ctx context.Context, datasetId int64, filename string) string {
|
||||
return fmt.Sprintf("/api/v1/admin/datasets/image?%s",
|
||||
url.Values{"datasetId": {fmt.Sprintf("%d", datasetId)}, "filename": {filename}}.Encode())
|
||||
}
|
||||
|
||||
// ImageFile 图片文件定位(校验归属后返回绝对路径;controller 直写响应体)
|
||||
func (s *datasetService) ImageFile(ctx context.Context, datasetId int64, filename string) (string, error) {
|
||||
if filename != filepath.Base(filename) || !imgExts[strings.ToLower(filepath.Ext(filename))] {
|
||||
return "", gerror.NewCode(common.CodeImageNotFound)
|
||||
}
|
||||
dataset, err := dao.Dataset.GetById(ctx, datasetId)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if dataset == nil {
|
||||
return "", gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
img, err := dao.DatasetImage.GetByFilename(ctx, dataset.Id, filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if img == nil {
|
||||
return "", gerror.NewCode(common.CodeImageNotFound)
|
||||
}
|
||||
path := filepath.Join(common.DatasetImagesDir(ctx, dataset.Name), filename)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return "", gerror.NewCode(common.CodeImageNotFound)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ExportZip 打包数据集图片目录为 zip(内存构建;controller 直写响应体下载)
|
||||
func (s *datasetService) ExportZip(ctx context.Context, datasetId int64) ([]byte, string, error) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, datasetId)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, "", gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
dir := common.DatasetImagesDir(ctx, dataset.Name)
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, "", gerror.New("数据集图片目录不存在")
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
w, err := zw.Create(e.Name())
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return buf.Bytes(), dataset.Name + ".zip", nil
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"observer-server/biz/consts"
|
||||
"observer-server/biz/dao"
|
||||
"observer-server/biz/model/dto"
|
||||
"observer-server/biz/model/entity"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
// labelTaskService 预标注工作台后端:RF-DETR 全图扫描,AI 标注结果直写
|
||||
// dataset_image.labels_json(与人工标注同存同编辑),人工可修改/清理全部框;
|
||||
// 训练前整理 YOLO 训练集。
|
||||
// 全图扫描规则(项目既定):不套用生成规格的位置裁剪,候选宁多勿漏。
|
||||
type labelTaskService struct{}
|
||||
|
||||
var LabelTask = &labelTaskService{}
|
||||
|
||||
// recoverLabelTasks 服务重启恢复:孤儿 running 预标注任务置 done + 错误提示
|
||||
// (RF-DETR 检测无状态,重新发起即可重新生成标注)。
|
||||
func (s *labelTaskService) recoverLabelTasks(ctx context.Context) {
|
||||
list, err := dao.LabelTask.ListRunning(ctx)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "恢复预标注任务失败: %+v", err)
|
||||
return
|
||||
}
|
||||
for _, t := range list {
|
||||
if err := dao.LabelTask.Finish(ctx, t.Id, "", "服务重启,任务中断,可重新发起"); err != nil {
|
||||
g.Log().Errorf(ctx, "恢复预标注任务 %d 失败: %+v", t.Id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AdminListLabelTasks 预标注任务分页(组装数据集名)
|
||||
func (s *labelTaskService) AdminListLabelTasks(ctx context.Context, req *dto.AdminLabelTaskListReq) (*dto.AdminLabelTaskListRes, error) {
|
||||
page, size := common.NormalizePage(req.Page, req.Size)
|
||||
list, total, err := dao.LabelTask.Page(ctx, page, size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := Training.datasetNameMap(ctx)
|
||||
items := make([]*dto.AdminLabelTaskItem, 0, len(list))
|
||||
for _, v := range list {
|
||||
items = append(items, &dto.AdminLabelTaskItem{
|
||||
Id: v.Id,
|
||||
DatasetId: v.DatasetId,
|
||||
DatasetName: names[v.DatasetId],
|
||||
Status: v.Status,
|
||||
Total: v.Total,
|
||||
Done: v.Done,
|
||||
Error: v.Error,
|
||||
CreatedAt: v.CreatedAt,
|
||||
FinishedAt: v.FinishedAt,
|
||||
})
|
||||
}
|
||||
return &dto.AdminLabelTaskListRes{Total: total, List: items}, nil
|
||||
}
|
||||
|
||||
// AdminStartLabelTask 发起预标注:串行检查(数据集存在 + 无 running 任务)→ 插任务 →
|
||||
// 池内逐张调 RF-DETR(全图扫描,AI 端点/模型取 config.yml localAi 节点),
|
||||
// 完成后标注直写 dataset_image.labels_json 置 done;任一图片失败则任务置 done + error。
|
||||
// 多选批量:req.Filenames 非空时只扫选中图。
|
||||
func (s *labelTaskService) AdminStartLabelTask(ctx context.Context, req *dto.AdminLabelTaskStartReq) (*dto.AdminLabelTaskStartRes, error) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
if common.LocalAiClient(ctx) == nil {
|
||||
return nil, gerror.NewCode(common.CodeLocalAiNotConfigured)
|
||||
}
|
||||
images, err := dao.DatasetImage.ListByDataset(ctx, dataset.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(images) == 0 {
|
||||
return nil, gerror.New("数据集暂无图片")
|
||||
}
|
||||
// 多选批量:只处理选中图片(缺省全量)
|
||||
subset := len(req.Filenames) > 0
|
||||
filenamesJSON := ""
|
||||
if subset {
|
||||
sel := make(map[string]bool, len(req.Filenames))
|
||||
for _, f := range req.Filenames {
|
||||
sel[f] = true
|
||||
}
|
||||
filtered := make([]*entity.DatasetImage, 0, len(sel))
|
||||
for _, img := range images {
|
||||
if sel[img.Filename] {
|
||||
filtered = append(filtered, img)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil, gerror.New("选中的图片不在该数据集内")
|
||||
}
|
||||
images = filtered
|
||||
names := make([]string, 0, len(images))
|
||||
for _, img := range images {
|
||||
names = append(names, img.Filename)
|
||||
}
|
||||
if raw, jErr := json.Marshal(names); jErr == nil {
|
||||
filenamesJSON = string(raw)
|
||||
}
|
||||
}
|
||||
taskId, err := s.startDetection(ctx, dataset, images, filenamesJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.AdminLabelTaskStartRes{Id: taskId}, nil
|
||||
}
|
||||
|
||||
// startDetection 发起预标注任务:Serial 内检查并发(已有 running 任务报错)→ 插任务 → 启动检测协程
|
||||
func (s *labelTaskService) startDetection(ctx context.Context, dataset *entity.Dataset, images []*entity.DatasetImage, filenamesJSON string) (int64, error) {
|
||||
now := gtime.Now()
|
||||
var taskId int64
|
||||
err := common.Serial().Submit(ctx, func() error {
|
||||
running, err := dao.LabelTask.GetRunningByDataset(ctx, dataset.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if running != nil {
|
||||
return gerror.NewCode(common.CodeLabelTaskRunning)
|
||||
}
|
||||
taskId, err = dao.LabelTask.Insert(ctx, &entity.LabelTask{
|
||||
DatasetId: dataset.Id,
|
||||
Status: consts.LabelTaskRunning,
|
||||
Total: len(images),
|
||||
Filenames: filenamesJSON,
|
||||
CreatedAt: now,
|
||||
})
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.runDetection(ctx, taskId, dataset, images, common.LocalAiClient(ctx))
|
||||
return taskId, nil
|
||||
}
|
||||
|
||||
// AutoLabel 图片入库自动触发标注(上传/AI 生成成功后调用):只标本次新增图。
|
||||
// localAi 未配置 → 报错(调用方拒绝/回滚,不允许产生无标注图);
|
||||
// 已有 running 任务(忙)→ 不报错:本次跳过,由任务成功完成后的自动补标轮兜底。
|
||||
func (s *labelTaskService) AutoLabel(ctx context.Context, dataset *entity.Dataset, images []*entity.DatasetImage) error {
|
||||
if len(images) == 0 {
|
||||
return nil
|
||||
}
|
||||
if common.LocalAiClient(ctx) == nil {
|
||||
return gerror.NewCode(common.CodeLocalAiNotConfigured)
|
||||
}
|
||||
names := make([]string, 0, len(images))
|
||||
for _, img := range images {
|
||||
names = append(names, img.Filename)
|
||||
}
|
||||
filenamesJSON := ""
|
||||
if raw, jErr := json.Marshal(names); jErr == nil {
|
||||
filenamesJSON = string(raw)
|
||||
}
|
||||
_, err := s.startDetection(ctx, dataset, images, filenamesJSON)
|
||||
if err != nil && gerror.HasCode(err, common.CodeLabelTaskRunning) {
|
||||
return nil // 忙:不报错
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// autoSupplement 自动补标:任务成功完成后,该数据集仍有未标注图(忙时入库的图/人工清空的图)
|
||||
// 则续一轮只标未标注图;失败任务不续(防配置故障时无限重试)。
|
||||
func (s *labelTaskService) autoSupplement(ctx context.Context, dataset *entity.Dataset) {
|
||||
unlabeled, err := dao.DatasetImage.ListUnlabeledByDataset(ctx, dataset.Id)
|
||||
if err != nil || len(unlabeled) == 0 {
|
||||
return
|
||||
}
|
||||
if err := s.AutoLabel(ctx, dataset, unlabeled); err != nil {
|
||||
g.Log().Infof(ctx, "自动补标未触发(数据集 %d): %v", dataset.Id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// runDetection 预标注执行协程(生命周期任务):池内逐张检测,进度经 Serial 更新。
|
||||
// 每张:读图 → 全图检测(等比缩放提交,坐标映射回原图归一化)→ 标注(conf≥confConfirmed 为 class 0)。
|
||||
// 全部成功后 Serial 内逐张覆写 labels_json(重跑覆盖该图标注)。
|
||||
func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, dataset *entity.Dataset, images []*entity.DatasetImage, client *common.LocalAi) {
|
||||
// 生命周期任务必须脱离请求 ctx:请求结束即取消,会让 Submit 秒退 + Finish 静默失败 → 任务悬挂
|
||||
bgCtx := context.Background()
|
||||
go func() {
|
||||
if client == nil {
|
||||
_ = dao.LabelTask.Finish(bgCtx, taskId, "", "标注服务未配置")
|
||||
return
|
||||
}
|
||||
dir := common.DatasetImagesDir(bgCtx, dataset.Name)
|
||||
results := make([][]*dto.AdminLabelBox, len(images))
|
||||
failed := ""
|
||||
doneCount := 0
|
||||
for i, img := range images {
|
||||
// 单张 120s 超时兜底(无超时 + 无取消的独立 ctx 下防检测服务悬挂拖死任务)
|
||||
detCtx, cancel := context.WithTimeout(bgCtx, 120*time.Second)
|
||||
err := common.LabelTaskPoolInstance().Submit(detCtx, func(ctx context.Context) error {
|
||||
data, err := os.ReadFile(filepath.Join(dir, img.Filename))
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "读取图片失败")
|
||||
}
|
||||
w, h := imageSize(data)
|
||||
if w <= 0 || h <= 0 {
|
||||
return gerror.New("无法识别图片尺寸")
|
||||
}
|
||||
mime := imageMime(img.Filename)
|
||||
detections, err := client.Detect(ctx, data, mime, w, h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cands := make([]*dto.AdminLabelBox, 0, len(detections))
|
||||
for _, d := range detections {
|
||||
// 坐标映射回原图像素后归一化,越界轻微裁剪
|
||||
box := &dto.AdminLabelBox{
|
||||
Cx: clamp01((d.X + d.Width/2) / float64(w)),
|
||||
Cy: clamp01((d.Y + d.Height/2) / float64(h)),
|
||||
W: clamp01(d.Width / float64(w)),
|
||||
H: clamp01(d.Height / float64(h)),
|
||||
Confidence: d.Confidence,
|
||||
Class: 1,
|
||||
}
|
||||
if d.Confidence >= client.ConfConfirmed {
|
||||
box.Class = 0
|
||||
}
|
||||
cands = append(cands, box)
|
||||
}
|
||||
results[i] = suppressOverlap(cands, client.OverlapThreshold)
|
||||
return nil
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
failed = fmt.Sprintf("第 %d 张(%s)检测失败: %v", doneCount+1, img.Filename, err)
|
||||
break
|
||||
}
|
||||
doneCount++
|
||||
_ = common.Serial().Submit(bgCtx, func() error {
|
||||
return dao.LabelTask.UpdateProgress(bgCtx, taskId, doneCount)
|
||||
})
|
||||
}
|
||||
if failed != "" {
|
||||
_ = dao.LabelTask.Finish(bgCtx, taskId, "", failed)
|
||||
return
|
||||
}
|
||||
// 全部成功:Serial 内逐张覆写标注(重跑覆盖该图标注,人工保存同走 UpdateLabels)
|
||||
err := common.Serial().Submit(bgCtx, func() error {
|
||||
for i := range results {
|
||||
raw, jErr := json.Marshal(results[i])
|
||||
if jErr != nil {
|
||||
return gerror.Wrap(jErr, "标注序列化失败")
|
||||
}
|
||||
if uErr := dao.DatasetImage.UpdateLabels(bgCtx, images[i].Id, string(raw)); uErr != nil {
|
||||
return uErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
_ = dao.LabelTask.Finish(bgCtx, taskId, "", err.Error())
|
||||
return
|
||||
}
|
||||
_ = dao.LabelTask.Finish(bgCtx, taskId, "", "")
|
||||
// 成功完成:自动补标该数据集未标注图(忙时入库的图等),失败任务不续
|
||||
s.autoSupplement(bgCtx, dataset)
|
||||
}()
|
||||
}
|
||||
|
||||
// AdminLabelTaskDetail 预标注任务详情:标注(labels_json)输出,供工作台 canvas 叠框;
|
||||
// 图片尺寸读取文件头。
|
||||
func (s *labelTaskService) AdminLabelTaskDetail(ctx context.Context, req *dto.AdminLabelTaskDetailReq) (*dto.AdminLabelTaskDetailRes, error) {
|
||||
t, err := dao.LabelTask.GetById(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t == nil {
|
||||
return nil, gerror.New("标注任务不存在")
|
||||
}
|
||||
dataset, err := dao.Dataset.GetById(ctx, t.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
items, err := s.buildWorkbenchItems(ctx, dataset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.AdminLabelTaskDetailRes{
|
||||
TaskId: t.Id,
|
||||
DatasetId: t.DatasetId,
|
||||
DatasetName: dataset.Name,
|
||||
Status: t.Status,
|
||||
Total: t.Total,
|
||||
Done: t.Done,
|
||||
Error: t.Error,
|
||||
Images: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildWorkbenchItems 工作台单张图数据:全部标注(labels_json,AI 自动标注与人工框同层);
|
||||
// 尺寸统一读文件头。
|
||||
func (s *labelTaskService) buildWorkbenchItems(ctx context.Context, dataset *entity.Dataset) ([]*dto.AdminLabelImageItem, error) {
|
||||
images, err := dao.DatasetImage.ListByDataset(ctx, dataset.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imgDir := common.DatasetImagesDir(ctx, dataset.Name)
|
||||
items := make([]*dto.AdminLabelImageItem, 0, len(images))
|
||||
for _, img := range images {
|
||||
item := &dto.AdminLabelImageItem{
|
||||
Filename: img.Filename,
|
||||
Url: datasetImageUrl(ctx, dataset.Id, img.Filename),
|
||||
}
|
||||
if img.LabelsJson != "" && img.LabelsJson != "[]" {
|
||||
var boxes []*dto.AdminLabelBox
|
||||
if json.Unmarshal([]byte(img.LabelsJson), &boxes) == nil && len(boxes) > 0 {
|
||||
item.Boxes = boxes
|
||||
item.Labeled = true
|
||||
}
|
||||
}
|
||||
if data, rErr := os.ReadFile(filepath.Join(imgDir, img.Filename)); rErr == nil {
|
||||
item.Width, item.Height = imageSize(data)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// AdminLabelWorkbench 标注工作台数据:数据集无历史任务时直接输出图片 + 全部标注,
|
||||
// 标注读 dataset_image.labels_json(与任务详情同一组装)。
|
||||
func (s *labelTaskService) AdminLabelWorkbench(ctx context.Context, req *dto.AdminLabelWorkbenchReq) (*dto.AdminLabelWorkbenchRes, error) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
items, err := s.buildWorkbenchItems(ctx, dataset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.AdminLabelWorkbenchRes{
|
||||
DatasetId: dataset.Id,
|
||||
DatasetName: dataset.Name,
|
||||
Images: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AdminLabelSave 保存单张图标注:校验坐标 → json.Marshal 覆写 labels_json(空框=清空),
|
||||
// 并刷新数据集 labeled_count(labels_json 非空数组的图片数)。
|
||||
func (s *labelTaskService) AdminLabelSave(ctx context.Context, req *dto.AdminLabelSaveReq) (*dto.AdminLabelSaveRes, error) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
img, err := dao.DatasetImage.GetByFilename(ctx, dataset.Id, req.Filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if img == nil {
|
||||
return nil, gerror.NewCode(common.CodeImageNotFound)
|
||||
}
|
||||
for _, box := range req.Boxes {
|
||||
if box.Cx < 0 || box.Cy < 0 || box.W <= 0 || box.H <= 0 || box.Cx > 1 || box.Cy > 1 {
|
||||
return nil, gerror.New("标注框坐标非法(需 0~1 归一化)")
|
||||
}
|
||||
}
|
||||
// 空框 = 清空标注
|
||||
raw := ""
|
||||
if len(req.Boxes) > 0 {
|
||||
b, err := json.Marshal(req.Boxes)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "标注序列化失败")
|
||||
}
|
||||
raw = string(b)
|
||||
}
|
||||
var labeled int64
|
||||
if err := common.Serial().Submit(ctx, func() error {
|
||||
if err := dao.DatasetImage.UpdateLabels(ctx, img.Id, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
labeled, err = dao.DatasetImage.CountLabeledByDataset(ctx, dataset.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status := ""
|
||||
if labeled > 0 {
|
||||
status = consts.DatasetStatusLabeled
|
||||
}
|
||||
return dao.Dataset.UpdateCounters(ctx, dataset.Id, 0, labeled, status)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.AdminLabelSaveRes{LabeledCount: labeled}, nil
|
||||
}
|
||||
|
||||
// prepareYoloSet 训练前组装内存 YOLO 训练集包:已标注图(labels_json 非空)按 80/20 拆 train/val,
|
||||
// 标注 txt 内存生成、原图仅记源路径(由训练通道读取,不落本地暂存盘);无标注报错。
|
||||
// data.yaml 由训练发起方追加进包(path 需指向训练机)。
|
||||
func (s *labelTaskService) prepareYoloSet(ctx context.Context, dataset *entity.Dataset) (*common.YoloPackage, error) {
|
||||
images, err := dao.DatasetImage.ListByDataset(ctx, dataset.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type item struct {
|
||||
filename string
|
||||
lines string
|
||||
}
|
||||
var kept []item
|
||||
for _, img := range images {
|
||||
if img.LabelsJson == "" || img.LabelsJson == "[]" {
|
||||
continue // 空标注图剔除
|
||||
}
|
||||
var boxes []*dto.AdminLabelBox
|
||||
if json.Unmarshal([]byte(img.LabelsJson), &boxes) != nil || len(boxes) == 0 {
|
||||
continue
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, box := range boxes {
|
||||
fmt.Fprintf(&b, "%d %.6f %.6f %.6f %.6f\n", box.Class, box.Cx, box.Cy, box.W, box.H)
|
||||
}
|
||||
kept = append(kept, item{filename: img.Filename, lines: strings.TrimSpace(b.String())})
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
return nil, gerror.New("数据集无有效标注,请先在标注工作台完成标注")
|
||||
}
|
||||
// 固定随机种子 + 20% val(至少 1 张,语义沿用原 prepare_yolo.py)
|
||||
idx := make([]int, len(kept))
|
||||
for i := range idx {
|
||||
idx[i] = i
|
||||
}
|
||||
randShuffle(idx)
|
||||
nVal := len(kept) / 5
|
||||
if nVal < 1 {
|
||||
nVal = 1
|
||||
}
|
||||
imgSrc := common.DatasetImagesDir(ctx, dataset.Name)
|
||||
pkg := &common.YoloPackage{}
|
||||
addSplit := func(split string, items []item) {
|
||||
for _, it := range items {
|
||||
pkg.Files = append(pkg.Files,
|
||||
common.YoloFile{Name: filepath.Join("images", split, it.filename), ImagePath: filepath.Join(imgSrc, it.filename)},
|
||||
common.YoloFile{Name: filepath.Join("labels", split, strings.TrimSuffix(it.filename, filepath.Ext(it.filename))+".txt"), Content: []byte(it.lines + "\n")},
|
||||
)
|
||||
}
|
||||
}
|
||||
var trainItems, valItems []item
|
||||
for i, it := range kept {
|
||||
if i < nVal {
|
||||
valItems = append(valItems, it)
|
||||
} else {
|
||||
trainItems = append(trainItems, it)
|
||||
}
|
||||
}
|
||||
addSplit("train", trainItems)
|
||||
addSplit("val", valItems)
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
// randShuffle Fisher-Yates 伪随机(固定种子,沿用原 prepare_yolo.py random.seed(42) 语义)
|
||||
func randShuffle(n []int) {
|
||||
state := uint32(42)
|
||||
seed := func() uint32 {
|
||||
state = state*1664525 + 1013904223
|
||||
return state
|
||||
}
|
||||
for i := len(n) - 1; i > 0; i-- {
|
||||
j := int(seed() % uint32(i+1))
|
||||
n[i], n[j] = n[j], n[i]
|
||||
}
|
||||
}
|
||||
|
||||
// suppressOverlap 重叠去重(NMS 风格):按置信度降序依次保留,与已保留框重叠比 > overlapThreshold
|
||||
// 的框剔除(RF-DETR 同一目标重复检出时多个高度重叠框,只留置信度最高者;跨 class 去重)。
|
||||
func suppressOverlap(boxes []*dto.AdminLabelBox, overlapThreshold float64) []*dto.AdminLabelBox {
|
||||
if len(boxes) <= 1 {
|
||||
return boxes
|
||||
}
|
||||
sorted := append([]*dto.AdminLabelBox(nil), boxes...)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].Confidence > sorted[j].Confidence
|
||||
})
|
||||
kept := make([]*dto.AdminLabelBox, 0, len(sorted))
|
||||
for i := range sorted {
|
||||
dup := false
|
||||
for _, k := range kept {
|
||||
if boxOverlap(sorted[i], k) > overlapThreshold {
|
||||
dup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !dup {
|
||||
kept = append(kept, sorted[i])
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// boxOverlap 两个归一化框(cx,cy,w,h)的重叠比(minIoU):交叠面积 / 两框较小面积。
|
||||
// 用 minIoU 而非 IoU:RF-DETR 对同一目标常输出一大一小两个框(IoU 仅 0.3~0.5),
|
||||
// 大框套小框时小框被覆盖比例高(0.3~0.9)能命中;相邻目标两框互有外露,minIoU 通常 < 0.3。
|
||||
func boxOverlap(a, b *dto.AdminLabelBox) float64 {
|
||||
ax1, ay1, ax2, ay2 := a.Cx-a.W/2, a.Cy-a.H/2, a.Cx+a.W/2, a.Cy+a.H/2
|
||||
bx1, by1, bx2, by2 := b.Cx-b.W/2, b.Cy-b.H/2, b.Cx+b.W/2, b.Cy+b.H/2
|
||||
ix1, iy1 := math.Max(ax1, bx1), math.Max(ay1, by1)
|
||||
ix2, iy2 := math.Min(ax2, bx2), math.Min(ay2, by2)
|
||||
if ix2 <= ix1 || iy2 <= iy1 {
|
||||
return 0
|
||||
}
|
||||
inter := (ix2 - ix1) * (iy2 - iy1)
|
||||
minArea := math.Min(a.W*a.H, b.W*b.H)
|
||||
if minArea <= 0 {
|
||||
return 0
|
||||
}
|
||||
return inter / minArea
|
||||
}
|
||||
|
||||
// imageSize 读取图片尺寸(文件头,不解码全图)
|
||||
func imageSize(data []byte) (int, int) {
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
return cfg.Width, cfg.Height
|
||||
}
|
||||
|
||||
func imageMime(filename string) string {
|
||||
switch strings.ToLower(filepath.Ext(filename)) {
|
||||
case ".png":
|
||||
return "image/png"
|
||||
default:
|
||||
return "image/jpeg"
|
||||
}
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"observer-server/biz/dao"
|
||||
"observer-server/biz/model/dto"
|
||||
)
|
||||
|
||||
// modelVersionService 模型版本业务:管理端无模型管理界面(2026-08-26 决策),
|
||||
// 版本记录仅支撑客户端模型目录(App 多模型下载热更新)。文件布局见 common/workspace.go。
|
||||
type modelVersionService struct{}
|
||||
|
||||
var ModelVersion = &modelVersionService{}
|
||||
|
||||
// ClientCatalog 客户端模型目录:全部数据集当前生效模型(App 多模型下载热更新)。
|
||||
// downloadUrl 复用 /download 静态托管(/download/models/<数据集名>/latest.tflite)。
|
||||
func (s *modelVersionService) ClientCatalog(ctx context.Context) (*dto.ModelCatalogRes, error) {
|
||||
list, err := dao.ModelVersion.ListAllLatest(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := Training.datasetNameMap(ctx)
|
||||
items := make([]*dto.ModelCatalogItem, 0, len(list))
|
||||
for _, v := range list {
|
||||
datasetName := names[v.DatasetId]
|
||||
if datasetName == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, &dto.ModelCatalogItem{
|
||||
DatasetId: v.DatasetId,
|
||||
DatasetName: datasetName,
|
||||
Version: v.Version,
|
||||
Labels: parseLabels(v.Labels),
|
||||
SizeBytes: v.SizeBytes,
|
||||
Sha256: v.Sha256,
|
||||
Notes: v.Notes,
|
||||
PublishedAt: v.CreatedAt,
|
||||
DownloadUrl: "/download/models/" + datasetName + "/latest.tflite",
|
||||
})
|
||||
}
|
||||
return &dto.ModelCatalogRes{List: items}, nil
|
||||
}
|
||||
|
||||
// parseLabels 解析类别名 JSON 数组
|
||||
func parseLabels(s string) []string {
|
||||
var out []string
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil || len(out) == 0 {
|
||||
return []string{"class0", "class1"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"observer-server/biz/consts"
|
||||
"observer-server/biz/dao"
|
||||
"observer-server/biz/model/dto"
|
||||
"observer-server/biz/model/entity"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
// trainingService 训练编排业务:发起训练(同步数据集 → 写任务参数 → 起进程)、
|
||||
// 后台轮询进度/判定结束、取消、发布模型版本。并发度 1(GPU 独占)。
|
||||
type trainingService struct{}
|
||||
|
||||
var Training = &trainingService{}
|
||||
|
||||
// StartBackgroundJobs 启动后台协程:训练进度轮询 + 孤儿预标注任务恢复(main.go 启动时调用)。
|
||||
// 单协程生命周期任务(非并行工作负载),不做池封装。
|
||||
func (s *trainingService) StartBackgroundJobs(ctx context.Context) {
|
||||
LabelTask.recoverLabelTasks(ctx)
|
||||
go s.pollTrainings(ctx)
|
||||
}
|
||||
|
||||
// pollTrainings 训练轮询:每 10s 扫描 running 任务,更新进度/日志,按 result.json 或进程
|
||||
// 存活判定结束;超时无结果判死。Go 重启后自动恢复扫描(进程已死 → 置 failed)。
|
||||
func (s *trainingService) pollTrainings(ctx context.Context) {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
runner := common.Runner(ctx)
|
||||
if runner == nil {
|
||||
continue
|
||||
}
|
||||
cfg, ok := common.TrainingConfigOf(ctx)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
running, err := dao.Training.ListRunning(ctx)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "训练轮询读取 running 任务失败: %+v", err)
|
||||
continue
|
||||
}
|
||||
for _, t := range running {
|
||||
job, err := s.buildJob(ctx, t, cfg)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "训练 %d 构建任务参数失败: %+v", t.Id, err)
|
||||
continue
|
||||
}
|
||||
s.pollOne(ctx, runner, job, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *trainingService) pollOne(ctx context.Context, runner common.TrainingRunner, job *common.TrainingJob, t *entity.ModelTraining) {
|
||||
tail, err := runner.FetchLogTail(ctx, job)
|
||||
if err != nil {
|
||||
tail = ""
|
||||
}
|
||||
// 结束判定:result.json 存在 = 训练完成(先于存活判定,进程可能已退出)
|
||||
result, err := runner.FetchResult(ctx, job)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "训练 %d 读取结果失败: %+v", t.Id, err)
|
||||
return
|
||||
}
|
||||
if result != "" {
|
||||
// result.json 带 error 字段 = 脚本异常退出(如 tflite 导出失败),置失败并带出原因
|
||||
var resErr struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal([]byte(result), &resErr) == nil && resErr.Error != "" {
|
||||
_ = s.finishFailed(ctx, t, "训练失败: %s", resErr.Error)
|
||||
return
|
||||
}
|
||||
s.finishSuccess(ctx, runner, job, t, result, tail)
|
||||
return
|
||||
}
|
||||
alive, err := runner.IsAlive(ctx, job)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "训练 %d 存活探测失败: %+v", t.Id, err)
|
||||
return
|
||||
}
|
||||
// 超时判死(started_at 起算)
|
||||
cfg, _ := common.TrainingConfigOf(ctx)
|
||||
timeout := time.Duration(cfg.TimeoutMins) * time.Minute
|
||||
if t.StartedAt != nil && time.Since(t.StartedAt.Time) > timeout {
|
||||
_ = runner.Cancel(ctx, job)
|
||||
_ = s.finishFailed(ctx, t, "训练超时(超过 %d 分钟无结果,已终止)", cfg.TimeoutMins)
|
||||
return
|
||||
}
|
||||
if !alive {
|
||||
_ = s.finishFailed(ctx, t, "训练进程已退出(无结果文件)")
|
||||
return
|
||||
}
|
||||
// 进度:日志尾解析最后一条 epoch 行
|
||||
epoch, total, metrics := parseEpochTail(tail)
|
||||
if epoch > 0 {
|
||||
_ = dao.Training.UpdateProgress(ctx, t.Id, epoch, total, metrics, truncateTail(tail))
|
||||
}
|
||||
}
|
||||
|
||||
// finishSuccess 训练成功:解析 result.json(最终指标 + 类别名)→ 更新任务 + 拉取产物到服务器
|
||||
func (s *trainingService) finishSuccess(ctx context.Context, runner common.TrainingRunner, job *common.TrainingJob, t *entity.ModelTraining, result, tail string) {
|
||||
var res struct {
|
||||
Metrics map[string]float64 `json:"metrics"`
|
||||
Names []string `json:"names"`
|
||||
BestTflite string `json:"best_tflite"`
|
||||
TfliteCheck *struct {
|
||||
OK bool `json:"ok"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"tflite_check"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(result), &res)
|
||||
metricsJSON := ""
|
||||
if res.Metrics != nil {
|
||||
if b, err := json.Marshal(res.Metrics); err == nil {
|
||||
metricsJSON = string(b)
|
||||
}
|
||||
}
|
||||
// tflite 产物自检失败(训练脚本内嵌检查,旧任务无该字段不校验):坏产物不进发布链路
|
||||
if res.TfliteCheck != nil && !res.TfliteCheck.OK {
|
||||
reason := res.TfliteCheck.Reason
|
||||
if reason == "" {
|
||||
reason = "shape 校验未通过"
|
||||
}
|
||||
_ = s.finishFailed(ctx, t, "tflite 产物自检失败: %s", reason)
|
||||
return
|
||||
}
|
||||
// 先拉产物再置成功:产物拉取失败则置失败(发布依赖 tflite 存在)
|
||||
dest := common.TrainingArtifactsDir(ctx, t.Id)
|
||||
if res.BestTflite == "" {
|
||||
_ = s.finishFailed(ctx, t, "训练完成但 result.json 缺少 best_tflite")
|
||||
return
|
||||
}
|
||||
if err := runner.FetchArtifact(ctx, job, res.BestTflite, filepath.Join(dest, "best.tflite")); err != nil {
|
||||
g.Log().Errorf(ctx, "训练 %d 拉取 best.tflite 失败: %+v", t.Id, err)
|
||||
_ = s.finishFailed(ctx, t, "拉取训练产物失败: %v", err)
|
||||
return
|
||||
}
|
||||
zipPath := fmt.Sprintf("artifacts/%d.zip", t.Id)
|
||||
if err := runner.FetchArtifact(ctx, job, zipPath, filepath.Join(dest, "artifact.zip")); err != nil {
|
||||
g.Log().Warningf(ctx, "训练 %d 拉取 artifact.zip 失败(不阻断): %+v", t.Id, err)
|
||||
}
|
||||
// 指标尾部带上类别名,发布时解析 labels
|
||||
if len(res.Names) > 0 {
|
||||
if names, err := json.Marshal(res.Names); err == nil {
|
||||
metricsJSON = mergeNamesIntoMetrics(metricsJSON, string(names))
|
||||
}
|
||||
}
|
||||
if err := dao.Training.Finish(ctx, t.Id, consts.TrainingStatusSuccess, metricsJSON, truncateTail(tail), ""); err != nil {
|
||||
g.Log().Errorf(ctx, "训练 %d 置成功失败: %+v", t.Id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// mergeNamesIntoMetrics 把 names 数组并入 metrics JSON(names 字段供发布解析类别名)
|
||||
func mergeNamesIntoMetrics(metricsJSON, namesJSON string) string {
|
||||
if metricsJSON == "" {
|
||||
return `{"names":` + namesJSON + `}`
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal([]byte(metricsJSON), &m) != nil {
|
||||
return metricsJSON
|
||||
}
|
||||
m["names"] = json.RawMessage(namesJSON)
|
||||
if b, err := json.Marshal(m); err == nil {
|
||||
return string(b)
|
||||
}
|
||||
return metricsJSON
|
||||
}
|
||||
|
||||
func (s *trainingService) finishFailed(ctx context.Context, t *entity.ModelTraining, format string, args ...any) error {
|
||||
return dao.Training.Finish(ctx, t.Id, consts.TrainingStatusFailed, "", "", fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// buildJob 组装训练机路径布局的 runner 任务
|
||||
func (s *trainingService) buildJob(ctx context.Context, t *entity.ModelTraining, cfg common.TrainingConfig) (*common.TrainingJob, error) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, t.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
job := &common.TrainingJob{
|
||||
TaskId: t.Id,
|
||||
DatasetName: dataset.Name,
|
||||
Python: cfg.Python,
|
||||
Workdir: cfg.Workdir,
|
||||
DatasetDir: cfg.DatasetDir,
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// parseEpochTail 从日志尾部解析最后一条 epoch 进度行({"epoch":N,"total":M,"metrics":{...}})
|
||||
func parseEpochTail(tail string) (epoch, total int, metrics string) {
|
||||
lines := strings.Split(tail, "\n")
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
line := strings.TrimSpace(lines[i])
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var e struct {
|
||||
Epoch int `json:"epoch"`
|
||||
Total int `json:"total"`
|
||||
Metrics map[string]float64 `json:"metrics"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &e); err != nil || e.Epoch <= 0 {
|
||||
continue
|
||||
}
|
||||
metrics = ""
|
||||
if e.Metrics != nil {
|
||||
if b, err := json.Marshal(e.Metrics); err == nil {
|
||||
metrics = string(b)
|
||||
}
|
||||
}
|
||||
return e.Epoch, e.Total, metrics
|
||||
}
|
||||
return 0, 0, ""
|
||||
}
|
||||
|
||||
func truncateTail(s string) string {
|
||||
if len(s) > 8*1024 {
|
||||
return s[len(s)-8*1024:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// AdminListTrainings 训练任务分页(组装数据集名)
|
||||
func (s *trainingService) AdminListTrainings(ctx context.Context, req *dto.AdminTrainingListReq) (*dto.AdminTrainingListRes, error) {
|
||||
page, size := common.NormalizePage(req.Page, req.Size)
|
||||
var list []*entity.ModelTraining
|
||||
var total int64
|
||||
var err error
|
||||
if req.Status != "" {
|
||||
list, total, err = dao.Training.PageByStatus(ctx, req.Status, page, size)
|
||||
} else {
|
||||
list, total, err = dao.Training.Page(ctx, page, size)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := s.datasetNameMap(ctx)
|
||||
items := make([]*dto.AdminTrainingItem, 0, len(list))
|
||||
for _, v := range list {
|
||||
items = append(items, &dto.AdminTrainingItem{
|
||||
Id: v.Id,
|
||||
Name: v.Name,
|
||||
DatasetId: v.DatasetId,
|
||||
DatasetName: names[v.DatasetId],
|
||||
Status: v.Status,
|
||||
Imgsz: v.Imgsz,
|
||||
Epochs: v.Epochs,
|
||||
Batch: v.Batch,
|
||||
Device: v.Device,
|
||||
CurrentEpoch: v.CurrentEpoch,
|
||||
TotalEpochs: v.TotalEpochs,
|
||||
Metrics: v.Metrics,
|
||||
Error: v.Error,
|
||||
StartedAt: v.StartedAt,
|
||||
FinishedAt: v.FinishedAt,
|
||||
CreatedAt: v.CreatedAt,
|
||||
})
|
||||
}
|
||||
return &dto.AdminTrainingListRes{Total: total, List: items}, nil
|
||||
}
|
||||
|
||||
// datasetNameMap 全量数据集 id → 名称(列表组装用,避免 N+1)
|
||||
func (s *trainingService) datasetNameMap(ctx context.Context) map[int64]string {
|
||||
m := map[int64]string{}
|
||||
list, err := dao.Dataset.ListAll(ctx)
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
for _, d := range list {
|
||||
m[d.Id] = d.Name
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// AdminStartTraining 发起训练:并发度 1(已有 running 拒绝);先本地整理 yolo 训练集
|
||||
// (80/20 拆 train/val)再同步训练机 → 写任务参数 → 启动进程;任何一步失败置任务 failed。
|
||||
func (s *trainingService) AdminStartTraining(ctx context.Context, req *dto.AdminTrainingStartReq) (*dto.AdminTrainingStartRes, error) {
|
||||
runner := common.Runner(ctx)
|
||||
if runner == nil {
|
||||
return nil, gerror.NewCode(common.CodeTrainingNotConfigured)
|
||||
}
|
||||
cfg, ok := common.TrainingConfigOf(ctx)
|
||||
if !ok {
|
||||
return nil, gerror.NewCode(common.CodeTrainingNotConfigured)
|
||||
}
|
||||
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
// 组装 yolo 训练集包(有标注才可训练;内存组装,不落本地暂存盘)
|
||||
pkg, err := LabelTask.prepareYoloSet(ctx, dataset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imgsz := req.Imgsz
|
||||
if imgsz <= 0 {
|
||||
imgsz = 704
|
||||
}
|
||||
epochs := req.Epochs
|
||||
if epochs <= 0 {
|
||||
epochs = 150
|
||||
}
|
||||
batch := req.Batch
|
||||
if batch <= 0 {
|
||||
batch = 16
|
||||
}
|
||||
device := req.Device
|
||||
if device == "" {
|
||||
device = "0"
|
||||
}
|
||||
now := gtime.Now()
|
||||
var taskId int64
|
||||
err = common.Serial().Submit(ctx, func() error {
|
||||
running, err := dao.Training.Running(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if running != nil {
|
||||
return gerror.NewCode(common.CodeTrainingRunning)
|
||||
}
|
||||
taskId, err = dao.Training.Insert(ctx, &entity.ModelTraining{
|
||||
Name: req.Name,
|
||||
Status: consts.TrainingStatusRunning,
|
||||
DatasetId: dataset.Id,
|
||||
Imgsz: imgsz,
|
||||
Epochs: epochs,
|
||||
Batch: batch,
|
||||
Device: device,
|
||||
StartedAt: now,
|
||||
CreatedAt: now,
|
||||
})
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 准备阶段(训练机侧)失败 → 任务置 failed(记录保留便于排查)
|
||||
job := &common.TrainingJob{
|
||||
TaskId: taskId,
|
||||
DatasetName: dataset.Name,
|
||||
Python: cfg.Python,
|
||||
Workdir: cfg.Workdir,
|
||||
DatasetDir: cfg.DatasetDir,
|
||||
}
|
||||
// data.yaml 的 path 指向训练机路径,随包一起同步
|
||||
trainPath := filepath.Join(cfg.Workdir, cfg.DatasetDir, "yolo", dataset.Name)
|
||||
pkg.Files = append(pkg.Files, common.YoloFile{
|
||||
Name: "dataset.yaml",
|
||||
Content: []byte(yoloYamlContent(trainPath, localAiClassNames(ctx))),
|
||||
})
|
||||
taskJSON, _ := json.Marshal(map[string]any{
|
||||
"workdir": cfg.Workdir,
|
||||
"yolo": filepath.ToSlash(filepath.Join(cfg.DatasetDir, "yolo", dataset.Name)),
|
||||
"imgsz": imgsz,
|
||||
"epochs": epochs,
|
||||
"batch": batch,
|
||||
"device": device,
|
||||
"project": filepath.ToSlash(filepath.Join("runs", "tasks", strconv.FormatInt(taskId, 10))),
|
||||
"log_file": filepath.ToSlash(filepath.Join("logs", strconv.FormatInt(taskId, 10)+".jsonl")),
|
||||
"result_file": filepath.ToSlash(filepath.Join("results", strconv.FormatInt(taskId, 10)+".json")),
|
||||
"artifact_zip": filepath.ToSlash(filepath.Join("artifacts", strconv.FormatInt(taskId, 10)+".zip")),
|
||||
})
|
||||
if err := runner.WriteTaskJson(ctx, job, string(taskJSON)); err != nil {
|
||||
_ = s.finishFailed(ctx, &entity.ModelTraining{Id: taskId}, "写任务参数失败: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
if err := runner.SyncYoloDataset(ctx, job, pkg); err != nil {
|
||||
_ = s.finishFailed(ctx, &entity.ModelTraining{Id: taskId}, "同步数据集失败: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
pid, err := runner.Start(ctx, job)
|
||||
if err != nil {
|
||||
_ = s.finishFailed(ctx, &entity.ModelTraining{Id: taskId}, "启动训练失败: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
if err := dao.Training.UpdatePid(ctx, taskId, pid); err != nil {
|
||||
g.Log().Errorf(ctx, "训练 %d 记录 pid 失败: %+v", taskId, err)
|
||||
}
|
||||
return &dto.AdminTrainingStartRes{Id: taskId}, nil
|
||||
}
|
||||
|
||||
// yoloYamlContent 生成训练集 data.yaml 内容(path 为训练机绝对路径)
|
||||
func yoloYamlContent(trainPath string, names []string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "path: %s\n", trainPath)
|
||||
b.WriteString("train: images/train\nval: images/val\nnames:\n")
|
||||
for i, n := range names {
|
||||
fmt.Fprintf(&b, " %d: %s\n", i, n)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// localAiClassNames 标注类别名(config.yml localAi.classNames,默认 class0/class1)
|
||||
func localAiClassNames(ctx context.Context) []string {
|
||||
names := g.Cfg().MustGet(ctx, "localAi.classNames").Strings()
|
||||
if len(names) == 0 {
|
||||
return []string{"class0", "class1"}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// AdminTrainingDetail 训练任务详情(含日志尾部)
|
||||
func (s *trainingService) AdminTrainingDetail(ctx context.Context, req *dto.AdminTrainingDetailReq) (*dto.AdminTrainingDetailRes, error) {
|
||||
t, err := dao.Training.GetById(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t == nil {
|
||||
return nil, gerror.NewCode(common.CodeTrainingNotFound)
|
||||
}
|
||||
names := s.datasetNameMap(ctx)
|
||||
return &dto.AdminTrainingDetailRes{
|
||||
AdminTrainingItem: dto.AdminTrainingItem{
|
||||
Id: t.Id,
|
||||
Name: t.Name,
|
||||
DatasetId: t.DatasetId,
|
||||
DatasetName: names[t.DatasetId],
|
||||
Status: t.Status,
|
||||
Imgsz: t.Imgsz,
|
||||
Epochs: t.Epochs,
|
||||
Batch: t.Batch,
|
||||
Device: t.Device,
|
||||
CurrentEpoch: t.CurrentEpoch,
|
||||
TotalEpochs: t.TotalEpochs,
|
||||
Metrics: t.Metrics,
|
||||
Error: t.Error,
|
||||
StartedAt: t.StartedAt,
|
||||
FinishedAt: t.FinishedAt,
|
||||
CreatedAt: t.CreatedAt,
|
||||
},
|
||||
LogTail: t.LogTail,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AdminCancelTraining 取消训练:杀进程 + 置 failed
|
||||
func (s *trainingService) AdminCancelTraining(ctx context.Context, req *dto.AdminTrainingCancelReq) (*dto.AdminTrainingCancelRes, error) {
|
||||
var t *entity.ModelTraining
|
||||
err := common.Serial().Submit(ctx, func() error {
|
||||
var err error
|
||||
t, err = dao.Training.GetById(ctx, req.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t == nil {
|
||||
return gerror.NewCode(common.CodeTrainingNotFound)
|
||||
}
|
||||
if t.Status != consts.TrainingStatusRunning {
|
||||
return gerror.New("仅运行中的训练任务可取消")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runner := common.Runner(ctx)
|
||||
if runner != nil {
|
||||
if cfg, ok := common.TrainingConfigOf(ctx); ok {
|
||||
if job, jErr := s.buildJob(ctx, t, cfg); jErr == nil {
|
||||
_ = runner.Cancel(ctx, job)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.finishFailed(ctx, t, "用户取消"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.AdminTrainingCancelRes{}, nil
|
||||
}
|
||||
|
||||
// AdminPublish 发布模型版本:仅 success 任务 + 本地 best.tflite 存在;
|
||||
// 版本号同数据集内 m<major>.<minor>.<patch> 自增(无记录从 m1.0.0 起)。
|
||||
// 落库(置旧版 is_latest=0 + 插新版)后写 latest 副本(无存档回退机制),文件失败补偿删记录,
|
||||
// 保证「记录存在 ⟺ 文件存在」(同 APK 版本管理)。
|
||||
func (s *trainingService) AdminPublish(ctx context.Context, req *dto.AdminTrainingPublishReq) (*dto.AdminTrainingPublishRes, error) {
|
||||
t, err := dao.Training.GetById(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t == nil {
|
||||
return nil, gerror.NewCode(common.CodeTrainingNotFound)
|
||||
}
|
||||
if t.Status != consts.TrainingStatusSuccess {
|
||||
return nil, gerror.NewCode(common.CodeTrainingNotSuccess)
|
||||
}
|
||||
dataset, err := dao.Dataset.GetById(ctx, t.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
bestTflite := filepath.Join(common.TrainingArtifactsDir(ctx, t.Id), "best.tflite")
|
||||
data, err := os.ReadFile(bestTflite)
|
||||
if err != nil {
|
||||
return nil, gerror.New("训练产物 best.tflite 缺失,无法发布")
|
||||
}
|
||||
labels := labelsFromMetrics(t.Metrics)
|
||||
sha256, err := common.Sha256Hex(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
version, err := s.nextVersion(ctx, t.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := gtime.Now()
|
||||
var mv *entity.ModelVersion
|
||||
err = common.Serial().Submit(ctx, func() error {
|
||||
// 该数据集旧版全部置 0,再插新版本(is_latest=1)
|
||||
if err := dao.ModelVersion.ClearLatest(ctx, t.DatasetId); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := dao.ModelVersion.Insert(ctx, &entity.ModelVersion{
|
||||
DatasetId: t.DatasetId,
|
||||
Version: version,
|
||||
TrainingId: t.Id,
|
||||
Metrics: t.Metrics,
|
||||
Labels: labelsJSON(labels),
|
||||
Sha256: sha256,
|
||||
SizeBytes: int64(len(data)),
|
||||
IsLatest: 1,
|
||||
Notes: t.Name,
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mv = &entity.ModelVersion{Id: id, Version: version, DatasetId: t.DatasetId}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 文件:当前生效副本 latest.tflite(tmp+rename 原子覆盖),客户端固定下载
|
||||
dir := common.DatasetModelsDir(ctx, dataset.Name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, gerror.Wrap(err, "创建模型目录失败")
|
||||
}
|
||||
if err := common.WriteFileAtomic(filepath.Join(dir, "latest.tflite"), data); err != nil {
|
||||
_ = dao.ModelVersion.DeleteById(ctx, mv.Id)
|
||||
return nil, gerror.Wrap(err, "写当前生效模型失败")
|
||||
}
|
||||
return &dto.AdminTrainingPublishRes{Version: version}, nil
|
||||
}
|
||||
|
||||
// nextVersion 同数据集内版本号自增:取最大 m<x>.<y>.<z>,patch+1;无记录从 m1.0.0 起
|
||||
func (s *trainingService) nextVersion(ctx context.Context, datasetId int64) (string, error) {
|
||||
list, _, err := dao.ModelVersion.PageByDataset(ctx, datasetId, 1, 1000)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
maxPatch := 0
|
||||
maxMinor := 0
|
||||
maxMajor := 0
|
||||
for _, v := range list {
|
||||
major, minor, patch, ok := parseModelVersion(v.Version)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if major > maxMajor || major == maxMajor && (minor > maxMinor || minor == maxMinor && patch > maxPatch) {
|
||||
maxMajor, maxMinor, maxPatch = major, minor, patch
|
||||
}
|
||||
}
|
||||
if maxMajor == 0 && maxMinor == 0 && maxPatch == 0 {
|
||||
return consts.ModelVersionPrefix + "1.0.0", nil
|
||||
}
|
||||
return fmt.Sprintf("%s%d.%d.%d", consts.ModelVersionPrefix, maxMajor, maxMinor, maxPatch+1), nil
|
||||
}
|
||||
|
||||
// parseModelVersion 解析 m1.2.3 → (1,2,3,true)
|
||||
func parseModelVersion(v string) (major, minor, patch int, ok bool) {
|
||||
trimmed := strings.TrimPrefix(v, consts.ModelVersionPrefix)
|
||||
parts := strings.Split(trimmed, ".")
|
||||
if len(parts) != 3 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
major, err1 := strconv.Atoi(parts[0])
|
||||
minor, err2 := strconv.Atoi(parts[1])
|
||||
patch, err3 := strconv.Atoi(parts[2])
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
return major, minor, patch, true
|
||||
}
|
||||
|
||||
// labelsFromMetrics 从任务 metrics JSON 解析类别名(names 字段),缺省 class0/class1
|
||||
func labelsFromMetrics(metrics string) []string {
|
||||
if metrics != "" {
|
||||
var m map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(metrics), &m) == nil {
|
||||
if raw, ok := m["names"]; ok {
|
||||
var names []string
|
||||
if json.Unmarshal(raw, &names) == nil && len(names) > 0 {
|
||||
return names
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []string{"class0", "class1"}
|
||||
}
|
||||
|
||||
func labelsJSON(labels []string) string {
|
||||
b, err := json.Marshal(labels)
|
||||
if err != nil {
|
||||
return `["class0","class1"]`
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -10,9 +10,14 @@ import (
|
||||
|
||||
// AdminAuth 管理端静态 token 鉴权中间件:请求头 X-Admin-Token 须与 config.yml
|
||||
// admin.token 一致;token 未配置(空)时管理接口全部拒绝。
|
||||
// 图片等 <img>/<video> 标签无法携带自定义请求头,允许 query 参数 token 兜底。
|
||||
func AdminAuth(r *ghttp.Request) {
|
||||
want := g.Cfg().MustGet(r.GetCtx(), "admin.token", "").String()
|
||||
if want == "" || r.Header.Get("X-Admin-Token") != want {
|
||||
token := r.Header.Get("X-Admin-Token")
|
||||
if token == "" {
|
||||
token = r.Get("token").String()
|
||||
}
|
||||
if want == "" || token != want {
|
||||
r.Response.WriteStatusExit(http.StatusUnauthorized, g.Map{
|
||||
"code": gcode.CodeNotAuthorized.Code(),
|
||||
"message": "管理端未授权",
|
||||
|
||||
+20
-9
@@ -5,13 +5,24 @@ import "github.com/gogf/gf/v2/errors/gcode"
|
||||
// 业务错误码(1000+,框架保留 <1000):统一 HTTP 200 + body code!=0 表示失败,
|
||||
// 客户端按 code 分支。错误统一用 gerror.NewCode(common.CodeXxx, "...") 构造。
|
||||
var (
|
||||
CodePlanNotConfigured = gcode.New(1001, "套餐不存在或未配置", nil)
|
||||
CodePaymentNotConfigured = gcode.New(1002, "支付渠道未配置", nil)
|
||||
CodeOrderNotFound = gcode.New(1003, "订单不存在", nil)
|
||||
CodeOrderClosed = gcode.New(1004, "订单已关闭,需重新下单", nil)
|
||||
CodeCallbackVerifyFailed = gcode.New(1005, "回调验签失败", nil)
|
||||
CodeCallbackMismatch = gcode.New(1006, "回调商户/金额不匹配", nil)
|
||||
CodeVersionDuplicate = gcode.New(1007, "该版本号已存在,请勿重复下发", nil)
|
||||
CodeApkInvalid = gcode.New(1008, "请上传 APK 文件(.apk 后缀)", nil)
|
||||
CodeVersionNotFound = gcode.New(1009, "版本记录不存在", nil)
|
||||
CodePlanNotConfigured = gcode.New(1001, "套餐不存在或未配置", nil)
|
||||
CodePaymentNotConfigured = gcode.New(1002, "支付渠道未配置", nil)
|
||||
CodeOrderNotFound = gcode.New(1003, "订单不存在", nil)
|
||||
CodeOrderClosed = gcode.New(1004, "订单已关闭,需重新下单", nil)
|
||||
CodeCallbackVerifyFailed = gcode.New(1005, "回调验签失败", nil)
|
||||
CodeCallbackMismatch = gcode.New(1006, "回调商户/金额不匹配", nil)
|
||||
CodeVersionDuplicate = gcode.New(1007, "该版本号已存在,请勿重复下发", nil)
|
||||
CodeApkInvalid = gcode.New(1008, "请上传 APK 文件(.apk 后缀)", nil)
|
||||
CodeVersionNotFound = gcode.New(1009, "版本记录不存在", nil)
|
||||
CodeDatasetNotFound = gcode.New(1010, "数据集不存在", nil)
|
||||
CodeDatasetNameDuplicate = gcode.New(1011, "数据集名称已存在", nil)
|
||||
CodeImageGenNotConfigured = gcode.New(1012, "图像生成服务未配置(imageGen 节点)", nil)
|
||||
CodeImageGenFailed = gcode.New(1013, "图像生成失败", nil)
|
||||
CodeTrainingNotConfigured = gcode.New(1014, "训练通道未配置(training 节点)", nil)
|
||||
CodeTrainingRunning = gcode.New(1015, "已有训练任务进行中(并发度 1)", nil)
|
||||
CodeTrainingNotFound = gcode.New(1016, "训练任务不存在", nil)
|
||||
CodeTrainingNotSuccess = gcode.New(1017, "仅训练成功的任务可发布", nil)
|
||||
CodeLabelTaskRunning = gcode.New(1020, "该数据集已有预标注任务进行中", nil)
|
||||
CodeLocalAiNotConfigured = gcode.New(1021, "标注服务未配置(localAi 节点)", nil)
|
||||
CodeImageNotFound = gcode.New(1022, "图片不存在", nil)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// ImageGenProvider 图像生成 provider 抽象:管理端「AI 生成图片」按 config.yml imageGen.provider 选择实现。
|
||||
type ImageGenProvider interface {
|
||||
// Generate 生成一张图片,返回图片字节;prompt 由调用方保证不含位置描述(项目提示词规范)。
|
||||
Generate(ctx context.Context, prompt, size string) ([]byte, error)
|
||||
}
|
||||
|
||||
// ImageGen 当前配置的图像生成 provider 单例(未配置返回 nil,调用方判 CodeImageGenNotConfigured)。
|
||||
func ImageGen(ctx context.Context) ImageGenProvider {
|
||||
if g.Cfg().MustGet(ctx, "imageGen.apiKey").String() == "" {
|
||||
return nil
|
||||
}
|
||||
switch g.Cfg().MustGet(ctx, "imageGen.provider", "dashscope").String() {
|
||||
case "dashscope":
|
||||
return &dashScopeImageGen{apiKey: g.Cfg().MustGet(ctx, "imageGen.apiKey").String()}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// dashScopeImageGen 通义万相(DashScope)实现:提交异步任务 → 轮询 task 状态 → 下载产物图片。
|
||||
// 文档:https://help.aliyun.com/zh/model-studio/text-to-image-api-reference
|
||||
type dashScopeImageGen struct {
|
||||
apiKey string
|
||||
}
|
||||
|
||||
const (
|
||||
dashScopeSynthUrl = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis"
|
||||
dashScopeTaskUrlFmt = "https://dashscope.aliyuncs.com/api/v1/tasks/%s"
|
||||
)
|
||||
|
||||
func (p *dashScopeImageGen) Generate(ctx context.Context, prompt, size string) ([]byte, error) {
|
||||
model := g.Cfg().MustGet(ctx, "imageGen.model", "qwen-image-3.0").String()
|
||||
// 前端尺寸格式 1152x2048 → API 规格 1152*2048
|
||||
apiSize := strings.ReplaceAll(size, "x", "*")
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"model": model,
|
||||
"input": map[string]string{"prompt": prompt, "size": apiSize},
|
||||
"parameters": map[string]any{"n": 1, "watermark": false},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := p.post(ctx, dashScopeSynthUrl, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taskId, ok := resp["task_id"].(string)
|
||||
if !ok || taskId == "" {
|
||||
return nil, fmt.Errorf("DashScope 提交失败: %v", resp)
|
||||
}
|
||||
// 轮询任务结果:异步生成通常 10~60s,上限 120s(与设计一致:超时 2min/张)
|
||||
deadline := time.Now().Add(120 * time.Second)
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("DashScope 生成超时(120s)")
|
||||
}
|
||||
task, err := p.post(ctx, fmt.Sprintf(dashScopeTaskUrlFmt, taskId), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status, _ := task["task_status"].(string)
|
||||
switch status {
|
||||
case "SUCCEEDED":
|
||||
results, _ := task["results"].([]any)
|
||||
if len(results) == 0 {
|
||||
return nil, fmt.Errorf("DashScope 成功但无产物图片")
|
||||
}
|
||||
url, _ := results[0].(map[string]any)["url"].(string)
|
||||
if url == "" {
|
||||
return nil, fmt.Errorf("DashScope 成功但无图片 URL")
|
||||
}
|
||||
return p.download(ctx, url)
|
||||
case "FAILED", "CANCELED":
|
||||
msg, _ := task["message"].(string)
|
||||
return nil, fmt.Errorf("DashScope 生成失败: %s", msg)
|
||||
}
|
||||
select {
|
||||
case <-time.After(2 * time.Second):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// post 调用 DashScope HTTP 接口并解析统一 JSON(无 body 时为空 GET,轮询任务用)
|
||||
func (p *dashScopeImageGen) post(ctx context.Context, url string, body []byte) (map[string]any, error) {
|
||||
method := http.MethodPost
|
||||
var rd io.Reader
|
||||
if body == nil {
|
||||
method = http.MethodGet
|
||||
} else {
|
||||
rd = strings.NewReader(string(body))
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, rd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("DashScope HTTP %d: %s", resp.StatusCode, truncateStr(string(raw), 200))
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// download 下载生成产物图片(存于阿里云 OSS,无需鉴权)
|
||||
func (p *dashScopeImageGen) download(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("下载生成图片失败: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
}
|
||||
|
||||
func truncateStr(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// LocalAi RF-DETR 检测服务客户端(local-ai 兼容 /v1/detection 协议):
|
||||
// POST {baseUrl}/v1/detection,body {"model","image":"data:image/jpeg;base64,...","threshold"},
|
||||
// 响应 {"detections":[{"x","y","width","height","confidence","class_name"}]},坐标单位 = 提交图片像素。
|
||||
type LocalAi struct {
|
||||
BaseUrl string
|
||||
Model string
|
||||
Threshold float64
|
||||
ConfConfirmed float64
|
||||
// 重叠去重阈值(minIoU = 交叠面积/两框较小面积):与高置信框重叠超过该值的框剔除(同目标只留一个)。
|
||||
// 用 minIoU 而非 IoU:RF-DETR 对同一目标常输出一大一小两个框,标准 IoU 可能仅 0.3~0.5 而漏杀,
|
||||
// 大框套小框时小框被覆盖比例高,minIoU 能命中;相邻目标两框互有外露,minIoU 通常 < 0.3。
|
||||
OverlapThreshold float64
|
||||
// 提交前整图等比缩放到的最长边(RF-DETR 对小图更稳)
|
||||
InputSize int
|
||||
}
|
||||
|
||||
// Detection RF-DETR 单目标检测结果(提交图比例尺下的像素坐标)
|
||||
type Detection struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
ClassName string `json:"class_name"`
|
||||
}
|
||||
|
||||
// LocalAiClient 当前配置的标注服务客户端(未配置 baseUrl 返回 nil,调用方判 CodeLocalAiNotConfigured)
|
||||
func LocalAiClient(ctx context.Context) *LocalAi {
|
||||
base := g.Cfg().MustGet(ctx, "localAi.baseUrl").String()
|
||||
if base == "" {
|
||||
return nil
|
||||
}
|
||||
return &LocalAi{
|
||||
BaseUrl: strings.TrimRight(base, "/"),
|
||||
Model: g.Cfg().MustGet(ctx, "localAi.model", "rfdetr-xlarge").String(),
|
||||
Threshold: g.Cfg().MustGet(ctx, "localAi.threshold", 0.08).Float64(),
|
||||
ConfConfirmed: g.Cfg().MustGet(ctx, "localAi.confConfirmed", 0.2).Float64(),
|
||||
OverlapThreshold: g.Cfg().MustGet(ctx, "localAi.overlapThreshold", 0.3).Float64(),
|
||||
InputSize: g.Cfg().MustGet(ctx, "localAi.inputSize", 700).Int(),
|
||||
}
|
||||
}
|
||||
|
||||
// Detect 对单张图片做全图检测(不做任何裁剪,位置由模型自行推理):
|
||||
// 整图等比缩放至最长边 InputSize 提交,坐标映射回原图像素后返回。
|
||||
// imgW/imgH 为原图尺寸;返回坐标均为原图像素尺度。
|
||||
func (c *LocalAi) Detect(ctx context.Context, data []byte, mime string, imgW, imgH int) ([]*Detection, error) {
|
||||
sub, scale := c.prepare(data, mime, imgW, imgH)
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"model": c.Model,
|
||||
"image": fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(sub)),
|
||||
"threshold": c.Threshold,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseUrl+"/v1/detection",
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("RF-DETR HTTP %d: %s", resp.StatusCode, truncateStr(string(raw), 200))
|
||||
}
|
||||
var out struct {
|
||||
Detections []*Detection `json:"detections"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 坐标从提交图比例尺映射回原图
|
||||
for _, d := range out.Detections {
|
||||
d.X /= scale
|
||||
d.Y /= scale
|
||||
d.Width /= scale
|
||||
d.Height /= scale
|
||||
}
|
||||
return out.Detections, nil
|
||||
}
|
||||
|
||||
// prepare 整图等比缩放至最长边 InputSize(等比,不裁剪),返回提交字节与缩放比(原图/提交图)。
|
||||
func (c *LocalAi) prepare(data []byte, mime string, imgW, imgH int) ([]byte, float64) {
|
||||
if imgW <= 0 || imgH <= 0 || imgW <= c.InputSize && imgH <= c.InputSize {
|
||||
return data, 1
|
||||
}
|
||||
scale := float64(c.InputSize) / float64(maxInt(imgW, imgH))
|
||||
w, h := maxInt(1, int(float64(imgW)*scale)), maxInt(1, int(float64(imgH)*scale))
|
||||
src, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return data, 1
|
||||
}
|
||||
dst := bilinearResize(src, w, h)
|
||||
var buf bytes.Buffer
|
||||
if strings.Contains(mime, "png") {
|
||||
_ = png.Encode(&buf, dst)
|
||||
} else {
|
||||
_ = jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 92})
|
||||
}
|
||||
return buf.Bytes(), scale
|
||||
}
|
||||
|
||||
// bilinearResize 双线性缩放(RF-DETR 对小图鲁棒,检测场景无需高质量插值)
|
||||
func bilinearResize(src image.Image, w, h int) *image.RGBA {
|
||||
b := src.Bounds()
|
||||
dst := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
if b.Dx() == 0 || b.Dy() == 0 {
|
||||
return dst
|
||||
}
|
||||
for y := 0; y < h; y++ {
|
||||
sy := float64(y) * float64(b.Dy()-1) / float64(maxInt(h-1, 1))
|
||||
y0, y1 := int(sy), minInt(int(sy)+1, b.Dy()-1)
|
||||
fy := sy - float64(y0)
|
||||
for x := 0; x < w; x++ {
|
||||
sx := float64(x) * float64(b.Dx()-1) / float64(maxInt(w-1, 1))
|
||||
x0, x1 := int(sx), minInt(int(sx)+1, b.Dx()-1)
|
||||
fx := sx - float64(x0)
|
||||
r00, g00, b00, _ := src.At(b.Min.X+x0, b.Min.Y+y0).RGBA()
|
||||
r10, g10, b10, _ := src.At(b.Min.X+x1, b.Min.Y+y0).RGBA()
|
||||
r01, g01, b01, _ := src.At(b.Min.X+x0, b.Min.Y+y1).RGBA()
|
||||
r11, g11, b11, _ := src.At(b.Min.X+x1, b.Min.Y+y1).RGBA()
|
||||
top := func(v00, v10 uint32) uint8 {
|
||||
return uint8((float64(v00)*(1-fx) + float64(v10)*fx) / 257)
|
||||
}
|
||||
bot := func(v01, v11 uint32) uint8 {
|
||||
return uint8((float64(v01)*(1-fx) + float64(v11)*fx) / 257)
|
||||
}
|
||||
r := uint8((float64(top(r00, r10))*(1-fy) + float64(bot(r01, r11))*fy))
|
||||
gx := uint8((float64(top(g00, g10))*(1-fy) + float64(bot(g01, g11))*fy))
|
||||
bb := uint8((float64(top(b00, b10))*(1-fy) + float64(bot(b01, b11))*fy))
|
||||
dst.Set(x, y, color.RGBA{R: r, G: gx, B: bb, A: 255})
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -50,3 +50,44 @@ func (p *CallbackPool) Submit(ctx context.Context, fn func(ctx context.Context)
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// LabelTaskPool 预标注任务并发池:逐张图片调 RF-DETR(IO 等待为主),
|
||||
// 并发度来自 config.yml labelTask.poolSize,缺失或非法时回退 consts 默认值。
|
||||
// 池内任务禁止提交本池(防 worker 饿死死锁);DB 写仍走 Serial 单写者。
|
||||
type LabelTaskPool struct {
|
||||
pool *grpool.Pool
|
||||
}
|
||||
|
||||
var (
|
||||
labelTaskPoolOnce sync.Once
|
||||
labelTaskPool *LabelTaskPool
|
||||
)
|
||||
|
||||
// LabelTaskPoolInstance 进程级预标注池单例(懒初始化,读取配置)。
|
||||
func LabelTaskPoolInstance() *LabelTaskPool {
|
||||
labelTaskPoolOnce.Do(func() {
|
||||
ctx := context.Background()
|
||||
size := g.Cfg().MustGet(ctx, "labelTask.poolSize", consts.LabelPoolDefaultSize).Int()
|
||||
if size <= 0 {
|
||||
size = consts.LabelPoolDefaultSize
|
||||
}
|
||||
labelTaskPool = &LabelTaskPool{pool: grpool.New(size, size)}
|
||||
})
|
||||
return labelTaskPool
|
||||
}
|
||||
|
||||
// Submit 提交单张图片的预标注任务并等待完成,返回任务的 error。
|
||||
func (p *LabelTaskPool) Submit(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||
res := make(chan error, 1)
|
||||
if err := p.pool.Add(ctx, func(ctx context.Context) {
|
||||
res <- fn(ctx)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case err := <-res:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// TrainingRunner 训练通道抽象(config.yml training.mode 选择实现):训练机与 Go 服务器
|
||||
// 可同机(subprocess)或异机(ssh)。任务脚本 server/training/train_server.py 以 --task-json 驱动,
|
||||
// 产物约定(相对训练机 workdir):
|
||||
//
|
||||
// tasks/<taskId>.json 任务参数(Go 侧写入)
|
||||
// logs/<taskId>.jsonl 每 epoch 一行 JSON:{"epoch","total","metrics"}
|
||||
// results/<taskId>.json 结束结果:{"metrics","names","best_tflite"(相对路径)}
|
||||
// artifacts/<taskId>.zip 打包产物(best.pt + results.csv + 曲线)
|
||||
type TrainingRunner interface {
|
||||
// Start 启动训练进程,返回可探测存活的 pid(subprocess 本机 pid;ssh 远程 pid)
|
||||
Start(ctx context.Context, job *TrainingJob) (int, error)
|
||||
// IsAlive 进程存活探测
|
||||
IsAlive(ctx context.Context, job *TrainingJob) (bool, error)
|
||||
// Cancel 终止训练(杀进程组,含 ultralytics 子进程)
|
||||
Cancel(ctx context.Context, job *TrainingJob) error
|
||||
// FetchLogTail 拉取日志尾部(截断 N KB 返回,供轮询解析 epoch 进度)
|
||||
FetchLogTail(ctx context.Context, job *TrainingJob) (string, error)
|
||||
// FetchResult 读取结束结果 JSON 内容;不存在返回 nil(任务仍在跑)
|
||||
FetchResult(ctx context.Context, job *TrainingJob) (string, error)
|
||||
// FetchArtifact 把训练机产物文件拉回服务器本地路径
|
||||
FetchArtifact(ctx context.Context, job *TrainingJob, remoteName, localPath string) error
|
||||
// SyncYoloDataset 把训练集包落到训练机(subprocess 直写 workdir;ssh 走 tar 流式管道,本地不落盘)
|
||||
SyncYoloDataset(ctx context.Context, job *TrainingJob, pkg *YoloPackage) error
|
||||
// WriteTaskJson 把任务参数文件写到训练机(随 Start 前的准备阶段调用)
|
||||
WriteTaskJson(ctx context.Context, job *TrainingJob, content string) error
|
||||
}
|
||||
|
||||
// YoloFile 训练集包内单个文件:Name 为包内相对路径(如 images/train/x.jpg / dataset.yaml),
|
||||
// ImagePath 非空时内容取自该文件(原图直接读数据集目录,不复制暂存),否则用 Content。
|
||||
type YoloFile struct {
|
||||
Name string
|
||||
ImagePath string
|
||||
Content []byte
|
||||
}
|
||||
|
||||
// YoloPackage 内存中的 YOLO 训练集包(训练前按 80/20 拆 train/val 组装,不落本地磁盘)
|
||||
type YoloPackage struct {
|
||||
Files []YoloFile
|
||||
}
|
||||
|
||||
// TrainingJob 训练任务运行信息(runner 视角;字段为训练机路径布局)。
|
||||
// SSH 凭据不随任务传递:sshRunner 直接读 config.yml training.ssh 节点。
|
||||
type TrainingJob struct {
|
||||
TaskId int64
|
||||
DatasetName string // yolo 数据集目录名(训练机 datasetDir/yolo/<name>)
|
||||
Python string // 训练机 venv python 路径
|
||||
Workdir string // 训练机工作目录(train_server.py / yolov8n.pt 所在)
|
||||
DatasetDir string // 训练机数据集根目录(相对 workdir)
|
||||
}
|
||||
|
||||
// Runner 按 config.yml training.mode 返回训练通道(未配置返回 nil,调用方判 CodeTrainingNotConfigured)
|
||||
func Runner(ctx context.Context) TrainingRunner {
|
||||
mode := g.Cfg().MustGet(ctx, "training.mode", "").String()
|
||||
switch mode {
|
||||
case "subprocess":
|
||||
return &subprocessRunner{}
|
||||
case "ssh":
|
||||
return &sshRunner{}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// TrainingConfig 训练通道配置快照
|
||||
type TrainingConfig struct {
|
||||
Workdir string
|
||||
DatasetDir string
|
||||
Python string
|
||||
TimeoutMins int
|
||||
}
|
||||
|
||||
// TrainingConfigOf 读取训练通道配置(未配置返回 ok=false)
|
||||
func TrainingConfigOf(ctx context.Context) (TrainingConfig, bool) {
|
||||
cfg := TrainingConfig{
|
||||
Workdir: g.Cfg().MustGet(ctx, "training.workdir").String(),
|
||||
DatasetDir: g.Cfg().MustGet(ctx, "training.datasetDir", "datasets").String(),
|
||||
Python: g.Cfg().MustGet(ctx, "training.venvPython").String(),
|
||||
TimeoutMins: g.Cfg().MustGet(ctx, "training.timeoutMinutes", 600).Int(),
|
||||
}
|
||||
return cfg, cfg.Workdir != "" && cfg.Python != ""
|
||||
}
|
||||
|
||||
// ---------------- subprocess 实现(同机) ----------------
|
||||
|
||||
type subprocessRunner struct {
|
||||
mu sync.Mutex
|
||||
cmds map[int64]*exec.Cmd // taskId → 进程(并发度 1,实际最多一条)
|
||||
}
|
||||
|
||||
func (r *subprocessRunner) Start(ctx context.Context, job *TrainingJob) (int, error) {
|
||||
script := filepath.Join(job.Workdir, "train_server.py")
|
||||
taskJson := filepath.Join(job.Workdir, "tasks", fmt.Sprintf("%d.json", job.TaskId))
|
||||
cmd := exec.Command(job.Python, script, "--task-json", taskJson)
|
||||
cmd.Dir = job.Workdir
|
||||
// 独立进程组:取消时 kill 整个组,连带 ultralytics 的子进程
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, gerror.Wrap(err, "启动训练进程失败")
|
||||
}
|
||||
r.mu.Lock()
|
||||
if r.cmds == nil {
|
||||
r.cmds = map[int64]*exec.Cmd{}
|
||||
}
|
||||
r.cmds[job.TaskId] = cmd
|
||||
r.mu.Unlock()
|
||||
return cmd.Process.Pid, nil
|
||||
}
|
||||
|
||||
func (r *subprocessRunner) IsAlive(ctx context.Context, job *TrainingJob) (bool, error) {
|
||||
r.mu.Lock()
|
||||
cmd := r.cmds[job.TaskId]
|
||||
r.mu.Unlock()
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return false, nil
|
||||
}
|
||||
err := cmd.Process.Signal(syscall.Signal(0))
|
||||
if err != nil {
|
||||
if err == os.ErrProcessDone {
|
||||
return false, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *subprocessRunner) Cancel(ctx context.Context, job *TrainingJob) error {
|
||||
r.mu.Lock()
|
||||
cmd := r.cmds[job.TaskId]
|
||||
r.mu.Unlock()
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
// 杀进程组(负 pid),覆盖 python + ultralytics 子进程
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *subprocessRunner) FetchLogTail(ctx context.Context, job *TrainingJob) (string, error) {
|
||||
path := filepath.Join(job.Workdir, "logs", fmt.Sprintf("%d.jsonl", job.TaskId))
|
||||
return tailFile(path, 8*1024)
|
||||
}
|
||||
|
||||
func (r *subprocessRunner) FetchResult(ctx context.Context, job *TrainingJob) (string, error) {
|
||||
path := filepath.Join(job.Workdir, "results", fmt.Sprintf("%d.json", job.TaskId))
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (r *subprocessRunner) FetchArtifact(ctx context.Context, job *TrainingJob, remoteName, localPath string) error {
|
||||
src := filepath.Join(job.Workdir, filepath.FromSlash(remoteName))
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "读取训练机产物失败")
|
||||
}
|
||||
return WriteFileAtomic(localPath, data)
|
||||
}
|
||||
|
||||
func (r *subprocessRunner) SyncYoloDataset(ctx context.Context, job *TrainingJob, pkg *YoloPackage) error {
|
||||
// 同机训练:训练进程直接读 workdir 下文件,包直写目标目录(无中间暂存)
|
||||
dst := filepath.Join(job.Workdir, job.DatasetDir, "yolo", job.DatasetName)
|
||||
_ = os.RemoveAll(dst)
|
||||
for _, f := range pkg.Files {
|
||||
data := f.Content
|
||||
if f.ImagePath != "" {
|
||||
imgData, rErr := os.ReadFile(f.ImagePath)
|
||||
if rErr != nil {
|
||||
return gerror.Wrapf(rErr, "读取原图失败: %s", f.ImagePath)
|
||||
}
|
||||
data = imgData
|
||||
}
|
||||
if err := WriteFileAtomic(filepath.Join(dst, filepath.FromSlash(f.Name)), data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *subprocessRunner) WriteTaskJson(ctx context.Context, job *TrainingJob, content string) error {
|
||||
dir := filepath.Join(job.Workdir, "tasks")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteFileAtomic(filepath.Join(dir, fmt.Sprintf("%d.json", job.TaskId)), []byte(content))
|
||||
}
|
||||
|
||||
// ---------------- ssh 实现(异机) ----------------
|
||||
|
||||
type sshRunner struct {
|
||||
mu sync.Mutex
|
||||
pids map[int64]int // taskId → 远程 pid
|
||||
}
|
||||
|
||||
// dial 建立 SSH 连接(凭据直读 config.yml training.ssh:privateKeyPath 与 password 二选一)
|
||||
func (r *sshRunner) dial(ctx context.Context) (*ssh.Client, error) {
|
||||
host := g.Cfg().MustGet(ctx, "training.ssh.host").String()
|
||||
if host == "" {
|
||||
return nil, gerror.New("training.ssh 未配置 host")
|
||||
}
|
||||
user := g.Cfg().MustGet(ctx, "training.ssh.user").String()
|
||||
port := g.Cfg().MustGet(ctx, "training.ssh.port", 22).Int()
|
||||
password := g.Cfg().MustGet(ctx, "training.ssh.password").String()
|
||||
keyPath := g.Cfg().MustGet(ctx, "training.ssh.privateKeyPath").String()
|
||||
cfg := &ssh.ClientConfig{
|
||||
User: user,
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // 内网训练机,信任首次连接
|
||||
Timeout: 15e9,
|
||||
}
|
||||
if keyPath != "" {
|
||||
data, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "读取 SSH 私钥失败")
|
||||
}
|
||||
signer, err := ssh.ParsePrivateKey(data)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "解析 SSH 私钥失败")
|
||||
}
|
||||
cfg.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
|
||||
} else if password != "" {
|
||||
cfg.Auth = []ssh.AuthMethod{ssh.Password(password)}
|
||||
} else {
|
||||
return nil, gerror.New("training.ssh 未配置认证方式(私钥或密码)")
|
||||
}
|
||||
return ssh.Dial("tcp", fmt.Sprintf("%s:%d", host, port), cfg)
|
||||
}
|
||||
|
||||
// runCmd 远程执行单条命令,返回 stdout
|
||||
func (r *sshRunner) runCmd(ctx context.Context, job *TrainingJob, cmd string) (string, error) {
|
||||
client, err := r.dial(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = session.Close() }()
|
||||
var buf bytes.Buffer
|
||||
session.Stdout = &buf
|
||||
session.Stderr = &buf
|
||||
if err := session.Run(cmd); err != nil {
|
||||
return "", gerror.Wrapf(err, "远程命令失败: %s", cmd)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func (r *sshRunner) Start(ctx context.Context, job *TrainingJob) (int, error) {
|
||||
taskJson := filepath.Join(job.Workdir, "tasks", fmt.Sprintf("%d.json", job.TaskId))
|
||||
cmd := fmt.Sprintf("cd %s && nohup %s train_server.py --task-json %s > logs/%d.stdout 2>&1 & echo $!",
|
||||
job.Workdir, job.Python, taskJson, job.TaskId)
|
||||
out, err := r.runCmd(ctx, job, cmd)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(out))
|
||||
if err != nil {
|
||||
return 0, gerror.New("SSH 启动未返回 pid: " + out)
|
||||
}
|
||||
r.mu.Lock()
|
||||
if r.pids == nil {
|
||||
r.pids = map[int64]int{}
|
||||
}
|
||||
r.pids[job.TaskId] = pid
|
||||
r.mu.Unlock()
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
func (r *sshRunner) IsAlive(ctx context.Context, job *TrainingJob) (bool, error) {
|
||||
r.mu.Lock()
|
||||
pid := r.pids[job.TaskId]
|
||||
r.mu.Unlock()
|
||||
if pid == 0 {
|
||||
return false, nil
|
||||
}
|
||||
out, err := r.runCmd(ctx, job, fmt.Sprintf("kill -0 %d 2>/dev/null && echo alive || echo dead", pid))
|
||||
if err != nil {
|
||||
return false, nil // 连接故障按"状态未知"处理,不误判任务结束
|
||||
}
|
||||
return strings.TrimSpace(out) == "alive", nil
|
||||
}
|
||||
|
||||
func (r *sshRunner) Cancel(ctx context.Context, job *TrainingJob) error {
|
||||
r.mu.Lock()
|
||||
pid := r.pids[job.TaskId]
|
||||
r.mu.Unlock()
|
||||
if pid == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := r.runCmd(ctx, job, fmt.Sprintf("kill -9 %d 2>/dev/null; pkill -9 -P %d 2>/dev/null; true", pid, pid))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *sshRunner) FetchLogTail(ctx context.Context, job *TrainingJob) (string, error) {
|
||||
return r.runCmd(ctx, job, fmt.Sprintf("tail -c 8192 %s/logs/%d.jsonl", job.Workdir, job.TaskId))
|
||||
}
|
||||
|
||||
func (r *sshRunner) FetchResult(ctx context.Context, job *TrainingJob) (string, error) {
|
||||
out, err := r.runCmd(ctx, job, fmt.Sprintf("cat %s/results/%d.json 2>/dev/null", job.Workdir, job.TaskId))
|
||||
if err != nil || out == "" {
|
||||
return "", nil // 文件不存在 = 仍在跑
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *sshRunner) FetchArtifact(ctx context.Context, job *TrainingJob, remoteName, localPath string) error {
|
||||
client, err := r.dial(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = session.Close() }()
|
||||
src := filepath.Join(job.Workdir, filepath.FromSlash(remoteName))
|
||||
data, err := session.Output(fmt.Sprintf("cat %s", src))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "拉取训练机产物失败")
|
||||
}
|
||||
return WriteFileAtomic(localPath, data)
|
||||
}
|
||||
|
||||
func (r *sshRunner) SyncYoloDataset(ctx context.Context, job *TrainingJob, pkg *YoloPackage) error {
|
||||
// tar 流式管道:原图直接读数据集目录打包,stdin 推远端解包,本地不落盘
|
||||
client, err := r.dial(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = session.Close() }()
|
||||
parent := fmt.Sprintf("%s/%s", job.Workdir, job.DatasetDir)
|
||||
dst := fmt.Sprintf("%s/%s/yolo/%s", job.Workdir, job.DatasetDir, job.DatasetName)
|
||||
// tar 解包到 parent,条目前缀 yolo/<name>/ 即落到 dst
|
||||
cmd := fmt.Sprintf("mkdir -p %s && rm -rf %s && tar -xf - -C %s", parent, dst, parent)
|
||||
pr, pw := io.Pipe()
|
||||
session.Stdin = pr
|
||||
tarErr := make(chan error, 1)
|
||||
go func() {
|
||||
defer pw.Close() // 失败路径也必须关管道,否则远端 tar 等不到 EOF
|
||||
tarErr <- writeYoloTar(pw, job.DatasetName, pkg)
|
||||
}()
|
||||
runErr := session.Run(cmd)
|
||||
_ = pr.Close()
|
||||
if werr := <-tarErr; werr != nil {
|
||||
return werr
|
||||
}
|
||||
if runErr != nil {
|
||||
return gerror.Wrap(runErr, "tar 同步数据集失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeYoloTar 把训练集包写入 tar 流(条目前缀 yolo/<name>/,供远端 tar -xf 解包)
|
||||
func writeYoloTar(w io.Writer, datasetName string, pkg *YoloPackage) error {
|
||||
tw := tar.NewWriter(w)
|
||||
for _, f := range pkg.Files {
|
||||
data := f.Content
|
||||
if f.ImagePath != "" {
|
||||
imgData, rErr := os.ReadFile(f.ImagePath)
|
||||
if rErr != nil {
|
||||
return gerror.Wrapf(rErr, "读取原图失败: %s", f.ImagePath)
|
||||
}
|
||||
data = imgData
|
||||
}
|
||||
hdr := &tar.Header{
|
||||
Name: filepath.ToSlash(filepath.Join("yolo", datasetName, f.Name)),
|
||||
Mode: 0o644,
|
||||
Size: int64(len(data)),
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return gerror.Wrap(err, "写 tar 头失败")
|
||||
}
|
||||
if _, err := tw.Write(data); err != nil {
|
||||
return gerror.Wrap(err, "写 tar 内容失败")
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return gerror.Wrap(err, "关闭 tar 流失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sshRunner) WriteTaskJson(ctx context.Context, job *TrainingJob, content string) error {
|
||||
// 通过 ssh stdin 管道写文件(避免命令行转义地狱)
|
||||
client, err := r.dial(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = session.Close() }()
|
||||
dir := filepath.Join(job.Workdir, "tasks")
|
||||
path := filepath.Join(dir, fmt.Sprintf("%d.json", job.TaskId))
|
||||
cmd := fmt.Sprintf("mkdir -p %s && cat > %s", dir, path)
|
||||
session.Stdin = strings.NewReader(content)
|
||||
return session.Run(cmd)
|
||||
}
|
||||
|
||||
// ---------------- 文件工具 ----------------
|
||||
|
||||
// atomicWriteFile tmp + rename 原子写(避免中断产生半截文件)
|
||||
func WriteFileAtomic(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// tailFile 读文件末尾最多 maxBytes 字节
|
||||
func tailFile(path string, maxBytes int64) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
start := st.Size() - maxBytes
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
buf := make([]byte, st.Size()-start)
|
||||
if _, err := f.ReadAt(buf, start); err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package common
|
||||
|
||||
import "crypto/rand"
|
||||
|
||||
// UuidV4 生成 UUIDv4 字符串(crypto/rand 16 字节 + 版本/变体位,无第三方依赖);
|
||||
// 用于封面等文件名,避免固定命名碰撞。
|
||||
func UuidV4() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
panic(err) // crypto/rand 失败属系统级故障,直接崩溃重启
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
|
||||
const hex = "0123456789abcdef"
|
||||
out := make([]byte, 36)
|
||||
j := 0
|
||||
for i := 0; i < 16; i++ {
|
||||
if i == 4 || i == 6 || i == 8 || i == 10 {
|
||||
out[j] = '-'
|
||||
j++
|
||||
}
|
||||
out[j] = hex[b[i]>>4]
|
||||
j++
|
||||
out[j] = hex[b[i]&0x0f]
|
||||
j++
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// 训练体系运行时数据布局(config.yml app.datasetDir,默认 ./workspace,挂载持久化、不提交 git):
|
||||
//
|
||||
// datasets/<name>/ 数据集图片(平铺,文件名唯一,标注存 DB dataset_image.labels_json)
|
||||
// models/<name>/ latest.tflite 当前生效副本(无存档,客户端固定下载)
|
||||
// trainings/<taskId>/ 训练产物(best.tflite + artifact.zip)
|
||||
//
|
||||
// 训练机与 Go 服务器异机时,数据集经 training 通道同步(见 common/training_runner.go)。
|
||||
|
||||
// DatasetDir 训练体系运行时数据根目录:config.yml app.datasetDir(默认 ./workspace)
|
||||
func DatasetDir(ctx context.Context) string {
|
||||
return g.Cfg().MustGet(ctx, "app.datasetDir", "./workspace").String()
|
||||
}
|
||||
|
||||
// DatasetImagesDir 某数据集图片目录
|
||||
func DatasetImagesDir(ctx context.Context, datasetName string) string {
|
||||
return filepath.Join(DatasetDir(ctx), "datasets", datasetName)
|
||||
}
|
||||
|
||||
// DatasetModelsDir 某数据集模型目录
|
||||
func DatasetModelsDir(ctx context.Context, datasetName string) string {
|
||||
return filepath.Join(DatasetDir(ctx), "models", datasetName)
|
||||
}
|
||||
|
||||
// TrainingArtifactsDir 某训练任务产物目录(拉回的 best.tflite + artifact.zip)
|
||||
func TrainingArtifactsDir(ctx context.Context, taskId int64) string {
|
||||
return filepath.Join(DatasetDir(ctx), "trainings", strconv.FormatInt(taskId, 10))
|
||||
}
|
||||
|
||||
// Sha256Hex 计算文件内容 SHA-256 十六进制(模型版本校验用)
|
||||
func Sha256Hex(data []byte) (string, error) {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
@@ -28,6 +28,43 @@ app:
|
||||
# Android APK 上传目录(运行时数据,与 ./data 平级、docker-compose 挂载持久化;
|
||||
# 目录下永远只保留最新一个文件 observer-latest.apk,下载地址固定 /download/observer-latest.apk)
|
||||
apkDir: "./workspace"
|
||||
# 模型训练体系运行时数据根目录(图片/标注/模型/训练产物;与 apkDir 同目录时共用 workspace)
|
||||
datasetDir: "./workspace"
|
||||
|
||||
# 图像生成(管理端「AI 生成图片」):未配置 apiKey 时生成接口返回「图像生成服务未配置」
|
||||
imageGen:
|
||||
provider: dashscope # 当前唯一实现
|
||||
apiKey: "" # DashScope API Key
|
||||
model: qwen-image-3.0
|
||||
|
||||
# 训练通道(并发度 1:GPU 独占,同时仅一个 running 任务):
|
||||
# mode=subprocess 训练机与服务器同机;mode=ssh 异机(训练脚本/数据集经 ssh 通道同步)
|
||||
training:
|
||||
mode: subprocess # subprocess | ssh
|
||||
ssh:
|
||||
host: "192.168.3.210" # 训练机地址(mode=ssh 必填)
|
||||
user: "root"
|
||||
port: 22
|
||||
privateKeyPath: "" # 私钥路径与 password 二选一
|
||||
password: "123"
|
||||
workdir: /opt/pheasant_data # 训练机工作目录(train_server.py / yolov8n.pt 所在)
|
||||
venvPython: /opt/pheasant_data/venv/bin/python
|
||||
datasetDir: datasets # 训练机数据集根目录(相对 workdir,yolo/<name> 为子目录)
|
||||
timeoutMinutes: 600 # 训练超时判死
|
||||
|
||||
# RF-DETR 预标注服务(管理端标注工作台):需从服务器可达,未配置时预标注接口返回「标注服务未配置」
|
||||
localAi:
|
||||
baseUrl: "http://192.168.3.210:18080" # 如 http://127.0.0.1:18080(local-ai 兼容 /v1/detection)
|
||||
model: rfdetr-xlarge
|
||||
threshold: 0.08 # 候选置信度阈值(宁多勿漏)
|
||||
confConfirmed: 0.2 # 高于此视为确认(class 0),否则疑似(class 1)
|
||||
overlapThreshold: 0.3 # 重叠去重阈值(交叠/较小框面积,NMS 风格;RF-DETR 同目标常输出一大一小两框,minIoU 比 IoU 更能命中)
|
||||
inputSize: 700 # 提交前整图等比缩放最长边
|
||||
classNames: [pheasant, suspect] # 标注类别名(写入 data.yaml,随 result.json 存模型 labels)
|
||||
|
||||
labelTask:
|
||||
# 预标注并发度(逐张调 RF-DETR,缺失或非法时回退默认值)
|
||||
poolSize: 4
|
||||
|
||||
# 套餐定价(静态配置,改价 = 改本节点后重启服务;金额单位为分,展示名由 days 派生"N天")
|
||||
plans:
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -7,6 +7,7 @@ require (
|
||||
github.com/gogf/gf/v2 v2.10.2
|
||||
github.com/smartwalle/alipay/v3 v3.2.31
|
||||
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
|
||||
golang.org/x/crypto v0.55.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -39,7 +40,6 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
|
||||
+2
-6
@@ -98,18 +98,14 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
+101
-3
@@ -9,29 +9,35 @@ import (
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
"observer-server/biz/controller"
|
||||
"observer-server/biz/service"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
initDatabase(ctx)
|
||||
// 封面存量迁移:历史固定命名 cover* → UUID jpg(幂等,新库空跑)
|
||||
if err := service.Dataset.MigrateLegacyCovers(ctx); err != nil {
|
||||
g.Log().Errorf(ctx, "封面存量迁移失败: %+v", err)
|
||||
}
|
||||
|
||||
s := g.Server()
|
||||
// Android APK 下载静态托管:app.apkDir 目录下固定文件 observer-latest.apk,
|
||||
// URL 固定 /download/observer-latest.apk(绕过统一响应包装,纯二进制流)
|
||||
// URL 固定 /download/observer-latest.apk(绕过统一响应包装,纯二进制流);
|
||||
// 模型热更新文件同根 /download/models/<数据集名>/latest.tflite(datasetDir 下 models 目录)
|
||||
if err := os.MkdirAll(common.ApkDir(ctx), 0o755); err != nil {
|
||||
g.Log().Fatalf(ctx, "创建 APK 目录失败: %+v", err)
|
||||
}
|
||||
s.AddStaticPath("/download", common.ApkDir(ctx))
|
||||
// 客户端接口组:统一响应包装 {"code":0,"message":"ok","data":...}
|
||||
// 账号组公开(注册/登录),业务组(订单/授权)需登录态(Authorization: Bearer token)
|
||||
// 账号组公开(注册/登录),业务组(订单/授权/模型目录)需登录态(Authorization: Bearer token)
|
||||
s.Group("/api/v1", func(group *ghttp.RouterGroup) {
|
||||
group.Middleware(common.UnifiedResponse)
|
||||
common.BindController(group, controller.Auth, controller.AppVersion)
|
||||
})
|
||||
s.Group("/api/v1", func(group *ghttp.RouterGroup) {
|
||||
group.Middleware(common.UnifiedResponse, common.AuthRequired)
|
||||
common.BindController(group, controller.Order, controller.License)
|
||||
common.BindController(group, controller.Order, controller.License, controller.ModelCatalog)
|
||||
})
|
||||
// 支付回调组:不做统一包装,按渠道应答格式直接返回(微信 SUCCESS/FAIL JSON、支付宝 success/failure 文本)
|
||||
s.Group("/api/v1/payment", func(group *ghttp.RouterGroup) {
|
||||
@@ -56,6 +62,8 @@ func main() {
|
||||
} else {
|
||||
g.Log().Warningf(ctx, "admin_dist 不存在,跳过管理端静态托管(cd server_admin && npm run build)")
|
||||
}
|
||||
// 后台协程:训练进度轮询 + 孤儿预标注任务恢复
|
||||
service.Training.StartBackgroundJobs(ctx)
|
||||
s.Run()
|
||||
}
|
||||
|
||||
@@ -104,4 +112,94 @@ func initDatabase(ctx context.Context) {
|
||||
}
|
||||
g.Log().Infof(ctx, "数据库初始化完成(version=3)")
|
||||
}
|
||||
if version < 8 {
|
||||
// v8:dataset 加训练配置/展示列 + label_task 加 filenames(多选批量标注)。
|
||||
// PRAGMA table_info 逐列检测缺失才 ADD COLUMN,全新库建表自带全列直接跳过
|
||||
addCols := []struct{ table, col, ddl string }{
|
||||
{"dataset", "cover", "TEXT"},
|
||||
{"dataset", "description", "TEXT"},
|
||||
{"dataset", "ai_endpoint", "TEXT"},
|
||||
{"dataset", "ai_model", "TEXT"},
|
||||
{"dataset", "train_host", "TEXT"},
|
||||
{"dataset", "train_user", "TEXT"},
|
||||
{"dataset", "train_password", "TEXT"},
|
||||
{"dataset", "train_key", "TEXT"},
|
||||
{"label_task", "filenames", "TEXT"},
|
||||
}
|
||||
for _, c := range addCols {
|
||||
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info("+c.table+")")
|
||||
if err != nil {
|
||||
g.Log().Fatalf(ctx, "读取 %s 表结构失败: %+v", c.table, err)
|
||||
}
|
||||
exists := false
|
||||
for _, col := range cols {
|
||||
if gconv.String(col["name"]) == c.col {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "ALTER TABLE "+c.table+" ADD COLUMN "+c.col+" "+c.ddl); err != nil {
|
||||
g.Log().Fatalf(ctx, "给 %s 加列 %s 失败: %+v", c.table, c.col, err)
|
||||
}
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "PRAGMA user_version = 8"); err != nil {
|
||||
g.Log().Fatalf(ctx, "库版本写入失败: %+v", err)
|
||||
}
|
||||
g.Log().Infof(ctx, "数据库初始化完成(version=8)")
|
||||
}
|
||||
if version < 10 {
|
||||
// v10:标注流程简化,撤销候选确认两阶段 —— dataset_image 删 candidates_json 列
|
||||
//(存量候选数据为空直接删;全新库建表已无此列、PRAGMA table_info 检测后跳过)
|
||||
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info(dataset_image)")
|
||||
if err != nil {
|
||||
g.Log().Fatalf(ctx, "读取 dataset_image 表结构失败: %+v", err)
|
||||
}
|
||||
hasCandidates := false
|
||||
for _, col := range cols {
|
||||
if gconv.String(col["name"]) == "candidates_json" {
|
||||
hasCandidates = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasCandidates {
|
||||
if _, err := g.DB().Exec(ctx, "ALTER TABLE dataset_image DROP COLUMN candidates_json"); err != nil {
|
||||
g.Log().Fatalf(ctx, "删除 candidates_json 列失败: %+v", err)
|
||||
}
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "PRAGMA user_version = 10"); err != nil {
|
||||
g.Log().Fatalf(ctx, "库版本写入失败: %+v", err)
|
||||
}
|
||||
g.Log().Infof(ctx, "数据库初始化完成(version=10)")
|
||||
}
|
||||
if version < 11 {
|
||||
// v11:移除模型存档回退机制 —— model_version 删 model_file 列
|
||||
//(模型文件不落表:发布即写 latest.tflite,客户端固定下载;存量库删列、新库建表已无此列自动跳过)
|
||||
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info(model_version)")
|
||||
if err != nil {
|
||||
g.Log().Fatalf(ctx, "读取 model_version 表结构失败: %+v", err)
|
||||
}
|
||||
hasModelFile := false
|
||||
for _, col := range cols {
|
||||
if gconv.String(col["name"]) == "model_file" {
|
||||
hasModelFile = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasModelFile {
|
||||
if _, err := g.DB().Exec(ctx, "ALTER TABLE model_version DROP COLUMN model_file"); err != nil {
|
||||
g.Log().Fatalf(ctx, "删除 model_file 列失败: %+v", err)
|
||||
}
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "PRAGMA user_version = 11"); err != nil {
|
||||
g.Log().Fatalf(ctx, "库版本写入失败: %+v", err)
|
||||
}
|
||||
g.Log().Infof(ctx, "数据库初始化完成(version=11)")
|
||||
}
|
||||
// 死表清理:app_config 全局训练配置表已撤销(配置走 config.yml),存量库残留表启动即删
|
||||
if _, err := g.DB().Exec(ctx, "DROP TABLE IF EXISTS app_config"); err != nil {
|
||||
g.Log().Fatalf(ctx, "删除残留 app_config 表失败: %+v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""训练任务脚本(Go 后端 --task-json 驱动,产物契约见 server/common/training_runner.go)。
|
||||
|
||||
用法:
|
||||
python train_server.py --task-json tasks/<taskId>.json
|
||||
|
||||
任务参数(Go 侧写入,字段相对训练机 workdir):
|
||||
workdir 训练机工作目录(脚本 / yolov8n.pt / venv 所在),启动即 chdir
|
||||
yolo 训练集目录(含 dataset.yaml),相对 workdir
|
||||
imgsz 训练/导出分辨率(默认 704,与端侧推理对齐)
|
||||
epochs 训练轮数
|
||||
batch 批大小
|
||||
device GPU 编号或 cpu
|
||||
project 训练输出目录(相对 workdir,末尾自动拼 name)
|
||||
log_file 每 epoch 一行 JSON 的进度日志(相对 workdir)
|
||||
result_file 结束结果 JSON(相对 workdir)
|
||||
artifact_zip 产物打包(best.pt + results.csv + 曲线,相对 workdir)
|
||||
|
||||
产物契约:
|
||||
log_file {"epoch":1,"total":150,"metrics":{"metrics/mAP50(B)":0.87,...}}
|
||||
result_file {"metrics":{...},"names":["pheasant","suspect"],"best_tflite":"runs/tasks/<id>/weights/best.tflite",
|
||||
"tflite_check":{"ok":true,"reason":"","inputs":[...],"outputs":[...]}}
|
||||
result_file 存在 = 训练完成;异常时写 {"error":"..."},Go 侧据以置失败并展示原因。
|
||||
tflite_check.ok=false(产物 shape 异常)时 Go 侧置训练失败并带出 reason。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import traceback
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
# 抗尺度漂移(与人工训练基线一致):随机缩放输入 0.5~1.5x
|
||||
MULTI_SCALE = 0.5
|
||||
# 早停耐心(连续 N 轮无提升即停)
|
||||
PATIENCE = 30
|
||||
|
||||
|
||||
def on_fit_epoch_end(trainer):
|
||||
"""ultralytics 回调:每 epoch 结束写一行进度 JSON(行缓冲,进程被杀不丢行)。"""
|
||||
metrics = {}
|
||||
for k, v in (trainer.metrics or {}).items():
|
||||
if isinstance(v, (int, float)) and math.isfinite(v):
|
||||
metrics[k] = round(float(v), 5)
|
||||
if _LOG is None:
|
||||
return
|
||||
total = getattr(trainer, "epochs", None) or getattr(trainer, "total_epochs", 0) or 0
|
||||
_LOG.write(json.dumps({
|
||||
"epoch": trainer.epoch + 1,
|
||||
"total": total,
|
||||
"metrics": metrics,
|
||||
}, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def register_callback():
|
||||
from ultralytics.utils.callbacks import callbacks
|
||||
callbacks["on_fit_epoch_end"].append(on_fit_epoch_end)
|
||||
|
||||
|
||||
def write_result(result_file, payload):
|
||||
Path(result_file).parent.mkdir(parents=True, exist_ok=True)
|
||||
# tmp + rename 原子写,避免 Go 侧读到半截文件
|
||||
tmp = result_file + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, result_file)
|
||||
|
||||
|
||||
def read_names(yolo_dir):
|
||||
"""从 data.yaml 读类别名(有序列表,ultralytics 依赖 pyyaml)"""
|
||||
try:
|
||||
import yaml
|
||||
with open(Path(yolo_dir) / "dataset.yaml", encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
names = cfg.get("names") or {}
|
||||
if isinstance(names, dict):
|
||||
return [names[k] for k in sorted(names, key=lambda k: int(k))]
|
||||
return list(names)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def find_best_tflite(save_dir):
|
||||
"""定位导出的 tflite(ultralytics 各版本产物位置不一):
|
||||
优先 fp32 best.tflite(与人工基线一致),再 float32/int8,最后兜底全局搜。"""
|
||||
weights = save_dir / "weights"
|
||||
for name in ("best.tflite", "best_float32.tflite", "best_int8.tflite"):
|
||||
p = weights / name
|
||||
if p.exists():
|
||||
return p
|
||||
candidates = sorted(weights.rglob("*.tflite"))
|
||||
if candidates:
|
||||
return candidates[0]
|
||||
return None
|
||||
|
||||
|
||||
# TFLite flatbuffer 张量类型(tensorflow/lite/schema/schema.fbs,字段顺序敏感)
|
||||
TENSOR_TYPE = {0: "FLOAT32", 1: "FLOAT16", 2: "INT32", 3: "UINT8", 4: "INT64",
|
||||
5: "STRING", 6: "BOOL", 7: "INT16", 8: "COMPLEX64", 9: "INT8",
|
||||
10: "FLOAT64", 11: "COMPLEX128", 12: "UINT64", 13: "RESOURCE",
|
||||
14: "VARIANT", 15: "UINT32", 16: "UINT16", 17: "INT4",
|
||||
18: "BFLOAT16", 19: "FLOAT8_E4M3FN", 20: "FLOAT8_E4M3FNUZ",
|
||||
21: "FLOAT8_E5M2", 22: "FLOAT8_E5M2FNUZ"}
|
||||
|
||||
|
||||
class _FB:
|
||||
"""极简 flatbuffer 读取器(仅 Model/SubGraph/Tensor 表所需字段)"""
|
||||
|
||||
def __init__(self, data):
|
||||
self.d = data
|
||||
|
||||
def u32(self, pos): return struct.unpack_from("<I", self.d, pos)[0]
|
||||
def i32(self, pos): return struct.unpack_from("<i", self.d, pos)[0]
|
||||
def u16(self, pos): return struct.unpack_from("<H", self.d, pos)[0]
|
||||
|
||||
def field(self, t, i):
|
||||
vto = self.i32(t)
|
||||
vt = t - vto
|
||||
vs = self.u16(vt)
|
||||
off = vt + 4 + 2 * i
|
||||
if off + 2 > vt + vs:
|
||||
return None
|
||||
f = self.u16(off)
|
||||
return None if f == 0 else t + f
|
||||
|
||||
def deref(self, pos):
|
||||
return pos + self.u32(pos) if pos is not None else None
|
||||
|
||||
def vec(self, pos):
|
||||
pos = self.deref(pos)
|
||||
if pos is None:
|
||||
return None
|
||||
return pos + 4, self.u32(pos)
|
||||
|
||||
def vec_table(self, pos):
|
||||
start, n = self.vec(pos)
|
||||
return [start + i * 4 + self.u32(start + i * 4) for i in range(n)]
|
||||
|
||||
def string(self, pos):
|
||||
pos = self.deref(pos)
|
||||
if pos is None:
|
||||
return None
|
||||
n = self.u32(pos)
|
||||
return self.d[pos + 4:pos + 4 + n].decode("utf-8", "replace")
|
||||
|
||||
def int_vec(self, pos):
|
||||
start, n = self.vec(pos)
|
||||
return [self.i32(start + 4 * i) for i in range(n)]
|
||||
|
||||
|
||||
def check_tflite(path, imgsz):
|
||||
"""TFLite 产物自检(原 inspect_tflite.py,2026-08-26 并入):
|
||||
输入恰 1 张且 4 维、元素总数 == imgsz²×3(NCHW/NHWC 皆可)、输出 ≥ 1 张且 batch 维 = 1。
|
||||
返回 {"ok","reason","inputs":[{"name","shape","type"}],"outputs":[...]}。"""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
fb = _FB(f.read())
|
||||
root = fb.u32(0) # Model 表
|
||||
subgraphs = fb.vec_table(fb.field(root, 2)) # Model.subgraphs [2]
|
||||
sg = subgraphs[0]
|
||||
tensors = fb.vec_table(fb.field(sg, 0)) # SubGraph.tensors [0]
|
||||
inputs = fb.int_vec(fb.field(sg, 1)) # SubGraph.inputs [1]
|
||||
outputs = fb.int_vec(fb.field(sg, 2)) # SubGraph.outputs [2]
|
||||
|
||||
def describe(i):
|
||||
t = tensors[i]
|
||||
shape = fb.int_vec(fb.field(t, 0))
|
||||
ttype = fb.field(t, 1)
|
||||
return {"name": fb.string(fb.field(t, 3)) or "?",
|
||||
"shape": shape,
|
||||
"type": TENSOR_TYPE.get(fb.d[ttype], fb.d[ttype]) if ttype else "?"}
|
||||
|
||||
in_desc = [describe(i) for i in inputs]
|
||||
out_desc = [describe(i) for i in outputs]
|
||||
|
||||
problems = []
|
||||
if len(in_desc) != 1:
|
||||
problems.append(f"输入张量 {len(in_desc)} 个(期望 1)")
|
||||
else:
|
||||
shape = in_desc[0]["shape"]
|
||||
n = 1
|
||||
for d in shape:
|
||||
n *= d
|
||||
if len(shape) != 4:
|
||||
problems.append(f"输入 shape {shape} 非 4 维")
|
||||
elif n != imgsz * imgsz * 3:
|
||||
problems.append(f"输入元素数 {n} ≠ imgsz²×3={imgsz * imgsz * 3}(shape {shape},imgsz={imgsz})")
|
||||
if not out_desc:
|
||||
problems.append("无输出张量")
|
||||
else:
|
||||
for o in out_desc:
|
||||
if o["shape"] and o["shape"][0] != 1:
|
||||
problems.append(f"输出 batch 维 ≠ 1: {o['shape']}")
|
||||
|
||||
return {"ok": not problems, "reason": ";".join(problems),
|
||||
"inputs": in_desc, "outputs": out_desc}
|
||||
except Exception as e:
|
||||
return {"ok": False, "reason": f"解析失败: {e}", "inputs": [], "outputs": []}
|
||||
|
||||
|
||||
def build_artifact_zip(zip_path, save_dir):
|
||||
Path(zip_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for p in (save_dir / "weights" / "best.pt", save_dir / "results.csv"):
|
||||
if p.exists():
|
||||
zf.write(p, arcname=p.name)
|
||||
# 曲线/混淆矩阵等绘图产物在 save_dir 根目录
|
||||
for p in sorted(save_dir.glob("*.png")) + sorted(save_dir.glob("*.jpg")):
|
||||
zf.write(p, arcname=p.name)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="observer 训练任务脚本(Go --task-json 驱动)")
|
||||
ap.add_argument("--task-json", required=True, help="任务参数 JSON 文件路径")
|
||||
args = ap.parse_args()
|
||||
|
||||
with open(args.task_json, encoding="utf-8") as f:
|
||||
task = json.load(f)
|
||||
|
||||
global _LOG
|
||||
base = os.path.abspath(task["workdir"])
|
||||
|
||||
def task_path(key):
|
||||
"""任务参数里的路径全部相对 workdir,这里绝对化(chdir 失败也能定位)"""
|
||||
p = task[key]
|
||||
return p if os.path.isabs(p) else os.path.join(base, p)
|
||||
|
||||
try:
|
||||
os.chdir(base)
|
||||
except Exception:
|
||||
pass # 目录不可用则后续训练必然失败,错误路径仍尽力写 result.json
|
||||
|
||||
log_file = task_path("log_file")
|
||||
result_file = task_path("result_file")
|
||||
artifact_zip = task_path("artifact_zip")
|
||||
global _LOG
|
||||
_LOG = None
|
||||
try:
|
||||
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
||||
_LOG = open(log_file, "a", encoding="utf-8", buffering=1)
|
||||
except Exception:
|
||||
pass # 日志不可写(如 workdir 缺失)不阻断错误路径,traceback 仍走 stderr
|
||||
|
||||
imgsz = int(task.get("imgsz") or 704)
|
||||
epochs = int(task.get("epochs") or 150)
|
||||
batch = int(task.get("batch") or 16)
|
||||
device = task.get("device") or "0"
|
||||
data = os.path.join(task["yolo"], "dataset.yaml")
|
||||
names = read_names(task["yolo"])
|
||||
|
||||
try:
|
||||
from ultralytics import YOLO
|
||||
register_callback()
|
||||
model = YOLO("yolov8n.pt")
|
||||
model.train(
|
||||
data=data, imgsz=imgsz, epochs=epochs,
|
||||
patience=PATIENCE, batch=batch, device=device, workers=4,
|
||||
# project 必须绝对路径,相对路径会被拼到默认 runs/detect 下造成双层嵌套
|
||||
project=task_path("project"), name="train",
|
||||
exist_ok=True, plots=True, multi_scale=MULTI_SCALE,
|
||||
)
|
||||
trainer = getattr(model, "trainer", None)
|
||||
if trainer is None:
|
||||
raise RuntimeError("训练完成但无法获取 trainer(save_dir 未知)")
|
||||
save_dir = Path(trainer.save_dir)
|
||||
|
||||
best_tflite = find_best_tflite(save_dir)
|
||||
if best_tflite is None:
|
||||
model.export(format="tflite", imgsz=imgsz)
|
||||
best_tflite = find_best_tflite(save_dir)
|
||||
if best_tflite is None:
|
||||
raise RuntimeError("tflite 导出失败:weights 目录下未找到任何 .tflite 产物")
|
||||
|
||||
# tflite 产物自检:shape 异常也写进 result.json(ok=false),Go 侧据此置失败
|
||||
tflite_check = check_tflite(str(best_tflite), imgsz)
|
||||
if not tflite_check["ok"]:
|
||||
print(f"[tflite-check] FAIL: {tflite_check['reason']}", file=sys.stderr)
|
||||
|
||||
# 结果指标取 best 轮(发布的是 best.pt),缺省回退末轮
|
||||
metrics = getattr(trainer, "best_metrics", None) or trainer.metrics
|
||||
clean_metrics = {}
|
||||
for k, v in (metrics or {}).items():
|
||||
if isinstance(v, (int, float)) and math.isfinite(v):
|
||||
clean_metrics[k] = round(float(v), 5)
|
||||
|
||||
build_artifact_zip(artifact_zip, save_dir)
|
||||
# best_tflite 相对 workdir,Go 侧按此路径拉取
|
||||
write_result(result_file, {
|
||||
"metrics": clean_metrics,
|
||||
"names": names,
|
||||
"best_tflite": os.path.relpath(best_tflite, base),
|
||||
"tflite_check": tflite_check,
|
||||
})
|
||||
except Exception:
|
||||
err = traceback.format_exc()
|
||||
if _LOG is not None:
|
||||
_LOG.write("\n" + err + "\n")
|
||||
try:
|
||||
write_result(result_file, {"error": err[-2000:]})
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+185
@@ -62,7 +62,96 @@ CREATE TABLE IF NOT EXISTS app_version (
|
||||
-- 无下载地址列:APK 为固定文件 app.apkDir/observer-latest.apk,上传即覆盖,目录永远只有一个文件
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dataset (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE, -- 数据集名(≤50 字),同时是服务器目录名
|
||||
source TEXT NOT NULL DEFAULT 'manual', -- manual | ai
|
||||
image_count INTEGER NOT NULL DEFAULT 0, -- 图片数(冗余计数,随增删更新)
|
||||
labeled_count INTEGER NOT NULL DEFAULT 0, -- 已标注数(二期标注任务完成累计)
|
||||
status TEXT NOT NULL DEFAULT 'building',-- building | labeled | synced
|
||||
cover TEXT, -- 封面文件名(上传自动转 jpg + UUID 命名,卡片展示)
|
||||
description TEXT, -- 描述(卡片展示)
|
||||
-- 遗留列(ai_endpoint/ai_model/train_host/train_user/train_password/train_key):
|
||||
-- AI 标注/训练机 SSH 配置统一走 config.yml(localAi / training.ssh),这 6 个覆盖列新代码不再读写,
|
||||
-- 保留不迁移(存量库列不动、无数据迁移)
|
||||
ai_endpoint TEXT,
|
||||
ai_model TEXT,
|
||||
train_host TEXT,
|
||||
train_user TEXT,
|
||||
train_password TEXT,
|
||||
train_key TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
-- 图片文件在 app.datasetDir/datasets/<name>/;DB 存元数据 + 标注(dataset_image.labels_json)
|
||||
);
|
||||
-- 存量库 v8 迁移:逐列检测补列(PRAGMA table_info),新库 CREATE 自带全列
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dataset_image (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dataset_id INTEGER NOT NULL, -- → dataset.id
|
||||
filename TEXT NOT NULL, -- 唯一文件名(防重名加时间戳后缀)
|
||||
source TEXT NOT NULL DEFAULT 'manual', -- manual | ai
|
||||
prompt TEXT, -- AI 生成图记录提示词(追溯用)
|
||||
labels_json TEXT, -- 标注 JSON 数组(YOLO 归一化 xywh+类别+置信度),AI 自动标注与人工标注同存(人工可修改/清理),null/''/'[]'=未标注
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (dataset_id, filename)
|
||||
);
|
||||
-- 存量库迁移:EnsureColumn 逐列检测补列(v9);candidates_json 列已随 v10 删除(DROP COLUMN);
|
||||
-- model_version.model_file 列已随 v11 删除(模型无存档回退机制,只留 latest.tflite)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_training (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL, -- 任务名(默认「数据集+时间」)
|
||||
status TEXT NOT NULL DEFAULT 'running', -- running | success | failed
|
||||
dataset TEXT NOT NULL, -- 训练机数据集名(datasetDir 下子目录名)
|
||||
imgsz INTEGER NOT NULL DEFAULT 704,
|
||||
epochs INTEGER NOT NULL DEFAULT 150,
|
||||
batch INTEGER NOT NULL DEFAULT 16,
|
||||
device TEXT NOT NULL DEFAULT '0',
|
||||
current_epoch INTEGER NOT NULL DEFAULT 0,
|
||||
total_epochs INTEGER NOT NULL DEFAULT 0,
|
||||
metrics TEXT, -- JSON {p, r, map50}(成功后写)
|
||||
log_tail TEXT, -- 日志尾部(截断 N KB,轮询更新)
|
||||
pid INTEGER, -- 训练进程 pid(取消/存活探测用)
|
||||
error TEXT, -- 失败原因
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (dataset, imgsz, epochs, batch, started_at) -- 防止重复提交同参任务(宽松防呆)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_version (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dataset_id INTEGER NOT NULL, -- → dataset.id,**每个数据集独立模型版本序列**
|
||||
version TEXT NOT NULL, -- m1.0.0 递增(每次发布 patch+1,同数据集内唯一)
|
||||
training_id INTEGER, -- 来源训练任务 → model_training.id
|
||||
artifact_file TEXT, -- 归档 zip(best.pt + results 曲线),可选
|
||||
metrics TEXT, -- JSON,与来源任务一致
|
||||
labels TEXT NOT NULL, -- JSON 类别名数组(随模型下发,App 合并/展示用)
|
||||
sha256 TEXT NOT NULL, -- tflite 文件校验
|
||||
size_bytes INTEGER NOT NULL,
|
||||
is_latest INTEGER NOT NULL DEFAULT 0, -- 1=该数据集当前生效(客户端拉取对象),每数据集至多一条
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (dataset_id, version)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS label_task (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dataset_id INTEGER NOT NULL, -- → dataset.id
|
||||
filenames TEXT, -- JSON 选中图片列表(多选批量标注);NULL = 全量扫描
|
||||
status TEXT NOT NULL DEFAULT 'running', -- running | done
|
||||
total INTEGER NOT NULL DEFAULT 0, -- 待标注图片数
|
||||
done INTEGER NOT NULL DEFAULT 0, -- 已标注数
|
||||
boxes_file TEXT, -- 遗留列:候选框 JSON 路径(boxes.json 已废弃,新代码不读写)
|
||||
error TEXT, -- 失败原因(检测中途失败/服务重启中断)
|
||||
created_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_order_phone ON payment_order(phone_num);
|
||||
CREATE INDEX IF NOT EXISTS idx_dataset_image_dataset ON dataset_image(dataset_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_training_status ON model_training(status);
|
||||
```
|
||||
|
||||
存量库迁移以 `PRAGMA user_version` 版本化标记,禁止重复执行。迁移记录:
|
||||
@@ -72,6 +161,17 @@ CREATE INDEX IF NOT EXISTS idx_payment_order_phone ON payment_order(phone_num);
|
||||
- v4 = `license` 加 `remark` 列(管理端备注;`PRAGMA table_info` 检测列缺失才 `ALTER TABLE ... ADD COLUMN remark TEXT`,新库直接建表跳过)
|
||||
- v5 = `app_version` 新表(版本管理;`dao` init `CREATE TABLE IF NOT EXISTS` 自动建,新库/存量库均无需 `user_version` 迁移,此处记录 DDL 变更)
|
||||
- v6 = `app_version` 删 `url` 列(下载地址改为固定文件 `app.apkDir`/`observer-latest.apk`,表内不再记录;`PRAGMA table_info` 检测列存在才 `ALTER TABLE ... DROP COLUMN url`,新库建表已无此列直接跳过)
|
||||
- v7 = `dataset` / `dataset_image` / `model_training` / `model_version` / `label_task` 新表(模型训练体系;各 `dao` init `CREATE TABLE IF NOT EXISTS` 自动建,此处记录 DDL 变更)
|
||||
- v8 = `dataset` 加 9 列(`cover`/`description`/`ai_endpoint`/`ai_model`/`train_host`/`train_user`/`train_password`/`train_key`)+ `label_task` 加 `filenames` 列(多选批量标注;`PRAGMA table_info` 逐列检测缺失才 `ALTER TABLE ... ADD COLUMN`,新库建表自带跳过)——**其中 6 列(ai/train 覆盖字段)为遗留列**:AI 标注/训练机 SSH 配置统一走 `config.yml`(`localAi` / `training.ssh`),新代码不读写,存量库保留不迁移
|
||||
- v9 = 标注存储从文件迁移入库:`dataset_image` 加 `labels_json`/`candidates_json` 两列(`common.EnsureColumn` 迁移),启动时把历史 `labels/<数据集>/*.txt` 解析入 `labels_json`(幂等:仅未迁移行处理),`boxes.json` 废弃;**历史 labels/ 目录与迁移代码已删除(2026-08-26:数据全部入表后无保留价值)**
|
||||
- v10 = 标注流程简化(撤销候选确认两阶段):`ALTER TABLE dataset_image DROP COLUMN candidates_json`(`PRAGMA table_info` 检测列存在才 DROP,新库建表已无此列直接跳过;存量候选数据为空直接删)——AI 预标注结果直写 `labels_json`,人工可修改/清理全部标注框,`label_task.boxes_file` 与 `dataset` 的 ai/train 六列同为遗留列保留不迁移
|
||||
|
||||
## 全局训练配置(config.yml 直读)
|
||||
|
||||
**决策(2026-08-26)**:AI 标注端点(`localAi`)与训练机 SSH 凭据(`training.ssh`)是全局配置、与数据集无关——**不设独立存储**(曾尝试 `app_config` KV 表 + 管理端「训练配置」入口,2026-08-26 撤销):标注与训练直接读 `config.yml` 的 `localAi` / `training.ssh` 节点,改配置需重启服务。数据集表 6 个覆盖列(ai_endpoint 等)为遗留列,代码不读写。
|
||||
|
||||
- AI 客户端:`common.LocalAiClient(ctx)` 直读 `localAi.baseUrl`/`model`,未配置返回 nil(预标注接口报「标注服务未配置」)
|
||||
- SSH 凭据:`common/training_runner.go` 的 sshRunner 直读 `training.ssh.host`/`user`/`port`/`privateKeyPath`/`password`,未配置 host 报「training.ssh 未配置 host」
|
||||
|
||||
## 账号体系(注册/登录)
|
||||
|
||||
@@ -208,9 +308,94 @@ Android 客户端启动 GET /api/v1/app/update(公开,无需 token;iOS 不
|
||||
- **写入串行**:新增/删除记录均走 `common.Serial()` 单写者(与授权链路一致,SQLite 无 WAL);列表/最新查询读走普通读,表极小不设查询缓存
|
||||
- **写入串行**:新增记录走 `common.Serial()` 单写者(与授权链路一致,SQLite 无 WAL);列表/最新查询读走普通读,表极小不设查询缓存
|
||||
|
||||
## 模型训练体系(数据集 → 训练 → 模型版本)
|
||||
|
||||
**背景**:识别模型(YOLOv8n → TFLite 704x704)此前在仓库根 `training/` 目录人工命令行训练(本机 MPS / GPU 服务器跑 `train_server.py`,best.tflite 手动拷进 App assets 重打包 APK 下发)。本模块将「数据集管理 → 训练编排 → 模型版本发布 → 客户端热更新」闭环搬进后台管理端,模型迭代不再依赖开发者本机与 APK 发版。**Python 训练脚本随项目整合进 `server/training/`(2026-08-26:能 Go 化的已 Go 化——prepare_yolo→prepareYoloSet、analyze_rfdetr→LocalAi.Detect,脚本删除;train_server.py/yolov8n.pt/调试工具迁移入 server/training/,yolov8n.pt 权重不进 git,由 .gitignore 排除;2026-08-26 再收口:inspect_tflite.py 并入 train_server.py 自检、dump_graph.py 保留)**。
|
||||
|
||||
**整体数据流**:
|
||||
|
||||
```
|
||||
管理端 数据集管理(上传 / AI 生成)→ 图片落服务器 workspace/datasets/<name>/
|
||||
├─► 二期:标注工作台(RF-DETR 预标注 + 人工确认)→ labels/
|
||||
└─► 发起训练 → 同步数据集到训练机 → runner 起训练 → 进度/日志/指标
|
||||
└─► 产物(best.tflite + best.pt 归档)拉回服务器
|
||||
└─► 发布 → model_version + workspace/model-latest.tflite(sha256)
|
||||
└─► 三期:客户端启动 GET /app/update → 模型热更新下载替换
|
||||
```
|
||||
|
||||
### 数据集存储与流转(决策:图片不进 DB、不提交 git)
|
||||
|
||||
- 图片目录:`app.datasetDir`(默认 `./workspace/`)下 `datasets/<数据集名>/`(图片平铺,文件名唯一防重名);标注存 `dataset_image.labels_json`(JSON 数组,每元素 `{class,cx,cy,w,h,confidence}` 归一化,AI 自动标注与人工标注同存、人工可修改/清理);**历史 labels/ txt 目录与 yolo 暂存目录已删除(2026-08-26,标注全在 DB,训练集流式化不落本地盘)**
|
||||
- **封面命名规范(2026-08-26)**:封面文件不得固定命名 `cover`,上传时解码为 **jpg 格式 + UUIDv4 命名**(`common.UuidV4()` 生成,crypto/rand 无第三方依赖,落盘 `datasets/<name>/<uuid>.jpg`,旧封面删除);DB `cover` 列只存文件名,前端用 DB 值拼 URL 零改动;删除/校验按 `isCoverName` 正则(UUID v4 + `.jpg` 后缀)识别,不认固定名——历史 `cover.jpg` 等存量封面启动时 `MigrateLegacyCovers` 幂等重命名迁移
|
||||
- DB 只存文件名/来源/prompt 等元数据(`dataset` / `dataset_image`),**禁止图片进库**
|
||||
- 生成/上传图片是**付费资产**:删除接口必须带前端确认文案(提示 AI 生成图有成本);删除 = 删文件 + 删记录,目录清理
|
||||
- 训练机与 Go 服务器可能异机:训练前按 `training` 通道同步(subprocess 同机 cp、ssh 异机 scp);**数据集在训练机上的权威路径** `training.workdir`/`training.datasetDir`/`<name>/`,DB `model_training.dataset` 只存数据集名
|
||||
- zip 导出(`GET /admin/datasets/export`):打包图片目录为 zip 下载——用于标注衔接(二期前的人工标注路径)与备份
|
||||
|
||||
### AI 生成图片(provider 抽象)
|
||||
|
||||
- `imageGen` 配置节点:`provider: dashscope`(当前唯一实现)+ `apiKey` + `model: qwen-image-3.0`;接口在 `common` 层抽象 `ImageGenProvider`(`Generate(ctx, prompt, size) ([]byte, error)`),后续可加本地 SD 等实现,config 切换
|
||||
- 尺寸参数由前端传(下拉可选,默认 1152x2048 竖图,与既有训练图一致;另提供 704x704 档)
|
||||
- 生成同步执行(count 1..8,超时 2min/张),逐张落盘 + 入库(记录 prompt 便于追溯);中途失败返回错误(已成功的图保留,不删除——付费资产原则)
|
||||
- prompt 由前端预设模板 + 用户微调;**遵守项目提示词规范:不得包含目标位置描述**(位置由模型自行推理,前端模板与校验文案落实此约束)
|
||||
|
||||
### 训练通道(runner 抽象,决策:同机/异机不确定 → 可配置)
|
||||
|
||||
```yaml
|
||||
training:
|
||||
mode: subprocess # subprocess | ssh
|
||||
ssh: { host: "", user: "", port: 22, privateKeyPath: "", password: "" }
|
||||
workdir: /opt/pheasant_data # 训练机工作目录
|
||||
venvPython: /opt/pheasant_data/venv/bin/python
|
||||
datasetDir: datasets # 训练机数据集根目录(相对 workdir,数据集为子目录)
|
||||
concurrency: 1 # GPU 独占:同时仅一个 running,新任务排队
|
||||
timeoutMinutes: 600 # 超时判死
|
||||
```
|
||||
|
||||
- service 内 `Runner` 接口:`Start(ctx, *TrainingJob) (pid, error)` / `FetchLogTail(ctx, job)` / `IsAlive(ctx, job) bool` / `Cancel(ctx, job)` / `FetchArtifacts(ctx, job, destDir)`;`subprocess` 与 `ssh` 两个实现,按 config `mode` 选择;**ssh 凭据直接读本节点 `training.ssh` 配置**(见「全局训练配置」节)
|
||||
- **训练脚本**(`server/training/train_server.py`,随项目迁移):支持 `--task-json <file>`(含 dataset/imgsz/epochs/batch/device/project 名),每 epoch 输出一行机器可读 JSON 到 `--log-file`(`{"epoch":1,"total":150,"metrics":{...}}`),结束写 `result.json`(最终指标)+ 自动打包 `artifact.zip`(best.pt + results.csv + 曲线);Go 侧解析日志行更新进度、轮询日志尾部截断 N KB 存 `model_training.log_tail`
|
||||
- **tflite 产物自检**(2026-08-26):`inspect_tflite.py` 的 flatbuffer 解析逻辑内嵌进 `train_server.py`(`check_tflite`),训练收尾定位 `best.tflite` 后自动校验并写 `result.json` 的 `tflite_check` 字段:`{"ok":bool,"reason":string,"inputs":[{"name","shape","type"}],"outputs":[...]}`;校验规则 = 输入恰 1 张且 4 维、元素总数 == imgsz²×3(兼容 NCHW/NHWC)、输出 ≥ 1 张且 batch 维 = 1;`ok=false`(如 shape 漂移、导出异常)时 Go 侧在拉产物前直接置训练失败并带出 reason,杜绝坏产物进入发布链路;`dump_graph.py` 保留作训练机人工深度调试
|
||||
- **任务生命周期**:`running → success/failed`;取消 = 杀进程(ssh 模式远程 kill pid);超时无心跳判死;**Go 服务重启后启动扫描** running 任务按 pid 存活探测(subprocess 本机、ssh 远程 `kill -0`),进程已死则置 failed
|
||||
- **并发度 1**:发起训练时若已有 running 任务返回错误「训练进行中」;训练任务不排队(简化,管理端人工再点一次)
|
||||
- 产物拉取:成功后拉 `best.tflite` + `artifact.zip` 到服务器 `workspace/trainings/<taskId>/`,发布时引用
|
||||
- 写操作走 `common.Serial()` 单写者(SQLite 无 WAL,与既有链路一致);任务状态更新(进度轮询)为高频写,单独小事务
|
||||
|
||||
### 模型版本(每数据集一个模型,多模型体系)
|
||||
|
||||
**核心决策:每个数据集训练一个模型,模型按数据集独立版本化,App 多模型并行推理合并**——用户按需下载若干数据集的模型,加载全部已下载模型共同推理标注(类别名不同则自然互补,同类名跨模型 NMS 去重)。
|
||||
|
||||
- 版本号规则:`m<major>.<minor>.<patch>`,**同一数据集内**每次发布 patch+1(取该数据集最大版本号解析自增,无记录从 m1.0.0 起);`UNIQUE(dataset_id, version)` 防重复
|
||||
- 文件布局:当前生效副本 `workspace/models/<数据集名>/latest.tflite`(tmp + rename 原子覆盖),客户端固定下载该文件;**无 `<version>.tflite` 存档(2026-08-26 决策:不需要模型回退机制,模型只增不删不回滚)**;每数据集一个目录互不影响
|
||||
- 类别名:发布时从训练任务/数据集记录类别(训练脚本 result.json 输出 `names`),存 `model_version.labels`(JSON 数组),**App 合并推理依赖它**
|
||||
- **发布**(`POST /admin/trainings/publish`):校验任务 success + tflite 产物存在 → sha256 → 写 latest 副本 → 插 `model_version` + 该数据集旧版 `is_latest=0`;「记录存在 ⟺ 文件存在」补偿逻辑同 APK 版本管理
|
||||
- **管理端无模型管理界面**(2026-08-26 决策):删 `AdminListModels`/`AdminActivateModel`/`AdminDeleteModel` 三个管理接口,`model_version` 表保留——仅支撑客户端下发目录;版本只增不删不回滚(发布即最新)
|
||||
- **模型目录(客户端拉取)**:`GET /api/v1/models`(公开,登录态即可)返回所有数据集当前生效模型:`{datasetId, datasetName, version, labels, sizeBytes, sha256, notes, publishedAt, downloadUrl}`;下载 URL `/download/models/<数据集名>/latest.tflite`(复用 `/download` 静态托管)
|
||||
|
||||
### 标注工作台(依赖 local-ai 可达;2026-08-26 布局重构)
|
||||
|
||||
- `localAi` 配置节点:`{ baseUrl: "http://127.0.0.1:18080", model: "rfdetr-xlarge", threshold: 0.08, confConfirmed: 0.2, overlapThreshold: 0.3 }`——**部署前提:RF-DETR 服务须从 Go 服务器可达**(现跑在 Mac 上,部署时搬服务器/训练机;**未配置时图片上传/生成接口直接报错**——标注是强语义,不允许产生无标注图片;配置调不通则任务 failed,页顶错误条展示,不自动重试);**端点为配置唯一来源,无运行时覆盖**
|
||||
- **预标注自动触发(2026-08-26 决策,无手动按钮)**:手动上传/AI 生成图片入库成功后,自动对**本次新增图**发起标注;**`localAi` 未配置 → 上传/生成接口直接报错**(同步检查,生成场景在第一张生成前检查避免付费资产生成后标不了);**已有 running 标注任务(忙)→ 不报错**:上传/生成照常成功,新图由「任务成功完成后自动补标未标注图」机制兜底(每轮任务成功结束时检查该数据集未标注图,有则自动续一轮只标未标注图——失败任务不续,防配置坏时无限重试);service 逐张调 local-ai 推理(common 池并行,`label_task` 记录 total/done 进度)→ 扫描结果(YOLO 归一化 xywh + 置信度 + 建议类别,conf≥confConfirmed 为 class 0)**直写 `dataset_image.labels_json`(重跑覆盖该图标注)**;**重叠去重(2026-08-26 修订)**:NMS 风格、按置信度降序依次保留,与已保留框**重叠比(minIoU = 交叠面积/两框较小面积)> `localAi.overlapThreshold`(默认 0.3)**的框剔除(同目标被重复检出只留置信度最高者,跨 class 去重;人工画框不参与去重)——**用 minIoU 而非 IoU**:RF-DETR 对同一目标常输出一大一小两个框(标准 IoU 仅 0.3~0.5 会漏杀,实测红黄重叠即此形态),大框套小框时小框被覆盖比例高,minIoU 命中;相邻目标两框互有外露,minIoU 通常 < 0.3;**全图扫描**(项目既定规则:不套用生成规格的位置裁剪,候选宁多勿漏);不设候选确认两阶段(2026-08-26 撤销 candidates_json);`POST /admin/label-tasks` 接口保留(详情页「全量标注」按钮,2026-08-26 恢复),可手动全量/指定图重标
|
||||
- **详情页布局(无选项卡)**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照;页顶标注任务进度条(自动触发任务进度,含错误展示);标注图 = 原图 + 标注框叠加(只读 canvas,与工作台同一绘制逻辑)
|
||||
- **弹窗标注**:点击原图/标注图打开放大弹窗(大 canvas,1152x2048 原尺寸),支持两点画框/点框删除/清空/类别切换(确认 0/疑似 1)/上下一张/保存——**AI 自动标注与人工框同层可编辑**(含清理 AI 框);保存即整体覆写 `dataset_image.labels_json`(JSON 数组,YOLO 归一化 xywh+类别+置信度,空=清空标注);未保存修改切换图片有确认
|
||||
- 无历史标注任务时详情页工作台数据源为 `GET /admin/label-workbench?dataset_id=`(图片 + 全量标注框),避免依赖人工先跑一次任务
|
||||
- 训练前自动整理(prepare_yolo 逻辑服务端,**内存组装不落本地盘**):数据集有 labels → 按 80/20 拆 train/val 生成 YOLO 训练集包(标注 txt 内存生成、原图只记数据集目录源路径)→ 随同步推训练机(subprocess 直写 `workdir/datasetDir/yolo/<name>/`;ssh 走 **tar 流式管道**本地不落盘);**自动标注图直接进训练集**(人工修改/清理后即为训练数据);本地无暂存目录(旧 `workspace/datasets/yolo/` 与 `labels/` 已删除)
|
||||
|
||||
### 模型热更新与多模型推理(客户端 Flutter)
|
||||
|
||||
- `GET /api/v1/app/update` 扩展:响应追加 `models` 数组(`GET /api/v1/models` 同构:datasetId/datasetName/version/labels/sizeBytes/sha256/downloadUrl)——**多模型目录,不再有单一 modelUrl**;无发布模型时不返回该字段(旧 App 忽略、新 App 兼容旧服务器)
|
||||
- **模型管理**:App 设置页「模型管理」——列出服务器全部可用模型 + 本地已下载状态(版本/大小/说明),用户**自由下载/删除/启用**;下载到应用私有目录 `.tmp` → sha256 校验 → 原子替换;已下载模型跨启动持久
|
||||
- **并行推理合并**:`tflite_detector` 启动时加载**全部已启用且已下载**的模型(每模型独立 interpreter + 独立 isolate 线程并行推理);单帧结果按**类别名**合并 + 跨模型 NMS 去重(IoU 阈值 0.45,同类名才合并);类别名冲突以合并结果展示、标签文本随模型 labels
|
||||
- 内置模型兜底:assets `model.tflite` + `labels.txt` 作为无任何下载模型时的默认;有下载模型则并行加载下载模型(内置不重复加载,避免重复检测同类别)
|
||||
- **非强制**:下载失败/校验不过删 tmp 用旧状态,下次启动重试;不弹阻塞窗
|
||||
- 性能:N 模型并行推理墙钟 ≈ 单模型 1.2~1.5 倍(多核并行);用户按需下载控制 N;帧率不足时可降采样
|
||||
- 版本比较规则同 APK(数字段比较,m 前缀剥离后按 x.y.z)
|
||||
|
||||
## 待办/风险
|
||||
|
||||
- 微信支付需商户号(APP 支付权限)、APIv3 密钥与平台证书;支付宝需商户应用与密钥 —— 当前均未配置,接口按真实 SDK 契约实现,配置走 `config.yml` 占位
|
||||
- iOS App Store 对数字内容强制 IAP,微信/支付宝直连有被拒风险(已决策,记录在案)
|
||||
- 管理端静态 token 由登录页输入(浏览器 localStorage),属内网管理凭据:`config.yml admin.token` 失配/未配置时管理接口全部拒绝;token 不进入前端构建产物与 git
|
||||
- 手动授权等同免费发卡,仅限运营/客诉排查场景,前端页面带确认弹窗,后端不限制频率(按需再加审计)
|
||||
- 模型训练体系依赖外部环境:训练机 GPU + ultralytics venv(`training.venvPython`)、RF-DETR 服务从服务器可达(`localAi.baseUrl`)、DashScope API key(`imageGen.apiKey`)——未配置对应节点时相关管理接口返回配置缺失错误,不影响支付/授权主链路
|
||||
- subprocess 模式下训练与在线服务同机,训练吃满 GPU 可能影响识别类在线任务(当前在线服务无 GPU 推理,风险低);异机部署切换 `training.mode: ssh`
|
||||
- 训练产物与数据集为磁盘占用大户(图片百 MB~GB、每轮产物几十 MB),`workspace/` 已挂载持久化;清理入口:删除数据集(付费资产需确认);模型无存档,仅 latest.tflite 被新发布覆盖,无需单独清理
|
||||
|
||||
+12
-10
@@ -1,15 +1,22 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
List as ListIcon,
|
||||
Key as KeyIcon,
|
||||
Refresh as RefreshIcon,
|
||||
SwitchButton as SwitchIcon,
|
||||
Picture as PictureIcon,
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 详情页 /datasets/:id 仍高亮「数据训练」菜单项
|
||||
const activeMenu = computed(() =>
|
||||
route.path.startsWith('/datasets') ? '/datasets' : route.path,
|
||||
)
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('adminToken')
|
||||
router.push('/login')
|
||||
@@ -22,8 +29,11 @@ function logout() {
|
||||
|
||||
<el-container v-else class="app-layout">
|
||||
<el-aside width="200px" class="app-aside">
|
||||
<div class="app-logo">视野管理端</div>
|
||||
<el-menu :default-active="route.path" router>
|
||||
<el-menu :default-active="activeMenu" router>
|
||||
<el-menu-item index="/datasets">
|
||||
<el-icon><PictureIcon /></el-icon>
|
||||
<span>数据训练</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/orders">
|
||||
<el-icon><ListIcon /></el-icon>
|
||||
<span>订单管理</span>
|
||||
@@ -57,14 +67,6 @@ function logout() {
|
||||
.app-aside {
|
||||
background: #001529;
|
||||
}
|
||||
.app-logo {
|
||||
height: 56px;
|
||||
line-height: 56px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.app-aside :deep(.el-menu) {
|
||||
border-right: none;
|
||||
background: #001529;
|
||||
|
||||
@@ -3,13 +3,17 @@ import Login from '../views/Login.vue'
|
||||
import Orders from '../views/Orders.vue'
|
||||
import Licenses from '../views/Licenses.vue'
|
||||
import AppVersions from '../views/AppVersions.vue'
|
||||
import Datasets from '../views/Datasets.vue'
|
||||
import DatasetDetail from '../views/DatasetDetail.vue'
|
||||
|
||||
// 生产部署于 /admin/ 前缀(后端静态托管),history 路由由后端 SPA fallback 兜底
|
||||
const router = createRouter({
|
||||
history: createWebHistory('/admin/'),
|
||||
routes: [
|
||||
{ path: '/login', name: 'login', component: Login, meta: { title: '登录' } },
|
||||
{ path: '/', redirect: '/orders' },
|
||||
{ path: '/', redirect: '/datasets' },
|
||||
{ path: '/datasets', name: 'datasets', component: Datasets, meta: { title: '数据训练' } },
|
||||
{ path: '/datasets/:id', name: 'datasetDetail', component: DatasetDetail, meta: { title: '数据训练详情' } },
|
||||
{ path: '/orders', name: 'orders', component: Orders, meta: { title: '订单管理' } },
|
||||
{ path: '/licenses', name: 'licenses', component: Licenses, meta: { title: '授权管理' } },
|
||||
{ path: '/app-versions', name: 'appVersions', component: AppVersions, meta: { title: '版本管理' } },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,565 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Picture, Setting, VideoPlay, Download } from '@element-plus/icons-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import request from '../api/request'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const size = ref(12)
|
||||
const keyword = ref('')
|
||||
|
||||
const createVisible = ref(false)
|
||||
const createForm = reactive({ name: '', source: 'manual' })
|
||||
const creating = ref(false)
|
||||
|
||||
const configVisible = ref(false)
|
||||
const configSaving = ref(false)
|
||||
const configForm = reactive({
|
||||
id: 0,
|
||||
description: '',
|
||||
cover: '',
|
||||
})
|
||||
const coverFile = ref(null) // 新选择的封面文件(el-upload 单文件)
|
||||
const coverFileList = ref([]) // 封面回显:已有封面(服务端 url)或新选文件(本地预览)
|
||||
const coverDeleted = ref(false) // 用户删除了已有封面(保存时调删除接口)
|
||||
|
||||
const trainVisible = ref(false)
|
||||
const training = ref(false)
|
||||
const trainForm = reactive({ datasetId: 0, name: '', imgsz: 704, epochs: 150, batch: 16, device: '0' })
|
||||
|
||||
const statusMap = { building: '建设中', labeled: '已标注', synced: '已同步' }
|
||||
const statusTag = { building: 'info', labeled: 'success', synced: 'primary' }
|
||||
const trainStatusMap = { running: '训练中', success: '已训练', failed: '训练失败' }
|
||||
const trainStatusTag = { running: 'primary', success: 'success', failed: 'danger' }
|
||||
|
||||
function load() {
|
||||
loading.value = true
|
||||
request
|
||||
.get('/datasets', { params: { page: page.value, size: size.value, keyword: keyword.value || undefined } })
|
||||
.then((data) => {
|
||||
list.value = data.list || []
|
||||
total.value = data.total || 0
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function search() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createForm.name = ''
|
||||
createForm.source = 'manual'
|
||||
createVisible.value = true
|
||||
}
|
||||
|
||||
function submitCreate() {
|
||||
if (!createForm.name.trim()) {
|
||||
ElMessage.warning('请输入数据集名称')
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
request
|
||||
.post('/datasets', { name: createForm.name.trim(), source: createForm.source })
|
||||
.then(() => {
|
||||
ElMessage.success('数据集已创建,请上传或生成图片')
|
||||
createVisible.value = false
|
||||
page.value = 1
|
||||
load()
|
||||
})
|
||||
.finally(() => {
|
||||
creating.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 数据集级配置(描述 + 封面;AI 端点/训练机 SSH 走 config.yml,与数据集无关) ----------
|
||||
|
||||
function openConfig(row) {
|
||||
Object.assign(configForm, {
|
||||
id: row.id,
|
||||
description: row.description || '',
|
||||
cover: row.cover || '',
|
||||
})
|
||||
coverFile.value = null
|
||||
coverDeleted.value = false
|
||||
// 已有封面回显:el-upload picture-card 直接以 url 项展示
|
||||
coverFileList.value = row.cover ? [{ name: '当前封面', url: imgUrl(`/api/v1/admin/datasets/cover?datasetId=${row.id}`) }] : []
|
||||
configVisible.value = true
|
||||
}
|
||||
|
||||
// 封面文件选中:多选时只保留最后一个(替换语义);新文件选中即视为不再删除
|
||||
function onCoverChange(file, fileList) {
|
||||
if (fileList.length > 1) coverFileList.value = [fileList[fileList.length - 1]]
|
||||
coverFile.value = coverFileList.value.length ? coverFileList.value[0].raw : null
|
||||
if (coverFile.value) coverDeleted.value = false
|
||||
}
|
||||
|
||||
// 删除项:删服务端已有封面(无 raw)→ 标记待删除;删新选文件且列表清空 → 同样按删除处理(服务端旧封面仍在)
|
||||
function onCoverRemove(file) {
|
||||
coverFile.value = null
|
||||
if (!file.raw) {
|
||||
coverDeleted.value = true
|
||||
} else if (!coverFileList.value.length && configForm.cover) {
|
||||
coverDeleted.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function submitConfig() {
|
||||
configSaving.value = true
|
||||
try {
|
||||
if (coverFile.value) {
|
||||
const fd = new FormData()
|
||||
fd.append('datasetId', configForm.id)
|
||||
fd.append('file', coverFile.value)
|
||||
await request.post('/datasets/cover', fd, { timeout: 60000 })
|
||||
} else if (coverDeleted.value) {
|
||||
await request.post('/datasets/cover/delete', { datasetId: configForm.id })
|
||||
}
|
||||
// cover 由上传接口单独维护,update 不携带
|
||||
await request.post('/datasets/update', { ...configForm, cover: '' })
|
||||
ElMessage.success('配置已保存')
|
||||
configVisible.value = false
|
||||
load()
|
||||
} finally {
|
||||
configSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 开始训练 ----------
|
||||
|
||||
function openTrain(row) {
|
||||
trainForm.datasetId = row.id
|
||||
trainForm.name = `${row.name} 训练`
|
||||
trainForm.imgsz = 704
|
||||
trainForm.epochs = 150
|
||||
trainForm.batch = 16
|
||||
trainForm.device = '0'
|
||||
trainVisible.value = true
|
||||
}
|
||||
|
||||
function submitTrain() {
|
||||
if (!trainForm.name.trim()) {
|
||||
ElMessage.warning('请输入任务名称')
|
||||
return
|
||||
}
|
||||
training.value = true
|
||||
request
|
||||
.post('/trainings', { ...trainForm })
|
||||
.then(() => {
|
||||
ElMessage.success('训练任务已发起,完成后可发布为模型版本')
|
||||
trainVisible.value = false
|
||||
load()
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
training.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 导出 / 删除 ----------
|
||||
|
||||
function exportDataset(row) {
|
||||
window.open(`${location.origin}/api/v1/admin/datasets/export?datasetId=${row.id}`, '_blank')
|
||||
}
|
||||
|
||||
function removeDataset(row) {
|
||||
const warn =
|
||||
row.source === 'ai' || row.imageCount > 0
|
||||
? '将删除图片文件、标注与记录,AI 生成图属付费资产,删除后无法恢复。'
|
||||
: ''
|
||||
ElMessageBox.confirm(`确定删除数据集「${row.name}」?${warn}`, '删除数据集', {
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(() => request.post('/datasets/delete', { id: row.id }))
|
||||
.then(() => {
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// <img> 标签无法带自定义请求头,图片地址以 query 参数携带 admin token
|
||||
function imgUrl(u) {
|
||||
return `${location.origin}${u}&token=${encodeURIComponent(localStorage.getItem('adminToken') || '')}`
|
||||
}
|
||||
|
||||
function coverUrl(row) {
|
||||
if (!row.cover) return ''
|
||||
return imgUrl(`/api/v1/admin/datasets/cover?datasetId=${row.id}`)
|
||||
}
|
||||
|
||||
// 训练进度(百分比):有总轮数才显示比例
|
||||
function trainPercent(row) {
|
||||
if (row.trainingStatus !== 'running' || !row.trainingTotalEpochs) return 0
|
||||
return Math.min(100, Math.round((row.trainingCurrentEpoch / row.trainingTotalEpochs) * 100))
|
||||
}
|
||||
|
||||
function publishModel(row) {
|
||||
ElMessageBox.confirm(
|
||||
`确定将「${row.name}」的最新训练结果发布为模型版本?将按数据集版本号自增(m<主>.<次>.<修订>),客户端下次启动热更新下载。`,
|
||||
'发布模型版本',
|
||||
{ confirmButtonText: '发布', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
.then(() => request.post('/trainings/publish', { id: row.trainingId }))
|
||||
.then((data) => {
|
||||
ElMessage.success(`已发布版本 ${data.version}`)
|
||||
load()
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// 有训练中的任务时周期刷新,卡片进度条保持最新
|
||||
let trainPoll = null
|
||||
onMounted(() => {
|
||||
load()
|
||||
trainPoll = setInterval(() => {
|
||||
if (list.value.some((r) => r.trainingStatus === 'running')) load()
|
||||
}, 10000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (trainPoll) clearInterval(trainPoll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
class="kw"
|
||||
placeholder="按名称搜索"
|
||||
clearable
|
||||
@keyup.enter="search"
|
||||
@clear="search"
|
||||
/>
|
||||
<el-button @click="search">搜索</el-button>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新建数据集</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="ds-grid">
|
||||
<div v-for="row in list" :key="row.id" class="ds-card" @click="router.push(`/datasets/${row.id}`)">
|
||||
<div class="ds-cover">
|
||||
<img v-if="row.cover" :src="coverUrl(row)" :alt="row.name" loading="lazy" />
|
||||
<div v-else class="ds-cover-placeholder"><el-icon :size="34"><Picture /></el-icon></div>
|
||||
<div v-if="row.source === 'ai'" class="ds-cover-source">AI 生成</div>
|
||||
</div>
|
||||
<div class="ds-body">
|
||||
<div class="ds-name" :title="row.name">{{ row.name }}</div>
|
||||
<div class="ds-desc">{{ row.description || '暂无描述,点击进入管理图片与标注' }}</div>
|
||||
<div class="ds-stats">
|
||||
<span>图片 {{ row.imageCount }}</span>
|
||||
<span>已标注 {{ row.labeledCount }}</span>
|
||||
<el-tag :type="statusTag[row.status] || 'info'" size="small">{{ statusMap[row.status] || row.status }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ds-actions" @click.stop>
|
||||
<el-button type="primary" size="small" :icon="VideoPlay" :disabled="row.trainingStatus === 'running'" @click="openTrain(row)">
|
||||
{{ row.trainingStatus === 'running' ? '训练中' : '开始训练' }}
|
||||
</el-button>
|
||||
<el-button size="small" :icon="Setting" @click="openConfig(row)">配置</el-button>
|
||||
<el-button size="small" :icon="Download" @click="exportDataset(row)">导出</el-button>
|
||||
<el-button size="small" type="danger" @click="removeDataset(row)">删除</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 训练任务进度与状态(最新一条训练记录) -->
|
||||
<div v-if="row.trainingStatus" class="ds-train" @click.stop>
|
||||
<template v-if="row.trainingStatus === 'running'">
|
||||
<el-progress :percentage="trainPercent(row)" :stroke-width="6" :show-text="false" class="ds-train-bar" />
|
||||
<div class="ds-train-text">
|
||||
<span>训练中 {{ row.trainingCurrentEpoch || 0 }}/{{ row.trainingTotalEpochs || '-' }} 轮</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-tag :type="trainStatusTag[row.trainingStatus] || 'info'" size="small">
|
||||
{{ trainStatusMap[row.trainingStatus] || row.trainingStatus }}
|
||||
</el-tag>
|
||||
<el-button v-if="row.trainingStatus === 'success' && row.trainingId" size="small" type="success" @click="publishModel(row)">
|
||||
发布模型
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!loading && !list.length" description="暂无数据集,点击右上角新建" />
|
||||
</div>
|
||||
|
||||
<el-pagination
|
||||
class="pager"
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="size"
|
||||
:total="total"
|
||||
:page-sizes="[12, 24, 48]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@change="load"
|
||||
/>
|
||||
|
||||
<!-- 新建数据集 -->
|
||||
<el-dialog v-model="createVisible" title="新建数据集" width="min(440px, 92vw)">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="名称">
|
||||
<el-input
|
||||
v-model="createForm.name"
|
||||
maxlength="50"
|
||||
show-word-limit
|
||||
placeholder="中文/字母/数字/下划线/短横线,唯一且作磁盘目录名"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="图片来源">
|
||||
<el-radio-group v-model="createForm.source">
|
||||
<el-radio value="manual">手动上传</el-radio>
|
||||
<el-radio value="ai">AI 生成</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="creating" @click="submitCreate">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 数据集配置 -->
|
||||
<el-dialog v-model="configVisible" title="数据集配置" width="min(720px, 94vw)">
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="configForm.description" type="textarea" :rows="6" maxlength="500" show-word-limit placeholder="数据集说明,展示在卡片上" />
|
||||
</el-form-item>
|
||||
<el-form-item label="封面">
|
||||
<div class="cover-row">
|
||||
<el-upload
|
||||
v-model:file-list="coverFileList"
|
||||
:auto-upload="false"
|
||||
accept=".jpg,.jpeg,.png"
|
||||
list-type="picture-card"
|
||||
:on-change="onCoverChange"
|
||||
:on-remove="onCoverRemove"
|
||||
>
|
||||
<div v-if="coverFileList.length === 0" class="upload-tile">
|
||||
<el-icon :size="22"><Plus /></el-icon>
|
||||
</div>
|
||||
</el-upload>
|
||||
<span class="add-tip">jpg/jpeg/png,≤2MB;悬停预览右上角 × 可删除当前封面</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="configVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="configSaving" @click="submitConfig">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 开始训练 -->
|
||||
<el-dialog v-model="trainVisible" title="开始训练" width="min(480px, 92vw)">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="任务名称">
|
||||
<el-input v-model="trainForm.name" maxlength="50" show-word-limit placeholder="如:雉鸡数据集 v2 训练" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分辨率">
|
||||
<el-input-number v-model="trainForm.imgsz" :min="64" :max="2048" :step="32" />
|
||||
<span class="field-tip">与端侧推理对齐,默认 704</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="轮数">
|
||||
<el-input-number v-model="trainForm.epochs" :min="1" :max="1000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Batch">
|
||||
<el-input-number v-model="trainForm.batch" :min="1" :max="128" />
|
||||
</el-form-item>
|
||||
<el-form-item label="设备">
|
||||
<el-input v-model="trainForm.device" maxlength="16" placeholder="GPU 编号 0,CPU 填 cpu" />
|
||||
</el-form-item>
|
||||
<div class="start-tip">训练前自动整理 80/20 训练/验证集,须先完成图片标注;训练机并发度 1,已有运行中任务会被拒绝。</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="trainVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="training" @click="submitTrain">发起训练</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.kw {
|
||||
width: 220px;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 14px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.ds-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 14px;
|
||||
min-height: 120px;
|
||||
}
|
||||
.ds-card {
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.ds-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.ds-cover {
|
||||
position: relative;
|
||||
height: 150px;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.ds-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.ds-cover-placeholder {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.ds-cover-source {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.ds-train {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px 12px;
|
||||
border-top: 1px dashed #ebeef5;
|
||||
}
|
||||
.ds-train-bar {
|
||||
flex: 1;
|
||||
}
|
||||
.ds-train-text {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ds-body {
|
||||
padding: 10px 12px 6px;
|
||||
}
|
||||
.ds-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ds-desc {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
height: 36px;
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.ds-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.ds-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 8px 12px 12px;
|
||||
border-top: 1px dashed #ebeef5;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.field-tip {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.start-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.6;
|
||||
margin-left: 90px;
|
||||
}
|
||||
.cover-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
/* 上传方块:虚线边框 + 居中加号(el-upload picture-card 触发块) */
|
||||
.upload-tile {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #8c939d;
|
||||
}
|
||||
.add-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.6;
|
||||
align-self: flex-start;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.toolbar {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.toolbar-left {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
.toolbar-right {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.kw {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
.ds-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -23,7 +23,7 @@ function login() {
|
||||
.then(() => {
|
||||
localStorage.setItem('adminToken', token.value.trim())
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/orders')
|
||||
router.push('/datasets')
|
||||
})
|
||||
.catch(() => {
|
||||
errorMsg.value = 'token 无效或服务端未配置(检查 config.yml admin.token)'
|
||||
|
||||
@@ -12,7 +12,7 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8080',
|
||||
'/api': 'http://127.0.0.1:18080',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""RF-DETR 整图批量检测野鸡,生成候选标注(初标)
|
||||
|
||||
- 整图等比缩放至最长边 INPUT_SIZE(700)送检测,**不做任何裁剪**,坐标按比例映射回原图
|
||||
- 只保留 COCO bird 类;同目标重复框用 NMS 去重
|
||||
- 藏身点:图像分析(植被密度/地形边缘/光线)生成 class 1 疑似藏匿位置
|
||||
- 输出: YOLO txt (class 0 = 确认野鸡, class 1 = 疑似(低置信检测+藏身点))
|
||||
-> datasets/labels/pheasant_rfdetr/
|
||||
- 分析JSON -> datasets/analysis/pheasant_rfdetr/
|
||||
- 预览图 -> datasets/previews/pheasant_rfdetr/
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
SRC_IMAGES = os.path.join(BASE_DIR, "datasets/images/pheasant")
|
||||
OUT_LABELS = os.path.join(BASE_DIR, "datasets/labels/pheasant_rfdetr")
|
||||
OUT_JSON = os.path.join(BASE_DIR, "datasets/analysis/pheasant_rfdetr")
|
||||
OUT_PREVIEW = os.path.join(BASE_DIR, "datasets/previews/pheasant_rfdetr")
|
||||
|
||||
ENDPOINT = "http://192.168.3.210:18080/v1/detection"
|
||||
MODEL = "rfdetr-xlarge"
|
||||
TIMEOUT = 300
|
||||
THRESHOLD = 0.08
|
||||
CONF_CONFIRMED = 0.2 # 置信度高于此为明确(class 0 红框),否则为疑似(class 1 蓝框)
|
||||
# RF-DETR 置信度分布:强目标 0.3-0.9,0.05-0.14 多为噪声尾;
|
||||
# THRESHOLD=0.08 砍掉纯噪声,CONF_CONFIRMED=0.2 只把强目标标红,宁多勿漏
|
||||
INPUT_SIZE = 700 # 整图等比缩放后的最长边,不裁剪
|
||||
NMS_IOU = 0.4
|
||||
WORKERS = 4
|
||||
KEEP_CLASSES = set() # 空 = 保留全部类别(RF-DETR 对小目标类别不稳定,定位一致即可)
|
||||
|
||||
|
||||
IMG_MIME = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png"}
|
||||
|
||||
|
||||
def detect(buf, mime):
|
||||
body = json.dumps({"model": MODEL, "image": f"data:{mime};base64,{base64.b64encode(buf.tobytes()).decode()}",
|
||||
"threshold": THRESHOLD}).encode()
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
req = urllib.request.Request(ENDPOINT, data=body,
|
||||
headers={"Content-Type": "application/json"}, method="POST")
|
||||
with opener.open(req, timeout=TIMEOUT) as r:
|
||||
resp = json.loads(r.read().decode())
|
||||
return resp.get("detections", [])
|
||||
|
||||
|
||||
def nms(boxes, iou_thr):
|
||||
"""boxes: [(x, y, w, h, conf)], 按置信度降序去重"""
|
||||
boxes = sorted(boxes, key=lambda b: -b[4])
|
||||
keep = []
|
||||
for b in boxes:
|
||||
overlap = False
|
||||
for k in keep:
|
||||
ix = min(b[0] + b[2], k[0] + k[2]) - max(b[0], k[0])
|
||||
iy = min(b[1] + b[3], k[1] + k[3]) - max(b[1], k[1])
|
||||
if ix <= 0 or iy <= 0:
|
||||
continue
|
||||
inter = ix * iy
|
||||
union = b[2] * b[3] + k[2] * k[3] - inter
|
||||
if inter / union > iou_thr:
|
||||
overlap = True
|
||||
break
|
||||
if not overlap:
|
||||
keep.append(b)
|
||||
return keep
|
||||
|
||||
|
||||
def find_hiding_spots(img, H, W, n_spots=3):
|
||||
"""图像分析疑似藏身点:植被密度/地形边缘(田埂、林田交界、沟渠)/光线调制。
|
||||
|
||||
野鸡习性:地面活动,藏身于茂密低矮遮蔽物,避开开阔裸地;
|
||||
强光场景偏好阴影遮蔽,弱光场景偏好相对亮且有遮蔽的区域。
|
||||
返回 [(x, y, w, h)] 像素坐标,框面积约占画面 2%~8%。
|
||||
"""
|
||||
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
|
||||
s, v = hsv[..., 1], hsv[..., 2]
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY).astype(np.float32) / 255.0
|
||||
b = img[..., 0].astype(np.float32) / 255.0
|
||||
g = img[..., 1].astype(np.float32) / 255.0
|
||||
r = img[..., 2].astype(np.float32) / 255.0
|
||||
veg = np.clip(g - np.maximum(r, b), 0, 1)
|
||||
veg[s / 255.0 < 0.12] = 0
|
||||
tex = np.abs(gray - cv2.GaussianBlur(gray, (0, 0), 3))
|
||||
base = veg + tex
|
||||
grad = cv2.magnitude(cv2.Sobel(base, cv2.CV_32F, 1, 0, ksize=3),
|
||||
cv2.Sobel(base, cv2.CV_32F, 0, 1, ksize=3))
|
||||
light = v / 255.0
|
||||
mean_light = float(gray.mean())
|
||||
if mean_light > 0.55:
|
||||
light_w = 1.0 - np.abs(light - 0.25)
|
||||
else:
|
||||
light_w = 1.0 - np.abs(light - 0.6)
|
||||
cover = (veg * 0.55 + tex * 0.30 + grad * 0.15) * (0.4 + 0.6 * light_w)
|
||||
cover[light < 0.12] = 0
|
||||
cover[light > 0.93] *= 0.15
|
||||
cover = cv2.GaussianBlur(cover, (0, 0), 7)
|
||||
windows = []
|
||||
for area_frac in (0.02, 0.04, 0.07):
|
||||
for aspect in (1.0, 1.6, 2.5):
|
||||
bw = int(round((area_frac * aspect * H * W) ** 0.5))
|
||||
bh = int(round((area_frac * H * W / aspect) ** 0.5))
|
||||
bw, bh = min(bw, W), min(bh, H)
|
||||
windows.append((cv2.boxFilter(cover, -1, (bw, bh)), bw, bh))
|
||||
stack = np.stack([w[0] for w in windows])
|
||||
best = stack.max(axis=0)
|
||||
best_idx = stack.argmax(axis=0)
|
||||
floor = max(float(best.max()) * 0.35, 0.02)
|
||||
spots = []
|
||||
for _ in range(n_spots):
|
||||
idx = np.unravel_index(int(np.argmax(best)), best.shape)
|
||||
y, x = idx
|
||||
if best[y, x] < floor:
|
||||
break
|
||||
_, bw, bh = windows[int(best_idx[y, x])]
|
||||
x0 = max(0, min(x - bw // 2, W - bw))
|
||||
y0 = max(0, min(y - bh // 2, H - bh))
|
||||
spots.append((x0, y0, bw, bh))
|
||||
mx, my = max(bw, 64), max(bh, 64)
|
||||
best[max(0, y - my):min(H, y + my), max(0, x - mx):min(W, x + mx)] = -1
|
||||
return [(int(x), int(y), int(w), int(h)) for x, y, w, h in spots]
|
||||
|
||||
|
||||
def suppress_overlap(lines):
|
||||
"""class 1(疑似)框与任一 class 0(确认)框重叠时删除,只保留 class 0"""
|
||||
cls0 = [l for l in lines if l.startswith("0 ")]
|
||||
if not cls0:
|
||||
return lines
|
||||
boxes0 = [tuple(map(float, l.split())) for l in cls0]
|
||||
keep1 = []
|
||||
for l in lines:
|
||||
if l.startswith("0 "):
|
||||
continue
|
||||
c, cx, cy, bw, bh = map(float, l.split())
|
||||
x1, y1, x2, y2 = cx - bw / 2, cy - bh / 2, cx + bw / 2, cy + bh / 2
|
||||
overlap = False
|
||||
for kc, kx, ky, kw, kh in boxes0:
|
||||
ix = min(x2, kx + kw / 2) - max(x1, kx - kw / 2)
|
||||
iy = min(y2, ky + kh / 2) - max(y1, ky - kh / 2)
|
||||
if ix > 0 and iy > 0:
|
||||
overlap = True
|
||||
break
|
||||
if not overlap:
|
||||
keep1.append(l)
|
||||
return cls0 + keep1
|
||||
|
||||
|
||||
def process(name):
|
||||
path = os.path.join(SRC_IMAGES, name)
|
||||
img = cv2.imread(path)
|
||||
H, W = img.shape[:2]
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
mime = IMG_MIME.get(ext, "image/jpeg")
|
||||
jpeg_params = [cv2.IMWRITE_JPEG_QUALITY, 100] if ext in (".jpg", ".jpeg") else []
|
||||
# 整图等比缩放至最长边 INPUT_SIZE,不裁剪;检测坐标按比例映射回原图
|
||||
scale = min(1.0, INPUT_SIZE / max(H, W))
|
||||
if scale < 1.0:
|
||||
small = cv2.resize(img, (round(W * scale), round(H * scale)),
|
||||
interpolation=cv2.INTER_AREA)
|
||||
else:
|
||||
small = img
|
||||
ok, buf = cv2.imencode(ext, small, jpeg_params)
|
||||
boxes = []
|
||||
for d in detect(buf, mime):
|
||||
if not KEEP_CLASSES or d.get("class_name") in KEEP_CLASSES:
|
||||
boxes.append((d["x"] / scale, d["y"] / scale,
|
||||
d["width"] / scale, d["height"] / scale, d["confidence"]))
|
||||
# 野鸡目标 20-100px(近大远小);排除大物体误检(田地纹理)与细条误检
|
||||
boxes = [b for b in nms(boxes, NMS_IOU)
|
||||
if 4 <= b[3] <= H * 0.06 and 4 <= b[2] <= W * 0.1
|
||||
and 0.3 <= b[2] / b[3] <= 3.0]
|
||||
hiding = find_hiding_spots(img, H, W)
|
||||
return name, W, H, boxes, hiding
|
||||
|
||||
|
||||
def reclassify():
|
||||
"""不重新检测:按当前 CONF_CONFIRMED 从分析 JSON 重出 txt 标签与预览"""
|
||||
total_boxes = total_empty = 0
|
||||
for p in sorted(glob.glob(os.path.join(OUT_JSON, "*.json"))):
|
||||
j = json.load(open(p, encoding="utf-8"))
|
||||
W, H = j["size"]
|
||||
stem = os.path.splitext(os.path.basename(p))[0]
|
||||
lines = []
|
||||
for b in j["candidates"]:
|
||||
x, y, w, h, conf = b["x"], b["y"], b["width"], b["height"], b["confidence"]
|
||||
cx, cy, bw, bh = (x + w / 2) / W, (y + h / 2) / H, w / W, h / H
|
||||
cls = 0 if conf >= CONF_CONFIRMED else 1
|
||||
lines.append(f"{cls} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}")
|
||||
for b in j.get("hiding_spots", []):
|
||||
x, y, w, h = b["x"], b["y"], b["width"], b["height"]
|
||||
cx, cy, bw, bh = (x + w / 2) / W, (y + h / 2) / H, w / W, h / H
|
||||
lines.append(f"1 {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}")
|
||||
lines = suppress_overlap(lines)
|
||||
with open(os.path.join(OUT_LABELS, f"{stem}.txt"), "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines) + ("\n" if lines else ""))
|
||||
draw_preview(os.path.join(SRC_IMAGES, j["image"]), lines,
|
||||
os.path.join(OUT_PREVIEW, f"{stem}.jpg"))
|
||||
total_boxes += len(lines)
|
||||
if not lines:
|
||||
total_empty += 1
|
||||
print(f"重分类完成: {total_boxes} 框, 空图 {total_empty} 张", flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="RF-DETR 切块放大批量检测野鸡候选框")
|
||||
ap.add_argument("--names", type=str, default="", help="只处理指定编号,逗号分隔")
|
||||
ap.add_argument("--reclassify", action="store_true",
|
||||
help="不重新检测,按当前 CONF_CONFIRMED 从分析JSON重出标签与预览")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reclassify:
|
||||
reclassify()
|
||||
return
|
||||
|
||||
for d in (OUT_LABELS, OUT_JSON, OUT_PREVIEW):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
names = sorted(p.name for p in os.scandir(SRC_IMAGES)
|
||||
if p.name.startswith("pheasant_") and p.name.endswith(".jpg"))
|
||||
if args.names:
|
||||
want = {f"pheasant_{n}.jpg" for n in args.names.split(",")}
|
||||
names = [n for n in names if n in want]
|
||||
|
||||
total_boxes = total_empty = 0
|
||||
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
|
||||
futures = [pool.submit(process, n) for n in names]
|
||||
for fut in as_completed(futures):
|
||||
name, W, H, boxes, hiding = fut.result()
|
||||
stem = os.path.splitext(name)[0]
|
||||
lines = []
|
||||
for x, y, w, h, conf in boxes:
|
||||
cx, cy, bw, bh = (x + w / 2) / W, (y + h / 2) / H, w / W, h / H
|
||||
cls = 0 if conf >= CONF_CONFIRMED else 1
|
||||
lines.append(f"{cls} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}")
|
||||
for x, y, w, h in hiding:
|
||||
cx, cy, bw, bh = (x + w / 2) / W, (y + h / 2) / H, w / W, h / H
|
||||
lines.append(f"1 {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}")
|
||||
lines = suppress_overlap(lines)
|
||||
with open(os.path.join(OUT_JSON, f"{stem}.json"), "w", encoding="utf-8") as f:
|
||||
json.dump({"image": name, "size": [W, H],
|
||||
"candidates": [{"x": b[0], "y": b[1], "width": b[2],
|
||||
"height": b[3], "confidence": b[4]} for b in boxes],
|
||||
"hiding_spots": [{"x": b[0], "y": b[1], "width": b[2],
|
||||
"height": b[3]} for b in hiding]},
|
||||
f, ensure_ascii=False, indent=1)
|
||||
with open(os.path.join(OUT_LABELS, f"{stem}.txt"), "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines) + ("\n" if lines else ""))
|
||||
draw_preview(os.path.join(SRC_IMAGES, name), lines,
|
||||
os.path.join(OUT_PREVIEW, f"{stem}.jpg"))
|
||||
total_boxes += len(lines)
|
||||
if not lines:
|
||||
total_empty += 1
|
||||
print(f"{name}: 候选{len(lines)}", flush=True)
|
||||
|
||||
print(f"完成: {len(names)} 张, 候选框 {total_boxes}, 无候选 {total_empty} 张", flush=True)
|
||||
|
||||
|
||||
def draw_preview(img_path, lines, out_path):
|
||||
img = cv2.imread(img_path)
|
||||
h, w = img.shape[:2]
|
||||
for line in lines:
|
||||
cls, cx, cy, bw, bh = map(float, line.split())
|
||||
x1, y1 = int((cx - bw / 2) * w), int((cy - bh / 2) * h)
|
||||
x2, y2 = int((cx + bw / 2) * w), int((cy + bh / 2) * h)
|
||||
color = (0, 0, 255) if cls == 0 else (255, 0, 0)
|
||||
cv2.rectangle(img, (x1, y1), (x2, y2), color, 2)
|
||||
cv2.imwrite(out_path, img)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""从 RF-DETR 候选标注整理 YOLO 训练集(class 0 确认野鸡 + class 1 疑似/藏身点, 80/20 拆分)"""
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
IMG_SRC = os.path.join(BASE, "datasets/images/pheasant")
|
||||
LBL_SRC = os.path.join(BASE, "datasets/labels/pheasant_rfdetr")
|
||||
OUT = os.path.join(BASE, "datasets/yolo")
|
||||
|
||||
random.seed(42)
|
||||
|
||||
stems = sorted(f[:-4] for f in os.listdir(LBL_SRC) if f.endswith(".txt"))
|
||||
kept, dropped = [], []
|
||||
for s in stems:
|
||||
src_lbl = os.path.join(LBL_SRC, s + ".txt")
|
||||
with open(src_lbl, encoding="utf-8") as f:
|
||||
lines = [l for l in f.read().splitlines() if l]
|
||||
if not lines:
|
||||
dropped.append(s)
|
||||
continue
|
||||
kept.append((s, lines))
|
||||
|
||||
random.shuffle(kept)
|
||||
n_val = max(1, round(len(kept) * 0.2))
|
||||
val, train = kept[:n_val], kept[n_val:]
|
||||
|
||||
for split, items in (("train", train), ("val", val)):
|
||||
for d in (os.path.join(OUT, "images", split), os.path.join(OUT, "labels", split)):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
for s, lines in items:
|
||||
shutil.copy(os.path.join(IMG_SRC, s + ".jpg"), os.path.join(OUT, "images", split, s + ".jpg"))
|
||||
with open(os.path.join(OUT, "labels", split, s + ".txt"), "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
yaml = """path: /opt/pheasant_data/datasets/yolo
|
||||
train: images/train
|
||||
val: images/val
|
||||
names:
|
||||
0: pheasant
|
||||
1: suspect
|
||||
"""
|
||||
with open(os.path.join(OUT, "dataset.yaml"), "w", encoding="utf-8") as f:
|
||||
f.write(yaml)
|
||||
|
||||
print(f"train: {len(train)} 张, val: {len(val)} 张, 空标注被剔除: {len(dropped)} 张")
|
||||
print("剔除:", ", ".join(dropped) if dropped else "无")
|
||||
@@ -1,21 +0,0 @@
|
||||
from ultralytics import YOLO
|
||||
import os
|
||||
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
YOLO_DIR = os.path.join(BASE, "datasets/yolo")
|
||||
data = os.path.join(YOLO_DIR, "dataset_local.yaml")
|
||||
with open(data, "w", encoding="utf-8") as f:
|
||||
f.write(f"""path: {YOLO_DIR}
|
||||
train: images/train
|
||||
val: images/val
|
||||
names:
|
||||
0: pheasant
|
||||
1: suspect
|
||||
""")
|
||||
|
||||
model = YOLO("yolov8n.pt")
|
||||
model.train(data=data, imgsz=1024, epochs=150, patience=30, batch=8,
|
||||
device="mps", workers=4,
|
||||
# project 必须绝对路径,相对路径会被拼到默认 runs/detect 下造成双层嵌套
|
||||
project=os.path.join(BASE, "runs/pheasant"), name="yolov8n_1024",
|
||||
exist_ok=True, plots=True)
|
||||
@@ -1,18 +0,0 @@
|
||||
from ultralytics import YOLO
|
||||
import os
|
||||
|
||||
# 训练分辨率与端侧推理对齐:tflite 导出/真机推理均为 704x704。
|
||||
# 训练 1280 会让模型对"清晰大目标"自信,推理时目标变小置信度崩坏(误报根源)。
|
||||
# multi_scale=0.5 随机缩放输入 0.5~1.5x,进一步抗尺度漂移。
|
||||
# 训练完成后导出 tflite(imgsz=704,与训练一致)。
|
||||
os.chdir("/opt/pheasant_data")
|
||||
model = YOLO("yolov8n.pt")
|
||||
model.train(data="datasets/yolo/dataset.yaml", imgsz=704, epochs=150,
|
||||
patience=30, batch=16, device=0, workers=4,
|
||||
# project 必须绝对路径,相对路径会被拼到默认 runs/detect 下造成双层嵌套
|
||||
project="/opt/pheasant_data/runs/pheasant", name="yolov8n_704",
|
||||
exist_ok=True, plots=True, multi_scale=0.5)
|
||||
|
||||
# 导出 tflite:输入固定 [1,3,704,704],与 flutter_app/assets/model.tflite 一致
|
||||
model.export(format="tflite", imgsz=704)
|
||||
print("EXPORT_DONE: /opt/pheasant_data/runs/pheasant/yolov8n_704/weights/best.tflite")
|
||||
Reference in New Issue
Block a user