Init: cursor生成V1.0版本

master
hssheng 2026-07-17 17:38:32 +08:00
commit f3543da90f
27 changed files with 2998 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
################################################################################
# 此 .gitignore 文件已由 Microsoft(R) Visual Studio 自动创建。
################################################################################
/build
/data
/install
.vscode

78
CMakeCache.txt Normal file
View File

@ -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

View File

@ -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)

View File

@ -0,0 +1 @@
The system is: Windows - 10.0.19045 - AMD64

View File

@ -0,0 +1 @@
# This file is generated by cmake for dependency checking of the CMakeCache.txt file

74
CMakeLists.txt Normal file
View File

@ -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()

42
README.md Normal file
View File

@ -0,0 +1,42 @@
# DoseCompare
Qt5 + ITK/VTK 放疗剂量三切面对比与 Profile 查看工具。
## 功能
- 上:轴位 / 矢状 / 冠状三切面Front/Background 融合对比
- 下:交线处三条 profile横轴 mm纵轴 Gy
- 左侧:剂量图列表(勾选进 profile右键删除
- 仅加载 RT Dose / MHDDICOM 按 `DoseGridScaling` 转为物理剂量
## 依赖
- Qt 5.x
- ITK含 GDCM、MetaIO
- VTK含 GUISupportQt
- CMake ≥ 3.16MSVCWindows
## 构建
```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 按当前显示窗口可见范围采样。

66
crashlog.txt Normal file
View File

@ -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

1
resources/app.rc Normal file
View File

@ -0,0 +1 @@
IDI_ICON1 ICON "dosecompare.ico"

BIN
resources/dosecompare.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
resources/dosecompare.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 KiB

7
resources/resources.qrc Normal file
View File

@ -0,0 +1,7 @@
<!DOCTYPE RCC>
<RCC version="1.0">
<qresource prefix="/">
<file>dosecompare.png</file>
<file>dosecompare.ico</file>
</qresource>
</RCC>

167
src/DoseImage.cpp Normal file
View File

@ -0,0 +1,167 @@
#include "DoseImage.h"
#include <itkImageFileReader.h>
#include <itkGDCMImageIO.h>
#include <itkMetaImageIO.h>
#include <itkMinimumMaximumImageCalculator.h>
#include <itkImageRegionIterator.h>
#include <QFileInfo>
#include <algorithm>
#include <cmath>
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<DoseImageType>;
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<DoseImageType> 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<DoseImageType> 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<DoseImageType>;
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<float>(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<long>(size[0] - 1) : 0);
corners[n][1] = index[1] + (dy ? static_cast<long>(size[1] - 1) : 0);
corners[n][2] = index[2] + (dz ? static_cast<long>(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]);
}
}
}

52
src/DoseImage.h Normal file
View File

@ -0,0 +1,52 @@
#pragma once
#include "types.h"
#include <itkImage.h>
#include <itkLinearInterpolateImageFunction.h>
#include <memory>
using DoseImageType = itk::Image<float, 3>;
using DoseInterpolator = itk::LinearInterpolateImageFunction<DoseImageType, double>;
class DoseImage {
public:
using Pointer = std::shared_ptr<DoseImage>;
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;
};

62
src/ImageListPanel.cpp Normal file
View File

@ -0,0 +1,62 @@
#include "ImageListPanel.h"
#include "ImageManager.h"
#include <QVBoxLayout>
#include <QLabel>
#include <QMenu>
#include <QListWidgetItem>
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);
}
}

27
src/ImageListPanel.h Normal file
View File

@ -0,0 +1,27 @@
#pragma once
#include <QWidget>
#include <QListWidget>
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;
};

71
src/ImageManager.cpp Normal file
View File

@ -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();
}
}
}

38
src/ImageManager.h Normal file
View File

@ -0,0 +1,38 @@
#pragma once
#include "DoseImage.h"
#include <QObject>
#include <QVector>
#include <QUuid>
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<ManagedImage>& 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<ManagedImage> m_images;
int m_colorIndex = 0;
};

681
src/MainWindow.cpp Normal file
View File

@ -0,0 +1,681 @@
#include "MainWindow.h"
#include "ImageManager.h"
#include "ImageListPanel.h"
#include "SliceViewWidget.h"
#include "ProfilePlotWidget.h"
#include <QFileDialog>
#include <QMessageBox>
#include <QComboBox>
#include <QSlider>
#include <QLabel>
#include <QToolBar>
#include <QStatusBar>
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <QGridLayout>
#include <QGroupBox>
#include <QAction>
#include <QMenuBar>
#include <QMenu>
#include <QSettings>
#include <QFileInfo>
#include <QDir>
#include <QCoreApplication>
#include <QMimeData>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QUrl>
#include <algorithm>
#include <initializer_list>
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<int>(DoseColorMap::Gray));
m_colorMapCombo->addItem(tr("Rainbow"), static_cast<int>(DoseColorMap::Rainbow));
m_colorMapCombo->addItem(tr("Jet"), static_cast<int>(DoseColorMap::Jet));
m_colorMapCombo->addItem(tr("Hot"), static_cast<int>(DoseColorMap::Hot));
m_colorMapCombo->setMinimumWidth(110);
m_colorMapCombo->blockSignals(false);
tb->addWidget(m_colorMapCombo);
connect(m_bgCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &MainWindow::onBackgroundChanged);
connect(m_frontCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &MainWindow::onFrontChanged);
connect(m_opacitySlider, &QSlider::valueChanged, this, &MainWindow::onOpacityChanged);
connect(m_colorMapCombo, QOverload<int>::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 之前根据“删除后”的逻辑准备 pendingremove 时 list 仍含 deleted
QVector<ManagedImage> 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<DoseColorMap>(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<SliceViewWidget*>(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<int>(size[0]) - 1);
m_sliderCoronal->setRange(0, static_cast<int>(size[1]) - 1);
m_sliderAxial->setRange(0, static_cast<int>(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<int>(n);
return static_cast<int>(v);
};
m_sliderSagittal->setValue(clampIdx(idx[0] - start[0], static_cast<long>(size[0]) - 1));
m_sliderCoronal->setValue(clampIdx(idx[1] - start[1], static_cast<long>(size[1]) - 1));
m_sliderAxial->setValue(clampIdx(idx[2] - start[2], static_cast<long>(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<int>(start[0]);
const int iy = m_sliderCoronal->value() + static_cast<int>(start[1]);
const int iz = m_sliderAxial->value() + static_cast<int>(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<ProfileSeries> 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<SliceViewWidget*> 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<double>& pos,
QVector<double>& 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<double>(bg->minValue());
const double yMax = static_cast<double>(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);
}

89
src/MainWindow.h Normal file
View File

@ -0,0 +1,89 @@
#pragma once
#include "types.h"
#include "DoseImage.h"
#include <QMainWindow>
#include <QString>
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;
};

154
src/ProfilePlotWidget.cpp Normal file
View File

@ -0,0 +1,154 @@
#include "ProfilePlotWidget.h"
#include <QPainter>
#include <QPainterPath>
#include <QPaintEvent>
#include <algorithm>
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<ProfileSeries>& 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<int>(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;
}
}

32
src/ProfilePlotWidget.h Normal file
View File

@ -0,0 +1,32 @@
#pragma once
#include "types.h"
#include <QWidget>
#include <QVector>
#include <QColor>
class ProfilePlotWidget : public QWidget {
Q_OBJECT
public:
explicit ProfilePlotWidget(ViewAxis axis, QWidget* parent = nullptr);
void setTitle(const QString& title);
void setSeries(const QVector<ProfileSeries>& 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<ProfileSeries> m_series;
double m_cursorMm = 0;
double m_yMin = 0;
double m_yMax = 1;
bool m_hasYRange = false;
QColor m_frameColor{200, 200, 200};
};

1046
src/SliceViewWidget.cpp Normal file

File diff suppressed because it is too large Load Diff

119
src/SliceViewWidget.h Normal file
View File

@ -0,0 +1,119 @@
#pragma once
#include "types.h"
#include "DoseImage.h"
#include <QWidget>
#include <vtkSmartPointer.h>
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<vtkImageData> 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<vtkRenderer> m_renderer;
vtkSmartPointer<vtkImageData> m_bgSlice;
vtkSmartPointer<vtkImageData> m_fgSlice;
vtkSmartPointer<vtkLookupTable> m_lutBg;
vtkSmartPointer<vtkLookupTable> m_lutFg;
vtkSmartPointer<vtkImageMapToColors> m_mapBg;
vtkSmartPointer<vtkImageMapToColors> m_mapFg;
vtkSmartPointer<vtkImageActor> m_actorBg;
vtkSmartPointer<vtkImageActor> m_actorFg;
vtkSmartPointer<vtkActor2D> m_crossH;
vtkSmartPointer<vtkActor2D> m_crossV;
bool m_leftDown = false;
bool m_rightDown = false;
bool m_middleDown = false;
QPoint m_lastPos;
};

30
src/main.cpp Normal file
View File

@ -0,0 +1,30 @@
#include "MainWindow.h"
#include <QApplication>
#include <QSurfaceFormat>
#include <QMessageBox>
#include <QIcon>
#include <QVTKOpenGLWidget.h>
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();
}

83
src/smoke_slice_test.cpp Normal file
View File

@ -0,0 +1,83 @@
// Quick smoke test: load MHD and sample three physical slices (no Qt GUI)
#include "DoseImage.h"
#include <cstdio>
#include <cmath>
#include <algorithm>
#include <vector>
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<int>(std::ceil(2.0 * halfU / su)) + 1;
int nv = static_cast<int>(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;
}

54
src/types.h Normal file
View File

@ -0,0 +1,54 @@
#pragma once
#include <QString>
#include <QColor>
#include <QVector>
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<double> positionsMm;
QVector<double> 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];
}