Ir al contenido

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.


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:

  1. Abre una sesión en el bundle, declarando el número total de archivos que enviarás.
  2. Para cada archivo: inicia la subida, envía sus partes mediante PUT directamente a las URLs devueltas y, a continuación, finalízalo.
  3. Cuando el número de archivos finalizados alcanza expectedFileCount, la sesión se marca como completed.
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í.

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:

ArchivoTipoNotas
scan.lasnube de puntosexactamente una
…laser_0.txttrayectoriaexactamente una
panorama/*.jpgimágenes panorámicasmuchas, cada una emparejada con metadatos
panorama/*.jsonmetadatos por imagenmuchos, 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.

Todas las rutas son relativas a tu api_url regional. El bundle se direcciona por su propio id (bundleId).

MétodoRutaÁmbitoPropósito
POST/v1/bundles/{bundleId}/upload-sessionswrite:bundleAbrir una sesión
GET/v1/bundles/{bundleId}/upload-sessionsread:bundleListar sesiones (reanudar / progreso)
POST/v1/bundles/{bundleId}/upload-sessions/{sessionId}/fileswrite:bundleIniciar la subida de un archivo
GET/v1/bundles/{bundleId}/upload-sessions/{sessionId}/files/{fileId}/refresh/{startIndex}write:bundleReemitir URLs de partes caducadas
POST/v1/bundles/{bundleId}/upload-sessions/{sessionId}/files/{fileId}/finalizewrite:bundleFinalizar la subida de un archivo

Para subir datos a un data bundle, necesitas:

  • un flujo OAuth completado con un access_token y tu api_url regional
  • 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.

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-sessions
Authorization: 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:

CampoSignificado
typeEl identificador que pasas como type al iniciar la subida de un archivo.
allowedExtensionsExtensiones de archivo aceptadas para este tipo.
optionalSi es false, el bundle no puede procesarse sin al menos un archivo de este tipo.
allowMultiplesSi es false, solo puede subirse un archivo de este tipo.
dependenciesOtros tipos que también deben estar presentes siempre que este tipo esté presente.

Para el ejemplo ZF:

  • pointclouds y trajectories son obligatorios (optional: false) y únicos (allowMultiples: false). Todo bundle ZF necesita exactamente uno de cada.
  • pictures y picturesMetadata son opcionales y aceptan varios archivos.
  • Los dos tipos de imagen dependen entre sí: pictures.dependencies = ["picturesMetadata"] y picturesMetadata.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ínimo1 pointcloud + 1 trajectory (sin imágenes). expectedFileCount = 2.
  • Completo1 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.

Repite las tres llamadas siguientes para cada archivo del lote.

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}/files
Authorization: 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?…" }
]
}

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 parts

Devuelve el ETag exactamente como se recibió, incluidas las comillas que lo rodean.

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}/finalize
Authorization: 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.

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/0
Authorization: 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.

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=DESC
Authorization: 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.

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 type que proporcionas y completan la sesión únicamente por recuento: una vez que finalizedFileCount es igual a expectedFileCount, la sesión pasa a completed. En el momento de la subida, el servidor no verifica que el type sea uno de los requirements, 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, allowMultiples y las dependencies entre tipos) se aplican después de enviar el bundle a procesamiento. Un lote que ignore los requirements se subirá correctamente pero no se podrá procesar.
  • La API de subida nunca realiza una validación profunda del contenido. Si un .las es una nube de puntos ZF genuina, o si un .json referencia 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.

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 batch
files = [
("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 = None
for 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"

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.