feat: add real-time preprocessing preview with side-by-side comparison
- Create PreprocessingPreview component with debounced config updates - Show original vs preprocessed images side-by-side - Display image quality metrics (contrast, sharpness) with quality indicators - Add zoom controls and fullscreen view for detailed inspection - Show auto-detected configuration when in auto mode - Integrate preview toggle with PreprocessingSettings component - Add i18n translations for preview panel UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
357
frontend/src/components/PreprocessingPreview.tsx
Normal file
357
frontend/src/components/PreprocessingPreview.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Loader2, AlertCircle, RefreshCw, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { apiClientV2 } from '@/services/apiV2'
|
||||
import type {
|
||||
PreprocessingMode,
|
||||
PreprocessingConfig,
|
||||
PreprocessingPreviewResponse,
|
||||
ImageQualityMetrics,
|
||||
} from '@/types/apiV2'
|
||||
|
||||
interface PreprocessingPreviewProps {
|
||||
taskId: string
|
||||
mode: PreprocessingMode
|
||||
config: PreprocessingConfig
|
||||
page?: number
|
||||
className?: string
|
||||
onAutoConfigReceived?: (config: PreprocessingConfig) => void
|
||||
}
|
||||
|
||||
export default function PreprocessingPreview({
|
||||
taskId,
|
||||
mode,
|
||||
config,
|
||||
page = 1,
|
||||
className,
|
||||
onAutoConfigReceived,
|
||||
}: PreprocessingPreviewProps) {
|
||||
const { t } = useTranslation()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [previewData, setPreviewData] = useState<PreprocessingPreviewResponse | null>(null)
|
||||
const [originalImageUrl, setOriginalImageUrl] = useState<string | null>(null)
|
||||
const [preprocessedImageUrl, setPreprocessedImageUrl] = useState<string | null>(null)
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [showFullscreen, setShowFullscreen] = useState<'original' | 'preprocessed' | null>(null)
|
||||
|
||||
// Debounce config changes to avoid too many API calls
|
||||
const [debouncedConfig, setDebouncedConfig] = useState(config)
|
||||
const [debouncedMode, setDebouncedMode] = useState(mode)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedConfig(config)
|
||||
setDebouncedMode(mode)
|
||||
}, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [config, mode])
|
||||
|
||||
// Fetch preview when debounced config changes
|
||||
const fetchPreview = useCallback(async () => {
|
||||
if (!taskId || debouncedMode === 'disabled') {
|
||||
setPreviewData(null)
|
||||
setOriginalImageUrl(null)
|
||||
setPreprocessedImageUrl(null)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await apiClientV2.previewPreprocessing(taskId, {
|
||||
page,
|
||||
mode: debouncedMode,
|
||||
config: debouncedMode === 'manual' ? debouncedConfig : undefined,
|
||||
})
|
||||
|
||||
setPreviewData(response)
|
||||
|
||||
// Fetch images as blobs to handle authentication
|
||||
const [originalBlob, preprocessedBlob] = await Promise.all([
|
||||
apiClientV2.getPreviewImage(taskId, 'original', page),
|
||||
apiClientV2.getPreviewImage(taskId, 'preprocessed', page),
|
||||
])
|
||||
|
||||
// Revoke old URLs to prevent memory leaks
|
||||
if (originalImageUrl) URL.revokeObjectURL(originalImageUrl)
|
||||
if (preprocessedImageUrl) URL.revokeObjectURL(preprocessedImageUrl)
|
||||
|
||||
setOriginalImageUrl(URL.createObjectURL(originalBlob))
|
||||
setPreprocessedImageUrl(URL.createObjectURL(preprocessedBlob))
|
||||
|
||||
// Pass auto config to parent if available
|
||||
if (response.auto_config && onAutoConfigReceived) {
|
||||
onAutoConfigReceived(response.auto_config)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Preview fetch error:', err)
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to load preview')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [taskId, debouncedMode, debouncedConfig, page, onAutoConfigReceived])
|
||||
|
||||
useEffect(() => {
|
||||
fetchPreview()
|
||||
}, [fetchPreview])
|
||||
|
||||
// Cleanup URLs on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (originalImageUrl) URL.revokeObjectURL(originalImageUrl)
|
||||
if (preprocessedImageUrl) URL.revokeObjectURL(preprocessedImageUrl)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleZoomIn = () => setZoom((z) => Math.min(z + 0.25, 3))
|
||||
const handleZoomOut = () => setZoom((z) => Math.max(z - 0.25, 0.5))
|
||||
const handleResetZoom = () => setZoom(1)
|
||||
|
||||
const formatMetric = (value: number) => value.toFixed(2)
|
||||
|
||||
const getMetricQuality = (metric: keyof ImageQualityMetrics, value: number) => {
|
||||
const pp = 'processing.preprocessing.previewPanel'
|
||||
if (metric === 'contrast') {
|
||||
if (value < 30) return { label: t(`${pp}.qualityLow`), color: 'text-red-600' }
|
||||
if (value < 50) return { label: t(`${pp}.qualityMedium`), color: 'text-yellow-600' }
|
||||
return { label: t(`${pp}.qualityHigh`), color: 'text-green-600' }
|
||||
}
|
||||
if (metric === 'edge_strength') {
|
||||
if (value < 20) return { label: t(`${pp}.qualityBlurry`), color: 'text-red-600' }
|
||||
if (value < 40) return { label: t(`${pp}.qualityNormal`), color: 'text-yellow-600' }
|
||||
return { label: t(`${pp}.qualitySharp`), color: 'text-green-600' }
|
||||
}
|
||||
return { label: '', color: '' }
|
||||
}
|
||||
|
||||
if (debouncedMode === 'disabled') {
|
||||
return (
|
||||
<div className={cn('border rounded-lg p-6 bg-gray-50 text-center', className)}>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('processing.preprocessing.mode.disabledDesc', '前處理已停用,將直接使用原始影像')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('border rounded-lg bg-white overflow-hidden', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-gray-50">
|
||||
<h4 className="text-sm font-medium text-gray-700">
|
||||
{t('processing.preprocessing.preview', '前處理預覽')}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Zoom controls */}
|
||||
<div className="flex items-center gap-1 bg-white rounded-md border px-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleZoomOut}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
title="縮小"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetZoom}
|
||||
className="px-2 py-1 text-xs text-gray-600 hover:bg-gray-100 rounded min-w-[3rem]"
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleZoomIn}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
title="放大"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Refresh button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchPreview}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'p-1.5 rounded-md hover:bg-gray-200 transition-colors',
|
||||
isLoading && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
title="重新整理預覽"
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4 text-gray-600', isLoading && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && !previewData && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-500 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-500">{t('processing.preprocessing.previewPanel.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-3 p-3 bg-red-50 rounded-lg border border-red-200">
|
||||
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-700">{t('processing.preprocessing.previewPanel.loadError')}</p>
|
||||
<p className="text-xs text-red-600 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview content */}
|
||||
{previewData && originalImageUrl && preprocessedImageUrl && (
|
||||
<>
|
||||
{/* Image quality metrics */}
|
||||
<div className="px-4 py-3 border-b bg-blue-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-blue-700">{t('processing.preprocessing.previewPanel.qualityAnalysis')}</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-600">{t('processing.preprocessing.previewPanel.contrast')}:</span>
|
||||
<span className={cn('text-xs font-medium', getMetricQuality('contrast', previewData.quality_metrics.contrast).color)}>
|
||||
{formatMetric(previewData.quality_metrics.contrast)}
|
||||
<span className="ml-1">({getMetricQuality('contrast', previewData.quality_metrics.contrast).label})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-600">{t('processing.preprocessing.previewPanel.sharpness')}:</span>
|
||||
<span className={cn('text-xs font-medium', getMetricQuality('edge_strength', previewData.quality_metrics.edge_strength).color)}>
|
||||
{formatMetric(previewData.quality_metrics.edge_strength)}
|
||||
<span className="ml-1">({getMetricQuality('edge_strength', previewData.quality_metrics.edge_strength).label})</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Side-by-side comparison */}
|
||||
<div className="grid grid-cols-2 divide-x">
|
||||
{/* Original */}
|
||||
<div className="relative">
|
||||
<div className="px-3 py-2 border-b bg-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-600">{t('processing.preprocessing.previewPanel.original')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFullscreen('original')}
|
||||
className="p-1 hover:bg-gray-200 rounded"
|
||||
title={t('processing.preprocessing.previewPanel.fullscreen')}
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[400px] bg-gray-200">
|
||||
<div
|
||||
className="flex items-center justify-center min-h-[300px] p-2"
|
||||
style={{ transform: `scale(${zoom})`, transformOrigin: 'center center' }}
|
||||
>
|
||||
<img
|
||||
src={originalImageUrl}
|
||||
alt="Original"
|
||||
className="max-w-full h-auto shadow-md"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preprocessed */}
|
||||
<div className="relative">
|
||||
<div className="px-3 py-2 border-b bg-blue-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-blue-700">{t('processing.preprocessing.previewPanel.preprocessed')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFullscreen('preprocessed')}
|
||||
className="p-1 hover:bg-blue-200 rounded"
|
||||
title={t('processing.preprocessing.previewPanel.fullscreen')}
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5 text-blue-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[400px] bg-gray-200">
|
||||
<div
|
||||
className="flex items-center justify-center min-h-[300px] p-2"
|
||||
style={{ transform: `scale(${zoom})`, transformOrigin: 'center center' }}
|
||||
>
|
||||
<img
|
||||
src={preprocessedImageUrl}
|
||||
alt="Preprocessed"
|
||||
className="max-w-full h-auto shadow-md"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto config info */}
|
||||
{debouncedMode === 'auto' && previewData.auto_config && (
|
||||
<div className="px-4 py-3 border-t bg-green-50">
|
||||
<div className="text-xs text-green-700">
|
||||
<span className="font-medium">{t('processing.preprocessing.previewPanel.autoDetectedConfig')}:</span>
|
||||
<span className="ml-2">
|
||||
{t('processing.preprocessing.previewPanel.contrastEnhancement')}: {previewData.auto_config.contrast}
|
||||
{previewData.auto_config.contrast !== 'none' && ` (${previewData.auto_config.contrast_strength.toFixed(1)})`}
|
||||
</span>
|
||||
<span className="mx-2">|</span>
|
||||
<span>
|
||||
{t('processing.preprocessing.sharpen')}: {previewData.auto_config.sharpen ? t('processing.preprocessing.previewPanel.sharpenEnabled') : t('processing.preprocessing.previewPanel.sharpenDisabled')}
|
||||
{previewData.auto_config.sharpen && ` (${previewData.auto_config.sharpen_strength.toFixed(1)})`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading overlay for refresh */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-white/50 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fullscreen modal */}
|
||||
{showFullscreen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
|
||||
onClick={() => setShowFullscreen(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-4 right-4 text-white hover:text-gray-300 text-xl"
|
||||
onClick={() => setShowFullscreen(null)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<img
|
||||
src={showFullscreen === 'original' ? originalImageUrl! : preprocessedImageUrl!}
|
||||
alt={showFullscreen === 'original' ? 'Original (fullscreen)' : 'Preprocessed (fullscreen)'}
|
||||
className="max-w-[90vw] max-h-[90vh] object-contain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white/90 px-4 py-2 rounded-full text-sm font-medium">
|
||||
{showFullscreen === 'original'
|
||||
? t('processing.preprocessing.previewPanel.original')
|
||||
: t('processing.preprocessing.previewPanel.preprocessed')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user