editmamei 0.17.4 → 0.18.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 (69) hide show
  1. package/NOTICES.md +43 -0
  2. package/README.md +91 -8
  3. package/dist/api/extendscript/_helpers.js +8 -0
  4. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  5. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  6. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  7. package/dist/cli/clients/json-config.js +1 -1
  8. package/dist/core/server.js +92 -24
  9. package/dist/core/tool-groups.js +178 -0
  10. package/dist/core/tool-tiers.js +34 -48
  11. package/dist/delivery/bundle.js +8 -0
  12. package/dist/delivery/config.js +23 -0
  13. package/dist/delivery/provision.js +30 -2
  14. package/dist/delivery/store.js +40 -1
  15. package/dist/detection/detect-active-doc.js +84 -0
  16. package/dist/detection/detection-client.js +62 -0
  17. package/dist/detection/face-contour-geometry.js +137 -0
  18. package/dist/detection/face-detector.js +34 -0
  19. package/dist/detection/face-feature-geometry.js +180 -0
  20. package/dist/detection/geometry.js +35 -0
  21. package/dist/detection/landmark-detection-client.js +38 -0
  22. package/dist/detection/landmark-detector.js +63 -0
  23. package/dist/detection/landmark-spec.js +29 -0
  24. package/dist/detection/mesh-face.js +36 -0
  25. package/dist/detection/object-detector.js +135 -0
  26. package/dist/detection/runtime.js +46 -0
  27. package/dist/license/env-activation.js +46 -7
  28. package/dist/models/dfine-s-coco-quant.onnx +0 -0
  29. package/dist/models/ultraface-rfb-320.onnx +0 -0
  30. package/dist/modules/ce/index.js +23 -5
  31. package/dist/perception/facets.js +148 -0
  32. package/dist/perception/region-precompute.js +122 -0
  33. package/dist/perception/region-scorer.js +151 -0
  34. package/dist/perception/scene-model.js +156 -0
  35. package/dist/perception/select-recipes.js +493 -0
  36. package/dist/perception/sky-ground-flood.js +234 -0
  37. package/dist/perception/sky-mask-transfer.js +77 -0
  38. package/dist/platform/macos-executor.js +0 -1
  39. package/dist/platform/windows-executor.js +6 -1
  40. package/dist/preview/coordinate-grid.js +250 -0
  41. package/dist/skills/editmamei-skill.zip +0 -0
  42. package/dist/telemetry/events.js +2 -1
  43. package/dist/telemetry/sanitize.js +15 -1
  44. package/dist/tools/adjustment-tools.js +46 -51
  45. package/dist/tools/detection-tools.js +209 -0
  46. package/dist/tools/document-tools.js +41 -30
  47. package/dist/tools/filter-tools.js +616 -101
  48. package/dist/tools/guide-tools.js +186 -0
  49. package/dist/tools/history-tools.js +1 -39
  50. package/dist/tools/image-tools.js +88 -0
  51. package/dist/tools/inspect-tools.js +86 -0
  52. package/dist/tools/layer-properties-tools.js +195 -106
  53. package/dist/tools/layer-tools.js +55 -14
  54. package/dist/tools/layer-transform-tools.js +236 -73
  55. package/dist/tools/metadata-tools.js +2 -32
  56. package/dist/tools/object-tools.js +193 -0
  57. package/dist/tools/overview-tools.js +69 -43
  58. package/dist/tools/path-tools.js +274 -0
  59. package/dist/tools/portrait-tools.js +146 -0
  60. package/dist/tools/preview-tools.js +78 -3
  61. package/dist/tools/retouch-tools.js +45 -33
  62. package/dist/tools/scene-tools.js +401 -0
  63. package/dist/tools/selection-tools.js +766 -228
  64. package/dist/tools/text-on-object-tools.js +225 -0
  65. package/dist/tools/text-tools.js +47 -56
  66. package/dist/tools/transform-canvas-tools.js +138 -0
  67. package/dist/tools/vector-mask-tools.js +116 -0
  68. package/dist/version.js +1 -1
  69. package/package.json +4 -2
@@ -0,0 +1,493 @@
1
+ import { runScript } from '../utils/run-script.js';
2
+ import { jsNum } from '../utils/jsx.js';
3
+ import { getContextInfo, getSelectionInfo } from '../api/extendscript/_helpers.js';
4
+ import { computeSkyMaskFromJpeg } from './sky-ground-flood.js';
5
+ import { loadSkyMaskAsSelection } from './sky-mask-transfer.js';
6
+ import { Logger } from '../utils/logger.js';
7
+ const logger = new Logger('select-recipes');
8
+ import { buildRegionSignals, scoreRegion, } from './region-scorer.js';
9
+ export const FACE_FEATURE_TARGETS = [
10
+ 'face_skin',
11
+ 'face_eyes',
12
+ 'face_brows',
13
+ 'face_lips',
14
+ 'face_teeth',
15
+ 'face_nose',
16
+ 'face_under_eye',
17
+ 'face_cheeks',
18
+ ];
19
+ export const SELECT_REFERENCE_TARGETS = [
20
+ 'sky',
21
+ 'ground',
22
+ 'foliage',
23
+ 'subject',
24
+ 'face',
25
+ 'shadows',
26
+ 'highlights',
27
+ 'skin',
28
+ 'above_horizon',
29
+ ...FACE_FEATURE_TARGETS,
30
+ ];
31
+ export function isFaceFeatureTarget(t) {
32
+ return FACE_FEATURE_TARGETS.includes(t);
33
+ }
34
+ async function measure(connection) {
35
+ const script = `${getSelectionInfo}\nreturn getSelectionInfo();`;
36
+ return (await runScript(connection, script));
37
+ }
38
+ const INDOOR_LABELS = new Set([
39
+ 'bed',
40
+ 'couch',
41
+ 'sofa',
42
+ 'dining table',
43
+ 'diningtable',
44
+ 'toilet',
45
+ 'tv',
46
+ 'tvmonitor',
47
+ 'microwave',
48
+ 'oven',
49
+ 'refrigerator',
50
+ 'sink',
51
+ 'laptop',
52
+ ]);
53
+ function sceneCtx(model) {
54
+ let indoorObjectCount = 0;
55
+ for (const s of model.subjects)
56
+ if (INDOOR_LABELS.has(s.label))
57
+ indoorObjectCount++;
58
+ return {
59
+ docW: model.doc.width,
60
+ docH: model.doc.height,
61
+ horizonY: model.horizon.y,
62
+ horizonConfidence: model.horizon.confidence,
63
+ indoorObjectCount,
64
+ };
65
+ }
66
+ async function measureAndScore(connection, model, kind, composition) {
67
+ const info = await measure(connection);
68
+ const signals = buildRegionSignals({
69
+ has_selection: info.has_selection,
70
+ bounds: info.bounds ?? null,
71
+ area_percent: info.area_percent,
72
+ edge_complexity: info.edge_complexity,
73
+ bounds_fill_ratio: info.bounds_fill_ratio,
74
+ }, sceneCtx(model));
75
+ return { info, score: scoreRegion(kind, signals, composition) };
76
+ }
77
+ async function deselect(connection, snippet) {
78
+ try {
79
+ await runScript(connection, await snippet.build('deselect'));
80
+ }
81
+ catch {
82
+ }
83
+ }
84
+ async function gateResult(connection, snippet, target, method, info, score, detail) {
85
+ if (!score.passed) {
86
+ await deselect(connection, snippet);
87
+ }
88
+ return {
89
+ target,
90
+ method,
91
+ passed: score.passed,
92
+ confidence: score.confidence,
93
+ reasons: score.reasons,
94
+ selection_info: score.passed ? info : undefined,
95
+ detail,
96
+ };
97
+ }
98
+ async function bestOf(connection, snippet, model, kind, composition, target, candidates, detail = {}) {
99
+ let winner = null;
100
+ for (const c of candidates) {
101
+ try {
102
+ await c.produce();
103
+ }
104
+ catch {
105
+ continue;
106
+ }
107
+ const { score } = await measureAndScore(connection, model, kind, composition);
108
+ if (!winner || score.confidence > winner.confidence)
109
+ winner = { c, confidence: score.confidence };
110
+ }
111
+ if (!winner) {
112
+ await deselect(connection, snippet);
113
+ return {
114
+ target,
115
+ method: 'none',
116
+ passed: false,
117
+ confidence: 0,
118
+ reasons: ['no method produced a selection'],
119
+ detail,
120
+ };
121
+ }
122
+ await winner.c.produce();
123
+ const { info, score } = await measureAndScore(connection, model, kind, composition);
124
+ return gateResult(connection, snippet, target, winner.c.method, info, score, detail);
125
+ }
126
+ function thresholdSkyScript(level, seedX, seedY) {
127
+ return `
128
+ // __mcp_scene_thr__ region-select glue
129
+ ${getContextInfo}
130
+ function cTID(s){ return app.charIDToTypeID(s); }
131
+ function sTID(s){ return app.stringIDToTypeID(s); }
132
+ if (app.documents.length === 0) { throw new Error('No active document'); }
133
+ var doc = app.activeDocument;
134
+ var __mk = new ActionDescriptor();
135
+ var __ref = new ActionReference();
136
+ __ref.putClass(sTID('adjustmentLayer'));
137
+ __mk.putReference(cTID('null'), __ref);
138
+ var __type = new ActionDescriptor();
139
+ __type.putInteger(cTID('Lvl '), ${jsNum(level, 128)});
140
+ var __using = new ActionDescriptor();
141
+ __using.putObject(cTID('Type'), cTID('Thrs'), __type);
142
+ __mk.putObject(cTID('Usng'), sTID('adjustmentLayer'), __using);
143
+ app.executeAction(cTID('Mk '), __mk, DialogModes.NO);
144
+ var __adj = doc.activeLayer;
145
+ var __wand = new ActionDescriptor();
146
+ var __wref = new ActionReference();
147
+ __wref.putProperty(cTID('Chnl'), cTID('fsel'));
148
+ __wand.putReference(cTID('null'), __wref);
149
+ var __pt = new ActionDescriptor();
150
+ __pt.putUnitDouble(cTID('Hrzn'), cTID('#Pxl'), ${jsNum(seedX, 0)});
151
+ __pt.putUnitDouble(cTID('Vrtc'), cTID('#Pxl'), ${jsNum(seedY, 0)});
152
+ __wand.putObject(cTID('T '), cTID('Pnt '), __pt);
153
+ __wand.putInteger(cTID('Tlrn'), 32);
154
+ __wand.putBoolean(cTID('AntA'), true);
155
+ __wand.putBoolean(cTID('Mrgd'), true);
156
+ app.executeAction(cTID('setd'), __wand, DialogModes.NO);
157
+ try { __adj.remove(); } catch (eRm) {}
158
+ return { ok: true };
159
+ `;
160
+ }
161
+ function posterizeRegionScript(seedX, seedY) {
162
+ return `
163
+ // __mcp_scene_pst__ region-select glue
164
+ ${getContextInfo}
165
+ function cTID(s){ return app.charIDToTypeID(s); }
166
+ function sTID(s){ return app.stringIDToTypeID(s); }
167
+ if (app.documents.length === 0) { throw new Error('No active document'); }
168
+ var doc = app.activeDocument;
169
+ var __mk = new ActionDescriptor();
170
+ var __ref = new ActionReference();
171
+ __ref.putClass(sTID('adjustmentLayer'));
172
+ __mk.putReference(cTID('null'), __ref);
173
+ var __type = new ActionDescriptor();
174
+ __type.putInteger(cTID('Lvls'), 3);
175
+ var __using = new ActionDescriptor();
176
+ __using.putObject(cTID('Type'), cTID('Pstr'), __type);
177
+ __mk.putObject(cTID('Usng'), sTID('adjustmentLayer'), __using);
178
+ app.executeAction(cTID('Mk '), __mk, DialogModes.NO);
179
+ var __adj = doc.activeLayer;
180
+ var __wand = new ActionDescriptor();
181
+ var __wref = new ActionReference();
182
+ __wref.putProperty(cTID('Chnl'), cTID('fsel'));
183
+ __wand.putReference(cTID('null'), __wref);
184
+ var __pt = new ActionDescriptor();
185
+ __pt.putUnitDouble(cTID('Hrzn'), cTID('#Pxl'), ${jsNum(seedX, 0)});
186
+ __pt.putUnitDouble(cTID('Vrtc'), cTID('#Pxl'), ${jsNum(seedY, 0)});
187
+ __wand.putObject(cTID('T '), cTID('Pnt '), __pt);
188
+ __wand.putInteger(cTID('Tlrn'), 32);
189
+ __wand.putBoolean(cTID('AntA'), true);
190
+ __wand.putBoolean(cTID('Mrgd'), true);
191
+ app.executeAction(cTID('setd'), __wand, DialogModes.NO);
192
+ try { __adj.remove(); } catch (eRm) {}
193
+ return { ok: true };
194
+ `;
195
+ }
196
+ function skySeed(model) {
197
+ return {
198
+ x: Math.round(model.doc.width / 2),
199
+ y: Math.max(1, Math.round(model.doc.height * 0.06)),
200
+ };
201
+ }
202
+ export async function resolveSelection(connection, snippet, model, target, opts = {}) {
203
+ if (isFaceFeatureTarget(target)) {
204
+ return resolveFaceFeature(connection, snippet, target, target.slice('face_'.length), opts);
205
+ }
206
+ switch (target) {
207
+ case 'sky':
208
+ return resolveSky(connection, snippet, model, opts.composition, opts.skyCtx);
209
+ case 'ground':
210
+ return resolveGround(connection, snippet, model, opts.composition);
211
+ case 'foliage':
212
+ return resolveFoliage(connection, snippet, model, opts.composition);
213
+ case 'shadows':
214
+ return resolveLuminance(connection, snippet, model, 'shadows', opts.composition);
215
+ case 'highlights':
216
+ return resolveLuminance(connection, snippet, model, 'highlights', opts.composition);
217
+ case 'skin':
218
+ return resolveSkin(connection, snippet, model, opts.composition);
219
+ case 'subject':
220
+ return resolveSubject(connection, snippet, model, opts);
221
+ case 'face':
222
+ return resolveFace(connection, snippet, model, opts);
223
+ case 'above_horizon':
224
+ return resolveAboveHorizon(connection, snippet, model, opts.composition);
225
+ default:
226
+ throw new Error(`Unknown select-by-reference target: ${String(target)}`);
227
+ }
228
+ }
229
+ async function produceThresholdSky(connection, model) {
230
+ const skyRecipe = model.regions.find((r) => r.kind === 'sky');
231
+ const level = skyRecipe && skyRecipe.recipe.kind === 'threshold_white' ? skyRecipe.recipe.level : 128;
232
+ const seed = skySeed(model);
233
+ await runScript(connection, thresholdSkyScript(level, seed.x, seed.y));
234
+ return { level, seed };
235
+ }
236
+ async function resolveSky(connection, snippet, model, composition, skyCtx) {
237
+ if (skyCtx) {
238
+ try {
239
+ const { mask, width, height } = computeSkyMaskFromJpeg(skyCtx.exportBytes, skyCtx.boxes);
240
+ await loadSkyMaskAsSelection(connection, mask, width, height, skyCtx.docW, skyCtx.docH);
241
+ const { info, score } = await measureAndScore(connection, model, 'sky', composition);
242
+ if (score.passed) {
243
+ return gateResult(connection, snippet, 'sky', 'sky_ground_flood', info, score, {});
244
+ }
245
+ }
246
+ catch (e) {
247
+ logger.warn(`sky_ground_flood failed: ${e instanceof Error ? e.message : String(e)}`);
248
+ }
249
+ }
250
+ return bestOf(connection, snippet, model, 'sky', composition, 'sky', [
251
+ {
252
+ method: 'threshold_white',
253
+ produce: async () => {
254
+ await produceThresholdSky(connection, model);
255
+ },
256
+ },
257
+ {
258
+ method: 'sensei_select_sky',
259
+ produce: async () => {
260
+ await runScript(connection, await snippet.build('selectSky', { sampleAllLayers: true, selectionType: 'replace' }));
261
+ },
262
+ },
263
+ ]);
264
+ }
265
+ async function resolveGround(connection, snippet, model, composition) {
266
+ await produceThresholdSky(connection, model);
267
+ const sky = await measureAndScore(connection, model, 'sky', composition);
268
+ if (!sky.score.passed) {
269
+ await deselect(connection, snippet);
270
+ return {
271
+ target: 'ground',
272
+ method: 'invert_sky_minus_subjects',
273
+ passed: false,
274
+ confidence: 0,
275
+ reasons: ['no confident sky to invert', ...sky.score.reasons],
276
+ detail: { note: 'ground = invert(confident sky) − subjects; no confident sky found' },
277
+ };
278
+ }
279
+ await runScript(connection, await snippet.build('invertSelection'));
280
+ for (const s of model.subjects) {
281
+ const [l, t, r, b] = s.bbox;
282
+ await runScript(connection, await snippet.build('selectRectangle', {
283
+ left: l,
284
+ top: t,
285
+ right: r,
286
+ bottom: b,
287
+ featherPx: 0,
288
+ selectionType: 'subtract',
289
+ }));
290
+ }
291
+ const { info, score } = await measureAndScore(connection, model, 'ground', composition);
292
+ return gateResult(connection, snippet, 'ground', 'invert_sky_minus_subjects', info, score, {
293
+ subjects_subtracted: model.subjects.length,
294
+ });
295
+ }
296
+ async function resolveFoliage(connection, snippet, model, composition) {
297
+ const seedX = Math.round(model.doc.width / 2);
298
+ const seedY = Math.round(model.doc.height * 0.8);
299
+ await runScript(connection, posterizeRegionScript(seedX, seedY));
300
+ const { info, score } = await measureAndScore(connection, model, 'foliage', composition);
301
+ return gateResult(connection, snippet, 'foliage', 'posterize_region', info, score, {
302
+ seed: { x: seedX, y: seedY },
303
+ });
304
+ }
305
+ async function resolveLuminance(connection, snippet, model, band, composition) {
306
+ await runScript(connection, await snippet.build('selectLuminanceRange', {
307
+ mode: band,
308
+ fuzziness: 40,
309
+ selectionType: 'replace',
310
+ }));
311
+ const { info, score } = await measureAndScore(connection, model, band, composition);
312
+ return gateResult(connection, snippet, band, 'luminance_range', info, score, { band });
313
+ }
314
+ async function resolveSkin(connection, snippet, model, composition) {
315
+ await runScript(connection, await snippet.build('selectColorPreset', {
316
+ preset: 'skin_tones',
317
+ fuzziness: 40,
318
+ useFaces: false,
319
+ selectionType: 'replace',
320
+ }));
321
+ const person = model.subjects.find((s) => s.label === 'person');
322
+ const box = person?.bbox ?? model.faces.find((f) => f.is_primary)?.bbox;
323
+ let intersected = false;
324
+ if (box) {
325
+ const [l, t, r, b] = box;
326
+ await runScript(connection, await snippet.build('selectRectangle', {
327
+ left: l,
328
+ top: t,
329
+ right: r,
330
+ bottom: b,
331
+ featherPx: 0,
332
+ selectionType: 'intersect',
333
+ }));
334
+ intersected = true;
335
+ }
336
+ const { info, score } = await measureAndScore(connection, model, 'skin', composition);
337
+ return gateResult(connection, snippet, 'skin', 'color_range_skin_∩_box', info, score, {
338
+ intersected_with_box: intersected,
339
+ });
340
+ }
341
+ async function resolveSubject(connection, snippet, model, opts) {
342
+ const box = pickSubjectBox(model, opts);
343
+ if (!box) {
344
+ const seen = [...new Set(model.subjects.map((s) => s.label))].sort();
345
+ return {
346
+ target: 'subject',
347
+ method: 'none',
348
+ passed: false,
349
+ confidence: 0,
350
+ reasons: [`no subject${opts.label ? ` for label "${opts.label}"` : ''}`],
351
+ detail: { detected: seen },
352
+ };
353
+ }
354
+ if (opts.proRefine) {
355
+ const ok = await opts.proRefine({
356
+ kind: 'subject',
357
+ label: box.label,
358
+ instance: labelRelativeInstance(model, box, opts.instance),
359
+ box: box.bbox,
360
+ });
361
+ if (ok) {
362
+ const { info, score } = await measureAndScore(connection, model, 'subject', opts.composition);
363
+ return gateResult(connection, snippet, 'subject', 'pro_refine', info, score, {
364
+ label: box.label,
365
+ box: box.bbox,
366
+ });
367
+ }
368
+ }
369
+ const cx = Math.round((box.bbox[0] + box.bbox[2]) / 2);
370
+ const cy = Math.round((box.bbox[1] + box.bbox[3]) / 2);
371
+ await runScript(connection, posterizeRegionScript(cx, cy));
372
+ const { info, score } = await measureAndScore(connection, model, 'subject', opts.composition);
373
+ return gateResult(connection, snippet, 'subject', 'box_posterize_wand', info, score, {
374
+ label: box.label,
375
+ box: box.bbox,
376
+ seed: { x: cx, y: cy },
377
+ });
378
+ }
379
+ async function resolveFace(connection, snippet, model, opts) {
380
+ const face = model.faces.find((f) => f.is_primary) ?? model.faces[0];
381
+ if (!face) {
382
+ return {
383
+ target: 'face',
384
+ method: 'none',
385
+ passed: false,
386
+ confidence: 0,
387
+ reasons: ['no face detected'],
388
+ detail: {},
389
+ };
390
+ }
391
+ if (opts.proRefine) {
392
+ const ok = await opts.proRefine({ kind: 'face', box: face.bbox });
393
+ if (ok) {
394
+ const { info, score } = await measureAndScore(connection, model, 'face', opts.composition);
395
+ return gateResult(connection, snippet, 'face', 'pro_refine', info, score, { box: face.bbox });
396
+ }
397
+ }
398
+ const [l, t, r2, b] = face.bbox;
399
+ const feather = Math.max(4, Math.round(Math.min(r2 - l, b - t) * 0.08));
400
+ await runScript(connection, await snippet.build('selectEllipse', {
401
+ left: l,
402
+ top: t,
403
+ right: r2,
404
+ bottom: b,
405
+ featherPx: feather,
406
+ antiAlias: true,
407
+ selectionType: 'replace',
408
+ }));
409
+ const { info, score } = await measureAndScore(connection, model, 'face', opts.composition);
410
+ return gateResult(connection, snippet, 'face', 'face_box_ellipse', info, score, {
411
+ box: face.bbox,
412
+ feather,
413
+ });
414
+ }
415
+ async function resolveFaceFeature(connection, snippet, target, feature, opts) {
416
+ if (!opts.proRefine) {
417
+ return {
418
+ target,
419
+ method: 'none',
420
+ passed: false,
421
+ confidence: 0,
422
+ reasons: ['face-feature selection needs the Pro face mesh (not available in this edition)'],
423
+ detail: { feature },
424
+ };
425
+ }
426
+ const ok = await opts.proRefine({ kind: 'face_feature', feature });
427
+ if (!ok) {
428
+ await deselect(connection, snippet);
429
+ return {
430
+ target,
431
+ method: 'face_mesh',
432
+ passed: false,
433
+ confidence: 0,
434
+ reasons: ['no face mesh resolved, or the feature could not be built from the landmarks'],
435
+ detail: { feature },
436
+ };
437
+ }
438
+ const info = await measure(connection);
439
+ const passed = info.has_selection === true && (info.area_percent ?? 0) > 0;
440
+ if (!passed)
441
+ await deselect(connection, snippet);
442
+ return {
443
+ target,
444
+ method: 'face_mesh',
445
+ passed,
446
+ confidence: passed ? 1 : 0,
447
+ reasons: passed ? ['mesh polygon selection'] : ['mesh produced an empty selection'],
448
+ selection_info: passed ? info : undefined,
449
+ detail: { feature },
450
+ };
451
+ }
452
+ async function resolveAboveHorizon(connection, snippet, model, composition) {
453
+ const y = Math.max(1, Math.min(model.doc.height, model.horizon.y));
454
+ await runScript(connection, await snippet.build('selectRectangle', {
455
+ left: 0,
456
+ top: 0,
457
+ right: model.doc.width,
458
+ bottom: y,
459
+ featherPx: Math.max(2, Math.round(model.doc.height * 0.01)),
460
+ selectionType: 'replace',
461
+ }));
462
+ const { info, score } = await measureAndScore(connection, model, 'above_horizon', composition);
463
+ return gateResult(connection, snippet, 'above_horizon', 'rectangle_to_horizon', info, score, {
464
+ horizon_y: y,
465
+ });
466
+ }
467
+ function labelRelativeInstance(model, box, requested) {
468
+ if (typeof requested !== 'number')
469
+ return undefined;
470
+ const sameLabel = model.subjects
471
+ .filter((s) => s.label === box.label)
472
+ .sort((a, b) => a.bbox[0] - b.bbox[0]);
473
+ const idx = sameLabel.findIndex((s) => s.bbox === box.bbox);
474
+ return idx >= 0 ? idx : requested;
475
+ }
476
+ function pickSubjectBox(model, opts) {
477
+ let pool = model.subjects;
478
+ if (opts.label)
479
+ pool = pool.filter((s) => s.label === opts.label);
480
+ if (pool.length === 0)
481
+ return null;
482
+ if (typeof opts.instance === 'number') {
483
+ const byLeft = [...pool].sort((a, b) => a.bbox[0] - b.bbox[0]);
484
+ const s = byLeft[opts.instance];
485
+ return s ? { label: s.label, bbox: s.bbox } : null;
486
+ }
487
+ const main = pool.find((s) => s.is_main);
488
+ const chosen = main ?? [...pool].sort((a, b) => boxAreaOf(b.bbox) - boxAreaOf(a.bbox))[0];
489
+ return { label: chosen.label, bbox: chosen.bbox };
490
+ }
491
+ function boxAreaOf(b) {
492
+ return Math.max(0, b[2] - b[0]) * Math.max(0, b[3] - b[1]);
493
+ }