update web workflow and preview behavior
This commit is contained in:
@@ -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="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="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>
|
</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" />
|
||||||
|
</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,9 +1183,19 @@ 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="text-right">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-[10px] font-mono text-slate-400">{cervicalRotation.toFixed(1)} DEG</span>
|
<div className="text-right">
|
||||||
{isPreviewLoading && <p className="text-[9px] font-bold text-blue-500 mt-1">自动更新中...</p>}
|
<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>}
|
||||||
|
</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>
|
||||||
<div className="h-[360px] bg-slate-950 flex items-center justify-center">
|
<div className="h-[360px] bg-slate-950 flex items-center justify-center">
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
125
web_backend.py
125
web_backend.py
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user