Compare commits
25 Commits
664fec7485
...
149cbc95d3
| Author | SHA1 | Date | |
|---|---|---|---|
| 149cbc95d3 | |||
| f4bde6460a | |||
| f568faf6ed | |||
| 5ba2d48fdb | |||
| 2fcab9c71a | |||
| 49b797e7dc | |||
| d46f320a74 | |||
| a937583400 | |||
| 1d2d428aab | |||
| ea71713b0a | |||
| 2559522e3d | |||
| 5663e1c6d3 | |||
| 022828095b | |||
| a795aa13bf | |||
| 0aa9cffb97 | |||
| eb03bea7d4 | |||
| d5a6b1c935 | |||
| c6e39984a5 | |||
| 479a39d880 | |||
| 18541db9d4 | |||
| e6f9873036 | |||
| 89e6fef7f9 | |||
| 40dbd2c839 | |||
| bef76448f7 | |||
| 676ef25106 |
@@ -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
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -51,7 +51,7 @@ class VideoGeneratorApp:
|
||||
self.max_angle = Scale(
|
||||
controls,
|
||||
from_=5,
|
||||
to=30,
|
||||
to=45,
|
||||
orient=HORIZONTAL,
|
||||
resolution=1,
|
||||
length=360,
|
||||
|
||||
653
web_backend.py
653
web_backend.py
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user