add dataset centering option

This commit is contained in:
Harry Chen
2026-09-10 11:51:51 -04:00
parent d76b67a447
commit 09fdb7f9a5
34 changed files with 1406 additions and 251 deletions
+22
View File
@@ -315,6 +315,28 @@ viewer's default camera, not the training coordinates. The unported methods
(`pca`, `vertical`, `gsplat`, `focus`) still have a working Python reference:
[notes/pose-normalization.md](notes/pose-normalization.md).
`--scene-center` is the one option that DOES move the training coordinates:
it translates every camera and seed point so the chosen statistic sits at the
origin, in double precision, before anything is narrowed to float. That is
what a geo-referenced reconstruction needs -- a model millions of units from
its origin loses metres to single precision otherwise. The modes are
`point-median` (geometric median of the seed cloud), `camera-median`,
`camera-focus` (the point the optical axes converge on), `point-mean` and
`camera-mean`; `none` (the default) keeps the frame the files came in. The
shift, and the identity rotation and scale that go with it, are written to
`scene_transform.json` in the run folder in every common spelling (4x4
matrices, quaternions, Euler angles), so a downstream tool can put the
splats back into the dataset's frame without converting anything by hand.
The centre is taken over every frame before the train/eval split and over
the whole seed cloud, so both splits, `spirula mesh` and the viewers -- all
of which re-read `config.json` -- land in the same frame.
The same six modes are also a *view* setting, offered by all three viewers as
a "center" menu (camera position median by default) that moves the orbit
pivot and nothing else. `src/data/SceneCenter.h` is the one implementation;
the table of centres travels to a viewer on `ViewerRenderConfig::centers`,
and the training viewer's browser client fetches it from `/scene`.
## Train/eval split
`eval_mode` selects the strategy:
+7 -2
View File
@@ -542,8 +542,13 @@ switch off: the parsers rotate every dataset so the mean camera up axis
becomes +Z, which on the X5 walk is 11.4 deg from the IMU's answer, and that
rotation is what turntable and first-person navigation orbit about. The switch
is offered wherever the rotation is not the identity, and undoes only that
rotation — an `applied_transform` a file came with is left alone. The second
bit puts the grid's cell size on screen as a length, so a metric model can be
rotation — an `applied_transform` a file came with is left alone. Next to it
sits the **center** menu, which picks what the view orbits about (camera
position median by default; `src/data/SceneCenter.h`) and moves only the
camera. All three viewers offer it: the native viewport reads the table off
`ViewerRenderConfig`, and the browser clients ask for it — `/scene` for the
training viewer, `ssv_ds_fit_sphere` for the standalone one. The second bit
puts the grid's cell size on screen as a length, so a metric model can be
measured by looking at it.
The GUI also stopped hiding the inputs. Each video row says whether the file
+35 -14
View File
@@ -1,6 +1,7 @@
// TrainerCore.cpp -- see TrainerCore.h.
#include "app/TrainerCore.h"
#include "data/SceneTransform.h"
#include "app/EvalMetrics.h"
#include "checkpoint/Adapt.h"
#include "checkpoint/Resume.h"
@@ -223,7 +224,7 @@ SeedSplats seed_splats(const ColmapPoints3D& pts, const TrainConfig& cfg,
float rescale = cfg.relative_scale.value_or(1.0f);
for (int64_t i = 0; i < num; i++)
for (int d = 0; d < 3; d++)
s.means[i*3 + d] = pts.xyz[pick[i]*3 + d] * rescale;
s.means[i*3 + d] = (float)(pts.xyz[pick[i]*3 + d] * rescale);
// log(scale_init * sqrt(mean d^2 of 4-NN)) over xyz, over the DISTINCT
// points: a repeat is its own zero-distance neighbor, and would seed
@@ -509,18 +510,26 @@ EngineStepConfig build_step_config(const TrainConfig& c, const RunState& st, int
// config.json dump
// ===========================================================================
// Flat dump: one key per flag, spelled exactly as the flag is. It used to be
// nested under the field table's group column, which quietly made a
// presentational choice part of an on-disk format -- moving a flag to another
// heading moved its key, and a reader that could not find it silently fell
// back to the default. Flat, a heading can be renamed or reshuffled without
// touching anything that reads this file (`spirula mesh`, --resume).
//
// Macro flags (--quality and friends) are written alongside the values they
// resolved to, so a reader takes the values and never re-resolves them.
//
// The encoding itself is config/TrainConfigJson.h, shared with --resume's
// reader and the GUI's saved presets so the three cannot disagree.
// p_train = relative_scale * (p_dataset - center): the parser's shift and the
// trainer's own rescale, which is every way the splats' frame differs from
// the dataset's.
void save_scene_transform_json(const ParsedDataset& ds, const TrainConfig& c,
const fs::path& out_dir) {
SceneTransform T;
T.scale = (double)c.relative_scale.value_or(1.0f);
for (int i = 0; i < 3; i++) T.t[i] = -T.scale * ds.center[i];
const std::string text =
scene_transform_json(T, ds.center_mode, ds.center.data());
const fs::path path = out_dir / "scene_transform.json";
FILE* f = std::fopen(path.string().c_str(), "w");
if (!f) throw std::runtime_error("cannot write " + path.string());
std::fputs(text.c_str(), f);
std::fclose(f);
}
// Flat, one key per flag: a key that followed the field table's heading
// moved whenever a flag was reshuffled, and readers fell back to the default.
// Macro flags are written beside what they resolved to; config/TrainConfigJson.h.
void save_config_json(const TrainConfig& c, const fs::path& out_dir,
const std::string& preset) {
FILE* f = std::fopen((out_dir / "config.json").string().c_str(), "w");
@@ -602,6 +611,7 @@ void TrainerSession::load_dataset() {
pcfg.eval_interval = cfg.eval_interval;
pcfg.train_split_fraction = cfg.train_split_fraction;
pcfg.outlier_threshold = cfg.outlier_threshold;
pcfg.center_mode = cfg.scene_center;
pcfg.probe_image_size = probe_image_size;
pcfg.train_resolution_divisor = cfg.train_resolution_divisor;
pcfg.downscale_rounding_mode = cfg.downscale_rounding_mode;
@@ -609,6 +619,12 @@ void TrainerSession::load_dataset() {
pcfg.metashape_ply = cfg.metashape_ply;
pcfg.metashape_psx = cfg.metashape_psx;
ds = parse_dataset(cfg.data, pcfg, cfg.data_format);
if (ds.center_mode != "none") {
char xyz[96];
std::snprintf(xyz, sizeof xyz, "%.12g, %.12g, %.12g",
ds.center[0], ds.center[1], ds.center[2]);
log(lfmt(lmsg::scene_centered, {ds.center_mode, xyz}));
}
// An EXR carries its own colour space, and nothing downstream can recover
// it: DataManager hands the engine the file's raw scene-linear floats. The
@@ -768,8 +784,10 @@ void TrainerSession::setup_engine() {
(fs::path(cfg.data).stem().string() + "_" + stamp);
}
fs::create_directories(out_dir);
if (write_config_json)
if (write_config_json) {
save_config_json(cfg, out_dir, preset);
save_scene_transform_json(ds, cfg, out_dir);
}
log(lfmt(lmsg::output_directory, {fs::absolute(out_dir).string()}));
// ---- Engine setup -------------------------------------------------
@@ -1221,6 +1239,8 @@ ViewerRenderConfig TrainerSession::make_viewer_config() const {
cfg.normal_distortion_reg != 0.0f;
const auto color = resolve_color(cfg);
vc.color_space_on = color.splat_on();
vc.centers = dsparse::scene_centers(ds);
vc.center_cameras = ds.num_cameras > 0;
vc.train_frame_scale = ds.train_frame_scale;
vc.train_to_normalized = ds.train_to_normalized;
vc.base_camera_size = viewer_base_camera_size;
@@ -1279,6 +1299,7 @@ void TrainerSession::eval() {
pcfg.eval_interval = cfg.eval_interval;
pcfg.train_split_fraction = cfg.train_split_fraction;
pcfg.outlier_threshold = cfg.outlier_threshold;
pcfg.center_mode = cfg.scene_center;
pcfg.probe_image_size = probe_image_size;
pcfg.train_resolution_divisor = cfg.train_resolution_divisor;
pcfg.downscale_rounding_mode = cfg.downscale_rounding_mode;
+4
View File
@@ -125,6 +125,10 @@ EngineStepConfig build_step_config(const TrainConfig& c, const RunState& st,
// Flat config.json dump, one key per flag (config/TrainConfigJson.h).
void save_config_json(const TrainConfig& c, const std::filesystem::path& out_dir,
const std::string& preset);
// scene_transform.json: the similarity from the dataset's own frame to the
// one the splats are trained in (data/SceneTransform.h).
void save_scene_transform_json(const ParsedDataset& ds, const TrainConfig& c,
const std::filesystem::path& out_dir);
// "" when this config is runnable, else the sentence naming the flag that is
// not implemented -- exactly what TrainerSession::check_config() throws. A
+1
View File
@@ -76,6 +76,7 @@ MeshCameras load_cameras(const JsonValue& run_cfg, const std::string& data_dir,
pcfg.metashape_ply = dp_str("metashape_ply", "");
pcfg.metashape_psx = dp_str("metashape_psx", "");
pcfg.downscale_rounding_mode = dp_str("downscale_rounding_mode", "floor");
pcfg.center_mode = dp_str("scene_center", "none");
pcfg.probe_image_size = probe_image_size;
{
const JsonValue* v = run_cfg.find("train_resolution_divisor");
+1 -1
View File
@@ -718,7 +718,7 @@ bool PreviewRenderer::build(const ParsedDataset& ds, const PostSplitCameras& pos
for (int i = 0; i < 16; i++) T[i] = ds.train_to_normalized[i];
dsparse::invert_affine4x4(T, A);
}
auto map_pt = [&](const float p[3], float out[3]) {
auto map_pt = [&](const auto* p, float out[3]) {
for (int r = 0; r < 3; r++)
out[r] = (float)(A[r*4+0]*p[0] + A[r*4+1]*p[1] + A[r*4+2]*p[2] + A[r*4+3]);
};
+24
View File
@@ -2,6 +2,7 @@
#include "app/gui/SfmInProcess.h"
#ifdef SS_TOOL_SFM
#include "sfm/Pipeline.h"
#include "sfm/core/Cancel.h"
#include "sfm/core/Events.h"
@@ -9,9 +10,30 @@
#include "sfm/core/Progress.h"
#include "i18n/catalog/Sfm.h"
#else
#include "i18n/catalog/Log.h"
#endif
namespace gui {
#ifndef SS_TOOL_SFM
// SfmRunner::availability() refuses a run long before this, so reaching it is
// a bug rather than a configuration; it still says which.
InProcessResult run_sfm_in_process(
const std::vector<std::string>& args,
const std::function<void(const std::string&)>& log,
const std::function<void(const RunStatus&)>& on_status,
const std::atomic<bool>& cancel) {
(void)args; (void)log; (void)on_status; (void)cancel;
InProcessResult out;
out.exit_code = -1;
out.error = spirula::i18n::msg::log::err_no_sfm_module.get();
return out;
}
#else
namespace {
// The line the CLI would have printed, so the GUI's log reads the same
@@ -127,4 +149,6 @@ InProcessResult run_sfm_in_process(
return out;
}
#endif // SS_TOOL_SFM
} // namespace gui
+2
View File
@@ -502,11 +502,13 @@ std::vector<std::string> SfmRunner::recon_args(const SfmJob& job,
// Not flags any more: the groups go in the manifest. Its text joins the
// stamp so that changing a lens still counts as a different model
// (recon_stamp_change), which is the whole point of this list.
#ifdef SS_TOOL_SFM
const std::string manifest = sfm::manifest_write(build_manifest(job, prep));
if (!manifest.empty()) {
argv.push_back("--manifest");
argv.push_back(manifest);
}
#endif
if (job.max_features > 0) {
// Each frontend has its own count flag: the budgets are not comparable,
// a learned detector emitting a few thousand better-localized points
+14 -5
View File
@@ -37,7 +37,8 @@ TorchTensorView tv(std::vector<float>& v, std::vector<int64_t> shape) {
// viewer a kilometre away looking at a dot -- and the median is what is left.
// `radius` comes back as the web viewer's model radius, twice the MEDIAN
// distance from the centre.
void scene_extent(const std::vector<float>& xyz, int64_t n,
template <typename T>
void scene_extent(const std::vector<T>& xyz, int64_t n,
float center[3], float& radius) {
center[0] = center[1] = center[2] = 0.0f;
radius = 1.0f;
@@ -49,15 +50,15 @@ void scene_extent(const std::vector<float>& xyz, int64_t n,
tmp.reserve((size_t)(n / step + 1));
for (int d = 0; d < 3; d++) {
tmp.clear();
for (int64_t i = 0; i < n; i += step) tmp.push_back(xyz[(size_t)i * 3 + d]);
for (int64_t i = 0; i < n; i += step) tmp.push_back((float)xyz[(size_t)i * 3 + d]);
std::nth_element(tmp.begin(), tmp.begin() + tmp.size() / 2, tmp.end());
center[d] = tmp[tmp.size() / 2];
}
tmp.clear();
for (int64_t i = 0; i < n; i += step) {
const float dx = xyz[(size_t)i * 3 + 0] - center[0];
const float dy = xyz[(size_t)i * 3 + 1] - center[1];
const float dz = xyz[(size_t)i * 3 + 2] - center[2];
const float dx = (float)xyz[(size_t)i * 3 + 0] - center[0];
const float dy = (float)xyz[(size_t)i * 3 + 1] - center[1];
const float dz = (float)xyz[(size_t)i * 3 + 2] - center[2];
tmp.push_back(dx * dx + dy * dy + dz * dz);
}
std::nth_element(tmp.begin(), tmp.begin() + tmp.size() / 2, tmp.end());
@@ -354,6 +355,14 @@ void SplatViewer::run(std::string path) {
0, 0, 0, 1};
vc.base_camera_size = 0.0f; // no cameras to draw
vc.scene_slot = _pending_slot;
{
const double inv = unit > 1e-20f ? 1.0 / unit : 1.0;
const double A[12] = {inv, 0, 0, -inv * center[0],
0, inv, 0, -inv * center[1],
0, 0, inv, -inv * center[2]};
vc.centers = dsparse::scene_centers(nullptr, 0, c.means.data(),
c.num, 3, A);
}
{
std::lock_guard<std::mutex> lk(*_engine_mutex);
+82 -7
View File
@@ -8,6 +8,7 @@
#include "app/gui/Ui.h"
#include "i18n/catalog/Gui.h"
#include "i18n/catalog/TrainFields.h"
#include "app/gui/GlLoader.h" // GL types + 1.1 entry points
#include "imgui.h"
@@ -61,20 +62,44 @@ void fov_to_intrinsics(float fov_deg, int w, int h, const char* model,
// ---------------------------------------------------------------------------
void ViewportPanel::reset_pose(float radius) {
// Match the web viewer's cam.reset() exactly: target = client-frame
// origin (the normalized frame is centered on the CAMERA POSES via
// center_method="poses", i.e. the captured object for object-centric
// datasets -- NOT the point-cloud centroid, which distant background
// points drag far away), pos = [0,0,1], then orbit(0, -250).
_cam.pos[0] = 0; _cam.pos[1] = 0; _cam.pos[2] = 1;
// The web viewer's cam.reset() about the chosen centre: target = centre,
// pos = centre + [0,0,1], then orbit(0, -250).
float c[3];
center_shared(c);
_cam.pos[0] = c[0]; _cam.pos[1] = c[1]; _cam.pos[2] = c[2] + 1.0f;
_cam.rot[0] = _cam.rot[1] = _cam.rot[2] = 0; _cam.rot[3] = 1;
_cam.target[0] = _cam.target[1] = _cam.target[2] = 0;
_cam.target[0] = c[0]; _cam.target[1] = c[1]; _cam.target[2] = c[2];
_cam.orbit(0, -250);
_home = _cam;
_home_dist = radius;
_dirty = true;
}
void ViewportPanel::set_centers(const dsparse::CenterTable* centers, bool has_cameras) {
_centers_known = centers != nullptr;
_center_has_cameras = has_cameras;
if (centers) _centers = *centers;
}
int ViewportPanel::effective_center_mode() const {
using M = dsparse::CenterMode;
if (_center_has_cameras) return _center_mode;
switch ((M)_center_mode) {
case M::CameraMedian: return (int)M::PointMedian;
case M::CameraMean:
case M::CameraFocus: return (int)M::PointMean;
default: return _center_mode;
}
}
void ViewportPanel::center_shared(float out[3]) const {
if (!_centers_known) {
out[0] = out[1] = out[2] = 0.0f;
return;
}
shared_point(_centers[_center_mode].data(), out);
}
void ViewportPanel::compute_framing(const spirula::TrainerSession& session) {
reset_pose(1.0f);
@@ -105,6 +130,8 @@ bool ViewportPanel::maybe_frame(const spirula::TrainerSession& session) {
std::to_string(session.ds.points.num());
if (key == _framed_key) return false; // same dataset: keep the pose
_framed_key = key;
const dsparse::CenterTable centers = dsparse::scene_centers(session.ds);
set_centers(&centers, session.ds.num_cameras > 0);
compute_framing(session);
_show_cams = true; // default on for a fresh dataset preview
return true;
@@ -350,6 +377,10 @@ void ViewportPanel::attach_preview_data(const ParsedDataset& ds,
_last_error = "preview renderer unavailable (OpenGL 3.2 required)";
return;
}
{
const dsparse::CenterTable centers = dsparse::scene_centers(ds);
set_centers(&centers, ds.num_cameras > 0);
}
if (first) {
_framed_key = key;
reset_pose(radius);
@@ -391,6 +422,14 @@ void ViewportPanel::attach_preview_mesh(const meshing::MeshData& mesh,
_last_error = "preview renderer unavailable (OpenGL 3.2 required)";
return;
}
{
double A[12];
for (int i = 0; i < 12; i++) A[i] = to_normalized ? to_normalized[i] : (i % 5 == 0);
const dsparse::CenterTable centers = dsparse::scene_centers(
nullptr, 0, mesh.V.empty() ? nullptr : mesh.V[0].data(),
(int64_t)mesh.V.size(), 3, A);
set_centers(&centers, false);
}
if (key != _framed_key) {
_framed_key = key;
reset_pose(radius);
@@ -420,6 +459,7 @@ void ViewportPanel::attach_scene(const ViewerRenderConfig& cfg,
_buffer_idx = std::min<int>(_buffer_idx, (int)_buffer_keys.size() - 1);
_has_cameras = false;
_show_cams = false;
set_centers(&cfg.centers, cfg.center_cameras);
if (key != _framed_key) {
_framed_key = key;
reset_pose(radius);
@@ -670,6 +710,41 @@ void ViewportPanel::draw_controls(bool engine) {
}
ui::help_on_hover(msg::viewport_level_cameras_help);
}
if (_centers_known) {
namespace fld = spirula::i18n::msg::field;
auto center_label = [](int mode) -> const char* {
const spirula::i18n::Msg* m =
fld::choice_label("scene_center", dsparse::kCenterModeNames[mode]);
return m ? m->get() : dsparse::kCenterModeNames[mode];
};
auto is_camera_mode = [](int mode) {
using M = dsparse::CenterMode;
return mode == (int)M::CameraMedian || mode == (int)M::CameraMean ||
mode == (int)M::CameraFocus;
};
place(px(170.0f) + st.ItemInnerSpacing.x + text_w(msg::viewport_center.get()));
ImGui::SetNextItemWidth(px(170.0f));
if (ui::BeginComboRaw(ui::detail::label(msg::viewport_center),
center_label(effective_center_mode()))) {
for (int i = 0; i < dsparse::kNumCenterModes; i++) {
if (!_center_has_cameras && is_camera_mode(i)) continue;
if (ui::SelectableRaw(center_label(i), i == _center_mode) &&
i != _center_mode) {
_center_mode = i;
// The model stays where it is; the pivot moves to the new
// centre, and so does the pose Reset view returns to.
float c[3];
center_shared(c);
recenter_at(c);
const NavCamera live = _cam;
reset_pose(_home_dist);
_cam = live;
}
}
ImGui::EndCombo();
}
ui::help_on_hover(msg::viewport_center_help);
}
if (engine) {
place(px(66.0f) + st.ItemInnerSpacing.x +
text_w(msg::viewport_scale.get()));
+20 -7
View File
@@ -16,6 +16,7 @@
#include "app/webviewer/RenderWorker.h"
#include "core/ColorSpace.h"
#include "data/DatasetParser.h"
#include "app/gui/NavCamera.h"
#include "app/gui/PreviewRenderer.h"
@@ -64,12 +65,9 @@ public:
const std::string& key, float radius = 1.0f);
// Engine renderer (needs engine_ready).
void attach(spirula::TrainerSession& session);
// Engine renderer over something that is not a training session -- a splat
// file opened in the viewer (SplatViewer). `key` identifies the scene, so
// reopening the same file keeps the pose; `radius` is the scene radius in
// the client's normalized frame, which is 1 for anything normalized.
// There are no training cameras behind this, so the frustum controls are
// not offered.
// Engine renderer over a file (SplatViewer): `key` keeps the pose across
// a reopen and `radius` is the scene radius in the client frame; the
// centering menu comes from cfg.centers.
void attach_scene(const ViewerRenderConfig& cfg, const ViewerHooks& hooks,
const std::string& key, float radius = 1.0f);
// The render-option controls a VIEWER gets, on top of attach_scene. The
@@ -132,8 +130,17 @@ private:
enum class Mode { None, Preview, Engine };
void compute_framing(const spirula::TrainerSession& session);
// The client-frame default pose (web viewer cam.reset() + orbit(0,-250)).
// The client-frame default pose (web viewer cam.reset() + orbit(0,-250)),
// about the chosen centre.
void reset_pose(float radius);
// The centering choices, in the model frame. `has_cameras` says whether
// the camera statistics are real or fell back to the point ones.
void set_centers(const dsparse::CenterTable* centers, bool has_cameras);
// The chosen centre in the shared frame; the origin when none is known.
void center_shared(float out[3]) const;
// The mode the menu shows: the point statistic a camera one fell back to
// when there are no cameras.
int effective_center_mode() const;
// Frame the scene only when a different dataset arrives; a preview ->
// engine transition on the same dataset keeps the navigated pose and
// intrinsics (no jump when training starts).
@@ -215,6 +222,12 @@ private:
bool _align_identity = true;
bool _level_cameras = true;
bool _gauge_metric = false;
// What the view orbits about and Reset view frames (dsparse::CenterMode),
// a point per mode in the model frame. Moves only the camera.
int _center_mode = (int)dsparse::CenterMode::CameraMedian;
dsparse::CenterTable _centers{};
bool _centers_known = false;
bool _center_has_cameras = false;
// Model units per unit of the navigated frame: what turns the grid's cell
// size into a length (ParsedDataset::train_frame_scale).
float _scene_scale = 1.0f;
+5
View File
@@ -50,6 +50,11 @@ struct ViewerRenderConfig {
// Which engine scene slot this worker renders (Engine.h "Viewer scenes").
// < 0 = whatever is bound, which is what a training session wants.
int scene_slot = -1;
// What the client's view orbits about and Reset frames, one point per
// dsparse::CenterMode in the client's normalized frame. `center_cameras`
// is false over a file, which has none, so those modes are not offered.
dsparse::CenterTable centers{};
bool center_cameras = false;
};
struct ViewerHooks {
+23
View File
@@ -38,6 +38,8 @@ struct ViewerServer::Impl {
HttpServer http;
RenderWorker worker;
std::vector<std::string> buffer_keys;
dsparse::CenterTable centers{};
bool center_cameras = false;
bool started = false;
// ---- endpoint handlers -------------------------------------------------
@@ -169,6 +171,24 @@ struct ViewerServer::Impl {
body += "]";
return HttpResponse::json(body);
}
// What the client cannot work out for itself: it holds no splats and no
// camera table, only the normalized frame both are expressed in.
HttpResponse handle_scene() {
std::string body = "{\"center_modes\": [";
for (int i = 0; i < dsparse::kNumCenterModes; i++) {
char one[128];
std::snprintf(one, sizeof one,
"%s{\"name\": \"%s\", \"center\": [%.9g, %.9g, %.9g]}",
i ? ", " : "", dsparse::kCenterModeNames[i],
centers[i][0], centers[i][1], centers[i][2]);
body += one;
}
body += "], \"has_cameras\": ";
body += center_cameras ? "true" : "false";
body += "}";
return HttpResponse::json(body);
}
};
@@ -191,6 +211,8 @@ void ViewerServer::start(const std::string& host, int port,
cfg.base_camera_size = viewer_upload_cameras(post);
viewer_upload_grid(post);
im.centers = cfg.centers;
im.center_cameras = cfg.center_cameras;
im.worker.start(std::move(cfg), std::move(hooks));
im.buffer_keys = im.worker.buffer_keys();
im.started = true;
@@ -200,6 +222,7 @@ void ViewerServer::start(const std::string& host, int port,
im.http.route("/render", [&im](const HttpRequest& r) { return im.handle_render(r); });
im.http.route("/pick", [&im](const HttpRequest& r) { return im.handle_pick(r); });
im.http.route("/buffers", [&im](const HttpRequest&) { return im.handle_buffers(); });
im.http.route("/scene", [&im](const HttpRequest&) { return im.handle_scene(); });
im.http.route("/progress", [&im](const HttpRequest&) {
return HttpResponse::json(im.hooks.progress_json ? im.hooks.progress_json()
: "{}");
+72 -2
View File
@@ -443,6 +443,10 @@
</div>
<input type="range" id="move-speed" min="-2" max="2" step="0.1" value="0" autocomplete="off"/>
</div>
<div class="ctrl-row">
<div class="ctrl-label"><span>Center</span></div>
<select id="center-mode" autocomplete="off" title="What the view orbits about and Reset Camera frames. Moves the view, never the model."></select>
</div>
<button class="btn" id="btn-reset-pose">Reset Camera</button>
</div>
@@ -637,9 +641,10 @@ const cam = {
forward() { return quat.rotVec(this.rot, [0,0,-1]); },
reset() {
this.pos = [0, 0, 1];
const c = Scene.center();
this.pos = [c[0], c[1], c[2] + 1];
this.rot = quat.identity();
this.target = [0, 0, 0];
this.target = c.slice();
Nav.orbit(0, -250);
},
};
@@ -910,6 +915,63 @@ function showHint(text) {
// HTTP client
// ═══════════════════════════════════════════════════════
// The centering choices, which only the server can compute: the client holds
// no splats and no camera table, just the normalized frame both live in.
const Scene = {
modes: [], // [{name, center}] in dsparse::CenterMode order
hasCameras: false,
mode: 2, // camera position median
center() {
const m = this.modes[this.mode];
return m ? m.center.slice() : [0, 0, 0];
},
// A file has no cameras, so a camera statistic falls back to the point one.
isCameraMode(i) { return i === 2 || i === 3 || i === 5; },
fallback(i) { return i === 2 ? 1 : 4; },
};
const CENTER_LABELS = {
"none": "None (model origin)",
"point-median": "Point cloud median",
"camera-median": "Camera position median",
"camera-focus": "Camera focus",
"point-mean": "Point cloud mean",
"camera-mean": "Camera position mean",
};
async function fetchScene() {
try {
const response = await fetch(`${CFG.serverUrl}/scene`);
if (!response.ok) throw new Error("no /scene");
const j = await response.json();
Scene.modes = j.center_modes || [];
Scene.hasCameras = !!j.has_cameras;
} catch (e) {
// A server without /scene (an older build) keeps the model origin.
Scene.modes = [];
Scene.hasCameras = false;
}
const sel = $("center-mode");
sel.innerHTML = "";
if (!Scene.modes.length) {
sel.parentElement.parentElement.style.display = "none";
return;
}
if (!Scene.hasCameras && Scene.isCameraMode(Scene.mode))
Scene.mode = Scene.fallback(Scene.mode);
Scene.modes.forEach((m, i) => {
if (!Scene.hasCameras && Scene.isCameraMode(i)) return;
const o = document.createElement("option");
o.value = String(i);
o.textContent = CENTER_LABELS[m.name] || m.name;
sel.appendChild(o);
});
sel.value = String(Scene.mode);
// The pose was reset before the centres arrived; put it where it belongs.
cam.reset();
Viewer.requestRender();
}
async function fetchBuffers() {
try {
const response = await fetch(`${CFG.serverUrl}/buffers`);
@@ -1377,6 +1439,13 @@ $("btn-reset-pose").addEventListener("click", () => {
Viewer.requestRender();
});
// The model stays where it is: the orbit pivot moves to the new centre, and so
// does the pose Reset Camera returns to.
$("center-mode").addEventListener("change", e => {
Scene.mode = +e.target.value;
recenterAt(Scene.center());
});
$("btn-pause").addEventListener("click", async () => {
try {
const response = await fetch('/pause-toggle');
@@ -1460,6 +1529,7 @@ setInterval(updateProgress, 1000);
cam.reset();
showHint(hintFor("turntable"));
fetchBuffers();
fetchScene();
Viewer.loopRender();
// Initial res label
const res = getResolution($("res-slider").value);
+2 -1
View File
@@ -138,6 +138,7 @@ inline int train_tier_rank(const char* tier) {
X(std::string, center_method, "poses", "scene", "expert", "poses|focus|none|gsplat") \
X(bool, auto_scale_poses, true, "scene", "expert", "") \
X(float, outlier_threshold, kTrainInf, "scene", "basic", "") \
X(std::string, scene_center, "none", "scene", "basic", "none|point-median|camera-median|camera-focus|point-mean|camera-mean") \
X(std::optional<float>, relative_scale, std::nullopt, "scene", "expert", "") \
X(std::string, train_frame, "points", "scene", "expert", "normalized|camera|points") \
\
@@ -347,7 +348,7 @@ struct TrainConfig {
X(depth_dir) X(normal_dir) X(metashape_xml) X(metashape_ply) \
X(metashape_psx) X(train_resolution_divisor) X(downscale_rounding_mode) \
X(orientation_method) X(center_method) X(auto_scale_poses) \
X(outlier_threshold) X(train_frame) X(eval_mode) X(train_split_fraction) \
X(outlier_threshold) X(scene_center) X(train_frame) X(eval_mode) X(train_split_fraction) \
X(eval_interval) X(depth_unit_scale_factor) X(validation_fraction) \
X(warp_to_pinhole) X(warp_spherical_to_pinhole) X(warp_face_fit) \
X(warp_back_face) \
+24 -10
View File
@@ -11,6 +11,8 @@
// that to the POST-split arrays engine_setup_data_manager consumes: identity
// at K=1, or the pinhole faces camhost::plan_split_faces cuts a wide camera into.
#include "data/SceneCenter.h"
#include <array>
#include <cstdint>
#include <limits>
@@ -59,8 +61,10 @@ struct ColmapImage {
std::string name; // path relative to the image dir
};
// xyz stays double: a geo-referenced model puts the cloud millions of units
// from the origin, where float has a resolution of a metre.
struct ColmapPoints3D {
std::vector<float> xyz; // [N, 3] flat
std::vector<double> xyz; // [N, 3] flat
std::vector<uint8_t> rgb; // [N, 3] flat
int64_t num() const { return (int64_t)xyz.size() / 3; }
};
@@ -138,6 +142,11 @@ struct DatasetParserConfig {
// the geometric median of all camera positions. inf = off (default).
float outlier_threshold = std::numeric_limits<float>::infinity();
// Which point of the raw frame becomes the training frame's origin: a
// dsparse::CenterMode name. Computed over ALL post-outlier frames and every
// seed point, in double, before anything is narrowed to float.
std::string center_mode = "none";
// Pixel size of an image file (data/ImageProbe.h). Set: every camera trains
// at its own image's resolution. Null: a caller with no decoders -- the
// WebAssembly viewer, given a dataset's cameras but never its pixels.
@@ -205,6 +214,12 @@ struct ParsedDataset {
// Seed point cloud in the training frame.
ColmapPoints3D points;
// p_train = p_raw - center, where p_raw is the frame the files came in
// (COLMAP's own, or nerfstudio's with applied_transform undone). Zero
// unless DatasetParserConfig::center_mode asked for one.
std::array<double, 3> center{0.0, 0.0, 0.0};
std::string center_mode = "none";
// 1 / scale_factor of the would-be normalized frame. Computed over ALL
// frames, before the eval_mode subset is dropped.
float train_frame_scale = 1.0f;
@@ -317,20 +332,19 @@ PostSplitCameras bake_post_split(const ParsedDataset& ds,
// ===========================================================================
namespace dsparse {
// Normalized-frame scale factor over c2w [N,3,4] (orient="up",
// center="poses", auto-scale). Only the scalar matters for
// train_frame="points". Returns 1/max_abs.
double compute_normalized_scale_factor(const std::vector<float>& c2w, int64_t n);
// Writes T_n_from_camera = scale * [R_align | -R_align @ center] (row-major
// 4x4) and returns scale_factor; the viewer remap is inv(that @ applied).
// `R_out` takes R_align alone, the one part of it a viewer can offer to skip.
double compute_normalized_transform(const std::vector<float>& c2w, int64_t n,
// 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.
double compute_normalized_transform(const double* c2w, int64_t n,
double T_out[16], double R_out[9] = nullptr);
// inv([A|b; 0 1]) for a general invertible 3x3 A (row-major 4x4 in/out).
void invert_affine4x4(const double in[16], double out[16]);
// Every centering mode over a parsed dataset, in its NORMALIZED frame --
// which is what both viewers navigate.
CenterTable scene_centers(const ParsedDataset& ds);
// eval_mode subset over N sorted frames, honouring cfg.split; identity for
// "all". `names` are image filenames (used by eval_mode="filename").
std::vector<int64_t> train_subset(int64_t n, const std::vector<std::string>& names,
+63
View File
@@ -0,0 +1,63 @@
#pragma once
// SceneCenter -- where a scene's origin goes: the `--scene-center` choices
// and the viewports' centering menu, over cameras and points in one frame.
// Implemented in parsers/DatasetCommon.cpp; kept apart from DatasetParser.h
// so the WebGL viewer's fast-math source can call it without the parsers.
#include <array>
#include <cstdint>
#include <string>
namespace dsparse {
// Where a scene's origin goes. The names are the `--scene-center` choices and
// the viewport's centering menu, in that order.
enum class CenterMode {
None = 0, PointMedian, CameraMedian, CameraFocus, PointMean, CameraMean
};
constexpr const char* kCenterModeNames[] = {
"none", "point-median", "camera-median", "camera-focus", "point-mean",
"camera-mean"};
constexpr int kNumCenterModes = 6;
// Throws on a name not in kCenterModeNames; "" is `none`.
CenterMode center_mode_from_name(const std::string& name);
// Weiszfeld from the per-axis median, ~8 passes over `pos`: n points of
// `stride` elements, the first three being xyz. `max_samples` > 0 strides
// over at most that many of them; the zero vector when n == 0.
std::array<double, 3> geometric_median(const double* pos, int64_t n,
int stride = 3, int64_t max_samples = 0);
std::array<double, 3> geometric_median(const float* pos, int64_t n,
int stride = 3, int64_t max_samples = 0);
// The point the cameras look at: the least-squares intersection of the
// optical axes of every camera that has it in front, iterated from `init`
// (camera_utils.focus_of_attention). c2w [N,3,4], OpenGL convention.
std::array<double, 3> focus_of_attention(const double* c2w, int64_t n,
const double init[3]);
// The center `mode` names over c2w [N,3,4] and m points of `stride` elements.
// A point mode with no points falls back to the camera mode of the same
// statistic and vice versa; over nothing at all it is the origin.
std::array<double, 3> scene_center(CenterMode mode, const double* c2w, int64_t n,
const double* points, int64_t m,
int stride = 3, int64_t max_samples = 0);
std::array<double, 3> scene_center(CenterMode mode, const double* c2w, int64_t n,
const float* points, int64_t m,
int stride = 3, int64_t max_samples = 0);
// One centre per mode, in mode order.
using CenterTable = std::array<std::array<float, 3>, kNumCenterModes>;
// Every mode at once over c2w [N,3,4] (may be null) and m points of `stride`
// values, each mapped through `to_model` (row-major 3x4 similarity, null =
// identity). A viewport centre needs no more than 2^18 samples.
CenterTable scene_centers(const double* c2w, int64_t n,
const float* points, int64_t m,
int stride = 3, const double* to_model = nullptr);
CenterTable scene_centers(const double* c2w, int64_t n,
const double* points, int64_t m,
int stride = 3, const double* to_model = nullptr);
} // namespace dsparse
+294
View File
@@ -0,0 +1,294 @@
// SceneTransform.cpp -- see SceneTransform.h.
#include "data/SceneTransform.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <string>
namespace spirula {
namespace {
constexpr double kPi = 3.14159265358979323846;
constexpr double kDeg = 180.0 / kPi;
// Right-handed rotation about axis `n` (0 = x, 1 = y, 2 = z), row-major.
void axis_rotation(int n, double angle, double R[9]) {
const double c = std::cos(angle), s = std::sin(angle);
const int i = (n + 1) % 3, j = (n + 2) % 3;
std::fill(R, R + 9, 0.0);
R[n*3 + n] = 1.0;
R[i*3 + i] = c; R[i*3 + j] = -s;
R[j*3 + i] = s; R[j*3 + j] = c;
}
void mat3_mul(const double A[9], const double B[9], double out[9]) {
double t[9];
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++)
t[r*3 + c] = A[r*3]*B[c] + A[r*3+1]*B[3+c] + A[r*3+2]*B[6+c];
std::copy(t, t + 9, out);
}
int axis_index(char c) {
switch (c) {
case 'x': case 'X': return 0;
case 'y': case 'Y': return 1;
case 'z': case 'Z': return 2;
}
return -1;
}
// ---- JSON text ------------------------------------------------------------
std::string num(double v) {
if (v == 0.0) v = 0.0; // no "-0"
char buf[40];
std::snprintf(buf, sizeof buf, "%.17g", v);
return buf;
}
std::string arr(const double* v, int n) {
std::string s = "[";
for (int i = 0; i < n; i++) s += (i ? ", " : "") + num(v[i]);
return s + "]";
}
// Rows of a row-major matrix as nested arrays.
std::string rows(const double* m, int nrow, int ncol) {
std::string s = "[";
for (int r = 0; r < nrow; r++)
s += (r ? ", " : "") + arr(m + r * ncol, ncol);
return s + "]";
}
std::string transposed(const double* m, int nrow, int ncol) {
std::string s = "[";
for (int c = 0; c < ncol; c++)
for (int r = 0; r < nrow; r++)
s += ((r || c) ? ", " : "") + num(m[r*ncol + c]);
return s + "]";
}
std::string quoted(const std::string& s) {
std::string out = "\"";
for (char c : s) {
if (c == '"' || c == '\\') out += '\\';
out += c;
}
return out + "\"";
}
std::string euler_block(const double R[9], const char* pad) {
static const char* kOrders[] = {"xyz", "xzy", "yxz", "yzx", "zxy", "zyx"};
std::string s;
auto entry = [&](const char* name, const double a[3], bool last) {
double deg[3] = {a[0]*kDeg, a[1]*kDeg, a[2]*kDeg};
s += std::string(pad) + " \"" + name + "\": {\"radians\": " + arr(a, 3) +
", \"degrees\": " + arr(deg, 3) + "}" + (last ? "\n" : ",\n");
};
s += std::string(pad) + "\"intrinsic\": {\n";
for (int i = 0; i < 6; i++) {
double a[3];
rotation_to_euler_intrinsic(R, kOrders[i], a);
entry(kOrders[i], a, i == 5);
}
s += std::string(pad) + "},\n";
s += std::string(pad) + "\"extrinsic\": {\n";
for (int i = 0; i < 6; i++) {
// Fixed-axis abc is body-axis cba with the angles read backwards.
char rev[3] = {kOrders[i][2], kOrders[i][1], kOrders[i][0]};
double a[3], b[3];
rotation_to_euler_intrinsic(R, rev, a);
b[0] = a[2]; b[1] = a[1]; b[2] = a[0];
entry(kOrders[i], b, i == 5);
}
s += std::string(pad) + "}\n";
return s;
}
std::string similarity_block(const SceneTransform& T, const char* pad) {
const std::string p(pad);
double q[4];
rotation_to_quaternion(T.R, q);
const double qxyzw[4] = {q[1], q[2], q[3], q[0]};
// Axis-angle from the quaternion; the axis of a zero rotation is +Z by
// convention (any unit vector would do).
double angle = 2.0 * std::atan2(std::sqrt(q[1]*q[1] + q[2]*q[2] + q[3]*q[3]), q[0]);
double axis[3] = {0, 0, 1};
const double sn = std::sqrt(q[1]*q[1] + q[2]*q[2] + q[3]*q[3]);
if (sn > 1e-15) for (int i = 0; i < 3; i++) axis[i] = q[1 + i] / sn;
const double rotvec[3] = {axis[0]*angle, axis[1]*angle, axis[2]*angle};
// p_out = s R (p + t_pre) <=> t = s R t_pre.
double t_pre[3];
for (int r = 0; r < 3; r++)
t_pre[r] = (T.R[r]*T.t[0] + T.R[3+r]*T.t[1] + T.R[6+r]*T.t[2]) / T.scale;
double M[16] = {0};
for (int r = 0; r < 3; r++) {
for (int c = 0; c < 3; c++) M[r*4 + c] = T.scale * T.R[r*3 + c];
M[r*4 + 3] = T.t[r];
}
M[15] = 1.0;
std::string s;
s += p + "\"scale\": " + num(T.scale) + ",\n";
s += p + "\"translation\": " + arr(T.t, 3) + ",\n";
s += p + "\"translation_before_rotation\": " + arr(t_pre, 3) + ",\n";
s += p + "\"rotation\": {\n";
s += p + " \"matrix_3x3\": " + rows(T.R, 3, 3) + ",\n";
s += p + " \"matrix_3x3_flat_row_major\": " + arr(T.R, 9) + ",\n";
s += p + " \"matrix_3x3_flat_column_major\": " + transposed(T.R, 3, 3) + ",\n";
s += p + " \"quaternion_wxyz\": " + arr(q, 4) + ",\n";
s += p + " \"quaternion_xyzw\": " + arr(qxyzw, 4) + ",\n";
s += p + " \"axis_angle\": {\"axis\": " + arr(axis, 3) + ", \"angle_rad\": " +
num(angle) + ", \"angle_deg\": " + num(angle * kDeg) + "},\n";
s += p + " \"rotation_vector\": " + arr(rotvec, 3) + ",\n";
s += p + " \"euler\": {\n";
s += euler_block(T.R, (p + " ").c_str());
s += p + " }\n";
s += p + "},\n";
s += p + "\"matrix_4x4\": " + rows(M, 4, 4) + ",\n";
s += p + "\"matrix_4x4_flat_row_major\": " + arr(M, 16) + ",\n";
s += p + "\"matrix_4x4_flat_column_major\": " + transposed(M, 4, 4) + ",\n";
s += p + "\"matrix_3x4_flat_row_major\": " + arr(M, 12) + "\n";
return s;
}
} // namespace
// ---------------------------------------------------------------------------
SceneTransform SceneTransform::inverse() const {
SceneTransform inv;
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++) inv.R[r*3 + c] = R[c*3 + r];
inv.scale = 1.0 / scale;
for (int r = 0; r < 3; r++)
inv.t[r] = -(inv.R[r*3]*t[0] + inv.R[r*3+1]*t[1] + inv.R[r*3+2]*t[2]) * inv.scale;
return inv;
}
// Shepperd's method: divide by the largest of the four candidates, which is
// what keeps the result exact for every rotation angle including 180 degrees.
void rotation_to_quaternion(const double R[9], double q[4]) {
const double tr = R[0] + R[4] + R[8];
double w, x, y, z;
if (tr > 0.0) {
const double s = std::sqrt(tr + 1.0) * 2.0;
w = 0.25 * s;
x = (R[7] - R[5]) / s;
y = (R[2] - R[6]) / s;
z = (R[3] - R[1]) / s;
} else if (R[0] > R[4] && R[0] > R[8]) {
const double s = std::sqrt(1.0 + R[0] - R[4] - R[8]) * 2.0;
w = (R[7] - R[5]) / s;
x = 0.25 * s;
y = (R[1] + R[3]) / s;
z = (R[2] + R[6]) / s;
} else if (R[4] > R[8]) {
const double s = std::sqrt(1.0 + R[4] - R[0] - R[8]) * 2.0;
w = (R[2] - R[6]) / s;
x = (R[1] + R[3]) / s;
y = 0.25 * s;
z = (R[5] + R[7]) / s;
} else {
const double s = std::sqrt(1.0 + R[8] - R[0] - R[4]) * 2.0;
w = (R[3] - R[1]) / s;
x = (R[2] + R[6]) / s;
y = (R[5] + R[7]) / s;
z = 0.25 * s;
}
const double n = std::sqrt(w*w + x*x + y*y + z*z);
q[0] = w / n; q[1] = x / n; q[2] = y / n; q[3] = z / n;
if (q[0] < 0.0) for (int i = 0; i < 4; i++) q[i] = -q[i];
}
void euler_intrinsic_to_rotation(const char order[3], const double a[3],
double R[9]) {
double acc[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1}, step[9];
for (int k = 0; k < 3; k++) {
axis_rotation(axis_index(order[k]), a[k], step);
mat3_mul(acc, step, acc);
}
std::copy(acc, acc + 9, R);
}
// For R = R_i(a) R_j(b) R_k(c): R[i][k] = e sin b with e = +1 when ijk is a
// cyclic permutation, and the other two angles fall out of the row and column
// through that entry. At |sin b| = 1 only a +- c is determined; c is set to 0.
void rotation_to_euler_intrinsic(const double R[9], const char order[3],
double out[3]) {
const int i = axis_index(order[0]), j = axis_index(order[1]),
k = axis_index(order[2]);
const double e = ((j == (i + 1) % 3) ? 1.0 : -1.0);
auto at = [&](int r, int c) { return R[r*3 + c]; };
const double sb = std::clamp(e * at(i, k), -1.0, 1.0);
out[1] = std::asin(sb);
if (std::fabs(sb) < 1.0 - 1e-12) {
out[0] = std::atan2(-e * at(j, k), at(k, k));
out[2] = std::atan2(-e * at(i, j), at(i, i));
} else {
out[0] = std::atan2(sb * at(j, i), at(j, j));
out[2] = 0.0;
}
}
std::string scene_transform_json(const SceneTransform& train_from_world,
const std::string& center_mode,
const double center_world[3]) {
const SceneTransform world_from_train = train_from_world.inverse();
std::string s = "{\n";
s += " \"format\": \"spirula-scene-transform\",\n";
s += " \"version\": 1,\n";
s += " \"readme\": [\n";
const char* lines[] = {
"p_train = train_from_world.scale * train_from_world.rotation.matrix_3x3 "
"@ p_world + train_from_world.translation, for a column vector p.",
"train_from_world is the ACTIVE transform taking a point of the "
"dataset's own frame (world) into the frame the splats were trained in "
"(train); read passively, it gives a fixed point's train coordinates "
"from its world coordinates. world_from_train is its inverse.",
"matrix_4x4 acts on column vectors [x, y, z, 1]. "
"matrix_4x4_flat_row_major lists it row by row; "
"matrix_4x4_flat_column_major column by column, which is also the "
"row-major layout of its transpose, the matrix that acts on row "
"vectors [x, y, z, 1] @ M^T.",
"rotation.matrix_3x3 acts on column vectors. quaternion_wxyz is "
"(w, x, y, z) and quaternion_xyzw is (x, y, z, w), the same unit "
"quaternion. axis_angle is a unit axis with a right-handed angle; "
"rotation_vector is axis * angle_rad.",
"euler.intrinsic.abc = [a1, a2, a3] means R = R_a(a1) @ R_b(a2) @ "
"R_c(a3): rotate about the body axis a, then the new b, then the new "
"c. euler.extrinsic.abc = [a1, a2, a3] means R = R_c(a3) @ R_b(a2) @ "
"R_a(a1): rotate about the fixed axis a, then b, then c. So "
"extrinsic.xyz = [roll about X, pitch about Y, yaw about Z] applied in "
"that order, and intrinsic.zyx = [yaw, pitch, roll]. Every entry is "
"given in radians and in degrees.",
"translation is applied AFTER the rotation and scale (p_out = s R p + "
"t). translation_before_rotation is the equivalent shift applied first "
"(p_out = s R (p + t_pre)); for train_from_world it is minus "
"centering.center_world.",
};
const int n_lines = (int)(sizeof(lines) / sizeof(lines[0]));
for (int i = 0; i < n_lines; i++)
s += " " + quoted(lines[i]) + (i + 1 < n_lines ? ",\n" : "\n");
s += " ],\n";
s += " \"centering\": {\"mode\": " + quoted(center_mode) +
", \"center_world\": " + arr(center_world, 3) + "},\n";
s += " \"train_from_world\": {\n";
s += similarity_block(train_from_world, " ");
s += " },\n";
s += " \"world_from_train\": {\n";
s += similarity_block(world_from_train, " ");
s += " }\n";
s += "}\n";
return s;
}
} // namespace spirula
+41
View File
@@ -0,0 +1,41 @@
#pragma once
// SceneTransform -- the similarity between a dataset's own frame and the
// frame the splats are trained in, and the scene_transform.json a run leaves
// beside config.json: one transform spelled every common way (matrices,
// quaternions, Euler angles, both directions) so nothing has to be converted
// by hand downstream.
#include <string>
namespace spirula {
// p_train = scale * R @ p_world + t. R row-major.
struct SceneTransform {
double R[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1};
double t[3] = {0, 0, 0};
double scale = 1.0;
// p_world = R^T (p_train - t) / scale.
SceneTransform inverse() const;
};
// Unit quaternion (w, x, y, z) of a row-major rotation matrix.
void rotation_to_quaternion(const double R[9], double q[4]);
// Tait-Bryan angles (radians) of R = R_a(x) R_b(y) R_c(z) for an intrinsic
// axis order such as "xyz": the first rotation is about the first letter.
// Extrinsic "abc" is intrinsic "cba" with the angles reversed.
void rotation_to_euler_intrinsic(const double R[9], const char order[3],
double out[3]);
void euler_intrinsic_to_rotation(const char order[3], const double a[3],
double R[9]);
// The JSON text. `center_mode` and `center_world` say how the translation was
// chosen (DatasetParser.h center_mode; the point of the dataset frame that
// became the origin).
std::string scene_transform_json(const SceneTransform& train_from_world,
const std::string& center_mode,
const double center_world[3]);
} // namespace spirula
+30 -15
View File
@@ -168,7 +168,7 @@ ColmapPoints3D read_points3D_binary(const std::string& recon_dir) {
pts.rgb.reserve(n * 3);
for (uint64_t i = 0; i < n; i++) {
r.skip(sizeof(uint64_t)); // point3D_id
for (int k = 0; k < 3; k++) pts.xyz.push_back((float)r.read<double>());
for (int k = 0; k < 3; k++) pts.xyz.push_back(r.read<double>());
for (int k = 0; k < 3; k++) pts.rgb.push_back(r.read<uint8_t>());
r.skip(sizeof(double)); // reprojection error
uint64_t track_len = r.read<uint64_t>();
@@ -287,7 +287,7 @@ ColmapPoints3D read_points3D_text(const std::string& recon_dir) {
while (r.next_line(&s, &e)) {
char* p;
fast_strtol(s, &p); // point3D_id
for (int k = 0; k < 3; k++) pts.xyz.push_back((float)fast_strtod(p, &p));
for (int k = 0; k < 3; k++) pts.xyz.push_back(fast_strtod(p, &p));
for (int k = 0; k < 3; k++) pts.rgb.push_back((uint8_t)fast_strtol(p, &p));
// reprojection error + track: rest of line, skipped
}
@@ -523,16 +523,16 @@ void qvec2rotmat(const std::array<double, 4>& q, double R[3][3]) {
// COLMAP w2c -> nerfstudio/OpenGL c2w:
// c2w[:3,:3] = R^T with columns 1, 2 negated (OpenCV -> OpenGL axis flip)
// c2w[:3,3] = -R^T @ t
void colmap_to_c2w(const ColmapImage& im, float* out12) {
void colmap_to_c2w(const ColmapImage& im, double* out12) {
double R[3][3];
qvec2rotmat(im.qvec, R);
static const double flip[3] = {1.0, -1.0, -1.0};
for (int r = 0; r < 3; r++) {
for (int c = 0; c < 3; c++)
out12[r*4 + c] = (float)(R[c][r] * flip[c]);
out12[r*4 + c] = R[c][r] * flip[c];
double t = 0.0;
for (int c = 0; c < 3; c++) t -= R[c][r] * im.tvec[c];
out12[r*4 + 3] = (float)t;
out12[r*4 + 3] = t;
}
}
@@ -772,7 +772,7 @@ ParsedDataset parse_colmap_dataset(const std::string& dataset_dir,
// ---- All-frame c2w (needed for outlier filter + train_frame_scale) ----
int64_t n_all = (int64_t)frames.size();
std::vector<float> c2w_all(n_all * 12);
std::vector<double> c2w_all(n_all * 12);
std::vector<double> positions(n_all * 3);
for (int64_t i = 0; i < n_all; i++) {
colmap_to_c2w(*frames[i], &c2w_all[i*12]);
@@ -784,7 +784,7 @@ ParsedDataset parse_colmap_dataset(const std::string& dataset_dir,
std::vector<char> keep = dsparse::outlier_keep_mask(
positions, n_all, cfg.outlier_threshold);
std::vector<const ColmapImage*> kept;
std::vector<float> kept_c2w;
std::vector<double> kept_c2w;
for (int64_t i = 0; i < n_all; i++) {
if (!keep[i]) continue;
kept.push_back(frames[i]);
@@ -795,13 +795,31 @@ ParsedDataset parse_colmap_dataset(const std::string& dataset_dir,
n_all = (int64_t)frames.size();
}
// In lenient (viewer) mode, tolerate a missing points3D file so a
// cameras-only reconstruction still yields camera poses / frustums.
ColmapPoints3D points;
if (fmt.points3D == ColmapFmt::Bin)
points = read_points3D_binary(recon_dir);
else if (fmt.points3D == ColmapFmt::Text)
points = read_points3D_text(recon_dir);
// ---- Centering, over ALL post-outlier frames and every point, while
// both are still double --------------------------------------------------
const dsparse::CenterMode center_mode = dsparse::center_mode_from_name(cfg.center_mode);
const std::array<double, 3> center = dsparse::scene_center(
center_mode, c2w_all.data(), n_all, points.xyz.data(), points.num());
for (int64_t i = 0; i < n_all; i++)
for (int r = 0; r < 3; r++) c2w_all[i*12 + r*4 + 3] -= center[r];
for (int64_t i = 0; i < points.num(); i++)
for (int r = 0; r < 3; r++) points.xyz[i*3 + r] -= center[r];
// ---- train_frame_scale + viewer remap transform over ALL post-outlier
// frames (train + eval, matching the Python dataparser, which splits
// after normalization). No applied_transform on the COLMAP path, so
// train_to_normalized = inv(T_n_from_camera). -----------------------------
double T_n[16], T_inv[16], R_align[9];
double scale_factor =
dsparse::compute_normalized_transform(c2w_all, n_all, T_n, R_align);
dsparse::compute_normalized_transform(c2w_all.data(), n_all, T_n, R_align);
dsparse::invert_affine4x4(T_n, T_inv);
float train_frame_scale = (float)(scale_factor != 0.0 ? 1.0 / scale_factor : 1.0);
@@ -818,6 +836,9 @@ ParsedDataset parse_colmap_dataset(const std::string& dataset_dir,
ds.train_frame_scale = train_frame_scale;
for (int k = 0; k < 16; k++) ds.train_to_normalized[k] = (float)T_inv[k];
for (int k = 0; k < 9; k++) ds.normalized_rotation[k] = (float)R_align[k];
ds.center = center;
ds.center_mode = dsparse::kCenterModeNames[(int)center_mode];
ds.points = std::move(points);
read_gauge(recon_dir, ds);
ds.camera_models.reserve(N);
ds.camera_distortions.reserve(N);
@@ -882,7 +903,7 @@ ParsedDataset parse_colmap_dataset(const std::string& dataset_dir,
ds.camera_distortions.push_back((int32_t)bi.distortion);
if (any_redistort) ds.redistort[j] = bi.source;
std::copy(&c2w_all[i*12], &c2w_all[i*12] + 12, &ds.c2w[j*12]);
for (int k = 0; k < 12; k++) ds.c2w[j*12 + k] = (float)c2w_all[i*12 + k];
// Auxiliary supervision buffers, discovered by filename convention.
mask_files[j] = dsparse::find_aux_file(
@@ -899,12 +920,6 @@ ParsedDataset parse_colmap_dataset(const std::string& dataset_dir,
if (any_depth) ds.depth_filenames = std::move(depth_files);
if (any_normal) ds.normal_filenames = std::move(normal_files);
// In lenient (viewer) mode, tolerate a missing points3D file so a
// cameras-only reconstruction still yields camera poses / frustums.
if (fmt.points3D == ColmapFmt::Bin)
ds.points = read_points3D_binary(recon_dir);
else if (fmt.points3D == ColmapFmt::Text)
ds.points = read_points3D_text(recon_dir);
// validation_fraction holds out part of the TRAIN set; the eval split is
// already a held-out set, so it is all "train" from the DataManager's
// point of view.
+227 -44
View File
@@ -22,19 +22,11 @@ constexpr double kPi = 3.14159265358979323846; // MSVC has no M_PI by default
namespace dsparse {
// ---------------------------------------------------------------------------
// Normalized-frame scale:
// up = normalize(mean of c2w Y columns)
// R_align = rotation taking `up` to +Z (Rodrigues)
// center = mean camera position
// scale_factor = 1 / max |R_align @ (pos - center)|
// TODO: "pca" / "vertical" / "gsplat" orientation_method and "focus" /
// "gsplat" center_method. This implements the up/poses pair only;
// check_config() warns when the config asks for anything else. The reference
// implementation for the rest is kept in Python, on no code path:
// reference/python/camera_utils.py plus the call-site algebra in
// docs/notes/pose-normalization.md.
// up = normalize(mean c2w Y column), R_align: up -> +Z, center = mean camera
// position, scale = 1 / max |R_align (pos - center)|. The up/poses pair only;
// the rest is reference/python/camera_utils.py and check_config() warns.
// ---------------------------------------------------------------------------
double compute_normalized_transform(const std::vector<float>& c2w, int64_t n,
double compute_normalized_transform(const double* c2w, int64_t n,
double T_out[16], double R_out[9]) {
std::fill(T_out, T_out + 16, 0.0);
T_out[0] = T_out[5] = T_out[10] = T_out[15] = 1.0;
@@ -95,11 +87,6 @@ double compute_normalized_transform(const std::vector<float>& c2w, int64_t n,
return scale_factor;
}
double compute_normalized_scale_factor(const std::vector<float>& c2w, int64_t n) {
double T[16];
return compute_normalized_transform(c2w, n, T);
}
void invert_affine4x4(const double in[16], double out[16]) {
double a = in[0], b = in[1], c = in[2],
d = in[4], e = in[5], g = in[6],
@@ -233,10 +220,10 @@ std::string find_aux_file(const std::string& aux_dir_s, const std::string& rel_n
// ---------------------------------------------------------------------------
// Outlier rejection via geometric median
// Scene centre + outlier rejection
// ---------------------------------------------------------------------------
namespace {
double median_of(std::vector<double> v) {
double median_of(std::vector<double>& v) {
if (v.empty()) return 0.0;
size_t mid = v.size() / 2;
std::nth_element(v.begin(), v.begin() + mid, v.end());
@@ -245,45 +232,78 @@ double median_of(std::vector<double> v) {
double lo = *std::max_element(v.begin(), v.begin() + mid);
return 0.5 * (lo + hi);
}
} // namespace
std::vector<char> outlier_keep_mask(const std::vector<double>& pos,
int64_t n, float threshold) {
std::vector<char> keep(n, 1);
if (!(threshold < std::numeric_limits<float>::infinity()) || n == 0)
return keep;
// Geometric median via Weiszfeld with the zero-distance correction
// (eps=0, maxiter=10).
double y[3];
for (int d = 0; d < 3; d++) {
std::vector<double> col(n);
for (int64_t i = 0; i < n; i++) col[i] = pos[i*3 + d];
y[d] = median_of(col);
template <typename T>
std::array<double, 3> mean_of(const T* pos, int64_t n, int stride, int64_t step) {
double sx = 0.0, sy = 0.0, sz = 0.0;
int64_t cnt = 0;
#pragma omp parallel for reduction(+:sx,sy,sz,cnt)
for (int64_t i = 0; i < n; i += step) {
sx += pos[i*stride]; sy += pos[i*stride+1]; sz += pos[i*stride+2];
cnt++;
}
for (int it = 0; it < 10; it++) {
double T[3] = {0, 0, 0}, Dinvs = 0.0;
int64_t num_zeros = 0;
for (int64_t i = 0; i < n; i++) {
double dx = pos[i*3] - y[0], dy = pos[i*3+1] - y[1], dz = pos[i*3+2] - y[2];
if (cnt <= 0) return {0.0, 0.0, 0.0};
return {sx / (double)cnt, sy / (double)cnt, sz / (double)cnt};
}
// Translation columns of c2w [N,3,4], packed [N,3].
std::vector<double> camera_positions(const double* c2w, int64_t n) {
std::vector<double> pos((size_t)std::max<int64_t>(n, 0) * 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];
return pos;
}
int64_t sample_step(int64_t n, int64_t max_samples) {
if (max_samples <= 0 || n <= max_samples) return 1;
return (n + max_samples - 1) / max_samples;
}
template <typename T>
std::array<double, 3> geometric_median_t(const T* pos, int64_t n, int stride,
int64_t max_samples) {
std::array<double, 3> y = {0.0, 0.0, 0.0};
if (n <= 0) return y;
const int64_t step = sample_step(n, max_samples);
{
std::vector<double> col;
col.reserve((size_t)(n / step + 1));
for (int d = 0; d < 3; d++) {
col.clear();
for (int64_t i = 0; i < n; i += step) col.push_back(pos[i*stride + d]);
y[d] = median_of(col);
}
}
// Weiszfeld with the zero-distance correction (Vardi & Zhang 2000), which
// is what lets it start ON a sample. Eight passes converge to well under
// 1e-6 of the spread on every capture tried; 20M points cost ~0.3 s.
for (int it = 0; it < 8; it++) {
double Tx = 0.0, Ty = 0.0, Tz = 0.0, Dinvs = 0.0;
int64_t num_zeros = 0, cnt = 0;
#pragma omp parallel for reduction(+:Tx,Ty,Tz,Dinvs,num_zeros,cnt)
for (int64_t i = 0; i < n; i += step) {
const T* p = pos + i*stride;
double dx = p[0] - y[0], dy = p[1] - y[1], dz = p[2] - y[2];
double D = std::sqrt(dx*dx + dy*dy + dz*dz);
cnt++;
if (D == 0.0) { num_zeros++; continue; }
double w = 1.0 / D;
Dinvs += w;
for (int d = 0; d < 3; d++) T[d] += w * pos[i*3 + d];
Tx += w * p[0]; Ty += w * p[1]; Tz += w * p[2];
}
if (num_zeros == cnt) break;
double W[3] = {Tx, Ty, Tz};
if (Dinvs > 0) for (int d = 0; d < 3; d++) W[d] /= Dinvs;
double y1[3];
if (num_zeros == n) break;
if (Dinvs > 0) for (int d = 0; d < 3; d++) T[d] /= Dinvs;
if (num_zeros == 0) {
for (int d = 0; d < 3; d++) y1[d] = T[d];
for (int d = 0; d < 3; d++) y1[d] = W[d];
} else {
double R[3], r = 0.0;
for (int d = 0; d < 3; d++) { R[d] = (T[d] - y[d]) * Dinvs; r += R[d]*R[d]; }
for (int d = 0; d < 3; d++) { R[d] = (W[d] - y[d]) * Dinvs; r += R[d]*R[d]; }
r = std::sqrt(r);
double rinv = (r == 0.0) ? 0.0 : (double)num_zeros / r;
double a = std::max(0.0, 1.0 - rinv), b = std::min(1.0, rinv);
for (int d = 0; d < 3; d++) y1[d] = a * T[d] + b * y[d];
for (int d = 0; d < 3; d++) y1[d] = a * W[d] + b * y[d];
}
double diff = 0.0;
for (int d = 0; d < 3; d++) {
@@ -292,7 +312,170 @@ std::vector<char> outlier_keep_mask(const std::vector<double>& pos,
}
if (diff == 0.0) break;
}
return y;
}
} // namespace
std::array<double, 3> geometric_median(const double* pos, int64_t n, int stride,
int64_t max_samples) {
return geometric_median_t(pos, n, stride, max_samples);
}
std::array<double, 3> geometric_median(const float* pos, int64_t n, int stride,
int64_t max_samples) {
return geometric_median_t(pos, n, stride, max_samples);
}
std::array<double, 3> focus_of_attention(const double* c2w, int64_t n,
const double init[3]) {
std::array<double, 3> focus = {init[0], init[1], init[2]};
if (n <= 0) return focus;
// Optical axis is -Z of the OpenGL camera frame.
std::vector<double> dir(n * 3), org = camera_positions(c2w, n);
for (int64_t i = 0; i < n; i++)
for (int r = 0; r < 3; r++) dir[i*3 + r] = -c2w[i*12 + r*4 + 2];
std::vector<char> active(n);
auto in_front = [&](int64_t i) {
double s = 0.0;
for (int r = 0; r < 3; r++) s += dir[i*3 + r] * (focus[r] - org[i*3 + r]);
return s > 0.0;
};
int64_t num_active = 0;
for (int64_t i = 0; i < n; i++) num_active += (active[i] = in_front(i));
// Cameras only ever leave the active set, so this terminates.
while (num_active > 1) {
// Least squares over (I - d d^T) p = (I - d d^T) o for the active
// rays; (I - d d^T) is symmetric idempotent, so M^T M = M.
double A[3][3] = {}, b[3] = {};
for (int64_t i = 0; i < n; i++) {
if (!active[i]) continue;
const double* d = &dir[i*3];
const double* o = &org[i*3];
for (int r = 0; r < 3; r++) {
for (int c = 0; c < 3; c++) {
double m = (r == c ? 1.0 : 0.0) - d[r] * d[c];
A[r][c] += m;
b[r] += m * o[c];
}
}
}
double Ain[16] = {A[0][0], A[0][1], A[0][2], 0,
A[1][0], A[1][1], A[1][2], 0,
A[2][0], A[2][1], A[2][2], 0,
0, 0, 0, 1};
double Ainv[16];
try { invert_affine4x4(Ain, Ainv); } catch (const std::exception&) { break; }
for (int r = 0; r < 3; r++)
focus[r] = Ainv[r*4]*b[0] + Ainv[r*4+1]*b[1] + Ainv[r*4+2]*b[2];
int64_t still = 0;
for (int64_t i = 0; i < n; i++) {
if (!active[i]) continue;
active[i] = in_front(i);
still += active[i];
}
if (still == num_active) break;
num_active = still;
}
return focus;
}
CenterMode center_mode_from_name(const std::string& name) {
if (name.empty()) return CenterMode::None; // the CLI's spelling of `none`
for (int i = 0; i < kNumCenterModes; i++)
if (name == kCenterModeNames[i]) return (CenterMode)i;
throw std::runtime_error("unknown scene center mode '" + name + "'");
}
namespace {
template <typename T>
std::array<double, 3> scene_center_t(CenterMode mode, const double* c2w, int64_t n,
const T* points, int64_t m, int stride,
int64_t max_samples) {
if (m <= 0) {
if (mode == CenterMode::PointMedian) mode = CenterMode::CameraMedian;
if (mode == CenterMode::PointMean) mode = CenterMode::CameraMean;
}
if (n <= 0) {
if (mode == CenterMode::CameraMedian) mode = CenterMode::PointMedian;
if (mode == CenterMode::CameraMean || mode == CenterMode::CameraFocus)
mode = CenterMode::PointMean;
if (m <= 0) mode = CenterMode::None;
}
switch (mode) {
case CenterMode::None: return {0.0, 0.0, 0.0};
case CenterMode::PointMedian: return geometric_median_t(points, m, stride, max_samples);
case CenterMode::PointMean: return mean_of(points, m, stride, sample_step(m, max_samples));
case CenterMode::CameraMedian: {
std::vector<double> pos = camera_positions(c2w, n);
return geometric_median_t(pos.data(), n, 3, 0);
}
case CenterMode::CameraMean: {
std::vector<double> pos = camera_positions(c2w, n);
return mean_of(pos.data(), n, 3, 1);
}
case CenterMode::CameraFocus: {
std::vector<double> pos = camera_positions(c2w, n);
std::array<double, 3> mean = mean_of(pos.data(), n, 3, 1);
return focus_of_attention(c2w, n, mean.data());
}
}
return {0.0, 0.0, 0.0};
}
} // namespace
std::array<double, 3> scene_center(CenterMode mode, const double* c2w, int64_t n,
const double* points, int64_t m,
int stride, int64_t max_samples) {
return scene_center_t(mode, c2w, n, points, m, stride, max_samples);
}
std::array<double, 3> scene_center(CenterMode mode, const double* c2w, int64_t n,
const float* points, int64_t m,
int stride, int64_t max_samples) {
return scene_center_t(mode, c2w, n, points, m, stride, max_samples);
}
namespace {
template <typename T>
CenterTable scene_centers_t(const double* c2w, int64_t n, const T* points,
int64_t m, int stride, const double* A) {
CenterTable out{};
for (int i = 0; i < kNumCenterModes; i++) {
const std::array<double, 3> c = scene_center_t(
(CenterMode)i, c2w, n, points, m, stride, 1 << 18);
for (int r = 0; r < 3; r++)
out[i][r] = A ? (float)(A[r*4]*c[0] + A[r*4+1]*c[1] + A[r*4+2]*c[2] + A[r*4+3])
: (float)c[r];
}
return out;
}
} // namespace
CenterTable scene_centers(const double* c2w, int64_t n, const float* points,
int64_t m, int stride, const double* to_model) {
return scene_centers_t(c2w, n, points, m, stride, to_model);
}
CenterTable scene_centers(const double* c2w, int64_t n, const double* points,
int64_t m, int stride, const double* to_model) {
return scene_centers_t(c2w, n, points, m, stride, to_model);
}
CenterTable scene_centers(const ParsedDataset& ds) {
const int64_t n = std::min<int64_t>(ds.num_cameras, (int64_t)ds.c2w.size() / 12);
std::vector<double> c2w(ds.c2w.begin(), ds.c2w.begin() + n * 12);
double A[16] = {1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1};
if (ds.train_frame_scale != 1.0f) {
double T[16];
for (int i = 0; i < 16; i++) T[i] = ds.train_to_normalized[i];
invert_affine4x4(T, A);
}
return scene_centers_t(c2w.data(), n, ds.points.xyz.data(), ds.points.num(), 3, A);
}
std::vector<char> outlier_keep_mask(const std::vector<double>& pos,
int64_t n, float threshold) {
std::vector<char> keep(n, 1);
if (!(threshold < std::numeric_limits<float>::infinity()) || n == 0)
return keep;
std::array<double, 3> y = geometric_median(pos.data(), n, 3, 0);
std::vector<double> dist(n);
for (int64_t i = 0; i < n; i++) {
double dx = pos[i*3] - y[0], dy = pos[i*3+1] - y[1], dz = pos[i*3+2] - y[2];
+106 -87
View File
@@ -222,9 +222,9 @@ ColmapPoints3D read_ply_points(const std::string& path) {
throw std::runtime_error("PLY: truncated " + path);
for (int64_t i = 0; i < el.count; i++) {
const uint8_t* row = (const uint8_t*)p + (size_t)i * stride;
pts.xyz[i*3 + 0] = (float)ply_read_scalar(row + offsets[ix], el.props[ix].type);
pts.xyz[i*3 + 1] = (float)ply_read_scalar(row + offsets[iy], el.props[iy].type);
pts.xyz[i*3 + 2] = (float)ply_read_scalar(row + offsets[iz], el.props[iz].type);
pts.xyz[i*3 + 0] = ply_read_scalar(row + offsets[ix], el.props[ix].type);
pts.xyz[i*3 + 1] = ply_read_scalar(row + offsets[iy], el.props[iy].type);
pts.xyz[i*3 + 2] = ply_read_scalar(row + offsets[iz], el.props[iz].type);
pts.rgb[i*3 + 0] = to_u8(ply_read_scalar(row + offsets[ir], el.props[ir].type), el.props[ir].type);
pts.rgb[i*3 + 1] = to_u8(ply_read_scalar(row + offsets[ig], el.props[ig].type), el.props[ig].type);
pts.rgb[i*3 + 2] = to_u8(ply_read_scalar(row + offsets[ib], el.props[ib].type), el.props[ib].type);
@@ -241,9 +241,9 @@ ColmapPoints3D read_ply_points(const std::string& path) {
throw std::runtime_error("PLY: short ascii row in " + path);
p = q;
}
pts.xyz[i*3 + 0] = (float)vals[ix];
pts.xyz[i*3 + 1] = (float)vals[iy];
pts.xyz[i*3 + 2] = (float)vals[iz];
pts.xyz[i*3 + 0] = vals[ix];
pts.xyz[i*3 + 1] = vals[iy];
pts.xyz[i*3 + 2] = vals[iz];
pts.rgb[i*3 + 0] = to_u8(vals[ir], el.props[ir].type);
pts.rgb[i*3 + 1] = to_u8(vals[ig], el.props[ig].type);
pts.rgb[i*3 + 2] = to_u8(vals[ib], el.props[ib].type);
@@ -455,7 +455,7 @@ ParsedDataset parse_nerfstudio_meta(const JsonValue& meta,
[](const Frame& a, const Frame& b) { return a.abs < b.abs; });
// ---- All-frame c2w -------------------------------------------------------
auto read_c2w = [](const JsonValue& fr, float* out12) {
auto read_c2w = [](const JsonValue& fr, double* out12) {
const JsonValue* tm = fr.find("transform_matrix");
if (!tm || !tm->is_array() || tm->arr.size() < 3)
throw std::runtime_error("NerfstudioParser: bad transform_matrix");
@@ -463,11 +463,11 @@ ParsedDataset parse_nerfstudio_meta(const JsonValue& meta,
const JsonValue& row = tm->arr[r];
if (!row.is_array() || row.arr.size() < 4)
throw std::runtime_error("NerfstudioParser: bad transform_matrix row");
for (int c = 0; c < 4; c++) out12[r*4 + c] = (float)row.arr[c].as_double();
for (int c = 0; c < 4; c++) out12[r*4 + c] = row.arr[c].as_double();
}
};
int64_t n_all = (int64_t)frames.size();
std::vector<float> c2w_all(n_all * 12);
std::vector<double> c2w_all(n_all * 12);
std::vector<double> positions(n_all * 3);
for (int64_t i = 0; i < n_all; i++) {
read_c2w(*frames[i].j, &c2w_all[i*12]);
@@ -479,7 +479,7 @@ ParsedDataset parse_nerfstudio_meta(const JsonValue& meta,
std::vector<char> keep = dsparse::outlier_keep_mask(
positions, n_all, cfg.outlier_threshold);
std::vector<Frame> kept;
std::vector<float> kept_c2w;
std::vector<double> kept_c2w;
for (int64_t i = 0; i < n_all; i++) {
if (!keep[i]) continue;
kept.push_back(frames[i]);
@@ -490,11 +490,101 @@ ParsedDataset parse_nerfstudio_meta(const JsonValue& meta,
n_all = (int64_t)frames.size();
}
// ---- Seed points ------------------------------------------------------
ColmapPoints3D points;
{
std::string ply_rel;
if (const JsonValue* v = meta.find("ply_file_path")) ply_rel = v->as_string();
else {
for (const char* cand : {"sparse_pc.ply", "pointcloud.ply"})
if (fs::exists(root / cand)) { ply_rel = cand; break; }
}
if (ply_rel.empty()) {
// Lenient (viewer) mode: a transforms.json with no point cloud
// still yields camera poses / frustums. The trainer requires the
// seed cloud.
if (cfg.require_image_files)
throw std::runtime_error(
"NerfstudioParser: no initial point cloud found (ply_file_path / "
"sparse_pc.ply / pointcloud.ply)");
} else if (cfg.require_image_files || fs::exists(root / ply_rel)) {
points = read_ply_points((root / ply_rel).string());
}
}
// ---- applied_transform inverse (train_frame="points" branch): poses and
// points go back to the ORIGINAL (pre-applied_transform) frame, which is
// where the centre is taken. -------------------------------------------
double A[3][3] = {{1,0,0},{0,1,0},{0,0,1}}, b[3] = {0, 0, 0};
bool applied = false;
if (const JsonValue* at = meta.find("applied_transform")) {
for (int r = 0; r < 3; r++) {
const JsonValue& row = at->arr.at(r);
for (int c = 0; c < 3; c++) A[r][c] = row.arr.at(c).as_double();
b[r] = row.arr.at(3).as_double();
}
for (int r = 0; r < 3 && !applied; r++)
for (int c = 0; c < 3; c++)
if (A[r][c] != (r == c ? 1.0 : 0.0) || b[r] != 0.0) { applied = true; break; }
}
std::vector<double> c2w_world = c2w_all;
if (applied) {
double Ai[3][3];
invert3x3d(A, Ai);
double bi[3];
for (int r = 0; r < 3; r++)
bi[r] = -(Ai[r][0]*b[0] + Ai[r][1]*b[1] + Ai[r][2]*b[2]);
// c2w' = inv(T) @ c2w (c2w has implicit bottom row 0 0 0 1)
for (int64_t i = 0; i < n_all; i++) {
const double* m = &c2w_all[i*12];
double* out = &c2w_world[i*12];
for (int r = 0; r < 3; r++)
for (int c = 0; c < 4; c++)
out[r*4 + c] = Ai[r][0]*m[0*4+c] + Ai[r][1]*m[1*4+c]
+ Ai[r][2]*m[2*4+c] + (c == 3 ? bi[r] : 0.0);
}
for (int64_t i = 0; i < points.num(); i++) {
double* p = &points.xyz[i*3];
double x = Ai[0][0]*p[0] + Ai[0][1]*p[1] + Ai[0][2]*p[2] + bi[0];
double y = Ai[1][0]*p[0] + Ai[1][1]*p[1] + Ai[1][2]*p[2] + bi[1];
double z = Ai[2][0]*p[0] + Ai[2][1]*p[1] + Ai[2][2]*p[2] + bi[2];
p[0] = x; p[1] = y; p[2] = z;
}
}
// ---- Centering, over ALL post-outlier frames and every point, still in
// double. The same shift is A @ center + b in the transforms.json frame,
// after which the map between the two frames is A alone. ----------------
const dsparse::CenterMode center_mode = dsparse::center_mode_from_name(cfg.center_mode);
const std::array<double, 3> center = dsparse::scene_center(
center_mode, c2w_world.data(), n_all, points.xyz.data(), points.num());
double center_json[3];
for (int r = 0; r < 3; r++)
center_json[r] = A[r][0]*center[0] + A[r][1]*center[1] + A[r][2]*center[2] + b[r];
for (int64_t i = 0; i < n_all; i++)
for (int r = 0; r < 3; r++) {
c2w_world[i*12 + r*4 + 3] -= center[r];
c2w_all[i*12 + r*4 + 3] -= center_json[r];
}
for (int64_t i = 0; i < points.num(); i++)
for (int r = 0; r < 3; r++) points.xyz[i*3 + r] -= center[r];
// ---- train_frame_scale + normalized-frame similarity (all post-outlier
// frames, pre-split). ------------------------------------------------------
// frames, pre-split), in the transforms.json frame so the levelling
// rotation stays relative to the file's own axes. -----------------------
double T_n_from_camera[16], R_align[9];
double scale_factor = dsparse::compute_normalized_transform(
c2w_all, n_all, T_n_from_camera, R_align);
c2w_all.data(), n_all, T_n_from_camera, R_align);
// train_to_normalized = inv(T_n_from_camera @ [A | 0])
double T_n_from_train[16];
for (int r = 0; r < 4; r++)
for (int c = 0; c < 4; c++) {
double v = 0.0;
for (int m = 0; m < 3; m++)
v += T_n_from_camera[r*4 + m] * (c < 3 ? A[m][c] : 0.0);
if (c == 3) v += T_n_from_camera[r*4 + 3];
T_n_from_train[r*4 + c] = v;
}
// ---- eval_mode train subset ----------------------------------------------
std::vector<std::string> names(n_all);
@@ -505,6 +595,9 @@ ParsedDataset parse_nerfstudio_meta(const JsonValue& meta,
const int64_t N = (int64_t)subset.size();
ds.num_cameras = N;
ds.train_frame_scale = (float)(scale_factor != 0.0 ? 1.0 / scale_factor : 1.0);
ds.center = center;
ds.center_mode = dsparse::kCenterModeNames[(int)center_mode];
ds.points = std::move(points);
ds.c2w.resize(N * 12);
ds.intrins.resize(N * 4);
ds.dist_coeffs.resize(N * kCameraDistortionParams);
@@ -648,7 +741,7 @@ ParsedDataset parse_nerfstudio_meta(const JsonValue& meta,
ds.intrins[j*4 + 1] = (float)fy;
ds.intrins[j*4 + 2] = (float)cx;
ds.intrins[j*4 + 3] = (float)cy;
std::copy(&c2w_all[subset[j]*12], &c2w_all[subset[j]*12] + 12, &ds.c2w[j*12]);
for (int k = 0; k < 12; k++) ds.c2w[j*12 + k] = (float)c2w_world[subset[j]*12 + k];
// Auxiliary buffers: explicit frame paths win; directory-convention
// probing as fallback (_add_auxiliary_buffers). Unlike the Python
@@ -679,80 +772,6 @@ ParsedDataset parse_nerfstudio_meta(const JsonValue& meta,
if (any_normal) ds.normal_filenames = std::move(normal_files);
print_lens_fits(lens_fits);
// ---- Seed points ------------------------------------------------------
std::string ply_rel;
if (const JsonValue* v = meta.find("ply_file_path")) ply_rel = v->as_string();
else {
for (const char* cand : {"sparse_pc.ply", "pointcloud.ply"})
if (fs::exists(root / cand)) { ply_rel = cand; break; }
}
if (ply_rel.empty()) {
// Lenient (viewer) mode: a transforms.json with no point cloud still
// yields camera poses / frustums. The trainer requires the seed cloud.
if (cfg.require_image_files)
throw std::runtime_error(
"NerfstudioParser: no initial point cloud found (ply_file_path / "
"sparse_pc.ply / pointcloud.ply)");
} else if (cfg.require_image_files || fs::exists(root / ply_rel)) {
ds.points = read_ply_points((root / ply_rel).string());
}
// ---- applied_transform inverse (train_frame="points" branch): poses and
// points go back to the ORIGINAL (pre-applied_transform) frame. Also folds
// into the viewer remap:
// train_to_normalized = inv(T_n_from_camera @ applied)
double T_n_from_train[16];
std::copy(T_n_from_camera, T_n_from_camera + 16, T_n_from_train);
if (const JsonValue* at = meta.find("applied_transform")) {
double A[3][3], b[3];
for (int r = 0; r < 3; r++) {
const JsonValue& row = at->arr.at(r);
for (int c = 0; c < 3; c++) A[r][c] = row.arr.at(c).as_double();
b[r] = row.arr.at(3).as_double();
}
bool identity = true;
for (int r = 0; r < 3 && identity; r++)
for (int c = 0; c < 3; c++)
if (A[r][c] != (r == c ? 1.0 : 0.0) || b[r] != 0.0) { identity = false; break; }
if (!identity) {
// T_n_from_train = T_n_from_camera @ applied (both affine, 0001 rows)
double ap[16] = {A[0][0],A[0][1],A[0][2],b[0],
A[1][0],A[1][1],A[1][2],b[1],
A[2][0],A[2][1],A[2][2],b[2],
0,0,0,1};
for (int r = 0; r < 4; r++)
for (int c = 0; c < 4; c++) {
double v = 0.0;
for (int m = 0; m < 4; m++)
v += T_n_from_camera[r*4 + m] * ap[m*4 + c];
T_n_from_train[r*4 + c] = v;
}
double Ai[3][3];
invert3x3d(A, Ai);
double bi[3];
for (int r = 0; r < 3; r++)
bi[r] = -(Ai[r][0]*b[0] + Ai[r][1]*b[1] + Ai[r][2]*b[2]);
// c2w' = inv(T) @ c2w (c2w has implicit bottom row 0 0 0 1)
for (int64_t j = 0; j < N; j++) {
float* m = &ds.c2w[j*12];
double out[3][4];
for (int r = 0; r < 3; r++)
for (int c = 0; c < 4; c++)
out[r][c] = Ai[r][0]*m[0*4+c] + Ai[r][1]*m[1*4+c]
+ Ai[r][2]*m[2*4+c] + (c == 3 ? bi[r] : 0.0);
for (int r = 0; r < 3; r++)
for (int c = 0; c < 4; c++) m[r*4+c] = (float)out[r][c];
}
for (int64_t i = 0; i < ds.points.num(); i++) {
float* p = &ds.points.xyz[i*3];
double x = Ai[0][0]*p[0] + Ai[0][1]*p[1] + Ai[0][2]*p[2] + bi[0];
double y = Ai[1][0]*p[0] + Ai[1][1]*p[1] + Ai[1][2]*p[2] + bi[1];
double z = Ai[2][0]*p[0] + Ai[2][1]*p[1] + Ai[2][2]*p[2] + bi[2];
p[0] = (float)x; p[1] = (float)y; p[2] = (float)z;
}
}
}
double T_remap[16];
dsparse::invert_affine4x4(T_n_from_train, T_remap);
for (int k = 0; k < 16; k++) ds.train_to_normalized[k] = (float)T_remap[k];
+45
View File
@@ -2281,6 +2281,51 @@ SS_MSG(viewport_level_cameras_help,
"döndürür. Bu bir tahmindir ve eğik ya da ters 360 çekimlerde kötüdür; "
"kapalıyken modelin kendi eksenleri kullanılır."));
SS_MSG(viewport_center,
EN("center"), JA("中心"), ZH_HANS("中心"), ZH_HANT("中心"),
KO("중심"), DE("Zentrum"), FR("centre"), ES("centro"),
PT("centro"), IT("centro"), NL("centrum"), RU("центр"),
TR("merkez"));
SS_MSG(viewport_center_help,
EN("The point the view orbits about and Reset view frames: a statistic of "
"the cameras or of the points, or the model's own origin. This moves "
"only the view, never the model."),
JA("視点が回転する中心であり、ビューのリセットで画面に収める点です。カメラ"
"または点の統計値か、モデル自身の原点を選びます。動くのは視点だけで、モデル"
"は動きません。"),
ZH_HANS("视图绕其旋转、重置视图时对准的点:相机或点的某种统计量,或模型自身的"
"原点。只移动视图,不移动模型。"),
ZH_HANT("檢視繞其旋轉、重設檢視時對準的點:相機或點的某種統計量,或模型自身的"
"原點。只移動檢視,不移動模型。"),
KO("시점이 회전하는 중심이자 뷰 재설정이 맞추는 점입니다. 카메라나 점의 "
"통계값, 또는 모델 자체의 원점 중에서 고릅니다. 시점만 움직이며 모델은 "
"움직이지 않습니다."),
DE("Der Punkt, um den die Ansicht kreist und den das Zurücksetzen einrahmt: "
"eine Statistik der Kameras oder der Punkte, oder der Ursprung des "
"Modells. Bewegt nur die Ansicht, nie das Modell."),
FR("Le point autour duquel la vue tourne et que la réinitialisation cadre : "
"une statistique des caméras ou des points, ou l'origine du modèle. Ne "
"déplace que la vue, jamais le modèle."),
ES("El punto alrededor del cual gira la vista y que el reinicio encuadra: "
"una estadística de las cámaras o de los puntos, o el origen del modelo. "
"Solo mueve la vista, nunca el modelo."),
PT("O ponto em torno do qual a vista gira e que o reinício enquadra: uma "
"estatística das câmeras ou dos pontos, ou a origem do modelo. Só move a "
"vista, nunca o modelo."),
IT("Il punto attorno a cui ruota la vista e che il ripristino inquadra: una "
"statistica delle camere o dei punti, oppure l'origine del modello. "
"Sposta solo la vista, mai il modello."),
NL("Het punt waar het beeld omheen draait en dat beeld herstellen in beeld "
"brengt: een statistiek van de camera's of de punten, of de oorsprong van "
"het model. Verplaatst alleen het beeld, nooit het model."),
RU("Точка, вокруг которой вращается вид и на которую его наводит сброс: "
"статистика камер или точек либо начало координат модели. Двигает только "
"вид, но не модель."),
TR("Görünümün etrafında döndüğü ve görünüm sıfırlamanın çerçevelediği "
"nokta: kameraların ya da noktaların bir istatistiği veya modelin kendi "
"başlangıcı. Yalnızca görünümü oynatır, modeli asla."));
SS_MSG(viewport_scale,
EN("resolution"), JA("解像度"), ZH_HANS("分辨率"), ZH_HANT("解析度"),
KO("해상도"), DE("Auflösung"), FR("résolution"), ES("resolución"),
+17
View File
@@ -1076,6 +1076,23 @@ SS_MSG(parsed_dataset,
TR("Kamera: {0} (bölmeden sonra {1}), başlangıç noktası: {2} "
"(train_frame_scale={3})"));
// {1} is "x, y, z" in the dataset's frame; the shift lives on in
// scene_transform.json, so this line is what a reader of the log sees first.
SS_MSG(scene_centered,
EN("Scene origin ({0}): {1} in the dataset's own frame"),
JA("シーンの原点({0}): データセット自身の座標系で {1}"),
ZH_HANS("场景原点({0}):数据集自身坐标系中的 {1}"),
ZH_HANT("場景原點({0}):資料集自身座標系中的 {1}"),
KO("장면 원점({0}): 데이터셋 자체 좌표계에서 {1}"),
DE("Szenenursprung ({0}): {1} im eigenen Bezugssystem des Datensatzes"),
FR("Origine de la scène ({0}) : {1} dans le repère propre du jeu de données"),
ES("Origen de la escena ({0}): {1} en el sistema propio del conjunto de datos"),
PT("Origem da cena ({0}): {1} no referencial próprio do conjunto de dados"),
IT("Origine della scena ({0}): {1} nel sistema proprio del dataset"),
NL("Oorsprong van de scène ({0}): {1} in het eigen stelsel van de dataset"),
RU("Начало координат сцены ({0}): {1} в собственной системе набора данных"),
TR("Sahne başlangıcı ({0}): veri kümesinin kendi çerçevesinde {1}"));
// Printed only when --input-depth-is-ray-depth was left unset and there are
// depth maps to read; {0} is the convention the lens picked.
SS_MSG(ray_depth_resolved,
+133
View File
@@ -2068,6 +2068,85 @@ SS_MSG(outlier_threshold_help,
"daha çoğunu eler; birkaç yanlış kestirilmiş duruş sahneyi gerip ölçeğini "
"bozduğunda işe yarar. Tüm kameraları tutmak için sonsuzda bırakın."));
SS_MSG(scene_center,
EN("Scene centering"), JA("シーンの中心合わせ"), ZH_HANS("场景居中"),
ZH_HANT("場景置中"), KO("장면 중심 맞추기"), DE("Szene zentrieren"),
FR("Centrage de la scène"), ES("Centrado de la escena"),
PT("Centralização da cena"), IT("Centratura della scena"),
NL("Scène centreren"), RU("Центрирование сцены"), TR("Sahne ortalama"));
SS_MSG(scene_center_help,
EN("Move the dataset so this point becomes the origin before training, and "
"record the shift in scene_transform.json in the output folder. Splats "
"are then trained in the shifted frame. Useful for geo-referenced "
"reconstructions whose coordinates are millions of units from the "
"origin, where single precision would lose detail. `none` keeps the "
"dataset's own frame."),
JA("学習前にデータセットを移動し、この点を原点にします。移動量は出力フォルダの "
"scene_transform.json に記録され、スプラットは移動後の座標系で学習されます。"
"座標が原点から数百万単位も離れた地理参照付きの再構成では、単精度では細部が"
"失われるため有効です。`none` はデータセット自身の座標系をそのまま使います。"),
ZH_HANS("训练前平移数据集,使该点成为原点,并把平移量记录到输出文件夹的 "
"scene_transform.json 中;泼溅在平移后的坐标系中训练。适用于坐标距原点"
"数百万单位的地理参考重建,否则单精度会丢失细节。`none` 保留数据集自身"
"的坐标系。"),
ZH_HANT("訓練前平移資料集,使該點成為原點,並把平移量記錄到輸出資料夾的 "
"scene_transform.json 中;潑濺在平移後的座標系中訓練。適用於座標距原點"
"數百萬單位的地理參考重建,否則單精度會遺失細節。`none` 保留資料集自身"
"的座標系。"),
KO("학습 전에 데이터셋을 옮겨 이 점을 원점으로 삼고, 그 이동량을 출력 폴더의 "
"scene_transform.json에 기록합니다. 스플랫은 옮겨진 좌표계에서 학습됩니다. "
"좌표가 원점에서 수백만 단위 떨어진 지리 참조 복원에서는 단정밀도로 세부가 "
"사라지므로 유용합니다. `none`은 데이터셋 자체의 좌표계를 그대로 둡니다."),
DE("Verschiebt den Datensatz vor dem Training so, dass dieser Punkt zum "
"Ursprung wird, und hält die Verschiebung in scene_transform.json im "
"Ausgabeordner fest. Die Splats werden im verschobenen Bezugssystem "
"trainiert. Nützlich für georeferenzierte Rekonstruktionen, deren "
"Koordinaten Millionen Einheiten vom Ursprung entfernt liegen, wo einfache "
"Genauigkeit Details verliert. `none` behält das Bezugssystem des "
"Datensatzes."),
FR("Déplace le jeu de données avant l'entraînement pour que ce point devienne "
"l'origine, et note le décalage dans scene_transform.json dans le dossier "
"de sortie. Les splats sont alors entraînés dans le repère décalé. Utile "
"pour les reconstructions géoréférencées dont les coordonnées sont à des "
"millions d'unités de l'origine, où la simple précision perd des détails. "
"`none` garde le repère du jeu de données."),
ES("Desplaza el conjunto de datos antes de entrenar para que este punto sea "
"el origen, y anota el desplazamiento en scene_transform.json en la carpeta "
"de salida. Los splats se entrenan entonces en el sistema desplazado. Útil "
"para reconstrucciones georreferenciadas cuyas coordenadas están a millones "
"de unidades del origen, donde la precisión simple pierde detalle. `none` "
"conserva el sistema propio del conjunto de datos."),
PT("Desloca o conjunto de dados antes do treino para que este ponto vire a "
"origem, e registra o deslocamento em scene_transform.json na pasta de "
"saída. Os splats são então treinados no referencial deslocado. Útil para "
"reconstruções georreferenciadas cujas coordenadas ficam a milhões de "
"unidades da origem, onde a precisão simples perde detalhe. `none` mantém "
"o referencial do próprio conjunto de dados."),
IT("Sposta il dataset prima dell'addestramento in modo che questo punto "
"diventi l'origine, e annota lo spostamento in scene_transform.json nella "
"cartella di output. Gli splat vengono quindi addestrati nel sistema "
"spostato. Utile per ricostruzioni georeferenziate le cui coordinate "
"distano milioni di unità dall'origine, dove la precisione singola perde "
"dettaglio. `none` mantiene il sistema proprio del dataset."),
NL("Verschuift de dataset vóór het trainen zodat dit punt de oorsprong wordt, "
"en legt de verschuiving vast in scene_transform.json in de uitvoermap. De "
"splats worden dan in het verschoven stelsel getraind. Handig voor "
"gegeorefereerde reconstructies waarvan de coördinaten miljoenen eenheden "
"van de oorsprong liggen, waar enkele precisie detail verliest. `none` "
"behoudt het eigen stelsel van de dataset."),
RU("Сдвигает набор данных перед обучением так, чтобы эта точка стала началом "
"координат, и записывает сдвиг в scene_transform.json в папке вывода. "
"Сплаты обучаются в сдвинутой системе координат. Полезно для "
"геопривязанных реконструкций, координаты которых отстоят от начала на "
"миллионы единиц, где одинарная точность теряет детали. `none` сохраняет "
"собственную систему набора данных."),
TR("Eğitimden önce veri kümesini bu nokta başlangıç olacak şekilde kaydırır "
"ve kaydırmayı çıktı klasöründeki scene_transform.json dosyasına yazar. "
"Splatlar kaydırılmış çerçevede eğitilir. Koordinatları başlangıçtan "
"milyonlarca birim uzakta olan coğrafi referanslı yeniden kurmalar için "
"yararlıdır; tek duyarlık orada ayrıntı kaybeder. `none` veri kümesinin "
"kendi çerçevesini korur."));
SS_MSG(relative_scale,
EN("Scene scale multiplier"), JA("シーンの倍率"),
ZH_HANS("场景缩放倍数"), ZH_HANT("場景縮放倍數"), KO("장면 배율"),
@@ -10099,6 +10178,53 @@ SS_MSG(choice_points,
PT("pontos"), IT("punti"), NL("punten"), RU("точки"),
TR("noktalar"));
SS_MSG(choice_none,
EN("none"), JA("なし"), ZH_HANS("无"), ZH_HANT("無"),
KO("없음"), DE("keine"), FR("aucun"), ES("ninguno"),
PT("nenhum"), IT("nessuno"), NL("geen"), RU("нет"),
TR("yok"));
SS_MSG(choice_point_median,
EN("point cloud median"), JA("点群の中央値"), ZH_HANS("点云中位数"),
ZH_HANT("點雲中位數"), KO("점군 중앙값"), DE("Median der Punktwolke"),
FR("médiane du nuage de points"), ES("mediana de la nube de puntos"),
PT("mediana da nuvem de pontos"), IT("mediana della nuvola di punti"),
NL("mediaan van de puntenwolk"), RU("медиана облака точек"),
TR("nokta bulutu ortancası"));
SS_MSG(choice_camera_median,
EN("camera position median"), JA("カメラ位置の中央値"),
ZH_HANS("相机位置中位数"), ZH_HANT("相機位置中位數"), KO("카메라 위치 중앙값"),
DE("Median der Kamerapositionen"), FR("médiane des positions de caméra"),
ES("mediana de las posiciones de cámara"),
PT("mediana das posições das câmeras"),
IT("mediana delle posizioni delle camere"),
NL("mediaan van de cameraposities"), RU("медиана положений камер"),
TR("kamera konumu ortancası"));
SS_MSG(choice_camera_focus,
EN("camera focus"), JA("カメラの注視点"), ZH_HANS("相机注视点"),
ZH_HANT("相機注視點"), KO("카메라 주시점"), DE("Kamerafokus"),
FR("point de convergence des caméras"), ES("foco de las cámaras"),
PT("foco das câmeras"), IT("fuoco delle camere"), NL("camerafocus"),
RU("фокус камер"), TR("kamera odağı"));
SS_MSG(choice_point_mean,
EN("point cloud mean"), JA("点群の平均"), ZH_HANS("点云平均"),
ZH_HANT("點雲平均"), KO("점군 평균"), DE("Mittelwert der Punktwolke"),
FR("moyenne du nuage de points"), ES("media de la nube de puntos"),
PT("média da nuvem de pontos"), IT("media della nuvola di punti"),
NL("gemiddelde van de puntenwolk"), RU("среднее облака точек"),
TR("nokta bulutu ortalaması"));
SS_MSG(choice_camera_mean,
EN("camera position mean"), JA("カメラ位置の平均"), ZH_HANS("相机位置平均"),
ZH_HANT("相機位置平均"), KO("카메라 위치 평균"),
DE("Mittelwert der Kamerapositionen"), FR("moyenne des positions de caméra"),
ES("media de las posiciones de cámara"), PT("média das posições das câmeras"),
IT("media delle posizioni delle camere"), NL("gemiddelde van de cameraposities"),
RU("среднее положений камер"), TR("kamera konumu ortalaması"));
SS_MSG(choice_mean,
EN("mean"), JA("平均"), ZH_HANS("平均"), ZH_HANT("平均"),
KO("평균"), DE("Mittelwert"), FR("moyenne"), ES("media"),
@@ -10196,6 +10322,13 @@ inline constexpr ChoiceText kChoiceText[] = {
{"train_frame", "camera", &choice_camera},
{"train_frame", "points", &choice_points},
{"scene_center", "none", &choice_none},
{"scene_center", "point-median", &choice_point_median},
{"scene_center", "camera-median", &choice_camera_median},
{"scene_center", "camera-focus", &choice_camera_focus},
{"scene_center", "point-mean", &choice_point_mean},
{"scene_center", "camera-mean", &choice_camera_mean},
{"densify_score_mode", "mean", &choice_mean},
{"densify_score_mode", "max", &choice_max},
{"densify_score_mode", "median", &choice_median},
+8
View File
@@ -107,6 +107,14 @@ plain C++17 with no CUDA dependency (see `csrc/CameraModel.h`).
optical axis). Splats use a one-pixel GPU depth pass; meshes use a WASM
raycast; datasets pick the nearest point along the view ray. Works with every
camera model.
- The **Center** menu (Scene section, next to the up-axis toggle) picks what
the view orbits about and fits to: the model's origin, the point cloud's
geometric median or mean, or over a dataset the camera positions' median or
mean or the point the cameras look at. Camera position median is the
default; over a splat or mesh file, which has no cameras, the camera entries
are disabled and the point statistics stand in. The same six modes are the
trainer's `--scene-center` and the native GUI viewport's menu
(`src/data/SceneCenter.h` is the one implementation).
- One model at a time — dropping another replaces it and frees the previous GPU
buffers. Replacing keeps the current viewpoint (the camera is only fitted for
the first model; refresh the page to start over).
+11
View File
@@ -184,6 +184,17 @@
<label class="toggle"><input autocomplete="off" type="checkbox" id="up-axis"><span class="track"></span></label>
</div>
</div>
<div class="row">
<div class="label"><span>Center</span></div>
<select autocomplete="off" id="center-mode" title="What the view orbits about and fits to. Camera statistics need a dataset; over a splat or mesh file they fall back to the point ones.">
<option value="0">None (model origin)</option>
<option value="1">Point cloud median</option>
<option value="2" selected>Camera position median</option>
<option value="3">Camera focus</option>
<option value="4">Point cloud mean</option>
<option value="5">Camera position mean</option>
</select>
</div>
<div class="row">
<div class="label"><span>Show Axes / Grid</span>
<label class="toggle"><input autocomplete="off" type="checkbox" id="grid"><span class="track"></span></label>
+2 -3
View File
@@ -12,7 +12,7 @@
import {
dsMount, dsEnumerate, dsParse, dsReadCameras, dsReadPoints,
dsSummary, dsFitSphere, dsFrustumSize, dsLastError,
dsSummary, dsFrustumSize, dsLastError,
} from './wasm.js';
// Engine CameraModelType ints (ParsedDataset.camera_models / Common.cuh:130).
@@ -319,9 +319,8 @@ export function parseDatasetComponent(token) {
if (!ok) throw new Error(error || 'failed to parse dataset component');
const cameras = dsReadCameras(); // copied
const summary = dsSummary(); // copied
const fit = dsFitSphere(); // copied
const frustumSize = dsFrustumSize();
const frustum = buildFrustums(cameras);
const points = dsReadPoints(); // live heap views — upload before dsFree
return { cameras, summary, fit, frustumSize, frustum, points, error };
return { cameras, summary, frustumSize, frustum, points, error };
}
+43 -8
View File
@@ -1,7 +1,7 @@
// Application entry: wires the WASM loader, WebGL renderer, camera controller,
// and the UI panel together.
import { initWasm, loadModel, sortSplats, freeSplatSh, reduceSh, splatHistogram, meshHistogram, meshEdgeCount, fitSphere, raycastMesh, dsFree, dsPickPoint } from './wasm.js';
import { initWasm, loadModel, sortSplats, freeSplatSh, reduceSh, splatHistogram, meshHistogram, meshEdgeCount, fitSphere, raycastMesh, dsFree, dsFitSphere, dsPickPoint } from './wasm.js';
import { loadDatasetFiles, parseDatasetComponent } from './dataset.js';
import { Renderer } from './renderer.js';
import { Camera, Nav } from './camera.js';
@@ -20,13 +20,15 @@ const opts = {
primitive: 0, gamut: toColMajor(GAMUTS['Rec.709']), transfer: 0, isLinear: false,
shDegree: 0, exposure: 1.0, opacityScale: 1.0,
cameraModel: 'perspective', upAxis: 'z', showGrid: true, gridRadius: 1,
// what the view orbits about (#center-mode; dsparse::CenterMode index)
centerMode: 2,
background: hexToRgb('0a0b0e'), shade: true, flatShade: false, meshColor: true,
// dataset (point cloud + camera frustums)
pointSize: 2.0, frustumScale: 1.0, showPoints: true, showFrustums: true, hoverCam: -1,
};
// dataset session state (cameras metadata for hover/pick/view-from-camera)
let dataset = null; // { cameras, components, token, fit, frustumBase, frustumMult, pickR }
let dataset = null; // { cameras, components, token, frustumBase, frustumMult, pickR }
// Valid FOV range (degrees) per display camera model: tan blows up toward
// 180° for the linear models; the fisheye projections are defined to 360°.
@@ -129,6 +131,8 @@ window.__viewer = {
snapshot: () => { if (model && model.type==='splat') maybeSort(true); renderer.render(camera, opts); return renderer.snapshot(); },
get sortStats() { return sortStats; },
get dataset() { return dataset; },
// [cx, cy, cz, radius] of the loaded model about the chosen centering
get fit() { return model && model.type === 'dataset' ? dsFitSphere(opts.centerMode) : fitSphere(opts.centerMode); },
viewFromCamera: (i) => viewFromCamera(i),
pickCamera: (px, py) => pickCamera(px, py),
};
@@ -161,8 +165,8 @@ function upTransform() {
// outliers that make a bounding-box fit useless.
function fitModel() {
if (!model) return;
if (model.type === 'dataset') { if (dataset) fitDataset(dataset.fit); return; }
const fs = fitSphere(); // [cx,cy,cz, medianDist] (native frame)
if (model.type === 'dataset') { if (dataset) fitDataset(dsFitSphere(opts.centerMode)); return; }
const fs = fitSphere(opts.centerMode); // [cx,cy,cz, medianDist] (native frame)
const c = mat3.mulVec(upTransform(), [fs[0], fs[1], fs[2]]);
const r = 2.0 * fs[3] || 1;
opts.gridRadius = r;
@@ -177,6 +181,28 @@ function setUpAxis(axis) {
lastSortDir = [0,0,0];
dirty = true;
}
// Switch the centering: the model stays put and the orbit pivot moves to the
// new centre, which is also what Reset View will fit to.
function setCenterMode(mode) {
mode = mode | 0;
if (mode === opts.centerMode) return;
opts.centerMode = mode;
if (!model) return;
const fs = model.type === 'dataset' ? dsFitSphere(mode) : fitSphere(mode);
recenterAt(mat3.mulVec(upTransform(), [fs[0], fs[1], fs[2]]));
}
const CAMERA_CENTER_MODES = { 2: 1, 3: 4, 5: 4 }; // camera mode -> its point fallback
// A file has no cameras, so the camera statistics are offered only over a
// dataset; a selected one drops to the point statistic it would fall back to.
function syncCenterMenu() {
const sel = $('center-mode');
const isDataset = !!(model && model.type === 'dataset');
for (const o of sel.options) o.disabled = !isDataset && (+o.value in CAMERA_CENTER_MODES);
if (!isDataset && opts.centerMode in CAMERA_CENTER_MODES) {
opts.centerMode = CAMERA_CENTER_MODES[opts.centerMode];
sel.value = String(opts.centerMode);
}
}
function forwardNative() {
// camera forward in the model's native frame: U^T * forward_canonical
@@ -350,11 +376,12 @@ async function loadSingleModel(entries) {
model = { type:'mesh', nv: res.data.nv, nt: res.data.nt };
showMeshUI(res.data);
}
syncCenterMenu();
// Only fit the camera for the first model of the session: replacing the
// model (e.g. dropping the mesh of the same object after a splat) keeps
// the current view. The scene scale (move/zoom speed) is still refreshed.
if (firstModel) fitModel();
else { const fs = fitSphere(); nav._sceneScale = 2.0 * fs[3] || 1; opts.gridRadius = nav._sceneScale; }
else { const fs = fitSphere(opts.centerMode); nav._sceneScale = 2.0 * fs[3] || 1; opts.gridRadius = nav._sceneScale; }
lastSortDir = [0,0,0]; lastSortMode = -1;
if (model.type === 'splat') maybeSort(true); // initial order, synchronous
histCache.clear();
@@ -381,7 +408,6 @@ async function loadDataset(entries, token) {
// (dsPickPoint) still works; it is freed when a non-dataset model loads.
sortGen++; sortPending = null; // invalidate any in-flight splat sort
const fit = res.fit;
const frustumBase = res.frustumSize;
const frustumMult = dataset ? dataset.frustumMult : 1.0;
opts.frustumScale = frustumBase * frustumMult;
@@ -389,12 +415,14 @@ async function loadDataset(entries, token) {
cameras: res.cameras,
components: res.components || (dataset && dataset.components) || [],
token: res.selectedToken || token,
fit, frustumBase, frustumMult,
frustumBase, frustumMult,
pickR: res.frustum.pickR, // size-1 per-camera frustum radii (ray picking)
};
model = { type:'dataset', numCameras: res.cameras.length, numPoints: res.points.count };
opts.hoverCam = -1;
showDatasetUI(res.summary);
syncCenterMenu();
const fit = dsFitSphere(opts.centerMode);
if (firstDataset || token) fitDataset(fit); else nav._sceneScale = 2.0*(fit[3]||1);
histCache.clear(); updateHistParams();
$('drop-hint').style.display = 'none';
@@ -875,7 +903,13 @@ function pinchState(pointers) {
// UI control wiring
// ---------------------------------------------------------------------------
function wireControls() {
const bind = (id, ev, fn) => $(id).addEventListener(ev, fn);
// A host page may carry a subset of the controls (viewer/test/test_ds.html).
// Throwing here would leave every control wired after it dead instead.
const bind = (id, ev, fn) => {
const el = $(id);
if (!el) { console.warn('no control #' + id); return; }
el.addEventListener(ev, fn);
};
bind('nav-mode','change', e => nav.mode = e.target.value);
bind('move-speed','input', e => { nav.speedExp = +e.target.value; $('v-speed').textContent = Math.pow(10,+e.target.value).toFixed(2)+'×'; });
bind('btn-reset','click', () => { if (model) fitModel(); else nav.reset(); });
@@ -914,6 +948,7 @@ function wireControls() {
});
bind('fov','input', e => { camera.fov = +e.target.value*Math.PI/180; fovMemory[camera.model] = camera.fov; $('v-fov').textContent = e.target.value+'°'; dirty=true; });
bind('up-axis','change', e => setUpAxis(e.target.checked ? 'y' : 'z'));
bind('center-mode','change', e => setCenterMode(e.target.value));
bind('flat-shade','change', e => { opts.flatShade = e.target.checked; dirty=true; });
bind('mesh-color','change', e => { opts.meshColor = e.target.checked; dirty=true; });
bind('grid','change', e => { opts.showGrid = e.target.checked; dirty=true; });
File diff suppressed because one or more lines are too long
Binary file not shown.
+4 -3
View File
@@ -421,8 +421,9 @@ export function meshHistogram(param, nbins) {
}
export function meshEdgeCount() { return call('ssv_mesh_edge_count'); }
export function bbox() { return f32(call('ssv_bbox'), 6).slice(); }
// robust fit sphere: [cx, cy, cz, medianDistance]
export function fitSphere() { return f32(call('ssv_fit_sphere'), 4).slice(); }
// fit sphere about centering mode `mode` (index.html #center-mode):
// [cx, cy, cz, medianDistance]
export function fitSphere(mode = 2) { return f32(call('ssv_fit_sphere', 'number', ['number'], [mode|0]), 4).slice(); }
// nearest ray/mesh hit distance (model-native frame), or -1
export function raycastMesh(ox, oy, oz, dx, dy, dz) {
return call('ssv_raycast_mesh','number',
@@ -530,7 +531,7 @@ export function dsSummary() {
export function dsLastError() {
return Module.ccall('ssv_ds_last_error', 'string', [], []);
}
export function dsFitSphere() { return f32(call('ssv_ds_fit_sphere') >>> 0, 4).slice(); }
export function dsFitSphere(mode = 2) { return f32(call('ssv_ds_fit_sphere', 'number', ['number'], [mode|0]) >>> 0, 4).slice(); }
export function dsFrustumSize() { return call('ssv_ds_frustum_size'); }
export function dsPickPoint(ox, oy, oz, dx, dy, dz) {
const ptr = Module.ccall('ssv_ds_pick_point', 'number',
+34 -29
View File
@@ -44,11 +44,14 @@ static std::string g_format; // "COLMAP" / "Nerfstudio" / "Metashape" / "
static std::string g_json; // reused output buffer (enumerate / summary)
static std::string g_names; // newline-joined image basenames
static std::string g_error; // last parse error ("" = clean load)
static std::vector<float> g_pts; // the seed cloud as JS reads it, [N, 3]
static float g_fit[4]; // (cx, cy, cz, radius)
static float g_pick[3]; // (index, t, perp) for point picking
static void reset_result() {
g_ds = ParsedDataset();
g_pts.clear();
g_pts.shrink_to_fit();
g_loaded = false;
g_format.clear();
g_names.clear();
@@ -364,7 +367,12 @@ KEEP int ssv_ds_parse(const char* token_c) {
g_ds.num_cameras = (int64_t)g_ds.camera_models.size();
build_names();
g_loaded = (g_ds.num_cameras > 0 || g_ds.points.num() > 0);
// The parsers keep the cloud in double; JS wants one Float32Array view of
// it, and the double copy has no reader left once that exists.
g_pts.assign(g_ds.points.xyz.begin(), g_ds.points.xyz.end());
g_ds.points.xyz.clear();
g_ds.points.xyz.shrink_to_fit();
g_loaded = (g_ds.num_cameras > 0 || g_pts.size() > 0);
return g_loaded ? 1 : 0;
}
@@ -375,8 +383,8 @@ KEEP int ssv_ds_ok() { return g_loaded ? 1 : 0; }
KEEP char* ssv_ds_json() { return (char*)g_json.c_str(); }
KEEP char* ssv_ds_last_error() { return (char*)g_error.c_str(); }
KEEP int ssv_ds_num_points() { return (int)g_ds.points.num(); }
KEEP float* ssv_ds_points_xyz() { return g_ds.points.xyz.empty()? nullptr : g_ds.points.xyz.data(); }
KEEP int ssv_ds_num_points() { return (int)(g_pts.size() / 3); }
KEEP float* ssv_ds_points_xyz() { return g_pts.empty()? nullptr : g_pts.data(); }
KEEP uint8_t* ssv_ds_points_rgb() { return g_ds.points.rgb.empty()? nullptr : g_ds.points.rgb.data(); }
KEEP int ssv_ds_num_cameras() { return (int)g_ds.num_cameras; }
@@ -447,39 +455,36 @@ KEEP char* ssv_ds_summary_json() {
}
// ---------------------------------------------------------------------------
// Robust fit sphere over points + camera centers (median center, median
// distance) -> (cx, cy, cz, radius). Mirrors ssv_fit_sphere in viewer.cpp.
// Fit sphere: the dsparse::CenterMode `mode` over points + cameras, and a
// radius about it -> (cx, cy, cz, radius). Mirrors ssv_fit_sphere in
// viewer.cpp.
// ---------------------------------------------------------------------------
KEEP float* ssv_ds_fit_sphere() {
std::vector<float> xs, ys, zs; // combined (for a robust center)
KEEP float* ssv_ds_fit_sphere(int mode) {
std::vector<float> pd, cd; // squared distances: points / cameras
const auto& P = g_ds.points.xyz;
int64_t np = g_ds.points.num();
int64_t step = np > (1<<20) ? np / (1<<20) : 1; if (step < 1) step = 1;
for (int64_t i = 0; i < np; i += step) {
xs.push_back(P[i*3]); ys.push_back(P[i*3+1]); zs.push_back(P[i*3+2]);
const int64_t np = (int64_t)g_pts.size() / 3;
const int64_t ncam = std::min<int64_t>(g_ds.num_cameras, (int64_t)g_ds.c2w.size() / 12);
if (np == 0 && ncam == 0) { g_fit[0]=g_fit[1]=g_fit[2]=0.f; g_fit[3]=1.f; return g_fit; }
{
std::vector<double> c2w(g_ds.c2w.begin(), g_ds.c2w.begin() + ncam * 12);
mode = std::max(0, std::min(mode, dsparse::kNumCenterModes - 1));
const std::array<double, 3> c = dsparse::scene_center(
(dsparse::CenterMode)mode, c2w.data(), ncam, g_pts.data(), np, 3, 1 << 20);
for (int k = 0; k < 3; k++) g_fit[k] = (float)c[k];
}
// camera centers = translation column of c2w [N,3,4]
int ncam = 0;
for (int i = 0; i < (int)g_ds.num_cameras; i++) {
if (12*i+11 >= (int)g_ds.c2w.size()) break;
xs.push_back(g_ds.c2w[12*i+3]);
ys.push_back(g_ds.c2w[12*i+7]);
zs.push_back(g_ds.c2w[12*i+11]);
ncam++;
}
if (xs.empty()) { g_fit[0]=g_fit[1]=g_fit[2]=0.f; g_fit[3]=1.f; return g_fit; }
auto quantile = [](std::vector<float>& v, float q)->float{
if (v.empty()) return 0.f;
size_t k = (size_t)(q * (v.size()-1));
std::nth_element(v.begin(), v.begin()+k, v.end());
return v[k];
};
g_fit[0]=quantile(xs,0.5f); g_fit[1]=quantile(ys,0.5f); g_fit[2]=quantile(zs,0.5f);
size_t nps = xs.size() - ncam;
for (size_t i = 0; i < xs.size(); i++) {
float dx=xs[i]-g_fit[0], dy=ys[i]-g_fit[1], dz=zs[i]-g_fit[2];
(i < nps ? pd : cd).push_back(dx*dx+dy*dy+dz*dz);
int64_t step = np > (1<<20) ? np / (1<<20) : 1; if (step < 1) step = 1;
for (int64_t i = 0; i < np; i += step) {
float dx=g_pts[i*3]-g_fit[0], dy=g_pts[i*3+1]-g_fit[1], dz=g_pts[i*3+2]-g_fit[2];
pd.push_back(dx*dx+dy*dy+dz*dz);
}
for (int64_t i = 0; i < ncam; i++) {
float dx=g_ds.c2w[12*i+3]-g_fit[0], dy=g_ds.c2w[12*i+7]-g_fit[1], dz=g_ds.c2w[12*i+11]-g_fit[2];
cd.push_back(dx*dx+dy*dy+dz*dz);
}
// Frame the capture volume: points are dense (median), cameras are the
// outer boundary we still want in view (90th percentile, robust to a stray
@@ -502,8 +507,8 @@ KEEP float ssv_ds_frustum_size() {
KEEP float* ssv_ds_pick_point(float ox, float oy, float oz,
float dx, float dy, float dz) {
g_pick[0] = -1.f; g_pick[1] = 0.f; g_pick[2] = 3.4e38f;
const auto& P = g_ds.points.xyz;
int64_t np = g_ds.points.num();
const auto& P = g_pts;
int64_t np = (int64_t)g_pts.size() / 3;
float dd = dx*dx + dy*dy + dz*dz; if (dd < 1e-20f) return g_pick;
float best_score = 3.4e38f;
for (int64_t i = 0; i < np; i++) {
+9 -12
View File
@@ -29,6 +29,7 @@
// strtod/strtof cost ~1.5us per call — tens of seconds over a 100+ MB OBJ
// or ascii PLY.
#include "data/FastFloat.h"
#include "data/SceneCenter.h"
#define KEEP EMSCRIPTEN_KEEPALIVE extern "C"
@@ -1255,12 +1256,11 @@ KEEP float* ssv_bbox(){
return g_bbox;
}
// Robust fitting sphere of the current model: per-axis median center and the
// median distance from it -> 4 floats (cx, cy, cz, median_dist). Trained
// splats and generated meshes often contain far-away outliers that blow up a
// bounding-box fit; medians ignore them. Subsamples uniformly for speed.
// (cx, cy, cz, median_dist) about the dsparse::CenterMode `mode`; a file has
// no cameras, so the camera modes fall back to the point ones. Medians,
// because trained splats and meshes carry far-away outliers a box fit cannot.
static float g_fitsph[4];
KEEP float* ssv_fit_sphere(){
KEEP float* ssv_fit_sphere(int mode){
const float* P = nullptr; uint32_t N = 0, stride = 3;
if (g_last_kind == 1) { P = g_splat.posop.data(); N = g_splat.count; stride = 4; }
else if (g_last_kind == 2) { P = g_mesh.pos.data(); N = g_mesh.nv; stride = 3; }
@@ -1268,14 +1268,11 @@ KEEP float* ssv_fit_sphere(){
const uint32_t MAXS = 1u<<20;
uint32_t step = (N + MAXS - 1) / MAXS; if (step < 1) step = 1;
uint32_t M = (N + step - 1) / step;
mode = std::max(0, std::min(mode, dsparse::kNumCenterModes - 1));
const std::array<double, 3> c = dsparse::scene_center(
(dsparse::CenterMode)mode, nullptr, 0, P, (int64_t)N, (int)stride, (int64_t)MAXS);
for (int k = 0; k < 3; k++) g_fitsph[k] = (float)c[k];
std::vector<float> tmp; tmp.reserve(M);
for (int k = 0; k < 3; k++) {
tmp.clear();
for (uint32_t i = 0; i < N; i += step) tmp.push_back(P[(size_t)i*stride+k]);
std::nth_element(tmp.begin(), tmp.begin()+tmp.size()/2, tmp.end());
g_fitsph[k] = tmp[tmp.size()/2];
}
tmp.clear();
for (uint32_t i = 0; i < N; i += step) {
float dx=P[(size_t)i*stride]-g_fitsph[0], dy=P[(size_t)i*stride+1]-g_fitsph[1], dz=P[(size_t)i*stride+2]-g_fitsph[2];
tmp.push_back(dx*dx+dy*dy+dz*dz);