@bindimaps/hyperlocal-web-sdk 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +162 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +248 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/image-selection.ts +75 -0
- package/src/index.ts +299 -0
package/README.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# @bindimaps/hyperlocal-web-sdk
|
|
2
|
+
|
|
3
|
+
Core SDK for image-based indoor localisation using the BindiMaps Landseer API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @bindimaps/hyperlocal-web-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { estimatePosition, type CapturedFrame } from "@bindimaps/hyperlocal-web-sdk"
|
|
15
|
+
|
|
16
|
+
const frames: CapturedFrame[] = capturedFrames // Array of { timestamp, imageData: Blob }
|
|
17
|
+
|
|
18
|
+
const result = await estimatePosition(
|
|
19
|
+
frames,
|
|
20
|
+
"your-location-id",
|
|
21
|
+
{ latitude: -33.8688, longitude: 151.2093 },
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if (result.type === "success") {
|
|
25
|
+
console.log("Position:", result.position, "Floor:", result.floorId)
|
|
26
|
+
} else {
|
|
27
|
+
console.error("Failed:", result.code, result.message)
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## API Reference
|
|
32
|
+
|
|
33
|
+
### `estimatePosition(frames, locationId, gpsPosition, options?)`
|
|
34
|
+
|
|
35
|
+
Estimate a user's indoor position from captured camera frames and GPS.
|
|
36
|
+
|
|
37
|
+
#### Parameters
|
|
38
|
+
|
|
39
|
+
| Parameter | Type | Description |
|
|
40
|
+
|-----------|------|-------------|
|
|
41
|
+
| `frames` | `CapturedFrame[]` | Captured camera frames (minimum 6) |
|
|
42
|
+
| `locationId` | `string` | Bindi location ID to localise within |
|
|
43
|
+
| `gpsPosition` | `GeoCoordinate` | Approximate GPS coordinates |
|
|
44
|
+
| `options` | `EstimatePositionOptions` | Optional config (environment, mock mode) |
|
|
45
|
+
|
|
46
|
+
#### Returns `Promise<PositionResult>`
|
|
47
|
+
|
|
48
|
+
Success:
|
|
49
|
+
```typescript
|
|
50
|
+
{
|
|
51
|
+
type: "success"
|
|
52
|
+
position: { latitude: number; longitude: number }
|
|
53
|
+
floorId: string
|
|
54
|
+
headingDegrees: number
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Error:
|
|
59
|
+
```typescript
|
|
60
|
+
{
|
|
61
|
+
type: "error"
|
|
62
|
+
code: "invalid_input" | "insufficient_frames" | "no_match" | "location_mismatch" | "api_error"
|
|
63
|
+
message: string
|
|
64
|
+
details?: string
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Types
|
|
69
|
+
|
|
70
|
+
### `CapturedFrame`
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
type CapturedFrame = {
|
|
74
|
+
timestamp: Date
|
|
75
|
+
imageData: Blob // JPEG image data
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### `GeoCoordinate`
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
type GeoCoordinate = {
|
|
83
|
+
latitude: number
|
|
84
|
+
longitude: number
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### `EstimatePositionOptions`
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
type EstimatePositionOptions = {
|
|
92
|
+
environment?: HyperlocalEnvironment
|
|
93
|
+
mock?: MockPositionConfig
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### `MockPositionConfig`
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
type MockPositionConfig =
|
|
101
|
+
| true
|
|
102
|
+
| {
|
|
103
|
+
position?: GeoCoordinate
|
|
104
|
+
floorId?: string
|
|
105
|
+
headingDegrees?: number
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Mock Mode
|
|
110
|
+
|
|
111
|
+
Use mock mode for local development and testing without a real camera or API connection:
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
// Simple mock — returns default position based on GPS input
|
|
115
|
+
const result = await estimatePosition(frames, "loc-id", gps, { mock: true })
|
|
116
|
+
|
|
117
|
+
// Custom mock — specify exact values
|
|
118
|
+
const result = await estimatePosition(frames, "loc-id", gps, {
|
|
119
|
+
mock: {
|
|
120
|
+
position: { latitude: -33.87, longitude: 151.21 },
|
|
121
|
+
floorId: "level-2",
|
|
122
|
+
headingDegrees: 180,
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Mock mode:
|
|
128
|
+
- Skips all validation, protobuf, and gRPC calls
|
|
129
|
+
- Returns after a 500ms simulated delay
|
|
130
|
+
- Always returns `type: "success"`
|
|
131
|
+
|
|
132
|
+
## Error Codes
|
|
133
|
+
|
|
134
|
+
| Code | Description |
|
|
135
|
+
|------|-------------|
|
|
136
|
+
| `invalid_input` | Invalid `locationId`, GPS input, or frame payload shape |
|
|
137
|
+
| `insufficient_frames` | Fewer than 6 frames provided |
|
|
138
|
+
| `no_match` | Images didn't match any known location features |
|
|
139
|
+
| `location_mismatch` | Matched a different location than the requested `locationId` |
|
|
140
|
+
| `api_error` | Network or transport failure |
|
|
141
|
+
|
|
142
|
+
## Integration with React
|
|
143
|
+
|
|
144
|
+
For React applications, use `@bindimaps/hyperlocal-react` which provides hooks for camera stream management and frame capture.
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
npm install @bindimaps/hyperlocal-react
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
See the [@bindimaps/hyperlocal-react README](https://www.npmjs.com/package/@bindimaps/hyperlocal-react) for React-specific documentation.
|
|
151
|
+
|
|
152
|
+
## Development
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
yarn build # Build the library
|
|
156
|
+
yarn start # Watch mode
|
|
157
|
+
yarn type-check # Type check
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bindimaps/hyperlocal-web-sdk
|
|
3
|
+
*
|
|
4
|
+
* SDK for image-based indoor localisation using the Landseer API.
|
|
5
|
+
*/
|
|
6
|
+
type GeoCoordinate = {
|
|
7
|
+
latitude: number;
|
|
8
|
+
longitude: number;
|
|
9
|
+
};
|
|
10
|
+
type CapturedFrame = {
|
|
11
|
+
timestamp: Date;
|
|
12
|
+
imageData: Blob;
|
|
13
|
+
};
|
|
14
|
+
type PositionEstimate = {
|
|
15
|
+
type: "success";
|
|
16
|
+
position: GeoCoordinate;
|
|
17
|
+
floorId: string;
|
|
18
|
+
headingDegrees: number;
|
|
19
|
+
};
|
|
20
|
+
type PositionErrorCode = "invalid_input" | "insufficient_frames" | "no_match" | "location_mismatch" | "api_error";
|
|
21
|
+
type PositionError = {
|
|
22
|
+
type: "error";
|
|
23
|
+
code: PositionErrorCode;
|
|
24
|
+
message: string;
|
|
25
|
+
details?: string;
|
|
26
|
+
};
|
|
27
|
+
type PositionResult = PositionEstimate | PositionError;
|
|
28
|
+
type MockPositionConfig = true | {
|
|
29
|
+
position?: GeoCoordinate;
|
|
30
|
+
floorId?: string;
|
|
31
|
+
headingDegrees?: number;
|
|
32
|
+
};
|
|
33
|
+
type EstimatePositionOptions = {
|
|
34
|
+
environment?: HyperlocalEnvironment;
|
|
35
|
+
mock?: MockPositionConfig;
|
|
36
|
+
};
|
|
37
|
+
declare const MIN_LOCALISATION_IMAGE_COUNT = 6;
|
|
38
|
+
declare enum HyperlocalEnvironment {
|
|
39
|
+
UNSPECIFIED = 0,
|
|
40
|
+
DEV_PREVIEW = 1,
|
|
41
|
+
DEV_PUBLIC = 2,
|
|
42
|
+
PROD_PREVIEW = 3,
|
|
43
|
+
PROD_PUBLIC = 4
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Estimate a user's indoor position from captured camera frames and GPS.
|
|
47
|
+
*
|
|
48
|
+
* @param frames - Captured camera frames (minimum 6)
|
|
49
|
+
* @param locationId - The Bindi location ID to localise within
|
|
50
|
+
* @param gpsPosition - Approximate GPS coordinates
|
|
51
|
+
* @param options - Optional configuration (environment, mock mode)
|
|
52
|
+
*/
|
|
53
|
+
declare const estimatePosition: (frames: CapturedFrame[], locationId: string, gpsPosition: GeoCoordinate, options?: EstimatePositionOptions) => Promise<PositionResult>;
|
|
54
|
+
|
|
55
|
+
export { type CapturedFrame, type EstimatePositionOptions, type GeoCoordinate, HyperlocalEnvironment, MIN_LOCALISATION_IMAGE_COUNT, type MockPositionConfig, type PositionError, type PositionErrorCode, type PositionEstimate, type PositionResult, estimatePosition };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
var __async = (__this, __arguments, generator) => {
|
|
2
|
+
return new Promise((resolve, reject) => {
|
|
3
|
+
var fulfilled = (value) => {
|
|
4
|
+
try {
|
|
5
|
+
step(generator.next(value));
|
|
6
|
+
} catch (e) {
|
|
7
|
+
reject(e);
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
var rejected = (value) => {
|
|
11
|
+
try {
|
|
12
|
+
step(generator.throw(value));
|
|
13
|
+
} catch (e) {
|
|
14
|
+
reject(e);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected);
|
|
18
|
+
step((generator = generator.apply(__this, __arguments)).next());
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// ../../../node_modules/@bindimaps/landseer/bmproto/landseer/landseer_pb.js
|
|
23
|
+
import { enumDesc, fileDesc, messageDesc, serviceDesc, tsEnum } from "@bufbuild/protobuf/codegenv2";
|
|
24
|
+
import { file_google_protobuf_wrappers } from "@bufbuild/protobuf/wkt";
|
|
25
|
+
var file_bmproto_landseer_landseer = /* @__PURE__ */ fileDesc("Ch9ibXByb3RvL2xhbmRzZWVyL2xhbmRzZWVyLnByb3RvEhBibXByb3RvLmxhbmRzZWVyIhAKDkhlYWx0aHpSZXF1ZXN0IiQKD0hlYWx0aHpSZXNwb25zZRIRCgl0aW1lc3RhbXAYASABKAMiIgoEUG9zZRIMCgR0dmVjGAEgAygCEgwKBHF2ZWMYAiADKAIiSAoRUGluaG9sZUludHJpbnNpY3MSCQoBZhgBIAMoAhIJCgFjGAIgAygCEg4KBmhlaWdodBgDIAEoDRINCgV3aWR0aBgEIAEoDSJDCg1EZWx0YUVuY29kaW5nEg8KB2luaXRpYWwYASABKAESDgoGZGVsdGFzGAIgAygREhEKCXByZWNpc2lvbhgDIAEoDSLUAQoQQWNjZWxlcm9tZXRlckxvZxIzCgp1bml4TWlsbGlzGAEgASgLMh8uYm1wcm90by5sYW5kc2Vlci5EZWx0YUVuY29kaW5nEi0KBHhNczIYAiABKAsyHy5ibXByb3RvLmxhbmRzZWVyLkRlbHRhRW5jb2RpbmcSLQoEeU1zMhgDIAEoCzIfLmJtcHJvdG8ubGFuZHNlZXIuRGVsdGFFbmNvZGluZxItCgR6TXMyGAQgASgLMh8uYm1wcm90by5sYW5kc2Vlci5EZWx0YUVuY29kaW5nIt0BCgdHeXJvTG9nEjMKCnVuaXhNaWxsaXMYASABKAsyHy5ibXByb3RvLmxhbmRzZWVyLkRlbHRhRW5jb2RpbmcSMwoKeFJhZFBlclNlYxgCIAEoCzIfLmJtcHJvdG8ubGFuZHNlZXIuRGVsdGFFbmNvZGluZxIzCgp5UmFkUGVyU2VjGAMgASgLMh8uYm1wcm90by5sYW5kc2Vlci5EZWx0YUVuY29kaW5nEjMKCnpSYWRQZXJTZWMYBCABKAsyHy5ibXByb3RvLmxhbmRzZWVyLkRlbHRhRW5jb2Rpbmci8gEKC1JvdGF0aW9uTG9nEjMKCnVuaXhNaWxsaXMYASABKAsyHy5ibXByb3RvLmxhbmRzZWVyLkRlbHRhRW5jb2RpbmcSKgoBeBgCIAEoCzIfLmJtcHJvdG8ubGFuZHNlZXIuRGVsdGFFbmNvZGluZxIqCgF5GAMgASgLMh8uYm1wcm90by5sYW5kc2Vlci5EZWx0YUVuY29kaW5nEioKAXoYBCABKAsyHy5ibXByb3RvLmxhbmRzZWVyLkRlbHRhRW5jb2RpbmcSKgoBdxgFIAEoCzIfLmJtcHJvdG8ubGFuZHNlZXIuRGVsdGFFbmNvZGluZyLKAwoFRnJhbWUSCgoCdHMYASABKAMSEQoJaW1hZ2VEYXRhGAIgASgMEigKCHNsYW1Qb3NlGAMgASgLMhYuYm1wcm90by5sYW5kc2Vlci5Qb3NlEjcKCmludHJpbnNpY3MYBCABKAsyIy5ibXByb3RvLmxhbmRzZWVyLlBpbmhvbGVJbnRyaW5zaWNzEhgKDG5vcnRoRGVncmVlcxgFIAEoAkICGAESOQoUbm9ydGhEZWdyZWVzRXN0aW1hdGUYBiABKAsyGy5nb29nbGUucHJvdG9idWYuRmxvYXRWYWx1ZRIwCgtwcmVzc3VyZUhQYRgHIAEoCzIbLmdvb2dsZS5wcm90b2J1Zi5GbG9hdFZhbHVlEjYKEHByZWRpY3RlZEZsb29ySWQYCCABKAsyHC5nb29nbGUucHJvdG9idWYuU3RyaW5nVmFsdWUSPQoYcHJlZGljdGVkRmxvb3JDb25maWRlbmNlGAkgASgLMhsuZ29vZ2xlLnByb3RvYnVmLkZsb2F0VmFsdWUSEgoKZnJhbWVJbmRleBgKIAEoDRItCgtncHNMb2NhdGlvbhgLIAEoCzIYLmJtcHJvdG8ubGFuZHNlZXIuTGF0TG9uIrkBCgZMYXRMb24SCwoDbGF0GAEgASgCEgsKA2xvbhgCIAEoAhIgChhob3Jpem9udGFsQWNjdXJhY3lNZXRlcnMYAyABKAISNgoRbXNsQWx0aXR1ZGVNZXRlcnMYBCABKAsyGy5nb29nbGUucHJvdG9idWYuRmxvYXRWYWx1ZRI7ChZ2ZXJ0aWNhbEFjY3VyYWN5TWV0ZXJzGAUgASgLMhsuZ29vZ2xlLnByb3RvYnVmLkZsb2F0VmFsdWUiwAEKCFNsYW1Qb3NlEgoKAnRzGAEgASgEEiQKBHBvc2UYAiABKAsyFi5ibXByb3RvLmxhbmRzZWVyLlBvc2USMQoMbm9ydGhEZWdyZWVzGAMgASgLMhsuZ29vZ2xlLnByb3RvYnVmLkZsb2F0VmFsdWUSMAoLcHJlc3N1cmVIUGEYBCABKAsyGy5nb29nbGUucHJvdG9idWYuRmxvYXRWYWx1ZRIdChVzbGFtQ29vcmRpbmF0ZVNwYWNlSWQYBSABKA0ifgoORGV2aWNlUG9zaXRpb24SCQoBeBgBIAEoAhIJCgF5GAIgASgCEhQKDG5vcnRoRGVncmVlcxgDIAEoAhIgChhob3Jpem9udGFsQWNjdXJhY3lNZXRlcnMYBCABKAISDwoHZmxvb3JJZBgFIAEoCRINCgVsb2NJZBgGIAEoCSJOChFEZXZpY2VQb3NpdGlvbkxvZxIKCgJ0cxgBIAEoBBItCgNwb3MYAiABKAsyIC5ibXByb3RvLmxhbmRzZWVyLkRldmljZVBvc2l0aW9uIkgKC0dwc0xvZ0VudHJ5EgoKAnRzGAEgASgEEi0KC2dwc0xvY2F0aW9uGAIgASgLMhguYm1wcm90by5sYW5kc2Vlci5MYXRMb24i6wUKD0xvY2FsaXNlUmVxdWVzdBIRCglzZXNzaW9uSWQYASABKAkSEwoLc2VxdWVuY2VOdW0YAiABKA0SMAoOZ3BzQ29vcmRpbmF0ZXMYAyABKAsyGC5ibXByb3RvLmxhbmRzZWVyLkxhdExvbhInCgZmcmFtZXMYBCADKAsyFy5ibXByb3RvLmxhbmRzZWVyLkZyYW1lEi0KCXNsYW1Qb3NlcxgFIAMoCzIWLmJtcHJvdG8ubGFuZHNlZXIuUG9zZUICGAESFwoPcGFyZW50U2Vzc2lvbklkGAYgASgJEisKB3NsYW1Mb2cYByADKAsyGi5ibXByb3RvLmxhbmRzZWVyLlNsYW1Qb3NlEjAKCmRldmljZVR5cGUYCCABKA4yHC5ibXByb3RvLmxhbmRzZWVyLkRldmljZVR5cGUSMgoLZW52aXJvbm1lbnQYCSABKA4yHS5ibXByb3RvLmxhbmRzZWVyLkVudmlyb25tZW50Eh0KFXNsYW1Db29yZGluYXRlU3BhY2VJZBgKIAEoDRI+ChFkZXZpY2VQb3NpdGlvbkxvZxgLIAMoCzIjLmJtcHJvdG8ubGFuZHNlZXIuRGV2aWNlUG9zaXRpb25Mb2cSNgoNdHJhbnNmb3JtVHlwZRgMIAEoDjIfLmJtcHJvdG8ubGFuZHNlZXIuVHJhbnNmb3JtVHlwZRItCgZncHNMb2cYDSADKAsyHS5ibXByb3RvLmxhbmRzZWVyLkdwc0xvZ0VudHJ5EjIKBmFjY0xvZxgOIAEoCzIiLmJtcHJvdG8ubGFuZHNlZXIuQWNjZWxlcm9tZXRlckxvZxIqCgdneXJvTG9nGA8gASgLMhkuYm1wcm90by5sYW5kc2Vlci5HeXJvTG9nEi0KBnJvdExvZxgQIAEoCzIdLmJtcHJvdG8ubGFuZHNlZXIuUm90YXRpb25Mb2cSFQoNaXNIZWFsdGhjaGVjaxgRIAEoCBIOCgZpc1NjYW4YEiABKAgiNgoGTWF0cml4EgwKBHJvd3MYASABKA0SDAoEY29scxgCIAEoDRIQCghlbGVtZW50cxgDIAMoASLiAgoQTG9jYWxpc2VSZXNwb25zZRIRCglzZXNzaW9uSWQYASABKAkSEwoLc2VxdWVuY2VOdW0YAiABKA0SKwoLc2xhbUludmVyc2UYAyABKAsyFi5ibXByb3RvLmxhbmRzZWVyLlBvc2USLgoOb3B0aW1pc2VkUG9zZXMYBCADKAsyFi5ibXByb3RvLmxhbmRzZWVyLlBvc2USEgoKbG9jYXRpb25JZBgFIAEoCRIPCgdmbG9vcklkGAYgASgJEi0KC3NsYW1Ub01vZGVsGAcgASgLMhguYm1wcm90by5sYW5kc2Vlci5NYXRyaXgSHgoWY29uZmlkZW5jZVJhZGl1c01ldGVycxgIIAEoAhIdChVzbGFtQ29vcmRpbmF0ZVNwYWNlSWQYCSABKA0SNgoNdHJhbnNmb3JtVHlwZRgKIAEoDjIfLmJtcHJvdG8ubGFuZHNlZXIuVHJhbnNmb3JtVHlwZSKOAQoRU3VibWl0U2NhblJlcXVlc3QSDgoGc2NhbklkGAEgASgJEgoKAnRzGAIgASgDEjAKCmRldmljZVR5cGUYAyABKA4yHC5ibXByb3RvLmxhbmRzZWVyLkRldmljZVR5cGUSKwoKc2NhbkZyYW1lcxgEIAMoCzIXLmJtcHJvdG8ubGFuZHNlZXIuRnJhbWUiJAoSU3VibWl0U2NhblJlc3BvbnNlEg4KBnNjYW5JZBgBIAEoCSJbChlHZXRGbG9vclRyYW5zZm9ybXNSZXF1ZXN0EhIKCmxvY2F0aW9uSWQYASABKAkSKgoDZW52GAIgASgOMh0uYm1wcm90by5sYW5kc2Vlci5FbnZpcm9ubWVudCLnAQoORmxvb3JUcmFuc2Zvcm0SEgoKbG9jYXRpb25JZBgBIAEoCRIPCgdmbG9vcklkGAIgASgJEjIKDG1vZGVsVG9GbG9vchgDIAEoCzIYLmJtcHJvdG8ubGFuZHNlZXIuTWF0cml4QgIYARIXCg9tb2RlbFRvRmxvb3JSb3QYBCADKAISGQoRY29vcmRpbmF0ZVNwYWNlSWQYBSABKAkSDQoFZmxpcFkYBiABKAgSOQoXbW9kZWxUb0Zsb29yVW5jb3JyZWN0ZWQYByABKAsyGC5ibXByb3RvLmxhbmRzZWVyLk1hdHJpeCKXAQoaR2V0Rmxvb3JUcmFuc2Zvcm1zUmVzcG9uc2USEgoKbG9jYXRpb25JZBgBIAEoCRI5Cg9mbG9vclRyYW5zZm9ybXMYAiADKAsyIC5ibXByb3RvLmxhbmRzZWVyLkZsb29yVHJhbnNmb3JtEioKA2VudhgDIAEoDjIdLmJtcHJvdG8ubGFuZHNlZXIuRW52aXJvbm1lbnQiOQoQTG9jYWxpc2VBc3luY0FjaxIRCglzZXNzaW9uSWQYASABKAkSEgoKc2VxdWVuY2VJZBgCIAEoDSKMAQoVTG9jYWxpc2VBc3luY1Jlc3BvbnNlEjEKA2FjaxgBIAEoCzIiLmJtcHJvdG8ubGFuZHNlZXIuTG9jYWxpc2VBc3luY0Fja0gAEjYKCHJlc3BvbnNlGAIgASgLMiIuYm1wcm90by5sYW5kc2Vlci5Mb2NhbGlzZVJlc3BvbnNlSABCCAoGcmVzdWx0IkYKFExvY2FsaXNlQXN5bmNSZXF1ZXN0Ei4KA3JlcRgBIAEoCzIhLmJtcHJvdG8ubGFuZHNlZXIuTG9jYWxpc2VSZXF1ZXN0IkYKFFN0YXJ0TG9jYWxpc2VSZXF1ZXN0Ei4KA3JlcRgBIAEoCzIhLmJtcHJvdG8ubGFuZHNlZXIuTG9jYWxpc2VSZXF1ZXN0Ij4KFVN0YXJ0TG9jYWxpc2VSZXNwb25zZRIRCglzZXNzaW9uSWQYASABKAkSEgoKc2VxdWVuY2VJZBgCIAEoDSK4AQoRTW9kZWxQb3NlRXN0aW1hdGUSDwoHZmxvb3JJZBgBIAEoCRItCg1lc3RpbWF0ZWRQb3NlGAIgASgLMhYuYm1wcm90by5sYW5kc2Vlci5Qb3NlEhYKDmFjY3VyYWN5TWV0ZXJzGAMgASgCEhcKD2FjY3VyYWN5RGVncmVlcxgEIAEoAhISCgpsb2NhdGlvbklkGAUgASgJEh4KFndlaWdodGVkQXZlcmFnZUltYWdlVHMYBiABKAQikgEKD0xvY2FsaXNlUGVuZGluZxIRCglzZXNzaW9uSWQYASABKAkSEgoKc2VxdWVuY2VJZBgCIAEoDRIdChVyZXF1ZXN0UmVjZWl2ZWRVbml4TXMYAyABKAMSOQoMcG9zZUVzdGltYXRlGAQgASgLMiMuYm1wcm90by5sYW5kc2Vlci5Nb2RlbFBvc2VFc3RpbWF0ZSKIAQoNTG9jYWxpc2VFcnJvchIRCglzZXNzaW9uSWQYASABKAkSEgoKc2VxdWVuY2VJZBgCIAEoDRIdChVyZXF1ZXN0UmVjZWl2ZWRVbml4TXMYAyABKAMSGwoTcmVxdWVzdEZhaWxlZFVuaXhNcxgEIAEoAxIUCgxlcnJvck1lc3NhZ2UYBSABKAkiQQoYR2V0TG9jYWxpc2VSZXN1bHRSZXF1ZXN0EhEKCXNlc3Npb25JZBgBIAEoCRISCgpzZXF1ZW5jZUlkGAIgASgNIsUBChlHZXRMb2NhbGlzZVJlc3VsdFJlc3BvbnNlEjQKB3BlbmRpbmcYASABKAsyIS5ibXByb3RvLmxhbmRzZWVyLkxvY2FsaXNlUGVuZGluZ0gAEjAKBWVycm9yGAIgASgLMh8uYm1wcm90by5sYW5kc2Vlci5Mb2NhbGlzZUVycm9ySAASNgoIcmVzcG9uc2UYAyABKAsyIi5ibXByb3RvLmxhbmRzZWVyLkxvY2FsaXNlUmVzcG9uc2VIAEIICgZyZXN1bHQicQoVTG9jYXRpb25Fc3RpbWF0ZUltYWdlEgoKAnRzGAEgASgEEhEKCWltYWdlRGF0YRgCIAEoDBI5ChRub3J0aERlZ3JlZXNFc3RpbWF0ZRgDIAEoCzIbLmdvb2dsZS5wcm90b2J1Zi5GbG9hdFZhbHVlIrYBChdMb2NhdGlvbkVzdGltYXRlUmVxdWVzdBIKCgJ0cxgBIAEoBBIqCgNncHMYAiABKAsyHS5ibXByb3RvLmxhbmRzZWVyLkdwc0xvZ0VudHJ5EjcKBmltYWdlcxgDIAMoCzInLmJtcHJvdG8ubGFuZHNlZXIuTG9jYXRpb25Fc3RpbWF0ZUltYWdlEioKA2VudhgEIAEoDjIdLmJtcHJvdG8ubGFuZHNlZXIuRW52aXJvbm1lbnQimAEKEExvY2F0aW9uRXN0aW1hdGUSCgoCdHMYASABKAQSEgoKbG9jYXRpb25JZBgCIAEoCRIPCgdmbG9vcklkGAMgASgJEhAKCGxhdGl0dWRlGAQgASgBEhEKCWxvbmdpdHVkZRgFIAEoARIWCg5oZWFkaW5nRGVncmVlcxgGIAEoAhIWCg5hY2N1cmFjeU1ldGVycxgHIAEoAiKeAQoYTG9jYXRpb25Fc3RpbWF0ZVJlc3BvbnNlEjwKEGxvY2F0aW9uRXN0aW1hdGUYASABKAsyIi5ibXByb3RvLmxhbmRzZWVyLkxvY2F0aW9uRXN0aW1hdGUSNAoMbG9jYWxpc2VUeXBlGAIgASgOMh4uYm1wcm90by5sYW5kc2Vlci5Mb2NhbGlzZVR5cGUSDgoGZXJyb3JzGAMgAygJKlcKCkRldmljZVR5cGUSGwoXREVWSUNFX1RZUEVfVU5TUEVDSUZJRUQQABITCg9ERVZJQ0VfVFlQRV9JT1MQARIXChNERVZJQ0VfVFlQRV9BTkRST0lEEAIqngEKC0Vudmlyb25tZW50EhsKF0VOVklST05NRU5UX1VOU1BFQ0lGSUVEEAASGwoXRU5WSVJPTk1FTlRfREVWX1BSRVZJRVcQARIaChZFTlZJUk9OTUVOVF9ERVZfUFVCTElDEAISHAoYRU5WSVJPTk1FTlRfUFJPRF9QUkVWSUVXEAMSGwoXRU5WSVJPTk1FTlRfUFJPRF9QVUJMSUMQBCpWCg1UcmFuc2Zvcm1UeXBlEh4KGlRSQU5TRk9STV9UWVBFX1VOU1BFQ0lGSUVEEAASJQohVFJBTlNGT1JNX1RZUEVfTk9fUVVBVEVSTklPTl9GTElQEAEqgQEKDExvY2FsaXNlVHlwZRIdChlMT0NBTElTRV9UWVBFX1VOU1BFQ0lGSUVEEAASGAoUTE9DQUxJU0VfVFlQRV9GQUlMRUQQARIVChFMT0NBTElTRV9UWVBFX0dQUxACEiEKHUxPQ0FMSVNFX1RZUEVfVklTVUFMX0VTVElNQVRFEAMy0wUKEkxhbmRzZWVyQVBJU2VydmljZRJQCgdoZWFsdGh6EiAuYm1wcm90by5sYW5kc2Vlci5IZWFsdGh6UmVxdWVzdBohLmJtcHJvdG8ubGFuZHNlZXIuSGVhbHRoelJlc3BvbnNlIgASUwoIbG9jYWxpc2USIS5ibXByb3RvLmxhbmRzZWVyLkxvY2FsaXNlUmVxdWVzdBoiLmJtcHJvdG8ubGFuZHNlZXIuTG9jYWxpc2VSZXNwb25zZSIAEmIKDWxvY2FsaXNlQXN5bmMSJi5ibXByb3RvLmxhbmRzZWVyLkxvY2FsaXNlQXN5bmNSZXF1ZXN0GicuYm1wcm90by5sYW5kc2Vlci5Mb2NhbGlzZUFzeW5jUmVzcG9uc2UiABJiCg1zdGFydExvY2FsaXNlEiYuYm1wcm90by5sYW5kc2Vlci5TdGFydExvY2FsaXNlUmVxdWVzdBonLmJtcHJvdG8ubGFuZHNlZXIuU3RhcnRMb2NhbGlzZVJlc3BvbnNlIgASbgoRZ2V0TG9jYWxpc2VSZXN1bHQSKi5ibXByb3RvLmxhbmRzZWVyLkdldExvY2FsaXNlUmVzdWx0UmVxdWVzdBorLmJtcHJvdG8ubGFuZHNlZXIuR2V0TG9jYWxpc2VSZXN1bHRSZXNwb25zZSIAEmsKEGxvY2F0aW9uRXN0aW1hdGUSKS5ibXByb3RvLmxhbmRzZWVyLkxvY2F0aW9uRXN0aW1hdGVSZXF1ZXN0GiouYm1wcm90by5sYW5kc2Vlci5Mb2NhdGlvbkVzdGltYXRlUmVzcG9uc2UiABJxChJnZXRGbG9vclRyYW5zZm9ybXMSKy5ibXByb3RvLmxhbmRzZWVyLkdldEZsb29yVHJhbnNmb3Jtc1JlcXVlc3QaLC5ibXByb3RvLmxhbmRzZWVyLkdldEZsb29yVHJhbnNmb3Jtc1Jlc3BvbnNlIgBCJVojZ2l0aHViLmNvbS9iaW5kaW1hcHMvbGFuZHNlZXItZ29idWZiBnByb3RvMw", [file_google_protobuf_wrappers]);
|
|
26
|
+
var EnvironmentSchema = /* @__PURE__ */ enumDesc(file_bmproto_landseer_landseer, 1);
|
|
27
|
+
var Environment = /* @__PURE__ */ tsEnum(EnvironmentSchema);
|
|
28
|
+
var LocaliseTypeSchema = /* @__PURE__ */ enumDesc(file_bmproto_landseer_landseer, 3);
|
|
29
|
+
var LocaliseType = /* @__PURE__ */ tsEnum(LocaliseTypeSchema);
|
|
30
|
+
var LandseerAPIService = /* @__PURE__ */ serviceDesc(file_bmproto_landseer_landseer, 0);
|
|
31
|
+
|
|
32
|
+
// src/index.ts
|
|
33
|
+
import { createClient } from "@connectrpc/connect";
|
|
34
|
+
import { createGrpcWebTransport } from "@connectrpc/connect-web";
|
|
35
|
+
|
|
36
|
+
// src/image-selection.ts
|
|
37
|
+
var getImageThumbnail = (blob, size = 16) => __async(null, null, function* () {
|
|
38
|
+
const bitmap = yield createImageBitmap(blob, { resizeWidth: size, resizeHeight: size, resizeQuality: "low" });
|
|
39
|
+
const canvas = document.createElement("canvas");
|
|
40
|
+
canvas.width = size;
|
|
41
|
+
canvas.height = size;
|
|
42
|
+
const ctx = canvas.getContext("2d");
|
|
43
|
+
if (!ctx) throw new Error("Could not get canvas context");
|
|
44
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
45
|
+
bitmap.close();
|
|
46
|
+
const imageData = ctx.getImageData(0, 0, size, size).data;
|
|
47
|
+
const grayscale = new Uint8Array(size * size);
|
|
48
|
+
for (let i = 0; i < imageData.length; i += 4) {
|
|
49
|
+
grayscale[i / 4] = (imageData[i] + imageData[i + 1] + imageData[i + 2]) / 3;
|
|
50
|
+
}
|
|
51
|
+
return grayscale;
|
|
52
|
+
});
|
|
53
|
+
var calculateDistance = (a, b) => {
|
|
54
|
+
let sum = 0;
|
|
55
|
+
for (let i = 0; i < a.length; i++) {
|
|
56
|
+
const diff = a[i] - b[i];
|
|
57
|
+
sum += diff * diff;
|
|
58
|
+
}
|
|
59
|
+
return Math.sqrt(sum);
|
|
60
|
+
};
|
|
61
|
+
var selectDiverseFrames = (frames, count) => __async(null, null, function* () {
|
|
62
|
+
if (frames.length <= count) return frames;
|
|
63
|
+
const thumbnails = yield Promise.all(frames.map((f) => getImageThumbnail(f.imageData)));
|
|
64
|
+
const selectedIndices = /* @__PURE__ */ new Set([0]);
|
|
65
|
+
while (selectedIndices.size < count) {
|
|
66
|
+
let maxMinDist = -1;
|
|
67
|
+
let bestCandidateIdx = -1;
|
|
68
|
+
for (let i = 0; i < frames.length; i++) {
|
|
69
|
+
if (selectedIndices.has(i)) continue;
|
|
70
|
+
let minDist = Infinity;
|
|
71
|
+
for (const selectedIdx of selectedIndices) {
|
|
72
|
+
const dist = calculateDistance(thumbnails[i], thumbnails[selectedIdx]);
|
|
73
|
+
if (dist < minDist) minDist = dist;
|
|
74
|
+
}
|
|
75
|
+
if (minDist > maxMinDist) {
|
|
76
|
+
maxMinDist = minDist;
|
|
77
|
+
bestCandidateIdx = i;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (bestCandidateIdx === -1) break;
|
|
81
|
+
selectedIndices.add(bestCandidateIdx);
|
|
82
|
+
}
|
|
83
|
+
return [...selectedIndices].sort((a, b) => a - b).map((idx) => frames[idx]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// src/index.ts
|
|
87
|
+
var MIN_LOCALISATION_IMAGE_COUNT = 6;
|
|
88
|
+
var USE_DIVERSE_FRAME_SELECTION = false;
|
|
89
|
+
var HyperlocalEnvironment = /* @__PURE__ */ ((HyperlocalEnvironment2) => {
|
|
90
|
+
HyperlocalEnvironment2[HyperlocalEnvironment2["UNSPECIFIED"] = 0] = "UNSPECIFIED";
|
|
91
|
+
HyperlocalEnvironment2[HyperlocalEnvironment2["DEV_PREVIEW"] = 1] = "DEV_PREVIEW";
|
|
92
|
+
HyperlocalEnvironment2[HyperlocalEnvironment2["DEV_PUBLIC"] = 2] = "DEV_PUBLIC";
|
|
93
|
+
HyperlocalEnvironment2[HyperlocalEnvironment2["PROD_PREVIEW"] = 3] = "PROD_PREVIEW";
|
|
94
|
+
HyperlocalEnvironment2[HyperlocalEnvironment2["PROD_PUBLIC"] = 4] = "PROD_PUBLIC";
|
|
95
|
+
return HyperlocalEnvironment2;
|
|
96
|
+
})(HyperlocalEnvironment || {});
|
|
97
|
+
var toLandseerEnv = (env) => {
|
|
98
|
+
const map = {
|
|
99
|
+
[0 /* UNSPECIFIED */]: Environment.UNSPECIFIED,
|
|
100
|
+
[1 /* DEV_PREVIEW */]: Environment.DEV_PREVIEW,
|
|
101
|
+
[2 /* DEV_PUBLIC */]: Environment.DEV_PUBLIC,
|
|
102
|
+
[3 /* PROD_PREVIEW */]: Environment.PROD_PREVIEW,
|
|
103
|
+
[4 /* PROD_PUBLIC */]: Environment.PROD_PUBLIC
|
|
104
|
+
};
|
|
105
|
+
return map[env];
|
|
106
|
+
};
|
|
107
|
+
var blobToUint8Array = (blob) => __async(null, null, function* () {
|
|
108
|
+
const arrayBuffer = yield blob.arrayBuffer();
|
|
109
|
+
return new Uint8Array(arrayBuffer);
|
|
110
|
+
});
|
|
111
|
+
var getBaseUrl = (environment) => environment === 4 /* PROD_PUBLIC */ || environment === 3 /* PROD_PREVIEW */ ? "https://landseer.bindimaps.app" : "https://landseer.bindi.dev";
|
|
112
|
+
var isValidGpsPosition = (gpsPosition) => {
|
|
113
|
+
if (!gpsPosition || typeof gpsPosition !== "object") return false;
|
|
114
|
+
const maybeGps = gpsPosition;
|
|
115
|
+
return Number.isFinite(maybeGps.latitude) && Number.isFinite(maybeGps.longitude) && Number(maybeGps.latitude) >= -90 && Number(maybeGps.latitude) <= 90 && Number(maybeGps.longitude) >= -180 && Number(maybeGps.longitude) <= 180;
|
|
116
|
+
};
|
|
117
|
+
var normalizeHeadingDegrees = (headingDegrees) => Number.isFinite(headingDegrees) ? Number(headingDegrees) : 0;
|
|
118
|
+
var validateInputs = (frames, locationId, gpsPosition) => {
|
|
119
|
+
if (typeof locationId !== "string" || locationId.trim().length === 0) {
|
|
120
|
+
return {
|
|
121
|
+
type: "error",
|
|
122
|
+
code: "invalid_input",
|
|
123
|
+
message: "locationId must be a non-empty string."
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (!isValidGpsPosition(gpsPosition)) {
|
|
127
|
+
return {
|
|
128
|
+
type: "error",
|
|
129
|
+
code: "invalid_input",
|
|
130
|
+
message: "gpsPosition coordinates are out of range (lat: -90..90, lng: -180..180)."
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (!Array.isArray(frames)) {
|
|
134
|
+
return {
|
|
135
|
+
type: "error",
|
|
136
|
+
code: "invalid_input",
|
|
137
|
+
message: "frames must be an array."
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (frames.length < MIN_LOCALISATION_IMAGE_COUNT) {
|
|
141
|
+
return {
|
|
142
|
+
type: "error",
|
|
143
|
+
code: "insufficient_frames",
|
|
144
|
+
message: `At least ${MIN_LOCALISATION_IMAGE_COUNT} frames are required for localisation.`,
|
|
145
|
+
details: `Received ${frames.length} frames, required ${MIN_LOCALISATION_IMAGE_COUNT}.`
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
};
|
|
150
|
+
var buildRequest = (frames, gpsPosition, environment) => __async(null, null, function* () {
|
|
151
|
+
const selectedFrames = USE_DIVERSE_FRAME_SELECTION ? yield selectDiverseFrames(frames, MIN_LOCALISATION_IMAGE_COUNT) : frames;
|
|
152
|
+
const now = /* @__PURE__ */ new Date();
|
|
153
|
+
const ts = BigInt(now.getTime());
|
|
154
|
+
const gps = {
|
|
155
|
+
gpsLocation: {
|
|
156
|
+
lat: gpsPosition.latitude,
|
|
157
|
+
lon: gpsPosition.longitude,
|
|
158
|
+
horizontalAccuracyMeters: 0,
|
|
159
|
+
mslAltitudeMeters: 0,
|
|
160
|
+
verticalAccuracyMeters: 0,
|
|
161
|
+
$typeName: "bmproto.landseer.LatLon"
|
|
162
|
+
},
|
|
163
|
+
ts,
|
|
164
|
+
$typeName: "bmproto.landseer.GpsLogEntry"
|
|
165
|
+
};
|
|
166
|
+
const images = yield Promise.all(
|
|
167
|
+
selectedFrames.map((img) => __async(null, null, function* () {
|
|
168
|
+
return {
|
|
169
|
+
ts: BigInt(img.timestamp.getTime()),
|
|
170
|
+
imageData: yield blobToUint8Array(img.imageData),
|
|
171
|
+
$typeName: "bmproto.landseer.LocationEstimateImage"
|
|
172
|
+
};
|
|
173
|
+
}))
|
|
174
|
+
);
|
|
175
|
+
return {
|
|
176
|
+
ts,
|
|
177
|
+
gps,
|
|
178
|
+
images,
|
|
179
|
+
env: toLandseerEnv(environment),
|
|
180
|
+
$typeName: "bmproto.landseer.LocationEstimateRequest"
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
var interpretResponse = (response, locationId) => {
|
|
184
|
+
var _a;
|
|
185
|
+
if (response.errors.length > 0 || response.localiseType === LocaliseType.FAILED || !response.locationEstimate || !response.locationEstimate.floorId || !Number.isFinite(response.locationEstimate.latitude) || !Number.isFinite(response.locationEstimate.longitude)) {
|
|
186
|
+
return {
|
|
187
|
+
type: "error",
|
|
188
|
+
code: "no_match",
|
|
189
|
+
message: response.errors.length > 0 ? response.errors.join(", ") : "Localisation failed \u2014 no match found.",
|
|
190
|
+
details: response.errors.length > 0 ? response.errors.join("; ") : `localiseType=${response.localiseType}`
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (!response.locationEstimate.locationId || response.locationEstimate.locationId !== locationId) {
|
|
194
|
+
return {
|
|
195
|
+
type: "error",
|
|
196
|
+
code: "location_mismatch",
|
|
197
|
+
message: "Matched a different location than requested.",
|
|
198
|
+
details: `expected=${locationId}, got=${(_a = response.locationEstimate.locationId) != null ? _a : "undefined"}`
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
type: "success",
|
|
203
|
+
position: {
|
|
204
|
+
latitude: response.locationEstimate.latitude,
|
|
205
|
+
longitude: response.locationEstimate.longitude
|
|
206
|
+
},
|
|
207
|
+
floorId: response.locationEstimate.floorId,
|
|
208
|
+
headingDegrees: normalizeHeadingDegrees(response.locationEstimate.headingDegrees)
|
|
209
|
+
};
|
|
210
|
+
};
|
|
211
|
+
var estimatePosition = (frames, locationId, gpsPosition, options) => __async(null, null, function* () {
|
|
212
|
+
var _a, _b, _c, _d;
|
|
213
|
+
if (options == null ? void 0 : options.mock) {
|
|
214
|
+
yield new Promise((resolve) => setTimeout(resolve, 500));
|
|
215
|
+
const config = options.mock === true ? {} : options.mock;
|
|
216
|
+
return {
|
|
217
|
+
type: "success",
|
|
218
|
+
position: (_a = config.position) != null ? _a : gpsPosition,
|
|
219
|
+
floorId: (_b = config.floorId) != null ? _b : "mock-floor-1",
|
|
220
|
+
headingDegrees: (_c = config.headingDegrees) != null ? _c : 45
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const validationError = validateInputs(frames, locationId, gpsPosition);
|
|
224
|
+
if (validationError) return validationError;
|
|
225
|
+
const environment = (_d = options == null ? void 0 : options.environment) != null ? _d : 4 /* PROD_PUBLIC */;
|
|
226
|
+
try {
|
|
227
|
+
const req = yield buildRequest(frames, gpsPosition, environment);
|
|
228
|
+
const transport = createGrpcWebTransport({ baseUrl: getBaseUrl(environment) });
|
|
229
|
+
const client = createClient(LandseerAPIService, transport);
|
|
230
|
+
const response = yield client.locationEstimate(req, {
|
|
231
|
+
signal: AbortSignal.timeout(3e4)
|
|
232
|
+
});
|
|
233
|
+
return interpretResponse(response, locationId);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
return {
|
|
236
|
+
type: "error",
|
|
237
|
+
code: "api_error",
|
|
238
|
+
message: "Network or transport error during localisation.",
|
|
239
|
+
details: err instanceof Error ? err.message : String(err)
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
export {
|
|
244
|
+
HyperlocalEnvironment,
|
|
245
|
+
MIN_LOCALISATION_IMAGE_COUNT,
|
|
246
|
+
estimatePosition
|
|
247
|
+
};
|
|
248
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../node_modules/@bindimaps/landseer/bmproto/landseer/landseer_pb.js","../src/index.ts","../src/image-selection.ts"],"sourcesContent":["// @generated by protoc-gen-es v2.9.0\n// @generated from file bmproto/landseer/landseer.proto (package bmproto.landseer, syntax proto3)\n/* eslint-disable */\n\nimport { enumDesc, fileDesc, messageDesc, serviceDesc, tsEnum } from \"@bufbuild/protobuf/codegenv2\";\nimport { file_google_protobuf_wrappers } from \"@bufbuild/protobuf/wkt\";\n\n/**\n * Describes the file bmproto/landseer/landseer.proto.\n */\nexport const file_bmproto_landseer_landseer = /*@__PURE__*/\n fileDesc(\"Ch9ibXByb3RvL2xhbmRzZWVyL2xhbmRzZWVyLnByb3RvEhBibXByb3RvLmxhbmRzZWVyIhAKDkhlYWx0aHpSZXF1ZXN0IiQKD0hlYWx0aHpSZXNwb25zZRIRCgl0aW1lc3RhbXAYASABKAMiIgoEUG9zZRIMCgR0dmVjGAEgAygCEgwKBHF2ZWMYAiADKAIiSAoRUGluaG9sZUludHJpbnNpY3MSCQoBZhgBIAMoAhIJCgFjGAIgAygCEg4KBmhlaWdodBgDIAEoDRINCgV3aWR0aBgEIAEoDSJDCg1EZWx0YUVuY29kaW5nEg8KB2luaXRpYWwYASABKAESDgoGZGVsdGFzGAIgAygREhEKCXByZWNpc2lvbhgDIAEoDSLUAQoQQWNjZWxlcm9tZXRlckxvZxIzCgp1bml4TWlsbGlzGAEgASgLMh8uYm1wcm90by5sYW5kc2Vlci5EZWx0YUVuY29kaW5nEi0KBHhNczIYAiABKAsyHy5ibXByb3RvLmxhbmRzZWVyLkRlbHRhRW5jb2RpbmcSLQoEeU1zMhgDIAEoCzIfLmJtcHJvdG8ubGFuZHNlZXIuRGVsdGFFbmNvZGluZxItCgR6TXMyGAQgASgLMh8uYm1wcm90by5sYW5kc2Vlci5EZWx0YUVuY29kaW5nIt0BCgdHeXJvTG9nEjMKCnVuaXhNaWxsaXMYASABKAsyHy5ibXByb3RvLmxhbmRzZWVyLkRlbHRhRW5jb2RpbmcSMwoKeFJhZFBlclNlYxgCIAEoCzIfLmJtcHJvdG8ubGFuZHNlZXIuRGVsdGFFbmNvZGluZxIzCgp5UmFkUGVyU2VjGAMgASgLMh8uYm1wcm90by5sYW5kc2Vlci5EZWx0YUVuY29kaW5nEjMKCnpSYWRQZXJTZWMYBCABKAsyHy5ibXByb3RvLmxhbmRzZWVyLkRlbHRhRW5jb2Rpbmci8gEKC1JvdGF0aW9uTG9nEjMKCnVuaXhNaWxsaXMYASABKAsyHy5ibXByb3RvLmxhbmRzZWVyLkRlbHRhRW5jb2RpbmcSKgoBeBgCIAEoCzIfLmJtcHJvdG8ubGFuZHNlZXIuRGVsdGFFbmNvZGluZxIqCgF5GAMgASgLMh8uYm1wcm90by5sYW5kc2Vlci5EZWx0YUVuY29kaW5nEioKAXoYBCABKAsyHy5ibXByb3RvLmxhbmRzZWVyLkRlbHRhRW5jb2RpbmcSKgoBdxgFIAEoCzIfLmJtcHJvdG8ubGFuZHNlZXIuRGVsdGFFbmNvZGluZyLKAwoFRnJhbWUSCgoCdHMYASABKAMSEQoJaW1hZ2VEYXRhGAIgASgMEigKCHNsYW1Qb3NlGAMgASgLMhYuYm1wcm90by5sYW5kc2Vlci5Qb3NlEjcKCmludHJpbnNpY3MYBCABKAsyIy5ibXByb3RvLmxhbmRzZWVyLlBpbmhvbGVJbnRyaW5zaWNzEhgKDG5vcnRoRGVncmVlcxgFIAEoAkICGAESOQoUbm9ydGhEZWdyZWVzRXN0aW1hdGUYBiABKAsyGy5nb29nbGUucHJvdG9idWYuRmxvYXRWYWx1ZRIwCgtwcmVzc3VyZUhQYRgHIAEoCzIbLmdvb2dsZS5wcm90b2J1Zi5GbG9hdFZhbHVlEjYKEHByZWRpY3RlZEZsb29ySWQYCCABKAsyHC5nb29nbGUucHJvdG9idWYuU3RyaW5nVmFsdWUSPQoYcHJlZGljdGVkRmxvb3JDb25maWRlbmNlGAkgASgLMhsuZ29vZ2xlLnByb3RvYnVmLkZsb2F0VmFsdWUSEgoKZnJhbWVJbmRleBgKIAEoDRItCgtncHNMb2NhdGlvbhgLIAEoCzIYLmJtcHJvdG8ubGFuZHNlZXIuTGF0TG9uIrkBCgZMYXRMb24SCwoDbGF0GAEgASgCEgsKA2xvbhgCIAEoAhIgChhob3Jpem9udGFsQWNjdXJhY3lNZXRlcnMYAyABKAISNgoRbXNsQWx0aXR1ZGVNZXRlcnMYBCABKAsyGy5nb29nbGUucHJvdG9idWYuRmxvYXRWYWx1ZRI7ChZ2ZXJ0aWNhbEFjY3VyYWN5TWV0ZXJzGAUgASgLMhsuZ29vZ2xlLnByb3RvYnVmLkZsb2F0VmFsdWUiwAEKCFNsYW1Qb3NlEgoKAnRzGAEgASgEEiQKBHBvc2UYAiABKAsyFi5ibXByb3RvLmxhbmRzZWVyLlBvc2USMQoMbm9ydGhEZWdyZWVzGAMgASgLMhsuZ29vZ2xlLnByb3RvYnVmLkZsb2F0VmFsdWUSMAoLcHJlc3N1cmVIUGEYBCABKAsyGy5nb29nbGUucHJvdG9idWYuRmxvYXRWYWx1ZRIdChVzbGFtQ29vcmRpbmF0ZVNwYWNlSWQYBSABKA0ifgoORGV2aWNlUG9zaXRpb24SCQoBeBgBIAEoAhIJCgF5GAIgASgCEhQKDG5vcnRoRGVncmVlcxgDIAEoAhIgChhob3Jpem9udGFsQWNjdXJhY3lNZXRlcnMYBCABKAISDwoHZmxvb3JJZBgFIAEoCRINCgVsb2NJZBgGIAEoCSJOChFEZXZpY2VQb3NpdGlvbkxvZxIKCgJ0cxgBIAEoBBItCgNwb3MYAiABKAsyIC5ibXByb3RvLmxhbmRzZWVyLkRldmljZVBvc2l0aW9uIkgKC0dwc0xvZ0VudHJ5EgoKAnRzGAEgASgEEi0KC2dwc0xvY2F0aW9uGAIgASgLMhguYm1wcm90by5sYW5kc2Vlci5MYXRMb24i6wUKD0xvY2FsaXNlUmVxdWVzdBIRCglzZXNzaW9uSWQYASABKAkSEwoLc2VxdWVuY2VOdW0YAiABKA0SMAoOZ3BzQ29vcmRpbmF0ZXMYAyABKAsyGC5ibXByb3RvLmxhbmRzZWVyLkxhdExvbhInCgZmcmFtZXMYBCADKAsyFy5ibXByb3RvLmxhbmRzZWVyLkZyYW1lEi0KCXNsYW1Qb3NlcxgFIAMoCzIWLmJtcHJvdG8ubGFuZHNlZXIuUG9zZUICGAESFwoPcGFyZW50U2Vzc2lvbklkGAYgASgJEisKB3NsYW1Mb2cYByADKAsyGi5ibXByb3RvLmxhbmRzZWVyLlNsYW1Qb3NlEjAKCmRldmljZVR5cGUYCCABKA4yHC5ibXByb3RvLmxhbmRzZWVyLkRldmljZVR5cGUSMgoLZW52aXJvbm1lbnQYCSABKA4yHS5ibXByb3RvLmxhbmRzZWVyLkVudmlyb25tZW50Eh0KFXNsYW1Db29yZGluYXRlU3BhY2VJZBgKIAEoDRI+ChFkZXZpY2VQb3NpdGlvbkxvZxgLIAMoCzIjLmJtcHJvdG8ubGFuZHNlZXIuRGV2aWNlUG9zaXRpb25Mb2cSNgoNdHJhbnNmb3JtVHlwZRgMIAEoDjIfLmJtcHJvdG8ubGFuZHNlZXIuVHJhbnNmb3JtVHlwZRItCgZncHNMb2cYDSADKAsyHS5ibXByb3RvLmxhbmRzZWVyLkdwc0xvZ0VudHJ5EjIKBmFjY0xvZxgOIAEoCzIiLmJtcHJvdG8ubGFuZHNlZXIuQWNjZWxlcm9tZXRlckxvZxIqCgdneXJvTG9nGA8gASgLMhkuYm1wcm90by5sYW5kc2Vlci5HeXJvTG9nEi0KBnJvdExvZxgQIAEoCzIdLmJtcHJvdG8ubGFuZHNlZXIuUm90YXRpb25Mb2cSFQoNaXNIZWFsdGhjaGVjaxgRIAEoCBIOCgZpc1NjYW4YEiABKAgiNgoGTWF0cml4EgwKBHJvd3MYASABKA0SDAoEY29scxgCIAEoDRIQCghlbGVtZW50cxgDIAMoASLiAgoQTG9jYWxpc2VSZXNwb25zZRIRCglzZXNzaW9uSWQYASABKAkSEwoLc2VxdWVuY2VOdW0YAiABKA0SKwoLc2xhbUludmVyc2UYAyABKAsyFi5ibXByb3RvLmxhbmRzZWVyLlBvc2USLgoOb3B0aW1pc2VkUG9zZXMYBCADKAsyFi5ibXByb3RvLmxhbmRzZWVyLlBvc2USEgoKbG9jYXRpb25JZBgFIAEoCRIPCgdmbG9vcklkGAYgASgJEi0KC3NsYW1Ub01vZGVsGAcgASgLMhguYm1wcm90by5sYW5kc2Vlci5NYXRyaXgSHgoWY29uZmlkZW5jZVJhZGl1c01ldGVycxgIIAEoAhIdChVzbGFtQ29vcmRpbmF0ZVNwYWNlSWQYCSABKA0SNgoNdHJhbnNmb3JtVHlwZRgKIAEoDjIfLmJtcHJvdG8ubGFuZHNlZXIuVHJhbnNmb3JtVHlwZSKOAQoRU3VibWl0U2NhblJlcXVlc3QSDgoGc2NhbklkGAEgASgJEgoKAnRzGAIgASgDEjAKCmRldmljZVR5cGUYAyABKA4yHC5ibXByb3RvLmxhbmRzZWVyLkRldmljZVR5cGUSKwoKc2NhbkZyYW1lcxgEIAMoCzIXLmJtcHJvdG8ubGFuZHNlZXIuRnJhbWUiJAoSU3VibWl0U2NhblJlc3BvbnNlEg4KBnNjYW5JZBgBIAEoCSJbChlHZXRGbG9vclRyYW5zZm9ybXNSZXF1ZXN0EhIKCmxvY2F0aW9uSWQYASABKAkSKgoDZW52GAIgASgOMh0uYm1wcm90by5sYW5kc2Vlci5FbnZpcm9ubWVudCLnAQoORmxvb3JUcmFuc2Zvcm0SEgoKbG9jYXRpb25JZBgBIAEoCRIPCgdmbG9vcklkGAIgASgJEjIKDG1vZGVsVG9GbG9vchgDIAEoCzIYLmJtcHJvdG8ubGFuZHNlZXIuTWF0cml4QgIYARIXCg9tb2RlbFRvRmxvb3JSb3QYBCADKAISGQoRY29vcmRpbmF0ZVNwYWNlSWQYBSABKAkSDQoFZmxpcFkYBiABKAgSOQoXbW9kZWxUb0Zsb29yVW5jb3JyZWN0ZWQYByABKAsyGC5ibXByb3RvLmxhbmRzZWVyLk1hdHJpeCKXAQoaR2V0Rmxvb3JUcmFuc2Zvcm1zUmVzcG9uc2USEgoKbG9jYXRpb25JZBgBIAEoCRI5Cg9mbG9vclRyYW5zZm9ybXMYAiADKAsyIC5ibXByb3RvLmxhbmRzZWVyLkZsb29yVHJhbnNmb3JtEioKA2VudhgDIAEoDjIdLmJtcHJvdG8ubGFuZHNlZXIuRW52aXJvbm1lbnQiOQoQTG9jYWxpc2VBc3luY0FjaxIRCglzZXNzaW9uSWQYASABKAkSEgoKc2VxdWVuY2VJZBgCIAEoDSKMAQoVTG9jYWxpc2VBc3luY1Jlc3BvbnNlEjEKA2FjaxgBIAEoCzIiLmJtcHJvdG8ubGFuZHNlZXIuTG9jYWxpc2VBc3luY0Fja0gAEjYKCHJlc3BvbnNlGAIgASgLMiIuYm1wcm90by5sYW5kc2Vlci5Mb2NhbGlzZVJlc3BvbnNlSABCCAoGcmVzdWx0IkYKFExvY2FsaXNlQXN5bmNSZXF1ZXN0Ei4KA3JlcRgBIAEoCzIhLmJtcHJvdG8ubGFuZHNlZXIuTG9jYWxpc2VSZXF1ZXN0IkYKFFN0YXJ0TG9jYWxpc2VSZXF1ZXN0Ei4KA3JlcRgBIAEoCzIhLmJtcHJvdG8ubGFuZHNlZXIuTG9jYWxpc2VSZXF1ZXN0Ij4KFVN0YXJ0TG9jYWxpc2VSZXNwb25zZRIRCglzZXNzaW9uSWQYASABKAkSEgoKc2VxdWVuY2VJZBgCIAEoDSK4AQoRTW9kZWxQb3NlRXN0aW1hdGUSDwoHZmxvb3JJZBgBIAEoCRItCg1lc3RpbWF0ZWRQb3NlGAIgASgLMhYuYm1wcm90by5sYW5kc2Vlci5Qb3NlEhYKDmFjY3VyYWN5TWV0ZXJzGAMgASgCEhcKD2FjY3VyYWN5RGVncmVlcxgEIAEoAhISCgpsb2NhdGlvbklkGAUgASgJEh4KFndlaWdodGVkQXZlcmFnZUltYWdlVHMYBiABKAQikgEKD0xvY2FsaXNlUGVuZGluZxIRCglzZXNzaW9uSWQYASABKAkSEgoKc2VxdWVuY2VJZBgCIAEoDRIdChVyZXF1ZXN0UmVjZWl2ZWRVbml4TXMYAyABKAMSOQoMcG9zZUVzdGltYXRlGAQgASgLMiMuYm1wcm90by5sYW5kc2Vlci5Nb2RlbFBvc2VFc3RpbWF0ZSKIAQoNTG9jYWxpc2VFcnJvchIRCglzZXNzaW9uSWQYASABKAkSEgoKc2VxdWVuY2VJZBgCIAEoDRIdChVyZXF1ZXN0UmVjZWl2ZWRVbml4TXMYAyABKAMSGwoTcmVxdWVzdEZhaWxlZFVuaXhNcxgEIAEoAxIUCgxlcnJvck1lc3NhZ2UYBSABKAkiQQoYR2V0TG9jYWxpc2VSZXN1bHRSZXF1ZXN0EhEKCXNlc3Npb25JZBgBIAEoCRISCgpzZXF1ZW5jZUlkGAIgASgNIsUBChlHZXRMb2NhbGlzZVJlc3VsdFJlc3BvbnNlEjQKB3BlbmRpbmcYASABKAsyIS5ibXByb3RvLmxhbmRzZWVyLkxvY2FsaXNlUGVuZGluZ0gAEjAKBWVycm9yGAIgASgLMh8uYm1wcm90by5sYW5kc2Vlci5Mb2NhbGlzZUVycm9ySAASNgoIcmVzcG9uc2UYAyABKAsyIi5ibXByb3RvLmxhbmRzZWVyLkxvY2FsaXNlUmVzcG9uc2VIAEIICgZyZXN1bHQicQoVTG9jYXRpb25Fc3RpbWF0ZUltYWdlEgoKAnRzGAEgASgEEhEKCWltYWdlRGF0YRgCIAEoDBI5ChRub3J0aERlZ3JlZXNFc3RpbWF0ZRgDIAEoCzIbLmdvb2dsZS5wcm90b2J1Zi5GbG9hdFZhbHVlIrYBChdMb2NhdGlvbkVzdGltYXRlUmVxdWVzdBIKCgJ0cxgBIAEoBBIqCgNncHMYAiABKAsyHS5ibXByb3RvLmxhbmRzZWVyLkdwc0xvZ0VudHJ5EjcKBmltYWdlcxgDIAMoCzInLmJtcHJvdG8ubGFuZHNlZXIuTG9jYXRpb25Fc3RpbWF0ZUltYWdlEioKA2VudhgEIAEoDjIdLmJtcHJvdG8ubGFuZHNlZXIuRW52aXJvbm1lbnQimAEKEExvY2F0aW9uRXN0aW1hdGUSCgoCdHMYASABKAQSEgoKbG9jYXRpb25JZBgCIAEoCRIPCgdmbG9vcklkGAMgASgJEhAKCGxhdGl0dWRlGAQgASgBEhEKCWxvbmdpdHVkZRgFIAEoARIWCg5oZWFkaW5nRGVncmVlcxgGIAEoAhIWCg5hY2N1cmFjeU1ldGVycxgHIAEoAiKeAQoYTG9jYXRpb25Fc3RpbWF0ZVJlc3BvbnNlEjwKEGxvY2F0aW9uRXN0aW1hdGUYASABKAsyIi5ibXByb3RvLmxhbmRzZWVyLkxvY2F0aW9uRXN0aW1hdGUSNAoMbG9jYWxpc2VUeXBlGAIgASgOMh4uYm1wcm90by5sYW5kc2Vlci5Mb2NhbGlzZVR5cGUSDgoGZXJyb3JzGAMgAygJKlcKCkRldmljZVR5cGUSGwoXREVWSUNFX1RZUEVfVU5TUEVDSUZJRUQQABITCg9ERVZJQ0VfVFlQRV9JT1MQARIXChNERVZJQ0VfVFlQRV9BTkRST0lEEAIqngEKC0Vudmlyb25tZW50EhsKF0VOVklST05NRU5UX1VOU1BFQ0lGSUVEEAASGwoXRU5WSVJPTk1FTlRfREVWX1BSRVZJRVcQARIaChZFTlZJUk9OTUVOVF9ERVZfUFVCTElDEAISHAoYRU5WSVJPTk1FTlRfUFJPRF9QUkVWSUVXEAMSGwoXRU5WSVJPTk1FTlRfUFJPRF9QVUJMSUMQBCpWCg1UcmFuc2Zvcm1UeXBlEh4KGlRSQU5TRk9STV9UWVBFX1VOU1BFQ0lGSUVEEAASJQohVFJBTlNGT1JNX1RZUEVfTk9fUVVBVEVSTklPTl9GTElQEAEqgQEKDExvY2FsaXNlVHlwZRIdChlMT0NBTElTRV9UWVBFX1VOU1BFQ0lGSUVEEAASGAoUTE9DQUxJU0VfVFlQRV9GQUlMRUQQARIVChFMT0NBTElTRV9UWVBFX0dQUxACEiEKHUxPQ0FMSVNFX1RZUEVfVklTVUFMX0VTVElNQVRFEAMy0wUKEkxhbmRzZWVyQVBJU2VydmljZRJQCgdoZWFsdGh6EiAuYm1wcm90by5sYW5kc2Vlci5IZWFsdGh6UmVxdWVzdBohLmJtcHJvdG8ubGFuZHNlZXIuSGVhbHRoelJlc3BvbnNlIgASUwoIbG9jYWxpc2USIS5ibXByb3RvLmxhbmRzZWVyLkxvY2FsaXNlUmVxdWVzdBoiLmJtcHJvdG8ubGFuZHNlZXIuTG9jYWxpc2VSZXNwb25zZSIAEmIKDWxvY2FsaXNlQXN5bmMSJi5ibXByb3RvLmxhbmRzZWVyLkxvY2FsaXNlQXN5bmNSZXF1ZXN0GicuYm1wcm90by5sYW5kc2Vlci5Mb2NhbGlzZUFzeW5jUmVzcG9uc2UiABJiCg1zdGFydExvY2FsaXNlEiYuYm1wcm90by5sYW5kc2Vlci5TdGFydExvY2FsaXNlUmVxdWVzdBonLmJtcHJvdG8ubGFuZHNlZXIuU3RhcnRMb2NhbGlzZVJlc3BvbnNlIgASbgoRZ2V0TG9jYWxpc2VSZXN1bHQSKi5ibXByb3RvLmxhbmRzZWVyLkdldExvY2FsaXNlUmVzdWx0UmVxdWVzdBorLmJtcHJvdG8ubGFuZHNlZXIuR2V0TG9jYWxpc2VSZXN1bHRSZXNwb25zZSIAEmsKEGxvY2F0aW9uRXN0aW1hdGUSKS5ibXByb3RvLmxhbmRzZWVyLkxvY2F0aW9uRXN0aW1hdGVSZXF1ZXN0GiouYm1wcm90by5sYW5kc2Vlci5Mb2NhdGlvbkVzdGltYXRlUmVzcG9uc2UiABJxChJnZXRGbG9vclRyYW5zZm9ybXMSKy5ibXByb3RvLmxhbmRzZWVyLkdldEZsb29yVHJhbnNmb3Jtc1JlcXVlc3QaLC5ibXByb3RvLmxhbmRzZWVyLkdldEZsb29yVHJhbnNmb3Jtc1Jlc3BvbnNlIgBCJVojZ2l0aHViLmNvbS9iaW5kaW1hcHMvbGFuZHNlZXItZ29idWZiBnByb3RvMw\", [file_google_protobuf_wrappers]);\n\n/**\n * Describes the message bmproto.landseer.HealthzRequest.\n * Use `create(HealthzRequestSchema)` to create a new message.\n */\nexport const HealthzRequestSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 0);\n\n/**\n * Describes the message bmproto.landseer.HealthzResponse.\n * Use `create(HealthzResponseSchema)` to create a new message.\n */\nexport const HealthzResponseSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 1);\n\n/**\n * Describes the message bmproto.landseer.Pose.\n * Use `create(PoseSchema)` to create a new message.\n */\nexport const PoseSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 2);\n\n/**\n * Describes the message bmproto.landseer.PinholeIntrinsics.\n * Use `create(PinholeIntrinsicsSchema)` to create a new message.\n */\nexport const PinholeIntrinsicsSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 3);\n\n/**\n * Describes the message bmproto.landseer.DeltaEncoding.\n * Use `create(DeltaEncodingSchema)` to create a new message.\n */\nexport const DeltaEncodingSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 4);\n\n/**\n * Describes the message bmproto.landseer.AccelerometerLog.\n * Use `create(AccelerometerLogSchema)` to create a new message.\n */\nexport const AccelerometerLogSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 5);\n\n/**\n * Describes the message bmproto.landseer.GyroLog.\n * Use `create(GyroLogSchema)` to create a new message.\n */\nexport const GyroLogSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 6);\n\n/**\n * Describes the message bmproto.landseer.RotationLog.\n * Use `create(RotationLogSchema)` to create a new message.\n */\nexport const RotationLogSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 7);\n\n/**\n * Describes the message bmproto.landseer.Frame.\n * Use `create(FrameSchema)` to create a new message.\n */\nexport const FrameSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 8);\n\n/**\n * Describes the message bmproto.landseer.LatLon.\n * Use `create(LatLonSchema)` to create a new message.\n */\nexport const LatLonSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 9);\n\n/**\n * Describes the message bmproto.landseer.SlamPose.\n * Use `create(SlamPoseSchema)` to create a new message.\n */\nexport const SlamPoseSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 10);\n\n/**\n * Describes the message bmproto.landseer.DevicePosition.\n * Use `create(DevicePositionSchema)` to create a new message.\n */\nexport const DevicePositionSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 11);\n\n/**\n * Describes the message bmproto.landseer.DevicePositionLog.\n * Use `create(DevicePositionLogSchema)` to create a new message.\n */\nexport const DevicePositionLogSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 12);\n\n/**\n * Describes the message bmproto.landseer.GpsLogEntry.\n * Use `create(GpsLogEntrySchema)` to create a new message.\n */\nexport const GpsLogEntrySchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 13);\n\n/**\n * Describes the message bmproto.landseer.LocaliseRequest.\n * Use `create(LocaliseRequestSchema)` to create a new message.\n */\nexport const LocaliseRequestSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 14);\n\n/**\n * Describes the message bmproto.landseer.Matrix.\n * Use `create(MatrixSchema)` to create a new message.\n */\nexport const MatrixSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 15);\n\n/**\n * Describes the message bmproto.landseer.LocaliseResponse.\n * Use `create(LocaliseResponseSchema)` to create a new message.\n */\nexport const LocaliseResponseSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 16);\n\n/**\n * Describes the message bmproto.landseer.SubmitScanRequest.\n * Use `create(SubmitScanRequestSchema)` to create a new message.\n */\nexport const SubmitScanRequestSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 17);\n\n/**\n * Describes the message bmproto.landseer.SubmitScanResponse.\n * Use `create(SubmitScanResponseSchema)` to create a new message.\n */\nexport const SubmitScanResponseSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 18);\n\n/**\n * Describes the message bmproto.landseer.GetFloorTransformsRequest.\n * Use `create(GetFloorTransformsRequestSchema)` to create a new message.\n */\nexport const GetFloorTransformsRequestSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 19);\n\n/**\n * Describes the message bmproto.landseer.FloorTransform.\n * Use `create(FloorTransformSchema)` to create a new message.\n */\nexport const FloorTransformSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 20);\n\n/**\n * Describes the message bmproto.landseer.GetFloorTransformsResponse.\n * Use `create(GetFloorTransformsResponseSchema)` to create a new message.\n */\nexport const GetFloorTransformsResponseSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 21);\n\n/**\n * Describes the message bmproto.landseer.LocaliseAsyncAck.\n * Use `create(LocaliseAsyncAckSchema)` to create a new message.\n */\nexport const LocaliseAsyncAckSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 22);\n\n/**\n * Describes the message bmproto.landseer.LocaliseAsyncResponse.\n * Use `create(LocaliseAsyncResponseSchema)` to create a new message.\n */\nexport const LocaliseAsyncResponseSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 23);\n\n/**\n * Describes the message bmproto.landseer.LocaliseAsyncRequest.\n * Use `create(LocaliseAsyncRequestSchema)` to create a new message.\n */\nexport const LocaliseAsyncRequestSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 24);\n\n/**\n * Describes the message bmproto.landseer.StartLocaliseRequest.\n * Use `create(StartLocaliseRequestSchema)` to create a new message.\n */\nexport const StartLocaliseRequestSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 25);\n\n/**\n * Describes the message bmproto.landseer.StartLocaliseResponse.\n * Use `create(StartLocaliseResponseSchema)` to create a new message.\n */\nexport const StartLocaliseResponseSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 26);\n\n/**\n * Describes the message bmproto.landseer.ModelPoseEstimate.\n * Use `create(ModelPoseEstimateSchema)` to create a new message.\n */\nexport const ModelPoseEstimateSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 27);\n\n/**\n * Describes the message bmproto.landseer.LocalisePending.\n * Use `create(LocalisePendingSchema)` to create a new message.\n */\nexport const LocalisePendingSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 28);\n\n/**\n * Describes the message bmproto.landseer.LocaliseError.\n * Use `create(LocaliseErrorSchema)` to create a new message.\n */\nexport const LocaliseErrorSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 29);\n\n/**\n * Describes the message bmproto.landseer.GetLocaliseResultRequest.\n * Use `create(GetLocaliseResultRequestSchema)` to create a new message.\n */\nexport const GetLocaliseResultRequestSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 30);\n\n/**\n * Describes the message bmproto.landseer.GetLocaliseResultResponse.\n * Use `create(GetLocaliseResultResponseSchema)` to create a new message.\n */\nexport const GetLocaliseResultResponseSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 31);\n\n/**\n * Describes the message bmproto.landseer.LocationEstimateImage.\n * Use `create(LocationEstimateImageSchema)` to create a new message.\n */\nexport const LocationEstimateImageSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 32);\n\n/**\n * Describes the message bmproto.landseer.LocationEstimateRequest.\n * Use `create(LocationEstimateRequestSchema)` to create a new message.\n */\nexport const LocationEstimateRequestSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 33);\n\n/**\n * Describes the message bmproto.landseer.LocationEstimate.\n * Use `create(LocationEstimateSchema)` to create a new message.\n */\nexport const LocationEstimateSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 34);\n\n/**\n * Describes the message bmproto.landseer.LocationEstimateResponse.\n * Use `create(LocationEstimateResponseSchema)` to create a new message.\n */\nexport const LocationEstimateResponseSchema = /*@__PURE__*/\n messageDesc(file_bmproto_landseer_landseer, 35);\n\n/**\n * Describes the enum bmproto.landseer.DeviceType.\n */\nexport const DeviceTypeSchema = /*@__PURE__*/\n enumDesc(file_bmproto_landseer_landseer, 0);\n\n/**\n * @generated from enum bmproto.landseer.DeviceType\n */\nexport const DeviceType = /*@__PURE__*/\n tsEnum(DeviceTypeSchema);\n\n/**\n * Describes the enum bmproto.landseer.Environment.\n */\nexport const EnvironmentSchema = /*@__PURE__*/\n enumDesc(file_bmproto_landseer_landseer, 1);\n\n/**\n * specifies the CMS environment we will use for localisation\n *\n * @generated from enum bmproto.landseer.Environment\n */\nexport const Environment = /*@__PURE__*/\n tsEnum(EnvironmentSchema);\n\n/**\n * Describes the enum bmproto.landseer.TransformType.\n */\nexport const TransformTypeSchema = /*@__PURE__*/\n enumDesc(file_bmproto_landseer_landseer, 2);\n\n/**\n * @generated from enum bmproto.landseer.TransformType\n */\nexport const TransformType = /*@__PURE__*/\n tsEnum(TransformTypeSchema);\n\n/**\n * Describes the enum bmproto.landseer.LocaliseType.\n */\nexport const LocaliseTypeSchema = /*@__PURE__*/\n enumDesc(file_bmproto_landseer_landseer, 3);\n\n/**\n * @generated from enum bmproto.landseer.LocaliseType\n */\nexport const LocaliseType = /*@__PURE__*/\n tsEnum(LocaliseTypeSchema);\n\n/**\n * @generated from service bmproto.landseer.LandseerAPIService\n */\nexport const LandseerAPIService = /*@__PURE__*/\n serviceDesc(file_bmproto_landseer_landseer, 0);\n\n","/**\n * @bindimaps/hyperlocal-web-sdk\n *\n * SDK for image-based indoor localisation using the Landseer API.\n */\n\nimport type {\n GpsLogEntry,\n LocationEstimateImage,\n LocationEstimateRequest,\n LocationEstimateResponse,\n} from \"@bindimaps/landseer/bmproto/landseer/landseer_pb\"\nimport {\n LandseerAPIService,\n Environment as LandseerEnvironment,\n LocaliseType,\n} from \"@bindimaps/landseer/bmproto/landseer/landseer_pb\"\nimport { createClient } from \"@connectrpc/connect\"\nimport { createGrpcWebTransport } from \"@connectrpc/connect-web\"\nimport { selectDiverseFrames } from \"./image-selection\"\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport type GeoCoordinate = {\n latitude: number\n longitude: number\n}\n\nexport type CapturedFrame = {\n timestamp: Date\n imageData: Blob\n}\n\nexport type PositionEstimate = {\n type: \"success\"\n position: GeoCoordinate\n floorId: string\n headingDegrees: number\n}\n\nexport type PositionErrorCode =\n | \"invalid_input\"\n | \"insufficient_frames\"\n | \"no_match\"\n | \"location_mismatch\"\n | \"api_error\"\n\nexport type PositionError = {\n type: \"error\"\n code: PositionErrorCode\n message: string\n details?: string\n}\n\nexport type PositionResult = PositionEstimate | PositionError\n\nexport type MockPositionConfig =\n | true\n | {\n position?: GeoCoordinate\n floorId?: string\n headingDegrees?: number\n }\n\nexport type EstimatePositionOptions = {\n environment?: HyperlocalEnvironment\n mock?: MockPositionConfig\n}\n\n// ============================================================================\n// Constants\n// ============================================================================\n\nexport const MIN_LOCALISATION_IMAGE_COUNT = 6\nconst USE_DIVERSE_FRAME_SELECTION = false\n\n// Prefixed to avoid clashing with other `Environment` enums of the same name\nexport enum HyperlocalEnvironment {\n UNSPECIFIED = 0,\n DEV_PREVIEW = 1,\n DEV_PUBLIC = 2,\n PROD_PREVIEW = 3,\n PROD_PUBLIC = 4,\n}\n\n// ============================================================================\n// Internal Helpers\n// ============================================================================\n\nconst toLandseerEnv = (env: HyperlocalEnvironment): LandseerEnvironment => {\n const map: Record<HyperlocalEnvironment, LandseerEnvironment> = {\n [HyperlocalEnvironment.UNSPECIFIED]: LandseerEnvironment.UNSPECIFIED,\n [HyperlocalEnvironment.DEV_PREVIEW]: LandseerEnvironment.DEV_PREVIEW,\n [HyperlocalEnvironment.DEV_PUBLIC]: LandseerEnvironment.DEV_PUBLIC,\n [HyperlocalEnvironment.PROD_PREVIEW]: LandseerEnvironment.PROD_PREVIEW,\n [HyperlocalEnvironment.PROD_PUBLIC]: LandseerEnvironment.PROD_PUBLIC,\n }\n return map[env]\n}\n\nconst blobToUint8Array = async (blob: Blob): Promise<Uint8Array> => {\n const arrayBuffer = await blob.arrayBuffer()\n return new Uint8Array(arrayBuffer)\n}\n\nconst getBaseUrl = (environment: HyperlocalEnvironment): string =>\n (environment === HyperlocalEnvironment.PROD_PUBLIC || environment === HyperlocalEnvironment.PROD_PREVIEW)\n ? \"https://landseer.bindimaps.app\"\n : \"https://landseer.bindi.dev\"\n\nconst isValidGpsPosition = (gpsPosition: unknown): gpsPosition is GeoCoordinate => {\n if (!gpsPosition || typeof gpsPosition !== \"object\") return false\n const maybeGps = gpsPosition as { latitude?: unknown, longitude?: unknown }\n\n return (\n Number.isFinite(maybeGps.latitude) &&\n Number.isFinite(maybeGps.longitude) &&\n Number(maybeGps.latitude) >= -90 && Number(maybeGps.latitude) <= 90 &&\n Number(maybeGps.longitude) >= -180 && Number(maybeGps.longitude) <= 180\n )\n}\n\nconst normalizeHeadingDegrees = (headingDegrees: unknown): number =>\n Number.isFinite(headingDegrees) ? Number(headingDegrees) : 0\n\nconst validateInputs = (\n frames: CapturedFrame[],\n locationId: string,\n gpsPosition: GeoCoordinate,\n): PositionError | null => {\n if (typeof locationId !== \"string\" || locationId.trim().length === 0) {\n return {\n type: \"error\",\n code: \"invalid_input\",\n message: \"locationId must be a non-empty string.\",\n }\n }\n\n if (!isValidGpsPosition(gpsPosition)) {\n return {\n type: \"error\",\n code: \"invalid_input\",\n message: \"gpsPosition coordinates are out of range (lat: -90..90, lng: -180..180).\",\n }\n }\n\n if (!Array.isArray(frames)) {\n return {\n type: \"error\",\n code: \"invalid_input\",\n message: \"frames must be an array.\",\n }\n }\n\n if (frames.length < MIN_LOCALISATION_IMAGE_COUNT) {\n return {\n type: \"error\",\n code: \"insufficient_frames\",\n message: `At least ${MIN_LOCALISATION_IMAGE_COUNT} frames are required for localisation.`,\n details: `Received ${frames.length} frames, required ${MIN_LOCALISATION_IMAGE_COUNT}.`,\n }\n }\n\n return null\n}\n\nconst buildRequest = async (\n frames: CapturedFrame[],\n gpsPosition: GeoCoordinate,\n environment: HyperlocalEnvironment,\n): Promise<LocationEstimateRequest> => {\n const selectedFrames = USE_DIVERSE_FRAME_SELECTION\n ? await selectDiverseFrames(frames, MIN_LOCALISATION_IMAGE_COUNT)\n : frames\n\n const now = new Date()\n const ts = BigInt(now.getTime())\n\n const gps: GpsLogEntry = {\n gpsLocation: {\n lat: gpsPosition.latitude,\n lon: gpsPosition.longitude,\n horizontalAccuracyMeters: 0,\n mslAltitudeMeters: 0,\n verticalAccuracyMeters: 0,\n $typeName: \"bmproto.landseer.LatLon\",\n },\n ts,\n $typeName: \"bmproto.landseer.GpsLogEntry\",\n }\n\n const images: LocationEstimateImage[] = await Promise.all(\n selectedFrames.map(async (img) => ({\n ts: BigInt(img.timestamp.getTime()),\n imageData: await blobToUint8Array(img.imageData),\n $typeName: \"bmproto.landseer.LocationEstimateImage\",\n }))\n )\n\n return {\n ts,\n gps,\n images,\n env: toLandseerEnv(environment),\n $typeName: \"bmproto.landseer.LocationEstimateRequest\",\n }\n}\n\nconst interpretResponse = (response: LocationEstimateResponse, locationId: string): PositionResult => {\n if (\n response.errors.length > 0 ||\n response.localiseType === LocaliseType.FAILED ||\n !response.locationEstimate ||\n !response.locationEstimate.floorId ||\n !Number.isFinite(response.locationEstimate.latitude) ||\n !Number.isFinite(response.locationEstimate.longitude)\n ) {\n return {\n type: \"error\",\n code: \"no_match\",\n message: response.errors.length > 0 ? response.errors.join(\", \") : \"Localisation failed — no match found.\",\n details: response.errors.length > 0 ? response.errors.join(\"; \") : `localiseType=${response.localiseType}`,\n }\n }\n\n if (!response.locationEstimate.locationId || response.locationEstimate.locationId !== locationId) {\n return {\n type: \"error\",\n code: \"location_mismatch\",\n message: \"Matched a different location than requested.\",\n details: `expected=${locationId}, got=${response.locationEstimate.locationId ?? \"undefined\"}`,\n }\n }\n\n return {\n type: \"success\",\n position: {\n latitude: response.locationEstimate.latitude,\n longitude: response.locationEstimate.longitude,\n },\n floorId: response.locationEstimate.floorId,\n headingDegrees: normalizeHeadingDegrees(response.locationEstimate.headingDegrees),\n }\n}\n\n// ============================================================================\n// API\n// ============================================================================\n\n/**\n * Estimate a user's indoor position from captured camera frames and GPS.\n *\n * @param frames - Captured camera frames (minimum 6)\n * @param locationId - The Bindi location ID to localise within\n * @param gpsPosition - Approximate GPS coordinates\n * @param options - Optional configuration (environment, mock mode)\n */\nexport const estimatePosition = async (\n frames: CapturedFrame[],\n locationId: string,\n gpsPosition: GeoCoordinate,\n options?: EstimatePositionOptions,\n): Promise<PositionResult> => {\n // Mock mode — early return before any validation/protobuf/gRPC\n if (options?.mock) {\n await new Promise(resolve => setTimeout(resolve, 500))\n const config = options.mock === true ? {} : options.mock\n return {\n type: \"success\",\n position: config.position ?? gpsPosition,\n floorId: config.floorId ?? \"mock-floor-1\",\n headingDegrees: config.headingDegrees ?? 45,\n }\n }\n\n const validationError = validateInputs(frames, locationId, gpsPosition)\n if (validationError) return validationError\n\n const environment = options?.environment ?? HyperlocalEnvironment.PROD_PUBLIC\n\n try {\n const req = await buildRequest(frames, gpsPosition, environment)\n const transport = createGrpcWebTransport({ baseUrl: getBaseUrl(environment) })\n const client = createClient(LandseerAPIService, transport)\n const response = await client.locationEstimate(req, {\n signal: AbortSignal.timeout(30_000),\n })\n return interpretResponse(response, locationId)\n } catch (err) {\n return {\n type: \"error\",\n code: \"api_error\",\n message: \"Network or transport error during localisation.\",\n details: err instanceof Error ? err.message : String(err),\n }\n }\n}\n","import type { CapturedFrame } from \"./index\"\n\n/**\n * Downsamples an image to a small grayscale thumbnail for diversity comparison.\n */\nconst getImageThumbnail = async (blob: Blob, size = 16): Promise<Uint8Array> => {\n const bitmap = await createImageBitmap(blob, { resizeWidth: size, resizeHeight: size, resizeQuality: \"low\" })\n const canvas = document.createElement(\"canvas\")\n canvas.width = size\n canvas.height = size\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) throw new Error(\"Could not get canvas context\")\n ctx.drawImage(bitmap, 0, 0)\n bitmap.close()\n const imageData = ctx.getImageData(0, 0, size, size).data\n const grayscale = new Uint8Array(size * size)\n for (let i = 0; i < imageData.length; i += 4) {\n grayscale[i / 4] = (imageData[i] + imageData[i + 1] + imageData[i + 2]) / 3\n }\n return grayscale\n}\n\n/**\n * Calculates the Euclidean distance between two thumbnails.\n */\nconst calculateDistance = (a: Uint8Array, b: Uint8Array): number => {\n let sum = 0\n for (let i = 0; i < a.length; i++) {\n const diff = a[i] - b[i]\n sum += diff * diff\n }\n return Math.sqrt(sum)\n}\n\n/**\n * Selects the most diverse frames from a set of candidates.\n * Uses a greedy \"max-min\" distance algorithm.\n * O(n·k) greedy, suitable for <100 candidates. For larger inputs consider a spatial index.\n */\nexport const selectDiverseFrames = async (\n frames: CapturedFrame[],\n count: number\n): Promise<CapturedFrame[]> => {\n if (frames.length <= count) return frames\n\n const thumbnails = await Promise.all(frames.map(f => getImageThumbnail(f.imageData)))\n const selectedIndices = new Set<number>([0])\n\n while (selectedIndices.size < count) {\n let maxMinDist = -1\n let bestCandidateIdx = -1\n\n for (let i = 0; i < frames.length; i++) {\n if (selectedIndices.has(i)) continue\n\n let minDist = Infinity\n for (const selectedIdx of selectedIndices) {\n const dist = calculateDistance(thumbnails[i], thumbnails[selectedIdx])\n if (dist < minDist) minDist = dist\n }\n\n if (minDist > maxMinDist) {\n maxMinDist = minDist\n bestCandidateIdx = i\n }\n }\n\n if (bestCandidateIdx === -1) break\n selectedIndices.add(bestCandidateIdx)\n }\n\n return [...selectedIndices]\n .sort((a, b) => a - b)\n .map(idx => frames[idx])\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAIA,SAAS,UAAU,UAAU,aAAa,aAAa,cAAc;AACrE,SAAS,qCAAqC;AAKvC,IAAM,iCACX,yBAAS,kiSAAkiS,CAAC,6BAA6B,CAAC;AA6QrkS,IAAM,oBACX,yBAAS,gCAAgC,CAAC;AAOrC,IAAM,cACX,uBAAO,iBAAiB;AAiBnB,IAAM,qBACX,yBAAS,gCAAgC,CAAC;AAKrC,IAAM,eACX,uBAAO,kBAAkB;AAKpB,IAAM,qBACX,4BAAY,gCAAgC,CAAC;;;AC9S/C,SAAS,oBAAoB;AAC7B,SAAS,8BAA8B;;;ACbvC,IAAM,oBAAoB,CAAO,MAAY,OAAO,OAA4B;AAC5E,QAAM,SAAS,MAAM,kBAAkB,MAAM,EAAE,aAAa,MAAM,cAAc,MAAM,eAAe,MAAM,CAAC;AAC5G,QAAM,SAAS,SAAS,cAAc,QAAQ;AAC9C,SAAO,QAAQ;AACf,SAAO,SAAS;AAChB,QAAM,MAAM,OAAO,WAAW,IAAI;AAClC,MAAI,CAAC,IAAK,OAAM,IAAI,MAAM,8BAA8B;AACxD,MAAI,UAAU,QAAQ,GAAG,CAAC;AAC1B,SAAO,MAAM;AACb,QAAM,YAAY,IAAI,aAAa,GAAG,GAAG,MAAM,IAAI,EAAE;AACrD,QAAM,YAAY,IAAI,WAAW,OAAO,IAAI;AAC5C,WAAS,IAAI,GAAG,IAAI,UAAU,QAAQ,KAAK,GAAG;AAC1C,cAAU,IAAI,CAAC,KAAK,UAAU,CAAC,IAAI,UAAU,IAAI,CAAC,IAAI,UAAU,IAAI,CAAC,KAAK;AAAA,EAC9E;AACA,SAAO;AACX;AAKA,IAAM,oBAAoB,CAAC,GAAe,MAA0B;AAChE,MAAI,MAAM;AACV,WAAS,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK;AAC/B,UAAM,OAAO,EAAE,CAAC,IAAI,EAAE,CAAC;AACvB,WAAO,OAAO;AAAA,EAClB;AACA,SAAO,KAAK,KAAK,GAAG;AACxB;AAOO,IAAM,sBAAsB,CAC/B,QACA,UAC2B;AAC3B,MAAI,OAAO,UAAU,MAAO,QAAO;AAEnC,QAAM,aAAa,MAAM,QAAQ,IAAI,OAAO,IAAI,OAAK,kBAAkB,EAAE,SAAS,CAAC,CAAC;AACpF,QAAM,kBAAkB,oBAAI,IAAY,CAAC,CAAC,CAAC;AAE3C,SAAO,gBAAgB,OAAO,OAAO;AACjC,QAAI,aAAa;AACjB,QAAI,mBAAmB;AAEvB,aAAS,IAAI,GAAG,IAAI,OAAO,QAAQ,KAAK;AACpC,UAAI,gBAAgB,IAAI,CAAC,EAAG;AAE5B,UAAI,UAAU;AACd,iBAAW,eAAe,iBAAiB;AACvC,cAAM,OAAO,kBAAkB,WAAW,CAAC,GAAG,WAAW,WAAW,CAAC;AACrE,YAAI,OAAO,QAAS,WAAU;AAAA,MAClC;AAEA,UAAI,UAAU,YAAY;AACtB,qBAAa;AACb,2BAAmB;AAAA,MACvB;AAAA,IACJ;AAEA,QAAI,qBAAqB,GAAI;AAC7B,oBAAgB,IAAI,gBAAgB;AAAA,EACxC;AAEA,SAAO,CAAC,GAAG,eAAe,EACrB,KAAK,CAAC,GAAG,MAAM,IAAI,CAAC,EACpB,IAAI,SAAO,OAAO,GAAG,CAAC;AAC/B;;;ADCO,IAAM,+BAA+B;AAC5C,IAAM,8BAA8B;AAG7B,IAAK,wBAAL,kBAAKA,2BAAL;AACH,EAAAA,8CAAA,iBAAc,KAAd;AACA,EAAAA,8CAAA,iBAAc,KAAd;AACA,EAAAA,8CAAA,gBAAa,KAAb;AACA,EAAAA,8CAAA,kBAAe,KAAf;AACA,EAAAA,8CAAA,iBAAc,KAAd;AALQ,SAAAA;AAAA,GAAA;AAYZ,IAAM,gBAAgB,CAAC,QAAoD;AACvE,QAAM,MAA0D;AAAA,IAC5D,CAAC,mBAAiC,GAAG,YAAoB;AAAA,IACzD,CAAC,mBAAiC,GAAG,YAAoB;AAAA,IACzD,CAAC,kBAAgC,GAAG,YAAoB;AAAA,IACxD,CAAC,oBAAkC,GAAG,YAAoB;AAAA,IAC1D,CAAC,mBAAiC,GAAG,YAAoB;AAAA,EAC7D;AACA,SAAO,IAAI,GAAG;AAClB;AAEA,IAAM,mBAAmB,CAAO,SAAoC;AAChE,QAAM,cAAc,MAAM,KAAK,YAAY;AAC3C,SAAO,IAAI,WAAW,WAAW;AACrC;AAEA,IAAM,aAAa,CAAC,gBACf,gBAAgB,uBAAqC,gBAAgB,uBAChE,mCACA;AAEV,IAAM,qBAAqB,CAAC,gBAAuD;AAC/E,MAAI,CAAC,eAAe,OAAO,gBAAgB,SAAU,QAAO;AAC5D,QAAM,WAAW;AAEjB,SACI,OAAO,SAAS,SAAS,QAAQ,KACjC,OAAO,SAAS,SAAS,SAAS,KAClC,OAAO,SAAS,QAAQ,KAAK,OAAO,OAAO,SAAS,QAAQ,KAAK,MACjE,OAAO,SAAS,SAAS,KAAK,QAAQ,OAAO,SAAS,SAAS,KAAK;AAE5E;AAEA,IAAM,0BAA0B,CAAC,mBAC7B,OAAO,SAAS,cAAc,IAAI,OAAO,cAAc,IAAI;AAE/D,IAAM,iBAAiB,CACnB,QACA,YACA,gBACuB;AACvB,MAAI,OAAO,eAAe,YAAY,WAAW,KAAK,EAAE,WAAW,GAAG;AAClE,WAAO;AAAA,MACH,MAAM;AAAA,MACN,MAAM;AAAA,MACN,SAAS;AAAA,IACb;AAAA,EACJ;AAEA,MAAI,CAAC,mBAAmB,WAAW,GAAG;AAClC,WAAO;AAAA,MACH,MAAM;AAAA,MACN,MAAM;AAAA,MACN,SAAS;AAAA,IACb;AAAA,EACJ;AAEA,MAAI,CAAC,MAAM,QAAQ,MAAM,GAAG;AACxB,WAAO;AAAA,MACH,MAAM;AAAA,MACN,MAAM;AAAA,MACN,SAAS;AAAA,IACb;AAAA,EACJ;AAEA,MAAI,OAAO,SAAS,8BAA8B;AAC9C,WAAO;AAAA,MACH,MAAM;AAAA,MACN,MAAM;AAAA,MACN,SAAS,YAAY,4BAA4B;AAAA,MACjD,SAAS,YAAY,OAAO,MAAM,qBAAqB,4BAA4B;AAAA,IACvF;AAAA,EACJ;AAEA,SAAO;AACX;AAEA,IAAM,eAAe,CACjB,QACA,aACA,gBACmC;AACnC,QAAM,iBAAiB,8BACjB,MAAM,oBAAoB,QAAQ,4BAA4B,IAC9D;AAEN,QAAM,MAAM,oBAAI,KAAK;AACrB,QAAM,KAAK,OAAO,IAAI,QAAQ,CAAC;AAE/B,QAAM,MAAmB;AAAA,IACrB,aAAa;AAAA,MACT,KAAK,YAAY;AAAA,MACjB,KAAK,YAAY;AAAA,MACjB,0BAA0B;AAAA,MAC1B,mBAAmB;AAAA,MACnB,wBAAwB;AAAA,MACxB,WAAW;AAAA,IACf;AAAA,IACA;AAAA,IACA,WAAW;AAAA,EACf;AAEA,QAAM,SAAkC,MAAM,QAAQ;AAAA,IAClD,eAAe,IAAI,CAAO,QAAK;AAAI;AAAA,QAC/B,IAAI,OAAO,IAAI,UAAU,QAAQ,CAAC;AAAA,QAClC,WAAW,MAAM,iBAAiB,IAAI,SAAS;AAAA,QAC/C,WAAW;AAAA,MACf;AAAA,MAAE;AAAA,EACN;AAEA,SAAO;AAAA,IACH;AAAA,IACA;AAAA,IACA;AAAA,IACA,KAAK,cAAc,WAAW;AAAA,IAC9B,WAAW;AAAA,EACf;AACJ;AAEA,IAAM,oBAAoB,CAAC,UAAoC,eAAuC;AAlNtG;AAmNI,MACI,SAAS,OAAO,SAAS,KACzB,SAAS,iBAAiB,aAAa,UACvC,CAAC,SAAS,oBACV,CAAC,SAAS,iBAAiB,WAC3B,CAAC,OAAO,SAAS,SAAS,iBAAiB,QAAQ,KACnD,CAAC,OAAO,SAAS,SAAS,iBAAiB,SAAS,GACtD;AACE,WAAO;AAAA,MACH,MAAM;AAAA,MACN,MAAM;AAAA,MACN,SAAS,SAAS,OAAO,SAAS,IAAI,SAAS,OAAO,KAAK,IAAI,IAAI;AAAA,MACnE,SAAS,SAAS,OAAO,SAAS,IAAI,SAAS,OAAO,KAAK,IAAI,IAAI,gBAAgB,SAAS,YAAY;AAAA,IAC5G;AAAA,EACJ;AAEA,MAAI,CAAC,SAAS,iBAAiB,cAAc,SAAS,iBAAiB,eAAe,YAAY;AAC9F,WAAO;AAAA,MACH,MAAM;AAAA,MACN,MAAM;AAAA,MACN,SAAS;AAAA,MACT,SAAS,YAAY,UAAU,UAAS,cAAS,iBAAiB,eAA1B,YAAwC,WAAW;AAAA,IAC/F;AAAA,EACJ;AAEA,SAAO;AAAA,IACH,MAAM;AAAA,IACN,UAAU;AAAA,MACN,UAAU,SAAS,iBAAiB;AAAA,MACpC,WAAW,SAAS,iBAAiB;AAAA,IACzC;AAAA,IACA,SAAS,SAAS,iBAAiB;AAAA,IACnC,gBAAgB,wBAAwB,SAAS,iBAAiB,cAAc;AAAA,EACpF;AACJ;AAcO,IAAM,mBAAmB,CAC5B,QACA,YACA,aACA,YAC0B;AAxQ9B;AA0QI,MAAI,mCAAS,MAAM;AACf,UAAM,IAAI,QAAQ,aAAW,WAAW,SAAS,GAAG,CAAC;AACrD,UAAM,SAAS,QAAQ,SAAS,OAAO,CAAC,IAAI,QAAQ;AACpD,WAAO;AAAA,MACH,MAAM;AAAA,MACN,WAAU,YAAO,aAAP,YAAmB;AAAA,MAC7B,UAAS,YAAO,YAAP,YAAkB;AAAA,MAC3B,iBAAgB,YAAO,mBAAP,YAAyB;AAAA,IAC7C;AAAA,EACJ;AAEA,QAAM,kBAAkB,eAAe,QAAQ,YAAY,WAAW;AACtE,MAAI,gBAAiB,QAAO;AAE5B,QAAM,eAAc,wCAAS,gBAAT,YAAwB;AAE5C,MAAI;AACA,UAAM,MAAM,MAAM,aAAa,QAAQ,aAAa,WAAW;AAC/D,UAAM,YAAY,uBAAuB,EAAE,SAAS,WAAW,WAAW,EAAE,CAAC;AAC7E,UAAM,SAAS,aAAa,oBAAoB,SAAS;AACzD,UAAM,WAAW,MAAM,OAAO,iBAAiB,KAAK;AAAA,MAChD,QAAQ,YAAY,QAAQ,GAAM;AAAA,IACtC,CAAC;AACD,WAAO,kBAAkB,UAAU,UAAU;AAAA,EACjD,SAAS,KAAK;AACV,WAAO;AAAA,MACH,MAAM;AAAA,MACN,MAAM;AAAA,MACN,SAAS;AAAA,MACT,SAAS,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,IAC5D;AAAA,EACJ;AACJ;","names":["HyperlocalEnvironment"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"//": "@bindimaps/landseer is a devDependency because tsup bundles it into dist — it must NOT be a runtime dependency in the published package",
|
|
3
|
+
"name": "@bindimaps/hyperlocal-web-sdk",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "BindiMaps Hyperlocal Web SDK for indoor localisation",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"development": "./src/index.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"start": "tsc -w",
|
|
23
|
+
"build": "tsup",
|
|
24
|
+
"type-check": "tsc --noEmit",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"prepublishOnly": "yarn build"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"bindimaps",
|
|
30
|
+
"hyperlocal",
|
|
31
|
+
"indoor-positioning",
|
|
32
|
+
"navigation",
|
|
33
|
+
"sdk"
|
|
34
|
+
],
|
|
35
|
+
"author": "BindiMaps",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public",
|
|
39
|
+
"registry": "https://registry.npmjs.org"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@bufbuild/protobuf": "^2.9.0",
|
|
43
|
+
"@connectrpc/connect": "^2.1.0",
|
|
44
|
+
"@connectrpc/connect-web": "^2.1.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@bindimaps/eslint-config": "workspace:*",
|
|
48
|
+
"@bindimaps/landseer": "^0.0.65",
|
|
49
|
+
"@bindimaps/tsconfig": "workspace:*",
|
|
50
|
+
"tsup": "^8.4.0",
|
|
51
|
+
"typescript": "^5.9.3",
|
|
52
|
+
"vitest": "^2.1.8"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { CapturedFrame } from "./index"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Downsamples an image to a small grayscale thumbnail for diversity comparison.
|
|
5
|
+
*/
|
|
6
|
+
const getImageThumbnail = async (blob: Blob, size = 16): Promise<Uint8Array> => {
|
|
7
|
+
const bitmap = await createImageBitmap(blob, { resizeWidth: size, resizeHeight: size, resizeQuality: "low" })
|
|
8
|
+
const canvas = document.createElement("canvas")
|
|
9
|
+
canvas.width = size
|
|
10
|
+
canvas.height = size
|
|
11
|
+
const ctx = canvas.getContext("2d")
|
|
12
|
+
if (!ctx) throw new Error("Could not get canvas context")
|
|
13
|
+
ctx.drawImage(bitmap, 0, 0)
|
|
14
|
+
bitmap.close()
|
|
15
|
+
const imageData = ctx.getImageData(0, 0, size, size).data
|
|
16
|
+
const grayscale = new Uint8Array(size * size)
|
|
17
|
+
for (let i = 0; i < imageData.length; i += 4) {
|
|
18
|
+
grayscale[i / 4] = (imageData[i] + imageData[i + 1] + imageData[i + 2]) / 3
|
|
19
|
+
}
|
|
20
|
+
return grayscale
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Calculates the Euclidean distance between two thumbnails.
|
|
25
|
+
*/
|
|
26
|
+
const calculateDistance = (a: Uint8Array, b: Uint8Array): number => {
|
|
27
|
+
let sum = 0
|
|
28
|
+
for (let i = 0; i < a.length; i++) {
|
|
29
|
+
const diff = a[i] - b[i]
|
|
30
|
+
sum += diff * diff
|
|
31
|
+
}
|
|
32
|
+
return Math.sqrt(sum)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Selects the most diverse frames from a set of candidates.
|
|
37
|
+
* Uses a greedy "max-min" distance algorithm.
|
|
38
|
+
* O(n·k) greedy, suitable for <100 candidates. For larger inputs consider a spatial index.
|
|
39
|
+
*/
|
|
40
|
+
export const selectDiverseFrames = async (
|
|
41
|
+
frames: CapturedFrame[],
|
|
42
|
+
count: number
|
|
43
|
+
): Promise<CapturedFrame[]> => {
|
|
44
|
+
if (frames.length <= count) return frames
|
|
45
|
+
|
|
46
|
+
const thumbnails = await Promise.all(frames.map(f => getImageThumbnail(f.imageData)))
|
|
47
|
+
const selectedIndices = new Set<number>([0])
|
|
48
|
+
|
|
49
|
+
while (selectedIndices.size < count) {
|
|
50
|
+
let maxMinDist = -1
|
|
51
|
+
let bestCandidateIdx = -1
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i < frames.length; i++) {
|
|
54
|
+
if (selectedIndices.has(i)) continue
|
|
55
|
+
|
|
56
|
+
let minDist = Infinity
|
|
57
|
+
for (const selectedIdx of selectedIndices) {
|
|
58
|
+
const dist = calculateDistance(thumbnails[i], thumbnails[selectedIdx])
|
|
59
|
+
if (dist < minDist) minDist = dist
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (minDist > maxMinDist) {
|
|
63
|
+
maxMinDist = minDist
|
|
64
|
+
bestCandidateIdx = i
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (bestCandidateIdx === -1) break
|
|
69
|
+
selectedIndices.add(bestCandidateIdx)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return [...selectedIndices]
|
|
73
|
+
.sort((a, b) => a - b)
|
|
74
|
+
.map(idx => frames[idx])
|
|
75
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bindimaps/hyperlocal-web-sdk
|
|
3
|
+
*
|
|
4
|
+
* SDK for image-based indoor localisation using the Landseer API.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
GpsLogEntry,
|
|
9
|
+
LocationEstimateImage,
|
|
10
|
+
LocationEstimateRequest,
|
|
11
|
+
LocationEstimateResponse,
|
|
12
|
+
} from "@bindimaps/landseer/bmproto/landseer/landseer_pb"
|
|
13
|
+
import {
|
|
14
|
+
LandseerAPIService,
|
|
15
|
+
Environment as LandseerEnvironment,
|
|
16
|
+
LocaliseType,
|
|
17
|
+
} from "@bindimaps/landseer/bmproto/landseer/landseer_pb"
|
|
18
|
+
import { createClient } from "@connectrpc/connect"
|
|
19
|
+
import { createGrpcWebTransport } from "@connectrpc/connect-web"
|
|
20
|
+
import { selectDiverseFrames } from "./image-selection"
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// Types
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
export type GeoCoordinate = {
|
|
27
|
+
latitude: number
|
|
28
|
+
longitude: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type CapturedFrame = {
|
|
32
|
+
timestamp: Date
|
|
33
|
+
imageData: Blob
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type PositionEstimate = {
|
|
37
|
+
type: "success"
|
|
38
|
+
position: GeoCoordinate
|
|
39
|
+
floorId: string
|
|
40
|
+
headingDegrees: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type PositionErrorCode =
|
|
44
|
+
| "invalid_input"
|
|
45
|
+
| "insufficient_frames"
|
|
46
|
+
| "no_match"
|
|
47
|
+
| "location_mismatch"
|
|
48
|
+
| "api_error"
|
|
49
|
+
|
|
50
|
+
export type PositionError = {
|
|
51
|
+
type: "error"
|
|
52
|
+
code: PositionErrorCode
|
|
53
|
+
message: string
|
|
54
|
+
details?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type PositionResult = PositionEstimate | PositionError
|
|
58
|
+
|
|
59
|
+
export type MockPositionConfig =
|
|
60
|
+
| true
|
|
61
|
+
| {
|
|
62
|
+
position?: GeoCoordinate
|
|
63
|
+
floorId?: string
|
|
64
|
+
headingDegrees?: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type EstimatePositionOptions = {
|
|
68
|
+
environment?: HyperlocalEnvironment
|
|
69
|
+
mock?: MockPositionConfig
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ============================================================================
|
|
73
|
+
// Constants
|
|
74
|
+
// ============================================================================
|
|
75
|
+
|
|
76
|
+
export const MIN_LOCALISATION_IMAGE_COUNT = 6
|
|
77
|
+
const USE_DIVERSE_FRAME_SELECTION = false
|
|
78
|
+
|
|
79
|
+
// Prefixed to avoid clashing with other `Environment` enums of the same name
|
|
80
|
+
export enum HyperlocalEnvironment {
|
|
81
|
+
UNSPECIFIED = 0,
|
|
82
|
+
DEV_PREVIEW = 1,
|
|
83
|
+
DEV_PUBLIC = 2,
|
|
84
|
+
PROD_PREVIEW = 3,
|
|
85
|
+
PROD_PUBLIC = 4,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// Internal Helpers
|
|
90
|
+
// ============================================================================
|
|
91
|
+
|
|
92
|
+
const toLandseerEnv = (env: HyperlocalEnvironment): LandseerEnvironment => {
|
|
93
|
+
const map: Record<HyperlocalEnvironment, LandseerEnvironment> = {
|
|
94
|
+
[HyperlocalEnvironment.UNSPECIFIED]: LandseerEnvironment.UNSPECIFIED,
|
|
95
|
+
[HyperlocalEnvironment.DEV_PREVIEW]: LandseerEnvironment.DEV_PREVIEW,
|
|
96
|
+
[HyperlocalEnvironment.DEV_PUBLIC]: LandseerEnvironment.DEV_PUBLIC,
|
|
97
|
+
[HyperlocalEnvironment.PROD_PREVIEW]: LandseerEnvironment.PROD_PREVIEW,
|
|
98
|
+
[HyperlocalEnvironment.PROD_PUBLIC]: LandseerEnvironment.PROD_PUBLIC,
|
|
99
|
+
}
|
|
100
|
+
return map[env]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const blobToUint8Array = async (blob: Blob): Promise<Uint8Array> => {
|
|
104
|
+
const arrayBuffer = await blob.arrayBuffer()
|
|
105
|
+
return new Uint8Array(arrayBuffer)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const getBaseUrl = (environment: HyperlocalEnvironment): string =>
|
|
109
|
+
(environment === HyperlocalEnvironment.PROD_PUBLIC || environment === HyperlocalEnvironment.PROD_PREVIEW)
|
|
110
|
+
? "https://landseer.bindimaps.app"
|
|
111
|
+
: "https://landseer.bindi.dev"
|
|
112
|
+
|
|
113
|
+
const isValidGpsPosition = (gpsPosition: unknown): gpsPosition is GeoCoordinate => {
|
|
114
|
+
if (!gpsPosition || typeof gpsPosition !== "object") return false
|
|
115
|
+
const maybeGps = gpsPosition as { latitude?: unknown, longitude?: unknown }
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
Number.isFinite(maybeGps.latitude) &&
|
|
119
|
+
Number.isFinite(maybeGps.longitude) &&
|
|
120
|
+
Number(maybeGps.latitude) >= -90 && Number(maybeGps.latitude) <= 90 &&
|
|
121
|
+
Number(maybeGps.longitude) >= -180 && Number(maybeGps.longitude) <= 180
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const normalizeHeadingDegrees = (headingDegrees: unknown): number =>
|
|
126
|
+
Number.isFinite(headingDegrees) ? Number(headingDegrees) : 0
|
|
127
|
+
|
|
128
|
+
const validateInputs = (
|
|
129
|
+
frames: CapturedFrame[],
|
|
130
|
+
locationId: string,
|
|
131
|
+
gpsPosition: GeoCoordinate,
|
|
132
|
+
): PositionError | null => {
|
|
133
|
+
if (typeof locationId !== "string" || locationId.trim().length === 0) {
|
|
134
|
+
return {
|
|
135
|
+
type: "error",
|
|
136
|
+
code: "invalid_input",
|
|
137
|
+
message: "locationId must be a non-empty string.",
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!isValidGpsPosition(gpsPosition)) {
|
|
142
|
+
return {
|
|
143
|
+
type: "error",
|
|
144
|
+
code: "invalid_input",
|
|
145
|
+
message: "gpsPosition coordinates are out of range (lat: -90..90, lng: -180..180).",
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!Array.isArray(frames)) {
|
|
150
|
+
return {
|
|
151
|
+
type: "error",
|
|
152
|
+
code: "invalid_input",
|
|
153
|
+
message: "frames must be an array.",
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (frames.length < MIN_LOCALISATION_IMAGE_COUNT) {
|
|
158
|
+
return {
|
|
159
|
+
type: "error",
|
|
160
|
+
code: "insufficient_frames",
|
|
161
|
+
message: `At least ${MIN_LOCALISATION_IMAGE_COUNT} frames are required for localisation.`,
|
|
162
|
+
details: `Received ${frames.length} frames, required ${MIN_LOCALISATION_IMAGE_COUNT}.`,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const buildRequest = async (
|
|
170
|
+
frames: CapturedFrame[],
|
|
171
|
+
gpsPosition: GeoCoordinate,
|
|
172
|
+
environment: HyperlocalEnvironment,
|
|
173
|
+
): Promise<LocationEstimateRequest> => {
|
|
174
|
+
const selectedFrames = USE_DIVERSE_FRAME_SELECTION
|
|
175
|
+
? await selectDiverseFrames(frames, MIN_LOCALISATION_IMAGE_COUNT)
|
|
176
|
+
: frames
|
|
177
|
+
|
|
178
|
+
const now = new Date()
|
|
179
|
+
const ts = BigInt(now.getTime())
|
|
180
|
+
|
|
181
|
+
const gps: GpsLogEntry = {
|
|
182
|
+
gpsLocation: {
|
|
183
|
+
lat: gpsPosition.latitude,
|
|
184
|
+
lon: gpsPosition.longitude,
|
|
185
|
+
horizontalAccuracyMeters: 0,
|
|
186
|
+
mslAltitudeMeters: 0,
|
|
187
|
+
verticalAccuracyMeters: 0,
|
|
188
|
+
$typeName: "bmproto.landseer.LatLon",
|
|
189
|
+
},
|
|
190
|
+
ts,
|
|
191
|
+
$typeName: "bmproto.landseer.GpsLogEntry",
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const images: LocationEstimateImage[] = await Promise.all(
|
|
195
|
+
selectedFrames.map(async (img) => ({
|
|
196
|
+
ts: BigInt(img.timestamp.getTime()),
|
|
197
|
+
imageData: await blobToUint8Array(img.imageData),
|
|
198
|
+
$typeName: "bmproto.landseer.LocationEstimateImage",
|
|
199
|
+
}))
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
ts,
|
|
204
|
+
gps,
|
|
205
|
+
images,
|
|
206
|
+
env: toLandseerEnv(environment),
|
|
207
|
+
$typeName: "bmproto.landseer.LocationEstimateRequest",
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const interpretResponse = (response: LocationEstimateResponse, locationId: string): PositionResult => {
|
|
212
|
+
if (
|
|
213
|
+
response.errors.length > 0 ||
|
|
214
|
+
response.localiseType === LocaliseType.FAILED ||
|
|
215
|
+
!response.locationEstimate ||
|
|
216
|
+
!response.locationEstimate.floorId ||
|
|
217
|
+
!Number.isFinite(response.locationEstimate.latitude) ||
|
|
218
|
+
!Number.isFinite(response.locationEstimate.longitude)
|
|
219
|
+
) {
|
|
220
|
+
return {
|
|
221
|
+
type: "error",
|
|
222
|
+
code: "no_match",
|
|
223
|
+
message: response.errors.length > 0 ? response.errors.join(", ") : "Localisation failed — no match found.",
|
|
224
|
+
details: response.errors.length > 0 ? response.errors.join("; ") : `localiseType=${response.localiseType}`,
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!response.locationEstimate.locationId || response.locationEstimate.locationId !== locationId) {
|
|
229
|
+
return {
|
|
230
|
+
type: "error",
|
|
231
|
+
code: "location_mismatch",
|
|
232
|
+
message: "Matched a different location than requested.",
|
|
233
|
+
details: `expected=${locationId}, got=${response.locationEstimate.locationId ?? "undefined"}`,
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
type: "success",
|
|
239
|
+
position: {
|
|
240
|
+
latitude: response.locationEstimate.latitude,
|
|
241
|
+
longitude: response.locationEstimate.longitude,
|
|
242
|
+
},
|
|
243
|
+
floorId: response.locationEstimate.floorId,
|
|
244
|
+
headingDegrees: normalizeHeadingDegrees(response.locationEstimate.headingDegrees),
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ============================================================================
|
|
249
|
+
// API
|
|
250
|
+
// ============================================================================
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Estimate a user's indoor position from captured camera frames and GPS.
|
|
254
|
+
*
|
|
255
|
+
* @param frames - Captured camera frames (minimum 6)
|
|
256
|
+
* @param locationId - The Bindi location ID to localise within
|
|
257
|
+
* @param gpsPosition - Approximate GPS coordinates
|
|
258
|
+
* @param options - Optional configuration (environment, mock mode)
|
|
259
|
+
*/
|
|
260
|
+
export const estimatePosition = async (
|
|
261
|
+
frames: CapturedFrame[],
|
|
262
|
+
locationId: string,
|
|
263
|
+
gpsPosition: GeoCoordinate,
|
|
264
|
+
options?: EstimatePositionOptions,
|
|
265
|
+
): Promise<PositionResult> => {
|
|
266
|
+
// Mock mode — early return before any validation/protobuf/gRPC
|
|
267
|
+
if (options?.mock) {
|
|
268
|
+
await new Promise(resolve => setTimeout(resolve, 500))
|
|
269
|
+
const config = options.mock === true ? {} : options.mock
|
|
270
|
+
return {
|
|
271
|
+
type: "success",
|
|
272
|
+
position: config.position ?? gpsPosition,
|
|
273
|
+
floorId: config.floorId ?? "mock-floor-1",
|
|
274
|
+
headingDegrees: config.headingDegrees ?? 45,
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const validationError = validateInputs(frames, locationId, gpsPosition)
|
|
279
|
+
if (validationError) return validationError
|
|
280
|
+
|
|
281
|
+
const environment = options?.environment ?? HyperlocalEnvironment.PROD_PUBLIC
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const req = await buildRequest(frames, gpsPosition, environment)
|
|
285
|
+
const transport = createGrpcWebTransport({ baseUrl: getBaseUrl(environment) })
|
|
286
|
+
const client = createClient(LandseerAPIService, transport)
|
|
287
|
+
const response = await client.locationEstimate(req, {
|
|
288
|
+
signal: AbortSignal.timeout(30_000),
|
|
289
|
+
})
|
|
290
|
+
return interpretResponse(response, locationId)
|
|
291
|
+
} catch (err) {
|
|
292
|
+
return {
|
|
293
|
+
type: "error",
|
|
294
|
+
code: "api_error",
|
|
295
|
+
message: "Network or transport error during localisation.",
|
|
296
|
+
details: err instanceof Error ? err.message : String(err),
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|