Newer
Older
SmTIAS-Capture / scripts / preview_region_picker.html
<!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>