From 34f6968ffd806a1028271a28a40ae9a876bca37b Mon Sep 17 00:00:00 2001 From: soeroesg Date: Thu, 27 Aug 2026 08:50:23 +0200 Subject: [PATCH] removed the need for default georeference point for every map; removed need for default starting point for MapAligner map view (use Greenwich observatory as starting point of an empty map); validate input before saving map transform; propagate transform-related error messages to the client --- README.md | 7 +- docker-compose.yaml | 9 -- mapaligner/Dockerfile | 18 +--- mapaligner/README.md | 4 - mapaligner/app/page.js | 150 +++++++++++++++++++------- mapaligner/app/viewer.js | 22 ++-- mapbuilder/Backend/.env.example | 4 - mapbuilder/README.md | 4 - maplocalizer/server/hloc_localizer.py | 5 +- maplocalizer/server/main.py | 12 ++- 10 files changed, 139 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index e16d39e..51652ac 100644 --- a/README.md +++ b/README.md @@ -88,11 +88,6 @@ MY_HTTPS_PROXY= MY_USER_ID=1000 MY_GROUP_ID=1000 -# Default reference point in geo coordinates -DEFAULT_LONGITUDE= -DEFAULT_LATITUDE= -DEFAULT_HEIGHT=0 - # Shared folder where maps are stored (default ${HOME}/data/maps) MY_SHARED_MAPS_DIR=${HOME}/data/maps @@ -179,7 +174,7 @@ Once you recorded a sequence, go to the iOS Files app on your phone, find the fi Once you uploaded your image sequence dataset, it will get a unique ID assigned, it gets extracted, the video gets converted to an image sequences, the images get resized and rotated, and a thumbnail gets assigned to the dataset. Now you can start the map building process by pressing the play button. This might take around 10-30 minutes depending on the size of your recording. ### MapAligner -Once the map is built, more buttons will appear in the dataset's GUI. Next, you need to align the map with the world by clicking on the cross arrows icon, which opens the MapAligner with the corresponding map. First you need to define the coarse location by navigation the map view to your mapped street. Open the 'set reference origin' panel and check 'set reference origin with click'. Click on any point that you want to be the reference of the alignment, it can be any point in the neighborhood. Next, perform the map alignment with the gizmo. You can adjust the scale with the sliders in the left side menu. Finally, press the big green save button, this writes the transform to the MapBuilder. +Once the map is built, more buttons will appear in the dataset's GUI. Next, you need to align the map with the world by clicking on the cross arrows icon, which opens the MapAligner with the corresponding map. First you need to define the coarse location by navigation the map view to your mapped street. Open the 'set georeference origin' panel and check 'set georeference origin with click'. Click on any point that you want to be the reference of the alignment, it can be any point in the neighborhood. Next, perform the map alignment with the gizmo. You can adjust the scale with the sliders in the left side menu. Finally, press the big green save button, this writes the transform to the MapBuilder. ### MapLocalizer Once your map is aligned with the world, it is ready to be served in the MapLocalizer. Click on the localization pin icon of the dataset's GUI, which loads the map into the MapLocalizer. This might take up to 30 seconds depending on the map size. Once the map is loaded, you can send GeoPoseRequest queries to the MapLocalizer's `/localize/geopose` endpoint. diff --git a/docker-compose.yaml b/docker-compose.yaml index bc31aea..1c36647 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -29,9 +29,6 @@ services: - AUTH_FUSIONAUTH_ISSUER=${AUTH_FUSIONAUTH_ISSUER} - AUTH_SECRET=${AUTH_SECRET} - MAPLOCALIZER_URL=http://maplocalizer:8000 - - DEFAULT_LONGITUDE=${DEFAULT_LONGITUDE} - - DEFAULT_LATITUDE=${DEFAULT_LATITUDE} - - DEFAULT_HEIGHT=${DEFAULT_HEIGHT} - NODE_TLS_REJECT_UNAUTHORIZED=0 volumes: - ${MY_SHARED_MAPS_DIR}:/uploads @@ -54,9 +51,6 @@ services: args: - HTTP_PROXY=${MY_HTTP_PROXY} - HTTPS_PROXY=${MY_HTTPS_PROXY} - - NEXT_PUBLIC_DEFAULT_LONGITUDE=${DEFAULT_LONGITUDE} - - NEXT_PUBLIC_DEFAULT_LATITUDE=${DEFAULT_LATITUDE} - - NEXT_PUBLIC_DEFAULT_HEIGHT=${DEFAULT_HEIGHT} depends_on: backend: condition: service_healthy @@ -68,9 +62,6 @@ services: - AUTH_URL=${MAPALIGNER_URL} - AUTH_TRUST_HOST=1 - MAPBUILDER_URL=${MAPBUILDER_URL} - - NEXT_PUBLIC_DEFAULT_LONGITUDE=${DEFAULT_LONGITUDE} - - NEXT_PUBLIC_DEFAULT_LATITUDE=${DEFAULT_LATITUDE} - - NEXT_PUBLIC_DEFAULT_HEIGHT=${DEFAULT_HEIGHT} - NODE_TLS_REJECT_UNAUTHORIZED=0 deploy: restart_policy: diff --git a/mapaligner/Dockerfile b/mapaligner/Dockerfile index 9273658..a88c7e0 100644 --- a/mapaligner/Dockerfile +++ b/mapaligner/Dockerfile @@ -42,14 +42,6 @@ COPY . . # Uncomment the following line in case you want to disable telemetry during the build. ENV NEXT_TELEMETRY_DISABLED=1 -ARG NEXT_PUBLIC_DEFAULT_LATITUDE -ARG NEXT_PUBLIC_DEFAULT_LONGITUDE -ARG NEXT_PUBLIC_DEFAULT_HEIGHT - -ENV NEXT_PUBLIC_DEFAULT_LATITUDE=${NEXT_PUBLIC_DEFAULT_LATITUDE} -ENV NEXT_PUBLIC_DEFAULT_LONGITUDE=${NEXT_PUBLIC_DEFAULT_LONGITUDE} -ENV NEXT_PUBLIC_DEFAULT_HEIGHT=${NEXT_PUBLIC_DEFAULT_HEIGHT} - RUN \ if [ -f yarn.lock ]; then yarn run build; \ elif [ -f package-lock.json ]; then npm run build; \ @@ -81,15 +73,7 @@ EXPOSE 3001 ENV PORT=3001 -ARG NEXT_PUBLIC_DEFAULT_LATITUDE -ARG NEXT_PUBLIC_DEFAULT_LONGITUDE -ARG NEXT_PUBLIC_DEFAULT_HEIGHT - -ENV NEXT_PUBLIC_DEFAULT_LATITUDE=${NEXT_PUBLIC_DEFAULT_LATITUDE} -ENV NEXT_PUBLIC_DEFAULT_LONGITUDE=${NEXT_PUBLIC_DEFAULT_LONGITUDE} -ENV NEXT_PUBLIC_DEFAULT_HEIGHT=${NEXT_PUBLIC_DEFAULT_HEIGHT} - # server.js is created by next build from the standalone output # https://nextjs.org/docs/pages/api-reference/config/next-config-js/output ENV HOSTNAME="0.0.0.0" -CMD ["node", "server.js"] \ No newline at end of file +CMD ["node", "server.js"] diff --git a/mapaligner/README.md b/mapaligner/README.md index 58f21c4..6c3ea8a 100644 --- a/mapaligner/README.md +++ b/mapaligner/README.md @@ -42,10 +42,6 @@ AUTH_FUSIONAUTH_ISSUER= # URL of mapbuilder to fetch the maps MAPBUILDER_URL= -# Default start location -NEXT_PUBLIC_DEFAULT_LATITUDE= -NEXT_PUBLIC_DEFAULT_LONGITUDE= -NEXT_PUBLIC_DEFAULT_HEIGHT= ``` 2. Generate Auth.js secret (must be the same for MapBuilder and MapAligner) diff --git a/mapaligner/app/page.js b/mapaligner/app/page.js index db531f3..95a31fb 100644 --- a/mapaligner/app/page.js +++ b/mapaligner/app/page.js @@ -31,7 +31,7 @@ function reshape4x4(seq) { return output; } -/** @typedef {{matrix:number[][], longitude:number, latitude:number, height:number}} Data */ +/** @typedef {{matrix:number[][], longitude:number|null, latitude:number|null, height:number|null}} Data */ /** @typedef {{id: string, name: string, size:number}} DataSet */ @@ -42,13 +42,22 @@ export default function Matcher() { const [pointSize, setPointSize] = useState(1); const [progress, setProgress] = useState(0); - const initialLatitude = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LATITUDE); - const initialLongitude = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LONGITUDE); - const initialHeight = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_HEIGHT ?? "0"); + const [latitude, setLatitude] = useState(NaN); + const [longitude, setLongitude] = useState(NaN); + const [height, setHeight] = useState(NaN); - const [latitude, setLatitude] = useState(initialLatitude); - const [longitude, setLongitude] = useState(initialLongitude); - const [height, setHeight] = useState(initialHeight); + function setGeoreference(nextLatitude, nextLongitude, nextHeight) { + const values = [nextLatitude, nextLongitude, nextHeight]; + if (values.some((value) => !Number.isFinite(value))) { + setLatitude(NaN); + setLongitude(NaN); + setHeight(NaN); + return; + } + setLatitude(nextLatitude); + setLongitude(nextLongitude); + setHeight(nextHeight); + } const [refMouseEnabled, setRefMouseEnabled] = useState(false); @@ -111,12 +120,16 @@ export default function Matcher() { function pickReference(event) { if (refMouseEnabled) { const { lat, lng } = event.lngLat; - setLongitude(lng); - setLatitude(lat); + setGeoreference(lat, lng, Number.isFinite(height) ? height : 0); setRefMouseEnabled(false); } } + function clearReference() { + setGeoreference(NaN, NaN, NaN); + setRefMouseEnabled(false); + } + /*console.log("longitude", longitude); console.log("latitude", latitude); console.log("translation", translation); @@ -148,6 +161,23 @@ export default function Matcher() { console.error("No map selected"); return; } + const geoFields = [latitude, longitude, height]; + const numGeoFieldsSet = geoFields.filter((v) => Number.isFinite(v)).length; + if (numGeoFieldsSet !== 0 && numGeoFieldsSet !== geoFields.length) { + alert( + "Set all of latitude, longitude and height, or clear all three (no geo anchor). " + + "maplocalizer rejects a partially set georeference origin." + ); + return; + } + if (Number.isFinite(latitude) && (latitude < -90 || latitude > 90)) { + alert("Latitude must be between -90 and 90."); + return; + } + if (Number.isFinite(longitude) && (longitude < -180 || longitude > 180)) { + alert("Longitude must be between -180 and 180."); + return; + } console.log(output); const params = new URLSearchParams({ type: selectedMap.type, dataSetId: selectedMap.dataSetId }); const saveQuery = await fetch(`/api/maps/${selectedMap.id}/transform?${params}`, { @@ -180,9 +210,7 @@ export default function Matcher() { credentials: "include", }).then((response) => response.json()); - setLatitude(data.latitude); - setLongitude(data.longitude); - setHeight(data.height); + setGeoreference(data.latitude ?? NaN, data.longitude ?? NaN, data.height ?? NaN); const matrix = new Matrix4().fromArray(data.matrix.flat()).transpose(); const position = new Vector3(); const quaternion = new Quaternion(); @@ -225,21 +253,22 @@ export default function Matcher() { setTranslation={setTranslation} setRotation={setRotation} setScale={setScale} - setLatitude={setLatitude} - setLongitude={setLongitude} + setGeoreference={setGeoreference} />
- Set reference origin + Set georeference origin
+ -
@@ -355,7 +384,7 @@ function LoadFile({ maps, selectedMap, setSelectedMap, progress, modified }) { ); } -function LoadConfig({ setTranslation, setRotation, setScale, setLatitude, setLongitude }) { +function LoadConfig({ setTranslation, setRotation, setScale, setGeoreference }) { const textArea = useRef(null); function load() { /** @type {Data} */ @@ -368,8 +397,7 @@ function LoadConfig({ setTranslation, setRotation, setScale, setLatitude, setLon setTranslation(translation); setRotation(new Euler().setFromQuaternion(quaternion)); setScale(scale.x); - setLatitude(input.latitude); - setLongitude(input.longitude); + setGeoreference(input.latitude ?? NaN, input.longitude ?? NaN, input.height ?? NaN); } return ( <> @@ -390,7 +418,39 @@ function LoadConfig({ setTranslation, setRotation, setScale, setLatitude, setLon ); } -function ReferenceInput({ longitude, latitude, height, setLongitude, setLatitude, setHeight }) { +function ReferenceInput({ + longitude, + latitude, + height, + setGeoreference, +}) { + const [latitudeText, setLatitudeText] = useState(formatReferenceValue(latitude)); + const [longitudeText, setLongitudeText] = useState(formatReferenceValue(longitude)); + const [heightText, setHeightText] = useState(formatReferenceValue(height)); + + useEffect(() => setLatitudeText(formatReferenceValue(latitude)), [latitude]); + useEffect(() => setLongitudeText(formatReferenceValue(longitude)), [longitude]); + useEffect(() => setHeightText(formatReferenceValue(height)), [height]); + + function commitReferenceValues() { + const values = [latitudeText, longitudeText, heightText].map((value) => Number(value)); + if (values.some((value) => !Number.isFinite(value))) { + setGeoreference(NaN, NaN, NaN); + return; + } + if (values[0] < -90 || values[0] > 90) { + alert("Latitude must be between -90 and 90."); + setGeoreference(NaN, NaN, NaN); + return; + } + if (values[1] < -180 || values[1] > 180) { + alert("Longitude must be between -180 and 180."); + setGeoreference(NaN, NaN, NaN); + return; + } + setGeoreference(...values); + } + return ( <>
@@ -398,24 +458,27 @@ function ReferenceInput({ longitude, latitude, height, setLongitude, setLatitude
setLatitude(parseFloat(event.target.value || "0"))} + value={latitudeText} + onChange={(event) => setLatitudeText(event.target.value)} + onBlur={commitReferenceValues} />
Ref. longitude:
setLongitude(parseFloat(event.target.value || "0"))} + value={longitudeText} + onChange={(event) => setLongitudeText(event.target.value)} + onBlur={commitReferenceValues} />
Ref. height:
setHeight(parseFloat(event.target.value || "0"))} + value={heightText} + onChange={(event) => setHeightText(event.target.value)} + onBlur={commitReferenceValues} />
@@ -423,18 +486,27 @@ function ReferenceInput({ longitude, latitude, height, setLongitude, setLatitude ); } -function ReferenceMouse({ enabled, setEnabled }) { +function formatReferenceValue(value) { + return Number.isFinite(value) ? String(value) : "NaN"; +} + +function ReferenceMouse({ enabled, setEnabled, onClear }) { return ( <> - +
+ + +
); } diff --git a/mapaligner/app/viewer.js b/mapaligner/app/viewer.js index 5c03719..a93e875 100644 --- a/mapaligner/app/viewer.js +++ b/mapaligner/app/viewer.js @@ -16,6 +16,10 @@ import { useMap } from "react-three-map"; import { PivotControls } from "./pivotcontrols"; import { MAX_PROGRESS } from "./page"; +// Greenwich Observatory, used as the visual fallback for maps without georeferencing. +const FALLBACK_LATITUDE = 51.47684731776207; +const FALLBACK_LONGITUDE = -0.0005196757942558923; + export default function ThreeJsViewer({ url, latitude, @@ -35,10 +39,16 @@ export default function ThreeJsViewer({ const mapStyleUrl = darkMode ? "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json" : "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; + const mapLatitude = Number.isFinite(latitude) && latitude >= -90 && latitude <= 90 + ? latitude + : FALLBACK_LATITUDE; + const mapLongitude = Number.isFinite(longitude) && longitude >= -180 && longitude <= 180 + ? longitude + : FALLBACK_LONGITUDE; const [viewState, setViewState] = useState({ - longitude, - latitude, + longitude: mapLongitude, + latitude: mapLatitude, zoom: 19, pitch: 30, }); @@ -47,11 +57,11 @@ export default function ThreeJsViewer({ setViewState((viewState) => { return { ...viewState, - longitude, - latitude, + longitude: mapLongitude, + latitude: mapLatitude, }; }); - }, [longitude, latitude]); + }, [mapLongitude, mapLatitude]); return (
@@ -71,7 +81,7 @@ export default function ThreeJsViewer({ ) : ( - + Tuple[bool, str]: mapConfigs[map_id] = map_config localizer = HlocLocalizer(debug=settings.debug) - if not localizer.load_map_transform(transform_path, map_id): + try: + localizer.load_map_transform(transform_path, map_id) + except Exception as ex: del mapConfigs[map_id] - return False, f"Failed to load map transform {map_id}" + return False, f"Failed to load map transform {map_id}: {ex}" localizer.load_map(map_config, map_id=map_id) localizers[map_id] = localizer @@ -201,9 +203,11 @@ async def load_transform(id: str, response: Response): return {"ERROR": f"There is no map loaded with id {id}. Try to load it first."} map_path = allMapIdsAndPaths[id] transform_path = map_path / "transform.json" - if not localizers[id].load_map_transform(transform_path, id): + try: + localizers[id].load_map_transform(transform_path, id) + except Exception as ex: response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - return {"ERROR": f"Failed to load map transform {id}"} + return {"ERROR": f"Failed to load map transform {id}: {ex}"} _touch_map(id) return {"STATUS": f"Successfully updated the transform of map {id}"}