@deepblend/dsh-blender-contracts 0.1.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.
@@ -0,0 +1,738 @@
1
+ /**
2
+ * VisualIssue: the shared vocabulary for "what is wrong with this render", and the
3
+ * deterministic scorer that turns measurements into a number (SPEC §12.3).
4
+ *
5
+ * THE SPLIT THIS FILE EXISTS TO ENFORCE (decision D30)
6
+ * ----------------------------------------------------
7
+ * There are two different claims in a visual review and they must not be confused:
8
+ *
9
+ * the model JUDGES "the subject is off-centre and the label is in shadow"
10
+ * the machine MEASURES coverage 0.061, centroid (0.79, 0.42), p95 0.06
11
+ *
12
+ * Only the second is reproducible, and SPEC §12.3 requires "修改前后都保存评分和证据"
13
+ * — so the SCORE comes from measurements and the model's job is to name what it
14
+ * sees. A score the model reported about itself would make "自动修复后评分提高"
15
+ * unfalsifiable, which is the same as not testing it.
16
+ *
17
+ * The consequence, stated plainly because it is a real limitation: a model can see
18
+ * a genuine problem this scorer does not deduct for, and the loop will then be
19
+ * unable to "improve" it. That is the correct failure direction — a wrong score
20
+ * silently accepted is worse than a missed deduction reported as a lower score.
21
+ *
22
+ * Every finding carries its evidence inline. A deduction without the number that
23
+ * caused it is an opinion with a number bolted on.
24
+ *
25
+ * THRESHOLDS ARE BEHAVIORAL (the D1/D9/D25 rule)
26
+ * ----------------------------------------------
27
+ * The constants below are not from documentation and were not guessed: each is set
28
+ * from renders of the real fixtures, produced by `deepblend/tests/fixtures/*` and
29
+ * recorded in `deepblend/docs/milestone-status.md`. When a threshold moves, the
30
+ * fixture renders that justified it move with it.
31
+ *
32
+ * Owner: DeepBlend Studio — M2
33
+ */
34
+
35
+ /** Visual review document version. */
36
+ export const VISUAL_REVIEW_VERSION = 'deepblend.visual-review/v1'
37
+
38
+ /** Issue categories. A closed set: an unknown category cannot be scored. */
39
+ export const VISUAL_ISSUE_CATEGORIES = Object.freeze([
40
+ 'composition',
41
+ 'exposure',
42
+ 'occlusion',
43
+ ])
44
+
45
+ /**
46
+ * The tag that says "this entity is part of the subject's own body".
47
+ *
48
+ * WHY THIS TAG HAS TO EXIST
49
+ * -------------------------
50
+ * A real watch exposed the limit of measuring occlusion without it. The dial stands
51
+ * proud of the case, so the dial covers 56% of the case's silhouette — and every view
52
+ * reported the case as occluded by its own face, scoring a CORRECT watch 10 points
53
+ * below a watch whose dial was buried invisibly inside the case. Both readings were
54
+ * arithmetically right and the ranking between them was backwards.
55
+ *
56
+ * The measurement cannot tell a product's own face from a wall: the two are the same
57
+ * geometry. So the scene has to say it, exactly as `environment` says "this is
58
+ * backdrop, not a participant". With this tag:
59
+ *
60
+ * - a part in front of the subject is not an obstruction (it IS the subject);
61
+ * - a part that is itself invisible IS a defect, because a required component
62
+ * that cannot be seen is missing from the shot.
63
+ *
64
+ * `environment` and `subject-part` are the two ends of one axis — backdrop, body,
65
+ * and (untagged) everything that can genuinely stand in the way.
66
+ */
67
+ export const SUBJECT_PART_TAG = 'subject-part'
68
+
69
+ /**
70
+ * Whether a measurement entry belongs to the subject's own body.
71
+ *
72
+ * Read from the measurement, not from the spec: the renderer knows which entities the
73
+ * scene tagged and stamps each one, so the scorer never re-derives scene semantics
74
+ * from a document it was not given.
75
+ *
76
+ * @param {object} object - one object entry from a view's measurements.
77
+ * @returns {boolean}
78
+ */
79
+ export function isSubjectPart(object) {
80
+ return object?.part === true
81
+ }
82
+
83
+ /** Severity vocabulary, ordered by how much a finding is allowed to deduct. */
84
+ export const VISUAL_SEVERITIES = Object.freeze(['minor', 'major', 'critical'])
85
+
86
+ /** Deduction per severity, before per-rule weighting. */
87
+ const SEVERITY_POINTS = Object.freeze({ minor: 4, major: 10, critical: 18 })
88
+
89
+ /**
90
+ * A perfect score. Below this the automated loop has something to fix; at or above
91
+ * it the loop hands over instead of pretending there is work (see `shouldHandOver`).
92
+ */
93
+ export const VISUAL_PASS_SCORE = 90
94
+
95
+ /** Coverage band a subject should occupy to read as the subject of a shot. */
96
+ const COVERAGE_MIN = 0.04
97
+ const COVERAGE_MAX = 0.60
98
+ /** Distance from the frame centre, in normalized units, below which a subject is centred. */
99
+ const CENTERED_TOLERANCE = 0.10
100
+ /** How far off-centre is worth the worst composition deduction. */
101
+ const CENTERED_MAX = 0.40
102
+ /** Below this visible fraction of its own silhouette, a subject is materially hidden. */
103
+ const OCCLUSION_MIN_VISIBLE = 0.75
104
+ /**
105
+ * The same floor for an entity the scene declared part of the subject's own body.
106
+ *
107
+ * Lower on purpose. A product's parts are EXPECTED to overlap each other and the body
108
+ * they sit on — a bezel around a dial, a crown against a case — so 0.75 would fire on
109
+ * every correctly assembled object. What is not expected is a declared component that
110
+ * is substantially or entirely absent, which is the defect this catches.
111
+ */
112
+ const PART_VISIBLE_FLOOR = 0.5
113
+ /** Below this, a declared component is effectively not in the shot at all. */
114
+ const PART_VISIBLE_ABSENT = 0.15
115
+ /** Below this, it is effectively not in the shot. */
116
+ const OCCLUSION_CRITICAL_VISIBLE = 0.35
117
+ /** A declared part smaller than this on screen cannot be judged present or absent. */
118
+ const PART_SILHOUETTE_MIN_PIXELS = 200
119
+ /** Mean display luminance band. Below the floor, the frame reads dark; above the
120
+ * ceiling, it reads washed out. Both are measured luminance, not exposure stops. */
121
+ const LUMINANCE_MIN = 0.18
122
+ const LUMINANCE_MAX = 0.82
123
+ /** Fraction of pixels at the bottom of the range that makes a frame "clipped dark". */
124
+ const CLIPPED_DARK_MAX = 0.25
125
+ /** Fraction at the top that makes it "clipped bright". */
126
+ const CLIPPED_BRIGHT_MAX = 0.15
127
+
128
+ /**
129
+ * @typedef {object} VisualIssue
130
+ * @property {string} id - stable within one review.
131
+ * @property {'composition'|'exposure'|'occlusion'} category
132
+ * @property {'minor'|'major'|'critical'} severity
133
+ * @property {string} code - the specific finding, e.g. `SUBJECT_OFF_CENTER`.
134
+ * @property {string} viewId
135
+ * @property {string|null} objectId
136
+ * @property {string} evidence - prose a human can check against the sheet.
137
+ * @property {Record<string, number|string>} measurements - the numbers behind it.
138
+ * @property {number} confidence - 0..1.
139
+ * @property {object[]} suggestedOperations - ScenePatch operations (may be empty).
140
+ * @property {string} fingerprint - the repetition key (decision D31).
141
+ */
142
+
143
+ /**
144
+ * Quantise a measurement into a coarse bucket.
145
+ *
146
+ * This is what makes an issue fingerprint survive a partial fix: "luminance 0.31"
147
+ * and "luminance 0.34" are the same problem and must collide, while "0.31" and
148
+ * "0.72" must not. Precise values would let round 3 and round 4 describe one
149
+ * problem as two, and the loop would then run to its iteration cap without ever
150
+ * counting a repeat (decision D31).
151
+ *
152
+ * @param {number|null} value
153
+ * @param {number[]} edges - ascending bucket boundaries.
154
+ * @param {string[]} names - one name per bucket, length = edges.length + 1.
155
+ * @returns {string}
156
+ */
157
+ export function quantise(value, edges, names) {
158
+ if (typeof value !== 'number' || !Number.isFinite(value)) return names[names.length - 1]
159
+ for (let index = 0; index < edges.length; index += 1) {
160
+ if (value < edges[index]) return names[index]
161
+ }
162
+ return names[names.length - 1]
163
+ }
164
+
165
+ /** Bucket names for luminance, from dark to bright. */
166
+ export const LUMINANCE_BUCKETS = Object.freeze(['veryDark', 'dark', 'mid', 'bright', 'veryBright'])
167
+ const LUMINANCE_EDGES = Object.freeze([0.10, 0.25, 0.55, 0.85])
168
+
169
+ /** Bucket names for frame coverage, from tiny to filling. */
170
+ export const COVERAGE_BUCKETS = Object.freeze(['tiny', 'small', 'medium', 'large', 'huge'])
171
+ const COVERAGE_EDGES = Object.freeze([0.02, 0.06, 0.20, 0.55])
172
+
173
+ /** Bucket names for how far a subject sits from the frame centre. */
174
+ export const CENTERING_BUCKETS = Object.freeze(['centered', 'slightlyOff', 'off', 'farOff'])
175
+ const CENTERING_EDGES = Object.freeze([0.06, 0.15, 0.30])
176
+
177
+ /**
178
+ * The repetition key for one finding.
179
+ *
180
+ * @param {{ category: string, code: string, objectId?: string|null, buckets: string[] }} finding
181
+ * @returns {string}
182
+ */
183
+ export function issueFingerprint(finding) {
184
+ return [finding.category, finding.code, finding.objectId ?? 'scene', ...finding.buckets].join('|')
185
+ }
186
+
187
+ /**
188
+ * Score one view's measurements and report what is wrong with it.
189
+ *
190
+ * @param {object} view - one view entry from a render plan (`{ viewId, role, metrics }`).
191
+ * @param {object} [options]
192
+ * @param {string|null} [options.subjectId] - the object the shot is about; when
193
+ * omitted, every tracked object is scored.
194
+ * @returns {{ score: number, issues: VisualIssue[] }}
195
+ */
196
+ export function scoreView(view, options = {}) {
197
+ const metrics = view?.metrics
198
+ if (metrics === undefined || metrics === null) {
199
+ return { score: 100, issues: [] }
200
+ }
201
+ const viewId = typeof view.viewId === 'string' ? view.viewId : 'view'
202
+
203
+ const issues = []
204
+ let score = 100
205
+ const add = issue => {
206
+ issues.push(issue)
207
+ score -= SEVERITY_POINTS[issue.severity]
208
+ }
209
+
210
+ const objects = Array.isArray(metrics.objects) ? metrics.objects : []
211
+ const subjects = options.subjectId === undefined || options.subjectId === null
212
+ ? objects
213
+ : objects.filter(entry => entry.id === options.subjectId)
214
+
215
+ const exposure = assessExposure(metrics, viewId, subjects[0] ?? null)
216
+ if (exposure !== null) add(exposure)
217
+
218
+ for (const object of subjects) {
219
+ const composition = assessComposition(object)
220
+ if (composition !== null) add(composition)
221
+ const occlusion = assessOcclusion(object)
222
+ if (occlusion !== null) add(occlusion)
223
+ }
224
+
225
+ // Declared components are NOT judged per view. A watch dial faces the viewer in three
226
+ // of four standard views and is legitimately invisible in the top-down one, so a
227
+ // per-view rule reports geometry as a defect. `scoreReview` judges them across the
228
+ // whole review instead, where "invisible" can mean "invisible everywhere".
229
+
230
+ // One bad view should not be hidden by averaging it against good ones: the sheet
231
+ // is reviewed as a set, and the set is as good as its worst framing.
232
+ return { score: Math.max(0, Math.min(100, score)), issues }
233
+ }
234
+
235
+ /**
236
+ * Score a whole review from its per-view measurements.
237
+ *
238
+ * The reported score is the WORST view's score, not the mean. A mean would let one
239
+ * unusable angle hide behind three fine ones, and the automated loop optimises the
240
+ * number it is given — so the number has to be the one that represents whether the
241
+ * animation is deliverable.
242
+ *
243
+ * @param {object[]} views
244
+ * @param {object} [options]
245
+ * @returns {{ score: number, issues: VisualIssue[], perView: { viewId: string, score: number }[] }}
246
+ */
247
+ export function scoreReview(views, options = {}) {
248
+ const perView = []
249
+ const issues = []
250
+ let worst = 100
251
+ for (const view of views) {
252
+ const scored = scoreView(view, options)
253
+ perView.push({ viewId: view.viewId, score: scored.score })
254
+ issues.push(...scored.issues)
255
+ if (scored.score < worst) worst = scored.score
256
+ }
257
+
258
+ // ---- declared components, judged across the whole review ------------------
259
+ //
260
+ // THE GAP THIS CLOSES, in the words of the session that found it: a review "scored
261
+ // 100/100 while the render shows no face at all, because the scorer has no metric for
262
+ // a required component being completely hidden". The 36 mm dial sat entirely INSIDE
263
+ // the case, measured 0.0 visible pixels in every view, and nothing looked at it —
264
+ // because only the subject was judged, and the subject (the case) was framed fine.
265
+ //
266
+ // The bar is deliberately the weakest defensible one: a component has to be visible
267
+ // in AT LEAST ONE view. Anything stronger reports a part that merely faces away from
268
+ // one camera, and over-reporting is how a scorer stops being believed.
269
+ const best = new Map()
270
+ for (const view of views) {
271
+ for (const object of view.metrics?.objects ?? []) {
272
+ if (!isSubjectPart(object)) continue
273
+ if (object.id === options.subjectId) continue
274
+ const current = best.get(object.id)
275
+ if (current === undefined || numberOr(object.visibleFraction, 1) > current.visibleFraction) {
276
+ best.set(object.id, { object, visibleFraction: numberOr(object.visibleFraction, 1), viewId: view.viewId })
277
+ }
278
+ }
279
+ }
280
+
281
+ let reviewDeduction = 0
282
+ for (const [objectId, entry] of best) {
283
+ if (numberOr(entry.object.silhouettePixels, 0) < PART_SILHOUETTE_MIN_PIXELS) continue
284
+ if (entry.visibleFraction >= PART_VISIBLE_ABSENT) continue
285
+ const behind = Array.isArray(entry.object.occludedBy) ? entry.object.occludedBy : []
286
+ const culprit = behind.length > 0 ? `, behind "${behind[0].entityId}"` : ''
287
+ const issue = buildIssue({
288
+ category: 'occlusion',
289
+ code: 'SUBJECT_PART_HIDDEN',
290
+ severity: 'critical',
291
+ viewId: entry.viewId,
292
+ objectId,
293
+ evidence:
294
+ `"${objectId}" is declared part of the subject, and the most visible it gets in any of the ` +
295
+ `${views.length} views is ${format(entry.visibleFraction)}${culprit} — a required component of the ` +
296
+ 'product is not in the shot',
297
+ measurements: {
298
+ bestVisibleFraction: round(entry.visibleFraction),
299
+ viewsChecked: views.length,
300
+ silhouettePixels: numberOr(entry.object.silhouettePixels, 0),
301
+ ...behind.length > 0 ? { occludedBy: behind[0].entityId } : {},
302
+ },
303
+ buckets: [quantise(entry.visibleFraction, [PART_VISIBLE_ABSENT], ['absent', 'present'])],
304
+ })
305
+ issues.push(issue)
306
+ reviewDeduction += SEVERITY_POINTS[issue.severity]
307
+ }
308
+
309
+ return {
310
+ score: Math.max(0, Math.min(100, worst - reviewDeduction)),
311
+ issues,
312
+ perView,
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Exposure findings.
318
+ *
319
+ * JUDGED ON THE SUBJECT'S OWN PIXELS when the view measured them, and on the frame
320
+ * only as a fallback. The difference is not a refinement; it is the difference
321
+ * between the metric agreeing with the brief and fighting it.
322
+ *
323
+ * A product brief asks for a black background — the ordinary way to photograph a
324
+ * product. The frame's mean luminance then sits near zero however well the product is
325
+ * lit, so a frame-wide floor reports `FRAME_UNDEREXPOSED` (critical) on a correct
326
+ * shot. The repair loop accepts only patches that RAISE the score, so it would have
327
+ * answered that finding by lightening the background: "fixing" the scene away from
328
+ * what the brief asked for.
329
+ *
330
+ * @param {object} metrics
331
+ * @param {string} viewId
332
+ * @param {object|null} subject - the scored subject's own measurement, when there is one.
333
+ * @returns {VisualIssue|null}
334
+ */
335
+ function assessExposure(metrics, viewId, subject = null) {
336
+ const own = subject?.luminance
337
+ const measuredOn = own !== undefined && typeof own.mean === 'number' ? 'subject' : 'frame'
338
+ const luminance = measuredOn === 'subject' ? own : metrics.luminance
339
+ if (luminance === undefined || typeof luminance.mean !== 'number') return null
340
+
341
+ const bucket = quantise(luminance.mean, LUMINANCE_EDGES, LUMINANCE_BUCKETS)
342
+ const clippedDark = numberOr(luminance.clippedDarkFraction, 0)
343
+ const clippedBright = numberOr(luminance.clippedBrightFraction, 0)
344
+ // Phrased as a noun so the sentence reads the same for one subject and for a frame:
345
+ // "the subject's own pixels is blown out" was the first attempt.
346
+ // `measuredOn` in the measurements carries the WHY; the sentence stays a sentence.
347
+ const where = measuredOn === 'subject'
348
+ ? `exposure measured on the subject's own pixels ("${subject.id}")`
349
+ : 'exposure measured on the whole frame'
350
+
351
+ if (luminance.mean < LUMINANCE_MIN || clippedDark > CLIPPED_DARK_MAX) {
352
+ const severity = luminance.mean < LUMINANCE_MIN / 2 || clippedDark > CLIPPED_DARK_MAX * 2
353
+ ? 'critical'
354
+ : 'major'
355
+ return buildIssue({
356
+ category: 'exposure',
357
+ code: 'FRAME_UNDEREXPOSED',
358
+ severity,
359
+ viewId,
360
+ objectId: measuredOn === 'subject' ? subject.id : null,
361
+ // The sentence names the condition that ACTUALLY fired. The first version asserted
362
+ // that the mean had crossed the floor, which read as nonsense whenever the clipping
363
+ // rule was the one that triggered: "mean luminance 0.65 exceeds the 0.82 ceiling".
364
+ evidence:
365
+ `${where} is too dark: ` +
366
+ (luminance.mean < LUMINANCE_MIN
367
+ ? `mean display luminance ${format(luminance.mean)} is below the ${LUMINANCE_MIN} floor for a lit scene`
368
+ : `mean display luminance ${format(luminance.mean)} is acceptable, but ${format(clippedDark)} of ` +
369
+ `those pixels sit at the bottom of the range (limit ${CLIPPED_DARK_MAX})`),
370
+ measurements: {
371
+ measuredOn,
372
+ meanLuminance: round(luminance.mean),
373
+ clippedDarkFraction: round(clippedDark),
374
+ p05: round(numberOr(luminance.p05, 0)),
375
+ p95: round(numberOr(luminance.p95, 0)),
376
+ },
377
+ buckets: [bucket],
378
+ })
379
+ }
380
+
381
+ if (luminance.mean > LUMINANCE_MAX || clippedBright > CLIPPED_BRIGHT_MAX) {
382
+ const severity = luminance.mean > (LUMINANCE_MAX + 1) / 2 || clippedBright > CLIPPED_BRIGHT_MAX * 2
383
+ ? 'critical'
384
+ : 'major'
385
+ return buildIssue({
386
+ category: 'exposure',
387
+ code: 'FRAME_OVEREXPOSED',
388
+ severity,
389
+ viewId,
390
+ objectId: measuredOn === 'subject' ? subject.id : null,
391
+ evidence:
392
+ `${where} is blown out: ` +
393
+ (luminance.mean > LUMINANCE_MAX
394
+ ? `mean display luminance ${format(luminance.mean)} exceeds the ${LUMINANCE_MAX} ceiling`
395
+ : `mean display luminance ${format(luminance.mean)} is acceptable, but ${format(clippedBright)} of ` +
396
+ `those pixels are clipped at the top of the range (limit ${CLIPPED_BRIGHT_MAX})`) + '.',
397
+ measurements: {
398
+ measuredOn,
399
+ meanLuminance: round(luminance.mean),
400
+ clippedBrightFraction: round(clippedBright),
401
+ p05: round(numberOr(luminance.p05, 0)),
402
+ p95: round(numberOr(luminance.p95, 0)),
403
+ },
404
+ buckets: [bucket],
405
+ })
406
+ }
407
+
408
+ return null
409
+ }
410
+
411
+ /**
412
+ * Where the subject sits in the frame, and how big it is.
413
+ *
414
+ * @param {object} object - one object entry from the view's measurements.
415
+ * @returns {VisualIssue|null}
416
+ */
417
+ function assessComposition(object) {
418
+ if (object.visiblePixels === 0) return null // fully hidden is an occlusion finding
419
+ const centroid = object.centroid
420
+ if (!Array.isArray(centroid) || centroid.length !== 2) return null
421
+
422
+ const offsetX = centroid[0] - 0.5
423
+ const offsetY = centroid[1] - 0.5
424
+ const offset = Math.sqrt(offsetX * offsetX + offsetY * offsetY)
425
+ const centering = quantise(offset, CENTERING_EDGES, CENTERING_BUCKETS)
426
+
427
+ if (offset > CENTERED_TOLERANCE) {
428
+ const severity = offset > CENTERED_MAX ? 'critical' : offset > (CENTERED_TOLERANCE + CENTERED_MAX) / 2 ? 'major' : 'minor'
429
+ return buildIssue({
430
+ category: 'composition',
431
+ code: 'SUBJECT_OFF_CENTER',
432
+ severity,
433
+ viewId: object.viewId,
434
+ objectId: object.id,
435
+ evidence:
436
+ `"${object.id}" sits at (${format(centroid[0])}, ${format(centroid[1])}) of the frame, ` +
437
+ `${format(offset)} from the centre — outside the ${CENTERED_TOLERANCE} tolerance`,
438
+ measurements: {
439
+ centroidX: round(centroid[0]),
440
+ centroidY: round(centroid[1]),
441
+ centerOffset: round(offset),
442
+ frameCoverage: round(numberOr(object.frameCoverage, 0)),
443
+ },
444
+ buckets: [centering],
445
+ })
446
+ }
447
+
448
+ // COMPOSITION uses the SILHOUETTE coverage, not the visible coverage, and the
449
+ // distinction matters: an occluder that hides half the subject also halves the
450
+ // subject's visible share of the frame. Measured against visible pixels, a blocked
451
+ // subject would additionally be reported as "too small to read as the subject" —
452
+ // two findings for one cause, and the second one sends the reviewer to change the
453
+ // lens on a shot whose problem is a wall.
454
+ const coverage = numberOr(object.silhouetteCoverage, numberOr(object.frameCoverage, 0))
455
+ const coverageBucket = quantise(coverage, COVERAGE_EDGES, COVERAGE_BUCKETS)
456
+ if (coverage > 0 && coverage < COVERAGE_MIN) {
457
+ return buildIssue({
458
+ category: 'composition',
459
+ code: 'SUBJECT_TOO_SMALL',
460
+ severity: 'major',
461
+ viewId: object.viewId,
462
+ objectId: object.id,
463
+ evidence:
464
+ `"${object.id}" fills only ${format(coverage)} of the frame, below the ${COVERAGE_MIN} floor, ` +
465
+ `so it does not read as the subject of the shot`,
466
+ measurements: {
467
+ frameCoverage: round(coverage),
468
+ centroidX: round(centroid[0]),
469
+ centroidY: round(centroid[1]),
470
+ visiblePixels: object.visiblePixels,
471
+ },
472
+ buckets: [coverageBucket],
473
+ })
474
+ }
475
+
476
+ if (coverage > COVERAGE_MAX) {
477
+ return buildIssue({
478
+ category: 'composition',
479
+ code: 'SUBJECT_FILLS_FRAME',
480
+ severity: 'major',
481
+ viewId: object.viewId,
482
+ objectId: object.id,
483
+ evidence:
484
+ `"${object.id}" fills ${format(coverage)} of the frame, above the ${COVERAGE_MAX} ceiling, ` +
485
+ `so it is cropped rather than framed`,
486
+ measurements: { frameCoverage: round(coverage), visiblePixels: object.visiblePixels },
487
+ buckets: [coverageBucket],
488
+ })
489
+ }
490
+
491
+ return null
492
+ }
493
+
494
+ /**
495
+ * Whether another object is sitting in front of the subject.
496
+ *
497
+ * The measurement is the ratio of the subject's silhouette that survives to its
498
+ * silhouette with nothing in the way — see `deepblend_views.py`. It is the one
499
+ * occlusion reading in this system that is definitional rather than a brightness
500
+ * heuristic.
501
+ *
502
+ * @param {object} object
503
+ * @returns {VisualIssue|null}
504
+ */
505
+ function assessOcclusion(object) {
506
+ const visible = numberOr(object.visibleFraction, 1)
507
+ if (visible >= OCCLUSION_MIN_VISIBLE) return null
508
+ const silhouette = numberOr(object.silhouettePixels, 0)
509
+ // A handful of surviving pixels out of a handful is not occlusion, it is an
510
+ // object that is essentially absent from the shot — which the composition rules
511
+ // already report, and reporting both would double-charge one cause.
512
+ if (silhouette < 200) return null
513
+
514
+ const severity = visible < OCCLUSION_CRITICAL_VISIBLE ? 'critical' : 'major'
515
+ const covered = 1 - visible
516
+ const behind = Array.isArray(object.occludedBy) ? object.occludedBy : []
517
+ const culprit = behind.length > 0 ? `, mostly behind "${behind[0].entityId}"` : ''
518
+ // The remedy is stated when it is knowable, because the alternative is a reader
519
+ // concluding the score is wrong — which is exactly what happened on a real watch
520
+ // whose own face covers its case.
521
+ const hint = behind.length > 0
522
+ ? ` If "${behind[0].entityId}" is part of the same object rather than something standing in the way, ` +
523
+ `tag it "${SUBJECT_PART_TAG}" and it stops counting as an obstruction.`
524
+ : ''
525
+ return buildIssue({
526
+ category: 'occlusion',
527
+ code: 'SUBJECT_OCCLUDED',
528
+ severity,
529
+ viewId: object.viewId,
530
+ objectId: object.id,
531
+ evidence:
532
+ `only ${format(visible)} of "${object.id}"'s own silhouette survives in this view ` +
533
+ `(${format(covered)} is hidden)${culprit}, against a ${OCCLUSION_MIN_VISIBLE} visibility floor.${hint}`,
534
+ measurements: {
535
+ visibleFraction: round(visible),
536
+ occludedFraction: round(covered),
537
+ silhouettePixels: silhouette,
538
+ visiblePixels: numberOr(object.visiblePixels, 0),
539
+ ...behind.length > 0 ? { occludedBy: behind[0].entityId, occludedByFraction: behind[0].fraction } : {},
540
+ },
541
+ buckets: [
542
+ quantise(visible, [OCCLUSION_CRITICAL_VISIBLE, OCCLUSION_MIN_VISIBLE], ['hidden', 'partlyHidden', 'visible']),
543
+ ],
544
+ })
545
+ }
546
+
547
+ /**
548
+ * Assemble one issue with a stable id and fingerprint.
549
+ * @param {object} input
550
+ * @returns {VisualIssue}
551
+ */
552
+ function buildIssue(input) {
553
+ const measurements = {}
554
+ for (const [key, value] of Object.entries(input.measurements ?? {})) {
555
+ if (value !== undefined && value !== null) measurements[key] = value
556
+ }
557
+ const issue = {
558
+ category: input.category,
559
+ code: input.code,
560
+ severity: input.severity,
561
+ viewId: input.viewId ?? null,
562
+ objectId: input.objectId ?? null,
563
+ evidence: input.evidence,
564
+ measurements,
565
+ confidence: 1,
566
+ suggestedOperations: [],
567
+ }
568
+ issue.fingerprint = issueFingerprint({
569
+ category: issue.category,
570
+ code: issue.code,
571
+ objectId: issue.objectId,
572
+ buckets: input.buckets ?? [],
573
+ })
574
+ issue.id = `measured-${issue.fingerprint.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}`
575
+ return issue
576
+ }
577
+
578
+ /** Coerce a possibly-absent measurement into a number. */
579
+ function numberOr(value, fallback) {
580
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
581
+ }
582
+
583
+ /** Six decimals: comparable across rounds, short enough to read. */
584
+ function round(value) {
585
+ return typeof value === 'number' && Number.isFinite(value) ? Math.round(value * 1e6) / 1e6 : null
586
+ }
587
+
588
+ /** Fixed three-decimal formatting, so evidence text is stable across rounds. */
589
+ function format(value) {
590
+ const number = numberOr(value, 0)
591
+ return number.toFixed(3)
592
+ }
593
+
594
+ /**
595
+ * Record the findings of a state the loop has observed but not yet acted on.
596
+ *
597
+ * Identical bookkeeping to {@link updateFingerprintCounters} except that it seeds each
598
+ * counter at its CURRENT value instead of incrementing. The two are separate
599
+ * functions rather than one with a flag, because "this problem is present" and "a fix
600
+ * for this problem failed" are different facts and the whole point of the repeat rule
601
+ * is that only the second one means the loop is not working.
602
+ *
603
+ * @param {Map<string, number>} counters - mutated in place.
604
+ * @param {VisualIssue[]} issues
605
+ */
606
+ export function seedFingerprintCounters(counters, issues) {
607
+ const seen = new Set()
608
+ for (const issue of issues) {
609
+ if (typeof issue.fingerprint !== 'string') continue
610
+ seen.add(issue.fingerprint)
611
+ if (!counters.has(issue.fingerprint)) counters.set(issue.fingerprint, 0)
612
+ }
613
+ for (const fingerprint of [...counters.keys()]) {
614
+ if (!seen.has(fingerprint)) counters.delete(fingerprint)
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Validate a model-authored finding against the measurements it claims to describe.
620
+ *
621
+ * A vision model is asked what it sees; it answers in this shape. Everything it
622
+ * asserts is checked here, because the alternative is trusting prose from a system
623
+ * that cannot be asked to reproduce itself. A finding survives only when the view it
624
+ * names exists and its category is one of the closed set.
625
+ *
626
+ * @param {unknown} raw
627
+ * @param {{ viewIds: Set<string>, objectIds: Set<string> }} context
628
+ * @returns {{ accepted: object[], rejected: { finding: unknown, reason: string }[] }}
629
+ */
630
+ export function validateFindings(raw, context) {
631
+ const accepted = []
632
+ const rejected = []
633
+ const list = Array.isArray(raw) ? raw : []
634
+ if (!Array.isArray(raw)) {
635
+ rejected.push({ finding: raw, reason: 'findings must be an array' })
636
+ return { accepted, rejected }
637
+ }
638
+
639
+ for (const entry of list) {
640
+ if (entry === null || typeof entry !== 'object') {
641
+ rejected.push({ finding: entry, reason: 'a finding must be an object' })
642
+ continue
643
+ }
644
+ const category = entry.category
645
+ if (!VISUAL_ISSUE_CATEGORIES.includes(category)) {
646
+ rejected.push({ finding: entry, reason: `unknown category "${String(category)}"` })
647
+ continue
648
+ }
649
+ const viewId = entry.viewId
650
+ if (typeof viewId !== 'string' || !context.viewIds.has(viewId)) {
651
+ rejected.push({ finding: entry, reason: `unknown viewId "${String(viewId)}"` })
652
+ continue
653
+ }
654
+ const objectId = entry.objectId === undefined || entry.objectId === null ? null : String(entry.objectId)
655
+ if (objectId !== null && context.objectIds.size > 0 && !context.objectIds.has(objectId)) {
656
+ rejected.push({ finding: entry, reason: `unknown objectId "${objectId}"` })
657
+ continue
658
+ }
659
+ const evidence = typeof entry.evidence === 'string' ? entry.evidence.trim() : ''
660
+ if (evidence.length < 8) {
661
+ // Evidence is the whole reason a model finding is worth keeping: without it
662
+ // the review is an assertion, and an assertion cannot be audited later.
663
+ rejected.push({ finding: entry, reason: 'evidence is missing or too short to check' })
664
+ continue
665
+ }
666
+ const confidence = typeof entry.confidence === 'number' && entry.confidence >= 0 && entry.confidence <= 1
667
+ ? entry.confidence
668
+ : 0.5
669
+ accepted.push({
670
+ category,
671
+ code: typeof entry.code === 'string' && entry.code.length > 0 ? entry.code : `${String(category).toUpperCase()}_REPORTED`,
672
+ severity: VISUAL_SEVERITIES.includes(entry.severity) ? entry.severity : 'major',
673
+ viewId,
674
+ objectId,
675
+ evidence,
676
+ measurements: {},
677
+ confidence,
678
+ suggestedOperations: Array.isArray(entry.suggestedOperations) ? entry.suggestedOperations : [],
679
+ })
680
+ }
681
+ return { accepted, rejected }
682
+ }
683
+
684
+ /**
685
+ * Decide whether the automated loop may keep trying.
686
+ *
687
+ * Three ways to stop, and each one exists because the alternative is a loop that
688
+ * spends money to make things worse:
689
+ *
690
+ * - the score is already passing — there is no measured problem to improve;
691
+ * - the iteration cap is reached (SPEC §12.3 "达到最大迭代数后进入人工审查");
692
+ * - the same fingerprint has repeated `stopOnRepeatedIssueCount` times, which is
693
+ * the structural form of "同一问题两轮未改善则停止自动迭代".
694
+ *
695
+ * @param {object} input
696
+ * @param {number} input.score
697
+ * @param {number} input.iteration
698
+ * @param {number} input.maxIterations
699
+ * @param {Map<string, number>} input.fingerprints - fingerprint -> consecutive count
700
+ * @param {number} input.stopOnRepeatedIssueCount
701
+ * @returns {{ stop: boolean, reason: string|null }}
702
+ */
703
+ export function shouldHandOver(input) {
704
+ if (input.score >= VISUAL_PASS_SCORE) {
705
+ return { stop: true, reason: 'PASSING_SCORE' }
706
+ }
707
+ if (input.iteration >= input.maxIterations) {
708
+ return { stop: true, reason: 'MAX_ITERATIONS' }
709
+ }
710
+ for (const [fingerprint, count] of input.fingerprints) {
711
+ if (count >= input.stopOnRepeatedIssueCount) {
712
+ return { stop: true, reason: 'REPEATED_ISSUE', fingerprint }
713
+ }
714
+ }
715
+ return { stop: false, reason: null }
716
+ }
717
+
718
+ /**
719
+ * Update the consecutive-repeat counters for one round's findings.
720
+ *
721
+ * "Consecutive" is the load-bearing word: a finding that vanishes and comes back is
722
+ * a different situation from one that never went away, and only the second means
723
+ * the fix is not working.
724
+ *
725
+ * @param {Map<string, number>} counters - mutated in place.
726
+ * @param {VisualIssue[]} issues - this round's findings.
727
+ */
728
+ export function updateFingerprintCounters(counters, issues) {
729
+ const seen = new Set()
730
+ for (const issue of issues) {
731
+ if (typeof issue.fingerprint !== 'string') continue
732
+ seen.add(issue.fingerprint)
733
+ counters.set(issue.fingerprint, (counters.get(issue.fingerprint) ?? 0) + 1)
734
+ }
735
+ for (const fingerprint of [...counters.keys()]) {
736
+ if (!seen.has(fingerprint)) counters.delete(fingerprint)
737
+ }
738
+ }