csm2map
Purpose
Map-project an ISIS cube into a GeoTIFF using the Community Sensor Model (CSM) instead of ISIS’s CSPICE-based camera. Drop-in replacement for ISIS cam2map, validated against ISIS 9.0.0 output on multiple CTX images at 99.95% coverage match and 100% agreement within 0.01 DN, while running 5–13× faster than ISIS cam2map (5.3× on J08 at 2536×12288, 6.5× on F09 at 5000×7168, 13× on full-length F05 at 5000×52224 — the larger the strip, the larger the speedup because the per-image ALE/camera overhead is amortized over more pixels).
By default csm2map reads the cube’s Kernels.ShapeModel keyword and uses the same DEM that ISIS would (e.g. the MOLA DEM for Mars), so the two tools produce essentially the same valid-pixel mask out of the box. Use --shape-model ellipsoid to disable DEM lookups (faster but less accurate over topography).
--clip-to-footprint is also available but serves a different purpose than its name might suggest: it applies an extra mask from the footprintinit polygon on top of the camera-model mask. It does not reproduce ISIS cam2map behavior — empirical testing (see § The footprintinit polygon precision story below) showed that cam2map ignores the polygon entirely. The flag is kept as an escape hatch for downstream tooling that expects a polygon-masked output for reasons unrelated to ISIS matching.
Installation
csm2map is an optional isistools feature. Base isistools users (viewer, tiepoint review, cnet inspection, footprint map, etc.) do not need any of the CSM stack — install only if you actually intend to map-project.
1. Install the [csm] extra
pip install "isistools[csm]"This pulls the Python-side deps that are pip-installable: scipy, pyproj, rich.
2. Install the CSM C++ stack from conda-forge
The CSM plugin (usgscsm) and its Python bindings (csmapi) are not available on PyPI, so they must come from conda-forge. Along with ale (the ISD generator) and rasterio (GeoTIFF writer):
conda install -c conda-forge usgscsm ale rasterio3. Apple Silicon (arm64) — critical gotcha
At the time of writing, the conda-forge csmapi package is built for x86_64 only. On an Apple-Silicon Mac it installs cleanly but fails at import with:
ImportError: dlopen(.../_csmapi.so): tried: ... (mach-o file, but is an
incompatible architecture (have 'x86_64', need 'arm64e' or 'arm64'))
arm64 Mac users must build csmapi from source into the same conda env that holds ale, usgscsm, and isistools. A known-working build recipe lives in scripts/build_csmapi.sh in the isistools repo — it clones the DOI-USGS/swigcsm upstream, configures with the native architecture, and installs the resulting wheel into the active env.
Once csmapi is arm64-native, ale + usgscsm + csmapi coexist in a single env and isistools csm2map works exactly as it does on Linux.
4. Sanity check
After install, this should run silently:
from isistools.processing.camera import load_camera # noqa: F401If it raises ModuleNotFoundError: csmapi or an architecture-mismatch ImportError, re-check step 3.
5. Reference: the maintainer’s working install
The isistools maintainer keeps two conda envs on macOS arm64:
py312— holdsisistools,ale,usgscsm, and a locally-built arm64csmapi. This is whereisistools csm2mapruns.isis— holds ISIS 9.0.0 (from conda-forge) itself. Only used for the referencecam2map,camrange,catlabtools thatcsm2map-compareshells out to for validation.
This two-env split is not required — you can put everything in one env — but it cleanly separates the “what we’re replacing” from “what’s doing the replacing” and avoids any accidental library-path collision between ISIS’s vendored CSPICE and the one ale picks up from conda-forge.
Usage
# Basic: use an ISIS MAP PVL file for grid parameters
isistools csm2map input.cub output.tif --map equi.map
# Explicit projection + resolution
isistools csm2map input.cub output.tif -r 6.0
# Dense mode (slower, evaluates CSM at every pixel instead of every 16th)
isistools csm2map input.cub output.tif --map equi.map --dense
# Validate coarse-grid accuracy against dense ground truth
isistools csm2map input.cub output.tif --map equi.map --validate
# Apply an extra footprint-polygon mask (does NOT match cam2map —
# see the "footprintinit polygon precision story" section below)
isistools csm2map input.cub output.tif --map equi.map --clip-to-footprint
# Compare our output to an existing ISIS cam2map cube
isistools csm2map-compare isis_output.cub csm_output.tifOptions
| Option | Default | Description |
|---|---|---|
--map |
— | ISIS MAP PVL file defining projection/resolution/range |
--projection |
equirectangular Mars | PROJ string, used if no MAP file |
--resolution, -r |
— | Pixel resolution in meters/pixel |
--minlat/--maxlat/--minlon/--maxlon |
auto | Ground range in degrees |
--step, -s |
16 | Coarse grid step in pixels |
--dense |
off | Evaluate CSM at every output pixel |
--validate |
off | Spot-check coarse transform accuracy |
--clip-to-footprint |
off | Apply an extra footprintinit-polygon mask. Does not match cam2map — cam2map ignores the polygon. Escape hatch for downstream polygon-masked outputs. |
--shape-model |
auto |
auto reads from cube label, ellipsoid disables DEM, or path to a DEM cube |
--interp, -i |
bicubic | Interpolation: nearest, bilinear, bicubic |
How it works
The pipeline has five stages:
- Load camera model.
ale.loads()generates an ISD JSON from the spiceinit’d cube;usgscsm’s plugin constructs acsmapi.RasterGMsensor model. No knoten dependency — the plugin is loaded via ctypes fromlibusgscsm.dyliband plugins are iterated viacsmapi.Plugin.getList(). - Build output grid. From a MAP file or explicit lat/lon bounds. If the MAP file contains
UpperLeftCornerX/Y+Samples/Lines(from an existing ISIS output), the grid matches ISIS pixel-for-pixel. - Compute coordinate map. For each output pixel, inverse-project to ground (via pyproj), convert to ECEF, then call
RasterGM.groundToImage()to get input (sample, line). Done on a coarse 16-pixel grid by default, then bilinearly interpolated with a per-pixel bounds check applied AFTER interpolation (so edge accuracy is per-pixel). - Resample.
scipy.ndimage.map_coordinateswith the chosen interpolation order reads input DN values at the mapped positions. - Write GeoTIFF. rasterio + LZW compression + tiled 256×256 blocks.
ISIS compatibility
With the default --shape-model auto (i.e. read the DEM from the cube label, matching ISIS), csm2map already matches ISIS cam2map to 99.95% coverage and 100% of overlapping pixels within 0.01 DN. You typically don’t need any extra flag. The residual ~14–18 K disagreeing pixels are a structural camera-model floor and are not resolvable by clipping to a polygon — see the next section for why.
--clip-to-footprint does not help close that gap. It applies an extra mask from the footprintinit polygon on top of the camera model, which can only remove valid pixels that were otherwise correctly projected. The flag predates the empirical investigation described in the next section, which showed that ISIS cam2map ignores the polygon entirely. We kept it as an escape hatch for users who want a polygon-masked output for reasons unrelated to matching cam2map — e.g. downstream pipelines that expect exactly the footprintinit polygon shape — but it is not recommended for validation or regression testing against cam2map.
Validation across two MRO CTX cubes (different orbits, regions and aspect ratios), both with the MOLA DEM as shape model, against ISIS 9.0.0 cam2map:
| Metric | J08 (2536×12288) | F09 (5000×7168) |
|---|---|---|
| ISIS cam2map wall time | 64.64 s | 73.74 s |
csm2map wall time |
12.14 s | 11.36 s |
| Speedup | 5.32× | 6.49× |
| ISIS valid pixels | 27,371,919 | 31,483,792 |
csm2map valid pixels |
27,373,477 | 31,474,360 |
| Both valid | 27,357,115 | 31,471,845 |
| Coverage overlap | 99.95% | 99.96% |
| Disagreeing pixels | 18,205 | 14,462 |
| Mean (CSM − ISIS) | -0.000000 | -0.000000 |
| Std (CSM − ISIS) | 0.0011 | 0.0012 |
| Max abs diff | 0.0157 | 0.0116 |
\|diff\| < 0.001 |
68.20% | 66.09% |
\|diff\| < 0.01 |
100.00% | 100.00% |
The 14–18 K disagreeing pixels are the structural floor between CSM UsgsAstroLineScanSensorModel and ISIS CTXCamera — see scripts/disagreement_analysis.md for the deep dive. They’re scattered single-pixel rounding noise at the perimeter of the camera footprint, and the two camera implementations cannot be made to agree on them without using the same camera class.
The footprintinit polygon precision story
This section is mostly historical: it documents an investigation that turned out to be a wild goose chase, plus the actual footprintinit behaviour we did learn from it.
When we first validated our implementation against ISIS 9.0.0 cam2map, we used a constant ellipsoidal radius and saw a systematic ~3% coverage gap: our tool produced ~870,000 more valid pixels than ISIS along the tilted CTX footprint’s edges. Investigating why those extra pixels appeared, we noticed that ~99.7% of them lay outside the footprint polygon stored in the cube by footprintinit — rasterising the polygon brought us into 99.96% match with ISIS.
That looked at first like a smoking gun: maybe cam2map was secretly clipping to the polygon. But reading the ISIS 9.0.0 cam2map.cpp source carefully there is no reference to Polygon, footprint, ImagePolygon, or Blob anywhere in the file. The validity test in cam2mapReverse::Xform() is Camera::SetUniversalGround(lat, lon) followed by a bounds check on the resulting Sample()/Line(). Ground range comes from Camera::GroundRange(). Neither call touches the polygon.
The question is then: how can two independent computations agree to sub-pixel precision?
Resolution: it really is coincidence — or rather, both computations inherit the same answer from the same Camera class. Once we integrated the MOLA DEM as the shape model in our csm2map (matching what ISIS cam2map does by default), the 870 K-pixel “extra” band disappeared without any polygon clipping. Our DEM-aware CSM output now matches ISIS to 99.95% coverage out of the box, and the remaining ~18 K disagreement is attributable to a sub-pixel-level divergence between the two camera implementations themselves (CSM UsgsAstroLineScanSensorModel vs ISIS CTXCamera), not to polygon clipping. See scripts/disagreement_analysis.md for the full post-DEM picture.
So the original 870 K-pixel “polygon coupling” was an artifact of our shape-model mismatch, not of any cam2map–polygon coupling. The mechanism was: when we used a constant ellipsoid, our CSM iteration converged to ground points that lay slightly farther from the local surface than ISIS’s DEM-aware iteration, which made the CSM camera “see” extra ground points just outside the true footprint. ISIS’s camera (and footprintinit’s perimeter sampling, which uses the same camera) both reject those points. Once we fed our CSM the same DEM as ISIS, the agreement went from “looks like polygon clipping” to “directly matches per pixel”. The polygon mask and the post-DEM camera-based mask happen to look very similar because they both reflect the same underlying Camera::SetImage decisions on the same image — not because cam2map ever consults the polygon.
Empirical confirmation (now done — see commit <>): we ran two direct tests on the J08 CTX cube:
| Test | Polygon byte size | cam2map output diff vs reference |
|---|---|---|
| Default polygon (LINC=100) | 11,116 bytes | — (reference) |
| No polygon (stripped from cube) | 0 bytes | 0 — bit-identical |
| Tight polygon (LINC=10, ~10× denser) | 109,946 bytes | 0 — bit-identical |
All three runs produced the same byte-identical 46.4 M-pixel output. cam2map is confirmed to be fully independent of the cube’s polygon blob: it doesn’t read the polygon, doesn’t clip to it, and doesn’t even change behaviour when the polygon is 10× denser. The earlier csm2map-vs-cam2map agreement with the polygon was indeed a coincidence of both tools using the same Camera class with the same shape model.
This also means: in your standard pipeline (spiceinit → footprintinit → cam2map), footprintinit is not actually required before cam2map — spiceinit strips the polygon, footprintinit puts a new one back, but cam2map would happily run on either form and produce the same output. footprintinit is required for the other downstream tools (qmos, findimageoverlaps, autoseed, isistools footprints) that consume the polygon directly.
The --clip-to-footprint flag in csm2map therefore does not help validate against cam2map — cam2map and the polygon are independent, and the flag simply ANDs an extra polygon mask onto the camera-model mask. For all ISIS-matching use cases the default (DEM-aware CSM camera, no polygon clipping) is what you want. The flag is retained only for the narrow escape-hatch use case of downstream tooling that explicitly expects the footprintinit polygon as an output mask shape.
How footprintinit samples the image perimeter
footprintinit walks the four sides of the input image and evaluates the camera model at a grid of points with spacing controlled by LINC (lines) and SINC (samples). It then builds a polygon through those points with straight edges between consecutive samples. The true camera footprint is curved (especially for line-scan sensors on a sphere), so the polygon under-represents the real footprint on convex edges and over-represents it on concave ones.
Default: LINC = SINC = 100.
For a 12288-line × 2536-sample CTX image that gives only ~150 perimeter vertices — approximately one vertex every 600 m on the ground. Between vertices the polygon cuts a straight chord across a curved boundary.
What INCREASEPRECISION actually does
Reading the ISIS source (footprintinit.cpp):
if (precision && sinc > 1 && linc > 1) {
sinc = sinc * 2 / 3;
linc = linc * 2 / 3;
poly.Create(*cube, sinc, linc);
}INCREASEPRECISION=TRUE is a retry loop that only fires when the default polygon is geometrically invalid (self-intersecting, zero-area, etc., which typically happens near the limb). On each retry it multiplies LINC/SINC by 2/3 and tries again: 100 → 66 → 44 → 29 → 19 → 12 → 8 → 5 → 3 → 2 → 1.
For a well-behaved image (like our CTX test case), the default polygon is valid and INCREASEPRECISION has NO EFFECT. The name is misleading — it does not make the polygon denser in general, it just prevents a hard failure when the coarse polygon breaks.
To actually densify the polygon for accuracy, you must manually set small values:
footprintinit from=my.cub linc=10 sinc=10What isistools csm2map does instead
isistools csm2map does not use the cube’s stored polygon at all by default. It evaluates the camera model directly on the output grid:
- Coarse mode (
--step=16, default): CSM model at every 16th output pixel. Interior is bilinearly interpolated, bounds check applied per output pixel after interpolation. At 6 m/pix that’s a 96 × 96 m coarse cell — roughly 10× finer effective sampling than ISIS’s default polygon, and dense across the interior rather than only on the perimeter. - Dense mode (
--dense): CSM at every output pixel. Equivalent toLINC=SINC=1. Much slower (~3 min for a 46 M-pixel output vs. ~20 s coarse), used for validation of the coarse mode.
What if footprintinit was never run?
In practice this is not a concern for an isistools-based pipeline — isistools footprintinit is part of the standard workflow and is always re-run after any spiceinit or jigsaw update=True step, so the cube’s Polygon blob is always present when cam2map runs. The --clip-to-footprint flag is therefore always usable.
But for the sake of understanding what the underlying tools actually do: ISIS cam2map does not require footprintinit. Verified against the ISIS 9.0.0 source (isis/src/base/apps/cam2map/cam2map.cpp): there is no reference to Polygon, footprint, ImagePolygon, or Blob anywhere in the file. The per-pixel validity test is Camera::SetUniversalGround(lat, lon) plus detector-bounds checks on the resulting Sample()/Line(). The auto-computed output lat/lon range comes from Camera::GroundRange(), which probes the camera model — not from any stored polygon.
So cam2map run on a cube that has never seen footprintinit will succeed and produce a complete output, with no hard failure. The isistools --clip-to-footprint flag does require a polygon in the cube (it reads it and rasterizes it as a mask) and will error if the polygon is missing, but that is a limitation of our compat-mode flag, not of ISIS cam2map.
An interesting experimental test would be to delete the Polygon blob from a cube copy and re-run ISIS cam2map on both. If our hypothesis is correct (that cam2map and the polygon match because both go through Camera::SetUniversalGround with the same internal rejection, not because cam2map reads the polygon), the outputs should be pixel-for-pixel identical.
Optional cam2map flags you should know about
Two cam2map parameters affect the valid-pixel mask but default to false, so they do not explain the behavior we observed by default:
OCCLUSION (default: false): when enabled, adds a forward-reverse consistency check after SetUniversalGround returns a (sample, line). It calls SetImage(sample, line) to convert back to (lat, lon) and compares against the original, rejecting the pixel if the round-trip differs by more than 0.00001° (about 0.6 m on Mars, roughly 0.1 pixel at 6 m/pix). The parameter is described as catching occluded regions on non-convex bodies, but the documentation notes it “significantly increases processing time”. It is off by default.
TRIM (default: false): when enabled, rejects output pixels whose lat/lon falls outside the MAP file’s MinimumLatitude/Maximum… bounds. Off by default, which means by default cam2map will happily write pixels slightly outside the MAP file’s nominal range if the camera geometry produces valid mappings for them.
Is this an ISIS bug or a docs bug?
The footprintinit side is clearly a documentation gap:
INCREASEPRECISIONis described as “allow the automatic reduction of the SINC and LINC parameters whenever their current values result in an invalid polygon”. Technically correct but very easy to read as “make the result more precise in general” — it does not do that. The name is misleading and the docs don’t warn about it.- The default
LINC = SINC = 100is not flagged as a coarse approximation that under-represents curved footprint edges. For a CTX-sized image that’s roughly one polygon vertex per 600 m of perimeter — a notable discretization.
The cam2map side is either a documentation gap or an implicit dependency that is not spelled out anywhere. The empirical fact that cam2map’s output mask matches the stored polygon to within rasterization error means one of the following is true:
cam2mapdepends on the polygon through some indirect path we have not traced (plausible but unverified).ProcessRubberSheet’s patch-based approximation for line-scan cameras happens to produce an edge that looks like the LINC=100 polygon even though it’s computed independently (also plausible, also unverified).
In either case, the cam2map reference page does not mention any dependency on footprintinit or the cube’s polygon blob, nor does it mention that its output shape is determined by ProcessRubberSheet’s patch-based approximation rather than an exact per-pixel test. A user who reads the cam2map docs has no way to know that re-running footprintinit with tighter sampling — or that running footprintinit at all — could affect their cam2map output.
Other cam2map-side docs gaps:
- The
OCCLUSIONflag’s description is brief; what “occluded” means in this context (a forward-reverse round-trip check against the original lat/lon with a 0.00001° threshold) is not spelled out in the user-facing docs. Users who want to understand the accuracy trade-off have to read the source. - The
TRIMflag’s default offalsemeans the MAP file’sMinimum*/Maximum*keywords are accepted as a “ground range” for auto-sizing the output but are not enforced as a clip region unless the user explicitly asks. This is easy to misread.
Requirements
isistools csm2map requires the CSM stack which is not pip-installable on all platforms:
conda install -c conda-forge usgscsm ale rasterio rich scipy pyprojPlus the Python bindings (csmapi) which are not available as a pre-built conda package on osx-arm64. Build from source via scripts/build_csmapi.sh:
conda install -c conda-forge cmake swig csm
bash scripts/build_csmapi.shSee scripts/test_csm2map_comparison.sh for a complete end-to-end validation script against a reference ISIS output.