feat: implement backend image classification with TensorFlow.js model

- Extend shared types for image classification, including PredictionProbability, UploaderMetadata, and ImageClassificationRecord.
- Create image_classifications table in the database with necessary fields and foreign key constraints.
- Implement disease mappers to convert database rows to shared disease records.
- Develop uploader client to handle image uploads to external service.
- Create image model service to load and classify images using TensorFlow.js.
- Add API routes for image classification, including GET for history and POST for new classifications.
- Implement frontend components for image classification form and display results.
- Update dashboard to integrate image classification functionality and display results.
- Document implementation plan for backend image classification.
This commit is contained in:
Asep Haryana Saputra
2026-05-22 15:13:22 +00:00
parent 229db82a08
commit 15c6be9e84
19 changed files with 1750 additions and 33 deletions
+55 -24
View File
@@ -1,9 +1,10 @@
import { useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { BookOpen, History, Leaf, LayoutDashboard, TrendingUp } from 'lucide-react';
import { BookOpen, History, Leaf, LayoutDashboard, TrendingUp, Image } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ManualClassificationForm } from '@/components/manual-classification-form';
import { ImageClassificationForm } from '@/components/image-classification-form';
import { RiskBadge } from '@/components/risk-badge';
import { useUiStore } from '@/store/ui-store';
import { apiClient } from '@/lib/api-client';
@@ -12,7 +13,7 @@ export function DashboardPage() {
const { dashboardCompact, toggleDashboardCompact } = useUiStore();
const queryClient = useQueryClient();
const [diseasesQuery, summaryQuery, classificationsQuery] = useQueries({
const [diseasesQuery, summaryQuery, classificationsQuery, imageClassificationsQuery] = useQueries({
queries: [
{
queryKey: ['diseases'],
@@ -26,6 +27,10 @@ export function DashboardPage() {
queryKey: ['manual-classifications'],
queryFn: () => apiClient.getManualClassifications(),
},
{
queryKey: ['image-classifications'],
queryFn: () => apiClient.getImageClassifications(),
},
],
});
@@ -39,9 +44,20 @@ export function DashboardPage() {
},
});
const createImageClassificationMutation = useMutation({
mutationFn: async (file: File) => {
return await apiClient.createImageClassification(file);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['image-classifications'] });
queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] });
},
});
const diseases = diseasesQuery.data || [];
const summary = summaryQuery.data;
const classifications = classificationsQuery.data || [];
const imageClassifications = imageClassificationsQuery.data || [];
const isLoadingData = diseasesQuery.isLoading || summaryQuery.isLoading;
const hasError = diseasesQuery.error || summaryQuery.error;
@@ -220,6 +236,14 @@ export function DashboardPage() {
</Card>
)}
<ImageClassificationForm
onSubmit={async (file) => {
await createImageClassificationMutation.mutateAsync(file);
}}
isSubmitting={createImageClassificationMutation.isPending}
latestResult={imageClassifications[0] ?? null}
/>
<ManualClassificationForm
diseases={diseases}
onSubmit={async (payload) => {
@@ -228,38 +252,45 @@ export function DashboardPage() {
isSubmitting={createClassificationMutation.isPending}
/>
{classifications.length > 0 && (
{imageClassifications.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Leaf className="h-5 w-5" />
Riwayat Laporan
<Image className="h-5 w-5" />
Riwayat Klasifikasi Gambar
</CardTitle>
<CardDescription>
{classifications.length} laporan pengamatan penyakit
{imageClassifications.length} gambar yang diklasifikasi
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{classifications.slice(0, 5).map((classification) => (
<div key={classification.id} className="rounded-lg border border-border p-3">
<div className="flex items-start justify-between gap-4 mb-2">
<div>
<h4 className="text-sm font-semibold">
{classification.disease.commonName}
</h4>
<p className="text-xs text-muted-foreground">
{classification.disease.label}
</p>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{imageClassifications.slice(0, 6).map((classification) => (
<div key={classification.id} className="rounded-lg border border-border overflow-hidden">
<img
src={classification.imageUrl}
alt="Classified corn leaf"
className="w-full h-32 object-cover"
/>
<div className="p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<h4 className="text-sm font-semibold">
{classification.disease.commonName}
</h4>
<p className="text-xs text-muted-foreground">
{classification.disease.label}
</p>
</div>
<RiskBadge level={classification.disease.riskLevel} className="text-xs" />
</div>
<RiskBadge level={classification.disease.riskLevel} />
<div className="text-xs text-muted-foreground">
Kepercayaan: {(classification.confidence * 100).toFixed(1)}%
</div>
<p className="text-xs text-muted-foreground">
{new Date(classification.createdAt).toLocaleDateString('id-ID')}
</p>
</div>
<p className="text-xs text-muted-foreground mb-1">
{classification.observation}
</p>
<p className="text-xs text-muted-foreground">
{classification.location} {new Date(classification.createdAt).toLocaleDateString('id-ID')}
</p>
</div>
))}
</div>