Harden raster detection manifest handoff
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-07-08 12:04:21 +02:00
parent 0ed71c6f1e
commit 46204edbf4
11 changed files with 250 additions and 9 deletions
+9
View File
@@ -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.
@@ -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
+24
View File
@@ -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:
+1
View File
@@ -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.
+4
View File
@@ -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,
@@ -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}
@@ -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.'}
</p>
<button type="button" onClick={onUseTileManifestForDetection} disabled={!latestRasterTileManifestPath}>
Use in Detection Lab
{latestRasterTileManifest ? (
<dl className="raster-manifest-details">
<div>
<dt>Tile count</dt>
<dd>{latestRasterTileManifest.count ?? 'n/a'}</dd>
</div>
<div>
<dt>Tile size</dt>
<dd>{latestRasterTileManifest.tile_size ?? 'n/a'}</dd>
</div>
<div>
<dt>Overlap</dt>
<dd>{latestRasterTileManifest.overlap ?? 'n/a'}</dd>
</div>
<div>
<dt>Tile set</dt>
<dd>{latestRasterTileManifest.tile_set_id ?? 'n/a'}</dd>
</div>
</dl>
) : null}
<button
type="button"
aria-label="Use in Detection Lab with current manifest"
onClick={onUseTileManifestForDetection}
disabled={!latestRasterTileManifestPath}
>
Use manifest in Detection Lab
</button>
<button type="button" onClick={onUseTileManifestForSegmentation} disabled={!latestRasterTileManifestPath}>
Use in Segmentation Lab
<button
type="button"
aria-label="Use in Segmentation Lab with current manifest"
onClick={onUseTileManifestForSegmentation}
disabled={!latestRasterTileManifestPath}
>
Use manifest in Segmentation Lab
</button>
</div>
</section>
@@ -311,11 +311,20 @@ export function DetectionLab({
<span>CUDA</span>
<strong>{yoloPreflight.runtime.cuda_available === true ? 'available' : yoloPreflight.runtime.cuda_available === false ? 'not available' : 'not checked'}</strong>
</div>
<div className={yoloPreflight.checks.manifest_valid ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Tile manifest validation</span>
<strong>{yoloPreflight.checks.manifest_valid === true ? 'valid' : yoloPreflight.checks.manifest_path_set ? 'not valid' : 'not provided'}</strong>
</div>
<div className={yoloPreflight.tile_count > 0 ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Tile count</span>
<strong>{yoloPreflight.tile_count} / {yoloPreflight.max_tiles}</strong>
</div>
</div>
<div className="entity-meta">
<span>torch_version: {yoloPreflight.runtime.torch_version ?? 'n/a'}</span>
<span>ultralytics_version: {yoloPreflight.runtime.ultralytics_version ?? 'n/a'}</span>
<span>cuda_available: {String(yoloPreflight.runtime.cuda_available ?? 'unknown')}</span>
<span>will_run_inference: {String(yoloPreflight.will_run_inference)}</span>
<span>YOLO_CONFIG_DIR: {yoloPreflight.runtime.yolo_config_dir ?? 'n/a'}</span>
<span>model directory: {yoloPreflight.runtime.model_directory ?? 'n/a'}</span>
<span>model_asset_id: {yoloPreflight.model_asset_id ?? 'n/a'}</span>
@@ -437,6 +446,13 @@ export function DetectionLab({
/>
</label>
) : null}
{selectedDetectionModelId === 'yolo-configured' && detectionTileManifestPath.trim() ? (
<div className="linked-manifest-card">
<strong>Linked tile manifest</strong>
<p>{detectionTileManifestPath}</p>
<span>Refresh preflight after changing model asset or manifest path.</span>
</div>
) : null}
<button className="primary-action" type="button" onClick={onRunDetection} disabled={runningDetection || !detectionRunReady}>
Run detection
</button>
+42 -4
View File
@@ -4,6 +4,7 @@ import type {
AreaRead,
DatasetCreateResponse,
JobRead,
RasterTileHandoff,
RasterMetadataResponse,
RasterPreviewResponse,
RasterStatsResponse,
@@ -28,12 +29,39 @@ function toRasterMetadata(metadata: Record<string, unknown> | 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<string, unknown>
: {}
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<JobRead[]>([])
const [rasterPreview, setRasterPreview] = useState<RasterPreviewResponse | null>(null)
const [latestRasterTileManifestPath, setLatestRasterTileManifestPath] = useState('')
const [latestRasterTileManifest, setLatestRasterTileManifest] = useState<RasterTileHandoff | null>(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,
+57
View File
@@ -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);
+10
View File
@@ -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<string, number> | null