diff --git a/kstars/fitsviewer/fitstab.h b/kstars/fitsviewer/fitstab.h --- a/kstars/fitsviewer/fitstab.h +++ b/kstars/fitsviewer/fitstab.h @@ -28,6 +28,9 @@ #include #include #include +#include +#include +#include #include @@ -129,6 +132,12 @@ private: bool setupView(FITSMode mode, FITSScale filter); + + QHBoxLayout* setupStretchBar(); + void setStretchUIValues(bool adjustSliders); + void rescaleShadows(); + void rescaleMidtones(); + void processData(); /** Ask user whether he wants to save changes and save if he do. */ @@ -161,10 +170,16 @@ QString previewText; int uid { 0 }; - //QFuture histogramFuture; + // Stretch bar widgets + std::unique_ptr shadowsLabel, midtonesLabel, highlightsLabel; + std::unique_ptr shadowsVal, midtonesVal, highlightsVal; + std::unique_ptr shadowsSlider, midtonesSlider, highlightsSlider; + std::unique_ptr stretchButton, autoButton; + float maxShadows {0.5}, maxMidtones {0.5}, maxHighlights {1.0}; + //QFuture histogramFuture; - signals: +signals: void debayerToggled(bool); void newStatus(const QString &msg, FITSBar id); void changeStatus(bool clean); diff --git a/kstars/fitsviewer/fitstab.cpp b/kstars/fitsviewer/fitstab.cpp --- a/kstars/fitsviewer/fitstab.cpp +++ b/kstars/fitsviewer/fitstab.cpp @@ -28,6 +28,14 @@ #include #include +#include + + +namespace { +const char kAutoToolTip[] = "Automatically find stretch parameters"; +const char kStretchOffToolTip[] = "Stretch the image"; +const char kStretchOnToolTip[] = "Disable stretching of the image."; +} // namespace FITSTab::FITSTab(FITSViewer *parent) : QWidget(parent) { @@ -98,6 +106,236 @@ connect(recentImages, &QListWidget::currentRowChanged, this, &FITSTab::selectRecentFITS); } +namespace { + +// Sets the text value in the slider's value display, and if adjustSlider is true, +// moves the slider to the correct position. +void setSlider(QSlider *slider, QLabel *label, float value, float maxValue, bool adjustSlider) +{ + if (adjustSlider) + slider->setValue(static_cast(value * 10000 / maxValue)); + QString valStr = QString("%1").arg(static_cast(value), 5, 'f', 4); + label->setText(valStr); +} + +// Adds the following to a horizontal layout (left to right): a vertical line, +// a label with the slider's name, a slider, and a text field to display the slider's value. +void setupStretchSlider(QSlider *slider, QLabel *label, QLabel *val, int fontSize, + const QString& name, QHBoxLayout *layout) +{ + QFrame* line = new QFrame(); + line->setFrameShape(QFrame::VLine); + line->setFrameShadow(QFrame::Sunken); + layout->addWidget(line); + QFont font = label->font(); + font.setPointSize(fontSize); + + label->setText(name); + label->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + label->setFont(font); + layout->addWidget(label); + slider->setMinimum(0); + slider->setMaximum(10000); + slider->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + layout->addWidget(slider); + val->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + val->setFont(font); + layout->addWidget(val); +} + +// Adds a button with the icon and tooltip to the layout. +void setupStretchButton(QPushButton *button, const QString &iconName, const QString &tip, QHBoxLayout *layout) +{ + button->setIcon(QIcon::fromTheme(iconName)); + button->setIconSize(QSize(22, 22)); + button->setToolTip(tip); + button->setCheckable(true); + button->setChecked(true); + button->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + layout->addWidget(button); +} + +} // namespace + +// Updates all the widgets in the stretch area to display the view's stretch parameters. +void FITSTab::setStretchUIValues(bool adjustSliders) +{ + StretchParams1Channel params = view->getStretchParams().grey_red; + setSlider(shadowsSlider.get(), shadowsVal.get(), params.shadows, maxShadows, adjustSliders); + setSlider(midtonesSlider.get(), midtonesVal.get(), params.midtones, maxMidtones, adjustSliders); + setSlider(highlightsSlider.get(), highlightsVal.get(), params.highlights, maxHighlights, adjustSliders); + + + bool stretchActive = view->isImageStretched(); + if (stretchActive) + { + stretchButton->setChecked(true); + stretchButton->setToolTip(kStretchOnToolTip); + } + else + { + stretchButton->setChecked(false); + stretchButton->setToolTip(kStretchOffToolTip); + } + + // Only activeate the auto button if stretching is on and auto-stretching is not set. + if (stretchActive && !view->getAutoStretch()) + { + autoButton->setEnabled(true); + autoButton->setIcon(QIcon::fromTheme("tools-wizard")); + autoButton->setIconSize(QSize(22, 22)); + autoButton->setToolTip(kAutoToolTip); + } + else + { + autoButton->setEnabled(false); + autoButton->setIcon(QIcon()); + autoButton->setIconSize(QSize(22, 22)); + autoButton->setToolTip(""); + } + autoButton->setChecked(view->getAutoStretch()); + + // Disable most of the UI if stretching is not active. + shadowsSlider->setEnabled(stretchActive); + shadowsVal->setEnabled(stretchActive); + shadowsLabel->setEnabled(stretchActive); + midtonesSlider->setEnabled(stretchActive); + midtonesVal->setEnabled(stretchActive); + midtonesLabel->setEnabled(stretchActive); + highlightsSlider->setEnabled(stretchActive); + highlightsVal->setEnabled(stretchActive); + highlightsLabel->setEnabled(stretchActive); +} + +// Adjusts the maxShadows value so that we have room to adjust the slider. +void FITSTab::rescaleShadows() +{ + if (!view) return; + StretchParams1Channel params = view->getStretchParams().grey_red; + maxShadows = std::max(0.002f, std::min(1.0f, params.shadows * 2.0f)); + setStretchUIValues(true); +} + +// Adjusts the maxMidtones value so that we have room to adjust the slider. +void FITSTab::rescaleMidtones() +{ + if (!view) return; + StretchParams1Channel params = view->getStretchParams().grey_red; + maxMidtones = std::max(.002f, std::min(1.0f, params.midtones * 2.0f)); + setStretchUIValues(true); +} + +QHBoxLayout* FITSTab::setupStretchBar() +{ + constexpr int fontSize = 12; + + QHBoxLayout *stretchBarLayout = new QHBoxLayout(); + + stretchButton.reset(new QPushButton()); + setupStretchButton(stretchButton.get(), "transform-move", kStretchOffToolTip, stretchBarLayout); + + // Shadows + shadowsLabel.reset(new QLabel()); + shadowsVal.reset(new QLabel()); + shadowsSlider.reset(new QSlider(Qt::Horizontal, this)); + setupStretchSlider(shadowsSlider.get(), shadowsLabel.get(), shadowsVal.get(), fontSize, "Shadows", stretchBarLayout); + + // Midtones + midtonesLabel.reset(new QLabel()); + midtonesVal.reset(new QLabel()); + midtonesSlider.reset(new QSlider(Qt::Horizontal, this)); + setupStretchSlider(midtonesSlider.get(), midtonesLabel.get(), midtonesVal.get(), fontSize, "Midtones", stretchBarLayout); + + // Highlights + highlightsLabel.reset(new QLabel()); + highlightsVal.reset(new QLabel()); + highlightsSlider.reset(new QSlider(Qt::Horizontal, this)); + setupStretchSlider(highlightsSlider.get(), highlightsLabel.get(), highlightsVal.get(), fontSize, "Hightlights", stretchBarLayout); + + // Separator + QFrame* line4 = new QFrame(); + line4->setFrameShape(QFrame::VLine); + line4->setFrameShadow(QFrame::Sunken); + stretchBarLayout->addWidget(line4); + + autoButton.reset(new QPushButton()); + setupStretchButton(autoButton.get(), "tools-wizard", kAutoToolTip, stretchBarLayout); + + connect(stretchButton.get(), &QPushButton::clicked, [=]() { + // This will toggle whether we're currently stretching. + view->setStretch(!view->isImageStretched()); + }); + + // Make rough displays for the slider movement. + connect(shadowsSlider.get(), &QSlider::sliderMoved, [=](int value) { + StretchParams params = view->getStretchParams(); + params.grey_red.shadows = this->maxShadows * value / 10000.0f; + view->setSampling(4); + view->setStretchParams(params); + view->setSampling(1); + }); + connect(midtonesSlider.get(), &QSlider::sliderMoved, [=](int value) { + StretchParams params = view->getStretchParams(); + params.grey_red.midtones = this->maxMidtones * value / 10000.0f; + view->setSampling(4); + view->setStretchParams(params); + view->setSampling(1); + }); + connect(highlightsSlider.get(), &QSlider::sliderMoved, [=](int value) { + StretchParams params = view->getStretchParams(); + params.grey_red.highlights = this->maxHighlights * value / 10000.0f; + view->setSampling(4); + view->setStretchParams(params); + view->setSampling(1); + }); + + // Make a final full-res display when the slider is released. + connect(shadowsSlider.get(), &QSlider::sliderReleased, [=]() { + if (!view) return; + rescaleShadows(); + StretchParams params = view->getStretchParams(); + view->setStretchParams(params); + }); + connect(midtonesSlider.get(), &QSlider::sliderReleased, [=]() { + if (!view) return; + rescaleMidtones(); + StretchParams params = view->getStretchParams(); + view->setStretchParams(params); + }); + connect(highlightsSlider.get(), &QSlider::sliderReleased, [=]() { + if (!view) return; + StretchParams params = view->getStretchParams(); + view->setStretchParams(params); + }); + + connect(autoButton.get(), &QPushButton::clicked, [=]() { + // If we're not currently using automatic stretch parameters, turn that on. + // If we're already using automatic parameters, don't do anything. + // User can just move the sliders to take manual control. + if (!view->getAutoStretch()) + view->setAutoStretchParams(); + else + KMessageBox::information(this, "You are already using automatic stretching. To manually stretch, drag a slider."); + setStretchUIValues(false); + }); + + // This is mostly useful right at the start, when the image is displayed without any user interaction. + // Check for slider-in-use, as we don't wont to rescale while the user is active. + connect(view.get(), &FITSView::newStatus, [=](const QString &ignored) { + Q_UNUSED(ignored) + bool slidersInUse = shadowsSlider->isSliderDown() || midtonesSlider->isSliderDown() || + highlightsSlider->isSliderDown(); + if (!slidersInUse) + { + rescaleShadows(); + rescaleMidtones(); + } + setStretchUIValues(!slidersInUse); + }); + + return stretchBarLayout; +} + bool FITSTab::setupView(FITSMode mode, FITSScale filter) { if (view.get() == nullptr) @@ -154,10 +392,10 @@ fitsSplitter->setSizes(QList() << 0 << view->width() ); vlayout->addWidget(fitsSplitter); + vlayout->addLayout(setupStretchBar()); connect(fitsSplitter, &QSplitter::splitterMoved, histogram, &FITSHistogram::resizePlot); - setLayout(vlayout); connect(view.get(), &FITSView::newStatus, this, &FITSTab::newStatus); connect(view.get(), &FITSView::debayerToggled, this, &FITSTab::debayerToggled); diff --git a/kstars/fitsviewer/fitsview.h b/kstars/fitsviewer/fitsview.h --- a/kstars/fitsviewer/fitsview.h +++ b/kstars/fitsviewer/fitsview.h @@ -13,6 +13,7 @@ #include "fitscommon.h" #include +#include "stretch.h" #ifdef HAVE_DATAVISUALIZATION #include "starprofileviewer.h" @@ -203,6 +204,28 @@ //void setLoadWCSEnabled(bool value); + // Returns the params set to stretch the image. + StretchParams getStretchParams() const { return stretchParams; } + + // Returns true if we're automatically generating stretch parameters. + // Note: this is not whether we're stretching, that's controlled by stretchImage. + bool getAutoStretch() const { return autoStretch; } + + // Sets the params for stretching. Will also stretch and re-display the image. + // This only sets the first channel stretch params. For RGB images, the G&B channel + // stretch parameters are a function of the Red input param and the existing RGB params. + void setStretchParams(const StretchParams& params); + + // Sets whether to stretch the image or not. + // Will also re-display the image if onOff != stretchImage. + void setStretch(bool onOff); + + // Automatically generates stretch parameters and use them to re-display the image. + void setAutoStretchParams(); + + // When sampling is > 1, we will display the image at a lower resolution. + void setSampling(int value) { if (value > 0) sampling = value; } + public slots: void wheelEvent(QWheelEvent *event) override; void resizeEvent(QResizeEvent *event) override; @@ -268,17 +291,15 @@ private: bool processData(); + void doStretch(FITSData *data, QImage *outputImage); QLabel *noImageLabel { nullptr }; QPixmap noImage; QVector eqGridPoints; std::unique_ptr image_frame; - uint32_t image_width { 0 }; - uint32_t image_height { 0 }; - /// Current width due to zoom uint16_t currentWidth { 0 }; uint16_t lastWidth { 0 }; @@ -304,8 +325,21 @@ bool showPixelGrid { false }; bool showStarsHFR { false }; + // Should the image be displayed in linear (false) or stretched (true). + // Initial value controlled by Options::autoStretch. bool stretchImage { false }; - + + // When stretching, should we automatically compute parameters. + // When first displaying, this should be true, but may be set to false + // if the user has overridden the automatically set parameters. + bool autoStretch { true }; + + // Params for stretching image. + StretchParams stretchParams; + + // Resolution for display. Sampling=2 means display every other sample. + int sampling { 1 }; + struct { bool used() const @@ -353,7 +387,7 @@ QPointer starProfileWidget; #endif - signals: +signals: void newStatus(const QString &msg, FITSBar id); void debayerToggled(bool); void wcsToggled(bool); diff --git a/kstars/fitsviewer/fitsview.cpp b/kstars/fitsviewer/fitsview.cpp --- a/kstars/fitsviewer/fitsview.cpp +++ b/kstars/fitsviewer/fitsview.cpp @@ -36,34 +36,115 @@ #include #define BASE_OFFSET 50 -#define ZOOM_DEFAULT 100.0 +#define ZOOM_DEFAULT 100.0f #define ZOOM_MIN 10 #define ZOOM_MAX 400 #define ZOOM_LOW_INCR 10 #define ZOOM_HIGH_INCR 50 -namespace -{ +namespace { + +// Derive the Green and Blue stretch parameters from their previous values and the +// changes made to the Red parameters. We apply the same offsets used for Red to the +// other channels' parameters, but clip them. +void ComputeGBStretchParams(const StretchParams &newParams, StretchParams* params) { + float shadow_diff = newParams.grey_red.shadows - params->grey_red.shadows; + float highlight_diff = newParams.grey_red.highlights - params->grey_red.highlights; + float midtones_diff = newParams.grey_red.midtones - params->grey_red.midtones; + + params->green.shadows = params->green.shadows + shadow_diff; + params->green.shadows = KSUtils::clamp(params->green.shadows, 0.0f, 1.0f); + params->green.highlights = params->green.highlights + highlight_diff; + params->green.highlights = KSUtils::clamp(params->green.highlights, 0.0f, 1.0f); + params->green.midtones = params->green.midtones + midtones_diff; + params->green.midtones = std::max(params->green.midtones, 0.0f); -void doStretch(FITSData *data, QImage *outputImage, bool stretchOn) + params->blue.shadows = params->blue.shadows + shadow_diff; + params->blue.shadows = KSUtils::clamp(params->blue.shadows, 0.0f, 1.0f); + params->blue.highlights = params->blue.highlights + highlight_diff; + params->blue.highlights = KSUtils::clamp(params->blue.highlights, 0.0f, 1.0f); + params->blue.midtones = params->blue.midtones + midtones_diff; + params->blue.midtones = std::max(params->blue.midtones, 0.0f); +} + +} // namespace + +// Runs the stretch checking the variables to see which parameters to use. +// We call stretch even if we're not stretching, as the stretch code still +// converts the image to the uint8 output image which will be displayed. +// In that case, it will use an identity stretch. +void FITSView::doStretch(FITSData *data, QImage *outputImage) { if (outputImage->isNull()) return; Stretch stretch(static_cast(data->width()), static_cast(data->height()), data->channels(), data->property("dataType").toInt()); - if (stretchOn) - stretch.setParams(stretch.computeParams(data->getImageBuffer())); - stretch.run(data->getImageBuffer(), outputImage); + + StretchParams tempParams; + if (!stretchImage) + tempParams = StretchParams(); // Keeping it linear + else if (autoStretch) + { + // Compute new auto-stretch params. + stretchParams = stretch.computeParams(data->getImageBuffer()); + tempParams = stretchParams; + } + else + // Use the existing stretch params. + tempParams = stretchParams; + + stretch.setParams(tempParams); + stretch.run(data->getImageBuffer(), outputImage, sampling); } -} // namespace +// Store stretch parameters, and turn on stretching if it isn't already on. +void FITSView::setStretchParams(const StretchParams& params) +{ + if (imageData->channels() == 3) + ComputeGBStretchParams(params, &stretchParams); + + stretchParams.grey_red = params.grey_red; + stretchParams.grey_red.shadows = std::max(stretchParams.grey_red.shadows, 0.0f); + stretchParams.grey_red.highlights = std::max(stretchParams.grey_red.highlights, 0.0f); + stretchParams.grey_red.midtones = std::max(stretchParams.grey_red.midtones, 0.0f); + + autoStretch = false; + stretchImage = true; + + if (image_frame != nullptr && rescale(ZOOM_KEEP_LEVEL)) + updateFrame(); +} + +// Turn on or off stretching, and if on, use whatever parameters are currently stored. +void FITSView::setStretch(bool onOff) +{ + if (stretchImage != onOff) + { + stretchImage = onOff; + if (image_frame != nullptr && rescale(ZOOM_KEEP_LEVEL)) + updateFrame(); + } +} +// Turn on stretching, using automatically generated parameters. +void FITSView::setAutoStretchParams() +{ + stretchImage = true; + autoStretch = true; + if (image_frame != nullptr && rescale(ZOOM_KEEP_LEVEL)) + updateFrame(); +} FITSView::FITSView(QWidget * parent, FITSMode fitsMode, FITSScale filterType) : QScrollArea(parent), zoomFactor(1.2) { + // stretchImage is whether to stretch or not--the stretch may or may not use automatically generated parameters. + // The user may enter his/her own. stretchImage = Options::autoStretch(); - + // autoStretch means use automatically-generated parameters. This is the default, unless the user overrides + // by adjusting the stretchBar's sliders. + autoStretch = true; + grabGesture(Qt::PinchGesture); image_frame.reset(new FITSLabel(this)); @@ -212,6 +293,7 @@ bool FITSView::loadFITSFromData(FITSData *data, const QString &inFilename) { + Q_UNUSED(inFilename) if (imageData != nullptr) { delete imageData; @@ -240,11 +322,12 @@ bool FITSView::processData() { // Set current width and height + if (!imageData) return false; currentWidth = imageData->width(); currentHeight = imageData->height(); - image_width = currentWidth; - image_height = currentHeight; + int image_width = currentWidth; + int image_height = currentHeight; image_frame->setSize(image_width, image_height); @@ -405,29 +488,20 @@ { if (rawImage.isNull()) return false; - if (true || image_height != imageData->height() || image_width != imageData->width()) - { - image_width = imageData->width(); - image_height = imageData->height(); - - initDisplayImage(); - - if (isVisible()) - emit newStatus(QString("%1x%2").arg(image_width).arg(image_height), FITS_RESOLUTION); - } - image_frame->setScaledContents(true); - currentWidth = rawImage.width(); - currentHeight = rawImage.height(); + if (!imageData) return false; + int image_width = imageData->width(); + int image_height = imageData->height(); + currentWidth = image_width; + currentHeight = image_height; - doStretch(imageData, &rawImage, stretchImage); - - scaledImage = QImage(); + if (isVisible()) + emit newStatus(QString("%1x%2").arg(image_width).arg(image_height), FITS_RESOLUTION); switch (type) { case ZOOM_FIT_WINDOW: - if ((rawImage.width() > width() || rawImage.height() > height())) + if ((image_width > width() || image_height > height())) { double w = baseSize().width() - BASE_OFFSET; double h = baseSize().height() - BASE_OFFSET; @@ -470,10 +544,14 @@ break; } + initDisplayImage(); + image_frame->setScaledContents(true); + doStretch(imageData, &rawImage); + scaledImage = QImage(); setWidget(image_frame.get()); - if (type != ZOOM_KEEP_LEVEL) - emit newStatus(QString("%1%").arg(currentZoom), FITS_ZOOM); + // This is needed by fitstab, even if the zoom doesn't change, to change the stretch UI. + emit newStatus(QString("%1%").arg(currentZoom), FITS_ZOOM); return true; } @@ -498,8 +576,9 @@ emit actionUpdated("view_zoom_in", false); } - currentWidth = image_width * (currentZoom / ZOOM_DEFAULT); - currentHeight = image_height * (currentZoom / ZOOM_DEFAULT); + if (!imageData) return; + currentWidth = imageData->width() * (currentZoom / ZOOM_DEFAULT); + currentHeight = imageData->height() * (currentZoom / ZOOM_DEFAULT); updateFrame(); @@ -521,8 +600,9 @@ emit actionUpdated("view_zoom_in", true); - currentWidth = image_width * (currentZoom / ZOOM_DEFAULT); - currentHeight = image_height * (currentZoom / ZOOM_DEFAULT); + if (!imageData) return; + currentWidth = imageData->width() * (currentZoom / ZOOM_DEFAULT); + currentHeight = imageData->height() * (currentZoom / ZOOM_DEFAULT); updateFrame(); @@ -606,8 +686,8 @@ emit actionUpdated("view_zoom_in", true); currentZoom = ZOOM_DEFAULT; - currentWidth = image_width; - currentHeight = image_height; + currentWidth = imageData->width(); + currentHeight = imageData->height(); updateFrame(); @@ -746,6 +826,9 @@ void FITSView::drawCrosshair(QPainter * painter) { + if (!imageData) return; + int image_width = imageData->width(); + int image_height = imageData->height(); float scale = (currentZoom / ZOOM_DEFAULT); QPointF c = QPointF((qreal)image_width / 2 * scale, (qreal)image_height / 2 * scale); float midX = (float)image_width / 2 * scale; @@ -784,12 +867,12 @@ void FITSView::drawPixelGrid(QPainter * painter) { float scale = (currentZoom / ZOOM_DEFAULT); - double width = image_width * scale; - double height = image_height * scale; - double cX = width / 2; - double cY = height / 2; - double deltaX = width / 10; - double deltaY = height / 10; + float width = imageData->width() * scale; + float height = imageData->height() * scale; + float cX = width / 2; + float cY = height / 2; + float deltaX = width / 10; + float deltaY = height / 10; //draw the Axes painter->setPen(QPen(Qt::red)); painter->drawText(cX - 30, height - 5, QString::number((int)((cX) / scale))); @@ -846,6 +929,8 @@ void FITSView::drawEQGrid(QPainter * painter) { float scale = (currentZoom / ZOOM_DEFAULT); + int image_width = imageData->width(); + int image_height = imageData->height(); if (imageData->hasWCS()) { @@ -1033,6 +1118,8 @@ bool FITSView::pointIsInImage(QPointF pt, bool scaled) { + int image_width = imageData->width(); + int image_height = imageData->height(); float scale = (currentZoom / ZOOM_DEFAULT); if (scaled) return pt.x() < image_width * scale && pt.y() < image_height * scale && pt.x() > 0 && pt.y() > 0; @@ -1042,6 +1129,8 @@ QPointF FITSView::getPointForGridLabel() { + int image_width = imageData->width(); + int image_height = imageData->height(); float scale = (currentZoom / ZOOM_DEFAULT); //These get the maximum X and Y points in the list that are in the image @@ -1453,17 +1542,21 @@ void FITSView::initDisplayImage() { + // Account for leftover when sampling. Thus a 5-wide image sampled by 2 + // would result in a width of 3 (samples 0, 2 and 4). + int w = (imageData->width() + sampling - 1) / sampling; + int h = (imageData->height() + sampling - 1) / sampling; if (imageData->channels() == 1) { - rawImage = QImage(image_width, image_height, QImage::Format_Indexed8); + rawImage = QImage(w, h, QImage::Format_Indexed8); rawImage.setColorCount(256); for (int i = 0; i < 256; i++) rawImage.setColor(i, qRgb(i, i, i)); } else { - rawImage = QImage(image_width, image_height, QImage::Format_RGB32); + rawImage = QImage(w, h, QImage::Format_RGB32); } } diff --git a/kstars/fitsviewer/fitsviewer.h b/kstars/fitsviewer/fitsviewer.h --- a/kstars/fitsviewer/fitsviewer.h +++ b/kstars/fitsviewer/fitsviewer.h @@ -127,7 +127,6 @@ void centerTelescope(); void updateWCSFunctions(); void applyFilter(int ftype); - void toggleStretch(); void rotateCW(); void rotateCCW(); void flipHorizontal(); diff --git a/kstars/fitsviewer/fitsviewer.cpp b/kstars/fitsviewer/fitsviewer.cpp --- a/kstars/fitsviewer/fitsviewer.cpp +++ b/kstars/fitsviewer/fitsviewer.cpp @@ -149,13 +149,6 @@ action->setText(i18n("Debayer...")); connect(action, SIGNAL(triggered(bool)), SLOT(debayerFITS())); - action = actionCollection()->addAction("image_stretch"); - action->setText(i18n("Toggle Auto stretch")); - action->setCheckable(true); - connect(action, SIGNAL(triggered(bool)), SLOT(toggleStretch())); - actionCollection()->setDefaultShortcut(action, QKeySequence::SelectAll); - action->setIcon(QIcon::fromTheme("transform-move")); - action = KStandardAction::close(this, SLOT(close()), actionCollection()); action->setIcon(QIcon::fromTheme("window-close")); @@ -398,8 +391,6 @@ tab->getView()->setCursorMode(FITSView::dragCursor); - updateButtonStatus("image_stretch", i18n("Toggle Auto stretch"), tab->getView()->isImageStretched()); - updateWCSFunctions(); return true; @@ -596,8 +587,6 @@ updateButtonStatus("view_objects", i18n("Objects in Image"), getCurrentView()->areObjectsShown()); updateButtonStatus("view_pixel_grid", i18n("Pixel Gridlines"), getCurrentView()->isPixelGridShown()); - fprintf(stderr, "Updating button status to %s\n", getCurrentView()->isImageStretched() ? "true" : "false"); - updateButtonStatus("image_stretch", i18n("Toggle Auto stretch"), getCurrentView()->isImageStretched()); updateScopeButton(); updateWCSFunctions(); } @@ -992,23 +981,6 @@ updateStatusBar(i18n("Ready."), FITS_MESSAGE); } -void FITSViewer::toggleStretch() -{ - if (fitsTabs.empty()) - return; - - QApplication::setOverrideCursor(Qt::WaitCursor); - updateStatusBar(i18n("Processing toggle stretch"), FITS_MESSAGE); - qApp->processEvents(); - fitsTabs[fitsTabWidget->currentIndex()]->getView()->toggleStretch(); - - updateButtonStatus("image_stretch", i18n("Toggle Auto stretch"), - getCurrentView()->isImageStretched()); - - QApplication::restoreOverrideCursor(); - updateStatusBar(i18n("Ready."), FITS_MESSAGE); -} - FITSView *FITSViewer::getView(int fitsUID) { FITSTab *tab = fitsMap.value(fitsUID); diff --git a/kstars/fitsviewer/stretch.h b/kstars/fitsviewer/stretch.h --- a/kstars/fitsviewer/stretch.h +++ b/kstars/fitsviewer/stretch.h @@ -74,10 +74,19 @@ /** * @brief run run the stretch algorithm according to the params given * placing the output in output_image. + * @param input the raw data buffer. + * @param output_image a QImage pointer that should be the same size as the input if + * sampling is 1 otherwise, the proper size if the input is downsampled by sampling. + * @param sampling The sampling parameter. Applies to both width and height. + * Sampling is applied to the output (that is, with sampling=2, we compute every other output + * sample both in width and height, so the output would have about 4X fewer pixels. */ - void run(uint8_t *input, QImage *output_image); + void run(uint8_t *input, QImage *output_image, int sampling=1); private: + // Adjusts input_range for float and double types. + void recalculateInputRange(uint8_t *input); + // Inputs. int image_width; int image_height; diff --git a/kstars/fitsviewer/stretch.cpp b/kstars/fitsviewer/stretch.cpp --- a/kstars/fitsviewer/stretch.cpp +++ b/kstars/fitsviewer/stretch.cpp @@ -16,7 +16,7 @@ namespace { -// Returns the median v of the vector. +// Returns the median value of the vector. // The vector is modified in an undefined way. template T median(std::vector& values) @@ -26,6 +26,17 @@ return values[middle]; } +// Returns the rough max of the buffer. +template +T sampledMax(T *values, int size, int sampleBy) +{ + T maxVal = 0; + for (int i = 0; i < size; i+= sampleBy) + if (maxVal < values[i]) + maxVal = values[i]; + return maxVal; +} + // Returns the median of the sample values. // The values are not modified. template @@ -43,10 +54,12 @@ // http://pixinsight.com/doc/docs/XISF-1.0-spec/XISF-1.0-spec.html // Uses multiple threads, blocks until done. // The extension parameters are not used. +// Sampling is applied to the output (that is, with sampling=2, we compute every other output +// sample both in width and height, so the output would have about 4X fewer pixels. template void stretchOneChannel(T *input_buffer, QImage *output_image, const StretchParams& stretch_params, - int input_range, int image_height, int image_width) + int input_range, int image_height, int image_width, int sampling) { QVector> futures; @@ -62,30 +75,31 @@ // Precomputed expressions moved out of the loop. // hightlights - shadows, protecting for divide-by-0, in a 0->1.0 scale. - const float hsRangeFactor = highlights == shadows ? 1.0 : 1.0 / (highlights - shadows); + const float hsRangeFactor = highlights == shadows ? 1.0f : 1.0f / (highlights - shadows); // Shadow and highlight values translated to the ADU scale. const T nativeShadows = shadows * maxInput; const T nativeHighlights = highlights * maxInput; // Constants based on above needed for the stretch calculations. const float k1 = (midtones - 1) * hsRangeFactor * maxOutput / maxInput; const float k2 = ((2 * midtones) - 1) * hsRangeFactor / maxInput; - for (int j = 0; j < image_height; j++) + // Increment the input index by the sampling, the output index increments by 1. + for (int j = 0, jout = 0; j < image_height; j+=sampling, jout++) { futures.append(QtConcurrent::run([ = ]() { T * inputLine = input_buffer + j * image_width; - auto * scanLine = output_image->scanLine(j); + auto * scanLine = output_image->scanLine(jout); - for (int i = 0; i < image_width; i++) + for (int i = 0, iout = 0; i < image_width; i+=sampling, iout++) { const T input = inputLine[i]; - if (input < nativeShadows) scanLine[i] = 0; - else if (input >= nativeHighlights) scanLine[i] = maxOutput; + if (input < nativeShadows) scanLine[iout] = 0; + else if (input >= nativeHighlights) scanLine[iout] = maxOutput; else { const T inputFloored = (input - nativeShadows); - scanLine[i] = (inputFloored * k1) / (inputFloored * k2 - midtones); + scanLine[iout] = (inputFloored * k1) / (inputFloored * k2 - midtones); } } })); @@ -99,10 +113,12 @@ // into a single qRgb value at the end, so it seems the simplest thing is to // replicate the code. It is assume the colors are not interleaved--the red image // is stored fully, then the green, then the blue. +// Sampling is applied to the output (that is, with sampling=2, we compute every other output +// sample both in width and height, so the output would have about 4X fewer pixels. template void stretchThreeChannels(T *inputBuffer, QImage *outputImage, const StretchParams& stretchParams, - int inputRange, int imageHeight, int imageWidth) + int inputRange, int imageHeight, int imageWidth, int sampling) { QVector> futures; @@ -124,9 +140,9 @@ // Precomputed expressions moved out of the loop. // hightlights - shadows, protecting for divide-by-0, in a 0->1.0 scale. - const float hsRangeFactorR = highlightsR == shadowsR ? 1.0 : 1.0 / (highlightsR - shadowsR); - const float hsRangeFactorG = highlightsG == shadowsG ? 1.0 : 1.0 / (highlightsG - shadowsG); - const float hsRangeFactorB = highlightsB == shadowsB ? 1.0 : 1.0 / (highlightsB - shadowsB); + const float hsRangeFactorR = highlightsR == shadowsR ? 1.0f : 1.0f / (highlightsR - shadowsR); + const float hsRangeFactorG = highlightsG == shadowsG ? 1.0f : 1.0f / (highlightsG - shadowsG); + const float hsRangeFactorB = highlightsB == shadowsB ? 1.0f : 1.0f / (highlightsB - shadowsB); // Shadow and highlight values translated to the ADU scale. const T nativeShadowsR = shadowsR * maxInput; const T nativeShadowsG = shadowsG * maxInput; @@ -144,18 +160,18 @@ const int size = imageWidth * imageHeight; - for (int j = 0; j < imageHeight; j++) + for (int j = 0, jout = 0; j < imageHeight; j+=sampling, jout++) { futures.append(QtConcurrent::run([ = ]() { // R, G, B input images are stored one after another. T * inputLineR = inputBuffer + j * imageWidth; T * inputLineG = inputLineR + size; T * inputLineB = inputLineG + size; - auto * scanLine = reinterpret_cast(outputImage->scanLine(j)); + auto * scanLine = reinterpret_cast(outputImage->scanLine(jout)); - for (int i = 0; i < imageWidth; i++) + for (int i = 0, iout = 0; i < imageWidth; i+=sampling, iout++) { const T inputR = inputLineR[i]; const T inputG = inputLineG[i]; @@ -186,7 +202,7 @@ const T inputFloored = (inputB - nativeShadowsB); blue = (inputFloored * k1B) / (inputFloored * k2B - midtonesB); } - scanLine[i] = qRgb(red, green, blue); + scanLine[iout] = qRgb(red, green, blue); } })); } @@ -197,14 +213,14 @@ template void stretchChannels(T *input_buffer, QImage *output_image, const StretchParams& stretch_params, - int input_range, int image_height, int image_width, int num_channels) + int input_range, int image_height, int image_width, int num_channels, int sampling) { if (num_channels == 1) stretchOneChannel(input_buffer, output_image, stretch_params, input_range, - image_height, image_width); + image_height, image_width, sampling); else if (num_channels == 3) stretchThreeChannels(input_buffer, output_image, stretch_params, input_range, - image_height, image_width); + image_height, image_width, sampling); } // See section 8.5.7 in above link http://pixinsight.com/doc/docs/XISF-1.0-spec/XISF-1.0-spec.html @@ -215,9 +231,8 @@ // Find the median sample. constexpr int maxSamples = 500000; const int sampleBy = width * height < maxSamples ? 1 : width * height / maxSamples; - const int size = width * height; - T medianSample = median(buffer, width * height, sampleBy); + T medianSample = median(buffer, width * height, sampleBy); // Find the Median deviation: 1.4826 * median of abs(sample[i] - median). const int numSamples = width * height / sampleBy; std::vector deviations(numSamples); @@ -252,9 +267,9 @@ M = highlights - normalizedMedian; } float midtones; - if (X == 0) midtones = 0; - else if (X == M) midtones = 0.5; - else if (X == 1) midtones = 1.0; + if (X == 0) midtones = 0.0f; + else if (X == M) midtones = 0.5f; + else if (X == 1) midtones = 1.0f; else midtones = ((M - 1) * X) / ((2 * M - 1) * X - M); // Store the params. @@ -275,28 +290,20 @@ { case TBYTE: return 256; - break; case TSHORT: return 64*1024; - break; case TUSHORT: return 64*1024; - break; case TLONG: return 64*1024; - break; case TFLOAT: return 64*1024; - break; case TLONGLONG: return 64*1024; - break; case TDOUBLE: return 64*1024; - break; default: return 64*1024; - break; } } @@ -311,45 +318,65 @@ input_range = getRange(dataType); } -void Stretch::run(uint8_t *input, QImage *outputImage) +void Stretch::run(uint8_t *input, QImage *outputImage, int sampling) { + Q_ASSERT(outputImage->width() == (image_width + sampling - 1) / sampling); + Q_ASSERT(outputImage->height() == (image_height + sampling - 1) / sampling); + recalculateInputRange(input); + switch (dataType) { case TBYTE: stretchChannels(reinterpret_cast(input), outputImage, params, - input_range, image_height, image_width, image_channels); + input_range, image_height, image_width, image_channels, sampling); break; case TSHORT: stretchChannels(reinterpret_cast(input), outputImage, params, - input_range, image_height, image_width, image_channels); + input_range, image_height, image_width, image_channels, sampling); break; case TUSHORT: stretchChannels(reinterpret_cast(input), outputImage, params, - input_range, image_height, image_width, image_channels); + input_range, image_height, image_width, image_channels, sampling); break; case TLONG: stretchChannels(reinterpret_cast(input), outputImage, params, - input_range, image_height, image_width, image_channels); + input_range, image_height, image_width, image_channels, sampling); break; case TFLOAT: stretchChannels(reinterpret_cast(input), outputImage, params, - input_range, image_height, image_width, image_channels); + input_range, image_height, image_width, image_channels, sampling); break; case TLONGLONG: stretchChannels(reinterpret_cast(input), outputImage, params, - input_range, image_height, image_width, image_channels); + input_range, image_height, image_width, image_channels, sampling); break; case TDOUBLE: stretchChannels(reinterpret_cast(input), outputImage, params, - input_range, image_height, image_width, image_channels); + input_range, image_height, image_width, image_channels, sampling); break; default: break; } } +// The input range for float/double is ambiguous, and we can't tell without the buffer, +// so we set it to 64K and possibly reduce it when we see the data. +void Stretch::recalculateInputRange(uint8_t *input) +{ + if (input_range <= 1) return; + if (dataType != TFLOAT && dataType != TDOUBLE) return; + + float mx = 0; + if (dataType == TFLOAT) + mx = sampledMax(reinterpret_cast(input), image_height * image_width, 1000); + else if (dataType == TDOUBLE) + mx = sampledMax(reinterpret_cast(input), image_height * image_width, 1000); + if (mx <= 1.01f) input_range = 1; +} + StretchParams Stretch::computeParams(uint8_t *input) { + recalculateInputRange(input); StretchParams result; for (int channel = 0; channel < image_channels; ++channel) {