Files
odysseus/static/js/editor/select-subject.js
T
pewdiepie-archdaemonandClaude Opus 5.5 5fac693c1c feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).

Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:57:35 +00:00

175 lines
5.5 KiB
JavaScript

// Select Subject — one-click subject selection.
//
// This is an offline, deterministic heuristic (no network, no AI): it
// estimates the background colour from the image border, scores every cell
// by how far it is from that background (plus a small centre bias), splits
// the image with Otsu's threshold, keeps the largest connected component,
// and upsamples the result into a soft selection mask. It is a saliency
// approximation of Photoshop's on-device Select Subject, not the ML model.
function otsuThreshold(hist, total) {
let sum = 0;
for (let i = 0; i < 256; i++) sum += i * hist[i];
let sumB = 0;
let wB = 0;
let best = 0;
let threshold = 0;
for (let i = 0; i < 256; i++) {
wB += hist[i];
if (!wB) continue;
const wF = total - wB;
if (!wF) break;
sumB += i * hist[i];
const mB = sumB / wB;
const mF = (sum - sumB) / wF;
const between = wB * wF * (mB - mF) * (mB - mF);
if (between > best) {
best = between;
threshold = i;
}
}
return threshold;
}
const MAX_CHANNEL_DIST = 441.6729559300637; // sqrt(3 * 255^2)
export function computeSubjectMask(data, width, height, options = {}) {
if (!data || width < 2 || height < 2) return null;
const maxDim = Math.max(32, Math.min(512, Number(options.maxDim) || 200));
const scale = Math.min(1, maxDim / Math.max(width, height));
const sw = Math.max(2, Math.round(width * scale));
const sh = Math.max(2, Math.round(height * scale));
const cellW = width / sw;
const cellH = height / sh;
// Average source pixels into a small grid.
const cells = new Float32Array(sw * sh * 3);
for (let sy = 0; sy < sh; sy++) {
const y0 = Math.floor(sy * cellH);
const y1 = Math.max(y0 + 1, Math.min(height, Math.floor((sy + 1) * cellH)));
for (let sx = 0; sx < sw; sx++) {
const x0 = Math.floor(sx * cellW);
const x1 = Math.max(x0 + 1, Math.min(width, Math.floor((sx + 1) * cellW)));
let r = 0;
let g = 0;
let b = 0;
let n = 0;
for (let y = y0; y < y1; y++) {
let idx = (y * width + x0) * 4;
for (let x = x0; x < x1; x++, idx += 4) {
r += data[idx];
g += data[idx + 1];
b += data[idx + 2];
n++;
}
}
const o = (sy * sw + sx) * 3;
if (n) {
cells[o] = r / n;
cells[o + 1] = g / n;
cells[o + 2] = b / n;
}
}
}
// Background colour = average of the border cells.
let br = 0;
let bg = 0;
let bb = 0;
let bn = 0;
const addCell = (sx, sy) => {
const o = (sy * sw + sx) * 3;
br += cells[o];
bg += cells[o + 1];
bb += cells[o + 2];
bn++;
};
for (let sx = 0; sx < sw; sx++) {
addCell(sx, 0);
addCell(sx, sh - 1);
}
for (let sy = 1; sy < sh - 1; sy++) {
addCell(0, sy);
addCell(sw - 1, sy);
}
br /= bn;
bg /= bn;
bb /= bn;
// Saliency = distance from the background (+ a small centre bias).
const sal = new Float32Array(sw * sh);
let maxSal = 0;
for (let sy = 0; sy < sh; sy++) {
for (let sx = 0; sx < sw; sx++) {
const o = (sy * sw + sx) * 3;
const dr = cells[o] - br;
const dg = cells[o + 1] - bg;
const db = cells[o + 2] - bb;
const dist = Math.sqrt(dr * dr + dg * dg + db * db) / MAX_CHANNEL_DIST;
const nx = ((sx + 0.5) / sw) * 2 - 1;
const ny = ((sy + 0.5) / sh) * 2 - 1;
const center = Math.max(0, 1 - (nx * nx + ny * ny));
const v = dist + center * 0.12;
sal[sy * sw + sx] = v;
if (v > maxSal) maxSal = v;
}
}
// No clear foreground -> nothing to select.
if (maxSal < 0.12) return null;
const hist = new Int32Array(256);
for (let i = 0; i < sal.length; i++) {
hist[Math.min(255, Math.round((sal[i] / maxSal) * 255))]++;
}
const threshold = (otsuThreshold(hist, sal.length) / 255) * maxSal;
const bin = new Uint8Array(sw * sh);
for (let i = 0; i < sal.length; i++) bin[i] = sal[i] >= threshold ? 1 : 0;
// Keep the largest connected component (4-neighbour flood fill).
const seen = new Uint8Array(sw * sh);
let best = null;
for (let start = 0; start < bin.length; start++) {
if (!bin[start] || seen[start]) continue;
const group = [];
const stack = [start];
seen[start] = 1;
while (stack.length) {
const cell = stack.pop();
group.push(cell);
const cx = cell % sw;
const cy = (cell - cx) / sw;
if (cx > 0) { const nn = cell - 1; if (bin[nn] && !seen[nn]) { seen[nn] = 1; stack.push(nn); } }
if (cx < sw - 1) { const nn = cell + 1; if (bin[nn] && !seen[nn]) { seen[nn] = 1; stack.push(nn); } }
if (cy > 0) { const nn = cell - sw; if (bin[nn] && !seen[nn]) { seen[nn] = 1; stack.push(nn); } }
if (cy < sh - 1) { const nn = cell + sw; if (bin[nn] && !seen[nn]) { seen[nn] = 1; stack.push(nn); } }
}
if (!best || group.length > best.length) best = group;
}
if (!best || best.length < 0.005 * sw * sh) return null;
const small = document.createElement('canvas');
small.width = sw;
small.height = sh;
const sctx = small.getContext('2d');
const out = sctx.createImageData(sw, sh);
for (const cell of best) {
const o = cell * 4;
out.data[o] = 255;
out.data[o + 1] = 255;
out.data[o + 2] = 255;
out.data[o + 3] = 255;
}
sctx.putImageData(out, 0, 0);
const mask = document.createElement('canvas');
mask.width = width;
mask.height = height;
const mctx = mask.getContext('2d');
mctx.imageSmoothingEnabled = true;
mctx.imageSmoothingQuality = 'high';
mctx.drawImage(small, 0, 0, width, height);
return mask;
}