@pixerate/editor 0.215.0 → 0.217.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.
@@ -1070,6 +1070,105 @@ function startMarchingAnts(tick, stepMs = MARCHING_ANTS_STEP_MS) {
1070
1070
  return () => clearInterval(id);
1071
1071
  }
1072
1072
 
1073
+ // src/image-editor/selection/diff.ts
1074
+ function compareSelectionMasks(a, b, width, height, options = {}) {
1075
+ const minArea = Math.max(1, options.minArea ?? 4);
1076
+ const maxRegions = Math.max(1, options.maxRegions ?? 10);
1077
+ const n = width * height;
1078
+ const ad = a?.data;
1079
+ const bd = b?.data;
1080
+ const added = new Uint8Array(n);
1081
+ const removed = new Uint8Array(n);
1082
+ let areaA = 0;
1083
+ let areaB = 0;
1084
+ let inter = 0;
1085
+ let change = 0;
1086
+ let addedArea = 0;
1087
+ let removedArea = 0;
1088
+ for (let i = 0; i < n; i++) {
1089
+ const va = ad ? ad[i] : 0;
1090
+ const vb = bd ? bd[i] : 0;
1091
+ if (va !== vb) change += Math.abs(vb - va);
1092
+ const ia = va >= 128;
1093
+ const ib = vb >= 128;
1094
+ if (ia) areaA++;
1095
+ if (ib) areaB++;
1096
+ if (ia && ib) inter++;
1097
+ else if (ib) {
1098
+ added[i] = 255;
1099
+ addedArea++;
1100
+ } else if (ia) {
1101
+ removed[i] = 255;
1102
+ removedArea++;
1103
+ }
1104
+ }
1105
+ const union = areaA + areaB - inter;
1106
+ const addedMask = createSelectionMask(width, height, added);
1107
+ const removedMask = createSelectionMask(width, height, removed);
1108
+ return {
1109
+ a: { area: areaA, bounds: a?.bounds ?? null },
1110
+ b: { area: areaB, bounds: b?.bounds ?? null },
1111
+ added: { area: addedArea, ...pieces(added, width, height, addedMask.bounds, minArea, maxRegions) },
1112
+ removed: { area: removedArea, ...pieces(removed, width, height, removedMask.bounds, minArea, maxRegions) },
1113
+ iou: union ? Math.round(inter / union * 1e4) / 1e4 : 1,
1114
+ coverageChange: Math.round(change / 255),
1115
+ identical: change === 0,
1116
+ addedMask,
1117
+ removedMask
1118
+ };
1119
+ }
1120
+ function pieces(bits, W, H, bounds, minArea, maxRegions) {
1121
+ if (!bounds) return { count: 0, regions: [] };
1122
+ const { x: x0, y: y0, width: w, height: h } = bounds;
1123
+ const seen = new Uint8Array(w * h);
1124
+ const stack = new Int32Array(w * h);
1125
+ const found = [];
1126
+ for (let start = 0; start < w * h; start++) {
1127
+ if (seen[start] || !bits[(y0 + Math.floor(start / w)) * W + x0 + start % w]) continue;
1128
+ let area = 0;
1129
+ let bx0 = w;
1130
+ let by0 = h;
1131
+ let bx1 = -1;
1132
+ let by1 = -1;
1133
+ let top = 0;
1134
+ stack[top++] = start;
1135
+ seen[start] = 1;
1136
+ while (top) {
1137
+ const k = stack[--top];
1138
+ const i = k % w;
1139
+ const j = (k - i) / w;
1140
+ area++;
1141
+ if (i < bx0) bx0 = i;
1142
+ if (i > bx1) bx1 = i;
1143
+ if (j < by0) by0 = j;
1144
+ if (j > by1) by1 = j;
1145
+ for (let dy = -1; dy <= 1; dy++) {
1146
+ for (let dx = -1; dx <= 1; dx++) {
1147
+ const ni = i + dx;
1148
+ const nj = j + dy;
1149
+ if (ni < 0 || nj < 0 || ni >= w || nj >= h) continue;
1150
+ const nk = nj * w + ni;
1151
+ if (seen[nk] || !bits[(y0 + nj) * W + x0 + ni]) continue;
1152
+ seen[nk] = 1;
1153
+ stack[top++] = nk;
1154
+ }
1155
+ }
1156
+ }
1157
+ if (area >= minArea) found.push({ area, bounds: { x: x0 + bx0, y: y0 + by0, width: bx1 - bx0 + 1, height: by1 - by0 + 1 } });
1158
+ }
1159
+ found.sort((p, q) => q.area - p.area);
1160
+ return { count: found.length, regions: found.slice(0, maxRegions) };
1161
+ }
1162
+ function selectionDiffPixels(added, removed) {
1163
+ const n = added.width * added.height;
1164
+ const out = new Uint8ClampedArray(n * 4);
1165
+ for (let i = 0; i < n; i++) {
1166
+ if (added.data[i]) out.set([34, 197, 94, 140], i * 4);
1167
+ else if (removed.data[i]) out.set([239, 68, 68, 140], i * 4);
1168
+ }
1169
+ return out;
1170
+ }
1171
+
1073
1172
  // src/image-editor/layers/render.ts
1074
1173
  function isQuarterTurn(rotate) {
1075
1174
  return normalizeAngle(rotate) % 90 === 0;
@@ -1515,6 +1614,19 @@ var ImageEditorRenderer = class {
1515
1614
  }
1516
1615
  ctx.restore();
1517
1616
  }
1617
+ const diff = state.selectionDiff;
1618
+ if (diff && diff.selection === (state.selection ?? null)) {
1619
+ ctx.save();
1620
+ try {
1621
+ applyImageProjectionToContext(ctx, projection);
1622
+ ctx.beginPath();
1623
+ ctx.rect(region.x, region.y, region.width, region.height);
1624
+ ctx.clip();
1625
+ this.drawSelectionDiff(ctx, diff);
1626
+ } catch {
1627
+ }
1628
+ ctx.restore();
1629
+ }
1518
1630
  if (options.brushCursor) {
1519
1631
  const b = options.brushCursor;
1520
1632
  ctx.save();
@@ -1970,6 +2082,7 @@ var ImageEditorRenderer = class {
1970
2082
  this.onPreviewHistogram?.(histogram);
1971
2083
  }
1972
2084
  quickMaskCache = /* @__PURE__ */ new WeakMap();
2085
+ diffCache = /* @__PURE__ */ new WeakMap();
1973
2086
  outlineCache = /* @__PURE__ */ new WeakMap();
1974
2087
  emptyMaskCache = null;
1975
2088
  /** An empty selection of the image's size (Quick Mask mode with nothing selected). */
@@ -2023,6 +2136,19 @@ var ImageEditorRenderer = class {
2023
2136
  ctx.drawImage(cached.canvas, 0, 0, mask.width, mask.height);
2024
2137
  }
2025
2138
  /** Rubylith over the unselected area, drawn in natural image space. */
2139
+ drawSelectionDiff(ctx, diff) {
2140
+ let canvas = this.diffCache.get(diff);
2141
+ if (!canvas) {
2142
+ canvas = this.createCanvas(diff.added.width, diff.added.height);
2143
+ const mctx = canvas.getContext("2d");
2144
+ if (!mctx || typeof mctx.createImageData !== "function") return;
2145
+ const img = mctx.createImageData(diff.added.width, diff.added.height);
2146
+ img.data.set(selectionDiffPixels(diff.added, diff.removed));
2147
+ mctx.putImageData(img, 0, 0);
2148
+ this.diffCache.set(diff, canvas);
2149
+ }
2150
+ ctx.drawImage(canvas, 0, 0, diff.added.width, diff.added.height);
2151
+ }
2026
2152
  drawQuickMask(ctx, selection) {
2027
2153
  let canvas = this.quickMaskCache.get(selection);
2028
2154
  if (!canvas) {
@@ -2609,27 +2735,7 @@ function inspectMaskEdge(mask, image, options = {}) {
2609
2735
  if (img[i + 3] < 128) return null;
2610
2736
  return [img[i], img[i + 1], img[i + 2]];
2611
2737
  };
2612
- const premul = (x, y, c) => {
2613
- const ix = Math.min(image.width - 1, Math.max(0, x - image.x));
2614
- const iy = Math.min(image.height - 1, Math.max(0, y - image.y));
2615
- const i = (iy * image.width + ix) * 4;
2616
- return img[i + c] * img[i + 3] / 255;
2617
- };
2618
- const gradCache = /* @__PURE__ */ new Map();
2619
- const gradient = (x, y) => {
2620
- const k = y * W + x;
2621
- const hit = gradCache.get(k);
2622
- if (hit !== void 0) return hit;
2623
- let best = 0;
2624
- for (let c = 0; c < 3; c++) {
2625
- const p = (dx, dy) => premul(x + dx, y + dy, c);
2626
- const gx = p(1, -1) + 2 * p(1, 0) + p(1, 1) - p(-1, -1) - 2 * p(-1, 0) - p(-1, 1);
2627
- const gy = p(-1, 1) + 2 * p(0, 1) + p(1, 1) - p(-1, -1) - 2 * p(0, -1) - p(1, -1);
2628
- best = Math.max(best, Math.hypot(gx, gy) / 1020);
2629
- }
2630
- gradCache.set(k, best);
2631
- return best;
2632
- };
2738
+ const gradient = createGradientSampler(image, W);
2633
2739
  const B = Math.min(256, Math.max(24, Math.round(Math.max(W, H) / 12)));
2634
2740
  const BW = Math.ceil(W / B);
2635
2741
  const blocks = /* @__PURE__ */ new Map();
@@ -2806,6 +2912,30 @@ function inspectMaskEdge(mask, image, options = {}) {
2806
2912
  issues
2807
2913
  };
2808
2914
  }
2915
+ function createGradientSampler(image, W) {
2916
+ const img = image.data;
2917
+ const premul = (x, y, c) => {
2918
+ const ix = Math.min(image.width - 1, Math.max(0, x - image.x));
2919
+ const iy = Math.min(image.height - 1, Math.max(0, y - image.y));
2920
+ const i = (iy * image.width + ix) * 4;
2921
+ return img[i + c] * img[i + 3] / 255;
2922
+ };
2923
+ const cache = /* @__PURE__ */ new Map();
2924
+ return (x, y) => {
2925
+ const k = y * W + x;
2926
+ const hit = cache.get(k);
2927
+ if (hit !== void 0) return hit;
2928
+ let best = 0;
2929
+ for (let c = 0; c < 3; c++) {
2930
+ const p = (dx, dy) => premul(x + dx, y + dy, c);
2931
+ const gx = p(1, -1) + 2 * p(1, 0) + p(1, 1) - p(-1, -1) - 2 * p(-1, 0) - p(-1, 1);
2932
+ const gy = p(-1, 1) + 2 * p(0, 1) + p(1, 1) - p(-1, -1) - 2 * p(0, -1) - p(1, -1);
2933
+ best = Math.max(best, Math.hypot(gx, gy) / 1020);
2934
+ }
2935
+ cache.set(k, best);
2936
+ return best;
2937
+ };
2938
+ }
2809
2939
  function traceLoops(a, W, H, rx0, ry0, rx1, ry1) {
2810
2940
  const w = rx1 - rx0;
2811
2941
  const h = ry1 - ry0;
@@ -2983,10 +3113,139 @@ function regions(a, W, H, b, minArea) {
2983
3113
  const kept = list.filter((r) => r.area >= minArea).sort((p, q) => q.area - p.area);
2984
3114
  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) })) };
2985
3115
  };
2986
- const pieces = found.filter((r) => r.fg).sort((p, q) => q.area - p.area);
3116
+ const pieces2 = found.filter((r) => r.fg).sort((p, q) => q.area - p.area);
2987
3117
  return {
2988
3118
  holes: summarize(found.filter((r) => !r.fg && !r.outer)),
2989
- islands: summarize(pieces.slice(1))
3119
+ islands: summarize(pieces2.slice(1))
3120
+ };
3121
+ }
3122
+
3123
+ // src/image-editor/selection/uncertainty.ts
3124
+ var BACKED = 0.2;
3125
+ function selectionUncertainty(mask, image, options = {}) {
3126
+ const W = mask.width;
3127
+ const H = mask.height;
3128
+ const empty = () => ({ unknown: createSelectionMask(W, H), regions: [], count: 0, confidence: 1 });
3129
+ const b = mask.bounds;
3130
+ if (!b) return empty();
3131
+ const band = Math.max(1, Math.round(options.band ?? Math.max(2, Math.min(W, H) / 200)));
3132
+ const minArea = Math.max(1, options.minArea ?? 16);
3133
+ const maxRegions = Math.max(1, options.maxRegions ?? 20);
3134
+ const a = mask.data;
3135
+ const gradient = createGradientSampler(image, W);
3136
+ const x0 = Math.max(0, b.x - band - 1);
3137
+ const y0 = Math.max(0, b.y - band - 1);
3138
+ const x1 = Math.min(W, b.x + b.width + band + 1);
3139
+ const y1 = Math.min(H, b.y + b.height + band + 1);
3140
+ const w = x1 - x0;
3141
+ const h = y1 - y0;
3142
+ const evidence = new Float32Array(w * h).fill(NaN);
3143
+ const seeds = new Uint8Array(w * h);
3144
+ let sum = 0;
3145
+ let n = 0;
3146
+ const on = (x, y) => a[y * W + x] >= 128;
3147
+ for (let y = y0; y < y1; y++) {
3148
+ for (let x = x0; x < x1; x++) {
3149
+ const v = on(x, y);
3150
+ const differs = (nx, ny) => nx >= 0 && ny >= 0 && nx < W && ny < H && on(nx, ny) !== v;
3151
+ if (!(differs(x - 1, y) || differs(x + 1, y) || differs(x, y - 1) || differs(x, y + 1))) continue;
3152
+ let g = 0;
3153
+ for (let dy = -2; dy <= 2; dy++) {
3154
+ for (let dx = -2; dx <= 2; dx++) {
3155
+ const nx = x + dx;
3156
+ const ny = y + dy;
3157
+ if (nx >= 0 && ny >= 0 && nx < W && ny < H) g = Math.max(g, gradient(nx, ny));
3158
+ }
3159
+ }
3160
+ const e = Math.min(1, g / BACKED);
3161
+ const k = (y - y0) * w + (x - x0);
3162
+ evidence[k] = e;
3163
+ sum += e;
3164
+ n++;
3165
+ if (e < 0.5) seeds[k] = 1;
3166
+ }
3167
+ }
3168
+ if (!n) return empty();
3169
+ const rows = new Uint8Array(w * h);
3170
+ for (let y = 0; y < h; y++) {
3171
+ let last = -Infinity;
3172
+ for (let x = 0; x < w; x++) {
3173
+ if (seeds[y * w + x]) last = x;
3174
+ if (x - last <= band) rows[y * w + x] = 1;
3175
+ }
3176
+ last = Infinity;
3177
+ for (let x = w - 1; x >= 0; x--) {
3178
+ if (seeds[y * w + x]) last = x;
3179
+ if (last - x <= band) rows[y * w + x] = 1;
3180
+ }
3181
+ }
3182
+ const grown = new Uint8Array(w * h);
3183
+ for (let x = 0; x < w; x++) {
3184
+ let last = -Infinity;
3185
+ for (let y = 0; y < h; y++) {
3186
+ if (rows[y * w + x]) last = y;
3187
+ if (y - last <= band) grown[y * w + x] = 1;
3188
+ }
3189
+ last = Infinity;
3190
+ for (let y = h - 1; y >= 0; y--) {
3191
+ if (rows[y * w + x]) last = y;
3192
+ if (last - y <= band) grown[y * w + x] = 1;
3193
+ }
3194
+ }
3195
+ const label = new Int32Array(w * h);
3196
+ const stack = new Int32Array(w * h);
3197
+ const found = [];
3198
+ for (let start = 0; start < w * h; start++) {
3199
+ if (!grown[start] || label[start]) continue;
3200
+ const r = { area: 0, e: 0, en: 0, bx0: w, by0: h, bx1: -1, by1: -1 };
3201
+ const id = found.length + 1;
3202
+ let top = 0;
3203
+ stack[top++] = start;
3204
+ label[start] = id;
3205
+ while (top) {
3206
+ const k = stack[--top];
3207
+ const i = k % w;
3208
+ const j = (k - i) / w;
3209
+ r.area++;
3210
+ if (!Number.isNaN(evidence[k])) {
3211
+ r.e += evidence[k];
3212
+ r.en++;
3213
+ }
3214
+ if (i < r.bx0) r.bx0 = i;
3215
+ if (i > r.bx1) r.bx1 = i;
3216
+ if (j < r.by0) r.by0 = j;
3217
+ if (j > r.by1) r.by1 = j;
3218
+ for (let dy = -1; dy <= 1; dy++) {
3219
+ for (let dx = -1; dx <= 1; dx++) {
3220
+ const ni = i + dx;
3221
+ const nj = j + dy;
3222
+ if (ni < 0 || nj < 0 || ni >= w || nj >= h) continue;
3223
+ const nk = nj * w + ni;
3224
+ if (!grown[nk] || label[nk]) continue;
3225
+ label[nk] = id;
3226
+ stack[top++] = nk;
3227
+ }
3228
+ }
3229
+ }
3230
+ found.push(r);
3231
+ }
3232
+ const unknownData = new Uint8Array(W * H);
3233
+ for (let y = 0; y < h; y++) {
3234
+ for (let x = 0; x < w; x++) {
3235
+ const id = label[y * w + x];
3236
+ if (id && found[id - 1].area >= minArea) unknownData[(y + y0) * W + x + x0] = 255;
3237
+ }
3238
+ }
3239
+ const kept = found.filter((r) => r.area >= minArea).map((r) => ({
3240
+ bounds: { x: x0 + r.bx0, y: y0 + r.by0, width: r.bx1 - r.bx0 + 1, height: r.by1 - r.by0 + 1 },
3241
+ area: r.area,
3242
+ confidence: Math.round((r.en ? r.e / r.en : 0) * 1e3) / 1e3
3243
+ })).sort((p, q) => p.confidence - q.confidence || q.area - p.area);
3244
+ return {
3245
+ unknown: createSelectionMask(W, H, unknownData),
3246
+ regions: kept.slice(0, maxRegions),
3247
+ count: kept.length,
3248
+ confidence: Math.round(sum / n * 1e3) / 1e3
2990
3249
  };
2991
3250
  }
2992
3251
 
@@ -9730,6 +9989,7 @@ var ImageEditorController = class {
9730
9989
  */
9731
9990
  async refineSelectionTo(params, output = "selection", options = {}) {
9732
9991
  const edits = options.edits ?? [];
9992
+ const regions2 = this.checkRegions(options.regions);
9733
9993
  const refineMode = options.refineMode ?? "color";
9734
9994
  if (!this.state.selection?.bounds && !edits.length) throw new Error("Nothing is selected; select first, or pass edits that paint a selection.");
9735
9995
  if (refineMode === "object" && !this.segmentation) {
@@ -9764,7 +10024,14 @@ var ImageEditorController = class {
9764
10024
  if (!clipped?.bounds) throw new Error("The segmentation model found no object in the selection; use refineMode color.");
9765
10025
  sel = clipped;
9766
10026
  }
9767
- const refined = refineSelection(pixels, sel, params, edited.refineBrush);
10027
+ let refined = refineSelection(pixels, sel, params, edited.refineBrush);
10028
+ if (regions2) {
10029
+ const data = Uint8Array.from(sel.data);
10030
+ for (const r of regions2) {
10031
+ for (let y = r.y; y < r.y + r.height; y++) data.set(refined.data.subarray(y * width + r.x, y * width + r.x + r.width), y * width + r.x);
10032
+ }
10033
+ refined = createSelectionMask(width, height, data);
10034
+ }
9768
10035
  if (!refined.bounds) throw new Error("Refining left nothing selected; use a smaller shiftEdge or contrast.");
9769
10036
  if (toDocument) {
9770
10037
  const document2 = this.documentFromSelection({ layerId, withMask: output === "new-document-with-mask", selection: refined, decontaminate: amount * 100, radius: params.radius });
@@ -10209,6 +10476,96 @@ var ImageEditorController = class {
10209
10476
  const report = inspectMaskEdge(mask, image, options);
10210
10477
  return { target, ...layer ? { layerId: layer.id } : {}, ...report };
10211
10478
  }
10479
+ /** Whole-pixel rectangles inside the canvas, or a useful error; undefined stays undefined. */
10480
+ checkRegions(regions2) {
10481
+ if (regions2 === void 0) return void 0;
10482
+ const { width, height } = this.naturalSize;
10483
+ if (!Array.isArray(regions2) || !regions2.length) throw new Error("regions must be a non-empty list of { x, y, width, height } (e.g. the uncertainRegions bounds); leave it out to refine everywhere.");
10484
+ return regions2.map((r, i) => {
10485
+ const x = Math.max(0, Math.floor(r?.x));
10486
+ const y = Math.max(0, Math.floor(r?.y));
10487
+ const x1 = Math.min(width, Math.ceil(r?.x + r?.width));
10488
+ const y1 = Math.min(height, Math.ceil(r?.y + r?.height));
10489
+ if (![x, y, x1, y1].every(Number.isFinite) || x1 <= x || y1 <= y) throw new Error(`regions[${i}] must overlap the image (0..${width} \xD7 0..${height}), got ${JSON.stringify(r)}.`);
10490
+ return { x, y, width: x1 - x, height: y1 - y };
10491
+ });
10492
+ }
10493
+ /**
10494
+ * Where the selection is unsure of itself: the stretches of its edge with no image edge under
10495
+ * them, widened into a band (a trimap's unknown), as a mask and regions with bounds and
10496
+ * confidence, least confident first. Measured against the composite. Read-only; an empty
10497
+ * selection has none.
10498
+ */
10499
+ selectionUncertainty(options = {}) {
10500
+ const { width, height } = this.naturalSize;
10501
+ const sel = this.getSelection();
10502
+ if (!sel?.bounds) return selectionUncertainty(createSelectionMask(width, height), { x: 0, y: 0, width: 0, height: 0, data: new Uint8ClampedArray(0) }, options);
10503
+ const b = sel.bounds;
10504
+ const m = Math.max(8, Math.ceil(options.band ?? Math.max(2, Math.min(width, height) / 200)) + 4);
10505
+ const x = Math.max(0, b.x - m);
10506
+ const y = Math.max(0, b.y - m);
10507
+ const region = { x, y, width: Math.min(width, b.x + b.width + m) - x, height: Math.min(height, b.y + b.height + m) - y };
10508
+ return selectionUncertainty(sel, { ...this.compositePixels(region), x, y }, options);
10509
+ }
10510
+ /**
10511
+ * A selection to compare (`compareSelections`): the current one, the one `steps` undo steps
10512
+ * ago, a saved channel, or a layer's painted or vector mask. `null` is nothing selected.
10513
+ */
10514
+ selectionFrom(source) {
10515
+ const { width, height } = this.naturalSize;
10516
+ switch (source?.kind) {
10517
+ case "selection":
10518
+ return this.getSelection();
10519
+ case "history": {
10520
+ const steps = source.steps ?? 1;
10521
+ const depth = this.undoStack.length;
10522
+ if (!Number.isInteger(steps) || steps < 1) throw new LayerOperationError(`steps must be a whole number of undo steps back, at least 1; got ${String(source.steps)}.`);
10523
+ if (steps > depth) throw new LayerOperationError(`There ${depth === 1 ? "is 1 undo step" : `are ${depth} undo steps`}; steps must be 1..${depth || 1}${depth ? "" : " once something has been done"}.`);
10524
+ return this.undoStack[depth - steps].selection ?? null;
10525
+ }
10526
+ case "channel":
10527
+ return this.requireChannel(source.channelId).mask;
10528
+ case "layer-mask": {
10529
+ const layer = this.getLayer(source.layerId);
10530
+ if (!layer) throw new LayerOperationError(`There is no layer "${source.layerId}" (see list_layers).`);
10531
+ const mask = this.layerMaskCoverage(layer.id);
10532
+ if (!mask) throw new LayerOperationError(`Layer "${layer.name || layer.id}" has no painted mask; add one with add_layer_mask, or compare its vector-mask.`);
10533
+ return mask;
10534
+ }
10535
+ case "vector-mask": {
10536
+ const layer = this.getLayer(source.layerId);
10537
+ if (!layer) throw new LayerOperationError(`There is no layer "${source.layerId}" (see list_layers).`);
10538
+ const { vm } = this.requireVectorMask(layer.id);
10539
+ const mask = pathToSelection(vm.components, width, height, { fillRule: vm.fillRule });
10540
+ return vm.inverted ? invertSelection(mask) : mask;
10541
+ }
10542
+ default:
10543
+ throw new LayerOperationError(`Unknown selection source ${JSON.stringify(source)}. Use selection, history, channel, layer-mask or vector-mask.`);
10544
+ }
10545
+ }
10546
+ /**
10547
+ * What changed from selection `a` (default: before the last undo step) to `b` (default: the
10548
+ * current selection): added and removed area with their largest pieces, IoU and the coverage
10549
+ * change. With `show` (default true) the canvas tints added area green and removed red until the
10550
+ * selection next changes (view state, no undo step). Read-only otherwise.
10551
+ */
10552
+ compareSelections(a = { kind: "history", steps: 1 }, b = { kind: "selection" }, options = {}) {
10553
+ const { width, height } = this.naturalSize;
10554
+ const ma = this.selectionFrom(a);
10555
+ const mb = this.selectionFrom(b);
10556
+ const { addedMask, removedMask, ...diff } = compareSelectionMasks(ma, mb, width, height, options);
10557
+ if (options.show !== false) {
10558
+ this.state.selectionDiff = diff.identical ? null : { added: addedMask, removed: removedMask, selection: this.state.selection ?? null };
10559
+ this.notify();
10560
+ }
10561
+ return diff;
10562
+ }
10563
+ /** Hides the `compareSelections` overlay. */
10564
+ clearSelectionDiff() {
10565
+ if (!this.state.selectionDiff) return;
10566
+ this.state.selectionDiff = null;
10567
+ this.notify();
10568
+ }
10212
10569
  /** A layer's painted mask over the canvas as a selection mask; null without one (or for a depth mask). */
10213
10570
  layerMaskCoverage(layerId) {
10214
10571
  const layer = this.getLayer(layerId);
@@ -12921,6 +13278,10 @@ function rectOf(args) {
12921
13278
  if (rect2.width <= 0 || rect2.height <= 0) throw new Error("width and height must be greater than 0.");
12922
13279
  return rect2;
12923
13280
  }
13281
+ function uncertainty(controller) {
13282
+ const u = controller.selectionUncertainty();
13283
+ return { uncertainRegions: u.regions, uncertainCount: u.count, edgeConfidence: u.confidence };
13284
+ }
12924
13285
  function summary(controller) {
12925
13286
  const sel = controller.getSelection();
12926
13287
  const { width, height } = controller.getState().imageDimensions;
@@ -12970,6 +13331,65 @@ function createImageSelectionTools(controller) {
12970
13331
  return controller.inspectMaskEdge(t, { layerId, minArea });
12971
13332
  }
12972
13333
  };
13334
+ const sourceSchema = (which, dflt) => ({
13335
+ type: "object",
13336
+ properties: {
13337
+ source: { type: "string", enum: ["selection", "history", "channel", "layer-mask", "vector-mask"], description: "selection: the current one; history: as it was steps undo steps ago; channel: a saved selection (channelId, see list_channels); layer-mask / vector-mask: a layer's mask (layerId)." },
13338
+ steps: { type: "integer", minimum: 1, description: "history only: how many undo steps back (default 1, before the last edit)." },
13339
+ channelId: { type: "string", description: "channel only: the channel id from list_channels." },
13340
+ layerId: { type: "string", description: "layer-mask and vector-mask only: the layer id from list_layers." }
13341
+ },
13342
+ required: ["source"],
13343
+ additionalProperties: false,
13344
+ description: `${which} (default ${dflt}).`
13345
+ });
13346
+ const toSource = (v, fallback) => {
13347
+ if (v === void 0) return fallback;
13348
+ const o = v;
13349
+ const extra = (keys) => {
13350
+ const bad = Object.keys(o).filter((k) => k !== "source" && !keys.includes(k));
13351
+ if (bad.length) throw new Error(`${bad.join(", ")} ${bad.length === 1 ? "does" : "do"} not apply to source ${o.source}.`);
13352
+ };
13353
+ switch (o.source) {
13354
+ case "selection":
13355
+ extra([]);
13356
+ return { kind: "selection" };
13357
+ case "history":
13358
+ extra(["steps"]);
13359
+ return { kind: "history", steps: o.steps ?? 1 };
13360
+ case "channel":
13361
+ extra(["channelId"]);
13362
+ if (!o.channelId) throw new Error("source channel needs channelId (see list_channels).");
13363
+ return { kind: "channel", channelId: o.channelId };
13364
+ case "layer-mask":
13365
+ case "vector-mask":
13366
+ extra(["layerId"]);
13367
+ if (!o.layerId) throw new Error(`source ${o.source} needs layerId (see list_layers).`);
13368
+ return { kind: o.source, layerId: o.layerId };
13369
+ default:
13370
+ throw new Error(`Unknown source "${String(o.source)}". Use selection, history, channel, layer-mask or vector-mask.`);
13371
+ }
13372
+ };
13373
+ const compareTool = {
13374
+ name: "compare_selections",
13375
+ title: "Compare two selections",
13376
+ description: "Shows what changed between two selections, to check that a modify, refine or select step did what you meant. a (default: the selection before the last undo step) and b (default: the current selection) can each be the current selection, a history step, a saved channel or a layer's mask. Returns a and b (area, bounds), added and removed (area in pixels, count and the 10 largest pieces with bounds, ready for capture_view), iou 0-1, coverageChange (pixels of coverage changed, counting edges that only softened) and identical. With show (default true) the canvas tints added area green and removed red until the selection next changes (view only, no undo step).",
13377
+ inputSchema: {
13378
+ type: "object",
13379
+ properties: {
13380
+ a: sourceSchema("The selection before", "history, 1 step back"),
13381
+ b: sourceSchema("The selection after", "the current selection"),
13382
+ minArea: { type: "number", minimum: 1, maximum: 1e6, description: "Pieces smaller than this many pixels are counted but not listed (default 4)." },
13383
+ show: { type: "boolean", description: "Tint the change on the canvas (default true)." }
13384
+ },
13385
+ additionalProperties: false
13386
+ },
13387
+ annotations: { title: "Compare two selections", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
13388
+ handler: ({ a, b, minArea, show }) => controller.compareSelections(toSource(a, { kind: "history", steps: 1 }), toSource(b, { kind: "selection" }), {
13389
+ ...minArea !== void 0 ? { minArea } : {},
13390
+ show: show !== false
13391
+ })
13392
+ };
12973
13393
  const selectRegion = {
12974
13394
  name: "select_region",
12975
13395
  title: "Select a region",
@@ -13145,7 +13565,14 @@ function createImageSelectionTools(controller) {
13145
13565
  additionalProperties: false
13146
13566
  }
13147
13567
  },
13148
- refineMode: { type: "string", enum: ["color", "object"], description: "color (default): refine from the image colours; object: from a host segmentation model's outline of the object (needs a model)." }
13568
+ refineMode: { type: "string", enum: ["color", "object"], description: "color (default): refine from the image colours; object: from a host segmentation model's outline of the object (needs a model)." },
13569
+ regions: {
13570
+ type: "array",
13571
+ minItems: 1,
13572
+ maxItems: 50,
13573
+ items: rectSchemaStrict,
13574
+ description: "Refine only inside these rectangles (natural image pixels), e.g. the uncertainRegions bounds a smart selection returned; the rest of the selection stays as it is. Default: everywhere."
13575
+ }
13149
13576
  },
13150
13577
  additionalProperties: false
13151
13578
  },
@@ -13176,7 +13603,8 @@ function createImageSelectionTools(controller) {
13176
13603
  layerId: typeof params.layerId === "string" ? params.layerId : void 0,
13177
13604
  decontaminate: num2(params.decontaminate, 0),
13178
13605
  edits,
13179
- refineMode: params.refineMode === "object" ? "object" : "color"
13606
+ refineMode: params.refineMode === "object" ? "object" : "color",
13607
+ ...params.regions !== void 0 ? { regions: params.regions } : {}
13180
13608
  }
13181
13609
  );
13182
13610
  return { ...summary(controller), ...result };
@@ -13332,7 +13760,7 @@ function createImageSelectionTools(controller) {
13332
13760
  const quickSelectTool = {
13333
13761
  name: "quick_select",
13334
13762
  title: "Quick Selection",
13335
- description: "Quick Selection: paints brush strokes (paths in natural image pixels) that each grow into the region they are painted on, out to its edges (a graph cut on colour and edges), combined with the selection by mode. With replace (default) the first stroke replaces the selection and later ones add, as the brush does; use mode subtract to take an area back out. Best for a subject or area that differs from its surroundings: paint inside it, away from the edge, then check with get_selection and capture_view and add or subtract strokes where it leaked or fell short. All strokes are one undoable step. Returns the selection.",
13763
+ description: "Quick Selection: paints brush strokes (paths in natural image pixels) that each grow into the region they are painted on, out to its edges (a graph cut on colour and edges), combined with the selection by mode. With replace (default) the first stroke replaces the selection and later ones add, as the brush does; use mode subtract to take an area back out. Best for a subject or area that differs from its surroundings: paint inside it, away from the edge, then check with get_selection and capture_view and add or subtract strokes where it leaked or fell short. All strokes are one undoable step. Returns the selection. Also returns uncertainRegions (bounds, area, confidence 0-1; up to 20, least confident first), uncertainCount and edgeConfidence: where the edge has no image edge under it. Look at those with capture_view and fix only them (refine_selection with regions).",
13336
13764
  inputSchema: {
13337
13765
  type: "object",
13338
13766
  properties: {
@@ -13363,13 +13791,13 @@ function createImageSelectionTools(controller) {
13363
13791
  enhanceEdge: args.enhanceEdge,
13364
13792
  signal: context?.signal
13365
13793
  });
13366
- return summary(controller);
13794
+ return { ...summary(controller), ...uncertainty(controller) };
13367
13795
  }
13368
13796
  };
13369
13797
  const selectObjectTool = {
13370
13798
  name: "select_object",
13371
13799
  title: "Object Selection",
13372
- description: 'Object Selection: selects the object inside rect (a box drawn loosely around it, natural image pixels; what lies outside the box is treated as background) or the object under point. With a host segmentation model it asks the model (falling back if nothing it returns fits); otherwise it finds the object from the colours of object and background and the edges between them (GrabCut). Combined with the selection by mode. A box is more reliable than a point: make it a little larger than the object on every side. Check with get_selection and capture_view, then fix with quick_select strokes or select_object with mode add / subtract. One undoable step. Returns the selection and method ("model" or "grabcut").',
13800
+ description: 'Object Selection: selects the object inside rect (a box drawn loosely around it, natural image pixels; what lies outside the box is treated as background) or the object under point. With a host segmentation model it asks the model (falling back if nothing it returns fits); otherwise it finds the object from the colours of object and background and the edges between them (GrabCut). Combined with the selection by mode. A box is more reliable than a point: make it a little larger than the object on every side. Check with get_selection and capture_view, then fix with quick_select strokes or select_object with mode add / subtract. One undoable step. Returns the selection and method ("model" or "grabcut"). Also returns uncertainRegions (bounds, area, confidence 0-1; up to 20, least confident first), uncertainCount and edgeConfidence: where the edge has no image edge under it. Look at those with capture_view and fix only them (refine_selection with regions).',
13373
13801
  inputSchema: {
13374
13802
  type: "object",
13375
13803
  properties: {
@@ -13402,7 +13830,7 @@ function createImageSelectionTools(controller) {
13402
13830
  signal: context?.signal
13403
13831
  };
13404
13832
  const { method } = rect2 ? await controller.selectObject({ rect: { x: num2(rect2.x, NaN), y: num2(rect2.y, NaN), width: num2(rect2.width, NaN), height: num2(rect2.height, NaN) } }, options) : await controller.selectObject({ point: { x: num2(point.x, NaN), y: num2(point.y, NaN) } }, options);
13405
- return { ...summary(controller), method };
13833
+ return { ...summary(controller), method, ...uncertainty(controller) };
13406
13834
  }
13407
13835
  };
13408
13836
  const selectFocusAreaTool = {
@@ -13436,7 +13864,7 @@ function createImageSelectionTools(controller) {
13436
13864
  const selectSkyTool = {
13437
13865
  name: "select_sky",
13438
13866
  title: "Select the sky",
13439
- description: 'Select \u203A Sky: selects the sky, combined with the selection by mode. With a host segmentation model it asks the model; otherwise it finds it classically (blue, or bright and grey when overcast, smooth, reaching down from the top of the image, including patches seen through branches or gaps) with the edge fitted to the skyline. Use it to replace or recolour a sky or to adjust the land without it (invert_selection). Raise threshold to take in haze or a sunset glow, lower it if bright buildings come in. One undoable step. Returns the selection, method ("model" or "classical"; threshold and softness apply to classical), and a note when no sky was found (the selection is then unchanged).',
13867
+ description: 'Select \u203A Sky: selects the sky, combined with the selection by mode. With a host segmentation model it asks the model; otherwise it finds it classically (blue, or bright and grey when overcast, smooth, reaching down from the top of the image, including patches seen through branches or gaps) with the edge fitted to the skyline. Use it to replace or recolour a sky or to adjust the land without it (invert_selection). Raise threshold to take in haze or a sunset glow, lower it if bright buildings come in. One undoable step. Returns the selection, method ("model" or "classical"; threshold and softness apply to classical), and a note when no sky was found (the selection is then unchanged). Also returns uncertainRegions (bounds, area, confidence 0-1; up to 20, least confident first), uncertainCount and edgeConfidence: where the edge has no image edge under it. Look at those with capture_view and fix only them (refine_selection with regions).',
13440
13868
  inputSchema: {
13441
13869
  type: "object",
13442
13870
  properties: {
@@ -13458,7 +13886,7 @@ function createImageSelectionTools(controller) {
13458
13886
  useModel: args.useModel,
13459
13887
  signal: context?.signal
13460
13888
  });
13461
- return { ...summary(controller), method, ...note ? { note } : {} };
13889
+ return { ...summary(controller), method, ...note ? { note } : {}, ...uncertainty(controller) };
13462
13890
  }
13463
13891
  };
13464
13892
  const magicWandTool = {
@@ -14016,6 +14444,7 @@ function createImageSelectionTools(controller) {
14016
14444
  return [
14017
14445
  getSelection,
14018
14446
  inspectEdge,
14447
+ compareTool,
14019
14448
  selectAll,
14020
14449
  deselect,
14021
14450
  invert2,
@@ -14063,7 +14492,7 @@ function createImageSubjectSelectionTool(controller) {
14063
14492
  return {
14064
14493
  name: "select_subject",
14065
14494
  title: "Select a subject",
14066
- description: 'Select \u203A Subject: selects the main subject of the flattened image, combined with the selection by mode. With a segmentation model set by the host, target (a description such as "the dog" or "the house") chooses which objects; describe one object precisely to get only that one. Without a model, the subject that stands out most from the background is found classically (superpixel saliency and GrabCut): target cannot choose it, and method "saliency" and a note say so. Then check it with capture_view and fix it with quick_select or select_object. One undoable step. Returns the selection, the objects found (label, bounds), method ("model" or "saliency") and any note; none found leaves the selection unchanged.',
14495
+ description: 'Select \u203A Subject: selects the main subject of the flattened image, combined with the selection by mode. With a segmentation model set by the host, target (a description such as "the dog" or "the house") chooses which objects; describe one object precisely to get only that one. Without a model, the subject that stands out most from the background is found classically (superpixel saliency and GrabCut): target cannot choose it, and method "saliency" and a note say so. Then check it with capture_view and fix it with quick_select or select_object. One undoable step. Returns the selection, the objects found (label, bounds), method ("model" or "saliency") and any note; none found leaves the selection unchanged. Also returns uncertainRegions (bounds, area, confidence 0-1; up to 20, least confident first), uncertainCount and edgeConfidence: where the edge has no image edge under it. Look at those with capture_view and fix only them (refine_selection with regions).',
14067
14496
  inputSchema: {
14068
14497
  type: "object",
14069
14498
  properties: {
@@ -14094,7 +14523,7 @@ function createImageSubjectSelectionTool(controller) {
14094
14523
  // Under playback the cursor traces what the model found, as a lasso, before it is selected.
14095
14524
  beforeApply: perform ? (outlines) => perform(planOutlineTrace(outlines)) : void 0
14096
14525
  });
14097
- return { ...summary(controller), found: segments, method, ...note ? { note } : {} };
14526
+ return { ...summary(controller), found: segments, method, ...note ? { note } : {}, ...uncertainty(controller) };
14098
14527
  }
14099
14528
  };
14100
14529
  }
@@ -22043,6 +22472,7 @@ export {
22043
22472
  colorRange,
22044
22473
  colorRangePreset,
22045
22474
  combineSelections,
22475
+ compareSelectionMasks,
22046
22476
  compileAdjustmentLayer,
22047
22477
  componentPath,
22048
22478
  componentsCoverage,
@@ -22070,6 +22500,7 @@ export {
22070
22500
  createAdjustmentLayer,
22071
22501
  createBlenderScratch,
22072
22502
  createCanvasTextShaper,
22503
+ createGradientSampler,
22073
22504
  createImageCaptureSurface,
22074
22505
  createImageColorTools,
22075
22506
  createImageEditTools,
@@ -22587,6 +23018,7 @@ export {
22587
23018
  selectObjectIn,
22588
23019
  selectSimilar,
22589
23020
  selectionArea,
23021
+ selectionDiffPixels,
22590
23022
  selectionFromCoverage,
22591
23023
  selectionFromPixels,
22592
23024
  selectionFromRegion,
@@ -22597,6 +23029,7 @@ export {
22597
23029
  selectionToCoverage,
22598
23030
  selectionToPixels,
22599
23031
  selectionToSubpaths,
23032
+ selectionUncertainty,
22600
23033
  selectionValueAt,
22601
23034
  serializeChannels,
22602
23035
  serializeColorMode,