diff --git a/ScopeOneCore/include/scopeone/ScopeOneCore.h b/ScopeOneCore/include/scopeone/ScopeOneCore.h index 037f366..132b2da 100644 --- a/ScopeOneCore/include/scopeone/ScopeOneCore.h +++ b/ScopeOneCore/include/scopeone/ScopeOneCore.h @@ -252,6 +252,11 @@ namespace scopeone::core { return m_cameraPixelSizesUm.value(cameraId.trimmed(), 0.0); } + // Sets the size of a pixel of the images of a camera in this session, in micrometers + void setPixelSizeUm(const QString& cameraId, double pixelSizeUm) + { + m_cameraPixelSizesUm.insert(cameraId.trimmed(), pixelSizeUm); + } QStringList recordedCameraIds() const { diff --git a/src/ImageAnalysis.cpp b/src/ImageAnalysis.cpp index f30def8..86147de 100644 --- a/src/ImageAnalysis.cpp +++ b/src/ImageAnalysis.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include namespace scopeone::ui @@ -29,12 +30,15 @@ namespace scopeone::ui void ImageAnalysis::addMenus(QMenuBar* menuBar, QAction* before) { + QMenu* edit = new QMenu(tr("&Edit"), menuBar); QMenu* image = new QMenu(tr("&Image"), menuBar); QMenu* process = new QMenu(tr("&Process"), menuBar); QMenu* analyze = new QMenu(tr("&Analyze"), menuBar); + menuBar->insertMenu(before, edit); menuBar->insertMenu(before, image); menuBar->insertMenu(before, process); menuBar->insertMenu(before, analyze); + buildEditMenu(edit); buildImageMenu(image); buildProcessMenu(process); buildAnalyzeMenu(analyze); @@ -253,6 +257,63 @@ namespace scopeone::ui [this, text, operation]() { apply(QString(text).remove(QLatin1Char('&')), operation); }); } + void ImageAnalysis::buildEditMenu(QMenu* menu) + { + QAction* selectAllAction = menu->addAction(tr("Select &All")); + selectAllAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+A"))); + connect(selectAllAction, &QAction::triggered, this, [this]() { selectAll(); }); + QAction* selectNoneAction = menu->addAction(tr("Select &None")); + selectNoneAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+A"))); + connect(selectNoneAction, &QAction::triggered, this, [this]() + { + if (ImageSceneModel* scene = m_workspace->activeSceneModel()) + { + scene->selectOnly(QString()); + } + }); + menu->addSeparator(); + + QMenu* selection = menu->addMenu(tr("&Selection")); + connect(selection->addAction(tr("To &Bounding Box")), &QAction::triggered, this, [this]() + { + Source source; + ImageSceneModel::Markup shape; + if (requireSelection(source, shape)) + { + const QRect box = imageops::selectionBounds(shape); + source.scene->remove(shape.id); + const QString id = source.scene->createRect(source.layerKey, box, shape.label); + source.scene->selectOnly(id); + } + }); + connect(selection->addAction(tr("&Convex Hull")), &QAction::triggered, this, [this]() + { + Source source; + ImageSceneModel::Markup shape; + if (requireSelection(source, shape)) + { + replaceWithPolygon(source, shape, imageops::convexHull(imageops::selectionCorners(shape))); + } + }); + connect(selection->addAction(tr("Fit &Ellipse")), &QAction::triggered, this, [this]() + { + Source source; + ImageSceneModel::Markup shape; + if (requireSelection(source, shape)) + { + replaceWithPolygon(source, shape, imageops::fittedEllipse(source.frames.first().width, + source.frames.first().height, shape)); + } + }); + connect(selection->addAction(tr("En&large...")), &QAction::triggered, this, [this]() { enlargeSelection(); }); + connect(selection->addAction(tr("&Specify...")), &QAction::triggered, this, [this]() { specifySelection(); }); + connect(selection->addAction(tr("&Rotate...")), &QAction::triggered, this, [this]() { rotateSelection(); }); + connect(selection->addAction(tr("S&cale...")), &QAction::triggered, this, [this]() { scaleSelection(); }); + selection->addSeparator(); + connect(selection->addAction(tr("Create &Mask")), &QAction::triggered, this, [this]() { createMask(); }); + connect(selection->addAction(tr("Create Se&lection")), &QAction::triggered, this, [this]() { createSelection(); }); + } + void ImageAnalysis::buildImageMenu(QMenu* menu) { connect(menu->addAction(tr("&Duplicate...")), &QAction::triggered, this, [this]() { duplicateImage(); }); @@ -270,6 +331,8 @@ namespace scopeone::ui [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(); }); + connect(menu->addAction(tr("&Properties...")), &QAction::triggered, this, [this]() { imageProperties(); }); + QMenu* adjust = menu->addMenu(tr("&Adjust")); connect(adjust->addAction(tr("&Threshold...")), &QAction::triggered, this, [this]() { thresholdImage(); }); @@ -369,6 +432,7 @@ namespace scopeone::ui connect(menu->addAction(tr("Plot &Z-axis Profile")), &QAction::triggered, this, [this]() { plotZProfile(); }); menu->addSeparator(); connect(menu->addAction(tr("Analyze &Particles...")), &QAction::triggered, this, [this]() { analyzeParticles(); }); + connect(menu->addAction(tr("&Wand Tolerance...")), &QAction::triggered, this, [this]() { wandTolerance(); }); } // ---- Image menu @@ -643,6 +707,202 @@ namespace scopeone::ui measureShapes({shape}, false); } + // ---- selections + + bool ImageAnalysis::requireSelection(Source& source, ImageSceneModel::Markup& shape) + { + if (!activeSource(source)) + { + return false; + } + if (!selectedShape(source, shape)) + { + QMessageBox::information(m_window, tr("Selection"), tr("Draw a selection first.")); + return false; + } + return true; + } + + void ImageAnalysis::replaceWithPolygon(const Source& source, const ImageSceneModel::Markup& old, + const QList& corners) + { + if (corners.size() < 3) + { + QMessageBox::information(m_window, tr("Selection"), tr("Nothing is left of the selection.")); + return; + } + source.scene->remove(old.id); + const QString id = source.scene->createPolygon(source.layerKey, corners, old.label); + source.scene->selectOnly(id); + } + + void ImageAnalysis::selectAll() + { + Source source; + if (!activeSource(source) || !source.scene) + { + return; + } + const ImageFrame& frame = source.frames.first(); + const QString id = source.scene->createRect(source.layerKey, QRect(0, 0, frame.width, frame.height)); + source.scene->selectOnly(id); + } + + void ImageAnalysis::specifySelection() + { + Source source; + if (!activeSource(source) || !source.scene) + { + return; + } + const ImageFrame& frame = source.frames.first(); + ImageSceneModel::Markup shape; + const bool hasShape = selectedShape(source, shape) + && (shape.type == ImageSceneModel::MarkupType::Rect || shape.type == ImageSceneModel::MarkupType::Ellipse); + const QRect current = hasShape ? shape.rect.normalized() : QRect(0, 0, frame.width / 2, frame.height / 2); + QList fields{ + ParameterField::integer(tr("Width"), current.width(), 1, frame.width), + ParameterField::integer(tr("Height"), current.height(), 1, frame.height), + ParameterField::integer(tr("X coordinate"), current.x(), 0, frame.width), + ParameterField::integer(tr("Y coordinate"), current.y(), 0, frame.height), + ParameterField::check(tr("Oval"), hasShape && shape.type == ImageSceneModel::MarkupType::Ellipse)}; + if (!askParameters(m_window, tr("Specify"), fields)) + { + return; + } + const QRect rect(static_cast(fields.at(2).value), static_cast(fields.at(3).value), + static_cast(fields.at(0).value), static_cast(fields.at(1).value)); + if (hasShape) + { + source.scene->remove(shape.id); + } + const QString id = fields.at(4).checked ? source.scene->createEllipse(source.layerKey, rect) + : source.scene->createRect(source.layerKey, rect); + source.scene->selectOnly(id); + } + + void ImageAnalysis::enlargeSelection() + { + Source source; + ImageSceneModel::Markup shape; + if (!requireSelection(source, shape)) + { + return; + } + QList fields{ParameterField::real(tr("Enlarge by (pixels, negative to shrink)"), 15.0, -500.0, 500.0, 1)}; + if (askParameters(m_window, tr("Enlarge"), fields)) + { + replaceWithPolygon(source, shape, + imageops::enlargedOutline(source.frames.first().width, source.frames.first().height, + shape, fields.first().value)); + } + } + + void ImageAnalysis::rotateSelection() + { + Source source; + ImageSceneModel::Markup shape; + if (!requireSelection(source, shape)) + { + return; + } + QList fields{ParameterField::real(tr("Angle (degrees)"), 15.0, -360.0, 360.0, 1)}; + if (askParameters(m_window, tr("Rotate Selection"), fields)) + { + replaceWithPolygon(source, shape, imageops::rotatedCorners(shape, fields.first().value)); + } + } + + void ImageAnalysis::scaleSelection() + { + Source source; + ImageSceneModel::Markup shape; + if (!requireSelection(source, shape)) + { + return; + } + QList fields{ParameterField::real(tr("X scale factor"), 1.5, 0.05, 20.0, 2), + ParameterField::real(tr("Y scale factor"), 1.5, 0.05, 20.0, 2)}; + if (askParameters(m_window, tr("Scale Selection"), fields)) + { + replaceWithPolygon(source, shape, imageops::scaledCorners(shape, fields.at(0).value, fields.at(1).value)); + } + } + + // Makes an image in which the pixels of the selection are 255 and the others 0 + void ImageAnalysis::createMask() + { + Source source; + ImageSceneModel::Markup shape; + if (!requireSelection(source, shape)) + { + return; + } + ImageFrame mask = imageops::selectionMask(source.frames.first().width, source.frames.first().height, shape); + mask.cameraId = source.frames.first().cameraId; + present({mask}, tr("Mask of %1").arg(source.title)); + } + + // Makes a selection of every group of touching pixels above zero of a binary image + void ImageAnalysis::createSelection() + { + Source source; + if (!activeSource(source) || !source.scene) + { + return; + } + QSignalBlocker blocker(source.scene); + int number = 0; + for (const imageops::Particle& particle : imageops::findParticles(source.frames.at(source.index))) + { + source.scene->createPolygon(source.layerKey, particle.outline, tr("Selection %1").arg(++number)); + } + blocker.unblock(); + emit source.scene->markupsChanged(); + } + + // The size of a pixel of the image + void ImageAnalysis::imageProperties() + { + Source source; + if (!activeSource(source)) + { + return; + } + const ImageFrame& frame = source.frames.first(); + QList fields{ + ParameterField::real(tr("Pixel size (µm)"), m_workspace->pixelSizeUm(source.layerKey), 0.0, 1.0e6, 4)}; + if (!askParameters(m_window, tr("Properties of %1").arg(source.title), fields)) + { + return; + } + const double size = fields.first().value; + const ImageDocumentInfo document = m_workspace->document(); + if (document.isValid()) + { + if (const auto session = m_workspace->documentSession()) + { + session->setPixelSizeUm(document.cameraId, size); + } + } + else + { + m_core->setCameraPixelSizeUm(frame.cameraId, size); + } + } + + void ImageAnalysis::wandTolerance() + { + QSettings settings(QStringLiteral("ScopeOne"), QStringLiteral("ScopeOne")); + QList fields{ + ParameterField::integer(tr("Tolerance (gray levels)"), settings.value(QStringLiteral("Wand/tolerance"), 0).toInt(), + 0, 65535)}; + if (askParameters(m_window, tr("Wand Tolerance"), fields)) + { + settings.setValue(QStringLiteral("Wand/tolerance"), static_cast(fields.first().value)); + } + } + // Finds the particles of a binary image, or of an image above a threshold, and measures each // one, like Analyze Particles of ImageJ void ImageAnalysis::analyzeParticles() diff --git a/src/ImageAnalysis.h b/src/ImageAnalysis.h index cf4c594..5d6500c 100644 --- a/src/ImageAnalysis.h +++ b/src/ImageAnalysis.h @@ -62,6 +62,7 @@ namespace scopeone::ui using FrameOperation = std::function; void apply(const QString& title, const FrameOperation& operation); + void buildEditMenu(QMenu* menu); void buildImageMenu(QMenu* menu); void buildProcessMenu(QMenu* menu); void buildAnalyzeMenu(QMenu* menu); @@ -85,6 +86,20 @@ namespace scopeone::ui void unsharpMask(); void calculate(); void measure(); + // The selection that a command changes, which has to be drawn first + bool requireSelection(Source& source, scopeone::core::ImageSceneModel::Markup& shape); + // Takes the place of the selection with a polygon, a rectangle or nothing + void replaceWithPolygon(const Source& source, const scopeone::core::ImageSceneModel::Markup& old, + const QList& corners); + void selectAll(); + void specifySelection(); + void enlargeSelection(); + void rotateSelection(); + void scaleSelection(); + void createMask(); + void createSelection(); + void imageProperties(); + void wandTolerance(); void analyzeParticles(); void measureShapes(const QList& shapes, bool allSlices); diff --git a/src/ImageOps.cpp b/src/ImageOps.cpp index c8acfee..8588f74 100644 --- a/src/ImageOps.cpp +++ b/src/ImageOps.cpp @@ -455,6 +455,22 @@ namespace scopeone::ui::imageops return mask; } + QRect convexBounds(const QList& points) + { + int left = points.first().x(); + int right = left; + int top = points.first().y(); + int bottom = top; + for (const QPoint& point : points) + { + left = std::min(left, point.x()); + right = std::max(right, point.x()); + top = std::min(top, point.y()); + bottom = std::max(bottom, point.y()); + } + return QRect(left, top, std::max(1, right - left), std::max(1, bottom - top)); + } + // Histogram of the values of a frame, one bin for each possible value std::vector histogramOf(const ImageFrame& frame) { @@ -1742,4 +1758,375 @@ namespace scopeone::ui::imageops } return result; } + + ImageFrame selectionMask(int width, int height, const Markup& selection) + { + ImageFrame like; + like.pixelFormat = ImagePixelFormat::Mono8; + like.bitsPerSample = 8; + ImageFrame canvas = blankFrame(like, width, height); + ImageFrame result = blankFrame(like, width, height); + visitPixels(canvas, selection, [&](int x, int y, int) + { + result.bytes.data()[static_cast(y) * result.stride + x] = static_cast(255); + }); + return result; + } + + QList convexHull(const QList& input) + { + QList points = input; + std::sort(points.begin(), points.end(), [](const QPoint& a, const QPoint& b) + { + return a.x() < b.x() || (a.x() == b.x() && a.y() < b.y()); + }); + points.erase(std::unique(points.begin(), points.end()), points.end()); + if (points.size() < 3) + { + return points; + } + const auto cross = [](const QPoint& o, const QPoint& a, const QPoint& b) + { + return static_cast(a.x() - o.x()) * (b.y() - o.y()) - static_cast(a.y() - o.y()) * (b.x() - o.x()); + }; + QList hull(points.size() * 2); + int k = 0; + for (int i = 0; i < points.size(); ++i) + { + while (k >= 2 && cross(hull[k - 2], hull[k - 1], points[i]) <= 0) --k; + hull[k++] = points[i]; + } + for (int i = static_cast(points.size()) - 2, t = k + 1; i >= 0; --i) + { + while (k >= t && cross(hull[k - 2], hull[k - 1], points[i]) <= 0) --k; + hull[k++] = points[i]; + } + hull.resize(k - 1); + return hull; + } + + QList selectionCorners(const Markup& selection) + { + switch (selection.type) + { + case MarkupType::Polygon: + return selection.points; + case MarkupType::Line: + return {selection.start, selection.end}; + case MarkupType::Point: + return {selection.start}; + case MarkupType::Rect: + { + const QRect shape = selection.rect.normalized(); + return {shape.topLeft(), QPoint(shape.right() + 1, shape.top()), + QPoint(shape.right() + 1, shape.bottom() + 1), QPoint(shape.left(), shape.bottom() + 1)}; + } + case MarkupType::Ellipse: + { + const QRect shape = selection.rect.normalized(); + const double radiusX = shape.width() / 2.0; + const double radiusY = shape.height() / 2.0; + QList ring; + for (int step = 0; step < 72; ++step) + { + const double angle = step * 3.14159265358979323846 / 36.0; + ring.append(QPoint(static_cast(std::lround(shape.x() + radiusX + radiusX * std::cos(angle))), + static_cast(std::lround(shape.y() + radiusY + radiusY * std::sin(angle))))); + } + return ring; + } + } + return {}; + } + + QList simplifiedOutline(const QList& points, double epsilon) + { + if (points.size() < 4) + { + return points; + } + // Douglas and Peucker, on the closed outline cut at its first and its farthest point + const auto distance = [](const QPoint& p, const QPoint& a, const QPoint& b) + { + const double dx = b.x() - a.x(); + const double dy = b.y() - a.y(); + const double length = std::hypot(dx, dy); + if (length == 0.0) + { + return std::hypot(static_cast(p.x() - a.x()), static_cast(p.y() - a.y())); + } + return std::abs(dy * (p.x() - a.x()) - dx * (p.y() - a.y())) / length; + }; + std::vector keep(static_cast(points.size()), 0); + int far = 0; + double farthest = -1.0; + for (int i = 1; i < points.size(); ++i) + { + const double d = std::hypot(static_cast(points[i].x() - points[0].x()), + static_cast(points[i].y() - points[0].y())); + if (d > farthest) + { + farthest = d; + far = i; + } + } + keep[0] = 1; + keep[static_cast(far)] = 1; + struct Span { int first; int last; }; + std::vector stack{{0, far}, {far, static_cast(points.size())}}; + while (!stack.empty()) + { + const Span span = stack.back(); + stack.pop_back(); + const QPoint& a = points[span.first]; + const QPoint& b = points[span.last % points.size()]; + int index = -1; + double worst = epsilon; + for (int i = span.first + 1; i < span.last; ++i) + { + const double d = distance(points[i], a, b); + if (d > worst) + { + worst = d; + index = i; + } + } + if (index >= 0) + { + keep[static_cast(index)] = 1; + stack.push_back({span.first, index}); + stack.push_back({index, span.last}); + } + } + QList result; + for (int i = 0; i < points.size(); ++i) + { + if (keep[static_cast(i)]) + { + result.append(points[i]); + } + } + return result.size() >= 3 ? result : points; + } + + QList enlargedOutline(int width, int height, const Markup& selection, double pixels) + { + // Pad the image, so that a selection at the edge can grow beyond it + const int pad = pixels > 0.0 ? static_cast(std::ceil(pixels)) + 1 : 1; + const int paddedWidth = width + 2 * pad; + const int paddedHeight = height + 2 * pad; + std::vector inside(static_cast(paddedWidth) * paddedHeight, 0); + const ImageFrame mask = selectionMask(width, height, selection); + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + inside[static_cast(y + pad) * paddedWidth + x + pad] = pixelAt(mask, x, y) > 0 ? 1 : 0; + } + } + std::vector grown(inside.size(), 0); + if (pixels >= 0.0) + { + // Everything within the distance of the selection + std::vector outside(inside.size()); + for (size_t i = 0; i < inside.size(); ++i) + { + outside[i] = inside[i] ? 0 : 1; + } + const std::vector distance = squaredDistances(outside, paddedWidth, paddedHeight); + for (size_t i = 0; i < inside.size(); ++i) + { + grown[i] = (inside[i] || distance[i] <= pixels * pixels) ? 1 : 0; + } + } + else + { + const std::vector distance = squaredDistances(inside, paddedWidth, paddedHeight); + for (size_t i = 0; i < inside.size(); ++i) + { + grown[i] = (inside[i] && distance[i] > pixels * pixels) ? 1 : 0; + } + } + ImageFrame like; + like.pixelFormat = ImagePixelFormat::Mono8; + like.bitsPerSample = 8; + ImageFrame grownFrame = blankFrame(like, paddedWidth, paddedHeight); + for (int y = 0; y < paddedHeight; ++y) + { + for (int x = 0; x < paddedWidth; ++x) + { + grownFrame.bytes.data()[static_cast(y) * grownFrame.stride + x] = + grown[static_cast(y) * paddedWidth + x] ? static_cast(255) : 0; + } + } + // The largest particle is the new selection + const QList particles = findParticles(grownFrame); + const Particle* largest = nullptr; + for (const Particle& particle : particles) + { + if (!largest || particle.pixels.size() > largest->pixels.size()) + { + largest = &particle; + } + } + if (!largest) + { + return {}; + } + QList outline; + for (const QPoint& corner : largest->outline) + { + outline.append(QPoint(std::clamp(corner.x() - pad, 0, width), std::clamp(corner.y() - pad, 0, height))); + } + return outline; + } + + QList fittedEllipse(int width, int height, const Markup& selection) + { + const ImageFrame mask = selectionMask(width, height, selection); + double count = 0.0; + double meanX = 0.0; + double meanY = 0.0; + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + if (pixelAt(mask, x, y) > 0) + { + count += 1.0; + meanX += x + 0.5; + meanY += y + 0.5; + } + } + } + if (count < 3.0) + { + return {}; + } + meanX /= count; + meanY /= count; + double xx = 1.0 / 12.0; + double yy = 1.0 / 12.0; + double xy = 0.0; + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + if (pixelAt(mask, x, y) > 0) + { + const double dx = x + 0.5 - meanX; + const double dy = y + 0.5 - meanY; + xx += dx * dx / count; + yy += dy * dy / count; + xy += dx * dy / count; + } + } + } + const double root = std::sqrt((xx - yy) * (xx - yy) + 4.0 * xy * xy); + const double major = 2.0 * std::sqrt(std::max(0.0, (xx + yy + root) / 2.0)); + const double minor = 2.0 * std::sqrt(std::max(0.0, (xx + yy - root) / 2.0)); + const double angle = 0.5 * std::atan2(2.0 * xy, xx - yy); + QList ring; + for (int step = 0; step < 72; ++step) + { + const double t = step * 3.14159265358979323846 / 36.0; + const double ex = major * std::cos(t); + const double ey = minor * std::sin(t); + const QPoint point(static_cast(std::lround(meanX + ex * std::cos(angle) - ey * std::sin(angle))), + static_cast(std::lround(meanY + ex * std::sin(angle) + ey * std::cos(angle)))); + if (ring.isEmpty() || ring.last() != point) + { + ring.append(point); + } + } + return ring; + } + + QList rotatedCorners(const Markup& selection, double degrees) + { + const QList corners = selectionCorners(selection); + if (corners.isEmpty()) + { + return {}; + } + double centerX = 0.0; + double centerY = 0.0; + const QRect box = selection.type == MarkupType::Line || selection.type == MarkupType::Point + ? selectionBounds(selection) : QRect(convexBounds(corners)); + centerX = box.x() + box.width() / 2.0; + centerY = box.y() + box.height() / 2.0; + const double radians = degrees * 3.14159265358979323846 / 180.0; + QList result; + for (const QPoint& corner : corners) + { + const double dx = corner.x() - centerX; + const double dy = corner.y() - centerY; + result.append(QPoint(static_cast(std::lround(centerX + dx * std::cos(radians) - dy * std::sin(radians))), + static_cast(std::lround(centerY + dx * std::sin(radians) + dy * std::cos(radians))))); + } + return result; + } + + QList scaledCorners(const Markup& selection, double factorX, double factorY) + { + const QList corners = selectionCorners(selection); + if (corners.isEmpty()) + { + return {}; + } + const QRect box = QRect(convexBounds(corners)); + const double centerX = box.x() + box.width() / 2.0; + const double centerY = box.y() + box.height() / 2.0; + QList result; + for (const QPoint& corner : corners) + { + result.append(QPoint(static_cast(std::lround(centerX + (corner.x() - centerX) * factorX)), + static_cast(std::lround(centerY + (corner.y() - centerY) * factorY)))); + } + return result; + } + + QList wandOutline(const ImageFrame& frame, const QPoint& seed, int tolerance) + { + if (!isMono(frame) || !QRect(0, 0, frame.width, frame.height).contains(seed)) + { + return {}; + } + const int reference = pixelAt(frame, seed.x(), seed.y()); + ImageFrame like; + like.pixelFormat = ImagePixelFormat::Mono8; + like.bitsPerSample = 8; + ImageFrame mask = blankFrame(like, frame.width, frame.height); + std::vector stack{seed.y() * frame.width + seed.x()}; + std::vector seen(static_cast(frame.width) * frame.height, 0); + seen[static_cast(stack.back())] = 1; + while (!stack.empty()) + { + const int index = stack.back(); + stack.pop_back(); + const int x = index % frame.width; + const int y = index / frame.width; + mask.bytes.data()[static_cast(y) * mask.stride + x] = static_cast(255); + for (int dy = -1; dy <= 1; ++dy) + { + for (int dx = -1; dx <= 1; ++dx) + { + const int nx = x + dx; + const int ny = y + dy; + if (nx < 0 || ny < 0 || nx >= frame.width || ny >= frame.height) + { + continue; + } + const size_t next = static_cast(ny) * frame.width + nx; + if (!seen[next] && std::abs(pixelAt(frame, nx, ny) - reference) <= tolerance) + { + seen[next] = 1; + stack.push_back(static_cast(next)); + } + } + } + } + const QList particles = findParticles(mask); + return particles.isEmpty() ? QList{} : particles.first().outline; + } } diff --git a/src/ImageOps.h b/src/ImageOps.h index 1b870c8..0da5136 100644 --- a/src/ImageOps.h +++ b/src/ImageOps.h @@ -166,6 +166,26 @@ namespace scopeone::ui::imageops }; Measurement measure(const ImageFrame& frame, const Markup& markup, double scale); + // ---- changing selections + + // The pixels of the selection as an 8 bit frame of the given size, 255 inside + ImageFrame selectionMask(int width, int height, const Markup& selection); + // Corners of the outline as a polygon. A rectangle gives its four corners and an ellipse + // a ring of points on it. + QList selectionCorners(const Markup& selection); + QList convexHull(const QList& points); + // The outline of the selection grown by the given number of pixels, or shrunk when negative. + // Empty when nothing is left. + QList enlargedOutline(int width, int height, const Markup& selection, double pixels); + // The ellipse that has the same second moments as the selection, as a polygon + QList fittedEllipse(int width, int height, const Markup& selection); + QList rotatedCorners(const Markup& selection, double degrees); + QList scaledCorners(const Markup& selection, double factorX, double factorY); + // The outline of the pixels that touch the seed and differ from it by at most tolerance + QList wandOutline(const ImageFrame& frame, const QPoint& seed, int tolerance); + // Fewer corners, with every point of the outline within epsilon of the result + QList simplifiedOutline(const QList& points, double epsilon); + // ---- frames // A frame like the given one in format and identity, with the given size and zeroed pixels diff --git a/src/InspectWidget.cpp b/src/InspectWidget.cpp index 76ca14b..2830d15 100644 --- a/src/InspectWidget.cpp +++ b/src/InspectWidget.cpp @@ -612,10 +612,15 @@ namespace scopeone::ui {ToolIconKind::Polygon, PreviewWidget::AnalysisTool::Polygon, "Polygon: click the corners. Double-click, Enter or a click on the first corner closes it. " "Backspace takes back a corner and Esc cancels."}, + {ToolIconKind::Freehand, PreviewWidget::AnalysisTool::Freehand, + "Freehand: drag along the outline. It closes when the button is released."}, {ToolIconKind::Line, PreviewWidget::AnalysisTool::Line, "Line: drag on the image to plot the profile along it"}, {ToolIconKind::Point, PreviewWidget::AnalysisTool::Point, "Point: click to read one pixel"}, + {ToolIconKind::Wand, PreviewWidget::AnalysisTool::Wand, + "Wand: click a region to select the connected pixels of a similar value. " + "The tolerance is set in Analyze > Wand Tolerance."}, }; auto* toolGroup = new QButtonGroup(this); toolGroup->setExclusive(true); diff --git a/src/PreviewWidget.cpp b/src/PreviewWidget.cpp index 754793d..5ee14c5 100644 --- a/src/PreviewWidget.cpp +++ b/src/PreviewWidget.cpp @@ -1,4 +1,5 @@ #include "PreviewWidget.h" +#include "ImageOps.h" #include "scopeone/ImageSceneModel.h" #include "scopeone/ScopeOneCore.h" @@ -11,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -1625,7 +1627,7 @@ namespace scopeone::ui void PreviewWidget::drawActiveInteractionMarkup(QPainter& painter, const std::vector& renderItems) const { - if (m_draft.polygonActive) + if (m_draft.polygonActive || (m_draft.dragging && m_draft.type == ImageSceneModel::MarkupType::Polygon)) { // The corners clicked so far, and a dashed edge from the last one to the pointer painter.save(); @@ -1637,14 +1639,17 @@ namespace scopeone::ui { painter.drawLine(m_draft.vertices.at(index - 1), m_draft.vertices.at(index)); } - QPen dashed = pen; - dashed.setStyle(Qt::DashLine); - painter.setPen(dashed); - painter.drawLine(m_draft.vertices.last(), m_draft.end); - painter.setPen(pen); - for (const QPoint& vertex : m_draft.vertices) + if (m_draft.polygonActive) { - painter.drawRect(QRect(vertex - QPoint(3, 3), QSize(6, 6))); + QPen dashed = pen; + dashed.setStyle(Qt::DashLine); + painter.setPen(dashed); + painter.drawLine(m_draft.vertices.last(), m_draft.end); + painter.setPen(pen); + for (const QPoint& vertex : m_draft.vertices) + { + painter.drawRect(QRect(vertex - QPoint(3, 3), QSize(6, 6))); + } } if (m_draft.vertices.size() >= 3 && QLineF(QPointF(m_draft.end), QPointF(m_draft.vertices.first())).length() <= 8.0) @@ -3718,6 +3723,11 @@ namespace scopeone::ui { corners.removeLast(); } + // A freehand path has a corner for every few pixels of the way, which are too many to keep + if (draft.dragging) + { + corners = imageops::simplifiedOutline(corners, 0.8); + } if (corners.size() < 3) { return; @@ -3875,6 +3885,34 @@ namespace scopeone::ui { setActiveLayerKey(target.layerKey); emit layerClicked(target.layerKey); + if (m_analysisTool == AnalysisTool::Wand) + { + // The region around the click that has a similar value + FrameSourceState wandState; + bool wandProcessed = false; + QRect wandArea; + QRect wandDisplay; + QSize wandSize; + if (resolveLayerDisplayGeometry(target.layerKey, wandState, wandProcessed, wandArea, + wandDisplay, wandSize)) + { + QSettings settings(QStringLiteral("ScopeOne"), QStringLiteral("ScopeOne")); + const QList outline = imageops::wandOutline( + wandProcessed ? wandState.processedFrame : wandState.rawFrame, + target.imagePos, + settings.value(QStringLiteral("Wand/tolerance"), 0).toInt()); + if (outline.size() >= 3) + { + const QString wandId = m_sceneModel->createPolygon(target.layerKey, outline); + if (!wandId.isEmpty()) + { + m_sceneModel->selectOnly(wandId); + } + } + } + update(); + return; + } if (m_analysisTool == AnalysisTool::Point) { // A point is placed by one click @@ -3898,6 +3936,7 @@ namespace scopeone::ui m_draft.type = ImageSceneModel::MarkupType::Ellipse; break; case AnalysisTool::Polygon: + case AnalysisTool::Freehand: m_draft.type = ImageSceneModel::MarkupType::Polygon; break; default: @@ -3917,6 +3956,10 @@ namespace scopeone::ui else { m_draft.dragging = true; + if (m_analysisTool == AnalysisTool::Freehand) + { + m_draft.vertices = {event->pos()}; + } } update(); return; @@ -4043,6 +4086,13 @@ namespace scopeone::ui if (m_draft.dragging) { m_draft.end = event->pos(); + // A freehand outline collects the points that the pointer passes + if (m_draft.type == ImageSceneModel::MarkupType::Polygon + && (m_draft.vertices.isEmpty() + || (event->pos() - m_draft.vertices.last()).manhattanLength() >= 3)) + { + m_draft.vertices.append(event->pos()); + } update(); return; } @@ -4201,7 +4251,14 @@ namespace scopeone::ui if (m_draft.dragging && event->button() == Qt::LeftButton) { m_draft.end = event->pos(); - finishDraft(); + if (m_draft.type == ImageSceneModel::MarkupType::Polygon) + { + finishPolygonDraft(); + } + else + { + finishDraft(); + } return; } diff --git a/src/PreviewWidget.h b/src/PreviewWidget.h index 5589208..e66d895 100644 --- a/src/PreviewWidget.h +++ b/src/PreviewWidget.h @@ -47,7 +47,7 @@ namespace scopeone::ui public: enum class LayerLayoutMode { SideBySide, Overlay }; enum class ViewDimensionMode { TwoDimensional, ThreeDimensional }; - enum class AnalysisTool { Select, Rectangle, Ellipse, Polygon, Line, Point }; + enum class AnalysisTool { Select, Rectangle, Ellipse, Polygon, Freehand, Line, Point, Wand }; struct PreviewInteractionTarget { diff --git a/src/ToolIcons.cpp b/src/ToolIcons.cpp index 01a6131..6a45d18 100644 --- a/src/ToolIcons.cpp +++ b/src/ToolIcons.cpp @@ -59,6 +59,28 @@ namespace scopeone::ui } break; } + case ToolIconKind::Freehand: + { + // A closed curve drawn by hand + QPainterPath curve; + curve.moveTo(4.0, 11.0); + curve.cubicTo(3.0, 5.0, 9.0, 3.0, 11.0, 6.0); + curve.cubicTo(13.0, 8.5, 17.5, 5.5, 16.5, 11.5); + curve.cubicTo(16.0, 16.0, 10.0, 17.5, 7.5, 15.0); + curve.cubicTo(5.5, 13.5, 6.0, 12.5, 4.0, 11.0); + painter.drawPath(curve); + break; + } + case ToolIconKind::Wand: + { + // A stick with sparks at the tip + painter.drawLine(QPointF(3.5, 16.5), QPointF(11.0, 9.0)); + painter.drawLine(QPointF(14.0, 3.0), QPointF(14.0, 8.0)); + painter.drawLine(QPointF(11.5, 5.5), QPointF(16.5, 5.5)); + painter.drawLine(QPointF(12.2, 3.7), QPointF(15.8, 7.3)); + painter.drawLine(QPointF(15.8, 3.7), QPointF(12.2, 7.3)); + break; + } case ToolIconKind::Line: painter.drawLine(QPointF(4.0, 16.0), QPointF(16.0, 4.0)); handle(QPointF(4.0, 16.0)); diff --git a/src/ToolIcons.h b/src/ToolIcons.h index 10013d8..825c6d3 100644 --- a/src/ToolIcons.h +++ b/src/ToolIcons.h @@ -11,8 +11,10 @@ namespace scopeone::ui Rectangle, Ellipse, Polygon, + Freehand, Line, - Point + Point, + Wand }; // A line drawing of a selection tool, in the colors of the palette so that it follows the