File size: 8,606 Bytes
1f86983
8329543
 
 
 
 
 
 
 
 
 
1f86983
 
 
8329543
1f86983
9911bc7
8329543
1f86983
9911bc7
8329543
 
9911bc7
8329543
 
 
 
 
 
 
 
 
 
9911bc7
8329543
9911bc7
8329543
9911bc7
8329543
 
 
 
 
 
 
1f86983
8329543
 
 
9911bc7
 
8329543
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9911bc7
8329543
9911bc7
8329543
 
 
 
 
 
 
 
 
 
8133beb
8329543
 
 
 
8133beb
8329543
1f86983
 
8329543
 
 
 
 
 
 
 
 
 
 
9911bc7
8329543
 
 
 
 
 
 
 
 
 
 
9911bc7
8329543
 
 
 
 
 
 
 
 
 
9911bc7
8329543
9911bc7
8329543
 
 
 
 
 
1f86983
9911bc7
 
8329543
 
 
 
 
 
 
 
 
 
1f86983
9911bc7
8329543
9911bc7
 
8329543
9911bc7
8329543
 
 
9911bc7
 
8329543
 
 
 
9911bc7
 
8329543
 
 
1f86983
8329543
1f86983
8329543
 
 
 
1f86983
8329543
 
9911bc7
8329543
 
 
 
f8399d6
8329543
 
 
1f86983
ca94a10
3d38cca
8329543
 
f8399d6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
"""
Aerix β€” Hugging Face Spaces Gradio App with ZeroGPU
Wraps the inference.py pipeline as a Gradio interface with automatic A100 GPU
allocation via @spaces.GPU decorator.

Key fix vs the previous version: CPU-bound work (loading/downsampling the
image, building tiles, postprocessing/rooftop classification) now happens
OUTSIDE the @spaces.GPU-decorated functions. Only the actual tensor forward
pass runs inside them. This is what was causing crashes/hangs on large TIFF
uploads β€” a big GeoTIFF's decode+resize could take longer than the ZeroGPU
duration window, or blow past available RAM before the GPU was even attached.
"""

import io
import gc
import base64
import numpy as np
from pathlib import Path
from PIL import Image

import cv2
import torch

try:
    import spaces
except ImportError:
    class _SpacesFallback:
        @staticmethod
        def GPU(duration=0):
            def decorator(func):
                return func
            return decorator
    spaces = _SpacesFallback()

import gradio as gr

from inference import AerixSegmentationModel, get_image_info, validate_upload

# ---------------------------------------------------------------------------
# Model initialisation (module-level, CPU β€” ZeroGPU attaches the GPU later,
# only for the duration of a @spaces.GPU-decorated call)
# ---------------------------------------------------------------------------
print("Initialising AerixSegmentationModel …")
model = AerixSegmentationModel(models_dir="models")
print(f"Model ready on device: {model.device}")

# Single tile vs sliding-window routing threshold (post-downsample px, longest side)
TILE_ROUTE_THRESHOLD = 512
MAX_DISPLAY_DIM = 2048


def numpy_to_base64(arr: np.ndarray, fmt: str = "PNG") -> str:
    img = Image.fromarray(arr)
    buf = io.BytesIO()
    img.save(buf, format=fmt)
    return base64.b64encode(buf.getvalue()).decode("utf-8")


# ---------------------------------------------------------------------------
# Available sample tiles
# ---------------------------------------------------------------------------
TILES_DIR = Path("sample_data/tiles")
TILE_CHOICES = ["none"]
if TILES_DIR.exists():
    TILE_CHOICES += [
        f.name for f in sorted(TILES_DIR.glob("*.png"))
        if "mask" not in f.name and "gt_" not in f.name
    ]


# ---------------------------------------------------------------------------
# GPU stages β€” kept as thin as possible. These are the ONLY functions that
# touch CUDA. Everything else in this file is plain CPU code.
# ---------------------------------------------------------------------------
@spaces.GPU(duration=20)
def _gpu_run_single(feature: str, input_array: np.ndarray) -> np.ndarray:
    return model.run_model(feature, input_array)


@spaces.GPU(duration=60)
def _gpu_run_tiles(feature: str, chips):
    return model.run_model_tiles(feature, chips)


# ---------------------------------------------------------------------------
# Main handler β€” plain function, NOT @spaces.GPU. It does CPU work directly
# and only reaches into the GPU stages above for the actual model forward.
# ---------------------------------------------------------------------------
def predict(image, feature, threshold, sample_tile):
    """
    Run UNet++ segmentation on a drone orthophoto.
    """
    if sample_tile and sample_tile != "none":
        image_path = str(TILES_DIR / sample_tile)
        if not Path(image_path).exists():
            return {"error": f"Sample tile not found: {sample_tile}"}
    elif image is not None:
        image_path = image
    else:
        return {"error": "No image provided. Upload an image or select a sample tile."}

    # --- Fast, cheap rejection before touching any pixel data -------------
    try:
        info = get_image_info(image_path)
        validate_upload(image_path)
    except ValueError as e:
        return {"error": str(e)}
    except Exception as e:
        return {"error": f"Could not read file: {e}"}

    try:
        # ---- CPU stage: load + downsample (rasterio windowed read for TIFF)
        original_image = _load_for_routing(image_path)
        h, w = original_image.shape[:2]

        if max(h, w) <= TILE_ROUTE_THRESHOLD:
            # ---- small image: single-tile path
            resized = cv2.resize(original_image, (512, 512))
            input_array = resized.astype(np.float32) / 255.0
            raw = _gpu_run_single(feature, input_array)          # GPU
            raw_full = cv2.resize(raw, (w, h))
            result = model.postprocess(original_image, raw_full, feature, threshold)  # CPU
        else:
            # ---- large orthomosaic: tiled / sliding-window path
            tiles = model.prepare_tiles(original_image)          # CPU
            preds = _gpu_run_tiles(feature, tiles["chips"])      # GPU
            mask = model.stitch_tiles(preds, tiles["coords"], tiles["weight_map"],
                                       tiles["image_shape"], threshold)  # CPU
            overlay = model._create_overlay(original_image, mask)
            detected_pixels = int(np.sum(mask > 0))
            total_pixels = int(mask.size)
            rooftop_analysis = (
                model.classify_rooftops(original_image, mask)
                if feature == "buildings" and detected_pixels > 0 else None
            )
            result = {
                "original_image": original_image,
                "mask": mask,
                "overlay": overlay,
                "feature": feature,
                "detected_ratio": detected_pixels / total_pixels if total_pixels else 0,
                "detected_pixels": detected_pixels,
                "total_pixels": total_pixels,
                "rooftop_analysis": rooftop_analysis,
            }

        response = {
            "feature": result["feature"],
            "detected_ratio": float(result["detected_ratio"]),
            "detected_pixels": int(result["detected_pixels"]),
            "total_pixels": int(result["total_pixels"]),
            "original_image": numpy_to_base64(result["original_image"]),
            "mask": numpy_to_base64(result["mask"]),
            "overlay": numpy_to_base64(result["overlay"]),
        }

        if result.get("rooftop_analysis"):
            ra = result["rooftop_analysis"]
            response["rooftop_analysis"] = {
                "rooftop_counts": ra["rooftop_counts"],
                "total_buildings": ra["total_buildings"],
                "total_builtup_m2": ra["total_builtup_m2"],
                "rcc_area_m2": ra["rcc_area_m2"],
                "annual_solar_kwh": ra["annual_solar_kwh"],
                "annual_property_tax_inr": ra["annual_property_tax_inr"],
                "classification_map": numpy_to_base64(ra["classification_map"]),
                "building_details": ra["building_details"][:50],
            }

        return response

    except Exception as e:
        return {"error": str(e)}
    finally:
        gc.collect()
        if torch.cuda.is_available():
            torch.cuda.empty_cache()


def _load_for_routing(image_path: str) -> np.ndarray:
    """CPU-only load, used to decide single-tile vs sliding-window routing."""
    from inference import load_robust_image
    return load_robust_image(image_path, max_dim=MAX_DISPLAY_DIM)


# ---------------------------------------------------------------------------
# Gradio Interface
# ---------------------------------------------------------------------------
demo = gr.Interface(
    fn=predict,
    inputs=[
        gr.Image(type="filepath", label="Drone Orthophoto"),
        gr.Dropdown(choices=["buildings", "roads", "water_bodies"], value="buildings", label="Feature Class"),
        gr.Slider(minimum=0.1, maximum=0.9, step=0.05, value=0.5, label="Confidence Threshold"),
        gr.Dropdown(choices=TILE_CHOICES, value="none", label="Sample Tile"),
    ],
    outputs=gr.JSON(label="Inference Results"),
    title="πŸ›°οΈ Aerix β€” SVAMITVA AI Segmentation",
    description=(
        "UNet++ deep learning inference on 50 cm GSD drone orthophotos for "
        "building footprint demarcation, road network vectorisation, and "
        "waterbody extraction. Part of SIH Problem Statement 1705 β€” "
        "Ministry of Panchayati Raj, Government of India.\n\n"
        "Uploads over 1 GB are rejected up front β€” downsample or crop first."
    ),
    api_name="predict",
    flagging_mode="never",
)

if __name__ == "__main__":
    # max_file_size caps the raw upload so an oversized file fails fast at the
    # Gradio layer instead of hanging the request indefinitely.
    demo.queue().launch(max_file_size="1gb")