@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.
@@ -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 dist = new Float64Array(n).fill(Infinity);
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
- dist[start] = 0;
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 > dist[p]) continue;
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 < dist[q]) {
2891
- dist[q] = nd;
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 dist = Math.hypot(x - s.at.x, y - s.at.y);
3608
- k *= Math.min(1, Math.max(0, 1 - dist / radius));
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 regions = /* @__PURE__ */ new Map();
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 = regions.get(key2);
4974
- regions.set(key2, { tx: t.tx, ty: t.ty, r: cur ? unionRect(cur.r, r) : r });
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 regions) {
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 toLab = Transform.create(src, builtinProfile("lab-d50"), { intent: "relative", bpc: false });
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
- toLab.eval(rgb, l);
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, regions, blockSize = 12) {
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 regions) {
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, regions, radius = 10) {
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 regions) {
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,
@@ -17,3 +17,4 @@ export * from './segment/index.cjs';
17
17
  export * from './refine-edits.cjs';
18
18
  export * from './select-and-mask.cjs';
19
19
  export * from './worker.cjs';
20
+ export * from './inspect.cjs';
@@ -17,3 +17,4 @@ export * from './segment/index.js';
17
17
  export * from './refine-edits.js';
18
18
  export * from './select-and-mask.js';
19
19
  export * from './worker.js';
20
+ export * from './inspect.js';