Skip to content

LAZ (file format)

LAZ 파일 형식은 Lidar 포인트 클라우드 데이터를 저장하기 위해 특별히 설계된 LAS(Lidar LAser) 파일 형식의 압축 버전입니다. LAZ 파일은 LAS 파일과 동일한 데이터 및 구조를 유지하지만 무손실 압축 기술을 사용하여 원본 데이터 충실도를 유지하면서 파일 크기를 줄입니다.

ply-to-las.js

/**
 * ply-to-las.js
 *
 * 브라우저에서 PLY(binary_little_endian) 파일을 LAS 1.4 바이너리로 변환.
 * Converts a binary_little_endian PLY file into LAS 1.4 in the browser.
 *
 * 압축(LAZ)은 하지 않음 — laz-perf의 JS 바인딩은 디코더 전용이라
 * 브라우저에서 실용적인 LAZ 인코딩이 불가능함. 압축/octree 빌드는
 * 업로드 후 백엔드 PDAL(writers.copc)에서 처리하는 것을 전제로 함.
 * No compression here (LAZ) — laz-perf's JS bindings are decode-only,
 * so there's no practical LAZ encoder in the browser. Compression and
 * octree building are expected to happen server-side via PDAL afterwards.
 *
 * 지원 property: x, y, z (필수), red/green/blue 또는 r/g/b (옵션, uchar 기준)
 * Supported properties: x, y, z (required), red/green/blue or r/g/b (optional, uchar)
 *
 * 지원하지 않는 것 / Not supported:
 *  - ASCII PLY (binary_little_endian만 지원 / binary_little_endian only)
 *  - binary_big_endian
 *  - normal, custom scalar 필드는 파싱은 하되 LAS extra bytes로 옮기려면
 *    별도 확장 필요 (아래 TODO 참고)
 */

// ---------------------------------------------------------------------------
// 1. PLY 헤더 파서 / PLY header parser
// ---------------------------------------------------------------------------

const PLY_TYPE_SIZES = {
  char: 1, uchar: 1, int8: 1, uint8: 1,
  short: 2, ushort: 2, int16: 2, uint16: 2,
  int: 4, uint: 4, int32: 4, uint32: 4,
  float: 4, float32: 4,
  double: 8, float64: 8,
};

/**
 * ArrayBuffer에서 PLY 헤더를 파싱하고, 헤더 끝의 바이트 오프셋을 함께 반환.
 * Parses the PLY header from an ArrayBuffer and returns the byte offset
 * where the header ends (i.e. where binary point data begins).
 */
function parsePlyHeader(buffer) {
  // 헤더는 텍스트이므로 앞부분만 우선 디코딩해서 "end_header"를 찾는다.
  // Header is ASCII text; decode a prefix chunk to locate "end_header".
  const headerPreviewBytes = Math.min(buffer.byteLength, 1024 * 64); // 64KB면 충분 / 64KB is plenty
  const previewText = new TextDecoder('ascii').decode(
    new Uint8Array(buffer, 0, headerPreviewBytes)
  );

  const endHeaderIdx = previewText.indexOf('end_header');
  if (endHeaderIdx === -1) {
    throw new Error('PLY 헤더에서 end_header를 찾지 못했습니다 (헤더가 64KB를 초과하는지 확인).');
  }
  // "end_header\n" 다음 바이트부터 point data 시작
  const newlineAfterEnd = previewText.indexOf('\n', endHeaderIdx);
  const dataStartOffset = newlineAfterEnd + 1;

  const headerText = previewText.slice(0, endHeaderIdx);
  const lines = headerText.split('\n').map((l) => l.trim()).filter(Boolean);

  if (lines[0] !== 'ply') {
    throw new Error('유효한 PLY 파일이 아닙니다 (magic "ply" 없음).');
  }

  const formatLine = lines.find((l) => l.startsWith('format'));
  if (!formatLine || !formatLine.includes('binary_little_endian')) {
    throw new Error(
      `지원하지 않는 PLY 포맷입니다: "${formatLine}". ` +
      `이 변환기는 binary_little_endian만 지원합니다.`
    );
  }

  // element/property 파싱 / Parse element & property declarations
  const elements = [];
  let currentElement = null;

  for (const line of lines) {
    if (line.startsWith('element')) {
      const [, name, countStr] = line.split(/\s+/);
      currentElement = { name, count: parseInt(countStr, 10), properties: [] };
      elements.push(currentElement);
    } else if (line.startsWith('property') && currentElement) {
      const parts = line.split(/\s+/);
      if (parts[1] === 'list') {
        // list property는 vertex에는 거의 없고 face에만 쓰임 → 스킵 대상 표시
        // list properties normally appear on "face", not "vertex" — mark for skip
        currentElement.properties.push({
          isList: true,
          countType: parts[2],
          valueType: parts[3],
          name: parts[4],
        });
      } else {
        currentElement.properties.push({
          isList: false,
          type: parts[1],
          name: parts[2],
        });
      }
    }
  }

  const vertexElement = elements.find((e) => e.name === 'vertex');
  if (!vertexElement) {
    throw new Error('PLY 파일에 vertex element가 없습니다.');
  }

  // vertex 레코드의 고정 바이트 길이 계산 (list property가 섞여 있으면 미지원)
  // Compute fixed byte stride of a vertex record (list properties unsupported here)
  let stride = 0;
  for (const prop of vertexElement.properties) {
    if (prop.isList) {
      throw new Error('vertex element에 list property가 포함된 PLY는 지원하지 않습니다.');
    }
    const size = PLY_TYPE_SIZES[prop.type];
    if (!size) throw new Error(`알 수 없는 PLY 타입: ${prop.type}`);
    prop.offset = stride;
    prop.size = size;
    stride += size;
  }
  vertexElement.stride = stride;

  return { elements, vertexElement, dataStartOffset };
}

// ---------------------------------------------------------------------------
// 2. vertex 데이터 추출 (x,y,z + 선택적 rgb) / Extract vertex data
// ---------------------------------------------------------------------------

function readVertexData(buffer, header) {
  const { vertexElement, dataStartOffset } = header;
  const { count, stride, properties } = vertexElement;

  const propMap = Object.fromEntries(properties.map((p) => [p.name, p]));
  const px = propMap.x, py = propMap.y, pz = propMap.z;
  if (!px || !py || !pz) {
    throw new Error('vertex element에 x, y, z property가 모두 있어야 합니다.');
  }

  // 색상 property 이름은 파일마다 red/green/blue 또는 r/g/b로 다름
  // Color property names vary: red/green/blue vs r/g/b
  const pr = propMap.red || propMap.r;
  const pg = propMap.green || propMap.g;
  const pb = propMap.blue || propMap.b;
  const hasColor = Boolean(pr && pg && pb);

  const view = new DataView(buffer, dataStartOffset, count * stride);

  // Float64Array로 좌표 저장 (스케일/오프셋 계산 정밀도를 위해)
  // Store coordinates as Float64Array for scale/offset precision
  const xs = new Float64Array(count);
  const ys = new Float64Array(count);
  const zs = new Float64Array(count);
  const colors = hasColor ? new Uint8Array(count * 3) : null;

  const readTyped = (dv, offset, type) => {
    switch (type) {
      case 'float': case 'float32': return dv.getFloat32(offset, true);
      case 'double': case 'float64': return dv.getFloat64(offset, true);
      case 'uchar': case 'uint8': return dv.getUint8(offset);
      case 'char': case 'int8': return dv.getInt8(offset);
      case 'ushort': case 'uint16': return dv.getUint16(offset, true);
      case 'short': case 'int16': return dv.getInt16(offset, true);
      case 'uint': case 'uint32': return dv.getUint32(offset, true);
      case 'int': case 'int32': return dv.getInt32(offset, true);
      default: throw new Error(`읽기 미지원 타입: ${type}`);
    }
  };

  for (let i = 0; i < count; i++) {
    const base = i * stride;
    xs[i] = readTyped(view, base + px.offset, px.type);
    ys[i] = readTyped(view, base + py.offset, py.type);
    zs[i] = readTyped(view, base + pz.offset, pz.type);

    if (hasColor) {
      colors[i * 3 + 0] = readTyped(view, base + pr.offset, pr.type);
      colors[i * 3 + 1] = readTyped(view, base + pg.offset, pg.type);
      colors[i * 3 + 2] = readTyped(view, base + pb.offset, pb.type);
    }
  }

  return { count, xs, ys, zs, colors, hasColor };
}

// ---------------------------------------------------------------------------
// 3. LAS 1.4 바이너리 작성 / Write LAS 1.4 binary
// ---------------------------------------------------------------------------

const LAS_HEADER_SIZE = 375; // LAS 1.4 header size
const PDRF_WITH_COLOR = 2;   // Point Data Record Format 2: xyz + intensity + ... + RGB
const PDRF_NO_COLOR = 0;     // PDRF 0: xyz + intensity + ...
const RECORD_LEN_WITH_COLOR = 26;
const RECORD_LEN_NO_COLOR = 20;

/**
 * 파싱된 vertex 데이터를 LAS 1.4 바이너리(Uint8Array)로 직렬화.
 * Serializes parsed vertex data into a LAS 1.4 binary buffer.
 */
function writeLas(vertexData) {
  const { count, xs, ys, zs, colors, hasColor } = vertexData;

  // bounding box 계산 / Compute bounding box
  let minX = Infinity, minY = Infinity, minZ = Infinity;
  let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
  for (let i = 0; i < count; i++) {
    if (xs[i] < minX) minX = xs[i];
    if (xs[i] > maxX) maxX = xs[i];
    if (ys[i] < minY) minY = ys[i];
    if (ys[i] > maxY) maxY = ys[i];
    if (zs[i] < minZ) minZ = zs[i];
    if (zs[i] > maxZ) maxZ = zs[i];
  }

  // 스케일: 0.001 (mm 단위 정밀도) — 산업용 스캔 데이터에 적합
  // Scale: 0.001 (mm-level precision) — suitable for industrial scan data
  const scale = 0.001;
  const offsetX = minX, offsetY = minY, offsetZ = minZ;

  const pdrf = hasColor ? PDRF_WITH_COLOR : PDRF_NO_COLOR;
  const recordLen = hasColor ? RECORD_LEN_WITH_COLOR : RECORD_LEN_NO_COLOR;

  const totalSize = LAS_HEADER_SIZE + count * recordLen;
  const out = new ArrayBuffer(totalSize);
  const dv = new DataView(out);
  const u8 = new Uint8Array(out);

  // --- 헤더 작성 / Write header ---
  const writeStr = (offset, str, len) => {
    for (let i = 0; i < len; i++) u8[offset + i] = i < str.length ? str.charCodeAt(i) : 0;
  };

  writeStr(0, 'LASF', 4);                          // File signature
  dv.setUint16(4, 0, true);                         // File source ID
  dv.setUint16(6, 0, true);                         // Global encoding
  // Project ID (GUID) — 전부 0으로 채움 / left as zeros
  dv.setUint8(26, 1);                                // Version major
  dv.setUint8(27, 4);                                // Version minor
  writeStr(28, 'CVP-PLY2LAS', 32);                   // System identifier
  writeStr(60, 'ply-to-las.js', 32);                 // Generating software
  dv.setUint16(92, 1, true);                         // File creation day of year
  dv.setUint16(94, new Date().getFullYear(), true);  // File creation year
  dv.setUint16(96, LAS_HEADER_SIZE, true);           // Header size
  dv.setUint32(98, LAS_HEADER_SIZE, true);           // Offset to point data
  dv.setUint32(102, 0, true);                        // Number of VLRs
  dv.setUint8(104, pdrf);                            // Point data record format
  dv.setUint16(105, recordLen, true);                // Point data record length
  dv.setUint32(107, 0, true);                        // Legacy point count (1.4는 0 허용, 아래 확장 필드 사용)
  // Legacy point count by return (5 x uint32) — 0으로 둠
  dv.setFloat64(131, scale, true);                    // X scale
  dv.setFloat64(139, scale, true);                    // Y scale
  dv.setFloat64(147, scale, true);                    // Z scale
  dv.setFloat64(155, offsetX, true);                  // X offset
  dv.setFloat64(163, offsetY, true);                  // Y offset
  dv.setFloat64(171, offsetZ, true);                  // Z offset
  dv.setFloat64(179, maxX, true);                     // Max X
  dv.setFloat64(187, minX, true);                     // Min X
  dv.setFloat64(195, maxY, true);                     // Max Y
  dv.setFloat64(203, minY, true);                     // Min Y
  dv.setFloat64(211, maxZ, true);                     // Max Z
  dv.setFloat64(219, minZ, true);                     // Min Z
  // Waveform data packet offset (227, 8bytes) = 0
  // Start of first EVLR (235, 8bytes) = 0
  // Number of EVLRs (243, 4bytes) = 0
  dv.setBigUint64(247, BigInt(count), true);          // Number of point records (LAS 1.4 extended)
  // Number of points by return (15 x uint64, 255~) — 0으로 둠

  // --- point records 작성 / Write point records ---
  let ptr = LAS_HEADER_SIZE;
  for (let i = 0; i < count; i++) {
    const ix = Math.round((xs[i] - offsetX) / scale);
    const iy = Math.round((ys[i] - offsetY) / scale);
    const iz = Math.round((zs[i] - offsetZ) / scale);

    dv.setInt32(ptr + 0, ix, true);
    dv.setInt32(ptr + 4, iy, true);
    dv.setInt32(ptr + 8, iz, true);
    dv.setUint16(ptr + 12, 0, true);   // Intensity
    dv.setUint8(ptr + 14, 1);          // Return number(bits) / classification flags 등, 기본값
    dv.setUint8(ptr + 15, 0);          // Classification
    dv.setInt8(ptr + 16, 0);           // Scan angle
    dv.setUint8(ptr + 17, 0);          // User data
    dv.setUint16(ptr + 18, 0, true);   // Point source ID

    if (hasColor) {
      // LAS는 16bit 컬러 채널 사용 → 8bit 값을 << 8로 확장
      // LAS uses 16-bit color channels → scale up from 8-bit PLY values
      dv.setUint16(ptr + 20, colors[i * 3 + 0] << 8, true);
      dv.setUint16(ptr + 22, colors[i * 3 + 1] << 8, true);
      dv.setUint16(ptr + 24, colors[i * 3 + 2] << 8, true);
    }

    ptr += recordLen;
  }

  return u8;
}

// ---------------------------------------------------------------------------
// 4. 공개 API / Public API
// ---------------------------------------------------------------------------

/**
 * File 객체(PLY)를 받아 LAS 바이너리(Blob)로 변환.
 * Converts a File (PLY) into a LAS binary Blob.
 *
 * @param {File} plyFile
 * @returns {Promise<Blob>} LAS 1.4 바이너리를 담은 Blob
 */
export async function convertPlyFileToLas(plyFile) {
  // 몇백 MB 파일도 arrayBuffer()로 한번에 읽는 게 일반 데스크톱 브라우저에서는 무리 없음.
  // 다만 모바일/저사양 환경 대응이 필요하면 File.slice()로 청크 단위 스트리밍 파싱으로 바꿔야 함.
  // Reading via arrayBuffer() at once is fine on desktop for a few hundred MB.
  // For mobile/low-memory targets, switch to chunked parsing via File.slice().
  const buffer = await plyFile.arrayBuffer();
  const header = parsePlyHeader(buffer);
  const vertexData = readVertexData(buffer, header);
  const lasBytes = writeLas(vertexData);
  return new Blob([lasBytes], { type: 'application/octet-stream' });
}

/**
 * 사용 예시 / Usage example:
 *
 * const input = document.querySelector('#ply-input');
 * input.addEventListener('change', async (e) => {
 *   const file = e.target.files[0];
 *   const lasBlob = await convertPlyFileToLas(file);
 *   // lasBlob을 R2 presigned URL로 업로드
 *   await fetch(presignedUrl, { method: 'PUT', body: lasBlob });
 * });
 *
 * TODO: 몇백 MB급 파일에서 메인 스레드 블로킹을 피하려면
 * 이 모듈을 Web Worker 안으로 옮기고 postMessage로 결과 Blob을 전달할 것.
 * To avoid blocking the main thread on large files, move this module
 * into a Web Worker and transfer the resulting Blob via postMessage.
 */

See also

Favorite site