diff --git a/.ci/build-freebsd.sh b/.ci/build-freebsd.sh
index 7e10691203..439d54890b 100755
--- a/.ci/build-freebsd.sh
+++ b/.ci/build-freebsd.sh
@@ -4,7 +4,7 @@
# Note: Tried to use git submodule status, but it takes over 20 seconds
# shellcheck disable=SC2046
git config --global --add safe.directory .
-git submodule -q update --init --depth 1 $(awk '/path/ && !/llvm/ && !/opencv/ && !/libpng/ && !/libsdl-org/ && !/curl/ && !/zlib/ && !/libusb/ && !/feralinteractive/ { print $3 }' .gitmodules)
+git submodule -q update --init --depth 1 $(awk '/path/ && !/llvm/ && !/libpng/ && !/libsdl-org/ && !/curl/ && !/zlib/ && !/libusb/ && !/feralinteractive/ { print $3 }' .gitmodules)
CONFIGURE_ARGS="
-DWITH_LLVM=ON
@@ -15,7 +15,6 @@ CONFIGURE_ARGS="
-DUSE_SYSTEM_CURL=ON
-DUSE_SYSTEM_LIBPNG=ON
-DUSE_SYSTEM_LIBUSB=ON
- -DUSE_SYSTEM_OPENCV=ON
"
# base Clang workaround (missing clang-scan-deps)
diff --git a/.ci/build-linux-aarch64.sh b/.ci/build-linux-aarch64.sh
index 26fc1dc9d9..914d45333f 100755
--- a/.ci/build-linux-aarch64.sh
+++ b/.ci/build-linux-aarch64.sh
@@ -8,7 +8,7 @@ git config --global --add safe.directory '*'
# Pull all the submodules except some
# shellcheck disable=SC2046
-git submodule -q update --init $(awk '/path/ && !/llvm/ && !/opencv/ && !/libsdl-org/ && !/curl/ && !/zlib/ { print $3 }' .gitmodules)
+git submodule -q update --init $(awk '/path/ && !/llvm/ && !/libsdl-org/ && !/curl/ && !/zlib/ { print $3 }' .gitmodules)
mkdir build && cd build || exit 1
@@ -36,7 +36,6 @@ cmake .. \
-DUSE_SDL=ON \
-DUSE_SYSTEM_SDL=ON \
-DUSE_SYSTEM_FFMPEG=ON \
- -DUSE_SYSTEM_OPENCV=ON \
-DUSE_DISCORD_RPC=ON \
-DOpenGL_GL_PREFERENCE=LEGACY \
-DLLVM_DIR=/opt/llvm/lib/cmake/llvm \
diff --git a/.ci/build-linux.sh b/.ci/build-linux.sh
index d9f66f6a04..e9ee3230f1 100755
--- a/.ci/build-linux.sh
+++ b/.ci/build-linux.sh
@@ -9,7 +9,7 @@ git config --global --add safe.directory '*'
# Pull all the submodules except some
# Note: Tried to use git submodule status, but it takes over 20 seconds
# shellcheck disable=SC2046
-git submodule -q update --init $(awk '/path/ && !/llvm/ && !/opencv/ && !/libsdl-org/ && !/curl/ && !/zlib/ { print $3 }' .gitmodules)
+git submodule -q update --init $(awk '/path/ && !/llvm/ && !/libsdl-org/ && !/curl/ && !/zlib/ { print $3 }' .gitmodules)
mkdir build && cd build || exit 1
@@ -47,7 +47,6 @@ cmake .. \
-DUSE_SDL=ON \
-DUSE_SYSTEM_SDL=ON \
-DUSE_SYSTEM_FFMPEG=ON \
- -DUSE_SYSTEM_OPENCV=ON \
-DUSE_DISCORD_RPC=ON \
-DOpenGL_GL_PREFERENCE=LEGACY \
-DLLVM_DIR=/opt/llvm/lib/cmake/llvm \
diff --git a/.ci/build-mac.sh b/.ci/build-mac.sh
index 5b39b1ad2a..ed8788cce5 100755
--- a/.ci/build-mac.sh
+++ b/.ci/build-mac.sh
@@ -43,7 +43,6 @@ ditto "/tmp/Qt/$QT_VER" "qt-downloader/$QT_VER"
export Qt6_DIR="$WORKDIR/qt-downloader/$QT_VER/clang_64/lib/cmake/Qt$QT_VER_MAIN"
export SDL3_DIR="/opt/local/lib/cmake/SDL3"
-export OpenCV_DIR="/opt/local/libexec/opencv4"
export PATH="/opt/local/libexec/llvm-$LLVM_COMPILER_VER/bin:/$WORKDIR/qt-downloader/$QT_VER/clang_64/bin:/opt/local/bin:/opt/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/X11/bin:/Library/Apple/usr/bin:$PATH"
export LDFLAGS="-L/opt/local/lib -Wl,-rpath,/opt/local/lib"
export VULKAN_SDK="/opt/local"
@@ -51,7 +50,7 @@ export LLVM_DIR="/opt/local/libexec/llvm-$LLVM_COMPILER_VER"
# Pull all the submodules except some
# shellcheck disable=SC2046
-git submodule -q update --init --depth=1 --jobs=8 $(awk '/path/ && !/llvm/ && !/opencv/ && !/libsdl-org/ && !/feralinteractive/ && !/curl/ && !/zlib/ { print $3 }' .gitmodules)
+git submodule -q update --init --depth=1 --jobs=8 $(awk '/path/ && !/llvm/ && !/libsdl-org/ && !/feralinteractive/ && !/curl/ && !/zlib/ { print $3 }' .gitmodules)
mkdir build && cd build || exit 1
# The below should be uncommented once bugs with Qt 6 QListWidgets when using the OS 26 visual style are resolved.
@@ -73,7 +72,6 @@ cmake .. \
-DUSE_PRECOMPILED_HEADERS=OFF \
-DUSE_SYSTEM_MVK=ON \
-DUSE_SYSTEM_SDL=ON \
- -DUSE_SYSTEM_OPENCV=ON \
-G Ninja
ninja; build_status=$?;
diff --git a/.ci/build-windows-clang.sh b/.ci/build-windows-clang.sh
index 4f07688ee5..ac52f2f471 100644
--- a/.ci/build-windows-clang.sh
+++ b/.ci/build-windows-clang.sh
@@ -8,7 +8,7 @@ MSYS2="${2:-clang64}"
# Pull all the submodules except some
# Note: Tried to use git submodule status, but it takes over 20 seconds
# shellcheck disable=SC2046
-git submodule -q update --init $(awk '/path/ && !/llvm/ && !/opencv/ && !/ffmpeg/ && !/curl/ && !/FAudio/ && !/zlib/ { print $3 }' .gitmodules)
+git submodule -q update --init $(awk '/path/ && !/llvm/ && !/ffmpeg/ && !/curl/ && !/FAudio/ && !/zlib/ { print $3 }' .gitmodules)
mkdir build && cd build || exit 1
@@ -42,7 +42,6 @@ cmake .. \
-DUSE_SDL=ON \
-DUSE_SYSTEM_SDL=OFF \
-DUSE_SYSTEM_FFMPEG=ON \
- -DUSE_SYSTEM_OPENCV=ON \
-DUSE_SYSTEM_OPENAL=OFF \
-DUSE_DISCORD_RPC=ON \
-DOpenGL_GL_PREFERENCE=LEGACY \
diff --git a/.ci/install-freebsd.sh b/.ci/install-freebsd.sh
index 836ff2f4d9..193ea3d979 100755
--- a/.ci/install-freebsd.sh
+++ b/.ci/install-freebsd.sh
@@ -15,4 +15,4 @@ pkg install "llvm$LLVM_COMPILER_VER"
pkg install git ccache cmake ninja "qt$QT_VER_MAIN-multimedia" "qt$QT_VER_MAIN-svg" glew openal-soft ffmpeg pcre2
# Optional dependencies (libevdev is pulled by qtX-base)
-pkg install pkgconf alsa-lib pulseaudio sdl3 evdev-proto vulkan-headers vulkan-loader opencv
+pkg install pkgconf alsa-lib pulseaudio sdl3 evdev-proto vulkan-headers vulkan-loader
diff --git a/.github/parameters/setup-macports.yaml b/.github/parameters/setup-macports.yaml
index 1da4c67d44..fba26efc02 100644
--- a/.github/parameters/setup-macports.yaml
+++ b/.github/parameters/setup-macports.yaml
@@ -2,7 +2,6 @@ ports:
- name: abseil
- name: ccache
- name: gtest
- - name: opencv4
- name: sdl3
- name: vulkan-headers
- name: vulkan-loader
diff --git a/.github/workflows/rpcs3.yml b/.github/workflows/rpcs3.yml
index 0303fd1128..c637ea6f62 100644
--- a/.github/workflows/rpcs3.yml
+++ b/.github/workflows/rpcs3.yml
@@ -534,7 +534,6 @@ jobs:
mingw-w64-clang-${{ matrix.arch }}-ninja
mingw-w64-clang-${{ matrix.arch }}-llvm
mingw-w64-clang-${{ matrix.arch }}-ffmpeg
- mingw-w64-clang-${{ matrix.arch }}-opencv
mingw-w64-clang-${{ matrix.arch }}-iconv
mingw-w64-clang-${{ matrix.arch }}-glew
mingw-w64-clang-${{ matrix.arch }}-vulkan
diff --git a/.gitmodules b/.gitmodules
index 4c1c0f0f12..868c64158a 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -88,10 +88,6 @@
path = 3rdparty/stblib/stb
url = ../../nothings/stb.git
ignore = dirty
-[submodule "3rdparty/opencv/opencv"]
- path = 3rdparty/opencv/opencv
- url = ../../Megamouse/opencv_minimal.git
- ignore = dirty
[submodule "3rdparty/fusion/fusion"]
path = 3rdparty/fusion/fusion
url = ../../xioTechnologies/Fusion.git
diff --git a/3rdparty/CMakeLists.txt b/3rdparty/CMakeLists.txt
index b25afb8834..4d1fc687ef 100644
--- a/3rdparty/CMakeLists.txt
+++ b/3rdparty/CMakeLists.txt
@@ -344,9 +344,6 @@ add_subdirectory(miniupnp EXCLUDE_FROM_ALL)
# RTMIDI
add_subdirectory(rtmidi EXCLUDE_FROM_ALL)
-# OPENCV
-add_subdirectory(opencv EXCLUDE_FROM_ALL)
-
# FUSION
add_subdirectory(fusion EXCLUDE_FROM_ALL)
@@ -383,6 +380,5 @@ add_library(3rdparty::soundtouch ALIAS soundtouch)
add_library(3rdparty::sdl3 ALIAS ${SDL3_TARGET})
add_library(3rdparty::miniupnpc ALIAS 3rdparty_miniupnpc)
add_library(3rdparty::rtmidi ALIAS rtmidi)
-add_library(3rdparty::opencv ALIAS ${OPENCV_TARGET})
add_library(3rdparty::fusion ALIAS Fusion)
add_library(3rdparty::feralinteractive ALIAS 3rdparty_feralinteractive)
diff --git a/3rdparty/opencv/CMakeLists.txt b/3rdparty/opencv/CMakeLists.txt
deleted file mode 100644
index 93441d6546..0000000000
--- a/3rdparty/opencv/CMakeLists.txt
+++ /dev/null
@@ -1,21 +0,0 @@
-# OpenCV
-
-set(OPENCV_TARGET 3rdparty_dummy_lib PARENT_SCOPE)
-
-if (USE_SYSTEM_OPENCV)
- message(STATUS "RPCS3: using system OpenCV")
- find_package(OpenCV COMPONENTS core imgproc)
-
- if(OPENCV_FOUND)
- message(STATUS "RPCS3: found system OpenCV")
- include_directories(${OpenCV_INCLUDE_DIRS})
- add_library(3rdparty_opencv INTERFACE)
- target_link_libraries(3rdparty_opencv INTERFACE ${OpenCV_LIBS})
- target_compile_definitions(3rdparty_opencv INTERFACE -DHAVE_OPENCV)
- set(OPENCV_TARGET 3rdparty_opencv PARENT_SCOPE)
- else()
- message(WARNING "RPCS3: OpenCV not found. Building without OpenCV support")
- endif()
-else()
- message(WARNING "RPCS3: Building without OpenCV support")
-endif()
diff --git a/3rdparty/opencv/opencv b/3rdparty/opencv/opencv
deleted file mode 160000
index d4dde8afe3..0000000000
--- a/3rdparty/opencv/opencv
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit d4dde8afe3098f33f253278e25d45d6554afb10c
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 179e835b3e..3b3c9a0968 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -51,7 +51,6 @@ option(USE_SYSTEM_LIBUSB "Prefer system libusb instead of the builtin one" OFF)
option(USE_SYSTEM_MINIUPNPC "Prefer system MiniUPnPc instead of the builtin one" OFF)
option(USE_SYSTEM_MVK "Prefer system MoltenVK instead of the builtin one" OFF)
option(USE_SYSTEM_OPENAL "Prefer system OpenAL instead of the prebuild one" ${USE_SYSTEM_OPENAL_DEFAULT})
-option(USE_SYSTEM_OPENCV "Prefer system OpenCV instead of the builtin one" ON)
option(USE_SYSTEM_PUGIXML "Prefer system pugixml instead of the builtin one" OFF)
option(USE_SYSTEM_RTMIDI "Prefer system RtMidi instead of the builtin one" OFF)
option(USE_SYSTEM_SDL "Prefer system SDL instead of the builtin one" ON)
diff --git a/CMakePresets.json b/CMakePresets.json
index a81db6227d..f2ad04077c 100644
--- a/CMakePresets.json
+++ b/CMakePresets.json
@@ -13,7 +13,6 @@
"USE_SYSTEM_CURL": "OFF",
"USE_SYSTEM_ZLIB": "OFF",
"USE_SYSTEM_LIBPNG": "OFF",
- "USE_SYSTEM_OPENCV": "ON",
"BUILD_LLVM": "OFF",
"STATIC_LINK_LLVM": "ON"
}
@@ -30,7 +29,6 @@
"USE_SYSTEM_CURL": "OFF",
"USE_SYSTEM_ZLIB": "OFF",
"USE_SYSTEM_LIBPNG": "OFF",
- "USE_SYSTEM_OPENCV": "ON",
"LLVM_ENABLE_LIBCXX": "ON",
"BUILD_LLVM": "OFF",
"STATIC_LINK_LLVM": "ON"
@@ -58,7 +56,6 @@
"USE_SYSTEM_CURL": "OFF",
"USE_SYSTEM_ZLIB": "OFF",
"USE_SYSTEM_OPENAL": "OFF",
- "USE_SYSTEM_OPENCV": "OFF",
"BUILD_LLVM": "ON",
"STATIC_LINK_LLVM": "ON"
},
diff --git a/buildfiles/msvc/rpcs3_default.props b/buildfiles/msvc/rpcs3_default.props
index a7d830c5df..6199b446f4 100644
--- a/buildfiles/msvc/rpcs3_default.props
+++ b/buildfiles/msvc/rpcs3_default.props
@@ -9,8 +9,6 @@
$(SolutionDir)build\tmp\$(ProjectName)-$(Configuration)-$(Platform)\
$(SolutionDir)packages\Microsoft.googletest.v140.windesktop.msvcstl.static.rt-static.1.8.1.8\build\native\Microsoft.googletest.v140.windesktop.msvcstl.static.rt-static.targets
true
- $(SolutionDir)3rdparty\opencv\opencv\opencv414\build
- opencv_world4140
diff --git a/rpcs3/CMakeLists.txt b/rpcs3/CMakeLists.txt
index 852bf4fdf3..5073d6c649 100644
--- a/rpcs3/CMakeLists.txt
+++ b/rpcs3/CMakeLists.txt
@@ -67,7 +67,6 @@ if (NOT ANDROID)
3rdparty::wolfssl
3rdparty::libcurl
3rdparty::zlib
- 3rdparty::opencv
3rdparty::fusion)
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
@@ -184,6 +183,7 @@ if(BUILD_RPCS3_TESTS)
tests/test.cpp
tests/test_address_range.cpp
tests/test_bit_set.cpp
+ tests/test_blob_detector.cpp
tests/test_cctype.cpp
tests/test_dmux_pamf.cpp
tests/test_fmt.cpp
diff --git a/rpcs3/Emu/Cell/Modules/cellGem.cpp b/rpcs3/Emu/Cell/Modules/cellGem.cpp
index d32bbcc4c5..70da44545e 100644
--- a/rpcs3/Emu/Cell/Modules/cellGem.cpp
+++ b/rpcs3/Emu/Cell/Modules/cellGem.cpp
@@ -1707,8 +1707,8 @@ public:
m_tracker.set_active(gem_num, controller.enabled_tracking && controller.status == CELL_GEM_STATUS_READY);
m_tracker.set_hue(gem_num, g_cfg.io.allow_move_hue_set_by_game ? controller.hue : config->hue);
- m_tracker.set_hue_threshold(gem_num, config->hue_threshold);
- m_tracker.set_saturation_threshold(gem_num, config->saturation_threshold);
+ m_tracker.set_hue_threshold(gem_num, static_cast(config->hue_threshold.get()));
+ m_tracker.set_saturation_threshold(gem_num, static_cast(config->saturation_threshold.get()));
}
m_tracker.set_min_radius(static_cast(g_cfg_move.min_radius) / 100.0f);
diff --git a/rpcs3/Input/blob_detector.cpp b/rpcs3/Input/blob_detector.cpp
new file mode 100644
index 0000000000..0bd49bdbc3
--- /dev/null
+++ b/rpcs3/Input/blob_detector.cpp
@@ -0,0 +1,941 @@
+#include "stdafx.h"
+#include "blob_detector.h"
+
+#include
+#include
+#include
+#include
+#include
+
+f32 blob_detector::blob::fill_ratio() const
+{
+ if (!area)
+ {
+ return 0.0f;
+ }
+
+ // The radius goes through the centers of the boundary pixels, so we add half a pixel to cover the whole pixels.
+ const f32 r = radius + 0.5f;
+ return area / (std::numbers::pi_v * r * r);
+}
+
+f32 blob_detector::blob::ellipse_fill_ratio() const
+{
+ if (!area || major_axis <= 0.0f || minor_axis <= 0.0f)
+ {
+ return 0.0f;
+ }
+
+ return area / (std::numbers::pi_v * major_axis * minor_axis);
+}
+
+u32 blob_detector::find_root(u32 label)
+{
+ while (m_parents[label] != label)
+ {
+ // Path halving
+ m_parents[label] = m_parents[m_parents[label]];
+ label = m_parents[label];
+ }
+
+ return label;
+}
+
+void blob_detector::unite(u32 a, u32 b)
+{
+ const u32 root_a = find_root(a);
+ const u32 root_b = find_root(b);
+
+ if (root_a == root_b)
+ {
+ return;
+ }
+
+ // Keep the smaller label as root
+ if (root_a < root_b)
+ {
+ m_parents[root_b] = root_a;
+ }
+ else
+ {
+ m_parents[root_a] = root_b;
+ }
+}
+
+void blob_detector::fill_holes()
+{
+ // Background regions are 4-connected (the complement of the 8-connected foreground).
+ constexpr u8 outside = 2; // Background that is connected to the image border
+ constexpr u8 visited = 3; // Background that is part of a hole
+
+ const u32 width = m_width;
+ const u32 height = m_height;
+ const u32 size = width * height;
+
+ // Flood fill the background from the image border
+ m_stack.clear();
+
+ const auto push_outside = [this](u32 index)
+ {
+ if (m_mask[index] == 0)
+ {
+ m_mask[index] = outside;
+ m_stack.push_back(index);
+ }
+ };
+
+ for (u32 x = 0; x < width; x++)
+ {
+ push_outside(x);
+ push_outside((height - 1) * width + x);
+ }
+
+ for (u32 y = 0; y < height; y++)
+ {
+ push_outside(y * width);
+ push_outside(y * width + width - 1);
+ }
+
+ while (!m_stack.empty())
+ {
+ const u32 index = m_stack.back();
+ m_stack.pop_back();
+
+ const u32 x = index % width;
+ const u32 y = index / width;
+
+ if (x > 0) push_outside(index - 1);
+ if (x + 1 < width) push_outside(index + 1);
+ if (y > 0) push_outside(index - width);
+ if (y + 1 < height) push_outside(index + width);
+ }
+
+ // All remaining background pixels are part of a hole.
+ // Only fill holes that are enclosed by a single blob. A hole that touches multiple blobs is the gap between an outer blob and the blobs inside of it.
+ // Also skip holes that are larger than their blob. Those are usually large areas that are enclosed by a thin outline and not a ring.
+ for (u32 start = 0; start < size; start++)
+ {
+ if (m_mask[start] != 0)
+ {
+ continue;
+ }
+
+ u32 hole_label = 0;
+ bool single_blob = true;
+
+ m_region.clear();
+ m_stack.clear();
+ m_mask[start] = visited;
+ m_stack.push_back(start);
+
+ while (!m_stack.empty())
+ {
+ const u32 index = m_stack.back();
+ m_stack.pop_back();
+ m_region.push_back(index);
+
+ const auto visit = [&](u32 neighbor)
+ {
+ const u8 value = m_mask[neighbor];
+
+ if (value == 0)
+ {
+ m_mask[neighbor] = visited;
+ m_stack.push_back(neighbor);
+ }
+ else if (value == 1)
+ {
+ const u32 label = m_labels[neighbor];
+
+ if (!hole_label)
+ {
+ hole_label = label;
+ }
+ else if (label != hole_label)
+ {
+ single_blob = false;
+ }
+ }
+ };
+
+ // Holes can't touch the image border, so all neighbors exist
+ visit(index - 1);
+ visit(index + 1);
+ visit(index - width);
+ visit(index + width);
+ }
+
+ if (single_blob && hole_label && m_region.size() <= m_areas[hole_label - 1])
+ {
+ for (u32 index : m_region)
+ {
+ m_labels[index] = hole_label;
+ }
+ }
+ }
+
+ // Filled holes are foreground now
+ for (u32 i = 0; i < size; i++)
+ {
+ m_mask[i] = m_labels[i] ? 1 : 0;
+ }
+}
+
+// Calculates the squared euclidean distance transform of one row or column in place (Felzenszwalb and Huttenlocher).
+// v needs n entries, z and d need n + 1 entries.
+static void distance_transform_1d(f32* f, u32 n, s32* v, f32* z, f32* d)
+{
+ u32 k = 0;
+ v[0] = 0;
+ z[0] = -std::numeric_limits::infinity();
+ z[1] = std::numeric_limits::infinity();
+
+ // Intersection of the parabolas at q and p
+ const auto intersection = [f](s32 q, s32 p)
+ {
+ return ((f[q] + static_cast(q * q)) - (f[p] + static_cast(p * p))) / static_cast(2 * (q - p));
+ };
+
+ for (s32 q = 1; q < static_cast(n); q++)
+ {
+ f32 s = intersection(q, v[k]);
+
+ while (s <= z[k])
+ {
+ k--;
+ s = intersection(q, v[k]);
+ }
+
+ k++;
+ v[k] = q;
+ z[k] = s;
+ z[k + 1] = std::numeric_limits::infinity();
+ }
+
+ k = 0;
+
+ for (s32 q = 0; q < static_cast(n); q++)
+ {
+ while (z[k + 1] < q)
+ {
+ k++;
+ }
+
+ const f32 dq = static_cast(q - v[k]);
+ d[q] = dq * dq + f[v[k]];
+ }
+
+ std::copy(d, d + n, f);
+}
+
+blob_detector::inscribed_circle blob_detector::find_inscribed_circle(const blob& b)
+{
+ inscribed_circle result {};
+
+ if (!b.label || b.label > m_blobs.size() || b.min_x > b.max_x || b.min_y > b.max_y || b.max_x >= m_width || b.max_y >= m_height)
+ {
+ return result;
+ }
+
+ // The center of the largest inscribed circle is the pixel with the largest distance to the background.
+ // Calculate the exact distance transform of the bounding box with a border of 1 pixel.
+ // Every pixel that doesn't belong to the blob counts as background, so other blobs in the bounding box are ignored.
+ const u32 w = b.max_x - b.min_x + 3;
+ const u32 h = b.max_y - b.min_y + 3;
+
+ // Larger than any real squared distance, but small enough to keep the calculations precise
+ const f32 far = static_cast(u64{w + h} * (w + h));
+
+ m_distances.resize(usz{w} * h);
+
+ for (u32 y = 0; y < h; y++)
+ {
+ for (u32 x = 0; x < w; x++)
+ {
+ const bool inside = x > 0 && y > 0 && x + 1 < w && y + 1 < h &&
+ m_labels[(b.min_y + y - 1) * m_width + (b.min_x + x - 1)] == b.label;
+
+ m_distances[y * w + x] = inside ? far : 0.0f;
+ }
+ }
+
+ const u32 n = std::max(w, h);
+ m_line.resize(n * 2 + 1);
+ m_parabolas.resize(n);
+ m_bounds.resize(n + 1);
+
+ f32* line = m_line.data();
+ f32* temp = line + n;
+
+ // Columns
+ for (u32 x = 0; x < w; x++)
+ {
+ for (u32 y = 0; y < h; y++) line[y] = m_distances[y * w + x];
+ distance_transform_1d(line, h, m_parabolas.data(), m_bounds.data(), temp);
+ for (u32 y = 0; y < h; y++) m_distances[y * w + x] = line[y];
+ }
+
+ // Rows
+ f32 max_distance = 0.0f;
+ u32 max_x = 0;
+ u32 max_y = 0;
+
+ for (u32 y = 0; y < h; y++)
+ {
+ f32* row = &m_distances[y * w];
+ distance_transform_1d(row, w, m_parabolas.data(), m_bounds.data(), temp);
+
+ for (u32 x = 0; x < w; x++)
+ {
+ if (row[x] > max_distance)
+ {
+ max_distance = row[x];
+ max_x = x;
+ max_y = y;
+ }
+ }
+ }
+
+ if (max_distance <= 0.0f)
+ {
+ return result;
+ }
+
+ result.center_x = static_cast(b.min_x + max_x - 1);
+ result.center_y = static_cast(b.min_y + max_y - 1);
+
+ // The distance goes to the center of the closest background pixel. Subtract half a pixel to get a radius through the boundary pixel centers.
+ result.radius = std::max(0.0f, std::sqrt(max_distance) - 0.5f);
+
+ // Find the largest distance outside of the inscribed circle. This is small if the rest of the blob is thin compared to the circle.
+ const f32 outer_radius = std::sqrt(max_distance) + 1.0f;
+ const f32 outer_radius_squared = outer_radius * outer_radius;
+ f32 max_remainder_distance = 0.0f;
+
+ for (u32 y = 0; y < h; y++)
+ {
+ const f32* row = &m_distances[y * w];
+ const f32 dy = static_cast(y) - static_cast(max_y);
+
+ for (u32 x = 0; x < w; x++)
+ {
+ const f32 dx = static_cast(x) - static_cast(max_x);
+
+ if (row[x] > max_remainder_distance && dx * dx + dy * dy > outer_radius_squared)
+ {
+ max_remainder_distance = row[x];
+ }
+ }
+ }
+
+ result.remainder_radius = max_remainder_distance > 0.0f ? std::max(0.0f, std::sqrt(max_remainder_distance) - 0.5f) : 0.0f;
+
+ return result;
+}
+
+f32 blob_detector::free_outline_ratio(const blob& b, f32 center_x, f32 center_y, f32 radius) const
+{
+ constexpr u32 sample_count = 64;
+ const f32 r = radius + 2.0f;
+ const s32 width = static_cast(m_width);
+ const s32 height = static_cast(m_height);
+
+ u32 free_count = 0;
+
+ for (u32 i = 0; i < sample_count; i++)
+ {
+ const f32 angle = i * (2.0f * std::numbers::pi_v / sample_count);
+ const s32 x = static_cast(std::lround(center_x + r * std::cos(angle)));
+ const s32 y = static_cast(std::lround(center_y + r * std::sin(angle)));
+
+ if (x < 0 || y < 0 || x >= width || y >= height || m_labels[y * width + x] != b.label)
+ {
+ free_count++;
+ }
+ }
+
+ return free_count / static_cast(sample_count);
+}
+
+bool blob_detector::is_round_bulge(const blob& b, const inscribed_circle& circle) const
+{
+ if (circle.radius <= 0.0f)
+ {
+ return false;
+ }
+
+ return circle.remainder_radius < circle.radius * 0.6f &&
+ free_outline_ratio(b, circle.center_x, circle.center_y, circle.radius) >= 0.6f;
+}
+
+bool blob_detector::fit_capsule(const blob& b, const inscribed_circle& circle, capsule& result)
+{
+ const f32 radius = circle.radius;
+
+ if (b.minor_axis <= 0.0f || radius <= 0.0f)
+ {
+ return false;
+ }
+
+ // Clearly elongated, but not a line
+ const f32 elongation = b.major_axis / b.minor_axis;
+ if (elongation < 1.2f || elongation > 5.0f)
+ {
+ return false;
+ }
+
+ // Capsules have about the same area as their moment ellipse (0.97 to 1.0). Irregular shapes have less.
+ if (b.ellipse_fill_ratio() < 0.85f)
+ {
+ return false;
+ }
+
+ // The minor axis of a capsule is 1.0 to 1.15 times its half width
+ if (b.minor_axis < radius * 0.9f || b.minor_axis > radius * 1.25f)
+ {
+ return false;
+ }
+
+ result.center_x = b.centroid_x;
+ result.center_y = b.centroid_y;
+ result.radius = radius;
+ result.half_length = std::max(0.0f, b.radius - radius); // The enclosing radius of a capsule is half its length plus its radius
+ result.dir_x = std::cos(b.angle);
+ result.dir_y = std::sin(b.angle);
+ return true;
+}
+
+// 3x3 erosion (min) or dilation (max) in two separable passes.
+// Pixels outside of the image are ignored, so blobs that touch the image border don't shrink.
+template
+static void morphology_3x3(u8* image, u8* temp, u32 width, u32 height)
+{
+ const auto op = [](u8 a, u8 b) -> u8
+ {
+ if constexpr (Erode) return a < b ? a : b;
+ else return a > b ? a : b;
+ };
+
+ // Horizontal pass
+ for (u32 y = 0; y < height; y++)
+ {
+ const u8* src = image + y * width;
+ u8* dst = temp + y * width;
+
+ if (width == 1)
+ {
+ dst[0] = src[0];
+ continue;
+ }
+
+ dst[0] = op(src[0], src[1]);
+
+ for (u32 x = 1; x + 1 < width; x++)
+ {
+ dst[x] = op(op(src[x - 1], src[x]), src[x + 1]);
+ }
+
+ dst[width - 1] = op(src[width - 2], src[width - 1]);
+ }
+
+ // Vertical pass. Missing rows at the border are replaced by the current row.
+ for (u32 y = 0; y < height; y++)
+ {
+ const u8* above = temp + (y > 0 ? y - 1 : y) * width;
+ const u8* center = temp + y * width;
+ const u8* below = temp + (y + 1 < height ? y + 1 : y) * width;
+ u8* dst = image + y * width;
+
+ for (u32 x = 0; x < width; x++)
+ {
+ dst[x] = op(op(above[x], center[x]), below[x]);
+ }
+ }
+}
+
+void blob_detector::open(std::vector& binary, u32 width, u32 height)
+{
+ const usz size = usz{width} * height;
+
+ if (binary.size() != size || !size || size > u32{umax})
+ {
+ return;
+ }
+
+ m_temp.resize(size);
+
+ // Normalize the foreground to 255, so we can use min and max
+ for (u8& value : binary)
+ {
+ value = value ? 255 : 0;
+ }
+
+ morphology_3x3(binary.data(), m_temp.data(), width, height);
+ morphology_3x3(binary.data(), m_temp.data(), width, height);
+}
+
+void blob_detector::detect(const std::vector& binary, u32 width, u32 height, bool fill_holes, u32 min_circle_area)
+{
+ m_blobs.clear();
+ m_width = 0;
+ m_height = 0;
+
+ const usz size = usz{width} * height;
+
+ if (binary.size() != size || !size || size > u32{umax})
+ {
+ m_mask.clear();
+ m_labels.clear();
+ return;
+ }
+
+ m_width = width;
+ m_height = height;
+
+ m_mask.resize(size);
+ m_labels.assign(size, 0);
+
+ for (usz i = 0; i < size; i++)
+ {
+ m_mask[i] = binary[i] ? 1 : 0;
+ }
+
+ // First pass: assign provisional labels and record which ones are connected (8-connectivity)
+ m_parents.clear();
+ m_parents.push_back(0); // Label 0 is the background
+
+ for (u32 y = 0; y < height; y++)
+ {
+ for (u32 x = 0; x < width; x++)
+ {
+ const u32 index = y * width + x;
+
+ if (!m_mask[index])
+ {
+ continue;
+ }
+
+ u32 label = 0;
+
+ const auto check_neighbor = [&](u32 neighbor)
+ {
+ const u32 neighbor_label = m_labels[neighbor];
+
+ if (!neighbor_label)
+ {
+ return;
+ }
+
+ if (!label)
+ {
+ label = neighbor_label;
+ }
+ else if (neighbor_label != label)
+ {
+ unite(label, neighbor_label);
+ }
+ };
+
+ // Check the neighbors that were already visited
+ if (x > 0)
+ {
+ check_neighbor(index - 1);
+ }
+
+ if (y > 0)
+ {
+ const u32 above = index - width;
+
+ if (x > 0) check_neighbor(above - 1);
+ check_neighbor(above);
+ if (x + 1 < width) check_neighbor(above + 1);
+ }
+
+ if (!label)
+ {
+ label = ::size32(m_parents);
+ m_parents.push_back(label);
+ }
+
+ m_labels[index] = label;
+ }
+ }
+
+ // Second pass: resolve the final labels in raster order
+ m_compact_labels.assign(m_parents.size(), 0);
+ u32 blob_count = 0;
+ m_areas.clear();
+
+ for (u32& label : m_labels)
+ {
+ if (!label)
+ {
+ continue;
+ }
+
+ u32& final_label = m_compact_labels[find_root(label)];
+
+ if (!final_label)
+ {
+ final_label = ++blob_count;
+ m_areas.push_back(0);
+ }
+
+ label = final_label;
+ m_areas[final_label - 1]++;
+ }
+
+ if (fill_holes)
+ {
+ this->fill_holes();
+ }
+
+ // Third pass: collect the blob data
+ struct sums
+ {
+ u64 x = 0;
+ u64 y = 0;
+ u64 xx = 0;
+ u64 yy = 0;
+ u64 xy = 0;
+ };
+ std::vector blob_sums(blob_count);
+
+ m_blobs.resize(blob_count);
+
+ if (m_boundaries.size() < blob_count)
+ {
+ m_boundaries.resize(blob_count);
+ }
+
+ for (u32 i = 0; i < blob_count; i++)
+ {
+ m_blobs[i].label = i + 1;
+ m_boundaries[i].clear();
+ }
+
+ for (u32 y = 0; y < height; y++)
+ {
+ for (u32 x = 0; x < width; x++)
+ {
+ const u32 index = y * width + x;
+ const u32 label = m_labels[index];
+
+ if (!label)
+ {
+ continue;
+ }
+
+ blob& b = m_blobs[label - 1];
+
+ if (!b.area)
+ {
+ b.min_x = x;
+ b.min_y = y;
+ b.max_x = x;
+ }
+
+ b.area++;
+ b.min_x = std::min(b.min_x, x);
+ b.max_x = std::max(b.max_x, x);
+ b.max_y = y;
+
+ sums& s = blob_sums[label - 1];
+ s.x += x;
+ s.y += y;
+ s.xx += u64{x} * x;
+ s.yy += u64{y} * y;
+ s.xy += u64{x} * y;
+
+ // A pixel is part of the boundary if it touches the image border or the background (4-connectivity)
+ const bool is_boundary =
+ x == 0 || y == 0 || x + 1 == width || y + 1 == height ||
+ !m_mask[index - 1] || !m_mask[index + 1] ||
+ !m_mask[index - width] || !m_mask[index + width];
+
+ if (is_boundary)
+ {
+ m_boundaries[label - 1].push_back(point{ static_cast(x), static_cast(y) });
+ }
+ }
+ }
+
+ for (usz i = 0; i < m_blobs.size(); i++)
+ {
+ blob& b = m_blobs[i];
+ const sums& s = blob_sums[i];
+ std::vector& boundary = m_boundaries[i];
+
+ const f64 area = b.area;
+ const f64 centroid_x = s.x / area;
+ const f64 centroid_y = s.y / area;
+
+ b.centroid_x = static_cast(centroid_x);
+ b.centroid_y = static_cast(centroid_y);
+ b.perimeter = ::size32(boundary);
+
+ // Fit an ellipse with the same second order central moments. The variance along an axis of a filled ellipse is (semi-axis)^2 / 4.
+ const f64 mu20 = s.xx / area - centroid_x * centroid_x;
+ const f64 mu02 = s.yy / area - centroid_y * centroid_y;
+ const f64 mu11 = s.xy / area - centroid_x * centroid_y;
+ const f64 mean = (mu20 + mu02) / 2.0;
+ const f64 half_diff = (mu20 - mu02) / 2.0;
+ const f64 delta = std::sqrt(half_diff * half_diff + mu11 * mu11);
+
+ b.major_axis = static_cast(2.0 * std::sqrt(std::max(0.0, mean + delta)));
+ b.minor_axis = static_cast(2.0 * std::sqrt(std::max(0.0, mean - delta)));
+ b.angle = static_cast(0.5 * std::atan2(2.0 * mu11, mu20 - mu02));
+
+ if (b.area < min_circle_area)
+ {
+ // Skip the expensive part for small blobs that will be ignored anyway
+ b.center_x = b.centroid_x;
+ b.center_y = b.centroid_y;
+ b.radius = 0.0f;
+ continue;
+ }
+
+ min_enclosing_circle(boundary, b.center_x, b.center_y, b.radius);
+ }
+}
+
+void blob_detector::fill_blob(std::vector& binary, const blob& b, u8 value) const
+{
+ const usz size = usz{m_width} * m_height;
+
+ if (binary.size() != size || !b.label || b.max_x >= m_width || b.max_y >= m_height)
+ {
+ return;
+ }
+
+ for (u32 y = b.min_y; y <= b.max_y; y++)
+ {
+ for (u32 x = b.min_x; x <= b.max_x; x++)
+ {
+ const u32 index = y * m_width + x;
+
+ if (m_labels[index] == b.label)
+ {
+ binary[index] = value;
+ }
+ }
+ }
+}
+
+void blob_detector::fill_blob_rgba(std::vector& rgba, const blob& b, const std::array& color) const
+{
+ const usz size = usz{m_width} * m_height * 4u;
+
+ if (rgba.size() != size || !b.label || b.max_x >= m_width || b.max_y >= m_height)
+ {
+ return;
+ }
+
+ for (u32 y = b.min_y; y <= b.max_y; y++)
+ {
+ for (u32 x = b.min_x; x <= b.max_x; x++)
+ {
+ const u32 index = y * m_width + x;
+
+ if (m_labels[index] == b.label)
+ {
+ std::copy(color.begin(), color.end(), &rgba[index * 4]);
+ }
+ }
+ }
+}
+
+void blob_detector::min_enclosing_circle(std::vector& points, f32& center_x, f32& center_y, f32& radius)
+{
+ center_x = 0.0f;
+ center_y = 0.0f;
+ radius = 0.0f;
+
+ if (points.empty())
+ {
+ return;
+ }
+
+ struct circle
+ {
+ f64 x = 0.0;
+ f64 y = 0.0;
+ f64 r2 = 0.0; // Squared radius
+ };
+
+ const auto contains = [](const circle& c, const point& p)
+ {
+ const f64 dx = p.x - c.x;
+ const f64 dy = p.y - c.y;
+ return dx * dx + dy * dy <= c.r2 * (1.0 + 1e-10) + 1e-7;
+ };
+
+ const auto circle_from_2 = [](const point& a, const point& b)
+ {
+ const f64 x = (a.x + b.x) / 2.0;
+ const f64 y = (a.y + b.y) / 2.0;
+ const f64 dx = a.x - x;
+ const f64 dy = a.y - y;
+ return circle{ x, y, dx * dx + dy * dy };
+ };
+
+ const auto circle_from_3 = [&](const point& a, const point& b, const point& c)
+ {
+ const f64 ax = a.x, ay = a.y;
+ const f64 bx = b.x, by = b.y;
+ const f64 cx = c.x, cy = c.y;
+ const f64 d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
+
+ if (std::abs(d) < 1e-12)
+ {
+ // Collinear points: use the two points that are furthest apart
+ const circle ab = circle_from_2(a, b);
+ const circle ac = circle_from_2(a, c);
+ const circle bc = circle_from_2(b, c);
+ return std::max({ ab, ac, bc }, [](const circle& l, const circle& r) { return l.r2 < r.r2; });
+ }
+
+ const f64 a2 = ax * ax + ay * ay;
+ const f64 b2 = bx * bx + by * by;
+ const f64 c2 = cx * cx + cy * cy;
+ const f64 x = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d;
+ const f64 y = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d;
+ const f64 dx = ax - x;
+ const f64 dy = ay - y;
+ return circle{ x, y, dx * dx + dy * dy };
+ };
+
+ // Randomize the order to get the expected linear runtime. Use a fixed seed to get reproducible results.
+ std::mt19937 rng(static_cast(points.size()));
+ std::shuffle(points.begin(), points.end(), rng);
+
+ circle c{ static_cast(points[0].x), static_cast(points[0].y), 0.0 };
+
+ for (usz i = 1; i < points.size(); i++)
+ {
+ if (contains(c, points[i]))
+ {
+ continue;
+ }
+
+ // points[i] has to be on the boundary
+ c = circle{ static_cast(points[i].x), static_cast(points[i].y), 0.0 };
+
+ for (usz j = 0; j < i; j++)
+ {
+ if (contains(c, points[j]))
+ {
+ continue;
+ }
+
+ // points[i] and points[j] have to be on the boundary
+ c = circle_from_2(points[i], points[j]);
+
+ for (usz k = 0; k < j; k++)
+ {
+ if (!contains(c, points[k]))
+ {
+ c = circle_from_3(points[i], points[j], points[k]);
+ }
+ }
+ }
+ }
+
+ center_x = static_cast(c.x);
+ center_y = static_cast(c.y);
+ radius = static_cast(std::sqrt(c.r2));
+}
+
+namespace rgba_draw
+{
+ static inline void set_pixel(std::vector& rgba, u32 width, u32 height, s32 x, s32 y, const std::array& color)
+ {
+ if (x < 0 || y < 0 || static_cast(x) >= width || static_cast(y) >= height)
+ {
+ return;
+ }
+
+ std::copy(color.begin(), color.end(), &rgba[(static_cast(y) * width + x) * 4]);
+ }
+
+ void circle(std::vector& rgba, u32 width, u32 height, f32 center_x, f32 center_y, s32 radius, const std::array& color, bool filled)
+ {
+ const usz size = usz{width} * height * 4u;
+
+ if (rgba.empty() || rgba.size() != size || radius < 0)
+ {
+ return;
+ }
+
+ const s32 cx = static_cast(std::lround(center_x));
+ const s32 cy = static_cast(std::lround(center_y));
+
+ // Outline: all pixels within half a pixel of the radius
+ const f32 outer = radius + 0.5f;
+ const f32 inner = std::max(0.0f, radius - 0.5f);
+ const f32 outer2 = outer * outer;
+ const f32 inner2 = inner * inner;
+ const s32 r2 = radius * radius;
+
+ const s32 y_begin = std::max(0, cy - radius - 1);
+ const s32 y_end = std::min(static_cast(height) - 1, cy + radius + 1);
+ const s32 x_begin = std::max(0, cx - radius - 1);
+ const s32 x_end = std::min(static_cast(width) - 1, cx + radius + 1);
+
+ for (s32 y = y_begin; y <= y_end; y++)
+ {
+ for (s32 x = x_begin; x <= x_end; x++)
+ {
+ const s32 dx = x - cx;
+ const s32 dy = y - cy;
+ const s32 d2 = dx * dx + dy * dy;
+
+ if (filled ? (d2 <= r2) : (d2 >= inner2 && d2 < outer2))
+ {
+ set_pixel(rgba, width, height, x, y, color);
+ }
+ }
+ }
+ }
+
+ void line(std::vector& rgba, u32 width, u32 height, s32 x0, s32 y0, s32 x1, s32 y1, const std::array& color)
+ {
+ const usz size = usz{width} * height * 4u;
+
+ if (rgba.empty() || rgba.size() != size)
+ {
+ return;
+ }
+
+ // Bresenham
+ const s32 dx = std::abs(x1 - x0);
+ const s32 dy = -std::abs(y1 - y0);
+ const s32 sx = x0 < x1 ? 1 : -1;
+ const s32 sy = y0 < y1 ? 1 : -1;
+ s32 err = dx + dy;
+
+ while (true)
+ {
+ set_pixel(rgba, width, height, x0, y0, color);
+
+ if (x0 == x1 && y0 == y1)
+ {
+ break;
+ }
+
+ const s32 e2 = 2 * err;
+
+ if (e2 >= dy)
+ {
+ err += dy;
+ x0 += sx;
+ }
+
+ if (e2 <= dx)
+ {
+ err += dx;
+ y0 += sy;
+ }
+ }
+ }
+}
diff --git a/rpcs3/Input/blob_detector.h b/rpcs3/Input/blob_detector.h
new file mode 100644
index 0000000000..07d448e685
--- /dev/null
+++ b/rpcs3/Input/blob_detector.h
@@ -0,0 +1,131 @@
+#pragma once
+
+#include "util/types.hpp"
+
+#include
+#include
+
+// Finds connected blobs in binary images.
+class blob_detector
+{
+public:
+ struct point
+ {
+ s32 x = 0;
+ s32 y = 0;
+ };
+
+ struct blob
+ {
+ u32 label = 0; // Label of the blob in labels() (1-based)
+ u32 area = 0; // Number of pixels (including filled holes)
+ u32 perimeter = 0; // Number of boundary pixels
+ u32 min_x = 0; // Bounding box
+ u32 min_y = 0;
+ u32 max_x = 0;
+ u32 max_y = 0;
+ f32 centroid_x = 0.0f; // Center of mass
+ f32 centroid_y = 0.0f;
+ f32 center_x = 0.0f; // Center of the minimum enclosing circle
+ f32 center_y = 0.0f;
+ f32 radius = 0.0f; // Radius of the minimum enclosing circle (through the boundary pixel centers)
+ f32 major_axis = 0.0f; // Semi-axes of the ellipse with the same second order moments as the blob
+ f32 minor_axis = 0.0f;
+ f32 angle = 0.0f; // Angle of the major axis in radians (-pi/2 to pi/2, y points down)
+
+ // Ratio between the area of the blob and the area of its enclosing circle.
+ // About 1.0 for a disk, 0.64 for a square and 0.5 for a half disk or a 2:1 ellipse.
+ f32 fill_ratio() const;
+
+ // Ratio between the area of the blob and the area of its moment ellipse.
+ // About 1.0 for ellipses and capsules, 0.95 for rectangles and lower for irregular shapes.
+ f32 ellipse_fill_ratio() const;
+ };
+
+ struct inscribed_circle
+ {
+ f32 center_x = 0.0f; // Center of the largest circle that fits inside the blob
+ f32 center_y = 0.0f;
+ f32 radius = 0.0f; // Radius of the largest inscribed circle (through the boundary pixel centers)
+ f32 remainder_radius = 0.0f; // Radius of the largest inscribed circle whose center lies outside of the largest inscribed circle
+ };
+
+ struct capsule
+ {
+ f32 center_x = 0.0f; // Center of the capsule (centroid of the blob)
+ f32 center_y = 0.0f;
+ f32 radius = 0.0f; // Half the width of the capsule
+ f32 half_length = 0.0f; // Half the length of the straight part
+ f32 dir_x = 1.0f; // Direction of the long axis
+ f32 dir_y = 0.0f;
+ };
+
+ // Finds all 8-connected blobs in a binary image (0 = background, anything else = foreground).
+ // If fill_holes is true, background regions that are completely enclosed by a single blob are added to that blob,
+ // unless the hole is larger than the blob itself (e.g. a large area that is only enclosed by a thin outline).
+ // Holes that touch more than one blob (e.g. the gap between a frame and a blob inside of it) are not filled.
+ // The enclosing circle is only calculated for blobs with at least min_circle_area pixels. Smaller blobs get radius 0.
+ void detect(const std::vector& binary, u32 width, u32 height, bool fill_holes, u32 min_circle_area = 0);
+
+ const std::vector& blobs() const { return m_blobs; }
+ const std::vector& labels() const { return m_labels; } // One label per pixel (0 = background)
+ u32 width() const { return m_width; }
+ u32 height() const { return m_height; }
+
+ // Sets all pixels of the blob to value. The image must have the same size as the one passed to detect().
+ void fill_blob(std::vector& binary, const blob& b, u8 value) const;
+
+ // Sets all pixels of the blob to color. The image must have the same size as the one passed to detect().
+ void fill_blob_rgba(std::vector& rgba, const blob& b, const std::array& color) const;
+
+ // Calculates the largest circle that fits inside the blob. The blob has to be one of the blobs found by the last call to detect().
+ // This is relatively expensive for large blobs, so it is only done on demand.
+ inscribed_circle find_inscribed_circle(const blob& b);
+
+ // Returns the fraction of a circle outline (slightly outside of the given circle) that doesn't belong to the blob.
+ f32 free_outline_ratio(const blob& b, f32 center_x, f32 center_y, f32 radius) const;
+
+ // Checks if the inscribed circle is a round bulge of the blob, e.g. a disk that touches a thinner object.
+ // The rest of the blob has to be clearly thinner than the circle and most of the circle outline has to be free.
+ bool is_round_bulge(const blob& b, const inscribed_circle& circle) const;
+
+ // Checks if the blob looks like a capsule (an elongated, smooth shape), e.g. a disk that is blurred by movement.
+ // circle has to be the inscribed circle of the blob. Rectangles can't be distinguished from capsules.
+ static bool fit_capsule(const blob& b, const inscribed_circle& circle, capsule& result);
+
+ // Morphological opening with a 3x3 square (erode, then dilate). Removes noise and thin lines and splits blobs that are connected by thin bridges.
+ void open(std::vector& binary, u32 width, u32 height);
+
+ // Calculates the smallest circle that contains all points (Welzl's algorithm). The points will be reordered.
+ static void min_enclosing_circle(std::vector& points, f32& center_x, f32& center_y, f32& radius);
+
+private:
+ void fill_holes();
+ u32 find_root(u32 label);
+ void unite(u32 a, u32 b);
+
+ u32 m_width = 0;
+ u32 m_height = 0;
+
+ std::vector m_mask; // 1 = foreground
+ std::vector m_labels; // Label per pixel
+ std::vector m_parents; // Union-find parents of the provisional labels
+ std::vector m_compact_labels; // Provisional root label -> final label
+ std::vector m_stack; // Pixel indices for the flood fill
+ std::vector m_region; // Pixel indices of the current hole
+ std::vector m_areas; // Area of each blob before the holes are filled
+ std::vector m_temp; // Temporary image for the morphology
+ std::vector m_distances; // Squared distances for the distance transform
+ std::vector m_line; // One row or column of the distance transform
+ std::vector m_parabolas; // Parabola positions for the distance transform
+ std::vector m_bounds; // Parabola boundaries for the distance transform
+ std::vector> m_boundaries; // Boundary pixels of each blob
+ std::vector m_blobs;
+};
+
+// Simple drawing functions for RGBA images (4 bytes per pixel, no row padding)
+namespace rgba_draw
+{
+ void circle(std::vector& rgba, u32 width, u32 height, f32 center_x, f32 center_y, s32 radius, const std::array& color, bool filled);
+ void line(std::vector& rgba, u32 width, u32 height, s32 x0, s32 y0, s32 x1, s32 y1, const std::array& color);
+}
diff --git a/rpcs3/Input/ps_move_tracker.cpp b/rpcs3/Input/ps_move_tracker.cpp
index e933151568..26bd2665bd 100644
--- a/rpcs3/Input/ps_move_tracker.cpp
+++ b/rpcs3/Input/ps_move_tracker.cpp
@@ -4,14 +4,7 @@
#include "ps_move_tracker.h"
#include
-
-#ifdef HAVE_OPENCV
-#include
-#include
-#if CV_VERSION_MAJOR >= 5
-#include
-#endif
-#endif
+#include
LOG_CHANNEL(ps_move);
@@ -112,7 +105,7 @@ void ps_move_tracker::set_hue(u32 index, u16 hue)
}
template
-void ps_move_tracker::set_hue_threshold(u32 index, u16 threshold)
+void ps_move_tracker::set_hue_threshold(u32 index, u8 threshold)
{
ps_move_config& config = ::at32(m_config, index);
config.hue_threshold = threshold;
@@ -120,11 +113,10 @@ void ps_move_tracker::set_hue_threshold(u32 index, u16 thres
}
template
-void ps_move_tracker::set_saturation_threshold(u32 index, u16 threshold)
+void ps_move_tracker::set_saturation_threshold(u32 index, u8 threshold)
{
ps_move_config& config = ::at32(m_config, index);
config.saturation_threshold_u8 = threshold;
- config.calculate_values();
}
template
@@ -309,24 +301,42 @@ void ps_move_tracker::process_hues()
}
}
-#ifdef HAVE_OPENCV
-static bool is_circular_contour(const std::vector& contour, f32& area)
+static constexpr u32 min_blob_area = 30;
+static constexpr f32 min_sphere_radius = 5.0f;
+
+static bool is_circular_blob(const blob_detector::blob& b)
{
- std::vector approx;
- cv::approxPolyDP(contour, approx, 0.01 * cv::arcLength(contour, true), true);
- if (approx.size() < 8ULL) return false;
+ // A disk has a fill ratio of about 1. Squares (0.64), half disks and 2:1 ellipses (0.5) are rejected.
+ constexpr f32 min_fill_ratio = 0.7f;
- area = static_cast(cv::contourArea(contour));
- if (area < 30.0f) return false;
-
- cv::Point2f center;
- f32 radius;
- cv::minEnclosingCircle(contour, center, radius);
- if (radius < 5.0f) return false;
+ if (b.area < min_blob_area) return false;
+ if (b.radius < min_sphere_radius) return false;
+ if (b.fill_ratio() < min_fill_ratio) return false;
return true;
}
+// Checks if the last position is close to the line of movement of a sphere that was blurred into a capsule.
+// The sphere usually moves further between two frames than during the exposure, so the last position may be behind the end of the capsule.
+static bool is_on_movement_path(const blob_detector::capsule& capsule, f32 last_x, f32 last_y, f32 last_radius)
+{
+ const f32 dx = last_x - capsule.center_x;
+ const f32 dy = last_y - capsule.center_y;
+ const f32 axis_distance = std::abs(dx * capsule.dir_x + dy * capsule.dir_y);
+ const f32 side_distance = std::abs(dx * capsule.dir_y - dy * capsule.dir_x);
+
+ return side_distance <= last_radius * 2.0f && axis_distance <= capsule.half_length * 4.0f + last_radius * 2.0f;
+}
+
+struct sphere_candidate
+{
+ const blob_detector::blob* blob = nullptr;
+ f32 x = 0.0f;
+ f32 y = 0.0f;
+ f32 radius = 0.0f;
+ f32 score = 0.0f;
+};
+
template
void ps_move_tracker::draw_sphere_size_range(f32 result_radius)
{
@@ -336,25 +346,67 @@ void ps_move_tracker::draw_sphere_size_range(f32 result_radi
// Protect the destination image from race conditions
std::lock_guard lock(m_mutex);
- // Map memory
- cv::Mat rgba(cv::Size(m_width, m_height), CV_8UC4, m_image_rgba_contours.data(), 0);
-
// Draw result, min and max radius
const f32 min_radius = m_min_radius * m_width;
const f32 max_radius = m_max_radius * m_width;
const f32 min_radius_clamped = std::max(0.0f, std::min(min_radius, max_radius));
- const cv::Point2f center = cv::Point2f(m_width - 1 - max_radius, max_radius);
+ const f32 center_x = m_width - 1 - max_radius;
+ const f32 center_y = max_radius;
if (result_radius > 0.0f)
{
- cv::circle(rgba, center, static_cast(result_radius), cv::Scalar(255, 0, 0, 255), cv::FILLED);
+ rgba_draw::circle(m_image_rgba_contours, m_width, m_height, center_x, center_y, static_cast(result_radius), { 255, 0, 0, 255 }, true);
}
if (min_radius_clamped > 0.0f && min_radius_clamped <= max_radius)
{
- cv::circle(rgba, center, static_cast(min_radius_clamped), cv::Scalar(0, 0, 0, 255), cv::FILLED);
+ rgba_draw::circle(m_image_rgba_contours, m_width, m_height, center_x, center_y, static_cast(min_radius_clamped), { 0, 0, 0, 255 }, true);
}
if (max_radius > min_radius_clamped)
{
- cv::circle(rgba, center, static_cast(max_radius), cv::Scalar(0, 0, 0, 255), 1);
+ rgba_draw::circle(m_image_rgba_contours, m_width, m_height, center_x, center_y, static_cast(max_radius), { 0, 0, 0, 255 }, false);
+ }
+}
+
+template
+void ps_move_tracker::filter_image(u32 index)
+{
+ const u32 width = m_width;
+ const u32 height = m_height;
+
+ if (width == 0 || height == 0)
+ {
+ return;
+ }
+
+ const ps_move_config& config = ::at32(m_config, index);
+ const std::vector& image_hsv = m_image_hsv;
+ std::vector& image_binary = ::at32(m_image_binary, index);
+
+ const bool wrapped_hue = config.min_hue > config.max_hue; // e.g. min=355, max=5 (red)
+ const bool has_hue_threshold = config.hue_threshold < 180; // with hue threshold 180 min and max are the same value
+
+ for (u32 y = 0; y < height; y++)
+ {
+ const u8* src = &image_hsv[y * width * 3];
+ u8* dst = &image_binary[y * width];
+
+ for (u32 x = 0; x < width; x++, src += 3)
+ {
+ const u16 hue = src[0] * 2;
+ const u8 saturation = src[1];
+ const u8 value = src[2];
+
+ // Simply drop dark and colorless pixels as well as pixels that don't match our hue
+ if ((has_hue_threshold && (wrapped_hue ? (hue < config.min_hue && hue > config.max_hue) : (hue < config.min_hue || hue > config.max_hue))) ||
+ saturation < config.saturation_threshold_u8 ||
+ value < 150)
+ {
+ dst[x] = 0;
+ }
+ else
+ {
+ dst[x] = 255;
+ }
+ }
}
}
@@ -370,134 +422,115 @@ void ps_move_tracker::process_contours(ps_move_info& info, u
return;
}
- const ps_move_config& config = ::at32(m_config, index);
- const std::vector& image_hsv = m_image_hsv;
- std::vector& image_binary = ::at32(m_image_binary, index);
-
- const bool wrapped_hue = config.min_hue > config.max_hue; // e.g. min=355, max=5 (red)
- const bool has_hue_threshold = config.hue_threshold < 180; // with hue threshold 180 min and max are the same value
-
- // Map memory
- cv::Mat binary(cv::Size(width, height), CV_8UC1, image_binary.data(), 0);
-
// Filter image
- for (u32 y = 0; y < height; y++)
- {
- const u8* src = &image_hsv[y * width * 3];
- u8* dst = &image_binary[y * width];
+ filter_image(index);
- for (u32 x = 0; x < width; x++, src += 3)
- {
- const u16 hue = src[0] * 2;
- const u8 saturation = src[1];
- const u8 value = src[2];
+ std::vector& image_binary = ::at32(m_image_binary, index);
+ blob_detector& detector = ::at32(m_blob_detectors, index);
- // Simply drop dark and colorless pixels as well as pixels that don't match our hue
- if ((has_hue_threshold && (wrapped_hue ? (hue < config.min_hue && hue > config.max_hue) : (hue < config.min_hue || hue > config.max_hue))) ||
- saturation < config.saturation_threshold_u8 || saturation > 200 ||
- value < 150)
- {
- dst[x] = 0;
- }
- else
- {
- dst[x] = 255;
- }
- }
- }
+ // Remove noise and thin lines. This also separates the sphere from background objects that only touch it with a thin bridge.
+ detector.open(image_binary, width, height);
- // Remove all small outer contours
- if (m_filter_small_contours)
- {
- std::vector> contours;
- cv::findContours(binary, contours, cv::RETR_LIST, cv::CHAIN_APPROX_SIMPLE);
- for (auto it = contours.begin(); it != contours.end();)
- {
- f32 area;
- if (is_circular_contour(*it, area))
- {
- it = contours.erase(it);
- continue;
- }
- it++;
- }
- if (!contours.empty())
- {
- cv::drawContours(binary, contours, -1, 0, cv::FILLED);
- }
- }
-
- // Find best contour
- std::vector> all_contours;
- cv::findContours(binary, all_contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);
-
- if (all_contours.empty())
- {
- set_valid(info, index, false);
-
- if constexpr (DiagnosticsEnabled)
- {
- draw_sphere_size_range(0.0f);
- }
- return;
- }
-
- std::vector> contours;
- std::vector centers;
- std::vector radii;
-
- if constexpr (DiagnosticsEnabled)
- {
- contours.reserve(all_contours.size());
- centers.reserve(all_contours.size());
- radii.reserve(all_contours.size());
- }
+ // Find all blobs. Holes are filled, so overexposed sphere centers don't split the sphere into a ring.
+ detector.detect(image_binary, width, height, true, min_blob_area);
const f32 min_radius = m_min_radius * width;
const f32 max_radius = m_max_radius * width;
- usz best_index = umax;
- f32 best_area = 0.0f;
- f32 best_radius = 0.0f;
- cv::Point2f best_center {};
+ // Prefer candidates near the last known position
+ const bool has_last_position = info.valid && info.radius > 0.0f;
+ const f32 last_x = info.x_pos * width;
+ const f32 last_y = info.y_pos * height;
+ const f32 search_radius = info.radius * 2.0f;
- for (usz i = 0; i < all_contours.size(); i++)
+ // The inscribed circle has to be similar to the last sphere
+ const f32 min_inscribed_radius = std::max(min_sphere_radius, info.radius * 0.5f);
+ const f32 max_inscribed_radius = info.radius * 1.5f;
+
+ std::vector candidates;
+ sphere_candidate best_candidate {};
+
+ for (const blob_detector::blob& b : detector.blobs())
{
- const std::vector& contour = all_contours[i];
- f32 area;
- if (!is_circular_contour(contour, area))
- continue;
+ sphere_candidate candidate { .blob = &b };
+ f32 weight = 1.0f;
- // Get center and radius
- cv::Point2f center;
- f32 radius;
- cv::minEnclosingCircle(contour, center, radius);
+ if (is_circular_blob(b))
+ {
+ candidate.x = b.center_x;
+ candidate.y = b.center_y;
+ candidate.radius = b.radius;
+ }
+ else if (has_last_position &&
+ b.area >= min_blob_area &&
+ b.max_x - b.min_x + 1 >= 2.0f * min_inscribed_radius &&
+ b.max_y - b.min_y + 1 >= 2.0f * min_inscribed_radius &&
+ b.area >= std::numbers::pi_v * min_inscribed_radius * min_inscribed_radius)
+ {
+ // The blob is not round. This is only used to keep tracking a sphere of similar size. A new sphere has to be found as a real circle.
+ // The cheap checks above make sure that we only calculate the inscribed circle if the blob is large enough to contain it.
+ const blob_detector::inscribed_circle circle = detector.find_inscribed_circle(b);
+ const bool similar_size = circle.radius >= min_inscribed_radius && circle.radius <= max_inscribed_radius;
+ blob_detector::capsule capsule {};
+
+ if (similar_size && blob_detector::fit_capsule(b, circle, capsule) && is_on_movement_path(capsule, last_x, last_y, info.radius))
+ {
+ // The sphere is blurred by fast movement. The center of the capsule is the average position during the exposure.
+ candidate.x = capsule.center_x;
+ candidate.y = capsule.center_y;
+ candidate.radius = capsule.radius;
+ weight = 0.75f;
+ }
+ else if (similar_size && detector.is_round_bulge(b, circle))
+ {
+ // The blob contains a round bulge. This happens if the sphere touches a background object with a similar color.
+ // Use the largest circle that fits inside the blob, but prefer real circles.
+ candidate.x = circle.center_x;
+ candidate.y = circle.center_y;
+ candidate.radius = circle.radius;
+ weight = 0.5f;
+ }
+ }
+
+ if (candidate.radius <= 0.0f)
+ {
+ // Remove all non-circular blobs from the binary image
+ if constexpr (DiagnosticsEnabled)
+ {
+ if (m_filter_small_contours)
+ {
+ detector.fill_blob(image_binary, b, 0);
+ }
+ }
+ continue;
+ }
// Filter radius
- if (radius < min_radius || radius > max_radius)
+ if (candidate.radius < min_radius || candidate.radius > max_radius)
continue;
+ // Larger spheres are more likely to be the real one, but the distance to the last position is more important
+ candidate.score = weight * candidate.radius * candidate.radius;
+
+ if (has_last_position)
+ {
+ const f32 dx = (candidate.x - last_x) / search_radius;
+ const f32 dy = (candidate.y - last_y) / search_radius;
+ candidate.score /= 1.0f + dx * dx + dy * dy;
+ }
+
if constexpr (DiagnosticsEnabled)
{
- contours.push_back(std::move(all_contours[i]));
- centers.push_back(center);
- radii.push_back(radius);
+ candidates.push_back(candidate);
}
- if (area > best_area)
+ if (!best_candidate.blob || candidate.score > best_candidate.score)
{
- best_area = area;
- best_center = center;
- best_radius = radius;
-
- if constexpr (DiagnosticsEnabled)
- {
- best_index = contours.size() - 1;
- }
+ best_candidate = candidate;
}
}
- if (best_area <= 0.0f)
+ if (!best_candidate.blob)
{
set_valid(info, index, false);
@@ -509,15 +542,15 @@ void ps_move_tracker::process_contours(ps_move_info& info, u
}
// Calculate distance from sphere to camera
- const f32 sphere_radius_pixels = best_radius;
+ const f32 sphere_radius_pixels = best_candidate.radius;
constexpr f32 focal_length_mm = 3.5f; // Based on common webcam specs
constexpr f32 sensor_width_mm = 3.6f; // Based on common webcam specs
const f32 image_width_pixels = static_cast(width);
const f32 focal_length_pixels = (focal_length_mm * image_width_pixels) / sensor_width_mm;
const f32 distance_mm = (focal_length_pixels * CELL_GEM_SPHERE_RADIUS_MM) / sphere_radius_pixels;
- const u32 x_pos = std::clamp(static_cast(best_center.x), 0u, width);
- const u32 y_pos = std::clamp(static_cast(best_center.y), 0u, height);
+ const u32 x_pos = std::clamp(static_cast(best_candidate.x), 0u, width);
+ const u32 y_pos = std::clamp(static_cast(best_candidate.y), 0u, height);
// Only set new values if the new shape and position are relatively similar to the old ones.
const auto distance_squared = [](int x1, int y1, int x2, int y2)
@@ -555,52 +588,38 @@ void ps_move_tracker::process_contours(ps_move_info& info, u
// Protect the destination image from race conditions
std::lock_guard lock(m_mutex);
- // Map memory
- cv::Mat rgba(cv::Size(width, height), CV_8UC4, m_image_rgba_contours.data(), 0);
-
if (!m_show_all_contours) [[likely]]
{
- std::vector contour = std::move(contours[best_index]);
- contours = { std::move(contour) };
- centers = { best_center };
- radii = { best_radius };
+ candidates = { best_candidate };
}
- static const cv::Scalar contour_color(255, 0, 0, 255);
- static const cv::Scalar circle_color(0, 255, 0, 255);
- static const cv::Scalar center_color(0, 0, 255, 255);
+ static constexpr std::array contour_color = { 255, 0, 0, 255 };
+ static constexpr std::array circle_color = { 0, 255, 0, 255 };
+ static constexpr std::array center_color = { 0, 0, 255, 255 };
// Draw contours
if (m_draw_contours)
{
- cv::drawContours(rgba, contours, -1, contour_color, cv::FILLED);
+ for (const sphere_candidate& candidate : candidates)
+ {
+ detector.fill_blob_rgba(m_image_rgba_contours, *candidate.blob, contour_color);
+ }
}
// Draw overlays
if (m_draw_overlays)
{
- for (usz i = 0; i < centers.size(); i++)
+ for (const sphere_candidate& candidate : candidates)
{
- const cv::Point2f& center = centers[i];
- const f32 radius = radii[i];
+ const s32 x = static_cast(std::lround(candidate.x));
+ const s32 y = static_cast(std::lround(candidate.y));
- cv::circle(rgba, center, static_cast(radius), circle_color, 1);
- cv::line(rgba, center + cv::Point2f(-2.0f, 0.0f), center + cv::Point2f(2.0f, 0.0f), center_color, 1);
- cv::line(rgba, center + cv::Point2f(0.0f, -2.0f), center + cv::Point2f(0.0f, 2.0f), center_color, 1);
+ rgba_draw::circle(m_image_rgba_contours, width, height, candidate.x, candidate.y, static_cast(candidate.radius), circle_color, false);
+ rgba_draw::line(m_image_rgba_contours, width, height, x - 2, y, x + 2, y, center_color);
+ rgba_draw::line(m_image_rgba_contours, width, height, x, y - 2, x, y + 2, center_color);
}
}
}
-#else
-template
-void ps_move_tracker::process_contours(ps_move_info& info, u32 index)
-{
- ensure(index < m_config.size());
-
- info.valid = false;
-
- ps_move.error("The tracker is not implemented for this operating system.");
-}
-#endif
template
std::tuple ps_move_tracker::hsv_to_rgb(u16 hue, f32 saturation, f32 value)
diff --git a/rpcs3/Input/ps_move_tracker.h b/rpcs3/Input/ps_move_tracker.h
index e4b3078843..b0595d1049 100644
--- a/rpcs3/Input/ps_move_tracker.h
+++ b/rpcs3/Input/ps_move_tracker.h
@@ -1,12 +1,7 @@
#pragma once
#include "Emu/Cell/Modules/cellGem.h"
-
-#ifdef HAVE_OPENCV
- constexpr bool g_ps_move_tracking_supported = true;
-#else
- constexpr bool g_ps_move_tracking_supported = false;
-#endif
+#include "blob_detector.h"
struct ps_move_info
{
@@ -35,8 +30,8 @@ public:
void set_active(u32 index, bool active);
void set_hue(u32 index, u16 hue);
- void set_hue_threshold(u32 index, u16 threshold);
- void set_saturation_threshold(u32 index, u16 threshold);
+ void set_hue_threshold(u32 index, u8 threshold);
+ void set_saturation_threshold(u32 index, u8 threshold);
void set_min_radius(f32 radius) { m_min_radius = radius; }
void set_max_radius(f32 radius) { m_max_radius = radius; }
@@ -62,8 +57,8 @@ private:
bool active = false;
u16 hue = 0;
- u16 hue_threshold = 0;
- u16 saturation_threshold_u8 = 0;
+ u8 hue_threshold = 0;
+ u8 saturation_threshold_u8 = 0;
s16 min_hue = 0;
s16 max_hue = 0;
@@ -73,6 +68,9 @@ private:
void set_valid(ps_move_info& info, u32 index, bool valid);
+ // Creates the binary image of all pixels that match the hue, saturation and value of the given device
+ void filter_image(u32 index);
+
void draw_sphere_size_range(f32 result_radius);
CellGemVideoConvertAttribute m_vc_attr {};
@@ -104,6 +102,7 @@ private:
std::array m_hues{};
std::array m_info{};
std::array m_config{};
+ std::array m_blob_detectors{};
std::array>>, CELL_GEM_MAX_NUM> m_workers{};
std::array, CELL_GEM_MAX_NUM> m_wake_up_workers{};
diff --git a/rpcs3/rpcs3.vcxproj b/rpcs3/rpcs3.vcxproj
index 707644d406..0bc5955ee0 100644
--- a/rpcs3/rpcs3.vcxproj
+++ b/rpcs3/rpcs3.vcxproj
@@ -62,7 +62,7 @@
- $(OpenCvBuildDir)\include;$(SolutionDir)3rdparty\SoundTouch\soundtouch\include;$(SolutionDir)3rdparty\cubeb\extra;$(SolutionDir)3rdparty\cubeb\cubeb\include\;$(SolutionDir)build\lib\$(Configuration)-$(Platform)\protobuf_build\include;$(SolutionDir)3rdparty\wolfssl\wolfssl;$(SolutionDir)3rdparty\wolfssl\extra\win32;$(SolutionDir)3rdparty\curl\curl\include;$(SolutionDir)3rdparty\rtmidi\rtmidi;$(SolutionDir)3rdparty\libusb\libusb\libusb;$(VULKAN_SDK)\Include;$(SolutionDir)3rdparty\libsdl-org\SDL\include;$(QTDIR)\include;$(QTDIR)\include\QtCore;$(QTDIR)\include\QtConcurrent;$(QTDIR)\include\QtGui;$(QTDIR)\include\QtSvg;$(QTDIR)\include\QtSvgWidgets;$(QTDIR)\include\QtWidgets;$(QTDIR)\include\QtMultimedia;$(QTDIR)\mkspecs\win32-msvc;.\release;.\QTGeneratedFiles\$(ConfigurationName);.\QTGeneratedFiles;%(AdditionalIncludeDirectories)
+ $(SolutionDir)3rdparty\SoundTouch\soundtouch\include;$(SolutionDir)3rdparty\cubeb\extra;$(SolutionDir)3rdparty\cubeb\cubeb\include\;$(SolutionDir)build\lib\$(Configuration)-$(Platform)\protobuf_build\include;$(SolutionDir)3rdparty\wolfssl\wolfssl;$(SolutionDir)3rdparty\wolfssl\extra\win32;$(SolutionDir)3rdparty\curl\curl\include;$(SolutionDir)3rdparty\rtmidi\rtmidi;$(SolutionDir)3rdparty\libusb\libusb\libusb;$(VULKAN_SDK)\Include;$(SolutionDir)3rdparty\libsdl-org\SDL\include;$(QTDIR)\include;$(QTDIR)\include\QtCore;$(QTDIR)\include\QtConcurrent;$(QTDIR)\include\QtGui;$(QTDIR)\include\QtSvg;$(QTDIR)\include\QtSvgWidgets;$(QTDIR)\include\QtWidgets;$(QTDIR)\include\QtMultimedia;$(QTDIR)\mkspecs\win32-msvc;.\release;.\QTGeneratedFiles\$(ConfigurationName);.\QTGeneratedFiles;%(AdditionalIncludeDirectories)
/Zc:__cplusplus -Zc:strictStrings -Zc:throwingNew- -w34100 -w34189 -w44996 -w44456 -w44457 -w44458 %(AdditionalOptions)
release\
false
@@ -70,7 +70,7 @@
4577;4467;4281;%(DisableSpecificWarnings)
$(IntDir)
MaxSpeed
- _WINDOWS;UNICODE;WIN32;WIN64;WIN32_LEAN_AND_MEAN;HAVE_VULKAN;HAVE_OPENCV;CV_IGNORE_DEBUG_BUILD_GUARD;MINIUPNP_STATICLIB;ZLIB_CONST;AL_LIBTYPE_STATIC;WOLFSSL_USER_SETTINGS;HAVE_SDL3;WITH_DISCORD_RPC;QT_NO_DEBUG;QT_WIDGETS_LIB;QT_GUI_LIB;QT_CORE_LIB;NDEBUG;QT_CONCURRENT_LIB;QT_MULTIMEDIA_LIB;QT_MULTIMEDIAWIDGETS_LIB;QT_SVG_LIB;CURL_STATICLIB;%(PreprocessorDefinitions)
+ _WINDOWS;UNICODE;WIN32;WIN64;WIN32_LEAN_AND_MEAN;HAVE_VULKAN;MINIUPNP_STATICLIB;ZLIB_CONST;AL_LIBTYPE_STATIC;WOLFSSL_USER_SETTINGS;HAVE_SDL3;WITH_DISCORD_RPC;QT_NO_DEBUG;QT_WIDGETS_LIB;QT_GUI_LIB;QT_CORE_LIB;NDEBUG;QT_CONCURRENT_LIB;QT_MULTIMEDIA_LIB;QT_MULTIMEDIAWIDGETS_LIB;QT_SVG_LIB;CURL_STATICLIB;%(PreprocessorDefinitions)
false
$(IntDir)vc$(PlatformToolsetVersion).pdb
true
@@ -81,8 +81,8 @@
true
- $(OpenCvWorld).lib;DbgHelp.lib;Ole32.lib;gdi32.lib;hidapi.lib;libusb-1.0.lib;winmm.lib;miniupnpc_static.lib;rtmidi.lib;imm32.lib;ksuser.lib;version.lib;OpenAL32.lib;XAudio.lib;GLGSRender.lib;shlwapi.lib;VKGSRender.lib;vulkan-1.lib;wolfssl.lib;libcurl.lib;Wldap32.lib;glslang.lib;OSDependent.lib;SPIRV.lib;MachineIndependent.lib;GenericCodeGen.lib;Advapi32.lib;user32.lib;zlib.lib;zstd.lib;libpng16.lib;asmjit.lib;yaml-cpp.lib;discord-rpc.lib;emucore.lib;dxgi.lib;shell32.lib;Qt6Core.lib;Qt6Gui.lib;Qt6Widgets.lib;Qt6Concurrent.lib;Qt6Multimedia.lib;Qt6MultimediaWidgets.lib;Qt6Svg.lib;Qt6SvgWidgets.lib;7zip.lib;libcubeb.lib;cubeb.lib;soundtouch.lib;Avrt.lib;SDL.lib;fusion.lib;%(AdditionalDependencies)
- $(OpenCvBuildDir)\x64\lib;$(SolutionDir)build\lib\$(Configuration)-$(Platform)\glslang;$(SolutionDir)build\lib_ext\$(CONFIGURATION)-$(PLATFORM);$(SolutionDir)build\lib\$(Configuration)-$(Platform)\protobuf_build\lib;$(QTDIR)\lib;$(VULKAN_SDK)\Lib;%(AdditionalLibraryDirectories)
+ DbgHelp.lib;Ole32.lib;gdi32.lib;hidapi.lib;libusb-1.0.lib;winmm.lib;miniupnpc_static.lib;rtmidi.lib;imm32.lib;ksuser.lib;version.lib;OpenAL32.lib;XAudio.lib;GLGSRender.lib;shlwapi.lib;VKGSRender.lib;vulkan-1.lib;wolfssl.lib;libcurl.lib;Wldap32.lib;glslang.lib;OSDependent.lib;SPIRV.lib;MachineIndependent.lib;GenericCodeGen.lib;Advapi32.lib;user32.lib;zlib.lib;zstd.lib;libpng16.lib;asmjit.lib;yaml-cpp.lib;discord-rpc.lib;emucore.lib;dxgi.lib;shell32.lib;Qt6Core.lib;Qt6Gui.lib;Qt6Widgets.lib;Qt6Concurrent.lib;Qt6Multimedia.lib;Qt6MultimediaWidgets.lib;Qt6Svg.lib;Qt6SvgWidgets.lib;7zip.lib;libcubeb.lib;cubeb.lib;soundtouch.lib;Avrt.lib;SDL.lib;fusion.lib;%(AdditionalDependencies)
+ $(SolutionDir)build\lib\$(Configuration)-$(Platform)\glslang;$(SolutionDir)build\lib_ext\$(CONFIGURATION)-$(PLATFORM);$(SolutionDir)build\lib\$(Configuration)-$(Platform)\protobuf_build\lib;$(QTDIR)\lib;$(VULKAN_SDK)\Lib;%(AdditionalLibraryDirectories)
"/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' publicKeyToken='6595b64144ccf1df' language='*' processorArchitecture='*'" /d2:-cgthreads8 %(AdditionalOptions)
true
Debug
@@ -108,7 +108,6 @@
$(QTDIR)\bin\windeployqt6 --no-compiler-runtime --no-opengl-sw --no-patchqt --no-quick --no-system-d3d-compiler --no-system-dxc-compiler --no-quick-import --plugindir "$(TargetDir)qt6\plugins" --translationdir "$(TargetDir)qt6\translations" --release "$(TargetPath)"
- xcopy /y /d "$(OpenCvBuildDir)\x64\bin\$(OpenCvWorld).dll" "$(OutDir)"
@@ -123,7 +122,7 @@
- $(OpenCvBuildDir)\include;$(SolutionDir)3rdparty\SoundTouch\soundtouch\include;$(SolutionDir)3rdparty\cubeb\extra;$(SolutionDir)3rdparty\cubeb\cubeb\include\;$(SolutionDir)build\lib\$(Configuration)-$(Platform)\protobuf_build\include;$(SolutionDir)3rdparty\wolfssl\wolfssl;$(SolutionDir)3rdparty\wolfssl\extra\win32;$(SolutionDir)3rdparty\curl\curl\include;$(SolutionDir)3rdparty\rtmidi\rtmidi;$(SolutionDir)3rdparty\libusb\libusb\libusb;$(VULKAN_SDK)\Include;$(SolutionDir)3rdparty\libsdl-org\SDL\include;$(QTDIR)\include;$(QTDIR)\include\QtCore;$(QTDIR)\include\QtConcurrent;$(QTDIR)\include\QtGui;$(QTDIR)\include\QtSvg;$(QTDIR)\include\QtSvgWidgets;$(QTDIR)\include\QtWidgets;$(QTDIR)\include\QtMultimedia;$(QTDIR)\mkspecs\win32-msvc;.\debug;.\QTGeneratedFiles\$(ConfigurationName);.\QTGeneratedFiles;%(AdditionalIncludeDirectories)
+ $(SolutionDir)3rdparty\SoundTouch\soundtouch\include;$(SolutionDir)3rdparty\cubeb\extra;$(SolutionDir)3rdparty\cubeb\cubeb\include\;$(SolutionDir)build\lib\$(Configuration)-$(Platform)\protobuf_build\include;$(SolutionDir)3rdparty\wolfssl\wolfssl;$(SolutionDir)3rdparty\wolfssl\extra\win32;$(SolutionDir)3rdparty\curl\curl\include;$(SolutionDir)3rdparty\rtmidi\rtmidi;$(SolutionDir)3rdparty\libusb\libusb\libusb;$(VULKAN_SDK)\Include;$(SolutionDir)3rdparty\libsdl-org\SDL\include;$(QTDIR)\include;$(QTDIR)\include\QtCore;$(QTDIR)\include\QtConcurrent;$(QTDIR)\include\QtGui;$(QTDIR)\include\QtSvg;$(QTDIR)\include\QtSvgWidgets;$(QTDIR)\include\QtWidgets;$(QTDIR)\include\QtMultimedia;$(QTDIR)\mkspecs\win32-msvc;.\debug;.\QTGeneratedFiles\$(ConfigurationName);.\QTGeneratedFiles;%(AdditionalIncludeDirectories)
/Zc:__cplusplus -Zc:strictStrings -Zc:throwingNew- -w34100 -w34189 -w44996 -w44456 -w44457 -w44458 %(AdditionalOptions)
debug\
false
@@ -131,7 +130,7 @@
4577;4467;4281;%(DisableSpecificWarnings)
$(IntDir)
Disabled
- _WINDOWS;UNICODE;WIN32;WIN64;WIN32_LEAN_AND_MEAN;HAVE_VULKAN;HAVE_OPENCV;CV_IGNORE_DEBUG_BUILD_GUARD;MINIUPNP_STATICLIB;ZLIB_CONST;AL_LIBTYPE_STATIC;WOLFSSL_USER_SETTINGS;HAVE_SDL3;QT_WIDGETS_LIB;QT_GUI_LIB;QT_CORE_LIB;QT_CONCURRENT_LIB;QT_MULTIMEDIA_LIB;QT_MULTIMEDIAWIDGETS_LIB;QT_SVG_LIB;CURL_STATICLIB;%(PreprocessorDefinitions)
+ _WINDOWS;UNICODE;WIN32;WIN64;WIN32_LEAN_AND_MEAN;HAVE_VULKAN;MINIUPNP_STATICLIB;ZLIB_CONST;AL_LIBTYPE_STATIC;WOLFSSL_USER_SETTINGS;HAVE_SDL3;QT_WIDGETS_LIB;QT_GUI_LIB;QT_CORE_LIB;QT_CONCURRENT_LIB;QT_MULTIMEDIA_LIB;QT_MULTIMEDIAWIDGETS_LIB;QT_SVG_LIB;CURL_STATICLIB;%(PreprocessorDefinitions)
false
true
true
@@ -141,8 +140,8 @@
true
- $(OpenCvWorld).lib;DbgHelp.lib;Ole32.lib;gdi32.lib;hidapi.lib;libusb-1.0.lib;winmm.lib;miniupnpc_static.lib;rtmidi.lib;imm32.lib;ksuser.lib;version.lib;OpenAL32.lib;XAudio.lib;GLGSRender.lib;shlwapi.lib;VKGSRender.lib;vulkan-1.lib;wolfssl.lib;libcurl-d.lib;Wldap32.lib;glslangd.lib;OSDependentd.lib;SPIRVd.lib;MachineIndependentd.lib;GenericCodeGend.lib;Advapi32.lib;user32.lib;zlib.lib;zstd.lib;libpng16.lib;asmjit.lib;yaml-cpp.lib;discord-rpc.lib;emucore.lib;dxgi.lib;shell32.lib;Qt6Cored.lib;Qt6Guid.lib;Qt6Widgetsd.lib;Qt6Concurrentd.lib;Qt6Multimediad.lib;Qt6MultimediaWidgetsd.lib;Qt6Svgd.lib;Qt6SvgWidgetsd.lib;7zip.lib;libcubeb.lib;cubeb.lib;soundtouch.lib;Avrt.lib;SDL.lib;fusion.lib;%(AdditionalDependencies)
- $(OpenCvBuildDir)\x64\lib;$(SolutionDir)build\lib\$(Configuration)-$(Platform)\glslang;$(SolutionDir)build\lib\$(CONFIGURATION)-$(PLATFORM);$(SolutionDir)build\lib\$(Configuration)-$(Platform)\protobuf_build\lib;$(QTDIR)\lib;$(VULKAN_SDK)\Lib;%(AdditionalLibraryDirectories)
+ DbgHelp.lib;Ole32.lib;gdi32.lib;hidapi.lib;libusb-1.0.lib;winmm.lib;miniupnpc_static.lib;rtmidi.lib;imm32.lib;ksuser.lib;version.lib;OpenAL32.lib;XAudio.lib;GLGSRender.lib;shlwapi.lib;VKGSRender.lib;vulkan-1.lib;wolfssl.lib;libcurl-d.lib;Wldap32.lib;glslangd.lib;OSDependentd.lib;SPIRVd.lib;MachineIndependentd.lib;GenericCodeGend.lib;Advapi32.lib;user32.lib;zlib.lib;zstd.lib;libpng16.lib;asmjit.lib;yaml-cpp.lib;discord-rpc.lib;emucore.lib;dxgi.lib;shell32.lib;Qt6Cored.lib;Qt6Guid.lib;Qt6Widgetsd.lib;Qt6Concurrentd.lib;Qt6Multimediad.lib;Qt6MultimediaWidgetsd.lib;Qt6Svgd.lib;Qt6SvgWidgetsd.lib;7zip.lib;libcubeb.lib;cubeb.lib;soundtouch.lib;Avrt.lib;SDL.lib;fusion.lib;%(AdditionalDependencies)
+ $(SolutionDir)build\lib\$(Configuration)-$(Platform)\glslang;$(SolutionDir)build\lib\$(CONFIGURATION)-$(PLATFORM);$(SolutionDir)build\lib\$(Configuration)-$(Platform)\protobuf_build\lib;$(QTDIR)\lib;$(VULKAN_SDK)\Lib;%(AdditionalLibraryDirectories)
"/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' publicKeyToken='6595b64144ccf1df' language='*' processorArchitecture='*'" /VERBOSE /d2:-cgthreads8 %(AdditionalOptions)
true
Debug
@@ -167,7 +166,6 @@
$(QTDIR)\bin\windeployqt6 --no-compiler-runtime --no-opengl-sw --no-patchqt --no-quick --no-system-d3d-compiler --no-system-dxc-compiler --no-quick-import --plugindir "$(TargetDir)qt6\plugins" --translationdir "$(TargetDir)qt6\translations" --debug "$(TargetPath)"
- xcopy /y /d "$(OpenCvBuildDir)\x64\bin\$(OpenCvWorld).dll" "$(OutDir)"
@@ -191,6 +189,7 @@
+
@@ -1128,6 +1127,7 @@
+
diff --git a/rpcs3/rpcs3.vcxproj.filters b/rpcs3/rpcs3.vcxproj.filters
index f63cca55b7..28a36d4583 100644
--- a/rpcs3/rpcs3.vcxproj.filters
+++ b/rpcs3/rpcs3.vcxproj.filters
@@ -1266,6 +1266,9 @@
Io\Move
+
+ Io\Move
+
Io\Move
@@ -1562,6 +1565,9 @@
Io\Move
+
+ Io\Move
+
Io\Move
diff --git a/rpcs3/rpcs3qt/CMakeLists.txt b/rpcs3/rpcs3qt/CMakeLists.txt
index cfe2e0d9af..0220757ab0 100644
--- a/rpcs3/rpcs3qt/CMakeLists.txt
+++ b/rpcs3/rpcs3qt/CMakeLists.txt
@@ -161,6 +161,7 @@ add_library(rpcs3_ui STATIC
../Input/basic_keyboard_handler.cpp
../Input/basic_mouse_handler.cpp
+ ../Input/blob_detector.cpp
../Input/camera_video_sink.cpp
../Input/ds3_pad_handler.cpp
../Input/ds4_pad_handler.cpp
@@ -229,7 +230,6 @@ target_link_libraries(rpcs3_ui
3rdparty::libusb
3rdparty::wolfssl
3rdparty::libcurl
- 3rdparty::opencv
3rdparty::fusion
PRIVATE
diff --git a/rpcs3/rpcs3qt/ps_move_tracker_dialog.cpp b/rpcs3/rpcs3qt/ps_move_tracker_dialog.cpp
index 7ee3c3bdcb..7de9da462a 100644
--- a/rpcs3/rpcs3qt/ps_move_tracker_dialog.cpp
+++ b/rpcs3/rpcs3qt/ps_move_tracker_dialog.cpp
@@ -266,14 +266,6 @@ ps_move_tracker_dialog::ps_move_tracker_dialog(QWidget* parent)
update_min_radius(true);
update_max_radius(true);
- if constexpr (!g_ps_move_tracking_supported)
- {
- QTimer::singleShot(1000, this, [this]()
- {
- QMessageBox::warning(this, QObject::tr("Tracking not supported!"), QObject::tr("The PS Move tracking is not yet supported on this operating system."));
- });
- }
-
ui->imageLabel->installEventFilter(this);
}
@@ -562,8 +554,8 @@ void ps_move_tracker_dialog::process_camera_frame()
m_ps_move_tracker->set_active(i, index == i);
m_ps_move_tracker->set_hue(i, config->hue);
- m_ps_move_tracker->set_hue_threshold(i, config->hue_threshold);
- m_ps_move_tracker->set_saturation_threshold(i, config->saturation_threshold);
+ m_ps_move_tracker->set_hue_threshold(i, static_cast(config->hue_threshold.get()));
+ m_ps_move_tracker->set_saturation_threshold(i, static_cast(config->saturation_threshold.get()));
}
m_ps_move_tracker->set_image_data(m_frame_frozen ? m_image_data_frozen.data() : m_image_data.data(), m_image_data.size(), width, height, m_camera_handler->format());
diff --git a/rpcs3/tests/rpcs3_test.vcxproj b/rpcs3/tests/rpcs3_test.vcxproj
index 8488c1b9b1..9d3bd337a6 100644
--- a/rpcs3/tests/rpcs3_test.vcxproj
+++ b/rpcs3/tests/rpcs3_test.vcxproj
@@ -47,8 +47,8 @@
true
- rpcs3.lib;$(OpenCvWorld).lib;DbgHelp.lib;Ole32.lib;gdi32.lib;hidapi.lib;libusb-1.0.lib;winmm.lib;miniupnpc_static.lib;rtmidi.lib;imm32.lib;ksuser.lib;version.lib;OpenAL32.lib;XAudio.lib;GLGSRender.lib;shlwapi.lib;VKGSRender.lib;vulkan-1.lib;wolfssl.lib;libcurl.lib;Wldap32.lib;glslang.lib;OSDependent.lib;SPIRV.lib;MachineIndependent.lib;GenericCodeGen.lib;Advapi32.lib;user32.lib;zlib.lib;zstd.lib;libpng16.lib;asmjit.lib;yaml-cpp.lib;discord-rpc.lib;emucore.lib;dxgi.lib;shell32.lib;Qt6Core.lib;Qt6Gui.lib;Qt6Widgets.lib;Qt6Concurrent.lib;Qt6Multimedia.lib;Qt6MultimediaWidgets.lib;Qt6Svg.lib;Qt6SvgWidgets.lib;7zip.lib;libcubeb.lib;cubeb.lib;soundtouch.lib;Avrt.lib;SDL.lib;fusion.lib;%(AdditionalDependencies)
- $(OpenCvBuildDir)\x64\lib;$(SolutionDir)build\lib\$(Configuration)-$(Platform)\glslang;$(SolutionDir)build\lib_ext\$(CONFIGURATION)-$(PLATFORM);$(SolutionDir)build\lib\$(Configuration)-$(Platform)\protobuf_build\lib;$(QTDIR)\lib;$(VULKAN_SDK)\Lib;%(AdditionalLibraryDirectories)
+ rpcs3.lib;DbgHelp.lib;Ole32.lib;gdi32.lib;hidapi.lib;libusb-1.0.lib;winmm.lib;miniupnpc_static.lib;rtmidi.lib;imm32.lib;ksuser.lib;version.lib;OpenAL32.lib;XAudio.lib;GLGSRender.lib;shlwapi.lib;VKGSRender.lib;vulkan-1.lib;wolfssl.lib;libcurl.lib;Wldap32.lib;glslang.lib;OSDependent.lib;SPIRV.lib;MachineIndependent.lib;GenericCodeGen.lib;Advapi32.lib;user32.lib;zlib.lib;zstd.lib;libpng16.lib;asmjit.lib;yaml-cpp.lib;discord-rpc.lib;emucore.lib;dxgi.lib;shell32.lib;Qt6Core.lib;Qt6Gui.lib;Qt6Widgets.lib;Qt6Concurrent.lib;Qt6Multimedia.lib;Qt6MultimediaWidgets.lib;Qt6Svg.lib;Qt6SvgWidgets.lib;7zip.lib;libcubeb.lib;cubeb.lib;soundtouch.lib;Avrt.lib;SDL.lib;fusion.lib;%(AdditionalDependencies)
+ $(SolutionDir)build\lib\$(Configuration)-$(Platform)\glslang;$(SolutionDir)build\lib_ext\$(CONFIGURATION)-$(PLATFORM);$(SolutionDir)build\lib\$(Configuration)-$(Platform)\protobuf_build\lib;$(QTDIR)\lib;$(VULKAN_SDK)\Lib;%(AdditionalLibraryDirectories)
/d2:-cgthreads8 %(AdditionalOptions)
Console
true
@@ -56,7 +56,6 @@
$(QTDIR)\bin\windeployqt6 --no-compiler-runtime --no-opengl-sw --no-patchqt --no-translations --no-quick --no-system-d3d-compiler --no-system-dxc-compiler --no-ffmpeg --no-quick-import --plugindir "$(TargetDir)qt6\plugins" --release "$(TargetPath)"
- xcopy /y /d "$(OpenCvBuildDir)\x64\bin\$(OpenCvWorld).dll" "$(OutDir)"
@@ -72,8 +71,8 @@
true
- rpcs3d.lib;$(OpenCvWorld).lib;DbgHelp.lib;Ole32.lib;gdi32.lib;hidapi.lib;libusb-1.0.lib;winmm.lib;miniupnpc_static.lib;rtmidi.lib;imm32.lib;ksuser.lib;version.lib;OpenAL32.lib;XAudio.lib;GLGSRender.lib;shlwapi.lib;VKGSRender.lib;vulkan-1.lib;wolfssl.lib;libcurl-d.lib;Wldap32.lib;glslangd.lib;OSDependentd.lib;SPIRVd.lib;MachineIndependentd.lib;GenericCodeGend.lib;Advapi32.lib;user32.lib;zlib.lib;zstd.lib;libpng16.lib;asmjit.lib;yaml-cpp.lib;discord-rpc.lib;emucore.lib;dxgi.lib;shell32.lib;Qt6Cored.lib;Qt6Guid.lib;Qt6Widgetsd.lib;Qt6Concurrentd.lib;Qt6Multimediad.lib;Qt6MultimediaWidgetsd.lib;Qt6Svgd.lib;Qt6SvgWidgetsd.lib;7zip.lib;libcubeb.lib;cubeb.lib;soundtouch.lib;Avrt.lib;SDL.lib;fusion.lib;%(AdditionalDependencies)
- $(OpenCvBuildDir)\x64\lib;$(SolutionDir)build\lib\$(Configuration)-$(Platform)\glslang;$(SolutionDir)build\lib\$(CONFIGURATION)-$(PLATFORM);$(QTDIR)\lib;$(VULKAN_SDK)\Lib;%(AdditionalLibraryDirectories)
+ rpcs3d.lib;DbgHelp.lib;Ole32.lib;gdi32.lib;hidapi.lib;libusb-1.0.lib;winmm.lib;miniupnpc_static.lib;rtmidi.lib;imm32.lib;ksuser.lib;version.lib;OpenAL32.lib;XAudio.lib;GLGSRender.lib;shlwapi.lib;VKGSRender.lib;vulkan-1.lib;wolfssl.lib;libcurl-d.lib;Wldap32.lib;glslangd.lib;OSDependentd.lib;SPIRVd.lib;MachineIndependentd.lib;GenericCodeGend.lib;Advapi32.lib;user32.lib;zlib.lib;zstd.lib;libpng16.lib;asmjit.lib;yaml-cpp.lib;discord-rpc.lib;emucore.lib;dxgi.lib;shell32.lib;Qt6Cored.lib;Qt6Guid.lib;Qt6Widgetsd.lib;Qt6Concurrentd.lib;Qt6Multimediad.lib;Qt6MultimediaWidgetsd.lib;Qt6Svgd.lib;Qt6SvgWidgetsd.lib;7zip.lib;libcubeb.lib;cubeb.lib;soundtouch.lib;Avrt.lib;SDL.lib;fusion.lib;%(AdditionalDependencies)
+ $(SolutionDir)build\lib\$(Configuration)-$(Platform)\glslang;$(SolutionDir)build\lib\$(CONFIGURATION)-$(PLATFORM);$(QTDIR)\lib;$(VULKAN_SDK)\Lib;%(AdditionalLibraryDirectories)
/d2:-cgthreads8 %(AdditionalOptions)
Console
true
@@ -81,7 +80,6 @@
$(QTDIR)\bin\windeployqt6 --no-compiler-runtime --no-opengl-sw --no-patchqt --no-translations --no-quick --no-system-d3d-compiler --no-system-dxc-compiler --no-ffmpeg --no-quick-import --plugindir "$(TargetDir)qt6\plugins" --release "$(TargetPath)"
- xcopy /y /d "$(OpenCvBuildDir)\x64\bin\$(OpenCvWorld).dll" "$(OutDir)"
@@ -96,6 +94,7 @@
+
true
diff --git a/rpcs3/tests/test_blob_detector.cpp b/rpcs3/tests/test_blob_detector.cpp
new file mode 100644
index 0000000000..b37278bca4
--- /dev/null
+++ b/rpcs3/tests/test_blob_detector.cpp
@@ -0,0 +1,663 @@
+#include
+
+#include "Input/blob_detector.h"
+
+#include
+#include
+#include
+
+namespace blob
+{
+ struct test_image
+ {
+ u32 width = 0;
+ u32 height = 0;
+ std::vector pixels;
+
+ test_image(u32 w, u32 h)
+ : width(w), height(h), pixels(usz{w} * h, 0)
+ {
+ }
+
+ void disk(f32 center_x, f32 center_y, f32 radius, u8 value = 255)
+ {
+ for (u32 y = 0; y < height; y++)
+ {
+ for (u32 x = 0; x < width; x++)
+ {
+ const f32 dx = x - center_x;
+ const f32 dy = y - center_y;
+
+ if (dx * dx + dy * dy <= radius * radius)
+ {
+ pixels[y * width + x] = value;
+ }
+ }
+ }
+ }
+
+ void rect(u32 x0, u32 y0, u32 x1, u32 y1, u8 value = 255)
+ {
+ for (u32 y = y0; y <= y1; y++)
+ {
+ for (u32 x = x0; x <= x1; x++)
+ {
+ pixels[y * width + x] = value;
+ }
+ }
+ }
+
+ void detect(blob_detector& detector, bool fill_holes, u32 min_circle_area = 0) const
+ {
+ detector.detect(pixels, width, height, fill_holes, min_circle_area);
+ }
+ };
+
+ // Returns the blob that contains the given pixel
+ const blob_detector::blob* blob_at(const blob_detector& detector, u32 x, u32 y)
+ {
+ const u32 label = detector.labels().at(y * detector.width() + x);
+ return label ? &detector.blobs().at(label - 1) : nullptr;
+ }
+
+ TEST(BlobDetector, EmptyImage)
+ {
+ blob_detector detector;
+ test_image image(64, 48);
+
+ image.detect(detector, true);
+ EXPECT_TRUE(detector.blobs().empty());
+ EXPECT_EQ(detector.labels().size(), 64u * 48u);
+ }
+
+ TEST(BlobDetector, InvalidInput)
+ {
+ blob_detector detector;
+
+ detector.detect({}, 64, 48, true);
+ EXPECT_TRUE(detector.blobs().empty());
+
+ const std::vector pixel { 255 };
+ detector.detect(pixel, 0, 0, true);
+ EXPECT_TRUE(detector.blobs().empty());
+ EXPECT_TRUE(detector.labels().empty());
+ }
+
+ TEST(BlobDetector, SinglePixel)
+ {
+ blob_detector detector;
+ test_image image(10, 10);
+ image.pixels[4 * 10 + 5] = 1;
+
+ image.detect(detector, true);
+ ASSERT_EQ(detector.blobs().size(), 1u);
+
+ const blob_detector::blob& b = detector.blobs()[0];
+ EXPECT_EQ(b.area, 1u);
+ EXPECT_EQ(b.perimeter, 1u);
+ EXPECT_EQ(b.min_x, 5u);
+ EXPECT_EQ(b.max_x, 5u);
+ EXPECT_EQ(b.min_y, 4u);
+ EXPECT_EQ(b.max_y, 4u);
+ EXPECT_FLOAT_EQ(b.center_x, 5.0f);
+ EXPECT_FLOAT_EQ(b.center_y, 4.0f);
+ EXPECT_FLOAT_EQ(b.radius, 0.0f);
+ }
+
+ TEST(BlobDetector, Disk)
+ {
+ blob_detector detector;
+ test_image image(640, 480);
+ image.disk(300.0f, 200.0f, 20.0f);
+
+ image.detect(detector, true);
+ ASSERT_EQ(detector.blobs().size(), 1u);
+
+ const blob_detector::blob& b = detector.blobs()[0];
+ EXPECT_NEAR(b.radius, 20.0f, 0.6f);
+ EXPECT_NEAR(b.center_x, 300.0f, 0.6f);
+ EXPECT_NEAR(b.center_y, 200.0f, 0.6f);
+ EXPECT_NEAR(b.centroid_x, 300.0f, 0.1f);
+ EXPECT_NEAR(b.centroid_y, 200.0f, 0.1f);
+ EXPECT_GT(b.fill_ratio(), 0.9f);
+ EXPECT_LT(b.fill_ratio(), 1.1f);
+ EXPECT_EQ(b.min_x, 280u);
+ EXPECT_EQ(b.max_x, 320u);
+ EXPECT_EQ(b.min_y, 180u);
+ EXPECT_EQ(b.max_y, 220u);
+ }
+
+ TEST(BlobDetector, RingWithAndWithoutHoleFilling)
+ {
+ blob_detector detector;
+ test_image image(200, 200);
+ image.disk(100.0f, 100.0f, 30.0f);
+ image.disk(100.0f, 100.0f, 15.0f, 0);
+
+ image.detect(detector, false);
+ ASSERT_EQ(detector.blobs().size(), 1u);
+ const u32 ring_area = detector.blobs()[0].area;
+
+ image.detect(detector, true);
+ ASSERT_EQ(detector.blobs().size(), 1u);
+ const blob_detector::blob& b = detector.blobs()[0];
+
+ // The hole is part of the blob now
+ EXPECT_GT(b.area, ring_area);
+ EXPECT_GT(b.fill_ratio(), 0.9f);
+ EXPECT_NEAR(b.radius, 30.0f, 0.6f);
+
+ // Only the outer boundary is left
+ EXPECT_LT(b.perimeter, 2 * 3.1416f * 30.0f * 1.5f);
+ }
+
+ TEST(BlobDetector, NestedBlobStaysSeparateWhenFillingHoles)
+ {
+ blob_detector detector;
+ test_image image(100, 100);
+ image.disk(50.0f, 50.0f, 30.0f);
+ image.disk(50.0f, 50.0f, 20.0f, 0);
+ image.disk(50.0f, 50.0f, 5.0f);
+
+ image.detect(detector, false);
+ ASSERT_EQ(detector.blobs().size(), 2u);
+ const u32 ring_area = blob_at(detector, 50, 20)->area;
+
+ // The gap touches the ring and the inner disk, so it must not be filled
+ image.detect(detector, true);
+ ASSERT_EQ(detector.blobs().size(), 2u);
+ EXPECT_EQ(detector.labels()[50 * 100 + 35], 0u);
+
+ const blob_detector::blob* ring = blob_at(detector, 50, 20);
+ const blob_detector::blob* inner = blob_at(detector, 50, 50);
+ ASSERT_NE(ring, nullptr);
+ ASSERT_NE(inner, nullptr);
+ EXPECT_NE(ring, inner);
+ EXPECT_EQ(ring->area, ring_area);
+ EXPECT_NEAR(inner->radius, 5.0f, 0.6f);
+ }
+
+ TEST(BlobDetector, OnlyHolesOfSingleBlobsAreFilled)
+ {
+ blob_detector detector;
+ test_image image(100, 100);
+
+ // Outer ring
+ image.disk(50.0f, 50.0f, 40.0f);
+ image.disk(50.0f, 50.0f, 35.0f, 0);
+
+ // Inner ring with a hole that should be filled
+ image.disk(50.0f, 50.0f, 20.0f);
+ image.disk(50.0f, 50.0f, 10.0f, 0);
+
+ image.detect(detector, true);
+ ASSERT_EQ(detector.blobs().size(), 2u);
+
+ // The inner hole was filled, the gap between the rings was not
+ const blob_detector::blob* inner = blob_at(detector, 50, 50);
+ ASSERT_NE(inner, nullptr);
+ EXPECT_EQ(detector.labels()[50 * 100 + 75], 0u);
+ EXPECT_GT(inner->fill_ratio(), 0.9f);
+ EXPECT_NEAR(inner->radius, 20.0f, 0.6f);
+ }
+
+ TEST(BlobDetector, LargeHoleIsNotFilled)
+ {
+ blob_detector detector;
+ test_image image(100, 100);
+
+ // A thin outline around a large area
+ image.rect(10, 10, 89, 89);
+ image.rect(13, 13, 86, 86, 0);
+
+ image.detect(detector, true);
+ ASSERT_EQ(detector.blobs().size(), 1u);
+ EXPECT_EQ(detector.blobs()[0].area, 80u * 80u - 74u * 74u);
+ EXPECT_EQ(detector.labels()[50 * 100 + 50], 0u);
+
+ // A hole that is smaller than its blob is still filled
+ image.rect(10, 10, 89, 89);
+ image.rect(40, 40, 59, 59, 0);
+
+ image.detect(detector, true);
+ ASSERT_EQ(detector.blobs().size(), 1u);
+ EXPECT_EQ(detector.blobs()[0].area, 80u * 80u);
+ }
+
+ TEST(BlobDetector, MomentEllipse)
+ {
+ blob_detector detector;
+ test_image image(300, 200);
+
+ // Disk
+ image.disk(50.0f, 50.0f, 20.0f);
+
+ // Horizontal capsule with radius 20 and a straight part of 40 pixels
+ image.disk(120.0f, 150.0f, 20.0f);
+ image.disk(160.0f, 150.0f, 20.0f);
+ image.rect(120, 130, 160, 170);
+
+ // Vertical 2:1 rectangle
+ image.rect(240, 20, 259, 59);
+
+ image.detect(detector, true, 30);
+ ASSERT_EQ(detector.blobs().size(), 3u);
+
+ const blob_detector::blob* disk = blob_at(detector, 50, 50);
+ const blob_detector::blob* capsule = blob_at(detector, 140, 150);
+ const blob_detector::blob* rect = blob_at(detector, 250, 40);
+ ASSERT_NE(disk, nullptr);
+ ASSERT_NE(capsule, nullptr);
+ ASSERT_NE(rect, nullptr);
+
+ EXPECT_NEAR(disk->major_axis, 20.0f, 0.5f);
+ EXPECT_NEAR(disk->minor_axis, 20.0f, 0.5f);
+ EXPECT_NEAR(disk->ellipse_fill_ratio(), 1.0f, 0.02f);
+
+ EXPECT_NEAR(capsule->major_axis / capsule->minor_axis, 1.94f, 0.05f);
+ EXPECT_NEAR(capsule->minor_axis, 20.0f * 1.09f, 0.5f);
+ EXPECT_NEAR(capsule->angle, 0.0f, 0.01f);
+ EXPECT_GT(capsule->ellipse_fill_ratio(), 0.97f);
+ EXPECT_NEAR(capsule->centroid_x, 140.0f, 0.1f);
+ EXPECT_NEAR(capsule->centroid_y, 150.0f, 0.1f);
+
+ // The capsule is as wide as the sphere
+ EXPECT_NEAR(detector.find_inscribed_circle(*capsule).radius, 20.0f, 1.0f);
+
+ EXPECT_NEAR(rect->major_axis / rect->minor_axis, 2.0f, 0.05f);
+ EXPECT_NEAR(std::abs(rect->angle), std::numbers::pi_v / 2.0f, 0.01f);
+ EXPECT_NEAR(rect->ellipse_fill_ratio(), 0.95f, 0.02f);
+
+ // A single pixel has no extent
+ image.pixels.assign(image.pixels.size(), 0);
+ image.pixels[10 * 300 + 10] = 255;
+ image.detect(detector, true);
+ ASSERT_EQ(detector.blobs().size(), 1u);
+ EXPECT_FLOAT_EQ(detector.blobs()[0].major_axis, 0.0f);
+ EXPECT_FLOAT_EQ(detector.blobs()[0].ellipse_fill_ratio(), 0.0f);
+ }
+
+ TEST(BlobDetector, RoundBulgeAndCapsule)
+ {
+ blob_detector detector;
+ test_image image(400, 300);
+
+ // Disk
+ image.disk(50.0f, 50.0f, 20.0f);
+
+ // Disk that touches a thinner bar
+ image.disk(200.0f, 50.0f, 25.0f);
+ image.rect(220, 40, 399, 60);
+
+ // Two disks of the same size that touch each other
+ image.disk(50.0f, 150.0f, 20.0f);
+ image.disk(85.0f, 150.0f, 20.0f);
+
+ // Diagonal capsule with radius 15
+ for (int i = 0; i <= 40; i++)
+ {
+ image.disk(200.0f + i, 150.0f + i, 15.0f);
+ }
+
+ // L shape
+ image.rect(300, 200, 389, 219);
+ image.rect(300, 200, 319, 289);
+
+ image.detect(detector, true, 30);
+ ASSERT_EQ(detector.blobs().size(), 5u);
+
+ const blob_detector::blob* disk = blob_at(detector, 50, 50);
+ const blob_detector::blob* bulge = blob_at(detector, 200, 50);
+ const blob_detector::blob* pair = blob_at(detector, 50, 150);
+ const blob_detector::blob* capsule = blob_at(detector, 220, 170);
+ const blob_detector::blob* corner = blob_at(detector, 305, 205);
+ ASSERT_NE(disk, nullptr);
+ ASSERT_NE(bulge, nullptr);
+ ASSERT_NE(pair, nullptr);
+ ASSERT_NE(capsule, nullptr);
+ ASSERT_NE(corner, nullptr);
+
+ // Round bulge
+ EXPECT_TRUE(detector.is_round_bulge(*disk, detector.find_inscribed_circle(*disk)));
+ EXPECT_TRUE(detector.is_round_bulge(*bulge, detector.find_inscribed_circle(*bulge)));
+ EXPECT_FALSE(detector.is_round_bulge(*pair, detector.find_inscribed_circle(*pair)));
+ EXPECT_FALSE(detector.is_round_bulge(*disk, blob_detector::inscribed_circle{}));
+
+ // The outline around a lone disk is free, the bar covers a small part of the bulge outline
+ const blob_detector::inscribed_circle bulge_circle = detector.find_inscribed_circle(*bulge);
+ EXPECT_FLOAT_EQ(detector.free_outline_ratio(*disk, 50.0f, 50.0f, 20.0f), 1.0f);
+ EXPECT_GT(detector.free_outline_ratio(*bulge, bulge_circle.center_x, bulge_circle.center_y, bulge_circle.radius), 0.8f);
+ EXPECT_LT(detector.free_outline_ratio(*bulge, bulge_circle.center_x, bulge_circle.center_y, bulge_circle.radius), 1.0f);
+
+ // Capsule
+ blob_detector::capsule result {};
+ ASSERT_TRUE(blob_detector::fit_capsule(*capsule, detector.find_inscribed_circle(*capsule), result));
+ EXPECT_NEAR(result.center_x, 220.0f, 0.5f);
+ EXPECT_NEAR(result.center_y, 170.0f, 0.5f);
+ EXPECT_NEAR(result.radius, 15.0f, 1.0f);
+ EXPECT_NEAR(result.half_length, 20.0f * std::numbers::sqrt2_v, 1.5f);
+ EXPECT_NEAR(std::abs(result.dir_x), std::numbers::sqrt2_v / 2.0f, 0.01f);
+ EXPECT_NEAR(result.dir_x, result.dir_y, 0.01f);
+
+ // Disks are not elongated and L shapes are not smooth
+ EXPECT_FALSE(blob_detector::fit_capsule(*disk, detector.find_inscribed_circle(*disk), result));
+ EXPECT_FALSE(blob_detector::fit_capsule(*corner, detector.find_inscribed_circle(*corner), result));
+ }
+
+ TEST(BlobDetector, InscribedCircle)
+ {
+ blob_detector detector;
+ test_image image(200, 200);
+ image.disk(100.0f, 100.0f, 20.0f);
+
+ image.detect(detector, true);
+ ASSERT_EQ(detector.blobs().size(), 1u);
+
+ const blob_detector::blob& b = detector.blobs()[0];
+ const blob_detector::inscribed_circle circle = detector.find_inscribed_circle(b);
+ EXPECT_NEAR(circle.center_x, 100.0f, 1.0f);
+ EXPECT_NEAR(circle.center_y, 100.0f, 1.0f);
+ EXPECT_NEAR(circle.radius, b.radius, 1.0f);
+
+ // There is nothing left outside of the inscribed circle
+ EXPECT_LT(circle.remainder_radius, 2.0f);
+
+ // Invalid blobs are ignored
+ EXPECT_FLOAT_EQ(detector.find_inscribed_circle(blob_detector::blob{}).radius, 0.0f);
+ }
+
+ TEST(BlobDetector, InscribedCircleOfMergedBlob)
+ {
+ blob_detector detector;
+ test_image image(300, 200);
+
+ // A disk that touches a bar with the same color
+ image.disk(100.0f, 100.0f, 25.0f);
+ image.rect(120, 85, 299, 115);
+
+ image.detect(detector, true, 30);
+ ASSERT_EQ(detector.blobs().size(), 1u);
+
+ // The merged blob is not round, but the inscribed circle still finds the disk
+ const blob_detector::blob& b = detector.blobs()[0];
+ EXPECT_LT(b.fill_ratio(), 0.5f);
+ const blob_detector::inscribed_circle circle = detector.find_inscribed_circle(b);
+ EXPECT_NEAR(circle.center_x, 100.0f, 1.5f);
+ EXPECT_NEAR(circle.center_y, 100.0f, 1.5f);
+ EXPECT_NEAR(circle.radius, 25.0f, 1.0f);
+
+ // The bar is 31 pixels wide
+ EXPECT_NEAR(circle.remainder_radius, 15.0f, 1.0f);
+ }
+
+ TEST(BlobDetector, InscribedCircleIgnoresOtherBlobs)
+ {
+ blob_detector detector;
+ test_image image(200, 200);
+
+ // A frame around a small disk. The disk is inside the bounding box of the frame, but it doesn't belong to it.
+ image.rect(10, 10, 189, 189);
+ image.rect(20, 20, 179, 179, 0);
+ image.disk(100.0f, 100.0f, 10.0f);
+
+ image.detect(detector, true, 30);
+ ASSERT_EQ(detector.blobs().size(), 2u);
+
+ const blob_detector::blob* frame = blob_at(detector, 10, 10);
+ const blob_detector::blob* disk = blob_at(detector, 100, 100);
+ ASSERT_NE(frame, nullptr);
+ ASSERT_NE(disk, nullptr);
+
+ // The frame is 10 pixels wide
+ EXPECT_NEAR(detector.find_inscribed_circle(*frame).radius, 5.0f, 1.0f);
+ EXPECT_NEAR(detector.find_inscribed_circle(*disk).radius, 10.0f, 1.0f);
+ }
+
+ TEST(BlobDetector, OpeningRemovesNoiseAndBridges)
+ {
+ blob_detector detector;
+ test_image image(200, 100);
+
+ // Two disks that are connected by a thin line
+ image.disk(50.0f, 50.0f, 20.0f);
+ image.disk(150.0f, 50.0f, 20.0f);
+ image.rect(50, 50, 150, 51);
+
+ // Noise
+ image.pixels[10 * 200 + 10] = 255;
+ image.pixels[90 * 200 + 100] = 255;
+
+ // A blob at the image border
+ image.rect(0, 80, 9, 99);
+
+ image.detect(detector, true);
+ EXPECT_EQ(detector.blobs().size(), 4u);
+
+ std::vector pixels = image.pixels;
+ detector.open(pixels, image.width, image.height);
+ detector.detect(pixels, image.width, image.height, true);
+ ASSERT_EQ(detector.blobs().size(), 3u);
+
+ const blob_detector::blob* left = blob_at(detector, 50, 50);
+ const blob_detector::blob* right = blob_at(detector, 150, 50);
+ const blob_detector::blob* border = blob_at(detector, 0, 99);
+ ASSERT_NE(left, nullptr);
+ ASSERT_NE(right, nullptr);
+ ASSERT_NE(border, nullptr);
+ EXPECT_NE(left, right);
+
+ // The disks keep their shape and the border blob doesn't shrink
+ EXPECT_NEAR(left->radius, 20.0f, 0.6f);
+ EXPECT_NEAR(right->radius, 20.0f, 0.6f);
+ EXPECT_EQ(border->area, 10u * 20u);
+
+ // Invalid input is ignored
+ std::vector invalid(10, 255);
+ detector.open(invalid, 100, 100);
+ EXPECT_EQ(invalid, std::vector(10, 255));
+ }
+
+ TEST(BlobDetector, FillRatioRejectsNonCircularShapes)
+ {
+ blob_detector detector;
+ test_image image(200, 200);
+ image.rect(20, 20, 59, 59); // Square
+ image.rect(100, 20, 179, 59); // 2:1 rectangle
+
+ image.detect(detector, true);
+ ASSERT_EQ(detector.blobs().size(), 2u);
+
+ EXPECT_NEAR(detector.blobs()[0].fill_ratio(), 0.64f, 0.05f);
+ EXPECT_NEAR(detector.blobs()[1].fill_ratio(), 0.51f, 0.05f);
+ }
+
+ TEST(BlobDetector, DiagonalPixelsAreConnected)
+ {
+ blob_detector detector;
+ test_image image(10, 10);
+ image.pixels[1 * 10 + 1] = 255;
+ image.pixels[2 * 10 + 2] = 255;
+ image.pixels[3 * 10 + 3] = 255;
+
+ image.detect(detector, true);
+ ASSERT_EQ(detector.blobs().size(), 1u);
+ EXPECT_EQ(detector.blobs()[0].area, 3u);
+ }
+
+ TEST(BlobDetector, SeparateBlobs)
+ {
+ blob_detector detector;
+ test_image image(100, 50);
+ image.disk(20.0f, 25.0f, 10.0f);
+ image.disk(70.0f, 25.0f, 15.0f);
+
+ image.detect(detector, true);
+ ASSERT_EQ(detector.blobs().size(), 2u);
+
+ // Labels are 1-based and assigned in raster order (the larger disk starts in an earlier row)
+ EXPECT_EQ(detector.blobs()[0].label, 1u);
+ EXPECT_EQ(detector.blobs()[1].label, 2u);
+ EXPECT_EQ(detector.labels()[25 * 100 + 70], 1u);
+ EXPECT_EQ(detector.labels()[25 * 100 + 20], 2u);
+ EXPECT_EQ(detector.labels()[0], 0u);
+
+ const blob_detector::blob* small = blob_at(detector, 20, 25);
+ const blob_detector::blob* large = blob_at(detector, 70, 25);
+ ASSERT_NE(small, nullptr);
+ ASSERT_NE(large, nullptr);
+ EXPECT_NEAR(small->radius, 10.0f, 0.6f);
+ EXPECT_NEAR(large->radius, 15.0f, 0.6f);
+ }
+
+ TEST(BlobDetector, LateMergeAndBorderContact)
+ {
+ blob_detector detector;
+ test_image image(20, 20);
+
+ // U shape: the two arms are only connected at the bottom, and the shape touches the image border
+ image.rect(0, 0, 2, 15);
+ image.rect(10, 0, 12, 15);
+ image.rect(0, 13, 12, 15);
+
+ image.detect(detector, true);
+ ASSERT_EQ(detector.blobs().size(), 1u);
+ EXPECT_EQ(detector.blobs()[0].area, 3u * 16u + 3u * 16u + 7u * 3u);
+
+ // The open U is not a hole, so nothing should have been filled
+ EXPECT_EQ(detector.labels()[5 * 20 + 6], 0u);
+ }
+
+ TEST(BlobDetector, FillBlob)
+ {
+ blob_detector detector;
+ test_image image(100, 50);
+ image.disk(20.0f, 25.0f, 10.0f);
+ image.disk(70.0f, 25.0f, 15.0f);
+
+ image.detect(detector, true);
+ ASSERT_EQ(detector.blobs().size(), 2u);
+
+ const blob_detector::blob* small = blob_at(detector, 20, 25);
+ const blob_detector::blob* large = blob_at(detector, 70, 25);
+ ASSERT_NE(small, nullptr);
+ ASSERT_NE(large, nullptr);
+
+ std::vector pixels = image.pixels;
+ detector.fill_blob(pixels, *small, 0);
+
+ // The small blob is gone, the large one is untouched
+ EXPECT_EQ(pixels[25 * 100 + 20], 0);
+ EXPECT_EQ(pixels[25 * 100 + 70], 255);
+
+ std::vector rgba(pixels.size() * 4, 0);
+ detector.fill_blob_rgba(rgba, *large, { 1, 2, 3, 4 });
+ EXPECT_EQ(rgba[(25 * 100 + 70) * 4 + 0], 1);
+ EXPECT_EQ(rgba[(25 * 100 + 70) * 4 + 3], 4);
+ EXPECT_EQ(rgba[(25 * 100 + 20) * 4 + 0], 0);
+ }
+
+ TEST(BlobDetector, MinCircleArea)
+ {
+ blob_detector detector;
+ test_image image(100, 50);
+ image.disk(20.0f, 25.0f, 2.0f); // Small
+ image.disk(70.0f, 25.0f, 15.0f); // Large
+
+ image.detect(detector, true, 30);
+ ASSERT_EQ(detector.blobs().size(), 2u);
+
+ const blob_detector::blob* small = blob_at(detector, 20, 25);
+ const blob_detector::blob* large = blob_at(detector, 70, 25);
+ ASSERT_NE(small, nullptr);
+ ASSERT_NE(large, nullptr);
+
+ // The small blob is still reported, but without an enclosing circle
+ EXPECT_LT(small->area, 30u);
+ EXPECT_FLOAT_EQ(small->radius, 0.0f);
+ EXPECT_FLOAT_EQ(small->center_x, small->centroid_x);
+ EXPECT_NEAR(large->radius, 15.0f, 0.6f);
+ }
+
+ TEST(BlobDetector, MinEnclosingCircle)
+ {
+ std::mt19937 rng(42);
+
+ for (int iteration = 0; iteration < 200; iteration++)
+ {
+ std::vector points;
+ const u32 count = 1 + rng() % 40;
+
+ for (u32 i = 0; i < count; i++)
+ {
+ points.push_back({ static_cast(rng() % 100), static_cast(rng() % 100) });
+ }
+
+ std::vector shuffled = points;
+ f32 center_x, center_y, radius;
+ blob_detector::min_enclosing_circle(shuffled, center_x, center_y, radius);
+
+ f32 max_distance = 0.0f;
+ f32 max_half_distance = 0.0f;
+
+ for (const blob_detector::point& a : points)
+ {
+ max_distance = std::max(max_distance, std::hypot(a.x - center_x, a.y - center_y));
+
+ for (const blob_detector::point& b : points)
+ {
+ max_half_distance = std::max(max_half_distance, std::hypot(static_cast(a.x - b.x), static_cast(a.y - b.y)) / 2.0f);
+ }
+ }
+
+ // All points are inside
+ EXPECT_LE(max_distance, radius + 1e-3f);
+
+ // The circle is at least as large as half the largest distance, and at most diameter / sqrt(3)
+ EXPECT_GE(radius, max_half_distance - 1e-3f);
+ EXPECT_LE(radius, max_half_distance * 1.1548f + 1e-3f);
+ }
+ }
+
+ TEST(BlobDetector, MinEnclosingCircleCollinear)
+ {
+ std::vector points = { { 0, 0 }, { 5, 0 }, { 10, 0 } };
+ f32 center_x, center_y, radius;
+ blob_detector::min_enclosing_circle(points, center_x, center_y, radius);
+
+ EXPECT_NEAR(center_x, 5.0f, 1e-4f);
+ EXPECT_NEAR(center_y, 0.0f, 1e-4f);
+ EXPECT_NEAR(radius, 5.0f, 1e-4f);
+ }
+
+ TEST(BlobDetector, DrawingStaysInBounds)
+ {
+ constexpr u32 width = 32;
+ constexpr u32 height = 16;
+ std::vector rgba(width * height * 4, 0);
+
+ // Partially or completely outside of the image
+ rgba_draw::circle(rgba, width, height, 30.0f, 2.0f, 10, { 1, 2, 3, 4 }, true);
+ rgba_draw::circle(rgba, width, height, -5.0f, 8.0f, 3, { 1, 2, 3, 4 }, false);
+ rgba_draw::circle(rgba, width, height, 1000.0f, 1000.0f, 3, { 1, 2, 3, 4 }, true);
+ rgba_draw::line(rgba, width, height, -10, -10, 50, 40, { 1, 2, 3, 4 });
+
+ EXPECT_EQ(rgba[(2 * width + 30) * 4], 1);
+ }
+
+ TEST(BlobDetector, DrawCircleOutline)
+ {
+ constexpr u32 width = 21;
+ constexpr u32 height = 21;
+ std::vector rgba(width * height * 4, 0);
+
+ rgba_draw::circle(rgba, width, height, 10.0f, 10.0f, 5, { 255, 0, 0, 255 }, false);
+
+ // On the outline
+ EXPECT_EQ(rgba[(10 * width + 15) * 4], 255);
+ EXPECT_EQ(rgba[(5 * width + 10) * 4], 255);
+
+ // Inside and outside
+ EXPECT_EQ(rgba[(10 * width + 10) * 4], 0);
+ EXPECT_EQ(rgba[(10 * width + 18) * 4], 0);
+ }
+}
diff --git a/rpcs3/tests/test_iso_extent.cpp b/rpcs3/tests/test_iso_extent.cpp
index ece67d1798..456c8f8aa6 100644
--- a/rpcs3/tests/test_iso_extent.cpp
+++ b/rpcs3/tests/test_iso_extent.cpp
@@ -1,6 +1,6 @@
#include "iso_test_fixture.h"
-namespace
+namespace iso
{
class counted_file final : public fs::file_base
{
@@ -57,70 +57,70 @@ namespace
this->m_file = fs::file(std::make_unique(std::move(this->m_file), reads));
}
};
-}
-TEST_F(iso_test, ExtentEofDoesNotReadBackingFile)
-{
- for (bool encrypted_path : {false, true})
+ TEST_F(iso_test, ExtentEofDoesNotReadBackingFile)
{
- for (bool multiple_extents : {false, true})
+ for (bool encrypted_path : {false, true})
{
- SCOPED_TRACE(testing::Message() << "encrypted path=" << encrypted_path << ", multiple extents=" << multiple_extents);
- iso_fs_node node;
- node.metadata.extents = {{20, 3}};
- if (multiple_extents)
+ for (bool multiple_extents : {false, true})
{
- node.metadata.extents.insert(node.metadata.extents.begin(), {22, ISO_SECTOR_SIZE});
- }
- u64 reads = 0;
- std::unique_ptr member;
- if (encrypted_path)
- {
- member = std::make_unique>(reads, m_path, fs::read, node, std::make_shared());
- }
- else
- {
- member = std::make_unique>(reads, m_path, fs::read, node);
- }
- for (u64 offset : {member->size(), member->size() + 1, member->size() + ISO_SECTOR_SIZE, u64{umax}})
- {
- SCOPED_TRACE(offset);
- std::array buffer;
- buffer.fill(0x5a);
- const auto expected = buffer;
- const u64 before = reads;
- EXPECT_EQ(member->read_at(offset, buffer.data(), buffer.size()), 0);
- EXPECT_EQ(reads, before);
- EXPECT_EQ(buffer, expected);
+ SCOPED_TRACE(testing::Message() << "encrypted path=" << encrypted_path << ", multiple extents=" << multiple_extents);
+ iso_fs_node node;
+ node.metadata.extents = {{20, 3}};
+ if (multiple_extents)
+ {
+ node.metadata.extents.insert(node.metadata.extents.begin(), {22, ISO_SECTOR_SIZE});
+ }
+ u64 reads = 0;
+ std::unique_ptr member;
+ if (encrypted_path)
+ {
+ member = std::make_unique>(reads, m_path, fs::read, node, std::make_shared());
+ }
+ else
+ {
+ member = std::make_unique>(reads, m_path, fs::read, node);
+ }
+ for (u64 offset : {member->size(), member->size() + 1, member->size() + ISO_SECTOR_SIZE, u64{umax}})
+ {
+ SCOPED_TRACE(offset);
+ std::array buffer;
+ buffer.fill(0x5a);
+ const auto expected = buffer;
+ const u64 before = reads;
+ EXPECT_EQ(member->read_at(offset, buffer.data(), buffer.size()), 0);
+ EXPECT_EQ(reads, before);
+ EXPECT_EQ(buffer, expected);
+ }
}
}
}
-}
-TEST_F(iso_test, ReadsAcrossExtentsStopAtLogicalEof)
-{
- iso_fs_node node;
- node.metadata.extents = {{22, ISO_SECTOR_SIZE}, {20, 3}};
- iso_file member(m_path, fs::read, node);
- std::array buffer{};
- EXPECT_EQ(member.read_at(ISO_SECTOR_SIZE - 1, buffer.data(), buffer.size()), 4);
- EXPECT_EQ(buffer[0], '\0');
- EXPECT_EQ(std::string(buffer.data() + 1, 3), "abc");
- EXPECT_EQ(buffer[4], '\0');
- EXPECT_EQ(member.seek(member.size() + 1, fs::seek_set), member.size() + 1);
- EXPECT_EQ(member.read(buffer.data(), buffer.size()), 0);
-}
+ TEST_F(iso_test, ReadsAcrossExtentsStopAtLogicalEof)
+ {
+ iso_fs_node node;
+ node.metadata.extents = {{22, ISO_SECTOR_SIZE}, {20, 3}};
+ iso_file member(m_path, fs::read, node);
+ std::array buffer{};
+ EXPECT_EQ(member.read_at(ISO_SECTOR_SIZE - 1, buffer.data(), buffer.size()), 4);
+ EXPECT_EQ(buffer[0], '\0');
+ EXPECT_EQ(std::string(buffer.data() + 1, 3), "abc");
+ EXPECT_EQ(buffer[4], '\0');
+ EXPECT_EQ(member.seek(member.size() + 1, fs::seek_set), member.size() + 1);
+ EXPECT_EQ(member.read(buffer.data(), buffer.size()), 0);
+ }
-TEST_F(iso_test, EmptyMemberDoesNotReadBackingFile)
-{
- iso_fs_node node;
- node.metadata.extents = {{20, 0}};
- u64 reads = 0;
- observed_iso_file member(reads, m_path, fs::read, node);
- char byte = 'x';
- EXPECT_EQ(member.read_at(0, &byte, 1), 0);
- EXPECT_EQ(member.read_at(1, &byte, 1), 0);
- EXPECT_EQ(member.read_at(0, &byte, 0), 0);
- EXPECT_EQ(reads, 0);
- EXPECT_EQ(byte, 'x');
+ TEST_F(iso_test, EmptyMemberDoesNotReadBackingFile)
+ {
+ iso_fs_node node;
+ node.metadata.extents = {{20, 0}};
+ u64 reads = 0;
+ observed_iso_file member(reads, m_path, fs::read, node);
+ char byte = 'x';
+ EXPECT_EQ(member.read_at(0, &byte, 1), 0);
+ EXPECT_EQ(member.read_at(1, &byte, 1), 0);
+ EXPECT_EQ(member.read_at(0, &byte, 0), 0);
+ EXPECT_EQ(reads, 0);
+ EXPECT_EQ(byte, 'x');
+ }
}
diff --git a/rpcs3/tests/test_iso_open.cpp b/rpcs3/tests/test_iso_open.cpp
index 6d31bde2d6..74a0b20f92 100644
--- a/rpcs3/tests/test_iso_open.cpp
+++ b/rpcs3/tests/test_iso_open.cpp
@@ -1,64 +1,67 @@
#include "iso_test_fixture.h"
-TEST_F(iso_test, MissingMemberReturnsEmptyHandle)
+namespace iso
{
- iso_archive archive(m_path);
- ASSERT_TRUE(archive.is_valid());
- EXPECT_FALSE(archive.open("MISSING.BIN"));
- EXPECT_EQ(fs::g_tls_error, fs::error::noent);
- EXPECT_FALSE(archive.open("TEST.BIN/MISSING"));
- EXPECT_FALSE(archive.open(""));
-
- fs::file member(archive.open("TEST.BIN"));
- ASSERT_TRUE(member);
- std::array data{};
- EXPECT_EQ(member.read(data.data(), data.size()), data.size());
- EXPECT_EQ(std::string(data.data(), data.size()), "abc");
-}
-
-TEST_F(iso_test, InvalidArchiveReturnsEmptyHandle)
-{
- iso_archive archive(m_path + ".missing");
- ASSERT_FALSE(archive.is_valid());
- EXPECT_FALSE(archive.open("TEST.BIN"));
-}
-
-TEST_F(iso_test, TruncatedMetadataRejectsArchive)
-{
- for (usz length : {16 * ISO_SECTOR_SIZE + 157, 18 * ISO_SECTOR_SIZE, 18 * ISO_SECTOR_SIZE + 10, 18 * ISO_SECTOR_SIZE + 33})
+ TEST_F(iso_test, MissingMemberReturnsEmptyHandle)
{
- SCOPED_TRACE(length);
- {
- std::ofstream out(m_path, std::ios::binary);
- out.write(reinterpret_cast(m_image.data()), length);
- ASSERT_TRUE(out.good());
- }
iso_archive archive(m_path);
- EXPECT_FALSE(archive.is_valid());
+ ASSERT_TRUE(archive.is_valid());
+ EXPECT_FALSE(archive.open("MISSING.BIN"));
+ EXPECT_EQ(fs::g_tls_error, fs::error::noent);
+ EXPECT_FALSE(archive.open("TEST.BIN/MISSING"));
+ EXPECT_FALSE(archive.open(""));
+
+ fs::file member(archive.open("TEST.BIN"));
+ ASSERT_TRUE(member);
+ std::array data{};
+ EXPECT_EQ(member.read(data.data(), data.size()), data.size());
+ EXPECT_EQ(std::string(data.data(), data.size()), "abc");
}
-}
-TEST_F(iso_test, InvalidDirectoryRecordRejectsArchive)
-{
- const auto original = m_image;
- for (bool invalid_length : {false, true})
+ TEST_F(iso_test, InvalidArchiveReturnsEmptyHandle)
{
- SCOPED_TRACE(invalid_length);
- m_image = original;
- if (invalid_length)
- {
- m_image[18 * ISO_SECTOR_SIZE] = 1;
- }
- else
+ iso_archive archive(m_path + ".missing");
+ ASSERT_FALSE(archive.is_valid());
+ EXPECT_FALSE(archive.open("TEST.BIN"));
+ }
+
+ TEST_F(iso_test, TruncatedMetadataRejectsArchive)
+ {
+ for (usz length : {16 * ISO_SECTOR_SIZE + 157, 18 * ISO_SECTOR_SIZE, 18 * ISO_SECTOR_SIZE + 10, 18 * ISO_SECTOR_SIZE + 33})
{
- m_image[18 * ISO_SECTOR_SIZE + 32] = 255;
+ SCOPED_TRACE(length);
+ {
+ std::ofstream out(m_path, std::ios::binary);
+ out.write(reinterpret_cast(m_image.data()), length);
+ ASSERT_TRUE(out.good());
+ }
+ iso_archive archive(m_path);
+ EXPECT_FALSE(archive.is_valid());
}
+ }
+
+ TEST_F(iso_test, InvalidDirectoryRecordRejectsArchive)
+ {
+ const auto original = m_image;
+ for (bool invalid_length : {false, true})
{
- std::ofstream out(m_path, std::ios::binary);
- out.write(reinterpret_cast(m_image.data()), m_image.size());
- ASSERT_TRUE(out.good());
+ SCOPED_TRACE(invalid_length);
+ m_image = original;
+ if (invalid_length)
+ {
+ m_image[18 * ISO_SECTOR_SIZE] = 1;
+ }
+ else
+ {
+ m_image[18 * ISO_SECTOR_SIZE + 32] = 255;
+ }
+ {
+ std::ofstream out(m_path, std::ios::binary);
+ out.write(reinterpret_cast(m_image.data()), m_image.size());
+ ASSERT_TRUE(out.good());
+ }
+ iso_archive archive(m_path);
+ EXPECT_FALSE(archive.is_valid());
}
- iso_archive archive(m_path);
- EXPECT_FALSE(archive.is_valid());
}
}
diff --git a/scripts/win_build.ps1 b/scripts/win_build.ps1
index a5e42b31d2..cdb33a9ead 100644
--- a/scripts/win_build.ps1
+++ b/scripts/win_build.ps1
@@ -147,7 +147,6 @@ if (-not (Test-Path $CacheFile)) {
& cmake $SourceDir -G 'Ninja Multi-Config' `
-DBUILD_LLVM=ON `
- -DUSE_SYSTEM_OPENCV=OFF `
-DUSE_SYSTEM_CURL=OFF `
-DUSE_SYSTEM_ZLIB=OFF `
-DUSE_SYSTEM_LIBPNG=OFF `