2026-05-03-22-36-18 增加DICOM阅览和单项下载
This commit is contained in:
@@ -93,6 +93,19 @@ const VIDEO_SOURCE_OPTIONS = [
|
|||||||
|
|
||||||
const PREVIEW_ALGORITHM_OPTIONS = VIDEO_SOURCE_OPTIONS;
|
const PREVIEW_ALGORITHM_OPTIONS = VIDEO_SOURCE_OPTIONS;
|
||||||
|
|
||||||
|
const VIEWER_PLANE_OPTIONS = [
|
||||||
|
{ key: 'coronal', label: '冠状位' },
|
||||||
|
{ key: 'sagittal', label: '矢状位' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const VIEWER_WINDOW_OPTIONS = [
|
||||||
|
{ key: 'default', label: '默认' },
|
||||||
|
{ key: 'bone', label: '骨窗' },
|
||||||
|
{ key: 'soft_tissue', label: '软组织' },
|
||||||
|
{ key: 'brain', label: '脑窗' },
|
||||||
|
{ key: 'lung', label: '肺窗' },
|
||||||
|
];
|
||||||
|
|
||||||
type LibraryItem = {
|
type LibraryItem = {
|
||||||
id: string;
|
id: string;
|
||||||
patientId: string;
|
patientId: string;
|
||||||
@@ -116,6 +129,16 @@ type LibraryInfo = {
|
|||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LibraryViewerPreview = {
|
||||||
|
imageUrl: string;
|
||||||
|
index: number;
|
||||||
|
count: number;
|
||||||
|
plane: string;
|
||||||
|
window: string;
|
||||||
|
windowLabel: string;
|
||||||
|
patientId: string;
|
||||||
|
};
|
||||||
|
|
||||||
type StoredDeformationJob = {
|
type StoredDeformationJob = {
|
||||||
job: BackendJob;
|
job: BackendJob;
|
||||||
progress: number;
|
progress: number;
|
||||||
@@ -354,6 +377,13 @@ export default function App() {
|
|||||||
const [isUploadingDicom, setIsUploadingDicom] = useState(false);
|
const [isUploadingDicom, setIsUploadingDicom] = useState(false);
|
||||||
const [libraryInfo, setLibraryInfo] = useState<LibraryInfo | null>(null);
|
const [libraryInfo, setLibraryInfo] = useState<LibraryInfo | null>(null);
|
||||||
const [isLibraryInfoLoading, setIsLibraryInfoLoading] = useState(false);
|
const [isLibraryInfoLoading, setIsLibraryInfoLoading] = useState(false);
|
||||||
|
const [libraryViewerItem, setLibraryViewerItem] = useState<LibraryItem | null>(null);
|
||||||
|
const [viewerPlane, setViewerPlane] = useState('coronal');
|
||||||
|
const [viewerWindow, setViewerWindow] = useState('default');
|
||||||
|
const [viewerSliceIndex, setViewerSliceIndex] = useState<number | 'middle'>('middle');
|
||||||
|
const [viewerPreview, setViewerPreview] = useState<LibraryViewerPreview | null>(null);
|
||||||
|
const [isViewerLoading, setIsViewerLoading] = useState(false);
|
||||||
|
const [viewerError, setViewerError] = useState('');
|
||||||
const folderUploadInputRef = useRef<HTMLInputElement | null>(null);
|
const folderUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const zipUploadInputRef = useRef<HTMLInputElement | null>(null);
|
const zipUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
@@ -868,6 +898,47 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openLibraryViewer = (item: LibraryItem) => {
|
||||||
|
setLibraryViewerItem(item);
|
||||||
|
setViewerPlane('coronal');
|
||||||
|
setViewerWindow('default');
|
||||||
|
setViewerSliceIndex('middle');
|
||||||
|
setViewerPreview(null);
|
||||||
|
setViewerError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeLibraryViewer = () => {
|
||||||
|
setLibraryViewerItem(null);
|
||||||
|
setViewerPreview(null);
|
||||||
|
setViewerError('');
|
||||||
|
setIsViewerLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!libraryViewerItem) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
setIsViewerLoading(true);
|
||||||
|
setViewerError('');
|
||||||
|
fetch(
|
||||||
|
`${API_BASE}/api/library/reformat-preview?id=${encodeURIComponent(libraryViewerItem.id)}&plane=${encodeURIComponent(viewerPlane)}&index=${encodeURIComponent(String(viewerSliceIndex))}&window=${encodeURIComponent(viewerWindow)}`,
|
||||||
|
{ signal: controller.signal }
|
||||||
|
)
|
||||||
|
.then(async response => {
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.error || '阅览图像生成失败');
|
||||||
|
setViewerPreview(data);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
if ((error as Error).name !== 'AbortError') setViewerError((error as Error).message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) setIsViewerLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [libraryViewerItem?.id, viewerPlane, viewerSliceIndex, viewerWindow]);
|
||||||
|
|
||||||
const changePassword = (userId: string, newPass: string) => {
|
const changePassword = (userId: string, newPass: string) => {
|
||||||
setUsers(users.map(u => u.id === userId ? { ...u, password: newPass } : u));
|
setUsers(users.map(u => u.id === userId ? { ...u, password: newPass } : u));
|
||||||
setPwChangeInput('');
|
setPwChangeInput('');
|
||||||
@@ -1198,7 +1269,7 @@ export default function App() {
|
|||||||
<Download size={14} />
|
<Download size={14} />
|
||||||
{zipJobs.all?.status === 'running'
|
{zipJobs.all?.status === 'running'
|
||||||
? `四状态 ZIP 打包中 ${formatProgress(progressFromJob(zipJobs.all, 0))}%`
|
? `四状态 ZIP 打包中 ${formatProgress(progressFromJob(zipJobs.all, 0))}%`
|
||||||
: '下载四状态 ZIP'}
|
: '下载结果'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{zipJobs.all?.status === 'failed' && <p className="text-[10px] text-red-500 font-bold mt-2 break-all">{zipJobs.all.error || '四状态 ZIP 打包失败'}</p>}
|
{zipJobs.all?.status === 'failed' && <p className="text-[10px] text-red-500 font-bold mt-2 break-all">{zipJobs.all.error || '四状态 ZIP 打包失败'}</p>}
|
||||||
@@ -1369,7 +1440,7 @@ export default function App() {
|
|||||||
<Download size={14} />
|
<Download size={14} />
|
||||||
{zipJobs.all?.status === 'running'
|
{zipJobs.all?.status === 'running'
|
||||||
? `打包中 ${formatProgress(progressFromJob(zipJobs.all, 0))}%`
|
? `打包中 ${formatProgress(progressFromJob(zipJobs.all, 0))}%`
|
||||||
: '下载四状态 DICOM ZIP'}
|
: '下载结果'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 gap-4 p-4">
|
<div className="grid grid-cols-4 gap-4 p-4">
|
||||||
@@ -1384,9 +1455,27 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<div key={t.key} className="min-w-0">
|
<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 === 'original' ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-500'}`}>{t.label}</span>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePackageDownload(t.key)}
|
||||||
|
disabled={deformationJob?.status !== 'completed' || !deformationJob.result?.outputs || zipJobs[t.key]?.status === 'running'}
|
||||||
|
className="w-7 h-7 rounded-lg bg-slate-100 text-slate-500 hover:bg-green-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={`下载${t.label} DICOM`}
|
||||||
|
>
|
||||||
|
<Download size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<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>
|
||||||
|
{zipJobs[t.key]?.status === 'running' && (
|
||||||
|
<p className="mb-2 text-[9px] font-bold text-green-600">
|
||||||
|
打包中 {formatProgress(progressFromJob(zipJobs[t.key], 0))}%
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{zipJobs[t.key]?.status === 'failed' && (
|
||||||
|
<p className="mb-2 text-[9px] font-bold text-red-500 break-all">{zipJobs[t.key].error || '本状态 DICOM ZIP 打包失败'}</p>
|
||||||
|
)}
|
||||||
<div className="h-44 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" />
|
||||||
@@ -1519,7 +1608,7 @@ export default function App() {
|
|||||||
<span>{item.fileCount || 0} 张</span>
|
<span>{item.fileCount || 0} 张</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3 mt-2">
|
<div className="grid grid-cols-4 gap-2 mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (item.status === 'processed') {
|
if (item.status === 'processed') {
|
||||||
@@ -1535,11 +1624,17 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
className={`py-2.5 text-[11px] font-black rounded-xl transition-all ${item.status === 'processed' ? 'bg-blue-600 text-white hover:bg-black shadow-lg shadow-blue-500/20' : 'bg-slate-100 text-slate-300 cursor-not-allowed'}`}
|
className={`py-2.5 text-[11px] font-black rounded-xl transition-all ${item.status === 'processed' ? 'bg-blue-600 text-white hover:bg-black shadow-lg shadow-blue-500/20' : 'bg-slate-100 text-slate-300 cursor-not-allowed'}`}
|
||||||
>
|
>
|
||||||
调阅
|
变换
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => item.status === 'processed' ? openLibraryViewer(item) : showToast('该影像尚在处理队列中,无法阅览')}
|
||||||
|
className={`py-2.5 text-[11px] font-black rounded-xl transition-all flex items-center justify-center gap-1 ${item.status === 'processed' ? 'bg-slate-900 text-white hover:bg-blue-600' : 'bg-slate-100 text-slate-300 cursor-not-allowed'}`}
|
||||||
|
>
|
||||||
|
<Eye size={12} /> 阅览
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => showLibraryInfo(item)}
|
onClick={() => showLibraryInfo(item)}
|
||||||
className="py-2.5 bg-slate-900 text-white text-[11px] font-black rounded-xl hover:bg-blue-600 transition-all flex items-center justify-center gap-1"
|
className="py-2.5 bg-slate-100 text-slate-500 text-[11px] font-black rounded-xl hover:bg-blue-50 hover:text-blue-600 transition-all flex items-center justify-center gap-1"
|
||||||
>
|
>
|
||||||
<Info size={12} /> 信息
|
<Info size={12} /> 信息
|
||||||
</button>
|
</button>
|
||||||
@@ -1831,6 +1926,112 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{libraryViewerItem && (
|
||||||
|
<div className="fixed inset-0 bg-slate-950/55 backdrop-blur-sm z-40 flex items-center justify-center p-6">
|
||||||
|
<div className="w-full max-w-6xl max-h-[90vh] bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden flex flex-col">
|
||||||
|
<div className="px-7 py-5 border-b flex items-center justify-between gap-5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">DICOM 阅览</p>
|
||||||
|
<h3 className="text-xl font-black text-slate-800 mt-1 truncate">{libraryViewerItem.patientId}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={closeLibraryViewer}
|
||||||
|
className="w-10 h-10 rounded-xl bg-slate-50 text-slate-400 hover:bg-red-50 hover:text-red-500 flex items-center justify-center transition-all shrink-0"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-6 overflow-y-auto min-h-0">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3">平面</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{VIEWER_PLANE_OPTIONS.map(option => (
|
||||||
|
<button
|
||||||
|
key={option.key}
|
||||||
|
onClick={() => {
|
||||||
|
setViewerPlane(option.key);
|
||||||
|
setViewerSliceIndex('middle');
|
||||||
|
}}
|
||||||
|
className={`py-3 rounded-xl text-xs font-black transition-all ${
|
||||||
|
viewerPlane === option.key
|
||||||
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-500/20'
|
||||||
|
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3">显示模式</p>
|
||||||
|
<select
|
||||||
|
value={viewerWindow}
|
||||||
|
onChange={event => setViewerWindow(event.target.value)}
|
||||||
|
className="w-full px-3 py-3 bg-slate-50 border border-slate-200 rounded-xl text-xs font-bold text-slate-700 outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{VIEWER_WINDOW_OPTIONS.map(option => (
|
||||||
|
<option key={option.key} value={option.key}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">切片</p>
|
||||||
|
<span className="text-[10px] font-mono font-black text-blue-600">
|
||||||
|
{(viewerPreview?.index ?? 0) + 1} / {viewerPreview?.count || 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={Math.max(0, (viewerPreview?.count || 1) - 1)}
|
||||||
|
value={viewerPreview?.index ?? 0}
|
||||||
|
onChange={event => setViewerSliceIndex(parseInt(event.target.value, 10))}
|
||||||
|
className="w-full h-1.5 accent-blue-600 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="font-bold text-slate-400">当前平面</span>
|
||||||
|
<span className="font-black text-slate-700">{VIEWER_PLANE_OPTIONS.find(option => option.key === viewerPlane)?.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="font-bold text-slate-400">窗宽窗位</span>
|
||||||
|
<span className="font-black text-slate-700">{viewerPreview?.windowLabel || VIEWER_WINDOW_OPTIONS.find(option => option.key === viewerWindow)?.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="font-bold text-slate-400">文件数</span>
|
||||||
|
<span className="font-black text-slate-700">{libraryViewerItem.fileCount || 0} 张</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-[360px] lg:min-h-[560px] bg-slate-950 rounded-2xl border border-slate-900 overflow-hidden flex items-center justify-center relative">
|
||||||
|
{viewerPreview?.imageUrl && !viewerError ? (
|
||||||
|
<img src={`${API_BASE}${viewerPreview.imageUrl}`} className="w-full h-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-white/35">
|
||||||
|
<ImageIcon size={46} className="mx-auto mb-3" />
|
||||||
|
<p className="text-xs font-bold">{viewerError || '等待影像载入'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isViewerLoading && (
|
||||||
|
<div className="absolute top-4 right-4 px-3 py-1.5 rounded-lg bg-black/60 text-white text-[10px] font-black">
|
||||||
|
载入中...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{(libraryInfo || isLibraryInfoLoading) && (
|
{(libraryInfo || isLibraryInfoLoading) && (
|
||||||
<div className="fixed inset-0 bg-slate-950/45 backdrop-blur-sm z-40 flex items-center justify-center p-6">
|
<div className="fixed inset-0 bg-slate-950/45 backdrop-blur-sm z-40 flex items-center justify-center p-6">
|
||||||
<div className="w-full max-w-4xl max-h-[85vh] bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden">
|
<div className="w-full max-w-4xl max-h-[85vh] bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden">
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ RESULT_DIR = APP_DIR / "web_results"
|
|||||||
JOBS_META = RESULT_DIR / "jobs.json"
|
JOBS_META = RESULT_DIR / "jobs.json"
|
||||||
USER_TASKS_META = RESULT_DIR / "user_tasks.json"
|
USER_TASKS_META = RESULT_DIR / "user_tasks.json"
|
||||||
PREVIEW_CACHE_DIR = LIBRARY_DIR / "_preview_cache"
|
PREVIEW_CACHE_DIR = LIBRARY_DIR / "_preview_cache"
|
||||||
|
VIEWER_WINDOWS = {
|
||||||
|
"default": {"label": "默认", "low": -500, "high": 1200},
|
||||||
|
"bone": {"label": "骨窗", "low": -500, "high": 1800},
|
||||||
|
"soft_tissue": {"label": "软组织", "low": -160, "high": 240},
|
||||||
|
"brain": {"label": "脑窗", "low": 0, "high": 80},
|
||||||
|
"lung": {"label": "肺窗", "low": -1000, "high": 400},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def json_default(value):
|
def json_default(value):
|
||||||
@@ -213,6 +220,54 @@ def make_library_slice_preview(item_id, index):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_library_reformat_preview(item_id, plane, index, window):
|
||||||
|
item = find_library_item(item_id)
|
||||||
|
if not item:
|
||||||
|
raise RuntimeError("影像库中没有找到该数据。")
|
||||||
|
|
||||||
|
plane = plane if plane in {"coronal", "sagittal"} else "coronal"
|
||||||
|
window = window if window in VIEWER_WINDOWS else "default"
|
||||||
|
volume = load_dicom_volume(item["dicomPath"])
|
||||||
|
|
||||||
|
def normalize_reformat_index(raw_index, count):
|
||||||
|
if str(raw_index) == "middle":
|
||||||
|
return count // 2
|
||||||
|
try:
|
||||||
|
return int(raw_index)
|
||||||
|
except Exception:
|
||||||
|
return count // 2
|
||||||
|
|
||||||
|
if plane == "coronal":
|
||||||
|
count = volume.shape[1]
|
||||||
|
index = normalize_reformat_index(index, count)
|
||||||
|
index = max(0, min(index, count - 1))
|
||||||
|
image = volume[:, index, :]
|
||||||
|
else:
|
||||||
|
count = volume.shape[2]
|
||||||
|
index = normalize_reformat_index(index, count)
|
||||||
|
index = max(0, min(index, count - 1))
|
||||||
|
image = volume[:, :, index]
|
||||||
|
|
||||||
|
cache_dir = PREVIEW_CACHE_DIR / item_id / "reformat"
|
||||||
|
safe_mkdir(cache_dir)
|
||||||
|
preview_path = cache_dir / f"{plane}_{window}_{index:04d}.png"
|
||||||
|
if not preview_path.exists():
|
||||||
|
preset = VIEWER_WINDOWS[window]
|
||||||
|
preview = Image.fromarray(ct_window(image, preset["low"], preset["high"])).convert("RGB")
|
||||||
|
preview = fit_image(preview, 960, 720)
|
||||||
|
preview.save(preview_path, format="PNG")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"imageUrl": f"/api/file?path={quote(str(preview_path.resolve()), safe='')}",
|
||||||
|
"index": index,
|
||||||
|
"count": count,
|
||||||
|
"plane": plane,
|
||||||
|
"window": window,
|
||||||
|
"windowLabel": VIEWER_WINDOWS[window]["label"],
|
||||||
|
"patientId": item["patientId"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def dicom_value(ds, name, fallback="-"):
|
def dicom_value(ds, name, fallback="-"):
|
||||||
value = getattr(ds, name, fallback)
|
value = getattr(ds, name, fallback)
|
||||||
if value in [None, ""]:
|
if value in [None, ""]:
|
||||||
@@ -881,6 +936,15 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json(make_library_slice_preview(item_id, index))
|
self.send_json(make_library_slice_preview(item_id, index))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/library/reformat-preview":
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
item_id = params.get("id", [""])[0]
|
||||||
|
plane = params.get("plane", ["coronal"])[0]
|
||||||
|
index = params.get("index", ["0"])[0]
|
||||||
|
window = params.get("window", ["default"])[0]
|
||||||
|
self.send_json(make_library_reformat_preview(item_id, plane, index, window))
|
||||||
|
return
|
||||||
|
|
||||||
if parsed.path == "/api/library/info":
|
if parsed.path == "/api/library/info":
|
||||||
params = parse_qs(parsed.query)
|
params = parse_qs(parsed.query)
|
||||||
item_id = params.get("id", [""])[0]
|
item_id = params.get("id", [""])[0]
|
||||||
|
|||||||
78
工程分析/实现方案-2026-05-03-22-36-18.md
Normal file
78
工程分析/实现方案-2026-05-03-22-36-18.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# 实现方案
|
||||||
|
|
||||||
|
开始时间:2026-05-03-22-36-18
|
||||||
|
|
||||||
|
## 本次方案路径
|
||||||
|
|
||||||
|
`工程分析/实现方案-2026-05-03-22-36-18.md`
|
||||||
|
|
||||||
|
## 实现目标
|
||||||
|
|
||||||
|
完成下载文案、四状态单项 DICOM 下载、影像库阅览入口和冠状/矢状 DICOM 显示模式切换。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `WebSite/src/App.tsx`
|
||||||
|
- `web_backend.py`
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
|
||||||
|
## 执行步骤
|
||||||
|
|
||||||
|
1. 前端文案调整:
|
||||||
|
- 将左侧工作站区域的“下载四状态ZIP”改为“下载结果”。
|
||||||
|
- 将影像库卡片中的“调阅”改为“变换”。
|
||||||
|
2. 四状态单项下载:
|
||||||
|
- 在四状态结果卡片中,为原始序列、硬边界、高斯平滑、软过渡重建标题旁增加下载图标按钮。
|
||||||
|
- 点击按钮调用已有 `handlePackageDownload(t.key)`,复用后端现有 `prepare_deformation_zip(job_id, target)`。
|
||||||
|
- 按钮在形变任务未完成、对应状态 ZIP 正在打包时禁用,并显示简短状态。
|
||||||
|
3. 后端新增影像库重建阅览接口:
|
||||||
|
- 新增 `/api/library/reformat-preview`,参数包含 `id`、`plane`、`index`、`window`。
|
||||||
|
- `plane` 支持 `coronal`、`sagittal`。
|
||||||
|
- `window` 支持 `default`、`bone`、`soft_tissue`、`brain`、`lung` 等固定窗宽窗位模式。
|
||||||
|
- 读取影像库 DICOM 序列为体数据,根据平面切片后用窗宽窗位转 PNG,缓存到 `web_library/_preview_cache/<item_id>/reformat/`。
|
||||||
|
4. 前端新增影像库阅览弹层:
|
||||||
|
- 增加 `libraryViewerItem`、`viewerPlane`、`viewerWindow`、`viewerSliceIndex`、`viewerPreview` 等状态。
|
||||||
|
- 新增“阅览”按钮,打开弹层。
|
||||||
|
- 弹层提供冠状位/矢状位分段按钮、显示模式选择、切片滑杆、当前切片计数和预览图。
|
||||||
|
- 按切片、平面、显示模式请求 `/api/library/reformat-preview`。
|
||||||
|
5. 保持现有“信息”“删除”“变换”行为不变,避免影响数据选择和工作站流程。
|
||||||
|
6. 执行测试方案。
|
||||||
|
7. 将关键问题和解决方案追加到 `工程分析/经验记录.md`。
|
||||||
|
8. 提交并推送 Gitea,commit 信息使用 `2026-05-03-22-36-18 增加DICOM阅览和单项下载`。
|
||||||
|
9. 重新部署到 `http://192.168.3.11:3005/`。
|
||||||
|
|
||||||
|
## 预期实现细节
|
||||||
|
|
||||||
|
- 单项下载 target 使用现有键:
|
||||||
|
- `original`
|
||||||
|
- `hard_boundary`
|
||||||
|
- `gaussian_smooth`
|
||||||
|
- `soft_transition`
|
||||||
|
- 阅览接口返回:
|
||||||
|
- `imageUrl`
|
||||||
|
- `index`
|
||||||
|
- `count`
|
||||||
|
- `plane`
|
||||||
|
- `window`
|
||||||
|
- `patientId`
|
||||||
|
- 窗宽窗位建议:
|
||||||
|
- default:沿用现有 `ct_window` 默认范围。
|
||||||
|
- bone:骨窗。
|
||||||
|
- soft_tissue:软组织窗。
|
||||||
|
- brain:脑窗。
|
||||||
|
- lung:肺窗。
|
||||||
|
|
||||||
|
## 回滚思路
|
||||||
|
|
||||||
|
如阅览接口或 UI 不符合预期,可回滚 `web_backend.py` 的新接口和 `WebSite/src/App.tsx` 中的阅览弹层及按钮;四状态单项下载和文案调整可单独保留或回退。
|
||||||
|
|
||||||
|
## 风险控制
|
||||||
|
|
||||||
|
- 不引入新的前端依赖。
|
||||||
|
- 不修改真实 DICOM 形变与输出目录结构。
|
||||||
|
- 后端预览只输出 PNG,不暴露额外 DICOM 文件写操作。
|
||||||
|
- 缓存文件写入现有 `_preview_cache` 下,删除影像库条目时继续随缓存目录清理。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
待用户二次人工审核确认。未经确认不得修改业务代码。
|
||||||
87
工程分析/测试方案-2026-05-03-22-36-18.md
Normal file
87
工程分析/测试方案-2026-05-03-22-36-18.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# 测试方案
|
||||||
|
|
||||||
|
开始时间:2026-05-03-22-36-18
|
||||||
|
|
||||||
|
## 本次方案路径
|
||||||
|
|
||||||
|
`工程分析/测试方案-2026-05-03-22-36-18.md`
|
||||||
|
|
||||||
|
## 测试范围
|
||||||
|
|
||||||
|
- 前端文案是否正确调整。
|
||||||
|
- 四状态输出区每个状态是否都有独立下载按钮。
|
||||||
|
- 单状态 DICOM ZIP 下载是否能触发、轮询并下载。
|
||||||
|
- 数据影像库“变换”“阅览”“信息”“删除”按钮是否正常布局和工作。
|
||||||
|
- 阅览弹层是否能显示冠状位、矢状位。
|
||||||
|
- 阅览弹层是否能切换 DICOM/CT 显示模式和切片。
|
||||||
|
- 前端类型检查、构建和 Python 语法检查是否通过。
|
||||||
|
- 重新部署后目标地址是否可访问。
|
||||||
|
|
||||||
|
## 测试命令
|
||||||
|
|
||||||
|
Python 语法检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m py_compile web_backend.py head_extension_app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
前端类型检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd WebSite
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
前端构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd WebSite
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
后端阅览接口验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s 'http://127.0.0.1:8787/api/library/reformat-preview?id=<影像库ID>&plane=coronal&index=0&window=bone'
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s 'http://127.0.0.1:8787/api/library/reformat-preview?id=<影像库ID>&plane=sagittal&index=0&window=soft_tissue'
|
||||||
|
```
|
||||||
|
|
||||||
|
部署验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I --max-time 5 http://192.168.3.11:3005/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 手工验证点
|
||||||
|
|
||||||
|
- 左侧按钮显示“下载结果”。
|
||||||
|
- 四状态卡片中原始序列、硬边界、高斯平滑、软过渡重建旁边均有下载按钮。
|
||||||
|
- 点击单状态下载按钮后,能下载对应 DICOM 序列 ZIP。
|
||||||
|
- 数据影像库卡片中“调阅”改为“变换”。
|
||||||
|
- 点击“变换”仍能进入影像变换工作站并选择该数据。
|
||||||
|
- 点击“阅览”打开弹层,能查看冠状位和矢状位。
|
||||||
|
- 切换显示模式后图像灰度/对比度发生变化。
|
||||||
|
- 调节切片滑杆后图像更新。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 所有用户提出的文字和功能入口均出现。
|
||||||
|
- 单状态 DICOM ZIP 下载可用。
|
||||||
|
- 阅览弹层可用,冠状位/矢状位和显示模式切换可用。
|
||||||
|
- `python -m py_compile web_backend.py head_extension_app.py` 通过。
|
||||||
|
- `npm run lint` 通过。
|
||||||
|
- `npm run build` 通过。
|
||||||
|
- Gitea commit 和 push 完成。
|
||||||
|
- 项目重新部署后 `http://192.168.3.11:3005/` 返回 `200 OK`。
|
||||||
|
|
||||||
|
## 无法测试或残余风险
|
||||||
|
|
||||||
|
- 若没有真实业务 DICOM 数据,使用仓库样例或当前影像库数据验证;不同扫描协议下的窗宽窗位显示效果可能需用户进一步微调。
|
||||||
|
- 冠状/矢状重建为轻量预览,不替代专业 DICOM 阅片器的诊断级 MPR 功能。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
待用户二次人工审核确认。未经确认不得修改业务代码。
|
||||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -37,3 +37,21 @@ C. 解决问题方案
|
|||||||
D. 后续如何避免问题
|
D. 后续如何避免问题
|
||||||
|
|
||||||
预览标注层、参考线、箭头等非 CT 内容应优先在形变结果上单独叠加,不要和医学影像像素一起参与形变采样。若必须参与形变,应先确认该标注能承受硬边界或快速变化权重造成的撕裂效果。
|
预览标注层、参考线、箭头等非 CT 内容应优先在形变结果上单独叠加,不要和医学影像像素一起参与形变采样。若必须参与形变,应先确认该标注能承受硬边界或快速变化权重造成的撕裂效果。
|
||||||
|
|
||||||
|
## 2026-05-03-22-36-18 增加 DICOM 阅览和单项下载
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
四状态结果区只有总 ZIP 下载入口,用户无法直接下载单个状态的 DICOM 序列;影像库卡片只能进入变换工作站或查看信息,缺少冠状位、矢状位阅览能力和 CT 显示模式切换。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
后端已经具备单状态 DICOM ZIP 打包能力,但前端未暴露单状态按钮。影像库已有轴位缩略图接口,但该接口只读取单张 DICOM,未提供基于整套 DICOM 体数据的冠状/矢状重建预览。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
前端在四状态卡片标题旁增加单状态下载按钮,复用现有 ZIP job 轮询和下载逻辑。后端新增影像库冠状/矢状重建预览接口,并按平面、切片和窗宽窗位缓存 PNG。前端新增影像库阅览弹层,支持冠状位、矢状位、切片滑杆和显示模式切换。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
已有后端能力应先通过前端入口复用,不重复实现打包逻辑。DICOM 阅览类预览需要使用缓存,避免每次切换切片或窗宽窗位都重复生成相同图像;新增缓存应继续放在现有 `_preview_cache` 下,避免污染仓库。
|
||||||
|
|||||||
55
工程分析/需求分析-2026-05-03-22-36-18.md
Normal file
55
工程分析/需求分析-2026-05-03-22-36-18.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# 需求分析
|
||||||
|
|
||||||
|
开始时间:2026-05-03-22-36-18
|
||||||
|
|
||||||
|
## 原始需求
|
||||||
|
|
||||||
|
用户提出三项修改:
|
||||||
|
|
||||||
|
1. 左侧“下载四状态ZIP”描述变为“下载结果”。
|
||||||
|
2. “四状态 DICOM 输出结果”中,原始序列、硬边界、高斯平滑、软过渡重建旁边都增加下载按钮,可以下载对应的 DICOM 文件。
|
||||||
|
3. 在“数据影像库”中,“调阅”描述变为“变换”;右侧增加“阅览”按钮,可以看 CT 冠状位、矢状位,同时可以调节 DCM 影像显示模式。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 调整已有按钮文案,使下载入口和变换入口更符合用户表达。
|
||||||
|
- 在四状态结果区为每个状态提供独立 DICOM 下载入口。
|
||||||
|
- 在影像库卡片中新增阅览入口,打开 DICOM 阅览弹层。
|
||||||
|
- 阅览弹层支持冠状位、矢状位切换,并支持常见 DICOM/CT 显示窗宽窗位模式切换。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- `WebSite/src/App.tsx`
|
||||||
|
- 修改按钮文字。
|
||||||
|
- 增加四状态单项下载按钮。
|
||||||
|
- 增加影像库阅览弹层状态、UI 和请求逻辑。
|
||||||
|
- `web_backend.py`
|
||||||
|
- 复用或扩展现有 DICOM 读取、窗宽窗位、预览缓存能力。
|
||||||
|
- 增加影像库冠状位/矢状位阅览预览接口。
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
- 完成后追加关键问题和解决方案。
|
||||||
|
|
||||||
|
## 当前定位
|
||||||
|
|
||||||
|
- 四状态单状态 ZIP 下载能力后端已存在:`prepare_deformation_zip(job_id, target)` 支持 `original`、`hard_boundary`、`gaussian_smooth`、`soft_transition`。
|
||||||
|
- 前端现有 `handlePackageDownload(target)` 已支持传入 target,并会轮询 ZIP job 完成后下载文件;当前仅全量 DICOM ZIP 按钮显式暴露。
|
||||||
|
- 影像库目前只有轴位切片缩略预览接口 `/api/library/preview`,使用单张 DICOM 像素生成 PNG。
|
||||||
|
- 影像库信息弹层已存在,可在其旁边新增独立阅览弹层,不必复用信息弹层。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 修改业务代码前必须先等待用户确认实现方案和测试方案。
|
||||||
|
- 不改变 DICOM 形变算法。
|
||||||
|
- 单状态下载应下载对应状态完整 DICOM 序列的 ZIP,而不是仅下载截图。
|
||||||
|
- 阅览功能应尽量轻量,不引入大型医学影像前端库。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 冠状位/矢状位预览需要读取整套 DICOM 体数据,可能比单张轴位预览更耗时;需要做缓存。
|
||||||
|
- DICOM 切片排序、窗宽窗位和方向显示若处理粗糙,可能导致阅览体验不稳定。
|
||||||
|
- 四状态卡片空间有限,新增下载按钮需要避免文字拥挤或遮挡。
|
||||||
|
- 单状态 ZIP job 与全量 ZIP job 共用 `zipJobs` 状态,需避免 target key 冲突。
|
||||||
|
|
||||||
|
## 待确认事项
|
||||||
|
|
||||||
|
建议实现为:四状态卡片标题旁增加图标下载按钮;影像库卡片按钮区从“调阅/信息/删除”调整为“变换/阅览/信息/删除”,阅览弹层支持冠状位、矢状位,以及默认/骨窗/软组织/脑窗/肺窗等显示模式。待用户确认后执行。
|
||||||
Reference in New Issue
Block a user