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