跳转到内容

下载扫描文件

本指南说明如何从 RealityConnect API 下载处理后的扫描输出:点云、网格和全景图像。该工作流程使用 Client Credentials 流程download:scan 作用域。


  • 一个配置为 Client Credentials 的 OAuth 应用
  • 作用域:read:basicread:hierarchydownload:scan
  • 你的 client_idclient_secret
  • 对持有你要下载的 Bundle 的站点具有内容访问权限

扫描文件下载需要 read:twinread:asset

处理后的扫描数据存在于附加到站点Bundle 中。每个 Bundle 包含一个或多个组件,每个组件是一种不同的输出格式:

组件类型格式说明
OgcPointCloudHlodOGC 3D Tiles(开放标准)点云数据。tileset.json 入口点,附带 .glb 瓦片文件
RealityMeshHlodPrevu3D HLOD网格数据。hlod_tree.json 入口点,附带 .pvt 瓦片文件
RealityPhotosphereJPEG 图像全景数据。stations.json 清单,附带 .jpeg 图像
flowchart LR
  A["1. Authenticate"] --> B["2. Find a site"]
  B --> C["3. List bundles"]
  C --> D["4. Get bundle details"]
  D --> E["5. Download entry files"]
  E --> F["6. Follow file references"]
步骤端点你会得到什么
身份验证POST /oauth/token访问令牌
解析 API URLGET /oauth/api-info区域 apiUrl 和组织 ID
查找站点GET /v1/nodes/{id}/browse来自内容层级的站点 ID
列出 BundleGET /v1/site/{siteId}/bundles站点的 Bundle ID
获取组件GET /v1/site/{siteId}/bundles/{bundleId}每个组件的 signedLinkroots
下载文件对构造的 CDN URL 执行 GET入口文件、瓦片和图像

使用 Client Credentials 获取访问令牌:

POST https://cloud-api.prevu3d.com/oauth/token
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials

解析你的区域 API URL:

GET https://cloud-api.prevu3d.com/oauth/api-info
Authorization: Bearer {access_token}

响应包含 apiUrl(例如 https://api-ue1.prevu3d.com/realityconnect-api)。将此用作所有后续 API 调用的基础 URL。

从你的组织开始浏览节点层级:

GET {api_url}/v1/nodes/{organizationId}/browse
Authorization: Bearer {access_token}

递归遍历树,直到找到一个 type: "Site" 的节点。记下它的 id

GET {api_url}/v1/site/{siteId}/bundles
Authorization: Bearer {access_token}

返回该站点的 Bundle 分页列表。

GET {api_url}/v1/site/{siteId}/bundles/{bundleId}
Authorization: Bearer {access_token}

示例响应:

{
"id": "f75de673-...",
"name": "My Bundle",
"components": [
{
"id": "abc123",
"type": "OgcPointCloudHlod",
"signedLink": "https://cdn.prevu3d.com/.../OgcPointCloudHlod/*?Policy=...&Signature=...&Key-Pair-Id=...",
"roots": [
"sessions/0f4a60cb09a8/tileset.json",
"sessions/2a5ed597dc0f/tileset.json"
]
},
{
"id": "def456",
"type": "RealityMeshHlod",
"signedLink": "https://cdn.prevu3d.com/.../RealityMeshHlod/*?Policy=...&Signature=...&Key-Pair-Id=...",
"roots": ["hlod_tree.json"]
},
{
"id": "ghi789",
"type": "RealityPhotosphere",
"signedLink": "https://cdn.prevu3d.com/.../RealityPhotosphere/*?Policy=...&Signature=...&Key-Pair-Id=...",
"roots": ["stations.json"]
}
]
}

每个组件公开两个下载所需的字段:

  • signedLink:以 /* 结尾的 CDN 签名 URL。* 是一个占位符。该签名授予对此前缀下所有文件的读取访问权限,而不仅仅是单个文件。
  • roots:指向此组件入口点文件的相对路径数组。

通过将 signedLink 中的 * 替换为文件路径来构造可下载的 URL:

download_url = component["signedLink"].replace("*", root)
response = requests.get(download_url)

查询参数(PolicySignatureKey-Pair-Id)保持附加并对请求进行身份验证。直接从 CDN 下载文件字节,GET 请求上无需 Authorization 标头。

入口点: roots 中的每个值都是指向一个 tileset.json 文件的路径(每个扫描会话一个)。

for root in component["roots"]:
url = component["signedLink"].replace("*", root)
tileset = requests.get(url).json()

瓦片遍历: tileset.json 遵循 OGC 3D Tiles 规范。它包含一棵瓦片节点树。每个节点可能有一个 content 字段,其 uri 指向相对于 tileset 目录的 .glb 文件:

{
"asset": { "version": "1.0" },
"root": {
"boundingVolume": { },
"content": { "uri": "cell.glb" },
"children": [
{
"content": { "uri": "cell0.glb" },
"children": [ ]
}
]
}
}

相对于 tileset 目录解析瓦片路径:

session_dir = root.rsplit("/tileset.json", 1)[0]
tile_path = f"{session_dir}/{tile_uri}"
tile_url = component["signedLink"].replace("*", tile_path)
tile_data = requests.get(tile_url).content

递归遍历树以下载所有瓦片。

入口点: roots 包含 ["hlod_tree.json"]

url = component["signedLink"].replace("*", "hlod_tree.json")
hlod_tree = requests.get(url).json()

树结构: hlod_tree.json 包含一棵 HLOD 树:

{
"environment_offset": [ ],
"version": 1,
"root": {
"center": [1.17, -1.22, 5.63],
"size": [50.54, 46.61, 19.63],
"model_path": "cell",
"density": 6.73,
"children": [
{
"model_path": "cell0",
"children": [ ]
}
],
"textures": [
{ "name": "color", "min_mip": 5, "max_mip": 11, "path": "cell" },
{ "name": "ao", "min_mip": 5, "max_mip": 11, "path": "cell_ao" }
]
}
}

瓦片文件命名: 几何和纹理文件使用 {model_path}_{mip_level}.pvt 模式:

model_path = node["model_path"]
mip_level = texture["min_mip"]
tile_file = f"{model_path}_{mip_level}.pvt"
tile_url = component["signedLink"].replace("*", tile_file)
tile_data = requests.get(tile_url).content

递归遍历 children 数组以发现每个细节层次上的所有 model_path 值。

入口点: roots 包含 ["stations.json"]

url = component["signedLink"].replace("*", "stations.json")
stations = requests.get(url).json()

其他文件:

  • index.tsv:与 stations.json 并列的制表符分隔索引文件
  • 全景图像遵循 station_{n}/H_{face}_{x}_{y}.jpeg 模式
image_path = "station_1/H_0_0_0.jpeg"
image_url = component["signedLink"].replace("*", image_path)
image_data = requests.get(image_url).content

解析 stations.json 以发现所有站点目录及其关联的图像文件。

此脚本进行身份验证、查找站点、获取第一个 Bundle、下载入口点文件,并为每个组件跟随一个被引用的瓦片或图像。

import base64
import json
import os
from pathlib import Path
import requests
CLIENT_ID = "your-client-id"
CLIENT_SECRET = "your-client-secret"
CLOUD_API_BASE = "https://cloud-api.prevu3d.com"
OUTPUT_DIR = Path("downloads")
credentials = base64.b64encode(f"{CLIENT_ID}:{CLIENT_SECRET}".encode()).decode()
token_response = requests.post(
f"{CLOUD_API_BASE}/oauth/token",
data={"grant_type": "client_credentials"},
headers={"Authorization": f"Basic {credentials}"},
)
token_response.raise_for_status()
access_token = token_response.json()["access_token"]
headers = {"Authorization": f"Bearer {access_token}"}
api_info = requests.get(f"{CLOUD_API_BASE}/oauth/api-info", headers=headers).json()
api_url = api_info["apiUrl"].rstrip("/")
organization_id = api_info["organization"]["id"]
def api_get(path: str) -> dict:
response = requests.get(f"{api_url}{path}", headers=headers)
response.raise_for_status()
return response.json()
def download(url: str, dest: Path) -> bool:
response = requests.get(url, timeout=30)
if not response.ok:
return False
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(response.content)
return True
def find_tile_uri(node: dict, depth: int = 0):
if depth > 5 or not isinstance(node, dict):
return None
content = node.get("content", {})
uri = content.get("uri") or content.get("url")
if isinstance(uri, str) and not uri.startswith("http"):
return uri
for value in node.values():
if isinstance(value, dict):
found = find_tile_uri(value, depth + 1)
if found:
return found
elif isinstance(value, list):
for item in value:
found = find_tile_uri(item, depth + 1)
if found:
return found
return None
queue = api_get(f"/v1/nodes/{organization_id}/browse").get("items", [])
site_id = None
visited = set()
while queue and not site_id:
node = queue.pop(0)
if node["id"] in visited:
continue
visited.add(node["id"])
browse = api_get(f"/v1/nodes/{node['id']}/browse")
current = browse.get("node", {})
if current.get("type", "").lower() == "site":
site_id = current["id"]
break
queue.extend(browse.get("items", []))
if not site_id:
raise RuntimeError("No site found in hierarchy")
bundles = api_get(f"/v1/site/{site_id}/bundles")
bundle_id = bundles["items"][0]["id"]
bundle = api_get(f"/v1/site/{site_id}/bundles/{bundle_id}")
for component in bundle.get("components", []):
comp_type = component.get("type", "unknown")
signed_link = component.get("signedLink", "")
roots = component.get("roots", [])
if not signed_link or not roots:
continue
for root in roots:
entry_url = signed_link.replace("*", root)
entry_dest = OUTPUT_DIR / comp_type / root.replace("/", os.sep)
if not download(entry_url, entry_dest):
continue
if root.endswith("tileset.json"):
tileset = json.loads(entry_dest.read_bytes())
tile_uri = find_tile_uri(tileset.get("root", tileset))
if tile_uri:
session_dir = root.rsplit("/tileset.json", 1)[0]
tile_path = f"{session_dir}/{tile_uri}" if session_dir else tile_uri
download(signed_link.replace("*", tile_path), OUTPUT_DIR / comp_type / tile_path.replace("/", os.sep))
elif root == "hlod_tree.json":
tree = json.loads(entry_dest.read_bytes())
model_path = tree.get("root", {}).get("model_path", "")
if model_path:
tile_file = f"{model_path}_5.pvt"
download(signed_link.replace("*", tile_file), OUTPUT_DIR / comp_type / tile_file)
elif root == "stations.json":
download(signed_link.replace("*", "index.tsv"), OUTPUT_DIR / comp_type / "index.tsv")
download(signed_link.replace("*", "station_1/H_0_0_0.jpeg"), OUTPUT_DIR / comp_type / "station_1" / "H_0_0_0.jpeg")
break