324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
|
||
export type SampledPoint = { x: number; y: number; z: number; error: number };
|
||
|
||
/** 与 fileRoutes 批量采样一致:随机 seek 文本点云行 */
|
||
export async function randomSeekSample(
|
||
filePath: string,
|
||
fileSize: number,
|
||
k: number,
|
||
): Promise<SampledPoint[]> {
|
||
if (fileSize === 0 || k === 0) return [];
|
||
|
||
const PROBE_BYTES = 64 * 1024;
|
||
const probeBuf = Buffer.alloc(Math.min(PROBE_BYTES, fileSize));
|
||
const probeFd = fs.openSync(filePath, 'r');
|
||
fs.readSync(probeFd, probeBuf, 0, probeBuf.length, 0);
|
||
fs.closeSync(probeFd);
|
||
|
||
const probeLines = probeBuf.toString('utf8').split('\n').filter((l) => {
|
||
const t = l.trim();
|
||
return t && !t.startsWith('#') && !t.startsWith('//');
|
||
});
|
||
const sampleCount = Math.min(probeLines.length, 200);
|
||
const avgLineBytes =
|
||
sampleCount > 0
|
||
? probeLines.slice(0, sampleCount).reduce((s, l) => s + Buffer.byteLength(l) + 1, 0) / sampleCount
|
||
: 40;
|
||
|
||
console.log(
|
||
`[SeekSampler] ${path.basename(filePath)}: size=${(fileSize / 1e9).toFixed(2)}GB ` +
|
||
`avgLine=${avgLineBytes.toFixed(1)}B quota=${k}`,
|
||
);
|
||
|
||
const startOffset = Math.min(512, fileSize);
|
||
const usableSize = fileSize - startOffset;
|
||
if (usableSize <= 0) return [];
|
||
|
||
const interval = usableSize / k;
|
||
const seekPositions: number[] = [];
|
||
for (let i = 0; i < k; i++) {
|
||
const base = startOffset + Math.floor(i * interval);
|
||
const jitter = Math.floor(Math.random() * Math.min(interval * 0.5, 1024));
|
||
seekPositions.push(Math.min(base + jitter, fileSize - 2));
|
||
}
|
||
seekPositions.sort((a, b) => a - b);
|
||
|
||
const points: SampledPoint[] = [];
|
||
const fd = fs.openSync(filePath, 'r');
|
||
const lineBuf = Buffer.alloc(1024);
|
||
|
||
try {
|
||
for (const pos of seekPositions) {
|
||
if (points.length >= k) break;
|
||
const bytesRead = fs.readSync(fd, lineBuf, 0, lineBuf.length, pos);
|
||
if (bytesRead < 4) continue;
|
||
const chunk = lineBuf.toString('utf8', 0, bytesRead);
|
||
const nl1 = chunk.indexOf('\n');
|
||
if (nl1 === -1) continue;
|
||
const afterFirst = chunk.slice(nl1 + 1);
|
||
const nl2 = afterFirst.indexOf('\n');
|
||
const line = (nl2 === -1 ? afterFirst : afterFirst.slice(0, nl2)).trim();
|
||
if (!line || line.startsWith('#') || line.startsWith('//')) continue;
|
||
const parts = line.split(/[\s,\t]+/);
|
||
const x = parseFloat(parts[0]);
|
||
const y = parseFloat(parts[1]);
|
||
const z = parseFloat(parts[2]);
|
||
if (isNaN(x) || isNaN(y) || isNaN(z)) continue;
|
||
let error = parts.length >= 4 ? parseFloat(parts[3]) : NaN;
|
||
if (isNaN(error)) error = Math.abs(Math.sin(x * 0.5) * Math.cos(z * 0.5)) * 1.5;
|
||
points.push({ x, y, z, error });
|
||
}
|
||
} finally {
|
||
fs.closeSync(fd);
|
||
}
|
||
return points;
|
||
}
|
||
|
||
// ─── PLY 格式解析与采样 ───────────────────────────────────────────────────────
|
||
|
||
/** PLY 属性类型 → 字节数 */
|
||
const PLY_PROP_SIZES: Record<string, number> = {
|
||
char: 1, int8: 1,
|
||
uchar: 1, uint8: 1,
|
||
short: 2, int16: 2,
|
||
ushort: 2, uint16: 2,
|
||
int: 4, int32: 4,
|
||
uint: 4, uint32: 4,
|
||
float: 4, float32: 4,
|
||
double: 8, float64: 8,
|
||
};
|
||
|
||
interface PlyHeader {
|
||
/** 'ascii' | 'binary_little_endian' | 'binary_big_endian' */
|
||
format: string;
|
||
/** 顶点数 */
|
||
vertexCount: number;
|
||
/** 顶点元素各属性,顺序与文件一致 */
|
||
properties: Array<{ name: string; type: string; byteSize: number }>;
|
||
/** 'end_header\n' 之后的第一个字节偏移量(即数据区起始位置) */
|
||
dataOffset: number;
|
||
}
|
||
|
||
/**
|
||
* 读取 PLY 文件头(总是 ASCII,以 "end_header\n" 结尾)。
|
||
* 最多读取 16 KB,足以容纳任何合理的 PLY 头。
|
||
*/
|
||
function parsePlyHeader(filePath: string): PlyHeader | null {
|
||
const HEADER_MAX = 16 * 1024;
|
||
const buf = Buffer.alloc(HEADER_MAX);
|
||
const fd = fs.openSync(filePath, 'r');
|
||
const bytesRead = fs.readSync(fd, buf, 0, HEADER_MAX, 0);
|
||
fs.closeSync(fd);
|
||
|
||
const raw = buf.toString('ascii', 0, bytesRead);
|
||
|
||
// 必须以 "ply\n" 开头
|
||
if (!raw.startsWith('ply\n') && !raw.startsWith('ply\r\n')) return null;
|
||
|
||
const endHeaderMarker = 'end_header';
|
||
const endIdx = raw.indexOf(endHeaderMarker);
|
||
if (endIdx === -1) return null;
|
||
|
||
// 找到 end_header 之后换行符,跳过它
|
||
let dataStart = endIdx + endHeaderMarker.length;
|
||
if (raw[dataStart] === '\r') dataStart++;
|
||
if (raw[dataStart] === '\n') dataStart++;
|
||
|
||
const header = raw.slice(0, endIdx);
|
||
const lines = header.split(/\r?\n/);
|
||
|
||
let format = 'ascii';
|
||
let vertexCount = 0;
|
||
const properties: PlyHeader['properties'] = [];
|
||
let inVertex = false;
|
||
|
||
for (const line of lines) {
|
||
const parts = line.trim().split(/\s+/);
|
||
if (parts[0] === 'format') {
|
||
format = parts[1] ?? 'ascii';
|
||
} else if (parts[0] === 'element') {
|
||
inVertex = parts[1] === 'vertex';
|
||
if (inVertex) vertexCount = parseInt(parts[2] ?? '0', 10);
|
||
} else if (parts[0] === 'property' && inVertex) {
|
||
// skip "property list ..." (face element)
|
||
if (parts[1] === 'list') { inVertex = false; continue; }
|
||
const type = parts[1] ?? 'float';
|
||
const name = parts[2] ?? '';
|
||
const byteSize = PLY_PROP_SIZES[type] ?? 4;
|
||
properties.push({ name, type, byteSize });
|
||
}
|
||
}
|
||
|
||
if (vertexCount <= 0 || properties.length === 0) return null;
|
||
|
||
return { format, vertexCount, properties, dataOffset: dataStart };
|
||
}
|
||
|
||
/**
|
||
* 采样二进制 PLY 文件(little_endian 或 big_endian)。
|
||
* 策略:均匀间隔 + 小量随机抖动(与 randomSeekSample 一致)。
|
||
*/
|
||
function samplePlyBinary(
|
||
filePath: string,
|
||
header: PlyHeader,
|
||
k: number,
|
||
): SampledPoint[] {
|
||
const { vertexCount, properties, dataOffset, format } = header;
|
||
const littleEndian = format !== 'binary_big_endian';
|
||
|
||
const bytesPerVertex = properties.reduce((s, p) => s + p.byteSize, 0);
|
||
|
||
// 找 x, y, z(可能叫 x/y/z 或 X/Y/Z;允许宽松匹配)
|
||
let xOff = -1, yOff = -1, zOff = -1;
|
||
let xType = 'float', yType = 'float', zType = 'float';
|
||
let byteOff = 0;
|
||
for (const p of properties) {
|
||
const n = p.name.toLowerCase();
|
||
if (n === 'x') { xOff = byteOff; xType = p.type; }
|
||
else if (n === 'y') { yOff = byteOff; yType = p.type; }
|
||
else if (n === 'z') { zOff = byteOff; zType = p.type; }
|
||
byteOff += p.byteSize;
|
||
}
|
||
|
||
if (xOff === -1 || yOff === -1 || zOff === -1) {
|
||
console.warn(`[PLY] ${path.basename(filePath)}: could not find x/y/z properties`);
|
||
return [];
|
||
}
|
||
|
||
const actualK = Math.min(k, vertexCount);
|
||
const interval = vertexCount / actualK;
|
||
const indices: number[] = [];
|
||
for (let i = 0; i < actualK; i++) {
|
||
const base = Math.floor(i * interval);
|
||
const jitter = Math.floor(Math.random() * Math.max(1, interval * 0.5));
|
||
indices.push(Math.min(base + jitter, vertexCount - 1));
|
||
}
|
||
|
||
const recBuf = Buffer.alloc(bytesPerVertex);
|
||
const fd = fs.openSync(filePath, 'r');
|
||
const points: SampledPoint[] = [];
|
||
|
||
function readNum(buf: Buffer, off: number, type: string): number {
|
||
switch (type) {
|
||
case 'float': case 'float32': return littleEndian ? buf.readFloatLE(off) : buf.readFloatBE(off);
|
||
case 'double': case 'float64': return littleEndian ? buf.readDoubleLE(off) : buf.readDoubleBE(off);
|
||
case 'int': case 'int32': return littleEndian ? buf.readInt32LE(off) : buf.readInt32BE(off);
|
||
case 'uint': case 'uint32': return littleEndian ? buf.readUInt32LE(off) : buf.readUInt32BE(off);
|
||
case 'short': case 'int16': return littleEndian ? buf.readInt16LE(off) : buf.readInt16BE(off);
|
||
case 'ushort': case 'uint16': return littleEndian ? buf.readUInt16LE(off) : buf.readUInt16BE(off);
|
||
case 'char': case 'int8': return buf.readInt8(off);
|
||
case 'uchar': case 'uint8': return buf.readUInt8(off);
|
||
default: return littleEndian ? buf.readFloatLE(off) : buf.readFloatBE(off);
|
||
}
|
||
}
|
||
|
||
try {
|
||
for (const idx of indices) {
|
||
const filePos = dataOffset + idx * bytesPerVertex;
|
||
const n = fs.readSync(fd, recBuf, 0, bytesPerVertex, filePos);
|
||
if (n < bytesPerVertex) continue;
|
||
const x = readNum(recBuf, xOff, xType);
|
||
const y = readNum(recBuf, yOff, yType);
|
||
const z = readNum(recBuf, zOff, zType);
|
||
if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue;
|
||
const error = Math.abs(Math.sin(x * 0.001) * Math.cos(z * 0.001)) * 1.5;
|
||
points.push({ x, y, z, error });
|
||
}
|
||
} finally {
|
||
fs.closeSync(fd);
|
||
}
|
||
|
||
console.log(
|
||
`[PLY-Binary] ${path.basename(filePath)}: ${vertexCount} verts, ` +
|
||
`${bytesPerVertex}B/vert, sampled ${points.length}`,
|
||
);
|
||
return points;
|
||
}
|
||
|
||
/**
|
||
* 采样 ASCII PLY 文件。
|
||
* 复用 randomSeekSample 的均匀 seek 策略,跳过 PLY 头,从数据区随机取行。
|
||
*/
|
||
function samplePlyAscii(
|
||
filePath: string,
|
||
fileSize: number,
|
||
header: PlyHeader,
|
||
k: number,
|
||
): SampledPoint[] {
|
||
const { dataOffset } = header;
|
||
const usableSize = fileSize - dataOffset;
|
||
if (usableSize <= 0) return [];
|
||
|
||
const actualK = Math.min(k, header.vertexCount);
|
||
const interval = usableSize / actualK;
|
||
const seekPositions: number[] = [];
|
||
for (let i = 0; i < actualK; i++) {
|
||
const base = dataOffset + Math.floor(i * interval);
|
||
const jitter = Math.floor(Math.random() * Math.min(interval * 0.5, 1024));
|
||
seekPositions.push(Math.min(base + jitter, fileSize - 2));
|
||
}
|
||
|
||
const points: SampledPoint[] = [];
|
||
const fd = fs.openSync(filePath, 'r');
|
||
const lineBuf = Buffer.alloc(512);
|
||
|
||
try {
|
||
for (const pos of seekPositions) {
|
||
if (points.length >= actualK) break;
|
||
const bytesRead = fs.readSync(fd, lineBuf, 0, lineBuf.length, pos);
|
||
if (bytesRead < 4) continue;
|
||
const chunk = lineBuf.toString('utf8', 0, bytesRead);
|
||
const nl1 = chunk.indexOf('\n');
|
||
if (nl1 === -1) continue;
|
||
const afterFirst = chunk.slice(nl1 + 1);
|
||
const nl2 = afterFirst.indexOf('\n');
|
||
const line = (nl2 === -1 ? afterFirst : afterFirst.slice(0, nl2)).trim();
|
||
if (!line) continue;
|
||
const parts = line.split(/\s+/);
|
||
const x = parseFloat(parts[0]);
|
||
const y = parseFloat(parts[1]);
|
||
const z = parseFloat(parts[2]);
|
||
if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue;
|
||
const error = Math.abs(Math.sin(x * 0.001) * Math.cos(z * 0.001)) * 1.5;
|
||
points.push({ x, y, z, error });
|
||
}
|
||
} finally {
|
||
fs.closeSync(fd);
|
||
}
|
||
|
||
console.log(
|
||
`[PLY-ASCII] ${path.basename(filePath)}: sampled ${points.length} / ${header.vertexCount} verts`,
|
||
);
|
||
return points;
|
||
}
|
||
|
||
/**
|
||
* 通用采样入口:根据扩展名分发到对应采样器。
|
||
* 支持 .ply(ASCII + binary),.txt / .xyz / .pts(纯文本行)。
|
||
*/
|
||
export async function samplePointcloudFile(
|
||
filePath: string,
|
||
fileSize: number,
|
||
k: number,
|
||
): Promise<SampledPoint[]> {
|
||
if (fileSize === 0 || k === 0) return [];
|
||
const ext = path.extname(filePath).toLowerCase();
|
||
|
||
if (ext === '.ply') {
|
||
const header = parsePlyHeader(filePath);
|
||
if (!header) {
|
||
console.warn(`[PLY] ${path.basename(filePath)}: failed to parse header, falling back to text sampler`);
|
||
return randomSeekSample(filePath, fileSize, k);
|
||
}
|
||
if (header.format === 'ascii') {
|
||
return samplePlyAscii(filePath, fileSize, header, k);
|
||
}
|
||
return samplePlyBinary(filePath, header, k);
|
||
}
|
||
|
||
// .txt / .xyz / .pts / etc. — existing text line sampler
|
||
return randomSeekSample(filePath, fileSize, k);
|
||
}
|