diff --git a/CMakeLists.txt b/CMakeLists.txt index 6ebb1d1..268a597 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -81,6 +81,13 @@ set(SCOPEONE_UI_SOURCES src/PositionListFile.cpp src/CollapsibleSection.cpp src/ToolIcons.cpp + src/ImageOps.cpp + src/ParameterDialog.cpp + src/ResultsWindow.cpp + src/RoiManagerWindow.cpp + src/ProfilePlotWindow.cpp + src/ImageAnalysis.cpp + src/OrthogonalViewsDialog.cpp src/UpdateChecker.cpp src/DevicePropertyWidget.cpp src/ConfigPresetWidget.cpp @@ -106,6 +113,13 @@ set(SCOPEONE_UI_HEADERS src/PositionListFile.h src/CollapsibleSection.h src/ToolIcons.h + src/ImageOps.h + src/ParameterDialog.h + src/ResultsWindow.h + src/RoiManagerWindow.h + src/ProfilePlotWindow.h + src/ImageAnalysis.h + src/OrthogonalViewsDialog.h src/UpdateChecker.h src/DevicePropertyWidget.h src/ConfigPresetWidget.h diff --git a/ScopeOneCore/include/scopeone/ScopeOneCore.h b/ScopeOneCore/include/scopeone/ScopeOneCore.h index c1d9033..037f366 100644 --- a/ScopeOneCore/include/scopeone/ScopeOneCore.h +++ b/ScopeOneCore/include/scopeone/ScopeOneCore.h @@ -314,6 +314,13 @@ namespace scopeone::core return recordedFrameCount() > 0; } + // One recorded frame, from memory or from the files that were written. Invalid when + // there is no such frame. + ImageFrame recordedFrame(const QString& cameraId, int index) const + { + return imageFrameAt(cameraId, index); + } + bool isSaved() const { return m_saved; } const QString& saveMessage() const { return m_saveMessage; } diff --git a/src/ImageAnalysis.cpp b/src/ImageAnalysis.cpp new file mode 100644 index 0000000..3764de7 --- /dev/null +++ b/src/ImageAnalysis.cpp @@ -0,0 +1,645 @@ +#include "ImageAnalysis.h" + +#include "ImageWorkspace.h" +#include "OrthogonalViewsDialog.h" +#include "ParameterDialog.h" +#include "ProfilePlotWindow.h" +#include "ResultsWindow.h" +#include "RoiManagerWindow.h" + +#include +#include +#include +#include + +namespace scopeone::ui +{ + using scopeone::core::ImageFrame; + using scopeone::core::ImageSceneModel; + + ImageAnalysis::ImageAnalysis(scopeone::core::ScopeOneCore* core, ImageWorkspace* workspace, QWidget* window, + QObject* parent) + : QObject(parent) + , m_core(core) + , m_workspace(workspace) + , m_window(window) + { + } + + void ImageAnalysis::addMenus(QMenuBar* menuBar, QAction* before) + { + QMenu* image = new QMenu(tr("&Image"), menuBar); + QMenu* process = new QMenu(tr("&Process"), menuBar); + QMenu* analyze = new QMenu(tr("&Analyze"), menuBar); + menuBar->insertMenu(before, image); + menuBar->insertMenu(before, process); + menuBar->insertMenu(before, analyze); + buildImageMenu(image); + buildProcessMenu(process); + buildAnalyzeMenu(analyze); + } + + // ---- the image a command works on + + bool ImageAnalysis::activeSource(Source& source) const + { + source = Source{}; + source.layerKey = m_workspace->activeLayerKey(); + source.scene = m_workspace->activeSceneModel(); + const ImageDocumentInfo document = m_workspace->document(); + if (document.isValid()) + { + source.title = document.title; + const auto session = m_workspace->documentSession(); + if (session && document.frameCount > 1) + { + for (int index = 0; index < document.frameCount; ++index) + { + const ImageFrame frame = session->recordedFrame(document.cameraId, index); + if (frame.isValid()) + { + source.frames.append(frame); + } + } + source.index = std::clamp(document.frameIndex, 0, static_cast(source.frames.size()) - 1); + } + else + { + source.frames.append(m_workspace->currentFrame(document.id)); + } + } + else + { + source.title = tr("Live image"); + source.frames.append(m_workspace->frameForLayer(source.layerKey)); + } + if (source.frames.isEmpty() || !imageops::isMono(source.frames.first())) + { + QMessageBox::information(m_window, tr("No image"), tr("There is no 8 bit or 16 bit gray image to work on.")); + return false; + } + return true; + } + + // Size of a pixel in micrometers, which is zero when the image is not calibrated + double ImageAnalysis::pixelScale(const Source& source) const + { + const double pixelSizeUm = m_workspace->pixelSizeUm(source.layerKey); + scopeone::core::DocumentLayer layer; + if (pixelSizeUm > 0.0 && source.scene && source.scene->findLayer(source.layerKey, layer)) + { + const double determinant = std::abs(layer.pixelToSensor.m11 * layer.pixelToSensor.m22 + - layer.pixelToSensor.m12 * layer.pixelToSensor.m21); + return std::sqrt(determinant) * pixelSizeUm; + } + return 0.0; + } + + bool ImageAnalysis::selectedShape(const Source& source, ImageSceneModel::Markup& shape) const + { + if (!source.scene) + { + return false; + } + for (const ImageSceneModel::Markup& markup : source.scene->markups(source.layerKey)) + { + if (markup.selected) + { + shape = markup; + return true; + } + } + return false; + } + + // Like ImageJ, asks whether a stack is processed as a whole when it has more than one slice + ImageAnalysis::Scope ImageAnalysis::chooseScope(const Source& source, const QString& command) const + { + if (source.frames.size() < 2) + { + return Scope::Current; + } + QMessageBox box(m_window); + box.setWindowTitle(command); + box.setText(tr("Process all %1 slices?").arg(source.frames.size())); + QPushButton* all = box.addButton(tr("All slices"), QMessageBox::YesRole); + QPushButton* current = box.addButton(tr("This slice"), QMessageBox::NoRole); + box.addButton(QMessageBox::Cancel); + box.exec(); + if (box.clickedButton() == all) + { + return Scope::All; + } + return box.clickedButton() == current ? Scope::Current : Scope::Cancel; + } + + void ImageAnalysis::present(const QList& frames, const QString& title) + { + if (frames.isEmpty() || !m_presenter) + { + return; + } + const auto session = m_core->createFrameSession(frames, scopeone::core::ExperimentPlan{}); + if (session) + { + m_presenter(session, title); + } + } + + void ImageAnalysis::apply(const QString& title, const FrameOperation& operation) + { + Source source; + if (!activeSource(source)) + { + return; + } + const Scope scope = chooseScope(source, title); + if (scope == Scope::Cancel) + { + return; + } + QList result; + if (scope == Scope::All) + { + for (const ImageFrame& frame : source.frames) + { + result.append(operation(frame)); + } + } + else + { + result.append(operation(source.frames.at(source.index))); + } + present(result, QStringLiteral("%1 (%2)").arg(title, source.title)); + } + + // ---- menus + + void ImageAnalysis::addFilter(QMenu* menu, const QString& text, imageops::Filter filter, double radius) + { + connect(menu->addAction(text), &QAction::triggered, this, [this, text, filter, radius]() + { + QList fields{ParameterField::real(tr("Radius (pixels)"), radius, 0.5, 200.0, 1)}; + if (askParameters(m_window, text, fields)) + { + const double chosen = fields.first().value; + apply(text, [filter, chosen](const ImageFrame& frame) { return imageops::filtered(frame, filter, chosen); }); + } + }); + } + + void ImageAnalysis::addMath(QMenu* menu, const QString& text, imageops::MathOperation operation, + bool needsValue, double value) + { + connect(menu->addAction(text), &QAction::triggered, this, [this, text, operation, needsValue, value]() + { + double chosen = value; + if (needsValue) + { + QList fields{ParameterField::real(tr("Value"), value, -1.0e9, 1.0e9, 3)}; + if (!askParameters(m_window, text, fields)) + { + return; + } + chosen = fields.first().value; + } + apply(text, [operation, chosen](const ImageFrame& frame) { return imageops::math(frame, operation, chosen); }); + }); + } + + void ImageAnalysis::addBinary(QMenu* menu, const QString& text, imageops::BinaryOperation operation) + { + connect(menu->addAction(text), &QAction::triggered, this, [this, text, operation]() + { + int iterations = 1; + if (operation == imageops::BinaryOperation::Erode || operation == imageops::BinaryOperation::Dilate + || operation == imageops::BinaryOperation::Open || operation == imageops::BinaryOperation::Close) + { + QList fields{ParameterField::integer(tr("Iterations"), 1, 1, 100)}; + if (!askParameters(m_window, text, fields)) + { + return; + } + iterations = static_cast(fields.first().value); + } + apply(text, [operation, iterations](const ImageFrame& frame) + { + return imageops::binary(frame, operation, iterations); + }); + }); + } + + void ImageAnalysis::buildImageMenu(QMenu* menu) + { + connect(menu->addAction(tr("&Duplicate...")), &QAction::triggered, this, [this]() { duplicateImage(); }); + connect(menu->addAction(tr("&Crop")), &QAction::triggered, this, [this]() { cropImage(); }); + connect(menu->addAction(tr("&Scale...")), &QAction::triggered, this, [this]() { scaleImage(); }); + + QMenu* transform = menu->addMenu(tr("&Transform")); + connect(transform->addAction(tr("Flip &Horizontally")), &QAction::triggered, this, + [this]() { apply(tr("Flip Horizontally"), [](const ImageFrame& f) { return imageops::flipHorizontal(f); }); }); + connect(transform->addAction(tr("Flip &Vertically")), &QAction::triggered, this, + [this]() { apply(tr("Flip Vertically"), [](const ImageFrame& f) { return imageops::flipVertical(f); }); }); + connect(transform->addAction(tr("Rotate 90 Degrees &Right")), &QAction::triggered, this, + [this]() { apply(tr("Rotate 90 Degrees Right"), [](const ImageFrame& f) { return imageops::rotate90(f, true); }); }); + connect(transform->addAction(tr("Rotate 90 Degrees &Left")), &QAction::triggered, this, + [this]() { apply(tr("Rotate 90 Degrees Left"), [](const ImageFrame& f) { return imageops::rotate90(f, false); }); }); + connect(transform->addAction(tr("&Rotate...")), &QAction::triggered, this, [this]() { rotateImage(); }); + + QMenu* adjust = menu->addMenu(tr("&Adjust")); + connect(adjust->addAction(tr("&Threshold...")), &QAction::triggered, this, [this]() { thresholdImage(); }); + + QMenu* stacks = menu->addMenu(tr("S&tacks")); + connect(stacks->addAction(tr("&Z Project...")), &QAction::triggered, this, [this]() { zProject(); }); + connect(stacks->addAction(tr("&Substack...")), &QAction::triggered, this, [this]() { makeSubstack(); }); + connect(stacks->addAction(tr("&Montage...")), &QAction::triggered, this, [this]() { makeMontage(); }); + connect(stacks->addAction(tr("Plot Z-axis &Profile")), &QAction::triggered, this, [this]() { plotZProfile(); }); + connect(stacks->addAction(tr("&Orthogonal Views")), &QAction::triggered, this, [this]() { showOrthogonalViews(); }); + } + + void ImageAnalysis::buildProcessMenu(QMenu* menu) + { + QMenu* filters = menu->addMenu(tr("&Filters")); + addFilter(filters, tr("&Gaussian Blur..."), imageops::Filter::Gaussian, 2.0); + addFilter(filters, tr("&Mean..."), imageops::Filter::Mean, 2.0); + addFilter(filters, tr("M&edian..."), imageops::Filter::Median, 2.0); + addFilter(filters, tr("Mi&nimum..."), imageops::Filter::Minimum, 2.0); + addFilter(filters, tr("Ma&ximum..."), imageops::Filter::Maximum, 2.0); + addFilter(filters, tr("&Variance..."), imageops::Filter::Variance, 2.0); + connect(filters->addAction(tr("&Unsharp Mask...")), &QAction::triggered, this, [this]() { unsharpMask(); }); + connect(menu->addAction(tr("&Sharpen")), &QAction::triggered, this, + [this]() { apply(tr("Sharpen"), [](const ImageFrame& f) { return imageops::sharpened(f); }); }); + connect(menu->addAction(tr("Find &Edges")), &QAction::triggered, this, + [this]() { apply(tr("Find Edges"), [](const ImageFrame& f) { return imageops::findEdges(f); }); }); + connect(menu->addAction(tr("Subtract &Background...")), &QAction::triggered, this, + [this]() { subtractBackground(); }); + + QMenu* math = menu->addMenu(tr("&Math")); + addMath(math, tr("&Add..."), imageops::MathOperation::Add, true, 25.0); + addMath(math, tr("&Subtract..."), imageops::MathOperation::Subtract, true, 25.0); + addMath(math, tr("&Multiply..."), imageops::MathOperation::Multiply, true, 1.25); + addMath(math, tr("&Divide..."), imageops::MathOperation::Divide, true, 1.25); + addMath(math, tr("M&inimum..."), imageops::MathOperation::Min, true, 0.0); + addMath(math, tr("Ma&ximum..."), imageops::MathOperation::Max, true, 255.0); + addMath(math, tr("&Gamma..."), imageops::MathOperation::Gamma, true, 0.5); + addMath(math, tr("&Log"), imageops::MathOperation::Log, false, 0.0); + addMath(math, tr("&Exp"), imageops::MathOperation::Exp, false, 0.0); + addMath(math, tr("S&quare"), imageops::MathOperation::Square, false, 0.0); + addMath(math, tr("Square &Root"), imageops::MathOperation::Sqrt, false, 0.0); + addMath(math, tr("A&bsolute Value"), imageops::MathOperation::Abs, false, 0.0); + + connect(menu->addAction(tr("Image &Calculator...")), &QAction::triggered, this, [this]() { calculate(); }); + + QMenu* binary = menu->addMenu(tr("Bin&ary")); + connect(binary->addAction(tr("&Make Binary")), &QAction::triggered, this, [this]() { thresholdImage(); }); + addBinary(binary, tr("&Erode..."), imageops::BinaryOperation::Erode); + addBinary(binary, tr("&Dilate..."), imageops::BinaryOperation::Dilate); + addBinary(binary, tr("&Open..."), imageops::BinaryOperation::Open); + addBinary(binary, tr("&Close..."), imageops::BinaryOperation::Close); + addBinary(binary, tr("&Fill Holes"), imageops::BinaryOperation::FillHoles); + addBinary(binary, tr("Out&line"), imageops::BinaryOperation::Outline); + addBinary(binary, tr("&Invert"), imageops::BinaryOperation::Invert); + } + + void ImageAnalysis::buildAnalyzeMenu(QMenu* menu) + { + QAction* measureAction = menu->addAction(tr("&Measure")); + measureAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+M"))); + connect(measureAction, &QAction::triggered, this, [this]() { measure(); }); + connect(menu->addAction(tr("&Results")), &QAction::triggered, this, [this]() + { + if (!m_results) + { + m_results = new ResultsWindow(m_window); + } + m_results->show(); + m_results->raise(); + }); + connect(menu->addAction(tr("&Set Measurements...")), &QAction::triggered, this, [this]() + { + if (!m_results) + { + m_results = new ResultsWindow(m_window); + } + m_results->chooseMeasurements(); + }); + menu->addSeparator(); + QAction* managerAction = menu->addAction(tr("&ROI Manager")); + managerAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+T"))); + connect(managerAction, &QAction::triggered, this, [this]() + { + if (!m_roiManager) + { + m_roiManager = new RoiManagerWindow(m_workspace, m_window); + connect(m_roiManager, &RoiManagerWindow::measureRequested, this, + [this](const QList& shapes, bool allSlices) + { measureShapes(shapes, allSlices); }); + } + m_roiManager->show(); + m_roiManager->raise(); + }); + connect(menu->addAction(tr("Plot &Z-axis Profile")), &QAction::triggered, this, [this]() { plotZProfile(); }); + } + + // ---- Image menu + + void ImageAnalysis::duplicateImage() + { + Source source; + if (!activeSource(source)) + { + return; + } + const Scope scope = chooseScope(source, tr("Duplicate")); + if (scope == Scope::Cancel) + { + return; + } + present(scope == Scope::All ? source.frames : QList{source.frames.at(source.index)}, + tr("Copy of %1").arg(source.title)); + } + + void ImageAnalysis::cropImage() + { + Source source; + if (!activeSource(source)) + { + return; + } + ImageSceneModel::Markup shape; + if (!selectedShape(source, shape)) + { + QMessageBox::information(m_window, tr("Crop"), tr("Draw a selection to crop to.")); + return; + } + const QRect area = imageops::selectionBounds(shape); + apply(tr("Crop"), [area](const ImageFrame& frame) { return imageops::crop(frame, area); }); + } + + void ImageAnalysis::scaleImage() + { + QList fields{ParameterField::real(tr("X scale"), 0.5, 0.01, 20.0, 3), + ParameterField::real(tr("Y scale"), 0.5, 0.01, 20.0, 3), + ParameterField::check(tr("Interpolate"), true)}; + if (askParameters(m_window, tr("Scale"), fields)) + { + const double x = fields.at(0).value; + const double y = fields.at(1).value; + const bool smooth = fields.at(2).checked; + apply(tr("Scale"), [x, y, smooth](const ImageFrame& frame) { return imageops::scaled(frame, x, y, smooth); }); + } + } + + void ImageAnalysis::rotateImage() + { + QList fields{ParameterField::real(tr("Angle (degrees)"), 15.0, -360.0, 360.0, 1), + ParameterField::check(tr("Interpolate"), true), + ParameterField::check(tr("Enlarge image to fit result"), false)}; + if (askParameters(m_window, tr("Rotate"), fields)) + { + const double angle = fields.at(0).value; + const bool smooth = fields.at(1).checked; + const bool grow = fields.at(2).checked; + apply(tr("Rotate"), [angle, smooth, grow](const ImageFrame& frame) + { + return imageops::rotated(frame, angle, smooth, grow); + }); + } + } + + // Makes a binary image from the pixels that are above a limit found by one of the methods + void ImageAnalysis::thresholdImage() + { + QList fields{ + ParameterField::options(tr("Method"), {tr("IsoData"), tr("Otsu"), tr("Mean"), tr("Triangle")}, 1), + ParameterField::check(tr("Dark objects on a light background"), false)}; + if (!askParameters(m_window, tr("Threshold"), fields)) + { + return; + } + const auto method = static_cast(fields.at(0).choice); + const bool darkObjects = fields.at(1).checked; + apply(tr("Threshold"), [method, darkObjects](const ImageFrame& frame) + { + const int limit = imageops::autoThreshold(frame, method); + return darkObjects ? imageops::thresholded(frame, 0, limit) + : imageops::thresholded(frame, limit + 1, imageops::maxValue(frame)); + }); + } + + // ---- Stacks + + void ImageAnalysis::zProject() + { + Source source; + if (!activeSource(source)) + { + return; + } + const int count = static_cast(source.frames.size()); + QList fields{ + ParameterField::integer(tr("Start slice"), 1, 1, count), + ParameterField::integer(tr("Stop slice"), count, 1, count), + ParameterField::options(tr("Projection"), + {tr("Maximum Intensity"), tr("Average Intensity"), tr("Sum Slices"), + tr("Minimum Intensity"), tr("Standard Deviation"), tr("Median")})}; + if (!askParameters(m_window, tr("Z Project"), fields)) + { + return; + } + static const imageops::Projection methods[] = { + imageops::Projection::Max, imageops::Projection::Average, imageops::Projection::Sum, + imageops::Projection::Min, imageops::Projection::StdDev, imageops::Projection::Median}; + const imageops::Projection method = methods[fields.at(2).choice]; + present({imageops::zProject(source.frames, static_cast(fields.at(0).value) - 1, + static_cast(fields.at(1).value) - 1, method)}, + tr("Projection of %1").arg(source.title)); + } + + void ImageAnalysis::makeSubstack() + { + Source source; + if (!activeSource(source)) + { + return; + } + const int count = static_cast(source.frames.size()); + QList fields{ParameterField::integer(tr("First slice"), 1, 1, count), + ParameterField::integer(tr("Last slice"), count, 1, count), + ParameterField::integer(tr("Step"), 1, 1, std::max(1, count))}; + if (askParameters(m_window, tr("Substack"), fields)) + { + present(imageops::substack(source.frames, static_cast(fields.at(0).value) - 1, + static_cast(fields.at(1).value) - 1, + static_cast(fields.at(2).value)), + tr("Substack of %1").arg(source.title)); + } + } + + void ImageAnalysis::makeMontage() + { + Source source; + if (!activeSource(source)) + { + return; + } + const int count = static_cast(source.frames.size()); + QList fields{ + ParameterField::integer(tr("Columns"), static_cast(std::ceil(std::sqrt(static_cast(count)))), 1, std::max(1, count)), + ParameterField::real(tr("Scale"), 0.5, 0.05, 4.0, 2), + ParameterField::integer(tr("Border width"), 2, 0, 100)}; + if (askParameters(m_window, tr("Montage"), fields)) + { + present({imageops::montage(source.frames, static_cast(fields.at(0).value), fields.at(1).value, + static_cast(fields.at(2).value))}, + tr("Montage of %1").arg(source.title)); + } + } + + void ImageAnalysis::plotZProfile() + { + Source source; + if (!activeSource(source)) + { + return; + } + ImageSceneModel::Markup shape; + const bool hasShape = selectedShape(source, shape); + const QVector values = imageops::zProfile(source.frames, hasShape ? &shape : nullptr, + imageops::ProfileStat::Mean); + if (!m_profile) + { + m_profile = new ProfilePlotWindow(m_window); + } + m_profile->setProfile(tr("Z-axis profile of %1").arg(source.title), tr("Slice"), tr("Mean"), values); + m_profile->show(); + m_profile->raise(); + } + + void ImageAnalysis::showOrthogonalViews() + { + Source source; + if (!activeSource(source) || source.frames.size() < 2) + { + return; + } + OrthogonalViewsDialog dialog(source.frames, source.index, m_window); + dialog.exec(); + } + + // ---- Process menu + + void ImageAnalysis::subtractBackground() + { + QList fields{ParameterField::real(tr("Rolling ball radius (pixels)"), 50.0, 1.0, 500.0, 1), + ParameterField::check(tr("Light background"), false)}; + if (askParameters(m_window, tr("Subtract Background"), fields)) + { + const double radius = fields.at(0).value; + const bool light = fields.at(1).checked; + apply(tr("Subtract Background"), [radius, light](const ImageFrame& frame) + { + return imageops::subtractBackground(frame, radius, light); + }); + } + } + + void ImageAnalysis::unsharpMask() + { + QList fields{ParameterField::real(tr("Radius (sigma, pixels)"), 1.0, 0.5, 200.0, 1), + ParameterField::real(tr("Mask weight (0.1 to 0.9)"), 0.6, 0.1, 0.9, 2)}; + if (askParameters(m_window, tr("Unsharp Mask"), fields)) + { + const double sigma = fields.at(0).value; + const double weight = fields.at(1).value; + apply(tr("Unsharp Mask"), [sigma, weight](const ImageFrame& frame) + { + return imageops::unsharpMask(frame, sigma, weight); + }); + } + } + + void ImageAnalysis::calculate() + { + QStringList titles; + QStringList ids; + for (const ImageDocumentInfo& document : m_workspace->documents()) + { + titles.append(document.title); + ids.append(document.id); + } + if (titles.size() < 2) + { + QMessageBox::information(m_window, tr("Image Calculator"), tr("Two images have to be open.")); + return; + } + QList fields{ + ParameterField::options(tr("Image 1"), titles, 0), + ParameterField::options(tr("Operation"), + {tr("Add"), tr("Subtract"), tr("Multiply"), tr("Divide"), tr("AND"), tr("OR"), + tr("XOR"), tr("Min"), tr("Max"), tr("Average"), tr("Difference")}), + ParameterField::options(tr("Image 2"), titles, 1)}; + if (!askParameters(m_window, tr("Image Calculator"), fields)) + { + return; + } + const ImageFrame first = m_workspace->currentFrame(ids.at(fields.at(0).choice)); + const ImageFrame second = m_workspace->currentFrame(ids.at(fields.at(2).choice)); + if (!imageops::isMono(first) || !imageops::isMono(second)) + { + return; + } + present({imageops::calculate(first, second, static_cast(fields.at(1).choice))}, + tr("Result of %1").arg(fields.at(1).choices.at(fields.at(1).choice))); + } + + // ---- Analyze menu + + void ImageAnalysis::measure() + { + Source source; + if (!activeSource(source)) + { + return; + } + ImageSceneModel::Markup shape; + if (!selectedShape(source, shape)) + { + // Without a selection the whole image is measured, as in ImageJ + shape.type = ImageSceneModel::MarkupType::Rect; + shape.rect = QRect(0, 0, source.frames.first().width, source.frames.first().height); + shape.label = source.title; + } + measureShapes({shape}, false); + } + + void ImageAnalysis::measureShapes(const QList& shapes, bool allSlices) + { + Source source; + if (shapes.isEmpty() || !activeSource(source)) + { + return; + } + if (!m_results) + { + m_results = new ResultsWindow(m_window); + } + const double scale = pixelScale(source); + const QString unit = scale > 0.0 ? QStringLiteral("µm") : QStringLiteral("pixel"); + const int first = allSlices ? 0 : source.index; + const int last = allSlices ? static_cast(source.frames.size()) - 1 : source.index; + for (const ImageSceneModel::Markup& shape : shapes) + { + const QString label = shape.label.isEmpty() + ? QStringLiteral("%1:%2").arg(source.title, ImageSceneModel::typeName(shape.type)) + : QStringLiteral("%1:%2").arg(source.title, shape.label); + for (int index = first; index <= last; ++index) + { + m_results->addMeasurement(label, + imageops::measure(source.frames.at(index), shape, scale), + source.frames.size() > 1 ? index + 1 : 0, + unit); + } + } + } +} diff --git a/src/ImageAnalysis.h b/src/ImageAnalysis.h new file mode 100644 index 0000000..fd26478 --- /dev/null +++ b/src/ImageAnalysis.h @@ -0,0 +1,98 @@ +#pragma once + +#include "ImageOps.h" +#include "scopeone/ScopeOneCore.h" + +#include +#include +#include +#include + +#include +#include + +class QAction; +class QMenu; +class QMenuBar; +class QWidget; + +namespace scopeone::ui +{ + class ImageWorkspace; + class ProfilePlotWindow; + class ResultsWindow; + class RoiManagerWindow; + + // The Image, Process and Analyze menus in the manner of ImageJ. Every command works on the + // active image, or on all of its slices, and puts its result in a new image. + class ImageAnalysis : public QObject + { + Q_OBJECT + + public: + using Presenter = std::function&, const QString&)>; + + ImageAnalysis(scopeone::core::ScopeOneCore* core, ImageWorkspace* workspace, QWidget* window, + QObject* parent = nullptr); + + // Where a new image is shown + void setPresenter(Presenter presenter) { m_presenter = std::move(presenter); } + // Adds the menus to the menu bar in front of the given action + void addMenus(QMenuBar* menuBar, QAction* before); + + private: + // The image that a command works on + struct Source + { + QList frames; + int index{0}; + QString title; + QString layerKey; + scopeone::core::ImageSceneModel* scene{nullptr}; + }; + enum class Scope { Cancel, Current, All }; + + bool activeSource(Source& source) const; + double pixelScale(const Source& source) const; + bool selectedShape(const Source& source, scopeone::core::ImageSceneModel::Markup& shape) const; + Scope chooseScope(const Source& source, const QString& command) const; + // Makes a new image from the slices that a command works on + void present(const QList& frames, const QString& title); + using FrameOperation = std::function; + void apply(const QString& title, const FrameOperation& operation); + + void buildImageMenu(QMenu* menu); + void buildProcessMenu(QMenu* menu); + void buildAnalyzeMenu(QMenu* menu); + void addFilter(QMenu* menu, const QString& text, imageops::Filter filter, double radius); + void addMath(QMenu* menu, const QString& text, imageops::MathOperation operation, bool needsValue, + double value); + void addBinary(QMenu* menu, const QString& text, imageops::BinaryOperation operation); + + void duplicateImage(); + void cropImage(); + void scaleImage(); + void rotateImage(); + void thresholdImage(); + void zProject(); + void makeSubstack(); + void makeMontage(); + void plotZProfile(); + void showOrthogonalViews(); + void subtractBackground(); + void unsharpMask(); + void calculate(); + void measure(); + void measureShapes(const QList& shapes, bool allSlices); + + scopeone::core::ScopeOneCore* m_core{nullptr}; + ImageWorkspace* m_workspace{nullptr}; + QWidget* m_window{nullptr}; + Presenter m_presenter; + QPointer m_results; + QPointer m_roiManager; + QPointer m_profile; + int m_resultCount{0}; + }; +} diff --git a/src/ImageOps.cpp b/src/ImageOps.cpp new file mode 100644 index 0000000..129c6b2 --- /dev/null +++ b/src/ImageOps.cpp @@ -0,0 +1,1205 @@ +#include "ImageOps.h" + +#include + +#include +#include + +namespace scopeone::ui::imageops +{ + using scopeone::core::ImagePixelFormat; + + namespace + { + // Offsets of a disc around a pixel, and for a ball the height of the ball above each + struct Offset + { + int dx; + int dy; + float height; + }; + + std::vector discOffsets(double radius) + { + std::vector offsets; + const int reach = static_cast(std::floor(radius)); + const double limit = radius * radius + 1.0; + for (int dy = -reach; dy <= reach; ++dy) + { + for (int dx = -reach; dx <= reach; ++dx) + { + if (dx * dx + dy * dy <= limit - 1.0 + 0.5) + { + offsets.push_back({dx, dy, 0.0f}); + } + } + } + if (offsets.empty()) + { + offsets.push_back({0, 0, 0.0f}); + } + return offsets; + } + + std::vector ballOffsets(double radius) + { + std::vector offsets; + const int reach = static_cast(std::floor(radius)); + for (int dy = -reach; dy <= reach; ++dy) + { + for (int dx = -reach; dx <= reach; ++dx) + { + const double squared = radius * radius - dx * dx - dy * dy; + if (squared >= 0.0) + { + offsets.push_back({dx, dy, static_cast(std::sqrt(squared))}); + } + } + } + return offsets; + } + + inline int clampIndex(int value, int low, int high) + { + return value < low ? low : (value > high ? high : value); + } + + // Smallest or largest of the values under the offsets. The heights of a ball lower the + // values for the smallest and raise them for the largest. + std::vector morphology(const std::vector& source, + int width, + int height, + const std::vector& offsets, + bool minimum) + { + std::vector result(source.size()); + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + float best = minimum ? std::numeric_limits::max() + : std::numeric_limits::lowest(); + for (const Offset& offset : offsets) + { + const int sx = clampIndex(x + offset.dx, 0, width - 1); + const int sy = clampIndex(y + offset.dy, 0, height - 1); + const float value = source[static_cast(sy) * width + sx]; + best = minimum ? std::min(best, value - offset.height) + : std::max(best, value + offset.height); + } + result[static_cast(y) * width + x] = best; + } + } + return result; + } + + std::vector gaussianKernel(double sigma) + { + const int reach = std::max(1, static_cast(std::ceil(sigma * 3.0))); + std::vector kernel(static_cast(2 * reach + 1)); + float sum = 0.0f; + for (int i = -reach; i <= reach; ++i) + { + const float weight = static_cast(std::exp(-(i * i) / (2.0 * sigma * sigma))); + kernel[static_cast(i + reach)] = weight; + sum += weight; + } + for (float& weight : kernel) + { + weight /= sum; + } + return kernel; + } + + // Smooths along x and then along y with the same kernel, repeating the edge pixels + std::vector separableBlur(const std::vector& source, + int width, + int height, + const std::vector& kernel) + { + const int reach = static_cast(kernel.size() / 2); + std::vector horizontal(source.size()); + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + float sum = 0.0f; + for (int k = -reach; k <= reach; ++k) + { + sum += kernel[static_cast(k + reach)] + * source[static_cast(y) * width + clampIndex(x + k, 0, width - 1)]; + } + horizontal[static_cast(y) * width + x] = sum; + } + } + std::vector result(source.size()); + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + float sum = 0.0f; + for (int k = -reach; k <= reach; ++k) + { + sum += kernel[static_cast(k + reach)] + * horizontal[static_cast(clampIndex(y + k, 0, height - 1)) * width + x]; + } + result[static_cast(y) * width + x] = sum; + } + } + return result; + } + + std::vector boxKernel(double radius) + { + const int reach = std::max(1, static_cast(std::round(radius))); + return std::vector(static_cast(2 * reach + 1), 1.0f / (2 * reach + 1)); + } + + float sampleBilinear(const std::vector& source, int width, int height, double x, double y) + { + const double fx = std::clamp(x - 0.5, 0.0, static_cast(width - 1)); + const double fy = std::clamp(y - 0.5, 0.0, static_cast(height - 1)); + const int x0 = static_cast(std::floor(fx)); + const int y0 = static_cast(std::floor(fy)); + const int x1 = std::min(x0 + 1, width - 1); + const int y1 = std::min(y0 + 1, height - 1); + const double tx = fx - x0; + const double ty = fy - y0; + const auto at = [&](int ix, int iy) { return source[static_cast(iy) * width + ix]; }; + return static_cast((1 - ty) * ((1 - tx) * at(x0, y0) + tx * at(x1, y0)) + + ty * ((1 - tx) * at(x0, y1) + tx * at(x1, y1))); + } + + std::vector resampled(const std::vector& source, + int width, + int height, + int newWidth, + int newHeight, + bool smooth) + { + std::vector result(static_cast(newWidth) * newHeight); + for (int y = 0; y < newHeight; ++y) + { + const double sy = (y + 0.5) * height / newHeight; + for (int x = 0; x < newWidth; ++x) + { + const double sx = (x + 0.5) * width / newWidth; + result[static_cast(y) * newWidth + x] = + smooth ? sampleBilinear(source, width, height, sx, sy) + : source[static_cast(clampIndex(static_cast(sy), 0, height - 1)) * width + + clampIndex(static_cast(sx), 0, width - 1)]; + } + } + return result; + } + + // Histogram of the values of a frame, one bin for each possible value + std::vector histogramOf(const ImageFrame& frame) + { + std::vector histogram(static_cast(maxValue(frame)) + 1, 0); + for (int y = 0; y < frame.height; ++y) + { + for (int x = 0; x < frame.width; ++x) + { + ++histogram[static_cast(pixelAt(frame, x, y))]; + } + } + return histogram; + } + } + + bool isMono(const ImageFrame& frame) + { + return frame.isValid() && (frame.isMono8() || frame.isMono16()); + } + + int maxValue(const ImageFrame& frame) + { + return frame.isMono16() ? ((1 << std::clamp(frame.bitsPerSample, 1, 16)) - 1) : 255; + } + + QRect selectionBounds(const Markup& markup) + { + switch (markup.type) + { + case MarkupType::Line: + return QRect(markup.start, markup.end).normalized().adjusted(0, 0, 1, 1); + case MarkupType::Point: + return QRect(markup.start, QSize(1, 1)); + default: + return markup.rect.normalized(); + } + } + + double selectionPerimeter(const Markup& markup) + { + switch (markup.type) + { + case MarkupType::Line: + return std::hypot(static_cast(markup.end.x() - markup.start.x()), + static_cast(markup.end.y() - markup.start.y())); + case MarkupType::Point: + return 0.0; + case MarkupType::Rect: + { + const QRect shape = markup.rect.normalized(); + return 2.0 * (shape.width() + shape.height()); + } + case MarkupType::Ellipse: + { + // Ramanujan's approximation + const QRect shape = markup.rect.normalized(); + const double a = shape.width() / 2.0; + const double b = shape.height() / 2.0; + return 3.14159265358979323846 * (3.0 * (a + b) - std::sqrt((3.0 * a + b) * (a + 3.0 * b))); + } + case MarkupType::Polygon: + { + double length = 0.0; + for (int index = 0; index < markup.points.size(); ++index) + { + const QPoint current = markup.points.at(index); + const QPoint next = markup.points.at((index + 1) % markup.points.size()); + length += std::hypot(static_cast(next.x() - current.x()), + static_cast(next.y() - current.y())); + } + return length; + } + } + return 0.0; + } + + Measurement measure(const ImageFrame& frame, const Markup& markup, double scale) + { + Measurement result; + if (!isMono(frame)) + { + return result; + } + const double unit = scale > 0.0 ? scale : 1.0; + std::vector values; + double sum = 0.0; + double sum2 = 0.0; + double sum3 = 0.0; + double sum4 = 0.0; + double sumX = 0.0; + double sumY = 0.0; + int low = 0; + int high = 0; + visitPixels(frame, markup, [&](int x, int y, int value) + { + if (values.empty()) + { + low = high = value; + } + low = std::min(low, value); + high = std::max(high, value); + values.push_back(value); + const double v = value; + sum += v; + sum2 += v * v; + sum3 += v * v * v; + sum4 += v * v * v * v; + sumX += x + 0.5; + sumY += y + 0.5; + }); + result.pixelCount = static_cast(values.size()); + const QRect box = selectionBounds(markup); + result.boundsX = box.x() * unit; + result.boundsY = box.y() * unit; + result.boundsWidth = box.width() * unit; + result.boundsHeight = box.height() * unit; + result.perimeter = selectionPerimeter(markup) * unit; + if (values.empty()) + { + return result; + } + const double n = static_cast(values.size()); + result.area = n * unit * unit; + result.mean = sum / n; + result.stdDev = std::sqrt(std::max(0.0, sum2 / n - result.mean * result.mean)); + result.minValue = low; + result.maxValue = high; + result.centroidX = sumX / n * unit; + result.centroidY = sumY / n * unit; + result.rawIntegratedDensity = sum; + result.integratedDensity = result.area * result.mean; + const auto middle = values.begin() + static_cast(values.size() / 2); + std::nth_element(values.begin(), middle, values.end()); + result.median = *middle; + if (result.stdDev > 0.0) + { + const double mean = result.mean; + const double sd = result.stdDev; + result.skewness = ((sum3 - 3.0 * mean * sum2) / n + 2.0 * mean * mean * mean) / (sd * sd * sd); + result.kurtosis = (((sum4 - 4.0 * mean * sum3 + 6.0 * mean * mean * sum2) / n + - 3.0 * mean * mean * mean * mean) / (sd * sd * sd * sd)) - 3.0; + } + return result; + } + + ImageFrame blankFrame(const ImageFrame& like, int width, int height) + { + ImageFrame frame; + frame.cameraId = like.cameraId; + frame.width = width; + frame.height = height; + frame.pixelFormat = like.pixelFormat; + frame.bitsPerSample = like.bitsPerSample; + const int bytesPerPixel = like.isMono16() ? 2 : 1; + frame.stride = width * bytesPerPixel; + frame.frameIndex = like.frameIndex; + frame.timestampNs = like.timestampNs; + frame.sourceRoiX = 0; + frame.sourceRoiY = 0; + frame.sourceRoiWidth = width; + frame.sourceRoiHeight = height; + frame.bytes = QByteArray(static_cast(frame.stride) * height, 0); + return frame; + } + + std::vector toFloat(const ImageFrame& frame) + { + std::vector values(static_cast(frame.width) * frame.height); + for (int y = 0; y < frame.height; ++y) + { + for (int x = 0; x < frame.width; ++x) + { + values[static_cast(y) * frame.width + x] = static_cast(pixelAt(frame, x, y)); + } + } + return values; + } + + ImageFrame fromFloat(const std::vector& values, const ImageFrame& like, int width, int height) + { + ImageFrame frame = blankFrame(like, width, height); + const float limit = static_cast(maxValue(like)); + char* data = frame.bytes.data(); + for (int y = 0; y < height; ++y) + { + char* row = data + static_cast(y) * frame.stride; + for (int x = 0; x < width; ++x) + { + const float clamped = std::clamp(values[static_cast(y) * width + x], 0.0f, limit); + const int rounded = static_cast(std::lround(clamped)); + if (frame.isMono16()) + { + const quint16 value = static_cast(rounded); + std::memcpy(row + static_cast(x) * 2, &value, sizeof(value)); + } + else + { + row[x] = static_cast(rounded); + } + } + } + return frame; + } + + ImageFrame zProject(const QList& frames, int first, int last, Projection method) + { + first = std::clamp(first, 0, static_cast(frames.size()) - 1); + last = std::clamp(last, first, static_cast(frames.size()) - 1); + const ImageFrame& like = frames.at(first); + const size_t count = static_cast(like.width) * like.height; + const int slices = last - first + 1; + std::vector result(count, 0.0f); + if (method == Projection::Max || method == Projection::Min) + { + result = toFloat(like); + for (int index = first + 1; index <= last; ++index) + { + const std::vector other = toFloat(frames.at(index)); + for (size_t i = 0; i < count; ++i) + { + result[i] = method == Projection::Max ? std::max(result[i], other[i]) + : std::min(result[i], other[i]); + } + } + } + else if (method == Projection::Median) + { + std::vector> all; + for (int index = first; index <= last; ++index) + { + all.push_back(toFloat(frames.at(index))); + } + std::vector column(static_cast(slices)); + for (size_t i = 0; i < count; ++i) + { + for (int s = 0; s < slices; ++s) + { + column[static_cast(s)] = all[static_cast(s)][i]; + } + std::nth_element(column.begin(), column.begin() + slices / 2, column.end()); + result[i] = column[static_cast(slices / 2)]; + } + } + else + { + std::vector sum(count, 0.0); + std::vector sum2(count, 0.0); + for (int index = first; index <= last; ++index) + { + const std::vector other = toFloat(frames.at(index)); + for (size_t i = 0; i < count; ++i) + { + sum[i] += other[i]; + sum2[i] += static_cast(other[i]) * other[i]; + } + } + for (size_t i = 0; i < count; ++i) + { + if (method == Projection::Sum) + { + result[i] = static_cast(sum[i]); + } + else if (method == Projection::Average) + { + result[i] = static_cast(sum[i] / slices); + } + else + { + const double mean = sum[i] / slices; + result[i] = static_cast(std::sqrt(std::max(0.0, sum2[i] / slices - mean * mean))); + } + } + } + return fromFloat(result, like, like.width, like.height); + } + + QList substack(const QList& frames, int first, int last, int step) + { + QList result; + first = std::clamp(first, 0, static_cast(frames.size()) - 1); + last = std::clamp(last, first, static_cast(frames.size()) - 1); + for (int index = first; index <= last; index += std::max(1, step)) + { + result.append(frames.at(index)); + } + return result; + } + + ImageFrame montage(const QList& frames, int columns, double scale, int border) + { + columns = std::clamp(columns, 1, static_cast(frames.size())); + const int rows = (static_cast(frames.size()) + columns - 1) / columns; + const ImageFrame& like = frames.first(); + const int tileWidth = std::max(1, static_cast(std::lround(like.width * scale))); + const int tileHeight = std::max(1, static_cast(std::lround(like.height * scale))); + const int width = columns * tileWidth + (columns + 1) * border; + const int height = rows * tileHeight + (rows + 1) * border; + // Frames of the other format are not mixed in + ImageFrame result = blankFrame(like, width, height); + std::vector canvas(static_cast(width) * height, 0.0f); + for (int index = 0; index < frames.size(); ++index) + { + const ImageFrame& frame = frames.at(index); + if (frame.pixelFormat != like.pixelFormat) + { + continue; + } + const std::vector tile = resampled(toFloat(frame), frame.width, frame.height, tileWidth, tileHeight, true); + const int originX = border + (index % columns) * (tileWidth + border); + const int originY = border + (index / columns) * (tileHeight + border); + for (int y = 0; y < tileHeight; ++y) + { + for (int x = 0; x < tileWidth; ++x) + { + canvas[static_cast(originY + y) * width + originX + x] = + tile[static_cast(y) * tileWidth + x]; + } + } + } + return fromFloat(canvas, like, width, height); + } + + QVector zProfile(const QList& frames, const Markup* roi, ProfileStat stat) + { + QVector values; + values.reserve(frames.size()); + for (const ImageFrame& frame : frames) + { + double sum = 0.0; + double low = 0.0; + double high = 0.0; + qint64 count = 0; + const auto add = [&](int, int, int value) + { + if (count == 0) + { + low = high = value; + } + low = std::min(low, static_cast(value)); + high = std::max(high, static_cast(value)); + sum += value; + ++count; + }; + if (roi) + { + visitPixels(frame, *roi, add); + } + else + { + Markup whole; + whole.type = MarkupType::Rect; + whole.rect = QRect(0, 0, frame.width, frame.height); + visitPixels(frame, whole, add); + } + double value = 0.0; + if (count > 0) + { + switch (stat) + { + case ProfileStat::Mean: value = sum / count; break; + case ProfileStat::Sum: value = sum; break; + case ProfileStat::Min: value = low; break; + case ProfileStat::Max: value = high; break; + } + } + values.append(value); + } + return values; + } + + ImageFrame crop(const ImageFrame& frame, const QRect& area) + { + const QRect inside = area.normalized().intersected(QRect(0, 0, frame.width, frame.height)); + ImageFrame result = blankFrame(frame, inside.width(), inside.height()); + const int bytesPerPixel = frame.isMono16() ? 2 : 1; + for (int y = 0; y < inside.height(); ++y) + { + std::memcpy(result.bytes.data() + static_cast(y) * result.stride, + frame.bytes.constData() + static_cast(inside.y() + y) * frame.stride + + static_cast(inside.x()) * bytesPerPixel, + static_cast(inside.width()) * bytesPerPixel); + } + return result; + } + + ImageFrame flipHorizontal(const ImageFrame& frame) + { + ImageFrame result = blankFrame(frame, frame.width, frame.height); + const int bytesPerPixel = frame.isMono16() ? 2 : 1; + for (int y = 0; y < frame.height; ++y) + { + for (int x = 0; x < frame.width; ++x) + { + std::memcpy(result.bytes.data() + static_cast(y) * result.stride + + static_cast(x) * bytesPerPixel, + frame.bytes.constData() + static_cast(y) * frame.stride + + static_cast(frame.width - 1 - x) * bytesPerPixel, + static_cast(bytesPerPixel)); + } + } + return result; + } + + ImageFrame flipVertical(const ImageFrame& frame) + { + ImageFrame result = blankFrame(frame, frame.width, frame.height); + for (int y = 0; y < frame.height; ++y) + { + std::memcpy(result.bytes.data() + static_cast(y) * result.stride, + frame.bytes.constData() + static_cast(frame.height - 1 - y) * frame.stride, + static_cast(result.stride)); + } + return result; + } + + ImageFrame rotate90(const ImageFrame& frame, bool clockwise) + { + ImageFrame result = blankFrame(frame, frame.height, frame.width); + const int bytesPerPixel = frame.isMono16() ? 2 : 1; + for (int y = 0; y < frame.height; ++y) + { + for (int x = 0; x < frame.width; ++x) + { + const int targetX = clockwise ? frame.height - 1 - y : y; + const int targetY = clockwise ? x : frame.width - 1 - x; + std::memcpy(result.bytes.data() + static_cast(targetY) * result.stride + + static_cast(targetX) * bytesPerPixel, + frame.bytes.constData() + static_cast(y) * frame.stride + + static_cast(x) * bytesPerPixel, + static_cast(bytesPerPixel)); + } + } + return result; + } + + ImageFrame scaled(const ImageFrame& frame, double factorX, double factorY, bool smooth) + { + const int width = std::max(1, static_cast(std::lround(frame.width * factorX))); + const int height = std::max(1, static_cast(std::lround(frame.height * factorY))); + return fromFloat(resampled(toFloat(frame), frame.width, frame.height, width, height, smooth), + frame, width, height); + } + + ImageFrame rotated(const ImageFrame& frame, double degrees, bool smooth, bool grow) + { + const double radians = degrees * 3.14159265358979323846 / 180.0; + const double cosine = std::cos(radians); + const double sine = std::sin(radians); + int width = frame.width; + int height = frame.height; + if (grow) + { + width = static_cast(std::ceil(std::abs(frame.width * cosine) + std::abs(frame.height * sine))); + height = static_cast(std::ceil(std::abs(frame.width * sine) + std::abs(frame.height * cosine))); + } + const std::vector source = toFloat(frame); + std::vector result(static_cast(width) * height, 0.0f); + const double centerX = frame.width / 2.0; + const double centerY = frame.height / 2.0; + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + // Where the pixel comes from before the turn + const double dx = x + 0.5 - width / 2.0; + const double dy = y + 0.5 - height / 2.0; + const double sx = centerX + dx * cosine + dy * sine; + const double sy = centerY - dx * sine + dy * cosine; + if (sx < 0.0 || sy < 0.0 || sx >= frame.width || sy >= frame.height) + { + continue; + } + result[static_cast(y) * width + x] = + smooth ? sampleBilinear(source, frame.width, frame.height, sx, sy) + : source[static_cast(static_cast(sy)) * frame.width + static_cast(sx)]; + } + } + return fromFloat(result, frame, width, height); + } + + ImageFrame filtered(const ImageFrame& frame, Filter filter, double radius) + { + const int width = frame.width; + const int height = frame.height; + const std::vector source = toFloat(frame); + std::vector result; + switch (filter) + { + case Filter::Mean: + result = separableBlur(source, width, height, boxKernel(radius)); + break; + case Filter::Gaussian: + result = separableBlur(source, width, height, gaussianKernel(std::max(0.1, radius))); + break; + case Filter::Minimum: + result = morphology(source, width, height, discOffsets(radius), true); + break; + case Filter::Maximum: + result = morphology(source, width, height, discOffsets(radius), false); + break; + case Filter::Median: + { + const std::vector offsets = discOffsets(radius); + result.resize(source.size()); + std::vector window(offsets.size()); + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + for (size_t k = 0; k < offsets.size(); ++k) + { + window[k] = source[static_cast(clampIndex(y + offsets[k].dy, 0, height - 1)) * width + + clampIndex(x + offsets[k].dx, 0, width - 1)]; + } + std::nth_element(window.begin(), window.begin() + window.size() / 2, window.end()); + result[static_cast(y) * width + x] = window[window.size() / 2]; + } + } + break; + } + case Filter::Variance: + { + const std::vector offsets = discOffsets(radius); + result.resize(source.size()); + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + double sum = 0.0; + double sum2 = 0.0; + for (const Offset& offset : offsets) + { + const double value = source[static_cast(clampIndex(y + offset.dy, 0, height - 1)) * width + + clampIndex(x + offset.dx, 0, width - 1)]; + sum += value; + sum2 += value * value; + } + const double n = static_cast(offsets.size()); + result[static_cast(y) * width + x] = + static_cast(std::max(0.0, sum2 / n - (sum / n) * (sum / n))); + } + } + break; + } + } + return fromFloat(result, frame, width, height); + } + + ImageFrame unsharpMask(const ImageFrame& frame, double sigma, double weight) + { + const std::vector source = toFloat(frame); + const std::vector blurred = separableBlur(source, frame.width, frame.height, + gaussianKernel(std::max(0.1, sigma))); + std::vector result(source.size()); + for (size_t i = 0; i < source.size(); ++i) + { + result[i] = static_cast((source[i] - weight * blurred[i]) / (1.0 - weight)); + } + return fromFloat(result, frame, frame.width, frame.height); + } + + ImageFrame sharpened(const ImageFrame& frame) + { + const std::vector source = toFloat(frame); + std::vector result(source.size()); + const int width = frame.width; + const int height = frame.height; + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + float sum = 0.0f; + for (int dy = -1; dy <= 1; ++dy) + { + for (int dx = -1; dx <= 1; ++dx) + { + const float weight = (dx == 0 && dy == 0) ? 12.0f : -1.0f; + sum += weight * source[static_cast(clampIndex(y + dy, 0, height - 1)) * width + + clampIndex(x + dx, 0, width - 1)]; + } + } + result[static_cast(y) * width + x] = sum / 4.0f; + } + } + return fromFloat(result, frame, width, height); + } + + ImageFrame findEdges(const ImageFrame& frame) + { + const std::vector source = toFloat(frame); + std::vector result(source.size()); + const int width = frame.width; + const int height = frame.height; + const auto at = [&](int x, int y) + { + return source[static_cast(clampIndex(y, 0, height - 1)) * width + clampIndex(x, 0, width - 1)]; + }; + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + const float gx = at(x + 1, y - 1) + 2 * at(x + 1, y) + at(x + 1, y + 1) + - at(x - 1, y - 1) - 2 * at(x - 1, y) - at(x - 1, y + 1); + const float gy = at(x - 1, y + 1) + 2 * at(x, y + 1) + at(x + 1, y + 1) + - at(x - 1, y - 1) - 2 * at(x, y - 1) - at(x + 1, y - 1); + result[static_cast(y) * width + x] = std::sqrt(gx * gx + gy * gy); + } + } + return fromFloat(result, frame, width, height); + } + + ImageFrame subtractBackground(const ImageFrame& frame, double radius, bool lightBackground) + { + // The ball rolls on a smaller copy of the image, which keeps large balls affordable + const int shrink = radius <= 10.0 ? 1 : radius <= 30.0 ? 2 : radius <= 100.0 ? 4 : 8; + const int smallWidth = std::max(1, frame.width / shrink); + const int smallHeight = std::max(1, frame.height / shrink); + std::vector source = toFloat(frame); + if (lightBackground) + { + const float limit = static_cast(maxValue(frame)); + for (float& value : source) + { + value = limit - value; + } + } + const std::vector small = resampled(source, frame.width, frame.height, smallWidth, smallHeight, true); + const std::vector ball = ballOffsets(std::max(1.0, radius / shrink)); + // Rolling a ball under the surface is an opening: the smallest values first, then the largest + const std::vector opened = morphology(morphology(small, smallWidth, smallHeight, ball, true), + smallWidth, smallHeight, ball, false); + // The smoothing keeps the background from showing the blocks of the smaller image + const std::vector smooth = separableBlur(opened, smallWidth, smallHeight, boxKernel(1.0)); + const std::vector background = resampled(smooth, smallWidth, smallHeight, frame.width, frame.height, true); + std::vector result(source.size()); + for (size_t i = 0; i < source.size(); ++i) + { + result[i] = source[i] - std::min(source[i], background[i]); + } + if (lightBackground) + { + const float limit = static_cast(maxValue(frame)); + for (float& value : result) + { + value = limit - value; + } + } + return fromFloat(result, frame, frame.width, frame.height); + } + + ImageFrame math(const ImageFrame& frame, MathOperation operation, double value) + { + std::vector values = toFloat(frame); + const float limit = static_cast(maxValue(frame)); + for (float& pixel : values) + { + switch (operation) + { + case MathOperation::Add: pixel += static_cast(value); break; + case MathOperation::Subtract: pixel -= static_cast(value); break; + case MathOperation::Multiply: pixel *= static_cast(value); break; + case MathOperation::Divide: pixel = value != 0.0 ? pixel / static_cast(value) : limit; break; + case MathOperation::Min: pixel = std::max(pixel, static_cast(value)); break; + case MathOperation::Max: pixel = std::min(pixel, static_cast(value)); break; + case MathOperation::Gamma: + pixel = static_cast(std::pow(pixel / limit, value) * limit); + break; + case MathOperation::Log: + pixel = static_cast(pixel > 0.0f ? std::log(pixel) * (limit / std::log(limit)) : 0.0); + break; + case MathOperation::Exp: + pixel = static_cast(std::exp(pixel * (std::log(limit) / limit))); + break; + case MathOperation::Square: pixel = pixel * pixel; break; + case MathOperation::Sqrt: pixel = std::sqrt(pixel); break; + case MathOperation::Abs: pixel = std::abs(pixel); break; + } + } + return fromFloat(values, frame, frame.width, frame.height); + } + + ImageFrame calculate(const ImageFrame& first, const ImageFrame& second, CalculatorOperation operation) + { + // Where the images differ in size only the part they share is used + const int width = std::min(first.width, second.width); + const int height = std::min(first.height, second.height); + std::vector values(static_cast(width) * height); + const float limit = static_cast(maxValue(first)); + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + const float a = static_cast(pixelAt(first, x, y)); + const float b = static_cast(pixelAt(second, x, y)); + float r = 0.0f; + switch (operation) + { + case CalculatorOperation::Add: r = a + b; break; + case CalculatorOperation::Subtract: r = a - b; break; + case CalculatorOperation::Multiply: r = a * b; break; + case CalculatorOperation::Divide: r = b != 0.0f ? a / b : limit; break; + case CalculatorOperation::And: r = static_cast(static_cast(a) & static_cast(b)); break; + case CalculatorOperation::Or: r = static_cast(static_cast(a) | static_cast(b)); break; + case CalculatorOperation::Xor: r = static_cast(static_cast(a) ^ static_cast(b)); break; + case CalculatorOperation::Min: r = std::min(a, b); break; + case CalculatorOperation::Max: r = std::max(a, b); break; + case CalculatorOperation::Average: r = (a + b) / 2.0f; break; + case CalculatorOperation::Difference: r = std::abs(a - b); break; + } + values[static_cast(y) * width + x] = r; + } + } + return fromFloat(values, first, width, height); + } + + int autoThreshold(const ImageFrame& frame, ThresholdMethod method) + { + const std::vector histogram = histogramOf(frame); + const int bins = static_cast(histogram.size()); + qint64 total = 0; + double weighted = 0.0; + for (int i = 0; i < bins; ++i) + { + total += histogram[static_cast(i)]; + weighted += static_cast(i) * histogram[static_cast(i)]; + } + if (total == 0) + { + return 0; + } + switch (method) + { + case ThresholdMethod::Mean: + return static_cast(weighted / total); + case ThresholdMethod::Otsu: + { + double best = -1.0; + int threshold = 0; + double sumBelow = 0.0; + qint64 countBelow = 0; + for (int t = 0; t < bins; ++t) + { + countBelow += histogram[static_cast(t)]; + sumBelow += static_cast(t) * histogram[static_cast(t)]; + const qint64 countAbove = total - countBelow; + if (countBelow == 0 || countAbove == 0) + { + continue; + } + const double meanBelow = sumBelow / countBelow; + const double meanAbove = (weighted - sumBelow) / countAbove; + const double between = static_cast(countBelow) * countAbove + * (meanBelow - meanAbove) * (meanBelow - meanAbove); + if (between > best) + { + best = between; + threshold = t; + } + } + return threshold; + } + case ThresholdMethod::IsoData: + { + int threshold = static_cast(weighted / total); + for (int iteration = 0; iteration < 1000; ++iteration) + { + double sumBelow = 0.0; + double sumAbove = 0.0; + qint64 countBelow = 0; + qint64 countAbove = 0; + for (int i = 0; i < bins; ++i) + { + if (i <= threshold) + { + sumBelow += static_cast(i) * histogram[static_cast(i)]; + countBelow += histogram[static_cast(i)]; + } + else + { + sumAbove += static_cast(i) * histogram[static_cast(i)]; + countAbove += histogram[static_cast(i)]; + } + } + if (countBelow == 0 || countAbove == 0) + { + break; + } + const int next = static_cast((sumBelow / countBelow + sumAbove / countAbove) / 2.0); + if (next == threshold) + { + break; + } + threshold = next; + } + return threshold; + } + case ThresholdMethod::Triangle: + { + int low = 0; + int high = bins - 1; + int peak = 0; + for (int i = 0; i < bins; ++i) + { + if (histogram[static_cast(i)] > 0) + { + low = i; + break; + } + } + for (int i = bins - 1; i >= 0; --i) + { + if (histogram[static_cast(i)] > 0) + { + high = i; + break; + } + } + for (int i = 0; i < bins; ++i) + { + if (histogram[static_cast(i)] > histogram[static_cast(peak)]) + { + peak = i; + } + } + // The line is drawn from the peak to the end that is farther away + const bool flipped = (peak - low) < (high - peak); + const int end = flipped ? high : low; + const double dx = peak - end; + const double dy = static_cast(histogram[static_cast(peak)]); + const double norm = std::hypot(dx, dy); + double best = -1.0; + int threshold = peak; + const int step = flipped ? 1 : -1; + for (int i = peak; i != end + step * (flipped ? 1 : 1) && i >= 0 && i < bins; i += step) + { + const double distance = std::abs(dy * (i - end) - dx * static_cast(histogram[static_cast(i)])) / norm; + if (distance > best) + { + best = distance; + threshold = i; + } + } + return threshold; + } + } + return 0; + } + + ImageFrame thresholded(const ImageFrame& frame, double low, double high) + { + ImageFrame like = frame; + like.pixelFormat = ImagePixelFormat::Mono8; + like.bitsPerSample = 8; + ImageFrame result = blankFrame(like, frame.width, frame.height); + for (int y = 0; y < frame.height; ++y) + { + for (int x = 0; x < frame.width; ++x) + { + const int value = pixelAt(frame, x, y); + result.bytes.data()[static_cast(y) * result.stride + x] = + (value >= low && value <= high) ? static_cast(255) : 0; + } + } + return result; + } + + ImageFrame binary(const ImageFrame& frame, BinaryOperation operation, int iterations) + { + const int width = frame.width; + const int height = frame.height; + std::vector mask(static_cast(width) * height); + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + mask[static_cast(y) * width + x] = pixelAt(frame, x, y) > 0 ? 1 : 0; + } + } + const auto neighbours = [&](const std::vector& m, int x, int y, bool wantForeground, bool eight) + { + for (int dy = -1; dy <= 1; ++dy) + { + for (int dx = -1; dx <= 1; ++dx) + { + if ((dx == 0 && dy == 0) || (!eight && dx != 0 && dy != 0)) + { + continue; + } + const int nx = x + dx; + const int ny = y + dy; + // Outside the image counts as background + const bool foreground = nx >= 0 && ny >= 0 && nx < width && ny < height + && m[static_cast(ny) * width + nx] != 0; + if (foreground == wantForeground) + { + return true; + } + } + } + return false; + }; + const auto erode = [&](std::vector& m) + { + std::vector next(m.size()); + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + next[static_cast(y) * width + x] = + (m[static_cast(y) * width + x] != 0 && !neighbours(m, x, y, false, true)) ? 1 : 0; + } + } + m = std::move(next); + }; + const auto dilate = [&](std::vector& m) + { + std::vector next(m.size()); + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + next[static_cast(y) * width + x] = + (m[static_cast(y) * width + x] != 0 || neighbours(m, x, y, true, true)) ? 1 : 0; + } + } + m = std::move(next); + }; + iterations = std::max(1, iterations); + switch (operation) + { + case BinaryOperation::Erode: + for (int i = 0; i < iterations; ++i) erode(mask); + break; + case BinaryOperation::Dilate: + for (int i = 0; i < iterations; ++i) dilate(mask); + break; + case BinaryOperation::Open: + for (int i = 0; i < iterations; ++i) erode(mask); + for (int i = 0; i < iterations; ++i) dilate(mask); + break; + case BinaryOperation::Close: + for (int i = 0; i < iterations; ++i) dilate(mask); + for (int i = 0; i < iterations; ++i) erode(mask); + break; + case BinaryOperation::Invert: + for (unsigned char& value : mask) value = value ? 0 : 1; + break; + case BinaryOperation::Outline: + { + std::vector next(mask.size(), 0); + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + next[static_cast(y) * width + x] = + (mask[static_cast(y) * width + x] != 0 && neighbours(mask, x, y, false, false)) ? 1 : 0; + } + } + mask = std::move(next); + break; + } + case BinaryOperation::FillHoles: + { + // Background that can be reached from the border stays, the rest is a hole + std::vector outside(mask.size(), 0); + std::vector stack; + const auto push = [&](int x, int y) + { + const size_t index = static_cast(y) * width + x; + if (mask[index] == 0 && outside[index] == 0) + { + outside[index] = 1; + stack.push_back(y * width + x); + } + }; + for (int x = 0; x < width; ++x) { push(x, 0); push(x, height - 1); } + for (int y = 0; y < height; ++y) { push(0, y); push(width - 1, y); } + while (!stack.empty()) + { + const int index = stack.back(); + stack.pop_back(); + const int x = index % width; + const int y = index / width; + if (x > 0) push(x - 1, y); + if (x + 1 < width) push(x + 1, y); + if (y > 0) push(x, y - 1); + if (y + 1 < height) push(x, y + 1); + } + for (size_t i = 0; i < mask.size(); ++i) + { + mask[i] = outside[i] ? 0 : 1; + } + break; + } + } + ImageFrame like = frame; + like.pixelFormat = ImagePixelFormat::Mono8; + like.bitsPerSample = 8; + ImageFrame result = blankFrame(like, width, height); + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + result.bytes.data()[static_cast(y) * result.stride + x] = + mask[static_cast(y) * width + x] ? static_cast(255) : 0; + } + } + return result; + } +} diff --git a/src/ImageOps.h b/src/ImageOps.h new file mode 100644 index 0000000..b50ed59 --- /dev/null +++ b/src/ImageOps.h @@ -0,0 +1,218 @@ +#pragma once + +#include "scopeone/ImageFrame.h" +#include "scopeone/ImageSceneModel.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +// Operations on the images of ScopeOne in the manner of ImageJ: measurements of selections, +// stack tools, transforms, filters, math and binary operations. They work on mono 8 bit and +// 16 bit frames and make new frames, so the images they start from stay as they are. +namespace scopeone::ui::imageops +{ + using scopeone::core::ImageFrame; + using Markup = scopeone::core::ImageSceneModel::Markup; + using MarkupType = scopeone::core::DocumentMarkupType; + + bool isMono(const ImageFrame& frame); + int maxValue(const ImageFrame& frame); + + inline int pixelAt(const ImageFrame& frame, int x, int y) + { + const char* row = frame.bytes.constData() + static_cast(y) * frame.stride; + if (frame.isMono16()) + { + quint16 value = 0; + std::memcpy(&value, row + static_cast(x) * 2, sizeof(value)); + return value; + } + return static_cast(row[x]); + } + + // ---- selections + + // Calls visit(x, y, value) for every pixel of the frame that a selection covers. A pixel + // belongs to a rectangle, ellipse or polygon when its center is inside. A line covers the + // pixels on it and a point its own pixel. + template + void visitPixels(const ImageFrame& frame, const Markup& markup, Visitor&& visit) + { + const QRect bounds(0, 0, frame.width, frame.height); + switch (markup.type) + { + case MarkupType::Line: + { + const QPoint delta = markup.end - markup.start; + const int steps = std::max(std::abs(delta.x()), std::abs(delta.y())); + for (int step = 0; step <= steps; ++step) + { + const double t = steps == 0 ? 0.0 : static_cast(step) / steps; + const int x = static_cast(std::lround(markup.start.x() + t * delta.x())); + const int y = static_cast(std::lround(markup.start.y() + t * delta.y())); + if (bounds.contains(x, y)) + { + visit(x, y, pixelAt(frame, x, y)); + } + } + break; + } + case MarkupType::Point: + if (bounds.contains(markup.start)) + { + visit(markup.start.x(), markup.start.y(), pixelAt(frame, markup.start.x(), markup.start.y())); + } + break; + case MarkupType::Ellipse: + { + const QRect shape = markup.rect.normalized(); + const double radiusX = shape.width() / 2.0; + const double radiusY = shape.height() / 2.0; + const double centerX = shape.x() + radiusX; + const double centerY = shape.y() + radiusY; + const QRect area = shape.intersected(bounds); + for (int y = area.top(); y <= area.bottom(); ++y) + { + for (int x = area.left(); x <= area.right(); ++x) + { + const double dx = (x + 0.5 - centerX) / radiusX; + const double dy = (y + 0.5 - centerY) / radiusY; + if (dx * dx + dy * dy <= 1.0) + { + visit(x, y, pixelAt(frame, x, y)); + } + } + } + break; + } + case MarkupType::Polygon: + { + QPolygonF polygon; + for (const QPoint& corner : markup.points) + { + polygon << QPointF(corner); + } + const QRect area = polygon.boundingRect().toAlignedRect().intersected(bounds); + for (int y = area.top(); y <= area.bottom(); ++y) + { + for (int x = area.left(); x <= area.right(); ++x) + { + if (polygon.containsPoint(QPointF(x + 0.5, y + 0.5), Qt::OddEvenFill)) + { + visit(x, y, pixelAt(frame, x, y)); + } + } + } + break; + } + case MarkupType::Rect: + { + const QRect area = markup.rect.normalized().intersected(bounds); + for (int y = area.top(); y <= area.bottom(); ++y) + { + for (int x = area.left(); x <= area.right(); ++x) + { + visit(x, y, pixelAt(frame, x, y)); + } + } + break; + } + } + } + + // The smallest rectangle that holds the selection, in pixels + QRect selectionBounds(const Markup& markup); + // Length of the outline, or of the line, in pixels + double selectionPerimeter(const Markup& markup); + + // What ImageJ measures of a selection. Lengths and areas follow scale, the size of a pixel + // in the unit of the image, and are in pixels when it is zero. + struct Measurement + { + qint64 pixelCount{0}; + double area{0.0}; + double mean{0.0}; + double stdDev{0.0}; + double median{0.0}; + double minValue{0.0}; + double maxValue{0.0}; + double centroidX{0.0}; + double centroidY{0.0}; + double perimeter{0.0}; + double boundsX{0.0}; + double boundsY{0.0}; + double boundsWidth{0.0}; + double boundsHeight{0.0}; + double integratedDensity{0.0}; + double rawIntegratedDensity{0.0}; + double skewness{0.0}; + double kurtosis{0.0}; + }; + Measurement measure(const ImageFrame& frame, const Markup& markup, double scale); + + // ---- frames + + // A frame like the given one in format and identity, with the given size and zeroed pixels + ImageFrame blankFrame(const ImageFrame& like, int width, int height); + std::vector toFloat(const ImageFrame& frame); + // Rounds and clamps to the range of the frame. The result has the format of like. + ImageFrame fromFloat(const std::vector& values, const ImageFrame& like, int width, int height); + + // ---- stacks + + enum class Projection { Max, Average, Sum, Min, StdDev, Median }; + ImageFrame zProject(const QList& frames, int first, int last, Projection method); + QList substack(const QList& frames, int first, int last, int step); + ImageFrame montage(const QList& frames, int columns, double scale, int border); + + enum class ProfileStat { Mean, Sum, Min, Max }; + // One value per frame, over the selection or over the whole frame when roi is null + QVector zProfile(const QList& frames, const Markup* roi, ProfileStat stat); + + // ---- geometry + + ImageFrame crop(const ImageFrame& frame, const QRect& area); + ImageFrame flipHorizontal(const ImageFrame& frame); + ImageFrame flipVertical(const ImageFrame& frame); + ImageFrame rotate90(const ImageFrame& frame, bool clockwise); + ImageFrame scaled(const ImageFrame& frame, double factorX, double factorY, bool smooth); + // Turns the image by an angle in degrees, clockwise. With grow the image is made large + // enough to hold the turned image. + ImageFrame rotated(const ImageFrame& frame, double degrees, bool smooth, bool grow); + + // ---- filters + + enum class Filter { Mean, Median, Minimum, Maximum, Gaussian, Variance }; + ImageFrame filtered(const ImageFrame& frame, Filter filter, double radius); + ImageFrame unsharpMask(const ImageFrame& frame, double sigma, double weight); + ImageFrame sharpened(const ImageFrame& frame); + ImageFrame findEdges(const ImageFrame& frame); + // Takes away the slowly changing background with a rolling ball of the given radius + ImageFrame subtractBackground(const ImageFrame& frame, double radius, bool lightBackground); + + // ---- math + + enum class MathOperation { Add, Subtract, Multiply, Divide, Min, Max, Gamma, Log, Exp, Square, Sqrt, Abs }; + ImageFrame math(const ImageFrame& frame, MathOperation operation, double value); + + enum class CalculatorOperation { Add, Subtract, Multiply, Divide, And, Or, Xor, Min, Max, Average, Difference }; + ImageFrame calculate(const ImageFrame& first, const ImageFrame& second, CalculatorOperation operation); + + // ---- thresholds and binary images + + enum class ThresholdMethod { IsoData, Otsu, Mean, Triangle }; + int autoThreshold(const ImageFrame& frame, ThresholdMethod method); + // Pixels from low to high become 255 and the others 0, in an 8 bit frame + ImageFrame thresholded(const ImageFrame& frame, double low, double high); + + enum class BinaryOperation { Erode, Dilate, Open, Close, FillHoles, Outline, Invert }; + ImageFrame binary(const ImageFrame& frame, BinaryOperation operation, int iterations); +} diff --git a/src/ImageWorkspace.cpp b/src/ImageWorkspace.cpp index f0c862f..e534a06 100644 --- a/src/ImageWorkspace.cpp +++ b/src/ImageWorkspace.cpp @@ -667,6 +667,13 @@ namespace scopeone::ui return result; } + std::shared_ptr ImageWorkspace::documentSession( + const QString& documentId) const + { + const Document* document = findDocument(documentId); + return document ? document->session : nullptr; + } + ImageDocumentInfo ImageWorkspace::document(const QString& documentId) const { const Document* document = findDocument(documentId); diff --git a/src/ImageWorkspace.h b/src/ImageWorkspace.h index 0bfa2ad..8a5912a 100644 --- a/src/ImageWorkspace.h +++ b/src/ImageWorkspace.h @@ -62,6 +62,9 @@ namespace scopeone::ui const QString& cameraId = QString()); QString openFrame(const scopeone::core::ImageFrame& frame, const QString& title); + // The session behind an image document, which holds all of its frames + std::shared_ptr documentSession( + const QString& documentId = QString()) const; QList documents() const; ImageDocumentInfo document(const QString& documentId = QString()) const; QString activeDocumentId() const; diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index edd6f6f..32ef891 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -1,4 +1,5 @@ #include "MainWindow.h" +#include "ImageAnalysis.h" #include "UpdateChecker.h" #include "scopeone/ScopeOneCore.h" @@ -1493,6 +1494,12 @@ namespace scopeone::ui addAction(autoContrastAction); auto* toolsMenu = menuBar()->addMenu(tr("&Tools")); + // Image, Process and Analyze come before Tools, as in ImageJ + m_imageAnalysis = new ImageAnalysis(m_scopeonecore, m_imageWorkspace, this, this); + m_imageAnalysis->setPresenter([this]( + const std::shared_ptr& session, + const QString& title) { presentSession(session, title); }); + m_imageAnalysis->addMenus(menuBar(), toolsMenu->menuAction()); m_toolRegistry->populateMenu(toolsMenu, this); toolsMenu->addSeparator(); auto* pluginManagerAction = toolsMenu->addAction(tr("Plugin &Manager...")); diff --git a/src/MainWindow.h b/src/MainWindow.h index 8b57f8f..c455bc2 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -22,6 +22,7 @@ class QTimer; namespace scopeone::ui { + class ImageAnalysis; class UpdateChecker; } @@ -185,6 +186,7 @@ namespace scopeone::ui bool m_statusIsError{false}; QLabel* m_statusUpdateLabel{nullptr}; QMenu* m_workspacesMenu{nullptr}; + ImageAnalysis* m_imageAnalysis{nullptr}; UpdateChecker* m_updateChecker{nullptr}; bool m_updateCheckUserInitiated{false}; QLabel* m_statusCursorLabel{nullptr}; diff --git a/src/OrthogonalViewsDialog.cpp b/src/OrthogonalViewsDialog.cpp new file mode 100644 index 0000000..96fcf70 --- /dev/null +++ b/src/OrthogonalViewsDialog.cpp @@ -0,0 +1,208 @@ +#include "OrthogonalViewsDialog.h" + +#include "ImageOps.h" + +#include +#include +#include +#include +#include + +#include + +namespace scopeone::ui +{ + // Shows a gray image fitted into the widget, with a cross hair at a pixel. A click moves it. + class GrayCanvas : public QWidget + { + Q_OBJECT + + public: + explicit GrayCanvas(QWidget* parent = nullptr) : QWidget(parent) + { + setMinimumSize(200, 120); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + } + void setImage(const QImage& image, const QPoint& cross) + { + m_image = image; + m_cross = cross; + update(); + } + + signals: + void clicked(const QPoint& imagePosition); + + protected: + void paintEvent(QPaintEvent*) override + { + QPainter painter(this); + painter.fillRect(rect(), Qt::black); + if (m_image.isNull()) + { + return; + } + const QRect target = fitted(); + painter.drawImage(target, m_image); + painter.setPen(QPen(QColor(255, 210, 0), 1)); + const double scale = static_cast(target.width()) / m_image.width(); + const int x = target.left() + static_cast((m_cross.x() + 0.5) * scale); + const int y = target.top() + static_cast((m_cross.y() + 0.5) * scale); + painter.drawLine(x, target.top(), x, target.bottom()); + painter.drawLine(target.left(), y, target.right(), y); + } + void mousePressEvent(QMouseEvent* event) override + { + mouseMoveEvent(event); + } + void mouseMoveEvent(QMouseEvent* event) override + { + if (m_image.isNull() || !(event->buttons() & Qt::LeftButton)) + { + return; + } + const QRect target = fitted(); + const double scale = static_cast(m_image.width()) / target.width(); + const int x = std::clamp(static_cast((event->pos().x() - target.left()) * scale), 0, m_image.width() - 1); + const int y = std::clamp(static_cast((event->pos().y() - target.top()) * scale), 0, m_image.height() - 1); + emit clicked(QPoint(x, y)); + } + + private: + QRect fitted() const + { + const double scale = std::min(static_cast(width()) / m_image.width(), + static_cast(height()) / m_image.height()); + const int w = std::max(1, static_cast(m_image.width() * scale)); + const int h = std::max(1, static_cast(m_image.height() * scale)); + return QRect((width() - w) / 2, (height() - h) / 2, w, h); + } + + QImage m_image; + QPoint m_cross; + }; + + OrthogonalViewsDialog::OrthogonalViewsDialog(const QList& frames, int slice, + QWidget* parent) + : QDialog(parent) + , m_frames(frames) + , m_z(std::clamp(slice, 0, static_cast(frames.size()) - 1)) + { + setWindowTitle(tr("Orthogonal Views")); + resize(760, 640); + const scopeone::core::ImageFrame& first = m_frames.first(); + m_x = first.width / 2; + m_y = first.height / 2; + + // One display range for the whole stack, so the cuts have the same brightness as the slices + bool seen = false; + for (const scopeone::core::ImageFrame& frame : m_frames) + { + const int step = std::max(1, std::max(frame.width, frame.height) / 128); + for (int y = 0; y < frame.height; y += step) + { + for (int x = 0; x < frame.width; x += step) + { + const double value = imageops::pixelAt(frame, x, y); + m_low = seen ? std::min(m_low, value) : value; + m_high = seen ? std::max(m_high, value) : value; + seen = true; + } + } + } + if (m_high <= m_low) + { + m_high = m_low + 1.0; + } + + auto* layout = new QGridLayout(this); + m_xy = new GrayCanvas(this); + m_yz = new GrayCanvas(this); + m_xz = new GrayCanvas(this); + m_slider = new QSlider(Qt::Horizontal, this); + m_slider->setRange(1, static_cast(m_frames.size())); + m_slider->setValue(m_z + 1); + layout->addWidget(m_xy, 0, 0); + layout->addWidget(m_yz, 0, 1); + layout->addWidget(m_xz, 1, 0); + layout->addWidget(new QLabel(tr("Slice"), this), 1, 1, Qt::AlignTop); + layout->addWidget(m_slider, 2, 0, 1, 2); + layout->setColumnStretch(0, 3); + layout->setColumnStretch(1, 1); + layout->setRowStretch(0, 3); + layout->setRowStretch(1, 1); + + connect(m_slider, &QSlider::valueChanged, this, [this](int value) + { + m_z = value - 1; + refresh(); + }); + connect(m_xy, &GrayCanvas::clicked, this, [this](const QPoint& position) + { + m_x = position.x(); + m_y = position.y(); + refresh(); + }); + // A click in a cut moves the cross hair in the other directions + connect(m_xz, &GrayCanvas::clicked, this, [this](const QPoint& position) + { + m_x = position.x(); + m_z = position.y(); + m_slider->setValue(m_z + 1); + refresh(); + }); + connect(m_yz, &GrayCanvas::clicked, this, [this](const QPoint& position) + { + m_z = position.x(); + m_y = position.y(); + m_slider->setValue(m_z + 1); + refresh(); + }); + refresh(); + } + + void OrthogonalViewsDialog::refresh() + { + const scopeone::core::ImageFrame& first = m_frames.first(); + const int width = first.width; + const int height = first.height; + const int depth = static_cast(m_frames.size()); + const auto gray = [this](int value) + { + return static_cast(std::clamp((value - m_low) / (m_high - m_low), 0.0, 1.0) * 255.0); + }; + + QImage xy(width, height, QImage::Format_Grayscale8); + const scopeone::core::ImageFrame& slice = m_frames.at(m_z); + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + xy.scanLine(y)[x] = gray(imageops::pixelAt(slice, x, y)); + } + } + // Slices on the vertical axis, along the row of the cross hair + QImage xz(width, depth, QImage::Format_Grayscale8); + for (int z = 0; z < depth; ++z) + { + for (int x = 0; x < width; ++x) + { + xz.scanLine(z)[x] = gray(imageops::pixelAt(m_frames.at(z), x, m_y)); + } + } + // Slices on the horizontal axis, along the column of the cross hair + QImage yz(depth, height, QImage::Format_Grayscale8); + for (int y = 0; y < height; ++y) + { + for (int z = 0; z < depth; ++z) + { + yz.scanLine(y)[z] = gray(imageops::pixelAt(m_frames.at(z), m_x, y)); + } + } + m_xy->setImage(xy, QPoint(m_x, m_y)); + m_xz->setImage(xz, QPoint(m_x, m_z)); + m_yz->setImage(yz, QPoint(m_z, m_y)); + } +} + +#include "OrthogonalViewsDialog.moc" diff --git a/src/OrthogonalViewsDialog.h b/src/OrthogonalViewsDialog.h new file mode 100644 index 0000000..0aa1233 --- /dev/null +++ b/src/OrthogonalViewsDialog.h @@ -0,0 +1,38 @@ +#pragma once + +#include "scopeone/ImageFrame.h" + +#include +#include +#include + +class QSlider; + +namespace scopeone::ui +{ + class GrayCanvas; + + // The slice of a stack on one side and the cuts through the stack at the cross hair on the + // other two sides, like the Orthogonal Views of ImageJ + class OrthogonalViewsDialog : public QDialog + { + Q_OBJECT + + public: + OrthogonalViewsDialog(const QList& frames, int slice, QWidget* parent = nullptr); + + private: + void refresh(); + + QList m_frames; + int m_x{0}; + int m_y{0}; + int m_z{0}; + double m_low{0.0}; + double m_high{1.0}; + GrayCanvas* m_xy{nullptr}; + GrayCanvas* m_xz{nullptr}; + GrayCanvas* m_yz{nullptr}; + QSlider* m_slider{nullptr}; + }; +} diff --git a/src/ParameterDialog.cpp b/src/ParameterDialog.cpp new file mode 100644 index 0000000..da6408a --- /dev/null +++ b/src/ParameterDialog.cpp @@ -0,0 +1,97 @@ +#include "ParameterDialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace scopeone::ui +{ + bool askParameters(QWidget* parent, const QString& title, QList& fields) + { + QDialog dialog(parent); + dialog.setWindowTitle(title); + auto* layout = new QVBoxLayout(&dialog); + auto* form = new QFormLayout(); + layout->addLayout(form); + + QList editors; + for (const ParameterField& field : fields) + { + QWidget* editor = nullptr; + switch (field.kind) + { + case ParameterField::Kind::Integer: + { + auto* spin = new QSpinBox(&dialog); + spin->setRange(static_cast(field.minimum), static_cast(field.maximum)); + spin->setValue(static_cast(field.value)); + spin->setSuffix(field.suffix); + editor = spin; + break; + } + case ParameterField::Kind::Real: + { + auto* spin = new QDoubleSpinBox(&dialog); + spin->setDecimals(field.decimals); + spin->setRange(field.minimum, field.maximum); + spin->setValue(field.value); + spin->setSuffix(field.suffix); + editor = spin; + break; + } + case ParameterField::Kind::Choice: + { + auto* combo = new QComboBox(&dialog); + combo->addItems(field.choices); + combo->setCurrentIndex(field.choice); + editor = combo; + break; + } + case ParameterField::Kind::Check: + { + auto* check = new QCheckBox(&dialog); + check->setChecked(field.checked); + editor = check; + break; + } + } + form->addRow(field.label, editor); + editors.append(editor); + } + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog); + QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + QObject::connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + layout->addWidget(buttons); + + if (dialog.exec() != QDialog::Accepted) + { + return false; + } + for (qsizetype index = 0; index < fields.size(); ++index) + { + ParameterField& field = fields[index]; + switch (field.kind) + { + case ParameterField::Kind::Integer: + field.value = static_cast(editors.at(index))->value(); + break; + case ParameterField::Kind::Real: + field.value = static_cast(editors.at(index))->value(); + break; + case ParameterField::Kind::Choice: + field.choice = static_cast(editors.at(index))->currentIndex(); + break; + case ParameterField::Kind::Check: + field.checked = static_cast(editors.at(index))->isChecked(); + break; + } + } + return true; + } +} diff --git a/src/ParameterDialog.h b/src/ParameterDialog.h new file mode 100644 index 0000000..dfe9b36 --- /dev/null +++ b/src/ParameterDialog.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include +#include + +namespace scopeone::ui +{ + // One entry of a small dialog that asks for the settings of an operation + struct ParameterField + { + enum class Kind { Integer, Real, Choice, Check }; + + Kind kind{Kind::Real}; + QString label; + double value{0.0}; + double minimum{-1.0e9}; + double maximum{1.0e9}; + int decimals{2}; + QString suffix; + QStringList choices; + int choice{0}; + bool checked{false}; + + static ParameterField integer(const QString& label, int value, int minimum, int maximum) + { + ParameterField field; + field.kind = Kind::Integer; + field.label = label; + field.value = value; + field.minimum = minimum; + field.maximum = maximum; + return field; + } + static ParameterField real(const QString& label, double value, double minimum, double maximum, int decimals = 2) + { + ParameterField field; + field.kind = Kind::Real; + field.label = label; + field.value = value; + field.minimum = minimum; + field.maximum = maximum; + field.decimals = decimals; + return field; + } + static ParameterField options(const QString& label, const QStringList& choices, int selected = 0) + { + ParameterField field; + field.kind = Kind::Choice; + field.label = label; + field.choices = choices; + field.choice = selected; + return field; + } + static ParameterField check(const QString& label, bool checked) + { + ParameterField field; + field.kind = Kind::Check; + field.label = label; + field.checked = checked; + return field; + } + }; + + // Shows the fields in a dialog. The values the user chose are written back to the fields. + // Returns false when the dialog was canceled. + bool askParameters(QWidget* parent, const QString& title, QList& fields); +} diff --git a/src/ProfilePlotWindow.cpp b/src/ProfilePlotWindow.cpp new file mode 100644 index 0000000..c93869e --- /dev/null +++ b/src/ProfilePlotWindow.cpp @@ -0,0 +1,116 @@ +#include "ProfilePlotWindow.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace scopeone::ui +{ + ProfilePlotWindow::ProfilePlotWindow(QWidget* parent) + : QWidget(parent, Qt::Window) + { + resize(560, 360); + auto* layout = new QVBoxLayout(this); + layout->addStretch(); + auto* buttons = new QHBoxLayout(); + auto* copyButton = new QPushButton(tr("Copy"), this); + auto* saveButton = new QPushButton(tr("Save as CSV..."), this); + buttons->addWidget(copyButton); + buttons->addWidget(saveButton); + buttons->addStretch(); + layout->addLayout(buttons); + connect(copyButton, &QPushButton::clicked, this, [this]() + { + QString text = m_xLabel + QLatin1Char('\t') + m_yLabel + QLatin1Char('\n'); + for (qsizetype i = 0; i < m_values.size(); ++i) + { + text += QString::number(i + 1) + QLatin1Char('\t') + QString::number(m_values.at(i), 'g', 8) + QLatin1Char('\n'); + } + QApplication::clipboard()->setText(text); + }); + connect(saveButton, &QPushButton::clicked, this, [this]() { saveCsv(); }); + } + + void ProfilePlotWindow::setProfile(const QString& title, const QString& xLabel, const QString& yLabel, + const QVector& values) + { + m_title = title; + m_xLabel = xLabel; + m_yLabel = yLabel; + m_values = values; + setWindowTitle(title); + update(); + } + + void ProfilePlotWindow::saveCsv() + { + const QString path = QFileDialog::getSaveFileName(this, tr("Save profile"), QStringLiteral("profile.csv"), + tr("CSV files (*.csv)")); + if (path.isEmpty()) + { + return; + } + QFile file(path); + if (file.open(QIODevice::WriteOnly | QIODevice::Text)) + { + QTextStream out(&file); + out << m_xLabel << ',' << m_yLabel << '\n'; + for (qsizetype i = 0; i < m_values.size(); ++i) + { + out << (i + 1) << ',' << QString::number(m_values.at(i), 'g', 8) << '\n'; + } + } + } + + void ProfilePlotWindow::paintEvent(QPaintEvent*) + { + QPainter painter(this); + const QPalette& colors = palette(); + painter.fillRect(rect(), colors.brush(QPalette::Base)); + painter.setRenderHint(QPainter::Antialiasing, true); + if (m_values.isEmpty()) + { + return; + } + const QRect plot = rect().adjusted(60, 28, -16, -80); + painter.setPen(colors.color(QPalette::Mid)); + painter.drawRect(plot); + + double low = *std::min_element(m_values.begin(), m_values.end()); + double high = *std::max_element(m_values.begin(), m_values.end()); + if (high - low < 1e-12) + { + high = low + 1.0; + } + painter.setPen(colors.color(QPalette::Text)); + painter.drawText(QRect(8, 4, width() - 16, 20), Qt::AlignLeft | Qt::AlignVCenter, m_title); + painter.drawText(QRect(0, plot.top() - 6, 56, 14), Qt::AlignRight, QString::number(high, 'g', 5)); + painter.drawText(QRect(0, plot.bottom() - 8, 56, 14), Qt::AlignRight, QString::number(low, 'g', 5)); + painter.drawText(QRect(plot.left(), plot.bottom() + 4, 60, 14), Qt::AlignLeft, QStringLiteral("1")); + painter.drawText(QRect(plot.right() - 60, plot.bottom() + 4, 60, 14), Qt::AlignRight, + QString::number(m_values.size())); + painter.drawText(QRect(plot.left(), plot.bottom() + 20, plot.width(), 16), Qt::AlignCenter, m_xLabel); + + QPen pen(QColor(0, 150, 200)); + pen.setWidthF(1.6); + painter.setPen(pen); + QPolygonF line; + for (qsizetype i = 0; i < m_values.size(); ++i) + { + const double x = m_values.size() == 1 + ? plot.center().x() + : plot.left() + plot.width() * static_cast(i) / (m_values.size() - 1); + const double y = plot.bottom() - plot.height() * (m_values.at(i) - low) / (high - low); + line << QPointF(x, y); + } + painter.drawPolyline(line); + } +} diff --git a/src/ProfilePlotWindow.h b/src/ProfilePlotWindow.h new file mode 100644 index 0000000..4770f93 --- /dev/null +++ b/src/ProfilePlotWindow.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +namespace scopeone::ui +{ + // A window that plots a list of values against their position, for example the intensity of + // a selection at each slice of a stack + class ProfilePlotWindow : public QWidget + { + Q_OBJECT + + public: + explicit ProfilePlotWindow(QWidget* parent = nullptr); + void setProfile(const QString& title, const QString& xLabel, const QString& yLabel, + const QVector& values); + + protected: + void paintEvent(QPaintEvent* event) override; + + private: + void saveCsv(); + + QString m_title; + QString m_xLabel; + QString m_yLabel; + QVector m_values; + }; +} diff --git a/src/ResultsWindow.cpp b/src/ResultsWindow.cpp new file mode 100644 index 0000000..74abe66 --- /dev/null +++ b/src/ResultsWindow.cpp @@ -0,0 +1,264 @@ +#include "ResultsWindow.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace scopeone::ui +{ + QList ResultsWindow::allColumns() + { + return { + {QStringLiteral("label"), QStringLiteral("Label")}, + {QStringLiteral("area"), QStringLiteral("Area")}, + {QStringLiteral("mean"), QStringLiteral("Mean")}, + {QStringLiteral("stddev"), QStringLiteral("StdDev")}, + {QStringLiteral("median"), QStringLiteral("Median")}, + {QStringLiteral("min"), QStringLiteral("Min")}, + {QStringLiteral("max"), QStringLiteral("Max")}, + {QStringLiteral("centroid"), QStringLiteral("X, Y (centroid)")}, + {QStringLiteral("perimeter"), QStringLiteral("Perim.")}, + {QStringLiteral("bounds"), QStringLiteral("BX, BY, Width, Height")}, + {QStringLiteral("intden"), QStringLiteral("IntDen")}, + {QStringLiteral("rawintden"), QStringLiteral("RawIntDen")}, + {QStringLiteral("skewness"), QStringLiteral("Skew")}, + {QStringLiteral("kurtosis"), QStringLiteral("Kurt")}, + {QStringLiteral("slice"), QStringLiteral("Slice")}, + }; + } + + ResultsWindow::ResultsWindow(QWidget* parent) + : QWidget(parent, Qt::Window) + { + setWindowTitle(tr("Results")); + resize(760, 360); + auto* layout = new QVBoxLayout(this); + auto* buttons = new QHBoxLayout(); + auto* copyButton = new QPushButton(tr("Copy"), this); + auto* saveButton = new QPushButton(tr("Save as CSV..."), this); + auto* clearButton = new QPushButton(tr("Clear"), this); + auto* measurementsButton = new QPushButton(tr("Set Measurements..."), this); + for (QPushButton* button : {copyButton, saveButton, clearButton, measurementsButton}) + { + buttons->addWidget(button); + } + buttons->addStretch(); + layout->addLayout(buttons); + + m_table = new QTableWidget(this); + m_table->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_table->setSelectionBehavior(QAbstractItemView::SelectRows); + m_table->horizontalHeader()->setStretchLastSection(true); + layout->addWidget(m_table); + + connect(copyButton, &QPushButton::clicked, this, [this]() + { + QApplication::clipboard()->setText(asText(QStringLiteral("\t"))); + }); + connect(saveButton, &QPushButton::clicked, this, [this]() { saveCsv(); }); + connect(clearButton, &QPushButton::clicked, this, [this]() + { + m_rows.clear(); + m_plainRows.clear(); + m_plainHeaders.clear(); + rebuild(); + }); + connect(measurementsButton, &QPushButton::clicked, this, [this]() { chooseMeasurements(); }); + rebuild(); + } + + QStringList ResultsWindow::enabledKeys() const + { + QSettings settings(QStringLiteral("ScopeOne"), QStringLiteral("ScopeOne")); + const QStringList defaults{QStringLiteral("label"), QStringLiteral("area"), QStringLiteral("mean"), + QStringLiteral("min"), QStringLiteral("max")}; + return settings.value(QStringLiteral("Measurements/columns"), defaults).toStringList(); + } + + void ResultsWindow::chooseMeasurements() + { + QDialog dialog(this); + dialog.setWindowTitle(tr("Set Measurements")); + auto* layout = new QVBoxLayout(&dialog); + const QStringList enabled = enabledKeys(); + QList boxes; + for (const Column& column : allColumns()) + { + auto* box = new QCheckBox(column.title, &dialog); + box->setChecked(enabled.contains(column.key)); + layout->addWidget(box); + boxes.append(box); + } + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog); + connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + layout->addWidget(buttons); + if (dialog.exec() != QDialog::Accepted) + { + return; + } + QStringList chosen; + const QList columns = allColumns(); + for (qsizetype index = 0; index < boxes.size(); ++index) + { + if (boxes.at(index)->isChecked()) + { + chosen.append(columns.at(index).key); + } + } + QSettings settings(QStringLiteral("ScopeOne"), QStringLiteral("ScopeOne")); + settings.setValue(QStringLiteral("Measurements/columns"), chosen); + rebuild(); + } + + void ResultsWindow::addMeasurement(const QString& label, const imageops::Measurement& measurement, + int slice, const QString& unit) + { + // Rows of another kind are not mixed into one table + if (!m_plainRows.isEmpty()) + { + m_plainRows.clear(); + m_plainHeaders.clear(); + } + m_rows.append({label, measurement, slice, unit}); + rebuild(); + show(); + raise(); + } + + void ResultsWindow::addValues(const QStringList& headers, const QList& values) + { + if (!m_rows.isEmpty() || m_plainHeaders != headers) + { + m_rows.clear(); + m_plainRows.clear(); + } + m_plainHeaders = headers; + m_plainRows.append(values); + rebuild(); + show(); + raise(); + } + + QString ResultsWindow::cell(const Row& row, const QString& key) const + { + const imageops::Measurement& m = row.measurement; + const auto number = [](double value) { return QString::number(value, 'g', 6); }; + const QString unit = row.unit; + if (key == QLatin1String("label")) return row.label; + if (key == QLatin1String("area")) return number(m.area); + if (key == QLatin1String("mean")) return number(m.mean); + if (key == QLatin1String("stddev")) return number(m.stdDev); + if (key == QLatin1String("median")) return number(m.median); + if (key == QLatin1String("min")) return number(m.minValue); + if (key == QLatin1String("max")) return number(m.maxValue); + if (key == QLatin1String("centroid")) return number(m.centroidX) + QStringLiteral(", ") + number(m.centroidY); + if (key == QLatin1String("perimeter")) return number(m.perimeter); + if (key == QLatin1String("bounds")) + return number(m.boundsX) + QStringLiteral(", ") + number(m.boundsY) + QStringLiteral(", ") + + number(m.boundsWidth) + QStringLiteral(", ") + number(m.boundsHeight); + if (key == QLatin1String("intden")) return number(m.integratedDensity); + if (key == QLatin1String("rawintden")) return number(m.rawIntegratedDensity); + if (key == QLatin1String("skewness")) return number(m.skewness); + if (key == QLatin1String("kurtosis")) return number(m.kurtosis); + if (key == QLatin1String("slice")) return row.slice > 0 ? QString::number(row.slice) : QString(); + return {}; + } + + void ResultsWindow::rebuild() + { + m_table->clear(); + if (!m_plainHeaders.isEmpty()) + { + m_table->setColumnCount(m_plainHeaders.size()); + m_table->setHorizontalHeaderLabels(m_plainHeaders); + m_table->setRowCount(static_cast(m_plainRows.size())); + for (int row = 0; row < m_plainRows.size(); ++row) + { + for (int column = 0; column < m_plainHeaders.size(); ++column) + { + m_table->setItem(row, column, + new QTableWidgetItem(QString::number(m_plainRows.at(row).at(column), 'g', 8))); + } + } + return; + } + const QStringList keys = enabledKeys(); + QList shown; + for (const Column& column : allColumns()) + { + if (keys.contains(column.key)) + { + shown.append(column); + } + } + m_table->setColumnCount(static_cast(shown.size())); + QStringList titles; + for (const Column& column : shown) + { + titles.append(column.title); + } + m_table->setHorizontalHeaderLabels(titles); + m_table->setRowCount(static_cast(m_rows.size())); + for (int row = 0; row < m_rows.size(); ++row) + { + for (int column = 0; column < shown.size(); ++column) + { + m_table->setItem(row, column, new QTableWidgetItem(cell(m_rows.at(row), shown.at(column).key))); + } + } + m_table->scrollToBottom(); + } + + QString ResultsWindow::asText(const QString& separator) const + { + QString text; + QStringList header; + for (int column = 0; column < m_table->columnCount(); ++column) + { + header.append(m_table->horizontalHeaderItem(column)->text()); + } + text += header.join(separator) + QLatin1Char('\n'); + for (int row = 0; row < m_table->rowCount(); ++row) + { + QStringList values; + for (int column = 0; column < m_table->columnCount(); ++column) + { + QString value = m_table->item(row, column)->text(); + if (separator == QLatin1String(",") && value.contains(QLatin1Char(','))) + { + value = QLatin1Char('"') + value + QLatin1Char('"'); + } + values.append(value); + } + text += values.join(separator) + QLatin1Char('\n'); + } + return text; + } + + void ResultsWindow::saveCsv() + { + const QString path = QFileDialog::getSaveFileName(this, tr("Save results"), QStringLiteral("results.csv"), + tr("CSV files (*.csv)")); + if (path.isEmpty()) + { + return; + } + QFile file(path); + if (file.open(QIODevice::WriteOnly | QIODevice::Text)) + { + QTextStream(&file) << asText(QStringLiteral(",")); + } + } +} diff --git a/src/ResultsWindow.h b/src/ResultsWindow.h new file mode 100644 index 0000000..c11b88f --- /dev/null +++ b/src/ResultsWindow.h @@ -0,0 +1,56 @@ +#pragma once + +#include "ImageOps.h" + +#include +#include +#include +#include + +class QTableWidget; + +namespace scopeone::ui +{ + // The table of measurements, like the Results window of ImageJ. The columns it shows are + // chosen with Set Measurements and kept between runs. + class ResultsWindow : public QWidget + { + Q_OBJECT + + public: + explicit ResultsWindow(QWidget* parent = nullptr); + + // Adds a row for one measured selection. slice counts from one, and is zero without a stack. + void addMeasurement(const QString& label, const imageops::Measurement& measurement, int slice, + const QString& unit); + // Adds a row of plain values, for example the intensity at one slice of a stack + void addValues(const QStringList& headers, const QList& values); + void chooseMeasurements(); + + private: + struct Row + { + QString label; + imageops::Measurement measurement; + int slice{0}; + QString unit; + }; + struct Column + { + QString key; + QString title; + }; + static QList allColumns(); + QStringList enabledKeys() const; + QString cell(const Row& row, const QString& key) const; + void rebuild(); + QString asText(const QString& separator) const; + void saveCsv(); + + QTableWidget* m_table{nullptr}; + QList m_rows; + // Rows that are not selection measurements + QStringList m_plainHeaders; + QList> m_plainRows; + }; +} diff --git a/src/RoiManagerWindow.cpp b/src/RoiManagerWindow.cpp new file mode 100644 index 0000000..2652301 --- /dev/null +++ b/src/RoiManagerWindow.cpp @@ -0,0 +1,318 @@ +#include "RoiManagerWindow.h" + +#include "ImageWorkspace.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace scopeone::ui +{ + using scopeone::core::ImageSceneModel; + + namespace + { + QString typeText(ImageSceneModel::MarkupType type) + { + return ImageSceneModel::typeName(type); + } + + QString describe(const ImageSceneModel::Markup& markup, int number) + { + const QString name = markup.label.isEmpty() ? QStringLiteral("%1 %2").arg(typeText(markup.type)).arg(number) + : markup.label; + switch (markup.type) + { + case ImageSceneModel::MarkupType::Line: + return QStringLiteral("%1 (%2, %3) to (%4, %5)").arg(name).arg(markup.start.x()).arg(markup.start.y()) + .arg(markup.end.x()).arg(markup.end.y()); + case ImageSceneModel::MarkupType::Point: + return QStringLiteral("%1 (%2, %3)").arg(name).arg(markup.start.x()).arg(markup.start.y()); + case ImageSceneModel::MarkupType::Polygon: + return QStringLiteral("%1 %2 corners").arg(name).arg(markup.points.size()); + default: + return QStringLiteral("%1 %2 x %3 at (%4, %5)").arg(name).arg(markup.rect.width()).arg(markup.rect.height()) + .arg(markup.rect.x()).arg(markup.rect.y()); + } + } + } + + RoiManagerWindow::RoiManagerWindow(ImageWorkspace* workspace, QWidget* parent) + : QWidget(parent, Qt::Window) + , m_workspace(workspace) + { + setWindowTitle(tr("ROI Manager")); + resize(380, 420); + auto* layout = new QHBoxLayout(this); + m_list = new QListWidget(this); + m_list->setSelectionMode(QAbstractItemView::ExtendedSelection); + layout->addWidget(m_list, 1); + + auto* buttons = new QVBoxLayout(); + const auto addButton = [this, buttons](const QString& text) + { + auto* button = new QPushButton(text, this); + buttons->addWidget(button); + return button; + }; + auto* measureButton = addButton(tr("Measure")); + auto* measureAllButton = addButton(tr("Measure All")); + auto* stackButton = addButton(tr("Measure on Every Slice")); + auto* renameButton = addButton(tr("Rename...")); + auto* deleteButton = addButton(tr("Delete")); + auto* copyButton = addButton(tr("Copy to Image...")); + auto* saveButton = addButton(tr("Save...")); + auto* openButton = addButton(tr("Open...")); + m_showAll = new QCheckBox(tr("Show all"), this); + m_showAll->setChecked(true); + buttons->addWidget(m_showAll); + buttons->addStretch(); + layout->addLayout(buttons); + + connect(m_list, &QListWidget::itemSelectionChanged, this, [this]() + { + if (m_refreshing || !m_scene) + { + return; + } + const auto items = m_list->selectedItems(); + m_scene->selectOnly(items.isEmpty() ? QString() : items.first()->data(Qt::UserRole).toString()); + }); + connect(measureButton, &QPushButton::clicked, this, + [this]() { emit measureRequested(chosenShapes(false), false); }); + connect(measureAllButton, &QPushButton::clicked, this, + [this]() { emit measureRequested(chosenShapes(true), false); }); + connect(stackButton, &QPushButton::clicked, this, + [this]() { emit measureRequested(chosenShapes(true), true); }); + connect(renameButton, &QPushButton::clicked, this, [this]() { renameChosen(); }); + connect(deleteButton, &QPushButton::clicked, this, [this]() { deleteChosen(); }); + connect(copyButton, &QPushButton::clicked, this, [this]() { copyChosen(); }); + connect(saveButton, &QPushButton::clicked, this, [this]() { saveShapes(); }); + connect(openButton, &QPushButton::clicked, this, [this]() { openShapes(); }); + connect(m_showAll, &QCheckBox::toggled, this, [this](bool visible) + { + if (!m_scene) + { + return; + } + for (const ImageSceneModel::Markup& markup : m_scene->markups(m_layerKey)) + { + m_scene->setVisible(markup.id, visible); + } + }); + + connect(m_workspace, &ImageWorkspace::activeDocumentChanged, this, [this]() { attachToActiveImage(); }); + connect(m_workspace, &ImageWorkspace::activeLayerChanged, this, [this]() { attachToActiveImage(); }); + attachToActiveImage(); + } + + void RoiManagerWindow::attachToActiveImage() + { + if (m_scene) + { + disconnect(m_scene, nullptr, this, nullptr); + } + m_scene = m_workspace->activeSceneModel(); + m_layerKey = m_workspace->activeLayerKey(); + if (m_scene) + { + connect(m_scene, &ImageSceneModel::markupsChanged, this, [this]() { refresh(); }); + } + refresh(); + } + + void RoiManagerWindow::refresh() + { + m_refreshing = true; + m_list->clear(); + if (m_scene) + { + int number = 1; + for (const ImageSceneModel::Markup& markup : m_scene->markups(m_layerKey)) + { + auto* item = new QListWidgetItem(describe(markup, number++), m_list); + item->setData(Qt::UserRole, markup.id); + item->setSelected(markup.selected); + } + } + m_refreshing = false; + } + + QList RoiManagerWindow::chosenShapes(bool allWhenNothingChosen) const + { + QList shapes; + if (!m_scene) + { + return shapes; + } + const auto items = m_list->selectedItems(); + for (const ImageSceneModel::Markup& markup : m_scene->markups(m_layerKey)) + { + bool chosen = false; + for (QListWidgetItem* item : items) + { + chosen = chosen || item->data(Qt::UserRole).toString() == markup.id; + } + if (chosen || (allWhenNothingChosen && items.isEmpty())) + { + shapes.append(markup); + } + } + return shapes; + } + + void RoiManagerWindow::renameChosen() + { + const auto shapes = chosenShapes(false); + if (shapes.isEmpty()) + { + return; + } + bool accepted = false; + const QString name = QInputDialog::getText(this, tr("Rename"), tr("Name:"), QLineEdit::Normal, + shapes.first().label, &accepted); + if (accepted) + { + for (const ImageSceneModel::Markup& shape : shapes) + { + m_scene->setLabel(shape.id, name); + } + } + } + + void RoiManagerWindow::deleteChosen() + { + for (const ImageSceneModel::Markup& shape : chosenShapes(false)) + { + m_scene->remove(shape.id); + } + } + + // Puts copies of the chosen shapes on another open image + void RoiManagerWindow::copyChosen() + { + const auto shapes = chosenShapes(true); + QStringList titles; + QStringList ids; + for (const ImageDocumentInfo& document : m_workspace->documents()) + { + if (!document.active) + { + titles.append(document.title); + ids.append(document.id); + } + } + if (shapes.isEmpty() || titles.isEmpty()) + { + return; + } + bool accepted = false; + const QString title = QInputDialog::getItem(this, tr("Copy to Image"), tr("Image:"), titles, 0, false, &accepted); + if (!accepted) + { + return; + } + ImageSceneModel* target = m_workspace->sceneModel(ids.at(titles.indexOf(title))); + if (!target || target->layerIds().isEmpty()) + { + return; + } + const QString layerKey = target->layerIds().first(); + for (const ImageSceneModel::Markup& shape : shapes) + { + switch (shape.type) + { + case ImageSceneModel::MarkupType::Line: target->createLine(layerKey, shape.start, shape.end, shape.label); break; + case ImageSceneModel::MarkupType::Rect: target->createRect(layerKey, shape.rect, shape.label); break; + case ImageSceneModel::MarkupType::Ellipse: target->createEllipse(layerKey, shape.rect, shape.label); break; + case ImageSceneModel::MarkupType::Polygon: target->createPolygon(layerKey, shape.points, shape.label); break; + case ImageSceneModel::MarkupType::Point: target->createPoint(layerKey, shape.start, shape.label); break; + } + } + } + + void RoiManagerWindow::saveShapes() + { + const auto shapes = chosenShapes(true); + const QString path = QFileDialog::getSaveFileName(this, tr("Save ROIs"), QStringLiteral("rois.json"), + tr("ROI files (*.json)")); + if (path.isEmpty()) + { + return; + } + QJsonArray rois; + for (const ImageSceneModel::Markup& shape : shapes) + { + QJsonObject roi; + roi.insert(QStringLiteral("type"), typeText(shape.type)); + roi.insert(QStringLiteral("label"), shape.label); + roi.insert(QStringLiteral("x1"), shape.start.x()); + roi.insert(QStringLiteral("y1"), shape.start.y()); + roi.insert(QStringLiteral("x2"), shape.end.x()); + roi.insert(QStringLiteral("y2"), shape.end.y()); + roi.insert(QStringLiteral("x"), shape.rect.x()); + roi.insert(QStringLiteral("y"), shape.rect.y()); + roi.insert(QStringLiteral("width"), shape.rect.width()); + roi.insert(QStringLiteral("height"), shape.rect.height()); + QJsonArray points; + for (const QPoint& point : shape.points) + { + points.append(QJsonArray{point.x(), point.y()}); + } + roi.insert(QStringLiteral("points"), points); + rois.append(roi); + } + QFile file(path); + if (file.open(QIODevice::WriteOnly)) + { + file.write(QJsonDocument(QJsonObject{{QStringLiteral("format"), QStringLiteral("ScopeOne ROI set")}, + {QStringLiteral("rois"), rois}}).toJson()); + } + } + + void RoiManagerWindow::openShapes() + { + const QString path = QFileDialog::getOpenFileName(this, tr("Open ROIs"), QString(), tr("ROI files (*.json)")); + if (path.isEmpty() || !m_scene) + { + return; + } + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + { + return; + } + const QJsonArray rois = QJsonDocument::fromJson(file.readAll()).object().value(QStringLiteral("rois")).toArray(); + for (const QJsonValue& value : rois) + { + const QJsonObject roi = value.toObject(); + const QString type = roi.value(QStringLiteral("type")).toString(); + const QString label = roi.value(QStringLiteral("label")).toString(); + const QPoint start(roi.value(QStringLiteral("x1")).toInt(), roi.value(QStringLiteral("y1")).toInt()); + const QPoint end(roi.value(QStringLiteral("x2")).toInt(), roi.value(QStringLiteral("y2")).toInt()); + const QRect rect(roi.value(QStringLiteral("x")).toInt(), roi.value(QStringLiteral("y")).toInt(), + roi.value(QStringLiteral("width")).toInt(), roi.value(QStringLiteral("height")).toInt()); + if (type == QLatin1String("Line")) m_scene->createLine(m_layerKey, start, end, label); + else if (type == QLatin1String("Rect")) m_scene->createRect(m_layerKey, rect, label); + else if (type == QLatin1String("Ellipse")) m_scene->createEllipse(m_layerKey, rect, label); + else if (type == QLatin1String("Point")) m_scene->createPoint(m_layerKey, start, label); + else if (type == QLatin1String("Polygon")) + { + QList corners; + for (const QJsonValue& point : roi.value(QStringLiteral("points")).toArray()) + { + corners.append(QPoint(point.toArray().at(0).toInt(), point.toArray().at(1).toInt())); + } + m_scene->createPolygon(m_layerKey, corners, label); + } + } + } +} diff --git a/src/RoiManagerWindow.h b/src/RoiManagerWindow.h new file mode 100644 index 0000000..7baee09 --- /dev/null +++ b/src/RoiManagerWindow.h @@ -0,0 +1,47 @@ +#pragma once + +#include "scopeone/ImageSceneModel.h" + +#include +#include +#include + +class QCheckBox; +class QListWidget; + +namespace scopeone::ui +{ + class ImageWorkspace; + + // The list of the shapes drawn on the active image, like the ROI Manager of ImageJ. Shapes + // stay on the image as long as they are in the list, can be named, measured, saved to a + // file and opened again. + class RoiManagerWindow : public QWidget + { + Q_OBJECT + + public: + explicit RoiManagerWindow(ImageWorkspace* workspace, QWidget* parent = nullptr); + + signals: + // Measure these shapes on the current slice, or on every slice of the stack + void measureRequested(const QList& shapes, bool allSlices); + + private: + void attachToActiveImage(); + void refresh(); + QList chosenShapes(bool allWhenNothingChosen) const; + void renameChosen(); + void deleteChosen(); + void copyChosen(); + void saveShapes(); + void openShapes(); + + ImageWorkspace* m_workspace{nullptr}; + QPointer m_scene; + QString m_layerKey; + QListWidget* m_list{nullptr}; + QCheckBox* m_showAll{nullptr}; + bool m_refreshing{false}; + }; +}