speed up DICOM slice previews

This commit is contained in:
2026-05-02 23:49:43 +08:00
parent 676ef25106
commit bef76448f7
2 changed files with 59 additions and 16 deletions

View File

@@ -72,26 +72,39 @@ const API_BASE = typeof window === 'undefined'
function LibraryDicomPreview({ item }: { item: LibraryItem }) { function LibraryDicomPreview({ item }: { item: LibraryItem }) {
const [sliceIndex, setSliceIndex] = useState(Math.max(0, Math.floor((item.fileCount || 1) / 2))); const [sliceIndex, setSliceIndex] = useState(Math.max(0, Math.floor((item.fileCount || 1) / 2)));
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); const count = Math.max(1, item.fileCount || 1);
useEffect(() => { useEffect(() => {
setSliceIndex(Math.max(0, Math.floor((item.fileCount || 1) / 2))); const middleIndex = Math.max(0, Math.floor((item.fileCount || 1) / 2));
setSliceIndex(middleIndex);
setRequestedSliceIndex(middleIndex);
}, [item.id, item.fileCount]); }, [item.id, item.fileCount]);
useEffect(() => {
const timer = window.setTimeout(() => {
setRequestedSliceIndex(sliceIndex);
}, 120);
return () => window.clearTimeout(timer);
}, [sliceIndex]);
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
setIsLoading(true); setIsLoading(true);
setError(''); setError('');
fetch(`${API_BASE}/api/library/preview?id=${encodeURIComponent(item.id)}&index=${sliceIndex}`, { fetch(`${API_BASE}/api/library/preview?id=${encodeURIComponent(item.id)}&index=${requestedSliceIndex}`, {
signal: controller.signal signal: controller.signal
}) })
.then(async response => { .then(async response => {
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(`${API_BASE}${data.imageUrl}`);
(data.neighbors || []).forEach((url: string) => {
fetch(`${API_BASE}${url}`, { signal: controller.signal }).catch(() => {});
});
}) })
.catch(error => { .catch(error => {
if ((error as Error).name !== 'AbortError') setError((error as Error).message); if ((error as Error).name !== 'AbortError') setError((error as Error).message);
@@ -100,7 +113,7 @@ function LibraryDicomPreview({ item }: { item: LibraryItem }) {
if (!controller.signal.aborted) setIsLoading(false); if (!controller.signal.aborted) setIsLoading(false);
}); });
return () => controller.abort(); return () => controller.abort();
}, [item.id, sliceIndex]); }, [item.id, requestedSliceIndex]);
return ( 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="h-52 bg-slate-950 relative flex items-center justify-center border-b border-slate-100 shadow-inner overflow-hidden">

View File

@@ -12,7 +12,7 @@ from email.policy import default
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse from urllib.parse import parse_qs, quote, unquote, urlparse
os.environ.setdefault("MPLCONFIGDIR", "/tmp/head_ct_morph_matplotlib") os.environ.setdefault("MPLCONFIGDIR", "/tmp/head_ct_morph_matplotlib")
@@ -37,9 +37,11 @@ HOST = "0.0.0.0"
PORT = 8787 PORT = 8787
JOBS = {} JOBS = {}
JOBS_LOCK = threading.Lock() JOBS_LOCK = threading.Lock()
DICOM_FILE_CACHE = {}
LIBRARY_DIR = APP_DIR / "web_library" LIBRARY_DIR = APP_DIR / "web_library"
LIBRARY_META = LIBRARY_DIR / "library.json" LIBRARY_META = LIBRARY_DIR / "library.json"
RESULT_DIR = APP_DIR / "web_results" RESULT_DIR = APP_DIR / "web_results"
PREVIEW_CACHE_DIR = LIBRARY_DIR / "_preview_cache"
def json_default(value): def json_default(value):
@@ -124,7 +126,23 @@ def sort_key_for_dicom(path):
def sorted_dicom_files(dicom_dir): def sorted_dicom_files(dicom_dir):
return sorted(Path(dicom_dir).glob("*.dcm"), key=sort_key_for_dicom) dicom_dir = Path(dicom_dir).resolve()
files = list(dicom_dir.glob("*.dcm"))
signature = (
str(dicom_dir),
len(files),
max((file_path.stat().st_mtime for file_path in files), default=0),
)
cached = DICOM_FILE_CACHE.get(str(dicom_dir))
if cached and cached["signature"] == signature:
return cached["files"]
sorted_files = sorted(files, key=sort_key_for_dicom)
DICOM_FILE_CACHE[str(dicom_dir)] = {
"signature": signature,
"files": sorted_files,
}
return sorted_files
def find_library_item(item_id): def find_library_item(item_id):
@@ -142,6 +160,10 @@ def make_library_slice_preview(item_id, index):
count = len(dicom_files) count = len(dicom_files)
index = max(0, min(int(index), count - 1)) index = max(0, min(int(index), count - 1))
cache_dir = PREVIEW_CACHE_DIR / item_id
safe_mkdir(cache_dir)
preview_path = cache_dir / f"slice_{index:04d}.png"
if not preview_path.exists():
ds = pydicom.dcmread(str(dicom_files[index]), force=True) ds = pydicom.dcmread(str(dicom_files[index]), force=True)
image = ds.pixel_array.astype("float32") image = ds.pixel_array.astype("float32")
image = image * float(getattr(ds, "RescaleSlope", 1)) image = image * float(getattr(ds, "RescaleSlope", 1))
@@ -149,15 +171,19 @@ def make_library_slice_preview(item_id, index):
preview = Image.fromarray(ct_window(image)).convert("RGB") preview = Image.fromarray(ct_window(image)).convert("RGB")
preview = fit_image(preview, 720, 520) preview = fit_image(preview, 720, 520)
canvas = BytesIO() preview.save(preview_path, format="PNG")
preview.save(canvas, format="PNG")
encoded = base64.b64encode(canvas.getvalue()).decode("ascii") neighbors = [value for value in [index - 1, index + 1] if 0 <= value < count]
return { return {
"image": f"data:image/png;base64,{encoded}", "imageUrl": f"/api/file?path={quote(str(preview_path.resolve()), safe='')}",
"index": index, "index": index,
"count": count, "count": count,
"file": dicom_files[index].name, "file": dicom_files[index].name,
"patientId": item["patientId"], "patientId": item["patientId"],
"neighbors": [
f"/api/library/preview?id={item_id}&index={neighbor}"
for neighbor in neighbors
],
} }
@@ -522,7 +548,11 @@ class Handler(BaseHTTPRequestHandler):
write_library_meta(remaining) write_library_meta(remaining)
upload_root = Path(target["dicomPath"]).resolve().parent upload_root = Path(target["dicomPath"]).resolve().parent
if upload_root.exists() and upload_root.is_relative_to(LIBRARY_DIR.resolve()): if upload_root.exists() and upload_root.is_relative_to(LIBRARY_DIR.resolve()):
DICOM_FILE_CACHE.pop(str(Path(target["dicomPath"]).resolve()), None)
shutil.rmtree(upload_root) shutil.rmtree(upload_root)
preview_cache = PREVIEW_CACHE_DIR / item_id
if preview_cache.exists():
shutil.rmtree(preview_cache)
self.send_json({"ok": True, "items": remaining}) self.send_json({"ok": True, "items": remaining})
def read_bytes(self): def read_bytes(self):