feat: add measured detection review loop
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user