From f4d9dd621ec479422329fcfb3270cc3dd2e4e6ea Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Sun, 21 Dec 2025 05:36:29 +0000 Subject: [PATCH 01/18] feat: add initial documentation for manual reference distance feature and its implementation plan --- docs/feat-reference-distances.md | 115 +++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/feat-reference-distances.md diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md new file mode 100644 index 0000000..5f43015 --- /dev/null +++ b/docs/feat-reference-distances.md @@ -0,0 +1,115 @@ +# 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 + pixelsPerMeter: number // Derived scale: distance(p1, p2) / meters + } + ``` +* **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. + 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 **only** if `referenceDistance` is set. +* **Interaction Flow**: + 1. User taps "Measure". + 2. User taps/drags to draw a temporary line. + 3. **Real-time Feedback**: A label on the line shows the distance in meters, calculated as `pixelDistance / state.referenceDistance.pixelsPerMeter`. + +## 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$). This is mathematically more robust when GPS accuracy is low but the manual measurement is trusted. + * **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." + +## 4. Code Structure Changes + +### `src/index.js` +* Add `referenceDistance` to `state`. +* Implement `startReferenceMode()` and `startMeasureMode()`. +* 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. + +### `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. +* **Integration Tests**: + * Verify that setting a reference distance enables the measure tool. + * Verify that measurements are accurate based on the reference. + + From 986951f5ca806c2accb36d2cb092f53727a1c26e Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Sun, 21 Dec 2025 05:43:13 +0000 Subject: [PATCH 02/18] refined the spec further: enhance user experience by adding zoom functionality for setting reference distances and measuring --- docs/feat-reference-distances.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md index 5f43015..015a9a8 100644 --- a/docs/feat-reference-distances.md +++ b/docs/feat-reference-distances.md @@ -57,6 +57,8 @@ We need to extend the application state to store the manual reference. 1. User taps "Set Scale". 2. Toast: "Tap start point of known distance". 3. User taps point A on the photo. + * He should be able to zoom in on the image while doing these tabs, 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". @@ -67,8 +69,10 @@ We need to extend the application state to store the manual reference. * **Entry Point**: "Measure" button (icon: tape measure), enabled **only** if `referenceDistance` is set. * **Interaction Flow**: 1. User taps "Measure". - 2. User taps/drags to draw a temporary line. - 3. **Real-time Feedback**: A label on the line shows the distance in meters, calculated as `pixelDistance / state.referenceDistance.pixelsPerMeter`. + 2. User taps to set a start point of the temporary measure line. (He can still zoom and move around on the image while doing so) + 3. user tabs to set the end point of the temporary measure line. (He 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 / state.referenceDistance.pixelsPerMeter`. + 5. Afterwards he can still long press and drag the start and end points of the measure line around on the image to refine his initial placement of these 2 points ## 3. Logic & Calibration Integration From 3dc224d87625b971269c7639dd22a966da51ebd0 Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Sun, 21 Dec 2025 05:48:01 +0000 Subject: [PATCH 03/18] feat: enhance manual reference distance feature with scale stabilization and interaction improvements --- docs/feat-reference-distances.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md index 015a9a8..f4eb14b 100644 --- a/docs/feat-reference-distances.md +++ b/docs/feat-reference-distances.md @@ -88,7 +88,8 @@ We need to extend the application state to store the manual reference. 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$). This is mathematically more robust when GPS accuracy is low but the manual measurement is trusted. + * **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." ## 4. Code Structure Changes @@ -96,12 +97,16 @@ We need to extend the application state to store the manual reference. ### `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`. From 34be6b650bc684db3bfe98be49ac44c6ac6e1c06 Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Sun, 21 Dec 2025 05:56:34 +0000 Subject: [PATCH 04/18] feat: add fitSimilarityFixedScale function for fixed scale similarity transformation --- src/geo/transformations.js | 69 +++++++++++++++++++++++++++++++++ src/geo/transformations.test.js | 1 + 2 files changed, 70 insertions(+) diff --git a/src/geo/transformations.js b/src/geo/transformations.js index 19988c4..accaa3a 100644 --- a/src/geo/transformations.js +++ b/src/geo/transformations.js @@ -100,6 +100,74 @@ export function fitSimilarity(pairs, weights) { }; } +export function fitSimilarityFixedScale(pairs, fixedScale, weights) { + if (pairs.length < 2) { + return null; + } + + if (!Number.isFinite(fixedScale) || Math.abs(fixedScale) < TOLERANCE) { + return null; + } + + const w = ensureWeights(pairs.length, weights); + let weightSum = 0; + let pixelCentroid = { x: 0, y: 0 }; + let enuCentroid = { x: 0, y: 0 }; + + for (let i = 0; i < pairs.length; i += 1) { + const weight = w[i]; + weightSum += weight; + pixelCentroid.x += weight * pairs[i].pixel.x; + pixelCentroid.y += weight * pairs[i].pixel.y; + enuCentroid.x += weight * pairs[i].enu.x; + enuCentroid.y += weight * pairs[i].enu.y; + } + + if (Math.abs(weightSum) < TOLERANCE) { + return null; + } + + pixelCentroid = { x: pixelCentroid.x / weightSum, y: pixelCentroid.y / weightSum }; + enuCentroid = { x: enuCentroid.x / weightSum, y: enuCentroid.y / weightSum }; + + // 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; + + 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); + } + + 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 +522,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..6e3d46b 100644 --- a/src/geo/transformations.test.js +++ b/src/geo/transformations.test.js @@ -1,5 +1,6 @@ import { fitSimilarity, + fitSimilarityFixedScale, fitAffine, fitHomography, applyTransform, From 2ac9f137c306c3857560689177a44413385ade49 Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Sun, 21 Dec 2025 05:56:40 +0000 Subject: [PATCH 05/18] feat: implement fixed scale similarity transformation and add corresponding tests --- src/calibration/calibrator.js | 19 +++--- src/calibration/calibrator.test.js | 47 +++++++++++++++ src/geo/transformations.test.js | 96 ++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 7 deletions(-) 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.test.js b/src/geo/transformations.test.js index 6e3d46b..8b4e05c 100644 --- a/src/geo/transformations.test.js +++ b/src/geo/transformations.test.js @@ -176,4 +176,100 @@ 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(); + }); + + 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', () => { + // Two points suggesting different rotations, weights should favor second + const pairs = [ + { pixel: { x: 10, y: 0 }, enu: { x: 10, y: 0 } }, + { pixel: { x: 0, y: 10 }, enu: { x: 0, y: 10 } }, + ]; + const uniformTransform = fitSimilarityFixedScale(pairs, 1, [1, 1]); + const weightedTransform = fitSimilarityFixedScale(pairs, 1, [0.01, 1]); + // Both should work but give slightly different results due to weighting + expect(uniformTransform).not.toBeNull(); + expect(weightedTransform).not.toBeNull(); + }); + }); }); From 99a28237421f56ebb40f270a72cc481ab7519d73 Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:00:05 +0000 Subject: [PATCH 06/18] feat: document implementation progress for manual reference distance feature --- docs/feat-reference-distances.md | 62 ++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md index f4eb14b..4352eb5 100644 --- a/docs/feat-reference-distances.md +++ b/docs/feat-reference-distances.md @@ -121,4 +121,66 @@ We need to extend the application state to store the manual reference. * Verify that setting a reference distance enables the measure tool. * Verify that measurements are accurate based on the reference. +--- + +## 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 8 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 + +#### `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:** 41 tests pass, 98.41% code coverage + +--- + +### 🔲 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 `pixelsPerMeter` + - Draggable endpoints for refinement +- [ ] Add UI buttons to toolbar +- [ ] Persistence of `referenceDistance` to IndexedDB + + From 922e1f49a26da8bd82565de55b6cd85c4ca47d97 Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:04:26 +0000 Subject: [PATCH 07/18] feat: simplify error handling in fetch event and geolocation prompt --- service-worker.js | 4 ++-- src/index.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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/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 { From 9272d65fe869f11126b5a1ca102939d8b4b1e432 Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:04:46 +0000 Subject: [PATCH 08/18] refactor to extract the shared centroid computation into a helper function --- src/geo/transformations.js | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/geo/transformations.js b/src/geo/transformations.js index accaa3a..7f020e5 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) => { From 134e175c950e25a187cfa8ea847173292e06dd34 Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:05:09 +0000 Subject: [PATCH 09/18] feat: refactor fitSimilarityFixedScale to use computeWeightedCentroids for centroid calculation --- src/geo/transformations.js | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/src/geo/transformations.js b/src/geo/transformations.js index 7f020e5..12e2d2f 100644 --- a/src/geo/transformations.js +++ b/src/geo/transformations.js @@ -122,26 +122,12 @@ export function fitSimilarityFixedScale(pairs, fixedScale, weights) { return null; } - const w = ensureWeights(pairs.length, weights); - let weightSum = 0; - let pixelCentroid = { x: 0, y: 0 }; - let enuCentroid = { x: 0, y: 0 }; - - for (let i = 0; i < pairs.length; i += 1) { - const weight = w[i]; - weightSum += weight; - pixelCentroid.x += weight * pairs[i].pixel.x; - pixelCentroid.y += weight * pairs[i].pixel.y; - enuCentroid.x += weight * pairs[i].enu.x; - enuCentroid.y += weight * pairs[i].enu.y; - } - - if (Math.abs(weightSum) < TOLERANCE) { + const centroids = computeWeightedCentroids(pairs, weights); + if (!centroids) { return null; } - pixelCentroid = { x: pixelCentroid.x / weightSum, y: pixelCentroid.y / weightSum }; - enuCentroid = { x: enuCentroid.x / weightSum, y: enuCentroid.y / weightSum }; + 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 From b248c12eac32a912544e3fb51091b343cdaab48e Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:06:22 +0000 Subject: [PATCH 10/18] feat: improve user instructions for setting reference distances and measuring --- docs/feat-reference-distances.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md index 4352eb5..f7f3859 100644 --- a/docs/feat-reference-distances.md +++ b/docs/feat-reference-distances.md @@ -57,7 +57,7 @@ We need to extend the application state to store the manual reference. 1. User taps "Set Scale". 2. Toast: "Tap start point of known distance". 3. User taps point A on the photo. - * He should be able to zoom in on the image while doing these tabs, to place the points as accurate as possible. + * 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. @@ -69,10 +69,10 @@ We need to extend the application state to store the manual reference. * **Entry Point**: "Measure" button (icon: tape measure), enabled **only** if `referenceDistance` is set. * **Interaction Flow**: 1. User taps "Measure". - 2. User taps to set a start point of the temporary measure line. (He can still zoom and move around on the image while doing so) - 3. user tabs to set the end point of the temporary measure line. (He can still zoom and move around on the image while doing so) + 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 / state.referenceDistance.pixelsPerMeter`. - 5. Afterwards he can still long press and drag the start and end points of the measure line around on the image to refine his initial placement of these 2 points + 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 ## 3. Logic & Calibration Integration From d325a8a36f352e20f8833834b130037290fb6bd1 Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:11:58 +0000 Subject: [PATCH 11/18] feat: enhance rotation computation test for fitSimilarityFixedScale with weight influence --- src/geo/transformations.test.js | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/geo/transformations.test.js b/src/geo/transformations.test.js index 8b4e05c..e68fe83 100644 --- a/src/geo/transformations.test.js +++ b/src/geo/transformations.test.js @@ -260,16 +260,30 @@ describe('transformations', () => { }); test('respects weights in rotation computation', () => { - // Two points suggesting different rotations, weights should favor second + // 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: 0, y: 10 } }, + { pixel: { x: 0, y: 10 }, enu: { x: -10, y: 0 } }, ]; - const uniformTransform = fitSimilarityFixedScale(pairs, 1, [1, 1]); - const weightedTransform = fitSimilarityFixedScale(pairs, 1, [0.01, 1]); - // Both should work but give slightly different results due to weighting - expect(uniformTransform).not.toBeNull(); - expect(weightedTransform).not.toBeNull(); + + // 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); }); }); }); From b979daa8c23c3eec7e6832b41424ef772cdf9902 Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:14:40 +0000 Subject: [PATCH 12/18] feat: expand unit tests for fitSimilarityFixedScale to document geometric properties and edge cases --- docs/feat-reference-distances.md | 27 +++++++++-- src/geo/transformations.test.js | 81 ++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md index f7f3859..f469902 100644 --- a/docs/feat-reference-distances.md +++ b/docs/feat-reference-distances.md @@ -139,7 +139,7 @@ This phase implements the core math needed to support a fixed reference scale in - [x] Exported via module API #### `src/geo/transformations.test.js` -- [x] Added 8 comprehensive unit tests: +- [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 @@ -147,7 +147,12 @@ This phase implements the core math needed to support a fixed reference scale in - 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 + - 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 collinear pixel points + - Handles 180 degree rotation #### `src/calibration/calibrator.js` - [x] Updated `calibrateMap` to accept optional `userOptions.referenceScale` @@ -161,7 +166,23 @@ This phase implements the core math needed to support a fixed reference scale in - Fixed scale overrides natural GPS-derived scale - `referenceScale` only affects similarity model, not affine/homography -**Test Results:** 41 tests pass, 98.41% code coverage +**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. --- diff --git a/src/geo/transformations.test.js b/src/geo/transformations.test.js index e68fe83..186b44d 100644 --- a/src/geo/transformations.test.js +++ b/src/geo/transformations.test.js @@ -285,5 +285,86 @@ describe('transformations', () => { 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 collinear 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 } }, + ]; + + // This should return a valid transform (rotation is arbitrary but consistent) + // or handle gracefully - let's verify the behavior + const transform = fitSimilarityFixedScale(pairs, 1); + // When pixel points coincide, rotation is undefined (atan2(0,0)) + // The function should still return a transform with the fixed scale + if (transform !== null) { + expect(transform.scale).toBe(1); + } + }); + + 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); + }); }); }); From f62b458868fd3d10c023533131fca57feb763eb7 Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:16:06 +0000 Subject: [PATCH 13/18] feat: update reference distance calculations to use metersPerPixel for improved accuracy --- docs/feat-reference-distances.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md index f469902..9776e97 100644 --- a/docs/feat-reference-distances.md +++ b/docs/feat-reference-distances.md @@ -44,9 +44,10 @@ We need to extend the application state to store the manual reference. p1: { x: number, y: number }, // Pixel coordinates p2: { x: number, y: number }, // Pixel coordinates meters: number, // User-defined distance - pixelsPerMeter: number // Derived scale: distance(p1, p2) / meters + 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 @@ -71,7 +72,7 @@ We need to extend the application state to store the manual reference. 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 / state.referenceDistance.pixelsPerMeter`. + 4. **Real-time Feedback**: A label on the line shows the distance in meters, calculated as `pixelDistance * state.referenceDistance.metersPerPixel`. 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 ## 3. Logic & Calibration Integration @@ -198,7 +199,7 @@ This phase implements the user-facing features: setting a reference distance and - Visual feedback with dashed line and label - [ ] Implement "Measure" mode (`startMeasureMode()`) - Two-tap workflow to draw measurement line - - Real-time distance calculation using `pixelsPerMeter` + - Real-time distance calculation using `metersPerPixel` - Draggable endpoints for refinement - [ ] Add UI buttons to toolbar - [ ] Persistence of `referenceDistance` to IndexedDB From a0fc40ed9e762d4b7dfbc9ac19424b7a9d328ea1 Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:18:14 +0000 Subject: [PATCH 14/18] feat: update fitSimilarityFixedScale to handle negative fixedScale values --- src/geo/transformations.js | 2 +- src/geo/transformations.test.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/geo/transformations.js b/src/geo/transformations.js index 12e2d2f..3155028 100644 --- a/src/geo/transformations.js +++ b/src/geo/transformations.js @@ -118,7 +118,7 @@ export function fitSimilarityFixedScale(pairs, fixedScale, weights) { return null; } - if (!Number.isFinite(fixedScale) || Math.abs(fixedScale) < TOLERANCE) { + if (!Number.isFinite(fixedScale) || fixedScale <= TOLERANCE) { return null; } diff --git a/src/geo/transformations.test.js b/src/geo/transformations.test.js index 186b44d..94b8ea5 100644 --- a/src/geo/transformations.test.js +++ b/src/geo/transformations.test.js @@ -249,6 +249,8 @@ describe('transformations', () => { 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', () => { From 808b1364e56931242370dfe7a92d335369fe2eab Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:23:56 +0000 Subject: [PATCH 15/18] feat: handle degenerate cases in fitSimilarityFixedScale by returning null for coincident pixel points --- src/geo/transformations.js | 7 +++++++ src/geo/transformations.test.js | 12 ++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/geo/transformations.js b/src/geo/transformations.js index 3155028..b4f5be3 100644 --- a/src/geo/transformations.js +++ b/src/geo/transformations.js @@ -135,6 +135,7 @@ export function fitSimilarityFixedScale(pairs, fixedScale, weights) { // 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]; @@ -148,6 +149,12 @@ export function fitSimilarityFixedScale(pairs, fixedScale, weights) { // 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); diff --git a/src/geo/transformations.test.js b/src/geo/transformations.test.js index 94b8ea5..ddfd7d9 100644 --- a/src/geo/transformations.test.js +++ b/src/geo/transformations.test.js @@ -340,21 +340,17 @@ describe('transformations', () => { expect(transform.scale).toBe(fixedScale); }); - test('returns null for degenerate collinear pixel points', () => { + 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 } }, ]; - // This should return a valid transform (rotation is arbitrary but consistent) - // or handle gracefully - let's verify the behavior + // When pixel points coincide, rotation is mathematically undefined + // The function should return null to indicate invalid input const transform = fitSimilarityFixedScale(pairs, 1); - // When pixel points coincide, rotation is undefined (atan2(0,0)) - // The function should still return a transform with the fixed scale - if (transform !== null) { - expect(transform.scale).toBe(1); - } + expect(transform).toBeNull(); }); test('handles 180 degree rotation', () => { From 46497dc8e48a38110b8b932c5bbe2e688e91ec3c Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:25:33 +0000 Subject: [PATCH 16/18] feat: update documentation to clarify handling of degenerate coincident pixel points --- docs/feat-reference-distances.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md index 9776e97..885587f 100644 --- a/docs/feat-reference-distances.md +++ b/docs/feat-reference-distances.md @@ -152,7 +152,7 @@ This phase implements the core math needed to support a fixed reference scale in - 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 collinear pixel points + - Returns null for degenerate coincident pixel points - Handles 180 degree rotation #### `src/calibration/calibrator.js` From 8cc2bce00899bec8bd532c931450b37906773563 Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:30:06 +0000 Subject: [PATCH 17/18] feat: enhance measurement mode to prioritize manual reference distance over GPS calibration --- docs/feat-reference-distances.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md index 885587f..88f2f94 100644 --- a/docs/feat-reference-distances.md +++ b/docs/feat-reference-distances.md @@ -67,12 +67,15 @@ We need to extend the application state to store the manual reference. * **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 **only** if `referenceDistance` is set. +* **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). * **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 * state.referenceDistance.metersPerPixel`. + 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 ## 3. Logic & Calibration Integration From 2c6c53874cd6f9ba4f24f487fa435e22fdfbc3e7 Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:34:33 +0000 Subject: [PATCH 18/18] feat: enhance reference distance feature with scale source indicators and unit selection --- docs/feat-reference-distances.md | 103 ++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 2 deletions(-) diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md index 88f2f94..4e33709 100644 --- a/docs/feat-reference-distances.md +++ b/docs/feat-reference-distances.md @@ -71,12 +71,46 @@ We need to extend the application state to store the manual reference. 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 @@ -95,9 +129,63 @@ We need to extend the application state to store the manual reference. * **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()`. @@ -121,9 +209,20 @@ We need to extend the application state to store the manual reference. * **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 that setting a reference distance enables the measure tool. - * Verify that measurements are accurate based on the reference. + * 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. ---