Skip to content

Commit 159163b

Browse files
committed
Add image pipeline with fal.ai vision judging and gen ai
1 parent 24cba15 commit 159163b

15 files changed

Lines changed: 1275 additions & 669 deletions

.gitignore

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,17 @@ mlruns/
1616
.terraform/
1717
.terraform.lock.hcl
1818
infra/.env
19+
20+
# macOS
21+
.DS_Store
22+
**/.DS_Store
23+
24+
# Claude Code
25+
.claude/
26+
27+
# Project planning docs
28+
flora-asset-pipeline-v2.md
29+
30+
# Generated image output
31+
/tmp/flora_images/
32+
test_results/

backend/config.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ class Settings(BaseSettings):
2525
aws_region: str = "us-east-1"
2626
s3_bucket: str = "flora-assets"
2727

28-
replicate_api_token: str = ""
28+
# fal.ai (vision judge + FLUX lock icon generation)
29+
fal_key: str = ""
2930

3031
# MLflow
3132
mlflow_tracking_uri: str = "http://localhost:5001"

backend/routers/images.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,16 +106,27 @@ async def _run_images_bg(flower_id: int) -> None:
106106
return
107107

108108
try:
109+
from config import settings
110+
109111
pair = await find_images(flower.latin_name)
110112

111113
info_path, author = await process_info_image(pair.info, flower.latin_name)
112114
flower.info_image_path = info_path
113115
flower.info_image_author = author
114116

115-
main_path = await process_main_image(pair.blossom, flower.latin_name)
117+
main_path, _ = await process_main_image(
118+
pair.blossom,
119+
flower.latin_name,
120+
candidates=pair.blossom_candidates,
121+
fal_key=settings.fal_key,
122+
)
116123
flower.main_image_path = main_path
117124

118-
lock_path = await generate_lock_image(main_path, flower.latin_name)
125+
lock_path = await generate_lock_image(
126+
main_path,
127+
flower.latin_name,
128+
fal_key=settings.fal_key,
129+
)
119130
flower.lock_image_path = lock_path
120131

121132
flower.status = "images_done"
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""iNaturalist observation photo search — no API key required.
2+
3+
iNaturalist is the best free source for species-specific flower photographs:
4+
• Research-grade observations are verified by multiple identifiers
5+
• Photos are typically close-up shots of the plant/flower
6+
• CC-licensed photos are clearly tagged
7+
• Taxon search by Latin name is accurate
8+
9+
Strategy:
10+
1. Resolve latin name → iNaturalist taxon ID
11+
2. Fetch research-grade observations with photos
12+
3. Parse photo URLs, license, dimensions, and observer attribution
13+
"""
14+
from __future__ import annotations
15+
16+
import re
17+
from dataclasses import dataclass
18+
19+
import httpx
20+
21+
_TAXA_API = "https://api.inaturalist.org/v1/taxa"
22+
_OBS_API = "https://api.inaturalist.org/v1/observations"
23+
_HEADERS = {
24+
"User-Agent": "FloraRAGPipeline/1.0 (portfolio; contact: simone.84858@gmail.com)",
25+
"Accept": "application/json",
26+
}
27+
28+
# iNaturalist CC licenses we accept (same spirit as Wikimedia whitelist)
29+
_ALLOWED_LICENSES: frozenset[str] = frozenset({
30+
"cc0", "cc-by", "cc-by-sa",
31+
"cc-by-nc", # iNaturalist-specific: many citizen-science photos
32+
"cc-by-nc-sa",
33+
})
34+
35+
# Photo size suffixes on iNaturalist CDN
36+
# original → full res, large → 1024px, medium → 500px
37+
_SIZE_ORIGINAL = "original"
38+
_SIZE_LARGE = "large"
39+
40+
41+
@dataclass
42+
class INatPhoto:
43+
"""A single photo from an iNaturalist observation."""
44+
photo_id: int
45+
url_original: str # full-resolution URL
46+
url_large: str # 1024 px version
47+
attribution: str # observer / photographer credit
48+
license_code: str # e.g. "cc-by-nc"
49+
taxon_name: str # confirmed species
50+
observation_id: int
51+
quality_grade: str # "research" or "needs_id"
52+
53+
# iNaturalist doesn't return dimensions in the API, but we know:
54+
# - original: variable (typically 1000–4000 px)
55+
# - large: 1024 px longest edge
56+
# We estimate conservatively for scoring.
57+
width: int = 1024
58+
height: int = 768
59+
60+
@property
61+
def aspect(self) -> float:
62+
return self.width / self.height if self.height else 1.0
63+
64+
@property
65+
def source(self) -> str:
66+
return "inaturalist"
67+
68+
69+
async def _resolve_taxon(
70+
client: httpx.AsyncClient, latin_name: str,
71+
) -> int | None:
72+
"""Resolve a Latin species name to an iNaturalist taxon ID.
73+
74+
Returns None if no match found.
75+
"""
76+
resp = await client.get(_TAXA_API, params={
77+
"q": latin_name,
78+
"rank": "species,subspecies,variety",
79+
"is_active": "true",
80+
"per_page": 5,
81+
})
82+
resp.raise_for_status()
83+
results = resp.json().get("results", [])
84+
85+
# Prefer exact match on the name
86+
latin_lower = latin_name.lower()
87+
for taxon in results:
88+
name = (taxon.get("name") or "").lower()
89+
if name == latin_lower:
90+
return taxon["id"]
91+
92+
# Fall back to first result if it's close
93+
if results:
94+
return results[0]["id"]
95+
96+
return None
97+
98+
99+
async def search_inaturalist(
100+
latin_name: str, *, limit: int = 60,
101+
) -> list[INatPhoto]:
102+
"""Search iNaturalist for CC-licensed photos of a species.
103+
104+
Returns up to `limit` photos, prioritizing research-grade observations.
105+
"""
106+
async with httpx.AsyncClient(
107+
timeout=30.0, headers=_HEADERS,
108+
) as client:
109+
taxon_id = await _resolve_taxon(client, latin_name)
110+
if taxon_id is None:
111+
return []
112+
113+
photos: list[INatPhoto] = []
114+
seen_photo_ids: set[int] = set()
115+
116+
# Fetch research-grade first, then needs_id if not enough
117+
for quality in ("research", "needs_id"):
118+
if len(photos) >= limit:
119+
break
120+
121+
resp = await client.get(_OBS_API, params={
122+
"taxon_id": taxon_id,
123+
"quality_grade": quality,
124+
"photos": "true",
125+
"photo_licensed": "true",
126+
"per_page": min(limit, 50),
127+
"order_by": "votes", # community-upvoted first
128+
"order": "desc",
129+
"locale": "en",
130+
})
131+
resp.raise_for_status()
132+
observations = resp.json().get("results", [])
133+
134+
for obs in observations:
135+
if len(photos) >= limit:
136+
break
137+
138+
obs_photos = obs.get("photos", [])
139+
obs_taxon = obs.get("taxon", {})
140+
taxon_display = obs_taxon.get("name", latin_name)
141+
observer = obs.get("user", {}).get("login", "Unknown")
142+
143+
for p in obs_photos:
144+
if len(photos) >= limit:
145+
break
146+
147+
pid = p.get("id")
148+
if pid in seen_photo_ids:
149+
continue
150+
seen_photo_ids.add(pid)
151+
152+
# License check
153+
lic = (p.get("license_code") or "").lower().replace("_", "-")
154+
if lic not in _ALLOWED_LICENSES:
155+
continue
156+
157+
# Build URLs — iNaturalist uses a suffix pattern
158+
url_raw = p.get("url", "")
159+
if not url_raw:
160+
continue
161+
162+
# URL pattern: .../photos/{id}/{size}.{ext}
163+
# The API returns "square" size by default
164+
url_original = re.sub(
165+
r"/square\.", "/original.", url_raw,
166+
)
167+
url_large = re.sub(
168+
r"/square\.", "/large.", url_raw,
169+
)
170+
171+
attribution = p.get("attribution", f"(c) {observer}")
172+
173+
photos.append(INatPhoto(
174+
photo_id=pid,
175+
url_original=url_original,
176+
url_large=url_large,
177+
attribution=attribution,
178+
license_code=lic,
179+
taxon_name=taxon_display,
180+
observation_id=obs.get("id", 0),
181+
quality_grade=quality,
182+
))
183+
184+
return photos

0 commit comments

Comments
 (0)