"""Shared helpers for seed scripts.""" import logging import math import random from datetime import datetime, timedelta, timezone from PIL import Image from sqlalchemy.orm import Session from app.config import get_settings from app.db import SessionLocal from app.models import Sighting from app.services.detector import NoDogDetectedError from app.services.pipeline import process_and_store from app.services.storage import ensure_bucket log = logging.getLogger("seeder") _METERS_PER_DEG_LAT = 111_000.0 def random_geo(rng: random.Random) -> tuple[float, float]: """Filler-style scatter: gaussian around the configured city center.""" s = get_settings() return ( rng.gauss(s.seed_center_lat, s.seed_geo_sigma), rng.gauss(s.seed_center_lng, s.seed_geo_sigma), ) def random_city_point(rng: random.Random) -> tuple[float, float]: """One uniform point inside the city scatter — used as a per-identity 'home'.""" s = get_settings() return ( rng.gauss(s.seed_center_lat, s.seed_geo_sigma), rng.gauss(s.seed_center_lng, s.seed_geo_sigma), ) def offset_within_meters( rng: random.Random, center_lat: float, center_lng: float, max_meters: float, ) -> tuple[float, float]: """Uniform random point inside a disc of `max_meters` radius around (lat, lng).""" # sqrt(uniform) keeps the distribution uniform over area, not radius. radius_m = max_meters * math.sqrt(rng.random()) angle = rng.uniform(0.0, 2.0 * math.pi) dlat = (radius_m * math.cos(angle)) / _METERS_PER_DEG_LAT dlng = (radius_m * math.sin(angle)) / ( _METERS_PER_DEG_LAT * math.cos(math.radians(center_lat)) ) return (center_lat + dlat, center_lng + dlng) def random_recent_timestamp(rng: random.Random, *, max_days: int = 30) -> datetime: seconds = rng.uniform(0, max_days * 24 * 3600) return datetime.now(timezone.utc) - timedelta(seconds=seconds) def insert_sighting( session: Session, image: Image.Image, *, source: str, identity: str | None, rng: random.Random, prefix: str, coords: tuple[float, float] | None = None, lost_dog_id=None, ) -> Sighting | None: try: stored = process_and_store(image, prefix=prefix) except NoDogDetectedError: log.warning("Skipping image — no dog detected.") return None lat, lng = coords if coords is not None else random_geo(rng) sighting = Sighting( image_url=stored.image_url, cropped_url=stored.cropped_url, embedding=stored.embedding.tolist(), latitude=lat, longitude=lng, sighted_at=random_recent_timestamp(rng), notes=None, source=source, identity=identity, lost_dog_id=lost_dog_id, ) session.add(sighting) return sighting def open_session() -> Session: ensure_bucket() return SessionLocal()