From e1feeb6591360397cb27396b84272658ab6209ef Mon Sep 17 00:00:00 2001 From: LegendaryFire Date: Fri, 26 Dec 2025 15:58:07 -0800 Subject: [PATCH 1/3] Implement local maxima neighboring pixel suppression for better multi-contact detection --- .../detection/algorithms/suppression.hpp | 116 ++++++++++++++++++ src/contacts/detection/config.hpp | 12 ++ src/contacts/detection/detector.hpp | 28 ++++- src/core/generic/config.hpp | 5 + src/core/linux/config-loader.hpp | 2 + 5 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 src/contacts/detection/algorithms/suppression.hpp diff --git a/src/contacts/detection/algorithms/suppression.hpp b/src/contacts/detection/algorithms/suppression.hpp new file mode 100644 index 0000000..1d4dc38 --- /dev/null +++ b/src/contacts/detection/algorithms/suppression.hpp @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +#ifndef IPTSD_CONTACTS_DETECTION_ALGORITHMS_SUPPRESSION_HPP +#define IPTSD_CONTACTS_DETECTION_ALGORITHMS_SUPPRESSION_HPP + +#include +#include + +#include +#include +#include + +namespace iptsd::contacts::detection::suppression { + +/* + * Darken pixels around each maxima by a constant factor, while leaving maxima pixels unchanged. + * + * This is intended to "sharpen" peaks / deepen valleys between peaks so that cluster spanning + * is less likely to connect neighboring contacts. + * + * Behavior: + * - out is initialized as a copy of in + * - for each maxima, all pixels within euclidean radius (excluding the center pixel) are reduced: + * out(y,x) = min(out(y,x), in(y,x) * factor) + * - maxima pixels are restored to their original values (exactly unchanged) + * + * Notes: + * - If neighborhoods overlap, the result is still "at most factor" darkening (no stacking), + * because we always compare against (in * factor), not (out * factor). + */ +template +void darken_around_maximas(const DenseBase &in, + const std::vector &maximas, + const Eigen::Index radius, + const typename DenseBase::Scalar factor, + DenseBase &out) +{ + using T = typename DenseBase::Scalar; + + out = in; + + if (maximas.empty()) + return; + + if (radius <= 0) + return; + + // Clamp factor to sane range. (factor = 1 => no-op, factor = 0 => nuke neighbors to 0) + const T f = std::clamp(factor, casts::to(0), casts::to(1)); + if (f >= casts::to(1)) + return; + + const Eigen::Index cols = in.cols(); + const Eigen::Index rows = in.rows(); + + // Precompute offsets in a disk (euclidean radius). + const isize r = casts::to_signed(radius); + const isize r2 = r * r; + + std::vector> offsets; + offsets.reserve(static_cast((2 * r + 1) * (2 * r + 1))); + + for (isize dy = -r; dy <= r; ++dy) { + for (isize dx = -r; dx <= r; ++dx) { + if (dx == 0 && dy == 0) + continue; + + if (((dx * dx) + (dy * dy)) <= r2) + offsets.emplace_back(dx, dy); + } + } + + // Darken neighbors. + for (const Point &p : maximas) { + const Eigen::Index cx = p.x(); + const Eigen::Index cy = p.y(); + + // Skip invalid points defensively (shouldn't happen, but costs nothing). + if (cx < 0 || cx >= cols || cy < 0 || cy >= rows) + continue; + + for (const auto &[dx, dy] : offsets) { + const isize nx_s = casts::to_signed(cx) + dx; + const isize ny_s = casts::to_signed(cy) + dy; + + if (nx_s < 0 || ny_s < 0) + continue; + + const Eigen::Index nx = casts::to_eigen(nx_s); + const Eigen::Index ny = casts::to_eigen(ny_s); + + if (nx >= cols || ny >= rows) + continue; + + const T target = in(ny, nx) * f; + + // Use min() against (in*factor) to prevent multi-maxima "stacking" darkness. + out(ny, nx) = std::min(out(ny, nx), target); + } + } + + // Restore maxima pixels exactly. + for (const Point &p : maximas) { + const Eigen::Index x = p.x(); + const Eigen::Index y = p.y(); + + if (x < 0 || x >= cols || y < 0 || y >= rows) + continue; + + out(y, x) = in(y, x); + } +} + +} // namespace iptsd::contacts::detection::suppression + +#endif // IPTSD_CONTACTS_DETECTION_ALGORITHMS_SUPPRESSION_HPP diff --git a/src/contacts/detection/config.hpp b/src/contacts/detection/config.hpp index 46ac127..121228c 100644 --- a/src/contacts/detection/config.hpp +++ b/src/contacts/detection/config.hpp @@ -49,6 +49,18 @@ struct Config { * the recursive cluster search will stop once it reaches it. */ T deactivation_threshold = casts::to(20); + + /* + * Radius in pixels around each maxima to darken (excluding the maxima pixel itself). + * Setting this value to zero disables local maxima surrounding pixel suppression. + */ + usize peak_suppression_radius = 0; + + /* + * The factor in which to darken surrounding pixels when using peak suppression. + * Multiplies neighbors by this factor (e.g., 0.5 = reduce brightness by 50%). + */ + T peak_suppression_factor = casts::to(0); }; } // namespace iptsd::contacts::detection diff --git a/src/contacts/detection/detector.hpp b/src/contacts/detection/detector.hpp index 0f396d2..d8af4c5 100644 --- a/src/contacts/detection/detector.hpp +++ b/src/contacts/detection/detector.hpp @@ -12,6 +12,7 @@ #include "algorithms/maximas.hpp" #include "algorithms/neutral.hpp" #include "algorithms/overlaps.hpp" +#include "algorithms/suppression.hpp" #include "config.hpp" #include @@ -43,6 +44,9 @@ class Detector { // The blurred heatmap. Image m_img_blurred {}; + // A copy of the blurred heatmap used for cluster spanning when suppression is enabled. + Image m_img_span {}; + // The kernel that is used for blurring. Matrix3 m_kernel_blur = kernels::gaussian(gsl::narrow_cast(0.75)); @@ -97,6 +101,7 @@ class Detector { if (brows != rows || bcols != cols) { m_img_neutral.conservativeResize(rows, cols); m_img_blurred.conservativeResize(rows, cols); + m_img_span.conservativeResize(rows, cols); m_fitting_temp.conservativeResize(rows, cols); if (m_config.normalize) @@ -126,12 +131,29 @@ class Detector { const T athresh = m_config.activation_threshold; const T dthresh = m_config.deactivation_threshold; - // Search for local maximas + // Find local maximas on the original blurred heatmap (stable maxima positions) maximas::find(m_img_blurred, athresh, m_maximas); - // Iterate over the maximas and start building clusters + // Create a suppressed copy used only for cluster spanning + const Image *span_src = &m_img_blurred; + + const Eigen::Index suppression_radius = m_config.peak_suppression_radius; + if (suppression_radius >= 1) { + const T suppression_factor = m_config.peak_suppression_factor; + suppression::darken_around_maximas( + m_img_blurred, + m_maximas, + suppression_radius, + suppression_factor, + m_img_span + ); + + span_src = &m_img_span; + } + + // Iterate over the maximas and start building clusters (on suppressed image if enabled) for (const Point &point : m_maximas) { - Box cluster = cluster::span(m_img_blurred, point, athresh, dthresh); + Box cluster = cluster::span(*span_src, point, athresh, dthresh); if (cluster.isEmpty()) continue; diff --git a/src/core/generic/config.hpp b/src/core/generic/config.hpp index 7bb08c7..f16553d 100644 --- a/src/core/generic/config.hpp +++ b/src/core/generic/config.hpp @@ -50,6 +50,8 @@ class Config { f64 contacts_size_max = 2; f64 contacts_aspect_min = 1; f64 contacts_aspect_max = 2.5; + usize contacts_peak_suppression_radius = 0; + f64 contacts_peak_suppression_factor = 0; // [Stylus] bool stylus_disable = false; @@ -100,6 +102,9 @@ class Config { config.detection.neutral_value_offset = nval_offset / 255.0; config.detection.neutral_value_backoff = 16; // TODO: config option + config.detection.peak_suppression_factor = this->contacts_peak_suppression_factor; + config.detection.peak_suppression_radius = this->contacts_peak_suppression_radius; + const f64 diagonal = std::hypot(this->width, this->height); config.validation.track_validity = true; diff --git a/src/core/linux/config-loader.hpp b/src/core/linux/config-loader.hpp index 073da29..0c4e7c3 100644 --- a/src/core/linux/config-loader.hpp +++ b/src/core/linux/config-loader.hpp @@ -175,6 +175,8 @@ class ConfigLoader { this->get(ini, "Contacts", "SizeMax", m_config.contacts_size_max); this->get(ini, "Contacts", "AspectMin", m_config.contacts_aspect_max); this->get(ini, "Contacts", "AspectMax", m_config.contacts_aspect_max); + this->get(ini, "Contacts", "PeakSuppresionRadius", m_config.contacts_peak_suppression_radius); + this->get(ini, "Contacts", "PeakSuppresionFactor", m_config.contacts_peak_suppression_factor); this->get(ini, "Stylus", "Disable", m_config.stylus_disable); this->get(ini, "Stylus", "TipDistance", m_config.stylus_tip_distance); From ceba95a9e82da66b224487c374ce466546f4b857 Mon Sep 17 00:00:00 2001 From: LegendaryFire Date: Fri, 26 Dec 2025 16:20:29 -0800 Subject: [PATCH 2/3] Update configuration docs & add defaults for Surface Laptop Studio 2 --- etc/iptsd.conf | 12 ++++++++++++ etc/presets/surface-laptop-studio-2.conf | 12 ++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 etc/presets/surface-laptop-studio-2.conf diff --git a/etc/iptsd.conf b/etc/iptsd.conf index aecf5d4..ef5058f 100644 --- a/etc/iptsd.conf +++ b/etc/iptsd.conf @@ -65,6 +65,18 @@ ## # NeutralValue = 0 +## +## Radius in pixels around each maxima to darken (excluding the maxima pixel itself). +## Setting this value to zero disables local maxima surrounding pixel suppression. +## +# PeakSuppresionRadius = 0 + +## +## The factor in which to darken surrounding pixels when using peak suppression. +## Multiplies neighbors by this factor (e.g., 0.7 = reduce brightness by 30%). +## +# PeakSuppresionFactor = 0.00 + ## ## The activation threshold for blob detection (Range 0 - 255). ## If a pixel of the heatmap is larger than this value plus the neutral value, the blob detector diff --git a/etc/presets/surface-laptop-studio-2.conf b/etc/presets/surface-laptop-studio-2.conf new file mode 100644 index 0000000..1ae8ed4 --- /dev/null +++ b/etc/presets/surface-laptop-studio-2.conf @@ -0,0 +1,12 @@ +[Device] +Vendor = 0x045E +Product = 0x0C46 + +[Contacts] +Neutral = average +NeutralValue = 10 +PeakSuppresionRadius = 2 +PeakSuppresionFactor = 0.25 +ActivationThreshold = 20 +DeactivationThreshold = 16 +OrientationThresholdMax = 90 \ No newline at end of file From 907fda07638596954ae8ae9f60d8da4e18fef24e Mon Sep 17 00:00:00 2001 From: LegendaryFire Date: Sat, 27 Dec 2025 00:02:28 -0800 Subject: [PATCH 3/3] Fix spelling typo in config names --- etc/iptsd.conf | 4 ++-- etc/presets/surface-laptop-studio-2.conf | 4 ++-- src/core/linux/config-loader.hpp | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/etc/iptsd.conf b/etc/iptsd.conf index ef5058f..140fb2b 100644 --- a/etc/iptsd.conf +++ b/etc/iptsd.conf @@ -69,13 +69,13 @@ ## Radius in pixels around each maxima to darken (excluding the maxima pixel itself). ## Setting this value to zero disables local maxima surrounding pixel suppression. ## -# PeakSuppresionRadius = 0 +# PeakSuppressionRadius = 0 ## ## The factor in which to darken surrounding pixels when using peak suppression. ## Multiplies neighbors by this factor (e.g., 0.7 = reduce brightness by 30%). ## -# PeakSuppresionFactor = 0.00 +# PeakSuppressionFactor = 0.00 ## ## The activation threshold for blob detection (Range 0 - 255). diff --git a/etc/presets/surface-laptop-studio-2.conf b/etc/presets/surface-laptop-studio-2.conf index 1ae8ed4..405557e 100644 --- a/etc/presets/surface-laptop-studio-2.conf +++ b/etc/presets/surface-laptop-studio-2.conf @@ -5,8 +5,8 @@ Product = 0x0C46 [Contacts] Neutral = average NeutralValue = 10 -PeakSuppresionRadius = 2 -PeakSuppresionFactor = 0.25 +PeakSuppressionRadius = 2 +PeakSuppressionFactor = 0.25 ActivationThreshold = 20 DeactivationThreshold = 16 OrientationThresholdMax = 90 \ No newline at end of file diff --git a/src/core/linux/config-loader.hpp b/src/core/linux/config-loader.hpp index 0c4e7c3..9ddb1a2 100644 --- a/src/core/linux/config-loader.hpp +++ b/src/core/linux/config-loader.hpp @@ -175,8 +175,8 @@ class ConfigLoader { this->get(ini, "Contacts", "SizeMax", m_config.contacts_size_max); this->get(ini, "Contacts", "AspectMin", m_config.contacts_aspect_max); this->get(ini, "Contacts", "AspectMax", m_config.contacts_aspect_max); - this->get(ini, "Contacts", "PeakSuppresionRadius", m_config.contacts_peak_suppression_radius); - this->get(ini, "Contacts", "PeakSuppresionFactor", m_config.contacts_peak_suppression_factor); + this->get(ini, "Contacts", "PeakSuppressionRadius", m_config.contacts_peak_suppression_radius); + this->get(ini, "Contacts", "PeakSuppressionFactor", m_config.contacts_peak_suppression_factor); this->get(ini, "Stylus", "Disable", m_config.stylus_disable); this->get(ini, "Stylus", "TipDistance", m_config.stylus_tip_distance);