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'
|
? '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">
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user