Refine PACS viewer annotation workflow
This commit is contained in:
@@ -38,13 +38,15 @@ uvicorn app:app --host 127.0.0.1 --port 8107
|
|||||||
|
|
||||||
- 左侧检查列表:来自 PostgreSQL `pacs_dicom_files`。
|
- 左侧检查列表:来自 PostgreSQL `pacs_dicom_files`。
|
||||||
- 中间序列列表:从已处理 DICOM 目录扫描 DICOM 头信息。
|
- 中间序列列表:从已处理 DICOM 目录扫描 DICOM 头信息。
|
||||||
- 右侧查看器:支持横断面、矢状面、冠状面,窗宽窗位、旋转、切片进度条。
|
- 右侧查看器:支持轴位原始、矢状位重建、冠状位重建,窗宽窗位、旋转、切片进度条。
|
||||||
- 影像操作:支持鼠标滚轮缩放、拖拽平移、按钮缩放和复位。
|
- 影像操作:支持鼠标滚轮缩放、拖拽平移、按钮缩放和复位。
|
||||||
|
- 图像叠层:可显示/隐藏患者、检查、方向、张数、窗宽窗位和缩放信息。
|
||||||
- DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。
|
- DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。
|
||||||
- 序列标注:选择略过、头颈部、胸部、上腹部、下腹部、盆腔;上腹部需继续选择动脉期、门静脉期或无法判别。
|
- 序列列表:默认按拍摄时间升序排序,可切换降序;少于 80 张且未标注部位的序列默认略过。
|
||||||
- AI 识别:配置 Kimi 后,可把序列张数及横断面、矢状面、冠状面代表图像传入 AI,自动给出部位、期相和备注建议。
|
- 序列标注:选择略过、平扫 CT、头颈部、胸部、上腹部、下腹部、盆腔;点击后自动保存;上腹部需继续选择动脉期、门静脉期或无法判别。
|
||||||
|
- AI 识别:配置 Kimi 后,可把序列张数及轴位原始、矢状位重建、冠状位重建代表图像传入 AI,自动给出部位、期相和备注建议。
|
||||||
- 设置:支持用户创建、角色权限展示、数据库状态和 AI 配置查看。
|
- 设置:支持用户创建、角色权限展示、数据库状态和 AI 配置查看。
|
||||||
- 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`。
|
- 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`,人工标注和 AI 标注分别记录。
|
||||||
|
|
||||||
## 数据安全
|
## 数据安全
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ 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]] = {}
|
||||||
STACK_CACHE: dict[str, tuple[float, np.ndarray]] = {}
|
STACK_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||||
|
|
||||||
|
|
||||||
class LoginIn(BaseModel):
|
class LoginIn(BaseModel):
|
||||||
@@ -86,9 +86,17 @@ class LoginIn(BaseModel):
|
|||||||
|
|
||||||
class AnnotationIn(BaseModel):
|
class AnnotationIn(BaseModel):
|
||||||
body_parts: list[str] = []
|
body_parts: list[str] = []
|
||||||
|
manual_body_parts: list[str] = []
|
||||||
|
ai_body_parts: list[str] = []
|
||||||
upper_abdomen_phase: str = ""
|
upper_abdomen_phase: str = ""
|
||||||
|
manual_upper_abdomen_phase: str = ""
|
||||||
|
ai_upper_abdomen_phase: str = ""
|
||||||
|
plain_ct: bool = False
|
||||||
|
manual_plain_ct: bool | None = None
|
||||||
|
ai_plain_ct: bool | None = None
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
skipped: bool = False
|
skipped: bool = False
|
||||||
|
ai_skipped: bool = False
|
||||||
|
|
||||||
|
|
||||||
class AIRequest(BaseModel):
|
class AIRequest(BaseModel):
|
||||||
@@ -174,8 +182,16 @@ def ensure_annotation_table() -> None:
|
|||||||
series_instance_uid text NOT NULL,
|
series_instance_uid text NOT NULL,
|
||||||
series_description text,
|
series_description text,
|
||||||
body_parts jsonb NOT NULL DEFAULT '[]'::jsonb,
|
body_parts jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
manual_body_parts jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
ai_body_parts jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
upper_abdomen_phase text NOT NULL DEFAULT '',
|
upper_abdomen_phase text NOT NULL DEFAULT '',
|
||||||
|
manual_upper_abdomen_phase text NOT NULL DEFAULT '',
|
||||||
|
ai_upper_abdomen_phase text NOT NULL DEFAULT '',
|
||||||
|
plain_ct boolean NOT NULL DEFAULT false,
|
||||||
|
manual_plain_ct boolean,
|
||||||
|
ai_plain_ct boolean,
|
||||||
skipped boolean NOT NULL DEFAULT false,
|
skipped boolean NOT NULL DEFAULT false,
|
||||||
|
ai_skipped boolean NOT NULL DEFAULT false,
|
||||||
notes text NOT NULL DEFAULT '',
|
notes text NOT NULL DEFAULT '',
|
||||||
ai_result jsonb,
|
ai_result jsonb,
|
||||||
ai_model text,
|
ai_model text,
|
||||||
@@ -184,12 +200,34 @@ def ensure_annotation_table() -> None:
|
|||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
PRIMARY KEY (ct_number, series_instance_uid)
|
PRIMARY KEY (ct_number, series_instance_uid)
|
||||||
);
|
);
|
||||||
|
ALTER TABLE public.pacs_dicom_series_annotations
|
||||||
|
ADD COLUMN IF NOT EXISTS manual_body_parts jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
ALTER TABLE public.pacs_dicom_series_annotations
|
||||||
|
ADD COLUMN IF NOT EXISTS ai_body_parts jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
ALTER TABLE public.pacs_dicom_series_annotations
|
||||||
|
ADD COLUMN IF NOT EXISTS manual_upper_abdomen_phase text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE public.pacs_dicom_series_annotations
|
||||||
|
ADD COLUMN IF NOT EXISTS ai_upper_abdomen_phase text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE public.pacs_dicom_series_annotations
|
||||||
|
ADD COLUMN IF NOT EXISTS plain_ct boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE public.pacs_dicom_series_annotations
|
||||||
|
ADD COLUMN IF NOT EXISTS manual_plain_ct boolean;
|
||||||
|
ALTER TABLE public.pacs_dicom_series_annotations
|
||||||
|
ADD COLUMN IF NOT EXISTS ai_plain_ct boolean;
|
||||||
ALTER TABLE public.pacs_dicom_series_annotations
|
ALTER TABLE public.pacs_dicom_series_annotations
|
||||||
ADD COLUMN IF NOT EXISTS skipped boolean NOT NULL DEFAULT false;
|
ADD COLUMN IF NOT EXISTS skipped boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE public.pacs_dicom_series_annotations
|
||||||
|
ADD COLUMN IF NOT EXISTS ai_skipped boolean NOT NULL DEFAULT false;
|
||||||
ALTER TABLE public.pacs_dicom_series_annotations
|
ALTER TABLE public.pacs_dicom_series_annotations
|
||||||
ADD COLUMN IF NOT EXISTS ai_result jsonb;
|
ADD COLUMN IF NOT EXISTS ai_result jsonb;
|
||||||
ALTER TABLE public.pacs_dicom_series_annotations
|
ALTER TABLE public.pacs_dicom_series_annotations
|
||||||
ADD COLUMN IF NOT EXISTS ai_model text;
|
ADD COLUMN IF NOT EXISTS ai_model text;
|
||||||
|
UPDATE public.pacs_dicom_series_annotations
|
||||||
|
SET manual_body_parts = body_parts,
|
||||||
|
manual_upper_abdomen_phase = upper_abdomen_phase
|
||||||
|
WHERE jsonb_array_length(body_parts) > 0
|
||||||
|
AND jsonb_array_length(manual_body_parts) = 0
|
||||||
|
AND jsonb_array_length(ai_body_parts) = 0;
|
||||||
"""
|
"""
|
||||||
pg_scalar(sql)
|
pg_scalar(sql)
|
||||||
|
|
||||||
@@ -372,6 +410,13 @@ def read_header(path: Path) -> dict[str, str]:
|
|||||||
tags = [
|
tags = [
|
||||||
"SeriesInstanceUID",
|
"SeriesInstanceUID",
|
||||||
"StudyInstanceUID",
|
"StudyInstanceUID",
|
||||||
|
"AccessionNumber",
|
||||||
|
"PatientName",
|
||||||
|
"PatientID",
|
||||||
|
"PatientBirthDate",
|
||||||
|
"PatientSex",
|
||||||
|
"InstitutionName",
|
||||||
|
"StudyDate",
|
||||||
"SeriesNumber",
|
"SeriesNumber",
|
||||||
"SeriesDescription",
|
"SeriesDescription",
|
||||||
"InstanceNumber",
|
"InstanceNumber",
|
||||||
@@ -414,11 +459,101 @@ def sort_key(item: tuple[Path, dict[str, str]]) -> tuple[float, float, str]:
|
|||||||
return (z, instance, str(path))
|
return (z, instance, str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def valid_parts(parts: Any) -> list[str]:
|
||||||
|
if not isinstance(parts, list):
|
||||||
|
return []
|
||||||
|
return [part for part in parts if part in BODY_PARTS]
|
||||||
|
|
||||||
|
|
||||||
|
def valid_phase(value: Any) -> str:
|
||||||
|
return value if value in PHASES else ""
|
||||||
|
|
||||||
|
|
||||||
|
def bool_or_none(value: Any) -> bool | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
if value.lower() in {"true", "t", "1", "yes", "y"}:
|
||||||
|
return True
|
||||||
|
if value.lower() in {"false", "f", "0", "no", "n"}:
|
||||||
|
return False
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_parts(manual_parts: list[str], ai_parts: list[str], skipped: bool) -> list[str]:
|
||||||
|
if skipped:
|
||||||
|
return []
|
||||||
|
merged: list[str] = []
|
||||||
|
for part in manual_parts + ai_parts:
|
||||||
|
if part in BODY_PARTS and part not in merged:
|
||||||
|
merged.append(part)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def effective_phase(manual_phase: str, ai_phase: str, body_parts: list[str], skipped: bool) -> str:
|
||||||
|
if skipped or "upper_abdomen" not in body_parts:
|
||||||
|
return ""
|
||||||
|
return valid_phase(manual_phase) or valid_phase(ai_phase)
|
||||||
|
|
||||||
|
|
||||||
|
def effective_plain_ct(manual_plain_ct: bool | None, ai_plain_ct: bool | None, fallback: bool, skipped: bool) -> bool:
|
||||||
|
if skipped:
|
||||||
|
return False
|
||||||
|
if manual_plain_ct is not None:
|
||||||
|
return manual_plain_ct
|
||||||
|
if ai_plain_ct is not None:
|
||||||
|
return ai_plain_ct
|
||||||
|
return bool(fallback)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_annotation(row: dict[str, Any] | None, default_skipped: bool = False) -> dict[str, Any]:
|
||||||
|
row = row or {}
|
||||||
|
manual_parts = valid_parts(row.get("manual_body_parts") or [])
|
||||||
|
ai_parts = valid_parts(row.get("ai_body_parts") or [])
|
||||||
|
if not manual_parts and not ai_parts:
|
||||||
|
manual_parts = valid_parts(row.get("body_parts") or [])
|
||||||
|
skipped = bool(row.get("skipped", default_skipped))
|
||||||
|
ai_skipped = bool(row.get("ai_skipped", False))
|
||||||
|
manual_phase = valid_phase(row.get("manual_upper_abdomen_phase") or "")
|
||||||
|
ai_phase = valid_phase(row.get("ai_upper_abdomen_phase") or "")
|
||||||
|
if not manual_phase and not ai_phase:
|
||||||
|
manual_phase = valid_phase(row.get("upper_abdomen_phase") or "")
|
||||||
|
body_parts = merge_parts(manual_parts, ai_parts, skipped)
|
||||||
|
plain_ct = effective_plain_ct(
|
||||||
|
bool_or_none(row.get("manual_plain_ct")),
|
||||||
|
bool_or_none(row.get("ai_plain_ct")),
|
||||||
|
bool(row.get("plain_ct", False)),
|
||||||
|
skipped,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"body_parts": body_parts,
|
||||||
|
"manual_body_parts": [] if skipped else manual_parts,
|
||||||
|
"ai_body_parts": [] if skipped else ai_parts,
|
||||||
|
"upper_abdomen_phase": effective_phase(manual_phase, ai_phase, body_parts, skipped),
|
||||||
|
"manual_upper_abdomen_phase": "" if skipped else manual_phase,
|
||||||
|
"ai_upper_abdomen_phase": "" if skipped else ai_phase,
|
||||||
|
"plain_ct": plain_ct,
|
||||||
|
"manual_plain_ct": None if skipped else bool_or_none(row.get("manual_plain_ct")),
|
||||||
|
"ai_plain_ct": None if skipped else bool_or_none(row.get("ai_plain_ct")),
|
||||||
|
"skipped": skipped,
|
||||||
|
"ai_skipped": ai_skipped,
|
||||||
|
"notes": row.get("notes", ""),
|
||||||
|
"updated_at": row.get("updated_at", ""),
|
||||||
|
"ai_model": row.get("ai_model", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_annotations(ct_number: str) -> dict[str, dict[str, Any]]:
|
def get_annotations(ct_number: str) -> dict[str, dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
|
ensure_annotation_table()
|
||||||
rows = pg_json_rows(
|
rows = pg_json_rows(
|
||||||
f"""
|
f"""
|
||||||
SELECT series_instance_uid, body_parts, upper_abdomen_phase, skipped, notes, updated_at, ai_model
|
SELECT
|
||||||
|
series_instance_uid, body_parts, manual_body_parts, ai_body_parts,
|
||||||
|
upper_abdomen_phase, manual_upper_abdomen_phase, ai_upper_abdomen_phase,
|
||||||
|
plain_ct, manual_plain_ct, ai_plain_ct, skipped, ai_skipped, notes, updated_at, ai_model
|
||||||
FROM public.pacs_dicom_series_annotations
|
FROM public.pacs_dicom_series_annotations
|
||||||
WHERE ct_number = {sql_literal(ct_number)}
|
WHERE ct_number = {sql_literal(ct_number)}
|
||||||
"""
|
"""
|
||||||
@@ -450,17 +585,35 @@ def scan_study(ct_number: str) -> dict[str, Any]:
|
|||||||
annotations = get_annotations(ct_number)
|
annotations = get_annotations(ct_number)
|
||||||
series_list = []
|
series_list = []
|
||||||
file_map = {}
|
file_map = {}
|
||||||
|
default_skip_rows = []
|
||||||
for uid, items in grouped.items():
|
for uid, items in grouped.items():
|
||||||
items.sort(key=sort_key)
|
items.sort(key=sort_key)
|
||||||
first = items[0][1]
|
first = items[0][1]
|
||||||
last = items[-1][1]
|
last = items[-1][1]
|
||||||
file_map[uid] = [path for path, _ in items]
|
file_map[uid] = [path for path, _ in items]
|
||||||
annotation = annotations.get(uid, {})
|
raw_annotation = annotations.get(uid)
|
||||||
|
annotation = normalize_annotation(raw_annotation)
|
||||||
|
default_skipped = len(items) < 80 and not annotation.get("body_parts") and not annotation.get("plain_ct")
|
||||||
|
if default_skipped:
|
||||||
|
annotation["skipped"] = True
|
||||||
|
annotation["manual_body_parts"] = []
|
||||||
|
annotation["ai_body_parts"] = []
|
||||||
|
annotation["body_parts"] = []
|
||||||
|
annotation["plain_ct"] = False
|
||||||
|
if default_skipped:
|
||||||
|
default_skip_rows.append((uid, first, len(items)))
|
||||||
series_list.append(
|
series_list.append(
|
||||||
{
|
{
|
||||||
"ct_number": ct_number,
|
"ct_number": ct_number,
|
||||||
"series_uid": uid,
|
"series_uid": uid,
|
||||||
"study_uid": first.get("StudyInstanceUID", ""),
|
"study_uid": first.get("StudyInstanceUID", ""),
|
||||||
|
"accession_number": first.get("AccessionNumber", ""),
|
||||||
|
"patient_name": first.get("PatientName", ""),
|
||||||
|
"patient_id": first.get("PatientID", ""),
|
||||||
|
"patient_birth_date": first.get("PatientBirthDate", ""),
|
||||||
|
"patient_sex": first.get("PatientSex", ""),
|
||||||
|
"institution_name": first.get("InstitutionName", ""),
|
||||||
|
"study_date": first.get("StudyDate", "") or study.get("study_date", ""),
|
||||||
"series_number": first.get("SeriesNumber", ""),
|
"series_number": first.get("SeriesNumber", ""),
|
||||||
"description": first.get("SeriesDescription", "") or "未命名序列",
|
"description": first.get("SeriesDescription", "") or "未命名序列",
|
||||||
"count": len(items),
|
"count": len(items),
|
||||||
@@ -476,24 +629,75 @@ def scan_study(ct_number: str) -> dict[str, Any]:
|
|||||||
"pixel_spacing": first.get("PixelSpacing", ""),
|
"pixel_spacing": first.get("PixelSpacing", ""),
|
||||||
"slice_thickness": first.get("SliceThickness", ""),
|
"slice_thickness": first.get("SliceThickness", ""),
|
||||||
"spacing_between_slices": first.get("SpacingBetweenSlices", ""),
|
"spacing_between_slices": first.get("SpacingBetweenSlices", ""),
|
||||||
"annotation": {
|
"window_center": first.get("WindowCenter", ""),
|
||||||
"body_parts": annotation.get("body_parts", []),
|
"window_width": first.get("WindowWidth", ""),
|
||||||
"upper_abdomen_phase": annotation.get("upper_abdomen_phase", ""),
|
"default_skipped": default_skipped,
|
||||||
"skipped": bool(annotation.get("skipped", False)),
|
"annotation": annotation,
|
||||||
"notes": annotation.get("notes", ""),
|
|
||||||
"updated_at": annotation.get("updated_at", ""),
|
|
||||||
"ai_model": annotation.get("ai_model", ""),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for uid, first, count in default_skip_rows:
|
||||||
|
try:
|
||||||
|
pg_scalar(
|
||||||
|
f"""
|
||||||
|
INSERT INTO public.pacs_dicom_series_annotations (
|
||||||
|
ct_number, study_instance_uid, series_instance_uid, series_description,
|
||||||
|
body_parts, manual_body_parts, ai_body_parts, upper_abdomen_phase,
|
||||||
|
plain_ct, skipped, notes, updated_by, updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
{sql_literal(ct_number)},
|
||||||
|
{sql_literal(first.get('StudyInstanceUID', ''))},
|
||||||
|
{sql_literal(uid)},
|
||||||
|
{sql_literal(first.get('SeriesDescription', '') or '未命名序列')},
|
||||||
|
'[]'::jsonb,
|
||||||
|
'[]'::jsonb,
|
||||||
|
'[]'::jsonb,
|
||||||
|
'',
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
{sql_literal(f'少于80张默认略过({count}张)')},
|
||||||
|
'system',
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (ct_number, series_instance_uid) DO UPDATE SET
|
||||||
|
skipped = true,
|
||||||
|
body_parts = '[]'::jsonb,
|
||||||
|
manual_body_parts = '[]'::jsonb,
|
||||||
|
ai_body_parts = '[]'::jsonb,
|
||||||
|
upper_abdomen_phase = '',
|
||||||
|
manual_upper_abdomen_phase = '',
|
||||||
|
ai_upper_abdomen_phase = '',
|
||||||
|
plain_ct = false,
|
||||||
|
notes = CASE
|
||||||
|
WHEN pacs_dicom_series_annotations.notes = ''
|
||||||
|
THEN EXCLUDED.notes
|
||||||
|
ELSE pacs_dicom_series_annotations.notes
|
||||||
|
END,
|
||||||
|
updated_by = 'system',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE jsonb_array_length(pacs_dicom_series_annotations.body_parts) = 0
|
||||||
|
AND pacs_dicom_series_annotations.plain_ct IS NOT TRUE
|
||||||
|
""",
|
||||||
|
timeout=8,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def series_time_key(row: dict[str, Any]) -> tuple[str, int, str]:
|
||||||
|
return (
|
||||||
|
row.get("series_time") or row.get("first_time") or row.get("study_time") or "",
|
||||||
|
numeric(row.get("series_number", "")),
|
||||||
|
row.get("description", ""),
|
||||||
|
)
|
||||||
|
|
||||||
def numeric(value: str) -> int:
|
def numeric(value: str) -> int:
|
||||||
try:
|
try:
|
||||||
return int(float(value))
|
return int(float(value))
|
||||||
except Exception:
|
except Exception:
|
||||||
return 999999
|
return 999999
|
||||||
|
|
||||||
series_list.sort(key=lambda row: (1 if row["annotation"].get("skipped") else 0, numeric(row["series_number"]), row["description"]))
|
series_list.sort(key=lambda row: (1 if row["annotation"].get("skipped") else 0, *series_time_key(row)))
|
||||||
cached = {"cached_at": time.time(), "study": study, "series": series_list, "files": file_map}
|
cached = {"cached_at": time.time(), "study": study, "series": series_list, "files": file_map}
|
||||||
STUDY_CACHE[ct_number] = cached
|
STUDY_CACHE[ct_number] = cached
|
||||||
return cached
|
return cached
|
||||||
@@ -535,13 +739,91 @@ def dicom_to_hu(ds: pydicom.Dataset) -> np.ndarray:
|
|||||||
return arr * slope + intercept
|
return arr * slope + intercept
|
||||||
|
|
||||||
|
|
||||||
def render_array(arr: np.ndarray, center: float, width: float, invert: bool = False, rotate: int = 0, max_size: int = 900) -> bytes:
|
def as_float(value: Any, default: float = 1.0) -> float:
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def pixel_spacing_from_ds(ds: pydicom.Dataset) -> tuple[float, float]:
|
||||||
|
spacing = getattr(ds, "PixelSpacing", None)
|
||||||
|
if spacing and len(spacing) >= 2:
|
||||||
|
row_spacing = as_float(spacing[0], 1.0)
|
||||||
|
col_spacing = as_float(spacing[1], row_spacing)
|
||||||
|
return max(row_spacing, 0.001), max(col_spacing, 0.001)
|
||||||
|
return 1.0, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def slice_spacing_from_datasets(datasets: list[pydicom.Dataset]) -> float:
|
||||||
|
distances = []
|
||||||
|
positions = []
|
||||||
|
for ds in datasets:
|
||||||
|
position = getattr(ds, "ImagePositionPatient", None)
|
||||||
|
if position and len(position) >= 3:
|
||||||
|
positions.append(np.array([as_float(position[0]), as_float(position[1]), as_float(position[2])], dtype=np.float32))
|
||||||
|
for first, second in zip(positions, positions[1:]):
|
||||||
|
distance = float(np.linalg.norm(second - first))
|
||||||
|
if distance > 0.001:
|
||||||
|
distances.append(distance)
|
||||||
|
if distances:
|
||||||
|
return max(float(np.median(distances)), 0.001)
|
||||||
|
|
||||||
|
locations = []
|
||||||
|
for ds in datasets:
|
||||||
|
value = getattr(ds, "SliceLocation", None)
|
||||||
|
if value is not None:
|
||||||
|
locations.append(as_float(value, 0.0))
|
||||||
|
for first, second in zip(locations, locations[1:]):
|
||||||
|
distance = abs(second - first)
|
||||||
|
if distance > 0.001:
|
||||||
|
distances.append(distance)
|
||||||
|
if distances:
|
||||||
|
return max(float(np.median(distances)), 0.001)
|
||||||
|
|
||||||
|
sample = datasets[0] if datasets else None
|
||||||
|
if sample is not None:
|
||||||
|
spacing = as_float(getattr(sample, "SpacingBetweenSlices", 0), 0.0)
|
||||||
|
if spacing > 0.001:
|
||||||
|
return spacing
|
||||||
|
thickness = as_float(getattr(sample, "SliceThickness", 0), 0.0)
|
||||||
|
if thickness > 0.001:
|
||||||
|
return thickness
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def resize_for_spacing(pil: Image.Image, row_spacing: float, col_spacing: float) -> Image.Image:
|
||||||
|
row_spacing = max(row_spacing, 0.001)
|
||||||
|
col_spacing = max(col_spacing, 0.001)
|
||||||
|
if abs(row_spacing - col_spacing) < 0.01:
|
||||||
|
return pil
|
||||||
|
base = min(row_spacing, col_spacing)
|
||||||
|
target_w = max(1, int(round(pil.width * col_spacing / base)))
|
||||||
|
target_h = max(1, int(round(pil.height * row_spacing / base)))
|
||||||
|
max_edge = 2200
|
||||||
|
scale = min(1.0, max_edge / max(target_w, target_h))
|
||||||
|
target_size = (max(1, int(round(target_w * scale))), max(1, int(round(target_h * scale))))
|
||||||
|
if target_size == pil.size:
|
||||||
|
return pil
|
||||||
|
return pil.resize(target_size, Image.Resampling.BILINEAR)
|
||||||
|
|
||||||
|
|
||||||
|
def render_array(
|
||||||
|
arr: np.ndarray,
|
||||||
|
center: float,
|
||||||
|
width: float,
|
||||||
|
invert: bool = False,
|
||||||
|
rotate: int = 0,
|
||||||
|
max_size: int = 900,
|
||||||
|
pixel_spacing: tuple[float, float] = (1.0, 1.0),
|
||||||
|
) -> bytes:
|
||||||
low = center - width / 2.0
|
low = center - width / 2.0
|
||||||
high = center + width / 2.0
|
high = center + width / 2.0
|
||||||
img = ((np.clip(arr, low, high) - low) / max(high - low, 1.0) * 255.0).astype(np.uint8)
|
img = ((np.clip(arr, low, high) - low) / max(high - low, 1.0) * 255.0).astype(np.uint8)
|
||||||
if invert:
|
if invert:
|
||||||
img = 255 - img
|
img = 255 - img
|
||||||
pil = Image.fromarray(img)
|
pil = Image.fromarray(img)
|
||||||
|
pil = resize_for_spacing(pil, pixel_spacing[0], pixel_spacing[1])
|
||||||
if rotate:
|
if rotate:
|
||||||
pil = pil.rotate(-rotate, expand=True)
|
pil = pil.rotate(-rotate, expand=True)
|
||||||
if max(pil.size) > max_size:
|
if max(pil.size) > max_size:
|
||||||
@@ -551,7 +833,7 @@ def render_array(arr: np.ndarray, center: float, width: float, invert: bool = Fa
|
|||||||
return output.getvalue()
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def load_stack(ct_number: str, series_uid: str) -> np.ndarray:
|
def load_stack_data(ct_number: str, series_uid: str) -> dict[str, Any]:
|
||||||
key = f"{ct_number}|{series_uid}"
|
key = f"{ct_number}|{series_uid}"
|
||||||
cached = STACK_CACHE.get(key)
|
cached = STACK_CACHE.get(key)
|
||||||
if cached:
|
if cached:
|
||||||
@@ -559,15 +841,24 @@ def load_stack(ct_number: str, series_uid: str) -> np.ndarray:
|
|||||||
return cached[1]
|
return cached[1]
|
||||||
files = get_series_files(ct_number, series_uid)
|
files = get_series_files(ct_number, series_uid)
|
||||||
arrays = []
|
arrays = []
|
||||||
|
datasets = []
|
||||||
for path in files:
|
for path in files:
|
||||||
ds = pydicom.dcmread(str(path), force=True)
|
ds = pydicom.dcmread(str(path), force=True)
|
||||||
|
datasets.append(ds)
|
||||||
arrays.append(dicom_to_hu(ds))
|
arrays.append(dicom_to_hu(ds))
|
||||||
stack = np.stack(arrays, axis=0)
|
stack = np.stack(arrays, axis=0)
|
||||||
STACK_CACHE[key] = (time.time(), stack)
|
row_spacing, col_spacing = pixel_spacing_from_ds(datasets[min(len(datasets) - 1, len(datasets) // 2)])
|
||||||
|
payload = {
|
||||||
|
"stack": stack,
|
||||||
|
"row_spacing": row_spacing,
|
||||||
|
"col_spacing": col_spacing,
|
||||||
|
"slice_spacing": slice_spacing_from_datasets(datasets),
|
||||||
|
}
|
||||||
|
STACK_CACHE[key] = (time.time(), payload)
|
||||||
if len(STACK_CACHE) > 2:
|
if len(STACK_CACHE) > 2:
|
||||||
oldest = sorted(STACK_CACHE.items(), key=lambda item: item[1][0])[0][0]
|
oldest = sorted(STACK_CACHE.items(), key=lambda item: item[1][0])[0][0]
|
||||||
STACK_CACHE.pop(oldest, None)
|
STACK_CACHE.pop(oldest, None)
|
||||||
return stack
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/image")
|
@app.get("/api/image")
|
||||||
@@ -586,21 +877,31 @@ def image(
|
|||||||
index = min(index, len(files) - 1)
|
index = min(index, len(files) - 1)
|
||||||
ds = pydicom.dcmread(str(files[index]), force=True)
|
ds = pydicom.dcmread(str(files[index]), force=True)
|
||||||
center, width = window_values(ds, window)
|
center, width = window_values(ds, window)
|
||||||
payload = render_array(dicom_to_hu(ds), center, width, getattr(ds, "PhotometricInterpretation", "") == "MONOCHROME1", rotate)
|
payload = render_array(
|
||||||
|
dicom_to_hu(ds),
|
||||||
|
center,
|
||||||
|
width,
|
||||||
|
getattr(ds, "PhotometricInterpretation", "") == "MONOCHROME1",
|
||||||
|
rotate,
|
||||||
|
pixel_spacing=pixel_spacing_from_ds(ds),
|
||||||
|
)
|
||||||
return Response(payload, media_type="image/png")
|
return Response(payload, media_type="image/png")
|
||||||
|
|
||||||
stack = load_stack(ct_number, series_uid)
|
stack_data = load_stack_data(ct_number, series_uid)
|
||||||
|
stack = stack_data["stack"]
|
||||||
sample_ds = pydicom.dcmread(str(files[min(len(files) - 1, len(files) // 2)]), stop_before_pixels=True, force=True)
|
sample_ds = pydicom.dcmread(str(files[min(len(files) - 1, len(files) // 2)]), stop_before_pixels=True, force=True)
|
||||||
center, width = window_values(sample_ds, window)
|
center, width = window_values(sample_ds, window)
|
||||||
if plane == "coronal":
|
if plane == "coronal":
|
||||||
index = min(index, stack.shape[1] - 1)
|
index = min(index, stack.shape[1] - 1)
|
||||||
arr = stack[:, index, :]
|
arr = stack[:, index, :]
|
||||||
|
spacing = (stack_data["slice_spacing"], stack_data["col_spacing"])
|
||||||
elif plane == "sagittal":
|
elif plane == "sagittal":
|
||||||
index = min(index, stack.shape[2] - 1)
|
index = min(index, stack.shape[2] - 1)
|
||||||
arr = stack[:, :, index]
|
arr = stack[:, :, index]
|
||||||
|
spacing = (stack_data["slice_spacing"], stack_data["row_spacing"])
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="invalid plane")
|
raise HTTPException(status_code=400, detail="invalid plane")
|
||||||
payload = render_array(np.flipud(arr), center, width, False, rotate)
|
payload = render_array(np.flipud(arr), center, width, False, rotate, pixel_spacing=spacing)
|
||||||
return Response(payload, media_type="image/png")
|
return Response(payload, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
@@ -644,22 +945,45 @@ def dicom_info(ct_number: str, series_uid: str, index: int = 0, _: str = Depends
|
|||||||
return {"path": str(path), "fields": fields}
|
return {"path": str(path), "fields": fields}
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/series/{ct_number}/{series_uid}/annotation")
|
def sql_bool_or_null(value: bool | None) -> str:
|
||||||
def save_annotation(ct_number: str, series_uid: str, data: AnnotationIn, user: str = Depends(require_auth)) -> dict[str, Any]:
|
if value is None:
|
||||||
body_parts = [] if data.skipped else [part for part in data.body_parts if part in BODY_PARTS]
|
return "NULL"
|
||||||
phase = data.upper_abdomen_phase if data.upper_abdomen_phase in PHASES else ""
|
return "true" if value else "false"
|
||||||
if "upper_abdomen" not in body_parts:
|
|
||||||
phase = ""
|
|
||||||
study = scan_study(ct_number)
|
def save_annotation_payload(
|
||||||
series_row = next((row for row in study["series"] if row["series_uid"] == series_uid), None)
|
ct_number: str,
|
||||||
if not series_row:
|
series_uid: str,
|
||||||
raise HTTPException(status_code=404, detail="series not found")
|
series_row: dict[str, Any],
|
||||||
|
manual_parts: list[str],
|
||||||
|
ai_parts: list[str],
|
||||||
|
manual_phase: str,
|
||||||
|
ai_phase: str,
|
||||||
|
manual_plain_ct: bool | None,
|
||||||
|
ai_plain_ct: bool | None,
|
||||||
|
skipped: bool,
|
||||||
|
ai_skipped: bool,
|
||||||
|
notes: str,
|
||||||
|
user: str,
|
||||||
|
ai_result: dict[str, Any] | None = None,
|
||||||
|
ai_model: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
manual_parts = valid_parts(manual_parts)
|
||||||
|
ai_parts = valid_parts(ai_parts)
|
||||||
|
body_parts = merge_parts(manual_parts, ai_parts, skipped)
|
||||||
|
manual_phase = valid_phase(manual_phase)
|
||||||
|
ai_phase = valid_phase(ai_phase)
|
||||||
|
phase = effective_phase(manual_phase, ai_phase, body_parts, skipped)
|
||||||
|
plain_ct = effective_plain_ct(manual_plain_ct, ai_plain_ct, False, skipped)
|
||||||
ensure_annotation_table()
|
ensure_annotation_table()
|
||||||
pg_scalar(
|
pg_scalar(
|
||||||
f"""
|
f"""
|
||||||
INSERT INTO public.pacs_dicom_series_annotations (
|
INSERT INTO public.pacs_dicom_series_annotations (
|
||||||
ct_number, study_instance_uid, series_instance_uid, series_description,
|
ct_number, study_instance_uid, series_instance_uid, series_description,
|
||||||
body_parts, upper_abdomen_phase, skipped, notes, updated_by, updated_at
|
body_parts, manual_body_parts, ai_body_parts,
|
||||||
|
upper_abdomen_phase, manual_upper_abdomen_phase, ai_upper_abdomen_phase,
|
||||||
|
plain_ct, manual_plain_ct, ai_plain_ct,
|
||||||
|
skipped, ai_skipped, notes, ai_result, ai_model, updated_by, updated_at
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
{sql_literal(ct_number)},
|
{sql_literal(ct_number)},
|
||||||
@@ -667,9 +991,19 @@ def save_annotation(ct_number: str, series_uid: str, data: AnnotationIn, user: s
|
|||||||
{sql_literal(series_uid)},
|
{sql_literal(series_uid)},
|
||||||
{sql_literal(series_row.get('description', ''))},
|
{sql_literal(series_row.get('description', ''))},
|
||||||
{sql_literal(json.dumps(body_parts, ensure_ascii=False))}::jsonb,
|
{sql_literal(json.dumps(body_parts, ensure_ascii=False))}::jsonb,
|
||||||
|
{sql_literal(json.dumps([] if skipped else manual_parts, ensure_ascii=False))}::jsonb,
|
||||||
|
{sql_literal(json.dumps([] if skipped else ai_parts, ensure_ascii=False))}::jsonb,
|
||||||
{sql_literal(phase)},
|
{sql_literal(phase)},
|
||||||
{'true' if data.skipped else 'false'},
|
{sql_literal('' if skipped else manual_phase)},
|
||||||
{sql_literal(data.notes)},
|
{sql_literal('' if skipped else ai_phase)},
|
||||||
|
{'true' if plain_ct else 'false'},
|
||||||
|
{sql_bool_or_null(None if skipped else manual_plain_ct)},
|
||||||
|
{sql_bool_or_null(None if skipped else ai_plain_ct)},
|
||||||
|
{'true' if skipped else 'false'},
|
||||||
|
{'true' if ai_skipped else 'false'},
|
||||||
|
{sql_literal(notes)},
|
||||||
|
{sql_literal(json.dumps(ai_result, ensure_ascii=False)) + '::jsonb' if ai_result is not None else 'NULL'},
|
||||||
|
{sql_literal(ai_model) if ai_model is not None else 'NULL'},
|
||||||
{sql_literal(user)},
|
{sql_literal(user)},
|
||||||
now()
|
now()
|
||||||
)
|
)
|
||||||
@@ -677,15 +1011,65 @@ def save_annotation(ct_number: str, series_uid: str, data: AnnotationIn, user: s
|
|||||||
study_instance_uid = EXCLUDED.study_instance_uid,
|
study_instance_uid = EXCLUDED.study_instance_uid,
|
||||||
series_description = EXCLUDED.series_description,
|
series_description = EXCLUDED.series_description,
|
||||||
body_parts = EXCLUDED.body_parts,
|
body_parts = EXCLUDED.body_parts,
|
||||||
|
manual_body_parts = EXCLUDED.manual_body_parts,
|
||||||
|
ai_body_parts = EXCLUDED.ai_body_parts,
|
||||||
upper_abdomen_phase = EXCLUDED.upper_abdomen_phase,
|
upper_abdomen_phase = EXCLUDED.upper_abdomen_phase,
|
||||||
|
manual_upper_abdomen_phase = EXCLUDED.manual_upper_abdomen_phase,
|
||||||
|
ai_upper_abdomen_phase = EXCLUDED.ai_upper_abdomen_phase,
|
||||||
|
plain_ct = EXCLUDED.plain_ct,
|
||||||
|
manual_plain_ct = EXCLUDED.manual_plain_ct,
|
||||||
|
ai_plain_ct = EXCLUDED.ai_plain_ct,
|
||||||
skipped = EXCLUDED.skipped,
|
skipped = EXCLUDED.skipped,
|
||||||
|
ai_skipped = EXCLUDED.ai_skipped,
|
||||||
notes = EXCLUDED.notes,
|
notes = EXCLUDED.notes,
|
||||||
|
ai_result = COALESCE(EXCLUDED.ai_result, pacs_dicom_series_annotations.ai_result),
|
||||||
|
ai_model = COALESCE(EXCLUDED.ai_model, pacs_dicom_series_annotations.ai_model),
|
||||||
updated_by = EXCLUDED.updated_by,
|
updated_by = EXCLUDED.updated_by,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
return normalize_annotation(
|
||||||
|
{
|
||||||
|
"body_parts": body_parts,
|
||||||
|
"manual_body_parts": [] if skipped else manual_parts,
|
||||||
|
"ai_body_parts": [] if skipped else ai_parts,
|
||||||
|
"upper_abdomen_phase": phase,
|
||||||
|
"manual_upper_abdomen_phase": "" if skipped else manual_phase,
|
||||||
|
"ai_upper_abdomen_phase": "" if skipped else ai_phase,
|
||||||
|
"plain_ct": plain_ct,
|
||||||
|
"manual_plain_ct": None if skipped else manual_plain_ct,
|
||||||
|
"ai_plain_ct": None if skipped else ai_plain_ct,
|
||||||
|
"skipped": skipped,
|
||||||
|
"ai_skipped": ai_skipped,
|
||||||
|
"notes": notes,
|
||||||
|
"ai_model": ai_model or series_row.get("annotation", {}).get("ai_model", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/series/{ct_number}/{series_uid}/annotation")
|
||||||
|
def save_annotation(ct_number: str, series_uid: str, data: AnnotationIn, user: str = Depends(require_auth)) -> dict[str, Any]:
|
||||||
|
study = scan_study(ct_number)
|
||||||
|
series_row = next((row for row in study["series"] if row["series_uid"] == series_uid), None)
|
||||||
|
if not series_row:
|
||||||
|
raise HTTPException(status_code=404, detail="series not found")
|
||||||
|
annotation = save_annotation_payload(
|
||||||
|
ct_number=ct_number,
|
||||||
|
series_uid=series_uid,
|
||||||
|
series_row=series_row,
|
||||||
|
manual_parts=data.manual_body_parts or data.body_parts,
|
||||||
|
ai_parts=data.ai_body_parts,
|
||||||
|
manual_phase=data.manual_upper_abdomen_phase or data.upper_abdomen_phase,
|
||||||
|
ai_phase=data.ai_upper_abdomen_phase,
|
||||||
|
manual_plain_ct=data.manual_plain_ct if data.manual_plain_ct is not None else (data.plain_ct if data.plain_ct and data.ai_plain_ct is None else None),
|
||||||
|
ai_plain_ct=data.ai_plain_ct,
|
||||||
|
skipped=data.skipped,
|
||||||
|
ai_skipped=data.ai_skipped,
|
||||||
|
notes=data.notes,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
STUDY_CACHE.pop(ct_number, None)
|
STUDY_CACHE.pop(ct_number, None)
|
||||||
return {"ok": True, "body_parts": body_parts, "upper_abdomen_phase": phase, "skipped": data.skipped}
|
return {"ok": True, **annotation}
|
||||||
|
|
||||||
|
|
||||||
def representative_images(ct_number: str, series_uid: str) -> list[tuple[str, bytes]]:
|
def representative_images(ct_number: str, series_uid: str) -> list[tuple[str, bytes]]:
|
||||||
@@ -693,17 +1077,30 @@ def representative_images(ct_number: str, series_uid: str) -> list[tuple[str, by
|
|||||||
axial_index = max(0, min(len(files) - 1, len(files) // 2))
|
axial_index = max(0, min(len(files) - 1, len(files) // 2))
|
||||||
ds = pydicom.dcmread(str(files[axial_index]), force=True)
|
ds = pydicom.dcmread(str(files[axial_index]), force=True)
|
||||||
center, width = window_values(ds, "soft")
|
center, width = window_values(ds, "soft")
|
||||||
images = [("横断面", render_array(dicom_to_hu(ds), center, width, getattr(ds, "PhotometricInterpretation", "") == "MONOCHROME1", max_size=720))]
|
images = [
|
||||||
|
(
|
||||||
|
"轴位原始",
|
||||||
|
render_array(
|
||||||
|
dicom_to_hu(ds),
|
||||||
|
center,
|
||||||
|
width,
|
||||||
|
getattr(ds, "PhotometricInterpretation", "") == "MONOCHROME1",
|
||||||
|
max_size=720,
|
||||||
|
pixel_spacing=pixel_spacing_from_ds(ds),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
if len(files) >= 3:
|
if len(files) >= 3:
|
||||||
try:
|
try:
|
||||||
stack = load_stack(ct_number, series_uid)
|
stack_data = load_stack_data(ct_number, series_uid)
|
||||||
|
stack = stack_data["stack"]
|
||||||
sample_ds = pydicom.dcmread(str(files[axial_index]), stop_before_pixels=True, force=True)
|
sample_ds = pydicom.dcmread(str(files[axial_index]), stop_before_pixels=True, force=True)
|
||||||
center, width = window_values(sample_ds, "soft")
|
center, width = window_values(sample_ds, "soft")
|
||||||
coronal = np.flipud(stack[:, stack.shape[1] // 2, :])
|
coronal = np.flipud(stack[:, stack.shape[1] // 2, :])
|
||||||
sagittal = np.flipud(stack[:, :, stack.shape[2] // 2])
|
sagittal = np.flipud(stack[:, :, stack.shape[2] // 2])
|
||||||
images.append(("矢状面", render_array(sagittal, center, width, False, max_size=720)))
|
images.append(("矢状位重建", render_array(sagittal, center, width, False, max_size=720, pixel_spacing=(stack_data["slice_spacing"], stack_data["row_spacing"]))))
|
||||||
images.append(("冠状面", render_array(coronal, center, width, False, max_size=720)))
|
images.append(("冠状位重建", render_array(coronal, center, width, False, max_size=720, pixel_spacing=(stack_data["slice_spacing"], stack_data["col_spacing"]))))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return images
|
return images
|
||||||
@@ -742,8 +1139,9 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen
|
|||||||
"可选部位键: head_neck(头颈部), chest(胸部), upper_abdomen(上腹部), "
|
"可选部位键: head_neck(头颈部), chest(胸部), upper_abdomen(上腹部), "
|
||||||
"lower_abdomen(下腹部), pelvis(盆腔)。一个序列可包含多个部位。"
|
"lower_abdomen(下腹部), pelvis(盆腔)。一个序列可包含多个部位。"
|
||||||
"如果不是可用于标注的平扫CT影像、定位像、剂量报告或无法判断,请 skipped=true。"
|
"如果不是可用于标注的平扫CT影像、定位像、剂量报告或无法判断,请 skipped=true。"
|
||||||
|
"请同时判断 plain_ct 是否为平扫CT。"
|
||||||
"如果包含上腹部,请判断期相: arterial(动脉期)、portal_venous(门静脉期)、unknown(无法判别)。"
|
"如果包含上腹部,请判断期相: arterial(动脉期)、portal_venous(门静脉期)、unknown(无法判别)。"
|
||||||
"只返回JSON: {\"body_parts\":[],\"upper_abdomen_phase\":\"\",\"skipped\":false,\"notes\":\"\"}。"
|
"只返回JSON: {\"body_parts\":[],\"upper_abdomen_phase\":\"\",\"plain_ct\":false,\"skipped\":false,\"notes\":\"\"}。"
|
||||||
f"PACS张数: {series_row.get('count', 0)}。"
|
f"PACS张数: {series_row.get('count', 0)}。"
|
||||||
f"序列描述: {series_row.get('description','')};DICOM部位: {series_row.get('body_part_dicom','')}。"
|
f"序列描述: {series_row.get('description','')};DICOM部位: {series_row.get('body_part_dicom','')}。"
|
||||||
),
|
),
|
||||||
@@ -781,36 +1179,27 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen
|
|||||||
suggestion = parse_ai_json(message)
|
suggestion = parse_ai_json(message)
|
||||||
body_parts = [part for part in suggestion.get("body_parts", []) if part in BODY_PARTS]
|
body_parts = [part for part in suggestion.get("body_parts", []) if part in BODY_PARTS]
|
||||||
skipped = bool(suggestion.get("skipped", False))
|
skipped = bool(suggestion.get("skipped", False))
|
||||||
|
plain_ct = bool(suggestion.get("plain_ct", False))
|
||||||
phase = suggestion.get("upper_abdomen_phase", "")
|
phase = suggestion.get("upper_abdomen_phase", "")
|
||||||
if phase not in PHASES or "upper_abdomen" not in body_parts:
|
if phase not in PHASES or "upper_abdomen" not in body_parts:
|
||||||
phase = ""
|
phase = ""
|
||||||
ensure_annotation_table()
|
current = normalize_annotation(series_row.get("annotation", {}))
|
||||||
pg_scalar(
|
annotation = save_annotation_payload(
|
||||||
f"""
|
ct_number=ct_number,
|
||||||
INSERT INTO public.pacs_dicom_series_annotations (
|
series_uid=series_uid,
|
||||||
ct_number, study_instance_uid, series_instance_uid, series_description,
|
series_row=series_row,
|
||||||
body_parts, upper_abdomen_phase, skipped, notes, ai_result, ai_model, updated_by, updated_at
|
manual_parts=current.get("manual_body_parts", []),
|
||||||
)
|
ai_parts=[] if skipped else body_parts,
|
||||||
VALUES (
|
manual_phase=current.get("manual_upper_abdomen_phase", ""),
|
||||||
{sql_literal(ct_number)},
|
ai_phase="" if skipped else phase,
|
||||||
{sql_literal(series_row.get('study_uid', ''))},
|
manual_plain_ct=current.get("manual_plain_ct"),
|
||||||
{sql_literal(series_uid)},
|
ai_plain_ct=None if skipped else plain_ct,
|
||||||
{sql_literal(series_row.get('description', ''))},
|
skipped=skipped,
|
||||||
'[]'::jsonb,
|
ai_skipped=skipped,
|
||||||
'',
|
notes=str(suggestion.get("notes", "")) or current.get("notes", ""),
|
||||||
false,
|
user=user,
|
||||||
'',
|
ai_result=suggestion,
|
||||||
{sql_literal(json.dumps(suggestion, ensure_ascii=False))}::jsonb,
|
ai_model=KIMI_MODEL,
|
||||||
{sql_literal(KIMI_MODEL)},
|
|
||||||
{sql_literal(user)},
|
|
||||||
now()
|
|
||||||
)
|
|
||||||
ON CONFLICT (ct_number, series_instance_uid) DO UPDATE SET
|
|
||||||
ai_result = EXCLUDED.ai_result,
|
|
||||||
ai_model = EXCLUDED.ai_model,
|
|
||||||
updated_by = EXCLUDED.updated_by,
|
|
||||||
updated_at = now()
|
|
||||||
"""
|
|
||||||
)
|
)
|
||||||
STUDY_CACHE.pop(ct_number, None)
|
STUDY_CACHE.pop(ct_number, None)
|
||||||
return {
|
return {
|
||||||
@@ -818,10 +1207,7 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen
|
|||||||
"provider": "Kimi",
|
"provider": "Kimi",
|
||||||
"name": KIMI_API_NAME,
|
"name": KIMI_API_NAME,
|
||||||
"model": KIMI_MODEL,
|
"model": KIMI_MODEL,
|
||||||
"body_parts": [] if skipped else body_parts,
|
**annotation,
|
||||||
"upper_abdomen_phase": "" if skipped else phase,
|
|
||||||
"skipped": skipped,
|
|
||||||
"notes": str(suggestion.get("notes", "")),
|
|
||||||
"raw": suggestion,
|
"raw": suggestion,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const app = {
|
|||||||
study: null,
|
study: null,
|
||||||
series: [],
|
series: [],
|
||||||
activeSeries: null,
|
activeSeries: null,
|
||||||
|
draft: null,
|
||||||
slice: 0,
|
slice: 0,
|
||||||
plane: "axial",
|
plane: "axial",
|
||||||
window: "default",
|
window: "default",
|
||||||
@@ -14,12 +15,21 @@ const app = {
|
|||||||
imageUrl: "",
|
imageUrl: "",
|
||||||
searchTimer: null,
|
searchTimer: null,
|
||||||
statusTimer: null,
|
statusTimer: null,
|
||||||
|
saveTimer: null,
|
||||||
pendingImage: null,
|
pendingImage: null,
|
||||||
drag: null,
|
drag: null,
|
||||||
|
seriesSort: "asc",
|
||||||
|
showOverlay: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
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 WINDOW_PRESETS = {
|
||||||
|
bone: { wl: 500, ww: 1800 },
|
||||||
|
soft: { wl: 50, ww: 360 },
|
||||||
|
contrast: { wl: 90, ww: 140 },
|
||||||
|
};
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value ?? "")
|
return String(value ?? "")
|
||||||
@@ -80,6 +90,10 @@ function timeRange(series) {
|
|||||||
return first || last || "未记录";
|
return first || last || "未记录";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function timeKey(series) {
|
||||||
|
return String(series.series_time || series.first_time || series.study_time || "").replace(/\D/g, "").padEnd(6, "0");
|
||||||
|
}
|
||||||
|
|
||||||
function phaseLabel(value) {
|
function phaseLabel(value) {
|
||||||
return { arterial: "动脉期", portal_venous: "门静脉期", unknown: "无法判别" }[value] || "";
|
return { arterial: "动脉期", portal_venous: "门静脉期", unknown: "无法判别" }[value] || "";
|
||||||
}
|
}
|
||||||
@@ -94,6 +108,28 @@ function partLabel(value) {
|
|||||||
}[value] || value;
|
}[value] || value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asList(value) {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniq(list) {
|
||||||
|
return Array.from(new Set(list.filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFirstNumber(value) {
|
||||||
|
const match = String(value || "").match(/-?\d+(\.\d+)?/);
|
||||||
|
return match ? Number(match[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ageText(birthDate, studyDate) {
|
||||||
|
const birth = String(birthDate || "");
|
||||||
|
const study = String(studyDate || "");
|
||||||
|
if (birth.length !== 8 || study.length !== 8) return "";
|
||||||
|
let age = Number(study.slice(0, 4)) - Number(birth.slice(0, 4));
|
||||||
|
if (study.slice(4) < birth.slice(4)) age -= 1;
|
||||||
|
return age > 0 ? `${age}Y` : "";
|
||||||
|
}
|
||||||
|
|
||||||
async function login(event) {
|
async function login(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
$("loginError").textContent = "";
|
$("loginError").textContent = "";
|
||||||
@@ -132,6 +168,22 @@ async function refreshStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortSeries(list) {
|
||||||
|
return [...list].sort((a, b) => {
|
||||||
|
const skipA = a.annotation?.skipped ? 1 : 0;
|
||||||
|
const skipB = b.annotation?.skipped ? 1 : 0;
|
||||||
|
if (skipA !== skipB) return skipA - skipB;
|
||||||
|
const result = timeKey(a).localeCompare(timeKey(b)) || String(a.series_number || "").localeCompare(String(b.series_number || ""), undefined, { numeric: true });
|
||||||
|
return app.seriesSort === "asc" ? result : -result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSeries(list) {
|
||||||
|
app.series = sortSeries(list || []);
|
||||||
|
$("seriesCount").textContent = String(app.series.length);
|
||||||
|
renderSeries();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadStudies() {
|
async function loadStudies() {
|
||||||
const q = encodeURIComponent($("studySearch").value.trim());
|
const q = encodeURIComponent($("studySearch").value.trim());
|
||||||
app.studies = await json(`/api/studies?q=${q}&limit=500`);
|
app.studies = await json(`/api/studies?q=${q}&limit=500`);
|
||||||
@@ -163,6 +215,7 @@ async function selectStudy(ctNumber) {
|
|||||||
app.study = app.studies.find((item) => item.ct_number === ctNumber) || { ct_number: ctNumber };
|
app.study = app.studies.find((item) => item.ct_number === ctNumber) || { ct_number: ctNumber };
|
||||||
app.series = [];
|
app.series = [];
|
||||||
app.activeSeries = null;
|
app.activeSeries = null;
|
||||||
|
app.draft = null;
|
||||||
$("activeStudyLabel").textContent = ctNumber;
|
$("activeStudyLabel").textContent = ctNumber;
|
||||||
$("seriesCount").textContent = "读取中";
|
$("seriesCount").textContent = "读取中";
|
||||||
$("seriesGrid").innerHTML = "";
|
$("seriesGrid").innerHTML = "";
|
||||||
@@ -171,24 +224,45 @@ async function selectStudy(ctNumber) {
|
|||||||
try {
|
try {
|
||||||
const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/series`);
|
const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/series`);
|
||||||
app.study = data.study;
|
app.study = data.study;
|
||||||
app.series = data.series || [];
|
setSeries(data.series || []);
|
||||||
$("seriesCount").textContent = String(app.series.length);
|
|
||||||
renderSeries();
|
|
||||||
if (app.series.length) selectSeries(app.series[0].series_uid);
|
if (app.series.length) selectSeries(app.series[0].series_uid);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
$("seriesGrid").innerHTML = `<p class="error-line">${escapeHtml(err.message)}</p>`;
|
$("seriesGrid").innerHTML = `<p class="error-line">${escapeHtml(err.message)}</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sourceTag(value, annotation) {
|
||||||
|
if (asList(annotation.manual_body_parts).includes(value)) return "manual";
|
||||||
|
if (asList(annotation.ai_body_parts).includes(value)) return "ai";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function seriesTags(series) {
|
||||||
|
const annotation = series.annotation || {};
|
||||||
|
if (annotation.skipped) {
|
||||||
|
return [{ label: "略过", source: annotation.ai_skipped ? "ai" : "manual" }];
|
||||||
|
}
|
||||||
|
const tags = [];
|
||||||
|
if (annotation.plain_ct) {
|
||||||
|
tags.push({ label: "平扫CT", source: annotation.manual_plain_ct !== null && annotation.manual_plain_ct !== undefined ? "manual" : "ai" });
|
||||||
|
}
|
||||||
|
for (const part of asList(annotation.body_parts)) {
|
||||||
|
tags.push({ label: partLabel(part), source: sourceTag(part, annotation) });
|
||||||
|
}
|
||||||
|
const phase = phaseLabel(annotation.upper_abdomen_phase);
|
||||||
|
if (phase) {
|
||||||
|
tags.push({ label: phase, source: annotation.manual_upper_abdomen_phase ? "manual" : "ai" });
|
||||||
|
}
|
||||||
|
if (series.body_part_dicom) tags.unshift({ label: series.body_part_dicom, source: "" });
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
function renderSeries() {
|
function renderSeries() {
|
||||||
const grid = $("seriesGrid");
|
const grid = $("seriesGrid");
|
||||||
grid.innerHTML = "";
|
grid.innerHTML = "";
|
||||||
for (const series of app.series) {
|
for (const series of app.series) {
|
||||||
const skipped = Boolean(series.annotation?.skipped);
|
const skipped = Boolean(series.annotation?.skipped);
|
||||||
const parts = series.annotation?.body_parts || [];
|
const tags = seriesTags(series);
|
||||||
const tags = skipped
|
|
||||||
? ["略过"]
|
|
||||||
: [series.body_part_dicom, ...parts.map(partLabel), phaseLabel(series.annotation?.upper_abdomen_phase)].filter(Boolean);
|
|
||||||
const card = document.createElement("button");
|
const card = document.createElement("button");
|
||||||
card.className = "series-card";
|
card.className = "series-card";
|
||||||
card.classList.toggle("active", app.activeSeries?.series_uid === series.series_uid);
|
card.classList.toggle("active", app.activeSeries?.series_uid === series.series_uid);
|
||||||
@@ -203,7 +277,7 @@ function renderSeries() {
|
|||||||
<strong>${escapeHtml(series.description || "未命名序列")}</strong>
|
<strong>${escapeHtml(series.description || "未命名序列")}</strong>
|
||||||
<span class="shot-time">拍摄 ${escapeHtml(timeRange(series))}</span>
|
<span class="shot-time">拍摄 ${escapeHtml(timeRange(series))}</span>
|
||||||
<small>序列 ${escapeHtml(series.series_number || "-")} · ${escapeHtml(series.rows || "-")}×${escapeHtml(series.columns || "-")} · ${escapeHtml(series.modality || "")}</small>
|
<small>序列 ${escapeHtml(series.series_number || "-")} · ${escapeHtml(series.rows || "-")}×${escapeHtml(series.columns || "-")} · ${escapeHtml(series.modality || "")}</small>
|
||||||
<div class="tag-line">${tags.length ? tags.map((tag) => `<em>${escapeHtml(tag)}</em>`).join("") : "<em>未标注</em>"}</div>
|
<div class="tag-line">${tags.length ? tags.map((tag) => `<em class="${tag.source ? `tag-${tag.source}` : ""}">${escapeHtml(tag.label)}</em>`).join("") : "<em>未标注</em>"}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
card.onclick = () => selectSeries(series.series_uid);
|
card.onclick = () => selectSeries(series.series_uid);
|
||||||
@@ -262,12 +336,56 @@ function resetViewer() {
|
|||||||
$("dicomImage").removeAttribute("src");
|
$("dicomImage").removeAttribute("src");
|
||||||
$("imageEmpty").classList.remove("hidden");
|
$("imageEmpty").classList.remove("hidden");
|
||||||
$("sliceText").textContent = "0 / 0";
|
$("sliceText").textContent = "0 / 0";
|
||||||
$("saveState").textContent = "未保存";
|
$("saveState").textContent = "自动保存";
|
||||||
resetViewState();
|
resetViewState();
|
||||||
|
updateOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTransform() {
|
function applyTransform() {
|
||||||
$("dicomImage").style.transform = `translate(${app.panX}px, ${app.panY}px) scale(${app.zoom}) rotate(${app.rotate}deg)`;
|
$("dicomImage").style.transform = `translate(${app.panX}px, ${app.panY}px) scale(${app.zoom}) rotate(${app.rotate}deg)`;
|
||||||
|
updateOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowInfo() {
|
||||||
|
if (WINDOW_PRESETS[app.window]) return WINDOW_PRESETS[app.window];
|
||||||
|
return {
|
||||||
|
wl: parseFirstNumber(app.activeSeries?.window_center) ?? 50,
|
||||||
|
ww: parseFirstNumber(app.activeSeries?.window_width) ?? 360,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function orientationLabels() {
|
||||||
|
if (app.plane === "sagittal") return { top: "H", bottom: "F", left: "A", right: "P" };
|
||||||
|
if (app.plane === "coronal") return { top: "H", bottom: "F", left: "R", right: "L" };
|
||||||
|
return { top: "A", bottom: "P", left: "R", right: "L" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOverlay() {
|
||||||
|
const overlay = $("imageOverlay");
|
||||||
|
overlay.classList.toggle("hidden", !app.showOverlay || !app.activeSeries);
|
||||||
|
$("overlayToggle").textContent = app.showOverlay ? "隐藏信息" : "显示信息";
|
||||||
|
if (!app.activeSeries) return;
|
||||||
|
const s = app.activeSeries;
|
||||||
|
const orient = orientationLabels();
|
||||||
|
const win = windowInfo();
|
||||||
|
const age = ageText(s.patient_birth_date, s.study_date || app.study?.study_date);
|
||||||
|
const sex = s.patient_sex || "";
|
||||||
|
document.querySelector(".ov-left-top").innerHTML = [
|
||||||
|
s.patient_name || app.study?.source_patient_name || "",
|
||||||
|
[fmtDate(s.patient_birth_date), age, sex].filter(Boolean).join(" "),
|
||||||
|
s.patient_id || app.study?.patient_id || "",
|
||||||
|
fmtDate(s.study_date || app.study?.study_date),
|
||||||
|
].filter(Boolean).map(escapeHtml).join("<br />");
|
||||||
|
document.querySelector(".ov-right-top").innerHTML = [
|
||||||
|
s.institution_name || "",
|
||||||
|
s.manufacturer || "",
|
||||||
|
].filter(Boolean).map(escapeHtml).join("<br />");
|
||||||
|
document.querySelector(".ov-left-mid").textContent = orient.left;
|
||||||
|
document.querySelector(".ov-right-mid").textContent = orient.right;
|
||||||
|
document.querySelector(".ov-top-mid").textContent = orient.top;
|
||||||
|
document.querySelector(".ov-bottom-mid").textContent = orient.bottom;
|
||||||
|
document.querySelector(".ov-left-bottom").textContent = `Img:${app.slice + 1}/${maxSlice() + 1}`;
|
||||||
|
document.querySelector(".ov-right-bottom").innerHTML = `WW:${Math.round(win.ww)}<br />WL:${Math.round(win.wl)}<br />Zoom:${app.zoom.toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateImage() {
|
async function updateImage() {
|
||||||
@@ -278,6 +396,7 @@ async function updateImage() {
|
|||||||
$("sliceSlider").value = String(app.slice);
|
$("sliceSlider").value = String(app.slice);
|
||||||
$("sliceText").textContent = `${app.slice + 1} / ${max + 1}`;
|
$("sliceText").textContent = `${app.slice + 1} / ${max + 1}`;
|
||||||
$("imageEmpty").classList.add("hidden");
|
$("imageEmpty").classList.add("hidden");
|
||||||
|
updateOverlay();
|
||||||
const ticket = Symbol("image");
|
const ticket = Symbol("image");
|
||||||
app.pendingImage = ticket;
|
app.pendingImage = ticket;
|
||||||
try {
|
try {
|
||||||
@@ -292,95 +411,186 @@ async function updateImage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSkippedChecked() {
|
function cloneAnnotation(annotation = {}) {
|
||||||
return Boolean(document.querySelector('.part-grid input[value="skip"]')?.checked);
|
return {
|
||||||
|
body_parts: asList(annotation.body_parts),
|
||||||
|
manual_body_parts: asList(annotation.manual_body_parts),
|
||||||
|
ai_body_parts: asList(annotation.ai_body_parts),
|
||||||
|
upper_abdomen_phase: annotation.upper_abdomen_phase || "",
|
||||||
|
manual_upper_abdomen_phase: annotation.manual_upper_abdomen_phase || "",
|
||||||
|
ai_upper_abdomen_phase: annotation.ai_upper_abdomen_phase || "",
|
||||||
|
plain_ct: Boolean(annotation.plain_ct),
|
||||||
|
manual_plain_ct: annotation.manual_plain_ct ?? null,
|
||||||
|
ai_plain_ct: annotation.ai_plain_ct ?? null,
|
||||||
|
skipped: Boolean(annotation.skipped),
|
||||||
|
ai_skipped: Boolean(annotation.ai_skipped),
|
||||||
|
notes: annotation.notes || "",
|
||||||
|
ai_model: annotation.ai_model || "",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedParts() {
|
function effectiveParts() {
|
||||||
return Array.from(document.querySelectorAll('.part-grid input:checked:not([value="skip"])')).map((input) => input.value);
|
if (!app.draft || app.draft.skipped) return [];
|
||||||
|
return uniq([...app.draft.manual_body_parts, ...app.draft.ai_body_parts]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncPartState() {
|
function effectivePhase() {
|
||||||
const skipped = isSkippedChecked();
|
if (!effectiveParts().includes("upper_abdomen")) return "";
|
||||||
document.querySelectorAll('.part-grid input:not([value="skip"])').forEach((input) => {
|
return app.draft.manual_upper_abdomen_phase || app.draft.ai_upper_abdomen_phase || "";
|
||||||
input.disabled = skipped;
|
}
|
||||||
if (skipped) input.checked = false;
|
|
||||||
|
function effectivePlainCt() {
|
||||||
|
if (!app.draft || app.draft.skipped) return false;
|
||||||
|
if (app.draft.manual_plain_ct !== null && app.draft.manual_plain_ct !== undefined) return Boolean(app.draft.manual_plain_ct);
|
||||||
|
if (app.draft.ai_plain_ct !== null && app.draft.ai_plain_ct !== undefined) return Boolean(app.draft.ai_plain_ct);
|
||||||
|
return Boolean(app.draft.plain_ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLabelSource(input, source) {
|
||||||
|
const label = input.closest("label");
|
||||||
|
label.classList.toggle("manual-selected", source === "manual");
|
||||||
|
label.classList.toggle("ai-selected", source === "ai");
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAnnotationControls() {
|
||||||
|
if (!app.draft) return;
|
||||||
|
const parts = new Set(effectiveParts());
|
||||||
|
document.querySelectorAll(".part-grid input").forEach((input) => {
|
||||||
|
const value = input.value;
|
||||||
|
if (value === "skip") {
|
||||||
|
input.checked = app.draft.skipped;
|
||||||
|
input.disabled = false;
|
||||||
|
setLabelSource(input, app.draft.skipped ? (app.draft.ai_skipped ? "ai" : "manual") : "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value === "plain_ct") {
|
||||||
|
input.checked = effectivePlainCt();
|
||||||
|
input.disabled = app.draft.skipped;
|
||||||
|
setLabelSource(input, app.draft.manual_plain_ct !== null && app.draft.manual_plain_ct !== undefined ? "manual" : app.draft.ai_plain_ct !== null && app.draft.ai_plain_ct !== undefined ? "ai" : "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.checked = parts.has(value);
|
||||||
|
input.disabled = app.draft.skipped;
|
||||||
|
setLabelSource(input, app.draft.manual_body_parts.includes(value) ? "manual" : app.draft.ai_body_parts.includes(value) ? "ai" : "");
|
||||||
});
|
});
|
||||||
if (skipped) {
|
|
||||||
document.querySelectorAll("input[name=phase]").forEach((input) => {
|
const phase = effectivePhase();
|
||||||
input.checked = false;
|
document.querySelectorAll("input[name=phase]").forEach((input) => {
|
||||||
});
|
input.checked = input.value === phase;
|
||||||
}
|
input.disabled = app.draft.skipped;
|
||||||
const show = !skipped && selectedParts().includes("upper_abdomen");
|
setLabelSource(input, app.draft.manual_upper_abdomen_phase === input.value ? "manual" : app.draft.ai_upper_abdomen_phase === input.value ? "ai" : "");
|
||||||
$("phaseBox").classList.toggle("visible", show);
|
});
|
||||||
|
$("phaseBox").classList.toggle("visible", !app.draft.skipped && parts.has("upper_abdomen"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function hydrateAnnotation() {
|
function hydrateAnnotation() {
|
||||||
const annotation = app.activeSeries?.annotation || {};
|
app.draft = cloneAnnotation(app.activeSeries?.annotation || {});
|
||||||
const parts = new Set(annotation.body_parts || []);
|
$("annotationNotes").value = app.draft.notes || "";
|
||||||
document.querySelectorAll(".part-grid input").forEach((input) => {
|
applyAnnotationControls();
|
||||||
if (input.value === "skip") {
|
|
||||||
input.checked = Boolean(annotation.skipped);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
input.checked = !annotation.skipped && parts.has(input.value);
|
|
||||||
input.disabled = Boolean(annotation.skipped);
|
|
||||||
});
|
|
||||||
document.querySelectorAll("input[name=phase]").forEach((input) => {
|
|
||||||
input.checked = !annotation.skipped && input.value === (annotation.upper_abdomen_phase || "");
|
|
||||||
});
|
|
||||||
$("annotationNotes").value = annotation.notes || "";
|
|
||||||
syncPartState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reloadCurrentStudySeries(keepUid) {
|
function updateLocalAnnotation(annotation) {
|
||||||
const data = await json(`/api/studies/${encodeURIComponent(app.study.ct_number)}/series`);
|
const normalized = cloneAnnotation(annotation);
|
||||||
app.study = data.study;
|
normalized.body_parts = asList(annotation.body_parts);
|
||||||
app.series = data.series || [];
|
normalized.upper_abdomen_phase = annotation.upper_abdomen_phase || "";
|
||||||
app.activeSeries = app.series.find((item) => item.series_uid === keepUid) || app.series[0] || null;
|
app.draft = normalized;
|
||||||
$("seriesCount").textContent = String(app.series.length);
|
app.activeSeries.annotation = normalized;
|
||||||
|
const index = app.series.findIndex((item) => item.series_uid === app.activeSeries.series_uid);
|
||||||
|
if (index >= 0) app.series[index].annotation = normalized;
|
||||||
|
app.series = sortSeries(app.series);
|
||||||
renderSeries();
|
renderSeries();
|
||||||
hydrateAnnotation();
|
applyAnnotationControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAnnotation() {
|
function annotationPayload() {
|
||||||
if (!app.study || !app.activeSeries) return;
|
const bodyParts = app.draft.skipped ? [] : effectiveParts();
|
||||||
const skipped = isSkippedChecked();
|
return {
|
||||||
const parts = skipped ? [] : selectedParts();
|
body_parts: bodyParts,
|
||||||
let phase = skipped ? "" : document.querySelector("input[name=phase]:checked")?.value || "";
|
manual_body_parts: app.draft.skipped ? [] : app.draft.manual_body_parts,
|
||||||
if (parts.includes("upper_abdomen") && !phase) {
|
ai_body_parts: app.draft.skipped ? [] : app.draft.ai_body_parts,
|
||||||
$("saveState").textContent = "请选择上腹部期相";
|
upper_abdomen_phase: app.draft.skipped ? "" : effectivePhase(),
|
||||||
return;
|
manual_upper_abdomen_phase: app.draft.skipped ? "" : app.draft.manual_upper_abdomen_phase,
|
||||||
}
|
ai_upper_abdomen_phase: app.draft.skipped ? "" : app.draft.ai_upper_abdomen_phase,
|
||||||
if (!parts.includes("upper_abdomen")) phase = "";
|
plain_ct: effectivePlainCt(),
|
||||||
|
manual_plain_ct: app.draft.skipped ? null : app.draft.manual_plain_ct,
|
||||||
|
ai_plain_ct: app.draft.skipped ? null : app.draft.ai_plain_ct,
|
||||||
|
skipped: app.draft.skipped,
|
||||||
|
ai_skipped: app.draft.ai_skipped,
|
||||||
|
notes: $("annotationNotes").value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAnnotationNow() {
|
||||||
|
if (!app.study || !app.activeSeries || !app.draft) return;
|
||||||
$("saveState").textContent = "保存中";
|
$("saveState").textContent = "保存中";
|
||||||
const uid = app.activeSeries.series_uid;
|
const uid = app.activeSeries.series_uid;
|
||||||
try {
|
try {
|
||||||
await json(`/api/series/${encodeURIComponent(app.study.ct_number)}/${encodeURIComponent(uid)}/annotation`, {
|
const data = await json(`/api/series/${encodeURIComponent(app.study.ct_number)}/${encodeURIComponent(uid)}/annotation`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ body_parts: parts, upper_abdomen_phase: phase, skipped, notes: $("annotationNotes").value }),
|
body: JSON.stringify(annotationPayload()),
|
||||||
});
|
});
|
||||||
$("saveState").textContent = "已保存";
|
$("saveState").textContent = "已保存";
|
||||||
await reloadCurrentStudySeries(uid);
|
updateLocalAnnotation(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
$("saveState").textContent = err.message;
|
$("saveState").textContent = err.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyAiSuggestion(data) {
|
function queueSave(delay = 450) {
|
||||||
const parts = new Set(data.body_parts || []);
|
clearTimeout(app.saveTimer);
|
||||||
document.querySelectorAll(".part-grid input").forEach((input) => {
|
$("saveState").textContent = "待保存";
|
||||||
if (input.value === "skip") {
|
app.saveTimer = setTimeout(saveAnnotationNow, delay);
|
||||||
input.checked = Boolean(data.skipped);
|
}
|
||||||
return;
|
|
||||||
|
function handlePartChange(event) {
|
||||||
|
if (!app.draft) return;
|
||||||
|
const input = event.target;
|
||||||
|
const value = input.value;
|
||||||
|
if (value === "skip") {
|
||||||
|
app.draft.skipped = input.checked;
|
||||||
|
if (!input.checked) app.draft.ai_skipped = false;
|
||||||
|
if (input.checked) {
|
||||||
|
app.draft.manual_body_parts = [];
|
||||||
|
app.draft.ai_body_parts = [];
|
||||||
|
app.draft.manual_upper_abdomen_phase = "";
|
||||||
|
app.draft.ai_upper_abdomen_phase = "";
|
||||||
|
app.draft.manual_plain_ct = null;
|
||||||
|
app.draft.ai_plain_ct = null;
|
||||||
}
|
}
|
||||||
input.checked = !data.skipped && parts.has(input.value);
|
} else if (value === "plain_ct") {
|
||||||
});
|
if (input.checked) {
|
||||||
document.querySelectorAll("input[name=phase]").forEach((input) => {
|
app.draft.manual_plain_ct = true;
|
||||||
input.checked = !data.skipped && input.value === (data.upper_abdomen_phase || "");
|
app.draft.ai_plain_ct = null;
|
||||||
});
|
} else {
|
||||||
if (data.notes) $("annotationNotes").value = data.notes;
|
app.draft.manual_plain_ct = false;
|
||||||
syncPartState();
|
app.draft.ai_plain_ct = null;
|
||||||
|
}
|
||||||
|
} else if (BODY_PARTS.includes(value)) {
|
||||||
|
if (input.checked) {
|
||||||
|
app.draft.manual_body_parts = uniq([...app.draft.manual_body_parts, value]);
|
||||||
|
} else {
|
||||||
|
app.draft.manual_body_parts = app.draft.manual_body_parts.filter((part) => part !== value);
|
||||||
|
app.draft.ai_body_parts = app.draft.ai_body_parts.filter((part) => part !== value);
|
||||||
|
if (value === "upper_abdomen") {
|
||||||
|
app.draft.manual_upper_abdomen_phase = "";
|
||||||
|
app.draft.ai_upper_abdomen_phase = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.draft.body_parts = effectiveParts();
|
||||||
|
app.draft.upper_abdomen_phase = effectivePhase();
|
||||||
|
app.draft.plain_ct = effectivePlainCt();
|
||||||
|
applyAnnotationControls();
|
||||||
|
queueSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePhaseChange(event) {
|
||||||
|
if (!app.draft) return;
|
||||||
|
app.draft.manual_upper_abdomen_phase = event.target.value;
|
||||||
|
if (app.draft.ai_upper_abdomen_phase !== event.target.value) app.draft.ai_upper_abdomen_phase = "";
|
||||||
|
app.draft.upper_abdomen_phase = effectivePhase();
|
||||||
|
applyAnnotationControls();
|
||||||
|
queueSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAI() {
|
async function runAI() {
|
||||||
@@ -393,8 +603,9 @@ async function runAI() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ sample_count: 3 }),
|
body: JSON.stringify({ sample_count: 3 }),
|
||||||
});
|
});
|
||||||
applyAiSuggestion(data);
|
updateLocalAnnotation(data);
|
||||||
$("saveState").textContent = "AI 建议已填入,确认后保存";
|
$("annotationNotes").value = data.notes || "";
|
||||||
|
$("saveState").textContent = "AI 结果已保存";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
$("saveState").textContent = err.message;
|
$("saveState").textContent = err.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -530,7 +741,7 @@ function wireImageGestures() {
|
|||||||
{ passive: false },
|
{ passive: false },
|
||||||
);
|
);
|
||||||
wrap.addEventListener("pointerdown", (event) => {
|
wrap.addEventListener("pointerdown", (event) => {
|
||||||
if (!app.activeSeries) return;
|
if (!app.activeSeries || event.target.closest(".slice-rail")) return;
|
||||||
app.drag = { x: event.clientX, y: event.clientY, panX: app.panX, panY: app.panY };
|
app.drag = { x: event.clientX, y: event.clientY, panX: app.panX, panY: app.panY };
|
||||||
wrap.classList.add("dragging");
|
wrap.classList.add("dragging");
|
||||||
wrap.setPointerCapture(event.pointerId);
|
wrap.setPointerCapture(event.pointerId);
|
||||||
@@ -549,6 +760,17 @@ function wireImageGestures() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSort(direction) {
|
||||||
|
app.seriesSort = direction;
|
||||||
|
$("sortAsc").classList.toggle("active", direction === "asc");
|
||||||
|
$("sortDesc").classList.toggle("active", direction === "desc");
|
||||||
|
app.series = sortSeries(app.series);
|
||||||
|
renderSeries();
|
||||||
|
if (app.activeSeries) {
|
||||||
|
app.activeSeries = app.series.find((item) => item.series_uid === app.activeSeries.series_uid) || app.activeSeries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function wire() {
|
function wire() {
|
||||||
$("loginForm").addEventListener("submit", login);
|
$("loginForm").addEventListener("submit", login);
|
||||||
$("logoutBtn").addEventListener("click", logout);
|
$("logoutBtn").addEventListener("click", logout);
|
||||||
@@ -557,6 +779,12 @@ function wire() {
|
|||||||
$("aiClassify").addEventListener("click", runAI);
|
$("aiClassify").addEventListener("click", runAI);
|
||||||
$("zoomIn").addEventListener("click", () => changeZoom(1.18));
|
$("zoomIn").addEventListener("click", () => changeZoom(1.18));
|
||||||
$("zoomOut").addEventListener("click", () => changeZoom(1 / 1.18));
|
$("zoomOut").addEventListener("click", () => changeZoom(1 / 1.18));
|
||||||
|
$("overlayToggle").addEventListener("click", () => {
|
||||||
|
app.showOverlay = !app.showOverlay;
|
||||||
|
updateOverlay();
|
||||||
|
});
|
||||||
|
$("sortAsc").addEventListener("click", () => setSort("asc"));
|
||||||
|
$("sortDesc").addEventListener("click", () => setSort("desc"));
|
||||||
$("studySearch").addEventListener("input", () => {
|
$("studySearch").addEventListener("input", () => {
|
||||||
clearTimeout(app.searchTimer);
|
clearTimeout(app.searchTimer);
|
||||||
app.searchTimer = setTimeout(loadStudies, 250);
|
app.searchTimer = setTimeout(loadStudies, 250);
|
||||||
@@ -589,8 +817,13 @@ function wire() {
|
|||||||
applyTransform();
|
applyTransform();
|
||||||
});
|
});
|
||||||
$("resetView").addEventListener("click", resetViewState);
|
$("resetView").addEventListener("click", resetViewState);
|
||||||
document.querySelectorAll(".part-grid input").forEach((input) => input.addEventListener("change", syncPartState));
|
document.querySelectorAll(".part-grid input").forEach((input) => input.addEventListener("change", handlePartChange));
|
||||||
$("saveAnnotation").addEventListener("click", saveAnnotation);
|
document.querySelectorAll("input[name=phase]").forEach((input) => input.addEventListener("change", handlePhaseChange));
|
||||||
|
$("annotationNotes").addEventListener("input", () => {
|
||||||
|
if (!app.draft) return;
|
||||||
|
app.draft.notes = $("annotationNotes").value;
|
||||||
|
queueSave(900);
|
||||||
|
});
|
||||||
document.querySelectorAll("[data-close]").forEach((button) => {
|
document.querySelectorAll("[data-close]").forEach((button) => {
|
||||||
button.addEventListener("click", () => $(button.dataset.close).classList.add("hidden"));
|
button.addEventListener("click", () => $(button.dataset.close).classList.add("hidden"));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,7 +53,11 @@
|
|||||||
<section class="series-pane">
|
<section class="series-pane">
|
||||||
<div class="pane-head">
|
<div class="pane-head">
|
||||||
<h2>序列</h2>
|
<h2>序列</h2>
|
||||||
<span id="seriesCount">0</span>
|
<div class="series-head-tools">
|
||||||
|
<button id="sortAsc" class="sort-btn active">时间 ↑</button>
|
||||||
|
<button id="sortDesc" class="sort-btn">时间 ↓</button>
|
||||||
|
<span id="seriesCount">0</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="seriesGrid" class="series-grid"></div>
|
<div id="seriesGrid" class="series-grid"></div>
|
||||||
</section>
|
</section>
|
||||||
@@ -61,9 +65,9 @@
|
|||||||
<section class="viewer-pane">
|
<section class="viewer-pane">
|
||||||
<div class="viewer-toolbar">
|
<div class="viewer-toolbar">
|
||||||
<div class="segmented" data-group="plane">
|
<div class="segmented" data-group="plane">
|
||||||
<button data-plane="axial" class="active">横断面</button>
|
<button data-plane="axial" class="active">轴位原始</button>
|
||||||
<button data-plane="sagittal">矢状面</button>
|
<button data-plane="sagittal">矢状位重建</button>
|
||||||
<button data-plane="coronal">冠状面</button>
|
<button data-plane="coronal">冠状位重建</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="segmented" data-group="window">
|
<div class="segmented" data-group="window">
|
||||||
<button data-window="default" class="active">默认</button>
|
<button data-window="default" class="active">默认</button>
|
||||||
@@ -77,6 +81,7 @@
|
|||||||
<button id="zoomOut">− 缩小</button>
|
<button id="zoomOut">− 缩小</button>
|
||||||
<button id="zoomIn">+ 放大</button>
|
<button id="zoomIn">+ 放大</button>
|
||||||
<button id="resetView">↺ 复位</button>
|
<button id="resetView">↺ 复位</button>
|
||||||
|
<button id="overlayToggle">隐藏信息</button>
|
||||||
<button id="infoBtn">DICOM 信息</button>
|
<button id="infoBtn">DICOM 信息</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,6 +89,18 @@
|
|||||||
<div class="viewer-stage">
|
<div class="viewer-stage">
|
||||||
<div class="image-wrap">
|
<div class="image-wrap">
|
||||||
<img id="dicomImage" alt="" />
|
<img id="dicomImage" alt="" />
|
||||||
|
<div id="imageOverlay" class="image-overlay">
|
||||||
|
<div class="crosshair crosshair-v"></div>
|
||||||
|
<div class="crosshair crosshair-h"></div>
|
||||||
|
<div class="ov ov-left-top"></div>
|
||||||
|
<div class="ov ov-right-top"></div>
|
||||||
|
<div class="ov ov-left-mid"></div>
|
||||||
|
<div class="ov ov-right-mid"></div>
|
||||||
|
<div class="ov ov-top-mid"></div>
|
||||||
|
<div class="ov ov-bottom-mid"></div>
|
||||||
|
<div class="ov ov-left-bottom"></div>
|
||||||
|
<div class="ov ov-right-bottom"></div>
|
||||||
|
</div>
|
||||||
<div id="imageEmpty" class="image-empty"></div>
|
<div id="imageEmpty" class="image-empty"></div>
|
||||||
<div class="slice-rail">
|
<div class="slice-rail">
|
||||||
<input id="sliceSlider" type="range" min="0" max="0" value="0" orient="vertical" />
|
<input id="sliceSlider" type="range" min="0" max="0" value="0" orient="vertical" />
|
||||||
@@ -95,12 +112,17 @@
|
|||||||
<div class="annotation-head">
|
<div class="annotation-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>部位标注</h2>
|
<h2>部位标注</h2>
|
||||||
<span id="saveState">未保存</span>
|
<span id="saveState">自动保存</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="aiClassify" class="ghost-btn ai-btn">AI 识别</button>
|
<button id="aiClassify" class="ghost-btn ai-btn">AI 识别</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="source-legend">
|
||||||
|
<span><i class="manual-dot"></i>人工</span>
|
||||||
|
<span><i class="ai-dot"></i>AI</span>
|
||||||
|
</div>
|
||||||
<div class="part-grid">
|
<div class="part-grid">
|
||||||
<label class="skip-option"><input type="checkbox" value="skip" />略过</label>
|
<label class="skip-option"><input type="checkbox" value="skip" />略过</label>
|
||||||
|
<label class="plain-option"><input type="checkbox" value="plain_ct" />平扫CT</label>
|
||||||
<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>
|
||||||
@@ -117,7 +139,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="annotation-actions">
|
<div class="annotation-actions">
|
||||||
<input id="annotationNotes" class="note-input" placeholder="备注" />
|
<input id="annotationNotes" class="note-input" placeholder="备注" />
|
||||||
<button id="saveAnnotation" class="primary-btn">保存标注</button>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -284,6 +284,28 @@ button:disabled {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.series-head-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-btn {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #0b0f16;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-btn.active {
|
||||||
|
border-color: var(--blue);
|
||||||
|
background: rgba(52, 116, 246, 0.2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.study-list,
|
.study-list,
|
||||||
.series-grid {
|
.series-grid {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -433,6 +455,18 @@ button:disabled {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-line .tag-manual {
|
||||||
|
border-color: rgba(25, 212, 194, 0.58);
|
||||||
|
color: #baf8ee;
|
||||||
|
background: rgba(25, 212, 194, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-line .tag-ai {
|
||||||
|
border-color: rgba(240, 181, 78, 0.65);
|
||||||
|
color: #ffe0a3;
|
||||||
|
background: rgba(240, 181, 78, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.viewer-pane {
|
.viewer-pane {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -502,6 +536,95 @@ button:disabled {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
color: rgba(245, 248, 255, 0.86);
|
||||||
|
font-family: "Consolas", "Cascadia Mono", monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-overlay.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crosshair {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crosshair-v {
|
||||||
|
top: 4%;
|
||||||
|
bottom: 4%;
|
||||||
|
left: 50%;
|
||||||
|
width: 1px;
|
||||||
|
background: #ff3b30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crosshair-h {
|
||||||
|
left: 3%;
|
||||||
|
right: 3%;
|
||||||
|
top: 50%;
|
||||||
|
height: 1px;
|
||||||
|
background: #21c55d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov {
|
||||||
|
position: absolute;
|
||||||
|
max-width: 42%;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-left-top {
|
||||||
|
top: 12px;
|
||||||
|
left: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-right-top {
|
||||||
|
top: 12px;
|
||||||
|
right: 54px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-left-mid {
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-right-mid {
|
||||||
|
right: 54px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-top-mid {
|
||||||
|
top: 12px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-bottom-mid {
|
||||||
|
bottom: 12px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-left-bottom {
|
||||||
|
left: 14px;
|
||||||
|
bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-right-bottom {
|
||||||
|
right: 54px;
|
||||||
|
bottom: 12px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.slice-rail {
|
.slice-rail {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 16px;
|
top: 16px;
|
||||||
@@ -550,9 +673,39 @@ button:disabled {
|
|||||||
color: #baf8ee;
|
color: #baf8ee;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.source-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin: -2px 0 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-legend span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-dot,
|
||||||
|
.ai-dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-dot {
|
||||||
|
background: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-dot {
|
||||||
|
background: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
.part-grid {
|
.part-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, minmax(82px, 1fr));
|
grid-template-columns: repeat(7, minmax(78px, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,11 +730,29 @@ button:disabled {
|
|||||||
background: rgba(25, 212, 194, 0.1);
|
background: rgba(25, 212, 194, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.part-grid label.manual-selected,
|
||||||
|
.phase-options label.manual-selected {
|
||||||
|
border-color: rgba(25, 212, 194, 0.72);
|
||||||
|
box-shadow: inset 3px 0 0 rgba(25, 212, 194, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-grid label.ai-selected,
|
||||||
|
.phase-options label.ai-selected {
|
||||||
|
border-color: rgba(240, 181, 78, 0.72);
|
||||||
|
background: rgba(240, 181, 78, 0.1);
|
||||||
|
box-shadow: inset 3px 0 0 rgba(240, 181, 78, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
.part-grid .skip-option:has(input:checked) {
|
.part-grid .skip-option:has(input:checked) {
|
||||||
border-color: rgba(240, 181, 78, 0.72);
|
border-color: rgba(240, 181, 78, 0.72);
|
||||||
background: rgba(240, 181, 78, 0.12);
|
background: rgba(240, 181, 78, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.part-grid .plain-option:has(input:checked) {
|
||||||
|
border-color: rgba(52, 116, 246, 0.72);
|
||||||
|
background: rgba(52, 116, 246, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.part-grid input:disabled + * {
|
.part-grid input:disabled + * {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -610,9 +781,6 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.annotation-actions {
|
.annotation-actions {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(220px, 1fr) 160px;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user