@bindimaps/hyperlocal-web-sdk 0.0.1 → 0.0.4
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/package.json +5 -6
- package/src/image-selection.ts +0 -75
- package/src/index.ts +0 -299
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "@bindimaps/landseer is a devDependency because tsup bundles it into dist — it must NOT be a runtime dependency in the published package",
|
|
3
3
|
"name": "@bindimaps/hyperlocal-web-sdk",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "BindiMaps Hyperlocal Web SDK for indoor localisation",
|
|
7
7
|
"main": "dist/index.js",
|
|
@@ -9,13 +9,12 @@
|
|
|
9
9
|
"exports": {
|
|
10
10
|
".": {
|
|
11
11
|
"types": "./dist/index.d.ts",
|
|
12
|
-
"
|
|
12
|
+
"source": "./src/index.ts",
|
|
13
13
|
"import": "./dist/index.js"
|
|
14
14
|
}
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"dist",
|
|
18
|
-
"src",
|
|
19
18
|
"README.md"
|
|
20
19
|
],
|
|
21
20
|
"scripts": {
|
|
@@ -44,11 +43,11 @@
|
|
|
44
43
|
"@connectrpc/connect-web": "^2.1.0"
|
|
45
44
|
},
|
|
46
45
|
"devDependencies": {
|
|
47
|
-
"@bindimaps/eslint-config": "
|
|
46
|
+
"@bindimaps/eslint-config": "0.0.0",
|
|
48
47
|
"@bindimaps/landseer": "^0.0.65",
|
|
49
|
-
"@bindimaps/tsconfig": "
|
|
48
|
+
"@bindimaps/tsconfig": "0.0.0",
|
|
50
49
|
"tsup": "^8.4.0",
|
|
51
50
|
"typescript": "^5.9.3",
|
|
52
51
|
"vitest": "^2.1.8"
|
|
53
52
|
}
|
|
54
|
-
}
|
|
53
|
+
}
|
package/src/image-selection.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,299 +0,0 @@
|
|
|
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
|
-
}
|