add DICOM slice previews to image library
This commit is contained in:
@@ -70,6 +70,77 @@ const API_BASE = typeof window === 'undefined'
|
||||
? 'http://127.0.0.1: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() {
|
||||
// --- Authentication State ---
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
@@ -798,28 +869,7 @@ export default function App() {
|
||||
<div className="grid grid-cols-4 gap-8">
|
||||
{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">
|
||||
{/* Simulated Preview Box */}
|
||||
<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>
|
||||
<LibraryDicomPreview item={item} />
|
||||
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
<div className="flex justify-between items-start">
|
||||
|
||||
@@ -16,10 +16,14 @@ from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
os.environ.setdefault("MPLCONFIGDIR", "/tmp/head_ct_morph_matplotlib")
|
||||
|
||||
import pydicom
|
||||
from PIL import Image
|
||||
|
||||
from generate_head_extension_video import generate_video
|
||||
from head_extension_app import (
|
||||
APP_DIR,
|
||||
crop_head_neck,
|
||||
ct_window,
|
||||
fit_image,
|
||||
load_dicom_volume,
|
||||
preview_deform_2d,
|
||||
@@ -109,6 +113,54 @@ def list_library():
|
||||
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):
|
||||
content_type = headers.get("content-type", "")
|
||||
message = BytesParser(policy=default).parsebytes(
|
||||
@@ -333,6 +385,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({"items": list_library()})
|
||||
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":
|
||||
params = parse_qs(parsed.query)
|
||||
job_id = params.get("id", [""])[0]
|
||||
|
||||
Reference in New Issue
Block a user