Optimize DICOM UPP registration lazy loading and fusion views
This commit is contained in:
@@ -12,6 +12,7 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -123,6 +124,7 @@ app = FastAPI(title="DICOM UPP Registration Workspace")
|
|||||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
TOKENS: dict[str, str] = {}
|
TOKENS: dict[str, str] = {}
|
||||||
STUDY_CACHE: dict[str, dict[str, Any]] = {}
|
STUDY_CACHE: dict[str, dict[str, Any]] = {}
|
||||||
|
SERIES_SORT_CACHE: dict[str, tuple[float, list[Path]]] = {}
|
||||||
STACK_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
|
STACK_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||||
CACHE_REFRESH_LOCK = threading.Lock()
|
CACHE_REFRESH_LOCK = threading.Lock()
|
||||||
CACHE_REFRESH_THREAD: threading.Thread | None = None
|
CACHE_REFRESH_THREAD: threading.Thread | None = None
|
||||||
@@ -663,9 +665,10 @@ def cases(
|
|||||||
status_filter: str = Query(default="", alias="status"),
|
status_filter: str = Query(default="", alias="status"),
|
||||||
body_part: str = "",
|
body_part: str = "",
|
||||||
algorithm_model: str = "",
|
algorithm_model: str = "",
|
||||||
limit: int = Query(default=120, ge=1, le=400),
|
limit: int = Query(default=20, ge=1, le=80),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
_: str = Depends(require_auth),
|
_: str = Depends(require_auth),
|
||||||
) -> list[dict[str, Any]]:
|
) -> dict[str, Any]:
|
||||||
ensure_registration_table()
|
ensure_registration_table()
|
||||||
clauses = []
|
clauses = []
|
||||||
if q.strip():
|
if q.strip():
|
||||||
@@ -690,7 +693,15 @@ def cases(
|
|||||||
if algorithm_model.strip():
|
if algorithm_model.strip():
|
||||||
clauses.append(f"algorithm_model ILIKE {sql_literal('%' + algorithm_model.strip().replace('%', '').replace('_', '') + '%')}")
|
clauses.append(f"algorithm_model ILIKE {sql_literal('%' + algorithm_model.strip().replace('%', '').replace('_', '') + '%')}")
|
||||||
where_sql = "WHERE " + " AND ".join(clauses) if clauses else ""
|
where_sql = "WHERE " + " AND ".join(clauses) if clauses else ""
|
||||||
return pg_json_rows(
|
total = int(pg_scalar(
|
||||||
|
f"""
|
||||||
|
SELECT count(*)::int
|
||||||
|
FROM ({relation_select()}) relation
|
||||||
|
{where_sql}
|
||||||
|
""",
|
||||||
|
timeout=20,
|
||||||
|
) or 0)
|
||||||
|
rows = pg_json_rows(
|
||||||
f"""
|
f"""
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM ({relation_select()}) relation
|
FROM ({relation_select()}) relation
|
||||||
@@ -701,9 +712,17 @@ def cases(
|
|||||||
COALESCE(study_time, '') DESC,
|
COALESCE(study_time, '') DESC,
|
||||||
ct_key
|
ct_key
|
||||||
LIMIT {int(limit)}
|
LIMIT {int(limit)}
|
||||||
|
OFFSET {int(offset)}
|
||||||
""",
|
""",
|
||||||
timeout=20,
|
timeout=20,
|
||||||
)
|
)
|
||||||
|
return {
|
||||||
|
"rows": rows,
|
||||||
|
"total": total,
|
||||||
|
"limit": int(limit),
|
||||||
|
"offset": int(offset),
|
||||||
|
"has_more": int(offset) + len(rows) < total,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/cases/{ct_number}")
|
@app.get("/api/cases/{ct_number}")
|
||||||
@@ -739,6 +758,23 @@ def read_header(path: Path) -> dict[str, str]:
|
|||||||
return {tag: text(getattr(ds, tag, "")) for tag in DICOM_TAGS}
|
return {tag: text(getattr(ds, tag, "")) for tag in DICOM_TAGS}
|
||||||
|
|
||||||
|
|
||||||
|
def read_headers_bulk(paths: list[Path], max_workers: int = 10) -> dict[Path, dict[str, str]]:
|
||||||
|
unique_paths = list(dict.fromkeys(paths))
|
||||||
|
if not unique_paths:
|
||||||
|
return {}
|
||||||
|
results: dict[Path, dict[str, str]] = {}
|
||||||
|
workers = max(1, min(max_workers, len(unique_paths)))
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||||
|
future_map = {executor.submit(read_header, path): path for path in unique_paths}
|
||||||
|
for future in as_completed(future_map):
|
||||||
|
path = future_map[future]
|
||||||
|
try:
|
||||||
|
results[path] = future.result()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def sort_key(item: tuple[Path, dict[str, str]]) -> tuple[float, float, str]:
|
def sort_key(item: tuple[Path, dict[str, str]]) -> tuple[float, float, str]:
|
||||||
path, meta = item
|
path, meta = item
|
||||||
z = numeric(meta.get("SliceLocation"), 0.0)
|
z = numeric(meta.get("SliceLocation"), 0.0)
|
||||||
@@ -823,29 +859,36 @@ def scan_study(ct_number: str) -> dict[str, Any]:
|
|||||||
root = resolve_study_root(study)
|
root = resolve_study_root(study)
|
||||||
if not root.exists():
|
if not root.exists():
|
||||||
raise HTTPException(status_code=404, detail=f"DICOM 目录不存在:{root}")
|
raise HTTPException(status_code=404, detail=f"DICOM 目录不存在:{root}")
|
||||||
grouped: dict[str, list[tuple[Path, dict[str, str]]]] = defaultdict(list)
|
grouped: dict[str, list[Path]] = defaultdict(list)
|
||||||
for path in root.rglob("*.dcm"):
|
for path in root.rglob("*.dcm"):
|
||||||
try:
|
grouped[path.parent.name].append(path)
|
||||||
meta = read_header(path)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
uid = meta.get("SeriesInstanceUID") or path.parent.name
|
|
||||||
grouped[uid].append((path, meta))
|
|
||||||
annotations = get_annotations(ct_number)
|
annotations = get_annotations(ct_number)
|
||||||
series = []
|
series = []
|
||||||
file_map = {}
|
file_map = {}
|
||||||
for uid, items in grouped.items():
|
summary_paths: list[Path] = []
|
||||||
items.sort(key=sort_key)
|
for paths in grouped.values():
|
||||||
first = items[0][1]
|
paths = sorted(paths)
|
||||||
last = items[-1][1]
|
if not paths:
|
||||||
file_map[uid] = [path for path, _ in items]
|
continue
|
||||||
annotation = annotations.get(uid) or {}
|
summary_paths.append(paths[0])
|
||||||
|
header_map = read_headers_bulk(summary_paths)
|
||||||
|
for uid, paths in grouped.items():
|
||||||
|
paths = sorted(paths)
|
||||||
|
if not paths:
|
||||||
|
continue
|
||||||
|
first = header_map.get(paths[0])
|
||||||
|
if not first:
|
||||||
|
continue
|
||||||
|
last = first
|
||||||
|
actual_uid = first.get("SeriesInstanceUID") or uid
|
||||||
|
file_map[actual_uid] = paths
|
||||||
|
annotation = annotations.get(actual_uid) or annotations.get(uid) or {}
|
||||||
series.append(
|
series.append(
|
||||||
{
|
{
|
||||||
"series_uid": uid,
|
"series_uid": actual_uid,
|
||||||
"description": first.get("SeriesDescription") or "未命名序列",
|
"description": first.get("SeriesDescription") or "未命名序列",
|
||||||
"series_number": first.get("SeriesNumber") or "",
|
"series_number": first.get("SeriesNumber") or "",
|
||||||
"count": len(items),
|
"count": len(paths),
|
||||||
"modality": first.get("Modality") or "",
|
"modality": first.get("Modality") or "",
|
||||||
"rows": first.get("Rows") or "",
|
"rows": first.get("Rows") or "",
|
||||||
"columns": first.get("Columns") or "",
|
"columns": first.get("Columns") or "",
|
||||||
@@ -876,7 +919,22 @@ def get_series_files(ct_number: str, series_uid: str) -> list[Path]:
|
|||||||
files = data["files"].get(series_uid)
|
files = data["files"].get(series_uid)
|
||||||
if not files:
|
if not files:
|
||||||
raise HTTPException(status_code=404, detail="DICOM 序列不存在")
|
raise HTTPException(status_code=404, detail="DICOM 序列不存在")
|
||||||
return files
|
cache_key = f"{normalize_ct(ct_number)}|{series_uid}"
|
||||||
|
cached = SERIES_SORT_CACHE.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
SERIES_SORT_CACHE[cache_key] = (time.time(), cached[1])
|
||||||
|
return cached[1]
|
||||||
|
header_map = read_headers_bulk(list(files), max_workers=12)
|
||||||
|
items: list[tuple[Path, dict[str, str]]] = [(path, meta) for path, meta in header_map.items()]
|
||||||
|
if not items:
|
||||||
|
raise HTTPException(status_code=404, detail="DICOM 序列文件不可读")
|
||||||
|
sorted_files = [path for path, _ in sorted(items, key=sort_key)]
|
||||||
|
data["files"][series_uid] = sorted_files
|
||||||
|
SERIES_SORT_CACHE[cache_key] = (time.time(), sorted_files)
|
||||||
|
if len(SERIES_SORT_CACHE) > 8:
|
||||||
|
oldest = sorted(SERIES_SORT_CACHE.items(), key=lambda item: item[1][0])[0][0]
|
||||||
|
SERIES_SORT_CACHE.pop(oldest, None)
|
||||||
|
return sorted_files
|
||||||
|
|
||||||
|
|
||||||
def window_values(ds: pydicom.Dataset, preset: str) -> tuple[float, float]:
|
def window_values(ds: pydicom.Dataset, preset: str) -> tuple[float, float]:
|
||||||
@@ -945,7 +1003,7 @@ def render_array(arr: np.ndarray, center: float, width: float, max_size: int = 9
|
|||||||
pil = Image.fromarray(img)
|
pil = Image.fromarray(img)
|
||||||
pil = resize_for_spacing(pil, spacing[0], spacing[1])
|
pil = resize_for_spacing(pil, spacing[0], spacing[1])
|
||||||
if max(pil.size) > max_size:
|
if max(pil.size) > max_size:
|
||||||
pil.thumbnail((max_size, max_size), Image.Resampling.BILINEAR)
|
pil.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
|
||||||
output = io.BytesIO()
|
output = io.BytesIO()
|
||||||
pil.save(output, format="PNG", optimize=True)
|
pil.save(output, format="PNG", optimize=True)
|
||||||
return output.getvalue()
|
return output.getvalue()
|
||||||
@@ -1016,7 +1074,7 @@ def dicom_fusion_volume(
|
|||||||
else:
|
else:
|
||||||
start = max(0, center_index - radius)
|
start = max(0, center_index - radius)
|
||||||
end = min(total - 1, center_index + radius)
|
end = min(total - 1, center_index + radius)
|
||||||
max_frames = {"low": 48, "medium": 64, "high": 80}.get(str(detail).lower(), 64)
|
max_frames = {"low": 24, "medium": 48, "high": 72}.get(str(detail).lower(), 48)
|
||||||
if end - start + 1 > max_frames:
|
if end - start + 1 > max_frames:
|
||||||
step = int(np.ceil((end - start + 1) / max_frames))
|
step = int(np.ceil((end - start + 1) / max_frames))
|
||||||
indices = list(range(start, end + 1, step))
|
indices = list(range(start, end + 1, step))
|
||||||
@@ -1226,6 +1284,7 @@ def run_cache_refresh(reason: str = "manual", user: str = "system") -> None:
|
|||||||
try:
|
try:
|
||||||
cache_root.mkdir(parents=True, exist_ok=True)
|
cache_root.mkdir(parents=True, exist_ok=True)
|
||||||
STUDY_CACHE.clear()
|
STUDY_CACHE.clear()
|
||||||
|
SERIES_SORT_CACHE.clear()
|
||||||
STACK_CACHE.clear()
|
STACK_CACHE.clear()
|
||||||
rows = relation_rows_for_cache()
|
rows = relation_rows_for_cache()
|
||||||
total = len(rows)
|
total = len(rows)
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ const POSE_CONTROLS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a];
|
const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a];
|
||||||
|
const CASE_PAGE_SIZE = 20;
|
||||||
|
const POSE_SIGNATURE_KEYS = [
|
||||||
|
"rotateX",
|
||||||
|
"rotateY",
|
||||||
|
"rotateZ",
|
||||||
|
"translateX",
|
||||||
|
"translateY",
|
||||||
|
"translateZ",
|
||||||
|
"scale",
|
||||||
|
"flipX",
|
||||||
|
"flipY",
|
||||||
|
"flipZ",
|
||||||
|
];
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
token: localStorage.getItem("dicom_upp_registration_token") || "",
|
token: localStorage.getItem("dicom_upp_registration_token") || "",
|
||||||
@@ -51,7 +64,13 @@ const state = {
|
|||||||
partFilter: "",
|
partFilter: "",
|
||||||
modelFilter: "",
|
modelFilter: "",
|
||||||
search: "",
|
search: "",
|
||||||
|
searchTimer: 0,
|
||||||
caseCollapsed: false,
|
caseCollapsed: false,
|
||||||
|
caseLimit: CASE_PAGE_SIZE,
|
||||||
|
caseOffset: 0,
|
||||||
|
caseTotal: 0,
|
||||||
|
caseHasMore: false,
|
||||||
|
loadingCases: false,
|
||||||
activeToolTab: "series",
|
activeToolTab: "series",
|
||||||
cases: [],
|
cases: [],
|
||||||
activeCase: null,
|
activeCase: null,
|
||||||
@@ -65,7 +84,7 @@ const state = {
|
|||||||
pose: { ...DEFAULT_POSE },
|
pose: { ...DEFAULT_POSE },
|
||||||
windowMode: "default",
|
windowMode: "default",
|
||||||
fusionMode: "fusion",
|
fusionMode: "fusion",
|
||||||
fusionDetail: "high",
|
fusionDetail: "low",
|
||||||
modelDetail: "standard",
|
modelDetail: "standard",
|
||||||
mappingMode: "result",
|
mappingMode: "result",
|
||||||
sliceIndex: 0,
|
sliceIndex: 0,
|
||||||
@@ -76,6 +95,7 @@ const state = {
|
|||||||
lastAutoPose: null,
|
lastAutoPose: null,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
dirtyReason: "",
|
dirtyReason: "",
|
||||||
|
savedPoseSignature: "",
|
||||||
saving: false,
|
saving: false,
|
||||||
fusionVersion: 0,
|
fusionVersion: 0,
|
||||||
volumeMeta: null,
|
volumeMeta: null,
|
||||||
@@ -86,7 +106,7 @@ const state = {
|
|||||||
scene: null,
|
scene: null,
|
||||||
controls: null,
|
controls: null,
|
||||||
fusionRoot: null,
|
fusionRoot: null,
|
||||||
viewPose: { rotateX: 0, rotateZ: 0, translateX: 0, translateY: 0, scale: 1 },
|
viewPose: { rotateX: 1.012, rotateZ: -0.314, translateX: 0, translateY: 0, scale: 1 },
|
||||||
sceneDrag: null,
|
sceneDrag: null,
|
||||||
dicomGroup: null,
|
dicomGroup: null,
|
||||||
dicomPlane: null,
|
dicomPlane: null,
|
||||||
@@ -175,9 +195,23 @@ function markDirty(reason = "pose") {
|
|||||||
setSaveState("未保存", "warn");
|
setSaveState("未保存", "warn");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function poseSignature(pose = state.pose) {
|
||||||
|
const normalized = {};
|
||||||
|
POSE_SIGNATURE_KEYS.forEach((key) => {
|
||||||
|
const value = pose?.[key] ?? DEFAULT_POSE[key];
|
||||||
|
normalized[key] = typeof value === "boolean" ? value : Number(Number(value || 0).toFixed(4));
|
||||||
|
});
|
||||||
|
return JSON.stringify(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function poseChanged() {
|
||||||
|
return state.savedPoseSignature && poseSignature(state.pose) !== state.savedPoseSignature;
|
||||||
|
}
|
||||||
|
|
||||||
function resetDirty() {
|
function resetDirty() {
|
||||||
state.dirty = false;
|
state.dirty = false;
|
||||||
state.dirtyReason = "";
|
state.dirtyReason = "";
|
||||||
|
state.savedPoseSignature = poseSignature(state.pose);
|
||||||
setSaveState("已保存", "ok");
|
setSaveState("已保存", "ok");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,28 +375,47 @@ function pollCacheWhileRunning(force = false) {
|
|||||||
}, running ? 1600 : 800);
|
}, running ? 1600 : 800);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCases(selectFirst = true) {
|
async function loadCases(selectFirst = true, append = false) {
|
||||||
setTopLoading(true);
|
if (state.loadingCases) return;
|
||||||
|
state.loadingCases = true;
|
||||||
|
if (!append) {
|
||||||
|
state.caseOffset = 0;
|
||||||
|
state.caseHasMore = false;
|
||||||
|
state.cases = [];
|
||||||
|
}
|
||||||
|
setTopLoading(!append);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (state.search) params.set("q", state.search);
|
if (state.search) params.set("q", state.search);
|
||||||
if (state.statusFilter) params.set("status", state.statusFilter);
|
if (state.statusFilter) params.set("status", state.statusFilter);
|
||||||
if (state.partFilter) params.set("body_part", state.partFilter);
|
if (state.partFilter) params.set("body_part", state.partFilter);
|
||||||
if (state.modelFilter) params.set("algorithm_model", state.modelFilter);
|
if (state.modelFilter) params.set("algorithm_model", state.modelFilter);
|
||||||
const rows = await api(`/api/cases?${params.toString()}`);
|
params.set("limit", String(state.caseLimit));
|
||||||
state.cases = rows;
|
params.set("offset", String(append ? state.caseOffset : 0));
|
||||||
|
const payload = await api(`/api/cases?${params.toString()}`);
|
||||||
|
const rows = Array.isArray(payload) ? payload : payload.rows || [];
|
||||||
|
state.caseTotal = Array.isArray(payload) ? (append ? state.cases.length + rows.length : rows.length) : Number(payload.total || 0);
|
||||||
|
state.caseHasMore = Array.isArray(payload) ? rows.length >= state.caseLimit : Boolean(payload.has_more);
|
||||||
|
state.caseOffset = (append ? state.caseOffset : 0) + rows.length;
|
||||||
|
state.cases = append ? [...state.cases, ...rows] : rows;
|
||||||
renderCases();
|
renderCases();
|
||||||
if (selectFirst && !state.activeCase && rows.length) {
|
if (selectFirst && !state.activeCase && rows.length) {
|
||||||
await selectCase(rows[0].ct_number, rows[0].algorithm_model || "未指定模型");
|
await selectCase(rows[0].ct_number, rows[0].algorithm_model || "未指定模型");
|
||||||
} else if (state.activeCase && !rows.some((row) => sameCase(row, state.activeCase))) {
|
} else if (!append && state.activeCase && !state.cases.some((row) => sameCase(row, state.activeCase))) {
|
||||||
state.activeCase = null;
|
state.activeCase = null;
|
||||||
clearDetail();
|
clearDetail();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setTopLoading(false);
|
state.loadingCases = false;
|
||||||
|
if (!append) setTopLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMoreCases() {
|
||||||
|
if (state.loadingCases || !state.caseHasMore) return;
|
||||||
|
await loadCases(false, true);
|
||||||
|
}
|
||||||
|
|
||||||
function sameCase(a, b) {
|
function sameCase(a, b) {
|
||||||
return normalize(a?.ct_number) === normalize(b?.ct_number) && (a?.algorithm_model || "未指定模型") === (b?.algorithm_model || "未指定模型");
|
return normalize(a?.ct_number) === normalize(b?.ct_number) && (a?.algorithm_model || "未指定模型") === (b?.algorithm_model || "未指定模型");
|
||||||
}
|
}
|
||||||
@@ -439,11 +492,22 @@ function visibleModelRecords() {
|
|||||||
return state.modelMeshes.filter((record) => state.selectedStlIds.has(Number(record.file?.id)) && record.mesh?.visible !== false);
|
return state.modelMeshes.filter((record) => state.selectedStlIds.has(Number(record.file?.id)) && record.mesh?.visible !== false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function modelPoseMatrix() {
|
||||||
|
if (!state.modelGroup) return new THREE.Matrix4();
|
||||||
|
state.modelGroup.updateMatrix();
|
||||||
|
return state.modelGroup.matrix.clone();
|
||||||
|
}
|
||||||
|
|
||||||
function visibleModelBox() {
|
function visibleModelBox() {
|
||||||
const records = visibleModelRecords();
|
const records = visibleModelRecords();
|
||||||
if (!records.length) return null;
|
if (!records.length) return null;
|
||||||
const box = new THREE.Box3();
|
const box = new THREE.Box3();
|
||||||
records.forEach((record) => box.expandByObject(record.mesh));
|
const matrix = modelPoseMatrix();
|
||||||
|
records.forEach((record) => {
|
||||||
|
record.mesh.geometry.computeBoundingBox?.();
|
||||||
|
const localBox = record.mesh.geometry.boundingBox;
|
||||||
|
if (localBox) box.union(localBox.clone().applyMatrix4(matrix));
|
||||||
|
});
|
||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,9 +519,9 @@ function bodyPartTags(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderCases() {
|
function renderCases() {
|
||||||
$("caseCount").textContent = state.cases.length;
|
$("caseCount").textContent = state.caseTotal ? `${state.cases.length}/${state.caseTotal}` : state.cases.length;
|
||||||
const active = state.activeCase;
|
const active = state.activeCase;
|
||||||
$("caseList").innerHTML = state.cases.length
|
const cards = state.cases.length
|
||||||
? state.cases
|
? state.cases
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const tags = bodyPartTags(row).slice(0, 3);
|
const tags = bodyPartTags(row).slice(0, 3);
|
||||||
@@ -483,9 +547,11 @@ function renderCases() {
|
|||||||
})
|
})
|
||||||
.join("")
|
.join("")
|
||||||
: `<div class="empty-state">没有符合条件的完整关联 CT</div>`;
|
: `<div class="empty-state">没有符合条件的完整关联 CT</div>`;
|
||||||
|
$("caseList").innerHTML = `${cards}${state.caseHasMore ? `<button id="loadMoreCasesBtn" class="load-more-btn" type="button">${state.loadingCases ? "读取中..." : "继续加载"}</button>` : ""}`;
|
||||||
$("caseList").querySelectorAll(".case-card").forEach((button) => {
|
$("caseList").querySelectorAll(".case-card").forEach((button) => {
|
||||||
button.addEventListener("click", () => selectCase(button.dataset.ct, button.dataset.algorithm || "未指定模型"));
|
button.addEventListener("click", () => selectCase(button.dataset.ct, button.dataset.algorithm || "未指定模型"));
|
||||||
});
|
});
|
||||||
|
$("loadMoreCasesBtn")?.addEventListener("click", loadMoreCases);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearDetail() {
|
function clearDetail() {
|
||||||
@@ -505,7 +571,7 @@ function clearDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function confirmChange() {
|
async function confirmChange() {
|
||||||
if (!state.dirty) return true;
|
if (!state.dirty || !poseChanged()) return true;
|
||||||
return window.confirm("当前配准参数还未保存,确定切换吗?");
|
return window.confirm("当前配准参数还未保存,确定切换吗?");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,7 +584,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
|
|||||||
const previousPose = { ...state.pose };
|
const previousPose = { ...state.pose };
|
||||||
if (!(await confirmChange())) return;
|
if (!(await confirmChange())) return;
|
||||||
setTopLoading(true);
|
setTopLoading(true);
|
||||||
setFusionLoading(true, "正在读取 CT 关联数据", 6);
|
setFusionLoading(true, "正在读取当前 CT 数据", 6);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ algorithm_model: normalizedAlgorithm });
|
const params = new URLSearchParams({ algorithm_model: normalizedAlgorithm });
|
||||||
const [detail, seriesPayload, stlPayload, registration] = await Promise.all([
|
const [detail, seriesPayload, stlPayload, registration] = await Promise.all([
|
||||||
@@ -532,10 +598,10 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
|
|||||||
state.series = seriesPayload.series || [];
|
state.series = seriesPayload.series || [];
|
||||||
state.stlFiles = stlPayload.files || [];
|
state.stlFiles = stlPayload.files || [];
|
||||||
state.registrationStatus = registration.registration_status || "unregistered";
|
state.registrationStatus = registration.registration_status || "unregistered";
|
||||||
const savedTransform = registration.transform || {};
|
const savedTransform = { ...(registration.transform || {}), scaleX: 1, scaleY: 1, scaleZ: 1 };
|
||||||
const keepPreviousPose = switchingModelOnly;
|
const keepPreviousPose = switchingModelOnly;
|
||||||
state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform };
|
state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform };
|
||||||
state.fusionDetail = registration.model_reference?.fusion_detail || state.fusionDetail || "low";
|
state.fusionDetail = state.fusionDetail || "low";
|
||||||
state.modelDetail = registration.model_reference?.model_detail || state.modelDetail || "standard";
|
state.modelDetail = registration.model_reference?.model_detail || state.modelDetail || "standard";
|
||||||
state.autoResult = null;
|
state.autoResult = null;
|
||||||
state.lastAutoPose = null;
|
state.lastAutoPose = null;
|
||||||
@@ -617,18 +683,14 @@ function currentSeries() {
|
|||||||
function renderActiveHeader() {
|
function renderActiveHeader() {
|
||||||
const row = state.activeCase;
|
const row = state.activeCase;
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
const selected = currentSeries();
|
|
||||||
const registration = registrationSeries();
|
const registration = registrationSeries();
|
||||||
const bodyTags = bodyPartTags(row);
|
|
||||||
const selectedLabels = registration?.annotation_labels?.length ? registration.annotation_labels.filter((label) => !String(label).includes("略过") && !String(label).includes("不采用")) : [];
|
const selectedLabels = registration?.annotation_labels?.length ? registration.annotation_labels.filter((label) => !String(label).includes("略过") && !String(label).includes("不采用")) : [];
|
||||||
$("activeTitle").textContent = `${row.ct_number} · ${row.algorithm_model || "未指定模型"}`;
|
$("activeTitle").textContent = `${row.ct_number} · ${row.algorithm_model || "未指定模型"}`;
|
||||||
$("activeMeta").textContent = `${row.patient_name || row.upp_patient_name || "-"} · ${row.patient_id || "-"} · ${fmtDateTime(row.study_date, row.study_time) || "检查时间未知"} · ${bodyTags.join("、") || row.study_description || "部位未知"} · ${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)} 个`;
|
$("activeMeta").textContent = `${row.patient_name || row.upp_patient_name || "-"} · ${row.patient_id || "-"} · ${fmtDateTime(row.study_date, row.study_time) || "检查时间未知"} · ${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)} 个`;
|
||||||
const tags = [
|
const tags = [
|
||||||
`<span class="tag ${state.registrationStatus === "registered" ? "green" : "amber"}">${statusText(state.registrationStatus)}</span>`,
|
`<span class="tag ${state.registrationStatus === "registered" ? "green" : "amber"}">${statusText(state.registrationStatus)}</span>`,
|
||||||
`<span class="tag">${escapeHtml(state.algorithmModel || row.algorithm_model || "未指定模型")}</span>`,
|
`<span class="tag">${escapeHtml(state.algorithmModel || row.algorithm_model || "未指定模型")}</span>`,
|
||||||
...(registration ? [`<span class="tag">配准 ${escapeHtml(registration.description || "当前序列")}</span>`] : []),
|
|
||||||
...selectedLabels.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
|
...selectedLabels.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
|
||||||
...bodyTags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
|
|
||||||
];
|
];
|
||||||
$("activeTags").innerHTML = tags.join("");
|
$("activeTags").innerHTML = tags.join("");
|
||||||
$("statusBtn").textContent = state.registrationStatus === "registered" ? "标为未配准" : "标为已配准";
|
$("statusBtn").textContent = state.registrationStatus === "registered" ? "标为未配准" : "标为已配准";
|
||||||
@@ -766,10 +828,21 @@ async function invertStlSelection() {
|
|||||||
async function applyStlSelectionChange() {
|
async function applyStlSelectionChange() {
|
||||||
markDirty("stl");
|
markDirty("stl");
|
||||||
renderStl();
|
renderStl();
|
||||||
if (!state.modelMeshes.length) {
|
const loadedIds = new Set(state.modelMeshes.map((record) => Number(record.file?.id)).filter(Number.isFinite));
|
||||||
|
const needsLoad = [...state.selectedStlIds].some((id) => !loadedIds.has(Number(id)));
|
||||||
|
if (!state.volumeMeta || (!state.modelMeshes.length && state.selectedStlIds.size)) {
|
||||||
await loadFusion();
|
await loadFusion();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (needsLoad) {
|
||||||
|
setFusionLoading(true, "正在补加载 STL 模型", 62);
|
||||||
|
await buildStlModels(state.volumeMeta, (progress) => setFusionLoading(true, "正在补加载 STL 模型", 62 + progress * 0.3));
|
||||||
|
setFusionLoading(false);
|
||||||
|
applyModelDetail();
|
||||||
|
if (state.sceneReady) fitCamera();
|
||||||
|
renderStl();
|
||||||
|
return;
|
||||||
|
}
|
||||||
applyStlVisibility();
|
applyStlVisibility();
|
||||||
applyModelDetail();
|
applyModelDetail();
|
||||||
scheduleMappingDraw();
|
scheduleMappingDraw();
|
||||||
@@ -1207,10 +1280,10 @@ function updateFusionMeta() {
|
|||||||
|
|
||||||
function fusionDetailSettings() {
|
function fusionDetailSettings() {
|
||||||
return {
|
return {
|
||||||
low: { planeOpacity: 0.82, sliceOpacity: 0.018, slicePlanes: 10, pointOpacity: 0.035, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 },
|
low: { planeOpacity: 1, sliceOpacity: 0.045, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 },
|
||||||
medium: { planeOpacity: 0.92, sliceOpacity: 0.024, slicePlanes: 14, pointOpacity: 0.055, pointSize: 0.014, pointGrid: 88, pointThreshold: 34, textureSize: 512 },
|
medium: { planeOpacity: 1, sliceOpacity: 0.07, slicePlanes: 48, pointOpacity: 0, pointSize: 0.014, pointGrid: 88, pointThreshold: 34, textureSize: 640 },
|
||||||
high: { planeOpacity: 1, sliceOpacity: 0.028, slicePlanes: 18, pointOpacity: 0.075, pointSize: 0.015, pointGrid: 104, pointThreshold: 32, textureSize: 768 },
|
high: { planeOpacity: 1, sliceOpacity: 0.11, slicePlanes: 72, pointOpacity: 0, pointSize: 0.015, pointGrid: 104, pointThreshold: 32, textureSize: 1024 },
|
||||||
}[state.fusionDetail] || { planeOpacity: 0.82, sliceOpacity: 0.018, slicePlanes: 10, pointOpacity: 0.035, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 };
|
}[state.fusionDetail] || { planeOpacity: 1, sliceOpacity: 0.045, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelDetailSettings() {
|
function modelDetailSettings() {
|
||||||
@@ -1333,7 +1406,7 @@ async function loadTexture(url) {
|
|||||||
texture.colorSpace = THREE.SRGBColorSpace;
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
texture.generateMipmaps = false;
|
texture.generateMipmaps = false;
|
||||||
texture.minFilter = THREE.LinearFilter;
|
texture.minFilter = THREE.LinearFilter;
|
||||||
texture.magFilter = THREE.LinearFilter;
|
texture.magFilter = THREE.NearestFilter;
|
||||||
texture.anisotropy = Math.min(4, state.renderer?.capabilities?.getMaxAnisotropy?.() || 1);
|
texture.anisotropy = Math.min(4, state.renderer?.capabilities?.getMaxAnisotropy?.() || 1);
|
||||||
texture.needsUpdate = true;
|
texture.needsUpdate = true;
|
||||||
resolve(texture);
|
resolve(texture);
|
||||||
@@ -1677,7 +1750,6 @@ function fillSegmentsAsSolidMask(context, width, height, segments, color, opacit
|
|||||||
const maskData = maskContext.createImageData(width, height);
|
const maskData = maskContext.createImageData(width, height);
|
||||||
const radius = solidStrokeRadius(width, height);
|
const radius = solidStrokeRadius(width, height);
|
||||||
const groups = groupPlaneSegmentsByConnectivity(segments, radius * 1.15);
|
const groups = groupPlaneSegmentsByConnectivity(segments, radius * 1.15);
|
||||||
const fallbackGroups = [];
|
|
||||||
let filledPixels = 0;
|
let filledPixels = 0;
|
||||||
|
|
||||||
groups.forEach((group) => {
|
groups.forEach((group) => {
|
||||||
@@ -1713,16 +1785,12 @@ function fillSegmentsAsSolidMask(context, width, height, segments, color, opacit
|
|||||||
});
|
});
|
||||||
groupPixels += fillSegmentCapsulesIntoMask(maskData, width, height, group, rgb, alpha, radius);
|
groupPixels += fillSegmentCapsulesIntoMask(maskData, width, height, group, rgb, alpha, radius);
|
||||||
filledPixels += groupPixels;
|
filledPixels += groupPixels;
|
||||||
if (groupPixels < Math.max(20, Math.round(group.length * 0.5)) && group.length >= 3) fallbackGroups.push(group);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha, 3);
|
filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha, 3);
|
||||||
filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha);
|
filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha);
|
||||||
maskContext.putImageData(maskData, 0, 0);
|
maskContext.putImageData(maskData, 0, 0);
|
||||||
context.drawImage(maskCanvas, 0, 0);
|
context.drawImage(maskCanvas, 0, 0);
|
||||||
fallbackGroups.forEach((group) => {
|
|
||||||
filledPixels += drawFallbackClosedRegion(context, width, height, group, color, opacity);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (filledPixels === 0) {
|
if (filledPixels === 0) {
|
||||||
context.save();
|
context.save();
|
||||||
@@ -1739,6 +1807,19 @@ function fillSegmentsAsSolidMask(context, width, height, segments, color, opacit
|
|||||||
context.stroke();
|
context.stroke();
|
||||||
context.restore();
|
context.restore();
|
||||||
}
|
}
|
||||||
|
context.save();
|
||||||
|
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.65;
|
||||||
|
context.strokeStyle = color;
|
||||||
|
context.lineWidth = Math.max(0.8, Math.max(width, height) * 0.0012);
|
||||||
|
context.lineCap = "round";
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.beginPath();
|
||||||
|
segments.forEach((segment) => {
|
||||||
|
context.moveTo(segment.a.x, segment.a.y);
|
||||||
|
context.lineTo(segment.b.x, segment.b.y);
|
||||||
|
});
|
||||||
|
context.stroke();
|
||||||
|
context.restore();
|
||||||
|
|
||||||
return filledPixels;
|
return filledPixels;
|
||||||
}
|
}
|
||||||
@@ -1774,7 +1855,7 @@ async function drawMappingView(version = state.mappingVersion) {
|
|||||||
x: ((point.x + volumeScene.width / 2) / volumeScene.width) * width,
|
x: ((point.x + volumeScene.width / 2) / volumeScene.width) * width,
|
||||||
y: (0.5 - point.y / volumeScene.height) * height,
|
y: (0.5 - point.y / volumeScene.height) * height,
|
||||||
});
|
});
|
||||||
state.modelGroup?.updateMatrixWorld(true);
|
const matrix = modelPoseMatrix();
|
||||||
|
|
||||||
let activeModules = 0;
|
let activeModules = 0;
|
||||||
let segmentCount = 0;
|
let segmentCount = 0;
|
||||||
@@ -1789,15 +1870,12 @@ async function drawMappingView(version = state.mappingVersion) {
|
|||||||
const position = record.mesh.geometry.attributes.position;
|
const position = record.mesh.geometry.attributes.position;
|
||||||
if (!position) continue;
|
if (!position) continue;
|
||||||
const triangleCount = Math.floor(position.count / 3);
|
const triangleCount = Math.floor(position.count / 3);
|
||||||
const step = Math.max(1, Math.ceil(triangleCount / 70000));
|
const step = Math.max(1, Math.ceil(triangleCount / 260000));
|
||||||
const segments = [];
|
const segments = [];
|
||||||
for (let tri = 0; tri < triangleCount; tri += step) {
|
for (let tri = 0; tri < triangleCount; tri += step) {
|
||||||
tempA.fromBufferAttribute(position, tri * 3);
|
tempA.fromBufferAttribute(position, tri * 3).applyMatrix4(matrix);
|
||||||
tempB.fromBufferAttribute(position, tri * 3 + 1);
|
tempB.fromBufferAttribute(position, tri * 3 + 1).applyMatrix4(matrix);
|
||||||
tempC.fromBufferAttribute(position, tri * 3 + 2);
|
tempC.fromBufferAttribute(position, tri * 3 + 2).applyMatrix4(matrix);
|
||||||
record.mesh.localToWorld(tempA);
|
|
||||||
record.mesh.localToWorld(tempB);
|
|
||||||
record.mesh.localToWorld(tempC);
|
|
||||||
const segment = intersectTriangleWithZ(tempA, tempB, tempC, targetZ);
|
const segment = intersectTriangleWithZ(tempA, tempB, tempC, targetZ);
|
||||||
if (!segment) continue;
|
if (!segment) continue;
|
||||||
const mapped = { a: mapPoint(segment.a), b: mapPoint(segment.b) };
|
const mapped = { a: mapPoint(segment.a), b: mapPoint(segment.b) };
|
||||||
@@ -1912,11 +1990,11 @@ async function buildDicomVolume(volume) {
|
|||||||
opacity: fusionDetail.planeOpacity,
|
opacity: fusionDetail.planeOpacity,
|
||||||
side: THREE.DoubleSide,
|
side: THREE.DoubleSide,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
depthTest: true,
|
depthTest: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
plane.position.z = sliceToSceneZ(centerIndex) + 0.006;
|
plane.position.z = sliceToSceneZ(centerIndex) + 0.006;
|
||||||
plane.renderOrder = 20;
|
plane.renderOrder = 80;
|
||||||
state.dicomGroup.add(plane);
|
state.dicomGroup.add(plane);
|
||||||
state.dicomPlane = plane;
|
state.dicomPlane = plane;
|
||||||
}
|
}
|
||||||
@@ -1944,6 +2022,7 @@ async function buildDicomVolume(volume) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fusionDetail.pointOpacity > 0) {
|
||||||
const pointPositions = [];
|
const pointPositions = [];
|
||||||
const pointColors = [];
|
const pointColors = [];
|
||||||
for (const [index, frame] of frames.entries()) {
|
for (const [index, frame] of frames.entries()) {
|
||||||
@@ -1981,9 +2060,13 @@ async function buildDicomVolume(volume) {
|
|||||||
state.dicomGroup.add(points);
|
state.dicomGroup.add(points);
|
||||||
state.dicomPoints = points;
|
state.dicomPoints = points;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state.baseModelScale = sceneScale;
|
state.baseModelScale = sceneScale;
|
||||||
state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup);
|
state.dicomSceneBox = new THREE.Box3(
|
||||||
|
new THREE.Vector3(-width / 2, -height / 2, -depth / 2),
|
||||||
|
new THREE.Vector3(width / 2, height / 2, depth / 2),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBaseModelScaleFromBounds() {
|
function updateBaseModelScaleFromBounds() {
|
||||||
@@ -1998,6 +2081,7 @@ function updateBaseModelScaleFromBounds() {
|
|||||||
|
|
||||||
function addModelBoundsFrame(size) {
|
function addModelBoundsFrame(size) {
|
||||||
if (!state.rawModelGroup || !size) return;
|
if (!state.rawModelGroup || !size) return;
|
||||||
|
removeModelBoundsFrame();
|
||||||
const frame = new THREE.LineSegments(
|
const frame = new THREE.LineSegments(
|
||||||
new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)),
|
new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)),
|
||||||
new THREE.LineBasicMaterial({
|
new THREE.LineBasicMaterial({
|
||||||
@@ -2015,9 +2099,34 @@ function addModelBoundsFrame(size) {
|
|||||||
state.modelBoundsFrame = frame;
|
state.modelBoundsFrame = frame;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeModelBoundsFrame() {
|
||||||
|
const frame = state.modelBoundsFrame;
|
||||||
|
if (!frame || !state.rawModelGroup) return;
|
||||||
|
state.rawModelGroup.remove(frame);
|
||||||
|
frame.geometry?.dispose?.();
|
||||||
|
if (Array.isArray(frame.material)) frame.material.forEach((material) => material.dispose?.());
|
||||||
|
else frame.material?.dispose?.();
|
||||||
|
state.modelBoundsFrame = null;
|
||||||
|
}
|
||||||
|
|
||||||
async function buildStlModels(volume, onProgress = null) {
|
async function buildStlModels(volume, onProgress = null) {
|
||||||
const signature = stlSelectionSignature();
|
const signature = stlSelectionSignature();
|
||||||
if (signature && signature === state.stlSignature && state.rawModelGroup?.children.length) {
|
if (signature && signature !== state.stlSignature) {
|
||||||
|
state.rawModelGroup.position.set(0, 0, 0);
|
||||||
|
state.rawModelGroup.rotation.set(0, 0, 0);
|
||||||
|
state.rawModelGroup.scale.set(1, 1, 1);
|
||||||
|
clearGroup(state.rawModelGroup);
|
||||||
|
state.modelMeshes = [];
|
||||||
|
state.modelBoundsFrame = null;
|
||||||
|
state.modelLocalBounds = null;
|
||||||
|
state.stlSignature = signature;
|
||||||
|
}
|
||||||
|
const loadedIds = new Set(state.modelMeshes.map((record) => Number(record.file?.id)).filter(Number.isFinite));
|
||||||
|
const files = state.stlFiles.filter((file) => (
|
||||||
|
state.selectedStlIds.has(Number(file.id))
|
||||||
|
&& !loadedIds.has(Number(file.id))
|
||||||
|
));
|
||||||
|
if (signature && signature === state.stlSignature && !files.length && state.rawModelGroup?.children.length) {
|
||||||
updateBaseModelScaleFromBounds();
|
updateBaseModelScaleFromBounds();
|
||||||
if (!state.modelBoundsFrame && state.modelLocalBounds?.size) addModelBoundsFrame(state.modelLocalBounds.size);
|
if (!state.modelBoundsFrame && state.modelLocalBounds?.size) addModelBoundsFrame(state.modelLocalBounds.size);
|
||||||
applyStlVisibility();
|
applyStlVisibility();
|
||||||
@@ -2027,22 +2136,15 @@ async function buildStlModels(volume, onProgress = null) {
|
|||||||
onProgress?.(100);
|
onProgress?.(100);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.rawModelGroup.position.set(0, 0, 0);
|
if (!files.length && !state.modelMeshes.length) {
|
||||||
state.rawModelGroup.rotation.set(0, 0, 0);
|
|
||||||
state.rawModelGroup.scale.set(1, 1, 1);
|
|
||||||
clearGroup(state.rawModelGroup);
|
|
||||||
state.modelMeshes = [];
|
|
||||||
state.modelBoundsFrame = null;
|
|
||||||
state.modelLocalBounds = null;
|
|
||||||
state.stlSignature = signature;
|
|
||||||
const files = state.stlFiles;
|
|
||||||
if (!files.length) {
|
|
||||||
applyPose();
|
applyPose();
|
||||||
resetMappingCanvas("当前没有可见 STL 构件");
|
resetMappingCanvas("当前没有可见 STL 构件");
|
||||||
|
onProgress?.(100);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const loader = new STLLoader();
|
const loader = new STLLoader();
|
||||||
const detail = modelDetailSettings();
|
const detail = modelDetailSettings();
|
||||||
|
const freshRecords = [];
|
||||||
for (const [index, file] of files.entries()) {
|
for (const [index, file] of files.entries()) {
|
||||||
onProgress?.((index / Math.max(files.length, 1)) * 100);
|
onProgress?.((index / Math.max(files.length, 1)) * 100);
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -2069,18 +2171,19 @@ async function buildStlModels(volume, onProgress = null) {
|
|||||||
mesh.userData.file = file;
|
mesh.userData.file = file;
|
||||||
mesh.userData.color = cssColor(index);
|
mesh.userData.color = cssColor(index);
|
||||||
state.rawModelGroup.add(mesh);
|
state.rawModelGroup.add(mesh);
|
||||||
state.modelMeshes.push({ mesh, file, color: cssColor(index), index });
|
const record = { mesh, file, color: cssColor(Number(file.id) || index), index: Number(file.id) || index };
|
||||||
|
state.modelMeshes.push(record);
|
||||||
|
freshRecords.push(record);
|
||||||
onProgress?.(((index + 1) / Math.max(files.length, 1)) * 100);
|
onProgress?.(((index + 1) / Math.max(files.length, 1)) * 100);
|
||||||
}
|
}
|
||||||
const bbox = new THREE.Box3();
|
if (!state.modelLocalBounds && state.modelMeshes.length) {
|
||||||
|
const rawBox = new THREE.Box3();
|
||||||
state.modelMeshes.forEach((record) => {
|
state.modelMeshes.forEach((record) => {
|
||||||
record.mesh.geometry.computeBoundingBox?.();
|
record.mesh.geometry.computeBoundingBox?.();
|
||||||
if (record.mesh.geometry.boundingBox) bbox.union(record.mesh.geometry.boundingBox);
|
if (record.mesh.geometry.boundingBox) rawBox.union(record.mesh.geometry.boundingBox);
|
||||||
});
|
});
|
||||||
const center = new THREE.Vector3();
|
const center = new THREE.Vector3();
|
||||||
const size = new THREE.Vector3();
|
rawBox.getCenter(center);
|
||||||
bbox.getCenter(center);
|
|
||||||
bbox.getSize(size);
|
|
||||||
state.modelMeshes.forEach((record) => {
|
state.modelMeshes.forEach((record) => {
|
||||||
const geometry = record.mesh.geometry;
|
const geometry = record.mesh.geometry;
|
||||||
geometry.translate(-center.x, -center.y, -center.z);
|
geometry.translate(-center.x, -center.y, -center.z);
|
||||||
@@ -2090,10 +2193,28 @@ async function buildStlModels(volume, onProgress = null) {
|
|||||||
});
|
});
|
||||||
state.modelLocalBounds = {
|
state.modelLocalBounds = {
|
||||||
center: { x: center.x, y: center.y, z: center.z },
|
center: { x: center.x, y: center.y, z: center.z },
|
||||||
size: { x: size.x, y: size.y, z: size.z },
|
size: { x: 1, y: 1, z: 1 },
|
||||||
};
|
};
|
||||||
|
} else if (state.modelLocalBounds && freshRecords.length) {
|
||||||
|
const center = state.modelLocalBounds.center;
|
||||||
|
freshRecords.forEach((record) => {
|
||||||
|
const geometry = record.mesh.geometry;
|
||||||
|
geometry.translate(-Number(center.x || 0), -Number(center.y || 0), -Number(center.z || 0));
|
||||||
|
geometry.computeBoundingBox?.();
|
||||||
|
geometry.computeBoundingSphere?.();
|
||||||
|
geometry.computeVertexNormals?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const bbox = new THREE.Box3();
|
||||||
|
state.modelMeshes.forEach((record) => {
|
||||||
|
record.mesh.geometry.computeBoundingBox?.();
|
||||||
|
if (record.mesh.geometry.boundingBox) bbox.union(record.mesh.geometry.boundingBox);
|
||||||
|
});
|
||||||
|
const size = new THREE.Vector3();
|
||||||
|
if (!bbox.isEmpty()) bbox.getSize(size);
|
||||||
|
if (state.modelLocalBounds) state.modelLocalBounds.size = { x: size.x, y: size.y, z: size.z };
|
||||||
updateBaseModelScaleFromBounds();
|
updateBaseModelScaleFromBounds();
|
||||||
addModelBoundsFrame(size);
|
if (size.x || size.y || size.z) addModelBoundsFrame(size);
|
||||||
applyStlVisibility();
|
applyStlVisibility();
|
||||||
applyPose();
|
applyPose();
|
||||||
scheduleMappingDraw();
|
scheduleMappingDraw();
|
||||||
@@ -2111,9 +2232,9 @@ function applyPose(poseInput = state.pose) {
|
|||||||
state.modelGroup.position.set(Number(pose.translateX) || 0, Number(pose.translateY) || 0, Number(pose.translateZ) || 0);
|
state.modelGroup.position.set(Number(pose.translateX) || 0, Number(pose.translateY) || 0, Number(pose.translateZ) || 0);
|
||||||
const scale = Math.max(0.001, Number(pose.scale) || 1) * state.baseModelScale;
|
const scale = Math.max(0.001, Number(pose.scale) || 1) * state.baseModelScale;
|
||||||
state.modelGroup.scale.set(
|
state.modelGroup.scale.set(
|
||||||
scale * Math.max(0.001, Number(pose.scaleX) || 1) * (pose.flipX ? -1 : 1),
|
scale * (pose.flipX ? -1 : 1),
|
||||||
scale * Math.max(0.001, Number(pose.scaleY) || 1) * (pose.flipY ? -1 : 1),
|
scale * (pose.flipY ? -1 : 1),
|
||||||
scale * Math.max(0.001, Number(pose.scaleZ) || 1) * (pose.flipZ ? -1 : 1),
|
scale * (pose.flipZ ? -1 : 1),
|
||||||
);
|
);
|
||||||
state.modelGroup.updateMatrixWorld(true);
|
state.modelGroup.updateMatrixWorld(true);
|
||||||
scheduleMappingDraw();
|
scheduleMappingDraw();
|
||||||
@@ -2154,7 +2275,7 @@ function fitCamera() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetSceneView() {
|
function resetSceneView() {
|
||||||
state.viewPose = { rotateX: 0, rotateZ: 0, translateX: 0, translateY: 0, scale: 1 };
|
state.viewPose = { rotateX: 1.012, rotateZ: -0.314, translateX: 0, translateY: 0, scale: 1 };
|
||||||
applySceneViewPose();
|
applySceneViewPose();
|
||||||
fitCamera();
|
fitCamera();
|
||||||
}
|
}
|
||||||
@@ -2180,21 +2301,24 @@ function stretchAxis(axis) {
|
|||||||
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
|
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const axisKey = { x: "scaleX", y: "scaleY", z: "scaleZ" }[axis];
|
if (!["x", "y", "z"].includes(axis)) return;
|
||||||
if (!axisKey) return;
|
|
||||||
const dicomSize = new THREE.Vector3();
|
const dicomSize = new THREE.Vector3();
|
||||||
const modelSize = new THREE.Vector3();
|
const modelSize = new THREE.Vector3();
|
||||||
state.dicomSceneBox.getSize(dicomSize);
|
state.dicomSceneBox.getSize(dicomSize);
|
||||||
modelBox.getSize(modelSize);
|
modelBox.getSize(modelSize);
|
||||||
const currentAxisScale = Math.max(0.001, Number(state.pose[axisKey]) || 1);
|
|
||||||
const targetSize = Math.max(0.001, dicomSize[axis]);
|
const targetSize = Math.max(0.001, dicomSize[axis]);
|
||||||
const currentSize = Math.max(0.001, modelSize[axis]);
|
const currentSize = Math.max(0.001, modelSize[axis]);
|
||||||
const next = Math.max(0.2, Math.min(4, currentAxisScale * (targetSize / currentSize)));
|
const currentScale = Math.max(0.001, Number(state.pose.scale) || 1);
|
||||||
state.pose[axisKey] = Number(next.toFixed(3));
|
const next = Math.max(0.2, Math.min(4, currentScale * (targetSize / currentSize)));
|
||||||
|
state.pose.scale = Number(next.toFixed(3));
|
||||||
|
state.pose.scaleX = 1;
|
||||||
|
state.pose.scaleY = 1;
|
||||||
|
state.pose.scaleZ = 1;
|
||||||
|
renderPoseControls();
|
||||||
applyPose();
|
applyPose();
|
||||||
markDirty("pose");
|
markDirty("pose");
|
||||||
setAutoState(`${axis.toUpperCase()} 拉伸完成`, "ok");
|
setAutoState(`${axis.toUpperCase()} 拉伸完成`, "ok");
|
||||||
$("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体粗略拉伸,系数 ${state.pose[axisKey]}`;
|
$("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体等比例拉伸,缩放 ${state.pose.scale}`;
|
||||||
updateAutoPoseResult();
|
updateAutoPoseResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2609,6 +2733,12 @@ function wireEvents() {
|
|||||||
window.clearTimeout(state.searchTimer);
|
window.clearTimeout(state.searchTimer);
|
||||||
state.searchTimer = window.setTimeout(() => loadCases(false), 240);
|
state.searchTimer = window.setTimeout(() => loadCases(false), 240);
|
||||||
});
|
});
|
||||||
|
$("caseList").addEventListener("scroll", () => {
|
||||||
|
const list = $("caseList");
|
||||||
|
if (list.scrollTop + list.clientHeight >= list.scrollHeight - 180) {
|
||||||
|
loadMoreCases();
|
||||||
|
}
|
||||||
|
});
|
||||||
document.querySelectorAll(".filter[data-status]").forEach((button) => {
|
document.querySelectorAll(".filter[data-status]").forEach((button) => {
|
||||||
button.addEventListener("click", async () => {
|
button.addEventListener("click", async () => {
|
||||||
state.statusFilter = button.dataset.status || "";
|
state.statusFilter = button.dataset.status || "";
|
||||||
|
|||||||
@@ -292,6 +292,17 @@
|
|||||||
<div id="fusionViewport"></div>
|
<div id="fusionViewport"></div>
|
||||||
<div class="fusion-hud left" id="fusionStatus">等待选择 DICOM 与 STL</div>
|
<div class="fusion-hud left" id="fusionStatus">等待选择 DICOM 与 STL</div>
|
||||||
<div class="fusion-hud right" id="fusionMeta">DICOM - · STL -</div>
|
<div class="fusion-hud right" id="fusionMeta">DICOM - · STL -</div>
|
||||||
|
<div class="fusion-axis-inset" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 76 76" role="img">
|
||||||
|
<line x1="25" y1="56" x2="58" y2="56" class="axis-x" />
|
||||||
|
<line x1="25" y1="56" x2="36" y2="34" class="axis-y" />
|
||||||
|
<line x1="25" y1="56" x2="25" y2="20" class="axis-z" />
|
||||||
|
<circle cx="25" cy="56" r="3" />
|
||||||
|
<text x="64" y="60">X</text>
|
||||||
|
<text x="39" y="31">Y</text>
|
||||||
|
<text x="21" y="16">Z</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<div id="fusionLoading" class="fusion-loading hidden">
|
<div id="fusionLoading" class="fusion-loading hidden">
|
||||||
<span>正在构建融合视图</span>
|
<span>正在构建融合视图</span>
|
||||||
<div><i id="fusionProgressFill"></i></div>
|
<div><i id="fusionProgressFill"></i></div>
|
||||||
|
|||||||
@@ -478,6 +478,18 @@ button {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.load-more-btn {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
margin: 2px 0 8px;
|
||||||
|
border: 1px solid rgba(45, 212, 191, 0.4);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(20, 184, 166, 0.08);
|
||||||
|
color: #bffbf2;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
.case-card,
|
.case-card,
|
||||||
.series-card,
|
.series-card,
|
||||||
.stl-row {
|
.stl-row {
|
||||||
@@ -1116,6 +1128,53 @@ button {
|
|||||||
right: 14px;
|
right: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fusion-axis-inset {
|
||||||
|
position: absolute;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 18px;
|
||||||
|
z-index: 4;
|
||||||
|
width: 76px;
|
||||||
|
height: 76px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.58);
|
||||||
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion-axis-inset svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion-axis-inset line {
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion-axis-inset circle {
|
||||||
|
fill: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion-axis-inset text {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 950;
|
||||||
|
fill: #e8f1fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion-axis-inset .axis-x {
|
||||||
|
stroke: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion-axis-inset .axis-y {
|
||||||
|
stroke: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion-axis-inset .axis-z {
|
||||||
|
stroke: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
.fusion-loading {
|
.fusion-loading {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 40px;
|
left: 40px;
|
||||||
|
|||||||
Reference in New Issue
Block a user