Skip to content

Commit dd17e16

Browse files
authored
Merge pull request #388 from ohmg-dev/helmert_tests
Helmert tests
2 parents 97c4f4c + c128ad7 commit dd17e16

5 files changed

Lines changed: 150 additions & 72 deletions

File tree

ohmg/georeference/geometry.py

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,60 @@
11
import math
2-
from typing import Tuple
2+
from typing import List, Tuple
33

4+
import numpy as np
45
from django.contrib.gis.geos import LineString
56

67

7-
def angle_from_coords(pt1: Tuple[float, float], pt2: Tuple[float, float]) -> float:
8-
"""
9-
Calculates the absolute Cartesian angle (in degrees) of the vector
10-
pointing from p1 to p2, relative to the positive Y-axis.
11-
"""
12-
13-
# Calculate differences
14-
dx = pt2[0] - pt1[0]
15-
dy = pt2[1] - pt1[1]
16-
17-
# Calculate angle in radians (-pi to pi)
18-
radians = math.atan2(dx, dy)
19-
20-
degrees = math.degrees(radians)
21-
22-
return degrees
8+
def azimuth_from_coords(coords: List[Tuple[float, float]]) -> float:
9+
"""Calculate the least-squares slope of the input coordinates, return
10+
as degrees relative to the x axis."""
11+
12+
x_coords = [i[0] for i in coords]
13+
y_coords = [i[1] for i in coords]
14+
x_diff = x_coords[-1] - x_coords[0]
15+
y_diff = y_coords[-1] - y_coords[0]
16+
17+
# handle cases where the fit line would be horizontal or vertical,
18+
# as well as an issue where passing all 0s to np.polyfit (e.g. all
19+
# of the x values are 0, even though there are different y values)
20+
# raises an exception
21+
if len(set(x_coords)) == 1:
22+
if y_diff > 0:
23+
azimuth = 0
24+
else:
25+
azimuth = 180
26+
elif len(set(y_coords)) == 1:
27+
if x_diff > 0:
28+
azimuth = 90
29+
else:
30+
azimuth = 270
31+
# now handle all other cases by calculating the slope
32+
else:
33+
slope, intercept = np.polyfit(x_coords, y_coords, 1)
34+
35+
# this is the angle from 0 axis
36+
angle = math.degrees(math.atan(slope))
37+
38+
# now convert the angle to degrees from north by comparing
39+
# the first and last set of coords to determine the general
40+
# orientation of the fit line
41+
x_diff = coords[-1][0] - coords[0][0]
42+
y_diff = coords[-1][1] - coords[0][1]
43+
44+
# orientation: ne
45+
if x_diff > 0 and y_diff > 0:
46+
azimuth = 90 - angle
47+
# orientation: se
48+
elif x_diff > 0 and y_diff < 0:
49+
azimuth = 90 + abs(angle)
50+
# orientation: sw
51+
elif x_diff < 0 and y_diff < 0:
52+
azimuth = 270 - angle
53+
# orientation: nw
54+
elif x_diff < 0 and y_diff > 0:
55+
azimuth = 270 + abs(angle)
56+
57+
return azimuth
2358

2459

2560
def extend_vector(

ohmg/georeference/georeferencer.py

Lines changed: 35 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@
55
import time
66
from io import StringIO
77
from pathlib import Path
8-
from typing import List, Tuple
8+
from typing import List, Tuple, Union
99
from uuid import uuid4
1010

1111
from django.conf import settings
1212
from osgeo import gdal, ogr, osr
1313

1414
from ohmg.core.utils.srs import retrieve_srs_wkt
1515

16-
from .geometry import angle_from_coords
16+
from .geometry import azimuth_from_coords
1717

1818
logger = logging.getLogger(__name__)
1919

@@ -244,73 +244,57 @@ def _load_gcps_from_geojson(self, geo_json):
244244
)
245245
self.gcps.append(gcp)
246246

247-
def _geo_coords_from_gcp(self, gcp: gdal.GCP) -> Tuple[float, float]:
248-
"""Return the geographic x, y coords from the input GCP"""
249-
return (gcp.GCPX, gcp.GCPY)
250-
251-
def _pixel_coords_from_gcp(
252-
self, gcp: gdal.GCP, cartesian_y: bool = False
253-
) -> Tuple[float, float]:
254-
"""Return the image pixel coords from the input GCP.
255-
256-
Y coordinate is measured down from the top of the image.
257-
258-
If cartesian_y=True, then invert the Y coordinate against the height
259-
of the dataset, to match a cartesian plane with 0,0 at the bottom left of the
260-
image."""
261-
x, y = gcp.GCPPixel, gcp.GCPLine
262-
if cartesian_y:
263-
ds = gdal.Open(self.gcps_vrt.get_vsi_url())
264-
y = ds.RasterYSize - y
265-
return (x, y)
266-
267247
def _calculate_scale(self) -> float:
268248
"""Compares two GCPs and returns a scale factor."""
269249

270-
if len(self.gcps) != 2:
271-
raise Exception("Two GCPs are needed to calculate a scale factor")
250+
if len(self.gcps) < 2:
251+
raise Exception("At least GCPs are needed to calculate a scale factor")
272252

273-
gcp1, gcp2 = self.gcps
253+
gcp1, gcp2 = self.gcps[0], self.gcps[1]
274254

275255
# distance between the geographic coords in each GCP
276256
# this distance is absolute so the order of the coords doesn't matter
277-
pt_dist = math.dist(
278-
self._geo_coords_from_gcp(gcp1),
279-
self._geo_coords_from_gcp(gcp2),
257+
geo_dist = math.dist(
258+
(gcp1.GCPX, gcp1.GCPY),
259+
(gcp2.GCPX, gcp2.GCPY),
280260
)
281261

282262
# distance between the pixel coords in each GCP
283263
# this distance is absolute so the order of the coords doesn't matter
284-
px_dist = math.dist(
285-
self._pixel_coords_from_gcp(gcp1),
286-
self._pixel_coords_from_gcp(gcp2),
264+
img_dist = math.dist(
265+
(gcp1.GCPPixel, gcp1.GCPLine),
266+
(gcp2.GCPPixel, gcp2.GCPLine),
287267
)
288268

289-
return pt_dist / px_dist
269+
return geo_dist / img_dist
290270

291-
def _calculate_rotation_from_north(self) -> float:
271+
def _calculate_rotation(self, img_height: Union[float, None] = None) -> float:
292272
"""Compares two GCPs and calculates the difference in the angles
293273
between the geometric points and the pixel points.
294274
295275
Returns the angle in degrees from 'north', i.e. the positive Y axis"""
296-
if len(self.gcps) != 2:
297-
raise Exception("Two GCPs are needed to calculate a scale factor")
298276

299-
## the GCPLine value is the number of pixels DOWN from the top of the
300-
## image, so we'll call it "upper" because it appears the higher of the
301-
## two on the page
302-
upper_gcp = min(self.gcps, key=lambda x: x.GCPLine)
303-
lower_gcp = max(self.gcps, key=lambda x: x.GCPLine)
277+
if len(self.gcps) < 2:
278+
raise Exception("At least two GCPs are needed to calculate a scale factor")
304279

305-
pt_degrees = angle_from_coords(
306-
self._geo_coords_from_gcp(upper_gcp), self._geo_coords_from_gcp(lower_gcp)
307-
)
308-
px_degrees = angle_from_coords(
309-
self._pixel_coords_from_gcp(upper_gcp, cartesian_y=True),
310-
self._pixel_coords_from_gcp(lower_gcp, cartesian_y=True),
311-
)
280+
# sort the GCPs so they are ordered from lowest to highest,
281+
# then right to left, as they appear on the source image.
282+
# this allows us to figure out the orientation
283+
self.gcps.sort(key=lambda x: x.GCPPixel)
284+
self.gcps.sort(key=lambda x: x.GCPLine, reverse=True)
285+
286+
# make sure img height is set because it is needed to properly
287+
# handle the inverted Y coords.
288+
if not img_height:
289+
ds = gdal.Open(self.gcps_vrt.get_vsi_url())
290+
img_height = ds.RasterYSize
291+
img_coords = [(i.GCPPixel, img_height - i.GCPLine) for i in self.gcps]
292+
img_azimuth = azimuth_from_coords(img_coords)
293+
294+
geo_coords = [(i.GCPX, i.GCPY) for i in self.gcps]
295+
geo_azimuth = azimuth_from_coords(geo_coords)
312296

313-
difference = pt_degrees - px_degrees
297+
difference = geo_azimuth - img_azimuth
314298
return difference
315299

316300
def _calculate_helmert_offsets(self, scale: float, rotation: float) -> Tuple[float, float]:
@@ -325,8 +309,8 @@ def _calculate_helmert_offsets(self, scale: float, rotation: float) -> Tuple[flo
325309
dist_to_page_edge = use_gcp.GCPLine * scale
326310
dist_to_page_top = use_gcp.GCPPixel * scale
327311

328-
## rotation is degrees from positive y-axis, adjust to be degrees from
329-
## positive x-axis
312+
## rotation is azimuth, i.e. degrees from positive y-axis,
313+
## must adjust to be degrees from positive x-axis
330314
theta1 = 90 - rotation
331315
## further adjustments to normalize
332316
if theta1 < 0:
@@ -421,7 +405,7 @@ def make_warped_vrt(
421405
scale = self._calculate_scale()
422406

423407
## get rotation
424-
rotation = self._calculate_rotation_from_north()
408+
rotation = self._calculate_rotation()
425409
## adjust and convert to arcseconds
426410
arcseconds = (rotation + 90) * 3600
427411

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ dependencies = [
3232
"dialogos==0.4",
3333
"pinax==0.9a2",
3434
"pinax-announcements==4.0.1",
35-
"gdal>=3.5,<3.6",
35+
"gdal==3.8.4",
3636
"Pillow<10.0.0",
3737
"django_compressor",
3838
"natsort",

tests/test_warp.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import math
2+
3+
from django.test import tag
4+
from osgeo import gdal
5+
6+
from ohmg.georeference.georeferencer import Georeferencer
7+
8+
from .base import OHMGTestCase
9+
10+
11+
@tag("warp")
12+
class HelmertTransformationTestCase(OHMGTestCase):
13+
def test_helmert_transformation_calculations(self):
14+
"""This test runs through 8 permutations of GCPs, and makes
15+
sure that the calculations used to set up the helmert
16+
transformations return the right values for every permutation.
17+
"""
18+
19+
# assume an image with these dimensions
20+
img_width, img_height = 5, 8 # noqa: F841
21+
22+
# inner (smallest) angle for a 3,4,5 triangle
23+
theta_345 = math.degrees(math.asin(3 / 5))
24+
25+
# variables that define positions and expected values to test
26+
data_matrix = [
27+
(0, 2.5, 0, -0.5, 3),
28+
(2, 1.5, 90 - theta_345, 2.1, 2.2),
29+
(2.5, 0, 90, 3, 0.5),
30+
(2, -1.5, 90 + theta_345, 2.7, -1.4),
31+
(0, -2.5, 180, 0.5, -3),
32+
(-2, -1.5, 270 - theta_345, -2.1, -2.2),
33+
(-2.5, 0, 270, -3, -0.5),
34+
(-2, 1.5, 270 + theta_345, -2.7, 1.4),
35+
]
36+
37+
# GCP 1 stays constant
38+
# GCP 2's image coords stay constant while the geo coords move
39+
# clockwise around the origin.
40+
gcp1 = gdal.GCP(0, 0, 0, 1, 6)
41+
for gcpx, gcpy, target_rotation, x_offset, y_offset in data_matrix:
42+
# note 1. GCP args are: geo x, geo y, geo z (not used), img x, img y
43+
# note 2: img y uses inverse y axis (per GCP convention)
44+
# note 3: The distance between img GCPs is 5 which creates a 3,4,5
45+
# triangle during rotated permutations of the test (helpful)
46+
# note 4: All geo GCP coords halve the dimensions of the triangle
47+
# so scale is .5
48+
gcp2 = gdal.GCP(gcpx, gcpy, 0, 1, 1)
49+
50+
g = Georeferencer(crs="EPSG:3857", transformation="helmert", gcps_gdal=[gcp1, gcp2])
51+
52+
scale = g._calculate_scale()
53+
self.assertEqual(scale, 0.5)
54+
rotation = g._calculate_rotation(img_height=img_height)
55+
self.assertEqual(rotation, target_rotation)
56+
57+
dx, dy = g._calculate_helmert_offsets(scale=scale, rotation=rotation)
58+
self.assertAlmostEqual(dx, x_offset)
59+
self.assertAlmostEqual(dy, y_offset)

uv.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)