@windsland52/maafw-live 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +293 -2
  3. package/bin/maafw-live.mjs +25 -0
  4. package/docs/daemon-protocol.md +121 -0
  5. package/docs/keyframe-retention-contract-v0.md +225 -0
  6. package/docs/session-unify-v0.md +115 -0
  7. package/lib/client/daemon.js +276 -0
  8. package/lib/commands/annotate.js +179 -0
  9. package/lib/commands/calibrate.js +61 -0
  10. package/lib/commands/color.js +73 -0
  11. package/lib/commands/crop.js +228 -0
  12. package/lib/commands/dispatch.js +121 -0
  13. package/lib/commands/env.js +409 -0
  14. package/lib/commands/index.js +70 -0
  15. package/lib/commands/input.js +257 -0
  16. package/lib/commands/kf.js +167 -0
  17. package/lib/commands/probe.js +70 -0
  18. package/lib/commands/reco.js +440 -0
  19. package/lib/commands/repl.js +1501 -0
  20. package/lib/commands/roi-check.js +273 -0
  21. package/lib/commands/roi-compare.js +388 -0
  22. package/lib/commands/runtime.js +718 -0
  23. package/lib/commands/skill.js +169 -0
  24. package/lib/commands/timing.js +247 -0
  25. package/lib/commands/version.js +66 -0
  26. package/lib/daemon/framed.mjs +3680 -0
  27. package/lib/daemon/reco_child.mjs +287 -0
  28. package/lib/exec.js +96 -0
  29. package/lib/flags.js +44 -0
  30. package/lib/image.js +90 -0
  31. package/lib/index.js +143 -0
  32. package/lib/interface/hotkey.js +142 -0
  33. package/lib/interface/index.js +12 -0
  34. package/lib/interface/jsonc.js +95 -0
  35. package/lib/interface/load.js +397 -0
  36. package/lib/interface/override.js +287 -0
  37. package/lib/interface/plan.js +279 -0
  38. package/lib/option-args.js +29 -0
  39. package/lib/pkg.js +54 -0
  40. package/lib/protocol.js +94 -0
  41. package/lib/runtime/actions.js +225 -0
  42. package/lib/runtime/keyframes.js +73 -0
  43. package/lib/runtime/log-watch.js +160 -0
  44. package/lib/runtime/node-source.js +100 -0
  45. package/lib/runtime/ocr-model.js +45 -0
  46. package/lib/runtime/pi-env.js +34 -0
  47. package/lib/runtime/pretask.js +217 -0
  48. package/lib/runtime/prune.js +28 -0
  49. package/lib/runtime/receipts.js +57 -0
  50. package/lib/runtime/reco-args.js +38 -0
  51. package/lib/runtime/reco-verdict.js +82 -0
  52. package/lib/runtime/roi-compare.js +198 -0
  53. package/lib/runtime/session-forward.js +79 -0
  54. package/lib/runtime/session-ipc.js +471 -0
  55. package/lib/runtime/session.js +135 -0
  56. package/lib/runtime/signal-guard.js +35 -0
  57. package/lib/types/client/daemon.d.ts +86 -0
  58. package/lib/types/commands/annotate.d.ts +3 -0
  59. package/lib/types/commands/calibrate.d.ts +7 -0
  60. package/lib/types/commands/color.d.ts +9 -0
  61. package/lib/types/commands/crop.d.ts +3 -0
  62. package/lib/types/commands/dispatch.d.ts +22 -0
  63. package/lib/types/commands/env.d.ts +21 -0
  64. package/lib/types/commands/index.d.ts +13 -0
  65. package/lib/types/commands/input.d.ts +17 -0
  66. package/lib/types/commands/kf.d.ts +10 -0
  67. package/lib/types/commands/probe.d.ts +15 -0
  68. package/lib/types/commands/reco.d.ts +27 -0
  69. package/lib/types/commands/repl.d.ts +10 -0
  70. package/lib/types/commands/roi-check.d.ts +3 -0
  71. package/lib/types/commands/roi-compare.d.ts +3 -0
  72. package/lib/types/commands/runtime.d.ts +66 -0
  73. package/lib/types/commands/skill.d.ts +17 -0
  74. package/lib/types/commands/timing.d.ts +64 -0
  75. package/lib/types/commands/version.d.ts +3 -0
  76. package/lib/types/exec.d.ts +31 -0
  77. package/lib/types/flags.d.ts +62 -0
  78. package/lib/types/image.d.ts +27 -0
  79. package/lib/types/index.d.ts +1 -0
  80. package/lib/types/interface/hotkey.d.ts +36 -0
  81. package/lib/types/interface/index.d.ts +11 -0
  82. package/lib/types/interface/jsonc.d.ts +9 -0
  83. package/lib/types/interface/load.d.ts +190 -0
  84. package/lib/types/interface/override.d.ts +78 -0
  85. package/lib/types/interface/plan.d.ts +68 -0
  86. package/lib/types/option-args.d.ts +19 -0
  87. package/lib/types/pkg.d.ts +14 -0
  88. package/lib/types/protocol.d.ts +124 -0
  89. package/lib/types/runtime/actions.d.ts +248 -0
  90. package/lib/types/runtime/keyframes.d.ts +43 -0
  91. package/lib/types/runtime/log-watch.d.ts +28 -0
  92. package/lib/types/runtime/node-source.d.ts +25 -0
  93. package/lib/types/runtime/ocr-model.d.ts +8 -0
  94. package/lib/types/runtime/pi-env.d.ts +24 -0
  95. package/lib/types/runtime/pretask.d.ts +53 -0
  96. package/lib/types/runtime/prune.d.ts +4 -0
  97. package/lib/types/runtime/receipts.d.ts +7 -0
  98. package/lib/types/runtime/reco-args.d.ts +18 -0
  99. package/lib/types/runtime/reco-verdict.d.ts +50 -0
  100. package/lib/types/runtime/roi-compare.d.ts +117 -0
  101. package/lib/types/runtime/session-forward.d.ts +10 -0
  102. package/lib/types/runtime/session-ipc.d.ts +117 -0
  103. package/lib/types/runtime/session.d.ts +77 -0
  104. package/lib/types/runtime/signal-guard.d.ts +19 -0
  105. package/lib/types/update-check.d.ts +20 -0
  106. package/lib/types/usage.d.ts +3 -0
  107. package/lib/update-check.js +97 -0
  108. package/lib/usage.js +50 -0
  109. package/package.json +94 -4
  110. package/skills/README.md +51 -0
  111. package/skills/maafw-live/SKILL.md +293 -0
  112. package/skills/maafw-live/references/examples.md +238 -0
  113. package/skills/maafw-live/references/pitfalls.md +211 -0
  114. package/skills/maafw-live/references/workflows.md +164 -0
@@ -0,0 +1,388 @@
1
+ /**
2
+ * `maafw-live roi-compare` —— 外来目标 + 一组位置假设 → 排序表 / bestGap / 并列带 / 三态结论。
3
+ *
4
+ * 与 `roi-check` 的关系:`roi-check` 的候选写死成"沿 x 偏移 ±pitch/2"(案例专用,且要求你先知道
5
+ * 周期),`roi-compare` 把判据泛化成**候选集合比较**——候选从哪来由参数决定(显式全套 / 相对平移 /
6
+ * 发现模式),判据只有一份(`src/runtime/roi-compare.ts`)。
7
+ *
8
+ * 判据三条(灵魂,别改):
9
+ * 1. **外来目标**:用资源目录里的模板(`--target`);
10
+ * 2. **允许说"分不出来"**:最优与次优差 < `--gap`(缺省 0.15)且并列 ≥ 2 → AMBIGUOUS + 退出 3;
11
+ * 3. **不说无证据的结论**:无得分的候选既不当 0 分,也不许被当成"垫底"来宣布胜出。
12
+ *
13
+ * 成本(**别以为是秒回**):候选在 daemon 侧**逐例串行**跑,实测 ≈ 0.24s/例 + 一次子进程启动
14
+ * (本项目实测 15 例约 12s);候选数线性涨(41 个候选按实测 ≈ 20s,换更重的模板/画面会到分钟级)。
15
+ * 一次命令只 spawn 一次识别子进程(候选折进同一个 `cases`),不是每个候选 spawn 一次。
16
+ */
17
+ import { existsSync } from 'node:fs';
18
+ import { EXIT, fail } from '../protocol.js';
19
+ import { recoTest, withDaemon } from '../runtime/actions.js';
20
+ import { aggregate, DEFAULT_GAP, fromOffsets, fromRois, fromScan, parseScan, sampleOf, scanInfo, } from '../runtime/roi-compare.js';
21
+ import { daemonFail, argPath } from './runtime.js';
22
+ function parseRois(raw) {
23
+ const value = [];
24
+ for (const part of raw.split(';')) {
25
+ const seg = part.trim();
26
+ if (!seg)
27
+ continue;
28
+ const nums = seg.split(',').map((x) => Number(x.trim()));
29
+ if (nums.length !== 4 || nums.some((n) => !Number.isFinite(n))) {
30
+ return { error: '--rois 每段要 4 个数字 x,y,w,h(段之间用 ; 隔开),不认识:' + seg };
31
+ }
32
+ value.push(nums);
33
+ }
34
+ if (!value.length)
35
+ return { error: '--rois 是空的:至少给一个 x,y,w,h' };
36
+ return { value };
37
+ }
38
+ function parseOffsets(raw) {
39
+ const value = [];
40
+ for (const part of raw.split(';')) {
41
+ const seg = part.trim();
42
+ if (!seg)
43
+ continue;
44
+ const nums = seg.split(',').map((x) => Number(x.trim()));
45
+ if (nums.some((n) => !Number.isFinite(n)) || (nums.length !== 2 && nums.length !== 4)) {
46
+ return {
47
+ error: '--offsets 每段要 2 个数字 dx,dy(相对 --roi 的平移)或 4 个数字 x,y,w,h(当基准用),不认识:' + seg,
48
+ };
49
+ }
50
+ value.push(nums.length === 2 ? { dx: nums[0], dy: nums[1] } : { dx: 0, dy: 0, abs: nums });
51
+ }
52
+ if (!value.length)
53
+ return { error: '--offsets 是空的:至少给一个 dx,dy' };
54
+ return { value };
55
+ }
56
+ const fmt = (n) => (n === null ? '(无得分)' : String(Math.round(n * 1000) / 1000));
57
+ /** 一个轴的人读写法:单点写 `axis=<n>`,区间写 `axis=<from>..<to>/<step>`。 */
58
+ const axisText = (name, a) => a === null ? name + '=0'
59
+ : a.from === a.to ? name + '=' + a.from
60
+ : name + '=' + a.from + '..' + a.to + '/' + a.step;
61
+ /** 单轴有范围时给出步长(两轴都有范围时"那个"步长无从谈起,如实给 null)。 */
62
+ function scanStep(spec) {
63
+ const ranged = [spec.dx, spec.dy].filter((a) => a !== undefined && a.from !== a.to);
64
+ return ranged.length === 1 ? ranged[0].step : null;
65
+ }
66
+ export const roiCompareCommand = {
67
+ name: 'roi-compare',
68
+ summary: '候选集合比较:外来模板在同图的一批位置假设上的排序表 + bestGap + 并列带 + 三态结论',
69
+ usage: 'maafw-live roi-compare --image <png> --target <资源目录内的模板> --resource-dir <dir>\n' +
70
+ ' ( --rois "x,y,w,h;x,y,w,h;…" # 显式全套(位置已经知道时用这条)\n' +
71
+ ' | --offsets "0,0;135,0;140,0;0,111" # 相对 --roi 的平移,x/y 分量与非均匀列表都允许\n' +
72
+ ' | --scan "dx=-160..160/8,dy=0" ) # 你也不知道周期时的发现模式\n' +
73
+ ' [--roi x,y,w,h] [--threshold <t>] [--gap <0.15>] [--name TemplateMatch]\n' +
74
+ ' 判据是**候选集合比较**:按分数排序,报第 1/2 名的真实分差(bestGap)与并列带(withinGap)\n' +
75
+ ' 三态:唯一最优且过门槛 → 命中(0) / 歧义·未命中·有候选无法评估 → 检出问题(3) / 参数错 → 2\n' +
76
+ ' 候选越界(含左上负坐标):跳过并报出是谁;全部越界 → 检出问题(3)\n' +
77
+ ' --name 只接受 TemplateMatch:别的识别类型不读 template,会把 --target 静默吞掉\n' +
78
+ ' --scan 是**发现模式**,不是判据:结论只说"发现/未发现唯一最优",不据此判位置对错\n' +
79
+ ' --roi 是 --offsets/--scan 的基准(--offsets 缺省用第一个候选:把它写成 x,y,w,h)\n' +
80
+ ' 成本:候选逐例**串行**,实测 ≈ 0.24s/例 + 一次子进程启动;候选越多越慢,不是秒回',
81
+ options: {
82
+ image: { type: 'string' },
83
+ target: { type: 'string' },
84
+ 'resource-dir': { type: 'string' },
85
+ rois: { type: 'string' },
86
+ offsets: { type: 'string' },
87
+ scan: { type: 'string' },
88
+ roi: { type: 'string' },
89
+ threshold: { type: 'string' },
90
+ gap: { type: 'string' },
91
+ name: { type: 'string' },
92
+ },
93
+ async run(ctx) {
94
+ /* kf: 前缀必须在 argPath 之前判——先 resolve 会变成 C:\...\kf:x 绕过本校验 */
95
+ const imageRaw = typeof ctx.values.image === 'string' ? ctx.values.image : undefined;
96
+ const target = typeof ctx.values.target === 'string' ? ctx.values.target : undefined;
97
+ const resourceDir = argPath(ctx, typeof ctx.values['resource-dir'] === 'string' ? ctx.values['resource-dir'] : undefined);
98
+ if (!imageRaw || !target) {
99
+ return fail('BAD_ARGUMENTS', '缺 --image <png> 或 --target <模板>', '外来目标是本命令的灵魂:用"假说自己"当模板,候选之间各自 1.000,什么都证明不了', EXIT.USAGE);
100
+ }
101
+ if (imageRaw.startsWith('kf:')) {
102
+ return fail('BAD_ARGUMENTS', '--image 要文件路径;kf: 开头的库帧 ID 请走 reco --from-kf(本命令暂不支持库帧)', undefined, EXIT.USAGE);
103
+ }
104
+ const image = argPath(ctx, imageRaw); /* imageRaw 上面已判非空 */
105
+ if (!existsSync(image)) {
106
+ return fail('BAD_ARGUMENTS', '图像不存在:' + image, undefined, EXIT.USAGE);
107
+ }
108
+ if (!resourceDir) {
109
+ return fail('BAD_ARGUMENTS', '缺 --resource-dir:模板与 OCR 模型都要靠它加载,不给只会得到零例数(看不出病因)', '模板路径按资源目录的相对路径给,如 CritterCrash/P40/Leaders_30027.png', EXIT.USAGE);
110
+ }
111
+ let base;
112
+ const roiRaw = typeof ctx.values.roi === 'string' ? ctx.values.roi : undefined;
113
+ if (roiRaw !== undefined) {
114
+ const nums = roiRaw.split(',').map((x) => Number(x.trim()));
115
+ if (nums.length !== 4 || nums.some((n) => !Number.isFinite(n))) {
116
+ return fail('BAD_ARGUMENTS', '--roi 需要 4 个数字:x,y,w,h(按 --image 的像素坐标给)', undefined, EXIT.USAGE);
117
+ }
118
+ base = nums;
119
+ }
120
+ /* 候选来源三选一:多给不是"叠加",是语义打架——明确报错,不猜哪个优先。 */
121
+ const sources = ['rois', 'offsets', 'scan']
122
+ .filter((k) => typeof ctx.values[k] === 'string' && ctx.values[k] !== '');
123
+ if (sources.length !== 1) {
124
+ return fail('BAD_ARGUMENTS', sources.length === 0
125
+ ? '缺候选来源:给 --rois / --offsets / --scan 之一'
126
+ : '候选来源只能给一个,收到:' + sources.map((s) => '--' + s).join(' + '), '--rois 是显式全套;--offsets 是相对基准的平移;--scan 是你也说不上周期时的发现模式', EXIT.USAGE);
127
+ }
128
+ const source = sources[0];
129
+ const thrRaw = typeof ctx.values.threshold === 'string' ? ctx.values.threshold : undefined;
130
+ const threshold = thrRaw !== undefined ? Number(thrRaw) : undefined;
131
+ if (thrRaw !== undefined && (!Number.isFinite(threshold) || threshold < 0 || threshold > 1)) {
132
+ return fail('BAD_ARGUMENTS', '--threshold 要在 [0,1](TemplateMatch 的置信度门槛;缺省不做门槛断言)', undefined, EXIT.USAGE);
133
+ }
134
+ const gapRaw = typeof ctx.values.gap === 'string' ? ctx.values.gap : undefined;
135
+ const gap = gapRaw !== undefined ? Number(gapRaw) : DEFAULT_GAP;
136
+ if (!Number.isFinite(gap) || gap < 0 || gap > 1) {
137
+ return fail('BAD_ARGUMENTS', '--gap 要在 [0,1](并列带宽度:最优与次优分差小于它就不下结论)', undefined, EXIT.USAGE);
138
+ }
139
+ /* 只认 TemplateMatch:`--target` 是必给的**模板**,而 OCR / ColorMatch 这些类型根本不读
140
+ * `template`——放行它们就是"参数被静默忽略、分数换成另一套含义,而回执照旧打「外来目标」",
141
+ * 正是本命令要治的那种静默错。 */
142
+ const nameRaw = typeof ctx.values.name === 'string' ? ctx.values.name.trim() : '';
143
+ if (nameRaw && nameRaw !== 'TemplateMatch') {
144
+ return fail('BAD_ARGUMENTS', '--name 只接受 TemplateMatch,收到:' + nameRaw, '别的识别类型(OCR / ColorMatch …)不读 template:--target 会被静默忽略、score 也换成另一套含义', EXIT.USAGE);
145
+ }
146
+ const type = 'TemplateMatch';
147
+ /* 候选生成器只写一遍:`build()` 不判越界(还不知道图像尺寸)→ 决定发哪些候选;
148
+ * `build({w,h})` 用回执里的 meta 尺寸跑同一个函数 → 决定报哪些候选被跳过。
149
+ * 越界判据(roiOutOfBounds)因此仍然只有一份,不在这里重写。 */
150
+ let scan;
151
+ let build;
152
+ if (source === 'rois') {
153
+ const parsed = parseRois(ctx.values.rois);
154
+ if (parsed.error)
155
+ return fail('BAD_ARGUMENTS', parsed.error, undefined, EXIT.USAGE);
156
+ const rois = parsed.value;
157
+ build = (size) => fromRois(rois, size);
158
+ }
159
+ else if (source === 'offsets') {
160
+ const parsed = parseOffsets(ctx.values.offsets);
161
+ if (parsed.error)
162
+ return fail('BAD_ARGUMENTS', parsed.error, undefined, EXIT.USAGE);
163
+ const entries = parsed.value;
164
+ let anchor;
165
+ let offsets;
166
+ if (base) {
167
+ if (entries.some((e) => e.abs)) {
168
+ return fail('BAD_ARGUMENTS', '--offsets 里出现 4 个数字的绝对 roi,同时又给了 --roi 基准:只能二选一', '要么全部写 dx,dy 并用 --roi 给基准,要么不给 --roi、让第一段写成 x,y,w,h 当基准', EXIT.USAGE);
169
+ }
170
+ anchor = base;
171
+ offsets = entries.map((e) => ({ dx: e.dx, dy: e.dy }));
172
+ }
173
+ else {
174
+ const first = entries[0];
175
+ if (!first.abs) {
176
+ return fail('BAD_ARGUMENTS', '缺 --roi(--offsets 的基准):第一段只有 2 个分量,没法当绝对位置', '给 --roi x,y,w,h,或把第一段写成 x,y,w,h 当基准', EXIT.USAGE);
177
+ }
178
+ anchor = first.abs;
179
+ offsets = [{ dx: 0, dy: 0 }, ...entries.slice(1).map((e) => ({ dx: e.dx, dy: e.dy }))];
180
+ }
181
+ base = anchor;
182
+ build = (size) => fromOffsets(offsets, anchor, size);
183
+ }
184
+ else {
185
+ const parsed = parseScan(ctx.values.scan);
186
+ if (parsed.error)
187
+ return fail('BAD_ARGUMENTS', parsed.error, undefined, EXIT.USAGE);
188
+ scan = parsed.value;
189
+ if (!base) {
190
+ return fail('BAD_ARGUMENTS', '--scan 缺 --roi:扫描是相对一个基准位置的平移,基准必须显式给', '形如 --roi 462,175,68,44 --scan "dx=-160..160/8,dy=0"', EXIT.USAGE);
191
+ }
192
+ const anchor = base;
193
+ build = (size) => fromScan(scan, anchor, size);
194
+ }
195
+ const sent = build().candidates;
196
+ if (!sent.length) {
197
+ return fail('BAD_ARGUMENTS', '没有可用的候选:候选来源给出的都不是 4 个有限数字', undefined, EXIT.USAGE);
198
+ }
199
+ try {
200
+ return await withDaemon(ctx, async (c) => {
201
+ const t0 = Date.now();
202
+ /* 客户端预算必须比 daemon 侧那笔更宽(daemon:max(90s, 5s×例),spawnRecoChild 再封顶 120s)
203
+ * ——反过来的话,还在正常跑的子进程会先被客户端判成超时,看起来像"识别失败"。 */
204
+ const r = await recoTest(c, {
205
+ resourceDir, type, image,
206
+ param: { template: target },
207
+ candidates: sent.map((x) => x.roi),
208
+ }, Math.max(90000, sent.length * 5000) + 20000);
209
+ const elapsedMs = Date.now() - t0;
210
+ const results = r.results ?? [];
211
+ const meta = r.meta;
212
+ if (r.ok === false && !results.length) {
213
+ return fail('ROI_COMPARE', String(r.error ?? '识别失败'), undefined, EXIT.FINDINGS);
214
+ }
215
+ /* 逐例回执与发出的候选**逐个对齐**(顺序不变量):数量不符就是契约破了,不按"分数看起来对"猜。 */
216
+ if (results.length !== sent.length) {
217
+ return fail('ROI_COMPARE', '识别回执例数 ' + results.length + ' ≠ 发出的候选数 ' + sent.length + ':逐例对应关系断了,不猜', 'daemon 的 candidates 折叠应逐例透传(同一次 spawnRecoChild)', EXIT.FINDINGS);
218
+ }
219
+ const w = Number(meta?.w);
220
+ const h = Number(meta?.h);
221
+ const known = Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0;
222
+ /* 逐例对应关系是**结构性不变量**(results[i] ↔ 发出的第 i 条),而 results[i].param.roi
223
+ * 必须就是我发出去的那条 roi。不是就明确报"断了"——`sweep` 是"同键换值"、候选是"逐例换 roi",
224
+ * 形状像但不变量不同;分数悄悄错位是这条判据最贵的失败方式。 */
225
+ const sampleByOrigin = new Map();
226
+ const caseRoiByOrigin = new Map();
227
+ for (let i = 0; i < sent.length; i++) {
228
+ const sentRoi = sent[i].roi;
229
+ const echoed = results[i]?.param?.roi;
230
+ const got = Array.isArray(echoed) ? echoed.map(Number) : null;
231
+ if (!got || got.length !== 4 || !got.every(Number.isFinite) || got.join(',') !== sentRoi.join(',')) {
232
+ return fail('ROI_COMPARE', '第 ' + (i + 1) + ' 例回执的 param.roi=' + JSON.stringify(echoed ?? null)
233
+ + ' 与发出的候选 ' + sentRoi.join(',') + ' 不一致:逐例对应关系断了,不猜', '候选集合的比较依赖"逐例换 roi"这条不变量(reco_test 的 candidates 逐例透传)', EXIT.FINDINGS);
234
+ }
235
+ sampleByOrigin.set(sent[i].origin, sampleOf(results[i]));
236
+ caseRoiByOrigin.set(sent[i].origin, got);
237
+ }
238
+ const set = build(known ? { w, h } : undefined);
239
+ const skipped = set.skipped;
240
+ const sizeText = known ? w + 'x' + h : '尺寸未知';
241
+ const human = [
242
+ 'roi-compare(外来目标):' + target + ' on ' + image + ' 图像 ' + sizeText,
243
+ ];
244
+ const skipText = skipped.map((s) => s.roi.join(',')).join(' / ');
245
+ if (skipped.length) {
246
+ human.push(' 候选越界:跳过 ' + skipped.length + ' 个(' + skipText + ')——越界是**参数用错空间**,'
247
+ + '不是识别未命中(roi 按 ' + sizeText + ' 的像素空间给)');
248
+ }
249
+ if (!set.candidates.length) {
250
+ return {
251
+ exitCode: EXIT.FINDINGS,
252
+ human: [...human, ' 候选全部越界(' + skipped.length + ' 个):' + skipText],
253
+ error: {
254
+ code: 'ROI_OUT_OF_BOUNDS',
255
+ message: skipped[0].reason,
256
+ hint: 'roi 要按 --image 的像素坐标给(控制器的 roi 差一个 scale)',
257
+ },
258
+ };
259
+ }
260
+ /* 逐例得分按 origin 归位:发出的第 i 例 ↔ results[i](上面已验数量与 roi)。 */
261
+ const items = set.candidates.map((x) => ({
262
+ roi: x.roi, origin: x.origin, ...sampleByOrigin.get(x.origin),
263
+ }));
264
+ const outcome = aggregate(items, { gap, threshold });
265
+ const missing = items.filter((x) => x.score === null);
266
+ /* `ranked` 里**只有拿得到分数的候选**:没有分数就没有名次(它们仍在回执的逐例记录里点名)。 */
267
+ const top = outcome.ranked[0] ?? null;
268
+ const best = top?.score ?? null;
269
+ if (outcome.ranked.length) {
270
+ const roiW = Math.max(15, ...outcome.ranked.map((x) => x.roi.join(',').length));
271
+ human.push(' rank ' + 'roi'.padEnd(roiW) + ' score 命中');
272
+ for (const row of outcome.ranked) {
273
+ const delta = best !== null && row.score !== null ? Math.round((best - row.score) * 1000) / 1000 : null;
274
+ human.push(' ' + String(row.rank).padStart(4) + ' ' + row.roi.join(',').padEnd(roiW)
275
+ + ' ' + fmt(row.score).padStart(5) + ' ' + (row.ok ? '✓' : '')
276
+ + (row.inBand && row.rank > 1 && delta !== null
277
+ ? ' ← Δ=' + delta + ' < ' + outcome.gap + ',并列带内' : ''));
278
+ }
279
+ }
280
+ else {
281
+ human.push(' (没有候选拿到分数:无排序表可打)');
282
+ }
283
+ human.push(' bestGap = ' + (outcome.bestGap === null ? '不可用(可评估候选不足 2 个)' : outcome.bestGap)
284
+ + ' gap = ' + outcome.gap
285
+ + ' withinGap = [' + outcome.withinGap.join(',') + ']'
286
+ + ' best = ' + fmt(best)
287
+ + (threshold !== undefined ? ' 门槛 = ' + threshold : ''));
288
+ if (source === 'scan') {
289
+ const info = scanInfo(scan);
290
+ human.push(' 发现模式(--scan):' + axisText('dx', info.dx) + ' × ' + axisText('dy', info.dy)
291
+ + ' → ' + info.count + ' 个候选;耗时 ' + (elapsedMs / 1000).toFixed(1) + 's'
292
+ + '(候选逐例**串行**,候选数线性涨;不是秒回)');
293
+ }
294
+ else {
295
+ human.push(' 候选来源:'
296
+ + (source === 'rois'
297
+ ? '--rois(显式全套 ' + sent.length + ' 个)'
298
+ : '--offsets(相对基准 ' + (base ?? []).join(',') + ' 的 ' + sent.length + ' 个平移)')
299
+ + ';耗时 ' + (elapsedMs / 1000).toFixed(1) + 's');
300
+ }
301
+ /* 结论文案分两套:--rois/--offsets 是判据(谈位置与歧义),--scan 是发现模式
302
+ * (只说"发现/未发现唯一最优",不据此判"位置错了")。 */
303
+ const discovery = source === 'scan';
304
+ const passText = threshold !== undefined ? '过门槛 ' + threshold : '未给 --threshold(不做门槛断言)';
305
+ if (outcome.verdict === 'HIT') {
306
+ const gapText = outcome.bestGap === null
307
+ ? '(可评估候选只有 1 个,无从谈并列)'
308
+ : '与次优差 ' + outcome.bestGap + ' ≥ gap ' + outcome.gap;
309
+ human.push(discovery
310
+ ? ' 结论:**发现唯一最优** rank ' + top.rank + '(roi=' + top.roi.join(',') + ' score=' + fmt(best)
311
+ + ',' + gapText + ')—— 这是发现模式的输出,**不构成"位置对了"的判据**;'
312
+ + '要判位置请把候选集合显式给出来(--rois / --offsets)'
313
+ : ' 结论:HIT —— 唯一最优 rank 1(roi=' + top.roi.join(',') + ' score=' + fmt(best) + '),'
314
+ + gapText + ',' + passText);
315
+ }
316
+ else if (outcome.reason === 'ambiguous') {
317
+ human.push(' 结论:AMBIGUOUS —— 无法区分,需人工放大对比(差值过小)'
318
+ + ':并列带内有 rank [' + outcome.withinGap.join(',') + '](与最优的差 < gap ' + outcome.gap + ')'
319
+ + (discovery ? '—— 周期结构下不唯一正是这个信号;这是发现模式的输出,不是"位置错了"的判据' : '')
320
+ + ';下一步是人工放大对比,不是调阈值');
321
+ }
322
+ else if (outcome.reason === 'no-evidence') {
323
+ human.push(' 结论:' + (discovery ? '未发现唯一最优:' : 'MISS —— ')
324
+ + '所有候选都没有得分(外来目标在这张图上什么都没匹配上),**不是"分不出来"**;'
325
+ + '先核对目标模板与图像是否同源、roi 是否用错空间');
326
+ }
327
+ else if (outcome.reason === 'below-threshold') {
328
+ human.push(' 结论:' + (discovery ? '未发现唯一最优:' : 'MISS —— ')
329
+ + '最优 ' + top.roi.join(',') + ' 得分 ' + fmt(best) + ' < 门槛 ' + threshold
330
+ + ';识别未命中不是调低阈值的理由(先核对模板与画面是否同源)');
331
+ }
332
+ else {
333
+ human.push(' 结论:' + (discovery ? '未发现唯一最优' : 'MISS') + '(' + outcome.reason + ')');
334
+ }
335
+ /* 无得分的候选被**剔除**在比较之外(不是当 0 分垫底):这件事必须写出来,
336
+ * 并由退出码 3 承担——否则"还有候选没被评估过"就被结论盖掉了。 */
337
+ if (missing.length && outcome.reason !== 'no-evidence') {
338
+ human.push(' 注:另有 ' + missing.length + ' 个候选无得分(' + missing.map((x) => x.roi.join(',')).join(' / ')
339
+ + '),已从比较里剔除(无证据 ≠ 0 分,也不当垫底)——本结论只对可评估的 '
340
+ + (items.length - missing.length) + ' 个成立;退出码按"有候选无法评估"给 3');
341
+ }
342
+ const exitCode = outcome.verdict === 'HIT' && !missing.length ? EXIT.OK : EXIT.FINDINGS;
343
+ return {
344
+ exitCode,
345
+ human,
346
+ data: {
347
+ image, target, type,
348
+ source,
349
+ ...(base ? { base } : {}),
350
+ imageSize: known ? { w, h } : null,
351
+ candidates: {
352
+ sent: sent.length,
353
+ evaluated: items.length - missing.length,
354
+ missing: missing.map((x) => x.roi),
355
+ skipped: skipped.map((s) => ({ roi: s.roi, origin: s.origin, reason: s.reason })),
356
+ },
357
+ /* 逐例回执(**输入顺序**,**含没有得分的那几例**):完整记录,`ranked` 只放有得分的。
358
+ * `caseRoi` 是 daemon 回执里那例自己的 param.roi,必须与 `roi` 逐例相等。 */
359
+ cases: items.map((x) => ({
360
+ origin: x.origin,
361
+ roi: x.roi,
362
+ caseRoi: caseRoiByOrigin.get(x.origin) ?? null,
363
+ score: x.score,
364
+ ok: x.ok,
365
+ })),
366
+ ranked: outcome.ranked.map((x) => ({
367
+ rank: x.rank, roi: x.roi, score: x.score, ok: x.ok, box: x.box, origin: x.origin, inBand: x.inBand,
368
+ })),
369
+ bestGap: outcome.bestGap,
370
+ gap: outcome.gap,
371
+ withinGap: outcome.withinGap,
372
+ threshold: threshold ?? null,
373
+ verdict: outcome.verdict,
374
+ reason: outcome.reason,
375
+ exit: exitCode,
376
+ elapsedMs,
377
+ ...(scan ? { scan: { ...scanInfo(scan), step: scanStep(scan) } } : {}),
378
+ },
379
+ };
380
+ });
381
+ }
382
+ catch (e) {
383
+ return daemonFail(e);
384
+ }
385
+ },
386
+ };
387
+ export const ROI_COMPARE_COMMANDS = [roiCompareCommand];
388
+ //# sourceMappingURL=roi-compare.js.map