add DICOM slice previews to image library

This commit is contained in:
2026-05-02 23:34:33 +08:00
parent 664fec7485
commit 676ef25106
2 changed files with 131 additions and 22 deletions

View File

@@ -70,6 +70,77 @@ const API_BASE = typeof window === 'undefined'
? 'http://127.0.0.1:8787' ? 'http://127.0.0.1:8787'
: `${window.location.protocol}//${window.location.hostname}:8787`; : `${window.location.protocol}//${window.location.hostname}:8787`;
function LibraryDicomPreview({ item }: { item: LibraryItem }) {
const [sliceIndex, setSliceIndex] = useState(Math.max(0, Math.floor((item.fileCount || 1) / 2)));
const [previewImage, setPreviewImage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const count = Math.max(1, item.fileCount || 1);
useEffect(() => {
setSliceIndex(Math.max(0, Math.floor((item.fileCount || 1) / 2)));
}, [item.id, item.fileCount]);
useEffect(() => {
const controller = new AbortController();
setIsLoading(true);
setError('');
fetch(`${API_BASE}/api/library/preview?id=${encodeURIComponent(item.id)}&index=${sliceIndex}`, {
signal: controller.signal
})
.then(async response => {
const data = await response.json();
if (!response.ok) throw new Error(data.error || '预览生成失败');
setPreviewImage(data.image);
})
.catch(error => {
if ((error as Error).name !== 'AbortError') setError((error as Error).message);
})
.finally(() => {
if (!controller.signal.aborted) setIsLoading(false);
});
return () => controller.abort();
}, [item.id, sliceIndex]);
return (
<div className="h-52 bg-slate-950 relative flex items-center justify-center border-b border-slate-100 shadow-inner overflow-hidden">
<div className="absolute top-3 left-4 flex gap-1.5 z-10">
<div className="w-1.5 h-1.5 rounded-full bg-white/25"></div>
<div className="w-1.5 h-1.5 rounded-full bg-white/25"></div>
</div>
<div className="absolute top-3 right-4 z-10">
<span className={`text-[8px] font-black px-2 py-0.5 rounded border ${item.status === 'processed' ? 'bg-green-500/20 text-green-400 border-green-500/30' : 'bg-amber-500/20 text-amber-400 border-amber-500/30'}`}>
{item.status.toUpperCase()}
</span>
</div>
{previewImage && !error ? (
<img src={previewImage} className="w-full h-full object-contain" />
) : (
<div className="text-center text-white/35">
<ImageIcon size={34} className="mx-auto mb-2" />
<p className="text-[10px] font-bold">{error || (isLoading ? '正在生成预览...' : '等待预览')}</p>
</div>
)}
<div className="absolute inset-x-0 bottom-0 p-3 bg-gradient-to-t from-black/85 via-black/45 to-transparent">
<div className="flex items-center justify-between text-[8px] font-mono text-white/65 mb-2 uppercase tracking-[0.18em]">
<span>Axial DICOM</span>
<span>{sliceIndex + 1} / {count}</span>
</div>
<input
type="range"
min="0"
max={count - 1}
value={Math.min(sliceIndex, count - 1)}
onChange={event => setSliceIndex(parseInt(event.target.value, 10))}
className="w-full h-1 accent-blue-500 cursor-pointer"
/>
</div>
</div>
);
}
export default function App() { export default function App() {
// --- Authentication State --- // --- Authentication State ---
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
@@ -798,28 +869,7 @@ export default function App() {
<div className="grid grid-cols-4 gap-8"> <div className="grid grid-cols-4 gap-8">
{libraryData.map(item => ( {libraryData.map(item => (
<div key={item.id} className="bg-white rounded-[2.5rem] border border-slate-200 overflow-hidden flex flex-col group hover:border-blue-400 hover:shadow-2xl hover:shadow-blue-500/10 transition-all duration-300"> <div key={item.id} className="bg-white rounded-[2.5rem] border border-slate-200 overflow-hidden flex flex-col group hover:border-blue-400 hover:shadow-2xl hover:shadow-blue-500/10 transition-all duration-300">
{/* Simulated Preview Box */} <LibraryDicomPreview item={item} />
<div className={`h-44 ${item.previewColor} relative flex items-center justify-center p-6 border-b border-slate-100 shadow-inner group-hover:scale-[1.02] transition-transform`}>
<div className="absolute top-3 left-4 flex gap-1.5">
<div className="w-1.5 h-1.5 rounded-full bg-white/20"></div>
<div className="w-1.5 h-1.5 rounded-full bg-white/20"></div>
</div>
{/* Abstract DICOM Scan Visualization */}
<div className="w-full h-full border border-white/5 rounded-xl flex items-center justify-center overflow-hidden">
<div className="w-24 h-24 border-2 border-dashed border-white/10 rounded-full animate-spin-slow opacity-30"></div>
<div className="absolute w-20 h-28 border-2 border-white/20 rounded-[40%] rotate-45 blur-[1px]"></div>
<div className="absolute w-28 h-20 border-2 border-white/20 rounded-[40%] -rotate-45 blur-[1px]"></div>
</div>
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent"></div>
<div className="absolute bottom-3 left-4 text-[7px] font-mono text-white/40 uppercase tracking-[0.2em] leading-none">
Axial View :: ID_{item.id.toUpperCase()}
</div>
<div className="absolute top-3 right-4">
<span className={`text-[8px] font-black px-2 py-0.5 rounded border ${item.status === 'processed' ? 'bg-green-500/20 text-green-400 border-green-500/30' : 'bg-amber-500/20 text-amber-400 border-amber-500/30'}`}>
{item.status.toUpperCase()}
</span>
</div>
</div>
<div className="p-6 flex flex-col gap-4"> <div className="p-6 flex flex-col gap-4">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">

View File

@@ -16,10 +16,14 @@ from urllib.parse import parse_qs, unquote, urlparse
os.environ.setdefault("MPLCONFIGDIR", "/tmp/head_ct_morph_matplotlib") os.environ.setdefault("MPLCONFIGDIR", "/tmp/head_ct_morph_matplotlib")
import pydicom
from PIL import Image
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 (
APP_DIR, APP_DIR,
crop_head_neck, crop_head_neck,
ct_window,
fit_image, fit_image,
load_dicom_volume, load_dicom_volume,
preview_deform_2d, preview_deform_2d,
@@ -109,6 +113,54 @@ def list_library():
return live_items return live_items
def sort_key_for_dicom(path):
path = Path(path)
try:
ds = pydicom.dcmread(str(path), stop_before_pixels=True, force=True)
return (0, int(getattr(ds, "InstanceNumber", 0)), path.name)
except Exception:
stem = path.stem
return (1, int(stem) if stem.isdigit() else 0, path.name)
def sorted_dicom_files(dicom_dir):
return sorted(Path(dicom_dir).glob("*.dcm"), key=sort_key_for_dicom)
def find_library_item(item_id):
return next((item for item in list_library() if item["id"] == item_id), None)
def make_library_slice_preview(item_id, index):
item = find_library_item(item_id)
if not item:
raise RuntimeError("影像库中没有找到该数据。")
dicom_files = sorted_dicom_files(item["dicomPath"])
if not dicom_files:
raise RuntimeError("该影像数据没有可预览的 .dcm 文件。")
count = len(dicom_files)
index = max(0, min(int(index), count - 1))
ds = pydicom.dcmread(str(dicom_files[index]), force=True)
image = ds.pixel_array.astype("float32")
image = image * float(getattr(ds, "RescaleSlope", 1))
image = image + float(getattr(ds, "RescaleIntercept", 0))
preview = Image.fromarray(ct_window(image)).convert("RGB")
preview = fit_image(preview, 720, 520)
canvas = BytesIO()
preview.save(canvas, format="PNG")
encoded = base64.b64encode(canvas.getvalue()).decode("ascii")
return {
"image": f"data:image/png;base64,{encoded}",
"index": index,
"count": count,
"file": dicom_files[index].name,
"patientId": item["patientId"],
}
def parse_multipart(headers, body): def parse_multipart(headers, body):
content_type = headers.get("content-type", "") content_type = headers.get("content-type", "")
message = BytesParser(policy=default).parsebytes( message = BytesParser(policy=default).parsebytes(
@@ -333,6 +385,13 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"items": list_library()}) self.send_json({"items": list_library()})
return return
if parsed.path == "/api/library/preview":
params = parse_qs(parsed.query)
item_id = params.get("id", [""])[0]
index = params.get("index", ["0"])[0]
self.send_json(make_library_slice_preview(item_id, index))
return
if parsed.path == "/api/job": if parsed.path == "/api/job":
params = parse_qs(parsed.query) params = parse_qs(parsed.query)
job_id = params.get("id", [""])[0] job_id = params.get("id", [""])[0]