feat: add measured detection review loop
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-15 03:00:08 +02:00
parent 94ecd377b7
commit d22abe8e7b
27 changed files with 1578 additions and 29 deletions
+7
View File
@@ -8,6 +8,12 @@ The user-facing shell is task based: `Kaart`, `Bronnen`, `Kwaliteit`, `Beeldanal
Detection defaults to the configured local YOLO asset and automatically selects an available raster and active model asset where possible. The model registry and preflight remain honest when PyTorch, Ultralytics, a local model file or a tile manifest is unavailable. The active building profile is operational but remains review-required: its recorded coverage-aware benchmark is approximately precision 0.590, recall 0.577 and F1 0.582 over seven positive AOIs, with zero detections in the empty-background control. Another training pass is intentionally blocked until the generated false-positive and false-negative review decisions are completed.
Map-driven building analysis uses the documented footprint-IoU `0.25` and
distinguishes model candidates from verified buildings. It shows persisted
matches, precision, recall, F1, false positives and false negatives. The
Quality workspace includes a paginated review queue for persisted detection-QA
evidence. Reviews do not rewrite detections, GRB geometry or QA metrics.
The map-first explorer has two deliberate modes. `Latest state` selects the
latest explicitly dated source snapshot without claiming an old edition is
current, while `Evolution` lets the operator compare an earlier and later
@@ -373,6 +379,7 @@ Before creating tiles, the guided action inspects raster dimensions and estimate
- The QA/QC workspace includes a selected-check evidence drilldown with candidate/reference provenance, false-positive/negative metric evidence, map handoff context and parameters/findings JSON.
- QA/QC findings now persist feature-level evidence in `findings_json`: matched candidate/reference feature ids with IoU, false-positive candidate feature ids and false-negative reference feature ids. The QA/QC drilldown renders these as compact evidence lists before the raw JSON.
- Persisted QA/QC checks can be rendered as a Map workspace evidence overlay. The QA/QC panel calls `GET /api/v1/projects/{project_id}/quality-checks/{quality_check_id}/evidence/geojson`, then MapLibre draws matched candidate/reference geometries, false positives and false negatives with distinct styling and a compact legend.
- Detection QA checks expose a filtered, paginated operator review queue through `GET/POST /api/v1/projects/{project_id}/quality-checks/{quality_check_id}/reviews`. The UI keeps confirmed model errors separate from reference gaps and box-to-footprint alignment mismatches.
## Raster dependency visibility
+4 -2
View File
@@ -477,8 +477,8 @@ function App(): JSX.Element {
datasets,
loadProjectData,
prepareAndRunDetection: (datasetId) => prepareAndRunDetection(datasetId, 'yolo-configured'),
compareDetectionRunWithReference: (analysisRunId, referenceDatasetId) =>
compareDetectionRunWithReference(analysisRunId, referenceDatasetId, false),
compareDetectionRunWithReference: (analysisRunId, referenceDatasetId, iouThreshold) =>
compareDetectionRunWithReference(analysisRunId, referenceDatasetId, false, iouThreshold),
onAnalysisReady: () => {
setMapContentMode('analysis')
setMapLayerVisible(true)
@@ -1011,6 +1011,8 @@ function App(): JSX.Element {
orthophotoAnalysisStatus={mapOrthophotoAnalysis.status}
orthophotoAnalysisError={mapOrthophotoAnalysis.error}
orthophotoAnalysisRunning={mapOrthophotoAnalysis.running}
orthophotoAnalysisQuality={mapOrthophotoAnalysis.lastQuality}
orthophotoAnalysisDetectionCount={mapOrthophotoAnalysis.lastDetectionCount}
availableMapDatasets={availableMapDatasets}
selectedMapDatasetId={selectedDataset && isVectorDatasetType(selectedDataset.dataset_type) ? selectedDataset.id : ''}
selectedFeature={selectedMapFeature}
+21 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import GeoMap from '../GeoMap'
import type { AreaRead, DatasetCreateResponse, MapViewportState, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
import type { AreaRead, DatasetCreateResponse, DetectionQaResult, MapViewportState, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
import { featureCollectionBounds } from '../../lib/geojsonBounds'
import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights'
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
@@ -327,6 +327,12 @@ function formatBboxLabel(bbox: VectorSelectionBBox | null): string {
return `${formatCoordinate(bbox.min_x)}, ${formatCoordinate(bbox.min_y)} -> ${formatCoordinate(bbox.max_x)}, ${formatCoordinate(bbox.max_y)}`
}
function formatPercentage(value: number | null | undefined): string {
return typeof value === 'number' && Number.isFinite(value)
? `${(value * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%`
: 'n.v.t.'
}
function bboxToInputState(bbox: VectorSelectionBBox | null) {
return {
min_x: bbox ? String(bbox.min_x) : '',
@@ -445,6 +451,8 @@ interface MapWorkspaceProps {
orthophotoAnalysisStatus: string
orthophotoAnalysisError: string | null
orthophotoAnalysisRunning: boolean
orthophotoAnalysisQuality: DetectionQaResult | null
orthophotoAnalysisDetectionCount: number | null
availableMapDatasets: DatasetCreateResponse[]
selectedMapDatasetId: string
onSelectMapArea: (areaId: string) => void
@@ -518,6 +526,8 @@ export function MapWorkspace({
orthophotoAnalysisStatus,
orthophotoAnalysisError,
orthophotoAnalysisRunning,
orthophotoAnalysisQuality,
orthophotoAnalysisDetectionCount,
availableMapDatasets,
selectedMapDatasetId,
onSelectMapArea,
@@ -1194,6 +1204,16 @@ export function MapWorkspace({
</button>
{orthophotoAnalysisStatus ? <p role="status">{orthophotoAnalysisStatus}</p> : null}
{orthophotoAnalysisError ? <p className="error" role="alert">{orthophotoAnalysisError}</p> : null}
{orthophotoAnalysisQuality ? (
<div className="geo-image-quality-metrics" aria-label="Gemeten kwaliteit van de beeldanalyse">
<div><span>Kandidaten</span><strong>{orthophotoAnalysisDetectionCount?.toLocaleString('nl-BE') ?? 'n.v.t.'}</strong></div>
<div><span>Precision</span><strong>{formatPercentage(orthophotoAnalysisQuality.precision)}</strong></div>
<div><span>Recall</span><strong>{formatPercentage(orthophotoAnalysisQuality.recall)}</strong></div>
<div><span>F1</span><strong>{formatPercentage(orthophotoAnalysisQuality.f1_score)}</strong></div>
<div><span>Fout</span><strong>{orthophotoAnalysisQuality.false_positives.toLocaleString('nl-BE')}</strong></div>
<div><span>Gemist</span><strong>{orthophotoAnalysisQuality.false_negatives.toLocaleString('nl-BE')}</strong></div>
</div>
) : null}
</div>
) : null}
@@ -0,0 +1,244 @@
import { useEffect, useState } from 'react'
import { qaApi } from '../../services/api'
import type {
DetectionEvidenceRole,
DetectionReviewDecision,
DetectionReviewList,
DetectionReviewRead,
} from '../../types'
import { formatError } from '../../lib/formatError'
interface DetectionReviewPanelProps {
projectId: string
qualityCheckId: string
onOpenEvidenceMap?: (qualityCheckId: string) => void
}
const ROLE_LABELS: Record<DetectionEvidenceRole, string> = {
false_positive: 'Onterecht gevonden',
false_negative: 'Gemist gebouw',
}
const DECISION_LABELS: Record<DetectionReviewDecision, string> = {
confirmed_model_false_positive: 'Bevestigde foutdetectie',
confirmed_model_false_negative: 'Bevestigd gemist gebouw',
reference_gap_or_change: 'Referentie ontbreekt of is verouderd',
qa_alignment_mismatch: 'Vormvergelijking is te streng',
imagery_obscured_or_uncertain: 'Luchtbeeld is onduidelijk',
uncertain: 'Verder onderzoek nodig',
unreviewed: 'Nog niet beoordeeld',
}
const ROLE_DECISIONS: Record<DetectionEvidenceRole, DetectionReviewDecision[]> = {
false_positive: [
'unreviewed',
'confirmed_model_false_positive',
'reference_gap_or_change',
'qa_alignment_mismatch',
'uncertain',
],
false_negative: [
'unreviewed',
'confirmed_model_false_negative',
'reference_gap_or_change',
'qa_alignment_mismatch',
'imagery_obscured_or_uncertain',
'uncertain',
],
}
function shortId(value: string): string {
return value.length > 18 ? `${value.slice(0, 8)}...${value.slice(-6)}` : value
}
export function DetectionReviewPanel({
projectId,
qualityCheckId,
onOpenEvidenceMap,
}: DetectionReviewPanelProps): JSX.Element {
const [queue, setQueue] = useState<DetectionReviewList | null>(null)
const [loading, setLoading] = useState(false)
const [savingKey, setSavingKey] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [roleFilter, setRoleFilter] = useState<'all' | DetectionEvidenceRole>('all')
const [statusFilter, setStatusFilter] = useState<'all' | 'reviewed' | 'unreviewed'>('unreviewed')
const [offset, setOffset] = useState(0)
const [draftDecisions, setDraftDecisions] = useState<Record<string, DetectionReviewDecision>>({})
const [draftNotes, setDraftNotes] = useState<Record<string, string>>({})
const load = async () => {
setLoading(true)
setError(null)
try {
const result = await qaApi.listDetectionReviews(projectId, qualityCheckId, {
evidenceRole: roleFilter === 'all' ? undefined : roleFilter,
reviewed: statusFilter === 'all' ? undefined : statusFilter === 'reviewed',
limit: 50,
offset,
})
setQueue(result)
setDraftDecisions(Object.fromEntries(result.items.map((item) => [reviewKey(item), item.decision])))
setDraftNotes(Object.fromEntries(result.items.map((item) => [reviewKey(item), item.notes ?? ''])))
} catch (caught) {
setError(formatError(caught, 'De controlelijst kon niet worden geladen'))
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [projectId, qualityCheckId, roleFilter, statusFilter, offset])
const save = async (item: DetectionReviewRead) => {
const key = reviewKey(item)
setSavingKey(key)
setError(null)
try {
await qaApi.upsertDetectionReview(projectId, qualityCheckId, {
evidence_role: item.evidence_role,
evidence_feature_id: item.evidence_feature_id,
decision: draftDecisions[key] ?? item.decision,
notes: draftNotes[key]?.trim() || null,
reviewed_by: 'operator',
})
await load()
} catch (caught) {
setError(formatError(caught, 'De beoordeling kon niet worden bewaard'))
} finally {
setSavingKey(null)
}
}
return (
<section className="detection-review-panel" aria-label="Handmatige controle van beeldanalyse">
<div className="panel-title-row">
<div>
<h3>Fouten controleren</h3>
<p className="muted">Beoordeel alleen twijfelgevallen. Bevestigde fouten kunnen later veilig als trainingsfeedback worden gebruikt.</p>
</div>
<button type="button" className="secondary-action" onClick={() => onOpenEvidenceMap?.(qualityCheckId)}>
Op kaart bekijken
</button>
</div>
{queue ? (
<div className="detection-review-summary">
<div><span>Te beoordelen</span><strong>{queue.summary.total}</strong></div>
<div><span>Afgerond</span><strong>{queue.summary.reviewed}</strong></div>
<div><span>Resterend</span><strong>{queue.summary.remaining}</strong></div>
<div><span>Fout gevonden</span><strong>{queue.summary.false_positive_total}</strong></div>
<div><span>Gemist</span><strong>{queue.summary.false_negative_total}</strong></div>
</div>
) : null}
<div className="detection-review-filters">
<label>
Soort
<select value={roleFilter} onChange={(event) => {
setRoleFilter(event.target.value as typeof roleFilter)
setOffset(0)
}}>
<option value="all">Alles</option>
<option value="false_positive">Onterecht gevonden</option>
<option value="false_negative">Gemist gebouw</option>
</select>
</label>
<label>
Status
<select value={statusFilter} onChange={(event) => {
setStatusFilter(event.target.value as typeof statusFilter)
setOffset(0)
}}>
<option value="unreviewed">Nog te beoordelen</option>
<option value="reviewed">Beoordeeld</option>
<option value="all">Alles</option>
</select>
</label>
<button type="button" className="secondary-action" disabled={loading} onClick={() => void load()}>
{loading ? 'Laden...' : 'Vernieuwen'}
</button>
</div>
{error ? <p className="error" role="alert">{error}</p> : null}
{!loading && queue && queue.items.length === 0 ? (
<div className="result-state result-state-empty">
<strong>Geen objecten in deze selectie</strong>
<p>Pas de filters aan of open de bewijslaag op de kaart.</p>
</div>
) : null}
<ol className="detection-review-list">
{queue?.items.map((item) => {
const key = reviewKey(item)
const decision = draftDecisions[key] ?? item.decision
return (
<li key={key} className="detection-review-item">
<div className="detection-review-item-heading">
<div>
<span>{ROLE_LABELS[item.evidence_role]}</span>
<strong>{item.class_name ?? 'gebouw'} / {shortId(item.evidence_feature_id)}</strong>
</div>
{typeof item.confidence === 'number' ? <span className="count-pill">{Math.round(item.confidence * 100)}% vertrouwen</span> : null}
</div>
<div className="detection-review-editor">
<label>
Beoordeling
<select
value={decision}
onChange={(event) => setDraftDecisions((current) => ({
...current,
[key]: event.target.value as DetectionReviewDecision,
}))}
>
{ROLE_DECISIONS[item.evidence_role].map((option) => (
<option key={option} value={option}>{DECISION_LABELS[option]}</option>
))}
</select>
</label>
<label>
Notitie
<input
type="text"
maxLength={2000}
placeholder="Waarom is dit correct, fout of onzeker?"
value={draftNotes[key] ?? ''}
onChange={(event) => setDraftNotes((current) => ({ ...current, [key]: event.target.value }))}
/>
</label>
<button type="button" className="primary-action" disabled={savingKey === key} onClick={() => void save(item)}>
{savingKey === key ? 'Bewaren...' : 'Beoordeling bewaren'}
</button>
</div>
</li>
)
})}
</ol>
{queue && queue.total > queue.limit ? (
<div className="detection-review-pagination" aria-label="Pagina's van de controlelijst">
<button
type="button"
className="secondary-action"
disabled={offset === 0 || loading}
onClick={() => setOffset((current) => Math.max(current - queue.limit, 0))}
>
Vorige
</button>
<span>{offset + 1}-{Math.min(offset + queue.limit, queue.total)} van {queue.total}</span>
<button
type="button"
className="secondary-action"
disabled={offset + queue.limit >= queue.total || loading}
onClick={() => setOffset((current) => current + queue.limit)}
>
Volgende
</button>
</div>
) : null}
</section>
)
}
function reviewKey(item: Pick<DetectionReviewRead, 'evidence_role' | 'evidence_feature_id'>): string {
return `${item.evidence_role}:${item.evidence_feature_id}`
}
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react'
import type { DatasetCreateResponse, MetricRead, QualityCheckRead } from '../../types'
import { DetectionReviewPanel } from './DetectionReviewPanel'
const CORE_METRIC_ORDER = [
'precision',
@@ -304,6 +305,13 @@ export function QualityResultsPanel({
</button>
</div>
</div>
{selectedQualityCheck.check_type === 'detections_vs_reference' && selectedProjectId ? (
<DetectionReviewPanel
projectId={selectedProjectId}
qualityCheckId={selectedQualityCheck.id}
onOpenEvidenceMap={onOpenEvidenceMap}
/>
) : null}
<div className="quality-feature-evidence-grid" aria-label="Feature-level QA/QC evidence">
<div>
<span>Overeenkomende object-ID's</span>
+2 -1
View File
@@ -405,6 +405,7 @@ export function useDetectionWorkflow({
analysisRunId: string,
referenceDatasetId: string,
useCurrentFilters = true,
iouThresholdOverride?: number,
): Promise<DetectionQaResult | null> => {
if (!analysisRunId) {
setDetectionQaError('Select a detection run')
@@ -422,7 +423,7 @@ export function useDetectionWorkflow({
try {
const result = await detectionApi.compareWithReference(analysisRunId, {
reference_dataset_id: referenceDatasetId,
iou_threshold: qaIouThreshold,
iou_threshold: iouThresholdOverride ?? qaIouThreshold,
class_name: useCurrentFilters ? detectionClassFilter || null : null,
min_confidence: useCurrentFilters && detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null,
})
+32 -4
View File
@@ -17,6 +17,8 @@ export type MapOrthophotoAnalysisStage =
| 'complete'
| 'failed'
export const MAP_BUILDING_QA_IOU_THRESHOLD = 0.25
interface MapOrthophotoAnalysisOptions {
selectedProjectId: string | null
selectedAreaId: string
@@ -27,6 +29,7 @@ interface MapOrthophotoAnalysisOptions {
compareDetectionRunWithReference: (
analysisRunId: string,
referenceDatasetId: string,
iouThreshold: number,
) => Promise<DetectionQaResult | null>
onAnalysisReady: () => void
}
@@ -61,6 +64,12 @@ function formatOrthophotoError(caught: unknown): string {
return formatError(caught, 'De kaartgestuurde beeldanalyse is mislukt')
}
function formatQualityPercentage(value: number | null | undefined): string {
return typeof value === 'number' && Number.isFinite(value)
? `${(value * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%`
: 'n.v.t.'
}
export function useMapOrthophotoAnalysis({
selectedProjectId,
selectedAreaId,
@@ -75,12 +84,18 @@ export function useMapOrthophotoAnalysis({
const [status, setStatus] = useState('')
const [error, setError] = useState<string | null>(null)
const [lastResult, setLastResult] = useState<OrthophotoAcquisitionResult | null>(null)
const [lastQuality, setLastQuality] = useState<DetectionQaResult | null>(null)
const [lastDetectionCount, setLastDetectionCount] = useState<number | null>(null)
const [lastAnalysisRunId, setLastAnalysisRunId] = useState<string | null>(null)
useEffect(() => {
setStage('idle')
setStatus('')
setError(null)
setLastResult(null)
setLastQuality(null)
setLastDetectionCount(null)
setLastAnalysisRunId(null)
}, [selectionBbox?.min_x, selectionBbox?.min_y, selectionBbox?.max_x, selectionBbox?.max_y])
const run = async (bbox: VectorSelectionBBox): Promise<boolean> => {
@@ -91,6 +106,9 @@ export function useMapOrthophotoAnalysis({
}
setError(null)
setLastResult(null)
setLastQuality(null)
setLastDetectionCount(null)
setLastAnalysisRunId(null)
setStage('acquiring')
setStatus('1/3 Officieel luchtbeeld voor de rechthoek ophalen...')
try {
@@ -112,20 +130,27 @@ export function useMapOrthophotoAnalysis({
if (!detection) {
throw new Error('De beeldanalyse stopte. Open Beeldanalyse voor de technische oorzaak.')
}
setLastDetectionCount(detection.detection_count)
setLastAnalysisRunId(detection.analysis_run_id)
const reference = findBuildingReference(datasets)
if (reference) {
setStage('validating')
setStatus('3/3 Resultaat vergelijken met officiële GRB-gebouwen...')
const quality = await compareDetectionRunWithReference(detection.analysis_run_id, reference.id)
const quality = await compareDetectionRunWithReference(
detection.analysis_run_id,
reference.id,
MAP_BUILDING_QA_IOU_THRESHOLD,
)
setLastQuality(quality)
setStatus(
quality
? `Analyse klaar: ${detection.detection_count.toLocaleString('nl-BE')} gebouwen herkend en gecontroleerd.`
: `Analyse klaar: ${detection.detection_count.toLocaleString('nl-BE')} gebouwen herkend; kwaliteitscontrole kon niet afronden.`,
? `Analyse klaar: ${detection.detection_count.toLocaleString('nl-BE')} kandidaten, ${quality.matches.toLocaleString('nl-BE')} gekoppeld aan GRB. Precision ${formatQualityPercentage(quality.precision)}, recall ${formatQualityPercentage(quality.recall)}.`
: `Analyse klaar: ${detection.detection_count.toLocaleString('nl-BE')} kandidaten; kwaliteitscontrole kon niet afronden.`,
)
} else {
setStatus(
`Analyse klaar: ${detection.detection_count.toLocaleString('nl-BE')} gebouwen herkend. De GRB-referentielaag ontbreekt voor automatische controle.`,
`Analyse klaar: ${detection.detection_count.toLocaleString('nl-BE')} kandidaatvormen. De GRB-referentielaag ontbreekt voor automatische controle.`,
)
}
setStage('complete')
@@ -144,6 +169,9 @@ export function useMapOrthophotoAnalysis({
status,
error,
lastResult,
lastQuality,
lastDetectionCount,
lastAnalysisRunId,
running: stage === 'acquiring' || stage === 'detecting' || stage === 'validating',
run,
}
+28 -1
View File
@@ -1,5 +1,13 @@
import { apiGet, apiPost } from './client'
import type { QaComparisonRequest, JobRead, QualityCheckListResponse, QualityEvidenceGeoJsonResponse } from '../../types'
import type {
DetectionReviewList,
DetectionReviewRead,
DetectionReviewUpsert,
JobRead,
QaComparisonRequest,
QualityCheckListResponse,
QualityEvidenceGeoJsonResponse,
} from '../../types'
export const qaApi = {
runQa: (payload: QaComparisonRequest): Promise<JobRead> =>
@@ -8,4 +16,23 @@ export const qaApi = {
apiGet<QualityCheckListResponse>(`/api/v1/projects/${projectId}/quality-checks`),
getQualityEvidenceGeoJson: (projectId: string, qualityCheckId: string): Promise<QualityEvidenceGeoJsonResponse> =>
apiGet<QualityEvidenceGeoJsonResponse>(`/api/v1/projects/${projectId}/quality-checks/${qualityCheckId}/evidence/geojson`),
listDetectionReviews: (
projectId: string,
qualityCheckId: string,
options: { evidenceRole?: string; reviewed?: boolean; limit?: number; offset?: number } = {},
): Promise<DetectionReviewList> => {
const query = new URLSearchParams({
limit: String(options.limit ?? 50),
offset: String(options.offset ?? 0),
})
if (options.evidenceRole) query.set('evidence_role', options.evidenceRole)
if (typeof options.reviewed === 'boolean') query.set('reviewed', String(options.reviewed))
return apiGet<DetectionReviewList>(`/api/v1/projects/${projectId}/quality-checks/${qualityCheckId}/reviews?${query}`)
},
upsertDetectionReview: (
projectId: string,
qualityCheckId: string,
payload: DetectionReviewUpsert,
): Promise<DetectionReviewRead> =>
apiPost<DetectionReviewRead>(`/api/v1/projects/${projectId}/quality-checks/${qualityCheckId}/reviews`, payload),
}
+136
View File
@@ -1862,3 +1862,139 @@ details.ai-lab-model-surface > summary strong {
background: var(--accent-soft);
}
}
.geo-image-quality-metrics,
.detection-review-summary {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.5rem;
}
.geo-image-quality-metrics {
grid-column: 1 / -1;
margin-top: 0.35rem;
border-top: 1px solid var(--line);
padding-top: 0.65rem;
}
.geo-image-quality-metrics > div,
.detection-review-summary > div {
display: grid;
min-width: 0;
gap: 0.15rem;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.55rem;
background: var(--panel-soft);
}
.geo-image-quality-metrics span,
.detection-review-summary span,
.detection-review-item-heading span {
color: var(--muted);
font-size: 0.7rem;
}
.geo-image-quality-metrics strong,
.detection-review-summary strong {
color: var(--text);
font-size: 0.9rem;
}
.detection-review-panel {
display: grid;
gap: 0.75rem;
margin-top: 0.85rem;
border-top: 1px solid var(--line);
padding-top: 0.85rem;
}
.detection-review-summary {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.detection-review-filters,
.detection-review-editor,
.detection-review-pagination {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
gap: 0.65rem;
align-items: end;
}
.detection-review-filters label,
.detection-review-editor label {
display: grid;
min-width: 0;
gap: 0.3rem;
color: var(--muted);
font-size: 0.72rem;
}
.detection-review-list {
display: grid;
max-height: 34rem;
gap: 0.5rem;
margin: 0;
overflow: auto;
padding: 0;
list-style: none;
}
.detection-review-item {
display: grid;
gap: 0.6rem;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.7rem;
background: #ffffff;
}
.detection-review-item-heading {
display: flex;
min-width: 0;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
}
.detection-review-item-heading > div {
display: grid;
min-width: 0;
gap: 0.15rem;
}
.detection-review-item-heading strong {
overflow-wrap: anywhere;
font-size: 0.82rem;
}
.detection-review-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
}
.detection-review-pagination span {
color: var(--muted);
font-size: 0.76rem;
}
@media (max-width: 900px) {
.geo-image-quality-metrics,
.detection-review-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.detection-review-editor,
.detection-review-filters {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
.geo-image-quality-metrics,
.detection-review-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+55
View File
@@ -894,6 +894,61 @@ export interface QualityEvidenceGeoJsonResponse {
geojson: GeoJSON.FeatureCollection
}
export type DetectionEvidenceRole = 'false_positive' | 'false_negative'
export type DetectionReviewDecision =
| 'confirmed_model_false_positive'
| 'confirmed_model_false_negative'
| 'reference_gap_or_change'
| 'qa_alignment_mismatch'
| 'imagery_obscured_or_uncertain'
| 'uncertain'
| 'unreviewed'
export interface DetectionReviewUpsert {
evidence_role: DetectionEvidenceRole
evidence_feature_id: string
decision: DetectionReviewDecision
notes?: string | null
reviewed_by?: string
}
export interface DetectionReviewRead {
id?: string | null
project_id: string
quality_check_id: string
analysis_run_id?: string | null
evidence_role: DetectionEvidenceRole
evidence_feature_id: string
detection_id?: string | null
reference_feature_id?: string | null
decision: DetectionReviewDecision
notes?: string | null
reviewed_by?: string | null
confidence?: number | null
class_name?: string | null
source_tile_path?: string | null
created_at?: string | null
updated_at?: string | null
}
export interface DetectionReviewSummary {
total: number
reviewed: number
remaining: number
false_positive_total: number
false_negative_total: number
decision_counts: Record<string, number>
}
export interface DetectionReviewList {
items: DetectionReviewRead[]
total: number
limit: number
offset: number
summary: DetectionReviewSummary
}
export type ExportKind = 'dataset' | 'detection_run' | 'segmentation_run' | 'vector_selection'
export interface ExportRead {