diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml
index 39dd0cd..49e529e 100644
--- a/.github/workflows/run-unit-tests.yml
+++ b/.github/workflows/run-unit-tests.yml
@@ -15,7 +15,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- python-version: ['3.9', '3.11', '3.12', '3.x']
+ python-version: ['3.9', '3.11', '3.12', '3.13']
os: [ macos-latest, ubuntu-latest, windows-latest ]
steps:
diff --git a/README.md b/README.md
index 5a2c2be..c422579 100644
--- a/README.md
+++ b/README.md
@@ -16,14 +16,11 @@ pip install plan-rect
## Usage
-Rectification is performed with the ``plan-rect`` command. It requires an image, camera interior parameters and marker locations as inputs, and creates a rectified image and rectification data file as outputs. Its options are described below:
+Rectification is performed with the ``plan-rect`` command. It requires an image and marker locations as inputs, and creates a rectified image and rectification data file as outputs. Its options are described below:
| Option | Value | Description |
|-------------------------------|----------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------|
| ``-im`` / ``--image`` | FILE | Path / URI of the source image (required). |
-| ``-fl`` / ``--focal-len`` | FLOAT | Camera focal length (any units). |
-| ``-ss`` / ``--sensor-size`` | WIDTH HEIGHT | Camera sensor size in the same units as ``-fl`` / ``--focal-len``. |
-| ``-ip`` / ``--int-param`` | FILE | Path / URI of an Orthority format interior parameter file. |
| ``-m`` / ``--marker`` | ID X Y COL ROW | Marker ID and location in world and pixel coordinates, with pixel coordinate origin at the bottom left image corner. |
| ``-g`` / ``--gcp`` | FILE | Path / URI of an Orthority GCP file defining marker locations. |
| ``-r`` / ``--res`` | FLOAT | Rectified pixel size in meters. Can be used twice for non-square pixels: ``--res WIDTH --res HEIGHT``. Defaults to the ground sampling distance. |
@@ -35,35 +32,33 @@ Rectification is performed with the ``plan-rect`` command. It requires an image
| ``--version`` | | Show the version and exit. |
| ``--help`` | | Show the help and exit. |
-Camera interior parameters are required with either ``-fl`` / ``--focal-len`` and ``-ss`` / ``--sensor-size``, or ``-ip`` / ``--int-param``.
-
-Marker locations are required with either ``-m`` / ``--marker`` or ``-g`` / ``--gcp``. The ``-m`` / ``--marker`` option can be provided multiple times. At least three markers are required.
+Marker locations are required with either ``-m`` / ``--marker`` or ``-g`` / ``--gcp``. The ``-m`` / ``--marker`` option can be provided multiple times. At least four markers are required.
### Examples
-Supply interior parameters with ``-fl`` / ``--focal-len`` and ``-ss`` / ``--sensor-size``, and marker locations with ``-m`` / ``--marker``:
+Supply marker locations with ``-m`` / ``--marker``:
```commandline
-plan-rect --image source.jpg --focal-len 50 --sensor-size 31.290 23.491 --marker A 0 0 1002 1221 --marker B 2.659 0 4261 1067 --marker C 2.321 5.198 3440 3706 --marker D -0.313 4.729 1410 3663
+plan-rect --image source.jpg --marker A 0 0 1002 1221 --marker B 2.659 0 4261 1067 --marker C 2.321 5.198 3440 3706 --marker D -0.313 4.729 1410 3663
```
-Supply interior parameters with ``-ip`` / ``--int-param`` and marker locations with ``-g`` / ``--gcp``:
+Supply marker locations with ``-g`` / ``--gcp``:
```commandline
-plan-rect --image source.jpg --int-param int_param.yaml --gcp gcps.geojson
+plan-rect --image source.jpg --gcp gcps.geojson
```
Set the rectified image pixel size with ``-r`` / ``--res``:
```commandline
-plan-rect --image source.jpg --res 0.01 --int-param int_param.yaml --gcp gcps.geojson
+plan-rect --image source.jpg --res 0.01 --gcp gcps.geojson
```
-Export interior parameters and marker locations to Orthority format files in the ``data`` directory, overwriting existing outputs:
+Export marker locations to an Orthority GCP file in the ``data`` directory, overwriting any existing file:
```commandline
-plan-rect --image source.jpg --export-params --out-dir data --overwrite --focal-len 50 --sensor-size 31.290 23.491 --marker A 0 0 1002 1221 --marker B 2.659 0 4261 1067 --marker C 2.321 5.198 3440 3706 --marker D -0.313 4.729 1410 3663
+plan-rect --image source.jpg --export-params --out-dir data --overwrite --marker A 0 0 1002 1221 --marker B 2.659 0 4261 1067 --marker C 2.321 5.198 3440 3706 --marker D -0.313 4.729 1410 3663
```
## Licence
diff --git a/plan_rect/camera.py b/plan_rect/camera.py
new file mode 100644
index 0000000..55b0223
--- /dev/null
+++ b/plan_rect/camera.py
@@ -0,0 +1,48 @@
+# Copyright The Plan-Rect Contributors.
+#
+# This file is part of Plan-Rect.
+#
+# Plan-Rect is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Affero General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later version.
+#
+# Plan-Rect is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+# PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License along with
+# Plan-Rect. If not, see .
+from __future__ import annotations
+
+import numpy as np
+from orthority.camera import Camera
+
+
+class PerspectiveCamera(Camera):
+ """
+ Perspective camera for projecting between planes.
+
+ :param im_size:
+ Image (width, height) in pixels.
+ :param tform:
+ Perspective transform from world to pixel coordinates, as a 3-by-3 array.
+ """
+
+ def __init__(self, im_size: tuple[int, int], tform: np.ndarray):
+ self._im_size = im_size
+ self._tform = tform
+
+ def world_to_pixel(self, xyz: np.ndarray) -> np.ndarray:
+ xyz_ = np.vstack((xyz[:2], np.ones((1, xyz.shape[1]))))
+ ji_ = self._tform.dot(xyz_)
+ ji = ji_[:2] / ji_[2]
+ return ji
+
+ def pixel_to_world_z(self, ji: np.ndarray, z: float | np.ndarray) -> np.ndarray:
+ # only allow projecting to the world z=0 plane
+ assert np.all(z == 0)
+ ji_ = np.vstack((ji, np.ones((1, ji.shape[1]))))
+ xyz = np.linalg.inv(self._tform).dot(ji_)
+ xyz /= xyz[2]
+ xyz[2] = 0
+ return xyz
diff --git a/plan_rect/cli.py b/plan_rect/cli.py
index 0466b8b..8c82037 100644
--- a/plan_rect/cli.py
+++ b/plan_rect/cli.py
@@ -20,17 +20,17 @@
from pathlib import Path
import click
+import cv2
import fsspec
import numpy as np
import rasterio as rio
from fsspec.core import OpenFile
from orthority import param_io
-from orthority.camera import create_camera
from orthority.common import OpenRaster, join_ofile
-from orthority.enums import CameraType, Interp
-from orthority.fit import fit_frame_exterior
+from orthority.enums import Interp
from rasterio.errors import NotGeoreferencedWarning
+from plan_rect.camera import PerspectiveCamera
from plan_rect.param_io import write_rectification_data
from plan_rect.rectify import rectify
from plan_rect.version import __version__
@@ -90,30 +90,6 @@ def _dir_cb(ctx: click.Context, param: click.Parameter, uri_path: str) -> OpenFi
callback=partial(_file_cb, mode='rb'),
help='Path / URI of the source image.',
)
-@click.option(
- '-fl',
- '--focal-len',
- type=click.FLOAT,
- default=None,
- help='Camera focal length.',
-)
-@click.option(
- '-ss',
- '--sensor-size',
- type=(float, float),
- metavar='WIDTH HEIGHT',
- default=None,
- help='Camera sensor size in the same units as --focal-len.',
-)
-@click.option(
- '-ip',
- '--int-param',
- 'int_param_file',
- type=click.Path(dir_okay=False),
- default=None,
- callback=partial(_file_cb, mode='rt'),
- help='Path / URI of an Orthority format interior parameter file.',
-)
@click.option(
'-m',
'--marker',
@@ -166,10 +142,9 @@ def _dir_cb(ctx: click.Context, param: click.Parameter, uri_path: str) -> OpenFi
'-ep',
'--export-params',
is_flag=True,
- type=click.BOOL,
default=False,
show_default=True,
- help='Export interior parameters and markers to Orthority format files and exit.',
+ help='Export markers to an Orthority GCP file and exit.',
)
@click.option(
'-od',
@@ -184,7 +159,6 @@ def _dir_cb(ctx: click.Context, param: click.Parameter, uri_path: str) -> OpenFi
'-o',
'--overwrite',
is_flag=True,
- type=click.BOOL,
default=False,
show_default=True,
help='Overwrite existing output(s).',
@@ -194,9 +168,6 @@ def _dir_cb(ctx: click.Context, param: click.Parameter, uri_path: str) -> OpenFi
def cli(
ctx: click.Context,
image_file: OpenFile,
- focal_len: float,
- sensor_size: tuple[float, float],
- int_param_file: OpenFile,
markers: tuple[tuple[str, float, float, float, float]],
gcp_file: OpenFile,
export_params: bool,
@@ -206,11 +177,8 @@ def cli(
):
"""Rectify an image onto a plane.
- Camera interior parameters are required with either '-fl' / '--focal-len' and
- '-ss' / '--sensor-size', or '-ip' / '--int-param'.
-
Marker locations are required with either '-m' / '--marker' or '-g' / '--gcp'.
- The '-m' / '--marker' option can be provided multiple times. At least three
+ The '-m' / '--marker' option can be provided multiple times. At least four
markers are required.
"""
# silence not georeferenced warnings
@@ -218,27 +186,6 @@ def cli(
# enter rasterio environment
ctx.with_resource(rio.Env(GDAL_NUM_THREADS='ALL_CPUS', GTIFF_FORCE_RGBA=False))
- # form an interior parameter dictionary
- if (focal_len is None or not sensor_size) and not int_param_file:
- raise click.UsageError(
- "Interior parameters are required with '-fl' / '--focal-len' and '-ss' "
- "/'--sensor-size', or '-ip' / '--int-param'."
- )
- if focal_len and sensor_size:
- with OpenRaster(image_file, 'r') as src_im:
- im_size = src_im.shape[::-1]
- int_param = dict(
- cam_type=CameraType.pinhole,
- im_size=im_size,
- focal_len=focal_len,
- sensor_size=sensor_size,
- )
- int_param_dict = {'PR-Camera': int_param}
- else:
- int_param_dict = param_io.read_oty_int_param(int_param_file)
- int_param = next(iter(int_param_dict.values()))
- im_size = int_param['im_size']
-
# form a GCP dictionary
image_path = Path(image_file.path)
if not markers and not gcp_file:
@@ -246,6 +193,8 @@ def cli(
"Marker locations are required with either '-m' / '--marker' or '-g' / "
"'--gcp'."
)
+ with OpenRaster(image_file, 'r') as src_im:
+ im_size = src_im.shape[::-1]
if markers:
# convert marker locations to GCPs with pixel coordinates converted from BL to
@@ -272,29 +221,28 @@ def cli(
)
gcp_dict = {image_path.name: gcps}
- if len(gcps) < 3:
- raise click.UsageError('At least three markers are required.')
+ if len(gcps) < 4:
+ raise click.UsageError('At least four markers are required.')
if export_params:
# export interior parameters and GCPs as orthority format files, and exit
gcp_file = join_ofile(out_dir, 'gcps.geojson', mode='wt')
param_io.write_gcps(gcp_file, gcp_dict, overwrite=overwrite)
- int_param_file = join_ofile(out_dir, 'int_param.yaml', mode='wt')
- param_io.write_int_param(int_param_file, int_param_dict, overwrite=overwrite)
- click.echo(f"Orthority format files written to: '{out_dir.path}'.")
+ click.echo(f"Orthority GCP file written to: '{gcp_file.path}'.")
return
- # fit exterior parameters & create camera
- ext_param_dict = fit_frame_exterior(int_param_dict, gcp_dict)
- ext_param = next(iter(ext_param_dict.values()))
- cam = create_camera(**int_param, xyz=ext_param['xyz'], opk=ext_param['opk'])
+ # fit perspective transform & create camera
+ gcp_ji = np.array([gcp['ji'] for gcp in gcps]).T
+ gcp_xyz = np.array([gcp['xyz'] for gcp in gcps]).T
+ tform, _ = cv2.findHomography(
+ gcp_xyz[:2].T.astype('float32'), gcp_ji.T.astype('float32')
+ )
+ cam = PerspectiveCamera(im_size, tform)
# find & print fitting error
- gcp_xyz = np.array([gcp['xyz'] for gcp in gcps]).T
- gcp_ji = np.array([gcp['ji'] for gcp in gcps]).T
cam_ji = cam.world_to_pixel(gcp_xyz)
err = np.sqrt(np.sum((gcp_ji - cam_ji) ** 2, axis=0)).mean()
- click.echo(f'RMS fitting error: {err:.4f} (pixels).')
+ click.echo(f'RMS fitting error: {err:.4e} (pixels).')
# rectify
rect_array, transform = rectify(image_file, cam, **kwargs)
@@ -315,9 +263,8 @@ def cli(
# find pixel coordinates of markers in the rectified image (0.5 translation
# shifts the transform from the GDAL / Rasterio convention to give integer
# coordinates that refer to pixel centers)
- cam_xyz = cam.pixel_to_world_z(gcp_ji, z=0)
inv_transform = ~(rio.Affine(*transform) * rio.Affine.translation(0.5, 0.5))
- rect_ji = np.array(inv_transform * cam_xyz[:2])
+ rect_ji = np.array(inv_transform * gcp_xyz[:2])
# create a rectified marker list, converting pixel coordinates from TL to BL
# origin convention
@@ -330,11 +277,7 @@ def cli(
# write rectification data
rect_data_file = join_ofile(out_dir, 'pixeldata.txt', mode='wt')
write_rectification_data(
- rect_data_file,
- image_path.name,
- int_param,
- rect_markers,
- overwrite=overwrite,
+ rect_data_file, image_path.name, im_size, rect_markers, overwrite=overwrite
)
click.echo(f"Output files written to: '{out_dir.path}'.")
diff --git a/plan_rect/param_io.py b/plan_rect/param_io.py
index def65d2..3f2d1df 100644
--- a/plan_rect/param_io.py
+++ b/plan_rect/param_io.py
@@ -24,7 +24,7 @@
def write_rectification_data(
file: str | PathLike | OpenFile,
src_name: str,
- int_param: dict[str, Any],
+ im_size: tuple[int, int],
markers: list[dict[str, Any]],
overwrite: bool = False,
):
@@ -37,8 +37,8 @@ def write_rectification_data(
mode (``'wt'``).
:param src_name:
Source image file name.
- :param int_param:
- Camera interior parameters.
+ :param im_size:
+ Image (width, height) in pixels.
:param markers:
Markers as a list of dictionaries with ``id``: , and ``ji``:
items.
@@ -47,20 +47,7 @@ def write_rectification_data(
"""
with Open(file, 'wt', overwrite=overwrite) as f:
f.write(f'Photo;{src_name}\n')
- im_size = int_param['im_size']
f.write(f'Size;{im_size[0]},{im_size[1]};px\n')
- focal_len = int_param['focal_len']
- focal_str = (
- f'{focal_len:.4f}'
- if not isinstance(focal_len, tuple)
- else f'{focal_len[0]:.4f},{focal_len[1]:.4f}'
- )
- f.write(f'Lens;{focal_str};?\n')
-
- if 'sensor_size' in int_param:
- sensor_size = int_param['sensor_size']
- f.write(f'Sensor;{sensor_size[0]:.4f},{sensor_size[1]:.4f};?\n')
-
for m in markers:
f.write(f'{m["id"]};{m["ji"][0]:.4f},{m["ji"][1]:.4f},0\n')
diff --git a/plan_rect/rectify.py b/plan_rect/rectify.py
index 8174cd8..d1417a2 100644
--- a/plan_rect/rectify.py
+++ b/plan_rect/rectify.py
@@ -20,7 +20,7 @@
import numpy as np
import rasterio as rio
from fsspec.core import OpenFile
-from orthority.camera import FrameCamera
+from orthority.camera import Camera
from orthority.common import OpenRaster
from orthority.enums import Interp
@@ -38,7 +38,7 @@ def _area_poly(coords: np.ndarray) -> float:
def rectify(
src_file: str | PathLike | OpenFile,
- camera: FrameCamera,
+ camera: Camera,
resolution: tuple[float, float] | None = None,
interp: str | Interp = Interp.cubic,
nodata: int | float | None = None,
diff --git a/tests/test_plan_rect.py b/tests/test_plan_rect.py
index 21ab253..a9eb6f3 100644
--- a/tests/test_plan_rect.py
+++ b/tests/test_plan_rect.py
@@ -17,25 +17,22 @@
from pathlib import Path
from typing import Any
+import cv2
import numpy as np
import pytest
import rasterio as rio
from click.testing import CliRunner
-from orthority.camera import FrameCamera, create_camera
-from orthority.enums import CameraType, Interp
-from orthority.param_io import (
- read_oty_gcps,
- read_oty_int_param,
- write_gcps,
- write_int_param,
-)
+from orthority.camera import Camera
+from orthority.enums import Interp
+from orthority.param_io import read_oty_gcps, write_gcps
+from plan_rect.camera import PerspectiveCamera
from plan_rect.cli import cli
from plan_rect.param_io import write_rectification_data
from plan_rect.rectify import rectify
-def get_gcps(camera: FrameCamera, ji: np.ndarray) -> list[dict[str, Any]]:
+def get_gcps(camera: Camera, ji: np.ndarray) -> list[dict[str, Any]]:
"""Return a list of GCPs generated with the given camera and ji pixel
coordinates.
"""
@@ -68,40 +65,45 @@ def runner():
@pytest.fixture()
-def gradient_array() -> np.ndarray:
+def im_size() -> tuple[int, int]:
+ """A (width, height) image size."""
+ return (200, 100)
+
+
+@pytest.fixture()
+def gradient_array(im_size: tuple[int, int]) -> np.ndarray:
"""An asymmetrical gradient array."""
- x = np.linspace(0, 1, 200)
- y = np.linspace(0, 1, 100)
+ x = np.linspace(0, 1, im_size[0])
+ y = np.linspace(0, 1, im_size[1])
xgrid, ygrid = np.meshgrid(x, y, indexing='xy')
return (xgrid * ygrid * 250).astype('uint8')
@pytest.fixture()
-def int_param(gradient_array: np.ndarray) -> dict[str, Any]:
- """Pinhole camera interior parameters."""
- im_size = gradient_array.shape[::-1]
- return dict(
- cam_type=CameraType.pinhole,
- im_size=im_size,
- focal_len=1.0,
- sensor_size=(1.0, im_size[1] / im_size[0]),
- )
+def straight_tform(im_size: tuple[int, int]) -> np.ndarray:
+ """A perspective transform from world to pixel coordinates with aligned axes and
+ origins.
+ """
+ # tform[1, 1] is -ve as pixel and world y axes are flipped
+ tform = np.diag([im_size[0], -im_size[0], 1.0])
+ tform[:2, 2] = (np.array(im_size) - 1) / 2
+ return tform
@pytest.fixture()
-def straight_camera(int_param: dict[str, Any]) -> FrameCamera:
- """A pinhole camera aligned with world coordinate axes and positioned above the
- world coordinate origin.
- """
- return create_camera(**int_param, opk=(0.0, 0.0, 0.0), xyz=(0.0, 0.0, 1.0))
+def straight_camera(im_size: tuple[int, int], straight_tform: np.ndarray) -> Camera:
+ """A perspective camera aligned with world coordinate axes and origin."""
+ return PerspectiveCamera(im_size, straight_tform)
@pytest.fixture()
-def oblique_camera(int_param: dict[str, Any]) -> FrameCamera:
- """A pinhole camera with an oblique world view."""
- return create_camera(
- **int_param, opk=np.radians((15.0, -5.0, 10.0)).tolist(), xyz=(1.0, 2.0, 3.0)
- )
+def oblique_camera(im_size: tuple[int, int], straight_tform: np.ndarray) -> Camera:
+ """A perspective camera with an oblique world view."""
+ oblique_tform = straight_tform.copy()
+ R = cv2.Rodrigues(np.radians((15.0, -5.0, 10.0)))[0]
+ oblique_tform = oblique_tform.dot(R)
+ oblique_tform /= oblique_tform[2, 2]
+ return PerspectiveCamera(im_size, oblique_tform)
@pytest.fixture()
@@ -121,35 +123,24 @@ def gradient_image_file(gradient_array: np.ndarray, tmp_path: Path) -> Path:
@pytest.fixture()
-def int_param_file(int_param: dict[str, Any], tmp_path: Path) -> Path:
- """An Orthority interior parameter file."""
- file = tmp_path.joinpath('int_param.yaml')
- int_param_dict = {'PR Camera': int_param}
- write_int_param(file, int_param_dict)
- return file
-
-
-@pytest.fixture()
-def gcp_ji(int_param: dict[str, Any]) -> np.ndarray:
+def gcp_ji(im_size: tuple[int, int]) -> np.ndarray:
"""A Numpy array of pixel coordinates for four GCPs."""
buf = 10
- w, h = (int_param['im_size'][0] - 1, int_param['im_size'][1] - 1)
+ w, h = (im_size[0] - 1, im_size[1] - 1)
ji = [[buf, h - buf], [w - buf, h - buf], [w - buf, buf], [buf, buf]]
return np.array(ji).T
@pytest.fixture()
-def marker_ji(int_param: dict[str, Any], gcp_ji: np.ndarray) -> np.ndarray:
+def marker_ji(im_size: tuple[int, int], gcp_ji: np.ndarray) -> np.ndarray:
"""A Numpy array of pixel coordinates for four 'markers' (coordinate origin in
bottom left image corner).
"""
- return np.array([gcp_ji[0], int_param['im_size'][1] - 1 - gcp_ji[1]])
+ return np.array([gcp_ji[0], im_size[1] - 1 - gcp_ji[1]])
@pytest.fixture()
-def straight_gcps(
- straight_camera: FrameCamera, gcp_ji: np.ndarray
-) -> list[dict[str, Any]]:
+def straight_gcps(straight_camera: Camera, gcp_ji: np.ndarray) -> list[dict[str, Any]]:
"""A list of GCPs generated with 'straight_camera'."""
return get_gcps(straight_camera, gcp_ji)
@@ -166,40 +157,42 @@ def straight_gcp_file(
@pytest.fixture()
-def cli_focal_len_sensor_size_marker_str(
+def cli_marker_str(
gradient_image_file: Path,
- int_param: dict[str, Any],
straight_gcps: list[dict[str, Any]],
marker_ji: np.ndarray,
gcp_ji: np.ndarray,
) -> str:
- """A CLI string using the --focal-len, --sensor-size and --marker options."""
+ """A CLI string using the --marker option."""
# create marker option strings
marker_strs = [
- f' -m {m_id} {gcp["xyz"][0]} {gcp["xyz"][1]} {m_ji[0]} {m_ji[1]} '
+ f' -m {m_id} {gcp["xyz"][0]} {gcp["xyz"][1]} {m_ji[0]} {m_ji[1]}'
for m_id, m_ji, gcp in zip('ABCD', marker_ji.T, straight_gcps)
]
- # create cli string
- cli_str = (
- f'-im {gradient_image_file} -fl {int_param["focal_len"]} -ss '
- f'{int_param["sensor_size"][0]} {int_param["sensor_size"][1]}'
- )
- cli_str += ''.join(marker_strs)
- return cli_str
+ return f'-im {gradient_image_file}' + ''.join(marker_strs)
@pytest.fixture()
-def cli_int_param_gcp_str(
- gradient_image_file: Path, int_param_file: dict[str, Any], straight_gcp_file: Path
-) -> str:
- """A CLI string using the --int-param and --gcp options."""
- cli_str = f'-im {gradient_image_file} -ip {int_param_file} -g {straight_gcp_file}'
- return cli_str
+def cli_gcp_str(gradient_image_file: Path, straight_gcp_file: Path) -> str:
+ """A CLI string using the --gcp option."""
+ return f'-im {gradient_image_file} -g {straight_gcp_file}'
+
+
+@pytest.mark.parametrize('camera', ['straight_camera', 'oblique_camera'])
+def test_perspective_camera(
+ camera: str, gcp_ji: np.ndarray, request: pytest.FixtureRequest
+):
+ """Test camera.PerspectiveCamera."""
+ camera: PerspectiveCamera = request.getfixturevalue(camera)
+ test_xyz = camera.pixel_to_world_z(gcp_ji, z=0)
+ assert np.all(test_xyz[2] == 0)
+ test_ji = camera.world_to_pixel(test_xyz)
+ assert test_ji == pytest.approx(gcp_ji, abs=1e-9)
def test_rectify(
- straight_camera: FrameCamera, gradient_image_file: Path, gradient_array: np.ndarray
+ straight_camera: Camera, gradient_image_file: Path, gradient_array: np.ndarray
):
"""Test rectify.rectify() with auto resolution."""
# the camera looks straight down on world coordinates so that the rectified image
@@ -212,22 +205,23 @@ def test_rectify(
assert transform == (0.005, 0, -0.5, 0, -0.005, 0.25)
-def test_rectify_resolution(straight_camera: FrameCamera, gradient_image_file: Path):
+def test_rectify_resolution(straight_camera: Camera, gradient_image_file: Path):
"""Test the rectify.rectify() resolution parameter."""
res = (0.02, 0.01)
rect_array, transform = rectify(
gradient_image_file, straight_camera, resolution=res, interp='average'
)
- assert rect_array.shape[1:] == (50, 50)
+ assert rect_array.shape[1:] == pytest.approx((50, 50), abs=1)
assert (transform[0], abs(transform[4])) == res
-def test_rectify_interp(straight_camera: FrameCamera, gradient_image_file: Path):
+def test_rectify_interp(straight_camera: Camera, gradient_image_file: Path):
"""Test the rectify.rectify() interp parameter."""
rect_arrays = []
- # use a resolution that gives non-integer remap maps to force interpolation
+ # use a resolution that gives non-integer remap maps to force interpolation,
+ # and interpolation types with kernels that span >1 pixel to avoid nodata on border
res = (0.011, 0.011)
- for interp in [Interp.nearest, Interp.cubic]:
+ for interp in [Interp.bilinear, Interp.cubic]:
rect_array, _ = rectify(
gradient_image_file, straight_camera, interp=interp, resolution=res
)
@@ -238,7 +232,7 @@ def test_rectify_interp(straight_camera: FrameCamera, gradient_image_file: Path)
assert (rect_arrays[0] != rect_arrays[1]).any()
-def test_rectify_nodata(oblique_camera: FrameCamera, gradient_image_file: Path):
+def test_rectify_nodata(oblique_camera: Camera, gradient_image_file: Path):
"""Test the rectify.rectify() nodata parameter."""
rect_masks = []
for nodata in [254, 255]:
@@ -252,39 +246,37 @@ def test_rectify_nodata(oblique_camera: FrameCamera, gradient_image_file: Path):
def test_write_rectification_data(
- int_param: dict, marker_ji: np.ndarray, tmp_path: Path
+ im_size: tuple[int, int], marker_ji: np.ndarray, tmp_path: Path
):
"""Test param_io.write_rectification_data()."""
src_name = 'source.jpg'
ids = 'ABCD'
markers = [dict(id=mkr_id, ji=mkr_ji) for mkr_id, mkr_ji in zip(ids, marker_ji.T)]
out_file = tmp_path.joinpath('pixeldata.txt')
- write_rectification_data(out_file, src_name, int_param, markers)
+ write_rectification_data(out_file, src_name, im_size, markers)
assert out_file.exists()
# rough check of contents
with open(out_file) as f:
lines = f.readlines()
- prefixes = ['Photo', 'Size', 'Lens', 'Sensor', *ids]
+ prefixes = ['Photo', 'Size', *ids]
assert len(lines) == len(prefixes)
assert all([line.startswith(start + ';') for start, line in zip(prefixes, lines)])
for line, mkr_ji in zip(lines[-len(markers) :], marker_ji.T):
assert f'{mkr_ji[0]:.4f},{mkr_ji[1]:.4f}' in line
-def test_write_rectification_data_overwrite(int_param: dict, tmp_path: Path):
+def test_write_rectification_data_overwrite(im_size: tuple[int, int], tmp_path: Path):
"""Test the param_io.write_rectification_data() overwrite parameter."""
out_file = tmp_path.joinpath('pixeldata.txt')
out_file.touch()
with pytest.raises(FileExistsError):
- write_rectification_data(out_file, 'source.jpg', int_param, [])
- write_rectification_data(out_file, 'source.jpg', int_param, [], overwrite=True)
+ write_rectification_data(out_file, 'source.jpg', im_size, [])
+ write_rectification_data(out_file, 'source.jpg', im_size, [], overwrite=True)
assert out_file.exists()
-@pytest.mark.parametrize(
- 'cli_str', ['cli_focal_len_sensor_size_marker_str', 'cli_int_param_gcp_str']
-)
+@pytest.mark.parametrize('cli_str', ['cli_marker_str', 'cli_gcp_str'])
def test_cli_outputs(
cli_str: str,
marker_ji: np.ndarray,
@@ -293,9 +285,7 @@ def test_cli_outputs(
tmp_path: Path,
request: pytest.FixtureRequest,
):
- """Tes the accuracy of CLI output files with different interior parameter and
- marker location options.
- """
+ """Tes the accuracy of CLI output files with different marker location options."""
cli_str: str = request.getfixturevalue(cli_str)
# run the command
cli_str += f' -od {tmp_path}'
@@ -311,21 +301,18 @@ def test_cli_outputs(
# test accuracy of output data
with rio.open(rect_image_file, 'r') as rect_im:
transform = rect_im.transform
- rect_array = rect_im.read()
rect_ji = read_rectification_ji(rect_data_file)
- # the camera looks straight down on world coordinates so that the rectified image
- # should ~match the source image, and rectified marker pixel coordinates ~match
- # the input marker pixel coordinates
- assert transform[:6] == pytest.approx((0.005, 0, -0.5, 0, -0.005, 0.25), abs=1e-6)
- assert rect_array[0] == pytest.approx(gradient_array, abs=1)
- assert rect_ji == pytest.approx(marker_ji, abs=1)
+ # the camera looks straight down on world coordinates so that the rectified
+ # marker pixel coordinates should ~match the input marker pixel coordinates
+ assert transform[:6] == pytest.approx((0.005, 0, -0.5, 0, -0.005, 0.25), abs=1e-4)
+ assert rect_ji == pytest.approx(marker_ji, abs=0.1)
-def test_cli_resolution(cli_int_param_gcp_str: str, runner: CliRunner, tmp_path: Path):
+def test_cli_resolution(cli_gcp_str: str, runner: CliRunner, tmp_path: Path):
"""Tes the CLI --res option."""
res = (0.02, 0.01)
- cli_str = cli_int_param_gcp_str + f' -r {res[0]} -r {res[1]} -od {tmp_path}'
+ cli_str = cli_gcp_str + f' -r {res[0]} -r {res[1]} -od {tmp_path}'
res_ = runner.invoke(cli, cli_str.split())
assert res_.exit_code == 0
@@ -335,20 +322,17 @@ def test_cli_resolution(cli_int_param_gcp_str: str, runner: CliRunner, tmp_path:
assert rect_im.res == res
-def test_cli_interp(
- cli_int_param_gcp_str: str,
- runner: CliRunner,
- tmp_path: Path,
-):
+def test_cli_interp(cli_gcp_str: str, runner: CliRunner, tmp_path: Path):
"""Tes the CLI --interp option."""
# create rectified images with different interpolation types
out_dirs = []
- for interp in ['nearest', 'cubic']:
+ # use interpolation types with kernels that span >1 pixel to avoid nodata on border
+ for interp in ['bilinear', 'cubic']:
out_dir = tmp_path.joinpath(interp)
out_dir.mkdir()
out_dirs.append(out_dir)
- # use a resolution that gives non-integer remap maps to force interpolation
- cli_str = cli_int_param_gcp_str + f' -i {interp} -r 0.011 -od {out_dir}'
+ # use a resolution that gives non-integer remap maps to force interpolation,
+ cli_str = cli_gcp_str + f' -i {interp} -r 0.011 -od {out_dir}'
res_ = runner.invoke(cli, cli_str.split())
assert res_.exit_code == 0
@@ -365,10 +349,9 @@ def test_cli_interp(
def test_cli_nodata(
- oblique_camera: FrameCamera,
+ oblique_camera: Camera,
gcp_ji: np.ndarray,
gradient_image_file: Path,
- int_param_file: Path,
runner: CliRunner,
tmp_path: Path,
):
@@ -382,7 +365,7 @@ def test_cli_nodata(
# rectify with different --nodata vals
out_dirs = []
nodatas = [254, 255] # image vals are <= 250
- cli_common_str = f'-im {gradient_image_file} -ip {int_param_file} -g {gcp_file}'
+ cli_common_str = f'-im {gradient_image_file} -g {gcp_file}'
for nodata in nodatas:
out_dir = tmp_path.joinpath(str(nodata))
out_dir.mkdir()
@@ -405,39 +388,33 @@ def test_cli_nodata(
assert (rect_masks[1] == rect_masks[0]).all()
-def test_cli_overwrite(cli_int_param_gcp_str: str, runner: CliRunner, tmp_path: Path):
+def test_cli_overwrite(cli_gcp_str: str, runner: CliRunner, tmp_path: Path):
"""Tes the CLI --overwrite option."""
rect_image_file = tmp_path.joinpath('rect.png')
rect_image_file.touch()
- cli_str = cli_int_param_gcp_str + f' -od {tmp_path}'
+ cli_str = cli_gcp_str + f' -od {tmp_path}'
res = runner.invoke(cli, cli_str.split())
assert res.exit_code != 0
- cli_str = cli_int_param_gcp_str + f' -o -od {tmp_path} '
+ cli_str = cli_gcp_str + f' -o -od {tmp_path} '
res = runner.invoke(cli, cli_str.split())
assert res.exit_code == 0
def test_cli_export_params(
- cli_focal_len_sensor_size_marker_str: str,
+ cli_marker_str: str,
gradient_image_file: Path,
- int_param: dict[str, Any],
straight_gcps: list[dict[str, Any]],
runner: CliRunner,
tmp_path: Path,
):
"""Tes the CLI --export-params option."""
- cli_str = cli_focal_len_sensor_size_marker_str + f' -ep -od {tmp_path}'
+ cli_str = cli_marker_str + f' -ep -od {tmp_path}'
res_ = runner.invoke(cli, cli_str.split())
assert res_.exit_code == 0
- int_param_file = tmp_path.joinpath('int_param.yaml')
gcp_file = tmp_path.joinpath('gcps.geojson')
- assert int_param_file.exists()
assert gcp_file.exists()
- test_int_param_dict = read_oty_int_param(int_param_file)
- assert next(iter(test_int_param_dict.values())) == int_param
-
ref_gcp_dict = {gradient_image_file.name: straight_gcps}
test_gcp_dict = read_oty_gcps(gcp_file)
assert test_gcp_dict == ref_gcp_dict