Subir datos a un Data Bundle
Esta guía explica cómo subir los archivos de entrada sin procesar de una captura a un data bundle a través de la API de RealityConnect.
Todos los endpoints de subida requieren el ámbito write:bundle; listar sesiones requiere read:bundle.
Flujo de subida
Sección titulada «Flujo de subida»Un data bundle contiene las entradas sin procesar de una sola captura: una nube de puntos, una trayectoria, imágenes, metadatos por imagen, etc. El conjunto exacto de archivos que acepta un bundle depende del dispositivo para el que se creó el bundle.
Los archivos se suben en sesiones de subida. Una sesión es un contenedor creado por el servidor para un lote de archivos de un bundle. Al abrirla, declaras cuántos archivos contendrá el lote (expectedFileCount); la sesión se completa automáticamente una vez que ese número de archivos se ha finalizado.
Cada archivo individual se sube con una subida multiparte de S3:
- Abre una sesión en el bundle, declarando el número total de archivos que enviarás.
- Para cada archivo: inicia la subida, envía sus partes mediante PUT directamente a las URLs devueltas y, a continuación, finalízalo.
- Cuando el número de archivos finalizados alcanza
expectedFileCount, la sesión se marca comocompleted.
sequenceDiagram
participant Client as Client
participant RCAPI as RCAPI
participant S3 as S3
Client->>RCAPI: POST upload-sessions
RCAPI-->>Client: sessionId + requirements
loop for each file
Client->>RCAPI: POST initiate file
RCAPI-->>Client: fileId + presigned part URLs
loop for each part
Client->>S3: PUT part
S3-->>Client: ETag
end
Client->>RCAPI: POST finalize
RCAPI-->>Client: file completed + sessionStatus
end
Los requirements de subida (devueltos al abrir una sesión) te indican exactamente qué tipos de archivo acepta el bundle, cuáles son obligatorios y cómo dependen entre sí.
El ejemplo práctico: una captura ZF SLAM
Sección titulada «El ejemplo práctico: una captura ZF SLAM»Un bundle ZF SLAM es un buen ejemplo porque acepta cuatro tipos de archivo diferentes con una dependencia entre tipos. Una captura ZF completa consta de:
| Archivo | Tipo | Notas |
|---|---|---|
scan.las | nube de puntos | exactamente una |
…laser_0.txt | trayectoria | exactamente una |
panorama/*.jpg | imágenes panorámicas | muchas, cada una emparejada con metadatos |
panorama/*.json | metadatos por imagen | muchos, uno por imagen |
Así que un lote ZF completo es 1 nube de puntos + 1 trayectoria + N imágenes + N archivos de metadatos. Los subiremos todos en una sola sesión.
Endpoints
Sección titulada «Endpoints»Todas las rutas son relativas a tu api_url regional. El bundle se direcciona por su propio id (bundleId).
| Método | Ruta | Ámbito | Propósito |
|---|---|---|---|
POST | /v1/bundles/{bundleId}/upload-sessions | write:bundle | Abrir una sesión |
GET | /v1/bundles/{bundleId}/upload-sessions | read:bundle | Listar sesiones (reanudar / progreso) |
POST | /v1/bundles/{bundleId}/upload-sessions/{sessionId}/files | write:bundle | Iniciar la subida de un archivo |
GET | /v1/bundles/{bundleId}/upload-sessions/{sessionId}/files/{fileId}/refresh/{startIndex} | write:bundle | Reemitir URLs de partes caducadas |
POST | /v1/bundles/{bundleId}/upload-sessions/{sessionId}/files/{fileId}/finalize | write:bundle | Finalizar la subida de un archivo |
Antes de empezar
Sección titulada «Antes de empezar»Para subir datos a un data bundle, necesitas:
- un flujo OAuth completado con un
access_tokeny tuapi_urlregional - un data bundle existente (
bundleId)
Si aún no tienes un bundle, crea uno primero siguiendo Crear un Data Bundle. Ese flujo obtiene el árbol de entradas del dispositivo, elige un dbuPath de hoja y crea el bundle bajo un nodo padre.
Paso 1: abrir una sesión de subida
Sección titulada «Paso 1: abrir una sesión de subida»Declara el número total de archivos que contendrá el lote. Para una captura ZF completa con 13 imágenes, es 1 + 1 + 13 + 13 = 28.
POST {api_url}/v1/bundles/{bundleId}/upload-sessionsAuthorization: Bearer {access_token}Content-Type: application/json
{ "expectedFileCount": 28 }La respuesta devuelve el sessionId y los requirements del bundle:
{ "sessionId": "6ebf0fe5-4428-4b26-a7e4-e3274980123b", "expectedFileCount": 28, "requirements": [ { "type": "pointclouds", "allowedExtensions": ["las", "laz"], "optional": false, "allowMultiples": false, "dependencies": [] }, { "type": "trajectories", "allowedExtensions": ["txt"], "optional": false, "allowMultiples": false, "dependencies": [] }, { "type": "pictures", "allowedExtensions": ["jpg", "png", "jpeg"], "optional": true, "allowMultiples": true, "dependencies": ["picturesMetadata"] }, { "type": "picturesMetadata", "allowedExtensions": ["json"], "optional": true, "allowMultiples": true, "dependencies": ["pictures"] } ]}Entender los requirements y las dependencias entre tipos
Sección titulada «Entender los requirements y las dependencias entre tipos»Cada entrada de requirements describe un tipo de archivo que acepta el bundle:
| Campo | Significado |
|---|---|
type | El identificador que pasas como type al iniciar la subida de un archivo. |
allowedExtensions | Extensiones de archivo aceptadas para este tipo. |
optional | Si es false, el bundle no puede procesarse sin al menos un archivo de este tipo. |
allowMultiples | Si es false, solo puede subirse un archivo de este tipo. |
dependencies | Otros tipos que también deben estar presentes siempre que este tipo esté presente. |
Para el ejemplo ZF:
pointcloudsytrajectoriesson obligatorios (optional: false) y únicos (allowMultiples: false). Todo bundle ZF necesita exactamente uno de cada.picturesypicturesMetadatason opcionales y aceptan varios archivos.- Los dos tipos de imagen dependen entre sí:
pictures.dependencies = ["picturesMetadata"]ypicturesMetadata.dependencies = ["pictures"].
Una dependencia significa: si un tipo está presente, todos los tipos que enumera también deben estarlo. Como los tipos de imagen de ZF se referencian mutuamente, son todo o nada como par. Puedes subir las imágenes y sus metadatos juntos, u omitir ambos, pero no puedes subir uno sin el otro.
Lotes ZF válidos:
- Mínimo —
1 pointcloud + 1 trajectory(sin imágenes).expectedFileCount = 2. - Completo —
1 pointcloud + 1 trajectory + N pictures + N picturesMetadata.expectedFileCount = 2 + 2N.
Inválido:
- Imágenes sin sus metadatos (o viceversa), ya que no se satisface la dependencia entre tipos.
- Un lote sin nube de puntos o sin trayectoria, ya que falta un tipo obligatorio.
Antes de abrir una sesión, cuenta cada archivo que planeas subir y establece ese total como expectedFileCount. Los tipos opcionales solo cuentan si los incluyes en el lote. La sesión se completa cuando ese número de archivos se finaliza, por lo que el número que declaras debe coincidir con lo que realmente subes.
Paso 2: subir cada archivo
Sección titulada «Paso 2: subir cada archivo»Repite las tres llamadas siguientes para cada archivo del lote.
2a. Iniciar
Sección titulada «2a. Iniciar»Pasa el nombre del archivo, su tamaño en bytes y su type (uno de los tipos de requirements):
POST {api_url}/v1/bundles/{bundleId}/upload-sessions/{sessionId}/filesAuthorization: Bearer {access_token}Content-Type: application/json
{ "fileName": "scan.las", "fileSize": 734003200, "type": "pointclouds" }La respuesta describe la subida multiparte: cuántas parts enviar y una url prefirmada por parte.
{ "fileId": "c8c8cb0b-f476-4211-a3a4-72e4e04b91d5", "parts": 8, "urls": [ { "partNumber": 1, "url": "https://s3…/part-1?…" }, { "partNumber": 2, "url": "https://s3…/part-2?…" } ]}2b. Subir las partes
Sección titulada «2b. Subir las partes»Divide el archivo en parts fragmentos secuenciales de ceil(fileSize / parts) bytes y envía cada fragmento mediante PUT a su url prefirmada. En estas solicitudes no se envía ninguna cabecera de autenticación; la URL ya está firmada. Conserva la cabecera de respuesta ETag de cada parte, la necesitas para finalizar.
import math, requests
def upload_parts(file_path, file_size, urls): part_size = math.ceil(file_size / len(urls)) parts = [] with open(file_path, "rb") as handle: for entry in sorted(urls, key=lambda u: u["partNumber"]): res = requests.put(entry["url"], data=handle.read(part_size)) res.raise_for_status() parts.append({"partNumber": entry["partNumber"], "etag": res.headers["ETag"]}) return partsDevuelve el ETag exactamente como se recibió, incluidas las comillas que lo rodean.
2c. Finalizar
Sección titulada «2c. Finalizar»Envía las partes recopiladas para completar el archivo. El endpoint devuelve 200 OK.
POST {api_url}/v1/bundles/{bundleId}/upload-sessions/{sessionId}/files/{fileId}/finalizeAuthorization: Bearer {access_token}Content-Type: application/json
{ "parts": [ { "partNumber": 1, "etag": "\"a1b2…\"" }, { "partNumber": 2, "etag": "\"c3d4…\"" } ] }La respuesta refleja el archivo e informa del estado de la sesión:
{ "fileId": "c8c8cb0b-f476-4211-a3a4-72e4e04b91d5", "fileName": "scan.las", "type": "pointclouds", "size": 734003200, "status": "completed", "sessionStatus": "pending"}sessionStatus permanece en pending hasta que se finaliza el último archivo esperado, momento en el que pasa a completed.
Actualizar URLs caducadas
Sección titulada «Actualizar URLs caducadas»Las URLs prefirmadas caducan. Si una subida se prolonga y un PUT empieza a devolver 403, reemite las URLs a partir de la parte en la que te detuviste y continúa:
GET {api_url}/v1/bundles/{bundleId}/upload-sessions/{sessionId}/files/{fileId}/refresh/0Authorization: Bearer {access_token}{ "startIndex": 0, "urls": [ { "partNumber": 1, "url": "https://s3…/part-1?…" } ] }No es necesario volver a iniciar el archivo; solo se renuevan las URLs de las partes.
Paso 3: seguir el progreso y reanudar
Sección titulada «Paso 3: seguir el progreso y reanudar»Lista las sesiones de un bundle para comprobar el progreso o reanudar un lote interrumpido sin mantener el sessionId en memoria. La lista está paginada y es ordenable, con un filtro status opcional.
Parámetros de consulta: page (predeterminado 1), limit (predeterminado 20, máximo 20), sortBy (createdAt o status, predeterminado createdAt), sortDir (ASC o DESC, predeterminado ASC), status (filtro opcional: pending o completed).
GET {api_url}/v1/bundles/{bundleId}/upload-sessions?page=1&limit=20&sortBy=createdAt&sortDir=DESCAuthorization: Bearer {access_token}{ "items": [ { "sessionId": "6ebf0fe5-4428-4b26-a7e4-e3274980123b", "status": "pending", "expectedFileCount": 28, "finalizedFileCount": 14 } ], "total": 1}finalizedFileCount frente a expectedFileCount te indica cuántos archivos quedan. Para reanudar, inicia y finaliza solo los archivos que aún no se han completado.
Validación y límites
Sección titulada «Validación y límites»Lee esto con atención, ya que determina de qué eres responsable al usar la API de RealityConnect para subir datos.
- Los endpoints de subida registran cada archivo bajo el
typeque proporcionas y completan la sesión únicamente por recuento: una vez quefinalizedFileCountes igual aexpectedFileCount, la sesión pasa acompleted. En el momento de la subida, el servidor no verifica que eltypesea uno de losrequirements, que la extensión del archivo esté permitida, que los tipos obligatorios estén presentes ni que se satisfagan las dependencias entre tipos. - Todas las reglas (tipos obligatorios,
allowMultiplesy lasdependenciesentre tipos) se aplican después de enviar el bundle a procesamiento. Un lote que ignore losrequirementsse subirá correctamente pero no se podrá procesar. - La API de subida nunca realiza una validación profunda del contenido. Si un
.lases una nube de puntos ZF genuina, o si un.jsonreferencia correctamente su imagen, solo se comprueba durante el procesamiento.
Trata los requirements como el contrato. Respeta los tipos, extensiones, opcionalidad, multiplicidad y dependencias en tu lote de subida para que el bundle sea procesable una vez subido. Los fallos de procesamiento causados por una estructura de subida incorrecta no son reembolsables.
Ejemplo completo
Sección titulada «Ejemplo completo»Poniéndolo todo junto para la captura ZF. files es la lista que construyes a partir de tu conjunto de datos local, donde cada entrada empareja una ruta con su type de requirements.
import math, requests
API_URL = "https://<your-regional-api-url>"TOKEN = "<access_token>"BUNDLE_ID = "<bundleId>"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
# (local path, schema type) for every file in the batchfiles = [ ("scan.las", "pointclouds"), ("2024-07-scan.laser_laser_0.txt", "trajectories"), ("panorama/2024-07-scan-idx40.jpg", "pictures"), ("panorama/2024-07-scan-idx40.json", "picturesMetadata"), # … remaining image / metadata pairs …]
def upload_one(session_id, path, file_type): size = __import__("os").path.getsize(path) base = f"{API_URL}/v1/bundles/{BUNDLE_ID}/upload-sessions/{session_id}/files"
initiated = requests.post( base, headers=HEADERS, json={"fileName": path.split("/")[-1], "fileSize": size, "type": file_type}, ).json()
part_size = math.ceil(size / initiated["parts"]) parts = [] with open(path, "rb") as handle: for entry in sorted(initiated["urls"], key=lambda u: u["partNumber"]): res = requests.put(entry["url"], data=handle.read(part_size)) res.raise_for_status() parts.append({"partNumber": entry["partNumber"], "etag": res.headers["ETag"]})
return requests.post( f"{base}/{initiated['fileId']}/finalize", headers=HEADERS, json={"parts": parts}, ).json()
# 1. Open the session for the exact number of files we will send.session = requests.post( f"{API_URL}/v1/bundles/{BUNDLE_ID}/upload-sessions", headers=HEADERS, json={"expectedFileCount": len(files)},).json()
# 2. Upload every file under its type.result = Nonefor path, file_type in files: result = upload_one(session["sessionId"], path, file_type)
# 3. The last finalize reports the session as completed.assert result["sessionStatus"] == "completed"Paso siguiente
Sección titulada «Paso siguiente»Una vez que todos los archivos se hayan subido y la sesión esté completed, continúa con Procesamiento y seguimiento para convertir las entradas sin procesar en salidas visualizables.