Apiarist / scripts /scrape_inaturalist.py
Apiarist Dev
polish: remove emojis and em dashes from sources and docs
238bdf6
Raw
History Blame Contribute Delete
4.96 kB
"""
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
# iNaturalist asks for max 1 req/sec, be polite
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()