From 894d18b4325f6ca43f9596b779803652113e9a43 Mon Sep 17 00:00:00 2001 From: egg Date: Thu, 27 Nov 2025 17:25:52 +0800 Subject: [PATCH] feat: add real-time preprocessing preview with side-by-side comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/components/PreprocessingPreview.tsx | 357 ++++++++++++++++++ frontend/src/i18n/locales/zh-TW.json | 24 +- frontend/src/pages/ProcessingPage.tsx | 18 + frontend/src/services/apiV2.ts | 37 ++ 4 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/PreprocessingPreview.tsx diff --git a/frontend/src/components/PreprocessingPreview.tsx b/frontend/src/components/PreprocessingPreview.tsx new file mode 100644 index 0000000..3527fac --- /dev/null +++ b/frontend/src/components/PreprocessingPreview.tsx @@ -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(null) + const [previewData, setPreviewData] = useState(null) + const [originalImageUrl, setOriginalImageUrl] = useState(null) + const [preprocessedImageUrl, setPreprocessedImageUrl] = useState(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 ( +
+

+ {t('processing.preprocessing.mode.disabledDesc', '前處理已停用,將直接使用原始影像')} +

+
+ ) + } + + return ( +
+ {/* Header */} +
+

+ {t('processing.preprocessing.preview', '前處理預覽')} +

+
+ {/* Zoom controls */} +
+ + + +
+ {/* Refresh button */} + +
+
+ + {/* Loading state */} + {isLoading && !previewData && ( +
+
+ +

{t('processing.preprocessing.previewPanel.loading')}

+
+
+ )} + + {/* Error state */} + {error && ( +
+
+ +
+

{t('processing.preprocessing.previewPanel.loadError')}

+

{error}

+
+
+
+ )} + + {/* Preview content */} + {previewData && originalImageUrl && preprocessedImageUrl && ( + <> + {/* Image quality metrics */} +
+
+ {t('processing.preprocessing.previewPanel.qualityAnalysis')} +
+
+ {t('processing.preprocessing.previewPanel.contrast')}: + + {formatMetric(previewData.quality_metrics.contrast)} + ({getMetricQuality('contrast', previewData.quality_metrics.contrast).label}) + +
+
+ {t('processing.preprocessing.previewPanel.sharpness')}: + + {formatMetric(previewData.quality_metrics.edge_strength)} + ({getMetricQuality('edge_strength', previewData.quality_metrics.edge_strength).label}) + +
+
+
+
+ + {/* Side-by-side comparison */} +
+ {/* Original */} +
+
+
+ {t('processing.preprocessing.previewPanel.original')} + +
+
+
+
+ Original +
+
+
+ + {/* Preprocessed */} +
+
+
+ {t('processing.preprocessing.previewPanel.preprocessed')} + +
+
+
+
+ Preprocessed +
+
+
+
+ + {/* Auto config info */} + {debouncedMode === 'auto' && previewData.auto_config && ( +
+
+ {t('processing.preprocessing.previewPanel.autoDetectedConfig')}: + + {t('processing.preprocessing.previewPanel.contrastEnhancement')}: {previewData.auto_config.contrast} + {previewData.auto_config.contrast !== 'none' && ` (${previewData.auto_config.contrast_strength.toFixed(1)})`} + + | + + {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)})`} + +
+
+ )} + + {/* Loading overlay for refresh */} + {isLoading && ( +
+ +
+ )} + + )} + + {/* Fullscreen modal */} + {showFullscreen && ( +
setShowFullscreen(null)} + > + + {showFullscreen e.stopPropagation()} + /> +
+ {showFullscreen === 'original' + ? t('processing.preprocessing.previewPanel.original') + : t('processing.preprocessing.previewPanel.preprocessed')} +
+
+ )} +
+ ) +} diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index e72f027..2111535 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -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": { diff --git a/frontend/src/pages/ProcessingPage.tsx b/frontend/src/pages/ProcessingPage.tsx index 9a86ce5..5f245fa 100644 --- a/frontend/src/pages/ProcessingPage.tsx +++ b/frontend/src/pages/ProcessingPage.tsx @@ -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 && ( + { + // Only update if user hasn't switched to manual mode + if (preprocessingMode === 'auto') { + setPreprocessingConfig(autoConfig) + } + }} + /> + )} )} diff --git a/frontend/src/services/apiV2.ts b/frontend/src/services/apiV2.ts index 9b8f67b..f5281eb 100644 --- a/frontend/src/services/apiV2.ts +++ b/frontend/src/services/apiV2.ts @@ -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 { + const response = await this.client.post( + `/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 { + const response = await this.client.get(`/tasks/${taskId}/preview/image`, { + params: { type, page }, + responseType: 'blob', + }) + return response.data + } + // ==================== Admin APIs ==================== /**