Compare commits

...

25 Commits

Author SHA1 Message Date
149cbc95d3 update web workflow and preview behavior 2026-05-03 05:41:22 +08:00
f4bde6460a Revert "generate videos from selected z-axis sequences"
This reverts commit f568faf6ed.
2026-05-03 03:03:16 +08:00
f568faf6ed generate videos from selected z-axis sequences 2026-05-03 02:59:00 +08:00
5ba2d48fdb choose source and arrow for generated videos 2026-05-03 02:51:27 +08:00
2fcab9c71a select contents for four-state zip package 2026-05-03 02:45:45 +08:00
49b797e7dc fix slow zip progress display rounding 2026-05-03 02:38:37 +08:00
d46f320a74 preserve cutoff line color in rotated preview 2026-05-03 02:29:17 +08:00
a937583400 toggle cutoff line only in quick preview 2026-05-03 02:11:14 +08:00
1d2d428aab align cutoff line rendering across previews 2026-05-03 02:01:00 +08:00
ea71713b0a draw deformation cutoff line in previews 2026-05-03 01:57:57 +08:00
2559522e3d further slow zip packaging progress display 2026-05-03 01:52:07 +08:00
5663e1c6d3 slow zip packaging progress display 2026-05-03 01:49:18 +08:00
022828095b show zip packaging progress on download 2026-05-03 01:43:52 +08:00
a795aa13bf zip deformation outputs on download 2026-05-03 01:33:18 +08:00
0aa9cffb97 share deformation job progress by account 2026-05-03 01:23:10 +08:00
eb03bea7d4 persist deformation job progress across refresh 2026-05-03 01:18:25 +08:00
d5a6b1c935 serve frontend on port 3005 2026-05-03 00:34:10 +08:00
c6e39984a5 avoid duplicate DICOM zip compression 2026-05-03 00:22:34 +08:00
479a39d880 add per-state DICOM zip downloads 2026-05-03 00:17:30 +08:00
18541db9d4 separate workspace data source and processing sections 2026-05-03 00:08:55 +08:00
e6f9873036 auto refresh workspace preview 2026-05-03 00:03:56 +08:00
89e6fef7f9 close user menu on outside click 2026-05-02 23:59:38 +08:00
40dbd2c839 add DICOM metadata details modal 2026-05-02 23:56:22 +08:00
bef76448f7 speed up DICOM slice previews 2026-05-02 23:49:43 +08:00
676ef25106 add DICOM slice previews to image library 2026-05-02 23:34:33 +08:00
7 changed files with 1584 additions and 193 deletions

View File

@@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"dev": "vite --port=3005 --host=0.0.0.0",
"backend": "python ../web_backend.py",
"build": "vite build",
"preview": "vite preview",

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,9 @@ export default defineConfig(({mode}) => {
},
},
server: {
host: '0.0.0.0',
port: 3005,
strictPort: true,
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',

View File

@@ -5,6 +5,7 @@ from pathlib import Path
import imageio.v2 as imageio
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from scipy.ndimage import gaussian_filter
from scipy.ndimage import map_coordinates
from head_extension_app import (
@@ -20,9 +21,41 @@ OUTPUT_DIR = Path("ppt_video")
FPS = 30
DURATION_SECONDS = 6
END_HOLD_SECONDS = 1
VIDEO_MODES = {"hard_boundary", "gaussian_smooth", "soft_transition"}
MODE_LABELS = {
"hard_boundary": "Hard boundary",
"gaussian_smooth": "Gaussian smooth",
"soft_transition": "Soft transition",
}
def video_soft_bend_2d(image, angle_degrees):
def normalize_mode(mode):
return mode if mode in VIDEO_MODES else "soft_transition"
def video_motion_weight(height, width, mode):
mode = normalize_mode(mode)
yy, xx = np.mgrid[0:height, 0:width]
full_motion_y = height * 0.50
fixed_y = height * 0.92
boundary_y = (full_motion_y + fixed_y) * 0.5
if mode == "hard_boundary":
return (yy <= boundary_y).astype(np.float32)
if mode == "gaussian_smooth":
hard = (yy <= boundary_y).astype(np.float32)
return np.clip(gaussian_filter(hard, sigma=height * 0.025), 0, 1).astype(np.float32)
t = np.clip((yy - full_motion_y) / (fixed_y - full_motion_y), 0, 1)
weight = 1 - (t * t * (3 - 2 * t))
x_soft = np.clip((xx - width * 0.15) / (width * 0.75), 0, 1)
x_soft = x_soft * x_soft * (3 - 2 * x_soft)
return np.clip(weight * (0.90 + 0.10 * x_soft), 0, 1).astype(np.float32)
def video_soft_bend_2d(image, angle_degrees, mode="soft_transition"):
"""Video-only 2D deformation with a broad neck transition.
The app's fast preview uses a compact transition, which is useful for
@@ -37,19 +70,7 @@ def video_soft_bend_2d(image, angle_degrees):
pivot_x = int(width * 0.55)
pivot_y = int(height * 0.62)
# Broad transition: nearly full motion for head/upper C-spine, then a long
# smooth blend through the lower C-spine to avoid splitting bone structures.
full_motion_y = height * 0.50
fixed_y = height * 0.92
t = np.clip((yy - full_motion_y) / (fixed_y - full_motion_y), 0, 1)
weight = 1 - (t * t * (3 - 2 * t))
# A small x-dependent term keeps the posterior contour from looking like a
# straight sliced plane while remaining deterministic and smooth.
x_soft = np.clip((xx - width * 0.15) / (width * 0.75), 0, 1)
x_soft = x_soft * x_soft * (3 - 2 * x_soft)
weight = np.clip(weight * (0.90 + 0.10 * x_soft), 0, 1)
weight = video_motion_weight(height, width, mode)
theta = np.deg2rad(angle_degrees) * weight
cos_t = np.cos(theta)
sin_t = np.sin(theta)
@@ -74,8 +95,9 @@ def smoothstep(t):
return t * t * (3 - 2 * t)
def make_frame(before_image, angle, max_angle):
after_image = video_soft_bend_2d(before_image, angle)
def make_frame(before_image, angle, max_angle, show_arrow=True, mode="soft_transition"):
mode = normalize_mode(mode)
after_image = video_soft_bend_2d(before_image, angle, mode)
frame = Image.new("RGB", (1920, 1080), (0, 0, 0))
draw = ImageDraw.Draw(frame)
@@ -90,32 +112,16 @@ def make_frame(before_image, angle, max_angle):
draw.text((135, 120), "Original: 0 deg", font=title_font, fill=(255, 255, 255))
draw.text(
(1055, 120),
f"Head extension: {angle:04.1f} deg",
f"{MODE_LABELS[mode]}: {angle:04.1f} deg",
font=title_font,
fill=(255, 255, 255),
)
# Yellow direction arrow on the animated side.
arrow = (255, 210, 60)
x0, y0, x1, y1 = 1390, 405, 1515, 335
draw.line((x0, y0, x1, y1), fill=arrow, width=8)
draw.polygon([(x1, y1), (x1 - 34, y1 + 3), (x1 - 12, y1 + 29)], fill=arrow)
# Minimal progress bar.
bar_x, bar_y, bar_w, bar_h = 1040, 980, 760, 12
draw.rounded_rectangle(
(bar_x, bar_y, bar_x + bar_w, bar_y + bar_h),
radius=6,
fill=(70, 70, 70),
)
fill_w = int(bar_w * angle / max_angle) if max_angle else 0
draw.rounded_rectangle(
(bar_x, bar_y, bar_x + fill_w, bar_y + bar_h),
radius=6,
fill=arrow,
)
draw.text((1040, 1000), "0 deg", font=get_font(24), fill=(210, 210, 210))
draw.text((1745, 1000), f"{max_angle:g} deg", font=get_font(24), fill=(210, 210, 210))
if show_arrow:
x0, y0, x1, y1 = 1390, 405, 1515, 335
draw.line((x0, y0, x1, y1), fill=arrow, width=8)
draw.polygon([(x1, y1), (x1 - 34, y1 + 3), (x1 - 12, y1 + 29)], fill=arrow)
# Invisible-looking spacer: keeps the font imported above alive for some PIL builds.
_ = angle_font
@@ -148,10 +154,22 @@ def parse_args():
default=DURATION_SECONDS,
help="Animation duration in seconds before final hold. Default: 6",
)
parser.add_argument(
"--no-arrow",
action="store_true",
help="Hide the yellow direction arrow.",
)
parser.add_argument(
"--mode",
default="soft_transition",
choices=["hard_boundary", "gaussian_smooth", "soft_transition"],
help="2D video deformation mode. Default: soft_transition",
)
return parser.parse_args()
def generate_video(input_dir, output_path, max_angle=20.0, duration_seconds=6.0):
def generate_video(input_dir, output_path, max_angle=20.0, duration_seconds=6.0, show_arrow=True, mode="soft_transition"):
mode = normalize_mode(mode)
output_file = Path(output_path)
output_file.parent.mkdir(parents=True, exist_ok=True)
@@ -172,17 +190,17 @@ def generate_video(input_dir, output_path, max_angle=20.0, duration_seconds=6.0)
for index in range(moving_frames):
t = index / (moving_frames - 1)
angle = max_angle * smoothstep(t)
writer.append_data(np.asarray(make_frame(before_image, angle, max_angle)))
writer.append_data(np.asarray(make_frame(before_image, angle, max_angle, show_arrow, mode)))
for _ in range(hold_frames):
writer.append_data(np.asarray(make_frame(before_image, max_angle, max_angle)))
writer.append_data(np.asarray(make_frame(before_image, max_angle, max_angle, show_arrow, mode)))
return output_file.resolve()
def main():
args = parse_args()
output_file = generate_video(args.input, args.output, args.max_angle, args.duration)
output_file = generate_video(args.input, args.output, args.max_angle, args.duration, not args.no_arrow, args.mode)
print(output_file)

View File

@@ -119,6 +119,28 @@ def crop_head_neck(image):
return image.crop((left, top, right, bottom))
def cutoff_center_z(coordinates_cutoff):
return float(np.mean([point[0] for point in coordinates_cutoff]))
def draw_cutoff_line(panel, image_depth, coordinates_cutoff=DEFAULT_COORDINATES_CUTOFF):
panel = panel.copy()
crop_height = int(image_depth * 0.72)
if crop_height <= 0:
return panel
line_y = int(round((image_depth - 1 - cutoff_center_z(coordinates_cutoff)) * panel.height / crop_height))
if line_y < 0 or line_y >= panel.height:
return panel
draw = ImageDraw.Draw(panel)
shadow = (0, 0, 0)
line_color = (255, 215, 60)
draw.line((0, line_y, panel.width, line_y), fill=shadow, width=6)
draw.line((0, line_y, panel.width, line_y), fill=line_color, width=3)
return panel
def fit_image(image, width, height):
scale = min(width / image.width, height / image.height)
resized = image.resize(
@@ -130,29 +152,51 @@ def fit_image(image, width, height):
return canvas
def preview_deform_2d(image, angle_degrees):
def preview_motion_weight(height, width, mode="soft_transition", transition_width=90, gaussian_sigma=3):
yy, xx = np.mgrid[0:height, 0:width]
boundary_y = height * 0.71
transition_span = height * np.clip(float(transition_width) / 90 * 0.42, 0.18, 0.56)
full_motion_y = boundary_y - transition_span * 0.5
fixed_y = boundary_y + transition_span * 0.5
if mode == "hard_boundary":
return (yy <= boundary_y).astype(np.float32)
if mode == "gaussian_smooth":
try:
from scipy.ndimage import gaussian_filter
except Exception:
gaussian_filter = None
hard = (yy <= boundary_y).astype(np.float32)
if gaussian_filter is None:
return hard
sigma = float(np.clip(float(gaussian_sigma), 1, 12))
return np.clip(gaussian_filter(hard, sigma=sigma), 0, 1).astype(np.float32)
t = np.clip((yy - full_motion_y) / (fixed_y - full_motion_y), 0, 1)
weight = 1 - (t * t * (3 - 2 * t))
x_soft = np.clip((xx - width * 0.15) / (width * 0.75), 0, 1)
x_soft = x_soft * x_soft * (3 - 2 * x_soft)
return np.clip(weight * (0.90 + 0.10 * x_soft), 0, 1).astype(np.float32)
def preview_deform_2d(image, angle_degrees, mode="soft_transition", transition_width=90, gaussian_sigma=3):
"""Fast visual preview only. The DICOM output uses the real 3D field."""
try:
from scipy.ndimage import map_coordinates
except Exception:
return image
arr = np.asarray(image.convert("L")).astype(np.float32)
h, w = arr.shape
arr = np.asarray(image.convert("RGB")).astype(np.float32)
h, w, _ = arr.shape
yy, xx = np.mgrid[0:h, 0:w]
pivot_x = int(w * 0.55)
pivot_y = int(h * 0.62)
full_motion_y = h * 0.50
fixed_y = h * 0.92
t = np.clip((yy - full_motion_y) / (fixed_y - full_motion_y), 0, 1)
weight = 1 - (t * t * (3 - 2 * t))
x_soft = np.clip((xx - w * 0.15) / (w * 0.75), 0, 1)
x_soft = x_soft * x_soft * (3 - 2 * x_soft)
weight = np.clip(weight * (0.90 + 0.10 * x_soft), 0, 1)
weight = preview_motion_weight(h, w, mode, transition_width, gaussian_sigma)
theta = np.deg2rad(angle_degrees) * weight
cos_t = np.cos(theta)
sin_t = np.sin(theta)
@@ -161,7 +205,11 @@ def preview_deform_2d(image, angle_degrees):
src_x = pivot_x + cos_t * dx + sin_t * dy
src_y = pivot_y - sin_t * dx + cos_t * dy
warped = map_coordinates(arr, [src_y, src_x], order=1, mode="constant", cval=0)
warped_channels = [
map_coordinates(arr[..., channel], [src_y, src_x], order=1, mode="constant", cval=0)
for channel in range(arr.shape[2])
]
warped = np.stack(warped_channels, axis=2)
return Image.fromarray(np.clip(warped, 0, 255).astype(np.uint8)).convert("RGB")
@@ -333,7 +381,7 @@ def run_deformation(input_dir, output_dir, angle_degrees, transition_width, prog
output_paths["legacy_soft"] = legacy_soft_dir
progress("正在生成四状态过程对比图...")
preview_paths = make_four_state_preview(state_images, Path(output_dir), angle_degrees)
preview_paths = make_four_state_preview(state_images, Path(output_dir), angle_degrees, transition_width=transition_width)
make_output_preview_from_images(
state_images["original"],
state_images["soft_transition"],
@@ -375,14 +423,17 @@ def make_output_preview(original_dir, deformed_dicom_dir, output_dir, angle_degr
return preview_path
def sitk_sagittal_panel(image):
def sitk_sagittal_panel(image, coordinates_cutoff=None):
volume = sitk.GetArrayFromImage(image)[::-1]
return crop_head_neck(sagittal_mip(volume))
panel = crop_head_neck(sagittal_mip(volume))
if coordinates_cutoff is not None:
panel = draw_cutoff_line(panel, volume.shape[0], coordinates_cutoff)
return panel
def make_output_preview_from_images(original_image, deformed_image, output_dir, angle_degrees):
before = sitk_sagittal_panel(original_image)
after = sitk_sagittal_panel(deformed_image)
def make_output_preview_from_images(original_image, deformed_image, output_dir, angle_degrees, coordinates_cutoff=None):
before = sitk_sagittal_panel(original_image, coordinates_cutoff)
after = sitk_sagittal_panel(deformed_image, coordinates_cutoff)
slide = Image.new("RGB", (2560, 1440), (0, 0, 0))
draw = ImageDraw.Draw(slide)
@@ -408,14 +459,32 @@ def make_output_preview_from_images(original_image, deformed_image, output_dir,
return preview_path
def make_four_state_preview(state_images, output_dir, angle_degrees):
def make_four_state_preview(state_images, output_dir, angle_degrees, coordinates_cutoff=None, transition_width=90):
output_dir = Path(output_dir)
screenshot_dir = output_dir / "process_screenshots"
reset_folder(screenshot_dir)
original_panel = sitk_sagittal_panel(state_images["original"], coordinates_cutoff)
preview_panels = {
"original": original_panel,
"hard_boundary": preview_deform_2d(original_panel, angle_degrees, "hard_boundary"),
"gaussian_smooth": preview_deform_2d(
original_panel,
angle_degrees,
"gaussian_smooth",
transition_width,
),
"soft_transition": preview_deform_2d(
original_panel,
angle_degrees,
"soft_transition",
transition_width,
),
}
panels = []
for state_key, label, _ in STATE_LABELS:
panel = sitk_sagittal_panel(state_images[state_key])
panel = preview_panels[state_key]
panel_path = screenshot_dir / f"{state_key}.png"
panel.save(panel_path, quality=95)
panels.append((label, panel))
@@ -488,7 +557,7 @@ class HeadExtensionApp:
self.angle = Scale(
controls,
from_=0,
to=20,
to=45,
orient=HORIZONTAL,
resolution=0.5,
length=420,
@@ -573,11 +642,17 @@ class HeadExtensionApp:
self.status.set("正在读取 DICOM 生成预览...")
self.cached_volume = load_dicom_volume(self.input_dir.get())
before = crop_head_neck(sagittal_mip(self.cached_volume))
after = preview_deform_2d(before, float(self.angle.get()))
before_with_line = draw_cutoff_line(before, self.cached_volume.shape[0])
after = preview_deform_2d(
before_with_line,
float(self.angle.get()),
transition_width=float(self.transition.get()),
gaussian_sigma=3,
)
canvas = Image.new("RGB", (1120, 610), (0, 0, 0))
draw = ImageDraw.Draw(canvas)
before_panel = fit_image(before, 520, 500)
before_panel = fit_image(before_with_line, 520, 500)
after_panel = fit_image(after, 520, 500)
canvas.paste(before_panel, (30, 80))
canvas.paste(after_panel, (570, 80))

View File

@@ -51,7 +51,7 @@ class VideoGeneratorApp:
self.max_angle = Scale(
controls,
from_=5,
to=30,
to=45,
orient=HORIZONTAL,
resolution=1,
length=360,

View File

@@ -12,14 +12,20 @@ from email.policy import default
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from io import BytesIO
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
from urllib.parse import parse_qs, quote, unquote, urlparse
os.environ.setdefault("MPLCONFIGDIR", "/tmp/head_ct_morph_matplotlib")
import pydicom
from pydicom.multival import MultiValue
from PIL import Image
from generate_head_extension_video import generate_video
from head_extension_app import (
APP_DIR,
crop_head_neck,
ct_window,
draw_cutoff_line,
fit_image,
load_dicom_volume,
preview_deform_2d,
@@ -33,9 +39,13 @@ HOST = "0.0.0.0"
PORT = 8787
JOBS = {}
JOBS_LOCK = threading.Lock()
DICOM_FILE_CACHE = {}
LIBRARY_DIR = APP_DIR / "web_library"
LIBRARY_META = LIBRARY_DIR / "library.json"
RESULT_DIR = APP_DIR / "web_results"
JOBS_META = RESULT_DIR / "jobs.json"
USER_TASKS_META = RESULT_DIR / "user_tasks.json"
PREVIEW_CACHE_DIR = LIBRARY_DIR / "_preview_cache"
def json_default(value):
@@ -52,6 +62,27 @@ def safe_filename(name):
return "".join(char if char.isalnum() or char in "._-" else "_" for char in Path(name).name)
def normalized_username(username):
username = str(username or "").strip()
return username or "anonymous"
def read_json_file(path, default):
path = Path(path)
if not path.exists():
return default
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return default
def write_json_file(path, value):
path = Path(path)
safe_mkdir(path.parent)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2, default=json_default), encoding="utf-8")
def read_library_meta():
safe_mkdir(LIBRARY_DIR)
if LIBRARY_META.exists():
@@ -109,6 +140,175 @@ def list_library():
return live_items
def sort_key_for_dicom(path):
path = Path(path)
try:
ds = pydicom.dcmread(str(path), stop_before_pixels=True, force=True)
return (0, int(getattr(ds, "InstanceNumber", 0)), path.name)
except Exception:
stem = path.stem
return (1, int(stem) if stem.isdigit() else 0, path.name)
def sorted_dicom_files(dicom_dir):
dicom_dir = Path(dicom_dir).resolve()
files = list(dicom_dir.glob("*.dcm"))
signature = (
str(dicom_dir),
len(files),
max((file_path.stat().st_mtime for file_path in files), default=0),
)
cached = DICOM_FILE_CACHE.get(str(dicom_dir))
if cached and cached["signature"] == signature:
return cached["files"]
sorted_files = sorted(files, key=sort_key_for_dicom)
DICOM_FILE_CACHE[str(dicom_dir)] = {
"signature": signature,
"files": sorted_files,
}
return sorted_files
def find_library_item(item_id):
return next((item for item in list_library() if item["id"] == item_id), None)
def make_library_slice_preview(item_id, index):
item = find_library_item(item_id)
if not item:
raise RuntimeError("影像库中没有找到该数据。")
dicom_files = sorted_dicom_files(item["dicomPath"])
if not dicom_files:
raise RuntimeError("该影像数据没有可预览的 .dcm 文件。")
count = len(dicom_files)
index = max(0, min(int(index), count - 1))
cache_dir = PREVIEW_CACHE_DIR / item_id
safe_mkdir(cache_dir)
preview_path = cache_dir / f"slice_{index:04d}.png"
if not preview_path.exists():
ds = pydicom.dcmread(str(dicom_files[index]), force=True)
image = ds.pixel_array.astype("float32")
image = image * float(getattr(ds, "RescaleSlope", 1))
image = image + float(getattr(ds, "RescaleIntercept", 0))
preview = Image.fromarray(ct_window(image)).convert("RGB")
preview = fit_image(preview, 720, 520)
preview.save(preview_path, format="PNG")
neighbors = [value for value in [index - 1, index + 1] if 0 <= value < count]
return {
"imageUrl": f"/api/file?path={quote(str(preview_path.resolve()), safe='')}",
"index": index,
"count": count,
"file": dicom_files[index].name,
"patientId": item["patientId"],
"neighbors": [
f"/api/library/preview?id={item_id}&index={neighbor}"
for neighbor in neighbors
],
}
def dicom_value(ds, name, fallback="-"):
value = getattr(ds, name, fallback)
if value in [None, ""]:
return fallback
if isinstance(value, (list, tuple, MultiValue)):
return " / ".join(str(item) for item in value)
return str(value)
def dicom_date(value):
value = str(value or "")
if len(value) == 8 and value.isdigit():
return f"{value[0:4]}-{value[4:6]}-{value[6:8]}"
return value or "-"
def dicom_time(value):
value = str(value or "")
if len(value) >= 6 and value[:6].isdigit():
return f"{value[0:2]}:{value[2:4]}:{value[4:6]}"
return value or "-"
def make_library_info(item_id):
item = find_library_item(item_id)
if not item:
raise RuntimeError("影像库中没有找到该数据。")
dicom_files = sorted_dicom_files(item["dicomPath"])
if not dicom_files:
raise RuntimeError("该影像数据没有可读取的 .dcm 文件。")
first = pydicom.dcmread(str(dicom_files[0]), stop_before_pixels=True, force=True)
last = pydicom.dcmread(str(dicom_files[-1]), stop_before_pixels=True, force=True)
pixel_spacing = dicom_value(first, "PixelSpacing")
matrix = f"{dicom_value(first, 'Columns')} x {dicom_value(first, 'Rows')}"
instance_range = f"{dicom_value(first, 'InstanceNumber')} - {dicom_value(last, 'InstanceNumber')}"
return {
"id": item["id"],
"patientId": item["patientId"],
"fileCount": len(dicom_files),
"groups": [
{
"title": "患者信息",
"items": [
{"label": "患者姓名", "value": dicom_value(first, "PatientName")},
{"label": "患者 ID", "value": dicom_value(first, "PatientID")},
{"label": "性别", "value": dicom_value(first, "PatientSex")},
{"label": "年龄", "value": dicom_value(first, "PatientAge")},
],
},
{
"title": "检查信息",
"items": [
{"label": "检查日期", "value": dicom_date(getattr(first, "StudyDate", ""))},
{"label": "检查时间", "value": dicom_time(getattr(first, "StudyTime", ""))},
{"label": "检查描述", "value": dicom_value(first, "StudyDescription")},
{"label": "检查号", "value": dicom_value(first, "AccessionNumber")},
{"label": "机构", "value": dicom_value(first, "InstitutionName")},
],
},
{
"title": "序列信息",
"items": [
{"label": "模态", "value": dicom_value(first, "Modality")},
{"label": "部位", "value": dicom_value(first, "BodyPartExamined")},
{"label": "序列描述", "value": dicom_value(first, "SeriesDescription")},
{"label": "序列号", "value": dicom_value(first, "SeriesNumber")},
{"label": "制造商", "value": dicom_value(first, "Manufacturer")},
],
},
{
"title": "图像参数",
"items": [
{"label": "切片数", "value": str(len(dicom_files))},
{"label": "Instance 范围", "value": instance_range},
{"label": "矩阵", "value": matrix},
{"label": "像素间距", "value": pixel_spacing},
{"label": "层厚", "value": dicom_value(first, "SliceThickness")},
{"label": "层间距", "value": dicom_value(first, "SpacingBetweenSlices")},
{"label": "卷积核", "value": dicom_value(first, "ConvolutionKernel")},
],
},
{
"title": "扫描参数",
"items": [
{"label": "KVP", "value": dicom_value(first, "KVP")},
{"label": "管电流", "value": dicom_value(first, "XRayTubeCurrent")},
{"label": "曝光时间", "value": dicom_value(first, "ExposureTime")},
{"label": "重建直径", "value": dicom_value(first, "ReconstructionDiameter")},
],
},
],
}
def parse_multipart(headers, body):
content_type = headers.get("content-type", "")
message = BytesParser(policy=default).parsebytes(
@@ -160,6 +360,91 @@ def add_dicom_from_zip(target_dir, zip_filename, payload, start_index):
return count
def validate_single_dicom_series(dicom_dir):
series_counts = {}
for dicom_path in Path(dicom_dir).glob("*.dcm"):
try:
ds = pydicom.dcmread(str(dicom_path), stop_before_pixels=True, force=True)
except Exception as exc:
raise RuntimeError(f"{dicom_path.name} 不是可读取的 DICOM 文件。") from exc
series_uid = str(getattr(ds, "SeriesInstanceUID", "") or "NO_SERIES_UID")
series_counts[series_uid] = series_counts.get(series_uid, 0) + 1
if len(series_counts) > 1:
series_summary = ", ".join(str(count) for count in sorted(series_counts.values(), reverse=True))
raise RuntimeError(
"上传内容包含多个 DICOM 序列,不能作为一个影像库数据集导入。"
f"检测到 {len(series_counts)} 个序列,分别约 {series_summary} 张。"
"请上传单个序列文件夹/ZIP。"
)
def reset_demo_environment():
source_dir = APP_DIR / "Ori_Head_CT"
source_zip = APP_DIR / "Ori_Head_CT.zip"
demo_root = LIBRARY_DIR / "demo_ori_head_ct"
demo_dicom_dir = demo_root / "dicom"
if not source_dir.exists() and not source_zip.exists():
raise RuntimeError("未找到 Ori_Head_CT 或 Ori_Head_CT.zip无法恢复演示环境。")
safe_mkdir(LIBRARY_DIR)
for child in LIBRARY_DIR.iterdir():
if child.is_dir():
shutil.rmtree(child)
else:
child.unlink()
safe_mkdir(demo_dicom_dir)
copied = 0
if source_dir.exists():
for dicom_path in sorted(source_dir.glob("*.dcm")):
shutil.copy2(dicom_path, demo_dicom_dir / dicom_path.name)
copied += 1
else:
with zipfile.ZipFile(source_zip) as archive:
for member in archive.infolist():
if member.is_dir():
continue
member_name = member.filename.replace("\\", "/")
if Path(member_name).suffix.lower() != ".dcm":
continue
if Path(member_name).is_absolute() or ".." in Path(member_name).parts:
continue
copied += 1
with archive.open(member) as member_file:
write_dicom_payload(demo_dicom_dir, Path(member_name).name, member_file.read(), copied)
if copied == 0:
shutil.rmtree(demo_root)
raise RuntimeError("Ori_Head_CT 中没有找到 .dcm 文件。")
validate_single_dicom_series(demo_dicom_dir)
record = build_library_record(
"demo_ori_head_ct",
"Ori_Head_CT",
demo_dicom_dir,
source="upload",
version="DICOM-DEMO",
)
write_library_meta([record])
DICOM_FILE_CACHE.clear()
if RESULT_DIR.exists():
shutil.rmtree(RESULT_DIR)
safe_mkdir(RESULT_DIR)
with JOBS_LOCK:
JOBS.clear()
persist_jobs_locked()
write_json_file(USER_TASKS_META, {})
return {
"ok": True,
"message": "演示环境已恢复出厂设置。",
"items": list_library(),
}
def upload_library_item(headers, body):
fields, files = parse_multipart(headers, body)
dicom_files = [
@@ -198,6 +483,11 @@ def upload_library_item(headers, body):
if total_dicom_count == 0:
shutil.rmtree(target_dir.parent)
raise RuntimeError("压缩包里没有找到 .dcm 文件。")
try:
validate_single_dicom_series(target_dir)
except Exception:
shutil.rmtree(target_dir.parent)
raise
record = build_library_record(
item_id,
@@ -231,11 +521,76 @@ def zip_folder(source_dir, zip_path):
return zip_path
def read_user_tasks():
tasks = read_json_file(USER_TASKS_META, {})
return tasks if isinstance(tasks, dict) else {}
def write_user_tasks(tasks):
write_json_file(USER_TASKS_META, tasks)
def set_user_task(username, kind, job_id):
username = normalized_username(username)
tasks = read_user_tasks()
tasks.setdefault(username, {})[kind] = job_id
write_user_tasks(tasks)
def get_user_task_job(username, kind):
username = normalized_username(username)
tasks = read_user_tasks()
job_id = tasks.get(username, {}).get(kind)
if not job_id:
return None
return get_job(job_id)
def persist_jobs_locked():
write_json_file(JOBS_META, JOBS)
def load_persisted_jobs():
saved_jobs = read_json_file(JOBS_META, {})
if not isinstance(saved_jobs, dict):
return
now_text = time.strftime("%Y-%m-%d %H:%M:%S")
with JOBS_LOCK:
for job_id, job in saved_jobs.items():
if not isinstance(job, dict):
continue
if job.get("status") == "running":
job = {
**job,
"status": "failed",
"message": "后端已重启,运行中的任务已中断。",
"error": "后端服务重启后无法继续运行中的任务,请重新提交。",
"updatedAt": now_text,
}
JOBS[job_id] = job
persist_jobs_locked()
def deformation_progress_for_message(message):
if "已复制" in message:
return 20
if "正在生成四种状态" in message:
return 35
if "正在应用形变" in message:
return 55
if "正在写出四种状态" in message:
return 72
if "正在生成四状态过程对比图" in message:
return 82
return None
def set_job(job_id, **updates):
with JOBS_LOCK:
job = JOBS[job_id]
job.update(updates)
job["updatedAt"] = time.strftime("%Y-%m-%d %H:%M:%S")
persist_jobs_locked()
def get_job(job_id):
@@ -244,24 +599,31 @@ def get_job(job_id):
return dict(job) if job else None
def start_job(kind, worker):
def start_job(kind, worker, owner=None, params=None, remember_user_task=True):
job_id = uuid.uuid4().hex[:12]
owner = normalized_username(owner)
with JOBS_LOCK:
JOBS[job_id] = {
"id": job_id,
"kind": kind,
"owner": owner,
"status": "running",
"message": "任务已启动。",
"progress": 1,
"params": params or {},
"result": None,
"error": None,
"createdAt": time.strftime("%Y-%m-%d %H:%M:%S"),
"updatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
}
persist_jobs_locked()
if remember_user_task:
set_user_task(owner, kind, job_id)
def run():
try:
result = worker(job_id)
set_job(job_id, status="completed", message="任务完成。", result=result)
set_job(job_id, status="completed", message="任务完成。", progress=100, result=result)
except Exception as exc:
set_job(
job_id,
@@ -275,14 +637,33 @@ def start_job(kind, worker):
return get_job(job_id)
def make_preview(input_dir, angle_degrees):
def make_preview(
input_dir,
angle_degrees,
show_cutoff_line=True,
transition_width=90,
mode="soft_transition",
gaussian_sigma=3,
):
if mode not in {"hard_boundary", "gaussian_smooth", "soft_transition"}:
mode = "soft_transition"
volume = load_dicom_volume(input_dir)
before = crop_head_neck(sagittal_mip(volume))
after = preview_deform_2d(before, float(angle_degrees))
before_display = draw_cutoff_line(before, volume.shape[0]) if show_cutoff_line else before
after = preview_deform_2d(
before_display,
float(angle_degrees),
mode,
transition_width=float(transition_width),
gaussian_sigma=float(gaussian_sigma),
)
canvas_image = Image.new("RGB", (1440, 520), (0, 0, 0))
canvas_image.paste(fit_image(before_display, 700, 520), (0, 0))
canvas_image.paste(fit_image(after, 700, 520), (740, 0))
canvas = BytesIO()
preview = fit_image(after, 720, 520)
preview.save(canvas, format="PNG")
canvas_image.save(canvas, format="PNG")
encoded = base64.b64encode(canvas.getvalue()).decode("ascii")
return {
"image": f"data:image/png;base64,{encoded}",
@@ -291,21 +672,116 @@ def make_preview(input_dir, angle_degrees):
}
def serialize_outputs(output_paths, preview_paths, zip_path=None):
def serialize_outputs(output_paths, preview_paths):
return {
"outputs": {key: str(Path(value).resolve()) for key, value in output_paths.items()},
"previews": {
"comparison": str(Path(preview_paths["comparison"]).resolve()),
"screenshots": str(Path(preview_paths["screenshots"]).resolve()),
},
"zip": {
"path": str(Path(zip_path).resolve()),
"name": Path(zip_path).name,
"size": Path(zip_path).stat().st_size,
} if zip_path else None,
"zip": None,
"stateZips": {},
}
def file_metadata(file_path):
file_path = Path(file_path).resolve()
return {
"path": str(file_path),
"name": file_path.name,
"size": file_path.stat().st_size,
}
def zip_selected_deformation_outputs(job_root, outputs, previews, package_options):
dicom_keys = package_options.get("dicom") or []
image_keys = package_options.get("images") or []
if not dicom_keys and not image_keys:
raise RuntimeError("请至少选择一个要打包的内容。")
zip_path = Path(job_root) / f"head_ct_morph_selected_{Path(job_root).name}.zip"
if zip_path.exists():
zip_path.unlink()
dicom_names = {
"original": "dicom/ct_original",
"hard_boundary": "dicom/ct_hard_boundary",
"gaussian_smooth": "dicom/ct_gaussian_smooth",
"soft_transition": "dicom/ct_soft_transition",
}
image_files = {
"comparison": Path(previews.get("comparison", "")),
"original": Path(previews.get("screenshots", "")) / "original.png",
"hard_boundary": Path(previews.get("screenshots", "")) / "hard_boundary.png",
"gaussian_smooth": Path(previews.get("screenshots", "")) / "gaussian_smooth.png",
"soft_transition": Path(previews.get("screenshots", "")) / "soft_transition.png",
}
image_names = {
"comparison": "images/process_comparison_4states.png",
"original": "images/original.png",
"hard_boundary": "images/hard_boundary.png",
"gaussian_smooth": "images/gaussian_smooth.png",
"soft_transition": "images/soft_transition.png",
}
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for key in dicom_keys:
if key not in dicom_names:
continue
source_dir = Path(outputs.get(key, ""))
if not source_dir.exists():
raise RuntimeError(f"{key} 的 DICOM 输出目录不存在。")
for file_path in source_dir.rglob("*"):
if file_path.is_file():
archive.write(file_path, Path(dicom_names[key]) / file_path.relative_to(source_dir))
for key in image_keys:
if key not in image_files:
continue
source_file = image_files[key]
if not source_file.exists():
raise RuntimeError(f"{key} 的图片输出不存在。")
archive.write(source_file, image_names[key])
return zip_path
def prepare_deformation_zip(job_id, target, package_options=None):
job = get_job(job_id)
if not job:
raise RuntimeError("任务不存在。")
if job.get("kind") != "deformation":
raise RuntimeError("该任务不是四状态生成任务。")
if job.get("status") != "completed":
raise RuntimeError("四状态任务尚未完成,无法下载。")
result = job.get("result") or {}
outputs = result.get("outputs") or {}
previews = result.get("previews") or {}
job_root = RESULT_DIR / job_id
if target == "all":
if package_options:
return zip_selected_deformation_outputs(job_root, outputs, previews, package_options)
output_dir = job_root / "four_state_output"
if not output_dir.exists():
raise RuntimeError("四状态输出目录不存在。")
return zip_folder(output_dir, job_root / f"head_ct_morph_{job_id}.zip")
state_labels = {
"original": "original",
"hard_boundary": "hard_boundary",
"gaussian_smooth": "gaussian_smooth",
"soft_transition": "soft_transition",
}
if target not in state_labels:
raise RuntimeError("未知的四状态下载类型。")
source_dir = Path(outputs.get(target, ""))
if not source_dir.exists():
raise RuntimeError("该状态的 DICOM 输出目录不存在。")
return zip_folder(source_dir, job_root / f"{target}_{job_id}.zip")
class Handler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
print("%s - %s" % (self.address_string(), format % args))
@@ -333,6 +809,19 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"items": list_library()})
return
if parsed.path == "/api/library/preview":
params = parse_qs(parsed.query)
item_id = params.get("id", [""])[0]
index = params.get("index", ["0"])[0]
self.send_json(make_library_slice_preview(item_id, index))
return
if parsed.path == "/api/library/info":
params = parse_qs(parsed.query)
item_id = params.get("id", [""])[0]
self.send_json(make_library_info(item_id))
return
if parsed.path == "/api/job":
params = parse_qs(parsed.query)
job_id = params.get("id", [""])[0]
@@ -343,6 +832,27 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(job)
return
if parsed.path == "/api/user/job":
params = parse_qs(parsed.query)
username = params.get("username", [""])[0]
kind = params.get("kind", ["deformation"])[0]
if kind not in ["deformation", "video"]:
self.send_json({"error": "未知任务类型。"}, status=400)
return
self.send_json({"job": get_user_task_job(username, kind)})
return
if parsed.path == "/api/deformation/download":
params = parse_qs(parsed.query)
job_id = params.get("job", [""])[0]
target = params.get("target", ["all"])[0]
try:
zip_path = prepare_deformation_zip(job_id, target)
self.send_file(zip_path, "application/zip", as_attachment=True)
except Exception as exc:
self.send_json({"error": str(exc)}, status=400)
return
if parsed.path == "/api/file":
params = parse_qs(parsed.query)
file_path = Path(unquote(params.get("path", [""])[0])).resolve()
@@ -356,15 +866,11 @@ class Handler(BaseHTTPRequestHandler):
content_type = "video/mp4"
elif file_path.suffix.lower() == ".zip":
content_type = "application/zip"
data = file_path.read_bytes()
self.send_response(200)
self.send_cors_headers()
self.send_header("Content-Type", content_type)
if file_path.suffix.lower() == ".zip":
self.send_header("Content-Disposition", f'attachment; filename="{file_path.name}"')
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
self.send_file(
file_path,
content_type,
as_attachment=file_path.suffix.lower() == ".zip",
)
return
self.send_json({"error": "接口不存在。"}, status=404)
@@ -378,14 +884,57 @@ class Handler(BaseHTTPRequestHandler):
return
body = self.read_json()
if parsed.path == "/api/demo/reset":
self.send_json(reset_demo_environment())
return
if parsed.path == "/api/preview":
self.send_json(make_preview(body["inputDir"], body.get("angleDegrees", 12)))
self.send_json(
make_preview(
body["inputDir"],
body.get("angleDegrees", 12),
bool(body.get("showCutoffLine", True)),
body.get("transitionWidth", 90),
body.get("mode", "soft_transition"),
body.get("gaussianSigma", 3),
)
)
return
if parsed.path == "/api/deformation/package":
source_job_id = body["jobId"]
target = body.get("target", "all")
package_options = body.get("packageOptions")
username = normalized_username(body.get("username"))
def worker(job_id):
label = "四状态总 ZIP" if target == "all" else "本状态 DICOM ZIP"
set_job(job_id, message=f"正在打包{label}...", progress=10)
zip_path = prepare_deformation_zip(source_job_id, target, package_options)
set_job(job_id, message="打包完成,准备下载...", progress=95)
return {"file": file_metadata(zip_path)}
self.send_json(
start_job(
"zip",
worker,
owner=username,
params={
"sourceJobId": source_job_id,
"target": target,
"packageOptions": package_options,
},
remember_user_task=False,
),
status=202,
)
return
if parsed.path == "/api/deformation":
input_dir = body["inputDir"]
angle_degrees = float(body.get("angleDegrees", 12))
transition_width = int(body.get("transitionWidth", 90))
username = normalized_username(body.get("username"))
def worker(job_id):
job_root = RESULT_DIR / job_id
@@ -393,7 +942,11 @@ class Handler(BaseHTTPRequestHandler):
reset_dir(job_root)
def progress(message):
set_job(job_id, message=message)
progress_value = deformation_progress_for_message(message)
updates = {"message": message}
if progress_value is not None:
updates["progress"] = progress_value
set_job(job_id, **updates)
output_paths, preview_paths = run_deformation(
input_dir,
@@ -402,27 +955,38 @@ class Handler(BaseHTTPRequestHandler):
transition_width,
progress,
)
set_job(job_id, message="正在打包四状态输出 ZIP...")
zip_path = zip_folder(
output_dir,
job_root / f"head_ct_morph_{job_id}.zip",
)
return serialize_outputs(output_paths, preview_paths, zip_path)
return serialize_outputs(output_paths, preview_paths)
self.send_json(start_job("deformation", worker), status=202)
self.send_json(
start_job(
"deformation",
worker,
owner=username,
params={
"inputDir": input_dir,
"angleDegrees": angle_degrees,
"transitionWidth": transition_width,
},
),
status=202,
)
return
if parsed.path == "/api/video":
input_dir = body["inputDir"]
max_angle = float(body.get("maxAngle", 20))
duration = float(body.get("durationSeconds", 6))
show_arrow = bool(body.get("showArrow", True))
mode = body.get("mode", "hard_boundary")
if mode not in {"hard_boundary", "gaussian_smooth", "soft_transition"}:
mode = "hard_boundary"
def worker(job_id):
job_root = RESULT_DIR / job_id
reset_dir(job_root)
output_file = job_root / f"head_extension_{job_id}.mp4"
set_job(job_id, message="正在生成 0° 到目标角度的视频。")
output = generate_video(input_dir, output_file, max_angle, duration)
output_file = job_root / f"head_extension_{mode}_{job_id}.mp4"
set_job(job_id, message=f"正在生成 {mode} 视频。")
output = generate_video(input_dir, output_file, max_angle, duration, show_arrow, mode)
output = Path(output).resolve()
return {
"video": {
@@ -463,7 +1027,11 @@ class Handler(BaseHTTPRequestHandler):
write_library_meta(remaining)
upload_root = Path(target["dicomPath"]).resolve().parent
if upload_root.exists() and upload_root.is_relative_to(LIBRARY_DIR.resolve()):
DICOM_FILE_CACHE.pop(str(Path(target["dicomPath"]).resolve()), None)
shutil.rmtree(upload_root)
preview_cache = PREVIEW_CACHE_DIR / item_id
if preview_cache.exists():
shutil.rmtree(preview_cache)
self.send_json({"ok": True, "items": remaining})
def read_bytes(self):
@@ -478,6 +1046,22 @@ class Handler(BaseHTTPRequestHandler):
return {}
return json.loads(body.decode("utf-8"))
def send_file(self, file_path, content_type="application/octet-stream", as_attachment=False):
file_path = Path(file_path).resolve()
if not file_path.exists() or not file_path.is_file():
self.send_json({"error": "文件不存在。"}, status=404)
return
self.send_response(200)
self.send_cors_headers()
self.send_header("Content-Type", content_type)
if as_attachment:
self.send_header("Content-Disposition", f'attachment; filename="{file_path.name}"')
self.send_header("Content-Length", str(file_path.stat().st_size))
self.end_headers()
with file_path.open("rb") as file_handle:
shutil.copyfileobj(file_handle, self.wfile)
def send_cors_headers(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
@@ -498,6 +1082,7 @@ def main():
safe_mkdir(APP_DIR / "ppt_video")
safe_mkdir(LIBRARY_DIR)
safe_mkdir(RESULT_DIR)
load_persisted_jobs()
server = ThreadingHTTPServer((HOST, PORT), Handler)
print(f"Head CT Morph backend running at http://{HOST}:{PORT}")
server.serve_forever()