File size: 6,207 Bytes
d32533a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
706f0cb
d32533a
 
 
 
 
 
706f0cb
d32533a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
706f0cb
 
 
 
 
 
 
 
 
 
d32533a
706f0cb
d32533a
 
706f0cb
 
d32533a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
706f0cb
 
 
 
 
 
 
 
 
 
 
 
 
 
d32533a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
706f0cb
d32533a
 
706f0cb
d32533a
 
 
706f0cb
 
 
 
 
d32533a
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
"""Seed target identity sightings from `seed_data/targets/{identity}/*.jpg`.

For each identity folder, the script holds out one photo into
`seed_data/holdout/{identity}.{ext}` (so you can use it as a query) and
inserts the rest into the database, marked source='target' identity='{name}'.

The identity column is for evaluation only — the search API never reads it.

Run inside the backend container:
    python -m scripts.seed_targets
"""
import argparse
import logging
import random
import shutil
from pathlib import Path

from PIL import Image
from sqlalchemy import select

from app.config import get_settings
from app.models import LostDog, Sighting
from scripts._common import (
    insert_sighting,
    log,
    offset_within_meters,
    open_session,
    random_city_point,
    random_recent_timestamp,
)

VALID_EXTS = {".jpg", ".jpeg", ".png", ".webp"}


def _is_image(p: Path) -> bool:
    return p.is_file() and p.suffix.lower() in VALID_EXTS


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--force",
        action="store_true",
        help="Re-seed even if target rows already exist.",
    )
    parser.add_argument("--seed", type=int, default=7)
    parser.add_argument(
        "--root",
        type=Path,
        default=Path("/seed_data"),
        help="Root containing targets/ and holdout/ subdirs.",
    )
    args = parser.parse_args()

    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
    rng = random.Random(args.seed)

    targets_root: Path = args.root / "targets"
    holdout_root: Path = args.root / "holdout"
    holdout_root.mkdir(parents=True, exist_ok=True)

    if not targets_root.exists():
        log.info(
            "No targets directory at %s — drop dogs into seed_data/targets/{name}/ "
            "and re-run. Skipping.",
            targets_root,
        )
        return

    identity_dirs = [d for d in sorted(targets_root.iterdir()) if d.is_dir()]
    if not identity_dirs:
        log.info(
            "targets/ is empty — drop ~5 dogs with 2-3 photos each into "
            "seed_data/targets/{name}/ and re-run. Skipping."
        )
        return

    session = open_session()
    try:
        existing = session.scalar(
            select(Sighting).where(Sighting.source == "target").limit(1)
        )
        if existing and not args.force:
            log.info("Target rows already present — skipping (pass --force to re-seed).")
            return
        if existing and args.force:
            # Cascade-delete the LostDog rows; FK ondelete=CASCADE will remove
            # the linked sightings too.
            session.query(LostDog).filter(
                LostDog.id.in_(
                    select(Sighting.lost_dog_id)
                    .where(Sighting.source == "target")
                    .where(Sighting.lost_dog_id.is_not(None))
                )
            ).delete(synchronize_session=False)
            # Catch any orphan target sightings (no lost_dog_id) too.
            session.query(Sighting).filter(Sighting.source == "target").delete()
            session.commit()

        settings = get_settings()
        total_dogs = 0
        total_photos = 0
        for ident_dir in identity_dirs:
            identity = ident_dir.name
            photos = sorted([p for p in ident_dir.iterdir() if _is_image(p)])
            if len(photos) < 2:
                log.warning(
                    "Identity '%s' has %d photo(s); need at least 2 (one for index, "
                    "one as holdout query). Skipping.",
                    identity,
                    len(photos),
                )
                continue

            # One "home turf" point per identity — every photo of this dog is placed
            # within seed_target_radius_m (default 100 m) of this center, so the
            # matched sightings cluster tightly on the map.
            home_lat, home_lng = random_city_point(rng)

            # Hold out one photo deterministically (last one alphabetically).
            holdout_src = photos[-1]
            index_photos = photos[:-1]
            holdout_dst = holdout_root / f"{identity}{holdout_src.suffix.lower()}"
            shutil.copyfile(holdout_src, holdout_dst)
            log.info(
                "Identity '%s': home=(%.5f, %.5f), indexing %d photo(s), holdout=%s",
                identity,
                home_lat,
                home_lng,
                len(index_photos),
                holdout_dst.name,
            )

            # Create the LostDog cluster row first so FKs are valid.
            lost_dog = LostDog(
                name=identity.title(),
                last_seen_at=random_recent_timestamp(rng),
                last_seen_lat=home_lat,
                last_seen_lng=home_lng,
                contact_name=f"{identity.title()}'s Owner",
                contact_email="koenig.andres@gmail.com",
                contact_phone=None,
            )
            session.add(lost_dog)
            session.flush()  # populate lost_dog.id
            total_dogs += 1

            for photo in index_photos:
                try:
                    image = Image.open(photo)
                    image.load()
                except Exception as exc:  # noqa: BLE001
                    log.warning("Could not open %s: %s", photo, exc)
                    continue
                coords = offset_within_meters(
                    rng, home_lat, home_lng, settings.seed_target_radius_m
                )
                row = insert_sighting(
                    session,
                    image,
                    source="target",
                    identity=identity,
                    coords=coords,
                    rng=rng,
                    prefix="target",
                    lost_dog_id=lost_dog.id,
                )
                if row is not None:
                    total_photos += 1

            session.commit()

        log.info(
            "Seeded %d lost-dog clusters with %d total reference photos.",
            total_dogs,
            total_photos,
        )
    finally:
        session.close()


if __name__ == "__main__":
    main()