satdetect-dev / scripts /filter_regions.py
coderuday21's picture
Cursor
Deploy satdetect-dev with Priyanka audit/exception logs.
d70361b
Raw
History Blame Contribute Delete
7.83 kB
"""
Post-process detected change regions to cut false-positive "hallucinations"
WITHOUT re-running the model. Operates purely on the saved region list
(objectType / confidence / area), so it is CPU-trivial and safe on any
machine — no imagery load, no deep model.
Motivation (DDA Grid_54 vs H43X2E1 reports, Jul 2026): of 60 detected
regions, ~25-30 were "Unclassified Ground Change" at 24-50% confidence,
clustered in vegetation — these are the visible hallucinations. Dropping
low-confidence unclassified regions removes most of them while keeping the
confident Vegetation / New Construction / Demolition detections.
Input sources (pick one):
--in regions.json a JSON list of region dicts (or {"regions": [...]})
--run-id N read regions from the app DB (data/satellite_app.db)
Filters (all optional, combine freely):
--min-confidence 0.0 global confidence floor (0-1)
--min-area 0 global minimum area in pixels
--type-min-conf "Unclassified Ground Change=0.55,Other=0.55"
per-objectType confidence floor — the main
hallucination lever. Type names match the
engine's objectType strings; matching is
case-insensitive substring.
--drop-types "..." comma list of objectType substrings to drop entirely
Output:
--out filtered.json write the filtered region list (default: print summary only)
--apply-to-run (with --run-id) write filtered regions back to that
DB run. OFF by default — this mutates stored app
data, so it must be requested explicitly.
Examples:
# Preview what a 55% floor on unclassified regions would remove, from a JSON export
python scripts/filter_regions.py --in regions.json \\
--type-min-conf "Unclassified Ground Change=0.55"
# Same, reading a real run from the DB, writing the cleaned list to a file
python scripts/filter_regions.py --run-id 37 \\
--type-min-conf "Unclassified Ground Change=0.55,Other=0.55" \\
--min-area 1500 --out runs/filtered_run37.json
"""
import argparse
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
def _load_from_json(path: Path) -> list:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict):
data = data.get("regions", [])
if not isinstance(data, list):
raise SystemExit(f"{path} does not contain a region list or {{'regions': [...]}}")
return data
def _load_from_run(run_id: int) -> tuple:
"""Return (regions, run) read from the app DB. Read-only."""
from app.database import SessionLocal
from app.models import DetectionRun
db = SessionLocal()
run = db.query(DetectionRun).filter(DetectionRun.id == run_id).first()
if run is None:
db.close()
raise SystemExit(f"No DetectionRun with id={run_id} in the database.")
regions = json.loads(run.regions_json or "[]")
return regions, run, db
def _parse_type_floors(raw: str) -> list:
"""'A=0.55,B=0.6' -> [('a', 0.55), ('b', 0.6)] (lowercased substrings)."""
out = []
for part in raw.split(","):
part = part.strip()
if not part or "=" not in part:
continue
name, val = part.rsplit("=", 1)
out.append((name.strip().lower(), float(val)))
return out
def _summary_by_type(regions: list) -> dict:
counts = {}
for r in regions:
t = r.get("objectType", "unknown")
counts[t] = counts.get(t, 0) + 1
return dict(sorted(counts.items(), key=lambda kv: -kv[1]))
def apply_filters(regions, min_conf, min_area, type_floors, drop_types):
"""Return (kept, removed) region lists. Pure, no side effects."""
drop_types_l = [d.strip().lower() for d in drop_types if d.strip()]
kept, removed = [], []
for r in regions:
obj = str(r.get("objectType", "")).lower()
conf = float(r.get("confidence", 0.0))
area = float(r.get("area", 0))
reason = None
if any(d in obj for d in drop_types_l):
reason = "dropped-type"
elif conf < min_conf:
reason = f"conf<{min_conf}"
elif area < min_area:
reason = f"area<{min_area}"
else:
for name_sub, floor in type_floors:
if name_sub in obj and conf < floor:
reason = f"'{name_sub}' conf<{floor}"
break
if reason:
rr = dict(r)
rr["_removedReason"] = reason
removed.append(rr)
else:
kept.append(r)
return kept, removed
def main():
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
src = parser.add_mutually_exclusive_group(required=True)
src.add_argument("--in", dest="in_path", default="", help="JSON file of regions")
src.add_argument("--run-id", type=int, default=None, help="read regions from the app DB")
parser.add_argument("--min-confidence", type=float, default=0.0)
parser.add_argument("--min-area", type=float, default=0.0)
parser.add_argument("--type-min-conf", default="",
help='per-type confidence floors, e.g. "Unclassified Ground Change=0.55"')
parser.add_argument("--drop-types", default="",
help="comma list of objectType substrings to drop entirely")
parser.add_argument("--out", default="", help="write filtered regions to this JSON file")
parser.add_argument("--apply-to-run", action="store_true",
help="(with --run-id) write filtered regions back to the DB run")
args = parser.parse_args()
db = run = None
if args.run_id is not None:
regions, run, db = _load_from_run(args.run_id)
source = f"DB run #{args.run_id} ({run.title!r})"
else:
regions = _load_from_json(Path(args.in_path))
source = args.in_path
type_floors = _parse_type_floors(args.type_min_conf)
drop_types = [d for d in args.drop_types.split(",") if d.strip()]
kept, removed = apply_filters(
regions, args.min_confidence, args.min_area, type_floors, drop_types)
print(f"Source: {source}")
print(f"Regions in: {len(regions)}")
print(f"Regions kept: {len(kept)} removed: {len(removed)}\n")
print("By type — before:")
for t, n in _summary_by_type(regions).items():
print(f" {t:32s} {n}")
print("\nBy type — after:")
for t, n in _summary_by_type(kept).items():
print(f" {t:32s} {n}")
if removed:
print(f"\nRemoved {len(removed)} region(s). Reason breakdown:")
reasons = {}
for r in removed:
reasons[r["_removedReason"]] = reasons.get(r["_removedReason"], 0) + 1
for reason, n in sorted(reasons.items(), key=lambda kv: -kv[1]):
print(f" {reason:32s} {n}")
if args.out:
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
Path(args.out).write_text(json.dumps(kept, indent=2), encoding="utf-8")
print(f"\nWrote {len(kept)} kept region(s) to {args.out}")
if args.apply_to_run:
if run is None:
raise SystemExit("--apply-to-run requires --run-id")
run.regions_json = json.dumps(kept)
run.regions_count = len(kept)
db.commit()
print(f"\nApplied: DB run #{args.run_id} now has {len(kept)} regions "
f"(was {len(regions)}). Re-generate its report to see the cleaned result.")
if db is not None:
db.close()
if __name__ == "__main__":
main()