speed up DICOM slice previews
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user