Spaces:
Runtime error
Runtime error
| """ | |
| FeatherFind Photo-ID Server | |
| --------------------------------------------------- | |
| Wraps a free, open-source bird photo classifier to identify bird | |
| species from photos. | |
| WHY THIS FILE IS STRUCTURED THE WAY IT IS: | |
| Same pattern as backend-perch-hf/server.py and backend-birdnet/server.py: | |
| 1. MODEL_LOADER -- the ONLY place that knows which specific vision | |
| model is in use. Everything below talks to a generic prediction | |
| interface. | |
| 2. Flask routes -- talk only to the abstraction above, never | |
| directly to the model object. | |
| This means swapping to a different photo-ID model later should only | |
| ever require changing the MODEL_LOADER section below. | |
| MODEL CHOICE NOTE: | |
| We use prithivMLmods/Bird-Species-Classifier-526 (Apache 2.0, fine- | |
| tuned from Google's SigLIP2), NOT the alternative | |
| dennisjooo/Birds-Classifier-EfficientNetB2 (also real, Apache 2.0, | |
| higher benchmark accuracy at 99.1% vs 89.9%, but smaller/faster). | |
| This was a deliberate choice, not a default: dennisjooo's species | |
| list skews toward common North American/hobbyist-dataset birds. | |
| prithivMLmods' 526-species list explicitly includes South Asian | |
| species relevant to this app's actual use (Himalayan Monal, Indian | |
| Pitta, Indian Roller, Red-whiskered Bulbul, Asian Green Bee-eater, | |
| Asian Openbill Stork, and others) -- verified directly against the | |
| real model card before choosing, not assumed. Two independent AI | |
| reviews (ChatGPT and Gemini) were also consulted and both converged | |
| on the same conclusion: regional species coverage matters more than | |
| ~9 percentage points of benchmark accuracy on a globally-skewed test | |
| set for this app's actual users. See ARCHITECTURE_HANDOFF.md for the | |
| fuller reasoning and the real trade-off table. | |
| This model covers 526 species globally (not India-specific) -- a | |
| fine-tuned, India-specific model would have better per-species | |
| accuracy on a much narrower list, but India alone has approximately | |
| 1,300 bird species, so a small regional fine-tuned model would | |
| actually have WORSE real-world coverage than this broader one. Future | |
| improvement path: collect real usage data (photos + corrections) from | |
| this app over time, then fine-tune on that real distribution -- not | |
| on a generic Kaggle dataset upfront. This is a deliberately deferred | |
| Phase 2/3 project, not something to rush into now. | |
| LICENSE NOTE: Apache 2.0 -- fully permissive, safe for commercial/ | |
| app-store distribution, no restrictions. Same standard as Perch. | |
| SETUP (for a parent/guardian): | |
| 1. pip install -r requirements.txt | |
| 2. python server.py | |
| 3. The first run downloads the model (~370MB) from Hugging Face -- | |
| this only happens once. | |
| 4. Deploy this to Hugging Face Spaces (same pattern as | |
| backend-perch-hf -- Render's free tier RAM was not enough for | |
| the similarly-sized Perch model, so this almost certainly needs | |
| the same Hugging Face Spaces approach, not Render). | |
| """ | |
| from flask import Flask, request, jsonify | |
| from flask_cors import CORS | |
| import tempfile | |
| import os | |
| from datetime import date | |
| app = Flask(__name__) | |
| CORS(app) | |
| # ================================================================= | |
| # MODEL_LOADER -- the ONLY section that should change if/when we | |
| # swap to a different photo-ID model. Everything below this block | |
| # in the rest of the file is model-agnostic. | |
| # ================================================================= | |
| print("Loading bird photo classifier, this may take a moment on first run...") | |
| import torch | |
| from PIL import Image | |
| from transformers import AutoImageProcessor, SiglipForImageClassification | |
| MODEL_ID = "prithivMLmods/Bird-Species-Classifier-526" | |
| MODEL_NAME = "Bird-Species-Classifier-526 (SigLIP2, prithivMLmods)" | |
| PROCESSOR = AutoImageProcessor.from_pretrained(MODEL_ID) | |
| MODEL = SiglipForImageClassification.from_pretrained(MODEL_ID) | |
| MODEL.eval() | |
| print(f"{MODEL_NAME} loaded successfully. {len(MODEL.config.id2label)} species known.") | |
| def run_model_prediction(image_path): | |
| """ | |
| Model-agnostic prediction wrapper. | |
| Takes a path to an image file, returns a list of | |
| {"commonName": str, "scientificName": str, "confidence": float 0-100} | |
| dicts, sorted by confidence descending, top 3 only. | |
| If swapping models: as long as the new model also exposes a | |
| standard `transformers` image-classification interface | |
| (processor + model + config.id2label), this function should only | |
| need small adjustments -- the overall shape stays the same. | |
| """ | |
| image = Image.open(image_path).convert("RGB") | |
| inputs = PROCESSOR(images=image, return_tensors="pt") | |
| with torch.no_grad(): | |
| outputs = MODEL(**inputs) | |
| logits = outputs.logits | |
| probabilities = torch.nn.functional.softmax(logits, dim=1).squeeze().tolist() | |
| # Pair each probability with its label, sort descending, take top 3. | |
| id2label = MODEL.config.id2label | |
| scored = [(id2label[i], probabilities[i]) for i in range(len(probabilities))] | |
| scored.sort(key=lambda pair: pair[1], reverse=True) | |
| top = scored[:3] | |
| results = [] | |
| for label, prob in top: | |
| # This model's labels are plain common names (e.g. "INDIAN ROLLER"), | |
| # not "Scientific_Common" pairs like BirdNET/Perch use. No | |
| # scientific name is available from this model directly. | |
| common = label.title() # tidy up from ALL CAPS to Title Case | |
| results.append({ | |
| "commonName": common, | |
| "scientificName": "", | |
| "confidence": round(float(prob) * 100, 1), | |
| }) | |
| return results | |
| # ================================================================= | |
| # Everything below this line is model-agnostic and intentionally | |
| # mirrors backend-perch-hf/server.py and backend-birdnet/server.py. | |
| # ================================================================= | |
| MAX_REQUESTS_PER_DAY = 100 | |
| request_count = {"date": None, "count": 0} | |
| def check_and_increment_rate_limit(): | |
| today = str(date.today()) | |
| if request_count["date"] != today: | |
| request_count["date"] = today | |
| request_count["count"] = 0 | |
| if request_count["count"] >= MAX_REQUESTS_PER_DAY: | |
| return False | |
| request_count["count"] += 1 | |
| return True | |
| def identify_photo(): | |
| if not check_and_increment_rate_limit(): | |
| return jsonify({ | |
| "error": "Daily identification limit reached. Please try again tomorrow.", | |
| "matches": [] | |
| }), 429 | |
| if "photo" not in request.files: | |
| return jsonify({"error": "No photo file provided.", "matches": []}), 400 | |
| photo_file = request.files["photo"] | |
| suffix = os.path.splitext(photo_file.filename or "photo.jpg")[1] or ".jpg" | |
| with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: | |
| photo_file.save(tmp.name) | |
| tmp_path = tmp.name | |
| try: | |
| matches = run_model_prediction(tmp_path) | |
| if not matches: | |
| return jsonify({"matches": [], "error": "No bird clearly detected in this photo."}) | |
| return jsonify({"matches": matches}) | |
| except Exception as e: | |
| return jsonify({"error": f"Could not analyze photo: {str(e)}", "matches": []}), 500 | |
| finally: | |
| if os.path.exists(tmp_path): | |
| os.remove(tmp_path) | |
| def health(): | |
| return jsonify({"status": "ok", "model": MODEL_NAME}) | |
| if __name__ == "__main__": | |
| port = int(os.environ.get("PORT", 8080)) | |
| app.run(host="0.0.0.0", port=port) | |