Skip to main content

Mobile capture

Echo 3 turns a phone scan of a real space into a Gaussian splat world at real-world scale. You send one ZIP of frames, and the world is ready in 30 minutes to 2 hours. Camera poses are optional, but include them when you have them.

Any capture app works as long as it produces the format below. If you'd rather not start from scratch, you can reuse our open-source capture prototype, which already exports valid ZIPs.

Endpoints​

EndpointScopePurpose
POST /v1/worldsworlds:createCreate a world from a capture ZIP.
GET /v1/captures/process-quoteworlds:createGet the price and ETA for a frame count.
GET /v1/worlds/requests/{request_id}/statusworlds:readCheck progress.

Create a world​

Host the ZIP at a signed HTTPS URL and pass it as capture_bundle input:

curl -X POST https://api.spaitial.ai/v1/worlds \
-H "Authorization: Bearer $SPAITIAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "echo-3",
"input": {
"type": "capture_bundle",
"zip": { "type": "url", "zip_url": "https://your-bucket.s3.amazonaws.com/capture.zip?X-Amz-..." }
},
"webhook": { "url": "https://your-host/spaitial" }
}'

The request returns 202 Accepted:

{
"request_id": "req_abc123",
"status": "PENDING",
"status_url": "https://api.spaitial.ai/v1/worlds/requests/req_abc123/status",
"response_url": "https://api.spaitial.ai/v1/worlds/requests/req_abc123",
"cancel_url": "https://api.spaitial.ai/v1/worlds/requests/req_abc123/cancel"
}

Poll status_url, or wait for the world.completed or world.failed webhook.

The ZIP URL must meet these requirements:

RequirementDetail
Protocolhttps://, with no credentials in the URL.
AccessReadable with a plain GET. Presigned S3, GCS and R2 URLs work.
Range requestsRequired. The ZIP is read entry by entry.
LifetimeValid and unchanged for 2 hours, or until the job finishes.

Get a price quote​

Each capture costs 900 credits plus 1 credit per frame. Check the price and your balance before you submit:

curl -s "https://api.spaitial.ai/v1/captures/process-quote?model=echo-3&frame_count=1000" \
-H "Authorization: Bearer $SPAITIAL_API_KEY"
{
"model": "echo-3",
"required_credits": 1900,
"available_credits": 7500,
"has_enough": true,
"estimated_duration_seconds": 3210
}

Failed jobs and ZIPs that fail validation aren't charged. See Credits and Billing.

ZIP format​

capture.zip
├── images/
│ ├── Image_000001.jpg
│ └── ...
└── cameras/
├── camera_trajectory.json
└── per_frame_intrinsics.json
PathRequiredContents
images/Image_000001.jpg ...YesOne JPEG per frame, numbered from 1 with no gaps.
cameras/camera_trajectory.jsonNo, recommendedOne camera pose per frame. Without it, Echo 3 estimates the cameras from the images.
cameras/per_frame_intrinsics.jsonYesOne calibration per frame.
cameras/camera_intrinsics.jsonNoA shared calibration. Per-frame values take precedence.
cameras/orientation.jsonNo{ "orientation": ... }. Inferred from the poses when absent. Without poses, the images are used as stored.

Frame i is Image_{i+1}, intrinsics entry i and, when present, trajectory entry i. The two folders can sit inside one top-level folder, and other files are ignored.

LimitValue
Frames1 to 5,000
File size64 MiB per file, 8 GiB uncompressed in total
CompressionStored or deflate
Not allowedEncryption, symlinks, absolute paths, .. segments

Images​

PropertyRequirement
FormatJPEG at the native sensor size: 1920x1440 on iOS, 1920x1080 on Android. Don't resize or rotate.
OrientationEXIF Orientation absent or 1. Keep one device orientation for the whole scan.
TimestampsDateTimeOriginal, SubSecTimeOriginal and OffsetTimeOriginal in the first 64 KiB, strictly increasing. On iOS, embed ARFrame.exifData.
Frame rateAbout 3 to 5 frames per second of scanning.

Intrinsics​

cameras/per_frame_intrinsics.json holds one pinhole calibration per frame, in pixels:

{
"frame_count": 2,
"width_px": 1920,
"height_px": 1440,
"fx_px": [1403.0, 1408.2],
"fy_px": [1403.0, 1408.2],
"cx_px": [960.1, 960.2],
"cy_px": [719.9, 720.0]
}
FieldRule
frame_countNumber of frames. Every array has this many values.
width_px, height_pxThe JPEG size, the same for every frame.
fx_px, fy_pxBetween 100 and 10,000.
cx_px, cy_pxInside the image: 0 < cx_px < width_px, 0 < cy_px < height_px.

On iOS, read columns 0.x, 1.y, 2.x and 2.y of ARFrame.camera.intrinsics. If imageResolution differs from the JPEG size, scale the X and Y values separately.

Camera poses​

Poses are optional but recommended: they make reconstruction more reliable. cameras/camera_trajectory.json is an array of row-major 4x4 camera_from_world matrices, one per frame, in OpenCV convention: +X right, +Y down, +Z forward, in meters. All poses share one world frame.

// ARKit's transform is world_from_camera with -Z forward and +Y up.
let M = simd_float4x4(diagonal: [1, -1, -1, 1]) * frame.camera.transform.inverse
let rowMajor = (0..<4).map { row in (0..<4).map { col in M[col][row] } }

Each rotation must be orthonormal within 1e-4 with determinant +1, and each last row must be [0, 0, 0, 1].

Capture settings​

Echo 3 expects scans recorded with these settings. The capture prototype already applies them.

guard let format = ARWorldTrackingConfiguration.supportedVideoFormats
.first(where: { $0.imageResolution == CGSize(width: 1920, height: 1440) })
else { return } // Don't scan without an exact 1920x1440 format.

let configuration = ARWorldTrackingConfiguration()
configuration.worldAlignment = .gravity
configuration.isAutoFocusEnabled = true
configuration.videoFormat = format
SettingValue
CameraBuilt-in wide-angle at exactly 1920x1440.
Focus and exposureAutofocus on, automatic exposure, native frame rate.
TrackingSave frames once tracking has been normal for 0.5 s, with no frame gap over 0.5 s and motion under 5 m/s and 720°/s. Start the wait again after an interruption, tracking loss or pose jump.
Frame spacingAt least 0.12 s apart and 7 cm or 10° of movement. After 0.35 s, 1 cm or 1.5° is enough. Don't save on a timer or while standing still.
Motion blurSkip frames with predicted blur over 64 px (formula below). A missing or stale signal (older than 0.3 s) doesn't block a frame.
LengthUp to 5,000 frames, JPEG quality 0.85.
blur_px = fx_px * angular_speed * exposure + fx_px * |lateral_velocity| / 2.0 m * exposure
exposure = ARFrame.camera.exposureDuration

Results​

Set output_format to spz (default), sog or ply when you create the world, then download it with GET /v1/worlds/requests/{request_id}/splat. Capture worlds also get a floorplan export, a top-down render of the splat. See Create export for other formats.

Errors​

Request errors​

HTTPerror.codeWhen it happens
400INVALID_INPUTThe body or zip_url is invalid.
402INSUFFICIENT_CREDITSThe balance is under 900 credits.
403MODEL_FORBIDDENEcho 3 is disabled for this account.
503CAPTURE_PROCESSING_PAUSED, CAPTURE_USER_LIMIT, CAPTURE_QUEUE_FULL, CAPTURE_PROCESSING_UNAVAILABLEIntake is paused, too many of your captures are running, or the queue is full. Retry later.

Validation errors​

When the ZIP fails validation, the job ends as FAILED and validation.issues lists every problem. It appears in the status response and in the world.failed webhook:

{
"status": "FAILED",
"validation": {
"passed": false,
"issues": [
{
"code": "TRAJECTORY_COUNT_MISMATCH",
"severity": "error",
"message": "Trajectory has 604 matrices but expected 605 (frame_count)"
}
]
}
}
CategoryCodes
DownloadZIP_URL_HTTP_ERROR, ZIP_URL_NOT_FOUND, ZIP_URL_FORBIDDEN, ZIP_URL_NOT_ALLOWED, ZIP_URL_TOO_MANY_REDIRECTS, ZIP_RANGE_UNSUPPORTED
ArchiveZIP_INVALID, ZIP_ENCRYPTED, ZIP_UNSUPPORTED_COMPRESSION, ZIP_ENTRY_CORRUPT, ZIP_TOO_LARGE, ZIP_TOO_MANY_ENTRIES, ZIP_ENTRY_TOO_LARGE, ZIP_COMPRESSION_RATIO, ZIP_UNSAFE_PATH, ZIP_SYMLINK, ZIP_DUPLICATE_ENTRY
ImagesIMAGES_MISSING, TOO_MANY_FRAMES, IMAGE_NAME_INVALID, IMAGE_SEQUENCE_START, IMAGE_SEQUENCE_GAP, IMAGE_COUNT_MISMATCH
EXIFEXIF_MISSING, EXIF_CAPTURE_TIME_INVALID, EXIF_TIMESTAMPS_NOT_INCREASING, EXIF_ORIENTATION_NOT_IDENTITY
CamerasTRAJECTORY_COUNT_MISMATCH, MATRIX_*, ROTATION_*, PER_FRAME_INTRINSICS_*, INTRINSICS_*, ORIENTATION_*, CAMERA_FILE_INVALID_JSON, ORIENTATION_UNRESOLVED
ProcessingCAPTURE_INGEST_TIMEOUT, INSUFFICIENT_CREDITS, MODEL_FORBIDDEN, INGEST_FAILED, INGEST_DISPATCH_FAILED, INGEST_RESULT_UNREADABLE, CAPTURE_LAUNCH_FAILED

CAPTURE_INGEST_TIMEOUT means the ZIP couldn't be downloaded within 2.5 hours. INGEST_FAILED, INGEST_DISPATCH_FAILED, INGEST_RESULT_UNREADABLE and CAPTURE_LAUNCH_FAILED are temporary: retry with a new request.

See Errors for the error format and Rate Limits for request limits.