diff --git a/.gitignore b/.gitignore index 0d60ccc..aca7b0b 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,7 @@ cameras.json # Demo demo/.build/ + +# Node +node_modules/ +.msplat-web/ diff --git a/README.md b/README.md index 5e863fd..34af029 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/cli/msplat.cpp b/cli/msplat.cpp index 5c853bf..3399c24 100644 --- a/cli/msplat.cpp +++ b/cli/msplat.cpp @@ -3,10 +3,13 @@ #include #include #include +#include #include +#include #include #include "model.hpp" #include "input_data.hpp" +#include "msplat_c_api.h" #include "random_iter.hpp" #include "loaders.hpp" #include "msplat.hpp" @@ -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)"); @@ -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(cam.width) / static_cast(downScaleFactor))); + double height = std::max(1.0, std::floor(static_cast(cam.height) / static_cast(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(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 cams; std::vector testCams; @@ -115,6 +170,11 @@ 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, @@ -122,6 +182,13 @@ int main(int argc, char *argv[]) { 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 camIndices(cams.size()); std::iota(camIndices.begin(), camIndices.end(), 0); InfiniteRandomIterator camsIter(camIndices); @@ -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(); @@ -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; } } @@ -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()) { diff --git a/core/include/msplat_api.hpp b/core/include/msplat_api.hpp index 139740d..b01502d 100644 --- a/core/include/msplat_api.hpp +++ b/core/include/msplat_api.hpp @@ -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. diff --git a/core/metal/msplat_metal.mm b/core/metal/msplat_metal.mm index d6a73ea..0eb1573 100644 --- a/core/metal/msplat_metal.mm +++ b/core/metal/msplat_metal.mm @@ -97,10 +97,15 @@ void syncCB() { NSError *error = nil; id 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]; diff --git a/core/src/model.cpp b/core/src/model.cpp index 3e8b47b..4b44596 100644 --- a/core/src/model.cpp +++ b/core/src/model.cpp @@ -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); diff --git a/datasets/mipnerf360/garden/images/DSC07956.JPG b/datasets/mipnerf360/garden/images/DSC07956.JPG deleted file mode 100644 index b3aa216..0000000 --- a/datasets/mipnerf360/garden/images/DSC07956.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9dab83b11881c741f92293e35ce2fa452206237f0abf9376e9ce08b1c117f0a4 -size 1097465 diff --git a/datasets/mipnerf360/garden/images/DSC07957.JPG b/datasets/mipnerf360/garden/images/DSC07957.JPG deleted file mode 100644 index 5b37525..0000000 --- a/datasets/mipnerf360/garden/images/DSC07957.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2cf56eb82779b04f1da57567d9e9a613a4df4cf36ca3b32ac40bd5e7f32961b5 -size 1104324 diff --git a/datasets/mipnerf360/garden/images/DSC07958.JPG b/datasets/mipnerf360/garden/images/DSC07958.JPG deleted file mode 100644 index 095bc88..0000000 --- a/datasets/mipnerf360/garden/images/DSC07958.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b4391d3062ded1249c586b97d2015f722216f34c19176eadca315d837b28f857 -size 1105737 diff --git a/datasets/mipnerf360/garden/images/DSC07959.JPG b/datasets/mipnerf360/garden/images/DSC07959.JPG deleted file mode 100644 index efefcc4..0000000 --- a/datasets/mipnerf360/garden/images/DSC07959.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:587861ed9ec0a2f1e9c57e5819fc899c4f19e1ac81b9ad862f6ad753c822af21 -size 1098050 diff --git a/datasets/mipnerf360/garden/images/DSC07960.JPG b/datasets/mipnerf360/garden/images/DSC07960.JPG deleted file mode 100644 index 059fd5f..0000000 --- a/datasets/mipnerf360/garden/images/DSC07960.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d5c00bba4f3aee13e168142354ea96e5a7269635edfdbab6b4685fd09ab476cc -size 1091487 diff --git a/datasets/mipnerf360/garden/images/DSC07961.JPG b/datasets/mipnerf360/garden/images/DSC07961.JPG deleted file mode 100644 index ca33228..0000000 --- a/datasets/mipnerf360/garden/images/DSC07961.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5b4f64f80618f574486ee149a335b2d5d880f3ee77ac39502db81245fe71652e -size 1087212 diff --git a/datasets/mipnerf360/garden/images/DSC07962.JPG b/datasets/mipnerf360/garden/images/DSC07962.JPG deleted file mode 100644 index 64770df..0000000 --- a/datasets/mipnerf360/garden/images/DSC07962.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4cf07f505ea648807e9665dedfb51184ee6f2b3d03bb1812c817dfe3a91c8096 -size 1087670 diff --git a/datasets/mipnerf360/garden/images/DSC07963.JPG b/datasets/mipnerf360/garden/images/DSC07963.JPG deleted file mode 100644 index 351eb18..0000000 --- a/datasets/mipnerf360/garden/images/DSC07963.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5ef582aa27d29a1ff8b4c9723b8460a01ca45e7f1763f40398d23846d9ce02f9 -size 1079488 diff --git a/datasets/mipnerf360/garden/images/DSC07964.JPG b/datasets/mipnerf360/garden/images/DSC07964.JPG deleted file mode 100644 index 2da671d..0000000 --- a/datasets/mipnerf360/garden/images/DSC07964.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7c8469c9ce249841a07bdb5766eb66592fad16169eca0707b500cd16f5f629ec -size 1071884 diff --git a/datasets/mipnerf360/garden/images/DSC07965.JPG b/datasets/mipnerf360/garden/images/DSC07965.JPG deleted file mode 100644 index 89a9609..0000000 --- a/datasets/mipnerf360/garden/images/DSC07965.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:85621fd2be8555b2e1cff45210c354c1167c8d70aae3f1603c986691f6c2753e -size 1056172 diff --git a/datasets/mipnerf360/garden/images/DSC07966.JPG b/datasets/mipnerf360/garden/images/DSC07966.JPG deleted file mode 100644 index 955bff2..0000000 --- a/datasets/mipnerf360/garden/images/DSC07966.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b37f707485b0437db01d716b35dc3fa88b534c9aa2ddf0309a05e1b40d2d6f33 -size 1054367 diff --git a/datasets/mipnerf360/garden/images/DSC07967.JPG b/datasets/mipnerf360/garden/images/DSC07967.JPG deleted file mode 100644 index 0766860..0000000 --- a/datasets/mipnerf360/garden/images/DSC07967.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3782e4cafffd33c8f02e34b1e07c8c1738ab58e1a6835af00c26fa406c7d04b2 -size 1052243 diff --git a/datasets/mipnerf360/garden/images/DSC07968.JPG b/datasets/mipnerf360/garden/images/DSC07968.JPG deleted file mode 100644 index 5bbfc14..0000000 --- a/datasets/mipnerf360/garden/images/DSC07968.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:aa4629f50c6a06f3c29c5b3c22f4c26c621fd59444b713ff83b1ad88862eefca -size 1050700 diff --git a/datasets/mipnerf360/garden/images/DSC07969.JPG b/datasets/mipnerf360/garden/images/DSC07969.JPG deleted file mode 100644 index ced16b9..0000000 --- a/datasets/mipnerf360/garden/images/DSC07969.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f8147174bcf12acbb69a60ea366d3ea7f02e7aab773045d1cac3e713cabfcdb8 -size 1038737 diff --git a/datasets/mipnerf360/garden/images/DSC07970.JPG b/datasets/mipnerf360/garden/images/DSC07970.JPG deleted file mode 100644 index 23d9409..0000000 --- a/datasets/mipnerf360/garden/images/DSC07970.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4547c7a5cb63cdf0c2fc0d6716a2bcd6f606c3bf1534d1d34417a3756bfd7a4b -size 1031401 diff --git a/datasets/mipnerf360/garden/images/DSC07971.JPG b/datasets/mipnerf360/garden/images/DSC07971.JPG deleted file mode 100644 index b0534c5..0000000 --- a/datasets/mipnerf360/garden/images/DSC07971.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:53ed83662b6b2328b8318b081c8cf2001d8de9df5c4e08c35635eba8e5436402 -size 1031594 diff --git a/datasets/mipnerf360/garden/images/DSC07972.JPG b/datasets/mipnerf360/garden/images/DSC07972.JPG deleted file mode 100644 index f57cd6f..0000000 --- a/datasets/mipnerf360/garden/images/DSC07972.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fd2b3605e7add1c3ea3666580d8c6866189e2b68d85ffd074e4d6b3586e93618 -size 1030744 diff --git a/datasets/mipnerf360/garden/images/DSC07973.JPG b/datasets/mipnerf360/garden/images/DSC07973.JPG deleted file mode 100644 index 24344ef..0000000 --- a/datasets/mipnerf360/garden/images/DSC07973.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:eb0d080c41108982992cfeddc189b607c418a985d9326de90ed163d1dc236f11 -size 1048302 diff --git a/datasets/mipnerf360/garden/images/DSC07974.JPG b/datasets/mipnerf360/garden/images/DSC07974.JPG deleted file mode 100644 index aed52ad..0000000 --- a/datasets/mipnerf360/garden/images/DSC07974.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d785678702b1994a86eb3c7e38d1bc02be32b10243e2dccfbc67531f9436a696 -size 1053399 diff --git a/datasets/mipnerf360/garden/images/DSC07975.JPG b/datasets/mipnerf360/garden/images/DSC07975.JPG deleted file mode 100644 index 288cacf..0000000 --- a/datasets/mipnerf360/garden/images/DSC07975.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a79cf22ac88a28f82691804b27fde63523db354be0f7c03fb7977680d43a380b -size 1069383 diff --git a/datasets/mipnerf360/garden/images/DSC07976.JPG b/datasets/mipnerf360/garden/images/DSC07976.JPG deleted file mode 100644 index 0b97487..0000000 --- a/datasets/mipnerf360/garden/images/DSC07976.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2c5c5aae091a47eb0162148b4cd0cad6e12fe8c29f593ee6bb2bcdff72185f44 -size 1069643 diff --git a/datasets/mipnerf360/garden/images/DSC07977.JPG b/datasets/mipnerf360/garden/images/DSC07977.JPG deleted file mode 100644 index a3cf4af..0000000 --- a/datasets/mipnerf360/garden/images/DSC07977.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:de987911623c378420972a1fc6d4289abe57c82963b8c87fa8801d5aa6dbb5dd -size 1066815 diff --git a/datasets/mipnerf360/garden/images/DSC07978.JPG b/datasets/mipnerf360/garden/images/DSC07978.JPG deleted file mode 100644 index efa821b..0000000 --- a/datasets/mipnerf360/garden/images/DSC07978.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cb2c600b54e56df3087571ba6aeb289cfc9c8824a15b64ffb3c4ba229cbe1c83 -size 1066682 diff --git a/datasets/mipnerf360/garden/images/DSC07979.JPG b/datasets/mipnerf360/garden/images/DSC07979.JPG deleted file mode 100644 index ae1c628..0000000 --- a/datasets/mipnerf360/garden/images/DSC07979.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:77ff2cbc06371a375ecebe87ce26e171e275ff698892ad62521ab02a5823c42b -size 1074969 diff --git a/datasets/mipnerf360/garden/images/DSC07980.JPG b/datasets/mipnerf360/garden/images/DSC07980.JPG deleted file mode 100644 index a6f8224..0000000 --- a/datasets/mipnerf360/garden/images/DSC07980.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:191b9aa681552a2070e50e3e7e9f667ad36f54eea03a76376cd976729881eab5 -size 1068596 diff --git a/datasets/mipnerf360/garden/images/DSC07981.JPG b/datasets/mipnerf360/garden/images/DSC07981.JPG deleted file mode 100644 index fdb9704..0000000 --- a/datasets/mipnerf360/garden/images/DSC07981.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0cdbf7e88d6a418923ac1040a62cc83e192da166aa00ea2d872e83dfe1ab0215 -size 1056401 diff --git a/datasets/mipnerf360/garden/images/DSC07982.JPG b/datasets/mipnerf360/garden/images/DSC07982.JPG deleted file mode 100644 index ff0cb63..0000000 --- a/datasets/mipnerf360/garden/images/DSC07982.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a37b085be5f85740ee1cad9974372a2012398edf02a0ef66768c6e37407f9589 -size 1057253 diff --git a/datasets/mipnerf360/garden/images/DSC07983.JPG b/datasets/mipnerf360/garden/images/DSC07983.JPG deleted file mode 100644 index 931fcc3..0000000 --- a/datasets/mipnerf360/garden/images/DSC07983.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:483a484d59e9da20f3b8a97505eddff20c5ff0dda97fe167639cfca3b8d9e14b -size 1075851 diff --git a/datasets/mipnerf360/garden/images/DSC07984.JPG b/datasets/mipnerf360/garden/images/DSC07984.JPG deleted file mode 100644 index 8c4fc05..0000000 --- a/datasets/mipnerf360/garden/images/DSC07984.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ab3a3d84c86ce262361a29fe92a68311096dc14dd5eccc08342145ff1ff75158 -size 1085413 diff --git a/datasets/mipnerf360/garden/images/DSC07985.JPG b/datasets/mipnerf360/garden/images/DSC07985.JPG deleted file mode 100644 index c1e6f52..0000000 --- a/datasets/mipnerf360/garden/images/DSC07985.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:98e51e7edb5dcd692d06c7f6ebba92ff15b86c008c0521852160dd3e21e85ef3 -size 1097026 diff --git a/datasets/mipnerf360/garden/images/DSC07986.JPG b/datasets/mipnerf360/garden/images/DSC07986.JPG deleted file mode 100644 index a64670d..0000000 --- a/datasets/mipnerf360/garden/images/DSC07986.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a416a37dd9b54cfc2313f368d8e8aa55fea9b3b7aa594f3062ae42db1db649e0 -size 1100202 diff --git a/datasets/mipnerf360/garden/images/DSC07987.JPG b/datasets/mipnerf360/garden/images/DSC07987.JPG deleted file mode 100644 index 5883860..0000000 --- a/datasets/mipnerf360/garden/images/DSC07987.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a71ee79ff3c2172f39479f1bb96fac3d3ce4a2e9e28194a879e84419447de4c9 -size 1103885 diff --git a/datasets/mipnerf360/garden/images/DSC07988.JPG b/datasets/mipnerf360/garden/images/DSC07988.JPG deleted file mode 100644 index d0ab81c..0000000 --- a/datasets/mipnerf360/garden/images/DSC07988.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e9625620633791b02fc87f02cc511a8a0e33faeeeaf96d81622f73438dd980db -size 1108579 diff --git a/datasets/mipnerf360/garden/images/DSC07989.JPG b/datasets/mipnerf360/garden/images/DSC07989.JPG deleted file mode 100644 index b09b852..0000000 --- a/datasets/mipnerf360/garden/images/DSC07989.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:169ce5968c558408138bb60d584c795bf36b614b44f7dfbc9bbc4272ce466f5e -size 1085430 diff --git a/datasets/mipnerf360/garden/images/DSC07990.JPG b/datasets/mipnerf360/garden/images/DSC07990.JPG deleted file mode 100644 index e1df393..0000000 --- a/datasets/mipnerf360/garden/images/DSC07990.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d489ffa601f4a7ea28e9e8ba223a85cb79b6b608e88cc4cf22acf97ef1deb715 -size 1075746 diff --git a/datasets/mipnerf360/garden/images/DSC07991.JPG b/datasets/mipnerf360/garden/images/DSC07991.JPG deleted file mode 100644 index 7800283..0000000 --- a/datasets/mipnerf360/garden/images/DSC07991.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3889c651cae45a07f8560aba22cf59821bb439fee1ae80ffaa034b48cf13152c -size 1077056 diff --git a/datasets/mipnerf360/garden/images/DSC07992.JPG b/datasets/mipnerf360/garden/images/DSC07992.JPG deleted file mode 100644 index e9f0ad9..0000000 --- a/datasets/mipnerf360/garden/images/DSC07992.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1262d17aa8dd4c89f54515f8063a35fbffd8d4105a996929f868e4b8762c23bd -size 1080579 diff --git a/datasets/mipnerf360/garden/images/DSC07993.JPG b/datasets/mipnerf360/garden/images/DSC07993.JPG deleted file mode 100644 index 21214ab..0000000 --- a/datasets/mipnerf360/garden/images/DSC07993.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f002a7dc6149bcf7305df68fd5197eb0298db12d33118f46c076cf9829cb1f72 -size 1055795 diff --git a/datasets/mipnerf360/garden/images/DSC07994.JPG b/datasets/mipnerf360/garden/images/DSC07994.JPG deleted file mode 100644 index b0077e6..0000000 --- a/datasets/mipnerf360/garden/images/DSC07994.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f1edebe12a2d343b4cc92126a69d65ce572b49a497fcb51c77500d90152dd27e -size 1072441 diff --git a/datasets/mipnerf360/garden/images/DSC07995.JPG b/datasets/mipnerf360/garden/images/DSC07995.JPG deleted file mode 100644 index 4e66fbc..0000000 --- a/datasets/mipnerf360/garden/images/DSC07995.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4eeb381097ae27ba6106e1742d744d6b382617a86ff1fd92fbc64791a6d7e787 -size 1050588 diff --git a/datasets/mipnerf360/garden/images/DSC07996.JPG b/datasets/mipnerf360/garden/images/DSC07996.JPG deleted file mode 100644 index e5bfe07..0000000 --- a/datasets/mipnerf360/garden/images/DSC07996.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:96bbb4fe572645593abb56bd2e2ba85449f63e6a21206a2cd5b33df657ba83b1 -size 1060342 diff --git a/datasets/mipnerf360/garden/images/DSC07997.JPG b/datasets/mipnerf360/garden/images/DSC07997.JPG deleted file mode 100644 index 185be6f..0000000 --- a/datasets/mipnerf360/garden/images/DSC07997.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1b898efdad181a1c22296c31b8e5aa5a079970d135bc4e93a6a9285821f55b34 -size 1062068 diff --git a/datasets/mipnerf360/garden/images/DSC07998.JPG b/datasets/mipnerf360/garden/images/DSC07998.JPG deleted file mode 100644 index 4e1b6b2..0000000 --- a/datasets/mipnerf360/garden/images/DSC07998.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2f3520a06184113e78dd59fa7458d708de3c7d52a3e5e3c59aedaafa177a8210 -size 1037437 diff --git a/datasets/mipnerf360/garden/images/DSC07999.JPG b/datasets/mipnerf360/garden/images/DSC07999.JPG deleted file mode 100644 index 1dd40db..0000000 --- a/datasets/mipnerf360/garden/images/DSC07999.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:68ad4d5da8788916792f36114c7e279f3158c452cf17dd537536634133d689ea -size 1051609 diff --git a/datasets/mipnerf360/garden/images/DSC08000.JPG b/datasets/mipnerf360/garden/images/DSC08000.JPG deleted file mode 100644 index e6b23fa..0000000 --- a/datasets/mipnerf360/garden/images/DSC08000.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4d996eadde675f384170d4791ea8ecb53e78dad51dbb575e45dc650afbe68642 -size 1040364 diff --git a/datasets/mipnerf360/garden/images/DSC08001.JPG b/datasets/mipnerf360/garden/images/DSC08001.JPG deleted file mode 100644 index 0cca66a..0000000 --- a/datasets/mipnerf360/garden/images/DSC08001.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c76303318f0d542a8d3e42b8a25b5ae0a2e70552cf0862e9a27e75dfb9db515a -size 1043349 diff --git a/datasets/mipnerf360/garden/images/DSC08002.JPG b/datasets/mipnerf360/garden/images/DSC08002.JPG deleted file mode 100644 index f25dd3d..0000000 --- a/datasets/mipnerf360/garden/images/DSC08002.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7705b70c9a710b8b1d379fb67135ae3b17c7d5c5d10ba041aab1e9aabb6448e1 -size 1042169 diff --git a/datasets/mipnerf360/garden/images/DSC08003.JPG b/datasets/mipnerf360/garden/images/DSC08003.JPG deleted file mode 100644 index 1afd81b..0000000 --- a/datasets/mipnerf360/garden/images/DSC08003.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:115c27902121b65f307b9a82436725963bd3fe2336c51e3132c47dd753f65764 -size 1042485 diff --git a/datasets/mipnerf360/garden/images/DSC08004.JPG b/datasets/mipnerf360/garden/images/DSC08004.JPG deleted file mode 100644 index 768bacd..0000000 --- a/datasets/mipnerf360/garden/images/DSC08004.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:994bd3a97e113672114dae3356ac81c21309c5f49d2084b9b11d42af30c4a870 -size 1022934 diff --git a/datasets/mipnerf360/garden/images/DSC08005.JPG b/datasets/mipnerf360/garden/images/DSC08005.JPG deleted file mode 100644 index 201e275..0000000 --- a/datasets/mipnerf360/garden/images/DSC08005.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6cf825a00a7f72e5125ca80caa286a47a0623c4ea55f93bf60a35c09a0ea86df -size 1037767 diff --git a/datasets/mipnerf360/garden/images/DSC08006.JPG b/datasets/mipnerf360/garden/images/DSC08006.JPG deleted file mode 100644 index 10515c1..0000000 --- a/datasets/mipnerf360/garden/images/DSC08006.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b768b8a6a67da0d62f9155e84477c01d207cff04c88e551892de64e8ec3f7d99 -size 1015080 diff --git a/datasets/mipnerf360/garden/images/DSC08007.JPG b/datasets/mipnerf360/garden/images/DSC08007.JPG deleted file mode 100644 index 37f93f4..0000000 --- a/datasets/mipnerf360/garden/images/DSC08007.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b32ffab8634aca0ad3449ff837f01e3440e95f498830bf81b5d50c17a684e45d -size 991738 diff --git a/datasets/mipnerf360/garden/images/DSC08008.JPG b/datasets/mipnerf360/garden/images/DSC08008.JPG deleted file mode 100644 index a985805..0000000 --- a/datasets/mipnerf360/garden/images/DSC08008.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:334e3299a4931c265745737958c0525f610a77ee02d3269fba15dd0df389dd28 -size 1022007 diff --git a/datasets/mipnerf360/garden/images/DSC08009.JPG b/datasets/mipnerf360/garden/images/DSC08009.JPG deleted file mode 100644 index d14eb3c..0000000 --- a/datasets/mipnerf360/garden/images/DSC08009.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:dfb80974c2b9ea23bc9d81c3c62b710004d7089b9c5bd5e930645ff816f6c5cb -size 1032154 diff --git a/datasets/mipnerf360/garden/images/DSC08010.JPG b/datasets/mipnerf360/garden/images/DSC08010.JPG deleted file mode 100644 index acaf64d..0000000 --- a/datasets/mipnerf360/garden/images/DSC08010.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1617916f459e07ebd30833f235e4cc3fea6b554861c7baea3f49e40578475c9a -size 1041129 diff --git a/datasets/mipnerf360/garden/images/DSC08011.JPG b/datasets/mipnerf360/garden/images/DSC08011.JPG deleted file mode 100644 index 89263ac..0000000 --- a/datasets/mipnerf360/garden/images/DSC08011.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3132f66f871447570a25f91f90a7adeed93a81480ca896d840fa843d07068b74 -size 1031462 diff --git a/datasets/mipnerf360/garden/images/DSC08012.JPG b/datasets/mipnerf360/garden/images/DSC08012.JPG deleted file mode 100644 index e2846be..0000000 --- a/datasets/mipnerf360/garden/images/DSC08012.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:53c7e20ad7fa00f82ec41bb6d8caf8343191bc69f35172295c4030307e1db16d -size 1009844 diff --git a/datasets/mipnerf360/garden/images/DSC08013.JPG b/datasets/mipnerf360/garden/images/DSC08013.JPG deleted file mode 100644 index 59b1607..0000000 --- a/datasets/mipnerf360/garden/images/DSC08013.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e8462034c0977015edc265034c9827ca801e07949a7a64b6ee5eb67a5ae32cc7 -size 1026246 diff --git a/datasets/mipnerf360/garden/images/DSC08014.JPG b/datasets/mipnerf360/garden/images/DSC08014.JPG deleted file mode 100644 index 26d4234..0000000 --- a/datasets/mipnerf360/garden/images/DSC08014.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6f153fbeac84cd110e070347f9df1a835b7d456ed9e0a03dc5db9ecc409c88d5 -size 1025338 diff --git a/datasets/mipnerf360/garden/images/DSC08015.JPG b/datasets/mipnerf360/garden/images/DSC08015.JPG deleted file mode 100644 index e3f838e..0000000 --- a/datasets/mipnerf360/garden/images/DSC08015.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b117edcf5f23300e26babd5143a2584438f74138d39927672b54e55237be5761 -size 1031256 diff --git a/datasets/mipnerf360/garden/images/DSC08016.JPG b/datasets/mipnerf360/garden/images/DSC08016.JPG deleted file mode 100644 index 54bff9e..0000000 --- a/datasets/mipnerf360/garden/images/DSC08016.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ed948c7b6c715dff4d6174d35245c0354d6910f7c3badf4656ff0972785ce814 -size 1026601 diff --git a/datasets/mipnerf360/garden/images/DSC08017.JPG b/datasets/mipnerf360/garden/images/DSC08017.JPG deleted file mode 100644 index a1ad694..0000000 --- a/datasets/mipnerf360/garden/images/DSC08017.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:439ba691c94a3635565f82b0e23431b27661d5c5a8ec9ac5df9a913ed1a580ba -size 1026801 diff --git a/datasets/mipnerf360/garden/images/DSC08018.JPG b/datasets/mipnerf360/garden/images/DSC08018.JPG deleted file mode 100644 index c6bfb76..0000000 --- a/datasets/mipnerf360/garden/images/DSC08018.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:070be6d3a082c2b74642331f1e6914100a15b569851fbaf8150bbd7ee9f5f387 -size 1015187 diff --git a/datasets/mipnerf360/garden/images/DSC08019.JPG b/datasets/mipnerf360/garden/images/DSC08019.JPG deleted file mode 100644 index 9ef149e..0000000 --- a/datasets/mipnerf360/garden/images/DSC08019.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2edf384e7035a5cacb3713cc3c347707f1af2ea6eb119abc3ef83e6b5d559cb4 -size 1028632 diff --git a/datasets/mipnerf360/garden/images/DSC08020.JPG b/datasets/mipnerf360/garden/images/DSC08020.JPG deleted file mode 100644 index e97971d..0000000 --- a/datasets/mipnerf360/garden/images/DSC08020.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1c1e4ed5c0a61df292684ad5a3bd0aa59fe21a1edb78277afcc623237db41e0c -size 1037382 diff --git a/datasets/mipnerf360/garden/images/DSC08021.JPG b/datasets/mipnerf360/garden/images/DSC08021.JPG deleted file mode 100644 index 80c4106..0000000 --- a/datasets/mipnerf360/garden/images/DSC08021.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ec85c8aff2dc0d0a797b2faf6bbb437a031c10c3b5abf9bb27f689555a857bcb -size 1040195 diff --git a/datasets/mipnerf360/garden/images/DSC08022.JPG b/datasets/mipnerf360/garden/images/DSC08022.JPG deleted file mode 100644 index 6b19fcf..0000000 --- a/datasets/mipnerf360/garden/images/DSC08022.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0c5d454e6939d79e61fe8025ec6c72572e638ec021dc1902e0a85f6d80fe1429 -size 1046035 diff --git a/datasets/mipnerf360/garden/images/DSC08023.JPG b/datasets/mipnerf360/garden/images/DSC08023.JPG deleted file mode 100644 index e1fe9ec..0000000 --- a/datasets/mipnerf360/garden/images/DSC08023.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a262c6dfe7079e90b62462408b210fcb3a7778d2d9570f2c9cec04dffa6944f2 -size 1073106 diff --git a/datasets/mipnerf360/garden/images/DSC08024.JPG b/datasets/mipnerf360/garden/images/DSC08024.JPG deleted file mode 100644 index 50abbf9..0000000 --- a/datasets/mipnerf360/garden/images/DSC08024.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:49ff49f08daf22a594ee9c16412ed8265396e5c7e2a887cdf2994a494dc37abb -size 1081192 diff --git a/datasets/mipnerf360/garden/images/DSC08025.JPG b/datasets/mipnerf360/garden/images/DSC08025.JPG deleted file mode 100644 index 5bc7a16..0000000 --- a/datasets/mipnerf360/garden/images/DSC08025.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:af02008943596e7064cb7fa88ebbfc63badca179892dc7994bae1904ffb78bed -size 1096069 diff --git a/datasets/mipnerf360/garden/images/DSC08026.JPG b/datasets/mipnerf360/garden/images/DSC08026.JPG deleted file mode 100644 index ae674ef..0000000 --- a/datasets/mipnerf360/garden/images/DSC08026.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:dd124a3b22cf7eab63f7d781a0711507df2a25d5529eaf1eecb5ba5f14542e18 -size 1097945 diff --git a/datasets/mipnerf360/garden/images/DSC08027.JPG b/datasets/mipnerf360/garden/images/DSC08027.JPG deleted file mode 100644 index 565ca28..0000000 --- a/datasets/mipnerf360/garden/images/DSC08027.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5bd624c62178253dc646795cf4532a8aae1c4a3377730345c109163f746c348b -size 1082769 diff --git a/datasets/mipnerf360/garden/images/DSC08028.JPG b/datasets/mipnerf360/garden/images/DSC08028.JPG deleted file mode 100644 index ba94417..0000000 --- a/datasets/mipnerf360/garden/images/DSC08028.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3c876904bbcbd2ed91412e417e036fcb7c2fb320403dd03cdcbbeb559e10a8b8 -size 1069159 diff --git a/datasets/mipnerf360/garden/images/DSC08029.JPG b/datasets/mipnerf360/garden/images/DSC08029.JPG deleted file mode 100644 index 468521b..0000000 --- a/datasets/mipnerf360/garden/images/DSC08029.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6b5f36dfe7794fd1146faef6e2b42e46fa80ab34af6b8a1cf8e6bf3b44b51ab5 -size 1058060 diff --git a/datasets/mipnerf360/garden/images/DSC08030.JPG b/datasets/mipnerf360/garden/images/DSC08030.JPG deleted file mode 100644 index c6aba6e..0000000 --- a/datasets/mipnerf360/garden/images/DSC08030.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:37dfb4be547e013b9e8839f37baf8c686487de57eae80dafd1b4f9110711a725 -size 1053732 diff --git a/datasets/mipnerf360/garden/images/DSC08031.JPG b/datasets/mipnerf360/garden/images/DSC08031.JPG deleted file mode 100644 index 13647f3..0000000 --- a/datasets/mipnerf360/garden/images/DSC08031.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c70f6e53f99a7073ba13e3284ad2d17afdd66b7d952270f671e7891cf5770506 -size 1049577 diff --git a/datasets/mipnerf360/garden/images/DSC08032.JPG b/datasets/mipnerf360/garden/images/DSC08032.JPG deleted file mode 100644 index b047e6b..0000000 --- a/datasets/mipnerf360/garden/images/DSC08032.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:adc3c201f81f383d456ec82f382136e9eaa18f3dab3cd33822a703d4ec500a87 -size 1054662 diff --git a/datasets/mipnerf360/garden/images/DSC08033.JPG b/datasets/mipnerf360/garden/images/DSC08033.JPG deleted file mode 100644 index 2377e92..0000000 --- a/datasets/mipnerf360/garden/images/DSC08033.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7282a11f3daf97dfcf481da2b1d8392c6c6c2f433ce71af23a223a1dec1f8a81 -size 1046248 diff --git a/datasets/mipnerf360/garden/images/DSC08034.JPG b/datasets/mipnerf360/garden/images/DSC08034.JPG deleted file mode 100644 index 9b1e1c3..0000000 --- a/datasets/mipnerf360/garden/images/DSC08034.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e8cc2bbf81e1f2dbe199424dbc59fd70913120c70686d29d79d8fe3bdb74280d -size 1052667 diff --git a/datasets/mipnerf360/garden/images/DSC08035.JPG b/datasets/mipnerf360/garden/images/DSC08035.JPG deleted file mode 100644 index 13f082c..0000000 --- a/datasets/mipnerf360/garden/images/DSC08035.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a2ce5a96fc06a4511c176a38ed7e21f221d23e1e41cd2d425d526b80387c75c3 -size 1056043 diff --git a/datasets/mipnerf360/garden/images/DSC08036.JPG b/datasets/mipnerf360/garden/images/DSC08036.JPG deleted file mode 100644 index e8dcc1e..0000000 --- a/datasets/mipnerf360/garden/images/DSC08036.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b83fa8d6332c38fcb9ef930146b23569867ecc4db08da433659934ab213da038 -size 1051492 diff --git a/datasets/mipnerf360/garden/images/DSC08037.JPG b/datasets/mipnerf360/garden/images/DSC08037.JPG deleted file mode 100644 index 3ec223f..0000000 --- a/datasets/mipnerf360/garden/images/DSC08037.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:35e70d8027210049ab19793561b3ab33e7cf25f9e7430205ccbac273dd5afea5 -size 1034737 diff --git a/datasets/mipnerf360/garden/images/DSC08038.JPG b/datasets/mipnerf360/garden/images/DSC08038.JPG deleted file mode 100644 index 86635e7..0000000 --- a/datasets/mipnerf360/garden/images/DSC08038.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6cb480cd12b09c41b3d31ca62c08f67a5eb7275865e179326ab36c97e8fcd3e6 -size 1041893 diff --git a/datasets/mipnerf360/garden/images/DSC08039.JPG b/datasets/mipnerf360/garden/images/DSC08039.JPG deleted file mode 100644 index 3d6a682..0000000 --- a/datasets/mipnerf360/garden/images/DSC08039.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:540a3f28f3f1ad35c71ab8e24d2a01ca2470d838ba293567ea637c43926204ed -size 1036576 diff --git a/datasets/mipnerf360/garden/images/DSC08040.JPG b/datasets/mipnerf360/garden/images/DSC08040.JPG deleted file mode 100644 index 1f08d45..0000000 --- a/datasets/mipnerf360/garden/images/DSC08040.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2fbc2c727f9b36fc6fd3a4612b76e154c27a801a78d788718a2338599c652359 -size 1043751 diff --git a/datasets/mipnerf360/garden/images/DSC08041.JPG b/datasets/mipnerf360/garden/images/DSC08041.JPG deleted file mode 100644 index 8aa14d0..0000000 --- a/datasets/mipnerf360/garden/images/DSC08041.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7001665b1edd9a9eb9b5455613f18c85c21e13bda1fa9e535ba593a4cfc44a96 -size 1042847 diff --git a/datasets/mipnerf360/garden/images/DSC08042.JPG b/datasets/mipnerf360/garden/images/DSC08042.JPG deleted file mode 100644 index a665197..0000000 --- a/datasets/mipnerf360/garden/images/DSC08042.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:87b5a3b3ac846b8e02aea6350b2ecf1a7db25338837bf26c5a180ddc3aa60080 -size 1042629 diff --git a/datasets/mipnerf360/garden/images/DSC08043.JPG b/datasets/mipnerf360/garden/images/DSC08043.JPG deleted file mode 100644 index 28e149f..0000000 --- a/datasets/mipnerf360/garden/images/DSC08043.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:af863581940dd0feb4aab23ec002c4bca50ff3742c799d54464e37d4a70d1854 -size 1033864 diff --git a/datasets/mipnerf360/garden/images/DSC08044.JPG b/datasets/mipnerf360/garden/images/DSC08044.JPG deleted file mode 100644 index 98ad1c0..0000000 --- a/datasets/mipnerf360/garden/images/DSC08044.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7dd8c53e2bf5293383651636bf2509c5fec6f54624a83e3dd6750fad1a63f055 -size 1025306 diff --git a/datasets/mipnerf360/garden/images/DSC08045.JPG b/datasets/mipnerf360/garden/images/DSC08045.JPG deleted file mode 100644 index e260649..0000000 --- a/datasets/mipnerf360/garden/images/DSC08045.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3d4639e48d7f5c095d3d7d1230a5872a038b547d0ef73acd1026b3abcf10621c -size 1036026 diff --git a/datasets/mipnerf360/garden/images/DSC08046.JPG b/datasets/mipnerf360/garden/images/DSC08046.JPG deleted file mode 100644 index 0d00e82..0000000 --- a/datasets/mipnerf360/garden/images/DSC08046.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3ee4aa0bf6233459e55425b98171be3c9645710b0b5d7bd32c575d8db10b26ff -size 1040984 diff --git a/datasets/mipnerf360/garden/images/DSC08047.JPG b/datasets/mipnerf360/garden/images/DSC08047.JPG deleted file mode 100644 index b2e40b3..0000000 --- a/datasets/mipnerf360/garden/images/DSC08047.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5f6e928a8be942fdeecdd723e6ea58de0daad3aa90d8561e6cf11c988e6ca64d -size 1038386 diff --git a/datasets/mipnerf360/garden/images/DSC08048.JPG b/datasets/mipnerf360/garden/images/DSC08048.JPG deleted file mode 100644 index a32a916..0000000 --- a/datasets/mipnerf360/garden/images/DSC08048.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e82687c2f3dbb217309555b92f0a7ce1edf99fbcef57a9082b92eada1691d67a -size 1032632 diff --git a/datasets/mipnerf360/garden/images/DSC08049.JPG b/datasets/mipnerf360/garden/images/DSC08049.JPG deleted file mode 100644 index 23365bb..0000000 --- a/datasets/mipnerf360/garden/images/DSC08049.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:82a4f3241d178be264b9219e2fb7e1085a4ffb1449e7dc360d2c2e903f3ea5e3 -size 1036263 diff --git a/datasets/mipnerf360/garden/images/DSC08050.JPG b/datasets/mipnerf360/garden/images/DSC08050.JPG deleted file mode 100644 index ec9a33a..0000000 --- a/datasets/mipnerf360/garden/images/DSC08050.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d6c4534d1a213cd43f96ac28ce05daf683930578a30158e4aa092114ca0efc37 -size 1045014 diff --git a/datasets/mipnerf360/garden/images/DSC08051.JPG b/datasets/mipnerf360/garden/images/DSC08051.JPG deleted file mode 100644 index e4a7b10..0000000 --- a/datasets/mipnerf360/garden/images/DSC08051.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a97110c35d73aa545799d4946123fe16e7c7799ae01943ab015f4b46339f4c45 -size 1046685 diff --git a/datasets/mipnerf360/garden/images/DSC08052.JPG b/datasets/mipnerf360/garden/images/DSC08052.JPG deleted file mode 100644 index c5a5dce..0000000 --- a/datasets/mipnerf360/garden/images/DSC08052.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:851e8e643bc4fe99bd95fc1b51ea133899598a51de8838bba5472dd00f22e7a4 -size 1057823 diff --git a/datasets/mipnerf360/garden/images/DSC08053.JPG b/datasets/mipnerf360/garden/images/DSC08053.JPG deleted file mode 100644 index dfdd507..0000000 --- a/datasets/mipnerf360/garden/images/DSC08053.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c5f1d0341f81e2233abfc400973def8945f478ebb7f90e8b3f599ac543d608ba -size 1057375 diff --git a/datasets/mipnerf360/garden/images/DSC08054.JPG b/datasets/mipnerf360/garden/images/DSC08054.JPG deleted file mode 100644 index 123bd5f..0000000 --- a/datasets/mipnerf360/garden/images/DSC08054.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:73e2add65751941259b4f716629b220bd5096d713d6f26a328c96e6edb71055d -size 1060536 diff --git a/datasets/mipnerf360/garden/images/DSC08055.JPG b/datasets/mipnerf360/garden/images/DSC08055.JPG deleted file mode 100644 index 9d379e7..0000000 --- a/datasets/mipnerf360/garden/images/DSC08055.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:eb6e182466a252e9e21cd1a17531686d1a777fe742967e9c8933ec2a872972c7 -size 1067901 diff --git a/datasets/mipnerf360/garden/images/DSC08056.JPG b/datasets/mipnerf360/garden/images/DSC08056.JPG deleted file mode 100644 index 15c3863..0000000 --- a/datasets/mipnerf360/garden/images/DSC08056.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ddb81fe6a5a0487286bc9b2f51276746a9fab6c737dde06c1bf570a7c83f1491 -size 1068351 diff --git a/datasets/mipnerf360/garden/images/DSC08057.JPG b/datasets/mipnerf360/garden/images/DSC08057.JPG deleted file mode 100644 index f5855c8..0000000 --- a/datasets/mipnerf360/garden/images/DSC08057.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ffef6f128c99810327683852cffc041e52b8c45df83d86333417875486cf4418 -size 1072212 diff --git a/datasets/mipnerf360/garden/images/DSC08058.JPG b/datasets/mipnerf360/garden/images/DSC08058.JPG deleted file mode 100644 index d0260fa..0000000 --- a/datasets/mipnerf360/garden/images/DSC08058.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7599a716296375438d52da5bde6846db4fae937075b4c9131e46af01f9ae10fe -size 1070011 diff --git a/datasets/mipnerf360/garden/images/DSC08059.JPG b/datasets/mipnerf360/garden/images/DSC08059.JPG deleted file mode 100644 index d7c3c53..0000000 --- a/datasets/mipnerf360/garden/images/DSC08059.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:08cec98354f27e048cc8e0d1e0b90e774f4c02ebbe1e29fad1a5c4e9ca27eb4a -size 1057516 diff --git a/datasets/mipnerf360/garden/images/DSC08060.JPG b/datasets/mipnerf360/garden/images/DSC08060.JPG deleted file mode 100644 index d30100f..0000000 --- a/datasets/mipnerf360/garden/images/DSC08060.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0684863ea7e80047172dc4d9ae299cdd47c2885a07ec396c085587021fa17704 -size 1057450 diff --git a/datasets/mipnerf360/garden/images/DSC08061.JPG b/datasets/mipnerf360/garden/images/DSC08061.JPG deleted file mode 100644 index bc09e8b..0000000 --- a/datasets/mipnerf360/garden/images/DSC08061.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1b0d50c1bde7607f7f6f013fe07e9c3bf52d88f35828344b166689bf0ce2e4ce -size 1061973 diff --git a/datasets/mipnerf360/garden/images/DSC08062.JPG b/datasets/mipnerf360/garden/images/DSC08062.JPG deleted file mode 100644 index de99320..0000000 --- a/datasets/mipnerf360/garden/images/DSC08062.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:829e788c1d8b305c38280a5e16e8774e7e8e84a8e399a1b871c1aa15a1119197 -size 1060442 diff --git a/datasets/mipnerf360/garden/images/DSC08063.JPG b/datasets/mipnerf360/garden/images/DSC08063.JPG deleted file mode 100644 index 0827092..0000000 --- a/datasets/mipnerf360/garden/images/DSC08063.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f34f8ee531bb8964da4b4984a8336cdefbd3b7d69646832891bc022bfb0a6983 -size 1050980 diff --git a/datasets/mipnerf360/garden/images/DSC08064.JPG b/datasets/mipnerf360/garden/images/DSC08064.JPG deleted file mode 100644 index 61c7e9b..0000000 --- a/datasets/mipnerf360/garden/images/DSC08064.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:19d92b286a2b7951e39549f7e44c8fdfc825f7bb0fae6672dd337ab9e1e2dbe5 -size 1046247 diff --git a/datasets/mipnerf360/garden/images/DSC08065.JPG b/datasets/mipnerf360/garden/images/DSC08065.JPG deleted file mode 100644 index b261f50..0000000 --- a/datasets/mipnerf360/garden/images/DSC08065.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:233463d1bed4c4e41557ccfc0913c5a1c7bd6e956740d8654ca8ef9b75153320 -size 1059218 diff --git a/datasets/mipnerf360/garden/images/DSC08066.JPG b/datasets/mipnerf360/garden/images/DSC08066.JPG deleted file mode 100644 index 467fed0..0000000 --- a/datasets/mipnerf360/garden/images/DSC08066.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f2a65a0d257a37fb4f46426e4ce6bbd64ec7cb9f5235a3bebe57aaf97c02e656 -size 1054704 diff --git a/datasets/mipnerf360/garden/images/DSC08067.JPG b/datasets/mipnerf360/garden/images/DSC08067.JPG deleted file mode 100644 index 7e9641d..0000000 --- a/datasets/mipnerf360/garden/images/DSC08067.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:dde3912c3cf1a46710e4c6578dec28ee96a0e32cc9d869b6223d987d815608a7 -size 1054473 diff --git a/datasets/mipnerf360/garden/images/DSC08068.JPG b/datasets/mipnerf360/garden/images/DSC08068.JPG deleted file mode 100644 index 578d073..0000000 --- a/datasets/mipnerf360/garden/images/DSC08068.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d19df8b8926b57b6ba16ec48e237d1a259c62b512c82ffaea166598e50ffcddd -size 1046023 diff --git a/datasets/mipnerf360/garden/images/DSC08069.JPG b/datasets/mipnerf360/garden/images/DSC08069.JPG deleted file mode 100644 index 7a0ee8d..0000000 --- a/datasets/mipnerf360/garden/images/DSC08069.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:473255e0d6cd249581abcc0db0575253fa09a80092d0cc0b7ec28603c9852c88 -size 1038483 diff --git a/datasets/mipnerf360/garden/images/DSC08070.JPG b/datasets/mipnerf360/garden/images/DSC08070.JPG deleted file mode 100644 index 8d663bb..0000000 --- a/datasets/mipnerf360/garden/images/DSC08070.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b021332654899b3fb5807416b66dde964e0d43e63d6620ccc55a8ae444c1f39a -size 1057346 diff --git a/datasets/mipnerf360/garden/images/DSC08071.JPG b/datasets/mipnerf360/garden/images/DSC08071.JPG deleted file mode 100644 index 61e0258..0000000 --- a/datasets/mipnerf360/garden/images/DSC08071.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f7da1c58aa7c455452b6980e883e5d6139ef05cfdc4fb33a2078da8b1fe81bd2 -size 1052307 diff --git a/datasets/mipnerf360/garden/images/DSC08072.JPG b/datasets/mipnerf360/garden/images/DSC08072.JPG deleted file mode 100644 index 18fe916..0000000 --- a/datasets/mipnerf360/garden/images/DSC08072.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e1fa521241faf9c5eacb82c748f9ae44403a0d947491911db06c645455c11197 -size 1060048 diff --git a/datasets/mipnerf360/garden/images/DSC08073.JPG b/datasets/mipnerf360/garden/images/DSC08073.JPG deleted file mode 100644 index 171ed16..0000000 --- a/datasets/mipnerf360/garden/images/DSC08073.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:14b343674afc3a244b8766329483a4e4d844d8ccb410f45d6f3840c8b04ce1c7 -size 1040465 diff --git a/datasets/mipnerf360/garden/images/DSC08074.JPG b/datasets/mipnerf360/garden/images/DSC08074.JPG deleted file mode 100644 index d53d6ba..0000000 --- a/datasets/mipnerf360/garden/images/DSC08074.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5f0ee0e0c77e16d440e46ff29eec541a3d2f05be803e796667d04fd29312291b -size 1033808 diff --git a/datasets/mipnerf360/garden/images/DSC08075.JPG b/datasets/mipnerf360/garden/images/DSC08075.JPG deleted file mode 100644 index 6e52072..0000000 --- a/datasets/mipnerf360/garden/images/DSC08075.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0d69019f3a2c448b56eef7a7e1a13e0ffa9a5403a8797df078642c9b7e26737a -size 1044629 diff --git a/datasets/mipnerf360/garden/images/DSC08076.JPG b/datasets/mipnerf360/garden/images/DSC08076.JPG deleted file mode 100644 index 038bb2e..0000000 --- a/datasets/mipnerf360/garden/images/DSC08076.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:466cd0d7b1d7295184d4e9ce598cfd4e2cd2aeb77544012cc9aa92c91cf80b78 -size 1057734 diff --git a/datasets/mipnerf360/garden/images/DSC08077.JPG b/datasets/mipnerf360/garden/images/DSC08077.JPG deleted file mode 100644 index f5f3e5f..0000000 --- a/datasets/mipnerf360/garden/images/DSC08077.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ebee0b206c759316efecddc02b3b60516742d42df5533981cd90d84099d28f87 -size 1046134 diff --git a/datasets/mipnerf360/garden/images/DSC08078.JPG b/datasets/mipnerf360/garden/images/DSC08078.JPG deleted file mode 100644 index b1a7a2e..0000000 --- a/datasets/mipnerf360/garden/images/DSC08078.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e4b3580fcbc2ee25d0f59bc42259028e411f6a9e084128bc73bcf797f312e407 -size 1057465 diff --git a/datasets/mipnerf360/garden/images/DSC08079.JPG b/datasets/mipnerf360/garden/images/DSC08079.JPG deleted file mode 100644 index a9d299b..0000000 --- a/datasets/mipnerf360/garden/images/DSC08079.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f1866679d4321fe88f7ba8db76c80c6bfebc3ff5f7c03294031b0362d2b233b3 -size 1055574 diff --git a/datasets/mipnerf360/garden/images/DSC08080.JPG b/datasets/mipnerf360/garden/images/DSC08080.JPG deleted file mode 100644 index b8d542f..0000000 --- a/datasets/mipnerf360/garden/images/DSC08080.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3c48936d51d69df66887d78a2f14970a98de18a89b95fea848230fdc1df236e5 -size 1056396 diff --git a/datasets/mipnerf360/garden/images/DSC08081.JPG b/datasets/mipnerf360/garden/images/DSC08081.JPG deleted file mode 100644 index 0c88af6..0000000 --- a/datasets/mipnerf360/garden/images/DSC08081.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:19f910504134b6186432d6f3a8f9e5a20663ce494bc656ca64234cca365aaf43 -size 1065386 diff --git a/datasets/mipnerf360/garden/images/DSC08082.JPG b/datasets/mipnerf360/garden/images/DSC08082.JPG deleted file mode 100644 index 1bde5e5..0000000 --- a/datasets/mipnerf360/garden/images/DSC08082.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b9b078f9ad9eab3fa92357744ae88becc36fce80a0f0f0e6c8a775b8f6fdf45a -size 1064616 diff --git a/datasets/mipnerf360/garden/images/DSC08083.JPG b/datasets/mipnerf360/garden/images/DSC08083.JPG deleted file mode 100644 index d050e30..0000000 --- a/datasets/mipnerf360/garden/images/DSC08083.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b801f81706d5d51b053ea45fbd047e2dbd84570988fdc9bc1d0124314437138e -size 1062359 diff --git a/datasets/mipnerf360/garden/images/DSC08084.JPG b/datasets/mipnerf360/garden/images/DSC08084.JPG deleted file mode 100644 index d00f25a..0000000 --- a/datasets/mipnerf360/garden/images/DSC08084.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bd8e774116d4357a7cd59b962a416a1c2a821622bcad3ca42d17c9b59bb560f4 -size 1070377 diff --git a/datasets/mipnerf360/garden/images/DSC08085.JPG b/datasets/mipnerf360/garden/images/DSC08085.JPG deleted file mode 100644 index 685fd3f..0000000 --- a/datasets/mipnerf360/garden/images/DSC08085.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5b8fc5bc0e3ee8a25b249361223dff079d865decacbe84cd31a215912310ccf3 -size 1060923 diff --git a/datasets/mipnerf360/garden/images/DSC08086.JPG b/datasets/mipnerf360/garden/images/DSC08086.JPG deleted file mode 100644 index 4bf755c..0000000 --- a/datasets/mipnerf360/garden/images/DSC08086.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:efc2a72fc4627be241eb48e857867cc95b11799a5cf41a75e7b77d15b700dc49 -size 1066066 diff --git a/datasets/mipnerf360/garden/images/DSC08087.JPG b/datasets/mipnerf360/garden/images/DSC08087.JPG deleted file mode 100644 index 4c3a7bb..0000000 --- a/datasets/mipnerf360/garden/images/DSC08087.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:85a3d3d80da280a496e30d24c0081304b894ddfdd204ea1373e6f52731d37e24 -size 1059953 diff --git a/datasets/mipnerf360/garden/images/DSC08088.JPG b/datasets/mipnerf360/garden/images/DSC08088.JPG deleted file mode 100644 index f94e778..0000000 --- a/datasets/mipnerf360/garden/images/DSC08088.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:266a6163da1e71653174ad2553d02ccebf493c70f2d35959b69f5f7e702fca8d -size 1059313 diff --git a/datasets/mipnerf360/garden/images/DSC08089.JPG b/datasets/mipnerf360/garden/images/DSC08089.JPG deleted file mode 100644 index 131f543..0000000 --- a/datasets/mipnerf360/garden/images/DSC08089.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b9680d6ef77495f0d0d21f994642d0d2fa8e25e5fb45dec187e7c59593257b2b -size 1051919 diff --git a/datasets/mipnerf360/garden/images/DSC08090.JPG b/datasets/mipnerf360/garden/images/DSC08090.JPG deleted file mode 100644 index 257a817..0000000 --- a/datasets/mipnerf360/garden/images/DSC08090.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6285ab95b86ebdbc59d9c837ac59e06ae68e7ac6562729ef49dc9a00cbebcf1c -size 1031784 diff --git a/datasets/mipnerf360/garden/images/DSC08091.JPG b/datasets/mipnerf360/garden/images/DSC08091.JPG deleted file mode 100644 index 442eb41..0000000 --- a/datasets/mipnerf360/garden/images/DSC08091.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e9cb5d3bfc01181e15bdf6796e237805dcf6ab2139a2cced77e137192ca5fa09 -size 1043322 diff --git a/datasets/mipnerf360/garden/images/DSC08092.JPG b/datasets/mipnerf360/garden/images/DSC08092.JPG deleted file mode 100644 index 233186e..0000000 --- a/datasets/mipnerf360/garden/images/DSC08092.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:922514a784905023191cd6eecc4cf66b4fbfc3d5377e6e625bbec5710348604b -size 1050352 diff --git a/datasets/mipnerf360/garden/images/DSC08093.JPG b/datasets/mipnerf360/garden/images/DSC08093.JPG deleted file mode 100644 index e7a553e..0000000 --- a/datasets/mipnerf360/garden/images/DSC08093.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:49983211239a3003e687a5fddb798242b00628c1268c035d6ed536f2a119ee2c -size 1039370 diff --git a/datasets/mipnerf360/garden/images/DSC08094.JPG b/datasets/mipnerf360/garden/images/DSC08094.JPG deleted file mode 100644 index 2e212dd..0000000 --- a/datasets/mipnerf360/garden/images/DSC08094.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:853d9b4333fea8accc8a8ea9a7c82e2a47835e679bf0a676f6029e7123fc6a42 -size 1065111 diff --git a/datasets/mipnerf360/garden/images/DSC08095.JPG b/datasets/mipnerf360/garden/images/DSC08095.JPG deleted file mode 100644 index 1d7fb57..0000000 --- a/datasets/mipnerf360/garden/images/DSC08095.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:980b45997ef1cbd6c7210f9419c2a803d480b4fcf1a885247ca78e6a4e310ebe -size 1037673 diff --git a/datasets/mipnerf360/garden/images/DSC08096.JPG b/datasets/mipnerf360/garden/images/DSC08096.JPG deleted file mode 100644 index 4027f24..0000000 --- a/datasets/mipnerf360/garden/images/DSC08096.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:23034982f62d68d70d1f07da6aff4f90f5bec1441e7a7a9005fa276f7c316fea -size 1038640 diff --git a/datasets/mipnerf360/garden/images/DSC08097.JPG b/datasets/mipnerf360/garden/images/DSC08097.JPG deleted file mode 100644 index 18888ce..0000000 --- a/datasets/mipnerf360/garden/images/DSC08097.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8461a92cae9e8bbe09fb29f27bb2e6a4898160de7f1d52d23eeb516d67191bee -size 1027088 diff --git a/datasets/mipnerf360/garden/images/DSC08098.JPG b/datasets/mipnerf360/garden/images/DSC08098.JPG deleted file mode 100644 index 1621e0f..0000000 --- a/datasets/mipnerf360/garden/images/DSC08098.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a599278132cb429de7fa784147c3b54f99b88b564e59102249886a16fc008648 -size 1038674 diff --git a/datasets/mipnerf360/garden/images/DSC08099.JPG b/datasets/mipnerf360/garden/images/DSC08099.JPG deleted file mode 100644 index 64d7f86..0000000 --- a/datasets/mipnerf360/garden/images/DSC08099.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0577122c17dfa0eff8d7ea9c082f08e65f2263a48224cbd1f0f10016645f3771 -size 1040228 diff --git a/datasets/mipnerf360/garden/images/DSC08100.JPG b/datasets/mipnerf360/garden/images/DSC08100.JPG deleted file mode 100644 index 9a5b4f1..0000000 --- a/datasets/mipnerf360/garden/images/DSC08100.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:63f3767ac11bcaa8219337277512f91999aedd69570ca612335f5552cf75143d -size 1044480 diff --git a/datasets/mipnerf360/garden/images/DSC08101.JPG b/datasets/mipnerf360/garden/images/DSC08101.JPG deleted file mode 100644 index 824cef0..0000000 --- a/datasets/mipnerf360/garden/images/DSC08101.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4926d58b49237a4f1c80436938fca76373dd328bfae22ee3ae1026bce69ac813 -size 1034024 diff --git a/datasets/mipnerf360/garden/images/DSC08102.JPG b/datasets/mipnerf360/garden/images/DSC08102.JPG deleted file mode 100644 index 91e4976..0000000 --- a/datasets/mipnerf360/garden/images/DSC08102.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2fcd9814cc49c57a77cf59c949935c0ba47d0bafa13688c2cb09a4a386bb1acd -size 1055602 diff --git a/datasets/mipnerf360/garden/images/DSC08103.JPG b/datasets/mipnerf360/garden/images/DSC08103.JPG deleted file mode 100644 index 518ceb3..0000000 --- a/datasets/mipnerf360/garden/images/DSC08103.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:280705aeb74bc89f1fbe1eead17f830b56a89396ede5f6ac8625f043c84f9469 -size 1056109 diff --git a/datasets/mipnerf360/garden/images/DSC08104.JPG b/datasets/mipnerf360/garden/images/DSC08104.JPG deleted file mode 100644 index 9abd6ee..0000000 --- a/datasets/mipnerf360/garden/images/DSC08104.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7460c78215bc3e2d695bbcd74843f8114b57f7aedc036057a7ce956537166d0b -size 1051756 diff --git a/datasets/mipnerf360/garden/images/DSC08105.JPG b/datasets/mipnerf360/garden/images/DSC08105.JPG deleted file mode 100644 index b096673..0000000 --- a/datasets/mipnerf360/garden/images/DSC08105.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:19871cd035d679142e84386b8b8585d3c275ebb3da67b17a6488a87c4e82fb53 -size 1061192 diff --git a/datasets/mipnerf360/garden/images/DSC08106.JPG b/datasets/mipnerf360/garden/images/DSC08106.JPG deleted file mode 100644 index 5fc5ff2..0000000 --- a/datasets/mipnerf360/garden/images/DSC08106.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f5570a608aa5e3ed640c79fcdb8ba99e67d6894e8276bee4df4328fff593c97c -size 1047493 diff --git a/datasets/mipnerf360/garden/images/DSC08107.JPG b/datasets/mipnerf360/garden/images/DSC08107.JPG deleted file mode 100644 index e3f7c10..0000000 --- a/datasets/mipnerf360/garden/images/DSC08107.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8e8e1d853405638192425bdf5fc308426a56b068d39550ec016197bcccd75f70 -size 1057562 diff --git a/datasets/mipnerf360/garden/images/DSC08108.JPG b/datasets/mipnerf360/garden/images/DSC08108.JPG deleted file mode 100644 index 735613d..0000000 --- a/datasets/mipnerf360/garden/images/DSC08108.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:10656f5e9f7b10a1f7755e4e19159721a27880b348517c64449ea4da37193a15 -size 1050287 diff --git a/datasets/mipnerf360/garden/images/DSC08109.JPG b/datasets/mipnerf360/garden/images/DSC08109.JPG deleted file mode 100644 index 4d01d13..0000000 --- a/datasets/mipnerf360/garden/images/DSC08109.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:de350d5980f6c719a5e94358c91bca0a9e853bb03eb7808d7f86f53a25960e2a -size 1056171 diff --git a/datasets/mipnerf360/garden/images/DSC08110.JPG b/datasets/mipnerf360/garden/images/DSC08110.JPG deleted file mode 100644 index f4af825..0000000 --- a/datasets/mipnerf360/garden/images/DSC08110.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4ee43a5817990303433b0d6da5d9049272ed9c012d9bee9f910bdf322621aba5 -size 1054063 diff --git a/datasets/mipnerf360/garden/images/DSC08111.JPG b/datasets/mipnerf360/garden/images/DSC08111.JPG deleted file mode 100644 index 5d8bf91..0000000 --- a/datasets/mipnerf360/garden/images/DSC08111.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:86e3f5d50e4c9e13af934e3cfcfd65dcc9fdb59d59d53e6786bf1636c629e542 -size 1058837 diff --git a/datasets/mipnerf360/garden/images/DSC08112.JPG b/datasets/mipnerf360/garden/images/DSC08112.JPG deleted file mode 100644 index 0ef6515..0000000 --- a/datasets/mipnerf360/garden/images/DSC08112.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3b772304f63db139b0302df3e06cd96353af05f4666a14efba6caadecbad6b57 -size 1041958 diff --git a/datasets/mipnerf360/garden/images/DSC08113.JPG b/datasets/mipnerf360/garden/images/DSC08113.JPG deleted file mode 100644 index 7ded7d1..0000000 --- a/datasets/mipnerf360/garden/images/DSC08113.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f30a816623a02d3bdaf1103d3b344e58c68d23f6a8548ee6507506702bab204d -size 1039665 diff --git a/datasets/mipnerf360/garden/images/DSC08114.JPG b/datasets/mipnerf360/garden/images/DSC08114.JPG deleted file mode 100644 index c5aa682..0000000 --- a/datasets/mipnerf360/garden/images/DSC08114.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8b139e3aca6dd99b79a2732e333dda362d17f0f363d42690bd8ae577b358b2c5 -size 1068665 diff --git a/datasets/mipnerf360/garden/images/DSC08115.JPG b/datasets/mipnerf360/garden/images/DSC08115.JPG deleted file mode 100644 index 767dc7e..0000000 --- a/datasets/mipnerf360/garden/images/DSC08115.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:322695f5d88e6b3c46951c5d3bd3e226d46c283415b3d33db52b54e7d8f5e488 -size 1066577 diff --git a/datasets/mipnerf360/garden/images/DSC08116.JPG b/datasets/mipnerf360/garden/images/DSC08116.JPG deleted file mode 100644 index 2a7aed5..0000000 --- a/datasets/mipnerf360/garden/images/DSC08116.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9767e93d1855b33badae1e3b41ee65ba6e8074d7ff1d22d8d8a5965b977350cf -size 1064531 diff --git a/datasets/mipnerf360/garden/images/DSC08117.JPG b/datasets/mipnerf360/garden/images/DSC08117.JPG deleted file mode 100644 index ff36839..0000000 --- a/datasets/mipnerf360/garden/images/DSC08117.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:80692c8267231d63aaa6160ff2641050cba170bfa8fabd2536c7b30350a42350 -size 1028128 diff --git a/datasets/mipnerf360/garden/images/DSC08118.JPG b/datasets/mipnerf360/garden/images/DSC08118.JPG deleted file mode 100644 index 883a5e5..0000000 --- a/datasets/mipnerf360/garden/images/DSC08118.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:218a860435f737f1d2ceb6e75f040bb17bc8f7e484a1cd0379b2e38a3c1bed85 -size 1050765 diff --git a/datasets/mipnerf360/garden/images/DSC08119.JPG b/datasets/mipnerf360/garden/images/DSC08119.JPG deleted file mode 100644 index 2fe9948..0000000 --- a/datasets/mipnerf360/garden/images/DSC08119.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c5b22c665c7be936bfe0e9c50aacc0b11205c309744354b35d8f7e73c61cfe45 -size 1049460 diff --git a/datasets/mipnerf360/garden/images/DSC08120.JPG b/datasets/mipnerf360/garden/images/DSC08120.JPG deleted file mode 100644 index 7404df8..0000000 --- a/datasets/mipnerf360/garden/images/DSC08120.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:58bc0bc20ad6a5286f9e87de2b0adb43df46f3ee5e59b0737e1dedda2142d07f -size 1061805 diff --git a/datasets/mipnerf360/garden/images/DSC08121.JPG b/datasets/mipnerf360/garden/images/DSC08121.JPG deleted file mode 100644 index 1fa6ca5..0000000 --- a/datasets/mipnerf360/garden/images/DSC08121.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2be0e2e6a6707df1a3624aeffbb8a79dc6ecc2feca1824493a92bfb911a6a3c3 -size 1015365 diff --git a/datasets/mipnerf360/garden/images/DSC08122.JPG b/datasets/mipnerf360/garden/images/DSC08122.JPG deleted file mode 100644 index d0f1bf1..0000000 --- a/datasets/mipnerf360/garden/images/DSC08122.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:07435c7944f5c8f593fd39c07eefb7dd0940fe23cc4fd405498472e27c908180 -size 1013990 diff --git a/datasets/mipnerf360/garden/images/DSC08123.JPG b/datasets/mipnerf360/garden/images/DSC08123.JPG deleted file mode 100644 index 608ba48..0000000 --- a/datasets/mipnerf360/garden/images/DSC08123.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:adc3b400d9a74ad09aba4c959fc0dbef3a1136f591657e0ec3bb45afc9ed26fd -size 1060840 diff --git a/datasets/mipnerf360/garden/images/DSC08124.JPG b/datasets/mipnerf360/garden/images/DSC08124.JPG deleted file mode 100644 index 05908d3..0000000 --- a/datasets/mipnerf360/garden/images/DSC08124.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f670776534052e8b49a918be7c8e742a71204e5ef205a7df5abe4ba9c8c57491 -size 1048855 diff --git a/datasets/mipnerf360/garden/images/DSC08125.JPG b/datasets/mipnerf360/garden/images/DSC08125.JPG deleted file mode 100644 index bfca976..0000000 --- a/datasets/mipnerf360/garden/images/DSC08125.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:312448623026e135120a9813bf4c20c6a6a8754047e013d31bb2438c4cfd1e91 -size 1059005 diff --git a/datasets/mipnerf360/garden/images/DSC08126.JPG b/datasets/mipnerf360/garden/images/DSC08126.JPG deleted file mode 100644 index 6834149..0000000 --- a/datasets/mipnerf360/garden/images/DSC08126.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6a4810a5aaa44dd70caf801dff20c242b99f2d964bda756e1ee30666c8c6058d -size 1010480 diff --git a/datasets/mipnerf360/garden/images/DSC08127.JPG b/datasets/mipnerf360/garden/images/DSC08127.JPG deleted file mode 100644 index 6ae6677..0000000 --- a/datasets/mipnerf360/garden/images/DSC08127.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e0f75f1dc6e1b9f6c8ff73585ed4a519cbc37659c2914d87baa54967ab60f2b1 -size 1050421 diff --git a/datasets/mipnerf360/garden/images/DSC08128.JPG b/datasets/mipnerf360/garden/images/DSC08128.JPG deleted file mode 100644 index 8c96377..0000000 --- a/datasets/mipnerf360/garden/images/DSC08128.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:22036c2b7a236d97c09b6f2bdf1126e12673df924f723da72e2f8c72ab1060e5 -size 1064240 diff --git a/datasets/mipnerf360/garden/images/DSC08129.JPG b/datasets/mipnerf360/garden/images/DSC08129.JPG deleted file mode 100644 index 86b9c01..0000000 --- a/datasets/mipnerf360/garden/images/DSC08129.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f20994c3d15fb8e3e48fc63a44acd8f26bc97d34aad47adea2da70d5dbb43e5d -size 1070248 diff --git a/datasets/mipnerf360/garden/images/DSC08130.JPG b/datasets/mipnerf360/garden/images/DSC08130.JPG deleted file mode 100644 index f608645..0000000 --- a/datasets/mipnerf360/garden/images/DSC08130.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:816a77b29d5b587fafdc4769331264506d5d449d4970799d8ee54974ba82a5d0 -size 1059050 diff --git a/datasets/mipnerf360/garden/images/DSC08131.JPG b/datasets/mipnerf360/garden/images/DSC08131.JPG deleted file mode 100644 index a5b6d34..0000000 --- a/datasets/mipnerf360/garden/images/DSC08131.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9255bec24c5ec2427b43b149d1c36aceb9eda898666cdd3452a5610368b79902 -size 1047934 diff --git a/datasets/mipnerf360/garden/images/DSC08132.JPG b/datasets/mipnerf360/garden/images/DSC08132.JPG deleted file mode 100644 index f537d50..0000000 --- a/datasets/mipnerf360/garden/images/DSC08132.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c521b8195a50fbd1bb0f99b1a5fce0e9391ed64814224090b1847af4941bd6a3 -size 1060820 diff --git a/datasets/mipnerf360/garden/images/DSC08133.JPG b/datasets/mipnerf360/garden/images/DSC08133.JPG deleted file mode 100644 index 1f34e04..0000000 --- a/datasets/mipnerf360/garden/images/DSC08133.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:199d5f93cd299a530a87558f3bfd788bade76985d51e7e11c78ab9c2cd04b733 -size 1062516 diff --git a/datasets/mipnerf360/garden/images/DSC08134.JPG b/datasets/mipnerf360/garden/images/DSC08134.JPG deleted file mode 100644 index d8f0ab0..0000000 --- a/datasets/mipnerf360/garden/images/DSC08134.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:469195504b9b827c953a32033800917d2dcc03d662f0d2f67fcd8e3c035c81c8 -size 1030778 diff --git a/datasets/mipnerf360/garden/images/DSC08135.JPG b/datasets/mipnerf360/garden/images/DSC08135.JPG deleted file mode 100644 index cd82573..0000000 --- a/datasets/mipnerf360/garden/images/DSC08135.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:909a2498d45ca3008c25db4775ab8f75bdb254cd3de539a118b0898d0c3b836e -size 1067285 diff --git a/datasets/mipnerf360/garden/images/DSC08136.JPG b/datasets/mipnerf360/garden/images/DSC08136.JPG deleted file mode 100644 index e0bea23..0000000 --- a/datasets/mipnerf360/garden/images/DSC08136.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e69b23bb346cb149b355984b3159935600ababf28a812872b4a915101cfdf89f -size 999999 diff --git a/datasets/mipnerf360/garden/images/DSC08137.JPG b/datasets/mipnerf360/garden/images/DSC08137.JPG deleted file mode 100644 index 3728f93..0000000 --- a/datasets/mipnerf360/garden/images/DSC08137.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3a584046503b07321b1d6e844ac6d878cca9380a5383d1d02d597a99a62f732b -size 1065563 diff --git a/datasets/mipnerf360/garden/images/DSC08138.JPG b/datasets/mipnerf360/garden/images/DSC08138.JPG deleted file mode 100644 index b50d9a7..0000000 --- a/datasets/mipnerf360/garden/images/DSC08138.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:efdaaf3930496699b35baeb4e8ac53c257c8bf442fc9ea3b0381adb628b1d4f1 -size 1068776 diff --git a/datasets/mipnerf360/garden/images/DSC08139.JPG b/datasets/mipnerf360/garden/images/DSC08139.JPG deleted file mode 100644 index bb2198e..0000000 --- a/datasets/mipnerf360/garden/images/DSC08139.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b89d060386e62c116a36bd37cc442a1f9264e5dc4de4c275101609429e67dd52 -size 1058262 diff --git a/datasets/mipnerf360/garden/images/DSC08140.JPG b/datasets/mipnerf360/garden/images/DSC08140.JPG deleted file mode 100644 index 1c16fcb..0000000 --- a/datasets/mipnerf360/garden/images/DSC08140.JPG +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:33092a1f1d2ae547ae4bfdc92b472f6db5536ae87121069d1cef4cb3e38e118a -size 1055493 diff --git a/datasets/mipnerf360/garden/sparse/0/cameras.bin b/datasets/mipnerf360/garden/sparse/0/cameras.bin deleted file mode 100644 index 2f7a2b9..0000000 --- a/datasets/mipnerf360/garden/sparse/0/cameras.bin +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8472cc1a821db10de24155a120abfacec9268c39b562b924f8993ca0e0f299e4 -size 64 diff --git a/datasets/mipnerf360/garden/sparse/0/images.bin b/datasets/mipnerf360/garden/sparse/0/images.bin deleted file mode 100644 index 404f961..0000000 --- a/datasets/mipnerf360/garden/sparse/0/images.bin +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9490fc63d509ab5bcff3d85771402dd6c623809dbf0fa250a05a29bb3cea8c41 -size 47011789 diff --git a/datasets/mipnerf360/garden/sparse/0/points3D.bin b/datasets/mipnerf360/garden/sparse/0/points3D.bin deleted file mode 100644 index 64135fe..0000000 --- a/datasets/mipnerf360/garden/sparse/0/points3D.bin +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:be52e343ad059bbfce00e2b22d397de75dad2d6f1ac7cf359b1b926c2b0482c8 -size 14835394 diff --git a/demo/Package.swift b/demo/Package.swift deleted file mode 100644 index 2a1d608..0000000 --- a/demo/Package.swift +++ /dev/null @@ -1,20 +0,0 @@ -// swift-tools-version: 6.0 - -import PackageDescription - -let package = Package( - name: "DemoApp", - platforms: [.macOS(.v15)], - dependencies: [ - .package(path: "../swift"), - ], - targets: [ - .executableTarget( - name: "DemoApp", - dependencies: [ - .product(name: "Msplat", package: "swift"), - ], - path: "Sources" - ), - ] -) diff --git a/demo/Sources/DemoApp.swift b/demo/Sources/DemoApp.swift deleted file mode 100644 index a68bf7f..0000000 --- a/demo/Sources/DemoApp.swift +++ /dev/null @@ -1,456 +0,0 @@ -import SwiftUI -import Msplat -import MsplatCore -import AppKit -import QuartzCore -import Accelerate - -// MARK: - Pixel conversion - -/// Reusable RGBA buffer to avoid per-frame allocation -final class RGBABuffer { - var bytes: UnsafeMutablePointer - var capacity: Int - - init() { bytes = .allocate(capacity: 0); capacity = 0 } - deinit { bytes.deallocate() } - - func ensure(_ count: Int) { - guard count > capacity else { return } - bytes.deallocate() - bytes = .allocate(capacity: count) - capacity = count - } -} - -nonisolated(unsafe) let rgbaBuffer = RGBABuffer() - -/// Build CGImage directly from C pixel buffer. Reuses a persistent RGBA buffer. -func pixelBufferToCGImage(_ buf: MsplatPixelBuffer) -> CGImage { - let w = Int(buf.width), h = Int(buf.height) - let n = w * h - let src = buf.data! - let needed = n * 4 - rgbaBuffer.ensure(needed) - let dst = rgbaBuffer.bytes - - // Single-pass RGB float → RGBA uint8 - for i in 0.. NSImage { - let cg = pixelBufferToCGImage(buf) - return NSImage(cgImage: cg, size: NSSize(width: cg.width, height: cg.height)) -} - -// MARK: - Vector helpers - -func normalize(_ v: (Float, Float, Float)) -> (Float, Float, Float) { - let len = sqrt(v.0*v.0 + v.1*v.1 + v.2*v.2) - guard len > 1e-8 else { return (0, 1, 0) } - return (v.0/len, v.1/len, v.2/len) -} - -func cross(_ a: (Float, Float, Float), _ b: (Float, Float, Float)) -> (Float, Float, Float) { - (a.1*b.2 - a.2*b.1, a.2*b.0 - a.0*b.2, a.0*b.1 - a.1*b.0) -} - -// MARK: - Orbit camera - -/// Build a cam-to-world matrix (4x4 row-major, OpenGL: Y-up, Z-back) -/// looking from `eye` toward `target` with a given world-up hint. -func lookAtCamToWorld(eye: (Float, Float, Float), target: (Float, Float, Float), - up: (Float, Float, Float)) -> [Float] { - // Forward = normalize(eye - target) (camera looks along -Z in OpenGL) - let f = normalize((eye.0 - target.0, eye.1 - target.1, eye.2 - target.2)) - // Right = normalize(up × forward) - let r = normalize(cross(up, f)) - // True up = forward × right - let u = cross(f, r) - - // Row-major cam-to-world: columns are right, up, forward; last column is eye position - return [ - r.0, u.0, f.0, eye.0, - r.1, u.1, f.1, eye.1, - r.2, u.2, f.2, eye.2, - 0, 0, 0, 1, - ] -} - -struct OrbitParams { - var lookAt: (Float, Float, Float) // where the camera looks (scene focus) - var eyeCenter: (Float, Float, Float) // center of the orbit circle (above lookAt) - var radius: Float - var up: (Float, Float, Float) // scene up direction (from camera average) - var tangent1: (Float, Float, Float) // ground-plane basis vector 1 - var tangent2: (Float, Float, Float) // ground-plane basis vector 2 -} - -/// Compute orbit parameters from dataset camera poses. -/// Derives the ground plane from the average camera up-vector (column 1 of camToWorld). -/// The orbit center is where cameras are looking, not where they are. -func computeOrbitParams(_ poses: [[Float]]) -> OrbitParams { - let n = Float(poses.count) - - // Camera positions and up vectors - var cx: Float = 0, cy: Float = 0, cz: Float = 0 - var ux: Float = 0, uy: Float = 0, uz: Float = 0 - - for p in poses { - cx += p[3]; cy += p[7]; cz += p[11] - ux += p[1]; uy += p[5]; uz += p[9] - } - cx /= n; cy /= n; cz /= n - let up = normalize((ux, uy, uz)) - - // Orbit radius = average distance from centroid projected onto ground plane - var totalR: Float = 0 - for p in poses { - let dx = p[3] - cx, dy = p[7] - cy, dz = p[11] - cz - let dot = dx*up.0 + dy*up.1 + dz*up.2 - let gx = dx - dot*up.0, gy = dy - dot*up.1, gz = dz - dot*up.2 - totalR += sqrt(gx*gx + gy*gy + gz*gz) - } - let radius = totalR / n * 1.3 - - // Look-at target: below camera centroid (where the scene centerpiece is) - let lookAt = ( - cx - up.0 * radius * 0.3, - cy - up.1 * radius * 0.3, - cz - up.2 * radius * 0.3 - ) - // Eye orbits slightly above the camera centroid so it looks down at the scene - let eyeCenter = ( - cx - up.0 * radius * 0.07, - cy - up.1 * radius * 0.07, - cz - up.2 * radius * 0.07 - ) - - // Build two tangent vectors spanning the ground plane - let absX = abs(up.0), absY = abs(up.1), absZ = abs(up.2) - let seed: (Float, Float, Float) - if absX <= absY && absX <= absZ { seed = (1, 0, 0) } - else if absY <= absZ { seed = (0, 1, 0) } - else { seed = (0, 0, 1) } - - let t1 = normalize(cross(up, seed)) - let t2 = cross(up, t1) - - return OrbitParams(lookAt: lookAt, eyeCenter: eyeCenter, radius: radius, - up: up, tangent1: t1, tangent2: t2) -} - -// MARK: - Engine - -@MainActor -final class Engine: ObservableObject { - @Published var image: NSImage? - @Published var iteration: Int = 0 - @Published var totalIterations: Int = 2_000 - @Published var splatCount: Int = 0 - @Published var msPerStep: Float = 0 - @Published var fps: Float = 0 - @Published var phase: Phase = .countdown(5) - @Published var countdown: Int = 5 - - enum Phase: Equatable { - case countdown(Int), loading, training, orbiting - } - - func start(datasetPath: String) { - phase = .countdown(5) - countdown = 5 - - // 5-second countdown on main thread - Task { @MainActor in - for s in stride(from: 4, through: 0, by: -1) { - try? await Task.sleep(for: .seconds(1)) - self.countdown = s - self.phase = .countdown(s) - } - self.phase = .loading - self.beginTraining(datasetPath: datasetPath) - } - } - - private func beginTraining(datasetPath: String) { - Thread.detachNewThread { [weak self] in - guard let self else { return } - - // Use C API directly to avoid extra copies in the hot path - let ds = msplat_dataset_create(datasetPath, 1.0, false, 8)! - let numCameras = Int(msplat_dataset_num_train(ds)) - - var config = msplat_default_config() - config.iterations = 2_000 - config.numDownscales = 0 - config.bgColor = (0, 0, 0) - let trainer = msplat_trainer_create(ds, config)! - - DispatchQueue.main.async { self.phase = .training } - - // Phase 1: Training - var batchStart = CACurrentMediaTime() - var batchSteps = 0 - - for i in 0..<2_000 { - let stats = msplat_trainer_step(trainer) - batchSteps += 1 - - if i % 25 == 0 || i == 1_999 { - let batchEnd = CACurrentMediaTime() - let avgMs = Float((batchEnd - batchStart) / Double(batchSteps) * 1000.0) - - let buf = msplat_trainer_render(trainer, 0, false) - let img = pixelBufferToNSImage(buf) - free(buf.data) - let iter = stats.iteration - let count = stats.splatCount - DispatchQueue.main.async { - self.image = img - self.iteration = Int(iter) - self.splatCount = Int(count) - self.msPerStep = avgMs - } - - batchStart = CACurrentMediaTime() - batchSteps = 0 - } - } - - let finalCount = Int(msplat_trainer_splat_count(trainer)) - DispatchQueue.main.async { - self.splatCount = finalCount - self.phase = .orbiting - } - - // Phase 2: Smooth circular orbit — use C API for zero-copy render - var poses = [[Float]]() - for i in 0...allocate(capacity: Int(imgW * imgH) * 4) - // Pre-fill alpha channel - for i in 0..= 0.5 { - currentFps = Float(frameCount) / Float(dt) - frameCount = 0 - fpsTimer = now - } - - let fpsVal = currentFps - DispatchQueue.main.async { - self.image = img - if fpsVal > 0 { self.fps = fpsVal } - } - } - } - } -} - -// MARK: - UI - -struct ContentView: View { - @StateObject private var engine = Engine() - let datasetPath: String - - var body: some View { - ZStack { - Color.black - - if let img = engine.image { - Image(nsImage: img) - .resizable() - .aspectRatio(contentMode: .fit) - } - - if case .countdown(let s) = engine.phase { - Text("\(s)") - .font(.system(size: 120, weight: .bold, design: .monospaced)) - .foregroundStyle(.white.opacity(0.8)) - } - - if engine.phase == .loading { - VStack { - ProgressView() - .scaleEffect(1.5) - .tint(.white) - Text("Loading dataset...") - .foregroundStyle(.white) - .padding(.top, 8) - } - } - - if engine.phase == .training || engine.phase == .orbiting { - VStack(spacing: 0) { - Spacer() - - // Full-width progress bar during training - if engine.phase == .training { - progressBar - } - - // Full-width stats bar - statsBar - } - } - - // Prominent FPS counter top-right during orbit - if engine.phase == .orbiting { - VStack { - HStack { - Spacer() - Text(String(format: "%.0f FPS", engine.fps)) - .font(.system(size: 36, weight: .bold, design: .monospaced)) - .foregroundStyle(.white) - .padding(.horizontal, 20) - .padding(.vertical, 10) - .background(.black.opacity(0.7)) - .cornerRadius(12) - .padding(20) - } - Spacer() - } - } - } - .onAppear { - engine.start(datasetPath: datasetPath) - } - } - - private var progressBar: some View { - let progress = Double(engine.iteration) / Double(engine.totalIterations) - return GeometryReader { geo in - ZStack(alignment: .leading) { - Rectangle() - .fill(.white.opacity(0.15)) - Rectangle() - .fill( - LinearGradient( - colors: [Color(red: 0.2, green: 0.5, blue: 1.0), - Color(red: 0.0, green: 0.9, blue: 0.7)], - startPoint: .leading, endPoint: .trailing - ) - ) - .frame(width: geo.size.width * progress) - .animation(.linear(duration: 0.1), value: progress) - } - } - .frame(height: 6) - } - - private var statsBar: some View { - HStack(spacing: 32) { - switch engine.phase { - case .countdown, .loading: - EmptyView() - case .training: - Text("step \(engine.iteration) / \(engine.totalIterations)") - Text("\(fmtCount(engine.splatCount)) splats") - Text(String(format: "%.1f ms/step", engine.msPerStep)) - case .orbiting: - Text("\(fmtCount(engine.splatCount)) splats") - Text(String(format: "%.0f fps", engine.fps)) - } - } - .font(.system(size: 18, weight: .semibold, design: .monospaced)) - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(.black.opacity(0.85)) - } - - private func fmtCount(_ n: Int) -> String { - if n >= 1_000_000 { return String(format: "%.2fM", Double(n) / 1_000_000) } - if n >= 1_000 { return String(format: "%.0fK", Double(n) / 1_000) } - return "\(n)" - } -} - -// MARK: - App entry - -class AppDelegate: NSObject, NSApplicationDelegate { - func applicationDidFinishLaunching(_ notification: Notification) { - NSApp.setActivationPolicy(.regular) - NSApp.activate(ignoringOtherApps: true) - } -} - -@main -struct DemoApp: App { - @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate - let datasetPath: String - - init() { - let args = CommandLine.arguments - datasetPath = args.count > 1 ? args[1] : "datasets/mipnerf360/garden" - } - - var body: some Scene { - WindowGroup { - ContentView(datasetPath: datasetPath) - .frame(minWidth: 960, minHeight: 640) - } - .defaultSize(width: 1280, height: 720) - } -} diff --git a/examples/train_garden.py b/examples/train_garden.py deleted file mode 100644 index 5ef74b9..0000000 --- a/examples/train_garden.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Train 3DGS on mipnerf360 garden — matches msplat CLI output.""" - -import msplat - -dataset = msplat.load_dataset( - "datasets/mipnerf360/garden", - downscale_factor=1.0, - eval_mode=True, - test_every=8, -) -print(f"Loaded {dataset.num_train} train, {dataset.num_test} test cameras") - -config = msplat.TrainingConfig( - iterations=7000, - num_downscales=0, - downscale_factor=1.0, -) - -trainer = msplat.GaussianTrainer(dataset, config) - -def on_step(stats): - print( - f"step={stats.iteration:>6} " - f"splats={stats.splat_count:>8,} " - f"ms={stats.ms_per_step:.1f}" - ) - -trainer.train(on_step, callback_every=100) - -trainer.export_ply("garden.ply") -print(f"\nSaved garden.ply ({trainer.splat_count:,} gaussians)") - -metrics = trainer.evaluate() -print(f"\n=== Evaluation ({metrics['num_test']} test views) ===") -print(f" PSNR: {metrics['psnr']:.4f}") -print(f" SSIM: {metrics['ssim']:.4f}") -print(f" L1: {metrics['l1']:.4f}") -print(f" Gaussians: {metrics['num_gaussians']:,}") - -msplat.cleanup() diff --git a/package.json b/package.json new file mode 100644 index 0000000..41b65d5 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "msplat-internal-site", + "private": true, + "type": "module", + "scripts": { + "web": "node web/server.mjs", + "worker": "node web/worker.mjs", + "dev": "node web/dev.mjs", + "test": "node --test tests/web/*.test.mjs" + } +} diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 2534d1a..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,39 +0,0 @@ -[build-system] -requires = ["scikit-build-core>=0.10", "nanobind>=2.0"] -build-backend = "scikit_build_core.build" - -[project] -name = "msplat" -version = "1.1.2" -description = "Metal-accelerated 3D Gaussian Splatting for Apple Silicon" -readme = "README.md" -license = "Apache-2.0" -requires-python = ">=3.12" -dependencies = ["numpy"] -authors = [{ name = "Rayan Hatout" }] -keywords = ["gaussian-splatting", "3dgs", "metal", "apple-silicon", "nerf"] -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Science/Research", - "Operating System :: MacOS", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Topic :: Scientific/Engineering :: Artificial Intelligence", - "Topic :: Multimedia :: Graphics :: 3D Rendering", -] - -[project.urls] -Homepage = "https://github.com/rayanht/msplat" -Repository = "https://github.com/rayanht/msplat" -Issues = "https://github.com/rayanht/msplat/issues" - -[project.optional-dependencies] -cli = ["tyro"] - -[project.scripts] -msplat-train = "msplat.cli:main" - -[tool.scikit-build] -cmake.build-type = "Release" -cmake.args = ["-DMSPLAT_BUILD_PYTHON=ON"] -wheel.packages = ["python/msplat"] diff --git a/python/bindings.cpp b/python/bindings.cpp deleted file mode 100644 index 290eab3..0000000 --- a/python/bindings.cpp +++ /dev/null @@ -1,418 +0,0 @@ -#include -#include -#include -#include -#include -#include - -#include "model.hpp" -#include "input_data.hpp" -#include "msplat.hpp" -#include "ssim.hpp" - -#include -#include -#include -#include -#include - -namespace nb = nanobind; -using namespace nb::literals; -namespace fs = std::filesystem; - -// ── TrainingConfig ────────────────────────────────────────────────────────── - -struct TrainingConfig { - int iterations = 30000; - int sh_degree = 3; - int sh_degree_interval = 1000; - float ssim_weight = 0.2f; - int num_downscales = 2; - int resolution_schedule = 3000; - int refine_every = 100; - int warmup_length = 500; - int reset_alpha_every = 30; - float densify_grad_thresh = 0.0002f; - float densify_size_thresh = 0.01f; - int stop_screen_size_at = 4000; - float split_screen_size = 0.05f; - bool keep_crs = false; - float downscale_factor = 1.0f; - std::string output = "splat.ply"; - int save_every = -1; - // Magenta default — high contrast against typical scenes, makes - // under-reconstructed regions obvious during training. - std::vector bg_color = {0.6130f, 0.0101f, 0.3984f}; -}; - -// ── TrainingStats ─────────────────────────────────────────────────────────── - -struct TrainingStats { - int iteration; - int splat_count; - float ms_per_step; -}; - -// ── Dataset ───────────────────────────────────────────────────────────────── - -class Dataset { -public: - InputData data; - std::vector train_cams; - std::vector test_cams; - - Dataset(const std::string &path, float downscale_factor, - bool eval_mode, int test_every) - { - data = inputDataFromX(path); - - // Load images (parallel) - for (auto &cam : data.cameras) { - cam.loadImage(downscale_factor); - } - - if (eval_mode) { - auto split = data.splitTrainTest(test_every); - train_cams = std::get<0>(split); - test_cams = std::get<1>(split); - } else { - auto t = data.getCameras(false); - train_cams = std::get<0>(t); - } - } - - size_t num_train() const { return train_cams.size(); } - size_t num_test() const { return test_cams.size(); } - - // Get camera-to-world pose (4x4 row-major) as numpy array - nb::object camera_pose(int index) { - if (index < 0 || index >= (int)train_cams.size()) - throw std::runtime_error("Camera index out of range"); - float *buf = new float[16]; - memcpy(buf, train_cams[index].camToWorld, 16 * sizeof(float)); - nb::capsule deleter(buf, [](void *p) noexcept { delete[] static_cast(p); }); - size_t shape[2] = {4, 4}; - return nb::cast(nb::ndarray(buf, 2, shape, deleter)); - } -}; - -// ── GaussianTrainer ───────────────────────────────────────────────────────── - -class GaussianTrainer { -public: - std::unique_ptr model; - TrainingConfig config; - Dataset* dataset_ptr = nullptr; // non-owning reference - int current_step = 0; - - // Random camera iterator - std::vector cam_indices; - size_t cam_iter_pos = 0; - std::mt19937 rng; - - GaussianTrainer(Dataset &dataset, const TrainingConfig &cfg) - : config(cfg), dataset_ptr(&dataset) - { - model = std::make_unique( - dataset.data, - dataset.train_cams.size(), - cfg.num_downscales, cfg.resolution_schedule, - cfg.sh_degree, cfg.sh_degree_interval, - cfg.refine_every, cfg.warmup_length, cfg.reset_alpha_every, - cfg.densify_grad_thresh, cfg.densify_size_thresh, - cfg.stop_screen_size_at, cfg.split_screen_size, - cfg.iterations, cfg.keep_crs, - cfg.bg_color.data() - ); - - cam_indices.resize(dataset.train_cams.size()); - std::iota(cam_indices.begin(), cam_indices.end(), 0); - rng.seed(42); - shuffle_cameras(); - } - - void shuffle_cameras() { - std::shuffle(cam_indices.begin(), cam_indices.end(), rng); - cam_iter_pos = 0; - } - - size_t next_camera() { - if (cam_iter_pos >= cam_indices.size()) shuffle_cameras(); - return cam_indices[cam_iter_pos++]; - } - - TrainingStats step() { - current_step++; - size_t cam_idx = next_camera(); - Camera &cam = dataset_ptr->train_cams[cam_idx]; - - int ds = model->getDownscaleFactor(current_step); - MTensor > = cam.getGPUImage(ds); - - auto t0 = std::chrono::high_resolution_clock::now(); - - model->fullIteration(cam, current_step, gt, config.ssim_weight); - model->schedulersStep(current_step); - model->afterTrain(current_step); - msplat_commit(); - - auto t1 = std::chrono::high_resolution_clock::now(); - float ms = std::chrono::duration_cast(t1 - t0).count() / 1000.0f; - - TrainingStats stats; - stats.iteration = current_step; - stats.splat_count = model->means.size(0); - stats.ms_per_step = ms; - return stats; - } - - void train(nb::object callback, int callback_every) { - while (current_step < config.iterations) { - TrainingStats stats = step(); - - if (callback_every > 0 && stats.iteration % callback_every == 0) { - callback(stats); - } - } - } - - // Evaluate on test cameras - nb::dict evaluate() { - if (dataset_ptr->test_cams.empty()) { - throw std::runtime_error("No test cameras. Load dataset with eval_mode=True."); - } - - double sum_psnr = 0, sum_ssim = 0, sum_l1 = 0; - int n = dataset_ptr->test_cams.size(); - - for (int i = 0; i < n; i++) { - Camera &cam = dataset_ptr->test_cams[i]; - MTensor rgb = model->render(cam, config.iterations); - msplat_gpu_sync(); - - MTensor rgb_cpu = rgb.cpu(); - int ds = model->getDownscaleFactor(config.iterations); - MTensor gt_cpu = cam.getGPUImage(ds).cpu(); - - sum_psnr += psnr(rgb_cpu, gt_cpu); - sum_ssim += ssim_eval(rgb_cpu, gt_cpu); - sum_l1 += l1_loss(rgb_cpu, gt_cpu); - } - - nb::dict result; - result["psnr"] = sum_psnr / n; - result["ssim"] = sum_ssim / n; - result["l1"] = sum_l1 / n; - result["num_test"] = n; - result["num_gaussians"] = (int)model->means.size(0); - return result; - } - - // Render a single camera view → numpy (H, W, 3) float32 - nb::object render(int cam_idx, bool use_test) { - auto &cams = use_test ? dataset_ptr->test_cams : dataset_ptr->train_cams; - if (cam_idx < 0 || cam_idx >= (int)cams.size()) { - throw std::runtime_error("Camera index out of range"); - } - - Camera &cam = cams[cam_idx]; - MTensor rgb = model->render(cam, current_step); - msplat_gpu_sync(); - MTensor rgb_cpu = rgb.cpu(); - - int h = rgb_cpu.size(0); - int w = rgb_cpu.size(1); - - // Copy to numpy-owned buffer - float *buf = new float[h * w * 3]; - memcpy(buf, rgb_cpu.data_ptr(), h * w * 3 * sizeof(float)); - - nb::capsule deleter(buf, [](void *p) noexcept { delete[] static_cast(p); }); - size_t shape[3] = {(size_t)h, (size_t)w, 3}; - return nb::cast(nb::ndarray(buf, 3, shape, deleter)); - } - - // Render from arbitrary pose → numpy (H, W, 3) float32 - nb::object render_from_pose(nb::ndarray cam_to_world, int ref_cam_idx) { - if (cam_to_world.size() != 16) - throw std::runtime_error("cam_to_world must have 16 elements (4x4 matrix)"); - if (ref_cam_idx < 0 || ref_cam_idx >= (int)dataset_ptr->train_cams.size()) - throw std::runtime_error("ref_cam_idx out of range"); - - Camera cam = dataset_ptr->train_cams[ref_cam_idx]; - memcpy(cam.camToWorld, cam_to_world.data(), 16 * sizeof(float)); - cam.cachedViewMat = MTensor(); - cam.cachedProjViewMat = MTensor(); - - MTensor rgb = model->render(cam, current_step); - msplat_gpu_sync(); - MTensor rgb_cpu = rgb.cpu(); - - int h = rgb_cpu.size(0); - int w = rgb_cpu.size(1); - float *buf = new float[h * w * 3]; - memcpy(buf, rgb_cpu.data_ptr(), h * w * 3 * sizeof(float)); - - nb::capsule deleter(buf, [](void *p) noexcept { delete[] static_cast(p); }); - size_t shape[3] = {(size_t)h, (size_t)w, 3}; - return nb::cast(nb::ndarray(buf, 3, shape, deleter)); - } - - void export_ply(const std::string &path) { - model->savePly(path, current_step); - } - - void export_splat(const std::string &path) { - model->saveSplat(path); - } - - void save_checkpoint(const std::string &path) { - model->saveCheckpoint(path, current_step); - } - - void load_checkpoint(const std::string &path) { - current_step = model->loadCheckpoint(path); - } - - int splat_count() const { - return model->means.size(0); - } -}; - -// ── Module definition ─────────────────────────────────────────────────────── - -NB_MODULE(_core, m) { - m.doc() = "msplat: Metal-accelerated 3D Gaussian Splatting"; - - // TrainingConfig - nb::class_(m, "TrainingConfig") - .def(nb::init<>()) - .def("__init__", [](TrainingConfig *cfg, - int iterations, int sh_degree, int sh_degree_interval, - float ssim_weight, int num_downscales, int resolution_schedule, - int refine_every, int warmup_length, int reset_alpha_every, - float densify_grad_thresh, float densify_size_thresh, - int stop_screen_size_at, float split_screen_size, - bool keep_crs, float downscale_factor, - const std::string &output, int save_every, - std::vector bg_color) { - new (cfg) TrainingConfig(); - cfg->iterations = iterations; - cfg->sh_degree = sh_degree; - cfg->sh_degree_interval = sh_degree_interval; - cfg->ssim_weight = ssim_weight; - cfg->num_downscales = num_downscales; - cfg->resolution_schedule = resolution_schedule; - cfg->refine_every = refine_every; - cfg->warmup_length = warmup_length; - cfg->reset_alpha_every = reset_alpha_every; - cfg->densify_grad_thresh = densify_grad_thresh; - cfg->densify_size_thresh = densify_size_thresh; - cfg->stop_screen_size_at = stop_screen_size_at; - cfg->split_screen_size = split_screen_size; - cfg->keep_crs = keep_crs; - cfg->downscale_factor = downscale_factor; - cfg->output = output; - cfg->save_every = save_every; - if (bg_color.size() != 3) - throw std::invalid_argument("bg_color must have exactly 3 elements [R, G, B]"); - cfg->bg_color = bg_color; - }, - "iterations"_a = 30000, - "sh_degree"_a = 3, - "sh_degree_interval"_a = 1000, - "ssim_weight"_a = 0.2f, - "num_downscales"_a = 2, - "resolution_schedule"_a = 3000, - "refine_every"_a = 100, - "warmup_length"_a = 500, - "reset_alpha_every"_a = 30, - "densify_grad_thresh"_a = 0.0002f, - "densify_size_thresh"_a = 0.01f, - "stop_screen_size_at"_a = 4000, - "split_screen_size"_a = 0.05f, - "keep_crs"_a = false, - "downscale_factor"_a = 1.0f, - "output"_a = "splat.ply", - "save_every"_a = -1, - "bg_color"_a = std::vector{0.6130f, 0.0101f, 0.3984f}) - .def_rw("iterations", &TrainingConfig::iterations) - .def_rw("sh_degree", &TrainingConfig::sh_degree) - .def_rw("sh_degree_interval", &TrainingConfig::sh_degree_interval) - .def_rw("ssim_weight", &TrainingConfig::ssim_weight) - .def_rw("num_downscales", &TrainingConfig::num_downscales) - .def_rw("resolution_schedule", &TrainingConfig::resolution_schedule) - .def_rw("refine_every", &TrainingConfig::refine_every) - .def_rw("warmup_length", &TrainingConfig::warmup_length) - .def_rw("reset_alpha_every", &TrainingConfig::reset_alpha_every) - .def_rw("densify_grad_thresh", &TrainingConfig::densify_grad_thresh) - .def_rw("densify_size_thresh", &TrainingConfig::densify_size_thresh) - .def_rw("stop_screen_size_at", &TrainingConfig::stop_screen_size_at) - .def_rw("split_screen_size", &TrainingConfig::split_screen_size) - .def_rw("keep_crs", &TrainingConfig::keep_crs) - .def_rw("downscale_factor", &TrainingConfig::downscale_factor) - .def_rw("output", &TrainingConfig::output) - .def_rw("save_every", &TrainingConfig::save_every) - .def_rw("bg_color", &TrainingConfig::bg_color, - "Background color as [R, G, B] floats in [0, 1]. Default magenta [0.613, 0.010, 0.398]."); - - // TrainingStats - nb::class_(m, "TrainingStats", - "Per-step training statistics returned by GaussianTrainer.step().") - .def_ro("iteration", &TrainingStats::iteration, "Current training iteration.") - .def_ro("splat_count", &TrainingStats::splat_count, "Number of active Gaussians.") - .def_ro("ms_per_step", &TrainingStats::ms_per_step, "Wall-clock time for this step in milliseconds.") - .def("__repr__", [](const TrainingStats &s) { - return "TrainingStats(iteration=" + std::to_string(s.iteration) + - ", splats=" + std::to_string(s.splat_count) + - ", ms=" + std::to_string(s.ms_per_step) + ")"; - }); - - // Dataset - nb::class_(m, "Dataset", - "A loaded dataset of camera images. Auto-detects COLMAP, Nerfstudio, and Polycam formats.") - .def(nb::init(), - "path"_a, "downscale_factor"_a = 1.0f, - "eval_mode"_a = false, "test_every"_a = 8) - .def_prop_ro("num_train", &Dataset::num_train, "Number of training cameras.") - .def_prop_ro("num_test", &Dataset::num_test, "Number of test cameras (0 unless eval_mode=True).") - .def("camera_pose", &Dataset::camera_pose, "index"_a, - "Get camera-to-world pose (4x4 row-major, OpenGL convention) as numpy array."); - - // GaussianTrainer - nb::class_(m, "GaussianTrainer", - "3D Gaussian Splatting trainer. All computation runs on the Metal GPU.") - .def(nb::init(), - "dataset"_a, "config"_a, nb::keep_alive<1, 2>()) - .def("step", &GaussianTrainer::step, - "Run a single training iteration. Returns TrainingStats.") - .def("train", &GaussianTrainer::train, - "callback"_a, "callback_every"_a = 100, - "Run training to completion, calling callback(stats) every callback_every steps.") - .def("evaluate", &GaussianTrainer::evaluate, - "Evaluate on held-out test cameras. Returns dict with psnr, ssim, l1 keys.\n" - "Requires the dataset to have been loaded with eval_mode=True.") - .def("render", &GaussianTrainer::render, - "cam_idx"_a, "use_test"_a = false, - "Render a camera view. Returns a numpy array of shape (H, W, 3), float32, RGB [0,1].") - .def("render_from_pose", &GaussianTrainer::render_from_pose, - "cam_to_world"_a, "ref_cam_idx"_a = 0, - "Render from an arbitrary camera-to-world pose (4x4 row-major, OpenGL convention).\n" - "Uses intrinsics from ref_cam_idx. Returns numpy (H, W, 3) float32.") - .def("export_ply", &GaussianTrainer::export_ply, "path"_a, - "Export the current Gaussians as a PLY file.") - .def("export_splat", &GaussianTrainer::export_splat, "path"_a, - "Export the current Gaussians as a .splat file.") - .def("save_checkpoint", &GaussianTrainer::save_checkpoint, "path"_a, - "Save a training checkpoint.") - .def("load_checkpoint", &GaussianTrainer::load_checkpoint, "path"_a, - "Load a training checkpoint and resume from the saved iteration.") - .def_prop_ro("splat_count", &GaussianTrainer::splat_count, - "Current number of active Gaussians.") - .def_prop_ro("iteration", [](const GaussianTrainer &t) { return t.current_step; }, - "Current training iteration."); - - // Utility - m.def("sync", &msplat_gpu_sync, "Synchronize GPU (wait for all commands to complete)"); - m.def("cleanup", &cleanup_msplat_metal, "Release all cached GPU resources"); -} diff --git a/python/msplat/__init__.py b/python/msplat/__init__.py deleted file mode 100644 index 1bde433..0000000 --- a/python/msplat/__init__.py +++ /dev/null @@ -1,47 +0,0 @@ -"""msplat: Metal-accelerated 3D Gaussian Splatting.""" - -import atexit - -from msplat._core import ( - TrainingConfig, - TrainingStats, - Dataset, - GaussianTrainer, - sync, - cleanup as _cleanup_raw, -) - -_cleaned_up = False - - -def cleanup(): - """Release all cached GPU resources. Safe to call multiple times.""" - global _cleaned_up - if not _cleaned_up: - _cleaned_up = True - _cleanup_raw() - - -atexit.register(cleanup) - -__all__ = [ - "TrainingConfig", - "TrainingStats", - "Dataset", - "GaussianTrainer", - "sync", - "cleanup", - "load_dataset", -] - -__version__ = "1.1.2" - - -def load_dataset( - path: str, - downscale_factor: float = 1.0, - eval_mode: bool = False, - test_every: int = 8, -) -> Dataset: - """Load a dataset (auto-detects COLMAP, Nerfstudio, Polycam).""" - return Dataset(path, downscale_factor, eval_mode, test_every) diff --git a/python/msplat/_core.pyi b/python/msplat/_core.pyi deleted file mode 100644 index a4a8a19..0000000 --- a/python/msplat/_core.pyi +++ /dev/null @@ -1,167 +0,0 @@ -"""Type stubs for msplat._core (compiled nanobind extension).""" - -import numpy as np -from numpy.typing import NDArray - -class TrainingConfig: - iterations: int - sh_degree: int - sh_degree_interval: int - ssim_weight: float - num_downscales: int - resolution_schedule: int - refine_every: int - warmup_length: int - reset_alpha_every: int - densify_grad_thresh: float - densify_size_thresh: float - stop_screen_size_at: int - split_screen_size: float - keep_crs: bool - downscale_factor: float - output: str - save_every: int - bg_color: list[float] - """Background color as [R, G, B] floats in [0, 1]. Default magenta [0.613, 0.010, 0.398].""" - - def __init__( - self, - iterations: int = 30000, - sh_degree: int = 3, - sh_degree_interval: int = 1000, - ssim_weight: float = 0.2, - num_downscales: int = 2, - resolution_schedule: int = 3000, - refine_every: int = 100, - warmup_length: int = 500, - reset_alpha_every: int = 30, - densify_grad_thresh: float = 0.0002, - densify_size_thresh: float = 0.01, - stop_screen_size_at: int = 4000, - split_screen_size: float = 0.05, - keep_crs: bool = False, - downscale_factor: float = 1.0, - output: str = "splat.ply", - save_every: int = -1, - bg_color: list[float] = ..., - ) -> None: ... - -class TrainingStats: - """Per-step training statistics returned by GaussianTrainer.step().""" - - @property - def iteration(self) -> int: - """Current training iteration.""" - ... - - @property - def splat_count(self) -> int: - """Number of active Gaussians.""" - ... - - @property - def ms_per_step(self) -> float: - """Wall-clock time for this step in milliseconds.""" - ... - -class Dataset: - """A loaded dataset of camera images. Auto-detects COLMAP, Nerfstudio, and Polycam formats.""" - - def __init__( - self, - path: str, - downscale_factor: float = 1.0, - eval_mode: bool = False, - test_every: int = 8, - ) -> None: ... - - @property - def num_train(self) -> int: - """Number of training cameras.""" - ... - - @property - def num_test(self) -> int: - """Number of test cameras (0 unless eval_mode=True).""" - ... - - def camera_pose(self, index: int) -> NDArray[np.float32]: - """Get camera-to-world pose (4x4 row-major, OpenGL convention) as numpy array.""" - ... - -class GaussianTrainer: - """3D Gaussian Splatting trainer. All computation runs on the Metal GPU.""" - - def __init__(self, dataset: Dataset, config: TrainingConfig) -> None: ... - - def step(self) -> TrainingStats: - """Run a single training iteration. Returns TrainingStats.""" - ... - - def train( - self, - callback: object, - callback_every: int = 100, - ) -> None: - """Run training to completion, calling callback(stats) every callback_every steps.""" - ... - - def evaluate(self) -> dict[str, float | int]: - """Evaluate on held-out test cameras. Returns dict with psnr, ssim, l1 keys. - - Requires the dataset to have been loaded with eval_mode=True. - """ - ... - - def render( - self, - cam_idx: int, - use_test: bool = False, - ) -> NDArray[np.float32]: - """Render a camera view. Returns a numpy array of shape (H, W, 3), float32, RGB [0,1].""" - ... - - def render_from_pose( - self, - cam_to_world: NDArray[np.float32], - ref_cam_idx: int = 0, - ) -> NDArray[np.float32]: - """Render from an arbitrary camera-to-world pose (4x4 row-major, OpenGL convention). - - Uses intrinsics from ref_cam_idx. Returns numpy (H, W, 3) float32. - """ - ... - - def export_ply(self, path: str) -> None: - """Export the current Gaussians as a PLY file.""" - ... - - def export_splat(self, path: str) -> None: - """Export the current Gaussians as a .splat file.""" - ... - - def save_checkpoint(self, path: str) -> None: - """Save a training checkpoint.""" - ... - - def load_checkpoint(self, path: str) -> None: - """Load a training checkpoint and resume from the saved iteration.""" - ... - - @property - def splat_count(self) -> int: - """Current number of active Gaussians.""" - ... - - @property - def iteration(self) -> int: - """Current training iteration.""" - ... - -def sync() -> None: - """Synchronize GPU (wait for all commands to complete).""" - ... - -def cleanup() -> None: - """Release all cached GPU resources.""" - ... diff --git a/python/msplat/cli.py b/python/msplat/cli.py deleted file mode 100644 index 172c586..0000000 --- a/python/msplat/cli.py +++ /dev/null @@ -1,141 +0,0 @@ -"""msplat-train CLI entry point.""" - -import sys - - -def main(): - try: - import tyro - except ImportError: - print("Install tyro for CLI support: pip install msplat[cli]", file=sys.stderr) - sys.exit(1) - - from dataclasses import dataclass, field - - @dataclass - class Args: - """Train 3D Gaussian Splatting on a dataset.""" - - input: str - """Path to dataset (COLMAP, Nerfstudio, etc.)""" - - output: str = "splat.ply" - """Output PLY file path""" - - num_iters: int = 30000 - """Number of training iterations""" - - downscale_factor: float = 1.0 - """Image downscale factor""" - - num_downscales: int = 2 - """Number of progressive downscales""" - - resolution_schedule: int = 3000 - """Double resolution every N steps""" - - sh_degree: int = 3 - """Max spherical harmonics degree""" - - sh_degree_interval: int = 1000 - """Steps between SH degree increases""" - - ssim_weight: float = 0.2 - """SSIM loss weight""" - - refine_every: int = 100 - """Densification interval""" - - warmup_length: int = 500 - """Steps before densification starts""" - - reset_alpha_every: int = 30 - """Reset opacity every N refinements""" - - densify_grad_thresh: float = 0.0002 - """Gradient threshold for densification""" - - densify_size_thresh: float = 0.01 - """Size threshold for split vs clone""" - - stop_screen_size_at: int = 4000 - """Stop screen-size split after this step""" - - split_screen_size: float = 0.05 - """Screen-space split threshold""" - - keep_crs: bool = False - """Keep input coordinate reference system""" - - save_every: int = -1 - """Save every N steps (-1 to disable)""" - - eval: bool = False - """Evaluate on held-out test views""" - - test_every: int = 8 - """Hold out every Nth image for eval""" - - args = tyro.cli(Args) - - from msplat import TrainingConfig, Dataset, GaussianTrainer, sync, cleanup - - config = TrainingConfig( - iterations=args.num_iters, - sh_degree=args.sh_degree, - sh_degree_interval=args.sh_degree_interval, - ssim_weight=args.ssim_weight, - num_downscales=args.num_downscales, - resolution_schedule=args.resolution_schedule, - refine_every=args.refine_every, - warmup_length=args.warmup_length, - reset_alpha_every=args.reset_alpha_every, - densify_grad_thresh=args.densify_grad_thresh, - densify_size_thresh=args.densify_size_thresh, - stop_screen_size_at=args.stop_screen_size_at, - split_screen_size=args.split_screen_size, - keep_crs=args.keep_crs, - downscale_factor=args.downscale_factor, - output=args.output, - save_every=args.save_every, - ) - - dataset = Dataset( - args.input, - downscale_factor=args.downscale_factor, - eval_mode=args.eval, - test_every=args.test_every, - ) - print(f"Loaded {dataset.num_train} train cameras", end="") - if args.eval: - print(f", {dataset.num_test} test cameras") - else: - print() - - trainer = GaussianTrainer(dataset, config) - - def on_step(stats): - print( - f"step={stats.iteration:>6} " - f"splats={stats.splat_count:>8,} " - f"ms={stats.ms_per_step:.1f}" - ) - - trainer.train(on_step, callback_every=100) - - trainer.export_ply(args.output) - print(f"Saved {args.output}") - - if args.eval: - metrics = trainer.evaluate() - print(f"\n=== Evaluation ({metrics['num_test']} test views) ===") - print(f" PSNR: {metrics['psnr']:.4f}") - print(f" SSIM: {metrics['ssim']:.4f}") - print(f" L1: {metrics['l1']:.4f}") - print(f" Gaussians: {metrics['num_gaussians']:,}") - - cleanup() - - -if __name__ == "__main__": - main() diff --git a/python/msplat/py.typed b/python/msplat/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/build-xcframework.sh b/scripts/build-xcframework.sh deleted file mode 100755 index 98088c0..0000000 --- a/scripts/build-xcframework.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash -set -euo pipefail - -cd "$(dirname "$0")/.." - -echo "=== Building msplat ===" -cmake -B build -DCMAKE_BUILD_TYPE=Release -cmake --build build -j - -echo "=== Creating XCFramework ===" - -# Prepare headers with modulemap -rm -rf build/xcf-headers -mkdir -p build/xcf-headers -cp core/include/msplat_c_api.h build/xcf-headers/ -cat > build/xcf-headers/module.modulemap <<'MAP' -module MsplatCore { - header "msplat_c_api.h" - export * -} -MAP - -# Create XCFramework -rm -rf MsplatCore.xcframework -xcodebuild -create-xcframework \ - -library build/libmsplat_core.a \ - -headers build/xcf-headers \ - -output MsplatCore.xcframework - -# Copy metallib as Swift package resource -mkdir -p swift/Sources/Msplat/Resources -cp build/default.metallib swift/Sources/Msplat/Resources/ - -echo "=== Done ===" -echo " MsplatCore.xcframework" -echo " swift/Sources/Msplat/Resources/default.metallib" diff --git a/swift/Package.swift b/swift/Package.swift deleted file mode 100644 index 88a8084..0000000 --- a/swift/Package.swift +++ /dev/null @@ -1,41 +0,0 @@ -// swift-tools-version: 6.0 -import PackageDescription - -let package = Package( - name: "Msplat", - platforms: [.macOS(.v15)], - products: [ - .library(name: "Msplat", targets: ["Msplat"]), - ], - targets: [ - // Pre-built core library (run scripts/build-xcframework.sh to generate) - .binaryTarget( - name: "MsplatCore", - path: "../MsplatCore.xcframework" - ), - - // Swift API - .target( - name: "Msplat", - dependencies: ["MsplatCore"], - path: "Sources/Msplat", - resources: [.copy("Resources/default.metallib")], - linkerSettings: [ - .linkedFramework("Metal"), - .linkedFramework("MetalKit"), - .linkedFramework("MetalPerformanceShaders"), - .linkedFramework("Foundation"), - .linkedFramework("ImageIO"), - .linkedFramework("CoreGraphics"), - .unsafeFlags(["-lc++"]), - ] - ), - - // Tests - .testTarget( - name: "MsplatTests", - dependencies: ["Msplat"], - path: "Tests" - ), - ] -) diff --git a/swift/Sources/Msplat/GaussianDataset.swift b/swift/Sources/Msplat/GaussianDataset.swift deleted file mode 100644 index 7479e20..0000000 --- a/swift/Sources/Msplat/GaussianDataset.swift +++ /dev/null @@ -1,48 +0,0 @@ -import MsplatCore -import Foundation - -nonisolated(unsafe) private var _metallibConfigured = false - -func ensureMetallibConfigured() { - guard !_metallibConfigured else { return } - _metallibConfigured = true - if let path = Bundle.module.path(forResource: "default", ofType: "metallib") { - msplat_set_metallib_path(path) - } -} - -/// A loaded dataset of camera views for training. -public class GaussianDataset { - let handle: MsplatDataset - - /// Load a dataset from disk. - /// - Parameters: - /// - path: Path to COLMAP, Nerfstudio, or other supported format. - /// - downscaleFactor: Image downscale factor (1.0 = full resolution). - /// - evalMode: If true, split cameras into train/test sets. - /// - testEvery: Hold out every Nth image for evaluation. - public init(path: String, downscaleFactor: Float = 1.0, - evalMode: Bool = false, testEvery: Int32 = 8) { - ensureMetallibConfigured() - handle = msplat_dataset_create(path, downscaleFactor, evalMode, testEvery) - } - - deinit { - msplat_dataset_destroy(handle) - } - - /// Number of training cameras. - public var numTrain: Int { Int(msplat_dataset_num_train(handle)) } - - /// Number of test cameras (0 if evalMode was false). - public var numTest: Int { Int(msplat_dataset_num_test(handle)) } - - /// Get the camera-to-world pose (4x4 row-major, OpenGL convention) for a training camera. - public func cameraPose(at index: Int) -> [Float] { - var pose = [Float](repeating: 0, count: 16) - pose.withUnsafeMutableBufferPointer { ptr in - msplat_dataset_camera_pose(handle, Int32(index), ptr.baseAddress!) - } - return pose - } -} diff --git a/swift/Sources/Msplat/GaussianTrainer.swift b/swift/Sources/Msplat/GaussianTrainer.swift deleted file mode 100644 index 71df5ce..0000000 --- a/swift/Sources/Msplat/GaussianTrainer.swift +++ /dev/null @@ -1,104 +0,0 @@ -import MsplatCore -import Foundation - -/// Trains a 3D Gaussian Splatting scene on a dataset. -public class GaussianTrainer { - private let handle: MsplatTrainer - - /// Create a trainer. - /// - Parameters: - /// - dataset: The loaded dataset. Must outlive the trainer. - /// - config: Training configuration. - public init(dataset: GaussianDataset, config: TrainingConfig = TrainingConfig()) { - handle = msplat_trainer_create(dataset.handle, config.toC()) - } - - deinit { - msplat_trainer_destroy(handle) - msplat_cleanup() - } - - /// Run one training step. - @discardableResult - public func step() -> TrainingStats { - TrainingStats(from: msplat_trainer_step(handle)) - } - - /// Train for all remaining iterations (blocking, no progress callbacks). - /// For progress reporting, use `step()` in a loop instead. - public func train() { - msplat_trainer_train(handle) - } - - /// Evaluate on held-out test views. - public func evaluate() -> EvalMetrics { - EvalMetrics(from: msplat_trainer_evaluate(handle)) - } - - /// Render a camera view as RGB float32 pixel data. - public func render(cameraIndex: Int, useTest: Bool = false) -> PixelData { - let buf = msplat_trainer_render(handle, Int32(cameraIndex), useTest) - let count = Int(buf.width) * Int(buf.height) * 3 - let data = Array(UnsafeBufferPointer(start: buf.data, count: count)) - free(buf.data) - return PixelData(pixels: data, width: Int(buf.width), height: Int(buf.height)) - } - - /// Render from an arbitrary camera-to-world pose (4x4 row-major, OpenGL convention). - /// Uses intrinsics (focal length, resolution) from the given reference camera. - public func renderFromPose(camToWorld: [Float], refCameraIndex: Int = 0) -> PixelData { - precondition(camToWorld.count == 16) - let buf = camToWorld.withUnsafeBufferPointer { ptr in - msplat_trainer_render_pose(handle, ptr.baseAddress!, Int32(refCameraIndex)) - } - let count = Int(buf.width) * Int(buf.height) * 3 - let data = Array(UnsafeBufferPointer(start: buf.data, count: count)) - free(buf.data) - return PixelData(pixels: data, width: Int(buf.width), height: Int(buf.height)) - } - - /// Export scene as PLY. - public func exportPly(to path: String) { - msplat_trainer_export_ply(handle, path) - } - - /// Export scene as .splat. - public func exportSplat(to path: String) { - msplat_trainer_export_splat(handle, path) - } - - /// Save full training state for resume. - public func saveCheckpoint(to path: String) { - msplat_trainer_save_checkpoint(handle, path) - } - - /// Load checkpoint and resume training. Returns the saved iteration. - @discardableResult - public func loadCheckpoint(from path: String) -> Int { - Int(msplat_trainer_load_checkpoint(handle, path)) - } - - /// Current number of gaussians. - public var splatCount: Int { Int(msplat_trainer_splat_count(handle)) } - - /// Current training iteration. - public var iteration: Int { Int(msplat_trainer_iteration(handle)) } -} - -/// RGB float32 pixel data from a render. -public struct PixelData { - public let pixels: [Float] // RGB, HWC layout - public let width: Int - public let height: Int -} - -/// Synchronize the GPU (wait for all commands to complete). -public func msplatSync() { - msplat_sync() -} - -/// Release cached GPU resources. Called automatically when GaussianTrainer is deallocated. -/// Only needed if you want to free GPU memory early in a long-running process. -public func msplatCleanup() { - msplat_cleanup() -} diff --git a/swift/Sources/Msplat/TrainingConfig.swift b/swift/Sources/Msplat/TrainingConfig.swift deleted file mode 100644 index 065f58b..0000000 --- a/swift/Sources/Msplat/TrainingConfig.swift +++ /dev/null @@ -1,46 +0,0 @@ -import MsplatCore - -/// Configuration for Gaussian splatting training. -public struct TrainingConfig { - public var iterations: Int32 = 30_000 - public var shDegree: Int32 = 3 - public var shDegreeInterval: Int32 = 1_000 - public var ssimWeight: Float = 0.2 - public var numDownscales: Int32 = 2 - public var resolutionSchedule: Int32 = 3_000 - public var refineEvery: Int32 = 100 - public var warmupLength: Int32 = 500 - public var resetAlphaEvery: Int32 = 30 - public var densifyGradThresh: Float = 0.0002 - public var densifySizeThresh: Float = 0.01 - public var stopScreenSizeAt: Int32 = 4_000 - public var splitScreenSize: Float = 0.05 - public var keepCrs: Bool = false - public var downscaleFactor: Float = 1.0 - /// Background color as (R, G, B) in [0, 1]. Default magenta — high contrast - /// against typical scenes, makes under-reconstructed regions obvious. - public var bgColor: (Float, Float, Float) = (0.6130, 0.0101, 0.3984) - - public init() {} - - func toC() -> MsplatConfig { - var c = msplat_default_config() - c.iterations = iterations - c.shDegree = shDegree - c.shDegreeInterval = shDegreeInterval - c.ssimWeight = ssimWeight - c.numDownscales = numDownscales - c.resolutionSchedule = resolutionSchedule - c.refineEvery = refineEvery - c.warmupLength = warmupLength - c.resetAlphaEvery = resetAlphaEvery - c.densifyGradThresh = densifyGradThresh - c.densifySizeThresh = densifySizeThresh - c.stopScreenSizeAt = stopScreenSizeAt - c.splitScreenSize = splitScreenSize - c.keepCrs = keepCrs - c.downscaleFactor = downscaleFactor - c.bgColor = (bgColor.0, bgColor.1, bgColor.2) - return c - } -} diff --git a/swift/Sources/Msplat/TrainingStats.swift b/swift/Sources/Msplat/TrainingStats.swift deleted file mode 100644 index 6ab1c91..0000000 --- a/swift/Sources/Msplat/TrainingStats.swift +++ /dev/null @@ -1,31 +0,0 @@ -import MsplatCore - -/// Statistics from a single training step. -public struct TrainingStats { - public let iteration: Int - public let splatCount: Int - public let msPerStep: Float - - init(from c: MsplatStats) { - self.iteration = Int(c.iteration) - self.splatCount = Int(c.splatCount) - self.msPerStep = c.msPerStep - } -} - -/// Evaluation metrics from held-out test views. -public struct EvalMetrics { - public let psnr: Float - public let ssim: Float - public let l1: Float - public let numTest: Int - public let numGaussians: Int - - init(from c: MsplatEvalMetrics) { - self.psnr = c.psnr - self.ssim = c.ssim - self.l1 = c.l1 - self.numTest = Int(c.numTest) - self.numGaussians = Int(c.numGaussians) - } -} diff --git a/swift/Tests/MsplatTests.swift b/swift/Tests/MsplatTests.swift deleted file mode 100644 index 55d73c1..0000000 --- a/swift/Tests/MsplatTests.swift +++ /dev/null @@ -1,85 +0,0 @@ -import XCTest -import Msplat - -final class MsplatTests: XCTestCase { - - static let gardenPath = "../datasets/mipnerf360/garden" - - func testConfigDefaults() { - let config = TrainingConfig() - XCTAssertEqual(config.iterations, 30_000) - XCTAssertEqual(config.shDegree, 3) - XCTAssertEqual(config.ssimWeight, 0.2, accuracy: 0.001) - } - - func testLoadDataset() throws { - let dataset = GaussianDataset( - path: Self.gardenPath, - downscaleFactor: 4.0, - evalMode: true, - testEvery: 8 - ) - XCTAssertGreaterThan(dataset.numTrain, 0) - XCTAssertGreaterThan(dataset.numTest, 0) - } - - func testTrainShort() throws { - let dataset = GaussianDataset( - path: Self.gardenPath, - downscaleFactor: 4.0 - ) - var config = TrainingConfig() - config.iterations = 10 - config.numDownscales = 0 - - let trainer = GaussianTrainer(dataset: dataset, config: config) - - for _ in 0..<10 { - let stats = trainer.step() - XCTAssertGreaterThan(stats.splatCount, 0) - } - - XCTAssertEqual(trainer.iteration, 10) - XCTAssertGreaterThan(trainer.splatCount, 100_000) - } - - func testRender() throws { - let dataset = GaussianDataset( - path: Self.gardenPath, - downscaleFactor: 4.0 - ) - var config = TrainingConfig() - config.iterations = 5 - config.numDownscales = 0 - - let trainer = GaussianTrainer(dataset: dataset, config: config) - for _ in 0..<5 { trainer.step() } - - let rendered = trainer.render(cameraIndex: 0) - XCTAssertGreaterThan(rendered.width, 0) - XCTAssertGreaterThan(rendered.height, 0) - XCTAssertEqual(rendered.pixels.count, rendered.width * rendered.height * 3) - } - - func testExportPly() throws { - let dataset = GaussianDataset( - path: Self.gardenPath, - downscaleFactor: 4.0 - ) - var config = TrainingConfig() - config.iterations = 5 - config.numDownscales = 0 - - let trainer = GaussianTrainer(dataset: dataset, config: config) - for _ in 0..<5 { trainer.step() } - - let tmpPath = NSTemporaryDirectory() + "msplat_test_export.ply" - trainer.exportPly(to: tmpPath) - XCTAssertTrue(FileManager.default.fileExists(atPath: tmpPath)) - - let fileSize = try FileManager.default.attributesOfItem(atPath: tmpPath)[.size] as! Int - XCTAssertGreaterThan(fileSize, 0) - - try FileManager.default.removeItem(atPath: tmpPath) - } -} diff --git a/tests/test_msplat.py b/tests/test_msplat.py deleted file mode 100644 index fe5b8d3..0000000 --- a/tests/test_msplat.py +++ /dev/null @@ -1,280 +0,0 @@ -"""msplat test suite.""" - -import pytest -import numpy as np -import tempfile -import os - -GARDEN = os.path.join(os.path.dirname(__file__), "..", "datasets", "mipnerf360", "garden") -HAS_GARDEN = os.path.isdir(GARDEN) - - -# ── Import tests ───────────────────────────────────────────────────────────── - - -def test_import(): - import msplat - assert hasattr(msplat, "GaussianTrainer") - assert hasattr(msplat, "TrainingConfig") - assert hasattr(msplat, "Dataset") - assert hasattr(msplat, "load_dataset") - - -def test_training_config_defaults(): - from msplat import TrainingConfig - - cfg = TrainingConfig() - assert cfg.iterations == 30000 - assert cfg.sh_degree == 3 - assert cfg.ssim_weight == pytest.approx(0.2) - assert cfg.refine_every == 100 - assert cfg.warmup_length == 500 - - -def test_training_config_custom(): - from msplat import TrainingConfig - - cfg = TrainingConfig(iterations=100, sh_degree=1, ssim_weight=0.0) - assert cfg.iterations == 100 - assert cfg.sh_degree == 1 - assert cfg.ssim_weight == 0.0 - - -def test_training_config_mutable(): - from msplat import TrainingConfig - - cfg = TrainingConfig() - cfg.iterations = 500 - assert cfg.iterations == 500 - - -# ── Dataset tests ──────────────────────────────────────────────────────────── - - -@pytest.mark.skipif(not HAS_GARDEN, reason="garden dataset not found") -def test_load_dataset(): - from msplat import Dataset - - ds = Dataset(GARDEN, downscale_factor=4.0, eval_mode=True, test_every=8) - assert ds.num_train > 0 - assert ds.num_test > 0 - assert ds.num_train + ds.num_test > 100 - - -@pytest.mark.skipif(not HAS_GARDEN, reason="garden dataset not found") -def test_load_dataset_no_eval(): - from msplat import Dataset - - ds = Dataset(GARDEN, downscale_factor=4.0, eval_mode=False) - assert ds.num_train > 0 - assert ds.num_test == 0 - - -# ── Training tests ─────────────────────────────────────────────────────────── - - -@pytest.mark.skipif(not HAS_GARDEN, reason="garden dataset not found") -def test_train_short(): - """Train 50 steps at 4x downscale — verify it runs without error.""" - from msplat import TrainingConfig, Dataset, GaussianTrainer - - ds = Dataset(GARDEN, downscale_factor=4.0) - cfg = TrainingConfig(iterations=50, num_downscales=0) - trainer = GaussianTrainer(ds, cfg) - - steps_seen = [] - trainer.train(lambda s: steps_seen.append(s.iteration), callback_every=10) - - assert trainer.iteration == 50 - assert trainer.splat_count > 100000 - assert steps_seen == [10, 20, 30, 40, 50] - - -@pytest.mark.skipif(not HAS_GARDEN, reason="garden dataset not found") -def test_step_by_step(): - """Manual step loop works.""" - from msplat import TrainingConfig, Dataset, GaussianTrainer - - ds = Dataset(GARDEN, downscale_factor=4.0) - cfg = TrainingConfig(iterations=10, num_downscales=0) - trainer = GaussianTrainer(ds, cfg) - - for _ in range(10): - stats = trainer.step() - - assert stats.iteration == 10 - assert stats.splat_count > 0 - assert stats.ms_per_step > 0 - - -# ── Render tests ───────────────────────────────────────────────────────────── - - -@pytest.mark.skipif(not HAS_GARDEN, reason="garden dataset not found") -def test_render(): - """Render produces valid image array.""" - from msplat import TrainingConfig, Dataset, GaussianTrainer, sync - - ds = Dataset(GARDEN, downscale_factor=4.0) - cfg = TrainingConfig(iterations=10, num_downscales=0) - trainer = GaussianTrainer(ds, cfg) - - for _ in range(10): - trainer.step() - - img = trainer.render(0) - assert isinstance(img, np.ndarray) - assert img.dtype == np.float32 - assert img.ndim == 3 - assert img.shape[2] == 3 - assert img.shape[0] > 0 and img.shape[1] > 0 - # Values should be in [0, 1] range (approximately) - assert img.min() >= -0.1 - assert img.max() <= 1.5 - - -# ── Export tests ───────────────────────────────────────────────────────────── - - -@pytest.mark.skipif(not HAS_GARDEN, reason="garden dataset not found") -def test_export_ply(): - """PLY export creates a valid file.""" - from msplat import TrainingConfig, Dataset, GaussianTrainer - - ds = Dataset(GARDEN, downscale_factor=4.0) - cfg = TrainingConfig(iterations=10, num_downscales=0) - trainer = GaussianTrainer(ds, cfg) - - for _ in range(10): - trainer.step() - - with tempfile.NamedTemporaryFile(suffix=".ply", delete=False) as f: - path = f.name - - try: - trainer.export_ply(path) - assert os.path.exists(path) - size = os.path.getsize(path) - assert size > 1000 # non-trivial file - finally: - os.unlink(path) - - -@pytest.mark.skipif(not HAS_GARDEN, reason="garden dataset not found") -def test_export_splat(): - """Splat export creates a valid file.""" - from msplat import TrainingConfig, Dataset, GaussianTrainer - - ds = Dataset(GARDEN, downscale_factor=4.0) - cfg = TrainingConfig(iterations=10, num_downscales=0) - trainer = GaussianTrainer(ds, cfg) - - for _ in range(10): - trainer.step() - - with tempfile.NamedTemporaryFile(suffix=".splat", delete=False) as f: - path = f.name - - try: - trainer.export_splat(path) - assert os.path.exists(path) - size = os.path.getsize(path) - assert size > 1000 - finally: - os.unlink(path) - - -# ── Eval tests ─────────────────────────────────────────────────────────────── - - -@pytest.mark.skipif(not HAS_GARDEN, reason="garden dataset not found") -def test_evaluate(): - """Evaluation returns valid metrics dict.""" - from msplat import TrainingConfig, Dataset, GaussianTrainer - - ds = Dataset(GARDEN, downscale_factor=4.0, eval_mode=True, test_every=8) - cfg = TrainingConfig(iterations=50, num_downscales=0) - trainer = GaussianTrainer(ds, cfg) - - trainer.train(lambda s: None, callback_every=50) - metrics = trainer.evaluate() - - assert "psnr" in metrics - assert "ssim" in metrics - assert "l1" in metrics - assert "num_test" in metrics - assert metrics["num_test"] > 0 - assert metrics["psnr"] > 10 # sanity — should be at least somewhat trained - assert 0 < metrics["ssim"] < 1 - assert metrics["l1"] > 0 - - -# ── Checkpoint tests ──────────────────────────────────────────────────────── - - -@pytest.mark.skipif(not HAS_GARDEN, reason="garden dataset not found") -def test_checkpoint_save_load(): - """Save checkpoint, load it, verify state is preserved.""" - from msplat import TrainingConfig, Dataset, GaussianTrainer - - ds = Dataset(GARDEN, downscale_factor=4.0) - cfg = TrainingConfig(iterations=100, num_downscales=0) - trainer = GaussianTrainer(ds, cfg) - - for _ in range(50): - trainer.step() - - splats_at_50 = trainer.splat_count - - with tempfile.NamedTemporaryFile(suffix=".msplat", delete=False) as f: - ckpt_path = f.name - - try: - trainer.save_checkpoint(ckpt_path) - assert os.path.exists(ckpt_path) - assert os.path.getsize(ckpt_path) > 1000 - - # Load into a fresh trainer - ds2 = Dataset(GARDEN, downscale_factor=4.0) - cfg2 = TrainingConfig(iterations=100, num_downscales=0) - trainer2 = GaussianTrainer(ds2, cfg2) - trainer2.load_checkpoint(ckpt_path) - - assert trainer2.iteration == 50 - assert trainer2.splat_count == splats_at_50 - finally: - os.unlink(ckpt_path) - - -@pytest.mark.skipif(not HAS_GARDEN, reason="garden dataset not found") -def test_checkpoint_resume_training(): - """Train 50 → save → load → train 50 more. Verify it completes.""" - from msplat import TrainingConfig, Dataset, GaussianTrainer - - ds = Dataset(GARDEN, downscale_factor=4.0) - cfg = TrainingConfig(iterations=100, num_downscales=0) - trainer = GaussianTrainer(ds, cfg) - - for _ in range(50): - trainer.step() - - with tempfile.NamedTemporaryFile(suffix=".msplat", delete=False) as f: - ckpt_path = f.name - - try: - trainer.save_checkpoint(ckpt_path) - - # Resume in a new trainer - ds2 = Dataset(GARDEN, downscale_factor=4.0) - cfg2 = TrainingConfig(iterations=100, num_downscales=0) - trainer2 = GaussianTrainer(ds2, cfg2) - trainer2.load_checkpoint(ckpt_path) - - for _ in range(50): - stats = trainer2.step() - - assert trainer2.iteration == 100 - assert stats.splat_count > 0 - assert stats.ms_per_step > 0 - finally: - os.unlink(ckpt_path) diff --git a/tests/web/fixtures/fake-colmap.mjs b/tests/web/fixtures/fake-colmap.mjs new file mode 100755 index 0000000..91349be --- /dev/null +++ b/tests/web/fixtures/fake-colmap.mjs @@ -0,0 +1,233 @@ +#!/usr/bin/env node +import fs from "node:fs/promises"; +import path from "node:path"; + +const [command, ...args] = process.argv.slice(2); +const flagStyle = process.env.FAKE_COLMAP_FLAG_STYLE || "modern"; + +function readOption(name, fallback = "") { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : fallback; +} + +function expectedGpuFlagNames() { + if (flagStyle === "legacy") { + return { + featureExtraction: "--SiftExtraction.use_gpu", + featureMatching: "--SiftMatching.use_gpu" + }; + } + if (flagStyle === "modern") { + return { + featureExtraction: "--FeatureExtraction.use_gpu", + featureMatching: "--FeatureMatching.use_gpu" + }; + } + throw new Error(`Unsupported FAKE_COLMAP_FLAG_STYLE: ${flagStyle}`); +} + +function assertExpectedFlag(expectedName, unexpectedName) { + const expectedIndex = args.indexOf(expectedName); + if (expectedIndex < 0 || args[expectedIndex + 1] !== "0") { + console.error(`expected ${expectedName} 0`); + process.exit(10); + } + const unexpectedIndex = args.indexOf(unexpectedName); + if (unexpectedIndex >= 0) { + console.error(`unexpected ${unexpectedName}`); + process.exit(11); + } +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +let cancelled = false; +process.on("SIGTERM", () => { + cancelled = true; + console.error("cancelled"); +}); + +async function maybeSlow(tokens) { + if (!tokens.has("slow")) return; + for (let index = 0; index < 30; index += 1) { + if (cancelled) { + process.exit(143); + } + await sleep(100); + } +} + +async function ensureModelFiles(modelDir, label) { + await fs.mkdir(modelDir, { recursive: true }); + await fs.writeFile(path.join(modelDir, "cameras.bin"), `cameras-${label}`); + await fs.writeFile(path.join(modelDir, "images.bin"), `images-${label}`); + await fs.writeFile(path.join(modelDir, "points3D.bin"), `points-${label}`); + await fs.writeFile(path.join(modelDir, `selected-model-${label}.txt`), label); +} + +async function loadImageTokens(imageDir) { + const entries = await fs.readdir(imageDir).catch(() => []); + const tokens = new Set(); + for (const entry of entries) { + const lower = entry.toLowerCase(); + if (lower.includes("slow")) tokens.add("slow"); + if (lower.includes("multi")) tokens.add("multi"); + if (lower.includes("tie")) tokens.add("tie"); + if (lower.includes("fallback")) tokens.add("fallback"); + if (lower.includes("extractfail")) tokens.add("extractfail"); + if (lower.includes("matchfail")) tokens.add("matchfail"); + if (lower.includes("mapfail")) tokens.add("mapfail"); + } + return tokens; +} + +async function readDbState(databasePath) { + try { + return JSON.parse(await fs.readFile(databasePath, "utf8")); + } catch { + return { tokens: [] }; + } +} + +async function writeDbState(databasePath, state) { + await fs.mkdir(path.dirname(databasePath), { recursive: true }); + await fs.writeFile(databasePath, JSON.stringify(state, null, 2)); +} + +async function handleModelConverter() { + const inputPath = readOption("--input_path"); + const outputPath = readOption("--output_path"); + const failMarker = path.join(path.dirname(inputPath), "mode-convert-fail.txt"); + if (await fs.access(failMarker).then(() => true).catch(() => false)) { + console.error("simulated model conversion failure"); + process.exit(2); + } + + await fs.mkdir(outputPath, { recursive: true }); + await ensureModelFiles(outputPath, "converted"); + console.log(`Converted ${inputPath} -> ${outputPath}`); +} + +async function handleFeatureExtractor() { + const databasePath = readOption("--database_path"); + const imagePath = readOption("--image_path"); + const flags = expectedGpuFlagNames(); + assertExpectedFlag( + flags.featureExtraction, + flags.featureExtraction === "--FeatureExtraction.use_gpu" ? "--SiftExtraction.use_gpu" : "--FeatureExtraction.use_gpu" + ); + const tokens = await loadImageTokens(imagePath); + if (tokens.has("extractfail")) { + console.error("simulated feature extraction failure"); + process.exit(3); + } + + await maybeSlow(tokens); + if (cancelled) process.exit(143); + + await writeDbState(databasePath, { + imagePath, + tokens: [...tokens] + }); + console.log(`feature_extractor image_path=${imagePath}`); +} + +async function handleMatcher() { + const databasePath = readOption("--database_path"); + const flags = expectedGpuFlagNames(); + assertExpectedFlag( + flags.featureMatching, + flags.featureMatching === "--FeatureMatching.use_gpu" ? "--SiftMatching.use_gpu" : "--FeatureMatching.use_gpu" + ); + const state = await readDbState(databasePath); + const tokens = new Set(state.tokens || []); + if (tokens.has("matchfail")) { + console.error("simulated matcher failure"); + process.exit(4); + } + + await maybeSlow(tokens); + if (cancelled) process.exit(143); + + state.matcher = command; + state.matcherArgs = args; + await writeDbState(databasePath, state); + console.log(command); +} + +async function handleMapper() { + const databasePath = readOption("--database_path"); + const outputPath = readOption("--output_path"); + const state = await readDbState(databasePath); + const tokens = new Set(state.tokens || []); + if (tokens.has("mapfail")) { + console.error("simulated mapper failure"); + process.exit(5); + } + if (tokens.has("fallback") && state.matcher !== "exhaustive_matcher") { + console.error("simulated no initial pair found"); + process.exit(6); + } + + await maybeSlow(tokens); + if (cancelled) process.exit(143); + + await fs.mkdir(outputPath, { recursive: true }); + + if (tokens.has("multi") || tokens.has("tie")) { + const models = tokens.has("tie") + ? [ + { name: "0", registeredImages: 4, points: 100, label: "0" }, + { name: "1", registeredImages: 4, points: 250, label: "1" } + ] + : [ + { name: "0", registeredImages: 3, points: 90, label: "0" }, + { name: "1", registeredImages: 6, points: 180, label: "1" } + ]; + + for (const model of models) { + const modelDir = path.join(outputPath, model.name); + await ensureModelFiles(modelDir, model.label); + await fs.writeFile(path.join(modelDir, "stats.json"), JSON.stringify(model)); + } + } else { + const modelDir = path.join(outputPath, "0"); + await ensureModelFiles(modelDir, "0"); + await fs.writeFile(path.join(modelDir, "stats.json"), JSON.stringify({ + registeredImages: 5, + points: 120, + label: "0" + })); + } + + console.log(`mapper output_path=${outputPath}`); +} + +async function handleModelAnalyzer() { + const modelPath = readOption("--path"); + const stats = JSON.parse(await fs.readFile(path.join(modelPath, "stats.json"), "utf8").catch(() => "{}")); + console.log(`Registered images: ${stats.registeredImages ?? 0}`); + console.log(`Points: ${stats.points ?? 0}`); +} + +try { + if (command === "model_converter") { + await handleModelConverter(); + } else if (command === "feature_extractor") { + await handleFeatureExtractor(); + } else if (command === "sequential_matcher" || command === "exhaustive_matcher") { + await handleMatcher(); + } else if (command === "mapper") { + await handleMapper(); + } else if (command === "model_analyzer") { + await handleModelAnalyzer(); + } else { + console.error(`unsupported command: ${command}`); + process.exit(64); + } +} catch (error) { + console.error(error.message); + process.exit(1); +} diff --git a/tests/web/fixtures/fake-msplat.mjs b/tests/web/fixtures/fake-msplat.mjs new file mode 100755 index 0000000..ba24605 --- /dev/null +++ b/tests/web/fixtures/fake-msplat.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +import fs from "node:fs/promises"; +import path from "node:path"; + +const args = process.argv.slice(2); +const inputDir = args[0]; + +function readOption(name, fallback = "") { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : fallback; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const outputPath = readOption("-o"); +const exportPlyPath = readOption("--export-ply"); +const valRenderDir = readOption("--val-render"); +const pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5X7ioAAAAASUVORK5CYII="; + +async function writePreview(step) { + await fs.mkdir(valRenderDir, { recursive: true }); + await fs.writeFile(path.join(valRenderDir, `${step}.png`), Buffer.from(pngBase64, "base64")); + console.log(`preview ${step}`); +} + +let cancelled = false; +process.on("SIGTERM", () => { + cancelled = true; + console.error("cancelled"); +}); + +const mode = await (async () => { + for (const name of ["mode-fail.txt", "mode-slow.txt"]) { + try { + await fs.access(path.join(inputDir, name)); + return name.includes("fail") ? "fail" : "slow"; + } catch { + continue; + } + } + return "success"; +})(); + +await fs.mkdir(path.dirname(outputPath), { recursive: true }); +await writePreview(10); + +if (mode !== "fail") { + await writePreview(20); +} + +if (mode === "slow") { + for (let index = 0; index < 20; index += 1) { + if (cancelled) process.exit(143); + await sleep(200); + } +} + +if (cancelled) { + process.exit(143); +} + +if (mode === "fail") { + console.error("simulated failure"); + process.exit(2); +} + +await fs.writeFile(outputPath, "fake splat"); +if (exportPlyPath) { + await fs.mkdir(path.dirname(exportPlyPath), { recursive: true }); + await fs.writeFile(exportPlyPath, "fake ply"); +} +await fs.writeFile(path.join(path.dirname(outputPath), "cameras.json"), JSON.stringify([{ file_path: "frame.png" }], null, 2)); +console.log("=== Validation (frame.png) ==="); +console.log(" PSNR: 28.5 SSIM: 0.912 L1: 0.034 Gaussians: 12345"); diff --git a/tests/web/helpers.mjs b/tests/web/helpers.mjs new file mode 100644 index 0000000..84fb7c0 --- /dev/null +++ b/tests/web/helpers.mjs @@ -0,0 +1,231 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { createAppServer } from "../../web/src/server-app.mjs"; +import { createWorker } from "../../web/src/worker-app.mjs"; + +const execFileAsync = promisify(execFile); + +async function chmodExec(filePath) { + if (!filePath) return; + await fs.chmod(filePath, 0o755); +} + +export async function makeTempDir(prefix = "msplat-web-") { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +export async function writeFile(filePath, content) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); +} + +export async function createZipFromDirectory(sourceDir, archivePath) { + await execFileAsync("/usr/bin/zip", ["-qry", archivePath, "."], { cwd: sourceDir }); +} + +export async function createUnsafeZipWithTraversal(archivePath) { + await execFileAsync("/usr/bin/python3", [ + "-c", + "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w'); z.writestr('../escape.txt','x'); z.close()", + archivePath + ]); +} + +export async function createZipWithSymlink(sourceDir, archivePath) { + await execFileAsync("/bin/ln", ["-sf", "/tmp/escape", path.join(sourceDir, "linked.txt")]); + await execFileAsync("/usr/bin/zip", ["-qry", "-y", archivePath, "."], { cwd: sourceDir }); +} + +export async function createColmapDataset(dir, { modeFile } = {}) { + await writeFile(path.join(dir, "images", "frame.jpg"), "jpg"); + await writeFile(path.join(dir, "sparse", "0", "cameras.bin"), "bin"); + await writeFile(path.join(dir, "sparse", "0", "images.bin"), "bin"); + await writeFile(path.join(dir, "sparse", "0", "points3D.bin"), "bin"); + if (modeFile) { + await writeFile(path.join(dir, modeFile), modeFile); + } +} + +export async function createColmapTextDataset(dir, { layout = "sparse", includeDatabase = true, modeFile } = {}) { + const modelDir = { + root: dir, + sparse: path.join(dir, "sparse"), + sparse0: path.join(dir, "sparse", "0") + }[layout]; + + if (!modelDir) { + throw new Error(`Unsupported COLMAP text layout: ${layout}`); + } + + await writeFile(path.join(dir, "images", "frame.jpg"), "jpg"); + await writeFile(path.join(modelDir, "cameras.txt"), "# cameras"); + await writeFile(path.join(modelDir, "images.txt"), "# images"); + await writeFile(path.join(modelDir, "points3D.txt"), "# points"); + + if (includeDatabase) { + await writeFile(path.join(dir, "database.db"), "db"); + } + + if (modeFile) { + await writeFile(path.join(dir, modeFile), modeFile); + } +} + +export async function createRawImagesDataset(dir, { fileNames = ["frame-1.jpg", "frame-2.jpg", "frame-3.jpg"] } = {}) { + for (const fileName of fileNames) { + await writeFile(path.join(dir, fileName), "raw-image"); + } +} + +export async function createNerfstudioDataset(dir) { + await writeFile(path.join(dir, "images", "frame.png"), "png"); + await writeFile(path.join(dir, "points3D.ply"), "ply"); + await writeFile( + path.join(dir, "transforms.json"), + JSON.stringify({ + frames: [{ file_path: "images/frame.png", transform_matrix: [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]] }] + }) + ); +} + +export async function createPolycamDataset(dir) { + await writeFile( + path.join(dir, "keyframes", "corrected_cameras", "0001.json"), + JSON.stringify({ width: 1, height: 1, fx: 1, fy: 1, cx: 0.5, cy: 0.5 }) + ); + await writeFile(path.join(dir, "keyframes", "corrected_images", "0001.png"), "png"); + await writeFile(path.join(dir, "keyframes", "point_cloud.ply"), "ply"); +} + +async function createMultipartBody({ fields = {}, files = [] }) { + const formData = new FormData(); + + for (const [key, value] of Object.entries(fields)) { + if (value != null) { + formData.append(key, String(value)); + } + } + + for (const file of files) { + formData.append( + file.fieldName ?? "images", + new Blob([file.content ?? "raw-image"], { type: file.contentType ?? "image/jpeg" }), + file.name + ); + } + + const request = new Request("http://local/upload", { + method: "POST", + body: formData + }); + + return { + body: Buffer.from(await request.arrayBuffer()), + headers: Object.fromEntries(request.headers.entries()) + }; +} + +export async function startHarness({ msplatBin, colmapBin, colmapFlagStyle } = {}) { + await chmodExec(msplatBin); + await chmodExec(colmapBin); + + const tempRoot = await makeTempDir(); + const jobsDir = path.join(tempRoot, "jobs"); + const databasePath = path.join(tempRoot, "jobs.sqlite"); + const app = await createAppServer({ + port: 0, + host: "127.0.0.1", + jobsDir, + databasePath + }); + const worker = await createWorker({ + jobsDir, + databasePath, + msplatBin, + colmapBin, + colmapFlagStyle, + pollIntervalMs: 20 + }); + + return { + tempRoot, + app, + worker, + async close() { + await worker.close(); + await app.close(); + } + }; +} + +export async function waitFor(predicate, { timeoutMs = 8000, intervalMs = 50 } = {}) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const value = await predicate(); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error("Timed out waiting for condition"); +} + +export async function driveWorkerUntil(worker, checkDone, { timeoutMs = 8000 } = {}) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + await worker.tickOnce(); + const result = await checkDone(); + if (result) return result; + } + throw new Error("Worker did not reach the expected state"); +} + +export async function requestJson(app, url, { method = "GET", headers = {}, body = null } = {}) { + const response = await app.inject({ method, url, headers, body }); + return { + response, + payload: response.text ? response.json() : null + }; +} + +export async function uploadJob(app, archivePath, { name = "Test Job", preset = "preview", colmapMode } = {}) { + const body = await fs.readFile(archivePath); + const params = new URLSearchParams({ name, preset }); + if (colmapMode) { + params.set("colmapMode", colmapMode); + } + + const response = await app.inject({ + url: `/api/jobs?${params.toString()}`, + method: "POST", + headers: { + "content-type": "application/zip", + "x-file-name": path.basename(archivePath) + }, + body + }); + return { + response, + payload: response.json() + }; +} + +export async function uploadRawJob(app, { name = "Raw Job", preset = "preview", colmapMode = "sequential", files = [] } = {}) { + const { body, headers } = await createMultipartBody({ + fields: { name, preset, colmapMode }, + files + }); + + const response = await app.inject({ + url: "/api/jobs/raw", + method: "POST", + headers, + body + }); + + return { + response, + payload: response.json() + }; +} diff --git a/tests/web/integration.test.mjs b/tests/web/integration.test.mjs new file mode 100644 index 0000000..deba708 --- /dev/null +++ b/tests/web/integration.test.mjs @@ -0,0 +1,387 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { createWorker } from "../../web/src/worker-app.mjs"; +import { + createColmapDataset, + createColmapTextDataset, + createRawImagesDataset, + createZipFromDirectory, + driveWorkerUntil, + makeTempDir, + requestJson, + startHarness, + uploadJob, + uploadRawJob, + waitFor +} from "./helpers.mjs"; + +const fakeMsplatBin = path.resolve("tests/web/fixtures/fake-msplat.mjs"); +const fakeColmapBin = path.resolve("tests/web/fixtures/fake-colmap.mjs"); + +async function waitForTerminalDetail(app, worker, jobId, timeoutMs = 8000) { + return driveWorkerUntil( + worker, + async () => { + const { payload: detail } = await requestJson(app, `/api/jobs/${jobId}`); + return ["succeeded", "failed", "cancelled"].includes(detail.job.status) ? detail : null; + }, + { timeoutMs } + ); +} + +test("shows a sample dataset link on the new job page", async () => { + const harness = await startHarness({ msplatBin: fakeMsplatBin, colmapBin: fakeColmapBin }); + try { + const page = await harness.app.inject({ url: "/jobs/new" }); + assert.match(page.text, /COLMAP sample datasets/); + assert.match(page.text, /https:\/\/demuc\.de\/colmap\/datasets\//); + } finally { + await harness.close(); + } +}); + +test("queues, runs, and exposes artifacts for a prepared COLMAP BIN upload", async () => { + const harness = await startHarness({ msplatBin: fakeMsplatBin, colmapBin: fakeColmapBin }); + try { + const datasetDir = path.join(await makeTempDir(), "dataset"); + await createColmapDataset(datasetDir); + const archivePath = path.join(await makeTempDir(), "dataset.zip"); + await createZipFromDirectory(datasetDir, archivePath); + + const { response, payload } = await uploadJob(harness.app, archivePath, { name: "Garden pass", preset: "preview" }); + assert.equal(response.statusCode, 201); + + const detail = await waitForTerminalDetail(harness.app, harness.worker, payload.job.id); + assert.equal(detail.job.status, "succeeded"); + assert.equal(detail.job.datasetType, "colmap"); + assert.equal(detail.job.sourceFormat, "colmap_bin"); + assert.equal(detail.job.finalPsnr, 28.5); + assert.ok(detail.latestPreviewUrl.includes(".png")); + const primaryOutput = detail.artifacts.find((artifact) => artifact.name === "final.spl"); + assert.ok(primaryOutput); + assert.match(primaryOutput.path, /output\/final\.spl$/); + assert.ok(detail.artifacts.some((artifact) => artifact.name === "final.ply")); + assert.ok(detail.artifacts.some((artifact) => artifact.name === "train.log")); + + const detailPage = await harness.app.inject({ url: `/jobs/${payload.job.id}` }); + assert.match(detailPage.text, /Stored on this machine/); + assert.match(detailPage.text, /output\/final\.spl/); + assert.match(detailPage.text, /Copy log/); + } finally { + await harness.close(); + } +}); + +test("deletes a completed job and removes its files", async () => { + const harness = await startHarness({ msplatBin: fakeMsplatBin, colmapBin: fakeColmapBin }); + try { + const datasetDir = path.join(await makeTempDir(), "dataset"); + await createColmapDataset(datasetDir); + const archivePath = path.join(await makeTempDir(), "dataset.zip"); + await createZipFromDirectory(datasetDir, archivePath); + + const { payload } = await uploadJob(harness.app, archivePath, { name: "Delete me", preset: "preview" }); + const detail = await waitForTerminalDetail(harness.app, harness.worker, payload.job.id); + const jobDir = detail.job.jobDir; + + const detailPage = await harness.app.inject({ url: `/jobs/${payload.job.id}` }); + assert.match(detailPage.text, /Delete Job/); + + const jobsPage = await harness.app.inject({ url: "/jobs" }); + assert.match(jobsPage.text, new RegExp(`data-delete-job="${payload.job.id}"`)); + + const { response, payload: deletePayload } = await requestJson(harness.app, `/api/jobs/${payload.job.id}/delete`, { method: "POST" }); + assert.equal(response.statusCode, 200); + assert.equal(deletePayload.deleted, true); + + const { response: detailResponse, payload: detailPayload } = await requestJson(harness.app, `/api/jobs/${payload.job.id}`); + assert.equal(detailResponse.statusCode, 404); + assert.equal(detailPayload.error, "Job not found"); + + const { payload: jobsPayload } = await requestJson(harness.app, "/api/jobs"); + assert.equal(jobsPayload.jobs.length, 0); + + await assert.rejects(fs.stat(jobDir)); + } finally { + await harness.close(); + } +}); + +test("converts a COLMAP TXT upload and keeps COLMAP artifacts", async () => { + const harness = await startHarness({ msplatBin: fakeMsplatBin, colmapBin: fakeColmapBin }); + try { + const datasetDir = path.join(await makeTempDir(), "dataset"); + await createColmapTextDataset(datasetDir, { layout: "sparse" }); + const archivePath = path.join(await makeTempDir(), "dataset.zip"); + await createZipFromDirectory(datasetDir, archivePath); + + const { payload } = await uploadJob(harness.app, archivePath, { name: "TXT upload", preset: "preview" }); + const detail = await waitForTerminalDetail(harness.app, harness.worker, payload.job.id); + + assert.equal(detail.job.status, "succeeded"); + assert.equal(detail.job.sourceFormat, "colmap_txt"); + assert.equal(detail.job.inputKind, "prepared_zip"); + assert.equal(detail.job.datasetType, "colmap"); + assert.ok(detail.artifacts.some((artifact) => artifact.name === "colmap-database.db")); + assert.ok(detail.artifacts.some((artifact) => artifact.name === "colmap-model-cameras.bin")); + } finally { + await harness.close(); + } +}); + +test("reconstructs a raw image zip with exhaustive matching before training", async () => { + const harness = await startHarness({ msplatBin: fakeMsplatBin, colmapBin: fakeColmapBin }); + try { + const rawDir = path.join(await makeTempDir(), "raw"); + await createRawImagesDataset(rawDir, { + fileNames: ["multi-1.jpg", "multi-2.jpg", "multi-3.jpg", "multi-4.jpg"] + }); + const archivePath = path.join(await makeTempDir(), "raw.zip"); + await createZipFromDirectory(rawDir, archivePath); + + const { payload } = await uploadJob(harness.app, archivePath, { + name: "Raw zip", + preset: "preview", + colmapMode: "exhaustive" + }); + const detail = await waitForTerminalDetail(harness.app, harness.worker, payload.job.id); + + assert.equal(detail.job.status, "succeeded"); + assert.equal(detail.job.sourceFormat, "raw_images"); + assert.equal(detail.job.inputKind, "raw_zip"); + assert.equal(detail.job.colmapMode, "exhaustive"); + assert.ok(detail.artifacts.some((artifact) => artifact.name === "colmap-database.db")); + assert.ok(detail.artifacts.some((artifact) => artifact.name === "colmap-model-selected-model-1.txt")); + assert.match(detail.logTail, /exhaustive_matcher/); + assert.match(detail.logTail, /--FeatureExtraction\.use_gpu 0/); + assert.match(detail.logTail, /--FeatureMatching\.use_gpu 0/); + assert.match(detail.logTail, /--Mapper\.tri_ignore_two_view_tracks 0/); + } finally { + await harness.close(); + } +}); + +test("accepts direct raw photo uploads and runs sequential COLMAP reconstruction", async () => { + const harness = await startHarness({ msplatBin: fakeMsplatBin, colmapBin: fakeColmapBin }); + try { + const { response, payload } = await uploadRawJob(harness.app, { + name: "Raw files", + preset: "preview", + colmapMode: "sequential", + files: [ + { name: "tie-1.jpg" }, + { name: "tie-2.jpg" }, + { name: "tie-3.jpg" }, + { name: "tie-4.jpg" } + ] + }); + + assert.equal(response.statusCode, 201); + + const detail = await waitForTerminalDetail(harness.app, harness.worker, payload.job.id); + assert.equal(detail.job.status, "succeeded"); + assert.equal(detail.job.inputKind, "raw_files"); + assert.equal(detail.job.sourceFormat, "raw_images"); + assert.equal(detail.job.colmapMode, "sequential"); + assert.ok(detail.artifacts.some((artifact) => artifact.name === "colmap-model-selected-model-1.txt")); + assert.match(detail.logTail, /sequential_matcher/); + assert.match(detail.logTail, /colmap_flag_style=modern/); + assert.match(detail.logTail, /--FeatureExtraction\.use_gpu 0/); + assert.match(detail.logTail, /--FeatureMatching\.use_gpu 0/); + assert.match(detail.logTail, /--Mapper\.tri_ignore_two_view_tracks 0/); + } finally { + await harness.close(); + } +}); + +test("retries small sequential raw uploads with exhaustive matching when COLMAP cannot initialize", async () => { + const harness = await startHarness({ msplatBin: fakeMsplatBin, colmapBin: fakeColmapBin }); + try { + const { payload } = await uploadRawJob(harness.app, { + name: "Fallback raw files", + preset: "preview", + colmapMode: "sequential", + files: [ + { name: "fallback-1.jpg" }, + { name: "fallback-2.jpg" }, + { name: "fallback-3.jpg" }, + { name: "fallback-4.jpg" } + ] + }); + + const detail = await waitForTerminalDetail(harness.app, harness.worker, payload.job.id); + assert.equal(detail.job.status, "succeeded"); + assert.match(detail.logTail, /sequential_matcher/); + assert.match(detail.logTail, /retrying_with_exhaustive/); + assert.match(detail.logTail, /exhaustive_matcher/); + assert.match(detail.logTail, /<<< \[mapping\] FAILED/); + } finally { + await harness.close(); + } +}); + +test("supports legacy COLMAP option names when explicitly configured", async () => { + const harness = await startHarness({ + msplatBin: fakeMsplatBin, + colmapBin: fakeColmapBin, + colmapFlagStyle: "legacy" + }); + try { + const { payload } = await uploadRawJob(harness.app, { + name: "Legacy raw files", + preset: "preview", + colmapMode: "sequential", + files: [ + { name: "legacy-1.jpg" }, + { name: "legacy-2.jpg" }, + { name: "legacy-3.jpg" } + ] + }); + + const detail = await waitForTerminalDetail(harness.app, harness.worker, payload.job.id); + assert.equal(detail.job.status, "succeeded"); + assert.match(detail.logTail, /colmap_flag_style=legacy/); + assert.match(detail.logTail, /--SiftExtraction\.use_gpu 0/); + assert.match(detail.logTail, /--SiftMatching\.use_gpu 0/); + } finally { + await harness.close(); + } +}); + +test("rejects invalid COLMAP_FLAG_STYLE at worker startup", async () => { + const tempRoot = await makeTempDir(); + const jobsDir = path.join(tempRoot, "jobs"); + const databasePath = path.join(tempRoot, "jobs.sqlite"); + + await assert.rejects( + createWorker({ + jobsDir, + databasePath, + msplatBin: fakeMsplatBin, + colmapBin: fakeColmapBin, + colmapFlagStyle: "bad-style" + }), + /COLMAP_FLAG_STYLE must be one of: modern, legacy/ + ); +}); + +test("cancels a job during COLMAP preprocessing", async () => { + const harness = await startHarness({ msplatBin: fakeMsplatBin, colmapBin: fakeColmapBin }); + try { + const { payload } = await uploadRawJob(harness.app, { + name: "Slow raw files", + preset: "preview", + colmapMode: "sequential", + files: [ + { name: "slow-1.jpg" }, + { name: "slow-2.jpg" }, + { name: "slow-3.jpg" }, + { name: "slow-4.jpg" } + ] + }); + + const jobId = payload.job.id; + const runner = (async () => { + while (true) { + await harness.worker.tickOnce(); + const { payload: detail } = await requestJson(harness.app, `/api/jobs/${jobId}`); + if (["cancelled", "failed", "succeeded"].includes(detail.job.status)) { + return detail; + } + } + })(); + + await waitFor(async () => { + const { payload: detail } = await requestJson(harness.app, `/api/jobs/${jobId}`); + return ["extracting_features", "matching", "mapping"].includes(detail.job.phase) ? detail : null; + }); + + const { response: cancelResponse } = await requestJson(harness.app, `/api/jobs/${jobId}/cancel`, { method: "POST" }); + assert.equal(cancelResponse.statusCode, 200); + + const detail = await runner; + assert.equal(detail.job.status, "cancelled"); + } finally { + await harness.close(); + } +}); + +test("cancels a running training job", async () => { + const harness = await startHarness({ msplatBin: fakeMsplatBin, colmapBin: fakeColmapBin }); + try { + const datasetDir = path.join(await makeTempDir(), "dataset"); + await createColmapDataset(datasetDir, { modeFile: "mode-slow.txt" }); + const archivePath = path.join(await makeTempDir(), "dataset.zip"); + await createZipFromDirectory(datasetDir, archivePath); + + const { payload } = await uploadJob(harness.app, archivePath, { name: "Slow job", preset: "preview" }); + const jobId = payload.job.id; + + const runner = (async () => { + while (true) { + await harness.worker.tickOnce(); + const { payload: detail } = await requestJson(harness.app, `/api/jobs/${jobId}`); + if (["cancelled", "failed", "succeeded"].includes(detail.job.status)) { + return detail.job.status; + } + } + })(); + + await waitFor(async () => { + const { payload: detail } = await requestJson(harness.app, `/api/jobs/${jobId}`); + return detail.job.status === "running" ? detail : null; + }); + + const { response: cancelResponse } = await requestJson(harness.app, `/api/jobs/${jobId}/cancel`, { method: "POST" }); + assert.equal(cancelResponse.statusCode, 200); + + const finalStatus = await runner; + assert.equal(finalStatus, "cancelled"); + } finally { + await harness.close(); + } +}); + +test("rejects deleting an active job", async () => { + const harness = await startHarness({ msplatBin: fakeMsplatBin, colmapBin: fakeColmapBin }); + try { + const datasetDir = path.join(await makeTempDir(), "dataset"); + await createColmapDataset(datasetDir); + const archivePath = path.join(await makeTempDir(), "dataset.zip"); + await createZipFromDirectory(datasetDir, archivePath); + + const { payload } = await uploadJob(harness.app, archivePath, { name: "Active job", preset: "preview" }); + const { response, payload: deletePayload } = await requestJson(harness.app, `/api/jobs/${payload.job.id}/delete`, { method: "POST" }); + + assert.equal(response.statusCode, 409); + assert.match(deletePayload.error, /Active jobs cannot be deleted/); + + const { payload: detail } = await requestJson(harness.app, `/api/jobs/${payload.job.id}`); + assert.equal(detail.job.status, "uploaded"); + } finally { + await harness.close(); + } +}); + +test("marks the job failed when COLMAP preprocessing fails", async () => { + const harness = await startHarness({ msplatBin: fakeMsplatBin, colmapBin: fakeColmapBin }); + try { + const rawDir = path.join(await makeTempDir(), "raw"); + await createRawImagesDataset(rawDir, { + fileNames: ["mapfail-1.jpg", "mapfail-2.jpg", "mapfail-3.jpg"] + }); + const archivePath = path.join(await makeTempDir(), "mapfail.zip"); + await createZipFromDirectory(rawDir, archivePath); + + const { payload } = await uploadJob(harness.app, archivePath, { name: "Failing raw zip", preset: "preview" }); + const detail = await waitForTerminalDetail(harness.app, harness.worker, payload.job.id); + + assert.equal(detail.job.status, "failed"); + assert.equal(detail.job.phase, "mapping"); + assert.match(detail.job.errorMessage, /exited with code 5/); + } finally { + await harness.close(); + } +}); diff --git a/tests/web/smoke.test.mjs b/tests/web/smoke.test.mjs new file mode 100644 index 0000000..fcd2076 --- /dev/null +++ b/tests/web/smoke.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; +import { createAppServer } from "../../web/src/server-app.mjs"; +import { createWorker } from "../../web/src/worker-app.mjs"; +import { driveWorkerUntil, makeTempDir, requestJson, uploadJob } from "./helpers.mjs"; + +const smokeDataset = process.env.MSPLAT_SMOKE_DATASET; +const smokeBinary = process.env.MSPLAT_SMOKE_BIN; +const smokeColmapBin = process.env.MSPLAT_SMOKE_COLMAP_BIN; + +test("optional Apple Silicon smoke run", { skip: !smokeDataset || !smokeBinary }, async () => { + const tempRoot = await makeTempDir("msplat-smoke-"); + const jobsDir = path.join(tempRoot, "jobs"); + const databasePath = path.join(tempRoot, "jobs.sqlite"); + const app = await createAppServer({ port: 0, host: "127.0.0.1", jobsDir, databasePath }); + const worker = await createWorker({ + jobsDir, + databasePath, + msplatBin: smokeBinary, + colmapBin: smokeColmapBin, + pollIntervalMs: 50 + }); + + try { + const { payload } = await uploadJob(app, smokeDataset, { name: "Smoke", preset: "preview" }); + const finalDetail = await driveWorkerUntil( + worker, + async () => { + const { payload: detail } = await requestJson(app, `/api/jobs/${payload.job.id}`); + return ["succeeded", "failed"].includes(detail.job.status) ? detail : null; + }, + { timeoutMs: 180000 } + ); + + assert.equal(finalDetail.job.status, "succeeded"); + } finally { + await worker.close(); + await app.close(); + } +}); diff --git a/tests/web/validator.test.mjs b/tests/web/validator.test.mjs new file mode 100644 index 0000000..9f885b5 --- /dev/null +++ b/tests/web/validator.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { assertArchiveSafe, extractArchive } from "../../web/src/archive.mjs"; +import { inspectDataset } from "../../web/src/dataset.mjs"; +import { + createColmapDataset, + createColmapTextDataset, + createNerfstudioDataset, + createPolycamDataset, + createRawImagesDataset, + createUnsafeZipWithTraversal, + createZipFromDirectory, + createZipWithSymlink, + makeTempDir +} from "./helpers.mjs"; + +test("detects supported dataset layouts including COLMAP TXT and raw image sets", async () => { + const root = await makeTempDir(); + + const colmapBinDir = path.join(root, "colmap-bin"); + await createColmapDataset(colmapBinDir); + assert.equal((await inspectDataset(colmapBinDir)).sourceFormat, "colmap_bin"); + + const colmapTextSparseDir = path.join(root, "colmap-text-sparse"); + await createColmapTextDataset(colmapTextSparseDir, { layout: "sparse" }); + assert.equal((await inspectDataset(colmapTextSparseDir)).sourceFormat, "colmap_txt"); + + const colmapTextSparse0Dir = path.join(root, "colmap-text-sparse0"); + await createColmapTextDataset(colmapTextSparse0Dir, { layout: "sparse0" }); + assert.equal((await inspectDataset(colmapTextSparse0Dir)).sourceFormat, "colmap_txt"); + + const colmapTextRootDir = path.join(root, "colmap-text-root"); + await createColmapTextDataset(colmapTextRootDir, { layout: "root" }); + assert.equal((await inspectDataset(colmapTextRootDir)).sourceFormat, "colmap_txt"); + + const nerfstudioDir = path.join(root, "nerfstudio"); + await createNerfstudioDataset(nerfstudioDir); + assert.equal((await inspectDataset(nerfstudioDir)).sourceFormat, "nerfstudio"); + + const polycamDir = path.join(root, "polycam"); + await createPolycamDataset(polycamDir); + assert.equal((await inspectDataset(polycamDir)).sourceFormat, "polycam"); + + const rawImagesDir = path.join(root, "raw-images"); + await createRawImagesDataset(rawImagesDir); + const rawInfo = await inspectDataset(rawImagesDir); + assert.equal(rawInfo.sourceFormat, "raw_images"); + assert.equal(rawInfo.imageFiles.length, 3); +}); + +test("rejects incomplete raw photo sets", async () => { + const root = await makeTempDir(); + const rawImagesDir = path.join(root, "too-few-images"); + await createRawImagesDataset(rawImagesDir, { fileNames: ["frame-1.jpg", "frame-2.jpg"] }); + + await assert.rejects(() => inspectDataset(rawImagesDir), /at least 3 images/); +}); + +test("rejects archives with traversal paths", async () => { + const root = await makeTempDir(); + const archivePath = path.join(root, "unsafe.zip"); + await createUnsafeZipWithTraversal(archivePath); + await assert.rejects(() => assertArchiveSafe(archivePath), /escapes the extraction root/); +}); + +test("rejects archives with symlinks", async () => { + const root = await makeTempDir(); + const sourceDir = path.join(root, "source"); + await fs.mkdir(sourceDir, { recursive: true }); + const archivePath = path.join(root, "symlink.zip"); + await createZipWithSymlink(sourceDir, archivePath); + await assert.rejects(() => assertArchiveSafe(archivePath), /symbolic link/); +}); + +test("extracts a safe archive and preserves dataset detection", async () => { + const root = await makeTempDir(); + const sourceDir = path.join(root, "dataset"); + await createColmapTextDataset(sourceDir, { layout: "sparse" }); + const archivePath = path.join(root, "dataset.zip"); + await createZipFromDirectory(sourceDir, archivePath); + + await assert.doesNotReject(() => assertArchiveSafe(archivePath)); + + const extractedDir = path.join(root, "unzipped"); + await extractArchive(archivePath, extractedDir); + const detected = await inspectDataset(extractedDir); + assert.equal(detected.sourceFormat, "colmap_txt"); +}); diff --git a/web/dev.mjs b/web/dev.mjs new file mode 100644 index 0000000..7ea1e84 --- /dev/null +++ b/web/dev.mjs @@ -0,0 +1,22 @@ +import { spawn } from "node:child_process"; + +const children = [ + ["server", spawn(process.execPath, ["web/server.mjs"], { stdio: "inherit" })], + ["worker", spawn(process.execPath, ["web/worker.mjs"], { stdio: "inherit" })] +]; + +for (const [name, child] of children) { + console.log(`[dev] started ${name} pid=${child.pid}`); + child.on("exit", (code, signal) => { + console.log(`[dev] ${name} exited code=${code ?? "null"} signal=${signal ?? "null"}`); + }); +} + +function shutdown() { + for (const [, child] of children) { + if (!child.killed) child.kill("SIGTERM"); + } +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/web/public/app.css b/web/public/app.css new file mode 100644 index 0000000..12282b5 --- /dev/null +++ b/web/public/app.css @@ -0,0 +1,786 @@ +:root { + --ink: #15211b; + --muted: #5d685f; + --line: rgba(21, 33, 27, 0.12); + --cream: #f4f0e6; + --panel: rgba(255, 252, 245, 0.88); + --accent: #bc5f37; + --accent-dark: #964622; + --green: #4d6b50; + --shadow: 0 28px 80px rgba(29, 30, 22, 0.12); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: Georgia, "Iowan Old Style", "Palatino Linotype", serif; + color: var(--ink); + background: + radial-gradient(circle at top left, rgba(188, 95, 55, 0.12), transparent 28rem), + radial-gradient(circle at top right, rgba(77, 107, 80, 0.12), transparent 24rem), + linear-gradient(180deg, #fbf6ee 0%, var(--cream) 100%); +} + +a { + color: inherit; +} + +code, +pre, +.site-header small, +.status, +.button, +.job-card__details, +.stat-grid dt, +.inline-status, +.section-head p, +.eyebrow { + font-family: "Menlo", "Monaco", monospace; +} + +.page-shell { + max-width: 1180px; + margin: 0 auto; + padding: 2rem 1.2rem 4rem; +} + +.site-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 2rem; +} + +.site-mark { + display: inline-flex; + align-items: center; + gap: 0.9rem; + text-decoration: none; +} + +.site-mark__badge { + width: 2.6rem; + height: 2.6rem; + display: inline-grid; + place-items: center; + border-radius: 999px; + background: linear-gradient(135deg, var(--accent), #d78955); + color: #fff9f2; + font-weight: 700; +} + +.site-mark small { + display: block; + color: var(--muted); + margin-top: 0.12rem; +} + +.site-nav { + display: flex; + gap: 1rem; +} + +.site-nav a { + text-decoration: none; + padding: 0.7rem 1rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.52); + border: 1px solid var(--line); +} + +.hero, +.panel, +.job-card, +.empty-state { + background: var(--panel); + border: 1px solid rgba(255, 255, 255, 0.7); + border-radius: 1.6rem; + box-shadow: var(--shadow); + backdrop-filter: blur(8px); +} + +.hero { + padding: 2.2rem; + margin-bottom: 1.5rem; +} + +.hero--compact { + padding: 1.7rem 2rem; +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.72rem; + color: var(--muted); +} + +.hero h1, +.section-head h2, +.job-card h2 { + margin: 0.3rem 0 0.6rem; + line-height: 1.02; +} + +.hero h1 { + font-size: clamp(2.3rem, 4vw, 4.2rem); + max-width: 14ch; +} + +.hero--job h1 { + font-size: clamp(2.8rem, 5vw, 4.8rem); + max-width: none; + margin-bottom: 0; +} + +.lede { + max-width: 62ch; + color: var(--muted); + font-size: 1.05rem; +} + +.job-hero { + display: grid; + gap: 1.2rem; +} + +.job-hero__main { + display: grid; + gap: 0.75rem; +} + +.job-hero__lede { + margin: 0; + max-width: 56ch; + color: var(--muted); + font-size: 1.02rem; +} + +.job-hero__meta { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.hero__actions, +.section-head, +.job-card__meta, +.job-card__details, +.split-grid, +.upload-form, +.upload-form fieldset, +.preset-grid, +.detail-grid, +.stat-grid, +.artifact-list { + display: grid; + gap: 1rem; +} + +.hero__actions { + grid-auto-flow: column; + justify-content: start; + align-items: center; +} + +.hero__actions--job { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + text-decoration: none; + border-radius: 999px; + padding: 0.95rem 1.2rem; + background: linear-gradient(135deg, var(--accent), var(--accent-dark)); + color: #fff9f2; + cursor: pointer; + letter-spacing: 0.04em; +} + +.button--ghost { + background: rgba(255, 255, 255, 0.6); + color: var(--ink); + border: 1px solid var(--line); +} + +.button--danger { + background: linear-gradient(135deg, #7d2d24, #9f463a); +} + +.button--ghost-danger { + background: rgba(125, 45, 36, 0.12); + color: #7c2f28; + border: 1px solid rgba(125, 45, 36, 0.22); +} + +.button--small { + padding: 0.7rem 0.95rem; + font-size: 0.82rem; +} + +.meta-chip { + display: inline-flex; + align-items: center; + padding: 0.4rem 0.78rem; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.58); + color: var(--ink); + font-size: 0.82rem; +} + +.section-head { + grid-template-columns: 1fr auto; + align-items: end; + margin: 1.5rem 0 1rem; +} + +.panel .section-head { + margin: 0 0 1rem; +} + +.section-head--tight { + margin-bottom: 0.9rem; +} + +.section-head p { + margin: 0; + color: var(--muted); +} + +.section-head > * { + min-width: 0; +} + +.section-head__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.65rem; +} + +.job-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; +} + +.job-card { + padding: 1.2rem; +} + +.job-card__meta, +.job-card__details { + display: flex; + flex-wrap: wrap; + color: var(--muted); + font-size: 0.82rem; +} + +.job-card__body { + display: grid; + grid-template-columns: 1fr 7.6rem; + gap: 1rem; + margin: 1rem 0; +} + +.job-card__actions { + display: flex; + gap: 0.75rem; + margin-top: 1rem; +} + +.job-card__body p { + margin: 0; + color: var(--muted); +} + +.job-card__phase { + margin-top: 0.6rem; + color: var(--ink); +} + +.job-card__thumb { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + border-radius: 1rem; + background: #e8e0d2; + border: 1px solid var(--line); +} + +.job-card__thumb--empty, +.preview-empty { + display: grid; + place-items: center; + color: var(--muted); +} + +.status { + display: inline-flex; + align-items: center; + padding: 0.38rem 0.7rem; + border-radius: 999px; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.68rem; + background: rgba(255, 255, 255, 0.72); +} + +.status--running, +.status--succeeded { + color: #245130; + background: rgba(77, 107, 80, 0.16); +} + +.status--failed, +.status--cancelled { + color: #7c2f28; + background: rgba(160, 78, 62, 0.14); +} + +.progress { + width: 100%; + height: 0.72rem; + background: rgba(21, 33, 27, 0.08); + border-radius: 999px; + overflow: hidden; +} + +.progress__fill { + height: 100%; + background: linear-gradient(90deg, var(--green), #7a926e); +} + +.panel { + padding: 1.4rem; +} + +.panel__header h2 { + margin: 0.3rem 0 0.6rem; +} + +.panel__lede { + margin: 0 0 1.1rem; + color: var(--muted); +} + +.split-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; +} + +.upload-stack { + display: grid; + grid-template-columns: minmax(0, 760px); + justify-content: start; +} + +.panel--upload { + max-width: 100%; +} + +.upload-mode-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.upload-mode-card { + align-content: start; +} + +.upload-mode-card small { + color: var(--muted); +} + +.upload-section { + display: grid; + gap: 1rem; +} + +.upload-form label, +.upload-form fieldset { + border: 1px solid var(--line); + padding: 1rem; + border-radius: 1rem; + background: rgba(255, 255, 255, 0.55); +} + +.upload-form label span, +.upload-form legend { + display: block; + margin-bottom: 0.65rem; + font-weight: 700; +} + +.upload-form input[type="text"], +.upload-form input[type="file"], +.upload-form select { + width: 100%; + font: inherit; + padding: 0.65rem 0.75rem; + border-radius: 0.8rem; + border: 1px solid rgba(21, 33, 27, 0.16); + background: rgba(255, 255, 255, 0.82); +} + +.preset-grid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.preset-card { + display: grid; + gap: 0.35rem; + padding: 1rem; + border-radius: 1rem; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.7); +} + +.preset-card input { + margin: 0; +} + +.upload-form__notes { + color: var(--muted); +} + +.upload-form__notes p { + margin: 0; +} + +.field-help { + display: block; + margin-top: 0.55rem; + color: var(--muted); +} + +.detail-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; +} + +.job-board { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr) minmax(0, 1.1fr); + gap: 1rem; + align-items: start; +} + +.job-lane { + display: grid; + gap: 1rem; + align-content: start; + min-width: 0; +} + +.job-lane__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.1rem 0.25rem; +} + +.job-lane__head p { + margin: 0; + color: var(--muted); + font-family: "Menlo", "Monaco", monospace; +} + +.panel--board { + display: grid; + gap: 1rem; + min-width: 0; + overflow: hidden; +} + +.panel-note { + margin: 0; + color: var(--muted); + font-size: 0.96rem; +} + +.panel--artifacts, +.panel--log { + min-height: 0; +} + +.panel--wide { + grid-column: 1 / -1; +} + +.stat-grid { + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); +} + +.stat-grid--board { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} + +.stat-grid--board > div { + padding: 0.95rem 1rem; + border-radius: 1rem; + background: rgba(255, 255, 255, 0.62); + border: 1px solid var(--line); + min-width: 0; +} + +.stat-grid dt { + margin-bottom: 0.35rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.72rem; +} + +.stat-grid dd { + margin: 0; + font-size: 1.1rem; + overflow-wrap: anywhere; +} + +.stat-grid--board > div { + overflow: hidden; +} + +.preview-image { + width: 100%; + border-radius: 1rem; + border: 1px solid var(--line); + background: #efe7db; +} + +.preview-image--board { + aspect-ratio: 4 / 3; + object-fit: cover; +} + +.preview-image--hidden { + display: none; +} + +.preview-empty--board { + min-height: 15rem; + border-radius: 1rem; + border: 1px dashed rgba(21, 33, 27, 0.14); + background: rgba(255, 255, 255, 0.46); +} + +.artifact-list { + list-style: none; + padding: 0; + margin: 0; +} + +.output-file-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 0.85rem; +} + +.output-file { + display: grid; + gap: 0.65rem; + padding: 1rem; + border-radius: 1rem; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.64); +} + +.output-file--empty { + color: var(--muted); +} + +.output-file__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.output-file__head div { + display: grid; + gap: 0.25rem; +} + +.output-file__head small { + color: var(--muted); + font-family: "Menlo", "Monaco", monospace; +} + +.output-file__label { + margin: 0; + color: var(--muted); + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.08em; + font-family: "Menlo", "Monaco", monospace; +} + +.output-file__path { + display: block; + overflow-wrap: anywhere; + padding: 0.8rem 0.9rem; + border-radius: 0.9rem; + background: rgba(21, 33, 27, 0.06); + border: 1px solid rgba(21, 33, 27, 0.08); + font-size: 0.83rem; +} + +.artifact-list--board { + max-height: 28rem; + overflow: auto; + padding-right: 0.35rem; +} + +.artifact-list li { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + border-top: 1px solid var(--line); + padding-top: 0.8rem; +} + +.artifact-list li:first-child { + border-top: 0; + padding-top: 0; +} + +.artifact-list a { + min-width: 0; + overflow-wrap: anywhere; +} + +.log-view { + margin: 0; + min-height: 18rem; + max-height: 28rem; + overflow: auto; + padding: 1rem; + border-radius: 1rem; + background: #1b231f; + color: #f6eee0; +} + +.log-view--board { + min-height: 34rem; + max-height: min(62vh, 44rem); +} + +#copy-log-status[data-state="success"] { + color: #245130; +} + +#copy-log-status[data-state="error"] { + color: #8a352d; +} + +.inline-status { + margin: 0; + color: var(--muted); +} + +.inline-status--error { + color: #8a352d; +} + +.empty-state { + padding: 2rem; + text-align: center; +} + +@media (max-width: 1120px) { + .job-board { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .job-lane:last-child { + grid-column: 1 / -1; + } + + .log-view--board { + min-height: 24rem; + max-height: 30rem; + } +} + +@media (max-width: 820px) { + .site-header, + .hero__actions, + .section-head, + .job-card__body, + .split-grid, + .detail-grid, + .job-board { + grid-template-columns: 1fr; + } + + .site-header, + .site-nav { + display: grid; + } + + .detail-grid { + gap: 1rem; + } + + .job-lane:last-child { + grid-column: auto; + } + + .job-lane__head { + flex-direction: column; + align-items: flex-start; + } + + .section-head__actions { + justify-content: flex-start; + } + + .stat-grid--board { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .preview-empty--board, + .preview-image--board { + aspect-ratio: auto; + min-height: 12rem; + } + + .upload-mode-grid { + grid-template-columns: 1fr; + } + + .output-file__head { + flex-direction: column; + align-items: flex-start; + } + + .log-view--board, + .artifact-list--board { + max-height: none; + } +} + +@media (max-width: 560px) { + .stat-grid--board { + grid-template-columns: 1fr; + } +} diff --git a/web/server.mjs b/web/server.mjs new file mode 100644 index 0000000..4d94db6 --- /dev/null +++ b/web/server.mjs @@ -0,0 +1,5 @@ +import { createAppServer } from "./src/server-app.mjs"; + +const app = await createAppServer(); +await app.start(); +console.log(`msplat internal site listening on http://${app.config.host}:${app.config.port}`); diff --git a/web/src/archive.mjs b/web/src/archive.mjs new file mode 100644 index 0000000..6af3f3a --- /dev/null +++ b/web/src/archive.mjs @@ -0,0 +1,127 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { ensureDir, fileExists, toPosixPath } from "./utils.mjs"; + +function runCommand(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"] + }); + + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + reject(new Error(stderr.trim() || `${command} exited with code ${code}`)); + }); + }); +} + +function validateEntryName(entryName) { + if (!entryName || entryName.includes("\0")) { + throw new Error("Archive contains an invalid path"); + } + + const normalized = toPosixPath(entryName).replace(/^\.\/+/, ""); + const segments = normalized.split("/").filter(Boolean); + + if (normalized.startsWith("/") || /^[a-zA-Z]:\//.test(normalized)) { + throw new Error(`Archive path is not relative: ${entryName}`); + } + + if (segments.some((segment) => segment === "..")) { + throw new Error(`Archive path escapes the extraction root: ${entryName}`); + } +} + +async function assertNoSymlinks(rootDir) { + const stack = [rootDir]; + while (stack.length > 0) { + const currentDir = stack.pop(); + const entries = await fs.readdir(currentDir, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(currentDir, entry.name); + const stat = await fs.lstat(entryPath); + if (stat.isSymbolicLink()) { + throw new Error(`Archive contains a symbolic link: ${entryPath}`); + } + if (entry.isDirectory()) { + stack.push(entryPath); + } + } + } +} + +export async function listArchiveEntries(archivePath) { + const { stdout } = await runCommand("/usr/bin/zipinfo", ["-1", archivePath]); + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((entry) => { + validateEntryName(entry); + return entry; + }); +} + +export async function assertArchiveSafe(archivePath) { + const entries = await listArchiveEntries(archivePath); + const { stdout } = await runCommand("/usr/bin/zipinfo", ["-l", archivePath]); + const symlinkLine = stdout + .split(/\r?\n/) + .find((line) => line.trim().startsWith("l")); + + if (symlinkLine) { + throw new Error("Archive contains a symbolic link entry"); + } + + if (entries.length === 0) { + throw new Error("Archive is empty"); + } + + return entries; +} + +export async function extractArchive(archivePath, destinationDir) { + await ensureDir(destinationDir); + await runCommand("/usr/bin/unzip", ["-qq", archivePath, "-d", destinationDir]); + await assertNoSymlinks(destinationDir); +} + +export async function detectDatasetRoot(extractionDir) { + const visibleEntries = (await fs.readdir(extractionDir, { withFileTypes: true })) + .filter((entry) => entry.name !== "__MACOSX"); + + const candidates = [extractionDir]; + if (visibleEntries.length === 1 && visibleEntries[0].isDirectory()) { + candidates.push(path.join(extractionDir, visibleEntries[0].name)); + } + + for (const candidate of candidates) { + if (await fileExists(path.join(candidate, "transforms.json"))) return candidate; + if ( + await fileExists(path.join(candidate, "cameras.bin")) || + await fileExists(path.join(candidate, "sparse", "0", "cameras.bin")) || + await fileExists(path.join(candidate, "keyframes", "corrected_cameras")) || + await fileExists(path.join(candidate, "cameras.json")) + ) { + return candidate; + } + } + + return extractionDir; +} diff --git a/web/src/artifacts.mjs b/web/src/artifacts.mjs new file mode 100644 index 0000000..fafca36 --- /dev/null +++ b/web/src/artifacts.mjs @@ -0,0 +1,91 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileExists, statIfExists } from "./utils.mjs"; + +async function listPreviewFiles(previewsDir) { + try { + const files = await fs.readdir(previewsDir); + return files + .filter((fileName) => fileName.endsWith(".png")) + .sort((left, right) => Number(path.basename(left, ".png")) - Number(path.basename(right, ".png"))); + } catch { + return []; + } +} + +async function addArtifact(artifacts, name, filePath, kind, label = name) { + if (!(await fileExists(filePath))) return; + const stat = await statIfExists(filePath); + artifacts.push({ + name, + label, + path: filePath, + sizeBytes: stat?.size ?? 0, + kind + }); +} + +export async function getLatestPreview(previewsDir) { + const files = await listPreviewFiles(previewsDir); + if (files.length === 0) return null; + const fileName = files.at(-1); + return { + fileName, + step: Number(path.basename(fileName, ".png")), + path: path.join(previewsDir, fileName) + }; +} + +export async function buildArtifactList(job) { + const artifacts = []; + const outputDir = path.dirname(job.outputPath); + const primaryOutputName = path.basename(job.outputPath); + + await addArtifact(artifacts, primaryOutputName, job.outputPath, "output"); + await addArtifact(artifacts, "final.ply", path.join(outputDir, "final.ply"), "output"); + await addArtifact(artifacts, "final.spz", path.join(outputDir, "final.spz"), "output"); + await addArtifact(artifacts, "cameras.json", job.camerasPath, "metadata"); + await addArtifact(artifacts, "train.log", job.logPath, "log"); + + if (job.colmapDatabasePath) { + await addArtifact(artifacts, "colmap-database.db", job.colmapDatabasePath, "colmap", "colmap/database.db"); + } + + if (job.colmapModelPath && (await fileExists(job.colmapModelPath))) { + const entries = await fs.readdir(job.colmapModelPath, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (!entry.isFile()) continue; + const fileName = entry.name; + await addArtifact( + artifacts, + `colmap-model-${fileName}`, + path.join(job.colmapModelPath, fileName), + "colmap", + `colmap/model/${fileName}` + ); + } + } + + const previewFiles = await listPreviewFiles(job.previewsDir); + for (const fileName of previewFiles.slice(-8).reverse()) { + await addArtifact( + artifacts, + fileName, + path.join(job.previewsDir, fileName), + "preview", + `preview/${fileName}` + ); + } + + return artifacts; +} + +export async function resolveArtifact(job, name) { + if (name === "latest-preview") { + const latest = await getLatestPreview(job.previewsDir); + return latest ? { name: latest.fileName, label: `preview/${latest.fileName}`, path: latest.path, kind: "preview" } : null; + } + + const artifacts = await buildArtifactList(job); + return artifacts.find((artifact) => artifact.name === name) ?? null; +} diff --git a/web/src/config.mjs b/web/src/config.mjs new file mode 100644 index 0000000..f7ffbd6 --- /dev/null +++ b/web/src/config.mjs @@ -0,0 +1,55 @@ +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, "..", ".."); +const stateRoot = path.resolve(projectRoot, ".msplat-web"); +const VALID_COLMAP_FLAG_STYLES = new Set(["modern", "legacy"]); + +function resolveMaybeRelative(value, fallback) { + const raw = value || fallback; + return path.isAbsolute(raw) ? raw : path.resolve(projectRoot, raw); +} + +function resolveCommand(value, fallback) { + const raw = value || fallback; + if (!raw.includes("/") && !raw.startsWith(".")) { + return raw; + } + return path.isAbsolute(raw) ? raw : path.resolve(projectRoot, raw); +} + +export function resolveColmapFlagStyle(value) { + const style = value || "modern"; + if (!VALID_COLMAP_FLAG_STYLES.has(style)) { + throw new Error(`COLMAP_FLAG_STYLE must be one of: ${[...VALID_COLMAP_FLAG_STYLES].join(", ")}`); + } + return style; +} + +export function loadConfig(env = process.env) { + const jobsDir = resolveMaybeRelative(env.MSPLAT_JOBS_DIR, path.join(stateRoot, "jobs")); + const databasePath = resolveMaybeRelative(env.DATABASE_URL, path.join(stateRoot, "jobs.sqlite")); + const maxUploadGb = Number(env.MAX_UPLOAD_GB || "10"); + + if (!Number.isFinite(maxUploadGb) || maxUploadGb <= 0) { + throw new Error("MAX_UPLOAD_GB must be a positive number"); + } + + return { + host: env.HOST || "127.0.0.1", + port: Number(env.PORT || "4321"), + projectRoot, + jobsDir, + databasePath, + maxUploadBytes: Math.floor(maxUploadGb * 1024 * 1024 * 1024), + msplatBin: resolveCommand(env.MSPLAT_BIN, path.join(projectRoot, "build", "msplat")), + colmapBin: resolveCommand(env.COLMAP_BIN, "colmap"), + colmapFlagStyle: resolveColmapFlagStyle(env.COLMAP_FLAG_STYLE), + pollIntervalMs: Number(env.MSPLAT_POLL_MS || "2000"), + tailBytes: Number(env.MSPLAT_LOG_TAIL_BYTES || "24000"), + title: env.MSPLAT_SITE_TITLE || "msplat Internal Trainer", + machineName: env.MSPLAT_MACHINE_NAME || os.hostname() + }; +} diff --git a/web/src/dataset.mjs b/web/src/dataset.mjs new file mode 100644 index 0000000..84e0d55 --- /dev/null +++ b/web/src/dataset.mjs @@ -0,0 +1,292 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileExists } from "./utils.mjs"; + +export const IMAGE_EXTS = [".png", ".jpg", ".jpeg", ".JPG"]; + +async function listFiles(dir) { + try { + return await fs.readdir(dir, { withFileTypes: true }); + } catch { + return []; + } +} + +async function listCandidateRoots(rootDir) { + const entries = (await listFiles(rootDir)).filter((entry) => entry.name !== "__MACOSX"); + if (entries.length === 1 && entries[0].isDirectory()) { + return [path.join(rootDir, entries[0].name), rootDir]; + } + return [rootDir]; +} + +async function hasFile(filePath) { + return fileExists(filePath); +} + +async function findColmapBinaryModelDir(rootDir) { + for (const candidate of [rootDir, path.join(rootDir, "sparse", "0")]) { + if ( + (await hasFile(path.join(candidate, "cameras.bin"))) && + (await hasFile(path.join(candidate, "images.bin"))) && + ((await hasFile(path.join(candidate, "points3D.bin"))) || (await hasFile(path.join(candidate, "points3D.ply")))) + ) { + return candidate; + } + } + return null; +} + +async function findColmapTextModelDir(rootDir) { + for (const candidate of [rootDir, path.join(rootDir, "sparse"), path.join(rootDir, "sparse", "0")]) { + if ( + (await hasFile(path.join(candidate, "cameras.txt"))) && + (await hasFile(path.join(candidate, "images.txt"))) && + (await hasFile(path.join(candidate, "points3D.txt"))) + ) { + return candidate; + } + } + return null; +} + +async function collectImages(rootDir, maxDepth = 3, currentDepth = 0) { + if (currentDepth > maxDepth) return []; + const entries = await listFiles(rootDir); + const images = []; + for (const entry of entries) { + const entryPath = path.join(rootDir, entry.name); + if (entry.isFile() && IMAGE_EXTS.includes(path.extname(entry.name))) { + images.push(entryPath); + continue; + } + if (entry.isDirectory()) { + images.push(...await collectImages(entryPath, maxDepth, currentDepth + 1)); + } + } + return images.sort((left, right) => left.localeCompare(right)); +} + +async function resolveImageRoot(rootDir) { + for (const candidate of [path.join(rootDir, "images"), rootDir]) { + const images = await collectImages(candidate); + if (images.length > 0) { + return { imageRoot: candidate, imageFiles: images }; + } + } + return { imageRoot: null, imageFiles: [] }; +} + +async function resolveImagePath(baseDir, rawPath) { + const candidate = path.isAbsolute(rawPath) ? rawPath : path.join(baseDir, rawPath); + if (await fileExists(candidate)) return candidate; + for (const ext of IMAGE_EXTS) { + if (await fileExists(candidate + ext)) return candidate + ext; + } + return null; +} + +async function validateColmapBinary(rootDir, modelDir) { + const { imageFiles } = await resolveImageRoot(rootDir); + if (imageFiles.length === 0) { + throw new Error("COLMAP dataset is missing source images"); + } + + return { + type: "colmap", + sourceFormat: "colmap_bin", + datasetRoot: rootDir, + modelDir, + imageRoot: path.join(rootDir, "images"), + imageFiles + }; +} + +async function validateColmapText(rootDir, modelDir) { + const { imageFiles, imageRoot } = await resolveImageRoot(rootDir); + if (imageFiles.length === 0) { + throw new Error("COLMAP text export is missing source images"); + } + + return { + type: "colmap", + sourceFormat: "colmap_txt", + datasetRoot: rootDir, + modelDir, + imageRoot, + imageFiles + }; +} + +async function validateNerfstudio(rootDir) { + const transformsPath = path.join(rootDir, "transforms.json"); + const content = JSON.parse(await fs.readFile(transformsPath, "utf8")); + const frames = Array.isArray(content.frames) ? content.frames : []; + + if (frames.length === 0) { + throw new Error("Nerfstudio dataset has no frames"); + } + + for (const frame of frames) { + if (!frame.file_path) { + throw new Error("Nerfstudio dataset contains a frame without file_path"); + } + const resolved = await resolveImagePath(rootDir, frame.file_path); + if (!resolved) { + throw new Error(`Nerfstudio image not found: ${frame.file_path}`); + } + } + + let pointCloudPath = null; + if (content.ply_file_path) { + pointCloudPath = path.isAbsolute(content.ply_file_path) + ? content.ply_file_path + : path.join(rootDir, content.ply_file_path); + } else if (await fileExists(path.join(rootDir, "sparse", "0", "points3D.ply"))) { + pointCloudPath = path.join(rootDir, "sparse", "0", "points3D.ply"); + } else if (await fileExists(path.join(rootDir, "points3D.ply"))) { + pointCloudPath = path.join(rootDir, "points3D.ply"); + } + + if (!pointCloudPath || !(await fileExists(pointCloudPath))) { + throw new Error("Nerfstudio dataset is missing a seed point cloud"); + } + + return { + type: "nerfstudio", + sourceFormat: "nerfstudio", + datasetRoot: rootDir, + pointCloudPath + }; +} + +async function validatePolycam(rootDir) { + const correctedCamerasDir = path.join(rootDir, "keyframes", "corrected_cameras"); + const correctedImagesDir = path.join(rootDir, "keyframes", "corrected_images"); + const pointCloudCandidates = [ + path.join(rootDir, "keyframes", "point_cloud.ply"), + path.join(rootDir, "point_cloud.ply"), + path.join(rootDir, "sparse.ply") + ]; + + let pointCloudFound = false; + for (const candidate of pointCloudCandidates) { + if (await fileExists(candidate)) { + pointCloudFound = true; + break; + } + } + + if (!pointCloudFound) { + throw new Error("Polycam dataset is missing point_cloud.ply or sparse.ply"); + } + + if (await fileExists(correctedCamerasDir)) { + const files = (await fs.readdir(correctedCamerasDir)).filter((name) => name.endsWith(".json")).sort(); + if (files.length === 0) { + throw new Error("Polycam dataset has no corrected camera JSON files"); + } + for (const fileName of files) { + const stem = path.basename(fileName, ".json"); + const matched = await Promise.any( + IMAGE_EXTS.map(async (ext) => { + const candidate = path.join(correctedImagesDir, `${stem}${ext}`); + if (await fileExists(candidate)) return candidate; + return Promise.reject(); + }) + ).catch(() => null); + if (!matched) { + throw new Error(`Polycam image not found for ${fileName}`); + } + } + + return { + type: "polycam", + sourceFormat: "polycam", + datasetRoot: rootDir + }; + } + + const camerasJsonPath = path.join(rootDir, "cameras.json"); + if (!(await fileExists(camerasJsonPath))) { + throw new Error("Polycam dataset is missing cameras.json"); + } + + const content = JSON.parse(await fs.readFile(camerasJsonPath, "utf8")); + const frames = Array.isArray(content.frames) ? content.frames : Array.isArray(content) ? content : []; + + if (frames.length === 0) { + throw new Error("Polycam cameras.json contains no frames"); + } + + for (const frame of frames) { + if (!frame.file_path) { + throw new Error("Polycam cameras.json contains a frame without file_path"); + } + const resolved = await resolveImagePath(rootDir, frame.file_path); + if (!resolved) { + throw new Error(`Polycam image not found: ${frame.file_path}`); + } + } + + return { + type: "polycam", + sourceFormat: "polycam", + datasetRoot: rootDir + }; +} + +async function validateRawImages(rootDir) { + const { imageFiles, imageRoot } = await resolveImageRoot(rootDir); + if (imageFiles.length < 3) { + throw new Error("Raw photo uploads need at least 3 images"); + } + + return { + type: "colmap", + sourceFormat: "raw_images", + datasetRoot: rootDir, + imageRoot, + imageFiles + }; +} + +export async function inspectInput(rootDir) { + const candidates = await listCandidateRoots(rootDir); + + for (const candidate of candidates) { + if (await fileExists(path.join(candidate, "transforms.json"))) { + return validateNerfstudio(candidate); + } + + const colmapBinaryDir = await findColmapBinaryModelDir(candidate); + if (colmapBinaryDir) { + return validateColmapBinary(candidate, colmapBinaryDir); + } + + const colmapTextDir = await findColmapTextModelDir(candidate); + if (colmapTextDir) { + return validateColmapText(candidate, colmapTextDir); + } + + if ( + (await fileExists(path.join(candidate, "keyframes", "corrected_cameras"))) || + (await fileExists(path.join(candidate, "cameras.json"))) + ) { + return validatePolycam(candidate); + } + } + + for (const candidate of candidates) { + const rawImages = await collectImages(candidate); + if (rawImages.length > 0) { + return validateRawImages(candidate); + } + } + + throw new Error("Dataset is not a supported COLMAP, Nerfstudio, or Polycam export"); +} + +export async function inspectDataset(rootDir) { + return inspectInput(rootDir); +} diff --git a/web/src/db.mjs b/web/src/db.mjs new file mode 100644 index 0000000..acbab8e --- /dev/null +++ b/web/src/db.mjs @@ -0,0 +1,356 @@ +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { ensureDir, nowIso } from "./utils.mjs"; +import { getPreset } from "./presets.mjs"; + +const BOOLEAN_COLUMNS = new Set(["cancel_requested"]); + +const COLUMN_MAP = { + name: "name", + preset: "preset", + status: "status", + inputKind: "input_kind", + phase: "phase", + phaseMessage: "phase_message", + colmapMode: "colmap_mode", + sourceFormat: "source_format", + datasetType: "dataset_type", + uploadName: "upload_name", + uploadSizeBytes: "upload_size_bytes", + uploadPath: "upload_path", + jobDir: "job_dir", + datasetRoot: "dataset_root", + outputPath: "output_path", + camerasPath: "cameras_path", + logPath: "log_path", + previewsDir: "previews_dir", + latestPreviewPath: "latest_preview_path", + progressStep: "progress_step", + progressPercent: "progress_percent", + finalPsnr: "final_psnr", + finalSsim: "final_ssim", + finalL1: "final_l1", + finalGaussians: "final_gaussians", + errorMessage: "error_message", + cancelRequested: "cancel_requested", + workerPid: "worker_pid", + runnerPid: "runner_pid", + colmapWorkspacePath: "colmap_workspace_path", + colmapDatabasePath: "colmap_database_path", + colmapModelPath: "colmap_model_path", + createdAt: "created_at", + updatedAt: "updated_at", + startedAt: "started_at", + finishedAt: "finished_at" +}; + +const MIGRATIONS = [ + ["input_kind", "ALTER TABLE jobs ADD COLUMN input_kind TEXT NOT NULL DEFAULT 'prepared_zip'"], + ["phase", "ALTER TABLE jobs ADD COLUMN phase TEXT NOT NULL DEFAULT 'validating'"], + ["phase_message", "ALTER TABLE jobs ADD COLUMN phase_message TEXT NOT NULL DEFAULT 'Waiting for worker'"], + ["colmap_mode", "ALTER TABLE jobs ADD COLUMN colmap_mode TEXT"], + ["source_format", "ALTER TABLE jobs ADD COLUMN source_format TEXT"], + ["colmap_workspace_path", "ALTER TABLE jobs ADD COLUMN colmap_workspace_path TEXT"], + ["colmap_database_path", "ALTER TABLE jobs ADD COLUMN colmap_database_path TEXT"], + ["colmap_model_path", "ALTER TABLE jobs ADD COLUMN colmap_model_path TEXT"] +]; + +function rowToJob(row) { + if (!row) return null; + return { + id: row.id, + name: row.name, + preset: row.preset, + presetLabel: getPreset(row.preset)?.label ?? row.preset, + status: row.status, + inputKind: row.input_kind, + phase: row.phase, + phaseMessage: row.phase_message, + colmapMode: row.colmap_mode, + sourceFormat: row.source_format, + datasetType: row.dataset_type, + uploadName: row.upload_name, + uploadSizeBytes: row.upload_size_bytes, + uploadPath: row.upload_path, + jobDir: row.job_dir, + datasetRoot: row.dataset_root, + outputPath: row.output_path, + camerasPath: row.cameras_path, + logPath: row.log_path, + previewsDir: row.previews_dir, + latestPreviewPath: row.latest_preview_path, + progressStep: row.progress_step, + progressPercent: row.progress_percent, + finalPsnr: row.final_psnr, + finalSsim: row.final_ssim, + finalL1: row.final_l1, + finalGaussians: row.final_gaussians, + errorMessage: row.error_message, + cancelRequested: Boolean(row.cancel_requested), + workerPid: row.worker_pid, + runnerPid: row.runner_pid, + colmapWorkspacePath: row.colmap_workspace_path, + colmapDatabasePath: row.colmap_database_path, + colmapModelPath: row.colmap_model_path, + createdAt: row.created_at, + updatedAt: row.updated_at, + startedAt: row.started_at, + finishedAt: row.finished_at + }; +} + +function normalizePatchValue(column, value) { + if (BOOLEAN_COLUMNS.has(column)) { + return value ? 1 : 0; + } + return value; +} + +function ensureColumns(db) { + const columns = new Set(db.prepare("PRAGMA table_info(jobs)").all().map((row) => row.name)); + for (const [column, statement] of MIGRATIONS) { + if (!columns.has(column)) { + db.exec(statement); + } + } +} + +export async function openDatabase(databasePath) { + await ensureDir(path.dirname(databasePath)); + const db = new DatabaseSync(databasePath); + db.exec(` + PRAGMA journal_mode = WAL; + PRAGMA busy_timeout = 5000; + CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + preset TEXT NOT NULL, + status TEXT NOT NULL, + input_kind TEXT NOT NULL DEFAULT 'prepared_zip', + phase TEXT NOT NULL DEFAULT 'validating', + phase_message TEXT NOT NULL DEFAULT 'Waiting for worker', + colmap_mode TEXT, + source_format TEXT, + dataset_type TEXT, + upload_name TEXT NOT NULL, + upload_size_bytes INTEGER NOT NULL, + upload_path TEXT NOT NULL, + job_dir TEXT NOT NULL, + dataset_root TEXT, + output_path TEXT NOT NULL, + cameras_path TEXT NOT NULL, + log_path TEXT NOT NULL, + previews_dir TEXT NOT NULL, + latest_preview_path TEXT, + progress_step INTEGER NOT NULL DEFAULT 0, + progress_percent REAL NOT NULL DEFAULT 0, + final_psnr REAL, + final_ssim REAL, + final_l1 REAL, + final_gaussians INTEGER, + error_message TEXT, + cancel_requested INTEGER NOT NULL DEFAULT 0, + worker_pid INTEGER, + runner_pid INTEGER, + colmap_workspace_path TEXT, + colmap_database_path TEXT, + colmap_model_path TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + started_at TEXT, + finished_at TEXT + ); + CREATE INDEX IF NOT EXISTS jobs_status_created_idx ON jobs(status, created_at); + `); + ensureColumns(db); + return db; +} + +export function createJobStore(db) { + function patchJob(id, patch) { + const sets = []; + const values = []; + + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue; + const column = COLUMN_MAP[key]; + if (!column) continue; + sets.push(`${column} = ?`); + values.push(normalizePatchValue(column, value)); + } + + sets.push("updated_at = ?"); + values.push(nowIso()); + values.push(id); + + db.prepare(`UPDATE jobs SET ${sets.join(", ")} WHERE id = ?`).run(...values); + return getJob(id); + } + + function getJob(id) { + return rowToJob(db.prepare("SELECT * FROM jobs WHERE id = ?").get(id)); + } + + return { + createJob(input) { + const timestamp = nowIso(); + db.prepare(` + INSERT INTO jobs ( + id, name, preset, status, input_kind, phase, phase_message, colmap_mode, source_format, + upload_name, upload_size_bytes, upload_path, job_dir, output_path, cameras_path, + log_path, previews_dir, created_at, updated_at + ) VALUES (?, ?, ?, 'uploaded', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + input.id, + input.name, + input.preset, + input.inputKind, + input.phase ?? "validating", + input.phaseMessage ?? "Waiting for worker", + input.colmapMode ?? null, + input.sourceFormat ?? null, + input.uploadName, + input.uploadSizeBytes, + input.uploadPath, + input.jobDir, + input.outputPath, + input.camerasPath, + input.logPath, + input.previewsDir, + timestamp, + timestamp + ); + return getJob(input.id); + }, + + getJob, + + listJobs() { + return db.prepare("SELECT * FROM jobs ORDER BY created_at DESC").all().map(rowToJob); + }, + + deleteJob(id) { + const result = db.prepare("DELETE FROM jobs WHERE id = ?").run(id); + return result.changes > 0; + }, + + patchJob, + + claimNextUploaded(workerPid) { + const row = db.prepare(` + UPDATE jobs + SET status = 'validating', + worker_pid = ?, + updated_at = ? + WHERE id = ( + SELECT id + FROM jobs + WHERE status = 'uploaded' AND cancel_requested = 0 + ORDER BY created_at ASC + LIMIT 1 + ) + RETURNING * + `).get(workerPid, nowIso()); + return rowToJob(row); + }, + + markQueued(id, fields = {}) { + return patchJob(id, { + status: "queued", + cancelRequested: false, + ...fields + }); + }, + + claimNextQueued(workerPid) { + const timestamp = nowIso(); + const row = db.prepare(` + UPDATE jobs + SET status = 'running', + worker_pid = ?, + started_at = COALESCE(started_at, ?), + updated_at = ? + WHERE id = ( + SELECT id + FROM jobs + WHERE status = 'queued' AND cancel_requested = 0 + ORDER BY created_at ASC + LIMIT 1 + ) + RETURNING * + `).get(workerPid, timestamp, timestamp); + return rowToJob(row); + }, + + requestCancel(id) { + const job = getJob(id); + if (!job) return null; + + if (job.status === "uploaded" || (job.status === "queued" && !job.runnerPid && !job.workerPid)) { + return this.markCancelled(id); + } + + if (job.status === "validating" || job.status === "queued" || job.status === "running") { + return patchJob(id, { cancelRequested: true }); + } + + return job; + }, + + setRunnerPid(id, runnerPid) { + return patchJob(id, { runnerPid }); + }, + + markPhase(id, phase, phaseMessage, progressPercent, progressStep = 0, extra = {}) { + return patchJob(id, { + phase, + phaseMessage, + progressPercent, + progressStep, + ...extra + }); + }, + + markSucceeded(id, metrics = {}) { + return patchJob(id, { + status: "succeeded", + phase: "finalizing", + phaseMessage: "Training complete", + progressPercent: 100, + finalPsnr: metrics.psnr ?? null, + finalSsim: metrics.ssim ?? null, + finalL1: metrics.l1 ?? null, + finalGaussians: metrics.gaussians ?? null, + errorMessage: null, + cancelRequested: false, + runnerPid: null, + workerPid: null, + finishedAt: nowIso() + }); + }, + + markFailed(id, errorMessage) { + return patchJob(id, { + status: "failed", + phaseMessage: errorMessage, + errorMessage, + cancelRequested: false, + runnerPid: null, + workerPid: null, + finishedAt: nowIso() + }); + }, + + markCancelled(id, errorMessage = null) { + return patchJob(id, { + status: "cancelled", + phaseMessage: "Cancelled", + errorMessage, + cancelRequested: false, + runnerPid: null, + workerPid: null, + finishedAt: nowIso() + }); + } + }; +} diff --git a/web/src/logger.mjs b/web/src/logger.mjs new file mode 100644 index 0000000..95cd513 --- /dev/null +++ b/web/src/logger.mjs @@ -0,0 +1,44 @@ +function formatValue(value) { + if (value === undefined) return null; + if (value === null) return "null"; + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (value instanceof Error) { + return JSON.stringify(value.message); + } + return JSON.stringify(value); +} + +function writeLine(method, scope, level, message, fields = {}) { + const parts = [ + `[${new Date().toISOString()}]`, + `[${scope}]`, + `[${level}]`, + message + ]; + + const fieldParts = Object.entries(fields) + .map(([key, value]) => { + const rendered = formatValue(value); + return rendered === null ? null : `${key}=${rendered}`; + }) + .filter(Boolean); + + const line = fieldParts.length > 0 ? `${parts.join(" ")} ${fieldParts.join(" ")}` : parts.join(" "); + method(line); +} + +export function createLogger(scope) { + return { + info(message, fields = {}) { + writeLine(console.log, scope, "INFO", message, fields); + }, + warn(message, fields = {}) { + writeLine(console.warn, scope, "WARN", message, fields); + }, + error(message, fields = {}) { + writeLine(console.error, scope, "ERROR", message, fields); + } + }; +} diff --git a/web/src/logs.mjs b/web/src/logs.mjs new file mode 100644 index 0000000..041b6b1 --- /dev/null +++ b/web/src/logs.mjs @@ -0,0 +1,37 @@ +import fs from "node:fs/promises"; + +export async function readTail(filePath, maxBytes) { + try { + const handle = await fs.open(filePath, "r"); + try { + const stat = await handle.stat(); + const size = stat.size; + const start = Math.max(0, size - maxBytes); + const length = size - start; + const buffer = Buffer.alloc(length); + await handle.read(buffer, 0, length, start); + return buffer.toString("utf8"); + } finally { + await handle.close(); + } + } catch { + return ""; + } +} + +export function parseFinalMetrics(logText) { + const match = logText.match( + /=== Validation[\s\S]*?PSNR:\s*([0-9.]+)\s+SSIM:\s*([0-9.]+)\s+L1:\s*([0-9.]+)\s+Gaussians:\s*([0-9]+)/m + ); + + if (!match) { + return null; + } + + return { + psnr: Number(match[1]), + ssim: Number(match[2]), + l1: Number(match[3]), + gaussians: Number(match[4]) + }; +} diff --git a/web/src/pages.mjs b/web/src/pages.mjs new file mode 100644 index 0000000..966a263 --- /dev/null +++ b/web/src/pages.mjs @@ -0,0 +1,789 @@ +import path from "node:path"; +import { buildArtifactList } from "./artifacts.mjs"; +import { escapeHtml, formatBytes, formatDate, formatNumber } from "./utils.mjs"; +import { listPresets } from "./presets.mjs"; + +function layout(config, title, content, scripts = "") { + return ` + + + + + ${escapeHtml(title)} · ${escapeHtml(config.title)} + + + + + ${scripts} + +`; +} + +function statusPill(status, id = "") { + const attr = id ? ` id="${id}"` : ""; + return `${escapeHtml(status)}`; +} + +function progressBar(percent) { + return `
+
+
`; +} + +function labelForSourceFormat(sourceFormat) { + return { + colmap_bin: "COLMAP BIN", + colmap_txt: "COLMAP TXT", + nerfstudio: "Nerfstudio", + polycam: "Polycam", + raw_images: "Raw Images" + }[sourceFormat] ?? "Pending"; +} + +function labelForInputKind(inputKind) { + return { + prepared_zip: "Prepared Zip", + raw_zip: "Raw Image Zip", + raw_files: "Raw Photos" + }[inputKind] ?? "Pending"; +} + +function buildProgressLabel(job) { + if (job.phaseMessage && job.phase === "training" && job.progressStep) { + return `${job.phaseMessage} · step ${job.progressStep.toLocaleString()}`; + } + if (job.phaseMessage) { + return job.phaseMessage; + } + if (job.progressStep) { + return `Step ${job.progressStep.toLocaleString()}`; + } + return "Waiting for worker"; +} + +function isActiveStatus(status) { + return ["uploaded", "validating", "queued", "running"].includes(status); +} + +function canDeleteStatus(status) { + return ["succeeded", "failed", "cancelled"].includes(status); +} + +function renderPresetCards(groupName, checkedKey = "preview") { + return listPresets().map((preset) => ` + + `).join(""); +} + +function renderColmapModeSelect(name, required = false, blankLabel = "Use only for raw-image uploads") { + return ` + + `; +} + +export async function renderJobsPage(config, jobs) { + const rows = jobs.map((job) => { + const latestPreview = job.latestPreviewPath + ? `/api/jobs/${job.id}/artifacts/${encodeURIComponent(path.basename(job.latestPreviewPath))}` + : ""; + + return `
+
+ ${statusPill(job.status)} + ${escapeHtml(job.presetLabel)} + ${escapeHtml(labelForSourceFormat(job.sourceFormat))} + ${escapeHtml(labelForInputKind(job.inputKind))} +
+
+
+

${escapeHtml(job.name)}

+

${escapeHtml(job.uploadName)}

+

${escapeHtml(buildProgressLabel(job))}

+
+ ${latestPreview + ? `Latest validation preview for ${escapeHtml(job.name)}` + : `
Awaiting preview
`} +
+
+ ${formatDate(job.createdAt)} + ${formatBytes(job.uploadSizeBytes)} + ${escapeHtml(job.colmapMode ? `COLMAP ${job.colmapMode}` : (job.phase || "pending"))} +
+ ${progressBar(job.progressPercent || 0)} +
+ Open + ${canDeleteStatus(job.status) ? `` : ""} +
+
`; + }).join(""); + + return layout( + config, + "Jobs", + `
+

Internal training queue

+

Queue prepared datasets or raw photos and let the worker build the splat.

+

This site now accepts prepared zips, COLMAP TXT exports, raw-photo zips, and direct multi-image uploads. Raw photos are reconstructed with COLMAP on the same Apple Silicon host before msplat training starts.

+ +
+
+

Recent jobs

+

${jobs.length} total

+
+
+ ${rows || `

No jobs yet

Upload a prepared dataset zip or start with raw photos.

Create the first job
`} +
`, + `` + ); +} + +export function renderNewJobPage(config) { + const presetCards = renderPresetCards("preset"); + + return layout( + config, + "New Job", + `
+

New training run

+

Upload one input and queue the job.

+

Use a dataset zip for prepared inputs, COLMAP TXT exports, or raw-image zips. Use raw photos when you only have images and want the worker to build COLMAP before training.

+
+
+
+
+

Single Upload Flow

+

Choose the input type, then upload once

+

Prepared datasets can train directly. Raw images and raw-image zips will run through COLMAP first.

+
+
+ +
+ Input type +
+ + +
+
+
+ +
+ + +
+ Preset +
${presetCards}
+
+
+

Dataset zip mode accepts prepared datasets directly.

+

COLMAP TXT zips will be converted to BIN automatically.

+

Raw-image zips can optionally use a selected COLMAP matching mode before training.

+

Example prepared zip datasets: COLMAP sample datasets.

+
+ +

+
+
+
`, + `` + ); +} + +export async function renderJobDetailPage(config, job) { + const artifacts = await buildArtifactList(job); + const outputArtifacts = artifacts.filter((artifact) => artifact.kind === "output"); + const artifactLinks = artifacts + .map((artifact) => `
  • ${escapeHtml(artifact.label || artifact.name)} ${formatBytes(artifact.sizeBytes)}
  • `) + .join(""); + const outputLinks = outputArtifacts + .map((artifact) => ` +
  • +
    +
    + ${escapeHtml(artifact.name)} + ${formatBytes(artifact.sizeBytes)} +
    + Download +
    +

    Stored on this machine

    + ${escapeHtml(artifact.path)} +
  • + `) + .join(""); + const latestPreviewUrl = job.latestPreviewPath + ? `/api/jobs/${job.id}/artifacts/${encodeURIComponent(path.basename(job.latestPreviewPath))}` + : ""; + + return layout( + config, + job.name, + `
    +
    +
    +

    Job detail

    +

    ${escapeHtml(job.name)}

    +

    ${escapeHtml(buildProgressLabel(job))}

    +
    +
    + ${statusPill(job.status, "job-status-pill")} + ${escapeHtml(job.phase || "pending")} + ${escapeHtml(job.presetLabel)} + ${escapeHtml(labelForSourceFormat(job.sourceFormat))} +
    +
    + Back to queue + + +
    +
    +
    +
    +
    +
    +

    Overview

    +

    ${Math.round(job.progressPercent || 0)}%

    +
    +
    +
    +

    Training progress

    +

    Current state

    +
    + ${progressBar(job.progressPercent || 0)} +
    +
    Status
    ${escapeHtml(job.status)}
    +
    Phase
    ${escapeHtml(job.phase || "pending")}
    +
    Preset
    ${escapeHtml(job.presetLabel)}
    +
    Dataset
    ${escapeHtml(job.datasetType || "pending")}
    +
    Source
    ${escapeHtml(labelForSourceFormat(job.sourceFormat))}
    +
    Input
    ${escapeHtml(labelForInputKind(job.inputKind))}
    +
    COLMAP Mode
    ${escapeHtml(job.colmapMode || "—")}
    +
    Uploaded
    ${formatDate(job.createdAt)}
    +
    PSNR
    ${formatNumber(job.finalPsnr)}
    +
    SSIM
    ${formatNumber(job.finalSsim, 3)}
    +
    L1
    ${formatNumber(job.finalL1, 4)}
    +
    Gaussians
    ${job.finalGaussians?.toLocaleString?.() ?? "—"}
    +
    +

    ${escapeHtml(job.errorMessage || "")}

    +
    +
    +
    +
    +

    Outputs

    +

    ${artifacts.length} artifacts

    +
    +
    +
    +

    Output files

    +

    Download or copy path

    +
    +

    Click Download to save through the browser. The generated file also stays on this machine at the path shown below.

    +
      ${outputLinks || "
    • No output file yet.
    • "}
    +
    +
    +
    +

    Latest validation render

    +

    Updates during training

    +
    + ${latestPreviewUrl + ? `Latest validation preview` + : `
    No preview yet
    Latest validation preview`} +
    +
    +
    +

    Artifacts

    +

    Logs, previews, outputs

    +
    +
      ${artifactLinks || "
    • Nothing available yet.
    • "}
    +
    +
    +
    +
    +

    Diagnostics

    +

    Polling every 5 seconds

    +
    +
    +
    +
    +

    Log tail

    +

    Worker + trainer output

    +
    +
    + +

    +
    +
    +
    
    +        
    +
    +
    `, + `` + ); +} diff --git a/web/src/presets.mjs b/web/src/presets.mjs new file mode 100644 index 0000000..e835810 --- /dev/null +++ b/web/src/presets.mjs @@ -0,0 +1,31 @@ +export const PRESETS = { + preview: { + key: "preview", + label: "Preview", + iterations: 1500, + downscaleFactor: 2, + args: ["-n", "1500", "-d", "2", "--num-downscales", "2", "--val"] + }, + standard: { + key: "standard", + label: "Standard", + iterations: 7000, + downscaleFactor: 1, + args: ["-n", "7000", "-d", "1", "--num-downscales", "0", "--val"] + }, + high: { + key: "high", + label: "High", + iterations: 30000, + downscaleFactor: 1, + args: ["-n", "30000", "-d", "1", "--num-downscales", "0", "--val"] + } +}; + +export function getPreset(key) { + return PRESETS[key] ?? null; +} + +export function listPresets() { + return Object.values(PRESETS); +} diff --git a/web/src/server-app.mjs b/web/src/server-app.mjs new file mode 100644 index 0000000..d959c67 --- /dev/null +++ b/web/src/server-app.mjs @@ -0,0 +1,556 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import http from "node:http"; +import path from "node:path"; +import { Readable, Writable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { Transform } from "node:stream"; +import { buildArtifactList, getLatestPreview, resolveArtifact } from "./artifacts.mjs"; +import { loadConfig } from "./config.mjs"; +import { openDatabase, createJobStore } from "./db.mjs"; +import { createLogger } from "./logger.mjs"; +import { renderJobDetailPage, renderJobsPage, renderNewJobPage } from "./pages.mjs"; +import { getPreset } from "./presets.mjs"; +import { readTail } from "./logs.mjs"; +import { + createJobId, + ensureDir, + escapeHtml, + fileExists, + formatBytes, + formatNumber, + safeFileName +} from "./utils.mjs"; + +const VALID_COLMAP_MODES = new Set(["sequential", "exhaustive"]); +const RAW_IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".JPG"]); +const DELETABLE_JOB_STATUSES = new Set(["succeeded", "failed", "cancelled"]); + +function json(response, statusCode, payload) { + response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify(payload)); +} + +function html(response, statusCode, body) { + response.writeHead(statusCode, { "content-type": "text/html; charset=utf-8" }); + response.end(body); +} + +function sendFile(response, filePath, contentType) { + response.writeHead(200, { "content-type": contentType }); + fs.createReadStream(filePath).pipe(response); +} + +function notFound(response, message = "Not found") { + html(response, 404, `Not found

    ${escapeHtml(message)}

    `); +} + +function buildJobPayload(job) { + return { + ...job, + finalPsnrLabel: formatNumber(job.finalPsnr), + finalSsimLabel: formatNumber(job.finalSsim, 3), + finalL1Label: formatNumber(job.finalL1, 4), + finalGaussiansLabel: job.finalGaussians != null ? Number(job.finalGaussians).toLocaleString() : "—" + }; +} + +function validatePreset(rawPreset) { + const preset = getPreset(rawPreset || ""); + if (!preset) { + throw new Error("Invalid preset"); + } + return preset; +} + +function validateColmapMode(rawMode, fallback = null) { + if (!rawMode) return fallback; + if (!VALID_COLMAP_MODES.has(rawMode)) { + throw new Error("Invalid COLMAP mode"); + } + return rawMode; +} + +function isPathInside(basePath, targetPath) { + const relative = path.relative(path.resolve(basePath), path.resolve(targetPath)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function buildJobPaths(baseJobsDir, jobId) { + const jobDir = path.join(baseJobsDir, jobId); + const outputDir = path.join(jobDir, "output"); + return { + jobDir, + outputDir, + uploadPath: path.join(jobDir, "upload.zip"), + rawUploadDir: path.join(jobDir, "raw-upload"), + outputPath: path.join(outputDir, "final.spl"), + camerasPath: path.join(outputDir, "cameras.json"), + previewsDir: path.join(jobDir, "previews"), + logPath: path.join(jobDir, "train.log") + }; +} + +async function readRequestBody(request, maxBytes) { + const chunks = []; + let bytes = 0; + + for await (const chunk of request) { + bytes += chunk.length; + if (bytes > maxBytes) { + throw new Error(`Upload exceeds ${formatBytes(maxBytes)}`); + } + chunks.push(Buffer.from(chunk)); + } + + return Buffer.concat(chunks); +} + +async function streamUploadToFile(request, destinationPath, maxBytes) { + let bytes = 0; + const limiter = new Transform({ + transform(chunk, _encoding, callback) { + bytes += chunk.length; + if (bytes > maxBytes) { + callback(new Error(`Upload exceeds ${formatBytes(maxBytes)}`)); + return; + } + callback(null, chunk); + } + }); + + await pipeline(request, limiter, fs.createWriteStream(destinationPath)); + return bytes; +} + +async function parseMultipartForm(request, maxBytes) { + const contentType = request.headers["content-type"] || ""; + if (!contentType.includes("multipart/form-data")) { + throw new Error("Expected multipart/form-data upload"); + } + + const body = await readRequestBody(request, maxBytes); + const parsed = await new Request("http://local/upload", { + method: "POST", + headers: { "content-type": contentType }, + body + }).formData(); + + return parsed; +} + +async function saveRawFiles(formData, rawUploadDir) { + const files = formData.getAll("images"); + if (files.length < 3) { + throw new Error("Raw photo uploads need at least 3 images"); + } + + await ensureDir(rawUploadDir); + + let uploadSizeBytes = 0; + const saved = []; + + for (let index = 0; index < files.length; index += 1) { + const file = files[index]; + if (typeof file?.arrayBuffer !== "function" || !file.name) { + continue; + } + + const extension = path.extname(file.name) || ".jpg"; + if (!RAW_IMAGE_EXTS.has(extension) && !(file.type || "").startsWith("image/")) { + throw new Error(`Unsupported raw photo file: ${file.name}`); + } + const baseName = safeFileName(path.basename(file.name, extension)) || `image-${index + 1}`; + const destinationName = `${String(index + 1).padStart(6, "0")}_${baseName}${extension}`; + const destinationPath = path.join(rawUploadDir, destinationName); + const buffer = Buffer.from(await file.arrayBuffer()); + uploadSizeBytes += buffer.length; + await fsp.writeFile(destinationPath, buffer); + saved.push(destinationPath); + } + + if (saved.length < 3) { + throw new Error("Raw photo uploads need at least 3 image files"); + } + + return { + uploadSizeBytes, + saved + }; +} + +export async function createAppServer(overrides = {}) { + const config = { ...loadConfig(), ...overrides }; + const logger = createLogger("server"); + await ensureDir(config.jobsDir); + const db = overrides.db ?? await openDatabase(config.databasePath); + const store = overrides.store ?? createJobStore(db); + const cssPath = path.join(config.projectRoot, "web", "public", "app.css"); + + const requestHandler = async (request, response) => { + try { + const url = new URL(request.url, `http://${request.headers.host || "localhost"}`); + const parts = url.pathname.split("/").filter(Boolean); + + if (request.method === "GET" && url.pathname === "/") { + response.writeHead(302, { location: "/jobs" }); + response.end(); + return; + } + + if (request.method === "GET" && url.pathname === "/assets/app.css") { + sendFile(response, cssPath, "text/css; charset=utf-8"); + return; + } + + if (request.method === "GET" && url.pathname === "/jobs") { + html(response, 200, await renderJobsPage(config, store.listJobs())); + return; + } + + if (request.method === "GET" && url.pathname === "/jobs/new") { + html(response, 200, renderNewJobPage(config)); + return; + } + + if (request.method === "GET" && parts[0] === "jobs" && parts.length === 2) { + const job = store.getJob(parts[1]); + if (!job) { + notFound(response, "Job not found"); + return; + } + html(response, 200, await renderJobDetailPage(config, job)); + return; + } + + if (parts[0] === "api" && parts[1] === "jobs" && request.method === "POST" && parts.length === 2) { + const preset = validatePreset(url.searchParams.get("preset")); + const colmapMode = validateColmapMode(url.searchParams.get("colmapMode"), null); + const rawName = safeFileName(url.searchParams.get("name") || ""); + const uploadName = safeFileName(request.headers["x-file-name"] || "dataset.zip"); + if (!uploadName.toLowerCase().endsWith(".zip")) { + json(response, 400, { error: "Upload must be a .zip file" }); + return; + } + + const jobId = createJobId(); + const paths = buildJobPaths(config.jobsDir, jobId); + const name = rawName || path.basename(uploadName, ".zip"); + + await ensureDir(paths.jobDir); + await ensureDir(paths.outputDir); + await ensureDir(paths.previewsDir); + + try { + const uploadSizeBytes = await streamUploadToFile(request, paths.uploadPath, config.maxUploadBytes); + const job = store.createJob({ + id: jobId, + name, + preset: preset.key, + inputKind: "prepared_zip", + colmapMode, + uploadName, + uploadSizeBytes, + uploadPath: paths.uploadPath, + jobDir: paths.jobDir, + outputPath: paths.outputPath, + camerasPath: paths.camerasPath, + logPath: paths.logPath, + previewsDir: paths.previewsDir + }); + logger.info("zip upload accepted", { + jobId, + name, + preset: preset.key, + colmapMode, + uploadName, + uploadSizeBytes + }); + json(response, 201, { job: buildJobPayload(job) }); + } catch (error) { + await fsp.rm(paths.jobDir, { recursive: true, force: true }); + logger.warn("zip upload rejected", { + uploadName, + error: error.message + }); + json(response, 400, { error: error.message }); + } + return; + } + + if (parts[0] === "api" && parts[1] === "jobs" && request.method === "POST" && parts[2] === "raw") { + const formData = await parseMultipartForm(request, config.maxUploadBytes); + const preset = validatePreset(formData.get("preset")); + const colmapMode = validateColmapMode(formData.get("colmapMode"), "sequential"); + const rawName = safeFileName(formData.get("name") || ""); + + const jobId = createJobId(); + const paths = buildJobPaths(config.jobsDir, jobId); + await ensureDir(paths.jobDir); + await ensureDir(paths.outputDir); + await ensureDir(paths.previewsDir); + await ensureDir(paths.rawUploadDir); + + try { + const { uploadSizeBytes, saved } = await saveRawFiles(formData, paths.rawUploadDir); + const job = store.createJob({ + id: jobId, + name: rawName || `Raw upload ${jobId}`, + preset: preset.key, + inputKind: "raw_files", + colmapMode, + sourceFormat: "raw_images", + uploadName: `${saved.length} images`, + uploadSizeBytes, + uploadPath: paths.rawUploadDir, + jobDir: paths.jobDir, + outputPath: paths.outputPath, + camerasPath: paths.camerasPath, + logPath: paths.logPath, + previewsDir: paths.previewsDir + }); + logger.info("raw upload accepted", { + jobId, + name: rawName || `Raw upload ${jobId}`, + preset: preset.key, + colmapMode, + imageCount: saved.length, + uploadSizeBytes + }); + json(response, 201, { job: buildJobPayload(job) }); + } catch (error) { + await fsp.rm(paths.jobDir, { recursive: true, force: true }); + logger.warn("raw upload rejected", { + error: error.message + }); + json(response, 400, { error: error.message }); + } + return; + } + + if (parts[0] === "api" && parts[1] === "jobs" && request.method === "GET" && parts.length === 2) { + json(response, 200, { jobs: store.listJobs().map(buildJobPayload) }); + return; + } + + if (parts[0] === "api" && parts[1] === "jobs" && request.method === "GET" && parts.length === 3) { + const job = store.getJob(parts[2]); + if (!job) { + json(response, 404, { error: "Job not found" }); + return; + } + + const latestPreview = await getLatestPreview(job.previewsDir); + const artifacts = await buildArtifactList(job); + json(response, 200, { + job: buildJobPayload(job), + logTail: await readTail(job.logPath, config.tailBytes), + latestPreviewUrl: latestPreview + ? `/api/jobs/${job.id}/artifacts/${encodeURIComponent(latestPreview.fileName)}` + : "", + artifacts: artifacts.map((artifact) => ({ + name: artifact.name, + label: artifact.label ?? artifact.name, + kind: artifact.kind, + path: artifact.path, + sizeBytes: artifact.sizeBytes, + sizeLabel: formatBytes(artifact.sizeBytes), + url: `/api/jobs/${job.id}/artifacts/${encodeURIComponent(artifact.name)}` + })) + }); + return; + } + + if (parts[0] === "api" && parts[1] === "jobs" && request.method === "POST" && parts.length === 4 && parts[3] === "cancel") { + const job = store.requestCancel(parts[2]); + if (!job) { + json(response, 404, { error: "Job not found" }); + return; + } + logger.info("cancel requested", { + jobId: parts[2], + status: job.status, + cancelRequested: job.cancelRequested + }); + json(response, 200, { job: buildJobPayload(job) }); + return; + } + + if (parts[0] === "api" && parts[1] === "jobs" && request.method === "POST" && parts.length === 4 && parts[3] === "delete") { + const job = store.getJob(parts[2]); + if (!job) { + json(response, 404, { error: "Job not found" }); + return; + } + if (!DELETABLE_JOB_STATUSES.has(job.status)) { + logger.warn("delete rejected for active job", { + jobId: job.id, + status: job.status + }); + json(response, 409, { error: "Active jobs cannot be deleted. Cancel them first and wait for completion." }); + return; + } + if (!job.jobDir || !isPathInside(config.jobsDir, job.jobDir)) { + logger.error("delete rejected for unmanaged job directory", { + jobId: job.id, + jobDir: job.jobDir + }); + json(response, 500, { error: "Job directory is outside the managed jobs folder" }); + return; + } + + await fsp.rm(job.jobDir, { recursive: true, force: true }); + store.deleteJob(job.id); + logger.info("job deleted", { + jobId: job.id, + status: job.status, + jobDir: job.jobDir + }); + json(response, 200, { deleted: true, jobId: job.id }); + return; + } + + if (parts[0] === "api" && parts[1] === "jobs" && request.method === "GET" && parts.length === 5 && parts[3] === "artifacts") { + const job = store.getJob(parts[2]); + if (!job) { + json(response, 404, { error: "Job not found" }); + return; + } + const artifact = await resolveArtifact(job, decodeURIComponent(parts[4])); + if (!artifact || !(await fileExists(artifact.path))) { + json(response, 404, { error: "Artifact not found" }); + return; + } + const contentType = artifact.kind === "preview" + ? "image/png" + : artifact.kind === "log" + ? "text/plain; charset=utf-8" + : artifact.kind === "colmap" && artifact.name.endsWith(".db") + ? "application/octet-stream" + : "application/octet-stream"; + sendFile(response, artifact.path, contentType); + return; + } + + notFound(response); + } catch (error) { + logger.error("request failed", { + method: request.method, + url: request.url, + error: error.message + }); + json(response, 500, { error: error.message }); + } + }; + + const server = http.createServer(requestHandler); + + class InjectRequest extends Readable { + constructor({ method, url, headers, body }) { + super(); + this.method = method; + this.url = url; + this.headers = headers; + this._body = body; + this._sent = false; + } + + _read() { + if (this._sent) { + this.push(null); + return; + } + this._sent = true; + if (this._body?.length) { + this.push(this._body); + } + this.push(null); + } + } + + class InjectResponse extends Writable { + constructor() { + super(); + this.statusCode = 200; + this.headers = {}; + this.chunks = []; + this.done = new Promise((resolve, reject) => { + this.once("finish", resolve); + this.once("error", reject); + }); + } + + writeHead(statusCode, headers = {}) { + this.statusCode = statusCode; + for (const [key, value] of Object.entries(headers)) { + this.headers[String(key).toLowerCase()] = value; + } + return this; + } + + setHeader(key, value) { + this.headers[String(key).toLowerCase()] = value; + } + + _write(chunk, _encoding, callback) { + this.chunks.push(Buffer.from(chunk)); + callback(); + } + + end(chunk, encoding, callback) { + if (chunk) { + this.write(chunk, encoding); + } + return super.end(callback); + } + } + + return { + config, + db, + store, + server, + async start() { + await new Promise((resolve) => { + server.listen(config.port, config.host, resolve); + }); + logger.info("server listening", { + host: config.host, + port: config.port, + jobsDir: config.jobsDir, + databasePath: config.databasePath + }); + return server.address(); + }, + async inject({ method = "GET", url = "/", headers = {}, body = null }) { + const request = new InjectRequest({ + method, + url, + headers: { host: "inject.local", ...headers }, + body: body ? (Buffer.isBuffer(body) ? body : Buffer.from(body)) : null + }); + const response = new InjectResponse(); + await requestHandler(request, response); + await response.done; + const responseBody = Buffer.concat(response.chunks); + return { + statusCode: response.statusCode, + headers: response.headers, + body: responseBody, + text: responseBody.toString("utf8"), + json() { + return JSON.parse(responseBody.toString("utf8")); + } + }; + }, + async close() { + logger.info("server shutdown requested"); + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + if (!overrides.db) db.close(); + } + }; +} diff --git a/web/src/utils.mjs b/web/src/utils.mjs new file mode 100644 index 0000000..7f046ce --- /dev/null +++ b/web/src/utils.mjs @@ -0,0 +1,88 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +export function createJobId() { + return crypto.randomUUID().replace(/-/g, "").slice(0, 16); +} + +export function nowIso() { + return new Date().toISOString(); +} + +export async function ensureDir(dirPath) { + await fs.mkdir(dirPath, { recursive: true }); +} + +export async function fileExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +export async function statIfExists(filePath) { + try { + return await fs.stat(filePath); + } catch { + return null; + } +} + +export function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +export function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function formatBytes(bytes) { + if (!Number.isFinite(bytes) || bytes < 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = bytes; + let unit = units[0]; + for (let index = 0; index < units.length; index += 1) { + unit = units[index]; + if (value < 1024 || index === units.length - 1) break; + value /= 1024; + } + const precision = value >= 10 || unit === "B" ? 0 : 1; + return `${value.toFixed(precision)} ${unit}`; +} + +export function formatDate(value) { + if (!value) return "—"; + return new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short" + }).format(new Date(value)); +} + +export function formatNumber(value, digits = 2) { + if (value === null || value === undefined || Number.isNaN(Number(value))) return "—"; + return Number(value).toFixed(digits); +} + +export function safeFileName(name) { + return String(name || "") + .trim() + .replace(/[/\\]/g, "-") + .replace(/\s+/g, " ") + .slice(0, 120); +} + +export function withLeadingSlash(relativePath) { + return relativePath.startsWith("/") ? relativePath : `/${relativePath}`; +} + +export function toPosixPath(filePath) { + return filePath.split(path.sep).join(path.posix.sep); +} diff --git a/web/src/worker-app.mjs b/web/src/worker-app.mjs new file mode 100644 index 0000000..7e5db16 --- /dev/null +++ b/web/src/worker-app.mjs @@ -0,0 +1,911 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { assertArchiveSafe, extractArchive } from "./archive.mjs"; +import { getLatestPreview } from "./artifacts.mjs"; +import { loadConfig, resolveColmapFlagStyle } from "./config.mjs"; +import { openDatabase, createJobStore } from "./db.mjs"; +import { inspectInput } from "./dataset.mjs"; +import { createLogger } from "./logger.mjs"; +import { parseFinalMetrics, readTail } from "./logs.mjs"; +import { getPreset } from "./presets.mjs"; +import { clamp, ensureDir, fileExists, safeFileName } from "./utils.mjs"; + +const PHASE_PROGRESS = { + validating: 10, + converting_text_model: 20, + extracting_features: 35, + matching: 55, + mapping: 65, + selecting_model: 70, + training: 70, + finalizing: 100 +}; +const SMALL_RAW_RECONSTRUCTION_IMAGE_COUNT = 8; + +class CancelledJobError extends Error { + constructor() { + super("Job cancelled"); + this.name = "CancelledJobError"; + } +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isCommandPath(command) { + return command.includes("/") || command.startsWith("."); +} + +function buildRunArgs(job) { + const preset = getPreset(job.preset); + if (!preset) { + throw new Error(`Unknown preset: ${job.preset}`); + } + + const outputDir = path.dirname(job.outputPath); + const outputPlyPath = path.join(outputDir, "final.ply"); + + return [ + job.datasetRoot, + "-o", + job.outputPath, + "--export-ply", + outputPlyPath, + ...preset.args, + "--val-render", + job.previewsDir + ]; +} + +function killProcessGroup(child) { + if (!child?.pid) return; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + try { + child.kill("SIGTERM"); + } catch { + // ignore + } + } +} + +async function writeLogBanner(logPath, message) { + await fsp.appendFile(logPath, `${message}\n`, "utf8"); +} + +async function resetDirectory(dirPath) { + await fsp.rm(dirPath, { recursive: true, force: true }); + await ensureDir(dirPath); +} + +async function findNumericModelDirs(sparseRoot) { + const entries = await fsp.readdir(sparseRoot, { withFileTypes: true }).catch(() => []); + const numeric = entries + .filter((entry) => entry.isDirectory() && /^\d+$/.test(entry.name)) + .map((entry) => path.join(sparseRoot, entry.name)) + .sort((left, right) => left.localeCompare(right)); + + if (numeric.length > 0) { + return numeric; + } + + if ( + (await fileExists(path.join(sparseRoot, "cameras.bin"))) && + (await fileExists(path.join(sparseRoot, "images.bin"))) + ) { + return [sparseRoot]; + } + + return []; +} + +function parseAnalyzerStats(output) { + const registeredImages = Number(output.match(/Registered images:\s*([0-9]+)/)?.[1] ?? 0); + const points = Number(output.match(/Points:\s*([0-9]+)/)?.[1] ?? 0); + return { registeredImages, points }; +} + +function matchesConfiguredCommand(command, configuredCommand) { + if (command === configuredCommand) { + return true; + } + if (isCommandPath(command) && isCommandPath(configuredCommand)) { + return path.resolve(command) === path.resolve(configuredCommand); + } + return false; +} + +function buildColmapGpuArgs(style) { + if (style === "legacy") { + return { + featureExtraction: ["--SiftExtraction.use_gpu", "0"], + featureMatching: ["--SiftMatching.use_gpu", "0"] + }; + } + return { + featureExtraction: ["--FeatureExtraction.use_gpu", "0"], + featureMatching: ["--FeatureMatching.use_gpu", "0"] + }; +} + +function buildMatcherArgs(databasePath, requestedMode, imageCount, gpuArgs) { + const mode = requestedMode === "exhaustive" ? "exhaustive" : "sequential"; + const args = [ + mode === "exhaustive" ? "exhaustive_matcher" : "sequential_matcher", + "--database_path", + databasePath, + ...gpuArgs.featureMatching + ]; + + if (mode === "sequential" && imageCount <= SMALL_RAW_RECONSTRUCTION_IMAGE_COUNT) { + // Small batches need denser pair coverage than COLMAP's default sequential schedule. + args.push( + "--SequentialMatching.overlap", + String(Math.max(3, imageCount - 1)), + "--SequentialMatching.quadratic_overlap", + "0" + ); + } + + return args; +} + +function buildMapperArgs(databasePath, imageRoot, sparseRoot, imageCount) { + const args = [ + "mapper", + "--database_path", + databasePath, + "--image_path", + imageRoot, + "--output_path", + sparseRoot + ]; + + if (imageCount <= SMALL_RAW_RECONSTRUCTION_IMAGE_COUNT) { + // The UI accepts tiny photo batches, so lower COLMAP's 10-image/100-inlier defaults. + args.push( + "--Mapper.min_model_size", + "3", + "--Mapper.init_min_num_inliers", + "30", + "--Mapper.abs_pose_min_num_inliers", + "15", + "--Mapper.init_min_tri_angle", + "4", + "--Mapper.tri_ignore_two_view_tracks", + "0" + ); + } + + return args; +} + +export async function createWorker(overrides = {}) { + const config = { + ...loadConfig(), + ...overrides + }; + config.colmapFlagStyle = resolveColmapFlagStyle(config.colmapFlagStyle); + const logger = createLogger("worker"); + await ensureDir(config.jobsDir); + const db = overrides.db ?? await openDatabase(config.databasePath); + const store = overrides.store ?? createJobStore(db); + const ownDb = !overrides.db; + + let shuttingDown = false; + let dbClosed = false; + let activeChild = null; + const lastPreviewStepByJob = new Map(); + + function closeDbIfNeeded() { + if (!ownDb || dbClosed) return; + db.close(); + dbClosed = true; + } + + function currentProgress(job, fallback = 0) { + return Number.isFinite(job.progressPercent) ? job.progressPercent : fallback; + } + + async function assertNotCancelled(jobId) { + const latest = store.getJob(jobId); + if (!latest || latest.cancelRequested || shuttingDown) { + throw new CancelledJobError(); + } + return latest; + } + + async function setPhase(jobId, phase, phaseMessage, progressPercent, extra = {}) { + const previous = store.getJob(jobId); + const next = store.markPhase(jobId, phase, phaseMessage, progressPercent, extra.progressStep ?? 0, extra); + const progressStep = extra.progressStep ?? 0; + + if ( + !previous || + previous.phase !== phase || + previous.phaseMessage !== phaseMessage || + previous.progressStep !== progressStep + ) { + logger.info("job phase updated", { + jobId, + phase, + phaseMessage, + progressPercent: Number(progressPercent?.toFixed?.(1) ?? progressPercent), + progressStep + }); + } + + return next; + } + + async function runLoggedCommand(job, command, args, options = {}) { + const { + phase, + phaseMessage, + progressPercent, + onInterval, + captureStdout = false + } = options; + + const startedAt = Date.now(); + const commandName = isCommandPath(command) ? path.basename(command) : command; + const commandPath = isCommandPath(command) ? path.resolve(command) : command; + const isColmapCommand = matchesConfiguredCommand(command, config.colmapBin); + const childEnv = { ...process.env }; + if (commandPath === path.resolve(config.msplatBin)) { + childEnv.MSPLAT_METALLIB_PATH = path.join(path.dirname(config.msplatBin), "default.metallib"); + } + if (isColmapCommand) { + childEnv.FAKE_COLMAP_FLAG_STYLE = config.colmapFlagStyle; + } + + await setPhase(job.id, phase, phaseMessage, progressPercent ?? currentProgress(job), { + phaseMessage, + progressPercent: progressPercent ?? currentProgress(job) + }); + logger.info("command starting", { + jobId: job.id, + phase, + command: commandName, + args: args.join(" "), + colmapFlagStyle: isColmapCommand ? config.colmapFlagStyle : undefined, + metallibPath: childEnv.MSPLAT_METALLIB_PATH + }); + await writeLogBanner( + job.logPath, + `>>> [${phase}] START ${command} ${args.join(" ")}${isColmapCommand ? ` [colmap_flag_style=${config.colmapFlagStyle}]` : ""}` + ); + await assertNotCancelled(job.id); + + const logStream = fs.createWriteStream(job.logPath, { flags: "a" }); + const child = spawn(command, args, { + cwd: config.projectRoot, + detached: true, + env: childEnv, + stdio: ["ignore", "pipe", "pipe"] + }); + + activeChild = child; + store.setRunnerPid(job.id, child.pid); + + let captured = ""; + child.stdout.on("data", (chunk) => { + if (captureStdout) captured += String(chunk); + }); + child.stdout.pipe(logStream, { end: false }); + child.stderr.pipe(logStream, { end: false }); + + const closePromise = new Promise((resolve, reject) => { + child.on("error", (error) => { + if (error.code === "ENOENT") { + reject(new Error(`${command === config.colmapBin ? "COLMAP_BIN" : "MSPLAT_BIN"} not found: ${command}`)); + return; + } + reject(error); + }); + child.on("close", (code, signal) => resolve({ code, signal })); + }); + + try { + while (true) { + const result = await Promise.race([ + closePromise.then((value) => ({ done: true, value })), + sleep(250).then(() => ({ done: false })) + ]); + + const latest = store.getJob(job.id); + if (!latest || latest.cancelRequested || shuttingDown) { + killProcessGroup(child); + } + + if (onInterval) { + await onInterval(latest ?? job); + } + + if (result.done) { + if ((store.getJob(job.id)?.cancelRequested ?? false) || shuttingDown || result.value.signal === "SIGTERM") { + const durationMs = Date.now() - startedAt; + logger.warn("command cancelled", { + jobId: job.id, + phase, + command: commandName, + durationMs + }); + await writeLogBanner(job.logPath, `<<< [${phase}] CANCELLED ${commandName} duration_ms=${durationMs}`); + throw new CancelledJobError(); + } + if (result.value.code !== 0) { + const durationMs = Date.now() - startedAt; + logger.error("command failed", { + jobId: job.id, + phase, + command: commandName, + code: result.value.code, + signal: result.value.signal, + durationMs, + colmapFlagStyle: isColmapCommand ? config.colmapFlagStyle : undefined + }); + await writeLogBanner( + job.logPath, + `<<< [${phase}] FAILED ${commandName} code=${result.value.code ?? "null"} signal=${result.value.signal ?? "null"} duration_ms=${durationMs}${isColmapCommand ? ` colmap_flag_style=${config.colmapFlagStyle}` : ""}` + ); + if (result.value.signal) { + throw new Error(`${path.basename(command)} exited with signal ${result.value.signal}`); + } + throw new Error(`${path.basename(command)} exited with code ${result.value.code ?? "unknown"}`); + } + const durationMs = Date.now() - startedAt; + logger.info("command finished", { + jobId: job.id, + phase, + command: commandName, + durationMs + }); + await writeLogBanner(job.logPath, `<<< [${phase}] DONE ${commandName} duration_ms=${durationMs}`); + return captured; + } + } + } finally { + activeChild = null; + store.setRunnerPid(job.id, null); + logStream.end(); + } + } + + async function prepareDatasetRoot(targetRoot, imageRoot, imageFiles, modelDir) { + await resetDirectory(targetRoot); + const targetImagesRoot = path.join(targetRoot, "images"); + const targetSparseRoot = path.join(targetRoot, "sparse", "0"); + await ensureDir(targetImagesRoot); + await ensureDir(targetSparseRoot); + + for (const sourcePath of imageFiles) { + const relativePath = path.relative(imageRoot, sourcePath); + const destinationPath = path.join(targetImagesRoot, relativePath); + await ensureDir(path.dirname(destinationPath)); + await fsp.copyFile(sourcePath, destinationPath); + } + + for (const entry of await fsp.readdir(modelDir, { withFileTypes: true })) { + if (!entry.isFile()) continue; + const sourcePath = path.join(modelDir, entry.name); + const destinationPath = path.join(targetSparseRoot, entry.name); + await fsp.copyFile(sourcePath, destinationPath); + } + + return targetRoot; + } + + async function stageArchive(job) { + const extractionDir = path.join(job.jobDir, "dataset"); + await resetDirectory(extractionDir); + logger.info("extracting archive", { + jobId: job.id, + uploadPath: job.uploadPath, + extractionDir + }); + await setPhase(job.id, "validating", "Extracting archive", 5); + await assertArchiveSafe(job.uploadPath); + await extractArchive(job.uploadPath, extractionDir); + await assertNotCancelled(job.id); + return extractionDir; + } + + async function chooseBestModel(job, sparseRoot) { + const modelDirs = await findNumericModelDirs(sparseRoot); + if (modelDirs.length === 0) { + throw new Error("COLMAP mapper did not produce a sparse model"); + } + if (modelDirs.length === 1) { + logger.info("single COLMAP model found", { + jobId: job.id, + modelDir: modelDirs[0] + }); + return modelDirs[0]; + } + + logger.info("multiple COLMAP models found", { + jobId: job.id, + count: modelDirs.length, + sparseRoot + }); + await setPhase(job.id, "selecting_model", "Selecting the best COLMAP model", 68); + + let best = null; + for (const modelDir of modelDirs) { + const output = await runLoggedCommand(job, config.colmapBin, ["model_analyzer", "--path", modelDir], { + phase: "selecting_model", + phaseMessage: `Analyzing ${path.basename(modelDir)}`, + progressPercent: 69, + captureStdout: true + }); + const stats = parseAnalyzerStats(output); + logger.info("COLMAP model analyzed", { + jobId: job.id, + modelDir, + registeredImages: stats.registeredImages, + points: stats.points + }); + if ( + !best || + stats.registeredImages > best.stats.registeredImages || + (stats.registeredImages === best.stats.registeredImages && stats.points > best.stats.points) + ) { + best = { modelDir, stats }; + } + } + + logger.info("COLMAP model selected", { + jobId: job.id, + modelDir: best.modelDir, + registeredImages: best.stats.registeredImages, + points: best.stats.points + }); + await writeLogBanner( + job.logPath, + `>>> [selecting_model] selected ${best.modelDir} registered_images=${best.stats.registeredImages} points=${best.stats.points}` + ); + return best.modelDir; + } + + async function convertTextModel(job, info) { + const workspace = path.join(job.jobDir, "colmap-workspace"); + const convertedDir = path.join(workspace, "converted"); + await resetDirectory(workspace); + await ensureDir(convertedDir); + + logger.info("converting COLMAP TXT dataset", { + jobId: job.id, + modelDir: info.modelDir, + imageCount: info.imageFiles.length + }); + await runLoggedCommand(job, config.colmapBin, [ + "model_converter", + "--input_path", + info.modelDir, + "--output_path", + convertedDir, + "--output_type", + "BIN" + ], { + phase: "converting_text_model", + phaseMessage: "Converting COLMAP TXT model to BIN", + progressPercent: PHASE_PROGRESS.converting_text_model + }); + + const normalizedDatasetRoot = path.join(job.jobDir, "normalized-dataset"); + await prepareDatasetRoot(normalizedDatasetRoot, info.imageRoot, info.imageFiles, convertedDir); + + const dbPath = await fileExists(path.join(info.datasetRoot, "database.db")) + ? path.join(info.datasetRoot, "database.db") + : null; + + return { + datasetRoot: normalizedDatasetRoot, + datasetType: "colmap", + sourceFormat: "colmap_txt", + colmapWorkspacePath: workspace, + colmapDatabasePath: dbPath, + colmapModelPath: path.join(normalizedDatasetRoot, "sparse", "0"), + phase: "converting_text_model", + phaseMessage: "COLMAP TXT model converted" + }; + } + + async function normalizeRawImages(job, info) { + const workspace = path.join(job.jobDir, "colmap-workspace"); + const normalizedImagesDir = path.join(workspace, "images"); + await resetDirectory(workspace); + await ensureDir(normalizedImagesDir); + + logger.info("normalizing raw images", { + jobId: job.id, + imageCount: info.imageFiles.length, + sourceRoot: info.datasetRoot, + workspace + }); + const normalizedFiles = []; + for (let index = 0; index < info.imageFiles.length; index += 1) { + const sourcePath = info.imageFiles[index]; + const extension = path.extname(sourcePath) || ".jpg"; + const baseName = safeFileName(path.basename(sourcePath, extension)) || `image-${index + 1}`; + const destinationName = `${String(index + 1).padStart(6, "0")}_${baseName}${extension}`; + const destinationPath = path.join(normalizedImagesDir, destinationName); + await fsp.copyFile(sourcePath, destinationPath); + normalizedFiles.push(destinationPath); + } + + return { + workspace, + imageRoot: normalizedImagesDir, + imageFiles: normalizedFiles, + databasePath: path.join(workspace, "database.db"), + sparseRoot: path.join(workspace, "sparse") + }; + } + + async function reconstructRawImages(job, info) { + const colmapMode = job.colmapMode || "sequential"; + const gpuArgs = buildColmapGpuArgs(config.colmapFlagStyle); + const normalized = await normalizeRawImages(job, info); + const imageCount = normalized.imageFiles.length; + logger.info("reconstructing raw images with COLMAP", { + jobId: job.id, + colmapMode, + colmapFlagStyle: config.colmapFlagStyle, + imageCount, + workspace: normalized.workspace + }); + await writeLogBanner(job.logPath, `>>> [validating] colmap_flag_style=${config.colmapFlagStyle}`); + + await runLoggedCommand(job, config.colmapBin, [ + "feature_extractor", + "--database_path", + normalized.databasePath, + "--image_path", + normalized.imageRoot, + "--ImageReader.camera_model", + "SIMPLE_RADIAL", + ...gpuArgs.featureExtraction + ], { + phase: "extracting_features", + phaseMessage: "Extracting COLMAP features", + progressPercent: PHASE_PROGRESS.extracting_features + }); + + await runLoggedCommand(job, config.colmapBin, buildMatcherArgs(normalized.databasePath, colmapMode, imageCount, gpuArgs), { + phase: "matching", + phaseMessage: colmapMode === "exhaustive" ? "Running exhaustive matching" : "Running sequential matching", + progressPercent: PHASE_PROGRESS.matching + }); + + const runMapper = async (phaseMessage) => { + await resetDirectory(normalized.sparseRoot); + await runLoggedCommand(job, config.colmapBin, buildMapperArgs( + normalized.databasePath, + normalized.imageRoot, + normalized.sparseRoot, + imageCount + ), { + phase: "mapping", + phaseMessage, + progressPercent: PHASE_PROGRESS.mapping + }); + }; + + try { + await runMapper("Building sparse COLMAP model"); + } catch (error) { + const shouldRetryWithExhaustive = + colmapMode === "sequential" && imageCount <= SMALL_RAW_RECONSTRUCTION_IMAGE_COUNT; + + if (!shouldRetryWithExhaustive) { + throw error; + } + + logger.warn("raw image mapping failed after sequential matching; retrying with exhaustive matching", { + jobId: job.id, + imageCount, + error: error.message + }); + await writeLogBanner( + job.logPath, + `>>> [matching] retrying_with_exhaustive image_count=${imageCount} reason=${JSON.stringify(error.message)}` + ); + + await runLoggedCommand(job, config.colmapBin, buildMatcherArgs(normalized.databasePath, "exhaustive", imageCount, gpuArgs), { + phase: "matching", + phaseMessage: "Retrying with exhaustive matching", + progressPercent: PHASE_PROGRESS.matching + }); + + try { + await runMapper("Retrying sparse COLMAP model"); + } catch (retryError) { + throw new Error( + `COLMAP could not build a sparse model from ${imageCount} images. Try uploading at least 8 overlapping photos or a prepared dataset. Last error: ${retryError.message}` + ); + } + } + + const bestModelDir = await chooseBestModel(job, normalized.sparseRoot); + const normalizedDatasetRoot = path.join(job.jobDir, "normalized-dataset"); + await prepareDatasetRoot(normalizedDatasetRoot, normalized.imageRoot, normalized.imageFiles, bestModelDir); + + return { + datasetRoot: normalizedDatasetRoot, + datasetType: "colmap", + sourceFormat: "raw_images", + colmapWorkspacePath: normalized.workspace, + colmapDatabasePath: normalized.databasePath, + colmapModelPath: bestModelDir, + phase: "selecting_model", + phaseMessage: "COLMAP model selected" + }; + } + + async function normalizeUploadedJob(job) { + await setPhase(job.id, "validating", "Inspecting upload", 5); + + const inputRoot = job.inputKind === "raw_files" ? job.uploadPath : await stageArchive(job); + const info = await inspectInput(inputRoot); + logger.info("upload inspected", { + jobId: job.id, + inputKind: job.inputKind, + sourceFormat: info.sourceFormat, + datasetType: info.type, + datasetRoot: info.datasetRoot + }); + await writeLogBanner( + job.logPath, + `>>> [validating] detected source_format=${info.sourceFormat} dataset_type=${info.type} dataset_root=${info.datasetRoot}` + ); + + store.patchJob(job.id, { + sourceFormat: info.sourceFormat, + datasetType: info.type, + inputKind: info.sourceFormat === "raw_images" ? (job.inputKind === "raw_files" ? "raw_files" : "raw_zip") : job.inputKind + }); + await assertNotCancelled(job.id); + + if (info.sourceFormat === "colmap_bin" || info.sourceFormat === "nerfstudio" || info.sourceFormat === "polycam") { + logger.info("prepared dataset queued", { + jobId: job.id, + sourceFormat: info.sourceFormat, + datasetRoot: info.datasetRoot + }); + store.markQueued(job.id, { + datasetRoot: info.datasetRoot, + datasetType: info.type, + sourceFormat: info.sourceFormat, + phase: "validating", + phaseMessage: "Prepared dataset ready", + progressPercent: PHASE_PROGRESS.validating + }); + return; + } + + if (info.sourceFormat === "colmap_txt") { + const normalized = await convertTextModel(job, info); + await assertNotCancelled(job.id); + logger.info("COLMAP TXT dataset queued", { + jobId: job.id, + datasetRoot: normalized.datasetRoot, + colmapModelPath: normalized.colmapModelPath + }); + store.markQueued(job.id, { + datasetRoot: normalized.datasetRoot, + datasetType: normalized.datasetType, + sourceFormat: normalized.sourceFormat, + colmapWorkspacePath: normalized.colmapWorkspacePath, + colmapDatabasePath: normalized.colmapDatabasePath, + colmapModelPath: normalized.colmapModelPath, + phase: normalized.phase, + phaseMessage: normalized.phaseMessage, + progressPercent: PHASE_PROGRESS.converting_text_model + }); + return; + } + + if (info.sourceFormat === "raw_images") { + const normalized = await reconstructRawImages(job, info); + await assertNotCancelled(job.id); + logger.info("raw image dataset queued", { + jobId: job.id, + datasetRoot: normalized.datasetRoot, + colmapMode: job.colmapMode || "sequential", + colmapModelPath: normalized.colmapModelPath + }); + store.markQueued(job.id, { + datasetRoot: normalized.datasetRoot, + datasetType: normalized.datasetType, + sourceFormat: normalized.sourceFormat, + colmapMode: job.colmapMode || "sequential", + colmapWorkspacePath: normalized.colmapWorkspacePath, + colmapDatabasePath: normalized.colmapDatabasePath, + colmapModelPath: normalized.colmapModelPath, + phase: normalized.phase, + phaseMessage: normalized.phaseMessage, + progressPercent: PHASE_PROGRESS.selecting_model + }); + return; + } + + throw new Error(`Unsupported source format: ${info.sourceFormat}`); + } + + async function runTrainingJob(job) { + logger.info("training job starting", { + jobId: job.id, + datasetRoot: job.datasetRoot, + preset: job.preset + }); + let lastPreviewStep = lastPreviewStepByJob.get(job.id) ?? 0; + await runLoggedCommand(job, config.msplatBin, buildRunArgs(job), { + phase: "training", + phaseMessage: "Training splat with msplat", + progressPercent: PHASE_PROGRESS.training, + onInterval: async () => { + const latestPreview = await getLatestPreview(job.previewsDir); + if (!latestPreview) return; + const preset = getPreset(job.preset); + const progressPercent = clamp(70 + (latestPreview.step / preset.iterations) * 30, 70, 100); + if (latestPreview.step !== lastPreviewStep) { + lastPreviewStep = latestPreview.step; + lastPreviewStepByJob.set(job.id, latestPreview.step); + logger.info("training preview updated", { + jobId: job.id, + step: latestPreview.step, + progressPercent: Number(progressPercent.toFixed(1)), + previewPath: latestPreview.path + }); + await writeLogBanner( + job.logPath, + `>>> [training] preview step=${latestPreview.step} progress_percent=${progressPercent.toFixed(1)}` + ); + } + store.markPhase(job.id, "training", "Training splat with msplat", progressPercent, latestPreview.step, { + latestPreviewPath: latestPreview.path + }); + } + }); + + const latestPreview = await getLatestPreview(job.previewsDir); + if (latestPreview) { + store.markPhase(job.id, "finalizing", "Collecting training metrics", 99, latestPreview.step, { + latestPreviewPath: latestPreview.path + }); + } else { + store.markPhase(job.id, "finalizing", "Collecting training metrics", 99); + } + + const metrics = parseFinalMetrics(await readTail(job.logPath, 200000)); + logger.info("training job finished", { + jobId: job.id, + psnr: metrics?.psnr ?? null, + ssim: metrics?.ssim ?? null, + l1: metrics?.l1 ?? null, + gaussians: metrics?.gaussians ?? null + }); + await writeLogBanner( + job.logPath, + `>>> [finalizing] metrics psnr=${metrics?.psnr ?? "n/a"} ssim=${metrics?.ssim ?? "n/a"} l1=${metrics?.l1 ?? "n/a"} gaussians=${metrics?.gaussians ?? "n/a"}` + ); + store.markSucceeded(job.id, metrics ?? {}); + lastPreviewStepByJob.delete(job.id); + } + + async function processUploadedJob(job) { + try { + await normalizeUploadedJob(job); + } catch (error) { + if (error instanceof CancelledJobError) { + logger.warn("job cancelled during preprocessing", { jobId: job.id }); + store.markCancelled(job.id); + lastPreviewStepByJob.delete(job.id); + return; + } + logger.error("job preprocessing failed", { + jobId: job.id, + error: error.message + }); + store.markFailed(job.id, error.message); + lastPreviewStepByJob.delete(job.id); + } + } + + async function processQueuedJob(job) { + try { + await runTrainingJob(job); + } catch (error) { + if (error instanceof CancelledJobError) { + logger.warn("job cancelled during training", { jobId: job.id }); + store.markCancelled(job.id); + lastPreviewStepByJob.delete(job.id); + return; + } + logger.error("job training failed", { + jobId: job.id, + error: error.message + }); + store.markFailed(job.id, error.message); + lastPreviewStepByJob.delete(job.id); + } + } + + async function tick() { + const queuedJob = store.claimNextQueued(process.pid); + if (queuedJob) { + logger.info("claimed queued job", { + jobId: queuedJob.id, + status: queuedJob.status, + preset: queuedJob.preset, + datasetType: queuedJob.datasetType + }); + await processQueuedJob(queuedJob); + return; + } + + const uploadedJob = store.claimNextUploaded(process.pid); + if (uploadedJob) { + logger.info("claimed uploaded job", { + jobId: uploadedJob.id, + inputKind: uploadedJob.inputKind, + uploadName: uploadedJob.uploadName, + preset: uploadedJob.preset + }); + await processUploadedJob(uploadedJob); + return; + } + + await sleep(config.pollIntervalMs); + } + + async function run() { + try { + logger.info("worker loop started", { + jobsDir: config.jobsDir, + databasePath: config.databasePath, + msplatBin: config.msplatBin, + colmapBin: config.colmapBin, + colmapFlagStyle: config.colmapFlagStyle, + pollIntervalMs: config.pollIntervalMs + }); + while (!shuttingDown) { + await tick(); + } + if (activeChild) { + killProcessGroup(activeChild); + } + } finally { + logger.info("worker loop stopping"); + closeDbIfNeeded(); + } + } + + async function close() { + shuttingDown = true; + logger.info("worker shutdown requested"); + if (activeChild) { + killProcessGroup(activeChild); + } + if (!activeChild) { + closeDbIfNeeded(); + } + } + + return { + config, + db, + store, + run, + async tickOnce() { + await tick(); + }, + close + }; +} diff --git a/web/worker.mjs b/web/worker.mjs new file mode 100644 index 0000000..27e7ba5 --- /dev/null +++ b/web/worker.mjs @@ -0,0 +1,14 @@ +import { createWorker } from "./src/worker-app.mjs"; + +const worker = await createWorker(); + +process.on("SIGINT", async () => { + await worker.close(); +}); + +process.on("SIGTERM", async () => { + await worker.close(); +}); + +console.log(`msplat worker polling ${worker.config.databasePath}`); +await worker.run();