Your Name commited on
Commit
f3d5e99
Β·
1 Parent(s): 5354624

v3: Auto API key generation with Supabase + developer portal

Browse files
Files changed (5) hide show
  1. Dockerfile +2 -16
  2. README.md +75 -4
  3. main.py +239 -274
  4. portal.html +295 -0
  5. requirements.txt +1 -0
Dockerfile CHANGED
@@ -1,25 +1,11 @@
1
- # Use official Python slim image
2
  FROM python:3.11-slim
3
-
4
- # Set working directory
5
  WORKDIR /app
6
-
7
- # Install system dependencies for TensorFlow
8
- RUN apt-get update && apt-get install -y \
9
- libhdf5-dev \
10
- && rm -rf /var/lib/apt/lists/*
11
-
12
- # Copy requirements first (for Docker cache efficiency)
13
  COPY requirements.txt .
14
  RUN pip install --no-cache-dir -r requirements.txt
15
-
16
- # Copy model files and API code
17
  COPY best_v6.keras .
18
  COPY class_names.json .
19
  COPY main.py .
20
-
21
- # Expose port
22
  EXPOSE 7860
23
-
24
- # Start the FastAPI server
25
  CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
 
 
1
  FROM python:3.11-slim
 
 
2
  WORKDIR /app
3
+ RUN apt-get update && apt-get install -y libhdf5-dev && rm -rf /var/lib/apt/lists/*
 
 
 
 
 
 
4
  COPY requirements.txt .
5
  RUN pip install --no-cache-dir -r requirements.txt
 
 
6
  COPY best_v6.keras .
7
  COPY class_names.json .
8
  COPY main.py .
9
+ COPY portal.html .
 
10
  EXPOSE 7860
 
 
11
  CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -10,10 +10,81 @@ app_port: 7860
10
 
11
  # 🌾 Crop Classifier API
12
 
13
- AI-powered crop classification API built on **EfficientNetB3 v6** (93.48% accuracy) + **LLaMA-3.2-90B Vision** expert verification.
14
 
15
- ## Usage
16
 
17
- `POST /predict` with your image + `x-api-key` header.
 
 
18
 
19
- See `/docs` for interactive Swagger UI.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  # 🌾 Crop Classifier API
12
 
13
+ AI-powered REST API that classifies **50 crop varieties** from images using EfficientNetB3 (93.48% accuracy) + LLaMA-3.2-90B Vision expert verification.
14
 
15
+ ## πŸ”‘ Get an API Key
16
 
17
+ Contact the admin to request your free API key:
18
+ - πŸ“§ Email: **your-email@gmail.com**
19
+ - Or open a [Discussion](https://huggingface.co/spaces/VDX-0/crop-classifier-api/discussions) on this Space
20
 
21
+ ---
22
+
23
+ ## πŸ“‘ Base URL
24
+ ```
25
+ https://vdx-0-crop-classifier-api.hf.space
26
+ ```
27
+
28
+ ## πŸš€ Quick Start
29
+
30
+ ### JavaScript
31
+ ```javascript
32
+ const formData = new FormData();
33
+ formData.append("file", imageFile); // your image file
34
+
35
+ const response = await fetch("https://vdx-0-crop-classifier-api.hf.space/predict", {
36
+ method: "POST",
37
+ headers: { "x-api-key": "YOUR_API_KEY_HERE" },
38
+ body: formData
39
+ });
40
+
41
+ const result = await response.json();
42
+ console.log(result.final_answer.crop_name); // "Wheat"
43
+ console.log(result.final_answer.quality); // "Excellent"
44
+ console.log(result.final_answer.explanation); // "Full description..."
45
+ ```
46
+
47
+ ### Python
48
+ ```python
49
+ import requests
50
+
51
+ with open("crop.jpg", "rb") as f:
52
+ res = requests.post(
53
+ "https://vdx-0-crop-classifier-api.hf.space/predict",
54
+ headers={"x-api-key": "YOUR_API_KEY_HERE"},
55
+ files={"file": f}
56
+ )
57
+ print(res.json()["final_answer"])
58
+ ```
59
+
60
+ ### cURL
61
+ ```bash
62
+ curl -X POST "https://vdx-0-crop-classifier-api.hf.space/predict" \
63
+ -H "x-api-key: YOUR_API_KEY_HERE" \
64
+ -F "file=@crop_image.jpg"
65
+ ```
66
+
67
+ ---
68
+
69
+ ## πŸ“¦ Response Example
70
+ ```json
71
+ {
72
+ "final_answer": {
73
+ "crop_name": "Wheat",
74
+ "quality": "Excellent",
75
+ "market_grade": "Grade A",
76
+ "characteristics": "Golden stalks with dry grain heads...",
77
+ "explanation": "High quality mature wheat crop...",
78
+ "storage_tip": "Store in cool, dry place in sealed bags",
79
+ "confidence_label": "High"
80
+ }
81
+ }
82
+ ```
83
+
84
+ ## ⚑ Endpoints
85
+ | Endpoint | Description |
86
+ |---|---|
87
+ | `POST /predict` | Full analysis (model + AI expert) |
88
+ | `POST /predict/fast` | Model only, instant response |
89
+ | `GET /crops` | List all 50 supported crops |
90
+ | `GET /docs` | Interactive API documentation |
main.py CHANGED
@@ -1,141 +1,104 @@
1
  """
2
- Crop Classifier REST API v2.0
3
- ===============================
4
- FastAPI server wrapping the EfficientNetB3 crop classification model (v6).
5
- Supports 50 crop varieties with API key authentication.
6
-
7
- Improvements in v2:
8
- - Richer LLaMA output: scientific name, market grade, storage tip, prediction accuracy
9
- - Confidence labels (High / Medium / Low) on every prediction
10
- - Combined final_verdict field
11
- - Request ID + timestamp on every response
12
- - /predict/fast endpoint (model only, no LLaMA) for speed-sensitive callers
13
  """
14
 
15
- from fastapi import FastAPI, File, UploadFile, HTTPException, Header, Depends, Query
16
  from fastapi.middleware.cors import CORSMiddleware
 
 
 
17
  import numpy as np
18
- import json
19
- import os
20
  from PIL import Image
21
  import tensorflow as tf
22
  from tensorflow.keras.applications.efficientnet import preprocess_input
23
- import io
24
- import time
25
- import logging
26
- import base64
27
- import requests
28
- import uuid
29
- from datetime import datetime, timezone
30
 
31
- # ─────────────────────────────────────────────
32
- # Logging
33
- # ─────────────────────────────────────────────
34
  logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
35
  logger = logging.getLogger(__name__)
36
 
37
- # ─────────────────────────────────────────────
38
- # App
39
- # ─────────────────────────────────────────────
40
- app = FastAPI(
41
- title="🌾 Crop Classifier API",
42
- description=(
43
- "AI-powered REST API to classify crop images into 50 varieties.\n\n"
44
- "**Model:** EfficientNetB3 v6 (93.48% accuracy)\n"
45
- "**AI Expert:** LLaMA-3.2-90B Vision (NVIDIA)\n\n"
46
- "### How to use\n"
47
- "1. Get an API key from the admin.\n"
48
- "2. `POST /predict` with your image + `x-api-key` header.\n"
49
- "3. Get structured JSON with crop name, quality, grade, storage tips.\n\n"
50
- "### Endpoints\n"
51
- "- `POST /predict` β€” Full analysis (model + LLaMA expert)\n"
52
- "- `POST /predict/fast` β€” Model only (no LLaMA, instant response)\n"
53
- "- `GET /crops` β€” List all 50 supported crops\n"
54
- ),
55
- version="2.0.0",
56
- )
57
-
58
- app.add_middleware(
59
- CORSMiddleware,
60
- allow_origins=["*"],
61
- allow_credentials=True,
62
- allow_methods=["*"],
63
- allow_headers=["*"],
64
  )
 
 
65
 
66
- # ─────────────────────────────────────────────
67
- # API Keys
68
- # ─────────────────────────────────────────────
69
- def load_api_keys() -> dict:
70
- raw = os.environ.get("API_KEYS", "")
71
- keys = {}
72
- if raw:
73
- for entry in raw.split(","):
74
- parts = entry.strip().split(":", 1)
75
- if len(parts) == 2:
76
- keys[parts[0]] = parts[1]
77
- if not keys:
78
- keys = {
79
- "dev-test-key-12345": "Local Development",
80
- "kisansetu-app-key-99": "KisanSetu WebApp",
81
- }
82
- return keys
83
 
84
- VALID_API_KEYS: dict = load_api_keys()
 
 
 
 
 
 
 
 
 
 
 
85
 
 
 
86
 
87
- def validate_api_key(x_api_key: str = Header(..., description="Your API key")):
88
- if x_api_key not in VALID_API_KEYS:
89
- logger.warning(f"Rejected invalid API key: {x_api_key[:8]}...")
90
- raise HTTPException(status_code=401, detail={
91
- "error": "Unauthorized",
92
- "message": "Invalid or missing API key.",
93
- })
94
- return VALID_API_KEYS[x_api_key]
 
 
 
 
95
 
96
- # ─────────────────────────────────────────────
97
- # Model Loading
98
- # ─────────────────────────────────────────────
99
  MODEL_PATH = os.environ.get("MODEL_PATH", "best_v6.keras")
100
  JSON_PATH = os.environ.get("JSON_PATH", "class_names.json")
101
 
102
  logger.info(f"Loading model: {MODEL_PATH}")
103
  model = tf.keras.models.load_model(MODEL_PATH)
104
- logger.info("Model loaded.")
105
-
106
  with open(JSON_PATH) as f:
107
  class_names: list = json.load(f)["class_names"]
108
- logger.info(f"Loaded {len(class_names)} classes.")
 
 
 
 
 
109
 
110
- # ─────────────────────────────────────────────
111
- # Helpers
112
- # ─────────────────────────────────────────────
113
  NVIDIA_API_KEY = os.environ.get(
114
  "NVIDIA_API_KEY",
115
  "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"
116
  )
117
  LLAMA_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
118
 
119
-
120
  def confidence_label(pct: float) -> str:
121
- """Convert confidence % to human-readable label."""
122
- if pct >= 70: return "High"
123
- if pct >= 40: return "Medium"
124
- return "Low"
125
-
126
 
127
  def preprocess_image(image_bytes: bytes) -> np.ndarray:
128
  try:
129
  image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
130
  except Exception:
131
- raise HTTPException(status_code=422, detail="Cannot decode image. Upload a valid JPG/PNG/WEBP/BMP file.")
132
  image = image.resize((224, 224))
133
  arr = np.expand_dims(np.array(image, dtype=np.float32), axis=0)
134
  return preprocess_input(arr)
135
 
136
-
137
  def compress_image(image_bytes: bytes) -> bytes:
138
- """Resize to 768Γ—768 JPEG-85 for LLaMA β€” balanced quality vs payload size."""
139
  try:
140
  img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
141
  img.thumbnail((768, 768))
@@ -145,211 +108,234 @@ def compress_image(image_bytes: bytes) -> bytes:
145
  except Exception:
146
  return image_bytes
147
 
148
-
149
  def call_llama_vision(image_bytes: bytes, top3_preds: list) -> dict:
150
- """
151
- Call NVIDIA LLaMA-3.2-90B Vision for expert crop analysis.
152
- Returns structured fields + richer agronomic data.
153
- """
154
  try:
155
- compressed = compress_image(image_bytes)
156
- img_b64 = base64.b64encode(compressed).decode("utf-8")
157
-
158
- predictions_str = ", ".join(
159
- f"{p['crop']} ({p['confidence_percent']}%)" for p in top3_preds
160
- )
161
-
162
  prompt = (
163
  "You are an expert agricultural scientist and crop quality inspector with 20 years of experience.\n"
164
  "Carefully analyze the crop or agricultural product shown in this image.\n\n"
165
  f"An automated vision model suggests it might be: {predictions_str}\n\n"
166
  "Respond ONLY in this exact format β€” no extra text, no preamble:\n\n"
167
- "**Crop Name:** [Correct common name of the crop/product]\n"
168
- "**Scientific Name:** [Latin/scientific name, or 'N/A' if unknown]\n"
169
- "**Characteristics:** [Visual features: color, shape, texture, size, form]\n"
170
  "**Quality:** [Choose ONE: Premium, Excellent, Very Good, Good, Fair, or Bad]\n"
171
  "**Market Grade:** [Choose ONE: Grade A, Grade B, Grade C, or Ungraded]\n"
172
- "**Prediction Accuracy:** [Is the model correct? Choose ONE: Correct, Partially Correct, or Incorrect]\n"
173
- "**Storage Tip:** [One practical tip for storing or handling this crop]\n"
174
- "**Explanation:** [2-3 sentences explaining your identification and quality assessment]"
175
  )
176
-
177
  payload = {
178
  "model": "meta/llama-3.2-90b-vision-instruct",
179
  "messages": [{"role": "user", "content": [
180
  {"type": "text", "text": prompt},
181
  {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
182
  ]}],
183
- "max_tokens": 600,
184
- "temperature": 0.3, # lower = more consistent, structured output
185
- "top_p": 0.9,
186
- "stream": False
187
  }
188
-
189
  headers = {"Authorization": f"Bearer {NVIDIA_API_KEY}", "Accept": "application/json"}
190
- resp = requests.post(LLAMA_URL, headers=headers, json=payload, timeout=60)
191
  resp.raise_for_status()
192
  raw_text = resp.json()["choices"][0]["message"]["content"]
193
 
194
- # Robust parser β€” strips leading bullets and ** markers before matching
195
- fields = {
196
- "crop_name": None,
197
- "scientific_name": None,
198
- "characteristics": None,
199
- "quality": None,
200
- "market_grade": None,
201
- "prediction_accuracy": None,
202
- "storage_tip": None,
203
- "explanation": None,
204
- }
205
-
206
  for line in raw_text.splitlines():
207
  line = line.strip()
208
- if line.startswith("- ") or line.startswith("* "):
209
- line = line[2:]
210
  clean = line.replace("**", "").strip()
211
  cl = clean.lower()
212
-
213
- if cl.startswith("crop name:"):
214
- fields["crop_name"] = clean.split(":", 1)[1].strip()
215
- elif cl.startswith("scientific name:"):
216
- fields["scientific_name"] = clean.split(":", 1)[1].strip()
217
- elif cl.startswith("characteristics:"):
218
- fields["characteristics"] = clean.split(":", 1)[1].strip()
219
- elif cl.startswith("quality:"):
220
- fields["quality"] = clean.split(":", 1)[1].strip()
221
- elif cl.startswith("market grade:"):
222
- fields["market_grade"] = clean.split(":", 1)[1].strip()
223
- elif cl.startswith("prediction accuracy:"):
224
- fields["prediction_accuracy"] = clean.split(":", 1)[1].strip()
225
- elif cl.startswith("storage tip:"):
226
- fields["storage_tip"] = clean.split(":", 1)[1].strip()
227
- elif cl.startswith("explanation:"):
228
- fields["explanation"] = clean.split(":", 1)[1].strip()
229
-
230
  fields["raw"] = raw_text
231
  return fields
232
-
233
  except Exception as e:
234
- logger.warning(f"LLaMA call failed: {e}")
235
  return {"error": str(e), "raw": None}
236
 
237
-
238
  def build_final_answer(top3: list, ai: dict | None) -> dict:
239
- """
240
- The clean, user-facing final answer β€” crop name, quality, characteristics, explanation.
241
- Sourced from LLaMA when available, falls back to model prediction.
242
- """
243
  ai_ok = ai and ai.get("crop_name") and not ai.get("error")
244
  return {
245
- "crop_name": ai.get("crop_name") if ai_ok else top3[0]["crop"],
246
- "quality": ai.get("quality") if ai_ok else "Unavailable",
247
- "market_grade": ai.get("market_grade") if ai_ok else "Unavailable",
248
- "characteristics": ai.get("characteristics") if ai_ok else "Unavailable",
249
- "explanation": ai.get("explanation") if ai_ok else "Unavailable",
250
- "storage_tip": ai.get("storage_tip") if ai_ok else "Unavailable",
251
- "confidence_label":confidence_label(top3[0]["confidence_percent"]),
252
  }
253
 
254
- # ─────────────────────────────────────────────
255
- # Routes
256
- # ─────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
 
258
  @app.get("/", tags=["Info"])
259
  def root():
260
  return {
261
- "api": "Crop Classifier API",
262
- "version": "2.0.0",
263
- "model": "EfficientNetB3 v6",
264
- "accuracy": "93.48%",
265
- "supported_crops": len(class_names),
266
- "status": "online",
267
- "endpoints": {
268
- "full_analysis": "POST /predict",
269
- "fast_predict": "POST /predict/fast",
270
- "crop_list": "GET /crops",
271
- "docs": "/docs",
272
- }
273
  }
274
 
275
-
276
  @app.get("/health", tags=["Info"])
277
  def health():
278
  return {"status": "ok"}
279
 
280
-
281
  @app.get("/crops", tags=["Info"])
282
  def list_crops():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  return {
284
- "total": len(class_names),
285
- "crops": [n.replace("_", " ").title() for n in class_names]
 
 
 
 
 
 
 
286
  }
287
 
288
 
 
 
 
 
 
 
 
 
 
289
  @app.post("/predict", tags=["Prediction"])
290
  async def predict(
291
- file: UploadFile = File(..., description="Crop image (JPG/PNG/WEBP/BMP, max 10MB)"),
292
- client_name: str = Depends(validate_api_key),
 
293
  ):
294
- """
295
- ## Full Crop Analysis
296
-
297
- Runs the EfficientNetB3 model **+** LLaMA Vision expert verification.
298
-
299
- Returns:
300
- - Top-3 model predictions with confidence labels
301
- - AI expert: crop name, scientific name, quality, market grade, storage tip
302
- - Final combined verdict
303
- - Request ID + timestamp for traceability
304
- """
305
  request_id = str(uuid.uuid4())
306
  ts = datetime.now(timezone.utc).isoformat()
307
 
308
- # Validate
309
- allowed = {"image/jpeg", "image/png", "image/webp", "image/bmp", "image/jpg"}
310
- if file.content_type and file.content_type not in allowed:
311
- raise HTTPException(status_code=415, detail=f"Unsupported type: {file.content_type}. Use JPG/PNG/WEBP/BMP.")
312
-
313
  image_bytes = await file.read()
314
- if not image_bytes:
315
- raise HTTPException(status_code=422, detail="File is empty.")
316
- if len(image_bytes) > 10 * 1024 * 1024:
317
- raise HTTPException(status_code=413, detail="File too large. Max 10MB.")
318
 
319
- # Model inference
320
  t0 = time.time()
321
  preds = model.predict(preprocess_image(image_bytes), verbose=0)[0]
322
- inference_ms = round((time.time() - t0) * 1000, 1)
323
 
324
  top3_idx = np.argsort(preds)[-3:][::-1]
325
- top3 = [
326
- {
327
- "rank": i + 1,
328
- "crop": class_names[idx].replace("_", " ").title(),
329
- "confidence_percent": round(float(preds[idx]) * 100, 2),
330
- "confidence_label": confidence_label(round(float(preds[idx]) * 100, 2)),
331
- }
332
- for i, idx in enumerate(top3_idx)
333
- ]
334
 
335
- logger.info(f"[{request_id[:8]}] [{client_name}] Model β†’ {top3[0]['crop']} ({top3[0]['confidence_percent']}%) in {inference_ms}ms")
336
-
337
- # LLaMA expert
338
  t1 = time.time()
339
- logger.info(f"[{request_id[:8]}] Calling LLaMA Vision...")
340
  ai = call_llama_vision(image_bytes, top3)
341
- llama_ms = round((time.time() - t1) * 1000, 1)
342
- logger.info(f"[{request_id[:8]}] LLaMA done in {llama_ms}ms")
 
 
343
 
344
  return {
345
  "success": True,
346
  "request_id": request_id,
347
  "timestamp": ts,
348
-
349
- # ── Final Answer (user-facing, all you need) ────────
350
  "final_answer": build_final_answer(top3, ai),
351
-
352
- # ── Model prediction ────────────────────────────────
353
  "model_prediction": {
354
  "top_prediction": top3[0]["crop"],
355
  "confidence_percent": top3[0]["confidence_percent"],
@@ -357,73 +343,52 @@ async def predict(
357
  "top3": top3,
358
  "inference_time_ms": inference_ms,
359
  },
360
-
361
- # ── LLaMA expert analysis ───────────────────────────
362
  "ai_expert_verification": {
363
- "crop_name": ai.get("crop_name"),
364
- "scientific_name": ai.get("scientific_name"),
365
- "characteristics": ai.get("characteristics"),
366
- "quality": ai.get("quality"),
367
- "market_grade": ai.get("market_grade"),
368
- "prediction_accuracy":ai.get("prediction_accuracy"),
369
- "storage_tip": ai.get("storage_tip"),
370
- "explanation": ai.get("explanation"),
371
- "llama_time_ms": llama_ms,
372
- "raw": ai.get("raw"),
373
  },
374
-
375
  "model_version": "v6",
376
- "request_by": client_name,
377
  }
378
 
379
 
 
380
  @app.post("/predict/fast", tags=["Prediction"])
381
  async def predict_fast(
382
- file: UploadFile = File(..., description="Crop image (JPG/PNG/WEBP/BMP, max 10MB)"),
383
- client_name: str = Depends(validate_api_key),
 
384
  ):
385
- """
386
- ## Fast Crop Prediction (Model Only)
387
-
388
- Runs **only** the EfficientNetB3 model β€” no LLaMA call.
389
- Returns results in under 500ms. Use this when speed matters more than expert verification.
390
- """
391
  request_id = str(uuid.uuid4())
392
- ts = datetime.now(timezone.utc).isoformat()
393
-
394
  image_bytes = await file.read()
395
- if not image_bytes:
396
- raise HTTPException(status_code=422, detail="File is empty.")
397
- if len(image_bytes) > 10 * 1024 * 1024:
398
- raise HTTPException(status_code=413, detail="File too large. Max 10MB.")
399
 
400
  t0 = time.time()
401
  preds = model.predict(preprocess_image(image_bytes), verbose=0)[0]
402
- inference_ms = round((time.time() - t0) * 1000, 1)
403
 
404
  top3_idx = np.argsort(preds)[-3:][::-1]
405
- top3 = [
406
- {
407
- "rank": i + 1,
408
- "crop": class_names[idx].replace("_", " ").title(),
409
- "confidence_percent": round(float(preds[idx]) * 100, 2),
410
- "confidence_label": confidence_label(round(float(preds[idx]) * 100, 2)),
411
- }
412
- for i, idx in enumerate(top3_idx)
413
- ]
414
 
415
- logger.info(f"[{request_id[:8]}] [FAST] [{client_name}] β†’ {top3[0]['crop']} ({top3[0]['confidence_percent']}%) in {inference_ms}ms")
416
 
417
  return {
418
- "success": True,
419
- "request_id": request_id,
420
- "timestamp": ts,
421
  "mode": "fast (model only)",
422
  "top_prediction": top3[0]["crop"],
423
  "confidence_percent": top3[0]["confidence_percent"],
424
  "confidence_label": top3[0]["confidence_label"],
425
- "top3": top3,
426
- "inference_time_ms": inference_ms,
427
- "model_version": "v6",
428
- "request_by": client_name,
429
  }
 
1
  """
2
+ Crop Classifier REST API v3.0
3
+ ================================
4
+ Auto-generated API keys via Supabase.
5
+ Each user registers once β†’ gets a unique key β†’ uses it forever.
 
 
 
 
 
 
 
6
  """
7
 
8
+ from fastapi import FastAPI, File, UploadFile, HTTPException, Header, Depends, BackgroundTasks
9
  from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import HTMLResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+ from pydantic import BaseModel, EmailStr
13
  import numpy as np
14
+ import json, os, io, time, logging, base64, uuid, secrets
15
+ from datetime import datetime, timezone
16
  from PIL import Image
17
  import tensorflow as tf
18
  from tensorflow.keras.applications.efficientnet import preprocess_input
19
+ import requests as req_lib
20
+ import httpx
 
 
 
 
 
21
 
 
 
 
22
  logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
23
  logger = logging.getLogger(__name__)
24
 
25
+ # ── Supabase config ─────────────────────────────────────────────────────────
26
+ SUPABASE_URL = os.environ.get("SUPABASE_URL", "https://ykvatttsnpjrwqfhhysu.supabase.co")
27
+ SUPABASE_KEY = os.environ.get("SUPABASE_KEY",
28
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlrdmF0dHRzbnBqcndxZmhoeXN1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA0OTk5NjQsImV4cCI6MjA4NjA3NTk2NH0.5Njnh8NBEcPDddHjwv3CoUpCcAHu-ALNUQHQVdAdq-Y"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  )
30
+ SB_HEADERS = {"apikey": SUPABASE_KEY, "Authorization": f"Bearer {SUPABASE_KEY}", "Content-Type": "application/json"}
31
+ SB_TABLE = f"{SUPABASE_URL}/rest/v1/crop_api_keys"
32
 
33
+ # ── In-memory key cache (refreshed every 60 seconds) ──────────────────────
34
+ _key_cache: dict = {} # {api_key: {name, email, id}}
35
+ _cache_ts: float = 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
+ async def refresh_key_cache():
38
+ global _key_cache, _cache_ts
39
+ try:
40
+ async with httpx.AsyncClient() as client:
41
+ r = await client.get(SB_TABLE + "?is_active=eq.true&select=api_key,name,email,id",
42
+ headers=SB_HEADERS, timeout=10)
43
+ if r.status_code == 200:
44
+ _key_cache = {row["api_key"]: row for row in r.json()}
45
+ _cache_ts = time.time()
46
+ logger.info(f"Key cache refreshed: {len(_key_cache)} active keys")
47
+ except Exception as e:
48
+ logger.warning(f"Key cache refresh failed: {e}")
49
 
50
+ def get_key_info(api_key: str) -> dict | None:
51
+ return _key_cache.get(api_key)
52
 
53
+ # ── App ──────────────────────────────────────────────────────────────────────
54
+ app = FastAPI(
55
+ title="🌾 Crop Classifier API",
56
+ description=(
57
+ "AI-powered crop image classification API.\n\n"
58
+ "**Get your free API key** β†’ visit `/portal` \n\n"
59
+ "**Docs** β†’ `/docs`"
60
+ ),
61
+ version="3.0.0",
62
+ )
63
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
64
+ allow_methods=["*"], allow_headers=["*"])
65
 
66
+ # ── Startup: load model + warm cache ─────────────────────────────────────────
 
 
67
  MODEL_PATH = os.environ.get("MODEL_PATH", "best_v6.keras")
68
  JSON_PATH = os.environ.get("JSON_PATH", "class_names.json")
69
 
70
  logger.info(f"Loading model: {MODEL_PATH}")
71
  model = tf.keras.models.load_model(MODEL_PATH)
 
 
72
  with open(JSON_PATH) as f:
73
  class_names: list = json.load(f)["class_names"]
74
+ logger.info(f"Model loaded. {len(class_names)} classes.")
75
+
76
+ import asyncio
77
+ @app.on_event("startup")
78
+ async def startup():
79
+ await refresh_key_cache()
80
 
81
+ # ── NVIDIA / LLaMA ───────────────────────────────────────────────────────────
 
 
82
  NVIDIA_API_KEY = os.environ.get(
83
  "NVIDIA_API_KEY",
84
  "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"
85
  )
86
  LLAMA_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
87
 
88
+ # ── Helpers ───────────────────────────────────────────────────────────────────
89
  def confidence_label(pct: float) -> str:
90
+ return "High" if pct >= 70 else "Medium" if pct >= 40 else "Low"
 
 
 
 
91
 
92
  def preprocess_image(image_bytes: bytes) -> np.ndarray:
93
  try:
94
  image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
95
  except Exception:
96
+ raise HTTPException(status_code=422, detail="Cannot decode image.")
97
  image = image.resize((224, 224))
98
  arr = np.expand_dims(np.array(image, dtype=np.float32), axis=0)
99
  return preprocess_input(arr)
100
 
 
101
  def compress_image(image_bytes: bytes) -> bytes:
 
102
  try:
103
  img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
104
  img.thumbnail((768, 768))
 
108
  except Exception:
109
  return image_bytes
110
 
 
111
  def call_llama_vision(image_bytes: bytes, top3_preds: list) -> dict:
 
 
 
 
112
  try:
113
+ img_b64 = base64.b64encode(compress_image(image_bytes)).decode("utf-8")
114
+ predictions_str = ", ".join(f"{p['crop']} ({p['confidence_percent']}%)" for p in top3_preds)
 
 
 
 
 
115
  prompt = (
116
  "You are an expert agricultural scientist and crop quality inspector with 20 years of experience.\n"
117
  "Carefully analyze the crop or agricultural product shown in this image.\n\n"
118
  f"An automated vision model suggests it might be: {predictions_str}\n\n"
119
  "Respond ONLY in this exact format β€” no extra text, no preamble:\n\n"
120
+ "**Crop Name:** [Correct common name]\n"
121
+ "**Scientific Name:** [Latin name, or 'N/A']\n"
122
+ "**Characteristics:** [Visual features: color, shape, texture, size]\n"
123
  "**Quality:** [Choose ONE: Premium, Excellent, Very Good, Good, Fair, or Bad]\n"
124
  "**Market Grade:** [Choose ONE: Grade A, Grade B, Grade C, or Ungraded]\n"
125
+ "**Prediction Accuracy:** [Choose ONE: Correct, Partially Correct, or Incorrect]\n"
126
+ "**Storage Tip:** [One practical storage recommendation]\n"
127
+ "**Explanation:** [2-3 sentences on identification and quality]"
128
  )
 
129
  payload = {
130
  "model": "meta/llama-3.2-90b-vision-instruct",
131
  "messages": [{"role": "user", "content": [
132
  {"type": "text", "text": prompt},
133
  {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
134
  ]}],
135
+ "max_tokens": 600, "temperature": 0.3, "top_p": 0.9, "stream": False
 
 
 
136
  }
 
137
  headers = {"Authorization": f"Bearer {NVIDIA_API_KEY}", "Accept": "application/json"}
138
+ resp = req_lib.post(LLAMA_URL, headers=headers, json=payload, timeout=60)
139
  resp.raise_for_status()
140
  raw_text = resp.json()["choices"][0]["message"]["content"]
141
 
142
+ fields = {"crop_name": None, "scientific_name": None, "characteristics": None,
143
+ "quality": None, "market_grade": None, "prediction_accuracy": None,
144
+ "storage_tip": None, "explanation": None}
 
 
 
 
 
 
 
 
 
145
  for line in raw_text.splitlines():
146
  line = line.strip()
147
+ if line.startswith("- ") or line.startswith("* "): line = line[2:]
 
148
  clean = line.replace("**", "").strip()
149
  cl = clean.lower()
150
+ if cl.startswith("crop name:"): fields["crop_name"] = clean.split(":",1)[1].strip()
151
+ elif cl.startswith("scientific name:"): fields["scientific_name"] = clean.split(":",1)[1].strip()
152
+ elif cl.startswith("characteristics:"): fields["characteristics"] = clean.split(":",1)[1].strip()
153
+ elif cl.startswith("quality:"): fields["quality"] = clean.split(":",1)[1].strip()
154
+ elif cl.startswith("market grade:"): fields["market_grade"] = clean.split(":",1)[1].strip()
155
+ elif cl.startswith("prediction accuracy:"):fields["prediction_accuracy"]= clean.split(":",1)[1].strip()
156
+ elif cl.startswith("storage tip:"): fields["storage_tip"] = clean.split(":",1)[1].strip()
157
+ elif cl.startswith("explanation:"): fields["explanation"] = clean.split(":",1)[1].strip()
 
 
 
 
 
 
 
 
 
 
158
  fields["raw"] = raw_text
159
  return fields
 
160
  except Exception as e:
161
+ logger.warning(f"LLaMA failed: {e}")
162
  return {"error": str(e), "raw": None}
163
 
 
164
  def build_final_answer(top3: list, ai: dict | None) -> dict:
 
 
 
 
165
  ai_ok = ai and ai.get("crop_name") and not ai.get("error")
166
  return {
167
+ "crop_name": ai.get("crop_name") if ai_ok else top3[0]["crop"],
168
+ "quality": ai.get("quality") if ai_ok else "Unavailable",
169
+ "market_grade": ai.get("market_grade") if ai_ok else "Unavailable",
170
+ "characteristics": ai.get("characteristics") if ai_ok else "Unavailable",
171
+ "explanation": ai.get("explanation") if ai_ok else "Unavailable",
172
+ "storage_tip": ai.get("storage_tip") if ai_ok else "Unavailable",
173
+ "confidence_label": confidence_label(top3[0]["confidence_percent"]),
174
  }
175
 
176
+ async def increment_usage(key_id: str):
177
+ """Background task: increment request counter + update last_used_at."""
178
+ try:
179
+ async with httpx.AsyncClient() as client:
180
+ await client.patch(
181
+ f"{SB_TABLE}?id=eq.{key_id}",
182
+ headers=SB_HEADERS,
183
+ json={"last_used_at": datetime.now(timezone.utc).isoformat(),
184
+ "requests_count": None}, # use DB increment below
185
+ timeout=5
186
+ )
187
+ # Use raw SQL increment
188
+ await client.post(f"{SUPABASE_URL}/rest/v1/rpc/increment_usage",
189
+ headers=SB_HEADERS, json={"row_id": key_id}, timeout=5)
190
+ except Exception:
191
+ pass # non-critical
192
+
193
+ # ── API Key Validation ────────────────────────────────────────────────────────
194
+ async def validate_api_key(x_api_key: str = Header(..., description="Your API key")):
195
+ global _cache_ts
196
+ # Refresh cache if older than 60 seconds
197
+ if time.time() - _cache_ts > 60:
198
+ await refresh_key_cache()
199
+ info = get_key_info(x_api_key)
200
+ if not info:
201
+ raise HTTPException(status_code=401, detail={
202
+ "error": "Unauthorized",
203
+ "message": "Invalid API key. Get your free key at /portal"
204
+ })
205
+ return info
206
+
207
+ # ── Registration Model ────────────────────────────────────────────────────────
208
+ class RegisterRequest(BaseModel):
209
+ name: str
210
+ email: str
211
+
212
+ # ══════════════════════════════════════════════════════════════════════════════
213
+ # ROUTES
214
+ # ══════════════════════════════════════════════════════════════════════════════
215
 
216
  @app.get("/", tags=["Info"])
217
  def root():
218
  return {
219
+ "api": "Crop Classifier API", "version": "3.0.0",
220
+ "model": "EfficientNetB3 v6", "accuracy": "93.48%",
221
+ "supported_crops": len(class_names), "status": "online",
222
+ "get_api_key": "/portal",
223
+ "docs": "/docs",
 
 
 
 
 
 
 
224
  }
225
 
 
226
  @app.get("/health", tags=["Info"])
227
  def health():
228
  return {"status": "ok"}
229
 
 
230
  @app.get("/crops", tags=["Info"])
231
  def list_crops():
232
+ return {"total": len(class_names),
233
+ "crops": [n.replace("_"," ").title() for n in class_names]}
234
+
235
+
236
+ # ── REGISTER: auto-generate API key ──────────────────────────────────────────
237
+ @app.post("/register", tags=["API Key"])
238
+ async def register(body: RegisterRequest):
239
+ """
240
+ ## Get your free API key
241
+
242
+ Submit your name and email to receive a unique API key instantly.
243
+ No manual approval needed.
244
+ """
245
+ # Check if email already has a key
246
+ try:
247
+ async with httpx.AsyncClient() as client:
248
+ check = await client.get(
249
+ f"{SB_TABLE}?email=eq.{body.email}&select=api_key,name",
250
+ headers=SB_HEADERS, timeout=10
251
+ )
252
+ if check.status_code == 200 and check.json():
253
+ existing = check.json()[0]
254
+ return {
255
+ "success": True,
256
+ "message": f"You already have an API key, {existing['name']}!",
257
+ "api_key": existing["api_key"],
258
+ "is_new": False,
259
+ }
260
+ except Exception:
261
+ pass
262
+
263
+ # Generate new unique key
264
+ new_key = "crop-" + secrets.token_urlsafe(20)
265
+
266
+ try:
267
+ async with httpx.AsyncClient() as client:
268
+ r = await client.post(SB_TABLE, headers={**SB_HEADERS, "Prefer": "return=representation"},
269
+ json={"api_key": new_key, "name": body.name, "email": body.email}, timeout=10)
270
+ if r.status_code not in (200, 201):
271
+ raise HTTPException(status_code=500, detail="Failed to save API key. Try again.")
272
+ except HTTPException:
273
+ raise
274
+ except Exception as e:
275
+ raise HTTPException(status_code=500, detail=f"Database error: {e}")
276
+
277
+ # Refresh cache immediately
278
+ await refresh_key_cache()
279
+
280
+ logger.info(f"New API key issued to {body.email} ({body.name}): {new_key[:12]}...")
281
  return {
282
+ "success": True,
283
+ "message": f"Welcome, {body.name}! Your API key is ready.",
284
+ "api_key": new_key,
285
+ "is_new": True,
286
+ "usage": {
287
+ "endpoint": "https://vdx-0-crop-classifier-api.hf.space/predict",
288
+ "header": f"x-api-key: {new_key}",
289
+ "docs": "https://vdx-0-crop-classifier-api.hf.space/docs",
290
+ }
291
  }
292
 
293
 
294
+ # ── Developer Portal ──────────────────────────────────────────────────────────
295
+ @app.get("/portal", response_class=HTMLResponse, tags=["API Key"], include_in_schema=False)
296
+ def portal():
297
+ """Beautiful developer portal to get an API key."""
298
+ with open("portal.html", "r") as f:
299
+ return f.read()
300
+
301
+
302
+ # ── PREDICT (full) ────────────────────────────────────────────────────────────
303
  @app.post("/predict", tags=["Prediction"])
304
  async def predict(
305
+ background_tasks: BackgroundTasks,
306
+ file: UploadFile = File(...),
307
+ key_info: dict = Depends(validate_api_key),
308
  ):
309
+ """Full crop analysis: EfficientNetB3 model + LLaMA Vision expert."""
 
 
 
 
 
 
 
 
 
 
310
  request_id = str(uuid.uuid4())
311
  ts = datetime.now(timezone.utc).isoformat()
312
 
 
 
 
 
 
313
  image_bytes = await file.read()
314
+ if not image_bytes: raise HTTPException(422, "File is empty.")
315
+ if len(image_bytes) > 10*1024*1024: raise HTTPException(413, "Max 10MB.")
 
 
316
 
 
317
  t0 = time.time()
318
  preds = model.predict(preprocess_image(image_bytes), verbose=0)[0]
319
+ inference_ms = round((time.time()-t0)*1000, 1)
320
 
321
  top3_idx = np.argsort(preds)[-3:][::-1]
322
+ top3 = [{"rank": i+1, "crop": class_names[idx].replace("_"," ").title(),
323
+ "confidence_percent": round(float(preds[idx])*100, 2),
324
+ "confidence_label": confidence_label(round(float(preds[idx])*100, 2))}
325
+ for i, idx in enumerate(top3_idx)]
 
 
 
 
 
326
 
 
 
 
327
  t1 = time.time()
 
328
  ai = call_llama_vision(image_bytes, top3)
329
+ llama_ms = round((time.time()-t1)*1000, 1)
330
+
331
+ # Increment usage counter in background (non-blocking)
332
+ background_tasks.add_task(increment_usage, key_info["id"])
333
 
334
  return {
335
  "success": True,
336
  "request_id": request_id,
337
  "timestamp": ts,
 
 
338
  "final_answer": build_final_answer(top3, ai),
 
 
339
  "model_prediction": {
340
  "top_prediction": top3[0]["crop"],
341
  "confidence_percent": top3[0]["confidence_percent"],
 
343
  "top3": top3,
344
  "inference_time_ms": inference_ms,
345
  },
 
 
346
  "ai_expert_verification": {
347
+ "crop_name": ai.get("crop_name"),
348
+ "scientific_name": ai.get("scientific_name"),
349
+ "characteristics": ai.get("characteristics"),
350
+ "quality": ai.get("quality"),
351
+ "market_grade": ai.get("market_grade"),
352
+ "prediction_accuracy": ai.get("prediction_accuracy"),
353
+ "storage_tip": ai.get("storage_tip"),
354
+ "explanation": ai.get("explanation"),
355
+ "llama_time_ms": llama_ms,
 
356
  },
 
357
  "model_version": "v6",
358
+ "request_by": key_info["name"],
359
  }
360
 
361
 
362
+ # ── PREDICT FAST (model only) ─────────────────────────────────────────────────
363
  @app.post("/predict/fast", tags=["Prediction"])
364
  async def predict_fast(
365
+ background_tasks: BackgroundTasks,
366
+ file: UploadFile = File(...),
367
+ key_info: dict = Depends(validate_api_key),
368
  ):
369
+ """Fast prediction β€” model only, no LLaMA. Response in <500ms."""
 
 
 
 
 
370
  request_id = str(uuid.uuid4())
 
 
371
  image_bytes = await file.read()
372
+ if not image_bytes: raise HTTPException(422, "File is empty.")
 
 
 
373
 
374
  t0 = time.time()
375
  preds = model.predict(preprocess_image(image_bytes), verbose=0)[0]
376
+ inference_ms = round((time.time()-t0)*1000, 1)
377
 
378
  top3_idx = np.argsort(preds)[-3:][::-1]
379
+ top3 = [{"rank": i+1, "crop": class_names[idx].replace("_"," ").title(),
380
+ "confidence_percent": round(float(preds[idx])*100, 2),
381
+ "confidence_label": confidence_label(round(float(preds[idx])*100, 2))}
382
+ for i, idx in enumerate(top3_idx)]
 
 
 
 
 
383
 
384
+ background_tasks.add_task(increment_usage, key_info["id"])
385
 
386
  return {
387
+ "success": True, "request_id": request_id,
 
 
388
  "mode": "fast (model only)",
389
  "top_prediction": top3[0]["crop"],
390
  "confidence_percent": top3[0]["confidence_percent"],
391
  "confidence_label": top3[0]["confidence_label"],
392
+ "top3": top3, "inference_time_ms": inference_ms,
393
+ "model_version": "v6", "request_by": key_info["name"],
 
 
394
  }
portal.html ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>🌾 Crop Classifier API β€” Developer Portal</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet"/>
8
+ <style>
9
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
10
+ :root {
11
+ --green: #22c55e;
12
+ --green2: #16a34a;
13
+ --bg: #0a0f0d;
14
+ --card: #111a14;
15
+ --border: #1f2e22;
16
+ --text: #e2f0e6;
17
+ --muted: #6b8f74;
18
+ --accent: #bbf7d0;
19
+ }
20
+ body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text);
21
+ min-height: 100vh; }
22
+
23
+ /* ── Hero ── */
24
+ .hero { text-align: center; padding: 72px 24px 48px; position: relative; overflow: hidden; }
25
+ .hero::before {
26
+ content: ''; position: absolute; inset: 0;
27
+ background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(34,197,94,.15) 0%, transparent 70%);
28
+ pointer-events: none;
29
+ }
30
+ .badge { display: inline-block; background: rgba(34,197,94,.15); border: 1px solid rgba(34,197,94,.3);
31
+ color: var(--green); padding: 4px 14px; border-radius: 99px; font-size: 13px; font-weight: 500;
32
+ margin-bottom: 20px; }
33
+ .hero h1 { font-size: clamp(2rem, 5vw, 3.2rem); font-weight: 700; line-height: 1.15; margin-bottom: 16px; }
34
+ .hero h1 span { color: var(--green); }
35
+ .hero p { color: var(--muted); font-size: 1.1rem; max-width: 540px; margin: 0 auto 40px; }
36
+
37
+ /* ── Stats bar ── */
38
+ .stats { display: flex; justify-content: center; gap: 40px; flex-wrap: wrap;
39
+ padding: 24px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);
40
+ background: rgba(255,255,255,.02); }
41
+ .stat { text-align: center; }
42
+ .stat-val { font-size: 1.6rem; font-weight: 700; color: var(--green); }
43
+ .stat-lbl { font-size: 12px; color: var(--muted); margin-top: 2px; }
44
+
45
+ /* ── Main layout ── */
46
+ .container { max-width: 900px; margin: 0 auto; padding: 48px 24px; }
47
+
48
+ /* ── Card ── */
49
+ .card { background: var(--card); border: 1px solid var(--border); border-radius: 16px;
50
+ padding: 36px; margin-bottom: 32px; }
51
+ .card h2 { font-size: 1.3rem; font-weight: 600; margin-bottom: 8px; }
52
+ .card .sub { color: var(--muted); font-size: 14px; margin-bottom: 28px; }
53
+
54
+ /* ── Form ── */
55
+ .form-group { margin-bottom: 20px; }
56
+ label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 8px; color: var(--accent); }
57
+ input { width: 100%; background: rgba(255,255,255,.05); border: 1px solid var(--border);
58
+ border-radius: 10px; padding: 13px 16px; color: var(--text); font-size: 15px;
59
+ font-family: inherit; outline: none; transition: border-color .2s; }
60
+ input:focus { border-color: var(--green); }
61
+ input::placeholder { color: var(--muted); }
62
+ .btn { width: 100%; background: var(--green); color: #000; border: none; border-radius: 10px;
63
+ padding: 14px; font-size: 16px; font-weight: 600; cursor: pointer;
64
+ transition: background .2s, transform .1s; font-family: inherit; }
65
+ .btn:hover { background: var(--green2); }
66
+ .btn:active { transform: scale(0.99); }
67
+ .btn:disabled { opacity: .6; cursor: not-allowed; }
68
+
69
+ /* ── Result box ── */
70
+ #result { display: none; margin-top: 28px; }
71
+ .result-header { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }
72
+ .result-header svg { width: 28px; color: var(--green); flex-shrink: 0; }
73
+ .result-header h3 { font-size: 1.1rem; font-weight: 600; }
74
+ .key-box { background: rgba(34,197,94,.08); border: 1px solid rgba(34,197,94,.25);
75
+ border-radius: 12px; padding: 20px; }
76
+ .key-label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em;
77
+ margin-bottom: 10px; }
78
+ .key-display { display: flex; align-items: center; gap: 12px; }
79
+ .key-text { font-family: 'Fira Code', monospace; font-size: 15px; color: var(--green);
80
+ flex: 1; word-break: break-all; }
81
+ .copy-btn { background: rgba(34,197,94,.15); border: 1px solid rgba(34,197,94,.3); color: var(--green);
82
+ border-radius: 8px; padding: 8px 16px; font-size: 13px; font-weight: 500;
83
+ cursor: pointer; white-space: nowrap; transition: background .2s; font-family: inherit; }
84
+ .copy-btn:hover { background: rgba(34,197,94,.25); }
85
+
86
+ /* ── Code tabs ── */
87
+ .tabs { display: flex; gap: 4px; margin-bottom: -1px; }
88
+ .tab { background: transparent; border: 1px solid var(--border); border-bottom: none;
89
+ color: var(--muted); padding: 8px 18px; border-radius: 8px 8px 0 0; font-size: 13px;
90
+ cursor: pointer; font-family: inherit; transition: all .15s; }
91
+ .tab.active { background: #1a2e1e; color: var(--green); border-color: var(--border); }
92
+ .code-panel { display: none; background: #0d1a10; border: 1px solid var(--border);
93
+ border-radius: 0 12px 12px 12px; padding: 20px; overflow-x: auto; }
94
+ .code-panel.active { display: block; }
95
+ pre { font-family: 'Fira Code', monospace; font-size: 13px; line-height: 1.7; color: #c9d9cb; white-space: pre; }
96
+ .kw { color: #79c0ff; } .fn { color: #d2a8ff; } .str { color: #a5d6ff; }
97
+ .cm { color: #6b8f74; font-style: italic; } .key { color: #ffa657; }
98
+
99
+ /* Endpoints table */
100
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
101
+ th { text-align: left; color: var(--muted); font-weight: 500; padding: 8px 12px;
102
+ border-bottom: 1px solid var(--border); }
103
+ td { padding: 12px 12px; border-bottom: 1px solid rgba(255,255,255,.05); }
104
+ td:first-child { font-family: 'Fira Code', monospace; color: var(--green); font-size: 13px; }
105
+ tr:last-child td { border-bottom: none; }
106
+
107
+ .error-msg { color: #f87171; background: rgba(248,113,113,.1); border: 1px solid rgba(248,113,113,.2);
108
+ border-radius: 8px; padding: 12px 16px; font-size: 14px; margin-top: 16px; }
109
+ </style>
110
+ </head>
111
+ <body>
112
+
113
+ <div class="hero">
114
+ <div class="badge">🌾 Free API · No Credit Card</div>
115
+ <h1>Crop Classifier <span>API</span></h1>
116
+ <p>Identify 50 crop varieties from images using AI. Get your free API key in seconds β€” no approval needed.</p>
117
+ </div>
118
+
119
+ <div class="stats">
120
+ <div class="stat"><div class="stat-val">50</div><div class="stat-lbl">Crop Varieties</div></div>
121
+ <div class="stat"><div class="stat-val">93.48%</div><div class="stat-lbl">Model Accuracy</div></div>
122
+ <div class="stat"><div class="stat-val">Free</div><div class="stat-lbl">Forever</div></div>
123
+ <div class="stat"><div class="stat-val">&lt;500ms</div><div class="stat-lbl">Fast Mode Speed</div></div>
124
+ </div>
125
+
126
+ <div class="container">
127
+
128
+ <!-- Get API Key Card -->
129
+ <div class="card">
130
+ <h2>πŸ”‘ Get Your Free API Key</h2>
131
+ <p class="sub">Enter your name and email β€” your unique key is generated instantly.</p>
132
+
133
+ <div class="form-group">
134
+ <label for="name">Your Name / App Name</label>
135
+ <input id="name" type="text" placeholder="e.g. My Farm App" autocomplete="off"/>
136
+ </div>
137
+ <div class="form-group">
138
+ <label for="email">Email Address</label>
139
+ <input id="email" type="email" placeholder="you@example.com"/>
140
+ </div>
141
+ <button class="btn" id="getKeyBtn" onclick="getKey()">Generate API Key β†’</button>
142
+ <div id="errorMsg" class="error-msg" style="display:none;"></div>
143
+
144
+ <div id="result">
145
+ <div class="result-header">
146
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
147
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>
148
+ </svg>
149
+ <h3 id="resultTitle">Your API key is ready!</h3>
150
+ </div>
151
+ <div class="key-box">
152
+ <div class="key-label">Your API Key</div>
153
+ <div class="key-display">
154
+ <div class="key-text" id="apiKeyDisplay"></div>
155
+ <button class="copy-btn" onclick="copyKey()">Copy</button>
156
+ </div>
157
+ </div>
158
+ </div>
159
+ </div>
160
+
161
+ <!-- Code Examples -->
162
+ <div class="card">
163
+ <h2>πŸ’» Code Examples</h2>
164
+ <p class="sub">Copy the code for your language and replace <code style="color:var(--green)">YOUR_API_KEY</code>.</p>
165
+
166
+ <div class="tabs">
167
+ <button class="tab active" onclick="switchTab('js',this)">JavaScript</button>
168
+ <button class="tab" onclick="switchTab('py',this)">Python</button>
169
+ <button class="tab" onclick="switchTab('curl',this)">cURL</button>
170
+ </div>
171
+
172
+ <div class="code-panel active" id="tab-js"><pre><span class="cm">// Full analysis (crop name, quality, characteristics, explanation)</span>
173
+ <span class="kw">const</span> formData = <span class="kw">new</span> <span class="fn">FormData</span>();
174
+ formData.<span class="fn">append</span>(<span class="str">"file"</span>, imageFile); <span class="cm">// File from &lt;input type="file"&gt;</span>
175
+
176
+ <span class="kw">const</span> response = <span class="kw">await</span> <span class="fn">fetch</span>(<span class="str">"https://vdx-0-crop-classifier-api.hf.space/predict"</span>, {
177
+ <span class="key">method</span>: <span class="str">"POST"</span>,
178
+ <span class="key">headers</span>: { <span class="str">"x-api-key"</span>: <span class="str">"YOUR_API_KEY"</span> },
179
+ <span class="key">body</span>: formData
180
+ });
181
+
182
+ <span class="kw">const</span> data = <span class="kw">await</span> response.<span class="fn">json</span>();
183
+
184
+ <span class="cm">// Use final_answer β€” has everything you need</span>
185
+ console.<span class="fn">log</span>(data.final_answer.<span class="key">crop_name</span>); <span class="cm">// "Wheat"</span>
186
+ console.<span class="fn">log</span>(data.final_answer.<span class="key">quality</span>); <span class="cm">// "Excellent"</span>
187
+ console.<span class="fn">log</span>(data.final_answer.<span class="key">characteristics</span>); <span class="cm">// "Golden stalks..."</span>
188
+ console.<span class="fn">log</span>(data.final_answer.<span class="key">explanation</span>); <span class="cm">// "Full description..."</span></pre></div>
189
+
190
+ <div class="code-panel" id="tab-py"><pre><span class="kw">import</span> requests
191
+
192
+ <span class="kw">with</span> <span class="fn">open</span>(<span class="str">"crop.jpg"</span>, <span class="str">"rb"</span>) <span class="kw">as</span> f:
193
+ response = requests.<span class="fn">post</span>(
194
+ <span class="str">"https://vdx-0-crop-classifier-api.hf.space/predict"</span>,
195
+ headers={<span class="str">"x-api-key"</span>: <span class="str">"YOUR_API_KEY"</span>},
196
+ files={<span class="str">"file"</span>: f}
197
+ )
198
+
199
+ data = response.<span class="fn">json</span>()
200
+ answer = data[<span class="str">"final_answer"</span>]
201
+
202
+ <span class="fn">print</span>(answer[<span class="str">"crop_name"</span>]) <span class="cm"># Wheat</span>
203
+ <span class="fn">print</span>(answer[<span class="str">"quality"</span>]) <span class="cm"># Excellent</span>
204
+ <span class="fn">print</span>(answer[<span class="str">"characteristics"</span>]) <span class="cm"># Golden stalks...</span>
205
+ <span class="fn">print</span>(answer[<span class="str">"explanation"</span>]) <span class="cm"># Full description...</span></pre></div>
206
+
207
+ <div class="code-panel" id="tab-curl"><pre>curl -X POST <span class="str">"https://vdx-0-crop-classifier-api.hf.space/predict"</span> \
208
+ -H <span class="str">"x-api-key: YOUR_API_KEY"</span> \
209
+ -F <span class="str">"file=@/path/to/crop.jpg"</span></pre></div>
210
+ </div>
211
+
212
+ <!-- Endpoints table -->
213
+ <div class="card">
214
+ <h2>πŸ“‘ Endpoints</h2>
215
+ <p class="sub">Base URL: <code style="color:var(--green)">https://vdx-0-crop-classifier-api.hf.space</code></p>
216
+ <table>
217
+ <thead><tr><th>Endpoint</th><th>Description</th></tr></thead>
218
+ <tbody>
219
+ <tr><td>POST /predict</td><td>Full analysis β€” model + LLaMA AI expert (~10s)</td></tr>
220
+ <tr><td>POST /predict/fast</td><td>Model only β€” instant response (&lt;500ms)</td></tr>
221
+ <tr><td>POST /register</td><td>Generate a new API key</td></tr>
222
+ <tr><td>GET /crops</td><td>List all 50 supported crop varieties</td></tr>
223
+ <tr><td>GET /docs</td><td>Interactive Swagger API documentation</td></tr>
224
+ </tbody>
225
+ </table>
226
+ </div>
227
+
228
+ </div>
229
+
230
+ <script>
231
+ let generatedKey = "";
232
+
233
+ async function getKey() {
234
+ const name = document.getElementById("name").value.trim();
235
+ const email = document.getElementById("email").value.trim();
236
+ const err = document.getElementById("errorMsg");
237
+ const btn = document.getElementById("getKeyBtn");
238
+
239
+ err.style.display = "none";
240
+ if (!name) { showError("Please enter your name."); return; }
241
+ if (!email) { showError("Please enter your email."); return; }
242
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { showError("Please enter a valid email."); return; }
243
+
244
+ btn.disabled = true;
245
+ btn.textContent = "Generating...";
246
+
247
+ try {
248
+ const res = await fetch("/register", {
249
+ method: "POST",
250
+ headers: {"Content-Type": "application/json"},
251
+ body: JSON.stringify({name, email})
252
+ });
253
+ const data = await res.json();
254
+ if (!res.ok) throw new Error(data.detail?.message || data.detail || "Failed");
255
+
256
+ generatedKey = data.api_key;
257
+ document.getElementById("apiKeyDisplay").textContent = data.api_key;
258
+ document.getElementById("resultTitle").textContent =
259
+ data.is_new ? `Welcome, ${name}! Your API key is ready.` : `Welcome back, ${name}! Here's your key.`;
260
+ document.getElementById("result").style.display = "block";
261
+ btn.textContent = "βœ“ Key Generated";
262
+
263
+ } catch(e) {
264
+ showError(e.message);
265
+ btn.disabled = false;
266
+ btn.textContent = "Generate API Key β†’";
267
+ }
268
+ }
269
+
270
+ function showError(msg) {
271
+ const err = document.getElementById("errorMsg");
272
+ err.textContent = msg;
273
+ err.style.display = "block";
274
+ }
275
+
276
+ function copyKey() {
277
+ navigator.clipboard.writeText(generatedKey);
278
+ const btn = document.querySelector(".copy-btn");
279
+ btn.textContent = "Copied!";
280
+ setTimeout(() => btn.textContent = "Copy", 2000);
281
+ }
282
+
283
+ function switchTab(lang, el) {
284
+ document.querySelectorAll(".tab").forEach(t => t.classList.remove("active"));
285
+ document.querySelectorAll(".code-panel").forEach(p => p.classList.remove("active"));
286
+ el.classList.add("active");
287
+ document.getElementById("tab-"+lang).classList.add("active");
288
+ }
289
+
290
+ document.getElementById("email").addEventListener("keydown", e => {
291
+ if (e.key === "Enter") getKey();
292
+ });
293
+ </script>
294
+ </body>
295
+ </html>
requirements.txt CHANGED
@@ -5,3 +5,4 @@ tensorflow==2.16.1
5
  pillow==10.3.0
6
  numpy==1.26.4
7
  python-dotenv==1.0.1
 
 
5
  pillow==10.3.0
6
  numpy==1.26.4
7
  python-dotenv==1.0.1
8
+ httpx==0.27.0