""" 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 @app.route("/identify-photo", methods=["POST"]) 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) @app.route("/health", methods=["GET"]) 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)