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
| Endpoint | Scope | Purpose |
|---|---|---|
POST /v1/worlds | worlds:create | Create a world from a capture ZIP. |
GET /v1/captures/process-quote | worlds:create | Get the price and ETA for a frame count. |
GET /v1/worlds/requests/{request_id}/status | worlds:read | Check 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:
| Requirement | Detail |
|---|---|
| Protocol | https://, with no credentials in the URL. |
| Access | Readable with a plain GET. Presigned S3, GCS and R2 URLs work. |
| Range requests | Required. The ZIP is read entry by entry. |
| Lifetime | Valid 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
| Path | Required | Contents |
|---|---|---|
images/Image_000001.jpg ... | Yes | One JPEG per frame, numbered from 1 with no gaps. |
cameras/camera_trajectory.json | No, recommended | One camera pose per frame. Without it, Echo 3 estimates the cameras from the images. |
cameras/per_frame_intrinsics.json | Yes | One calibration per frame. |
cameras/camera_intrinsics.json | No | A shared calibration. Per-frame values take precedence. |
cameras/orientation.json | No | { "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.
| Limit | Value |
|---|---|
| Frames | 1 to 5,000 |
| File size | 64 MiB per file, 8 GiB uncompressed in total |
| Compression | Stored or deflate |
| Not allowed | Encryption, symlinks, absolute paths, .. segments |
Images
| Property | Requirement |
|---|---|
| Format | JPEG at the native sensor size: 1920x1440 on iOS, 1920x1080 on Android. Don't resize or rotate. |
| Orientation | EXIF Orientation absent or 1. Keep one device orientation for the whole scan. |
| Timestamps | DateTimeOriginal, SubSecTimeOriginal and OffsetTimeOriginal in the first 64 KiB, strictly increasing. On iOS, embed ARFrame.exifData. |
| Frame rate | About 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]
}
| Field | Rule |
|---|---|
frame_count | Number of frames. Every array has this many values. |
width_px, height_px | The JPEG size, the same for every frame. |
fx_px, fy_px | Between 100 and 10,000. |
cx_px, cy_px | Inside 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
| Setting | Value |
|---|---|
| Camera | Built-in wide-angle at exactly 1920x1440. |
| Focus and exposure | Autofocus on, automatic exposure, native frame rate. |
| Tracking | Save 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 spacing | At 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 blur | Skip frames with predicted blur over 64 px (formula below). A missing or stale signal (older than 0.3 s) doesn't block a frame. |
| Length | Up 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
| HTTP | error.code | When it happens |
|---|---|---|
400 | INVALID_INPUT | The body or zip_url is invalid. |
402 | INSUFFICIENT_CREDITS | The balance is under 900 credits. |
403 | MODEL_FORBIDDEN | Echo 3 is disabled for this account. |
503 | CAPTURE_PROCESSING_PAUSED, CAPTURE_USER_LIMIT, CAPTURE_QUEUE_FULL, CAPTURE_PROCESSING_UNAVAILABLE | Intake 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)"
}
]
}
}
| Category | Codes |
|---|---|
| Download | ZIP_URL_HTTP_ERROR, ZIP_URL_NOT_FOUND, ZIP_URL_FORBIDDEN, ZIP_URL_NOT_ALLOWED, ZIP_URL_TOO_MANY_REDIRECTS, ZIP_RANGE_UNSUPPORTED |
| Archive | ZIP_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 |
| Images | IMAGES_MISSING, TOO_MANY_FRAMES, IMAGE_NAME_INVALID, IMAGE_SEQUENCE_START, IMAGE_SEQUENCE_GAP, IMAGE_COUNT_MISMATCH |
| EXIF | EXIF_MISSING, EXIF_CAPTURE_TIME_INVALID, EXIF_TIMESTAMPS_NOT_INCREASING, EXIF_ORIENTATION_NOT_IDENTITY |
| Cameras | TRAJECTORY_COUNT_MISMATCH, MATRIX_*, ROTATION_*, PER_FRAME_INTRINSICS_*, INTRINSICS_*, ORIENTATION_*, CAMERA_FILE_INVALID_JSON, ORIENTATION_UNRESOLVED |
| Processing | CAPTURE_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.