Implement a backend¶
sfmapi ships no concrete SfM engine. Real reconstructions happen in a backend package you (or someone else) ships separately. This page is the contract.
Backend levels¶
Backends use structural typing: no inheritance, no metaclass, just the
methods they actually support. The minimum contract is
sfmapi.backends.Backend: name, version, vendor,
capabilities(), and runtime_versions().
Use the smallest level that fits:
Level |
Implement |
Best for |
|---|---|---|
Native actions |
|
vendor CLIs, research repos, workflows that do not expose portable stage artifacts |
Artifact/stage backend |
|
tools that can consume/produce sfmapi-compatible databases, models, or snapshots |
Full SfM backend |
|
engines that support the full portable feature/match/map/refine/export surface |
Workers guard optional stage methods with
require_backend_method(...). If an action-only backend receives a
portable stage request it does not implement, sfmapi returns the
normal 501 CapabilityUnavailableError instead of an internal
AttributeError.
Portable stage backend¶
Full portable backends can satisfy sfmapi.backends.SfmBackend by
structural typing — no inheritance required, no metaclass. A backend
is any class with the right method names, signatures, and an
identity triple (name, version, vendor).
from sfmapi.backends import ProgressReporter
class MyBackend:
name = "my_backend"
version = "0.1.0"
vendor = "me"
def capabilities(self) -> set[str]:
# Advertise only what's wired. The /v1/capabilities endpoint
# surfaces this set; clients use it to decide which stages
# they can ask for.
return {"features.extract.sift", "pairs.exhaustive", "matchers.nn-mutual", "ba.standard"}
def extract_features(self, *, database_path, image_root,
image_list, options,
progress: ProgressReporter | None = None) -> dict:
# progress is optional. Older backends can omit it; sfmapi only
# passes the keyword when the method signature accepts it.
if progress is not None:
progress.phase_progress(
"feature_extraction",
current=0,
total=len(image_list),
)
...
def match(self, *, database_path, mode, options,
progress: ProgressReporter | None = None) -> dict:
...
def bundle_adjustment(self, **kw) -> dict:
...
def runtime_versions(self) -> dict[str, str]:
# Returned by /v1/version under backend.runtime_versions.
# Roll any sha / version / arch into here that should
# invalidate the cache when it changes.
return {"engine_sha": "abc123", "cuda_arch": "120"}
Only implement a stage method after the corresponding capability is
real. For example, advertise features.extract.sift only when
extract_features(...) succeeds for valid inputs. Unsupported stage
methods may be omitted entirely. If you choose to implement a method
but reject some modes, raise CapabilityUnavailableError.
Action-only backend¶
Action-only backends are first-class. They keep capabilities() empty
unless they also implement portable stages, and publish backend-native
tools through the action catalog:
class VendorCliBackend:
name = "vendor_cli"
version = "0.1.0"
vendor = "Vendor"
def capabilities(self) -> set[str]:
return set()
def runtime_versions(self) -> dict[str, str]:
return {"vendor_cli": "2026.1"}
def list_backend_actions(self, *, include_schemas: bool = False) -> list[dict]:
schema = {"type": "object", "additionalProperties": False}
return [{
"action_id": "vendor_cli.reconstruct",
"display_name": "Vendor reconstruct",
"category": "reconstruction",
"stability": "backend_extension",
"side_effects": "write",
"required_capabilities": [],
"input_schema": schema if include_schemas else None,
}]
def validate_backend_action(self, action_id: str, inputs: dict) -> dict:
return {"action_id": action_id, "valid": True, "errors": [], "normalized_inputs": inputs}
def run_backend_action(self, action_id: str, inputs: dict, *, workspace=None, progress=None) -> dict:
...
Registering at startup¶
from sfmapi.runtime import register_backend
register_backend("my_backend", MyBackend)
Then either:
set the env var:
SFMAPI_BACKEND=my_backend, orpass
name=toget_backend("my_backend")for explicit selection.
A common pattern is to register from the package’s __init__.py so
that import my_backend is the only thing the operator needs:
# my_backend/__init__.py
from sfmapi.runtime import register_backend
from .backend import MyBackend
register_backend("my_backend", MyBackend)
Reference backend packages¶
The maintained demo backends follow the same API discovery contract:
portable features in GET /v1/capabilities, backend-native commands
in GET /v1/backend/actions, and provider-specific stage options in
GET /v1/backend/config-schemas.
Repo |
Launcher |
Backend family |
|---|---|---|
|
|
Original COLMAP CLI |
|
|
PyCOLMAP with COLMAP CLI fallback |
|
|
Native COLMAP, PyCOLMAP, and C++ demos |
|
|
RealityCapture/RealityScan CLI actions |
|
|
InstantSfM Python actions |
|
|
SphereSfM/COLMAP-derived spherical actions |
These packages use distinct Python import packages so agents and tools can install or inspect more than one backend in the same environment without module-name collisions.
Capability strings¶
The capability vocabulary is canonical and stable. Backends advertise
the subset they implement; sfmapi reads capabilities() once at
boot and caches the result. The full list lives in
app.core.capabilities.ALL_KNOWN.
Do not put backend-native commands or vendor tool names in
capabilities(). Capabilities are portable sfmapi features. If a
backend has its own tools such as openmvg.compute_features,
hloc.match_pairs, or colmap.feature_extractor, expose them as
backend actions instead.
Common categories:
Category |
Capability strings |
|---|---|
Feature extraction |
|
Pair selection |
|
Matching |
|
Mapping |
|
Bundle adjustment |
|
Dense |
|
Projection |
|
Other |
|
If you advertise a capability, the corresponding portable stage method must succeed when called. If a backend does not implement that stage, omit both the capability and the method. sfmapi converts accidental portable-stage calls into a clean 501 with the capability name.
Projection-capable backends should prefer one generic
project_images(operation, input_image_path, output_path, spec) method.
For compatibility, equirectangular_to_cubemap can still be served by
the older render_spherical_cubemap_images(...) hook. The portable
cubemap convention is sfmapi-opencv: face cameras use OpenCV image
axes, and the canonical face order is front, right, back, left,
up, down.
sfmapi core can provide only the generic pixel path for
projection.equirectangular_to_cubemap when the projection extra is
installed. It supports nearest and linear sampling. Higher-order
sampling, reverse cubemap rendering, and equirectangular-to-perspective
views are contract-only in core. Backends that implement those paths
must advertise the corresponding projection.* capability and return a
manifest-compatible result.
Projection result dictionaries may include source_images,
output_images, and derived_dataset. If derived_dataset is present,
sfmapi registers the output directory as a normal Dataset with Image
rows. If it is omitted and the request keeps output.create_dataset=true,
sfmapi derives a conservative dataset manifest from the files written to
the output directory. Requested derived dataset names are collision-safe:
sfmapi appends a task suffix when a name already exists, and task retries
reuse the dataset registered for the same derived output root.
FeaturesSpec, PairsSpec, and MatcherSpec each have an optional
provider field. Use it only to disambiguate implementations that
share a portable capability, such as COLMAP SIFT and hloc learned
features. Keep the capability portable (features.extract.sift,
pairs.retrieval, matchers.superglue); put backend-native tool
names in backend actions instead.
Stage specs also expose backend_options. Use this for
provider-specific knobs that are not part of the portable sfmapi
contract, for example COLMAP’s SiftExtraction.peak_threshold or an
hloc extractor’s model checkpoint. Do not add those knobs as top-level
sfmapi fields unless they are meaningful across backends.
For pairs.strategy="explicit", the worker materializes either inline
image_pairs or an uploaded pair-list blob into
options["pairs"]["pairs_path"] / match_list_path before calling
backend.match(...). The pair-list text format is one image1 image2
row per line.
Stage output artifacts¶
Backend methods should return a dict. Data products must be declared
explicitly in an artifacts list so clients can choose among multiple
outputs without guessing from backend-local paths:
return {
"database_path": str(database_path),
"artifacts": [
{
"kind": "matches.indexed.v1",
"name": "hloc-lightglue",
"uri": str(matches_path),
"artifact_format": "sfmapi.matches.indexed.v1",
"schema_version": 1,
"summary": {"num_pairs": 12000, "num_matches": 2400000},
"metadata": {"provider": "hloc", "feature_set": "superpoint"},
},
{
"kind": "matches.database.colmap",
"name": "colmap-sift",
"uri": str(colmap_matches_path),
"artifact_format": "colmap.matches.database.v1",
"schema_version": 1,
"summary": {"num_pairs": 9000, "num_matches": 1800000},
"metadata": {"provider": "colmap", "feature_set": "sift"},
},
],
}
Artifact kind values are dot-notated identifiers. Reserved core kinds
include features.local.v1, features.global.v1,
pairs.image_names.v1, matches.indexed.v1,
matches.coordinates.v1, matches.dense.v1,
matches.verified.v1, reconstruction.sparse.v1,
reconstruction.snapshot, and reconstruction.submodel. Use
same-family namespaced extension kinds for backend-native formats, for
example features.hloc_h5 or matches.database.colmap.
artifact_format is the concrete storage or interchange contract.
Portable outputs should use versioned sfmapi.*.v1 format ids.
Backend-native outputs should use backend-owned ids such as
hloc.features.h5.v1 or colmap.matches.database.v1.
Invalid artifact descriptors fail the task with a clear validation
error. Clients discover outputs with GET /v1/jobs/{job_id}/artifacts,
GET /v1/reconstructions/{recon_id}/artifacts, or
GET /v1/artifacts/{artifact_id}. List endpoints support exact
kind, task_id, and name filters. GET /v1/artifacts/kinds
returns the reserved semantic vocabulary; GET /v1/artifacts/formats
returns the reserved core interchange formats.
Stage input artifacts¶
Clients can pass artifacts back into later stages through
input_artifacts, a role-keyed map of artifact references. Core roles
are features, pairs, matches, verified_matches, snapshot, and
submodel; backend-specific roles may use the same dot-key syntax.
{
"input_artifacts": {
"features": {
"artifact_id": "01HZ...",
"kind": "features.local.v1"
}
}
}
sfmapi validates tenant scope, optional dataset scope, expected kind,
and core role compatibility before a job is created. All resolved
artifact descriptors are passed to the backend in
options["input_artifacts"] so mixed backends can consume richer
formats without adding portable fields.
Artifact rows are created for new task completions. Pre-release databases are not backfilled automatically; rerun the stage if an older job needs typed artifact rows.
Backend artifact contracts¶
Backends can publish artifact input/output contracts through
list_backend_artifact_contracts(). This is the discovery surface for
interchange: it tells a client which portable or backend-native kinds
and format ids a stage accepts and emits.
class MyBackend:
def list_backend_artifact_contracts(self) -> list[dict]:
return [
{
"contract_id": "my_backend.matcher.lightglue",
"stage": "matcher",
"capability": "matchers.lightglue",
"provider": "my_backend",
"display_name": "LightGlue match artifacts",
"accepts": ["features.local.v1", "pairs.image_names.v1"],
"emits": ["matches.indexed.v1"],
"accepts_formats": [
"sfmapi.features.local.v1",
"sfmapi.pairs.image_names.v1",
],
"emits_formats": ["sfmapi.matches.indexed.v1", "hloc.matches.h5.v1"],
"preferred": "matches.indexed.v1",
"preferred_format": "sfmapi.matches.indexed.v1",
"conversions": [
{
"from_format": "hloc.matches.h5.v1",
"to_format": "sfmapi.matches.indexed.v1",
"lossless": False,
"description": "Descriptor scores are not preserved.",
}
],
}
]
If a backend omits this method, sfmapi derives a conservative contract
from advertised portable capabilities. Explicit contracts are preferred
for backends that can consume native formats or expose conversions.
When accepts_formats, emits_formats, or preferred_format are
omitted for core kinds, sfmapi fills them from the core format
vocabulary.
When a contract advertises conversions, the backend must also
implement convert_artifact(...). sfmapi uses this method for
POST /v1/artifacts/{artifact_id}:convert:
def convert_artifact(
self,
*,
input_artifact: dict,
output_dir: Path,
to_format: str,
to_kind: str | None = None,
options: dict | None = None,
) -> dict:
target = output_dir / "matches.json"
# Write the requested format, then return normal artifact descriptors.
return {
"artifacts": [{
"kind": to_kind or "matches.indexed.v1",
"name": "lightglue-indexed",
"uri": str(target),
"media_type": "application/json",
"artifact_format": to_format,
"schema_version": 1,
}]
}
Clients can call POST /v1/artifacts/{id}:conversionPlan with
accepted_formats to let sfmapi choose the shortest conversion path.
If the path has multiple steps, sfmapi calls convert_artifact() once
per step inside the conversion task and passes each returned artifact
descriptor into the next call. The API does not silently reinterpret
bytes: missing conversion contracts, lossy conversions when
require_lossless=true, and missing convert_artifact()
implementations fail before work is queued.
Clients can also register externally produced artifacts with
POST /v1/artifacts:import. Imports create a completed
artifact_import job/task and then persist a normal StageArtifact.
Backends should therefore treat imported artifacts exactly like
worker-produced artifacts: validate kind and artifact_format, then
consume the advertised uri or files entries.
Backend config schemas¶
Backends can publish JSON Schemas for their backend_options through
an optional list_backend_config_schemas() method. Clients discover
them with GET /v1/backend/config-schemas; when a matching schema is
available, sfmapi uses it to reject unknown keys and simple type or
enum mistakes before creating a job. If no matching schema exists,
options pass through to the backend.
class MyBackend:
def list_backend_config_schemas(self) -> list[dict]:
return [{
"config_id": "my_backend.features.superpoint",
"stage": "features",
"capability": "features.extract.superpoint",
"provider": "my_backend",
"display_name": "SuperPoint options",
"option_schema": {
"type": "object",
"additionalProperties": False,
"properties": {
"model_name": {"type": "string"},
"max_keypoints": {"type": "integer"},
},
},
}]
Descriptor rules:
config_idis a stable, dot-namespaced id such ascolmap.features.sift.stageis the portable stage (features,pairs,matcher,verify,mapping, orbundle_adjustment).capabilityshould be the portable capability the schema applies to, such asfeatures.extract.siftormap.incremental.providershould match the request spec’s optional provider selector when the schema is provider-specific.option_schemadescribes only user-supplied backend options. Do not require runtime-managed paths such as databases, image roots, or output directories; sfmapi supplies those to worker methods.
Set additionalProperties: false on every option_schema. The
contract checker rejects schemas without it so sfmapi can catch
misspelled backend_options before a job is queued.
Backend actions¶
Backend actions are for engine-native tools that are useful but not
part of the portable sfmapi standard. They are discovered through
GET /v1/backend/actions, validated through
POST /v1/backend/actions/{action_id}:validate, and executed as
normal sfmapi jobs through POST /v1/backend/actions/{action_id}:run.
The run endpoint returns the same 202 JobAcceptedResponse envelope
as every other job-submitting endpoint.
Implement these optional methods when your backend exposes native tools. These methods are enough for an action-only backend:
class MyBackend:
def list_backend_actions(self) -> list[dict]:
return [{
"action_id": "openmvg.compute_features",
"display_name": "OpenMVG compute_features",
"category": "features",
"stability": "backend_extension",
"side_effects": "write",
"input_schema": {"type": "object", "properties": {}},
"required_capabilities": ["features.extract.sift"],
}]
def get_backend_action(self, action_id: str) -> dict:
...
def validate_backend_action(self, action_id: str, inputs: dict) -> dict:
return {"valid": True, "errors": [], "normalized_inputs": inputs}
def run_backend_action(self, action_id: str, inputs: dict, *, workspace=None, progress=None) -> dict:
...
Descriptor rules:
action_idis a stable, dot-namespaced id such ascolmap.feature_extractor; do not include/.input_schemaandoutput_schemaare JSON Schema objects. List responses omit them unless the client passesinclude_schemas=true;GET /v1/backend/actions/{action_id}always includes them when available.required_capabilitiesmay contain only portable names fromapp.core.capabilities.ALL_KNOWN. Backend-native prerequisites belong inmetadata.side_effectsis one ofnone,read,write, orunknown.stabilityis one ofstable,experimental,backend_extension, ordeprecated.run_backend_actionmust return a JSON-serializable dict. Accept optionalworkspaceandprogresskeywords when the action needs a scratch directory or wants to emit progress events.
Use sfmapi’s combined contract checker in your backend package so this split is enforced in CI:
from sfmapi.backends import assert_backend_contract
from my_backend import MyBackend
def test_backend_contract():
assert_backend_contract(MyBackend())
The checker rejects unknown portable capabilities, duplicate
action/config ids, malformed descriptors, non-portable
required_capabilities, action/config ids leaked through
capabilities(), config schemas for unadvertised capabilities,
runtime-managed options exposed in backend_options, and schemas that
cannot catch misspelled keys.
You can also run it without writing a test:
sfmapi check-backend --import my_backend --backend my_backend
Progress reporting¶
Backends can report best-effort progress by accepting an optional
keyword-only progress argument on long-running methods. The reporter
writes the same ProgressEvent stream served by
GET /v1/jobs/{id}/events and summarized by
GET /v1/jobs/{id}/progress. sfmapi detects support from the method
signature and only passes progress= when it is accepted, so adding it
is backwards compatible.
Use current / total counts instead of pre-rounded percentages
when possible:
def match(self, *, database_path, mode, options, progress=None) -> dict:
for idx, pair in enumerate(selected_pairs, start=1):
run_pair(pair)
if progress is not None:
progress.phase_progress("matching", current=idx, total=len(selected_pairs))
return {"num_pairs": len(selected_pairs)}
Reporter methods:
Method |
Use for |
|---|---|
|
Numeric progress inside a known phase |
|
Scalar telemetry such as pairs/sec |
|
Non-fatal backend warnings |
|
Human-readable backend log lines |
|
Snapshot publication, normally emitted by sfmapi mapping tasks |
For core worker tasks, sfmapi emits the phase start/completion
envelope; backends usually emit intermediate phase_progress,
metric, warning, or log_line events. Progress is telemetry, not
control flow. A backend should never fail a job because progress
delivery failed.
What sfmapi guarantees you¶
No subprocess management for free — workers run in a process the supervisor manages. Lease / heartbeat / cancellation happens outside your method.
Sealed-snapshot writes — write your reconstruction artifacts under a tempdir; sfmapi atomically renames into
snapshots/{seq}/and signals the API.Cache-key salt —
runtime_versions()rolls into the cache key for every task. Bump any returned key on engine upgrades.Tenancy isolation — file paths and DB rows already filtered by
tenant_id. Your methods only see the per-tenant workspace.
What sfmapi does NOT do¶
Install your engine. Bring your own pycolmap / OpenSfM / hloc / custom binary. Backends typically declare them as Python deps and let
pip installresolve.Configure CUDA. Backends that need a GPU must check at startup and either work in CPU-fallback mode or raise from
__init__.Validate your dicts. The
dictreturn shape is loose; clients validate against the OpenAPI scene artifact schemas if they care.
Reference: the no-op stub¶
app.adapters.stub_backend.StubBackend is the internal no-op reference
that ships in this repo. It exists for tests, ephemeral mode, and
SDK live-server suites. Every method raises
CapabilityUnavailableError; capabilities() returns the empty
set; runtime_versions() returns a single stub_version key.
Use it as a template:
cp -r vendor/your-backend-template my-backend/
# then implement the methods incrementally, advertising each
# capability only after wiring it.
Testing a backend¶
Reuse sfmapi’s contract tests against your registered backend:
uv run pytest -m "contract" --backend=my_backend
…where the --backend arg is whatever your test harness wires
through sfmapi.runtime.register_backend() in a conftest. The contract tests
assert the protocol shape, not engine semantics — they catch
“forgot to add the new method when sfmapi added one to the
Protocol” regressions.