diff --git a/README.md b/README.md index 0fbfbdd..f9cadee 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,36 @@ 基于模型逆向体素化及 DICOM 分割标注系统。 工程流程与分析文档位于 `工程分析/`。 + +## 项目最重要达成目标 + +本项目的核心目标是:输入 DICOM 影像序列和重建 STL 模型后,将模型反向体素化并对齐到 DICOM 空间,最终生成可被医学影像工具读取的分割 Mask,例如 `.nii` 或 `.nii.gz`。 + +当前系统已经具备: + +- 前后端协调服务。 +- 跨浏览器共享登录状态。 +- 默认载入 `Head_CT_DICOM` 与 `Head_CT_ReConstruct`。 +- DICOM 切片预览。 +- STL 模型预览。 +- 分割结果展示与 NIfTI 演示导出。 + +## 构建运行 + +进入前端服务目录: + +```bash +cd WebSite +npm ci +npm run lint +npm run build +npm run serve -- --host 0.0.0.0 --port 4000 +``` + +访问地址: + +```text +http://192.168.3.11:4000/ +``` + +当前版本不需要 Python/conda。后续接入真实医学级 STL 体素化算法时,可新建 `revoxelseg` conda 环境并安装 SimpleITK、nibabel、numpy、trimesh、vtk 等算法依赖。 diff --git a/WebSite/README.md b/WebSite/README.md index 28d49ea..ae8ae58 100644 --- a/WebSite/README.md +++ b/WebSite/README.md @@ -1,20 +1,62 @@ -
-GHBanner -
+# 模型逆向系统 WebSite -# Run and deploy your AI Studio app +本目录是“基于模型逆向体素化及 DICOM 分割标注系统”的前后端一体服务。 -This contains everything you need to run your app locally. +## 环境要求 -View your app in AI Studio: https://ai.studio/apps/2e2bd558-1bd5-4424-b1b2-07238ed56ff7 +- Node.js 18 或更高版本 +- npm -## Run Locally +当前版本的 DICOM 预览、STL 预览和 NIfTI 演示导出均由 Node/React/Three.js 完成,不需要 Python 或 conda 环境。 -**Prerequisites:** Node.js +后续若接入真实医学级 STL 反向体素化算法,建议单独创建 Python conda 环境,例如: +```bash +conda create -n revoxelseg python=3.11 +conda activate revoxelseg +``` -1. Install dependencies: - `npm install` -2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key -3. Run the app: - `npm run dev` +再按真实算法依赖安装 SimpleITK、nibabel、numpy、trimesh、vtk 等包。 + +## 安装依赖 + +```bash +npm ci +``` + +## 开发运行 + +前后端统一由 Express + Vite 中间件托管: + +```bash +npm run serve -- --host 0.0.0.0 --port 4000 +``` + +访问: + +```text +http://192.168.3.11:4000/ +``` + +## 构建检查 + +```bash +npm run lint +npm run build +``` + +## 数据目录 + +默认演示项目读取仓库根目录: + +- `Head_CT_DICOM/`:DICOM 序列。 +- `Head_CT_ReConstruct/`:STL 重建模型。 + +这些医学影像和模型数据默认不提交到 Git。 + +## 运行态目录 + +- `WebSite/data/`:后端共享状态。 +- `WebSite/exports/`:生成的 NIfTI 导出文件。 + +这些目录是运行态产物,默认不提交到 Git。 diff --git a/WebSite/server.ts b/WebSite/server.ts index a26deec..d4b68fe 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -29,6 +29,8 @@ interface ProjectRecord { modelCount: number; stlFiles: string[]; maskFormats: Array<'nii' | 'nii.gz'>; + exportedMaskCount: number; + isDefault?: boolean; } interface SessionRecord { @@ -116,6 +118,25 @@ function buildDefaultProject(): ProjectRecord { modelCount: stlFiles.length, stlFiles, maskFormats: ['nii', 'nii.gz'], + exportedMaskCount: 0, + isDefault: true, + }; +} + +function buildEmptyProject(name: string): ProjectRecord { + return { + id: `project-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, + name, + createTime: today(), + status: 'pending', + dicomCount: 0, + hasModel: false, + dicomPath: '', + modelPath: '', + modelCount: 0, + stlFiles: [], + maskFormats: ['nii', 'nii.gz'], + exportedMaskCount: 0, }; } @@ -132,9 +153,27 @@ function defaultState(): AppState { } function normalizeState(state: AppState): AppState { + const defaultProject = buildDefaultProject(); + const customProjects = Array.isArray(state.projects) + ? state.projects + .filter((project) => project.id !== defaultProject.id) + .map((project) => ({ + ...project, + exportedMaskCount: project.exportedMaskCount ?? 0, + maskFormats: project.maskFormats ?? ['nii', 'nii.gz'], + })) + : []; + return { ...state, - projects: [buildDefaultProject()], + projects: [ + { + ...defaultProject, + name: state.projects?.find((project) => project.id === defaultProject.id)?.name ?? defaultProject.name, + exportedMaskCount: state.projects?.find((project) => project.id === defaultProject.id)?.exportedMaskCount ?? 0, + }, + ...customProjects, + ], }; } @@ -221,6 +260,102 @@ function createNiftiMask(project: ProjectRecord, compressed: boolean) { return compressed ? zlib.gzipSync(nifti) : nifti; } +function findProject(state: AppState, projectId: string) { + return state.projects.find((candidate) => candidate.id === projectId); +} + +function getProjectDicomFiles(project: ProjectRecord) { + if (project.id !== 'head-ct-demo') { + return []; + } + return listFiles(dicomDir, '.dcm').sort((a, b) => Number.parseInt(a) - Number.parseInt(b)); +} + +function readAsciiValue(buffer: Buffer, start: number, length: number) { + return buffer.subarray(start, start + length).toString('ascii').replace(/\0/g, '').trim(); +} + +function findExplicitTag(buffer: Buffer, group: number, element: number) { + const pattern = Buffer.from([ + group & 0xff, + (group >> 8) & 0xff, + element & 0xff, + (element >> 8) & 0xff, + ]); + const longVr = ['OB', 'OD', 'OF', 'OL', 'OW', 'SQ', 'UC', 'UR', 'UT', 'UN']; + let offset = buffer.indexOf(pattern, 132); + + while (offset >= 0 && offset + 8 < buffer.length) { + const vr = buffer.subarray(offset + 4, offset + 6).toString('ascii'); + if (/^[A-Z]{2}$/.test(vr)) { + if (longVr.includes(vr)) { + const length = buffer.readUInt32LE(offset + 8); + return { valueOffset: offset + 12, length, vr }; + } + const length = buffer.readUInt16LE(offset + 6); + return { valueOffset: offset + 8, length, vr }; + } + + offset = buffer.indexOf(pattern, offset + 1); + } + + return null; +} + +function parseDicomPreview(filePath: string) { + const buffer = fs.readFileSync(filePath); + const rowsTag = findExplicitTag(buffer, 0x0028, 0x0010); + const columnsTag = findExplicitTag(buffer, 0x0028, 0x0011); + const bitsTag = findExplicitTag(buffer, 0x0028, 0x0100); + const representationTag = findExplicitTag(buffer, 0x0028, 0x0103); + const centerTag = findExplicitTag(buffer, 0x0028, 0x1050); + const widthTag = findExplicitTag(buffer, 0x0028, 0x1051); + const interceptTag = findExplicitTag(buffer, 0x0028, 0x1052); + const slopeTag = findExplicitTag(buffer, 0x0028, 0x1053); + const pixelTag = findExplicitTag(buffer, 0x7fe0, 0x0010); + + const rows = rowsTag ? buffer.readUInt16LE(rowsTag.valueOffset) : 0; + const columns = columnsTag ? buffer.readUInt16LE(columnsTag.valueOffset) : 0; + const bitsAllocated = bitsTag ? buffer.readUInt16LE(bitsTag.valueOffset) : 16; + const pixelRepresentation = representationTag ? buffer.readUInt16LE(representationTag.valueOffset) : 0; + const windowCenter = centerTag ? Number.parseFloat(readAsciiValue(buffer, centerTag.valueOffset, centerTag.length).split('\\')[0]) || 40 : 40; + const windowWidth = widthTag ? Number.parseFloat(readAsciiValue(buffer, widthTag.valueOffset, widthTag.length).split('\\')[0]) || 400 : 400; + const rescaleIntercept = interceptTag ? Number.parseFloat(readAsciiValue(buffer, interceptTag.valueOffset, interceptTag.length)) || 0 : 0; + const rescaleSlope = slopeTag ? Number.parseFloat(readAsciiValue(buffer, slopeTag.valueOffset, slopeTag.length)) || 1 : 1; + const pixelOffset = pixelTag?.valueOffset ?? -1; + const pixelLength = pixelTag?.length ?? 0; + + if (!rows || !columns || pixelOffset < 0) { + throw new Error('无法解析当前 DICOM 像素数据'); + } + + const count = rows * columns; + const pixels = Buffer.alloc(count); + const min = windowCenter - windowWidth / 2; + const max = windowCenter + windowWidth / 2; + + for (let i = 0; i < count; i += 1) { + const position = pixelOffset + i * (bitsAllocated / 8); + if (position + 1 >= buffer.length || position >= pixelOffset + pixelLength) { + break; + } + const raw = bitsAllocated === 16 + ? (pixelRepresentation ? buffer.readInt16LE(position) : buffer.readUInt16LE(position)) + : buffer.readUInt8(position); + const hu = raw * rescaleSlope + rescaleIntercept; + const normalized = Math.max(0, Math.min(255, Math.round(((hu - min) / (max - min)) * 255))); + pixels[i] = normalized; + } + + return { + width: columns, + height: rows, + pixels: pixels.toString('base64'), + windowCenter, + windowWidth, + }; +} + async function startServer() { const app = express(); const host = process.argv.includes('--host') ? process.argv[process.argv.indexOf('--host') + 1] : '0.0.0.0'; @@ -268,8 +403,22 @@ async function startServer() { res.json(readState().projects); }); + app.post('/api/projects', (req, res) => { + const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; + if (!name) { + res.status(400).json({ message: '项目名称不能为空' }); + return; + } + + const state = readState(); + const project = buildEmptyProject(name); + state.projects.push(project); + writeState(state); + res.status(201).json(project); + }); + app.get('/api/projects/:projectId', (req, res) => { - const project = readState().projects.find((candidate) => candidate.id === req.params.projectId); + const project = findProject(readState(), req.params.projectId); if (!project) { res.status(404).json({ message: '项目不存在' }); return; @@ -277,24 +426,85 @@ async function startServer() { res.json(project); }); + app.patch('/api/projects/:projectId', (req, res) => { + const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; + if (!name) { + res.status(400).json({ message: '项目名称不能为空' }); + return; + } + + const state = readState(); + const project = findProject(state, req.params.projectId); + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + + project.name = name; + writeState(state); + res.json(project); + }); + + app.get('/api/projects/:projectId/dicom-preview', (req, res) => { + const project = findProject(readState(), req.params.projectId); + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + + const files = getProjectDicomFiles(project); + if (!files.length) { + res.status(404).json({ message: '当前项目没有可预览的 DICOM 文件' }); + return; + } + + const requestedSlice = Number.parseInt(String(req.query.slice ?? '0'), 10); + const slice = Math.max(0, Math.min(files.length - 1, Number.isFinite(requestedSlice) ? requestedSlice : 0)); + try { + const preview = parseDicomPreview(path.join(dicomDir, files[slice])); + res.json({ + ...preview, + slice, + total: files.length, + fileName: files[slice], + }); + } catch (error) { + res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 预览失败' }); + } + }); + + app.get('/api/projects/:projectId/models/:fileName', (req, res) => { + const project = findProject(readState(), req.params.projectId); + const fileName = path.basename(req.params.fileName); + + if (!project || project.id !== 'head-ct-demo' || !project.stlFiles.includes(fileName)) { + res.status(404).json({ message: '模型文件不存在' }); + return; + } + + res.sendFile(path.join(modelDir, fileName)); + }); + app.get('/api/overview', (_req, res) => { const state = readState(); const dicomCount = state.projects.reduce((sum, project) => sum + project.dicomCount, 0); const modelCount = state.projects.reduce((sum, project) => sum + project.modelCount, 0); + const exportedMaskProjects = state.projects.filter((project) => project.exportedMaskCount > 0).length; res.json({ totalProjects: state.projects.length, - processedProjects: state.projects.filter((project) => project.status === 'completed').length, + processedProjects: exportedMaskProjects, + exportedMaskProjects, dicomCount, modelCount, chartData: [ - { name: 'Mon', projects: 1, processing: 12 }, - { name: 'Tue', projects: 1, processing: 28 }, - { name: 'Wed', projects: 1, processing: 44 }, - { name: 'Thu', projects: 1, processing: 58 }, - { name: 'Fri', projects: 1, processing: 76 }, - { name: 'Sat', projects: 1, processing: 90 }, - { name: 'Sun', projects: state.projects.length, processing: 100 }, + { name: 'Mon', projects: state.projects.length, processing: exportedMaskProjects }, + { name: 'Tue', projects: state.projects.length, processing: exportedMaskProjects }, + { name: 'Wed', projects: state.projects.length, processing: exportedMaskProjects }, + { name: 'Thu', projects: state.projects.length, processing: exportedMaskProjects }, + { name: 'Fri', projects: state.projects.length, processing: exportedMaskProjects }, + { name: 'Sat', projects: state.projects.length, processing: exportedMaskProjects }, + { name: 'Sun', projects: state.projects.length, processing: exportedMaskProjects }, ], }); }); @@ -320,6 +530,8 @@ async function startServer() { const filename = `${project.id}-segmentation-mask.${format}`; const outputPath = path.join(exportDir, filename); fs.writeFileSync(outputPath, mask); + project.exportedMaskCount += 1; + writeState(state); res.setHeader('Content-Type', compressed ? 'application/gzip' : 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); diff --git a/WebSite/src/components/Overview.tsx b/WebSite/src/components/Overview.tsx index 499f698..0c2199f 100644 --- a/WebSite/src/components/Overview.tsx +++ b/WebSite/src/components/Overview.tsx @@ -20,7 +20,7 @@ export default function Overview() { const stats = [ { label: '项目总数', value: String(summary?.totalProjects ?? '-'), icon: FolderRoot, color: 'bg-blue-500', trend: '同步' }, - { label: '已处理项目总数', value: String(summary?.processedProjects ?? '-'), icon: CheckCircle2, color: 'bg-emerald-500', trend: '实时' }, + { label: '已导出 Mask 项目', value: String(summary?.exportedMaskProjects ?? summary?.processedProjects ?? '-'), icon: CheckCircle2, color: 'bg-emerald-500', trend: '结果' }, { label: 'DICOM 切片数', value: String(summary?.dicomCount ?? '-'), icon: Database, color: 'bg-indigo-500', trend: '默认' }, { label: 'STL 模型数', value: String(summary?.modelCount ?? '-'), icon: Box, color: 'bg-cyan-500', trend: '默认' }, ]; @@ -60,7 +60,7 @@ export default function Overview() { ))} -
+
最近30天
-
- +
+ {chartData.length > 0 && ( + @@ -87,7 +88,7 @@ export default function Overview() { - + + )}
@@ -113,8 +115,9 @@ export default function Overview() {
-
- +
+ {chartData.length > 0 && ( + @@ -124,7 +127,7 @@ export default function Overview() { - + + )}
diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index 5ecfda4..94ddbdc 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -1,53 +1,106 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { motion, AnimatePresence } from 'motion/react'; +import React, { Suspense, useEffect, useMemo, useRef, useState } from 'react'; import { Plus, Search, - MoreHorizontal, Eye, RotateCw, - FileText, Box, Image as ImageIcon, ChevronRight, - Filter, - Trash2, Edit2, FolderRoot, - Download + Download, + Layers, + Save, + X } from 'lucide-react'; -import { Canvas } from '@react-three/fiber'; -import { OrbitControls, Stage, Gltf, useGLTF, Environment, PerspectiveCamera } from '@react-three/drei'; -import { Project } from '../types'; -import { api } from '../lib/api'; +import { Canvas, useLoader } from '@react-three/fiber'; +import { Bounds, Center, OrbitControls, Stage } from '@react-three/drei'; +import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'; +import * as THREE from 'three'; +import { DicomPreview, Project } from '../types'; +import { api, downloadMask } from '../lib/api'; + +function StlModel({ url }: { url: string }) { + const geometry = useLoader(STLLoader, url); -// Mock 3D Model component -function ModelPreview() { return ( - - - + + ); } +function DicomCanvas({ preview }: { preview: DicomPreview }) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + const context = canvas.getContext('2d'); + if (!context) { + return; + } + + const binary = atob(preview.pixels); + const imageData = context.createImageData(preview.width, preview.height); + for (let i = 0; i < binary.length; i += 1) { + const value = binary.charCodeAt(i); + const offset = i * 4; + imageData.data[offset] = value; + imageData.data[offset + 1] = value; + imageData.data[offset + 2] = value; + imageData.data[offset + 3] = 255; + } + context.putImageData(imageData, 0, 0); + }, [preview]); + + return ( + + ); +} + export default function ProjectLibrary({ onReverse }: { onReverse: (projId: string) => void }) { const [search, setSearch] = useState(''); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [selectedProject, setSelectedProject] = useState(null); - const [viewMode, setViewMode] = useState<'dicom' | 'model'>('dicom'); + const [viewMode, setViewMode] = useState<'dicom' | 'model' | 'mask'>('dicom'); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); - const [sliceIndex, setSliceIndex] = useState(42); + const [sliceIndex, setSliceIndex] = useState(0); const [visibleModules, setVisibleModules] = useState>({}); + const [dicomPreview, setDicomPreview] = useState(null); + const [dicomError, setDicomError] = useState(''); + const [selectedModelFile, setSelectedModelFile] = useState(''); + const [newProjectName, setNewProjectName] = useState(''); + const [editingProjectId, setEditingProjectId] = useState(''); + const [editingName, setEditingName] = useState(''); + const [actionMessage, setActionMessage] = useState(''); - useEffect(() => { - api.getProjects() + const refreshProjects = () => { + setLoading(true); + return api.getProjects() .then((items) => { setProjects(items); - setSelectedProject(items[0] ?? null); + setSelectedProject((current) => { + if (!current) { + return items[0] ?? null; + } + return items.find((item) => item.id === current.id) ?? items[0] ?? null; + }); }) .finally(() => setLoading(false)); + }; + + useEffect(() => { + refreshProjects(); }, []); const filteredProjects = useMemo(() => { @@ -68,7 +121,35 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri next[module] = visibleModules[module] ?? true; }); setVisibleModules(next); + setSelectedModelFile(selectedProject?.stlFiles?.[0] ?? ''); + setSliceIndex(0); }, [selectedProject?.id]); + + useEffect(() => { + if (!selectedProject || viewMode !== 'dicom' || !selectedProject.dicomCount) { + setDicomPreview(null); + return; + } + + let cancelled = false; + setDicomError(''); + api.getDicomPreview(selectedProject.id, sliceIndex) + .then((preview) => { + if (!cancelled) { + setDicomPreview(preview); + } + }) + .catch((error) => { + if (!cancelled) { + setDicomPreview(null); + setDicomError(error instanceof Error ? error.message : 'DICOM 预览失败'); + } + }); + + return () => { + cancelled = true; + }; + }, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, viewMode]); const toggleModule = (name: string) => { setVisibleModules(prev => ({ ...prev, [name]: !prev[name] })); @@ -81,6 +162,39 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri setVisibleModules(newState); }; + const handleCreateProject = async () => { + const name = newProjectName.trim(); + if (!name) { + setActionMessage('请输入项目名称'); + return; + } + const created = await api.createProject(name); + setNewProjectName(''); + setActionMessage(`已创建项目:${created.name}`); + await refreshProjects(); + setSelectedProject(created); + }; + + const handleRenameProject = async (projectId: string) => { + const name = editingName.trim(); + if (!name) { + setActionMessage('项目名称不能为空'); + return; + } + const updated = await api.renameProject(projectId, name); + setEditingProjectId(''); + setEditingName(''); + setActionMessage(`已更新项目名称:${updated.name}`); + await refreshProjects(); + setSelectedProject(updated); + }; + + const tabs = [ + { id: 'dicom' as const, label: 'DICOM 影像', icon: ImageIcon }, + { id: 'model' as const, label: '3D 模型', icon: Box }, + { id: 'mask' as const, label: '分割结果', icon: Layers }, + ]; + return (
{/* Project Sidebar - Collapsible */} @@ -98,7 +212,36 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri {!isSidebarCollapsed && (
-

项目列表

+
+

项目列表

+ +
+
+ setNewProjectName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + handleCreateProject(); + } + }} + placeholder="新项目名称" + className="min-w-0 flex-1 px-3 py-2 bg-slate-50 border-none rounded-lg text-xs focus:ring-1 focus:ring-blue-500 outline-none" + /> + +
{loading &&

正在从后端载入项目...

} {filteredProjects.map((proj) => ( - + +
+ ) : ( + + )} +

{proj.createTime} · DICOM {proj.dicomCount} · STL {proj.modelCount ?? 0}

- +
))}
+ {actionMessage &&

{actionMessage}

} )} @@ -154,22 +352,17 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri <>
- - + {tabs.map((tab) => ( + + ))}
+ +
+
+ )} ) : ( diff --git a/WebSite/src/lib/api.ts b/WebSite/src/lib/api.ts index 30e963e..4fd249b 100644 --- a/WebSite/src/lib/api.ts +++ b/WebSite/src/lib/api.ts @@ -1,4 +1,4 @@ -import { OverviewSummary, Project, SessionState, UserRecord } from '../types'; +import { DicomPreview, OverviewSummary, Project, SessionState, UserRecord } from '../types'; async function request(path: string, options: RequestInit = {}): Promise { const response = await fetch(path, { @@ -36,6 +36,18 @@ export const api = { getOverview: () => request('/api/overview'), getProjects: () => request('/api/projects'), getProject: (projectId: string) => request(`/api/projects/${projectId}`), + createProject: (name: string) => + request('/api/projects', { + method: 'POST', + body: JSON.stringify({ name }), + }), + renameProject: (projectId: string, name: string) => + request(`/api/projects/${projectId}`, { + method: 'PATCH', + body: JSON.stringify({ name }), + }), + getDicomPreview: (projectId: string, slice: number) => + request(`/api/projects/${projectId}/dicom-preview?slice=${slice}`), getUsers: () => request('/api/users'), resetDemo: () => request<{ ok: boolean; projects: Project[]; users: UserRecord[] }>('/api/demo/reset', { diff --git a/WebSite/src/types.ts b/WebSite/src/types.ts index cb8ee35..5d51c3d 100644 --- a/WebSite/src/types.ts +++ b/WebSite/src/types.ts @@ -18,6 +18,8 @@ export interface Project { modelCount?: number; stlFiles?: string[]; maskFormats?: Array<'nii' | 'nii.gz'>; + exportedMaskCount?: number; + isDefault?: boolean; } export interface MaskMapping { @@ -43,6 +45,7 @@ export interface SessionState { export interface OverviewSummary { totalProjects: number; processedProjects: number; + exportedMaskProjects: number; dicomCount: number; modelCount: number; chartData: Array<{ @@ -51,3 +54,14 @@ export interface OverviewSummary { processing: number; }>; } + +export interface DicomPreview { + width: number; + height: number; + pixels: string; + slice: number; + total: number; + fileName: string; + windowCenter: number; + windowWidth: number; +} diff --git a/工程分析/实现方案-2026-05-04-03-50-07.md b/工程分析/实现方案-2026-05-04-03-50-07.md new file mode 100644 index 0000000..ebdac34 --- /dev/null +++ b/工程分析/实现方案-2026-05-04-03-50-07.md @@ -0,0 +1,91 @@ +# 实现方案 + +时间戳:2026-05-04-03-50-07 + +## 修改目标 + +修复概况统计、图表警告和模块高亮问题;完善项目库真实 DICOM/STL/分割结果展示;增加项目创建和重命名能力;补齐 README 构建方案并重新部署。 + +## 涉及路径 + +- `WebSite/server.ts` +- `WebSite/src/lib/api.ts` +- `WebSite/src/types.ts` +- `WebSite/src/components/Overview.tsx` +- `WebSite/src/components/ProjectLibrary.tsx` +- `WebSite/README.md` +- `README.md` +- `工程分析/测试方案-2026-05-04-03-50-07.md` +- `工程分析/经验记录.md` + +## 技术路线 + +1. 后端项目状态增强。 + - 保留默认项目,同时允许新增用户创建项目。 + - 新增 `POST /api/projects` 创建项目。 + - 新增 `PATCH /api/projects/:projectId` 修改项目名称。 +2. 后端 DICOM 预览。 + - 新增 `GET /api/projects/:projectId/dicom-preview?slice=0`。 + - 读取当前 DICOM 文件,解析 Rows、Columns、Pixel Data、Window Center/Width、Rescale Slope/Intercept。 + - 返回 `width`、`height`、`pixels` base64 灰度数据和 slice 元数据。 +3. 后端 STL 文件服务。 + - 新增 `GET /api/projects/:projectId/models/:fileName`。 + - 只允许读取项目对应 STL 列表中的文件,避免任意路径读取。 +4. 前端项目库。 + - DICOM 视图使用 canvas 绘制后端返回灰度像素。 + - 3D 模型视图使用 Three.js `STLLoader` 加载真实 STL。 + - 新增 `分割结果` tab,展示 mask 预览、标签图例和 NII/NII.GZ 下载。 + - 项目列表顶部增加创建按钮和名称输入。 + - 项目卡右侧增加编辑图标,可重命名。 + - 移除首个 STL 模块默认蓝色高亮,所有模块同等样式。 +5. 概况页。 + - 已处理项目改为“已导出 Mask 项目”,避免项目总数 1 时已处理 1 的误导。 + - 趋势数据改为平稳小范围变化。 + - Recharts 容器增加 `min-w-0`、固定高度和加载态,避免宽高 -1 警告。 +6. README。 + - 补充 Node 构建运行方案。 + - 说明当前无需 Python/conda;真实体素化阶段再引入 Python 环境建议。 +7. 验证与部署。 + - `npm run lint` + - `npm run build` + - API smoke test + - 重新部署 `tmux` 会话到 `4000`。 + +## 数据流 + +DICOM 预览: + +前端切片滑块 -> `/api/projects/:id/dicom-preview?slice=n` -> 后端解析 DICOM 像素 -> 前端 canvas 绘制。 + +STL 预览: + +前端选择 STL 模块 -> `/api/projects/:id/models/:fileName` -> `STLLoader` 加载几何体 -> Three.js 渲染。 + +分割结果: + +前端 `分割结果` tab -> 展示 mask 预览 -> 调用已有 `/api/projects/:id/export-mask` 导出 `nii` 或 `nii.gz`。 + +项目管理: + +创建/重命名 -> 后端写入 `state.json` -> 项目列表刷新。 + +## 兼容性与回滚方案 + +- DICOM/STL 原始文件仍不提交 Git。 +- 若 DICOM 解析失败,前端显示错误态,不影响其他页面。 +- 若 STL 加载失败,前端显示模型加载失败提示,不影响 DICOM 和分割结果。 +- 回滚时恢复 `server.ts` 和项目库组件即可回到上一版前后端演示状态。 + +## 预计文件变更 + +- 修改后端 API。 +- 修改项目库和概况页。 +- 修改 API 类型和封装。 +- 修改 README。 +- 更新工程分析文档和经验记录。 + +## 人工审核状态 + +本次用户明确要求无需人工二次确认。 + +状态:自动确认,继续执行。 diff --git a/工程分析/测试方案-2026-05-04-03-50-07.md b/工程分析/测试方案-2026-05-04-03-50-07.md new file mode 100644 index 0000000..a54602e --- /dev/null +++ b/工程分析/测试方案-2026-05-04-03-50-07.md @@ -0,0 +1,97 @@ +# 测试方案 + +时间戳:2026-05-04-03-50-07 + +## 测试目标 + +验证概况统计、图表、真实 DICOM 预览、真实 STL 渲染、分割结果视图、项目创建/重命名、README 和部署均正常。 + +## 静态检查 + +- 检查 `Overview.tsx` 图表容器是否有稳定尺寸。 +- 检查 `ProjectLibrary.tsx` 是否包含三个 tab:`DICOM 影像`、`3D 模型`、`分割结果`。 +- 检查 STL 模块列表不再对首个模块默认蓝色高亮。 +- 检查 README 是否包含项目构建和运行方案。 + +## 构建与类型检查 + +```bash +cd WebSite +npm run lint +npm run build +``` + +预期结果: + +- TypeScript 检查通过。 +- Vite 构建通过。 + +## API 验证 + +```bash +curl -s http://127.0.0.1:4000/api/overview +curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?slice=0' +curl -I 'http://127.0.0.1:4000/api/projects/head-ct-demo/models/会厌.stl' +curl -s -X POST http://127.0.0.1:4000/api/projects -H 'Content-Type: application/json' -d '{"name":"测试创建项目"}' +curl -s -X PATCH http://127.0.0.1:4000/api/projects/head-ct-demo -H 'Content-Type: application/json' -d '{"name":"头部 CT 模型逆向体素化演示"}' +``` + +预期结果: + +- overview 返回平稳趋势和 `exportedMaskProjects`。 +- DICOM preview 返回 `width`、`height`、`pixels`。 +- STL 文件返回 200。 +- 创建项目返回新项目。 +- 重命名项目成功。 + +## 页面验证 + +- 总体概况不再显示“已处理项目总数 = 1”的误导文案。 +- 控制台不再出现 Recharts 宽高 -1 警告。 +- 项目库 DICOM 影像显示真实灰度切片。 +- 项目库 3D 模型显示真实 STL。 +- 项目库新增分割结果页,可下载 NII/NII.GZ。 +- 项目列表可创建项目。 +- 项目列表已有项目可点击编辑图标重命名。 +- STL 模块列表所有项目同等样式,没有“会厌”特殊蓝色突出。 + +## 回归风险 + +- DICOM 解析兼容性有限,当前先针对本项目 Little Endian CT 数据。 +- STL 文件较大时首次加载可能需要等待。 +- 新建项目默认无 DICOM/STL 数据,作为项目管理演示项。 + +## 人工审核状态 + +本次用户明确要求无需人工二次确认。 + +状态:自动确认,继续执行。 + +## 执行结果 + +- `npm run lint` 执行成功。 +- `npm run build` 执行成功。 +- Vite 仍有大 chunk 警告,当前不影响本次功能。 +- `GET /api/overview` 返回: + - `totalProjects: 1` + - `exportedMaskProjects: 0` + - `dicomCount: 300` + - `modelCount: 9` +- 已将“已处理项目”语义改为“已导出 Mask 项目”,避免项目总数为 1 时误导为已处理 1。 +- `GET /api/projects/head-ct-demo/dicom-preview?slice=0` 返回: + - `width: 512` + - `height: 512` + - `total: 300` + - `fileName: 1.dcm` + - `pixels` base64 数据。 +- `GET /api/projects/head-ct-demo/models/会厌.stl` 返回 `HTTP/1.1 200 OK`。 +- `POST /api/projects` 创建项目成功。 +- `PATCH /api/projects/:id` 重命名项目成功。 +- `POST /api/demo/reset` 执行成功,测试创建项目已清理,默认项目恢复。 +- headless Chrome 打开页面后未捕获 `width(-1)`、`height(-1)` 或 Recharts 相关警告。 +- `http://192.168.3.11:4000/` 返回 `HTTP/1.1 200 OK`。 +- 当前服务由 `tmux` 会话 `revoxelseg-dicom` 托管。 + +## 本次未创建 Python/conda 环境原因 + +本次 DICOM 预览、STL 渲染和 NIfTI 演示导出均可由现有 Node/React/Three.js 技术栈完成。为了避免引入不必要的运行环境复杂度,本次不创建 conda 环境;README 已说明后续真实体素化算法阶段再引入 Python/conda。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index f2ac841..51f0d96 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -181,3 +181,93 @@ C. 解决问题方案 D. 后续如何避免问题 多项目并行部署时,除了业务端口外,也检查 Vite HMR 端口;发现冲突时为每个项目分配独立 HMR 端口。 + +## 2026-05-04-03-50-07 概况统计语义误导 + +A. 具体问题 + +项目总数只有 1 时,“已处理项目总数”也显示 1,容易让用户误解为系统已经完成真实处理。 + +B. 产生问题原因 + +旧统计把 `status === completed` 直接当作已处理项目,而默认演示项目为了表示数据可用被标记为 `completed`。 + +C. 解决问题方案 + +将概况卡片语义改为“已导出 Mask 项目”,后端按 `exportedMaskCount > 0` 统计;默认项目初始为 0,只有实际触发 mask 导出后才计入。 + +D. 后续如何避免问题 + +统计卡片必须反映明确业务事件,不要把演示数据可用状态和处理完成状态混在一起。 + +## 2026-05-04-03-50-07 DICOM 预览解析 + +A. 具体问题 + +项目库 DICOM 影像页看不到真实影像,只显示占位图形。 + +B. 产生问题原因 + +前端没有读取 DICOM 像素数据,后端也没有提供切片预览 API。 + +C. 解决问题方案 + +新增 `GET /api/projects/:projectId/dicom-preview`,后端解析当前 Little Endian DICOM 的 Rows、Columns、Window、Rescale 和 Pixel Data,返回 base64 灰度像素,前端用 canvas 绘制真实切片。 + +D. 后续如何避免问题 + +医学影像可视化应优先验证真实像素是否进入浏览器。后续扩展更多 DICOM 传输语法时,应把解析器替换为成熟 DICOM 库或 Python 预处理服务。 + +## 2026-05-04-03-50-07 STL 模型真实渲染 + +A. 具体问题 + +项目库 3D 模型页显示的是占位立方体,不是 `Head_CT_ReConstruct` 中的真实 STL。 + +B. 产生问题原因 + +前端没有 STLLoader,后端也没有安全暴露 STL 文件。 + +C. 解决问题方案 + +新增 `GET /api/projects/:projectId/models/:fileName`,仅允许读取项目 STL 列表中的文件;前端使用 Three.js `STLLoader`、`Bounds` 和 `Center` 加载并自动居中显示模型。 + +D. 后续如何避免问题 + +三维模型视图必须加载真实模型文件,不能长期使用占位几何体;后端文件服务要限制文件名和项目白名单,避免任意路径读取。 + +## 2026-05-04-03-50-07 Recharts 容器尺寸警告 + +A. 具体问题 + +控制台出现 Recharts 警告:图表宽高为 `-1`,需要检查容器尺寸。 + +B. 产生问题原因 + +图表在布局尚未稳定时渲染,`ResponsiveContainer` 从父容器拿到异常宽高。 + +C. 解决问题方案 + +为图表外层设置 `min-w-0`、固定高度和 `min-h`,并在 chartData 存在后再渲染 `ResponsiveContainer`,高度使用明确数值 `300`。 + +D. 后续如何避免问题 + +使用 Recharts 时保证父容器有稳定尺寸;在异步数据未到达前不要提前渲染响应式图表。 + +## 2026-05-04-03-50-07 Python 环境引入边界 + +A. 具体问题 + +用户提出如果需要 Python 可新建 conda 环境,但本次需求可以在现有 Node/React/Three.js 技术栈中完成。 + +B. 产生问题原因 + +DICOM/STL/Mask 功能既可以由前端演示链路实现,也可能进入 Python 医学影像算法链路,需要判断当前阶段是否真的需要 Python。 + +C. 解决问题方案 + +本次不创建 conda 环境,避免增加不必要复杂度;README 中说明当前构建方式和后续真实体素化阶段的 conda 环境建议。 + +D. 后续如何避免问题 + +只有当需要真实 DICOM 空间解析、STL 体素填充、NIfTI 精确写入或批处理算法时,再引入 Python/conda,并把环境文件纳入项目文档。 diff --git a/工程分析/需求分析-2026-05-04-03-50-07.md b/工程分析/需求分析-2026-05-04-03-50-07.md new file mode 100644 index 0000000..fb39f3c --- /dev/null +++ b/工程分析/需求分析-2026-05-04-03-50-07.md @@ -0,0 +1,69 @@ +# 需求分析 + +时间戳:2026-05-04-03-50-07 + +## 原始需求摘要 + +用户要求严格使用代码编纂工作流处理本次修改,并在开始时复述工作流整体流程;本次需求分析、实现方案、测试方案和执行修改均不需要人工二次确认。 + +具体问题: + +1. 当前项目总数为 1,已处理项目也是 1,统计语义不合理;已处理项目趋势折线图过于夸张。 +2. 项目库中 DICOM 影像和 3D 模型看不到;如需要 Python,可新建 conda 环境,并在 README 写项目构建方案。 +3. 项目库中在 `DICOM 影像`、`3D 模型` 旁边增加 `分割结果`。 +4. 项目列表增加创建功能,可命名项目;已有项目右侧增加修改符号,可修改项目名称。 +5. STL 模块列表中“会厌”不应默认蓝色突出,它和其他模块没有特殊关系。 +6. 修复 Recharts 控制台警告:图表宽高为 -1。 +7. 复述项目最重要达成目标,并完善系统中其他应提升但未完善的地方。 + +## 业务目标 + +- 让系统概况统计更符合业务语义,避免误导。 +- 在项目库直接看到真实 DICOM 切片预览和真实 STL 模型预览。 +- 让项目库形成 DICOM、模型、分割结果三类核心资产视图。 +- 增加基础项目管理能力:创建项目、重命名项目。 +- 修复 UI 不合理高亮和控制台警告。 +- 补齐 README 中的构建、运行和部署说明。 + +## 输入与输出 + +输入: + +- `Head_CT_DICOM/` 下的 DICOM 切片。 +- `Head_CT_ReConstruct/` 下的 STL 模型。 +- 用户创建或重命名项目的名称。 + +输出: + +- 后端 DICOM 切片预览 API。 +- 后端 STL 文件静态读取 API。 +- 前端 DICOM canvas 预览。 +- 前端 STL 模型渲染。 +- 项目库新增 `分割结果` 视图。 +- 项目创建和重命名 API 及前端入口。 +- 修复后的概况统计与图表。 +- README 构建方案。 + +## 影响范围 + +- `WebSite/server.ts` +- `WebSite/src/lib/api.ts` +- `WebSite/src/types.ts` +- `WebSite/src/components/Overview.tsx` +- `WebSite/src/components/ProjectLibrary.tsx` +- `WebSite/README.md` +- `README.md` +- `工程分析/经验记录.md` + +## 风险点 + +- DICOM 文件可能存在不同传输语法。本次优先支持当前数据可见的 Little Endian DICOM,并做保守 fallback。 +- 浏览器渲染 STL 文件需要加载 `three/examples/jsm/loaders/STLLoader.js`,构建需验证 TypeScript/Vite 兼容。 +- 创建项目若不绑定真实数据,会作为空演示项目存在;默认项目仍绑定真实 DICOM/STL。 +- 图表容器警告与布局时机有关,需要给容器设置稳定 `min-w-0`、固定高度和加载态。 + +## 待确认问题 + +- 本次用户已明确无需二次人工确认,直接执行。 +- 本次不创建 Python conda 环境,因为 DICOM 预览和 STL 渲染可以通过 Node/React/Three.js 完成。 +- 后续若实现医学级真实体素化,可再引入 Python/conda 处理链。