fix outliers and missing images in colmap parser

This commit is contained in:
Harry Chen
2026-09-27 14:51:36 -04:00
parent d8e6a267f9
commit 7281ae4fea
8 changed files with 85 additions and 15 deletions
+17 -1
View File
@@ -1,6 +1,6 @@
// Host-only check of camhost::frustum_display_size: the ball average against
// quadrature, the ~1% screen budget it promises, 1/sqrt(N) scaling, unit
// invariance, bracket merging and the degenerate fallbacks.
// invariance, bracket merging, stray cameras and the degenerate fallbacks.
// No GPU. Exit code 0 = every check passed.
#include "data/FrustumSize.h"
@@ -163,6 +163,21 @@ void test_fallbacks() {
CHECK(nan_cam.size() == s_clean, "a NaN position changed the size");
}
// The Stategallery RealityScan export: 649 cameras within ~16 units and a
// handful registered 1000-13800 units out.
void test_strays() {
Cloud c = fibonacci_sphere(600, 10.0);
const double s_clean = c.size();
for (double d : {1157.0, 2056.0, 2443.0, 7487.0, 13781.0}) c.add(d, 0.3 * d, 0);
CHECK(std::fabs(c.size() - s_clean) < 1e-9 * s_clean,
"stray cameras moved the size %.4f -> %.4f", s_clean, c.size());
Cloud walk;
for (int i = 0; i < 100; i++) walk.add(i, 0, 0);
const double s_walk = walk.size();
walk.add(150, 0, 0);
CHECK(walk.size() != s_walk, "a camera 1.5 walk-lengths on was dropped as a stray");
}
} // namespace
int main() {
@@ -171,6 +186,7 @@ int main() {
test_scaling();
test_bracket_merge();
test_fallbacks();
test_strays();
if (g_fail) {
std::fprintf(stderr, "frustum_size_test: %d check(s) failed\n", g_fail);
return 1;
+5
View File
@@ -360,6 +360,11 @@ PostSplitCameras bake_post_split(const ParsedDataset& ds,
// ===========================================================================
namespace dsparse {
// Stray cameras past this many median distances (a failed registration
// 1700x out on a RealityScan export) are kept but set no scale. Must match
// kStrayOverMedian in data/FrustumSize.h.
constexpr float kStrayCameraThreshold = 20.0f;
// T_n_from_camera = scale * [R_align | -R_align @ center] (row-major 4x4)
// over c2w [N,3,4], orient="up" / center="poses"; returns scale_factor. The
// viewer remap is inv(that @ applied); `R_out` is R_align alone.
+25
View File
@@ -28,6 +28,10 @@ constexpr double kShellInner = 0.5, kShellOuter = 2.0;
constexpr double kMergeTolerance = 0.01;
constexpr double kMaxSizeOverRadius = 0.15; // a handful of cameras must not become billboards
constexpr double kFallbackSize = 0.2; // no spread to measure: one distinct position
// A camera further than this many median distances out is a failed
// registration; kept, it sizes every frustum to itself. Must match
// dsparse::kStrayCameraThreshold (data/DatasetParser.h).
constexpr double kStrayOverMedian = 20.0;
// Mean of 1/|c - e|^2 over eyes e uniform in a ball of radius rho, for a
// camera c at distance r from the ball's centre. Finite everywhere.
@@ -58,6 +62,26 @@ inline P3 centroid(const std::vector<P3>& p) {
return c;
}
inline std::vector<P3> drop_strays(std::vector<P3> p) {
if (p.size() < 3) return p;
P3 m;
std::vector<double> v(p.size());
for (int k = 0; k < 3; k++) {
for (size_t i = 0; i < p.size(); i++) v[i] = p[i][k];
std::nth_element(v.begin(), v.begin() + v.size() / 2, v.end());
m[k] = v[v.size() / 2];
}
for (size_t i = 0; i < p.size(); i++) v[i] = dist2(p[i], m);
std::vector<double> d = v;
std::nth_element(d.begin(), d.begin() + d.size() / 2, d.end());
const double lim = kStrayOverMedian * kStrayOverMedian * d[d.size() / 2];
if (!(lim > 0.0)) return p;
std::vector<P3> kept;
for (size_t i = 0; i < p.size(); i++)
if (v[i] <= lim) kept.push_back(p[i]);
return kept;
}
inline double max_radius(const std::vector<P3>& p, const P3& c) {
double r2 = 0.0;
for (const P3& q : p) r2 = std::fmax(r2, dist2(q, c));
@@ -105,6 +129,7 @@ inline double frustum_display_size(const float* c2w, int64_t n) {
P3 q{c2w[i * 12 + 3], c2w[i * 12 + 7], c2w[i * 12 + 11]};
if (std::isfinite(q[0]) && std::isfinite(q[1]) && std::isfinite(q[2])) pos.push_back(q);
}
pos = drop_strays(std::move(pos));
if (pos.empty()) return kFallbackSize;
double radius = max_radius(pos, centroid(pos));
if (!(radius > 0.0)) return kFallbackSize;
+21 -7
View File
@@ -752,10 +752,11 @@ ParsedDataset parse_colmap_dataset(const std::string& dataset_dir,
std::to_string(id) + " has " +
std::to_string(cam.params.size()) +
" params, expected 2 (w, h)");
// COLMAP stores (w, h) as *metadata* params, so they are the camera's
// own dimensions by construction. A mismatch means a corrupt or
// hand-edited model, and the two would disagree about the projection.
if (cam.params[0] != (double)cam.width || cam.params[1] != (double)cam.height)
// COLMAP stores (w, h) as *metadata* params, so a mismatch means a
// corrupt or hand-edited model. Half a pixel of slack: v2026.9.24's
// SfM wrote 2*pi*(w/2pi), which is off by an ulp.
if (std::abs(cam.params[0] - (double)cam.width) > 0.5 ||
std::abs(cam.params[1] - (double)cam.height) > 0.5)
throw std::runtime_error(
"ColmapParser: EQUIRECTANGULAR camera " + std::to_string(id) +
" params (" + std::to_string((int64_t)cam.params[0]) + ", " +
@@ -782,16 +783,32 @@ ParsedDataset parse_colmap_dataset(const std::string& dataset_dir,
struct Frame { const ColmapImage* im; std::string path, sort_key, aux_name; };
std::vector<Frame> frames;
frames.reserve(images.size());
std::vector<std::string> missing;
for (const auto& [id, im] : images) {
std::error_code ec;
fs::path path = (fs::path(image_dir) / im.name).lexically_normal();
const fs::path in_dataset = (fs::path(dataset_dir) / im.name).lexically_normal();
if (!fs::exists(path, ec) && fs::exists(in_dataset, ec)) path = in_dataset;
if (cfg.require_image_files && !fs::exists(path, ec)) {
missing.push_back(path.string());
continue;
}
std::string aux_name = dsparse::relative_under(path.string(), image_dir);
if (aux_name.empty())
aux_name = dsparse::relative_under(path.string(), dataset_dir);
frames.push_back({&im, path.string(), path.generic_string(), std::move(aux_name)});
}
// Exporters list images they did not write (RealityScan leaves the ones
// it failed to align, with NaN observations), so skip those. None found
// is a wrong image dir, not a partial export.
if (frames.empty() && !missing.empty())
throw std::runtime_error("ColmapParser: " + missing.front() +
" does not exist (set --image-dir if needed)");
std::sort(missing.begin(), missing.end());
for (const std::string& m : missing)
std::printf("%s %s\n", spirula::i18n::msg::data::word_warning.get(),
spirula::i18n::format(spirula::i18n::msg::data::image_missing,
{m}).c_str());
std::sort(frames.begin(), frames.end(),
[](const Frame& a, const Frame& b) { return a.sort_key < b.sort_key; });
@@ -912,9 +929,6 @@ ParsedDataset parse_colmap_dataset(const std::string& dataset_dir,
std::to_string(im.camera_id));
const ColmapCamera& cam = cam_it->second;
if (cfg.require_image_files && !fs::exists(frames[i].path))
throw std::runtime_error("ColmapParser: " + frames[i].path +
" does not exist (set --image-dir if needed)");
ds.image_filenames.push_back(frames[i].path);
const int turns =
+14 -5
View File
@@ -64,18 +64,24 @@ double compute_normalized_transform(const double* c2w, int64_t n,
R_out[0] = R_out[4] = R_out[8] = 1.0;
}
if (n <= 0) return 1.0;
std::vector<double> pos(n * 3);
for (int64_t i = 0; i < n; i++)
for (int r = 0; r < 3; r++) pos[i*3 + r] = c2w[i*12 + r*4 + 3];
const std::vector<char> inlier = outlier_keep_mask(pos, n, kStrayCameraThreshold);
double up[3] = {0, 0, 0}, center[3] = {0, 0, 0};
int64_t n_inlier = 0;
for (int64_t i = 0; i < n; i++) {
double u[3] = {0, 1, 0};
if (exif_orientation) exif_up_gl(exif_orientation[i], u);
for (int r = 0; r < 3; r++) {
for (int r = 0; r < 3; r++)
for (int k = 0; k < 3; k++) up[r] += c2w[i*12 + r*4 + k] * u[k];
center[r] += c2w[i*12 + r*4 + 3];
}
if (!inlier[i]) continue;
n_inlier++;
for (int r = 0; r < 3; r++) center[r] += pos[i*3 + r];
}
double un = std::sqrt(up[0]*up[0] + up[1]*up[1] + up[2]*up[2]);
for (auto& u : up) u /= std::max(un, 1e-12);
for (auto& c : center) c /= (double)n;
for (auto& c : center) c /= (double)n_inlier;
double axis[3] = {up[1], -up[0], 0.0}; // up x z
double s = std::sqrt(axis[0]*axis[0] + axis[1]*axis[1]);
@@ -98,6 +104,7 @@ double compute_normalized_transform(const double* c2w, int64_t n,
double max_abs = 0.0;
for (int64_t i = 0; i < n; i++) {
if (!inlier[i]) continue;
double d[3];
for (int r = 0; r < 3; r++) d[r] = c2w[i*12 + r*4 + 3] - center[r];
for (int r = 0; r < 3; r++)
@@ -521,7 +528,9 @@ std::vector<char> outlier_keep_mask(const std::vector<double>& pos,
double dx = pos[i*3] - y[0], dy = pos[i*3+1] - y[1], dz = pos[i*3+2] - y[2];
dist[i] = std::sqrt(dx*dx + dy*dy + dz*dz);
}
double mad = median_of(dist);
std::vector<double> sorted = dist; // median_of reorders its argument
double mad = median_of(sorted);
if (!(mad > 0.0)) return keep; // most cameras at one spot: no spread to judge by
for (int64_t i = 0; i < n; i++)
keep[i] = dist[i] <= (double)threshold * mad;
return keep;
+2 -1
View File
@@ -451,7 +451,8 @@ inline void packColmap(const Camera& c, double* d) {
d[0] = c.fx; d[1] = c.fy; d[2] = c.cx; d[3] = c.cy;
d[4] = c.k1; d[5] = c.k2; d[6] = c.p1; d[7] = c.p2;
d[8] = c.k3; d[9] = c.k4; d[10] = c.sx1; d[11] = c.sy1; break;
case CamModel::Equirect: d[0] = 2.0 * M_PI * c.fx; d[1] = M_PI * c.fy; break;
// Not 2*pi*fx: that round trip misses the integer width by an ulp.
case CamModel::Equirect: d[0] = c.width; d[1] = c.height; break;
}
}
// COLMAP layout -> fields.
File diff suppressed because one or more lines are too long
Binary file not shown.