@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.
- package/lib/asset-content.js +134 -0
- package/lib/bitmap-font.js +149 -0
- package/lib/canonical.js +98 -0
- package/lib/config-surface.js +88 -0
- package/lib/contact-sheet.js +233 -0
- package/lib/deployment-paths.js +153 -0
- package/lib/index.js +829 -0
- package/lib/json-schema.js +337 -0
- package/lib/png.js +452 -0
- package/lib/projections.js +209 -0
- package/lib/redact.js +53 -0
- package/lib/render-job.js +459 -0
- package/lib/scene-patch.js +947 -0
- package/lib/scene-spec.js +901 -0
- package/lib/schemas/job-result.schema.json +295 -0
- package/lib/schemas/scene-patch.schema.json +1307 -0
- package/lib/schemas/scene-spec.schema.json +791 -0
- package/lib/ui-api.js +695 -0
- package/lib/visual-composition.js +574 -0
- package/lib/visual-issue.js +738 -0
- package/lib/visual-loop.js +401 -0
- package/package.json +19 -0
|
@@ -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
|
+
}
|