feat(capture): ship tape-line calibration (#99)

This commit is contained in:
Ty
2026-09-24 14:47:13 -07:00
parent 6fb92362a4
commit 4ca2ef57a7
13 changed files with 437 additions and 738 deletions
+231 -38
View File
@@ -1,58 +1,251 @@
<script lang="ts">
import type { Capture } from './capture.types';
import PhotoStage from './PhotoStage.svelte';
import { onMount } from 'svelte';
import type { Capture, TapeLine } from './capture.types';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
import { photoToScreen, screenToPhoto, type PhotoPoint, type PhotoViewTransform } from '../png/crop';
export let state: Extract<Capture, { k: 'axis' }>;
export let setAxisAngle: (deg: number) => void;
export let setTapeLine: (tapeLine: TapeLine) => void;
export let approveAxis: () => void;
export let back: () => void;
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
$: angleDeg = Math.round(state.axis.bbox.angleDeg);
let stage: HTMLDivElement | undefined;
let canvas: HTMLCanvasElement | undefined;
let bitmap: ImageBitmap | null = null;
let sourceW = state.source.widthPx;
let sourceH = state.source.heightPx;
let stageW = 0;
let stageH = 0;
let tickA: PhotoPoint = [...state.axis.tapeLine.start];
let tickB: PhotoPoint = [...state.axis.tapeLine.end];
let angleDeg = 0;
let zoom = 1;
let panX = 0;
let panY = 0;
let dragging: 'start' | 'end' | 'pan' | null = null;
let selected: 'start' | 'end' = 'start';
let lastPointer: PhotoPoint = [0, 0];
$: tickDistance = Math.hypot(tickB[0] - tickA[0], tickB[1] - tickA[1]);
function fittedScale(): number {
if (!(stageW > 0 && stageH > 0 && sourceW > 0 && sourceH > 0)) return 0;
return Math.min(stageW / sourceW, stageH / sourceH);
}
function view(): PhotoViewTransform {
return {
width: sourceW,
height: sourceH,
centerX: stageW / 2,
centerY: stageH / 2,
scale: fittedScale(),
zoom,
angleDeg,
panX,
panY,
};
}
function draw(): void {
if (!canvas || !stageW || !stageH) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(stageW * dpr);
canvas.height = Math.round(stageH * dpr);
canvas.style.width = `${stageW}px`;
canvas.style.height = `${stageH}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#161616';
ctx.fillRect(0, 0, stageW, stageH);
const transform = view();
if (bitmap && transform.scale > 0) {
ctx.save();
ctx.translate(transform.centerX + panX, transform.centerY + panY);
ctx.rotate((angleDeg * Math.PI) / 180);
ctx.scale(transform.scale * zoom, transform.scale * zoom);
ctx.drawImage(bitmap, -sourceW / 2, -sourceH / 2);
ctx.restore();
}
const a = photoToScreen(tickA, transform);
const b = photoToScreen(tickB, transform);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const length = Math.hypot(dx, dy) || 1;
const nx = -dy / length;
const ny = dx / length;
ctx.save();
ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
ctx.lineWidth = 6;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
ctx.strokeStyle = '#000';
ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
for (const [point, which] of [[a, 'start'], [b, 'end']] as const) {
if (which === selected) {
ctx.strokeStyle = '#ffd600';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(point[0], point[1], 16, 0, Math.PI * 2); ctx.stroke();
}
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
ctx.lineWidth = 5;
ctx.beginPath(); ctx.moveTo(point[0] - nx * 20, point[1] - ny * 20); ctx.lineTo(point[0] + nx * 20, point[1] + ny * 20); ctx.stroke();
ctx.strokeStyle = '#000';
ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(point[0] - nx * 20, point[1] - ny * 20); ctx.lineTo(point[0] + nx * 20, point[1] + ny * 20); ctx.stroke();
ctx.fillStyle = which === 'start' ? '#ffd600' : '#ff3d71';
ctx.beginPath(); ctx.arc(point[0], point[1], 5, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke();
}
ctx.restore();
}
function pointerPosition(event: PointerEvent): PhotoPoint {
const rect = canvas!.getBoundingClientRect();
return [event.clientX - rect.left, event.clientY - rect.top];
}
function onPointerDown(event: PointerEvent): void {
const point = pointerPosition(event);
const transform = view();
const a = photoToScreen(tickA, transform);
const b = photoToScreen(tickB, transform);
const hitA = Math.hypot(point[0] - a[0], point[1] - a[1]) < 28;
const hitB = Math.hypot(point[0] - b[0], point[1] - b[1]) < 28;
dragging = hitA ? 'start' : hitB ? 'end' : 'pan';
if (dragging !== 'pan') selected = dragging;
lastPointer = point;
canvas?.setPointerCapture(event.pointerId);
}
function onPointerMove(event: PointerEvent): void {
if (!dragging) return;
const point = pointerPosition(event);
if (dragging === 'pan') {
panX += point[0] - lastPointer[0];
panY += point[1] - lastPointer[1];
} else {
const sourcePoint = screenToPhoto(point, view());
sourcePoint[0] = Math.max(0, Math.min(sourceW, sourcePoint[0]));
sourcePoint[1] = Math.max(0, Math.min(sourceH, sourcePoint[1]));
if (dragging === 'start') tickA = sourcePoint;
else tickB = sourcePoint;
}
lastPointer = point;
draw();
}
function onPointerUp(event: PointerEvent): void {
if (dragging === 'start' || dragging === 'end') {
setTapeLine({ start: [...tickA], end: [...tickB] });
}
dragging = null;
canvas?.releasePointerCapture(event.pointerId);
}
function zoomAt(x: number, y: number, factor: number): void {
const before = screenToPhoto([x, y], view());
zoom = Math.max(0.5, Math.min(5, zoom * factor));
const after = photoToScreen(before, view());
panX += x - after[0];
panY += y - after[1];
draw();
}
function onWheel(event: WheelEvent): void {
event.preventDefault();
const rect = canvas!.getBoundingClientRect();
zoomAt(event.clientX - rect.left, event.clientY - rect.top, Math.exp(-event.deltaY * 0.0015));
}
function resetView(): void {
angleDeg = 0;
zoom = 1;
panX = 0;
panY = 0;
draw();
}
onMount(() => {
let alive = true;
const resize = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
stageW = entry.contentRect.width;
stageH = entry.contentRect.height;
draw();
});
if (stage) resize.observe(stage);
void decodeOrientedBitmap(state.source.blob).then((decoded) => {
if (!alive) {
decoded.close();
return;
}
bitmap = decoded;
sourceW = decoded.width;
sourceH = decoded.height;
draw();
});
return () => {
alive = false;
resize.disconnect();
bitmap?.close();
bitmap = null;
};
});
</script>
<section class="panel">
<h1>Length axis</h1>
<p class="hint">Rotate the rectangle until it follows the slab length. The rectangle sets direction only; you enter length L on the next step.</p>
<PhotoStage
blob={state.source.blob}
bbox={state.axis.bbox}
maskBitset={state.approvedMask.bitset}
{maskW}
{maskH}
/>
<div class="angle">
<button type="button" class="thumb" aria-label="Rotate axis counter-clockwise" on:click={() => setAxisAngle(angleDeg - 1)}>−</button>
<input
type="range" min="0" max="179" step="1"
value={angleDeg}
on:input={(e) => setAxisAngle(Number(e.currentTarget.value))}
/>
<button type="button" class="thumb" aria-label="Rotate axis clockwise" on:click={() => setAxisAngle(angleDeg + 1)}>+</button>
<span class="val">{angleDeg}°</span>
<h1>Tape-line calibration</h1>
<p class="hint">Place both rifle-scope ticks on the actual start and end marks of your tape. Drag the photo to pan, use the zoom slider or wheel to zoom, and rotate until the tape follows the slab's length.</p>
<div class="stage" bind:this={stage}>
<canvas
bind:this={canvas}
aria-label="Calibration photo with draggable tape endpoint ticks"
on:pointerdown={onPointerDown}
on:pointermove={onPointerMove}
on:pointerup={onPointerUp}
on:pointercancel={onPointerUp}
on:wheel|nonpassive={onWheel}
></canvas>
</div>
<div class="control">
<label for="photo-angle">Rotate photo</label>
<input id="photo-angle" type="range" min="-180" max="180" step="0.1" value={angleDeg}
on:input={(event) => { angleDeg = Number(event.currentTarget.value); draw(); }} />
<span class="value">{angleDeg.toFixed(1)}°</span>
</div>
<div class="control">
<label for="photo-zoom">Zoom</label>
<input id="photo-zoom" type="range" min="0.5" max="5" step="0.05" value={zoom}
on:input={(event) => { zoom = Number(event.currentTarget.value); draw(); }} />
<span class="value">{Math.round(zoom * 100)}%</span>
<button type="button" class="minor" on:click={resetView}>Reset view</button>
</div>
<p class="tick-distance">Tick distance in source photo: {tickDistance.toFixed(1)} px</p>
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
<button type="button" class="primary" on:click={approveAxis}>Approve axis</button>
<button type="button" class="primary" disabled={tickDistance < 4} on:click={approveAxis}>Use tape line</button>
</div>
</section>
<style>
.panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 8px; }
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; }
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
.thumb {
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
}
input[type='range'] { flex: 1; }
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; line-height: 1.45; }
.stage { width: 100%; height: min(56vh, 560px); min-height: 280px; overflow: hidden; background: #161616; border: 1px solid #333; }
canvas { display: block; touch-action: none; cursor: grab; }
.control { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
.control label { min-width: 88px; color: #bbb; font-size: 0.9rem; }
input[type='range'] { flex: 1; min-width: 0; accent-color: #00e5ff; }
.value { min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; color: #00e5ff; }
.minor { min-height: 40px; border: 1px solid #444; background: #1d1d1d; color: #ccc; padding: 8px 10px; }
.tick-distance { margin: 10px 0 0; color: #aaa; font-variant-numeric: tabular-nums; font-size: 0.85rem; }
.row { display: flex; gap: 10px; margin-top: 16px; }
.primary {
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
background: #00e5ff; color: #002; font-weight: 700;
}
.ghost { flex: 0.7; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
.primary { flex: 1; min-height: 48px; padding: 12px; font-size: 1rem; border: 0; background: #00e5ff; color: #002; font-weight: 700; }
.primary:disabled { background: #333; color: #777; }
.ghost { flex: 0.7; min-height: 48px; padding: 12px; border: 1px solid #444; background: transparent; color: #ccc; }
</style>