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", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --port=3000 --host=0.0.0.0", "dev": "vite --port=3005 --host=0.0.0.0",
"backend": "python ../web_backend.py", "backend": "python ../web_backend.py",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -5,6 +5,7 @@ from pathlib import Path
import imageio.v2 as imageio import imageio.v2 as imageio
import numpy as np import numpy as np
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from scipy.ndimage import gaussian_filter
from scipy.ndimage import map_coordinates from scipy.ndimage import map_coordinates
from head_extension_app import ( from head_extension_app import (
@@ -20,9 +21,41 @@ OUTPUT_DIR = Path("ppt_video")
FPS = 30 FPS = 30
DURATION_SECONDS = 6 DURATION_SECONDS = 6
END_HOLD_SECONDS = 1 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. """Video-only 2D deformation with a broad neck transition.
The app's fast preview uses a compact transition, which is useful for 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_x = int(width * 0.55)
pivot_y = int(height * 0.62) pivot_y = int(height * 0.62)
# Broad transition: nearly full motion for head/upper C-spine, then a long weight = video_motion_weight(height, width, mode)
# 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)
theta = np.deg2rad(angle_degrees) * weight theta = np.deg2rad(angle_degrees) * weight
cos_t = np.cos(theta) cos_t = np.cos(theta)
sin_t = np.sin(theta) sin_t = np.sin(theta)
@@ -74,8 +95,9 @@ def smoothstep(t):
return t * t * (3 - 2 * t) return t * t * (3 - 2 * t)
def make_frame(before_image, angle, max_angle): def make_frame(before_image, angle, max_angle, show_arrow=True, mode="soft_transition"):
after_image = video_soft_bend_2d(before_image, angle) mode = normalize_mode(mode)
after_image = video_soft_bend_2d(before_image, angle, mode)
frame = Image.new("RGB", (1920, 1080), (0, 0, 0)) frame = Image.new("RGB", (1920, 1080), (0, 0, 0))
draw = ImageDraw.Draw(frame) 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((135, 120), "Original: 0 deg", font=title_font, fill=(255, 255, 255))
draw.text( draw.text(
(1055, 120), (1055, 120),
f"Head extension: {angle:04.1f} deg", f"{MODE_LABELS[mode]}: {angle:04.1f} deg",
font=title_font, font=title_font,
fill=(255, 255, 255), fill=(255, 255, 255),
) )
# Yellow direction arrow on the animated side.
arrow = (255, 210, 60) arrow = (255, 210, 60)
x0, y0, x1, y1 = 1390, 405, 1515, 335 if show_arrow:
draw.line((x0, y0, x1, y1), fill=arrow, width=8) x0, y0, x1, y1 = 1390, 405, 1515, 335
draw.polygon([(x1, y1), (x1 - 34, y1 + 3), (x1 - 12, y1 + 29)], fill=arrow) 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))
# Invisible-looking spacer: keeps the font imported above alive for some PIL builds. # Invisible-looking spacer: keeps the font imported above alive for some PIL builds.
_ = angle_font _ = angle_font
@@ -148,10 +154,22 @@ def parse_args():
default=DURATION_SECONDS, default=DURATION_SECONDS,
help="Animation duration in seconds before final hold. Default: 6", 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() 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 = Path(output_path)
output_file.parent.mkdir(parents=True, exist_ok=True) 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): for index in range(moving_frames):
t = index / (moving_frames - 1) t = index / (moving_frames - 1)
angle = max_angle * smoothstep(t) 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): 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() return output_file.resolve()
def main(): def main():
args = parse_args() 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) print(output_file)

View File

@@ -119,6 +119,28 @@ def crop_head_neck(image):
return image.crop((left, top, right, bottom)) 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): def fit_image(image, width, height):
scale = min(width / image.width, height / image.height) scale = min(width / image.width, height / image.height)
resized = image.resize( resized = image.resize(
@@ -130,29 +152,51 @@ def fit_image(image, width, height):
return canvas 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.""" """Fast visual preview only. The DICOM output uses the real 3D field."""
try: try:
from scipy.ndimage import map_coordinates from scipy.ndimage import map_coordinates
except Exception: except Exception:
return image return image
arr = np.asarray(image.convert("L")).astype(np.float32) arr = np.asarray(image.convert("RGB")).astype(np.float32)
h, w = arr.shape h, w, _ = arr.shape
yy, xx = np.mgrid[0:h, 0:w] yy, xx = np.mgrid[0:h, 0:w]
pivot_x = int(w * 0.55) pivot_x = int(w * 0.55)
pivot_y = int(h * 0.62) pivot_y = int(h * 0.62)
full_motion_y = h * 0.50 weight = preview_motion_weight(h, w, mode, transition_width, gaussian_sigma)
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)
theta = np.deg2rad(angle_degrees) * weight theta = np.deg2rad(angle_degrees) * weight
cos_t = np.cos(theta) cos_t = np.cos(theta)
sin_t = np.sin(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_x = pivot_x + cos_t * dx + sin_t * dy
src_y = pivot_y - sin_t * dx + cos_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") 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 output_paths["legacy_soft"] = legacy_soft_dir
progress("正在生成四状态过程对比图...") 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( make_output_preview_from_images(
state_images["original"], state_images["original"],
state_images["soft_transition"], state_images["soft_transition"],
@@ -375,14 +423,17 @@ def make_output_preview(original_dir, deformed_dicom_dir, output_dir, angle_degr
return preview_path return preview_path
def sitk_sagittal_panel(image): def sitk_sagittal_panel(image, coordinates_cutoff=None):
volume = sitk.GetArrayFromImage(image)[::-1] 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): def make_output_preview_from_images(original_image, deformed_image, output_dir, angle_degrees, coordinates_cutoff=None):
before = sitk_sagittal_panel(original_image) before = sitk_sagittal_panel(original_image, coordinates_cutoff)
after = sitk_sagittal_panel(deformed_image) after = sitk_sagittal_panel(deformed_image, coordinates_cutoff)
slide = Image.new("RGB", (2560, 1440), (0, 0, 0)) slide = Image.new("RGB", (2560, 1440), (0, 0, 0))
draw = ImageDraw.Draw(slide) draw = ImageDraw.Draw(slide)
@@ -408,14 +459,32 @@ def make_output_preview_from_images(original_image, deformed_image, output_dir,
return preview_path 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) output_dir = Path(output_dir)
screenshot_dir = output_dir / "process_screenshots" screenshot_dir = output_dir / "process_screenshots"
reset_folder(screenshot_dir) 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 = [] panels = []
for state_key, label, _ in STATE_LABELS: 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_path = screenshot_dir / f"{state_key}.png"
panel.save(panel_path, quality=95) panel.save(panel_path, quality=95)
panels.append((label, panel)) panels.append((label, panel))
@@ -488,7 +557,7 @@ class HeadExtensionApp:
self.angle = Scale( self.angle = Scale(
controls, controls,
from_=0, from_=0,
to=20, to=45,
orient=HORIZONTAL, orient=HORIZONTAL,
resolution=0.5, resolution=0.5,
length=420, length=420,
@@ -573,11 +642,17 @@ class HeadExtensionApp:
self.status.set("正在读取 DICOM 生成预览...") self.status.set("正在读取 DICOM 生成预览...")
self.cached_volume = load_dicom_volume(self.input_dir.get()) self.cached_volume = load_dicom_volume(self.input_dir.get())
before = crop_head_neck(sagittal_mip(self.cached_volume)) 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)) canvas = Image.new("RGB", (1120, 610), (0, 0, 0))
draw = ImageDraw.Draw(canvas) 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) after_panel = fit_image(after, 520, 500)
canvas.paste(before_panel, (30, 80)) canvas.paste(before_panel, (30, 80))
canvas.paste(after_panel, (570, 80)) canvas.paste(after_panel, (570, 80))

View File

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

View File

@@ -12,14 +12,20 @@ from email.policy import default
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from io import BytesIO from io import BytesIO
from pathlib import Path 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") 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 generate_head_extension_video import generate_video
from head_extension_app import ( from head_extension_app import (
APP_DIR, APP_DIR,
crop_head_neck, crop_head_neck,
ct_window,
draw_cutoff_line,
fit_image, fit_image,
load_dicom_volume, load_dicom_volume,
preview_deform_2d, preview_deform_2d,
@@ -33,9 +39,13 @@ HOST = "0.0.0.0"
PORT = 8787 PORT = 8787
JOBS = {} JOBS = {}
JOBS_LOCK = threading.Lock() JOBS_LOCK = threading.Lock()
DICOM_FILE_CACHE = {}
LIBRARY_DIR = APP_DIR / "web_library" LIBRARY_DIR = APP_DIR / "web_library"
LIBRARY_META = LIBRARY_DIR / "library.json" LIBRARY_META = LIBRARY_DIR / "library.json"
RESULT_DIR = APP_DIR / "web_results" 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): 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) 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(): def read_library_meta():
safe_mkdir(LIBRARY_DIR) safe_mkdir(LIBRARY_DIR)
if LIBRARY_META.exists(): if LIBRARY_META.exists():
@@ -109,6 +140,175 @@ def list_library():
return live_items 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): def parse_multipart(headers, body):
content_type = headers.get("content-type", "") content_type = headers.get("content-type", "")
message = BytesParser(policy=default).parsebytes( message = BytesParser(policy=default).parsebytes(
@@ -160,6 +360,91 @@ def add_dicom_from_zip(target_dir, zip_filename, payload, start_index):
return count 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): def upload_library_item(headers, body):
fields, files = parse_multipart(headers, body) fields, files = parse_multipart(headers, body)
dicom_files = [ dicom_files = [
@@ -198,6 +483,11 @@ def upload_library_item(headers, body):
if total_dicom_count == 0: if total_dicom_count == 0:
shutil.rmtree(target_dir.parent) shutil.rmtree(target_dir.parent)
raise RuntimeError("压缩包里没有找到 .dcm 文件。") raise RuntimeError("压缩包里没有找到 .dcm 文件。")
try:
validate_single_dicom_series(target_dir)
except Exception:
shutil.rmtree(target_dir.parent)
raise
record = build_library_record( record = build_library_record(
item_id, item_id,
@@ -231,11 +521,76 @@ def zip_folder(source_dir, zip_path):
return 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): def set_job(job_id, **updates):
with JOBS_LOCK: with JOBS_LOCK:
job = JOBS[job_id] job = JOBS[job_id]
job.update(updates) job.update(updates)
job["updatedAt"] = time.strftime("%Y-%m-%d %H:%M:%S") job["updatedAt"] = time.strftime("%Y-%m-%d %H:%M:%S")
persist_jobs_locked()
def get_job(job_id): def get_job(job_id):
@@ -244,24 +599,31 @@ def get_job(job_id):
return dict(job) if job else None 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] job_id = uuid.uuid4().hex[:12]
owner = normalized_username(owner)
with JOBS_LOCK: with JOBS_LOCK:
JOBS[job_id] = { JOBS[job_id] = {
"id": job_id, "id": job_id,
"kind": kind, "kind": kind,
"owner": owner,
"status": "running", "status": "running",
"message": "任务已启动。", "message": "任务已启动。",
"progress": 1,
"params": params or {},
"result": None, "result": None,
"error": None, "error": None,
"createdAt": time.strftime("%Y-%m-%d %H:%M:%S"), "createdAt": time.strftime("%Y-%m-%d %H:%M:%S"),
"updatedAt": 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(): def run():
try: try:
result = worker(job_id) 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: except Exception as exc:
set_job( set_job(
job_id, job_id,
@@ -275,14 +637,33 @@ def start_job(kind, worker):
return get_job(job_id) 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) volume = load_dicom_volume(input_dir)
before = crop_head_neck(sagittal_mip(volume)) 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() canvas = BytesIO()
preview = fit_image(after, 720, 520) canvas_image.save(canvas, format="PNG")
preview.save(canvas, format="PNG")
encoded = base64.b64encode(canvas.getvalue()).decode("ascii") encoded = base64.b64encode(canvas.getvalue()).decode("ascii")
return { return {
"image": f"data:image/png;base64,{encoded}", "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 { return {
"outputs": {key: str(Path(value).resolve()) for key, value in output_paths.items()}, "outputs": {key: str(Path(value).resolve()) for key, value in output_paths.items()},
"previews": { "previews": {
"comparison": str(Path(preview_paths["comparison"]).resolve()), "comparison": str(Path(preview_paths["comparison"]).resolve()),
"screenshots": str(Path(preview_paths["screenshots"]).resolve()), "screenshots": str(Path(preview_paths["screenshots"]).resolve()),
}, },
"zip": { "zip": None,
"path": str(Path(zip_path).resolve()), "stateZips": {},
"name": Path(zip_path).name,
"size": Path(zip_path).stat().st_size,
} if zip_path else None,
} }
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): class Handler(BaseHTTPRequestHandler):
def log_message(self, format, *args): def log_message(self, format, *args):
print("%s - %s" % (self.address_string(), format % args)) print("%s - %s" % (self.address_string(), format % args))
@@ -333,6 +809,19 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"items": list_library()}) self.send_json({"items": list_library()})
return 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": if parsed.path == "/api/job":
params = parse_qs(parsed.query) params = parse_qs(parsed.query)
job_id = params.get("id", [""])[0] job_id = params.get("id", [""])[0]
@@ -343,6 +832,27 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(job) self.send_json(job)
return 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": if parsed.path == "/api/file":
params = parse_qs(parsed.query) params = parse_qs(parsed.query)
file_path = Path(unquote(params.get("path", [""])[0])).resolve() file_path = Path(unquote(params.get("path", [""])[0])).resolve()
@@ -356,15 +866,11 @@ class Handler(BaseHTTPRequestHandler):
content_type = "video/mp4" content_type = "video/mp4"
elif file_path.suffix.lower() == ".zip": elif file_path.suffix.lower() == ".zip":
content_type = "application/zip" content_type = "application/zip"
data = file_path.read_bytes() self.send_file(
self.send_response(200) file_path,
self.send_cors_headers() content_type,
self.send_header("Content-Type", content_type) as_attachment=file_path.suffix.lower() == ".zip",
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)
return return
self.send_json({"error": "接口不存在。"}, status=404) self.send_json({"error": "接口不存在。"}, status=404)
@@ -378,14 +884,57 @@ class Handler(BaseHTTPRequestHandler):
return return
body = self.read_json() body = self.read_json()
if parsed.path == "/api/demo/reset":
self.send_json(reset_demo_environment())
return
if parsed.path == "/api/preview": 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 return
if parsed.path == "/api/deformation": if parsed.path == "/api/deformation":
input_dir = body["inputDir"] input_dir = body["inputDir"]
angle_degrees = float(body.get("angleDegrees", 12)) angle_degrees = float(body.get("angleDegrees", 12))
transition_width = int(body.get("transitionWidth", 90)) transition_width = int(body.get("transitionWidth", 90))
username = normalized_username(body.get("username"))
def worker(job_id): def worker(job_id):
job_root = RESULT_DIR / job_id job_root = RESULT_DIR / job_id
@@ -393,7 +942,11 @@ class Handler(BaseHTTPRequestHandler):
reset_dir(job_root) reset_dir(job_root)
def progress(message): 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( output_paths, preview_paths = run_deformation(
input_dir, input_dir,
@@ -402,27 +955,38 @@ class Handler(BaseHTTPRequestHandler):
transition_width, transition_width,
progress, progress,
) )
set_job(job_id, message="正在打包四状态输出 ZIP...") return serialize_outputs(output_paths, preview_paths)
zip_path = zip_folder(
output_dir,
job_root / f"head_ct_morph_{job_id}.zip",
)
return serialize_outputs(output_paths, preview_paths, zip_path)
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 return
if parsed.path == "/api/video": if parsed.path == "/api/video":
input_dir = body["inputDir"] input_dir = body["inputDir"]
max_angle = float(body.get("maxAngle", 20)) max_angle = float(body.get("maxAngle", 20))
duration = float(body.get("durationSeconds", 6)) 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): def worker(job_id):
job_root = RESULT_DIR / job_id job_root = RESULT_DIR / job_id
reset_dir(job_root) reset_dir(job_root)
output_file = job_root / f"head_extension_{job_id}.mp4" output_file = job_root / f"head_extension_{mode}_{job_id}.mp4"
set_job(job_id, message="正在生成 0° 到目标角度的视频。") set_job(job_id, message=f"正在生成 {mode} 视频。")
output = generate_video(input_dir, output_file, max_angle, duration) output = generate_video(input_dir, output_file, max_angle, duration, show_arrow, mode)
output = Path(output).resolve() output = Path(output).resolve()
return { return {
"video": { "video": {
@@ -463,7 +1027,11 @@ class Handler(BaseHTTPRequestHandler):
write_library_meta(remaining) write_library_meta(remaining)
upload_root = Path(target["dicomPath"]).resolve().parent upload_root = Path(target["dicomPath"]).resolve().parent
if upload_root.exists() and upload_root.is_relative_to(LIBRARY_DIR.resolve()): 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) 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}) self.send_json({"ok": True, "items": remaining})
def read_bytes(self): def read_bytes(self):
@@ -478,6 +1046,22 @@ class Handler(BaseHTTPRequestHandler):
return {} return {}
return json.loads(body.decode("utf-8")) 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): def send_cors_headers(self):
self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS") self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
@@ -498,6 +1082,7 @@ def main():
safe_mkdir(APP_DIR / "ppt_video") safe_mkdir(APP_DIR / "ppt_video")
safe_mkdir(LIBRARY_DIR) safe_mkdir(LIBRARY_DIR)
safe_mkdir(RESULT_DIR) safe_mkdir(RESULT_DIR)
load_persisted_jobs()
server = ThreadingHTTPServer((HOST, PORT), Handler) server = ThreadingHTTPServer((HOST, PORT), Handler)
print(f"Head CT Morph backend running at http://{HOST}:{PORT}") print(f"Head CT Morph backend running at http://{HOST}:{PORT}")
server.serve_forever() server.serve_forever()