<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Preview Region Picker — SmTIAS-Capture</title>
<style>
:root {
--bg: #1e1e1e;
--panel: #2a2a2a;
--border: #3a3a3a;
--text: #e6e6e6;
--muted: #9a9a9a;
--accent: #4ea1ff;
--accent-soft: rgba(78, 161, 255, 0.25);
--warn: #ffb454;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", "Hiragino Sans", "Yu Gothic UI", sans-serif;
background: var(--bg);
color: var(--text);
display: grid;
grid-template-columns: 1fr 360px;
grid-template-rows: auto 1fr;
height: 100vh;
}
header {
grid-column: 1 / -1;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 16px;
}
header h1 { font-size: 16px; margin: 0; font-weight: 600; }
header .hint { font-size: 12px; color: var(--muted); }
main {
position: relative;
overflow: auto;
background:
linear-gradient(45deg, #252525 25%, transparent 25%) 0 0 / 16px 16px,
linear-gradient(-45deg, #252525 25%, transparent 25%) 0 8px / 16px 16px,
linear-gradient(45deg, transparent 75%, #252525 75%) 8px -8px / 16px 16px,
linear-gradient(-45deg, transparent 75%, #252525 75%) -8px 0 / 16px 16px,
#1a1a1a;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.canvas-wrap {
position: relative;
display: inline-block;
line-height: 0;
box-shadow: 0 4px 30px rgba(0,0,0,0.5);
}
#image {
display: block;
max-width: 100%;
user-select: none;
-webkit-user-drag: none;
}
#overlay {
position: absolute;
inset: 0;
cursor: crosshair;
}
aside {
padding: 16px;
background: var(--panel);
border-left: 1px solid var(--border);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.drop {
border: 2px dashed var(--border);
border-radius: 8px;
padding: 16px;
text-align: center;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.drop.dragging { border-color: var(--accent); background: var(--accent-soft); }
.drop input { display: none; }
.drop p { margin: 4px 0; font-size: 13px; }
.drop .small { color: var(--muted); font-size: 11px; }
.section h2 {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin: 0 0 8px;
font-weight: 600;
}
.grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 12px;
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 12px;
}
.grid2 .k { color: var(--muted); }
.grid2 .v { text-align: right; }
pre {
background: #181818;
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px;
font-size: 11px;
color: #cfe5ff;
overflow: auto;
margin: 0;
max-height: 280px;
}
.row { display: flex; gap: 8px; }
button {
flex: 1;
background: var(--accent);
color: #fff;
border: none;
padding: 10px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
button.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
button:disabled { opacity: 0.5; cursor: not-allowed; }
/* drawn rectangle */
.rect {
position: absolute;
border: 2px solid var(--accent);
background: var(--accent-soft);
cursor: move;
}
.handle {
position: absolute;
width: 10px;
height: 10px;
background: var(--accent);
border: 2px solid #fff;
border-radius: 50%;
margin: -6px;
}
.handle.nw { top: 0; left: 0; cursor: nwse-resize; }
.handle.ne { top: 0; right: 0; cursor: nesw-resize; }
.handle.sw { bottom: 0; left: 0; cursor: nesw-resize; }
.handle.se { bottom: 0; right: 0; cursor: nwse-resize; }
/* mask outside */
.mask {
position: absolute;
background: rgba(0,0,0,0.55);
pointer-events: none;
}
.empty {
color: var(--muted);
font-size: 12px;
font-style: italic;
}
</style>
</head>
<body>
<header>
<h1>Preview Region Picker</h1>
<span class="hint">参照画像でカメラプレビュー表示領域を矩形指定 → 正規化座標を JSON で保存</span>
</header>
<main>
<div id="canvas" class="canvas-wrap" style="display:none">
<img id="image" alt="reference">
<div id="overlay"></div>
</div>
<div id="placeholder" class="empty">右パネルから画像を読み込んでください</div>
</main>
<aside>
<div class="section">
<h2>1. 参照画像</h2>
<label class="drop" id="drop">
<p><strong>クリック</strong> または <strong>ドラッグ&ドロップ</strong></p>
<p class="small">推奨: local_reference/DSC_0066.JPG</p>
<input type="file" id="file" accept="image/*">
</label>
<div id="imgInfo" class="grid2" style="margin-top:8px"></div>
</div>
<div class="section">
<h2>2. 矩形(プレビュー表示領域)</h2>
<div id="coords" class="empty">画像上でドラッグして矩形を描画</div>
</div>
<div class="section">
<h2>3. 出力 JSON</h2>
<pre id="json">// 矩形を描画すると表示されます</pre>
<div class="row" style="margin-top:8px">
<button id="copy" disabled>コピー</button>
<button id="download" disabled>Download</button>
</div>
<div class="row" style="margin-top:6px">
<button id="reset" class="secondary" disabled>矩形をリセット</button>
</div>
</div>
<div class="section">
<h2>使い方</h2>
<p style="font-size:12px; color:var(--muted); line-height:1.5; margin:0">
画像上でドラッグして矩形を描く → 四隅のハンドルでサイズ調整、内部をドラッグで移動 → JSON を Download して
<code>assets/config/preview_region.json</code> に配置。
</p>
</div>
</aside>
<script>
(() => {
const $ = (id) => document.getElementById(id);
const canvasWrap = $('canvas');
const placeholder = $('placeholder');
const img = $('image');
const overlay = $('overlay');
const fileInput = $('file');
const drop = $('drop');
const imgInfoEl = $('imgInfo');
const coordsEl = $('coords');
const jsonEl = $('json');
const copyBtn = $('copy');
const dlBtn = $('download');
const resetBtn = $('reset');
let imgW = 0, imgH = 0;
let fileName = '';
/** rectangle in image-pixel space */
let rect = null; // {x, y, w, h}
let rectEl = null;
let masks = [];
// ---------- file load ----------
drop.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => {
if (e.target.files[0]) loadFile(e.target.files[0]);
});
['dragenter','dragover'].forEach(t => drop.addEventListener(t, (e) => {
e.preventDefault(); drop.classList.add('dragging');
}));
['dragleave','drop'].forEach(t => drop.addEventListener(t, (e) => {
e.preventDefault(); drop.classList.remove('dragging');
}));
drop.addEventListener('drop', (e) => {
const f = e.dataTransfer.files[0];
if (f) loadFile(f);
});
function loadFile(file) {
fileName = file.name;
const url = URL.createObjectURL(file);
img.onload = () => {
imgW = img.naturalWidth;
imgH = img.naturalHeight;
canvasWrap.style.display = 'inline-block';
placeholder.style.display = 'none';
imgInfoEl.innerHTML =
`<div class="k">file</div><div class="v">${fileName}</div>` +
`<div class="k">size</div><div class="v">${imgW} × ${imgH}</div>`;
clearRect();
};
img.src = url;
}
// ---------- coords conversion ----------
function clientToImage(clientX, clientY) {
const r = img.getBoundingClientRect();
const scaleX = imgW / r.width;
const scaleY = imgH / r.height;
return {
x: Math.max(0, Math.min(imgW, (clientX - r.left) * scaleX)),
y: Math.max(0, Math.min(imgH, (clientY - r.top) * scaleY)),
};
}
function imageToOverlay(x, y, w, h) {
const r = img.getBoundingClientRect();
const scaleX = r.width / imgW;
const scaleY = r.height / imgH;
return {
left: x * scaleX,
top: y * scaleY,
width: w * scaleX,
height: h * scaleY,
};
}
// ---------- rectangle drawing & editing ----------
let drag = null; // {mode, startX, startY, startRect}
overlay.addEventListener('mousedown', (e) => {
if (!imgW) return;
const p = clientToImage(e.clientX, e.clientY);
// start new drag-draw
drag = { mode: 'draw', startX: p.x, startY: p.y };
rect = { x: p.x, y: p.y, w: 0, h: 0 };
renderRect();
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!drag || !rect) return;
const p = clientToImage(e.clientX, e.clientY);
if (drag.mode === 'draw') {
rect.x = Math.min(drag.startX, p.x);
rect.y = Math.min(drag.startY, p.y);
rect.w = Math.abs(p.x - drag.startX);
rect.h = Math.abs(p.y - drag.startY);
} else if (drag.mode === 'move') {
const dx = p.x - drag.startX;
const dy = p.y - drag.startY;
rect.x = clamp(drag.startRect.x + dx, 0, imgW - drag.startRect.w);
rect.y = clamp(drag.startRect.y + dy, 0, imgH - drag.startRect.h);
} else if (drag.mode.startsWith('resize-')) {
const sr = drag.startRect;
let x1 = sr.x, y1 = sr.y, x2 = sr.x + sr.w, y2 = sr.y + sr.h;
if (drag.mode.includes('w')) x1 = clamp(p.x, 0, x2 - 5);
if (drag.mode.includes('e')) x2 = clamp(p.x, x1 + 5, imgW);
if (drag.mode.includes('n')) y1 = clamp(p.y, 0, y2 - 5);
if (drag.mode.includes('s')) y2 = clamp(p.y, y1 + 5, imgH);
rect = { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
}
renderRect();
});
window.addEventListener('mouseup', () => { drag = null; });
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function clearRect() {
rect = null;
renderRect();
}
function renderRect() {
// remove old elements
if (rectEl) { rectEl.remove(); rectEl = null; }
masks.forEach(m => m.remove());
masks = [];
if (!rect || rect.w < 1 || rect.h < 1) {
coordsEl.innerHTML = '<span class="empty">画像上でドラッグして矩形を描画</span>';
jsonEl.textContent = '// 矩形を描画すると表示されます';
[copyBtn, dlBtn, resetBtn].forEach(b => b.disabled = true);
return;
}
const ov = imageToOverlay(rect.x, rect.y, rect.w, rect.h);
// masks (4 sides) for "what the photographer sees" effect
const fullW = img.getBoundingClientRect().width;
const fullH = img.getBoundingClientRect().height;
const make = (style) => {
const d = document.createElement('div');
d.className = 'mask';
Object.assign(d.style, style);
overlay.appendChild(d);
masks.push(d);
};
make({ left: '0', top: '0', width: '100%', height: ov.top + 'px' });
make({ left: '0', top: (ov.top + ov.height) + 'px', width: '100%', bottom: '0' });
make({ left: '0', top: ov.top + 'px', width: ov.left + 'px', height: ov.height + 'px' });
make({ left: (ov.left + ov.width) + 'px', top: ov.top + 'px', right: '0', height: ov.height + 'px' });
// rectangle
rectEl = document.createElement('div');
rectEl.className = 'rect';
Object.assign(rectEl.style, {
left: ov.left + 'px', top: ov.top + 'px',
width: ov.width + 'px', height: ov.height + 'px',
});
// handles
['nw','ne','sw','se'].forEach(pos => {
const h = document.createElement('div');
h.className = 'handle ' + pos;
h.dataset.handle = pos;
rectEl.appendChild(h);
});
overlay.appendChild(rectEl);
// interaction on rectangle
rectEl.addEventListener('mousedown', (e) => {
e.stopPropagation();
const handle = e.target.dataset.handle;
const p = clientToImage(e.clientX, e.clientY);
if (handle) {
const map = { nw: 'resize-nw', ne: 'resize-ne', sw: 'resize-sw', se: 'resize-se' };
drag = { mode: map[handle], startX: p.x, startY: p.y, startRect: { ...rect } };
} else {
drag = { mode: 'move', startX: p.x, startY: p.y, startRect: { ...rect } };
}
});
updateOutputs();
}
function updateOutputs() {
const left = rect.x / imgW;
const top = rect.y / imgH;
const right = (rect.x + rect.w) / imgW;
const bottom = (rect.y + rect.h) / imgH;
coordsEl.innerHTML = `
<div class="grid2">
<div class="k">left</div><div class="v">${left.toFixed(4)}</div>
<div class="k">top</div><div class="v">${top.toFixed(4)}</div>
<div class="k">right</div><div class="v">${right.toFixed(4)}</div>
<div class="k">bottom</div><div class="v">${bottom.toFixed(4)}</div>
<div class="k">px (x,y,w,h)</div>
<div class="v">${Math.round(rect.x)}, ${Math.round(rect.y)}, ${Math.round(rect.w)}, ${Math.round(rect.h)}</div>
</div>`;
const data = {
version: 1,
reference_image: fileName,
reference_size: { width: imgW, height: imgH },
preview_region: {
left: round(left),
top: round(top),
right: round(right),
bottom: round(bottom),
},
note: '撮影画像(フル領域)に対する正規化座標。Camera Preview のクロップ表示領域。'
};
jsonEl.textContent = JSON.stringify(data, null, 2);
[copyBtn, dlBtn, resetBtn].forEach(b => b.disabled = false);
}
function round(v) { return Math.round(v * 10000) / 10000; }
copyBtn.addEventListener('click', async () => {
await navigator.clipboard.writeText(jsonEl.textContent);
copyBtn.textContent = 'コピーしました';
setTimeout(() => copyBtn.textContent = 'コピー', 1200);
});
dlBtn.addEventListener('click', () => {
const blob = new Blob([jsonEl.textContent], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'preview_region.json';
a.click();
});
resetBtn.addEventListener('click', clearRect);
// re-render on resize (image scales with viewport)
window.addEventListener('resize', () => { if (rect) renderRect(); });
})();
</script>
</body>
</html>