Compare commits
14 Commits
54bae18a21
...
stage-2026
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cad4257a4b | ||
|
|
30f73937aa | ||
|
|
40e18ed45d | ||
|
|
c1fbdd8bac | ||
|
|
bdd0db2a4c | ||
|
|
86e25d0a51 | ||
|
|
692c50e22c | ||
|
|
1d6f04061a | ||
|
|
aecfa957ef | ||
|
|
4bb482c4b0 | ||
|
|
adb2f41213 | ||
|
|
989303ec35 | ||
|
|
45e719aab0 | ||
|
|
69a5926e3c |
@@ -12,6 +12,7 @@ import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -123,6 +124,7 @@ app = FastAPI(title="DICOM UPP Registration Workspace")
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
TOKENS: dict[str, str] = {}
|
||||
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]]] = {}
|
||||
CACHE_REFRESH_LOCK = threading.Lock()
|
||||
CACHE_REFRESH_THREAD: threading.Thread | None = None
|
||||
@@ -592,14 +594,20 @@ def annotation_labels_sql() -> str:
|
||||
return f"""
|
||||
SELECT
|
||||
a.ct_number,
|
||||
COALESCE(jsonb_agg(DISTINCT part.value) FILTER (WHERE part.value IS NOT NULL), '[]'::jsonb) AS body_part_keys,
|
||||
COALESCE(jsonb_agg(DISTINCT
|
||||
CASE
|
||||
WHEN part.value IN ('lower_abdomen', 'pelvis') THEN 'abdomen_pelvis'
|
||||
ELSE part.value
|
||||
END
|
||||
) FILTER (WHERE part.value IS NOT NULL), '[]'::jsonb) AS body_part_keys,
|
||||
COALESCE(jsonb_agg(DISTINCT
|
||||
CASE part.value
|
||||
WHEN 'head_neck' THEN '头颈部'
|
||||
WHEN 'chest' THEN '胸部'
|
||||
WHEN 'upper_abdomen' THEN '上腹部'
|
||||
WHEN 'lower_abdomen' THEN '下腹部'
|
||||
WHEN 'pelvis' THEN '盆腔'
|
||||
WHEN 'abdomen_pelvis' THEN '腹盆部'
|
||||
WHEN 'lower_abdomen' THEN '腹盆部'
|
||||
WHEN 'pelvis' THEN '腹盆部'
|
||||
ELSE NULL
|
||||
END
|
||||
) FILTER (WHERE part.value IS NOT NULL), '[]'::jsonb) AS body_part_labels
|
||||
@@ -663,9 +671,10 @@ def cases(
|
||||
status_filter: str = Query(default="", alias="status"),
|
||||
body_part: 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),
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> dict[str, Any]:
|
||||
ensure_registration_table()
|
||||
clauses = []
|
||||
if q.strip():
|
||||
@@ -685,12 +694,20 @@ def cases(
|
||||
)
|
||||
if status_filter in {"registered", "unregistered"}:
|
||||
clauses.append(f"registration_status = {sql_literal(status_filter)}")
|
||||
if body_part in {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"}:
|
||||
if body_part in {"head_neck", "chest", "upper_abdomen", "abdomen_pelvis"}:
|
||||
clauses.append(f"body_part_keys ? {sql_literal(body_part)}")
|
||||
if algorithm_model.strip():
|
||||
clauses.append(f"algorithm_model ILIKE {sql_literal('%' + algorithm_model.strip().replace('%', '').replace('_', '') + '%')}")
|
||||
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"""
|
||||
SELECT *
|
||||
FROM ({relation_select()}) relation
|
||||
@@ -701,9 +718,17 @@ def cases(
|
||||
COALESCE(study_time, '') DESC,
|
||||
ct_key
|
||||
LIMIT {int(limit)}
|
||||
OFFSET {int(offset)}
|
||||
""",
|
||||
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}")
|
||||
@@ -739,6 +764,23 @@ def read_header(path: Path) -> dict[str, str]:
|
||||
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]:
|
||||
path, meta = item
|
||||
z = numeric(meta.get("SliceLocation"), 0.0)
|
||||
@@ -772,6 +814,9 @@ def resolve_study_root(study: dict[str, Any]) -> Path:
|
||||
return root
|
||||
target_folder = str(study.get("target_folder_name") or "")
|
||||
if target_folder and PROCESSED_ROOT.exists():
|
||||
direct = PROCESSED_ROOT / target_folder
|
||||
if direct.exists():
|
||||
return direct
|
||||
found = next(PROCESSED_ROOT.rglob(target_folder), None)
|
||||
if found:
|
||||
return found
|
||||
@@ -807,10 +852,8 @@ def annotation_labels(row: dict[str, Any]) -> list[str]:
|
||||
elif part == "upper_abdomen":
|
||||
phase = {"plain": "平扫", "arterial": "动脉期", "portal_venous": "门脉期", "delayed": "延迟期", "unknown": "无法判别"}.get(row.get("upper_abdomen_phase") or "unknown", "无法判别")
|
||||
labels.append(f"上腹部-{phase}")
|
||||
elif part == "lower_abdomen":
|
||||
labels.append("下腹部")
|
||||
elif part == "pelvis":
|
||||
labels.append("盆腔")
|
||||
elif part in {"abdomen_pelvis", "lower_abdomen", "pelvis"}:
|
||||
labels.append("腹盆部")
|
||||
return labels
|
||||
|
||||
|
||||
@@ -823,29 +866,36 @@ def scan_study(ct_number: str) -> dict[str, Any]:
|
||||
root = resolve_study_root(study)
|
||||
if not root.exists():
|
||||
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"):
|
||||
try:
|
||||
meta = read_header(path)
|
||||
except Exception:
|
||||
continue
|
||||
uid = meta.get("SeriesInstanceUID") or path.parent.name
|
||||
grouped[uid].append((path, meta))
|
||||
grouped[path.parent.name].append(path)
|
||||
annotations = get_annotations(ct_number)
|
||||
series = []
|
||||
file_map = {}
|
||||
for uid, items in grouped.items():
|
||||
items.sort(key=sort_key)
|
||||
first = items[0][1]
|
||||
last = items[-1][1]
|
||||
file_map[uid] = [path for path, _ in items]
|
||||
annotation = annotations.get(uid) or {}
|
||||
summary_paths: list[Path] = []
|
||||
for paths in grouped.values():
|
||||
paths = sorted(paths)
|
||||
if not paths:
|
||||
continue
|
||||
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_uid": uid,
|
||||
"series_uid": actual_uid,
|
||||
"description": first.get("SeriesDescription") or "未命名序列",
|
||||
"series_number": first.get("SeriesNumber") or "",
|
||||
"count": len(items),
|
||||
"count": len(paths),
|
||||
"modality": first.get("Modality") or "",
|
||||
"rows": first.get("Rows") or "",
|
||||
"columns": first.get("Columns") or "",
|
||||
@@ -876,7 +926,22 @@ def get_series_files(ct_number: str, series_uid: str) -> list[Path]:
|
||||
files = data["files"].get(series_uid)
|
||||
if not files:
|
||||
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]:
|
||||
@@ -945,7 +1010,7 @@ def render_array(arr: np.ndarray, center: float, width: float, max_size: int = 9
|
||||
pil = Image.fromarray(img)
|
||||
pil = resize_for_spacing(pil, spacing[0], spacing[1])
|
||||
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()
|
||||
pil.save(output, format="PNG", optimize=True)
|
||||
return output.getvalue()
|
||||
@@ -996,6 +1061,8 @@ def dicom_fusion_volume(
|
||||
series_uid: str,
|
||||
center_index: int = 0,
|
||||
window: str = "soft",
|
||||
detail: str = "high",
|
||||
texture_size: int | None = Query(default=None, ge=128, le=1024),
|
||||
radius: int = Query(default=96, ge=4, le=512),
|
||||
range_start: int | None = None,
|
||||
range_end: int | None = None,
|
||||
@@ -1014,19 +1081,22 @@ def dicom_fusion_volume(
|
||||
else:
|
||||
start = max(0, center_index - radius)
|
||||
end = min(total - 1, center_index + radius)
|
||||
max_frames = 72
|
||||
max_frames = {"low": 24, "medium": 48, "high": 72}.get(str(detail).lower(), 48)
|
||||
if end - start + 1 > max_frames:
|
||||
step = int(np.ceil((end - start + 1) / max_frames))
|
||||
indices = list(range(start, end + 1, step))
|
||||
else:
|
||||
indices = list(range(start, end + 1))
|
||||
sample_ds = datasets[center_index]
|
||||
focus_index = min(max(center_index, start), end)
|
||||
indices = sorted({*indices, start, end, focus_index})
|
||||
sample_ds = datasets[focus_index]
|
||||
center, width = window_values(sample_ds, window)
|
||||
max_texture_size = int(texture_size or {"low": 384, "medium": 512, "high": 768}.get(str(detail).lower(), 512))
|
||||
frames = []
|
||||
frame_width = 0
|
||||
frame_height = 0
|
||||
for index in indices:
|
||||
png = render_array(stack[index], center, width, max_size=256, spacing=(stack_data["row_spacing"], stack_data["col_spacing"]))
|
||||
png = render_array(stack[index], center, width, max_size=max_texture_size, spacing=(stack_data["row_spacing"], stack_data["col_spacing"]))
|
||||
frames.append("data:image/png;base64," + base64.b64encode(png).decode("ascii"))
|
||||
if not frame_width:
|
||||
with Image.open(io.BytesIO(png)) as pil:
|
||||
@@ -1036,7 +1106,7 @@ def dicom_fusion_volume(
|
||||
"indices": indices,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"center": center_index,
|
||||
"center": focus_index,
|
||||
"total": total,
|
||||
"width": frame_width,
|
||||
"height": frame_height,
|
||||
@@ -1221,6 +1291,7 @@ def run_cache_refresh(reason: str = "manual", user: str = "system") -> None:
|
||||
try:
|
||||
cache_root.mkdir(parents=True, exist_ok=True)
|
||||
STUDY_CACHE.clear()
|
||||
SERIES_SORT_CACHE.clear()
|
||||
STACK_CACHE.clear()
|
||||
rows = relation_rows_for_cache()
|
||||
total = len(rows)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -119,8 +119,7 @@
|
||||
<button class="filter" data-part="head_neck">头颈部</button>
|
||||
<button class="filter" data-part="chest">胸部</button>
|
||||
<button class="filter" data-part="upper_abdomen">上腹部</button>
|
||||
<button class="filter" data-part="lower_abdomen">下腹部</button>
|
||||
<button class="filter" data-part="pelvis">盆腔</button>
|
||||
<button class="filter" data-part="abdomen_pelvis">腹盆部</button>
|
||||
</div>
|
||||
<div class="model-filter">
|
||||
<button class="filter active" data-model-filter="">全部模型</button>
|
||||
@@ -163,13 +162,17 @@
|
||||
|
||||
<section class="tool-pane" data-tool-pane="models">
|
||||
<div class="display-segmented" id="modelDetailControls">
|
||||
<button class="active" data-model-detail="standard" type="button">标准</button>
|
||||
<button data-model-detail="standard" type="button">标准</button>
|
||||
<button data-model-detail="fine" type="button">精细</button>
|
||||
<button data-model-detail="ultra" type="button">超精细</button>
|
||||
<button class="active" data-model-detail="ultra" type="button">超精细</button>
|
||||
<button data-model-detail="solid" type="button">实体</button>
|
||||
</div>
|
||||
<div class="tool-section-title compact-title">
|
||||
<span>STL</span>
|
||||
<div class="title-actions">
|
||||
<button id="selectAllStlBtn" type="button">全选</button>
|
||||
<button id="invertStlBtn" type="button">反选</button>
|
||||
</div>
|
||||
<em id="stlCount">0</em>
|
||||
</div>
|
||||
<div id="modelRail" class="chip-rail"></div>
|
||||
@@ -233,23 +236,34 @@
|
||||
<label>迭代轮次<input id="autoIterations" type="number" min="1" max="100" value="30" /></label>
|
||||
<label>每轮候选<input id="autoCandidates" type="number" min="6" max="96" value="36" /></label>
|
||||
</div>
|
||||
<div class="pose-actions modal-actions">
|
||||
<button id="autoCoarseBtn" type="button">自动粗配准</button>
|
||||
<button id="autoFineBtn" type="button">自动微调</button>
|
||||
<div class="registration-operation-hint">
|
||||
下方为配准操作区:保存当前位姿、保存并迭代自动微调,或退回上次自动调整前位姿。
|
||||
</div>
|
||||
<div class="pose-actions modal-actions save-pose-actions">
|
||||
<button id="savePoseBtn" type="button">保存位姿</button>
|
||||
<button id="saveIterateBtn" type="button">保存并迭代</button>
|
||||
<button id="restorePoseBtn" type="button">退回位姿</button>
|
||||
</div>
|
||||
<div id="autoPoseResult" class="auto-pose-result">
|
||||
<span>旋转 X 0</span>
|
||||
<span>旋转 Y 0</span>
|
||||
<span>旋转 Z 0</span>
|
||||
<span>平移 X 0</span>
|
||||
<span>平移 Y 0</span>
|
||||
<span>平移 Z 0</span>
|
||||
<span>缩放 1</span>
|
||||
</div>
|
||||
<div class="pose-actions modal-actions">
|
||||
<button id="savePoseBtn" type="button">保存位姿</button>
|
||||
<button id="saveIterateBtn" type="button">保存并迭代</button>
|
||||
<button id="restorePoseBtn" type="button">退回位姿</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="autoResult" class="auto-result">选择 DICOM 序列和 STL 后可运行。</div>
|
||||
<div id="autoProgress" class="auto-progress hidden">
|
||||
<div class="auto-progress-head">
|
||||
<b id="autoProgressTitle">准备迭代</b>
|
||||
<span id="autoProgressText">0%</span>
|
||||
</div>
|
||||
<div class="auto-progress-track"><i id="autoProgressFill"></i></div>
|
||||
<div id="autoProgressSteps" class="auto-progress-steps"></div>
|
||||
<p id="autoProgressDetail">等待自动微调任务。</p>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="notes" class="notes" rows="2" placeholder="配准备注"></textarea>
|
||||
</section>
|
||||
@@ -279,17 +293,56 @@
|
||||
<button id="stretchXBtn" class="ghost-btn">X拉伸</button>
|
||||
<button id="stretchYBtn" class="ghost-btn">Y拉伸</button>
|
||||
<button id="stretchZBtn" class="ghost-btn">Z拉伸</button>
|
||||
<button id="fitBtn" class="ghost-btn">视角复位</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fusion-stage">
|
||||
<div id="fusionViewport"></div>
|
||||
<div class="fusion-hud left" id="fusionStatus">等待选择 DICOM 与 STL</div>
|
||||
<div class="fusion-hud right" id="fusionMeta">DICOM - · STL -</div>
|
||||
<div id="fusionViewport" aria-label="DICOM 与 STL 三维融合视图"></div>
|
||||
<div id="fusionWebglError" class="fusion-webgl-error hidden">
|
||||
<div>
|
||||
<strong>三维融合视图无法启动</strong>
|
||||
<p>请检查浏览器硬件加速、显卡驱动或远程桌面图形支持。二维 DICOM 与映射功能仍可继续使用。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fusion-hud fusion-status" id="fusionStatus">等待选择 DICOM 与 STL</div>
|
||||
<div class="fusion-hud fusion-meta" id="fusionMeta">DICOM - · STL -</div>
|
||||
<button id="fitBtn" class="fusion-reset-btn" type="button" title="重置影像与模型融合视角位置">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3 12a9 9 0 0 1 15.2-6.5L21 8.3M21 4v4.3h-4.3M21 12a9 9 0 0 1-15.2 6.5L3 15.7M3 20v-4.3h4.3" />
|
||||
</svg>
|
||||
位置重置
|
||||
</button>
|
||||
<div class="fusion-axis-inset" title="当前视角下模型平移 XYZ 方向" aria-hidden="true">
|
||||
<svg width="54" height="54" viewBox="0 0 54 54">
|
||||
<defs>
|
||||
<marker id="fusion-axis-arrow-x" markerWidth="5" markerHeight="5" refX="4.3" refY="2.5" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L5,2.5 L0,5 Z" fill="#ef4444" />
|
||||
</marker>
|
||||
<marker id="fusion-axis-arrow-y" markerWidth="5" markerHeight="5" refX="4.3" refY="2.5" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L5,2.5 L0,5 Z" fill="#22c55e" />
|
||||
</marker>
|
||||
<marker id="fusion-axis-arrow-z" markerWidth="5" markerHeight="5" refX="4.3" refY="2.5" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L5,2.5 L0,5 Z" fill="#38bdf8" />
|
||||
</marker>
|
||||
</defs>
|
||||
<circle cx="25" cy="31" r="2.2" />
|
||||
<g id="fusionAxisX">
|
||||
<line id="fusionAxisXLine" x1="25" y1="31" x2="42" y2="31" class="axis-x" marker-end="url(#fusion-axis-arrow-x)" />
|
||||
<text id="fusionAxisXText" x="46" y="28" class="axis-x-text" text-anchor="start">X</text>
|
||||
</g>
|
||||
<g id="fusionAxisY">
|
||||
<line id="fusionAxisYLine" x1="25" y1="31" x2="15" y2="41" class="axis-y" marker-end="url(#fusion-axis-arrow-y)" />
|
||||
<text id="fusionAxisYText" x="11" y="47" class="axis-y-text" text-anchor="end">Y</text>
|
||||
</g>
|
||||
<g id="fusionAxisZ">
|
||||
<line id="fusionAxisZLine" x1="25" y1="31" x2="25" y2="14" class="axis-z" marker-end="url(#fusion-axis-arrow-z)" />
|
||||
<text id="fusionAxisZText" x="29" y="11" class="axis-z-text" text-anchor="start">Z</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="fusionLoading" class="fusion-loading hidden">
|
||||
<span>正在构建融合视图</span>
|
||||
<span>正在融合三维影像与模型</span>
|
||||
<div><i id="fusionProgressFill"></i></div>
|
||||
<em id="fusionProgressText">0%</em>
|
||||
</div>
|
||||
@@ -349,7 +402,8 @@
|
||||
<div class="dicom-overlay bottom-left" id="dicomInfoBottom">Img: -</div>
|
||||
<div id="dicomLoading" class="fusion-loading hidden">
|
||||
<span>正在加载分割结果</span>
|
||||
<div><i></i></div>
|
||||
<div><i id="dicomProgressFill"></i></div>
|
||||
<em id="dicomProgressText">0%</em>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mapping-summary">
|
||||
|
||||
@@ -478,6 +478,18 @@ button {
|
||||
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,
|
||||
.series-card,
|
||||
.stl-row {
|
||||
@@ -499,30 +511,49 @@ button {
|
||||
.series-card {
|
||||
position: relative;
|
||||
padding-right: 48px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.series-check {
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
right: 13px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 !important;
|
||||
border: 1px solid rgba(25, 214, 195, 0.62);
|
||||
padding: 0;
|
||||
border: 2px solid rgba(45, 212, 191, 0.9);
|
||||
border-radius: 999px;
|
||||
background: rgba(20, 184, 166, 0.16);
|
||||
background: rgba(13, 148, 136, 0.22);
|
||||
color: #ccfbf1 !important;
|
||||
font-size: 15px !important;
|
||||
font-weight: 950 !important;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 0 1px rgba(6, 182, 212, 0.18), 0 0 18px rgba(20, 184, 166, 0.18);
|
||||
}
|
||||
|
||||
.series-card:not(.active) .series-check {
|
||||
border-color: rgba(148, 163, 184, 0.2);
|
||||
background: rgba(148, 163, 184, 0.06);
|
||||
.series-card:not(.registration-selected) .series-check {
|
||||
border-color: rgba(148, 163, 184, 0.42);
|
||||
background: rgba(15, 23, 42, 0.82);
|
||||
color: transparent !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.98);
|
||||
}
|
||||
|
||||
.series-card:not(.registration-selected) .series-check:hover,
|
||||
.series-check:focus-visible {
|
||||
border-color: rgba(45, 212, 191, 0.95);
|
||||
background: rgba(20, 184, 166, 0.18);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.series-card.registration-selected .series-check {
|
||||
border-color: rgba(153, 246, 228, 0.96);
|
||||
background: linear-gradient(180deg, rgba(20, 184, 166, 0.96), rgba(13, 148, 136, 0.82));
|
||||
color: #ecfeff !important;
|
||||
}
|
||||
|
||||
.case-card.active,
|
||||
@@ -532,6 +563,10 @@ button {
|
||||
background: linear-gradient(180deg, rgba(20, 45, 88, 0.96), rgba(10, 18, 31, 0.96));
|
||||
}
|
||||
|
||||
.series-card.registration-selected {
|
||||
border-color: rgba(20, 184, 166, 0.82);
|
||||
}
|
||||
|
||||
.series-card.skipped {
|
||||
opacity: 0.54;
|
||||
filter: grayscale(0.95);
|
||||
@@ -754,11 +789,27 @@ button {
|
||||
}
|
||||
|
||||
.tool-section-title button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #c4d7ee;
|
||||
border: 1px solid rgba(45, 212, 191, 0.34);
|
||||
border-radius: 8px;
|
||||
background: rgba(8, 17, 26, 0.62);
|
||||
color: #d9fbff;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
padding: 6px 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-section-title button:hover {
|
||||
border-color: rgba(45, 212, 191, 0.76);
|
||||
background: rgba(20, 184, 166, 0.16);
|
||||
}
|
||||
|
||||
.title-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tool-section-title em {
|
||||
@@ -967,6 +1018,10 @@ button {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.viewer-tools .ghost-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ghost-btn.accent {
|
||||
border-color: rgba(25, 214, 195, 0.56);
|
||||
color: #bffbf2;
|
||||
@@ -1036,13 +1091,25 @@ button {
|
||||
|
||||
.fusion-stage {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
min-height: 520px;
|
||||
margin: 12px 12px 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 24px;
|
||||
background: #000;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.48), 0 8px 10px -6px rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
|
||||
#fusionViewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
#fusionViewport:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
#fusionViewport canvas {
|
||||
@@ -1053,52 +1120,190 @@ button {
|
||||
|
||||
.fusion-hud {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 16px;
|
||||
z-index: 10;
|
||||
max-width: 46%;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.58);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 11px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
padding: 8px 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fusion-hud.left {
|
||||
top: 14px;
|
||||
left: 14px;
|
||||
.fusion-status {
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.fusion-hud.right {
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
.fusion-meta {
|
||||
right: 16px;
|
||||
border-color: rgba(34, 211, 238, 0.22);
|
||||
background: rgba(8, 47, 73, 0.5);
|
||||
color: #cffafe;
|
||||
}
|
||||
|
||||
.fusion-reset-btn {
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
right: 16px;
|
||||
z-index: 11;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
padding: 0 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.fusion-reset-btn:hover {
|
||||
border-color: rgba(103, 232, 249, 0.34);
|
||||
color: #cffafe;
|
||||
}
|
||||
|
||||
.fusion-reset-btn svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.fusion-axis-inset {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 10;
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 9px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
padding: 6px;
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.32);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fusion-axis-inset svg {
|
||||
display: block;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
}
|
||||
|
||||
.fusion-axis-inset line {
|
||||
stroke-width: 2.2;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.fusion-axis-inset circle {
|
||||
fill: #e5e7eb;
|
||||
}
|
||||
|
||||
.fusion-axis-inset text {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 8px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.fusion-axis-inset .axis-x {
|
||||
stroke: #ef4444;
|
||||
}
|
||||
|
||||
.fusion-axis-inset .axis-y {
|
||||
stroke: #22c55e;
|
||||
}
|
||||
|
||||
.fusion-axis-inset .axis-z {
|
||||
stroke: #38bdf8;
|
||||
}
|
||||
|
||||
.fusion-axis-inset .axis-x-text {
|
||||
fill: #fecaca;
|
||||
}
|
||||
|
||||
.fusion-axis-inset .axis-y-text {
|
||||
fill: #bbf7d0;
|
||||
}
|
||||
|
||||
.fusion-axis-inset .axis-z-text {
|
||||
fill: #bae6fd;
|
||||
}
|
||||
|
||||
.fusion-webgl-error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(2, 6, 23, 0.92);
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fusion-webgl-error > div {
|
||||
max-width: 420px;
|
||||
border: 1px solid rgba(252, 211, 77, 0.2);
|
||||
border-radius: 16px;
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.38);
|
||||
color: #fef3c7;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.fusion-webgl-error strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.fusion-webgl-error p {
|
||||
margin: 12px 0 0;
|
||||
color: rgba(254, 243, 199, 0.78);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.fusion-loading {
|
||||
position: absolute;
|
||||
left: 40px;
|
||||
right: 40px;
|
||||
bottom: 24px;
|
||||
z-index: 5;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 13px;
|
||||
background: rgba(0, 0, 0, 0.68);
|
||||
bottom: 32px;
|
||||
z-index: 12;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.fusion-loading span {
|
||||
display: block;
|
||||
display: inline-block;
|
||||
max-width: calc(100% - 54px);
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fusion-loading div {
|
||||
height: 4px;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
@@ -1114,7 +1319,7 @@ button {
|
||||
|
||||
.fusion-loading i {
|
||||
width: 0;
|
||||
background: linear-gradient(90deg, var(--cyan), var(--blue));
|
||||
background: #3b82f6;
|
||||
transition: width 0.18s ease;
|
||||
}
|
||||
|
||||
@@ -1129,10 +1334,13 @@ button {
|
||||
}
|
||||
|
||||
.fusion-loading em {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #c8f7ff;
|
||||
font-size: 11px;
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
text-align: right;
|
||||
@@ -1664,6 +1872,111 @@ input[type="range"] {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.auto-progress {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-height: 92px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.34);
|
||||
border-radius: 8px;
|
||||
background: rgba(7, 10, 15, 0.72);
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.auto-progress-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.auto-progress-head b {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #dbeafe;
|
||||
font-size: 12px;
|
||||
font-weight: 950;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auto-progress-track {
|
||||
height: 7px;
|
||||
overflow: hidden;
|
||||
border-radius: 99px;
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
.auto-progress-track i {
|
||||
display: block;
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #22d3ee, #3b82f6);
|
||||
transition: width 160ms ease;
|
||||
}
|
||||
|
||||
.auto-progress-head span {
|
||||
color: #c7d2fe;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 950;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.auto-progress-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.auto-progress-steps span {
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 7px;
|
||||
background: rgba(15, 23, 42, 0.62);
|
||||
color: #8ea3bb;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
line-height: 1.2;
|
||||
padding: 5px 3px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auto-progress-steps span.active {
|
||||
border-color: rgba(34, 211, 238, 0.68);
|
||||
background: rgba(8, 145, 178, 0.2);
|
||||
color: #cffafe;
|
||||
}
|
||||
|
||||
.auto-progress-steps span.done {
|
||||
border-color: rgba(52, 211, 153, 0.42);
|
||||
background: rgba(20, 184, 166, 0.12);
|
||||
color: #a7f3d0;
|
||||
}
|
||||
|
||||
.auto-progress p {
|
||||
margin: 0;
|
||||
color: #9fb3c8;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.pose-control.locked,
|
||||
.flip-row.locked,
|
||||
.pose-actions.locked,
|
||||
.auto-settings-panel.locked {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.pose-control button:disabled,
|
||||
.pose-control input:disabled,
|
||||
.pose-actions button:disabled,
|
||||
.flip-row button:disabled,
|
||||
.auto-box button:disabled,
|
||||
.auto-box input:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.compact-title {
|
||||
min-height: 28px;
|
||||
margin-top: 4px;
|
||||
@@ -1726,6 +2039,18 @@ input[type="range"] {
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.registration-operation-hint {
|
||||
border: 1px solid rgba(25, 214, 195, 0.26);
|
||||
border-radius: 8px;
|
||||
background: rgba(20, 184, 166, 0.08);
|
||||
color: #a7f3d0;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
line-height: 1.45;
|
||||
margin-top: 12px;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.auto-config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 0.85fr) minmax(300px, 1.15fr);
|
||||
@@ -1868,7 +2193,7 @@ input[type="range"] {
|
||||
}
|
||||
|
||||
.tool-pane .auto-pose-result {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.compact-empty {
|
||||
@@ -1876,17 +2201,50 @@ input[type="range"] {
|
||||
}
|
||||
|
||||
.auto-pose-result span {
|
||||
min-height: 36px;
|
||||
display: inline-flex;
|
||||
min-height: 50px;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
row-gap: 2px;
|
||||
border: 1px solid rgba(25, 214, 195, 0.38);
|
||||
border-radius: 10px;
|
||||
background: rgba(20, 184, 166, 0.09);
|
||||
color: #ccfbf1;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auto-pose-result span.changed {
|
||||
border-color: rgba(52, 211, 153, 0.72);
|
||||
background: rgba(20, 184, 166, 0.18);
|
||||
box-shadow: inset 0 0 0 1px rgba(20, 184, 166, 0.12);
|
||||
}
|
||||
|
||||
.auto-pose-result span.muted {
|
||||
border-color: rgba(148, 163, 184, 0.28);
|
||||
background: rgba(15, 23, 42, 0.58);
|
||||
color: #9fb3c8;
|
||||
}
|
||||
|
||||
.auto-pose-result b,
|
||||
.auto-pose-result strong,
|
||||
.auto-pose-result small {
|
||||
display: block;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.auto-pose-result strong {
|
||||
color: #e6fffb;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.auto-pose-result small {
|
||||
color: #8fb3c9;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.login-overlay {
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -42,6 +43,8 @@ DICOM_TAGS = [
|
||||
"SOPInstanceUID",
|
||||
]
|
||||
|
||||
CT_NUMBER_RE = re.compile(r"\bD?CT\d{6,}\b", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StudyRow:
|
||||
@@ -100,8 +103,22 @@ def parse_export_folder_name(name: str) -> tuple[str, str]:
|
||||
return ct_number.strip(), patient_name.strip()
|
||||
|
||||
|
||||
def normalize_ct_text(value: str) -> str:
|
||||
return value.strip().upper()
|
||||
|
||||
|
||||
def extract_ct_numbers(*values: object) -> list[str]:
|
||||
candidates: list[str] = []
|
||||
for value in values:
|
||||
for match in CT_NUMBER_RE.findall(text(value)):
|
||||
normalized = normalize_ct_text(match)
|
||||
if normalized not in candidates:
|
||||
candidates.append(normalized)
|
||||
return candidates
|
||||
|
||||
|
||||
def canonical_ct_number(accession_number: str) -> str:
|
||||
accession_number = accession_number.strip()
|
||||
accession_number = normalize_ct_text(accession_number)
|
||||
match = re.fullmatch(r"((?:D)?CT\d+)-\d+", accession_number)
|
||||
if match:
|
||||
return match.group(1)
|
||||
@@ -164,6 +181,13 @@ def scan_study_folder(batch_name: str, folder: Path, processed_batch_root: Path)
|
||||
|
||||
raw_accession_numbers = sorted(counters["AccessionNumber"])
|
||||
accession_numbers = sorted({canonical_ct_number(value) for value in raw_accession_numbers if value})
|
||||
fallback_ct_numbers = extract_ct_numbers(
|
||||
source_ct_number,
|
||||
folder.name,
|
||||
*counters["StudyID"].keys(),
|
||||
*counters["AccessionNumber"].keys(),
|
||||
*counters["PatientID"].keys(),
|
||||
)
|
||||
study_instance_uids = sorted(counters["StudyInstanceUID"])
|
||||
|
||||
status = "ok"
|
||||
@@ -174,9 +198,13 @@ def scan_study_folder(batch_name: str, folder: Path, processed_batch_root: Path)
|
||||
if raw_accession_numbers and raw_accession_numbers != accession_numbers:
|
||||
notes.append("normalized_accession_suffixes")
|
||||
if not accession_numbers:
|
||||
status = "error"
|
||||
notes.append("missing_accession_number")
|
||||
ct_number = source_ct_number
|
||||
if fallback_ct_numbers:
|
||||
notes.append("missing_accession_number_used_folder_or_dicom_fallback")
|
||||
ct_number = fallback_ct_numbers[0]
|
||||
else:
|
||||
status = "error"
|
||||
notes.append("missing_accession_number")
|
||||
ct_number = source_ct_number
|
||||
elif len(accession_numbers) > 1:
|
||||
status = "error"
|
||||
notes.append("multiple_accession_numbers")
|
||||
@@ -251,19 +279,153 @@ def hardlink_tree(source_folder: Path, target_folder: Path) -> tuple[int, int]:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
if target.stat().st_size != source.stat().st_size:
|
||||
if target.stat().st_size == source.stat().st_size:
|
||||
already_ok += 1
|
||||
continue
|
||||
else:
|
||||
raise RuntimeError(f"target exists with different size: {target}")
|
||||
target.unlink()
|
||||
try:
|
||||
os.link(source, target)
|
||||
except OSError as exc:
|
||||
if exc.errno != 18: # EXDEV
|
||||
if exc.errno not in {errno.EXDEV, errno.EPERM, errno.EOPNOTSUPP}:
|
||||
raise
|
||||
shutil.copy2(source, target)
|
||||
shutil.copyfile(source, target)
|
||||
try:
|
||||
shutil.copystat(source, target)
|
||||
except OSError as stat_exc:
|
||||
if stat_exc.errno not in {errno.EPERM, errno.EOPNOTSUPP}:
|
||||
raise
|
||||
linked += 1
|
||||
return linked, already_ok
|
||||
|
||||
|
||||
def write_sync_sql(path: Path, manifest_path: Path) -> None:
|
||||
sql = f"""BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.pacs_dicom_files (
|
||||
ct_number text PRIMARY KEY,
|
||||
batch_name text NOT NULL,
|
||||
source_folder_name text,
|
||||
source_ct_number text,
|
||||
source_patient_name text,
|
||||
target_folder_name text,
|
||||
needs_ct_number_fix boolean NOT NULL DEFAULT false,
|
||||
patient_name_dicom text,
|
||||
patient_id text,
|
||||
patient_birth_date text,
|
||||
patient_sex text,
|
||||
study_date text,
|
||||
study_time text,
|
||||
study_id text,
|
||||
modality text,
|
||||
body_part_examined text,
|
||||
protocol_name text,
|
||||
study_description text,
|
||||
accession_numbers text,
|
||||
raw_accession_numbers text,
|
||||
study_instance_uids text,
|
||||
series_count integer,
|
||||
dicom_file_count integer,
|
||||
total_file_count integer,
|
||||
total_bytes bigint,
|
||||
source_path text,
|
||||
processed_path text,
|
||||
status text,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TEMP TABLE pacs_dicom_files_stage (
|
||||
batch_name text,
|
||||
source_folder_name text,
|
||||
source_ct_number text,
|
||||
source_patient_name text,
|
||||
ct_number text,
|
||||
target_folder_name text,
|
||||
needs_ct_number_fix text,
|
||||
patient_name_dicom text,
|
||||
patient_id text,
|
||||
patient_birth_date text,
|
||||
patient_sex text,
|
||||
study_date text,
|
||||
study_time text,
|
||||
study_id text,
|
||||
modality text,
|
||||
body_part_examined text,
|
||||
protocol_name text,
|
||||
study_description text,
|
||||
accession_numbers text,
|
||||
raw_accession_numbers text,
|
||||
study_instance_uids text,
|
||||
series_count text,
|
||||
dicom_file_count text,
|
||||
total_file_count text,
|
||||
total_bytes text,
|
||||
source_path text,
|
||||
processed_path text,
|
||||
status text,
|
||||
notes text
|
||||
) ON COMMIT DROP;
|
||||
|
||||
\\copy pacs_dicom_files_stage FROM '{manifest_path}' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8')
|
||||
|
||||
INSERT INTO public.pacs_dicom_files (
|
||||
ct_number, batch_name, source_folder_name, source_ct_number, source_patient_name,
|
||||
target_folder_name, needs_ct_number_fix, patient_name_dicom, patient_id,
|
||||
patient_birth_date, patient_sex, study_date, study_time, study_id, modality,
|
||||
body_part_examined, protocol_name, study_description, accession_numbers,
|
||||
raw_accession_numbers, study_instance_uids, series_count, dicom_file_count,
|
||||
total_file_count, total_bytes, source_path, processed_path, status, notes, updated_at
|
||||
)
|
||||
SELECT
|
||||
ct_number, batch_name, source_folder_name, source_ct_number, source_patient_name,
|
||||
target_folder_name, COALESCE(NULLIF(needs_ct_number_fix, '')::boolean, false),
|
||||
patient_name_dicom, patient_id, patient_birth_date, patient_sex, study_date,
|
||||
study_time, study_id, modality, body_part_examined, protocol_name,
|
||||
study_description, accession_numbers, raw_accession_numbers, study_instance_uids,
|
||||
NULLIF(series_count, '')::integer,
|
||||
NULLIF(dicom_file_count, '')::integer,
|
||||
NULLIF(total_file_count, '')::integer,
|
||||
NULLIF(total_bytes, '')::bigint,
|
||||
source_path, processed_path, status, notes, now()
|
||||
FROM pacs_dicom_files_stage
|
||||
ON CONFLICT (ct_number) DO UPDATE SET
|
||||
batch_name = EXCLUDED.batch_name,
|
||||
source_folder_name = EXCLUDED.source_folder_name,
|
||||
source_ct_number = EXCLUDED.source_ct_number,
|
||||
source_patient_name = EXCLUDED.source_patient_name,
|
||||
target_folder_name = EXCLUDED.target_folder_name,
|
||||
needs_ct_number_fix = EXCLUDED.needs_ct_number_fix,
|
||||
patient_name_dicom = EXCLUDED.patient_name_dicom,
|
||||
patient_id = EXCLUDED.patient_id,
|
||||
patient_birth_date = EXCLUDED.patient_birth_date,
|
||||
patient_sex = EXCLUDED.patient_sex,
|
||||
study_date = EXCLUDED.study_date,
|
||||
study_time = EXCLUDED.study_time,
|
||||
study_id = EXCLUDED.study_id,
|
||||
modality = EXCLUDED.modality,
|
||||
body_part_examined = EXCLUDED.body_part_examined,
|
||||
protocol_name = EXCLUDED.protocol_name,
|
||||
study_description = EXCLUDED.study_description,
|
||||
accession_numbers = EXCLUDED.accession_numbers,
|
||||
raw_accession_numbers = EXCLUDED.raw_accession_numbers,
|
||||
study_instance_uids = EXCLUDED.study_instance_uids,
|
||||
series_count = EXCLUDED.series_count,
|
||||
dicom_file_count = EXCLUDED.dicom_file_count,
|
||||
total_file_count = EXCLUDED.total_file_count,
|
||||
total_bytes = EXCLUDED.total_bytes,
|
||||
source_path = EXCLUDED.source_path,
|
||||
processed_path = EXCLUDED.processed_path,
|
||||
status = EXCLUDED.status,
|
||||
notes = EXCLUDED.notes,
|
||||
updated_at = now();
|
||||
|
||||
COMMIT;
|
||||
"""
|
||||
path.write_text(sql, encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--source", type=Path, required=True)
|
||||
@@ -272,11 +434,12 @@ def main() -> int:
|
||||
parser.add_argument("--batch-name", default="")
|
||||
parser.add_argument("--apply", action="store_true", help="create processed hardlink tree")
|
||||
parser.add_argument("--write-file-manifest", action="store_true")
|
||||
parser.add_argument("--legacy-batch-layout", action="store_true", help="store processed studies under processed-root/batch-name")
|
||||
args = parser.parse_args()
|
||||
|
||||
source_root = args.source.resolve()
|
||||
batch_name = args.batch_name or source_root.name
|
||||
processed_batch_root = (args.processed_root / batch_name).resolve()
|
||||
processed_batch_root = (args.processed_root / batch_name if args.legacy_batch_layout else args.processed_root).absolute()
|
||||
result_dir = (args.results_root / batch_name).resolve()
|
||||
result_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -311,6 +474,7 @@ def main() -> int:
|
||||
study_fieldnames = list(asdict(study_rows[0]).keys()) if study_rows else list(StudyRow.__dataclass_fields__)
|
||||
|
||||
write_csv(result_dir / "study_manifest.csv", study_dicts, study_fieldnames)
|
||||
write_sync_sql(result_dir / "sync_pacs_dicom_files.sql", (result_dir / "study_manifest.csv").resolve())
|
||||
write_csv(
|
||||
result_dir / "rename_plan.csv",
|
||||
[
|
||||
|
||||
@@ -43,7 +43,7 @@ uvicorn app:app --host 127.0.0.1 --port 8107
|
||||
- 图像叠层:可显示/隐藏患者、检查、张数、窗宽窗位和缩放信息。
|
||||
- DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。
|
||||
- 序列列表:默认按拍摄时间升序排序,可切换时间升降序或张数升降序;略过/不采用序列固定在最下方,并在略过/不采用分组内继续按当前排序规则排列。
|
||||
- 序列标注:选择略过/不采用、平扫 CT、头颈部、胸部、上腹部、下腹部、盆腔;略过/不采用可与部位共存;DICOM 标识含 CHEST/ABDOMEN 时会自动预选胸部/上腹部;切换界面前会提示保存;上腹部需继续选择动脉期、门静脉期、延迟期或无法判别,胸部需继续选择肺窗、纵隔窗或无法判别。
|
||||
- 序列标注:选择略过/不采用、头颈部、胸部、上腹部、腹盆部;略过/不采用可与部位共存;DICOM 标识含 CHEST/ABDOMEN 时会自动预选胸部/上腹部;切换界面前会提示保存;上腹部需继续选择动脉期、门静脉期、延迟期或无法判别,胸部需继续选择肺窗、纵隔窗或无法判别。
|
||||
- AI 识别:配置并启用 Kimi 后,可把序列张数及轴位原始、矢状位重建、冠状位重建代表图像传入 AI,自动给出部位、期相和备注建议。
|
||||
- 设置:管理员独立页面,支持用户创建、角色权限展示、数据库状态、AI 开关、检查摘要立刻更新。
|
||||
- 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`,人工标注和 AI 标注分别记录。
|
||||
|
||||
@@ -15,7 +15,7 @@ import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -32,6 +32,15 @@ from starlette.background import BackgroundTask
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
PACS_ROOT = APP_DIR.parent
|
||||
STATIC_DIR = APP_DIR / "static"
|
||||
LOCAL_TZ = timezone(timedelta(hours=8), "Asia/Shanghai")
|
||||
|
||||
|
||||
def local_now() -> datetime:
|
||||
return datetime.now(LOCAL_TZ)
|
||||
|
||||
|
||||
def local_time_text() -> str:
|
||||
return local_now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def load_env_file() -> None:
|
||||
@@ -68,16 +77,16 @@ WINDOWS = {
|
||||
"contrast": (90.0, 140.0),
|
||||
}
|
||||
|
||||
BODY_PART_ORDER = ["head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"]
|
||||
BODY_PART_ORDER = ["head_neck", "chest", "upper_abdomen", "abdomen_pelvis"]
|
||||
BODY_PARTS = set(BODY_PART_ORDER)
|
||||
BODY_PART_ALIASES = {"lower_abdomen": "abdomen_pelvis", "pelvis": "abdomen_pelvis"}
|
||||
PHASES = {"plain", "arterial", "portal_venous", "delayed", "unknown", ""}
|
||||
CHEST_WINDOWS = {"lung", "mediastinal", "unknown", ""}
|
||||
BODY_PART_LABELS = {
|
||||
"head_neck": "头颈部",
|
||||
"chest": "胸部",
|
||||
"upper_abdomen": "上腹部",
|
||||
"lower_abdomen": "下腹部",
|
||||
"pelvis": "盆腔",
|
||||
"abdomen_pelvis": "腹盆部",
|
||||
}
|
||||
PHASE_LABELS = {"plain": "平扫", "arterial": "动脉期", "portal_venous": "门静脉期", "delayed": "延迟期", "unknown": "无法判别"}
|
||||
CHEST_WINDOW_LABELS = {"lung": "肺窗", "mediastinal": "纵隔窗", "unknown": "无法判别"}
|
||||
@@ -471,6 +480,11 @@ def settings_page() -> FileResponse:
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
@app.get("/favicon.ico")
|
||||
def favicon() -> Response:
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
def login(data: LoginIn) -> dict[str, str]:
|
||||
if authenticate_web_user(data.username, data.password):
|
||||
@@ -498,7 +512,7 @@ def status() -> dict[str, Any]:
|
||||
"database": {"ok": db_ok, "message": db_message, "host": PGHOST, "database": PGDATABASE, "table": PGTABLE, "rows": table_count},
|
||||
"dicom": {"processed_root": str(PROCESSED_ROOT), "exists": PROCESSED_ROOT.exists()},
|
||||
"ai": {"configured": bool(KIMI_API_KEY), "enabled": ai_enabled(), "provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL},
|
||||
"server_time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"server_time": local_time_text(),
|
||||
}
|
||||
|
||||
|
||||
@@ -646,6 +660,9 @@ def resolve_study_root(study: dict[str, Any]) -> Path:
|
||||
|
||||
target_folder = str(study.get("target_folder_name") or "")
|
||||
if target_folder:
|
||||
direct = PROCESSED_ROOT / target_folder
|
||||
if direct.exists():
|
||||
return direct
|
||||
direct_matches = list(PROCESSED_ROOT.glob(f"*/{target_folder}"))
|
||||
if direct_matches:
|
||||
return direct_matches[0]
|
||||
@@ -751,7 +768,7 @@ def build_series_export_zip(ct_number: str, series_uids: list[str]) -> tuple[str
|
||||
remove_file(temp_path)
|
||||
raise HTTPException(status_code=500, detail=f"导出失败:{exc}") from exc
|
||||
|
||||
filename = f"PACS_DICOM_{safe_archive_name(ct_number)}_{time.strftime('%Y%m%d_%H%M%S')}.zip"
|
||||
filename = f"PACS_DICOM_{safe_archive_name(ct_number)}_{local_now().strftime('%Y%m%d_%H%M%S')}.zip"
|
||||
return temp_path, filename
|
||||
|
||||
|
||||
@@ -811,7 +828,12 @@ def sort_key(item: tuple[Path, dict[str, str]]) -> tuple[float, float, str]:
|
||||
def valid_parts(parts: Any) -> list[str]:
|
||||
if not isinstance(parts, list):
|
||||
return []
|
||||
return [part for part in parts if part in BODY_PARTS]
|
||||
normalized: list[str] = []
|
||||
for value in parts:
|
||||
part = BODY_PART_ALIASES.get(str(value), str(value))
|
||||
if part in BODY_PARTS and part not in normalized:
|
||||
normalized.append(part)
|
||||
return normalized
|
||||
|
||||
|
||||
def valid_phase(value: Any) -> str:
|
||||
@@ -1162,7 +1184,7 @@ def study_summary_payload(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"unannotated_series": max(0, total - annotated),
|
||||
"undetermined_series": undetermined,
|
||||
"body_parts": body_parts,
|
||||
"summary_updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"summary_updated_at": local_time_text(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1197,12 +1219,17 @@ def save_study_summary(summary: dict[str, Any]) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def all_ct_numbers() -> list[str]:
|
||||
def all_ct_numbers(missing_summary_only: bool = False) -> list[str]:
|
||||
where = ""
|
||||
if missing_summary_only:
|
||||
where = "WHERE s.ct_number IS NULL OR COALESCE(s.series_count, 0) = 0"
|
||||
rows = pg_json_rows(
|
||||
f"""
|
||||
SELECT ct_number
|
||||
FROM public.{PGTABLE}
|
||||
ORDER BY study_date DESC NULLS LAST, study_time DESC NULLS LAST, ct_number
|
||||
SELECT p.ct_number
|
||||
FROM public.{PGTABLE} p
|
||||
LEFT JOIN public.pacs_dicom_study_summaries s USING (ct_number)
|
||||
{where}
|
||||
ORDER BY p.study_date DESC NULLS LAST, p.study_time DESC NULLS LAST, p.ct_number
|
||||
""",
|
||||
timeout=20,
|
||||
)
|
||||
@@ -1212,7 +1239,7 @@ def all_ct_numbers() -> list[str]:
|
||||
def refresh_all_study_summaries(reason: str = "manual") -> None:
|
||||
if not SUMMARY_REFRESH_LOCK.acquire(blocking=False):
|
||||
return
|
||||
SUMMARY_REFRESH_STATE.update({"running": True, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), "finished_at": "", "message": f"{reason} 刷新中"})
|
||||
SUMMARY_REFRESH_STATE.update({"running": True, "started_at": local_time_text(), "finished_at": "", "message": f"{reason} 刷新中"})
|
||||
try:
|
||||
numbers = all_ct_numbers()
|
||||
for index, ct_number in enumerate(numbers, start=1):
|
||||
@@ -1223,16 +1250,16 @@ def refresh_all_study_summaries(reason: str = "manual") -> None:
|
||||
SUMMARY_REFRESH_STATE["message"] = f"{reason} 刷新中:{index}/{len(numbers)}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
SUMMARY_REFRESH_STATE["message"] = f"{ct_number} 刷新失败:{exc}"
|
||||
SUMMARY_REFRESH_STATE.update({"finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "message": f"{reason} 刷新完成:{len(numbers)} 个检查"})
|
||||
SUMMARY_REFRESH_STATE.update({"finished_at": local_time_text(), "message": f"{reason} 刷新完成:{len(numbers)} 个检查"})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
SUMMARY_REFRESH_STATE.update({"finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "message": f"{reason} 刷新失败:{exc}"})
|
||||
SUMMARY_REFRESH_STATE.update({"finished_at": local_time_text(), "message": f"{reason} 刷新失败:{exc}"})
|
||||
finally:
|
||||
SUMMARY_REFRESH_STATE["running"] = False
|
||||
SUMMARY_REFRESH_LOCK.release()
|
||||
|
||||
|
||||
def next_four_am() -> datetime:
|
||||
now = datetime.now()
|
||||
now = local_now()
|
||||
target = now.replace(hour=4, minute=0, second=0, microsecond=0)
|
||||
if target <= now:
|
||||
target += timedelta(days=1)
|
||||
@@ -1241,7 +1268,7 @@ def next_four_am() -> datetime:
|
||||
|
||||
def summary_scheduler_loop() -> None:
|
||||
while True:
|
||||
sleep_seconds = max(60.0, (next_four_am() - datetime.now()).total_seconds())
|
||||
sleep_seconds = max(60.0, (next_four_am() - local_now()).total_seconds())
|
||||
time.sleep(sleep_seconds)
|
||||
refresh_all_study_summaries("凌晨4点定时")
|
||||
|
||||
@@ -1642,7 +1669,7 @@ def save_annotation_payload(
|
||||
"skipped": skipped,
|
||||
"ai_skipped": ai_skipped,
|
||||
"notes": notes,
|
||||
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"updated_at": local_now().strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"ai_model": ai_model or series_row.get("annotation", {}).get("ai_model", ""),
|
||||
"updated_by": user,
|
||||
}
|
||||
@@ -1741,7 +1768,7 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen
|
||||
"text": (
|
||||
"请根据这组CT序列的代表图像判断该序列所属部位。"
|
||||
"可选部位键: head_neck(头颈部), chest(胸部), upper_abdomen(上腹部), "
|
||||
"lower_abdomen(下腹部), pelvis(盆腔)。一个序列可包含多个部位。"
|
||||
"abdomen_pelvis(腹盆部)。一个序列可包含多个部位。"
|
||||
"如果不是可用于标注的平扫CT影像、定位像、剂量报告或无法判断,请 skipped=true。"
|
||||
"如果包含上腹部,请判断期相: plain(平扫)、arterial(动脉期)、portal_venous(门静脉期)、delayed(延迟期)、unknown(无法判别)。"
|
||||
"如果包含胸部,请判断窗位: lung(肺窗)、mediastinal(纵隔窗)、unknown(无法判别)。"
|
||||
@@ -1824,13 +1851,18 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen
|
||||
|
||||
@app.get("/api/settings")
|
||||
def settings(_: str = Depends(require_admin)) -> dict[str, Any]:
|
||||
summary_refresh = {
|
||||
**SUMMARY_REFRESH_STATE,
|
||||
"timezone": "Asia/Shanghai",
|
||||
"next_scheduled_at": next_four_am().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
return {
|
||||
"users": web_users(),
|
||||
"roles": [{"name": name, "permissions": permissions} for name, permissions in ROLES.items()],
|
||||
"database": {"host": PGHOST, "port": PGPORT, "database": PGDATABASE, "table": PGTABLE},
|
||||
"ai": {"provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL, "configured": bool(KIMI_API_KEY), "enabled": ai_enabled(), "url": KIMI_API_URL},
|
||||
"dicom": {"processed_root": str(PROCESSED_ROOT)},
|
||||
"summary_refresh": SUMMARY_REFRESH_STATE,
|
||||
"summary_refresh": summary_refresh,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@ const app = {
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||
const BODY_PARTS = ["head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"];
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const BODY_PARTS = ["head_neck", "chest", "upper_abdomen", "abdomen_pelvis"];
|
||||
const WINDOW_PRESETS = {
|
||||
bone: { wl: 500, ww: 1800 },
|
||||
soft: { wl: 50, ww: 360 },
|
||||
@@ -139,8 +140,7 @@ function partLabel(value) {
|
||||
head_neck: "头颈部",
|
||||
chest: "胸部",
|
||||
upper_abdomen: "上腹部",
|
||||
lower_abdomen: "下腹部",
|
||||
pelvis: "盆腔",
|
||||
abdomen_pelvis: "腹盆部",
|
||||
}[value] || value;
|
||||
}
|
||||
|
||||
@@ -443,6 +443,23 @@ function initialCtNumber() {
|
||||
return new URLSearchParams(location.search).get("ct_number") || "";
|
||||
}
|
||||
|
||||
function siblingSystemUrl(port, params = {}) {
|
||||
const url = new URL(window.location.href);
|
||||
url.port = String(port);
|
||||
url.pathname = "/";
|
||||
url.search = "";
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) url.searchParams.set(key, value);
|
||||
});
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function openSiblingSystem(kind) {
|
||||
const ctNumber = app.study?.ct_number || initialCtNumber();
|
||||
const port = kind === "registration" ? 8109 : 8108;
|
||||
window.open(siblingSystemUrl(port, { ct_number: ctNumber }), "_blank");
|
||||
}
|
||||
|
||||
async function loadStudies(preferredCtNumber = "") {
|
||||
const q = encodeURIComponent($("studySearch").value.trim());
|
||||
app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`));
|
||||
@@ -1323,7 +1340,8 @@ async function renderSettingsPage() {
|
||||
<div>
|
||||
<div class="settings-title"><h3>检查摘要</h3><span>${refresh.running ? "刷新中" : "待命"}</span></div>
|
||||
<dl>
|
||||
<dt>计划刷新</dt><dd>每天 04:00</dd>
|
||||
<dt>计划刷新</dt><dd>每天 04:00(北京时间,全量)</dd>
|
||||
<dt>下次刷新</dt><dd>${escapeHtml(refresh.next_scheduled_at || "-")}</dd>
|
||||
<dt>最近开始</dt><dd>${escapeHtml(refresh.started_at || "-")}</dd>
|
||||
<dt>最近完成</dt><dd>${escapeHtml(refresh.finished_at || "-")}</dd>
|
||||
<dt>状态</dt><dd>${escapeHtml(refresh.message || "-")}</dd>
|
||||
@@ -1382,6 +1400,15 @@ async function refreshSummariesNow() {
|
||||
button.disabled = true;
|
||||
button.textContent = "已开始刷新";
|
||||
await json("/api/settings/refresh-summaries", { method: "POST", body: JSON.stringify({}) });
|
||||
let refresh = {};
|
||||
for (let i = 0; i < 300; i += 1) {
|
||||
await sleep(2000);
|
||||
const settings = await json("/api/settings");
|
||||
refresh = settings.summary_refresh || {};
|
||||
button.textContent = refresh.running ? refresh.message || "刷新中" : "刷新完成";
|
||||
if (!refresh.running) break;
|
||||
}
|
||||
await loadStudies(app.study?.ct_number || initialCtNumber());
|
||||
await renderSettingsPage();
|
||||
}
|
||||
|
||||
@@ -1455,6 +1482,8 @@ function wire() {
|
||||
$("loginForm").addEventListener("submit", login);
|
||||
$("logoutBtn").addEventListener("click", logout);
|
||||
$("settingsBtn").addEventListener("click", openSettings);
|
||||
$("registrationLinkBtn").addEventListener("click", () => openSiblingSystem("registration"));
|
||||
$("relationLinkBtn").addEventListener("click", () => openSiblingSystem("relation"));
|
||||
$("backToViewer").addEventListener("click", () => showViewerPage(true));
|
||||
$("infoBtn").addEventListener("click", openInfo);
|
||||
$("aiClassify").addEventListener("click", runAI);
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<button id="registrationLinkBtn" class="ghost-btn nav-btn" type="button">进入配准系统</button>
|
||||
<button id="relationLinkBtn" class="ghost-btn nav-btn accent" type="button">数据库关联可视化</button>
|
||||
<div id="dbStatus" class="status-pill offline">数据库</div>
|
||||
<button id="settingsBtn" class="ghost-btn">设置</button>
|
||||
<button id="logoutBtn" class="dark-btn">退出</button>
|
||||
@@ -60,8 +62,7 @@
|
||||
<button data-study-part-filter="head_neck">头颈部</button>
|
||||
<button data-study-part-filter="chest">胸部</button>
|
||||
<button data-study-part-filter="upper_abdomen">上腹部</button>
|
||||
<button data-study-part-filter="lower_abdomen">下腹部</button>
|
||||
<button data-study-part-filter="pelvis">盆腔</button>
|
||||
<button data-study-part-filter="abdomen_pelvis">腹盆部</button>
|
||||
</div>
|
||||
<input id="studySearch" class="search" placeholder="搜索检查号 / 姓名" />
|
||||
<div id="studyList" class="study-list"></div>
|
||||
@@ -136,8 +137,7 @@
|
||||
<label><input type="checkbox" value="head_neck" />头颈部</label>
|
||||
<label><input type="checkbox" value="chest" />胸部</label>
|
||||
<label><input type="checkbox" value="upper_abdomen" />上腹部</label>
|
||||
<label><input type="checkbox" value="lower_abdomen" />下腹部</label>
|
||||
<label><input type="checkbox" value="pelvis" />盆腔</label>
|
||||
<label><input type="checkbox" value="abdomen_pelvis" />腹盆部</label>
|
||||
</div>
|
||||
<div id="phaseBox" class="phase-box">
|
||||
<span>上腹部期相</span>
|
||||
|
||||
@@ -242,6 +242,22 @@ button:disabled {
|
||||
background: #070a10;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
min-width: 118px;
|
||||
color: #d8e8ff;
|
||||
}
|
||||
|
||||
.nav-btn.accent {
|
||||
border-color: rgba(25, 212, 194, 0.42);
|
||||
color: #c7fff7;
|
||||
background: rgba(25, 212, 194, 0.08);
|
||||
}
|
||||
|
||||
.nav-btn.accent:hover {
|
||||
border-color: rgba(25, 212, 194, 0.72);
|
||||
background: rgba(25, 212, 194, 0.14);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
height: calc(100vh - 66px);
|
||||
display: grid;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
- 同一 CT 号有多个 STL 目录时,优先选择 STL 文件数更多的目录。
|
||||
- 文件数相同,则选择病例名或 `*-STL` 目录中序号更大的目录。
|
||||
- 规范化输出到 `UPP_STL处理/已处理STL数据/<CT号>/`。
|
||||
- 每个批次处理完成后,先核对待处理批次中的唯一 CT 号都已在 `已处理STL数据` 中生成对应 STL,再删除 `待处理STL数据/<批次目录>`,避免后续重复扫描和重复入库。
|
||||
- 数据库中 `upp_exam_assets.ct_number` 是主键,可用 list 的检查号查询对应 STL 路径;未来 CT 数据也可以继续挂在同一行。
|
||||
|
||||
运行:
|
||||
|
||||
11
UPP_数据库构建/UPP_STL处理记录.md
Normal file
11
UPP_数据库构建/UPP_STL处理记录.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# UPP STL 处理记录
|
||||
|
||||
## 2026-05-29
|
||||
|
||||
- `UPP重建-肝胆外科-2026_5_22` 已核对完成:待处理批次含 853 个 STL 候选目录、726 个唯一 CT、21453 个 STL 文件;`UPP_STL处理/已处理STL数据` 中 726 个 CT 均有对应 STL,缺失 0 个。
|
||||
- 已删除 `UPP_STL处理/待处理STL数据/UPP重建-肝胆外科-2026_5_22`。
|
||||
|
||||
## 后续约定
|
||||
|
||||
- 每次批次同步/整理完成后,先核对 `已处理STL数据` 覆盖待处理批次中的唯一 CT 号。
|
||||
- 核对通过后删除 `待处理STL数据/<批次目录>`,避免重复处理。
|
||||
@@ -111,6 +111,16 @@
|
||||
"contains": "肝胆外科",
|
||||
"业务分类1": "PACS",
|
||||
"业务分类2": "肝胆外科"
|
||||
},
|
||||
{
|
||||
"contains": "泌尿外科",
|
||||
"业务分类1": "PACS",
|
||||
"业务分类2": "泌尿外科"
|
||||
},
|
||||
{
|
||||
"contains": "胸外科",
|
||||
"业务分类1": "PACS",
|
||||
"业务分类2": "胸外科"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -176,11 +176,13 @@ def main() -> None:
|
||||
config = load_config(Path(args.config))
|
||||
root = Path(args.root)
|
||||
suffix = args.result_suffix or config.get("result_suffix") or "-列表归档结果"
|
||||
official_root = root / normalize_text(config.get("processed_input_root"))
|
||||
search_root = official_root if official_root.exists() else root
|
||||
archives: list[dict[str, Any]] = []
|
||||
batch_summaries: list[dict[str, Any]] = []
|
||||
raw_records: list[dict[str, Any]] = []
|
||||
|
||||
for result_dir in sorted(root.rglob(f"*{suffix}")):
|
||||
for result_dir in sorted(search_root.rglob(f"*{suffix}")):
|
||||
archive_path = result_dir / "图片表格_结构化.json"
|
||||
if not archive_path.exists():
|
||||
continue
|
||||
|
||||
@@ -351,16 +351,18 @@ def relation_cte() -> str:
|
||||
WHEN 'delayed' THEN '延迟期'
|
||||
ELSE '无法判别'
|
||||
END
|
||||
WHEN 'lower_abdomen' THEN '下腹部'
|
||||
WHEN 'pelvis' THEN '盆腔'
|
||||
WHEN 'abdomen_pelvis' THEN '腹盆部'
|
||||
WHEN 'lower_abdomen' THEN '腹盆部'
|
||||
WHEN 'pelvis' THEN '腹盆部'
|
||||
ELSE NULL
|
||||
END AS label,
|
||||
CASE part.value
|
||||
WHEN 'head_neck' THEN 1
|
||||
WHEN 'chest' THEN 2
|
||||
WHEN 'upper_abdomen' THEN 3
|
||||
WHEN 'abdomen_pelvis' THEN 4
|
||||
WHEN 'lower_abdomen' THEN 4
|
||||
WHEN 'pelvis' THEN 5
|
||||
WHEN 'pelvis' THEN 4
|
||||
ELSE 99
|
||||
END AS sort_key
|
||||
FROM public.{PACS_ANNOTATION_TABLE_SQL} a
|
||||
@@ -527,10 +529,16 @@ def relation_cte() -> str:
|
||||
r AS (
|
||||
SELECT
|
||||
upper(ct_number) AS ct_key,
|
||||
count(*)::int AS registration_count,
|
||||
count(DISTINCT series_instance_uid) FILTER (WHERE series_instance_uid <> '')::int AS registered_series,
|
||||
count(*) FILTER (WHERE locked)::int AS locked_count,
|
||||
max(updated_at) AS registration_updated_at
|
||||
count(*) FILTER (WHERE COALESCE(registration_status, 'unregistered') = 'registered')::int AS registration_count,
|
||||
count(DISTINCT series_instance_uid) FILTER (
|
||||
WHERE COALESCE(registration_status, 'unregistered') = 'registered'
|
||||
AND series_instance_uid <> ''
|
||||
)::int AS registered_series,
|
||||
count(*) FILTER (
|
||||
WHERE COALESCE(registration_status, 'unregistered') = 'registered'
|
||||
AND COALESCE(locked, false)
|
||||
)::int AS locked_count,
|
||||
max(updated_at) FILTER (WHERE COALESCE(registration_status, 'unregistered') = 'registered') AS registration_updated_at
|
||||
FROM public.{REGISTRATION_TABLE_SQL}
|
||||
GROUP BY upper(ct_number)
|
||||
),
|
||||
@@ -587,8 +595,7 @@ def relation_cte() -> str:
|
||||
CASE WHEN ps.body_parts ? 'head_neck' THEN '头颈部' END,
|
||||
CASE WHEN ps.body_parts ? 'chest' THEN '胸部' END,
|
||||
CASE WHEN ps.body_parts ? 'upper_abdomen' THEN '上腹部' END,
|
||||
CASE WHEN ps.body_parts ? 'lower_abdomen' THEN '下腹部' END,
|
||||
CASE WHEN ps.body_parts ? 'pelvis' THEN '盆腔' END
|
||||
CASE WHEN ps.body_parts ? 'abdomen_pelvis' OR ps.body_parts ? 'lower_abdomen' OR ps.body_parts ? 'pelvis' THEN '腹盆部' END
|
||||
], NULL)) AS dicom_body_parts,
|
||||
u.patient_name AS upp_patient_name,
|
||||
COALESCE(u.patient_names, '[]'::jsonb) AS upp_patient_names,
|
||||
@@ -683,10 +690,13 @@ def relation_where(q: str, status: str, algorithm_model: str, dicom_part: str) -
|
||||
if algorithm_model:
|
||||
clauses.append(f"algorithm_model = {sql_literal(algorithm_model)}")
|
||||
if dicom_part:
|
||||
valid_parts = {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"}
|
||||
valid_parts = {"head_neck", "chest", "upper_abdomen", "abdomen_pelvis"}
|
||||
if dicom_part not in valid_parts:
|
||||
raise HTTPException(status_code=400, detail="invalid DICOM part filter")
|
||||
clauses.append(f"body_parts ? {sql_literal(dicom_part)}")
|
||||
if dicom_part == "abdomen_pelvis":
|
||||
clauses.append("(body_parts ? 'abdomen_pelvis' OR body_parts ? 'lower_abdomen' OR body_parts ? 'pelvis')")
|
||||
else:
|
||||
clauses.append(f"body_parts ? {sql_literal(dicom_part)}")
|
||||
return "WHERE " + " AND ".join(clauses) if clauses else ""
|
||||
|
||||
|
||||
|
||||
@@ -130,8 +130,7 @@ function partLabel(value) {
|
||||
head_neck: "头颈部",
|
||||
chest: "胸部",
|
||||
upper_abdomen: "上腹部",
|
||||
lower_abdomen: "下腹部",
|
||||
pelvis: "盆腔",
|
||||
abdomen_pelvis: "腹盆部",
|
||||
}[value] || value;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,8 +47,7 @@
|
||||
<option value="head_neck">头颈部</option>
|
||||
<option value="chest">胸部</option>
|
||||
<option value="upper_abdomen">上腹部</option>
|
||||
<option value="lower_abdomen">下腹部</option>
|
||||
<option value="pelvis">盆腔</option>
|
||||
<option value="abdomen_pelvis">腹盆部</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-grid">
|
||||
|
||||
Reference in New Issue
Block a user