Skip to content

Command-Line Reference

mudm-tools ships exactly one installed console script — mudm-serve — plus a converter CLI you run as a Python module. This page documents both interfaces in full: every flag, its default, and what the command actually does on disk and on the wire.

Two entry points, one installed script

  • mudm-serve is a real console script declared in pyproject.toml ([project.scripts] mudm-serve = "mudm_tools.serve:main"). After pip install mudm-tools it lands on your PATH.
  • The converter CLI has no installed script. There is no mudm convert command. Always invoke it as a module: python -m mudm_tools.converters.cli .... (The argparse prog is mudm and some in-code docstrings show mudm convert ..., but that command is not installed — only mudm-serve is.)

mudm-serve

Serves your tiled output (3D Tiles GLB, 2D MVT/raster, and Neuroglancer precomputed) over HTTP and mounts a bundled web viewer at /. The process binds to all interfaces (host "") and blocks until you press Ctrl+C.

mudm-serve --tiles-base output/
uv run mudm-serve --tiles-base output/
uv run python -m mudm_tools.serve --tiles-base output/

Synopsis

mudm-serve --tiles-base DIR
           [--port PORT]
           [--tiles2d-base DIR]
           [--viewer {3d,2d}]
           [--viewer-dir DIR]

Flags

Flag Type Default Description
--tiles-base str required Directory containing pyramids.json and per-pyramid tile subdirs. Resolved to an absolute path. A WARNING is printed (not fatal) if no pyramids.json is found.
--port int 8080 TCP port to bind (HTTPServer(("", port), ...)).
--tiles2d-base str "" Directory of 2D datasets (each a subdir with metadata.json). Resolved to an absolute path only when non-empty; otherwise 2D tile serving is disabled.
--viewer str 3d Which bundled viewer to serve at /. One of 3d or 2d.
--viewer-dir str None A custom viewer directory whose index.html is served at /. Overrides --viewer.

--tiles-base is mandatory

mudm-serve exits with an argparse error if --tiles-base is omitted. Point it at the directory you passed as the output root when generating tiles — the one that holds pyramids.json. If that file is missing the server still starts, but the 3D viewer's pyramid dropdown will be empty.

What the server does

When you start mudm-serve it:

  1. Resolves --tiles-base (and --tiles2d-base, if set) to absolute paths.
  2. Resolves the viewer directory: --viewer-dir if given, otherwise the bundled viewer named by --viewer (via mudm_tools.serve.get_viewer_dir).
  3. Auto-mounts the 2D Leaflet viewer at /2d/ when --viewer is 3d (the default) and a custom --viewer-dir was not supplied — best-effort, silently skipped if the 2D viewer directory is missing.
  4. Prints a short startup banner and blocks on serve_forever() until Ctrl+C.

The startup banner looks like this:

Compression: Brotli
Viewer:      http://localhost:8080 (3d)
2D Viewer:   http://localhost:8080/2d/
Tiles:       /abs/path/to/output
2D Tiles:    /abs/path/to/output2d
Press Ctrl+C to stop

The first line reads gzip (install 'brotli' for better compression) when the optional brotli package is not installed. The 2D Viewer: and 2D Tiles: lines appear only when the 2D viewer is mounted and --tiles2d-base is set, respectively.

Exit code

main() returns 0 on a clean shutdown and 1 if the selected viewer directory has no index.html (it prints ERROR: No index.html in <dir> and exits before binding the port).

Routes

mudm-serve maps request paths to files on disk. Query strings and fragments are stripped before matching.

Request path Serves
/ <viewer_dir>/index.html (the primary viewer).
/<asset> <viewer_dir>/<asset> (viewer JS/CSS/etc.).
/tiles/pyramids.json <tiles_base>/pyramids.json (the pyramid manifest).
/tiles/<pyramid_id>/<rest> First tries <tiles_base>/<pyramid_id>/<rest> (e.g. features.json, tileset.json, tilejson3d.json); if that is not a file, falls back to <tiles_base>/<pyramid_id>/3dtiles/<rest> (the GLB tile tree).
/neuroglancer/<pyramid_id>/<rest> <tiles_base>/<pyramid_id>/neuroglancer/<rest> (Neuroglancer precomputed).
/2d or /2d/ <viewer_2d_dir>/index.html (only when the 2D viewer is mounted).
/2d/<rel> <viewer_2d_dir>/<rel> (only when the asset exists).
/tiles2d/datasets.json A JSON array synthesized on the fly (see below).
/tiles2d/<rel> <tiles2d_base>/<rel> if the file exists — raster {z}/{x}/{y}.png, vectors {z}/{x}/{y}.pbf, metadata.json, gene_list.json, gene_colormap.json.

GET /tiles2d/datasets.json is not read from disk — the server iterates the sorted subdirectories of --tiles2d-base and, for each one that contains a metadata.json, emits {"id": <dirname>, "name": <metadata.name or dirname>}.

GLB compression

Any request whose path ends in .glb is compressed on the fly and cached:

  • If Accept-Encoding contains br and the optional brotli package is installed → Content-Encoding: br via brotli.compress(quality=5).
  • Otherwise, if Accept-Encoding contains gzipContent-Encoding: gzip via gzip.compress(compresslevel=6).
  • Otherwise the raw GLB is served by the standard handler.

Compressed bytes are cached in a process-global dict keyed by (file_path, "br" | "gzip"), capped at 512 MB total (_CACHE_MAX_BYTES = 512 * 1024 * 1024). Compressed GLB responses carry Content-Type: model/gltf-binary, Vary: Accept-Encoding, Cache-Control: public, max-age=86400, and Access-Control-Allow-Origin: *.

Install brotli for smaller transfers

brotli is an optional dependency. Without it the server falls back to gzip and prints a hint at startup. Add it with uv add brotli (or pip install brotli) to enable the higher-ratio path.

CORS and caching for every response

The handler always adds Access-Control-Allow-Origin: *. .glb responses get Cache-Control: public, max-age=86400; .js/.html/.json/.css responses get Cache-Control: no-cache.

Neuroglancer

The /neuroglancer/<pyramid_id>/<rest> route is a server-only passthrough to <tiles_base>/<pyramid_id>/neuroglancer/. Point an external Neuroglancer client at it — neither bundled viewer (3D or 2D) contains Neuroglancer client code, so opening the route in the bundled viewer will not render anything. See the Neuroglancer guide for exporting precomputed output and wiring up a client.

Bundled viewers

Viewer assets are packaged inside the wheel at src/mudm_tools/viewers/viewer3d/ (Three.js) and src/mudm_tools/viewers/viewer2d/ (Leaflet), and are resolved at runtime by mudm_tools.serve.get_viewer_dir("3d" | "2d").

Viewer Served at Engine Feature list
3D Tiles / when --viewer 3d (default) Three.js 0.171.0 (Draco + meshopt) 3D Tiling guide
2D MVT + raster / when --viewer 2d; also /2d/ when --viewer 3d Leaflet 1.9.4 + VectorGrid 2D Tiling guide

Both viewers fetch their JS engine from a CDN

The 3D viewer loads three.js, the Draco decoder, and the meshopt decoder from CDNs at runtime; the 2D viewer loads Leaflet from unpkg (only Leaflet.VectorGrid is vendored locally). Both bundled viewers therefore require internet access to boot. Plan accordingly for air-gapped deployments.

Examples

Serve 3D Tiles with the default 3D viewer (and the 2D viewer auto-mounted at /2d/):

uv run mudm-serve --tiles-base output/

Serve the 2D viewer at / instead, with a separate 2D dataset root:

uv run mudm-serve \
    --tiles-base output/ \
    --tiles2d-base output2d/ \
    --viewer 2d

Serve 3D Tiles and 2D datasets together — 3D at /, 2D at /2d/:

uv run mudm-serve \
    --tiles-base output/ \
    --tiles2d-base output2d/ \
    --port 9000

Serve your own viewer build (this disables the /2d/ auto-mount):

uv run mudm-serve \
    --tiles-base output/ \
    --viewer-dir ./my-viewer

Expected directory layout

# --tiles-base (3D)
<tiles_base>/
├── pyramids.json                     # manifest: { "pyramids": [ {id, label, feature_count, ...}, ... ] }
└── <pyramid_id>/
    ├── tileset.json                  # OGC 3D Tiles tileset
    ├── tilejson3d.json               # optional: { maxzoom, id_fields, encodings }
    ├── features.json                 # MicroJSON FeatureCollection (or legacy features map)
    ├── 3dtiles/                      # GLB tiles, served via the /tiles/<id>/<rest> fallback
    │   └── {z}/{x}/{y}/{d}.glb       # Draco- or meshopt-compressed GLB
    └── neuroglancer/                 # served via /neuroglancer/<id>/<rest>

# --tiles2d-base (2D)
<tiles2d_base>/
└── <dataset_id>/
    ├── metadata.json                 # {name, um_per_px, bounds_um, raster{}, vectors{}}
    ├── raster/{z}/{x}/{y}.png        # DAPI raster pyramid
    ├── vectors/{z}/{x}/{y}.pbf       # multi-layer MVT (path from metadata.vectors.path)
    ├── gene_list.json                # optional: gene-filter dropdown values
    └── gene_colormap.json            # optional: gene category colors
# datasets.json is synthesized on the fly at GET /tiles2d/datasets.json

See Reference → TileJSON for the pyramids.json and 2D metadata.json schemas, and the legacy pipeline guide for how older output trees map onto these routes.


Converter CLI

The converter CLI tiles source data (GeoJSON, OBJ, or Xenium) into a muDM output tree. It is not an installed script — run it as a module:

uv run python -m mudm_tools.converters.cli convert \
    --format geojson \
    --input data.geojson \
    --output out/
python -m mudm_tools.converters.cli convert \
    --format geojson \
    --input data.geojson \
    --output out/

There is no mudm convert

The argparse program name is mudm and the in-code docstrings show mudm convert ..., but no mudm console script is installed — only mudm-serve is. Always use python -m mudm_tools.converters.cli .... Running the module with no subcommand just prints help.

Subcommand: convert

python -m mudm_tools.converters.cli convert
    --format {geojson,obj,xenium}
    --input  PATH
    --output PATH
    [--config FILE.json]
    [--temp-dir DIR]
    [--max-zoom N]
Flag Alias Required Default Description
--format -f yes Source format: geojson, obj, or xenium.
--input -i yes Path to the source data directory or file.
--output -o yes Path for the tiled output (created with parents).
--config -c no None Path to a JSON file of converter-specific settings.
--temp-dir no None Temp dir for fragments; injected into the config as temp_dir when set.
--max-zoom no None Max zoom level; injected into the config as max_zoom when set.

Flag precedence

The --config JSON file is loaded first, then --temp-dir and --max-zoom overwrite the matching keys. Every other converter option (e.g. bounds, layer_name, glob, min_zoom, generate_parquet) must come from the --config file — see the converters guide for the full per-format config key tables.

On success the command prints the converter's result dict as indented JSON:

Result: {
  "total_time": 1.84,
  "feature_count": 412,
  "timings": { "ingest": 0.31, "pbf": 1.20, "parquet": 0.33 }
}

Result shape depends on the format

Only the xenium converter returns layer_counts and tile_count. The obj and geojson converters return feature_count instead. All three always return total_time (seconds) and a timings dict.

Subcommand: list-formats

Prints each registered converter format, one per indented line.

uv run python -m mudm_tools.converters.cli list-formats
  geojson
  obj
  xenium

Examples

Tile a directory of OBJ meshes into 3D Tiles plus Parquet, overriding the octree depth:

uv run python -m mudm_tools.converters.cli convert \
    --format obj \
    --input meshes/ \
    --output out/ \
    --max-zoom 5

Convert a 10x Genomics Xenium bundle, using a config file for the Xenium-specific options and a dedicated scratch dir:

uv run python -m mudm_tools.converters.cli convert \
    --format xenium \
    --input xenium_bundle/ \
    --output out/ \
    --config xenium.json \
    --temp-dir /scratch/mudm

Where xenium.json might contain:

{
  "id_column": "cell_id",
  "point_zoom_offset": 3,
  "skip_raster": false
}

GeoJSON: pass bounds explicitly

The GeoJSON converter's automatic bounds computation is currently a stub that falls back to (0, 0, 1, 1). For correct tiling, supply bounds (a 4-tuple xmin, ymin, xmax, ymax) via the --config file. OBJ bounds are a 6-tuple xmin, ymin, zmin, xmax, ymax, zmax. See the converters guide for details.

After converting, serve the result with mudm-serve (above) — point --tiles-base at an OBJ/3D output root, or --tiles2d-base at a parent of GeoJSON/Xenium dataset directories.


See also

The serve entry point and viewer resolver are documented below.

main

main()
Source code in src/mudm_tools/serve.py
def main():
    parser = argparse.ArgumentParser(description="Serve muDM tile viewer")
    parser.add_argument("--port", type=int, default=8080, help="Port (default: 8080)")
    parser.add_argument(
        "--tiles-base", type=str, required=True, help="Directory containing pyramids.json"
    )
    parser.add_argument(
        "--tiles2d-base", type=str, default="", help="Directory containing 2D tile datasets"
    )
    parser.add_argument(
        "--viewer",
        type=str,
        default="3d",
        choices=["3d", "2d"],
        help="Bundled viewer to use (default: 3d)",
    )
    parser.add_argument(
        "--viewer-dir", type=str, default=None, help="Custom viewer directory (overrides --viewer)"
    )
    args = parser.parse_args()

    tiles_base = os.path.abspath(args.tiles_base)

    if args.viewer_dir:
        viewer_dir = os.path.abspath(args.viewer_dir)
    else:
        viewer_dir = str(get_viewer_dir(args.viewer))

    viewer_2d_dir = ""
    if args.viewer == "3d":
        # Also make 2D viewer available at /2d/ if it exists
        try:
            viewer_2d_dir = str(get_viewer_dir("2d"))
        except ValueError:
            pass

    manifest = os.path.join(tiles_base, "pyramids.json")
    if not os.path.isfile(manifest):
        print(f"WARNING: No pyramids.json in {tiles_base}")

    if not os.path.isfile(os.path.join(viewer_dir, "index.html")):
        print(f"ERROR: No index.html in {viewer_dir}")
        return 1

    TileHandler.tiles_base = tiles_base
    TileHandler.tiles2d_base = os.path.abspath(args.tiles2d_base) if args.tiles2d_base else ""
    TileHandler.viewer_dir = viewer_dir
    TileHandler.viewer_2d_dir = viewer_2d_dir

    comp = "Brotli" if _HAS_BROTLI else "gzip (install 'brotli' for better compression)"
    print(f"Compression: {comp}")
    print(f"Viewer:      http://localhost:{args.port} ({args.viewer})")
    if viewer_2d_dir:
        print(f"2D Viewer:   http://localhost:{args.port}/2d/")
    print(f"Tiles:       {tiles_base}")
    if TileHandler.tiles2d_base:
        print(f"2D Tiles:    {TileHandler.tiles2d_base}")
    print("Press Ctrl+C to stop")

    server = HTTPServer(("", args.port), TileHandler)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nStopped.")
    return 0

get_viewer_dir

get_viewer_dir(name: str) -> Path

Get the path to a bundled viewer by name ('3d' or '2d').

Source code in src/mudm_tools/serve.py
def get_viewer_dir(name: str) -> Path:
    """Get the path to a bundled viewer by name ('3d' or '2d')."""
    mapping = {
        "3d": _VIEWERS_DIR / "viewer3d",
        "2d": _VIEWERS_DIR / "viewer2d",
    }
    d = mapping.get(name)
    if d is None or not d.exists():
        available = [k for k, v in mapping.items() if v.exists()]
        raise ValueError(f"Unknown viewer '{name}'. Available: {available}")
    return d

cli

Unified CLI for muDM format conversion.

Usage

mudm convert --format xenium --input data/outs --output tiles/sample mudm convert --format obj --input data/meshes --output tiles/brain mudm convert --format geojson --input data/cells.geojson --output tiles/cells mudm list-formats