Spaces:
Paused
Paused
File size: 20,732 Bytes
a358495 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 | """Validated DAG executor using the existing specialist/GIS architecture."""
import asyncio
import json
import time
from datetime import datetime, UTC
from pathlib import Path
import numpy as np
from scipy import ndimage
from satquery_engine.schemas import AnalysisResponse, ArtifactRef, EvidenceItem, GeoVerdict, TraceStep, VerdictStatus, TaskType, TaskPlan, PlanNode
from satquery_engine.services.intents import plan_query, validate_dag
from satquery_engine.services.input_configuration import classify_configuration, policy_blockers, node_policy_error, acquisition_time
from satquery_engine.services.raster import validate_inputs, render_preview
from satquery_engine.services.alignment import align_pair
from satquery_engine.services.buildings import detect_buildings, match_buildings
from satquery_engine.services.measurements import measure_cover, measure_cover_change
from satquery_engine.services.model_registry import registry
from satquery_engine.services.confidence import confidence_breakdown
from satquery_engine.services.report import write_pdf_report, write_manifest
from satquery_engine.services.geolocation import parse_user_coordinates
async def analyze(*,result_id,query,pair_type,image_paths,output_dir,progress=None,input_filenames=None,input_asset_ids=None):
started=time.perf_counter(); output_dir.mkdir(parents=True,exist_ok=True)
trace=[]; artifacts=[]; evidence=[]; findings=[]; statistics={}; models=[]; limitations=[]; results={}
def emit(message):
if progress: progress(message)
def add_path(path):
rel=path.relative_to(output_dir).as_posix()
mime={".png":"image/png",".tif":"image/tiff",".geojson":"application/geo+json",".pdf":"application/pdf",".json":"application/json"}.get(path.suffix,"application/octet-stream")
if not any(a.url.endswith('/'+rel) for a in artifacts):
name = path.name if not any(a.name == path.name for a in artifacts) else rel.replace('/', '_')
import uuid
artifacts.append(ArtifactRef(artifact_id=str(uuid.uuid5(uuid.NAMESPACE_URL,f"{result_id}/{rel}")),name=name,url=f"/artifacts/{result_id}/{rel}",mime_type=mime))
emit("Reading and checking image metadata")
metadata,quality=await asyncio.to_thread(validate_inputs,image_paths)
if input_filenames:
metadata = [asset.model_copy(update={"filename": name}) for asset, name in zip(metadata, input_filenames)]
if input_asset_ids:
metadata=[asset.model_copy(update={"asset_id":ident}) for asset,ident in zip(metadata,input_asset_ids,strict=True)]
configuration,clarification=classify_configuration(metadata,pair_type)
emit("Understanding your request and planning analysis")
user_coordinates=parse_user_coordinates(query)
plan=(TaskPlan(task=TaskType.GROUNDING,application="user_location",specific_task="user_location",
intents=["USER_LOCATION"],tools=["validate","synthesize","report"],
reason="Validated explicit user coordinates without model inference.",
nodes=[PlanNode(node_id="validate",tool="validate"),
PlanNode(node_id="synthesize",tool="synthesize",depends_on=["validate"]),
PlanNode(node_id="report",tool="report",depends_on=["synthesize"])])
if user_coordinates is not None else plan_query(query,len(image_paths),pair_type,configuration))
validate_dag(plan)
clarification=plan.clarification or (clarification if len(image_paths)==2 else None)
if plan.requires_temporal_relationship and configuration.startswith("PAIR_BITEMPORAL"):
dates=[a.acquisition_date for a in metadata]
if all(dates):
try:
order=sorted(range(2),key=lambda i: acquisition_time(dates[i]))
image_paths=[image_paths[i] for i in order]; metadata=[metadata[i] for i in order]
except ValueError: pass
quality.blockers.extend(policy_blockers(plan,metadata,configuration))
quality.compatible=not quality.blockers and not clarification
trace.append(TraceStep(step=1,component="validation_and_planner",action="Checked metadata, pair semantics and allow-listed task DAG",
status="ok" if quality.compatible else "blocked",duration_ms=round((time.perf_counter()-started)*1000),
details={"configuration":configuration,"intents":plan.intents,"blockers":quality.blockers}))
for i,path in enumerate(image_paths):
preview=output_dir/f"preview_{i+1}.png"
await asyncio.to_thread(render_preview,path,preview)
add_path(preview)
if user_coordinates is not None and quality.compatible:
latitude,longitude=user_coordinates
location={"type":"user_location","latitude":latitude,"longitude":longitude,"source":"USER_COORDINATES"}
statistics["user_location"]=location
summary=f"Marked the user-provided location at latitude {latitude}, longitude {longitude}."
point_file=output_dir/"user_location.geojson"
point_file.write_text(json.dumps({"type":"FeatureCollection",
"properties":{"crs":"EPSG:4326","coordinate_space":"geographic","source":"USER_COORDINATES"},
"features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[longitude,latitude]},
"properties":location}]}),encoding="utf-8")
add_path(point_file)
evidence.append(EvidenceItem(evidence_id="evidence_1",kind="user_location",producer="user",
summary=summary,confidence=1.0,confidence_source="authoritative_user_input",
artifact_url=artifacts[-1].url,metrics=location))
findings.append({"text":summary,"evidence_ids":["evidence_1"],"status":"supported"})
trace.append(TraceStep(step=len(trace)+1,component="user_location",action="Validated and mapped explicit coordinates",
status="ok",duration_ms=0,details={"source":"USER_COORDINATES"}))
working=list(image_paths); completed={"validate"}; failed=set()
from satquery_engine.services.surface_context import SurfaceContext
surfaces = SurfaceContext(output_dir / "surface_context")
preflight={n.node_id:node_policy_error(n,metadata) for n in plan.nodes}
if quality.compatible and plan.task not in {TaskType.UNSUPPORTED,TaskType.UNCLEAR}:
for node in plan.nodes:
if node.tool in {"validate","synthesize","report"}: continue
if any(d in failed for d in node.depends_on):
failed.add(node.node_id)
trace.append(TraceStep(step=len(trace)+1,component=node.tool,action="Skipped because required evidence is unavailable",status="skipped",duration_ms=0))
continue
tick=time.perf_counter(); emit({"register":"Aligning images","buildings":"Running building detection","building_match":"Comparing building footprints", "spectral":"Measuring image features","spectral_change":"Measuring change","land_cover":"Mapping land cover from image evidence","change":"Measuring image differences","fusion":"Checking optical and radar evidence","vlm":"Describing image evidence"}[node.tool])
sub=output_dir/node.node_id; sub.mkdir(exist_ok=True)
params=node.parameters
try:
if preflight.get(node.node_id): raise ValueError(preflight[node.node_id])
result=None; summary=""
if node.tool=="register":
a,b,registration=await asyncio.to_thread(align_pair,*working,output_dir,not plan.requires_temporal_relationship)
working=[a,b]; add_path(output_dir/"registration_report.json")
quality.checks["alignment_score"]=registration.alignment_score
quality.checks["registration_method"]=registration.method
if plan.requires_temporal_relationship and registration.alignment_score<.35:
raise ValueError("The images could not be aligned reliably enough for change analysis.")
limitations.append(registration.message)
elif node.tool=="buildings":
emit("Checking water boundaries before counting buildings")
water_context = await asyncio.to_thread(surfaces.water, working[params["asset"]])
for path in water_context.get("paths", []): add_path(path)
models.extend(water_context.get("models_used", []))
result=await asyncio.to_thread(detect_buildings,working[params["asset"]],sub,emit,water_result=water_context)
summary=f'I found approximately {result["count"]} visible building footprints in image {params["asset"]+1}.'
if result.get("count_reliability") == "COUNT_UNRELIABLE":
summary += ' This is a low-confidence detection count; an accurate building inventory cannot be established from this result.'
if result["count"] == 0:
summary += ' No footprints were detected; this does not establish that no buildings are present.'
if any(i in plan.intents for i in ["BUILT_UP_ANALYSIS", "BUILT_UP_CHANGE", "BUILDING_FOOTPRINT"]):
summary += f' Their measured footprint area is {result["area_m2"]:.1f} square metres.' if result["area_m2"] is not None else f' They cover {result["selected_pixels"]} image pixels; geographic area is unknown.'
models.append(result["model_id"])
elif node.tool=="building_match":
result=await asyncio.to_thread(match_buildings,results["buildings_a"],results["buildings_b"],sub)
summary=f'The detected building count changed from {result["before_count"]} to {result["after_count"]}. There are {result["possible_new_count"]} possible new and {result["possible_removed_count"]} possibly removed footprints.'
if result['net_footprint_area_m2'] is not None:
summary += f' The net detected footprint area change is {result["net_footprint_area_m2"]:+.1f} square metres; this measures buildings, not all built-up surfaces.'
elif node.tool=="spectral":
if params["target"] == "water":
result=await asyncio.to_thread(surfaces.water,working[0],largest=params.get("largest",False),strict=params.get("strict",False))
else:
result=await asyncio.to_thread(measure_cover,working[0],sub,**params)
unit=f'{result["area_m2"]:.1f} square metres' if result["area_m2"] is not None else f'{result["selected_pixels"]} sampled image pixels'
loc=f' in the {result["location_description"]}' if result.get("location_description") else ''
if params["target"] == "water" and result.get("evidence_state") == "INSUFFICIENT_EVIDENCE":
summary="Water boundaries could not be verified from this RGB image because the compatible aerial models failed."
else:
summary=f'{result["method"]} identifies {params["target"]}-like regions{loc} covering {result["coverage_percent"]:.2f}% of valid pixels ({unit}).'
elif node.tool=="land_cover":
from satquery_engine.services.land_cover import classify_land_cover_composite
result=await asyncio.to_thread(classify_land_cover_composite,working[0],sub,query,
water_result=await asyncio.to_thread(surfaces.water,working[0]), building_result=results.get("buildings_a"),
vegetation_result=results.get("vegetation_measure"))
summary=result["summary"]
elif node.tool=="spectral_change":
result=await asyncio.to_thread(measure_cover_change,*working,sub,params["target"])
summary=f'The area meeting the {params["target"]} index threshold changed by {result["net_percentage_points"]:+.2f} percentage points across shared valid pixels.'
elif node.tool=="change":
from satquery_engine.services.raster import deterministic_change_detection
result=await asyncio.to_thread(deterministic_change_detection,*working,sub)
result.pop("mask_path"); result.pop("geojson_path")
result["method"]="Radiometric difference threshold; not a learned change model"
result["evidence_strength"]=0.0
result["limitations"]=["This measures image differences, which can reflect illumination or season. The learned change specialist is unavailable; the type of change is unverified."]
summary=f'I measured image differences over {result["changed_percent"]:.2f}% of the sampled pixels. I cannot establish what caused them.'
elif node.tool=="fusion":
external=await registry.invoke("croma",{"optical_path":str(working[0]),"sar_path":str(working[1])})
if not external.get("available"): raise ValueError("The optical-SAR fusion specialist is unavailable. No fused result was generated.")
# A representation alone cannot ground semantic spatial claims.
raise ValueError("The fusion service returned a representation, but a validated task-specific decoder is not configured. No geographic fusion claim was generated.")
elif node.tool=="vlm":
if params.get("counting"):
raise ValueError("A dedicated detector for this object type is unavailable. A language model will not supply an exact count.")
facts = {name:{k:v for k,v in value.items() if k in {"count","coverage_percent","area_m2","breakdown","method","limitations"}} for name,value in results.items()}
explanation_query = query + "\nExplain only the following backend evidence. Do not invent or revise counts, areas, coordinates, confidence or percentages. Do not introduce numerical claims in the scene description. Backend facts: " + json.dumps(facts,allow_nan=False)
external=await registry.invoke_vlm(query=explanation_query,image_paths=[str(p) for p in working],tile_paths=[],metadata=[a.model_dump(mode="json") for a in metadata])
if not external.get("available"): raise ValueError("The image description model is unavailable. No description was generated.")
if params.get("grounding"):
raise ValueError("A validated spatial detector for this object is unavailable; text alone cannot establish its location.")
summary=external.get("answer","")
if not summary: raise ValueError("The image description model returned no answer.")
# Numerical measurements never come from language output.
import re
if re.search(r"\d",summary): raise ValueError("The description included unsupported numerical claims and was withheld.")
models.append(external["model"])
result={"method":external["model"],"evidence_strength":0.0,"paths":[],
"limitations":["This description is a model interpretation; no exact geographic measurements were made."]}
if result is not None:
from satquery_engine.services.quality_gate import validate_result_evidence
validate_result_evidence(result,output_dir / "surface_context" if node.tool == "spectral" and params["target"] == "water" else sub)
results[node.node_id]=result
models.extend(result.get("models_used", []))
for path in result.get("paths",[]): add_path(path)
metrics={k:v for k,v in result.items() if k not in {"paths","features","limitations","mask_path","geojson_path"} and not isinstance(v,np.ndarray)}
statistics[node.node_id]=metrics
eid=f"evidence_{len(evidence)+1}"
evidence.append(EvidenceItem(evidence_id=eid,kind=node.tool,producer=result.get("model_id",result.get("method",node.tool)),summary=summary,
confidence=float(result.get("evidence_strength",0)),confidence_source="uncalibrated_model_score" if node.tool=="buildings" else "documented_measurement_strength",
artifact_url=next((a.url for a in artifacts if any(a.url.endswith('/'+p.relative_to(output_dir).as_posix()) for p in result.get("paths",[]) if 'overlay' in p.name)),None),metrics=metrics,
timestamp=datetime.now(UTC).isoformat(),asset_indices=[params["asset"]] if "asset" in params else list(range(len(working)))))
findings.append({"text":summary,"evidence_ids":[eid],"status":"supported_with_limitations"})
limitations.extend(result.get("limitations",[]))
completed.add(node.node_id)
trace.append(TraceStep(step=len(trace)+1,component=node.tool,action=node.node_id,status="ok",duration_ms=round((time.perf_counter()-tick)*1000),details={"preprocessing":result.get("preprocessing") if result else None,"checkpoint_sha256":result.get("checkpoint_sha256") if result else None}))
except (ValueError,ImportError,RuntimeError,OSError) as exc:
failed.add(node.node_id); limitations.append(str(exc))
findings.append({"text":str(exc),"evidence_ids":[],"status":"insufficient_evidence"})
trace.append(TraceStep(step=len(trace)+1,component=node.tool,action=node.node_id,status="unavailable",duration_ms=round((time.perf_counter()-tick)*1000),details={"message":str(exc)}))
emit("Collecting evidence and preparing your answer")
limitations=list(dict.fromkeys(limitations+quality.warnings))
if clarification:
answer=clarification; status=VerdictStatus.INSUFFICIENT_EVIDENCE
elif quality.blockers:
answer=" ".join(quality.blockers); status=VerdictStatus.INVALID_INPUT
elif plan.task==TaskType.UNSUPPORTED:
answer="This request is outside the supported satellite-image analysis tasks."; status=VerdictStatus.UNSUPPORTED_TASK
else:
answer=" ".join(f["text"] for f in findings) or "No evidence was produced for this request."
status=(VerdictStatus.SUPPORTED if user_coordinates is not None and evidence else
VerdictStatus.SUPPORTED_WITH_LIMITATIONS if evidence else VerdictStatus.INSUFFICIENT_EVIDENCE)
if any(r.get("count_reliability") == "COUNT_UNRELIABLE" or
(r.get("benchmark_f1") is not None and r["benchmark_f1"]<.6) for r in results.values()):
status=VerdictStatus.LOW_CONFIDENCE
if any((r.get("aerial_water_agreement") or {}).get("withheld_fraction", 0) > .5
for r in results.values()):
status=VerdictStatus.LOW_CONFIDENCE
if any(r.get("evidence_state") == "INSUFFICIENT_EVIDENCE" for r in results.values()):
status=(VerdictStatus.INSUFFICIENT_EVIDENCE if plan.task == TaskType.WATER_ANALYSIS
else VerdictStatus.LOW_CONFIDENCE)
breakdown=confidence_breakdown(quality,evidence)
verdict=GeoVerdict(status=status,answer=answer,confidence=breakdown.final_score,
confidence_kind="authoritative_user_input" if user_coordinates is not None and evidence else "uncalibrated_evidence_strength",
limitations=limitations,confidence_breakdown=breakdown)
# List canonical downloadable outputs before serialization, so JSON/PDF/API agree.
add_path(output_dir/"GeoProof_Report.pdf"); add_path(output_dir/"analysis.json")
response=AnalysisResponse(result_id=result_id,query=query,generated_at=datetime.now(UTC).isoformat(),task_plan=plan,
mode="automatic_evidence_analysis",assets=metadata,quality=quality,evidence=evidence,verdict=verdict,trace=trace,
artifacts=artifacts,input_configuration=configuration,findings=findings,statistics=statistics,models_used=list(dict.fromkeys(models)),
clarification=clarification,report_id=result_id,timings={"analysis_ms":round((time.perf_counter()-started)*1000)})
emit("Creating your report")
await asyncio.to_thread(write_pdf_report,output_dir,response.model_dump(mode="json"))
await asyncio.to_thread(write_manifest,output_dir,response.model_dump(mode="json"))
return response
|