convert-large-ply.ts
scripts/convert-large-ply.ts · raw · all examples · site home
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
// A PLY too large to read in one piece (Node reads at most 2 GiB at a time)
// into one RDX file, streamed: each chunk of records is decoded by
// PlyFile.read itself (the original header with the chunk's count, then
// the chunk's records) and copied into one table of the asked layout, with
// its spherical harmonics in the asked SH codec (--sh ext | packed | none).
//
// npm run convert:ply -- <in.ply> <out.rdx> [--layout packedSplats] [--sh ext] [--chunk 1000000]
import { closeSync, openSync, readSync, statSync, writeFileSync } from "node:fs";
import { ArrayData, PlyFile, RdxFile, Sh, Splats, type SplatLayout } from "../src/core";
const args = process.argv.slice(2);
const option = (name: string, fallback: string) => {
const index = args.indexOf(name);
return index >= 0 ? args[index + 1] : fallback;
};
const [input, output] = args.filter((arg, i) => !arg.startsWith("--") && !args[i - 1]?.startsWith("--"));
if (!input || !output) {
throw new Error("Usage: convert-large-ply.ts <in.ply> <out.rdx> [--layout packedSplats] [--sh ext | packed | none] [--chunk 1000000]");
}
const layout = option("--layout", "packedSplats") as SplatLayout;
const shOption = option("--sh", "ext");
const sh = shOption === "none" ? false : shOption;
const chunk = Number(option("--chunk", 1_000_000));
const fd = openSync(input, "r");
const headBytes = Buffer.alloc(64 * 1024);
readSync(fd, headBytes, 0, headBytes.length, 0);
const headText = headBytes.toString("latin1");
const end = headText.indexOf("end_header\n");
if (end < 0 || !headText.startsWith("ply\n") || !headText.includes("format binary_little_endian")) {
throw new Error("Not a binary little-endian PLY");
}
const header = headText.slice(0, end + "end_header\n".length);
const elements = [...header.matchAll(/^element (\w+) (\d+)$/gm)];
if (elements.length !== 1 || elements[0][1] !== "vertex") {
throw new Error("Only PLY files with a single vertex element are supported");
}
const count = Number(elements[0][2]);
const sizes: Record<string, number> = { char: 1, uchar: 1, short: 2, ushort: 2, int: 4, uint: 4, float: 4, double: 8 };
const recordSize = [...header.matchAll(/^property (\w+) \w+$/gm)].reduce((sum, [, type]) => sum + sizes[type], 0);
const dataStart = header.length;
const expected = dataStart + count * recordSize;
if (statSync(input).size < expected) {
throw new Error(`PLY ends early: ${statSync(input).size} bytes, expected ${expected}`);
}
console.log(`${input}: ${count.toLocaleString()} splats of ${recordSize} bytes, into ${layout}`);
const data = Splats.make(count, { layout, meta: { source: input.split("/").pop() } }) as unknown as ArrayData;
data.count = count;
const start = performance.now();
for (let at = 0; at < count; at += chunk) {
const n = Math.min(chunk, count - at);
const head = Buffer.from(header.replace(/^element vertex \d+$/m, `element vertex ${n}`), "latin1");
const bytes = new Uint8Array(head.length + n * recordSize);
bytes.set(head);
readSync(fd, bytes, head.length, n * recordSize, dataStart + at * recordSize);
const part = (await PlyFile.read(bytes, { layout, sh })).splats as unknown as ArrayData;
const found = Sh.find(part.bundles);
if (found && !Sh.find(data.bundles)) {
Sh.add(data, found.codec, found.degree);
}
data.meta.ignoredShDegree = part.meta.ignoredShDegree;
data.copyFrom(part, { dstStart: at, count: n });
process.stdout.write(`\r${Math.round(((at + n) / count) * 100)}% decoded`);
}
closeSync(fd);
console.log(` in ${((performance.now() - start) / 1000).toFixed(1)} s`);
const encodeStart = performance.now();
const file = await RdxFile.encode(data);
writeFileSync(output, file);
console.log(`${output}: ${(file.byteLength / 2 ** 20).toFixed(0)} MiB in ${((performance.now() - encodeStart) / 1000).toFixed(1)} s`);