Compare commits

18 Commits

Author SHA1 Message Date
Codex
5dc20cef56 Use fixed bright bone reward 2026-05-31 01:58:57 +08:00
Codex
2fa26320bc Add activated bone brightness scoring 2026-05-31 01:40:41 +08:00
Codex
747dd97949 Tighten bright bone scoring 2026-05-31 01:38:24 +08:00
Codex
e2be49677f Compact STL list and clarify auto iteration 2026-05-31 01:27:32 +08:00
Codex
5cac15fbed Group liver STL models by anatomy 2026-05-31 01:03:50 +08:00
Codex
b13715ac4e Refine STL visibility and pose panels 2026-05-30 16:51:44 +08:00
Codex
cad4257a4b Align registration status and crisp slice masks 2026-05-30 15:40:51 +08:00
Codex
30f73937aa Improve registration iteration feedback 2026-05-30 14:17:55 +08:00
Codex
40e18ed45d Update PACS DICOM navigation and summaries 2026-05-30 13:29:31 +08:00
Codex
c1fbdd8bac Improve auto pose refinement controls 2026-05-30 10:52:11 +08:00
Codex
bdd0db2a4c Update DICOM UPP fusion workspace view 2026-05-30 10:37:44 +08:00
Codex
86e25d0a51 Show automatic pose adjustment deltas 2026-05-29 17:16:02 +08:00
Codex
692c50e22c Optimize DICOM UPP registration lazy loading and fusion views 2026-05-29 16:53:01 +08:00
Codex
1d6f04061a Document UPP STL cleanup record 2026-05-29 14:22:14 +08:00
Codex
aecfa957ef Improve UPP list batch merge filtering 2026-05-29 13:56:42 +08:00
Codex
4bb482c4b0 Improve segmentation mapping render 2026-05-29 11:24:48 +08:00
Codex
adb2f41213 Improve registration fusion interaction 2026-05-29 11:00:43 +08:00
Codex
989303ec35 Cache STL workspace visibility toggles 2026-05-29 09:55:13 +08:00
17 changed files with 2902 additions and 545 deletions

View File

@@ -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
@@ -592,14 +594,20 @@ def annotation_labels_sql() -> str:
return f""" return f"""
SELECT SELECT
a.ct_number, 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 COALESCE(jsonb_agg(DISTINCT
CASE part.value CASE part.value
WHEN 'head_neck' THEN '头颈部' WHEN 'head_neck' THEN '头颈部'
WHEN 'chest' THEN '胸部' WHEN 'chest' THEN '胸部'
WHEN 'upper_abdomen' THEN '上腹部' WHEN 'upper_abdomen' THEN '上腹部'
WHEN 'lower_abdomen' THEN '腹部' WHEN 'abdomen_pelvis' THEN ''
WHEN 'pelvis' THEN '盆腔' WHEN 'lower_abdomen' THEN '腹盆部'
WHEN 'pelvis' THEN '腹盆部'
ELSE NULL ELSE NULL
END END
) FILTER (WHERE part.value IS NOT NULL), '[]'::jsonb) AS body_part_labels ) 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"), 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():
@@ -685,12 +694,20 @@ def cases(
) )
if status_filter in {"registered", "unregistered"}: if status_filter in {"registered", "unregistered"}:
clauses.append(f"registration_status = {sql_literal(status_filter)}") 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)}") clauses.append(f"body_part_keys ? {sql_literal(body_part)}")
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 +718,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 +764,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)
@@ -772,6 +814,9 @@ def resolve_study_root(study: dict[str, Any]) -> Path:
return root return root
target_folder = str(study.get("target_folder_name") or "") target_folder = str(study.get("target_folder_name") or "")
if target_folder and PROCESSED_ROOT.exists(): 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) found = next(PROCESSED_ROOT.rglob(target_folder), None)
if found: if found:
return found return found
@@ -807,10 +852,8 @@ def annotation_labels(row: dict[str, Any]) -> list[str]:
elif part == "upper_abdomen": elif part == "upper_abdomen":
phase = {"plain": "平扫", "arterial": "动脉期", "portal_venous": "门脉期", "delayed": "延迟期", "unknown": "无法判别"}.get(row.get("upper_abdomen_phase") or "unknown", "无法判别") phase = {"plain": "平扫", "arterial": "动脉期", "portal_venous": "门脉期", "delayed": "延迟期", "unknown": "无法判别"}.get(row.get("upper_abdomen_phase") or "unknown", "无法判别")
labels.append(f"上腹部-{phase}") labels.append(f"上腹部-{phase}")
elif part == "lower_abdomen": elif part in {"abdomen_pelvis", "lower_abdomen", "pelvis"}:
labels.append("腹部") labels.append("")
elif part == "pelvis":
labels.append("盆腔")
return labels return labels
@@ -823,29 +866,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 +926,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 +1010,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()
@@ -996,6 +1061,8 @@ def dicom_fusion_volume(
series_uid: str, series_uid: str,
center_index: int = 0, center_index: int = 0,
window: str = "soft", 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), radius: int = Query(default=96, ge=4, le=512),
range_start: int | None = None, range_start: int | None = None,
range_end: int | None = None, range_end: int | None = None,
@@ -1014,19 +1081,22 @@ 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 = 72 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))
else: else:
indices = list(range(start, end + 1)) 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) 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 = [] frames = []
frame_width = 0 frame_width = 0
frame_height = 0 frame_height = 0
for index in indices: 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")) frames.append("data:image/png;base64," + base64.b64encode(png).decode("ascii"))
if not frame_width: if not frame_width:
with Image.open(io.BytesIO(png)) as pil: with Image.open(io.BytesIO(png)) as pil:
@@ -1036,7 +1106,7 @@ def dicom_fusion_volume(
"indices": indices, "indices": indices,
"start": start, "start": start,
"end": end, "end": end,
"center": center_index, "center": focus_index,
"total": total, "total": total,
"width": frame_width, "width": frame_width,
"height": frame_height, "height": frame_height,
@@ -1221,6 +1291,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)

File diff suppressed because it is too large Load Diff

View File

@@ -119,8 +119,7 @@
<button class="filter" data-part="head_neck">头颈部</button> <button class="filter" data-part="head_neck">头颈部</button>
<button class="filter" data-part="chest">胸部</button> <button class="filter" data-part="chest">胸部</button>
<button class="filter" data-part="upper_abdomen">上腹部</button> <button class="filter" data-part="upper_abdomen">上腹部</button>
<button class="filter" data-part="lower_abdomen">腹部</button> <button class="filter" data-part="abdomen_pelvis"></button>
<button class="filter" data-part="pelvis">盆腔</button>
</div> </div>
<div class="model-filter"> <div class="model-filter">
<button class="filter active" data-model-filter="">全部模型</button> <button class="filter active" data-model-filter="">全部模型</button>
@@ -163,16 +162,15 @@
<section class="tool-pane" data-tool-pane="models"> <section class="tool-pane" data-tool-pane="models">
<div class="display-segmented" id="modelDetailControls"> <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="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> <button data-model-detail="solid" type="button">实体</button>
</div> </div>
<div class="tool-section-title compact-title"> <div class="tool-section-title compact-title">
<span>STL</span> <span>STL</span>
<div class="title-actions"> <div class="title-actions model-visibility-actions">
<button id="selectAllStlBtn" type="button">全选</button> <button id="cycleAllStlModeBtn" class="model-eye-btn" type="button" title="切换全部 STL 显示状态" aria-label="切换全部 STL 显示状态"><span></span></button>
<button id="invertStlBtn" type="button">反选</button>
</div> </div>
<em id="stlCount">0</em> <em id="stlCount">0</em>
</div> </div>
@@ -183,26 +181,30 @@
<section class="tool-pane" data-tool-pane="pose"> <section class="tool-pane" data-tool-pane="pose">
<div class="tool-section-title"> <div class="tool-section-title">
<span>位姿手动调整</span> <span>位姿手动调整</span>
<button id="poseManualToggle" class="section-toggle" type="button" title="收起/展开位姿手动调整" aria-label="收起或展开位姿手动调整"><span></span></button>
<em id="saveState">未保存</em> <em id="saveState">未保存</em>
</div> </div>
<div class="pose-actions"> <div id="poseManualBody" class="pose-section-body">
<button id="resetRotationBtn" type="button">重置旋转</button> <div class="pose-actions">
<button id="resetTransformBtn" type="button">重置平移缩放</button> <button id="resetRotationBtn" type="button">重置旋转</button>
<button id="resetFlipBtn" type="button">重置镜像</button> <button id="resetTransformBtn" type="button">重置平移缩放</button>
<button id="resetFlipBtn" type="button">重置镜像</button>
</div>
<div class="flip-row">
<button data-flip="flipX">镜像 X</button>
<button data-flip="flipY">镜像 Y</button>
<button data-flip="flipZ">镜像 Z</button>
</div>
<div id="poseGrid" class="pose-grid"></div>
</div> </div>
<div class="flip-row">
<button data-flip="flipX">镜像 X</button>
<button data-flip="flipY">镜像 Y</button>
<button data-flip="flipZ">镜像 Z</button>
</div>
<div id="poseGrid" class="pose-grid"></div>
<div class="tool-section-title"> <div class="tool-section-title">
<span>位姿自动调整</span> <span>位姿自动调整</span>
<button id="poseAutoToggle" class="section-toggle" type="button" title="收起/展开位姿自动调整" aria-label="收起或展开位姿自动调整"><span></span></button>
<em id="autoState">未运行</em> <em id="autoState">未运行</em>
</div> </div>
<div class="auto-box compact-auto"> <div id="poseAutoBody" class="pose-section-body auto-box compact-auto">
<button id="openAutoModalBtn" class="wide-action" type="button">自动调整设置</button> <button id="openAutoModalBtn" class="wide-action" type="button">恢复初始设置</button>
<div id="autoSettingsPanel" class="auto-settings-panel"> <div id="autoSettingsPanel" class="auto-settings-panel">
<div class="auto-options modal-options"> <div class="auto-options modal-options">
<label><input id="autoX" type="checkbox" checked /> X 方向</label> <label><input id="autoX" type="checkbox" checked /> X 方向</label>
@@ -229,6 +231,7 @@
</div> </div>
</section> </section>
</div> </div>
<div class="iteration-section-title">迭代参数</div>
<div class="auto-weight-grid"> <div class="auto-weight-grid">
<label>命中奖励<input id="autoBoneReward" type="number" step="0.1" value="1" /></label> <label>命中奖励<input id="autoBoneReward" type="number" step="0.1" value="1" /></label>
<label>区域惩罚<input id="autoOutsidePenalty" type="number" step="0.05" value="0.1" /></label> <label>区域惩罚<input id="autoOutsidePenalty" type="number" step="0.05" value="0.1" /></label>
@@ -237,23 +240,32 @@
<label>迭代轮次<input id="autoIterations" type="number" min="1" max="100" value="30" /></label> <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> <label>每轮候选<input id="autoCandidates" type="number" min="6" max="96" value="36" /></label>
</div> </div>
<div class="pose-actions modal-actions"> <div class="iteration-section-title">迭代操作区</div>
<button id="autoCoarseBtn" type="button">自动粗配准</button> <div class="pose-actions modal-actions save-pose-actions">
<button id="autoFineBtn" type="button">自动微调</button> <button id="savePoseBtn" type="button">保存位姿</button>
<button id="saveIterateBtn" type="button">保存并迭代</button>
<button id="restorePoseBtn" type="button">退回位姿</button>
</div> </div>
<div id="autoPoseResult" class="auto-pose-result"> <div id="autoPoseResult" class="auto-pose-result">
<span>旋转 X 0</span>
<span>旋转 Y 0</span>
<span>旋转 Z 0</span>
<span>平移 X 0</span> <span>平移 X 0</span>
<span>平移 Y 0</span> <span>平移 Y 0</span>
<span>平移 Z 0</span> <span>平移 Z 0</span>
<span>缩放 1</span> <span>缩放 1</span>
</div> </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>
<div id="autoResult" class="auto-result">选择 DICOM 序列和 STL 后可运行。</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> </div>
<textarea id="notes" class="notes" rows="2" placeholder="配准备注"></textarea> <textarea id="notes" class="notes" rows="2" placeholder="配准备注"></textarea>
</section> </section>
@@ -283,17 +295,56 @@
<button id="stretchXBtn" class="ghost-btn">X拉伸</button> <button id="stretchXBtn" class="ghost-btn">X拉伸</button>
<button id="stretchYBtn" class="ghost-btn">Y拉伸</button> <button id="stretchYBtn" class="ghost-btn">Y拉伸</button>
<button id="stretchZBtn" class="ghost-btn">Z拉伸</button> <button id="stretchZBtn" class="ghost-btn">Z拉伸</button>
<button id="fitBtn" class="ghost-btn">视角复位</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="fusion-stage"> <div class="fusion-stage">
<div id="fusionViewport"></div> <div id="fusionViewport" aria-label="DICOM 与 STL 三维融合视图"></div>
<div class="fusion-hud left" id="fusionStatus">等待选择 DICOM 与 STL</div> <div id="fusionWebglError" class="fusion-webgl-error hidden">
<div class="fusion-hud right" id="fusionMeta">DICOM - · STL -</div> <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"> <div id="fusionLoading" class="fusion-loading hidden">
<span>正在构建融合视图</span> <span>正在融合三维影像与模型</span>
<div><i id="fusionProgressFill"></i></div> <div><i id="fusionProgressFill"></i></div>
<em id="fusionProgressText">0%</em> <em id="fusionProgressText">0%</em>
</div> </div>
@@ -353,7 +404,8 @@
<div class="dicom-overlay bottom-left" id="dicomInfoBottom">Img: -</div> <div class="dicom-overlay bottom-left" id="dicomInfoBottom">Img: -</div>
<div id="dicomLoading" class="fusion-loading hidden"> <div id="dicomLoading" class="fusion-loading hidden">
<span>正在加载分割结果</span> <span>正在加载分割结果</span>
<div><i></i></div> <div><i id="dicomProgressFill"></i></div>
<em id="dicomProgressText">0%</em>
</div> </div>
</div> </div>
<div class="mapping-summary"> <div class="mapping-summary">

View File

@@ -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 {
@@ -800,6 +812,98 @@ button {
margin-left: auto; margin-left: auto;
} }
.model-visibility-actions {
gap: 5px;
}
.tool-section-title button.model-eye-btn {
width: 34px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.model-eye-btn span {
position: relative;
width: 18px;
height: 13px;
display: block;
}
.model-eye-btn span::before {
content: "";
position: absolute;
inset: 1px 0;
border: 2px solid #c8f7ff;
border-radius: 50% / 60%;
}
.model-eye-btn span::after {
content: "";
position: absolute;
top: 5px;
left: 7px;
width: 5px;
height: 5px;
border-radius: 999px;
background: #c8f7ff;
}
.solid-eye span::before {
background: rgba(45, 212, 191, 0.28);
}
.translucent-eye span::before {
background:
linear-gradient(45deg, rgba(15, 23, 42, 0.82) 25%, transparent 25% 50%, rgba(15, 23, 42, 0.82) 50% 75%, transparent 75%) 0 0 / 5px 5px,
rgba(45, 212, 191, 0.22);
}
.mixed-eye span::before {
border-color: #93c5fd;
background: linear-gradient(90deg, rgba(45, 212, 191, 0.26) 0 50%, rgba(15, 23, 42, 0.74) 50% 100%);
}
.mixed-eye span::after {
background: #93c5fd;
}
.hidden-eye span::after {
top: 5px;
left: -2px;
width: 22px;
height: 2px;
border-radius: 99px;
background: #cbd5e1;
transform: rotate(-38deg);
}
.section-toggle {
width: 30px;
height: 28px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: auto;
padding: 0 !important;
}
.section-toggle span {
width: 8px;
height: 8px;
border-right: 2px solid #c8f7ff;
border-bottom: 2px solid #c8f7ff;
transform: rotate(45deg);
transition: transform 0.16s ease;
}
.section-toggle.collapsed span {
transform: rotate(-45deg);
}
.tool-section-title em { .tool-section-title em {
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
@@ -1006,6 +1110,10 @@ button {
opacity: 0.62; opacity: 0.62;
} }
.viewer-tools .ghost-btn:disabled {
cursor: not-allowed;
}
.ghost-btn.accent { .ghost-btn.accent {
border-color: rgba(25, 214, 195, 0.56); border-color: rgba(25, 214, 195, 0.56);
color: #bffbf2; color: #bffbf2;
@@ -1075,13 +1183,25 @@ button {
.fusion-stage { .fusion-stage {
position: relative; 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; 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 { #fusionViewport {
position: absolute; position: absolute;
inset: 0; inset: 0;
cursor: grab;
touch-action: none;
}
#fusionViewport:active {
cursor: grabbing;
} }
#fusionViewport canvas { #fusionViewport canvas {
@@ -1092,52 +1212,190 @@ button {
.fusion-hud { .fusion-hud {
position: absolute; position: absolute;
z-index: 4; top: 16px;
z-index: 10;
max-width: 46%; max-width: 46%;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px; border-radius: 12px;
background: rgba(0, 0, 0, 0.58); background: rgba(0, 0, 0, 0.6);
color: rgba(255, 255, 255, 0.72); color: rgba(255, 255, 255, 0.62);
font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 10px;
font-weight: 800; font-weight: 800;
line-height: 1.2;
padding: 8px 10px; padding: 8px 10px;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.fusion-hud.left { .fusion-status {
top: 14px; left: 16px;
left: 14px;
} }
.fusion-hud.right { .fusion-meta {
top: 14px; right: 16px;
right: 14px; 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 { .fusion-loading {
position: absolute; position: absolute;
left: 40px; left: 40px;
right: 40px; right: 40px;
bottom: 24px; bottom: 32px;
z-index: 5; z-index: 12;
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 13px; border-radius: 12px;
background: rgba(0, 0, 0, 0.68); background: rgba(0, 0, 0, 0.7);
padding: 12px; padding: 12px;
} }
.fusion-loading span { .fusion-loading span {
display: block; display: inline-block;
max-width: calc(100% - 54px);
margin-bottom: 8px; margin-bottom: 8px;
overflow: hidden;
color: rgba(255, 255, 255, 0.72); color: rgba(255, 255, 255, 0.72);
font-size: 12px; font-size: 10px;
font-weight: 900; font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
} }
.fusion-loading div { .fusion-loading div {
height: 4px; height: 8px;
overflow: hidden; overflow: hidden;
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.12);
@@ -1153,7 +1411,7 @@ button {
.fusion-loading i { .fusion-loading i {
width: 0; width: 0;
background: linear-gradient(90deg, var(--cyan), var(--blue)); background: #3b82f6;
transition: width 0.18s ease; transition: width 0.18s ease;
} }
@@ -1168,10 +1426,13 @@ button {
} }
.fusion-loading em { .fusion-loading em {
position: absolute;
top: 12px;
right: 12px;
display: block; display: block;
margin-top: 6px; margin: 0;
color: #c8f7ff; color: rgba(255, 255, 255, 0.72);
font-size: 11px; font-size: 10px;
font-style: normal; font-style: normal;
font-weight: 900; font-weight: 900;
text-align: right; text-align: right;
@@ -1317,17 +1578,18 @@ button {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
gap: 12px; gap: 0;
border-left: 1px solid rgba(148, 163, 184, 0.18); border-left: 1px solid rgba(148, 163, 184, 0.18);
background: #0f172a; background: #0f172a;
padding: 12px 0 38px;
} }
.mapping-slice-rail::before { .mapping-slice-rail::before {
content: ""; content: "";
position: absolute; position: absolute;
top: 26px; top: 12px;
bottom: 56px; bottom: 38px;
left: 50%; left: 50%;
width: 8px; width: 8px;
transform: translateX(-50%); transform: translateX(-50%);
@@ -1338,15 +1600,19 @@ button {
#mappingSliceSlider { #mappingSliceSlider {
position: relative; position: relative;
z-index: 1; z-index: 1;
width: min(66vh, 520px); width: 34px;
height: 34px; height: 100%;
transform: rotate(-90deg); min-height: 180px;
transform: none;
writing-mode: vertical-lr;
direction: rtl;
appearance: none; appearance: none;
background: transparent; background: transparent;
} }
#mappingSliceSlider::-webkit-slider-runnable-track { #mappingSliceSlider::-webkit-slider-runnable-track {
height: 8px; width: 8px;
height: 100%;
border-radius: 999px; border-radius: 999px;
background: rgba(148, 163, 184, 0.2); background: rgba(148, 163, 184, 0.2);
} }
@@ -1355,7 +1621,8 @@ button {
appearance: none; appearance: none;
width: 25px; width: 25px;
height: 25px; height: 25px;
margin-top: -8.5px; margin-left: -8.5px;
margin-top: 0;
border: 3px solid #93c5fd; border: 3px solid #93c5fd;
border-radius: 7px; border-radius: 7px;
background: #60a5fa; background: #60a5fa;
@@ -1363,7 +1630,8 @@ button {
} }
#mappingSliceSlider::-moz-range-track { #mappingSliceSlider::-moz-range-track {
height: 8px; width: 8px;
height: 100%;
border-radius: 999px; border-radius: 999px;
background: rgba(148, 163, 184, 0.2); background: rgba(148, 163, 184, 0.2);
} }
@@ -1542,26 +1810,154 @@ input[type="range"] {
.stl-row { .stl-row {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) 30px;
gap: 10px; gap: 12px;
align-items: start; align-items: center;
padding: 10px; min-height: 38px;
padding: 7px 10px;
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease, opacity 0.16s ease;
} }
.stl-row input { .stl-row.stl-mode-hidden {
margin-top: 3px; opacity: 0.62;
accent-color: var(--cyan); background: rgba(8, 12, 18, 0.78);
} }
.stl-row strong { .stl-row strong {
display: block; display: block;
overflow: hidden; overflow: hidden;
color: #e8f1fb; color: #e8f1fb;
font-size: 13px; font-size: 12px;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.stl-file-name {
color: var(--stl-color) !important;
}
.stl-family {
margin-bottom: 7px;
}
.stl-family-head {
width: 100%;
min-height: 30px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto 14px;
align-items: center;
gap: 8px;
margin: 0 0 7px;
border: 1px solid rgba(45, 212, 191, 0.35);
border-radius: 10px;
background: rgba(20, 184, 166, 0.08);
color: #dffcff;
font-size: 12px;
font-weight: 950;
text-align: left;
padding: 0 10px;
cursor: pointer;
}
.stl-family-head span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stl-family-head em {
color: #9debf4;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
font-style: normal;
}
.stl-family-head i {
width: 8px;
height: 8px;
border-right: 2px solid #9debf4;
border-bottom: 2px solid #9debf4;
transform: rotate(45deg);
transition: transform 0.16s ease;
}
.stl-family.collapsed .stl-family-list {
display: none;
}
.stl-family.collapsed .stl-family-head i {
transform: rotate(-45deg);
}
.stl-hierarchy-group {
margin-bottom: 8px;
}
.stl-hierarchy-group .stl-family-head {
min-height: 32px;
margin-bottom: 5px;
border-color: rgba(148, 163, 184, 0.22);
border-radius: 7px;
background: linear-gradient(180deg, rgba(31, 41, 59, 0.94), rgba(15, 23, 42, 0.94));
color: #eef7ff;
}
.stl-hierarchy-group:not(.collapsed) .stl-family-head {
border-color: rgba(45, 212, 191, 0.58);
box-shadow: inset 3px 0 0 rgba(45, 212, 191, 0.85);
}
.stl-hierarchy-group .stl-family-list {
margin: -2px 0 8px 12px;
padding-left: 8px;
border-left: 1px solid rgba(45, 212, 191, 0.22);
}
.stl-hierarchy-group .stl-row {
min-height: 38px;
margin-bottom: 5px;
border-radius: 8px;
}
.stl-visibility-swatch {
width: 22px;
height: 22px;
justify-self: end;
border: 2px solid rgba(226, 232, 240, 0.68);
border-radius: 5px;
background: transparent;
box-shadow: inset 0 0 0 1px rgba(2, 6, 23, 0.78);
}
.stl-visibility-swatch.solid {
border-color: color-mix(in srgb, var(--stl-color) 78%, #ffffff 22%);
background: var(--stl-color);
}
.stl-visibility-swatch.translucent {
border-color: color-mix(in srgb, var(--stl-color) 72%, #ffffff 28%);
background:
linear-gradient(45deg, rgba(2, 6, 23, 0.82) 25%, transparent 25% 50%, rgba(2, 6, 23, 0.82) 50% 75%, transparent 75%) 0 0 / 8px 8px,
color-mix(in srgb, var(--stl-color) 48%, transparent);
}
.stl-visibility-swatch.hidden {
border-color: rgba(148, 163, 184, 0.66);
background: rgba(15, 23, 42, 0.5);
}
.stl-visibility-swatch.hidden::after {
content: "";
display: block;
width: 24px;
height: 2px;
margin: 8px 0 0 -3px;
border-radius: 99px;
background: rgba(226, 232, 240, 0.76);
transform: rotate(-45deg);
}
.pose-grid { .pose-grid {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -1661,6 +2057,18 @@ input[type="range"] {
gap: 8px; gap: 8px;
} }
.pose-section-body.collapsed {
display: none;
}
.iteration-section-title {
margin: 2px 0 -2px;
color: #9debf4;
font-size: 12px;
font-weight: 950;
letter-spacing: 0;
}
.auto-settings-panel { .auto-settings-panel {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -1703,6 +2111,111 @@ input[type="range"] {
padding: 8px; 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 { .compact-title {
min-height: 28px; min-height: 28px;
margin-top: 4px; margin-top: 4px;
@@ -1907,7 +2420,7 @@ input[type="range"] {
} }
.tool-pane .auto-pose-result { .tool-pane .auto-pose-result {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.compact-empty { .compact-empty {
@@ -1915,17 +2428,50 @@ input[type="range"] {
} }
.auto-pose-result span { .auto-pose-result span {
min-height: 36px; min-height: 50px;
display: inline-flex; display: grid;
grid-template-rows: auto auto auto;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
row-gap: 2px;
border: 1px solid rgba(25, 214, 195, 0.38); border: 1px solid rgba(25, 214, 195, 0.38);
border-radius: 10px; border-radius: 10px;
background: rgba(20, 184, 166, 0.09); background: rgba(20, 184, 166, 0.09);
color: #ccfbf1; color: #ccfbf1;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px; font-size: 11px;
font-weight: 900; 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 { .login-overlay {

View File

@@ -10,6 +10,7 @@ from __future__ import annotations
import argparse import argparse
import csv import csv
import errno
import json import json
import os import os
import re import re
@@ -42,6 +43,8 @@ DICOM_TAGS = [
"SOPInstanceUID", "SOPInstanceUID",
] ]
CT_NUMBER_RE = re.compile(r"\bD?CT\d{6,}\b", re.IGNORECASE)
@dataclass @dataclass
class StudyRow: class StudyRow:
@@ -100,8 +103,22 @@ def parse_export_folder_name(name: str) -> tuple[str, str]:
return ct_number.strip(), patient_name.strip() 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: 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) match = re.fullmatch(r"((?:D)?CT\d+)-\d+", accession_number)
if match: if match:
return match.group(1) 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"]) raw_accession_numbers = sorted(counters["AccessionNumber"])
accession_numbers = sorted({canonical_ct_number(value) for value in raw_accession_numbers if value}) 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"]) study_instance_uids = sorted(counters["StudyInstanceUID"])
status = "ok" 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: if raw_accession_numbers and raw_accession_numbers != accession_numbers:
notes.append("normalized_accession_suffixes") notes.append("normalized_accession_suffixes")
if not accession_numbers: if not accession_numbers:
status = "error" if fallback_ct_numbers:
notes.append("missing_accession_number") notes.append("missing_accession_number_used_folder_or_dicom_fallback")
ct_number = source_ct_number ct_number = fallback_ct_numbers[0]
else:
status = "error"
notes.append("missing_accession_number")
ct_number = source_ct_number
elif len(accession_numbers) > 1: elif len(accession_numbers) > 1:
status = "error" status = "error"
notes.append("multiple_accession_numbers") notes.append("multiple_accession_numbers")
@@ -251,19 +279,153 @@ def hardlink_tree(source_folder: Path, target_folder: Path) -> tuple[int, int]:
continue continue
except OSError: except OSError:
pass 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}") raise RuntimeError(f"target exists with different size: {target}")
target.unlink()
try: try:
os.link(source, target) os.link(source, target)
except OSError as exc: except OSError as exc:
if exc.errno != 18: # EXDEV if exc.errno not in {errno.EXDEV, errno.EPERM, errno.EOPNOTSUPP}:
raise 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 linked += 1
return linked, already_ok 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: def main() -> int:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--source", type=Path, required=True) parser.add_argument("--source", type=Path, required=True)
@@ -272,11 +434,12 @@ def main() -> int:
parser.add_argument("--batch-name", default="") parser.add_argument("--batch-name", default="")
parser.add_argument("--apply", action="store_true", help="create processed hardlink tree") parser.add_argument("--apply", action="store_true", help="create processed hardlink tree")
parser.add_argument("--write-file-manifest", action="store_true") 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() args = parser.parse_args()
source_root = args.source.resolve() source_root = args.source.resolve()
batch_name = args.batch_name or source_root.name 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 = (args.results_root / batch_name).resolve()
result_dir.mkdir(parents=True, exist_ok=True) 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__) 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_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( write_csv(
result_dir / "rename_plan.csv", result_dir / "rename_plan.csv",
[ [

View File

@@ -43,7 +43,7 @@ uvicorn app:app --host 127.0.0.1 --port 8107
- 图像叠层:可显示/隐藏患者、检查、张数、窗宽窗位和缩放信息。 - 图像叠层:可显示/隐藏患者、检查、张数、窗宽窗位和缩放信息。
- DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。 - DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。
- 序列列表:默认按拍摄时间升序排序,可切换时间升降序或张数升降序;略过/不采用序列固定在最下方,并在略过/不采用分组内继续按当前排序规则排列。 - 序列列表:默认按拍摄时间升序排序,可切换时间升降序或张数升降序;略过/不采用序列固定在最下方,并在略过/不采用分组内继续按当前排序规则排列。
- 序列标注:选择略过/不采用、平扫 CT、头颈部、胸部、上腹部、下腹部、盆腔;略过/不采用可与部位共存DICOM 标识含 CHEST/ABDOMEN 时会自动预选胸部/上腹部;切换界面前会提示保存;上腹部需继续选择动脉期、门静脉期、延迟期或无法判别,胸部需继续选择肺窗、纵隔窗或无法判别。 - 序列标注:选择略过/不采用、头颈部、胸部、上腹部、腹盆部;略过/不采用可与部位共存DICOM 标识含 CHEST/ABDOMEN 时会自动预选胸部/上腹部;切换界面前会提示保存;上腹部需继续选择动脉期、门静脉期、延迟期或无法判别,胸部需继续选择肺窗、纵隔窗或无法判别。
- AI 识别:配置并启用 Kimi 后,可把序列张数及轴位原始、矢状位重建、冠状位重建代表图像传入 AI自动给出部位、期相和备注建议。 - AI 识别:配置并启用 Kimi 后,可把序列张数及轴位原始、矢状位重建、冠状位重建代表图像传入 AI自动给出部位、期相和备注建议。
- 设置管理员独立页面支持用户创建、角色权限展示、数据库状态、AI 开关、检查摘要立刻更新。 - 设置管理员独立页面支持用户创建、角色权限展示、数据库状态、AI 开关、检查摘要立刻更新。
- 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`,人工标注和 AI 标注分别记录。 - 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`,人工标注和 AI 标注分别记录。

View File

@@ -15,7 +15,7 @@ import urllib.error
import urllib.request import urllib.request
import zipfile import zipfile
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -32,6 +32,15 @@ from starlette.background import BackgroundTask
APP_DIR = Path(__file__).resolve().parent APP_DIR = Path(__file__).resolve().parent
PACS_ROOT = APP_DIR.parent PACS_ROOT = APP_DIR.parent
STATIC_DIR = APP_DIR / "static" 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: def load_env_file() -> None:
@@ -68,16 +77,16 @@ WINDOWS = {
"contrast": (90.0, 140.0), "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_PARTS = set(BODY_PART_ORDER)
BODY_PART_ALIASES = {"lower_abdomen": "abdomen_pelvis", "pelvis": "abdomen_pelvis"}
PHASES = {"plain", "arterial", "portal_venous", "delayed", "unknown", ""} PHASES = {"plain", "arterial", "portal_venous", "delayed", "unknown", ""}
CHEST_WINDOWS = {"lung", "mediastinal", "unknown", ""} CHEST_WINDOWS = {"lung", "mediastinal", "unknown", ""}
BODY_PART_LABELS = { BODY_PART_LABELS = {
"head_neck": "头颈部", "head_neck": "头颈部",
"chest": "胸部", "chest": "胸部",
"upper_abdomen": "上腹部", "upper_abdomen": "上腹部",
"lower_abdomen": "腹部", "abdomen_pelvis": "",
"pelvis": "盆腔",
} }
PHASE_LABELS = {"plain": "平扫", "arterial": "动脉期", "portal_venous": "门静脉期", "delayed": "延迟期", "unknown": "无法判别"} PHASE_LABELS = {"plain": "平扫", "arterial": "动脉期", "portal_venous": "门静脉期", "delayed": "延迟期", "unknown": "无法判别"}
CHEST_WINDOW_LABELS = {"lung": "肺窗", "mediastinal": "纵隔窗", "unknown": "无法判别"} CHEST_WINDOW_LABELS = {"lung": "肺窗", "mediastinal": "纵隔窗", "unknown": "无法判别"}
@@ -471,6 +480,11 @@ def settings_page() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html") return FileResponse(STATIC_DIR / "index.html")
@app.get("/favicon.ico")
def favicon() -> Response:
return Response(status_code=204)
@app.post("/api/auth/login") @app.post("/api/auth/login")
def login(data: LoginIn) -> dict[str, str]: def login(data: LoginIn) -> dict[str, str]:
if authenticate_web_user(data.username, data.password): 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}, "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()}, "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}, "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 "") target_folder = str(study.get("target_folder_name") or "")
if target_folder: if target_folder:
direct = PROCESSED_ROOT / target_folder
if direct.exists():
return direct
direct_matches = list(PROCESSED_ROOT.glob(f"*/{target_folder}")) direct_matches = list(PROCESSED_ROOT.glob(f"*/{target_folder}"))
if direct_matches: if direct_matches:
return direct_matches[0] 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) remove_file(temp_path)
raise HTTPException(status_code=500, detail=f"导出失败:{exc}") from exc 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 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]: def valid_parts(parts: Any) -> list[str]:
if not isinstance(parts, list): if not isinstance(parts, list):
return [] 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: 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), "unannotated_series": max(0, total - annotated),
"undetermined_series": undetermined, "undetermined_series": undetermined,
"body_parts": body_parts, "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 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( rows = pg_json_rows(
f""" f"""
SELECT ct_number SELECT p.ct_number
FROM public.{PGTABLE} FROM public.{PGTABLE} p
ORDER BY study_date DESC NULLS LAST, study_time DESC NULLS LAST, ct_number 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, timeout=20,
) )
@@ -1212,7 +1239,7 @@ def all_ct_numbers() -> list[str]:
def refresh_all_study_summaries(reason: str = "manual") -> None: def refresh_all_study_summaries(reason: str = "manual") -> None:
if not SUMMARY_REFRESH_LOCK.acquire(blocking=False): if not SUMMARY_REFRESH_LOCK.acquire(blocking=False):
return 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: try:
numbers = all_ct_numbers() numbers = all_ct_numbers()
for index, ct_number in enumerate(numbers, start=1): 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)}" SUMMARY_REFRESH_STATE["message"] = f"{reason} 刷新中:{index}/{len(numbers)}"
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
SUMMARY_REFRESH_STATE["message"] = f"{ct_number} 刷新失败:{exc}" 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 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: finally:
SUMMARY_REFRESH_STATE["running"] = False SUMMARY_REFRESH_STATE["running"] = False
SUMMARY_REFRESH_LOCK.release() SUMMARY_REFRESH_LOCK.release()
def next_four_am() -> datetime: def next_four_am() -> datetime:
now = datetime.now() now = local_now()
target = now.replace(hour=4, minute=0, second=0, microsecond=0) target = now.replace(hour=4, minute=0, second=0, microsecond=0)
if target <= now: if target <= now:
target += timedelta(days=1) target += timedelta(days=1)
@@ -1241,7 +1268,7 @@ def next_four_am() -> datetime:
def summary_scheduler_loop() -> None: def summary_scheduler_loop() -> None:
while True: 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) time.sleep(sleep_seconds)
refresh_all_study_summaries("凌晨4点定时") refresh_all_study_summaries("凌晨4点定时")
@@ -1642,7 +1669,7 @@ def save_annotation_payload(
"skipped": skipped, "skipped": skipped,
"ai_skipped": ai_skipped, "ai_skipped": ai_skipped,
"notes": notes, "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", ""), "ai_model": ai_model or series_row.get("annotation", {}).get("ai_model", ""),
"updated_by": user, "updated_by": user,
} }
@@ -1741,7 +1768,7 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen
"text": ( "text": (
"请根据这组CT序列的代表图像判断该序列所属部位。" "请根据这组CT序列的代表图像判断该序列所属部位。"
"可选部位键: head_neck(头颈部), chest(胸部), upper_abdomen(上腹部), " "可选部位键: head_neck(头颈部), chest(胸部), upper_abdomen(上腹部), "
"lower_abdomen(下腹部), pelvis(盆腔)。一个序列可包含多个部位。" "abdomen_pelvis(腹盆部)。一个序列可包含多个部位。"
"如果不是可用于标注的平扫CT影像、定位像、剂量报告或无法判断请 skipped=true。" "如果不是可用于标注的平扫CT影像、定位像、剂量报告或无法判断请 skipped=true。"
"如果包含上腹部,请判断期相: plain(平扫)、arterial(动脉期)、portal_venous(门静脉期)、delayed(延迟期)、unknown(无法判别)。" "如果包含上腹部,请判断期相: plain(平扫)、arterial(动脉期)、portal_venous(门静脉期)、delayed(延迟期)、unknown(无法判别)。"
"如果包含胸部,请判断窗位: lung(肺窗)、mediastinal(纵隔窗)、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") @app.get("/api/settings")
def settings(_: str = Depends(require_admin)) -> dict[str, Any]: 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 { return {
"users": web_users(), "users": web_users(),
"roles": [{"name": name, "permissions": permissions} for name, permissions in ROLES.items()], "roles": [{"name": name, "permissions": permissions} for name, permissions in ROLES.items()],
"database": {"host": PGHOST, "port": PGPORT, "database": PGDATABASE, "table": PGTABLE}, "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}, "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)}, "dicom": {"processed_root": str(PROCESSED_ROOT)},
"summary_refresh": SUMMARY_REFRESH_STATE, "summary_refresh": summary_refresh,
} }

View File

@@ -41,7 +41,8 @@ const app = {
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); 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 = { const WINDOW_PRESETS = {
bone: { wl: 500, ww: 1800 }, bone: { wl: 500, ww: 1800 },
soft: { wl: 50, ww: 360 }, soft: { wl: 50, ww: 360 },
@@ -139,8 +140,7 @@ function partLabel(value) {
head_neck: "头颈部", head_neck: "头颈部",
chest: "胸部", chest: "胸部",
upper_abdomen: "上腹部", upper_abdomen: "上腹部",
lower_abdomen: "腹部", abdomen_pelvis: "腹部",
pelvis: "盆腔",
}[value] || value; }[value] || value;
} }
@@ -443,6 +443,23 @@ function initialCtNumber() {
return new URLSearchParams(location.search).get("ct_number") || ""; 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 = "") { async function loadStudies(preferredCtNumber = "") {
const q = encodeURIComponent($("studySearch").value.trim()); const q = encodeURIComponent($("studySearch").value.trim());
app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`)); app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`));
@@ -1323,7 +1340,8 @@ async function renderSettingsPage() {
<div> <div>
<div class="settings-title"><h3>检查摘要</h3><span>${refresh.running ? "刷新中" : "待命"}</span></div> <div class="settings-title"><h3>检查摘要</h3><span>${refresh.running ? "刷新中" : "待命"}</span></div>
<dl> <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.started_at || "-")}</dd>
<dt>最近完成</dt><dd>${escapeHtml(refresh.finished_at || "-")}</dd> <dt>最近完成</dt><dd>${escapeHtml(refresh.finished_at || "-")}</dd>
<dt>状态</dt><dd>${escapeHtml(refresh.message || "-")}</dd> <dt>状态</dt><dd>${escapeHtml(refresh.message || "-")}</dd>
@@ -1382,6 +1400,15 @@ async function refreshSummariesNow() {
button.disabled = true; button.disabled = true;
button.textContent = "已开始刷新"; button.textContent = "已开始刷新";
await json("/api/settings/refresh-summaries", { method: "POST", body: JSON.stringify({}) }); 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(); await renderSettingsPage();
} }
@@ -1455,6 +1482,8 @@ function wire() {
$("loginForm").addEventListener("submit", login); $("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", logout); $("logoutBtn").addEventListener("click", logout);
$("settingsBtn").addEventListener("click", openSettings); $("settingsBtn").addEventListener("click", openSettings);
$("registrationLinkBtn").addEventListener("click", () => openSiblingSystem("registration"));
$("relationLinkBtn").addEventListener("click", () => openSiblingSystem("relation"));
$("backToViewer").addEventListener("click", () => showViewerPage(true)); $("backToViewer").addEventListener("click", () => showViewerPage(true));
$("infoBtn").addEventListener("click", openInfo); $("infoBtn").addEventListener("click", openInfo);
$("aiClassify").addEventListener("click", runAI); $("aiClassify").addEventListener("click", runAI);

View File

@@ -34,6 +34,8 @@
</div> </div>
</div> </div>
<div class="top-actions"> <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> <div id="dbStatus" class="status-pill offline">数据库</div>
<button id="settingsBtn" class="ghost-btn">设置</button> <button id="settingsBtn" class="ghost-btn">设置</button>
<button id="logoutBtn" class="dark-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="head_neck">头颈部</button>
<button data-study-part-filter="chest">胸部</button> <button data-study-part-filter="chest">胸部</button>
<button data-study-part-filter="upper_abdomen">上腹部</button> <button data-study-part-filter="upper_abdomen">上腹部</button>
<button data-study-part-filter="lower_abdomen">腹部</button> <button data-study-part-filter="abdomen_pelvis"></button>
<button data-study-part-filter="pelvis">盆腔</button>
</div> </div>
<input id="studySearch" class="search" placeholder="搜索检查号 / 姓名" /> <input id="studySearch" class="search" placeholder="搜索检查号 / 姓名" />
<div id="studyList" class="study-list"></div> <div id="studyList" class="study-list"></div>
@@ -136,8 +137,7 @@
<label><input type="checkbox" value="head_neck" />头颈部</label> <label><input type="checkbox" value="head_neck" />头颈部</label>
<label><input type="checkbox" value="chest" />胸部</label> <label><input type="checkbox" value="chest" />胸部</label>
<label><input type="checkbox" value="upper_abdomen" />上腹部</label> <label><input type="checkbox" value="upper_abdomen" />上腹部</label>
<label><input type="checkbox" value="lower_abdomen" />腹部</label> <label><input type="checkbox" value="abdomen_pelvis" /></label>
<label><input type="checkbox" value="pelvis" />盆腔</label>
</div> </div>
<div id="phaseBox" class="phase-box"> <div id="phaseBox" class="phase-box">
<span>上腹部期相</span> <span>上腹部期相</span>

View File

@@ -242,6 +242,22 @@ button:disabled {
background: #070a10; 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 { .workspace {
height: calc(100vh - 66px); height: calc(100vh - 66px);
display: grid; display: grid;

View File

@@ -8,6 +8,7 @@
- 同一 CT 号有多个 STL 目录时,优先选择 STL 文件数更多的目录。 - 同一 CT 号有多个 STL 目录时,优先选择 STL 文件数更多的目录。
- 文件数相同,则选择病例名或 `*-STL` 目录中序号更大的目录。 - 文件数相同,则选择病例名或 `*-STL` 目录中序号更大的目录。
- 规范化输出到 `UPP_STL处理/已处理STL数据/<CT号>/` - 规范化输出到 `UPP_STL处理/已处理STL数据/<CT号>/`
- 每个批次处理完成后,先核对待处理批次中的唯一 CT 号都已在 `已处理STL数据` 中生成对应 STL再删除 `待处理STL数据/<批次目录>`,避免后续重复扫描和重复入库。
- 数据库中 `upp_exam_assets.ct_number` 是主键,可用 list 的检查号查询对应 STL 路径;未来 CT 数据也可以继续挂在同一行。 - 数据库中 `upp_exam_assets.ct_number` 是主键,可用 list 的检查号查询对应 STL 路径;未来 CT 数据也可以继续挂在同一行。
运行: 运行:

View 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数据/<批次目录>`,避免重复处理。

View File

@@ -111,6 +111,16 @@
"contains": "肝胆外科", "contains": "肝胆外科",
"业务分类1": "PACS", "业务分类1": "PACS",
"业务分类2": "肝胆外科" "业务分类2": "肝胆外科"
},
{
"contains": "泌尿外科",
"业务分类1": "PACS",
"业务分类2": "泌尿外科"
},
{
"contains": "胸外科",
"业务分类1": "PACS",
"业务分类2": "胸外科"
} }
] ]
}, },

View File

@@ -176,11 +176,13 @@ def main() -> None:
config = load_config(Path(args.config)) config = load_config(Path(args.config))
root = Path(args.root) root = Path(args.root)
suffix = args.result_suffix or config.get("result_suffix") or "-列表归档结果" 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]] = [] archives: list[dict[str, Any]] = []
batch_summaries: list[dict[str, Any]] = [] batch_summaries: list[dict[str, Any]] = []
raw_records: 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" archive_path = result_dir / "图片表格_结构化.json"
if not archive_path.exists(): if not archive_path.exists():
continue continue

View File

@@ -351,16 +351,18 @@ def relation_cte() -> str:
WHEN 'delayed' THEN '延迟期' WHEN 'delayed' THEN '延迟期'
ELSE '无法判别' ELSE '无法判别'
END END
WHEN 'lower_abdomen' THEN '腹部' WHEN 'abdomen_pelvis' THEN ''
WHEN 'pelvis' THEN '盆腔' WHEN 'lower_abdomen' THEN '腹盆部'
WHEN 'pelvis' THEN '腹盆部'
ELSE NULL ELSE NULL
END AS label, END AS label,
CASE part.value CASE part.value
WHEN 'head_neck' THEN 1 WHEN 'head_neck' THEN 1
WHEN 'chest' THEN 2 WHEN 'chest' THEN 2
WHEN 'upper_abdomen' THEN 3 WHEN 'upper_abdomen' THEN 3
WHEN 'abdomen_pelvis' THEN 4
WHEN 'lower_abdomen' THEN 4 WHEN 'lower_abdomen' THEN 4
WHEN 'pelvis' THEN 5 WHEN 'pelvis' THEN 4
ELSE 99 ELSE 99
END AS sort_key END AS sort_key
FROM public.{PACS_ANNOTATION_TABLE_SQL} a FROM public.{PACS_ANNOTATION_TABLE_SQL} a
@@ -527,10 +529,16 @@ def relation_cte() -> str:
r AS ( r AS (
SELECT SELECT
upper(ct_number) AS ct_key, upper(ct_number) AS ct_key,
count(*)::int AS registration_count, count(*) FILTER (WHERE COALESCE(registration_status, 'unregistered') = 'registered')::int AS registration_count,
count(DISTINCT series_instance_uid) FILTER (WHERE series_instance_uid <> '')::int AS registered_series, count(DISTINCT series_instance_uid) FILTER (
count(*) FILTER (WHERE locked)::int AS locked_count, WHERE COALESCE(registration_status, 'unregistered') = 'registered'
max(updated_at) AS registration_updated_at 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} FROM public.{REGISTRATION_TABLE_SQL}
GROUP BY upper(ct_number) 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 ? 'head_neck' THEN '头颈部' END,
CASE WHEN ps.body_parts ? 'chest' THEN '胸部' END, CASE WHEN ps.body_parts ? 'chest' THEN '胸部' END,
CASE WHEN ps.body_parts ? 'upper_abdomen' 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 ? 'abdomen_pelvis' OR ps.body_parts ? 'lower_abdomen' OR ps.body_parts ? 'pelvis' THEN '' END
CASE WHEN ps.body_parts ? 'pelvis' THEN '盆腔' END
], NULL)) AS dicom_body_parts, ], NULL)) AS dicom_body_parts,
u.patient_name AS upp_patient_name, u.patient_name AS upp_patient_name,
COALESCE(u.patient_names, '[]'::jsonb) AS upp_patient_names, 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: if algorithm_model:
clauses.append(f"algorithm_model = {sql_literal(algorithm_model)}") clauses.append(f"algorithm_model = {sql_literal(algorithm_model)}")
if dicom_part: 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: if dicom_part not in valid_parts:
raise HTTPException(status_code=400, detail="invalid DICOM part filter") 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 "" return "WHERE " + " AND ".join(clauses) if clauses else ""

View File

@@ -130,8 +130,7 @@ function partLabel(value) {
head_neck: "头颈部", head_neck: "头颈部",
chest: "胸部", chest: "胸部",
upper_abdomen: "上腹部", upper_abdomen: "上腹部",
lower_abdomen: "腹部", abdomen_pelvis: "腹部",
pelvis: "盆腔",
}[value] || value; }[value] || value;
} }

View File

@@ -47,8 +47,7 @@
<option value="head_neck">头颈部</option> <option value="head_neck">头颈部</option>
<option value="chest">胸部</option> <option value="chest">胸部</option>
<option value="upper_abdomen">上腹部</option> <option value="upper_abdomen">上腹部</option>
<option value="lower_abdomen">腹部</option> <option value="abdomen_pelvis"></option>
<option value="pelvis">盆腔</option>
</select> </select>
</div> </div>
<div class="filter-grid"> <div class="filter-grid">