Harden raster detection manifest handoff
This commit is contained in:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user