refine workspace previews and library thumbnails

This commit is contained in:
2026-05-03 15:00:26 +08:00
parent 149cbc95d3
commit 0d0a881555
2 changed files with 243 additions and 61 deletions

View File

@@ -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<string, number> {
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 (
<div className="w-16 h-12 rounded-xl flex items-center justify-center bg-slate-950 border border-slate-700/50 overflow-hidden relative shadow-inner">
{previewImage && !error ? (
<img src={previewImage} className="w-full h-full object-contain" />
) : (
<ImageIcon size={18} className="text-white/35" />
)}
<div className="absolute inset-0 bg-blue-500/10 pointer-events-none"></div>
</div>
);
}
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<BackendJob | null>(restoredDeformationJob?.job || null);
const [videoJob, setVideoJob] = useState<BackendJob | null>(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() {
<NavItem icon={LayoutDashboard} label="总体概况" page="overview" />
<NavItem icon={Database} label="数据影像库" page="library" />
<NavItem icon={ImageIcon} label="影像变换工作站" page="workspace" />
<NavItem icon={Users} label="用户管理工作区" page="users" />
<NavItem icon={Users} label="系统管理工作区" page="users" />
</nav>
<div className="mt-auto pt-6 border-t font-medium">
@@ -976,9 +1074,9 @@ export default function App() {
)}
<h3 className="font-bold text-slate-800">
{currentPage === 'overview' && '控制台总览'}
{currentPage === 'library' && '临床数据存档'}
{currentPage === 'library' && '数据影像库'}
{currentPage === 'workspace' && '影像变换工作站'}
{currentPage === 'users' && '系统访问权限管理'}
{currentPage === 'users' && '系统管理工作区'}
</h3>
</div>
<div className="flex items-center gap-6">
@@ -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,7 +1309,68 @@ export default function App() {
</div>
</div>
<div className="grid grid-cols-2 gap-5">
<div className="bg-white rounded-2xl border shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b flex items-center justify-between">
<div>
<h4 className="font-black text-slate-800"></h4>
<p className="text-[10px] text-slate-400 font-bold mt-1"></p>
</div>
<div className="flex items-center gap-3">
{isPreviewLoading && <span className="text-[9px] font-bold text-blue-500">...</span>}
<button
onClick={downloadProcessPreviewImage}
disabled={!processPreviewImage}
className="w-9 h-9 rounded-xl bg-slate-100 text-slate-500 hover:bg-blue-600 hover:text-white transition-all flex items-center justify-center disabled:opacity-40 disabled:hover:bg-slate-100 disabled:hover:text-slate-500"
title="下载当前四状态过程对比图"
>
<Download size={15} />
</button>
</div>
</div>
<div className="h-[300px] bg-slate-950 flex items-center justify-center">
{processPreviewImage ? (
<img src={processPreviewImage} className="max-w-full max-h-full object-contain" />
) : (
<div className="text-center text-slate-500">
<Layers size={42} className="mx-auto mb-3 opacity-40" />
<p className="text-xs font-bold">{isPreviewLoading ? '正在生成四状态对比图...' : '选择影像库数据后自动生成对比图'}</p>
</div>
)}
</div>
</div>
<div className="bg-white rounded-2xl border shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b flex items-center justify-between gap-4">
<div>
<h4 className="font-black text-slate-800"> DICOM </h4>
<p className="text-[10px] text-slate-400 font-bold mt-1"></p>
</div>
<div className="flex items-center gap-3 text-center">
<div className="px-3 py-2 rounded-xl bg-slate-50 border border-slate-100 min-w-24">
<p className="text-[8px] font-black text-slate-300 uppercase"></p>
<p className="text-xs font-black text-blue-600 font-mono">
{(deformationJob?.params?.angleDegrees ?? cervicalRotation).toFixed(1)}°
</p>
</div>
<div className="px-3 py-2 rounded-xl bg-slate-50 border border-slate-100 min-w-28">
<p className="text-[8px] font-black text-slate-300 uppercase"></p>
<p className="text-xs font-black text-blue-600 font-mono">
{deformationJob?.params?.transitionWidth ?? transitionWidth}
</p>
</div>
</div>
<button
onClick={downloadFourStateDicomZip}
disabled={deformationJob?.status !== 'completed' || !deformationJob.result?.outputs || zipJobs.all?.status === 'running'}
className="px-5 py-3 bg-green-600 text-white rounded-xl text-[11px] font-black hover:bg-green-700 transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-wait"
>
<Download size={14} />
{zipJobs.all?.status === 'running'
? `打包中 ${formatProgress(progressFromJob(zipJobs.all, 0))}%`
: '下载四状态 DICOM ZIP'}
</button>
</div>
<div className="grid grid-cols-4 gap-4 p-4">
{[
{ key: 'original', label: '原始序列', sub: 'ct_original' },
{ key: 'hard_boundary', label: '硬边界', sub: 'ct_hard_boundary' },
@@ -1219,50 +1379,29 @@ export default function App() {
].map(t => {
const screenshotDir = deformationJob?.result?.previews?.screenshots;
const imagePath = screenshotDir ? `${screenshotDir}/${t.key}.png` : '';
const zipJob = zipJobs[t.key];
return (
<div key={t.key} className="bg-white p-4 rounded-2xl border flex flex-col hover:border-blue-200 transition-colors shadow-sm group min-h-[285px]">
<div key={t.key} className="min-w-0">
<div className="flex justify-between items-center mb-3">
<span className={`text-[10px] font-bold px-2 py-0.5 rounded transition-colors ${t.key === 'soft_transition' ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-500 group-hover:bg-slate-200'}`}>{t.label}</span>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded transition-colors ${t.key === 'original' ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-500'}`}>{t.label}</span>
<span className="text-[8px] font-mono text-slate-300">{t.sub}</span>
</div>
<div className="flex-1 bg-slate-900 rounded-xl relative flex justify-center items-center overflow-hidden border border-slate-800">
<div className="h-44 bg-slate-900 rounded-xl relative flex justify-center items-center overflow-hidden border border-slate-800">
{imagePath ? (
<img src={fileUrl(imagePath)} className="w-full h-full object-contain" />
) : (
<div className="text-center text-white/25">
<Layers size={34} className="mx-auto mb-3" />
<Layers size={30} className="mx-auto mb-3" />
<p className="text-[10px] font-mono uppercase"> Python </p>
</div>
)}
</div>
{deformationJob?.status === 'completed' && deformationJob.result?.outputs?.[t.key] && (
<button
onClick={() => handlePackageDownload(t.key)}
disabled={zipJob?.status === 'running'}
className="mt-3 py-2.5 bg-slate-100 text-slate-600 rounded-xl text-[11px] font-black hover:bg-green-600 hover:text-white transition-all flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-wait"
>
<Download size={13} />
{zipJob?.status === 'running'
? `打包中 ${formatProgress(progressFromJob(zipJob, 0))}%`
: '下载本状态 DICOM ZIP'}
</button>
)}
{zipJob?.status === 'failed' && <p className="text-[10px] text-red-500 font-bold mt-2 break-all">{zipJob.error || '打包失败'}</p>}
</div>
);
})}
</div>
{zipJobs.all?.status === 'failed' && <p className="px-5 pb-4 text-[10px] text-red-500 font-bold break-all">{zipJobs.all.error || '四状态 DICOM ZIP 打包失败'}</p>}
</div>
{deformationJob?.result?.previews?.comparison && (
<div className="bg-white p-4 rounded-2xl border shadow-sm">
<div className="flex justify-between items-center mb-3">
<h4 className="font-black text-sm text-slate-700"></h4>
<span className="text-[9px] font-mono text-slate-400 break-all">{deformationJob.result.previews.comparison}</span>
</div>
<img src={fileUrl(deformationJob.result.previews.comparison)} className="w-full rounded-xl bg-slate-950" />
</div>
)}
</div>
</div>
)}
@@ -1293,10 +1432,7 @@ export default function App() {
{libraryData.slice(0, 4).map(item => (
<div key={item.id} className="flex items-center justify-between p-5 bg-slate-50/50 rounded-3xl border border-transparent hover:border-blue-100 transition-all group">
<div className="flex items-center gap-5">
<div className={`w-16 h-12 rounded-xl flex items-center justify-center ${item.previewColor} border border-slate-700/50 flex-col gap-1 overflow-hidden relative shadow-inner`}>
<div className="w-6 h-6 border-white/20 border-2 rounded-full rotate-12 opacity-50"></div>
<div className="absolute inset-0 bg-blue-500/10 pointer-events-none"></div>
</div>
<OverviewDicomThumbnail item={item} />
<div>
<p className="text-sm font-bold text-slate-700">{item.patientId}</p>
<p className="text-[10px] text-slate-400 font-mono">{item.date} · {item.size}MB</p>
@@ -1387,6 +1523,7 @@ export default function App() {
if (item.status === 'processed') {
setSelectedLibraryId(item.id);
setPreviewImage('');
setProcessPreviewImage('');
clearDeformationTask();
setCurrentPage('workspace');
showToast(`已选择 ${item.patientId} 作为工作站数据源`);

View File

@@ -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),
}