Skip to content

Latest commit

 

History

14 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

massingcapture

CI Python License

Reality capture for AEC, in pure Python — content-first format detection, declared-frame coordinate math, plan-linked walkthroughs, and a browser viewer with no build step.

pip install massingcapture
massingcapture demo --serve

Opens http://127.0.0.1:8787 on a project it just built — five views, no build step, nothing fetched from anywhere but the project:

Plan the drawing with every capture position on it, plus the tools to register it, scale it and drag nodes into place
Walkthrough an equirectangular sphere with hotspots to the adjacent nodes
Reality BIM, meshes, point clouds and splat previews in one 3D scene — click an element for its property sets, or measure between two points
Compare one capture session cross-faded against another
Layers what may be measured against, and what may not

Everything in the demo is synthetic and labelled as such.

On your own data:

massingcapture init ./riverside --name "Riverside Clinic"
massingcapture ingest ~/captures/2026-03-04 --modality handheld
massingcapture serve

What this is

Drones, mobile robots, handheld SLAM rigs, tripod scanners, 360 cameras and fixed jobsite cameras produce about thirty file formats between them, and no two agree on what a coordinate means. This package is the layer that turns that into a project.

It sits upstream of two sibling projects:

  • MassingCloud/massingviser — a federated AEC platform in pure Python, whose twin family consumes captured reality and gates its promotion into authored geometry. massingcapture.bridge.massingviser emits exactly the records it expects.
  • MassingCloud/massingifc — the framework-agnostic TypeScript kernel and capability contracts. The scene document here is shaped for a Three.js front end built on it.

Neither is a dependency. The three agree on documents, not imports, so a capture container never needs a viewer installed and a viewer never needs a photogrammetry stack.


Zero dependencies, and what that buys

The core has no runtime dependencies at all. tests/test_architecture.py fails the build if that stops being true. What runs on a bare pip install:

Format detection 30+ formats, decided from magic bytes and parsed headers
E57 the XML index — scan positions, poses, structured grids, embedded panoramas
LAS/LAZ public header and VLRs — count, extent, scale, and the EPSG code
PLY header, bounds, and which of three things it is
glTF/GLB JSON chunk, accessor bounds, extension list
IFC header, length unit, and the storey list — your floors, for free
Images JPEG/PNG/TIFF dimensions, EXIF GPS, GPano and DJI XMP
Video MP4/MOV duration and resolution, spherical-video markers
Coordinates column-major 4×4, WGS84 geodetic/ECEF/ENU, Horn's absolute orientation
Registration 3D control points and 2D plan fitting, both reporting residuals
Walkthrough connected graph building, navigation, plan linking
Delivery job planner, PLY decimation, GLB and 3D Tiles writing, plan slices
Viewer HTTP API and a WebGL viewer with its own glTF and PLY loaders

Everything heavier — Open3D, trimesh, OpenCV, pyproj, pye57, laspy, pymavlink — lives in adapters/ behind a token, is entirely optional, and when absent produces a skip with instructions rather than a failure.

from massingcapture import adapters
adapters.available()   # ('crs', 'mesh')
adapters.missing()     # {'pointcloud': "No module named 'open3d'"}

The check, and how to borrow it

The claim above is not documentation — tests/test_architecture.py fails the build if the core gains a runtime dependency. stdlib_only_offenders() in that file is the reference implementation, meant to be copied whole, and the tests around it are thin callers with everything package-specific passed as an argument. Three decisions in it are the ones that matter, each written down at the call site:

  • It takes a path and never imports anything. The question a vendoring team actually has is whether the subtree their script copies is dependency-free, and that subtree usually is not importable on its own. Reading pyproject.toml answers a different question, and answers it wrongly in both directions: a project declaring Flask can still have a pure-stdlib core, and a project declaring nothing can still import something vendored beside it.
  • It separates "imports it anywhere" from "imports it at module scope." A guarded import inside a function is a supported optional integration; the same import at module scope makes the package unimportable without that library. A single check either forbids the legitimate pattern or permits the fatal one.
  • Exemptions are paths, not module names, so the allowlist survives the subtree being copied under a different package name — which is precisely what vendoring does to it.

test_the_portable_check_needs_only_a_path demonstrates all three against a tree that exists only on disk, under a package name installed nowhere.

Which level the zero-dependency claim is made at: the whole of src/massingcapture/ except adapters/, checked as a directory tree. For this package the project-level and subtree-level answers coincide, because pyproject.toml declares dependencies = [] — but they can differ, and the subtree answer is the one that governs vendorability.


The two rules that matter

A .ply is three different things

The same extension carries point clouds, triangle meshes and Gaussian splats. Classifying on the suffix gets it wrong often enough to matter, and the way it goes wrong is the expensive way.

>>> classify_file("photoreal.ply")
splat/ply (100%) -- PLY header: vertex element carries "f_dc_0" -- spherical-harmonic radiance field
>>> classify_file("scan.ply")
point-cloud/ply (100%) -- PLY header: 4823119 vertices, no faces

Measured and visual never mix

A handheld scanner produces a point cloud and a splat from the same ten minutes, and the two are not convertible after processing. One is a dimension; the other is a picture. So the project keeps two branches, and the gate lives in the schema where everything can see it:

>>> measurability_reason("splat", purpose=None)
'no-surface'
>>> measurability_reason("splat", derivatives=RealityDerivatives(mesh_uri="mesh.glb"))
None   # measurable through the mesh, which came from the same capture

The pipeline never plans a mesh job for a splat. A splat may get a positions preview — the Gaussian centres and their DC colour, recorded as splat-view rather than glb so nothing downstream can mistake it for a mesh derived from the same capture. The viewer draws it under a visual badge, the measure tool will not snap to it, and it refuses even to measure through it onto a mesh behind. And if all of that were bypassed, the server still answers:

409  photoreal.ply may not back a measurement: visualization-only. A radiance field renders
     convincingly and measures badly; a photograph has no depth. Measure against the point
     cloud or mesh from the same capture.

Five independent enforcements of one rule, because the failure mode is silent.


The shape

massingcapture/
  schema/          eight record families, the codec, and the version gate — stdlib only
  classify/        content-first detection: magic bytes, parsed headers, then extensions
  probe/           E57 index, LAS VLRs, PLY, glTF, IFC storeys, EXIF/GPano, MP4, splats, PTX
  transform/       column-major 4×4, WGS84 ENU, Horn's absolute orientation, plan fitting
  ingest/          classify → probe → derive → record, plus folder sessions and GPS anchoring
  walkthrough/     spanning-tree-first graph building, navigation, plan registration
  pipeline/        deterministic job planning, runners, stdlib PLY decimation
  providers/       NodeODM, Evercam, EarthCam — over urllib, behind protocols
  adapters/        optional: Open3D, trimesh, OpenCV, pyproj, pye57, laspy, pymavlink
  bridge/          twin-export for massingviser, scene document for a viewer
  server/          the API as a function; stdlib server and optional FastAPI mount
  web/             the bundled viewer — raw WebGL, no CDN, no build step

Coordinates, and why a bare matrix is a bug

A capture project accumulates five or six frames: the scanner's own, project ENU, each floor plan's 2D space, the BIM's local coordinates, and ECEF. A 4×4 in a manifest without its two frames looks authoritative, composes without complaint, and is wrong in a way that surfaces three weeks later.

So it is unrepresentable:

>>> TransformRecord(id="t1", source="plan-2d", target="plan-2d", matrix=IDENTITY)
ValueError: A transform from "plan-2d" to itself is not a transform.

And composition across frames is a graph walk, not a thing you do by hand:

graph = FrameGraph(manifest.transforms)
graph.convert(point, "source-local", "plan-2d", scope=floor.id)

Every fit reports its residual, always — rms_error, max_error, and the per-point residuals so a UI can mark the control point dragging the fit:

>>> fit = fit_rigid_3d(scan_points, survey_points)
>>> residual_summary(fit)
'7 points, RMS 0.0042 m, worst 0.0091 m'

Walkthroughs that are actually connected

Capture systems hand over positions, not graphs. A pure k-nearest graph fragments — two rooms joined by a sparsely-scanned corridor become two components, and a user who walks into one can never reach the other.

So a spanning tree goes down first (nobody gets stranded), near neighbours on top (natural steps), and stair edges only between adjacent floors where a stair core plausibly is. Long spanning-tree edges that bridge clusters are marked teleport rather than walk, so the viewer can be honest instead of implying a door that is not there.

$ massingcapture graph
41 nodes, 96 edges
  connected

Drones, and OpenDroneMap

ODM is called, not embedded. NodeODM is a production task API in front of the ODM engine, already what WebODM and PyODM talk to, and it means the photogrammetry runs in its own container while this package stays a thing that reads headers and writes JSON.

from massingcapture.providers import NodeOdmProvider, import_products

odm = NodeOdmProvider("http://odm:3000")
uuid = odm.create_task(images, name="North facade, 4 March")
odm.wait(uuid, on_progress=lambda info: print(info.status_name, info.progress))
import_products(store, odm.download_products(uuid, folder), session_name="North facade", task_uuid=uuid)

The submission asks for GLB, COPC, EPT, DSM, DTM and 3D Tiles explicitly, because those are the derivatives a browser actually wants and converting afterwards loses georeferencing.

ODM is for the outside — envelopes, roofs, façades, terrain, campuses. It does not solve indoor walkthrough UX and this package does not pretend it does; interiors come from pano nodes and handheld capture, and the two meet on the plan and in the BIM.


Fixed cameras are integration layers, not geometry

EarthCam and Evercam are visual coordination systems. They consume registered imagery, drone products and BIM models; they do not produce measurable geometry. So they are adapters here — a camera registry, a frame timeline, and calibration metadata — and FixedCameraRecord.calibrated stays False until something actually solves a pose, so an overlay never claims accuracy it has not earned.

The timeline is the one collection in a project bounded by time rather than by the building. Everything else is a few floors, a few dozen scans, a few hundred elements; a camera at a frame every ten minutes is 52,560 records a year and never stops. Held in the manifest that came to 15 MB of JSON and 6.5 seconds to parse — paid by every unrelated command, and paid again in full to append one frame.

So frames live in frames/<camera_id>.jsonl, append-only, read a slice at a time, and the manifest keeps the summary: how many, over what span, how many still awaiting their image. Opening a project with a camera-year in it went from 6.5 s to 3 ms; with 200,000 frames, from 25 s to 3 ms. latest() reads backwards from the end of the file, because "show me the latest frame" is the question anyone actually asks a jobsite camera and answering it should not cost a year of parsing.

An older project with frames still in its manifest has them moved on first open, not dropped — a timeline is the one thing no job can regenerate, because by then the provider has aged the images out.


Drawings become plans

A floor plan arrives as a PDF or a DXF, and neither is something a browser draws. Until it becomes one, the whole plan-linked half of this — placing scan positions, dragging a node onto the drawing, laying an as-built slice over it — has nothing to work on.

A DXF renders to SVG in the standard library. Vector in, vector out. Blocks are instanced (translation, scale including mirroring, rotation), text stays text, and layer-0 contents inherit the referencing layer as the format requires. Entity types it does not draw are named rather than approximated — a spline drawn as a straight line is a wall in the wrong place — and so is a block referenced but never defined, because the fix is to ask for the missing xref.

And the plan arrives registered. $INSUNITS says what one drawing unit is, so metres per SVG unit is settled before anybody picks a control point. Calibration goes back to being what it should be: the fallback for a scanned drawing that cannot say.

A PDF rasterises behind the plan adapter, keeping the sheet geometry that makes it measurable — a page is 72 points to the inch, so a render at a recorded DPI plus a stated drawing scale gives metres per pixel exactly. Without a stated scale the plan arrives unregistered on purpose: a sheet size cannot tell you how big the building is, and the number that would make it look like it could is exactly the number nobody should invent.


API

The API is a function — handle(Request) -> Response — with no framework, no globals and no transport. A test calls it directly; the bundled stdlib server calls it; a FastAPI deployment mounts it. All three run the same code.

GET  /api/scene                          the whole viewer document
GET  /api/walkthrough/nodes/{id}         a node with its exits, bearings resolved
GET  /api/plan                           what processing the project implies
GET  /api/twin                           the massingviser twin-import document
GET  /api/branches                       measured vs visual, named
POST /api/plans/{id}/register            solve a plan transform from control points
POST /api/plans/{id}/calibrate           solve a scale from two points and a distance
POST /api/walkthrough/nodes/{id}/place   move a node onto the drawing
GET  /api/elements/{global_id}           an IFC element's property sets
GET  /api/cameras                        fixed cameras, each with its latest frame
GET  /api/cameras/{id}/frames            a page of one timeline — offset-paged, never all of it
POST /api/measurements                   record a dimension — refused off unmeasurable geometry
POST /api/jobs/run                       run what this deployment can
from massingcapture.server.fastapi_app import create_app
app = create_app("./riverside")

Documentation


Development

pip install -e ".[dev]"          # core only — the interesting case
pytest
ruff check . && ruff format --check .

pip install -e ".[all,dev]"      # with every adapter, for the cross-checks below
pytest

The suite writes its own E57s, LAS files, GLBs, JPEGs with EXIF and CRC-paged layouts, byte by byte — so the expected answer is known exactly, because the test wrote it.

Where an optional library is installed, the tests go further and cross-validate: the reference implementation writes a file and the dependency-free reader parses it, and the two must agree. pye57 writes an E57 and probe.e57 unpages its index; laspy writes a LAS and a struct header parser reads its extent and EPSG; trimesh writes a GLB and probe.mesh reads the accessor bounds; pyproj's EPSG:4326 → EPSG:4978 is held against the hand-written Ferrari solution to a millimetre.

That is worth more than testing either half alone. A misread byte offset produces a plausible number, not an exception — and it was this pass that caught the E57 writer using zlib.crc32 little-endian where the format wants CRC-32C big-endian, a file this package read perfectly and every other tool in the world rejected as corrupt.

The sample corpus

Both of the above are still closed loops: a writer and a reader that share this repository. To break the loop there is an opt-in corpus of ~5 MB of public files nobody here authored — buildingSMART's certification models, PDAL's conformance clouds, a Rust E57 crate's test data, and the header of an INRIA Gaussian splat.

python tests/corpus.py list      # what it fetches, from where, under which licence
python tests/corpus.py fetch     # ~5 MB into .corpus/, gitignored
pytest                           # tests/test_realdata.py stops skipping

Nothing downloads by default — a clone stays clonable offline, and CI does not spend somebody else's bandwidth. Files are chosen for the assumptions they can break: a deliberately corrupted page checksum (the negative control without which verify_pages returning [] proves nothing), one rabbit in six numeric encodings, a scan in spherical coordinates, a cloud in degrees, another in feet, and a schema newer than the sniffer. Between them they found four real defects on the first run, including that IfcMapConversion — the file's own statement of where the building is — was being discarded entirely.

Licence

MIT.

About

Reality capture for AEC in pure Python - content-first format detection, declared-frame coordinate math, plan-linked walkthroughs, and a dependency-free web viewer.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages