diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md new file mode 100644 index 0000000..4e33709 --- /dev/null +++ b/docs/feat-reference-distances.md @@ -0,0 +1,310 @@ +# Initial idea for new feature of Snap2Map + +## 1. Core Functional Requirements + +* **Manual Reference Distance:** Users can draw a line between two points on the photo/map and assign an explicit metric value (e.g., "This wall is 5 meters"). +* **Derived Measurements:** Once this reference is set, users can measure arbitrary objects in the image (e.g., width of a door), even if those objects had no dimensions in the original plan. +* **GPS Independence:** This feature must work **autonomously**. The user must be able to measure distances immediately after importing an image, *before* setting a single GPS reference pair. +* *Flow:* Start App → Import Map → Define Reference Distance → Measure immediately. + +## 2. Data Prioritization & "Ground Truth" + +* **Reference as "Ground Truth":** The manually entered distance is interpreted as the most reliable truth (weighted higher than GPS data). +* *Rationale:* Users often transcribe precise measurements from architectural plans or blueprints. + + +* **Accuracy Assumption:** The manual input is assumed to be more precise than GPS position data (which inherently fluctuates), despite potential minor clicking errors. +* **Conflict Resolution (Harmonization):** +* The system must harmonize the manual scale (pixels per meter) with the GPS-derived scale. +* In case of divergence between GPS calculations and the manual reference, the manual reference serves to **validate** or constrain the GPS solution (the manual scale takes precedence for local measurements). + +## 3. User Story + +* **Scenario:** A user imports a floor plan or house blueprint. +* **Step 1:** They identify a known dimension on a long wall (e.g., 10m) and mark this as the **Reference Distance**. +* **Step 2:** They want to know the width of the front door (which has no label on the PDF). +* **Step 3:** They measure the door on the image, and the app calculates the width based on the wall's reference scale. + +## 4. Technical Implications + +* **Hybrid State:** The system requires a flexible scaling state. The map scale (meters/pixel) can be defined by: +1. Purely manual reference distance. +2. Purely GPS reference pairs. +3. A combination (where the manual reference acts as an anchor/validator for the GPS fit). + +# Refined implementation plan based on the initial idea and current code base + +## 1. Data Model & State Management + +We need to extend the application state to store the manual reference. + +* **New State Property**: `referenceDistance` + ```javascript + { + p1: { x: number, y: number }, // Pixel coordinates + p2: { x: number, y: number }, // Pixel coordinates + meters: number, // User-defined distance + metersPerPixel: number // Derived scale: meters / distance(p1, p2) + } + ``` + * **Note**: `metersPerPixel` aligns with the backend's `referenceScale` parameter (meters/pixel), which can be passed directly to `calibrateMap()`. +* **Persistence**: This should be added to the `maps` store in `IndexedDB` (or the runtime `state` object in `src/index.js` for the MVP) to persist across sessions. + +## 2. UI/UX Implementation + +### 2.1 Reference Mode ("Set Scale") +* **Entry Point**: A new "Set Scale" button in the map toolbar (icon: ruler). +* **Interaction Flow**: + 1. User taps "Set Scale". + 2. Toast: "Tap start point of known distance". + 3. User taps point A on the photo. + * The user should be able to zoom in on the image while doing these taps, to place the points as accurate as possible. + * So zooming in general via pinch gestures (or mouse wheel on desktop) in the image would be an important UX to have in general in the app (in case it does not exist yet) + 4. Toast: "Tap end point". + 5. User taps point B on the photo. + 6. **Input Dialog**: A prompt appears asking "Enter distance in meters". + 7. **Visual Feedback**: A distinct line (e.g., blue dashed) is drawn between A and B with the label "X m". +* **Edit/Delete**: Tapping the reference line allows editing the value or deleting it. + +### 2.2 Measurement Mode +* **Entry Point**: "Measure" button (icon: tape measure), enabled if a **scale can be determined** from either: + 1. A manual `referenceDistance` (user-defined reference line with known meters). + 2. A successful GPS-based calibration (scale derived from the calibration transform). +* **Scale Source Priority**: When both sources exist, `referenceDistance.metersPerPixel` takes precedence (manual measurement is considered more precise than GPS-derived scale). +* **Scale Source Indicator**: Display a subtle indicator showing the active scale source: + * "📏 Manual scale: 0.05 m/px" (when using referenceDistance) + * "📡 GPS-derived scale: 0.048 m/px" (when using calibration) + * This helps users understand which scale is being used and aids debugging when measurements seem off. +* **Interaction Flow**: + 1. User taps "Measure". + 2. User taps to set a start point of the temporary measure line. (The user can still zoom and move around on the image while doing so) + 3. User taps to set the end point of the temporary measure line. (The user can still zoom and move around on the image while doing so) + 4. **Real-time Feedback**: A label on the line shows the distance in meters, calculated as `pixelDistance * activeMetersPerPixel` (where `activeMetersPerPixel` is sourced from `referenceDistance` if set, otherwise from the GPS calibration). + 5. Afterwards the user can still long press and drag the start and end points of the measure line around on the image to refine their initial placement of these 2 points +* **Multiple Measurements**: + * Allow multiple measurement lines on screen simultaneously for comparison. + * "Pin" button on each measurement to keep it visible (pinned measurements persist until manually cleared). + * "Clear All" button to remove all temporary and pinned measurements at once. + * Each measurement line displays its distance label independently. + +### 2.3 Unit Selection +* **User Preference**: Allow users to select their preferred display unit: + * Meters (m) - default + * Feet (ft) + * Feet and inches (e.g., 5' 6") +* **Internal Storage**: All values stored internally in meters for consistency. +* **Conversion Layer**: Simple display-time conversion: + ```javascript + const METERS_TO_FEET = 3.28084; + + function formatDistance(meters, unit) { + switch (unit) { + case 'ft': return `${(meters * METERS_TO_FEET).toFixed(2)} ft`; + case 'ft-in': { + const totalInches = meters * METERS_TO_FEET * 12; + const feet = Math.floor(totalInches / 12); + const inches = Math.round(totalInches % 12); + return `${feet}' ${inches}"`; + } + default: return `${meters.toFixed(2)} m`; + } + } + ``` +* **Persistence**: Unit preference stored in user settings (localStorage or similar). + +## 3. Logic & Calibration Integration + +### 3.1 GPS Independence (Phase 1) +* The `referenceDistance` allows immediate measurement without any GPS pairs. +* The map remains in "Image Space" (pixels) but with a known scalar for distance. +* This fulfills the requirement: *Start App → Import Map → Define Reference Distance → Measure immediately.* + +### 3.2 Hybrid Calibration (Phase 2) +* **Conflict Resolution Strategy**: "Constrained Similarity". +* **Algorithm**: + 1. **Standard Fit**: Run the existing RANSAC/IRLS to get a candidate model (Similarity/Affine). + 2. **Scale Check**: Calculate the scale of the candidate model ($S_{gps}$). + 3. **Comparison**: Compare $S_{gps}$ with $S_{manual}$ (from reference distance). + 4. **Harmonization**: + * **Similarity (2 pairs)**: If a manual reference exists, we can optionally **force** the scale to $S_{manual}$. This reduces the Similarity transform to finding only Rotation ($R$) and Translation ($t$). + * **Benefit for GPS Visualization**: This significantly stabilizes the user's position on the map. With only 2 GPS points, the scale is extremely sensitive to GPS noise (e.g., a 5m error can drastically zoom the map in/out). Fixing the scale locks the "zoom level" to the trusted manual measurement, leaving GPS to only solve for position and orientation. This prevents the map from "breathing" or jumping in size as the user moves. + * **Affine/Homography (3+ pairs)**: Use $S_{manual}$ as a **validator**. If the local scale of the GPS-derived transform differs significantly (e.g., > 10%) from $S_{manual}$, show a warning: "GPS scale disagrees with manual reference." + 5. **Scale Disagreement Warning (Universal)**: + * When both `referenceDistance` and GPS calibration exist, **always** compare their scales regardless of model type. + * If $|S_{gps} - S_{manual}| / S_{manual} > 0.10$ (10% threshold), display a non-blocking warning: + * "⚠️ Scale mismatch: Manual reference suggests 0.05 m/px, GPS calibration suggests 0.042 m/px (16% difference)" + * This helps users identify potential issues with either the manual reference placement or GPS data quality. + * The warning is informational only—manual scale still takes precedence for measurements. + +## 4. Code Structure Changes + +### Scale Extraction from Calibration + +To support measurement mode when only GPS calibration exists (no manual reference), we need a helper to extract `metersPerPixel` from the calibration result: + +```javascript +/** + * Extracts the scale (meters per pixel) from a calibration result. + * For similarity transforms, scale = sqrt(a² + b²) where matrix is [a, b, tx; -b, a, ty]. + * Note: The calibration matrix maps pixels → geo coordinates, so this gives geo-units/pixel. + * For lat/lon, additional conversion to meters is needed based on latitude. + * + * @param {Object} calibrationResult - Result from calibrateMap() + * @returns {number|null} - Scale in meters per pixel, or null if not extractable + */ +function getMetersPerPixelFromCalibration(calibrationResult) { + if (!calibrationResult || !calibrationResult.matrix) return null; + const { a, b } = calibrationResult.matrix; + const geoUnitsPerPixel = Math.sqrt(a * a + b * b); + // Convert degrees to meters (approximate, using center latitude) + // 1 degree ≈ 111,320 meters at equator, adjusted by cos(lat) + const centerLat = calibrationResult.centerLat || 0; + const metersPerDegree = 111320 * Math.cos(centerLat * Math.PI / 180); + return geoUnitsPerPixel * metersPerDegree; +} +``` + +### Active Scale Resolution + +```javascript +/** + * Determines the active metersPerPixel value based on available sources. + * Priority: manual referenceDistance > GPS calibration + * + * @param {Object} state - Application state + * @returns {{ metersPerPixel: number, source: 'manual' | 'gps' } | null} + */ +function getActiveScale(state) { + if (state.referenceDistance?.metersPerPixel) { + return { metersPerPixel: state.referenceDistance.metersPerPixel, source: 'manual' }; + } + if (state.calibration) { + const scale = getMetersPerPixelFromCalibration(state.calibration); + if (scale) return { metersPerPixel: scale, source: 'gps' }; + } + return null; +} +``` + +### `src/index.js` +* Add `referenceDistance` to `state`. +* Implement `startReferenceMode()` and `startMeasureMode()`. +* **Interaction Handling**: + * Use `L.Marker` with `draggable: true` for the start and end points of the reference/measurement lines. This allows the user to refine the position after the initial tap (as requested in the UX spec). + * Ensure `L.Polyline` connects these markers and updates in real-time during drag events. +* Handle canvas/overlay drawing for the reference line and active measurement line. + +### `src/geo/transformations.js` +* Implement `fitSimilarityFixedScale(pairs, fixedScale)`: + * Standard Procrustes analysis but with $s$ fixed to `fixedScale`. + * Solves for rotation $\theta$ and translation $t_x, t_y$ minimizing the error. + * *Implementation Note*: Reuse the centroid and rotation calculation from `fitSimilarity`. Instead of computing `scale = numerator / denom`, use `scale = fixedScale` and compute translation based on this fixed scale. + +### `src/calibration/calibrator.js` +* Update `calibrateMap` to accept an optional `referenceScale`. +* If `referenceScale` is provided and the model is 'similarity', use `fitSimilarityFixedScale`. + +## 5. Testing Plan + +* **Unit Tests (`src/geo/transformations.test.js`)**: + * Test `fitSimilarityFixedScale` with synthetic data. + * Verify that the resulting transform preserves the input scale exactly. +* **Unit Tests (`src/index.js` - Scale Helpers)**: + * Test `getMetersPerPixelFromCalibration` with various calibration results. + * Test `getActiveScale` priority logic (manual > GPS). + * Test `formatDistance` for all unit types (m, ft, ft-in). +* **Integration Tests**: + * Verify measure tool enabled when `referenceDistance` is set (no GPS). + * Verify measure tool enabled when valid GPS calibration exists (no manual reference). + * Verify measure tool enabled when both sources exist. + * Verify measure tool disabled when neither source exists. + * Verify correct scale source priority: manual reference takes precedence over GPS. + * Verify measurements are accurate based on the active scale source. + * Verify scale disagreement warning appears when scales differ by >10%. + * Verify multiple measurements can be displayed simultaneously. + * Verify unit preference persists across sessions. + +--- + +## 6. Implementation Progress + +### ✅ Phase 1: Backend Math Layer (Completed) + +**Date:** 2025-12-21 + +This phase implements the core math needed to support a fixed reference scale in the calibration pipeline. + +#### `src/geo/transformations.js` +- [x] Implemented `fitSimilarityFixedScale(pairs, fixedScale, weights)` function + - Procrustes analysis with fixed scale parameter + - Solves only for rotation (θ) and translation (tx, ty) + - Supports weighted pairs for IRLS integration +- [x] Exported via module API + +#### `src/geo/transformations.test.js` +- [x] Added 13 comprehensive unit tests: + - Preserves exact fixed scale with known transform + - Uses fixed scale even when data suggests different scale + - Correctly finds rotation when scale is fixed + - Computes correct translation with fixed scale + - Returns null for insufficient pairs + - Returns null for invalid fixed scale (0, NaN, Infinity) + - Returns null for zero total weight + - Respects weights in rotation computation (using 3 pairs) + - 2 pairs produce same rotation regardless of weights (geometric symmetry) + - 3 pairs with one zero weight behaves like 2 pairs + - Handles negative rotation correctly + - Returns null for degenerate coincident pixel points + - Handles 180 degree rotation + +#### `src/calibration/calibrator.js` +- [x] Updated `calibrateMap` to accept optional `userOptions.referenceScale` +- [x] Updated `fitModel` to use `fitSimilarityFixedScale` when `referenceScale` is provided and model is 'similarity' +- [x] Updated `runReweightedFit` to pass `referenceScale` through IRLS pipeline +- [x] Updated `runRansacForKind` to pass `referenceScale` through RANSAC pipeline + +#### `src/calibration/calibrator.test.js` +- [x] Added 3 integration tests: + - `calibrateMap` uses fixed scale when `referenceScale` is provided + - Fixed scale overrides natural GPS-derived scale + - `referenceScale` only affects similarity model, not affine/homography + +**Test Results:** 46 tests pass, 98.37% code coverage + +#### Key Learnings + +**2-Pair Geometric Symmetry Property:** +During test development, we discovered an important mathematical property of `fitSimilarityFixedScale`: + +> With exactly 2 pairs, weights cannot influence the computed rotation angle. + +This occurs because the weighted Procrustes rotation formula uses: +$$\theta = \arctan2\left(\sum w_i(e_y p_x - e_x p_y), \sum w_i(e_x p_x + e_y p_y)\right)$$ + +With only 2 pairs positioned symmetrically around the weighted centroid, the cross and dot contributions maintain equal ratios regardless of weight distribution. This means: +- **2 GPS pairs + fixed scale**: Rotation is fully determined by geometry, weights only affect translation +- **3+ GPS pairs + fixed scale**: Weights properly influence rotation computation + +This property is now documented in the test suite to prevent future confusion. + +--- + +### 🔲 Phase 2: UI Layer (Not Started) + +This phase implements the user-facing features: setting a reference distance and measuring arbitrary distances. + +#### `src/index.js` +- [ ] Add `referenceDistance` to application state +- [ ] Implement "Set Scale" mode (`startReferenceMode()`) + - Two-tap workflow to define reference line + - Input dialog for distance in meters + - Visual feedback with dashed line and label +- [ ] Implement "Measure" mode (`startMeasureMode()`) + - Two-tap workflow to draw measurement line + - Real-time distance calculation using `metersPerPixel` + - Draggable endpoints for refinement +- [ ] Add UI buttons to toolbar +- [ ] Persistence of `referenceDistance` to IndexedDB + + + diff --git a/service-worker.js b/service-worker.js index 59282be..58f4c01 100644 --- a/service-worker.js +++ b/service-worker.js @@ -62,7 +62,7 @@ self.addEventListener('fetch', (event) => { if (response) { return response; } - } catch (error) { + } catch { // network request failed, fall back to cache if possible } @@ -87,7 +87,7 @@ self.addEventListener('fetch', (event) => { try { return await fetchAndUpdate(); - } catch (error) { + } catch { if (cached) { return cached; } diff --git a/src/calibration/calibrator.js b/src/calibration/calibrator.js index da1f43b..7209bb8 100644 --- a/src/calibration/calibrator.js +++ b/src/calibration/calibrator.js @@ -1,6 +1,7 @@ import { computeOrigin, wgs84ToEnu } from '../geo/coordinate.js'; import { fitSimilarity, + fitSimilarityFixedScale, fitAffine, fitHomography, applyTransform, @@ -54,7 +55,10 @@ function sampleUniqueIndexes(randomFn, total, sampleSize) { return Array.from(selected); } -function fitModel(kind, pairs, weights) { +function fitModel(kind, pairs, weights, referenceScale) { + if (kind === 'similarity' && referenceScale != null) { + return fitSimilarityFixedScale(pairs, referenceScale, weights); + } const estimator = MODEL_PREFERENCES[kind].estimator; return estimator(pairs, weights); } @@ -77,12 +81,12 @@ function huberWeight(residual, delta) { return delta / absResidual; } -function runReweightedFit(kind, pairs, options) { +function runReweightedFit(kind, pairs, options, referenceScale) { let weights = Array.from({ length: pairs.length }, () => 1); let model = null; for (let iteration = 0; iteration <= options.irlsIterations; iteration += 1) { - model = fitModel(kind, pairs, weights); + model = fitModel(kind, pairs, weights, referenceScale); if (!model) { return null; } @@ -160,7 +164,7 @@ export function computeAccuracyRing(calibration, gpsAccuracy) { }; } -function runRansacForKind(kind, pairs, options) { +function runRansacForKind(kind, pairs, options, referenceScale) { const { minPairs } = MODEL_PREFERENCES[kind]; if (pairs.length < minPairs) { return null; @@ -172,7 +176,7 @@ function runRansacForKind(kind, pairs, options) { for (let iteration = 0; iteration < iterationBudget; iteration += 1) { const sampleIndexes = sampleUniqueIndexes(options.random, pairs.length, minPairs); const sample = sampleIndexes.map((index) => pairs[index]); - const candidate = fitModel(kind, sample); + const candidate = fitModel(kind, sample, undefined, referenceScale); if (!candidate) { continue; } @@ -188,7 +192,7 @@ function runRansacForKind(kind, pairs, options) { } const inlierPairs = pairs.filter((pair, index) => best.metrics.inliers[index]); - const refined = runReweightedFit(kind, inlierPairs, options); + const refined = runReweightedFit(kind, inlierPairs, options, referenceScale); if (!refined) { return null; } @@ -222,12 +226,13 @@ export function calibrateMap(pairs, userOptions = {}) { const options = { ...DEFAULT_OPTIONS, ...userOptions }; const origin = userOptions.origin || computeOrigin(pairs); const enrichedPairs = createEnrichedPairs(pairs, origin); + const referenceScale = userOptions.referenceScale; const modelKinds = pickModelKinds(enrichedPairs.length); for (let i = 0; i < modelKinds.length; i += 1) { const kind = modelKinds[i]; - const result = runRansacForKind(kind, enrichedPairs, options); + const result = runRansacForKind(kind, enrichedPairs, options, referenceScale); if (result) { const { metrics } = result; const combined = { diff --git a/src/calibration/calibrator.test.js b/src/calibration/calibrator.test.js index 02a636d..e61223b 100644 --- a/src/calibration/calibrator.test.js +++ b/src/calibration/calibrator.test.js @@ -116,6 +116,53 @@ describe('calibrator', () => { expect(result.statusMessage.level).toBe('low'); }); + test('calibrateMap uses fixed scale when referenceScale is provided', () => { + const referenceScale = 0.5; // meters per pixel + const pairs = [ + { pixel: { x: 0, y: 0 }, wgs84: { lat: origin.lat, lon: origin.lon } }, + { pixel: { x: 200, y: 0 }, wgs84: { lat: origin.lat, lon: origin.lon + 0.002 } }, + ]; + const result = calibrateMap(pairs, { origin, iterations: 5, random: makeRandomGenerator(), referenceScale }); + expect(result.status).toBe('ok'); + expect(result.kind).toBe('similarity'); + expect(result.model.scale).toBe(referenceScale); + }); + + test('calibrateMap with referenceScale ignores natural GPS-derived scale', () => { + // Create pairs that would naturally give a scale of ~1.11 m/px + // (based on lon difference of 0.002 degrees ≈ 222m at this latitude, over 200px) + const pairs = [ + { pixel: { x: 0, y: 0 }, wgs84: { lat: origin.lat, lon: origin.lon } }, + { pixel: { x: 200, y: 0 }, wgs84: { lat: origin.lat, lon: origin.lon + 0.002 } }, + ]; + + // First, calibrate without referenceScale to get the natural scale + const naturalResult = calibrateMap(pairs, { origin, iterations: 5, random: makeRandomGenerator() }); + expect(naturalResult.model.scale).toBeGreaterThan(0.5); + + // Now calibrate with a moderately different fixed scale (close enough to still produce inliers) + const fixedScale = naturalResult.model.scale * 0.8; + const fixedResult = calibrateMap(pairs, { origin, iterations: 5, random: makeRandomGenerator(), referenceScale: fixedScale }); + expect(fixedResult.status).toBe('ok'); + expect(fixedResult.model.scale).toBe(fixedScale); + expect(fixedResult.model.scale).not.toBeCloseTo(naturalResult.model.scale); + }); + + test('referenceScale only affects similarity model, not affine or homography', () => { + const referenceScale = 0.5; + // 3 pairs = affine model + const threePairs = [ + { pixel: { x: 0, y: 0 }, wgs84: { lat: origin.lat, lon: origin.lon } }, + { pixel: { x: 100, y: 0 }, wgs84: { lat: origin.lat, lon: origin.lon + 0.001 } }, + { pixel: { x: 0, y: 60 }, wgs84: { lat: origin.lat + 0.0005, lon: origin.lon } }, + ]; + const result = calibrateMap(threePairs, { origin, iterations: 10, random: makeRandomGenerator(), referenceScale }); + expect(result.status).toBe('ok'); + expect(result.kind).toBe('affine'); + // Affine doesn't have a single 'scale' property - it uses a matrix + expect(result.model.matrix).toBeDefined(); + }); + test('calibrateMap selects affine model for three pairs', () => { const pairs = [ { pixel: { x: 0, y: 0 }, wgs84: { lat: origin.lat, lon: origin.lon } }, diff --git a/src/geo/transformations.js b/src/geo/transformations.js index 19988c4..b4f5be3 100644 --- a/src/geo/transformations.js +++ b/src/geo/transformations.js @@ -10,11 +10,7 @@ function ensureWeights(length, weights) { return weights; } -export function fitSimilarity(pairs, weights) { - if (pairs.length < 2) { - return null; - } - +function computeWeightedCentroids(pairs, weights) { const w = ensureWeights(pairs.length, weights); let weightSum = 0; let pixelCentroid = { x: 0, y: 0 }; @@ -33,8 +29,25 @@ export function fitSimilarity(pairs, weights) { return null; } - pixelCentroid = { x: pixelCentroid.x / weightSum, y: pixelCentroid.y / weightSum }; - enuCentroid = { x: enuCentroid.x / weightSum, y: enuCentroid.y / weightSum }; + return { + w, + weightSum, + pixelCentroid: { x: pixelCentroid.x / weightSum, y: pixelCentroid.y / weightSum }, + enuCentroid: { x: enuCentroid.x / weightSum, y: enuCentroid.y / weightSum }, + }; +} + +export function fitSimilarity(pairs, weights) { + if (pairs.length < 2) { + return null; + } + + const centroids = computeWeightedCentroids(pairs, weights); + if (!centroids) { + return null; + } + + const { w, pixelCentroid, enuCentroid } = centroids; // Precompute centered deltas to avoid duplicated code patterns const deltas = pairs.map((p, i) => { @@ -100,6 +113,67 @@ export function fitSimilarity(pairs, weights) { }; } +export function fitSimilarityFixedScale(pairs, fixedScale, weights) { + if (pairs.length < 2) { + return null; + } + + if (!Number.isFinite(fixedScale) || fixedScale <= TOLERANCE) { + return null; + } + + const centroids = computeWeightedCentroids(pairs, weights); + if (!centroids) { + return null; + } + + const { w, pixelCentroid, enuCentroid } = centroids; + + // Compute optimal rotation for the fixed scale + // We minimize sum of w_i * ||(s*R*p_i + t) - e_i||^2 + // With fixed s, the optimal rotation angle is found from: + // theta = atan2(sum(w*(ey*px - ex*py)), sum(w*(ex*px + ey*py))) + let sumCross = 0; + let sumDot = 0; + let pixelVariance = 0; + + for (let i = 0; i < pairs.length; i += 1) { + const weight = w[i]; + const px = pairs[i].pixel.x - pixelCentroid.x; + const py = pairs[i].pixel.y - pixelCentroid.y; + const ex = pairs[i].enu.x - enuCentroid.x; + const ey = pairs[i].enu.y - enuCentroid.y; + + // For fixed scale, the rotation that minimizes error: + // We want to align s*R*p with e + // This gives: theta = atan2(sum(w*(ey*px - ex*py)), sum(w*(ex*px + ey*py))) + sumCross += weight * (ey * px - ex * py); + sumDot += weight * (ex * px + ey * py); + pixelVariance += weight * (px * px + py * py); + } + + // Degenerate case: all pixel points coincide, rotation is undefined + if (Math.abs(pixelVariance) < TOLERANCE) { + return null; + } + + const theta = Math.atan2(sumCross, sumDot); + const cos = Math.cos(theta); + const sin = Math.sin(theta); + + const translationX = enuCentroid.x - fixedScale * (cos * pixelCentroid.x - sin * pixelCentroid.y); + const translationY = enuCentroid.y - fixedScale * (sin * pixelCentroid.x + cos * pixelCentroid.y); + + return { + type: 'similarity', + scale: fixedScale, + rotation: theta, + cos, + sin, + translation: { x: translationX, y: translationY }, + }; +} + function buildNormalEquations(rows, values, variableCount) { const ata = Array.from({ length: variableCount }, () => Array(variableCount).fill(0)); const atb = Array(variableCount).fill(0); @@ -454,6 +528,7 @@ export function averageScaleFromJacobian(jacobian) { const api = { TOLERANCE, fitSimilarity, + fitSimilarityFixedScale, fitAffine, fitHomography, applyTransform, diff --git a/src/geo/transformations.test.js b/src/geo/transformations.test.js index 4be3c7e..ddfd7d9 100644 --- a/src/geo/transformations.test.js +++ b/src/geo/transformations.test.js @@ -1,5 +1,6 @@ import { fitSimilarity, + fitSimilarityFixedScale, fitAffine, fitHomography, applyTransform, @@ -175,4 +176,193 @@ describe('transformations', () => { expect(jacobianForTransform({ type: 'unsupported' }, { x: 0, y: 0 })).toBeNull(); expect(averageScaleFromJacobian(null)).toBeNull(); }); + + describe('fitSimilarityFixedScale', () => { + test('preserves exact fixed scale with known transform', () => { + const fixedScale = 5; + const theta = Math.PI / 6; + const cos = Math.cos(theta); + const sin = Math.sin(theta); + const translation = { x: 100, y: -40 }; + const pairs = [ + { pixel: { x: 0, y: 0 }, enu: { x: translation.x, y: translation.y } }, + { pixel: { x: 10, y: 0 }, enu: { x: translation.x + fixedScale * (cos * 10), y: translation.y + fixedScale * (sin * 10) } }, + { pixel: { x: 0, y: 10 }, enu: { x: translation.x + fixedScale * (-sin * 10), y: translation.y + fixedScale * (cos * 10) } }, + ]; + const transform = fitSimilarityFixedScale(pairs, fixedScale); + expect(transform.scale).toBe(fixedScale); + expect(transform.rotation).toBeCloseTo(theta); + expect(transform.translation.x).toBeCloseTo(translation.x); + expect(transform.translation.y).toBeCloseTo(translation.y); + }); + + test('uses fixed scale even when data suggests different scale', () => { + // Data that would naturally fit scale=2, but we force scale=5 + const pairs = [ + { pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } }, + { pixel: { x: 10, y: 0 }, enu: { x: 20, y: 0 } }, + { pixel: { x: 0, y: 10 }, enu: { x: 0, y: 20 } }, + ]; + const freeTransform = fitSimilarity(pairs); + expect(freeTransform.scale).toBeCloseTo(2); + + const fixedTransform = fitSimilarityFixedScale(pairs, 5); + expect(fixedTransform.scale).toBe(5); + }); + + test('correctly finds rotation when scale is fixed', () => { + const fixedScale = 3; + const theta = Math.PI / 4; + const cos = Math.cos(theta); + const sin = Math.sin(theta); + const pairs = [ + { pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } }, + { pixel: { x: 10, y: 0 }, enu: { x: fixedScale * (cos * 10), y: fixedScale * (sin * 10) } }, + ]; + const transform = fitSimilarityFixedScale(pairs, fixedScale); + expect(transform.rotation).toBeCloseTo(theta); + }); + + test('computes correct translation with fixed scale', () => { + const fixedScale = 2; + const pairs = [ + { pixel: { x: 5, y: 5 }, enu: { x: 100, y: 200 } }, + { pixel: { x: 15, y: 5 }, enu: { x: 120, y: 200 } }, + ]; + const transform = fitSimilarityFixedScale(pairs, fixedScale); + expect(transform.scale).toBe(fixedScale); + const mapped = applyTransform(transform, { x: 5, y: 5 }); + expect(mapped.x).toBeCloseTo(100); + expect(mapped.y).toBeCloseTo(200); + }); + + test('returns null for insufficient pairs', () => { + expect(fitSimilarityFixedScale([{ pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } }], 5)).toBeNull(); + expect(fitSimilarityFixedScale([], 5)).toBeNull(); + }); + + test('returns null for invalid fixed scale', () => { + const pairs = [ + { pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } }, + { pixel: { x: 10, y: 0 }, enu: { x: 20, y: 0 } }, + ]; + expect(fitSimilarityFixedScale(pairs, 0)).toBeNull(); + expect(fitSimilarityFixedScale(pairs, NaN)).toBeNull(); + expect(fitSimilarityFixedScale(pairs, Infinity)).toBeNull(); + expect(fitSimilarityFixedScale(pairs, -1)).toBeNull(); + expect(fitSimilarityFixedScale(pairs, -0.5)).toBeNull(); + }); + + test('returns null for zero total weight', () => { + const pairs = [ + { pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } }, + { pixel: { x: 10, y: 0 }, enu: { x: 20, y: 0 } }, + ]; + expect(fitSimilarityFixedScale(pairs, 5, [0, 0])).toBeNull(); + }); + + test('respects weights in rotation computation', () => { + // With 2 pairs, cross/dot contributions are geometrically symmetric, + // so we use 3 pairs to properly test weighting influence on rotation. + // p0: anchor at origin + // p1: suggests rotation 0 (pixel +x maps to enu +x) + // p2: suggests rotation 90deg (pixel +y maps to enu -x) + const pairs = [ + { pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } }, + { pixel: { x: 10, y: 0 }, enu: { x: 10, y: 0 } }, + { pixel: { x: 0, y: 10 }, enu: { x: -10, y: 0 } }, + ]; + + // With equal weights, rotation should be ~45deg + const uniformTransform = fitSimilarityFixedScale(pairs, 1, [1, 1, 1]); + expect(uniformTransform.rotation).toBeCloseTo(Math.PI / 4); + + // With more weight on p1 (rotation 0), result should be closer to 0 + const weightedTowardZero = fitSimilarityFixedScale(pairs, 1, [1, 10, 0.1]); + expect(weightedTowardZero.rotation).toBeLessThan(Math.PI / 4); + expect(weightedTowardZero.rotation).toBeGreaterThan(0); + + // With more weight on p2 (rotation 90deg), result should be closer to PI/2 + const weightedToward90 = fitSimilarityFixedScale(pairs, 1, [1, 0.1, 10]); + expect(weightedToward90.rotation).toBeGreaterThan(Math.PI / 4); + expect(weightedToward90.rotation).toBeLessThan(Math.PI / 2); + }); + + test('2 pairs produce same rotation regardless of weights due to geometric symmetry', () => { + // This documents the mathematical property discovered during testing: + // With exactly 2 pairs, the cross/dot contributions are always equal, + // so weights cannot influence the rotation result. + const pairs = [ + { pixel: { x: 10, y: 0 }, enu: { x: 10, y: 0 } }, + { pixel: { x: 0, y: 10 }, enu: { x: -10, y: 0 } }, + ]; + + const uniform = fitSimilarityFixedScale(pairs, 1, [1, 1]); + const heavyFirst = fitSimilarityFixedScale(pairs, 1, [100, 1]); + const heavySecond = fitSimilarityFixedScale(pairs, 1, [1, 100]); + + // All should produce the same rotation due to 2-pair symmetry + expect(uniform.rotation).toBeCloseTo(heavyFirst.rotation); + expect(uniform.rotation).toBeCloseTo(heavySecond.rotation); + }); + + test('3 pairs with one zero weight behaves like 2 pairs', () => { + const pairs = [ + { pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } }, + { pixel: { x: 10, y: 0 }, enu: { x: 10, y: 0 } }, + { pixel: { x: 0, y: 10 }, enu: { x: -10, y: 0 } }, + ]; + + // With third pair zeroed out, should match 2-pair result + const twoPairs = [pairs[0], pairs[1]]; + const twoPairResult = fitSimilarityFixedScale(twoPairs, 1, [1, 1]); + const threePairZeroWeight = fitSimilarityFixedScale(pairs, 1, [1, 1, 0]); + + expect(threePairZeroWeight.rotation).toBeCloseTo(twoPairResult.rotation); + expect(threePairZeroWeight.translation.x).toBeCloseTo(twoPairResult.translation.x); + expect(threePairZeroWeight.translation.y).toBeCloseTo(twoPairResult.translation.y); + }); + + test('handles negative rotation correctly', () => { + // Rotation of -45deg (or equivalently 315deg) + const fixedScale = 2; + const theta = -Math.PI / 4; + const cos = Math.cos(theta); + const sin = Math.sin(theta); + const pairs = [ + { pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } }, + { pixel: { x: 10, y: 0 }, enu: { x: fixedScale * (cos * 10), y: fixedScale * (sin * 10) } }, + { pixel: { x: 0, y: 10 }, enu: { x: fixedScale * (-sin * 10), y: fixedScale * (cos * 10) } }, + ]; + + const transform = fitSimilarityFixedScale(pairs, fixedScale); + expect(transform.rotation).toBeCloseTo(theta); + expect(transform.scale).toBe(fixedScale); + }); + + test('returns null for degenerate coincident pixel points', () => { + // All pixel points on same location - cannot determine rotation + const pairs = [ + { pixel: { x: 5, y: 5 }, enu: { x: 0, y: 0 } }, + { pixel: { x: 5, y: 5 }, enu: { x: 10, y: 10 } }, + ]; + + // When pixel points coincide, rotation is mathematically undefined + // The function should return null to indicate invalid input + const transform = fitSimilarityFixedScale(pairs, 1); + expect(transform).toBeNull(); + }); + + test('handles 180 degree rotation', () => { + const fixedScale = 1; + const pairs = [ + { pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } }, + { pixel: { x: 10, y: 0 }, enu: { x: -10, y: 0 } }, + { pixel: { x: 0, y: 10 }, enu: { x: 0, y: -10 } }, + ]; + + const transform = fitSimilarityFixedScale(pairs, fixedScale); + expect(Math.abs(transform.rotation)).toBeCloseTo(Math.PI); + }); + }); }); diff --git a/src/index.js b/src/index.js index 47924ab..cac7882 100644 --- a/src/index.js +++ b/src/index.js @@ -920,7 +920,7 @@ function maybePromptGeolocationForOsm() { .catch(() => { if (shouldPrompt) doRequest(); }); - } catch (_) { + } catch { if (shouldPrompt) doRequest(); } } else {