@pixerate/editor 0.213.0 → 0.214.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/dist/image-editor/controller.d.cts +15 -0
- package/dist/image-editor/controller.d.ts +15 -0
- package/dist/image-editor/index.cjs +687 -209
- package/dist/image-editor/index.js +495 -17
- package/dist/image-editor/selection/index.d.cts +1 -0
- package/dist/image-editor/selection/index.d.ts +1 -0
- package/dist/image-editor/selection/inspect.d.cts +70 -0
- package/dist/image-editor/selection/inspect.d.ts +70 -0
- package/package.json +1 -1
- package/skills/pixerate-image-editor/SKILL.md +1 -0
- package/skills/pixerate-image-selection-masking/SKILL.md +9 -9
|
@@ -2566,6 +2566,423 @@ function hexRgb(hex) {
|
|
|
2566
2566
|
return [v >> 16 & 255, v >> 8 & 255, v & 255];
|
|
2567
2567
|
}
|
|
2568
2568
|
|
|
2569
|
+
// src/image-editor/selection/inspect.ts
|
|
2570
|
+
var MASK_EDGE_THRESHOLDS = { halo: 0.35, alignment: 0.5, jaggedness: 15, soft: 0.9 };
|
|
2571
|
+
var STRONG_EDGE = 0.1;
|
|
2572
|
+
function toLab(r, g, b) {
|
|
2573
|
+
const lin = (v) => {
|
|
2574
|
+
v /= 255;
|
|
2575
|
+
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
2576
|
+
};
|
|
2577
|
+
const R = lin(r);
|
|
2578
|
+
const G = lin(g);
|
|
2579
|
+
const B = lin(b);
|
|
2580
|
+
const f = (t) => t > 8856e-6 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
|
|
2581
|
+
const x = f((0.4124 * R + 0.3576 * G + 0.1805 * B) / 0.95047);
|
|
2582
|
+
const y = f(0.2126 * R + 0.7152 * G + 0.0722 * B);
|
|
2583
|
+
const z = f((0.0193 * R + 0.1192 * G + 0.9505 * B) / 1.08883);
|
|
2584
|
+
return [116 * y - 16, 500 * (x - y), 200 * (y - z)];
|
|
2585
|
+
}
|
|
2586
|
+
var dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
|
2587
|
+
var r3 = (v) => Math.round(v * 1e3) / 1e3;
|
|
2588
|
+
function inspectMaskEdge(mask, image, options = {}) {
|
|
2589
|
+
const b = mask.bounds;
|
|
2590
|
+
if (!b) throw new Error("The mask is empty: there is no edge to inspect.");
|
|
2591
|
+
const minArea = Math.max(1, options.minArea ?? 16);
|
|
2592
|
+
const W = mask.width;
|
|
2593
|
+
const H = mask.height;
|
|
2594
|
+
const a = mask.data;
|
|
2595
|
+
const alpha = (x, y) => x < 0 || y < 0 || x >= W || y >= H ? 0 : a[y * W + x];
|
|
2596
|
+
const img = image.data;
|
|
2597
|
+
const rgb = (x, y) => {
|
|
2598
|
+
const ix = x - image.x;
|
|
2599
|
+
const iy = y - image.y;
|
|
2600
|
+
if (ix < 0 || iy < 0 || ix >= image.width || iy >= image.height) return null;
|
|
2601
|
+
const i = (iy * image.width + ix) * 4;
|
|
2602
|
+
if (img[i + 3] < 128) return null;
|
|
2603
|
+
return [img[i], img[i + 1], img[i + 2]];
|
|
2604
|
+
};
|
|
2605
|
+
const premul = (x, y, c) => {
|
|
2606
|
+
const ix = Math.min(image.width - 1, Math.max(0, x - image.x));
|
|
2607
|
+
const iy = Math.min(image.height - 1, Math.max(0, y - image.y));
|
|
2608
|
+
const i = (iy * image.width + ix) * 4;
|
|
2609
|
+
return img[i + c] * img[i + 3] / 255;
|
|
2610
|
+
};
|
|
2611
|
+
const gradCache = /* @__PURE__ */ new Map();
|
|
2612
|
+
const gradient = (x, y) => {
|
|
2613
|
+
const k = y * W + x;
|
|
2614
|
+
const hit = gradCache.get(k);
|
|
2615
|
+
if (hit !== void 0) return hit;
|
|
2616
|
+
let best = 0;
|
|
2617
|
+
for (let c = 0; c < 3; c++) {
|
|
2618
|
+
const p = (dx, dy) => premul(x + dx, y + dy, c);
|
|
2619
|
+
const gx = p(1, -1) + 2 * p(1, 0) + p(1, 1) - p(-1, -1) - 2 * p(-1, 0) - p(-1, 1);
|
|
2620
|
+
const gy = p(-1, 1) + 2 * p(0, 1) + p(1, 1) - p(-1, -1) - 2 * p(0, -1) - p(1, -1);
|
|
2621
|
+
best = Math.max(best, Math.hypot(gx, gy) / 1020);
|
|
2622
|
+
}
|
|
2623
|
+
gradCache.set(k, best);
|
|
2624
|
+
return best;
|
|
2625
|
+
};
|
|
2626
|
+
const B = Math.min(256, Math.max(24, Math.round(Math.max(W, H) / 12)));
|
|
2627
|
+
const BW = Math.ceil(W / B);
|
|
2628
|
+
const blocks = /* @__PURE__ */ new Map();
|
|
2629
|
+
const block = (x, y) => {
|
|
2630
|
+
const k = Math.floor(y / B) * BW + Math.floor(x / B);
|
|
2631
|
+
let blk = blocks.get(k);
|
|
2632
|
+
if (!blk) blocks.set(k, blk = { edge: 0, aligned: 0, halo: 0, haloN: 0, turn: 0, len: 0, x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity });
|
|
2633
|
+
return blk;
|
|
2634
|
+
};
|
|
2635
|
+
const grow = (blk, x, y) => {
|
|
2636
|
+
blk.x0 = Math.min(blk.x0, x);
|
|
2637
|
+
blk.y0 = Math.min(blk.y0, y);
|
|
2638
|
+
blk.x1 = Math.max(blk.x1, x);
|
|
2639
|
+
blk.y1 = Math.max(blk.y1, y);
|
|
2640
|
+
};
|
|
2641
|
+
const x0 = Math.max(0, b.x - 1);
|
|
2642
|
+
const y0 = Math.max(0, b.y - 1);
|
|
2643
|
+
const x1 = Math.min(W, b.x + b.width + 1);
|
|
2644
|
+
const y1 = Math.min(H, b.y + b.height + 1);
|
|
2645
|
+
let area = 0;
|
|
2646
|
+
let edgeLength = 0;
|
|
2647
|
+
let aligned = 0;
|
|
2648
|
+
let haloSum = 0;
|
|
2649
|
+
let haloN = 0;
|
|
2650
|
+
let band = 0;
|
|
2651
|
+
let soft = 0;
|
|
2652
|
+
for (let y = y0; y < y1; y++) {
|
|
2653
|
+
for (let x = x0; x < x1; x++) {
|
|
2654
|
+
const v = a[y * W + x];
|
|
2655
|
+
if (v >= 128) area++;
|
|
2656
|
+
let lo = false;
|
|
2657
|
+
let hi = false;
|
|
2658
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
2659
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
2660
|
+
const nx = x + dx;
|
|
2661
|
+
const ny = y + dy;
|
|
2662
|
+
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
|
|
2663
|
+
if (a[ny * W + nx] >= 128) hi = true;
|
|
2664
|
+
else lo = true;
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
const partial = v > 0 && v < 255;
|
|
2668
|
+
if (hi && lo || partial) {
|
|
2669
|
+
band++;
|
|
2670
|
+
if (partial) soft++;
|
|
2671
|
+
}
|
|
2672
|
+
if (v < 128) continue;
|
|
2673
|
+
const inCanvasLow = (nx, ny) => nx >= 0 && ny >= 0 && nx < W && ny < H && a[ny * W + nx] < 128;
|
|
2674
|
+
if (!(inCanvasLow(x - 1, y) || inCanvasLow(x + 1, y) || inCanvasLow(x, y - 1) || inCanvasLow(x, y + 1))) continue;
|
|
2675
|
+
edgeLength++;
|
|
2676
|
+
const blk = block(x, y);
|
|
2677
|
+
blk.edge++;
|
|
2678
|
+
grow(blk, x, y);
|
|
2679
|
+
let strong = false;
|
|
2680
|
+
for (let dy = -2; dy <= 2 && !strong; dy++) {
|
|
2681
|
+
for (let dx = -2; dx <= 2 && !strong; dx++) {
|
|
2682
|
+
const nx = x + dx;
|
|
2683
|
+
const ny = y + dy;
|
|
2684
|
+
if (nx >= 0 && ny >= 0 && nx < W && ny < H && gradient(nx, ny) >= STRONG_EDGE) strong = true;
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
if (strong) {
|
|
2688
|
+
aligned++;
|
|
2689
|
+
blk.aligned++;
|
|
2690
|
+
}
|
|
2691
|
+
const s = (dx, dy) => alpha(Math.min(W - 1, Math.max(0, x + dx)), Math.min(H - 1, Math.max(0, y + dy)));
|
|
2692
|
+
const gx = s(1, -1) + 2 * s(1, 0) + s(1, 1) - s(-1, -1) - 2 * s(-1, 0) - s(-1, 1);
|
|
2693
|
+
const gy = s(-1, 1) + 2 * s(0, 1) + s(1, 1) - s(-1, -1) - 2 * s(0, -1) - s(1, -1);
|
|
2694
|
+
const len = Math.hypot(gx, gy);
|
|
2695
|
+
if (len < 1e-6) continue;
|
|
2696
|
+
const ux = gx / len;
|
|
2697
|
+
const uy = gy / len;
|
|
2698
|
+
const at = (d) => rgb(Math.round(x + ux * d), Math.round(y + uy * d));
|
|
2699
|
+
const ring = at(1.5);
|
|
2700
|
+
const deep = at(5);
|
|
2701
|
+
const out = at(-3);
|
|
2702
|
+
if (!ring || !deep || !out) continue;
|
|
2703
|
+
const labOut = toLab(...out);
|
|
2704
|
+
const span = dist(toLab(...deep), labOut);
|
|
2705
|
+
if (span < 10) continue;
|
|
2706
|
+
const h = Math.min(1, Math.max(0, 1 - dist(toLab(...ring), labOut) / span));
|
|
2707
|
+
haloSum += h;
|
|
2708
|
+
haloN++;
|
|
2709
|
+
blk.halo += h;
|
|
2710
|
+
blk.haloN++;
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
let turnExcess = 0;
|
|
2714
|
+
let outline = 0;
|
|
2715
|
+
for (const loop of traceLoops(a, W, H, x0 - 1, y0 - 1, x1 + 1, y1 + 1)) {
|
|
2716
|
+
const pts = resample(loop, 2);
|
|
2717
|
+
const n = pts.length;
|
|
2718
|
+
if (n < 6) continue;
|
|
2719
|
+
const L = n * 2;
|
|
2720
|
+
let total = 0;
|
|
2721
|
+
let net = 0;
|
|
2722
|
+
const turns = [];
|
|
2723
|
+
for (let i = 0; i < n; i++) {
|
|
2724
|
+
const p0 = pts[(i + n - 1) % n];
|
|
2725
|
+
const p1 = pts[i];
|
|
2726
|
+
const p2 = pts[(i + 1) % n];
|
|
2727
|
+
let d = Math.atan2(p2[1] - p1[1], p2[0] - p1[0]) - Math.atan2(p1[1] - p0[1], p1[0] - p0[0]);
|
|
2728
|
+
while (d > Math.PI) d -= 2 * Math.PI;
|
|
2729
|
+
while (d <= -Math.PI) d += 2 * Math.PI;
|
|
2730
|
+
turns.push(d);
|
|
2731
|
+
total += Math.abs(d);
|
|
2732
|
+
net += d;
|
|
2733
|
+
}
|
|
2734
|
+
const closure = Math.abs(net);
|
|
2735
|
+
turnExcess += Math.max(0, total - closure);
|
|
2736
|
+
outline += L;
|
|
2737
|
+
for (let i = 0; i < n; i++) {
|
|
2738
|
+
const [px2, py] = pts[i];
|
|
2739
|
+
if (px2 < 0 || py < 0 || px2 >= W || py >= H) continue;
|
|
2740
|
+
const blk = block(Math.floor(px2), Math.floor(py));
|
|
2741
|
+
blk.turn += Math.abs(turns[i]) - closure / n;
|
|
2742
|
+
blk.len += 2;
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
const jaggedness = outline ? 100 * turnExcess / outline : 0;
|
|
2746
|
+
const { holes, islands } = regions(a, W, H, b, minArea);
|
|
2747
|
+
const haloScore = haloN ? haloSum / haloN : 0;
|
|
2748
|
+
const edgeAlignment = edgeLength ? aligned / edgeLength : 0;
|
|
2749
|
+
const softFraction = band ? soft / band : 0;
|
|
2750
|
+
const T2 = MASK_EDGE_THRESHOLDS;
|
|
2751
|
+
const candidates = [];
|
|
2752
|
+
const pad = (r) => {
|
|
2753
|
+
const px0 = Math.max(0, r.x - 8);
|
|
2754
|
+
const py0 = Math.max(0, r.y - 8);
|
|
2755
|
+
return { x: px0, y: py0, width: Math.min(W, r.x + r.width + 8) - px0, height: Math.min(H, r.y + r.height + 8) - py0 };
|
|
2756
|
+
};
|
|
2757
|
+
for (const blk of blocks.values()) {
|
|
2758
|
+
if (blk.edge < 8) continue;
|
|
2759
|
+
const bounds = pad({ x: blk.x0, y: blk.y0, width: blk.x1 - blk.x0 + 1, height: blk.y1 - blk.y0 + 1 });
|
|
2760
|
+
const opts = [];
|
|
2761
|
+
if (blk.haloN >= 8) {
|
|
2762
|
+
const h = blk.halo / blk.haloN;
|
|
2763
|
+
if (h >= T2.halo) opts.push({ severity: h, reason: `halo ${r3(h)}: the colour just inside the edge matches the background` });
|
|
2764
|
+
}
|
|
2765
|
+
const al = blk.aligned / blk.edge;
|
|
2766
|
+
if (al < T2.alignment) opts.push({ severity: (1 - al) * 0.9, reason: `alignment ${r3(al)}: the edge is off the image's edges` });
|
|
2767
|
+
if (blk.len >= 16) {
|
|
2768
|
+
const j = 100 * Math.max(0, blk.turn) / blk.len;
|
|
2769
|
+
if (j >= T2.jaggedness) opts.push({ severity: Math.min(1, j / (4 * T2.jaggedness)), reason: `jaggedness ${r3(j)}: the outline zigzags` });
|
|
2770
|
+
}
|
|
2771
|
+
const top = opts.sort((p, q) => q.severity - p.severity)[0];
|
|
2772
|
+
if (top) candidates.push({ ...top, bounds });
|
|
2773
|
+
}
|
|
2774
|
+
const imageArea = W * H;
|
|
2775
|
+
for (const r of holes.largest) candidates.push({ severity: Math.min(0.95, 0.5 + r.area / (0.01 * imageArea)), bounds: pad(r.bounds), reason: `hole of ${r.area} px inside the selection` });
|
|
2776
|
+
for (const r of islands.largest) candidates.push({ severity: Math.min(0.95, 0.5 + r.area / (0.01 * imageArea)), bounds: pad(r.bounds), reason: `island of ${r.area} px apart from the main selection` });
|
|
2777
|
+
const worst = candidates.sort((p, q) => q.severity - p.severity).slice(0, 5).map(({ bounds, reason }) => ({ bounds, reason }));
|
|
2778
|
+
const issues = [];
|
|
2779
|
+
if (haloScore >= T2.halo)
|
|
2780
|
+
issues.push(`Halo (haloScore ${r3(haloScore)} \u2265 ${T2.halo}): background colour is left inside the edge. Contract the edge (modify_selection contract 1\u20132 px, or refine_selection shiftEdge -20 to -40), or decontaminate on a new-layer output.`);
|
|
2781
|
+
if (edgeLength >= 16 && edgeAlignment < T2.alignment)
|
|
2782
|
+
issues.push(`Off the image's edges (edgeAlignment ${r3(edgeAlignment)} < ${T2.alignment}): much of the edge does not follow edges in the image. Rebuild it with select_subject, select_object or quick_select, or refine_selection with a larger radius.`);
|
|
2783
|
+
if (jaggedness >= T2.jaggedness) issues.push(`Jagged (jaggedness ${r3(jaggedness)} \u2265 ${T2.jaggedness}): the outline zigzags. Smooth it (refine_selection smooth 20\u201340, or modify_selection smooth).`);
|
|
2784
|
+
if (softFraction >= T2.soft && edgeAlignment >= T2.alignment)
|
|
2785
|
+
issues.push(`Very soft (softFraction ${r3(softFraction)} \u2265 ${T2.soft}): almost the whole edge is partly selected. If the subject has hard edges, raise refine_selection contrast or lower feather.`);
|
|
2786
|
+
if (holes.count) issues.push(`${holes.count} hole(s) (${holes.totalArea} px) inside the selection. If they belong to the subject, fill them (quick_select or select_polygon with mode add).`);
|
|
2787
|
+
if (islands.count) issues.push(`${islands.count} island(s) (${islands.totalArea} px) apart from the main selection. If they are stray, remove them (select_polygon or quick_select with mode subtract).`);
|
|
2788
|
+
return {
|
|
2789
|
+
area,
|
|
2790
|
+
bounds: { ...b },
|
|
2791
|
+
edgeLength,
|
|
2792
|
+
haloScore: r3(haloScore),
|
|
2793
|
+
edgeAlignment: r3(edgeAlignment),
|
|
2794
|
+
jaggedness: r3(jaggedness),
|
|
2795
|
+
softFraction: r3(softFraction),
|
|
2796
|
+
holes,
|
|
2797
|
+
islands,
|
|
2798
|
+
worst,
|
|
2799
|
+
issues
|
|
2800
|
+
};
|
|
2801
|
+
}
|
|
2802
|
+
function traceLoops(a, W, H, rx0, ry0, rx1, ry1) {
|
|
2803
|
+
const w = rx1 - rx0;
|
|
2804
|
+
const h = ry1 - ry0;
|
|
2805
|
+
const f = (i, j) => {
|
|
2806
|
+
const x = rx0 + i;
|
|
2807
|
+
const y = ry0 + j;
|
|
2808
|
+
return x < 0 || y < 0 || x >= W || y >= H ? 0 : a[y * W + x];
|
|
2809
|
+
};
|
|
2810
|
+
const on = (i, j) => f(i, j) >= 128;
|
|
2811
|
+
const V = w * h;
|
|
2812
|
+
const point = (id) => {
|
|
2813
|
+
const vertical = id >= V;
|
|
2814
|
+
const k = vertical ? id - V : id;
|
|
2815
|
+
const i = k % w;
|
|
2816
|
+
const j = (k - i) / w;
|
|
2817
|
+
const p = f(i, j);
|
|
2818
|
+
const q = vertical ? f(i, j + 1) : f(i + 1, j);
|
|
2819
|
+
const t = q === p ? 0.5 : Math.min(1, Math.max(0, (127.5 - p) / (q - p)));
|
|
2820
|
+
return vertical ? [rx0 + i, ry0 + j + t] : [rx0 + i + t, ry0 + j];
|
|
2821
|
+
};
|
|
2822
|
+
const segs = [];
|
|
2823
|
+
const touch = /* @__PURE__ */ new Map();
|
|
2824
|
+
const add2 = (e1, e2) => {
|
|
2825
|
+
const s = segs.length / 2;
|
|
2826
|
+
segs.push(e1, e2);
|
|
2827
|
+
for (const e of [e1, e2]) {
|
|
2828
|
+
const list = touch.get(e);
|
|
2829
|
+
if (list) list.push(s);
|
|
2830
|
+
else touch.set(e, [s]);
|
|
2831
|
+
}
|
|
2832
|
+
};
|
|
2833
|
+
for (let j = 0; j < h - 1; j++) {
|
|
2834
|
+
for (let i = 0; i < w - 1; i++) {
|
|
2835
|
+
const c = (on(i, j) ? 1 : 0) | (on(i + 1, j) ? 2 : 0) | (on(i + 1, j + 1) ? 4 : 0) | (on(i, j + 1) ? 8 : 0);
|
|
2836
|
+
if (c === 0 || c === 15) continue;
|
|
2837
|
+
const top = j * w + i;
|
|
2838
|
+
const bottom = (j + 1) * w + i;
|
|
2839
|
+
const left = V + j * w + i;
|
|
2840
|
+
const right = V + j * w + i + 1;
|
|
2841
|
+
const centre = (f(i, j) + f(i + 1, j) + f(i + 1, j + 1) + f(i, j + 1)) / 4 >= 128;
|
|
2842
|
+
switch (c) {
|
|
2843
|
+
case 1:
|
|
2844
|
+
case 14:
|
|
2845
|
+
add2(left, top);
|
|
2846
|
+
break;
|
|
2847
|
+
case 2:
|
|
2848
|
+
case 13:
|
|
2849
|
+
add2(top, right);
|
|
2850
|
+
break;
|
|
2851
|
+
case 3:
|
|
2852
|
+
case 12:
|
|
2853
|
+
add2(left, right);
|
|
2854
|
+
break;
|
|
2855
|
+
case 4:
|
|
2856
|
+
case 11:
|
|
2857
|
+
add2(right, bottom);
|
|
2858
|
+
break;
|
|
2859
|
+
case 6:
|
|
2860
|
+
case 9:
|
|
2861
|
+
add2(top, bottom);
|
|
2862
|
+
break;
|
|
2863
|
+
case 7:
|
|
2864
|
+
case 8:
|
|
2865
|
+
add2(left, bottom);
|
|
2866
|
+
break;
|
|
2867
|
+
case 5:
|
|
2868
|
+
if (centre) {
|
|
2869
|
+
add2(top, right);
|
|
2870
|
+
add2(left, bottom);
|
|
2871
|
+
} else {
|
|
2872
|
+
add2(left, top);
|
|
2873
|
+
add2(right, bottom);
|
|
2874
|
+
}
|
|
2875
|
+
break;
|
|
2876
|
+
case 10:
|
|
2877
|
+
if (centre) {
|
|
2878
|
+
add2(left, top);
|
|
2879
|
+
add2(right, bottom);
|
|
2880
|
+
} else {
|
|
2881
|
+
add2(top, right);
|
|
2882
|
+
add2(left, bottom);
|
|
2883
|
+
}
|
|
2884
|
+
break;
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
const used = new Uint8Array(segs.length / 2);
|
|
2889
|
+
const loops = [];
|
|
2890
|
+
for (let s0 = 0; s0 < used.length; s0++) {
|
|
2891
|
+
if (used[s0]) continue;
|
|
2892
|
+
const loop = [];
|
|
2893
|
+
let s = s0;
|
|
2894
|
+
let from = segs[s * 2];
|
|
2895
|
+
while (!used[s]) {
|
|
2896
|
+
used[s] = 1;
|
|
2897
|
+
loop.push(point(from));
|
|
2898
|
+
const to = segs[s * 2] === from ? segs[s * 2 + 1] : segs[s * 2];
|
|
2899
|
+
const next = touch.get(to).find((t) => !used[t]);
|
|
2900
|
+
from = to;
|
|
2901
|
+
if (next === void 0) break;
|
|
2902
|
+
s = next;
|
|
2903
|
+
}
|
|
2904
|
+
loops.push(loop);
|
|
2905
|
+
}
|
|
2906
|
+
return loops;
|
|
2907
|
+
}
|
|
2908
|
+
function resample(loop, step) {
|
|
2909
|
+
const n = loop.length;
|
|
2910
|
+
if (n < 2) return [];
|
|
2911
|
+
const out = [loop[0]];
|
|
2912
|
+
let carry = 0;
|
|
2913
|
+
for (let i = 0; i < n; i++) {
|
|
2914
|
+
const [ax, ay] = loop[i];
|
|
2915
|
+
const [bx, by] = loop[(i + 1) % n];
|
|
2916
|
+
const seg = Math.hypot(bx - ax, by - ay);
|
|
2917
|
+
let t = step - carry;
|
|
2918
|
+
while (t <= seg) {
|
|
2919
|
+
out.push([ax + (bx - ax) * t / seg, ay + (by - ay) * t / seg]);
|
|
2920
|
+
t += step;
|
|
2921
|
+
}
|
|
2922
|
+
carry = seg - (t - step);
|
|
2923
|
+
}
|
|
2924
|
+
if (out.length > 1 && Math.hypot(out[out.length - 1][0] - out[0][0], out[out.length - 1][1] - out[0][1]) < step / 2) out.pop();
|
|
2925
|
+
return out;
|
|
2926
|
+
}
|
|
2927
|
+
function regions(a, W, H, b, minArea) {
|
|
2928
|
+
const x0 = b.x - 1;
|
|
2929
|
+
const y0 = b.y - 1;
|
|
2930
|
+
const w = b.width + 2;
|
|
2931
|
+
const h = b.height + 2;
|
|
2932
|
+
const on = (i, j) => {
|
|
2933
|
+
const x = x0 + i;
|
|
2934
|
+
const y = y0 + j;
|
|
2935
|
+
return x >= 0 && y >= 0 && x < W && y < H && a[y * W + x] >= 128;
|
|
2936
|
+
};
|
|
2937
|
+
const label = new Int32Array(w * h);
|
|
2938
|
+
const stack = new Int32Array(w * h);
|
|
2939
|
+
const found = [];
|
|
2940
|
+
const N4 = [[1, 0], [-1, 0], [0, 1], [0, -1]];
|
|
2941
|
+
const N8 = [...N4, [1, 1], [1, -1], [-1, 1], [-1, -1]];
|
|
2942
|
+
for (let start = 0; start < w * h; start++) {
|
|
2943
|
+
if (label[start]) continue;
|
|
2944
|
+
const si = start % w;
|
|
2945
|
+
const sj = (start - si) / w;
|
|
2946
|
+
const fg = on(si, sj);
|
|
2947
|
+
const id = found.length + 1;
|
|
2948
|
+
const r = { fg, area: 0, bx0: si, by0: sj, bx1: si, by1: sj, outer: false };
|
|
2949
|
+
let top = 0;
|
|
2950
|
+
stack[top++] = start;
|
|
2951
|
+
label[start] = id;
|
|
2952
|
+
while (top) {
|
|
2953
|
+
const k = stack[--top];
|
|
2954
|
+
const i = k % w;
|
|
2955
|
+
const j = (k - i) / w;
|
|
2956
|
+
r.area++;
|
|
2957
|
+
if (i < r.bx0) r.bx0 = i;
|
|
2958
|
+
if (i > r.bx1) r.bx1 = i;
|
|
2959
|
+
if (j < r.by0) r.by0 = j;
|
|
2960
|
+
if (j > r.by1) r.by1 = j;
|
|
2961
|
+
if (i === 0 || j === 0 || i === w - 1 || j === h - 1) r.outer = true;
|
|
2962
|
+
for (const [dx, dy] of fg ? N8 : N4) {
|
|
2963
|
+
const ni = i + dx;
|
|
2964
|
+
const nj = j + dy;
|
|
2965
|
+
if (ni < 0 || nj < 0 || ni >= w || nj >= h) continue;
|
|
2966
|
+
const nk = nj * w + ni;
|
|
2967
|
+
if (label[nk] || on(ni, nj) !== fg) continue;
|
|
2968
|
+
label[nk] = id;
|
|
2969
|
+
stack[top++] = nk;
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
found.push(r);
|
|
2973
|
+
}
|
|
2974
|
+
const box = (r) => ({ x: x0 + r.bx0, y: y0 + r.by0, width: r.bx1 - r.bx0 + 1, height: r.by1 - r.by0 + 1 });
|
|
2975
|
+
const summarize = (list) => {
|
|
2976
|
+
const kept = list.filter((r) => r.area >= minArea).sort((p, q) => q.area - p.area);
|
|
2977
|
+
return { count: kept.length, totalArea: kept.reduce((s, r) => s + r.area, 0), largest: kept.slice(0, 10).map((r) => ({ area: r.area, bounds: box(r) })) };
|
|
2978
|
+
};
|
|
2979
|
+
const pieces = found.filter((r) => r.fg).sort((p, q) => q.area - p.area);
|
|
2980
|
+
return {
|
|
2981
|
+
holes: summarize(found.filter((r) => !r.fg && !r.outer)),
|
|
2982
|
+
islands: summarize(pieces.slice(1))
|
|
2983
|
+
};
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2569
2986
|
// src/image-editor/selection/path.ts
|
|
2570
2987
|
function selectionToSubpaths(mask, options = {}) {
|
|
2571
2988
|
const b = mask.bounds;
|
|
@@ -2854,19 +3271,19 @@ function liveWire(map, from, to, band) {
|
|
|
2854
3271
|
}
|
|
2855
3272
|
}
|
|
2856
3273
|
const scale = maxStrength > 0 ? 1 / maxStrength : 0;
|
|
2857
|
-
const
|
|
3274
|
+
const dist2 = new Float64Array(n).fill(Infinity);
|
|
2858
3275
|
const prev = new Int32Array(n).fill(-1);
|
|
2859
3276
|
const done = new Uint8Array(n);
|
|
2860
3277
|
const start = (ay - ry0) * w + (ax - rx0);
|
|
2861
3278
|
const goal = (by - ry0) * w + (bx - rx0);
|
|
2862
|
-
|
|
3279
|
+
dist2[start] = 0;
|
|
2863
3280
|
const heap = new Heap();
|
|
2864
3281
|
heap.push(start, 0);
|
|
2865
3282
|
while (heap.size) {
|
|
2866
3283
|
const p = heap.topId;
|
|
2867
3284
|
const d = heap.topKey;
|
|
2868
3285
|
heap.pop();
|
|
2869
|
-
if (done[p] || d >
|
|
3286
|
+
if (done[p] || d > dist2[p]) continue;
|
|
2870
3287
|
done[p] = 1;
|
|
2871
3288
|
if (p === goal) break;
|
|
2872
3289
|
const x = p % w;
|
|
@@ -2887,8 +3304,8 @@ function liveWire(map, from, to, band) {
|
|
|
2887
3304
|
const fZ = 1 - zero[q];
|
|
2888
3305
|
const cost = (W_ZERO * fZ + W_GRADIENT * fG + W_DIRECTION * fD + 0.01) * step;
|
|
2889
3306
|
const nd = d + cost;
|
|
2890
|
-
if (nd <
|
|
2891
|
-
|
|
3307
|
+
if (nd < dist2[q]) {
|
|
3308
|
+
dist2[q] = nd;
|
|
2892
3309
|
prev[q] = p;
|
|
2893
3310
|
heap.push(q, nd);
|
|
2894
3311
|
}
|
|
@@ -3604,8 +4021,8 @@ function colorRange(pixels, samples, options = {}) {
|
|
|
3604
4021
|
const d = Math.max(Math.abs(data[p] - s.c[0]), Math.abs(data[p + 1] - s.c[1]), Math.abs(data[p + 2] - s.c[2]));
|
|
3605
4022
|
let k = Math.min(1, Math.max(0, 1 - d / f));
|
|
3606
4023
|
if (k > 0 && radius !== void 0 && s.at) {
|
|
3607
|
-
const
|
|
3608
|
-
k *= Math.min(1, Math.max(0, 1 -
|
|
4024
|
+
const dist2 = Math.hypot(x - s.at.x, y - s.at.y);
|
|
4025
|
+
k *= Math.min(1, Math.max(0, 1 - dist2 / radius));
|
|
3609
4026
|
}
|
|
3610
4027
|
if (k > best) best = k;
|
|
3611
4028
|
}
|
|
@@ -4966,15 +5383,15 @@ var PaintStrokeSession = class {
|
|
|
4966
5383
|
return;
|
|
4967
5384
|
}
|
|
4968
5385
|
const stroke = this.stroke;
|
|
4969
|
-
const
|
|
5386
|
+
const regions2 = /* @__PURE__ */ new Map();
|
|
4970
5387
|
for (const r of rects) {
|
|
4971
5388
|
for (const t of tilesInRect({ x: r.x - this.apply.offset.x, y: r.y - this.apply.offset.y, width: r.width, height: r.height })) {
|
|
4972
5389
|
const key2 = tileKey(t.tx, t.ty);
|
|
4973
|
-
const cur =
|
|
4974
|
-
|
|
5390
|
+
const cur = regions2.get(key2);
|
|
5391
|
+
regions2.set(key2, { tx: t.tx, ty: t.ty, r: cur ? unionRect(cur.r, r) : r });
|
|
4975
5392
|
}
|
|
4976
5393
|
}
|
|
4977
|
-
for (const [key2, { tx, ty, r }] of
|
|
5394
|
+
for (const [key2, { tx, ty, r }] of regions2) {
|
|
4978
5395
|
if (this.deep) {
|
|
4979
5396
|
const rt = this.retouch;
|
|
4980
5397
|
const paint = rt?.kind === "coverage" ? rt.paint() : null;
|
|
@@ -9668,6 +10085,44 @@ var ImageEditorController = class {
|
|
|
9668
10085
|
if (!sel) return false;
|
|
9669
10086
|
return this.setSelection(invert2 ? invertSelection(sel) : sel, mode);
|
|
9670
10087
|
}
|
|
10088
|
+
/**
|
|
10089
|
+
* A measured report on a mask's edge (`inspect_mask_edge`): the selection (default), a layer's
|
|
10090
|
+
* painted mask or its vector mask (its path, without density or feather). Halo and alignment are
|
|
10091
|
+
* measured against the layer's own pixels for a pixel layer's masks, else the composite.
|
|
10092
|
+
* Read-only. Throws for an empty selection or a layer without that mask.
|
|
10093
|
+
*/
|
|
10094
|
+
inspectMaskEdge(target = "selection", options = {}) {
|
|
10095
|
+
const { width, height } = this.naturalSize;
|
|
10096
|
+
let mask;
|
|
10097
|
+
let layer;
|
|
10098
|
+
if (target === "selection") {
|
|
10099
|
+
mask = this.getSelection();
|
|
10100
|
+
if (!mask?.bounds) throw new LayerOperationError("Nothing is selected: make a selection first, or inspect a layer-mask or vector-mask with layerId.");
|
|
10101
|
+
} else if (target === "layer-mask" || target === "vector-mask") {
|
|
10102
|
+
if (!options.layerId) throw new LayerOperationError(`A ${target} needs layerId (see list_layers).`);
|
|
10103
|
+
layer = this.getLayer(options.layerId);
|
|
10104
|
+
if (!layer) throw new LayerOperationError(`There is no layer "${options.layerId}" (see list_layers).`);
|
|
10105
|
+
if (target === "layer-mask") {
|
|
10106
|
+
mask = this.layerMaskCoverage(layer.id);
|
|
10107
|
+
if (!mask) throw new LayerOperationError(`Layer "${layer.name || layer.id}" has no painted mask; add one with add_layer_mask, or inspect its vector-mask.`);
|
|
10108
|
+
} else {
|
|
10109
|
+
const { vm } = this.requireVectorMask(layer.id);
|
|
10110
|
+
mask = pathToSelection(vm.components, width, height, { fillRule: vm.fillRule });
|
|
10111
|
+
if (vm.inverted) mask = invertSelection(mask);
|
|
10112
|
+
}
|
|
10113
|
+
if (!mask.bounds) throw new LayerOperationError(`The ${target} of "${layer.name || layer.id}" hides everything: there is no edge to inspect.`);
|
|
10114
|
+
} else {
|
|
10115
|
+
throw new LayerOperationError(`Unknown target "${String(target)}". Use selection, layer-mask or vector-mask.`);
|
|
10116
|
+
}
|
|
10117
|
+
const b = mask.bounds;
|
|
10118
|
+
const x = Math.max(0, b.x - 8);
|
|
10119
|
+
const y = Math.max(0, b.y - 8);
|
|
10120
|
+
const region = { x, y, width: Math.min(width, b.x + b.width + 8) - x, height: Math.min(height, b.y + b.height + 8) - y };
|
|
10121
|
+
const own = layer?.type === "pixel" ? this.getLayerPixels(layer.id) : null;
|
|
10122
|
+
const image = own ?? { ...this.compositePixels(region), x: region.x, y: region.y };
|
|
10123
|
+
const report = inspectMaskEdge(mask, image, options);
|
|
10124
|
+
return { target, ...layer ? { layerId: layer.id } : {}, ...report };
|
|
10125
|
+
}
|
|
9671
10126
|
/** A layer's painted mask over the canvas as a selection mask; null without one (or for a depth mask). */
|
|
9672
10127
|
layerMaskCoverage(layerId) {
|
|
9673
10128
|
const layer = this.getLayer(layerId);
|
|
@@ -10717,7 +11172,7 @@ var ImageEditorController = class {
|
|
|
10717
11172
|
}
|
|
10718
11173
|
if (src.colorSpace !== "rgb") throw new LayerOperationError("Colours are given as #rrggbb, so the source profile must be RGB.");
|
|
10719
11174
|
const t = Transform.create(src, dst, { intent: options.intent ?? "relative", bpc: options.bpc ?? true });
|
|
10720
|
-
const
|
|
11175
|
+
const toLab2 = Transform.create(src, builtinProfile("lab-d50"), { intent: "relative", bpc: false });
|
|
10721
11176
|
return colors.map((input) => {
|
|
10722
11177
|
const m = /^#?([0-9a-f]{6})$/i.exec(input.trim());
|
|
10723
11178
|
if (!m) throw new LayerOperationError(`Invalid colour "${input}"; use #rrggbb.`);
|
|
@@ -10727,7 +11182,7 @@ var ImageEditorController = class {
|
|
|
10727
11182
|
t.eval(rgb, out);
|
|
10728
11183
|
const values = Array.from(out.subarray(0, t.outputs), (v) => Math.round(Math.min(1, Math.max(0, v)) * 1e4) / 1e4);
|
|
10729
11184
|
const l = new Float32Array(3);
|
|
10730
|
-
|
|
11185
|
+
toLab2.eval(rgb, l);
|
|
10731
11186
|
const lab = [Math.round(l[0] * 1e4) / 100, Math.round((l[1] * 255 - 128) * 100) / 100, Math.round((l[2] * 255 - 128) * 100) / 100];
|
|
10732
11187
|
const res = { input, values, lab };
|
|
10733
11188
|
if (dst.colorSpace === "rgb") res.hex = `#${values.map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join("")}`;
|
|
@@ -12399,6 +12854,26 @@ function createImageSelectionTools(controller) {
|
|
|
12399
12854
|
annotations: { title: "Read the selection", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
12400
12855
|
handler: () => summary(controller)
|
|
12401
12856
|
};
|
|
12857
|
+
const inspectEdge = {
|
|
12858
|
+
name: "inspect_mask_edge",
|
|
12859
|
+
title: "Inspect a mask edge",
|
|
12860
|
+
description: "Measures the edge of the selection (default), a layer's painted mask (layer-mask) or its vector mask (vector-mask), so you can check it without looking. Returns haloScore 0-1 (background colour left just inside the edge; worse above 0.35), edgeAlignment 0-1 (share of the edge on strong image edges; worse below 0.5), jaggedness (outline turning in radians per 100 px beyond what closing it needs; smooth outlines score under 3, worse above 15), softFraction 0-1 (share of edge pixels partly selected), holes and islands (count, total area, the 10 largest with bounds), worst (up to 5 regions with a reason, ready for capture_view rect) and issues (findings with a suggested fix; empty when clean). Measures a pixel layer's masks against its own pixels, otherwise the composite. Read-only: inspect, fix the issues, inspect again.",
|
|
12861
|
+
inputSchema: {
|
|
12862
|
+
type: "object",
|
|
12863
|
+
properties: {
|
|
12864
|
+
target: { type: "string", enum: ["selection", "layer-mask", "vector-mask"], description: "What to inspect (default selection). layer-mask and vector-mask need layerId." },
|
|
12865
|
+
layerId: { type: "string", description: "The layer whose mask to inspect (see list_layers); only with layer-mask or vector-mask." },
|
|
12866
|
+
minArea: { type: "number", minimum: 1, maximum: 1e6, description: "Holes and islands smaller than this many pixels are left out (default 16)." }
|
|
12867
|
+
},
|
|
12868
|
+
additionalProperties: false
|
|
12869
|
+
},
|
|
12870
|
+
annotations: { title: "Inspect a mask edge", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
12871
|
+
handler: ({ target, layerId, minArea }) => {
|
|
12872
|
+
const t = target ?? "selection";
|
|
12873
|
+
if (t === "selection" && layerId !== void 0) throw new Error("layerId is only for target layer-mask or vector-mask; leave it out to inspect the selection.");
|
|
12874
|
+
return controller.inspectMaskEdge(t, { layerId, minArea });
|
|
12875
|
+
}
|
|
12876
|
+
};
|
|
12402
12877
|
const selectRegion = {
|
|
12403
12878
|
name: "select_region",
|
|
12404
12879
|
title: "Select a region",
|
|
@@ -13444,6 +13919,7 @@ function createImageSelectionTools(controller) {
|
|
|
13444
13919
|
};
|
|
13445
13920
|
return [
|
|
13446
13921
|
getSelection,
|
|
13922
|
+
inspectEdge,
|
|
13447
13923
|
selectAll,
|
|
13448
13924
|
deselect,
|
|
13449
13925
|
invert2,
|
|
@@ -18677,10 +19153,10 @@ function resolveMosaicRegionBounds(region, imgWidth, imgHeight) {
|
|
|
18677
19153
|
if (x1 <= x0 || y1 <= y0) return null;
|
|
18678
19154
|
return { x0, y0, x1, y1 };
|
|
18679
19155
|
}
|
|
18680
|
-
function applyRegionMosaic(imageData,
|
|
19156
|
+
function applyRegionMosaic(imageData, regions2, blockSize = 12) {
|
|
18681
19157
|
const { data, width, height } = imageData;
|
|
18682
19158
|
const step = Math.max(2, Math.round(blockSize));
|
|
18683
|
-
for (const region of
|
|
19159
|
+
for (const region of regions2) {
|
|
18684
19160
|
const bounds = resolveMosaicRegionBounds(region, width, height);
|
|
18685
19161
|
if (!bounds) continue;
|
|
18686
19162
|
const { x0, y0, x1, y1 } = bounds;
|
|
@@ -18722,10 +19198,10 @@ function applyRegionMosaic(imageData, regions, blockSize = 12) {
|
|
|
18722
19198
|
}
|
|
18723
19199
|
return imageData;
|
|
18724
19200
|
}
|
|
18725
|
-
function applyRegionBlur(imageData,
|
|
19201
|
+
function applyRegionBlur(imageData, regions2, radius = 10) {
|
|
18726
19202
|
const { data, width, height } = imageData;
|
|
18727
19203
|
const rad = Math.max(1, Math.min(64, Math.round(radius)));
|
|
18728
|
-
for (const region of
|
|
19204
|
+
for (const region of regions2) {
|
|
18729
19205
|
const bounds = resolveMosaicRegionBounds(region, width, height);
|
|
18730
19206
|
if (!bounds) continue;
|
|
18731
19207
|
const { x0, y0, x1, y1 } = bounds;
|
|
@@ -21159,6 +21635,7 @@ export {
|
|
|
21159
21635
|
LayerEffectError,
|
|
21160
21636
|
LayerOperationError,
|
|
21161
21637
|
MARCHING_ANTS_STEP_MS,
|
|
21638
|
+
MASK_EDGE_THRESHOLDS,
|
|
21162
21639
|
MASK_FILE_ACCEPT,
|
|
21163
21640
|
MAX_BATCH,
|
|
21164
21641
|
MAX_BRUSH_SIZE,
|
|
@@ -21653,6 +22130,7 @@ export {
|
|
|
21653
22130
|
inflateRaw,
|
|
21654
22131
|
insertCurvePoint,
|
|
21655
22132
|
insertLayer,
|
|
22133
|
+
inspectMaskEdge,
|
|
21656
22134
|
instancesOf,
|
|
21657
22135
|
invertHomography,
|
|
21658
22136
|
invertMatrix3,
|