refine workspace previews and library thumbnails
This commit is contained in:
@@ -54,6 +54,7 @@ type BackendJob = {
|
|||||||
status: 'running' | 'completed' | 'failed';
|
status: 'running' | 'completed' | 'failed';
|
||||||
message: string;
|
message: string;
|
||||||
progress?: number;
|
progress?: number;
|
||||||
|
params?: any;
|
||||||
result?: any;
|
result?: any;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
@@ -124,6 +125,7 @@ type StoredDeformationJob = {
|
|||||||
|
|
||||||
const DEFORMATION_JOB_STORAGE_KEY = 'head_ct_morph_deformation_job';
|
const DEFORMATION_JOB_STORAGE_KEY = 'head_ct_morph_deformation_job';
|
||||||
const DEFORMATION_RESULT_RENDER_VERSION = 'quick-preview-cutoff-only-v1';
|
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'
|
const API_BASE = typeof window === 'undefined'
|
||||||
? 'http://127.0.0.1:8787'
|
? '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 }) {
|
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 [requestedSliceIndex, setRequestedSliceIndex] = useState(sliceIndex);
|
||||||
const [previewImage, setPreviewImage] = useState('');
|
const [previewImage, setPreviewImage] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const count = Math.max(1, item.fileCount || 1);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const middleIndex = Math.max(0, Math.floor((item.fileCount || 1) / 2));
|
const nextCount = Math.max(1, item.fileCount || 1);
|
||||||
setSliceIndex(middleIndex);
|
const nextMiddleIndex = Math.max(0, Math.floor(nextCount / 2));
|
||||||
setRequestedSliceIndex(middleIndex);
|
const storedIndex = readStoredLibrarySliceIndex(item.id, nextMiddleIndex, nextCount);
|
||||||
|
setSliceIndex(storedIndex);
|
||||||
|
setRequestedSliceIndex(storedIndex);
|
||||||
}, [item.id, item.fileCount]);
|
}, [item.id, item.fileCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
storeLibrarySliceIndex(item.id, Math.min(sliceIndex, count - 1));
|
||||||
|
}, [item.id, sliceIndex, count]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
setRequestedSliceIndex(sliceIndex);
|
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() {
|
export default function App() {
|
||||||
const restoredDeformationJob = useRef(readStoredDeformationJob()).current;
|
const restoredDeformationJob = useRef(readStoredDeformationJob()).current;
|
||||||
|
|
||||||
@@ -294,6 +370,7 @@ export default function App() {
|
|||||||
const [backendOnline, setBackendOnline] = useState(false);
|
const [backendOnline, setBackendOnline] = useState(false);
|
||||||
const [backendMessage, setBackendMessage] = useState('正在连接本地 Python 后端...');
|
const [backendMessage, setBackendMessage] = useState('正在连接本地 Python 后端...');
|
||||||
const [previewImage, setPreviewImage] = useState('');
|
const [previewImage, setPreviewImage] = useState('');
|
||||||
|
const [processPreviewImage, setProcessPreviewImage] = useState('');
|
||||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||||
const [deformationJob, setDeformationJob] = useState<BackendJob | null>(restoredDeformationJob?.job || null);
|
const [deformationJob, setDeformationJob] = useState<BackendJob | null>(restoredDeformationJob?.job || null);
|
||||||
const [videoJob, setVideoJob] = useState<BackendJob | null>(null);
|
const [videoJob, setVideoJob] = useState<BackendJob | null>(null);
|
||||||
@@ -376,6 +453,16 @@ export default function App() {
|
|||||||
link.remove();
|
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 = () => {
|
const clearDeformationTask = () => {
|
||||||
setDeformationJob(null);
|
setDeformationJob(null);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
@@ -453,6 +540,13 @@ export default function App() {
|
|||||||
handlePackageDownload('all', packageOptions);
|
handlePackageDownload('all', packageOptions);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const downloadFourStateDicomZip = () => {
|
||||||
|
handlePackageDownload('all', {
|
||||||
|
dicom: DICOM_PACKAGE_OPTIONS.map(option => option.key),
|
||||||
|
images: [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const loadLibrary = async () => {
|
const loadLibrary = async () => {
|
||||||
const data = await apiRequest('/api/library');
|
const data = await apiRequest('/api/library');
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
@@ -473,6 +567,7 @@ export default function App() {
|
|||||||
setSelectedLibraryId(items[0]?.id || '');
|
setSelectedLibraryId(items[0]?.id || '');
|
||||||
setLibraryInfo(null);
|
setLibraryInfo(null);
|
||||||
setPreviewImage('');
|
setPreviewImage('');
|
||||||
|
setProcessPreviewImage('');
|
||||||
setVideoJob(null);
|
setVideoJob(null);
|
||||||
setZipJobs({});
|
setZipJobs({});
|
||||||
clearDeformationTask();
|
clearDeformationTask();
|
||||||
@@ -728,6 +823,7 @@ export default function App() {
|
|||||||
setSelectedLibraryId(data.id || items[0]?.id || '');
|
setSelectedLibraryId(data.id || items[0]?.id || '');
|
||||||
setCurrentPage('workspace');
|
setCurrentPage('workspace');
|
||||||
setPreviewImage('');
|
setPreviewImage('');
|
||||||
|
setProcessPreviewImage('');
|
||||||
clearDeformationTask();
|
clearDeformationTask();
|
||||||
showToast(`已上传 ${data.fileCount || files.length} 张 DICOM`);
|
showToast(`已上传 ${data.fileCount || files.length} 张 DICOM`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -751,6 +847,7 @@ export default function App() {
|
|||||||
if (selectedLibraryId === id) {
|
if (selectedLibraryId === id) {
|
||||||
setSelectedLibraryId(data.items?.[0]?.id || '');
|
setSelectedLibraryId(data.items?.[0]?.id || '');
|
||||||
setPreviewImage('');
|
setPreviewImage('');
|
||||||
|
setProcessPreviewImage('');
|
||||||
clearDeformationTask();
|
clearDeformationTask();
|
||||||
}
|
}
|
||||||
showToast('影像已删除');
|
showToast('影像已删除');
|
||||||
@@ -802,6 +899,7 @@ export default function App() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok) throw new Error(data.error || '预览生成失败');
|
if (!response.ok) throw new Error(data.error || '预览生成失败');
|
||||||
setPreviewImage(data.image);
|
setPreviewImage(data.image);
|
||||||
|
setProcessPreviewImage(data.processImage || '');
|
||||||
setBackendOnline(true);
|
setBackendOnline(true);
|
||||||
setBackendMessage('预览已自动更新');
|
setBackendMessage('预览已自动更新');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -942,7 +1040,7 @@ export default function App() {
|
|||||||
<NavItem icon={LayoutDashboard} label="总体概况" page="overview" />
|
<NavItem icon={LayoutDashboard} label="总体概况" page="overview" />
|
||||||
<NavItem icon={Database} label="数据影像库" page="library" />
|
<NavItem icon={Database} label="数据影像库" page="library" />
|
||||||
<NavItem icon={ImageIcon} label="影像变换工作站" page="workspace" />
|
<NavItem icon={ImageIcon} label="影像变换工作站" page="workspace" />
|
||||||
<NavItem icon={Users} label="用户管理工作区" page="users" />
|
<NavItem icon={Users} label="系统管理工作区" page="users" />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="mt-auto pt-6 border-t font-medium">
|
<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">
|
<h3 className="font-bold text-slate-800">
|
||||||
{currentPage === 'overview' && '控制台总览'}
|
{currentPage === 'overview' && '控制台总览'}
|
||||||
{currentPage === 'library' && '临床数据存档'}
|
{currentPage === 'library' && '数据影像库'}
|
||||||
{currentPage === 'workspace' && '影像变换工作站'}
|
{currentPage === 'workspace' && '影像变换工作站'}
|
||||||
{currentPage === 'users' && '系统访问权限管理'}
|
{currentPage === 'users' && '系统管理工作区'}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
@@ -1013,6 +1111,7 @@ export default function App() {
|
|||||||
onChange={event => {
|
onChange={event => {
|
||||||
setSelectedLibraryId(event.target.value);
|
setSelectedLibraryId(event.target.value);
|
||||||
setPreviewImage('');
|
setPreviewImage('');
|
||||||
|
setProcessPreviewImage('');
|
||||||
clearDeformationTask();
|
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"
|
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>
|
</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: 'original', label: '原始序列', sub: 'ct_original' },
|
||||||
{ key: 'hard_boundary', label: '硬边界', sub: 'ct_hard_boundary' },
|
{ key: 'hard_boundary', label: '硬边界', sub: 'ct_hard_boundary' },
|
||||||
@@ -1219,50 +1379,29 @@ export default function App() {
|
|||||||
].map(t => {
|
].map(t => {
|
||||||
const screenshotDir = deformationJob?.result?.previews?.screenshots;
|
const screenshotDir = deformationJob?.result?.previews?.screenshots;
|
||||||
const imagePath = screenshotDir ? `${screenshotDir}/${t.key}.png` : '';
|
const imagePath = screenshotDir ? `${screenshotDir}/${t.key}.png` : '';
|
||||||
const zipJob = zipJobs[t.key];
|
|
||||||
return (
|
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">
|
<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>
|
<span className="text-[8px] font-mono text-slate-300">{t.sub}</span>
|
||||||
</div>
|
</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 ? (
|
{imagePath ? (
|
||||||
<img src={fileUrl(imagePath)} className="w-full h-full object-contain" />
|
<img src={fileUrl(imagePath)} className="w-full h-full object-contain" />
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-white/25">
|
<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>
|
<p className="text-[10px] font-mono uppercase">等待 Python 输出</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1293,10 +1432,7 @@ export default function App() {
|
|||||||
{libraryData.slice(0, 4).map(item => (
|
{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 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="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`}>
|
<OverviewDicomThumbnail item={item} />
|
||||||
<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>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-bold text-slate-700">{item.patientId}</p>
|
<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>
|
<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') {
|
if (item.status === 'processed') {
|
||||||
setSelectedLibraryId(item.id);
|
setSelectedLibraryId(item.id);
|
||||||
setPreviewImage('');
|
setPreviewImage('');
|
||||||
|
setProcessPreviewImage('');
|
||||||
clearDeformationTask();
|
clearDeformationTask();
|
||||||
setCurrentPage('workspace');
|
setCurrentPage('workspace');
|
||||||
showToast(`已选择 ${item.patientId} 作为工作站数据源`);
|
showToast(`已选择 ${item.patientId} 作为工作站数据源`);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ os.environ.setdefault("MPLCONFIGDIR", "/tmp/head_ct_morph_matplotlib")
|
|||||||
|
|
||||||
import pydicom
|
import pydicom
|
||||||
from pydicom.multival import MultiValue
|
from pydicom.multival import MultiValue
|
||||||
from PIL import Image
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
from generate_head_extension_video import generate_video
|
from generate_head_extension_video import generate_video
|
||||||
from head_extension_app import (
|
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(before_display, 700, 520), (0, 0))
|
||||||
canvas_image.paste(fit_image(after, 700, 520), (740, 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 = BytesIO()
|
||||||
canvas_image.save(canvas, format="PNG")
|
canvas_image.save(canvas, format="PNG")
|
||||||
encoded = base64.b64encode(canvas.getvalue()).decode("ascii")
|
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 {
|
return {
|
||||||
"image": f"data:image/png;base64,{encoded}",
|
"image": f"data:image/png;base64,{encoded}",
|
||||||
|
"processImage": f"data:image/png;base64,{process_encoded}",
|
||||||
"source": str(Path(input_dir).resolve()),
|
"source": str(Path(input_dir).resolve()),
|
||||||
"angleDegrees": float(angle_degrees),
|
"angleDegrees": float(angle_degrees),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user