feat(pipeline): split Deskew + Measure into separate steps

Step 4 (was "Result") now only runs the perspective correction and shows
diagnostics + a small preview at the standard column width. Step 5
("Measure") is a new full-bleed view dedicated to annotation, with the
download buttons promoted to the top of the page.

Re-running the deskew at a different output px/mm now rescales any
measurements already saved for the image (cached by file hash) so they
stay anchored to the same physical features instead of drifting.

Theme tweaks: card surfaces are now visibly distinct from the page
background in light mode, and dark mode is a touch lighter than the
previous near-black. The "Made by" footer is pinned to the bottom of
the viewport via fixed positioning, with corresponding pb on <main>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Samuel Prevost 2026-04-25 16:46:19 +02:00
parent b28ffe267b
commit 1118de74da
9 changed files with 564 additions and 383 deletions

View File

@ -4,7 +4,8 @@ import StepIndicator from "@/components/StepIndicator.vue"
import ImageUpload from "@/components/ImageUpload.vue"
import ExifViewer from "@/components/ExifViewer.vue"
import DatumEditor from "@/components/DatumEditor.vue"
import ResultViewer from "@/components/ResultViewer.vue"
import DeskewViewer from "@/components/DeskewViewer.vue"
import MeasureViewer from "@/components/MeasureViewer.vue"
import ThemeToggle from "@/components/ThemeToggle.vue"
import SkwikLogo from "@/components/SkwikLogo.vue"
@ -50,15 +51,19 @@ const store = useAppStore()
</div>
</header>
<main class="mx-auto max-w-7xl px-4 py-6">
<!-- Bottom padding clears the fixed footer so content never sits
underneath it. Footer height py-3 + 1lh 2.5rem; add a
small buffer. -->
<main class="mx-auto max-w-7xl px-4 pb-16 pt-6">
<ImageUpload v-if="store.currentStep === 1" />
<ExifViewer v-else-if="store.currentStep === 2" />
<DatumEditor v-else-if="store.currentStep === 3" />
<ResultViewer v-else-if="store.currentStep === 4" />
<DeskewViewer v-else-if="store.currentStep === 4" />
<MeasureViewer v-else-if="store.currentStep === 5" />
</main>
<footer
class="border-t border-border/50 py-4 text-center text-xs text-muted-foreground"
class="fixed inset-x-0 bottom-0 z-40 border-t border-border/50 bg-background/95 py-3 text-center text-xs text-muted-foreground backdrop-blur supports-[backdrop-filter]:bg-background/60"
>
Made by
<a

View File

@ -53,7 +53,7 @@
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card: oklch(0.965 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
@ -86,11 +86,11 @@
}
.dark {
--background: oklch(0.13 0 0);
--background: oklch(0.185 0 0);
--foreground: oklch(0.95 0 0);
--card: oklch(0.175 0 0);
--card: oklch(0.235 0 0);
--card-foreground: oklch(0.95 0 0);
--popover: oklch(0.205 0 0);
--popover: oklch(0.235 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);

View File

@ -2085,7 +2085,7 @@ function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
}
// Legacy export: bare image + scale bar, no measurements. Preserved as-is
// for any caller still wired to it (currently none ResultViewer's
// for any caller still wired to it (currently none MeasureViewer's
// addScaleBar handles the no-measurements case directly).
function exportWithScaleBar(): Promise<Blob> {
const image = img.value
@ -2441,9 +2441,10 @@ watch(
</span>
</div>
<!-- Canvas + side list. The parent ResultViewer clamps width to
max-w-4xl; widening the canvas beyond that requires a parent
change (see ResultViewer.vue root container). -->
<!-- Canvas + side list. Width is dictated by the parent when
rendered inside MeasureViewer the surrounding container spans
the full viewport width; inside DeskewViewer it is capped at
the standard step width. -->
<div class="grid gap-3 md:grid-cols-[1fr_220px]">
<div
ref="containerRef"

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue"
import { ref, computed, onMounted, onUnmounted, watch } from "vue"
import { useAppStore } from "@/stores/app"
import { deskewImage, waitForOpenCV } from "@/lib/deskew"
import type { Datum } from "@/types"
@ -8,12 +8,6 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Progress } from "@/components/ui/progress"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import {
Card,
CardContent,
@ -30,30 +24,20 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import CorrectedImageViewer from "@/components/CorrectedImageViewer.vue"
// `defineExpose` in CorrectedImageViewer makes these methods available on
// the template ref, but Vue's ComponentPublicInstance type doesn't surface
// them automatically we type the ref explicitly so the call is checked.
type CorrectedImageViewerRef = InstanceType<typeof CorrectedImageViewer> & {
exportWithMeasurements: (opts: {
scope: "full" | "view"
includeScaleBar: boolean
}) => Promise<Blob>
}
import { loadSettings } from "@/lib/settings-cache"
import {
loadSettings,
saveSettings,
} from "@/lib/settings-cache"
loadMeasurements,
saveMeasurements,
scaleMeasurements,
} from "@/lib/measurement-cache"
const store = useAppStore()
const resultUrl = ref<string | null>(null)
const viewerRef = ref<CorrectedImageViewerRef | null>(null)
const previewUrl = ref<string | null>(null)
const error = ref("")
const hasRun = ref(false)
const cvReady = ref(false)
const cvLoading = ref(false)
const showAlgoDetails = ref(false)
const includeScaleBar = ref(false)
const scaleInput = ref(String(store.scalePxPerMm))
const scaleValid = computed(() => {
const n = Number(scaleInput.value)
@ -149,29 +133,28 @@ function computeAutoScale(): number {
onMounted(() => {
const cached = loadSettings()
if (cached) {
includeScaleBar.value = cached.includeScaleBar
if (cached && cached.scalePxPerMm !== DEFAULT_SCALE_PX_PER_MM) {
// Only use cached scale if it was explicitly set before
if (cached.scalePxPerMm !== DEFAULT_SCALE_PX_PER_MM) {
scaleInput.value = String(cached.scalePxPerMm)
return
}
scaleInput.value = String(cached.scalePxPerMm)
} else {
// Auto-compute a sensible default scale
const auto = computeAutoScale()
store.scalePxPerMm = auto
scaleInput.value = String(auto)
}
// Re-create the preview URL if a deskew result is already cached on the
// store (e.g. user navigated back from Measure).
if (store.deskewResult) {
previewUrl.value = URL.createObjectURL(
store.deskewResult.correctedImageBlob,
)
hasRun.value = true
}
// Auto-compute a sensible default scale
const auto = computeAutoScale()
store.scalePxPerMm = auto
scaleInput.value = String(auto)
})
watch(
[() => store.scalePxPerMm, includeScaleBar],
() => {
saveSettings({
scalePxPerMm: store.scalePxPerMm,
includeScaleBar: includeScaleBar.value,
})
},
)
onUnmounted(() => {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
})
// Progress tracking
const progressStep = ref(0)
@ -234,11 +217,14 @@ async function runDeskew() {
requestAnimationFrame(r)
})
const newScale = store.scalePxPerMm
const oldScale = store.lastDeskewScale
const result = await deskewImage({
image: store.loadedImage,
datums: store.datums,
exif: store.exifData,
scalePxPerMm: store.scalePxPerMm,
scalePxPerMm: newScale,
onProgress: (step, total, label) => {
progressStep.value = step
progressTotal.value = total
@ -247,10 +233,28 @@ async function runDeskew() {
},
})
store.setResult(result)
// If the user changed the output scale between runs, the new
// corrected image is a different size rescale any measurements
// already cached for this image so they stay anchored to the same
// physical features. CorrectedImageViewer reads from cache on
// mount, so writing here is enough; no in-memory state to sync.
if (
oldScale !== null &&
oldScale > 0 &&
oldScale !== newScale &&
store.fileHash
) {
const cached = loadMeasurements(store.fileHash)
if (cached && cached.length > 0) {
const scaled = scaleMeasurements(cached, newScale / oldScale)
saveMeasurements(store.fileHash, scaled)
}
}
if (resultUrl.value) URL.revokeObjectURL(resultUrl.value)
resultUrl.value = URL.createObjectURL(result.correctedImageBlob)
store.setResult(result, newScale)
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = URL.createObjectURL(result.correctedImageBlob)
} catch (e) {
error.value = e instanceof Error ? e.message : "Deskew failed"
} finally {
@ -258,168 +262,27 @@ async function runDeskew() {
store.processingStatus = ""
}
}
function addScaleBar(image: HTMLImageElement): Promise<Blob> {
return new Promise((resolve, reject) => {
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
console.log("[download] includeScaleBar =", includeScaleBar.value)
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")
a.href = url
const baseName =
store.originalFile?.name.replace(/\.[^.]+$/, "") ?? "output"
a.download = `${baseName}-skwik.png`
a.click()
URL.revokeObjectURL(url)
}
// Download the corrected image with measurement annotations baked in.
// scope="full": natural-resolution image + overlay `-measured.png`.
// scope="view": current viewport (zoom/pan) + overlay `-measured-view.png`.
// Both honour the existing `includeScaleBar` toggle. View export's bar is
// sized for the on-screen pixel scale (image-px/mm × CSS view scale) so it
// represents the same physical mm length the user is actually looking at.
async function downloadMeasured(scope: "full" | "view") {
const viewer = viewerRef.value
if (!viewer || !store.deskewResult) return
try {
const blob = await viewer.exportWithMeasurements({
scope,
includeScaleBar: includeScaleBar.value,
})
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
const baseName =
store.originalFile?.name.replace(/\.[^.]+$/, "") ?? "output"
a.download =
scope === "full"
? `${baseName}-measured.png`
: `${baseName}-measured-view.png`
a.click()
URL.revokeObjectURL(url)
} catch (e) {
error.value =
e instanceof Error ? e.message : "Measured export failed"
}
}
</script>
<template>
<div class="mx-auto max-w-4xl space-y-6">
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-2">
<div>
<h2 class="text-xl font-semibold">Process &amp; Download</h2>
<h2 class="text-xl font-semibold">Deskew</h2>
<p class="text-sm text-muted-foreground">
Set the output scale, run perspective correction, and
download.
Set the output scale and run perspective correction.
</p>
</div>
<Button variant="outline" @click="store.goToStep(3)"
>Back</Button
>
<div class="flex shrink-0 gap-2">
<Button variant="outline" @click="store.goToStep(3)"
>Back</Button
>
<Button
:disabled="!store.canProceedToStep5"
@click="store.goToStep(5)"
>Next: Measure</Button
>
</div>
</div>
<!-- Scale setting -->
@ -510,7 +373,7 @@ async function downloadMeasured(scope: "full" | "view") {
{{ datum.label }}
<span class="ml-1 font-mono text-xs">{{
datum.type === "rectangle"
? `${datum.widthMm}\u00D7${datum.heightMm}mm`
? `${datum.widthMm}×${datum.heightMm}mm`
: datum.type === "line"
? `${datum.lengthMm}mm`
: `${datum.diameterMm}mm`
@ -594,7 +457,7 @@ async function downloadMeasured(scope: "full" | "view") {
</p>
<!-- Result -->
<template v-if="store.deskewResult && resultUrl">
<template v-if="store.deskewResult && previewUrl">
<!-- Diagnostics -->
<Card>
<CardHeader>
@ -847,173 +710,49 @@ async function downloadMeasured(scope: "full" | "view") {
</CardContent>
</Card>
<!-- Corrected image with tools full-bleed to use the whole page width
even though the surrounding column is capped at max-w-4xl. -->
<div
class="relative left-1/2 w-screen -translate-x-1/2 px-4"
>
<Card>
<CardHeader>
<CardTitle class="text-base"
>Corrected Image</CardTitle
>
</CardHeader>
<CardContent>
<CorrectedImageViewer
ref="viewerRef"
:image-url="resultUrl"
:scale-px-per-mm="store.scalePxPerMm"
/>
</CardContent>
</Card>
</div>
<!-- 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 select-none"
>
<input
v-model="includeScaleBar"
type="checkbox"
class="h-4 w-4 accent-primary"
/>
<span
class="text-sm text-muted-foreground"
>Include scale bar in export</span
>
</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 flex-wrap items-center justify-center gap-3">
<Button size="lg" @click="download">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mr-2"
>
<path
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
/>
<polyline points="7 10 12 15 17 10" />
<line
x1="12"
x2="12"
y1="15"
y2="3"
/>
</svg>
Download PNG
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
size="lg"
variant="secondary"
@click="downloadMeasured('full')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mr-2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
<path d="M3 3h6" />
</svg>
Download full + measurements
</Button>
</TooltipTrigger>
<TooltipContent side="top" class="max-w-xs">
Source image at full resolution with every
measurement (lines, rectangles, ellipses,
angles) and labels rendered on top.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
size="lg"
variant="secondary"
@click="downloadMeasured('view')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mr-2"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 9h6v6H9z" />
</svg>
Download view + measurements
</Button>
</TooltipTrigger>
<TooltipContent side="top" class="max-w-xs">
Captures exactly what's visible in the
viewer (current zoom and pan) with every
measurement rendered on top.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
size="lg"
variant="outline"
@click="store.reset()"
<!-- Deskewed preview -->
<Card>
<CardHeader>
<CardTitle class="text-base"
>Deskewed Preview</CardTitle
>
Process New Image
</Button>
</div>
<p
class="font-mono text-sm text-muted-foreground"
>
{{
store.deskewResult.diagnostics.outputWidthPx
}}&times;{{
store.deskewResult.diagnostics.outputHeightPx
}} px &mdash;
{{
(
store.deskewResult
.correctedImageBlob.size /
1024 /
1024
).toFixed(1)
}} MB
</p>
<CardDescription>
Continue to <strong>Measure</strong> to add
annotations and download.
</CardDescription>
</CardHeader>
<CardContent>
<div
class="flex items-center justify-center overflow-hidden rounded-md bg-muted"
>
<img
:src="previewUrl"
alt="Deskewed image preview"
class="max-h-[480px] w-full object-contain"
/>
</div>
</CardContent>
</Card>
<div class="flex justify-center pb-8">
<Button size="lg" @click="store.goToStep(5)">
Continue to Measure
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="ml-2"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</Button>
</div>
</template>
</div>

View File

@ -0,0 +1,372 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from "vue"
import { useAppStore } from "@/stores/app"
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import CorrectedImageViewer from "@/components/CorrectedImageViewer.vue"
// `defineExpose` in CorrectedImageViewer makes these methods available on
// the template ref, but Vue's ComponentPublicInstance type doesn't surface
// them automatically we type the ref explicitly so the call is checked.
type CorrectedImageViewerRef = InstanceType<typeof CorrectedImageViewer> & {
exportWithMeasurements: (opts: {
scope: "full" | "view"
includeScaleBar: boolean
}) => Promise<Blob>
}
import { loadSettings, saveSettings } from "@/lib/settings-cache"
const store = useAppStore()
const resultUrl = ref<string | null>(null)
const viewerRef = ref<CorrectedImageViewerRef | null>(null)
const error = ref("")
const includeScaleBar = ref(false)
onMounted(() => {
const cached = loadSettings()
if (cached) {
includeScaleBar.value = cached.includeScaleBar
}
if (store.deskewResult) {
resultUrl.value = URL.createObjectURL(
store.deskewResult.correctedImageBlob,
)
} else {
// No result yet bounce back to Deskew. Should never happen via
// normal navigation since the Next button is gated, but if a user
// edits the URL or hot-reloads we don't want to render an empty
// viewer.
store.goToStep(4)
}
})
onUnmounted(() => {
if (resultUrl.value) URL.revokeObjectURL(resultUrl.value)
})
watch(includeScaleBar, () => {
saveSettings({
scalePxPerMm: store.scalePxPerMm,
includeScaleBar: includeScaleBar.value,
})
})
function addScaleBar(image: HTMLImageElement): Promise<Blob> {
return new Promise((resolve, reject) => {
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) {
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")
a.href = url
const baseName =
store.originalFile?.name.replace(/\.[^.]+$/, "") ?? "output"
a.download = `${baseName}-skwik.png`
a.click()
URL.revokeObjectURL(url)
}
// Download the corrected image with measurement annotations baked in.
// scope="full": natural-resolution image + overlay `-measured.png`.
// scope="view": current viewport (zoom/pan) + overlay `-measured-view.png`.
// Both honour the existing `includeScaleBar` toggle. View export's bar is
// sized for the on-screen pixel scale (image-px/mm × CSS view scale) so it
// represents the same physical mm length the user is actually looking at.
async function downloadMeasured(scope: "full" | "view") {
const viewer = viewerRef.value
if (!viewer || !store.deskewResult) return
try {
const blob = await viewer.exportWithMeasurements({
scope,
includeScaleBar: includeScaleBar.value,
})
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
const baseName =
store.originalFile?.name.replace(/\.[^.]+$/, "") ?? "output"
a.download =
scope === "full"
? `${baseName}-measured.png`
: `${baseName}-measured-view.png`
a.click()
URL.revokeObjectURL(url)
} catch (e) {
error.value =
e instanceof Error ? e.message : "Measured export failed"
}
}
</script>
<template>
<!-- Break out of <main>'s max-w-7xl so the Measure step spans the full
viewport width annotation work benefits from the extra room. -->
<div class="relative left-1/2 w-screen -translate-x-1/2 space-y-4 px-4">
<!-- Top header with downloads + back -->
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold">Measure</h2>
<p class="text-sm text-muted-foreground">
Annotate the corrected image and download.
</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<label
class="flex cursor-pointer items-center gap-2 select-none"
>
<input
v-model="includeScaleBar"
type="checkbox"
class="h-4 w-4 accent-primary"
/>
<span
class="text-sm text-muted-foreground"
>Scale bar</span
>
</label>
</TooltipTrigger>
<TooltipContent side="bottom" 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="mx-1 h-6 w-px bg-border" />
<Button variant="outline" @click="store.goToStep(4)"
>Back</Button
>
<Button
variant="outline"
@click="store.reset()"
>New Image</Button
>
<Button @click="download">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mr-2"
>
<path
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
/>
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
</svg>
PNG
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
@click="downloadMeasured('full')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mr-2"
>
<path
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
/>
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
<path d="M3 3h6" />
</svg>
Full + measurements
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-xs">
Source image at full resolution with every
measurement (lines, rectangles, ellipses,
angles) and labels rendered on top.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
@click="downloadMeasured('view')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mr-2"
>
<rect
x="3"
y="3"
width="18"
height="18"
rx="2"
/>
<path d="M9 9h6v6H9z" />
</svg>
View + measurements
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-xs">
Captures exactly what's visible in the viewer
(current zoom and pan) with every measurement
rendered on top.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<p v-if="error" class="text-sm text-destructive">
{{ error }}
</p>
<!-- Full-width corrected image with measurement tools -->
<Card v-if="resultUrl">
<CardHeader>
<CardTitle class="text-base">Corrected Image</CardTitle>
</CardHeader>
<CardContent>
<CorrectedImageViewer
ref="viewerRef"
:image-url="resultUrl"
:scale-px-per-mm="store.scalePxPerMm"
/>
</CardContent>
</Card>
</div>
</template>

View File

@ -9,7 +9,8 @@ const steps: { num: AppStep; label: string }[] = [
{ num: 1, label: "Upload" },
{ num: 2, label: "EXIF" },
{ num: 3, label: "Datums" },
{ num: 4, label: "Result" },
{ num: 4, label: "Deskew" },
{ num: 5, label: "Measure" },
]
function isReachable(num: AppStep): boolean {

View File

@ -1,7 +1,59 @@
import type { Point } from "@/types"
import type { Measurement } from "@/types/measurements"
const KEY_PREFIX = "skwik-measurements-"
/** Scale every image-space point in `m` by `ratio`. Used when the user
* re-runs the deskew at a different output px/mm the corrected image
* changes size, so measurements (stored in image-pixel coords) must move
* with it to stay anchored to the same physical features. */
function scalePoint(p: Point, ratio: number): Point {
return { x: p.x * ratio, y: p.y * ratio }
}
export function scaleMeasurements(
measurements: Measurement[],
ratio: number,
): Measurement[] {
if (ratio === 1) return measurements
return measurements.map((m) => {
switch (m.type) {
case "line":
return { ...m, a: scalePoint(m.a, ratio), b: scalePoint(m.b, ratio) }
case "rectangle":
return {
...m,
corners: [
scalePoint(m.corners[0], ratio),
scalePoint(m.corners[1], ratio),
scalePoint(m.corners[2], ratio),
scalePoint(m.corners[3], ratio),
],
}
case "ellipse":
return {
...m,
center: scalePoint(m.center, ratio),
axisEndA: scalePoint(m.axisEndA, ratio),
axisEndB: scalePoint(m.axisEndB, ratio),
}
case "circle":
return {
...m,
center: scalePoint(m.center, ratio),
edge: scalePoint(m.edge, ratio),
}
case "angle":
return {
...m,
vertex: scalePoint(m.vertex, ratio),
armA: scalePoint(m.armA, ratio),
armB: scalePoint(m.armB, ratio),
}
}
})
}
export function saveMeasurements(
hash: string,
measurements: Measurement[],

View File

@ -23,6 +23,10 @@ export const useAppStore = defineStore("app", () => {
)
const fileHash = ref<string | null>(null)
const cacheRestoreMessage = ref("")
/** Output px/mm of the current `deskewResult`. Set whenever a deskew
* produces a new result; consumers compare against the live
* `scalePxPerMm` to detect when measurements need to be rescaled. */
const lastDeskewScale = ref<number | null>(null)
const canProceedToStep2 = computed(() => loadedImage.value !== null)
const canProceedToStep3 = computed(() => canProceedToStep2.value)
@ -34,6 +38,9 @@ export const useAppStore = defineStore("app", () => {
return d.diameterMm > 0
})
})
const canProceedToStep5 = computed(
() => canProceedToStep4.value && deskewResult.value !== null,
)
function setImage(file: File, image: HTMLImageElement) {
originalFile.value = file
@ -141,8 +148,9 @@ export const useAppStore = defineStore("app", () => {
}
}
function setResult(result: DeskewResult) {
function setResult(result: DeskewResult, scalePxPerMmUsed: number) {
deskewResult.value = result
lastDeskewScale.value = scalePxPerMmUsed
}
function setFileHash(hash: string) {
@ -163,6 +171,7 @@ export const useAppStore = defineStore("app", () => {
scalePxPerMm.value = DEFAULT_SCALE_PX_PER_MM
fileHash.value = null
cacheRestoreMessage.value = ""
lastDeskewScale.value = null
}
return {
@ -179,9 +188,11 @@ export const useAppStore = defineStore("app", () => {
scalePxPerMm,
fileHash,
cacheRestoreMessage,
lastDeskewScale,
canProceedToStep2,
canProceedToStep3,
canProceedToStep4,
canProceedToStep5,
setImage,
setExif,
goToStep,

View File

@ -114,7 +114,7 @@ export interface DeskewResult {
diagnostics: DeskewDiagnostics
}
export type AppStep = 1 | 2 | 3 | 4
export type AppStep = 1 | 2 | 3 | 4 | 5
/** Pixels per mm in the output image. Default 10 (= 100 px/cm). */
export const DEFAULT_SCALE_PX_PER_MM = 10