Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,7 @@ cameras.json

# Demo
demo/.build/

# Node
node_modules/
.msplat-web/
176 changes: 176 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,182 @@ cd swift && swift build

Requires macOS 14+, Apple Silicon. No external dependencies.

## Run Modes

This repo now has two distinct ways to use `msplat` locally:

1. `CLI`: run `msplat` directly against a prepared dataset that already matches the native loader formats.
2. `Web GUI`: upload prepared datasets, COLMAP TXT exports, raw-image zips, or raw photos and let the worker run the full COLMAP-to-training pipeline.

### CLI Workflow

Use this path when you already have a dataset that `msplat` can read directly.

What the CLI expects:

- COLMAP with a binary sparse model such as `sparse/0/cameras.bin`, `sparse/0/images.bin`, and `sparse/0/points3D.bin`
- Nerfstudio with `transforms.json` and a seed point cloud
- Polycam with the supported camera/image layout and a seed point cloud

The CLI does **not** build COLMAP from raw photos for you. If you only have images, use the web GUI workflow below.

Build and run:

```bash
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
./build/msplat path/to/dataset -n 7000 --val
```

Common examples:

```bash
# Preview
./build/msplat path/to/dataset -n 1500 -d 2 --num-downscales 2 --val

# Standard
./build/msplat path/to/dataset -n 7000 -d 1 --num-downscales 0 --val

# High
./build/msplat path/to/dataset -n 30000 -d 1 --num-downscales 0 --val
```

Add output flags as needed:

```bash
./build/msplat path/to/dataset -o output/final.spl --val --val-render output/previews
```

### Web GUI Workflow

Use this path when you want the full internal pipeline:

- upload a prepared dataset zip and train
- upload a COLMAP TXT export and convert it automatically
- upload a raw-image zip and reconstruct COLMAP first
- upload raw photo files directly and reconstruct COLMAP first

Requirements:

- built `msplat` CLI binary
- `colmap` installed and available on `PATH`, or passed explicitly through `COLMAP_BIN`
- Node.js with `npm`

Start the GUI and worker:

```bash
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
MSPLAT_BIN=./build/msplat npm run web
MSPLAT_BIN=./build/msplat COLMAP_BIN=colmap npm run worker
```

Or run both together:

```bash
MSPLAT_BIN=./build/msplat COLMAP_BIN=colmap npm run dev
```

Then open:

```text
http://127.0.0.1:4321
```

The GUI exposes two upload flows:

- `Prepared Dataset / Zip`: prepared COLMAP BIN, COLMAP TXT, Nerfstudio, Polycam, or raw-image zip
- `Raw Photos`: direct multi-image upload with `Sequential` or `Exhaustive` COLMAP matching

What the worker does:

1. Validates the upload and rejects unsafe archives.
2. Converts COLMAP TXT to BIN when needed.
3. Runs COLMAP feature extraction, matching, mapper, and best-model selection for raw photos.
4. Queues the normalized dataset for `msplat` training.
5. Stores `final.spl`, `final.ply`, `cameras.json`, previews, logs, and generated COLMAP artifacts for download.

Useful environment variables:

- `MSPLAT_BIN`: path to the `msplat` CLI binary
- `COLMAP_BIN`: path to the `colmap` binary, default `colmap`
- `COLMAP_FLAG_STYLE`: COLMAP option family for CPU-only reconstruction, `modern` by default, or `legacy` for older COLMAP builds
- `MSPLAT_JOBS_DIR`: job storage root
- `DATABASE_URL`: SQLite file path
- `MAX_UPLOAD_GB`: upload limit, default `10`

Browser/API entry points:

- `POST /api/jobs`: prepared dataset zips, COLMAP TXT zips, and raw-image zips
- `POST /api/jobs/raw`: direct `multipart/form-data` raw photo uploads

Raw-photo jobs require at least 3 images. In practice, use at least 8 overlapping images whenever possible.

For very small raw-photo batches, the worker lowers COLMAP's mapper thresholds and will retry with exhaustive matching if sequential initialization cannot find a starting pair.

If raw-photo jobs fail with a COLMAP option parse error on an older install, restart the worker with:

```bash
COLMAP_FLAG_STYLE=legacy MSPLAT_BIN=./build/msplat COLMAP_BIN=colmap npm run worker
```

## Internal Website

This repo also includes a small internal training site for queued `msplat` runs.

Features:

- Upload a prepared dataset zip (COLMAP BIN, COLMAP TXT, Nerfstudio, or Polycam)
- Upload a raw-image zip or raw photo files directly and reconstruct COLMAP first
- Queue a single-worker training job on Apple Silicon
- Watch status, phase, log tail, validation thumbnails, and final metrics
- Download `final.spl`, `final.ply`, `cameras.json`, previews, the run log, and generated COLMAP artifacts

The website uses the existing CLI as its worker backend and stores job state in SQLite.

### Run it

Build the CLI first:

```bash
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
```

Then start the app and worker in separate terminals:

```bash
MSPLAT_BIN=./build/msplat npm run web
MSPLAT_BIN=./build/msplat COLMAP_BIN=colmap npm run worker
```

Or run both together during development:

```bash
MSPLAT_BIN=./build/msplat COLMAP_BIN=colmap npm run dev
```

Homebrew COLMAP 3.13 works with the default `COLMAP_FLAG_STYLE=modern`. Use `COLMAP_FLAG_STYLE=legacy` only if your COLMAP build still expects `SiftExtraction.use_gpu` and `SiftMatching.use_gpu`.

Default web URL: `http://127.0.0.1:4321`

This section is the same web GUI workflow described above, kept here for feature-level reference.

### Website tests

```bash
npm test
```

There is also an opt-in smoke test that can run a real Apple Silicon training job:

```bash
MSPLAT_SMOKE_BIN=./build/msplat \
MSPLAT_SMOKE_COLMAP_BIN=colmap \
MSPLAT_SMOKE_DATASET=/absolute/path/to/dataset.zip \
npm test
```

## Benchmarks

mipnerf360, M4 Max. msplat runs 7K iterations with no downscales:
Expand Down
89 changes: 87 additions & 2 deletions cli/msplat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
#include <algorithm>
#include <numeric>
#include <cmath>
#include <iomanip>
#include <iostream>
#include <sstream>
#include <CLI/CLI.hpp>
#include "model.hpp"
#include "input_data.hpp"
#include "msplat_c_api.h"
#include "random_iter.hpp"
#include "loaders.hpp"
#include "msplat.hpp"
Expand All @@ -26,6 +29,8 @@ int main(int argc, char *argv[]) {
// Output
std::string outputScene = "splat.ply";
app.add_option("-o,--output", outputScene, "Output scene path");
std::string exportPly;
app.add_option("--export-ply", exportPly, "Additional PLY export path");
int saveEvery = -1;
app.add_option("-s,--save-every", saveEvery, "Save every N steps (-1 to disable)");

Expand Down Expand Up @@ -97,10 +102,60 @@ int main(int argc, char *argv[]) {
downScaleFactor = std::max(downScaleFactor, 1.0f);

try {
fs::path executablePath = fs::absolute(argv[0]);
fs::path metallibPath = executablePath.parent_path() / "default.metallib";
if (fs::exists(metallibPath)) {
msplat_set_metallib_path(metallibPath.string().c_str());
std::cout << "[runtime] metallib=" << metallibPath << std::endl;
} else {
std::cout << "[runtime] warning: default.metallib not found next to executable: "
<< metallibPath << std::endl;
}

InputData inputData = inputDataFromX(projectRoot, colmapImagePath);

for (auto &cam : inputData.cameras)
auto formatGiB = [](double bytes) {
std::ostringstream ss;
ss << std::fixed << std::setprecision(2) << (bytes / (1024.0 * 1024.0 * 1024.0));
return ss.str();
};

double estimatedImageBytes = 0.0;
for (const auto &cam : inputData.cameras) {
double width = std::max(1.0, std::floor(static_cast<double>(cam.width) / static_cast<double>(downScaleFactor)));
double height = std::max(1.0, std::floor(static_cast<double>(cam.height) / static_cast<double>(downScaleFactor)));
estimatedImageBytes += width * height * 3.0 * sizeof(float);
}

std::cout << "[dataset] path=" << projectRoot
<< " cameras=" << inputData.cameras.size()
<< " seed_points=" << inputData.points.count
<< " downscale=" << downScaleFactor
<< " est_cpu_image_mem=" << formatGiB(estimatedImageBytes) << " GiB"
<< std::endl;

if (estimatedImageBytes > 4.0 * 1024.0 * 1024.0 * 1024.0) {
std::cout << "[dataset] warning: high image memory estimate; consider Preview preset or -d 2 for faster startup"
<< std::endl;
}

double loadedImageBytes = 0.0;
const size_t loadLogEvery = std::max<size_t>(1, inputData.cameras.size() / 8);
std::cout << "[dataset] loading images..." << std::endl;

for (size_t index = 0; index < inputData.cameras.size(); index++) {
auto &cam = inputData.cameras[index];
cam.loadImage(downScaleFactor);
loadedImageBytes += (double)cam.width * (double)cam.height * 3.0 * sizeof(float);

if (index == 0 || (index + 1) % loadLogEvery == 0 || index + 1 == inputData.cameras.size()) {
std::cout << "[dataset] loaded " << (index + 1) << "/" << inputData.cameras.size()
<< " latest=" << fs::path(cam.filePath).filename().string()
<< " size=" << cam.width << "x" << cam.height
<< " approx_cpu_image_mem=" << formatGiB(loadedImageBytes) << " GiB"
<< std::endl;
}
}

std::vector<Camera> cams;
std::vector<Camera> testCams;
Expand All @@ -115,13 +170,25 @@ int main(int argc, char *argv[]) {
cams = train; valCam = val;
}

std::cout << "[train] train_cameras=" << cams.size()
<< " val_camera=" << (valCam ? fs::path(valCam->filePath).filename().string() : "none")
<< " eval_cameras=" << testCams.size()
<< std::endl;

Model model(inputData, cams.size(),
numDownscales, resolutionSchedule, shDegree, shDegreeInterval,
refineEvery, warmupLength, resetAlphaEvery, densifyGradThresh,
densifySizeThresh, stopScreenSizeAt, splitScreenSize,
numIters, keepCrs,
bgColor.data());

std::cout << "[train] starting iterations=" << numIters
<< " preset_downscale=" << downScaleFactor
<< " progressive_downscales=" << numDownscales
<< " resolution_schedule=" << resolutionSchedule
<< " output=" << outputScene
<< std::endl;

std::vector<size_t> camIndices(cams.size());
std::iota(camIndices.begin(), camIndices.end(), 0);
InfiniteRandomIterator<size_t> camsIter(camIndices);
Expand Down Expand Up @@ -150,6 +217,15 @@ int main(int argc, char *argv[]) {
model.afterTrain(step);
msplat_commit();

if (step <= 10 || step % 50 == 0 || step == (size_t)numIters) {
std::cout << "[train] step=" << step
<< "/" << numIters
<< " ds=" << model.getDownscaleFactor((int)step)
<< " gaussians=" << model.means.size(0)
<< " camera=" << fs::path(cam.filePath).filename().string()
<< std::endl;
}

if (benchmarking && step > (size_t)bench_warmup) {
auto pre_sync = cpu_now();
msplat_gpu_sync();
Expand All @@ -176,7 +252,11 @@ int main(int argc, char *argv[]) {
valImg.height = (int)rgb_cpu.size(0);
valImg.data.resize(valImg.width * valImg.height * 3);
memcpy(valImg.ptr(), rgb_cpu.data_ptr(), valImg.data.size() * sizeof(float));
imwriteRGB((fs::path(valRender) / (std::to_string(step) + ".png")).string(), valImg);
std::string previewPath = (fs::path(valRender) / (std::to_string(step) + ".png")).string();
imwriteRGB(previewPath, valImg);
std::cout << "[train] wrote_preview step=" << step
<< " path=" << previewPath
<< std::endl;
}
}

Expand Down Expand Up @@ -221,6 +301,11 @@ int main(int argc, char *argv[]) {

inputData.saveCameras((fs::path(outputScene).parent_path() / "cameras.json").string(), keepCrs);
model.save(outputScene, numIters);
if (!exportPly.empty() && fs::path(exportPly) != fs::path(outputScene)) {
model.savePly(exportPly, numIters);
std::cout << "[train] saved_output path=" << exportPly << std::endl;
}
std::cout << "[train] saved_output path=" << outputScene << std::endl;

// Evaluation
if (evalMode && !testCams.empty()) {
Expand Down
2 changes: 1 addition & 1 deletion core/include/msplat_api.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ class Trainer {
/// Export scene to PLY format.
void exportPly(const std::string& path);

/// Export scene to .splat format.
/// Export scene to .splat/.spl format.
void exportSplat(const std::string& path);

/// Save full training state (params + optimizer) for resume.
Expand Down
5 changes: 5 additions & 0 deletions core/metal/msplat_metal.mm
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,15 @@ void syncCB() {
NSError *error = nil;
id<MTLLibrary> metal_library = nil;

const char* env_metallib_path = getenv("MSPLAT_METALLIB_PATH");

if (g_metallib_path) {
// Explicit path (set by XCFramework / Swift wrapper)
NSURL *url = [NSURL fileURLWithPath:[NSString stringWithUTF8String:g_metallib_path]];
metal_library = [device newLibraryWithURL:url error:&error];
} else if (env_metallib_path && env_metallib_path[0] != '\0') {
NSURL *url = [NSURL fileURLWithPath:[NSString stringWithUTF8String:env_metallib_path]];
metal_library = [device newLibraryWithURL:url error:&error];
} else {
// Auto-discover default.metallib next to this library or the executable
NSFileManager *fm = [NSFileManager defaultManager];
Expand Down
2 changes: 1 addition & 1 deletion core/src/model.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ void Model::afterTrain(int step){

void Model::save(const std::string &filename, int step) {
std::string ext = fs::path(filename).extension().string();
if (ext == ".splat")
if (ext == ".splat" || ext == ".spl")
saveSplat(filename);
else
savePly(filename, step);
Expand Down
3 changes: 0 additions & 3 deletions datasets/mipnerf360/garden/images/DSC07956.JPG

This file was deleted.

3 changes: 0 additions & 3 deletions datasets/mipnerf360/garden/images/DSC07957.JPG

This file was deleted.

3 changes: 0 additions & 3 deletions datasets/mipnerf360/garden/images/DSC07958.JPG

This file was deleted.

3 changes: 0 additions & 3 deletions datasets/mipnerf360/garden/images/DSC07959.JPG

This file was deleted.

3 changes: 0 additions & 3 deletions datasets/mipnerf360/garden/images/DSC07960.JPG

This file was deleted.

Loading