first commit
This commit is contained in:
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Node/Vite
|
||||||
|
WebSite/node_modules/
|
||||||
|
WebSite/dist/
|
||||||
|
WebSite/.env.local
|
||||||
|
|
||||||
|
# Runtime outputs and uploaded data
|
||||||
|
app_output/
|
||||||
|
app_previews/
|
||||||
|
ppt_video/
|
||||||
|
web_library/
|
||||||
|
web_results/
|
||||||
|
|
||||||
|
# Local DICOM/demo data and archives
|
||||||
|
Ori_Head_CT/
|
||||||
|
*.dcm
|
||||||
|
*.zip
|
||||||
|
*.mp4
|
||||||
|
|
||||||
|
# OS/editor
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
25
README.md
Normal file
25
README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Head CT Morph
|
||||||
|
|
||||||
|
头颈部 CT 仰头形变工具与网页工作站。
|
||||||
|
|
||||||
|
## 主要入口
|
||||||
|
|
||||||
|
- `web_backend.py`: 本地 Python API 后端
|
||||||
|
- `head_extension_app.py`: 四状态 DICOM 形变核心逻辑与桌面界面
|
||||||
|
- `generate_head_extension_video.py`: 仰头角度变化 MP4 生成
|
||||||
|
- `video_generator_app.py`: 视频生成桌面界面
|
||||||
|
- `WebSite/`: React/Vite 网页工作站
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
cd WebSite
|
||||||
|
npm install
|
||||||
|
npm run backend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据说明
|
||||||
|
|
||||||
|
仓库默认不提交 DICOM 数据、上传数据、运行结果、视频、ZIP 和 `node_modules`。这些内容会在本地运行时生成或由网页上传。
|
||||||
9
WebSite/.env.example
Normal file
9
WebSite/.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||||
|
# AI Studio automatically injects this at runtime from user secrets.
|
||||||
|
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||||
|
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||||
|
|
||||||
|
# APP_URL: The URL where this applet is hosted.
|
||||||
|
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||||
|
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||||
|
APP_URL="MY_APP_URL"
|
||||||
8
WebSite/.gitignore
vendored
Normal file
8
WebSite/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
41
WebSite/README.md
Normal file
41
WebSite/README.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# Run and deploy your AI Studio app
|
||||||
|
|
||||||
|
This contains everything you need to run your app locally.
|
||||||
|
|
||||||
|
View your app in AI Studio: https://ai.studio/apps/b9650cf9-3b26-4e84-a699-78ba380bb4db
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
|
||||||
|
**Prerequisites:** Node.js, Python 3.8+
|
||||||
|
|
||||||
|
1. Install Python dependencies in the project root:
|
||||||
|
`pip install -r ../requirements.txt`
|
||||||
|
2. Install website dependencies:
|
||||||
|
`npm install`
|
||||||
|
3. Start the local Python backend:
|
||||||
|
`npm run backend`
|
||||||
|
4. In another terminal, run the website:
|
||||||
|
`npm run dev`
|
||||||
|
|
||||||
|
The website talks to `http://127.0.0.1:8787` and maps UI actions to:
|
||||||
|
|
||||||
|
- `head_extension_app.py`: preview and four-state DICOM deformation output
|
||||||
|
- `generate_head_extension_video.py`: 0° to target-angle MP4 generation
|
||||||
|
- `video_generator_app.py`: kept as the desktop GUI wrapper for the same video generator
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
The Image Library is now the source of DICOM data for the workstation:
|
||||||
|
|
||||||
|
1. Open `数据影像库`.
|
||||||
|
2. Click `上传文件夹` to choose a folder that contains `.dcm` files, or click `上传压缩包` to upload a `.zip` archive containing `.dcm` files.
|
||||||
|
3. Click `调阅工作站` on a library item.
|
||||||
|
4. The `影像变换工作站` will run preview, four-state deformation, and video generation from that selected library dataset.
|
||||||
|
5. Four-state deformation results are packaged by the backend as a downloadable `.zip` file under `../web_results/`.
|
||||||
|
6. Generated videos are also written under `../web_results/` and exposed as downloadable `.mp4` files.
|
||||||
|
|
||||||
|
Uploaded DICOM datasets are stored by the local backend under `../web_library/`.
|
||||||
13
WebSite/index.html
Normal file
13
WebSite/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>My Google AI Studio App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
6
WebSite/metadata.json
Normal file
6
WebSite/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"description": "",
|
||||||
|
"requestFramePermissions": [],
|
||||||
|
"majorCapabilities": []
|
||||||
|
}
|
||||||
4320
WebSite/package-lock.json
generated
Normal file
4320
WebSite/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
WebSite/package.json
Normal file
35
WebSite/package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "react-example",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||||
|
"backend": "python ../web_backend.py",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@google/genai": "^1.29.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"lucide-react": "^0.546.0",
|
||||||
|
"react": "^19.0.1",
|
||||||
|
"react-dom": "^19.0.1",
|
||||||
|
"vite": "^6.2.3",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"motion": "^12.23.24"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.3",
|
||||||
|
"@types/express": "^4.17.21"
|
||||||
|
}
|
||||||
|
}
|
||||||
1048
WebSite/src/App.tsx
Normal file
1048
WebSite/src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1
WebSite/src/index.css
Normal file
1
WebSite/src/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
10
WebSite/src/main.tsx
Normal file
10
WebSite/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import {StrictMode} from 'react';
|
||||||
|
import {createRoot} from 'react-dom/client';
|
||||||
|
import App from './App.tsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
26
WebSite/tsconfig.json
Normal file
26
WebSite/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowJs": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
24
WebSite/vite.config.ts
Normal file
24
WebSite/vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
import {defineConfig, loadEnv} from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig(({mode}) => {
|
||||||
|
const env = loadEnv(mode, '.', '');
|
||||||
|
return {
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
define: {
|
||||||
|
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
190
generate_head_extension_video.py
Normal file
190
generate_head_extension_video.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import os
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import imageio.v2 as imageio
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from scipy.ndimage import map_coordinates
|
||||||
|
|
||||||
|
from head_extension_app import (
|
||||||
|
crop_head_neck,
|
||||||
|
fit_image,
|
||||||
|
load_dicom_volume,
|
||||||
|
sagittal_mip,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
OUTPUT_DIR = Path("ppt_video")
|
||||||
|
|
||||||
|
FPS = 30
|
||||||
|
DURATION_SECONDS = 6
|
||||||
|
END_HOLD_SECONDS = 1
|
||||||
|
|
||||||
|
|
||||||
|
def video_soft_bend_2d(image, angle_degrees):
|
||||||
|
"""Video-only 2D deformation with a broad neck transition.
|
||||||
|
|
||||||
|
The app's fast preview uses a compact transition, which is useful for
|
||||||
|
interactive feedback but can visibly split vertebrae in animation. This
|
||||||
|
broad ramp keeps the head and upper cervical spine moving together and
|
||||||
|
blends gradually into the lower neck/shoulder region.
|
||||||
|
"""
|
||||||
|
arr = np.asarray(image.convert("L")).astype(np.float32)
|
||||||
|
height, width = arr.shape
|
||||||
|
yy, xx = np.mgrid[0:height, 0:width]
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
theta = np.deg2rad(angle_degrees) * weight
|
||||||
|
cos_t = np.cos(theta)
|
||||||
|
sin_t = np.sin(theta)
|
||||||
|
dx = xx - pivot_x
|
||||||
|
dy = yy - pivot_y
|
||||||
|
|
||||||
|
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)
|
||||||
|
return Image.fromarray(np.clip(warped, 0, 255).astype(np.uint8)).convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def get_font(size):
|
||||||
|
for path in [r"C:\Windows\Fonts\arial.ttf", r"C:\Windows\Fonts\calibri.ttf"]:
|
||||||
|
if os.path.exists(path):
|
||||||
|
return ImageFont.truetype(path, size)
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
frame = Image.new("RGB", (1920, 1080), (0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(frame)
|
||||||
|
|
||||||
|
left = fit_image(before_image, 760, 720)
|
||||||
|
right = fit_image(after_image, 760, 720)
|
||||||
|
frame.paste(left, (120, 245))
|
||||||
|
frame.paste(right, (1040, 245))
|
||||||
|
|
||||||
|
title_font = get_font(48)
|
||||||
|
angle_font = get_font(56)
|
||||||
|
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",
|
||||||
|
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))
|
||||||
|
|
||||||
|
# Invisible-looking spacer: keeps the font imported above alive for some PIL builds.
|
||||||
|
_ = angle_font
|
||||||
|
return frame
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Generate a 0-degree to target-angle head-extension MP4 video."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--input",
|
||||||
|
default="input_ct_2F",
|
||||||
|
help="Input DICOM folder. Default: input_ct_2F",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
default=str(OUTPUT_DIR / "head_extension_0_to_20deg.mp4"),
|
||||||
|
help="Output MP4 file path.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-angle",
|
||||||
|
type=float,
|
||||||
|
default=20.0,
|
||||||
|
help="Target head-extension angle. Default: 20",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--duration",
|
||||||
|
type=float,
|
||||||
|
default=DURATION_SECONDS,
|
||||||
|
help="Animation duration in seconds before final hold. Default: 6",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_video(input_dir, output_path, max_angle=20.0, duration_seconds=6.0):
|
||||||
|
output_file = Path(output_path)
|
||||||
|
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
volume = load_dicom_volume(input_dir)
|
||||||
|
before_image = crop_head_neck(sagittal_mip(volume))
|
||||||
|
|
||||||
|
moving_frames = int(FPS * duration_seconds)
|
||||||
|
hold_frames = FPS * END_HOLD_SECONDS
|
||||||
|
|
||||||
|
with imageio.get_writer(
|
||||||
|
output_file,
|
||||||
|
format="FFMPEG",
|
||||||
|
fps=FPS,
|
||||||
|
codec="libx264",
|
||||||
|
quality=8,
|
||||||
|
macro_block_size=1,
|
||||||
|
) as writer:
|
||||||
|
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)))
|
||||||
|
|
||||||
|
for _ in range(hold_frames):
|
||||||
|
writer.append_data(np.asarray(make_frame(before_image, max_angle, max_angle)))
|
||||||
|
|
||||||
|
return output_file.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
output_file = generate_video(args.input, args.output, args.max_angle, args.duration)
|
||||||
|
print(output_file)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
641
head_extension_app.py
Normal file
641
head_extension_app.py
Normal file
@@ -0,0 +1,641 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from tkinter import (
|
||||||
|
BOTH,
|
||||||
|
DISABLED,
|
||||||
|
END,
|
||||||
|
HORIZONTAL,
|
||||||
|
NORMAL,
|
||||||
|
Button,
|
||||||
|
Entry,
|
||||||
|
Frame,
|
||||||
|
Label,
|
||||||
|
Scale,
|
||||||
|
StringVar,
|
||||||
|
Tk,
|
||||||
|
filedialog,
|
||||||
|
messagebox,
|
||||||
|
)
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pydicom
|
||||||
|
import SimpleITK as sitk
|
||||||
|
from PIL import Image, ImageDraw, ImageFont, ImageTk
|
||||||
|
from platipy.imaging.generation.mask import get_external_mask
|
||||||
|
from platipy.imaging.registration.utils import apply_transform
|
||||||
|
|
||||||
|
np.alen = len
|
||||||
|
|
||||||
|
from DeformHeadCT.VolumeInfo import VolumeDeformation, convert_nifti_to_dicom_series
|
||||||
|
from DeformHeadCT.deformation import HeadDeformation, generate_field_rotation
|
||||||
|
|
||||||
|
|
||||||
|
APP_DIR = Path(__file__).resolve().parent
|
||||||
|
RUNTIME_DIR = Path(tempfile.gettempdir()) / "HeadExtensionApp"
|
||||||
|
PREVIEW_DIR = APP_DIR / "app_previews"
|
||||||
|
|
||||||
|
DEFAULT_COORDINATES_CUTOFF = [[145, 305, 256], [135, 205, 256]]
|
||||||
|
DEFAULT_VERTEBRAE = {
|
||||||
|
"Oc-C1": [220, 255, 256],
|
||||||
|
"C1-C2": [208, 255, 256],
|
||||||
|
"C2-C3": [190, 255, 256],
|
||||||
|
"C3-C4": [172, 258, 256],
|
||||||
|
"C4-C5": [154, 262, 256],
|
||||||
|
"C5-C6": [136, 268, 256],
|
||||||
|
"C6-C7": [118, 274, 256],
|
||||||
|
"C7-T1": [100, 282, 256],
|
||||||
|
}
|
||||||
|
|
||||||
|
STATE_LABELS = [
|
||||||
|
("original", "Original", "ct_original"),
|
||||||
|
("hard_boundary", "Hard boundary", "ct_hard_boundary"),
|
||||||
|
("gaussian_smooth", "Gaussian smooth", "ct_gaussian_smooth"),
|
||||||
|
("soft_transition", "Soft transition", "ct_soft_transition"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def safe_mkdir(path):
|
||||||
|
Path(path).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_dicom_to_ascii_folder(input_dir, run_id):
|
||||||
|
target = RUNTIME_DIR / run_id / "input_ct"
|
||||||
|
safe_mkdir(target)
|
||||||
|
count = 0
|
||||||
|
for file_path in Path(input_dir).iterdir():
|
||||||
|
if file_path.is_file() and file_path.suffix.lower() == ".dcm":
|
||||||
|
shutil.copy2(file_path, target / file_path.name)
|
||||||
|
count += 1
|
||||||
|
if count == 0:
|
||||||
|
raise RuntimeError("输入文件夹里没有找到 .dcm 文件。")
|
||||||
|
return target, count
|
||||||
|
|
||||||
|
|
||||||
|
def load_dicom_volume(input_dir):
|
||||||
|
items = []
|
||||||
|
for file_path in Path(input_dir).iterdir():
|
||||||
|
if file_path.is_file() and file_path.suffix.lower() == ".dcm":
|
||||||
|
ds = pydicom.dcmread(str(file_path), force=True)
|
||||||
|
instance = int(getattr(ds, "InstanceNumber", len(items)))
|
||||||
|
items.append((instance, ds))
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
raise RuntimeError("输入文件夹里没有找到 .dcm 文件。")
|
||||||
|
|
||||||
|
items.sort(key=lambda item: item[0])
|
||||||
|
volume = []
|
||||||
|
for _, ds in items:
|
||||||
|
image = ds.pixel_array.astype(np.float32)
|
||||||
|
image = image * float(getattr(ds, "RescaleSlope", 1))
|
||||||
|
image = image + float(getattr(ds, "RescaleIntercept", 0))
|
||||||
|
volume.append(image)
|
||||||
|
return np.stack(volume, axis=0)
|
||||||
|
|
||||||
|
|
||||||
|
def ct_window(image, low=-500, high=1200):
|
||||||
|
image = np.clip((image - low) / (high - low), 0, 1)
|
||||||
|
return (image * 255).astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
def sagittal_mip(volume):
|
||||||
|
x0 = max(0, volume.shape[2] // 2 - 21)
|
||||||
|
x1 = min(volume.shape[2], volume.shape[2] // 2 + 24)
|
||||||
|
image = ct_window(np.max(volume[:, :, x0:x1], axis=2))
|
||||||
|
return Image.fromarray(image).convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def crop_head_neck(image):
|
||||||
|
width, height = image.size
|
||||||
|
left = int(width * 0.09)
|
||||||
|
right = int(width * 0.89)
|
||||||
|
top = 0
|
||||||
|
bottom = int(height * 0.72)
|
||||||
|
return image.crop((left, top, right, bottom))
|
||||||
|
|
||||||
|
|
||||||
|
def fit_image(image, width, height):
|
||||||
|
scale = min(width / image.width, height / image.height)
|
||||||
|
resized = image.resize(
|
||||||
|
(int(image.width * scale), int(image.height * scale)),
|
||||||
|
Image.Resampling.LANCZOS,
|
||||||
|
)
|
||||||
|
canvas = Image.new("RGB", (width, height), (0, 0, 0))
|
||||||
|
canvas.paste(resized, ((width - resized.width) // 2, (height - resized.height) // 2))
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
def preview_deform_2d(image, angle_degrees):
|
||||||
|
"""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
|
||||||
|
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)
|
||||||
|
|
||||||
|
theta = np.deg2rad(angle_degrees) * weight
|
||||||
|
cos_t = np.cos(theta)
|
||||||
|
sin_t = np.sin(theta)
|
||||||
|
dx = xx - pivot_x
|
||||||
|
dy = yy - pivot_y
|
||||||
|
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)
|
||||||
|
return Image.fromarray(np.clip(warped, 0, 255).astype(np.uint8)).convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def transition_weight(image, coordinates_cutoff, width_voxels):
|
||||||
|
size_x, size_y, size_z = image.GetSize()
|
||||||
|
center_z = float(np.mean([p[0] for p in coordinates_cutoff]))
|
||||||
|
edge0 = center_z - float(width_voxels)
|
||||||
|
edge1 = center_z + float(width_voxels)
|
||||||
|
z_grid = np.arange(size_z, dtype=np.float32)[:, None, None]
|
||||||
|
weight = np.clip((z_grid - edge0) / (edge1 - edge0), 0, 1)
|
||||||
|
weight = weight * weight * (3 - 2 * weight)
|
||||||
|
return np.broadcast_to(weight, (size_z, size_y, size_x)).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def hard_boundary_weight(image, coordinates_cutoff):
|
||||||
|
size_x, size_y, size_z = image.GetSize()
|
||||||
|
center_z = float(np.mean([p[0] for p in coordinates_cutoff]))
|
||||||
|
z_grid = np.arange(size_z, dtype=np.float32)[:, None, None]
|
||||||
|
weight = (z_grid >= center_z).astype(np.float32)
|
||||||
|
return np.broadcast_to(weight, (size_z, size_y, size_x)).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def image_from_dvf_array(dvf_arr, reference_image):
|
||||||
|
dvf = sitk.GetImageFromArray(dvf_arr.astype(np.float32))
|
||||||
|
dvf.CopyInformation(reference_image)
|
||||||
|
return dvf
|
||||||
|
|
||||||
|
|
||||||
|
def apply_dvf_to_ct(image_ct, dvf):
|
||||||
|
transform = sitk.DisplacementFieldTransform(sitk.Cast(dvf, sitk.sitkVectorFloat64))
|
||||||
|
return apply_transform(
|
||||||
|
image_ct,
|
||||||
|
transform=transform,
|
||||||
|
interpolator=sitk.sitkLinear,
|
||||||
|
default_value=int(sitk.GetArrayViewFromImage(image_ct).min()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_folder(path):
|
||||||
|
path = Path(path)
|
||||||
|
if path.exists():
|
||||||
|
shutil.rmtree(path)
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def write_dicom_series(image, reference_dicom_dir, output_dir, run_root, state_key):
|
||||||
|
output_dir = Path(output_dir)
|
||||||
|
reset_folder(output_dir)
|
||||||
|
ascii_output_dir = run_root / "dicom_output" / state_key
|
||||||
|
reset_folder(ascii_output_dir)
|
||||||
|
convert_nifti_to_dicom_series(
|
||||||
|
image=image,
|
||||||
|
reference_dcm=str(reference_dicom_dir),
|
||||||
|
output_directory=str(ascii_output_dir),
|
||||||
|
)
|
||||||
|
for dicom_path in ascii_output_dir.glob("*.dcm"):
|
||||||
|
shutil.copy2(dicom_path, output_dir / dicom_path.name)
|
||||||
|
|
||||||
|
|
||||||
|
def write_info_json(info_path, input_dir, temp_dir, output_dir, angle_degrees, transition_width):
|
||||||
|
data = {
|
||||||
|
"name": "HEAD_EXTENSION",
|
||||||
|
"InputDirectory": str(input_dir).replace("\\", "/"),
|
||||||
|
"TempDirectory": str(temp_dir).replace("\\", "/"),
|
||||||
|
"OutputDirectory": str(output_dir).replace("\\", "/"),
|
||||||
|
"axes": [0, 0, -1],
|
||||||
|
"angles": [float(angle_degrees)],
|
||||||
|
"coordinates_cutoff": DEFAULT_COORDINATES_CUTOFF,
|
||||||
|
"transition_width_voxels": int(transition_width),
|
||||||
|
}
|
||||||
|
data.update(DEFAULT_VERTEBRAE)
|
||||||
|
info_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def run_deformation(input_dir, output_dir, angle_degrees, transition_width, progress):
|
||||||
|
run_id = time.strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:8]
|
||||||
|
run_root = RUNTIME_DIR / run_id
|
||||||
|
temp_dir = run_root / "temp"
|
||||||
|
ascii_input_dir, copied_count = copy_dicom_to_ascii_folder(input_dir, run_id)
|
||||||
|
|
||||||
|
safe_mkdir(output_dir)
|
||||||
|
info_path = run_root / "head_extension.json"
|
||||||
|
write_info_json(
|
||||||
|
info_path,
|
||||||
|
ascii_input_dir,
|
||||||
|
temp_dir,
|
||||||
|
Path(output_dir),
|
||||||
|
angle_degrees,
|
||||||
|
transition_width,
|
||||||
|
)
|
||||||
|
|
||||||
|
progress(f"已复制 {copied_count} 张 DICOM,开始转换体数据...")
|
||||||
|
vol_info = VolumeDeformation(InfoFile=str(info_path))
|
||||||
|
vol_info.PrepareDcmData()
|
||||||
|
|
||||||
|
patient_image_dir = vol_info.nifti_directory / vol_info.patientunderscore / "IMAGES"
|
||||||
|
if not patient_image_dir.exists():
|
||||||
|
patient_image_dir = next(vol_info.nifti_directory.glob("*/IMAGES"))
|
||||||
|
vol_info.patientunderscore = patient_image_dir.parent.name
|
||||||
|
|
||||||
|
root_image_dir = vol_info.nifti_directory / "IMAGES"
|
||||||
|
safe_mkdir(root_image_dir)
|
||||||
|
for image_path in patient_image_dir.glob("*.nii.gz"):
|
||||||
|
shutil.copy2(image_path, root_image_dir / image_path.name)
|
||||||
|
|
||||||
|
progress("正在生成四种状态的三维位移场...")
|
||||||
|
head_def = HeadDeformation(vol_info.nifti_directory, vol_info.patientunderscore, 0)
|
||||||
|
external_mask = get_external_mask(head_def.image_ct)
|
||||||
|
|
||||||
|
_, _, full_rotation_dvf, _ = generate_field_rotation(
|
||||||
|
head_def.image_ct,
|
||||||
|
external_mask,
|
||||||
|
tuple(vol_info.point_of_rotation[0]),
|
||||||
|
axis_of_rotation=vol_info.axes,
|
||||||
|
angle=-vol_info.angles[0] * np.pi / 180,
|
||||||
|
gaussian_smooth=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
base_dvf_arr = sitk.GetArrayFromImage(full_rotation_dvf).astype(np.float32)
|
||||||
|
mask_arr = sitk.GetArrayFromImage(external_mask).astype(np.float32)
|
||||||
|
hard_weight = hard_boundary_weight(head_def.image_ct, vol_info.coordinates_cutoff)
|
||||||
|
soft_weight = transition_weight(
|
||||||
|
head_def.image_ct,
|
||||||
|
vol_info.coordinates_cutoff,
|
||||||
|
int(transition_width),
|
||||||
|
)
|
||||||
|
|
||||||
|
hard_dvf = image_from_dvf_array(
|
||||||
|
base_dvf_arr * (hard_weight * mask_arr)[..., None],
|
||||||
|
head_def.image_ct,
|
||||||
|
)
|
||||||
|
gaussian_dvf = sitk.SmoothingRecursiveGaussian(hard_dvf, 3)
|
||||||
|
soft_dvf = image_from_dvf_array(
|
||||||
|
base_dvf_arr * (soft_weight * mask_arr)[..., None],
|
||||||
|
head_def.image_ct,
|
||||||
|
)
|
||||||
|
soft_dvf = sitk.SmoothingRecursiveGaussian(soft_dvf, 3)
|
||||||
|
|
||||||
|
progress("正在应用形变...")
|
||||||
|
state_images = {
|
||||||
|
"original": head_def.image_ct,
|
||||||
|
"hard_boundary": apply_dvf_to_ct(head_def.image_ct, hard_dvf),
|
||||||
|
"gaussian_smooth": apply_dvf_to_ct(head_def.image_ct, gaussian_dvf),
|
||||||
|
"soft_transition": apply_dvf_to_ct(head_def.image_ct, soft_dvf),
|
||||||
|
}
|
||||||
|
|
||||||
|
progress("正在写出四种状态的完整 DICOM 序列...")
|
||||||
|
reference_dicom_dir = vol_info.nifti_directory / "dicom" / "ct"
|
||||||
|
output_paths = {}
|
||||||
|
for state_key, _, folder_name in STATE_LABELS:
|
||||||
|
state_output_dir = Path(output_dir) / folder_name
|
||||||
|
write_dicom_series(
|
||||||
|
state_images[state_key],
|
||||||
|
reference_dicom_dir,
|
||||||
|
state_output_dir,
|
||||||
|
run_root,
|
||||||
|
state_key,
|
||||||
|
)
|
||||||
|
output_paths[state_key] = state_output_dir
|
||||||
|
|
||||||
|
legacy_soft_dir = Path(output_dir) / "ct"
|
||||||
|
write_dicom_series(
|
||||||
|
state_images["soft_transition"],
|
||||||
|
reference_dicom_dir,
|
||||||
|
legacy_soft_dir,
|
||||||
|
run_root,
|
||||||
|
"legacy_soft_transition",
|
||||||
|
)
|
||||||
|
output_paths["legacy_soft"] = legacy_soft_dir
|
||||||
|
|
||||||
|
progress("正在生成四状态过程对比图...")
|
||||||
|
preview_paths = make_four_state_preview(state_images, Path(output_dir), angle_degrees)
|
||||||
|
make_output_preview_from_images(
|
||||||
|
state_images["original"],
|
||||||
|
state_images["soft_transition"],
|
||||||
|
Path(output_dir),
|
||||||
|
angle_degrees,
|
||||||
|
)
|
||||||
|
return output_paths, preview_paths
|
||||||
|
|
||||||
|
|
||||||
|
def make_output_preview(original_dir, deformed_dicom_dir, output_dir, angle_degrees):
|
||||||
|
safe_mkdir(PREVIEW_DIR)
|
||||||
|
orig = load_dicom_volume(original_dir)
|
||||||
|
deformed = load_dicom_volume(deformed_dicom_dir)[::-1]
|
||||||
|
|
||||||
|
before = crop_head_neck(sagittal_mip(orig))
|
||||||
|
after = crop_head_neck(sagittal_mip(deformed))
|
||||||
|
|
||||||
|
slide = Image.new("RGB", (2560, 1440), (0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(slide)
|
||||||
|
font_path = Path(r"C:\Windows\Fonts\arial.ttf")
|
||||||
|
title_font = ImageFont.truetype(str(font_path), 58) if font_path.exists() else ImageFont.load_default()
|
||||||
|
|
||||||
|
slide.paste(fit_image(before, 920, 675), (280, 390))
|
||||||
|
slide.paste(fit_image(after, 920, 675), (1360, 390))
|
||||||
|
draw.text((300, 190), "Before", font=title_font, fill=(255, 255, 255))
|
||||||
|
draw.text(
|
||||||
|
(1380, 190),
|
||||||
|
f"After: {angle_degrees:g} deg head extension",
|
||||||
|
font=title_font,
|
||||||
|
fill=(255, 255, 255),
|
||||||
|
)
|
||||||
|
arrow = (255, 210, 60)
|
||||||
|
x0, y0, x1, y1 = 1685, 545, 1855, 455
|
||||||
|
draw.line((x0, y0, x1, y1), fill=arrow, width=10)
|
||||||
|
draw.polygon([(x1, y1), (x1 - 42, y1 + 5), (x1 - 15, y1 + 36)], fill=arrow)
|
||||||
|
|
||||||
|
preview_path = output_dir / "before_after_preview.png"
|
||||||
|
slide.save(preview_path, quality=95)
|
||||||
|
return preview_path
|
||||||
|
|
||||||
|
|
||||||
|
def sitk_sagittal_panel(image):
|
||||||
|
volume = sitk.GetArrayFromImage(image)[::-1]
|
||||||
|
return crop_head_neck(sagittal_mip(volume))
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
slide = Image.new("RGB", (2560, 1440), (0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(slide)
|
||||||
|
font_path = Path(r"C:\Windows\Fonts\arial.ttf")
|
||||||
|
title_font = ImageFont.truetype(str(font_path), 58) if font_path.exists() else ImageFont.load_default()
|
||||||
|
|
||||||
|
slide.paste(fit_image(before, 920, 675), (280, 390))
|
||||||
|
slide.paste(fit_image(after, 920, 675), (1360, 390))
|
||||||
|
draw.text((300, 190), "Before", font=title_font, fill=(255, 255, 255))
|
||||||
|
draw.text(
|
||||||
|
(1380, 190),
|
||||||
|
f"After: {angle_degrees:g} deg head extension",
|
||||||
|
font=title_font,
|
||||||
|
fill=(255, 255, 255),
|
||||||
|
)
|
||||||
|
arrow = (255, 210, 60)
|
||||||
|
x0, y0, x1, y1 = 1685, 545, 1855, 455
|
||||||
|
draw.line((x0, y0, x1, y1), fill=arrow, width=10)
|
||||||
|
draw.polygon([(x1, y1), (x1 - 42, y1 + 5), (x1 - 15, y1 + 36)], fill=arrow)
|
||||||
|
|
||||||
|
preview_path = Path(output_dir) / "before_after_preview.png"
|
||||||
|
slide.save(preview_path, quality=95)
|
||||||
|
return preview_path
|
||||||
|
|
||||||
|
|
||||||
|
def make_four_state_preview(state_images, output_dir, angle_degrees):
|
||||||
|
output_dir = Path(output_dir)
|
||||||
|
screenshot_dir = output_dir / "process_screenshots"
|
||||||
|
reset_folder(screenshot_dir)
|
||||||
|
|
||||||
|
panels = []
|
||||||
|
for state_key, label, _ in STATE_LABELS:
|
||||||
|
panel = sitk_sagittal_panel(state_images[state_key])
|
||||||
|
panel_path = screenshot_dir / f"{state_key}.png"
|
||||||
|
panel.save(panel_path, quality=95)
|
||||||
|
panels.append((label, panel))
|
||||||
|
|
||||||
|
slide = Image.new("RGB", (2560, 720), (0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(slide)
|
||||||
|
font_path = Path(r"C:\Windows\Fonts\arial.ttf")
|
||||||
|
title_font = ImageFont.truetype(str(font_path), 36) if font_path.exists() else ImageFont.load_default()
|
||||||
|
small_font = ImageFont.truetype(str(font_path), 24) if font_path.exists() else ImageFont.load_default()
|
||||||
|
|
||||||
|
panel_width = 560
|
||||||
|
panel_height = 430
|
||||||
|
margin = 55
|
||||||
|
gap = 70
|
||||||
|
y_image = 145
|
||||||
|
y_title = 62
|
||||||
|
for index, (label, panel) in enumerate(panels):
|
||||||
|
x = margin + index * (panel_width + gap)
|
||||||
|
slide.paste(fit_image(panel, panel_width, panel_height), (x, y_image))
|
||||||
|
draw.text((x, y_title), label, font=title_font, fill=(255, 255, 255))
|
||||||
|
|
||||||
|
draw.text(
|
||||||
|
(margin, 650),
|
||||||
|
f"Head extension angle: {angle_degrees:g} deg",
|
||||||
|
font=small_font,
|
||||||
|
fill=(190, 190, 190),
|
||||||
|
)
|
||||||
|
|
||||||
|
comparison_path = output_dir / "process_comparison_4states.png"
|
||||||
|
slide.save(comparison_path, quality=95)
|
||||||
|
return {
|
||||||
|
"comparison": comparison_path,
|
||||||
|
"screenshots": screenshot_dir,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HeadExtensionApp:
|
||||||
|
def __init__(self, root):
|
||||||
|
self.root = root
|
||||||
|
self.root.title("头颈部 CT 仰头形变工具")
|
||||||
|
self.root.geometry("1180x820")
|
||||||
|
|
||||||
|
self.input_dir = StringVar(value=str(APP_DIR / "input_ct_2F"))
|
||||||
|
self.output_dir = StringVar(value=str(APP_DIR / "app_output"))
|
||||||
|
self.status = StringVar(value="请选择 DICOM 文件夹,调节角度后可先预览,再生成四状态 DICOM 和过程对比图。")
|
||||||
|
self.angle_text = StringVar(value="12.0°")
|
||||||
|
self.transition_text = StringVar(value="45")
|
||||||
|
self.cached_volume = None
|
||||||
|
self.preview_photo = None
|
||||||
|
self.preview_after_id = None
|
||||||
|
|
||||||
|
self.build_ui()
|
||||||
|
|
||||||
|
def build_ui(self):
|
||||||
|
top = Frame(self.root)
|
||||||
|
top.pack(fill="x", padx=12, pady=10)
|
||||||
|
|
||||||
|
Label(top, text="输入 DICOM 文件夹").grid(row=0, column=0, sticky="w")
|
||||||
|
Entry(top, textvariable=self.input_dir, width=92).grid(row=0, column=1, padx=8)
|
||||||
|
Button(top, text="选择", command=self.choose_input).grid(row=0, column=2)
|
||||||
|
|
||||||
|
Label(top, text="输出文件夹").grid(row=1, column=0, sticky="w", pady=6)
|
||||||
|
Entry(top, textvariable=self.output_dir, width=92).grid(row=1, column=1, padx=8)
|
||||||
|
Button(top, text="选择", command=self.choose_output).grid(row=1, column=2)
|
||||||
|
|
||||||
|
controls = Frame(self.root)
|
||||||
|
controls.pack(fill="x", padx=12)
|
||||||
|
|
||||||
|
Label(controls, text="仰头角度").grid(row=0, column=0, sticky="w")
|
||||||
|
self.angle = Scale(
|
||||||
|
controls,
|
||||||
|
from_=0,
|
||||||
|
to=20,
|
||||||
|
orient=HORIZONTAL,
|
||||||
|
resolution=0.5,
|
||||||
|
length=420,
|
||||||
|
command=self.on_angle_change,
|
||||||
|
)
|
||||||
|
self.angle.set(12)
|
||||||
|
self.angle.grid(row=0, column=1, sticky="w", padx=8)
|
||||||
|
Label(controls, textvariable=self.angle_text, width=8, anchor="w").grid(
|
||||||
|
row=0, column=2, sticky="w"
|
||||||
|
)
|
||||||
|
|
||||||
|
Label(controls, text="过渡平滑宽度").grid(row=0, column=3, sticky="w", padx=(20, 0))
|
||||||
|
self.transition = Scale(
|
||||||
|
controls,
|
||||||
|
from_=50,
|
||||||
|
to=160,
|
||||||
|
orient=HORIZONTAL,
|
||||||
|
resolution=10,
|
||||||
|
length=300,
|
||||||
|
command=self.on_transition_change,
|
||||||
|
)
|
||||||
|
self.transition.set(90)
|
||||||
|
self.transition.grid(row=0, column=4, sticky="w", padx=8)
|
||||||
|
Label(controls, textvariable=self.transition_text, width=8, anchor="w").grid(
|
||||||
|
row=0, column=5, sticky="w"
|
||||||
|
)
|
||||||
|
|
||||||
|
buttons = Frame(self.root)
|
||||||
|
buttons.pack(fill="x", padx=12, pady=8)
|
||||||
|
self.preview_button = Button(buttons, text="更新预览", command=self.update_preview, width=16)
|
||||||
|
self.preview_button.pack(side="left", padx=(0, 8))
|
||||||
|
self.run_all_button = Button(
|
||||||
|
buttons,
|
||||||
|
text="保存过程对比图+四状态DICOM",
|
||||||
|
command=self.start_run,
|
||||||
|
width=28,
|
||||||
|
)
|
||||||
|
self.run_all_button.pack(side="left")
|
||||||
|
|
||||||
|
self.preview_label = Label(self.root, bg="black")
|
||||||
|
self.preview_label.pack(fill=BOTH, expand=True, padx=12, pady=8)
|
||||||
|
|
||||||
|
Label(self.root, textvariable=self.status, anchor="w").pack(fill="x", padx=12, pady=(0, 8))
|
||||||
|
self.log = Entry(self.root)
|
||||||
|
self.log.pack(fill="x", padx=12, pady=(0, 10))
|
||||||
|
|
||||||
|
def on_angle_change(self, value):
|
||||||
|
self.angle_text.set(f"{float(value):.1f}°")
|
||||||
|
self.schedule_preview_refresh()
|
||||||
|
|
||||||
|
def on_transition_change(self, value):
|
||||||
|
self.transition_text.set(f"{int(float(value))}")
|
||||||
|
|
||||||
|
def schedule_preview_refresh(self):
|
||||||
|
if self.preview_after_id is not None:
|
||||||
|
self.root.after_cancel(self.preview_after_id)
|
||||||
|
self.preview_after_id = self.root.after(250, self.update_preview)
|
||||||
|
|
||||||
|
def choose_input(self):
|
||||||
|
path = filedialog.askdirectory(title="选择 DICOM 文件夹")
|
||||||
|
if path:
|
||||||
|
self.input_dir.set(path)
|
||||||
|
self.cached_volume = None
|
||||||
|
|
||||||
|
def choose_output(self):
|
||||||
|
path = filedialog.askdirectory(title="选择输出文件夹")
|
||||||
|
if path:
|
||||||
|
self.output_dir.set(path)
|
||||||
|
|
||||||
|
def set_busy(self, busy):
|
||||||
|
state = DISABLED if busy else NORMAL
|
||||||
|
self.preview_button.config(state=state)
|
||||||
|
self.run_all_button.config(state=state)
|
||||||
|
|
||||||
|
def update_status(self, message):
|
||||||
|
self.root.after(0, lambda: self.status.set(message))
|
||||||
|
|
||||||
|
def update_preview(self):
|
||||||
|
self.preview_after_id = None
|
||||||
|
try:
|
||||||
|
if self.cached_volume is None:
|
||||||
|
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()))
|
||||||
|
|
||||||
|
canvas = Image.new("RGB", (1120, 610), (0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(canvas)
|
||||||
|
before_panel = fit_image(before, 520, 500)
|
||||||
|
after_panel = fit_image(after, 520, 500)
|
||||||
|
canvas.paste(before_panel, (30, 80))
|
||||||
|
canvas.paste(after_panel, (570, 80))
|
||||||
|
draw.text((40, 25), "Before", fill=(255, 255, 255))
|
||||||
|
draw.text((580, 25), f"Preview: {float(self.angle.get()):g} deg", fill=(255, 255, 255))
|
||||||
|
|
||||||
|
self.preview_photo = ImageTk.PhotoImage(canvas)
|
||||||
|
self.preview_label.config(image=self.preview_photo)
|
||||||
|
self.status.set("预览已更新。预览是快速 2D 示意,最终输出会使用 3D 位移场。")
|
||||||
|
except Exception as exc:
|
||||||
|
messagebox.showerror("预览失败", str(exc))
|
||||||
|
self.status.set("预览失败,请检查输入 DICOM 文件夹。")
|
||||||
|
|
||||||
|
def start_run(self):
|
||||||
|
self.set_busy(True)
|
||||||
|
self.status.set("开始生成四状态完整输出,请稍等。300 层 CT 通常需要 1-3 分钟。")
|
||||||
|
thread = threading.Thread(target=self.run_worker, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def run_worker(self):
|
||||||
|
try:
|
||||||
|
output_paths, preview_paths = run_deformation(
|
||||||
|
self.input_dir.get(),
|
||||||
|
self.output_dir.get(),
|
||||||
|
float(self.angle.get()),
|
||||||
|
int(self.transition.get()),
|
||||||
|
self.update_status,
|
||||||
|
)
|
||||||
|
self.update_status(f"完成。四状态 DICOM 与过程对比图已输出到:{self.output_dir.get()}")
|
||||||
|
self.root.after(
|
||||||
|
0,
|
||||||
|
lambda: messagebox.showinfo(
|
||||||
|
"完成",
|
||||||
|
"四状态完整输出已生成:\n"
|
||||||
|
f"Original:{output_paths['original']}\n"
|
||||||
|
f"Hard boundary:{output_paths['hard_boundary']}\n"
|
||||||
|
f"Gaussian smooth:{output_paths['gaussian_smooth']}\n"
|
||||||
|
f"Soft transition:{output_paths['soft_transition']}\n\n"
|
||||||
|
f"过程对比图:\n{preview_paths['comparison']}\n\n"
|
||||||
|
f"兼容旧版本的 Soft transition 输出:\n{output_paths['legacy_soft']}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
error_message = str(exc)
|
||||||
|
self.update_status("生成失败。")
|
||||||
|
self.root.after(0, lambda: messagebox.showerror("生成失败", error_message))
|
||||||
|
finally:
|
||||||
|
self.root.after(0, lambda: self.set_busy(False))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
safe_mkdir(RUNTIME_DIR)
|
||||||
|
safe_mkdir(PREVIEW_DIR)
|
||||||
|
root = Tk()
|
||||||
|
app = HeadExtensionApp(root)
|
||||||
|
app.update_preview()
|
||||||
|
root.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
numpy
|
||||||
|
scipy
|
||||||
|
pillow
|
||||||
|
pydicom
|
||||||
|
SimpleITK
|
||||||
|
platipy
|
||||||
|
CTHeadDeformation
|
||||||
|
imageio
|
||||||
|
imageio-ffmpeg
|
||||||
136
video_generator_app.py
Normal file
136
video_generator_app.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from tkinter import (
|
||||||
|
DISABLED,
|
||||||
|
HORIZONTAL,
|
||||||
|
NORMAL,
|
||||||
|
Button,
|
||||||
|
Entry,
|
||||||
|
Frame,
|
||||||
|
Label,
|
||||||
|
Scale,
|
||||||
|
StringVar,
|
||||||
|
Tk,
|
||||||
|
filedialog,
|
||||||
|
messagebox,
|
||||||
|
)
|
||||||
|
|
||||||
|
from generate_head_extension_video import generate_video
|
||||||
|
|
||||||
|
|
||||||
|
class VideoGeneratorApp:
|
||||||
|
def __init__(self, root):
|
||||||
|
self.root = root
|
||||||
|
self.root.title("仰头角度变化视频生成工具")
|
||||||
|
self.root.geometry("760x260")
|
||||||
|
|
||||||
|
self.input_dir = StringVar(value="input_ct_2F")
|
||||||
|
self.output_file = StringVar(value=str(Path("ppt_video") / "head_extension_0_to_20deg.mp4"))
|
||||||
|
self.status = StringVar(value="请选择 DICOM 文件夹,然后点击生成视频。")
|
||||||
|
self.angle_text = StringVar(value="20°")
|
||||||
|
self.duration_text = StringVar(value="6 秒")
|
||||||
|
|
||||||
|
self.build_ui()
|
||||||
|
|
||||||
|
def build_ui(self):
|
||||||
|
top = Frame(self.root)
|
||||||
|
top.pack(fill="x", padx=12, pady=12)
|
||||||
|
|
||||||
|
Label(top, text="DICOM 文件夹").grid(row=0, column=0, sticky="w")
|
||||||
|
Entry(top, textvariable=self.input_dir, width=72).grid(row=0, column=1, padx=8)
|
||||||
|
Button(top, text="选择", command=self.choose_input).grid(row=0, column=2)
|
||||||
|
|
||||||
|
Label(top, text="输出视频").grid(row=1, column=0, sticky="w", pady=8)
|
||||||
|
Entry(top, textvariable=self.output_file, width=72).grid(row=1, column=1, padx=8)
|
||||||
|
Button(top, text="选择", command=self.choose_output).grid(row=1, column=2)
|
||||||
|
|
||||||
|
controls = Frame(self.root)
|
||||||
|
controls.pack(fill="x", padx=12, pady=4)
|
||||||
|
|
||||||
|
Label(controls, text="最大仰头角度").grid(row=0, column=0, sticky="w")
|
||||||
|
self.max_angle = Scale(
|
||||||
|
controls,
|
||||||
|
from_=5,
|
||||||
|
to=30,
|
||||||
|
orient=HORIZONTAL,
|
||||||
|
resolution=1,
|
||||||
|
length=360,
|
||||||
|
command=self.on_angle_change,
|
||||||
|
)
|
||||||
|
self.max_angle.set(20)
|
||||||
|
self.max_angle.grid(row=0, column=1, padx=8)
|
||||||
|
Label(controls, textvariable=self.angle_text, width=8, anchor="w").grid(row=0, column=2)
|
||||||
|
|
||||||
|
Label(controls, text="动画时长").grid(row=1, column=0, sticky="w")
|
||||||
|
self.duration = Scale(
|
||||||
|
controls,
|
||||||
|
from_=3,
|
||||||
|
to=12,
|
||||||
|
orient=HORIZONTAL,
|
||||||
|
resolution=1,
|
||||||
|
length=360,
|
||||||
|
command=self.on_duration_change,
|
||||||
|
)
|
||||||
|
self.duration.set(6)
|
||||||
|
self.duration.grid(row=1, column=1, padx=8)
|
||||||
|
Label(controls, textvariable=self.duration_text, width=8, anchor="w").grid(row=1, column=2)
|
||||||
|
|
||||||
|
bottom = Frame(self.root)
|
||||||
|
bottom.pack(fill="x", padx=12, pady=12)
|
||||||
|
self.generate_button = Button(bottom, text="生成视频", width=18, command=self.start_generate)
|
||||||
|
self.generate_button.pack(side="left")
|
||||||
|
Label(bottom, textvariable=self.status, anchor="w").pack(side="left", padx=12)
|
||||||
|
|
||||||
|
def choose_input(self):
|
||||||
|
path = filedialog.askdirectory(title="选择 DICOM 文件夹")
|
||||||
|
if path:
|
||||||
|
self.input_dir.set(path)
|
||||||
|
|
||||||
|
def choose_output(self):
|
||||||
|
path = filedialog.asksaveasfilename(
|
||||||
|
title="选择输出视频路径",
|
||||||
|
defaultextension=".mp4",
|
||||||
|
filetypes=[("MP4 视频", "*.mp4")],
|
||||||
|
initialfile="head_extension_0_to_20deg.mp4",
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
self.output_file.set(path)
|
||||||
|
|
||||||
|
def on_angle_change(self, value):
|
||||||
|
self.angle_text.set(f"{float(value):.0f}°")
|
||||||
|
|
||||||
|
def on_duration_change(self, value):
|
||||||
|
self.duration_text.set(f"{float(value):.0f} 秒")
|
||||||
|
|
||||||
|
def start_generate(self):
|
||||||
|
self.generate_button.config(state=DISABLED)
|
||||||
|
self.status.set("正在生成视频,请稍等...")
|
||||||
|
thread = threading.Thread(target=self.generate_worker, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def generate_worker(self):
|
||||||
|
try:
|
||||||
|
output = generate_video(
|
||||||
|
self.input_dir.get(),
|
||||||
|
self.output_file.get(),
|
||||||
|
float(self.max_angle.get()),
|
||||||
|
float(self.duration.get()),
|
||||||
|
)
|
||||||
|
self.root.after(0, lambda: self.status.set(f"完成:{output}"))
|
||||||
|
self.root.after(0, lambda: messagebox.showinfo("完成", f"视频已生成:\n{output}"))
|
||||||
|
except Exception as exc:
|
||||||
|
error_message = str(exc)
|
||||||
|
self.root.after(0, lambda: self.status.set("生成失败。"))
|
||||||
|
self.root.after(0, lambda: messagebox.showerror("生成失败", error_message))
|
||||||
|
finally:
|
||||||
|
self.root.after(0, lambda: self.generate_button.config(state=NORMAL))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
root = Tk()
|
||||||
|
VideoGeneratorApp(root)
|
||||||
|
root.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
507
web_backend.py
Normal file
507
web_backend.py
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
from email.parser import BytesParser
|
||||||
|
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
|
||||||
|
|
||||||
|
os.environ.setdefault("MPLCONFIGDIR", "/tmp/head_ct_morph_matplotlib")
|
||||||
|
|
||||||
|
from generate_head_extension_video import generate_video
|
||||||
|
from head_extension_app import (
|
||||||
|
APP_DIR,
|
||||||
|
crop_head_neck,
|
||||||
|
fit_image,
|
||||||
|
load_dicom_volume,
|
||||||
|
preview_deform_2d,
|
||||||
|
run_deformation,
|
||||||
|
sagittal_mip,
|
||||||
|
safe_mkdir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
HOST = "0.0.0.0"
|
||||||
|
PORT = 8787
|
||||||
|
JOBS = {}
|
||||||
|
JOBS_LOCK = threading.Lock()
|
||||||
|
LIBRARY_DIR = APP_DIR / "web_library"
|
||||||
|
LIBRARY_META = LIBRARY_DIR / "library.json"
|
||||||
|
RESULT_DIR = APP_DIR / "web_results"
|
||||||
|
|
||||||
|
|
||||||
|
def json_default(value):
|
||||||
|
if isinstance(value, Path):
|
||||||
|
return str(value.resolve())
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def now_date():
|
||||||
|
return time.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_filename(name):
|
||||||
|
return "".join(char if char.isalnum() or char in "._-" else "_" for char in Path(name).name)
|
||||||
|
|
||||||
|
|
||||||
|
def read_library_meta():
|
||||||
|
safe_mkdir(LIBRARY_DIR)
|
||||||
|
if LIBRARY_META.exists():
|
||||||
|
return json.loads(LIBRARY_META.read_text(encoding="utf-8"))
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def write_library_meta(items):
|
||||||
|
safe_mkdir(LIBRARY_DIR)
|
||||||
|
LIBRARY_META.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def folder_size_mb(path):
|
||||||
|
total = 0
|
||||||
|
for file_path in Path(path).glob("*.dcm"):
|
||||||
|
total += file_path.stat().st_size
|
||||||
|
return round(total / 1024 / 1024)
|
||||||
|
|
||||||
|
|
||||||
|
def build_library_record(item_id, patient_id, dicom_path, source="upload", version="DICOM-LIB"):
|
||||||
|
dicom_dir = Path(dicom_path).resolve()
|
||||||
|
file_count = len(list(dicom_dir.glob("*.dcm")))
|
||||||
|
return {
|
||||||
|
"id": item_id,
|
||||||
|
"patientId": patient_id,
|
||||||
|
"date": now_date(),
|
||||||
|
"version": version,
|
||||||
|
"status": "processed",
|
||||||
|
"size": folder_size_mb(dicom_dir),
|
||||||
|
"previewColor": "bg-slate-900" if source == "upload" else "bg-slate-800",
|
||||||
|
"dicomPath": str(dicom_dir),
|
||||||
|
"fileCount": file_count,
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_library():
|
||||||
|
items = [
|
||||||
|
item for item in read_library_meta()
|
||||||
|
if item.get("source") != "seed" and item.get("id") != "seed_ori_head_ct"
|
||||||
|
]
|
||||||
|
|
||||||
|
live_items = []
|
||||||
|
changed = False
|
||||||
|
for item in items:
|
||||||
|
dicom_path = Path(item.get("dicomPath", ""))
|
||||||
|
if dicom_path.exists():
|
||||||
|
item["fileCount"] = len(list(dicom_path.glob("*.dcm")))
|
||||||
|
item["size"] = folder_size_mb(dicom_path)
|
||||||
|
live_items.append(item)
|
||||||
|
else:
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
write_library_meta(live_items)
|
||||||
|
return live_items
|
||||||
|
|
||||||
|
|
||||||
|
def parse_multipart(headers, body):
|
||||||
|
content_type = headers.get("content-type", "")
|
||||||
|
message = BytesParser(policy=default).parsebytes(
|
||||||
|
f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode("utf-8") + body
|
||||||
|
)
|
||||||
|
fields = {}
|
||||||
|
files = []
|
||||||
|
for part in message.iter_parts():
|
||||||
|
name = part.get_param("name", header="content-disposition")
|
||||||
|
filename = part.get_filename()
|
||||||
|
payload = part.get_payload(decode=True) or b""
|
||||||
|
if filename:
|
||||||
|
files.append((name, filename, payload))
|
||||||
|
elif name:
|
||||||
|
fields[name] = payload.decode("utf-8", errors="replace")
|
||||||
|
return fields, files
|
||||||
|
|
||||||
|
|
||||||
|
def write_dicom_payload(target_dir, filename, payload, index):
|
||||||
|
output_name = safe_filename(filename) or f"{index}.dcm"
|
||||||
|
output_path = target_dir / output_name
|
||||||
|
if output_path.exists():
|
||||||
|
output_path = target_dir / f"{index}_{output_name}"
|
||||||
|
output_path.write_bytes(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def add_dicom_from_zip(target_dir, zip_filename, payload, start_index):
|
||||||
|
count = 0
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(BytesIO(payload)) 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
|
||||||
|
count += 1
|
||||||
|
with archive.open(member) as member_file:
|
||||||
|
write_dicom_payload(
|
||||||
|
target_dir,
|
||||||
|
f"{Path(zip_filename).stem}_{Path(member_name).name}",
|
||||||
|
member_file.read(),
|
||||||
|
start_index + count,
|
||||||
|
)
|
||||||
|
except zipfile.BadZipFile as exc:
|
||||||
|
raise RuntimeError(f"{zip_filename} 不是有效的 zip 压缩包。") from exc
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def upload_library_item(headers, body):
|
||||||
|
fields, files = parse_multipart(headers, body)
|
||||||
|
dicom_files = [
|
||||||
|
(filename, payload)
|
||||||
|
for _, filename, payload in files
|
||||||
|
if Path(filename).suffix.lower() == ".dcm"
|
||||||
|
]
|
||||||
|
zip_files = [
|
||||||
|
(filename, payload)
|
||||||
|
for _, filename, payload in files
|
||||||
|
if Path(filename).suffix.lower() == ".zip"
|
||||||
|
]
|
||||||
|
if not dicom_files and not zip_files:
|
||||||
|
raise RuntimeError("上传内容里没有找到 .dcm 文件或 .zip 压缩包。")
|
||||||
|
|
||||||
|
item_id = time.strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:8]
|
||||||
|
first_upload_name = (dicom_files[0][0] if dicom_files else zip_files[0][0])
|
||||||
|
default_patient_id = Path(first_upload_name).stem.upper() if zip_files else f"WEB_{item_id[-8:].upper()}"
|
||||||
|
patient_id = fields.get("patientId") or default_patient_id
|
||||||
|
target_dir = LIBRARY_DIR / item_id / "dicom"
|
||||||
|
reset_dir(target_dir)
|
||||||
|
|
||||||
|
for index, (filename, payload) in enumerate(dicom_files, start=1):
|
||||||
|
write_dicom_payload(target_dir, filename, payload, index)
|
||||||
|
|
||||||
|
zip_dicom_count = 0
|
||||||
|
for filename, payload in zip_files:
|
||||||
|
zip_dicom_count += add_dicom_from_zip(
|
||||||
|
target_dir,
|
||||||
|
filename,
|
||||||
|
payload,
|
||||||
|
len(dicom_files) + zip_dicom_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
total_dicom_count = len(dicom_files) + zip_dicom_count
|
||||||
|
if total_dicom_count == 0:
|
||||||
|
shutil.rmtree(target_dir.parent)
|
||||||
|
raise RuntimeError("压缩包里没有找到 .dcm 文件。")
|
||||||
|
|
||||||
|
record = build_library_record(
|
||||||
|
item_id,
|
||||||
|
patient_id,
|
||||||
|
target_dir,
|
||||||
|
source="upload",
|
||||||
|
version="DICOM-ZIP" if zip_files and not dicom_files else "DICOM-LIB",
|
||||||
|
)
|
||||||
|
items = [record, *list_library()]
|
||||||
|
write_library_meta(items)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def reset_dir(path):
|
||||||
|
path = Path(path)
|
||||||
|
if path.exists():
|
||||||
|
shutil.rmtree(path)
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def zip_folder(source_dir, zip_path):
|
||||||
|
source_dir = Path(source_dir).resolve()
|
||||||
|
zip_path = Path(zip_path).resolve()
|
||||||
|
safe_mkdir(zip_path.parent)
|
||||||
|
if zip_path.exists():
|
||||||
|
zip_path.unlink()
|
||||||
|
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
for file_path in source_dir.rglob("*"):
|
||||||
|
if file_path.is_file():
|
||||||
|
archive.write(file_path, file_path.relative_to(source_dir))
|
||||||
|
return zip_path
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
def get_job(job_id):
|
||||||
|
with JOBS_LOCK:
|
||||||
|
job = JOBS.get(job_id)
|
||||||
|
return dict(job) if job else None
|
||||||
|
|
||||||
|
|
||||||
|
def start_job(kind, worker):
|
||||||
|
job_id = uuid.uuid4().hex[:12]
|
||||||
|
with JOBS_LOCK:
|
||||||
|
JOBS[job_id] = {
|
||||||
|
"id": job_id,
|
||||||
|
"kind": kind,
|
||||||
|
"status": "running",
|
||||||
|
"message": "任务已启动。",
|
||||||
|
"result": None,
|
||||||
|
"error": None,
|
||||||
|
"createdAt": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"updatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
result = worker(job_id)
|
||||||
|
set_job(job_id, status="completed", message="任务完成。", result=result)
|
||||||
|
except Exception as exc:
|
||||||
|
set_job(
|
||||||
|
job_id,
|
||||||
|
status="failed",
|
||||||
|
message="任务失败。",
|
||||||
|
error=str(exc),
|
||||||
|
traceback=traceback.format_exc(),
|
||||||
|
)
|
||||||
|
|
||||||
|
threading.Thread(target=run, daemon=True).start()
|
||||||
|
return get_job(job_id)
|
||||||
|
|
||||||
|
|
||||||
|
def make_preview(input_dir, angle_degrees):
|
||||||
|
volume = load_dicom_volume(input_dir)
|
||||||
|
before = crop_head_neck(sagittal_mip(volume))
|
||||||
|
after = preview_deform_2d(before, float(angle_degrees))
|
||||||
|
|
||||||
|
canvas = BytesIO()
|
||||||
|
preview = fit_image(after, 720, 520)
|
||||||
|
preview.save(canvas, format="PNG")
|
||||||
|
encoded = base64.b64encode(canvas.getvalue()).decode("ascii")
|
||||||
|
return {
|
||||||
|
"image": f"data:image/png;base64,{encoded}",
|
||||||
|
"source": str(Path(input_dir).resolve()),
|
||||||
|
"angleDegrees": float(angle_degrees),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_outputs(output_paths, preview_paths, zip_path=None):
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
print("%s - %s" % (self.address_string(), format % args))
|
||||||
|
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
self.send_response(204)
|
||||||
|
self.send_cors_headers()
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
if parsed.path == "/api/health":
|
||||||
|
self.send_json({"ok": True, "service": "Head CT Morph backend"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/defaults":
|
||||||
|
self.send_json(
|
||||||
|
{
|
||||||
|
"backend": f"http://127.0.0.1:{PORT}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/library":
|
||||||
|
self.send_json({"items": list_library()})
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/job":
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
job_id = params.get("id", [""])[0]
|
||||||
|
job = get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
self.send_json({"error": "任务不存在。"}, status=404)
|
||||||
|
return
|
||||||
|
self.send_json(job)
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/file":
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
file_path = Path(unquote(params.get("path", [""])[0])).resolve()
|
||||||
|
if not file_path.exists() or not file_path.is_file():
|
||||||
|
self.send_json({"error": "文件不存在。"}, status=404)
|
||||||
|
return
|
||||||
|
content_type = "application/octet-stream"
|
||||||
|
if file_path.suffix.lower() in [".png", ".jpg", ".jpeg"]:
|
||||||
|
content_type = "image/png" if file_path.suffix.lower() == ".png" else "image/jpeg"
|
||||||
|
elif file_path.suffix.lower() == ".mp4":
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_json({"error": "接口不存在。"}, status=404)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
try:
|
||||||
|
if parsed.path == "/api/library/upload":
|
||||||
|
body = self.read_bytes()
|
||||||
|
self.send_json(upload_library_item(self.headers, body), status=201)
|
||||||
|
return
|
||||||
|
|
||||||
|
body = self.read_json()
|
||||||
|
if parsed.path == "/api/preview":
|
||||||
|
self.send_json(make_preview(body["inputDir"], body.get("angleDegrees", 12)))
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/deformation":
|
||||||
|
input_dir = body["inputDir"]
|
||||||
|
angle_degrees = float(body.get("angleDegrees", 12))
|
||||||
|
transition_width = int(body.get("transitionWidth", 90))
|
||||||
|
|
||||||
|
def worker(job_id):
|
||||||
|
job_root = RESULT_DIR / job_id
|
||||||
|
output_dir = job_root / "four_state_output"
|
||||||
|
reset_dir(job_root)
|
||||||
|
|
||||||
|
def progress(message):
|
||||||
|
set_job(job_id, message=message)
|
||||||
|
|
||||||
|
output_paths, preview_paths = run_deformation(
|
||||||
|
input_dir,
|
||||||
|
output_dir,
|
||||||
|
angle_degrees,
|
||||||
|
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)
|
||||||
|
|
||||||
|
self.send_json(start_job("deformation", worker), 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))
|
||||||
|
|
||||||
|
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 = Path(output).resolve()
|
||||||
|
return {
|
||||||
|
"video": {
|
||||||
|
"path": str(output),
|
||||||
|
"name": output.name,
|
||||||
|
"size": output.stat().st_size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.send_json(start_job("video", worker), status=202)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_json({"error": "接口不存在。"}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self.send_json(
|
||||||
|
{
|
||||||
|
"error": str(exc),
|
||||||
|
"traceback": traceback.format_exc(),
|
||||||
|
},
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
def do_DELETE(self):
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
if parsed.path != "/api/library":
|
||||||
|
self.send_json({"error": "接口不存在。"}, status=404)
|
||||||
|
return
|
||||||
|
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
item_id = params.get("id", [""])[0]
|
||||||
|
items = list_library()
|
||||||
|
target = next((item for item in items if item["id"] == item_id), None)
|
||||||
|
if not target:
|
||||||
|
self.send_json({"error": "影像不存在。"}, status=404)
|
||||||
|
return
|
||||||
|
|
||||||
|
remaining = [item for item in items if item["id"] != item_id]
|
||||||
|
write_library_meta(remaining)
|
||||||
|
upload_root = Path(target["dicomPath"]).resolve().parent
|
||||||
|
if upload_root.exists() and upload_root.is_relative_to(LIBRARY_DIR.resolve()):
|
||||||
|
shutil.rmtree(upload_root)
|
||||||
|
self.send_json({"ok": True, "items": remaining})
|
||||||
|
|
||||||
|
def read_bytes(self):
|
||||||
|
length = int(self.headers.get("content-length", "0"))
|
||||||
|
if length == 0:
|
||||||
|
return b""
|
||||||
|
return self.rfile.read(length)
|
||||||
|
|
||||||
|
def read_json(self):
|
||||||
|
body = self.read_bytes()
|
||||||
|
if not body:
|
||||||
|
return {}
|
||||||
|
return json.loads(body.decode("utf-8"))
|
||||||
|
|
||||||
|
def send_cors_headers(self):
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
|
||||||
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
|
||||||
|
def send_json(self, payload, status=200):
|
||||||
|
data = json.dumps(payload, ensure_ascii=False, default=json_default).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_cors_headers()
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
safe_mkdir(APP_DIR / "app_output")
|
||||||
|
safe_mkdir(APP_DIR / "ppt_video")
|
||||||
|
safe_mkdir(LIBRARY_DIR)
|
||||||
|
safe_mkdir(RESULT_DIR)
|
||||||
|
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||||
|
print(f"Head CT Morph backend running at http://{HOST}:{PORT}")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
191
使用说明.md
Normal file
191
使用说明.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# 头颈部 CT 仰头形变工具使用说明
|
||||||
|
|
||||||
|
## 1. 工具用途
|
||||||
|
|
||||||
|
本工具用于将一组头颈部 CT DICOM 序列模拟成“仰头/后伸”状态,并输出新的 DICOM 序列和一张前后对比图。
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
|
||||||
|
- PPT 展示
|
||||||
|
- 方法演示
|
||||||
|
- 头颈部体位变化的初步可视化
|
||||||
|
- 插管体位下的仰头效果模拟
|
||||||
|
|
||||||
|
注意:本工具生成的是模拟形变结果,不应直接作为临床诊断或治疗计划依据。
|
||||||
|
|
||||||
|
## 2. 文件说明
|
||||||
|
|
||||||
|
用户版文件夹内包含:
|
||||||
|
|
||||||
|
- `启动头颈CT仰头形变工具.bat`:双击启动程序
|
||||||
|
- `head_extension_app.py`:主程序
|
||||||
|
- `generate_head_extension_video.py`:视频生成脚本
|
||||||
|
- `video_generator_app.py`:视频生成图形界面
|
||||||
|
- `requirements.txt`:Python 依赖包列表
|
||||||
|
- `使用说明.md`:本说明文档
|
||||||
|
- `生成0到20度仰头视频.bat`:生成角度连续变化视频
|
||||||
|
|
||||||
|
## 3. 运行环境
|
||||||
|
|
||||||
|
需要安装 Python 3.8 或以上版本。
|
||||||
|
|
||||||
|
首次使用前,在当前文件夹打开命令行,执行:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
如果已经在原电脑环境中配置好依赖,可直接双击启动。
|
||||||
|
|
||||||
|
## 4. 启动程序
|
||||||
|
|
||||||
|
双击:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
启动头颈CT仰头形变工具.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
若窗口没有打开,请在命令行中运行:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
python head_extension_app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
这样可以看到具体报错信息。
|
||||||
|
|
||||||
|
## 5. 输入数据要求
|
||||||
|
|
||||||
|
请选择一个只包含同一套 CT 序列 `.dcm` 文件的文件夹。
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 使用轴位 CT 序列
|
||||||
|
- 同一文件夹内不要混入多个 series
|
||||||
|
- 文件扩展名最好为 `.dcm`
|
||||||
|
- 推荐使用层厚较薄、覆盖完整头颈部的序列
|
||||||
|
|
||||||
|
## 6. 操作步骤
|
||||||
|
|
||||||
|
1. 点击“选择”,选择输入 DICOM 文件夹。
|
||||||
|
2. 点击“选择”,选择输出文件夹。
|
||||||
|
3. 调节“仰头角度”滑块。
|
||||||
|
4. 调节“过渡平滑宽度”滑块。
|
||||||
|
5. 点击“更新预览”查看快速示意。
|
||||||
|
6. 点击“生成形变 DICOM”。
|
||||||
|
7. 等待完成提示。
|
||||||
|
|
||||||
|
## 7. 参数说明
|
||||||
|
|
||||||
|
### 仰头角度
|
||||||
|
|
||||||
|
控制头部后伸程度。
|
||||||
|
|
||||||
|
建议范围:
|
||||||
|
|
||||||
|
- `5°`:轻微仰头
|
||||||
|
- `10°-15°`:较明显仰头
|
||||||
|
- `20°`:展示效果较强
|
||||||
|
|
||||||
|
### 过渡平滑宽度
|
||||||
|
|
||||||
|
控制头部旋转区域和肩颈固定区域之间的过渡范围。
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 默认 `90`
|
||||||
|
- 如果连接处仍有割裂感,可增大到 `110-140`
|
||||||
|
- 如果整体形变过软,可减小到 `60-80`
|
||||||
|
- 不建议使用过小数值,否则颈椎附近可能出现上下两段不连续的视觉效果
|
||||||
|
|
||||||
|
## 8. 输出结果
|
||||||
|
|
||||||
|
程序会在输出文件夹中生成:
|
||||||
|
|
||||||
|
- `ct_original/`:原始 Original 完整 DICOM 序列
|
||||||
|
- `ct_hard_boundary/`:硬边界 Hard boundary 完整 DICOM 序列
|
||||||
|
- `ct_gaussian_smooth/`:高斯平滑 Gaussian smooth 完整 DICOM 序列
|
||||||
|
- `ct_soft_transition/`:软过渡 Soft transition 完整 DICOM 序列
|
||||||
|
- `ct/`:兼容旧版本的软过渡 Soft transition DICOM 序列
|
||||||
|
- `process_comparison_4states.png`:四种状态过程对比图
|
||||||
|
- `process_screenshots/`:四种状态的单独截图
|
||||||
|
- `before_after_preview.png`:前后对比图
|
||||||
|
|
||||||
|
其中各个 `ct_*` 文件夹均可用 DICOM 查看器打开,也可作为后续 CT 三维重建的数据基础。
|
||||||
|
|
||||||
|
## 9. 常见问题
|
||||||
|
|
||||||
|
### 预览和最终 DICOM 完全一样吗?
|
||||||
|
|
||||||
|
不完全一样。
|
||||||
|
|
||||||
|
预览是快速 2D 示意,用于快速查看角度方向和大致效果;最终输出 DICOM 使用三维软过渡位移场生成。
|
||||||
|
|
||||||
|
### 为什么输出需要一段时间?
|
||||||
|
|
||||||
|
程序需要读取 CT、生成三维位移场、重采样体数据,并重新写出 DICOM。300 层 CT 通常需要 1-3 分钟。
|
||||||
|
|
||||||
|
### 为什么连接处仍可能有轻微不自然?
|
||||||
|
|
||||||
|
目前方法使用软过渡位移场模拟头颈后伸,不是完整的多节颈椎生物力学模型。如果需要更真实的临床级结果,需要进一步标注各颈椎旋转中心并建立多段运动模型。
|
||||||
|
|
||||||
|
### 中文路径会不会影响运行?
|
||||||
|
|
||||||
|
程序内部会自动把临时文件放到系统英文临时目录,尽量避免中文路径导致的第三方库兼容问题。输入和输出路径可以使用中文。
|
||||||
|
|
||||||
|
## 10. 推荐展示设置
|
||||||
|
|
||||||
|
用于 PPT 展示时,建议:
|
||||||
|
|
||||||
|
- 仰头角度:`12°-20°`
|
||||||
|
- 过渡平滑宽度:`90-120`
|
||||||
|
- 使用输出的 `process_comparison_4states.png` 或 `before_after_preview.png`
|
||||||
|
|
||||||
|
如果希望展示角度连续变化,可另行生成视频动画。
|
||||||
|
|
||||||
|
## 11. 生成 0° 到 20° 连续变化视频
|
||||||
|
|
||||||
|
用户版文件夹内提供了视频生成功能,可生成一段从 `0°` 平滑变化到 `20°` 的仰头动画。
|
||||||
|
|
||||||
|
双击:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
生成0到20度仰头视频.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
程序会打开一个视频生成窗口。
|
||||||
|
|
||||||
|
在窗口中可以:
|
||||||
|
|
||||||
|
- 选择 DICOM 文件夹
|
||||||
|
- 选择输出视频位置
|
||||||
|
- 调节最大仰头角度
|
||||||
|
- 调节动画时长
|
||||||
|
- 点击“生成视频”
|
||||||
|
|
||||||
|
生成的视频位于:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
ppt_video\head_extension_0_to_20deg.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
视频参数:
|
||||||
|
|
||||||
|
- 分辨率:`1920 × 1080`
|
||||||
|
- 帧率:`30 fps`
|
||||||
|
- 动画时长:约 `7 秒`
|
||||||
|
- 内容:左侧为原始 0° 图像,右侧为从 0° 平滑变化到 20° 的仰头效果
|
||||||
|
|
||||||
|
### 自定义视频角度和时长
|
||||||
|
|
||||||
|
不需要使用命令行。
|
||||||
|
|
||||||
|
在视频生成窗口中直接拖动滑块即可:
|
||||||
|
|
||||||
|
- “最大仰头角度”:控制视频最终达到的角度
|
||||||
|
- “动画时长”:控制从 0° 变化到最大角度所需时间
|
||||||
|
|
||||||
|
### 视频生成方式说明
|
||||||
|
|
||||||
|
视频使用轻量二维软弯曲预览算法生成,目的是用于 PPT 展示角度连续变化。它不会为每一帧重新生成完整三维 DICOM,因此速度较快。
|
||||||
|
|
||||||
|
如果需要生成某一个具体角度下的完整 DICOM 结果,请使用主程序 `启动头颈CT仰头形变工具.bat`。
|
||||||
Reference in New Issue
Block a user