Init: cursor生成V1.0版本
commit
f3543da90f
|
|
@ -0,0 +1,8 @@
|
|||
################################################################################
|
||||
# 此 .gitignore 文件已由 Microsoft(R) Visual Studio 自动创建。
|
||||
################################################################################
|
||||
|
||||
/build
|
||||
/data
|
||||
/install
|
||||
.vscode
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1 @@
|
|||
The system is: Windows - 10.0.19045 - AMD64
|
||||
|
|
@ -0,0 +1 @@
|
|||
# This file is generated by cmake for dependency checking of the CMakeCache.txt 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()
|
||||
|
|
@ -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 按当前显示窗口可见范围采样。
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
IDI_ICON1 ICON "dosecompare.ico"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 923 KiB |
|
|
@ -0,0 +1,7 @@
|
|||
<!DOCTYPE RCC>
|
||||
<RCC version="1.0">
|
||||
<qresource prefix="/">
|
||||
<file>dosecompare.png</file>
|
||||
<file>dosecompare.ico</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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 之前根据“删除后”的逻辑准备 pending;remove 时 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
Loading…
Reference in New Issue