mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-23 07:12:21 +02:00
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c74f475679
commit
5fac693c1c
@@ -0,0 +1,174 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user