From 2dd0ce937551fe15e09df535e099ac9b35772b78 Mon Sep 17 00:00:00 2001 From: Alexander Vieth Date: Fri, 29 Aug 2025 09:31:54 +0200 Subject: [PATCH] Color by selection mapping (#186) * WIP: color by selection mapping * Add explanations * Delay check * Better be safe * Add Reverse mapping as well * Fix false parent * Move new functions to own file * Centralize & inline check * Simpler return types and checks * Single swap * WIP: map means to HSNE embedding * Fix mapping from source of position to color by linked data * Remove debug prints * Delay surjection check for faster UI response --- CMakeLists.txt | 2 + src/MappingUtils.cpp | 109 ++++++++++++++++++++++++++ src/MappingUtils.h | 59 ++++++++++++++ src/ScatterplotPlugin.cpp | 158 ++++++++++++++++++++++++++++++-------- 4 files changed, 294 insertions(+), 34 deletions(-) create mode 100644 src/MappingUtils.cpp create mode 100644 src/MappingUtils.h diff --git a/CMakeLists.txt b/CMakeLists.txt index c590a4d..b9674d0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,6 +40,8 @@ find_package(ManiVault COMPONENTS Core PointData ClusterData ColorData ImageData set(PLUGIN src/ScatterplotPlugin.h src/ScatterplotPlugin.cpp + src/MappingUtils.h + src/MappingUtils.cpp ) set(UI diff --git a/src/MappingUtils.cpp b/src/MappingUtils.cpp new file mode 100644 index 0000000..e238064 --- /dev/null +++ b/src/MappingUtils.cpp @@ -0,0 +1,109 @@ +#include "MappingUtils.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +std::pair getSelectionMapping(const mv::Dataset& source, const mv::Dataset& target, LinkedDataCondition checkMapping) { + const std::vector& linkedDatas = source->getLinkedData(); + + if (linkedDatas.empty()) + return { nullptr, 0 } ; + + // find linked data between source and target OR source and target's parent, if target is derived and they have the same number of points + if (const auto result = std::ranges::find_if( + linkedDatas, + [&target, &checkMapping](const mv::LinkedData& linkedData) -> bool { + return checkMapping(linkedData, target); + }); + result != linkedDatas.end()) + { + return {&(*result), target->getNumPoints() }; + } + + return { nullptr, 0 }; +} + +std::pair getSelectionMappingColorsToPositions(const mv::Dataset& colors, const mv::Dataset& positions) { + auto testTargetAndParent = [](const mv::LinkedData& linkedData, const mv::Dataset& positions) -> bool { + const mv::Dataset mapTargetData = linkedData.getTargetDataset(); + return mapTargetData == positions || parentHasSameNumPoints(mapTargetData, positions); + }; + + return getSelectionMapping(colors, positions, testTargetAndParent); +} + +std::pair getSelectionMappingPositionsToColors(const mv::Dataset& positions, const mv::Dataset& colors) { + auto testTarget = [](const mv::LinkedData& linkedData, const mv::Dataset& colors) -> bool { + return linkedData.getTargetDataset() == colors; + }; + + auto [mapping, numTargetPoints] = getSelectionMapping(positions, colors, testTarget); + + if (mapping && parentHasSameNumPoints(positions, positions)) { + const auto positionsParent = positions->getParent(); + std::tie(mapping, numTargetPoints) = getSelectionMapping(positionsParent, colors, testTarget); + } + + return { mapping, numTargetPoints }; +} + +std::pair getSelectionMappingPositionSourceToColors(const mv::Dataset& positions, const mv::Dataset& colors) { + if (!positions->isDerivedData()) + return { nullptr, 0 }; + + const auto fullSourceData = positions->getSourceDataset()->getFullDataset(); + + if(!fullSourceData.isValid()) + return { nullptr, 0 }; + + return getSelectionMappingPositionsToColors(fullSourceData, colors); +} + +bool checkSurjectiveMapping(const mv::LinkedData& linkedData, const std::uint32_t numPointsInTarget) { + const std::map>& linkedMap = linkedData.getMapping().getMap(); + + std::vector found(numPointsInTarget, false); + std::uint32_t count = 0; + + for (const auto& [key, vec] : linkedMap) { + for (std::uint32_t val : vec) { + if (val >= numPointsInTarget) continue; // Skip values that are too large + + if (!found[val]) { + found[val] = true; + if (++count == numPointsInTarget) + return true; + } + } + } + + return false; // The previous loop would have returned early if the entire taget set was covered +} + +bool checkSelectionMapping(const mv::Dataset& colors, const mv::Dataset& positions) { + + // Check if there is a mapping + auto [mapping, numTargetPoints] = getSelectionMappingColorsToPositions(colors, positions); + + if (!mapping) + std::tie(mapping, numTargetPoints) = getSelectionMappingPositionsToColors(positions, colors); + + if (!mapping) + std::tie(mapping, numTargetPoints) = getSelectionMappingPositionSourceToColors(positions, colors); + + if (!mapping) + return false; + + return true; +} diff --git a/src/MappingUtils.h b/src/MappingUtils.h new file mode 100644 index 0000000..62a4cba --- /dev/null +++ b/src/MappingUtils.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +// This only checks the immedeate parent and is deliberately not recursive +// We might consider the latter in the future, but might need to cover edge cases +inline bool parentHasSameNumPoints(const mv::Dataset data, const mv::Dataset& other) { + if (!data->isDerivedData()) + return false; + + const auto parent = data->getParent(); + if (parent->getDataType() != PointType) + return false; + + const auto parentPoints = mv::Dataset(parent); + return parentPoints->getNumPoints() == other->getNumPoints(); +} + +// Is the data derived and does it's full source data have same number of points as the other data +inline bool fullSourceHasSameNumPoints(const mv::Dataset data, const mv::Dataset& other) { + if (!data->isDerivedData()) + return false; + + return data->getSourceDataset()->getFullDataset()->getNumPoints() == other->getNumPoints(); +} + +using LinkedDataCondition = std::function& target)>; + +/* Returns a mapping (linked data) from source that fulfils a given condition based on target, e.g. + auto checkMapping = [](const mv::LinkedData& linkedData, const mv::Dataset& target) -> bool { + return linkedData.getTargetDataset() == target; + }; + This function will return the first match of the condition +*/ +std::pair getSelectionMapping(const mv::Dataset& source, const mv::Dataset& target, LinkedDataCondition checkMapping); + +// Returns a mapping (linked data) from colors whose target is positions or whose target's parent has the same number of points as positions +std::pair getSelectionMappingColorsToPositions(const mv::Dataset& colors, const mv::Dataset& positions); + +// Returns a mapping (linked data) from positions whose target is colors or +// a mapping from positions' parent whose target is colors if the number of data points match +std::pair getSelectionMappingPositionsToColors(const mv::Dataset& positions, const mv::Dataset& colors); + +// Returns a mapping (linked data) from positions' source data whose target is colors +std::pair getSelectionMappingPositionSourceToColors(const mv::Dataset& positions, const mv::Dataset& colors); + +// Check if the mapping is surjective, i.e. hits all elements in the target +bool checkSurjectiveMapping(const mv::LinkedData& linkedData, const std::uint32_t numPointsInTarget); + +// returns whether there is a selection map from colors to positions or positions to colors (or respective parents) +// checks whether the mapping covers all elements in the target +bool checkSelectionMapping(const mv::Dataset& colors, const mv::Dataset& positions); diff --git a/src/ScatterplotPlugin.cpp b/src/ScatterplotPlugin.cpp index 6a3136e..c1b8b4a 100644 --- a/src/ScatterplotPlugin.cpp +++ b/src/ScatterplotPlugin.cpp @@ -1,5 +1,6 @@ #include "ScatterplotPlugin.h" +#include "MappingUtils.h" #include "ScatterplotWidget.h" #include @@ -31,6 +32,9 @@ #include #include +#include +#include +#include #include #define VIEW_SAMPLING_HTML @@ -172,17 +176,26 @@ ScatterplotPlugin::ScatterplotPlugin(const PluginFactory* factory) : }); } - // Accept both data with the same number if points and data which is derived from - // a parent that has the same number of points (e.g. for HSNE embeddings) + // Accept for recoloring: + // 1. data with the same number of points + // 2. data which is derived from a parent that has the same number of points (e.g. for HSNE embeddings), where we can use global indices for mapping + // 3. data which has a fully-covering selection mapping, that we can use for setting colors. Mapping in order of preference: + // a) from color (or it's parent) to position + // b) from color to position (or it's parent) + // c) from source of position to color + + // [1. Same number of points] const auto numPointsCandidate = candidateDataset->getNumPoints(); const auto numPointsPosition = _positionDataset->getNumPoints(); - const bool sameNumPoints = numPointsPosition == numPointsCandidate; - const bool sameNumPointsAsFull = - /*if*/ _positionDataset->isDerivedData() ? - /*then*/ _positionDataset->getSourceDataset()->getFullDataset()->getNumPoints() == numPointsCandidate : - /*else*/ false; + const bool hasSameNumPoints = numPointsPosition == numPointsCandidate; + + // [2. Derived from a parent] + const bool hasSameNumPointsAsFull = fullSourceHasSameNumPoints(_positionDataset, candidateDataset); - if (sameNumPoints || sameNumPointsAsFull) { + // [3. Full selection mapping] + const bool hasSelectionMapping = checkSelectionMapping(candidateDataset, _positionDataset); + + if (hasSameNumPoints || hasSameNumPointsAsFull || hasSelectionMapping) { // Offer the option to use the points dataset as source for points colors dropRegions << new DropWidget::DropRegion(this, "Point color", QString("Colorize %1 points with %2").arg(_positionDataset->text(), candidateDataset->text()), "palette", true, [this, candidateDataset]() { _settingsAction.getColoringAction().setCurrentColorDataset(candidateDataset); // calls addColorDataset internally @@ -190,7 +203,8 @@ ScatterplotPlugin::ScatterplotPlugin(const PluginFactory* factory) : } - if (sameNumPoints) { + // Accept for resizing and opacity: Only data with the same number of points + if (hasSameNumPoints) { // Offer the option to use the points dataset as source for points size dropRegions << new DropWidget::DropRegion(this, "Point size", QString("Size %1 points with %2").arg(_positionDataset->text(), candidateDataset->text()), "ruler-horizontal", true, [this, candidateDataset]() { _settingsAction.getPlotAction().getPointPlotAction().setCurrentPointSizeDataset(candidateDataset); @@ -647,49 +661,125 @@ void ScatterplotPlugin::positionDatasetChanged() updateData(); } -void ScatterplotPlugin::loadColors(const Dataset& points, const std::uint32_t& dimensionIndex) +void ScatterplotPlugin::loadColors(const Dataset& pointsColor, const std::uint32_t& dimensionIndex) { // Only proceed with valid points dataset - if (!points.isValid()) + if (!pointsColor.isValid()) return; - // Generate point scalars for color mapping - std::vector scalars; + const auto numColorPoints = pointsColor->getNumPoints(); - points->extractDataForDimension(scalars, dimensionIndex); + // Generate point colorScalars for color mapping + std::vector colorScalars = {}; + pointsColor->extractDataForDimension(colorScalars, dimensionIndex); - const auto numColorPoints = points->getNumPoints(); + // If number of points do not match, use a mapping + // prefer global IDs (for derived data) over selection mapping + // prefer color to position over position to color over source of position to color + if (numColorPoints != _numPoints) { + std::vector mappedColorScalars(_numPoints, std::numeric_limits::lowest()); - if (numColorPoints != _numPoints) { + try { + const bool hasSameNumPointsAsFull = fullSourceHasSameNumPoints(_positionDataset, pointsColor); - const bool sameNumPointsAsFull = - /*if*/ _positionDataset->isDerivedData() ? - /*then*/ _positionSourceDataset->getFullDataset()->getNumPoints() == numColorPoints : - /*else*/ false; + if (hasSameNumPointsAsFull) { + std::vector globalIndices = {}; + _positionDataset->getGlobalIndices(globalIndices); - if (sameNumPointsAsFull) { - std::vector globalIndices; - _positionDataset->getGlobalIndices(globalIndices); + for (std::int32_t localIndex = 0; localIndex < globalIndices.size(); localIndex++) { + mappedColorScalars[localIndex] = colorScalars[globalIndices[localIndex]]; + } - std::vector localScalars(_numPoints, 0); - std::int32_t localColorIndex = 0; + } + else if ( // mapping from color data set to position data set + const auto [selectionMapping, numPointsTarget] = getSelectionMappingColorsToPositions(pointsColor, _positionDataset); + /* check if valid */ + selectionMapping != nullptr && + numPointsTarget == _numPoints && + checkSurjectiveMapping(*selectionMapping, numPointsTarget) + ) + { + // Map values like selection + const mv::SelectionMap::Map& mapColorsToPositions = selectionMapping->getMapping().getMap(); - for (const auto& globalIndex : globalIndices) - localScalars[localColorIndex++] = scalars[globalIndex]; + for (const auto& [fromColorID, vecOfPositionIDs] : mapColorsToPositions) { + for (std::uint32_t toPositionID : vecOfPositionIDs) { + mappedColorScalars[toPositionID] = colorScalars[fromColorID]; + } + } - std::swap(localScalars, scalars); - } - else { - qWarning("Number of points used for coloring does not match number of points in data, aborting attempt to color plot"); + } + else if ( // mapping from position data set to color data set + const auto [selectionMapping, numPointsTarget] = getSelectionMappingPositionsToColors(_positionDataset, pointsColor); + /* check if valid */ + selectionMapping != nullptr && + numPointsTarget == numColorPoints && + checkSurjectiveMapping(*selectionMapping, numPointsTarget) + ) + { + // Map values like selection (in reverse, use first value that occurs) + const mv::SelectionMap::Map& mapPositionsToColors = selectionMapping->getMapping().getMap(); + + for (const auto& [fromPositionID, vecOfColorIDs] : mapPositionsToColors) { + if (mappedColorScalars[fromPositionID] != std::numeric_limits::lowest()) + continue; + for (std::uint32_t toColorID : vecOfColorIDs) { + mappedColorScalars[fromPositionID] = colorScalars[toColorID]; + } + } + + } + else if ( // mapping from source of position data set to color data set + const auto [selectionMapping, numPointsTarget] = getSelectionMappingPositionSourceToColors(_positionDataset, pointsColor); + /* check if valid */ + selectionMapping != nullptr && + numPointsTarget == numColorPoints && + checkSurjectiveMapping(*selectionMapping, numPointsTarget) + ) + { + // the selection map is from full source data of positions data to pointsColor + // we need to use both the global indices of the positions (i.e. in the source) and the linked data mapping + const mv::SelectionMap::Map& mapGlobalToColors = selectionMapping->getMapping().getMap(); + std::vector globalIndices = {}; + _positionDataset->getGlobalIndices(globalIndices); + + for (std::int32_t localIndex = 0; localIndex < globalIndices.size(); localIndex++) { + + if (mappedColorScalars[localIndex] != std::numeric_limits::lowest()) + continue; + + const auto& indxColors = mapGlobalToColors.at(globalIndices[localIndex]); // from full source (parent) to colorDataset + + for (const auto& indColors : indxColors) { + mappedColorScalars[localIndex] = colorScalars[indColors]; + } + } + + } + else { + throw std::runtime_error("Coloring data set does not match position data set in a known way, aborting attempt to color plot"); + } + + } + catch (const std::exception& e) { + qDebug() << "ScatterplotPlugin::loadColors: mapping failed -> " << e.what(); + _settingsAction.getColoringAction().getColorByAction().setCurrentIndex(0); // reset to color by constant return; } + catch (...) { + qDebug() << "ScatterplotPlugin::loadColors: mapping failed for an unknown reason."; + _settingsAction.getColoringAction().getColorByAction().setCurrentIndex(0); // reset to color by constant + return; + } + + std::swap(mappedColorScalars, colorScalars); } - assert(scalars.size() == _numPoints); + assert(colorScalars.size() == _numPoints); - // Assign scalars and scalar effect - _scatterPlotWidget->setScalars(scalars); + // Assign colorScalars and scalar effect + _scatterPlotWidget->setScalars(colorScalars); _scatterPlotWidget->setScalarEffect(PointEffect::Color); _settingsAction.getColoringAction().updateColorMapActionScalarRange();