Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f4d9dd6
feat: add initial documentation for manual reference distance feature…
cs-util Dec 21, 2025
986951f
refined the spec further: enhance user experience by adding zoom func…
cs-util Dec 21, 2025
3dc224d
feat: enhance manual reference distance feature with scale stabilizat…
cs-util Dec 21, 2025
34be6b6
feat: add fitSimilarityFixedScale function for fixed scale similarity…
cs-util Dec 21, 2025
2ac9f13
feat: implement fixed scale similarity transformation and add corresp…
cs-util Dec 21, 2025
99a2823
feat: document implementation progress for manual reference distance …
cs-util Dec 21, 2025
922e1f4
feat: simplify error handling in fetch event and geolocation prompt
cs-util Dec 21, 2025
9272d65
refactor to extract the shared centroid computation into a helper fun…
cs-util Dec 21, 2025
134e175
feat: refactor fitSimilarityFixedScale to use computeWeightedCentroid…
cs-util Dec 21, 2025
b248c12
feat: improve user instructions for setting reference distances and m…
cs-util Dec 21, 2025
d325a8a
feat: enhance rotation computation test for fitSimilarityFixedScale w…
cs-util Dec 21, 2025
b979daa
feat: expand unit tests for fitSimilarityFixedScale to document geome…
cs-util Dec 21, 2025
f62b458
feat: update reference distance calculations to use metersPerPixel fo…
cs-util Dec 21, 2025
a0fc40e
feat: update fitSimilarityFixedScale to handle negative fixedScale va…
cs-util Dec 21, 2025
808b136
feat: handle degenerate cases in fitSimilarityFixedScale by returning…
cs-util Dec 21, 2025
46497dc
feat: update documentation to clarify handling of degenerate coincide…
cs-util Dec 21, 2025
8cc2bce
feat: enhance measurement mode to prioritize manual reference distanc…
cs-util Dec 21, 2025
2c6c538
feat: enhance reference distance feature with scale source indicators…
cs-util Dec 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
310 changes: 310 additions & 0 deletions docs/feat-reference-distances.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ self.addEventListener('fetch', (event) => {
if (response) {
return response;
}
} catch (error) {
} catch {
// network request failed, fall back to cache if possible
}

Expand All @@ -87,7 +87,7 @@ self.addEventListener('fetch', (event) => {

try {
return await fetchAndUpdate();
} catch (error) {
} catch {
if (cached) {
return cached;
}
Expand Down
19 changes: 12 additions & 7 deletions src/calibration/calibrator.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { computeOrigin, wgs84ToEnu } from '../geo/coordinate.js';
import {
fitSimilarity,
fitSimilarityFixedScale,
fitAffine,
fitHomography,
applyTransform,
Expand Down Expand Up @@ -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);
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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 = {
Expand Down
47 changes: 47 additions & 0 deletions src/calibration/calibrator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand Down
89 changes: 82 additions & 7 deletions src/geo/transformations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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) => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -454,6 +528,7 @@ export function averageScaleFromJacobian(jacobian) {
const api = {
TOLERANCE,
fitSimilarity,
fitSimilarityFixedScale,
fitAffine,
fitHomography,
applyTransform,
Expand Down
Loading