Spaces:
Sleeping
Sleeping
| """ | |
| Soil Classification Backend | |
| FastAPI app that loads a CNN model to classify soil images and recommend crops. | |
| Deployed on Hugging Face Spaces via Docker. | |
| """ | |
| import os | |
| import json | |
| import zipfile | |
| import base64 | |
| import io | |
| import logging | |
| from contextlib import asynccontextmanager | |
| from typing import List, Optional | |
| import numpy as np | |
| import pandas as pd | |
| from PIL import Image | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel | |
| logger = logging.getLogger("soil-backend") | |
| logging.basicConfig(level=logging.INFO) | |
| # --------------------------------------------------------------------------- | |
| # Configuration | |
| # --------------------------------------------------------------------------- | |
| MODEL_PATH = os.environ.get("MODEL_PATH", "model/soil_model007.keras") | |
| DATA_PATH = os.environ.get("DATA_PATH", "data/data_core.csv") | |
| IMG_SIZE = (224, 224) | |
| # Classes in alphabetical order — default Keras behaviour when training | |
| # with image_dataset_from_directory or ImageDataGenerator. | |
| SOIL_CLASSES = ["Black", "Clayey", "Loamy", "Red", "Sandy"] | |
| # --------------------------------------------------------------------------- | |
| # Globals (populated at startup) | |
| # --------------------------------------------------------------------------- | |
| model = None | |
| crop_lookup: dict = {} | |
| # --------------------------------------------------------------------------- | |
| # Soil explanations per language | |
| # --------------------------------------------------------------------------- | |
| SOIL_EXPLANATIONS = { | |
| "Black": { | |
| "English": ( | |
| "This soil appears to be Black soil (also known as Regur soil). " | |
| "It is rich in clay, iron, magnesium, and calcium. Black soil is " | |
| "highly moisture-retentive and ideal for cotton and other " | |
| "deep-rooted crops." | |
| ), | |
| "Hindi": ( | |
| "यह मिट्टी काली मिट्टी (रेगुर मिट्टी) प्रतीत होती है। यह चिकनी मिट्टी, " | |
| "लोहा, मैग्नीशियम और कैल्शियम से समृद्ध है। काली मिट्टी नमी बनाए " | |
| "रखने में उत्कृष्ट है और कपास तथा गहरी जड़ वाली फसलों के लिए आदर्श है।" | |
| ), | |
| "Odia": ( | |
| "ଏହା କଳା ମାଟି (ରେଗୁର ମାଟି) ବୋଲି ମନେ ହେଉଛି। ଏଥିରେ ଚିକିଟା ମାଟି, " | |
| "ଲୁହା, ମ୍ୟାଗ୍ନେସିୟମ ଏବଂ କ୍ୟାଲସିୟମ ଭରପୂର। କଳା ମାଟି ଆର୍ଦ୍ରତା ଧାରଣ " | |
| "କ୍ଷମତା ଅଧିକ ଏବଂ କପା ଓ ଗଭୀର ଚେର ଫସଲ ପାଇଁ ଉପଯୁକ୍ତ।" | |
| ), | |
| }, | |
| "Clayey": { | |
| "English": ( | |
| "This soil appears to be Clayey soil. It has very fine particles " | |
| "that hold water well but can become waterlogged. Clayey soil is " | |
| "nutrient-rich and suitable for crops that thrive in moist " | |
| "conditions like paddy and wheat." | |
| ), | |
| "Hindi": ( | |
| "यह मिट्टी चिकनी मिट्टी प्रतीत होती है। इसमें बहुत महीन कण होते हैं " | |
| "जो पानी अच्छी तरह रोकते हैं लेकिन जलभराव हो सकता है। चिकनी मिट्टी " | |
| "पोषक तत्वों से भरपूर है और धान व गेहूं जैसी फसलों के लिए उपयुक्त है।" | |
| ), | |
| "Odia": ( | |
| "ଏହା ଚିକିଟା ମାଟି ବୋଲି ମନେ ହେଉଛି। ଏଥିରେ ଅତି ସୂକ୍ଷ୍ମ କଣିକା ଥାଏ " | |
| "ଯାହା ଜଳ ଭଲ ଭାବରେ ଧରି ରଖେ କିନ୍ତୁ ଜଳମଗ୍ନ ହୋଇପାରେ। ଚିକିଟା ମାଟି ପୋଷକ " | |
| "ତତ୍ତ୍ୱରେ ସମୃଦ୍ଧ ଏବଂ ଧାନ ଓ ଗହମ ଭଳି ଫସଲ ପାଇଁ ଉପଯୁକ୍ତ।" | |
| ), | |
| }, | |
| "Loamy": { | |
| "English": ( | |
| "This soil appears to be Loamy soil. It is a balanced mix of " | |
| "sand, silt, and clay — considered the ideal agricultural soil. " | |
| "Loamy soil has excellent drainage, good moisture retention, and " | |
| "is rich in nutrients, supporting a wide variety of crops." | |
| ), | |
| "Hindi": ( | |
| "यह मिट्टी दोमट मिट्टी प्रतीत होती है। यह रेत, गाद और चिकनी मिट्टी " | |
| "का संतुलित मिश्रण है — इसे आदर्श कृषि मिट्टी माना जाता है। दोमट मिट्टी " | |
| "में उत्कृष्ट जल निकास, अच्छी नमी धारण क्षमता और प्रचुर पोषक तत्व होते हैं।" | |
| ), | |
| "Odia": ( | |
| "ଏହା ଦୋମାଟି ମାଟି ବୋଲି ମନେ ହେଉଛି। ଏହା ବାଲି, ପଙ୍କ ଏବଂ ଚିକିଟା ମାଟିର " | |
| "ଏକ ସନ୍ତୁଳିତ ମିଶ୍ରଣ — ଏହାକୁ ଆଦର୍ଶ କୃଷି ମାଟି ବୋଲି ମନା ଯାଏ। ଦୋମାଟି " | |
| "ମାଟିରେ ଉତ୍କୃଷ୍ଟ ନିଷ୍କାସନ, ଭଲ ଆର୍ଦ୍ରତା ଧାରଣ ଏବଂ ପ୍ରଚୁର ପୋଷକ ତତ୍ତ୍ୱ ଥାଏ।" | |
| ), | |
| }, | |
| "Red": { | |
| "English": ( | |
| "This soil appears to be Red soil. It gets its colour from iron " | |
| "oxide content. Red soil is typically well-drained, slightly " | |
| "acidic, and found in areas with moderate rainfall. It is " | |
| "suitable for groundnuts, millets, and tobacco with proper " | |
| "fertilisation." | |
| ), | |
| "Hindi": ( | |
| "यह मिट्टी लाल मिट्टी प्रतीत होती है। इसका रंग लौह ऑक्साइड की मात्रा " | |
| "से आता है। लाल मिट्टी आम तौर पर अच्छी जल निकासी वाली, हल्की अम्लीय " | |
| "होती है। उचित उर्वरक के साथ मूंगफली, बाजरा और तम्बाकू जैसी फसलों के " | |
| "लिए उपयुक्त है।" | |
| ), | |
| "Odia": ( | |
| "ଏହା ଲାଲ ମାଟି ବୋଲି ମନେ ହେଉଛି। ଏହାର ରଙ୍ଗ ଲୌହ ଅକ୍ସାଇଡ୍ ଯୋଗୁଁ " | |
| "ହୋଇଥାଏ। ଲାଲ ମାଟି ସାଧାରଣତଃ ଭଲ ନିଷ୍କାସନ ଥିବା, ସାମାନ୍ୟ ଅମ୍ଳୀୟ। " | |
| "ଉପଯୁକ୍ତ ସାର ସହ ବାଦାମ, ମିଲେଟ ଓ ତମାଖୁ ଭଳି ଫସଲ ପାଇଁ ଏହା ଉପଯୁକ୍ତ।" | |
| ), | |
| }, | |
| "Sandy": { | |
| "English": ( | |
| "This soil appears to be Sandy soil. It has large, loose " | |
| "particles that provide excellent drainage but poor moisture " | |
| "and nutrient retention. Sandy soil warms up quickly and is " | |
| "suitable for barley, millets, and maize that tolerate dry " | |
| "conditions." | |
| ), | |
| "Hindi": ( | |
| "यह मिट्टी बलुई मिट्टी प्रतीत होती है। इसमें बड़े, ढीले कण होते हैं " | |
| "जो उत्कृष्ट जल निकास प्रदान करते हैं लेकिन नमी और पोषक तत्व कम " | |
| "बनाए रखते हैं। बलुई मिट्टी जौ, बाजरा और मक्का जैसी शुष्क फसलों के " | |
| "लिए उपयुक्त है।" | |
| ), | |
| "Odia": ( | |
| "ଏହା ବାଲୁକା ମାଟି ବୋଲି ମନେ ହେଉଛି। ଏଥିରେ ବଡ଼, ଢିଲା କଣିକା ଥାଏ " | |
| "ଯାହା ଉତ୍କୃଷ୍ଟ ନିଷ୍କାସନ ଦିଏ କିନ୍ତୁ ଆର୍ଦ୍ରତା ଓ ପୋଷକ ତତ୍ତ୍ୱ ଧାରଣ " | |
| "କ୍ଷମତା କମ୍। ବାଲୁକା ମାଟି ଯବ, ମିଲେଟ ଓ ମକା ଭଳି ଶୁଷ୍କ ଫସଲ ପାଇଁ ଉପଯୁକ୍ତ।" | |
| ), | |
| }, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Pydantic schemas (match frontend types.ts) | |
| # --------------------------------------------------------------------------- | |
| class ClassifyRequest(BaseModel): | |
| imageBase64: str | |
| language: str = "English" | |
| class ClassificationResult(BaseModel): | |
| soilType: str | |
| explanation: str | |
| recommendedCrops: List[str] | |
| # --------------------------------------------------------------------------- | |
| # Model loading helpers | |
| # --------------------------------------------------------------------------- | |
| def _clean_model_config(obj): | |
| """Recursively strip `quantization_config` from a Keras config dict. | |
| This fixes the version-mismatch error: | |
| TypeError: Unrecognized keyword arguments passed to Dense: | |
| {'quantization_config': None} | |
| """ | |
| if isinstance(obj, dict): | |
| obj.pop("quantization_config", None) | |
| for value in obj.values(): | |
| _clean_model_config(value) | |
| elif isinstance(obj, list): | |
| for item in obj: | |
| _clean_model_config(item) | |
| return obj | |
| def _load_model(path: str): | |
| """Load a .keras model with a fallback that cleans incompatible config.""" | |
| import tensorflow as tf | |
| # --- Attempt 1: direct load --- | |
| try: | |
| m = tf.keras.models.load_model(path) | |
| logger.info("Model loaded successfully (direct).") | |
| return m | |
| except TypeError as exc: | |
| if "quantization_config" not in str(exc): | |
| raise | |
| logger.warning( | |
| "Direct load failed due to quantization_config. " | |
| "Cleaning config and retrying…" | |
| ) | |
| # --- Attempt 2: strip quantization_config from the zip --- | |
| cleaned_path = path + ".cleaned.keras" | |
| with zipfile.ZipFile(path, "r") as zin, \ | |
| zipfile.ZipFile(cleaned_path, "w") as zout: | |
| for item in zin.infolist(): | |
| data = zin.read(item.filename) | |
| if item.filename == "config.json": | |
| config = json.loads(data) | |
| config = _clean_model_config(config) | |
| data = json.dumps(config).encode("utf-8") | |
| zout.writestr(item, data) | |
| m = tf.keras.models.load_model(cleaned_path) | |
| logger.info("Model loaded successfully (cleaned config).") | |
| # Clean up temp file | |
| try: | |
| os.remove(cleaned_path) | |
| except OSError: | |
| pass | |
| return m | |
| def _build_crop_lookup(csv_path: str) -> dict: | |
| """Build a mapping: soil_type → list of unique crop types from the CSV. | |
| The CSV dataset contains every crop under every soil type, so a naive | |
| groupby returns identical lists. To provide *useful* recommendations we | |
| rank crops per soil by how frequently they appear and keep only the top | |
| ones (those whose share within a soil type is above a threshold). If the | |
| data is still too uniform we fall back to curated agronomic defaults. | |
| """ | |
| # Curated agronomic defaults — used when the CSV cannot discriminate. | |
| AGRONOMIC_DEFAULTS: dict[str, list[str]] = { | |
| "Black": ["Cotton", "Sugarcane", "Wheat", "Oil seeds", "Pulses"], | |
| "Clayey": ["Paddy", "Wheat", "Pulses", "Sugarcane"], | |
| "Loamy": ["Wheat", "Sugarcane", "Cotton", "Maize", "Pulses", "Oil seeds"], | |
| "Red": ["Ground Nuts", "Tobacco", "Millets", "Cotton", "Pulses"], | |
| "Sandy": ["Barley", "Millets", "Maize", "Ground Nuts"], | |
| } | |
| try: | |
| df = pd.read_csv(csv_path) | |
| df.columns = df.columns.str.strip() | |
| lookup: dict[str, list[str]] = {} | |
| for soil in df["Soil Type"].unique(): | |
| soil_df = df.loc[df["Soil Type"] == soil, "Crop Type"].dropna() | |
| total = len(soil_df) | |
| if total == 0: | |
| lookup[soil] = AGRONOMIC_DEFAULTS.get(soil, []) | |
| continue | |
| freq = soil_df.value_counts(normalize=True) | |
| # Keep crops that appear ≥ 12 % of the time for this soil | |
| top_crops = freq[freq >= 0.12].index.tolist() | |
| # If the CSV is too uniform (every crop passes), use defaults | |
| all_crops = soil_df.unique().tolist() | |
| if len(top_crops) == 0 or len(top_crops) == len(all_crops): | |
| lookup[soil] = AGRONOMIC_DEFAULTS.get(soil, sorted(all_crops)) | |
| else: | |
| lookup[soil] = sorted(top_crops) | |
| # Ensure every known class has an entry | |
| for soil, crops in AGRONOMIC_DEFAULTS.items(): | |
| lookup.setdefault(soil, crops) | |
| return lookup | |
| except Exception as exc: | |
| logger.warning("Could not build crop lookup from CSV: %s — using agronomic defaults.", exc) | |
| return AGRONOMIC_DEFAULTS | |
| # --------------------------------------------------------------------------- | |
| # Application lifespan — load model & data once at startup | |
| # --------------------------------------------------------------------------- | |
| async def lifespan(app: FastAPI): | |
| global model, crop_lookup | |
| logger.info("Loading CNN model from %s …", MODEL_PATH) | |
| model = _load_model(MODEL_PATH) | |
| logger.info("Model input shape : %s", model.input_shape) | |
| logger.info("Model output shape: %s", model.output_shape) | |
| logger.info("Loading crop data from %s …", DATA_PATH) | |
| crop_lookup = _build_crop_lookup(DATA_PATH) | |
| logger.info("Crop lookup built for soil types: %s", list(crop_lookup.keys())) | |
| yield # application runs here | |
| logger.info("Shutting down — releasing model.") | |
| model = None | |
| # --------------------------------------------------------------------------- | |
| # FastAPI app | |
| # --------------------------------------------------------------------------- | |
| app = FastAPI( | |
| title="Soil Classification API", | |
| version="1.0.0", | |
| lifespan=lifespan, | |
| ) | |
| # CORS — allow the Wasmer-hosted frontend (and any other origin during dev) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Helper — decode & preprocess image | |
| # --------------------------------------------------------------------------- | |
| def _preprocess_image(base64_str: str) -> np.ndarray: | |
| """Decode a data-URI / raw base64 string into a (1, 224, 224, 3) array.""" | |
| # Strip the data URI prefix if present (e.g. "data:image/jpeg;base64,") | |
| if "," in base64_str: | |
| base64_str = base64_str.split(",", 1)[1] | |
| img_bytes = base64.b64decode(base64_str) | |
| img = Image.open(io.BytesIO(img_bytes)).convert("RGB") | |
| img = img.resize(IMG_SIZE) | |
| arr = np.array(img, dtype=np.float32) / 255.0 # normalise to [0, 1] | |
| return np.expand_dims(arr, axis=0) # add batch dimension | |
| # --------------------------------------------------------------------------- | |
| # Routes | |
| # --------------------------------------------------------------------------- | |
| async def health(): | |
| """Health-check endpoint.""" | |
| return { | |
| "status": "healthy", | |
| "model_loaded": model is not None, | |
| "soil_classes": SOIL_CLASSES, | |
| } | |
| async def classify_soil(payload: ClassifyRequest): | |
| """Classify a soil image and return soil type + crop recommendations.""" | |
| if model is None: | |
| raise HTTPException(status_code=503, detail="Model not loaded yet.") | |
| # 1. Preprocess image | |
| try: | |
| img_array = _preprocess_image(payload.imageBase64) | |
| except Exception as exc: | |
| logger.error("Image preprocessing failed: %s", exc) | |
| raise HTTPException(status_code=400, detail="Invalid image data.") | |
| # 2. Run prediction | |
| predictions = model.predict(img_array, verbose=0) | |
| class_idx = int(np.argmax(predictions[0])) | |
| soil_type = SOIL_CLASSES[class_idx] | |
| logger.info( | |
| "Prediction: %s (confidence %.2f%%)", | |
| soil_type, | |
| float(predictions[0][class_idx]) * 100, | |
| ) | |
| # 3. Look up recommended crops from CSV | |
| recommended_crops = crop_lookup.get(soil_type, []) | |
| # 4. Get explanation in the requested language | |
| lang = payload.language if payload.language in ("English", "Hindi", "Odia") else "English" | |
| explanation = SOIL_EXPLANATIONS.get(soil_type, {}).get(lang, "") | |
| return ClassificationResult( | |
| soilType=soil_type, | |
| explanation=explanation, | |
| recommendedCrops=recommended_crops, | |
| ) | |