kulmehul's picture
Identify From Photo
a547cfc verified
Raw
History Blame Contribute Delete
7.44 kB
"""
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)