| """ |
| Scrape Apis mellifera (Western honeybee) photos from the iNaturalist API. |
| |
| Most observations are forager bees on flowers, not hive frames, but they |
| still give Moondream2 plenty of bee imagery to ground its responses, and |
| the high-quality bee close-ups are useful negatives / context for YOLO. |
| |
| For hive-frame specific imagery (queens, varroa, swarm cells) see |
| data/SOURCES.md. |
| |
| Usage: |
| py scripts/scrape_inaturalist.py --pages 5 |
| py scripts/scrape_inaturalist.py --pages 20 --output data/raw/inaturalist |
| |
| Resumable: re-running skips already-downloaded photos. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import os |
| import time |
| from pathlib import Path |
| from urllib.parse import urlparse |
|
|
| import requests |
| from tqdm import tqdm |
|
|
|
|
| BASE_URL = "https://api.inaturalist.org/v1/observations" |
| USER_AGENT = "apiarist-hackathon/1.0 (build-small-hackathon)" |
|
|
|
|
| def search_page(taxon_name: str, page: int, per_page: int) -> dict: |
| params = { |
| "taxon_name": taxon_name, |
| "photos": "true", |
| "quality_grade": "research", |
| "per_page": per_page, |
| "page": page, |
| "photo_license": "cc0,cc-by,cc-by-sa,cc-by-nc,cc-by-nd,cc-by-nc-sa,cc-by-nc-nd", |
| "order_by": "votes", |
| } |
| resp = requests.get( |
| BASE_URL, |
| params=params, |
| headers={"User-Agent": USER_AGENT}, |
| timeout=30, |
| ) |
| resp.raise_for_status() |
| return resp.json() |
|
|
|
|
| def upgrade_photo_url(url: str) -> str: |
| """iNaturalist returns small thumbnails by default. Replace with medium.""" |
| return ( |
| url.replace("/square.", "/medium.") |
| .replace("/small.", "/medium.") |
| ) |
|
|
|
|
| def download_photo(url: str, dest: Path) -> None: |
| resp = requests.get( |
| url, headers={"User-Agent": USER_AGENT}, timeout=30, stream=True |
| ) |
| resp.raise_for_status() |
| with open(dest, "wb") as f: |
| for chunk in resp.iter_content(8192): |
| f.write(chunk) |
|
|
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--pages", type=int, default=5) |
| parser.add_argument("--per-page", type=int, default=100) |
| parser.add_argument( |
| "--output", type=str, default="data/raw/inaturalist" |
| ) |
| parser.add_argument("--taxon", type=str, default="Apis mellifera") |
| args = parser.parse_args() |
|
|
| output_dir = Path(args.output) |
| output_dir.mkdir(parents=True, exist_ok=True) |
| metadata_path = output_dir / "metadata.jsonl" |
|
|
| seen: set[int] = set() |
| if metadata_path.exists(): |
| with open(metadata_path) as f: |
| for line in f: |
| try: |
| seen.add(json.loads(line)["photo_id"]) |
| except Exception: |
| pass |
| print(f"Resume mode: {len(seen)} photos already downloaded.") |
|
|
| total_new = 0 |
|
|
| with open(metadata_path, "a", encoding="utf-8") as meta_f: |
| for page in range(1, args.pages + 1): |
| print(f"\n=== Page {page}/{args.pages} ===") |
| try: |
| data = search_page(args.taxon, page, args.per_page) |
| except Exception as e: |
| print(f" [!] page {page} failed: {e}") |
| time.sleep(2) |
| continue |
|
|
| obs_list = data.get("results", []) |
| print(f" {len(obs_list)} observations returned") |
|
|
| for obs in tqdm(obs_list, desc=f"page {page}", leave=False): |
| for photo in obs.get("photos", []): |
| photo_id = photo.get("id") |
| if photo_id is None or photo_id in seen: |
| continue |
|
|
| raw_url = photo.get("url", "") |
| if not raw_url: |
| continue |
| url = upgrade_photo_url(raw_url) |
| ext = os.path.splitext(urlparse(url).path)[1] or ".jpg" |
| dest = output_dir / f"{photo_id}{ext}" |
|
|
| try: |
| download_photo(url, dest) |
| except Exception as e: |
| tqdm.write(f" [!] photo {photo_id} failed: {e}") |
| continue |
|
|
| rec = { |
| "photo_id": photo_id, |
| "url": url, |
| "license": photo.get("license_code"), |
| "observation_id": obs.get("id"), |
| "observed_on": obs.get("observed_on"), |
| "place_guess": obs.get("place_guess"), |
| "filename": dest.name, |
| } |
| meta_f.write(json.dumps(rec) + "\n") |
| meta_f.flush() |
| seen.add(photo_id) |
| total_new += 1 |
|
|
| |
| time.sleep(1.5) |
|
|
| print(f"\n Done. Downloaded {total_new} new photos this run.") |
| print(f" Collection total: {len(seen)} photos in {output_dir}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|