coderuday21 Cursor commited on
Commit
d70361b
Β·
0 Parent(s):

Deploy satdetect-dev with Priyanka audit/exception logs.

Browse files

Orphan snapshot for Hugging Face 10 MiB limit. Includes dashboard, RBAC, and library load optimizations for online testing.

Co-authored-by: Cursor <cursoragent@cursor.com>

This view is limited to 50 files because it contains too many changes. Β  See raw diff
.claude/launch.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "0.0.1",
3
+ "configurations": [
4
+ {
5
+ "name": "change-detection-dev",
6
+ "runtimeExecutable": "venv/Scripts/python.exe",
7
+ "runtimeArgs": ["run.py"],
8
+ "port": 8000
9
+ }
10
+ ]
11
+ }
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__
2
+ *.pyc
3
+ .git
4
+ .gitignore
5
+ data/satellite_app.db
6
+ data/overlays/*
7
+ *.md
8
+ .env
.env.example ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copy to .env and uncomment values as needed (optional for local dev).
2
+ # python run.py sets APP_MODE=dda automatically.
3
+
4
+ # APP_MODE=dda
5
+ # REQUIRED for stable login sessions (including in production): without this,
6
+ # a random secret is generated per process start, invalidating every existing
7
+ # session/cookie on each restart. Generate one with:
8
+ # python -c "import secrets; print(secrets.token_hex(32))"
9
+ # SECRET_KEY=change-me-to-a-long-random-string
10
+
11
+ # Database (default: SQLite in data/satellite_app.db)
12
+ # DATABASE_URL=sqlite:///./data/satellite_app.db
13
+ #
14
+ # MySQL (production for this project): install the driver first
15
+ # (pip install pymysql, already in requirements.txt), then set:
16
+ # DATABASE_URL=mysql+pymysql://user:password@host:3306/dbname
17
+ # URL-encode special characters in the password (e.g. @ -> %40).
18
+ #
19
+ # PostgreSQL also supported:
20
+ # DATABASE_URL=postgresql://user:pass@host/db
21
+
22
+ # Local image library folder (default: library_sources/)
23
+ # LOCAL_LIBRARY_ROOT=C:/path/to/your/images
24
+
25
+ # Image upload limit in MB (default 15360 = 15 GB)
26
+ # MAX_GEOTIFF_MB=15360
27
+ # MAX_IMAGE_MB=15360
28
+
29
+ # Automatic mode: identified same-area pairs can be queued on a cadence.
30
+ # The queue does NOT start when the app launches. On the Automatic tab, set
31
+ # interval (days) and run-at time (IST), then click Start. Manual mode is
32
+ # still click-to-run only. The local server must stay running while the
33
+ # schedule is armed. Set AUTO_DETECT_ENABLED=0 to hide/disable the feature.
34
+ # AUTO_DETECT_ENABLED=1
35
+ # AUTO_DETECT_INTERVAL_DAYS=10
36
+ # AUTO_DETECT_RUN_AT=02:00
37
+
38
+ # Max pixel dimension for detection (lower = less RAM, default 4096 local)
39
+ # DETECTION_MAX_SIDE=4096
40
+
41
+ # --- Accuracy controls (all optional; defaults preserve current behavior) ---
42
+ # Inference mode: downscaled | fullres_tiled | auto
43
+ # auto = fullres_tiled when before/after paths are GeoTIFF (RCA Critical #1)
44
+ # DETECTION_INFERENCE_MODE=auto
45
+ # Full-res cap (px) in fullres_tiled mode; 0 = native resolution
46
+ # DETECTION_FULLRES_MAX_SIDE=8192
47
+ # Native side (px) above which GeoTIFFs stream from disk windows (avoids OOM)
48
+ # Default lowered 8192β†’4096 (RCA Significant #5)
49
+ # DETECTION_WINDOWED_THRESHOLD=4096
50
+ # Soft RAM budget (MB) per in-memory array before switching to windowed streaming
51
+ # DETECTION_TILE_MEMORY_MB=1536
52
+ # Tile size / overlap for full-res scoring (overlap default 0.35 β€” RCA #6 seams)
53
+ # DETECTION_TILE_SIZE=512
54
+ # DETECTION_TILE_OVERLAP=0.35
55
+ # Skip SIFT registration on GPS-aligned GeoTIFF pairs: true|false|auto (RCA #4)
56
+ # DETECTION_SKIP_REGISTRATION_GEOTIFF=auto
57
+ # Local Delhi fine-tuned AdaptFormer (v3 frozen = Test F1 0.581 @ thr 0.2).
58
+ # Relative paths are resolved against the repo root (not the process CWD).
59
+ # ADAPTFORMER_WEIGHTS=models/adaptformer_delhi/v3_frozen
60
+ # Val-calibrated DL threshold (alias: DETECTION_DL_THRESHOLD). v3 = 0.2
61
+ # ADAPTFORMER_THRESHOLD=0.2
62
+ # DETECTION_DL_THRESHOLD=0.2
63
+ # Multi-scale DL fusion: off or a comma list, e.g. 0.5,1.0,1.5
64
+ # DETECTION_MULTISCALE=off
65
+ # Fusion strategy: smart_union | hysteresis | dl_only
66
+ # v3 ablation (runs/v3_app_ablation): dl_only @ 0.2 β‰ˆ Test F1 0.58;
67
+ # smart_union/hysteresis collapsed F1 to ~0.04 β€” prefer dl_only with v3.
68
+ # DETECTION_FUSION=dl_only
69
+ # TTA: off | hflip | full | auto. Ablation: hflip β‰ˆ same F1, ~2x slower on CPU.
70
+ # DETECTION_TTA=off
71
+ # Preprocessing toggles
72
+ # DETECTION_CLAHE=true
73
+ # DETECTION_HIST_MATCH=false
74
+ # DETECTION_SKIP_PREBLUR= # auto: on in fullres_tiled mode
75
+ # Border pixels zeroed in mask cleanup (auto: 4 fullres / 12 downscaled)
76
+ # DETECTION_BORDER_MARGIN=
77
+ # Tiles per GPU forward pass (1 = no batching)
78
+ # DETECTION_TILE_BATCH=1
79
+ # Save a downsampled probability-map PNG per run for debugging
80
+ # DETECTION_SAVE_PROB_MAP=false
81
+ # Skip the deep model entirely on tiles that are fully unchanged between before/
82
+ # after (worst-cell LAB distance below this value β€” biggest single cell diff,
83
+ # not an average, so a small real change is never diluted away and missed).
84
+ # Big speed win on large images where most of the scene is static background;
85
+ # 0/unset = disabled (default). Start conservative (e.g. 3-5).
86
+ # DETECTION_SKIP_UNCHANGED_THRESHOLD=0
87
+
88
+ # --- smart_union fusion calibration (Delhi accuracy sprint, Day 4-6) ---
89
+ # Base classical-score percentile floor for smart_union fusion. Tuned from
90
+ # 0.92 -> 0.90 against real Delhi Sentinel-2 imagery: +53% mean F1 on the
91
+ # 16-pair labeled eval set, verified to pass the full synthetic regression
92
+ # suite (incl. the mandatory car-FP gate) unchanged. Lower values (0.75-0.85)
93
+ # scored even higher on Delhi but were REJECTED β€” they cause false positives
94
+ # on the parked_cars synthetic case. See runs/calibration/best_params.json
95
+ # for the full decision record (winning + rejected configs, with reasons).
96
+ # DETECTION_CL_Q_BASE=0.90
97
+ # Base DL confidence floor for smart_union fusion (default 0.36). Tested
98
+ # down to 0.10 against Delhi imagery β€” no meaningful effect at full-set scale
99
+ # (the pretrained AdaptFormer model's near-zero output on this coarse-GSD,
100
+ # out-of-domain imagery isn't a thresholding problem; needs fine-tuning, not
101
+ # calibration). Left at default. See runs/calibration/best_params.json.
102
+ # DETECTION_DL_FLOOR_BASE=0.15
103
+
104
+ # --- Research-integration channels (KPCAMNet / IR-MAD / SRCDNet / BIT_CD) ---
105
+ # KPCA deep features (KPCAMNet): auto (default; drives Feature-Based method),
106
+ # on (also adds a channel to the classical score map), off
107
+ # DETECTION_KPCA=auto
108
+ # Analysis-resolution cap for KPCA feature extraction (CPU bound)
109
+ # DETECTION_KPCA_MAX_SIDE=768
110
+ # IR-MAD change statistic: auto (default) | on (adds classical channel) | off
111
+ # DETECTION_IRMAD=auto
112
+ # Regression radiometric normalization from IR-MAD no-change pixels
113
+ # (off by default: reduced F1 on the LEVIR-CD gate; useful for strong
114
+ # sensor/illumination mismatch between acquisitions)
115
+ # DETECTION_IRMAD_NORM=false
116
+ # Resample GeoTIFF pairs to a common (coarser) GSD before detection
117
+ # DETECTION_GSD_HARMONIZE=true
118
+ # Relative GSD difference that triggers harmonization/preflight warning
119
+ # DETECTION_GSD_TOLERANCE=0.15
120
+ # Preflight overlap gate: below this fraction of the after-image footprint,
121
+ # a pair is rejected as not suitable for change detection (hard fail)
122
+ # DETECTION_MIN_OVERLAP_HARD=0.02
123
+ # Below this fraction, overlap is weak but detection still runs (soft warning)
124
+ # DETECTION_MIN_OVERLAP_WARN=0.20
125
+ # Ensemble AdaptFormer with BIT_CD (requires weights, see below; default off)
126
+ # DETECTION_ENSEMBLE=off
127
+ # BIT_CD LEVIR-CD checkpoint path. Download best_ckpt.pt from the BIT_CD
128
+ # repo's release links (https://github.com/justchenhao/BIT_CD) and place it at
129
+ # app/cd_models/weights/bit_cd_levir.pth (raw training checkpoints with a
130
+ # model_G_state_dict key are accepted as-is).
131
+ # BIT_CD_WEIGHTS=app/cd_models/weights/bit_cd_levir.pth
132
+
133
+ # Public URL for report links in emails (default http://localhost:8000 locally)
134
+ # PUBLIC_BASE_URL=http://localhost:8000
135
+
136
+ # Email notifications (optional). Password reset still uses this backend.
137
+ # EMAIL_API_URL=https://emailservice.managemybusinessess.com/api/email/send
138
+ # Login SMS OTP: POST ToMobile/Message to SMS_API_URL (default: same host as
139
+ # EMAIL_API_URL with /api/sms/send). Or set SMS_PROVIDER=fast2sms + SMS_API_KEY.
140
+ # SMS_API_URL=
141
+ # SMS_PROVIDER=
142
+ # SMS_API_KEY=
143
+ # LOGIN_OTP_LOG=1
144
+ # SMTP_HOST=smtp.gmail.com
145
+ # SMTP_PORT=587
146
+ # SMTP_USER=
147
+ # SMTP_PASS=
148
+
149
+ # Hugging Face model cache directory
150
+ # HF_HOME=./.hf_cache
151
+
152
+ # Department export API (optional β€” FR-08)
153
+ # DEPT_API_URL=https://dept.example.gov/api/changes
154
+ # DEPT_API_KEY=your-api-key
155
+
156
+ # DDA admin account (optional β€” UAT / ops)
157
+ # DDA_ADMIN_EMAIL=admin@example.com
158
+ # DDA_ADMIN_PASSWORD=change-me
159
+ # DDA_TRAINING_EXPORT_KEY=secret-key-for-fp-export
.gitignore ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ data/satellite_app.db
5
+ data/overlays/*
6
+ !data/overlays/.gitkeep
7
+ data/library_cache/
8
+ # Local library images (too large for git β€” keep on disk only)
9
+ library_sources/**/*.tif
10
+ library_sources/**/*.tiff
11
+ library_sources/**/*.TIF
12
+ library_sources/**/*.TIFF
13
+ data/library_sources/**/*.tif
14
+ data/library_sources/**/*.tiff
15
+ data/library_sources/**/*.TIF
16
+ data/library_sources/**/*.TIFF
17
+ data/library_sources/**/*.png
18
+ data/library_sources/**/*.jpg
19
+ data/library_sources/**/*.jpeg
20
+ !library_sources/**/.gitkeep
21
+ .env
22
+ *.egg-info/
23
+ dist/
24
+ build/
25
+ venv/
26
+ # Generated eval/calibration outputs (masks, leaderboards, metrics)
27
+ runs/
28
+ # Large fine-tuned weights (export locally via scripts/export_adaptformer_delhi.py)
29
+ models/adaptformer_delhi/best/
30
+ models/adaptformer_delhi/*.pt
31
+ *.safetensors
32
+ # Local label backup from merge conflicts
33
+ docs/delhi_eval/labels/_local_backup_*/
34
+ # WHU reference dataset (real-data pipeline test set, not Delhi imagery β€” see docs/whu_reference/)
35
+ data/whu_reference/
36
+ data/large_geotiff_test/
37
+ data/large_geotiff_test.zip
38
+ # Third-party CD clones + DSIFN Drive downloads (hundreds of MB)
39
+ third_party/
40
+ models/dsifn_proxy/*.pt
41
+ # Building-footprint reference data (461MB geojson β€” exceeds GitHub's 100MB limit)
42
+ data/building_footprints/
43
+ # Priyanka synthetic CD triplets (before/after/mask PNGs β€” local training only)
44
+ data/synthetic_cd/
.vscode/launch.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ // Use IntelliSense to learn about possible attributes.
3
+ // Hover to view descriptions of existing attributes.
4
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5
+ "version": "0.2.0",
6
+ "configurations": [
7
+ {
8
+ "name": "PowerShell: Launch Current File",
9
+ "type": "PowerShell",
10
+ "request": "launch",
11
+ "script": "${file}",
12
+ "args": []
13
+ }
14
+ ]
15
+ }
AI_Change_Detection_Handover.md ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AI Change Detection β€” Project Handover Document
2
+
3
+ **Prepared by:** Development Team
4
+ **Date:** February 7, 2026
5
+ **Version:** 2.0
6
+ **Live URL:** [Hugging Face Spaces β€” AI Change Detection](https://huggingface.co/spaces/coderuday21/satdetect)
7
+
8
+ ---
9
+
10
+ ## 1. Executive Summary
11
+
12
+ AI Change Detection is a web-based application that automatically detects, classifies, and visualizes changes between two satellite or aerial images taken at different times. Users upload a "before" and "after" image, and the system identifies what changed on the ground β€” new buildings, deforestation, road construction, demolitions, water body changes, and more.
13
+
14
+ The application provides detailed reports including change statistics, object-level classification with sub-type breakdowns, 3D building analysis (estimated height and construction stage), and an interactive visual overlay highlighting all detected changes in red.
15
+
16
+ ---
17
+
18
+ ## 2. Problem Statement
19
+
20
+ Manually comparing satellite imagery to identify ground-level changes is:
21
+
22
+ - **Time-consuming** β€” Analysts spend hours visually scanning large image pairs pixel by pixel.
23
+ - **Error-prone** β€” Human eyes miss subtle changes in vegetation health, gradual construction, or surface texture shifts.
24
+ - **Inconsistent** β€” Different analysts may classify the same change differently.
25
+ - **Unscalable** β€” As the volume of imagery grows (urban planning, environmental monitoring, disaster response), manual review cannot keep pace.
26
+
27
+ **AI Change Detection solves this** by automating the entire detection-to-classification pipeline, delivering consistent, quantified results in seconds.
28
+
29
+ ---
30
+
31
+ ## 3. Target Users
32
+
33
+ | User Role | How They Use It |
34
+ |-----------|----------------|
35
+ | Urban planners | Track unauthorized construction, building expansion, and infrastructure changes |
36
+ | Environmental analysts | Monitor deforestation, vegetation health decline, and land-use changes |
37
+ | Disaster response teams | Assess structural damage after natural disasters by comparing pre/post imagery |
38
+ | Agricultural analysts | Detect crop changes, seasonal variation, and land conversion |
39
+ | Government agencies | Monitor compliance with zoning and environmental regulations |
40
+ | Construction firms | Track construction progress and stage classification over time |
41
+
42
+ ---
43
+
44
+ ## 4. Key Features
45
+
46
+ ### 4.1 Multi-Method Change Detection
47
+
48
+ The system offers four detection methods, each suited to different scenarios:
49
+
50
+ | Method | Best For | Speed |
51
+ |--------|----------|-------|
52
+ | **Image Difference** | Quick scans where changes involve obvious color shifts (e.g., cleared forest) | Fastest |
53
+ | **Feature-Based** | Subtle, distributed changes across large areas (e.g., gradual land-use shift) | Fast |
54
+ | **AI-Based Deep Learning** | Highest accuracy β€” combines color, structure, texture, and edge analysis | Moderate |
55
+ | **Hybrid Approach** | Maximum coverage when unsure which type of changes to expect | Slowest |
56
+
57
+ Users select the method per run depending on their accuracy vs. speed needs.
58
+
59
+ ### 4.2 Intelligent Pre-Processing
60
+
61
+ Before comparing images, the system can automatically:
62
+
63
+ - **Align images** (Image Registration) β€” Corrects for camera angle, zoom, and position differences between the two photos so the comparison is pixel-accurate.
64
+ - **Normalize lighting** (Radiometric Normalization) β€” Corrects for brightness/contrast differences caused by weather, time of day, or different cameras, preventing false positives from lighting alone.
65
+
66
+ Both options are user-toggleable per run.
67
+
68
+ ### 4.3 Ground-Level Change Classification
69
+
70
+ Every detected change region is automatically classified into one of six categories:
71
+
72
+ | Category | What It Detects |
73
+ |----------|----------------|
74
+ | **New Construction / Building** | New structures appearing on previously empty land |
75
+ | **Demolition / Clearing** | Structures or vegetation that have been removed |
76
+ | **Vegetation Change** | Any change in plant cover β€” forests, crops, parks |
77
+ | **Water Body Change** | New, expanded, or reduced water features |
78
+ | **Road / Pavement Change** | New roads, widened paths, resurfaced areas |
79
+ | **Bare Land / Soil Change** | Exposed earth, grading, land preparation |
80
+
81
+ **Transient objects are automatically filtered out** β€” people, vehicles, animals, and shadows are excluded so only permanent ground-level changes are reported.
82
+
83
+ ### 4.4 Detailed Sub-Classification
84
+
85
+ Each primary category is further broken down into specific sub-types by comparing the "before" and "after" regions:
86
+
87
+ **Vegetation Sub-Types:**
88
+
89
+ | Sub-Type | What It Means |
90
+ |----------|---------------|
91
+ | Deforestation / Tree Removal | Green area has been cleared β€” trees or vegetation removed |
92
+ | New Vegetation / Growth | Previously bare area now has plant cover |
93
+ | Crop / Agricultural Change | Farmland has changed crop type or land use pattern |
94
+ | Vegetation Health Decline | Plants are browning or showing signs of drought/disease |
95
+ | Seasonal Variation | Normal seasonal color shift, both periods still green |
96
+
97
+ **Structural Sub-Types:**
98
+
99
+ | Sub-Type | What It Means |
100
+ |----------|---------------|
101
+ | New Building | Structure appeared where none existed before |
102
+ | Building Expansion | Existing building has been extended or enlarged |
103
+ | Renovation / Modification | Building exists in both images but has changed appearance |
104
+ | Partial Demolition | Part of a structure has been removed |
105
+ | Full Demolition | Entire structure has been removed, area is now bare |
106
+ | Infrastructure Change | Non-building structures (bridges, towers, utilities) |
107
+
108
+ **Road Sub-Types:**
109
+
110
+ | Sub-Type | What It Means |
111
+ |----------|---------------|
112
+ | New Road / Pavement | Road appeared where none existed before |
113
+ | Road Widening | Existing road has been expanded |
114
+ | Road Resurfacing | Road surface has been repaved (different appearance, same width) |
115
+ | Road Deterioration | Road surface has degraded |
116
+
117
+ ### 4.5 3D Building Analysis
118
+
119
+ For regions classified as buildings or construction, the system provides:
120
+
121
+ - **Estimated Stories** β€” Number of floors, calculated from shadow analysis and building footprint geometry.
122
+ - **Estimated Height** β€” Height in meters (based on 3m per story).
123
+ - **Construction Stage** β€” Classified as Foundation, Structural, Under Construction, or Complete based on visual features.
124
+
125
+ ### 4.6 Interactive Visual Output
126
+
127
+ - **Red overlay** β€” All detected changes are highlighted in red on the "after" image.
128
+ - **Before/After slider** β€” A draggable comparison slider lets users visually inspect what changed by sliding between the original and the annotated result.
129
+ - **Annotated bounding boxes** β€” Each detected region gets a labeled bounding box showing its sub-type, stories, and construction stage.
130
+
131
+ ### 4.7 User Accounts & History
132
+
133
+ - **Secure login system** β€” Email/password registration with encrypted password storage.
134
+ - **Detection history** β€” All past runs are saved with their results, overlay images, and metadata. Users can revisit or delete previous runs.
135
+ - **Password reset** β€” Users can reset their password from the login screen.
136
+
137
+ ### 4.8 Responsive Design
138
+
139
+ The application is fully functional on desktop browsers and mobile devices, with an optimized layout for smaller screens.
140
+
141
+ ---
142
+
143
+ ## 5. Technology Stack
144
+
145
+ | Layer | Technology | Purpose |
146
+ |-------|-----------|---------|
147
+ | **Frontend** | HTML, CSS, JavaScript (Vanilla) | Single-page application β€” no framework dependencies |
148
+ | **Backend** | Python, FastAPI | REST API server handling authentication and detection |
149
+ | **Image Processing** | OpenCV, NumPy, Scikit-learn, Pillow | Core algorithms for detection, classification, and visualization |
150
+ | **Database** | SQLite (local), PostgreSQL (production-ready) | User accounts and detection run history |
151
+ | **Authentication** | JWT tokens, bcrypt password hashing | Secure stateless authentication |
152
+ | **Deployment** | Docker, Hugging Face Spaces | Containerized cloud deployment |
153
+
154
+ ---
155
+
156
+ ## 6. Application Screens
157
+
158
+ ### Login / Registration
159
+ - Clean login form with email and password
160
+ - Password visibility toggle (eye icon)
161
+ - "Forgot password" option
162
+ - Toggle between sign-in and registration
163
+
164
+ ### Dashboard (Main Screen)
165
+ - **Upload Section** β€” Two drag-and-drop zones for before and after images with live previews
166
+ - **Configuration** β€” Method selector, run title, and optional pre-processing toggles
167
+ - **Results Section** β€” Appears after running detection:
168
+ - Statistics bar: change percentage, changed pixels, total pixels, region count
169
+ - Before/after comparison slider
170
+ - Detailed table of all detected regions with type, sub-type, confidence, area, building data, and coordinates
171
+ - **History Section** β€” List of all past runs with option to view overlay or delete
172
+
173
+ ---
174
+
175
+ ## 7. Output Data Per Detection Run
176
+
177
+ Each run produces the following data:
178
+
179
+ | Field | Description |
180
+ |-------|-------------|
181
+ | Change Percentage | Proportion of the image area that changed |
182
+ | Changed Pixels | Total number of pixels detected as changed |
183
+ | Total Pixels | Total image area in pixels |
184
+ | Regions Count | Number of distinct change regions identified |
185
+ | **Per Region:** | |
186
+ | Change Type | Primary category (Construction, Vegetation, etc.) |
187
+ | Sub-Type | Detailed sub-classification (New Building, Deforestation, etc.) |
188
+ | Confidence | Classification confidence score (0–100%) |
189
+ | Area | Region size in pixels |
190
+ | Estimated Stories | Number of building floors (building regions only) |
191
+ | Estimated Height | Height in meters (building regions only) |
192
+ | Construction Stage | Foundation / Structural / Under Construction / Complete (building regions only) |
193
+ | Center Coordinates | Location of the region center in the image |
194
+ | Overlay Image | Annotated image with red highlights and bounding boxes |
195
+
196
+ ---
197
+
198
+ ## 8. Deployment & Access
199
+
200
+ ### Current Deployment
201
+ - **Platform:** Hugging Face Spaces (free tier)
202
+ - **URL:** https://huggingface.co/spaces/coderuday21/satdetect
203
+ - **Container:** Docker-based, auto-deploys on code push
204
+ - **Database:** SQLite (ephemeral β€” resets when the container restarts)
205
+
206
+ ### Limitations of Current Deployment
207
+ | Limitation | Impact | Mitigation Path |
208
+ |-----------|--------|-----------------|
209
+ | Ephemeral database | User accounts and history are lost on container restart | Migrate to external PostgreSQL database |
210
+ | No email verification on password reset | Anyone who knows an email can reset the password | Implement token-based email verification flow |
211
+ | Single-worker server | Cannot handle high concurrent load | Scale to multiple workers with PostgreSQL |
212
+ | Free-tier resources | Limited CPU/memory, images capped at 2000px and 20MB | Upgrade to paid hosting for larger images |
213
+
214
+ ### For Production Deployment
215
+ To move from the current demo to a production environment, the following would be needed:
216
+ 1. External PostgreSQL database for persistent storage
217
+ 2. Email service integration (SendGrid, AWS SES) for password reset verification
218
+ 3. Paid cloud hosting (AWS, GCP, or Azure) for better performance
219
+ 4. HTTPS with a custom domain
220
+ 5. Environment-based secret key management
221
+
222
+ ---
223
+
224
+ ## 9. How Accuracy Works
225
+
226
+ The system does **not** use a pre-trained deep learning model (no neural network). Instead, it uses a **multi-signal fusion approach** β€” combining multiple independent analysis methods that each measure a different aspect of change:
227
+
228
+ 1. **Color difference** β€” Measures how much the color changed at each pixel (in perceptually uniform color space).
229
+ 2. **Structural similarity** β€” Measures whether the patterns and structures at each pixel changed.
230
+ 3. **Texture analysis** β€” Measures whether the surface texture changed (smooth β†’ rough, etc.).
231
+ 4. **Edge detection** β€” Measures whether structural boundaries appeared or disappeared.
232
+
233
+ These four signals are weighted by their discriminative power and fused into a single change map. This approach is:
234
+
235
+ - **Transparent** β€” Every decision can be traced to specific visual features (no "black box").
236
+ - **Deterministic** β€” The same inputs always produce the same outputs.
237
+ - **No training data required** β€” Works on any satellite/aerial imagery without needing labeled datasets.
238
+
239
+ **Trade-off:** A trained deep learning model (e.g., U-Net, Siamese network) would likely achieve higher accuracy on specific datasets, but would require labeled training data and GPU resources. The current approach provides good accuracy out-of-the-box for general-purpose change detection.
240
+
241
+ ---
242
+
243
+ ## 10. Future Enhancement Opportunities
244
+
245
+ | Enhancement | Business Value |
246
+ |-------------|---------------|
247
+ | Deep learning model integration (U-Net / Siamese CNN) | Significantly higher detection accuracy on trained domains |
248
+ | Batch processing / multi-image upload | Analyze time-series imagery for trend analysis |
249
+ | GIS integration (GeoTIFF, coordinate mapping) | Pin changes to real-world geographic coordinates |
250
+ | PDF/Excel report export | Downloadable reports for stakeholder distribution |
251
+ | Webhook / API integration | Automate detection as part of larger monitoring pipelines |
252
+ | Role-based access (admin, analyst, viewer) | Team collaboration with permission controls |
253
+ | Notification system | Alert stakeholders when significant changes are detected |
254
+ | Cloud-optimized image storage | Handle very large satellite tiles (10,000+ pixels) |
255
+
256
+ ---
257
+
258
+ ## 11. Repository Structure
259
+
260
+ ```
261
+ change_detection_webapp/
262
+ β”œβ”€β”€ app/
263
+ β”‚ β”œβ”€β”€ main.py β†’ API server and route definitions
264
+ β”‚ β”œβ”€β”€ auth.py β†’ Authentication (JWT, password hashing)
265
+ β”‚ β”œβ”€β”€ models.py β†’ Database table definitions
266
+ β”‚ β”œβ”€β”€ database.py β†’ Database connection setup
267
+ β”‚ └── detection_engine.py β†’ All image processing and classification algorithms
268
+ β”œβ”€β”€ templates/
269
+ β”‚ └── index.html β†’ Frontend page structure
270
+ β”œβ”€β”€ static/
271
+ β”‚ β”œβ”€β”€ css/style.css β†’ All visual styling
272
+ β”‚ └── js/app.js β†’ Client-side logic
273
+ β”œβ”€β”€ Dockerfile β†’ Container build instructions
274
+ β”œβ”€β”€ requirements.txt β†’ Python package dependencies
275
+ └── ARCHITECTURE.md β†’ Technical architecture reference (for developers)
276
+ ```
277
+
278
+ ---
279
+
280
+ *For technical implementation details, refer to `ARCHITECTURE.md` in the project repository.*
ARCHITECTURE.md ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AI Change Detection β€” Architecture & Component Guide
2
+
3
+ ## Overview
4
+
5
+ AI Change Detection is a full-stack web application that detects, classifies, and visualizes changes between two satellite or aerial images (before and after). It uses multi-signal image processing β€” combining color analysis, structural similarity, texture descriptors, and edge detection β€” to identify ground-level changes such as new buildings, deforestation, road construction, and more.
6
+
7
+ ---
8
+
9
+ ## System Architecture
10
+
11
+ ```
12
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
13
+ β”‚ Browser (SPA) β”‚
14
+ β”‚ index.html + style.css + app.js β”‚
15
+ β”‚ Login / Register / Upload / Results / History β”‚
16
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
17
+ β”‚ REST API (JSON + multipart)
18
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
19
+ β”‚ FastAPI Backend (main.py) β”‚
20
+ β”‚ Auth routes Β· Detection route Β· History β”‚
21
+ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
22
+ β”‚ auth.py β”‚ models.pyβ”‚ detection_engine.py β”‚
23
+ β”‚ JWT auth β”‚ ORM β”‚ Image processing pipeline β”‚
24
+ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚
25
+ β”‚ database.py β”‚ SQLite (local) / MySQL β”‚
26
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
27
+ ```
28
+
29
+ ---
30
+
31
+ ## File-by-File Component Breakdown
32
+
33
+ ### 1. `app/main.py` β€” API Server & Route Definitions
34
+
35
+ **Role:** The central FastAPI application. Defines all HTTP endpoints, wires together authentication, database, and the detection engine.
36
+
37
+ **Use cases solved:**
38
+ - **User registration & login** (`POST /api/auth/register`, `POST /api/auth/login`) β€” Creates accounts and issues JWT tokens stored as HTTP-only cookies.
39
+ - **Session management** (`POST /api/auth/logout`, `GET /api/me`) β€” Clears auth cookies; returns the current authenticated user.
40
+ - **Password reset** (`POST /api/auth/reset-password`) β€” Allows users to reset their password by email (no email verification β€” documented limitation).
41
+ - **Change detection** (`POST /api/detect`) β€” Accepts two images (before/after) plus configuration options, runs the full detection pipeline, saves the overlay image to disk, stores run metadata in the database, and returns results with a base64-encoded overlay for immediate display.
42
+ - **History** (`GET /api/history`, `DELETE /api/history/{id}`) β€” Lists past detection runs for the logged-in user; allows deletion of individual runs and their overlay files.
43
+ - **Static file serving** β€” Mounts the `static/` directory for CSS/JS and serves `templates/index.html` as the SPA entry point.
44
+ - **Upload size limiting** β€” Rejects images larger than 20 MB to prevent out-of-memory crashes.
45
+
46
+ ---
47
+
48
+ ### 2. `app/auth.py` β€” Authentication & Authorization
49
+
50
+ **Role:** Handles password hashing, JWT token creation/validation, and user lookup.
51
+
52
+ **Use cases solved:**
53
+ - **Secure password storage** β€” Uses bcrypt via `passlib` to hash passwords before storing them. Plaintext passwords never touch the database.
54
+ - **Stateless authentication** β€” Issues HS256 JWT tokens with a 7-day expiry. Tokens are sent as HTTP-only cookies (for browser requests) and also accepted as Bearer tokens in the Authorization header.
55
+ - **Multi-source token resolution** β€” `get_current_user()` checks three sources in order: Authorization header, HTTP cookie, then form field. This ensures authentication works for both API clients and browser multipart form submissions.
56
+
57
+ ---
58
+
59
+ ### 3. `app/models.py` β€” Database Models (ORM)
60
+
61
+ **Role:** Defines the SQLAlchemy ORM models that map to database tables.
62
+
63
+ **Tables:**
64
+ - **`users`** β€” Stores account data: email, bcrypt-hashed password, full name, and creation timestamp. Each user has a one-to-many relationship with detection runs.
65
+ - **`detection_runs`** β€” Stores metadata for each detection run: title, method used, pixel statistics (total, changed, percentage), region count, path to the saved overlay image, and a JSON blob of all classified regions with their sub-types and 3D analysis data.
66
+
67
+ **Use case solved:** Persistent storage of user accounts and detection history so users can revisit past results.
68
+
69
+ ---
70
+
71
+ ### 4. `app/database.py` β€” Database Connection & Session Management
72
+
73
+ **Role:** Configures the SQLAlchemy engine, session factory, and data directory paths.
74
+
75
+ **Use cases solved:**
76
+ - **Environment-aware storage** β€” Detects Hugging Face Spaces (via `SPACE_ID` env var) and uses a writable home directory; falls back to a local `data/` directory for development.
77
+ - **Database portability** β€” Defaults to SQLite for zero-config local development. Production uses MySQL via `DATABASE_URL` (`mysql+pymysql://...`); PostgreSQL is also supported (with automatic `postgres://` β†’ `postgresql://` rewrite for SQLAlchemy 2.x compatibility). `pool_pre_ping` is enabled for any non-SQLite backend so stale/dropped remote connections are transparently recycled.
78
+ - **Session lifecycle** β€” The `get_db()` generator provides request-scoped database sessions with automatic cleanup.
79
+
80
+ ---
81
+
82
+ ### 5. `app/detection_engine.py` β€” Image Processing & Classification Pipeline
83
+
84
+ This is the core of the application. It contains all image analysis algorithms organized into a sequential pipeline.
85
+
86
+ #### 5a. Pre-processing (`preprocess_image`)
87
+
88
+ **Use case:** Normalizes input images to a consistent format before analysis.
89
+ - Converts grayscale or RGBA images to RGB.
90
+ - Downscales images larger than 2000px on any side to prevent memory issues while preserving enough detail for analysis.
91
+
92
+ #### 5b. Image Registration (`register_images`)
93
+
94
+ **Use case:** Aligns the "after" image to the "before" image so pixel-level comparison is meaningful.
95
+ - Uses ORB feature detection to find matching keypoints between both images.
96
+ - Applies Lowe's ratio test to filter spurious matches.
97
+ - Computes a RANSAC homography to warp the after image into alignment.
98
+ - Rejects bad alignments via inlier ratio threshold and homography determinant check (catches degenerate transforms).
99
+ - **Why it matters:** Without alignment, even a slight camera angle difference would cause the entire image to appear "changed."
100
+
101
+ #### 5c. Radiometric Normalization (`normalize_radiometry`)
102
+
103
+ **Use case:** Corrects brightness and contrast differences between the two images caused by different lighting conditions, seasons, or sensors.
104
+ - Performs histogram matching in LAB color space (perceptually uniform) so the "after" image has the same color distribution as the "before."
105
+ - Applies CLAHE (Contrast Limited Adaptive Histogram Equalization) symmetrically to both images to enhance local contrast without introducing bias.
106
+ - **Why it matters:** A cloudy day vs. a sunny day would otherwise create massive false-positive "changes" everywhere.
107
+
108
+ #### 5d. SSIM Change Map (`compute_ssim_change_map`)
109
+
110
+ **Use case:** Measures structural dissimilarity between corresponding regions of both images.
111
+ - Computes per-pixel Structural Similarity Index (SSIM) using Gaussian-weighted windows.
112
+ - Outputs a dissimilarity map where 0 = identical and 1 = completely different.
113
+ - Clamps variance values to prevent numerical instability from floating-point rounding.
114
+ - **Why it matters:** SSIM captures structural patterns (edges, textures) that simple color difference would miss.
115
+
116
+ #### 5e. Texture Features (`compute_lbp`, `compute_texture_change`)
117
+
118
+ **Use case:** Detects changes in surface texture (e.g., smooth road vs. rough construction rubble).
119
+ - Computes Local Binary Patterns (LBP) β€” a texture descriptor that encodes the relationship between each pixel and its neighbors.
120
+ - The absolute difference between before/after LBP maps highlights regions where surface texture changed.
121
+ - **Why it matters:** Two regions can have similar colors but very different textures (e.g., grass vs. artificial turf, bare soil vs. concrete).
122
+
123
+ #### 5f. Edge Change Detection (`compute_edge_change`)
124
+
125
+ **Use case:** Identifies new or removed structural boundaries.
126
+ - Uses Canny edge detection with adaptive thresholds based on median intensity.
127
+ - Dilates edges so nearby edges match (tolerates minor misalignment).
128
+ - Computes the absolute difference to find edges present in one image but not the other.
129
+ - **Why it matters:** New buildings, roads, and demolished structures introduce or remove strong edges.
130
+
131
+ #### 5g. Detection Methods (4 options)
132
+
133
+ | Method | Description | Best For |
134
+ |--------|-------------|----------|
135
+ | **Image Difference** | LAB color space Delta-E difference with Otsu thresholding | Fast, simple comparisons with clear color changes |
136
+ | **Feature-Based** | Multi-space (LAB + HSV) feature clustering via KMeans | Subtle, distributed changes across large areas |
137
+ | **AI-Based Deep Learning** | Multi-signal fusion (color + SSIM + texture + edge) with entropy-weighted adaptive combination | Highest accuracy; catches both color and structural changes |
138
+ | **Hybrid Approach** | Weighted blend of all three methods (20% difference + 30% feature + 50% AI) | Maximum coverage when unsure which method is best |
139
+
140
+ **Image Difference** (`image_difference_method`):
141
+ - Converts to LAB and computes a weighted Delta-E (CIE76) difference per pixel.
142
+ - Applies Otsu's automatic threshold to separate "changed" from "unchanged."
143
+ - Fast but only catches color changes β€” misses structural changes where color stays similar.
144
+
145
+ **Feature-Based** (`feature_based_method`):
146
+ - Extracts LAB and HSV difference features per pixel.
147
+ - Downsamples to 250K pixels for KMeans clustering (4 clusters), then maps labels back to full resolution.
148
+ - The cluster with the highest mean feature magnitude is labeled "change."
149
+ - Good at finding spatially distributed or gradual changes.
150
+
151
+ **AI-Based Deep Learning** (`ai_deep_learning_method`):
152
+ - Fuses four independent signals: multi-scale LAB color difference, SSIM structural dissimilarity, LBP texture change, and Canny edge change.
153
+ - Weights each channel by its information entropy (channels with more discriminative power get higher weight).
154
+ - Applies Otsu thresholding followed by bilateral filtering for edge-preserving smoothing.
155
+ - The most accurate method β€” captures color, structure, texture, and edge changes simultaneously.
156
+
157
+ **Hybrid** (`hybrid_method`):
158
+ - Runs all three methods and combines their masks with fixed weights.
159
+ - AI method gets the most weight (50%) as it's most reliable.
160
+
161
+ #### 5h. Post-processing (`_clean_mask`)
162
+
163
+ **Use case:** Cleans up raw change masks to remove noise and fill gaps.
164
+ - Morphological closing (fills small gaps inside detected regions).
165
+ - Morphological opening (removes small noise speckles).
166
+ - Contour-based hole filling (ensures detected regions are solid).
167
+ - Sensitivity parameter controls the aggressiveness of gap-filling.
168
+
169
+ #### 5i. Visualization (`visualize_changes`)
170
+
171
+ **Use case:** Creates the final overlay image showing detected changes.
172
+ - Draws a semi-transparent red overlay on all changed pixels in the "after" image.
173
+ - Draws white bounding boxes around each classified region.
174
+ - Annotates regions with labels showing sub-type, building stories, and construction stage.
175
+
176
+ #### 5j. Object Classification (`classify_object_type`, `classify_with_ensemble`)
177
+
178
+ **Use case:** Determines what kind of ground-level change each detected region represents.
179
+
180
+ **Feature extraction** (`extract_advanced_features`):
181
+ - Extracts 17 features per region: color stats (RGB, HSV, LAB means/stds), vegetation index (NDVI), texture (LBP variance, standard deviation), edges (density, orientation entropy), GLCM-like contrast, shape ratios, and color ratios.
182
+
183
+ **Transient object filtering** (`_is_transient_object`):
184
+ - Filters out non-permanent changes: people, vehicles, animals, shadows.
185
+ - Uses area, aspect ratio, edge density, and texture variance thresholds.
186
+
187
+ **Classification categories:**
188
+ | Category | Key Indicators |
189
+ |----------|---------------|
190
+ | New Construction/Building | Low orientation entropy, compact shape, moderate edges, low saturation |
191
+ | Demolition/Clearing | High texture variance, high entropy, bright, low vegetation |
192
+ | Vegetation Change | High NDVI, green ratio, medium texture, high saturation |
193
+ | Water Body Change | High blue ratio, low texture, smooth surface, specific hue range |
194
+ | Road/Pavement Change | Elongated shape, homogeneous color, low saturation, low entropy |
195
+ | Bare Land/Soil Change | High red ratio, low NDVI, low texture, earth-tone hue |
196
+
197
+ **Ensemble voting** (`classify_with_ensemble`):
198
+ - Classifies the full region plus 5 sub-regions (quadrants + center).
199
+ - Combines votes weighted by confidence.
200
+ - Boosts confidence when β‰₯60% of sub-regions agree.
201
+ - Only excludes a region as transient if a majority of sub-regions are flagged.
202
+
203
+ #### 5k. Vegetation Sub-Classification (`classify_vegetation_subtype`)
204
+
205
+ **Use case:** Provides detailed breakdown of vegetation changes by comparing the before and after region crops.
206
+
207
+ | Sub-Type | Detection Logic |
208
+ |----------|----------------|
209
+ | Deforestation/Tree Removal | Before was green (high NDVI), after is bare (NDVI dropped, brightness increased) |
210
+ | New Vegetation/Growth | Before was bare, after is green (NDVI increased, saturation up) |
211
+ | Crop/Agricultural Change | Regular texture patterns in either image, moderate NDVI shift, large area |
212
+ | Vegetation Health Decline | Both images green but NDVI and saturation slightly decreased (browning/drought) |
213
+ | Seasonal Variation | Mild color/texture shift, both images still green |
214
+
215
+ #### 5l. Structural Sub-Classification (`classify_structural_subtype`)
216
+
217
+ **Use case:** Provides detailed breakdown of structural and road changes by comparing before/after structure presence, edge density, and texture.
218
+
219
+ | Sub-Type | Detection Logic |
220
+ |----------|----------------|
221
+ | New Building | No structure before, structure detected after |
222
+ | Building Expansion | Structure in both images, but after has higher edge density |
223
+ | Renovation/Modification | Structure in both, similar density but different appearance (brightness/saturation shift) |
224
+ | Partial Demolition | Structure before, reduced edges after |
225
+ | Full Demolition | Structure before, bare/empty after |
226
+ | Infrastructure Change | Elongated shape with high geometric regularity |
227
+ | New Road/Pavement | No structure before, structure after (for road-type regions) |
228
+ | Road Widening | Structure in both, edge density increased |
229
+ | Road Resurfacing | Structure in both, similar edges but brightness changed |
230
+ | Road Deterioration | Texture increased, edges decreased (surface degradation) |
231
+
232
+ #### 5m. 3D Building Analysis
233
+
234
+ **Use case:** Estimates building height/stories and classifies construction stage for building-type regions.
235
+
236
+ **Shadow-based height estimation** (`estimate_building_height`):
237
+ - Detects new shadow pixels adjacent to the building by comparing brightness in the expanded bounding box area between before and after images.
238
+ - Measures shadow length as the maximum spatial extent of the shadow cluster.
239
+ - Combines shadow ratio, footprint geometry (compact = likely taller), and texture regularity to estimate story count.
240
+ - Assumes 3 meters per story.
241
+
242
+ **Construction stage classification** (`classify_construction_stage`):
243
+ | Stage | Visual Indicators |
244
+ |-------|------------------|
245
+ | Foundation | Low texture, low edges, homogeneous, soil/concrete colors |
246
+ | Structural | High edges, geometric regularity (low entropy), high GLCM contrast |
247
+ | Under Construction | Mixed materials, irregular texture, medium edge density |
248
+ | Complete | Uniform roof, clean edges, low entropy, consistent color |
249
+
250
+ ---
251
+
252
+ ### 6. `templates/index.html` β€” Frontend Structure
253
+
254
+ **Role:** Single-page application (SPA) with four views managed by JavaScript.
255
+
256
+ **Views:**
257
+ - **Login** β€” Email/password form with password visibility toggle and "Forgot password" link.
258
+ - **Register** β€” Account creation form with name, email, and password.
259
+ - **Forgot Password** β€” Email + new password form (simplified reset without email verification).
260
+ - **Dashboard** β€” The main workspace containing:
261
+ - **Upload zone** β€” Drag-and-drop or click-to-browse for before/after images with live previews.
262
+ - **Detection options** β€” Method selector, title input, image registration and radiometric normalization toggles.
263
+ - **Results panel** β€” Statistics boxes (change %, changed pixels, total pixels, region count), before/after comparison slider, and a detailed regions table with columns for change type, sub-type, confidence, area, stories, height, construction stage, and center coordinates.
264
+ - **History** β€” List of past detection runs with view and delete actions.
265
+
266
+ ---
267
+
268
+ ### 7. `static/css/style.css` β€” Visual Design
269
+
270
+ **Role:** Complete styling for the application using CSS custom properties and a gradient theme.
271
+
272
+ **Key design decisions:**
273
+ - **Color theme** β€” Linear gradient from `#2e33c5` (blue) to `#cf2040` (red) applied to the navbar, footer, buttons, and accent elements. White background for content areas.
274
+ - **Sticky navbar** β€” Full-viewport-width gradient bar that stays fixed at the top during scrolling.
275
+ - **Mobile responsive** β€” Grid layouts collapse to single columns on screens under 768px. Font sizes, padding, and table layouts adapt to small screens.
276
+ - **Glassmorphism dropdown** β€” User account menu uses a gradient background with subtle shadow for the avatar popup.
277
+ - **Compare slider** β€” Custom CSS for the before/after image comparison handle with gradient accents.
278
+ - **Stat boxes** β€” Responsive font sizing with `clamp()` and compact number formatting to prevent overflow.
279
+
280
+ ---
281
+
282
+ ### 8. `static/js/app.js` β€” Client-Side Logic
283
+
284
+ **Role:** All browser-side interactivity β€” authentication flow, file uploads, API communication, result rendering, and UI state management.
285
+
286
+ **Key responsibilities:**
287
+ - **Authentication** β€” Login, register, forgot-password form handlers that call the API and store JWT tokens in localStorage. Automatic session check on page load (`init()`).
288
+ - **File upload** β€” Drag-and-drop and click-to-browse handlers with live image previews.
289
+ - **Detection submission** β€” Builds a FormData payload with images and settings, sends to `/api/detect`, and renders results.
290
+ - **Result rendering** β€” Populates stat boxes with compact-formatted numbers, fills the regions table, sets up the before/after comparison slider, and scrolls to results.
291
+ - **History management** β€” Loads past runs, renders them with view/delete actions, and handles deletion with an animated confirmation modal.
292
+ - **UI utilities** β€” View switching, error/success alerts, avatar dropdown toggle, password visibility toggle, and HTML escaping.
293
+
294
+ ---
295
+
296
+ ### 9. `Dockerfile` β€” Container Configuration
297
+
298
+ **Role:** Defines the Docker image for deployment on Hugging Face Spaces.
299
+
300
+ **Key aspects:**
301
+ - Base image: `python:3.11-slim` with OpenCV system dependencies (`libgl1`, `libglib2.0-0`, etc.).
302
+ - Non-root user (`appuser`) as required by Hugging Face Spaces security policy.
303
+ - Runs Gunicorn with a single Uvicorn worker on port 7860 (HF Spaces default) with 120-second timeout for long detection runs.
304
+ - Single worker prevents SQLite write-lock race conditions.
305
+
306
+ ---
307
+
308
+ ### 10. `requirements.txt` β€” Python Dependencies
309
+
310
+ | Package | Purpose |
311
+ |---------|---------|
312
+ | `fastapi` | Web framework for the REST API |
313
+ | `uvicorn` | ASGI server (production via Gunicorn) |
314
+ | `gunicorn` | Process manager for production deployment |
315
+ | `python-multipart` | Handles multipart/form-data file uploads |
316
+ | `sqlalchemy` | ORM for database operations |
317
+ | `pymysql` | MySQL driver (production database) |
318
+ | `python-jose` | JWT token encoding/decoding |
319
+ | `passlib` + `bcrypt` | Password hashing (bcrypt pinned to 4.0.1 for compatibility) |
320
+ | `pillow` | Image loading and format conversion |
321
+ | `numpy` | Numerical array operations for image processing |
322
+ | `opencv-python-headless` | Core image processing (registration, morphology, edge detection, color conversion) |
323
+ | `scikit-learn` | KMeans clustering for feature-based detection; StandardScaler for normalization |
324
+ | `scipy` | Scientific computing utilities |
325
+
326
+ ---
327
+
328
+ ## Data Flow: End-to-End Detection Run
329
+
330
+ ```
331
+ 1. User uploads before + after images via browser
332
+ β”‚
333
+ 2. POST /api/detect (multipart form with images + settings)
334
+ β”‚
335
+ 3. Authentication check (JWT from cookie/header/form)
336
+ β”‚
337
+ 4. Pre-processing
338
+ β”œβ”€β”€ Convert to RGB, limit to 2000px
339
+ β”‚
340
+ 5. Image Registration (optional)
341
+ β”œβ”€β”€ ORB keypoints β†’ ratio test β†’ RANSAC homography β†’ warp
342
+ β”‚
343
+ 6. Radiometric Normalization (optional)
344
+ β”œβ”€β”€ LAB histogram matching + symmetric CLAHE
345
+ β”‚
346
+ 7. Change Detection (selected method)
347
+ β”œβ”€β”€ Image Difference: LAB Delta-E + Otsu
348
+ β”œβ”€β”€ Feature-Based: LAB/HSV features + KMeans clustering
349
+ β”œβ”€β”€ AI-Based: Multi-signal fusion (color + SSIM + texture + edge)
350
+ └── Hybrid: Weighted combination of all three
351
+ β”‚
352
+ 8. Post-processing
353
+ β”œβ”€β”€ Morphological closing/opening + hole filling
354
+ β”‚
355
+ 9. Region Analysis
356
+ β”œβ”€β”€ Connected components β†’ bounding boxes
357
+ β”œβ”€β”€ Feature extraction (17 features per region)
358
+ β”œβ”€β”€ Transient object filtering (remove people, cars, etc.)
359
+ β”œβ”€β”€ Primary classification (6 ground-level categories)
360
+ β”œβ”€β”€ Ensemble voting (full region + 5 sub-regions)
361
+ β”‚
362
+ 10. Sub-Classification
363
+ β”œβ”€β”€ Vegetation: Deforestation / Growth / Crop / Health / Seasonal
364
+ β”œβ”€β”€ Structural: New / Expansion / Renovation / Partial Demo / Full Demo / Infrastructure
365
+ └── Road: New / Widening / Resurfacing / Deterioration
366
+ β”‚
367
+ 11. 3D Building Analysis (for construction/demolition regions)
368
+ β”œβ”€β”€ Shadow detection β†’ height/stories estimation
369
+ └── Construction stage classification
370
+ β”‚
371
+ 12. Visualization
372
+ β”œβ”€β”€ Red overlay on changed pixels
373
+ β”œβ”€β”€ Bounding boxes + annotation labels
374
+ β”‚
375
+ 13. Response
376
+ β”œβ”€β”€ Save overlay to disk + database record
377
+ └── Return JSON: stats, regions, base64 overlay image
378
+ ```
379
+
380
+ ---
381
+
382
+ ## Deployment
383
+
384
+ - **Local development:** Run `uvicorn app.main:app --reload` from the project root. Uses `DATABASE_URL` if set (MySQL for this project), otherwise SQLite in `data/satellite_app.db`.
385
+ - **Hugging Face Spaces:** Push to the linked HF repo. The Dockerfile builds and deploys automatically. Falls back to SQLite at `/home/appuser/data/` (ephemeral β€” resets on container restart) unless `DATABASE_URL` is set.
386
+ - **Production with MySQL:** Set `DATABASE_URL` to a `mysql+pymysql://user:pass@host:3306/dbname` connection string (`pymysql` driver is in `requirements.txt`). Set `SECRET_KEY` to a strong random value. On Render, set `DATABASE_URL` manually in the dashboard (Environment tab) β€” it's `sync: false` in `render.yaml`, not committed to git.
387
+ - **Production with PostgreSQL** (also supported): same pattern with a `postgresql://` URL β€” requires adding `psycopg2-binary` to `requirements.txt` first (not installed by default).
DEPLOYMENT.md ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deployment β€” Two Hugging Face Spaces
2
+
3
+ This project uses **two separate Hugging Face Spaces** so the live app stays stable while you build changes for a new client.
4
+
5
+ | Environment | Space | Git remote | Branch | URL |
6
+ |-------------|-------|------------|--------|-----|
7
+ | **Production** (current clients) | `coderuday21/satdetect` | `hf` | `production` | https://huggingface.co/spaces/coderuday21/satdetect |
8
+ | **Development** (new client work) | `coderuday21/satdetect-dev` | `hf-dev` | `master` | https://huggingface.co/spaces/coderuday21/satdetect-dev |
9
+
10
+ ---
11
+
12
+ ## One-time setup: create the dev Space
13
+
14
+ 1. Open **https://huggingface.co/new-space**
15
+ 2. Set:
16
+ - **Owner:** `coderuday21`
17
+ - **Space name:** `satdetect-dev`
18
+ - **SDK:** Docker
19
+ - **Visibility:** Public (or Private if you prefer)
20
+ 3. Click **Create Space**.
21
+
22
+ Then add the dev remote (once):
23
+
24
+ ```powershell
25
+ cd change_detection_webapp
26
+ git remote add hf-dev https://huggingface.co/spaces/coderuday21/satdetect-dev
27
+ ```
28
+
29
+ If `hf-dev` already exists, update it:
30
+
31
+ ```powershell
32
+ git remote set-url hf-dev https://huggingface.co/spaces/coderuday21/satdetect-dev
33
+ ```
34
+
35
+ Push the current development code to the new Space:
36
+
37
+ ```powershell
38
+ git push hf-dev master:main --force
39
+ ```
40
+
41
+ > **First push only:** Hugging Face creates a starter README commit when you create the Space. Use `--force` once to replace it with your app. Later pushes can omit `--force`.
42
+
43
+ Or run the helper script:
44
+
45
+ ```powershell
46
+ .\scripts\push_hf_dev.ps1
47
+ ```
48
+
49
+ ---
50
+
51
+ ## Branch strategy
52
+
53
+ ```
54
+ production ──► hf (satdetect) β€” live app (no login; direct to detection)
55
+ master ──► hf-dev (satdetect-dev) β€” new client experiments and upcoming changes
56
+ ```
57
+
58
+ - **`production`** β€” tracks the stable live release (currently same as `master`, no login).
59
+ - **`master`** β€” active development; push to the dev Space for testing before promoting to production.
60
+
61
+ Work on new client features on `master`. When ready for the live app, merge into `production` and push to `hf`.
62
+
63
+ ---
64
+
65
+ ## Deploy commands
66
+
67
+ ### Production (live app β€” no login)
68
+
69
+ ```powershell
70
+ git checkout production
71
+ git merge master
72
+ git push hf production:main
73
+ ```
74
+
75
+ Or push `master` directly to production:
76
+
77
+ ```powershell
78
+ git push hf master:main
79
+ ```
80
+
81
+ Helper:
82
+
83
+ ```powershell
84
+ .\scripts\push_hf_production.ps1
85
+ ```
86
+
87
+ ### Development (new client Space)
88
+
89
+ After every change you want on the dev Space:
90
+
91
+ ```powershell
92
+ git checkout master
93
+ git push hf-dev master:main
94
+ ```
95
+
96
+ Helper:
97
+
98
+ ```powershell
99
+ .\scripts\push_hf_dev.ps1
100
+ ```
101
+
102
+ ---
103
+
104
+ ## GitHub mirror
105
+
106
+ **Repo:** https://github.com/Uday-at-Vedang/DDA.ChangeDetection
107
+ **Default branch on GitHub:** `main`
108
+
109
+ Push the full app from local `master`:
110
+
111
+ ```powershell
112
+ git push origin master:main
113
+ ```
114
+
115
+ Push the production branch too:
116
+
117
+ ```powershell
118
+ git push origin production:main-production
119
+ ```
120
+
121
+ Set upstream (once, after first push):
122
+
123
+ ```powershell
124
+ git branch --set-upstream-to=origin/main master
125
+ ```
126
+
127
+ ---
128
+
129
+ ## Environment variables (both Spaces)
130
+
131
+ Set these in each Space’s **Settings β†’ Repository secrets / Variables** if needed:
132
+
133
+ | Variable | Purpose |
134
+ |----------|---------|
135
+ | `APP_MODE` | Set to `dda` on **satdetect-dev** only (enables Vedangsoft library UI) |
136
+ | `STORAGE_ROOT` | Tree library root directory (default: `data/library_sources/`) |
137
+ | `LOCAL_LIBRARY_ROOT` | Alias for storage root override |
138
+ | `MAX_GEOTIFF_MB` | Library GeoTIFF upload cap (default **5120** = 5 GB on dev) |
139
+ | `MAX_IMAGE_MB` | PNG/JPEG library cap (default 50 MB) |
140
+ | `SECRET_KEY` | Optional legacy JWT setting (login disabled) |
141
+ | `DATABASE_URL` | MySQL (production) instead of SQLite β€” `mysql+pymysql://user:pass@host:3306/db`; PostgreSQL also supported |
142
+ | `SMTP_USER` / `SMTP_PASS` | Email notifications via Gmail SMTP |
143
+ | `EMAIL_API_URL` | Custom email API (default in code) |
144
+ | `DEPT_API_URL` / `DEPT_API_KEY` | Departmental submit API (optional) |
145
+ | `DDA_ADMIN_EMAIL` / `DDA_ADMIN_PASSWORD` | Seed admin user on dev (role: admin) |
146
+ | `DDA_TRAINING_EXPORT_KEY` | Header `X-DDA-Training-Key` for false-positive export |
147
+
148
+ ### Detection accuracy controls (optional, dev-first)
149
+
150
+ All default to the previous behavior, so leaving them unset changes nothing.
151
+
152
+ | Variable | Purpose |
153
+ |----------|---------|
154
+ | `DETECTION_MAX_SIDE` | Downscaled-path pixel cap (default 4096 local / 2048 hosted) |
155
+ | `DETECTION_INFERENCE_MODE` | `downscaled` (default) or `fullres_tiled` (native detail) |
156
+ | `DETECTION_FULLRES_MAX_SIDE` | Full-res cap in fullres mode; 0 = native (default 8192) |
157
+ | `DETECTION_WINDOWED_THRESHOLD` | Native side above which GeoTIFFs stream from disk windows (default 8192) |
158
+ | `DETECTION_TILE_MEMORY_MB` | RAM budget per array before switching to windowed streaming (default 1536) |
159
+ | `DETECTION_TILE_SIZE` / `DETECTION_TILE_OVERLAP` | Full-res tile geometry (default 512 / 0.25) |
160
+ | `DETECTION_MULTISCALE` | `off` or scale list e.g. `0.5,1.0,1.5` (max-fused for recall) |
161
+ | `DETECTION_FUSION` | `smart_union` (default) or `hysteresis` |
162
+ | `DETECTION_CLAHE` / `DETECTION_HIST_MATCH` | Preprocessing toggles (CLAHE on, hist-match off) |
163
+ | `DETECTION_SKIP_PREBLUR` | Skip denoise (auto-on in fullres mode) |
164
+ | `DETECTION_BORDER_MARGIN` | Mask border zeroing (auto: 4 fullres / 12 downscaled) |
165
+ | `DETECTION_TILE_BATCH` | Tiles per GPU forward pass (default 1) |
166
+ | `DETECTION_SAVE_PROB_MAP` | Save a per-run probability PNG for debugging (default off) |
167
+
168
+ Dev Space can omit `SECRET_KEY` (login is disabled on both Spaces).
169
+
170
+ ---
171
+
172
+ ## Tree library layout (unlimited depth)
173
+
174
+ Images are organized in a **configurable tree** (zone β†’ area β†’ year β†’ image type, or any depth):
175
+
176
+ ```text
177
+ library_sources/
178
+ central_delhi/
179
+ karol_bagh/
180
+ 2025/
181
+ Images/
182
+ satellite.tif
183
+ ```
184
+
185
+ - **API:** `GET /api/dda/tree`, `POST /api/dda/tree/nodes`, upload via `POST /api/dda/tree/nodes/{id}/images/upload`
186
+ - **UI:** Recursive tree sidebar on Library and Change Detection tabs; **Manage** (admin) for create/rename/move/delete
187
+ - **Storage:** Slug-based disk paths; display names in `node_path`
188
+ - **Local folders:** Create folders under `library_sources/{zone}/{area}/…/` and drop files into `Images/`; click **Refresh** (or restart app) to sync disk β†’ DB via `POST /api/dda/local/rescan`
189
+ - **Legacy:** Flat `library_sources/YEAR/` files auto-migrate to `Unassigned/Legacy/{year}/Images/` on startup
190
+
191
+ ---
192
+
193
+ ## UAT checklist (satdetect-dev)
194
+
195
+ Run before promoting any Vedangsoft feature to production:
196
+
197
+ 1. **Health** β€” `GET /health` returns `status: ok`, `appMode: dda`, `dda.libraryImages` β‰₯ 0
198
+ 2. **Tree library** β€” Create zone β†’ area β†’ year nodes (admin); upload GeoTIFF to node; tree + grid show breadcrumb
199
+ 3. **Compare** β€” Tree sidebar filters image grid; T1/T2 dropdowns list all images; Run Detection completes with plausible lat/lng on georeferenced TIFFs
200
+ 4. **Viewer** β€” Slider / T1 / T2 / Overlay modes; click region to locate
201
+ 5. **Review** β€” Confirm and False Positive; Export confirmed CSV; Submit confirmed
202
+ 6. **Reports** β€” PDF download; `/dda/reports/{id}` page; email link (if SMTP configured)
203
+ 7. **Session isolation** β€” Two browsers see separate history (per-session cookie)
204
+ 8. **Admin** β€” `GET /api/dda/admin/status` (analyst+); stale job reconcile after restart
205
+ 9. **Training export** β€” Mark false positives β†’ `GET /api/dda/training/export` (admin or export key)
206
+ 10. **Security** β€” Path traversal blocked (`../` in library path); upload size limit enforced
207
+
208
+ Sign-off: DDA stakeholder approves sample runs on dev Space before any push to `satdetect` production.
209
+
210
+ ---
211
+
212
+ ## Quick reference
213
+
214
+ ```powershell
215
+ # Daily work (new client)
216
+ git checkout master
217
+ # ... edit code ...
218
+ git add .
219
+ git commit -m "Your message"
220
+ git push hf-dev master:main
221
+
222
+ # Update live app (only when ready)
223
+ git checkout production
224
+ git merge master # or cherry-pick specific commits
225
+ git push hf production:main
226
+ git checkout master
227
+ ```
DEV_SETUP.md ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vedangsoft Change Detection β€” Local Dev Setup
2
+
3
+ This repo is the **development branch** of the satellite change-detection app (DDA SOW). It runs the full dev UI: image library, GeoTIFF comparison, async jobs, reports, and PDF export.
4
+
5
+ **Live dev Space (reference):** https://coderuday21-satdetect-dev.hf.space
6
+ **Production Space (do not deploy this repo there without review):** https://coderuday21-satdetect.hf.space
7
+
8
+ ---
9
+
10
+ ## 1. Prerequisites
11
+
12
+ | Requirement | Notes |
13
+ |-------------|--------|
14
+ | **Python 3.10 – 3.12** | Tested with **3.11** (same as Docker). 3.13+ may have wheel issues for some packages. |
15
+ | **Git** | Clone this repository. |
16
+ | **~4 GB free disk** | PyTorch (CPU), transformers model cache, and sample GeoTIFFs. |
17
+ | **RAM 8 GB+ recommended** | Detection loads AdaptFormer; large GeoTIFFs use more RAM. |
18
+
19
+ ### Windows (GeoTIFF / rasterio)
20
+
21
+ `rasterio` needs GDAL. Easiest options:
22
+
23
+ **Option A β€” pip wheels (try first):**
24
+ ```powershell
25
+ pip install -r requirements.txt
26
+ python -c "import rasterio; print('rasterio OK', rasterio.__version__)"
27
+ ```
28
+
29
+ **Option B β€” if rasterio fails, use Conda for GDAL then pip for the rest:**
30
+ ```powershell
31
+ conda create -n dda-cd python=3.11 -y
32
+ conda activate dda-cd
33
+ conda install -c conda-forge gdal rasterio -y
34
+ pip install -r requirements.txt
35
+ ```
36
+
37
+ **Option C β€” OSGeo4W:** Install [OSGeo4W](https://trac.osgeo.org/osgeo4w/) and ensure `gdal` is on `PATH` before `pip install rasterio`.
38
+
39
+ ### macOS / Linux
40
+
41
+ ```bash
42
+ # macOS (Homebrew)
43
+ brew install gdal
44
+
45
+ # Ubuntu/Debian
46
+ sudo apt-get install gdal-bin libgdal-dev
47
+ export GDAL_CONFIG=/usr/bin/gdal-config
48
+ pip install -r requirements.txt
49
+ ```
50
+
51
+ ---
52
+
53
+ ## 2. Clone and install
54
+
55
+ ```bash
56
+ git clone https://github.com/Uday-at-Vedang/Change-Detection-DEV.git
57
+ cd Change-Detection-DEV
58
+ python -m venv venv
59
+ ```
60
+
61
+ **Windows:**
62
+ ```powershell
63
+ venv\Scripts\activate
64
+ pip install -U pip setuptools wheel
65
+ pip install -r requirements.txt
66
+ ```
67
+
68
+ **macOS / Linux:**
69
+ ```bash
70
+ source venv/bin/activate
71
+ pip install -U pip setuptools wheel
72
+ pip install -r requirements.txt
73
+ ```
74
+
75
+ > First `pip install` may take 10–20 minutes (PyTorch + transformers).
76
+
77
+ ---
78
+
79
+ ## 3. Environment variables (optional)
80
+
81
+ Copy the template and edit if needed:
82
+
83
+ ```bash
84
+ cp .env.example .env
85
+ ```
86
+
87
+ | Variable | Default (local) | Purpose |
88
+ |----------|----------------|---------|
89
+ | `APP_MODE` | `dda` (set by `run.py`) | `dda` = full dev UI; `legacy` = simple upload UI |
90
+ | `SECRET_KEY` | random fallback | Set in production |
91
+ | `DATABASE_URL` | SQLite in `data/` | Set to MySQL (`mysql+pymysql://...`) for production; PostgreSQL also supported |
92
+ | `LOCAL_LIBRARY_ROOT` | `library_sources/` | Custom image library folder |
93
+ | `MAX_GEOTIFF_MB` | `5120` | Max GeoTIFF upload size (MB) |
94
+ | `DETECTION_MAX_SIDE` | `4096` local / `2048` HF | Max pixel side for detection |
95
+ | `EMAIL_API_URL` | manager API | Email notifications |
96
+ | `SMTP_USER` / `SMTP_PASS` | β€” | Use SMTP if API URL empty |
97
+ | `PUBLIC_BASE_URL` | `http://localhost:8000` | Report links in emails |
98
+
99
+ Local dev does **not** require email config unless you test notifications.
100
+
101
+ ---
102
+
103
+ ## 4. Image library (local GeoTIFFs)
104
+
105
+ Place images under year folders (not committed to git β€” too large):
106
+
107
+ ```
108
+ library_sources/
109
+ 2024/
110
+ site_a.tif
111
+ 2025/
112
+ site_b.tif
113
+ 2026/
114
+ ```
115
+
116
+ See `library_sources/README.md` for details. Supported: `.tif`, `.tiff`, `.png`, `.jpg`.
117
+
118
+ After adding files, start the app and click **Image Library β†’ Refresh**.
119
+
120
+ ---
121
+
122
+ ## 5. Run the app
123
+
124
+ ```bash
125
+ python run.py
126
+ ```
127
+
128
+ Opens **http://127.0.0.1:8000** with the Vedangsoft dev UI (Home, Image Library, Change Detection, and Reports pages).
129
+
130
+ Alternative (with auto-reload during development):
131
+
132
+ ```bash
133
+ set APP_MODE=dda # Windows
134
+ export APP_MODE=dda # macOS/Linux
135
+ uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
136
+ ```
137
+
138
+ ### First run
139
+
140
+ - Creates `data/satellite_app.db` and `data/overlays/`.
141
+ - Downloads **AdaptFormer** model from Hugging Face on first detection (~500 MB). Requires internet.
142
+ - Seed data: Delhi zone/village hierarchy is loaded automatically in DDA mode.
143
+
144
+ ### Health check
145
+
146
+ ```bash
147
+ curl http://127.0.0.1:8000/health
148
+ ```
149
+
150
+ Expected: `"appMode": "dda"`, `"status": "ok"`.
151
+
152
+ ---
153
+
154
+ ## 6. Using the dev UI
155
+
156
+ 1. **Image Library** β€” scan year folders, upload GeoTIFFs (up to 5 GB), view hierarchy.
157
+ 2. **Change Detection** β€” pick Base (T1) and Comparison (T2), run detection (async jobs on HF; sync locally).
158
+ 3. **Reports** β€” history, PDF download, browser report at `/dda/reports/{id}`.
159
+ 4. **Bell icon** β€” in-app notifications for completed jobs.
160
+ 5. **Review (FR-08)** β€” Confirm / False Positive per region, export confirmed CSV, submit to dept API (`DEPT_API_URL`).
161
+ 6. **Session users** β€” Each browser gets isolated history via `dda_session_id` cookie (no login required).
162
+ 7. **Admin** β€” Optional `DDA_ADMIN_EMAIL` / `DDA_ADMIN_PASSWORD` for admin role; `GET /api/dda/admin/status`.
163
+
164
+ ---
165
+
166
+ ## 7. Project layout (Vedangsoft)
167
+
168
+ ```
169
+ app/
170
+ main.py # FastAPI entry
171
+ detection_engine.py # Change detection pipeline
172
+ dda/ # DDA modules (library, jobs, reports, geo)
173
+ static/js/dda/ # Dev frontend
174
+ templates/
175
+ partials/navbar_dda.html, result_modal_dda.html # shared includes
176
+ home_dda.html, library_dda.html, detect_dda.html, reports_dda.html
177
+ docs/IMPLEMENTATION_PLAN_DDA.md # SOW phase plan
178
+ ```
179
+
180
+ ---
181
+
182
+ ## 8. Troubleshooting
183
+
184
+ | Issue | Fix |
185
+ |-------|-----|
186
+ | `ImportError: rasterio` | Install GDAL (see Β§1), then reinstall rasterio |
187
+ | Simple upload UI instead of the Vedangsoft dev UI | Set `APP_MODE=dda` or use `python run.py` |
188
+ | Library empty | Add `.tif` files under `library_sources/YYYY/` and click Refresh |
189
+ | Detection slow / OOM | Lower `DETECTION_MAX_SIDE=2048` or use smaller images |
190
+ | Model download fails | Check internet; set `HF_HOME` to a writable folder |
191
+ | Port 8000 in use | Change `PORT` in `run.py` or use `--port 8001` with uvicorn |
192
+
193
+ ---
194
+
195
+ ## 9. Deploy to Hugging Face dev Space (maintainers)
196
+
197
+ See `DEPLOYMENT.md`. Dev Space remote:
198
+
199
+ ```powershell
200
+ git remote add hf-dev https://huggingface.co/spaces/coderuday21/satdetect-dev
201
+ git push hf-dev master:main
202
+ ```
203
+
204
+ Do **not** push `master` to production `satdetect` without explicit sign-off.
205
+
206
+ ---
207
+
208
+ ## 10. Key API endpoints (Vedangsoft)
209
+
210
+ | Method | Path | Description |
211
+ |--------|------|-------------|
212
+ | GET | `/health` | Health + app mode |
213
+ | GET | `/api/dda/local/images` | Library image list |
214
+ | POST | `/api/dda/jobs` | Queue async detection |
215
+ | GET | `/api/dda/reports/{id}/pdf` | PDF export |
216
+ | GET | `/dda/reports/{id}` | Browser report page |
217
+ | GET | `/api/history` | Detection run history |
218
+
219
+ ---
220
+
221
+ ## 11. Delhi evaluation workflow (accuracy calibration)
222
+
223
+ Full end-to-end reproduction of the Delhi accuracy-improvement sprint (see `Accuracy_Improvement_Plan.xlsx`). Every command below runs from the repo root.
224
+
225
+ ### 11.1 Curate real Delhi pairs
226
+
227
+ `library_sources/` ships empty (real imagery isn't committed β€” see `library_sources/README.md`). Two ways to populate it:
228
+
229
+ ```bash
230
+ # Free, openly-licensed Sentinel-2 imagery (10m GSD β€” good for large land-use
231
+ # change, vegetation, water bodies; not reliable for individual buildings/roads)
232
+ python scripts/build_delhi_pairs_sentinel2.py --count 32
233
+
234
+ # Or, once you have your own imagery (DDA-provided GeoTIFFs, drone, etc.) in
235
+ # library_sources/<year>/:
236
+ python scripts/build_delhi_manifest.py --scan # see what's available
237
+ python scripts/build_delhi_manifest.py --add --before library_sources/2024/x.tif \
238
+ --after library_sources/2026/x.tif --zone "..." --change-types building,road
239
+ python scripts/build_delhi_manifest.py --validate # check coverage (>=30 pairs, all 4 change types)
240
+ ```
241
+
242
+ ### 11.2 Label ground-truth masks
243
+
244
+ ```bash
245
+ # Semi-automated candidate generation (CVA + adaptive threshold + blob filter)
246
+ python scripts/generate_candidate_masks.py
247
+
248
+ # Review docs/delhi_eval/labels/candidates/previews/*.png (before | after | mask | overlay),
249
+ # then promote or reject each one:
250
+ python scripts/accept_candidate_mask.py --pair-id delhi_0001
251
+ python scripts/accept_candidate_mask.py --reject --pair-id delhi_0002
252
+ ```
253
+ Candidate masks are a starting point, not ground truth β€” always eyeball the preview before promoting. See `docs/delhi_eval/manifest.json` notes per pair for known limitations found during review (e.g. diffuse changes needing hand-drawn rough polygons instead).
254
+
255
+ ### 11.3 Run the detection harness against the labeled set
256
+
257
+ ```bash
258
+ python scripts/compare_methods.py --manifest docs/delhi_eval/manifest.json \
259
+ --methods "AI-Based Deep Learning,Feature-Based,Hybrid Approach" --sensitivities 0.5 \
260
+ --out runs/delhi_baseline
261
+ ```
262
+ Reports per-pair IoU/F1/precision/recall for every labeled pair (unlabeled pairs run without ground-truth scoring).
263
+
264
+ ### 11.4 Grid-search calibration
265
+
266
+ ```bash
267
+ python scripts/grid_search_calibration.py --methods "AI-Based Deep Learning" \
268
+ --sensitivities 0.5 --cl-qs 0.80,0.85,0.90,0.92 --manifest docs/delhi_eval/manifest.json
269
+ ```
270
+ Sweeps `sensitivity` / `DETECTION_FUSION` / `DETECTION_DL_FLOOR_BASE` / `DETECTION_CL_Q_BASE` and ranks configs by mean F1 in `runs/calibration/leaderboard.csv`. **Always verify a promising config against the synthetic regression suite (11.5) before promoting it** β€” the highest Delhi F1 isn't automatically safe; see `runs/calibration/best_params.json` for a real example of a config that won on Delhi but broke a regression gate.
271
+
272
+ ### 11.5 Synthetic regression gate (mandatory before promoting any default)
273
+
274
+ ```bash
275
+ python scripts/validate_detection.py --benchmark --method "AI-Based Deep Learning" \
276
+ --sensitivity 0.5 --out runs/synthetic_check
277
+ ```
278
+ Four cases: `inserted_buildings`, `brightness_only`, `misaligned_change`, `parked_cars` (the mandatory car/transient-FP guard β€” must stay F1=1.0). Pass `--method`/`--sensitivity` to test any calibrated config; set the relevant `DETECTION_*` env vars first to test non-default fusion parameters.
279
+
280
+ **Known gap:** the plan also references a LEVIR-CD regression case and a kappa statistic (Phase 0, marked "Complete"). Neither actually exists in this codebase as of the Day 5/6 calibration pass β€” only the 4 synthetic cases above are real, runnable gates. Treat any claim of "LEVIR-CD gate passing" as unverified until that harness is actually built.
281
+
282
+ ### 11.6 Promote a verified config to production defaults
283
+
284
+ Once a config passes 11.5 unchanged, hardcode it as the new default in `app/detection_config.py` (e.g. `get_cl_q_base()`'s fallback value), then re-run 11.3 and 11.5 **with no env var set** to confirm the new default behaves identically to what was verified.
Dockerfile ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Ensure build logs flush immediately (helps when HF shows β€œBUILDING” with no output)
4
+ ENV PYTHONUNBUFFERED=1
5
+
6
+ # System dependencies for OpenCV and image processing
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ libgl1 \
9
+ libglib2.0-0 \
10
+ libsm6 \
11
+ libxext6 \
12
+ libxrender1 \
13
+ gdal-bin \
14
+ libgdal-dev \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ # Create non-root user (required by Hugging Face Spaces)
18
+ RUN useradd -m -u 1000 appuser
19
+
20
+ WORKDIR /app
21
+
22
+ # Build-time info + cache-bust:
23
+ # Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
24
+ ARG APP_BUILD=38
25
+ ENV MAX_GEOTIFF_MB=15360
26
+ ENV APP_BUILD=${APP_BUILD}
27
+ ENV GDAL_CONFIG=/usr/bin/gdal-config
28
+ RUN echo "Docker build start: APP_BUILD=${APP_BUILD}" && python -V
29
+
30
+ # Install Python dependencies
31
+ COPY requirements.txt .
32
+ RUN pip install --no-cache-dir --disable-pip-version-check --default-timeout=300 -U pip setuptools wheel
33
+ RUN pip install --no-cache-dir --disable-pip-version-check --default-timeout=300 --prefer-binary -r requirements.txt -v
34
+
35
+ # Pre-download the AdaptFormer model so cold starts are instant
36
+ ENV HF_HOME=/app/.hf_cache
37
+ RUN python -c "from transformers import AutoImageProcessor, AutoModel; \
38
+ AutoImageProcessor.from_pretrained('deepang/adaptformer-LEVIR-CD', cache_dir='/app/.hf_cache', trust_remote_code=True); \
39
+ AutoModel.from_pretrained('deepang/adaptformer-LEVIR-CD', cache_dir='/app/.hf_cache', trust_remote_code=True); \
40
+ print('Model pre-downloaded successfully')"
41
+
42
+ # Copy application code
43
+ COPY . .
44
+
45
+ # Create data directories with correct permissions
46
+ RUN mkdir -p data/overlays && chown -R appuser:appuser /app
47
+
48
+ USER appuser
49
+
50
+ # HF Spaces generally uses 7860, but binding to $PORT is safer.
51
+ ENV PORT=7860
52
+ EXPOSE 7860
53
+
54
+ # Bind to runtime PORT so health checks always reach the server.
55
+ CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860} --log-level info --timeout-keep-alive 600"]
README.md ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Vedangsoft Change Detection (Dev)
3
+ emoji: πŸ›°οΈ
4
+ colorFrom: gray
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ ---
9
+
10
+ # Vedangsoft Change Detection β€” Development
11
+
12
+ Satellite / drone **change detection** web app for the DDA Scope of Work. This repository tracks **dev-only** features (`APP_MODE=dda`): image library, GeoTIFF comparison, async jobs, geo-referenced regions, reports, and PDF export.
13
+
14
+ > **New to the project?** Start with **[DEV_SETUP.md](DEV_SETUP.md)** β€” step-by-step install and run instructions for your machine.
15
+
16
+ | Environment | URL |
17
+ |-------------|-----|
18
+ | **Dev Space (HF)** | https://coderuday21-satdetect-dev.hf.space |
19
+ | **Local** | http://127.0.0.1:8000 after `python run.py` |
20
+
21
+ ## Quick start
22
+
23
+ ```bash
24
+ git clone https://github.com/Uday-at-Vedang/Change-Detection-DEV.git
25
+ cd Change-Detection-DEV
26
+ python -m venv venv
27
+ venv\Scripts\activate # Windows
28
+ # source venv/bin/activate # macOS/Linux
29
+ pip install -r requirements.txt
30
+ python run.py
31
+ ```
32
+
33
+ 1. Add GeoTIFFs to `library_sources/2025/` (or other year folders).
34
+ 2. Open **Image Library β†’ Refresh**.
35
+ 3. Use **Change Detection** to compare T1 vs T2.
36
+
37
+ Full details: **[DEV_SETUP.md](DEV_SETUP.md)** Β· SOW plan: **`docs/IMPLEMENTATION_PLAN_DDA.md`** Β· HF deploy: **`DEPLOYMENT.md`**
38
+
39
+ ---
40
+
41
+ # Satellite Change Detection β€” Standalone Web App
42
+
43
+ Standalone web application for satellite image change detection with **database storage** and a **clean, modern UI**. Opens directly on the detection page β€” no sign-in required.
44
+
45
+ ## Features
46
+
47
+ - **Vedangsoft dev UI** β€” Home, Library, Detection, Reports pages (when `APP_MODE=dda`)
48
+ - **Direct access** β€” upload and run detection immediately (no login)
49
+ - **Database** β€” SQLite by default; MySQL in production via `DATABASE_URL` (PostgreSQL also supported); stores detection runs
50
+ - **Change detection** β€” AdaptFormer deep learning + hybrid / difference methods
51
+ - **GeoTIFF library** β€” Year-folder library, 5 GB upload limit, lat/lng on regions
52
+ - **Object classification** β€” Changed regions labeled (Water, Vegetation, Building, Road, etc.)
53
+ - **History & reports** β€” Past runs, PDF export, email notifications
54
+ - **Region polygons** β€” Engine footprints (`polygon` / `polygonGeo`) in the result overlay + viewer SVG; export via `GET /api/dda/reports/{id}/export.geojson` (bbox/Point fallback for older runs). Verify: `python scripts/verify_polygon_mvp.py`
55
+
56
+ ## Setup (legacy summary)
57
+
58
+ See **[DEV_SETUP.md](DEV_SETUP.md)** for the complete guide. Minimal steps:
59
+
60
+ 1. **Create a virtual environment (recommended)**
61
+
62
+ ```bash
63
+ cd change_detection_webapp
64
+ python -m venv venv
65
+ source venv/bin/activate # Windows: venv\Scripts\activate
66
+ ```
67
+
68
+ 2. **Install dependencies**
69
+
70
+ ```bash
71
+ pip install -r requirements.txt
72
+ ```
73
+
74
+ 3. **Run the app**
75
+
76
+ ```bash
77
+ python run.py
78
+ # or: uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
79
+ ```
80
+
81
+ 4. Open **http://localhost:8000** in your browser.
82
+
83
+ ## First run
84
+
85
+ - The DB (SQLite locally, or the configured `DATABASE_URL`) and `data/` (overlay images) are created automatically on first use.
86
+ - **DDA mode:** place images in `library_sources/YYYY/` and use the Library tab.
87
+ - **Legacy mode:** upload Before/After PNGs on the detection page.
88
+ - AdaptFormer model downloads from Hugging Face on first detection (~500 MB).
89
+
90
+ ## Configuration
91
+
92
+ - **DDA mode:** `APP_MODE=dda` (default when using `run.py`). Copy `.env.example` β†’ `.env` for optional overrides.
93
+ - **Database**: set `DATABASE_URL` to use another DB β€” MySQL (production for this project): `mysql+pymysql://user:pass@host:3306/db`, or PostgreSQL: `postgresql://user:pass@host/db`. Otherwise SQLite under `data/satellite_app.db` is used.
94
+ - **JWT**: set `SECRET_KEY` via env in production.
95
+ - **Email**: `EMAIL_API_URL` or `SMTP_USER` / `SMTP_PASS` β€” see `.env.example`.
96
+
97
+ ## Project layout
98
+
99
+ ```
100
+ change_detection_webapp/
101
+ β”œβ”€β”€ app/
102
+ β”‚ β”œβ”€β”€ main.py # FastAPI app, routes
103
+ β”‚ β”œβ”€β”€ dda/ # DDA dev modules (library, jobs, reports)
104
+ β”‚ └── detection_engine.py # Change detection pipeline
105
+ β”œβ”€β”€ static/js/dda/ # DDA frontend
106
+ β”œβ”€β”€ templates/
107
+ β”‚ β”œβ”€β”€ partials/navbar_dda.html, result_modal_dda.html # shared includes
108
+ β”‚ β”œβ”€β”€ home_dda.html, library_dda.html, detect_dda.html,
109
+ β”‚ β”‚ reports_dda.html # Vedangsoft dev UI pages
110
+ β”‚ β”œβ”€β”€ login_dda.html, report_dda.html
111
+ β”‚ └── index.html # legacy (APP_MODE=legacy) UI
112
+ β”œβ”€β”€ library_sources/ # Local GeoTIFF library (year folders)
113
+ β”œβ”€β”€ docs/IMPLEMENTATION_PLAN_DDA.md
114
+ β”œβ”€β”€ DEV_SETUP.md # Colleague setup guide
115
+ β”œβ”€β”€ requirements.txt
116
+ └── run.py # Local launcher (APP_MODE=dda)
117
+ ```
118
+
119
+ ## API (Vedangsoft highlights)
120
+
121
+ - `GET /health` β€” app mode and version
122
+ - `GET /api/dda/local/images` β€” library image list
123
+ - `POST /api/dda/jobs` β€” queue async detection
124
+ - `GET /api/dda/reports/{id}/pdf` β€” PDF download
125
+ - `GET /dda/reports/{id}` β€” browser report page
126
+ - `POST /api/detect` β€” legacy multipart upload detect
127
+ - `GET /api/history` β€” detection run history
128
+
129
+ ## Hugging Face: Space stuck on β€œRestarting”
130
+
131
+ 1. Open your Space β†’ **Settings** β†’ under **Build**, click **Clear build cache** β†’ Save. Then trigger a rebuild (push a commit or click **Restart**).
132
+ 2. Check **Logs** (Build logs + App logs) for Python errors or β€œKilled” (out of memory).
133
+ 3. In **Settings** β†’ **Hardware**, try a slightly larger CPU/memory if available.
WORKFLOW.md ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Satellite Change Detection β€” Project Workflow Guide
2
+
3
+ A complete walkthrough of how this project is structured, how a detection request
4
+ flows through the system, and how to run it locally. Read this top-to-bottom to get
5
+ familiar with the codebase.
6
+
7
+ ---
8
+
9
+ ## 1. What this project does
10
+
11
+ This is a **standalone web application for satellite image change detection**. A user
12
+ uploads two images of the same place taken at different times (a **"before"** and an
13
+ **"after"**), and the app:
14
+
15
+ 1. Aligns the two images (registration).
16
+ 2. Normalizes their colors/brightness.
17
+ 3. Runs change detection (classical computer vision + a pretrained deep-learning model).
18
+ 4. Finds and classifies the changed regions (new buildings, vegetation change, water, etc.).
19
+ 5. Draws the changes on an overlay image and shows statistics.
20
+ 6. Saves the run to a per-user history (with login/accounts).
21
+
22
+ It is deployed as a Docker container on **Hugging Face Spaces**, but runs the same way locally.
23
+
24
+ ---
25
+
26
+ ## 2. Tech stack
27
+
28
+ | Layer | Technology |
29
+ |-------|-----------|
30
+ | Backend API | **FastAPI** (Python), served by **uvicorn** |
31
+ | Image processing | **OpenCV**, **NumPy**, **scikit-learn** |
32
+ | Deep learning | **PyTorch** + **Hugging Face Transformers** (AdaptFormer model) |
33
+ | Database | **SQLAlchemy** ORM over **SQLite** (default) or **PostgreSQL** |
34
+ | Auth | **JWT** tokens (python-jose) + **bcrypt** password hashing (passlib) |
35
+ | Frontend | Plain **HTML + CSS + vanilla JavaScript** (single-page app) |
36
+ | Email | HTTP email API or SMTP (optional notifications) |
37
+ | Deployment | **Docker** β†’ Hugging Face Spaces |
38
+
39
+ There is **no build step** for the frontend β€” it's static files served directly.
40
+
41
+ ---
42
+
43
+ ## 3. Repository structure
44
+
45
+ ```
46
+ change_detection_webapp/
47
+ β”œβ”€β”€ app/
48
+ β”‚ β”œβ”€β”€ main.py # FastAPI app + all HTTP routes (entry point)
49
+ β”‚ β”œβ”€β”€ detection_engine.py # CORE: preprocessing, registration, detection, classification
50
+ β”‚ β”œβ”€β”€ model_inference.py # AdaptFormer deep-learning model loader + tiled inference
51
+ β”‚ β”œβ”€β”€ cd_models/ # Extra DL building blocks
52
+ β”‚ β”‚ β”œβ”€β”€ change_model.py # Siamese U-Net architecture (optional, needs weights)
53
+ β”‚ β”‚ └── model_utils.py # tiling, multi-scale, confidence-map helpers
54
+ β”‚ β”œβ”€β”€ auth.py # JWT create/verify, password hashing, current-user lookup
55
+ β”‚ β”œβ”€β”€ database.py # SQLAlchemy engine/session, DATA_DIR resolution
56
+ β”‚ β”œβ”€β”€ models.py # ORM models: User, DetectionRun
57
+ β”‚ └── notifier.py # Email notification sending
58
+ β”œβ”€β”€ static/
59
+ β”‚ β”œβ”€β”€ css/style.css # All styles
60
+ β”‚ └── js/app.js # All frontend logic (auth, upload, render results)
61
+ β”œβ”€β”€ templates/
62
+ β”‚ └── index.html # Single-page UI shell
63
+ β”œβ”€β”€ scripts/
64
+ β”‚ └── validate_detection.py # Sanity checks for the detection pipeline
65
+ β”œβ”€β”€ data/ # Created at runtime: SQLite DB + overlay/thumbnail images
66
+ β”œβ”€β”€ requirements.txt # Python dependencies
67
+ β”œβ”€β”€ Dockerfile # Container build (also pre-downloads the model)
68
+ β”œβ”€β”€ README.md # Short setup notes (Hugging Face front-matter on top)
69
+ └── WORKFLOW.md # This document
70
+ ```
71
+
72
+ > Note: the `app/cd_models/` folder is named `cd_models` (not `models`) on purpose β€”
73
+ > `app/models.py` already exists for the database models, and a folder named `models`
74
+ > would shadow it and break imports.
75
+
76
+ ---
77
+
78
+ ## 4. High-level architecture
79
+
80
+ ```mermaid
81
+ flowchart LR
82
+ browser["Browser (index.html + app.js)"] -->|"HTTP / JSON"| api["FastAPI (main.py)"]
83
+ api --> auth["auth.py (JWT)"]
84
+ api --> engine["detection_engine.py"]
85
+ engine --> model["model_inference.py (AdaptFormer)"]
86
+ api --> db["(SQLite / Postgres via models.py)"]
87
+ api --> notifier["notifier.py (email)"]
88
+ api --> files["data/overlays (result images)"]
89
+ ```
90
+
91
+ - The **browser** talks only to FastAPI over JSON + multipart form uploads.
92
+ - **main.py** is the only place that defines routes; it delegates the heavy lifting to
93
+ `detection_engine.run_detection(...)`.
94
+ - Results (overlay PNGs, thumbnails) are written to `data/overlays/` and referenced by URL.
95
+ - Run metadata is stored in the database.
96
+
97
+ ---
98
+
99
+ ## 5. The detection pipeline (the core workflow)
100
+
101
+ This is the most important part to understand. Everything happens inside
102
+ `run_detection()` in [`app/detection_engine.py`](app/detection_engine.py).
103
+
104
+ ```mermaid
105
+ flowchart TD
106
+ start["run_detection(before, after, method, sensitivity, ...)"] --> pre["preprocess_image() -- RGB, resize, denoise"]
107
+ pre --> reg{"registration enabled?"}
108
+ reg -->|yes| align["register_images() -- SIFT/ORB + ECC align, returns quality metrics"]
109
+ reg -->|no| norm
110
+ align --> norm{"normalization enabled?"}
111
+ norm -->|yes| radio["normalize_radiometry() -- LAB color match + CLAHE"]
112
+ norm -->|no| method
113
+ radio --> method["pick detection method"]
114
+ method --> ai["AI-Based Deep Learning (default)"]
115
+ method --> diff["Image Difference"]
116
+ method --> feat["Feature-Based (KMeans)"]
117
+ method --> hyb["Hybrid / Hybrid AI"]
118
+ ai --> fuse["AdaptFormer score + classical score -> confidence-gated fusion"]
119
+ diff --> mask
120
+ feat --> mask
121
+ hyb --> fuse
122
+ fuse --> mask["binary change mask"]
123
+ mask --> regions["analyze_change_regions() -- connected components + classify + NMS"]
124
+ regions --> viz["visualize_changes() -- draw boxes on overlay"]
125
+ viz --> out["return mask, overlay, stats, regions"]
126
+ ```
127
+
128
+ ### Step by step
129
+
130
+ 1. **Preprocess** (`preprocess_image`): convert to RGB, cap the size (default 1600px max
131
+ side) for speed, apply light Gaussian blur (and a bilateral filter only when the image
132
+ is noisy) to reduce sensor noise without destroying edges.
133
+
134
+ 2. **Register / align** (`register_images`): the two screenshots rarely line up perfectly.
135
+ - First tries **SIFT + FLANN** feature matching β†’ homography.
136
+ - Falls back to **ORB** features if SIFT is weak.
137
+ - Refines with **ECC** (sub-pixel alignment).
138
+ - Falls back to **multi-scale ECC** if feature matching fails entirely.
139
+ - Returns an `(img1, img2_aligned, registration_ok, reg_meta)` tuple. `registration_ok`
140
+ and the quality metrics (inlier ratio, NCC) are surfaced to the UI as an
141
+ **alignment warning** when the alignment is weak β€” important for Google Earth
142
+ screenshots that differ in zoom/crop.
143
+
144
+ 3. **Radiometric normalization** (`normalize_radiometry`): match the "after" image's color
145
+ statistics to the "before" image in LAB space, plus symmetric CLAHE on the lightness
146
+ channel, so lighting/season differences don't look like real change.
147
+
148
+ 4. **Detection method** (selected by the `method` argument):
149
+ - **AI-Based Deep Learning** (default): runs the **AdaptFormer** model
150
+ (`model_inference.predict_change_mask`) to get a per-pixel change probability map,
151
+ computes a **classical** multi-channel score map (color Ξ”E, SSIM, texture/LBP, edges,
152
+ change-vector analysis), then **fuses** them with `fuse_dl_and_classical()`. Fusion is
153
+ **confidence-gated** (not a blind union): the model drives structural changes, the
154
+ classical signal + Excess-Green index supports vegetation changes.
155
+ - **Image Difference**: LAB Ξ”E difference with adaptive (Otsu/MAD) thresholding.
156
+ - **Feature-Based**: KMeans clustering of per-pixel difference features.
157
+ - **Hybrid / Hybrid AI**: weighted combinations of the above.
158
+
159
+ 5. **Clean the mask** (`_clean_mask`): morphological open/close, hole filling, remove tiny
160
+ or thin (shadow-like) components.
161
+
162
+ 6. **Find & classify regions** (`analyze_change_regions`): connected-components on the mask
163
+ β†’ bounding boxes β†’ `classify_object_type()` assigns a label (New Construction/Building,
164
+ Vegetation Change, Water Body Change, Road/Pavement, Bare Land, etc.) with a confidence
165
+ and severity β†’ **non-maximum suppression** removes overlapping/duplicate boxes.
166
+
167
+ 7. **Visualize** (`visualize_changes`): draw a subtle tint on changed pixels and numbered,
168
+ color-coded boxes for the top regions.
169
+
170
+ 8. **Return** `change_mask, result_image, stats, change_regions` back to `main.py`.
171
+
172
+ ### Sensitivity
173
+
174
+ The `detection_sensitivity` slider (0–1, default 0.5) shifts thresholds: higher = detect
175
+ more (more recall, more false positives), lower = stricter (fewer, higher-confidence
176
+ detections).
177
+
178
+ ---
179
+
180
+ ## 6. The deep-learning model
181
+
182
+ - File: [`app/model_inference.py`](app/model_inference.py)
183
+ - Model: **`deepang/adaptformer-LEVIR-CD`** (a change-detection transformer trained on the
184
+ LEVIR-CD building dataset), pulled from the Hugging Face Hub.
185
+ - It is **pre-downloaded at Docker build time** (see the Dockerfile) so cold starts are fast,
186
+ and **preloaded on app startup** so the first request isn't slow.
187
+ - Inference is **tile-based**: the image is split into overlapping 256Γ—256 tiles (the model's
188
+ native size), each tile is predicted, and the results are stitched back with a
189
+ raised-cosine blend to avoid visible seams.
190
+ - If PyTorch/Transformers aren't installed or the model fails to load, the engine **falls
191
+ back gracefully** to the classical-only path β€” the app still works.
192
+
193
+ `app/cd_models/change_model.py` contains a from-scratch **Siamese U-Net** as an alternative
194
+ DL backbone. It only activates if a trained weights file exists at
195
+ `app/cd_models/weights/siamese_unet_cd.pt` (none is shipped), so it's currently dormant.
196
+
197
+ ---
198
+
199
+ ## 7. Request lifecycle (auth + detect)
200
+
201
+ ```mermaid
202
+ sequenceDiagram
203
+ participant U as Browser
204
+ participant A as FastAPI (main.py)
205
+ participant Au as auth.py
206
+ participant E as detection_engine.py
207
+ participant D as Database
208
+
209
+ U->>A: POST /api/auth/login {email, password}
210
+ A->>Au: verify_password + create_access_token
211
+ Au-->>A: JWT
212
+ A-->>U: token (also set as httpOnly cookie)
213
+
214
+ U->>A: POST /api/detect (before, after, method, ...) + token
215
+ A->>Au: resolve user from token/cookie
216
+ A->>E: run_detection(...)
217
+ E-->>A: mask, overlay, stats, regions
218
+ A->>D: save DetectionRun row
219
+ A->>A: write overlay + thumbnails to data/overlays/
220
+ A-->>U: JSON {statistics, regions, overlayBase64Png, ...}
221
+ U->>U: app.js renders overlay + regions table
222
+ ```
223
+
224
+ ---
225
+
226
+ ## 8. Data model
227
+
228
+ Defined in [`app/models.py`](app/models.py):
229
+
230
+ - **User**: `id, email (unique), hashed_password, full_name, created_at`
231
+ - **DetectionRun**: `id, user_id, title, method, total_pixels, changed_pixels,
232
+ change_percentage, regions_count, overlay_path, before/after thumbnail paths, zone,
233
+ village, regions_json (serialized regions), created_at`
234
+
235
+ The database file lives at `data/satellite_app.db` (SQLite) and is created automatically on
236
+ first run. Set `DATABASE_URL` to use PostgreSQL instead.
237
+
238
+ ---
239
+
240
+ ## 9. API reference
241
+
242
+ All routes are in [`app/main.py`](app/main.py).
243
+
244
+ | Method & path | Purpose |
245
+ |---------------|---------|
246
+ | `POST /api/auth/register` | Create account `{email, password, full_name}` β†’ token |
247
+ | `POST /api/auth/login` | Log in `{email, password}` β†’ token (+ cookie) |
248
+ | `POST /api/auth/logout` | Clear auth cookie |
249
+ | `POST /api/auth/reset-password` | Set a new password `{email, new_password}` |
250
+ | `GET /api/me` | Current user (requires auth) |
251
+ | `POST /api/detect` | **Main endpoint.** multipart: `before`, `after` files + `method`, `title`, `zone`, `village`, `enable_registration`, `enable_normalization`, `detection_sensitivity`, `min_region_area`, `notify_email` |
252
+ | `GET /api/history` | List the current user's past runs |
253
+ | `POST /api/notify/test` | Send a test email |
254
+ | `GET /api/overlay/<path>` | Serve a saved overlay / thumbnail image |
255
+ | `GET /health` | Lightweight health check (no DB) |
256
+ | `GET /` | Serves the single-page UI |
257
+
258
+ `POST /api/detect` returns: `statistics` (pixels, change %, threshold debug, alignment
259
+ warning), `regions` (list with type/confidence/severity/bbox), and the overlay as base64 PNG.
260
+
261
+ ---
262
+
263
+ ## 10. Frontend
264
+
265
+ - [`templates/index.html`](templates/index.html) is the shell: login/register forms, the
266
+ upload form, history table, and the result modal.
267
+ - [`static/js/app.js`](static/js/app.js) handles everything client-side: calling the API,
268
+ storing the token, submitting the detect form, and rendering results (the before/after
269
+ slider, the regions table, alignment warnings, and fusion stats).
270
+ - [`static/css/style.css`](static/css/style.css) holds all styling.
271
+ - Cache-busting: the `?v=NN` suffixes on the CSS/JS `<link>`/`<script>` tags are bumped when
272
+ those files change so browsers don't serve stale copies.
273
+
274
+ ---
275
+
276
+ ## 11. Running locally
277
+
278
+ ### Option A β€” Python virtual environment (recommended for development)
279
+
280
+ ```bash
281
+ cd change_detection_webapp
282
+
283
+ # 1. Create and activate a virtual environment
284
+ python -m venv venv
285
+ # Windows (PowerShell):
286
+ venv\Scripts\Activate.ps1
287
+ # macOS / Linux:
288
+ source venv/bin/activate
289
+
290
+ # 2. Install dependencies (this pulls CPU PyTorch + Transformers, ~couple GB)
291
+ pip install -r requirements.txt
292
+
293
+ # 3. Run the dev server (auto-reload on code changes)
294
+ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
295
+ ```
296
+
297
+ Open **http://localhost:8000**, register an account, then upload a before/after pair.
298
+
299
+ > First detection run downloads the AdaptFormer model from Hugging Face (one time).
300
+ > If you don't have PyTorch installed or want a lighter setup, the app still runs using
301
+ > the classical detection path only.
302
+
303
+ ### Option B β€” Docker (mirrors production)
304
+
305
+ ```bash
306
+ cd change_detection_webapp
307
+ docker build -t change-detection .
308
+ docker run -p 7860:7860 change-detection
309
+ ```
310
+
311
+ Open **http://localhost:7860**. The Docker build pre-downloads the model, so the first
312
+ request is fast.
313
+
314
+ ### Validate the pipeline
315
+
316
+ ```bash
317
+ python scripts/validate_detection.py
318
+ ```
319
+
320
+ This runs registration/fusion/end-to-end sanity checks on synthetic images.
321
+
322
+ ---
323
+
324
+ ## 12. Configuration (environment variables)
325
+
326
+ | Variable | Default | Purpose |
327
+ |----------|---------|---------|
328
+ | `DATABASE_URL` | `sqlite:///data/satellite_app.db` | Use PostgreSQL by setting this |
329
+ | `SECRET_KEY` | (in `auth.py`) | JWT signing key β€” **set this in production** |
330
+ | `HF_HOME` | `/app/.hf_cache` (Docker) | Where the model is cached |
331
+ | `EMAIL_API_URL` | manager's API | Email backend; empty + `SMTP_USER`/`SMTP_PASS` for SMTP |
332
+ | `PORT` | `7860` | Port uvicorn binds to in the container |
333
+ | `SPACE_ID` | (set by HF) | When present, data is written to `~/data` |
334
+
335
+ ---
336
+
337
+ ## 13. Deployment (Hugging Face Spaces)
338
+
339
+ - The Space is a **Docker** Space. Pushing to its git remote triggers a rebuild.
340
+ - Hugging Face builds from the **`main`** branch. (This repo's working branch is `master`,
341
+ so deploys push `master` β†’ both `master` and `main` on the HF remote.)
342
+ - The `ARG APP_BUILD=NN` line in the Dockerfile is a cache-buster: bumping it forces pip to
343
+ reinstall and the model to re-download on the next build.
344
+ - The YAML front-matter at the top of `README.md` (title, emoji, `sdk: docker`,
345
+ `app_port: 7860`) is what Hugging Face reads to configure the Space.
346
+
347
+ ---
348
+
349
+ ## 14. Common tasks & gotchas
350
+
351
+ - **"It's detecting too much / too little"** β†’ adjust the sensitivity slider, and make sure
352
+ the before/after images are the **same location, zoom, and crop**. Weak alignment shows an
353
+ alignment warning in the result panel.
354
+ - **Changed CSS/JS but nothing updates** β†’ bump the `?v=NN` query string in `index.html`.
355
+ - **Adding a DB column** β†’ update `app/models.py`; the app creates tables on startup
356
+ (there's lightweight migration handling in `main.py`).
357
+ - **Don't create a folder named `app/models/`** β†’ it shadows `app/models.py`.
358
+ - **Model not loading** β†’ the app logs a warning and continues with classical detection;
359
+ check the logs for the AdaptFormer load message.
360
+
361
+ ---
362
+
363
+ ## 15. Where to start reading the code
364
+
365
+ 1. [`app/main.py`](app/main.py) β€” see the routes, especially `POST /api/detect`.
366
+ 2. [`app/detection_engine.py`](app/detection_engine.py) β€” start at `run_detection()` at the
367
+ bottom and follow the calls upward.
368
+ 3. [`app/model_inference.py`](app/model_inference.py) β€” how the DL model is loaded and run.
369
+ 4. [`static/js/app.js`](static/js/app.js) β€” how the frontend calls the API and renders results.
370
+
371
+ Welcome to the project!
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Satellite Change Detection Web App
app/auth.py ADDED
@@ -0,0 +1,456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import logging
3
+ import os
4
+ import re
5
+ import secrets
6
+ from datetime import datetime, timedelta, timezone
7
+ from typing import Optional, Tuple
8
+
9
+ from jose import JWTError, jwt
10
+ from passlib.context import CryptContext
11
+ from fastapi import Depends, Request
12
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
13
+ from sqlalchemy.orm import Session
14
+
15
+ from .database import get_db
16
+ from .models import User, LoginHistory, PasswordResetToken, LoginOtp
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _env_secret = os.environ.get("SECRET_KEY", "").strip()
21
+ if _env_secret:
22
+ SECRET_KEY = _env_secret
23
+ else:
24
+ # Never fall back to a fixed/shared string here β€” a hardcoded value in
25
+ # source is visible to anyone with the repo, and could be used to forge a
26
+ # valid login cookie for any user id without ever knowing a password.
27
+ # A fresh random secret per process closes that hole; the trade-off is
28
+ # every existing session is invalidated on each restart until a
29
+ # persistent SECRET_KEY is set in the environment (see .env.example).
30
+ SECRET_KEY = secrets.token_hex(32)
31
+ logger.warning(
32
+ "SECRET_KEY env var not set β€” generated a random one-time secret for "
33
+ "this process (existing sessions will be invalidated on restart). "
34
+ "Set SECRET_KEY to a persistent random string in your environment, "
35
+ "especially in production β€” see .env.example."
36
+ )
37
+ ALGORITHM = "HS256"
38
+ ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
39
+ COOKIE_NAME = "satellite_token"
40
+
41
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
42
+ security = HTTPBearer(auto_error=False)
43
+
44
+ # Brute-force lockout thresholds. IP-based is the primary defense (an attacker
45
+ # can't lock out a legitimate user just by failing their password from
46
+ # elsewhere); account-based is secondary. Both env-configurable.
47
+ LOGIN_IP_MAX_FAILURES = int(os.environ.get("LOGIN_IP_MAX_FAILURES", "20"))
48
+ LOGIN_IP_WINDOW_MINUTES = int(os.environ.get("LOGIN_IP_WINDOW_MINUTES", "15"))
49
+ LOGIN_ACCOUNT_MAX_FAILURES = int(os.environ.get("LOGIN_ACCOUNT_MAX_FAILURES", "5"))
50
+ LOGIN_ACCOUNT_WINDOW_MINUTES = int(os.environ.get("LOGIN_ACCOUNT_WINDOW_MINUTES", "15"))
51
+
52
+ _COOKIE_SECURE_ENV = os.environ.get("COOKIE_SECURE", "").strip().lower()
53
+ if _COOKIE_SECURE_ENV in ("1", "true", "yes"):
54
+ IS_SECURE_COOKIE = True
55
+ elif _COOKIE_SECURE_ENV in ("0", "false", "no"):
56
+ IS_SECURE_COOKIE = False
57
+ else:
58
+ # Render sets RENDER=true on its platform (genuine HTTPS hosting).
59
+ # Deliberately NOT keying off SPACE_ID: this project's own .env sets
60
+ # SPACE_ID=local-dev/satdetect-dev for *local* HTTP dev (to force
61
+ # "hosted" UI behavior, unrelated to HTTPS), so treating any SPACE_ID as
62
+ # "we're on HTTPS" would wrongly mark local dev secure and silently drop
63
+ # the auth cookie (verified via smoke test β€” this was the actual bug).
64
+ # A genuine Hugging Face Spaces deployment should set COOKIE_SECURE=true
65
+ # explicitly via the branch above.
66
+ IS_SECURE_COOKIE = bool(os.environ.get("RENDER"))
67
+
68
+
69
+ def get_client_ip(request: Request) -> str:
70
+ """Real client IP behind Render's proxy (no --proxy-headers on uvicorn, so
71
+ request.client.host would otherwise report the proxy's IP, not the visitor's)."""
72
+ xff = request.headers.get("x-forwarded-for", "")
73
+ if xff:
74
+ return xff.split(",")[0].strip()
75
+ return request.client.host if request.client else ""
76
+
77
+
78
+ def get_user_agent(request: Request) -> str:
79
+ return request.headers.get("user-agent", "")[:512]
80
+
81
+
82
+ def check_ip_throttle(db: Session, ip: str) -> bool:
83
+ """True if this IP has too many recent failed login attempts."""
84
+ if not ip:
85
+ return False
86
+ cutoff = datetime.now(timezone.utc) - timedelta(minutes=LOGIN_IP_WINDOW_MINUTES)
87
+ count = (
88
+ db.query(LoginHistory)
89
+ .filter(
90
+ LoginHistory.ip_address == ip,
91
+ LoginHistory.event_type == "login_failed",
92
+ LoginHistory.created_at >= cutoff,
93
+ )
94
+ .count()
95
+ )
96
+ return count >= LOGIN_IP_MAX_FAILURES
97
+
98
+
99
+ def check_account_lockout(db: Session, email: str) -> bool:
100
+ """True if this email has too many recent failed login attempts."""
101
+ if not email:
102
+ return False
103
+ cutoff = datetime.now(timezone.utc) - timedelta(minutes=LOGIN_ACCOUNT_WINDOW_MINUTES)
104
+ count = (
105
+ db.query(LoginHistory)
106
+ .filter(
107
+ LoginHistory.attempted_email == email,
108
+ LoginHistory.event_type == "login_failed",
109
+ LoginHistory.created_at >= cutoff,
110
+ )
111
+ .count()
112
+ )
113
+ return count >= LOGIN_ACCOUNT_MAX_FAILURES
114
+
115
+
116
+ def _login_event_role(db: Session, user_id: Optional[int]) -> str:
117
+ """Best-effort role lookup for the audit trail β€” mirrors the fallback in
118
+ app/dda/dda_auth.py::get_user_role, but avoids importing that module here
119
+ (it imports back from this one)."""
120
+ if not user_id:
121
+ return ""
122
+ try:
123
+ from sqlalchemy import text as sa_text
124
+ row = db.execute(sa_text("SELECT role FROM users WHERE id = :uid"), {"uid": user_id}).fetchone()
125
+ return str(row[0]) if row and row[0] else ""
126
+ except Exception:
127
+ return ""
128
+
129
+
130
+ def record_login_event(
131
+ db: Session,
132
+ *,
133
+ event_type: str,
134
+ attempted_email: str,
135
+ user_id: Optional[int] = None,
136
+ failure_reason: Optional[str] = None,
137
+ ip_address: str = "",
138
+ user_agent: str = "",
139
+ ) -> None:
140
+ entry = LoginHistory(
141
+ user_id=user_id,
142
+ attempted_email=attempted_email,
143
+ event_type=event_type,
144
+ failure_reason=failure_reason,
145
+ ip_address=ip_address,
146
+ user_agent=user_agent,
147
+ )
148
+ db.add(entry)
149
+ try:
150
+ from .dda.tree.audit_service import log_action
151
+ log_action(
152
+ db, event_type,
153
+ action_by=attempted_email,
154
+ role=_login_event_role(db, user_id),
155
+ module="auth",
156
+ new_value={"failure_reason": failure_reason, "ip_address": ip_address} if failure_reason else None,
157
+ )
158
+ except Exception:
159
+ pass
160
+ db.commit()
161
+
162
+
163
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
164
+ return pwd_context.verify(plain_password, hashed_password)
165
+
166
+
167
+ def get_password_hash(password: str) -> str:
168
+ return pwd_context.hash(password)
169
+
170
+
171
+ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
172
+ to_encode = data.copy()
173
+ expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
174
+ to_encode.update({"exp": expire})
175
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
176
+
177
+
178
+ PASSWORD_RESET_TOKEN_EXPIRE_MINUTES = 30
179
+
180
+
181
+ def _hash_reset_token(raw_token: str) -> str:
182
+ return hashlib.sha256(raw_token.encode()).hexdigest()
183
+
184
+
185
+ def create_password_reset_token(db: Session, user: User) -> str:
186
+ """Issue a fresh one-time reset token for `user`, invalidating any
187
+ previous unused ones (so an old, possibly-leaked link can't still work
188
+ once a new reset was requested). Returns the raw token β€” only the caller
189
+ (the emailed link) ever sees it; the DB only keeps its hash."""
190
+ db.query(PasswordResetToken).filter(
191
+ PasswordResetToken.user_id == user.id,
192
+ PasswordResetToken.used_at.is_(None),
193
+ ).delete(synchronize_session=False)
194
+
195
+ raw_token = secrets.token_urlsafe(32)
196
+ db.add(PasswordResetToken(
197
+ user_id=user.id,
198
+ token_hash=_hash_reset_token(raw_token),
199
+ expires_at=datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_TOKEN_EXPIRE_MINUTES),
200
+ ))
201
+ db.commit()
202
+ return raw_token
203
+
204
+
205
+ def consume_password_reset_token(db: Session, raw_token: str) -> Optional[User]:
206
+ """Validate + burn a reset token in one step (single use). The expiry
207
+ comparison is done in the SQL filter, not in Python, so it's never
208
+ exposed to naive/aware datetime mismatches from what the DB driver hands
209
+ back for a stored DATETIME column."""
210
+ if not raw_token:
211
+ return None
212
+ now = datetime.now(timezone.utc)
213
+ entry = (
214
+ db.query(PasswordResetToken)
215
+ .filter(
216
+ PasswordResetToken.token_hash == _hash_reset_token(raw_token),
217
+ PasswordResetToken.used_at.is_(None),
218
+ PasswordResetToken.expires_at >= now,
219
+ )
220
+ .first()
221
+ )
222
+ if not entry:
223
+ return None
224
+ entry.used_at = now
225
+ db.commit()
226
+ return get_user_by_id(db, entry.user_id)
227
+
228
+
229
+ def get_user_by_email(db: Session, email: str) -> Optional[User]:
230
+ """Case-insensitive email lookup with whitespace stripped.
231
+
232
+ Emails are supposed to be stored lowercase (see register()), but a) users
233
+ typing them in login forms may leave stray whitespace or capitalisation,
234
+ and b) older accounts predating the .lower() normalisation may still be
235
+ mixed-case. Using LOWER()/TRIM() on both sides avoids the case where the
236
+ same email is treated as "already exists" by one route and "not found" by
237
+ another.
238
+ """
239
+ if not email:
240
+ return None
241
+ from sqlalchemy import func
242
+ normalised = email.strip().lower()
243
+ return (
244
+ db.query(User)
245
+ .filter(func.lower(func.trim(User.email)) == normalised)
246
+ .first()
247
+ )
248
+
249
+
250
+ def get_user_by_phone(db: Session, phone: str) -> Optional[User]:
251
+ if not phone:
252
+ return None
253
+ return db.query(User).filter(User.phone == phone).first()
254
+
255
+
256
+ def get_user_by_id(db: Session, user_id: int) -> Optional[User]:
257
+ return db.query(User).filter(User.id == user_id).first()
258
+
259
+
260
+ def normalize_phone(raw: str, country_code: Optional[str] = None) -> Optional[str]:
261
+ """Return the number as E.164 ("+CCXXXXXXXX"), or None if invalid.
262
+
263
+ Accepts either a raw string that already starts with '+', or a local
264
+ number plus a separate country dial code. India-specific rules (mobile
265
+ prefix must be 6-9, exactly 10 digits) are applied when the country
266
+ dial code resolves to '+91'; every other country is checked only for
267
+ total length (7–15 digits per the E.164 spec).
268
+ """
269
+ raw = (raw or "").strip()
270
+ cc = (country_code or "").strip()
271
+ if raw.startswith("+"):
272
+ digits = re.sub(r"\D", "", raw)
273
+ e164_country = ""
274
+ if not cc:
275
+ for dial in ("91", "1", "44", "61", "971", "65", "880", "94", "977", "92"):
276
+ if digits.startswith(dial):
277
+ e164_country = "+" + dial
278
+ break
279
+ else:
280
+ e164_country = "+" + re.sub(r"\D", "", cc)
281
+ if not digits.startswith(e164_country[1:]):
282
+ return None
283
+ local = digits[len(e164_country) - 1:] if e164_country else digits
284
+ else:
285
+ if not cc:
286
+ cc = "+91"
287
+ e164_country = "+" + re.sub(r"\D", "", cc)
288
+ local = re.sub(r"\D", "", raw)
289
+ if local.startswith(e164_country[1:]) and len(local) > 10:
290
+ local = local[len(e164_country) - 1:]
291
+ if not e164_country.startswith("+") or len(e164_country) < 2:
292
+ return None
293
+ if not local.isdigit():
294
+ return None
295
+ total = e164_country[1:] + local
296
+ if not (7 <= len(total) <= 15):
297
+ return None
298
+ if e164_country == "+91" and (len(local) != 10 or local[0] not in "6789"):
299
+ return None
300
+ return e164_country + local
301
+
302
+
303
+ def mask_phone(phone: str) -> str:
304
+ """Mask an E.164 number for display: '+91******3210'."""
305
+ p = (phone or "").strip()
306
+ if not p:
307
+ return "******"
308
+ if p.startswith("+"):
309
+ digits = re.sub(r"\D", "", p)
310
+ if len(digits) <= 4:
311
+ return "+" + digits
312
+ for dial in ("91", "1", "44", "61", "971", "65", "880", "94", "977", "92"):
313
+ if digits.startswith(dial) and len(digits) > len(dial) + 3:
314
+ local = digits[len(dial):]
315
+ return f"+{dial}{'*' * max(0, len(local) - 4)}{local[-4:]}"
316
+ return "+" + "*" * max(0, len(digits) - 4) + digits[-4:]
317
+ digits = re.sub(r"\D", "", p)
318
+ if len(digits) < 4:
319
+ return "******"
320
+ return f"******{digits[-4:]}"
321
+
322
+
323
+ def get_user_from_token(token: str, db: Session, *, allow_pending_otp: bool = False) -> Optional[User]:
324
+ """Resolve user from JWT token.
325
+
326
+ Tokens issued after password-check, before SMS OTP, carry
327
+ purpose=otp_pending and must not grant a session by themselves.
328
+ """
329
+ if not token:
330
+ return None
331
+ try:
332
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
333
+ purpose = payload.get("purpose") or "session"
334
+ if purpose == "otp_pending" and not allow_pending_otp:
335
+ return None
336
+ if purpose not in ("session", "otp_pending"):
337
+ return None
338
+ user_id_str = payload.get("sub")
339
+ if user_id_str is None:
340
+ return None
341
+ try:
342
+ user_id = int(user_id_str)
343
+ except (ValueError, TypeError):
344
+ logger.warning("JWT 'sub' claim is not a valid integer")
345
+ return None
346
+ except JWTError:
347
+ return None
348
+ return get_user_by_id(db, user_id)
349
+
350
+
351
+ LOGIN_OTP_EXPIRE_MINUTES = 10
352
+ LOGIN_OTP_MAX_ATTEMPTS = 5
353
+ LOGIN_OTP_RESEND_SECONDS = 45
354
+
355
+
356
+ def _hash_otp(code: str) -> str:
357
+ return hashlib.sha256(code.encode()).hexdigest()
358
+
359
+
360
+ def mask_email(email: str) -> str:
361
+ local, _, domain = (email or "").partition("@")
362
+ if not domain:
363
+ return "***"
364
+ shown = (local[:1] + "***") if len(local) <= 2 else (local[:2] + "***")
365
+ return f"{shown}@{domain}"
366
+
367
+
368
+ def create_login_otp(db: Session, user: User) -> Tuple[LoginOtp, str]:
369
+ """Issue a fresh 6-digit email OTP, invalidating unused ones for this user."""
370
+ db.query(LoginOtp).filter(
371
+ LoginOtp.user_id == user.id,
372
+ LoginOtp.used_at.is_(None),
373
+ ).delete(synchronize_session=False)
374
+ raw = f"{secrets.randbelow(1_000_000):06d}"
375
+ row = LoginOtp(
376
+ user_id=user.id,
377
+ code_hash=_hash_otp(raw),
378
+ expires_at=datetime.now(timezone.utc) + timedelta(minutes=LOGIN_OTP_EXPIRE_MINUTES),
379
+ attempts=0,
380
+ )
381
+ db.add(row)
382
+ db.commit()
383
+ db.refresh(row)
384
+ return row, raw
385
+
386
+
387
+ def latest_unused_otp(db: Session, user_id: int) -> Optional[LoginOtp]:
388
+ return (
389
+ db.query(LoginOtp)
390
+ .filter(LoginOtp.user_id == user_id, LoginOtp.used_at.is_(None))
391
+ .order_by(LoginOtp.id.desc())
392
+ .first()
393
+ )
394
+
395
+
396
+ def verify_login_otp(db: Session, user: User, otp_id: int, code: str) -> str:
397
+ """Return '' if valid; otherwise a short error token for the caller to map."""
398
+ cleaned = (code or "").strip().replace(" ", "")
399
+ now = datetime.now(timezone.utc)
400
+ row = (
401
+ db.query(LoginOtp)
402
+ .filter(
403
+ LoginOtp.id == otp_id,
404
+ LoginOtp.user_id == user.id,
405
+ LoginOtp.used_at.is_(None),
406
+ )
407
+ .first()
408
+ )
409
+ if not row:
410
+ return "expired"
411
+ exp = row.expires_at
412
+ if exp.tzinfo is None:
413
+ exp = exp.replace(tzinfo=timezone.utc)
414
+ if exp < now:
415
+ return "expired"
416
+ if (row.attempts or 0) >= LOGIN_OTP_MAX_ATTEMPTS:
417
+ return "locked"
418
+ if not cleaned.isdigit() or len(cleaned) != 6 or row.code_hash != _hash_otp(cleaned):
419
+ row.attempts = (row.attempts or 0) + 1
420
+ db.commit()
421
+ return "invalid"
422
+ row.used_at = now
423
+ db.commit()
424
+ return ""
425
+
426
+
427
+ def get_or_create_guest_user(db: Session) -> User:
428
+ """Shared anonymous account when login is disabled."""
429
+ guest_email = "__guest__@system.local"
430
+ user = get_user_by_email(db, guest_email)
431
+ if user:
432
+ return user
433
+ user = User(
434
+ email=guest_email,
435
+ hashed_password=get_password_hash("guest-not-used"),
436
+ full_name="Guest",
437
+ )
438
+ db.add(user)
439
+ db.commit()
440
+ db.refresh(user)
441
+ return user
442
+
443
+
444
+ def get_current_user(
445
+ request: Request,
446
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
447
+ db: Session = Depends(get_db),
448
+ ) -> Optional[User]:
449
+ if credentials:
450
+ user = get_user_from_token(credentials.credentials, db)
451
+ if user:
452
+ return user
453
+ token = request.cookies.get(COOKIE_NAME)
454
+ if token:
455
+ return get_user_from_token(token, db)
456
+ return None
app/cd_models/__init__.py ADDED
File without changes
app/cd_models/bit_cd.py ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BIT_CD: Bitemporal Image Transformer for change detection (vendored).
2
+
3
+ Self-contained port of the ``base_transformer_pos_s4_dd8_dedim8`` network from
4
+ https://github.com/justchenhao/BIT_CD (Chen et al., "Remote Sensing Image
5
+ Change Detection with Transformers", TGRS 2021), rewritten without the einops
6
+ dependency and using the torchvision ResNet18 backbone.
7
+
8
+ Weights are NOT bundled. Place a LEVIR-CD checkpoint at
9
+ ``app/cd_models/weights/bit_cd_levir.pth`` (or point ``BIT_CD_WEIGHTS`` at it).
10
+ Both the raw training checkpoint (``model_G_state_dict`` key, from the
11
+ official repo's best_ckpt.pt) and a plain converted state_dict are accepted.
12
+
13
+ Used by ``model_inference`` as an optional second model ensembled with
14
+ AdaptFormer when ``DETECTION_ENSEMBLE=on``.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import os
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ import numpy as np
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _BIT_MODEL = None
28
+ _BIT_DEVICE = None
29
+ _BIT_FAILED = False
30
+
31
+ WEIGHTS_DIR = Path(__file__).resolve().parent / "weights"
32
+ DEFAULT_WEIGHTS = WEIGHTS_DIR / "bit_cd_levir.pth"
33
+ TILE_SIZE = 256 # LEVIR-CD native patch size
34
+
35
+
36
+ def weights_path() -> Path:
37
+ env = os.environ.get("BIT_CD_WEIGHTS", "").strip()
38
+ return Path(env) if env else DEFAULT_WEIGHTS
39
+
40
+
41
+ def weights_available() -> bool:
42
+ p = weights_path()
43
+ return p.is_file() and p.stat().st_size > 1_000_000
44
+
45
+
46
+ def _build_modules():
47
+ import torch
48
+ from torch import nn
49
+
50
+ class TwoLayerConv2d(nn.Sequential):
51
+ def __init__(self, in_channels, out_channels, kernel_size=3):
52
+ super().__init__(
53
+ nn.Conv2d(in_channels, in_channels, kernel_size=kernel_size,
54
+ padding=kernel_size // 2, stride=1, bias=False),
55
+ nn.BatchNorm2d(in_channels),
56
+ nn.ReLU(),
57
+ nn.Conv2d(in_channels, out_channels, kernel_size=kernel_size,
58
+ padding=kernel_size // 2, stride=1),
59
+ )
60
+
61
+ class Residual(nn.Module):
62
+ def __init__(self, fn):
63
+ super().__init__()
64
+ self.fn = fn
65
+
66
+ def forward(self, x, **kw):
67
+ return self.fn(x, **kw) + x
68
+
69
+ class Residual2(nn.Module):
70
+ def __init__(self, fn):
71
+ super().__init__()
72
+ self.fn = fn
73
+
74
+ def forward(self, x, m, **kw):
75
+ return self.fn(x, m, **kw) + x
76
+
77
+ class PreNorm(nn.Module):
78
+ def __init__(self, dim, fn):
79
+ super().__init__()
80
+ self.norm = nn.LayerNorm(dim)
81
+ self.fn = fn
82
+
83
+ def forward(self, x, **kw):
84
+ return self.fn(self.norm(x), **kw)
85
+
86
+ class PreNorm2(nn.Module):
87
+ def __init__(self, dim, fn):
88
+ super().__init__()
89
+ self.norm = nn.LayerNorm(dim)
90
+ self.fn = fn
91
+
92
+ def forward(self, x, m, **kw):
93
+ return self.fn(self.norm(x), self.norm(m), **kw)
94
+
95
+ class FeedForward(nn.Module):
96
+ def __init__(self, dim, hidden_dim):
97
+ super().__init__()
98
+ self.net = nn.Sequential(
99
+ nn.Linear(dim, hidden_dim), nn.GELU(), nn.Dropout(0.0),
100
+ nn.Linear(hidden_dim, dim), nn.Dropout(0.0),
101
+ )
102
+
103
+ def forward(self, x):
104
+ return self.net(x)
105
+
106
+ def _split_heads(t, heads):
107
+ b, n, hd = t.shape
108
+ d = hd // heads
109
+ return t.view(b, n, heads, d).permute(0, 2, 1, 3)
110
+
111
+ def _merge_heads(t):
112
+ b, h, n, d = t.shape
113
+ return t.permute(0, 2, 1, 3).reshape(b, n, h * d)
114
+
115
+ class Attention(nn.Module):
116
+ def __init__(self, dim, heads=8, dim_head=64):
117
+ super().__init__()
118
+ inner = dim_head * heads
119
+ self.heads = heads
120
+ self.scale = dim ** -0.5
121
+ self.to_qkv = nn.Linear(dim, inner * 3, bias=False)
122
+ self.to_out = nn.Sequential(nn.Linear(inner, dim), nn.Dropout(0.0))
123
+
124
+ def forward(self, x):
125
+ q, k, v = self.to_qkv(x).chunk(3, dim=-1)
126
+ q, k, v = (_split_heads(t, self.heads) for t in (q, k, v))
127
+ attn = (q @ k.transpose(-1, -2) * self.scale).softmax(dim=-1)
128
+ return self.to_out(_merge_heads(attn @ v))
129
+
130
+ class CrossAttention(nn.Module):
131
+ def __init__(self, dim, heads=8, dim_head=64, softmax=True):
132
+ super().__init__()
133
+ inner = dim_head * heads
134
+ self.heads = heads
135
+ self.scale = dim ** -0.5
136
+ self.softmax = softmax
137
+ self.to_q = nn.Linear(dim, inner, bias=False)
138
+ self.to_k = nn.Linear(dim, inner, bias=False)
139
+ self.to_v = nn.Linear(dim, inner, bias=False)
140
+ self.to_out = nn.Sequential(nn.Linear(inner, dim), nn.Dropout(0.0))
141
+
142
+ def forward(self, x, m):
143
+ q = _split_heads(self.to_q(x), self.heads)
144
+ k = _split_heads(self.to_k(m), self.heads)
145
+ v = _split_heads(self.to_v(m), self.heads)
146
+ dots = q @ k.transpose(-1, -2) * self.scale
147
+ attn = dots.softmax(dim=-1) if self.softmax else dots
148
+ return self.to_out(_merge_heads(attn @ v))
149
+
150
+ class Transformer(nn.Module):
151
+ def __init__(self, dim, depth, heads, dim_head, mlp_dim):
152
+ super().__init__()
153
+ self.layers = nn.ModuleList([
154
+ nn.ModuleList([
155
+ Residual(PreNorm(dim, Attention(dim, heads, dim_head))),
156
+ Residual(PreNorm(dim, FeedForward(dim, mlp_dim))),
157
+ ]) for _ in range(depth)
158
+ ])
159
+
160
+ def forward(self, x):
161
+ for attn, ff in self.layers:
162
+ x = attn(x)
163
+ x = ff(x)
164
+ return x
165
+
166
+ class TransformerDecoder(nn.Module):
167
+ def __init__(self, dim, depth, heads, dim_head, mlp_dim, softmax=True):
168
+ super().__init__()
169
+ self.layers = nn.ModuleList([
170
+ nn.ModuleList([
171
+ Residual2(PreNorm2(dim, CrossAttention(dim, heads, dim_head, softmax))),
172
+ Residual(PreNorm(dim, FeedForward(dim, mlp_dim))),
173
+ ]) for _ in range(depth)
174
+ ])
175
+
176
+ def forward(self, x, m):
177
+ for attn, ff in self.layers:
178
+ x = attn(x, m)
179
+ x = ff(x)
180
+ return x
181
+
182
+ class DilatedBasicBlock(nn.Module):
183
+ """torchvision BasicBlock with dilation allowed (as in BIT_CD's resnet)."""
184
+ expansion = 1
185
+
186
+ def __init__(self, inplanes, planes, stride=1, downsample=None, dilation=1):
187
+ super().__init__()
188
+ self.conv1 = nn.Conv2d(inplanes, planes, 3, stride=stride,
189
+ padding=dilation, dilation=dilation, bias=False)
190
+ self.bn1 = nn.BatchNorm2d(planes)
191
+ self.relu = nn.ReLU(inplace=True)
192
+ self.conv2 = nn.Conv2d(planes, planes, 3, padding=dilation,
193
+ dilation=dilation, bias=False)
194
+ self.bn2 = nn.BatchNorm2d(planes)
195
+ self.downsample = downsample
196
+
197
+ def forward(self, x):
198
+ identity = x
199
+ out = self.relu(self.bn1(self.conv1(x)))
200
+ out = self.bn2(self.conv2(out))
201
+ if self.downsample is not None:
202
+ identity = self.downsample(x)
203
+ return self.relu(out + identity)
204
+
205
+ class DilatedResNet18(nn.Module):
206
+ """ResNet18 trunk with replace_stride_with_dilation=[False, True, True].
207
+
208
+ Same module naming as torchvision so BIT_CD checkpoints load directly.
209
+ """
210
+
211
+ def __init__(self):
212
+ super().__init__()
213
+ self.inplanes = 64
214
+ self.dilation = 1
215
+ self.conv1 = nn.Conv2d(3, 64, 7, stride=2, padding=3, bias=False)
216
+ self.bn1 = nn.BatchNorm2d(64)
217
+ self.relu = nn.ReLU(inplace=True)
218
+ self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
219
+ # replace_stride_with_dilation=[False, True, True]: layer2 keeps
220
+ # stride 2; layers 3-4 trade stride for dilation.
221
+ self.layer1 = self._make_layer(64, 2, stride=1, dilate=False)
222
+ self.layer2 = self._make_layer(128, 2, stride=2, dilate=False)
223
+ self.layer3 = self._make_layer(256, 2, stride=2, dilate=True)
224
+ self.layer4 = self._make_layer(512, 2, stride=2, dilate=True)
225
+
226
+ def _make_layer(self, planes, blocks, stride, dilate):
227
+ downsample = None
228
+ previous_dilation = self.dilation
229
+ if dilate:
230
+ self.dilation *= stride
231
+ stride = 1
232
+ if stride != 1 or self.inplanes != planes:
233
+ downsample = nn.Sequential(
234
+ nn.Conv2d(self.inplanes, planes, 1, stride=stride, bias=False),
235
+ nn.BatchNorm2d(planes),
236
+ )
237
+ layers = [DilatedBasicBlock(self.inplanes, planes, stride,
238
+ downsample, previous_dilation)]
239
+ self.inplanes = planes
240
+ for _ in range(1, blocks):
241
+ layers.append(DilatedBasicBlock(planes, planes, dilation=self.dilation))
242
+ return nn.Sequential(*layers)
243
+
244
+ class BITTransformer(nn.Module):
245
+ """ResNet18 (4 stages, dilated) + token transformer + difference head."""
246
+
247
+ def __init__(self, output_nc=2, token_len=4, enc_depth=1, dec_depth=8,
248
+ dim_head=64, decoder_dim_head=8):
249
+ super().__init__()
250
+ self.resnet = DilatedResNet18()
251
+ self.relu = nn.ReLU()
252
+ self.upsamplex2 = nn.Upsample(scale_factor=2)
253
+ self.upsamplex4 = nn.Upsample(scale_factor=4, mode="bilinear")
254
+ self.classifier = TwoLayerConv2d(in_channels=32, out_channels=output_nc)
255
+ self.conv_pred = nn.Conv2d(256, 32, kernel_size=3, padding=1)
256
+
257
+ self.token_len = token_len
258
+ dim = 32
259
+ self.conv_a = nn.Conv2d(dim, token_len, kernel_size=1, padding=0, bias=False)
260
+ self.pos_embedding = nn.Parameter(torch.randn(1, token_len * 2, dim))
261
+ self.transformer = Transformer(dim, enc_depth, 8, dim_head, 2 * dim)
262
+ self.transformer_decoder = TransformerDecoder(
263
+ dim, dec_depth, 8, decoder_dim_head, 2 * dim, softmax=True)
264
+
265
+ def forward_single(self, x):
266
+ x = self.resnet.conv1(x)
267
+ x = self.resnet.bn1(x)
268
+ x = self.resnet.relu(x)
269
+ x = self.resnet.maxpool(x)
270
+ x = self.resnet.layer1(x)
271
+ x = self.resnet.layer2(x)
272
+ x = self.resnet.layer3(x) # stages_num=4 -> stop before layer4
273
+ x = self.upsamplex2(x)
274
+ return self.conv_pred(x)
275
+
276
+ def _semantic_tokens(self, x):
277
+ b, c, h, w = x.shape
278
+ att = self.conv_a(x).view(b, self.token_len, -1).softmax(dim=-1)
279
+ flat = x.view(b, c, -1)
280
+ return torch.einsum("bln,bcn->blc", att, flat)
281
+
282
+ def _decode(self, x, m):
283
+ b, c, h, w = x.shape
284
+ seq = x.flatten(2).transpose(1, 2)
285
+ seq = self.transformer_decoder(seq, m)
286
+ return seq.transpose(1, 2).reshape(b, c, h, w)
287
+
288
+ def forward(self, x1, x2):
289
+ x1 = self.forward_single(x1)
290
+ x2 = self.forward_single(x2)
291
+ t1 = self._semantic_tokens(x1)
292
+ t2 = self._semantic_tokens(x2)
293
+ tokens = torch.cat([t1, t2], dim=1) + self.pos_embedding
294
+ tokens = self.transformer(tokens)
295
+ t1, t2 = tokens.chunk(2, dim=1)
296
+ x1 = self._decode(x1, t1)
297
+ x2 = self._decode(x2, t2)
298
+ x = torch.abs(x1 - x2)
299
+ x = self.upsamplex4(x)
300
+ return self.classifier(x)
301
+
302
+ return BITTransformer
303
+
304
+
305
+ def _extract_state_dict(checkpoint) -> dict:
306
+ if isinstance(checkpoint, dict):
307
+ for key in ("model_G_state_dict", "state_dict", "model_state_dict"):
308
+ if key in checkpoint and isinstance(checkpoint[key], dict):
309
+ checkpoint = checkpoint[key]
310
+ break
311
+ return {k[7:] if k.startswith("module.") else k: v for k, v in checkpoint.items()}
312
+
313
+
314
+ def load_bit_model():
315
+ """Load BIT_CD with LEVIR weights. Returns None when unavailable."""
316
+ global _BIT_MODEL, _BIT_DEVICE, _BIT_FAILED
317
+ if _BIT_MODEL is not None:
318
+ return _BIT_MODEL
319
+ if _BIT_FAILED or not weights_available():
320
+ return None
321
+ try:
322
+ import torch
323
+
324
+ BITTransformer = _build_modules()
325
+ model = BITTransformer()
326
+ checkpoint = torch.load(weights_path(), map_location="cpu", weights_only=False)
327
+ state = _extract_state_dict(checkpoint)
328
+ missing, unexpected = model.load_state_dict(state, strict=False)
329
+ real_missing = [k for k in missing if "num_batches_tracked" not in k]
330
+ if len(real_missing) > 20:
331
+ raise RuntimeError(
332
+ f"checkpoint mismatch: {len(real_missing)} missing keys "
333
+ f"(e.g. {real_missing[:4]}), {len(unexpected)} unexpected")
334
+ if real_missing or unexpected:
335
+ logger.warning("BIT_CD loaded with %d missing / %d unexpected keys",
336
+ len(real_missing), len(unexpected))
337
+ _BIT_DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
338
+ model.to(_BIT_DEVICE)
339
+ model.eval()
340
+ _BIT_MODEL = model
341
+ logger.info("BIT_CD loaded from %s on %s", weights_path(), _BIT_DEVICE)
342
+ return _BIT_MODEL
343
+ except Exception as exc:
344
+ _BIT_FAILED = True
345
+ logger.error("BIT_CD load failed: %s", exc)
346
+ return None
347
+
348
+
349
+ def bit_score_map(img1: np.ndarray, img2: np.ndarray) -> Optional[np.ndarray]:
350
+ """Tiled BIT_CD change probability map in [0,1] at (h, w), or None."""
351
+ model = load_bit_model()
352
+ if model is None:
353
+ return None
354
+ try:
355
+ import torch
356
+
357
+ from .model_utils import tiled_score_map
358
+
359
+ # ImageNet-style normalization matching the BIT_CD training transforms
360
+ mean = np.array([0.5, 0.5, 0.5], dtype=np.float32)
361
+ std = np.array([0.5, 0.5, 0.5], dtype=np.float32)
362
+
363
+ def _to_tensor(arr):
364
+ t = (arr.astype(np.float32) / 255.0 - mean) / std
365
+ return torch.from_numpy(t.transpose(2, 0, 1)).unsqueeze(0).to(_BIT_DEVICE)
366
+
367
+ def _score_tile(t1, t2):
368
+ with torch.no_grad():
369
+ logits = model(_to_tensor(t1), _to_tensor(t2))
370
+ prob = torch.softmax(logits, dim=1)[0, 1]
371
+ return prob.cpu().numpy().astype(np.float32)
372
+
373
+ return tiled_score_map(_score_tile, img1, img2,
374
+ tile_size=TILE_SIZE, overlap=TILE_SIZE // 4)
375
+ except Exception as exc:
376
+ logger.warning("BIT_CD scoring failed: %s", exc)
377
+ return None
378
+
379
+
380
+ def bit_status() -> dict:
381
+ return {
382
+ "weightsPath": str(weights_path()),
383
+ "weightsAvailable": weights_available(),
384
+ "loaded": _BIT_MODEL is not None,
385
+ "loadFailed": _BIT_FAILED,
386
+ }
app/cd_models/change_model.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Siamese U-Net for satellite change detection.
3
+
4
+ A lightweight Siamese encoder shares weights between before/after images,
5
+ fuses features via concatenation + difference, and decodes into a binary
6
+ change probability map.
7
+
8
+ Designed for CPU inference (< 2s per 256x256 tile).
9
+ """
10
+ import logging
11
+ import os
12
+ from pathlib import Path
13
+
14
+ import cv2
15
+ import numpy as np
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _MODEL = None
20
+ _DEVICE = None
21
+ _AVAILABLE = None
22
+ _WEIGHTS_DIR = Path(__file__).parent / "weights"
23
+ _WEIGHTS_FILE = _WEIGHTS_DIR / "siamese_unet_cd.pt"
24
+
25
+
26
+ def _try_torch():
27
+ try:
28
+ import torch
29
+ import torch.nn as nn
30
+ return torch, nn
31
+ except ImportError:
32
+ return None, None
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Model architecture
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def _build_model():
40
+ torch, nn = _try_torch()
41
+ if torch is None:
42
+ return None
43
+
44
+ class ConvBlock(nn.Module):
45
+ def __init__(self, in_ch, out_ch):
46
+ super().__init__()
47
+ self.block = nn.Sequential(
48
+ nn.Conv2d(in_ch, out_ch, 3, padding=1, bias=False),
49
+ nn.BatchNorm2d(out_ch),
50
+ nn.ReLU(inplace=True),
51
+ nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False),
52
+ nn.BatchNorm2d(out_ch),
53
+ nn.ReLU(inplace=True),
54
+ )
55
+ def forward(self, x):
56
+ return self.block(x)
57
+
58
+ class Encoder(nn.Module):
59
+ def __init__(self, in_ch=3, base=32):
60
+ super().__init__()
61
+ self.enc1 = ConvBlock(in_ch, base)
62
+ self.enc2 = ConvBlock(base, base * 2)
63
+ self.enc3 = ConvBlock(base * 2, base * 4)
64
+ self.enc4 = ConvBlock(base * 4, base * 8)
65
+ self.pool = nn.MaxPool2d(2)
66
+
67
+ def forward(self, x):
68
+ e1 = self.enc1(x)
69
+ e2 = self.enc2(self.pool(e1))
70
+ e3 = self.enc3(self.pool(e2))
71
+ e4 = self.enc4(self.pool(e3))
72
+ return [e1, e2, e3, e4]
73
+
74
+ class SiameseUNet(nn.Module):
75
+ """
76
+ Siamese U-Net: shared encoder processes before/after images independently.
77
+ Decoder fuses features via concatenation of both streams + their absolute
78
+ difference, providing the decoder with explicit change information.
79
+ """
80
+ def __init__(self, in_ch=3, base=32, out_ch=2):
81
+ super().__init__()
82
+ self.encoder = Encoder(in_ch, base)
83
+ b = base
84
+
85
+ # Decoder: at each level receives [enc_a, enc_b, |enc_a-enc_b|] = 3x channels
86
+ self.up4 = nn.ConvTranspose2d(b * 8, b * 4, 2, stride=2)
87
+ self.dec4 = ConvBlock(b * 4 + b * 4 * 3, b * 4)
88
+
89
+ self.up3 = nn.ConvTranspose2d(b * 4, b * 2, 2, stride=2)
90
+ self.dec3 = ConvBlock(b * 2 + b * 2 * 3, b * 2)
91
+
92
+ self.up2 = nn.ConvTranspose2d(b * 2, b, 2, stride=2)
93
+ self.dec2 = ConvBlock(b + b * 3, b)
94
+
95
+ self.head = nn.Conv2d(b, out_ch, 1)
96
+
97
+ def forward(self, img_a, img_b):
98
+ feats_a = self.encoder(img_a)
99
+ feats_b = self.encoder(img_b)
100
+
101
+ # Bottleneck: fuse deepest features
102
+ bot = torch.cat([feats_a[3], feats_b[3], torch.abs(feats_a[3] - feats_b[3])], dim=1)
103
+
104
+ import torch.nn.functional as F
105
+ # Level 3
106
+ d4 = self.up4(feats_a[3])
107
+ skip3 = torch.cat([feats_a[2], feats_b[2], torch.abs(feats_a[2] - feats_b[2])], dim=1)
108
+ d4 = self.dec4(torch.cat([d4, skip3], dim=1))
109
+
110
+ # Level 2
111
+ d3 = self.up3(d4)
112
+ skip2 = torch.cat([feats_a[1], feats_b[1], torch.abs(feats_a[1] - feats_b[1])], dim=1)
113
+ d3 = self.dec3(torch.cat([d3, skip2], dim=1))
114
+
115
+ # Level 1
116
+ d2 = self.up2(d3)
117
+ skip1 = torch.cat([feats_a[0], feats_b[0], torch.abs(feats_a[0] - feats_b[0])], dim=1)
118
+ d2 = self.dec2(torch.cat([d2, skip1], dim=1))
119
+
120
+ return self.head(d2)
121
+
122
+ return SiameseUNet
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Model loading (singleton)
127
+ # ---------------------------------------------------------------------------
128
+
129
+ def has_siamese_weights():
130
+ """True only when a trained weights file is present."""
131
+ return _WEIGHTS_FILE.is_file()
132
+
133
+
134
+ def is_siamese_available():
135
+ """PyTorch installed and pretrained weights available."""
136
+ global _AVAILABLE
137
+ if _AVAILABLE is not None:
138
+ return _AVAILABLE
139
+ torch, _ = _try_torch()
140
+ _AVAILABLE = torch is not None and has_siamese_weights()
141
+ return _AVAILABLE
142
+
143
+
144
+ def _load_siamese():
145
+ global _MODEL, _DEVICE
146
+ if _MODEL is not None:
147
+ return _MODEL
148
+
149
+ torch, _ = _try_torch()
150
+ if torch is None:
151
+ raise RuntimeError("PyTorch not installed")
152
+
153
+ _DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
154
+
155
+ ModelClass = _build_model()
156
+ model = ModelClass(in_ch=3, base=32, out_ch=2)
157
+
158
+ if _WEIGHTS_FILE.exists():
159
+ logger.info("Loading Siamese U-Net weights from %s", _WEIGHTS_FILE)
160
+ state = torch.load(str(_WEIGHTS_FILE), map_location=_DEVICE, weights_only=True)
161
+ model.load_state_dict(state)
162
+ else:
163
+ logger.info("No pretrained weights found at %s β€” using random init "
164
+ "(model will still produce change maps but accuracy depends on "
165
+ "classical fusion weighting)", _WEIGHTS_FILE)
166
+
167
+ model.to(_DEVICE)
168
+ model.eval()
169
+ _MODEL = model
170
+ return _MODEL
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Inference
175
+ # ---------------------------------------------------------------------------
176
+
177
+ _TILE = 256
178
+
179
+
180
+ def predict_siamese(img1, img2, threshold=0.5):
181
+ """
182
+ Run Siamese U-Net inference on two RGB uint8 arrays.
183
+ Tile-based with overlap stitching (same pattern as AdaptFormer).
184
+ Returns (uint8 mask [0|255], float32 probability map [0-1]).
185
+ """
186
+ torch, _ = _try_torch()
187
+ model = _load_siamese()
188
+
189
+ if img1.shape != img2.shape:
190
+ img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
191
+
192
+ from .model_utils import tiled_score_map
193
+
194
+ mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
195
+ std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
196
+
197
+ def _score_tile(t1, t2):
198
+ n1 = (t1.astype(np.float32) / 255.0 - mean) / std
199
+ n2 = (t2.astype(np.float32) / 255.0 - mean) / std
200
+ ta = torch.from_numpy(n1.transpose(2, 0, 1)).unsqueeze(0).to(_DEVICE)
201
+ tb = torch.from_numpy(n2.transpose(2, 0, 1)).unsqueeze(0).to(_DEVICE)
202
+ logits = model(ta, tb)
203
+ probs = torch.softmax(logits, dim=1)
204
+ return probs[0, 1].cpu().numpy()
205
+
206
+ with torch.no_grad():
207
+ avg = tiled_score_map(_score_tile, img1, img2,
208
+ tile_size=_TILE, overlap=_TILE // 4)
209
+
210
+ mask = (avg >= threshold).astype(np.uint8) * 255
211
+ return mask, avg
app/cd_models/irmad.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """IR-MAD: Iteratively Reweighted Multivariate Alteration Detection.
2
+
3
+ Classical unsupervised change detection statistic (Nielsen 2007), listed among
4
+ the strongest traditional methods in the Change-Detection-Review repository
5
+ (https://github.com/MinZHANG-WHU/Change-Detection-Review).
6
+
7
+ MAD finds paired linear combinations of the two images' bands (via canonical
8
+ correlation analysis) whose differences maximally decorrelate; the chi-square
9
+ sum of squared standardized MAD variates measures change. The IR step
10
+ re-estimates the statistics using no-change weights so genuine changes don't
11
+ bias the transform, sharpening separation between change and no-change.
12
+
13
+ Pure numpy β€” no extra dependencies. Runs on a bounded-resolution copy for CPU
14
+ practicality and returns both the change probability map and the final
15
+ no-change weights (useful for regression-based radiometric normalization).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from dataclasses import dataclass
21
+ from typing import Optional, Tuple
22
+
23
+ import cv2
24
+ import numpy as np
25
+
26
+ _log = logging.getLogger(__name__)
27
+
28
+
29
+ @dataclass
30
+ class IRMADResult:
31
+ chi2: np.ndarray # float32, raw chi-square statistic (analysis res)
32
+ change_prob: np.ndarray # float32 [0,1], at the input resolution
33
+ no_change_weights: np.ndarray # float32 [0,1], at the input resolution
34
+ iterations: int
35
+ converged: bool
36
+
37
+
38
+ def _weighted_stats(x: np.ndarray, y: np.ndarray, w: np.ndarray):
39
+ """Weighted means and joint covariance of two multiband pixel matrices."""
40
+ wsum = float(np.sum(w)) + 1e-12
41
+ mx = (x * w[:, None]).sum(axis=0) / wsum
42
+ my = (y * w[:, None]).sum(axis=0) / wsum
43
+ xc = x - mx
44
+ yc = y - my
45
+ sxx = (xc * w[:, None]).T @ xc / wsum
46
+ syy = (yc * w[:, None]).T @ yc / wsum
47
+ sxy = (xc * w[:, None]).T @ yc / wsum
48
+ return mx, my, sxx, syy, sxy, xc, yc
49
+
50
+
51
+ def _chi2_cdf(x: np.ndarray, k: int) -> np.ndarray:
52
+ """Chi-square CDF via the regularized lower incomplete gamma function."""
53
+ try:
54
+ from scipy.special import gammainc
55
+ return gammainc(k / 2.0, x / 2.0)
56
+ except Exception:
57
+ # Wilson–Hilferty normal approximation (good for k>=3)
58
+ z = ((x / k) ** (1.0 / 3.0) - (1 - 2.0 / (9 * k))) / np.sqrt(2.0 / (9 * k))
59
+ return 0.5 * (1.0 + np.tanh(z * 0.79788456 * (1 + 0.044715 * z * z)))
60
+
61
+
62
+ def compute_irmad(
63
+ img1: np.ndarray,
64
+ img2: np.ndarray,
65
+ *,
66
+ max_iters: int = 10,
67
+ tol: float = 1e-3,
68
+ max_side: int = 1024,
69
+ eps: float = 1e-6,
70
+ ) -> Optional[IRMADResult]:
71
+ """Run IR-MAD on an RGB pair. Returns None on numerical failure."""
72
+ try:
73
+ if img1.shape != img2.shape or img1.ndim != 3:
74
+ return None
75
+ full_h, full_w = img1.shape[:2]
76
+
77
+ scale = min(1.0, max_side / max(full_h, full_w))
78
+ if scale < 1.0:
79
+ ah, aw = max(64, int(full_h * scale)), max(64, int(full_w * scale))
80
+ a = cv2.resize(img1, (aw, ah), interpolation=cv2.INTER_AREA)
81
+ b = cv2.resize(img2, (aw, ah), interpolation=cv2.INTER_AREA)
82
+ else:
83
+ ah, aw = full_h, full_w
84
+ a, b = img1, img2
85
+
86
+ bands = a.shape[2]
87
+ x = a.reshape(-1, bands).astype(np.float64)
88
+ y = b.reshape(-1, bands).astype(np.float64)
89
+ n = x.shape[0]
90
+ w = np.ones(n, dtype=np.float64)
91
+
92
+ rho_prev = None
93
+ converged = False
94
+ it = 0
95
+ mads_std = np.zeros_like(x)
96
+
97
+ for it in range(1, max_iters + 1):
98
+ _, _, sxx, syy, sxy, xc, yc = _weighted_stats(x, y, w)
99
+ sxx += eps * np.eye(bands)
100
+ syy += eps * np.eye(bands)
101
+
102
+ # CCA via generalized eigenproblem on sxx^-1 sxy syy^-1 syx
103
+ isxx = np.linalg.inv(sxx)
104
+ isyy = np.linalg.inv(syy)
105
+ m1 = isxx @ sxy @ isyy @ sxy.T
106
+ evals, evecs = np.linalg.eig(m1)
107
+ order = np.argsort(evals.real)[::-1]
108
+ rho2 = np.clip(evals.real[order], 0.0, 1.0)
109
+ avecs = evecs.real[:, order]
110
+
111
+ # Normalize canonical vectors: var(a^T x) = 1
112
+ for j in range(bands):
113
+ va = avecs[:, j] @ sxx @ avecs[:, j]
114
+ avecs[:, j] /= np.sqrt(max(va, eps))
115
+ bvecs = isyy @ sxy.T @ avecs
116
+ for j in range(bands):
117
+ vb = bvecs[:, j] @ syy @ bvecs[:, j]
118
+ bvecs[:, j] /= np.sqrt(max(vb, eps))
119
+
120
+ u = xc @ avecs
121
+ v = yc @ bvecs
122
+ # Sign alignment: positive correlation between pairs
123
+ for j in range(bands):
124
+ if np.sum(u[:, j] * v[:, j] * w) < 0:
125
+ bvecs[:, j] = -bvecs[:, j]
126
+ v[:, j] = -v[:, j]
127
+
128
+ mads = u - v
129
+ rho = np.sqrt(rho2)
130
+ sigma2 = np.maximum(2.0 * (1.0 - rho), eps) # MAD variances
131
+ mads_std = mads / np.sqrt(sigma2)[None, :]
132
+
133
+ chi2 = np.sum(mads_std ** 2, axis=1)
134
+ # No-change probability = 1 - CDF (large chi2 => change)
135
+ w_new = 1.0 - _chi2_cdf(chi2, bands)
136
+ w_new = np.clip(w_new, 1e-6, 1.0)
137
+
138
+ if rho_prev is not None and np.max(np.abs(rho - rho_prev)) < tol:
139
+ w = w_new
140
+ converged = True
141
+ break
142
+ rho_prev = rho
143
+ w = w_new
144
+
145
+ chi2_map = np.sum(mads_std ** 2, axis=1).reshape(ah, aw)
146
+ # Robust quantile normalization instead of the chi-square CDF: the CDF
147
+ # saturates at ~1.0 for most real pixels, which destroys the graded
148
+ # response needed by percentile-threshold fusion downstream.
149
+ hi = float(np.quantile(chi2_map, 0.995))
150
+ if hi <= 1e-8:
151
+ hi = float(chi2_map.max() + 1e-8)
152
+ prob = np.clip(chi2_map / hi, 0.0, 1.0)
153
+ nc_w = w.reshape(ah, aw)
154
+
155
+ prob_full = prob.astype(np.float32)
156
+ ncw_full = nc_w.astype(np.float32)
157
+ if (ah, aw) != (full_h, full_w):
158
+ prob_full = cv2.resize(prob_full, (full_w, full_h), interpolation=cv2.INTER_LINEAR)
159
+ ncw_full = cv2.resize(ncw_full, (full_w, full_h), interpolation=cv2.INTER_LINEAR)
160
+
161
+ return IRMADResult(
162
+ chi2=chi2_map.astype(np.float32),
163
+ change_prob=np.clip(prob_full, 0.0, 1.0),
164
+ no_change_weights=np.clip(ncw_full, 0.0, 1.0),
165
+ iterations=it,
166
+ converged=converged,
167
+ )
168
+ except Exception as exc:
169
+ _log.warning("IR-MAD failed (%s)", exc)
170
+ return None
171
+
172
+
173
+ def radiometric_regression_normalize(
174
+ img1: np.ndarray,
175
+ img2: np.ndarray,
176
+ no_change_weights: np.ndarray,
177
+ min_weight: float = 0.5,
178
+ ) -> Optional[np.ndarray]:
179
+ """Normalize img2 to img1 via weighted linear regression on no-change pixels.
180
+
181
+ Classic IR-MAD application: fit per-band ``img1 ~ a*img2 + b`` using only
182
+ pixels the IR-MAD iteration deemed unchanged, then map img2 through the
183
+ fit. More faithful than global mean/std matching because changed pixels
184
+ no longer skew the statistics. Returns uint8 image or None.
185
+ """
186
+ try:
187
+ if img1.shape != img2.shape:
188
+ return None
189
+ w = no_change_weights.astype(np.float64).ravel()
190
+ sel = w >= min_weight
191
+ if np.count_nonzero(sel) < 500:
192
+ return None
193
+ out = img2.astype(np.float64).copy()
194
+ for ch in range(img1.shape[2]):
195
+ s = img2[:, :, ch].astype(np.float64).ravel()[sel]
196
+ t = img1[:, :, ch].astype(np.float64).ravel()[sel]
197
+ ww = w[sel]
198
+ wsum = ww.sum() + 1e-12
199
+ ms, mt = (s * ww).sum() / wsum, (t * ww).sum() / wsum
200
+ cov = ((s - ms) * (t - mt) * ww).sum() / wsum
201
+ var = ((s - ms) ** 2 * ww).sum() / wsum
202
+ if var < 1e-6:
203
+ continue
204
+ gain = cov / var
205
+ if not (0.2 <= gain <= 5.0):
206
+ continue
207
+ offset = mt - gain * ms
208
+ out[:, :, ch] = img2[:, :, ch].astype(np.float64) * gain + offset
209
+ return np.clip(out, 0, 255).astype(np.uint8)
210
+ except Exception as exc:
211
+ _log.warning("IR-MAD regression normalization failed (%s)", exc)
212
+ return None
app/cd_models/kpca_features.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """KPCAMNet-style unsupervised deep change features (Kernel-PCA convolution).
2
+
3
+ Implements the core idea of "Unsupervised Change Detection in Multitemporal
4
+ VHR Images Based on Deep Kernel PCA Convolutional Mapping Network"
5
+ (Wu, Chen et al., IEEE TCYB 2022, https://github.com/ChenHongruixuan/KPCAMNet):
6
+
7
+ 1. Patch vectors from both timestamps are projected through *shared* kernel-PCA
8
+ mappings (siamese: identical projection for both images), stacked in layers
9
+ like a small convolutional network. No labels or pretraining required β€”
10
+ projections are fitted on the image pair itself.
11
+ 2. The per-pixel feature difference is mapped to a polar domain:
12
+ magnitude ``rho`` (change strength) and direction ``theta`` (change type).
13
+ 3. ``rho`` is thresholded (Otsu + hysteresis by the caller) for the binary
14
+ change mask; ``theta`` supports discriminating change types downstream.
15
+
16
+ For CPU practicality the exact KPCA (O(N^2) kernel matrix) is replaced by the
17
+ standard Nystroem approximation + linear PCA, which scales linearly in pixels
18
+ while preserving the kernelized mapping. Layer projections are fitted on a
19
+ random pixel sample from BOTH images so the mapping is shared (siamese).
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from dataclasses import dataclass
25
+ from typing import Optional, Tuple
26
+
27
+ import cv2
28
+ import numpy as np
29
+
30
+ _log = logging.getLogger(__name__)
31
+
32
+
33
+ @dataclass
34
+ class KPCAChangeResult:
35
+ rho: np.ndarray # float32 [0,1], change magnitude, input resolution
36
+ theta: np.ndarray # float32 [-pi,pi], change direction, input resolution
37
+ n_layers: int
38
+ n_components: int
39
+ analysis_shape: Tuple[int, int]
40
+
41
+
42
+ def _patch_stack(img: np.ndarray, patch: int) -> np.ndarray:
43
+ """(H,W,C) -> (H,W,C*patch*patch) neighborhood features via shifted views."""
44
+ h, w, c = img.shape
45
+ r = patch // 2
46
+ padded = cv2.copyMakeBorder(img, r, r, r, r, cv2.BORDER_REFLECT)
47
+ views = []
48
+ for dy in range(patch):
49
+ for dx in range(patch):
50
+ views.append(padded[dy:dy + h, dx:dx + w])
51
+ return np.concatenate(views, axis=2).astype(np.float32)
52
+
53
+
54
+ def _fit_shared_projection(feat_a: np.ndarray, feat_b: np.ndarray,
55
+ n_components: int, sample: int, seed: int):
56
+ """Fit Nystroem(RBF) + PCA on a pixel sample drawn from both images."""
57
+ from sklearn.decomposition import PCA
58
+ from sklearn.kernel_approximation import Nystroem
59
+ from sklearn.pipeline import Pipeline
60
+ from sklearn.preprocessing import StandardScaler
61
+
62
+ rng = np.random.default_rng(seed)
63
+ flat = np.concatenate([
64
+ feat_a.reshape(-1, feat_a.shape[2]),
65
+ feat_b.reshape(-1, feat_b.shape[2]),
66
+ ], axis=0)
67
+ take = min(sample, flat.shape[0])
68
+ idx = rng.choice(flat.shape[0], size=take, replace=False)
69
+ fit_data = flat[idx]
70
+
71
+ n_landmarks = int(min(128, max(32, take // 24)))
72
+ pipe = Pipeline([
73
+ ("scale", StandardScaler()),
74
+ ("nystroem", Nystroem(kernel="rbf", n_components=n_landmarks, random_state=seed)),
75
+ ("pca", PCA(n_components=n_components, random_state=seed)),
76
+ ])
77
+ pipe.fit(fit_data)
78
+ return pipe
79
+
80
+
81
+ def _transform_image(pipe, feat: np.ndarray) -> np.ndarray:
82
+ h, w, c = feat.shape
83
+ out = pipe.transform(feat.reshape(-1, c))
84
+ return out.reshape(h, w, -1).astype(np.float32)
85
+
86
+
87
+ def compute_kpca_change(
88
+ img1: np.ndarray,
89
+ img2: np.ndarray,
90
+ *,
91
+ n_components: int = 8,
92
+ n_layers: int = 2,
93
+ patch: int = 5,
94
+ max_side: int = 768,
95
+ fit_sample: int = 4000,
96
+ seed: int = 42,
97
+ ) -> Optional[KPCAChangeResult]:
98
+ """Siamese KPCA change features for an RGB image pair.
99
+
100
+ Returns rho/theta maps at the input resolution, or None on failure
101
+ (caller falls back to its previous behavior).
102
+ """
103
+ try:
104
+ if img1.shape != img2.shape or img1.ndim != 3:
105
+ return None
106
+ full_h, full_w = img1.shape[:2]
107
+
108
+ # Bounded analysis resolution keeps the Nystroem transform CPU-friendly
109
+ scale = min(1.0, max_side / max(full_h, full_w))
110
+ if scale < 1.0:
111
+ ah, aw = max(64, int(full_h * scale)), max(64, int(full_w * scale))
112
+ a = cv2.resize(img1, (aw, ah), interpolation=cv2.INTER_AREA)
113
+ b = cv2.resize(img2, (aw, ah), interpolation=cv2.INTER_AREA)
114
+ else:
115
+ a, b = img1, img2
116
+
117
+ a = a.astype(np.float32) / 255.0
118
+ b = b.astype(np.float32) / 255.0
119
+
120
+ feat_a, feat_b = a, b
121
+ layer_patch = patch
122
+ for layer in range(max(1, n_layers)):
123
+ stack_a = _patch_stack(feat_a, layer_patch)
124
+ stack_b = _patch_stack(feat_b, layer_patch)
125
+ pipe = _fit_shared_projection(
126
+ stack_a, stack_b, n_components=n_components,
127
+ sample=fit_sample, seed=seed + layer,
128
+ )
129
+ feat_a = _transform_image(pipe, stack_a)
130
+ feat_b = _transform_image(pipe, stack_b)
131
+ layer_patch = 3 # deeper layers use tighter neighborhoods
132
+
133
+ diff = feat_a - feat_b
134
+
135
+ # Polar mapping (KPCAMNet): magnitude over all components; direction
136
+ # from the two most-informative difference components.
137
+ rho = np.sqrt(np.sum(diff * diff, axis=2))
138
+ var_order = np.argsort(np.var(diff.reshape(-1, diff.shape[2]), axis=0))[::-1]
139
+ d1 = diff[:, :, var_order[0]]
140
+ d2 = diff[:, :, var_order[1]] if diff.shape[2] > 1 else np.zeros_like(d1)
141
+ theta = np.arctan2(d2, d1)
142
+
143
+ # Robust [0,1] normalization of magnitude
144
+ hi = float(np.quantile(rho, 0.995))
145
+ if hi <= 1e-8:
146
+ hi = float(rho.max() + 1e-8)
147
+ rho = np.clip(rho / hi, 0.0, 1.0).astype(np.float32)
148
+
149
+ if rho.shape != (full_h, full_w):
150
+ rho = cv2.resize(rho, (full_w, full_h), interpolation=cv2.INTER_LINEAR)
151
+ theta = cv2.resize(theta.astype(np.float32), (full_w, full_h),
152
+ interpolation=cv2.INTER_NEAREST)
153
+
154
+ return KPCAChangeResult(
155
+ rho=rho.astype(np.float32),
156
+ theta=theta.astype(np.float32),
157
+ n_layers=max(1, n_layers),
158
+ n_components=n_components,
159
+ analysis_shape=(a.shape[0], a.shape[1]),
160
+ )
161
+ except Exception as exc:
162
+ _log.warning("KPCA change features failed (%s) β€” caller will fall back", exc)
163
+ return None
app/cd_models/kpca_method.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ KPCA-based unsupervised change detection (KPCAMNet-inspired).
3
+
4
+ Fits Kernel PCA filters on randomly sampled patches from both images (weight-
5
+ shared/siamese, no labels needed), projects the whole image through those
6
+ filters, then detects change via the L2 norm of the feature difference. This
7
+ is a single-stage reconstruction of the described KPCAMNet algorithm (not a
8
+ port of the original repo's code, which isn't part of this project) β€” scoped
9
+ down for CPU feasibility: bounded processing resolution and a modest training
10
+ patch count, since KernelPCA.transform cost scales with
11
+ n_query_patches x n_train_patches.
12
+ """
13
+ import cv2
14
+ import numpy as np
15
+ from numpy.lib.stride_tricks import sliding_window_view
16
+ from sklearn.decomposition import KernelPCA
17
+
18
+
19
+ def _extract_all_patches(img: np.ndarray, patch_size: int) -> np.ndarray:
20
+ """Every overlapping patch_size x patch_size patch, one per pixel (reflect-padded)."""
21
+ half = patch_size // 2
22
+ padded = np.pad(img, half, mode="reflect")
23
+ windows = sliding_window_view(padded, (patch_size, patch_size))
24
+ h, w = img.shape
25
+ return windows.reshape(h, w, -1)
26
+
27
+
28
+ def _project_in_chunks(kpca: KernelPCA, flat_patches: np.ndarray, row_pixels: int,
29
+ n_components: int) -> np.ndarray:
30
+ """KernelPCA.transform in row-chunks so memory stays bounded on large images."""
31
+ chunks = []
32
+ step = max(1, 200_000 // max(1, row_pixels)) * row_pixels
33
+ for i in range(0, flat_patches.shape[0], step):
34
+ chunks.append(kpca.transform(flat_patches[i:i + step]))
35
+ return np.concatenate(chunks, axis=0)
36
+
37
+
38
+ def kpca_change_mask(img1: np.ndarray, img2: np.ndarray, patch_size: int = 5,
39
+ n_components: int = 8, gamma: float = 5e-4,
40
+ n_train_patches: int = 300, max_side: int = 768):
41
+ """
42
+ Returns (magnitude_map float32 [0,1] at processed resolution, debug dict).
43
+ Caller handles thresholding/cleanup/resize-back so this stays testable in
44
+ isolation without pulling in the rest of the detection pipeline.
45
+ """
46
+ if img1.shape != img2.shape:
47
+ img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
48
+
49
+ h0, w0 = img1.shape[:2]
50
+ scale = min(1.0, max_side / max(h0, w0, 1))
51
+ if scale < 1.0:
52
+ nh, nw = max(1, int(h0 * scale)), max(1, int(w0 * scale))
53
+ img1_s = cv2.resize(img1, (nw, nh), interpolation=cv2.INTER_AREA)
54
+ img2_s = cv2.resize(img2, (nw, nh), interpolation=cv2.INTER_AREA)
55
+ else:
56
+ img1_s, img2_s = img1, img2
57
+
58
+ gray1 = cv2.cvtColor(img1_s, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
59
+ gray2 = cv2.cvtColor(img2_s, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
60
+ # Per-image z-score standardization (matches the described preprocessing)
61
+ gray1 = (gray1 - gray1.mean()) / (gray1.std() + 1e-8)
62
+ gray2 = (gray2 - gray2.mean()) / (gray2.std() + 1e-8)
63
+
64
+ h, w = gray1.shape
65
+ patches1 = _extract_all_patches(gray1, patch_size)
66
+ patches2 = _extract_all_patches(gray2, patch_size)
67
+ flat1 = patches1.reshape(-1, patches1.shape[-1])
68
+ flat2 = patches2.reshape(-1, patches2.shape[-1])
69
+
70
+ rng = np.random.default_rng(42)
71
+ half_n = max(1, n_train_patches // 2)
72
+ idx1 = rng.choice(flat1.shape[0], size=min(half_n, flat1.shape[0]), replace=False)
73
+ idx2 = rng.choice(flat2.shape[0], size=min(half_n, flat2.shape[0]), replace=False)
74
+ train_patches = np.concatenate([flat1[idx1], flat2[idx2]], axis=0)
75
+
76
+ n_components_eff = max(1, min(n_components, train_patches.shape[0] - 1))
77
+ kpca = KernelPCA(n_components=n_components_eff, kernel="rbf", gamma=gamma)
78
+ kpca.fit(train_patches)
79
+
80
+ feat1 = _project_in_chunks(kpca, flat1, w, n_components_eff).reshape(h, w, n_components_eff)
81
+ feat2 = _project_in_chunks(kpca, flat2, w, n_components_eff).reshape(h, w, n_components_eff)
82
+
83
+ diff = feat1 - feat2
84
+ magnitude = np.linalg.norm(diff, axis=-1)
85
+ mag_norm = magnitude / (magnitude.max() + 1e-8)
86
+
87
+ if scale < 1.0:
88
+ mag_norm = cv2.resize(mag_norm.astype(np.float32), (w0, h0), interpolation=cv2.INTER_LINEAR)
89
+
90
+ debug = {
91
+ "n_components": int(n_components_eff),
92
+ "n_train_patches": int(train_patches.shape[0]),
93
+ "processedSide": int(max(h, w)),
94
+ "patchSize": int(patch_size),
95
+ "gamma": float(gamma),
96
+ }
97
+ return mag_norm.astype(np.float32), debug
app/cd_models/model_utils.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared utilities for model loading, tile processing, and multi-scale detection.
3
+ """
4
+ import cv2
5
+ import numpy as np
6
+
7
+
8
+ def _tile_signature(tile: np.ndarray, size: int = 16) -> np.ndarray:
9
+ """Cheap, deterministic per-tile fingerprint for a fast unchanged check.
10
+
11
+ Downsamples to a tiny ``size x size`` patch in LAB space (kept as a grid,
12
+ not flattened). Comparing two signatures is orders of magnitude cheaper
13
+ than a model forward pass, so it's usable as a pre-filter ahead of the
14
+ real scorer.
15
+ """
16
+ small = cv2.resize(tile, (size, size), interpolation=cv2.INTER_AREA)
17
+ if small.ndim == 3:
18
+ small = cv2.cvtColor(small, cv2.COLOR_RGB2LAB)
19
+ return small.astype(np.float32)
20
+
21
+
22
+ def _tiles_likely_unchanged(t1: np.ndarray, t2: np.ndarray, threshold: float) -> bool:
23
+ """True when two same-size tiles are near-identical everywhere.
24
+
25
+ Uses the *worst-cell* (max) per-cell LAB distance on the downsampled grid,
26
+ not a mean or percentile β€” a small localized change concentrated in just
27
+ one or two cells out of the grid would be washed out by averaging (mean)
28
+ or ignored entirely by a percentile below ~100, which risks silently
29
+ skipping a real small change. Max cleanly separates that from sensor
30
+ noise (empirically ~1 unit of noise floor vs 25+ for even an 8x8px real
31
+ change in a 256px tile), so this is the safe choice for a pre-filter that
32
+ must not increase false negatives.
33
+ """
34
+ sig1 = _tile_signature(t1)
35
+ sig2 = _tile_signature(t2)
36
+ diff = np.abs(sig1 - sig2)
37
+ if diff.ndim == 3:
38
+ diff = diff.max(axis=-1)
39
+ return float(diff.max()) <= threshold
40
+
41
+
42
+ def make_blend_weights(tile_size: int, overlap: int) -> np.ndarray:
43
+ """Raised-cosine (trapezoid) 2D weight for seamless tile stitching.
44
+
45
+ Ramps from 0 to 1 across the overlap band on each edge and stays flat in
46
+ the interior, so overlapping tile predictions blend without visible seams.
47
+ """
48
+ overlap = max(0, min(overlap, tile_size // 2))
49
+ if overlap == 0:
50
+ return np.ones((tile_size, tile_size), dtype=np.float32)
51
+ ramp = np.linspace(0.0, 1.0, overlap, dtype=np.float32)
52
+ flat = np.ones(tile_size - 2 * overlap, dtype=np.float32)
53
+ profile = np.concatenate([ramp, flat, ramp[::-1]])
54
+ return np.outer(profile, profile).astype(np.float32)
55
+
56
+
57
+ def tiled_score_map(score_tile_fn, img1, img2, tile_size: int = 256,
58
+ overlap: int | None = None,
59
+ score_batch_fn=None, batch: int = 1,
60
+ skip_unchanged_threshold: float | None = None,
61
+ stats: dict | None = None):
62
+ """Sliding-window tiled scoring with reflect padding + cosine blending.
63
+
64
+ ``score_tile_fn(tile1, tile2)`` must return a float32 array in [0, 1] the
65
+ same height/width as the input tile. Returns a float32 change-probability
66
+ map cropped back to the original (h, w). This is the single shared
67
+ implementation used by every deep model so blending stays consistent.
68
+
69
+ When ``batch > 1`` and ``score_batch_fn`` is provided, tiles are scored in
70
+ groups (``score_batch_fn(list[(t1, t2)]) -> list[prob]``) so GPUs can run
71
+ several tiles per forward pass.
72
+
73
+ When ``skip_unchanged_threshold`` is set, tiles whose before/after content
74
+ is near-identical skip the expensive scorer entirely and are recorded as
75
+ zero change β€” a cheap pre-filter that speeds up runs where most of the
76
+ scene didn't change, without affecting tiles that actually did. Pass a
77
+ ``stats`` dict to accumulate ``{"totalTiles", "skippedTiles"}`` into it.
78
+ """
79
+ h, w = img1.shape[:2]
80
+ if overlap is None:
81
+ overlap = tile_size // 4
82
+ overlap = max(0, min(overlap, tile_size // 2))
83
+ stride = max(1, tile_size - overlap)
84
+
85
+ pad_h = (tile_size - h % tile_size) % tile_size
86
+ pad_w = (tile_size - w % tile_size) % tile_size
87
+ # Guarantee at least one full tile even for small inputs
88
+ pad_h = max(pad_h, max(0, tile_size - h))
89
+ pad_w = max(pad_w, max(0, tile_size - w))
90
+ if pad_h or pad_w:
91
+ img1 = np.pad(img1, ((0, pad_h), (0, pad_w), (0, 0)), mode="reflect")
92
+ img2 = np.pad(img2, ((0, pad_h), (0, pad_w), (0, 0)), mode="reflect")
93
+
94
+ ph, pw = img1.shape[:2]
95
+ score_sum = np.zeros((ph, pw), dtype=np.float32)
96
+ count = np.zeros((ph, pw), dtype=np.float32)
97
+ weight_2d = make_blend_weights(tile_size, overlap)
98
+
99
+ ys = list(range(0, ph - tile_size + 1, stride))
100
+ xs = list(range(0, pw - tile_size + 1, stride))
101
+ # Ensure the far edges are always covered
102
+ if ys and ys[-1] != ph - tile_size:
103
+ ys.append(ph - tile_size)
104
+ if xs and xs[-1] != pw - tile_size:
105
+ xs.append(pw - tile_size)
106
+
107
+ coords = [(y0, x0) for y0 in ys for x0 in xs]
108
+
109
+ def _accumulate(y0, x0, prob):
110
+ if prob.shape != (tile_size, tile_size):
111
+ prob = cv2.resize(prob.astype(np.float32), (tile_size, tile_size),
112
+ interpolation=cv2.INTER_LINEAR)
113
+ score_sum[y0:y0 + tile_size, x0:x0 + tile_size] += prob * weight_2d
114
+ count[y0:y0 + tile_size, x0:x0 + tile_size] += weight_2d
115
+
116
+ zero_tile = np.zeros((tile_size, tile_size), dtype=np.float32)
117
+ skipped = 0
118
+
119
+ def _unchanged(t1, t2):
120
+ return (skip_unchanged_threshold is not None
121
+ and _tiles_likely_unchanged(t1, t2, skip_unchanged_threshold))
122
+
123
+ use_batch = score_batch_fn is not None and batch > 1
124
+ if use_batch:
125
+ for i in range(0, len(coords), batch):
126
+ chunk = coords[i:i + batch]
127
+ pairs = []
128
+ kept = []
129
+ for (y, x) in chunk:
130
+ t1 = np.ascontiguousarray(img1[y:y + tile_size, x:x + tile_size])
131
+ t2 = np.ascontiguousarray(img2[y:y + tile_size, x:x + tile_size])
132
+ if _unchanged(t1, t2):
133
+ skipped += 1
134
+ _accumulate(y, x, zero_tile)
135
+ continue
136
+ pairs.append((t1, t2))
137
+ kept.append((y, x))
138
+ if pairs:
139
+ probs = score_batch_fn(pairs)
140
+ for (y0, x0), prob in zip(kept, probs):
141
+ _accumulate(y0, x0, prob)
142
+ else:
143
+ for (y0, x0) in coords:
144
+ t1 = np.ascontiguousarray(img1[y0:y0 + tile_size, x0:x0 + tile_size])
145
+ t2 = np.ascontiguousarray(img2[y0:y0 + tile_size, x0:x0 + tile_size])
146
+ if _unchanged(t1, t2):
147
+ skipped += 1
148
+ _accumulate(y0, x0, zero_tile)
149
+ continue
150
+ _accumulate(y0, x0, score_tile_fn(t1, t2))
151
+
152
+ if stats is not None:
153
+ stats["totalTiles"] = stats.get("totalTiles", 0) + len(coords)
154
+ stats["skippedTiles"] = stats.get("skippedTiles", 0) + skipped
155
+
156
+ count = np.maximum(count, 1e-6)
157
+ avg = score_sum / count
158
+ return avg[:h, :w]
159
+
160
+
161
+ def split_into_tiles(img, tile_size=512, overlap=64):
162
+ """
163
+ Split an image into overlapping tiles.
164
+ Returns list of (tile, y_offset, x_offset) tuples.
165
+ """
166
+ h, w = img.shape[:2]
167
+ stride = tile_size - overlap
168
+ tiles = []
169
+
170
+ pad_h = (tile_size - h % tile_size) % tile_size if h % tile_size else 0
171
+ pad_w = (tile_size - w % tile_size) % tile_size if w % tile_size else 0
172
+ if pad_h or pad_w:
173
+ img = np.pad(img, ((0, pad_h), (0, pad_w), (0, 0)) if img.ndim == 3
174
+ else ((0, pad_h), (0, pad_w)), mode="reflect")
175
+
176
+ ph, pw = img.shape[:2]
177
+ for y in range(0, ph - tile_size + 1, stride):
178
+ for x in range(0, pw - tile_size + 1, stride):
179
+ tile = img[y:y+tile_size, x:x+tile_size]
180
+ tiles.append((tile, y, x))
181
+
182
+ return tiles, (ph, pw), (h, w)
183
+
184
+
185
+ def merge_tile_masks(tile_results, padded_shape, orig_shape, tile_size=512, overlap=64):
186
+ """
187
+ Merge tile-level binary masks back into a single full-resolution mask.
188
+ Uses raised-cosine blending to avoid tile boundary artifacts.
189
+ """
190
+ ph, pw = padded_shape
191
+ h, w = orig_shape
192
+
193
+ score_sum = np.zeros((ph, pw), dtype=np.float32)
194
+ count = np.zeros((ph, pw), dtype=np.float32)
195
+
196
+ ramp = np.linspace(0, 1, overlap)
197
+ flat = np.ones(tile_size - 2 * overlap)
198
+ profile = np.concatenate([ramp, flat, ramp[::-1]])
199
+ weight_2d = np.outer(profile, profile).astype(np.float32)
200
+
201
+ for (mask_tile, y, x) in tile_results:
202
+ score = mask_tile.astype(np.float32) / 255.0 if mask_tile.max() > 1 else mask_tile.astype(np.float32)
203
+ if score.shape != (tile_size, tile_size):
204
+ score = cv2.resize(score, (tile_size, tile_size))
205
+ score_sum[y:y+tile_size, x:x+tile_size] += score * weight_2d
206
+ count[y:y+tile_size, x:x+tile_size] += weight_2d
207
+
208
+ count = np.maximum(count, 1e-6)
209
+ merged = score_sum / count
210
+ merged = merged[:h, :w]
211
+ return (merged * 255).astype(np.uint8)
212
+
213
+
214
+ def multiscale_detect(detect_fn, img1, img2, scales=(1.0, 0.5, 0.25)):
215
+ """
216
+ Run a detection function at multiple scales and combine via logical OR.
217
+ detect_fn(img1, img2) -> uint8 mask [0|255].
218
+ Captures small structures at full res and large regions at coarse scales.
219
+ """
220
+ h, w = img1.shape[:2]
221
+ combined = np.zeros((h, w), dtype=np.uint8)
222
+
223
+ for scale in scales:
224
+ if scale == 1.0:
225
+ s1, s2 = img1, img2
226
+ else:
227
+ sh, sw = max(64, int(h * scale)), max(64, int(w * scale))
228
+ s1 = cv2.resize(img1, (sw, sh), interpolation=cv2.INTER_AREA)
229
+ s2 = cv2.resize(img2, (sw, sh), interpolation=cv2.INTER_AREA)
230
+
231
+ mask = detect_fn(s1, s2)
232
+
233
+ if scale != 1.0:
234
+ mask = cv2.resize(mask, (w, h), interpolation=cv2.INTER_NEAREST)
235
+
236
+ combined = np.maximum(combined, mask)
237
+
238
+ return combined
239
+
240
+
241
+ def build_confidence_map(channels, weights=None):
242
+ """
243
+ Build a [0-1] confidence map from multiple normalized signal channels.
244
+ Each channel should be a float32 array in [0,1].
245
+ If weights is None, uses equal weighting.
246
+ """
247
+ if not channels:
248
+ return None
249
+ if weights is None:
250
+ weights = [1.0 / len(channels)] * len(channels)
251
+ total_w = sum(weights)
252
+ weights = [w / total_w for w in weights]
253
+
254
+ shape = channels[0].shape
255
+ conf = np.zeros(shape, dtype=np.float64)
256
+ for ch, w in zip(channels, weights):
257
+ if ch.shape != shape:
258
+ ch = cv2.resize(ch.astype(np.float32), (shape[1], shape[0]))
259
+ conf += w * ch.astype(np.float64)
260
+
261
+ return np.clip(conf, 0, 1).astype(np.float32)
app/cd_models/weights/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+
app/database.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from sqlalchemy import create_engine, event
5
+ from sqlalchemy.orm import sessionmaker, declarative_base
6
+
7
+ BASE_DIR = Path(__file__).resolve().parent
8
+
9
+ # Use /home/appuser/data on HF Spaces (writable), fall back to local data/ dir
10
+ _home_data = Path.home() / "data"
11
+ _local_data = BASE_DIR.parent / "data"
12
+ DATA_DIR = _home_data if os.environ.get("SPACE_ID") else _local_data
13
+ try:
14
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
15
+ except Exception:
16
+ pass # avoid crashing if read-only or permission issue (e.g. some HF environments)
17
+
18
+ DB_PATH = DATA_DIR / "satellite_app.db"
19
+
20
+ DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DB_PATH}")
21
+
22
+ # Render gives postgres:// but SQLAlchemy 2.x requires postgresql://
23
+ if DATABASE_URL.startswith("postgres://"):
24
+ DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://", 1)
25
+
26
+ connect_args = {}
27
+ if DATABASE_URL.startswith("sqlite"):
28
+ # timeout = busy wait while another connection holds the write lock
29
+ # (progress updates vs the long-running detection worker).
30
+ connect_args = {"check_same_thread": False, "timeout": 60.0}
31
+
32
+ # pool_pre_ping: checks each connection before use and transparently reconnects
33
+ # if it's gone stale β€” matters for a remote DB (MySQL/Postgres) reached over
34
+ # the network, where idle connections can be dropped by the server or a
35
+ # firewall; irrelevant (and skipped) for local SQLite.
36
+ engine = create_engine(
37
+ DATABASE_URL,
38
+ connect_args=connect_args,
39
+ pool_pre_ping=not DATABASE_URL.startswith("sqlite"),
40
+ )
41
+
42
+
43
+ @event.listens_for(engine, "connect")
44
+ def _sqlite_pragmas(dbapi_conn, _connection_record):
45
+ if not DATABASE_URL.startswith("sqlite"):
46
+ return
47
+ cur = dbapi_conn.cursor()
48
+ try:
49
+ cur.execute("PRAGMA journal_mode=WAL")
50
+ cur.execute("PRAGMA busy_timeout=60000")
51
+ cur.execute("PRAGMA synchronous=NORMAL")
52
+ finally:
53
+ cur.close()
54
+
55
+
56
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
57
+ Base = declarative_base()
58
+
59
+
60
+ def get_db():
61
+ db = SessionLocal()
62
+ try:
63
+ yield db
64
+ finally:
65
+ db.close()
app/dda/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Vedangsoft change detection module (dev Space / APP_MODE=dda)."""
app/dda/admin_routes.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vedangsoft admin diagnostics and maintenance (Phase 7)."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import shutil
6
+ from datetime import datetime, timedelta, timezone
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
9
+ from sqlalchemy.orm import Session
10
+
11
+ from ..database import DATA_DIR, get_db
12
+ from ..models import DetectionRun, User
13
+ from .dda_auth import get_user_role
14
+ from .job_runner import is_job_runner_busy, reconcile_stale_jobs
15
+ from .tree.image_service import count_library_images
16
+ from .models import DetectionJob, RegionReview
17
+ from .rbac.permissions import require_module_permission
18
+
19
+ logger = logging.getLogger(__name__)
20
+ router = APIRouter()
21
+
22
+
23
+ def _require_dda():
24
+ from .config import IS_DDA_MODE
25
+ if not IS_DDA_MODE:
26
+ raise HTTPException(status_code=404, detail="DDA mode is not enabled")
27
+
28
+
29
+ @router.get("/admin/status")
30
+ def admin_status(
31
+ request: Request,
32
+ db: Session = Depends(get_db),
33
+ user: User = Depends(require_module_permission("admin", "view")),
34
+ ):
35
+ """System status for UAT / ops."""
36
+ _require_dda()
37
+
38
+ runs = db.query(DetectionRun).count()
39
+ jobs_queued = db.query(DetectionJob).filter(DetectionJob.status == "queued").count()
40
+ jobs_running = db.query(DetectionJob).filter(DetectionJob.status == "running").count()
41
+ fp_count = db.query(RegionReview).filter(RegionReview.status == "false_positive").count()
42
+
43
+ disk = shutil.disk_usage(DATA_DIR)
44
+ return {
45
+ "role": get_user_role(db, user),
46
+ "libraryImages": count_library_images(db),
47
+ "detectionRuns": runs,
48
+ "jobsQueued": jobs_queued,
49
+ "jobsRunning": jobs_running,
50
+ "runnerBusy": is_job_runner_busy(),
51
+ "falsePositiveArchive": fp_count,
52
+ "dataDir": str(DATA_DIR),
53
+ "diskFreeGb": round(disk.free / (1024 ** 3), 2),
54
+ "diskTotalGb": round(disk.total / (1024 ** 3), 2),
55
+ }
56
+
57
+
58
+ @router.post("/admin/reconcile-jobs")
59
+ def admin_reconcile_jobs(
60
+ request: Request,
61
+ db: Session = Depends(get_db),
62
+ user: User = Depends(require_module_permission("admin", "edit")),
63
+ ):
64
+ _require_dda()
65
+ fixed = reconcile_stale_jobs(db)
66
+ return {"ok": True, "staleRunningFixed": fixed}
67
+
68
+
69
+ @router.delete("/admin/purge-runs")
70
+ def admin_purge_old_runs(
71
+ request: Request,
72
+ days: int = Query(90, ge=7, le=3650),
73
+ db: Session = Depends(get_db),
74
+ user: User = Depends(require_module_permission("admin", "delete")),
75
+ ):
76
+ """Delete detection runs older than N days (admin only)."""
77
+ _require_dda()
78
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days)
79
+ old = db.query(DetectionRun).filter(DetectionRun.created_at < cutoff).all()
80
+ count = 0
81
+ overlays_root = DATA_DIR
82
+ for run in old:
83
+ db.query(RegionReview).filter(RegionReview.run_id == run.id).delete()
84
+ for job in db.query(DetectionJob).filter(DetectionJob.run_id == run.id).all():
85
+ job.run_id = None
86
+ for path_attr in ("overlay_path", "before_full_path", "before_thumb_path", "after_thumb_path", "after_full_path"):
87
+ path_val = getattr(run, path_attr, None)
88
+ if path_val:
89
+ f = overlays_root / path_val
90
+ if f.exists():
91
+ f.unlink(missing_ok=True)
92
+ db.delete(run)
93
+ count += 1
94
+ db.commit()
95
+ return {"ok": True, "purgedRuns": count, "olderThanDays": days}
app/dda/applog.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-memory + file application log so the Logs page can view recent events."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import threading
6
+ from collections import deque
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import List, Optional
10
+
11
+ from ..database import DATA_DIR
12
+
13
+ _LOCK = threading.Lock()
14
+ _BUFFER: deque = deque(maxlen=4000)
15
+ _HANDLER: Optional["RingLogHandler"] = None
16
+
17
+ LOG_DIR = DATA_DIR / "logs"
18
+ LOG_FILE = LOG_DIR / "app.log"
19
+
20
+ _LEVELS = {
21
+ "DEBUG": logging.DEBUG,
22
+ "INFO": logging.INFO,
23
+ "WARNING": logging.WARNING,
24
+ "ERROR": logging.ERROR,
25
+ "CRITICAL": logging.CRITICAL,
26
+ }
27
+
28
+
29
+ class RingLogHandler(logging.Handler):
30
+ def emit(self, record: logging.LogRecord) -> None:
31
+ try:
32
+ msg = self.format(record)
33
+ except Exception:
34
+ msg = record.getMessage()
35
+ created = datetime.fromtimestamp(record.created, tz=timezone.utc)
36
+ entry = {
37
+ "ts": created.isoformat(),
38
+ "level": record.levelname,
39
+ "logger": record.name,
40
+ "message": msg,
41
+ }
42
+ with _LOCK:
43
+ _BUFFER.append(entry)
44
+
45
+
46
+ def install_app_log_handler() -> None:
47
+ """Attach once to the root logger (and uvicorn access) for the Logs UI."""
48
+ global _HANDLER
49
+ if _HANDLER is not None:
50
+ return
51
+ try:
52
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
53
+ except OSError:
54
+ pass
55
+
56
+ handler = RingLogHandler(level=logging.INFO)
57
+ handler.setFormatter(logging.Formatter("%(message)s"))
58
+ _HANDLER = handler
59
+
60
+ root = logging.getLogger()
61
+ root.addHandler(handler)
62
+ if root.level > logging.INFO or root.level == logging.NOTSET:
63
+ root.setLevel(logging.INFO)
64
+
65
+ file_handler = None
66
+ try:
67
+ file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
68
+ file_handler.setLevel(logging.INFO)
69
+ file_handler.setFormatter(logging.Formatter(
70
+ "%(asctime)s %(levelname)s [%(name)s] %(message)s"
71
+ ))
72
+ root.addHandler(file_handler)
73
+ except OSError:
74
+ file_handler = None
75
+
76
+ for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
77
+ logging.getLogger(name).addHandler(handler)
78
+ if file_handler:
79
+ logging.getLogger(name).addHandler(file_handler)
80
+
81
+
82
+ def query_logs(
83
+ *,
84
+ level: str = "",
85
+ q: str = "",
86
+ logger_name: str = "",
87
+ limit: int = 200,
88
+ offset: int = 0,
89
+ ) -> dict:
90
+ min_level = _LEVELS.get((level or "").upper(), 0)
91
+ needle = (q or "").strip().lower()
92
+ logger_f = (logger_name or "").strip().lower()
93
+ with _LOCK:
94
+ rows = list(_BUFFER)
95
+ rows.reverse()
96
+ filtered: List[dict] = []
97
+ for row in rows:
98
+ if min_level and _LEVELS.get(row["level"], 0) < min_level:
99
+ continue
100
+ if logger_f and logger_f not in (row.get("logger") or "").lower():
101
+ continue
102
+ if needle and needle not in (row.get("message") or "").lower() and needle not in (row.get("logger") or "").lower():
103
+ continue
104
+ filtered.append(row)
105
+ total = len(filtered)
106
+ page = filtered[offset:offset + limit]
107
+ return {
108
+ "total": total,
109
+ "offset": offset,
110
+ "limit": limit,
111
+ "logs": page,
112
+ "file": str(LOG_FILE) if LOG_FILE.exists() else "",
113
+ }
114
+
115
+
116
+ def clear_logs() -> dict:
117
+ with _LOCK:
118
+ _BUFFER.clear()
119
+ truncated = False
120
+ try:
121
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
122
+ LOG_FILE.write_text("", encoding="utf-8")
123
+ truncated = True
124
+ except OSError:
125
+ pass
126
+ return {"ok": True, "cleared": True, "fileTruncated": truncated}
127
+
128
+
129
+ def log_file_path() -> Path:
130
+ return LOG_FILE
app/dda/audit_routes.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API for the Audit Log page β€” user activity trail (login/logout, uploads,
2
+ detections, reports, admin/master-data changes)."""
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Optional
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, Query
9
+ from sqlalchemy.orm import Session
10
+
11
+ from ..database import get_db
12
+ from ..models import User
13
+ from .rbac.permissions import require_module_permission
14
+ from .tree.audit_service import isoformat_ist
15
+ from .tree.models import AuditLog
16
+
17
+ router = APIRouter()
18
+
19
+
20
+ def _require_dda():
21
+ from .config import IS_DDA_MODE
22
+ if not IS_DDA_MODE:
23
+ raise HTTPException(status_code=404, detail="DDA mode is not enabled")
24
+
25
+
26
+ def _entry_dict(entry: AuditLog) -> dict:
27
+ return {
28
+ "id": entry.id,
29
+ "timestamp": isoformat_ist(entry.action_date),
30
+ "user": entry.action_by or "",
31
+ "role": entry.role or "",
32
+ "action": entry.action,
33
+ "module": entry.module or "",
34
+ "nodeId": entry.node_id,
35
+ "oldValue": entry.old_value or "",
36
+ "newValue": entry.new_value or "",
37
+ }
38
+
39
+
40
+ def _parse_date(value: Optional[str], *, end_of_day: bool = False) -> Optional[datetime]:
41
+ if not value:
42
+ return None
43
+ try:
44
+ dt = datetime.fromisoformat(value)
45
+ except ValueError:
46
+ raise HTTPException(status_code=400, detail=f"Invalid date: {value!r} (expected YYYY-MM-DD)")
47
+ if end_of_day:
48
+ dt = dt.replace(hour=23, minute=59, second=59, microsecond=999999)
49
+ return dt
50
+
51
+
52
+ @router.get("/audit-log")
53
+ def list_audit_log(
54
+ action: str = Query(""),
55
+ module: str = Query(""),
56
+ role: str = Query(""),
57
+ from_date: Optional[str] = Query(None, alias="from"),
58
+ to_date: Optional[str] = Query(None, alias="to"),
59
+ limit: int = Query(100, ge=1, le=1000),
60
+ offset: int = Query(0, ge=0),
61
+ user: User = Depends(require_module_permission("audit_log", "view")),
62
+ db: Session = Depends(get_db),
63
+ ):
64
+ _require_dda()
65
+ q = db.query(AuditLog)
66
+ if action:
67
+ q = q.filter(AuditLog.action == action)
68
+ if module:
69
+ q = q.filter(AuditLog.module == module)
70
+ if role:
71
+ q = q.filter(AuditLog.role == role)
72
+ start = _parse_date(from_date)
73
+ if start:
74
+ q = q.filter(AuditLog.action_date >= start)
75
+ end = _parse_date(to_date, end_of_day=True)
76
+ if end:
77
+ q = q.filter(AuditLog.action_date <= end)
78
+
79
+ total = q.count()
80
+ rows = q.order_by(AuditLog.action_date.desc()).offset(offset).limit(limit).all()
81
+
82
+ return {
83
+ "logs": [_entry_dict(r) for r in rows],
84
+ "total": total,
85
+ "limit": limit,
86
+ "offset": offset,
87
+ }
88
+
89
+
90
+ @router.get("/audit-log/filters")
91
+ def audit_log_filters(
92
+ user: User = Depends(require_module_permission("audit_log", "view")),
93
+ db: Session = Depends(get_db),
94
+ ):
95
+ """Distinct action/module/role values, for populating the filter dropdowns."""
96
+ _require_dda()
97
+ actions = [r[0] for r in db.query(AuditLog.action).distinct().order_by(AuditLog.action) if r[0]]
98
+ modules = [r[0] for r in db.query(AuditLog.module).distinct().order_by(AuditLog.module) if r[0]]
99
+ roles = [r[0] for r in db.query(AuditLog.role).distinct().order_by(AuditLog.role) if r[0]]
100
+ return {"actions": actions, "modules": modules, "roles": roles}
app/dda/auto_detect.py ADDED
@@ -0,0 +1,614 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Periodic automatic detection for identified same-area pairs.
2
+
3
+ Manual Change Detection is unchanged (user clicks Run). Automatic mode also
4
+ keeps Run-now; this module queues the same identified Before/After pairs on a
5
+ cadence (default 10 days) and saves reports without anyone opening the UI.
6
+
7
+ The FastAPI process must stay running for the schedule to fire.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import logging
14
+ import os
15
+ import threading
16
+ from datetime import datetime, timedelta, timezone
17
+ from pathlib import Path
18
+ from typing import Any, Optional
19
+
20
+ from sqlalchemy.orm import Session
21
+
22
+ from ..database import SessionLocal
23
+ from ..models import User
24
+ from .models import AutoDetectSettings, AutoPairSchedule, DetectionJob
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ AUTO_SOURCE = "auto_schedule"
29
+
30
+ _scheduler_lock = threading.Lock()
31
+ _tick_lock = threading.Lock()
32
+ _scheduler_started = False
33
+
34
+
35
+ def _utcnow() -> datetime:
36
+ return datetime.now(timezone.utc)
37
+
38
+
39
+ def _aware(dt: Optional[datetime]) -> Optional[datetime]:
40
+ if dt is None:
41
+ return None
42
+ if dt.tzinfo is None:
43
+ return dt.replace(tzinfo=timezone.utc)
44
+ return dt
45
+
46
+
47
+ _IST = timezone(timedelta(hours=5, minutes=30))
48
+
49
+
50
+ def parse_run_at(value: str) -> str:
51
+ raw = (value or "02:00").strip()
52
+ try:
53
+ parts = raw.split(":")
54
+ hour = int(parts[0])
55
+ minute = int(parts[1]) if len(parts) > 1 else 0
56
+ except (TypeError, ValueError):
57
+ hour, minute = 2, 0
58
+ hour = max(0, min(23, hour))
59
+ minute = max(0, min(59, minute))
60
+ return f"{hour:02d}:{minute:02d}"
61
+
62
+
63
+ def clamp_interval_days(value: Any, default: int = 10) -> int:
64
+ try:
65
+ days = int(value)
66
+ except (TypeError, ValueError):
67
+ days = default
68
+ return max(1, min(365, days))
69
+
70
+
71
+ def get_or_create_settings(db: Session) -> AutoDetectSettings:
72
+ row = db.query(AutoDetectSettings).filter(AutoDetectSettings.id == 1).first()
73
+ if row:
74
+ return row
75
+ row = AutoDetectSettings(
76
+ id=1,
77
+ running=False,
78
+ interval_days=clamp_interval_days(os.environ.get("AUTO_DETECT_INTERVAL_DAYS", "10")),
79
+ run_at=parse_run_at(os.environ.get("AUTO_DETECT_RUN_AT", "02:00")),
80
+ next_run_at=None,
81
+ )
82
+ db.add(row)
83
+ db.commit()
84
+ db.refresh(row)
85
+ return row
86
+
87
+
88
+ def scheduler_is_armed() -> bool:
89
+ from .config import IS_DDA_MODE
90
+ if not IS_DDA_MODE:
91
+ return False
92
+ raw = os.environ.get("AUTO_DETECT_ENABLED", "1").strip().lower()
93
+ if raw in ("0", "false", "no", "off"):
94
+ return False
95
+ db = SessionLocal()
96
+ try:
97
+ return bool(get_or_create_settings(db).running)
98
+ except Exception:
99
+ return False
100
+ finally:
101
+ db.close()
102
+
103
+
104
+ def auto_detect_enabled() -> bool:
105
+ """Feature exists in DDA mode unless explicitly disabled in env.
106
+
107
+ The background *queue* only runs when the operator clicks Start
108
+ (``scheduler_is_armed``). This flag only means the Automatic tab controls
109
+ are available.
110
+ """
111
+ from .config import IS_DDA_MODE
112
+ if not IS_DDA_MODE:
113
+ return False
114
+ raw = os.environ.get("AUTO_DETECT_ENABLED", "1").strip().lower()
115
+ return raw not in ("0", "false", "no", "off")
116
+
117
+
118
+ def get_auto_detect_interval_days() -> int:
119
+ db = SessionLocal()
120
+ try:
121
+ return clamp_interval_days(get_or_create_settings(db).interval_days)
122
+ except Exception:
123
+ return clamp_interval_days(os.environ.get("AUTO_DETECT_INTERVAL_DAYS", "10"))
124
+ finally:
125
+ db.close()
126
+
127
+
128
+ def next_run_at_from_clock(run_at: str, now: Optional[datetime] = None) -> datetime:
129
+ """Next future IST clock time for HH:MM. Never returns 'now' (no launch burst)."""
130
+ ist_now = (now or datetime.now(_IST)).astimezone(_IST)
131
+ hhmm = parse_run_at(run_at)
132
+ hour, minute = (int(p) for p in hhmm.split(":"))
133
+ candidate = ist_now.replace(hour=hour, minute=minute, second=0, microsecond=0)
134
+ if candidate <= ist_now + timedelta(seconds=45):
135
+ candidate += timedelta(days=1)
136
+ return candidate.astimezone(timezone.utc)
137
+
138
+
139
+ def cancel_queued_auto_jobs(db: Session, reason: str) -> int:
140
+ cancelled = 0
141
+ pending = (
142
+ db.query(DetectionJob)
143
+ .filter(DetectionJob.status == "queued")
144
+ .all()
145
+ )
146
+ for job in pending:
147
+ if not is_auto_schedule_job(job):
148
+ continue
149
+ job.status = "failed"
150
+ job.error_message = reason[:2000]
151
+ job.completed_at = _utcnow()
152
+ cancelled += 1
153
+ if cancelled:
154
+ db.commit()
155
+ logger.info("Cancelled %d queued auto-detect job(s)", cancelled)
156
+ return cancelled
157
+
158
+
159
+ def pair_key_for(before_path: str, after_path: str) -> str:
160
+ a = (before_path or "").replace("\\", "/").strip().lower()
161
+ b = (after_path or "").replace("\\", "/").strip().lower()
162
+ return hashlib.sha256(f"{a}|{b}".encode("utf-8")).hexdigest()
163
+
164
+
165
+ def job_source(job: DetectionJob) -> str:
166
+ try:
167
+ return str(json.loads(job.params_json or "{}").get("source") or "")
168
+ except json.JSONDecodeError:
169
+ return ""
170
+
171
+
172
+ def is_auto_schedule_job(job: Optional[DetectionJob]) -> bool:
173
+ return bool(job) and job_source(job) == AUTO_SOURCE
174
+
175
+
176
+ def is_auto_schedule_run(db: Session, run_id: int) -> bool:
177
+ if not run_id:
178
+ return False
179
+ job = (
180
+ db.query(DetectionJob)
181
+ .filter(DetectionJob.run_id == run_id)
182
+ .order_by(DetectionJob.id.desc())
183
+ .first()
184
+ )
185
+ return is_auto_schedule_job(job)
186
+
187
+
188
+ def user_can_access_run(db: Session, run_id: int, user_id: int) -> bool:
189
+ from ..models import DetectionRun
190
+ run = db.query(DetectionRun).filter(DetectionRun.id == run_id).first()
191
+ if not run:
192
+ return False
193
+ if run.user_id == user_id:
194
+ return True
195
+ return is_auto_schedule_run(db, run_id)
196
+
197
+
198
+ def _norm_path(path: str) -> str:
199
+ return (path or "").replace("\\", "/").strip()
200
+
201
+
202
+ def _paths_match(job: DetectionJob, before: str, after: str) -> bool:
203
+ try:
204
+ params = json.loads(job.params_json or "{}")
205
+ except json.JSONDecodeError:
206
+ return False
207
+ return (
208
+ _norm_path(params.get("base_path", "")) == _norm_path(before)
209
+ and _norm_path(params.get("comparison_path", "")) == _norm_path(after)
210
+ )
211
+
212
+
213
+ def _schedule_owner_id(db: Session) -> int:
214
+ admin = (
215
+ db.query(User)
216
+ .filter(User.role == "admin")
217
+ .order_by(User.id.asc())
218
+ .first()
219
+ )
220
+ if admin:
221
+ return admin.id
222
+ user = (
223
+ db.query(User)
224
+ .filter(User.email.notlike("__guest__%"))
225
+ .order_by(User.id.asc())
226
+ .first()
227
+ )
228
+ if user:
229
+ return user.id
230
+ from ..auth import get_or_create_guest_user
231
+ return get_or_create_guest_user(db).id
232
+
233
+
234
+ def _load_identified_pairs(db: Session) -> list[dict[str, Any]]:
235
+ from .tree.area_groups import load_cached_area_groups
236
+
237
+ payload = load_cached_area_groups(db)
238
+ pairs = []
239
+ for group in payload.get("groups") or []:
240
+ before = group.get("beforePath") or (group.get("suggestedBefore") or {}).get("path")
241
+ after = group.get("afterPath") or (group.get("suggestedAfter") or {}).get("path")
242
+ if not before or not after or _norm_path(before) == _norm_path(after):
243
+ continue
244
+ pairs.append({
245
+ "group_id": group.get("id") or "",
246
+ "label": group.get("label") or Path(before).name,
247
+ "before_path": _norm_path(before),
248
+ "after_path": _norm_path(after),
249
+ "before_date": group.get("beforeDate"),
250
+ "after_date": group.get("afterDate"),
251
+ })
252
+ return pairs
253
+
254
+
255
+ def _sync_schedules(db: Session, pairs: list[dict[str, Any]]) -> None:
256
+ interval = get_auto_detect_interval_days()
257
+ live_keys = set()
258
+ now = _utcnow()
259
+ for pair in pairs:
260
+ key = pair_key_for(pair["before_path"], pair["after_path"])
261
+ live_keys.add(key)
262
+ row = db.query(AutoPairSchedule).filter(AutoPairSchedule.pair_key == key).first()
263
+ if row:
264
+ row.group_label = pair["label"]
265
+ row.before_path = pair["before_path"]
266
+ row.after_path = pair["after_path"]
267
+ row.interval_days = interval
268
+ row.enabled = True
269
+ row.updated_at = now
270
+ else:
271
+ db.add(AutoPairSchedule(
272
+ pair_key=key,
273
+ group_label=pair["label"],
274
+ before_path=pair["before_path"],
275
+ after_path=pair["after_path"],
276
+ interval_days=interval,
277
+ enabled=True,
278
+ ))
279
+ stale = (
280
+ db.query(AutoPairSchedule)
281
+ .filter(AutoPairSchedule.enabled.is_(True))
282
+ .all()
283
+ )
284
+ for row in stale:
285
+ if row.pair_key not in live_keys:
286
+ row.enabled = False
287
+ row.updated_at = now
288
+ db.commit()
289
+
290
+
291
+ def _pending_job_for_paths(db: Session, before: str, after: str) -> Optional[DetectionJob]:
292
+ pending = (
293
+ db.query(DetectionJob)
294
+ .filter(DetectionJob.status.in_(("queued", "running")))
295
+ .order_by(DetectionJob.id.desc())
296
+ .limit(80)
297
+ .all()
298
+ )
299
+ for job in pending:
300
+ if _paths_match(job, before, after):
301
+ return job
302
+ return None
303
+
304
+
305
+ def _latest_success_for_paths(db: Session, before: str, after: str) -> Optional[datetime]:
306
+ done = (
307
+ db.query(DetectionJob)
308
+ .filter(DetectionJob.status == "completed", DetectionJob.completed_at.isnot(None))
309
+ .order_by(DetectionJob.completed_at.desc())
310
+ .limit(80)
311
+ .all()
312
+ )
313
+ for job in done:
314
+ if _paths_match(job, before, after):
315
+ return _aware(job.completed_at)
316
+ return None
317
+
318
+
319
+ def _is_due(row: AutoPairSchedule, last_success: Optional[datetime], now: datetime) -> bool:
320
+ interval = timedelta(days=max(1, int(row.interval_days or get_auto_detect_interval_days())))
321
+ last = _aware(row.last_completed_at) or last_success
322
+ if last and now < last + interval:
323
+ return False
324
+ last_enq = _aware(row.last_enqueued_at)
325
+ if row.last_error and last_enq and now < last_enq + timedelta(hours=12):
326
+ return False
327
+ if last_enq and last and now < last_enq + timedelta(minutes=30):
328
+ return False
329
+ return True
330
+
331
+
332
+ def _enqueue_pair(db: Session, row: AutoPairSchedule) -> Optional[DetectionJob]:
333
+ from .job_runner import create_local_folder_job
334
+ from .local_routes import safe_resolve
335
+
336
+ try:
337
+ safe_resolve(row.before_path)
338
+ safe_resolve(row.after_path)
339
+ except Exception as exc:
340
+ row.last_error = f"Library path missing: {exc}"[:2000]
341
+ db.commit()
342
+ logger.warning("Auto-detect skip %s: %s", row.group_label, exc)
343
+ return None
344
+
345
+ title = f"[Auto] {row.group_label}: {Path(row.before_path).name} vs {Path(row.after_path).name}"
346
+ job = create_local_folder_job(
347
+ db,
348
+ base_path=row.before_path,
349
+ comparison_path=row.after_path,
350
+ title=title,
351
+ created_by=_schedule_owner_id(db),
352
+ extra_params={
353
+ "source": AUTO_SOURCE,
354
+ "pair_key": row.pair_key,
355
+ "group_label": row.group_label,
356
+ },
357
+ )
358
+ row.last_enqueued_at = _utcnow()
359
+ row.last_job_id = job.id
360
+ row.last_error = ""
361
+ db.commit()
362
+ logger.info(
363
+ "Auto-detect queued job %d for %s (%s β†’ %s)",
364
+ job.id, row.group_label, Path(row.before_path).name, Path(row.after_path).name,
365
+ )
366
+ return job
367
+
368
+
369
+ def tick_auto_detect() -> int:
370
+ """Sync identified pairs and enqueue those that are due. Returns jobs queued."""
371
+ if not scheduler_is_armed():
372
+ return 0
373
+ if not _tick_lock.acquire(blocking=False):
374
+ return 0
375
+ queued = 0
376
+ db = SessionLocal()
377
+ try:
378
+ pairs = _load_identified_pairs(db)
379
+ _sync_schedules(db, pairs)
380
+ now = _utcnow()
381
+ rows = (
382
+ db.query(AutoPairSchedule)
383
+ .filter(AutoPairSchedule.enabled.is_(True))
384
+ .order_by(AutoPairSchedule.id.asc())
385
+ .all()
386
+ )
387
+ for row in rows:
388
+ if _pending_job_for_paths(db, row.before_path, row.after_path):
389
+ continue
390
+ last_success = _latest_success_for_paths(db, row.before_path, row.after_path)
391
+ if last_success and (
392
+ row.last_completed_at is None or _aware(row.last_completed_at) < last_success
393
+ ):
394
+ row.last_completed_at = last_success
395
+ db.commit()
396
+ if not _is_due(row, last_success, now):
397
+ continue
398
+ if _enqueue_pair(db, row):
399
+ queued += 1
400
+ if queued:
401
+ logger.info("Auto-detect tick queued %d pair(s)", queued)
402
+ except Exception:
403
+ logger.exception("Auto-detect tick failed")
404
+ db.rollback()
405
+ queued = 0
406
+ finally:
407
+ db.close()
408
+ _tick_lock.release()
409
+ if queued:
410
+ from .job_runner import start_next_queued_job
411
+ start_next_queued_job()
412
+ return queued
413
+
414
+
415
+ def mark_schedule_from_job(job_id: int, *, status: str, run_id: Optional[int], error: str = "") -> None:
416
+ db = SessionLocal()
417
+ try:
418
+ job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
419
+ if not is_auto_schedule_job(job):
420
+ # Manual success for the same paths still postpones the next auto run.
421
+ if status == "completed" and job:
422
+ try:
423
+ params = json.loads(job.params_json or "{}")
424
+ except json.JSONDecodeError:
425
+ return
426
+ key = pair_key_for(params.get("base_path", ""), params.get("comparison_path", ""))
427
+ row = db.query(AutoPairSchedule).filter(AutoPairSchedule.pair_key == key).first()
428
+ if row:
429
+ row.last_completed_at = _utcnow()
430
+ row.last_job_id = job_id
431
+ if run_id:
432
+ row.last_run_id = run_id
433
+ row.last_error = ""
434
+ db.commit()
435
+ return
436
+ try:
437
+ params = json.loads(job.params_json or "{}")
438
+ except json.JSONDecodeError:
439
+ params = {}
440
+ key = params.get("pair_key") or pair_key_for(
441
+ params.get("base_path", ""), params.get("comparison_path", ""))
442
+ row = db.query(AutoPairSchedule).filter(AutoPairSchedule.pair_key == key).first()
443
+ if not row:
444
+ return
445
+ row.last_job_id = job_id
446
+ if status == "completed":
447
+ row.last_completed_at = _utcnow()
448
+ row.last_run_id = run_id
449
+ row.last_error = ""
450
+ else:
451
+ row.last_error = (error or "")[:2000]
452
+ db.commit()
453
+ except Exception:
454
+ logger.exception("Could not update auto-detect schedule for job %s", job_id)
455
+ db.rollback()
456
+ finally:
457
+ db.close()
458
+
459
+
460
+ def refresh_schedule_rows() -> None:
461
+ """Create/update schedule rows from identified pairs without enqueueing jobs."""
462
+ db = SessionLocal()
463
+ try:
464
+ pairs = _load_identified_pairs(db)
465
+ _sync_schedules(db, pairs)
466
+ except Exception:
467
+ logger.exception("Auto-detect schedule sync failed")
468
+ db.rollback()
469
+ finally:
470
+ db.close()
471
+
472
+
473
+ def schedule_status_payload(db: Session) -> dict:
474
+ settings = get_or_create_settings(db)
475
+ interval = clamp_interval_days(settings.interval_days)
476
+ feature_on = auto_detect_enabled()
477
+ armed = bool(settings.running) and feature_on
478
+ next_run = _aware(settings.next_run_at)
479
+ rows = db.query(AutoPairSchedule).order_by(AutoPairSchedule.group_label.asc()).all()
480
+ out = []
481
+ for row in rows:
482
+ last = _aware(row.last_completed_at)
483
+ job = db.query(DetectionJob).filter(DetectionJob.id == row.last_job_id).first() if row.last_job_id else None
484
+ out.append({
485
+ "pairKey": row.pair_key,
486
+ "label": row.group_label,
487
+ "beforePath": row.before_path,
488
+ "afterPath": row.after_path,
489
+ "enabled": bool(row.enabled) and armed,
490
+ "intervalDays": interval,
491
+ "lastCompletedAt": last.isoformat() if last else None,
492
+ "nextDueAt": next_run.isoformat() if next_run and armed else None,
493
+ "dueNow": False,
494
+ "lastJobId": row.last_job_id,
495
+ "lastRunId": row.last_run_id,
496
+ "lastJobStatus": job.status if job else None,
497
+ "lastError": row.last_error or "",
498
+ })
499
+ return {
500
+ "enabled": feature_on,
501
+ "running": armed,
502
+ "intervalDays": interval,
503
+ "runAt": parse_run_at(settings.run_at),
504
+ "nextRunAt": next_run.isoformat() if next_run and armed else None,
505
+ "pairs": out,
506
+ }
507
+
508
+
509
+ def start_schedule(*, interval_days: int, run_at: str) -> dict:
510
+ if not auto_detect_enabled():
511
+ raise ValueError("Automatic detection is disabled on the server.")
512
+ db = SessionLocal()
513
+ try:
514
+ refresh_schedule_rows()
515
+ settings = get_or_create_settings(db)
516
+ settings.interval_days = clamp_interval_days(interval_days)
517
+ settings.run_at = parse_run_at(run_at)
518
+ settings.running = True
519
+ settings.next_run_at = next_run_at_from_clock(settings.run_at)
520
+ settings.updated_at = _utcnow()
521
+ db.commit()
522
+ payload = schedule_status_payload(db)
523
+ logger.info(
524
+ "Auto-detect started: interval=%d day(s) at %s IST; first queue %s (not now)",
525
+ settings.interval_days, settings.run_at, settings.next_run_at,
526
+ )
527
+ return payload
528
+ finally:
529
+ db.close()
530
+
531
+
532
+ def stop_schedule() -> dict:
533
+ db = SessionLocal()
534
+ try:
535
+ settings = get_or_create_settings(db)
536
+ settings.running = False
537
+ settings.next_run_at = None
538
+ settings.updated_at = _utcnow()
539
+ db.commit()
540
+ cancel_queued_auto_jobs(
541
+ db,
542
+ "Cancelled because automatic schedule was stopped.",
543
+ )
544
+ logger.info("Auto-detect stopped; queued auto jobs cancelled")
545
+ return schedule_status_payload(db)
546
+ finally:
547
+ db.close()
548
+
549
+
550
+ def _maybe_fire_scheduled_tick() -> None:
551
+ if not scheduler_is_armed():
552
+ return
553
+ db = SessionLocal()
554
+ try:
555
+ settings = get_or_create_settings(db)
556
+ due_at = _aware(settings.next_run_at)
557
+ if not due_at or _utcnow() < due_at:
558
+ return
559
+ slot = due_at
560
+ settings.next_run_at = slot + timedelta(days=clamp_interval_days(settings.interval_days))
561
+ db.commit()
562
+ except Exception:
563
+ logger.exception("Auto-detect schedule clock failed")
564
+ db.rollback()
565
+ return
566
+ finally:
567
+ db.close()
568
+ tick_auto_detect()
569
+
570
+
571
+ def _scheduler_loop() -> None:
572
+ while True:
573
+ threading.Event().wait(20)
574
+ try:
575
+ _maybe_fire_scheduled_tick()
576
+ except Exception:
577
+ logger.exception("Auto-detect waiter failed")
578
+
579
+
580
+ def reset_schedule_on_process_start() -> None:
581
+ """Disarm the queue and cancel leftover auto jobs. Safe to call twice."""
582
+ db = SessionLocal()
583
+ try:
584
+ settings = get_or_create_settings(db)
585
+ settings.running = False
586
+ settings.next_run_at = None
587
+ db.commit()
588
+ cancel_queued_auto_jobs(
589
+ db,
590
+ "Cancelled on server start. Automatic detection does not run until you click Start.",
591
+ )
592
+ except Exception:
593
+ logger.exception("Could not reset auto-detect on launch")
594
+ db.rollback()
595
+ finally:
596
+ db.close()
597
+
598
+
599
+ def start_auto_detect_scheduler() -> None:
600
+ """Start the idle waiter. Never enqueues jobs on launch."""
601
+ global _scheduler_started
602
+ reset_schedule_on_process_start()
603
+
604
+ if not auto_detect_enabled():
605
+ logger.info("Automatic pair detection feature disabled")
606
+ return
607
+ with _scheduler_lock:
608
+ if _scheduler_started:
609
+ return
610
+ _scheduler_started = True
611
+ logger.info(
612
+ "Automatic detection is idle. Set interval/time on the Automatic tab and click Start."
613
+ )
614
+ threading.Thread(target=_scheduler_loop, daemon=True, name="dda-auto-detect").start()
app/dda/bootstrap.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+
3
+ from fastapi import FastAPI
4
+ from sqlalchemy import text as sa_text
5
+
6
+ from ..database import engine
7
+ from .config import IS_DDA_MODE, ensure_library_dirs, get_storage_root, is_hf_hosted
8
+ from .admin_routes import router as admin_router
9
+ from .dashboard_routes import router as dashboard_router
10
+ from .jobs_routes import router as jobs_router
11
+ from .library_routes import router as library_router
12
+ from .local_routes import router as local_router
13
+ from .reports_routes import router as reports_router
14
+ from .review_routes import router as review_router
15
+ from .training_routes import router as training_router
16
+ from .tree.routes import router as tree_router
17
+ from .rbac.routes import router as rbac_router
18
+ from .masters_routes import router as masters_router
19
+ from .logs_routes import router as logs_router
20
+ from .audit_routes import router as audit_router
21
+ from .exception_routes import router as exception_router
22
+ from .dda_auth import seed_dda_admin
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def _migrate_detection_jobs_nullable() -> None:
28
+ """SQLite legacy schema required image asset FKs; tree library jobs use paths only.
29
+
30
+ SQLite-only: rebuilds the table via SQLite's PRAGMA/copy-and-rename
31
+ pattern because SQLite can't ALTER COLUMN to drop NOT NULL directly.
32
+ Irrelevant on MySQL/PostgreSQL β€” a fresh create_all() there already
33
+ creates the column with the current (nullable) definition, and neither
34
+ speaks SQLite's PRAGMA dialect, so skip cleanly instead of erroring.
35
+ """
36
+ if engine.dialect.name != "sqlite":
37
+ return
38
+ try:
39
+ with engine.connect() as conn:
40
+ rows = conn.execute(sa_text("PRAGMA table_info(dda_detection_jobs)")).fetchall()
41
+ if not rows:
42
+ return
43
+ by_name = {r[1]: r for r in rows}
44
+ base_col = by_name.get("base_image_id")
45
+ if not base_col or base_col[3] == 0:
46
+ return
47
+ logger.info("Migrating dda_detection_jobs to allow NULL image IDs for path-based jobs")
48
+ conn.execute(sa_text("PRAGMA foreign_keys=OFF"))
49
+ conn.execute(sa_text("""
50
+ CREATE TABLE dda_detection_jobs_new (
51
+ id INTEGER NOT NULL PRIMARY KEY,
52
+ status VARCHAR(32),
53
+ base_image_id INTEGER,
54
+ comparison_image_id INTEGER,
55
+ method VARCHAR(64),
56
+ params_json TEXT,
57
+ run_id INTEGER,
58
+ error_message TEXT,
59
+ notify_email VARCHAR(255),
60
+ created_by INTEGER,
61
+ started_at DATETIME,
62
+ completed_at DATETIME,
63
+ created_at DATETIME,
64
+ FOREIGN KEY(base_image_id) REFERENCES dda_image_assets (id),
65
+ FOREIGN KEY(comparison_image_id) REFERENCES dda_image_assets (id),
66
+ FOREIGN KEY(run_id) REFERENCES detection_runs (id),
67
+ FOREIGN KEY(created_by) REFERENCES users (id)
68
+ )
69
+ """))
70
+ conn.execute(sa_text("""
71
+ INSERT INTO dda_detection_jobs_new (
72
+ id, status, base_image_id, comparison_image_id, method, params_json,
73
+ run_id, error_message, notify_email, created_by,
74
+ started_at, completed_at, created_at
75
+ )
76
+ SELECT
77
+ id, status, base_image_id, comparison_image_id, method, params_json,
78
+ run_id, error_message, notify_email, created_by,
79
+ started_at, completed_at, created_at
80
+ FROM dda_detection_jobs
81
+ """))
82
+ conn.execute(sa_text("DROP TABLE dda_detection_jobs"))
83
+ conn.execute(sa_text("ALTER TABLE dda_detection_jobs_new RENAME TO dda_detection_jobs"))
84
+ conn.execute(sa_text("PRAGMA foreign_keys=ON"))
85
+ conn.commit()
86
+ except Exception as exc:
87
+ logger.warning("Detection jobs nullable migration skipped: %s", exc)
88
+
89
+
90
+ def init_dda_database():
91
+ """Run DDA-specific startup tasks (dirs, seed, migrations)."""
92
+ if not IS_DDA_MODE:
93
+ return
94
+ ensure_library_dirs()
95
+ get_storage_root().mkdir(parents=True, exist_ok=True)
96
+ logger.info("DDA database dialect=%s", engine.dialect.name)
97
+ try:
98
+ with engine.connect() as conn:
99
+ for stmt in (
100
+ "ALTER TABLE users ADD COLUMN role VARCHAR(32) DEFAULT 'analyst'",
101
+ "ALTER TABLE users ADD COLUMN role_id INTEGER DEFAULT NULL",
102
+ "ALTER TABLE users ADD COLUMN totp_secret VARCHAR(64) DEFAULT NULL",
103
+ "ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0",
104
+ "ALTER TABLE users ADD COLUMN totp_confirmed_at DATETIME DEFAULT NULL",
105
+ "ALTER TABLE users ADD COLUMN phone VARCHAR(20) DEFAULT NULL",
106
+ "ALTER TABLE detection_runs ADD COLUMN after_full_path VARCHAR(512) DEFAULT ''",
107
+ "ALTER TABLE dda_roles ADD COLUMN is_active BOOLEAN DEFAULT 1",
108
+ "ALTER TABLE dda_audit_logs ADD COLUMN role VARCHAR(64) DEFAULT ''",
109
+ "ALTER TABLE dda_audit_logs ADD COLUMN module VARCHAR(64) DEFAULT ''",
110
+ ):
111
+ try:
112
+ conn.execute(sa_text(stmt))
113
+ conn.commit()
114
+ except Exception:
115
+ conn.rollback()
116
+ try:
117
+ conn.execute(sa_text(
118
+ "CREATE UNIQUE INDEX IF NOT EXISTS ix_users_phone ON users (phone)"
119
+ ))
120
+ conn.commit()
121
+ except Exception:
122
+ conn.rollback()
123
+ try:
124
+ conn.execute(sa_text(
125
+ "CREATE UNIQUE INDEX IF NOT EXISTS ix_dda_region_reviews_run_region "
126
+ "ON dda_region_reviews (run_id, region_id)"
127
+ ))
128
+ conn.commit()
129
+ except Exception:
130
+ conn.rollback()
131
+ _migrate_detection_jobs_nullable()
132
+ except Exception as exc:
133
+ logger.warning("DDA schema migration skipped: %s", exc)
134
+
135
+ from ..database import Base, SessionLocal
136
+ from .tree.models import AuditLog, ExceptionLog, ImageLibrary, TreeNode # noqa: F401
137
+ try:
138
+ Base.metadata.create_all(bind=engine, tables=[
139
+ TreeNode.__table__,
140
+ ImageLibrary.__table__,
141
+ AuditLog.__table__,
142
+ ExceptionLog.__table__,
143
+ ])
144
+ except Exception as exc:
145
+ logger.warning("Tree table create skipped: %s", exc)
146
+
147
+ db = SessionLocal()
148
+ try:
149
+ seed_dda_admin(db)
150
+ from .rbac.seed import seed_rbac
151
+ try:
152
+ seed_rbac(db)
153
+ except Exception as exc:
154
+ logger.warning("RBAC seed skipped: %s", exc)
155
+ try:
156
+ from .seed import seed_delhi_hierarchy
157
+ seed_delhi_hierarchy(db)
158
+ except Exception as exc:
159
+ logger.warning("Zone/village seed skipped: %s", exc)
160
+ from .tree.migration import run_tree_migration
161
+ mig = run_tree_migration(db)
162
+ logger.info("Tree migration: %s", mig)
163
+ from .auto_detect import reset_schedule_on_process_start
164
+ reset_schedule_on_process_start()
165
+ from .job_runner import reconcile_stale_jobs
166
+ reconcile_stale_jobs(db)
167
+ finally:
168
+ db.close()
169
+
170
+ def _fs_sync_worker():
171
+ from ..database import SessionLocal as SL
172
+ from .tree.image_service import count_library_images
173
+ from .tree.sync_service import sync_from_filesystem
174
+
175
+ sdb = SL()
176
+ try:
177
+ try:
178
+ sync_stats = sync_from_filesystem(sdb, check_orphans=False)
179
+ logger.info("Filesystem sync at startup: %s", sync_stats)
180
+ except Exception as exc:
181
+ logger.warning("Filesystem sync at startup failed: %s", exc)
182
+ try:
183
+ total = count_library_images(sdb)
184
+ logger.info(
185
+ "DDA tree library ready (hosted=%s): %d images, storage=%s",
186
+ is_hf_hosted(),
187
+ total,
188
+ get_storage_root(),
189
+ )
190
+ except Exception as exc:
191
+ logger.warning("Tree library scan at startup failed: %s", exc)
192
+ finally:
193
+ sdb.close()
194
+
195
+ import threading
196
+ threading.Thread(target=_fs_sync_worker, daemon=True, name="dda-fs-sync").start()
197
+
198
+
199
+ def setup_dda(app: FastAPI) -> None:
200
+ if not IS_DDA_MODE:
201
+ logger.info("APP_MODE=legacy β€” DDA routes disabled")
202
+ return
203
+ app.include_router(tree_router, prefix="/api/dda", tags=["dda-tree"])
204
+ app.include_router(library_router, prefix="/api/dda", tags=["dda"])
205
+ app.include_router(jobs_router, prefix="/api/dda", tags=["dda-jobs"])
206
+ app.include_router(reports_router, prefix="/api/dda", tags=["dda-reports"])
207
+ app.include_router(review_router, prefix="/api/dda", tags=["dda-review"])
208
+ app.include_router(training_router, prefix="/api/dda", tags=["dda-training"])
209
+ app.include_router(admin_router, prefix="/api/dda", tags=["dda-admin"])
210
+ app.include_router(local_router, prefix="/api/dda", tags=["dda-local"])
211
+ app.include_router(rbac_router, prefix="/api/dda", tags=["dda-rbac"])
212
+ app.include_router(dashboard_router, prefix="/api/dda", tags=["dda-dashboard"])
213
+ app.include_router(masters_router, prefix="/api/dda", tags=["dda-masters"])
214
+ app.include_router(logs_router, prefix="/api/dda", tags=["dda-logs"])
215
+ app.include_router(audit_router, prefix="/api/dda", tags=["dda-audit"])
216
+ app.include_router(exception_router, prefix="/api/dda", tags=["dda-exceptions"])
217
+ logger.info("APP_MODE=dda β€” DDA routes enabled (tree, library, jobs, reports, review, training, admin, local, rbac, dashboard, masters, logs)")
app/dda/change_type_map.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Map internal detection_engine labels β†’ Vedangsoft report change types (FR-04)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ DDA_NEW_CONSTRUCTION = "New Construction"
7
+ DDA_DEMOLITION = "Demolition"
8
+ DDA_EXTENSION = "Extension"
9
+ DDA_VEGETATION = "Vegetation Change"
10
+ DDA_OTHER = "Other"
11
+
12
+ _KEYWORDS = [
13
+ (DDA_DEMOLITION, ("demolition", "clearing", "removed", "debris")),
14
+ (DDA_EXTENSION, ("expansion", "widening", "extension", "renovation", "addition")),
15
+ (DDA_VEGETATION, ("vegetation", "tree", "forest", "green", "crop")),
16
+ (DDA_NEW_CONSTRUCTION, (
17
+ "construction", "building", "structure", "road", "pavement",
18
+ "temporary", "roof", "concrete", "foundation", "solar", "panel",
19
+ "industrial",
20
+ )),
21
+ # Bare/soil/water only β€” do NOT match the substring "land" alone (too broad).
22
+ (DDA_OTHER, ("water body", "bare land", "bare earth", "soil change")),
23
+ ]
24
+
25
+
26
+ def map_to_dda_change_type(internal_type: str) -> str:
27
+ """Return canonical Vedangsoft change type for a detection_engine object_type string."""
28
+ if not internal_type:
29
+ return DDA_OTHER
30
+ lower = internal_type.lower()
31
+ for dda_type, keywords in _KEYWORDS:
32
+ if any(kw in lower for kw in keywords):
33
+ return dda_type
34
+ if "new" in lower:
35
+ return DDA_NEW_CONSTRUCTION
36
+ return DDA_OTHER
37
+
38
+
39
+ def enrich_region_for_dda(region: dict, *, dda_change_type: Optional[str] = None) -> dict:
40
+ """Add Vedangsoft report fields to a serialized region dict."""
41
+ internal = region.get("objectType") or region.get("object_type") or ""
42
+ out = dict(region)
43
+ out["ddaChangeType"] = dda_change_type or map_to_dda_change_type(internal)
44
+ out["internalObjectType"] = internal
45
+ out["reviewStatus"] = region.get("reviewStatus", "pending")
46
+ return out
app/dda/config.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+ from typing import List
4
+
5
+ from ..database import DATA_DIR
6
+
7
+ APP_MODE_RAW = os.environ.get("APP_MODE", "").strip().lower()
8
+ _SPACE_ID = os.environ.get("SPACE_ID", "").strip().lower()
9
+
10
+
11
+ def is_hf_hosted() -> bool:
12
+ return bool(_SPACE_ID)
13
+
14
+
15
+ def _is_dda_mode() -> bool:
16
+ if APP_MODE_RAW == "legacy":
17
+ return False
18
+ if APP_MODE_RAW == "dda":
19
+ return True
20
+ # APP_MODE unset: auto-enable on satdetect-dev only (production satdetect stays legacy)
21
+ return _SPACE_ID.endswith("/satdetect-dev")
22
+
23
+
24
+ def get_public_base_url() -> str:
25
+ """Public URL for deep links (email, share). Override with PUBLIC_BASE_URL."""
26
+ explicit = os.environ.get("PUBLIC_BASE_URL", "").strip().rstrip("/")
27
+ if explicit:
28
+ return explicit
29
+ if _SPACE_ID:
30
+ return f"https://{_SPACE_ID.replace('/', '-')}.hf.space"
31
+ return "http://localhost:7860"
32
+
33
+
34
+ IS_DDA_MODE = _is_dda_mode()
35
+ APP_MODE = APP_MODE_RAW or ("dda" if IS_DDA_MODE else "legacy")
36
+
37
+
38
+ # Project root: change_detection_webapp/
39
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
40
+
41
+
42
+ def get_writable_library_root() -> Path:
43
+ """Persistent folder for uploads (HF: /home/appuser/data/library_sources)."""
44
+ return (DATA_DIR / "library_sources").resolve()
45
+
46
+
47
+ def get_storage_root() -> Path:
48
+ """Tree library root (doc: root_directory / STORAGE_ROOT)."""
49
+ explicit = os.environ.get("STORAGE_ROOT", "").strip()
50
+ if explicit:
51
+ return Path(explicit).resolve()
52
+ if os.environ.get("LOCAL_LIBRARY_ROOT"):
53
+ return Path(os.environ["LOCAL_LIBRARY_ROOT"]).resolve()
54
+ return get_writable_library_root()
55
+
56
+
57
+ def get_library_roots() -> List[Path]:
58
+ """Folders scanned for library images (tree storage root)."""
59
+ roots: List[Path] = []
60
+ sr = get_storage_root()
61
+ if sr not in roots:
62
+ roots.append(sr)
63
+ if os.environ.get("LOCAL_LIBRARY_ROOT"):
64
+ p = Path(os.environ["LOCAL_LIBRARY_ROOT"]).resolve()
65
+ if p not in roots:
66
+ roots.append(p)
67
+ if is_hf_hosted():
68
+ wr = get_writable_library_root()
69
+ if wr not in roots:
70
+ roots.append(wr)
71
+ for candidate in (get_writable_library_root(), PROJECT_ROOT / "library_sources"):
72
+ resolved = candidate.resolve()
73
+ if resolved not in roots:
74
+ roots.append(resolved)
75
+ return roots
76
+
77
+
78
+ # Primary root (shown in UI) β€” first entry from get_library_roots()
79
+ LOCAL_LIBRARY_ROOT = get_library_roots()[0]
80
+
81
+ LIBRARY_DIR = DATA_DIR / "library"
82
+ THUMBS_DIR = LIBRARY_DIR / "thumbs"
83
+ PREVIEWS_DIR = LIBRARY_DIR / "previews"
84
+ LOCAL_THUMB_CACHE = DATA_DIR / "library_cache" / "thumbs"
85
+
86
+ # Library upload limit (default 15 GB; override with MAX_GEOTIFF_MB / MAX_IMAGE_MB)
87
+ _MAX_UPLOAD_MB_DEFAULT = "15360" # 15 * 1024
88
+ MAX_GEOTIFF_BYTES = int(os.environ.get("MAX_GEOTIFF_MB", _MAX_UPLOAD_MB_DEFAULT)) * 1024 * 1024
89
+
90
+ # PNG/JPEG use the same cap unless MAX_IMAGE_MB is set separately
91
+ MAX_IMAGE_BYTES = int(os.environ.get("MAX_IMAGE_MB", _MAX_UPLOAD_MB_DEFAULT)) * 1024 * 1024
92
+
93
+ ALLOWED_EXTENSIONS = {".tif", ".tiff", ".png", ".jpg", ".jpeg"}
94
+
95
+
96
+ def ensure_library_dirs() -> None:
97
+ for root in get_library_roots():
98
+ try:
99
+ root.mkdir(parents=True, exist_ok=True)
100
+ except OSError:
101
+ pass
102
+ for d in (LIBRARY_DIR, THUMBS_DIR, PREVIEWS_DIR, LOCAL_THUMB_CACHE):
103
+ try:
104
+ d.mkdir(parents=True, exist_ok=True)
105
+ except OSError:
106
+ pass
107
+
108
+
109
+ def ensure_local_year_folders() -> None:
110
+ """Create default year folders under each library root if missing."""
111
+ from datetime import datetime
112
+ current = datetime.now().year
113
+ for root in get_library_roots():
114
+ for year in (current - 1, current, current + 1):
115
+ try:
116
+ (root / str(year)).mkdir(parents=True, exist_ok=True)
117
+ except OSError:
118
+ pass
119
+
120
+
121
+ def max_upload_bytes_for_extension(ext: str) -> int:
122
+ if ext in (".tif", ".tiff"):
123
+ return MAX_GEOTIFF_BYTES
124
+ return MAX_IMAGE_BYTES
125
+
126
+
127
+ def geotiff_io_available() -> bool:
128
+ try:
129
+ import rasterio # noqa: F401
130
+ return True
131
+ except ImportError:
132
+ return False
133
+
134
+
135
+ def get_detection_max_side() -> int:
136
+ """Max pixel dimension for GeoTIFF load + detection pipeline (higher = sharper, more RAM)."""
137
+ from ..detection_config import get_detection_max_side as _central
138
+ return _central()
app/dda/dashboard_routes.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dashboard aggregation API.
2
+
3
+ Backs the app's home dashboard (Change Detection Overview, region/village
4
+ breakdowns, location comparisons, trend, recent reports). Scoped to the
5
+ requesting user the same way /api/history and reports.js are β€” own runs
6
+ plus any auto-scheduled runs everyone can see β€” so two different users see
7
+ different dashboards, exactly like they already see different Reports lists.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from collections import defaultdict
12
+ from datetime import datetime, timedelta, timezone
13
+ from typing import Optional
14
+
15
+ from fastapi import APIRouter, Depends, HTTPException, Query
16
+ from sqlalchemy import or_
17
+ from sqlalchemy.orm import Session
18
+
19
+ from ..database import get_db
20
+ from ..models import DetectionRun, User
21
+ from .auto_detect import is_auto_schedule_job
22
+ from .dda_auth import current_dda_user
23
+ from .models import DetectionJob
24
+ from .review_service import bulk_classification_counts
25
+
26
+ router = APIRouter()
27
+
28
+ UNASSIGNED = "Unassigned"
29
+ DEFAULT_ROLE = "analyst"
30
+
31
+ # Matches DEFAULT_ROLES rank order in app/dda/rbac/seed.py β€” keeps the role
32
+ # breakdown in a stable, meaningful order instead of alphabetical/count order.
33
+ ROLE_ORDER = ["viewer", "uploader", "analyst", "admin"]
34
+
35
+ MONTH_NAMES = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
36
+
37
+ _IST = timezone(timedelta(hours=5, minutes=30))
38
+
39
+
40
+ def _isoformat_ist(dt):
41
+ """Same conversion as detect_service._isoformat_ist, duplicated locally
42
+ to avoid importing detect_service (which pulls in geo_regions) just for
43
+ date formatting."""
44
+ if dt is None:
45
+ return None
46
+ if dt.tzinfo is None:
47
+ dt = dt.replace(tzinfo=timezone.utc)
48
+ return dt.astimezone(_IST).isoformat()
49
+
50
+
51
+ def _require_dda():
52
+ from .config import IS_DDA_MODE
53
+ if not IS_DDA_MODE:
54
+ raise HTTPException(status_code=404, detail="DDA mode is not enabled")
55
+
56
+
57
+ def _run_thumbs(run: DetectionRun) -> dict:
58
+ return {
59
+ "overlayUrl": f"/api/overlay/{run.overlay_path}" if run.overlay_path else None,
60
+ "beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if (run.before_thumb_path or "").strip() else None,
61
+ "afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if (run.after_thumb_path or "").strip() else None,
62
+ }
63
+
64
+
65
+ def _run_summary(row: dict) -> dict:
66
+ run = row["run"]
67
+ return {
68
+ "id": run.id,
69
+ "title": run.title,
70
+ "zone": row["zone"],
71
+ "village": row["village"],
72
+ "role": row["role"],
73
+ "method": run.method,
74
+ "changePercentage": run.change_percentage,
75
+ "regionsCount": run.regions_count or 0,
76
+ "unclassified": row["unclassified"],
77
+ "confirmed": row["confirmed"],
78
+ "falsePositive": row["falsePositive"],
79
+ "createdAt": _isoformat_ist(run.created_at),
80
+ **_run_thumbs(run),
81
+ }
82
+
83
+
84
+ def _accessible_run_filter(db: Session, user: User):
85
+ """Same run-visibility rule as GET /api/history in app/main.py: the
86
+ user's own runs, plus any run that came from the auto-schedule (visible
87
+ to everyone, not just whoever happened to be logged in when it fired)."""
88
+ auto_ids = [
89
+ j.run_id for j in (
90
+ db.query(DetectionJob)
91
+ .filter(DetectionJob.run_id.isnot(None))
92
+ .order_by(DetectionJob.id.desc())
93
+ .limit(200)
94
+ .all()
95
+ )
96
+ if is_auto_schedule_job(j)
97
+ ]
98
+ filt = DetectionRun.user_id == user.id
99
+ if auto_ids:
100
+ filt = or_(filt, DetectionRun.id.in_(auto_ids))
101
+ return filt
102
+
103
+
104
+ def _load_dataset(db: Session, user: User) -> list[dict]:
105
+ """One query for every accessible run + one grouped query for
106
+ classification counts; every endpoint below aggregates from this in
107
+ Python β€” mirrors the simple query style already used by
108
+ admin_routes.admin_status(). Runs with a blank zone/village are bucketed
109
+ under "Unassigned" so totals reconcile.
110
+ """
111
+ runs = (
112
+ db.query(DetectionRun)
113
+ .filter(_accessible_run_filter(db, user))
114
+ .order_by(DetectionRun.created_at.asc())
115
+ .all()
116
+ )
117
+ counts = bulk_classification_counts(db)
118
+ # Same role resolution as dda_auth.get_user_role (raw User.role string,
119
+ # falling back to "analyst") but bulk-loaded to avoid a query per run.
120
+ roles_by_user = {uid: (role or DEFAULT_ROLE) for uid, role in db.query(User.id, User.role).all()}
121
+ rows = []
122
+ for run in runs:
123
+ c = counts.get(run.id, {})
124
+ classified = c.get("confirmed", 0) + c.get("false_positive", 0) + c.get("submitted", 0)
125
+ unclassified = max(0, (run.regions_count or 0) - classified)
126
+ rows.append({
127
+ "run": run,
128
+ "zone": (run.zone or "").strip() or UNASSIGNED,
129
+ "village": (run.village or "").strip() or UNASSIGNED,
130
+ "role": roles_by_user.get(run.user_id, DEFAULT_ROLE),
131
+ "confirmed": c.get("confirmed", 0),
132
+ "falsePositive": c.get("false_positive", 0),
133
+ "submitted": c.get("submitted", 0),
134
+ "unclassified": unclassified,
135
+ })
136
+ return rows
137
+
138
+
139
+ @router.get("/dashboard/summary")
140
+ def dashboard_summary(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
141
+ _require_dda()
142
+ rows = _load_dataset(db, user)
143
+
144
+ total_detected = sum((r["run"].regions_count or 0) for r in rows)
145
+ pct_values = [r["run"].change_percentage for r in rows if r["run"].change_percentage is not None]
146
+ zones = {r["zone"] for r in rows}
147
+ villages = {(r["zone"], r["village"]) for r in rows}
148
+
149
+ return {
150
+ "totalRuns": len(rows),
151
+ "totalDetectedChanges": total_detected,
152
+ "totalUnclassified": sum(r["unclassified"] for r in rows),
153
+ "totalConfirmed": sum(r["confirmed"] for r in rows),
154
+ "totalFalsePositive": sum(r["falsePositive"] for r in rows),
155
+ "totalSubmitted": sum(r["submitted"] for r in rows),
156
+ "avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
157
+ "totalRegions": len(zones),
158
+ "totalVillages": len(villages),
159
+ "jobsQueued": db.query(DetectionJob).filter(DetectionJob.status == "queued", DetectionJob.created_by == user.id).count(),
160
+ "jobsRunning": db.query(DetectionJob).filter(DetectionJob.status == "running", DetectionJob.created_by == user.id).count(),
161
+ "latestRun": _run_summary(rows[-1]) if rows else None,
162
+ }
163
+
164
+
165
+ @router.get("/dashboard/regions")
166
+ def dashboard_regions(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
167
+ _require_dda()
168
+ rows = _load_dataset(db, user)
169
+ by_zone: dict[str, list] = defaultdict(list)
170
+ for r in rows:
171
+ by_zone[r["zone"]].append(r)
172
+
173
+ out = []
174
+ for zone, items in by_zone.items():
175
+ pct_values = [i["run"].change_percentage for i in items if i["run"].change_percentage is not None]
176
+ out.append({
177
+ "zone": zone,
178
+ "runsCount": len(items),
179
+ "detectedChanges": sum((i["run"].regions_count or 0) for i in items),
180
+ "unclassified": sum(i["unclassified"] for i in items),
181
+ "confirmed": sum(i["confirmed"] for i in items),
182
+ "falsePositive": sum(i["falsePositive"] for i in items),
183
+ "avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
184
+ "villagesCount": len({i["village"] for i in items}),
185
+ "lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
186
+ })
187
+ out.sort(key=lambda x: x["runsCount"], reverse=True)
188
+ return {"regions": out}
189
+
190
+
191
+ @router.get("/dashboard/roles")
192
+ def dashboard_roles(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
193
+ """Detection activity grouped by the role of the user who ran it."""
194
+ _require_dda()
195
+ rows = _load_dataset(db, user)
196
+ by_role: dict[str, list] = defaultdict(list)
197
+ for r in rows:
198
+ by_role[r["role"]].append(r)
199
+
200
+ def _rank(role: str) -> int:
201
+ return ROLE_ORDER.index(role) if role in ROLE_ORDER else len(ROLE_ORDER)
202
+
203
+ out = []
204
+ for role, items in by_role.items():
205
+ pct_values = [i["run"].change_percentage for i in items if i["run"].change_percentage is not None]
206
+ out.append({
207
+ "role": role,
208
+ "runsCount": len(items),
209
+ "detectedChanges": sum((i["run"].regions_count or 0) for i in items),
210
+ "unclassified": sum(i["unclassified"] for i in items),
211
+ "confirmed": sum(i["confirmed"] for i in items),
212
+ "falsePositive": sum(i["falsePositive"] for i in items),
213
+ "avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
214
+ "usersCount": len({i["run"].user_id for i in items}),
215
+ "lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
216
+ })
217
+ out.sort(key=lambda x: (_rank(x["role"]), x["role"]))
218
+ return {"roles": out}
219
+
220
+
221
+ @router.get("/dashboard/villages")
222
+ def dashboard_villages(
223
+ zone: Optional[str] = Query(None),
224
+ db: Session = Depends(get_db),
225
+ user: User = Depends(current_dda_user),
226
+ ):
227
+ _require_dda()
228
+ rows = _load_dataset(db, user)
229
+ if zone:
230
+ rows = [r for r in rows if r["zone"] == zone]
231
+ by_village: dict[tuple, list] = defaultdict(list)
232
+ for r in rows:
233
+ by_village[(r["zone"], r["village"])].append(r)
234
+
235
+ out = []
236
+ for (z, v), items in by_village.items():
237
+ pct_values = [i["run"].change_percentage for i in items if i["run"].change_percentage is not None]
238
+ out.append({
239
+ "zone": z,
240
+ "village": v,
241
+ "runsCount": len(items),
242
+ "detectedChanges": sum((i["run"].regions_count or 0) for i in items),
243
+ "unclassified": sum(i["unclassified"] for i in items),
244
+ "confirmed": sum(i["confirmed"] for i in items),
245
+ "falsePositive": sum(i["falsePositive"] for i in items),
246
+ "avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
247
+ "lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
248
+ })
249
+ out.sort(key=lambda x: x["runsCount"], reverse=True)
250
+ return {"villages": out}
251
+
252
+
253
+ @router.get("/dashboard/trend")
254
+ def dashboard_trend(
255
+ months: int = Query(6, ge=1, le=24),
256
+ db: Session = Depends(get_db),
257
+ user: User = Depends(current_dda_user),
258
+ ):
259
+ _require_dda()
260
+ rows = _load_dataset(db, user)
261
+
262
+ now = datetime.now(timezone.utc)
263
+ keys = []
264
+ y, m = now.year, now.month
265
+ for _ in range(months):
266
+ keys.append((y, m))
267
+ m -= 1
268
+ if m == 0:
269
+ m, y = 12, y - 1
270
+ keys.reverse()
271
+
272
+ counts = {k: {"runsCount": 0, "detectedChanges": 0} for k in keys}
273
+ for r in rows:
274
+ dt = r["run"].created_at
275
+ if dt is None:
276
+ continue
277
+ if dt.tzinfo is None:
278
+ dt = dt.replace(tzinfo=timezone.utc)
279
+ key = (dt.year, dt.month)
280
+ if key in counts:
281
+ counts[key]["runsCount"] += 1
282
+ counts[key]["detectedChanges"] += (r["run"].regions_count or 0)
283
+
284
+ buckets = [
285
+ {
286
+ "period": f"{y}-{m:02d}",
287
+ "label": f"{MONTH_NAMES[m]} {y}",
288
+ "runsCount": counts[(y, m)]["runsCount"],
289
+ "detectedChanges": counts[(y, m)]["detectedChanges"],
290
+ }
291
+ for (y, m) in keys
292
+ ]
293
+ return {"buckets": buckets}
294
+
295
+
296
+ @router.get("/dashboard/location")
297
+ def dashboard_location(
298
+ zone: str = Query(...),
299
+ village: str = Query(...),
300
+ db: Session = Depends(get_db),
301
+ user: User = Depends(current_dda_user),
302
+ ):
303
+ _require_dda()
304
+ rows = _load_dataset(db, user)
305
+ items = [r for r in rows if r["zone"] == zone and r["village"] == village]
306
+ items.sort(key=lambda r: r["run"].created_at)
307
+
308
+ history = [_run_summary(i) for i in items]
309
+ latest = history[-1] if history else None
310
+ previous = history[-2] if len(history) > 1 else None
311
+
312
+ comparison = None
313
+ if latest and previous:
314
+ comparison = {
315
+ "changePercentageDelta": (
316
+ round(latest["changePercentage"] - previous["changePercentage"], 2)
317
+ if latest["changePercentage"] is not None and previous["changePercentage"] is not None
318
+ else None
319
+ ),
320
+ "regionsCountDelta": latest["regionsCount"] - previous["regionsCount"],
321
+ }
322
+
323
+ return {
324
+ "zone": zone,
325
+ "village": village,
326
+ "runsCount": len(items),
327
+ "latest": latest,
328
+ "previous": previous,
329
+ "comparison": comparison,
330
+ "history": list(reversed(history)),
331
+ }
332
+
333
+
334
+ @router.get("/dashboard/recent-reports")
335
+ def dashboard_recent_reports(
336
+ limit: int = Query(8, ge=1, le=50),
337
+ db: Session = Depends(get_db),
338
+ user: User = Depends(current_dda_user),
339
+ ):
340
+ _require_dda()
341
+ rows = _load_dataset(db, user)
342
+ rows.sort(key=lambda r: r["run"].created_at, reverse=True)
343
+ return {"reports": [_run_summary(r) for r in rows[:limit]]}
app/dda/dda_auth.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vedangsoft session users and RBAC (Phase 7)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from typing import Optional
6
+
7
+ from fastapi import Depends, HTTPException, Request
8
+ from sqlalchemy.orm import Session
9
+
10
+ from ..auth import get_user_from_token
11
+ from ..database import get_db
12
+ from ..models import User
13
+
14
+ COOKIE_NAME = "satellite_token"
15
+
16
+ ROLE_RANK = {
17
+ "viewer": 0,
18
+ "uploader": 1,
19
+ "analyst": 2,
20
+ "admin": 3,
21
+ }
22
+
23
+ TRAINING_EXPORT_KEY = os.environ.get("DDA_TRAINING_EXPORT_KEY", "").strip()
24
+
25
+
26
+ def get_user_role(db: Session, user: User) -> str:
27
+ role = getattr(user, "role", None)
28
+ if role:
29
+ return str(role)
30
+ try:
31
+ from sqlalchemy import text as sa_text
32
+ row = db.execute(
33
+ sa_text("SELECT role FROM users WHERE id = :uid"),
34
+ {"uid": user.id},
35
+ ).fetchone()
36
+ if row and row[0]:
37
+ return str(row[0])
38
+ except Exception:
39
+ pass
40
+ return "analyst"
41
+
42
+
43
+ def get_dda_user(request: Request, db: Session) -> User:
44
+ """Resolve the logged-in user from a Bearer token or the auth cookie.
45
+
46
+ Login is required β€” no anonymous per-browser session or shared-guest
47
+ fallback anymore (removed; see git history for the prior behavior).
48
+ """
49
+ auth_header = request.headers.get("authorization", "")
50
+ if auth_header.lower().startswith("bearer "):
51
+ token = auth_header.split(" ", 1)[1].strip()
52
+ user = get_user_from_token(token, db)
53
+ if user:
54
+ return user
55
+
56
+ token = request.cookies.get(COOKIE_NAME)
57
+ if token:
58
+ user = get_user_from_token(token, db)
59
+ if user and not user.email.startswith("__guest__"):
60
+ return user
61
+
62
+ raise HTTPException(status_code=401, detail="Not authenticated")
63
+
64
+
65
+ def current_dda_user(request: Request, db: Session = Depends(get_db)) -> User:
66
+ return get_dda_user(request, db)
67
+
68
+
69
+ def _role_rank_from_db(db: Session, role_name: str) -> Optional[int]:
70
+ """Rank now lives on the dda_roles table (app/dda/rbac/models.py) so admins
71
+ can add/reorder roles without a code change. Falls back to None (caller
72
+ uses ROLE_RANK) if the table isn't seeded yet or the name isn't found."""
73
+ try:
74
+ from .rbac.models import Role
75
+ row = db.query(Role).filter(Role.name == role_name).first()
76
+ return row.rank if row else None
77
+ except Exception:
78
+ return None
79
+
80
+
81
+ def require_min_role(user: User, db: Session, minimum: str) -> None:
82
+ role = get_user_role(db, user)
83
+ need = _role_rank_from_db(db, minimum)
84
+ if need is None:
85
+ need = ROLE_RANK.get(minimum, 99)
86
+ have = _role_rank_from_db(db, role)
87
+ if have is None:
88
+ have = ROLE_RANK.get(role, 0)
89
+ if have < need:
90
+ raise HTTPException(
91
+ status_code=403,
92
+ detail=f"Role '{role}' cannot perform this action (requires {minimum} or higher).",
93
+ )
94
+
95
+
96
+ def require_admin_or_key(request: Request, user: User, db: Session) -> None:
97
+ key = request.headers.get("x-dda-training-key", "").strip()
98
+ if TRAINING_EXPORT_KEY and key == TRAINING_EXPORT_KEY:
99
+ return
100
+ require_min_role(user, db, "admin")
101
+
102
+
103
+ def seed_dda_admin(db: Session) -> None:
104
+ """Optional admin account from env (dev/UAT)."""
105
+ email = os.environ.get("DDA_ADMIN_EMAIL", "").strip()
106
+ password = os.environ.get("DDA_ADMIN_PASSWORD", "").strip()
107
+ if not email or not password:
108
+ return
109
+ from ..auth import get_password_hash, get_user_by_email
110
+ user = get_user_by_email(db, email)
111
+ if not user:
112
+ from ..auth import normalize_phone
113
+ admin_phone = normalize_phone(os.environ.get("DDA_ADMIN_PHONE", ""))
114
+ user = User(
115
+ email=email,
116
+ hashed_password=get_password_hash(password),
117
+ full_name=os.environ.get("DDA_ADMIN_NAME", "Vedangsoft Admin"),
118
+ phone=admin_phone,
119
+ )
120
+ db.add(user)
121
+ db.commit()
122
+ db.refresh(user)
123
+ try:
124
+ from sqlalchemy import text as sa_text
125
+ db.execute(
126
+ sa_text("UPDATE users SET role = 'admin' WHERE id = :uid"),
127
+ {"uid": user.id},
128
+ )
129
+ db.commit()
130
+ except Exception:
131
+ db.rollback()
app/dda/dept_export.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Department export adapter β€” API or file fallback (FR-08)."""
2
+ from __future__ import annotations
3
+
4
+ import csv
5
+ import io
6
+ import logging
7
+ import os
8
+ from dataclasses import dataclass
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ import requests
12
+
13
+ from ..models import DetectionRun
14
+ from .geo_regions import region_lat_lng
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ DEPT_API_URL = os.environ.get("DEPT_API_URL", "").strip()
19
+ DEPT_API_KEY = os.environ.get("DEPT_API_KEY", "").strip()
20
+
21
+
22
+ @dataclass
23
+ class ExportResult:
24
+ ok: bool
25
+ mode: str # api | file
26
+ message: str
27
+ submitted_count: int = 0
28
+ download_path: Optional[str] = None
29
+ detail: Optional[Dict[str, Any]] = None
30
+
31
+
32
+ def regions_to_csv_rows(run: DetectionRun, regions: List[dict]) -> str:
33
+ """Build CSV string for export."""
34
+ buf = io.StringIO()
35
+ writer = csv.writer(buf)
36
+ writer.writerow([
37
+ "run_id", "run_title", "zone", "village", "region_id",
38
+ "dda_change_type", "internal_type", "confidence", "area_px",
39
+ "latitude", "longitude", "area_sq_m", "review_status", "notes",
40
+ ])
41
+ for r in regions:
42
+ lat, lng = region_lat_lng(r)
43
+ writer.writerow([
44
+ run.id,
45
+ run.title,
46
+ run.zone or "",
47
+ run.village or "",
48
+ r.get("id", ""),
49
+ r.get("ddaChangeType") or "",
50
+ r.get("internalObjectType") or r.get("objectType") or "",
51
+ f'{(r.get("confidence") or 0):.4f}',
52
+ r.get("area", ""),
53
+ lat if lat is not None else "",
54
+ lng if lng is not None else "",
55
+ r.get("areaSqM") if r.get("areaSqM") is not None else "",
56
+ r.get("reviewStatus") or "pending",
57
+ r.get("reviewNotes") or "",
58
+ ])
59
+ return buf.getvalue()
60
+
61
+
62
+ class FileExporter:
63
+ """Fallback β€” client downloads CSV via API."""
64
+
65
+ def submit(self, run: DetectionRun, regions: List[dict]) -> ExportResult:
66
+ if not regions:
67
+ return ExportResult(
68
+ ok=False,
69
+ mode="file",
70
+ message="No confirmed regions to export.",
71
+ submitted_count=0,
72
+ )
73
+ return ExportResult(
74
+ ok=True,
75
+ mode="file",
76
+ message=f"{len(regions)} confirmed region(s) ready for CSV download.",
77
+ submitted_count=len(regions),
78
+ download_path=f"/api/dda/reports/{run.id}/export.csv?confirmed=1",
79
+ )
80
+
81
+
82
+ class ApiExporter:
83
+ """POST confirmed changes to departmental API."""
84
+
85
+ def __init__(self, url: str, api_key: str = ""):
86
+ self.url = url.rstrip("/")
87
+ self.api_key = api_key
88
+
89
+ def submit(self, run: DetectionRun, regions: List[dict]) -> ExportResult:
90
+ if not regions:
91
+ return ExportResult(
92
+ ok=False,
93
+ mode="api",
94
+ message="No confirmed regions to submit.",
95
+ )
96
+ payload = {
97
+ "runId": run.id,
98
+ "title": run.title,
99
+ "zone": run.zone or "",
100
+ "village": run.village or "",
101
+ "changePercentage": run.change_percentage,
102
+ "regions": [
103
+ {
104
+ "id": r.get("id"),
105
+ "ddaChangeType": r.get("ddaChangeType"),
106
+ "objectType": r.get("objectType"),
107
+ "confidence": r.get("confidence"),
108
+ "area": r.get("area"),
109
+ "latitude": r.get("latitude") or (r.get("latLng") or {}).get("lat"),
110
+ "longitude": r.get("longitude") or (r.get("latLng") or {}).get("lng"),
111
+ "areaSqM": r.get("areaSqM"),
112
+ }
113
+ for r in regions
114
+ ],
115
+ }
116
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
117
+ if self.api_key:
118
+ headers["Authorization"] = f"Bearer {self.api_key}"
119
+ try:
120
+ resp = requests.post(self.url, json=payload, headers=headers, timeout=60)
121
+ if 200 <= resp.status_code < 300:
122
+ detail = None
123
+ try:
124
+ detail = resp.json()
125
+ except Exception:
126
+ detail = {"body": resp.text[:500]}
127
+ return ExportResult(
128
+ ok=True,
129
+ mode="api",
130
+ message=f"Submitted {len(regions)} confirmed region(s) to departmental system.",
131
+ submitted_count=len(regions),
132
+ detail=detail,
133
+ )
134
+ return ExportResult(
135
+ ok=False,
136
+ mode="api",
137
+ message=f"Department API returned {resp.status_code}: {resp.text[:200]}",
138
+ )
139
+ except Exception as exc:
140
+ logger.exception("Department API submit failed")
141
+ return ExportResult(
142
+ ok=False,
143
+ mode="api",
144
+ message=f"Department API error: {exc}",
145
+ )
146
+
147
+
148
+ def get_exporter() -> FileExporter | ApiExporter:
149
+ if DEPT_API_URL:
150
+ return ApiExporter(DEPT_API_URL, DEPT_API_KEY)
151
+ return FileExporter()
app/dda/detect_service.py ADDED
@@ -0,0 +1,490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run change detection and persist results (shared by upload + library paths)."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import json
6
+ import logging
7
+ import uuid
8
+ from datetime import timezone
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from PIL import Image
13
+ from sqlalchemy.orm import Session
14
+
15
+ from ..auth import get_or_create_guest_user
16
+ from ..database import DATA_DIR
17
+ from ..models import DetectionRun
18
+ from ..notifier import send_notification
19
+ from .geo_regions import enrich_regions_geo, resolve_geo_context
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ OVERLAYS_DIR = DATA_DIR / "overlays"
24
+ THUMB_MAX_SIZE = 200
25
+
26
+
27
+ MAX_POLYGON_VERTICES = 100
28
+
29
+
30
+ def _serialize_polygon(raw) -> Optional[list]:
31
+ """Normalize an engine polygon to ``[[x, y], ...]`` ints, or None.
32
+
33
+ The engine emits an outer ring in detection-image pixels. Defensively caps
34
+ the vertex count (the extractor also limits, but region JSON is stored per
35
+ run and a runaway contour would bloat every report that loads it) by
36
+ keeping evenly-spaced vertices so the footprint's shape survives.
37
+ """
38
+ if not raw:
39
+ return None
40
+ try:
41
+ pts = [(int(round(float(p[0]))), int(round(float(p[1])))) for p in raw]
42
+ except (TypeError, ValueError, IndexError):
43
+ logger.warning("Ignoring malformed polygon on region")
44
+ return None
45
+ if len(pts) < 3:
46
+ return None
47
+ if len(pts) > MAX_POLYGON_VERTICES:
48
+ step = len(pts) / float(MAX_POLYGON_VERTICES)
49
+ pts = [pts[min(len(pts) - 1, int(i * step))] for i in range(MAX_POLYGON_VERTICES)]
50
+ return [[x, y] for x, y in pts]
51
+
52
+
53
+ def _serialize_regions(change_regions) -> list:
54
+ out = []
55
+ for r in change_regions:
56
+ entry = {
57
+ "id": int(r["id"]),
58
+ "area": int(r["area"]),
59
+ "center": {"x": round(float(r["center"][0])), "y": round(float(r["center"][1]))},
60
+ "bbox": {
61
+ "x": int(r["bbox"][0]),
62
+ "y": int(r["bbox"][1]),
63
+ "w": int(r["bbox"][2]),
64
+ "h": int(r["bbox"][3]),
65
+ },
66
+ "objectType": str(r["object_type"]),
67
+ "confidence": float(r["confidence"]),
68
+ "severity": r.get("severity", "minor"),
69
+ "subType": r.get("sub_type"),
70
+ "subTypeConfidence": float(r["sub_type_confidence"])
71
+ if r.get("sub_type_confidence") is not None
72
+ else None,
73
+ "estimatedStories": r.get("estimated_stories"),
74
+ "estimatedHeightM": float(r["estimated_height_m"])
75
+ if r.get("estimated_height_m") is not None
76
+ else None,
77
+ "constructionStage": r.get("construction_stage"),
78
+ }
79
+ # Polygon is optional: regions without one serialize exactly as before,
80
+ # and every consumer falls back to bbox.
81
+ polygon = _serialize_polygon(r.get("polygon"))
82
+ if polygon:
83
+ entry["polygon"] = polygon
84
+ out.append(entry)
85
+ return out
86
+
87
+
88
+ def _filter_weak_other_regions(regions: list) -> list:
89
+ """Optionally mark tiny low-conf Other blobs β€” never drop report rows.
90
+
91
+ History: a hard conf floor (DDA_OTHER_MIN_CONF=0.5) deleted 13/15 Grid_54
92
+ regions from ``regions_json`` while the overlay still drew them, so the
93
+ report disagreed with detection. Regions must match the engine list; ops
94
+ can hide noise in review instead of deleting at save time.
95
+
96
+ Set ``DDA_OTHER_MIN_CONF>0`` and ``DDA_SUPPRESS_WEAK_OTHER=1`` to flag
97
+ (not remove) tiny Other blobs with ``suppressed=true``.
98
+ """
99
+ import os
100
+ raw = os.environ.get("DDA_OTHER_MIN_CONF", "0").strip()
101
+ try:
102
+ floor = float(raw)
103
+ except ValueError:
104
+ floor = 0.0
105
+ try:
106
+ min_area = int(float(os.environ.get("DDA_OTHER_MIN_AREA", "1200")))
107
+ except ValueError:
108
+ min_area = 1200
109
+ suppress = os.environ.get("DDA_SUPPRESS_WEAK_OTHER", "").strip().lower() in (
110
+ "1", "true", "yes", "on",
111
+ )
112
+ if floor <= 0:
113
+ for r in regions:
114
+ r.setdefault("suppressed", False)
115
+ return regions
116
+
117
+ kept = []
118
+ flagged = 0
119
+ for r in regions:
120
+ dda = (r.get("ddaChangeType") or "").strip()
121
+ conf = float(r.get("confidence") or 0.0)
122
+ area = int(r.get("area") or 0)
123
+ weak = dda == "Other" and conf < floor and area < min_area
124
+ r = dict(r)
125
+ r["suppressed"] = bool(weak and suppress)
126
+ if weak and suppress:
127
+ flagged += 1
128
+ # Never drop β€” always keep in report JSON
129
+ kept.append(r)
130
+ if flagged:
131
+ logger.info("Flagged %d weak Other regions as suppressed (still in report)", flagged)
132
+ return kept
133
+
134
+
135
+ def _isoformat_ist(dt):
136
+ from datetime import timedelta
137
+ if dt is None:
138
+ return None
139
+ _IST = timezone(timedelta(hours=5, minutes=30))
140
+ if dt.tzinfo is None:
141
+ from datetime import timezone as tz
142
+ dt = dt.replace(tzinfo=tz.utc)
143
+ return dt.astimezone(_IST).isoformat()
144
+
145
+
146
+ def _alignment_warning(before_pil, after_pil, before_tif, after_tif):
147
+ """Operator-facing warning when a pair is identical or poorly aligned.
148
+
149
+ Runs the pre-detection guard (``app.dda.pair_align``) to catch the two
150
+ silent-failure modes that otherwise yield an empty or garbage change mask
151
+ with no explanation: (1) the same image uploaded twice, and (2) raw drone
152
+ frames from different viewpoints that never register onto a common grid.
153
+ Returns ``None`` when the pair looks fine or the check cannot run, so this
154
+ can never block or crash a detection job.
155
+ """
156
+ try:
157
+ import numpy as np
158
+
159
+ from .pair_align import are_identical, assess_alignment, geo_align_pair
160
+
161
+ b = np.asarray(before_pil.convert("RGB"))
162
+ a = np.asarray(after_pil.convert("RGB"))
163
+ if b.shape == a.shape and are_identical(b, a):
164
+ return ("Before and after are the same image (identical pixels). There is "
165
+ "no change to detect - please check that two different dates were "
166
+ "selected.")
167
+ # The NCC misalignment check only makes sense on georeferenced pairs, where
168
+ # both can be placed on a common grid. For plain uploads the engine's own
169
+ # SIFT/ECC registration handles alignment, so comparing the raw (pre-
170
+ # registration) frames here would raise false alarms.
171
+ if before_tif and after_tif:
172
+ aligned = geo_align_pair(Path(before_tif), Path(after_tif))
173
+ if aligned is not None:
174
+ bb, aa, _overlap = aligned
175
+ status, ncc = assess_alignment(bb, aa)
176
+ if status == "low_quality":
177
+ return (f"The before/after images do not align well (NCC={ncc:.2f}). "
178
+ "They look like raw drone frames from different viewpoints "
179
+ "rather than orthomosaics on a common grid, so detected "
180
+ "changes may be unreliable. For accurate results, export both "
181
+ "dates as north-up orthomosaics on the same grid.")
182
+ return None
183
+ except Exception as exc:
184
+ logger.warning("Alignment guard skipped: %s", exc)
185
+ return None
186
+
187
+
188
+ def run_detection_and_save(
189
+ db: Session,
190
+ before_pil: Image.Image,
191
+ after_pil: Image.Image,
192
+ *,
193
+ method: str = "AI-Based Deep Learning",
194
+ title: str = "Untitled run",
195
+ zone: str = "",
196
+ village: str = "",
197
+ enable_registration: bool = True,
198
+ enable_normalization: bool = True,
199
+ detection_sensitivity: float = 0.5,
200
+ min_region_area: Optional[int] = None,
201
+ notify_email: Optional[str] = None,
202
+ max_size: Optional[int] = None,
203
+ geo_bounds_path: Optional[Path] = None,
204
+ comparison_file: Optional[Path] = None,
205
+ base_path: str = "",
206
+ comparison_path: str = "",
207
+ user_id: Optional[int] = None,
208
+ job_id: Optional[int] = None,
209
+ gsd_debug: Optional[dict] = None,
210
+ shape_mode: str = "polygon",
211
+ preflight_warnings: Optional[list] = None,
212
+ ) -> dict:
213
+ from ..detection_engine import run_detection
214
+ from .job_progress import update_job_progress
215
+
216
+ def _report(pct: int, stage: str) -> None:
217
+ if job_id is not None:
218
+ update_job_progress(job_id, pct, stage)
219
+
220
+ if user_id:
221
+ from ..auth import get_user_by_id
222
+ user = get_user_by_id(db, user_id)
223
+ if not user:
224
+ user = get_or_create_guest_user(db)
225
+ else:
226
+ user = get_or_create_guest_user(db)
227
+ db.commit()
228
+ detection_sensitivity = max(0.0, min(1.0, float(detection_sensitivity)))
229
+ if min_region_area is not None:
230
+ min_region_area = int(max(50, min(10000, min_region_area)))
231
+
232
+ def _on_engine_progress(engine_pct: int, stage: str) -> None:
233
+ # Map engine 0–100% into job 15–78%
234
+ job_pct = 15 + int(engine_pct * 0.63)
235
+ _report(job_pct, stage)
236
+
237
+ _report(15, "Running detection")
238
+
239
+ def _geotiff_path(p: Optional[Path]) -> Optional[str]:
240
+ if p is not None and str(p).lower().endswith((".tif", ".tiff")):
241
+ return str(p)
242
+ return None
243
+
244
+ change_mask, result_image, stats, change_regions = run_detection(
245
+ before_pil,
246
+ after_pil,
247
+ method=method,
248
+ enable_registration=enable_registration,
249
+ enable_normalization=enable_normalization,
250
+ detection_sensitivity=detection_sensitivity,
251
+ min_region_area=min_region_area,
252
+ max_size=max_size,
253
+ on_progress=_on_engine_progress,
254
+ before_path=_geotiff_path(geo_bounds_path),
255
+ after_path=_geotiff_path(comparison_file),
256
+ shape_mode=shape_mode,
257
+ )
258
+ # Record the choice so the viewer can match the baked overlay's style.
259
+ if isinstance(stats.get("params"), dict):
260
+ stats["params"]["shape_mode"] = shape_mode
261
+
262
+ # Pre-detection guard: surface identical / poorly-aligned pairs to the operator
263
+ # instead of returning a silent empty or garbage mask (the UI already renders
264
+ # stats["alignment_warning"] as a banner). Never overrides an existing warning.
265
+ if not stats.get("alignment_warning"):
266
+ align_warn = _alignment_warning(
267
+ before_pil, after_pil,
268
+ _geotiff_path(geo_bounds_path), _geotiff_path(comparison_file),
269
+ )
270
+ if align_warn:
271
+ stats["alignment_warning"] = align_warn
272
+ logger.info("Alignment guard flagged pair: %s", align_warn)
273
+
274
+ if gsd_debug and isinstance(stats.get("threshold_debug"), dict):
275
+ stats["threshold_debug"]["gsdHarmonization"] = gsd_debug
276
+
277
+ if preflight_warnings:
278
+ stats["preflightWarnings"] = list(preflight_warnings)
279
+
280
+ _report(80, "Saving results")
281
+
282
+ base_name = f"{user.id}_{uuid.uuid4().hex}"
283
+ overlay_filename = base_name + ".png"
284
+ overlay_path = OVERLAYS_DIR / overlay_filename
285
+ overlay_path.parent.mkdir(parents=True, exist_ok=True)
286
+ Image.fromarray(result_image).save(overlay_path)
287
+ relative_overlay = f"overlays/{overlay_filename}"
288
+
289
+ relative_prob = ""
290
+ try:
291
+ from ..detection_config import get_save_prob_map
292
+ score_map = stats.pop("_score_map", None)
293
+ if get_save_prob_map() and score_map is not None:
294
+ import numpy as _np
295
+ prob_u8 = _np.clip(score_map * 255.0, 0, 255).astype("uint8")
296
+ prob_img = Image.fromarray(prob_u8)
297
+ prob_img.thumbnail((1024, 1024), Image.Resampling.LANCZOS)
298
+ prob_file = OVERLAYS_DIR / f"{base_name}_prob.png"
299
+ prob_img.save(prob_file)
300
+ relative_prob = f"overlays/{base_name}_prob.png"
301
+ except Exception as exc:
302
+ logger.warning("Probability map export failed: %s", exc)
303
+
304
+ relative_before_full = ""
305
+ relative_before_thumb = ""
306
+ relative_after_thumb = ""
307
+ relative_after_full = ""
308
+ before_for_slider = None
309
+ try:
310
+ # Prefer the detection working grid (registered + same resolution as
311
+ # region polygons). Falling back to a fresh preprocess only if the
312
+ # engine did not provide slider frames (keeps older callers safe).
313
+ slider_before = stats.pop("_slider_before", None)
314
+ slider_after = stats.pop("_slider_after", None)
315
+ if slider_before is not None and slider_after is not None:
316
+ before_for_slider = Image.fromarray(slider_before)
317
+ after_for_slider = Image.fromarray(slider_after)
318
+ else:
319
+ from ..detection_engine import preprocess_image, get_detection_max_size
320
+ before_for_slider = Image.fromarray(
321
+ preprocess_image(before_pil, max_size=max_size or get_detection_max_size())
322
+ )
323
+ after_for_slider = Image.fromarray(
324
+ preprocess_image(after_pil, max_size=max_size or get_detection_max_size())
325
+ )
326
+ before_full_file = OVERLAYS_DIR / f"{base_name}_before.png"
327
+ before_for_slider.save(before_full_file)
328
+ relative_before_full = f"overlays/{base_name}_before.png"
329
+ after_full_file = OVERLAYS_DIR / f"{base_name}_after.png"
330
+ after_for_slider.save(after_full_file)
331
+ relative_after_full = f"overlays/{base_name}_after.png"
332
+ before_thumb_pil = before_pil.copy()
333
+ before_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS)
334
+ before_thumb_pil.save(OVERLAYS_DIR / f"{base_name}_before_thumb.png")
335
+ after_thumb_pil = after_pil.copy()
336
+ after_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS)
337
+ after_thumb_pil.save(OVERLAYS_DIR / f"{base_name}_after_thumb.png")
338
+ relative_before_thumb = f"overlays/{base_name}_before_thumb.png"
339
+ relative_after_thumb = f"overlays/{base_name}_after_thumb.png"
340
+ except Exception as exc:
341
+ logger.warning("Failed to save thumbnails: %s", exc)
342
+ # Ensure det size fallback still has a slider image reference
343
+ from ..detection_engine import preprocess_image, get_detection_max_size
344
+ before_for_slider = Image.fromarray(
345
+ preprocess_image(before_pil, max_size=max_size or get_detection_max_size())
346
+ )
347
+
348
+ regions_serializable = _serialize_regions(change_regions)
349
+ det_w = int(stats.get("image_width") or 0)
350
+ det_h = int(stats.get("image_height") or 0)
351
+ if det_w <= 0 or det_h <= 0:
352
+ if before_for_slider is not None:
353
+ det_w, det_h = before_for_slider.size
354
+ else:
355
+ det_w, det_h = before_pil.size
356
+
357
+ geo_ctx = None
358
+ bounds = None
359
+ if geo_bounds_path:
360
+ rel_path = (base_path or "").replace("\\", "/").strip().lstrip("/")
361
+ if not rel_path:
362
+ try:
363
+ from .config import get_storage_root
364
+ rel_path = geo_bounds_path.resolve().relative_to(get_storage_root().resolve()).as_posix()
365
+ except Exception:
366
+ rel_path = geo_bounds_path.name
367
+ geo_ctx = resolve_geo_context(db, rel_path, geo_bounds_path)
368
+ bounds = geo_ctx.bounds
369
+
370
+ regions_serializable = enrich_regions_geo(
371
+ regions_serializable,
372
+ img_width=det_w,
373
+ img_height=det_h,
374
+ bounds=bounds,
375
+ geo=geo_ctx,
376
+ )
377
+ # Ops filter: PDF report (Grid_54 vs H43X2E1) showed many low-conf "Other"
378
+ # blobs at thr~0.2 β€” quarantine weak unclassified FPs without raising DL thr.
379
+ before_n = len(regions_serializable)
380
+ regions_serializable = _filter_weak_other_regions(regions_serializable)
381
+ dropped_other = before_n - len(regions_serializable)
382
+ if dropped_other:
383
+ logger.info("Filtered %d low-confidence Other regions", dropped_other)
384
+ regions_with_coords = sum(1 for r in regions_serializable if r.get("latLng"))
385
+ geo_debug = {
386
+ "source": geo_ctx.source if geo_ctx else "none",
387
+ "crs": str(geo_ctx.georef.crs) if (geo_ctx and geo_ctx.georef and geo_ctx.georef.crs) else "",
388
+ "bounds": list(bounds) if bounds else None,
389
+ "georefWidth": geo_ctx.georef_width if geo_ctx else 0,
390
+ "georefHeight": geo_ctx.georef_height if geo_ctx else 0,
391
+ "detectionWidth": det_w,
392
+ "detectionHeight": det_h,
393
+ "regionsWithCoords": regions_with_coords,
394
+ "regionsTotal": len(regions_serializable),
395
+ }
396
+ logger.info("Geo debug for run: %s", geo_debug)
397
+ total_px = int(stats["total_pixels"])
398
+ changed_px = int(stats["changed_pixels"])
399
+ change_pct = float(stats["change_percentage"])
400
+
401
+ run = DetectionRun(
402
+ user_id=user.id,
403
+ title=title,
404
+ method=method,
405
+ zone=zone,
406
+ village=village,
407
+ total_pixels=total_px,
408
+ changed_pixels=changed_px,
409
+ change_percentage=change_pct,
410
+ regions_count=len(regions_serializable),
411
+ overlay_path=relative_overlay,
412
+ before_full_path=relative_before_full,
413
+ before_thumb_path=relative_before_thumb,
414
+ after_thumb_path=relative_after_thumb,
415
+ after_full_path=relative_after_full,
416
+ regions_json=json.dumps(regions_serializable),
417
+ )
418
+ db.add(run)
419
+ db.commit()
420
+ db.refresh(run)
421
+
422
+ try:
423
+ from .dda_auth import get_user_role
424
+ from .tree.audit_service import log_action
425
+ log_action(
426
+ db, "run_detection", action_by=getattr(user, "email", "") or "",
427
+ role=get_user_role(db, user), module="detect",
428
+ new_value={"run_id": run.id, "method": method, "zone": zone, "village": village,
429
+ "change_percentage": change_pct},
430
+ )
431
+ db.commit()
432
+ except Exception:
433
+ logger.exception("Failed to write audit log for detection run %s", run.id)
434
+
435
+ _report(90, "Preparing report")
436
+ overlay_b64 = base64.b64encode(overlay_path.read_bytes()).decode("utf-8")
437
+ notification_sent = False
438
+ notification_error = None
439
+ if notify_email and notify_email.strip():
440
+ _report(95, "Sending notification")
441
+ from .config import IS_DDA_MODE, get_public_base_url
442
+ report_url = f"{get_public_base_url()}/dda/reports/{run.id}" if IS_DDA_MODE else ""
443
+ notification_sent, notification_error = send_notification(
444
+ recipient=notify_email.strip(),
445
+ title=title,
446
+ method=method,
447
+ zone=zone,
448
+ village=village,
449
+ change_pct=change_pct,
450
+ changed_px=changed_px,
451
+ total_px=total_px,
452
+ regions=regions_serializable,
453
+ report_url=report_url,
454
+ )
455
+
456
+ _report(100, "Complete")
457
+
458
+ return {
459
+ "id": run.id,
460
+ "title": run.title,
461
+ "method": run.method,
462
+ "zone": run.zone or "",
463
+ "village": run.village or "",
464
+ "basePath": base_path,
465
+ "comparisonPath": comparison_path,
466
+ "statistics": {
467
+ "totalPixels": total_px,
468
+ "changedPixels": changed_px,
469
+ "unchangedPixels": int(stats["unchanged_pixels"]),
470
+ "changePercentage": change_pct,
471
+ "thresholdDebug": stats.get("threshold_debug", {}),
472
+ "params": stats.get("params", {}),
473
+ "alignmentWarning": stats.get("alignment_warning"),
474
+ "registrationOk": stats.get("params", {}).get("registration_ok"),
475
+ "preflightWarnings": stats.get("preflightWarnings") or [],
476
+ "geo": geo_debug,
477
+ "probabilityMapUrl": f"/api/overlay/{relative_prob}" if relative_prob else None,
478
+ },
479
+ "regions": regions_serializable,
480
+ "overlayBase64Png": overlay_b64,
481
+ "overlayUrl": f"/api/overlay/{relative_overlay}",
482
+ "beforeFullUrl": f"/api/overlay/{relative_before_full}" if relative_before_full else None,
483
+ "beforeThumbUrl": f"/api/overlay/{relative_before_thumb}" if relative_before_thumb else None,
484
+ "afterThumbUrl": f"/api/overlay/{relative_after_thumb}" if relative_after_thumb else None,
485
+ "afterFullUrl": f"/api/overlay/{relative_after_full}" if relative_after_full else None,
486
+ "notificationSent": notification_sent,
487
+ "notificationError": notification_error,
488
+ "createdAt": _isoformat_ist(run.created_at),
489
+ "detectionMaxSide": max_size,
490
+ }
app/dda/exception_routes.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API for the Exception Log page β€” system errors captured by the global
2
+ FastAPI exception handlers in app/main.py."""
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Optional
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, Query
9
+ from sqlalchemy.orm import Session
10
+
11
+ from ..database import get_db
12
+ from ..models import User
13
+ from .rbac.permissions import require_module_permission
14
+ from .tree.audit_service import isoformat_ist
15
+ from .tree.models import ExceptionLog
16
+
17
+ router = APIRouter()
18
+
19
+
20
+ def _require_dda():
21
+ from .config import IS_DDA_MODE
22
+ if not IS_DDA_MODE:
23
+ raise HTTPException(status_code=404, detail="DDA mode is not enabled")
24
+
25
+
26
+ def _entry_dict(entry: ExceptionLog, *, with_traceback: bool = False) -> dict:
27
+ d = {
28
+ "id": entry.id,
29
+ "timestamp": isoformat_ist(entry.created_at),
30
+ "type": entry.exception_type,
31
+ "message": entry.message or "",
32
+ "path": entry.path or "",
33
+ "method": entry.method or "",
34
+ "statusCode": entry.status_code,
35
+ "handled": bool(entry.handled),
36
+ "userEmail": entry.user_email or "",
37
+ }
38
+ if with_traceback:
39
+ d["traceback"] = entry.traceback or ""
40
+ return d
41
+
42
+
43
+ def _parse_date(value: Optional[str], *, end_of_day: bool = False) -> Optional[datetime]:
44
+ if not value:
45
+ return None
46
+ try:
47
+ dt = datetime.fromisoformat(value)
48
+ except ValueError:
49
+ raise HTTPException(status_code=400, detail=f"Invalid date: {value!r} (expected YYYY-MM-DD)")
50
+ if end_of_day:
51
+ dt = dt.replace(hour=23, minute=59, second=59, microsecond=999999)
52
+ return dt
53
+
54
+
55
+ @router.get("/exception-log")
56
+ def list_exception_log(
57
+ exception_type: str = Query("", alias="type"),
58
+ status_code: Optional[int] = Query(None),
59
+ from_date: Optional[str] = Query(None, alias="from"),
60
+ to_date: Optional[str] = Query(None, alias="to"),
61
+ limit: int = Query(100, ge=1, le=1000),
62
+ offset: int = Query(0, ge=0),
63
+ user: User = Depends(require_module_permission("exception_log", "view")),
64
+ db: Session = Depends(get_db),
65
+ ):
66
+ _require_dda()
67
+ q = db.query(ExceptionLog)
68
+ if exception_type:
69
+ q = q.filter(ExceptionLog.exception_type == exception_type)
70
+ if status_code is not None:
71
+ q = q.filter(ExceptionLog.status_code == status_code)
72
+ start = _parse_date(from_date)
73
+ if start:
74
+ q = q.filter(ExceptionLog.created_at >= start)
75
+ end = _parse_date(to_date, end_of_day=True)
76
+ if end:
77
+ q = q.filter(ExceptionLog.created_at <= end)
78
+
79
+ total = q.count()
80
+ page_rows = q.order_by(ExceptionLog.created_at.desc()).offset(offset).limit(limit).all()
81
+
82
+ # Stat cards summarize the *filtered* result set (matching the reference UI's
83
+ # "(this page)" labels), computed on the full filtered query, not just page_rows.
84
+ filtered = q
85
+ errors_500 = filtered.filter(ExceptionLog.status_code >= 500).count()
86
+ errors_other = total - errors_500
87
+ unhandled = filtered.filter(ExceptionLog.handled.is_(False)).count()
88
+
89
+ return {
90
+ "logs": [_entry_dict(r) for r in page_rows],
91
+ "total": total,
92
+ "limit": limit,
93
+ "offset": offset,
94
+ "stats": {"errors500": errors_500, "errorsOther": errors_other, "unhandled": unhandled},
95
+ }
96
+
97
+
98
+ @router.get("/exception-log/filters")
99
+ def exception_log_filters(
100
+ user: User = Depends(require_module_permission("exception_log", "view")),
101
+ db: Session = Depends(get_db),
102
+ ):
103
+ _require_dda()
104
+ types = [r[0] for r in db.query(ExceptionLog.exception_type).distinct().order_by(ExceptionLog.exception_type) if r[0]]
105
+ codes = sorted({r[0] for r in db.query(ExceptionLog.status_code).distinct() if r[0] is not None})
106
+ return {"types": types, "statusCodes": codes}
107
+
108
+
109
+ @router.get("/exception-log/{entry_id}")
110
+ def get_exception_log_entry(
111
+ entry_id: int,
112
+ user: User = Depends(require_module_permission("exception_log", "view")),
113
+ db: Session = Depends(get_db),
114
+ ):
115
+ _require_dda()
116
+ entry = db.query(ExceptionLog).filter(ExceptionLog.id == entry_id).first()
117
+ if not entry:
118
+ raise HTTPException(status_code=404, detail="Exception log entry not found")
119
+ return _entry_dict(entry, with_traceback=True)
120
+
121
+
122
+ @router.delete("/exception-log/old")
123
+ def clear_old_exception_log(
124
+ days: int = Query(30, ge=1, le=3650),
125
+ user: User = Depends(require_module_permission("exception_log", "delete")),
126
+ db: Session = Depends(get_db),
127
+ ):
128
+ _require_dda()
129
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days)
130
+ deleted = db.query(ExceptionLog).filter(ExceptionLog.created_at < cutoff).delete(synchronize_session=False)
131
+ db.commit()
132
+ return {"ok": True, "deleted": deleted}
app/dda/geo_regions.py ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pixel coordinates Ò†’ WGS84 lat/lng for geo-referenced images (FR-04, FR-06)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ import math
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Tuple
10
+
11
+ from sqlalchemy.orm import Session
12
+
13
+ from .change_type_map import enrich_region_for_dda
14
+ from .geotiff_io import GeorefInfo, inspect_image, pixel_to_geo_wgs84, read_georef
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ BoundsWGS84 = Tuple[float, float, float, float] # west, south, east, north
19
+
20
+
21
+ @dataclass
22
+ class GeoContext:
23
+ bounds: Optional[BoundsWGS84]
24
+ georef: Optional[GeorefInfo]
25
+ georef_width: int
26
+ georef_height: int
27
+ source: str = "none" # embedded | worldfile | manual | linear | none
28
+
29
+
30
+ def _bounds_in_range(b: Optional[BoundsWGS84]) -> bool:
31
+ if not b:
32
+ return False
33
+ w, s, e, n = b
34
+ return abs(w) <= 180 and abs(e) <= 180 and abs(s) <= 90 and abs(n) <= 90
35
+
36
+
37
+ def parse_bounds(bounds: Any) -> Optional[BoundsWGS84]:
38
+ if bounds is None:
39
+ return None
40
+ if isinstance(bounds, (list, tuple)) and len(bounds) == 4:
41
+ return tuple(float(x) for x in bounds)
42
+ if isinstance(bounds, dict):
43
+ try:
44
+ return (
45
+ float(bounds["west"]),
46
+ float(bounds["south"]),
47
+ float(bounds["east"]),
48
+ float(bounds["north"]),
49
+ )
50
+ except (KeyError, TypeError, ValueError):
51
+ return None
52
+ if isinstance(bounds, str) and bounds.strip():
53
+ try:
54
+ data = json.loads(bounds)
55
+ return parse_bounds(data)
56
+ except json.JSONDecodeError:
57
+ parts = [float(x.strip()) for x in bounds.replace("[", "").replace("]", "").split(",")]
58
+ if len(parts) == 4:
59
+ return tuple(parts)
60
+ return None
61
+
62
+
63
+ def bounds_from_image_path(path: Path) -> Optional[BoundsWGS84]:
64
+ try:
65
+ meta = inspect_image(path)
66
+ return meta.bounds_wgs84
67
+ except Exception as exc:
68
+ logger.warning("Could not read bounds for %s: %s", path.name, exc)
69
+ return None
70
+
71
+
72
+ def resolve_geo_context(
73
+ db: Session,
74
+ base_path: str,
75
+ base_file: Path,
76
+ ) -> GeoContext:
77
+ """Resolve bounds and affine georef for detection geo enrichment.
78
+
79
+ Priority: embedded/world-file affine georef Ò†’ DB manual bounds Ò†’ linear
80
+ bounds inferred from the image sidecars. Tracks which source was used.
81
+ """
82
+ georef = read_georef(base_file)
83
+ bounds = georef.bounds_wgs84 if georef else None
84
+ georef_width = georef.width if georef else 0
85
+ georef_height = georef.height if georef else 0
86
+ source = getattr(georef, "source", "embedded") if georef else "none"
87
+
88
+ if not bounds:
89
+ from .tree.image_service import get_image_by_file_path
90
+
91
+ rel = base_path.replace("\\", "/").strip().lstrip("/")
92
+ img = get_image_by_file_path(db, rel)
93
+ if img and img.bounds_json:
94
+ db_bounds = parse_bounds(img.bounds_json)
95
+ if db_bounds:
96
+ bounds = db_bounds
97
+ source = "manual"
98
+
99
+ if not bounds:
100
+ bounds = bounds_from_image_path(base_file)
101
+ if bounds:
102
+ source = "linear"
103
+
104
+ if (georef_width <= 0 or georef_height <= 0) and bounds:
105
+ meta = inspect_image(base_file)
106
+ georef_width = meta.width or georef_width
107
+ georef_height = meta.height or georef_height
108
+
109
+ return GeoContext(
110
+ bounds=bounds,
111
+ georef=georef,
112
+ georef_width=georef_width or 0,
113
+ georef_height=georef_height or 0,
114
+ source=source if bounds else "none",
115
+ )
116
+
117
+
118
+ def pixel_to_lat_lng(
119
+ x: float,
120
+ y: float,
121
+ img_width: int,
122
+ img_height: int,
123
+ bounds: BoundsWGS84,
124
+ *,
125
+ geo: Optional[GeoContext] = None,
126
+ ) -> Optional[Dict[str, float]]:
127
+ if img_width <= 0 or img_height <= 0:
128
+ return None
129
+
130
+ if geo and geo.georef:
131
+ coords = pixel_to_geo_wgs84(
132
+ x, y, geo.georef,
133
+ detection_width=img_width,
134
+ detection_height=img_height,
135
+ )
136
+ if coords:
137
+ lng, lat = coords
138
+ # Guard against bad transforms emitting impossible coordinates
139
+ if abs(lat) <= 90 and abs(lng) <= 180:
140
+ return {"lat": round(lat, 6), "lng": round(lng, 6)}
141
+ logger.warning("Affine produced out-of-range lat/lng (%.3f,%.3f); using linear", lat, lng)
142
+
143
+ if not bounds:
144
+ return None
145
+
146
+ west, south, east, north = bounds
147
+ ref_w = geo.georef_width if geo and geo.georef_width > 0 else img_width
148
+ ref_h = geo.georef_height if geo and geo.georef_height > 0 else img_height
149
+ scale_x = ref_w / float(img_width)
150
+ scale_y = ref_h / float(img_height)
151
+ px = float(x) * scale_x
152
+ py = float(y) * scale_y
153
+ lng = west + (px / ref_w) * (east - west)
154
+ lat = north - (py / ref_h) * (north - south)
155
+ return {"lat": round(lat, 6), "lng": round(lng, 6)}
156
+
157
+
158
+ def _ground_m_per_px(
159
+ img_width: int,
160
+ img_height: int,
161
+ bounds: BoundsWGS84,
162
+ *,
163
+ geo: Optional[GeoContext] = None,
164
+ ) -> Optional[Tuple[float, float]]:
165
+ """Metres per detection-image pixel along X (east) and Y (south)."""
166
+ if img_width <= 0 or img_height <= 0 or not bounds:
167
+ return None
168
+ west, south, east, north = bounds
169
+ ref_w = geo.georef_width if geo and geo.georef_width > 0 else img_width
170
+ ref_h = geo.georef_height if geo and geo.georef_height > 0 else img_height
171
+ if ref_w <= 0 or ref_h <= 0:
172
+ return None
173
+ scale_x = ref_w / float(img_width)
174
+ scale_y = ref_h / float(img_height)
175
+ mid_lat = (north + south) / 2.0
176
+ lat_scale = 111_320.0
177
+ lng_scale = 111_320.0 * math.cos(math.radians(mid_lat))
178
+ m_per_px_x = abs(east - west) / ref_w * scale_x * lng_scale
179
+ m_per_px_y = abs(north - south) / ref_h * scale_y * lat_scale
180
+ if m_per_px_x <= 0 or m_per_px_y <= 0:
181
+ return None
182
+ return m_per_px_x, m_per_px_y
183
+
184
+
185
+ def bbox_area_sq_m(
186
+ bbox: Dict[str, int],
187
+ img_width: int,
188
+ img_height: int,
189
+ bounds: BoundsWGS84,
190
+ *,
191
+ geo: Optional[GeoContext] = None,
192
+ ) -> Optional[float]:
193
+ """Approximate region area in square metres using the axis-aligned bbox."""
194
+ scales = _ground_m_per_px(img_width, img_height, bounds, geo=geo)
195
+ if not scales:
196
+ return None
197
+ m_per_px_x, m_per_px_y = scales
198
+ w_m = float(bbox.get("w", 0)) * m_per_px_x
199
+ h_m = float(bbox.get("h", 0)) * m_per_px_y
200
+ return round(w_m * h_m, 1)
201
+
202
+
203
+ def polygon_area_px(polygon: Optional[List]) -> Optional[float]:
204
+ """Shoelace area of an image-pixel ring (absolute value, closed or open)."""
205
+ if not polygon or len(polygon) < 3:
206
+ return None
207
+ try:
208
+ pts = [(float(p[0]), float(p[1])) for p in polygon]
209
+ except (TypeError, ValueError, IndexError):
210
+ return None
211
+ if pts[0] == pts[-1]:
212
+ pts = pts[:-1]
213
+ if len(pts) < 3:
214
+ return None
215
+ area2 = 0.0
216
+ n = len(pts)
217
+ for i in range(n):
218
+ x1, y1 = pts[i]
219
+ x2, y2 = pts[(i + 1) % n]
220
+ area2 += x1 * y2 - x2 * y1
221
+ return abs(area2) * 0.5
222
+
223
+
224
+ def polygon_area_sq_m(
225
+ polygon: Optional[List],
226
+ img_width: int,
227
+ img_height: int,
228
+ bounds: Optional[BoundsWGS84],
229
+ *,
230
+ geo: Optional[GeoContext] = None,
231
+ ) -> Optional[float]:
232
+ """Ground area of a pixel-space polygon footprint (preferred over bbox)."""
233
+ if not bounds:
234
+ return None
235
+ scales = _ground_m_per_px(img_width, img_height, bounds, geo=geo)
236
+ if not scales:
237
+ return None
238
+ m_per_px_x, m_per_px_y = scales
239
+ px_area = polygon_area_px(polygon)
240
+ if px_area is None:
241
+ return None
242
+ return round(px_area * m_per_px_x * m_per_px_y, 1)
243
+
244
+
245
+ def polygon_to_lat_lng(
246
+ polygon: List[List[float]],
247
+ img_width: int,
248
+ img_height: int,
249
+ bounds: Optional[BoundsWGS84],
250
+ *,
251
+ geo: Optional[GeoContext] = None,
252
+ ) -> Optional[List[Dict[str, float]]]:
253
+ """Project a pixel-space polygon ring to WGS84, vertex by vertex.
254
+
255
+ Uses the same transform as the bbox-centre ``latLng`` so a polygon and its
256
+ region always agree. Returns None when any vertex fails to project, since a
257
+ partially-projected ring would draw a wrong footprint on a map.
258
+ """
259
+ if not polygon:
260
+ return None
261
+ ring: List[Dict[str, float]] = []
262
+ for point in polygon:
263
+ try:
264
+ x, y = float(point[0]), float(point[1])
265
+ except (TypeError, ValueError, IndexError):
266
+ return None
267
+ coords = pixel_to_lat_lng(x, y, img_width, img_height, bounds, geo=geo)
268
+ if not coords:
269
+ return None
270
+ ring.append(coords)
271
+ return ring or None
272
+
273
+
274
+ def enrich_regions_geo(
275
+ regions: List[dict],
276
+ *,
277
+ img_width: int,
278
+ img_height: int,
279
+ bounds: Optional[BoundsWGS84],
280
+ geo: Optional[GeoContext] = None,
281
+ ) -> List[dict]:
282
+ """Add latLng, polygonGeo, areaSqM, and Vedangsoft change type to each region."""
283
+ effective_bounds = bounds or (geo.bounds if geo else None)
284
+ out = []
285
+ for region in regions:
286
+ enriched = enrich_region_for_dda(region)
287
+ center = region.get("center") or {}
288
+ cx = center.get("x", 0)
289
+ cy = center.get("y", 0)
290
+ poly = region.get("polygon")
291
+ poly_px = polygon_area_px(poly)
292
+ if poly_px is not None:
293
+ enriched["polygonAreaPx"] = round(poly_px, 1)
294
+ if effective_bounds or (geo and geo.georef):
295
+ lat_lng = pixel_to_lat_lng(
296
+ cx, cy, img_width, img_height, effective_bounds,
297
+ geo=geo,
298
+ )
299
+ if lat_lng:
300
+ enriched["latLng"] = lat_lng
301
+ polygon_geo = polygon_to_lat_lng(
302
+ poly, img_width, img_height, effective_bounds,
303
+ geo=geo,
304
+ )
305
+ if polygon_geo:
306
+ enriched["polygonGeo"] = polygon_geo
307
+ bbox = region.get("bbox") or {}
308
+ if effective_bounds:
309
+ # Prefer true polygon footprint area; bbox is the fallback.
310
+ area_sq_m = polygon_area_sq_m(
311
+ poly, img_width, img_height, effective_bounds, geo=geo,
312
+ )
313
+ if area_sq_m is None:
314
+ area_sq_m = bbox_area_sq_m(
315
+ bbox, img_width, img_height, effective_bounds, geo=geo,
316
+ )
317
+ if area_sq_m is not None:
318
+ enriched["areaSqM"] = area_sq_m
319
+ else:
320
+ enriched["latLng"] = None
321
+ out.append(enriched)
322
+ return out
323
+
324
+
325
+ def region_lat_lng(region: dict) -> tuple[Optional[float], Optional[float]]:
326
+ """Read lat/lng from region dict (supports latLng object or flat keys)."""
327
+ ll = region.get("latLng") or {}
328
+ lat = region.get("latitude", ll.get("lat") if isinstance(ll, dict) else None)
329
+ lng = region.get("longitude", ll.get("lng") if isinstance(ll, dict) else None)
330
+ if lat is None or lng is None:
331
+ return None, None
332
+ try:
333
+ return float(lat), float(lng)
334
+ except (TypeError, ValueError):
335
+ return None, None
app/dda/geotiff_io.py ADDED
@@ -0,0 +1,666 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GeoTIFF ingest and preview generation (FR-02). Rasterio optional at import time."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Optional, Tuple
9
+
10
+ from PIL import Image, ImageOps
11
+
12
+ from .config import get_detection_max_side
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # World-file extensions per image type (ESRI convention + generic .wld)
17
+ _WORLD_FILE_EXTS = {
18
+ ".tif": (".tfw", ".tifw", ".wld"),
19
+ ".tiff": (".tfw", ".tifw", ".wld"),
20
+ ".jpg": (".jgw", ".jpgw", ".wld"),
21
+ ".jpeg": (".jgw", ".jpgw", ".wld"),
22
+ ".png": (".pgw", ".pngw", ".wld"),
23
+ }
24
+
25
+
26
+ @dataclass
27
+ class IngestResult:
28
+ width: int
29
+ height: int
30
+ has_georef: bool
31
+ crs: str
32
+ bounds_wgs84: Optional[Tuple[float, float, float, float]] # west, south, east, north
33
+ format: str
34
+ georef_source: str = "none" # embedded | worldfile | none
35
+
36
+
37
+ def _find_world_file(path: Path) -> Optional[Path]:
38
+ """Locate an ESRI world file or generic .wld sidecar next to an image."""
39
+ ext = path.suffix.lower()
40
+ candidates = []
41
+ for wext in _WORLD_FILE_EXTS.get(ext, (".wld",)):
42
+ candidates.append(path.with_suffix(wext))
43
+ # e.g. photo.jpg.wld
44
+ candidates.append(Path(str(path) + ".wld"))
45
+ for cand in candidates:
46
+ if cand.is_file():
47
+ return cand
48
+ return None
49
+
50
+
51
+ def _read_prj_crs(path: Path):
52
+ """Read a .prj sidecar (WKT) and return a rasterio CRS, or default EPSG:4326."""
53
+ try:
54
+ from rasterio.crs import CRS
55
+ except Exception:
56
+ return None
57
+ prj = path.with_suffix(".prj")
58
+ if prj.is_file():
59
+ try:
60
+ return CRS.from_wkt(prj.read_text(encoding="utf-8", errors="ignore").strip())
61
+ except Exception as exc:
62
+ logger.warning("Could not parse .prj for %s: %s", path.name, exc)
63
+ try:
64
+ return CRS.from_epsg(4326)
65
+ except Exception:
66
+ return None
67
+
68
+
69
+ def _world_file_georef(path: Path, width: int, height: int):
70
+ """Build (transform, crs, bounds_wgs84) from a world file sidecar, or None."""
71
+ wf = _find_world_file(path)
72
+ if wf is None:
73
+ return None
74
+ try:
75
+ from rasterio.transform import Affine, array_bounds
76
+ from rasterio.warp import transform_bounds
77
+
78
+ nums = [float(x.strip()) for x in wf.read_text().split() if x.strip()]
79
+ if len(nums) < 6:
80
+ return None
81
+ a, d, b, e, c, f = nums[:6]
82
+ # World file stores center of top-left pixel; shift to corner for GDAL/affine
83
+ gt_c = c - a / 2.0 - b / 2.0
84
+ gt_f = f - d / 2.0 - e / 2.0
85
+ transform = Affine(a, b, gt_c, d, e, gt_f)
86
+ crs = _read_prj_crs(path)
87
+ west, south, east, north = array_bounds(height, width, transform)
88
+ if crs is not None and str(crs) not in ("EPSG:4326", "OGC:CRS84"):
89
+ west, south, east, north = transform_bounds(crs, "EPSG:4326", west, south, east, north)
90
+ return transform, crs, (float(west), float(south), float(east), float(north))
91
+ except Exception as exc:
92
+ logger.warning("World-file georef failed for %s: %s", path.name, exc)
93
+ return None
94
+
95
+
96
+ def _read_with_rasterio(path: Path) -> IngestResult:
97
+ import rasterio
98
+ from rasterio.warp import transform_bounds
99
+
100
+ with rasterio.open(path) as src:
101
+ crs = str(src.crs) if src.crs else ""
102
+ has_georef = src.crs is not None
103
+ bounds = None
104
+ if has_georef and src.bounds:
105
+ try:
106
+ w, s, e, n = transform_bounds(src.crs, "EPSG:4326", *src.bounds)
107
+ bounds = (float(w), float(s), float(e), float(n))
108
+ except Exception as exc:
109
+ logger.warning("Could not transform bounds to WGS84: %s", exc)
110
+ ext = path.suffix.lower()
111
+ return IngestResult(
112
+ width=int(src.width),
113
+ height=int(src.height),
114
+ has_georef=has_georef,
115
+ crs=crs,
116
+ bounds_wgs84=bounds,
117
+ format="geotiff" if ext in (".tif", ".tiff") else "image",
118
+ georef_source="embedded" if has_georef else "none",
119
+ )
120
+
121
+
122
+ def _read_with_pillow(path: Path) -> IngestResult:
123
+ with Image.open(path) as img:
124
+ w, h = img.size
125
+ ext = path.suffix.lower()
126
+ fmt = "geotiff" if ext in (".tif", ".tiff") else "image"
127
+ # Try a world-file sidecar for plain images / non-georeferenced TIFFs
128
+ wf = _world_file_georef(path, w, h)
129
+ if wf is not None:
130
+ _, crs, bounds = wf
131
+ return IngestResult(
132
+ width=w, height=h, has_georef=bounds is not None,
133
+ crs=str(crs) if crs else "", bounds_wgs84=bounds,
134
+ format=fmt, georef_source="worldfile" if bounds else "none",
135
+ )
136
+ return IngestResult(
137
+ width=w,
138
+ height=h,
139
+ has_georef=False,
140
+ crs="",
141
+ bounds_wgs84=None,
142
+ format=fmt,
143
+ georef_source="none",
144
+ )
145
+
146
+
147
+ def inspect_image(path: Path) -> IngestResult:
148
+ """Read dimensions + georeferencing for any raster (TIFF/PNG/JPEG).
149
+
150
+ Tries rasterio first (honors embedded CRS, world files and GDAL .aux.xml for
151
+ all formats), then falls back to Pillow + explicit world-file parsing.
152
+ Corrupt / truncated GeoTIFFs must not crash library rescan β€” callers get a
153
+ zero-size stub and should skip indexing.
154
+ """
155
+ try:
156
+ res = _read_with_rasterio(path)
157
+ # rasterio opened but found no embedded georef: try explicit world file
158
+ if not res.has_georef:
159
+ wf = _world_file_georef(path, res.width, res.height)
160
+ if wf is not None and wf[2] is not None:
161
+ _, crs, bounds = wf
162
+ res.has_georef = True
163
+ res.crs = str(crs) if crs else ""
164
+ res.bounds_wgs84 = bounds
165
+ res.georef_source = "worldfile"
166
+ return res
167
+ except ImportError:
168
+ logger.warning("rasterio not installed β€” georef metadata limited")
169
+ except Exception as exc:
170
+ logger.warning("rasterio read failed (%s), falling back to Pillow", exc)
171
+ try:
172
+ return _read_with_pillow(path)
173
+ except Exception as exc:
174
+ # Common for truncated / BigTIFF files Pillow cannot parse
175
+ # ("Invalid dimensions"). Don't abort the whole library sync.
176
+ logger.warning("Pillow inspect failed for %s: %s", path.name, exc)
177
+ ext = path.suffix.lower()
178
+ return IngestResult(
179
+ width=0,
180
+ height=0,
181
+ has_georef=False,
182
+ crs="",
183
+ bounds_wgs84=None,
184
+ format="geotiff" if ext in (".tif", ".tiff") else "image",
185
+ georef_source="none",
186
+ )
187
+
188
+
189
+ def write_placeholder_png(dest_path: Path, label: str = "Image", max_side: int = 256) -> None:
190
+ """Fallback thumb when GeoTIFF is too large or rasterio is unavailable."""
191
+ from PIL import ImageDraw
192
+
193
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
194
+ img = Image.new("RGB", (max_side, max_side), color=(32, 40, 52))
195
+ draw = ImageDraw.Draw(img)
196
+ lines = [label[:28], "preview N/A"]
197
+ y = max_side // 2 - 20
198
+ for line in lines:
199
+ draw.text((12, y), line, fill=(100, 200, 170))
200
+ y += 18
201
+ img.save(dest_path, format="PNG")
202
+
203
+
204
+ def _rasterio_read_rgb(path: Path, max_side: int):
205
+ import numpy as np
206
+ import rasterio
207
+ from rasterio.enums import Resampling
208
+
209
+ with rasterio.open(path) as src:
210
+ count = min(3, src.count)
211
+ scale = min(1.0, max_side / max(src.width, src.height, 1))
212
+ out_h = max(1, int(src.height * scale))
213
+ out_w = max(1, int(src.width * scale))
214
+
215
+ read_kwargs = {
216
+ "indexes": list(range(1, count + 1)),
217
+ "out_shape": (count, out_h, out_w),
218
+ "resampling": Resampling.bilinear,
219
+ }
220
+ try:
221
+ data = src.read(**read_kwargs)
222
+ except Exception:
223
+ # Fallback: read from coarsest overview when full decimation fails
224
+ if src.overviews(1):
225
+ with rasterio.open(path, overview_level=len(src.overviews(1))) as ov_src:
226
+ ocount = min(3, ov_src.count)
227
+ data = ov_src.read(
228
+ indexes=list(range(1, ocount + 1)),
229
+ out_shape=(ocount, out_h, out_w),
230
+ resampling=Resampling.bilinear,
231
+ )
232
+ else:
233
+ raise
234
+
235
+ if count == 1:
236
+ rgb = np.stack([data[0], data[0], data[0]])
237
+ else:
238
+ rgb = data[:3]
239
+ rgb = np.transpose(rgb, (1, 2, 0)).astype("float32")
240
+ if rgb.max() > 255 or rgb.min() < 0:
241
+ lo, hi = np.percentile(rgb, (2, 98))
242
+ rgb = np.clip((rgb - lo) / max(hi - lo, 1e-6), 0, 1) * 255
243
+ return Image.fromarray(rgb.astype("uint8"), mode="RGB")
244
+
245
+
246
+ def read_native_size(path: Path) -> Optional[Tuple[int, int]]:
247
+ """Return (width, height) of a GeoTIFF without decoding pixels, or None."""
248
+ ext = path.suffix.lower()
249
+ if ext not in (".tif", ".tiff"):
250
+ return None
251
+ try:
252
+ import rasterio
253
+ with rasterio.open(path) as src:
254
+ return int(src.width), int(src.height)
255
+ except Exception:
256
+ return None
257
+
258
+
259
+ def _normalize_window_rgb(data):
260
+ """Convert a rasterio (bands, h, w) window read to RGB uint8 (h, w, 3)."""
261
+ import numpy as np
262
+ count = data.shape[0]
263
+ if count == 1:
264
+ rgb = np.stack([data[0], data[0], data[0]])
265
+ else:
266
+ rgb = data[:3]
267
+ rgb = np.transpose(rgb, (1, 2, 0)).astype("float32")
268
+ if rgb.max() > 255 or rgb.min() < 0:
269
+ lo, hi = np.percentile(rgb, (2, 98))
270
+ rgb = np.clip((rgb - lo) / max(hi - lo, 1e-6), 0, 1) * 255
271
+ return rgb.astype("uint8")
272
+
273
+
274
+ def iter_geotiff_window_pairs(path_a: Path, path_b: Path,
275
+ tile_size: int = 512, overlap: float = 0.25):
276
+ """Stream paired native-resolution RGB windows from two GeoTIFFs.
277
+
278
+ Reads windows from disk via rasterio so very large rasters never load
279
+ fully into RAM. Yields ``(tile_a, tile_b, y0, x0, full_h, full_w)`` where
280
+ tiles are RGB uint8 on **path_a's** pixel grid.
281
+
282
+ Same-size pairs use direct window reads. Different pixel sizes (common for
283
+ same-bounds DDA pairs with slight GSD drift) reproject ``path_b`` into each
284
+ ``path_a`` window via georef so windowed full-res inference still works.
285
+ """
286
+ import numpy as np
287
+ import rasterio
288
+ from rasterio.enums import Resampling
289
+ from rasterio.warp import reproject
290
+ from rasterio.windows import Window, transform as window_transform
291
+
292
+ tile_size = max(64, int(tile_size))
293
+ ov = min(0.5, max(0.0, float(overlap)))
294
+ step = max(1, int(round(tile_size * (1.0 - ov))))
295
+
296
+ with rasterio.open(path_a) as src_a, rasterio.open(path_b) as src_b:
297
+ full_w, full_h = int(src_a.width), int(src_a.height)
298
+ same_grid = (src_a.width, src_a.height) == (src_b.width, src_b.height)
299
+ bands_a = list(range(1, min(3, src_a.count) + 1))
300
+ bands_b = list(range(1, min(3, src_b.count) + 1))
301
+ n_bands = len(bands_a)
302
+
303
+ ys = list(range(0, max(1, full_h - tile_size + 1), step))
304
+ xs = list(range(0, max(1, full_w - tile_size + 1), step))
305
+ if ys[-1] != full_h - tile_size and full_h > tile_size:
306
+ ys.append(full_h - tile_size)
307
+ if xs[-1] != full_w - tile_size and full_w > tile_size:
308
+ xs.append(full_w - tile_size)
309
+
310
+ for y0 in ys:
311
+ for x0 in xs:
312
+ wh = min(tile_size, full_h - y0)
313
+ ww = min(tile_size, full_w - x0)
314
+ win = Window(x0, y0, ww, wh)
315
+ da = src_a.read(indexes=bands_a, window=win)
316
+
317
+ if same_grid:
318
+ db = src_b.read(indexes=bands_b, window=win)
319
+ else:
320
+ # Warp after β†’ before window CRS/transform/size
321
+ dst = np.zeros((n_bands, wh, ww), dtype=np.float32)
322
+ dst_transform = window_transform(win, src_a.transform)
323
+ for i, band in enumerate(bands_b[:n_bands]):
324
+ reproject(
325
+ source=rasterio.band(src_b, band),
326
+ destination=dst[i],
327
+ src_transform=src_b.transform,
328
+ src_crs=src_b.crs,
329
+ dst_transform=dst_transform,
330
+ dst_crs=src_a.crs,
331
+ resampling=Resampling.bilinear,
332
+ )
333
+ db = dst
334
+
335
+ yield (_normalize_window_rgb(da), _normalize_window_rgb(db),
336
+ y0, x0, full_h, full_w)
337
+
338
+
339
+ def load_rgb_pil(path: Path, max_side: Optional[int] = None) -> Image.Image:
340
+ """Load image as RGB PIL, downscaling large GeoTIFFs via rasterio."""
341
+ if max_side is None:
342
+ max_side = get_detection_max_side()
343
+ ext = path.suffix.lower()
344
+ if ext in (".tif", ".tiff"):
345
+ try:
346
+ img = _rasterio_read_rgb(path, max_side)
347
+ return img.copy()
348
+ except ImportError as exc:
349
+ raise RuntimeError(
350
+ "GeoTIFF support requires rasterio. Install with: pip install rasterio"
351
+ ) from exc
352
+ except Exception as exc:
353
+ raise RuntimeError(f"Could not read GeoTIFF: {exc}") from exc
354
+ with Image.open(path) as img:
355
+ img = ImageOps.exif_transpose(img)
356
+ img = img.convert("RGB")
357
+ if max(img.size) > max_side:
358
+ img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
359
+ return img.copy()
360
+
361
+
362
+ def parse_roi(roi) -> Optional[dict]:
363
+ """Validate a fractional ROI ``{x, y, w, h}`` (all in [0, 1]).
364
+
365
+ Returns a normalized dict (w/h clamped so the box stays inside the image),
366
+ or ``None`` when ``roi`` is empty/falsey. Raises ``ValueError`` on a
367
+ malformed or out-of-bounds ROI so callers can turn it into an HTTP 400.
368
+ """
369
+ if not roi:
370
+ return None
371
+ try:
372
+ x, y = float(roi["x"]), float(roi["y"])
373
+ w, h = float(roi["w"]), float(roi["h"])
374
+ except (KeyError, TypeError, ValueError):
375
+ raise ValueError("roi must have numeric x, y, w, h")
376
+ if not (0.0 <= x < 1.0 and 0.0 <= y < 1.0):
377
+ raise ValueError("roi x, y must be in [0, 1)")
378
+ if not (0.0 < w <= 1.0 and 0.0 < h <= 1.0):
379
+ raise ValueError("roi w, h must be in (0, 1]")
380
+ if x + w > 1.0 + 1e-6 or y + h > 1.0 + 1e-6:
381
+ raise ValueError("roi extends past image bounds (x+w or y+h > 1)")
382
+ return {"x": x, "y": y, "w": min(w, 1.0 - x), "h": min(h, 1.0 - y)}
383
+
384
+
385
+ def _rasterio_read_roi(path: Path, roi: dict, max_side: int) -> Image.Image:
386
+ """Read only the ROI window of a GeoTIFF natively (fast, low memory)."""
387
+ import rasterio
388
+ from rasterio.enums import Resampling
389
+ from rasterio.windows import Window
390
+
391
+ with rasterio.open(path) as src:
392
+ W, H = int(src.width), int(src.height)
393
+ col0 = int(round(roi["x"] * W))
394
+ row0 = int(round(roi["y"] * H))
395
+ cw = min(max(1, int(round(roi["w"] * W))), W - col0)
396
+ ch = min(max(1, int(round(roi["h"] * H))), H - row0)
397
+ win = Window(col0, row0, cw, ch)
398
+ count = min(3, src.count)
399
+ scale = min(1.0, max_side / max(cw, ch, 1))
400
+ out_h = max(1, int(ch * scale))
401
+ out_w = max(1, int(cw * scale))
402
+ data = src.read(
403
+ indexes=list(range(1, count + 1)), window=win,
404
+ out_shape=(count, out_h, out_w), resampling=Resampling.bilinear,
405
+ )
406
+ return Image.fromarray(_normalize_window_rgb(data), mode="RGB")
407
+
408
+
409
+ def load_rgb_roi(path: Path, roi, max_side: Optional[int] = None) -> Image.Image:
410
+ """Load only a fractional ROI window of an image as RGB PIL.
411
+
412
+ ``roi = {x, y, w, h}`` are fractions in [0, 1] of the image's own pixel
413
+ grid. For GeoTIFFs the window is read natively from disk (so a small ROI on
414
+ a huge raster stays fast and low-memory), then downscaled to ``max_side``;
415
+ other formats are opened, cropped, and downscaled. When ``roi`` is empty,
416
+ falls back to a full-image load.
417
+ """
418
+ if max_side is None:
419
+ max_side = get_detection_max_side()
420
+ roi = parse_roi(roi)
421
+ if roi is None:
422
+ return load_rgb_pil(path, max_side=max_side)
423
+ ext = path.suffix.lower()
424
+ if ext in (".tif", ".tiff"):
425
+ try:
426
+ return _rasterio_read_roi(path, roi, max_side).copy()
427
+ except ImportError as exc:
428
+ raise RuntimeError(
429
+ "GeoTIFF support requires rasterio. Install with: pip install rasterio"
430
+ ) from exc
431
+ except Exception as exc:
432
+ raise RuntimeError(f"Could not read GeoTIFF ROI: {exc}") from exc
433
+ with Image.open(path) as img:
434
+ img = ImageOps.exif_transpose(img).convert("RGB")
435
+ W, H = img.size
436
+ left = int(round(roi["x"] * W))
437
+ top = int(round(roi["y"] * H))
438
+ right = max(left + 1, int(round((roi["x"] + roi["w"]) * W)))
439
+ bottom = max(top + 1, int(round((roi["y"] + roi["h"]) * H)))
440
+ crop = img.crop((left, top, right, bottom))
441
+ if max(crop.size) > max_side:
442
+ crop.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
443
+ return crop.copy()
444
+
445
+
446
+ def raster_to_preview_png(src_path: Path, dest_path: Path, max_side: int = 512) -> None:
447
+ """Create RGB thumbnail/preview β€” uses decimated read for large GeoTIFFs."""
448
+ ext = src_path.suffix.lower()
449
+ if ext in (".tif", ".tiff"):
450
+ try:
451
+ img = _rasterio_read_rgb(src_path, max_side)
452
+ if max(img.size) > max_side:
453
+ img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
454
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
455
+ img.save(dest_path, format="PNG")
456
+ return
457
+ except Exception as exc:
458
+ logger.warning("GeoTIFF preview failed for %s: %s", src_path.name, exc)
459
+ write_placeholder_png(dest_path, src_path.name, max_side)
460
+ return
461
+
462
+ try:
463
+ with Image.open(src_path) as img:
464
+ img = ImageOps.exif_transpose(img)
465
+ img = img.convert("RGB")
466
+ img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
467
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
468
+ img.save(dest_path, format="PNG")
469
+ except Exception as exc:
470
+ logger.warning("Preview failed for %s: %s", src_path.name, exc)
471
+ write_placeholder_png(dest_path, src_path.name, max_side)
472
+
473
+
474
+ @dataclass
475
+ class GeorefInfo:
476
+ transform: Any
477
+ crs: Any
478
+ width: int
479
+ height: int
480
+ bounds_wgs84: Optional[Tuple[float, float, float, float]]
481
+ source: str = "embedded" # embedded | worldfile
482
+
483
+
484
+ def read_georef(path: Path) -> Optional[GeorefInfo]:
485
+ """Read raster affine transform + WGS84 bounds for any raster format.
486
+
487
+ Honors embedded CRS (GeoTIFF), GDAL .aux.xml sidecars, and ESRI world files
488
+ for TIFF/PNG/JPEG. Returns None when no georeferencing can be resolved.
489
+ """
490
+ width = height = 0
491
+ try:
492
+ import rasterio
493
+ from rasterio.warp import transform_bounds
494
+
495
+ with rasterio.open(path) as src:
496
+ width, height = int(src.width), int(src.height)
497
+ if src.crs is not None:
498
+ bounds = None
499
+ try:
500
+ w, s, e, n = transform_bounds(src.crs, "EPSG:4326", *src.bounds)
501
+ bounds = (float(w), float(s), float(e), float(n))
502
+ except Exception as exc:
503
+ logger.warning("Could not transform bounds for %s: %s", path.name, exc)
504
+ return GeorefInfo(
505
+ transform=src.transform, crs=src.crs,
506
+ width=width, height=height, bounds_wgs84=bounds, source="embedded",
507
+ )
508
+ except ImportError:
509
+ return None
510
+ except Exception as exc:
511
+ logger.warning("read_georef rasterio open failed for %s: %s", path.name, exc)
512
+
513
+ # No embedded CRS β€” try an explicit world-file sidecar
514
+ if width <= 0 or height <= 0:
515
+ try:
516
+ with Image.open(path) as img:
517
+ width, height = img.size
518
+ except Exception:
519
+ return None
520
+ wf = _world_file_georef(path, width, height)
521
+ if wf is not None and wf[2] is not None:
522
+ transform, crs, bounds = wf
523
+ return GeorefInfo(
524
+ transform=transform, crs=crs,
525
+ width=width, height=height, bounds_wgs84=bounds, source="worldfile",
526
+ )
527
+ return None
528
+
529
+
530
+ def read_gsd_meters(path: Path) -> Optional[float]:
531
+ """Ground sample distance (meters/pixel) from the raster's georeferencing.
532
+
533
+ For geographic CRS (degrees) the pixel size is converted to meters at the
534
+ raster's center latitude. Returns None when no usable georef exists.
535
+ """
536
+ info = read_georef(path)
537
+ if info is None or info.transform is None:
538
+ return None
539
+ try:
540
+ px_x = abs(float(info.transform.a))
541
+ px_y = abs(float(info.transform.e))
542
+ if px_x <= 0 or px_y <= 0:
543
+ return None
544
+ gsd = (px_x + px_y) / 2.0
545
+ crs = info.crs
546
+ is_geographic = False
547
+ try:
548
+ is_geographic = bool(crs is not None and crs.is_geographic)
549
+ except Exception:
550
+ is_geographic = bool(crs is not None and "4326" in str(crs))
551
+ if is_geographic:
552
+ import math
553
+ lat = 0.0
554
+ if info.bounds_wgs84:
555
+ lat = (info.bounds_wgs84[1] + info.bounds_wgs84[3]) / 2.0
556
+ meters_per_deg = 111320.0 * max(0.2, math.cos(math.radians(lat)))
557
+ gsd *= meters_per_deg
558
+ return float(gsd) if gsd > 0 else None
559
+ except Exception as exc:
560
+ logger.warning("read_gsd_meters failed for %s: %s", path.name, exc)
561
+ return None
562
+
563
+
564
+ def harmonize_pair_gsd(
565
+ before_pil: Image.Image,
566
+ after_pil: Image.Image,
567
+ before_path: Path,
568
+ after_path: Path,
569
+ ):
570
+ """Resample a loaded image pair to a common (coarser) ground resolution.
571
+
572
+ SRCDNet motivation: when the two acquisitions have different GSDs, naive
573
+ pixel-grid alignment upsamples the coarser image and the interpolated
574
+ texture produces spurious "changes". Downsampling BOTH to the coarser GSD
575
+ (area-weighted) avoids hallucinated texture diffs.
576
+
577
+ Accounts for any load-time downscaling by scaling each file's native GSD
578
+ with its loaded/native width ratio. Returns ``(before, after, debug)``
579
+ where debug is None when harmonization was skipped or not needed.
580
+ """
581
+ from ..detection_config import get_gsd_harmonize, get_gsd_tolerance
582
+
583
+ if not get_gsd_harmonize():
584
+ return before_pil, after_pil, None
585
+ try:
586
+ gsd_b = read_gsd_meters(before_path)
587
+ gsd_a = read_gsd_meters(after_path)
588
+ if not gsd_b or not gsd_a:
589
+ return before_pil, after_pil, None
590
+
591
+ native_b = read_native_size(before_path)
592
+ native_a = read_native_size(after_path)
593
+ if not native_b or not native_a:
594
+ return before_pil, after_pil, None
595
+
596
+ # Effective GSD of the in-memory image after load-time downscaling
597
+ eff_b = gsd_b * (native_b[0] / float(before_pil.size[0]))
598
+ eff_a = gsd_a * (native_a[0] / float(after_pil.size[0]))
599
+
600
+ rel_diff = abs(eff_b - eff_a) / max(eff_b, eff_a)
601
+ tolerance = get_gsd_tolerance()
602
+ debug = {
603
+ "gsdBefore": round(eff_b, 4),
604
+ "gsdAfter": round(eff_a, 4),
605
+ "relDiff": round(rel_diff, 4),
606
+ "tolerance": tolerance,
607
+ "harmonized": False,
608
+ }
609
+ if rel_diff <= tolerance:
610
+ return before_pil, after_pil, debug
611
+
612
+ target = max(eff_b, eff_a) # coarser resolution wins
613
+ out = []
614
+ for img, eff in ((before_pil, eff_b), (after_pil, eff_a)):
615
+ scale = eff / target
616
+ if scale < 0.999:
617
+ new_size = (max(64, round(img.size[0] * scale)),
618
+ max(64, round(img.size[1] * scale)))
619
+ # BOX = area-weighted downsample (no hallucinated texture)
620
+ img = img.resize(new_size, Image.Resampling.BOX)
621
+ out.append(img)
622
+ debug["harmonized"] = True
623
+ debug["targetGsd"] = round(target, 4)
624
+ debug["sizeBefore"] = list(out[0].size)
625
+ debug["sizeAfter"] = list(out[1].size)
626
+ logger.info("GSD harmonized to %.3f m/px (before %.3f, after %.3f)",
627
+ target, eff_b, eff_a)
628
+ return out[0], out[1], debug
629
+ except Exception as exc:
630
+ logger.warning("GSD harmonization failed: %s", exc)
631
+ return before_pil, after_pil, None
632
+
633
+
634
+ def pixel_to_geo_wgs84(
635
+ x: float,
636
+ y: float,
637
+ georef: GeorefInfo,
638
+ *,
639
+ detection_width: int,
640
+ detection_height: int,
641
+ ) -> Optional[Tuple[float, float]]:
642
+ """Map detection pixel (x=col, y=row) to WGS84 (lng, lat)."""
643
+ if detection_width <= 0 or detection_height <= 0:
644
+ return None
645
+ try:
646
+ from rasterio.transform import xy as transform_xy
647
+ from rasterio.warp import transform as warp_transform
648
+
649
+ scale_x = georef.width / float(detection_width)
650
+ scale_y = georef.height / float(detection_height)
651
+ col = float(x) * scale_x
652
+ row = float(y) * scale_y
653
+ geo_x, geo_y = transform_xy(georef.transform, row, col, offset="center")
654
+ if georef.crs and str(georef.crs) != "EPSG:4326":
655
+ lngs, lats = warp_transform(georef.crs, "EPSG:4326", [geo_x], [geo_y])
656
+ return float(lngs[0]), float(lats[0])
657
+ return float(geo_x), float(geo_y)
658
+ except Exception as exc:
659
+ logger.warning("pixel_to_geo_wgs84 failed: %s", exc)
660
+ return None
661
+
662
+
663
+ def bounds_to_json(bounds: Optional[Tuple[float, float, float, float]]) -> str:
664
+ if not bounds:
665
+ return ""
666
+ return json.dumps({"west": bounds[0], "south": bounds[1], "east": bounds[2], "north": bounds[3]})
app/dda/job_progress.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persist detection job progress for UI polling."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+
7
+ from ..database import SessionLocal
8
+ from .models import DetectionJob
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def update_job_progress(job_id: int, pct: int, stage: str) -> None:
14
+ """Update progress_pct and progress_stage in job params_json."""
15
+ db = SessionLocal()
16
+ try:
17
+ job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
18
+ if not job or job.status not in ("queued", "running"):
19
+ return
20
+ params = {}
21
+ try:
22
+ params = json.loads(job.params_json or "{}")
23
+ except json.JSONDecodeError:
24
+ pass
25
+ params["progress_pct"] = max(0, min(100, int(pct)))
26
+ params["progress_stage"] = stage or ""
27
+ job.params_json = json.dumps(params)
28
+ db.commit()
29
+ except Exception as exc:
30
+ logger.warning("Could not update job %d progress: %s", job_id, exc)
31
+ db.rollback()
32
+ finally:
33
+ db.close()
34
+
35
+
36
+ def get_job_progress(params: dict, status: str) -> tuple[int, str]:
37
+ pct = params.get("progress_pct")
38
+ stage = params.get("progress_stage") or ""
39
+ if status == "completed":
40
+ return 100, stage or "Complete"
41
+ if status == "failed":
42
+ return int(pct) if pct is not None else 0, stage or "Failed"
43
+ if status == "queued":
44
+ return int(pct) if pct is not None else 0, stage or "Queued"
45
+ if pct is None:
46
+ return 10, stage or "Running"
47
+ return int(pct), stage or "Running"
app/dda/job_runner.py ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Background detection job runner (FR-04 async pipeline)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ import threading
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Optional
10
+
11
+ from PIL import Image
12
+ from sqlalchemy.orm import Session
13
+
14
+ from ..auth import get_or_create_guest_user
15
+ from ..database import SessionLocal
16
+ from ..models import DetectionRun
17
+ from .detect_service import run_detection_and_save
18
+ from .geotiff_io import load_rgb_pil
19
+ from .job_progress import update_job_progress
20
+ from .local_routes import safe_resolve
21
+ from .models import DetectionJob
22
+ from .preflight import PreflightResult
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _runner_lock = threading.Lock()
27
+ _active_job_id: Optional[int] = None
28
+
29
+
30
+ def _utcnow():
31
+ return datetime.now(timezone.utc)
32
+
33
+
34
+ def _load_pair(
35
+ db: Session, base_path: str, comparison_path: str, roi: Optional[dict] = None,
36
+ ) -> tuple[Image.Image, Image.Image, Path, Optional[dict], PreflightResult]:
37
+ from ..detection_config import get_load_max_side
38
+ from .geotiff_io import load_rgb_roi
39
+ from .preflight import run_preflight_checks
40
+ base_file = safe_resolve(base_path)
41
+ comp_file = safe_resolve(comparison_path)
42
+
43
+ preflight = run_preflight_checks(db, base_file, comp_file, base_path, comparison_path, roi=roi)
44
+ if preflight.hard_fail:
45
+ raise ValueError(preflight.fail_reason)
46
+
47
+ max_side = get_load_max_side()
48
+ if roi:
49
+ # Crop both images to the same fractional window before detection so a
50
+ # small ROI on a huge raster runs fast and at full detail.
51
+ before_pil = load_rgb_roi(base_file, roi, max_side=max_side)
52
+ after_pil = load_rgb_roi(comp_file, roi, max_side=max_side)
53
+ else:
54
+ before_pil = load_rgb_pil(base_file, max_side=max_side)
55
+ after_pil = load_rgb_pil(comp_file, max_side=max_side)
56
+
57
+ gsd_debug = None
58
+ try:
59
+ from .geotiff_io import harmonize_pair_gsd
60
+ before_pil, after_pil, gsd_debug = harmonize_pair_gsd(
61
+ before_pil, after_pil, base_file, comp_file)
62
+ except Exception as exc:
63
+ logger.warning("GSD harmonization skipped: %s", exc)
64
+
65
+ if before_pil.size != after_pil.size:
66
+ after_pil = after_pil.resize(before_pil.size, Image.Resampling.LANCZOS)
67
+ return before_pil, after_pil, base_file, gsd_debug, preflight
68
+
69
+
70
+ def _parse_params(job: DetectionJob) -> Dict[str, Any]:
71
+ try:
72
+ return json.loads(job.params_json or "{}")
73
+ except json.JSONDecodeError:
74
+ return {}
75
+
76
+
77
+ def _run_job_sync(job_id: int) -> None:
78
+ global _active_job_id
79
+ db = SessionLocal()
80
+ try:
81
+ job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
82
+ if not job or job.status not in ("queued", "running"):
83
+ return
84
+
85
+ job.status = "running"
86
+ job.started_at = _utcnow()
87
+ job.error_message = ""
88
+ params = _parse_params(job)
89
+ method = job.method or params.get("method", "AI-Based Deep Learning")
90
+ created_by = job.created_by
91
+ notify_email = job.notify_email or params.get("notify_email")
92
+ db.commit()
93
+ # Detach the job row so this session cannot overwrite progress_pct
94
+ # written by update_job_progress() on another connection, and so we
95
+ # do not hold a SQLite/InnoDB transaction across the long CPU work.
96
+ db.expunge_all()
97
+ update_job_progress(job_id, 5, "Starting job")
98
+
99
+ base_path = params.get("base_path", "")
100
+ comparison_path = params.get("comparison_path", "")
101
+ if not base_path or not comparison_path:
102
+ raise ValueError("Job missing base_path or comparison_path in params_json")
103
+
104
+ roi = params.get("roi")
105
+ update_job_progress(job_id, 8, "Cropping ROI" if roi else "Loading images")
106
+ before_pil, after_pil, base_file, gsd_debug, preflight = _load_pair(
107
+ db, base_path, comparison_path, roi=roi)
108
+ comp_file = safe_resolve(comparison_path)
109
+ if preflight.warnings:
110
+ params["preflightWarnings"] = preflight.warnings
111
+ job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
112
+ if job:
113
+ job.params_json = json.dumps(params)
114
+ db.commit()
115
+ db.expunge_all()
116
+ if roi:
117
+ logger.info("Job %d ROI crop %s -> working size %s",
118
+ job_id, roi, before_pil.size)
119
+ update_job_progress(job_id, 12, "Images loaded")
120
+ title = params.get("title") or f"{Path(base_path).name} vs {Path(comparison_path).name}"
121
+ from ..detection_config import get_load_max_side
122
+ # Must use fullres load cap β€” get_detection_max_side() is 4096 and was
123
+ # silently downscaling overnight "max res" runs back to preview size.
124
+ detect_max = get_load_max_side(base_path, comparison_path)
125
+ logger.info(
126
+ "Job %d detection max_size=%d (loaded %s)",
127
+ job_id, detect_max, before_pil.size,
128
+ )
129
+ result = run_detection_and_save(
130
+ db,
131
+ before_pil,
132
+ after_pil,
133
+ method=method,
134
+ title=title,
135
+ zone=params.get("zone", ""),
136
+ village=params.get("village", ""),
137
+ enable_registration=bool(params.get("enable_registration", True)),
138
+ enable_normalization=bool(params.get("enable_normalization", True)),
139
+ detection_sensitivity=float(params.get("detection_sensitivity", 0.45)),
140
+ min_region_area=params.get("min_region_area"),
141
+ notify_email=notify_email,
142
+ max_size=detect_max,
143
+ geo_bounds_path=base_file,
144
+ comparison_file=comp_file,
145
+ base_path=base_path,
146
+ comparison_path=comparison_path,
147
+ user_id=created_by,
148
+ job_id=job_id,
149
+ gsd_debug=gsd_debug,
150
+ shape_mode=params.get("shape_mode", "polygon"),
151
+ preflight_warnings=preflight.warnings,
152
+ )
153
+
154
+ update_job_progress(job_id, 100, "Complete")
155
+
156
+ job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
157
+ if not job:
158
+ logger.error("Detection job %d vanished after completion", job_id)
159
+ return
160
+ job.status = "completed"
161
+ job.run_id = result["id"]
162
+ job.completed_at = _utcnow()
163
+ db.commit()
164
+ logger.info("Detection job %d completed β†’ run %s", job_id, result["id"])
165
+ from .auto_detect import mark_schedule_from_job
166
+ mark_schedule_from_job(job_id, status="completed", run_id=result["id"])
167
+ except Exception as exc:
168
+ logger.exception("Detection job %d failed", job_id)
169
+ try:
170
+ job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
171
+ if job:
172
+ job.status = "failed"
173
+ job.error_message = str(exc)[:2000]
174
+ job.completed_at = _utcnow()
175
+ db.commit()
176
+ from .auto_detect import mark_schedule_from_job
177
+ mark_schedule_from_job(job_id, status="failed", run_id=None, error=str(exc)[:2000])
178
+ except Exception:
179
+ db.rollback()
180
+ finally:
181
+ with _runner_lock:
182
+ if _active_job_id == job_id:
183
+ _active_job_id = None
184
+ db.close()
185
+
186
+
187
+ def _job_worker(job_id: int) -> None:
188
+ global _active_job_id
189
+ with _runner_lock:
190
+ _active_job_id = job_id
191
+ try:
192
+ _run_job_sync(job_id)
193
+ finally:
194
+ with _runner_lock:
195
+ if _active_job_id == job_id:
196
+ _active_job_id = None
197
+ try:
198
+ start_next_queued_job()
199
+ except Exception:
200
+ logger.exception("Could not start next queued detection job")
201
+
202
+
203
+ def enqueue_detection_job(job_id: int) -> bool:
204
+ """Start job in a background thread. Returns False if another job is running."""
205
+ global _active_job_id
206
+ with _runner_lock:
207
+ if _active_job_id is not None:
208
+ return False
209
+ _active_job_id = job_id
210
+ thread = threading.Thread(target=_job_worker, args=(job_id,), daemon=True, name=f"dda-job-{job_id}")
211
+ thread.start()
212
+ return True
213
+
214
+
215
+ def is_job_runner_busy() -> bool:
216
+ with _runner_lock:
217
+ return _active_job_id is not None
218
+
219
+
220
+ def start_next_queued_job() -> bool:
221
+ """Start the oldest queued job if the runner is idle.
222
+
223
+ Auto-scheduled jobs stay queued until the operator has clicked Start.
224
+ """
225
+ if is_job_runner_busy():
226
+ return False
227
+ from .auto_detect import is_auto_schedule_job, scheduler_is_armed
228
+ armed = scheduler_is_armed()
229
+ db = SessionLocal()
230
+ try:
231
+ pending = (
232
+ db.query(DetectionJob)
233
+ .filter(DetectionJob.status == "queued")
234
+ .order_by(DetectionJob.created_at.asc())
235
+ .all()
236
+ )
237
+ for job in pending:
238
+ if is_auto_schedule_job(job) and not armed:
239
+ continue
240
+ return enqueue_detection_job(job.id)
241
+ return False
242
+ finally:
243
+ db.close()
244
+
245
+
246
+ def reconcile_stale_jobs(db: Session, max_running_hours: float = 3.0) -> int:
247
+ """Mark orphaned / stuck running jobs failed; re-queue oldest queued job.
248
+
249
+ - Orphans: status=running but no in-process worker (server restart).
250
+ - Stuck: status=running longer than ``max_running_hours`` (crashed worker).
251
+ """
252
+ from datetime import timedelta
253
+
254
+ fixed = 0
255
+ busy = is_job_runner_busy()
256
+ cutoff = _utcnow() - timedelta(hours=max_running_hours)
257
+ running = db.query(DetectionJob).filter(DetectionJob.status == "running").all()
258
+ for job in running:
259
+ started = job.started_at or job.created_at
260
+ # Normalize naive datetimes from SQLite
261
+ if started is not None and started.tzinfo is None:
262
+ started = started.replace(tzinfo=timezone.utc)
263
+ stale_by_age = started is not None and started < cutoff
264
+ orphaned = not busy
265
+ if not (orphaned or stale_by_age):
266
+ continue
267
+ job.status = "failed"
268
+ if stale_by_age and busy:
269
+ job.error_message = (
270
+ f"Job timed out after {max_running_hours:g}h with no completion. "
271
+ "Please run detection again."
272
+ )
273
+ else:
274
+ job.error_message = "Job interrupted (server restarted). Please run detection again."
275
+ job.completed_at = _utcnow()
276
+ fixed += 1
277
+ if fixed:
278
+ db.commit()
279
+ logger.info("Reconciled %d stale running job(s)", fixed)
280
+
281
+ if not is_job_runner_busy():
282
+ start_next_queued_job()
283
+ return fixed
284
+
285
+
286
+ def create_local_folder_job(
287
+ db: Session,
288
+ *,
289
+ base_path: str,
290
+ comparison_path: str,
291
+ method: str = "AI-Based Deep Learning",
292
+ title: str = "",
293
+ zone: str = "",
294
+ village: str = "",
295
+ enable_registration: bool = True,
296
+ enable_normalization: bool = True,
297
+ detection_sensitivity: float = 0.45,
298
+ min_region_area: Optional[int] = 150,
299
+ notify_email: str = "",
300
+ created_by: Optional[int] = None,
301
+ roi: Optional[dict] = None,
302
+ shape_mode: str = "polygon",
303
+ extra_params: Optional[Dict[str, Any]] = None,
304
+ ) -> DetectionJob:
305
+ user = get_or_create_guest_user(db)
306
+ params = {
307
+ "source": "local_folder",
308
+ "base_path": base_path.replace("\\", "/"),
309
+ "comparison_path": comparison_path.replace("\\", "/"),
310
+ "method": method,
311
+ "title": title,
312
+ "zone": zone,
313
+ "village": village,
314
+ "enable_registration": enable_registration,
315
+ "enable_normalization": enable_normalization,
316
+ "detection_sensitivity": detection_sensitivity,
317
+ "min_region_area": min_region_area,
318
+ }
319
+ if extra_params:
320
+ params.update(extra_params)
321
+ if roi:
322
+ params["roi"] = roi
323
+ params["shape_mode"] = shape_mode
324
+ job = DetectionJob(
325
+ status="queued",
326
+ base_image_id=None,
327
+ comparison_image_id=None,
328
+ method=method,
329
+ params_json=json.dumps(params),
330
+ notify_email=notify_email or "",
331
+ created_by=created_by or user.id,
332
+ )
333
+ db.add(job)
334
+ db.commit()
335
+ db.refresh(job)
336
+ return job
337
+
338
+
339
+ def job_to_dict(job: DetectionJob, run: Optional[DetectionRun] = None) -> dict:
340
+ from .job_progress import get_job_progress
341
+
342
+ params = _parse_params(job)
343
+ progress_pct, progress_stage = get_job_progress(params, job.status)
344
+ out = {
345
+ "id": job.id,
346
+ "status": job.status,
347
+ "method": job.method,
348
+ "basePath": params.get("base_path", ""),
349
+ "comparisonPath": params.get("comparison_path", ""),
350
+ "title": params.get("title", ""),
351
+ "runId": job.run_id,
352
+ "errorMessage": job.error_message or "",
353
+ "notifyEmail": job.notify_email or "",
354
+ "preflightWarnings": params.get("preflightWarnings", []),
355
+ "progressPct": progress_pct,
356
+ "progressStage": progress_stage,
357
+ "createdAt": job.created_at.isoformat() if job.created_at else None,
358
+ "startedAt": job.started_at.isoformat() if job.started_at else None,
359
+ "completedAt": job.completed_at.isoformat() if job.completed_at else None,
360
+ "source": params.get("source") or "",
361
+ "autoScheduled": params.get("source") == "auto_schedule",
362
+ "zone": params.get("zone") or (getattr(run, "zone", None) if run else "") or "",
363
+ "village": params.get("village") or (getattr(run, "village", None) if run else "") or "",
364
+ }
365
+ if run:
366
+ out["report"] = {
367
+ "id": run.id,
368
+ "title": run.title,
369
+ "changePercentage": run.change_percentage,
370
+ "regionsCount": run.regions_count,
371
+ "overlayUrl": f"/api/overlay/{run.overlay_path}" if run.overlay_path else None,
372
+ }
373
+ return out
app/dda/jobs_routes.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Async detection job API (FR-04)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ from typing import Optional
7
+
8
+ from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request
9
+ from pydantic import BaseModel, Field
10
+ from sqlalchemy.orm import Session
11
+
12
+ from ..database import get_db
13
+ from ..models import DetectionRun, User
14
+ from .dda_auth import current_dda_user
15
+ from .job_runner import (
16
+ create_local_folder_job,
17
+ enqueue_detection_job,
18
+ is_job_runner_busy,
19
+ job_to_dict,
20
+ reconcile_stale_jobs,
21
+ )
22
+ from .local_routes import safe_resolve
23
+ from .models import DetectionJob
24
+ from .rbac.permissions import require_module_permission
25
+
26
+ logger = logging.getLogger(__name__)
27
+ router = APIRouter()
28
+
29
+
30
+ def _require_dda():
31
+ from .config import IS_DDA_MODE
32
+ if not IS_DDA_MODE:
33
+ raise HTTPException(status_code=404, detail="DDA mode is not enabled")
34
+
35
+
36
+ @router.post("/jobs")
37
+ async def create_job(
38
+ base_path: str = Form(...),
39
+ comparison_path: str = Form(...),
40
+ method: str = Form("AI-Based Deep Learning"),
41
+ title: str = Form(""),
42
+ zone: str = Form(""),
43
+ village: str = Form(""),
44
+ enable_registration: bool = Form(True),
45
+ enable_normalization: bool = Form(True),
46
+ detection_sensitivity: float = Form(0.45),
47
+ min_region_area: Optional[int] = Form(150),
48
+ notify_email: Optional[str] = Form(None),
49
+ roi: Optional[str] = Form(None),
50
+ shape_mode: str = Form("polygon"),
51
+ db: Session = Depends(get_db),
52
+ user: User = Depends(require_module_permission("detect", "create")),
53
+ ):
54
+ """Queue async detection from local library paths. Returns immediately with jobId.
55
+
56
+ ``roi`` (optional) is a JSON object ``{"x","y","w","h"}`` with fractional
57
+ coordinates in [0,1]; when present, detection runs only on that cropped
58
+ window (fast "test on a small area").
59
+ """
60
+ _require_dda()
61
+ base_norm = base_path.replace("\\", "/").strip()
62
+ comp_norm = comparison_path.replace("\\", "/").strip()
63
+ if not base_norm or not comp_norm:
64
+ raise HTTPException(status_code=400, detail="base_path and comparison_path are required")
65
+ if base_norm == comp_norm:
66
+ raise HTTPException(status_code=400, detail="Base and comparison images must be different")
67
+
68
+ roi_dict = None
69
+ if roi and roi.strip():
70
+ import json as _json
71
+
72
+ from .geotiff_io import parse_roi
73
+ try:
74
+ roi_dict = parse_roi(_json.loads(roi))
75
+ except (ValueError, _json.JSONDecodeError) as exc:
76
+ raise HTTPException(status_code=400, detail=f"Invalid roi: {exc}") from exc
77
+
78
+ try:
79
+ safe_resolve(base_norm)
80
+ safe_resolve(comp_norm)
81
+ except HTTPException:
82
+ raise
83
+ except Exception as exc:
84
+ raise HTTPException(status_code=400, detail=f"Invalid library path: {exc}") from exc
85
+
86
+ if is_job_runner_busy():
87
+ raise HTTPException(
88
+ status_code=409,
89
+ detail="Another detection job is already running. Wait for it to finish, then try again.",
90
+ )
91
+
92
+ # Clear DB orphans left after UI timeout / server restart so new jobs can start
93
+ reconcile_stale_jobs(db)
94
+
95
+ if is_job_runner_busy():
96
+ raise HTTPException(
97
+ status_code=409,
98
+ detail="Another detection job is already running. Wait for it to finish, then try again.",
99
+ )
100
+
101
+ if not title.strip():
102
+ from pathlib import Path
103
+ title = f"{Path(base_norm).name} vs {Path(comp_norm).name}"
104
+
105
+ job = create_local_folder_job(
106
+ db,
107
+ base_path=base_norm,
108
+ comparison_path=comp_norm,
109
+ method=method,
110
+ title=title,
111
+ zone=zone,
112
+ village=village,
113
+ enable_registration=enable_registration,
114
+ enable_normalization=enable_normalization,
115
+ detection_sensitivity=detection_sensitivity,
116
+ min_region_area=min_region_area,
117
+ notify_email=notify_email or "",
118
+ created_by=user.id,
119
+ roi=roi_dict,
120
+ shape_mode=shape_mode,
121
+ )
122
+
123
+ if not enqueue_detection_job(job.id):
124
+ job.status = "failed"
125
+ job.error_message = "Could not start background worker"
126
+ db.commit()
127
+ raise HTTPException(status_code=503, detail="Job queue is busy")
128
+
129
+ return {"jobId": job.id, "status": "queued", "message": "Detection job queued. Poll GET /api/dda/jobs/{id} for status."}
130
+
131
+
132
+ @router.get("/jobs/{job_id}")
133
+ def get_job(job_id: int, db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
134
+ _require_dda()
135
+ from .auto_detect import is_auto_schedule_job, is_auto_schedule_run
136
+
137
+ job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
138
+ if not job:
139
+ raise HTTPException(status_code=404, detail="Job not found")
140
+ if job.created_by and job.created_by != user.id and not is_auto_schedule_job(job):
141
+ raise HTTPException(status_code=403, detail="Not allowed to view this job")
142
+
143
+ run = None
144
+ if job.run_id:
145
+ run = db.query(DetectionRun).filter(DetectionRun.id == job.run_id).first()
146
+
147
+ data = job_to_dict(job, run=run)
148
+ if job.status == "completed" and run:
149
+ try:
150
+ data["result"] = _run_detail(db, run, user.id)
151
+ if data.get("preflightWarnings"):
152
+ data["result"].setdefault("statistics", {})["preflightWarnings"] = data["preflightWarnings"]
153
+ except HTTPException:
154
+ raise
155
+ except Exception as exc:
156
+ logger.warning("Could not load full run for job %s: %s", job_id, exc)
157
+ data["resultError"] = str(exc)[:500]
158
+ return data
159
+
160
+
161
+ def _run_detail(db: Session, run: DetectionRun, user_id: int) -> dict:
162
+ import base64
163
+
164
+ from ..database import DATA_DIR
165
+ from .auto_detect import is_auto_schedule_run
166
+
167
+ if run.user_id != user_id and not is_auto_schedule_run(db, run.id):
168
+ raise HTTPException(status_code=403, detail="Not allowed")
169
+
170
+ regions = json.loads(run.regions_json or "[]")
171
+ from .review_service import merge_reviews
172
+ regions = merge_reviews(db, run.id, regions)
173
+ overlay_b64 = ""
174
+ if run.overlay_path:
175
+ overlay_file = DATA_DIR / run.overlay_path
176
+ if overlay_file.exists():
177
+ overlay_b64 = base64.b64encode(overlay_file.read_bytes()).decode("utf-8")
178
+
179
+ from ..detection_config import get_load_max_side
180
+ from .detect_service import _isoformat_ist
181
+
182
+ return {
183
+ "id": run.id,
184
+ "title": run.title,
185
+ "method": run.method,
186
+ "zone": run.zone or "",
187
+ "village": run.village or "",
188
+ "statistics": {
189
+ "totalPixels": run.total_pixels,
190
+ "changedPixels": run.changed_pixels,
191
+ "unchangedPixels": run.total_pixels - run.changed_pixels,
192
+ "changePercentage": run.change_percentage,
193
+ },
194
+ "regions": regions,
195
+ "overlayBase64Png": overlay_b64,
196
+ "overlayUrl": f"/api/overlay/{run.overlay_path}" if run.overlay_path else None,
197
+ "beforeFullUrl": f"/api/overlay/{run.before_full_path}" if run.before_full_path else None,
198
+ "beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if run.before_thumb_path else None,
199
+ "afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if run.after_thumb_path else None,
200
+ "afterFullUrl": f"/api/overlay/{run.after_full_path}" if getattr(run, "after_full_path", None) else None,
201
+ "createdAt": _isoformat_ist(run.created_at),
202
+ "detectionMaxSide": get_load_max_side(),
203
+ }
204
+
205
+
206
+ @router.get("/jobs")
207
+ def list_jobs(
208
+ status: Optional[str] = Query(None),
209
+ limit: int = Query(20, ge=1, le=100),
210
+ db: Session = Depends(get_db),
211
+ user: User = Depends(current_dda_user),
212
+ ):
213
+ """Recent detection jobs for in-app notifications / reports feed (FR-05 partial)."""
214
+ _require_dda()
215
+ from .job_runner import reconcile_stale_jobs
216
+ reconcile_stale_jobs(db)
217
+ from .auto_detect import is_auto_schedule_job
218
+ q = db.query(DetectionJob).filter(DetectionJob.created_by == user.id)
219
+ if status:
220
+ q = q.filter(DetectionJob.status == status)
221
+ jobs = q.order_by(DetectionJob.created_at.desc()).limit(limit).all()
222
+ seen = {job.id for job in jobs}
223
+ extra = (
224
+ db.query(DetectionJob)
225
+ .order_by(DetectionJob.created_at.desc())
226
+ .limit(80)
227
+ .all()
228
+ )
229
+ for job in extra:
230
+ if job.id in seen:
231
+ continue
232
+ if not is_auto_schedule_job(job):
233
+ continue
234
+ if status and job.status != status:
235
+ continue
236
+ jobs.append(job)
237
+ seen.add(job.id)
238
+ jobs.sort(key=lambda j: j.id, reverse=True)
239
+ jobs = jobs[:limit]
240
+ out = []
241
+ for job in jobs:
242
+ run = db.query(DetectionRun).filter(DetectionRun.id == job.run_id).first() if job.run_id else None
243
+ out.append(job_to_dict(job, run=run))
244
+ return {"jobs": out, "runnerBusy": is_job_runner_busy()}
245
+
246
+
247
+ @router.get("/auto-detect")
248
+ def auto_detect_status(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
249
+ """Schedule for identified same-area pairs (Automatic mode)."""
250
+ _require_dda()
251
+ from .auto_detect import refresh_schedule_rows, schedule_status_payload
252
+
253
+ try:
254
+ refresh_schedule_rows()
255
+ except Exception as exc:
256
+ logger.warning("Auto-detect schedule sync skipped: %s", exc)
257
+ return schedule_status_payload(db)
258
+
259
+
260
+ class AutoDetectStartBody(BaseModel):
261
+ intervalDays: int = Field(10, ge=1, le=365)
262
+ runAt: str = "02:00"
263
+
264
+
265
+ @router.post("/auto-detect/start")
266
+ def auto_detect_start(
267
+ body: AutoDetectStartBody,
268
+ db: Session = Depends(get_db),
269
+ user: User = Depends(require_module_permission("detect", "create")),
270
+ ):
271
+ """Arm the automatic queue. Does not enqueue jobs immediately."""
272
+ _require_dda()
273
+ from .auto_detect import start_schedule
274
+ try:
275
+ return start_schedule(interval_days=body.intervalDays, run_at=body.runAt)
276
+ except ValueError as exc:
277
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
278
+
279
+
280
+ @router.post("/auto-detect/stop")
281
+ def auto_detect_stop(
282
+ db: Session = Depends(get_db),
283
+ user: User = Depends(require_module_permission("detect", "create")),
284
+ ):
285
+ """Stop the automatic queue and cancel queued auto jobs."""
286
+ _require_dda()
287
+ from .auto_detect import stop_schedule
288
+ return stop_schedule()
app/dda/library_routes.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ import shutil
4
+ import uuid
5
+ from datetime import date, datetime
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
10
+ from fastapi.responses import FileResponse
11
+ from sqlalchemy.orm import Session
12
+
13
+ from ..auth import get_or_create_guest_user
14
+ from ..database import get_db
15
+ from ..models import User
16
+ from .dda_auth import current_dda_user
17
+ from .config import (
18
+ ALLOWED_EXTENSIONS,
19
+ IS_DDA_MODE,
20
+ LIBRARY_DIR,
21
+ PREVIEWS_DIR,
22
+ THUMBS_DIR,
23
+ ensure_library_dirs,
24
+ geotiff_io_available,
25
+ max_upload_bytes_for_extension,
26
+ )
27
+ from .geotiff_io import bounds_to_json, inspect_image, raster_to_preview_png
28
+ from .models import DdaVillage, DdaZone, ImageAsset
29
+ from .upload_io import stream_upload_to_file
30
+
31
+ logger = logging.getLogger(__name__)
32
+ router = APIRouter()
33
+
34
+
35
+ def _require_dda():
36
+ if not IS_DDA_MODE:
37
+ raise HTTPException(status_code=404, detail="DDA mode is not enabled on this server")
38
+
39
+
40
+ def _image_to_dict(asset: ImageAsset, zone_name: str = "", village_name: str = "") -> dict:
41
+ return {
42
+ "id": asset.id,
43
+ "uuid": asset.uuid,
44
+ "zoneId": asset.zone_id,
45
+ "zoneName": zone_name,
46
+ "villageId": asset.village_id,
47
+ "villageName": village_name,
48
+ "areaName": asset.area_name or "",
49
+ "year": asset.year,
50
+ "gridId": asset.grid_id or "",
51
+ "captureDate": asset.capture_date.isoformat() if asset.capture_date else None,
52
+ "source": asset.source,
53
+ "format": asset.format,
54
+ "originalFilename": asset.original_filename,
55
+ "hasGeoref": asset.has_georef,
56
+ "crs": asset.crs or "",
57
+ "bounds": json.loads(asset.bounds_json) if asset.bounds_json else None,
58
+ "width": asset.width,
59
+ "height": asset.height,
60
+ "fileSizeBytes": asset.file_size_bytes,
61
+ "thumbUrl": f"/api/dda/images/{asset.id}/thumb" if asset.thumb_path else None,
62
+ "createdAt": asset.created_at.isoformat() if asset.created_at else None,
63
+ }
64
+
65
+
66
+ @router.get("/config")
67
+ def dda_config():
68
+ _require_dda()
69
+ from .config import MAX_GEOTIFF_BYTES, MAX_IMAGE_BYTES, get_library_roots, get_storage_root
70
+ max_gb = MAX_GEOTIFF_BYTES / (1024 ** 3)
71
+ return {
72
+ "mode": "dda",
73
+ "appMode": "dda",
74
+ "maxUploadMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
75
+ "maxUploadGb": round(max_gb, 2),
76
+ "maxGeotiffMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
77
+ "maxGeotiffBytes": MAX_GEOTIFF_BYTES,
78
+ "maxImageMb": MAX_IMAGE_BYTES // (1024 * 1024),
79
+ "maxImageBytes": MAX_IMAGE_BYTES,
80
+ "geotiffEnabled": geotiff_io_available(),
81
+ "allowedExtensions": sorted(ALLOWED_EXTENSIONS),
82
+ "hierarchyMode": "tree",
83
+ "librarySource": "tree_library",
84
+ "storageRoot": str(get_storage_root()),
85
+ "localLibraryPaths": [str(r) for r in get_library_roots()],
86
+ }
87
+
88
+
89
+ @router.get("/hierarchy")
90
+ def get_hierarchy(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
91
+ _require_dda()
92
+ from .tree.tree_service import build_tree
93
+ return {"tree": build_tree(db)}
94
+
95
+
96
+ @router.get("/images")
97
+ def list_images(
98
+ zone_id: Optional[int] = Query(None),
99
+ village_id: Optional[int] = Query(None),
100
+ year: Optional[int] = Query(None),
101
+ q: Optional[str] = Query(None),
102
+ db: Session = Depends(get_db),
103
+ user: User = Depends(current_dda_user),
104
+ ):
105
+ _require_dda()
106
+ query = db.query(ImageAsset).order_by(ImageAsset.created_at.desc())
107
+ if zone_id:
108
+ query = query.filter(ImageAsset.zone_id == zone_id)
109
+ if village_id:
110
+ query = query.filter(ImageAsset.village_id == village_id)
111
+ if year:
112
+ query = query.filter(ImageAsset.year == year)
113
+ if q:
114
+ like = f"%{q.strip()}%"
115
+ query = query.filter(
116
+ (ImageAsset.area_name.ilike(like))
117
+ | (ImageAsset.original_filename.ilike(like))
118
+ | (ImageAsset.grid_id.ilike(like))
119
+ )
120
+ assets = query.limit(200).all()
121
+ zone_map = {z.id: z.name for z in db.query(DdaZone).all()}
122
+ village_map = {v.id: v.name for v in db.query(DdaVillage).all()}
123
+ return [
124
+ _image_to_dict(
125
+ a,
126
+ zone_name=zone_map.get(a.zone_id, ""),
127
+ village_name=village_map.get(a.village_id, ""),
128
+ )
129
+ for a in assets
130
+ ]
131
+
132
+
133
+ @router.get("/images/{image_id}")
134
+ def get_image(image_id: int, db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
135
+ _require_dda()
136
+ asset = db.query(ImageAsset).filter(ImageAsset.id == image_id).first()
137
+ if not asset:
138
+ raise HTTPException(status_code=404, detail="Image not found")
139
+ zone_name = ""
140
+ village_name = ""
141
+ if asset.zone_id:
142
+ z = db.query(DdaZone).filter(DdaZone.id == asset.zone_id).first()
143
+ zone_name = z.name if z else ""
144
+ if asset.village_id:
145
+ v = db.query(DdaVillage).filter(DdaVillage.id == asset.village_id).first()
146
+ village_name = v.name if v else ""
147
+ return _image_to_dict(asset, zone_name=zone_name, village_name=village_name)
148
+
149
+
150
+ @router.get("/images/{image_id}/thumb")
151
+ def get_image_thumb(image_id: int, db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
152
+ _require_dda()
153
+ asset = db.query(ImageAsset).filter(ImageAsset.id == image_id).first()
154
+ if not asset or not asset.thumb_path:
155
+ raise HTTPException(status_code=404, detail="Thumbnail not found")
156
+ path = LIBRARY_DIR.parent / asset.thumb_path
157
+ if not path.exists():
158
+ raise HTTPException(status_code=404, detail="Thumbnail file missing")
159
+ return FileResponse(path, media_type="image/png")
160
+
161
+
162
+ @router.post("/images/upload")
163
+ async def upload_image(
164
+ file: UploadFile = File(...),
165
+ zone_id: int = Form(...),
166
+ village_id: int = Form(...),
167
+ area_name: str = Form(""),
168
+ year: int = Form(...),
169
+ capture_date: str = Form(...),
170
+ source: str = Form("satellite"),
171
+ grid_id: str = Form(""),
172
+ manual_bounds_json: str = Form(""),
173
+ db: Session = Depends(get_db),
174
+ ):
175
+ _require_dda()
176
+ raise HTTPException(
177
+ status_code=410,
178
+ detail="Deprecated: use POST /api/dda/tree/nodes/{node_id}/images/upload",
179
+ )
180
+ ensure_library_dirs()
181
+ user = get_or_create_guest_user(db)
182
+
183
+ zone = db.query(DdaZone).filter(DdaZone.id == zone_id).first()
184
+ village = db.query(DdaVillage).filter(DdaVillage.id == village_id, DdaVillage.zone_id == zone_id).first()
185
+ if not zone or not village:
186
+ raise HTTPException(status_code=400, detail="Invalid zone or village")
187
+
188
+ original = file.filename or "upload"
189
+ ext = Path(original).suffix.lower()
190
+ if ext not in ALLOWED_EXTENSIONS:
191
+ raise HTTPException(
192
+ status_code=400,
193
+ detail=f"Unsupported format. Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
194
+ )
195
+
196
+ try:
197
+ cap_date = date.fromisoformat(capture_date.strip())
198
+ except ValueError:
199
+ raise HTTPException(status_code=400, detail="capture_date must be YYYY-MM-DD")
200
+
201
+ if source not in ("satellite", "drone"):
202
+ raise HTTPException(status_code=400, detail="source must be satellite or drone")
203
+
204
+ asset_uuid = uuid.uuid4().hex
205
+ stored_name = f"{asset_uuid}{ext}"
206
+ dest_file = LIBRARY_DIR / str(year) / stored_name
207
+ max_bytes = max_upload_bytes_for_extension(ext)
208
+ file_size = await stream_upload_to_file(file, dest_file, max_bytes)
209
+
210
+ ingest = inspect_image(dest_file)
211
+ has_georef = ingest.has_georef
212
+ manual_location = manual_bounds_json.strip()
213
+
214
+ if ext in (".tif", ".tiff") and not has_georef and not manual_location:
215
+ dest_file.unlink(missing_ok=True)
216
+ raise HTTPException(
217
+ status_code=400,
218
+ detail="GeoTIFF has no embedded georeferencing. Provide manual_bounds_json (west,south,east,north in WGS84).",
219
+ )
220
+
221
+ thumb_file = THUMBS_DIR / f"{asset_uuid}.png"
222
+ preview_file = PREVIEWS_DIR / f"{asset_uuid}.png"
223
+ try:
224
+ raster_to_preview_png(dest_file, thumb_file, max_side=256)
225
+ raster_to_preview_png(dest_file, preview_file, max_side=1024)
226
+ except Exception as exc:
227
+ dest_file.unlink(missing_ok=True)
228
+ raise HTTPException(status_code=400, detail=f"Could not generate preview: {exc}")
229
+
230
+ rel_file = str(dest_file.relative_to(LIBRARY_DIR.parent))
231
+ rel_thumb = str(thumb_file.relative_to(LIBRARY_DIR.parent))
232
+ rel_preview = str(preview_file.relative_to(LIBRARY_DIR.parent))
233
+
234
+ bounds_json = bounds_to_json(ingest.bounds_wgs84)
235
+ if not bounds_json and manual_location:
236
+ try:
237
+ parts = [float(x.strip()) for x in manual_location.replace("[", "").replace("]", "").split(",")]
238
+ if len(parts) == 4:
239
+ bounds_json = bounds_to_json(tuple(parts))
240
+ except (ValueError, TypeError):
241
+ pass
242
+
243
+ asset = ImageAsset(
244
+ uuid=asset_uuid,
245
+ zone_id=zone_id,
246
+ village_id=village_id,
247
+ area_name=area_name.strip(),
248
+ year=year,
249
+ grid_id=grid_id.strip(),
250
+ capture_date=cap_date,
251
+ source=source,
252
+ format=ingest.format,
253
+ original_filename=original,
254
+ file_path=rel_file,
255
+ thumb_path=rel_thumb,
256
+ preview_path=rel_preview,
257
+ crs=ingest.crs,
258
+ bounds_json=bounds_json,
259
+ has_georef=has_georef or bool(bounds_json),
260
+ manual_location_json=manual_location,
261
+ width=ingest.width,
262
+ height=ingest.height,
263
+ file_size_bytes=file_size,
264
+ uploaded_by=user.id,
265
+ )
266
+ db.add(asset)
267
+ db.commit()
268
+ db.refresh(asset)
269
+
270
+ return {
271
+ "status": "success",
272
+ "image": _image_to_dict(asset, zone_name=zone.name, village_name=village.name),
273
+ }
app/dda/local_library.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Read satellite/drone images from local year-based folders.
3
+
4
+ Folder layout (under library_sources/):
5
+
6
+ library_sources/
7
+ 2024/
8
+ image_a.tif
9
+ 2025/
10
+ image_c.tif
11
+
12
+ Copy files into library_sources/YEAR/ in the project folder (or data/library_sources/ on HF).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import logging
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import List, Optional
21
+ from urllib.parse import quote
22
+
23
+ from fastapi import HTTPException
24
+
25
+ from .config import ALLOWED_EXTENSIONS, LOCAL_THUMB_CACHE, get_library_roots
26
+ from .geotiff_io import inspect_image, raster_to_preview_png, write_placeholder_png
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ @dataclass
32
+ class LocalImageEntry:
33
+ path: str # relative posix path, e.g. 2025/aerial.tif
34
+ root: Path
35
+ year: int
36
+ filename: str
37
+ file_size_bytes: int
38
+
39
+
40
+ def _is_year_dir(name: str) -> bool:
41
+ return len(name) == 4 and name.isdigit() and 1990 <= int(name) <= 2100
42
+
43
+
44
+ def _iter_image_files(root: Path, year: Optional[int] = None):
45
+ if not root.exists():
46
+ return
47
+ if year is not None:
48
+ year_dirs = [root / str(year)] if (root / str(year)).is_dir() else []
49
+ else:
50
+ year_dirs = [d for d in sorted(root.iterdir()) if d.is_dir() and _is_year_dir(d.name)]
51
+ for ydir in year_dirs:
52
+ y = int(ydir.name)
53
+ for path in sorted(ydir.rglob("*")):
54
+ if not path.is_file():
55
+ continue
56
+ ext = path.suffix.lower()
57
+ if ext not in ALLOWED_EXTENSIONS:
58
+ logger.debug("Skipped (extension %s): %s", ext, path)
59
+ continue
60
+ yield root, y, path
61
+
62
+
63
+ def safe_resolve(relative_path: str) -> Path:
64
+ """Resolve a library-relative path across all configured roots."""
65
+ rel = relative_path.replace("\\", "/").lstrip("/")
66
+ if not rel or ".." in rel.split("/"):
67
+ raise HTTPException(status_code=400, detail="Invalid image path")
68
+ for root in get_library_roots():
69
+ full = (root / rel).resolve()
70
+ try:
71
+ full.relative_to(root.resolve())
72
+ except ValueError:
73
+ continue
74
+ if full.is_file() and full.suffix.lower() in ALLOWED_EXTENSIONS:
75
+ return full
76
+ raise HTTPException(status_code=404, detail="Image file not found")
77
+
78
+
79
+ def scan_years() -> List[dict]:
80
+ """List year folders and image counts (merged across all roots)."""
81
+ ensure_root()
82
+ counts: dict[int, int] = {}
83
+ for root in get_library_roots():
84
+ if not root.exists():
85
+ continue
86
+ for entry in sorted(root.iterdir()):
87
+ if not entry.is_dir() or not _is_year_dir(entry.name):
88
+ continue
89
+ y = int(entry.name)
90
+ n = sum(1 for _ in _iter_image_files(root, year=y))
91
+ counts[y] = counts.get(y, 0) + n
92
+ return [{"year": y, "imageCount": counts[y]} for y in sorted(counts)]
93
+
94
+
95
+ def scan_images(year: Optional[int] = None, query: Optional[str] = None) -> List[LocalImageEntry]:
96
+ """Scan all library roots for images."""
97
+ ensure_root()
98
+ results: List[LocalImageEntry] = []
99
+ seen_paths: set[str] = set()
100
+ q = (query or "").strip().lower()
101
+
102
+ for root in get_library_roots():
103
+ for r, y, path in _iter_image_files(root, year=year):
104
+ rel = path.relative_to(r).as_posix()
105
+ if rel in seen_paths:
106
+ continue
107
+ if q and q not in rel.lower():
108
+ continue
109
+ try:
110
+ size = path.stat().st_size
111
+ except OSError:
112
+ continue
113
+ seen_paths.add(rel)
114
+ results.append(
115
+ LocalImageEntry(
116
+ path=rel,
117
+ root=r,
118
+ year=y,
119
+ filename=path.name,
120
+ file_size_bytes=size,
121
+ )
122
+ )
123
+ return results
124
+
125
+
126
+ def entry_to_dict(entry: LocalImageEntry, include_meta: bool = False) -> dict:
127
+ encoded_path = quote(entry.path, safe="/")
128
+ out = {
129
+ "path": entry.path,
130
+ "year": entry.year,
131
+ "filename": entry.filename,
132
+ "fileSizeBytes": entry.file_size_bytes,
133
+ "thumbUrl": f"/api/dda/local/thumb?path={encoded_path}",
134
+ "source": "local_folder",
135
+ "rootPath": str(entry.root),
136
+ }
137
+ if include_meta:
138
+ try:
139
+ full = safe_resolve(entry.path)
140
+ meta = inspect_image(full)
141
+ out.update({
142
+ "width": meta.width,
143
+ "height": meta.height,
144
+ "hasGeoref": meta.has_georef,
145
+ "format": meta.format,
146
+ })
147
+ except Exception as exc:
148
+ logger.warning("Metadata read failed for %s: %s", entry.path, exc)
149
+ return out
150
+
151
+
152
+ def thumb_cache_path(relative_path: str) -> Path:
153
+ key = hashlib.sha256(relative_path.encode("utf-8")).hexdigest()[:32]
154
+ return LOCAL_THUMB_CACHE / f"{key}.png"
155
+
156
+
157
+ def get_or_build_thumb(relative_path: str, max_side: int = 256) -> Path:
158
+ full = safe_resolve(relative_path)
159
+ cache = thumb_cache_path(relative_path)
160
+ try:
161
+ if cache.exists() and cache.stat().st_mtime >= full.stat().st_mtime:
162
+ return cache
163
+ cache.parent.mkdir(parents=True, exist_ok=True)
164
+ raster_to_preview_png(full, cache, max_side=max_side)
165
+ return cache
166
+ except Exception as exc:
167
+ logger.warning("Thumb build failed for %s: %s", relative_path, exc)
168
+ write_placeholder_png(cache, Path(relative_path).name, max_side)
169
+ return cache
170
+
171
+
172
+ def ensure_root() -> None:
173
+ for root in get_library_roots():
174
+ root.mkdir(parents=True, exist_ok=True)
175
+ LOCAL_THUMB_CACHE.mkdir(parents=True, exist_ok=True)
176
+
177
+
178
+ def library_debug_info() -> dict:
179
+ """Diagnostics for troubleshooting missing images."""
180
+ roots_info = []
181
+ for root in get_library_roots():
182
+ info = {
183
+ "path": str(root),
184
+ "exists": root.exists(),
185
+ "years": [],
186
+ "otherFiles": [],
187
+ }
188
+ if root.exists():
189
+ for entry in sorted(root.iterdir()):
190
+ if entry.is_dir() and _is_year_dir(entry.name):
191
+ files = []
192
+ for _, _, p in _iter_image_files(root, year=int(entry.name)):
193
+ files.append({"name": p.name, "size": p.stat().st_size})
194
+ info["years"].append({"year": entry.name, "files": files})
195
+ elif entry.is_file() and entry.name not in ("README.md", ".gitkeep"):
196
+ info["otherFiles"].append(entry.name)
197
+ roots_info.append(info)
198
+ return {
199
+ "roots": roots_info,
200
+ "allowedExtensions": sorted(ALLOWED_EXTENSIONS),
201
+ "totalImages": len(scan_images()),
202
+ }
app/dda/local_routes.py ADDED
@@ -0,0 +1,485 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Slim local helpers: thumb, resolve, detect β€” backed by tree library."""
2
+ import logging
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ from urllib.parse import unquote
6
+
7
+ from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request
8
+ from fastapi.responses import FileResponse, Response
9
+ from PIL import Image
10
+ from pydantic import BaseModel
11
+ from sqlalchemy.orm import Session
12
+
13
+ from ..database import get_db
14
+ from ..models import User
15
+ from .dda_auth import current_dda_user
16
+ from .detect_service import run_detection_and_save
17
+ from .geotiff_io import load_rgb_pil
18
+ from .rbac.permissions import require_module_permission, user_can
19
+
20
+ from .config import (
21
+ IS_DDA_MODE,
22
+ MAX_GEOTIFF_BYTES,
23
+ MAX_IMAGE_BYTES,
24
+ geotiff_io_available,
25
+ get_detection_max_side,
26
+ get_library_roots,
27
+ get_storage_root,
28
+ is_hf_hosted,
29
+ )
30
+ from .tree.image_service import (
31
+ count_library_images,
32
+ delete_library_image,
33
+ get_or_build_preview,
34
+ get_or_build_thumb,
35
+ list_all_images,
36
+ )
37
+ from .tree.path_service import resolve_file
38
+ from .tree.tree_service import build_tree
39
+
40
+ logger = logging.getLogger(__name__)
41
+ router = APIRouter()
42
+
43
+
44
+ class LocalImageDeleteBody(BaseModel):
45
+ path: str
46
+
47
+
48
+ def _normalize_library_path(path: str) -> str:
49
+ return unquote((path or "").replace("\\", "/").strip().lstrip("/"))
50
+
51
+
52
+ def _delete_image_by_path(db: Session, user: User, rel: str) -> dict:
53
+ if not user_can(user, db, "library", "delete"):
54
+ raise HTTPException(status_code=403, detail="Missing 'delete' permission on 'library'.")
55
+ if not rel:
56
+ raise HTTPException(status_code=400, detail="path is required")
57
+ result = delete_library_image(
58
+ db, relative_path=rel, action_by=user.email or str(user.id),
59
+ )
60
+ return {"ok": True, **result}
61
+
62
+
63
+ def _require_dda():
64
+ if not IS_DDA_MODE:
65
+ raise HTTPException(
66
+ status_code=404,
67
+ detail="DDA mode is not enabled. On HF dev Space this is automatic; locally use: python run.py",
68
+ )
69
+
70
+
71
+ def safe_resolve(relative_path: str) -> Path:
72
+ try:
73
+ return resolve_file(relative_path)
74
+ except FileNotFoundError:
75
+ raise HTTPException(status_code=404, detail="Image file not found")
76
+ except ValueError as exc:
77
+ raise HTTPException(status_code=400, detail=str(exc))
78
+
79
+
80
+ @router.get("/local/config")
81
+ def local_library_config(user: User = Depends(current_dda_user)):
82
+ _require_dda()
83
+ storage = str(get_storage_root())
84
+ hosted = is_hf_hosted()
85
+ instructions = (
86
+ "Select a node in the tree, choose image type, and upload. "
87
+ "Files are stored under {zone}/{area}/…/Images/ in persistent storage."
88
+ if hosted
89
+ else "Use the tree library to organize images by zone/area/year, or upload via the form."
90
+ )
91
+ return {
92
+ "source": "tree_library",
93
+ "isHosted": hosted,
94
+ "spaceId": __import__("os").environ.get("SPACE_ID", ""),
95
+ "appMode": "dda" if IS_DDA_MODE else "legacy",
96
+ "rootPath": storage,
97
+ "rootPaths": [str(r) for r in get_library_roots()],
98
+ "writablePath": storage,
99
+ "storageRoot": storage,
100
+ "instructions": instructions,
101
+ "geotiffEnabled": geotiff_io_available(),
102
+ "detectionMaxSide": get_detection_max_side(),
103
+ "maxGeotiffMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
104
+ "maxGeotiffBytes": MAX_GEOTIFF_BYTES,
105
+ "maxImageMb": MAX_IMAGE_BYTES // (1024 * 1024),
106
+ "maxImageBytes": MAX_IMAGE_BYTES,
107
+ "maxUploadGb": round(MAX_GEOTIFF_BYTES / (1024 ** 3), 2),
108
+ }
109
+
110
+
111
+ @router.get("/local/images")
112
+ def local_images(
113
+ node_id: Optional[int] = Query(None),
114
+ q: Optional[str] = Query(None),
115
+ limit: Optional[int] = Query(None, ge=1, le=500),
116
+ offset: int = Query(0, ge=0),
117
+ db: Session = Depends(get_db),
118
+ user: User = Depends(current_dda_user),
119
+ ):
120
+ _require_dda()
121
+ return list_all_images(
122
+ db,
123
+ node_id=node_id,
124
+ query=q,
125
+ include_bounds=False,
126
+ slim=True,
127
+ limit=limit,
128
+ offset=offset,
129
+ )
130
+
131
+
132
+ @router.get("/local/area-groups")
133
+ def local_area_groups(
134
+ node_id: Optional[int] = Query(None),
135
+ db: Session = Depends(get_db),
136
+ user: User = Depends(current_dda_user),
137
+ ):
138
+ """Images covering the same place, with oldest β†’ Before and newest β†’ After."""
139
+ _require_dda()
140
+ from .tree.area_groups import filter_area_groups_for_node, load_cached_area_groups
141
+ from .tree.tree_service import get_node_or_404
142
+
143
+ payload = load_cached_area_groups(db)
144
+ if node_id:
145
+ node = get_node_or_404(db, node_id)
146
+ return filter_area_groups_for_node(payload, node)
147
+ return payload
148
+
149
+
150
+ @router.get("/local/thumb")
151
+ def local_thumb(path: str = Query(...), user: User = Depends(current_dda_user)):
152
+ _require_dda()
153
+ path = _normalize_library_path(path)
154
+ try:
155
+ thumb = get_or_build_thumb(path)
156
+ return FileResponse(
157
+ thumb,
158
+ media_type="image/png",
159
+ headers={"Cache-Control": "private, max-age=86400"},
160
+ )
161
+ except Exception as exc:
162
+ logger.warning("Thumb endpoint fallback for %s: %s", path, exc)
163
+ from .config import LOCAL_THUMB_CACHE
164
+ from .geotiff_io import write_placeholder_png
165
+
166
+ cache = LOCAL_THUMB_CACHE / "fallback.png"
167
+ write_placeholder_png(cache, Path(path).name)
168
+ return FileResponse(cache, media_type="image/png")
169
+
170
+
171
+ @router.get("/local/preview")
172
+ def local_preview(
173
+ path: str = Query(...),
174
+ max: int = Query(1600, ge=256, le=4096),
175
+ user: User = Depends(current_dda_user),
176
+ ):
177
+ """Full-size preview for library image viewer (cached PNG)."""
178
+ _require_dda()
179
+ path = _normalize_library_path(path)
180
+ try:
181
+ preview = get_or_build_preview(path, max_side=max)
182
+ return FileResponse(
183
+ preview,
184
+ media_type="image/png",
185
+ headers={"Cache-Control": "private, max-age=86400"},
186
+ )
187
+ except FileNotFoundError:
188
+ raise HTTPException(status_code=404, detail="Image file not found")
189
+ except ValueError as exc:
190
+ raise HTTPException(status_code=400, detail=str(exc))
191
+ except Exception as exc:
192
+ logger.warning("Preview endpoint failed for %s: %s", path, exc)
193
+ raise HTTPException(status_code=500, detail=f"Could not build preview: {exc}") from exc
194
+
195
+
196
+ @router.get("/local/map-basemaps")
197
+ def local_map_basemaps(user: User = Depends(current_dda_user)):
198
+ """XYZ basemap presets (OSM, Google Satellite, custom URL)."""
199
+ _require_dda()
200
+ from .map_tiles import BASEMAP_PRESETS
201
+ return {"basemaps": BASEMAP_PRESETS}
202
+
203
+
204
+ @router.get("/local/map-info")
205
+ def local_map_info(
206
+ path: str = Query(...),
207
+ db: Session = Depends(get_db),
208
+ user: User = Depends(current_dda_user),
209
+ ):
210
+ """WGS84 bounds + tile URL for overlaying a library raster on a web map."""
211
+ _require_dda()
212
+ path = _normalize_library_path(path)
213
+ try:
214
+ from .map_tiles import build_map_info
215
+ return build_map_info(path, db=db)
216
+ except FileNotFoundError:
217
+ raise HTTPException(status_code=404, detail="Image file not found")
218
+ except ValueError as exc:
219
+ raise HTTPException(status_code=400, detail=str(exc))
220
+ except Exception as exc:
221
+ logger.warning("map-info failed for %s: %s", path, exc)
222
+ raise HTTPException(status_code=500, detail=f"Could not read georef: {exc}") from exc
223
+
224
+
225
+ @router.get("/local/map-tiles/{z}/{x}/{y}.png")
226
+ def local_map_tile(
227
+ z: int,
228
+ x: int,
229
+ y: int,
230
+ path: str = Query(...),
231
+ user: User = Depends(current_dda_user),
232
+ ):
233
+ """XYZ tile of a georeferenced GeoTIFF warped to Web Mercator."""
234
+ _require_dda()
235
+ path = _normalize_library_path(path)
236
+ try:
237
+ from .map_tiles import render_xyz_tile
238
+ png = render_xyz_tile(path, z, x, y)
239
+ return Response(
240
+ content=png,
241
+ media_type="image/png",
242
+ headers={"Cache-Control": "public, max-age=86400"},
243
+ )
244
+ except FileNotFoundError:
245
+ raise HTTPException(status_code=404, detail="Image file not found")
246
+ except ValueError as exc:
247
+ raise HTTPException(status_code=400, detail=str(exc))
248
+
249
+
250
+ @router.get("/local/map-overview")
251
+ def local_map_overview(
252
+ path: str = Query(...),
253
+ max: int = Query(2048, ge=256, le=4096),
254
+ user: User = Depends(current_dda_user),
255
+ ):
256
+ """Web-Mercator overview PNG for distortion-free overlay on XYZ basemaps."""
257
+ _require_dda()
258
+ path = _normalize_library_path(path)
259
+ try:
260
+ from .map_tiles import render_mercator_overview
261
+ png = render_mercator_overview(path, max_side=max)
262
+ return Response(
263
+ content=png,
264
+ media_type="image/png",
265
+ headers={"Cache-Control": "public, max-age=86400"},
266
+ )
267
+ except FileNotFoundError:
268
+ raise HTTPException(status_code=404, detail="Image file not found")
269
+ except ValueError as exc:
270
+ raise HTTPException(status_code=400, detail=str(exc))
271
+ except Exception as exc:
272
+ logger.warning("map-overview failed for %s: %s", path, exc)
273
+ raise HTTPException(status_code=500, detail=f"Could not build overlay: {exc}") from exc
274
+
275
+
276
+ @router.post("/local/images/delete")
277
+ def local_delete_image_post(
278
+ body: LocalImageDeleteBody,
279
+ db: Session = Depends(get_db),
280
+ user: User = Depends(current_dda_user),
281
+ ):
282
+ """Delete a library image (preferred β€” works reliably across browsers)."""
283
+ _require_dda()
284
+ return _delete_image_by_path(db, user, _normalize_library_path(body.path))
285
+
286
+
287
+ @router.delete("/local/images")
288
+ def local_delete_image(
289
+ body: Optional[LocalImageDeleteBody] = None,
290
+ path: Optional[str] = Query(None),
291
+ db: Session = Depends(get_db),
292
+ user: User = Depends(current_dda_user),
293
+ ):
294
+ _require_dda()
295
+ rel = _normalize_library_path(body.path if body and body.path else (path or ""))
296
+ return _delete_image_by_path(db, user, rel)
297
+
298
+
299
+ @router.post("/local/rescan")
300
+ def local_rescan(db: Session = Depends(get_db), user: User = Depends(require_module_permission("library", "edit"))):
301
+ _require_dda()
302
+ from .tree.sync_service import sync_from_filesystem
303
+
304
+ try:
305
+ sync_stats = sync_from_filesystem(db, check_orphans=True)
306
+ except Exception as exc:
307
+ logger.exception("Filesystem rescan failed")
308
+ raise HTTPException(status_code=500, detail=f"Rescan failed: {exc}") from exc
309
+ tree = build_tree(db)
310
+ return {
311
+ "ok": True,
312
+ "tree": tree,
313
+ "totalImages": count_library_images(db),
314
+ "storageRoot": str(get_storage_root()),
315
+ "sync": sync_stats,
316
+ }
317
+
318
+
319
+ @router.post("/detect/preflight")
320
+ async def detect_preflight(
321
+ base_path: str = Form(...),
322
+ comparison_path: str = Form(...),
323
+ roi: Optional[str] = Form(None),
324
+ db: Session = Depends(get_db),
325
+ user: User = Depends(require_module_permission("detect", "create")),
326
+ ):
327
+ """Check a library image pair (CRS/GSD/bands/georef/overlap) without running detection.
328
+
329
+ Lets the UI ask "is this pair suitable?" up front and show the user any
330
+ warnings before committing to a multi-minute detection run. ``roi``
331
+ (optional) narrows the overlap check to the selected crop window.
332
+ """
333
+ _require_dda()
334
+ base_norm = base_path.replace("\\", "/").strip()
335
+ comp_norm = comparison_path.replace("\\", "/").strip()
336
+ if not base_norm or not comp_norm:
337
+ raise HTTPException(status_code=400, detail="base_path and comparison_path are required")
338
+ if base_norm == comp_norm:
339
+ raise HTTPException(status_code=400, detail="Base and comparison images must be different")
340
+
341
+ try:
342
+ base_file = safe_resolve(base_norm)
343
+ comp_file = safe_resolve(comp_norm)
344
+ except HTTPException:
345
+ raise
346
+ except Exception as exc:
347
+ raise HTTPException(status_code=400, detail=f"Invalid library path: {exc}") from exc
348
+
349
+ roi_dict = None
350
+ if roi and roi.strip():
351
+ import json as _json
352
+
353
+ from .geotiff_io import parse_roi
354
+ try:
355
+ roi_dict = parse_roi(_json.loads(roi))
356
+ except (ValueError, _json.JSONDecodeError) as exc:
357
+ raise HTTPException(status_code=400, detail=f"Invalid roi: {exc}") from exc
358
+
359
+ from .preflight import run_preflight_checks
360
+ preflight = run_preflight_checks(db, base_file, comp_file, base_norm, comp_norm, roi=roi_dict)
361
+ return {
362
+ "hardFail": preflight.hard_fail,
363
+ "failReason": preflight.fail_reason,
364
+ "warnings": preflight.warnings,
365
+ }
366
+
367
+
368
+ @router.post("/detect/from-library")
369
+ async def detect_from_library(
370
+ request: Request,
371
+ base_path: str = Form(...),
372
+ comparison_path: str = Form(...),
373
+ method: str = Form("AI-Based Deep Learning"),
374
+ title: str = Form("Untitled run"),
375
+ zone: str = Form(""),
376
+ village: str = Form(""),
377
+ enable_registration: bool = Form(True),
378
+ enable_normalization: bool = Form(True),
379
+ detection_sensitivity: float = Form(0.5),
380
+ min_region_area: Optional[int] = Form(150),
381
+ notify_email: Optional[str] = Form(None),
382
+ roi: Optional[str] = Form(None),
383
+ shape_mode: str = Form("polygon"),
384
+ db: Session = Depends(get_db),
385
+ user: User = Depends(require_module_permission("detect", "create")),
386
+ ):
387
+ """Run change detection on two library images by relative path.
388
+
389
+ ``roi`` (optional) is a JSON ``{"x","y","w","h"}`` fractional window in
390
+ [0,1]; when present, only that cropped region is detected.
391
+ """
392
+ _require_dda()
393
+ base_norm = base_path.replace("\\", "/").strip()
394
+ comp_norm = comparison_path.replace("\\", "/").strip()
395
+ if not base_norm or not comp_norm:
396
+ raise HTTPException(status_code=400, detail="base_path and comparison_path are required")
397
+ if base_norm == comp_norm:
398
+ raise HTTPException(status_code=400, detail="Base and comparison images must be different")
399
+
400
+ try:
401
+ base_file = safe_resolve(base_norm)
402
+ comp_file = safe_resolve(comp_norm)
403
+ except HTTPException:
404
+ raise
405
+ except Exception as exc:
406
+ raise HTTPException(status_code=400, detail=f"Invalid library path: {exc}") from exc
407
+
408
+ roi_dict = None
409
+ if roi and roi.strip():
410
+ import json as _json
411
+
412
+ from .geotiff_io import parse_roi
413
+ try:
414
+ roi_dict = parse_roi(_json.loads(roi))
415
+ except (ValueError, _json.JSONDecodeError) as exc:
416
+ raise HTTPException(status_code=400, detail=f"Invalid roi: {exc}") from exc
417
+
418
+ from .preflight import run_preflight_checks
419
+ preflight = run_preflight_checks(db, base_file, comp_file, base_norm, comp_norm, roi=roi_dict)
420
+ if preflight.hard_fail:
421
+ raise HTTPException(status_code=400, detail=preflight.fail_reason)
422
+
423
+ try:
424
+ from ..detection_config import get_load_max_side
425
+ from .geotiff_io import load_rgb_roi
426
+ load_side = get_load_max_side()
427
+ if roi_dict:
428
+ before_pil = load_rgb_roi(base_file, roi_dict, max_side=load_side)
429
+ after_pil = load_rgb_roi(comp_file, roi_dict, max_side=load_side)
430
+ else:
431
+ before_pil = load_rgb_pil(base_file, max_side=load_side)
432
+ after_pil = load_rgb_pil(comp_file, max_side=load_side)
433
+ except RuntimeError as exc:
434
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
435
+ except Exception as exc:
436
+ raise HTTPException(status_code=400, detail=f"Could not load images: {exc}") from exc
437
+
438
+ gsd_debug = None
439
+ try:
440
+ from .geotiff_io import harmonize_pair_gsd
441
+ before_pil, after_pil, gsd_debug = harmonize_pair_gsd(
442
+ before_pil, after_pil, base_file, comp_file)
443
+ except Exception as exc:
444
+ logger.warning("GSD harmonization skipped: %s", exc)
445
+
446
+ if before_pil.size != after_pil.size:
447
+ after_pil = after_pil.resize(before_pil.size, Image.Resampling.LANCZOS)
448
+
449
+ if title == "Untitled run":
450
+ title = f"{Path(base_norm).name} vs {Path(comp_norm).name}"
451
+
452
+ try:
453
+ # Use fullres load cap (up to DETECTION_FULLRES_MAX_SIDE), not the
454
+ # legacy 4096 downscaled cap β€” otherwise max-res config is ignored.
455
+ detect_max = load_side
456
+ logger.info(
457
+ "Library detection max_size=%d (loaded %s)",
458
+ detect_max, before_pil.size,
459
+ )
460
+ return run_detection_and_save(
461
+ db,
462
+ before_pil,
463
+ after_pil,
464
+ method=method,
465
+ title=title,
466
+ zone=zone,
467
+ village=village,
468
+ enable_registration=enable_registration,
469
+ enable_normalization=enable_normalization,
470
+ detection_sensitivity=detection_sensitivity,
471
+ min_region_area=min_region_area,
472
+ notify_email=notify_email,
473
+ max_size=detect_max,
474
+ geo_bounds_path=base_file,
475
+ comparison_file=comp_file,
476
+ base_path=base_norm,
477
+ comparison_path=comp_norm,
478
+ user_id=user.id,
479
+ gsd_debug=gsd_debug,
480
+ shape_mode=shape_mode,
481
+ preflight_warnings=preflight.warnings,
482
+ )
483
+ except Exception as exc:
484
+ logger.exception("Library detection failed for %s vs %s", base_norm, comp_norm)
485
+ raise HTTPException(status_code=500, detail=f"Detection failed: {exc}") from exc
app/dda/logs_routes.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API for the application Logs page."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException, Query
5
+ from fastapi.responses import FileResponse, PlainTextResponse
6
+
7
+ from ..models import User
8
+ from .applog import clear_logs, log_file_path, query_logs
9
+ from .rbac.permissions import require_module_permission
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ def _require_dda():
15
+ from .config import IS_DDA_MODE
16
+ if not IS_DDA_MODE:
17
+ raise HTTPException(status_code=404, detail="DDA mode is not enabled")
18
+
19
+
20
+ @router.get("/logs")
21
+ def list_logs(
22
+ level: str = Query(""),
23
+ q: str = Query(""),
24
+ logger: str = Query(""),
25
+ limit: int = Query(200, ge=1, le=1000),
26
+ offset: int = Query(0, ge=0),
27
+ user: User = Depends(require_module_permission("logs", "view")),
28
+ ):
29
+ _require_dda()
30
+ return query_logs(level=level, q=q, logger_name=logger, limit=limit, offset=offset)
31
+
32
+
33
+ @router.get("/logs/download")
34
+ def download_logs(user: User = Depends(require_module_permission("logs", "view"))):
35
+ _require_dda()
36
+ path = log_file_path()
37
+ if path.exists() and path.stat().st_size:
38
+ return FileResponse(path, filename="app.log", media_type="text/plain")
39
+ data = query_logs(limit=1000, offset=0)
40
+ body = "\n".join(
41
+ f"{row['ts']} {row['level']} [{row['logger']}] {row['message']}"
42
+ for row in reversed(data["logs"])
43
+ ) or "(no log entries yet)"
44
+ return PlainTextResponse(body, headers={"Content-Disposition": 'attachment; filename="app.log"'})
45
+
46
+
47
+ @router.delete("/logs")
48
+ def api_clear_logs(user: User = Depends(require_module_permission("logs", "delete"))):
49
+ _require_dda()
50
+ return clear_logs()
app/dda/map_tiles.py ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """XYZ map tiles for georeferenced library rasters (QGIS-style overlay)."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import io
6
+ import logging
7
+ import math
8
+ from pathlib import Path
9
+ from typing import Any, Optional, Tuple
10
+ from urllib.parse import quote
11
+
12
+ from PIL import Image
13
+
14
+ from .config import LOCAL_THUMB_CACHE
15
+ from .geotiff_io import inspect_image, read_georef
16
+ from .tree.path_service import resolve_file
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ TILE_SIZE = 256
21
+ MAX_ZOOM = 22
22
+ WEB_MERCATOR_ORIGIN = 20037508.342789244
23
+ _EMPTY_PNG: Optional[bytes] = None
24
+
25
+ BoundsWGS84 = Tuple[float, float, float, float]
26
+
27
+
28
+ def empty_tile_png() -> bytes:
29
+ global _EMPTY_PNG
30
+ if _EMPTY_PNG is None:
31
+ buf = io.BytesIO()
32
+ Image.new("RGBA", (TILE_SIZE, TILE_SIZE), (0, 0, 0, 0)).save(buf, format="PNG")
33
+ _EMPTY_PNG = buf.getvalue()
34
+ return _EMPTY_PNG
35
+
36
+
37
+ def xyz_bounds_3857(z: int, x: int, y: int) -> Tuple[float, float, float, float]:
38
+ """Web Mercator bounds of an XYZ tile (minx, miny, maxx, maxy)."""
39
+ n = 2 ** int(z)
40
+ tile_w = (2.0 * WEB_MERCATOR_ORIGIN) / n
41
+ minx = -WEB_MERCATOR_ORIGIN + x * tile_w
42
+ maxx = -WEB_MERCATOR_ORIGIN + (x + 1) * tile_w
43
+ maxy = WEB_MERCATOR_ORIGIN - y * tile_w
44
+ miny = WEB_MERCATOR_ORIGIN - (y + 1) * tile_w
45
+ return minx, miny, maxx, maxy
46
+
47
+
48
+ def _rects_intersect(a: Tuple[float, float, float, float],
49
+ b: Tuple[float, float, float, float]) -> bool:
50
+ return not (a[2] <= b[0] or a[0] >= b[2] or a[3] <= b[1] or a[1] >= b[3])
51
+
52
+
53
+ def _max_native_zoom(bounds: Optional[BoundsWGS84], width: int, height: int) -> int:
54
+ """Leaflet maxNativeZoom from raster GSD (approx, WGS84)."""
55
+ if not bounds or width <= 0 or height <= 0:
56
+ return 18
57
+ west, south, east, north = bounds
58
+ lat = max(-85.0, min(85.0, (south + north) / 2.0))
59
+ # metres per pixel along the longer raster side
60
+ m_per_deg_lat = 111_320.0
61
+ m_per_deg_lng = 111_320.0 * max(0.05, math.cos(math.radians(lat)))
62
+ gsd = min(
63
+ abs(east - west) * m_per_deg_lng / max(width, 1),
64
+ abs(north - south) * m_per_deg_lat / max(height, 1),
65
+ )
66
+ gsd = max(gsd, 0.02)
67
+ mpp_z0 = 156543.03392804097 * math.cos(math.radians(lat))
68
+ z = int(round(math.log2(max(mpp_z0 / gsd, 1.0))))
69
+ return max(12, min(MAX_ZOOM, z))
70
+
71
+
72
+ def _bounds_dict(bounds: Optional[BoundsWGS84]) -> Optional[dict]:
73
+ if not bounds:
74
+ return None
75
+ west, south, east, north = bounds
76
+ return {
77
+ "west": west,
78
+ "south": south,
79
+ "east": east,
80
+ "north": north,
81
+ "latLng": [[south, west], [north, east]],
82
+ }
83
+
84
+
85
+ def build_map_info(relative_path: str, db=None) -> dict:
86
+ """Metadata the browser needs to overlay a library raster on XYZ basemaps."""
87
+ rel = (relative_path or "").replace("\\", "/").strip().lstrip("/")
88
+ full = resolve_file(rel)
89
+ meta = inspect_image(full)
90
+ georef = read_georef(full)
91
+ bounds = meta.bounds_wgs84
92
+ source = meta.georef_source or "none"
93
+ can_tile = bool(georef and georef.crs and bounds)
94
+
95
+ if not bounds and db is not None:
96
+ from .geo_regions import parse_bounds
97
+ from .tree.image_service import get_image_by_file_path
98
+
99
+ img = get_image_by_file_path(db, rel)
100
+ if img and img.bounds_json:
101
+ parsed = parse_bounds(img.bounds_json)
102
+ if parsed:
103
+ bounds = parsed
104
+ source = "manual"
105
+ can_tile = bool(georef and georef.crs)
106
+
107
+ encoded = quote(rel, safe="/")
108
+ merc_bounds = _mercator_aligned_wgs84(full) if can_tile else None
109
+ overlay_bounds = merc_bounds or bounds
110
+ return {
111
+ "path": rel,
112
+ "filename": Path(rel).name,
113
+ "hasGeoref": bool(overlay_bounds),
114
+ "canTile": can_tile,
115
+ "georefSource": source if overlay_bounds else "none",
116
+ "crs": str(georef.crs) if (georef and georef.crs) else (meta.crs or ""),
117
+ "bounds": _bounds_dict(overlay_bounds),
118
+ "width": meta.width,
119
+ "height": meta.height,
120
+ "maxNativeZoom": _max_native_zoom(overlay_bounds, meta.width, meta.height),
121
+ "tileUrl": f"/api/dda/local/map-tiles/{{z}}/{{x}}/{{y}}.png?path={encoded}",
122
+ "overviewUrl": (
123
+ f"/api/dda/local/map-overview?path={encoded}&max=2048" if can_tile else None
124
+ ),
125
+ "previewUrl": f"/api/dda/local/preview?path={encoded}&max=2048",
126
+ }
127
+
128
+
129
+ def _tile_cache_path(relative_path: str, src: Path, z: int, x: int, y: int) -> Path:
130
+ key = hashlib.sha256(relative_path.encode("utf-8")).hexdigest()[:24]
131
+ mtime = int(src.stat().st_mtime)
132
+ return (
133
+ LOCAL_THUMB_CACHE
134
+ / "map-tiles"
135
+ / f"{key}_{mtime}"
136
+ / str(z)
137
+ / str(x)
138
+ / f"{y}.png"
139
+ )
140
+
141
+
142
+ def _to_rgba(data, mask) -> Image.Image:
143
+ import numpy as np
144
+
145
+ count = int(data.shape[0])
146
+ if count == 1:
147
+ rgb = np.stack([data[0], data[0], data[0]])
148
+ else:
149
+ rgb = data[:3]
150
+ rgb = np.transpose(rgb, (1, 2, 0)).astype("float32")
151
+ if data.dtype != np.uint8 or float(np.nanmax(rgb) if rgb.size else 0) > 255:
152
+ lo, hi = np.percentile(rgb, (2, 98)) if rgb.size else (0, 1)
153
+ rgb = np.clip((rgb - lo) / max(hi - lo, 1e-6), 0, 1) * 255
154
+ rgb_u8 = np.clip(rgb, 0, 255).astype("uint8")
155
+ if mask is None:
156
+ alpha = np.full(rgb_u8.shape[:2], 255, dtype="uint8")
157
+ else:
158
+ alpha = np.asarray(mask)
159
+ if alpha.ndim == 3:
160
+ alpha = alpha[0]
161
+ if alpha.shape != rgb_u8.shape[:2]:
162
+ alpha = np.full(rgb_u8.shape[:2], 255, dtype="uint8")
163
+ else:
164
+ alpha = np.where(alpha > 0, 255, 0).astype("uint8")
165
+ rgba = np.dstack([rgb_u8, alpha])
166
+ return Image.fromarray(rgba, mode="RGBA")
167
+
168
+
169
+ def render_xyz_tile(relative_path: str, z: int, x: int, y: int) -> bytes:
170
+ """Warp one Web-Mercator XYZ tile from a georeferenced raster."""
171
+ if z < 0 or z > MAX_ZOOM:
172
+ return empty_tile_png()
173
+ n = 2 ** z
174
+ if x < 0 or y < 0 or x >= n or y >= n:
175
+ return empty_tile_png()
176
+
177
+ rel = (relative_path or "").replace("\\", "/").strip().lstrip("/")
178
+ src_path = resolve_file(rel)
179
+ cache = _tile_cache_path(rel, src_path, z, x, y)
180
+ try:
181
+ if cache.exists() and cache.stat().st_mtime >= src_path.stat().st_mtime:
182
+ return cache.read_bytes()
183
+ except OSError:
184
+ pass
185
+
186
+ png = _render_tile_uncached(src_path, z, x, y)
187
+ if png == empty_tile_png():
188
+ return png
189
+ try:
190
+ cache.parent.mkdir(parents=True, exist_ok=True)
191
+ tmp = cache.with_suffix(".tmp.png")
192
+ tmp.write_bytes(png)
193
+ tmp.replace(cache)
194
+ except OSError as exc:
195
+ logger.debug("Map tile cache write skipped: %s", exc)
196
+ return png
197
+
198
+
199
+ def _mercator_aligned_wgs84(src_path: Path) -> Optional[BoundsWGS84]:
200
+ """WGS84 box of the raster's Web-Mercator envelope (matches Google XYZ tiles)."""
201
+ try:
202
+ import rasterio
203
+ from rasterio.crs import CRS
204
+ from rasterio.warp import transform_bounds
205
+ except ImportError:
206
+ return None
207
+ try:
208
+ with rasterio.open(src_path) as src:
209
+ if src.crs is None:
210
+ return None
211
+ web = CRS.from_epsg(3857)
212
+ wgs = CRS.from_epsg(4326)
213
+ crs = _crs_2d(src.crs)
214
+ minx, miny, maxx, maxy = transform_bounds(crs, web, *src.bounds, densify_pts=21)
215
+ west, south, east, north = transform_bounds(web, wgs, minx, miny, maxx, maxy)
216
+ return (float(west), float(south), float(east), float(north))
217
+ except Exception as exc:
218
+ logger.debug("mercator-aligned bounds failed for %s: %s", src_path.name, exc)
219
+ return None
220
+
221
+
222
+ def render_mercator_overview(relative_path: str, max_side: int = 2048) -> bytes:
223
+ """North-up Web-Mercator PNG so Leaflet ImageOverlay matches Google tiles."""
224
+ rel = (relative_path or "").replace("\\", "/").strip().lstrip("/")
225
+ src_path = resolve_file(rel)
226
+ max_side = max(256, min(int(max_side), 4096))
227
+ key = hashlib.sha256(f"{rel}:overview:{max_side}".encode("utf-8")).hexdigest()[:24]
228
+ mtime = int(src_path.stat().st_mtime)
229
+ cache = LOCAL_THUMB_CACHE / "map-overview" / f"{key}_{mtime}_{max_side}.png"
230
+ try:
231
+ if cache.exists() and cache.stat().st_mtime >= src_path.stat().st_mtime:
232
+ return cache.read_bytes()
233
+ except OSError:
234
+ pass
235
+ png = _render_mercator_overview_uncached(src_path, max_side)
236
+ try:
237
+ cache.parent.mkdir(parents=True, exist_ok=True)
238
+ tmp = cache.with_suffix(".tmp.png")
239
+ tmp.write_bytes(png)
240
+ tmp.replace(cache)
241
+ except OSError as exc:
242
+ logger.debug("Overview cache write skipped: %s", exc)
243
+ return png
244
+
245
+
246
+ def _render_mercator_overview_uncached(src_path: Path, max_side: int) -> bytes:
247
+ import numpy as np
248
+ import rasterio
249
+ from rasterio.crs import CRS
250
+ from rasterio.enums import Resampling
251
+ from rasterio.transform import from_bounds as affine_from_bounds
252
+ from rasterio.warp import reproject, transform_bounds
253
+
254
+ web = CRS.from_epsg(3857)
255
+ with rasterio.open(src_path) as src:
256
+ if src.crs is None:
257
+ raise ValueError("raster has no CRS")
258
+ src_crs = _crs_2d(src.crs)
259
+ minx, miny, maxx, maxy = transform_bounds(src_crs, web, *src.bounds, densify_pts=21)
260
+ width_m = max(maxx - minx, 1e-6)
261
+ height_m = max(maxy - miny, 1e-6)
262
+ if width_m >= height_m:
263
+ out_w = max_side
264
+ out_h = max(1, int(round(max_side * height_m / width_m)))
265
+ else:
266
+ out_h = max_side
267
+ out_w = max(1, int(round(max_side * width_m / height_m)))
268
+ count = min(3, src.count)
269
+ indexes = list(range(1, count + 1))
270
+ dst_transform = affine_from_bounds(minx, miny, maxx, maxy, out_w, out_h)
271
+ src_nodata = src.nodata
272
+ if src_nodata is not None and 0 <= float(src_nodata) <= 255:
273
+ src_nodata = None
274
+ sentinel = -1.0
275
+ dst = np.full((count, out_h, out_w), sentinel, dtype=np.float32)
276
+ reproject(
277
+ source=rasterio.band(src, indexes),
278
+ destination=dst,
279
+ src_transform=src.transform,
280
+ src_crs=src_crs,
281
+ dst_transform=dst_transform,
282
+ dst_crs=web,
283
+ resampling=Resampling.bilinear,
284
+ src_nodata=src_nodata,
285
+ dst_nodata=sentinel,
286
+ )
287
+ valid = dst[0] != sentinel
288
+ if not np.any(valid):
289
+ return empty_tile_png()
290
+ rgb = dst.copy()
291
+ rgb[:, ~valid] = 0
292
+ mask = np.where(valid, 255, 0).astype(np.uint8)
293
+ img = _to_rgba(rgb, mask)
294
+ buf = io.BytesIO()
295
+ img.save(buf, format="PNG", optimize=False)
296
+ return buf.getvalue()
297
+
298
+
299
+ def _crs_2d(crs):
300
+ try:
301
+ if hasattr(crs, "to_2d"):
302
+ two = crs.to_2d()
303
+ if two:
304
+ return two
305
+ except Exception:
306
+ pass
307
+ return crs
308
+
309
+
310
+ def _render_tile_uncached(src_path: Path, z: int, x: int, y: int) -> bytes:
311
+ try:
312
+ import numpy as np
313
+ import rasterio
314
+ from rasterio.crs import CRS
315
+ from rasterio.enums import Resampling
316
+ from rasterio.transform import from_bounds as affine_from_bounds
317
+ from rasterio.warp import reproject, transform_bounds
318
+ except ImportError:
319
+ logger.warning("rasterio missing β€” cannot render map tiles")
320
+ return empty_tile_png()
321
+
322
+ minx, miny, maxx, maxy = xyz_bounds_3857(z, x, y)
323
+ web_merc = CRS.from_epsg(3857)
324
+
325
+ try:
326
+ with rasterio.open(src_path) as src:
327
+ if src.crs is None:
328
+ return empty_tile_png()
329
+ src_crs = _crs_2d(src.crs)
330
+ try:
331
+ src_3857 = transform_bounds(src_crs, web_merc, *src.bounds, densify_pts=21)
332
+ except Exception:
333
+ try:
334
+ src_3857 = transform_bounds(src.crs, web_merc, *src.bounds, densify_pts=21)
335
+ src_crs = src.crs
336
+ except Exception:
337
+ return empty_tile_png()
338
+ if not _rects_intersect(src_3857, (minx, miny, maxx, maxy)):
339
+ return empty_tile_png()
340
+
341
+ count = min(3, src.count)
342
+ indexes = list(range(1, count + 1))
343
+ dst_transform = affine_from_bounds(minx, miny, maxx, maxy, TILE_SIZE, TILE_SIZE)
344
+ # RGB 0 is valid (shadows/black roofs). Only treat a true nodata
345
+ # that is outside 0–255, otherwise GDAL punches holes in the overlay.
346
+ src_nodata = src.nodata
347
+ if src_nodata is not None and 0 <= float(src_nodata) <= 255:
348
+ src_nodata = None
349
+ sentinel = -1.0
350
+ dst = np.full((count, TILE_SIZE, TILE_SIZE), sentinel, dtype=np.float32)
351
+ reproject(
352
+ source=rasterio.band(src, indexes),
353
+ destination=dst,
354
+ src_transform=src.transform,
355
+ src_crs=src_crs,
356
+ dst_transform=dst_transform,
357
+ dst_crs=web_merc,
358
+ resampling=Resampling.bilinear,
359
+ src_nodata=src_nodata,
360
+ dst_nodata=sentinel,
361
+ )
362
+ valid = dst[0] != sentinel
363
+ if not np.any(valid):
364
+ return empty_tile_png()
365
+ rgb = dst.copy()
366
+ rgb[:, ~valid] = 0
367
+ mask = np.where(valid, 255, 0).astype(np.uint8)
368
+ img = _to_rgba(rgb, mask)
369
+ buf = io.BytesIO()
370
+ img.save(buf, format="PNG", optimize=False)
371
+ return buf.getvalue()
372
+ except Exception as exc:
373
+ logger.warning("Map tile %s z=%s x=%s y=%s failed: %s", src_path.name, z, x, y, exc)
374
+ return empty_tile_png()
375
+
376
+
377
+ BASEMAP_PRESETS: list[dict[str, Any]] = [
378
+ {
379
+ "id": "osm",
380
+ "label": "OpenStreetMap",
381
+ "url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
382
+ "attribution": "&copy; OpenStreetMap contributors",
383
+ "maxZoom": 19,
384
+ "subdomains": "",
385
+ },
386
+ {
387
+ "id": "google-satellite",
388
+ "label": "Google Satellite",
389
+ "url": "https://mt{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}",
390
+ "attribution": "Imagery &copy; Google",
391
+ "maxZoom": 21,
392
+ "subdomains": "0123",
393
+ },
394
+ {
395
+ "id": "google-hybrid",
396
+ "label": "Google Hybrid",
397
+ "url": "https://mt{s}.google.com/vt/lyrs=y&x={x}&y={y}&z={z}",
398
+ "attribution": "Imagery &copy; Google",
399
+ "maxZoom": 21,
400
+ "subdomains": "0123",
401
+ },
402
+ {
403
+ "id": "esri-imagery",
404
+ "label": "Esri World Imagery",
405
+ "url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
406
+ "attribution": "Tiles &copy; Esri",
407
+ "maxZoom": 19,
408
+ "subdomains": "",
409
+ },
410
+ {
411
+ "id": "custom-xyz",
412
+ "label": "Custom XYZ…",
413
+ "url": "",
414
+ "attribution": "",
415
+ "maxZoom": 22,
416
+ "subdomains": "",
417
+ },
418
+ ]
app/dda/masters_routes.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Zone / District and Village / Location master data."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException
7
+ from pydantic import BaseModel, Field
8
+ from sqlalchemy.orm import Session
9
+
10
+ from ..database import get_db
11
+ from ..models import User
12
+ from .models import DdaVillage, DdaZone, ImageAsset
13
+ from .rbac.permissions import require_module_permission
14
+ from .seed import seed_delhi_hierarchy
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ def _require_dda():
20
+ from .config import IS_DDA_MODE
21
+ if not IS_DDA_MODE:
22
+ raise HTTPException(status_code=404, detail="DDA mode is not enabled")
23
+
24
+
25
+ def _log_masters_action(db: Session, action: str, user: User, new_value: Optional[dict] = None) -> None:
26
+ try:
27
+ from .dda_auth import get_user_role
28
+ from .tree.audit_service import log_action
29
+ log_action(
30
+ db, action, action_by=getattr(user, "email", "") or "",
31
+ role=get_user_role(db, user), module="masters", new_value=new_value,
32
+ )
33
+ db.commit()
34
+ except Exception:
35
+ import logging
36
+ logging.getLogger(__name__).exception("Failed to write audit log for masters action %s", action)
37
+
38
+
39
+ class ZoneBody(BaseModel):
40
+ name: str = Field(..., min_length=1, max_length=128)
41
+ mode: str = Field("admin", max_length=32)
42
+
43
+
44
+ class VillageBody(BaseModel):
45
+ name: str = Field(..., min_length=1, max_length=128)
46
+ zone_id: int
47
+
48
+
49
+ def _zone_dict(zone: DdaZone, village_count: int = 0) -> dict:
50
+ return {
51
+ "id": zone.id,
52
+ "name": zone.name,
53
+ "mode": zone.mode or "admin",
54
+ "villageCount": village_count,
55
+ "createdAt": zone.created_at.isoformat() if zone.created_at else None,
56
+ }
57
+
58
+
59
+ def _village_dict(village: DdaVillage, zone: Optional[DdaZone] = None) -> dict:
60
+ z = zone or village.zone
61
+ return {
62
+ "id": village.id,
63
+ "name": village.name,
64
+ "zoneId": village.zone_id,
65
+ "zoneName": z.name if z else "",
66
+ "createdAt": village.created_at.isoformat() if village.created_at else None,
67
+ }
68
+
69
+
70
+ @router.get("/masters/zones")
71
+ def list_zones(
72
+ db: Session = Depends(get_db),
73
+ user: User = Depends(require_module_permission("masters", "view")),
74
+ ):
75
+ _require_dda()
76
+ seed_delhi_hierarchy(db)
77
+ zones = db.query(DdaZone).order_by(DdaZone.name).all()
78
+ counts = {}
79
+ for v in db.query(DdaVillage).all():
80
+ counts[v.zone_id] = counts.get(v.zone_id, 0) + 1
81
+ return {"zones": [_zone_dict(z, counts.get(z.id, 0)) for z in zones]}
82
+
83
+
84
+ @router.post("/masters/zones")
85
+ def create_zone(
86
+ body: ZoneBody,
87
+ db: Session = Depends(get_db),
88
+ user: User = Depends(require_module_permission("masters", "create")),
89
+ ):
90
+ _require_dda()
91
+ name = body.name.strip()
92
+ if db.query(DdaZone).filter(DdaZone.name == name).first():
93
+ raise HTTPException(status_code=409, detail="A zone with this name already exists.")
94
+ zone = DdaZone(name=name, mode=(body.mode or "admin").strip() or "admin")
95
+ db.add(zone)
96
+ db.commit()
97
+ db.refresh(zone)
98
+ _log_masters_action(db, "create_zone", user, {"zone_id": zone.id, "name": zone.name})
99
+ return {"ok": True, "zone": _zone_dict(zone, 0)}
100
+
101
+
102
+ @router.put("/masters/zones/{zone_id}")
103
+ def update_zone(
104
+ zone_id: int,
105
+ body: ZoneBody,
106
+ db: Session = Depends(get_db),
107
+ user: User = Depends(require_module_permission("masters", "edit")),
108
+ ):
109
+ _require_dda()
110
+ zone = db.query(DdaZone).filter(DdaZone.id == zone_id).first()
111
+ if not zone:
112
+ raise HTTPException(status_code=404, detail="Zone not found")
113
+ name = body.name.strip()
114
+ clash = db.query(DdaZone).filter(DdaZone.name == name, DdaZone.id != zone_id).first()
115
+ if clash:
116
+ raise HTTPException(status_code=409, detail="A zone with this name already exists.")
117
+ zone.name = name
118
+ zone.mode = (body.mode or zone.mode or "admin").strip()
119
+ db.commit()
120
+ _log_masters_action(db, "update_zone", user, {"zone_id": zone.id, "name": zone.name})
121
+ count = db.query(DdaVillage).filter(DdaVillage.zone_id == zone.id).count()
122
+ return {"ok": True, "zone": _zone_dict(zone, count)}
123
+
124
+
125
+ @router.delete("/masters/zones/{zone_id}")
126
+ def delete_zone(
127
+ zone_id: int,
128
+ db: Session = Depends(get_db),
129
+ user: User = Depends(require_module_permission("masters", "delete")),
130
+ ):
131
+ _require_dda()
132
+ zone = db.query(DdaZone).filter(DdaZone.id == zone_id).first()
133
+ if not zone:
134
+ raise HTTPException(status_code=404, detail="Zone not found")
135
+ in_use = db.query(ImageAsset).filter(ImageAsset.zone_id == zone_id).count()
136
+ if in_use:
137
+ raise HTTPException(status_code=409, detail="This zone is used by library images and cannot be deleted.")
138
+ zone_name = zone.name
139
+ db.query(DdaVillage).filter(DdaVillage.zone_id == zone_id).delete(synchronize_session=False)
140
+ db.delete(zone)
141
+ db.commit()
142
+ _log_masters_action(db, "delete_zone", user, {"zone_id": zone_id, "name": zone_name})
143
+ return {"ok": True}
144
+
145
+
146
+ @router.get("/masters/villages")
147
+ def list_villages(
148
+ zone_id: Optional[int] = None,
149
+ db: Session = Depends(get_db),
150
+ user: User = Depends(require_module_permission("masters", "view")),
151
+ ):
152
+ _require_dda()
153
+ seed_delhi_hierarchy(db)
154
+ q = db.query(DdaVillage).order_by(DdaVillage.name)
155
+ if zone_id:
156
+ q = q.filter(DdaVillage.zone_id == zone_id)
157
+ villages = q.all()
158
+ zones = {z.id: z for z in db.query(DdaZone).all()}
159
+ return {
160
+ "villages": [_village_dict(v, zones.get(v.zone_id)) for v in villages],
161
+ "zones": [_zone_dict(z) for z in sorted(zones.values(), key=lambda z: z.name)],
162
+ }
163
+
164
+
165
+ @router.post("/masters/villages")
166
+ def create_village(
167
+ body: VillageBody,
168
+ db: Session = Depends(get_db),
169
+ user: User = Depends(require_module_permission("masters", "create")),
170
+ ):
171
+ _require_dda()
172
+ zone = db.query(DdaZone).filter(DdaZone.id == body.zone_id).first()
173
+ if not zone:
174
+ raise HTTPException(status_code=404, detail="Zone not found")
175
+ name = body.name.strip()
176
+ clash = (
177
+ db.query(DdaVillage)
178
+ .filter(DdaVillage.zone_id == zone.id, DdaVillage.name == name)
179
+ .first()
180
+ )
181
+ if clash:
182
+ raise HTTPException(status_code=409, detail="That location already exists in this zone.")
183
+ village = DdaVillage(zone_id=zone.id, name=name)
184
+ db.add(village)
185
+ db.commit()
186
+ db.refresh(village)
187
+ _log_masters_action(db, "create_village", user, {"village_id": village.id, "name": village.name, "zone_id": zone.id})
188
+ return {"ok": True, "village": _village_dict(village, zone)}
189
+
190
+
191
+ @router.put("/masters/villages/{village_id}")
192
+ def update_village(
193
+ village_id: int,
194
+ body: VillageBody,
195
+ db: Session = Depends(get_db),
196
+ user: User = Depends(require_module_permission("masters", "edit")),
197
+ ):
198
+ _require_dda()
199
+ village = db.query(DdaVillage).filter(DdaVillage.id == village_id).first()
200
+ if not village:
201
+ raise HTTPException(status_code=404, detail="Location not found")
202
+ zone = db.query(DdaZone).filter(DdaZone.id == body.zone_id).first()
203
+ if not zone:
204
+ raise HTTPException(status_code=404, detail="Zone not found")
205
+ name = body.name.strip()
206
+ clash = (
207
+ db.query(DdaVillage)
208
+ .filter(
209
+ DdaVillage.zone_id == zone.id,
210
+ DdaVillage.name == name,
211
+ DdaVillage.id != village_id,
212
+ )
213
+ .first()
214
+ )
215
+ if clash:
216
+ raise HTTPException(status_code=409, detail="That location already exists in this zone.")
217
+ village.name = name
218
+ village.zone_id = zone.id
219
+ db.commit()
220
+ _log_masters_action(db, "update_village", user, {"village_id": village.id, "name": village.name, "zone_id": zone.id})
221
+ return {"ok": True, "village": _village_dict(village, zone)}
222
+
223
+
224
+ @router.delete("/masters/villages/{village_id}")
225
+ def delete_village(
226
+ village_id: int,
227
+ db: Session = Depends(get_db),
228
+ user: User = Depends(require_module_permission("masters", "delete")),
229
+ ):
230
+ _require_dda()
231
+ village = db.query(DdaVillage).filter(DdaVillage.id == village_id).first()
232
+ if not village:
233
+ raise HTTPException(status_code=404, detail="Location not found")
234
+ in_use = db.query(ImageAsset).filter(ImageAsset.village_id == village_id).count()
235
+ if in_use:
236
+ raise HTTPException(status_code=409, detail="This location is used by library images and cannot be deleted.")
237
+ village_name = village.name
238
+ db.delete(village)
239
+ db.commit()
240
+ _log_masters_action(db, "delete_village", user, {"village_id": village_id, "name": village_name})
241
+ return {"ok": True}
app/dda/models.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from datetime import datetime, timezone
3
+
4
+ from sqlalchemy import Boolean, Column, Date, DateTime, Float, ForeignKey, Integer, String, Text
5
+ from sqlalchemy.orm import relationship
6
+
7
+ from ..database import Base
8
+
9
+
10
+ def _utcnow():
11
+ return datetime.now(timezone.utc)
12
+
13
+
14
+ class DdaZone(Base):
15
+ __tablename__ = "dda_zones"
16
+
17
+ id = Column(Integer, primary_key=True, index=True)
18
+ name = Column(String(128), unique=True, nullable=False, index=True)
19
+ mode = Column(String(32), default="admin") # admin | grid_parent
20
+ created_at = Column(DateTime, default=_utcnow)
21
+
22
+ villages = relationship("DdaVillage", back_populates="zone", cascade="all, delete-orphan")
23
+
24
+
25
+ class DdaVillage(Base):
26
+ __tablename__ = "dda_villages"
27
+
28
+ id = Column(Integer, primary_key=True, index=True)
29
+ zone_id = Column(Integer, ForeignKey("dda_zones.id"), nullable=False, index=True)
30
+ name = Column(String(128), nullable=False, index=True)
31
+ created_at = Column(DateTime, default=_utcnow)
32
+
33
+ zone = relationship("DdaZone", back_populates="villages")
34
+ images = relationship("ImageAsset", back_populates="village")
35
+
36
+
37
+ class ImageAsset(Base):
38
+ """Centralized satellite / drone image (FR-01, FR-02)."""
39
+ __tablename__ = "dda_image_assets"
40
+
41
+ id = Column(Integer, primary_key=True, index=True)
42
+ uuid = Column(String(36), unique=True, nullable=False, default=lambda: str(uuid.uuid4()), index=True)
43
+ zone_id = Column(Integer, ForeignKey("dda_zones.id"), nullable=True, index=True)
44
+ village_id = Column(Integer, ForeignKey("dda_villages.id"), nullable=True, index=True)
45
+ area_name = Column(String(128), default="")
46
+ year = Column(Integer, nullable=True, index=True)
47
+ grid_id = Column(String(64), default="", index=True)
48
+
49
+ capture_date = Column(Date, nullable=True)
50
+ source = Column(String(32), default="satellite") # satellite | drone
51
+ format = Column(String(32), default="geotiff")
52
+ original_filename = Column(String(255), default="")
53
+
54
+ file_path = Column(String(512), nullable=False)
55
+ thumb_path = Column(String(512), default="")
56
+ preview_path = Column(String(512), default="")
57
+
58
+ crs = Column(String(64), default="")
59
+ bounds_json = Column(Text, default="") # WGS84 [west, south, east, north]
60
+ has_georef = Column(Boolean, default=False)
61
+ manual_location_json = Column(Text, default="")
62
+
63
+ width = Column(Integer, default=0)
64
+ height = Column(Integer, default=0)
65
+ file_size_bytes = Column(Integer, default=0)
66
+
67
+ uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=True)
68
+ created_at = Column(DateTime, default=_utcnow)
69
+
70
+ zone = relationship("DdaZone")
71
+ village = relationship("DdaVillage", back_populates="images")
72
+
73
+
74
+ class AutoDetectSettings(Base):
75
+ """Singleton (id=1): automatic queue is opt-in and does not start on launch."""
76
+ __tablename__ = "dda_auto_detect_settings"
77
+
78
+ id = Column(Integer, primary_key=True)
79
+ running = Column(Boolean, default=False)
80
+ interval_days = Column(Integer, default=10)
81
+ run_at = Column(String(8), default="02:00")
82
+ next_run_at = Column(DateTime, nullable=True)
83
+ updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
84
+
85
+
86
+ class AutoPairSchedule(Base):
87
+ """Periodic automatic detection for an identified same-area pair."""
88
+ __tablename__ = "dda_auto_pair_schedules"
89
+
90
+ id = Column(Integer, primary_key=True, index=True)
91
+ pair_key = Column(String(64), unique=True, nullable=False, index=True)
92
+ group_label = Column(String(255), default="")
93
+ before_path = Column(String(512), nullable=False)
94
+ after_path = Column(String(512), nullable=False)
95
+ interval_days = Column(Integer, default=10)
96
+ enabled = Column(Boolean, default=True)
97
+ last_enqueued_at = Column(DateTime, nullable=True)
98
+ last_completed_at = Column(DateTime, nullable=True)
99
+ last_job_id = Column(Integer, nullable=True)
100
+ last_run_id = Column(Integer, nullable=True)
101
+ last_error = Column(Text, default="")
102
+ created_at = Column(DateTime, default=_utcnow)
103
+ updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
104
+
105
+
106
+ class DetectionJob(Base):
107
+ """Async change detection job (FR-04 skeleton)."""
108
+ __tablename__ = "dda_detection_jobs"
109
+
110
+ id = Column(Integer, primary_key=True, index=True)
111
+ status = Column(String(32), default="queued", index=True) # queued|running|completed|failed
112
+ base_image_id = Column(Integer, ForeignKey("dda_image_assets.id"), nullable=True)
113
+ comparison_image_id = Column(Integer, ForeignKey("dda_image_assets.id"), nullable=True)
114
+ method = Column(String(64), default="AI-Based Deep Learning")
115
+ params_json = Column(Text, default="{}")
116
+ run_id = Column(Integer, ForeignKey("detection_runs.id"), nullable=True)
117
+ error_message = Column(Text, default="")
118
+ notify_email = Column(String(255), default="")
119
+ created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
120
+ started_at = Column(DateTime, nullable=True)
121
+ completed_at = Column(DateTime, nullable=True)
122
+ created_at = Column(DateTime, default=_utcnow)
123
+
124
+
125
+ class RegionReview(Base):
126
+ """Per-region human review state (FR-08)."""
127
+ __tablename__ = "dda_region_reviews"
128
+
129
+ id = Column(Integer, primary_key=True, index=True)
130
+ run_id = Column(Integer, ForeignKey("detection_runs.id"), nullable=False, index=True)
131
+ region_id = Column(Integer, nullable=False, index=True)
132
+ status = Column(String(32), default="pending") # pending|confirmed|false_positive|submitted
133
+ reviewer_id = Column(Integer, ForeignKey("users.id"), nullable=True)
134
+ notes = Column(Text, default="")
135
+ reviewed_at = Column(DateTime, nullable=True)
136
+ submitted_at = Column(DateTime, nullable=True)
137
+ created_at = Column(DateTime, default=_utcnow)
app/dda/pair_align.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Before/after pair pre-alignment and input-quality guard.
2
+
3
+ This module sits *in front of* the detection engine and answers one question
4
+ that the engine currently assumes rather than checks: **are these two images
5
+ actually a comparable, co-registered pair of the same ground?**
6
+
7
+ Three real-world failure modes motivated it (all observed on drone GeoTIFFs):
8
+
9
+ 1. **Identical inputs** β€” the same file uploaded twice. The engine dutifully
10
+ reports "no change"; the operator reads that as a detection failure. We
11
+ catch it up front with a cheap content hash.
12
+ 2. **Different pixel grids** β€” two georeferenced rasters of the same place at
13
+ different GSD / extent / band count. Naive ``cv2.resize`` to a common shape
14
+ *stretches* rather than *aligns*, so static ground shows up as change. We
15
+ reproject BOTH onto one common grid (their geographic overlap, at the finer
16
+ resolution) so a pixel means the same ground in both.
17
+ 3. **Un-registerable frames** β€” raw (non-orthorectified) frames shot from
18
+ different viewpoints. No 2D transform aligns them (parallax). We can't fix
19
+ that here, but we *measure* it and emit an honest warning instead of a
20
+ silent garbage mask.
21
+
22
+ The engine's detection path is untouched; callers opt in. A CLI is provided so
23
+ the alignment of any pair can be checked without running a full job:
24
+
25
+ python -m app.dda.pair_align before.tif after.tif [--out DIR]
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import logging
31
+ from dataclasses import asdict, dataclass
32
+ from pathlib import Path
33
+ from typing import Optional, Tuple
34
+
35
+ import cv2
36
+ import numpy as np
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Result container
43
+ # ---------------------------------------------------------------------------
44
+
45
+ @dataclass
46
+ class PairAlignResult:
47
+ """Outcome of aligning + assessing a before/after pair.
48
+
49
+ ``status`` is the single field a caller should branch on:
50
+ * ``"identical"`` β€” inputs are the same image; there is nothing to detect.
51
+ * ``"ok"`` β€” aligned and alignment quality is adequate.
52
+ * ``"low_quality"`` β€” aligned but residual misalignment is high; detection
53
+ will be unreliable (likely raw/un-orthorectified
54
+ frames). Results should be shown with a warning.
55
+ * ``"error"`` β€” alignment could not be attempted (see ``message``).
56
+ """
57
+ status: str
58
+ message: str
59
+ ncc: float = 0.0
60
+ method: str = "none"
61
+ grid: Optional[Tuple[int, int]] = None # (width, height) of aligned output
62
+ overlap_frac: float = 0.0
63
+
64
+ def to_json(self) -> dict:
65
+ d = asdict(self)
66
+ if self.grid is not None:
67
+ d["grid"] = list(self.grid)
68
+ return d
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # 1. Identical-input guard
73
+ # ---------------------------------------------------------------------------
74
+
75
+ def content_hash(arr: np.ndarray) -> str:
76
+ """Stable MD5 of raw pixel bytes β€” cheap identical-pair detector."""
77
+ return hashlib.md5(np.ascontiguousarray(arr).tobytes()).hexdigest()
78
+
79
+
80
+ def are_identical(before: np.ndarray, after: np.ndarray) -> bool:
81
+ """True when the two arrays are pixel-for-pixel identical."""
82
+ if before.shape != after.shape:
83
+ return False
84
+ return content_hash(before) == content_hash(after)
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # 2. Geographic grid alignment (the correct alignment for georeferenced pairs)
89
+ # ---------------------------------------------------------------------------
90
+
91
+ def geo_align_pair(before_path: Path, after_path: Path
92
+ ) -> Optional[Tuple[np.ndarray, np.ndarray, float]]:
93
+ """Reproject both rasters onto one common grid over their geographic overlap.
94
+
95
+ Returns ``(before_rgb, after_rgb, overlap_frac)`` as HxWx3 uint8 arrays on
96
+ an identical grid, or ``None`` when either input is not georeferenced or the
97
+ footprints do not overlap. ``overlap_frac`` is the overlap area as a
98
+ fraction of the *after* footprint β€” a low value means the two rasters barely
99
+ cover the same ground.
100
+ """
101
+ try:
102
+ import rasterio
103
+ from rasterio.transform import from_origin
104
+ from rasterio.warp import Resampling, reproject
105
+ except ImportError:
106
+ logger.warning("rasterio unavailable β€” cannot geo-align pair")
107
+ return None
108
+
109
+ try:
110
+ b = rasterio.open(str(before_path))
111
+ a = rasterio.open(str(after_path))
112
+ except Exception as exc:
113
+ logger.warning("geo_align_pair: could not open rasters: %s", exc)
114
+ return None
115
+
116
+ with b, a:
117
+ if b.crs is None or a.crs is None:
118
+ return None
119
+ # Work in the after image's CRS; transform the before bounds into it.
120
+ try:
121
+ from rasterio.warp import transform_bounds
122
+ b_in_a = transform_bounds(b.crs, a.crs, *b.bounds)
123
+ except Exception:
124
+ b_in_a = b.bounds
125
+
126
+ left = max(b_in_a[0], a.bounds.left)
127
+ bottom = max(b_in_a[1], a.bounds.bottom)
128
+ right = min(b_in_a[2], a.bounds.right)
129
+ top = min(b_in_a[3], a.bounds.top)
130
+ if right <= left or top <= bottom:
131
+ return None # no geographic overlap
132
+
133
+ a_area = (a.bounds.right - a.bounds.left) * (a.bounds.top - a.bounds.bottom)
134
+ overlap_frac = ((right - left) * (top - bottom)) / max(a_area, 1e-9)
135
+
136
+ res_x, res_y = a.res
137
+ width = max(1, int(round((right - left) / res_x)))
138
+ height = max(1, int(round((top - bottom) / res_y)))
139
+ dst_transform = from_origin(left, top, res_x, res_y)
140
+
141
+ def _warp(src) -> np.ndarray:
142
+ out = np.zeros((3, height, width), dtype=np.uint8)
143
+ for i in range(3): # first three bands = RGB; ignore any alpha
144
+ reproject(
145
+ source=rasterio.band(src, i + 1),
146
+ destination=out[i],
147
+ src_transform=src.transform, src_crs=src.crs,
148
+ dst_transform=dst_transform, dst_crs=a.crs,
149
+ resampling=Resampling.bilinear,
150
+ )
151
+ return np.ascontiguousarray(out.transpose(1, 2, 0))
152
+
153
+ return _warp(b), _warp(a), float(overlap_frac)
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # 3. Alignment-quality assessment
158
+ # ---------------------------------------------------------------------------
159
+
160
+ def _ncc(gray1: np.ndarray, gray2: np.ndarray) -> float:
161
+ """Normalized cross-correlation of two same-size grayscale images."""
162
+ a = gray1.astype(np.float32).ravel()
163
+ b = gray2.astype(np.float32).ravel()
164
+ if a.size != b.size or a.size < 64:
165
+ return 0.0
166
+ c = np.corrcoef(a, b)[0, 1]
167
+ return float(c) if np.isfinite(c) else 0.0
168
+
169
+
170
+ def assess_alignment(before: np.ndarray, after: np.ndarray,
171
+ ncc_ok: float = 0.45) -> Tuple[str, float]:
172
+ """Judge whether an already-same-grid pair is well enough aligned to detect.
173
+
174
+ Uses global NCC on the shared region. A well-registered VHR pair β€” even with
175
+ genuine change present β€” keeps most of the static scene correlated, so NCC
176
+ stays high; two frames off by rotation/parallax collapse toward zero. Returns
177
+ ``(status, ncc)`` where status is ``"ok"`` or ``"low_quality"``.
178
+ """
179
+ if before.shape != after.shape:
180
+ after = cv2.resize(after, (before.shape[1], before.shape[0]))
181
+ g1 = cv2.cvtColor(before, cv2.COLOR_RGB2GRAY)
182
+ g2 = cv2.cvtColor(after, cv2.COLOR_RGB2GRAY)
183
+ ncc = _ncc(g1, g2)
184
+ return ("ok" if ncc >= ncc_ok else "low_quality"), ncc
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Top-level entry point
189
+ # ---------------------------------------------------------------------------
190
+
191
+ def prepare_pair(before_path: Path, after_path: Path
192
+ ) -> Tuple[Optional[np.ndarray], Optional[np.ndarray], PairAlignResult]:
193
+ """Load, guard, and align a pair for detection.
194
+
195
+ Returns ``(before_rgb, after_rgb, result)``. When ``result.status`` is
196
+ ``"identical"`` or ``"error"`` the arrays may be ``None`` β€” callers should
197
+ surface ``result.message`` to the operator instead of running detection.
198
+ On ``"ok"``/``"low_quality"`` the arrays are on a common grid and ready for
199
+ the engine (detection still runs on ``"low_quality"``, just with a warning).
200
+ """
201
+ before_path, after_path = Path(before_path), Path(after_path)
202
+
203
+ aligned = geo_align_pair(before_path, after_path)
204
+ if aligned is not None:
205
+ before, after, overlap = aligned
206
+ method = "geo_reproject"
207
+ else:
208
+ # No georeferencing: fall back to a plain load + resize-to-match so the
209
+ # engine's own SIFT/ORB/ECC registration can still take over downstream.
210
+ from .geotiff_io import load_rgb_pil
211
+ before = np.array(load_rgb_pil(before_path))[:, :, :3]
212
+ after = np.array(load_rgb_pil(after_path))[:, :, :3]
213
+ if before.shape != after.shape:
214
+ before = cv2.resize(before, (after.shape[1], after.shape[0]))
215
+ overlap = 1.0
216
+ method = "resize_only"
217
+
218
+ if are_identical(before, after):
219
+ return None, None, PairAlignResult(
220
+ status="identical",
221
+ message=("Before and after are the same image (identical pixels). "
222
+ "There is no change to detect - check that two different "
223
+ "dates were selected."),
224
+ method=method, grid=(before.shape[1], before.shape[0]),
225
+ overlap_frac=overlap,
226
+ )
227
+
228
+ status, ncc = assess_alignment(before, after)
229
+ grid = (before.shape[1], before.shape[0])
230
+ if status == "ok":
231
+ msg = f"Pair aligned via {method} (NCC={ncc:.2f}, overlap={overlap:.0%})."
232
+ else:
233
+ msg = (f"Pair is poorly aligned (NCC={ncc:.2f}). The two images do not "
234
+ "register - likely raw drone frames from different viewpoints "
235
+ "rather than orthomosaics on a common grid. Detection will be "
236
+ "unreliable; export both dates as north-up orthomosaics on the "
237
+ "same grid for accurate results.")
238
+ return before, after, PairAlignResult(
239
+ status=status, message=msg, ncc=round(ncc, 4),
240
+ method=method, grid=grid, overlap_frac=round(overlap, 4),
241
+ )
242
+
243
+
244
+ def _main(argv=None) -> int:
245
+ import argparse
246
+
247
+ from PIL import Image
248
+
249
+ ap = argparse.ArgumentParser(description="Check before/after pair alignment.")
250
+ ap.add_argument("before")
251
+ ap.add_argument("after")
252
+ ap.add_argument("--out", help="dir to write aligned before/after PNGs")
253
+ args = ap.parse_args(argv)
254
+
255
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
256
+ before, after, res = prepare_pair(Path(args.before), Path(args.after))
257
+ print(f"status : {res.status}")
258
+ print(f"message : {res.message}")
259
+ print(f"method : {res.method}")
260
+ print(f"ncc : {res.ncc}")
261
+ print(f"overlap : {res.overlap_frac}")
262
+ print(f"grid (w x h): {res.grid}")
263
+ if args.out and before is not None:
264
+ out = Path(args.out)
265
+ out.mkdir(parents=True, exist_ok=True)
266
+ Image.fromarray(before).save(out / "aligned_before.png")
267
+ Image.fromarray(after).save(out / "aligned_after.png")
268
+ print(f"wrote aligned PNGs to {out}")
269
+ return 0 if res.status in ("ok", "low_quality") else 1
270
+
271
+
272
+ if __name__ == "__main__":
273
+ raise SystemExit(_main())
app/dda/preflight.py ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Preflight image-pair validation: CRS, GSD, RGB bands, overlap, georeferencing.
2
+
3
+ Runs once per detection request, right after library paths are resolved but
4
+ before either image is pixel-decoded β€” all checks here are cheap header-only
5
+ reads, so a genuinely unusable pair (unreadable file, unsupported band
6
+ layout, no ground overlap) is rejected in milliseconds instead of after
7
+ minutes of registration + model inference. Lesser issues (grayscale input,
8
+ manual-bounds-only georeferencing, GSD mismatch, weak overlap) don't block
9
+ detection β€” they're collected as warnings and surfaced on the result instead.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import List, Optional, Tuple
16
+
17
+ from sqlalchemy.orm import Session
18
+
19
+ from ..detection_config import (
20
+ get_gsd_tolerance,
21
+ get_max_aspect_ratio_diff,
22
+ get_min_overlap_hard,
23
+ get_min_overlap_warn,
24
+ )
25
+ from .geo_regions import BoundsWGS84, resolve_geo_context
26
+ from .geotiff_io import inspect_image, read_georef, read_gsd_meters
27
+
28
+ import logging
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ _MODE_BAND_COUNTS = {
33
+ "1": 1, "L": 1, "I": 1, "F": 1,
34
+ "LA": 2,
35
+ "RGB": 3, "YCbCr": 3, "P": 3,
36
+ "RGBA": 4, "CMYK": 4,
37
+ }
38
+
39
+ _NON_RGB_BAND_KEYWORDS = ("nir", "infrared", "swir", "thermal", "alpha", "panchromatic")
40
+
41
+
42
+ @dataclass
43
+ class CheckResult:
44
+ name: str # readability|bands|georef|crs|gsd|overlap
45
+ status: str # pass|warn|fail
46
+ message: str = ""
47
+
48
+
49
+ @dataclass
50
+ class PreflightResult:
51
+ hard_fail: bool = False
52
+ fail_reason: str = ""
53
+ warnings: List[str] = field(default_factory=list)
54
+ checks: List[CheckResult] = field(default_factory=list)
55
+
56
+
57
+ def read_band_count(path: Path) -> Optional[int]:
58
+ """Raster band count via rasterio, falling back to a Pillow mode→band map."""
59
+ try:
60
+ import rasterio
61
+ with rasterio.open(path) as src:
62
+ return int(src.count)
63
+ except ImportError:
64
+ pass
65
+ except Exception as exc:
66
+ logger.warning("read_band_count rasterio failed for %s: %s", path.name, exc)
67
+ try:
68
+ from PIL import Image
69
+ with Image.open(path) as img:
70
+ return _MODE_BAND_COUNTS.get(img.mode, len(img.getbands()))
71
+ except Exception as exc:
72
+ logger.warning("read_band_count Pillow failed for %s: %s", path.name, exc)
73
+ return None
74
+
75
+
76
+ def _rect_overlap_frac(
77
+ before_bounds: Optional[BoundsWGS84],
78
+ after_bounds: Optional[BoundsWGS84],
79
+ ) -> Optional[float]:
80
+ """Intersection area / after-footprint area, as a plain WGS84 rectangle."""
81
+ if not before_bounds or not after_bounds:
82
+ return None
83
+ bw, bs, be, bn = before_bounds
84
+ aw, asf, ae, an = after_bounds
85
+ ix0, iy0 = max(bw, aw), max(bs, asf)
86
+ ix1, iy1 = min(be, ae), min(bn, an)
87
+ inter_w, inter_h = max(0.0, ix1 - ix0), max(0.0, iy1 - iy0)
88
+ inter_area = inter_w * inter_h
89
+ after_area = max(1e-12, abs(ae - aw) * abs(an - asf))
90
+ return inter_area / after_area
91
+
92
+
93
+ def _apply_roi_to_bounds(
94
+ bounds: Optional[BoundsWGS84], roi: Optional[dict],
95
+ ) -> Optional[BoundsWGS84]:
96
+ """Crop a WGS84 rectangle to a fractional {x,y,w,h} ROI window.
97
+
98
+ Mirrors ``load_rgb_roi``'s convention: x/y are the top-left corner and
99
+ y grows downward in the image's own pixel grid, i.e. y=0 is the image's
100
+ top edge (north when north-up). The same fractional window is applied to
101
+ both images by the caller (matching how detection itself crops both
102
+ images to the same fractional ROI), so overlap is checked on the actual
103
+ selected area rather than the full footprint.
104
+ """
105
+ if not bounds or not roi:
106
+ return bounds
107
+ west, south, east, north = bounds
108
+ x, y, w, h = roi["x"], roi["y"], roi["w"], roi["h"]
109
+ roi_west = west + x * (east - west)
110
+ roi_east = west + (x + w) * (east - west)
111
+ roi_north = north - y * (north - south)
112
+ roi_south = north - (y + h) * (north - south)
113
+ return (roi_west, roi_south, roi_east, roi_north)
114
+
115
+
116
+ def read_band_descriptions(path: Path) -> Optional[List[str]]:
117
+ """First-3-band GDAL band descriptions, when the file has any set."""
118
+ try:
119
+ import rasterio
120
+ with rasterio.open(path) as src:
121
+ return list(src.descriptions[:3])
122
+ except Exception:
123
+ return None
124
+
125
+
126
+ def _suspicious_band_order(descriptions: Optional[List[Optional[str]]]) -> bool:
127
+ """Best-effort hint only β€” most files carry no band descriptions at all,
128
+ so this stays silent far more often than it fires. There is no reliable
129
+ way to verify true RGB band *order* from metadata alone; this only flags
130
+ the case where a sensor explicitly labelled a leading band as non-visible
131
+ (e.g. NIR), which is worth a second look before trusting on-screen color.
132
+ """
133
+ if not descriptions:
134
+ return False
135
+ for d in descriptions:
136
+ if not d:
137
+ continue
138
+ low = d.lower()
139
+ if any(kw in low for kw in _NON_RGB_BAND_KEYWORDS):
140
+ return True
141
+ return False
142
+
143
+
144
+ def run_preflight_checks(
145
+ db: Session,
146
+ before_path: Path,
147
+ after_path: Path,
148
+ before_rel: str,
149
+ after_rel: str,
150
+ roi: Optional[dict] = None,
151
+ ) -> PreflightResult:
152
+ """Validate a (before, after) library image pair before decoding pixels.
153
+
154
+ ``roi`` (optional fractional ``{x,y,w,h}``) narrows the overlap check to
155
+ the actually-selected crop window instead of the full image footprints,
156
+ matching what detection itself will run on when a ROI is set.
157
+ """
158
+ result = PreflightResult()
159
+
160
+ # 1. Readability
161
+ before_meta = inspect_image(before_path)
162
+ after_meta = inspect_image(after_path)
163
+ if before_meta.width <= 0 or before_meta.height <= 0:
164
+ result.hard_fail = True
165
+ result.fail_reason = f"Before image could not be read: {before_path.name}"
166
+ result.checks.append(CheckResult("readability", "fail", result.fail_reason))
167
+ return result
168
+ if after_meta.width <= 0 or after_meta.height <= 0:
169
+ result.hard_fail = True
170
+ result.fail_reason = f"After image could not be read: {after_path.name}"
171
+ result.checks.append(CheckResult("readability", "fail", result.fail_reason))
172
+ return result
173
+ result.checks.append(CheckResult("readability", "pass"))
174
+
175
+ # 2. RGB bands
176
+ before_bands = read_band_count(before_path)
177
+ after_bands = read_band_count(after_path)
178
+ for label, bands, path in (
179
+ ("Before", before_bands, before_path),
180
+ ("After", after_bands, after_path),
181
+ ):
182
+ if not bands:
183
+ result.hard_fail = True
184
+ result.fail_reason = f"{label} image band layout could not be determined: {path.name}"
185
+ result.checks.append(CheckResult("bands", "fail", result.fail_reason))
186
+ return result
187
+ if bands == 2:
188
+ result.hard_fail = True
189
+ result.fail_reason = f"{label} image has an unsupported 2-band layout: {path.name}"
190
+ result.checks.append(CheckResult("bands", "fail", result.fail_reason))
191
+ return result
192
+ band_msgs = []
193
+ if before_bands == 1 or after_bands == 1:
194
+ band_msgs.append(
195
+ "One or both images are single-band (grayscale) β€” detection runs on a tripled grayscale channel.")
196
+ extra_band_labels = [
197
+ label for label, bands in (("Before", before_bands), ("After", after_bands)) if bands > 3
198
+ ]
199
+ if extra_band_labels:
200
+ band_msgs.append(
201
+ f"{' and '.join(extra_band_labels)} image has more than 3 bands β€” "
202
+ "only the first 3 are used for detection.")
203
+ suspicious_labels = [
204
+ label for label, path in (("Before", before_path), ("After", after_path))
205
+ if _suspicious_band_order(read_band_descriptions(path))
206
+ ]
207
+ if suspicious_labels:
208
+ band_msgs.append(
209
+ f"{' and '.join(suspicious_labels)} image band descriptions suggest non-RGB content "
210
+ "(e.g. NIR/infrared) in the first bands β€” verify band order before relying on colors.")
211
+ if band_msgs:
212
+ for msg in band_msgs:
213
+ result.warnings.append(msg)
214
+ result.checks.append(CheckResult("bands", "warn", msg))
215
+ else:
216
+ result.checks.append(CheckResult("bands", "pass"))
217
+
218
+ # 3. Georeferencing (also feeds the CRS/GSD/overlap checks below)
219
+ before_geo = resolve_geo_context(db, before_rel, before_path)
220
+ after_geo = resolve_geo_context(db, after_rel, after_path)
221
+
222
+ if before_geo.source == "none" and after_geo.source == "none":
223
+ # Plain, non-georeferenced photo pair β€” an already-supported use case
224
+ # (pair_align's resize_only fallback). Skip CRS/GSD/overlap entirely
225
+ # rather than penalize a mode the app deliberately allows.
226
+ result.checks.append(CheckResult(
227
+ "georef", "pass", "Neither image is georeferenced β€” treated as a plain photo pair."))
228
+ result.checks.append(CheckResult("crs", "pass"))
229
+ result.checks.append(CheckResult("gsd", "pass"))
230
+ # With no georeferencing, overlap can't be checked geographically β€” but
231
+ # detection still force-resizes "after" to "before"'s pixel dimensions
232
+ # regardless, so a large aspect-ratio gap is worth flagging here instead.
233
+ ratio_b = before_meta.width / before_meta.height if before_meta.height else 0.0
234
+ ratio_a = after_meta.width / after_meta.height if after_meta.height else 0.0
235
+ if ratio_b > 0 and ratio_a > 0:
236
+ rel_diff = abs(ratio_b - ratio_a) / max(ratio_b, ratio_a)
237
+ if rel_diff > get_max_aspect_ratio_diff():
238
+ msg = (
239
+ f"Images have different aspect ratios (before {before_meta.width}x{before_meta.height}, "
240
+ f"after {after_meta.width}x{after_meta.height}) β€” the after image will be stretched "
241
+ "to match, which may distort content.")
242
+ result.warnings.append(msg)
243
+ result.checks.append(CheckResult("aspect_ratio", "warn", msg))
244
+ else:
245
+ result.checks.append(CheckResult("aspect_ratio", "pass"))
246
+ result.checks.append(CheckResult("overlap", "pass"))
247
+ return result
248
+
249
+ if before_geo.source == "none" or after_geo.source == "none":
250
+ msg = "Only one image is georeferenced β€” geographic overlap could not be verified."
251
+ result.warnings.append(msg)
252
+ result.checks.append(CheckResult("georef", "warn", msg))
253
+ elif before_geo.source == "manual" or after_geo.source == "manual":
254
+ msg = "One or both images rely on manually-entered bounds rather than embedded georeferencing."
255
+ result.warnings.append(msg)
256
+ result.checks.append(CheckResult("georef", "warn", msg))
257
+ else:
258
+ result.checks.append(CheckResult("georef", "pass"))
259
+
260
+ # 4. CRS β€” present but unreprojectable is distinguishable from absent
261
+ before_georef = read_georef(before_path)
262
+ after_georef = read_georef(after_path)
263
+ broken_crs = [
264
+ label for label, gi in (("Before", before_georef), ("After", after_georef))
265
+ if gi is not None and gi.crs is not None and gi.bounds_wgs84 is None
266
+ ]
267
+ if broken_crs:
268
+ msg = (f"{' and '.join(broken_crs)} image CRS is present but could not be reprojected "
269
+ "to WGS84 β€” verify the coordinate system.")
270
+ result.warnings.append(msg)
271
+ result.checks.append(CheckResult("crs", "warn", msg))
272
+ else:
273
+ result.checks.append(CheckResult("crs", "pass"))
274
+
275
+ # 5. GSD β€” never a hard fail, only harmonization is affected downstream
276
+ gsd_b = read_gsd_meters(before_path)
277
+ gsd_a = read_gsd_meters(after_path)
278
+ if gsd_b and gsd_a:
279
+ rel_diff = abs(gsd_b - gsd_a) / max(gsd_b, gsd_a)
280
+ tolerance = get_gsd_tolerance()
281
+ if rel_diff > tolerance:
282
+ msg = (f"Ground sample distance differs by {rel_diff * 100:.0f}% "
283
+ f"(before {gsd_b:.2f} m/px, after {gsd_a:.2f} m/px) β€” "
284
+ "the finer image will be resampled to match.")
285
+ result.warnings.append(msg)
286
+ result.checks.append(CheckResult("gsd", "warn", msg))
287
+ else:
288
+ result.checks.append(CheckResult("gsd", "pass"))
289
+ else:
290
+ result.checks.append(CheckResult("gsd", "pass"))
291
+
292
+ # 6. Overlap β€” cropped to the selected ROI when one is set, so the check
293
+ # reflects the area detection will actually run on, not the full images.
294
+ before_bounds = _apply_roi_to_bounds(before_geo.bounds, roi)
295
+ after_bounds = _apply_roi_to_bounds(after_geo.bounds, roi)
296
+ roi_note = " within the selected area" if roi else ""
297
+ overlap_frac = _rect_overlap_frac(before_bounds, after_bounds)
298
+ if overlap_frac is not None:
299
+ hard_min = get_min_overlap_hard()
300
+ warn_min = get_min_overlap_warn()
301
+ if overlap_frac < hard_min:
302
+ result.hard_fail = True
303
+ result.fail_reason = (
304
+ f"Images do not overlap on the ground{roi_note} (overlap {overlap_frac * 100:.1f}% "
305
+ "of the after-image footprint) β€” not suitable for change detection.")
306
+ result.checks.append(CheckResult("overlap", "fail", result.fail_reason))
307
+ return result
308
+ if overlap_frac < warn_min:
309
+ msg = f"Weak geographic overlap{roi_note} ({overlap_frac * 100:.1f}% of the after-image footprint)."
310
+ result.warnings.append(msg)
311
+ result.checks.append(CheckResult("overlap", "warn", msg))
312
+ else:
313
+ result.checks.append(CheckResult("overlap", "pass"))
314
+ else:
315
+ result.checks.append(CheckResult("overlap", "pass"))
316
+
317
+ return result