commit f3543da90f0f881ab8d4b31dca407e6c4d9d573b Author: hssheng Date: Fri Jul 17 17:38:32 2026 +0800 Init: cursor生成V1.0版本 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7739f5c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +################################################################################ +# 此 .gitignore 文件已由 Microsoft(R) Visual Studio 自动创建。 +################################################################################ + +/build +/data +/install +.vscode \ No newline at end of file diff --git a/CMakeCache.txt b/CMakeCache.txt new file mode 100644 index 0000000..5cbdd0b --- /dev/null +++ b/CMakeCache.txt @@ -0,0 +1,78 @@ +# This is the CMakeCache file. +# For build in directory: d:/Manteia/CURSOR_PROJ/Compare +# It was generated by CMake: D:/Program Files/CMake/bin/cmake.exe +# You can edit this file to change values found and used by cmake. +# If you do not want to change any of the values, simply exit the editor. +# If you do want to change a value, simply edit, save, and exit the editor. +# The syntax for the file is as follows: +# KEY:TYPE=VALUE +# KEY is the name of a variable in the cache. +# TYPE is a hint to GUIs for the type of VALUE, DO NOT EDIT TYPE!. +# VALUE is the current value for the KEY. + +######################## +# EXTERNAL cache entries +######################## + +//Semicolon separated list of supported configuration types, only +// supports Debug, Release, MinSizeRel, and RelWithDebInfo, anything +// else will be ignored. +CMAKE_CONFIGURATION_TYPES:STRING=Debug;Release;MinSizeRel;RelWithDebInfo + +//Value Computed by CMake +CMAKE_PROJECT_DESCRIPTION:STATIC= + +//Value Computed by CMake +CMAKE_PROJECT_HOMEPAGE_URL:STATIC= + +//Value Computed by CMake +CMAKE_PROJECT_NAME:STATIC=DoseCompare + +//Value Computed by CMake +DoseCompare_BINARY_DIR:STATIC=D:/Manteia/CURSOR_PROJ/Compare + +//Value Computed by CMake +DoseCompare_IS_TOP_LEVEL:STATIC=ON + +//Value Computed by CMake +DoseCompare_SOURCE_DIR:STATIC=D:/Manteia/CURSOR_PROJ/Compare + + +######################## +# INTERNAL cache entries +######################## + +//This is the directory where this CMakeCache.txt was created +CMAKE_CACHEFILE_DIR:INTERNAL=d:/Manteia/CURSOR_PROJ/Compare +//Major version of cmake used to create the current loaded cache +CMAKE_CACHE_MAJOR_VERSION:INTERNAL=3 +//Minor version of cmake used to create the current loaded cache +CMAKE_CACHE_MINOR_VERSION:INTERNAL=23 +//Patch version of cmake used to create the current loaded cache +CMAKE_CACHE_PATCH_VERSION:INTERNAL=2 +//Path to CMake executable. +CMAKE_COMMAND:INTERNAL=D:/Program Files/CMake/bin/cmake.exe +//Path to cpack program executable. +CMAKE_CPACK_COMMAND:INTERNAL=D:/Program Files/CMake/bin/cpack.exe +//Path to ctest program executable. +CMAKE_CTEST_COMMAND:INTERNAL=D:/Program Files/CMake/bin/ctest.exe +//Name of external makefile project generator. +CMAKE_EXTRA_GENERATOR:INTERNAL= +//Name of generator. +CMAKE_GENERATOR:INTERNAL=Visual Studio 15 2017 +//Generator instance identifier. +CMAKE_GENERATOR_INSTANCE:INTERNAL= +//Name of generator platform. +CMAKE_GENERATOR_PLATFORM:INTERNAL=x64 +//Name of generator toolset. +CMAKE_GENERATOR_TOOLSET:INTERNAL= +//Source directory with the top level CMakeLists.txt file for this +// project +CMAKE_HOME_DIRECTORY:INTERNAL=D:/Manteia/CURSOR_PROJ/Compare +//number of local generators +CMAKE_NUMBER_OF_MAKEFILES:INTERNAL=1 +//Platform information initialized +CMAKE_PLATFORM_INFO_INITIALIZED:INTERNAL=1 +//Path to CMake installation. +CMAKE_ROOT:INTERNAL=D:/Program Files/CMake/share/cmake-3.23 + diff --git a/CMakeFiles/3.23.2/CMakeSystem.cmake b/CMakeFiles/3.23.2/CMakeSystem.cmake new file mode 100644 index 0000000..909db20 --- /dev/null +++ b/CMakeFiles/3.23.2/CMakeSystem.cmake @@ -0,0 +1,15 @@ +set(CMAKE_HOST_SYSTEM "Windows-10.0.19045") +set(CMAKE_HOST_SYSTEM_NAME "Windows") +set(CMAKE_HOST_SYSTEM_VERSION "10.0.19045") +set(CMAKE_HOST_SYSTEM_PROCESSOR "AMD64") + + + +set(CMAKE_SYSTEM "Windows-10.0.19045") +set(CMAKE_SYSTEM_NAME "Windows") +set(CMAKE_SYSTEM_VERSION "10.0.19045") +set(CMAKE_SYSTEM_PROCESSOR "AMD64") + +set(CMAKE_CROSSCOMPILING "FALSE") + +set(CMAKE_SYSTEM_LOADED 1) diff --git a/CMakeFiles/CMakeOutput.log b/CMakeFiles/CMakeOutput.log new file mode 100644 index 0000000..63ea406 --- /dev/null +++ b/CMakeFiles/CMakeOutput.log @@ -0,0 +1 @@ +The system is: Windows - 10.0.19045 - AMD64 diff --git a/CMakeFiles/cmake.check_cache b/CMakeFiles/cmake.check_cache new file mode 100644 index 0000000..3dccd73 --- /dev/null +++ b/CMakeFiles/cmake.check_cache @@ -0,0 +1 @@ +# This file is generated by cmake for dependency checking of the CMakeCache.txt file diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..06c2fbf --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,74 @@ +cmake_minimum_required(VERSION 3.10) +project(DoseCompare LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 14) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) +set(CMAKE_AUTOUIC ON) + +# Defaults matching local Manteia Toolkits (tomoViewer) +if(NOT DEFINED Qt5_DIR AND EXISTS "D:/Qt/Qt5.9.4/5.9.4/msvc2017_64/lib/cmake/Qt5") + set(Qt5_DIR "D:/Qt/Qt5.9.4/5.9.4/msvc2017_64/lib/cmake/Qt5" CACHE PATH "") +endif() +if(NOT DEFINED ITK_DIR AND EXISTS "C:/Toolkits/ITK/lib/cmake/ITK-4.13") + set(ITK_DIR "C:/Toolkits/ITK/lib/cmake/ITK-4.13" CACHE PATH "") +endif() +if(NOT DEFINED VTK_DIR AND EXISTS "C:/Toolkits/VTK/lib/cmake/vtk-8.1") + set(VTK_DIR "C:/Toolkits/VTK/lib/cmake/vtk-8.1" CACHE PATH "") +endif() + +find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets) +find_package(ITK REQUIRED) +include(${ITK_USE_FILE}) +find_package(VTK REQUIRED) +include(${VTK_USE_FILE}) + +if("${VTK_QT_VERSION}" STREQUAL "") + message(FATAL_ERROR "VTK was not built with Qt support") +endif() + +set(SOURCES + src/main.cpp + src/MainWindow.cpp + src/DoseImage.cpp + src/ImageManager.cpp + src/SliceViewWidget.cpp + src/ProfilePlotWidget.cpp + src/ImageListPanel.cpp + resources/resources.qrc +) + +set(HEADERS + src/types.h + src/MainWindow.h + src/DoseImage.h + src/ImageManager.h + src/SliceViewWidget.h + src/ProfilePlotWidget.h + src/ImageListPanel.h +) + +if(WIN32) + set(APP_ICON_RC "${CMAKE_CURRENT_SOURCE_DIR}/resources/app.rc") + set(CMAKE_RC_FLAGS "${CMAKE_RC_FLAGS} /I \"${CMAKE_CURRENT_SOURCE_DIR}/resources\"") +endif() + +add_executable(DoseCompare ${SOURCES} ${HEADERS} ${APP_ICON_RC}) +target_include_directories(DoseCompare PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) +target_link_libraries(DoseCompare PRIVATE + Qt5::Core Qt5::Gui Qt5::Widgets + ${ITK_LIBRARIES} + ${VTK_LIBRARIES} +) + +add_executable(smoke_slice_test src/smoke_slice_test.cpp src/DoseImage.cpp) +target_include_directories(smoke_slice_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) +target_link_libraries(smoke_slice_test PRIVATE Qt5::Core Qt5::Gui ${ITK_LIBRARIES}) + +if(MSVC) + target_compile_options(DoseCompare PRIVATE /utf-8 /W3 /EHsc) + target_compile_definitions(DoseCompare PRIVATE _CRT_SECURE_NO_WARNINGS) + target_compile_options(smoke_slice_test PRIVATE /utf-8 /W3 /EHsc) + target_compile_definitions(smoke_slice_test PRIVATE _CRT_SECURE_NO_WARNINGS) +endif() diff --git a/README.md b/README.md new file mode 100644 index 0000000..c11b682 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# DoseCompare + +Qt5 + ITK/VTK 放疗剂量三切面对比与 Profile 查看工具。 + +## 功能 + +- 上:轴位 / 矢状 / 冠状三切面,Front/Background 融合对比 +- 下:交线处三条 profile(横轴 mm,纵轴 Gy) +- 左侧:剂量图列表(勾选进 profile,右键删除) +- 仅加载 RT Dose / MHD;DICOM 按 `DoseGridScaling` 转为物理剂量 + +## 依赖 + +- Qt 5.x +- ITK(含 GDCM、MetaIO) +- VTK(含 GUISupportQt) +- CMake ≥ 3.16,MSVC(Windows) + +## 构建 + +```bat +mkdir build && cd build +cmake .. -G "Visual Studio 16 2019" -A x64 ^ + -DQt5_DIR=C:/path/to/Qt5/lib/cmake/Qt5 ^ + -DITK_DIR=C:/path/to/ITK-build ^ + -DVTK_DIR=C:/path/to/VTK-build +cmake --build . --config Release +``` + +按本机 Qt/ITK/VTK 安装路径修改上述 `-D` 参数。 + +## 交互(切面窗) + +| 操作 | 作用 | +|------|------| +| 左键拖动 | 窗宽窗位 | +| 右键拖动 | 平移 | +| 滚轮 | 切换切片 | +| 中键上下拖 | 缩放 | +| 滑条 | 切层(十字线与 profile 同步) | + +配色:轴位红 / 矢状蓝 / 冠状橙黄(窗口边框与交线一致)。Profile 按当前显示窗口可见范围采样。 diff --git a/crashlog.txt b/crashlog.txt new file mode 100644 index 0000000..52fabaa --- /dev/null +++ b/crashlog.txt @@ -0,0 +1,66 @@ +2026-07-17T14:27:05 app start +2026-07-17T14:27:06 MainWindow created +2026-07-17T14:27:06 MainWindow shown +2026-07-17T14:27:06 processEvents done +2026-07-17T14:27:06 loading D:\testNewPhy\halcyon\examples\che1\0000137408\internal\doseOldAve.mhd +loadDoseFile enter +refreshViews view 0 setBackground +rebuild axis=0 begin +rebuild half=287.5,285 step=2.5,2.5 +rebuild before updateSlice +rebuild before setupCamera +rebuild before crosshair/render +rebuild done +refreshViews view 0 setFront +refreshViews view 0 resetCenter +refreshViews view 1 setBackground +rebuild axis=1 begin +rebuild half=285,160.5 step=2.5,3 +rebuild before updateSlice +rebuild before setupCamera +rebuild before crosshair/render +rebuild done +refreshViews view 1 setFront +refreshViews view 1 resetCenter +refreshViews view 2 setBackground +rebuild axis=2 begin +rebuild half=287.5,160.5 step=2.5,3 +rebuild before updateSlice +rebuild before setupCamera +rebuild before crosshair/render +rebuild done +refreshViews view 2 setFront +refreshViews view 2 resetCenter +loadFile done id=1aae13e9-b0e0-466b-b038-63a0fe104208 err= +before refreshImageSelectors +before refreshViews +refreshViews view 0 setBackground +rebuild axis=0 begin +rebuild half=287.5,285 step=2.5,2.5 +rebuild before updateSlice +rebuild before setupCamera +rebuild before crosshair/render +rebuild done +refreshViews view 0 setFront +refreshViews view 0 resetCenter +refreshViews view 1 setBackground +rebuild axis=1 begin +rebuild half=285,160.5 step=2.5,3 +rebuild before updateSlice +rebuild before setupCamera +rebuild before crosshair/render +rebuild done +refreshViews view 1 setFront +refreshViews view 1 resetCenter +refreshViews view 2 setBackground +rebuild axis=2 begin +rebuild half=287.5,160.5 step=2.5,3 +rebuild before updateSlice +rebuild before setupCamera +rebuild before crosshair/render +rebuild done +refreshViews view 2 setFront +refreshViews view 2 resetCenter +before refreshProfiles +2026-07-17T14:27:07 load ok +2026-07-17T14:27:07 enter event loop diff --git a/resources/app.rc b/resources/app.rc new file mode 100644 index 0000000..58a77c4 --- /dev/null +++ b/resources/app.rc @@ -0,0 +1 @@ +IDI_ICON1 ICON "dosecompare.ico" diff --git a/resources/dosecompare.ico b/resources/dosecompare.ico new file mode 100644 index 0000000..2458437 Binary files /dev/null and b/resources/dosecompare.ico differ diff --git a/resources/dosecompare.png b/resources/dosecompare.png new file mode 100644 index 0000000..f9f9a42 Binary files /dev/null and b/resources/dosecompare.png differ diff --git a/resources/resources.qrc b/resources/resources.qrc new file mode 100644 index 0000000..75ee518 --- /dev/null +++ b/resources/resources.qrc @@ -0,0 +1,7 @@ + + + + dosecompare.png + dosecompare.ico + + diff --git a/src/DoseImage.cpp b/src/DoseImage.cpp new file mode 100644 index 0000000..167d642 --- /dev/null +++ b/src/DoseImage.cpp @@ -0,0 +1,167 @@ +#include "DoseImage.h" + +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace { + +float readDoseGridScaling(itk::GDCMImageIO* gdcm) +{ + std::string scaling; + if (gdcm->GetValueFromTag("3004|000e", scaling) || + gdcm->GetValueFromTag("3004|000E", scaling)) { + try { + const float s = std::stof(scaling); + if (s > 0.f) + return s; + } catch (...) { + } + } + return 1.f; +} + +} // namespace + +DoseImage::Pointer DoseImage::loadFromFile(const QString& path, QString* error) +{ + auto dose = Pointer(new DoseImage()); + dose->m_path = path; + dose->m_name = QFileInfo(path).fileName(); + + const QString lower = path.toLower(); + const bool isMhd = lower.endsWith(".mhd") || lower.endsWith(".mha"); + const bool isNii = lower.endsWith(".nii") || lower.endsWith(".nii.gz"); + const bool isDcm = lower.endsWith(".dcm") || lower.endsWith(".dicom"); + + try { + using Reader = itk::ImageFileReader; + auto reader = Reader::New(); + reader->SetFileName(path.toStdString()); + + if (isMhd) { + auto meta = itk::MetaImageIO::New(); + reader->SetImageIO(meta); + reader->Update(); + dose->m_image = reader->GetOutput(); + } else if (isNii) { + // NIfTI:交给 ITK 工厂(通常含 NiftiImageIO) + reader->Update(); + dose->m_image = reader->GetOutput(); + } else if (isDcm) { + auto gdcm = itk::GDCMImageIO::New(); + reader->SetImageIO(gdcm); + reader->Update(); + dose->m_image = reader->GetOutput(); + + const float scale = readDoseGridScaling(gdcm); + if (std::abs(scale - 1.f) > 1e-6f) { + itk::ImageRegionIterator it( + dose->m_image, dose->m_image->GetLargestPossibleRegion()); + for (it.GoToBegin(); !it.IsAtEnd(); ++it) + it.Set(it.Get() * scale); + } + } else { + // 其它扩展名:先试通用读取,失败再试 GDCM + try { + reader->Update(); + dose->m_image = reader->GetOutput(); + } catch (...) { + auto gdcm = itk::GDCMImageIO::New(); + auto reader2 = Reader::New(); + reader2->SetImageIO(gdcm); + reader2->SetFileName(path.toStdString()); + reader2->Update(); + dose->m_image = reader2->GetOutput(); + const float scale = readDoseGridScaling(gdcm); + if (std::abs(scale - 1.f) > 1e-6f) { + itk::ImageRegionIterator it( + dose->m_image, dose->m_image->GetLargestPossibleRegion()); + for (it.GoToBegin(); !it.IsAtEnd(); ++it) + it.Set(it.Get() * scale); + } + } + } + } catch (const itk::ExceptionObject& e) { + if (error) + *error = QString::fromLocal8Bit(e.what()); + return nullptr; + } + + dose->computeStats(); + dose->ensureInterpolator(); + return dose; +} + +void DoseImage::computeStats() +{ + using Calc = itk::MinimumMaximumImageCalculator; + auto calc = Calc::New(); + calc->SetImage(m_image); + calc->Compute(); + m_min = calc->GetMinimum(); + m_max = calc->GetMaximum(); + if (m_max <= m_min) + m_max = m_min + 1.f; +} + +void DoseImage::ensureInterpolator() const +{ + if (!m_interp) { + m_interp = DoseInterpolator::New(); + m_interp->SetInputImage(m_image); + } +} + +float DoseImage::sampleWorld(double x, double y, double z) const +{ + ensureInterpolator(); + + DoseImageType::PointType pt; + pt[0] = x; + pt[1] = y; + pt[2] = z; + + if (!m_interp->IsInsideBuffer(pt)) + return m_min; + + return static_cast(m_interp->Evaluate(pt)); +} + +void DoseImage::worldBounds(double bmin[3], double bmax[3]) const +{ + const auto region = m_image->GetLargestPossibleRegion(); + const auto size = region.GetSize(); + const auto index = region.GetIndex(); + + DoseImageType::IndexType corners[8]; + int n = 0; + for (int dz = 0; dz < 2; ++dz) + for (int dy = 0; dy < 2; ++dy) + for (int dx = 0; dx < 2; ++dx) { + corners[n][0] = index[0] + (dx ? static_cast(size[0] - 1) : 0); + corners[n][1] = index[1] + (dy ? static_cast(size[1] - 1) : 0); + corners[n][2] = index[2] + (dz ? static_cast(size[2] - 1) : 0); + ++n; + } + + for (int i = 0; i < 3; ++i) { + bmin[i] = 1e30; + bmax[i] = -1e30; + } + for (int c = 0; c < 8; ++c) { + DoseImageType::PointType p; + m_image->TransformIndexToPhysicalPoint(corners[c], p); + for (int i = 0; i < 3; ++i) { + bmin[i] = std::min(bmin[i], p[i]); + bmax[i] = std::max(bmax[i], p[i]); + } + } +} diff --git a/src/DoseImage.h b/src/DoseImage.h new file mode 100644 index 0000000..6f6b274 --- /dev/null +++ b/src/DoseImage.h @@ -0,0 +1,52 @@ +#pragma once + +#include "types.h" + +#include +#include + +#include + +using DoseImageType = itk::Image; +using DoseInterpolator = itk::LinearInterpolateImageFunction; + +class DoseImage { +public: + using Pointer = std::shared_ptr; + + static Pointer loadFromFile(const QString& path, QString* error = nullptr); + + const QString& name() const { return m_name; } + const QString& path() const { return m_path; } + QColor color() const { return m_color; } + void setColor(const QColor& c) { m_color = c; } + + DoseImageType::Pointer image() const { return m_image; } + + float minValue() const { return m_min; } + float maxValue() const { return m_max; } + + DoseImageType::SpacingType spacing() const { return m_image->GetSpacing(); } + DoseImageType::PointType origin() const { return m_image->GetOrigin(); } + DoseImageType::DirectionType direction() const { return m_image->GetDirection(); } + DoseImageType::RegionType region() const { return m_image->GetLargestPossibleRegion(); } + + // Sample physical Gy at world point (mm). Out of bounds -> minValue. + float sampleWorld(double x, double y, double z) const; + + // World AABB of this volume (mm) + void worldBounds(double bmin[3], double bmax[3]) const; + +private: + DoseImage() = default; + void computeStats(); + void ensureInterpolator() const; + + QString m_name; + QString m_path; + QColor m_color{220, 50, 50}; + DoseImageType::Pointer m_image; + mutable DoseInterpolator::Pointer m_interp; + float m_min = 0.f; + float m_max = 1.f; +}; diff --git a/src/ImageListPanel.cpp b/src/ImageListPanel.cpp new file mode 100644 index 0000000..10715e5 --- /dev/null +++ b/src/ImageListPanel.cpp @@ -0,0 +1,62 @@ +#include "ImageListPanel.h" +#include "ImageManager.h" + +#include +#include +#include +#include + +ImageListPanel::ImageListPanel(ImageManager* manager, QWidget* parent) + : QWidget(parent) + , m_manager(manager) +{ + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(4, 4, 4, 4); + layout->addWidget(new QLabel(tr("剂量图像"), this)); + + m_list = new QListWidget(this); + m_list->setContextMenuPolicy(Qt::CustomContextMenu); + layout->addWidget(m_list); + + connect(m_list, &QListWidget::itemChanged, this, &ImageListPanel::onItemChanged); + connect(m_list, &QListWidget::customContextMenuRequested, this, + &ImageListPanel::onCustomContextMenu); + connect(m_manager, &ImageManager::imageListChanged, this, &ImageListPanel::refresh); + connect(m_manager, &ImageManager::profileSelectionChanged, this, &ImageListPanel::refresh); +} + +void ImageListPanel::refresh() +{ + m_block = true; + m_list->clear(); + for (const auto& img : m_manager->images()) { + auto* item = new QListWidgetItem(img.dose->name(), m_list); + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setCheckState(img.profileVisible ? Qt::Checked : Qt::Unchecked); + item->setData(Qt::UserRole, img.id); + item->setForeground(img.dose->color()); + item->setToolTip(img.dose->path()); + } + m_block = false; +} + +void ImageListPanel::onItemChanged(QListWidgetItem* item) +{ + if (m_block || !item) + return; + const QString id = item->data(Qt::UserRole).toString(); + m_manager->setProfileVisible(id, item->checkState() == Qt::Checked); +} + +void ImageListPanel::onCustomContextMenu(const QPoint& pos) +{ + auto* item = m_list->itemAt(pos); + if (!item) + return; + QMenu menu(this); + QAction* del = menu.addAction(tr("删除")); + if (menu.exec(m_list->mapToGlobal(pos)) == del) { + const QString id = item->data(Qt::UserRole).toString(); + emit requestDelete(id); + } +} diff --git a/src/ImageListPanel.h b/src/ImageListPanel.h new file mode 100644 index 0000000..6a19b71 --- /dev/null +++ b/src/ImageListPanel.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +class ImageManager; +class QListWidgetItem; + +class ImageListPanel : public QWidget { + Q_OBJECT +public: + explicit ImageListPanel(ImageManager* manager, QWidget* parent = nullptr); + +public slots: + void refresh(); + +signals: + void requestDelete(const QString& id); + +private: + void onItemChanged(QListWidgetItem* item); + void onCustomContextMenu(const QPoint& pos); + + ImageManager* m_manager = nullptr; + QListWidget* m_list = nullptr; + bool m_block = false; +}; diff --git a/src/ImageManager.cpp b/src/ImageManager.cpp new file mode 100644 index 0000000..2dd5af9 --- /dev/null +++ b/src/ImageManager.cpp @@ -0,0 +1,71 @@ +#include "ImageManager.h" + +ImageManager::ImageManager(QObject* parent) + : QObject(parent) +{ +} + +QString ImageManager::loadFile(const QString& path, QString* error) +{ + auto dose = DoseImage::loadFromFile(path, error); + if (!dose) + return {}; + + ManagedImage item; + item.id = QUuid::createUuid().toString().remove('{').remove('}'); + item.dose = dose; + item.dose->setColor(profileColor(m_colorIndex++)); + item.profileVisible = true; + m_images.push_back(item); + + emit imageListChanged(); + emit profileSelectionChanged(); + return item.id; +} + +bool ImageManager::remove(const QString& id) +{ + for (int i = 0; i < m_images.size(); ++i) { + if (m_images[i].id == id) { + m_images.removeAt(i); + emit imageListChanged(); + emit profileSelectionChanged(); + return true; + } + } + return false; +} + +ManagedImage* ImageManager::find(const QString& id) +{ + for (auto& m : m_images) { + if (m.id == id) + return &m; + } + return nullptr; +} + +const ManagedImage* ImageManager::find(const QString& id) const +{ + for (const auto& m : m_images) { + if (m.id == id) + return &m; + } + return nullptr; +} + +DoseImage::Pointer ImageManager::imageById(const QString& id) const +{ + const auto* m = find(id); + return m ? m->dose : nullptr; +} + +void ImageManager::setProfileVisible(const QString& id, bool on) +{ + if (auto* m = find(id)) { + if (m->profileVisible != on) { + m->profileVisible = on; + emit profileSelectionChanged(); + } + } +} diff --git a/src/ImageManager.h b/src/ImageManager.h new file mode 100644 index 0000000..7e17be1 --- /dev/null +++ b/src/ImageManager.h @@ -0,0 +1,38 @@ +#pragma once + +#include "DoseImage.h" + +#include +#include +#include + +struct ManagedImage { + QString id; + DoseImage::Pointer dose; + bool profileVisible = false; +}; + +class ImageManager : public QObject { + Q_OBJECT +public: + explicit ImageManager(QObject* parent = nullptr); + + QString loadFile(const QString& path, QString* error = nullptr); + bool remove(const QString& id); + + const QVector& images() const { return m_images; } + ManagedImage* find(const QString& id); + const ManagedImage* find(const QString& id) const; + + DoseImage::Pointer imageById(const QString& id) const; + + void setProfileVisible(const QString& id, bool on); + +signals: + void imageListChanged(); + void profileSelectionChanged(); + +private: + QVector m_images; + int m_colorIndex = 0; +}; diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp new file mode 100644 index 0000000..5e02317 --- /dev/null +++ b/src/MainWindow.cpp @@ -0,0 +1,681 @@ +#include "MainWindow.h" +#include "ImageManager.h" +#include "ImageListPanel.h" +#include "SliceViewWidget.h" +#include "ProfilePlotWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +MainWindow::MainWindow(QWidget* parent) + : QMainWindow(parent) +{ + m_manager = new ImageManager(this); + buildUi(); + setAcceptDrops(true); + + connect(m_manager, &ImageManager::imageListChanged, this, &MainWindow::refreshImageSelectors); + connect(m_manager, &ImageManager::profileSelectionChanged, this, &MainWindow::refreshProfiles); + + resize(1400, 900); + setWindowTitle(tr("DoseCompare — 剂量三切面对比")); +} + +MainWindow::~MainWindow() = default; + +void MainWindow::buildUi() +{ + auto* importAct = new QAction(tr("导入剂量..."), this); + connect(importAct, &QAction::triggered, this, &MainWindow::onImportDose); + menuBar()->addMenu(tr("文件"))->addAction(importAct); + + auto* tb = addToolBar(tr("对比")); + tb->addWidget(new QLabel(tr(" Background "), this)); + m_bgCombo = new QComboBox(this); + m_bgCombo->setMinimumWidth(160); + tb->addWidget(m_bgCombo); + tb->addWidget(new QLabel(tr(" Front "), this)); + m_frontCombo = new QComboBox(this); + m_frontCombo->setMinimumWidth(160); + tb->addWidget(m_frontCombo); + tb->addWidget(new QLabel(tr(" 透明度 "), this)); + m_opacitySlider = new QSlider(Qt::Horizontal, this); + m_opacitySlider->setRange(0, 100); + m_opacitySlider->setValue(50); + m_opacitySlider->setFixedWidth(160); + tb->addWidget(m_opacitySlider); + m_opacityLabel = new QLabel(tr("50%"), this); + tb->addWidget(m_opacityLabel); + + tb->addSeparator(); + tb->addWidget(new QLabel(tr(" Colormap "), this)); + m_colorMapCombo = new QComboBox(this); + m_colorMapCombo->blockSignals(true); + m_colorMapCombo->addItem(tr("Gray"), static_cast(DoseColorMap::Gray)); + m_colorMapCombo->addItem(tr("Rainbow"), static_cast(DoseColorMap::Rainbow)); + m_colorMapCombo->addItem(tr("Jet"), static_cast(DoseColorMap::Jet)); + m_colorMapCombo->addItem(tr("Hot"), static_cast(DoseColorMap::Hot)); + m_colorMapCombo->setMinimumWidth(110); + m_colorMapCombo->blockSignals(false); + tb->addWidget(m_colorMapCombo); + + connect(m_bgCombo, QOverload::of(&QComboBox::currentIndexChanged), + this, &MainWindow::onBackgroundChanged); + connect(m_frontCombo, QOverload::of(&QComboBox::currentIndexChanged), + this, &MainWindow::onFrontChanged); + connect(m_opacitySlider, &QSlider::valueChanged, this, &MainWindow::onOpacityChanged); + connect(m_colorMapCombo, QOverload::of(&QComboBox::currentIndexChanged), + this, &MainWindow::onColorMapChanged); + + auto* central = new QWidget(this); + setCentralWidget(central); + auto* root = new QHBoxLayout(central); + + m_listPanel = new ImageListPanel(m_manager, this); + m_listPanel->setFixedWidth(220); + connect(m_listPanel, &ImageListPanel::requestDelete, this, &MainWindow::onDeleteImage); + root->addWidget(m_listPanel); + + auto* right = new QVBoxLayout(); + root->addLayout(right, 1); + + auto* sliceRow = new QHBoxLayout(); + auto addSliceCol = [&](ViewAxis axis, SliceViewWidget*& view, QSlider*& slider, + const QString& title) { + auto* col = new QVBoxLayout(); + col->addWidget(new QLabel(title, this)); + view = new SliceViewWidget(axis, this); + col->addWidget(view, 1); + slider = new QSlider(Qt::Horizontal, this); + slider->setRange(0, 0); + col->addWidget(slider); + sliceRow->addLayout(col, 1); + connect(view, &SliceViewWidget::cursorChanged, this, &MainWindow::onCursorChanged); + connect(view, &SliceViewWidget::viewExtentChanged, this, &MainWindow::refreshProfiles); + connect(view, &SliceViewWidget::zoomScaleChanged, this, &MainWindow::onZoomScaleChanged); + connect(view, &SliceViewWidget::panWorldDelta, this, &MainWindow::onPanWorldDelta); + }; + addSliceCol(ViewAxis::Axial, m_axial, m_sliderAxial, tr("1 轴位 (Axial)")); + addSliceCol(ViewAxis::Sagittal, m_sagittal, m_sliderSagittal, tr("2 矢状 (Sagittal)")); + addSliceCol(ViewAxis::Coronal, m_coronal, m_sliderCoronal, tr("3 冠状 (Coronal)")); + right->addLayout(sliceRow, 3); + + connect(m_sliderAxial, &QSlider::valueChanged, this, &MainWindow::onSliceSliderChanged); + connect(m_sliderSagittal, &QSlider::valueChanged, this, &MainWindow::onSliceSliderChanged); + connect(m_sliderCoronal, &QSlider::valueChanged, this, &MainWindow::onSliceSliderChanged); + + auto* profRow = new QHBoxLayout(); + m_profAxial = new ProfilePlotWidget(ViewAxis::Axial, this); + m_profAxial->setTitle(tr("4 Profile — 沿 LR (X) [可视范围]")); + m_profAxial->setFrameColor(axisColor(ViewAxis::Sagittal)); + m_profSagittal = new ProfilePlotWidget(ViewAxis::Sagittal, this); + m_profSagittal->setTitle(tr("5 Profile — 沿 AP (Y) [可视范围]")); + m_profSagittal->setFrameColor(axisColor(ViewAxis::Coronal)); + m_profCoronal = new ProfilePlotWidget(ViewAxis::Coronal, this); + m_profCoronal->setTitle(tr("6 Profile — 沿 SI (Z) [可视范围]")); + m_profCoronal->setFrameColor(axisColor(ViewAxis::Axial)); + profRow->addWidget(m_profAxial, 1); + profRow->addWidget(m_profSagittal, 1); + profRow->addWidget(m_profCoronal, 1); + right->addLayout(profRow, 2); + + statusBar()->showMessage( + tr("可拖入 mhd/nii/dcm。Ctrl+在1/2/3上移动可重切另两面。")); +} + +QString MainWindow::defaultOpenDir() const +{ + QSettings settings(QStringLiteral("Manteia"), QStringLiteral("DoseCompare")); + const QString last = settings.value(QStringLiteral("lastOpenDir")).toString(); + if (!last.isEmpty() && QDir(last).exists()) + return last; + return QCoreApplication::applicationDirPath(); +} + +void MainWindow::rememberOpenPath(const QString& path) +{ + const QString dir = QFileInfo(path).absolutePath(); + if (dir.isEmpty()) + return; + QSettings settings(QStringLiteral("Manteia"), QStringLiteral("DoseCompare")); + settings.setValue(QStringLiteral("lastOpenDir"), dir); +} + +bool MainWindow::isSupportedDosePath(const QString& path) const +{ + const QString lower = path.toLower(); + return lower.endsWith(QStringLiteral(".mhd")) || lower.endsWith(QStringLiteral(".mha")) + || lower.endsWith(QStringLiteral(".nii")) || lower.endsWith(QStringLiteral(".nii.gz")) + || lower.endsWith(QStringLiteral(".dcm")) || lower.endsWith(QStringLiteral(".dicom")); +} + +void MainWindow::onImportDose() +{ + const QString path = QFileDialog::getOpenFileName( + this, tr("导入剂量图像"), defaultOpenDir(), + tr("Dose (*.dcm *.mhd *.mha *.nii *.nii.gz);;DICOM (*.dcm);;MetaImage (*.mhd *.mha);;" + "NIfTI (*.nii *.nii.gz);;All (*.*)")); + if (path.isEmpty()) + return; + QString err; + if (!loadDoseFile(path, &err)) + QMessageBox::warning(this, tr("导入失败"), err.isEmpty() ? tr("未知错误") : err); +} + +void MainWindow::dragEnterEvent(QDragEnterEvent* event) +{ + if (!event->mimeData() || !event->mimeData()->hasUrls()) { + event->ignore(); + return; + } + for (const QUrl& url : event->mimeData()->urls()) { + if (url.isLocalFile() && isSupportedDosePath(url.toLocalFile())) { + event->acceptProposedAction(); + return; + } + } + event->ignore(); +} + +void MainWindow::dropEvent(QDropEvent* event) +{ + if (!event->mimeData() || !event->mimeData()->hasUrls()) { + event->ignore(); + return; + } + QStringList errors; + int ok = 0; + for (const QUrl& url : event->mimeData()->urls()) { + if (!url.isLocalFile()) + continue; + const QString path = url.toLocalFile(); + if (!isSupportedDosePath(path)) + continue; + QString err; + if (loadDoseFile(path, &err)) + ++ok; + else + errors << QStringLiteral("%1: %2").arg(QFileInfo(path).fileName(), + err.isEmpty() ? tr("失败") : err); + } + if (!errors.isEmpty()) + QMessageBox::warning(this, tr("部分导入失败"), errors.join(QStringLiteral("\n"))); + if (ok > 0) + event->acceptProposedAction(); + else + event->ignore(); +} + +void MainWindow::applyBgFgAfterLoad(const QString& newId, int countBefore) +{ + m_pendingBgId.clear(); + m_pendingFgId.clear(); + m_hasPendingRoles = false; + if (countBefore == 0) { + m_pendingBgId = newId; + m_hasPendingRoles = true; + } else if (countBefore == 1) { + m_pendingFgId = newId; + m_hasPendingRoles = true; + } +} + +void MainWindow::promoteBgFgAfterDelete(const QString& deletedId, const QString& oldBg, + const QString& oldFg) +{ + // 在 remove 之前根据“删除后”的逻辑准备 pending;remove 时 list 仍含 deleted + QVector remaining; + for (const auto& im : m_manager->images()) { + if (im.id != deletedId) + remaining.push_back(im); + } + + QString bg = (oldBg == deletedId) ? QString() : oldBg; + QString fg = (oldFg == deletedId) ? QString() : oldFg; + + auto idExists = [&](const QString& id) { + for (const auto& im : remaining) { + if (im.id == id) + return true; + } + return false; + }; + if (!bg.isEmpty() && !idExists(bg)) + bg.clear(); + if (!fg.isEmpty() && !idExists(fg)) + fg.clear(); + + if (bg.isEmpty() && !remaining.isEmpty()) + bg = remaining.first().id; + + if (fg.isEmpty() && remaining.size() >= 2) { + for (const auto& im : remaining) { + if (im.id != bg) { + fg = im.id; + break; + } + } + } + if (fg == bg) + fg.clear(); + + m_pendingBgId = bg; + m_pendingFgId = fg; + m_hasPendingRoles = true; +} + +bool MainWindow::loadDoseFile(const QString& path, QString* error) +{ + const int countBefore = m_manager->images().size(); + QString err; + const QString id = m_manager->loadFile(path, &err); + if (id.isEmpty()) { + if (error) + *error = err; + return false; + } + + rememberOpenPath(path); + applyBgFgAfterLoad(id, countBefore); + + if (countBefore == 0) { + auto dose = m_manager->imageById(id); + double bmin[3], bmax[3]; + dose->worldBounds(bmin, bmax); + m_cursor = {(bmin[0] + bmax[0]) * 0.5, (bmin[1] + bmax[1]) * 0.5, + (bmin[2] + bmax[2]) * 0.5}; + } + + refreshImageSelectors(); + refreshViews(); + refreshProfiles(); + statusBar()->showMessage(tr("已加载: %1").arg(path), 5000); + return true; +} + +void MainWindow::onDeleteImage(const QString& id) +{ + const QString oldBg = currentBgId(); + const QString oldFg = currentFgId(); + promoteBgFgAfterDelete(id, oldBg, oldFg); + m_manager->remove(id); + refreshImageSelectors(); + refreshViews(); + refreshProfiles(); +} + +QString MainWindow::currentBgId() const +{ + return m_bgCombo->currentData().toString(); +} + +QString MainWindow::currentFgId() const +{ + return m_frontCombo->currentData().toString(); +} + +DoseImage::Pointer MainWindow::bgDose() const +{ + return m_manager->imageById(currentBgId()); +} + +DoseImage::Pointer MainWindow::fgDose() const +{ + const QString id = currentFgId(); + if (id.isEmpty() || id == currentBgId()) + return nullptr; + return m_manager->imageById(id); +} + +void MainWindow::refreshImageSelectors() +{ + QString oldBg = currentBgId(); + QString oldFg = currentFgId(); + if (m_hasPendingRoles) { + if (!m_pendingBgId.isEmpty()) + oldBg = m_pendingBgId; + oldFg = m_pendingFgId; + m_hasPendingRoles = false; + } + + m_bgCombo->blockSignals(true); + m_frontCombo->blockSignals(true); + m_bgCombo->clear(); + m_frontCombo->clear(); + m_frontCombo->addItem(tr("(无)"), QString()); + + for (const auto& img : m_manager->images()) { + m_bgCombo->addItem(img.dose->name(), img.id); + m_frontCombo->addItem(img.dose->name(), img.id); + } + + int bgIdx = m_bgCombo->findData(oldBg); + if (bgIdx < 0 && m_bgCombo->count() > 0) + bgIdx = 0; + m_bgCombo->setCurrentIndex(bgIdx); + + int fgIdx = m_frontCombo->findData(oldFg); + if (fgIdx < 0) { + fgIdx = 0; + if (m_manager->images().size() >= 2 && bgIdx >= 0) { + const QString bgId = m_bgCombo->itemData(bgIdx).toString(); + for (int i = 1; i < m_frontCombo->count(); ++i) { + if (m_frontCombo->itemData(i).toString() != bgId) { + fgIdx = i; + break; + } + } + } + } + m_frontCombo->setCurrentIndex(fgIdx); + + const bool multi = m_manager->images().size() >= 2; + m_frontCombo->setEnabled(multi); + m_opacitySlider->setEnabled(multi); + + m_bgCombo->blockSignals(false); + m_frontCombo->blockSignals(false); +} + +void MainWindow::onFrontChanged(int) +{ + refreshViews(); +} + +void MainWindow::onBackgroundChanged(int) +{ + refreshViews(); + syncSliceSliders(); + refreshProfiles(); +} + +void MainWindow::onOpacityChanged(int value) +{ + m_opacityLabel->setText(QStringLiteral("%1%").arg(value)); + const double op = value / 100.0; + m_axial->setOpacity(op); + m_sagittal->setOpacity(op); + m_coronal->setOpacity(op); +} + +void MainWindow::onColorMapChanged(int) +{ + if (!m_colorMapCombo || !m_axial) + return; + const auto map = static_cast(m_colorMapCombo->currentData().toInt()); + m_axial->setColorMap(map); + m_sagittal->setColorMap(map); + m_coronal->setColorMap(map); +} + +void MainWindow::refreshViews() +{ + auto bg = bgDose(); + auto fg = fgDose(); + const double op = m_opacitySlider->value() / 100.0; + + for (auto* v : {m_axial, m_sagittal, m_coronal}) { + v->setBackground(bg); + v->setFront(fg); + v->setOpacity(op); + if (bg) + v->resetViewToVolumeCenter(); + } + if (bg) { + double bmin[3], bmax[3]; + bg->worldBounds(bmin, bmax); + m_cursor = {(bmin[0] + bmax[0]) * 0.5, (bmin[1] + bmax[1]) * 0.5, + (bmin[2] + bmax[2]) * 0.5}; + syncSharedZoomFromViews(); + onColorMapChanged(0); + } + syncSliceSliders(); +} + +void MainWindow::syncSharedZoomFromViews() +{ + if (!m_axial || !m_sagittal || !m_coronal) + return; + const double s = std::max({m_axial->parallelScale(), m_sagittal->parallelScale(), + m_coronal->parallelScale()}); + m_blockViewSync = true; + m_axial->setParallelScaleShared(s); + m_sagittal->setParallelScaleShared(s); + m_coronal->setParallelScaleShared(s); + m_blockViewSync = false; +} + +void MainWindow::onZoomScaleChanged(double parallelScale) +{ + if (m_blockViewSync) + return; + m_blockViewSync = true; + for (auto* v : {m_axial, m_sagittal, m_coronal}) { + if (v) + v->setParallelScaleShared(parallelScale); + } + m_blockViewSync = false; +} + +void MainWindow::onPanWorldDelta(double dx, double dy, double dz) +{ + if (m_blockViewSync) + return; + auto* src = qobject_cast(sender()); + if (!src) + return; + m_blockViewSync = true; + for (auto* v : {m_axial, m_sagittal, m_coronal}) { + if (v && v != src) + v->applyWorldPanShared(dx, dy, dz); + } + m_blockViewSync = false; +} + +void MainWindow::syncSliceSliders() +{ + auto bg = bgDose(); + if (!bg) { + m_sliderAxial->setRange(0, 0); + m_sliderSagittal->setRange(0, 0); + m_sliderCoronal->setRange(0, 0); + return; + } + + const auto size = bg->region().GetSize(); + const auto spacing = bg->spacing(); + const auto origin = bg->origin(); + + m_blockSliders = true; + m_sliderSagittal->setRange(0, static_cast(size[0]) - 1); + m_sliderCoronal->setRange(0, static_cast(size[1]) - 1); + m_sliderAxial->setRange(0, static_cast(size[2]) - 1); + + DoseImageType::PointType pt; + pt[0] = m_cursor.x; + pt[1] = m_cursor.y; + pt[2] = m_cursor.z; + DoseImageType::IndexType idx; + bg->image()->TransformPhysicalPointToIndex(pt, idx); + const auto start = bg->region().GetIndex(); + + auto clampIdx = [](long v, long n) { + if (v < 0L) return 0; + if (v > n) return static_cast(n); + return static_cast(v); + }; + m_sliderSagittal->setValue(clampIdx(idx[0] - start[0], static_cast(size[0]) - 1)); + m_sliderCoronal->setValue(clampIdx(idx[1] - start[1], static_cast(size[1]) - 1)); + m_sliderAxial->setValue(clampIdx(idx[2] - start[2], static_cast(size[2]) - 1)); + m_blockSliders = false; + + Q_UNUSED(spacing); + Q_UNUSED(origin); +} + +void MainWindow::onSliceSliderChanged(int) +{ + if (m_blockSliders) + return; + auto bg = bgDose(); + if (!bg) + return; + + const auto spacing = bg->spacing(); + const auto origin = bg->origin(); + const auto start = bg->region().GetIndex(); + + const int ix = m_sliderSagittal->value() + static_cast(start[0]); + const int iy = m_sliderCoronal->value() + static_cast(start[1]); + const int iz = m_sliderAxial->value() + static_cast(start[2]); + + DoseImageType::IndexType idx; + idx[0] = ix; + idx[1] = iy; + idx[2] = iz; + DoseImageType::PointType pt; + bg->image()->TransformIndexToPhysicalPoint(idx, pt); + setCursor({pt[0], pt[1], pt[2]}, true); + Q_UNUSED(spacing); + Q_UNUSED(origin); +} + +void MainWindow::onCursorChanged(Vec3d world) +{ + setCursor(world, false); +} + +void MainWindow::setCursor(const Vec3d& world, bool fromSlider) +{ + m_cursor = world; + m_axial->setCursorWorld(m_cursor); + m_sagittal->setCursorWorld(m_cursor); + m_coronal->setCursorWorld(m_cursor); + if (!fromSlider) + syncSliceSliders(); + refreshProfiles(); +} + +void MainWindow::refreshProfiles() +{ + extractProfiles(); +} + +void MainWindow::extractProfiles() +{ + QVector alongX, alongY, alongZ; + auto bg = bgDose(); + if (!bg) { + m_profAxial->setSeries({}); + m_profSagittal->setSeries({}); + m_profCoronal->setSeries({}); + return; + } + + double volMin[3], volMax[3]; + bg->worldBounds(volMin, volMax); + const auto sp = bg->spacing(); + + double rmin[3] = {volMin[0], volMin[1], volMin[2]}; + double rmax[3] = {volMax[0], volMax[1], volMax[2]}; + + auto intersectVisible = [&](int axis, std::initializer_list views) { + bool any = false; + double lo = 0.0, hi = 0.0; + for (SliceViewWidget* view : views) { + double t0 = 0.0, t1 = 0.0; + if (!view->visibleRangeForWorldAxis(axis, t0, t1)) + continue; + if (!any) { + lo = t0; + hi = t1; + any = true; + } else { + lo = std::max(lo, t0); + hi = std::min(hi, t1); + } + } + if (!any || hi <= lo) + return; + rmin[axis] = std::max(volMin[axis], lo); + rmax[axis] = std::min(volMax[axis], hi); + if (rmax[axis] <= rmin[axis]) { + rmin[axis] = volMin[axis]; + rmax[axis] = volMax[axis]; + } + }; + + intersectVisible(0, {m_axial, m_coronal}); + intersectVisible(1, {m_axial, m_sagittal}); + intersectVisible(2, {m_sagittal, m_coronal}); + + auto sampleLine = [&](DoseImage::Pointer dose, int axis, QVector& pos, + QVector& val) { + pos.clear(); + val.clear(); + const double step = std::abs(sp[axis]); + const double lo = rmin[axis]; + const double hi = rmax[axis]; + if (step < 1e-9 || hi < lo) + return; + for (double t = lo; t <= hi + 0.5 * step; t += step) { + pos.push_back(t); + if (axis == 0) + val.push_back(dose->sampleWorld(t, m_cursor.y, m_cursor.z)); + else if (axis == 1) + val.push_back(dose->sampleWorld(m_cursor.x, t, m_cursor.z)); + else + val.push_back(dose->sampleWorld(m_cursor.x, m_cursor.y, t)); + } + }; + + for (const auto& img : m_manager->images()) { + if (!img.profileVisible) + continue; + ProfileSeries sx, sy, sz; + sx.name = sy.name = sz.name = img.dose->name(); + sx.color = sy.color = sz.color = img.dose->color(); + sampleLine(img.dose, 0, sx.positionsMm, sx.valuesGy); + sampleLine(img.dose, 1, sy.positionsMm, sy.valuesGy); + sampleLine(img.dose, 2, sz.positionsMm, sz.valuesGy); + alongX.push_back(sx); + alongY.push_back(sy); + alongZ.push_back(sz); + } + + const double yMin = static_cast(bg->minValue()); + const double yMax = static_cast(bg->maxValue()); + m_profAxial->setYRange(yMin, yMax); + m_profSagittal->setYRange(yMin, yMax); + m_profCoronal->setYRange(yMin, yMax); + + m_profAxial->setSeries(alongX); + m_profAxial->setCursorMm(m_cursor.x); + m_profSagittal->setSeries(alongY); + m_profSagittal->setCursorMm(m_cursor.y); + m_profCoronal->setSeries(alongZ); + m_profCoronal->setCursorMm(m_cursor.z); +} diff --git a/src/MainWindow.h b/src/MainWindow.h new file mode 100644 index 0000000..3ab9198 --- /dev/null +++ b/src/MainWindow.h @@ -0,0 +1,89 @@ +#pragma once + +#include "types.h" +#include "DoseImage.h" + +#include +#include + +class ImageManager; +class SliceViewWidget; +class ProfilePlotWidget; +class ImageListPanel; +class QComboBox; +class QSlider; +class QLabel; +class QDragEnterEvent; +class QDropEvent; + +class MainWindow : public QMainWindow { + Q_OBJECT +public: + explicit MainWindow(QWidget* parent = nullptr); + ~MainWindow() override; + + bool loadDoseFile(const QString& path, QString* error = nullptr); + +protected: + void dragEnterEvent(QDragEnterEvent* event) override; + void dropEvent(QDropEvent* event) override; + +private slots: + void onImportDose(); + void onDeleteImage(const QString& id); + void onFrontChanged(int index); + void onBackgroundChanged(int index); + void onOpacityChanged(int value); + void onColorMapChanged(int index); + void onSliceSliderChanged(int value); + void onCursorChanged(Vec3d world); + void refreshImageSelectors(); + void refreshViews(); + void refreshProfiles(); + void syncSliceSliders(); + void onZoomScaleChanged(double parallelScale); + void onPanWorldDelta(double dx, double dy, double dz); + void syncSharedZoomFromViews(); + +private: + void buildUi(); + void extractProfiles(); + QString currentBgId() const; + QString currentFgId() const; + DoseImage::Pointer bgDose() const; + DoseImage::Pointer fgDose() const; + void setCursor(const Vec3d& world, bool fromSlider); + QString defaultOpenDir() const; + void rememberOpenPath(const QString& path); + bool isSupportedDosePath(const QString& path) const; + void applyBgFgAfterLoad(const QString& newId, int countBefore); + void promoteBgFgAfterDelete(const QString& deletedId, const QString& oldBg, const QString& oldFg); + + ImageManager* m_manager = nullptr; + ImageListPanel* m_listPanel = nullptr; + + SliceViewWidget* m_axial = nullptr; + SliceViewWidget* m_sagittal = nullptr; + SliceViewWidget* m_coronal = nullptr; + bool m_blockViewSync = false; + + ProfilePlotWidget* m_profAxial = nullptr; + ProfilePlotWidget* m_profSagittal = nullptr; + ProfilePlotWidget* m_profCoronal = nullptr; + + QComboBox* m_frontCombo = nullptr; + QComboBox* m_bgCombo = nullptr; + QComboBox* m_colorMapCombo = nullptr; + QSlider* m_opacitySlider = nullptr; + QLabel* m_opacityLabel = nullptr; + + QSlider* m_sliderAxial = nullptr; + QSlider* m_sliderSagittal = nullptr; + QSlider* m_sliderCoronal = nullptr; + + Vec3d m_cursor; + bool m_blockSliders = false; + QString m_pendingFgId; + QString m_pendingBgId; + bool m_hasPendingRoles = false; +}; diff --git a/src/ProfilePlotWidget.cpp b/src/ProfilePlotWidget.cpp new file mode 100644 index 0000000..0f1ee80 --- /dev/null +++ b/src/ProfilePlotWidget.cpp @@ -0,0 +1,154 @@ +#include "ProfilePlotWidget.h" + +#include +#include +#include + +#include + +ProfilePlotWidget::ProfilePlotWidget(ViewAxis axis, QWidget* parent) + : QWidget(parent) + , m_axis(axis) + , m_frameColor(axisColor(axis)) +{ + setMinimumHeight(120); + setBackgroundRole(QPalette::Base); + setAutoFillBackground(true); + setFrameColor(m_frameColor); +} + +void ProfilePlotWidget::setTitle(const QString& title) +{ + m_title = title; + update(); +} + +void ProfilePlotWidget::setSeries(const QVector& series) +{ + m_series = series; + update(); +} + +void ProfilePlotWidget::setCursorMm(double posMm) +{ + m_cursorMm = posMm; + update(); +} + +void ProfilePlotWidget::setYRange(double yMin, double yMax) +{ + if (!(yMax > yMin)) + yMax = yMin + 1.0; + m_yMin = yMin; + m_yMax = yMax; + m_hasYRange = true; + update(); +} + +void ProfilePlotWidget::setFrameColor(const QColor& color) +{ + m_frameColor = color; + setStyleSheet(QStringLiteral( + "ProfilePlotWidget { border: 2px solid %1; background: #1e1e22; }") + .arg(m_frameColor.name())); + update(); +} + +void ProfilePlotWidget::paintEvent(QPaintEvent*) +{ + QPainter p(this); + p.fillRect(rect().adjusted(2, 2, -2, -2), QColor(30, 30, 34)); + p.setRenderHint(QPainter::Antialiasing); + + // 线框(与 stylesheet 双保险) + p.setPen(QPen(m_frameColor, 2)); + p.setBrush(Qt::NoBrush); + p.drawRect(rect().adjusted(1, 1, -2, -2)); + + const int left = 48, right = 12, top = 22, bottom = 28; + const QRect plot(left, top, width() - left - right, height() - top - bottom); + + p.setPen(QColor(180, 180, 190)); + p.drawText(8, 16, m_title.isEmpty() ? QStringLiteral("Profile") : m_title); + + if (m_series.isEmpty() || plot.width() < 10 || plot.height() < 10) { + p.drawText(plot, Qt::AlignCenter, tr("No data")); + return; + } + + double xMin = 1e30, xMax = -1e30; + for (const auto& s : m_series) { + for (double x : s.positionsMm) { + xMin = std::min(xMin, x); + xMax = std::max(xMax, x); + } + } + if (xMax <= xMin) + xMax = xMin + 1; + + double yMin, yMax; + if (m_hasYRange) { + yMin = m_yMin; + yMax = m_yMax; + } else { + yMin = 1e30; + yMax = -1e30; + for (const auto& s : m_series) { + for (double y : s.valuesGy) { + yMin = std::min(yMin, y); + yMax = std::max(yMax, y); + } + } + if (yMax <= yMin) + yMax = yMin + 1; + const double yPad = (yMax - yMin) * 0.05; + yMin -= yPad; + yMax += yPad; + } + + auto mapX = [&](double x) { + return plot.left() + (x - xMin) / (xMax - xMin) * plot.width(); + }; + auto mapY = [&](double y) { + return plot.bottom() - (y - yMin) / (yMax - yMin) * plot.height(); + }; + + p.setPen(QColor(70, 70, 80)); + p.drawRect(plot); + + if (m_cursorMm >= xMin && m_cursorMm <= xMax) { + p.setPen(QPen(QColor(80, 220, 120), 1, Qt::DashLine)); + const int cx = static_cast(mapX(m_cursorMm)); + p.drawLine(cx, plot.top(), cx, plot.bottom()); + } + + for (const auto& s : m_series) { + if (s.positionsMm.size() < 2) + continue; + p.setPen(QPen(s.color, 1.8)); + QPainterPath path; + path.moveTo(mapX(s.positionsMm[0]), mapY(s.valuesGy[0])); + for (int i = 1; i < s.positionsMm.size(); ++i) + path.lineTo(mapX(s.positionsMm[i]), mapY(s.valuesGy[i])); + p.drawPath(path); + } + + p.setPen(QColor(160, 160, 170)); + p.drawText(plot.left(), height() - 8, QStringLiteral("%1 mm").arg(xMin, 0, 'f', 1)); + p.drawText(plot.right() - 60, height() - 8, QStringLiteral("%1 mm").arg(xMax, 0, 'f', 1)); + p.drawText(4, plot.bottom(), QStringLiteral("%1").arg(yMin, 0, 'f', 1)); + p.drawText(4, plot.top() + 10, QStringLiteral("%1 Gy").arg(yMax, 0, 'f', 1)); + + int ly = top + 4; + int lx = plot.right() - 120; + for (const auto& s : m_series) { + p.setPen(s.color); + p.setBrush(s.color); + p.drawRect(lx, ly, 12, 8); + p.setPen(QColor(200, 200, 210)); + p.drawText(lx + 16, ly + 9, s.name); + ly += 14; + if (ly > plot.bottom() - 10) + break; + } +} diff --git a/src/ProfilePlotWidget.h b/src/ProfilePlotWidget.h new file mode 100644 index 0000000..424c1db --- /dev/null +++ b/src/ProfilePlotWidget.h @@ -0,0 +1,32 @@ +#pragma once + +#include "types.h" + +#include +#include +#include + +class ProfilePlotWidget : public QWidget { + Q_OBJECT +public: + explicit ProfilePlotWidget(ViewAxis axis, QWidget* parent = nullptr); + + void setTitle(const QString& title); + void setSeries(const QVector& series); + void setCursorMm(double posMm); + void setYRange(double yMin, double yMax); + void setFrameColor(const QColor& color); + +protected: + void paintEvent(QPaintEvent* event) override; + +private: + ViewAxis m_axis; + QString m_title; + QVector m_series; + double m_cursorMm = 0; + double m_yMin = 0; + double m_yMax = 1; + bool m_hasYRange = false; + QColor m_frameColor{200, 200, 200}; +}; diff --git a/src/SliceViewWidget.cpp b/src/SliceViewWidget.cpp new file mode 100644 index 0000000..7579c83 --- /dev/null +++ b/src/SliceViewWidget.cpp @@ -0,0 +1,1046 @@ +#include "SliceViewWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +vtkSmartPointer makeCrossActor(const double rgb[3]) +{ + auto poly = vtkSmartPointer::New(); + auto pts = vtkSmartPointer::New(); + pts->InsertNextPoint(0, 0, 0); + pts->InsertNextPoint(1, 0, 0); + auto lines = vtkSmartPointer::New(); + lines->InsertNextCell(2); + lines->InsertCellPoint(0); + lines->InsertCellPoint(1); + poly->SetPoints(pts); + poly->SetLines(lines); + auto mapper = vtkSmartPointer::New(); + mapper->SetInputData(poly); + auto actor = vtkSmartPointer::New(); + actor->SetMapper(mapper); + actor->GetProperty()->SetColor(rgb[0], rgb[1], rgb[2]); + actor->GetProperty()->SetLineWidth(1.8); + return actor; +} + +QLabel* makeEdgeLabel(QWidget* parent, const QString& text, const QColor& color) +{ + auto* lab = new QLabel(text, parent); + lab->setStyleSheet(QStringLiteral( + "QLabel { color: %1; font: bold 14px 'Segoe UI'; background: transparent; }") + .arg(color.name())); + lab->setAttribute(Qt::WA_TransparentForMouseEvents); + lab->raise(); + return lab; +} + +} // namespace + +ViewAxis SliceViewWidget::colorForLineDirection(const double dir[3]) +{ + int ax = 0; + if (std::abs(dir[1]) >= std::abs(dir[0]) && std::abs(dir[1]) >= std::abs(dir[2])) + ax = 1; + else if (std::abs(dir[2]) >= std::abs(dir[0]) && std::abs(dir[2]) >= std::abs(dir[1])) + ax = 2; + if (ax == 0) + return ViewAxis::Sagittal; + if (ax == 1) + return ViewAxis::Coronal; + return ViewAxis::Axial; +} + +SliceViewWidget::SliceViewWidget(ViewAxis axis, QWidget* parent) + : QWidget(parent) + , m_axis(axis) +{ + // 必须开 StyledBackground,否则 stylesheet 边框经常不画 + setAttribute(Qt::WA_StyledBackground, true); + setAutoFillBackground(true); + applyBorderStyle(); + + auto* layout = new QVBoxLayout(this); + // 给色框留出空隙(VTK OpenGL 子窗体会盖住无 margin 的边) + layout->setContentsMargins(2, 2, 2, 2); + layout->setSpacing(0); + + m_vtkWidget = new QVTKOpenGLWidget(this); + layout->addWidget(m_vtkWidget); + + auto window = vtkSmartPointer::New(); + m_vtkWidget->SetRenderWindow(window); + + m_renderer = vtkSmartPointer::New(); + m_renderer->SetBackground(0.08, 0.08, 0.1); + window->AddRenderer(m_renderer); + + m_lutBg = vtkSmartPointer::New(); + m_lutFg = vtkSmartPointer::New(); + m_mapBg = vtkSmartPointer::New(); + m_mapFg = vtkSmartPointer::New(); + m_actorBg = vtkSmartPointer::New(); + m_actorFg = vtkSmartPointer::New(); + rebuildColorMaps(); + + double rgbH[3], rgbV[3]; + axisColorRgb(ViewAxis::Axial, rgbH); + axisColorRgb(ViewAxis::Sagittal, rgbV); + m_crossH = makeCrossActor(rgbH); + m_crossV = makeCrossActor(rgbV); + m_renderer->AddActor2D(m_crossH); + m_renderer->AddActor2D(m_crossV); + + const QColor ac = axisColor(m_axis); + QString top, bottom, left, right; + switch (m_axis) { + case ViewAxis::Axial: + top = QStringLiteral("A"); + bottom = QStringLiteral("P"); + left = QStringLiteral("L"); + right = QStringLiteral("R"); + break; + case ViewAxis::Sagittal: + top = QStringLiteral("S"); + bottom = QStringLiteral("I"); + left = QStringLiteral("P"); + right = QStringLiteral("A"); + break; + case ViewAxis::Coronal: + top = QStringLiteral("S"); + bottom = QStringLiteral("I"); + left = QStringLiteral("L"); + right = QStringLiteral("R"); + break; + } + m_labelTop = makeEdgeLabel(this, top, ac); + m_labelBottom = makeEdgeLabel(this, bottom, ac); + m_labelLeft = makeEdgeLabel(this, left, ac); + m_labelRight = makeEdgeLabel(this, right, ac); + + m_pickLabel = new QLabel(this); + m_pickLabel->setStyleSheet(QStringLiteral( + "QLabel { color: #f0f0f0; font: 12px 'Consolas'; background: rgba(0,0,0,140);" + " padding: 4px 6px; border-radius: 3px; }")); + m_pickLabel->setAttribute(Qt::WA_TransparentForMouseEvents); + m_pickLabel->hide(); + m_pickLabel->raise(); + + auto style = vtkSmartPointer::New(); + m_vtkWidget->GetInteractor()->SetInteractorStyle(style); + + m_vtkWidget->installEventFilter(this); + m_vtkWidget->setMouseTracking(true); + setMouseTracking(true); + updateOrientationLabels(); +} + +void SliceViewWidget::applyBorderStyle() +{ + const QColor c = axisColor(m_axis); + QPalette pal = palette(); + pal.setColor(QPalette::Window, c); + setPalette(pal); + setStyleSheet(QStringLiteral( + "SliceViewWidget {" + " border: 1px solid %1;" + " background-color: %1;" + "}") + .arg(c.name())); +} + +void SliceViewWidget::paintEvent(QPaintEvent* event) +{ + QWidget::paintEvent(event); + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, false); + p.setPen(QPen(axisColor(m_axis), 1)); + p.setBrush(Qt::NoBrush); + p.drawRect(rect().adjusted(0, 0, -1, -1)); +} + +namespace { + +void fillLookupTable(vtkLookupTable* lut, DoseColorMap map, double lo, double hi) +{ + if (!lut) + return; + if (!(hi > lo)) + hi = lo + 1.0; + lut->SetNumberOfTableValues(256); + lut->SetRange(lo, hi); + + auto clamp01 = [](double x) { + return std::max(0.0, std::min(1.0, x)); + }; + + for (int i = 0; i < 256; ++i) { + const double t = i / 255.0; + double r = t, g = t, b = t; + switch (map) { + case DoseColorMap::Gray: + r = g = b = t; + break; + case DoseColorMap::Rainbow: { + // 蓝→青→绿→黄→红 + const double h = (1.0 - t) * 0.6667; + const double s = 1.0; + const double v = 1.0; + const int sector = static_cast(h * 6.0) % 6; + const double f = h * 6.0 - std::floor(h * 6.0); + const double p = v * (1.0 - s); + const double q = v * (1.0 - s * f); + const double u = v * (1.0 - s * (1.0 - f)); + switch (sector) { + case 0: r = v; g = u; b = p; break; + case 1: r = q; g = v; b = p; break; + case 2: r = p; g = v; b = u; break; + case 3: r = p; g = q; b = v; break; + case 4: r = u; g = p; b = v; break; + default: r = v; g = p; b = q; break; + } + break; + } + case DoseColorMap::Jet: { + // 经典 jet + r = clamp01(1.5 - std::abs(4.0 * t - 3.0)); + g = clamp01(1.5 - std::abs(4.0 * t - 2.0)); + b = clamp01(1.5 - std::abs(4.0 * t - 1.0)); + break; + } + case DoseColorMap::Hot: { + r = clamp01(t * 2.5); + g = clamp01(t * 2.5 - 1.0); + b = clamp01(t * 2.5 - 2.0); + break; + } + } + lut->SetTableValue(i, r, g, b, 1.0); + } + lut->Build(); +} + +} // namespace + +void SliceViewWidget::rebuildColorMaps() +{ + const double lo = m_level - 0.5 * m_window; + const double hi = m_level + 0.5 * m_window; + fillLookupTable(m_lutBg, m_colorMap, lo, hi); + fillLookupTable(m_lutFg, m_colorMap, lo, hi); + if (m_mapBg) + m_mapBg->SetLookupTable(m_lutBg); + if (m_mapFg) + m_mapFg->SetLookupTable(m_lutFg); +} + +void SliceViewWidget::refreshMappedImages() +{ + // 无切片输入时禁止 Update,否则 vtkImageMapToColors 会报 0 connections + if (m_bgSlice && m_mapBg) { + m_mapBg->SetInputData(m_bgSlice); + m_mapBg->SetLookupTable(m_lutBg); + m_mapBg->SetOutputFormatToRGB(); + m_mapBg->Update(); + if (auto* mapper = m_actorBg->GetMapper()) + mapper->SetInputConnection(m_mapBg->GetOutputPort()); + } + if (m_fgSlice && m_mapFg) { + m_mapFg->SetInputData(m_fgSlice); + m_mapFg->SetLookupTable(m_lutFg); + m_mapFg->SetOutputFormatToRGB(); + m_mapFg->Update(); + if (auto* mapper = m_actorFg->GetMapper()) + mapper->SetInputConnection(m_mapFg->GetOutputPort()); + } + if (m_bgSlice && m_vtkWidget && m_vtkWidget->GetRenderWindow()) + m_vtkWidget->GetRenderWindow()->Render(); +} + +void SliceViewWidget::setColorMap(DoseColorMap map) +{ + m_colorMap = map; + rebuildColorMaps(); + refreshMappedImages(); +} + +void SliceViewWidget::viewDirections(double u[3], double v[3], double n[3]) const +{ + // 物理轴按 LPS 显示(+X=L +Y=P +Z=S)。MHD 虽写 RAI,但按 LPS 才能上A下P。 + // 轴位:上A下P左L右R —— 「屁股朝天」= P 在上,必须用 v=-Y(A) + for (int i = 0; i < 3; ++i) + u[i] = v[i] = n[i] = 0.0; + switch (m_axis) { + case ViewAxis::Axial: + u[0] = -1.0; // 屏右 = R = -X + v[1] = -1.0; // 屏上 = A = -Y(+Y 是屁股 P) + n[2] = 1.0; // 从头(S)往脚(I)看 + break; + case ViewAxis::Sagittal: + // 从患者右看:上S下I 左P右A + u[1] = -1.0; // 右 = A = -Y + v[2] = 1.0; // 上 = S = +Z + n[0] = -1.0; // 患者右 = -X,从右侧看 + break; + case ViewAxis::Coronal: + // 面朝患者、约定俗成:上S下I 左L右R(图像水平翻转) + u[0] = -1.0; // 屏右 = R = -X;屏左 = L = +X + v[2] = 1.0; // 上 = S = +Z + n[1] = -1.0; // 从 A(-Y) 看向 P + break; + } +} + +void SliceViewWidget::inPlaneWorldAxes(int& uAxis, int& vAxis) const +{ + switch (m_axis) { + case ViewAxis::Axial: + uAxis = 0; + vAxis = 1; + break; + case ViewAxis::Sagittal: + uAxis = 1; + vAxis = 2; + break; + case ViewAxis::Coronal: + uAxis = 0; + vAxis = 2; + break; + } +} + +void SliceViewWidget::updateOrientationLabels() +{ + if (!m_labelTop) + return; + const int m = 6; + m_labelTop->move((width() - m_labelTop->sizeHint().width()) / 2, m); + m_labelBottom->move((width() - m_labelBottom->sizeHint().width()) / 2, + height() - m_labelBottom->sizeHint().height() - m); + m_labelLeft->move(m, (height() - m_labelLeft->sizeHint().height()) / 2); + m_labelRight->move(width() - m_labelRight->sizeHint().width() - m, + (height() - m_labelRight->sizeHint().height()) / 2); + m_labelTop->raise(); + m_labelBottom->raise(); + m_labelLeft->raise(); + m_labelRight->raise(); + m_pickLabel->raise(); +} + +void SliceViewWidget::setBackground(DoseImage::Pointer bg) +{ + m_bg = std::move(bg); + rebuildPipeline(); +} + +void SliceViewWidget::setFront(DoseImage::Pointer fg) +{ + m_fg = std::move(fg); + if (!m_bg) { + // 无背景时不必单独建 front + return; + } + // 轻量更新:不拆整管线 + if (m_fg) { + m_renderer->RemoveActor(m_actorFg); + m_renderer->AddActor(m_actorFg); + m_fgSlice = samplePhysicalSlice(m_fg); + rebuildColorMaps(); + m_mapFg->SetInputData(m_fgSlice); + m_mapFg->SetLookupTable(m_lutFg); + m_mapFg->SetOutputFormatToRGB(); + m_mapFg->Update(); + if (auto* mapper = m_actorFg->GetMapper()) + mapper->SetInputConnection(m_mapFg->GetOutputPort()); + m_actorFg->SetOpacity(m_frontOpacity); + m_actorFg->InterpolateOn(); + } else { + m_renderer->RemoveActor(m_actorFg); + m_fgSlice = nullptr; + } + m_vtkWidget->GetRenderWindow()->Render(); +} + +void SliceViewWidget::setOpacity(double frontOpacity) +{ + m_frontOpacity = std::max(0.0, std::min(1.0, frontOpacity)); + if (m_fg) { + m_actorFg->SetOpacity(m_frontOpacity); + m_actorBg->SetOpacity(1.0); + } else { + m_actorBg->SetOpacity(1.0); + } + m_vtkWidget->GetRenderWindow()->Render(); +} + +Vec3d SliceViewWidget::sliceOrigin() const +{ + // m_viewCenter = 体中心(仅加载/复位时设置),平移只动相机、绝不改它。 + // 否则:pan 把 viewCenter 写成 ori+fp,滚轮重采样后再叠加相机 fp → 双倍偏移,放大后看不见图。 + double u[3], v[3], n[3]; + viewDirections(u, v, n); + const double dx = m_cursor.x - m_viewCenter.x; + const double dy = m_cursor.y - m_viewCenter.y; + const double dz = m_cursor.z - m_viewCenter.z; + const double dn = dx * n[0] + dy * n[1] + dz * n[2]; + return {m_viewCenter.x + dn * n[0], m_viewCenter.y + dn * n[1], + m_viewCenter.z + dn * n[2]}; +} + +void SliceViewWidget::physicalStepUV(double& su, double& sv) const +{ + if (!m_bg) { + su = sv = 1.0; + return; + } + // 用 ITK spacing 在 U/V 上的物理步长(取主导分量) + const auto sp = m_bg->spacing(); + double u[3], v[3], n[3]; + viewDirections(u, v, n); + su = std::max(1e-3, std::abs(u[0]) * std::abs(sp[0]) + std::abs(u[1]) * std::abs(sp[1]) + + std::abs(u[2]) * std::abs(sp[2])); + sv = std::max(1e-3, std::abs(v[0]) * std::abs(sp[0]) + std::abs(v[1]) * std::abs(sp[1]) + + std::abs(v[2]) * std::abs(sp[2])); +} + +void SliceViewWidget::sliceSizeUV(double& halfU, double& halfV) const +{ + halfU = halfV = 50.0; + if (!m_bg) + return; + double bmin[3], bmax[3]; + m_bg->worldBounds(bmin, bmax); + double u[3], v[3], n[3]; + viewDirections(u, v, n); + const Vec3d ori = sliceOrigin(); + halfU = halfV = 0.0; + for (int iz = 0; iz < 2; ++iz) + for (int iy = 0; iy < 2; ++iy) + for (int ix = 0; ix < 2; ++ix) { + const double px = (ix ? bmax[0] : bmin[0]) - ori.x; + const double py = (iy ? bmax[1] : bmin[1]) - ori.y; + const double pz = (iz ? bmax[2] : bmin[2]) - ori.z; + halfU = std::max(halfU, std::abs(px * u[0] + py * u[1] + pz * u[2])); + halfV = std::max(halfV, std::abs(px * v[0] + py * v[1] + pz * v[2])); + } + halfU = std::max(halfU, 1.0); + halfV = std::max(halfV, 1.0); +} + +vtkSmartPointer SliceViewWidget::samplePhysicalSlice( + const DoseImage::Pointer& dose) const +{ + if (!dose) + return nullptr; + + double u[3], v[3], n[3]; + viewDirections(u, v, n); + + const Vec3d ori = sliceOrigin(); + double su = m_su, sv = m_sv; + // 始终按体数据全范围采样;缩放只改相机,绝不缩小采样半宽(否则滚轮切层会坏) + double halfU = 0.0, halfV = 0.0; + sliceSizeUV(halfU, halfV); + + int nu = static_cast(std::ceil(2.0 * halfU / su)) + 1; + int nv = static_cast(std::ceil(2.0 * halfV / sv)) + 1; + nu = std::max(nu, 8); + nv = std::max(nv, 8); + // 限制分辨率,避免过大 + const int kMax = 1024; + if (nu > kMax) { + su *= static_cast(nu) / kMax; + nu = kMax; + } + if (nv > kMax) { + sv *= static_cast(nv) / kMax; + nv = kMax; + } + + auto img = vtkSmartPointer::New(); + img->SetDimensions(nu, nv, 1); + img->SetSpacing(su, sv, 1.0); + // 2D 局部坐标:中心在 (0,0),便于相机固定看 XY + img->SetOrigin(-0.5 * (nu - 1) * su, -0.5 * (nv - 1) * sv, 0.0); + img->AllocateScalars(VTK_FLOAT, 1); + auto* ptr = static_cast(img->GetScalarPointer()); + + const double x0 = -0.5 * (nu - 1) * su; + const double y0 = -0.5 * (nv - 1) * sv; + + for (int j = 0; j < nv; ++j) { + const double tv = y0 + j * sv; + for (int i = 0; i < nu; ++i) { + const double tu = x0 + i * su; + const double wx = ori.x + tu * u[0] + tv * v[0]; + const double wy = ori.y + tu * u[1] + tv * v[1]; + const double wz = ori.z + tu * u[2] + tv * v[2]; + ptr[j * nu + i] = dose->sampleWorld(wx, wy, wz); + } + } + return img; +} + +void SliceViewWidget::setCursorWorld(const Vec3d& world) +{ + if (!m_bg) { + m_cursor = world; + m_hasCursor = true; + return; + } + + const Vec3d oldOri = sliceOrigin(); + m_cursor = world; + m_hasCursor = true; + const Vec3d newOri = sliceOrigin(); + const double d2 = (oldOri.x - newOri.x) * (oldOri.x - newOri.x) + + (oldOri.y - newOri.y) * (oldOri.y - newOri.y) + + (oldOri.z - newOri.z) * (oldOri.z - newOri.z); + + if (d2 > 1e-8) + updateSlice(true); + updateCrosshair(); + m_vtkWidget->GetRenderWindow()->Render(); +} + +void SliceViewWidget::resetViewToVolumeCenter() +{ + if (!m_bg) + return; + double bmin[3], bmax[3]; + m_bg->worldBounds(bmin, bmax); + m_cursor = {(bmin[0] + bmax[0]) * 0.5, (bmin[1] + bmax[1]) * 0.5, + (bmin[2] + bmax[2]) * 0.5}; + m_viewCenter = m_cursor; + m_hasCursor = true; + physicalStepUV(m_su, m_sv); + sliceSizeUV(m_halfU, m_halfV); + updateSlice(false); + setupCamera(true); + updateCrosshair(); + m_vtkWidget->GetRenderWindow()->Render(); + emit viewExtentChanged(); +} + +void SliceViewWidget::setWindowLevel(double window, double level) +{ + m_window = window; + m_level = level; + rebuildColorMaps(); + refreshMappedImages(); +} + +void SliceViewWidget::resetWindowLevelToData() +{ + if (!m_bg) + return; + m_window = static_cast(m_bg->maxValue() - m_bg->minValue()); + m_level = static_cast((m_bg->maxValue() + m_bg->minValue()) * 0.5); + if (m_window < 1e-6) + m_window = 1.0; + rebuildColorMaps(); + emit windowLevelChanged(m_window, m_level); +} + +bool SliceViewWidget::visibleRangeForWorldAxis(int worldAxis, double& rmin, double& rmax) const +{ + if (!m_renderer || !m_vtkWidget || !m_cameraReady) + return false; + int uAxis = 0, vAxis = 1; + inPlaneWorldAxes(uAxis, vAxis); + if (worldAxis != uAxis && worldAxis != vAxis) + return false; + + auto* cam = m_renderer->GetActiveCamera(); + if (!cam) + return false; + const double halfH = cam->GetParallelScale(); + const double aspect = static_cast(std::max(1, m_vtkWidget->width())) + / static_cast(std::max(1, m_vtkWidget->height())); + const double halfW = halfH * aspect; + double fp[3]; + cam->GetFocalPoint(fp); + double u[3], v[3], n[3]; + viewDirections(u, v, n); + const Vec3d ori = sliceOrigin(); + // 可视中心 = 切片原点 + 相机焦平面内偏移(与 pan 一致) + const double cx = ori.x + fp[0] * u[0] + fp[1] * v[0]; + const double cy = ori.y + fp[0] * u[1] + fp[1] * v[1]; + const double cz = ori.z + fp[0] * u[2] + fp[1] * v[2]; + const double center[3] = {cx, cy, cz}; + const double half = (worldAxis == uAxis) ? halfW : halfH; + rmin = center[worldAxis] - half; + rmax = center[worldAxis] + half; + return true; +} + +bool SliceViewWidget::displayToWorld(int displayX, int displayY, Vec3d& out) const +{ + if (!m_renderer || !m_cameraReady) + return false; + auto* cam = m_renderer->GetActiveCamera(); + if (!cam) + return false; + + const int w = std::max(1, m_vtkWidget->width()); + const int h = std::max(1, m_vtkWidget->height()); + const double halfH = cam->GetParallelScale(); + const double halfW = halfH * static_cast(w) / static_cast(h); + double fp[3]; + cam->GetFocalPoint(fp); + + // 切片画在局部 XY:fp 约在 (0,0,0),U→X,V→Y + const double du = (displayX - w * 0.5) / (w * 0.5) * halfW + fp[0]; + const double dv = (h * 0.5 - displayY) / (h * 0.5) * halfH + fp[1]; + + double u[3], v[3], n[3]; + viewDirections(u, v, n); + + const Vec3d ori = sliceOrigin(); + out.x = ori.x + du * u[0] + dv * v[0]; + out.y = ori.y + du * u[1] + dv * v[1]; + out.z = ori.z + du * u[2] + dv * v[2]; + return true; +} + +void SliceViewWidget::rebuildPipeline() +{ + m_renderer->RemoveActor(m_actorBg); + m_renderer->RemoveActor(m_actorFg); + m_bgSlice = nullptr; + m_fgSlice = nullptr; + m_cameraReady = false; + + if (!m_bg) { + if (m_vtkWidget && m_vtkWidget->GetRenderWindow()) + m_vtkWidget->GetRenderWindow()->Render(); + return; + } + + double bmin[3], bmax[3]; + m_bg->worldBounds(bmin, bmax); + m_cursor = {(bmin[0] + bmax[0]) * 0.5, (bmin[1] + bmax[1]) * 0.5, + (bmin[2] + bmax[2]) * 0.5}; + m_viewCenter = m_cursor; + m_hasCursor = true; + physicalStepUV(m_su, m_sv); + sliceSizeUV(m_halfU, m_halfV); + + m_renderer->AddActor(m_actorBg); + if (m_fg) + m_renderer->AddActor(m_actorFg); + + resetWindowLevelToData(); + updateSlice(false); + setupCamera(true); + updateCrosshair(); + m_vtkWidget->GetRenderWindow()->Render(); + emit viewExtentChanged(); +} + +void SliceViewWidget::updateSlice(bool /*moveCamera*/) +{ + if (!m_bg) + return; + + // 保留相机(缩放/平移),切层绝不能 reset 相机 + double savedScale = 1.0; + double savedFp[3] = {0, 0, 0}; + double savedPos[3] = {0, 0, 1}; + double savedUp[3] = {0, 1, 0}; + auto* cam = m_renderer ? m_renderer->GetActiveCamera() : nullptr; + if (cam && m_cameraReady) { + savedScale = cam->GetParallelScale(); + cam->GetFocalPoint(savedFp); + cam->GetPosition(savedPos); + cam->GetViewUp(savedUp); + } + + physicalStepUV(m_su, m_sv); + // 采样范围始终用体全图,与相机缩放解耦 + sliceSizeUV(m_halfU, m_halfV); + + m_bgSlice = samplePhysicalSlice(m_bg); + if (!m_bgSlice) + return; + + rebuildColorMaps(); + m_mapBg->SetInputData(m_bgSlice); + m_mapBg->SetLookupTable(m_lutBg); + m_mapBg->SetOutputFormatToRGB(); + m_mapBg->Update(); + if (auto* mapper = m_actorBg->GetMapper()) { + mapper->SetInputConnection(m_mapBg->GetOutputPort()); + } + m_actorBg->InterpolateOn(); + m_actorBg->Update(); + + if (m_fg) { + m_fgSlice = samplePhysicalSlice(m_fg); + if (m_fgSlice) { + m_mapFg->SetInputData(m_fgSlice); + m_mapFg->SetLookupTable(m_lutFg); + m_mapFg->SetOutputFormatToRGB(); + m_mapFg->Update(); + if (auto* mapper = m_actorFg->GetMapper()) + mapper->SetInputConnection(m_mapFg->GetOutputPort()); + m_actorFg->SetOpacity(m_frontOpacity); + m_actorFg->InterpolateOn(); + m_actorFg->Update(); + } + } + + if (cam && m_cameraReady) { + cam->SetParallelProjection(true); + cam->SetParallelScale(savedScale); + cam->SetFocalPoint(savedFp); + cam->SetPosition(savedPos); + cam->SetViewUp(savedUp); + m_renderer->ResetCameraClippingRange(); + } +} + +void SliceViewWidget::setupCamera(bool resetScale) +{ + if (!m_renderer) + return; + auto* cam = m_renderer->GetActiveCamera(); + if (!cam) + return; + + // 切片在局部 XY 平面,相机沿 +Z 看,ViewUp=+Y + cam->SetParallelProjection(true); + cam->SetFocalPoint(0.0, 0.0, 0.0); + cam->SetPosition(0.0, 0.0, 1.0); + cam->SetViewUp(0.0, 1.0, 0.0); + + if (resetScale) { + const double half = std::max(m_halfU, m_halfV); + cam->SetParallelScale(std::max(1.0, half * 0.55)); + } + + m_renderer->ResetCameraClippingRange(); + m_cameraReady = true; +} + +void SliceViewWidget::updateCrosshair() +{ + if (!m_renderer || !m_vtkWidget || !m_cameraReady) + return; + auto* cam = m_renderer->GetActiveCamera(); + if (!cam) + return; + + double u[3], v[3], n[3]; + viewDirections(u, v, n); + + const Vec3d ori = sliceOrigin(); + const double dx = m_cursor.x - ori.x; + const double dy = m_cursor.y - ori.y; + const double dz = m_cursor.z - ori.z; + const double tu = dx * u[0] + dy * u[1] + dz * u[2]; + const double tv = dx * v[0] + dy * v[1] + dz * v[2]; + + m_renderer->SetWorldPoint(tu, tv, 0.0, 1.0); + m_renderer->WorldToDisplay(); + double disp[3]; + m_renderer->GetDisplayPoint(disp); + if (!std::isfinite(disp[0]) || !std::isfinite(disp[1])) + return; + + const int w = std::max(1, m_vtkWidget->width()); + const int h = std::max(1, m_vtkWidget->height()); + const double cx = disp[0]; + const double cy = disp[1]; + + auto setLine = [](vtkActor2D* actor, double x0, double y0, double x1, double y1, + const double rgb[3]) { + auto* mapper = vtkPolyDataMapper2D::SafeDownCast(actor->GetMapper()); + auto* poly = vtkPolyData::SafeDownCast(mapper->GetInput()); + auto* pts = poly->GetPoints(); + pts->SetPoint(0, x0, y0, 0); + pts->SetPoint(1, x1, y1, 0); + pts->Modified(); + poly->Modified(); + actor->GetProperty()->SetColor(rgb[0], rgb[1], rgb[2]); + }; + + double rgbU[3], rgbV[3]; + axisColorRgb(colorForLineDirection(u), rgbU); + axisColorRgb(colorForLineDirection(v), rgbV); + setLine(m_crossH, 0, cy, w, cy, rgbU); + setLine(m_crossV, cx, 0, cx, h, rgbV); +} + +void SliceViewWidget::updatePickOverlay(const QPoint& pos) +{ + Vec3d world; + if (!displayToWorld(pos.x(), pos.y(), world) || !m_bg) { + m_pickLabel->hide(); + return; + } + const float bVal = m_bg->sampleWorld(world.x, world.y, world.z); + QString text; + if (m_fg) { + const float fVal = m_fg->sampleWorld(world.x, world.y, world.z); + text = QStringLiteral("F: %1\nB: %2").arg(fVal, 0, 'f', 3).arg(bVal, 0, 'f', 3); + } else { + text = QStringLiteral("F: -\nB: %1").arg(bVal, 0, 'f', 3); + } + m_pickLabel->setText(text); + m_pickLabel->adjustSize(); + m_pickLabel->move(std::max(0, width() - m_pickLabel->width() - 8), + std::max(0, height() - m_pickLabel->height() - 8)); + m_pickLabel->show(); + m_pickLabel->raise(); +} + +void SliceViewWidget::resizeEvent(QResizeEvent* event) +{ + QWidget::resizeEvent(event); + updateOrientationLabels(); + updateCrosshair(); + emit viewExtentChanged(); +} + +bool SliceViewWidget::eventFilter(QObject* obj, QEvent* event) +{ + if (obj != m_vtkWidget) + return QWidget::eventFilter(obj, event); + + switch (event->type()) { + case QEvent::MouseButtonPress: { + auto* e = static_cast(event); + m_lastPos = e->pos(); + if (e->button() == Qt::LeftButton) { + m_leftDown = true; + return true; + } + if (e->button() == Qt::RightButton) { + m_rightDown = true; + return true; + } + if (e->button() == Qt::MiddleButton) { + m_middleDown = true; + return true; + } + break; + } + case QEvent::MouseButtonRelease: { + auto* e = static_cast(event); + if (e->button() == Qt::LeftButton) + m_leftDown = false; + else if (e->button() == Qt::RightButton) + m_rightDown = false; + else if (e->button() == Qt::MiddleButton) + m_middleDown = false; + return true; + } + case QEvent::MouseMove: { + auto* e = static_cast(event); + const int dx = e->x() - m_lastPos.x(); + const int dy = e->y() - m_lastPos.y(); + m_lastPos = e->pos(); + updatePickOverlay(e->pos()); + + // Ctrl + 移动:在鼠标位置重切另外两个面(更新交点) + if ((e->modifiers() & Qt::ControlModifier) && !m_leftDown && !m_rightDown && + !m_middleDown) { + Vec3d world; + if (displayToWorld(e->x(), e->y(), world)) + emit cursorChanged(world); + return true; + } + + if (m_leftDown) { + applyMouseWindowLevel(dx, dy); + return true; + } + if (m_rightDown) { + applyPan(dx, dy); + return true; + } + if (m_middleDown) { + applyZoomByDrag(dy); + return true; + } + return true; + } + case QEvent::Leave: + m_pickLabel->hide(); + break; + case QEvent::Wheel: { + auto* e = static_cast(event); + const int steps = (e->angleDelta().y() > 0) ? 1 : (e->angleDelta().y() < 0 ? -1 : 0); + if (steps != 0) + applySliceStep(steps); + return true; + } + default: + break; + } + return QWidget::eventFilter(obj, event); +} + +void SliceViewWidget::applyMouseWindowLevel(int dx, int dy) +{ + m_window = std::max(1e-3, m_window + dx * (m_window * 0.005 + 0.01)); + m_level = m_level - dy * (m_window * 0.005 + 0.01); + setWindowLevel(m_window, m_level); + emit windowLevelChanged(m_window, m_level); +} + +void SliceViewWidget::applyPan(int dx, int dy) +{ + auto* cam = m_renderer->GetActiveCamera(); + if (!cam) + return; + cam->SetParallelProjection(true); + double fp[3], pos[3]; + cam->GetFocalPoint(fp); + cam->GetPosition(pos); + const double scale = cam->GetParallelScale() * 0.002; + const double dU = -dx * scale; + const double dV = dy * scale; + fp[0] += dU; + fp[1] += dV; + pos[0] += dU; + pos[1] += dV; + cam->SetFocalPoint(fp); + cam->SetPosition(pos); + + double u[3], v[3], n[3]; + viewDirections(u, v, n); + const double wdx = dU * u[0] + dV * v[0]; + const double wdy = dU * u[1] + dV * v[1]; + const double wdz = dU * u[2] + dV * v[2]; + + updateCrosshair(); + m_vtkWidget->GetRenderWindow()->Render(); + emit panWorldDelta(wdx, wdy, wdz); + emit viewExtentChanged(); +} + +void SliceViewWidget::applyZoomByDrag(int dy) +{ + auto* cam = m_renderer->GetActiveCamera(); + if (!cam) + return; + double scale = cam->GetParallelScale(); + scale *= std::max(0.5, std::min(1.5, 1.0 + dy * 0.01)); + scale = std::max(0.1, scale); + cam->SetParallelScale(scale); + updateCrosshair(); + m_vtkWidget->GetRenderWindow()->Render(); + emit zoomScaleChanged(scale); + emit viewExtentChanged(); +} + +double SliceViewWidget::parallelScale() const +{ + if (!m_renderer || !m_cameraReady) + return 1.0; + auto* cam = m_renderer->GetActiveCamera(); + return cam ? cam->GetParallelScale() : 1.0; +} + +void SliceViewWidget::setParallelScaleShared(double scale) +{ + if (!m_renderer || !m_cameraReady) + return; + auto* cam = m_renderer->GetActiveCamera(); + if (!cam) + return; + cam->SetParallelScale(std::max(0.1, scale)); + m_renderer->ResetCameraClippingRange(); + updateCrosshair(); + if (m_vtkWidget && m_vtkWidget->GetRenderWindow()) + m_vtkWidget->GetRenderWindow()->Render(); +} + +void SliceViewWidget::applyWorldPanShared(double dx, double dy, double dz) +{ + if (!m_renderer || !m_cameraReady) + return; + auto* cam = m_renderer->GetActiveCamera(); + if (!cam) + return; + + double u[3], v[3], n[3]; + viewDirections(u, v, n); + const double dU = dx * u[0] + dy * u[1] + dz * u[2]; + const double dV = dx * v[0] + dy * v[1] + dz * v[2]; + if (std::abs(dU) < 1e-12 && std::abs(dV) < 1e-12) + return; + + cam->SetParallelProjection(true); + double fp[3], pos[3]; + cam->GetFocalPoint(fp); + cam->GetPosition(pos); + fp[0] += dU; + fp[1] += dV; + pos[0] += dU; + pos[1] += dV; + cam->SetFocalPoint(fp); + cam->SetPosition(pos); + + updateCrosshair(); + if (m_vtkWidget && m_vtkWidget->GetRenderWindow()) + m_vtkWidget->GetRenderWindow()->Render(); +} + +void SliceViewWidget::applySliceStep(int steps) +{ + if (!m_bg || steps == 0) + return; + + const auto sp = m_bg->spacing(); + double u[3], v[3], n[3]; + viewDirections(u, v, n); + + double stepLen = std::abs(sp[2]); + if (m_axis == ViewAxis::Sagittal) + stepLen = std::abs(sp[0]); + else if (m_axis == ViewAxis::Coronal) + stepLen = std::abs(sp[1]); + + Vec3d c = m_cursor; + c.x += n[0] * steps * stepLen; + c.y += n[1] * steps * stepLen; + c.z += n[2] * steps * stepLen; + + double bmin[3], bmax[3]; + m_bg->worldBounds(bmin, bmax); + c.x = std::max(bmin[0], std::min(bmax[0], c.x)); + c.y = std::max(bmin[1], std::min(bmax[1], c.y)); + c.z = std::max(bmin[2], std::min(bmax[2], c.z)); + + emit cursorChanged(c); +} diff --git a/src/SliceViewWidget.h b/src/SliceViewWidget.h new file mode 100644 index 0000000..54a5620 --- /dev/null +++ b/src/SliceViewWidget.h @@ -0,0 +1,119 @@ +#pragma once + +#include "types.h" +#include "DoseImage.h" + +#include +#include + +class QVTKOpenGLWidget; +class QLabel; +class vtkImageData; +class vtkImageActor; +class vtkRenderer; +class vtkImageMapToColors; +class vtkLookupTable; +class vtkActor2D; + +class SliceViewWidget : public QWidget { + Q_OBJECT +public: + explicit SliceViewWidget(ViewAxis axis, QWidget* parent = nullptr); + + ViewAxis axis() const { return m_axis; } + + void setBackground(DoseImage::Pointer bg); + void setFront(DoseImage::Pointer fg); + void setOpacity(double frontOpacity); + + void setCursorWorld(const Vec3d& world); + Vec3d cursorWorld() const { return m_cursor; } + + void resetViewToVolumeCenter(); + + void setWindowLevel(double window, double level); + void resetWindowLevelToData(); + void setColorMap(DoseColorMap map); + DoseColorMap colorMap() const { return m_colorMap; } + + bool visibleRangeForWorldAxis(int worldAxis, double& rmin, double& rmax) const; + + double parallelScale() const; + void setParallelScaleShared(double scale); + void applyWorldPanShared(double dx, double dy, double dz); + +signals: + void cursorChanged(Vec3d world); + void windowLevelChanged(double window, double level); + void viewExtentChanged(); + void zoomScaleChanged(double parallelScale); + void panWorldDelta(double dx, double dy, double dz); + +protected: + bool eventFilter(QObject* obj, QEvent* event) override; + void resizeEvent(QResizeEvent* event) override; + void paintEvent(QPaintEvent* event) override; + +private: + void rebuildPipeline(); + void updateSlice(bool moveCamera); + void updateCrosshair(); + void updateOrientationLabels(); + void updatePickOverlay(const QPoint& pos); + void applyBorderStyle(); + void rebuildColorMaps(); + void refreshMappedImages(); + void viewDirections(double u[3], double v[3], double n[3]) const; + void inPlaneWorldAxes(int& uAxis, int& vAxis) const; + bool displayToWorld(int displayX, int displayY, Vec3d& out) const; + Vec3d sliceOrigin() const; + void physicalStepUV(double& su, double& sv) const; + void sliceSizeUV(double& halfU, double& halfV) const; + vtkSmartPointer samplePhysicalSlice(const DoseImage::Pointer& dose) const; + void setupCamera(bool resetScale); + void applyMouseWindowLevel(int dx, int dy); + void applyPan(int dx, int dy); + void applyZoomByDrag(int dy); + void applySliceStep(int steps); + static ViewAxis colorForLineDirection(const double dir[3]); + + ViewAxis m_axis; + DoseImage::Pointer m_bg; + DoseImage::Pointer m_fg; + double m_frontOpacity = 0.5; + Vec3d m_cursor{}; + Vec3d m_viewCenter{}; + double m_window = 1.0; + double m_level = 0.5; + DoseColorMap m_colorMap = DoseColorMap::Gray; + bool m_hasCursor = false; + bool m_cameraReady = false; + double m_halfU = 50.0; + double m_halfV = 50.0; + double m_su = 1.0; + double m_sv = 1.0; + + QVTKOpenGLWidget* m_vtkWidget = nullptr; + QLabel* m_labelTop = nullptr; + QLabel* m_labelBottom = nullptr; + QLabel* m_labelLeft = nullptr; + QLabel* m_labelRight = nullptr; + QLabel* m_pickLabel = nullptr; + + vtkSmartPointer m_renderer; + vtkSmartPointer m_bgSlice; + vtkSmartPointer m_fgSlice; + vtkSmartPointer m_lutBg; + vtkSmartPointer m_lutFg; + vtkSmartPointer m_mapBg; + vtkSmartPointer m_mapFg; + vtkSmartPointer m_actorBg; + vtkSmartPointer m_actorFg; + vtkSmartPointer m_crossH; + vtkSmartPointer m_crossV; + + bool m_leftDown = false; + bool m_rightDown = false; + bool m_middleDown = false; + QPoint m_lastPos; +}; diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..157f69c --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,30 @@ +#include "MainWindow.h" + +#include +#include +#include +#include + +#include + +int main(int argc, char* argv[]) +{ + QSurfaceFormat::setDefaultFormat(QVTKOpenGLWidget::defaultFormat()); + + QApplication app(argc, argv); + QApplication::setApplicationName(QStringLiteral("DoseCompare")); + QApplication::setOrganizationName(QStringLiteral("Manteia")); + QApplication::setWindowIcon(QIcon(QStringLiteral(":/dosecompare.png"))); + + MainWindow w; + w.setWindowIcon(QIcon(QStringLiteral(":/dosecompare.png"))); + w.show(); + + if (argc > 1) { + QString err; + if (!w.loadDoseFile(QString::fromLocal8Bit(argv[1]), &err)) + QMessageBox::warning(&w, QObject::tr("导入失败"), err); + } + + return app.exec(); +} diff --git a/src/smoke_slice_test.cpp b/src/smoke_slice_test.cpp new file mode 100644 index 0000000..7e54d91 --- /dev/null +++ b/src/smoke_slice_test.cpp @@ -0,0 +1,83 @@ +// Quick smoke test: load MHD and sample three physical slices (no Qt GUI) +#include "DoseImage.h" + +#include +#include +#include +#include + +static void sampleSlice(const DoseImage::Pointer& dose, int axisCode) +{ + double bmin[3], bmax[3]; + dose->worldBounds(bmin, bmax); + const double cx = 0.5 * (bmin[0] + bmax[0]); + const double cy = 0.5 * (bmin[1] + bmax[1]); + const double cz = 0.5 * (bmin[2] + bmax[2]); + + double u[3] = {0, 0, 0}, v[3] = {0, 0, 0}; + if (axisCode == 0) { // axial + u[0] = -1; v[1] = -1; + } else if (axisCode == 1) { // sag + u[1] = -1; v[2] = 1; + } else { + u[0] = 1; v[2] = 1; + } + + const auto sp = dose->spacing(); + const double su = std::max(1e-3, std::abs(u[0]) * std::abs(sp[0]) + std::abs(u[1]) * std::abs(sp[1]) + std::abs(u[2]) * std::abs(sp[2])); + const double sv = std::max(1e-3, std::abs(v[0]) * std::abs(sp[0]) + std::abs(v[1]) * std::abs(sp[1]) + std::abs(v[2]) * std::abs(sp[2])); + + double halfU = 0, halfV = 0; + for (int iz = 0; iz < 2; ++iz) + for (int iy = 0; iy < 2; ++iy) + for (int ix = 0; ix < 2; ++ix) { + const double px = (ix ? bmax[0] : bmin[0]) - cx; + const double py = (iy ? bmax[1] : bmin[1]) - cy; + const double pz = (iz ? bmax[2] : bmin[2]) - cz; + halfU = std::max(halfU, std::abs(px * u[0] + py * u[1] + pz * u[2])); + halfV = std::max(halfV, std::abs(px * v[0] + py * v[1] + pz * v[2])); + } + + int nu = static_cast(std::ceil(2.0 * halfU / su)) + 1; + int nv = static_cast(std::ceil(2.0 * halfV / sv)) + 1; + std::printf("axis=%d size=%dx%d su=%.3f sv=%.3f half=%.1f,%.1f\n", axisCode, nu, nv, su, sv, halfU, halfV); + + double sum = 0, mx = -1e30; + int nonzero = 0; + const double x0 = -0.5 * (nu - 1) * su; + const double y0 = -0.5 * (nv - 1) * sv; + for (int j = 0; j < nv; ++j) { + const double tv = y0 + j * sv; + for (int i = 0; i < nu; ++i) { + const double tu = x0 + i * su; + const float val = dose->sampleWorld(cx + tu * u[0] + tv * v[0], + cy + tu * u[1] + tv * v[1], + cz + tu * u[2] + tv * v[2]); + sum += val; + mx = std::max(mx, (double)val); + if (val > dose->minValue() + 1e-6f) + ++nonzero; + } + } + std::printf(" mean=%.4f max=%.4f nonzero=%d/%d\n", sum / (nu * nv), mx, nonzero, nu * nv); +} + +int main(int argc, char** argv) +{ + const char* path = (argc > 1) ? argv[1] + : "D:/testNewPhy/halcyon/examples/che1/0000137408/internal/doseOldAve.mhd"; + QString err; + auto dose = DoseImage::loadFromFile(QString::fromLocal8Bit(path), &err); + if (!dose) { + std::printf("LOAD FAIL: %s\n", err.toLocal8Bit().constData()); + return 1; + } + std::printf("loaded %s min=%.4f max=%.4f dim=%zu %zu %zu\n", + dose->name().toLocal8Bit().constData(), dose->minValue(), dose->maxValue(), + dose->region().GetSize()[0], dose->region().GetSize()[1], dose->region().GetSize()[2]); + sampleSlice(dose, 0); + sampleSlice(dose, 1); + sampleSlice(dose, 2); + std::printf("OK\n"); + return 0; +} diff --git a/src/types.h b/src/types.h new file mode 100644 index 0000000..55ad5ec --- /dev/null +++ b/src/types.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include + +enum class ViewAxis { Axial = 0, Sagittal = 1, Coronal = 2 }; + +enum class DoseColorMap { Gray = 0, Rainbow = 1, Jet = 2, Hot = 3 }; + +struct Vec3d { + double x = 0, y = 0, z = 0; +}; + +struct ProfileSeries { + QString name; + QColor color; + QVector positionsMm; + QVector valuesGy; +}; + +// 横断面红 / 矢状面亮 cyan / 冠状面橙黄 +inline QColor axisColor(ViewAxis axis) +{ + switch (axis) { + case ViewAxis::Axial: return QColor(220, 40, 40); // 红 + case ViewAxis::Sagittal: return QColor(0, 230, 255); // 亮 cyan + case ViewAxis::Coronal: return QColor(240, 160, 30); // 橙黄 + } + return QColor(200, 200, 200); +} + +inline void axisColorRgb(ViewAxis axis, double rgb[3]) +{ + const QColor c = axisColor(axis); + rgb[0] = c.redF(); + rgb[1] = c.greenF(); + rgb[2] = c.blueF(); +} + +inline QColor profileColor(int index) +{ + static const QColor kColors[] = { + QColor(220, 50, 50), + QColor(50, 120, 220), + QColor(40, 160, 80), + QColor(200, 140, 20), + QColor(160, 60, 180), + QColor(20, 160, 180), + QColor(180, 80, 40), + QColor(80, 80, 200), + }; + return kColors[index % 8]; +}