moonlas

    Bounded LAS point-record IO, metadata-preserving filtering and elevation grids in pure MoonBit

    las
    lidar
    point-cloud
    geospatial
    Download zip
    Author
    Version
    0.1.0
    License
    MIT
    Last updated
    5 hours ago
    Downloads
    3

    No dependencies

    #MoonLAS

    纯 MoonBit 的有界 LAS 点云二进制处理库,模块 wrMamba/moonlas@0.1.0。 读取 LAS 1.2/1.4 的七种点记录格式,保留点属性和不透明元数据,完成质量检查、筛选、空间裁剪、量化与高程栅格聚合。不依赖 Python、C 点云库或操作系统文件 API。

    #解决什么问题

    采集质量检查系统需要核对分类、回波、强度及头部边界;GIS/遥感流水线需要筛出地面点并生成小区域高程栅格;机器人和可视化接入层需要按批解码、统一坐标量化且保留颜色、GPS 和额外属性。MoonLAS 让这些系统共用格式与边界检查,而不是分别实现 LASF 字节偏移和位字段。

    本项目不是井测文本 LAS 解析器,不是完整 GIS 引擎,也不是 LAZ 解压器。

    #环境与安装

    固定验收工具链:moonc v0.10.18+6c83a7f62,moon 0.1.20261028。支持 wasm-gc、wasm、js 和 native 后端;JS 需要 Node.js,native 需要相应 C 编译器。CI 在 Ubuntu 24.04 对四后端执行严格 check/test/build 与三个示例,另有独立 laspy 参考向量再生成作业。

    在新的 MoonBit 项目中:

    moon add wrMamba/moonlas@0.1.0

    在使用包的 moon.pkg 中加入:

    import { "wrMamba/moonlas" @las }

    #快速开始

    下面是完整可运行的 main.mbt;包需设置 pkgtype(kind: "executable")。

    fn main {
    try {
    let p = @las.Point::new(10, 20, 30)
    let points = [p, { ..p, x: 40, classification: 2 }]
    let doc = @las.create(points, 0, @las.Transform::new(0.01), minor=2)
    let raw = @las.encode(doc)
    let parsed = @las.decode(raw)
    let ground = parsed.select({ ..@las.Selection::all(), classifications: [2] })
    println(ground.points.length()) // 1
    println(@las.encode(ground).length()) // 247
    } catch { _ => abort("LAS processing failed") }
    }

    库处理 Bytes 而不打开文件;应用自行从文件、网络或对象存储获取字节。错误类型 LasError 区分 Truncated(offset)、Limit(message)、Invalid(message) 与 Unsupported(message),调用者可匹配错误而不是终止进程。

    #已交付能力

    能力具体契约
    读写LAS 1.2 格式 0/1/2/3;LAS 1.4 格式 0/1/2/3/6/7/8
    点属性raw Int32 XYZ、强度、回波、分类、标志、扫描角、来源 ID、用户数据,以及对应格式的 GPS/RGB/NIR
    元数据16 字节项目 UUID、文件头标识、VLR 不透明 payload、VLR 到点区的 gap、每点额外字节
    质量审计分类/回波/标志直方图,强度范围与均值、实际边界,发现头部边界/回波统计陈旧
    筛选分类、来源 ID、强度、首/末/单回波、withheld、GPS 闭区间;所有条件取交集,输出顺序不变
    空间操作缩放坐标下的闭 AABB / 球形裁剪,正比例尺和偏移校验
    栅格有界稠密 XY 网格;非空 cell 的点数、class 2 数量、高程最小/最大/均值及平均强度
    量化/拼接半远离零舍入,Int32 溢出检查、调用者显式误差容限;元数据冲突拒绝而非猜测合并
    按需访问open_records、seek、next、batch;输入仍为完整有界 Bytes,不是磁盘流式 reader

    完整公共接口由 pkg.generated.mbti 给出。数据结构包含可变数组;操作前会重新检查结构,不提供并发/不可变快照保证。

    #API 工作流

    #采集检查与修复

    let report = doc.audit()
    let repaired = doc.with_points(doc.points)
    let output = @las.encode(repaired)

    decode 接受可审计的陈旧边界/回波统计;其他非法结构仍报错。with_points 重建点数、回波统计、边界、record width、VLR 数量与 offset。encode 先要求结构自洽,再规范化派生字段;它不是逐字节原文件归档器。LAS 1.4 输出的 legacy 计数字段为零,读取旧格式时也接受与扩展计数一致的非零 legacy 字段。固定文本字段只保留首个 NUL 之前的可打印 ASCII,后部填充重新置零。

    #GIS:地面筛选、裁剪和栅格

    let ground = doc.select({ ..@las.Selection::all(), classifications: [2], include_withheld: false })
    let area = ground.crop({ min: { x: 0.0, y: 0.0, z: -100.0 }, max: { x: 100.0, y: 100.0, z: 500.0 } })
    let grid = area.elevation_grid(2.0)
    let cell = grid.at(0, 0) // Cell?;空 cell/越界为 None

    栅格原点为输入点的实际 XY 最小值,按 floor((coord-origin)/cell_size) 分箱。边界最大值恰好位于分界线时落入下一个格;行优先存储。空点集返回 0×0 网格。class 2 仅作数值分类计数,不推断语义正确性或 CRS。

    #接入:批量读取、容限重编码与拼接

    let cursor = @las.open_records(input_bytes)
    let batch = cursor.batch(256)
    cursor.seek(0)
    let result = doc.requantize(@las.Transform::new(0.02), 0.011)
    let output_bytes = @las.encode(result.document)
    let joined = doc.merge(other_doc, 0.011)

    requantize 返回实际各轴最大误差,超容限拒绝。不是坐标参考系转换。merge 要求版本、点格式、额外字节宽度、global encoding、项目/文件标识、VLR 和 gap 相同;右侧量化到左侧坐标编码,保留左侧头部来源/日期/软件信息。仅比例尺不同并不证明 CRS 相同,应用必须保证坐标系一致。

    #三个可运行场景

    以下输入均为仓库内原创合成数据,不依赖网络或私人点云。

    moon run examples/quality --deny-warn # 120 点质量统计,剔除 withheld/低强度,101 点输出 moon run examples/terrain --deny-warn # 地面筛选与裁剪,3×3 网格,CSV 统计输出 moon run examples/ingest --deny-warn # 格式 8,64 点分批读取,量化、球裁剪,27 点输出

    #资源、安全与已知限制

    • 默认输入/序列化预算 64 MiB、点数 1,000,000、VLR 4096、网格 1,000,000 格;可通过 Limits 显式调整。安全上限分别为 1 GiB、10,000,000 点、65,535 VLR、10,000,000 格。预算不等于进程峰值内存保证;数组、元数据和输出会复制。
    • 64 位头部计数仅接受高 32 位为零且低位在预算内,不支持任意 64 位规模文件。乘法、偏移和分配前检查,失败返回错误。
    • LAS 1.0/1.1/1.3、格式 4/5/9/10、LAZ/压缩位、waveform、EVLR、扩展公共头、尾随数据和未知 global encoding 位不支持。
    • 严格子集:回波编号必须从 1 开始且不超过总回波,旧格式最多 5;0 回波记录拒绝。GPS/坐标/比例尺/偏移必须有限,比例尺必须为正;旧扫描角范围 [-90,90],现代 raw 扫描角 [-30000,30000](单位 0.006°)。
    • 固定字段仅支持可打印 ASCII;不解释 Extra Bytes schema、GeoTIFF/WKT 或 CRS、不做重投影。VLR payload 与点额外字节按不透明字节保留。
    • 空间筛选为线性扫描,栅格为有预算的稠密矩阵;不是通用八叉树、最近邻库、渲染器、DTM 插值器或生产级超大文件服务。
    • 边界/直方图审计为精确值比较;浮点量化需要结合应用误差容限。解析器会检查结构,但未做安全审计认证,也不保证任意合法 LAS 均可处理。

    #验证与复现

    moon info moon fmt --check moon check --deny-warn --target wasm-gc moon test --deny-warn --target wasm-gc moon build --deny-warn --target wasm-gc python tools/audit_source.py

    将目标替换为 wasm、js、native 可复核其他后端。当前 45 个测试:包括 11 份由 laspy 2.6.1 / numpy 2.2.6 生成的文件、352 条独立记录逐字段读取与更改后完整写入字节对照、1792 条自生成属性组合、截断前缀、预算、冲突和三个下游流程。

    独立向量再生成请使用隔离 Python 3.12 环境:

    python -m venv .oracle-env # 激活该环境后: python -m pip install laspy==2.6.1 numpy==2.2.6 python tools/generate_oracles.py moon fmt # 生成物应与提交版本一致: git diff --exit-code -- reference_wbtest.mbt tools/oracle-manifest.json

    实质生产源码的物理行/非空非注释行由 inventory 脚本输出;测试、生成的参考向量、示例和接口均不计入。没有发布性能优越性或实测用户规模声明。

    #来源、许可与 AI

    原创 MoonBit 实现,MIT;仅使用 MoonBit core。行为参考 laspy(BSD-3-Clause),不复制其实现、测试或数据。详见 NOTICE、AI-USE.md、架构与安全、语义查重。AI 参与实现、测试、调试与文档,测试结果不等于官方赛事验收;本流程不自动报名。

    LasError

    pub suberror LasError {
    Truncated(Int)
    Limit(String)
    Invalid(String)
    Unsupported(String)
    } derive(
    Debug
    )

    Recoverable input errors carry a byte offset where available.

    LasError::to_repr

    Audit

    pub(all) struct Audit {
    count : Int
    classifications : Array[Int]
    returns : Array[Int]
    flags : Array[Int]
    bounds : Bounds?
    intensity_min : Int
    intensity_max : Int
    intensity_mean : Double
    issues : Array[String]
    } derive(
    Debug
    )

    Computed from records, never copied from the public header.

    Audit::to_repr

    Bounds

    pub(all) struct Bounds {
    min : Position
    max : Position
    } derive(Eq,
    Debug
    )

    Bounds::contains

    fn Bounds::contains(self : Bounds, p : Position) -> Bool

    Bounds::equal

    fn Bounds::equal(Bounds, Bounds) -> Bool

    Bounds::not_equal

    fn Bounds::not_equal(x : Bounds, y : Bounds) -> Bool

    Bounds::to_repr

    Bounds::validate

    fn Bounds::validate(self : Bounds) -> Unit raise LasError

    Cell

    pub(all) struct Cell {
    count : Int
    ground_count : Int
    min_z : Double
    max_z : Double
    mean_z : Double
    mean_intensity : Double
    } derive(Eq,
    Debug
    )

    Cell::equal

    fn Cell::equal(Cell, Cell) -> Bool

    Cell::not_equal

    fn Cell::not_equal(x : Cell, y : Cell) -> Bool

    Cell::to_repr

    Document

    pub(all) struct Document {
    header : Header
    vlrs : Array[Vlr]
    gap : Bytes
    points : Array[Point]
    } derive(
    Debug
    )

    In-memory LAS document. Public operations validate mutable array contents before use.

    Document::audit

    fn Document::audit(self : Document, limits? : Limits) -> Audit raise LasError

    Structural invalidity raises; stale derived header fields become issues.

    Document::crop

    fn Document::crop(self : Document, bounds : Bounds, limits? : Limits) -> Document raise LasError

    Inclusive axis-aligned crop in scaled coordinates, not raw integer units.

    Document::elevation_grid

    fn Document::elevation_grid(self : Document, cell_size : Double, limits? : Limits) -> Grid raise LasError

    Bins are [origin+i*size, origin+(i+1)*size); the maximum endpoint gets a bin. Class 2 is counted as ground without interpreting CRS or classification rules.

    Document::merge

    fn Document::merge(self : Document, other : Document, tolerance : Double, limits? : Limits) -> Document raise LasError

    Stable concatenation into the left document's scale. Opaque metadata must match. Header provenance is retained from the left; right header dates/software are not merged.

    Document::requantize

    fn Document::requantize(self : Document, target : Transform, tolerance : Double, limits? : Limits) -> Requantized raise LasError

    Coordinate rescaling only. No CRS conversion; reject errors over caller tolerance.

    Document::select

    fn Document::select(self : Document, selection : Selection, limits? : Limits) -> Document raise LasError

    Stable-order selection retains every attribute and all opaque metadata.

    Document::to_repr

    Document::validate

    fn Document::validate(self : Document, limits? : Limits) -> Unit raise LasError

    Document::with_points

    fn Document::with_points(self : Document, points : Array[Point], limits? : Limits) -> Document raise LasError

    Recompute counts, bounds, offset and width while retaining metadata and format.

    Document::within_radius

    fn Document::within_radius(self : Document, center : Position, radius : Double, limits? : Limits) -> Document raise LasError

    Inclusive spherical selection; normalized distances avoid squared overflow.

    Grid

    pub(all) struct Grid {
    origin_x : Double
    origin_y : Double
    cell_size : Double
    columns : Int
    rows : Int
    cells : Array[Cell?]
    } derive(
    Debug
    )

    Dense row-major XY grid. Empty cells are None, never fabricated elevations.

    Grid::at

    fn Grid::at(self : Grid, column : Int, row : Int) -> Cell? raise LasError

    Grid::to_repr

    pub(all) struct Header {
    minor : Int
    file_source_id : Int
    global_encoding : Int
    project_id : Bytes
    system : String
    software : String
    day : Int
    year : Int
    point_offset : Int
    vlr_count : Int
    format : Int
    record_length : Int
    point_count : Int
    by_return : Array[Int]
    transform : Transform
    bounds : (Double, Double, Double, Double, Double, Double)
    } derive(
    Debug
    )

    Header fields are retained; counts and extents can be audited against records.

    Header::to_repr

    Limits

    pub(all) struct Limits {
    max_bytes : Int
    max_points : Int
    max_vlrs : Int
    max_cells : Int
    } derive(Eq,
    Debug
    )

    Budgets apply before allocating record arrays or metadata payloads.

    Limits::default

    fn Limits::default() -> Limits

    Limits::equal

    fn Limits::equal(Limits, Limits) -> Bool

    Limits::not_equal

    fn Limits::not_equal(x : Limits, y : Limits) -> Bool

    Limits::to_repr

    Limits::validate

    fn Limits::validate(self : Limits) -> Unit raise LasError

    Point

    pub(all) struct Point {
    x : Int
    y : Int
    z : Int
    intensity : Int
    return_number : Int
    returns : Int
    classification : Int
    flags : Int
    scanner_channel : Int
    scan_direction : Bool
    edge : Bool
    scan_angle : Int
    user_data : Int
    source_id : Int
    gps_time : Double?
    rgb : (Int, Int, Int)?
    nir : Int?
    extra : Bytes
    } derive(Eq,
    Debug
    )

    Raw integer attributes retain the exact LAS record semantics.

    Point::equal

    fn Point::equal(Point, Point) -> Bool

    Point::new

    fn Point::new(x : Int, y : Int, z : Int) -> Point

    Point::not_equal

    fn Point::not_equal(x : Point, y : Point) -> Bool

    Point::to_repr

    Point::validate

    fn Point::validate(self : Point, format : Int) -> Unit raise LasError

    PointCursor

    pub struct PointCursor {
    data : Bytes
    header : Header
    index : Int
    }

    A seekable cursor over a bounded, complete Bytes buffer; not OS/file streaming. Records are decoded on demand and a failed next() leaves the position unchanged.

    PointCursor::batch

    fn PointCursor::batch(self : PointCursor, n : Int) -> Array[Point] raise LasError

    At most n records, bounded by remaining input. Negative n is rejected.

    PointCursor::header

    fn PointCursor::header(self : PointCursor) -> Header

    PointCursor::next

    fn PointCursor::next(self : PointCursor) -> Point? raise LasError

    PointCursor::position

    fn PointCursor::position(self : PointCursor) -> Int

    PointCursor::remaining

    fn PointCursor::remaining(self : PointCursor) -> Int

    PointCursor::seek

    fn PointCursor::seek(self : PointCursor, index : Int) -> Unit raise LasError

    Position

    pub(all) struct Position {
    x : Double
    y : Double
    z : Double
    } derive(Eq,
    Debug
    )

    Position::equal

    fn Position::equal(Position, Position) -> Bool

    Position::not_equal

    fn Position::not_equal(x : Position, y : Position) -> Bool

    Position::to_repr

    Position::validate

    fn Position::validate(self : Position) -> Unit raise LasError

    Requantized

    pub(all) struct Requantized {
    document : Document
    max_error : Position
    } derive(
    Debug
    )

    ReturnMode

    pub(all) enum ReturnMode {
    Any
    First
    Last
    Single
    } derive(Eq,
    Debug
    )

    ReturnMode::equal

    fn ReturnMode::equal(ReturnMode, ReturnMode) -> Bool

    ReturnMode::not_equal

    fn ReturnMode::not_equal(x : ReturnMode, y : ReturnMode) -> Bool

    Selection

    pub(all) struct Selection {
    classifications : Array[Int]
    source_ids : Array[Int]
    min_intensity : Int
    max_intensity : Int
    return_mode : ReturnMode
    include_withheld : Bool
    gps_min : Double?
    gps_max : Double?
    } derive(
    Debug
    )

    Empty membership lists impose no restriction. All criteria are intersected.

    Selection::all

    fn Selection::all() -> Selection

    Selection::validate

    fn Selection::validate(self : Selection) -> Unit raise LasError

    Transform

    pub(all) struct Transform {
    sx : Double
    sy : Double
    sz : Double
    ox : Double
    oy : Double
    oz : Double
    } derive(Eq,
    Debug
    )

    XYZ scale factors and offsets. They never imply a coordinate reference system.

    Transform::equal

    fn Transform::equal(Transform, Transform) -> Bool

    Transform::new

    fn Transform::new(scale : Double) -> Transform raise LasError

    Transform::not_equal

    fn Transform::not_equal(x : Transform, y : Transform) -> Bool

    Transform::position

    fn Transform::position(self : Transform, point : Point) -> Position raise LasError

    Transform::quantize

    fn Transform::quantize(self : Transform, position : Position, attributes : Point) -> Point raise LasError

    Nearest integer; exact half-way cases round away from zero.

    Transform::validate

    fn Transform::validate(self : Transform) -> Unit raise LasError

    Vlr

    pub(all) struct Vlr {
    user_id : String
    record_id : Int
    description : String
    payload : Bytes
    } derive(Eq,
    Debug
    )

    VLR payloads are opaque. GeoTIFF/WKT records are retained without reprojection.

    Vlr::equal

    fn Vlr::equal(Vlr, Vlr) -> Bool

    Vlr::not_equal

    fn Vlr::not_equal(x : Vlr, y : Vlr) -> Bool

    Vlr::to_repr

    Vlr::validate

    fn Vlr::validate(self : Vlr) -> Unit raise LasError

    create

    fn create(points : Array[Point], format : Int, transform : Transform, minor? : Int, vlrs? : Array[Vlr], limits? : Limits) -> Document raise LasError

    decode

    fn decode(data : Bytes, limits? : Limits) -> Document raise LasError

    encode

    fn encode(doc : Document, limits? : Limits) -> Bytes raise LasError

    Canonical encoding recomputes derived counts, bounds, width and point offset. Unknown VLR payloads, gap bytes and per-point extra bytes remain opaque.

    open_records

    fn open_records(data : Bytes, limits? : Limits) -> PointCursor raise LasError

    point_bounds

    fn point_bounds(points : Array[Point], transform : Transform) -> Bounds? raise LasError

    read_header

    fn read_header(data : Bytes, limits? : Limits) -> Header raise LasError

    read_point

    fn read_point(data : Bytes, format : Int) -> Point raise LasError

    Decode exactly one point record; the caller supplies the declared record length.

    read_vlrs

    fn read_vlrs(data : Bytes, header : Header, limits? : Limits) -> (Array[Vlr], Bytes) raise LasError

    record_size

    fn record_size(format : Int) -> Int raise LasError

    vlrs_named

    fn vlrs_named(records : Array[Vlr], user_id : String, record_id : Int) -> Array[Vlr]

    write_point

    fn write_point(point : Point, format : Int) -> Bytes raise LasError

    Encodes attributes without clipping or silently adding format-specific fields.