feat: add zoomable image canvas with detection overlays
Load OpenCV in the browser via a script tag: the bundler's CJS interop copies the module's then() onto the import namespace, which never settles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4b19c7ea4e
commit
bec28fd0c1
4 files changed
+261
-5
No files matched your search
+63
-2
@@ -1,3 +1,64 @@
|
||||
<script lang="ts"></script>
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import ImageCanvas from './components/ImageCanvas.svelte';
|
||||
import { AppState } from './lib/app-state.svelte';
|
||||
import { loadOpenCV } from './lib/opencv-loader';
|
||||
|
||||
<h1>Scrap Tracer</h1>
|
||||
const app = new AppState();
|
||||
|
||||
onMount(() => {
|
||||
loadOpenCV().then(
|
||||
({ cv }) => app.setCv(cv),
|
||||
() => (app.cvError = 'Could not load image processing (OpenCV).'),
|
||||
);
|
||||
});
|
||||
|
||||
function ondrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file) app.loadFile(file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<input type="file" accept="image/*" onchange={(e) => {
|
||||
const file = e.currentTarget.files?.[0];
|
||||
if (file) app.loadFile(file);
|
||||
}} />
|
||||
</aside>
|
||||
<main ondragover={(e) => e.preventDefault()} {ondrop}>
|
||||
{#if app.image}
|
||||
<ImageCanvas {app} />
|
||||
{:else}
|
||||
<p class="empty">Drop a photo of a scrap piece here, or use “Load photo”.</p>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #222;
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
height: 100vh;
|
||||
}
|
||||
aside {
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid #ddd;
|
||||
padding: 16px;
|
||||
}
|
||||
main {
|
||||
position: relative;
|
||||
background: #2b2b2b;
|
||||
}
|
||||
.empty {
|
||||
color: #bbb;
|
||||
text-align: center;
|
||||
margin-top: 40vh;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import type { AppState } from '../lib/app-state.svelte';
|
||||
import type { Point, Shape } from '../lib/types';
|
||||
import { fitView, screenToImage, zoomAt, type View } from '../lib/view';
|
||||
|
||||
let { app }: { app: AppState } = $props();
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let canvas: HTMLCanvasElement;
|
||||
let size = $state({ width: 0, height: 0 });
|
||||
let view = $state<View>({ scale: 1, offsetX: 0, offsetY: 0 });
|
||||
let drag: { start: Point; last: Point; moved: boolean } | null = null;
|
||||
|
||||
const photo = $derived.by(() => {
|
||||
const image = app.image;
|
||||
if (!image) return null;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = image.width;
|
||||
c.height = image.height;
|
||||
c.getContext('2d')!.putImageData(new ImageData(image.data, image.width, image.height), 0, 0);
|
||||
return c;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
size = { width: Math.floor(entry.contentRect.width), height: Math.floor(entry.contentRect.height) };
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (app.image && size.width > 0 && size.height > 0) {
|
||||
view = fitView(app.image.width, app.image.height, size.width, size.height);
|
||||
}
|
||||
});
|
||||
|
||||
// Registered manually: wheel listeners must be non-passive to stop page scrolling.
|
||||
$effect(() => {
|
||||
const onwheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
view = zoomAt(view, local(e), Math.exp(-e.deltaY * 0.001));
|
||||
};
|
||||
canvas.addEventListener('wheel', onwheel, { passive: false });
|
||||
return () => canvas.removeEventListener('wheel', onwheel);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
canvas.width = size.width;
|
||||
canvas.height = size.height;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (!photo) return;
|
||||
|
||||
ctx.setTransform(view.scale, 0, 0, view.scale, view.offsetX, view.offsetY);
|
||||
ctx.drawImage(photo, 0, 0);
|
||||
const px = 1 / view.scale; // one screen pixel in image units
|
||||
|
||||
if (app.shapePx) drawShape(ctx, app.shapePx, px);
|
||||
if (app.mode === 'manual') {
|
||||
for (const ring of [app.manualOuter, ...app.manualHoles]) drawPolyline(ctx, ring, true, '#ff9800', px, [6 * px, 4 * px]);
|
||||
}
|
||||
if (app.drawing.length > 0) drawPolyline(ctx, app.drawing, false, '#ff9800', px, []);
|
||||
drawScale(ctx, app.scalePoints, app.lengthMm, px);
|
||||
if (app.seed) drawSeed(ctx, app.seed, px);
|
||||
});
|
||||
|
||||
function drawShape(ctx: CanvasRenderingContext2D, shape: Shape, px: number) {
|
||||
ctx.beginPath();
|
||||
for (const ring of [shape.outer, ...shape.holes]) traceRing(ctx, ring, true);
|
||||
ctx.fillStyle = 'rgba(0, 200, 83, 0.25)';
|
||||
ctx.fill('evenodd');
|
||||
ctx.strokeStyle = '#00c853';
|
||||
ctx.lineWidth = 2 * px;
|
||||
ctx.setLineDash([]);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawPolyline(ctx: CanvasRenderingContext2D, points: Point[], closed: boolean, color: string, px: number, dash: number[]) {
|
||||
ctx.beginPath();
|
||||
traceRing(ctx, points, closed);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2 * px;
|
||||
ctx.setLineDash(dash);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = color;
|
||||
for (const p of points) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, 3 * px, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function traceRing(ctx: CanvasRenderingContext2D, ring: Point[], closed: boolean) {
|
||||
ring.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)));
|
||||
if (closed) ctx.closePath();
|
||||
}
|
||||
|
||||
function drawScale(ctx: CanvasRenderingContext2D, points: Point[], lengthMm: number | null, px: number) {
|
||||
if (points.length === 0) return;
|
||||
drawPolyline(ctx, points, false, '#e53935', px, []);
|
||||
if (points.length === 2 && lengthMm) {
|
||||
const mid = { x: (points[0].x + points[1].x) / 2, y: (points[0].y + points[1].y) / 2 };
|
||||
ctx.font = `${14 * px}px sans-serif`;
|
||||
ctx.fillStyle = '#e53935';
|
||||
ctx.fillText(`${lengthMm} mm`, mid.x + 6 * px, mid.y - 6 * px);
|
||||
}
|
||||
}
|
||||
|
||||
function drawSeed(ctx: CanvasRenderingContext2D, p: Point, px: number) {
|
||||
const r = 8 * px;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x - r, p.y);
|
||||
ctx.lineTo(p.x + r, p.y);
|
||||
ctx.moveTo(p.x, p.y - r);
|
||||
ctx.lineTo(p.x, p.y + r);
|
||||
ctx.strokeStyle = '#2962ff';
|
||||
ctx.lineWidth = 2 * px;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function local(e: PointerEvent | WheelEvent): Point {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
}
|
||||
|
||||
function onpointerdown(e: PointerEvent) {
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
const p = local(e);
|
||||
drag = { start: p, last: p, moved: false };
|
||||
}
|
||||
|
||||
function onpointermove(e: PointerEvent) {
|
||||
if (!drag) return;
|
||||
const p = local(e);
|
||||
if (Math.hypot(p.x - drag.start.x, p.y - drag.start.y) > 4) drag.moved = true;
|
||||
if (drag.moved) {
|
||||
view = { ...view, offsetX: view.offsetX + p.x - drag.last.x, offsetY: view.offsetY + p.y - drag.last.y };
|
||||
}
|
||||
drag.last = p;
|
||||
}
|
||||
|
||||
function onpointerup(e: PointerEvent) {
|
||||
if (drag && !drag.moved) app.handleImageClick(screenToImage(local(e), view));
|
||||
drag = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="canvas-wrap" bind:this={container}>
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
class:picking={app.tool !== 'none'}
|
||||
{onpointerdown}
|
||||
{onpointermove}
|
||||
{onpointerup}
|
||||
></canvas>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.canvas-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
canvas.picking {
|
||||
cursor: crosshair;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,11 @@
|
||||
export type CV = typeof import('@techstark/opencv-js');
|
||||
|
||||
// Everything returns { cv }: the cv module is a thenable, so resolving with it directly never settles.
|
||||
let loading: Promise<{ cv: CV }> | null = null;
|
||||
|
||||
// Wrapped in an object: the cv module is a thenable, so resolving with it directly never settles.
|
||||
export function loadOpenCV(): Promise<{ cv: CV }> {
|
||||
loading ??= (async () => {
|
||||
const mod = (await import('@techstark/opencv-js')) as unknown as { default: CV };
|
||||
const cv = mod.default;
|
||||
const { cv } = typeof document === 'undefined' ? await importModule() : await loadScript();
|
||||
if (!cv.Mat) {
|
||||
await new Promise<void>((resolve) => {
|
||||
(cv as unknown as { onRuntimeInitialized: () => void }).onRuntimeInitialized = () => resolve();
|
||||
@@ -16,3 +15,23 @@ export function loadOpenCV(): Promise<{ cv: CV }> {
|
||||
})();
|
||||
return loading;
|
||||
}
|
||||
|
||||
/** Node (tests). The variable specifier keeps the bundler from emitting a second OpenCV chunk. */
|
||||
async function importModule(): Promise<{ cv: CV }> {
|
||||
const specifier = '@techstark/opencv-js';
|
||||
const mod = (await import(/* @vite-ignore */ specifier)) as { default: CV };
|
||||
return { cv: mod.default };
|
||||
}
|
||||
|
||||
// A script tag, not import(): bundler CJS interop copies `then` and the import never settles.
|
||||
async function loadScript(): Promise<{ cv: CV }> {
|
||||
const { default: url } = await import('@techstark/opencv-js/dist/opencv.js?url');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Failed to load OpenCV'));
|
||||
document.head.append(script);
|
||||
});
|
||||
return { cv: (window as unknown as { cv: CV }).cv };
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in new issue
Block a user