Skip to content

Commit 5557c91

Browse files
authored
Feature detect stacks (#114)
* add min shift option * add test for stack detection * skip complexity check in tests * add option to force install desktop files
1 parent 9853797 commit 5557c91

File tree

11 files changed

+85
-21
lines changed

11 files changed

+85
-21
lines changed

CMakeLists.txt

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ project(Xpano)
44
OPTION(BUILD_TESTING "Build tests" OFF)
55
OPTION(XPANO_STATIC_VCRT "Build with static VCRT" OFF)
66
OPTION(XPANO_WITH_MULTIBLEND "Build with multiblend" ON)
7+
OPTION(XPANO_INSTALL_DESKTOP_FILES "Install desktop files" OFF)
78

89
if(XPANO_STATIC_VCRT)
910
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
@@ -167,7 +168,7 @@ install(FILES
167168
TYPE BIN
168169
)
169170

170-
if(CMAKE_INSTALL_PREFIX MATCHES "^/usr.*|^/app.*")
171+
if(XPANO_INSTALL_DESKTOP_FILES OR CMAKE_INSTALL_PREFIX MATCHES "^/usr.*|^/app.*")
171172
install(FILES
172173
"misc/build/linux/xpano.desktop"
173174
DESTINATION "${CMAKE_INSTALL_PREFIX}/share/applications"

tests/stitcher_pipeline_test.cc

+37-5
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ int CountNonZero(const cv::Mat& image) {
4040
return cv::countNonZero(image_gray);
4141
}
4242

43+
// Clang-tidy doesn't like the macros
44+
// NOLINTBEGIN(readability-function-cognitive-complexity)
45+
4346
TEST_CASE("Stitcher pipeline defaults") {
4447
xpano::pipeline::StitcherPipeline<kReturnFuture> stitcher;
4548

@@ -93,9 +96,6 @@ TEST_CASE("Stitcher pipeline defaults") {
9396
CHECK(total_pixels == non_zero_pixels);
9497
}
9598

96-
// Clang-tidy doesn't like the macros
97-
// NOLINTBEGIN(readability-function-cognitive-complexity)
98-
9999
TEST_CASE("Stitcher pipeline single pano matching") {
100100
xpano::pipeline::StitcherPipeline<kReturnFuture> stitcher;
101101
auto loading_task = stitcher.RunLoading(
@@ -134,8 +134,6 @@ TEST_CASE("Stitcher pipeline no matching") {
134134
}
135135
}
136136

137-
// NOLINTEND(readability-function-cognitive-complexity)
138-
139137
const std::vector<std::filesystem::path> kShuffledInputs = {
140138
"data/image01.jpg", // Pano 1
141139
"data/image06.jpg", // 2
@@ -500,3 +498,37 @@ TEST_CASE("Stitcher pipeline polling") {
500498
CHECK_THAT(pano1->rows, WithinRel(976, eps));
501499
CHECK_THAT(pano1->cols, WithinRel(1335, eps));
502500
}
501+
502+
const std::vector<std::filesystem::path> kInputsWithStack = {
503+
"data/image01.jpg", // Minimal shift
504+
"data/image05.jpg", // between images
505+
"data/image06.jpg",
506+
"data/image07.jpg",
507+
};
508+
509+
TEST_CASE("Stitcher pipeline stack detection") {
510+
const float min_shift = 0.2f;
511+
xpano::pipeline::StitcherPipeline<kReturnFuture> stitcher;
512+
auto loading_task =
513+
stitcher.RunLoading(kInputsWithStack, {}, {.min_shift = min_shift});
514+
auto result = loading_task.future.get();
515+
auto progress = loading_task.progress->Report();
516+
CHECK(progress.tasks_done == progress.num_tasks);
517+
518+
std::vector<xpano::algorithm::Match> good_matches;
519+
std::copy_if(result.matches.begin(), result.matches.end(),
520+
std::back_inserter(good_matches), [](const auto& match) {
521+
return match.matches.size() >= xpano::kDefaultMatchThreshold;
522+
});
523+
524+
CHECK(result.images.size() == 4);
525+
526+
REQUIRE(good_matches.size() == 2);
527+
CHECK(good_matches[0].avg_shift < min_shift);
528+
CHECK(good_matches[1].avg_shift >= min_shift);
529+
530+
REQUIRE(result.panos.size() == 1);
531+
CHECK_THAT(result.panos[0].ids, Equals<int>({2, 3}));
532+
}
533+
534+
// NOLINTEND(readability-function-cognitive-complexity)

xpano/algorithm/algorithm.cc

+18-7
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,8 @@ cv::Ptr<cv::detail::Blender> PickBlender(BlendingMethod blending_method,
140140

141141
} // namespace
142142

143-
std::vector<cv::DMatch> MatchImages(const Image& img1, const Image& img2,
144-
float match_conf) {
143+
Match MatchImages(int img1_id, int img2_id, const Image& img1,
144+
const Image& img2, float match_conf) {
145145
if (img1.GetKeypoints().empty() || img2.GetKeypoints().empty()) {
146146
return {};
147147
}
@@ -183,24 +183,35 @@ std::vector<cv::DMatch> MatchImages(const Image& img1, const Image& img2,
183183

184184
// FILTER OUTLIERS
185185
std::vector<cv::DMatch> inliers;
186+
double total_shift = 0.0f;
187+
186188
for (int i = 0; i < good_matches.size(); i++) {
187-
const cv::Vec2f diff =
189+
const cv::Vec2f proj_diff =
188190
dst_points.at<cv::Vec2f>(0, i) - dst_points_proj.at<cv::Vec2f>(0, i);
189-
if (norm(diff) < 3) {
191+
if (norm(proj_diff) < 3) {
190192
inliers.push_back(good_matches[i]);
193+
194+
const cv::Vec2f diff =
195+
dst_points.at<cv::Vec2f>(0, i) - src_points.at<cv::Vec2f>(0, i);
196+
total_shift += norm(diff);
191197
}
192198
}
193199

194-
return inliers;
200+
const int max_size =
201+
std::max(img1.GetPreviewLongerSide(), img2.GetPreviewLongerSide());
202+
const auto avg_shift = static_cast<float>(
203+
total_shift / static_cast<double>(inliers.size()) / max_size);
204+
return {img1_id, img2_id, inliers, avg_shift};
195205
}
196206

197207
std::vector<Pano> FindPanos(const std::vector<Match>& matches,
198-
int match_threshold) {
208+
int match_threshold, float min_shift) {
199209
auto pano_ds = utils::DisjointSet();
200210

201211
std::unordered_set<int> images_in_panos;
202212
for (const auto& match : matches) {
203-
if (match.matches.size() > match_threshold) {
213+
if (match.matches.size() >= match_threshold &&
214+
match.avg_shift >= min_shift) {
204215
pano_ds.Union(match.id1, match.id2);
205216
images_in_panos.insert(match.id1);
206217
images_in_panos.insert(match.id2);

xpano/algorithm/algorithm.h

+4-3
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,16 @@ struct Match {
3232
int id1;
3333
int id2;
3434
std::vector<cv::DMatch> matches;
35+
float avg_shift = 0.0f;
3536
};
3637

3738
Pano SinglePano(int size);
3839

39-
std::vector<cv::DMatch> MatchImages(const Image& img1, const Image& img2,
40-
float match_conf);
40+
Match MatchImages(int img1_id, int img2_id, const Image& img1,
41+
const Image& img2, float match_conf);
4142

4243
std::vector<Pano> FindPanos(const std::vector<Match>& matches,
43-
int match_threshold);
44+
int match_threshold, float min_shift);
4445

4546
struct StitchResult {
4647
stitcher::Status status;

xpano/algorithm/image.cc

+4
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ cv::Mat Image::GetFullRes() const { return cv::imread(path_.string()); }
8484
cv::Mat Image::GetThumbnail() const { return thumbnail_; }
8585
cv::Mat Image::GetPreview() const { return preview_; }
8686

87+
int Image::GetPreviewLongerSide() const {
88+
return std::max(preview_.size[1], preview_.size[0]);
89+
}
90+
8791
float Image::GetAspect() const {
8892
auto width = static_cast<float>(preview_.size[1]);
8993
auto height = static_cast<float>(preview_.size[0]);

xpano/algorithm/image.h

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class Image {
2626
[[nodiscard]] cv::Mat GetFullRes() const;
2727
[[nodiscard]] cv::Mat GetThumbnail() const;
2828
[[nodiscard]] cv::Mat GetPreview() const;
29+
[[nodiscard]] int GetPreviewLongerSide() const;
2930
[[nodiscard]] float GetAspect() const;
3031
[[nodiscard]] cv::Mat Draw(bool show_debug) const;
3132
[[nodiscard]] const std::vector<cv::KeyPoint>& GetKeypoints() const;

xpano/constants.h

+4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ constexpr int kMinMatchThreshold = 4;
1717
constexpr int kDefaultMatchThreshold = 70;
1818
constexpr int kMaxMatchThreshold = 250;
1919

20+
constexpr float kMinShiftInPano = 0.0f;
21+
constexpr float kDefaultShiftInPano = 0.1f;
22+
constexpr float kMaxShiftInPano = 1.0f;
23+
2024
constexpr int kWindowWidth = 1280;
2125
constexpr int kWindowHeight = 800;
2226
constexpr int kMinWindowSize = 200;

xpano/gui/panels/sidebar.cc

+11-2
Original file line numberDiff line numberDiff line change
@@ -193,9 +193,9 @@ void DrawMatchingOptionsMenu(pipeline::MatchingOptions* matching_options,
193193
ImGui::Spacing();
194194
ImGui::Text(
195195
"Experiment with this if the app cannot find the panoramas you "
196-
"want.");
196+
"want.\nThese options are applied only after reloading images.");
197197
ImGui::Spacing();
198-
ImGui::SliderInt("Matching distance",
198+
ImGui::SliderInt("Matching neighbors",
199199
&matching_options->neighborhood_search_size, 0,
200200
kMaxNeighborhoodSearchSize);
201201
ImGui::SameLine();
@@ -210,6 +210,15 @@ void DrawMatchingOptionsMenu(pipeline::MatchingOptions* matching_options,
210210
"Number of keypoints that need to match in "
211211
"order to include the two "
212212
"images in a panorama.");
213+
ImGui::SliderFloat("Minimum shift", &matching_options->min_shift,
214+
kMinShiftInPano, kMaxShiftInPano, "%.2f");
215+
ImGui::SameLine();
216+
utils::imgui::InfoMarker(
217+
"(?)",
218+
"Minimum shift between images for it to be considered a part of a "
219+
"panorama.\nUseful to filter out burst shots / focus stacks that "
220+
"shouldn't be handled by Xpano.\nThe value is specified in relative "
221+
"terms to the size of the image.");
213222
if (debug_enabled) {
214223
ImGui::SeparatorText("Debug");
215224
DrawMatchConf(&matching_options->match_conf);

xpano/gui/pano_gui.cc

+1
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,7 @@ Action PanoGui::PerformAction(const Action& action) {
433433
selection_ = {SelectionType::kMatch, action.target_id};
434434
spdlog::info("Clicked match {}", action.target_id);
435435
const auto& match = stitcher_data_->matches[action.target_id];
436+
spdlog::info("Match distance {}", match.avg_shift);
436437
auto img = DrawMatches(match, stitcher_data_->images);
437438
plot_pane_.Load(img, ImageType::kMatch);
438439
thumbnail_pane_.SetScrollX(match.id1, match.id2);

xpano/pipeline/options.h

+1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ struct MatchingOptions {
5959
MatchingType type = MatchingType::kAuto;
6060
int neighborhood_search_size = kDefaultNeighborhoodSearchSize;
6161
int match_threshold = kDefaultMatchThreshold;
62+
float min_shift = kDefaultShiftInPano;
6263
float match_conf = kDefaultMatchConf;
6364
};
6465

xpano/pipeline/stitcher_pipeline.cc

+2-3
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,7 @@ StitcherData RunMatchingPipeline(std::vector<algorithm::Image> images,
175175
matches_future.push_back(
176176
pool->submit([i, j, left = images[i], right = images[j],
177177
match_conf = options.match_conf, progress]() {
178-
auto match =
179-
algorithm::Match{i, j, MatchImages(left, right, match_conf)};
178+
auto match = algorithm::MatchImages(i, j, left, right, match_conf);
180179
progress->NotifyTaskDone();
181180
return match;
182181
}));
@@ -188,7 +187,7 @@ StitcherData RunMatchingPipeline(std::vector<algorithm::Image> images,
188187
}
189188
auto matches = matches_future.get();
190189

191-
auto panos = FindPanos(matches, options.match_threshold);
190+
auto panos = FindPanos(matches, options.match_threshold, options.min_shift);
192191
progress->NotifyTaskDone();
193192
return StitcherData{images, matches, panos};
194193
}

0 commit comments

Comments
 (0)