partforge 0.54.0 → 0.55.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/package.json +1 -1
- package/src/framework/jobs.js +75 -2
- package/src/framework/oracle/match.js +263 -0
- package/src/framework/oracle/measure.js +5 -1
- package/src/framework/oracle/silhouette.js +146 -0
- package/src/testing.js +5 -0
- package/types/testing.d.ts +116 -1
package/package.json
CHANGED
package/src/framework/jobs.js
CHANGED
|
@@ -9,6 +9,9 @@ import { safeName } from "./safe-name.js";
|
|
|
9
9
|
import { exportSubParts, resolveParams, buildPosed } from "./part-model.js";
|
|
10
10
|
import { measure } from "./oracle/measure.js";
|
|
11
11
|
import { verify } from "./oracle/verify.js";
|
|
12
|
+
import { buildView } from "./oracle/build.js";
|
|
13
|
+
import { MATCH_VIEWS, rasterizeMeshMask, rasterizeRingsMask } from "./oracle/silhouette.js";
|
|
14
|
+
import { matchViews } from "./oracle/match.js";
|
|
12
15
|
|
|
13
16
|
// Handle one geometry job, posting results/progress via `post(msg, transfer?)`.
|
|
14
17
|
// Backend-agnostic and part-agnostic: every part specific comes through `part`.
|
|
@@ -26,6 +29,67 @@ import { verify } from "./oracle/verify.js";
|
|
|
26
29
|
// preview generates stay quiet (no callback) to avoid flicker during slider drags.
|
|
27
30
|
const bufferOf = (data) => (ArrayBuffer.isView(data) ? data.buffer : data);
|
|
28
31
|
|
|
32
|
+
// One caller-supplied match target as a reference mask, or null when it is not one we
|
|
33
|
+
// can score. Every field here is untrusted wire data, so shape is checked rather than
|
|
34
|
+
// assumed — a target the caller got wrong is skipped, never thrown, so one bad entry
|
|
35
|
+
// cannot cost the others their scores (or the caller their geometry report).
|
|
36
|
+
// {kind: "profile", rings: [[[x,y], ...], ...]} — millimetres, so it carries scale
|
|
37
|
+
// {kind: "image", mask: {data, width, height}} — a photo, so it carries none
|
|
38
|
+
function referenceMask(target) {
|
|
39
|
+
if (target?.kind === "profile") return Array.isArray(target.rings) ? rasterizeRingsMask(target.rings) : null;
|
|
40
|
+
if (target?.kind === "image") {
|
|
41
|
+
const m = target.mask;
|
|
42
|
+
if (!m?.data || !(m.width > 0) || !(m.height > 0)) return null;
|
|
43
|
+
// mmPerPx is deliberately absent: a photograph has no millimetres, which is what
|
|
44
|
+
// keeps the scale-aware comparison off for an image target no matter what is asked.
|
|
45
|
+
return { data: m.data, width: m.width, height: m.height };
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Score the part's six canonical silhouettes against each match target.
|
|
51
|
+
// `built` is the view's already-built sub-parts — the meshes are JS-owned, so this
|
|
52
|
+
// reads correctly after the kernel has been cleaned up.
|
|
53
|
+
//
|
|
54
|
+
// Total by construction: match scoring is an EXTRA on top of the geometric report, so
|
|
55
|
+
// any failure here omits `match` and leaves the report itself intact. A caller who
|
|
56
|
+
// asked for a score and got none can ask again; a caller who lost their measurement
|
|
57
|
+
// because a mask blew up has lost the thing they actually came for.
|
|
58
|
+
//
|
|
59
|
+
// The six mesh masks are rasterized ONCE and shared across every target — the targets
|
|
60
|
+
// are the cheap side of this (a couple of reference masks), the part is not.
|
|
61
|
+
function scoreMatchTargets(built, targets, onProgress) {
|
|
62
|
+
if (!targets?.length) return null;
|
|
63
|
+
try {
|
|
64
|
+
const meshes = built.map((b) => b.mesh);
|
|
65
|
+
const viewMasks = {};
|
|
66
|
+
for (const view of MATCH_VIEWS) viewMasks[view] = rasterizeMeshMask(meshes, view);
|
|
67
|
+
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const target of targets) {
|
|
70
|
+
try {
|
|
71
|
+
const reference = referenceMask(target);
|
|
72
|
+
if (!reference) continue;
|
|
73
|
+
// scaleAware is the CALLER's promise that both sides are in millimetres, and
|
|
74
|
+
// this is the caller: rings are mm and the mesh masks carry mmPerPx, so a
|
|
75
|
+
// profile target gets the absolute-size score (`iouScale`, contourDist in mm)
|
|
76
|
+
// while an image target gets the pose-normalized one.
|
|
77
|
+
const scoreOpts = { scaleAware: target.kind === "profile" };
|
|
78
|
+
const { best, views } = matchViews(viewMasks, reference, scoreOpts);
|
|
79
|
+
if (!best) continue; // nothing scoreable — a dropped target, never a zero score
|
|
80
|
+
const { delta, ...scores } = best;
|
|
81
|
+
out.push({ kind: target.kind, best: scores, views, delta: { view: best.view, ...delta } });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
onProgress(`match target skipped: ${String(err?.message || err)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out.length ? out : null;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
onProgress(`match scoring skipped: ${String(err?.message || err)}`);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
29
93
|
export async function handle(kernel, part, msg, post, opts = {}) {
|
|
30
94
|
const isStale = opts.isStale ?? (() => false);
|
|
31
95
|
const onProgress = (phase) => post({ type: "progress", phase, jobId: msg.jobId });
|
|
@@ -147,7 +211,12 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
147
211
|
// true }` here is what makes the seed usable by any verify run, min-wall
|
|
148
212
|
// gated or not — the result says so itself (`measuredMinWall`), so this
|
|
149
213
|
// call and the seed cannot drift apart.
|
|
150
|
-
|
|
214
|
+
//
|
|
215
|
+
// The view is built HERE rather than inside measure, and handed down through
|
|
216
|
+
// `opts.built`, because optional match scoring needs the same meshes: one build
|
|
217
|
+
// feeds the measurement and the six silhouette rasterizations both.
|
|
218
|
+
const built = buildView(kernel, part, msg.view, msg.params ?? {});
|
|
219
|
+
const measured = measure(kernel, part, msg.view, msg.params ?? {}, { minWall: true, built });
|
|
151
220
|
const report = {
|
|
152
221
|
measure: measured,
|
|
153
222
|
verify: verify(kernel, part, {
|
|
@@ -155,7 +224,11 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
155
224
|
seed: { params: msg.params ?? {}, result: measured },
|
|
156
225
|
}),
|
|
157
226
|
};
|
|
158
|
-
|
|
227
|
+
// `match` is present only when the caller asked for it AND something scored, so
|
|
228
|
+
// an inspect with no `matchTargets` answers on exactly the shape it always has.
|
|
229
|
+
const match = scoreMatchTargets(built, msg.matchTargets, onProgress);
|
|
230
|
+
if (match) report.match = match;
|
|
231
|
+
post({ type: "report", ...report }, match?.map((m) => m.delta.data.buffer) ?? []);
|
|
159
232
|
}
|
|
160
233
|
} catch (err) {
|
|
161
234
|
if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt", jobId: msg.jobId });
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Mask comparison: score a candidate silhouette against a reference one. Consumes the
|
|
2
|
+
// masks oracle/silhouette.js produces and answers "how close is this shape?" four ways —
|
|
3
|
+
// area overlap (`iou`), rim overlap (`boundaryIoU`), a mean contour separation
|
|
4
|
+
// (`contourDist`), and a per-pixel `delta` map naming what is missing versus extra. No
|
|
5
|
+
// kernel, no DOM, no three, no `node:`, so it runs in the geometry worker like the rest
|
|
6
|
+
// of the oracle.
|
|
7
|
+
//
|
|
8
|
+
// Shape is compared POSE-NORMALIZED: each mask is cropped to its tight foreground bbox,
|
|
9
|
+
// scaled so its longest side fills 0.92 of a 256² frame, and centroid-aligned there. So
|
|
10
|
+
// `iou`, `boundaryIoU`, and `delta` are invariant to where the part sits and how big it
|
|
11
|
+
// is, which is what "does it look like the picture?" means. Absolute size is a separate
|
|
12
|
+
// question and only `{scaleAware: true}` asks it — and only when BOTH masks carry a
|
|
13
|
+
// finite mmPerPx: `iouScale` overlays them on the reference's own mm grid,
|
|
14
|
+
// centroid-aligned and never rescaled, so a part twice too big scores 0.25, and
|
|
15
|
+
// `contourDist` is then a real millimetre distance instead of a fraction of the
|
|
16
|
+
// reference's bbox diagonal.
|
|
17
|
+
//
|
|
18
|
+
// A mask with no foreground pixels — or no mask at all — is UNSCOREABLE, not
|
|
19
|
+
// zero-scoring: matchMasks returns null and matchViews leaves the view out. 0/0 is
|
|
20
|
+
// never a score.
|
|
21
|
+
|
|
22
|
+
import { MATCH_VIEWS } from "./silhouette.js";
|
|
23
|
+
|
|
24
|
+
const S = 256; // internal normalization frame, whatever the input mask sizes
|
|
25
|
+
const FILL = 0.92; // fraction of the frame the longest bbox side occupies
|
|
26
|
+
const BAND_PX = 2; // boundary band thickness, per the Boundary IoU definition
|
|
27
|
+
const BIG = 1e20; // "unreachable" seed for the distance transform's lower envelope
|
|
28
|
+
const MAX_MM_FRAME = 2048; // px ceiling on the scale-aware grid; see mmFrame
|
|
29
|
+
|
|
30
|
+
// candidate/reference: Task 1 masks. opts: {scaleAware}. → null when either is unscoreable.
|
|
31
|
+
export function matchMasks(candidate, reference, opts = {}) {
|
|
32
|
+
const cs = stats(candidate), rs = stats(reference);
|
|
33
|
+
if (!cs || !rs) return null;
|
|
34
|
+
|
|
35
|
+
const nc = normalize(cs), nr = normalize(rs);
|
|
36
|
+
const iou = maskIoU(nc, nr);
|
|
37
|
+
const boundaryIoU = maskIoU(band(nc), band(nr));
|
|
38
|
+
const delta = deltaMap(nc, nr);
|
|
39
|
+
|
|
40
|
+
const scaleAware = opts.scaleAware === true && scaled(candidate) && scaled(reference);
|
|
41
|
+
let contourDist, contourUnit, iouScale;
|
|
42
|
+
if (scaleAware) {
|
|
43
|
+
const [sc, sr, pitch] = mmFrame(cs, candidate.mmPerPx, rs, reference.mmPerPx);
|
|
44
|
+
iouScale = maskIoU(sc, sr);
|
|
45
|
+
contourDist = contourDistance(sc, sr) * pitch;
|
|
46
|
+
contourUnit = "mm";
|
|
47
|
+
} else {
|
|
48
|
+
const diag = Math.hypot(nr.bw, nr.bh);
|
|
49
|
+
contourDist = (contourDistance(nc, nr) / diag) * 100;
|
|
50
|
+
contourUnit = "%bbox-diag";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const out = { iou, boundaryIoU, contourDist, contourUnit, delta };
|
|
54
|
+
if (scaleAware) out.iouScale = iouScale;
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// viewMasks: {front: mask|null, ...} → {best: {view, ...scores}|null, views: {view: iou}}.
|
|
59
|
+
// Views are walked in MATCH_VIEWS order, so a tie on `iou` resolves to the earlier view
|
|
60
|
+
// regardless of the object's own key order.
|
|
61
|
+
export function matchViews(viewMasks, reference, opts = {}) {
|
|
62
|
+
const views = {};
|
|
63
|
+
let best = null;
|
|
64
|
+
for (const view of MATCH_VIEWS) {
|
|
65
|
+
const scores = matchMasks(viewMasks?.[view], reference, opts);
|
|
66
|
+
if (!scores) continue;
|
|
67
|
+
views[view] = scores.iou;
|
|
68
|
+
if (!best || scores.iou > best.iou) best = { view, ...scores };
|
|
69
|
+
}
|
|
70
|
+
return { best, views };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Squared-then-rooted Euclidean distance (in px) from every pixel to the nearest non-zero
|
|
74
|
+
// pixel of `data`, by Felzenszwalb–Huttenlocher: a 1D lower-envelope pass over rows, then
|
|
75
|
+
// over columns. With no non-zero pixel at all every entry comes back astronomically large
|
|
76
|
+
// rather than infinite, which keeps the envelope's arithmetic finite.
|
|
77
|
+
export function distanceTransform(data, width, height) {
|
|
78
|
+
const n = width * height;
|
|
79
|
+
const sq = new Float64Array(n);
|
|
80
|
+
for (let i = 0; i < n; i++) sq[i] = data[i] ? 0 : BIG;
|
|
81
|
+
|
|
82
|
+
const m = Math.max(width, height);
|
|
83
|
+
const f = new Float64Array(m), d = new Float64Array(m);
|
|
84
|
+
const v = new Int32Array(m), z = new Float64Array(m + 1);
|
|
85
|
+
for (let r = 0; r < height; r++) {
|
|
86
|
+
for (let c = 0; c < width; c++) f[c] = sq[r * width + c];
|
|
87
|
+
envelope(f, width, d, v, z);
|
|
88
|
+
for (let c = 0; c < width; c++) sq[r * width + c] = d[c];
|
|
89
|
+
}
|
|
90
|
+
for (let c = 0; c < width; c++) {
|
|
91
|
+
for (let r = 0; r < height; r++) f[r] = sq[r * width + c];
|
|
92
|
+
envelope(f, height, d, v, z);
|
|
93
|
+
for (let r = 0; r < height; r++) sq[r * width + c] = d[r];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const out = new Float32Array(n);
|
|
97
|
+
for (let i = 0; i < n; i++) out[i] = Math.sqrt(sq[i]);
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Lower envelope of the parabolas (q - x)² + f[q]: `v` holds the parabolas in the
|
|
102
|
+
// envelope and `z` the boundaries between them. d[] comes back as squared distances.
|
|
103
|
+
function envelope(f, n, d, v, z) {
|
|
104
|
+
let k = 0;
|
|
105
|
+
v[0] = 0; z[0] = -Infinity; z[1] = Infinity;
|
|
106
|
+
for (let q = 1; q < n; q++) {
|
|
107
|
+
let s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
|
|
108
|
+
while (s <= z[k]) {
|
|
109
|
+
k--;
|
|
110
|
+
s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
|
|
111
|
+
}
|
|
112
|
+
k++; v[k] = q; z[k] = s; z[k + 1] = Infinity;
|
|
113
|
+
}
|
|
114
|
+
k = 0;
|
|
115
|
+
for (let q = 0; q < n; q++) {
|
|
116
|
+
while (z[k + 1] < q) k++;
|
|
117
|
+
d[q] = (q - v[k]) * (q - v[k]) + f[v[k]];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const scaled = (mask) => Number.isFinite(mask?.mmPerPx) && mask.mmPerPx > 0;
|
|
122
|
+
|
|
123
|
+
// Foreground bbox, centroid, and pixel count — null when there is nothing to score.
|
|
124
|
+
function stats(mask) {
|
|
125
|
+
const data = mask?.data, w = mask?.width | 0, h = mask?.height | 0;
|
|
126
|
+
if (!data || !(w > 0) || !(h > 0) || data.length < w * h) return null;
|
|
127
|
+
let c0 = w, c1 = -1, r0 = h, r1 = -1, n = 0, sc = 0, sr = 0;
|
|
128
|
+
for (let r = 0; r < h; r++) for (let c = 0; c < w; c++) {
|
|
129
|
+
if (!data[r * w + c]) continue;
|
|
130
|
+
n++; sc += c; sr += r;
|
|
131
|
+
if (c < c0) c0 = c;
|
|
132
|
+
if (c > c1) c1 = c;
|
|
133
|
+
if (r < r0) r0 = r;
|
|
134
|
+
if (r > r1) r1 = r;
|
|
135
|
+
}
|
|
136
|
+
return n ? { data, w, h, c0, c1, r0, r1, cx: sc / n, cy: sr / n, count: n } : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Crop to the bbox, scale the longer side to FILL*S, centroid-align into the S² frame,
|
|
140
|
+
// nearest-neighbour. Sampling is done backwards from the output so the two masks share
|
|
141
|
+
// one mapping form and identical shapes at different scales land on identical pixels.
|
|
142
|
+
//
|
|
143
|
+
// The scale is bbox-derived while the alignment is centroid-derived, so a shape whose
|
|
144
|
+
// centroid sits far from its bbox centre (an L) would run past the frame edge and be
|
|
145
|
+
// silently clipped. The guard shrinks such a shape to fit; it is a function of the
|
|
146
|
+
// shape's own proportions, so two copies of it at different scales still normalize
|
|
147
|
+
// identically and still score 1.
|
|
148
|
+
function normalize(st) {
|
|
149
|
+
const bw = st.c1 - st.c0 + 1, bh = st.r1 - st.r0 + 1;
|
|
150
|
+
let s = (FILL * S) / Math.max(bw, bh);
|
|
151
|
+
const reach = Math.max(st.cx - st.c0, st.c1 - st.cx, st.cy - st.r0, st.r1 - st.cy) + 0.5;
|
|
152
|
+
const room = (S - 2) / 2;
|
|
153
|
+
if (reach * s > room) s = room / reach;
|
|
154
|
+
|
|
155
|
+
const mid = (S - 1) / 2;
|
|
156
|
+
const data = new Uint8Array(S * S);
|
|
157
|
+
for (let R = 0; R < S; R++) {
|
|
158
|
+
const r = Math.round((R - mid) / s + st.cy);
|
|
159
|
+
if (r < st.r0 || r > st.r1) continue;
|
|
160
|
+
for (let C = 0; C < S; C++) {
|
|
161
|
+
const c = Math.round((C - mid) / s + st.cx);
|
|
162
|
+
if (c < st.c0 || c > st.c1) continue;
|
|
163
|
+
if (st.data[r * st.w + c]) data[R * S + C] = 1;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { data, width: S, height: S, bw: bw * s, bh: bh * s };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Both masks resampled onto ONE grid at the reference's mmPerPx, centroid-aligned and
|
|
170
|
+
// never rescaled — the frame where absolute size is the question. Sized to hold both, so
|
|
171
|
+
// an oversized candidate is measured rather than cropped by the reference's own extent.
|
|
172
|
+
// Returns the two masks and the grid's pitch in mm/px, which is the reference's own
|
|
173
|
+
// except when a wildly oversized candidate would need a frame past MAX_MM_FRAME: then the
|
|
174
|
+
// grid COARSENS rather than crops, since a truncated union would flatter the candidate.
|
|
175
|
+
function mmFrame(cs, cmm, rs, rmm) {
|
|
176
|
+
// Half-extents in mm from each mask's own centroid, unioned so neither is cropped.
|
|
177
|
+
const span = (st, mm) => [
|
|
178
|
+
(st.cx - st.c0) * mm + mm / 2, (st.c1 - st.cx) * mm + mm / 2,
|
|
179
|
+
(st.cy - st.r0) * mm + mm / 2, (st.r1 - st.cy) * mm + mm / 2,
|
|
180
|
+
];
|
|
181
|
+
const other = span(rs, rmm);
|
|
182
|
+
const [left, right, up, down] = span(cs, cmm).map((v, i) => Math.max(v, other[i]));
|
|
183
|
+
|
|
184
|
+
const longest = Math.max(left + right, up + down);
|
|
185
|
+
const p = Math.max(rmm, longest / MAX_MM_FRAME);
|
|
186
|
+
const gx = Math.ceil(left / p) + 1, gy = Math.ceil(up / p) + 1;
|
|
187
|
+
const width = gx + Math.ceil(right / p) + 2, height = gy + Math.ceil(down / p) + 2;
|
|
188
|
+
|
|
189
|
+
const draw = (st, mm) => {
|
|
190
|
+
const data = new Uint8Array(width * height);
|
|
191
|
+
for (let R = 0; R < height; R++) {
|
|
192
|
+
const r = Math.round(((R - gy) * p) / mm + st.cy);
|
|
193
|
+
if (r < st.r0 || r > st.r1) continue;
|
|
194
|
+
for (let C = 0; C < width; C++) {
|
|
195
|
+
const c = Math.round(((C - gx) * p) / mm + st.cx);
|
|
196
|
+
if (c < st.c0 || c > st.c1) continue;
|
|
197
|
+
if (st.data[r * st.w + c]) data[R * width + C] = 1;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return { data, width, height };
|
|
201
|
+
};
|
|
202
|
+
return [draw(cs, cmm), draw(rs, rmm), p];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function maskIoU(a, b) {
|
|
206
|
+
let inter = 0, union = 0;
|
|
207
|
+
for (let i = 0; i < a.data.length; i++) {
|
|
208
|
+
const x = a.data[i], y = b.data[i];
|
|
209
|
+
if (x && y) inter++;
|
|
210
|
+
if (x || y) union++;
|
|
211
|
+
}
|
|
212
|
+
return union ? inter / union : 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Boundary IoU's band: the mask minus its own erosion by BAND_PX. Erosion by a disk is
|
|
216
|
+
// the distance transform of the background, so the band is "foreground within BAND_PX of
|
|
217
|
+
// some background pixel".
|
|
218
|
+
function band(m) {
|
|
219
|
+
const inv = new Uint8Array(m.data.length);
|
|
220
|
+
for (let i = 0; i < inv.length; i++) inv[i] = m.data[i] ? 0 : 1;
|
|
221
|
+
const dt = distanceTransform(inv, m.width, m.height);
|
|
222
|
+
const data = new Uint8Array(m.data.length);
|
|
223
|
+
for (let i = 0; i < data.length; i++) if (m.data[i] && dt[i] <= BAND_PX) data[i] = 1;
|
|
224
|
+
return { data, width: m.width, height: m.height };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Contour = foreground with at least one background 4-neighbour; outside the image counts
|
|
228
|
+
// as background, so a shape flush with the edge still has one.
|
|
229
|
+
function contour(m) {
|
|
230
|
+
const { data, width, height } = m;
|
|
231
|
+
const out = new Uint8Array(data.length);
|
|
232
|
+
for (let r = 0; r < height; r++) for (let c = 0; c < width; c++) {
|
|
233
|
+
const i = r * width + c;
|
|
234
|
+
if (!data[i]) continue;
|
|
235
|
+
if (r === 0 || r === height - 1 || c === 0 || c === width - 1
|
|
236
|
+
|| !data[i - width] || !data[i + width] || !data[i - 1] || !data[i + 1]) out[i] = 1;
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Symmetric mean contour separation in px: mean distance from each mask's contour to the
|
|
242
|
+
// other's, averaged both directions so neither shape's rim length dominates.
|
|
243
|
+
function contourDistance(a, b) {
|
|
244
|
+
const ca = contour(a), cb = contour(b);
|
|
245
|
+
const da = distanceTransform(ca, a.width, a.height);
|
|
246
|
+
const db = distanceTransform(cb, b.width, b.height);
|
|
247
|
+
const mean = (pts, dt) => {
|
|
248
|
+
let n = 0, sum = 0;
|
|
249
|
+
for (let i = 0; i < pts.length; i++) if (pts[i]) { n++; sum += dt[i]; }
|
|
250
|
+
return n ? sum / n : 0;
|
|
251
|
+
};
|
|
252
|
+
return (mean(cb, da) + mean(ca, db)) / 2;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 0 = background, 1 = overlap, 2 = missing (reference only), 3 = excess (candidate only).
|
|
256
|
+
function deltaMap(c, r) {
|
|
257
|
+
const data = new Uint8Array(c.data.length);
|
|
258
|
+
for (let i = 0; i < data.length; i++) {
|
|
259
|
+
const inC = c.data[i], inR = r.data[i];
|
|
260
|
+
data[i] = inC && inR ? 1 : inR ? 2 : inC ? 3 : 0;
|
|
261
|
+
}
|
|
262
|
+
return { width: c.width, height: c.height, data };
|
|
263
|
+
}
|
|
@@ -19,7 +19,11 @@ const unionBounds = (list) => list.reduce(
|
|
|
19
19
|
// → { part, view, measuredMinWall, subparts[], aggregate, overlaps[], gaps[],
|
|
20
20
|
// nearMisses[], ok }
|
|
21
21
|
export function measure(kernel, part, view = Object.keys(part.views)[0], params = {}, opts = {}) {
|
|
22
|
-
|
|
22
|
+
// `opts.built` is a build of this view the caller already has. The inspect job
|
|
23
|
+
// needs those meshes anyway — it rasterizes them for silhouette match scoring —
|
|
24
|
+
// and a second buildView here would be a whole duplicate build of the part for
|
|
25
|
+
// nothing. Absent, this measures its own build exactly as it always did.
|
|
26
|
+
const built = opts.built ?? buildView(kernel, part, view, params);
|
|
23
27
|
// ONE BVH per sub-part mesh for this call, shared by the two passes that need
|
|
24
28
|
// one: min-wall (inward rays per triangle) and meshGaps (pair distances). They
|
|
25
29
|
// used to index the same mesh objects independently, so every sub-part of a
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Orthographic silhouette masks: project a part (or a set of 2D rings) onto one of the
|
|
2
|
+
// six canonical views and scanline-fill it into a binary image. The foundation of
|
|
3
|
+
// silhouette match scoring — no kernel, no DOM, no three, no `node:`, so it runs in the
|
|
4
|
+
// geometry worker alongside the rest of the oracle.
|
|
5
|
+
//
|
|
6
|
+
// Contract: {data: Uint8Array of 0|255, width, height, mmPerPx, minX, minY}. Row 0 is
|
|
7
|
+
// the TOP of the image; minX/minY are the projected-plane coordinates of the image's
|
|
8
|
+
// BOTTOM-LEFT corner, so a caller maps a pixel back with
|
|
9
|
+
// x = minX + (col + 0.5) * mmPerPx, y = minY + (height - 0.5 - row) * mmPerPx
|
|
10
|
+
// Nothing to draw, or a projection with zero extent, returns null.
|
|
11
|
+
|
|
12
|
+
export const MATCH_VIEWS = ["front", "back", "top", "bottom", "left", "right"];
|
|
13
|
+
|
|
14
|
+
// Image axes per view as [modelAxisIndex, sign] in MODEL space (x,y,z) — the third axis
|
|
15
|
+
// is dropped. Derived from the viewer's single pivot rotation (`pivot.rotation.x =
|
|
16
|
+
// -Math.PI/2` in viewer.js, taking model Z-up to world Y-up) composed with each view's
|
|
17
|
+
// camera basis in view-angles.js. A wrong sign here mirrors every downstream match
|
|
18
|
+
// score without failing anything else, so the table is written out rather than derived.
|
|
19
|
+
const VIEW_AXES = {
|
|
20
|
+
front: { x: [0, 1], y: [2, 1] }, // drops Y
|
|
21
|
+
back: { x: [0, -1], y: [2, 1] }, // drops Y
|
|
22
|
+
right: { x: [1, 1], y: [2, 1] }, // drops X
|
|
23
|
+
left: { x: [1, -1], y: [2, 1] }, // drops X
|
|
24
|
+
top: { x: [0, 1], y: [1, 1] }, // drops Z
|
|
25
|
+
bottom: { x: [0, 1], y: [1, -1] }, // drops Z
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const PAD = 0.04; // blank fraction of the frame on each side
|
|
29
|
+
const FILL = 1 - 2 * PAD; // 0.92 — the fraction the content's longest extent occupies
|
|
30
|
+
|
|
31
|
+
// meshes: [{positions: Float32Array|number[], indices?: Uint32Array|number[]}] — `indices`
|
|
32
|
+
// optional, flat triangle soup when absent (3 verts per triangle), same as oracle/mesh.js.
|
|
33
|
+
export function rasterizeMeshMask(meshes, view, size = 256) {
|
|
34
|
+
const axes = VIEW_AXES[view];
|
|
35
|
+
if (!axes) throw new Error(`unknown match view "${view}"`);
|
|
36
|
+
const [ax, sx] = axes.x, [ay, sy] = axes.y;
|
|
37
|
+
const groups = [];
|
|
38
|
+
for (const mesh of meshes || []) {
|
|
39
|
+
const P = mesh?.positions;
|
|
40
|
+
if (!P || P.length < 9) continue;
|
|
41
|
+
const idx = mesh.indices;
|
|
42
|
+
const n = idx ? idx.length : P.length / 3;
|
|
43
|
+
for (let i = 0; i + 3 <= n; i += 3) {
|
|
44
|
+
const tri = new Float64Array(6);
|
|
45
|
+
let ok = true;
|
|
46
|
+
for (let k = 0; k < 3 && ok; k++) {
|
|
47
|
+
const base = (idx ? idx[i + k] : i + k) * 3;
|
|
48
|
+
const x = P[base + ax] * sx, y = P[base + ay] * sy;
|
|
49
|
+
if (Number.isFinite(x) && Number.isFinite(y)) { tri[k * 2] = x; tri[k * 2 + 1] = y; }
|
|
50
|
+
else ok = false;
|
|
51
|
+
}
|
|
52
|
+
if (ok) groups.push([tri]); // one triangle = one even-odd group; see fillPolygons
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return fillPolygons(groups, size);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// rings: [[[x,y], ...], ...] in mm. All rings share ONE even-odd group, so a ring inside
|
|
59
|
+
// another is a hole.
|
|
60
|
+
export function rasterizeRingsMask(rings, size = 256) {
|
|
61
|
+
const group = [];
|
|
62
|
+
for (const ring of rings || []) {
|
|
63
|
+
if (!ring || ring.length < 3) continue;
|
|
64
|
+
const flat = new Float64Array(ring.length * 2);
|
|
65
|
+
let ok = true;
|
|
66
|
+
for (let i = 0; i < ring.length && ok; i++) {
|
|
67
|
+
const x = ring[i]?.[0], y = ring[i]?.[1];
|
|
68
|
+
if (Number.isFinite(x) && Number.isFinite(y)) { flat[i * 2] = x; flat[i * 2 + 1] = y; }
|
|
69
|
+
else ok = false;
|
|
70
|
+
}
|
|
71
|
+
if (ok) group.push(flat);
|
|
72
|
+
}
|
|
73
|
+
return group.length ? fillPolygons([group], size) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A GROUP is one even-odd polygon set, given as rings of flat [x0,y0,x1,y1,...] pairs.
|
|
77
|
+
// Groups UNION with each other, which is the whole reason for the grouping: a closed mesh
|
|
78
|
+
// projects its front and back faces onto the same pixels, and even-odd across the whole
|
|
79
|
+
// soup would cancel them into background. So each triangle is its own group and a ring
|
|
80
|
+
// set is one group, and holes still subtract.
|
|
81
|
+
function fillPolygons(groups, size) {
|
|
82
|
+
if (!groups.length) return null;
|
|
83
|
+
let loX = Infinity, loY = Infinity, hiX = -Infinity, hiY = -Infinity;
|
|
84
|
+
for (const g of groups) for (const ring of g) for (let i = 0; i < ring.length; i += 2) {
|
|
85
|
+
if (ring[i] < loX) loX = ring[i];
|
|
86
|
+
if (ring[i] > hiX) hiX = ring[i];
|
|
87
|
+
if (ring[i + 1] < loY) loY = ring[i + 1];
|
|
88
|
+
if (ring[i + 1] > hiY) hiY = ring[i + 1];
|
|
89
|
+
}
|
|
90
|
+
const extent = Math.max(hiX - loX, hiY - loY);
|
|
91
|
+
if (!(extent > 0)) return null;
|
|
92
|
+
|
|
93
|
+
// Uniform scale, tight bbox, PAD on each side, centred in a square frame.
|
|
94
|
+
const mmPerPx = extent / (FILL * size);
|
|
95
|
+
const span = size * mmPerPx;
|
|
96
|
+
const minX = (loX + hiX) / 2 - span / 2, minY = (loY + hiY) / 2 - span / 2;
|
|
97
|
+
const rowOf = (y) => size - 0.5 - (y - minY) / mmPerPx; // row 0 = top: y is flipped here
|
|
98
|
+
|
|
99
|
+
// Bucket each group by its first row and drop it once past its last, so a scanline only
|
|
100
|
+
// walks the groups that can cross it (a mesh soup is thousands of tiny groups).
|
|
101
|
+
const starts = Array.from({ length: size }, () => []);
|
|
102
|
+
const lastRow = new Int32Array(groups.length);
|
|
103
|
+
for (let g = 0; g < groups.length; g++) {
|
|
104
|
+
let gLo = Infinity, gHi = -Infinity;
|
|
105
|
+
for (const ring of groups[g]) for (let i = 1; i < ring.length; i += 2) {
|
|
106
|
+
if (ring[i] < gLo) gLo = ring[i];
|
|
107
|
+
if (ring[i] > gHi) gHi = ring[i];
|
|
108
|
+
}
|
|
109
|
+
const r0 = Math.min(size - 1, Math.max(0, Math.floor(rowOf(gHi))));
|
|
110
|
+
lastRow[g] = Math.min(size - 1, Math.max(0, Math.ceil(rowOf(gLo))));
|
|
111
|
+
starts[r0].push(g);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const data = new Uint8Array(size * size);
|
|
115
|
+
const xs = [];
|
|
116
|
+
let active = [];
|
|
117
|
+
for (let r = 0; r < size; r++) {
|
|
118
|
+
if (starts[r].length) active = active.concat(starts[r]);
|
|
119
|
+
if (!active.length) continue;
|
|
120
|
+
active = active.filter((g) => lastRow[g] >= r);
|
|
121
|
+
const y = minY + (size - 0.5 - r) * mmPerPx;
|
|
122
|
+
for (const g of active) {
|
|
123
|
+
xs.length = 0;
|
|
124
|
+
for (const ring of groups[g]) {
|
|
125
|
+
const n = ring.length;
|
|
126
|
+
// Half-open crossing test (y0 <= y) !== (y1 <= y): a vertex sitting exactly on the
|
|
127
|
+
// scanline is counted once, not twice, so parity stays sane.
|
|
128
|
+
for (let i = 0, j = n - 2; i < n; j = i, i += 2) {
|
|
129
|
+
const y0 = ring[j + 1], y1 = ring[i + 1];
|
|
130
|
+
if ((y0 <= y) === (y1 <= y)) continue;
|
|
131
|
+
xs.push(ring[j] + ((y - y0) * (ring[i] - ring[j])) / (y1 - y0));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (xs.length < 2) continue;
|
|
135
|
+
xs.sort((a, b) => a - b);
|
|
136
|
+
for (let k = 0; k + 1 < xs.length; k += 2) {
|
|
137
|
+
let c0 = Math.ceil((xs[k] - minX) / mmPerPx - 0.5); // first pixel CENTRE inside
|
|
138
|
+
let c1 = Math.floor((xs[k + 1] - minX) / mmPerPx - 0.5); // last pixel centre inside
|
|
139
|
+
if (c0 < 0) c0 = 0;
|
|
140
|
+
if (c1 > size - 1) c1 = size - 1;
|
|
141
|
+
for (let c = c0; c <= c1; c++) data[r * size + c] = 255;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { data, width: size, height: size, mmPerPx, minX, minY };
|
|
146
|
+
}
|
package/src/testing.js
CHANGED
|
@@ -23,3 +23,8 @@ export { renderViews, RENDER_VIEWS } from "./testing/render.js";
|
|
|
23
23
|
export { verify } from "./framework/oracle/verify.js";
|
|
24
24
|
export { buildBVH } from "./framework/oracle/bvh.js";
|
|
25
25
|
export { minWall } from "./framework/oracle/min-wall.js";
|
|
26
|
+
// Silhouette match scoring — also worker-reachable (the `inspect` job scores
|
|
27
|
+
// `matchTargets` with exactly these), re-exported so a downstream harness can build
|
|
28
|
+
// the same masks and reproduce a score outside the job loop.
|
|
29
|
+
export { MATCH_VIEWS, rasterizeMeshMask, rasterizeRingsMask } from "./framework/oracle/silhouette.js";
|
|
30
|
+
export { matchMasks, matchViews } from "./framework/oracle/match.js";
|
package/types/testing.d.ts
CHANGED
|
@@ -35,6 +35,15 @@ export function bootOcctKernel(opts?: { fonts?: Record<string, FontSource> }): P
|
|
|
35
35
|
|
|
36
36
|
// --- the job loop -----------------------------------------------------------
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A reference shape for the `inspect` job to score the part's silhouettes against.
|
|
40
|
+
* A `profile` is millimetres and so is compared at absolute scale too; an `image`
|
|
41
|
+
* is a photo mask with no scale, compared on shape alone.
|
|
42
|
+
*/
|
|
43
|
+
export type MatchTarget =
|
|
44
|
+
| { kind: "profile"; rings: Array<Array<[number, number]>> }
|
|
45
|
+
| { kind: "image"; mask: { data: Uint8Array; width: number; height: number } };
|
|
46
|
+
|
|
38
47
|
/** A job the worker loop accepts. */
|
|
39
48
|
export interface WorkerJob {
|
|
40
49
|
type: "generate" | "export-stl" | "export-step" | "export-3mf" | "inspect";
|
|
@@ -50,6 +59,16 @@ export interface WorkerJob {
|
|
|
50
59
|
jobId?: number;
|
|
51
60
|
/** Single-file export name base. */
|
|
52
61
|
name?: string;
|
|
62
|
+
/**
|
|
63
|
+
* `inspect`: score the part's six canonical silhouettes against these. Absent or
|
|
64
|
+
* empty leaves `match` off the report entirely.
|
|
65
|
+
*
|
|
66
|
+
* A target that cannot be scored — malformed, or with no foreground to score
|
|
67
|
+
* against — is DROPPED rather than reported as a zero, so `report.match` is not
|
|
68
|
+
* index-aligned with this list. Attribute a result by its `kind` and by the
|
|
69
|
+
* relative order of the targets sharing that kind, never by index.
|
|
70
|
+
*/
|
|
71
|
+
matchTargets?: MatchTarget[];
|
|
53
72
|
}
|
|
54
73
|
|
|
55
74
|
/**
|
|
@@ -272,7 +291,16 @@ export function measure(
|
|
|
272
291
|
part: PartDefinition,
|
|
273
292
|
view?: string,
|
|
274
293
|
params?: ResolvedParams,
|
|
275
|
-
opts?: {
|
|
294
|
+
opts?: {
|
|
295
|
+
minWall?: boolean;
|
|
296
|
+
gapThreshold?: number;
|
|
297
|
+
/**
|
|
298
|
+
* A build of this view the caller already has, measured instead of building a
|
|
299
|
+
* second time. It is trusted, not checked against `view`/`params` — hand in a
|
|
300
|
+
* build of the same view you are asking about.
|
|
301
|
+
*/
|
|
302
|
+
built?: BuiltSubPart[];
|
|
303
|
+
},
|
|
276
304
|
): MeasureReport;
|
|
277
305
|
|
|
278
306
|
// --- verify -----------------------------------------------------------------
|
|
@@ -339,6 +367,93 @@ export function verify(
|
|
|
339
367
|
},
|
|
340
368
|
): VerifyReport;
|
|
341
369
|
|
|
370
|
+
// --- silhouette match scoring -----------------------------------------------
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* A binary silhouette. `data` is 0 or 255, one byte per pixel, row 0 at the TOP.
|
|
374
|
+
* `minX`/`minY` are the projected-plane coordinates of the image's BOTTOM-LEFT
|
|
375
|
+
* corner. A mask with no `mmPerPx` carries no scale (a photo), which is what makes
|
|
376
|
+
* the scale-aware comparison unavailable for it.
|
|
377
|
+
*/
|
|
378
|
+
export interface SilhouetteMask {
|
|
379
|
+
data: Uint8Array;
|
|
380
|
+
width: number;
|
|
381
|
+
height: number;
|
|
382
|
+
mmPerPx?: number;
|
|
383
|
+
minX?: number;
|
|
384
|
+
minY?: number;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The six canonical orthographic views a part is rasterized into for matching. */
|
|
388
|
+
export const MATCH_VIEWS: string[];
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Project posed meshes onto one of `MATCH_VIEWS` and scanline-fill the silhouette.
|
|
392
|
+
* `null` when there is nothing to draw or the projection has zero extent.
|
|
393
|
+
*/
|
|
394
|
+
export function rasterizeMeshMask(
|
|
395
|
+
meshes: Array<Pick<Mesh, "positions" | "indices">>,
|
|
396
|
+
view: string,
|
|
397
|
+
size?: number,
|
|
398
|
+
): SilhouetteMask | null;
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Fill a set of closed 2-D rings (millimetres) into a mask. All rings share one
|
|
402
|
+
* even-odd group, so a ring inside another is a hole. `null` when nothing fills.
|
|
403
|
+
*/
|
|
404
|
+
export function rasterizeRingsMask(
|
|
405
|
+
rings: Array<Array<[number, number]>>,
|
|
406
|
+
size?: number,
|
|
407
|
+
): SilhouetteMask | null;
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Per-pixel comparison of the two masks: `0` background, `1` overlap, `2` missing
|
|
411
|
+
* (reference only), `3` excess (candidate only).
|
|
412
|
+
*/
|
|
413
|
+
export interface MatchDelta {
|
|
414
|
+
width: number;
|
|
415
|
+
height: number;
|
|
416
|
+
data: Uint8Array;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* How close a candidate silhouette is to a reference one. Shape is compared
|
|
421
|
+
* pose-normalized, so `iou` and `boundaryIoU` ignore position and size. `iouScale`
|
|
422
|
+
* appears only for a scale-aware comparison, which also makes `contourDist` a real
|
|
423
|
+
* millimetre distance instead of a percentage of the reference's bbox diagonal.
|
|
424
|
+
*/
|
|
425
|
+
export interface MatchScores {
|
|
426
|
+
iou: number;
|
|
427
|
+
boundaryIoU: number;
|
|
428
|
+
contourDist: number;
|
|
429
|
+
contourUnit: "mm" | "%bbox-diag";
|
|
430
|
+
iouScale?: number;
|
|
431
|
+
delta: MatchDelta;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Score one candidate mask against a reference. `null` when either has no
|
|
436
|
+
* foreground at all — unscoreable is not the same as scoring zero.
|
|
437
|
+
*
|
|
438
|
+
* `scaleAware` is the caller's promise that both masks are in millimetres; it takes
|
|
439
|
+
* effect only when both actually carry a finite `mmPerPx`.
|
|
440
|
+
*/
|
|
441
|
+
export function matchMasks(
|
|
442
|
+
candidate: SilhouetteMask | null | undefined,
|
|
443
|
+
reference: SilhouetteMask | null | undefined,
|
|
444
|
+
opts?: { scaleAware?: boolean },
|
|
445
|
+
): MatchScores | null;
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Score every view's mask against one reference and name the best. Unscoreable
|
|
449
|
+
* views are left out of `views` entirely; `best` is `null` when none scored.
|
|
450
|
+
*/
|
|
451
|
+
export function matchViews(
|
|
452
|
+
viewMasks: Record<string, SilhouetteMask | null>,
|
|
453
|
+
reference: SilhouetteMask | null | undefined,
|
|
454
|
+
opts?: { scaleAware?: boolean },
|
|
455
|
+
): { best: ({ view: string } & MatchScores) | null; views: Record<string, number> };
|
|
456
|
+
|
|
342
457
|
// --- rendering --------------------------------------------------------------
|
|
343
458
|
|
|
344
459
|
/** The canonical angle names `renderViews` accepts. */
|