File size: 18,376 Bytes
a270696
f0f5bef
de1e3fc
 
f0f5bef
a270696
 
b5442b0
a270696
9122959
de1e3fc
 
 
 
a270696
 
f0f5bef
 
a270696
 
 
 
f0f5bef
 
 
 
 
9122959
f0f5bef
 
 
 
 
9122959
f0f5bef
 
a270696
 
 
 
de1e3fc
b5442b0
 
 
 
 
 
 
a270696
f0f5bef
b5442b0
 
f0f5bef
a270696
de1e3fc
 
 
 
 
 
a270696
 
 
f0f5bef
 
 
 
a270696
f0f5bef
 
 
 
 
a270696
f0f5bef
a270696
de1e3fc
f0f5bef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a270696
 
 
 
f0f5bef
 
a270696
 
 
 
de1e3fc
 
 
a270696
de1e3fc
 
b5442b0
de1e3fc
 
a270696
 
 
 
 
b5442b0
 
 
de1e3fc
a270696
 
f0f5bef
a270696
b5442b0
 
 
de1e3fc
 
f0f5bef
de1e3fc
 
 
 
 
f0f5bef
a270696
de1e3fc
 
6d6ee02
b5442b0
de1e3fc
9122959
 
 
 
de1e3fc
 
a270696
 
 
 
 
 
 
 
 
 
 
b5442b0
 
 
 
 
 
 
 
 
 
a270696
b5442b0
9122959
b5442b0
 
9122959
b5442b0
 
a270696
 
 
b5442b0
a270696
 
 
 
 
 
f0f5bef
9122959
a270696
f0f5bef
 
 
 
 
 
 
 
 
 
 
 
a270696
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9122959
a270696
 
9122959
a270696
 
 
 
 
b5442b0
 
a270696
f0f5bef
 
 
a270696
 
 
de1e3fc
a270696
de1e3fc
f0f5bef
 
 
 
 
de1e3fc
a270696
de1e3fc
a270696
de1e3fc
 
 
a270696
de1e3fc
 
a270696
de1e3fc
a270696
de1e3fc
 
 
a270696
de1e3fc
 
 
 
a270696
de1e3fc
b5442b0
f0f5bef
b5442b0
9122959
b5442b0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de1e3fc
b5442b0
a270696
 
f0f5bef
a270696
 
 
 
 
9122959
a270696
 
 
 
 
 
 
 
 
f0f5bef
 
 
 
 
 
 
 
9122959
a270696
f0f5bef
9122959
a270696
 
 
 
b5442b0
a270696
b5442b0
 
a270696
de1e3fc
 
 
 
 
 
 
a270696
 
 
f0f5bef
a270696
 
 
 
de1e3fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a270696
de1e3fc
a270696
 
de1e3fc
 
 
 
 
b5442b0
 
de1e3fc
 
9122959
 
de1e3fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9122959
de1e3fc
 
 
b5442b0
de1e3fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
import { useEffect, useRef, useState } from "react";
import { api, ApiError } from "../api";
import type { BreedEstimateResult, PhotoSearchMatch, PhotoSearchResult } from "../types";
import { estimatedBreeds } from "./MatchCard";
import { prepareImage } from "../lib/prepareImage";
import BreedResults from "./BreedResults";
import ComparePhotosModal, { photosFromUrls } from "./ComparePhotosModal";
import SearchProgress, { MIN_PROGRESS_MS } from "./SearchProgress";
import TabButton from "./TabButton";
import { BreedNote, MatchingNote } from "./ToolNotes";

type Mode = "match" | "breed";
type Pool = "found" | "lost"; // "found": I lost my dog, search found dogs; "lost": I found a dog

interface PickedImage {
  file: File;
  /** Preview URL, or null when this browser couldn't decode the file (still uploadable). */
  url: string | null;
}

const MAX_IMAGES = 6;

/** Turn a failed request into something a visitor can act on. */
function describeRequestError(err: unknown): string {
  if (err instanceof ApiError) {
    // 400s carry a specific, already human-readable reason (unreadable format, too large, ...).
    if (err.status === 400) return err.message || "That image couldn't be read.";
    if (err.status === 413) return "That image is too large to upload. Try a smaller one.";
    if (err.status === 429) return "Too many requests just now. Give it a few seconds and retry.";
    if (err.status >= 500) return "The server had a problem running the model. Please try again.";
    return err.message;
  }
  // fetch() rejects (Safari says "Load failed") on a dropped or timed-out connection.
  return "The upload didn't complete. Check your connection and try again.";
}

// Public home-page tool: upload up to 6 dog images, then run image matching, breed estimation, or
// both. More images (different angles/lighting) only help matching — the server scores every query
// image against every candidate image and keeps the best pair. Breed estimation uses the first
// image. Read-only — stores nothing and creates no case. Match results carry their own photos so a
// viewer can open without an authenticated endpoint.
// `lockedPool` forces the search direction and hides the toggle — used by the demo, whose haystack is
// a single found/unclaimed pool (there is no registered-lost pool to search). `haystackHint` is the
// searchable-dog count shown in the progress indicator while a match runs.
export default function PhotoTools({
  lockedPool,
  haystackHint,
}: { lockedPool?: Pool; haystackHint?: number } = {}) {
  const [images, setImages] = useState<PickedImage[]>([]);
  const BREED_COUNT = 10; // always show the model's full top-10; no picker to fiddle with
  const [pool, setPool] = useState<Pool>(lockedPool ?? "found");
  const [dragging, setDragging] = useState(false);
  const [preparing, setPreparing] = useState(false);
  const [tab, setTab] = useState<Mode>("match");
  const [running, setRunning] = useState<Mode | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [matchData, setMatchData] = useState<PhotoSearchResult | null>(null);
  const [breedData, setBreedData] = useState<BreedEstimateResult | null>(null);
  const [active, setActive] = useState<PhotoSearchMatch | null>(null);

  // Release every preview blob URL when the component unmounts (removeImage handles the rest).
  const imagesRef = useRef(images);
  imagesRef.current = images;
  useEffect(
    () => () => imagesRef.current.forEach((i) => i.url && URL.revokeObjectURL(i.url)),
    []
  );

  // NOTE: we deliberately don't filter on `file.type` — iOS often reports an empty type for HEIC
  // and for files picked out of the Files app, which silently dropped valid photos. Instead every
  // file is decoded and re-encoded here (see prepareImage), so what reaches the server is always a
  // modest JPEG and anything genuinely unreadable is reported by name.
  async function addImages(list: FileList | File[] | null) {
    if (!list) return;
    const incoming = Array.from(list);
    if (incoming.length === 0) return;
    setError(null);
    setPreparing(true);
    try {
      const room = MAX_IMAGES - images.length;
      const picked: PickedImage[] = [];
      let fellBack = 0;
      for (const file of incoming.slice(0, room)) {
        try {
          const prepared = await prepareImage(file);
          picked.push({ file: prepared, url: URL.createObjectURL(prepared) });
        } catch {
          // This browser can't decode it (e.g. HEIC outside Safari). Send the original anyway —
          // the server reads HEIC too — and skip the preview rather than showing a broken image.
          picked.push({ file, url: null });
          fellBack++;
        }
      }
      if (picked.length > 0) {
        setImages((prev) => [...prev, ...picked]);
        setMatchData(null);
        setBreedData(null);
      }
      if (fellBack > 0) {
        setError(
          `${fellBack === 1 ? "One image" : `${fellBack} images`} couldn't be previewed in this ` +
            "browser, but will still be searched. If it fails, try a JPEG or PNG."
        );
      }
    } finally {
      setPreparing(false);
    }
  }

  function removeImage(idx: number) {
    setImages((prev) => {
      const gone = prev[idx].url;
      if (gone) URL.revokeObjectURL(gone);
      return prev.filter((_, i) => i !== idx);
    });
    setMatchData(null);
    setBreedData(null);
  }

  async function run(mode: Mode) {
    if (images.length === 0) return;
    setRunning(mode);
    setError(null);
    const start = Date.now();
    try {
      if (mode === "match") {
        const d = await api.searchByPhoto(
          images.map((i) => i.file),
          undefined,
          pool
        );
        const wait = MIN_PROGRESS_MS - (Date.now() - start);
        if (wait > 0) await new Promise((r) => setTimeout(r, wait));
        setMatchData(d);
      } else {
        const d = await api.estimateBreed(
          images.map((i) => i.file),
          BREED_COUNT
        );
        const wait = MIN_PROGRESS_MS - (Date.now() - start);
        if (wait > 0) await new Promise((r) => setTimeout(r, wait));
        setBreedData(d);
      }
    } catch (err) {
      setError(describeRequestError(err));
    } finally {
      setRunning(null);
    }
  }

  const busy = running !== null || preparing;
  const hasImages = images.length > 0;

  return (
    <section className="card mt-4">
      <h2 className="text-xl font-display font-semibold">Try it with an image</h2>
      <p className="text-sm text-gray-600 mt-1">
        Upload up to 6 images of a dog, and search for look-alikes in the found-dog database or
        estimate its breed. Pictures from more angles help the matching process. Use clear photos of
        a single dog. Both models only know dogs, giving them anything else will return a meaningless
        answer.
      </p>

      <input
        id="pt-file"
        type="file"
        accept="image/*"
        multiple
        className="sr-only"
        onChange={(e) => addImages(e.target.files)}
      />

      {!hasImages ? (
        // Big dropzone before anything is picked.
        <label
          htmlFor="pt-file"
          onDragOver={(e) => {
            e.preventDefault();
            setDragging(true);
          }}
          onDragLeave={() => setDragging(false)}
          onDrop={(e) => {
            e.preventDefault();
            setDragging(false);
            addImages(e.dataTransfer.files);
          }}
          className={`mt-5 block w-full cursor-pointer rounded-xl border-2 border-dashed px-6 py-14 text-center transition ${
            dragging
              ? "border-leaf-500 bg-leaf-50"
              : "border-brand-400 bg-gray-50 hover:border-leaf-400 hover:bg-leaf-50/50"
          }`}
        >
          <p className="text-lg font-semibold text-gray-800">Upload images of your dog</p>
          <p className="mt-1 text-sm text-gray-500">Click to choose up to 6, or drag them here</p>
          <p className="mt-2 text-xs text-gray-400">Nothing will be saved to the database.</p>
        </label>
      ) : (
        // Thumbnail grid + an add-more tile once at least one image is picked.
        <div className="mt-5">
          <div className="flex flex-wrap justify-center gap-3">
            {images.map((img, i) => (
              <div
                key={`${img.file.name}-${i}`}
                className="relative h-32 w-32 sm:h-44 sm:w-44 overflow-hidden rounded-lg border border-gray-200 bg-gray-100 shadow-sm"
              >
                {img.url ? (
                  <img
                    src={img.url}
                    alt={`Your dog, image ${i + 1}`}
                    className="h-full w-full object-cover"
                  />
                ) : (
                  <div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2 text-center">
                    <span className="text-xs font-medium text-gray-500">No preview</span>
                    <span className="text-[10px] text-gray-400 break-all">{img.file.name}</span>
                  </div>
                )}
                {running ? (
                  <>
                    <div className="absolute inset-0 bg-leaf-500/10" aria-hidden />
                    <div
                      className="scan-sweep absolute inset-x-0 h-0.5 bg-leaf-400 shadow-[0_0_10px_2px] shadow-leaf-400/80"
                      aria-hidden
                    />
                  </>
                ) : (
                  <button
                    type="button"
                    onClick={() => removeImage(i)}
                    aria-label={`Remove image ${i + 1}`}
                    className="absolute top-1.5 right-1.5 flex h-6 w-6 items-center justify-center rounded-full bg-black/60 text-sm leading-none text-white hover:bg-black/80"
                  >
                    &times;
                  </button>
                )}
              </div>
            ))}
            {images.length < MAX_IMAGES && !running && (
              <label
                htmlFor="pt-file"
                onDragOver={(e) => {
                  e.preventDefault();
                  setDragging(true);
                }}
                onDragLeave={() => setDragging(false)}
                onDrop={(e) => {
                  e.preventDefault();
                  setDragging(false);
                  addImages(e.dataTransfer.files);
                }}
                className={`flex h-32 w-32 sm:h-44 sm:w-44 cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed text-center transition ${
                  dragging
                    ? "border-leaf-500 bg-leaf-50"
                    : "border-brand-400 bg-gray-50 hover:border-leaf-400 hover:bg-leaf-50/50"
                }`}
              >
                <span className="text-3xl leading-none text-gray-400">+</span>
                <span className="mt-1.5 px-2 text-xs text-gray-500">Add more</span>
              </label>
            )}
          </div>
          <p className="mt-3 text-center text-xs text-gray-400">
            {preparing
              ? "Preparing images…"
              : `${images.length} of ${MAX_IMAGES} images · nothing will be saved to the database`}
          </p>
        </div>
      )}

      {/* Two tabs under the images: image matching | breed estimation (both use the same images) */}
      <div className="mt-6">
        <div
          role="tablist"
          aria-label="Image tools"
          className="flex justify-center gap-1 border-b border-gray-200"
        >
          <TabButton
            active={tab === "match"}
            onClick={() => {
              setTab("match");
              setError(null);
            }}
          >
            Image matching
          </TabButton>
          <TabButton
            active={tab === "breed"}
            onClick={() => {
              setTab("breed");
              setError(null);
            }}
          >
            Breed estimation
          </TabButton>
        </div>

        <div className="pt-4">
          {tab === "match" ? (
            <div>
              {!lockedPool && (
                <div className="text-center">
                  <span className="label block mb-1">What are you searching?</span>
                  <div className="inline-flex rounded-md border border-gray-200 overflow-hidden">
                    <button
                      type="button"
                      className={`px-3 py-2 text-sm ${
                        pool === "found" ? "bg-brand-500 text-white" : "bg-white text-gray-600 hover:bg-gray-50"
                      }`}
                      aria-pressed={pool === "found"}
                      onClick={() => setPool("found")}
                    >
                      I lost my dog
                    </button>
                    <button
                      type="button"
                      className={`px-3 py-2 text-sm border-l border-gray-200 ${
                        pool === "lost" ? "bg-brand-500 text-white" : "bg-white text-gray-600 hover:bg-gray-50"
                      }`}
                      aria-pressed={pool === "lost"}
                      onClick={() => setPool("lost")}
                    >
                      I found a dog
                    </button>
                  </div>
                  <p className="text-xs text-gray-500 mt-1">
                    {pool === "found"
                      ? "Searches found & unclaimed dogs in the system for a look-alike."
                      : "Searches currently-lost registered dogs to help find the owner."}
                  </p>
                </div>
              )}
              <button
                type="button"
                className={`btn-primary mx-auto block px-8 py-3 text-base ${lockedPool ? "" : "mt-4"}`}
                disabled={!hasImages || busy}
                onClick={() => run("match")}
              >
                {running === "match" ? "Searching…" : "Find matches"}
              </button>
              <MatchingNote />
              {running === "match" && (
                <div className="mt-4">
                  <SearchProgress mode="match" count={haystackHint} />
                </div>
              )}
              {matchData && <MatchResults data={matchData} onOpen={setActive} />}
            </div>
          ) : (
            <div>
              <button
                type="button"
                className="btn-primary mx-auto block px-8 py-3 text-base"
                disabled={!hasImages || busy}
                onClick={() => run("breed")}
              >
                {running === "breed" ? "Estimating…" : "Estimate breed"}
              </button>
              <BreedNote />
              {images.length > 1 && (
                <p className="text-xs text-gray-400 mt-3 text-center">
                  Averaged across all {images.length} images, since a single photo can easily flip the
                  top breed.
                </p>
              )}
              {running === "breed" && (
                <div className="mt-4">
                  <SearchProgress mode="breed" />
                </div>
              )}
              {breedData && <BreedResults data={breedData} />}
            </div>
          )}
        </div>
      </div>

      {error && <p className="text-red-600 text-sm mt-3">{error}</p>}

      {active && (
        <ComparePhotosModal
          match={active}
          queryPhotos={photosFromUrls(images.map((i) => i.url).filter((u): u is string => !!u))}
          queryLabel={`Your image${images.length > 1 ? "s" : ""}`}
          onClose={() => setActive(null)}
        />
      )}
    </section>
  );
}

function pct(score: number): string {
  return `${(score * 100).toFixed(1)}%`;
}

function MatchResults({
  data,
  onOpen,
}: {
  data: PhotoSearchResult;
  onOpen: (m: PhotoSearchMatch) => void;
}) {
  const noun = data.pool === "lost" ? "lost dog" : "found dog";
  return (
    <div className="mt-6">
      <h3 className="font-semibold mb-2">Closest {noun}s</h3>
      {data.results.length === 0 ? (
        <p className="text-gray-500 text-sm">
          No {noun}s to compare against yet. {" "}
          {data.pool === "lost"
            ? "Report the dog as found so we keep watching for the owner."
            : "Report your dog as lost so we keep watching for new matches."}
        </p>
      ) : (
        <>
          <p className="text-xs text-gray-500 mb-2">
            Closest of {data.candidate_count} {noun}(s)
            {data.zip ? ` near ${data.zip}` : ""}. Tap a card to see all its images. The scores measure
            image similarity only, so always confirm with a person.
          </p>
          <div className="grid sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
            {/* Ten, to match the "top 10" figure quoted under the button. */}
            {data.results.slice(0, 10).map((m) => (
              <ResultCard key={`${m.dog.kind}-${m.dog.id}`} match={m} onOpen={() => onOpen(m)} />
            ))}
          </div>
        </>
      )}
    </div>
  );
}

function ResultCard({ match, onOpen }: { match: PhotoSearchMatch; onOpen: () => void }) {
  const { dog } = match;
  const thumb = match.photos[0]?.url || dog.thumb_url || "/paw-placeholder.svg";
  return (
    <button className="card text-left hover:ring-2 hover:ring-brand-300" onClick={onOpen}>
      <div className="relative">
        <img src={thumb} alt={dog.name} className="h-40 w-full rounded-md object-contain bg-gray-100" />
        <span className="badge absolute top-2 left-2 bg-brand-600 text-white">{pct(match.score)} match</span>
        {dog.picture_count > 1 && (
          <span className="badge absolute top-2 right-2 bg-black/60 text-white">
            {dog.picture_count} images
          </span>
        )}
      </div>
      <div className="mt-2">
        <h3 className="font-semibold truncate" title={dog.name}>
          {dog.name}
        </h3>
        <p className="text-xs text-gray-500 truncate">{estimatedBreeds(dog.predicted_breeds)}</p>
        <p className="text-xs text-gray-400 mt-0.5">
          {dog.zip ? `ZIP ${dog.zip}` : "no ZIP"}
          {match.distance_miles != null ? ` · ${match.distance_miles} mi away` : ""}
        </p>
      </div>
    </button>
  );
}