mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 11:52:25 +02:00
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>
175 lines
5.5 KiB
JavaScript
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;
|
|
}
|