From 0d0a881555fd421008f197ea43ebbfc9c21a6d2c Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Sun, 3 May 2026 15:00:26 +0800 Subject: [PATCH] refine workspace previews and library thumbnails --- WebSite/src/App.tsx | 257 +++++++++++++++++++++++++++++++++----------- web_backend.py | 47 +++++++- 2 files changed, 243 insertions(+), 61 deletions(-) diff --git a/WebSite/src/App.tsx b/WebSite/src/App.tsx index 8fb7698..0f57a99 100644 --- a/WebSite/src/App.tsx +++ b/WebSite/src/App.tsx @@ -54,6 +54,7 @@ type BackendJob = { status: 'running' | 'completed' | 'failed'; message: string; progress?: number; + params?: any; result?: any; error?: string; }; @@ -124,6 +125,7 @@ type StoredDeformationJob = { const DEFORMATION_JOB_STORAGE_KEY = 'head_ct_morph_deformation_job'; const DEFORMATION_RESULT_RENDER_VERSION = 'quick-preview-cutoff-only-v1'; +const LIBRARY_SLICE_STORAGE_KEY = 'head_ct_morph_library_slice_index'; const API_BASE = typeof window === 'undefined' ? 'http://127.0.0.1:8787' @@ -162,20 +164,56 @@ function readStoredDeformationJob(): StoredDeformationJob | null { } } +function readStoredLibrarySliceIndexes(): Record { + if (typeof window === 'undefined') return {}; + try { + const rawValue = window.localStorage.getItem(LIBRARY_SLICE_STORAGE_KEY); + if (!rawValue) return {}; + const parsed = JSON.parse(rawValue); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +function readStoredLibrarySliceIndex(itemId: string, fallback: number, count: number) { + const value = readStoredLibrarySliceIndexes()[itemId]; + const numericValue = typeof value === 'number' ? value : fallback; + return Math.max(0, Math.min(Math.max(0, count - 1), Math.round(numericValue))); +} + +function storeLibrarySliceIndex(itemId: string, index: number) { + if (typeof window === 'undefined') return; + try { + const current = readStoredLibrarySliceIndexes(); + current[itemId] = index; + window.localStorage.setItem(LIBRARY_SLICE_STORAGE_KEY, JSON.stringify(current)); + } catch { + // 保存失败不影响影像预览使用。 + } +} + function LibraryDicomPreview({ item }: { item: LibraryItem }) { - const [sliceIndex, setSliceIndex] = useState(Math.max(0, Math.floor((item.fileCount || 1) / 2))); + const count = Math.max(1, item.fileCount || 1); + const middleIndex = Math.max(0, Math.floor(count / 2)); + const [sliceIndex, setSliceIndex] = useState(() => readStoredLibrarySliceIndex(item.id, middleIndex, count)); const [requestedSliceIndex, setRequestedSliceIndex] = useState(sliceIndex); const [previewImage, setPreviewImage] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); - const count = Math.max(1, item.fileCount || 1); useEffect(() => { - const middleIndex = Math.max(0, Math.floor((item.fileCount || 1) / 2)); - setSliceIndex(middleIndex); - setRequestedSliceIndex(middleIndex); + const nextCount = Math.max(1, item.fileCount || 1); + const nextMiddleIndex = Math.max(0, Math.floor(nextCount / 2)); + const storedIndex = readStoredLibrarySliceIndex(item.id, nextMiddleIndex, nextCount); + setSliceIndex(storedIndex); + setRequestedSliceIndex(storedIndex); }, [item.id, item.fileCount]); + useEffect(() => { + storeLibrarySliceIndex(item.id, Math.min(sliceIndex, count - 1)); + }, [item.id, sliceIndex, count]); + useEffect(() => { const timer = window.setTimeout(() => { setRequestedSliceIndex(sliceIndex); @@ -246,6 +284,44 @@ function LibraryDicomPreview({ item }: { item: LibraryItem }) { ); } +function OverviewDicomThumbnail({ item }: { item: LibraryItem }) { + const [previewImage, setPreviewImage] = useState(''); + const [error, setError] = useState(false); + + useEffect(() => { + const controller = new AbortController(); + const count = Math.max(1, item.fileCount || 1); + const middleIndex = Math.max(0, Math.floor(count / 2)); + const index = readStoredLibrarySliceIndex(item.id, middleIndex, count); + + setError(false); + fetch(`${API_BASE}/api/library/preview?id=${encodeURIComponent(item.id)}&index=${index}`, { + signal: controller.signal + }) + .then(async response => { + const data = await response.json(); + if (!response.ok) throw new Error(data.error || '预览生成失败'); + setPreviewImage(`${API_BASE}${data.imageUrl}`); + }) + .catch(error => { + if ((error as Error).name !== 'AbortError') setError(true); + }); + + return () => controller.abort(); + }, [item.id, item.fileCount]); + + return ( +
+ {previewImage && !error ? ( + + ) : ( + + )} +
+
+ ); +} + export default function App() { const restoredDeformationJob = useRef(readStoredDeformationJob()).current; @@ -294,6 +370,7 @@ export default function App() { const [backendOnline, setBackendOnline] = useState(false); const [backendMessage, setBackendMessage] = useState('正在连接本地 Python 后端...'); const [previewImage, setPreviewImage] = useState(''); + const [processPreviewImage, setProcessPreviewImage] = useState(''); const [isPreviewLoading, setIsPreviewLoading] = useState(false); const [deformationJob, setDeformationJob] = useState(restoredDeformationJob?.job || null); const [videoJob, setVideoJob] = useState(null); @@ -376,6 +453,16 @@ export default function App() { link.remove(); }; + const downloadProcessPreviewImage = () => { + if (!processPreviewImage) return; + const link = document.createElement('a'); + link.href = processPreviewImage; + link.download = `four_state_process_preview_${cervicalRotation.toFixed(1)}deg.png`; + document.body.appendChild(link); + link.click(); + link.remove(); + }; + const clearDeformationTask = () => { setDeformationJob(null); setProgress(0); @@ -453,6 +540,13 @@ export default function App() { handlePackageDownload('all', packageOptions); }; + const downloadFourStateDicomZip = () => { + handlePackageDownload('all', { + dicom: DICOM_PACKAGE_OPTIONS.map(option => option.key), + images: [], + }); + }; + const loadLibrary = async () => { const data = await apiRequest('/api/library'); const items = data.items || []; @@ -473,6 +567,7 @@ export default function App() { setSelectedLibraryId(items[0]?.id || ''); setLibraryInfo(null); setPreviewImage(''); + setProcessPreviewImage(''); setVideoJob(null); setZipJobs({}); clearDeformationTask(); @@ -728,6 +823,7 @@ export default function App() { setSelectedLibraryId(data.id || items[0]?.id || ''); setCurrentPage('workspace'); setPreviewImage(''); + setProcessPreviewImage(''); clearDeformationTask(); showToast(`已上传 ${data.fileCount || files.length} 张 DICOM`); } catch (error) { @@ -751,6 +847,7 @@ export default function App() { if (selectedLibraryId === id) { setSelectedLibraryId(data.items?.[0]?.id || ''); setPreviewImage(''); + setProcessPreviewImage(''); clearDeformationTask(); } showToast('影像已删除'); @@ -802,6 +899,7 @@ export default function App() { const data = await response.json(); if (!response.ok) throw new Error(data.error || '预览生成失败'); setPreviewImage(data.image); + setProcessPreviewImage(data.processImage || ''); setBackendOnline(true); setBackendMessage('预览已自动更新'); } catch (error) { @@ -942,7 +1040,7 @@ export default function App() { - +
@@ -976,9 +1074,9 @@ export default function App() { )}

{currentPage === 'overview' && '控制台总览'} - {currentPage === 'library' && '临床数据存档'} + {currentPage === 'library' && '数据影像库'} {currentPage === 'workspace' && '影像变换工作站'} - {currentPage === 'users' && '系统访问权限管理'} + {currentPage === 'users' && '系统管理工作区'}

@@ -1013,6 +1111,7 @@ export default function App() { onChange={event => { setSelectedLibraryId(event.target.value); setPreviewImage(''); + setProcessPreviewImage(''); clearDeformationTask(); }} className="w-full px-3 py-2.5 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-xs font-bold text-slate-700" @@ -1210,59 +1309,99 @@ export default function App() {
-
- {[ - { key: 'original', label: '原始序列', sub: 'ct_original' }, - { key: 'hard_boundary', label: '硬边界', sub: 'ct_hard_boundary' }, - { key: 'gaussian_smooth', label: '高斯平滑', sub: 'ct_gaussian_smooth' }, - { key: 'soft_transition', label: '软过渡重建', sub: 'ct_soft_transition' } - ].map(t => { - const screenshotDir = deformationJob?.result?.previews?.screenshots; - const imagePath = screenshotDir ? `${screenshotDir}/${t.key}.png` : ''; - const zipJob = zipJobs[t.key]; - return ( -
-
- {t.label} - {t.sub} -
-
- {imagePath ? ( - - ) : ( -
- -

等待 Python 输出

-
- )} -
- {deformationJob?.status === 'completed' && deformationJob.result?.outputs?.[t.key] && ( - - )} - {zipJob?.status === 'failed' &&

{zipJob.error || '打包失败'}

} +
+
+
+

四状态过程对比图

+

随仰头角度与算法参数自动更新

+
+
+ {isPreviewLoading && 同步更新中...} + +
+
+
+ {processPreviewImage ? ( + + ) : ( +
+ +

{isPreviewLoading ? '正在生成四状态对比图...' : '选择影像库数据后自动生成对比图'}

- ); - })} + )} +
- {deformationJob?.result?.previews?.comparison && ( -
-
-

四状态过程对比图

- {deformationJob.result.previews.comparison} +
+
+
+

四状态 DICOM 输出结果

+

本区域显示已生成的四状态输出截图

- +
+
+

仰头角度

+

+ {(deformationJob?.params?.angleDegrees ?? cervicalRotation).toFixed(1)}° +

+
+
+

软过渡宽度

+

+ {deformationJob?.params?.transitionWidth ?? transitionWidth} +

+
+
+
- )} +
+ {[ + { key: 'original', label: '原始序列', sub: 'ct_original' }, + { key: 'hard_boundary', label: '硬边界', sub: 'ct_hard_boundary' }, + { key: 'gaussian_smooth', label: '高斯平滑', sub: 'ct_gaussian_smooth' }, + { key: 'soft_transition', label: '软过渡重建', sub: 'ct_soft_transition' } + ].map(t => { + const screenshotDir = deformationJob?.result?.previews?.screenshots; + const imagePath = screenshotDir ? `${screenshotDir}/${t.key}.png` : ''; + return ( +
+
+ {t.label} + {t.sub} +
+
+ {imagePath ? ( + + ) : ( +
+ +

等待 Python 输出

+
+ )} +
+
+ ); + })} +
+ {zipJobs.all?.status === 'failed' &&

{zipJobs.all.error || '四状态 DICOM ZIP 打包失败'}

} +
+
)} @@ -1293,10 +1432,7 @@ export default function App() { {libraryData.slice(0, 4).map(item => (
-
-
-
-
+

{item.patientId}

{item.date} · {item.size}MB

@@ -1387,6 +1523,7 @@ export default function App() { if (item.status === 'processed') { setSelectedLibraryId(item.id); setPreviewImage(''); + setProcessPreviewImage(''); clearDeformationTask(); setCurrentPage('workspace'); showToast(`已选择 ${item.patientId} 作为工作站数据源`); diff --git a/web_backend.py b/web_backend.py index 5153140..1b957df 100644 --- a/web_backend.py +++ b/web_backend.py @@ -18,7 +18,7 @@ os.environ.setdefault("MPLCONFIGDIR", "/tmp/head_ct_morph_matplotlib") import pydicom from pydicom.multival import MultiValue -from PIL import Image +from PIL import Image, ImageDraw from generate_head_extension_video import generate_video from head_extension_app import ( @@ -662,11 +662,56 @@ def make_preview( canvas_image.paste(fit_image(before_display, 700, 520), (0, 0)) canvas_image.paste(fit_image(after, 700, 520), (740, 0)) + panels = [ + ("Original", before_display), + ( + "Hard boundary", + preview_deform_2d(before_display, float(angle_degrees), "hard_boundary"), + ), + ( + "Gaussian smooth", + preview_deform_2d( + before_display, + float(angle_degrees), + "gaussian_smooth", + gaussian_sigma=float(gaussian_sigma), + ), + ), + ( + "Soft transition", + preview_deform_2d( + before_display, + float(angle_degrees), + "soft_transition", + transition_width=float(transition_width), + ), + ), + ] + process_image = Image.new("RGB", (1440, 430), (0, 0, 0)) + process_draw = ImageDraw.Draw(process_image) + panel_width = 330 + panel_height = 300 + margin = 35 + gap = 20 + for index, (label, panel) in enumerate(panels): + x = margin + index * (panel_width + gap) + process_draw.text((x, 28), label, fill=(230, 230, 230)) + process_image.paste(fit_image(panel, panel_width, panel_height), (x, 70)) + process_draw.text( + (margin, 390), + f"Angle {float(angle_degrees):.1f} deg", + fill=(170, 170, 170), + ) + canvas = BytesIO() canvas_image.save(canvas, format="PNG") encoded = base64.b64encode(canvas.getvalue()).decode("ascii") + process_canvas = BytesIO() + process_image.save(process_canvas, format="PNG") + process_encoded = base64.b64encode(process_canvas.getvalue()).decode("ascii") return { "image": f"data:image/png;base64,{encoded}", + "processImage": f"data:image/png;base64,{process_encoded}", "source": str(Path(input_dir).resolve()), "angleDegrees": float(angle_degrees), }