update web workflow and preview behavior

This commit is contained in:
2026-05-03 05:41:22 +08:00
parent f4bde6460a
commit 149cbc95d3
5 changed files with 339 additions and 78 deletions

View File

@@ -86,12 +86,13 @@ const DEFAULT_PACKAGE_OPTIONS: PackageOptions = {
}; };
const VIDEO_SOURCE_OPTIONS = [ const VIDEO_SOURCE_OPTIONS = [
{ key: 'original', label: '原始序列' }, { key: 'hard_boundary', label: '硬边界', mode: 'hard_boundary' },
{ key: 'hard_boundary', label: '硬边界' }, { key: 'gaussian_smooth', label: '高斯平滑', mode: 'gaussian_smooth' },
{ key: 'gaussian_smooth', label: '高斯平滑' }, { key: 'soft_transition', label: '软过渡重建', mode: 'soft_transition' },
{ key: 'soft_transition', label: '软过渡重建' },
]; ];
const PREVIEW_ALGORITHM_OPTIONS = VIDEO_SOURCE_OPTIONS;
type LibraryItem = { type LibraryItem = {
id: string; id: string;
patientId: string; patientId: string;
@@ -284,6 +285,8 @@ export default function App() {
// --- Simulation State (Workspace) --- // --- Simulation State (Workspace) ---
const [cervicalRotation, setCervicalRotation] = useState(14.5); const [cervicalRotation, setCervicalRotation] = useState(14.5);
const [transitionWidth, setTransitionWidth] = useState(90); const [transitionWidth, setTransitionWidth] = useState(90);
const [previewGaussianSigma, setPreviewGaussianSigma] = useState(3);
const [previewAlgorithm, setPreviewAlgorithm] = useState('soft_transition');
const [showPreviewCutoffLine, setShowPreviewCutoffLine] = useState(true); const [showPreviewCutoffLine, setShowPreviewCutoffLine] = useState(true);
const [isSimulating, setIsSimulating] = useState(restoredDeformationJob?.job.status === 'running'); const [isSimulating, setIsSimulating] = useState(restoredDeformationJob?.job.status === 'running');
const [progress, setProgress] = useState(restoredDeformationJob ? progressFromJob(restoredDeformationJob.job, restoredDeformationJob.progress) : 0); const [progress, setProgress] = useState(restoredDeformationJob ? progressFromJob(restoredDeformationJob.job, restoredDeformationJob.progress) : 0);
@@ -299,7 +302,7 @@ export default function App() {
const [packageOptions, setPackageOptions] = useState<PackageOptions>(DEFAULT_PACKAGE_OPTIONS); const [packageOptions, setPackageOptions] = useState<PackageOptions>(DEFAULT_PACKAGE_OPTIONS);
const [videoMaxAngle, setVideoMaxAngle] = useState(20); const [videoMaxAngle, setVideoMaxAngle] = useState(20);
const [videoDuration, setVideoDuration] = useState(6); const [videoDuration, setVideoDuration] = useState(6);
const [videoSource, setVideoSource] = useState('original'); const [videoSource, setVideoSource] = useState('hard_boundary');
const [showVideoArrow, setShowVideoArrow] = useState(true); const [showVideoArrow, setShowVideoArrow] = useState(true);
// --- User Management Shared State --- // --- User Management Shared State ---
@@ -309,13 +312,12 @@ export default function App() {
const [pwChangeInput, setPwChangeInput] = useState(''); const [pwChangeInput, setPwChangeInput] = useState('');
const [showAddUser, setShowAddUser] = useState(false); const [showAddUser, setShowAddUser] = useState(false);
const [activeUserMenu, setActiveUserMenu] = useState<string | null>(null); const [activeUserMenu, setActiveUserMenu] = useState<string | null>(null);
const [isResettingDemo, setIsResettingDemo] = useState(false);
const selectedDataset = libraryData.find(item => item.id === selectedLibraryId) || libraryData[0]; const selectedDataset = libraryData.find(item => item.id === selectedLibraryId) || libraryData[0];
const selectedInputDir = selectedDataset?.dicomPath || ''; const selectedInputDir = selectedDataset?.dicomPath || '';
const selectedVideoSource = VIDEO_SOURCE_OPTIONS.find(option => option.key === videoSource) || VIDEO_SOURCE_OPTIONS[0]; const selectedVideoSource = VIDEO_SOURCE_OPTIONS.find(option => option.key === videoSource) || VIDEO_SOURCE_OPTIONS[0];
const videoSourceInputDir = videoSource === 'original' const videoSourceInputDir = selectedInputDir;
? selectedInputDir
: deformationJob?.result?.outputs?.[videoSource] || '';
const isVideoSourceReady = Boolean(videoSourceInputDir); const isVideoSourceReady = Boolean(videoSourceInputDir);
useEffect(() => { useEffect(() => {
@@ -364,6 +366,16 @@ export default function App() {
link.remove(); link.remove();
}; };
const downloadPreviewImage = () => {
if (!previewImage) return;
const link = document.createElement('a');
link.href = previewImage;
link.download = `quick_2d_preview_${previewAlgorithm}_${cervicalRotation.toFixed(1)}deg.png`;
document.body.appendChild(link);
link.click();
link.remove();
};
const clearDeformationTask = () => { const clearDeformationTask = () => {
setDeformationJob(null); setDeformationJob(null);
setProgress(0); setProgress(0);
@@ -449,6 +461,31 @@ export default function App() {
return items; return items;
}; };
const resetDemoEnvironment = async () => {
if (isResettingDemo) return;
if (!confirm('确定恢复演示环境出厂设置吗?影像数据库将只保留 Ori_Head_CT并清空当前任务结果。')) return;
setIsResettingDemo(true);
try {
const data = await apiRequest('/api/demo/reset', { method: 'POST' }) as { items?: LibraryItem[] };
const items = data.items || [];
setLibraryData(items);
setSelectedLibraryId(items[0]?.id || '');
setLibraryInfo(null);
setPreviewImage('');
setVideoJob(null);
setZipJobs({});
clearDeformationTask();
setBackendOnline(true);
setBackendMessage('演示环境已恢复出厂设置');
showToast('已恢复演示环境出厂设置');
} catch (error) {
showToast((error as Error).message);
} finally {
setIsResettingDemo(false);
}
};
const refreshBackendDefaults = async () => { const refreshBackendDefaults = async () => {
try { try {
const data = await apiRequest('/api/defaults'); const data = await apiRequest('/api/defaults');
@@ -755,6 +792,9 @@ export default function App() {
body: JSON.stringify({ body: JSON.stringify({
inputDir: selectedInputDir, inputDir: selectedInputDir,
angleDegrees: cervicalRotation, angleDegrees: cervicalRotation,
transitionWidth,
gaussianSigma: previewGaussianSigma,
mode: previewAlgorithm,
showCutoffLine: showPreviewCutoffLine showCutoffLine: showPreviewCutoffLine
}), }),
signal: controller.signal signal: controller.signal
@@ -778,7 +818,7 @@ export default function App() {
controller.abort(); controller.abort();
window.clearTimeout(timer); window.clearTimeout(timer);
}; };
}, [currentPage, selectedInputDir, cervicalRotation, showPreviewCutoffLine]); }, [currentPage, selectedInputDir, cervicalRotation, transitionWidth, previewGaussianSigma, previewAlgorithm, showPreviewCutoffLine]);
const handleRunSimulation = async () => { const handleRunSimulation = async () => {
if (isSimulating) return; if (isSimulating) return;
@@ -808,7 +848,7 @@ export default function App() {
const handleGenerateVideo = async () => { const handleGenerateVideo = async () => {
if (videoJob?.status === 'running') return; if (videoJob?.status === 'running') return;
if (!videoSourceInputDir) { if (!videoSourceInputDir) {
showToast(videoSource === 'original' ? '请选择影像库数据源' : '请先完成四状态输出,再生成该状态视频'); showToast('请选择影像库数据源');
return; return;
} }
try { try {
@@ -819,6 +859,7 @@ export default function App() {
maxAngle: videoMaxAngle, maxAngle: videoMaxAngle,
durationSeconds: videoDuration, durationSeconds: videoDuration,
showArrow: showVideoArrow, showArrow: showVideoArrow,
mode: selectedVideoSource.mode,
}) })
}) as BackendJob; }) as BackendJob;
setVideoJob(job); setVideoJob(job);
@@ -989,13 +1030,33 @@ export default function App() {
<div className="space-y-4 pt-2"> <div className="space-y-4 pt-2">
<div> <div>
<div className="flex justify-between text-xs font-bold mb-2 text-slate-600"><span></span><span className="text-blue-600 font-mono">{cervicalRotation.toFixed(1)}°</span></div> <div className="flex justify-between text-xs font-bold mb-2 text-slate-600"><span></span><span className="text-blue-600 font-mono">{PREVIEW_ALGORITHM_OPTIONS.find(option => option.key === previewAlgorithm)?.label}</span></div>
<input type="range" min="0" max="20" step="0.5" value={cervicalRotation} onChange={e => setCervicalRotation(parseFloat(e.target.value))} className="w-full h-1.5 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-blue-600 opacity-80 hover:opacity-100 transition-opacity" /> <select
value={previewAlgorithm}
onChange={event => setPreviewAlgorithm(event.target.value)}
className="w-full px-3 py-2.5 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-xs font-bold text-slate-700"
>
{PREVIEW_ALGORITHM_OPTIONS.map(option => (
<option key={option.key} value={option.key}>{option.label}</option>
))}
</select>
</div> </div>
<div> <div>
<div className="flex justify-between text-xs font-bold mb-2 text-slate-600"><span></span><span className="text-blue-600 font-mono">{transitionWidth}</span></div> <div className="flex justify-between text-xs font-bold mb-2 text-slate-600"><span></span><span className="text-blue-600 font-mono">{cervicalRotation.toFixed(1)}°</span></div>
<input type="range" min="0" max="45" step="0.5" value={cervicalRotation} onChange={e => setCervicalRotation(parseFloat(e.target.value))} className="w-full h-1.5 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-blue-600 opacity-80 hover:opacity-100 transition-opacity" />
</div>
{previewAlgorithm === 'gaussian_smooth' && (
<div>
<div className="flex justify-between text-xs font-bold mb-2 text-slate-600"><span></span><span className="text-blue-600 font-mono">{previewGaussianSigma.toFixed(1)}</span></div>
<input type="range" min="1" max="12" step="0.5" value={previewGaussianSigma} onChange={e => setPreviewGaussianSigma(parseFloat(e.target.value))} className="w-full h-1.5 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-blue-600 opacity-80 hover:opacity-100 transition-opacity" />
</div>
)}
{previewAlgorithm === 'soft_transition' && (
<div>
<div className="flex justify-between text-xs font-bold mb-2 text-slate-600"><span></span><span className="text-blue-600 font-mono">{transitionWidth}</span></div>
<input type="range" min="50" max="160" step="10" value={transitionWidth} onChange={e => setTransitionWidth(parseInt(e.target.value, 10))} className="w-full h-1.5 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-blue-600 opacity-80 hover:opacity-100 transition-opacity" /> <input type="range" min="50" max="160" step="10" value={transitionWidth} onChange={e => setTransitionWidth(parseInt(e.target.value, 10))} className="w-full h-1.5 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-blue-600 opacity-80 hover:opacity-100 transition-opacity" />
</div> </div>
)}
<button <button
onClick={() => setShowPreviewCutoffLine(value => !value)} onClick={() => setShowPreviewCutoffLine(value => !value)}
className={`w-full py-2.5 rounded-xl text-xs font-black transition-all flex items-center justify-center gap-2 border ${ className={`w-full py-2.5 rounded-xl text-xs font-black transition-all flex items-center justify-center gap-2 border ${
@@ -1050,8 +1111,8 @@ export default function App() {
</div> </div>
<div> <div>
<div className="flex justify-between text-[10px] font-bold mb-2 text-slate-500"> <div className="flex justify-between text-[10px] font-bold mb-2 text-slate-500">
<span></span> <span></span>
{videoSource !== 'original' && !isVideoSourceReady && <span className="text-amber-500"></span>} {!isVideoSourceReady && <span className="text-amber-500"></span>}
</div> </div>
<select <select
value={videoSource} value={videoSource}
@@ -1069,7 +1130,7 @@ export default function App() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<div className="flex justify-between text-[10px] font-bold mb-2 text-slate-500"><span></span><span>{videoMaxAngle}°</span></div> <div className="flex justify-between text-[10px] font-bold mb-2 text-slate-500"><span></span><span>{videoMaxAngle}°</span></div>
<input type="range" min="5" max="30" step="1" value={videoMaxAngle} onChange={e => setVideoMaxAngle(parseInt(e.target.value, 10))} className="w-full accent-blue-600" /> <input type="range" min="5" max="45" step="1" value={videoMaxAngle} onChange={e => setVideoMaxAngle(parseInt(e.target.value, 10))} className="w-full accent-blue-600" />
</div> </div>
<div> <div>
<div className="flex justify-between text-[10px] font-bold mb-2 text-slate-500"><span></span><span>{videoDuration}s</span></div> <div className="flex justify-between text-[10px] font-bold mb-2 text-slate-500"><span></span><span>{videoDuration}s</span></div>
@@ -1122,10 +1183,20 @@ export default function App() {
<h4 className="font-black text-slate-800"> 2D </h4> <h4 className="font-black text-slate-800"> 2D </h4>
<p className="text-[10px] text-slate-400 font-bold mt-1"> head_extension_app.py preview_deform_2d</p> <p className="text-[10px] text-slate-400 font-bold mt-1"> head_extension_app.py preview_deform_2d</p>
</div> </div>
<div className="flex items-center gap-3">
<div className="text-right"> <div className="text-right">
<span className="text-[10px] font-mono text-slate-400">{cervicalRotation.toFixed(1)} DEG</span> <span className="text-[10px] font-mono text-slate-400">{cervicalRotation.toFixed(1)} DEG</span>
{isPreviewLoading && <p className="text-[9px] font-bold text-blue-500 mt-1">...</p>} {isPreviewLoading && <p className="text-[9px] font-bold text-blue-500 mt-1">...</p>}
</div> </div>
<button
onClick={downloadPreviewImage}
disabled={!previewImage}
className="w-9 h-9 rounded-xl bg-slate-100 text-slate-500 hover:bg-blue-600 hover:text-white transition-all flex items-center justify-center disabled:opacity-40 disabled:hover:bg-slate-100 disabled:hover:text-slate-500"
title="下载当前快速 2D 预览图"
>
<Download size={15} />
</button>
</div>
</div> </div>
<div className="h-[360px] bg-slate-950 flex items-center justify-center"> <div className="h-[360px] bg-slate-950 flex items-center justify-center">
{previewImage ? ( {previewImage ? (
@@ -1399,6 +1470,23 @@ export default function App() {
</div> </div>
)} )}
{currentUser?.role === 'admin' && (
<div className="bg-white p-8 rounded-[2.5rem] border border-red-100 shadow-sm flex items-center justify-between gap-6">
<div>
<h3 className="font-black text-slate-800"></h3>
<p className="text-xs text-slate-400 font-bold mt-2"> Ori_Head_CT</p>
</div>
<button
onClick={resetDemoEnvironment}
disabled={isResettingDemo}
className="px-6 py-3.5 bg-red-50 text-red-600 rounded-2xl text-xs font-black hover:bg-red-600 hover:text-white transition-all flex items-center gap-3 disabled:opacity-60 disabled:cursor-wait"
>
<RefreshCw size={15} className={isResettingDemo ? 'animate-spin' : ''} />
{isResettingDemo ? '正在恢复' : '恢复演示环境出厂设置'}
</button>
</div>
)}
{/* User List Header */} {/* User List Header */}
<div className="flex items-center justify-between px-4"> <div className="flex items-center justify-between px-4">
<h3 className="text-xs font-black text-slate-400 uppercase tracking-[0.2em]"></h3> <h3 className="text-xs font-black text-slate-400 uppercase tracking-[0.2em]"></h3>

View File

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

View File

@@ -152,7 +152,37 @@ def fit_image(image, width, height):
return canvas return canvas
def preview_deform_2d(image, angle_degrees): def preview_motion_weight(height, width, mode="soft_transition", transition_width=90, gaussian_sigma=3):
yy, xx = np.mgrid[0:height, 0:width]
boundary_y = height * 0.71
transition_span = height * np.clip(float(transition_width) / 90 * 0.42, 0.18, 0.56)
full_motion_y = boundary_y - transition_span * 0.5
fixed_y = boundary_y + transition_span * 0.5
if mode == "hard_boundary":
return (yy <= boundary_y).astype(np.float32)
if mode == "gaussian_smooth":
try:
from scipy.ndimage import gaussian_filter
except Exception:
gaussian_filter = None
hard = (yy <= boundary_y).astype(np.float32)
if gaussian_filter is None:
return hard
sigma = float(np.clip(float(gaussian_sigma), 1, 12))
return np.clip(gaussian_filter(hard, sigma=sigma), 0, 1).astype(np.float32)
t = np.clip((yy - full_motion_y) / (fixed_y - full_motion_y), 0, 1)
weight = 1 - (t * t * (3 - 2 * t))
x_soft = np.clip((xx - width * 0.15) / (width * 0.75), 0, 1)
x_soft = x_soft * x_soft * (3 - 2 * x_soft)
return np.clip(weight * (0.90 + 0.10 * x_soft), 0, 1).astype(np.float32)
def preview_deform_2d(image, angle_degrees, mode="soft_transition", transition_width=90, gaussian_sigma=3):
"""Fast visual preview only. The DICOM output uses the real 3D field.""" """Fast visual preview only. The DICOM output uses the real 3D field."""
try: try:
from scipy.ndimage import map_coordinates from scipy.ndimage import map_coordinates
@@ -166,15 +196,7 @@ def preview_deform_2d(image, angle_degrees):
pivot_x = int(w * 0.55) pivot_x = int(w * 0.55)
pivot_y = int(h * 0.62) pivot_y = int(h * 0.62)
full_motion_y = h * 0.50 weight = preview_motion_weight(h, w, mode, transition_width, gaussian_sigma)
fixed_y = h * 0.92
t = np.clip((yy - full_motion_y) / (fixed_y - full_motion_y), 0, 1)
weight = 1 - (t * t * (3 - 2 * t))
x_soft = np.clip((xx - w * 0.15) / (w * 0.75), 0, 1)
x_soft = x_soft * x_soft * (3 - 2 * x_soft)
weight = np.clip(weight * (0.90 + 0.10 * x_soft), 0, 1)
theta = np.deg2rad(angle_degrees) * weight theta = np.deg2rad(angle_degrees) * weight
cos_t = np.cos(theta) cos_t = np.cos(theta)
sin_t = np.sin(theta) sin_t = np.sin(theta)
@@ -359,7 +381,7 @@ def run_deformation(input_dir, output_dir, angle_degrees, transition_width, prog
output_paths["legacy_soft"] = legacy_soft_dir output_paths["legacy_soft"] = legacy_soft_dir
progress("正在生成四状态过程对比图...") progress("正在生成四状态过程对比图...")
preview_paths = make_four_state_preview(state_images, Path(output_dir), angle_degrees) preview_paths = make_four_state_preview(state_images, Path(output_dir), angle_degrees, transition_width=transition_width)
make_output_preview_from_images( make_output_preview_from_images(
state_images["original"], state_images["original"],
state_images["soft_transition"], state_images["soft_transition"],
@@ -437,14 +459,32 @@ def make_output_preview_from_images(original_image, deformed_image, output_dir,
return preview_path return preview_path
def make_four_state_preview(state_images, output_dir, angle_degrees, coordinates_cutoff=None): def make_four_state_preview(state_images, output_dir, angle_degrees, coordinates_cutoff=None, transition_width=90):
output_dir = Path(output_dir) output_dir = Path(output_dir)
screenshot_dir = output_dir / "process_screenshots" screenshot_dir = output_dir / "process_screenshots"
reset_folder(screenshot_dir) reset_folder(screenshot_dir)
original_panel = sitk_sagittal_panel(state_images["original"], coordinates_cutoff)
preview_panels = {
"original": original_panel,
"hard_boundary": preview_deform_2d(original_panel, angle_degrees, "hard_boundary"),
"gaussian_smooth": preview_deform_2d(
original_panel,
angle_degrees,
"gaussian_smooth",
transition_width,
),
"soft_transition": preview_deform_2d(
original_panel,
angle_degrees,
"soft_transition",
transition_width,
),
}
panels = [] panels = []
for state_key, label, _ in STATE_LABELS: for state_key, label, _ in STATE_LABELS:
panel = sitk_sagittal_panel(state_images[state_key], coordinates_cutoff) panel = preview_panels[state_key]
panel_path = screenshot_dir / f"{state_key}.png" panel_path = screenshot_dir / f"{state_key}.png"
panel.save(panel_path, quality=95) panel.save(panel_path, quality=95)
panels.append((label, panel)) panels.append((label, panel))
@@ -517,7 +557,7 @@ class HeadExtensionApp:
self.angle = Scale( self.angle = Scale(
controls, controls,
from_=0, from_=0,
to=20, to=45,
orient=HORIZONTAL, orient=HORIZONTAL,
resolution=0.5, resolution=0.5,
length=420, length=420,
@@ -603,7 +643,12 @@ class HeadExtensionApp:
self.cached_volume = load_dicom_volume(self.input_dir.get()) self.cached_volume = load_dicom_volume(self.input_dir.get())
before = crop_head_neck(sagittal_mip(self.cached_volume)) before = crop_head_neck(sagittal_mip(self.cached_volume))
before_with_line = draw_cutoff_line(before, self.cached_volume.shape[0]) before_with_line = draw_cutoff_line(before, self.cached_volume.shape[0])
after = preview_deform_2d(before_with_line, float(self.angle.get())) after = preview_deform_2d(
before_with_line,
float(self.angle.get()),
transition_width=float(self.transition.get()),
gaussian_sigma=3,
)
canvas = Image.new("RGB", (1120, 610), (0, 0, 0)) canvas = Image.new("RGB", (1120, 610), (0, 0, 0))
draw = ImageDraw.Draw(canvas) draw = ImageDraw.Draw(canvas)

View File

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

View File

@@ -360,6 +360,91 @@ def add_dicom_from_zip(target_dir, zip_filename, payload, start_index):
return count return count
def validate_single_dicom_series(dicom_dir):
series_counts = {}
for dicom_path in Path(dicom_dir).glob("*.dcm"):
try:
ds = pydicom.dcmread(str(dicom_path), stop_before_pixels=True, force=True)
except Exception as exc:
raise RuntimeError(f"{dicom_path.name} 不是可读取的 DICOM 文件。") from exc
series_uid = str(getattr(ds, "SeriesInstanceUID", "") or "NO_SERIES_UID")
series_counts[series_uid] = series_counts.get(series_uid, 0) + 1
if len(series_counts) > 1:
series_summary = ", ".join(str(count) for count in sorted(series_counts.values(), reverse=True))
raise RuntimeError(
"上传内容包含多个 DICOM 序列,不能作为一个影像库数据集导入。"
f"检测到 {len(series_counts)} 个序列,分别约 {series_summary} 张。"
"请上传单个序列文件夹/ZIP。"
)
def reset_demo_environment():
source_dir = APP_DIR / "Ori_Head_CT"
source_zip = APP_DIR / "Ori_Head_CT.zip"
demo_root = LIBRARY_DIR / "demo_ori_head_ct"
demo_dicom_dir = demo_root / "dicom"
if not source_dir.exists() and not source_zip.exists():
raise RuntimeError("未找到 Ori_Head_CT 或 Ori_Head_CT.zip无法恢复演示环境。")
safe_mkdir(LIBRARY_DIR)
for child in LIBRARY_DIR.iterdir():
if child.is_dir():
shutil.rmtree(child)
else:
child.unlink()
safe_mkdir(demo_dicom_dir)
copied = 0
if source_dir.exists():
for dicom_path in sorted(source_dir.glob("*.dcm")):
shutil.copy2(dicom_path, demo_dicom_dir / dicom_path.name)
copied += 1
else:
with zipfile.ZipFile(source_zip) as archive:
for member in archive.infolist():
if member.is_dir():
continue
member_name = member.filename.replace("\\", "/")
if Path(member_name).suffix.lower() != ".dcm":
continue
if Path(member_name).is_absolute() or ".." in Path(member_name).parts:
continue
copied += 1
with archive.open(member) as member_file:
write_dicom_payload(demo_dicom_dir, Path(member_name).name, member_file.read(), copied)
if copied == 0:
shutil.rmtree(demo_root)
raise RuntimeError("Ori_Head_CT 中没有找到 .dcm 文件。")
validate_single_dicom_series(demo_dicom_dir)
record = build_library_record(
"demo_ori_head_ct",
"Ori_Head_CT",
demo_dicom_dir,
source="upload",
version="DICOM-DEMO",
)
write_library_meta([record])
DICOM_FILE_CACHE.clear()
if RESULT_DIR.exists():
shutil.rmtree(RESULT_DIR)
safe_mkdir(RESULT_DIR)
with JOBS_LOCK:
JOBS.clear()
persist_jobs_locked()
write_json_file(USER_TASKS_META, {})
return {
"ok": True,
"message": "演示环境已恢复出厂设置。",
"items": list_library(),
}
def upload_library_item(headers, body): def upload_library_item(headers, body):
fields, files = parse_multipart(headers, body) fields, files = parse_multipart(headers, body)
dicom_files = [ dicom_files = [
@@ -398,6 +483,11 @@ def upload_library_item(headers, body):
if total_dicom_count == 0: if total_dicom_count == 0:
shutil.rmtree(target_dir.parent) shutil.rmtree(target_dir.parent)
raise RuntimeError("压缩包里没有找到 .dcm 文件。") raise RuntimeError("压缩包里没有找到 .dcm 文件。")
try:
validate_single_dicom_series(target_dir)
except Exception:
shutil.rmtree(target_dir.parent)
raise
record = build_library_record( record = build_library_record(
item_id, item_id,
@@ -547,11 +637,26 @@ def start_job(kind, worker, owner=None, params=None, remember_user_task=True):
return get_job(job_id) return get_job(job_id)
def make_preview(input_dir, angle_degrees, show_cutoff_line=True): def make_preview(
input_dir,
angle_degrees,
show_cutoff_line=True,
transition_width=90,
mode="soft_transition",
gaussian_sigma=3,
):
if mode not in {"hard_boundary", "gaussian_smooth", "soft_transition"}:
mode = "soft_transition"
volume = load_dicom_volume(input_dir) volume = load_dicom_volume(input_dir)
before = crop_head_neck(sagittal_mip(volume)) before = crop_head_neck(sagittal_mip(volume))
before_display = draw_cutoff_line(before, volume.shape[0]) if show_cutoff_line else before before_display = draw_cutoff_line(before, volume.shape[0]) if show_cutoff_line else before
after = preview_deform_2d(before_display, float(angle_degrees)) 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 = Image.new("RGB", (1440, 520), (0, 0, 0))
canvas_image.paste(fit_image(before_display, 700, 520), (0, 0)) canvas_image.paste(fit_image(before_display, 700, 520), (0, 0))
@@ -779,12 +884,19 @@ class Handler(BaseHTTPRequestHandler):
return return
body = self.read_json() body = self.read_json()
if parsed.path == "/api/demo/reset":
self.send_json(reset_demo_environment())
return
if parsed.path == "/api/preview": if parsed.path == "/api/preview":
self.send_json( self.send_json(
make_preview( make_preview(
body["inputDir"], body["inputDir"],
body.get("angleDegrees", 12), body.get("angleDegrees", 12),
bool(body.get("showCutoffLine", True)), bool(body.get("showCutoffLine", True)),
body.get("transitionWidth", 90),
body.get("mode", "soft_transition"),
body.get("gaussianSigma", 3),
) )
) )
return return
@@ -865,13 +977,16 @@ class Handler(BaseHTTPRequestHandler):
max_angle = float(body.get("maxAngle", 20)) max_angle = float(body.get("maxAngle", 20))
duration = float(body.get("durationSeconds", 6)) duration = float(body.get("durationSeconds", 6))
show_arrow = bool(body.get("showArrow", True)) show_arrow = bool(body.get("showArrow", True))
mode = body.get("mode", "hard_boundary")
if mode not in {"hard_boundary", "gaussian_smooth", "soft_transition"}:
mode = "hard_boundary"
def worker(job_id): def worker(job_id):
job_root = RESULT_DIR / job_id job_root = RESULT_DIR / job_id
reset_dir(job_root) reset_dir(job_root)
output_file = job_root / f"head_extension_{job_id}.mp4" output_file = job_root / f"head_extension_{mode}_{job_id}.mp4"
set_job(job_id, message="正在生成 0° 到目标角度的视频。") set_job(job_id, message=f"正在生成 {mode} 视频。")
output = generate_video(input_dir, output_file, max_angle, duration, show_arrow) output = generate_video(input_dir, output_file, max_angle, duration, show_arrow, mode)
output = Path(output).resolve() output = Path(output).resolve()
return { return {
"video": { "video": {