@event4u/agent-config 16.4.0 → 16.5.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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +40 -191
- package/README.md +1 -1
- package/dist/agent-src/commands/design.md +1 -1
- package/dist/agent-src/guidelines/design-antipatterns.md +1 -1
- package/dist/agent-src/rules/token-budget-discipline.md +2 -2
- package/dist/agent-src/skills/accessibility-auditor/data/aria-patterns.csv +2 -0
- package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
- package/dist/agent-src/skills/canvas-design/SKILL.md +4 -2
- package/dist/agent-src/skills/design-intelligence/references/integration-mapping.md +13 -10
- package/dist/agent-src/skills/fe-design/references/design-patterns.md +49 -2
- package/dist/agent-src/skills/frontend-render-security/SKILL.md +9 -0
- package/dist/agent-src/skills/iconography/SKILL.md +4 -2
- package/dist/agent-src/skills/image-provider-routing/SKILL.md +16 -5
- package/dist/agent-src/skills/logo-generation/SKILL.md +12 -8
- package/dist/agent-src/skills/svg-authoring/SKILL.md +318 -0
- package/dist/agent-src/skills/wireframe/SKILL.md +5 -2
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/commands/uiRender.js +201 -4
- package/dist/cli/commands/uiRenderAsset.js +1308 -0
- package/dist/cli/commands/uiRenderAssetCompare.js +752 -0
- package/dist/cli/commands/uiRenderAssetGrid.js +265 -0
- package/dist/cli/commands/uiRenderAssetGrounds.js +323 -0
- package/dist/cli/commands/uiRenderAssetIsolation.js +85 -0
- package/dist/cli/commands/uiRenderAssetTypes.js +145 -0
- package/dist/cli/main.js +47 -1
- package/dist/cli/registry.js +1 -1
- package/dist/cli-delegate/{chunk-MSUL5NYP.js → chunk-4DBG5K7M.js} +1 -1
- package/dist/cli-delegate/{chunk-AED2VQGM.js → chunk-TMWYUOK4.js} +10 -1
- package/dist/cli-delegate/cmd_conformance.js +395 -214
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +47 -22
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +6 -6
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +19 -15
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +13 -9
- package/dist/hooks/dispatch.js +1073 -808
- package/dist/hooks/dispatch.sha256 +1 -1
- package/dist/hooks/svg_rules.chunk.cjs +1626 -0
- package/dist/hooks/svg_rules.chunk.cjs.sha256 +1 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +931 -735
- package/dist/scripts/_lib/scan_scope.js +105 -0
- package/dist/scripts/_lib/svg_dom.js +284 -0
- package/dist/scripts/_lib/svg_geometry.js +477 -0
- package/dist/scripts/_lib/svg_motion.js +179 -0
- package/dist/scripts/_lib/svg_rules.js +1337 -0
- package/dist/scripts/_lib/svg_urls.js +67 -0
- package/dist/scripts/lint_svg_asset.js +324 -0
- package/docs/guidelines/design-antipatterns.md +1 -1
- package/package.json +4 -3
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/routing-coverage-seed.json +9 -2
- package/src/scripts/_lib/svg_dom.ts +318 -0
- package/src/scripts/_lib/svg_geometry.ts +480 -0
- package/src/scripts/_lib/svg_motion.ts +208 -0
- package/src/scripts/_lib/svg_rules.ts +1453 -0
- package/src/scripts/_lib/svg_urls.ts +72 -0
- package/src/scripts/_lib/verification_command.ts +59 -0
- package/src/scripts/_lib/verify_clause.ts +27 -17
- package/src/scripts/hooks/block_speaking_inbox_dir.ts +16 -1
- package/src/scripts/hooks/design_pass_hook.ts +157 -3
- package/src/scripts/hooks/svg_obligation.ts +497 -0
- package/src/scripts/hooks/svg_rules_loader.ts +133 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +5 -3
- package/src/scripts/lint_consolidation_lineage.ts +6 -3
- package/src/scripts/lint_design_quality.ts +60 -12
- package/src/scripts/lint_motion_authority_drift.ts +27 -0
- package/src/scripts/lint_svg_asset.ts +365 -0
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uiRenderAssetCompare — step 2.6 of `road-to-svg-with-a-witness`.
|
|
3
|
+
*
|
|
4
|
+
* One comparison of two renders of the SAME frame: before and after an edit, or
|
|
5
|
+
* a source raster against its vector.
|
|
6
|
+
*
|
|
7
|
+
* Three things this deliberately does not do. It does not decide a surgical
|
|
8
|
+
* edit — `lint_svg_asset --baseline` does, on attributes, because anti-aliasing
|
|
9
|
+
* and font metrics would make a pixel verdict a per-machine verdict. It does
|
|
10
|
+
* not guess a registration for a raster: without a declared one the reading is
|
|
11
|
+
* `unverified`, never the best of several guesses. And it does not call a
|
|
12
|
+
* font-caused delta a failure — a delta whose only cause is a different
|
|
13
|
+
* resolved family reads `degraded`, with BOTH families named.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import { parseElements, readViewBox } from '../../scripts/lint_svg_asset.js';
|
|
17
|
+
import { confinePage } from './uiRenderAssetIsolation.js';
|
|
18
|
+
import { SAMPLE_RES, } from './uiRenderAssetTypes.js';
|
|
19
|
+
/**
|
|
20
|
+
* `--registration x,y,w,h[@scale]` — the source crop's own pixel frame (S9).
|
|
21
|
+
* An unparseable spec returns `null`, which leaves the raster reading
|
|
22
|
+
* `unverified` rather than letting the comparison guess an offset.
|
|
23
|
+
*
|
|
24
|
+
* Finding 2.4 of `road-to-svg-rules-in-the-dispatch-path`: `scale` was parsed
|
|
25
|
+
* and validated here and then read by nothing, so `@2` was a silent no-op. It
|
|
26
|
+
* now reaches the comparison, which multiplies the crop rect by it — the
|
|
27
|
+
* operator states the crop in logical units and `@2` says the raster is a 2x
|
|
28
|
+
* capture of them.
|
|
29
|
+
*
|
|
30
|
+
* `@0` and `@-1` still return `null`, and that is deliberate rather than
|
|
31
|
+
* leftover: a zero-scale crop has no pixels, so there is nothing to compare,
|
|
32
|
+
* and reporting the registration as absent is the same verdict a reader would
|
|
33
|
+
* reach from an empty crop. The caller prints the spec it could not parse, so
|
|
34
|
+
* "absent" is not the only thing the operator sees.
|
|
35
|
+
*/
|
|
36
|
+
export function parseRegistration(spec) {
|
|
37
|
+
if (!spec)
|
|
38
|
+
return null;
|
|
39
|
+
const [frame, scaleRaw] = spec.split('@');
|
|
40
|
+
const parts = (frame ?? '').split(',').map((n) => Number(n.trim()));
|
|
41
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n)))
|
|
42
|
+
return null;
|
|
43
|
+
const scale = scaleRaw === undefined ? 1 : Number(scaleRaw);
|
|
44
|
+
if (!Number.isFinite(scale) || scale <= 0)
|
|
45
|
+
return null;
|
|
46
|
+
// The CROP has to be a crop. Finding 7 of the fourth completion review:
|
|
47
|
+
// `scale <= 0` was refused with a reason — a zero or negative scale is not
|
|
48
|
+
// a frame — and the same reason applies to the rectangle, which was never
|
|
49
|
+
// checked. `0,0,0,0` and `10,10,-5,-5` were both accepted, and a crop with
|
|
50
|
+
// no area registers a subject that is not there.
|
|
51
|
+
if (parts[2] <= 0 || parts[3] <= 0)
|
|
52
|
+
return null;
|
|
53
|
+
return {
|
|
54
|
+
x: parts[0],
|
|
55
|
+
y: parts[1],
|
|
56
|
+
width: parts[2],
|
|
57
|
+
height: parts[3],
|
|
58
|
+
scale,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The crop rect in the raster's OWN pixels.
|
|
63
|
+
*
|
|
64
|
+
* Finding 2.4 of `road-to-svg-rules-in-the-dispatch-path`: `Registration.scale`
|
|
65
|
+
* was parsed by the CLI, validated, recorded in the manifest and then read by
|
|
66
|
+
* nothing — `@2` was a silent no-op. The operator states the region in logical
|
|
67
|
+
* units; `@2` says the raster is a 2x capture of them, so the rect in image
|
|
68
|
+
* pixels is the region multiplied through.
|
|
69
|
+
*
|
|
70
|
+
* Its own exported function rather than four expressions inside a
|
|
71
|
+
* `page.evaluate` argument, because the defect was exactly that those four
|
|
72
|
+
* expressions were unreachable from any test that does not drive a browser.
|
|
73
|
+
*/
|
|
74
|
+
export function cropRect(registration) {
|
|
75
|
+
if (!registration)
|
|
76
|
+
return null;
|
|
77
|
+
const s = registration.scale;
|
|
78
|
+
return {
|
|
79
|
+
x: registration.x * s,
|
|
80
|
+
y: registration.y * s,
|
|
81
|
+
width: registration.width * s,
|
|
82
|
+
height: registration.height * s,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export async function sampleSource(context, sourceFile, vb, registration, points, blocked) {
|
|
86
|
+
if (points.length === 0)
|
|
87
|
+
return [];
|
|
88
|
+
let buf;
|
|
89
|
+
try {
|
|
90
|
+
buf = fs.readFileSync(sourceFile);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const mime = /\.png$/i.test(sourceFile)
|
|
96
|
+
? 'image/png'
|
|
97
|
+
: /\.webp$/i.test(sourceFile)
|
|
98
|
+
? 'image/webp'
|
|
99
|
+
: /\.svg$/i.test(sourceFile)
|
|
100
|
+
? 'image/svg+xml'
|
|
101
|
+
: 'image/jpeg';
|
|
102
|
+
const crop = cropRect(registration);
|
|
103
|
+
if (!crop)
|
|
104
|
+
return null;
|
|
105
|
+
const page = await context.newPage();
|
|
106
|
+
await confinePage(page, blocked, []);
|
|
107
|
+
try {
|
|
108
|
+
await page.setContent('<!doctype html><body></body>');
|
|
109
|
+
return await page.evaluate(async (args) => {
|
|
110
|
+
const img = new Image();
|
|
111
|
+
img.src = args.url;
|
|
112
|
+
try {
|
|
113
|
+
await img.decode();
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const w = Math.max(1, Math.round(args.crop.width));
|
|
119
|
+
const h = Math.max(1, Math.round(args.crop.height));
|
|
120
|
+
const canvas = document.createElement('canvas');
|
|
121
|
+
canvas.width = w;
|
|
122
|
+
canvas.height = h;
|
|
123
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
124
|
+
if (!ctx)
|
|
125
|
+
return null;
|
|
126
|
+
ctx.drawImage(img, args.crop.x, args.crop.y, args.crop.width, args.crop.height, 0, 0, w, h);
|
|
127
|
+
const data = ctx.getImageData(0, 0, w, h).data;
|
|
128
|
+
const at = (px, py) => {
|
|
129
|
+
const x = Math.min(w - 1, Math.max(0, Math.round(px)));
|
|
130
|
+
const y = Math.min(h - 1, Math.max(0, Math.round(py)));
|
|
131
|
+
const i = (y * w + x) * 4;
|
|
132
|
+
return [data[i], data[i + 1], data[i + 2]];
|
|
133
|
+
};
|
|
134
|
+
const background = at(0, 0);
|
|
135
|
+
const near = (a, b) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) <= 24;
|
|
136
|
+
return args.points.map((p) => {
|
|
137
|
+
// User units → the crop's own pixels, which is the same
|
|
138
|
+
// mapping the comparison stretches the vector through.
|
|
139
|
+
const rgb = at(((p.x - args.vb.x) / args.vb.w) * w, ((p.y - args.vb.y) / args.vb.h) * h);
|
|
140
|
+
return { isBackground: near(rgb, background), rgb };
|
|
141
|
+
});
|
|
142
|
+
}, { url: `data:${mime};base64,${buf.toString('base64')}`, crop, vb, points });
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
await page.close();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* One comparison of two renders of the SAME frame — before and after an edit,
|
|
150
|
+
* or a source raster against its vector.
|
|
151
|
+
*
|
|
152
|
+
* Three things this deliberately does not do. It does not decide a surgical
|
|
153
|
+
* edit (`lint_svg_asset --baseline` does, on attributes, because anti-aliasing
|
|
154
|
+
* and font metrics would make a pixel verdict a per-machine verdict). It does
|
|
155
|
+
* not guess a registration for a raster — without a declared one the reading is
|
|
156
|
+
* `unverified`, never the best of several guesses. And it does not call a
|
|
157
|
+
* font-caused delta a failure: a delta whose only cause is a different resolved
|
|
158
|
+
* family reads `degraded`, with BOTH families named.
|
|
159
|
+
*/
|
|
160
|
+
export async function compareGeometry(context, spec) {
|
|
161
|
+
const baselineContent = fs.readFileSync(spec.baseline, 'utf8');
|
|
162
|
+
const elements = parseElements(baselineContent);
|
|
163
|
+
const vb = readViewBox(elements.find((el) => el.name === 'svg' && !el.closing)) ?? { x: 0, y: 0, w: 100, h: 100 };
|
|
164
|
+
const frame = sampleFrame(vb, SAMPLE_RES);
|
|
165
|
+
const unitsPerSample = 1 / frame.scale;
|
|
166
|
+
const base = {
|
|
167
|
+
verdict: 'unverified',
|
|
168
|
+
reason: '',
|
|
169
|
+
asset_class: spec.assetClass,
|
|
170
|
+
tolerance: spec.tolerance,
|
|
171
|
+
tolerance_source: spec.toleranceSource,
|
|
172
|
+
sample_resolution: SAMPLE_RES,
|
|
173
|
+
anti_alias_band_units: unitsPerSample,
|
|
174
|
+
silhouette_iou: null,
|
|
175
|
+
max_boundary_distance_units: null,
|
|
176
|
+
sub_pixel_offset: null,
|
|
177
|
+
texts: [],
|
|
178
|
+
font_only_delta: false,
|
|
179
|
+
raster: null,
|
|
180
|
+
};
|
|
181
|
+
const candidateIsRaster = /\.(png|jpe?g|webp|gif|bmp|avif)$/i.test(spec.candidate);
|
|
182
|
+
if (candidateIsRaster && !spec.registration) {
|
|
183
|
+
return {
|
|
184
|
+
...base,
|
|
185
|
+
reason: 'a raster source needs a declared registration (the source crop\'s own pixel frame, S9); ' +
|
|
186
|
+
'without one the reading is unverified rather than the best of several guesses',
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const page = await context.newPage();
|
|
190
|
+
await page.setViewportSize({ width: SAMPLE_RES, height: SAMPLE_RES });
|
|
191
|
+
await confinePage(page, spec.blocked, []);
|
|
192
|
+
try {
|
|
193
|
+
await page.goto('about:blank');
|
|
194
|
+
const baselineMask = await rasterise(page, spec.baseline, vb, null);
|
|
195
|
+
const candidateMask = await rasterise(page, spec.candidate, vb, spec.registration ?? null);
|
|
196
|
+
if (!baselineMask || !candidateMask) {
|
|
197
|
+
return { ...base, reason: 'one of the two inputs could not be decoded in the renderer' };
|
|
198
|
+
}
|
|
199
|
+
// Step 2.1: against a REGISTERED RASTER the comparison judges the
|
|
200
|
+
// SUBJECT, not everything the crop happened to contain. The subject is
|
|
201
|
+
// the baseline's own silhouette widened by the anti-alias band the
|
|
202
|
+
// comparison already computes — not by a number chosen to pass a
|
|
203
|
+
// fixture. Foreign content and a frame-edge cut are reported as their
|
|
204
|
+
// own readings below rather than folded into IoU, where they made the
|
|
205
|
+
// witness fail a trace its owner had accepted.
|
|
206
|
+
const judgeSubjectOnly = candidateIsRaster && spec.registration !== undefined;
|
|
207
|
+
// The subject for JUDGING is the baseline's silhouette exactly, with no
|
|
208
|
+
// dilation. A dilated mask would let a one-sample ring of whatever
|
|
209
|
+
// surrounds the subject — a glow, a shadow — into the union and cost
|
|
210
|
+
// the IoU about four points on a small mark, which is a tax on being
|
|
211
|
+
// photographed next to something rather than a reading about the
|
|
212
|
+
// conversion. What the restricted IoU then measures is how much of the
|
|
213
|
+
// subject the candidate actually paints; a candidate that misses part
|
|
214
|
+
// of it, or is shifted, still fails.
|
|
215
|
+
//
|
|
216
|
+
// The subject for FOREIGN CONTENT is dilated by the anti-alias band
|
|
217
|
+
// the comparison already computes, so the rasteriser's own edge smear
|
|
218
|
+
// is not reported as a neighbouring mark. Two masks, because the two
|
|
219
|
+
// questions have different right answers at the boundary.
|
|
220
|
+
const subjectForJudging = judgeSubjectOnly ? baselineMask.painted : null;
|
|
221
|
+
const subjectForForeign = judgeSubjectOnly
|
|
222
|
+
? // ONE SAMPLE, and the units are the point. `unitsPerSample` is
|
|
223
|
+
// user units per sample — `dilate`'s own doc says its radius is
|
|
224
|
+
// in SAMPLES, so passing it read a units value as a sample count
|
|
225
|
+
// (finding 5). It happened to equal 1 only while
|
|
226
|
+
// `frame.scale >= 1`; on a coarse frame — a large viewBox against
|
|
227
|
+
// a small raster — it dilated by several samples and
|
|
228
|
+
// `foreign_content_share` under-reported exactly where the
|
|
229
|
+
// sampling is least precise. The anti-alias band IS one sample by
|
|
230
|
+
// construction, whatever that sample is worth in user units.
|
|
231
|
+
dilate(baselineMask.painted, frame.w, frame.h, 1)
|
|
232
|
+
: null;
|
|
233
|
+
// ALIGNMENT FIRST, ON THE UNCLIPPED CANDIDATE — finding 1 of this
|
|
234
|
+
// change's second completion review, and it is a proof rather than an
|
|
235
|
+
// observation. Clipping the candidate to the baseline's silhouette
|
|
236
|
+
// makes it a SUBSET of the baseline, and for a subset the IoU at zero
|
|
237
|
+
// shift is |candidate| / |baseline|, which no shift can beat: shifting
|
|
238
|
+
// can only move part of the subset off the baseline (the intersection
|
|
239
|
+
// shrinks) and off its own footprint (the union grows). `bestAlignment`
|
|
240
|
+
// replaces its (0,0) seed only on a STRICTLY greater value, so it
|
|
241
|
+
// returned dx = dy = 0 for every registered raster ever measured, and
|
|
242
|
+
// `sub_pixel_offset` was a constant reported as a reading. That is the
|
|
243
|
+
// defect class this branch exists to fix, reintroduced by the clip that
|
|
244
|
+
// fixed a different one.
|
|
245
|
+
//
|
|
246
|
+
// So the offset is read from the candidate as rendered — registration
|
|
247
|
+
// has already cropped the source to the subject, so this is the mark
|
|
248
|
+
// against the mark — and the clip is applied to the ALIGNED candidate,
|
|
249
|
+
// where it still does its own job of keeping a neighbour out of the IoU.
|
|
250
|
+
const { dx, dy } = bestAlignment(baselineMask.painted, candidateMask.painted, frame.w, frame.h);
|
|
251
|
+
const aligned = shift(candidateMask.painted, frame.w, frame.h, dx, dy);
|
|
252
|
+
const candidateForJudging = subjectForJudging === null ? aligned : intersect(aligned, subjectForJudging);
|
|
253
|
+
const iou = iouOf(baselineMask.painted, candidateForJudging);
|
|
254
|
+
const maxDistance = boundaryDistance(baselineMask.painted, candidateForJudging, frame.w, frame.h) * unitsPerSample;
|
|
255
|
+
const foreign = subjectForForeign === null ? null : shareOutside(candidateMask.painted, subjectForForeign);
|
|
256
|
+
const cutSides = judgeSubjectOnly ? touchesFrameEdge(candidateMask.painted, frame.w, frame.h) : [];
|
|
257
|
+
// Text is compared by box and resolved family only.
|
|
258
|
+
const baseTexts = await readTextBoxes(page, spec.baseline);
|
|
259
|
+
const candTexts = candidateIsRaster ? [] : await readTextBoxes(page, spec.candidate);
|
|
260
|
+
const texts = [];
|
|
261
|
+
for (let i = 0; i < Math.min(baseTexts.length, candTexts.length); i++) {
|
|
262
|
+
const a = baseTexts[i];
|
|
263
|
+
const b = candTexts[i];
|
|
264
|
+
if (!a || !b)
|
|
265
|
+
continue;
|
|
266
|
+
texts.push({
|
|
267
|
+
index: i,
|
|
268
|
+
baseline_family: a.family,
|
|
269
|
+
candidate_family: b.family,
|
|
270
|
+
baseline_box: a.box,
|
|
271
|
+
candidate_box: b.box,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
const familyDiffers = texts.some((t) => t.baseline_family !== t.candidate_family);
|
|
275
|
+
// Unshifted on purpose: the alignment search above is driven by the very
|
|
276
|
+
// glyphs under test, so asking whether the delta is text-shaped must not
|
|
277
|
+
// consult an offset the delta itself chose.
|
|
278
|
+
const fontOnly = familyDiffers &&
|
|
279
|
+
disagreementIsInsideTextBoxes(baselineMask.painted, candidateMask.painted, frame, vb, texts);
|
|
280
|
+
let raster = null;
|
|
281
|
+
if (candidateIsRaster && spec.registration) {
|
|
282
|
+
// `whole` stays first, always — it is the reading every consumer
|
|
283
|
+
// already reads, and a per-group breakdown is added beside it
|
|
284
|
+
// rather than instead of it (step 2.2).
|
|
285
|
+
const derived = spec.regions ?? (await readTopLevelGroupBoxes(page, spec.baseline));
|
|
286
|
+
const regions = [
|
|
287
|
+
{ id: 'whole', box: { x: vb.x, y: vb.y, w: vb.w, h: vb.h } },
|
|
288
|
+
...derived.filter((r) => r.id !== 'whole'),
|
|
289
|
+
];
|
|
290
|
+
const readings = regions.map((region) => rasterRegionReading(baselineMask, candidateMask, frame, vb, region));
|
|
291
|
+
const worst = readings.reduce((acc, r) => (r.mean_colour_error > acc.mean_colour_error ? r : acc), readings[0] ?? { region: 'whole', silhouette_iou: 0, mean_colour_error: 0, share_off_by_40: 0 });
|
|
292
|
+
raster = {
|
|
293
|
+
registration: spec.registration,
|
|
294
|
+
regions: readings,
|
|
295
|
+
worst_region: worst.region,
|
|
296
|
+
foreign_content_share: foreign?.share ?? 0,
|
|
297
|
+
frame_edge_contact: cutSides,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
const withinIou = iou >= spec.tolerance.silhouette_iou_min;
|
|
301
|
+
const withinBoundary = maxDistance <= spec.tolerance.max_boundary_distance_units;
|
|
302
|
+
const verdict = fontOnly
|
|
303
|
+
? 'degraded'
|
|
304
|
+
: withinIou && withinBoundary
|
|
305
|
+
? 'pass'
|
|
306
|
+
: 'fail';
|
|
307
|
+
const reason = fontOnly
|
|
308
|
+
? `the only geometric delta lies inside <text>, and the resolved families differ: ` +
|
|
309
|
+
`"${texts.map((t) => t.baseline_family).join(', ')}" against "${texts.map((t) => t.candidate_family).join(', ')}" — ` +
|
|
310
|
+
'a renderer delta, never a fail'
|
|
311
|
+
: verdict === 'pass'
|
|
312
|
+
? `silhouette IoU ${iou.toFixed(4)} and boundary ${maxDistance.toFixed(2)} units are inside the ${spec.assetClass} tolerance`
|
|
313
|
+
: `silhouette IoU ${iou.toFixed(4)} (min ${spec.tolerance.silhouette_iou_min}) / boundary ` +
|
|
314
|
+
`${maxDistance.toFixed(2)} units (max ${spec.tolerance.max_boundary_distance_units})`;
|
|
315
|
+
return {
|
|
316
|
+
...base,
|
|
317
|
+
verdict,
|
|
318
|
+
reason,
|
|
319
|
+
silhouette_iou: iou,
|
|
320
|
+
max_boundary_distance_units: maxDistance,
|
|
321
|
+
sub_pixel_offset: { dx_units: dx * unitsPerSample, dy_units: dy * unitsPerSample },
|
|
322
|
+
texts,
|
|
323
|
+
font_only_delta: fontOnly,
|
|
324
|
+
raster,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
await page.close();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* The subject's own neighbourhood — a dilation of the baseline's silhouette.
|
|
333
|
+
*
|
|
334
|
+
* Step 2.1 of `road-to-svg-from-a-live-session`. When the candidate is a
|
|
335
|
+
* REGISTERED RASTER, everything inside the frame is in the comparison:
|
|
336
|
+
* neighbouring marks the crop happened to include, a soft glow around the
|
|
337
|
+
* subject, a drop shadow. The background mask is taken from the corner pixel,
|
|
338
|
+
* so all of it counts as painted and all of it lands in IoU and in the
|
|
339
|
+
* boundary distance. Measured consequence: the witness failed a trace the
|
|
340
|
+
* owner had accepted, and ranked the rejected trace above the accepted
|
|
341
|
+
* construction on every number.
|
|
342
|
+
*
|
|
343
|
+
* `radius` is in SAMPLES, and the caller passes the anti-alias band the
|
|
344
|
+
* comparison already computes — so this widens by exactly the amount the
|
|
345
|
+
* rasteriser can smear an edge and not by a number chosen to make a fixture
|
|
346
|
+
* pass.
|
|
347
|
+
*/
|
|
348
|
+
function dilate(mask, w, h, radius) {
|
|
349
|
+
if (radius <= 0)
|
|
350
|
+
return mask;
|
|
351
|
+
const r = Math.ceil(radius);
|
|
352
|
+
const out = new Uint8Array(mask.length);
|
|
353
|
+
for (let y = 0; y < h; y += 1) {
|
|
354
|
+
for (let x = 0; x < w; x += 1) {
|
|
355
|
+
if (mask[y * w + x] !== 1)
|
|
356
|
+
continue;
|
|
357
|
+
for (let dy = -r; dy <= r; dy += 1) {
|
|
358
|
+
const sy = y + dy;
|
|
359
|
+
if (sy < 0 || sy >= h)
|
|
360
|
+
continue;
|
|
361
|
+
for (let dx = -r; dx <= r; dx += 1) {
|
|
362
|
+
const sx = x + dx;
|
|
363
|
+
if (sx < 0 || sx >= w)
|
|
364
|
+
continue;
|
|
365
|
+
out[sy * w + sx] = 1;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
/** `a AND b`, pixel for pixel. */
|
|
373
|
+
export function intersect(a, b) {
|
|
374
|
+
const out = new Uint8Array(a.length);
|
|
375
|
+
for (let i = 0; i < a.length; i += 1)
|
|
376
|
+
out[i] = a[i] === 1 && b[i] === 1 ? 1 : 0;
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
/** Painted pixels in `mask` that `within` does not cover, as a share of `mask`. */
|
|
380
|
+
function shareOutside(mask, within) {
|
|
381
|
+
let painted = 0;
|
|
382
|
+
let outside = 0;
|
|
383
|
+
for (let i = 0; i < mask.length; i += 1) {
|
|
384
|
+
if (mask[i] !== 1)
|
|
385
|
+
continue;
|
|
386
|
+
painted += 1;
|
|
387
|
+
if (within[i] !== 1)
|
|
388
|
+
outside += 1;
|
|
389
|
+
}
|
|
390
|
+
return { share: painted === 0 ? 0 : outside / painted, count: outside };
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Does painted content touch the frame's own edge?
|
|
394
|
+
*
|
|
395
|
+
* Step 2.1, and the crown-cut case it exists for: a trace whose crop started
|
|
396
|
+
* five pixels below the top of the mark took the crop border for a contour,
|
|
397
|
+
* and a comparison against that same crop cannot see the cut — the trace and
|
|
398
|
+
* the source agree exactly, because both are missing the same thing. The only
|
|
399
|
+
* signal available is that the content runs into the border, which is what
|
|
400
|
+
* this reports.
|
|
401
|
+
*/
|
|
402
|
+
function touchesFrameEdge(mask, w, h) {
|
|
403
|
+
const sides = [];
|
|
404
|
+
const any = (idx, n) => {
|
|
405
|
+
for (let i = 0; i < n; i += 1)
|
|
406
|
+
if (mask[idx(i)] === 1)
|
|
407
|
+
return true;
|
|
408
|
+
return false;
|
|
409
|
+
};
|
|
410
|
+
if (any((i) => i, w))
|
|
411
|
+
sides.push('top');
|
|
412
|
+
if (any((i) => (h - 1) * w + i, w))
|
|
413
|
+
sides.push('bottom');
|
|
414
|
+
if (any((i) => i * w, h))
|
|
415
|
+
sides.push('left');
|
|
416
|
+
if (any((i) => i * w + (w - 1), h))
|
|
417
|
+
sides.push('right');
|
|
418
|
+
return sides;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* The sample grid, in viewBox units.
|
|
422
|
+
*
|
|
423
|
+
* MEASURED, not assumed: `drawImage` STRETCHES an SVG into the destination
|
|
424
|
+
* rect — a 160x60 viewBox drawn into 256x256 put a `y = 20..44` rect at rows
|
|
425
|
+
* 85..187, which is 256/60 per unit on the vertical and 256/160 on the
|
|
426
|
+
* horizontal, with no letterbox at all. A square canvas would therefore give
|
|
427
|
+
* the two axes different units and make "the largest boundary distance in
|
|
428
|
+
* viewBox units" two different numbers depending on direction. So the canvas
|
|
429
|
+
* takes the asset's own aspect instead, and one scale governs both axes.
|
|
430
|
+
*/
|
|
431
|
+
function sampleFrame(vb, res) {
|
|
432
|
+
const scale = res / Math.max(vb.w, vb.h);
|
|
433
|
+
return {
|
|
434
|
+
w: Math.max(1, Math.round(vb.w * scale)),
|
|
435
|
+
h: Math.max(1, Math.round(vb.h * scale)),
|
|
436
|
+
scale,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Painted = differs from this image's own background.
|
|
441
|
+
*
|
|
442
|
+
* Alpha alone cannot see a `<text>` sitting on an opaque background rect: every
|
|
443
|
+
* pixel of such a file is alpha 255, so an alpha mask reads two different
|
|
444
|
+
* labels as the same silhouette. The corner pixel is the background reference.
|
|
445
|
+
*/
|
|
446
|
+
function paintedMask(alpha, rgb) {
|
|
447
|
+
const out = new Uint8Array(alpha.length);
|
|
448
|
+
const bgA = alpha[0];
|
|
449
|
+
const bgR = rgb[0];
|
|
450
|
+
const bgG = rgb[1];
|
|
451
|
+
const bgB = rgb[2];
|
|
452
|
+
for (let i = 0; i < alpha.length; i++) {
|
|
453
|
+
const dA = Math.abs(alpha[i] - bgA);
|
|
454
|
+
const dR = Math.abs(rgb[i * 3] - bgR);
|
|
455
|
+
const dG = Math.abs(rgb[i * 3 + 1] - bgG);
|
|
456
|
+
const dB = Math.abs(rgb[i * 3 + 2] - bgB);
|
|
457
|
+
const visible = alpha[i] >= 16;
|
|
458
|
+
out[i] = visible && (dA > 8 || dR > 8 || dG > 8 || dB > 8) ? 1 : 0;
|
|
459
|
+
}
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* One file sampled into an alpha mask and an RGB buffer, both at `SAMPLE_RES`
|
|
464
|
+
* over the baseline's viewBox. A `data:` URL plus `drawImage` keeps this inside
|
|
465
|
+
* the page and inside the no-network rule — nothing is fetched.
|
|
466
|
+
*/
|
|
467
|
+
async function rasterise(page, file, vb, registration) {
|
|
468
|
+
const buf = fs.readFileSync(file);
|
|
469
|
+
const isSvg = /\.svg$/i.test(file);
|
|
470
|
+
const mime = isSvg
|
|
471
|
+
? 'image/svg+xml'
|
|
472
|
+
: /\.png$/i.test(file)
|
|
473
|
+
? 'image/png'
|
|
474
|
+
: /\.webp$/i.test(file)
|
|
475
|
+
? 'image/webp'
|
|
476
|
+
: 'image/jpeg';
|
|
477
|
+
const dataUrl = `data:${mime};base64,${buf.toString('base64')}`;
|
|
478
|
+
const frame = sampleFrame(vb, SAMPLE_RES);
|
|
479
|
+
const out = await page.evaluate(async (args) => {
|
|
480
|
+
const img = new Image();
|
|
481
|
+
img.src = args.url;
|
|
482
|
+
try {
|
|
483
|
+
await img.decode();
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
const canvas = document.createElement('canvas');
|
|
489
|
+
canvas.width = args.w;
|
|
490
|
+
canvas.height = args.h;
|
|
491
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
492
|
+
if (!ctx)
|
|
493
|
+
return null;
|
|
494
|
+
ctx.clearRect(0, 0, args.w, args.h);
|
|
495
|
+
if (args.reg) {
|
|
496
|
+
// The SAME destination rect the vector is stretched into, which
|
|
497
|
+
// is what makes the two masks comparable at all.
|
|
498
|
+
ctx.drawImage(img, args.reg.x, args.reg.y, args.reg.width, args.reg.height, 0, 0, args.w, args.h);
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
ctx.drawImage(img, 0, 0, args.w, args.h);
|
|
502
|
+
}
|
|
503
|
+
const data = ctx.getImageData(0, 0, args.w, args.h).data;
|
|
504
|
+
const alpha = [];
|
|
505
|
+
const rgb = [];
|
|
506
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
507
|
+
alpha.push(data[i + 3]);
|
|
508
|
+
rgb.push(data[i], data[i + 1], data[i + 2]);
|
|
509
|
+
}
|
|
510
|
+
return { alpha, rgb };
|
|
511
|
+
}, {
|
|
512
|
+
url: dataUrl,
|
|
513
|
+
w: frame.w,
|
|
514
|
+
h: frame.h,
|
|
515
|
+
reg: cropRect(registration),
|
|
516
|
+
});
|
|
517
|
+
if (!out)
|
|
518
|
+
return null;
|
|
519
|
+
const alpha = Uint8Array.from(out.alpha);
|
|
520
|
+
const rgb = Uint8Array.from(out.rgb);
|
|
521
|
+
return { alpha, rgb, painted: paintedMask(alpha, rgb) };
|
|
522
|
+
}
|
|
523
|
+
async function readTextBoxes(page, file) {
|
|
524
|
+
if (!/\.svg$/i.test(file))
|
|
525
|
+
return [];
|
|
526
|
+
const svg = fs.readFileSync(file, 'utf8').replace(/<\?xml[^>]*\?>\s*/i, '');
|
|
527
|
+
return page.evaluate((markup) => {
|
|
528
|
+
const holder = document.createElement('div');
|
|
529
|
+
holder.innerHTML = markup;
|
|
530
|
+
document.body.appendChild(holder);
|
|
531
|
+
const out = [];
|
|
532
|
+
for (const t of Array.from(holder.querySelectorAll('text'))) {
|
|
533
|
+
const bb = t.getBBox();
|
|
534
|
+
out.push({
|
|
535
|
+
family: window.getComputedStyle(t).fontFamily,
|
|
536
|
+
box: { x: bb.x, y: bb.y, w: bb.width, h: bb.height },
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
holder.remove();
|
|
540
|
+
return out;
|
|
541
|
+
}, svg);
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* The rendered box of every TOP-LEVEL `<g id>`, in user units.
|
|
545
|
+
*
|
|
546
|
+
* Step 2.2 of `road-to-svg-from-a-live-session`. `CompareSpec.regions` existed
|
|
547
|
+
* and `main.ts` carried no option for it, so `worst_region` was always
|
|
548
|
+
* `whole` and the per-region breakdown an earlier learning asked for was
|
|
549
|
+
* unreachable from the CLI. A flag would have been one more input nobody
|
|
550
|
+
* passes; a hand-built file already names its own parts — `hat`, `head-group`,
|
|
551
|
+
* `arms`, `legs` — so the regions are read from the file instead.
|
|
552
|
+
*
|
|
553
|
+
* TOP-LEVEL only, and `id` required. A nested group is part of its parent's
|
|
554
|
+
* reading, and an anonymous group has no name to report a verdict under.
|
|
555
|
+
*/
|
|
556
|
+
async function readTopLevelGroupBoxes(page, file) {
|
|
557
|
+
if (!/\.svg$/i.test(file))
|
|
558
|
+
return [];
|
|
559
|
+
const svg = fs.readFileSync(file, 'utf8').replace(/<\?xml[^>]*\?>\s*/i, '');
|
|
560
|
+
return page.evaluate((markup) => {
|
|
561
|
+
const holder = document.createElement('div');
|
|
562
|
+
holder.innerHTML = markup;
|
|
563
|
+
document.body.appendChild(holder);
|
|
564
|
+
const out = [];
|
|
565
|
+
const root = holder.querySelector('svg');
|
|
566
|
+
if (root) {
|
|
567
|
+
for (const child of Array.from(root.children)) {
|
|
568
|
+
if (child.tagName.toLowerCase() !== 'g')
|
|
569
|
+
continue;
|
|
570
|
+
const id = child.getAttribute('id');
|
|
571
|
+
if (!id)
|
|
572
|
+
continue;
|
|
573
|
+
try {
|
|
574
|
+
const bb = child.getBBox();
|
|
575
|
+
if (bb.width > 0 || bb.height > 0) {
|
|
576
|
+
out.push({ id, box: { x: bb.x, y: bb.y, w: bb.width, h: bb.height } });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
// A group the renderer cannot box contributes no region,
|
|
581
|
+
// which leaves `whole` as its reading rather than a guess.
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
holder.remove();
|
|
586
|
+
return out;
|
|
587
|
+
}, svg);
|
|
588
|
+
}
|
|
589
|
+
function shift(mask, w, h, dx, dy) {
|
|
590
|
+
if (dx === 0 && dy === 0)
|
|
591
|
+
return mask;
|
|
592
|
+
const out = new Uint8Array(mask.length);
|
|
593
|
+
for (let y = 0; y < h; y++) {
|
|
594
|
+
const sy = y - dy;
|
|
595
|
+
if (sy < 0 || sy >= h)
|
|
596
|
+
continue;
|
|
597
|
+
for (let x = 0; x < w; x++) {
|
|
598
|
+
const sx = x - dx;
|
|
599
|
+
if (sx < 0 || sx >= w)
|
|
600
|
+
continue;
|
|
601
|
+
out[y * w + x] = mask[sy * w + sx];
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return out;
|
|
605
|
+
}
|
|
606
|
+
export function iouOf(a, b) {
|
|
607
|
+
let inter = 0;
|
|
608
|
+
let union = 0;
|
|
609
|
+
for (let i = 0; i < a.length; i++) {
|
|
610
|
+
const pa = a[i] === 1;
|
|
611
|
+
const pb = b[i] === 1;
|
|
612
|
+
if (pa && pb)
|
|
613
|
+
inter += 1;
|
|
614
|
+
if (pa || pb)
|
|
615
|
+
union += 1;
|
|
616
|
+
}
|
|
617
|
+
return union === 0 ? 1 : inter / union;
|
|
618
|
+
}
|
|
619
|
+
/** The sub-pixel offset of the candidate against the baseline is PART of the reading (S12). */
|
|
620
|
+
export function bestAlignment(a, b, w, h) {
|
|
621
|
+
let best = { iou: iouOf(a, b), dx: 0, dy: 0 };
|
|
622
|
+
for (let dy = -2; dy <= 2; dy++) {
|
|
623
|
+
for (let dx = -2; dx <= 2; dx++) {
|
|
624
|
+
if (dx === 0 && dy === 0)
|
|
625
|
+
continue;
|
|
626
|
+
const v = iouOf(a, shift(b, w, h, dx, dy));
|
|
627
|
+
if (v > best.iou)
|
|
628
|
+
best = { iou: v, dx, dy };
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return best;
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* The thickness of the disagreement band, in sample pixels, with a one-device-
|
|
635
|
+
* pixel anti-aliasing band excluded: a multi-source BFS out of the pixels where
|
|
636
|
+
* the two masks agree, taking the furthest disagreeing pixel.
|
|
637
|
+
*/
|
|
638
|
+
function boundaryDistance(a, b, w, h) {
|
|
639
|
+
const dist = new Int32Array(a.length).fill(-1);
|
|
640
|
+
const queue = [];
|
|
641
|
+
for (let i = 0; i < a.length; i++) {
|
|
642
|
+
if ((a[i] === 1) === (b[i] === 1)) {
|
|
643
|
+
dist[i] = 0;
|
|
644
|
+
queue.push(i);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (queue.length === 0)
|
|
648
|
+
return Math.max(w, h);
|
|
649
|
+
let head = 0;
|
|
650
|
+
let max = 0;
|
|
651
|
+
while (head < queue.length) {
|
|
652
|
+
const i = queue[head++];
|
|
653
|
+
const d = dist[i];
|
|
654
|
+
const x = i % w;
|
|
655
|
+
const y = (i - x) / w;
|
|
656
|
+
for (const [nx, ny] of [
|
|
657
|
+
[x + 1, y],
|
|
658
|
+
[x - 1, y],
|
|
659
|
+
[x, y + 1],
|
|
660
|
+
[x, y - 1],
|
|
661
|
+
]) {
|
|
662
|
+
if (nx < 0 || nx >= w || ny < 0 || ny >= h)
|
|
663
|
+
continue;
|
|
664
|
+
const j = ny * w + nx;
|
|
665
|
+
if (dist[j] !== -1)
|
|
666
|
+
continue;
|
|
667
|
+
dist[j] = d + 1;
|
|
668
|
+
if (d + 1 > max)
|
|
669
|
+
max = d + 1;
|
|
670
|
+
queue.push(j);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// One sample pixel of disagreement is the anti-aliasing band, not a defect.
|
|
674
|
+
return Math.max(0, max - 1);
|
|
675
|
+
}
|
|
676
|
+
function disagreementIsInsideTextBoxes(a, b, frame, vb, texts) {
|
|
677
|
+
if (texts.length === 0)
|
|
678
|
+
return false;
|
|
679
|
+
const boxes = texts.flatMap((t) => [t.baseline_box, t.candidate_box]);
|
|
680
|
+
// One user unit for the glyph's own overshoot, plus one sample pixel for the
|
|
681
|
+
// anti-aliasing band. Wider than that and a real delta beside a label would
|
|
682
|
+
// be swallowed; the differing-family check is the other half of the gate.
|
|
683
|
+
const pad = 1 + 1 / frame.scale;
|
|
684
|
+
let disagreements = 0;
|
|
685
|
+
for (let i = 0; i < a.length; i++) {
|
|
686
|
+
if ((a[i] === 1) === (b[i] === 1))
|
|
687
|
+
continue;
|
|
688
|
+
disagreements += 1;
|
|
689
|
+
const px = i % frame.w;
|
|
690
|
+
const py = (i - px) / frame.w;
|
|
691
|
+
const ux = vb.x + px / frame.scale;
|
|
692
|
+
const uy = vb.y + py / frame.scale;
|
|
693
|
+
const inside = boxes.some((box) => ux >= box.x - pad && ux <= box.x + box.w + pad && uy >= box.y - pad && uy <= box.y + box.h + pad);
|
|
694
|
+
if (!inside)
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
return disagreements > 0;
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* TWO numbers per region, because S4 and S13 show either alone misleads:
|
|
701
|
+
* silhouette agreement over the saturated regions, and the color error over
|
|
702
|
+
* every non-background pixel with the share that is badly off beside it.
|
|
703
|
+
*/
|
|
704
|
+
function rasterRegionReading(baseline, candidate, frame, vb, region) {
|
|
705
|
+
const inRegion = (i) => {
|
|
706
|
+
const px = i % frame.w;
|
|
707
|
+
const py = (i - px) / frame.w;
|
|
708
|
+
const ux = vb.x + px / frame.scale;
|
|
709
|
+
const uy = vb.y + py / frame.scale;
|
|
710
|
+
return ux >= region.box.x && ux <= region.box.x + region.box.w && uy >= region.box.y && uy <= region.box.y + region.box.h;
|
|
711
|
+
};
|
|
712
|
+
const chroma = (buf, i) => {
|
|
713
|
+
const r = buf[i * 3];
|
|
714
|
+
const g = buf[i * 3 + 1];
|
|
715
|
+
const b = buf[i * 3 + 2];
|
|
716
|
+
return Math.max(r, g, b) - Math.min(r, g, b);
|
|
717
|
+
};
|
|
718
|
+
let inter = 0;
|
|
719
|
+
let union = 0;
|
|
720
|
+
let errorSum = 0;
|
|
721
|
+
let errorCount = 0;
|
|
722
|
+
let off40 = 0;
|
|
723
|
+
for (let i = 0; i < baseline.painted.length; i++) {
|
|
724
|
+
if (!inRegion(i))
|
|
725
|
+
continue;
|
|
726
|
+
const aPainted = baseline.painted[i] === 1;
|
|
727
|
+
const bPainted = candidate.painted[i] === 1;
|
|
728
|
+
const aSat = aPainted && chroma(baseline.rgb, i) >= 24;
|
|
729
|
+
const bSat = bPainted && chroma(candidate.rgb, i) >= 24;
|
|
730
|
+
if (aSat && bSat)
|
|
731
|
+
inter += 1;
|
|
732
|
+
if (aSat || bSat)
|
|
733
|
+
union += 1;
|
|
734
|
+
if (!aPainted && !bPainted)
|
|
735
|
+
continue;
|
|
736
|
+
const d = (Math.abs(baseline.rgb[i * 3] - candidate.rgb[i * 3]) +
|
|
737
|
+
Math.abs(baseline.rgb[i * 3 + 1] - candidate.rgb[i * 3 + 1]) +
|
|
738
|
+
Math.abs(baseline.rgb[i * 3 + 2] - candidate.rgb[i * 3 + 2])) /
|
|
739
|
+
3;
|
|
740
|
+
errorSum += d;
|
|
741
|
+
errorCount += 1;
|
|
742
|
+
if (d > 40)
|
|
743
|
+
off40 += 1;
|
|
744
|
+
}
|
|
745
|
+
return {
|
|
746
|
+
region: region.id,
|
|
747
|
+
silhouette_iou: union === 0 ? 1 : inter / union,
|
|
748
|
+
mean_colour_error: errorCount === 0 ? 0 : errorSum / errorCount,
|
|
749
|
+
share_off_by_40: errorCount === 0 ? 0 : off40 / errorCount,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
//# sourceMappingURL=uiRenderAssetCompare.js.map
|