From 9e7d1861b2ff6181155bbdb5fdfd210e22b0ace1 Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Mon, 21 Sep 2026 15:02:47 -0500 Subject: [PATCH 01/13] add helmert option in admin --- .../0004_alter_gcpgroup_transformation.py | 18 ++++++++++++++++++ ohmg/georeference/models.py | 1 + 2 files changed, 19 insertions(+) create mode 100644 ohmg/georeference/migrations/0004_alter_gcpgroup_transformation.py diff --git a/ohmg/georeference/migrations/0004_alter_gcpgroup_transformation.py b/ohmg/georeference/migrations/0004_alter_gcpgroup_transformation.py new file mode 100644 index 00000000..ca207657 --- /dev/null +++ b/ohmg/georeference/migrations/0004_alter_gcpgroup_transformation.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.27 on 2026-09-21 15:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('georeference', '0003_job'), + ] + + operations = [ + migrations.AlterField( + model_name='gcpgroup', + name='transformation', + field=models.CharField(blank=True, choices=[('helmert', 'helmert'), ('tps', 'tps'), ('poly1', 'poly1'), ('poly2', 'poly2'), ('poly3', 'poly3')], max_length=20, null=True), + ), + ] diff --git a/ohmg/georeference/models.py b/ohmg/georeference/models.py index 196d6c62..53506e88 100644 --- a/ohmg/georeference/models.py +++ b/ohmg/georeference/models.py @@ -72,6 +72,7 @@ class Meta: class GCPGroup(models.Model): TRANSFORMATION_CHOICES = ( + ("helmert", "helmert"), ("tps", "tps"), ("poly1", "poly1"), ("poly2", "poly2"), From b277c62e49a022746e96d3502c5ea44076884543 Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Tue, 22 Sep 2026 17:20:16 -0500 Subject: [PATCH 02/13] move utils to themed files --- ohmg/georeference/georeferencer.py | 148 +----------------- ohmg/georeference/mosaicker.py | 2 +- ohmg/georeference/splitter.py | 2 +- ohmg/georeference/{ => utils}/geometry.py | 0 .../georeference/{utils.py => utils/tiles.py} | 0 5 files changed, 8 insertions(+), 144 deletions(-) rename ohmg/georeference/{ => utils}/geometry.py (100%) rename ohmg/georeference/{utils.py => utils/tiles.py} (100%) diff --git a/ohmg/georeference/georeferencer.py b/ohmg/georeference/georeferencer.py index e9bc86e9..47841f7d 100644 --- a/ohmg/georeference/georeferencer.py +++ b/ohmg/georeference/georeferencer.py @@ -1,101 +1,24 @@ import logging -import math import os -import sys import time -from dataclasses import dataclass -from io import StringIO from pathlib import Path from typing import List from uuid import uuid4 -import numpy as np from django.conf import settings from osgeo import gdal, ogr, osr from ohmg.core.utils.srs import retrieve_srs_wkt -logger = logging.getLogger(__name__) - - -@dataclass -class HelmertParams: - """The four parameters of a Helmert (similarity) transformation that maps - image pixel/line coordinates to geographic coordinates.""" - - scale: float - # rotation in degrees, measured as an azimuth from north (the positive Y axis) - rotation: float - # geographic coordinates that the image origin (pixel=0, line=0) maps to - offset_x: float - offset_y: float +from .utils.gcps import ( + TRANSFORMATION_LOOKUP, + get_helmert_proj_pipeline, +) +logger = logging.getLogger(__name__) gdal.SetConfigOption("GDAL_NUM_THREADS", "ALL_CPUS") -TRANSFORMATION_LOOKUP = { - "helmert": { - "id": "helmert", - "gdal_code": None, - "name": "Helmert", - "desc": "implemented as four-parameter transformation", - }, - "tps": { - "id": "tps", - "gdal_code": -1, - "name": "Thin Plate Spline", - "desc": "max distortion", - }, - "poly": { - "id": "poly", - "gdal_code": 0, - "name": "Highest Possible Polynomial", - "desc": "uses highest possible polynomial order based on GCP count", - }, - "poly1": { - "id": "poly1", - "gdal_code": 1, - "name": "Polynomial 1", - "desc": "uses polynomial 1", - }, - "poly2": { - "id": "poly2", - "gdal_code": 2, - "name": "Polynomial 2", - "desc": "uses polynomial 2, requires 6 GCPs", - }, - "poly3": { - "id": "poly3", - "gdal_code": 3, - "name": "Polynomial 3", - "desc": "uses polynomial 3, requires 10 GCPs (not recommended in GDAL docs)", - }, -} - - -class CapturingStdout(list): - def __enter__(self): - self._stdout = sys.stdout - sys.stdout = self._stringio = StringIO() - return self - - def __exit__(self, *args): - self.extend(self._stringio.getvalue().splitlines()) - del self._stringio # free up some memory - sys.stdout = self._stdout - - -class CapturingStderr(list): - def __enter__(self): - self._stderr = sys.stderr - sys.stderr = self._stringio = StringIO() - return self - - def __exit__(self, *args): - self.extend(self._stringio.getvalue().splitlines()) - del self._stringio # free up some memory - sys.stderr = self._stderr - ## DEPRECATED - this was a fun idea, but polynomial 2 and beyond create ## crazy results, so only polynomial 1 is used throughout the app for now. @@ -258,69 +181,10 @@ def _load_gcps_from_geojson(self, geo_json): ) self.gcps.append(gcp) - def _get_helmert_params(self) -> HelmertParams: - """Fit a Helmert (four-parameter similarity) transformation to the GCPs - using least squares, returning the scale, rotation, and offsets. - - The transformation maps image pixel/line coordinates to geographic - coordinates as: - - geoX = offset_x + a * line - b * pixel - geoY = offset_y + a * pixel + b * line - - where (a, b) = (scale * cos(angle), scale * sin(angle)). Because this is - linear in the four unknowns (a, b, offset_x, offset_y), it can be solved - directly from two equations per GCP -- exactly determined with two GCPs, - and a best fit with more. This handles every orientation without any - special-casing. - """ - - if len(self.gcps) < 2: - raise Exception("At least two GCPs are needed to fit a Helmert transformation") - - # build two rows (the geoX and geoY equations) per GCP - matrix, targets = [], [] - for gcp in self.gcps: - matrix.append([gcp.GCPLine, -gcp.GCPPixel, 1, 0]) - targets.append(gcp.GCPX) - matrix.append([gcp.GCPPixel, gcp.GCPLine, 0, 1]) - targets.append(gcp.GCPY) - - (a, b, offset_x, offset_y), *_ = np.linalg.lstsq( - np.array(matrix, dtype=float), np.array(targets, dtype=float), rcond=None - ) - - scale = math.hypot(a, b) - # convert the fitted angle to an azimuth in degrees from north - rotation = (270 - math.degrees(math.atan2(b, a))) % 360 - - return HelmertParams( - scale=scale, - rotation=rotation, - offset_x=offset_x, - offset_y=offset_y, - ) - - def _get_helmert_proj_pipeline(self) -> str: - ## fit the four-parameter Helmert model in one shot - params = self._get_helmert_params() - - ## convert the rotation to arcseconds for the proj pipeline - arcseconds = (params.rotation + 90) * 3600 - - pipeline = ( - "+proj=pipeline " - "+step +proj=axisswap +order=2,1 " - f"+step +proj=helmert +x={params.offset_x} +y={params.offset_y} " - f"+theta={arcseconds} +s={params.scale}" - ) - - return pipeline - def make_transformer_options(self) -> list[str]: match self.transformation["id"]: case "helmert": - pipeline = self._get_helmert_proj_pipeline() + pipeline = get_helmert_proj_pipeline(self.gcps) logger.debug(f"applying: {pipeline}") return [ "SRC_METHOD=NO_GEOTRANSFORM", diff --git a/ohmg/georeference/mosaicker.py b/ohmg/georeference/mosaicker.py index c8d8f901..1bdc2fd0 100644 --- a/ohmg/georeference/mosaicker.py +++ b/ohmg/georeference/mosaicker.py @@ -18,7 +18,7 @@ from .georeferencer import Georeferencer, VRTHandler from .tasks import cleanup_existing_tileset -from .utils import make_xyz_tiles, make_xyz_tiles_with_multiprocessing +from .utils.tiles import make_xyz_tiles, make_xyz_tiles_with_multiprocessing gdal.SetConfigOption("GDAL_NUM_THREADS", "ALL_CPUS") gdal.SetConfigOption("GDAL_TIFF_INTERNAL_MASK", "YES") diff --git a/ohmg/georeference/splitter.py b/ohmg/georeference/splitter.py index 37785d09..f9924661 100644 --- a/ohmg/georeference/splitter.py +++ b/ohmg/georeference/splitter.py @@ -8,7 +8,7 @@ from django.db.models import FileField from PIL import Image, ImageDraw, ImageFilter -from .geometry import extend_linestring +from .utils.geometry import extend_linestring logger = logging.getLogger(__name__) diff --git a/ohmg/georeference/geometry.py b/ohmg/georeference/utils/geometry.py similarity index 100% rename from ohmg/georeference/geometry.py rename to ohmg/georeference/utils/geometry.py diff --git a/ohmg/georeference/utils.py b/ohmg/georeference/utils/tiles.py similarity index 100% rename from ohmg/georeference/utils.py rename to ohmg/georeference/utils/tiles.py From 943fab0bc3991f50e4655e09f5ba53b06a1e79e4 Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Tue, 22 Sep 2026 17:22:11 -0500 Subject: [PATCH 03/13] add utils for calculating rmse and other measures --- ohmg/georeference/georeferencer.py | 17 +++ ohmg/georeference/utils/gcps.py | 205 +++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 ohmg/georeference/utils/gcps.py diff --git a/ohmg/georeference/georeferencer.py b/ohmg/georeference/georeferencer.py index 47841f7d..0a98bf6f 100644 --- a/ohmg/georeference/georeferencer.py +++ b/ohmg/georeference/georeferencer.py @@ -12,6 +12,9 @@ from .utils.gcps import ( TRANSFORMATION_LOOKUP, + calculate_affine_distortion, + calculate_affine_rmse, + calculate_helmert_rmse, get_helmert_proj_pipeline, ) @@ -208,6 +211,20 @@ def cleanup_files(self): if self.cog and self.cog.is_file(): os.remove(self.cog) + def get_measures(self) -> tuple[float, float, float]: + if self.transformation["id"] == "helmert": + rmse, preds, lines = calculate_helmert_rmse(self.gcps) + skew = 0 + aniso = 1 + elif self.transformation["id"] == "poly1": + rmse, preds, lines = calculate_affine_rmse(self.gcps) + skew, aniso = calculate_affine_distortion(self.gcps) + else: + rmse, preds, lines = None, [], [] + skew, aniso = None, None + + return (rmse, preds, lines, skew, aniso) + def make_gcps_vrt( self, src_path, diff --git a/ohmg/georeference/utils/gcps.py b/ohmg/georeference/utils/gcps.py new file mode 100644 index 00000000..390acdd7 --- /dev/null +++ b/ohmg/georeference/utils/gcps.py @@ -0,0 +1,205 @@ +import math +from dataclasses import dataclass + +import numpy as np +from osgeo import gdal + +TRANSFORMATION_LOOKUP = { + "helmert": { + "id": "helmert", + "gdal_code": None, + "name": "Helmert", + "desc": "implemented as four-parameter transformation", + }, + "tps": { + "id": "tps", + "gdal_code": -1, + "name": "Thin Plate Spline", + "desc": "max distortion", + }, + "poly": { + "id": "poly", + "gdal_code": 0, + "name": "Highest Possible Polynomial", + "desc": "uses highest possible polynomial order based on GCP count", + }, + "poly1": { + "id": "poly1", + "gdal_code": 1, + "name": "Polynomial 1", + "desc": "uses polynomial 1", + }, + "poly2": { + "id": "poly2", + "gdal_code": 2, + "name": "Polynomial 2", + "desc": "uses polynomial 2, requires 6 GCPs", + }, + "poly3": { + "id": "poly3", + "gdal_code": 3, + "name": "Polynomial 3", + "desc": "uses polynomial 3, requires 10 GCPs (not recommended in GDAL docs)", + }, +} + + +@dataclass +class HelmertParams: + """The four parameters of a Helmert (similarity) transformation that maps + image pixel/line coordinates to geographic coordinates.""" + + scale: float + # rotation in degrees, measured as an azimuth from north (the positive Y axis) + rotation: float + # geographic coordinates that the image origin (pixel=0, line=0) maps to + offset_x: float + offset_y: float + + +def build_helmert_matrices(gcp_list: list[gdal.GCP]) -> tuple[np.array, np.array]: + """Builds interleaved 'a' (2M, 4) and 'b' (2M,) matrices for a Rigid/Similarity transformation.""" + sources, targets = [], [] + for gcp in gcp_list: + sources.append([gcp.GCPLine, -gcp.GCPPixel, 1, 0]) + targets.append(gcp.GCPX) + sources.append([gcp.GCPPixel, gcp.GCPLine, 0, 1]) + targets.append(gcp.GCPY) + return np.array(sources, dtype=float), np.array(targets, dtype=float) + + +def build_affine_matrices(gcp_list: list[gdal.GCP]) -> tuple[np.array, np.array]: + """Builds X (M, 3) and Y (M, 2) matrices for an Affine transformation.""" + sources = np.array([[gcp.GCPPixel, gcp.GCPLine, 1.0] for gcp in gcp_list]) + targets = np.array([[gcp.GCPX, gcp.GCPY] for gcp in gcp_list]) + return sources, targets + + +def calculate_helmert_rmse(gcp_list: list[gdal.GCP]) -> float: + sources, targets = build_helmert_matrices(gcp_list) + + x, *_ = np.linalg.lstsq(sources, targets, rcond=None) + + predictions = sources @ x + rmse = np.sqrt(np.mean((targets - predictions) ** 2)) + + def flat_list_to_pairs(flat_list: list) -> list[tuple[object, object]]: + iterator = iter(flat_list) + return list(zip(iterator, iterator)) + + pred_coords = flat_list_to_pairs(predictions.tolist()) + target_coords = flat_list_to_pairs(targets.tolist()) + + lines = list(zip(pred_coords, target_coords)) + + return round(rmse, 5), pred_coords, lines + + +def calculate_affine_rmse(gcp_list: list[gdal.GCP]): + """ + Calculates the overall RMSE and coordinate pairings + using a first-degree Polynomial (Affine) transformation. + """ + + sources, targets = build_affine_matrices(gcp_list) + + x, *_ = np.linalg.lstsq(sources, targets, rcond=None) + + predictions = sources @ x + rmse = np.sqrt(np.mean((targets - predictions) ** 2)) + + pred_coords = [tuple(coord) for coord in predictions.tolist()] + target_coords = [tuple(coord) for coord in targets.tolist()] + + lines = list(zip(pred_coords, target_coords)) + + return round(rmse, 5), pred_coords, lines + + +def calculate_affine_distortion(gcp_list: list[gdal.GCP]) -> tuple[float, float]: + """Compute skew (°) and anisotropy from the truth affine linear part. + + Returns: + skew_deg: deviation of pixel-x / pixel-y axes from perpendicular (0° = similarity) + aniso: scale_x / scale_y ratio (1.0 = similarity; >1 means x-pixels cover more ground) + """ + + sources, targets = build_affine_matrices(gcp_list) + + result, *_ = np.linalg.lstsq(sources, targets, rcond=None) + + # Column vectors: change in metric geo per one pixel in each image direction. + v_x = np.array([result[0, 0], result[1, 0]]) + v_y = np.array([result[0, 1], result[1, 1]]) + + scale_x = float(np.linalg.norm(v_x)) + scale_y = float(np.linalg.norm(v_y)) + + aniso = scale_x / scale_y if scale_y > 0 else 1.0 + + if scale_x > 0 and scale_y > 0: + cos_angle = float(np.dot(v_x, v_y) / (scale_x * scale_y)) + cos_angle = max(-1.0, min(1.0, cos_angle)) + skew_deg = math.degrees(math.acos(cos_angle)) - 90.0 + else: + skew_deg = 0.0 + + return round(skew_deg, 2), round(aniso, 3) + + +def get_helmert_params(gcp_list: list[gdal.GCP]) -> HelmertParams: + """Fit a Helmert (four-parameter similarity) transformation to the GCPs + using least squares, returning the scale, rotation, and offsets. + + The transformation maps image pixel/line coordinates to geographic + coordinates as: + + geoX = offset_x + a * line - b * pixel + geoY = offset_y + a * pixel + b * line + + where (a, b) = (scale * cos(angle), scale * sin(angle)). Because this is + linear in the four unknowns (a, b, offset_x, offset_y), it can be solved + directly from two equations per GCP -- exactly determined with two GCPs, + and a best fit with more. This handles every orientation without any + special-casing. + """ + + if len(gcp_list) < 2: + raise Exception("At least two GCPs are needed to fit a Helmert transformation") + + # build two rows (the geoX and geoY equations) per GCP + sources, targets = build_helmert_matrices(gcp_list) + + x, *_ = np.linalg.lstsq(sources, targets, rcond=None) + scale_cos, scale_sin, offset_x, offset_y = x + + # convert the scale coefficients to a single measure + scale = math.hypot(scale_cos, scale_sin) + # convert the fitted angle to an azimuth in degrees from north + rotation = (270 - math.degrees(math.atan2(scale_sin, scale_cos))) % 360 + + return HelmertParams( + scale=scale, + rotation=rotation, + offset_x=offset_x, + offset_y=offset_y, + ) + + +def get_helmert_proj_pipeline( + gcp_list: list[gdal.GCP] | None = None, params: HelmertParams | None = None +) -> str: + if not params: + params = get_helmert_params(gcp_list) + + ## convert the rotation to arcseconds for the proj pipeline + arcseconds = (params.rotation + 90) * 3600 + + pipeline = ( + "+proj=pipeline " + "+step +proj=axisswap +order=2,1 " + f"+step +proj=helmert +x={params.offset_x} +y={params.offset_y} " + f"+theta={arcseconds} +s={params.scale}" + ) + + return pipeline From b7939de5ab7c4e2a363fcef5508568a624849952 Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Tue, 22 Sep 2026 17:22:50 -0500 Subject: [PATCH 04/13] add post operation to get measures --- ohmg/georeference/views.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/ohmg/georeference/views.py b/ohmg/georeference/views.py index 32ca7b0e..98fca328 100644 --- a/ohmg/georeference/views.py +++ b/ohmg/georeference/views.py @@ -246,7 +246,7 @@ def get(self, request, docid): }, ) - @method_decorator(validate_post_request(operations=["preview", "submit", "cancel"])) + @method_decorator(validate_post_request(operations=["preview", "measures", "submit", "cancel"])) def post(self, request, docid): """ Runs the georeferencing process for this document. @@ -293,6 +293,28 @@ def _get_georef_session(sesh_id): logger.error(e) return JsonResponseFail(str(e)) + elif operation == "measures": + g = Georeferencer( + crs=projection, + gcps_geojson=gcp_geojson, + transformation=transformation, + ) + try: + rmse, preds, lines, skew, aniso = g.get_measures() + return JsonResponseSuccess( + "all good", + { + "rmse": rmse, + "preds": preds, + "lines": lines, + "skew": skew, + "aniso": aniso, + }, + ) + except Exception as e: + logger.error(e) + return JsonResponseFail(str(e)) + elif operation == "submit": sesh = _get_georef_session(sesh_id) if sesh: From 5fa50df613cce3998c0e53cc6e5f234b4e017648 Mon Sep 17 00:00:00 2001 From: Adam Cox Date: Tue, 22 Sep 2026 17:24:18 -0500 Subject: [PATCH 05/13] get quality measures during georef, staff users --- .../interfaces/Georeferencer.svelte | 174 +++++++++++++++++- 1 file changed, 167 insertions(+), 7 deletions(-) diff --git a/ohmg/frontend/svelte_components/src/components/interfaces/Georeferencer.svelte b/ohmg/frontend/svelte_components/src/components/interfaces/Georeferencer.svelte index 7a934548..0a6b5485 100644 --- a/ohmg/frontend/svelte_components/src/components/interfaces/Georeferencer.svelte +++ b/ohmg/frontend/svelte_components/src/components/interfaces/Georeferencer.svelte @@ -2,9 +2,9 @@ import X from 'phosphor-svelte/lib/X'; import Check from 'phosphor-svelte/lib/Check'; import ArrowsClockwise from 'phosphor-svelte/lib/ArrowsClockwise'; + import ChartScatter from 'phosphor-svelte/lib/ChartScatter'; import Trash from 'phosphor-svelte/lib/Trash'; import Stack from 'phosphor-svelte/lib/Stack'; - import GearSix from 'phosphor-svelte/lib/GearSix'; import { onMount } from 'svelte'; @@ -54,6 +54,8 @@ import InfoModalButton from '../shared/buttons/InfoModalButton.svelte'; import ModalConfirm from '../base/ModalConfirm.svelte'; + import { LineString } from 'ol/geom'; + import { Style, Circle, Stroke, Fill } from 'ol/style'; export let CONTEXT; export let REGION; @@ -86,6 +88,10 @@ let showNotePanel = false; let showSettingsPanel = false; + let skew = null; + let aniso = null; + let rmse = null; + let docRotate; let mapRotate; @@ -177,6 +183,41 @@ { id: 'ESRI:102009', name: 'Lambert North America' }, ]; + const fill = new Fill({ + color: 'rgba(255,255,255,0.4)', + }); + const stroke = new Stroke({ + color: 'red', + width: 1.25, + }); + const styles = [ + new Style({ + image: new Circle({ + fill: fill, + stroke: stroke, + radius: 5, + }), + fill: fill, + stroke: stroke, + }), + ]; + + const offsetPtSource = new VectorSource() + const offsetPtLayer = new VectorLayer({ + source: offsetPtSource, + style: styles, + }) + const offsetLnSource = new VectorSource() + const offsetLnLayer = new VectorLayer({ + source: offsetLnSource, + style: styles, + }) + let showOffets = false; + $: { + offsetPtLayer.setVisible(showOffets) + offsetLnLayer.setVisible(showOffets) + } + // CREATE GCP LAYERS const docGCPSource = new VectorSource(); docGCPSource.on('addfeature', function (e) { @@ -315,18 +356,35 @@ }); } + // const getMeasuresDuringModify = { + // debounce((e) => {getMeasures()}, 8) + // } + // SNAP LAYER STUFF let parcelLayer; // MAKING INTERACTIONS // this Modify interaction is created individually for each map panel - function makeModifyInteraction(source, targetElement) { + function makeModifyInteraction(source, targetElement, onChangeHandler) { const modify = new Modify({ source: source, style: gcpStyles.hover, }); + if (onChangeHandler) { + modify.on(['modifystart'], function (e) { + e.features.forEach(function (feature) { + feature.on('change', onChangeHandler) + }); + }) + modify.on(['modifyend'], function (e) { + e.features.forEach(function (feature) { + feature.un('change', onChangeHandler); + }); + } + )} + modify.on(['modifystart', 'modifyend'], function (e) { targetElement.style.cursor = e.type === 'modifystart' ? 'grabbing' : 'pointer'; if (e.type == 'modifyend') { @@ -392,7 +450,7 @@ docViewer.addInteraction('draw', makeDrawInteraction(docGCPSource, drawWithinDocCondition, emptyStyle)); - docViewer.addInteraction('modify', makeModifyInteraction(docGCPSource, docViewer.element)); + docViewer.addInteraction('modify', makeModifyInteraction(docGCPSource, docViewer.element, debounce((e) => {getMeasures()}, 8))); docRotate = makeRotateCenterLayer(); docViewer.addLayer(docRotate.layer); @@ -408,6 +466,8 @@ currentBasemap = mapViewer.currentBasemap.id; mapViewer.addLayer(previewLayer); mapViewer.addLayer(mapGCPLayer); + mapViewer.addLayer(offsetPtLayer) + mapViewer.addLayer(offsetLnLayer) // create controls mapViewer.addControl(new LyrMousePosition(null, 'ol-mouse-position')); @@ -418,7 +478,7 @@ return parcelLayer?.getVisible() ? gcpStyles.snapTarget : emptyStyle } mapViewer.addInteraction('draw', makeDrawInteraction(mapGCPSource, null, drawStyleFunction)); - mapViewer.addInteraction('modify', makeModifyInteraction(mapGCPSource, mapViewer.element)); + mapViewer.addInteraction('modify', makeModifyInteraction(mapGCPSource, mapViewer.element, debounce((e) => {getMeasures()}, 8))); // add some event listening to the map mapViewer.map.on('click', selectGCPOnClick); @@ -701,6 +761,17 @@ } } + function debounce(fn, delay) { + let timerId; + return function (...args) { + if (timerId) { + clearTimeout(timerId); + } + timerId = setTimeout(() => { + fn.apply(this, args); + }, delay); + }; + } function updatePreviewSource(previewUrl) { if (previewUrl) { showLoading = true; @@ -759,6 +830,7 @@ previewMode = 'n/a'; return; } + getMeasures() submitPostRequest( `/georeference/${REGION.id}/`, CONTEXT.ohmg_post_headers, @@ -774,6 +846,37 @@ ); } + function getMeasures() { + submitPostRequest( + `/georeference/${REGION.id}/`, + CONTEXT.ohmg_post_headers, + 'measures', + preparePayload(), + (result) => { + + rmse = result.payload.rmse; + aniso = result.payload.aniso; + skew = result.payload.skew; + + offsetPtSource.clear() + result.payload.preds?.forEach(coord => { + const feat = new Feature({ + geometry: new Point(coord), + }); + offsetPtSource.addFeature(feat) + }) + + offsetLnSource.clear() + result.payload.lines?.forEach(coord => { + const feat = new Feature({ + geometry: new LineString(coord), + }); + offsetLnSource.addFeature(feat) + }) + }, + ); + } + function submitSession() { if (gcpList.length < minGCPs) { previewMode = 'n/a'; @@ -1046,7 +1149,32 @@ {/if} {#if showSettingsPanel} -