jayvatliq's picture
Upload folder using huggingface_hub
16533c4 verified
Raw
History Blame Contribute Delete
16.6 kB
import { useState, useCallback } from "react";
import {
Car,
Scan,
RefreshCw,
FileArchive,
ChevronRight,
Layers,
CheckCircle2,
XCircle,
} from "lucide-react";
import { ZipUploader } from "@/components/ZipUploader";
import { CarAngleDiagram } from "@/components/CarAngleDiagram";
import { AngleCard } from "@/components/AngleCard";
import { ImageResultCard } from "@/components/ImageResultCard";
import { ProgressBar } from "@/components/ProgressBar";
import { classifyImage } from "@/services/api";
import type {
ImageResult,
AngleSummary,
ClassificationSession,
ProcessingStatus,
AngleLabel,
} from "@/types";
import { ALL_ANGLES } from "@/types";
export default function Index() {
const [status, setStatus] = useState<ProcessingStatus>("idle");
const [session, setSession] = useState<ClassificationSession | null>(null);
const [progress, setProgress] = useState({ current: 0, total: 0 });
const [activeTab, setActiveTab] = useState<"diagram" | "images">("diagram");
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const buildSummary = (results: ImageResult[]): AngleSummary[] => {
return ALL_ANGLES.map((angle) => {
const matching = results.filter((r) => r.angles.includes(angle));
const bestScore =
matching.length > 0
? Math.max(
...matching.map((r) => {
const idx = r.angles.indexOf(angle);
return idx >= 0 ? (r.scores[idx] ?? 0) : 0;
})
)
: 0;
return {
angle,
detected: matching.length > 0,
bestScore,
detectedIn: matching.map((r) => r.filename),
previewUrls: matching.map((r) => r.previewUrl).filter((u): u is string => !!u),
};
});
};
const handleFilesReady = useCallback(
async (files: File[], zipName: string) => {
setStatus("processing");
setErrorMsg(null);
setProgress({ current: 0, total: files.length });
const results: ImageResult[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const previewUrl = URL.createObjectURL(file);
try {
const result = await classifyImage(file);
results.push({ ...result, previewUrl });
} catch (err) {
results.push({
filename: file.name,
angles: [],
scores: [],
review: false,
error: err instanceof Error ? err.message : "Request failed",
fileDetails: null,
previewUrl,
});
}
setProgress({ current: i + 1, total: files.length });
}
const angleSummary = buildSummary(results);
setSession({
zipFilename: zipName,
processedAt: Date.now(),
totalImages: files.length,
angleSummary,
imageResults: results,
});
setStatus("done");
},
[]
);
const handleReset = () => {
setStatus("idle");
setSession(null);
setProgress({ current: 0, total: 0 });
setErrorMsg(null);
};
const detectedCount = session?.angleSummary.filter((s) => s.detected).length ?? 0;
const missingCount = (session?.angleSummary.length ?? 0) - detectedCount;
// Dynamic header stats
const allScores = session?.imageResults.flatMap((r) => r.scores).filter((s) => s > 0) ?? [];
const avgScore = allScores.length > 0 ? allScores.reduce((a, b) => a + b, 0) / allScores.length : 0;
const accuracyLabel = !session
? "—"
: avgScore >= 0.85
? "High"
: avgScore >= 0.65
? "Medium"
: "Low";
const accuracyColor = !session
? "text-slate-500"
: avgScore >= 0.85
? "text-emerald-400"
: avgScore >= 0.65
? "text-amber-400"
: "text-red-400";
const headerStats = [
{
label: session ? "Angles Found" : "Angles Tracked",
value: session ? `${detectedCount}/8` : "8",
color: session
? detectedCount === 8
? "text-emerald-400"
: detectedCount > 0
? "text-amber-400"
: "text-red-400"
: "text-brand-400",
},
{
label: "Images Processed",
value: session ? String(session.totalImages) : "—",
color: session ? "text-brand-400" : "text-slate-500",
},
{
label: "Avg Confidence",
value: session ? `${Math.round(avgScore * 100)}%` : "—",
color: accuracyColor,
},
];
return (
<div className="min-h-screen bg-surface-900 text-white font-sans">
{/* ── Hero Header ── */}
<header className="relative overflow-hidden border-b border-surface-700">
<div className="absolute inset-0 bg-hero-glow pointer-events-none" />
<div className="absolute inset-0 bg-grid-pattern bg-[size:40px_40px] opacity-10 pointer-events-none" />
<div className="relative max-w-7xl mx-auto px-6 py-8 flex items-center justify-between">
{/* Logo */}
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-brand-500/20 border border-brand-500/30 flex items-center justify-center">
<Car className="w-6 h-6 text-brand-400" />
</div>
<div>
<h1 className="text-xl font-bold text-white leading-none">CarLens AI</h1>
<p className="text-slate-400 text-xs mt-0.5">Vehicle Angle Classifier</p>
</div>
</div>
{/* Stats strip */}
<div className="hidden md:flex items-center gap-8">
{headerStats.map(({ label, value, color }) => (
<div key={label} className="text-center">
<p className={`text-lg font-bold tabular-nums transition-all duration-500 ${color}`}>
{value}
</p>
<p className="text-xs text-slate-500">{label}</p>
</div>
))}
</div>
{/* Reset button */}
{session && (
<button
onClick={handleReset}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-surface-700 hover:bg-surface-600 border border-surface-500 hover:border-surface-400 transition-all text-sm font-medium text-slate-300 hover:text-white"
>
<RefreshCw className="w-4 h-4" />
New Analysis
</button>
)}
</div>
</header>
<main className="max-w-7xl mx-auto px-6 py-10 space-y-10">
{/* ── Upload Section ── */}
{status === "idle" && (
<section className="animate-fade-in space-y-6">
{/* Hero copy */}
<div className="text-center space-y-4 py-4">
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-brand-500/10 border border-brand-500/20 text-brand-400 text-sm font-medium mb-2">
<Scan className="w-4 h-4" />
AI-Powered Angle Detection
</div>
<h2 className="text-4xl md:text-5xl font-extrabold text-white leading-tight">
Which car angles are{" "}
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-400 to-cyan-400">
missing?
</span>
</h2>
<p className="text-slate-400 text-lg max-w-2xl mx-auto">
Upload a ZIP of car photos. Our AI scans each image, classifies its viewing angle,
and tells you exactly which angles you still need to capture.
</p>
</div>
{/* How it works */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[
{
step: "01",
title: "Upload ZIP",
desc: "Drop a ZIP archive containing all your car images",
icon: FileArchive,
color: "text-brand-400",
bg: "bg-brand-500/10 border-brand-500/20",
},
{
step: "02",
title: "AI Analysis",
desc: "YOLO models detect parts and determine viewing angle per image",
icon: Scan,
color: "text-cyan-400",
bg: "bg-cyan-500/10 border-cyan-500/20",
},
{
step: "03",
title: "Gap Report",
desc: "See a visual map of detected vs. missing angles at a glance",
icon: Layers,
color: "text-emerald-400",
bg: "bg-emerald-500/10 border-emerald-500/20",
},
].map(({ step, title, desc, icon: Icon, color, bg }) => (
<div
key={step}
className={`rounded-2xl border p-6 ${bg} flex gap-4 items-start`}
>
<div className={`text-4xl font-black opacity-20 ${color} leading-none`}>
{step}
</div>
<div className="flex-1">
<div className={`flex items-center gap-2 mb-2 ${color}`}>
<Icon className="w-5 h-5" />
<span className="font-semibold">{title}</span>
</div>
<p className="text-slate-400 text-sm">{desc}</p>
</div>
</div>
))}
</div>
<ZipUploader onFilesReady={handleFilesReady} />
</section>
)}
{/* ── Processing ── */}
{status === "processing" && (
<section className="animate-fade-in flex flex-col items-center justify-center py-20 gap-8">
<div className="relative">
<div className="w-24 h-24 rounded-full border-4 border-brand-500/20 border-t-brand-500 animate-spin" />
<div className="absolute inset-0 flex items-center justify-center">
<Car className="w-8 h-8 text-brand-400 animate-pulse-slow" />
</div>
</div>
<div className="text-center space-y-2">
<h3 className="text-2xl font-bold text-white">Classifying Images</h3>
<p className="text-slate-400">
Running YOLO detection on each photo in your ZIP…
</p>
</div>
<div className="w-full max-w-md">
<ProgressBar current={progress.current} total={progress.total} />
</div>
</section>
)}
{/* ── Results ── */}
{status === "done" && session && (
<section className="animate-slide-up space-y-8">
{/* Summary header */}
<div className="rounded-2xl bg-surface-700 border border-surface-600 p-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-1">
<FileArchive className="w-5 h-5 text-slate-400" />
<span className="font-mono text-slate-300 text-sm">{session.zipFilename}</span>
</div>
<h3 className="text-2xl font-bold text-white">
Analysis Complete
</h3>
<p className="text-slate-400 text-sm mt-1">
Processed {session.totalImages} image{session.totalImages !== 1 ? "s" : ""}
{" "}·{" "}
{new Date(session.processedAt).toLocaleString()}
</p>
</div>
{/* Quick badges */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
<span className="text-emerald-400 font-bold">{detectedCount}</span>
<span className="text-slate-400 text-sm">detected</span>
</div>
{missingCount > 0 && (
<div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-red-500/10 border border-red-500/20">
<XCircle className="w-4 h-4 text-red-400" />
<span className="text-red-400 font-bold">{missingCount}</span>
<span className="text-slate-400 text-sm">missing</span>
</div>
)}
</div>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 border-b border-surface-600 pb-0">
{(["diagram", "images"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={[
"px-5 py-3 text-sm font-medium rounded-t-xl border-b-2 transition-all",
activeTab === tab
? "border-brand-500 text-white bg-brand-500/10"
: "border-transparent text-slate-400 hover:text-white hover:bg-surface-700",
].join(" ")}
>
{tab === "diagram" ? "Angle Coverage" : `Per-Image Results (${session.totalImages})`}
</button>
))}
</div>
{/* Tab content */}
{activeTab === "diagram" && (
<div className="animate-fade-in grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Diagram */}
<div className="rounded-2xl bg-surface-800 border border-surface-600 p-6">
<h4 className="font-semibold text-white mb-6 flex items-center gap-2">
<Car className="w-5 h-5 text-brand-400" />
Car Coverage Map
</h4>
<CarAngleDiagram summary={session.angleSummary} />
</div>
{/* Angle list */}
<div className="space-y-3">
<h4 className="font-semibold text-white flex items-center gap-2">
<Layers className="w-5 h-5 text-brand-400" />
Angle Breakdown
</h4>
<div className="space-y-2">
{session.angleSummary.map((s) => (
<AngleCard key={s.angle} summary={s} />
))}
</div>
</div>
</div>
)}
{activeTab === "images" && (
<div className="animate-fade-in space-y-3">
<h4 className="font-semibold text-white mb-2 text-sm text-slate-400 uppercase tracking-wider">
Individual Image Results
</h4>
{session.imageResults.map((result, i) => (
<ImageResultCard key={result.filename + i} result={result} index={i} />
))}
</div>
)}
{/* Missing angles callout */}
{missingCount > 0 && (
<div className="rounded-2xl bg-amber-500/5 border border-amber-500/20 p-5 flex gap-4 items-start">
<div className="w-10 h-10 rounded-xl bg-amber-500/20 flex items-center justify-center flex-shrink-0">
<XCircle className="w-5 h-5 text-amber-400" />
</div>
<div>
<h4 className="font-semibold text-amber-300 mb-1">Missing Angles</h4>
<p className="text-slate-400 text-sm mb-3">
The following views were not found in any of the uploaded images:
</p>
<div className="flex flex-wrap gap-2">
{session.angleSummary
.filter((s) => !s.detected)
.map((s) => (
<span
key={s.angle}
className="text-sm px-3 py-1 rounded-full bg-red-500/15 text-red-300 border border-red-500/25 font-medium"
>
{s.angle}
</span>
))}
</div>
</div>
</div>
)}
</section>
)}
</main>
{/* Footer */}
<footer className="border-t border-surface-700 mt-16 py-6 text-center">
<p className="text-slate-500 text-sm">
CarLens AI · Powered by YOLO &amp; FastAPI · Built by Atliq
</p>
</footer>
</div>
);
}