diff --git a/CHANGELOG.md b/CHANGELOG.md index 60314cca..6d264fd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1301,3 +1301,12 @@ Added: - Surfaced the current YOLOv8s hard-negative benchmark candidate and recommended starting threshold `0.25` as operator guidance. - Added regression coverage for the no-auto-select behavior and UI guardrail copy. - No backend API contracts, migrations, model downloads, provider fetching or model weight mutation behavior changed. + +## Sprint 123 Raster detection manifest handoff (2026-07-08) + +- Added a structured raster tile manifest handoff from the Data workspace into Detection Lab. +- Raster controls now surface manifest tile count, tile size, overlap and tile-set provenance before handing the manifest to AI workflows. +- The Detection Lab handoff now selects the configured YOLO run path, keeps local model assets explicit, applies the current recommended `0.25` starting threshold and refreshes YOLO preflight for the linked manifest. +- Detection Lab now shows linked tile manifest provenance plus preflight manifest validation, tile count and `will_run_inference` state. +- Added regression coverage for the handoff contract and preserved the existing no-auto-select model guardrail. +- No backend API contracts, migrations, model downloads, provider fetching or model weight mutation behavior changed. diff --git a/backend/tests/test_sprint123_raster_detection_handoff_operational.py b/backend/tests/test_sprint123_raster_detection_handoff_operational.py new file mode 100644 index 00000000..95833956 --- /dev/null +++ b/backend/tests/test_sprint123_raster_detection_handoff_operational.py @@ -0,0 +1,46 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_raster_workflow_exposes_structured_tile_manifest_handoff() -> None: + hook = (ROOT / "frontend" / "src" / "hooks" / "useDatasetWorkflow.ts").read_text(encoding="utf-8") + types = (ROOT / "frontend" / "src" / "types.ts").read_text(encoding="utf-8") + + assert "interface RasterTileHandoff" in types + assert "latestRasterTileManifest" in hook + assert "toRasterTileHandoff(job)" in hook + assert "setLatestRasterTileManifest(manifest)" in hook + assert "manifest.manifest_path" in hook + + +def test_raster_controls_show_manifest_details_and_ai_handoff_action() -> None: + controls = (ROOT / "frontend" / "src" / "components" / "datasets" / "RasterControls.tsx").read_text( + encoding="utf-8" + ) + + assert "latestRasterTileManifest" in controls + assert "Tile count" in controls + assert "Tile size" in controls + assert "Overlap" in controls + assert "Use manifest in Detection Lab" in controls + assert "Use manifest in Segmentation Lab" in controls + + +def test_detection_handoff_opens_ai_lab_preflights_manifest_and_keeps_asset_explicit() -> None: + app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + lab = (ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text( + encoding="utf-8" + ) + + assert "setDetectionTileManifestPath(manifestPath)" in app + assert "setSelectedDetectionDatasetId(selectedDataset.id)" in app + assert "setSelectedDetectionModelId('yolo-configured')" in app + assert "setDetectionConfidenceThreshold(0.25)" in app + assert "loadYoloPreflight(manifestPath).catch(() => null)" in app + assert "setSelectedModelAssetId(" not in app[app.index("const useRasterTileManifestForDetection"):app.index("const {", app.index("const useRasterTileManifestForDetection"))] + assert "Linked tile manifest" in lab + assert "Tile manifest validation" in lab + assert "Tile count" in lab + assert "will_run_inference" in lab diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index f17328f9..bf5a3cc9 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -5079,6 +5079,30 @@ Open: Next recommended pass: - Implement V1 model-catalog hardening: show available local model assets, make active model/threshold explicit, and prevent accidental silent default activation. +## Sprint 123 Raster detection manifest handoff (2026-07-08) + +Changed: +- Added a typed `RasterTileHandoff` frontend contract so raster tile jobs can expose manifest path, tile count, tile size, overlap, tile-set id and source dataset provenance. +- Updated the dataset workflow hook to retain the latest structured raster tile manifest alongside the existing manifest path. +- Updated Raster Controls to show manifest details and explicit Detection/Segmentation Lab handoff actions. +- Updated the Detection Lab handoff so a raster manifest selects `yolo-configured`, selects the active raster dataset, applies threshold `0.25`, refreshes YOLO preflight and does not auto-select a local model asset. +- Added linked tile manifest, manifest validation, tile count and `will_run_inference` visibility to Detection Lab. +- Added regression coverage in `backend/tests/test_sprint123_raster_detection_handoff_operational.py`. + +Tested: +- Red step: `python -m pytest backend\tests\test_sprint123_raster_detection_handoff_operational.py -q` failed while the typed handoff, Raster Controls details and Detection Lab preflight indicators were absent. +- `python -m pytest backend\tests\test_sprint123_raster_detection_handoff_operational.py -q` (`3 passed`) +- `python -m pytest backend\tests\test_sprint99_raster_ui_handoff.py backend\tests\test_sprint122_model_asset_activation_guardrails.py backend\tests\test_sprint120_model_asset_detection_workflow_smoke.py backend\tests\test_sprint123_raster_detection_handoff_operational.py -q` (`8 passed`) +- `python -m compileall backend/app` +- `cd frontend && npm run typecheck` +- `cd frontend && npm run build` + +Limitations: +- This pass improves the operator handoff and preflight visibility only. It does not change backend inference contracts, migrations, model files, provider fetching or automatic model promotion. + +Next recommended pass: +- Add threshold calibration comparison UX so an operator can compare candidate thresholds before promoting a local model. + ## Sprint 117 Safe local YOLO model activation (2026-07-06) Changed: diff --git a/docs/TODO.md b/docs/TODO.md index 2bbc8a47..a8cc2145 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -418,6 +418,7 @@ This file now starts with the current implementation status. Older preparation/b - [x] Train/evaluate a YOLOv8s hard-negative local building-detector candidate on Tower and keep it inactive because hard-negative false positives remain. - [x] Add an operator-facing local model catalog/activation workflow with SHA256, active model status and explicit threshold guidance. - [x] Block silent local model asset auto-selection in Detection Lab. +- [x] Add structured raster tile manifest handoff into Detection Lab with linked preflight visibility. - [ ] Add full threshold calibration comparison UX so detection runs can compare candidate thresholds before promotion. - [ ] Add more AOIs after the tile-level baseline so the next local model attempt is not limited to Geel/Mol/Turnhout. - [ ] Add negative/background AOIs so the next tile dataset is not all positive tiles. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 45fde704..052ab980 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -83,6 +83,7 @@ function App(): JSX.Element { jobs, rasterPreview, latestRasterTileManifestPath, + latestRasterTileManifest, selectedIntersectTargetId, selectedClipAreaId, rasterTileSize, @@ -279,6 +280,8 @@ function App(): JSX.Element { setDetectionTileManifestPath(manifestPath) setSelectedDetectionDatasetId(selectedDataset.id) setSelectedDetectionModelId('yolo-configured') + setDetectionConfidenceThreshold(0.25) + loadYoloPreflight(manifestPath).catch(() => null) setActiveWorkspace('ai') } const { @@ -1088,6 +1091,7 @@ function App(): JSX.Element { selectedRasterStats, rasterPreview, latestRasterTileManifestPath, + latestRasterTileManifest, rasterUnavailableMessage, selectedClipAreaId, selectedIntersectTargetId, diff --git a/frontend/src/components/datasets/DatasetDetailPanel.tsx b/frontend/src/components/datasets/DatasetDetailPanel.tsx index 6f64a2b8..a4bc3fb1 100644 --- a/frontend/src/components/datasets/DatasetDetailPanel.tsx +++ b/frontend/src/components/datasets/DatasetDetailPanel.tsx @@ -5,6 +5,7 @@ import type { RasterMetadataResponse, RasterPreviewResponse, RasterStatsResponse, + RasterTileHandoff, VectorSummary, } from '../../types' import { RasterControls } from './RasterControls' @@ -20,6 +21,7 @@ export interface DatasetDetailPanelProps { selectedRasterStats: RasterStatsResponse | null rasterPreview: RasterPreviewResponse | null latestRasterTileManifestPath: string + latestRasterTileManifest: RasterTileHandoff | null rasterUnavailableMessage: string | null selectedClipAreaId: string selectedIntersectTargetId: string @@ -104,6 +106,7 @@ export function DatasetDetailPanel({ selectedRasterStats, rasterPreview, latestRasterTileManifestPath, + latestRasterTileManifest, rasterUnavailableMessage, selectedClipAreaId, selectedIntersectTargetId, @@ -178,6 +181,7 @@ export function DatasetDetailPanel({ selectedRasterStats={selectedRasterStats} rasterPreview={rasterPreview} latestRasterTileManifestPath={latestRasterTileManifestPath} + latestRasterTileManifest={latestRasterTileManifest} rasterUnavailableMessage={rasterUnavailableMessage} selectedClipAreaId={selectedClipAreaId} rasterTileSize={rasterTileSize} diff --git a/frontend/src/components/datasets/RasterControls.tsx b/frontend/src/components/datasets/RasterControls.tsx index 8234ec93..7023e08f 100644 --- a/frontend/src/components/datasets/RasterControls.tsx +++ b/frontend/src/components/datasets/RasterControls.tsx @@ -1,4 +1,4 @@ -import type { AreaRead, RasterMetadataResponse, RasterPreviewResponse, RasterStatsResponse } from '../../types' +import type { AreaRead, RasterMetadataResponse, RasterPreviewResponse, RasterStatsResponse, RasterTileHandoff } from '../../types' interface RasterControlsProps { areas: AreaRead[] @@ -7,6 +7,7 @@ interface RasterControlsProps { selectedRasterStats: RasterStatsResponse | null rasterPreview: RasterPreviewResponse | null latestRasterTileManifestPath: string + latestRasterTileManifest: RasterTileHandoff | null rasterUnavailableMessage: string | null selectedClipAreaId: string rasterTileSize: number @@ -61,6 +62,7 @@ export function RasterControls({ selectedRasterStats, rasterPreview, latestRasterTileManifestPath, + latestRasterTileManifest, rasterUnavailableMessage, selectedClipAreaId, rasterTileSize, @@ -167,11 +169,41 @@ export function RasterControls({ {latestRasterTileManifestPath || 'No tile manifest generated yet. Generate tiles before handing this raster to Detection Lab.'}

- - diff --git a/frontend/src/components/detection/DetectionLab.tsx b/frontend/src/components/detection/DetectionLab.tsx index 1e546f91..df78b37d 100644 --- a/frontend/src/components/detection/DetectionLab.tsx +++ b/frontend/src/components/detection/DetectionLab.tsx @@ -311,11 +311,20 @@ export function DetectionLab({ CUDA {yoloPreflight.runtime.cuda_available === true ? 'available' : yoloPreflight.runtime.cuda_available === false ? 'not available' : 'not checked'} +
+ Tile manifest validation + {yoloPreflight.checks.manifest_valid === true ? 'valid' : yoloPreflight.checks.manifest_path_set ? 'not valid' : 'not provided'} +
+
0 ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}> + Tile count + {yoloPreflight.tile_count} / {yoloPreflight.max_tiles} +
torch_version: {yoloPreflight.runtime.torch_version ?? 'n/a'} ultralytics_version: {yoloPreflight.runtime.ultralytics_version ?? 'n/a'} cuda_available: {String(yoloPreflight.runtime.cuda_available ?? 'unknown')} + will_run_inference: {String(yoloPreflight.will_run_inference)} YOLO_CONFIG_DIR: {yoloPreflight.runtime.yolo_config_dir ?? 'n/a'} model directory: {yoloPreflight.runtime.model_directory ?? 'n/a'} model_asset_id: {yoloPreflight.model_asset_id ?? 'n/a'} @@ -437,6 +446,13 @@ export function DetectionLab({ /> ) : null} + {selectedDetectionModelId === 'yolo-configured' && detectionTileManifestPath.trim() ? ( +
+ Linked tile manifest +

{detectionTileManifestPath}

+ Refresh preflight after changing model asset or manifest path. +
+ ) : null} diff --git a/frontend/src/hooks/useDatasetWorkflow.ts b/frontend/src/hooks/useDatasetWorkflow.ts index da60733e..ca03c12d 100644 --- a/frontend/src/hooks/useDatasetWorkflow.ts +++ b/frontend/src/hooks/useDatasetWorkflow.ts @@ -4,6 +4,7 @@ import type { AreaRead, DatasetCreateResponse, JobRead, + RasterTileHandoff, RasterMetadataResponse, RasterPreviewResponse, RasterStatsResponse, @@ -28,12 +29,39 @@ function toRasterMetadata(metadata: Record | null | undefined): } function extractRasterTileManifestPath(job: JobRead | null | undefined): string { + const manifest = toRasterTileHandoff(job) + return manifest?.manifest_path ?? '' +} + +function optionalString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null +} + +function optionalNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function toRasterTileHandoff(job: JobRead | null | undefined): RasterTileHandoff | null { const result = job?.result_json if (!result) { - return '' + return null } const manifestPath = result.manifest_path - return typeof manifestPath === 'string' ? manifestPath : '' + if (typeof manifestPath !== 'string' || manifestPath.trim().length === 0) { + return null + } + const manifest = typeof result.manifest === 'object' && result.manifest !== null + ? result.manifest as Record + : {} + return { + manifest_path: manifestPath, + tile_set_id: optionalString(result.tile_set_id) ?? optionalString(manifest.tile_set_id), + tile_size: optionalNumber(result.tile_size) ?? optionalNumber(manifest.tile_size), + overlap: optionalNumber(result.overlap) ?? optionalNumber(manifest.overlap), + count: optionalNumber(result.count) ?? optionalNumber(manifest.count), + source_dataset_id: optionalString(result.source_dataset_id) ?? optionalString(manifest.source_dataset_id), + created_at: optionalString(manifest.created_at), + } } export function useDatasetWorkflow({ @@ -53,6 +81,7 @@ export function useDatasetWorkflow({ const [jobs, setJobs] = useState([]) const [rasterPreview, setRasterPreview] = useState(null) const [latestRasterTileManifestPath, setLatestRasterTileManifestPath] = useState('') + const [latestRasterTileManifest, setLatestRasterTileManifest] = useState(null) const [selectedIntersectTargetId, setSelectedIntersectTargetId] = useState('') const [selectedClipAreaId, setSelectedClipAreaId] = useState('') const [rasterTileSize, setRasterTileSize] = useState(512) @@ -113,7 +142,10 @@ export function useDatasetWorkflow({ const latestRasterTileJob = response.items.find( (job) => job.job_type === 'raster.tile' && extractRasterTileManifestPath(job), ) - setLatestRasterTileManifestPath(extractRasterTileManifestPath(latestRasterTileJob)) + const manifest = toRasterTileHandoff(latestRasterTileJob) + const manifestPath = manifest ? manifest.manifest_path : '' + setLatestRasterTileManifest(manifest) + setLatestRasterTileManifestPath(manifestPath) } const loadDatasetDetails = async (projectId: string, dataset: DatasetCreateResponse) => { @@ -126,6 +158,7 @@ export function useDatasetWorkflow({ setDatasetContent(null) setRasterPreview(null) setLatestRasterTileManifestPath('') + setLatestRasterTileManifest(null) setSelectedDatasetId(dataset.id) setJobs([]) try { @@ -372,10 +405,13 @@ export function useDatasetWorkflow({ overlap: rasterTileOverlap, output_name: rasterTileOutputName || undefined, }) - const manifestPath = extractRasterTileManifestPath(job) + const manifest = toRasterTileHandoff(job) + const manifestPath = manifest?.manifest_path ?? '' + setLatestRasterTileManifest(manifest) setLatestRasterTileManifestPath(manifestPath) await refreshSelectedDatasetAfterJob() if (manifestPath) { + setLatestRasterTileManifest(manifest) setLatestRasterTileManifestPath(manifestPath) } setDatasetDetailError(null) @@ -465,6 +501,7 @@ export function useDatasetWorkflow({ setDatasetContent(null) setRasterPreview(null) setLatestRasterTileManifestPath('') + setLatestRasterTileManifest(null) setJobs([]) } @@ -478,6 +515,7 @@ export function useDatasetWorkflow({ jobs, rasterPreview, latestRasterTileManifestPath, + latestRasterTileManifest, selectedIntersectTargetId, selectedClipAreaId, rasterTileSize, diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 67decd0e..2370f482 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -3702,6 +3702,63 @@ button.entity-card { background: #ffffff; } +.raster-manifest-details { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); + gap: 0.42rem; + min-width: 0; + margin: 0.2rem 0 0.35rem; +} + +.raster-manifest-details div { + min-width: 0; + border: 1px solid #d8e3de; + border-radius: 6px; + padding: 0.42rem 0.48rem; + background: #f8fbf9; +} + +.raster-manifest-details dt { + color: var(--muted); + font-size: 0.7rem; + font-weight: 850; + text-transform: uppercase; +} + +.raster-manifest-details dd { + min-width: 0; + margin: 0.12rem 0 0; + color: var(--text); + font-size: 0.86rem; + font-weight: 750; + overflow-wrap: anywhere; +} + +.linked-manifest-card { + display: grid; + gap: 0.22rem; + min-width: 0; + border: 1px solid rgba(47, 125, 86, 0.24); + border-radius: 7px; + padding: 0.6rem 0.65rem; + background: #f5fbf6; +} + +.linked-manifest-card strong { + color: var(--text); + font-size: 0.88rem; +} + +.linked-manifest-card p, +.linked-manifest-card span { + min-width: 0; + margin: 0; + color: var(--muted); + font-size: 0.78rem; + line-height: 1.35; + overflow-wrap: anywhere; +} + .raster-readiness-item span, .raster-manifest-handoff span { color: var(--muted); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b6d09f27..5f67f326 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -259,6 +259,16 @@ export interface RasterTileResponse { manifest: RasterTileManifest } +export interface RasterTileHandoff { + manifest_path: string + tile_set_id?: string | null + tile_size?: number | null + overlap?: number | null + count?: number | null + source_dataset_id?: string | null + created_at?: string | null +} + export interface VectorBBoxResponse { dataset_id: string bounds_json: Record | null