diff --git a/ohmg/core/admin.py b/ohmg/core/admin.py index ffa75757..987930e0 100644 --- a/ohmg/core/admin.py +++ b/ohmg/core/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin +from django.http import HttpResponseRedirect from django.utils.safestring import mark_safe from ohmg.core.models import ( @@ -70,8 +71,9 @@ def document_link(self, obj): class LayerAdmin(admin.ModelAdmin): search_fields = ("title",) raw_id_fields = ("region", "layerset2") - readonly_fields = ("title",) + readonly_fields = ("title", "gcp_count", "transformation", "rmse", "skew", "anisotropy") list_display = ("title", "created_by", "region_link", "map_link", "layerset_link") + actions = ("recalculate_measures",) @admin.display(description="Map") def map_link(self, obj): @@ -87,6 +89,25 @@ def layerset_link(self, obj): f'{obj.layerset2.category}' ) + # handles custom button in object change page + def response_change(self, request, obj): + if "_recalculate-measures" in request.POST: + msg = "Measures recalculated" + try: + obj.update_georeferencing_measures() + except Exception as e: + msg = f"Error calculating measures: {e}" + self.message_user(request, msg) + return HttpResponseRedirect(".") + return super().response_change(request, obj) + + # handles bulk updates through select/list page + @admin.action(description="Recalculate measures for selected layers") + def recalculate_measures(self, request, queryset): + for obj in queryset: + obj.update_georeferencing_measures() + self.message_user(request, f"{queryset.count()} updated") + admin.site.register(MapGroup) admin.site.register(Map, MapAdmin) diff --git a/ohmg/core/management/commands/oneoffs.py b/ohmg/core/management/commands/oneoffs.py index 76a29f28..ebb6ede2 100644 --- a/ohmg/core/management/commands/oneoffs.py +++ b/ohmg/core/management/commands/oneoffs.py @@ -27,6 +27,7 @@ def add_arguments(self, parser): "set-tilejson", "delete-duplicate-regions", "add-masks-to-layers", + "set-measures-on-layers", ], help="Choose what operation to run.", ) @@ -430,3 +431,14 @@ def old_ds_to_new(file_set): print(" -> setting mask to None") layer.mask = None layer.save(skip_map_lookup_update=True, set_extent=False) + + ## Sept 25th, 2026 + ## set the new metrics on all layers using the new method that has been added to Layer + elif operation == "set-measures-on-layers": + from ohmg.core.models import Layer + + layers = Layer.objects.all() + layers_ct = layers.count() + for n, layer in enumerate(Layer.objects.all(), start=1): + print(f"{n}/{layers_ct}: {layer.title}") + layer.update_georeferencing_measures() diff --git a/ohmg/core/migrations/0016_layer_anisotropy_layer_gcp_count_layer_rmse_and_more.py b/ohmg/core/migrations/0016_layer_anisotropy_layer_gcp_count_layer_rmse_and_more.py new file mode 100644 index 00000000..cb13c468 --- /dev/null +++ b/ohmg/core/migrations/0016_layer_anisotropy_layer_gcp_count_layer_rmse_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.27 on 2026-09-24 14:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0015_remove_layerset_mosaic_geotiff_date_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='layer', + name='anisotropy', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='layer', + name='gcp_count', + field=models.IntegerField(blank=True, null=True, verbose_name='GCP Count'), + ), + migrations.AddField( + model_name='layer', + name='rmse', + field=models.FloatField(blank=True, null=True, verbose_name='RMSE'), + ), + migrations.AddField( + model_name='layer', + name='skew', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='layer', + name='transformation', + field=models.CharField(blank=True, null=True), + ), + ] diff --git a/ohmg/core/models/layer.py b/ohmg/core/models/layer.py index c6ce4c46..c7ef4f26 100644 --- a/ohmg/core/models/layer.py +++ b/ohmg/core/models/layer.py @@ -82,6 +82,11 @@ class Meta: on_delete=models.SET_NULL, ) tilejson = models.JSONField(null=True, blank=True) + gcp_count = models.IntegerField("GCP Count", null=True, blank=True) + transformation = models.CharField(null=True, blank=True) + rmse = models.FloatField("RMSE", null=True, blank=True) + skew = models.FloatField(null=True, blank=True) + anisotropy = models.FloatField(null=True, blank=True) def __str__(self): return self.title @@ -117,6 +122,30 @@ def mask_geojson_feature(self) -> Union[dict | None]: else None ) + def get_gcp_group(self): + from ohmg.georeference.models import GCPGroup + + try: + return GCPGroup.objects.get(region2=self.region) + except Exception as e: + logger.error(e) + + def update_georeferencing_measures(self): + """Saves a suite of georeferencing-related data directly to this Layer instance.""" + + gcp_group = self.get_gcp_group() + if gcp_group: + rmse, _, _, skew, aniso = gcp_group.get_georeferencer().get_measures() + self.rmse = rmse + self.skew = skew + self.anisotropy = aniso + self.transformation = gcp_group.transformation + self.gcp_count = gcp_group.gcps.count() + self.save( + skip_map_lookup_update=True, + update_fields=["rmse", "skew", "anisotropy", "transformation", "gcp_count"], + ) + def create_xyz_url(self) -> Union[str, None]: file_url = get_file_url(self) if file_url: diff --git a/ohmg/extensions/iiif.py b/ohmg/extensions/iiif.py index 14b3c37e..1dcceb81 100644 --- a/ohmg/extensions/iiif.py +++ b/ohmg/extensions/iiif.py @@ -7,7 +7,6 @@ from ohmg.core.models import Layer from ohmg.core.utils import full_reverse -from ohmg.georeference.georeferencer import Georeferencer class IIIFResource: @@ -46,11 +45,7 @@ def get_target(self): else self.region.file.path ) - g = Georeferencer( - crs=target_crs, - transformation=self.region.gcpgroup.transformation, - gcps_geojson=self.region.gcpgroup.as_geojson, - ) + g = self.region.gcpgroup.get_georeferencer() g.make_gcps_vrt(in_path) ds = gdal.Open(g.gcps_vrt.get_vsi_url()) transformer = gdal.Transformer( diff --git a/ohmg/frontend/svelte_components/src/components/interfaces/Georeferencer.svelte b/ohmg/frontend/svelte_components/src/components/interfaces/Georeferencer.svelte index 7a934548..49a850fd 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; @@ -85,6 +87,24 @@ let showLayerPanel = true; let showNotePanel = false; let showSettingsPanel = false; + + let rmse = null; + let skew = null; + let aniso = null; + + const RMSE_WARN = 2.35; + const SKEW_WARN_DEGREES = 1.0; + const ANISOTROPY_WARN = 0.05; + $: rmseClass = rmse != null ? + rmse > RMSE_WARN ? 'is-warning' : 'is-success' + :'is-light' + $: skewClass = skew != null ? + Math.abs(skew) > SKEW_WARN_DEGREES ? 'is-warning' : 'is-success' + :'is-light' + $: anisoClass = aniso != null ? + Math.abs(aniso - 1) > ANISOTROPY_WARN ? 'is-warning' : 'is-success' + :'is-light' + let docRotate; let mapRotate; @@ -177,6 +197,43 @@ { 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, + zIndex: 99, + }) + const offsetLnSource = new VectorSource() + const offsetLnLayer = new VectorLayer({ + source: offsetLnSource, + style: styles, + zIndex: 98, + }) + let showOffets = false; + $: { + offsetPtLayer.setVisible(showOffets) + offsetLnLayer.setVisible(showOffets) + } + // CREATE GCP LAYERS const docGCPSource = new VectorSource(); docGCPSource.on('addfeature', function (e) { @@ -315,18 +372,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 +466,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 +482,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 +494,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 +777,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; @@ -750,6 +837,7 @@ } function getPreview() { + getMeasures() if (currentTransformation == "helmert") { minGCPs = 2; } else { @@ -774,6 +862,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 +1165,33 @@ {/if} {#if showSettingsPanel} -