Compare commits
No commits in common. "98c6fc9a357be8cd4a0ad6d19441ed433984adf2" and "3e0284da4cce4dca1968f25d6ca285763753c20a" have entirely different histories.
98c6fc9a35
...
3e0284da4c
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 9.3 KiB |
43
src/App.vue
43
src/App.vue
@ -6,44 +6,20 @@ import ExifViewer from "@/components/ExifViewer.vue"
|
|||||||
import DatumEditor from "@/components/DatumEditor.vue"
|
import DatumEditor from "@/components/DatumEditor.vue"
|
||||||
import ResultViewer from "@/components/ResultViewer.vue"
|
import ResultViewer from "@/components/ResultViewer.vue"
|
||||||
import ThemeToggle from "@/components/ThemeToggle.vue"
|
import ThemeToggle from "@/components/ThemeToggle.vue"
|
||||||
import SkwikLogo from "@/components/SkwikLogo.vue"
|
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-background text-foreground">
|
<div class="min-h-screen bg-background text-foreground">
|
||||||
<!-- Gitea fork ribbon — top-left, desktop only -->
|
|
||||||
<a
|
|
||||||
href="https://serv.e1n.sh/git/sam1902/skwik"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
class="github-fork-ribbon fixed left-0 top-0 z-[100] hidden md:block"
|
|
||||||
data-ribbon="Fork me on Gitea"
|
|
||||||
title="Fork me on Gitea"
|
|
||||||
>Fork me on Gitea</a
|
|
||||||
>
|
|
||||||
|
|
||||||
<header
|
<header
|
||||||
class="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
class="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mx-auto grid h-14 max-w-7xl grid-cols-3 items-center px-4"
|
class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4"
|
||||||
>
|
>
|
||||||
<div><!-- spacer for ribbon --></div>
|
<h1 class="text-lg font-semibold tracking-tight">Skwik</h1>
|
||||||
<div class="flex items-center justify-center gap-2">
|
<div class="flex items-center gap-4">
|
||||||
<SkwikLogo :size="28" />
|
|
||||||
<h1
|
|
||||||
class="font-mono text-lg font-semibold tracking-tight"
|
|
||||||
>
|
|
||||||
Skwik
|
|
||||||
</h1>
|
|
||||||
<span
|
|
||||||
class="hidden text-[10px] font-medium uppercase tracking-widest text-muted-foreground sm:inline"
|
|
||||||
>Perspective Correction</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-end gap-4">
|
|
||||||
<StepIndicator />
|
<StepIndicator />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
@ -56,18 +32,5 @@ const store = useAppStore()
|
|||||||
<DatumEditor v-else-if="store.currentStep === 3" />
|
<DatumEditor v-else-if="store.currentStep === 3" />
|
||||||
<ResultViewer v-else-if="store.currentStep === 4" />
|
<ResultViewer v-else-if="store.currentStep === 4" />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer
|
|
||||||
class="border-t border-border/50 py-4 text-center text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
Made by
|
|
||||||
<a
|
|
||||||
href="https://github.com/usr-ein"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
class="underline underline-offset-2 transition-colors hover:text-foreground"
|
|
||||||
>Samuel Prevost</a
|
|
||||||
>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap");
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap');
|
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@ -10,8 +9,7 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-sans: "Geist", "Geist Variable", ui-sans-serif, sans-serif;
|
--font-sans: "Geist Variable", sans-serif;
|
||||||
--font-mono: "Geist Mono", ui-monospace, monospace;
|
|
||||||
--font-heading: var(--font-sans);
|
--font-heading: var(--font-sans);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
@ -86,10 +84,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.13 0 0);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.95 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.175 0 0);
|
--card: oklch(0.205 0 0);
|
||||||
--card-foreground: oklch(0.95 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.205 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.922 0 0);
|
||||||
@ -101,7 +99,7 @@
|
|||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.269 0 0);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 12%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.556 0 0);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.87 0 0);
|
||||||
@ -128,63 +126,3 @@
|
|||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fork-me ribbon — adapted from github-fork-ribbon-css */
|
|
||||||
.github-fork-ribbon {
|
|
||||||
width: 12.1em;
|
|
||||||
height: 12.1em;
|
|
||||||
overflow: hidden;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 100;
|
|
||||||
pointer-events: none;
|
|
||||||
text-decoration: none;
|
|
||||||
text-indent: -999em;
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.github-fork-ribbon::before,
|
|
||||||
.github-fork-ribbon::after {
|
|
||||||
position: absolute;
|
|
||||||
display: block;
|
|
||||||
width: 15.38em;
|
|
||||||
height: 1.54em;
|
|
||||||
top: 3.23em;
|
|
||||||
right: 0.5em;
|
|
||||||
box-sizing: content-box;
|
|
||||||
transform: rotate(-45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.github-fork-ribbon::before {
|
|
||||||
content: "";
|
|
||||||
padding: 0.38em 0;
|
|
||||||
background-color: #2d7a2e;
|
|
||||||
background-image: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
rgba(255, 255, 255, 0.05),
|
|
||||||
rgba(0, 0, 0, 0.15)
|
|
||||||
);
|
|
||||||
box-shadow:
|
|
||||||
0 0.15em 0.23em 0 rgba(0, 0, 0, 0.5),
|
|
||||||
0 0 0 1px #5cb85c,
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.2),
|
|
||||||
inset 0 -1px 0 rgba(0, 0, 0, 0.2);
|
|
||||||
border-top: 1px dashed rgba(255, 255, 255, 0.25);
|
|
||||||
border-bottom: 1px dashed rgba(255, 255, 255, 0.25);
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.github-fork-ribbon::after {
|
|
||||||
content: attr(data-ribbon);
|
|
||||||
color: #fff;
|
|
||||||
font: 700 0.9em "Helvetica Neue", Helvetica, Arial, sans-serif;
|
|
||||||
line-height: 1.54em;
|
|
||||||
text-decoration: none;
|
|
||||||
text-align: center;
|
|
||||||
text-indent: 0;
|
|
||||||
text-shadow:
|
|
||||||
0 -1px 0 rgba(0, 0, 0, 0.5),
|
|
||||||
0 1px 2px rgba(0, 0, 0, 0.3);
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,638 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue"
|
|
||||||
import type { Point } from "@/types"
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
imageUrl: string
|
|
||||||
scalePxPerMm: number
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const containerRef = ref<HTMLDivElement | null>(null)
|
|
||||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
|
||||||
const overlayRef = ref<HTMLCanvasElement | null>(null)
|
|
||||||
|
|
||||||
const img = ref<HTMLImageElement | null>(null)
|
|
||||||
const imgLoaded = ref(false)
|
|
||||||
|
|
||||||
// View state
|
|
||||||
const viewScale = ref(1)
|
|
||||||
const viewOffsetX = ref(0)
|
|
||||||
const viewOffsetY = ref(0)
|
|
||||||
|
|
||||||
// Tool state
|
|
||||||
const activeTool = ref<"none" | "measure">("none")
|
|
||||||
const showGrid = ref(false)
|
|
||||||
const gridSpacingMm = ref(10)
|
|
||||||
|
|
||||||
// Measurement state
|
|
||||||
const measurePoints = ref<Point[]>([])
|
|
||||||
const measureHistory = ref<{ a: Point; b: Point; distMm: number }[]>([])
|
|
||||||
|
|
||||||
// Touch/pan state
|
|
||||||
let isPanning = false
|
|
||||||
let panStart = { x: 0, y: 0 }
|
|
||||||
let lastPinchDist = 0
|
|
||||||
|
|
||||||
const measureDistMm = computed(() => {
|
|
||||||
if (measurePoints.value.length < 2) return null
|
|
||||||
const [a, b] = measurePoints.value as [Point, Point]
|
|
||||||
const dxPx = b.x - a.x
|
|
||||||
const dyPx = b.y - a.y
|
|
||||||
const distPx = Math.hypot(dxPx, dyPx)
|
|
||||||
return distPx / props.scalePxPerMm
|
|
||||||
})
|
|
||||||
|
|
||||||
function loadImg() {
|
|
||||||
const image = new Image()
|
|
||||||
image.onload = () => {
|
|
||||||
img.value = image
|
|
||||||
imgLoaded.value = true
|
|
||||||
fitToContainer()
|
|
||||||
redraw()
|
|
||||||
}
|
|
||||||
image.src = props.imageUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
function fitToContainer() {
|
|
||||||
const c = containerRef.value
|
|
||||||
const i = img.value
|
|
||||||
if (!c || !i) return
|
|
||||||
|
|
||||||
const cw = c.clientWidth
|
|
||||||
const ch = c.clientHeight
|
|
||||||
|
|
||||||
if (canvasRef.value) {
|
|
||||||
canvasRef.value.width = cw
|
|
||||||
canvasRef.value.height = ch
|
|
||||||
}
|
|
||||||
if (overlayRef.value) {
|
|
||||||
overlayRef.value.width = cw
|
|
||||||
overlayRef.value.height = ch
|
|
||||||
}
|
|
||||||
|
|
||||||
const fit = Math.min(cw / i.naturalWidth, ch / i.naturalHeight) * 0.95
|
|
||||||
viewScale.value = fit
|
|
||||||
viewOffsetX.value = (cw - i.naturalWidth * fit) / 2
|
|
||||||
viewOffsetY.value = (ch - i.naturalHeight * fit) / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
function redraw() {
|
|
||||||
drawImage()
|
|
||||||
drawOverlay()
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawImage() {
|
|
||||||
const canvas = canvasRef.value
|
|
||||||
const image = img.value
|
|
||||||
if (!canvas || !image) return
|
|
||||||
const ctx = canvas.getContext("2d")
|
|
||||||
if (!ctx) return
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
|
||||||
ctx.save()
|
|
||||||
ctx.translate(viewOffsetX.value, viewOffsetY.value)
|
|
||||||
ctx.scale(viewScale.value, viewScale.value)
|
|
||||||
ctx.drawImage(image, 0, 0)
|
|
||||||
ctx.restore()
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawOverlay() {
|
|
||||||
const canvas = overlayRef.value
|
|
||||||
const image = img.value
|
|
||||||
if (!canvas || !image) return
|
|
||||||
const ctx = canvas.getContext("2d")
|
|
||||||
if (!ctx) return
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
|
||||||
ctx.save()
|
|
||||||
|
|
||||||
// Grid
|
|
||||||
if (showGrid.value) {
|
|
||||||
drawGrid(ctx, image)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Measurement history
|
|
||||||
for (const m of measureHistory.value) {
|
|
||||||
drawMeasureLine(ctx, m.a, m.b, m.distMm, "rgba(100,180,255,0.5)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active measurement
|
|
||||||
if (measurePoints.value.length >= 2) {
|
|
||||||
const [a, b] = measurePoints.value as [Point, Point]
|
|
||||||
drawMeasureLine(ctx, a, b, measureDistMm.value ?? 0, "#3b82f6")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Measurement points
|
|
||||||
for (const pt of measurePoints.value) {
|
|
||||||
drawPoint(ctx, pt, "#3b82f6")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.restore()
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawGrid(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
image: HTMLImageElement,
|
|
||||||
) {
|
|
||||||
const spacingPx = gridSpacingMm.value * props.scalePxPerMm
|
|
||||||
if (spacingPx <= 0) return
|
|
||||||
|
|
||||||
const w = image.naturalWidth
|
|
||||||
const h = image.naturalHeight
|
|
||||||
|
|
||||||
ctx.save()
|
|
||||||
ctx.translate(viewOffsetX.value, viewOffsetY.value)
|
|
||||||
ctx.scale(viewScale.value, viewScale.value)
|
|
||||||
|
|
||||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.15)"
|
|
||||||
ctx.lineWidth = 1 / viewScale.value
|
|
||||||
|
|
||||||
// Vertical lines
|
|
||||||
for (let x = 0; x <= w; x += spacingPx) {
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(x, 0)
|
|
||||||
ctx.lineTo(x, h)
|
|
||||||
ctx.stroke()
|
|
||||||
}
|
|
||||||
// Horizontal lines
|
|
||||||
for (let y = 0; y <= h; y += spacingPx) {
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(0, y)
|
|
||||||
ctx.lineTo(w, y)
|
|
||||||
ctx.stroke()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Major lines every 5 intervals
|
|
||||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.35)"
|
|
||||||
ctx.lineWidth = 1.5 / viewScale.value
|
|
||||||
const major = spacingPx * 5
|
|
||||||
for (let x = 0; x <= w; x += major) {
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(x, 0)
|
|
||||||
ctx.lineTo(x, h)
|
|
||||||
ctx.stroke()
|
|
||||||
}
|
|
||||||
for (let y = 0; y <= h; y += major) {
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(0, y)
|
|
||||||
ctx.lineTo(w, y)
|
|
||||||
ctx.stroke()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Labels on major lines
|
|
||||||
ctx.fillStyle = "rgba(255, 255, 255, 0.6)"
|
|
||||||
const fontSize = Math.max(10, 12 / viewScale.value)
|
|
||||||
ctx.font = `${String(fontSize)}px monospace`
|
|
||||||
for (let x = major; x <= w; x += major) {
|
|
||||||
const mm = x / props.scalePxPerMm
|
|
||||||
ctx.fillText(mm.toFixed(0), x + 2 / viewScale.value, fontSize + 2 / viewScale.value)
|
|
||||||
}
|
|
||||||
for (let y = major; y <= h; y += major) {
|
|
||||||
const mm = y / props.scalePxPerMm
|
|
||||||
ctx.fillText(mm.toFixed(0), 2 / viewScale.value, y - 2 / viewScale.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.restore()
|
|
||||||
}
|
|
||||||
|
|
||||||
function imgToScreen(pt: Point): Point {
|
|
||||||
return {
|
|
||||||
x: pt.x * viewScale.value + viewOffsetX.value,
|
|
||||||
y: pt.y * viewScale.value + viewOffsetY.value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawMeasureLine(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
a: Point,
|
|
||||||
b: Point,
|
|
||||||
distMm: number,
|
|
||||||
color: string,
|
|
||||||
) {
|
|
||||||
const sa = imgToScreen(a)
|
|
||||||
const sb = imgToScreen(b)
|
|
||||||
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(sa.x, sa.y)
|
|
||||||
ctx.lineTo(sb.x, sb.y)
|
|
||||||
ctx.strokeStyle = color
|
|
||||||
ctx.lineWidth = 2
|
|
||||||
ctx.setLineDash([6, 3])
|
|
||||||
ctx.stroke()
|
|
||||||
ctx.setLineDash([])
|
|
||||||
|
|
||||||
// Label
|
|
||||||
const mx = (sa.x + sb.x) / 2
|
|
||||||
const my = (sa.y + sb.y) / 2
|
|
||||||
const label = distMm >= 10
|
|
||||||
? `${distMm.toFixed(1)} mm`
|
|
||||||
: `${distMm.toFixed(2)} mm`
|
|
||||||
|
|
||||||
ctx.font = "bold 13px monospace"
|
|
||||||
const metrics = ctx.measureText(label)
|
|
||||||
const pad = 4
|
|
||||||
const tw = metrics.width + pad * 2
|
|
||||||
const th = 18
|
|
||||||
|
|
||||||
ctx.fillStyle = "rgba(0, 0, 0, 0.75)"
|
|
||||||
ctx.fillRect(mx - tw / 2, my - th / 2 - 10, tw, th)
|
|
||||||
ctx.fillStyle = "#fff"
|
|
||||||
ctx.textAlign = "center"
|
|
||||||
ctx.textBaseline = "middle"
|
|
||||||
ctx.fillText(label, mx, my - 10)
|
|
||||||
ctx.textAlign = "start"
|
|
||||||
ctx.textBaseline = "alphabetic"
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawPoint(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
pt: Point,
|
|
||||||
color: string,
|
|
||||||
) {
|
|
||||||
const s = imgToScreen(pt)
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(s.x, s.y, 5, 0, Math.PI * 2)
|
|
||||||
ctx.fillStyle = color
|
|
||||||
ctx.fill()
|
|
||||||
ctx.strokeStyle = "#fff"
|
|
||||||
ctx.lineWidth = 1.5
|
|
||||||
ctx.stroke()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert screen coords to image coords
|
|
||||||
function screenToImg(sx: number, sy: number): Point {
|
|
||||||
return {
|
|
||||||
x: (sx - viewOffsetX.value) / viewScale.value,
|
|
||||||
y: (sy - viewOffsetY.value) / viewScale.value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCanvasXY(e: MouseEvent | Touch): { x: number; y: number } {
|
|
||||||
const rect = overlayRef.value?.getBoundingClientRect()
|
|
||||||
if (!rect) return { x: 0, y: 0 }
|
|
||||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCanvasClick(e: MouseEvent) {
|
|
||||||
if (activeTool.value !== "measure") return
|
|
||||||
|
|
||||||
const { x, y } = getCanvasXY(e)
|
|
||||||
const imgPt = screenToImg(x, y)
|
|
||||||
|
|
||||||
if (measurePoints.value.length < 2) {
|
|
||||||
measurePoints.value.push(imgPt)
|
|
||||||
} else {
|
|
||||||
// Save previous measurement and start new
|
|
||||||
const [a, b] = measurePoints.value as [Point, Point]
|
|
||||||
measureHistory.value.push({
|
|
||||||
a,
|
|
||||||
b,
|
|
||||||
distMm: measureDistMm.value ?? 0,
|
|
||||||
})
|
|
||||||
measurePoints.value = [imgPt]
|
|
||||||
}
|
|
||||||
drawOverlay()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onWheel(e: WheelEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
const scaleBy = 1.08
|
|
||||||
const oldScale = viewScale.value
|
|
||||||
const newScale = e.deltaY < 0
|
|
||||||
? oldScale * scaleBy
|
|
||||||
: oldScale / scaleBy
|
|
||||||
const clamped = Math.max(0.05, Math.min(20, newScale))
|
|
||||||
|
|
||||||
const { x: px, y: py } = getCanvasXY(e)
|
|
||||||
const imgPt = screenToImg(px, py)
|
|
||||||
|
|
||||||
viewScale.value = clamped
|
|
||||||
viewOffsetX.value = px - imgPt.x * clamped
|
|
||||||
viewOffsetY.value = py - imgPt.y * clamped
|
|
||||||
redraw()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMouseDown(e: MouseEvent) {
|
|
||||||
if (activeTool.value === "measure") return
|
|
||||||
isPanning = true
|
|
||||||
panStart = { x: e.clientX - viewOffsetX.value, y: e.clientY - viewOffsetY.value }
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMouseMove(e: MouseEvent) {
|
|
||||||
if (!isPanning) return
|
|
||||||
viewOffsetX.value = e.clientX - panStart.x
|
|
||||||
viewOffsetY.value = e.clientY - panStart.y
|
|
||||||
redraw()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMouseUp() {
|
|
||||||
isPanning = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchStart(e: TouchEvent) {
|
|
||||||
const t0 = e.touches[0]
|
|
||||||
const t1 = e.touches[1]
|
|
||||||
if (e.touches.length === 2 && t0 && t1) {
|
|
||||||
e.preventDefault()
|
|
||||||
lastPinchDist = Math.hypot(
|
|
||||||
t1.clientX - t0.clientX,
|
|
||||||
t1.clientY - t0.clientY,
|
|
||||||
)
|
|
||||||
} else if (e.touches.length === 1 && t0 && activeTool.value !== "measure") {
|
|
||||||
isPanning = true
|
|
||||||
panStart = {
|
|
||||||
x: t0.clientX - viewOffsetX.value,
|
|
||||||
y: t0.clientY - viewOffsetY.value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchMove(e: TouchEvent) {
|
|
||||||
const t0 = e.touches[0]
|
|
||||||
const t1 = e.touches[1]
|
|
||||||
if (e.touches.length === 2 && t0 && t1) {
|
|
||||||
e.preventDefault()
|
|
||||||
const dist = Math.hypot(
|
|
||||||
t1.clientX - t0.clientX,
|
|
||||||
t1.clientY - t0.clientY,
|
|
||||||
)
|
|
||||||
const factor = dist / lastPinchDist
|
|
||||||
const oldScale = viewScale.value
|
|
||||||
const newScale = Math.max(0.05, Math.min(20, oldScale * factor))
|
|
||||||
|
|
||||||
const rect = overlayRef.value?.getBoundingClientRect()
|
|
||||||
if (!rect) return
|
|
||||||
const cx = (t0.clientX + t1.clientX) / 2 - rect.left
|
|
||||||
const cy = (t0.clientY + t1.clientY) / 2 - rect.top
|
|
||||||
const imgPt = screenToImg(cx, cy)
|
|
||||||
|
|
||||||
viewScale.value = newScale
|
|
||||||
viewOffsetX.value = cx - imgPt.x * newScale
|
|
||||||
viewOffsetY.value = cy - imgPt.y * newScale
|
|
||||||
|
|
||||||
lastPinchDist = dist
|
|
||||||
redraw()
|
|
||||||
} else if (e.touches.length === 1 && t0 && isPanning) {
|
|
||||||
viewOffsetX.value = t0.clientX - panStart.x
|
|
||||||
viewOffsetY.value = t0.clientY - panStart.y
|
|
||||||
redraw()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchEnd() {
|
|
||||||
isPanning = false
|
|
||||||
lastPinchDist = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMeasure() {
|
|
||||||
if (activeTool.value === "measure") {
|
|
||||||
activeTool.value = "none"
|
|
||||||
} else {
|
|
||||||
activeTool.value = "measure"
|
|
||||||
measurePoints.value = []
|
|
||||||
}
|
|
||||||
drawOverlay()
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearMeasurements() {
|
|
||||||
measurePoints.value = []
|
|
||||||
measureHistory.value = []
|
|
||||||
drawOverlay()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scale bar export
|
|
||||||
function exportWithScaleBar(): Promise<Blob> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const image = img.value
|
|
||||||
if (!image) {
|
|
||||||
console.error("[scale-bar] img.value is null")
|
|
||||||
reject(new Error("No image loaded for scale bar export"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const iw = image.naturalWidth
|
|
||||||
const ih = image.naturalHeight
|
|
||||||
console.log(`[scale-bar] image ${String(iw)}×${String(ih)}, scale=${String(props.scalePxPerMm)} px/mm`)
|
|
||||||
|
|
||||||
// Scale font/bar sizes relative to image width
|
|
||||||
const unit = Math.max(iw / 100, 8)
|
|
||||||
const barHeightPx = Math.round(unit * 5)
|
|
||||||
const canvas = document.createElement("canvas")
|
|
||||||
canvas.width = iw
|
|
||||||
canvas.height = ih + barHeightPx
|
|
||||||
|
|
||||||
const ctx = canvas.getContext("2d")
|
|
||||||
if (!ctx) {
|
|
||||||
reject(new Error("No 2D context"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw image
|
|
||||||
ctx.drawImage(image, 0, 0)
|
|
||||||
|
|
||||||
// Draw bar background
|
|
||||||
ctx.fillStyle = "#000"
|
|
||||||
ctx.fillRect(0, ih, iw, barHeightPx)
|
|
||||||
|
|
||||||
// Determine a nice scale bar length (~20% of image width)
|
|
||||||
const imgWidthMm = iw / props.scalePxPerMm
|
|
||||||
const niceSteps = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]
|
|
||||||
const targetMm = imgWidthMm * 0.2
|
|
||||||
let barMm = niceSteps[0] ?? 10
|
|
||||||
for (const s of niceSteps) {
|
|
||||||
barMm = s
|
|
||||||
if (s >= targetMm) break
|
|
||||||
}
|
|
||||||
const barWidthPx = barMm * props.scalePxPerMm
|
|
||||||
|
|
||||||
const margin = Math.round(unit * 2)
|
|
||||||
const barX = margin
|
|
||||||
const barY = ih + barHeightPx / 2
|
|
||||||
const barThick = Math.max(Math.round(unit * 0.6), 4)
|
|
||||||
const tickH = Math.round(unit * 1.5)
|
|
||||||
|
|
||||||
console.log(`[scale-bar] barMm=${String(barMm)}, barWidthPx=${barWidthPx.toFixed(0)}, barHeight=${String(barHeightPx)}, unit=${unit.toFixed(1)}`)
|
|
||||||
|
|
||||||
// Draw bar
|
|
||||||
ctx.fillStyle = "#fff"
|
|
||||||
ctx.fillRect(barX, barY - barThick / 2, barWidthPx, barThick)
|
|
||||||
|
|
||||||
// End ticks
|
|
||||||
ctx.fillRect(barX, barY - tickH / 2, Math.max(2, unit * 0.15), tickH)
|
|
||||||
ctx.fillRect(barX + barWidthPx - Math.max(2, unit * 0.15), barY - tickH / 2, Math.max(2, unit * 0.15), tickH)
|
|
||||||
|
|
||||||
// Label above bar
|
|
||||||
const fontSize = Math.round(unit * 1.4)
|
|
||||||
const label = `${String(barMm)} mm`
|
|
||||||
ctx.font = `bold ${String(fontSize)}px monospace`
|
|
||||||
ctx.fillStyle = "#fff"
|
|
||||||
ctx.textAlign = "center"
|
|
||||||
ctx.textBaseline = "bottom"
|
|
||||||
ctx.fillText(label, barX + barWidthPx / 2, barY - tickH / 2 - Math.round(unit * 0.3))
|
|
||||||
|
|
||||||
// Scale info on the right
|
|
||||||
const smallFont = Math.round(unit * 1)
|
|
||||||
ctx.textAlign = "right"
|
|
||||||
ctx.textBaseline = "middle"
|
|
||||||
ctx.font = `${String(smallFont)}px monospace`
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.6)"
|
|
||||||
ctx.fillText(
|
|
||||||
`${String(props.scalePxPerMm)} px/mm`,
|
|
||||||
iw - margin,
|
|
||||||
barY,
|
|
||||||
)
|
|
||||||
|
|
||||||
canvas.toBlob((b) => {
|
|
||||||
console.log(`[scale-bar] blob: ${b ? String(Math.round(b.size / 1024)) + " KB" : "NULL"}`)
|
|
||||||
if (b) resolve(b)
|
|
||||||
else reject(new Error("toBlob failed"))
|
|
||||||
}, "image/png")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({ exportWithScaleBar })
|
|
||||||
|
|
||||||
let resizeObs: ResizeObserver | null = null
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadImg()
|
|
||||||
if (containerRef.value) {
|
|
||||||
resizeObs = new ResizeObserver(() => {
|
|
||||||
const c = containerRef.value
|
|
||||||
if (!c || c.clientWidth === 0) return
|
|
||||||
fitToContainer()
|
|
||||||
redraw()
|
|
||||||
})
|
|
||||||
resizeObs.observe(containerRef.value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
resizeObs?.disconnect()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.imageUrl, loadImg)
|
|
||||||
watch(showGrid, () => { drawOverlay() })
|
|
||||||
watch(gridSpacingMm, () => { drawOverlay() })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<!-- Toolbar -->
|
|
||||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors"
|
|
||||||
:class="
|
|
||||||
activeTool === 'measure'
|
|
||||||
? 'border-primary bg-primary/10 text-primary'
|
|
||||||
: 'border-border text-muted-foreground hover:text-foreground'
|
|
||||||
"
|
|
||||||
@click="toggleMeasure"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z" />
|
|
||||||
<path d="m14.5 12.5 2-2" />
|
|
||||||
<path d="m11.5 9.5 2-2" />
|
|
||||||
<path d="m8.5 6.5 2-2" />
|
|
||||||
<path d="m17.5 15.5 2-2" />
|
|
||||||
</svg>
|
|
||||||
Measure
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="measureHistory.length > 0 || measurePoints.length > 0"
|
|
||||||
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1.5 text-xs text-muted-foreground hover:text-destructive"
|
|
||||||
@click="clearMeasurements"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="mx-1 h-4 w-px bg-border" />
|
|
||||||
|
|
||||||
<label
|
|
||||||
class="inline-flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="showGrid"
|
|
||||||
type="checkbox"
|
|
||||||
class="accent-primary"
|
|
||||||
/>
|
|
||||||
Grid
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<input
|
|
||||||
v-if="showGrid"
|
|
||||||
v-model.number="gridSpacingMm"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="1000"
|
|
||||||
class="w-16 rounded-md border border-border bg-transparent px-2 py-1 font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
v-if="showGrid"
|
|
||||||
class="font-mono text-xs text-muted-foreground"
|
|
||||||
>mm</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Measurement readout -->
|
|
||||||
<div
|
|
||||||
v-if="activeTool === 'measure'"
|
|
||||||
class="rounded-md border border-primary/30 bg-primary/5 px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<template v-if="measurePoints.length === 0">
|
|
||||||
Click two points on the image to measure distance.
|
|
||||||
</template>
|
|
||||||
<template v-else-if="measurePoints.length === 1">
|
|
||||||
Click a second point.
|
|
||||||
</template>
|
|
||||||
<template v-else-if="measureDistMm != null">
|
|
||||||
Distance:
|
|
||||||
<span class="font-mono font-semibold">
|
|
||||||
{{ measureDistMm >= 10
|
|
||||||
? measureDistMm.toFixed(1)
|
|
||||||
: measureDistMm.toFixed(2)
|
|
||||||
}} mm
|
|
||||||
</span>
|
|
||||||
<span class="ml-2 text-muted-foreground">
|
|
||||||
(click to start a new measurement)
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Canvas area -->
|
|
||||||
<div
|
|
||||||
ref="containerRef"
|
|
||||||
class="relative h-[500px] overflow-hidden rounded-lg border border-border bg-muted"
|
|
||||||
:class="activeTool === 'measure' ? 'cursor-crosshair' : 'cursor-grab'"
|
|
||||||
>
|
|
||||||
<canvas
|
|
||||||
ref="canvasRef"
|
|
||||||
class="absolute inset-0"
|
|
||||||
/>
|
|
||||||
<canvas
|
|
||||||
ref="overlayRef"
|
|
||||||
class="absolute inset-0"
|
|
||||||
@click="onCanvasClick"
|
|
||||||
@wheel.prevent="onWheel"
|
|
||||||
@mousedown="onMouseDown"
|
|
||||||
@mousemove="onMouseMove"
|
|
||||||
@mouseup="onMouseUp"
|
|
||||||
@mouseleave="onMouseUp"
|
|
||||||
@touchstart="onTouchStart"
|
|
||||||
@touchmove="onTouchMove"
|
|
||||||
@touchend="onTouchEnd"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -49,23 +49,19 @@ function getPointConfigs(datum: Datum, dIdx: number) {
|
|||||||
const color = getDatumColor(dIdx)
|
const color = getDatumColor(dIdx)
|
||||||
const isSelected = store.selectedDatumId === datum.id
|
const isSelected = store.selectedDatumId === datum.id
|
||||||
const points = datum.type === "rectangle" ? datum.corners : datum.endpoints
|
const points = datum.type === "rectangle" ? datum.corners : datum.endpoints
|
||||||
const baseRadius = isSelected ? 6 : 4
|
const radius = isSelected ? 10 : 7
|
||||||
const visualRadius = Math.max(
|
|
||||||
baseRadius / scale.value,
|
|
||||||
baseRadius * 0.5
|
|
||||||
)
|
|
||||||
|
|
||||||
return points.map((pt, pIdx) => ({
|
return points.map((pt, pIdx) => ({
|
||||||
x: pt.x,
|
x: pt.x,
|
||||||
y: pt.y,
|
y: pt.y,
|
||||||
radius: visualRadius,
|
radius: radius / scale.value,
|
||||||
fill: color,
|
fill: color,
|
||||||
stroke: isSelected ? "#fff" : color,
|
stroke: isSelected ? "#fff" : color,
|
||||||
strokeWidth: 1.5 / scale.value,
|
strokeWidth: 2 / scale.value,
|
||||||
draggable: true,
|
draggable: true,
|
||||||
_datumId: datum.id,
|
_datumId: datum.id,
|
||||||
_pointIndex: pIdx,
|
_pointIndex: pIdx,
|
||||||
hitStrokeWidth: 12 / scale.value,
|
hitStrokeWidth: 20 / scale.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from "vue"
|
import { ref, computed } from "vue"
|
||||||
import { useMediaQuery } from "@vueuse/core"
|
import { useMediaQuery } from "@vueuse/core"
|
||||||
import { useAppStore } from "@/stores/app"
|
import { useAppStore } from "@/stores/app"
|
||||||
import { saveDatums } from "@/lib/datum-cache"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@ -42,16 +40,6 @@ const nextTooltip = computed(() => {
|
|||||||
const names = incompleteDatums.value.map((d) => d.label)
|
const names = incompleteDatums.value.map((d) => d.label)
|
||||||
return `Missing dimensions: ${names.join(", ")}`
|
return `Missing dimensions: ${names.join(", ")}`
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
|
||||||
() => store.datums,
|
|
||||||
(datums) => {
|
|
||||||
if (store.fileHash && datums.length > 0) {
|
|
||||||
saveDatums(store.fileHash, datums)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deep: true },
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -89,16 +77,6 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Transition name="fade">
|
|
||||||
<Badge
|
|
||||||
v-if="store.cacheRestoreMessage"
|
|
||||||
variant="secondary"
|
|
||||||
class="text-xs"
|
|
||||||
>
|
|
||||||
{{ store.cacheRestoreMessage }}
|
|
||||||
</Badge>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<!-- Single layout: canvas always present, sidebar conditionally placed -->
|
<!-- Single layout: canvas always present, sidebar conditionally placed -->
|
||||||
<div
|
<div
|
||||||
class="grid gap-4"
|
class="grid gap-4"
|
||||||
|
|||||||
@ -149,9 +149,7 @@ function getExifRows(): ExifRow[] {
|
|||||||
<TableCell class="font-medium">{{
|
<TableCell class="font-medium">{{
|
||||||
row.label
|
row.label
|
||||||
}}</TableCell>
|
}}</TableCell>
|
||||||
<TableCell class="font-mono text-sm">{{
|
<TableCell>{{ row.value }}</TableCell>
|
||||||
row.value
|
|
||||||
}}</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
@ -181,31 +179,26 @@ function getExifRows(): ExifRow[] {
|
|||||||
</svg>
|
</svg>
|
||||||
<div class="text-sm text-muted-foreground">
|
<div class="text-sm text-muted-foreground">
|
||||||
<p class="font-medium text-foreground">
|
<p class="font-medium text-foreground">
|
||||||
Why EXIF matters
|
Lens Correction Info
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1">
|
<p class="mt-1">
|
||||||
Focal length and lens model data allow the
|
|
||||||
algorithm to estimate radial distortion
|
|
||||||
(barrel/pincushion) introduced by the lens.
|
|
||||||
Without this, the perspective correction relies
|
|
||||||
solely on the datum geometry you provide.
|
|
||||||
</p>
|
|
||||||
<p class="mt-2">
|
|
||||||
<template v-if="store.exifData.focalLength">
|
<template v-if="store.exifData.focalLength">
|
||||||
Detected
|
This image was shot at
|
||||||
<span class="font-mono font-medium text-foreground"
|
<strong
|
||||||
>{{ store.exifData.focalLength }}mm</span
|
>{{ store.exifData.focalLength }}mm</strong
|
||||||
>
|
>
|
||||||
<template v-if="store.exifData.lensModel">
|
<template v-if="store.exifData.lensModel">
|
||||||
on
|
with a
|
||||||
<span class="font-medium text-foreground">{{
|
<strong>{{
|
||||||
store.exifData.lensModel
|
store.exifData.lensModel
|
||||||
}}</span> </template
|
}}</strong> </template
|
||||||
>. Lens correction will be applied.
|
>. The deskew algorithm can use this to correct
|
||||||
|
barrel/pincushion distortion.
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
No focal length found. Lens distortion
|
No focal length data found. The algorithm will
|
||||||
correction will be skipped.
|
rely solely on datum measurements for
|
||||||
|
perspective correction.
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue"
|
import { ref } from "vue"
|
||||||
import { useAppStore } from "@/stores/app"
|
import { useAppStore } from "@/stores/app"
|
||||||
import { loadImage } from "@/lib/image-loader"
|
import { loadImage } from "@/lib/image-loader"
|
||||||
import { extractExif } from "@/lib/exif"
|
import { extractExif } from "@/lib/exif"
|
||||||
import { hashFile } from "@/lib/file-hash"
|
|
||||||
import { loadDatums, clearCache, getCacheSize } from "@/lib/datum-cache"
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@ -12,62 +10,33 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card"
|
} from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
const error = ref("")
|
const error = ref("")
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const cacheCount = ref(0)
|
|
||||||
|
|
||||||
const ACCEPTED = ".jpg,.jpeg,.heic,.heif"
|
const ACCEPTED = ".jpg,.jpeg,.heic,.heif"
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
cacheCount.value = getCacheSize()
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleClearCache() {
|
|
||||||
clearCache()
|
|
||||||
cacheCount.value = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleFile(file: File) {
|
async function handleFile(file: File) {
|
||||||
error.value = ""
|
error.value = ""
|
||||||
store.isProcessing = true
|
store.isProcessing = true
|
||||||
store.processingStatus = "Reading file..."
|
store.processingStatus = "Reading file..."
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { image, convertedFile } = await loadImage(
|
const { image, convertedFile } = await loadImage(file, (status) => {
|
||||||
file,
|
|
||||||
(status) => {
|
|
||||||
store.processingStatus = status
|
store.processingStatus = status
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|
||||||
store.processingStatus = "Extracting EXIF data..."
|
store.processingStatus = "Extracting EXIF data..."
|
||||||
const exif = await extractExif(file)
|
const exif = await extractExif(file)
|
||||||
|
|
||||||
store.processingStatus = "Computing file hash..."
|
|
||||||
const hash = await hashFile(file)
|
|
||||||
store.setFileHash(hash)
|
|
||||||
|
|
||||||
const cached = loadDatums(hash)
|
|
||||||
if (cached && cached.length > 0) {
|
|
||||||
store.datums = cached
|
|
||||||
store.cacheRestoreMessage =
|
|
||||||
`Restored ${String(cached.length)} datum${cached.length === 1 ? "" : "s"} from cache`
|
|
||||||
setTimeout(() => {
|
|
||||||
store.cacheRestoreMessage = ""
|
|
||||||
}, 4000)
|
|
||||||
}
|
|
||||||
|
|
||||||
store.setImage(convertedFile, image)
|
store.setImage(convertedFile, image)
|
||||||
store.setExif(exif)
|
store.setExif(exif)
|
||||||
store.goToStep(2)
|
store.goToStep(2)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value =
|
error.value = e instanceof Error ? e.message : "Failed to load image"
|
||||||
e instanceof Error ? e.message : "Failed to load image"
|
|
||||||
} finally {
|
} finally {
|
||||||
store.isProcessing = false
|
store.isProcessing = false
|
||||||
store.processingStatus = ""
|
store.processingStatus = ""
|
||||||
@ -88,14 +57,13 @@ function onFileSelect(e: Event) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-[60vh] items-start justify-center pt-12">
|
<div class="flex min-h-[60vh] items-center justify-center">
|
||||||
<div class="w-full max-w-2xl space-y-6">
|
<Card class="w-full max-w-lg">
|
||||||
<Card>
|
|
||||||
<CardHeader class="text-center">
|
<CardHeader class="text-center">
|
||||||
<CardTitle class="text-lg">Load Source Image</CardTitle>
|
<CardTitle class="text-2xl">Upload an Image</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Drop a JPG or HEIC file, or click to browse. HEIC
|
Drop a JPG or HEIC image, or click to browse. HEIC files
|
||||||
is converted automatically.
|
will be converted automatically.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -144,10 +112,8 @@ function onFileSelect(e: Event) {
|
|||||||
>browse</span
|
>browse</span
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p class="mt-1 text-xs text-muted-foreground/70">
|
||||||
class="mt-1 font-mono text-xs text-muted-foreground/60"
|
JPG, JPEG, HEIC, HEIF
|
||||||
>
|
|
||||||
.jpg .jpeg .heic .heif
|
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -168,191 +134,5 @@ function onFileSelect(e: Event) {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<div
|
|
||||||
v-if="cacheCount > 0"
|
|
||||||
class="flex justify-end"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="h-7 gap-1.5 text-xs text-muted-foreground/60 hover:text-destructive"
|
|
||||||
@click="handleClearCache"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M3 6h18" />
|
|
||||||
<path
|
|
||||||
d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"
|
|
||||||
/>
|
|
||||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
|
||||||
</svg>
|
|
||||||
Clear cache ({{ cacheCount }})
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2 text-left">
|
|
||||||
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">Example</p>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<img
|
|
||||||
src="/example-before.jpg"
|
|
||||||
alt="Before: angled photograph of a Pioneer CDJ-1000MK3 top case"
|
|
||||||
class="w-full rounded-md border border-border object-cover"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-muted-foreground">Before — angled shot</p>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<img
|
|
||||||
src="/example-after.jpg"
|
|
||||||
alt="After: perspective-corrected front-facing view"
|
|
||||||
class="w-full rounded-md border border-border object-cover"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-muted-foreground">After — corrected perspective</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2 text-left">
|
|
||||||
<p class="text-sm leading-relaxed text-muted-foreground">
|
|
||||||
Correct perspective distortion in photographs using
|
|
||||||
known reference dimensions. Useful when you need to
|
|
||||||
use a photo as a scale reference for design work,
|
|
||||||
measure objects from photographs, or recover accurate
|
|
||||||
geometry from angled shots.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4 text-left">
|
|
||||||
<h3 class="text-sm font-medium text-foreground">How it works</h3>
|
|
||||||
<p class="text-sm leading-relaxed text-muted-foreground">
|
|
||||||
Place an object with known dimensions (a ruler, credit card, or A4 sheet) next to the subject
|
|
||||||
you want to photograph. Take the picture from any angle. Skwik uses the reference object to
|
|
||||||
compute a perspective transform and produce a corrected, front-facing image with accurate
|
|
||||||
proportions.
|
|
||||||
</p>
|
|
||||||
<p class="text-sm leading-relaxed text-muted-foreground">
|
|
||||||
This is especially handy for reverse-engineering enclosure cutouts, measuring parts you
|
|
||||||
can't easily reach, or getting a dimensionally accurate top-down view without a tripod.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3 class="mt-8 text-sm font-medium text-foreground">Tips for best results</h3>
|
|
||||||
<ul class="list-disc space-y-2 pl-5 text-sm leading-relaxed text-muted-foreground">
|
|
||||||
<li>
|
|
||||||
<strong class="text-foreground/80">Use a large, rigid rectangle with precise dimensions.</strong>
|
|
||||||
An A4 magazine cover works well. Plain paper is acceptable but can bend or curl,
|
|
||||||
which degrades accuracy.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong class="text-foreground/80">Lay everything on a flat surface.</strong>
|
|
||||||
Both the subject and the reference object must sit on the same plane.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong class="text-foreground/80">Shoot from as high up as possible and zoom in.</strong>
|
|
||||||
Stand on a chair or stool and hold your phone at arm's length above the scene.
|
|
||||||
Use optical zoom (2× or more) to narrow the field of view — this compresses
|
|
||||||
perspective closer to an orthogonal projection and makes the correction more accurate.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<!-- Side-view illustration: person on chair photographing downward -->
|
|
||||||
<div class="mt-4 flex justify-center">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 360 260"
|
|
||||||
class="w-full max-w-xl"
|
|
||||||
role="img"
|
|
||||||
aria-label="Side-view illustration: a person standing on a chair holds a phone high, zooming in on an object on the ground below"
|
|
||||||
>
|
|
||||||
<!-- Ground line -->
|
|
||||||
<line x1="0" y1="240" x2="360" y2="240" stroke="currentColor" stroke-width="1.5" class="text-border" />
|
|
||||||
|
|
||||||
<!-- Object on the ground directly below phone (centered at x=180) -->
|
|
||||||
<rect x="145" y="230" width="70" height="10" rx="2"
|
|
||||||
class="text-primary" fill="currentColor" opacity="0.18"
|
|
||||||
stroke="currentColor" stroke-width="1.2" />
|
|
||||||
<rect x="165" y="224" width="30" height="16" rx="1"
|
|
||||||
fill="none" stroke="currentColor" stroke-width="0.8"
|
|
||||||
stroke-dasharray="3 2" class="text-muted-foreground" />
|
|
||||||
<text x="170" y="245" font-size="7" class="text-muted-foreground" fill="currentColor">ref</text>
|
|
||||||
|
|
||||||
<!-- Chair (simple side-view) -->
|
|
||||||
<g class="text-muted-foreground" stroke="currentColor" stroke-width="1.5" fill="none">
|
|
||||||
<!-- seat -->
|
|
||||||
<line x1="150" y1="170" x2="190" y2="170" />
|
|
||||||
<!-- legs -->
|
|
||||||
<line x1="153" y1="170" x2="150" y2="240" />
|
|
||||||
<line x1="187" y1="170" x2="190" y2="240" />
|
|
||||||
<!-- back rest -->
|
|
||||||
<line x1="150" y1="170" x2="147" y2="125" />
|
|
||||||
<line x1="147" y1="125" x2="157" y2="125" />
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Person (stick figure standing on the chair) -->
|
|
||||||
<g class="text-foreground" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<!-- head -->
|
|
||||||
<circle cx="170" cy="102" r="9" />
|
|
||||||
<!-- body -->
|
|
||||||
<line x1="170" y1="111" x2="170" y2="150" />
|
|
||||||
<!-- legs on chair seat -->
|
|
||||||
<line x1="170" y1="150" x2="160" y2="170" />
|
|
||||||
<line x1="170" y1="150" x2="180" y2="170" />
|
|
||||||
<!-- arms reaching forward/down holding phone -->
|
|
||||||
<polyline points="170,125 183,115 180,108" />
|
|
||||||
<!-- other arm at side for balance -->
|
|
||||||
<line x1="170" y1="125" x2="152" y2="140" />
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Phone in hand (held out, pointing straight down) -->
|
|
||||||
<rect x="175" y="100" width="10" height="16" rx="2"
|
|
||||||
class="text-foreground" fill="currentColor" opacity="0.85" />
|
|
||||||
<!-- small lens dot -->
|
|
||||||
<circle cx="180" cy="116" r="1.5" class="text-background" fill="currentColor" />
|
|
||||||
|
|
||||||
<!-- Camera FOV cone — wide angle (faded, dashed) pointing straight down -->
|
|
||||||
<polygon points="180,116 124,238 236,238"
|
|
||||||
fill="currentColor" class="text-muted-foreground" opacity="0.06" />
|
|
||||||
<line x1="180" y1="116" x2="124" y2="238"
|
|
||||||
stroke="currentColor" stroke-width="0.8" stroke-dasharray="4 3"
|
|
||||||
class="text-muted-foreground" opacity="0.3" />
|
|
||||||
<line x1="180" y1="116" x2="236" y2="238"
|
|
||||||
stroke="currentColor" stroke-width="0.8" stroke-dasharray="4 3"
|
|
||||||
class="text-muted-foreground" opacity="0.3" />
|
|
||||||
|
|
||||||
<!-- Camera FOV cone — zoomed in (narrow, stronger) pointing straight down -->
|
|
||||||
<polygon points="180,116 155,230 205,230"
|
|
||||||
fill="currentColor" class="text-primary" opacity="0.10" />
|
|
||||||
<line x1="180" y1="116" x2="155" y2="230"
|
|
||||||
stroke="currentColor" stroke-width="1.2"
|
|
||||||
class="text-primary" opacity="0.6" />
|
|
||||||
<line x1="180" y1="116" x2="205" y2="230"
|
|
||||||
stroke="currentColor" stroke-width="1.2"
|
|
||||||
class="text-primary" opacity="0.6" />
|
|
||||||
|
|
||||||
<!-- Labels -->
|
|
||||||
<text x="238" y="230" font-size="8" class="text-muted-foreground" fill="currentColor">wide angle</text>
|
|
||||||
<text x="238" y="240" font-size="7" class="text-muted-foreground" fill="currentColor">(more distortion)</text>
|
|
||||||
<text x="207" y="215" font-size="8" class="text-primary" fill="currentColor" font-weight="600">zoomed in</text>
|
|
||||||
|
|
||||||
<!-- Small arrow showing "higher = better" -->
|
|
||||||
<g class="text-muted-foreground" stroke="currentColor" stroke-width="1" opacity="0.5">
|
|
||||||
<line x1="50" y1="230" x2="50" y2="105" />
|
|
||||||
<polyline points="45,112 50,105 55,112" fill="none" />
|
|
||||||
</g>
|
|
||||||
<text x="28" y="168" font-size="7" class="text-muted-foreground" fill="currentColor"
|
|
||||||
transform="rotate(-90 40 168)">higher is better</text>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,19 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from "vue"
|
import { ref } from "vue"
|
||||||
import { useAppStore } from "@/stores/app"
|
import { useAppStore } from "@/stores/app"
|
||||||
import { deskewImage, waitForOpenCV } from "@/lib/deskew"
|
import { deskewImage, waitForOpenCV } from "@/lib/deskew"
|
||||||
import type { RectDatum } from "@/types"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
|
||||||
import { Progress } from "@/components/ui/progress"
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip"
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@ -30,11 +21,6 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import CorrectedImageViewer from "@/components/CorrectedImageViewer.vue"
|
|
||||||
import {
|
|
||||||
loadSettings,
|
|
||||||
saveSettings,
|
|
||||||
} from "@/lib/settings-cache"
|
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const resultUrl = ref<string | null>(null)
|
const resultUrl = ref<string | null>(null)
|
||||||
@ -42,77 +28,6 @@ const error = ref("")
|
|||||||
const hasRun = ref(false)
|
const hasRun = ref(false)
|
||||||
const cvReady = ref(false)
|
const cvReady = ref(false)
|
||||||
const cvLoading = ref(false)
|
const cvLoading = ref(false)
|
||||||
const showAlgoDetails = ref(false)
|
|
||||||
const includeScaleBar = ref(false)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const cached = loadSettings()
|
|
||||||
if (cached) {
|
|
||||||
includeScaleBar.value = cached.includeScaleBar
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
[() => store.scalePxPerMm, includeScaleBar],
|
|
||||||
() => {
|
|
||||||
saveSettings({
|
|
||||||
scalePxPerMm: store.scalePxPerMm,
|
|
||||||
includeScaleBar: includeScaleBar.value,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Progress tracking
|
|
||||||
const progressStep = ref(0)
|
|
||||||
const progressTotal = ref(7)
|
|
||||||
const progressLabel = ref("")
|
|
||||||
const progressPercent = computed(() =>
|
|
||||||
progressTotal.value > 0
|
|
||||||
? Math.round((progressStep.value / progressTotal.value) * 100)
|
|
||||||
: 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Estimated output size — accounts for full warped image, not just datum
|
|
||||||
const MAX_RGBA_MB = 512
|
|
||||||
const estimatedOutput = computed(() => {
|
|
||||||
const primary = store.datums.find(
|
|
||||||
(d): d is RectDatum => d.type === "rectangle",
|
|
||||||
)
|
|
||||||
const img = store.loadedImage
|
|
||||||
if (!primary || !img || store.scalePxPerMm <= 0) return null
|
|
||||||
|
|
||||||
// Datum dimensions in output pixels
|
|
||||||
const datumOutW = primary.widthMm * store.scalePxPerMm
|
|
||||||
const datumOutH = primary.heightMm * store.scalePxPerMm
|
|
||||||
|
|
||||||
// Datum dimensions in source pixels (approximate from corner spread)
|
|
||||||
const c = primary.corners
|
|
||||||
const datumSrcW = Math.max(
|
|
||||||
Math.hypot(c[1].x - c[0].x, c[1].y - c[0].y),
|
|
||||||
Math.hypot(c[2].x - c[3].x, c[2].y - c[3].y),
|
|
||||||
)
|
|
||||||
const datumSrcH = Math.max(
|
|
||||||
Math.hypot(c[3].x - c[0].x, c[3].y - c[0].y),
|
|
||||||
Math.hypot(c[2].x - c[1].x, c[2].y - c[1].y),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Scale factor from source to output (per-axis average)
|
|
||||||
const sx =
|
|
||||||
datumSrcW > 0 ? datumOutW / datumSrcW : store.scalePxPerMm
|
|
||||||
const sy =
|
|
||||||
datumSrcH > 0 ? datumOutH / datumSrcH : store.scalePxPerMm
|
|
||||||
const avgScale = (sx + sy) / 2
|
|
||||||
|
|
||||||
// Estimated full warped output = source image scaled
|
|
||||||
const w = Math.round(img.naturalWidth * avgScale)
|
|
||||||
const h = Math.round(img.naturalHeight * avgScale)
|
|
||||||
const mb = (w * h * 4) / (1024 * 1024)
|
|
||||||
return { w, h, mb, datumW: Math.round(datumOutW), datumH: Math.round(datumOutH) }
|
|
||||||
})
|
|
||||||
|
|
||||||
const tooLarge = computed(
|
|
||||||
() => (estimatedOutput.value?.mb ?? 0) > MAX_RGBA_MB,
|
|
||||||
)
|
|
||||||
|
|
||||||
async function ensureOpenCV() {
|
async function ensureOpenCV() {
|
||||||
if (cvReady.value) return
|
if (cvReady.value) return
|
||||||
@ -134,27 +49,12 @@ async function runDeskew() {
|
|||||||
await ensureOpenCV()
|
await ensureOpenCV()
|
||||||
|
|
||||||
store.processingStatus = "Running perspective correction..."
|
store.processingStatus = "Running perspective correction..."
|
||||||
progressStep.value = 0
|
|
||||||
progressLabel.value = "Starting..."
|
|
||||||
// Yield to let the browser repaint the spinner before heavy work
|
|
||||||
await new Promise((r) => {
|
|
||||||
requestAnimationFrame(r)
|
|
||||||
})
|
|
||||||
await new Promise((r) => {
|
|
||||||
requestAnimationFrame(r)
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await deskewImage({
|
const result = await deskewImage({
|
||||||
image: store.loadedImage,
|
image: store.loadedImage,
|
||||||
datums: store.datums,
|
datums: store.datums,
|
||||||
exif: store.exifData,
|
exif: store.exifData,
|
||||||
scalePxPerMm: store.scalePxPerMm,
|
scalePxPerMm: store.scalePxPerMm,
|
||||||
onProgress: (step, total, label) => {
|
|
||||||
progressStep.value = step
|
|
||||||
progressTotal.value = total
|
|
||||||
progressLabel.value = label
|
|
||||||
store.processingStatus = label
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
store.setResult(result)
|
store.setResult(result)
|
||||||
@ -169,118 +69,12 @@ async function runDeskew() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addScaleBar(image: HTMLImageElement): Promise<Blob> {
|
function download() {
|
||||||
return new Promise((resolve, reject) => {
|
if (!resultUrl.value) return
|
||||||
const iw = image.naturalWidth
|
|
||||||
const ih = image.naturalHeight
|
|
||||||
const scale = store.scalePxPerMm
|
|
||||||
|
|
||||||
const unit = Math.max(iw / 100, 8)
|
|
||||||
const barHeightPx = Math.round(unit * 5)
|
|
||||||
const canvas = document.createElement("canvas")
|
|
||||||
canvas.width = iw
|
|
||||||
canvas.height = ih + barHeightPx
|
|
||||||
|
|
||||||
const ctx = canvas.getContext("2d")
|
|
||||||
if (!ctx) {
|
|
||||||
reject(new Error("No 2D context"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.drawImage(image, 0, 0)
|
|
||||||
|
|
||||||
ctx.fillStyle = "#000"
|
|
||||||
ctx.fillRect(0, ih, iw, barHeightPx)
|
|
||||||
|
|
||||||
const imgWidthMm = iw / scale
|
|
||||||
const niceSteps = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]
|
|
||||||
const targetMm = imgWidthMm * 0.2
|
|
||||||
let barMm = niceSteps[0] ?? 10
|
|
||||||
for (const s of niceSteps) {
|
|
||||||
barMm = s
|
|
||||||
if (s >= targetMm) break
|
|
||||||
}
|
|
||||||
const barWidthPx = barMm * scale
|
|
||||||
|
|
||||||
const margin = Math.round(unit * 2)
|
|
||||||
const barX = margin
|
|
||||||
const barY = ih + barHeightPx / 2
|
|
||||||
const barThick = Math.max(Math.round(unit * 0.6), 4)
|
|
||||||
const tickH = Math.round(unit * 1.5)
|
|
||||||
const tickW = Math.max(2, Math.round(unit * 0.15))
|
|
||||||
|
|
||||||
ctx.fillStyle = "#fff"
|
|
||||||
ctx.fillRect(barX, barY - barThick / 2, barWidthPx, barThick)
|
|
||||||
ctx.fillRect(barX, barY - tickH / 2, tickW, tickH)
|
|
||||||
ctx.fillRect(
|
|
||||||
barX + barWidthPx - tickW,
|
|
||||||
barY - tickH / 2,
|
|
||||||
tickW,
|
|
||||||
tickH,
|
|
||||||
)
|
|
||||||
|
|
||||||
const fontSize = Math.round(unit * 1.4)
|
|
||||||
ctx.font = `bold ${String(fontSize)}px monospace`
|
|
||||||
ctx.fillStyle = "#fff"
|
|
||||||
ctx.textAlign = "center"
|
|
||||||
ctx.textBaseline = "bottom"
|
|
||||||
ctx.fillText(
|
|
||||||
`${String(barMm)} mm`,
|
|
||||||
barX + barWidthPx / 2,
|
|
||||||
barY - tickH / 2 - Math.round(unit * 0.3),
|
|
||||||
)
|
|
||||||
|
|
||||||
const smallFont = Math.round(unit * 1)
|
|
||||||
ctx.textAlign = "right"
|
|
||||||
ctx.textBaseline = "middle"
|
|
||||||
ctx.font = `${String(smallFont)}px monospace`
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.6)"
|
|
||||||
ctx.fillText(`${String(scale)} px/mm`, iw - margin, barY)
|
|
||||||
|
|
||||||
canvas.toBlob((b) => {
|
|
||||||
if (b) resolve(b)
|
|
||||||
else reject(new Error("toBlob failed"))
|
|
||||||
}, "image/png")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function download() {
|
|
||||||
if (!store.deskewResult) return
|
|
||||||
|
|
||||||
let blob: Blob = store.deskewResult.correctedImageBlob
|
|
||||||
|
|
||||||
if (includeScaleBar.value) {
|
|
||||||
// Load the corrected image into an HTMLImageElement for drawing
|
|
||||||
const imgUrl = URL.createObjectURL(
|
|
||||||
store.deskewResult.correctedImageBlob,
|
|
||||||
)
|
|
||||||
try {
|
|
||||||
const image = await new Promise<HTMLImageElement>(
|
|
||||||
(resolve, reject) => {
|
|
||||||
const el = new Image()
|
|
||||||
el.onload = () => {
|
|
||||||
resolve(el)
|
|
||||||
}
|
|
||||||
el.onerror = () => {
|
|
||||||
reject(new Error("Failed to load image"))
|
|
||||||
}
|
|
||||||
el.src = imgUrl
|
|
||||||
},
|
|
||||||
)
|
|
||||||
blob = await addScaleBar(image)
|
|
||||||
} finally {
|
|
||||||
URL.revokeObjectURL(imgUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement("a")
|
const a = document.createElement("a")
|
||||||
a.href = url
|
a.href = resultUrl.value
|
||||||
const baseName =
|
a.download = `skwik-${store.originalFile?.name ?? "output"}.png`
|
||||||
store.originalFile?.name.replace(/\.[^.]+$/, "") ?? "output"
|
|
||||||
a.download = `${baseName}-skwik.png`
|
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasRects(): boolean {
|
function hasRects(): boolean {
|
||||||
@ -298,9 +92,7 @@ function hasRects(): boolean {
|
|||||||
download.
|
download.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" @click="store.goToStep(3)"
|
<Button variant="outline" @click="store.goToStep(3)">Back</Button>
|
||||||
>Back</Button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scale setting -->
|
<!-- Scale setting -->
|
||||||
@ -308,8 +100,8 @@ function hasRects(): boolean {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-base">Output Scale</CardTitle>
|
<CardTitle class="text-base">Output Scale</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Pixels per millimeter in the corrected output image.
|
Pixels per millimeter in the corrected output image. Higher
|
||||||
Higher = larger output.
|
= larger output.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -319,45 +111,13 @@ function hasRects(): boolean {
|
|||||||
:model-value="String(store.scalePxPerMm)"
|
:model-value="String(store.scalePxPerMm)"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
class="w-28 font-mono"
|
class="w-28"
|
||||||
@update:model-value="
|
@update:model-value="
|
||||||
(v: string | number) =>
|
(v: string | number) =>
|
||||||
(store.scalePxPerMm = Number(v) || 10)
|
(store.scalePxPerMm = Number(v) || 10)
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<span class="font-mono text-sm text-muted-foreground"
|
<span class="text-sm text-muted-foreground">px / mm</span>
|
||||||
>px/mm</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<!-- Estimated output size -->
|
|
||||||
<div
|
|
||||||
v-if="estimatedOutput"
|
|
||||||
class="mt-3 space-y-1 text-sm"
|
|
||||||
:class="
|
|
||||||
tooLarge
|
|
||||||
? 'text-destructive'
|
|
||||||
: 'text-muted-foreground'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
Est. output:
|
|
||||||
<span class="font-mono"
|
|
||||||
>~{{ estimatedOutput.w }}×{{
|
|
||||||
estimatedOutput.h
|
|
||||||
}}px</span
|
|
||||||
>
|
|
||||||
 — 
|
|
||||||
<span class="font-mono"
|
|
||||||
>~{{
|
|
||||||
estimatedOutput.mb.toFixed(0)
|
|
||||||
}} MB</span
|
|
||||||
>
|
|
||||||
RAM
|
|
||||||
</p>
|
|
||||||
<p v-if="tooLarge" class="font-medium">
|
|
||||||
Exceeds {{ MAX_RGBA_MB }} MB limit —
|
|
||||||
lower the scale or use a smaller source image.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -377,36 +137,27 @@ function hasRects(): boolean {
|
|||||||
v-for="datum in store.datums"
|
v-for="datum in store.datums"
|
||||||
:key="datum.id"
|
:key="datum.id"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="font-normal"
|
|
||||||
>
|
>
|
||||||
{{ datum.label }}
|
{{ datum.label }}
|
||||||
<span class="ml-1 font-mono text-xs">{{
|
({{
|
||||||
datum.type === "rectangle"
|
datum.type === "rectangle"
|
||||||
? `${datum.widthMm}\u00D7${datum.heightMm}mm`
|
? `${datum.widthMm}\u00D7${datum.heightMm}mm`
|
||||||
: `${datum.lengthMm}mm`
|
: `${datum.lengthMm}mm`
|
||||||
}}</span>
|
}}) — confidence {{ datum.confidence }}/5
|
||||||
<span class="ml-1 text-muted-foreground"
|
|
||||||
>conf {{ datum.confidence }}/5</span
|
|
||||||
>
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p v-if="!hasRects()" class="mt-3 text-sm text-destructive">
|
||||||
v-if="!hasRects()"
|
|
||||||
class="mt-3 text-sm text-destructive"
|
|
||||||
>
|
|
||||||
At least one rectangle datum is required for perspective
|
At least one rectangle datum is required for perspective
|
||||||
correction.
|
correction.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Run button + progress -->
|
<!-- Run button -->
|
||||||
<div class="flex flex-col items-center gap-3">
|
<div class="flex flex-col items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
:disabled="
|
:disabled="store.isProcessing || !hasRects()"
|
||||||
store.isProcessing || !hasRects() || tooLarge
|
|
||||||
"
|
|
||||||
@click="runDeskew"
|
@click="runDeskew"
|
||||||
>
|
>
|
||||||
<template v-if="store.isProcessing">
|
<template v-if="store.isProcessing">
|
||||||
@ -440,20 +191,6 @@ function hasRects(): boolean {
|
|||||||
}}
|
}}
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- Progress bar -->
|
|
||||||
<div
|
|
||||||
v-if="store.isProcessing"
|
|
||||||
class="w-full max-w-sm space-y-1.5"
|
|
||||||
>
|
|
||||||
<Progress :model-value="progressPercent" class="h-2" />
|
|
||||||
<p
|
|
||||||
class="text-center font-mono text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
[{{ progressStep + 1 }}/{{ progressTotal }}]
|
|
||||||
{{ progressLabel }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="text-center text-sm text-destructive">
|
<p v-if="error" class="text-center text-sm text-destructive">
|
||||||
@ -461,7 +198,7 @@ function hasRects(): boolean {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Result -->
|
<!-- Result -->
|
||||||
<template v-if="store.deskewResult && resultUrl">
|
<template v-if="store.deskewResult">
|
||||||
<!-- Diagnostics -->
|
<!-- Diagnostics -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -471,60 +208,24 @@ function hasRects(): boolean {
|
|||||||
<strong>{{
|
<strong>{{
|
||||||
store.deskewResult.diagnostics.primaryDatum
|
store.deskewResult.diagnostics.primaryDatum
|
||||||
}}</strong>
|
}}</strong>
|
||||||
<span class="mx-1 text-muted-foreground/50"
|
 •  Output:
|
||||||
>|</span
|
{{
|
||||||
>
|
|
||||||
Output:
|
|
||||||
<span class="font-mono"
|
|
||||||
>{{
|
|
||||||
store.deskewResult.diagnostics.outputWidthPx
|
store.deskewResult.diagnostics.outputWidthPx
|
||||||
}}×{{
|
}}×{{
|
||||||
store.deskewResult.diagnostics.outputHeightPx
|
store.deskewResult.diagnostics.outputHeightPx
|
||||||
}}px</span
|
}}px
|
||||||
>
|
|
||||||
<span class="mx-1 text-muted-foreground/50"
|
|
||||||
>|</span
|
|
||||||
>
|
|
||||||
<span class="font-mono"
|
|
||||||
>{{
|
|
||||||
(
|
|
||||||
store.deskewResult.diagnostics
|
|
||||||
.outputWidthPx / store.scalePxPerMm
|
|
||||||
).toFixed(1)
|
|
||||||
}}×{{
|
|
||||||
(
|
|
||||||
store.deskewResult.diagnostics
|
|
||||||
.outputHeightPx / store.scalePxPerMm
|
|
||||||
).toFixed(1)
|
|
||||||
}}mm</span
|
|
||||||
>
|
|
||||||
<span class="mx-1 text-muted-foreground/50"
|
|
||||||
>|</span
|
|
||||||
>
|
|
||||||
<span class="font-mono"
|
|
||||||
>{{
|
|
||||||
(
|
|
||||||
store.deskewResult.correctedImageBlob
|
|
||||||
.size /
|
|
||||||
1024 /
|
|
||||||
1024
|
|
||||||
).toFixed(1)
|
|
||||||
}} MB</span
|
|
||||||
>
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<!-- Axis corrections -->
|
<!-- Axis corrections -->
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div
|
<div class="rounded-md border p-3">
|
||||||
class="rounded-md border border-border/50 p-3"
|
|
||||||
>
|
|
||||||
<p
|
<p
|
||||||
class="text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
class="text-xs font-medium text-muted-foreground"
|
||||||
>
|
>
|
||||||
X-axis correction
|
X-axis correction
|
||||||
</p>
|
</p>
|
||||||
<p class="font-mono text-lg font-semibold">
|
<p class="text-lg font-semibold">
|
||||||
{{
|
{{
|
||||||
(
|
(
|
||||||
store.deskewResult.diagnostics
|
store.deskewResult.diagnostics
|
||||||
@ -532,25 +233,22 @@ function hasRects(): boolean {
|
|||||||
).toFixed(2)
|
).toFixed(2)
|
||||||
}}%
|
}}%
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p class="text-xs text-muted-foreground">
|
||||||
class="font-mono text-xs text-muted-foreground"
|
weight:
|
||||||
>
|
{{
|
||||||
w={{
|
|
||||||
store.deskewResult.diagnostics.xCorrection.totalWeight.toFixed(
|
store.deskewResult.diagnostics.xCorrection.totalWeight.toFixed(
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="rounded-md border p-3">
|
||||||
class="rounded-md border border-border/50 p-3"
|
|
||||||
>
|
|
||||||
<p
|
<p
|
||||||
class="text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
class="text-xs font-medium text-muted-foreground"
|
||||||
>
|
>
|
||||||
Y-axis correction
|
Y-axis correction
|
||||||
</p>
|
</p>
|
||||||
<p class="font-mono text-lg font-semibold">
|
<p class="text-lg font-semibold">
|
||||||
{{
|
{{
|
||||||
(
|
(
|
||||||
store.deskewResult.diagnostics
|
store.deskewResult.diagnostics
|
||||||
@ -558,10 +256,9 @@ function hasRects(): boolean {
|
|||||||
).toFixed(2)
|
).toFixed(2)
|
||||||
}}%
|
}}%
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p class="text-xs text-muted-foreground">
|
||||||
class="font-mono text-xs text-muted-foreground"
|
weight:
|
||||||
>
|
{{
|
||||||
w={{
|
|
||||||
store.deskewResult.diagnostics.yCorrection.totalWeight.toFixed(
|
store.deskewResult.diagnostics.yCorrection.totalWeight.toFixed(
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
@ -573,8 +270,7 @@ function hasRects(): boolean {
|
|||||||
<!-- Per-datum table -->
|
<!-- Per-datum table -->
|
||||||
<Table
|
<Table
|
||||||
v-if="
|
v-if="
|
||||||
store.deskewResult.diagnostics.perDatum.length >
|
store.deskewResult.diagnostics.perDatum.length > 0
|
||||||
0
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -587,36 +283,32 @@ function hasRects(): boolean {
|
|||||||
<TableHead class="text-right"
|
<TableHead class="text-right"
|
||||||
>Measured (mm)</TableHead
|
>Measured (mm)</TableHead
|
||||||
>
|
>
|
||||||
<TableHead class="text-right"
|
<TableHead class="text-right">Error</TableHead>
|
||||||
>Error</TableHead
|
|
||||||
>
|
|
||||||
<TableHead>Axis</TableHead>
|
<TableHead>Axis</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow
|
<TableRow
|
||||||
v-for="report in store.deskewResult
|
v-for="report in store.deskewResult.diagnostics
|
||||||
.diagnostics.perDatum"
|
.perDatum"
|
||||||
:key="report.label"
|
:key="report.label"
|
||||||
>
|
>
|
||||||
<TableCell class="font-medium">{{
|
<TableCell class="font-medium">{{
|
||||||
report.label
|
report.label
|
||||||
}}</TableCell>
|
}}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge variant="outline" class="text-xs">{{
|
||||||
variant="outline"
|
report.type
|
||||||
class="text-xs"
|
}}</Badge>
|
||||||
>{{ report.type }}</Badge
|
|
||||||
>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="font-mono text-right">{{
|
<TableCell class="text-right">{{
|
||||||
report.expectedMm.toFixed(1)
|
report.expectedMm.toFixed(1)
|
||||||
}}</TableCell>
|
}}</TableCell>
|
||||||
<TableCell class="font-mono text-right">{{
|
<TableCell class="text-right">{{
|
||||||
report.measuredMm.toFixed(1)
|
report.measuredMm.toFixed(1)
|
||||||
}}</TableCell>
|
}}</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
class="font-mono text-right"
|
class="text-right"
|
||||||
:class="
|
:class="
|
||||||
report.errorPercent > 5
|
report.errorPercent > 5
|
||||||
? 'text-destructive'
|
? 'text-destructive'
|
||||||
@ -634,134 +326,26 @@ function hasRects(): boolean {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Algorithm explanation -->
|
<!-- Corrected image -->
|
||||||
<Card class="border-border/40">
|
<Card>
|
||||||
<CardContent class="pb-5 pt-5">
|
<CardHeader>
|
||||||
<button
|
<CardTitle class="text-base">Corrected Image</CardTitle>
|
||||||
class="flex w-full items-center gap-2 text-left text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
</CardHeader>
|
||||||
@click="
|
<CardContent>
|
||||||
showAlgoDetails = !showAlgoDetails
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="shrink-0 transition-transform duration-200"
|
|
||||||
:class="
|
|
||||||
showAlgoDetails
|
|
||||||
? 'rotate-90'
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<path d="m9 18 6-6-6-6" />
|
|
||||||
</svg>
|
|
||||||
How the algorithm works
|
|
||||||
</button>
|
|
||||||
<div
|
<div
|
||||||
v-show="showAlgoDetails"
|
class="flex items-center justify-center overflow-hidden rounded-md bg-muted"
|
||||||
class="mt-4 pl-6"
|
|
||||||
>
|
>
|
||||||
<ol
|
<img
|
||||||
class="list-decimal space-y-2 text-sm leading-relaxed text-muted-foreground marker:font-semibold marker:text-foreground/60"
|
v-if="resultUrl"
|
||||||
>
|
:src="resultUrl"
|
||||||
<li>
|
alt="Corrected image"
|
||||||
The primary rectangle datum
|
class="max-h-[500px] w-full object-contain"
|
||||||
defines a
|
/>
|
||||||
<strong
|
|
||||||
class="text-foreground/80"
|
|
||||||
>homography</strong
|
|
||||||
>
|
|
||||||
— a 3×3 projective
|
|
||||||
transform mapping the
|
|
||||||
quadrilateral in the source
|
|
||||||
image to a true rectangle at
|
|
||||||
the specified real-world
|
|
||||||
dimensions.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Secondary datums (additional
|
|
||||||
rectangles or line segments)
|
|
||||||
provide
|
|
||||||
<strong
|
|
||||||
class="text-foreground/80"
|
|
||||||
>weighted correction
|
|
||||||
factors</strong
|
|
||||||
>
|
|
||||||
for the X and Y axes. Each
|
|
||||||
secondary datum's contribution
|
|
||||||
is weighted by its confidence
|
|
||||||
score, refining the scale in
|
|
||||||
each axis independently.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
The final correction is applied
|
|
||||||
as a single
|
|
||||||
<code
|
|
||||||
class="rounded bg-muted px-1 py-0.5 font-mono text-xs text-foreground"
|
|
||||||
>cv::warpPerspective</code
|
|
||||||
>
|
|
||||||
call via OpenCV WASM, producing
|
|
||||||
the output image at the
|
|
||||||
requested px/mm scale.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Corrected image with tools -->
|
<div class="flex justify-center pb-8">
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle class="text-base"
|
|
||||||
>Corrected Image</CardTitle
|
|
||||||
>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<CorrectedImageViewer
|
|
||||||
:image-url="resultUrl"
|
|
||||||
:scale-px-per-mm="store.scalePxPerMm"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Download -->
|
|
||||||
<div class="flex flex-col items-center gap-3 pb-8">
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger as-child>
|
|
||||||
<label
|
|
||||||
class="flex cursor-pointer items-center gap-2"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id="scale-bar-check"
|
|
||||||
:checked="includeScaleBar"
|
|
||||||
@update:checked="
|
|
||||||
(v: boolean) =>
|
|
||||||
(includeScaleBar = v)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
for="scale-bar-check"
|
|
||||||
class="cursor-pointer text-sm text-muted-foreground"
|
|
||||||
>Include scale bar in export</Label
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" class="max-w-xs">
|
|
||||||
Appends a black bar at the bottom of the
|
|
||||||
exported image with a measurement scale and
|
|
||||||
px/mm annotation.
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<Button size="lg" @click="download">
|
<Button size="lg" @click="download">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@ -775,44 +359,12 @@ function hasRects(): boolean {
|
|||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
class="mr-2"
|
class="mr-2"
|
||||||
>
|
>
|
||||||
<path
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
|
|
||||||
/>
|
|
||||||
<polyline points="7 10 12 15 17 10" />
|
<polyline points="7 10 12 15 17 10" />
|
||||||
<line
|
<line x1="12" x2="12" y1="15" y2="3" />
|
||||||
x1="12"
|
|
||||||
x2="12"
|
|
||||||
y1="15"
|
|
||||||
y2="3"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
Download PNG
|
Download PNG
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
variant="outline"
|
|
||||||
@click="store.reset()"
|
|
||||||
>
|
|
||||||
Process New Image
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="font-mono text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
store.deskewResult.diagnostics.outputWidthPx
|
|
||||||
}}×{{
|
|
||||||
store.deskewResult.diagnostics.outputHeightPx
|
|
||||||
}} px —
|
|
||||||
{{
|
|
||||||
(
|
|
||||||
store.deskewResult
|
|
||||||
.correctedImageBlob.size /
|
|
||||||
1024 /
|
|
||||||
1024
|
|
||||||
).toFixed(1)
|
|
||||||
}} MB
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,280 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
size?: number
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
size: 28,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 64 64"
|
|
||||||
:width="size"
|
|
||||||
:height="size"
|
|
||||||
aria-label="Skwik squirrel logo"
|
|
||||||
>
|
|
||||||
<!-- Tail (fluffy, curling up behind) -->
|
|
||||||
<path
|
|
||||||
d="M50 52 Q58 40 54 28 Q52 22 48 20
|
|
||||||
Q55 18 56 12 Q56 8 52 10
|
|
||||||
Q48 12 46 18"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
opacity="0.7"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Body -->
|
|
||||||
<ellipse
|
|
||||||
cx="32"
|
|
||||||
cy="46"
|
|
||||||
rx="12"
|
|
||||||
ry="10"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Head -->
|
|
||||||
<ellipse
|
|
||||||
cx="32"
|
|
||||||
cy="28"
|
|
||||||
rx="12"
|
|
||||||
ry="13"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Ear tufts -->
|
|
||||||
<path
|
|
||||||
d="M22 18 Q18 10 14 12 Q12 16 20 20"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M42 18 Q46 10 50 12 Q52 16 44 20"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Eyes -->
|
|
||||||
<circle
|
|
||||||
cx="27"
|
|
||||||
cy="26"
|
|
||||||
r="3"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx="37"
|
|
||||||
cy="26"
|
|
||||||
r="3"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<!-- Eye shine -->
|
|
||||||
<circle cx="28.2" cy="25" r="1" fill="var(--background, #fff)" />
|
|
||||||
<circle cx="38.2" cy="25" r="1" fill="var(--background, #fff)" />
|
|
||||||
|
|
||||||
<!-- Nose -->
|
|
||||||
<ellipse
|
|
||||||
cx="32"
|
|
||||||
cy="32"
|
|
||||||
rx="2"
|
|
||||||
ry="1.5"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Cheeks -->
|
|
||||||
<ellipse
|
|
||||||
cx="24"
|
|
||||||
cy="32"
|
|
||||||
rx="4"
|
|
||||||
ry="3"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1"
|
|
||||||
opacity="0.3"
|
|
||||||
/>
|
|
||||||
<ellipse
|
|
||||||
cx="40"
|
|
||||||
cy="32"
|
|
||||||
rx="4"
|
|
||||||
ry="3"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1"
|
|
||||||
opacity="0.3"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Mouth -->
|
|
||||||
<path
|
|
||||||
d="M30 34 Q32 36 34 34"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Arms holding ruler -->
|
|
||||||
<path
|
|
||||||
d="M20 42 L12 38"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M44 42 L52 38"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Ruler (held diagonally) -->
|
|
||||||
<rect
|
|
||||||
x="4"
|
|
||||||
y="34"
|
|
||||||
width="22"
|
|
||||||
height="4"
|
|
||||||
rx="0.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
transform="rotate(-15 15 36)"
|
|
||||||
/>
|
|
||||||
<!-- Ruler tick marks -->
|
|
||||||
<line
|
|
||||||
x1="8" y1="34" x2="8" y2="36"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1"
|
|
||||||
transform="rotate(-15 15 36)"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="12" y1="34" x2="12" y2="36"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1"
|
|
||||||
transform="rotate(-15 15 36)"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="16" y1="34" x2="16" y2="36"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1"
|
|
||||||
transform="rotate(-15 15 36)"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="20" y1="34" x2="20" y2="36"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1"
|
|
||||||
transform="rotate(-15 15 36)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Safety goggles on forehead -->
|
|
||||||
<path
|
|
||||||
d="M22 20 Q27 17 32 18 Q37 17 42 20"
|
|
||||||
fill="none"
|
|
||||||
stroke="#f59e0b"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
<!-- Goggle lenses -->
|
|
||||||
<ellipse
|
|
||||||
cx="26"
|
|
||||||
cy="20"
|
|
||||||
rx="3.5"
|
|
||||||
ry="2.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="#f59e0b"
|
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
|
||||||
<ellipse
|
|
||||||
cx="38"
|
|
||||||
cy="20"
|
|
||||||
rx="3.5"
|
|
||||||
ry="2.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="#f59e0b"
|
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
|
||||||
<!-- Goggle bridge -->
|
|
||||||
<path
|
|
||||||
d="M29.5 20 Q32 19 34.5 20"
|
|
||||||
fill="none"
|
|
||||||
stroke="#f59e0b"
|
|
||||||
stroke-width="1.2"
|
|
||||||
/>
|
|
||||||
<!-- Goggle lens tint -->
|
|
||||||
<ellipse
|
|
||||||
cx="26"
|
|
||||||
cy="20"
|
|
||||||
rx="2.5"
|
|
||||||
ry="1.5"
|
|
||||||
fill="#f59e0b"
|
|
||||||
opacity="0.15"
|
|
||||||
/>
|
|
||||||
<ellipse
|
|
||||||
cx="38"
|
|
||||||
cy="20"
|
|
||||||
rx="2.5"
|
|
||||||
ry="1.5"
|
|
||||||
fill="#f59e0b"
|
|
||||||
opacity="0.15"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Hard hat -->
|
|
||||||
<path
|
|
||||||
d="M18 16 Q18 6 32 5 Q46 6 46 16"
|
|
||||||
fill="none"
|
|
||||||
stroke="#f59e0b"
|
|
||||||
stroke-width="2.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
<!-- Hat brim -->
|
|
||||||
<path
|
|
||||||
d="M16 16 L48 16"
|
|
||||||
stroke="#f59e0b"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
<!-- Hat dome highlight -->
|
|
||||||
<path
|
|
||||||
d="M26 9 Q32 7 38 9"
|
|
||||||
fill="none"
|
|
||||||
stroke="#fbbf24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
opacity="0.6"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Feet -->
|
|
||||||
<ellipse
|
|
||||||
cx="26"
|
|
||||||
cy="56"
|
|
||||||
rx="4"
|
|
||||||
ry="2"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
|
||||||
<ellipse
|
|
||||||
cx="38"
|
|
||||||
cy="56"
|
|
||||||
rx="4"
|
|
||||||
ry="2"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@ -17,32 +17,17 @@ const steps = [
|
|||||||
<template v-for="(step, i) in steps" :key="step.num">
|
<template v-for="(step, i) in steps" :key="step.num">
|
||||||
<Badge
|
<Badge
|
||||||
:variant="
|
:variant="
|
||||||
store.currentStep === step.num
|
store.currentStep === step.num ? 'default' : 'outline'
|
||||||
? 'default'
|
|
||||||
: 'outline'
|
|
||||||
"
|
"
|
||||||
class="select-none font-mono text-xs"
|
class="cursor-default select-none text-xs"
|
||||||
:class="{
|
:class="{
|
||||||
'opacity-40': store.currentStep < step.num,
|
'opacity-40': store.currentStep < step.num,
|
||||||
'cursor-pointer hover:bg-accent':
|
|
||||||
step.num <= store.maxStepReached
|
|
||||||
&& step.num !== store.currentStep,
|
|
||||||
'cursor-default':
|
|
||||||
step.num > store.maxStepReached
|
|
||||||
|| step.num === store.currentStep,
|
|
||||||
}"
|
}"
|
||||||
@click="
|
|
||||||
step.num <= store.maxStepReached
|
|
||||||
? store.goToStep(step.num)
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
{{ step.num }}. {{ step.label }}
|
{{ step.num }}. {{ step.label }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span
|
<span v-if="i < steps.length - 1" class="text-muted-foreground"
|
||||||
v-if="i < steps.length - 1"
|
>·</span
|
||||||
class="text-xs text-muted-foreground/40"
|
|
||||||
>›</span
|
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
|
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'vue'
|
|
||||||
import { reactiveOmit } from '@vueuse/core'
|
|
||||||
import { CheckIcon } from 'lucide-vue-next'
|
|
||||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
|
|
||||||
const emits = defineEmits<CheckboxRootEmits>()
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, 'class')
|
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<CheckboxRoot
|
|
||||||
v-slot="slotProps"
|
|
||||||
data-slot="checkbox"
|
|
||||||
v-bind="forwarded"
|
|
||||||
:class="cn('border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)"
|
|
||||||
>
|
|
||||||
<CheckboxIndicator
|
|
||||||
data-slot="checkbox-indicator"
|
|
||||||
class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
|
|
||||||
>
|
|
||||||
<slot v-bind="slotProps">
|
|
||||||
<CheckIcon />
|
|
||||||
</slot>
|
|
||||||
</CheckboxIndicator>
|
|
||||||
</CheckboxRoot>
|
|
||||||
</template>
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { default as Checkbox } from './Checkbox.vue'
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
import type { Datum } from "@/types"
|
|
||||||
|
|
||||||
const KEY_PREFIX = "skwik-datums-"
|
|
||||||
|
|
||||||
export function saveDatums(hash: string, datums: Datum[]): void {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
KEY_PREFIX + hash,
|
|
||||||
JSON.stringify(datums),
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
// localStorage full or unavailable — silently ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadDatums(hash: string): Datum[] | null {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(KEY_PREFIX + hash)
|
|
||||||
if (!raw) return null
|
|
||||||
return JSON.parse(raw) as Datum[]
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearCache(): void {
|
|
||||||
const toRemove: string[] = []
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i)
|
|
||||||
if (key?.startsWith(KEY_PREFIX)) {
|
|
||||||
toRemove.push(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const key of toRemove) {
|
|
||||||
localStorage.removeItem(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCacheSize(): number {
|
|
||||||
let count = 0
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i)
|
|
||||||
if (key?.startsWith(KEY_PREFIX)) {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* deskew.ts — Browser-based perspective correction using OpenCV.js (WASM)
|
* deskew.ts — Browser-based perspective correction using OpenCV.js (WASM)
|
||||||
*
|
*
|
||||||
* Accepts N datums (rectangles and/or lines), each with known real-world
|
* Adapted from the reference algorithm. Accepts N datums (rectangles and/or
|
||||||
* dimensions and a confidence score (1–5). Minimum: one rectangle.
|
* lines), each with known real-world dimensions and a confidence score (1–5).
|
||||||
|
* Minimum: one rectangle.
|
||||||
*
|
*
|
||||||
* Algorithm:
|
* Algorithm:
|
||||||
* 1. Pick the highest-confidence rectangle as primary reference.
|
* 1. Pick the highest-confidence rectangle as primary reference.
|
||||||
@ -26,10 +27,6 @@ import type {
|
|||||||
RectDatum,
|
RectDatum,
|
||||||
} from "@/types"
|
} from "@/types"
|
||||||
|
|
||||||
// Max output dimension in pixels to avoid WASM OOM
|
|
||||||
// 12288 = ~576MB RGBA at square, but actual images are rarely square
|
|
||||||
const MAX_OUTPUT_DIM = 12288
|
|
||||||
|
|
||||||
// ─── OpenCV helpers ──────────────────────────────────────────────────────────
|
// ─── OpenCV helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function pointsToMat(points: Point[]): InstanceType<typeof cv.Mat> {
|
function pointsToMat(points: Point[]): InstanceType<typeof cv.Mat> {
|
||||||
@ -78,8 +75,7 @@ function mul3x3(A: number[], B: number[]): number[] {
|
|||||||
for (let c = 0; c < 3; c++) {
|
for (let c = 0; c < 3; c++) {
|
||||||
let sum = 0
|
let sum = 0
|
||||||
for (let k = 0; k < 3; k++) {
|
for (let k = 0; k < 3; k++) {
|
||||||
sum +=
|
sum += (A[r * 3 + k] ?? 0) * (B[k * 3 + c] ?? 0)
|
||||||
(A[r * 3 + k] ?? 0) * (B[k * 3 + c] ?? 0)
|
|
||||||
}
|
}
|
||||||
R[r * 3 + c] = sum
|
R[r * 3 + c] = sum
|
||||||
}
|
}
|
||||||
@ -90,20 +86,17 @@ function mul3x3(A: number[], B: number[]): number[] {
|
|||||||
// ─── Validation ──────────────────────────────────────────────────────────────
|
// ─── Validation ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function pickPrimary(datums: Datum[]): RectDatum {
|
function pickPrimary(datums: Datum[]): RectDatum {
|
||||||
const rects = datums.filter(
|
const rects = datums.filter((d): d is RectDatum => d.type === "rectangle")
|
||||||
(d): d is RectDatum => d.type === "rectangle",
|
|
||||||
)
|
|
||||||
if (rects.length === 0) {
|
if (rects.length === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"At least one rectangle datum is required for perspective correction.",
|
"At least one rectangle datum is required for perspective correction.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Highest confidence; tie-break by pixel area (larger = more precise corners)
|
||||||
rects.sort((a, b) => {
|
rects.sort((a, b) => {
|
||||||
if (b.confidence !== a.confidence)
|
if (b.confidence !== a.confidence) return b.confidence - a.confidence
|
||||||
return b.confidence - a.confidence
|
|
||||||
const area = (r: RectDatum) =>
|
const area = (r: RectDatum) =>
|
||||||
dist(r.corners[0], r.corners[1]) *
|
dist(r.corners[0], r.corners[1]) * dist(r.corners[0], r.corners[3])
|
||||||
dist(r.corners[0], r.corners[3])
|
|
||||||
return area(b) - area(a)
|
return area(b) - area(a)
|
||||||
})
|
})
|
||||||
return rects[0] as RectDatum
|
return rects[0] as RectDatum
|
||||||
@ -116,6 +109,7 @@ function pickPrimary(datums: Datum[]): RectDatum {
|
|||||||
function cornersToAlgoOrder(
|
function cornersToAlgoOrder(
|
||||||
corners: [Point, Point, Point, Point],
|
corners: [Point, Point, Point, Point],
|
||||||
): [Point, Point, Point, Point] {
|
): [Point, Point, Point, Point] {
|
||||||
|
// App: [TL, TR, BR, BL] → Algo: [TL, TR, BL, BR]
|
||||||
return [corners[0], corners[1], corners[3], corners[2]]
|
return [corners[0], corners[1], corners[3], corners[2]]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,8 +123,11 @@ function canvasToBlob(
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
(b) => {
|
(b) => {
|
||||||
if (b) resolve(b)
|
if (b) {
|
||||||
else reject(new Error("toBlob failed"))
|
resolve(b)
|
||||||
|
} else {
|
||||||
|
reject(new Error("toBlob failed"))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
type,
|
type,
|
||||||
quality,
|
quality,
|
||||||
@ -140,91 +137,47 @@ function canvasToBlob(
|
|||||||
|
|
||||||
// ─── Core ────────────────────────────────────────────────────────────────────
|
// ─── Core ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const log = (tag: string, ...args: unknown[]) => {
|
export async function deskewImage(input: DeskewInput): Promise<DeskewResult> {
|
||||||
console.log(`[deskew:${tag}]`, ...args)
|
const { image, datums, scalePxPerMm: scale } = input
|
||||||
}
|
|
||||||
|
|
||||||
export async function deskewImage(
|
|
||||||
input: DeskewInput,
|
|
||||||
): Promise<DeskewResult> {
|
|
||||||
const { image, datums, scalePxPerMm: scale, onProgress } = input
|
|
||||||
log("start", `${String(datums.length)} datums, scale=${String(scale)} px/mm`)
|
|
||||||
|
|
||||||
const TOTAL_STEPS = 7
|
|
||||||
const progress = async (step: number, label: string) => {
|
|
||||||
log(`progress`, `[${String(step + 1)}/${String(TOTAL_STEPS)}] ${label}`)
|
|
||||||
onProgress?.(step, TOTAL_STEPS, label)
|
|
||||||
// Yield to let the browser repaint
|
|
||||||
await new Promise((r) => {
|
|
||||||
requestAnimationFrame(r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (datums.length === 0) throw new Error("No datums provided.")
|
if (datums.length === 0) throw new Error("No datums provided.")
|
||||||
|
|
||||||
const primary = pickPrimary(datums)
|
const primary = pickPrimary(datums)
|
||||||
log("primary", primary.label, `${String(primary.widthMm)}×${String(primary.heightMm)}mm`, `conf=${String(primary.confidence)}`)
|
|
||||||
|
|
||||||
// Load source image into OpenCV
|
// Load source image into OpenCV
|
||||||
let srcCanvas: HTMLCanvasElement
|
let srcCanvas: HTMLCanvasElement
|
||||||
if (image instanceof HTMLCanvasElement) {
|
if (image instanceof HTMLCanvasElement) {
|
||||||
srcCanvas = image
|
srcCanvas = image
|
||||||
log("input", `canvas ${String(image.width)}×${String(image.height)}`)
|
|
||||||
} else {
|
} else {
|
||||||
srcCanvas = document.createElement("canvas")
|
srcCanvas = document.createElement("canvas")
|
||||||
srcCanvas.width = image.naturalWidth
|
srcCanvas.width = image.naturalWidth
|
||||||
srcCanvas.height = image.naturalHeight
|
srcCanvas.height = image.naturalHeight
|
||||||
log("input", `img ${String(image.naturalWidth)}×${String(image.naturalHeight)}, drawing to canvas`)
|
|
||||||
const ctx = srcCanvas.getContext("2d")
|
const ctx = srcCanvas.getContext("2d")
|
||||||
if (!ctx) throw new Error("Failed to get 2d context")
|
if (!ctx) throw new Error("Failed to get 2d context")
|
||||||
ctx.drawImage(image, 0, 0)
|
ctx.drawImage(image, 0, 0)
|
||||||
}
|
}
|
||||||
|
const src = cv.imread(srcCanvas)
|
||||||
await progress(0, "Loading image into OpenCV")
|
|
||||||
|
|
||||||
// All OpenCV mats to clean up
|
|
||||||
const mats: InstanceType<typeof cv.Mat>[] = []
|
|
||||||
const track = <T extends InstanceType<typeof cv.Mat>>(m: T): T => {
|
|
||||||
mats.push(m)
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
log("cv.imread", "reading source canvas into cv.Mat")
|
|
||||||
const src = track(cv.imread(srcCanvas))
|
|
||||||
const imgW = src.cols
|
const imgW = src.cols
|
||||||
const imgH = src.rows
|
const imgH = src.rows
|
||||||
log("cv.imread", `done: ${String(imgW)}×${String(imgH)}, type=${String(src.type())}, channels=${String(src.channels())}`)
|
|
||||||
|
|
||||||
// ============================================================
|
// ================================================================
|
||||||
// STEP 1 — Initial perspective correction from primary rect
|
// STEP 1 — Initial perspective correction from primary rectangle
|
||||||
// ============================================================
|
// ================================================================
|
||||||
await progress(1, "Computing initial homography")
|
|
||||||
const pw = primary.widthMm * scale
|
const pw = primary.widthMm * scale
|
||||||
const ph = primary.heightMm * scale
|
const ph = primary.heightMm * scale
|
||||||
log("step1", `dest rect: ${pw.toFixed(1)}×${ph.toFixed(1)} px`)
|
|
||||||
|
|
||||||
const algoCorners = cornersToAlgoOrder(primary.corners)
|
const algoCorners = cornersToAlgoOrder(primary.corners)
|
||||||
log("step1", `corners (algo order): ${JSON.stringify(algoCorners)}`)
|
const srcPts = pointsToMat(algoCorners)
|
||||||
const srcPts = track(pointsToMat(algoCorners))
|
const dstInit = pointsToMat([
|
||||||
|
|
||||||
const dstInit = track(
|
|
||||||
pointsToMat([
|
|
||||||
{ x: 0, y: 0 },
|
{ x: 0, y: 0 },
|
||||||
{ x: pw, y: 0 },
|
{ x: pw, y: 0 },
|
||||||
{ x: 0, y: ph },
|
{ x: 0, y: ph },
|
||||||
{ x: pw, y: ph },
|
{ x: pw, y: ph },
|
||||||
]),
|
])
|
||||||
)
|
const mInit = cv.getPerspectiveTransform(srcPts, dstInit)
|
||||||
log("step1", "calling getPerspectiveTransform (initial)")
|
|
||||||
const mInit = track(
|
|
||||||
cv.getPerspectiveTransform(srcPts, dstInit),
|
|
||||||
)
|
|
||||||
log("step1", `mInit type=${String(mInit.type())}, rows=${String(mInit.rows)}, cols=${String(mInit.cols)}`)
|
|
||||||
|
|
||||||
// ============================================================
|
// ================================================================
|
||||||
// STEP 2 — Measure secondary datums, accumulate corrections
|
// STEP 2 — Measure all secondary datums, accumulate corrections
|
||||||
// ============================================================
|
// ================================================================
|
||||||
await progress(2, "Measuring secondary datums")
|
|
||||||
let xWSum = 0,
|
let xWSum = 0,
|
||||||
xWTotal = 0
|
xWTotal = 0
|
||||||
let yWSum = 0,
|
let yWSum = 0,
|
||||||
@ -247,12 +200,7 @@ export async function deskewImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (datum.type === "line") {
|
if (datum.type === "line") {
|
||||||
const pts = transformPoints(
|
const [s, e] = transformPoints(datum.endpoints as Point[], mInit)
|
||||||
datum.endpoints as Point[],
|
|
||||||
mInit,
|
|
||||||
)
|
|
||||||
const s = pts[0]
|
|
||||||
const e = pts[1]
|
|
||||||
if (!s || !e) continue
|
if (!s || !e) continue
|
||||||
const dx = Math.abs(e.x - s.x)
|
const dx = Math.abs(e.x - s.x)
|
||||||
const dy = Math.abs(e.y - s.y)
|
const dy = Math.abs(e.y - s.y)
|
||||||
@ -260,6 +208,7 @@ export async function deskewImage(
|
|||||||
const expected = datum.lengthMm * scale
|
const expected = datum.lengthMm * scale
|
||||||
const ratio = expected / measured
|
const ratio = expected / measured
|
||||||
|
|
||||||
|
// Axis contribution proportional to alignment
|
||||||
const total = dx + dy
|
const total = dx + dy
|
||||||
if (total > 1e-6) {
|
if (total > 1e-6) {
|
||||||
const xFrac = dx / total
|
const xFrac = dx / total
|
||||||
@ -279,14 +228,9 @@ export async function deskewImage(
|
|||||||
axisContribution: dx > dy ? "x" : "y",
|
axisContribution: dx > dy ? "x" : "y",
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
// Secondary rectangle: top edge → X, left edge → Y
|
||||||
const ac = cornersToAlgoOrder(datum.corners)
|
const ac = cornersToAlgoOrder(datum.corners)
|
||||||
const pts = transformPoints(
|
const [tl, tr, bl] = transformPoints([ac[0], ac[1], ac[2]], mInit)
|
||||||
[ac[0], ac[1], ac[2]],
|
|
||||||
mInit,
|
|
||||||
)
|
|
||||||
const tl = pts[0]
|
|
||||||
const tr = pts[1]
|
|
||||||
const bl = pts[2]
|
|
||||||
if (!tl || !tr || !bl) continue
|
if (!tl || !tr || !bl) continue
|
||||||
const mW = dist(tl, tr)
|
const mW = dist(tl, tr)
|
||||||
const mH = dist(tl, bl)
|
const mH = dist(tl, bl)
|
||||||
@ -303,17 +247,15 @@ export async function deskewImage(
|
|||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
measuredMm: mW / scale,
|
measuredMm: mW / scale,
|
||||||
expectedMm: datum.widthMm,
|
expectedMm: datum.widthMm,
|
||||||
errorPercent:
|
errorPercent: (Math.abs(1 - xR) + Math.abs(1 - yR)) * 50,
|
||||||
(Math.abs(1 - xR) + Math.abs(1 - yR)) * 50,
|
|
||||||
axisContribution: "both",
|
axisContribution: "both",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ================================================================
|
||||||
// STEP 3 — Weighted corrections (1.0 = no secondary data)
|
// STEP 3 — Weighted corrections (1.0 = no secondary data)
|
||||||
// ============================================================
|
// ================================================================
|
||||||
await progress(3, "Computing axis corrections")
|
|
||||||
const xCorr: AxisCorrection = {
|
const xCorr: AxisCorrection = {
|
||||||
ratio: xWTotal > 0 ? xWSum / xWTotal : 1.0,
|
ratio: xWTotal > 0 ? xWSum / xWTotal : 1.0,
|
||||||
totalWeight: xWTotal,
|
totalWeight: xWTotal,
|
||||||
@ -322,34 +264,24 @@ export async function deskewImage(
|
|||||||
ratio: yWTotal > 0 ? yWSum / yWTotal : 1.0,
|
ratio: yWTotal > 0 ? yWSum / yWTotal : 1.0,
|
||||||
totalWeight: yWTotal,
|
totalWeight: yWTotal,
|
||||||
}
|
}
|
||||||
log("step3", `xCorr=${xCorr.ratio.toFixed(4)} (w=${xCorr.totalWeight.toFixed(1)}), yCorr=${yCorr.ratio.toFixed(4)} (w=${yCorr.totalWeight.toFixed(1)})`)
|
|
||||||
|
|
||||||
// ============================================================
|
// ================================================================
|
||||||
// STEP 4 — Fold corrections, recompute transform
|
// STEP 4 — Fold into destination rectangle, recompute transform
|
||||||
// ============================================================
|
// ================================================================
|
||||||
await progress(4, "Recomputing final transform")
|
|
||||||
const pwFinal = pw * xCorr.ratio
|
const pwFinal = pw * xCorr.ratio
|
||||||
const phFinal = ph * yCorr.ratio
|
const phFinal = ph * yCorr.ratio
|
||||||
log("step4", `final dest rect: ${pwFinal.toFixed(1)}×${phFinal.toFixed(1)} px`)
|
|
||||||
|
|
||||||
const dstFinal = track(
|
const dstFinal = pointsToMat([
|
||||||
pointsToMat([
|
|
||||||
{ x: 0, y: 0 },
|
{ x: 0, y: 0 },
|
||||||
{ x: pwFinal, y: 0 },
|
{ x: pwFinal, y: 0 },
|
||||||
{ x: 0, y: phFinal },
|
{ x: 0, y: phFinal },
|
||||||
{ x: pwFinal, y: phFinal },
|
{ x: pwFinal, y: phFinal },
|
||||||
]),
|
])
|
||||||
)
|
const mFinal = cv.getPerspectiveTransform(srcPts, dstFinal)
|
||||||
log("step4", "calling getPerspectiveTransform (final)")
|
|
||||||
const mFinal = track(
|
|
||||||
cv.getPerspectiveTransform(srcPts, dstFinal),
|
|
||||||
)
|
|
||||||
log("step4", `mFinal type=${String(mFinal.type())}, rows=${String(mFinal.rows)}, cols=${String(mFinal.cols)}`)
|
|
||||||
|
|
||||||
// ============================================================
|
// ================================================================
|
||||||
// STEP 5 — Output bounds + translation shift
|
// STEP 5 — Output bounds + translation shift
|
||||||
// ============================================================
|
// ================================================================
|
||||||
await progress(5, "Computing output bounds")
|
|
||||||
const imgCorners: Point[] = [
|
const imgCorners: Point[] = [
|
||||||
{ x: 0, y: 0 },
|
{ x: 0, y: 0 },
|
||||||
{ x: imgW, y: 0 },
|
{ x: imgW, y: 0 },
|
||||||
@ -357,12 +289,6 @@ export async function deskewImage(
|
|||||||
{ x: imgW, y: imgH },
|
{ x: imgW, y: imgH },
|
||||||
]
|
]
|
||||||
const warped = transformPoints(imgCorners, mFinal)
|
const warped = transformPoints(imgCorners, mFinal)
|
||||||
if (warped.length < 4) {
|
|
||||||
throw new Error(
|
|
||||||
"Perspective transform produced invalid bounds",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let xMin = Infinity,
|
let xMin = Infinity,
|
||||||
yMin = Infinity,
|
yMin = Infinity,
|
||||||
xMax = -Infinity,
|
xMax = -Infinity,
|
||||||
@ -374,45 +300,18 @@ export async function deskewImage(
|
|||||||
yMax = Math.max(yMax, c.y)
|
yMax = Math.max(yMax, c.y)
|
||||||
}
|
}
|
||||||
|
|
||||||
let outW = Math.ceil(xMax - xMin)
|
const outW = Math.ceil(xMax - xMin)
|
||||||
let outH = Math.ceil(yMax - yMin)
|
const outH = Math.ceil(yMax - yMin)
|
||||||
log("step5", `bounds: x=[${xMin.toFixed(1)}, ${xMax.toFixed(1)}], y=[${yMin.toFixed(1)}, ${yMax.toFixed(1)}]`)
|
|
||||||
log("step5", `raw output: ${String(outW)}×${String(outH)} px`)
|
|
||||||
|
|
||||||
// Guard against absurd output sizes that crash WASM
|
|
||||||
if (outW <= 0 || outH <= 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid output dimensions: ${String(outW)}×${String(outH)}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
let downscale = 1
|
|
||||||
if (outW > MAX_OUTPUT_DIM || outH > MAX_OUTPUT_DIM) {
|
|
||||||
downscale = MAX_OUTPUT_DIM / Math.max(outW, outH)
|
|
||||||
log("step5", `CLAMPING from ${String(outW)}×${String(outH)} by factor ${downscale.toFixed(4)}`)
|
|
||||||
outW = Math.ceil(outW * downscale)
|
|
||||||
outH = Math.ceil(outH * downscale)
|
|
||||||
}
|
|
||||||
log("step5", `final output: ${String(outW)}×${String(outH)} px (${String(Math.round(outW * outH * 4 / 1024 / 1024))} MB RGBA)`)
|
|
||||||
|
|
||||||
const mData: number[] = readMat3x3(mFinal)
|
const mData: number[] = readMat3x3(mFinal)
|
||||||
// Translate so the top-left warped corner is at (0,0),
|
const tShift: number[] = [1, 0, -xMin, 0, 1, -yMin, 0, 0, 1]
|
||||||
// then scale down if we clamped the output size.
|
|
||||||
const tShift: number[] = [
|
|
||||||
downscale, 0, -xMin * downscale,
|
|
||||||
0, downscale, -yMin * downscale,
|
|
||||||
0, 0, 1,
|
|
||||||
]
|
|
||||||
const mOutData: number[] = mul3x3(tShift, mData)
|
const mOutData: number[] = mul3x3(tShift, mData)
|
||||||
const mOut = track(
|
const mOut = cv.matFromArray(3, 3, cv.CV_64FC1, mOutData)
|
||||||
cv.matFromArray(3, 3, cv.CV_64FC1, mOutData),
|
|
||||||
)
|
|
||||||
|
|
||||||
// ============================================================
|
// ================================================================
|
||||||
// STEP 6 — Warp
|
// STEP 6 — Warp
|
||||||
// ============================================================
|
// ================================================================
|
||||||
await progress(6, "Warping image (this may take a moment)")
|
const dstMat = new cv.Mat()
|
||||||
log("step6", "calling warpPerspective...")
|
|
||||||
const dstMat = track(new cv.Mat())
|
|
||||||
cv.warpPerspective(
|
cv.warpPerspective(
|
||||||
src,
|
src,
|
||||||
dstMat,
|
dstMat,
|
||||||
@ -423,17 +322,22 @@ export async function deskewImage(
|
|||||||
new cv.Scalar(0, 0, 0, 0),
|
new cv.Scalar(0, 0, 0, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
log("step6", `warpPerspective done, dstMat: ${String(dstMat.cols)}×${String(dstMat.rows)}, type=${String(dstMat.type())}`)
|
|
||||||
|
|
||||||
log("export", "cv.imshow to canvas")
|
|
||||||
const outCanvas = document.createElement("canvas")
|
const outCanvas = document.createElement("canvas")
|
||||||
outCanvas.width = outW
|
outCanvas.width = outW
|
||||||
outCanvas.height = outH
|
outCanvas.height = outH
|
||||||
cv.imshow(outCanvas, dstMat)
|
cv.imshow(outCanvas, dstMat)
|
||||||
|
|
||||||
log("export", "canvas.toBlob (PNG)")
|
// Cleanup OpenCV mats
|
||||||
|
src.delete()
|
||||||
|
srcPts.delete()
|
||||||
|
dstInit.delete()
|
||||||
|
mInit.delete()
|
||||||
|
dstFinal.delete()
|
||||||
|
mFinal.delete()
|
||||||
|
mOut.delete()
|
||||||
|
dstMat.delete()
|
||||||
|
|
||||||
const blob = await canvasToBlob(outCanvas, "image/png", 0.95)
|
const blob = await canvasToBlob(outCanvas, "image/png", 0.95)
|
||||||
log("export", `blob size: ${String(Math.round(blob.size / 1024))} KB`)
|
|
||||||
|
|
||||||
const diagnostics: DeskewDiagnostics = {
|
const diagnostics: DeskewDiagnostics = {
|
||||||
primaryDatum: primary.label,
|
primaryDatum: primary.label,
|
||||||
@ -444,51 +348,20 @@ export async function deskewImage(
|
|||||||
outputHeightPx: outH,
|
outputHeightPx: outH,
|
||||||
}
|
}
|
||||||
|
|
||||||
log("done", "success")
|
|
||||||
return { correctedImageBlob: blob, diagnostics }
|
return { correctedImageBlob: blob, diagnostics }
|
||||||
} finally {
|
|
||||||
// Always clean up all OpenCV mats, even on error
|
|
||||||
for (const m of mats) {
|
|
||||||
try {
|
|
||||||
m.delete()
|
|
||||||
} catch {
|
|
||||||
// already deleted or invalid — ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── OpenCV init ────────────────────────────────────────────────────────────
|
// ─── OpenCV init ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let cvReady = false
|
|
||||||
|
|
||||||
/** Wait for OpenCV WASM to initialize. Call once at app startup. */
|
/** Wait for OpenCV WASM to initialize. Call once at app startup. */
|
||||||
export function waitForOpenCV(): Promise<void> {
|
export function waitForOpenCV(): Promise<void> {
|
||||||
log("opencv", "waitForOpenCV called, cvReady=" + String(cvReady))
|
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
if (cvReady) {
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
log("opencv", "already ready")
|
if (cv.Mat) {
|
||||||
resolve()
|
resolve()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test if WASM is actually functional by trying to create a mat
|
|
||||||
try {
|
|
||||||
log("opencv", "probing cv.Mat()...")
|
|
||||||
const test = new cv.Mat()
|
|
||||||
test.delete()
|
|
||||||
cvReady = true
|
|
||||||
log("opencv", "probe succeeded, WASM ready")
|
|
||||||
resolve()
|
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
log("opencv", "probe failed, waiting for onRuntimeInitialized")
|
|
||||||
// Not ready yet, wait for callback
|
|
||||||
}
|
|
||||||
|
|
||||||
cv.onRuntimeInitialized = () => {
|
cv.onRuntimeInitialized = () => {
|
||||||
cvReady = true
|
|
||||||
log("opencv", "onRuntimeInitialized fired, WASM ready")
|
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
export async function hashFile(file: File): Promise<string> {
|
|
||||||
const buffer = await file.arrayBuffer()
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer)
|
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
||||||
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
export interface SkwikSettings {
|
|
||||||
scalePxPerMm: number
|
|
||||||
includeScaleBar: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const KEY = "skwik-settings"
|
|
||||||
|
|
||||||
export function saveSettings(settings: SkwikSettings): void {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(KEY, JSON.stringify(settings))
|
|
||||||
} catch {
|
|
||||||
// localStorage full or unavailable — silently ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadSettings(): SkwikSettings | null {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(KEY)
|
|
||||||
if (!raw) return null
|
|
||||||
return JSON.parse(raw) as SkwikSettings
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,13 +2,9 @@ import { defineStore } from "pinia"
|
|||||||
import { ref, computed } from "vue"
|
import { ref, computed } from "vue"
|
||||||
import type { AppStep, Datum, DeskewResult, ExifData } from "@/types"
|
import type { AppStep, Datum, DeskewResult, ExifData } from "@/types"
|
||||||
import { DEFAULT_SCALE_PX_PER_MM } from "@/types"
|
import { DEFAULT_SCALE_PX_PER_MM } from "@/types"
|
||||||
import { loadSettings } from "@/lib/settings-cache"
|
|
||||||
|
|
||||||
export const useAppStore = defineStore("app", () => {
|
export const useAppStore = defineStore("app", () => {
|
||||||
const cached = loadSettings()
|
|
||||||
|
|
||||||
const currentStep = ref<AppStep>(1)
|
const currentStep = ref<AppStep>(1)
|
||||||
const maxStepReached = ref<AppStep>(1)
|
|
||||||
const originalFile = ref<File | null>(null)
|
const originalFile = ref<File | null>(null)
|
||||||
const loadedImage = ref<HTMLImageElement | null>(null)
|
const loadedImage = ref<HTMLImageElement | null>(null)
|
||||||
const exifData = ref<ExifData>({})
|
const exifData = ref<ExifData>({})
|
||||||
@ -17,11 +13,7 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
const isProcessing = ref(false)
|
const isProcessing = ref(false)
|
||||||
const processingStatus = ref("")
|
const processingStatus = ref("")
|
||||||
const selectedDatumId = ref<string | null>(null)
|
const selectedDatumId = ref<string | null>(null)
|
||||||
const scalePxPerMm = ref(
|
const scalePxPerMm = ref(DEFAULT_SCALE_PX_PER_MM)
|
||||||
cached?.scalePxPerMm ?? DEFAULT_SCALE_PX_PER_MM,
|
|
||||||
)
|
|
||||||
const fileHash = ref<string | null>(null)
|
|
||||||
const cacheRestoreMessage = ref("")
|
|
||||||
|
|
||||||
const canProceedToStep2 = computed(() => loadedImage.value !== null)
|
const canProceedToStep2 = computed(() => loadedImage.value !== null)
|
||||||
const canProceedToStep3 = computed(() => canProceedToStep2.value)
|
const canProceedToStep3 = computed(() => canProceedToStep2.value)
|
||||||
@ -44,9 +36,6 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
|
|
||||||
function goToStep(step: AppStep) {
|
function goToStep(step: AppStep) {
|
||||||
currentStep.value = step
|
currentStep.value = step
|
||||||
if (step > maxStepReached.value) {
|
|
||||||
maxStepReached.value = step
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addDatum(datum: Datum) {
|
function addDatum(datum: Datum) {
|
||||||
@ -76,13 +65,8 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
deskewResult.value = result
|
deskewResult.value = result
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFileHash(hash: string) {
|
|
||||||
fileHash.value = hash
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
currentStep.value = 1
|
currentStep.value = 1
|
||||||
maxStepReached.value = 1
|
|
||||||
originalFile.value = null
|
originalFile.value = null
|
||||||
loadedImage.value = null
|
loadedImage.value = null
|
||||||
exifData.value = {}
|
exifData.value = {}
|
||||||
@ -92,13 +76,10 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
processingStatus.value = ""
|
processingStatus.value = ""
|
||||||
selectedDatumId.value = null
|
selectedDatumId.value = null
|
||||||
scalePxPerMm.value = DEFAULT_SCALE_PX_PER_MM
|
scalePxPerMm.value = DEFAULT_SCALE_PX_PER_MM
|
||||||
fileHash.value = null
|
|
||||||
cacheRestoreMessage.value = ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentStep,
|
currentStep,
|
||||||
maxStepReached,
|
|
||||||
originalFile,
|
originalFile,
|
||||||
loadedImage,
|
loadedImage,
|
||||||
exifData,
|
exifData,
|
||||||
@ -108,8 +89,6 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
processingStatus,
|
processingStatus,
|
||||||
selectedDatumId,
|
selectedDatumId,
|
||||||
scalePxPerMm,
|
scalePxPerMm,
|
||||||
fileHash,
|
|
||||||
cacheRestoreMessage,
|
|
||||||
canProceedToStep2,
|
canProceedToStep2,
|
||||||
canProceedToStep3,
|
canProceedToStep3,
|
||||||
canProceedToStep4,
|
canProceedToStep4,
|
||||||
@ -120,7 +99,6 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
updateDatum,
|
updateDatum,
|
||||||
removeDatum,
|
removeDatum,
|
||||||
setResult,
|
setResult,
|
||||||
setFileHash,
|
|
||||||
reset,
|
reset,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -49,8 +49,6 @@ export interface DeskewInput {
|
|||||||
exif: ExifData
|
exif: ExifData
|
||||||
/** Output pixels per mm. */
|
/** Output pixels per mm. */
|
||||||
scalePxPerMm: number
|
scalePxPerMm: number
|
||||||
/** Called with (stepIndex 0-based, totalSteps, stepLabel) */
|
|
||||||
onProgress?: (step: number, total: number, label: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AxisCorrection {
|
export interface AxisCorrection {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user