Støtte for flere bibs per bilde, EXIF-metadata og zoom i gjennomgang
Build & Deploy / build-and-deploy (push) Successful in 46s

- OCR: ny read_all_bibs() returnerer alle unike startnumre (≥2 sifre) per bilde
- Ingest: oppretter én passering per bib (ikke bare beste), ingen bib → needs_review
- image_tagger.py: skriv/les bib-metadata som JSON i EXIF UserComment (piexif)
- Ingest + resolve: tagger bildefilen med bibs automatisk og ved manuell bekreftelse
- API: POST /api/passages/{id}/reanalyze — re-kjør OCR på eksisterende bilde
- API: POST /api/passages/{id}/resolve oppdaterer nå EXIF med bekreftet bib
- races: ny kolonne bib_filter_enabled (med automatisk migrering) + per-løp toggle
- ReviewPage: Re-analyser-knapp og klikk-for-zoom med scroll/drag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 09:00:24 +01:00
parent 018f84efd8
commit 45f7a77171
8 changed files with 526 additions and 128 deletions
+1
View File
@@ -51,6 +51,7 @@ export const api = {
body: JSON.stringify(body),
}),
deletePassage: (id) => request(`/passages/${id}`, { method: 'DELETE' }),
reanalyzePassage: (id) => request(`/passages/${id}/reanalyze`, { method: 'POST' }),
// Resultater
getResults: (raceId) => request(`/results${qs({ race_id: raceId })}`),
+16
View File
@@ -63,6 +63,18 @@ function RaceCard({ race, isActive, onActivated, onDeleted, onUpdated }) {
const [expanded, setExpanded] = useState(false)
const [newStation, setNewStation] = useState({ display_name: '', name: '' })
const [adding, setAdding] = useState(false)
const [bibFilter, setBibFilter] = useState(!!race.bib_filter_enabled)
const handleBibFilterToggle = async (enabled) => {
setBibFilter(enabled)
await api.updateRace(race.race_id, {
name: race.name,
date: race.date || null,
description: race.description || null,
bib_filter_enabled: enabled,
})
onUpdated()
}
const loadStations = async () => {
const s = await api.getStations(race.race_id)
@@ -99,6 +111,10 @@ function RaceCard({ race, isActive, onActivated, onDeleted, onUpdated }) {
{race.date && <span style={{ marginLeft: 8, color: '#888', fontSize: '0.85rem' }}>{race.date}</span>}
{isActive && <span className="badge badge-success" style={{ marginLeft: 8 }}>Aktivt</span>}
{race.description && <p style={{ fontSize: '0.85rem', color: '#666', marginTop: 4 }}>{race.description}</p>}
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 4, fontSize: '0.82rem', color: '#555', cursor: 'pointer' }}>
<input type="checkbox" checked={bibFilter} onChange={e => handleBibFilterToggle(e.target.checked)} />
Filtrer OCR mot startliste
</label>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{!isActive && (
+128 -8
View File
@@ -1,6 +1,68 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { api } from '../api.js'
function ZoomOverlay({ src, onClose }) {
const [scale, setScale] = useState(1)
const [offset, setOffset] = useState({ x: 0, y: 0 })
const [dragging, setDragging] = useState(false)
const dragStart = useRef(null)
const handleWheel = (e) => {
e.preventDefault()
setScale(s => Math.min(8, Math.max(1, s - e.deltaY * 0.005)))
}
const handleMouseDown = (e) => {
setDragging(true)
dragStart.current = { x: e.clientX - offset.x, y: e.clientY - offset.y }
}
const handleMouseMove = (e) => {
if (!dragging || !dragStart.current) return
setOffset({ x: e.clientX - dragStart.current.x, y: e.clientY - dragStart.current.y })
}
const handleMouseUp = () => setDragging(false)
return (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)',
zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<div
onClick={e => e.stopPropagation()}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ overflow: 'hidden', cursor: dragging ? 'grabbing' : 'grab', maxWidth: '95vw', maxHeight: '95vh' }}
>
<img
src={src}
alt="Zoomet bilde"
style={{
transform: `scale(${scale}) translate(${offset.x / scale}px, ${offset.y / scale}px)`,
transformOrigin: 'center',
display: 'block',
maxWidth: '90vw',
maxHeight: '90vh',
userSelect: 'none',
transition: dragging ? 'none' : 'transform 0.1s',
}}
draggable={false}
/>
</div>
<div style={{ position: 'absolute', top: 16, right: 20, color: '#fff', fontSize: '0.8rem', opacity: 0.7 }}>
Rull for zoom · Dra for å flytte · Klikk utenfor for å lukke
</div>
</div>
)
}
function fmtTs(ts) {
if (!ts) return '—'
return new Date(ts).toLocaleString('no-NO', { timeZone: 'UTC', hour12: false })
@@ -14,31 +76,48 @@ function imgSrc(path) {
function ImageSlider({ passageId, currentImage }) {
const [images, setImages] = useState([])
const [index, setIndex] = useState(0)
const [zoomSrc, setZoomSrc] = useState(null)
useEffect(() => {
api.getPassageImages(passageId).then(imgs => {
setImages(imgs)
// Start på siste bilde (passeringstidspunktet)
setIndex(imgs.length > 0 ? imgs.length - 1 : 0)
})
}, [passageId])
if (images.length === 0) {
const src = imgSrc(currentImage)
return src
? <img src={src} alt="Passeringsbilde" className="image-preview" />
: <div className="empty-state" style={{ border: '1px dashed #ddd', borderRadius: 6 }}>Bilde ikke tilgjengelig</div>
return src ? (
<>
<img
src={src}
alt="Passeringsbilde"
className="image-preview"
onClick={() => setZoomSrc(src)}
style={{ cursor: 'zoom-in' }}
title="Klikk for å zoome"
/>
{zoomSrc && <ZoomOverlay src={zoomSrc} onClose={() => setZoomSrc(null)} />}
</>
) : (
<div className="empty-state" style={{ border: '1px dashed #ddd', borderRadius: 6 }}>Bilde ikke tilgjengelig</div>
)
}
const current = images[index]
const src = imgSrc(current.image_path)
return (
<div>
<img
src={imgSrc(current.image_path)}
src={src}
alt={`Bilde ${index + 1} av ${images.length}`}
className="image-preview"
onClick={() => setZoomSrc(src)}
style={{ cursor: 'zoom-in' }}
title="Klikk for å zoome"
/>
{zoomSrc && <ZoomOverlay src={zoomSrc} onClose={() => setZoomSrc(null)} />}
{images.length > 1 && (
<div style={{ marginTop: '0.5rem' }}>
<input
@@ -63,10 +142,12 @@ function ImageSlider({ passageId, currentImage }) {
)
}
function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
function ReviewCard({ passage, athletes, onResolved, onDeleted, onReanalyzed }) {
const [bib, setBib] = useState(passage.bib_number || '')
const [note, setNote] = useState('')
const [saving, setSaving] = useState(false)
const [reanalyzing, setReanalyzing] = useState(false)
const [reanalyzeResult, setReanalyzeResult] = useState(null)
const matchedAthlete = athletes.find(a => a.bib_number === bib)
@@ -90,6 +171,22 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
onDeleted()
}
const handleReanalyze = async () => {
setReanalyzing(true)
setReanalyzeResult(null)
try {
const result = await api.reanalyzePassage(passage.passage_id)
setReanalyzeResult(result)
if (result.new_passages.length > 0) {
onReanalyzed()
}
} catch (e) {
setReanalyzeResult({ error: e.message })
} finally {
setReanalyzing(false)
}
}
return (
<div className="card">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
@@ -137,12 +234,34 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button className="btn-success" onClick={handleResolve} disabled={saving}>
{saving ? 'Lagrer...' : 'Bekreft'}
</button>
<button onClick={handleReanalyze} disabled={reanalyzing} style={{ background: '#8e44ad', color: '#fff', border: 'none', borderRadius: 4, padding: '0.4rem 0.9rem', cursor: 'pointer' }}>
{reanalyzing ? 'Analyserer...' : 'Re-analyser bilde'}
</button>
<button className="btn-danger" onClick={handleDelete}>Slett</button>
</div>
{reanalyzeResult && (
<div style={{ marginTop: '0.75rem', fontSize: '0.85rem', padding: '0.5rem 0.75rem', background: '#f5f5f5', borderRadius: 4 }}>
{reanalyzeResult.error ? (
<span style={{ color: '#e74c3c' }}>Feil: {reanalyzeResult.error}</span>
) : (
<>
<div>Funne bibs: {reanalyzeResult.found_bibs.length > 0 ? reanalyzeResult.found_bibs.join(', ') : 'ingen'}</div>
{reanalyzeResult.new_passages.length > 0 ? (
<div style={{ color: '#27ae60', marginTop: 2 }}>
{reanalyzeResult.new_passages.length} ny(e) passering(er) opprettet: {reanalyzeResult.new_passages.map(p => p.bib_number).join(', ')}
</div>
) : (
<div style={{ color: '#888', marginTop: 2 }}>Ingen nye bibs funnet.</div>
)}
</>
)}
</div>
)}
</div>
</div>
</div>
@@ -188,6 +307,7 @@ export default function ReviewPage({ activeRace }) {
athletes={athletes}
onResolved={() => removeFromQueue(p.passage_id)}
onDeleted={() => removeFromQueue(p.passage_id)}
onReanalyzed={load}
/>
))}
</>