Feat:增加斜向角profile
parent
6cd6e39632
commit
79ad28fd58
|
|
@ -9,6 +9,7 @@
|
||||||
#include <QComboBox>
|
#include <QComboBox>
|
||||||
#include <QSlider>
|
#include <QSlider>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
|
#include <QDoubleSpinBox>
|
||||||
#include <QToolBar>
|
#include <QToolBar>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
@ -27,6 +28,7 @@
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <initializer_list>
|
#include <initializer_list>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
ProfileComparePage::ProfileComparePage(QWidget* parent)
|
ProfileComparePage::ProfileComparePage(QWidget* parent)
|
||||||
: QWidget(parent)
|
: QWidget(parent)
|
||||||
|
|
@ -83,6 +85,22 @@ void ProfileComparePage::buildUi()
|
||||||
m_colorMapCombo->setMinimumWidth(110);
|
m_colorMapCombo->setMinimumWidth(110);
|
||||||
m_colorMapCombo->blockSignals(false);
|
m_colorMapCombo->blockSignals(false);
|
||||||
tb->addWidget(m_colorMapCombo);
|
tb->addWidget(m_colorMapCombo);
|
||||||
|
|
||||||
|
tb->addWidget(new QLabel(tr(" Profile角 "), this));
|
||||||
|
m_angleSpin = new QDoubleSpinBox(this);
|
||||||
|
m_angleSpin->setRange(-180.0, 180.0);
|
||||||
|
m_angleSpin->setDecimals(1);
|
||||||
|
m_angleSpin->setSingleStep(5.0);
|
||||||
|
m_angleSpin->setValue(0.0);
|
||||||
|
m_angleSpin->setSuffix(QStringLiteral("°"));
|
||||||
|
m_angleSpin->setToolTip(tr(
|
||||||
|
"0°=轴对齐。\n"
|
||||||
|
"轴位/矢状:相对水平逆时针。\n"
|
||||||
|
"冠状:相对竖直 SI 逆时针。\n"
|
||||||
|
"绿箭头线为取样方向。"));
|
||||||
|
m_angleSpin->setFixedWidth(100);
|
||||||
|
tb->addWidget(m_angleSpin);
|
||||||
|
|
||||||
tb->addStretch(1);
|
tb->addStretch(1);
|
||||||
pageLay->addWidget(topBar);
|
pageLay->addWidget(topBar);
|
||||||
|
|
||||||
|
|
@ -93,6 +111,8 @@ void ProfileComparePage::buildUi()
|
||||||
connect(m_opacitySlider, &QSlider::valueChanged, this, &ProfileComparePage::onOpacityChanged);
|
connect(m_opacitySlider, &QSlider::valueChanged, this, &ProfileComparePage::onOpacityChanged);
|
||||||
connect(m_colorMapCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
|
connect(m_colorMapCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
|
||||||
this, &ProfileComparePage::onColorMapChanged);
|
this, &ProfileComparePage::onColorMapChanged);
|
||||||
|
connect(m_angleSpin, QOverload<double>::of(&QDoubleSpinBox::valueChanged),
|
||||||
|
this, &ProfileComparePage::onProfileAngleChanged);
|
||||||
|
|
||||||
auto* central = new QWidget(this);
|
auto* central = new QWidget(this);
|
||||||
auto* root = new QHBoxLayout(central);
|
auto* root = new QHBoxLayout(central);
|
||||||
|
|
@ -147,9 +167,10 @@ void ProfileComparePage::buildUi()
|
||||||
right->addLayout(profRow, 2);
|
right->addLayout(profRow, 2);
|
||||||
|
|
||||||
m_statusLabel = new QLabel(
|
m_statusLabel = new QLabel(
|
||||||
tr("可拖入 mhd/nii/dcm。Ctrl+在1/2/3上移动可重切另两面。"), this);
|
tr("可拖入 mhd/nii/dcm。Ctrl+在1/2/3上移动可重切另两面。Profile角非0时沿绿箭头斜线取样。"), this);
|
||||||
m_statusLabel->setContentsMargins(8, 4, 8, 4);
|
m_statusLabel->setContentsMargins(8, 4, 8, 4);
|
||||||
pageLay->addWidget(m_statusLabel);
|
pageLay->addWidget(m_statusLabel);
|
||||||
|
updateProfileTitles();
|
||||||
}
|
}
|
||||||
|
|
||||||
QString ProfileComparePage::defaultOpenDir() const
|
QString ProfileComparePage::defaultOpenDir() const
|
||||||
|
|
@ -586,6 +607,39 @@ void ProfileComparePage::setCursor(const Vec3d& world, bool fromSlider)
|
||||||
refreshProfiles();
|
refreshProfiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ProfileComparePage::onProfileAngleChanged(double deg)
|
||||||
|
{
|
||||||
|
if (m_axial)
|
||||||
|
m_axial->setProfileAngleDeg(deg);
|
||||||
|
if (m_sagittal)
|
||||||
|
m_sagittal->setProfileAngleDeg(deg);
|
||||||
|
if (m_coronal)
|
||||||
|
m_coronal->setProfileAngleDeg(deg);
|
||||||
|
updateProfileTitles();
|
||||||
|
refreshProfiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProfileComparePage::updateProfileTitles()
|
||||||
|
{
|
||||||
|
const double deg = m_angleSpin ? m_angleSpin->value() : 0.0;
|
||||||
|
if (std::abs(deg) < 1e-6) {
|
||||||
|
if (m_profAxial)
|
||||||
|
m_profAxial->setTitle(tr("4 Profile — 沿 LR (X) [可视范围]"));
|
||||||
|
if (m_profSagittal)
|
||||||
|
m_profSagittal->setTitle(tr("5 Profile — 沿 AP (Y) [可视范围]"));
|
||||||
|
if (m_profCoronal)
|
||||||
|
m_profCoronal->setTitle(tr("6 Profile — 沿 SI (Z) [可视范围]"));
|
||||||
|
} else {
|
||||||
|
const QString ang = QString::number(deg, 'f', 1);
|
||||||
|
if (m_profAxial)
|
||||||
|
m_profAxial->setTitle(tr("4 Profile — 轴位斜向 %1° [距交点 mm]").arg(ang));
|
||||||
|
if (m_profSagittal)
|
||||||
|
m_profSagittal->setTitle(tr("5 Profile — 矢状斜向 %1° [距交点 mm]").arg(ang));
|
||||||
|
if (m_profCoronal)
|
||||||
|
m_profCoronal->setTitle(tr("6 Profile — 冠状斜向 %1° [距交点 mm]").arg(ang));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void ProfileComparePage::refreshProfiles()
|
void ProfileComparePage::refreshProfiles()
|
||||||
{
|
{
|
||||||
extractProfiles();
|
extractProfiles();
|
||||||
|
|
@ -605,7 +659,59 @@ void ProfileComparePage::extractProfiles()
|
||||||
double volMin[3], volMax[3];
|
double volMin[3], volMax[3];
|
||||||
bg->worldBounds(volMin, volMax);
|
bg->worldBounds(volMin, volMax);
|
||||||
const auto sp = bg->spacing();
|
const auto sp = bg->spacing();
|
||||||
|
const double deg = m_angleSpin ? m_angleSpin->value() : 0.0;
|
||||||
|
const bool oblique = std::abs(deg) >= 1e-6;
|
||||||
|
|
||||||
|
auto rayAabbRange = [&](const double dir[3], double& t0, double& t1) {
|
||||||
|
t0 = -1e30;
|
||||||
|
t1 = 1e30;
|
||||||
|
auto clip = [&](double o, double d, double lo, double hi) {
|
||||||
|
if (std::abs(d) < 1e-12) {
|
||||||
|
if (o < lo || o > hi)
|
||||||
|
t1 = -1e30;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
double a = (lo - o) / d;
|
||||||
|
double b = (hi - o) / d;
|
||||||
|
if (a > b)
|
||||||
|
std::swap(a, b);
|
||||||
|
t0 = std::max(t0, a);
|
||||||
|
t1 = std::min(t1, b);
|
||||||
|
};
|
||||||
|
clip(m_cursor.x, dir[0], volMin[0], volMax[0]);
|
||||||
|
clip(m_cursor.y, dir[1], volMin[1], volMax[1]);
|
||||||
|
clip(m_cursor.z, dir[2], volMin[2], volMax[2]);
|
||||||
|
};
|
||||||
|
|
||||||
|
auto sampleOblique = [&](DoseImage::Pointer dose, SliceViewWidget* view,
|
||||||
|
QVector<double>& pos, QVector<double>& val) {
|
||||||
|
pos.clear();
|
||||||
|
val.clear();
|
||||||
|
if (!view)
|
||||||
|
return;
|
||||||
|
double dir[3];
|
||||||
|
view->profileDirectionWorld(dir);
|
||||||
|
double tVol0 = 0, tVol1 = 0;
|
||||||
|
rayAabbRange(dir, tVol0, tVol1);
|
||||||
|
if (!(tVol1 > tVol0))
|
||||||
|
return;
|
||||||
|
double tVis0 = 0, tVis1 = 0;
|
||||||
|
if (view->visibleRangeAlongProfile(tVis0, tVis1)) {
|
||||||
|
tVol0 = std::max(tVol0, tVis0);
|
||||||
|
tVol1 = std::min(tVol1, tVis1);
|
||||||
|
}
|
||||||
|
if (!(tVol1 > tVol0))
|
||||||
|
return;
|
||||||
|
const double step = std::max({std::abs(sp[0]), std::abs(sp[1]), std::abs(sp[2]), 0.5});
|
||||||
|
for (double t = tVol0; t <= tVol1 + 0.5 * step; t += step) {
|
||||||
|
pos.push_back(t);
|
||||||
|
val.push_back(dose->sampleWorld(m_cursor.x + t * dir[0],
|
||||||
|
m_cursor.y + t * dir[1],
|
||||||
|
m_cursor.z + t * dir[2]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!oblique) {
|
||||||
double rmin[3] = {volMin[0], volMin[1], volMin[2]};
|
double rmin[3] = {volMin[0], volMin[1], volMin[2]};
|
||||||
double rmax[3] = {volMax[0], volMax[1], volMax[2]};
|
double rmax[3] = {volMax[0], volMax[1], volMax[2]};
|
||||||
|
|
||||||
|
|
@ -613,16 +719,16 @@ void ProfileComparePage::extractProfiles()
|
||||||
bool any = false;
|
bool any = false;
|
||||||
double lo = 0.0, hi = 0.0;
|
double lo = 0.0, hi = 0.0;
|
||||||
for (SliceViewWidget* view : views) {
|
for (SliceViewWidget* view : views) {
|
||||||
double t0 = 0.0, t1 = 0.0;
|
double a = 0.0, b = 0.0;
|
||||||
if (!view->visibleRangeForWorldAxis(axis, t0, t1))
|
if (!view->visibleRangeForWorldAxis(axis, a, b))
|
||||||
continue;
|
continue;
|
||||||
if (!any) {
|
if (!any) {
|
||||||
lo = t0;
|
lo = a;
|
||||||
hi = t1;
|
hi = b;
|
||||||
any = true;
|
any = true;
|
||||||
} else {
|
} else {
|
||||||
lo = std::max(lo, t0);
|
lo = std::max(lo, a);
|
||||||
hi = std::min(hi, t1);
|
hi = std::min(hi, b);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!any || hi <= lo)
|
if (!any || hi <= lo)
|
||||||
|
|
@ -673,6 +779,28 @@ void ProfileComparePage::extractProfiles()
|
||||||
alongZ.push_back(sz);
|
alongZ.push_back(sz);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m_profAxial->setCursorMm(m_cursor.x);
|
||||||
|
m_profSagittal->setCursorMm(m_cursor.y);
|
||||||
|
m_profCoronal->setCursorMm(m_cursor.z);
|
||||||
|
} else {
|
||||||
|
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();
|
||||||
|
sampleOblique(img.dose, m_axial, sx.positionsMm, sx.valuesGy);
|
||||||
|
sampleOblique(img.dose, m_sagittal, sy.positionsMm, sy.valuesGy);
|
||||||
|
sampleOblique(img.dose, m_coronal, sz.positionsMm, sz.valuesGy);
|
||||||
|
alongX.push_back(sx);
|
||||||
|
alongY.push_back(sy);
|
||||||
|
alongZ.push_back(sz);
|
||||||
|
}
|
||||||
|
m_profAxial->setCursorMm(0.0);
|
||||||
|
m_profSagittal->setCursorMm(0.0);
|
||||||
|
m_profCoronal->setCursorMm(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
const double yMin = static_cast<double>(bg->minValue());
|
const double yMin = static_cast<double>(bg->minValue());
|
||||||
const double yMax = static_cast<double>(bg->maxValue());
|
const double yMax = static_cast<double>(bg->maxValue());
|
||||||
m_profAxial->setYRange(yMin, yMax);
|
m_profAxial->setYRange(yMin, yMax);
|
||||||
|
|
@ -680,9 +808,6 @@ void ProfileComparePage::extractProfiles()
|
||||||
m_profCoronal->setYRange(yMin, yMax);
|
m_profCoronal->setYRange(yMin, yMax);
|
||||||
|
|
||||||
m_profAxial->setSeries(alongX);
|
m_profAxial->setSeries(alongX);
|
||||||
m_profAxial->setCursorMm(m_cursor.x);
|
|
||||||
m_profSagittal->setSeries(alongY);
|
m_profSagittal->setSeries(alongY);
|
||||||
m_profSagittal->setCursorMm(m_cursor.y);
|
|
||||||
m_profCoronal->setSeries(alongZ);
|
m_profCoronal->setSeries(alongZ);
|
||||||
m_profCoronal->setCursorMm(m_cursor.z);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ class ImageListPanel;
|
||||||
class QComboBox;
|
class QComboBox;
|
||||||
class QSlider;
|
class QSlider;
|
||||||
class QLabel;
|
class QLabel;
|
||||||
|
class QDoubleSpinBox;
|
||||||
class QDragEnterEvent;
|
class QDragEnterEvent;
|
||||||
class QDropEvent;
|
class QDropEvent;
|
||||||
|
|
||||||
|
|
@ -37,9 +38,11 @@ private slots:
|
||||||
void onColorMapChanged(int index);
|
void onColorMapChanged(int index);
|
||||||
void onSliceSliderChanged(int value);
|
void onSliceSliderChanged(int value);
|
||||||
void onCursorChanged(Vec3d world);
|
void onCursorChanged(Vec3d world);
|
||||||
|
void onProfileAngleChanged(double deg);
|
||||||
void refreshImageSelectors();
|
void refreshImageSelectors();
|
||||||
void refreshViews();
|
void refreshViews();
|
||||||
void refreshProfiles();
|
void refreshProfiles();
|
||||||
|
void updateProfileTitles();
|
||||||
void syncSliceSliders();
|
void syncSliceSliders();
|
||||||
void onZoomScaleChanged(double parallelScale);
|
void onZoomScaleChanged(double parallelScale);
|
||||||
void onPanWorldDelta(double dx, double dy, double dz);
|
void onPanWorldDelta(double dx, double dy, double dz);
|
||||||
|
|
@ -76,6 +79,7 @@ private:
|
||||||
QComboBox* m_colorMapCombo = nullptr;
|
QComboBox* m_colorMapCombo = nullptr;
|
||||||
QSlider* m_opacitySlider = nullptr;
|
QSlider* m_opacitySlider = nullptr;
|
||||||
QLabel* m_opacityLabel = nullptr;
|
QLabel* m_opacityLabel = nullptr;
|
||||||
|
QDoubleSpinBox* m_angleSpin = nullptr;
|
||||||
|
|
||||||
QSlider* m_sliderAxial = nullptr;
|
QSlider* m_sliderAxial = nullptr;
|
||||||
QSlider* m_sliderSagittal = nullptr;
|
QSlider* m_sliderSagittal = nullptr;
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,35 @@ vtkSmartPointer<vtkActor2D> makeCrossActor(const double rgb[3])
|
||||||
return actor;
|
return actor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Points: 0=line start, 1=line end / arrow tip, 2=wingL, 3=wingR
|
||||||
|
vtkSmartPointer<vtkActor2D> makeObliqueActor()
|
||||||
|
{
|
||||||
|
auto poly = vtkSmartPointer<vtkPolyData>::New();
|
||||||
|
auto pts = vtkSmartPointer<vtkPoints>::New();
|
||||||
|
for (int i = 0; i < 4; ++i)
|
||||||
|
pts->InsertNextPoint(0, 0, 0);
|
||||||
|
auto lines = vtkSmartPointer<vtkCellArray>::New();
|
||||||
|
lines->InsertNextCell(2);
|
||||||
|
lines->InsertCellPoint(0);
|
||||||
|
lines->InsertCellPoint(1);
|
||||||
|
lines->InsertNextCell(2);
|
||||||
|
lines->InsertCellPoint(1);
|
||||||
|
lines->InsertCellPoint(2);
|
||||||
|
lines->InsertNextCell(2);
|
||||||
|
lines->InsertCellPoint(1);
|
||||||
|
lines->InsertCellPoint(3);
|
||||||
|
poly->SetPoints(pts);
|
||||||
|
poly->SetLines(lines);
|
||||||
|
auto mapper = vtkSmartPointer<vtkPolyDataMapper2D>::New();
|
||||||
|
mapper->SetInputData(poly);
|
||||||
|
auto actor = vtkSmartPointer<vtkActor2D>::New();
|
||||||
|
actor->SetMapper(mapper);
|
||||||
|
actor->GetProperty()->SetColor(0.15, 0.95, 0.35);
|
||||||
|
actor->GetProperty()->SetLineWidth(2.2);
|
||||||
|
actor->SetVisibility(0);
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
|
||||||
QLabel* makeEdgeLabel(QWidget* parent, const QString& text, const QColor& color)
|
QLabel* makeEdgeLabel(QWidget* parent, const QString& text, const QColor& color)
|
||||||
{
|
{
|
||||||
auto* lab = new QLabel(text, parent);
|
auto* lab = new QLabel(text, parent);
|
||||||
|
|
@ -116,8 +145,10 @@ SliceViewWidget::SliceViewWidget(ViewAxis axis, QWidget* parent)
|
||||||
axisColorRgb(ViewAxis::Sagittal, rgbV);
|
axisColorRgb(ViewAxis::Sagittal, rgbV);
|
||||||
m_crossH = makeCrossActor(rgbH);
|
m_crossH = makeCrossActor(rgbH);
|
||||||
m_crossV = makeCrossActor(rgbV);
|
m_crossV = makeCrossActor(rgbV);
|
||||||
|
m_oblique = makeObliqueActor();
|
||||||
m_renderer->AddActor2D(m_crossH);
|
m_renderer->AddActor2D(m_crossH);
|
||||||
m_renderer->AddActor2D(m_crossV);
|
m_renderer->AddActor2D(m_crossV);
|
||||||
|
m_renderer->AddActor2D(m_oblique);
|
||||||
|
|
||||||
const QColor ac = axisColor(m_axis);
|
const QColor ac = axisColor(m_axis);
|
||||||
QString top, bottom, left, right;
|
QString top, bottom, left, right;
|
||||||
|
|
@ -125,20 +156,20 @@ SliceViewWidget::SliceViewWidget(ViewAxis axis, QWidget* parent)
|
||||||
case ViewAxis::Axial:
|
case ViewAxis::Axial:
|
||||||
top = QStringLiteral("A");
|
top = QStringLiteral("A");
|
||||||
bottom = QStringLiteral("P");
|
bottom = QStringLiteral("P");
|
||||||
left = QStringLiteral("L");
|
left = QStringLiteral("R");
|
||||||
right = QStringLiteral("R");
|
right = QStringLiteral("L");
|
||||||
break;
|
break;
|
||||||
case ViewAxis::Sagittal:
|
case ViewAxis::Sagittal:
|
||||||
top = QStringLiteral("S");
|
top = QStringLiteral("S");
|
||||||
bottom = QStringLiteral("I");
|
bottom = QStringLiteral("I");
|
||||||
left = QStringLiteral("P");
|
left = QStringLiteral("A");
|
||||||
right = QStringLiteral("A");
|
right = QStringLiteral("P");
|
||||||
break;
|
break;
|
||||||
case ViewAxis::Coronal:
|
case ViewAxis::Coronal:
|
||||||
top = QStringLiteral("S");
|
top = QStringLiteral("S");
|
||||||
bottom = QStringLiteral("I");
|
bottom = QStringLiteral("I");
|
||||||
left = QStringLiteral("L");
|
left = QStringLiteral("R");
|
||||||
right = QStringLiteral("R");
|
right = QStringLiteral("L");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
m_labelTop = makeEdgeLabel(this, top, ac);
|
m_labelTop = makeEdgeLabel(this, top, ac);
|
||||||
|
|
@ -297,27 +328,27 @@ void SliceViewWidget::setColorMap(DoseColorMap map)
|
||||||
|
|
||||||
void SliceViewWidget::viewDirections(double u[3], double v[3], double n[3]) const
|
void SliceViewWidget::viewDirections(double u[3], double v[3], double n[3]) const
|
||||||
{
|
{
|
||||||
// 物理轴按 LPS 显示(+X=L +Y=P +Z=S)。MHD 虽写 RAI,但按 LPS 才能上A下P。
|
// 物理轴按 LPS(+X=L +Y=P +Z=S)。
|
||||||
// 轴位:上A下P左L右R —— 「屁股朝天」= P 在上,必须用 v=-Y(A)
|
// 轴位/冠状:放射学惯例(面朝患者)—— 屏左=患者右(R),屏右=患者左(L)。
|
||||||
for (int i = 0; i < 3; ++i)
|
for (int i = 0; i < 3; ++i)
|
||||||
u[i] = v[i] = n[i] = 0.0;
|
u[i] = v[i] = n[i] = 0.0;
|
||||||
switch (m_axis) {
|
switch (m_axis) {
|
||||||
case ViewAxis::Axial:
|
case ViewAxis::Axial:
|
||||||
u[0] = -1.0; // 屏右 = R = -X
|
u[0] = 1.0; // 屏右 = L = +X;屏左 = R = -X
|
||||||
v[1] = -1.0; // 屏上 = A = -Y(+Y 是屁股 P)
|
v[1] = -1.0; // 屏上 = A = -Y(+Y 是屁股 P)
|
||||||
n[2] = 1.0; // 从头(S)往脚(I)看
|
n[2] = 1.0; // 从头(S)往脚(I)看
|
||||||
break;
|
break;
|
||||||
case ViewAxis::Sagittal:
|
case ViewAxis::Sagittal:
|
||||||
// 从患者右看:上S下I 左P右A
|
// 与 3D Slicer 一致:上S下I,屏左A屏右P
|
||||||
u[1] = -1.0; // 右 = A = -Y
|
u[1] = 1.0; // 屏右 = P = +Y;屏左 = A = -Y
|
||||||
v[2] = 1.0; // 上 = S = +Z
|
v[2] = 1.0; // 上 = S = +Z
|
||||||
n[0] = -1.0; // 患者右 = -X,从右侧看
|
n[0] = -1.0; // 从患者右侧看
|
||||||
break;
|
break;
|
||||||
case ViewAxis::Coronal:
|
case ViewAxis::Coronal:
|
||||||
// 面朝患者、约定俗成:上S下I 左L右R(图像水平翻转)
|
// 面朝患者:上S下I,屏左R屏右L
|
||||||
u[0] = -1.0; // 屏右 = R = -X;屏左 = L = +X
|
u[0] = 1.0; // 屏右 = L = +X;屏左 = R = -X
|
||||||
v[2] = 1.0; // 上 = S = +Z
|
v[2] = 1.0; // 上 = S = +Z
|
||||||
n[1] = -1.0; // 从 A(-Y) 看向 P
|
n[1] = -1.0; // 从 A 看向 P
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -340,6 +371,46 @@ void SliceViewWidget::inPlaneWorldAxes(int& uAxis, int& vAxis) const
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SliceViewWidget::setProfileAngleDeg(double deg)
|
||||||
|
{
|
||||||
|
if (!std::isfinite(deg))
|
||||||
|
deg = 0.0;
|
||||||
|
// wrap to (-180, 180]
|
||||||
|
while (deg > 180.0)
|
||||||
|
deg -= 360.0;
|
||||||
|
while (deg <= -180.0)
|
||||||
|
deg += 360.0;
|
||||||
|
if (std::abs(deg - m_profileAngleDeg) < 1e-9)
|
||||||
|
return;
|
||||||
|
m_profileAngleDeg = deg;
|
||||||
|
updateCrosshair();
|
||||||
|
if (m_vtkWidget && m_vtkWidget->GetRenderWindow())
|
||||||
|
m_vtkWidget->GetRenderWindow()->Render();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SliceViewWidget::profileDirectionWorld(double dir[3]) const
|
||||||
|
{
|
||||||
|
double u[3], v[3], n[3];
|
||||||
|
viewDirections(u, v, n);
|
||||||
|
const double rad = m_profileAngleDeg * 3.14159265358979323846 / 180.0;
|
||||||
|
const double c = std::cos(rad);
|
||||||
|
const double s = std::sin(rad);
|
||||||
|
// Coronal: 0° along SI (V); Axial/Sagittal: 0° along screen-horizontal (U).
|
||||||
|
if (m_axis == ViewAxis::Coronal) {
|
||||||
|
for (int i = 0; i < 3; ++i)
|
||||||
|
dir[i] = -s * u[i] + c * v[i];
|
||||||
|
} else {
|
||||||
|
for (int i = 0; i < 3; ++i)
|
||||||
|
dir[i] = c * u[i] + s * v[i];
|
||||||
|
}
|
||||||
|
const double len = std::sqrt(dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]);
|
||||||
|
if (len > 1e-12) {
|
||||||
|
dir[0] /= len;
|
||||||
|
dir[1] /= len;
|
||||||
|
dir[2] /= len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void SliceViewWidget::updateOrientationLabels()
|
void SliceViewWidget::updateOrientationLabels()
|
||||||
{
|
{
|
||||||
if (!m_labelTop)
|
if (!m_labelTop)
|
||||||
|
|
@ -628,6 +699,66 @@ bool SliceViewWidget::visibleRangeForWorldAxis(int worldAxis, double& rmin, doub
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool SliceViewWidget::visibleRangeAlongProfile(double& tMin, double& tMax) const
|
||||||
|
{
|
||||||
|
if (!m_renderer || !m_vtkWidget || !m_cameraReady)
|
||||||
|
return false;
|
||||||
|
auto* cam = m_renderer->GetActiveCamera();
|
||||||
|
if (!cam)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const double halfH = cam->GetParallelScale();
|
||||||
|
const double aspect = static_cast<double>(std::max(1, m_vtkWidget->width()))
|
||||||
|
/ static_cast<double>(std::max(1, m_vtkWidget->height()));
|
||||||
|
const double halfW = halfH * aspect;
|
||||||
|
double fp[3];
|
||||||
|
cam->GetFocalPoint(fp);
|
||||||
|
|
||||||
|
double u[3], v[3], n[3];
|
||||||
|
viewDirections(u, v, n);
|
||||||
|
const Vec3d ori = sliceOrigin();
|
||||||
|
|
||||||
|
// Cursor in UV of this slice.
|
||||||
|
const double dx = m_cursor.x - ori.x;
|
||||||
|
const double dy = m_cursor.y - ori.y;
|
||||||
|
const double dz = m_cursor.z - ori.z;
|
||||||
|
const double cu = dx * u[0] + dy * u[1] + dz * u[2];
|
||||||
|
const double cv = dx * v[0] + dy * v[1] + dz * v[2];
|
||||||
|
|
||||||
|
double dirW[3];
|
||||||
|
profileDirectionWorld(dirW);
|
||||||
|
const double du = dirW[0] * u[0] + dirW[1] * u[1] + dirW[2] * u[2];
|
||||||
|
const double dv = dirW[0] * v[0] + dirW[1] * v[1] + dirW[2] * v[2];
|
||||||
|
const double dlen2 = du * du + dv * dv;
|
||||||
|
if (dlen2 < 1e-16)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Visible rectangle in UV (same as displayToWorld).
|
||||||
|
const double u0 = fp[0] - halfW;
|
||||||
|
const double u1 = fp[0] + halfW;
|
||||||
|
const double v0 = fp[1] - halfH;
|
||||||
|
const double v1 = fp[1] + halfH;
|
||||||
|
|
||||||
|
tMin = -1e30;
|
||||||
|
tMax = 1e30;
|
||||||
|
auto clip = [&](double p, double d, double lo, double hi) {
|
||||||
|
if (std::abs(d) < 1e-12) {
|
||||||
|
if (p < lo || p > hi)
|
||||||
|
tMax = -1e30;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
double a = (lo - p) / d;
|
||||||
|
double b = (hi - p) / d;
|
||||||
|
if (a > b)
|
||||||
|
std::swap(a, b);
|
||||||
|
tMin = std::max(tMin, a);
|
||||||
|
tMax = std::min(tMax, b);
|
||||||
|
};
|
||||||
|
clip(cu, du, u0, u1);
|
||||||
|
clip(cv, dv, v0, v1);
|
||||||
|
return tMax > tMin;
|
||||||
|
}
|
||||||
|
|
||||||
bool SliceViewWidget::displayToWorld(int displayX, int displayY, Vec3d& out) const
|
bool SliceViewWidget::displayToWorld(int displayX, int displayY, Vec3d& out) const
|
||||||
{
|
{
|
||||||
if (!m_renderer || !m_cameraReady)
|
if (!m_renderer || !m_cameraReady)
|
||||||
|
|
@ -824,6 +955,90 @@ void SliceViewWidget::updateCrosshair()
|
||||||
axisColorRgb(colorForLineDirection(v), rgbV);
|
axisColorRgb(colorForLineDirection(v), rgbV);
|
||||||
setLine(m_crossH, 0, cy, w, cy, rgbU);
|
setLine(m_crossH, 0, cy, w, cy, rgbU);
|
||||||
setLine(m_crossV, cx, 0, cx, h, rgbV);
|
setLine(m_crossV, cx, 0, cx, h, rgbV);
|
||||||
|
|
||||||
|
// Green oblique profile line (hidden at 0°).
|
||||||
|
if (!m_oblique)
|
||||||
|
return;
|
||||||
|
if (std::abs(m_profileAngleDeg) < 1e-6) {
|
||||||
|
m_oblique->SetVisibility(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double dirW[3];
|
||||||
|
profileDirectionWorld(dirW);
|
||||||
|
// Renderer world == in-plane UV; project world dir onto U/V.
|
||||||
|
const double tipLen = 20.0; // mm
|
||||||
|
const double du = dirW[0] * u[0] + dirW[1] * u[1] + dirW[2] * u[2];
|
||||||
|
const double dv = dirW[0] * v[0] + dirW[1] * v[1] + dirW[2] * v[2];
|
||||||
|
m_renderer->SetWorldPoint(tu + tipLen * du, tv + tipLen * dv, 0.0, 1.0);
|
||||||
|
m_renderer->WorldToDisplay();
|
||||||
|
double tipDisp[3];
|
||||||
|
m_renderer->GetDisplayPoint(tipDisp);
|
||||||
|
double ddx = tipDisp[0] - cx;
|
||||||
|
double ddy = tipDisp[1] - cy;
|
||||||
|
const double dlen = std::sqrt(ddx * ddx + ddy * ddy);
|
||||||
|
if (dlen < 1e-6) {
|
||||||
|
m_oblique->SetVisibility(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ddx /= dlen;
|
||||||
|
ddy /= dlen;
|
||||||
|
|
||||||
|
// Clip infinite line through (cx,cy) along (ddx,ddy) to the widget rectangle.
|
||||||
|
auto clipT = [&](double dx, double dy, double& tEnter, double& tLeave) {
|
||||||
|
tEnter = -1e30;
|
||||||
|
tLeave = 1e30;
|
||||||
|
auto clip = [&](double p, double d, double minB, double maxB) {
|
||||||
|
if (std::abs(d) < 1e-12) {
|
||||||
|
if (p < minB || p > maxB)
|
||||||
|
tLeave = -1e30; // empty
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
double ta = (minB - p) / d;
|
||||||
|
double tb = (maxB - p) / d;
|
||||||
|
if (ta > tb)
|
||||||
|
std::swap(ta, tb);
|
||||||
|
tEnter = std::max(tEnter, ta);
|
||||||
|
tLeave = std::min(tLeave, tb);
|
||||||
|
};
|
||||||
|
clip(cx, dx, 0.0, static_cast<double>(w));
|
||||||
|
clip(cy, dy, 0.0, static_cast<double>(h));
|
||||||
|
};
|
||||||
|
double tEnter = 0, tLeave = 0;
|
||||||
|
clipT(ddx, ddy, tEnter, tLeave);
|
||||||
|
if (!(tLeave > tEnter)) {
|
||||||
|
m_oblique->SetVisibility(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const double xA = cx + tEnter * ddx;
|
||||||
|
const double yA = cy + tEnter * ddy;
|
||||||
|
const double xB = cx + tLeave * ddx;
|
||||||
|
const double yB = cy + tLeave * ddy;
|
||||||
|
// Arrow at +dir end (tLeave).
|
||||||
|
const double tipX = xB;
|
||||||
|
const double tipY = yB;
|
||||||
|
const double startX = xA;
|
||||||
|
const double startY = yA;
|
||||||
|
|
||||||
|
const double arrow = 10.0;
|
||||||
|
const double nx = -ddy;
|
||||||
|
const double ny = ddx;
|
||||||
|
const double wx0 = tipX - arrow * ddx + 0.5 * arrow * nx;
|
||||||
|
const double wy0 = tipY - arrow * ddy + 0.5 * arrow * ny;
|
||||||
|
const double wx1 = tipX - arrow * ddx - 0.5 * arrow * nx;
|
||||||
|
const double wy1 = tipY - arrow * ddy - 0.5 * arrow * ny;
|
||||||
|
|
||||||
|
auto* mapper = vtkPolyDataMapper2D::SafeDownCast(m_oblique->GetMapper());
|
||||||
|
auto* poly = vtkPolyData::SafeDownCast(mapper->GetInput());
|
||||||
|
auto* pts = poly->GetPoints();
|
||||||
|
pts->SetPoint(0, startX, startY, 0);
|
||||||
|
pts->SetPoint(1, tipX, tipY, 0);
|
||||||
|
pts->SetPoint(2, wx0, wy0, 0);
|
||||||
|
pts->SetPoint(3, wx1, wy1, 0);
|
||||||
|
pts->Modified();
|
||||||
|
poly->Modified();
|
||||||
|
m_oblique->SetVisibility(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
void SliceViewWidget::updatePickOverlay(const QPoint& pos)
|
void SliceViewWidget::updatePickOverlay(const QPoint& pos)
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,14 @@ public:
|
||||||
void setCursorWorld(const Vec3d& world);
|
void setCursorWorld(const Vec3d& world);
|
||||||
Vec3d cursorWorld() const { return m_cursor; }
|
Vec3d cursorWorld() const { return m_cursor; }
|
||||||
|
|
||||||
|
// In-plane profile angle (degrees). 0 = axis-aligned (no green overlay).
|
||||||
|
// Axial/Sagittal: CCW from screen-horizontal (U).
|
||||||
|
// Coronal: CCW from screen-vertical SI (V), so 0° matches along-Z.
|
||||||
|
void setProfileAngleDeg(double deg);
|
||||||
|
double profileAngleDeg() const { return m_profileAngleDeg; }
|
||||||
|
// Unit world direction used for oblique profile sampling on this view.
|
||||||
|
void profileDirectionWorld(double dir[3]) const;
|
||||||
|
|
||||||
void resetViewToVolumeCenter();
|
void resetViewToVolumeCenter();
|
||||||
|
|
||||||
void setWindowLevel(double window, double level);
|
void setWindowLevel(double window, double level);
|
||||||
|
|
@ -43,6 +51,8 @@ public:
|
||||||
void setColorBarVisible(bool visible, double vmin = 0.0, double vmax = 2.0);
|
void setColorBarVisible(bool visible, double vmin = 0.0, double vmax = 2.0);
|
||||||
|
|
||||||
bool visibleRangeForWorldAxis(int worldAxis, double& rmin, double& rmax) const;
|
bool visibleRangeForWorldAxis(int worldAxis, double& rmin, double& rmax) const;
|
||||||
|
// Signed distance range along profileDirectionWorld() from cursor, clipped to current FOV.
|
||||||
|
bool visibleRangeAlongProfile(double& tMin, double& tMax) const;
|
||||||
|
|
||||||
double parallelScale() const;
|
double parallelScale() const;
|
||||||
void setParallelScaleShared(double scale);
|
void setParallelScaleShared(double scale);
|
||||||
|
|
@ -120,6 +130,8 @@ private:
|
||||||
vtkSmartPointer<vtkImageActor> m_actorFg;
|
vtkSmartPointer<vtkImageActor> m_actorFg;
|
||||||
vtkSmartPointer<vtkActor2D> m_crossH;
|
vtkSmartPointer<vtkActor2D> m_crossH;
|
||||||
vtkSmartPointer<vtkActor2D> m_crossV;
|
vtkSmartPointer<vtkActor2D> m_crossV;
|
||||||
|
vtkSmartPointer<vtkActor2D> m_oblique; // green angled profile line + arrow
|
||||||
|
double m_profileAngleDeg = 0.0;
|
||||||
|
|
||||||
bool m_leftDown = false;
|
bool m_leftDown = false;
|
||||||
bool m_rightDown = false;
|
bool m_rightDown = false;
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,10 @@ static void sampleSlice(const DoseImage::Pointer& dose, int axisCode)
|
||||||
const double cz = 0.5 * (bmin[2] + bmax[2]);
|
const double cz = 0.5 * (bmin[2] + bmax[2]);
|
||||||
|
|
||||||
double u[3] = {0, 0, 0}, v[3] = {0, 0, 0};
|
double u[3] = {0, 0, 0}, v[3] = {0, 0, 0};
|
||||||
if (axisCode == 0) { // axial
|
if (axisCode == 0) { // axial — 屏右=L=+X
|
||||||
u[0] = -1; v[1] = -1;
|
u[0] = 1; v[1] = -1;
|
||||||
} else if (axisCode == 1) { // sag
|
} else if (axisCode == 1) { // sag — 屏右=P=+Y
|
||||||
u[1] = -1; v[2] = 1;
|
u[1] = 1; v[2] = 1;
|
||||||
} else {
|
} else {
|
||||||
u[0] = 1; v[2] = 1;
|
u[0] = 1; v[2] = 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue