wer / index.html
roop07's picture
Update index.html
109cc13 verified
Raw History Blame Contribute Delete
15.3 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Comic panel detector</title>
<style>
:root {
box-sizing: border-box;
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
--surface-0: #FAFAF8;
--surface-1: #F0EFEA;
--surface-2: #E4E2DA;
--text-primary: #1A1A18;
--text-secondary: #5C5A52;
--text-muted: #8A877C;
--border-strong: #C9C6BA;
--border-accent: #185FA5;
--radius: 10px;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--bg2-light: #E6F1FB; --bg2-dark: #042C53;
--accent-light: #185FA5; --accent-dark: #85B7EB;
}
@media (prefers-color-scheme: dark) {
:root {
--surface-0: #161512;
--surface-1: #201F1B;
--surface-2: #2C2A24;
--text-primary: #F0EFEA;
--text-secondary: #A8A599;
--text-muted: #726F63;
--border-strong: #3A382F;
--border-accent: #85B7EB;
}
}
html { scroll-padding-top: env(safe-area-inset-top, 0px); }
*, *::before, *::after { box-sizing: inherit; }
body { background: var(--surface-0); color: var(--text-primary); font-family: var(--font-sans); margin: 0; padding: 1rem 0; }
.wrap { max-width: 480px; margin: 0 auto; padding: 0 16px; }
h2 { font-size: 18px; font-weight: 500; margin: 0 0 4px; }
p.sub { font-size: 13px; color: var(--text-secondary); margin: 0 0 20px; line-height: 1.5; }
.upload-btn {
display: flex; align-items: center; justify-content: center; gap: 8px;
width: 100%; padding: 14px; border-radius: var(--radius);
border: 1px dashed var(--border-strong); background: var(--surface-1);
color: var(--text-primary); font-size: 14px; cursor: pointer;
}
.upload-btn:active { background: var(--surface-2); }
input[type=file] { display: none; }
.thumbs { display: flex; gap: 8px; overflow-x: auto; margin: 14px 0; padding-bottom: 4px; }
.thumb { flex: 0 0 auto; width: 56px; height: 56px; border-radius: 8px; overflow: hidden;
border: 2px solid transparent; cursor: pointer; background: var(--surface-1); }
.thumb.active { border-color: var(--border-accent); }
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.canvas-card { background: var(--surface-1); border-radius: 12px; overflow: hidden; margin: 12px 0; }
.canvas-scroll { overflow: auto; max-height: 60vh; }
canvas { display: block; width: 100%; height: auto; }
.controls { background: var(--surface-1); border-radius: 12px; padding: 16px; margin: 12px 0; }
.ctrl-row { margin-bottom: 14px; }
.ctrl-row:last-child { margin-bottom: 0; }
.ctrl-label { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-secondary); margin-bottom: 6px; }
.ctrl-label span:last-child { color: var(--text-primary); font-weight: 500; }
input[type=range] { width: 100%; accent-color: var(--accent-light); }
@media (prefers-color-scheme: dark) { input[type=range] { accent-color: var(--accent-dark); } }
[data-mode="dark"] input[type=range] { accent-color: var(--accent-dark); }
.toggle-row { display: flex; align-items: center; justify-content: space-between; }
.toggle-row .ctrl-label { margin-bottom: 0; }
.switch { position: relative; width: 40px; height: 24px; flex: 0 0 auto; }
.switch input { display: none; }
.switch .track { position: absolute; inset: 0; background: var(--border-strong); border-radius: 12px; transition: background .15s; }
.switch input:checked + .track { background: var(--accent-light); }
[data-mode="dark"] .switch input:checked + .track,
@media (prefers-color-scheme: dark) { .switch input:checked + .track { background: var(--accent-dark); } }
.switch .knob { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: var(--surface-2); transition: transform .15s; }
.switch input:checked ~ .knob { transform: translateX(16px); }
.run-btn {
width: 100%; padding: 13px; border-radius: var(--radius); border: none;
background: var(--accent-light); color: white; font-size: 14px; font-weight: 500; cursor: pointer;
margin-top: 4px;
}
[data-mode="dark"] .run-btn { background: var(--accent-dark); color: #042C53; }
@media (prefers-color-scheme: dark) { .run-btn { background: var(--accent-dark); color: #042C53; } }
.run-btn:disabled { opacity: .5; }
.status { font-size: 13px; color: var(--text-secondary); text-align: center; margin: 10px 0; min-height: 18px; }
.results { margin: 14px 0 40px; }
.results h3 { font-size: 14px; font-weight: 500; margin: 0 0 8px; }
.panel-row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--surface-1);
border-radius: 8px; margin-bottom: 6px; font-size: 13px; }
.swatch { width: 14px; height: 14px; border-radius: 3px; flex: 0 0 auto; }
.panel-row .meta { color: var(--text-secondary); }
.panel-row .meta b { color: var(--text-primary); font-weight: 500; }
.empty-note { font-size: 13px; color: var(--text-muted); text-align: center; padding: 20px 0; }
</style>
</head>
<body>
<div class="wrap">
<h2>Comic panel detector</h2>
<p class="sub">Finds panel boundaries by tracing the actual border lines β€” handles rectangles, diagonals, and irregular shapes, not just grid layouts.</p>
<label class="upload-btn" for="fileInput">
<span id="uploadLabel">πŸ“€ Upload comic page(s)</span>
</label>
<input type="file" id="fileInput" accept="image/*" multiple>
<div class="thumbs" id="thumbs" style="display:none"></div>
<div class="canvas-card" id="canvasCard" style="display:none">
<div class="canvas-scroll">
<canvas id="canvas"></canvas>
</div>
</div>
<div class="controls" id="controls" style="display:none">
<div class="ctrl-row">
<div class="ctrl-label"><span>Border darkness threshold</span><span id="threshVal">80</span></div>
<input type="range" id="threshSlider" min="20" max="180" value="80">
</div>
<div class="ctrl-row">
<div class="ctrl-label"><span>Minimum panel size (% of page)</span><span id="areaVal">1.5%</span></div>
<input type="range" id="areaSlider" min="2" max="100" value="15">
</div>
<div class="ctrl-row">
<div class="ctrl-label"><span>Line-gap bridging</span><span id="dilateVal">2</span></div>
<input type="range" id="dilateSlider" min="0" max="8" value="2">
</div>
<div class="ctrl-row toggle-row">
<div class="ctrl-label"><span>Exclude regions touching page edge</span></div>
<label class="switch">
<input type="checkbox" id="edgeToggle" checked>
<div class="track"></div>
<div class="knob"></div>
</label>
</div>
<button class="run-btn" id="runBtn" disabled>Detect panels</button>
</div>
<div class="status" id="status"></div>
<div class="results" id="results"></div>
</div>
<script async src="https://docs.opencv.org/4.9.0/opencv.js" onerror="document.getElementById('status').textContent='Failed to load OpenCV.js β€” check your internet connection and reload.'"></script>
<script>
const PANEL_COLORS = ['#D85A30','#0F6E56','#534AB7','#993556','#185FA5','#BA7517','#639922','#712B13'];
let cvReady = false;
let images = [];
let activeIndex = -1;
let srcMat = null;
const fileInput = document.getElementById('fileInput');
const uploadLabel = document.getElementById('uploadLabel');
const thumbs = document.getElementById('thumbs');
const canvasCard = document.getElementById('canvasCard');
const canvas = document.getElementById('canvas');
const controls = document.getElementById('controls');
const runBtn = document.getElementById('runBtn');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
const threshSlider = document.getElementById('threshSlider');
const areaSlider = document.getElementById('areaSlider');
const dilateSlider = document.getElementById('dilateSlider');
const edgeToggle = document.getElementById('edgeToggle');
const threshVal = document.getElementById('threshVal');
const areaVal = document.getElementById('areaVal');
const dilateVal = document.getElementById('dilateVal');
threshSlider.addEventListener('input', () => { threshVal.textContent = threshSlider.value; });
dilateSlider.addEventListener('input', () => { dilateVal.textContent = dilateSlider.value; });
areaSlider.addEventListener('input', () => { areaVal.textContent = (areaSlider.value / 10).toFixed(1) + '%'; });
function setStatus(msg) { statusEl.textContent = msg; }
window.cv = window.cv || {};
let cvWaitElapsed = 0;
const CV_LOAD_TIMEOUT_MS = 20000;
const waitForCv = setInterval(() => {
cvWaitElapsed += 150;
if (window.cv && window.cv.Mat) {
clearInterval(waitForCv);
cv['onRuntimeInitialized'] = () => {
cvReady = true;
if (images.length) runBtn.disabled = false;
setStatus(images.length ? 'Ready β€” tap Detect panels' : 'Ready β€” upload a page to begin');
};
if (cv.getBuildInformation) {
cvReady = true;
if (images.length) runBtn.disabled = false;
setStatus(images.length ? 'Ready β€” tap Detect panels' : 'Ready β€” upload a page to begin');
}
return;
}
if (cvWaitElapsed >= CV_LOAD_TIMEOUT_MS) {
clearInterval(waitForCv);
setStatus('OpenCV.js failed to load (timed out). Check your connection and reload the page.');
}
}, 150);
setStatus('Loading image engine...');
fileInput.addEventListener('change', (e) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
images = [];
thumbs.innerHTML = '';
thumbs.style.display = 'flex';
uploadLabel.textContent = files.length + ' image(s) loaded';
files.forEach((file, i) => {
const reader = new FileReader();
reader.onload = (ev) => {
const img = new Image();
img.onload = () => {
images[i] = img;
const thumb = document.createElement('div');
thumb.className = 'thumb';
thumb.innerHTML = '<img src="' + ev.target.result + '">';
thumb.addEventListener('click', () => selectImage(i));
thumbs.appendChild(thumb);
if (activeIndex === -1) selectImage(i);
};
img.src = ev.target.result;
};
reader.readAsDataURL(file);
});
});
function selectImage(i) {
activeIndex = i;
Array.from(thumbs.children).forEach((t, idx) => t.classList.toggle('active', idx === i));
drawOriginal(images[i]);
canvasCard.style.display = 'block';
controls.style.display = 'block';
resultsEl.innerHTML = '';
runBtn.disabled = !cvReady;
setStatus(cvReady ? 'Ready β€” tap Detect panels' : 'Loading image engine...');
}
function drawOriginal(img) {
const maxDim = 1400;
let w = img.naturalWidth, h = img.naturalHeight;
if (Math.max(w, h) > maxDim) {
const scale = maxDim / Math.max(w, h);
w = Math.round(w * scale);
h = Math.round(h * scale);
}
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
}
runBtn.addEventListener('click', () => {
if (!cvReady || activeIndex === -1) return;
runBtn.disabled = true;
setStatus('Detecting panels...');
setTimeout(detectPanels, 30);
});
function detectPanels() {
try {
const ctx = canvas.getContext('2d');
drawOriginal(images[activeIndex]);
const src = cv.imread(canvas);
const gray = new cv.Mat();
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
const darkThresh = parseInt(threshSlider.value, 10);
const minAreaFrac = parseInt(areaSlider.value, 10) / 1000;
const dilateIter = parseInt(dilateSlider.value, 10);
const excludeEdge = edgeToggle.checked;
const bw = new cv.Mat();
cv.threshold(gray, bw, darkThresh, 255, cv.THRESH_BINARY);
const borderMask = new cv.Mat();
cv.bitwise_not(bw, borderMask);
const kernel = cv.Mat.ones(3, 3, cv.CV_8U);
const dilated = new cv.Mat();
if (dilateIter > 0) {
cv.dilate(borderMask, dilated, kernel, new cv.Point(-1, -1), dilateIter);
} else {
borderMask.copyTo(dilated);
}
const nonBorder = new cv.Mat();
cv.bitwise_not(dilated, nonBorder);
const contours = new cv.MatVector();
const hierarchy = new cv.Mat();
cv.findContours(nonBorder, contours, hierarchy, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE);
const imgArea = src.rows * src.cols;
const panels = [];
for (let i = 0; i < contours.size(); i++) {
const cnt = contours.get(i);
const area = cv.contourArea(cnt);
if (area < imgArea * minAreaFrac) { cnt.delete(); continue; }
const rect = cv.boundingRect(cnt);
if (excludeEdge) {
const m = 2;
if (rect.x <= m || rect.y <= m || rect.x + rect.width >= src.cols - m || rect.y + rect.height >= src.rows - m) {
cnt.delete();
continue;
}
}
const approx = new cv.Mat();
const peri = cv.arcLength(cnt, true);
cv.approxPolyDP(cnt, approx, 0.01 * peri, true);
const points = [];
for (let p = 0; p < approx.rows; p++) {
points.push({ x: approx.data32S[p * 2], y: approx.data32S[p * 2 + 1] });
}
approx.delete();
panels.push({ points, area, bbox: rect });
cnt.delete();
}
src.delete(); gray.delete(); bw.delete(); borderMask.delete();
kernel.delete(); dilated.delete(); nonBorder.delete();
contours.delete(); hierarchy.delete();
panels.sort((a, b) => (a.bbox.y - b.bbox.y) || (a.bbox.x - b.bbox.x));
renderResults(panels, ctx);
} catch (err) {
setStatus('Detection failed: ' + err.message);
} finally {
runBtn.disabled = false;
}
}
function renderResults(panels, ctx) {
if (!panels.length) {
setStatus('No panels found β€” try lowering the size threshold or adjusting darkness.');
resultsEl.innerHTML = '<div class="empty-note">No regions matched the current settings.</div>';
return;
}
panels.forEach((panel, idx) => {
const color = PANEL_COLORS[idx % PANEL_COLORS.length];
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.beginPath();
panel.points.forEach((pt, i) => {
if (i === 0) ctx.moveTo(pt.x, pt.y); else ctx.lineTo(pt.x, pt.y);
});
ctx.closePath();
ctx.stroke();
const cx = panel.points.reduce((s, p) => s + p.x, 0) / panel.points.length;
const cy = panel.points.reduce((s, p) => s + p.y, 0) / panel.points.length;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(cx, cy, 14, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = 'bold 16px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(idx), cx, cy);
});
resultsEl.innerHTML = '<h3>' + panels.length + ' panel(s) found</h3>' +
panels.map((p, idx) => {
const color = PANEL_COLORS[idx % PANEL_COLORS.length];
const shape = p.points.length <= 4 ? (p.points.length === 4 ? 'rectangle' : p.points.length + '-sided')
: p.points.length + '-point polygon (irregular)';
return '<div class="panel-row">' +
'<div class="swatch" style="background:' + color + '"></div>' +
'<div class="meta"><b>Panel ' + idx + '</b> β€” ' + shape +
' Β· bbox ' + p.bbox.width + 'Γ—' + p.bbox.height + 'px</div>' +
'</div>';
}).join('');
setStatus(panels.length + ' panel(s) detected β€” adjust sliders and re-run if this looks wrong.');
}
</script>
</body>
</html>