editmamei 0.19.0 → 0.22.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/NOTICES.md +17 -1
- package/README.md +9 -8
- package/dist/api/extendscript/_helpers.js +1 -1
- package/dist/bin/editmamei-core-darwin-arm64 +0 -0
- package/dist/bin/editmamei-core-darwin-x64 +0 -0
- package/dist/bin/editmamei-core-win-x64.exe +0 -0
- package/dist/cli/config.js +3 -3
- package/dist/cli/help.js +4 -0
- package/dist/cli/install.js +2 -2
- package/dist/cli/report.js +11 -0
- package/dist/cli/router.js +20 -0
- package/dist/core/server.js +8 -8
- package/dist/core/settings.js +2 -2
- package/dist/core/tool-groups.js +92 -82
- package/dist/core/tool-tiers.js +91 -81
- package/dist/detection/landmark-detection-client.js +8 -0
- package/dist/detection/mesh-eye-correction.js +126 -0
- package/dist/detection/sam-segmenter.js +83 -0
- package/dist/diagnostics/collect.js +169 -0
- package/dist/index.js +2 -0
- package/dist/license/entitlement.js +52 -0
- package/dist/modules/ce/index.js +6 -4
- package/dist/perception/grounding-anchors.js +37 -0
- package/dist/perception/grounding-corners.js +93 -0
- package/dist/perception/grounding-edge-trace.js +90 -0
- package/dist/perception/grounding-extrema.js +41 -0
- package/dist/perception/grounding-gate.js +154 -0
- package/dist/perception/grounding-geometry.js +132 -0
- package/dist/perception/grounding-grid.js +34 -0
- package/dist/perception/grounding-landmarks.js +40 -0
- package/dist/perception/grounding-locate.js +341 -0
- package/dist/perception/grounding-resolver.js +202 -0
- package/dist/perception/grounding-review-crop.js +85 -0
- package/dist/perception/grounding-warp.js +182 -0
- package/dist/perception/sky-mask-transfer.js +50 -18
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/tools/adjustment-tools.js +3 -3
- package/dist/tools/brush-tools.js +111 -23
- package/dist/tools/channel-compose-tools.js +241 -0
- package/dist/tools/detection-tools.js +1 -1
- package/dist/tools/diagnostics-tools.js +66 -0
- package/dist/tools/document-tools.js +6 -6
- package/dist/tools/filter-tools.js +476 -19
- package/dist/tools/group-tools.js +10 -10
- package/dist/tools/guide-tools.js +4 -4
- package/dist/tools/history-tools.js +4 -4
- package/dist/tools/image-placement-tools.js +1 -1
- package/dist/tools/image-tools.js +62 -19
- package/dist/tools/inspect-tools.js +2 -2
- package/dist/tools/layer-ordering-tools.js +1 -1
- package/dist/tools/layer-properties-tools.js +42 -23
- package/dist/tools/layer-tools.js +10 -10
- package/dist/tools/layer-transform-tools.js +80 -41
- package/dist/tools/overview-tools.js +42 -42
- package/dist/tools/path-tools.js +53 -10
- package/dist/tools/portrait-tools.js +3 -3
- package/dist/tools/preview-tools.js +7 -82
- package/dist/tools/retouch-tools.js +93 -25
- package/dist/tools/scene-tools.js +13 -13
- package/dist/tools/selection-tools.js +319 -40
- package/dist/tools/shape-tools.js +216 -0
- package/dist/tools/text-tools.js +1 -1
- package/dist/tools/transform-canvas-tools.js +2 -2
- package/dist/tools/vector-mask-tools.js +23 -8
- package/dist/utils/log-buffer.js +41 -0
- package/dist/utils/logger.js +12 -1
- package/dist/utils/session-log-reader.js +27 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/preview/coordinate-grid.js +0 -250
- package/dist/tools/object-tools.js +0 -193
- package/dist/tools/text-on-object-tools.js +0 -225
package/dist/modules/ce/index.js
CHANGED
|
@@ -17,17 +17,18 @@ import { createLayerOrderingTools } from '../../tools/layer-ordering-tools.js';
|
|
|
17
17
|
import { createPreviewTools } from '../../tools/preview-tools.js';
|
|
18
18
|
import { createInspectTools } from '../../tools/inspect-tools.js';
|
|
19
19
|
import { createOverviewTools } from '../../tools/overview-tools.js';
|
|
20
|
+
import { createDiagnosticsTools } from '../../tools/diagnostics-tools.js';
|
|
20
21
|
import { createRetouchTools } from '../../tools/retouch-tools.js';
|
|
21
22
|
import { createBrushTools } from '../../tools/brush-tools.js';
|
|
22
23
|
import { createTransformCanvasTools } from '../../tools/transform-canvas-tools.js';
|
|
23
24
|
import { createGuideTools } from '../../tools/guide-tools.js';
|
|
24
25
|
import { createDetectionTools } from '../../tools/detection-tools.js';
|
|
25
|
-
import { createObjectTools } from '../../tools/object-tools.js';
|
|
26
26
|
import { createPortraitTools } from '../../tools/portrait-tools.js';
|
|
27
|
-
import { createTextOnObjectTools } from '../../tools/text-on-object-tools.js';
|
|
28
27
|
import { createSceneTools } from '../../tools/scene-tools.js';
|
|
29
28
|
import { createPathTools } from '../../tools/path-tools.js';
|
|
30
29
|
import { createVectorMaskTools } from '../../tools/vector-mask-tools.js';
|
|
30
|
+
import { createChannelComposeTools } from '../../tools/channel-compose-tools.js';
|
|
31
|
+
import { createShapeTools } from '../../tools/shape-tools.js';
|
|
31
32
|
const ceFactories = [
|
|
32
33
|
createDocumentTools,
|
|
33
34
|
createLayerTools,
|
|
@@ -45,16 +46,17 @@ const ceFactories = [
|
|
|
45
46
|
createPreviewTools,
|
|
46
47
|
createInspectTools,
|
|
47
48
|
createOverviewTools,
|
|
49
|
+
createDiagnosticsTools,
|
|
48
50
|
createRetouchTools,
|
|
49
51
|
createBrushTools,
|
|
50
52
|
createTransformCanvasTools,
|
|
51
53
|
createGuideTools,
|
|
52
54
|
createDetectionTools,
|
|
53
|
-
createObjectTools,
|
|
54
55
|
createPortraitTools,
|
|
55
|
-
createTextOnObjectTools,
|
|
56
56
|
createPathTools,
|
|
57
57
|
createVectorMaskTools,
|
|
58
|
+
createChannelComposeTools,
|
|
59
|
+
createShapeTools,
|
|
58
60
|
];
|
|
59
61
|
export const ceModule = {
|
|
60
62
|
manifest: { id: 'ce', name: 'Editmamei Community Tools', abi: KERNEL_ABI },
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export function bboxToBox(b) {
|
|
2
|
+
return { left: b[0], top: b[1], right: b[2], bottom: b[3] };
|
|
3
|
+
}
|
|
4
|
+
const cx = (b) => (b[0] + b[2]) / 2;
|
|
5
|
+
const cy = (b) => (b[1] + b[3]) / 2;
|
|
6
|
+
const area = (b) => (b[2] - b[0]) * (b[3] - b[1]);
|
|
7
|
+
function orderBy(items, pick) {
|
|
8
|
+
const cmp = {
|
|
9
|
+
leftmost: (a, b) => cx(a.bbox) - cx(b.bbox),
|
|
10
|
+
rightmost: (a, b) => cx(b.bbox) - cx(a.bbox),
|
|
11
|
+
topmost: (a, b) => cy(a.bbox) - cy(b.bbox),
|
|
12
|
+
bottommost: (a, b) => cy(b.bbox) - cy(a.bbox),
|
|
13
|
+
largest: (a, b) => area(b.bbox) - area(a.bbox),
|
|
14
|
+
smallest: (a, b) => area(a.bbox) - area(b.bbox),
|
|
15
|
+
};
|
|
16
|
+
return pick === 'confidence' ? items : [...items].sort(cmp[pick]);
|
|
17
|
+
}
|
|
18
|
+
export function localizeAnchor(det, sel) {
|
|
19
|
+
const candidates = sel.kind === 'face'
|
|
20
|
+
? (det.faces ?? []).map((f) => ({ bbox: f.bbox }))
|
|
21
|
+
: (det.objects ?? []).filter((o) => o.label === sel.label).map((o) => ({ bbox: o.bbox }));
|
|
22
|
+
const ordered = orderBy(candidates, sel.pick ?? 'confidence');
|
|
23
|
+
const c = ordered[sel.instance ?? 0];
|
|
24
|
+
return c ? { kind: 'box', box: bboxToBox(c.bbox) } : null;
|
|
25
|
+
}
|
|
26
|
+
export function localizeAnchors(det, selectors) {
|
|
27
|
+
const anchors = {};
|
|
28
|
+
const missing = [];
|
|
29
|
+
for (const [id, sel] of Object.entries(selectors)) {
|
|
30
|
+
const prim = localizeAnchor(det, sel);
|
|
31
|
+
if (prim)
|
|
32
|
+
anchors[id] = prim;
|
|
33
|
+
else
|
|
34
|
+
missing.push(id);
|
|
35
|
+
}
|
|
36
|
+
return { anchors, missing };
|
|
37
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { dist } from './grounding-geometry.js';
|
|
2
|
+
const clampi = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
|
|
3
|
+
export function findCorners(img, region, opts = {}) {
|
|
4
|
+
const k = opts.k ?? 0.05;
|
|
5
|
+
const wr = Math.max(1, opts.windowRadius ?? 2);
|
|
6
|
+
const nms = Math.max(1, opts.nmsRadius ?? 3);
|
|
7
|
+
const relT = opts.relThreshold ?? 0.05;
|
|
8
|
+
const cap = opts.max ?? 32;
|
|
9
|
+
const rx0 = clampi(Math.round(region.left), 0, img.width - 1);
|
|
10
|
+
const ry0 = clampi(Math.round(region.top), 0, img.height - 1);
|
|
11
|
+
const rx1 = clampi(Math.round(region.right), 0, img.width - 1);
|
|
12
|
+
const ry1 = clampi(Math.round(region.bottom), 0, img.height - 1);
|
|
13
|
+
const rw = rx1 - rx0 + 1, rh = ry1 - ry0 + 1;
|
|
14
|
+
if (rw < 3 || rh < 3)
|
|
15
|
+
return [];
|
|
16
|
+
const lum = new Float32Array(rw * rh);
|
|
17
|
+
for (let y = 0; y < rh; y++)
|
|
18
|
+
for (let x = 0; x < rw; x++) {
|
|
19
|
+
const i = ((ry0 + y) * img.width + (rx0 + x)) * 4;
|
|
20
|
+
lum[y * rw + x] = 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2];
|
|
21
|
+
}
|
|
22
|
+
const L = (x, y) => lum[clampi(y, 0, rh - 1) * rw + clampi(x, 0, rw - 1)];
|
|
23
|
+
const Ix = new Float32Array(rw * rh), Iy = new Float32Array(rw * rh);
|
|
24
|
+
for (let y = 0; y < rh; y++)
|
|
25
|
+
for (let x = 0; x < rw; x++) {
|
|
26
|
+
Ix[y * rw + x] =
|
|
27
|
+
-L(x - 1, y - 1) -
|
|
28
|
+
2 * L(x - 1, y) -
|
|
29
|
+
L(x - 1, y + 1) +
|
|
30
|
+
L(x + 1, y - 1) +
|
|
31
|
+
2 * L(x + 1, y) +
|
|
32
|
+
L(x + 1, y + 1);
|
|
33
|
+
Iy[y * rw + x] =
|
|
34
|
+
-L(x - 1, y - 1) -
|
|
35
|
+
2 * L(x, y - 1) -
|
|
36
|
+
L(x + 1, y - 1) +
|
|
37
|
+
L(x - 1, y + 1) +
|
|
38
|
+
2 * L(x, y + 1) +
|
|
39
|
+
L(x + 1, y + 1);
|
|
40
|
+
}
|
|
41
|
+
const R = new Float32Array(rw * rh);
|
|
42
|
+
let maxR = 0;
|
|
43
|
+
for (let y = 0; y < rh; y++)
|
|
44
|
+
for (let x = 0; x < rw; x++) {
|
|
45
|
+
let a = 0, b = 0, c = 0;
|
|
46
|
+
for (let dy = -wr; dy <= wr; dy++)
|
|
47
|
+
for (let dx = -wr; dx <= wr; dx++) {
|
|
48
|
+
const xx = clampi(x + dx, 0, rw - 1), yy = clampi(y + dy, 0, rh - 1);
|
|
49
|
+
const gx = Ix[yy * rw + xx], gy = Iy[yy * rw + xx];
|
|
50
|
+
a += gx * gx;
|
|
51
|
+
b += gx * gy;
|
|
52
|
+
c += gy * gy;
|
|
53
|
+
}
|
|
54
|
+
const r = a * c - b * b - k * (a + c) * (a + c);
|
|
55
|
+
R[y * rw + x] = r;
|
|
56
|
+
if (r > maxR)
|
|
57
|
+
maxR = r;
|
|
58
|
+
}
|
|
59
|
+
if (maxR <= 0)
|
|
60
|
+
return [];
|
|
61
|
+
const threshold = relT * maxR;
|
|
62
|
+
const corners = [];
|
|
63
|
+
for (let y = 0; y < rh; y++)
|
|
64
|
+
for (let x = 0; x < rw; x++) {
|
|
65
|
+
const r = R[y * rw + x];
|
|
66
|
+
if (r < threshold)
|
|
67
|
+
continue;
|
|
68
|
+
let isMax = true;
|
|
69
|
+
for (let dy = -nms; dy <= nms && isMax; dy++)
|
|
70
|
+
for (let dx = -nms; dx <= nms; dx++) {
|
|
71
|
+
const xx = x + dx, yy = y + dy;
|
|
72
|
+
if (xx < 0 || yy < 0 || xx >= rw || yy >= rh)
|
|
73
|
+
continue;
|
|
74
|
+
if (R[yy * rw + xx] > r) {
|
|
75
|
+
isMax = false;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (isMax)
|
|
80
|
+
corners.push({ point: { x: rx0 + x, y: ry0 + y }, response: r });
|
|
81
|
+
}
|
|
82
|
+
corners.sort((p, q) => q.response - p.response);
|
|
83
|
+
return corners.slice(0, cap);
|
|
84
|
+
}
|
|
85
|
+
export function cornerAnchor(img, region, opts = {}) {
|
|
86
|
+
const corners = findCorners(img, region, opts);
|
|
87
|
+
if (corners.length === 0)
|
|
88
|
+
return null;
|
|
89
|
+
const chosen = opts.near
|
|
90
|
+
? corners.reduce((best, c) => dist(c.point, opts.near) < dist(best.point, opts.near) ? c : best)
|
|
91
|
+
: corners[0];
|
|
92
|
+
return { kind: 'point', point: chosen.point };
|
|
93
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const lumaAt = (img, x, y) => {
|
|
2
|
+
const i = (y * img.width + x) * 4;
|
|
3
|
+
return 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2];
|
|
4
|
+
};
|
|
5
|
+
function bandDeltaCol(img, x, y, band) {
|
|
6
|
+
let a = 0, b = 0, na = 0, nb = 0;
|
|
7
|
+
for (let k = 1; k <= band; k++) {
|
|
8
|
+
if (y - k >= 0) {
|
|
9
|
+
a += lumaAt(img, x, y - k);
|
|
10
|
+
na++;
|
|
11
|
+
}
|
|
12
|
+
if (y + k < img.height) {
|
|
13
|
+
b += lumaAt(img, x, y + k);
|
|
14
|
+
nb++;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (na === 0 || nb === 0)
|
|
18
|
+
return 0;
|
|
19
|
+
return Math.abs(a / na - b / nb);
|
|
20
|
+
}
|
|
21
|
+
function bandDeltaRow(img, x, y, band) {
|
|
22
|
+
let a = 0, b = 0, na = 0, nb = 0;
|
|
23
|
+
for (let k = 1; k <= band; k++) {
|
|
24
|
+
if (x - k >= 0) {
|
|
25
|
+
a += lumaAt(img, x - k, y);
|
|
26
|
+
na++;
|
|
27
|
+
}
|
|
28
|
+
if (x + k < img.width) {
|
|
29
|
+
b += lumaAt(img, x + k, y);
|
|
30
|
+
nb++;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (na === 0 || nb === 0)
|
|
34
|
+
return 0;
|
|
35
|
+
return Math.abs(a / na - b / nb);
|
|
36
|
+
}
|
|
37
|
+
const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
|
|
38
|
+
export function traceEdge(img, opts) {
|
|
39
|
+
const r = opts.region;
|
|
40
|
+
const left = clamp(Math.round(r.left), 0, img.width - 1);
|
|
41
|
+
const right = clamp(Math.round(r.right), 0, img.width - 1);
|
|
42
|
+
const top = clamp(Math.round(r.top), 0, img.height - 1);
|
|
43
|
+
const bottom = clamp(Math.round(r.bottom), 0, img.height - 1);
|
|
44
|
+
const samples = Math.max(2, opts.samples ?? 24);
|
|
45
|
+
const polyline = [];
|
|
46
|
+
const confidences = [];
|
|
47
|
+
if (opts.orientation === 'horizontal-edge') {
|
|
48
|
+
const band = opts.band ?? Math.max(3, Math.round((bottom - top) / 20));
|
|
49
|
+
for (let i = 0; i < samples; i++) {
|
|
50
|
+
const x = Math.round(left + ((i + 0.5) / samples) * (right - left));
|
|
51
|
+
let bestY = -1, best = -1;
|
|
52
|
+
for (let y = top + band; y <= bottom - band; y++) {
|
|
53
|
+
const d = bandDeltaCol(img, x, y, band);
|
|
54
|
+
if (d > best) {
|
|
55
|
+
best = d;
|
|
56
|
+
bestY = y;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (bestY < 0)
|
|
60
|
+
continue;
|
|
61
|
+
polyline.push({ x, y: bestY });
|
|
62
|
+
confidences.push(Math.round(best));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const band = opts.band ?? Math.max(3, Math.round((right - left) / 20));
|
|
67
|
+
for (let i = 0; i < samples; i++) {
|
|
68
|
+
const y = Math.round(top + ((i + 0.5) / samples) * (bottom - top));
|
|
69
|
+
let bestX = -1, best = -1;
|
|
70
|
+
for (let x = left + band; x <= right - band; x++) {
|
|
71
|
+
const d = bandDeltaRow(img, x, y, band);
|
|
72
|
+
if (d > best) {
|
|
73
|
+
best = d;
|
|
74
|
+
bestX = x;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (bestX < 0)
|
|
78
|
+
continue;
|
|
79
|
+
polyline.push({ x: bestX, y });
|
|
80
|
+
confidences.push(Math.round(best));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const meanConfidence = confidences.length
|
|
84
|
+
? confidences.reduce((s, v) => s + v, 0) / confidences.length
|
|
85
|
+
: 0;
|
|
86
|
+
return { polyline, confidences, meanConfidence };
|
|
87
|
+
}
|
|
88
|
+
export function edgeAnchor(edge) {
|
|
89
|
+
return { kind: 'polyline', polyline: edge.polyline };
|
|
90
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const clampi = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
|
|
2
|
+
function windowMean(img, x, y, w, measure) {
|
|
3
|
+
let sum = 0, n = 0;
|
|
4
|
+
for (let dy = -w; dy <= w; dy++) {
|
|
5
|
+
for (let dx = -w; dx <= w; dx++) {
|
|
6
|
+
const px = clampi(x + dx, 0, img.width - 1), py = clampi(y + dy, 0, img.height - 1);
|
|
7
|
+
const i = (py * img.width + px) * 4;
|
|
8
|
+
const r = img.data[i], g = img.data[i + 1], b = img.data[i + 2];
|
|
9
|
+
const v = measure === 'most-saturated'
|
|
10
|
+
? Math.max(r, g, b) - Math.min(r, g, b)
|
|
11
|
+
: 0.299 * r + 0.587 * g + 0.114 * b;
|
|
12
|
+
sum += v;
|
|
13
|
+
n++;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return sum / n;
|
|
17
|
+
}
|
|
18
|
+
export function findExtremum(img, region, measure, opts = {}) {
|
|
19
|
+
const stride = Math.max(1, opts.stride ?? 2);
|
|
20
|
+
const w = Math.max(0, opts.window ?? 1);
|
|
21
|
+
const left = clampi(Math.round(region.left), 0, img.width - 1);
|
|
22
|
+
const right = clampi(Math.round(region.right), 0, img.width - 1);
|
|
23
|
+
const top = clampi(Math.round(region.top), 0, img.height - 1);
|
|
24
|
+
const bottom = clampi(Math.round(region.bottom), 0, img.height - 1);
|
|
25
|
+
const minimize = measure === 'darkest';
|
|
26
|
+
let best = minimize ? Infinity : -Infinity;
|
|
27
|
+
let bestPt = { x: left, y: top };
|
|
28
|
+
for (let y = top; y <= bottom; y += stride) {
|
|
29
|
+
for (let x = left; x <= right; x += stride) {
|
|
30
|
+
const v = windowMean(img, x, y, w, measure);
|
|
31
|
+
if (minimize ? v < best : v > best) {
|
|
32
|
+
best = v;
|
|
33
|
+
bestPt = { x, y };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { point: bestPt, value: Math.round(best) };
|
|
38
|
+
}
|
|
39
|
+
export function extremumAnchor(img, region, measure, opts) {
|
|
40
|
+
return { kind: 'point', point: findExtremum(img, region, measure, opts).point };
|
|
41
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { boundsOf, dist, minSignedGap, polygonArea, pointInBox, polylineIntersectsRegion, } from './grounding-geometry.js';
|
|
2
|
+
const inCanvas = (p, c) => p.x >= 0 && p.x <= c.width && p.y >= 0 && p.y <= c.height;
|
|
3
|
+
function gatePoint(spec) {
|
|
4
|
+
const { point: p, canvas, withinBounds, anchors } = spec;
|
|
5
|
+
const minSep = spec.minAnchorSeparation ?? 2;
|
|
6
|
+
const sep = anchors ? dist(anchors[0], anchors[1]) : undefined;
|
|
7
|
+
const measured = { x: p.x, y: p.y };
|
|
8
|
+
if (sep !== undefined)
|
|
9
|
+
measured.anchorSeparation = sep;
|
|
10
|
+
if (!inCanvas(p, canvas)) {
|
|
11
|
+
return {
|
|
12
|
+
pass: false,
|
|
13
|
+
reason: `point (${Math.round(p.x)},${Math.round(p.y)}) outside canvas ${canvas.width}x${canvas.height}`,
|
|
14
|
+
measured,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
if (sep !== undefined && sep < minSep) {
|
|
18
|
+
return {
|
|
19
|
+
pass: false,
|
|
20
|
+
reason: `degenerate relation — anchors ${sep.toFixed(1)}px apart (< ${minSep}px)`,
|
|
21
|
+
measured,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (withinBounds && !pointInBox(p, withinBounds)) {
|
|
25
|
+
return { pass: false, reason: 'point outside the expected region for this relation', measured };
|
|
26
|
+
}
|
|
27
|
+
return { pass: true, measured };
|
|
28
|
+
}
|
|
29
|
+
function gateOffsetCurve(spec) {
|
|
30
|
+
const { curve, source, side, requiredMargin, exclusion, canvas } = spec;
|
|
31
|
+
const minMargin = minSignedGap(curve, source, side);
|
|
32
|
+
const sideOk = minMargin > 0;
|
|
33
|
+
const intersects = exclusion ? polylineIntersectsRegion(curve, exclusion) : false;
|
|
34
|
+
const outOfCanvas = curve.some((p) => !inCanvas(p, canvas));
|
|
35
|
+
const measured = {
|
|
36
|
+
minMargin: Math.round(minMargin * 100) / 100,
|
|
37
|
+
requiredMargin,
|
|
38
|
+
side,
|
|
39
|
+
sideOk,
|
|
40
|
+
intersectsExclusion: intersects,
|
|
41
|
+
};
|
|
42
|
+
if (!sideOk) {
|
|
43
|
+
return {
|
|
44
|
+
pass: false,
|
|
45
|
+
reason: `offset-curve on the wrong side of the source (worst gap ${minMargin.toFixed(1)}px on '${side}')`,
|
|
46
|
+
measured,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (minMargin < requiredMargin) {
|
|
50
|
+
return {
|
|
51
|
+
pass: false,
|
|
52
|
+
reason: `offset-curve clearance ${minMargin.toFixed(1)}px < required ${requiredMargin}px (overlaps feature)`,
|
|
53
|
+
measured,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (intersects) {
|
|
57
|
+
return { pass: false, reason: 'offset-curve enters the exclusion region', measured };
|
|
58
|
+
}
|
|
59
|
+
if (outOfCanvas) {
|
|
60
|
+
return { pass: false, reason: 'offset-curve runs off canvas', measured };
|
|
61
|
+
}
|
|
62
|
+
return { pass: true, measured };
|
|
63
|
+
}
|
|
64
|
+
function gateEdge(spec) {
|
|
65
|
+
const { curve, confidences, minConfidence, canvas } = spec;
|
|
66
|
+
const minConf = confidences.length ? Math.min(...confidences) : 0;
|
|
67
|
+
const outOfCanvas = curve.some((p) => !inCanvas(p, canvas));
|
|
68
|
+
const measured = { minConfidence: minConf, required: minConfidence };
|
|
69
|
+
if (confidences.length !== curve.length) {
|
|
70
|
+
return {
|
|
71
|
+
pass: false,
|
|
72
|
+
reason: `confidence count ${confidences.length} != curve points ${curve.length}`,
|
|
73
|
+
measured,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (minConf < minConfidence) {
|
|
77
|
+
return {
|
|
78
|
+
pass: false,
|
|
79
|
+
reason: `edge confidence ${minConf} < required ${minConfidence} (weak/absent boundary)`,
|
|
80
|
+
measured,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (outOfCanvas) {
|
|
84
|
+
return { pass: false, reason: 'edge runs off canvas', measured };
|
|
85
|
+
}
|
|
86
|
+
return { pass: true, measured };
|
|
87
|
+
}
|
|
88
|
+
function gateCurve(spec) {
|
|
89
|
+
const { curve, canvas } = spec;
|
|
90
|
+
const minLength = spec.minLength ?? 2;
|
|
91
|
+
let length = 0;
|
|
92
|
+
for (let k = 0; k < curve.length - 1; k++)
|
|
93
|
+
length += Math.hypot(curve[k + 1].x - curve[k].x, curve[k + 1].y - curve[k].y);
|
|
94
|
+
const outOfCanvas = curve.some((p) => !inCanvas(p, canvas));
|
|
95
|
+
const measured = {
|
|
96
|
+
points: curve.length,
|
|
97
|
+
length: Math.round(length),
|
|
98
|
+
on_canvas: !outOfCanvas,
|
|
99
|
+
};
|
|
100
|
+
if (curve.length < 2)
|
|
101
|
+
return { pass: false, reason: 'curve has fewer than 2 points', measured };
|
|
102
|
+
if (length < minLength)
|
|
103
|
+
return {
|
|
104
|
+
pass: false,
|
|
105
|
+
reason: `curve length ${Math.round(length)} < required ${minLength} (degenerate)`,
|
|
106
|
+
measured,
|
|
107
|
+
};
|
|
108
|
+
if (outOfCanvas)
|
|
109
|
+
return { pass: false, reason: 'curve runs off canvas', measured };
|
|
110
|
+
return { pass: true, measured };
|
|
111
|
+
}
|
|
112
|
+
function gateRegion(spec) {
|
|
113
|
+
const { polygon, canvas } = spec;
|
|
114
|
+
const maxFrac = spec.maxAreaFraction ?? 0.9;
|
|
115
|
+
const area = polygonArea(polygon);
|
|
116
|
+
const frameArea = canvas.width * canvas.height;
|
|
117
|
+
const areaFraction = frameArea > 0 ? area / frameArea : 0;
|
|
118
|
+
const b = polygon.length ? boundsOf(polygon) : undefined;
|
|
119
|
+
const outOfCanvas = b
|
|
120
|
+
? b.left < 0 || b.top < 0 || b.right > canvas.width || b.bottom > canvas.height
|
|
121
|
+
: true;
|
|
122
|
+
const measured = {
|
|
123
|
+
area: Math.round(area),
|
|
124
|
+
areaFraction: Math.round(areaFraction * 1000) / 1000,
|
|
125
|
+
};
|
|
126
|
+
if (area <= 0) {
|
|
127
|
+
return { pass: false, reason: 'empty region', measured };
|
|
128
|
+
}
|
|
129
|
+
if (outOfCanvas) {
|
|
130
|
+
return { pass: false, reason: 'region extends off canvas', measured };
|
|
131
|
+
}
|
|
132
|
+
if (areaFraction > maxFrac) {
|
|
133
|
+
return {
|
|
134
|
+
pass: false,
|
|
135
|
+
reason: `region ${(areaFraction * 100).toFixed(0)}% of frame — implausibly large for the relation`,
|
|
136
|
+
measured,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return { pass: true, measured };
|
|
140
|
+
}
|
|
141
|
+
export function runGate(spec) {
|
|
142
|
+
switch (spec.target) {
|
|
143
|
+
case 'point':
|
|
144
|
+
return gatePoint(spec);
|
|
145
|
+
case 'region':
|
|
146
|
+
return gateRegion(spec);
|
|
147
|
+
case 'path':
|
|
148
|
+
return spec.kind === 'offset-curve'
|
|
149
|
+
? gateOffsetCurve(spec)
|
|
150
|
+
: spec.kind === 'edge'
|
|
151
|
+
? gateEdge(spec)
|
|
152
|
+
: gateCurve(spec);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
export function dist(a, b) {
|
|
2
|
+
return Math.hypot(a.x - b.x, a.y - b.y);
|
|
3
|
+
}
|
|
4
|
+
export function boundsOf(poly) {
|
|
5
|
+
if (poly.length === 0)
|
|
6
|
+
throw new Error('boundsOf: empty polyline');
|
|
7
|
+
let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
|
|
8
|
+
for (const p of poly) {
|
|
9
|
+
if (p.x < left)
|
|
10
|
+
left = p.x;
|
|
11
|
+
if (p.x > right)
|
|
12
|
+
right = p.x;
|
|
13
|
+
if (p.y < top)
|
|
14
|
+
top = p.y;
|
|
15
|
+
if (p.y > bottom)
|
|
16
|
+
bottom = p.y;
|
|
17
|
+
}
|
|
18
|
+
return { left, top, right, bottom };
|
|
19
|
+
}
|
|
20
|
+
export function interpolateCurveAt(curve, at, axis) {
|
|
21
|
+
if (curve.length === 0)
|
|
22
|
+
throw new Error('interpolateCurveAt: empty curve');
|
|
23
|
+
const other = axis === 'x' ? 'y' : 'x';
|
|
24
|
+
if (curve.length === 1)
|
|
25
|
+
return curve[0][other];
|
|
26
|
+
const sorted = [...curve].sort((a, b) => a[axis] - b[axis]);
|
|
27
|
+
if (at <= sorted[0][axis])
|
|
28
|
+
return sorted[0][other];
|
|
29
|
+
const last = sorted[sorted.length - 1];
|
|
30
|
+
if (at >= last[axis])
|
|
31
|
+
return last[other];
|
|
32
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
33
|
+
const a = sorted[i], b = sorted[i + 1];
|
|
34
|
+
if (at >= a[axis] && at <= b[axis]) {
|
|
35
|
+
const span = b[axis] - a[axis];
|
|
36
|
+
if (span === 0)
|
|
37
|
+
return a[other];
|
|
38
|
+
const t = (at - a[axis]) / span;
|
|
39
|
+
return a[other] + t * (b[other] - a[other]);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return last[other];
|
|
43
|
+
}
|
|
44
|
+
export function signedGapToCurve(point, curve, side) {
|
|
45
|
+
switch (side) {
|
|
46
|
+
case 'down':
|
|
47
|
+
return point.y - interpolateCurveAt(curve, point.x, 'x');
|
|
48
|
+
case 'up':
|
|
49
|
+
return interpolateCurveAt(curve, point.x, 'x') - point.y;
|
|
50
|
+
case 'right':
|
|
51
|
+
return point.x - interpolateCurveAt(curve, point.y, 'y');
|
|
52
|
+
case 'left':
|
|
53
|
+
return interpolateCurveAt(curve, point.y, 'y') - point.x;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function minSignedGap(offset, source, side) {
|
|
57
|
+
if (offset.length === 0)
|
|
58
|
+
throw new Error('minSignedGap: empty offset curve');
|
|
59
|
+
let min = Infinity;
|
|
60
|
+
for (const p of offset) {
|
|
61
|
+
const g = signedGapToCurve(p, source, side);
|
|
62
|
+
if (g < min)
|
|
63
|
+
min = g;
|
|
64
|
+
}
|
|
65
|
+
return min;
|
|
66
|
+
}
|
|
67
|
+
export function polygonArea(poly) {
|
|
68
|
+
if (poly.length < 3)
|
|
69
|
+
return 0;
|
|
70
|
+
let sum = 0;
|
|
71
|
+
for (let i = 0; i < poly.length; i++) {
|
|
72
|
+
const a = poly[i], b = poly[(i + 1) % poly.length];
|
|
73
|
+
sum += a.x * b.y - b.x * a.y;
|
|
74
|
+
}
|
|
75
|
+
return Math.abs(sum) / 2;
|
|
76
|
+
}
|
|
77
|
+
export function pointInBox(p, box) {
|
|
78
|
+
return p.x >= box.left && p.x <= box.right && p.y >= box.top && p.y <= box.bottom;
|
|
79
|
+
}
|
|
80
|
+
export function pointInPolygon(p, poly) {
|
|
81
|
+
let inside = false;
|
|
82
|
+
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
|
83
|
+
const a = poly[i], b = poly[j];
|
|
84
|
+
const intersects = a.y > p.y !== b.y > p.y && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x;
|
|
85
|
+
if (intersects)
|
|
86
|
+
inside = !inside;
|
|
87
|
+
}
|
|
88
|
+
return inside;
|
|
89
|
+
}
|
|
90
|
+
function pointInRegion(p, region) {
|
|
91
|
+
return region.kind === 'box' ? pointInBox(p, region.box) : pointInPolygon(p, region.polygon);
|
|
92
|
+
}
|
|
93
|
+
export function segmentsIntersect(p1, p2, p3, p4) {
|
|
94
|
+
const o = (a, b, c) => Math.sign((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x));
|
|
95
|
+
const o1 = o(p1, p2, p3), o2 = o(p1, p2, p4), o3 = o(p3, p4, p1), o4 = o(p3, p4, p2);
|
|
96
|
+
return o1 !== o2 && o3 !== o4;
|
|
97
|
+
}
|
|
98
|
+
function regionEdges(region) {
|
|
99
|
+
if (region.kind === 'box') {
|
|
100
|
+
const { left: l, top: t, right: r, bottom: b } = region.box;
|
|
101
|
+
const c = [
|
|
102
|
+
{ x: l, y: t },
|
|
103
|
+
{ x: r, y: t },
|
|
104
|
+
{ x: r, y: b },
|
|
105
|
+
{ x: l, y: b },
|
|
106
|
+
];
|
|
107
|
+
return [
|
|
108
|
+
[c[0], c[1]],
|
|
109
|
+
[c[1], c[2]],
|
|
110
|
+
[c[2], c[3]],
|
|
111
|
+
[c[3], c[0]],
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
const poly = region.polygon;
|
|
115
|
+
const edges = [];
|
|
116
|
+
for (let i = 0; i < poly.length; i++)
|
|
117
|
+
edges.push([poly[i], poly[(i + 1) % poly.length]]);
|
|
118
|
+
return edges;
|
|
119
|
+
}
|
|
120
|
+
export function polylineIntersectsRegion(poly, region) {
|
|
121
|
+
for (const p of poly)
|
|
122
|
+
if (pointInRegion(p, region))
|
|
123
|
+
return true;
|
|
124
|
+
const edges = regionEdges(region);
|
|
125
|
+
for (let i = 0; i < poly.length - 1; i++) {
|
|
126
|
+
for (const [e1, e2] of edges) {
|
|
127
|
+
if (segmentsIntersect(poly[i], poly[i + 1], e1, e2))
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const NAMED = {
|
|
2
|
+
center: [0.5, 0.5],
|
|
3
|
+
'upper-left': [1 / 3, 1 / 3],
|
|
4
|
+
'upper-center': [0.5, 1 / 3],
|
|
5
|
+
'upper-right': [2 / 3, 1 / 3],
|
|
6
|
+
'center-left': [1 / 3, 0.5],
|
|
7
|
+
'center-right': [2 / 3, 0.5],
|
|
8
|
+
'lower-left': [1 / 3, 2 / 3],
|
|
9
|
+
'lower-center': [0.5, 2 / 3],
|
|
10
|
+
'lower-right': [2 / 3, 2 / 3],
|
|
11
|
+
};
|
|
12
|
+
export function frameBox(width, height) {
|
|
13
|
+
return { left: 0, top: 0, right: width, bottom: height };
|
|
14
|
+
}
|
|
15
|
+
export function gridPoint(box, name) {
|
|
16
|
+
let fx, fy;
|
|
17
|
+
if (name.startsWith('frac:')) {
|
|
18
|
+
const [a, b] = name.slice('frac:'.length).split(',');
|
|
19
|
+
fx = Number(a);
|
|
20
|
+
fy = Number(b);
|
|
21
|
+
if (Number.isNaN(fx) || Number.isNaN(fy))
|
|
22
|
+
throw new Error(`gridPoint: malformed '${name}' (want frac:fx,fy)`);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
const f = NAMED[name];
|
|
26
|
+
if (!f)
|
|
27
|
+
throw new Error(`gridPoint: unknown position '${name}' (want ${Object.keys(NAMED).join('|')} or frac:fx,fy)`);
|
|
28
|
+
[fx, fy] = f;
|
|
29
|
+
}
|
|
30
|
+
return { x: box.left + fx * (box.right - box.left), y: box.top + fy * (box.bottom - box.top) };
|
|
31
|
+
}
|
|
32
|
+
export function gridAnchor(box, name) {
|
|
33
|
+
return { kind: 'point', point: gridPoint(box, name) };
|
|
34
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { LANDMARK_GROUPS, LANDMARK_ANCHORS, LANDMARK_COUNT } from '../detection/landmark-spec.js';
|
|
2
|
+
const LEFT_EYE_LOWER = [263, 249, 390, 373, 374, 380, 381, 382, 362];
|
|
3
|
+
const RIGHT_EYE_LOWER = [33, 7, 163, 144, 145, 153, 154, 155, 133];
|
|
4
|
+
const LANDMARK_FEATURES = {
|
|
5
|
+
left_eye_lower: { as: 'polyline', indices: LEFT_EYE_LOWER },
|
|
6
|
+
right_eye_lower: { as: 'polyline', indices: RIGHT_EYE_LOWER },
|
|
7
|
+
left_eye: { as: 'polyline', indices: LANDMARK_GROUPS.leftEye },
|
|
8
|
+
right_eye: { as: 'polyline', indices: LANDMARK_GROUPS.rightEye },
|
|
9
|
+
lips_outer: { as: 'polyline', indices: LANDMARK_GROUPS.lipsOuter },
|
|
10
|
+
nose_tip: { as: 'point', index: LANDMARK_ANCHORS.noseTip },
|
|
11
|
+
chin: { as: 'point', index: LANDMARK_ANCHORS.chin },
|
|
12
|
+
between_eyes: { as: 'point', index: LANDMARK_ANCHORS.betweenEyes },
|
|
13
|
+
upper_lip_top: { as: 'point', index: LANDMARK_ANCHORS.upperLipTop },
|
|
14
|
+
lower_lip_bottom: { as: 'point', index: LANDMARK_ANCHORS.lowerLipBottom },
|
|
15
|
+
};
|
|
16
|
+
export const LANDMARK_FEATURE_NAMES = Object.keys(LANDMARK_FEATURES);
|
|
17
|
+
export function isLandmarkFeature(feature) {
|
|
18
|
+
return feature in LANDMARK_FEATURES;
|
|
19
|
+
}
|
|
20
|
+
export function landmarkAnchor(det, sel) {
|
|
21
|
+
const spec = LANDMARK_FEATURES[sel.feature];
|
|
22
|
+
if (!spec)
|
|
23
|
+
return null;
|
|
24
|
+
const meshed = (det.faces ?? []).filter((f) => f.features && f.features.points.length >= LANDMARK_COUNT);
|
|
25
|
+
const face = meshed[sel.face ?? 0];
|
|
26
|
+
if (!face || !face.features)
|
|
27
|
+
return null;
|
|
28
|
+
const pts = face.features.points;
|
|
29
|
+
if (spec.as === 'point') {
|
|
30
|
+
const p = pts[spec.index];
|
|
31
|
+
return p ? { kind: 'point', point: { x: p.x, y: p.y } } : null;
|
|
32
|
+
}
|
|
33
|
+
const polyline = [];
|
|
34
|
+
for (const i of spec.indices) {
|
|
35
|
+
const p = pts[i];
|
|
36
|
+
if (p)
|
|
37
|
+
polyline.push({ x: p.x, y: p.y });
|
|
38
|
+
}
|
|
39
|
+
return polyline.length >= 2 ? { kind: 'polyline', polyline } : null;
|
|
40
|
+
}
|