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>
|
||||
)
|
||||
}
|
||||
@@ -94,7 +94,29 @@
|
||||
"advanced": "進階選項",
|
||||
"binarize": "二值化處理",
|
||||
"binarizeWarning": "不建議使用",
|
||||
"note": "前處理僅影響版面偵測階段,用於改善表格和文字區塊的識別。原始影像仍用於最終的 OCR 文字提取,確保最佳識別品質。"
|
||||
"note": "前處理僅影響版面偵測階段,用於改善表格和文字區塊的識別。原始影像仍用於最終的 OCR 文字提取,確保最佳識別品質。",
|
||||
"previewPanel": {
|
||||
"title": "前處理預覽",
|
||||
"loading": "載入預覽中...",
|
||||
"loadError": "載入預覽失敗",
|
||||
"refresh": "重新整理預覽",
|
||||
"original": "原始影像",
|
||||
"preprocessed": "前處理後",
|
||||
"fullscreen": "全螢幕檢視",
|
||||
"qualityAnalysis": "影像品質分析",
|
||||
"contrast": "對比度",
|
||||
"sharpness": "清晰度",
|
||||
"qualityLow": "低",
|
||||
"qualityMedium": "中",
|
||||
"qualityHigh": "高",
|
||||
"qualityBlurry": "模糊",
|
||||
"qualityNormal": "一般",
|
||||
"qualitySharp": "清晰",
|
||||
"autoDetectedConfig": "自動偵測設定",
|
||||
"contrastEnhancement": "對比度增強",
|
||||
"sharpenEnabled": "啟用",
|
||||
"sharpenDisabled": "停用"
|
||||
}
|
||||
}
|
||||
},
|
||||
"results": {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { apiClientV2 } from '@/services/apiV2'
|
||||
import { Play, CheckCircle, FileText, AlertCircle, Clock, Activity, Loader2 } from 'lucide-react'
|
||||
import LayoutModelSelector from '@/components/LayoutModelSelector'
|
||||
import PreprocessingSettings from '@/components/PreprocessingSettings'
|
||||
import PreprocessingPreview from '@/components/PreprocessingPreview'
|
||||
import TaskNotFound from '@/components/TaskNotFound'
|
||||
import { useTaskValidation } from '@/hooks/useTaskValidation'
|
||||
import type { LayoutModel, ProcessingOptions, PreprocessingMode, PreprocessingConfig } from '@/types/apiV2'
|
||||
@@ -44,6 +45,7 @@ export default function ProcessingPage() {
|
||||
sharpen_strength: 1.0,
|
||||
binarize: false,
|
||||
})
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
|
||||
// Start OCR processing
|
||||
const processOCRMutation = useMutation({
|
||||
@@ -371,8 +373,24 @@ export default function ProcessingPage() {
|
||||
config={preprocessingConfig}
|
||||
onModeChange={setPreprocessingMode}
|
||||
onConfigChange={setPreprocessingConfig}
|
||||
onPreview={() => setShowPreview(!showPreview)}
|
||||
disabled={processOCRMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Preprocessing Preview */}
|
||||
{showPreview && taskId && (
|
||||
<PreprocessingPreview
|
||||
taskId={taskId}
|
||||
mode={preprocessingMode}
|
||||
config={preprocessingConfig}
|
||||
onAutoConfigReceived={(autoConfig) => {
|
||||
// Only update if user hasn't switched to manual mode
|
||||
if (preprocessingMode === 'auto') {
|
||||
setPreprocessingConfig(autoConfig)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,8 @@ import type {
|
||||
ProcessingOptions,
|
||||
ProcessingMetadata,
|
||||
DocumentAnalysisResponse,
|
||||
PreprocessingPreviewRequest,
|
||||
PreprocessingPreviewResponse,
|
||||
} from '@/types/apiV2'
|
||||
|
||||
/**
|
||||
@@ -517,6 +519,41 @@ class ApiClientV2 {
|
||||
window.URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
// ==================== Preprocessing Preview APIs ====================
|
||||
|
||||
/**
|
||||
* Preview preprocessing effect on a page
|
||||
*/
|
||||
async previewPreprocessing(
|
||||
taskId: string,
|
||||
request: PreprocessingPreviewRequest
|
||||
): Promise<PreprocessingPreviewResponse> {
|
||||
const response = await this.client.post<PreprocessingPreviewResponse>(
|
||||
`/tasks/${taskId}/preview/preprocessing`,
|
||||
request
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preprocessing preview image URL (with auth token)
|
||||
*/
|
||||
getPreviewImageUrl(taskId: string, type: 'original' | 'preprocessed', page: number): string {
|
||||
const baseUrl = `${API_BASE_URL}/api/${API_VERSION}/tasks/${taskId}/preview/image`
|
||||
return `${baseUrl}?type=${type}&page=${page}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preview image as blob (with authentication)
|
||||
*/
|
||||
async getPreviewImage(taskId: string, type: 'original' | 'preprocessed', page: number): Promise<Blob> {
|
||||
const response = await this.client.get(`/tasks/${taskId}/preview/image`, {
|
||||
params: { type, page },
|
||||
responseType: 'blob',
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ==================== Admin APIs ====================
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user