PawTrace / frontend /src /components /PhotoTools.tsx
Elliott Duke
Demo: Dobby hero, photo swaps, revised copy, softened accuracy figures
6d6ee02
Raw
History Blame Contribute Delete
18.4 kB
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>
);
}