summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-03-24 11:02:36 +0100
committerskal <pascal.massimino@gmail.com>2026-03-24 11:02:36 +0100
commit68e1e7cfb007546e493c469395c253c7a5937585 (patch)
tree3f4154135124534929f2f62982a1945d8f1d0ca2 /tools
parent249687bee829fccbbacfdee8b90fc0d0fe3ba7d3 (diff)
move and improve the 'adjust.html' tool
Diffstat (limited to 'tools')
-rw-r--r--tools/adjust.html419
1 files changed, 419 insertions, 0 deletions
diff --git a/tools/adjust.html b/tools/adjust.html
new file mode 100644
index 0000000..3b5b243
--- /dev/null
+++ b/tools/adjust.html
@@ -0,0 +1,419 @@
+<!-- tools for https://colorifyai.art/photo-to-sketch/#playground in 'Ink Sketch' mode -->
+
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="UTF-8">
+<title>Align & Optimize</title>
+<style>
+body{font-family:sans-serif;text-align:center;background:#f5f5f5}
+canvas{border:1px solid #ccc;margin-top:10px;cursor:grab}
+textarea{width:90%;height:90px;margin:10px;font-family:monospace;font-size:12px}
+button{margin:5px}
+button:disabled{opacity:0.5}
+#status{font-size:12px;color:#666;height:16px}
+</style>
+</head>
+<body>
+
+<h3>Align & Optimize</h3>
+
+<textarea id="cmd"></textarea><br>
+<button id="copy">Copy commands</button><br>
+
+<input type="file" id="f1">
+<input type="file" id="f2"><br><br>
+
+<button id="opt">Optimize</button>
+<button id="optZoom">Optimize Zoom</button>
+<button id="crop">Crop</button>
+<button id="dl" disabled>Download</button>
+<br>
+Iterations: <input type="range" id="iterSlider" min="2" max="30" value="8" style="vertical-align:middle;width:120px">
+<span id="iterVal">8</span>
+
+<div>MSE: <span id="score">-</span> &nbsp; Overlap: <span id="overlap">-</span></div>
+<div id="status"></div>
+
+<canvas id="cv"></canvas>
+
+<script>
+const canvas = document.getElementById('cv');
+function ctx() { return canvas.getContext('2d'); }
+const scoreEl = document.getElementById('score');
+const overlapEl = document.getElementById('overlap');
+const statusEl = document.getElementById('status');
+const cmdEl = document.getElementById('cmd');
+
+let i1 = new Image(), i2 = new Image(), l1 = 0, l2 = 0;
+let name1 = "image1.png", name2 = "image2.png";
+
+let ox = 0, oy = 0, sx = 1, sy = 1;
+let mx = 0, my = 0, drag = 0, lx = 0, ly = 0;
+
+let out1, out2;
+let pyr1 = [], pyr2 = [];
+const LEVELS = 4;
+
+let optimizing = false;
+
+// --- pyramid ---
+// FIX: clamp x+1 within row to avoid cross-row bleed
+function buildPyramid(img) {
+ let levels = [];
+ let cv = document.createElement('canvas');
+ cv.width = img.width; cv.height = img.height;
+ let x = cv.getContext('2d');
+ x.drawImage(img, 0, 0);
+
+ let d = x.getImageData(0, 0, img.width, img.height).data;
+ let g = new Float32Array(img.width * img.height);
+ for (let i = 0, j = 0; i < d.length; i += 4, j++)
+ g[j] = 0.299 * d[i] + 0.587 * d[i+1] + 0.114 * d[i+2];
+ levels.push({ w: img.width, h: img.height, data: g });
+
+ for (let l = 1; l < LEVELS; l++) {
+ let p = levels[l-1];
+ let w2 = Math.floor(p.w / 2), h2 = Math.floor(p.h / 2);
+ let g2 = new Float32Array(w2 * h2);
+ for (let y = 0; y < h2; y++) {
+ for (let xi = 0; xi < w2; xi++) {
+ // clamp xi+1 to stay within the row (p.w-1)
+ let x0 = 2 * xi, x1 = Math.min(2 * xi + 1, p.w - 1);
+ let y0 = 2 * y, y1 = Math.min(2 * y + 1, p.h - 1);
+ g2[y * w2 + xi] = (
+ p.data[y0 * p.w + x0] + p.data[y0 * p.w + x1] +
+ p.data[y1 * p.w + x0] + p.data[y1 * p.w + x1]
+ ) * 0.25;
+ }
+ }
+ levels.push({ w: w2, h: h2, data: g2 });
+ }
+ return levels;
+}
+
+// --- bilinear sample with bounds check ---
+// FIX: return NaN for out-of-bounds so caller can skip; clamp indices correctly
+function sample(img, x, y) {
+ if (x < 0 || y < 0 || x >= img.w || y >= img.h) return NaN;
+ let x0 = Math.floor(x), y0 = Math.floor(y);
+ let x1 = Math.min(x0 + 1, img.w - 1); // FIX: was img.w (off-by-one)
+ let y1 = Math.min(y0 + 1, img.h - 1); // FIX: was img.h (off-by-one)
+ let dx = x - x0, dy = y - y0;
+ let i00 = img.data[y0 * img.w + x0];
+ let i10 = img.data[y0 * img.w + x1];
+ let i01 = img.data[y1 * img.w + x0];
+ let i11 = img.data[y1 * img.w + x1];
+ return (i00 * (1-dx) + i10 * dx) * (1-dy) + (i01 * (1-dx) + i11 * dx) * dy;
+}
+
+// --- MSE over overlapping region only ---
+// FIX: compute the intersection of A's rect and the mapped B rect in A-space,
+// then only accumulate pixels where B is defined.
+function computeMSELevel(level) {
+ if (!pyr1.length || !pyr2.length) return Infinity;
+ let A = pyr1[level], B = pyr2[level];
+ let sF = 1 / (2 ** level);
+
+ // B's bounding box in A-pixel space at this pyramid level
+ let tox = ox * sF, toy = oy * sF;
+ let tsx = sx, tsy = sy;
+
+ // B covers [tox, tox + B.w*tsx] x [toy, toy + B.h*tsy] in A-space
+ let bx0 = tox, bx1 = tox + B.w * tsx;
+ let by0 = toy, by1 = toy + B.h * tsy;
+
+ // Intersect with A's extent [0, A.w] x [0, A.h]
+ let ix0 = Math.max(0, Math.ceil(bx0));
+ let iy0 = Math.max(0, Math.ceil(by0));
+ let ix1 = Math.min(A.w, Math.floor(bx1));
+ let iy1 = Math.min(A.h, Math.floor(by1));
+
+ if (ix1 <= ix0 || iy1 <= iy0) return Infinity;
+
+ let mse = 0, n = 0;
+ for (let y = iy0; y < iy1; y++) {
+ for (let x = ix0; x < ix1; x++) {
+ let bx = (x - tox) / tsx;
+ let by = (y - toy) / tsy;
+ let bVal = sample(B, bx, by);
+ if (isNaN(bVal)) continue;
+ let d = A.data[y * A.w + x] - bVal;
+ mse += d * d;
+ n++;
+ }
+ }
+
+ // Update overlap display (only at level 0)
+ if (level === 0) {
+ let pct = n / (A.w * A.h) * 100;
+ overlapEl.textContent = n > 0 ? pct.toFixed(1) + '%' : '0%';
+ }
+
+ return n ? mse / n : Infinity;
+}
+
+function computeMSE() { return computeMSELevel(0); }
+
+// --- draw ---
+function draw() {
+ if (!l1) return;
+ const c = ctx();
+ c.clearRect(0, 0, canvas.width, canvas.height);
+ c.drawImage(i1, 0, 0);
+ if (l2) {
+ c.save();
+ c.globalAlpha = 0.5;
+ c.translate(ox, oy);
+ c.scale(sx, sy);
+ c.drawImage(i2, 0, 0);
+ c.restore();
+ }
+ scoreEl.textContent = computeMSE().toFixed(2);
+ updateCmd();
+}
+
+// --- load ---
+f1.onchange = e => {
+ let f = e.target.files[0]; if (!f) return;
+ name1 = f.name;
+ i1 = new Image();
+ i1.onload = () => {
+ l1 = 1;
+ canvas.width = i1.width; canvas.height = i1.height;
+ pyr1 = buildPyramid(i1);
+ draw();
+ };
+ i1.src = URL.createObjectURL(f);
+};
+
+f2.onchange = e => {
+ let f = e.target.files[0]; if (!f) return;
+ name2 = f.name;
+ i2 = new Image();
+ i2.onload = () => {
+ l2 = 1;
+ pyr2 = buildPyramid(i2);
+ ox = (canvas.width - i2.width) / 2;
+ oy = (canvas.height - i2.height) / 2;
+ draw();
+ };
+ i2.src = URL.createObjectURL(f);
+};
+
+// --- mouse ---
+canvas.onmousemove = e => {
+ mx = e.offsetX; my = e.offsetY;
+ if (!drag) return;
+ ox += (e.offsetX - lx);
+ oy += (e.offsetY - ly);
+ lx = e.offsetX; ly = e.offsetY;
+ draw();
+};
+canvas.onmousedown = e => {
+ drag = 1;
+ lx = e.offsetX; ly = e.offsetY;
+ canvas.style.cursor = "grabbing";
+};
+canvas.onmouseup = canvas.onmouseleave = () => {
+ drag = 0;
+ canvas.style.cursor = "grab";
+};
+
+// --- scroll to zoom (mouse-anchored) ---
+canvas.onwheel = e => {
+ if (!l2) return;
+ e.preventDefault();
+ let factor = e.deltaY < 0 ? 1.01 : 0.99;
+ let ix = (mx - ox) / sx;
+ let iy = (my - oy) / sy;
+ sx *= factor; sy *= factor;
+ ox = mx - ix * sx;
+ oy = my - iy * sy;
+ draw();
+};
+
+// --- keyboard ---
+document.addEventListener("keydown", e => {
+ // FIX: don't intercept keys when typing in textarea
+ if (document.activeElement === cmdEl) return;
+
+ if (!l2) return;
+ if (e.key.startsWith("Arrow")) e.preventDefault();
+ const PAN_STEP = 1;
+ const SCALE_STEP = 0.002;
+ if (e.shiftKey) {
+ let ix = (mx - ox) / sx;
+ let iy = (my - oy) / sy;
+ if (e.key === "ArrowRight") sx += SCALE_STEP;
+ if (e.key === "ArrowLeft") sx -= SCALE_STEP;
+ if (e.key === "ArrowUp") sy += SCALE_STEP;
+ if (e.key === "ArrowDown") sy -= SCALE_STEP;
+ ox = mx - ix * sx;
+ oy = my - iy * sy;
+ } else {
+ if (e.key === "ArrowRight") ox += PAN_STEP;
+ if (e.key === "ArrowLeft") ox -= PAN_STEP;
+ if (e.key === "ArrowUp") oy -= PAN_STEP;
+ if (e.key === "ArrowDown") oy += PAN_STEP;
+ }
+ draw();
+});
+
+// FIX: space only fires optimize when not in textarea
+document.addEventListener("keydown", e => {
+ if (document.activeElement === cmdEl) return;
+ if (e.code === "Space") {
+ e.preventDefault();
+ opt.click();
+ }
+});
+
+// --- slider ---
+const iterSlider = document.getElementById('iterSlider');
+const iterVal = document.getElementById('iterVal');
+iterSlider.oninput = () => iterVal.textContent = iterSlider.value;
+function numIters() { return parseInt(iterSlider.value); }
+
+// --- set busy state ---
+function setBusy(busy) {
+ optimizing = busy;
+ opt.disabled = busy;
+ optZoom.disabled = busy;
+ statusEl.textContent = busy ? "Optimizing…" : "";
+}
+
+// --- probe MSE without mutating globals ---
+// Temporarily sets ox/oy/sx/sy, evaluates, then restores.
+function probeMSE(tox, toy, tsx, tsy, level) {
+ let pox = ox, poy = oy, psx = sx, psy = sy;
+ ox = tox; oy = toy; sx = tsx; sy = tsy;
+ let s = computeMSELevel(level);
+ ox = pox; oy = poy; sx = psx; sy = psy;
+ return s;
+}
+
+// --- optimizer (translation + uniform scale) ---
+opt.onclick = async () => {
+ if (!l1 || !l2 || optimizing) return;
+ setBusy(true);
+ const ITERS = numIters();
+ for (let level = LEVELS - 1; level >= 0; level--) {
+ let best = { ox, oy, sx, sy, score: computeMSELevel(level) };
+ for (let iter = 0; iter < ITERS; iter++) {
+ let stepO = (2 ** level) / (iter + 1);
+ let stepS = 0.01 / (iter + 1);
+ for (let dx of [-1, 0, 1])
+ for (let dy of [-1, 0, 1])
+ for (let ds of [-1, 0, 1]) {
+ let tox = best.ox + dx * stepO;
+ let toy = best.oy + dy * stepO;
+ let ts = best.sx + ds * stepS;
+ if (ts <= 0.01) continue;
+ // FIX: probe without touching globals
+ let s = probeMSE(tox, toy, ts, ts, level);
+ if (s < best.score)
+ best = { ox: tox, oy: toy, sx: ts, sy: ts, score: s };
+ }
+ // commit the best found this iteration
+ ox = best.ox; oy = best.oy; sx = best.sx; sy = best.sy;
+ draw();
+ await new Promise(r => setTimeout(r, 0));
+ }
+ }
+ setBusy(false);
+};
+
+// --- optimize zoom (mouse-anchor invariant) ---
+optZoom.onclick = async () => {
+ if (!l1 || !l2 || optimizing) return;
+ setBusy(true);
+ const ITERS = numIters();
+ let ax = mx, ay = my;
+
+ for (let level = LEVELS - 1; level >= 0; level--) {
+ // best stores the full transform, not just sx
+ let best = { ox, oy, sx, sy, score: computeMSELevel(level) };
+
+ for (let iter = 0; iter < ITERS; iter++) {
+ let stepS = 0.02 / (iter + 1);
+
+ for (let ds of [-1, 0, 1]) {
+ let ts = best.sx + ds * stepS;
+ if (ts <= 0.01) continue;
+ // anchor invariant: point (ax,ay) in canvas == point (ix,iy) in image2
+ // must satisfy: ax = ox + ix*sx => ix = (ax - ox) / sx
+ // after scale change to ts: tox = ax - ix*ts
+ // derive from COMMITTED globals (best.ox/sx), not mid-loop state
+ let ix = (ax - best.ox) / best.sx;
+ let iy = (ay - best.oy) / best.sy;
+ let tox = ax - ix * ts;
+ let toy = ay - iy * ts;
+ let s = probeMSE(tox, toy, ts, ts, level);
+ if (s < best.score) best = { ox: tox, oy: toy, sx: ts, sy: ts, score: s };
+ }
+
+ // commit best directly — no re-derivation needed
+ ox = best.ox; oy = best.oy; sx = best.sx; sy = best.sy;
+
+ draw();
+ await new Promise(r => setTimeout(r, 0));
+ }
+ }
+ setBusy(false);
+};
+
+// --- crop ---
+crop.onclick = () => {
+ let x2 = ox, y2 = oy, w2 = i2.width * sx, h2 = i2.height * sy;
+ let x0 = Math.max(0, x2), y0 = Math.max(0, y2);
+ let x1 = Math.min(canvas.width, x2 + w2), y1 = Math.min(canvas.height, y2 + h2);
+ let w = x1 - x0, h = y1 - y0;
+ if (w <= 0 || h <= 0) return alert("no overlap");
+
+ out1 = document.createElement('canvas');
+ out2 = document.createElement('canvas');
+ out1.width = out2.width = w; out1.height = out2.height = h;
+
+ out1.getContext('2d').drawImage(i1, x0, y0, w, h, 0, 0, w, h);
+ out2.getContext('2d').drawImage(
+ i2, (x0 - ox) / sx, (y0 - oy) / sy, w / sx, h / sy, 0, 0, w, h
+ );
+
+ dl.disabled = 0;
+};
+
+// --- download ---
+dl.onclick = () => {
+ let save = (cv, n) => {
+ let a = document.createElement('a');
+ a.download = n + '.clipped.png';
+ a.href = cv.toDataURL();
+ a.click();
+ };
+ save(out1, 'image1');
+ save(out2, 'image2');
+};
+
+// --- copy ---
+copy.onclick = () => navigator.clipboard.writeText(cmdEl.value);
+
+// --- ImageMagick command ---
+function updateCmd() {
+ if (!l1 || !l2) return;
+ let x2 = ox, y2 = oy, w2 = i2.width * sx, h2 = i2.height * sy;
+ let x0 = Math.max(0, x2), y0 = Math.max(0, y2);
+ let x1 = Math.min(canvas.width, x2 + w2), y1 = Math.min(canvas.height, y2 + h2);
+ let w = Math.floor(x1 - x0), h = Math.floor(y1 - y0);
+ if (w <= 0 || h <= 0) return;
+ let crop = `${w}x${h}+${Math.round(x0)}+${Math.round(y0)}`;
+ // AffineProjection: a,b,d,e,tx,ty (uniform scale → a=e=sx)
+ let a = sx, b = 0, d = 0, e = sy, tx = ox, ty = oy;
+ cmdEl.value =
+`magick "${name1}" -crop ${crop} +repage "${name1}.clipped.png"
+magick "${name2}" -virtual-pixel black -distort AffineProjection "${a},${b},${d},${e},${tx},${ty}" -crop ${crop} +repage "${name2}.clipped.png"`;
+}
+</script>
+
+</body>
+</html> \ No newline at end of file