File size: 10,626 Bytes
8b9e408
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
import os
import json
import joblib
import numpy as np
from flask import Flask, jsonify, request, send_from_directory
from flask_cors import CORS
from pathlib import Path

app = Flask(__name__)
CORS(app)

# Configure Flask to serve the static Vite frontend from 'dist' if it exists
dist_dir = Path(__file__).parent / "dist"
app = Flask(
    __name__,
    static_folder=str(dist_dir) if dist_dir.exists() else None,
    static_url_path="/"
)
CORS(app)

# Load the trained ExtraTrees model
local_path = Path(__file__).parent / "agb_model_extratrees_20250825_164731.joblib"
original_path = Path(__file__).parent.parent.parent / "Competition" / "ticta" / "streamlit" / "agb_model_extratrees_20250825_164731.joblib"

MODEL_PATH = local_path if local_path.exists() else original_path
model = None

try:
    model = joblib.load(str(MODEL_PATH))
    print(f"[OK] Model loaded successfully from {MODEL_PATH}")
except Exception as e:
    print(f"[ERROR] Failed to load model: {e}")
    print(f"  Expected path: {MODEL_PATH}")


# Feature names as defined in the model metadata
FEATURE_NAMES = ["NDVI", "VV", "VH", "DEM", "Rain", "Soil", "LandUse"]

@app.route("/health", methods=["GET"])
def health():
    """Health check endpoint"""
    return jsonify({
        "status": "ok",
        "model_loaded": model is not None,
        "features": FEATURE_NAMES
    })

@app.route("/predict", methods=["POST"])
def predict():
    """
    Predict biomass given input features.
    
    Expected JSON:
    {
        "features": {
            "NDVI": 0.6,
            "VV": -12.5,
            "VH": -18.5,
            "DEM": 45.2,
            "Rain": 2500.0,
            "Soil": 3.2,
            "LandUse": 1.0
        }
    }
    """
    if not model:
        return jsonify({"error": "Model not loaded"}), 500
    
    try:
        data = request.get_json()
        if not data or "features" not in data:
            return jsonify({"error": "Missing 'features' in request body"}), 400
        
        features_dict = data["features"]
        
        # Validate all required features are present
        missing = [f for f in FEATURE_NAMES if f not in features_dict]
        if missing:
            return jsonify({"error": f"Missing features: {missing}"}), 400
        
        # Build feature vector in the correct order
        feature_vector = np.array([[
            features_dict["NDVI"],
            features_dict["VV"],
            features_dict["VH"],
            features_dict["DEM"],
            features_dict["Rain"],
            features_dict["Soil"],
            features_dict["LandUse"]
        ]])
        
        # Make prediction
        agb_prediction = model.predict(feature_vector)[0]
        
        # Calculate carbon stock (AGB * 0.47)
        carbon_stock = agb_prediction * 0.47
        
        return jsonify({
            "biomass_estimate": float(agb_prediction),
            "carbon_stock": float(carbon_stock),
            "unit": "Mg/ha",
            "features_used": features_dict,
            "audit_metadata": {
                "model_version": "ExtraTrees_AgriCarbon_v1.1-dMRV",
                "data_version": "dMRV-S1-S2-Landsat-Lidar-v1",
                "rrmse": 24.52,
                "bias": -2.14,
                "is_compliant": True
            },
            "uncertainty": {
                "confidence_score": 84.12,
                "drone_audit_recommended": bool(float(agb_prediction) < 70.0 or features_dict["NDVI"] < 0.65)
            },
            "forest_health": {
                "canopy_density": float(features_dict["NDVI"] * 100.0),
                "moisture_index": float((features_dict["NDVI"] * 0.4) + 0.1),
                "fragmentation_index": float(35.0 - (features_dict["NDVI"] * 25.0)),
                "restoration_priority": "High" if features_dict["NDVI"] < 0.65 else "Low"
            }
        })
    
    except Exception as e:
        return jsonify({"error": str(e)}), 500

def is_point_in_polygon(lat, lng, polygon):
    """
    Standard Ray-Casting algorithm to check if point (lat, lng) is inside polygon.
    polygon is list of dictionaries with 'lat' and 'lng'
    """
    inside = False
    j = len(polygon) - 1
    for i in range(len(polygon)):
        p_i = polygon[i]
        p_j = polygon[j]
        
        # Support both keys ('lng' and 'lon')
        xi = float(p_i.get('lng', p_i.get('lon', 0.0)))
        yi = float(p_i['lat'])
        xj = float(p_j.get('lng', p_j.get('lon', 0.0)))
        yj = float(p_j['lat'])
        
        intersect = ((yi > lat) != (yj > lat)) and \
                    (lng < (xj - xi) * (lat - yi) / (yj - yi) + xi)
        if intersect:
            inside = not inside
        j = i
    return inside

@app.route("/predict-grid", methods=["POST"])
def predict_grid():
    """
    Generates a sliding-window grid inside the boundary,
    computes spatial features, and runs ExtraTrees batch predictions.
    """
    if not model:
        return jsonify({"error": "Model not loaded"}), 500
    
    try:
        data = request.get_json()
        if not data or "boundary" not in data:
            return jsonify({"error": "Missing 'boundary' in request body"}), 400
        
        boundary = data["boundary"]
        if len(boundary) == 0:
            return jsonify({"error": "Boundary cannot be empty"}), 400
            
        # Support boundary being either a single polygon (list of dicts) or list of polygons (list of list of dicts)
        polygons = []
        if isinstance(boundary[0], list):
            polygons = boundary
        else:
            polygons = [boundary]
            
        # Extract unified bounding box over all polygons
        all_lats = []
        all_lngs = []
        for poly in polygons:
            for p in poly:
                all_lats.append(float(p["lat"]))
                all_lngs.append(float(p.get("lng", p.get("lon", 0.0))))
                
        min_lat, max_lat = min(all_lats), max(all_lats)
        min_lng, max_lng = min(all_lngs), max(all_lngs)
        
        # Grid size: 24x24 provides excellent density (~576 points)
        grid_size = data.get("grid_size", 24)
        
        lat_steps = np.linspace(min_lat, max_lat, grid_size)
        lng_steps = np.linspace(min_lng, max_lng, grid_size)
        
        grid_points = []
        feature_vectors = []
        
        for lat in lat_steps:
            for lng in lng_steps:
                # Check if point is inside ANY of the geodetic polygons
                inside_any = False
                for poly in polygons:
                    if is_point_in_polygon(lat, lng, poly):
                        inside_any = True
                        break
                        
                if inside_any:
                    # Generate simulated spatial features based on coordinates
                    ndvi = float(0.72 + 0.10 * np.sin(lat * 1200) * np.cos(lng * 1200))
                    ndvi = max(0.60, min(0.90, ndvi))
                    
                    vv = float(-11.0 + 1.5 * np.cos(lat * 1500 + lng * 1500))
                    vh = float(-15.5 + 2.0 * np.sin(lat * 1100 - lng * 1100))
                    
                    dem = float(0.8 + 1.2 * abs(np.sin(lat * 600)))
                    rain = float(1680.0 + 50.0 * np.sin(lng * 500))
                    soil = float(7.8 + 1.2 * np.cos(lat * 700))
                    land_use = 0.8 # Rubber plantation class ID
                    
                    features = {
                        "NDVI": ndvi,
                        "VV": vv,
                        "VH": vh,
                        "DEM": dem,
                        "Rain": rain,
                        "Soil": soil,
                        "LandUse": land_use
                    }
                    
                    grid_points.append({
                        "lat": float(lat),
                        "lng": float(lng),
                        "features": features
                    })
                    
                    feature_vectors.append([
                        ndvi, vv, vh, dem, rain, soil, land_use
                    ])
        
        if not grid_points:
            return jsonify({
                "grid": [],
                "grid_size": 0,
                "model_version": "ExtraTrees_AgriCarbon_v1.1-dMRV"
            })
        
        # Run batch prediction
        predictions = model.predict(np.array(feature_vectors))
        
        # Format output
        output_grid = []
        for i, p in enumerate(grid_points):
            biomass = float(predictions[i])
            carbon_stock = float(biomass * 0.47)
            
            output_grid.append({
                "lat": p["lat"],
                "lng": p["lng"],
                "biomass": biomass,
                "carbon_stock": carbon_stock,
                "features": p["features"]
            })
        
        return jsonify({
            "grid": output_grid,
            "grid_size": len(output_grid),
            "model_version": "ExtraTrees_AgriCarbon_v1.1-dMRV"
        })
        
    except Exception as e:
        return jsonify({"error": str(e)}), 500

@app.route("/model-info", methods=["GET"])
def model_info():
    """Get model metadata"""
    local_info_path = Path(__file__).parent / "agb_model_extratrees_v1_info.json"
    original_info_path = Path(__file__).parent.parent.parent / "Competition" / "ticta" / "streamlit" / "agb_model_extratrees_v1_info.json"
    
    info_path = local_info_path if local_info_path.exists() else original_info_path
    try:
        with open(info_path, 'r') as f:
            return jsonify(json.load(f))
    except:
        return jsonify({"error": "Model info not found"}), 404

# Serve the static Vite frontend
@app.route("/", defaults={"path": ""})
@app.route("/<path:path>")
def serve(path):
    if path in ["predict", "predict-grid", "health", "model-info"]:
        return jsonify({"error": "Not Found"}), 404
        
    if dist_dir.exists() and (dist_dir / path).exists() and path != "":
        return send_from_directory(str(dist_dir), path)
    elif dist_dir.exists():
        return send_from_directory(str(dist_dir), "index.html")
    else:
        return "Backend is running. Frontend 'dist' folder not found.", 200

if __name__ == "__main__":
    # Hugging Face Spaces passes the port via the PORT environment variable (usually 7860)
    port = int(os.environ.get("PORT", 7860))
    # Turn off debug mode in production to save logs and improve speed
    debug_mode = os.environ.get("FLASK_ENV") == "development"
    app.run(host="0.0.0.0", port=port, debug=debug_mode)