Spaces:
Sleeping
Sleeping
| /** | |
| * AI Model Service | |
| * | |
| * Provides client-side crop disease detection using TensorFlow.js. | |
| * | |
| * Architecture: | |
| * 1. Attempts to load a custom PlantVillage-style model from /models/model.json. | |
| * 2. If unavailable, falls back to a deterministic mock inference that | |
| * analyses the uploaded image's pixel data to produce consistent, | |
| * image-dependent predictions from the local disease database. | |
| * 3. All results are mapped through the disease knowledge base | |
| * (diseaseDatabase.js) so the caller always receives full treatment info. | |
| * | |
| * Exported API: | |
| * - loadModel() : Pre-load / warm the TF model (cached). | |
| * - analyzeImage(file) : Full pipeline: preprocess -> infer -> post-process. | |
| * - getModelStatus() : Current model loading state. | |
| * - resetModel() : Free memory and reset cache. | |
| * | |
| * The module keeps a singleton model reference to avoid re-downloading | |
| * on every analysis request. | |
| */ | |
| import * as tf from "@tensorflow/tfjs"; | |
| import { MODEL_PATH } from "./constants"; | |
| import { getDiseaseByClassIndex, NUM_CLASSES } from "./diseaseDatabase"; | |
| // --------------------------------------------------------------------------- | |
| // Constants | |
| // --------------------------------------------------------------------------- | |
| /** Expected model input dimensions (width x height). */ | |
| const MODEL_INPUT_SIZE = 224; | |
| /** Minimum confidence to include a prediction in results. */ | |
| const CONFIDENCE_THRESHOLD = 0.10; | |
| /** Maximum predictions to return. */ | |
| const MAX_PREDICTIONS = 3; | |
| /** Stages reported during analysis for progress tracking. */ | |
| export const ANALYSIS_STAGES = { | |
| IDLE: "idle", | |
| LOADING_MODEL: "loading_model", | |
| PREPROCESSING: "preprocessing", | |
| RUNNING_INFERENCE: "running_inference", | |
| POSTPROCESSING: "postprocessing", | |
| COMPLETE: "complete", | |
| ERROR: "error", | |
| }; | |
| // --------------------------------------------------------------------------- | |
| // Module-level singleton state | |
| // --------------------------------------------------------------------------- | |
| let cachedModel = null; | |
| let modelLoadPromise = null; | |
| let modelStatus = { | |
| loaded: false, | |
| loading: false, | |
| error: null, | |
| usingMock: false, | |
| }; | |
| // --------------------------------------------------------------------------- | |
| // Model Loading | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Load and cache the TF.js model. | |
| * | |
| * On first call, attempts to load from MODEL_PATH/model.json. | |
| * Subsequent calls return the cached model immediately. | |
| * If loading fails, sets `usingMock = true` and the inference | |
| * pipeline will use deterministic mock analysis. | |
| * | |
| * @param {function} [onProgress] - Optional progress callback (0-1). | |
| * @returns {Promise<{ loaded: boolean, usingMock: boolean }>} | |
| */ | |
| export async function loadModel(onProgress) { | |
| // Return cached model if available | |
| if (cachedModel) { | |
| return { loaded: true, usingMock: false }; | |
| } | |
| // If a load is already in flight, wait for it | |
| if (modelLoadPromise) { | |
| return modelLoadPromise; | |
| } | |
| modelStatus = { loaded: false, loading: true, error: null, usingMock: false }; | |
| modelLoadPromise = (async () => { | |
| try { | |
| const modelUrl = `${MODEL_PATH}/model.json`; | |
| cachedModel = await tf.loadLayersModel(modelUrl, { | |
| onProgress: (fraction) => { | |
| if (typeof onProgress === "function") { | |
| onProgress(fraction); | |
| } | |
| }, | |
| }); | |
| // Warm-up inference with a dummy tensor to compile shaders/WASM | |
| const warmup = tf.zeros([1, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE, 3]); | |
| const warmupResult = cachedModel.predict(warmup); | |
| warmup.dispose(); | |
| if (warmupResult.dispose) warmupResult.dispose(); | |
| modelStatus = { loaded: true, loading: false, error: null, usingMock: false }; | |
| return { loaded: true, usingMock: false }; | |
| } catch (err) { | |
| cachedModel = null; | |
| modelStatus = { | |
| loaded: false, | |
| loading: false, | |
| error: err.message || "Failed to load AI model", | |
| usingMock: true, | |
| }; | |
| return { loaded: false, usingMock: true }; | |
| } finally { | |
| modelLoadPromise = null; | |
| } | |
| })(); | |
| return modelLoadPromise; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Image Preprocessing | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Convert a File/Blob to an HTMLImageElement. | |
| * | |
| * @param {File|Blob} file - Image file. | |
| * @returns {Promise<HTMLImageElement>} | |
| */ | |
| function fileToImage(file) { | |
| return new Promise((resolve, reject) => { | |
| const url = URL.createObjectURL(file); | |
| const img = new Image(); | |
| img.onload = () => { | |
| URL.revokeObjectURL(url); | |
| resolve(img); | |
| }; | |
| img.onerror = () => { | |
| URL.revokeObjectURL(url); | |
| reject(new Error("Failed to decode image file.")); | |
| }; | |
| img.src = url; | |
| }); | |
| } | |
| /** | |
| * Preprocess an image for model input. | |
| * | |
| * Steps: | |
| * 1. Decode the File into an HTMLImageElement | |
| * 2. Convert to a tensor and resize to MODEL_INPUT_SIZE x MODEL_INPUT_SIZE | |
| * 3. Normalize pixel values from [0, 255] to [0, 1] | |
| * 4. Expand dimensions to create a batch of size 1 | |
| * | |
| * @param {File} file - Uploaded image file. | |
| * @returns {Promise<{ tensor: tf.Tensor, imageElement: HTMLImageElement }>} | |
| */ | |
| async function preprocessImage(file) { | |
| const img = await fileToImage(file); | |
| const tensor = tf.tidy(() => { | |
| const rawTensor = tf.browser.fromPixels(img); | |
| const resized = tf.image.resizeBilinear( | |
| rawTensor, | |
| [MODEL_INPUT_SIZE, MODEL_INPUT_SIZE], | |
| ); | |
| const normalized = resized.div(255.0); | |
| return normalized.expandDims(0); | |
| }); | |
| return { tensor, imageElement: img }; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Real Model Inference | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Run inference on a preprocessed tensor using the loaded TF.js model. | |
| * | |
| * @param {tf.Tensor} inputTensor - Preprocessed [1, 224, 224, 3] tensor. | |
| * @returns {Promise<Array<{ classIndex: number, confidence: number }>>} | |
| */ | |
| async function runModelInference(inputTensor) { | |
| const outputTensor = cachedModel.predict(inputTensor); | |
| const probabilities = await outputTensor.data(); | |
| outputTensor.dispose(); | |
| const predictions = Array.from(probabilities) | |
| .map((confidence, classIndex) => ({ classIndex, confidence })) | |
| .filter((p) => p.confidence >= CONFIDENCE_THRESHOLD) | |
| .sort((a, b) => b.confidence - a.confidence) | |
| .slice(0, MAX_PREDICTIONS); | |
| return predictions; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Mock Inference (deterministic, image-dependent) | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Compute a simple hash from image pixel data to produce | |
| * deterministic but image-dependent mock predictions. | |
| * | |
| * This ensures the same image always produces the same result, | |
| * making the mock feel realistic for demo and testing purposes. | |
| * | |
| * @param {HTMLImageElement} img - Decoded image element. | |
| * @returns {number} Hash value. | |
| */ | |
| function computeImageHash(img) { | |
| const canvas = document.createElement("canvas"); | |
| const size = 32; | |
| canvas.width = size; | |
| canvas.height = size; | |
| const ctx = canvas.getContext("2d"); | |
| ctx.drawImage(img, 0, 0, size, size); | |
| const imageData = ctx.getImageData(0, 0, size, size); | |
| const data = imageData.data; | |
| let hash = 0; | |
| for (let i = 0; i < data.length; i += 4) { | |
| hash = ((hash << 5) - hash + data[i]) | 0; | |
| hash = ((hash << 5) - hash + data[i + 1]) | 0; | |
| hash = ((hash << 5) - hash + data[i + 2]) | 0; | |
| } | |
| return Math.abs(hash); | |
| } | |
| /** | |
| * Compute average colour channel values for green-detection heuristic. | |
| * | |
| * @param {HTMLImageElement} img - Decoded image element. | |
| * @returns {{ r: number, g: number, b: number }} | |
| */ | |
| function computeAverageColor(img) { | |
| const canvas = document.createElement("canvas"); | |
| const size = 64; | |
| canvas.width = size; | |
| canvas.height = size; | |
| const ctx = canvas.getContext("2d"); | |
| ctx.drawImage(img, 0, 0, size, size); | |
| const imageData = ctx.getImageData(0, 0, size, size); | |
| const data = imageData.data; | |
| let r = 0; | |
| let g = 0; | |
| let b = 0; | |
| const pixelCount = data.length / 4; | |
| for (let i = 0; i < data.length; i += 4) { | |
| r += data[i]; | |
| g += data[i + 1]; | |
| b += data[i + 2]; | |
| } | |
| return { | |
| r: r / pixelCount, | |
| g: g / pixelCount, | |
| b: b / pixelCount, | |
| }; | |
| } | |
| /** | |
| * Generate deterministic mock predictions based on actual image data. | |
| * | |
| * The hash determines the primary disease class, and the colour | |
| * profile influences confidence scores to make them feel realistic. | |
| * | |
| * @param {HTMLImageElement} img - Decoded image element. | |
| * @returns {Array<{ classIndex: number, confidence: number }>} | |
| */ | |
| function mockInference(img) { | |
| const hash = computeImageHash(img); | |
| const avgColor = computeAverageColor(img); | |
| // Determine "greenness" of the image. A very green image may be healthy. | |
| const greenRatio = avgColor.g / (avgColor.r + avgColor.g + avgColor.b + 1); | |
| const isLikelyHealthy = greenRatio > 0.42; | |
| // Exclude the "Healthy Plant" class index (13) for diseased predictions | |
| const diseaseClassCount = NUM_CLASSES - 1; | |
| let primaryIndex; | |
| if (isLikelyHealthy && hash % 5 === 0) { | |
| // ~20% chance to flag healthy for very green images | |
| primaryIndex = NUM_CLASSES - 1; // Healthy Plant | |
| } else { | |
| primaryIndex = hash % diseaseClassCount; | |
| } | |
| // Primary confidence: 72% - 95% range | |
| const baseConfidence = 0.72 + ((hash % 23) / 100); | |
| const primaryConfidence = Math.min(baseConfidence, 0.95); | |
| // Secondary prediction | |
| const secondaryIndex = (primaryIndex + 1 + (hash % 3)) % diseaseClassCount; | |
| const secondaryConfidence = Math.max( | |
| primaryConfidence * (0.4 + ((hash % 20) / 100)), | |
| CONFIDENCE_THRESHOLD, | |
| ); | |
| // Tertiary prediction | |
| const tertiaryIndex = (secondaryIndex + 1 + (hash % 4)) % diseaseClassCount; | |
| const tertiaryConfidence = Math.max( | |
| secondaryConfidence * (0.3 + ((hash % 15) / 100)), | |
| CONFIDENCE_THRESHOLD, | |
| ); | |
| const predictions = [ | |
| { classIndex: primaryIndex, confidence: primaryConfidence }, | |
| { classIndex: secondaryIndex, confidence: secondaryConfidence }, | |
| { classIndex: tertiaryIndex, confidence: tertiaryConfidence }, | |
| ]; | |
| // If primary is healthy, only return that | |
| if (primaryIndex === NUM_CLASSES - 1) { | |
| return [predictions[0]]; | |
| } | |
| return predictions.filter((p) => p.confidence >= CONFIDENCE_THRESHOLD); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Simulated delay for realistic UX during mock inference | |
| // --------------------------------------------------------------------------- | |
| function delay(ms) { | |
| return new Promise((resolve) => setTimeout(resolve, ms)); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Public API | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Full analysis pipeline. | |
| * | |
| * @param {File} imageFile - The uploaded image File object. | |
| * @param {Object} [options] | |
| * @param {function} [options.onStageChange] - Callback invoked with (stageName, stageData). | |
| * @returns {Promise<{ | |
| * predictions: Array<Object>, | |
| * usingMock: boolean, | |
| * analysisTimeMs: number, | |
| * }>} | |
| */ | |
| export async function analyzeImage(imageFile, options = {}) { | |
| const { onStageChange } = options; | |
| const startTime = performance.now(); | |
| function reportStage(stage, data = {}) { | |
| if (typeof onStageChange === "function") { | |
| onStageChange(stage, data); | |
| } | |
| } | |
| try { | |
| // Stage 1: Load model | |
| reportStage(ANALYSIS_STAGES.LOADING_MODEL, { progress: 0 }); | |
| const { usingMock } = await loadModel((progress) => { | |
| reportStage(ANALYSIS_STAGES.LOADING_MODEL, { progress }); | |
| }); | |
| // Stage 2: Preprocess image | |
| reportStage(ANALYSIS_STAGES.PREPROCESSING); | |
| const { tensor, imageElement } = await preprocessImage(imageFile); | |
| // Simulate slight delay for UX when using mock | |
| if (usingMock) { | |
| await delay(600); | |
| } | |
| // Stage 3: Run inference | |
| reportStage(ANALYSIS_STAGES.RUNNING_INFERENCE); | |
| let rawPredictions; | |
| if (usingMock) { | |
| await delay(800); | |
| rawPredictions = mockInference(imageElement); | |
| tensor.dispose(); | |
| } else { | |
| rawPredictions = await runModelInference(tensor); | |
| tensor.dispose(); | |
| } | |
| // Stage 4: Post-process - map class indices to disease info | |
| reportStage(ANALYSIS_STAGES.POSTPROCESSING); | |
| if (usingMock) { | |
| await delay(400); | |
| } | |
| const predictions = rawPredictions.map((pred) => { | |
| const diseaseInfo = getDiseaseByClassIndex(pred.classIndex); | |
| if (!diseaseInfo) { | |
| return { | |
| classIndex: pred.classIndex, | |
| confidence: Math.round(pred.confidence * 1000) / 10, | |
| disease_name: "Unknown Disease", | |
| disease_name_hindi: "अज्ञात रोग", | |
| crop_type: "Unknown", | |
| symptoms: "Unable to identify. Please consult a local agricultural expert.", | |
| affected_stages: "N/A", | |
| treatment_chemical: "Consult your nearest Krishi Vigyan Kendra (KVK) for diagnosis.", | |
| treatment_organic: "Consult your nearest Krishi Vigyan Kendra (KVK) for diagnosis.", | |
| dosage: "N/A", | |
| cost_per_acre: 0, | |
| prevention_tips: "Regular field monitoring, maintain crop hygiene", | |
| }; | |
| } | |
| return { | |
| ...diseaseInfo, | |
| confidence: Math.round(pred.confidence * 1000) / 10, | |
| }; | |
| }); | |
| const analysisTimeMs = Math.round(performance.now() - startTime); | |
| reportStage(ANALYSIS_STAGES.COMPLETE, { predictions, analysisTimeMs }); | |
| return { predictions, usingMock, analysisTimeMs }; | |
| } catch (err) { | |
| const analysisTimeMs = Math.round(performance.now() - startTime); | |
| reportStage(ANALYSIS_STAGES.ERROR, { | |
| error: err.message || "Analysis failed", | |
| }); | |
| throw new AnalysisError( | |
| err.message || "Image analysis failed. Please try again.", | |
| analysisTimeMs, | |
| ); | |
| } | |
| } | |
| /** | |
| * Custom error class for analysis failures. | |
| */ | |
| export class AnalysisError extends Error { | |
| /** | |
| * @param {string} message - Error description. | |
| * @param {number} analysisTimeMs - Elapsed time before failure. | |
| */ | |
| constructor(message, analysisTimeMs = 0) { | |
| super(message); | |
| this.name = "AnalysisError"; | |
| this.analysisTimeMs = analysisTimeMs; | |
| } | |
| } | |
| /** | |
| * Return the current model status. | |
| * | |
| * @returns {{ loaded: boolean, loading: boolean, error: string|null, usingMock: boolean }} | |
| */ | |
| export function getModelStatus() { | |
| return { ...modelStatus }; | |
| } | |
| /** | |
| * Dispose the cached model and reset state. | |
| * Useful for testing or freeing GPU/WASM memory. | |
| */ | |
| export function resetModel() { | |
| if (cachedModel) { | |
| cachedModel.dispose(); | |
| cachedModel = null; | |
| } | |
| modelLoadPromise = null; | |
| modelStatus = { | |
| loaded: false, | |
| loading: false, | |
| error: null, | |
| usingMock: false, | |
| }; | |
| } | |