diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0a8c3ba6..cf59f602 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,10 +32,10 @@ jobs: sudo apt-get -qq install exiftool libgdal-dev libzbar0t64 GDAL_VERSION=$(gdal-config --version) pip install "gdal==$GDAL_VERSION" + - name: Install package and test dependencies + run: pip install -e ".[test]" - name: Test with pytest - run: | - pip install -e .[test] - pytest -m "not newgdal" + run: pytest - name: Build package run: | python -m pip install build diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index c3a0fa1a..79a7c551 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -37,10 +37,10 @@ jobs: sudo apt-get -qq install exiftool libgdal-dev libzbar0t64 GDAL_VERSION=$(gdal-config --version) pip install "gdal==$GDAL_VERSION" + - name: Install package and test dependencies + run: pip install -e ".[test]" - name: Test with pytest - run: | - pip install -e .[test] - pytest -m "not newgdal" --cov=./ --cov-report=xml + run: pytest - uses: actions/upload-artifact@v6 if: ${{ matrix.python-version == '3.12' }} with: diff --git a/.gitignore b/.gitignore index 68437748..7a6a4293 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Add any directories, files, or patterns you don't want to be tracked by version control *.pyc .ipynb_checkpoints +.coverage +coverage.xml +.venv/ __pycache__ /dist/ /*.egg-info diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index efb4d41b..27c72c23 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,13 +1,13 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace - id: check-illegal-windows-names - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.14.6 + rev: v0.15.18 hooks: # Run the linter. - id: ruff @@ -17,7 +17,7 @@ repos: - id: ruff-format types_or: [ python, pyi ] # avoid jupyter - repo: https://github.com/codespell-project/codespell - rev: v2.3.0 + rev: v2.4.2 hooks: - id: codespell additional_dependencies: diff --git a/Alignment v2.ipynb b/Alignment v2.ipynb index 41c374ce..93d18d91 100644 --- a/Alignment v2.ipynb +++ b/Alignment v2.ipynb @@ -167,8 +167,7 @@ "metadata": {}, "outputs": [], "source": [ - "from skimage.transform import ProjectiveTransform\n", - "import numpy as np\n", + "from micasense.warp_io import load_warp_matrices\n", "\n", "if panchroCam:\n", " warp_matrices_filename = cam_serial + \"_warp_matrices_SIFT.npy\"\n", @@ -177,20 +176,13 @@ "\n", "if Path(\"./\" + warp_matrices_filename).is_file():\n", " print(\"Found existing warp matrices for camera\", cam_serial)\n", - " load_warp_matrices = np.load(warp_matrices_filename, allow_pickle=True)\n", - " loaded_warp_matrices = []\n", - " for matrix in load_warp_matrices:\n", - " if panchroCam:\n", - " transform = ProjectiveTransform(matrix=matrix.astype(\"float64\"))\n", - " loaded_warp_matrices.append(transform)\n", - " else:\n", - " loaded_warp_matrices.append(matrix.astype(\"float32\"))\n", + " loaded = load_warp_matrices(warp_matrices_filename, as_projective=panchroCam)\n", " print(\"Warp matrices successfully loaded.\")\n", "\n", " if panchroCam:\n", - " warp_matrices_SIFT = loaded_warp_matrices\n", + " warp_matrices_SIFT = loaded\n", " else:\n", - " warp_matrices = loaded_warp_matrices\n", + " warp_matrices = loaded\n", "else:\n", " print(\"No existing warp matrices found. Create them later in the notebook.\")\n", " warp_matrices_SIFT = False\n", @@ -381,24 +373,14 @@ "metadata": {}, "outputs": [], "source": [ - "import numpy\n", - "import skimage\n", - "from skimage.transform import ProjectiveTransform\n", + "from micasense.warp_io import save_warp_matrices\n", "\n", "if panchroCam:\n", " working_wm = warp_matrices_SIFT\n", "else:\n", " working_wm = warp_matrices\n", "if not Path(\"./\" + warp_matrices_filename).is_file() or regenerate:\n", - " temp_matrices = []\n", - " for x in working_wm:\n", - " if isinstance(x, numpy.ndarray):\n", - " temp_matrices.append(x)\n", - " if isinstance(x, skimage.transform._geometric.ProjectiveTransform):\n", - " temp_matrices.append(x.params)\n", - " np.save(\n", - " warp_matrices_filename, np.array(temp_matrices, dtype=object), allow_pickle=True\n", - " )\n", + " save_warp_matrices(warp_matrices_filename, working_wm)\n", " print(\"Saved to\", Path(\"./\" + warp_matrices_filename).resolve())\n", "else:\n", " print(\"Matrices already exist at\", Path(\"./\" + warp_matrices_filename).resolve())" diff --git a/Batch Processing v2.ipynb b/Batch Processing v2.ipynb index 7aa2ca14..7b377250 100644 --- a/Batch Processing v2.ipynb +++ b/Batch Processing v2.ipynb @@ -142,6 +142,7 @@ " ) > 0.005 or val == 1: # reduces cpu usage from updating the progressbar by 10x\n", " f.value = val\n", "\n", + "\n", "# time is not recognized and would remove the imageset as unused\n", "imgset = imageset.ImageSet.from_directory(imagePath, progress_callback=update_f)\n", "%time imgset = imageset.ImageSet.from_directory(imagePath, progress_callback=update_f)\n", @@ -173,8 +174,8 @@ "data, columns = imgset.as_nested_lists()\n", "df = pd.DataFrame.from_records(data, index=\"timestamp\", columns=columns)\n", "\n", - "# Insert your mapbox token here\n", - "token = \"pk.eyJ1Ijoic3RlcGhlbm1hbmd1bTIiLCJhIjoiY2xmOXdnYzF1MDFqejNvdGE0YW13aTN5ZyJ9.AG_ckhUqTBjuGC2LuWCfQQ\"\n", + "# Insert your Mapbox access token here (https://account.mapbox.com/access-tokens/)\n", + "token = \"YOUR_MAPBOX_ACCESS_TOKEN\"\n", "color_property = \"dls-yaw\"\n", "num_color_classes = 8\n", "\n", @@ -218,7 +219,7 @@ "metadata": {}, "outputs": [], "source": [ - "from skimage.transform import ProjectiveTransform\n", + "from micasense.warp_io import load_warp_matrices\n", "\n", "if panchroCam:\n", " warp_matrices_filename = cam_serial + \"_warp_matrices_SIFT.npy\"\n", @@ -227,19 +228,12 @@ "\n", "if Path(\"./\" + warp_matrices_filename).is_file():\n", " print(\"Found existing warp matrices for camera\", cam_serial)\n", - " load_warp_matrices = np.load(warp_matrices_filename, allow_pickle=True)\n", - " loaded_warp_matrices = []\n", - " for matrix in load_warp_matrices:\n", - " if panchroCam:\n", - " transform = ProjectiveTransform(matrix=matrix.astype(\"float64\"))\n", - " loaded_warp_matrices.append(transform)\n", - " else:\n", - " loaded_warp_matrices.append(matrix.astype(\"float32\"))\n", + " loaded = load_warp_matrices(warp_matrices_filename, as_projective=panchroCam)\n", "\n", " if panchroCam:\n", - " warp_matrices_SIFT = loaded_warp_matrices\n", + " warp_matrices_SIFT = loaded\n", " else:\n", - " warp_matrices = loaded_warp_matrices\n", + " warp_matrices = loaded\n", " print(\"Loaded warp matrices from\", Path(\"./\" + warp_matrices_filename).resolve())\n", "else:\n", " print(\"No warp matrices found at expected location:\", warp_matrices_filename)" @@ -298,7 +292,7 @@ " if panchroCam:\n", " capture.radiometric_pan_sharpened_aligned_capture(\n", " warp_matrices=warp_matrices_SIFT,\n", - " irradiance_list=capture.dls_irradiace(),\n", + " irradiance_list=capture.dls_irradiance(),\n", " img_type=img_type,\n", " write_exif=write_exif_to_individual_stacks,\n", " )\n", diff --git a/CHANGELOG.md b/CHANGELOG.md index 7156a90f..947a97a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * repr functions to classes so that debugging is easier * delete old config files * fixed jupyter notebooks +* `micasense.mp_config.spawn_pool()` using a per-pool spawn context (rawpy/OpenMP safe on Linux) +* `micasense.warp_io` — `save_warp_matrices` / `load_warp_matrices` for `.npy` warp matrix I/O +* Alignment v2, Batch Processing v2 notebooks and `batch_processing_script.py` use `warp_io` +* pytest 9+ native `[tool.pytest]` config with coverage in CI + +### Changed + +* setup docs and README use venv/pip instead of conda; removed `micasense_conda_env.yml` + +### Fixed + +* `SIFT_align_capture`: assign reference SIFT image when reference and target bands share shape (fixes `NameError` when `ref` is a multispectral band) +* `SIFT_align_capture`: fall back to calibrated warp matrices when SIFT match count is low (e.g. LWIR) instead of raising +* `ImageSet.save_stacks` and `imageutils` alignment pools use spawn context (not global `set_start_method`) +* declare `rawpy` as a runtime dependency +* remove unused `Capture.__sift_warp_matrices` +* `SIFT_align_capture`: remove redundant raw/undistort work and dead accumulators ## [0.1.1] - 2025-12-28 diff --git a/MicaSense Image Processing Setup.ipynb b/MicaSense Image Processing Setup.ipynb index 74148d72..73414559 100644 --- a/MicaSense Image Processing Setup.ipynb +++ b/MicaSense Image Processing Setup.ipynb @@ -8,138 +8,86 @@ "\n", "## Overview\n", "\n", - "This series of tutorials will be a walk through on how to process RedEdge data from raw images through conversion to reflectance. In this first tutorial, we will cover the tools required to do this, get them installed, and verify that the installation works. \n", + "This series of tutorials will be a walk through on how to process RedEdge data from raw images through conversion to reflectance. In this first tutorial, we will cover the tools required to do this, get them installed, and verify that the installation works.\n", "\n", "## System Requirements\n", "\n", - "Our tutorials are written using Python3. Python has great library support for image processing through libraries such as OpenCV, SciKit Image, and others. In this tutorial, we'll use python, OpenCV, numpy, and matplotlib, as well as the standalone exiftool and it's python wrapper to open and manipulate RedEdge images to transform raw digital number values into quantitative reflectance.\n", + "Our tutorials are written using Python 3.10+. Python has great library support for image processing through libraries such as OpenCV, SciKit Image, and others. In this tutorial, we'll use python, OpenCV, numpy, and matplotlib, as well as the standalone exiftool and its python wrapper to open and manipulate RedEdge images to transform raw digital number values into quantitative reflectance.\n", "\n", "This tutorial has been tested on Windows, MacOS, and Linux. It is likely to work on other platforms, especially unix-based platforms like OSX, but you will have to do the legwork to get the required software installed and working.\n", "\n", "### Software/Libraries Overview\n", "\n", - "The following softare and libraries are required for this tutorial:\n", + "The following software and libraries are required for this tutorial:\n", "\n", - "* [python3](https://www.python.org/downloads/release/latest)\n", + "* [python3](https://www.python.org/downloads/)\n", "* [numpy](https://www.numpy.org/)\n", - "* [openCV](https://opencv.org/releases.html)\n", - "* [matplotlib](https://matplotlib.org/users/installing.html)\n", - "* [exiftool](https://exiftool.org/) + [pyexiftool](https://github.com/smarnach/pyexiftool) (version 0.4.13 only)\n", + "* [openCV](https://opencv.org/)\n", + "* [matplotlib](https://matplotlib.org/)\n", + "* [exiftool](https://exiftool.org/) + [pyexiftool](https://github.com/smarnach/pyexiftool)\n", "* [scikit-image](https://scikit-image.org/)\n", "* [zbar](https://zbar.sourceforge.net/) + [pyzbar](https://github.com/NaturalHistoryMuseum/pyzbar)\n", "* [pysolar](http://pysolar.org/)\n", - "* [pandas](https://pandas.pydata.org/)\n", - "* [mapboxgl](https://github.com/mapbox/mapboxgl-jupyter)\n", + "* [GDAL](https://gdal.org/) (system package; match the pip `gdal` wheel to your installed version)\n", + "* [pandas](https://pandas.pydata.org/) (notebooks)\n", + "* [jupyter](https://jupyter.org/) (notebooks)\n", "\n", - "Below, we go through the options to download and install a full working python environment with these tools (and their dependencies). We're using the [Anaconda](https://www.anaconda.com/download/) or [miniconda](https://conda.io/miniconda.html) environments where possible to ease installation, but if you're already a python package management guru, you can use `git` to checkout this code repository and look at the `micasense_conda_env.yml` file for the dependencies you'll need in your virtual environment.\n", + "Install the library and notebook extras from a clone of this repository:\n", "\n", - "### Linux (Debian/Ubuntu)\n", - "\n", - "For linux (and Mac, to some extent) you can either install the libraries directly using `pip` or install `miniconda` or `anaconda` to create completely separate environments. We have had success installing `miniconda` locally -- it's a smaller install than `anaconda` and can be installed without using `sudo` and doesn't impact the system-installed python or python libraries. You will likely still need to use `sudo` to install \n", + " git clone https://github.com/brainergylab/micasense_imageprocessing.git\n", + " cd micasense_imageprocessing\n", + " python3 -m venv .venv\n", + " source .venv/bin/activate # Windows: .venv\\Scripts\\activate\n", + " pip install -e \".[test]\"\n", + " pip install jupyter pandas mapboxgl jenkspy imageio\n", "\n", - "The following is what we had to do on a fresh Ubuntu 18.04 image to install the library. First we installed some system tools and libraries:\n", + "On Linux, install system packages before `pip install` (see CI in `.github/workflows/python-test.yml`):\n", "\n", - " sudo apt install git\n", - " sudo apt install libzbar0\n", - " sudo apt install make\n", - " \n", - "Next we installed [exiftool](https://exiftool.org/):\n", + " sudo apt-get update\n", + " sudo apt-get install exiftool libgdal-dev libzbar0\n", + " GDAL_VERSION=$(gdal-config --version)\n", + " pip install \"gdal==$GDAL_VERSION\"\n", "\n", - " wget https://exiftool.org/Image-ExifTool-12.57.tar.gz\n", - " tar -xvzf Image-ExifTool-12.57.tar.gz \n", - " cd Image-ExifTool-12.57/\n", - " perl Makefile.PL \n", - " make test\n", - " sudo make install\n", + "### Linux (Debian/Ubuntu)\n", "\n", - "Then we installed miniconda. Navigate to the [miniconda download page](https://conda.io/miniconda.html) and download the installer for your system and follow the [installation instructions](https://conda.io/docs/user-guide/install/index.html)\n", + "The following is what we use on a fresh Ubuntu image. First install system tools and libraries:\n", "\n", - "Once these tools are installed, you can check out this repository and create the `micasense conda` environment:\n", + " sudo apt install git git-lfs libzbar0 libgdal-dev exiftool make\n", "\n", - " git clone https://github.com/micasense/imageprocessing.git\n", - " cd imageprocessing\n", - " conda env create -f micasense_conda_env.yml\n", + "Clone the repository, create a virtual environment, and install Python dependencies as shown above.\n", "\n", - "Finally, one way to verify our install by running the built in tests:\n", + "Verify the install:\n", "\n", - " cd imageprocessing\n", - " conda activate micasense\n", - " pytest .\n", + " pytest\n", "\n", - "Or, to start working with the notebooks (including running the test code below):\n", + "Or start the notebooks:\n", "\n", - " cd imageprocessing\n", - " conda activate micasense\n", " jupyter notebook .\n", "\n", - "\n", "### Windows setup\n", "\n", - "When installing on Windows we rely on the [Anaconda](https://www.anaconda.com/download/) python environment to do most of the heavy lifting for us.\n", - "\n", - "* Install [Anaconda](https://www.anaconda.com/download/) for your system by downloading the **Python 3.7** version\n", + "* Install [Python 3](https://www.python.org/downloads/) for your system.\n", + "* Download the [exiftool windows package](https://exiftool.org/) and unzip it to a permanent location such as `c:\\exiftool\\`.\n", + "* Add an environment variable `exiftoolpath` pointing to `c:\\exiftool\\exiftool.exe` (Settings \u2192 Environment Variables \u2192 New).\n", + "* Open Command Prompt or PowerShell, `cd` to the repository, create and activate a venv, then run `pip install -e \".[test]\"` and install GDAL matching your environment if needed.\n", "\n", - " * When installing Anaconda, choose **\"install for only me\"** instead of \"install for all users,\" as this simplifies installation of other packages\n", - "\n", - "* Download the [exiftool windows package](https://exiftool.org/) and unzip it to a permanent location such as `c:\\exiftool\\`. Now we need to tell the python code where to find exiftool (so we don't have to set it up in every script we write), and we do that by adding the path to exiftool as an environment variable.\n", - " * Create an environment variable called `exiftoolpath` with a value of the full path to exiftool. For example, `c:\\exiftool\\exiftool.exe`\n", - " * To do this on Windows 10, press Start button or the Windows key, then type `Path` and click `Edit Environment Variables for Your Account`\n", - " * Click `New`\n", - " * In Variable Name type `exiftoolpath`\n", - " * In Variable Value type `c:\\exiftool\\exiftool.exe`\n", - "\n", - "* Open an Anaconda console from the start menu as an administrator by clicking `Start->Anaconda`, right-click `Anaconda Console`, choose `Run as Administrator`. Execute the following commands in the anaconda console:\n", - "\n", - " * `cd` to the directory you git cloned this repository to\n", - " * `conda env create -f micasense_conda_env.yml`\n", - " * This will configure an anaconda environment with all of the required tools and libraries This will take a while depending on your computer and internet speeds (5-10 minutes is not uncommon)\n", - " * When it's done, run `activate micasense` to activate the environment configured\n", - " * Each time you run start a new anaconda prompt, you'll need to run `activate micasense`\n", - " \n", "### MacOS setup\n", "\n", - "The following steps to get going on MacOS worked for us. \n", - "\n", - "First we installed `git` by installing the `xcode` developer tools, or you can follow the instructions at the [git site](https://git-scm.com/downloads). \n", - "\n", - "Next, we [downloaded and installed exiftool](https://exiftool.org/) using the MacOS installer.\n", - "\n", - "Third, we installed [Homebrew](https://brew.sh/) and used it to install `zbar`:\n", - "\n", - " /usr/bin/ruby -e \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)\"\n", - " brew install zbar\n", - " \n", - "Then we installed miniconda. If you're comfortable on the command line, navigate to the [miniconda download page](https://conda.io/miniconda.html) and download the installer for your system. Open an iTerm and follow the [installation instructions](https://conda.io/docs/user-guide/install/index.html).\n", - "\n", - "If instead you're more comfortable with graphical installers, the [Anaconda](https://www.anaconda.com/download/) version for **Python 3.7** may be right for you.\n", - "\n", - "Once these tools are installed, you can check out this repository and create the `micasense conda` environment by opening an iTerm and running the following commands:\n", - " \n", - " git clone https://github.com/micasense/imageprocessing.git\n", - " cd imageprocessing\n", - " conda env create -f micasense_conda_env.yml\n", - " \n", - "This will take a while (5-10 minutes isn't uncommon). Once it's done, one way to verify our install by running the built-in tests:\n", - "\n", - " conda activate micasense\n", - " pytest .\n", - "\n", - "Or, to start working with the notebooks (including running the test code below):\n", - "\n", - " cd imageprocessing\n", - " conda activate micasense\n", - " jupyter notebook .\n", - "\n", + "* Install [git](https://git-scm.com/downloads) and [git-lfs](https://git-lfs.github.com/).\n", + "* Install [exiftool](https://exiftool.org/).\n", + "* Install [Homebrew](https://brew.sh/) and `zbar` / GDAL as needed: `brew install zbar gdal`\n", + "* Create a virtual environment in the repository and `pip install -e \".[test]\"` as above.\n", "\n", "## Running the notebooks\n", "\n", - "* If running on Windows, run the `Anaconda Prompt` from the Start menu and type `activate micasense`\n", - "* `cd` to the imageprocessing checkout directory\n", + "* Activate your virtual environment (`source .venv/bin/activate` or `.venv\\Scripts\\activate` on Windows)\n", + "* `cd` to the repository directory\n", "* Run `jupyter notebook .`\n", "\n", "## Testing Installation\n", "\n", - "The following python snippet can be run from a jupyter notebook, inside iPython, or by saving to a script and running from the command line. If you're on windows, make sure you have set the location of exiftool in the `exiftoolpath` environment variable. If this script succeeds, your system is ready to go! If not, check the installation documentation for the module import that is having issues.\n" + "The following python snippet can be run from a jupyter notebook, inside iPython, or by saving to a script and running from the command line. If you're on windows, make sure you have set the location of exiftool in the `exiftoolpath` environment variable. If this script succeeds, your system is ready to go! If not, check the installation documentation for the module import that is having issues.\n", + "\n" ] }, { diff --git a/README.md b/README.md index 1e92da73..236d0b8c 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ analysis. ### Tutorial Articles -[Click here to view the tutorial articles](https://micasense.github.io/imageprocessing/index.html). The set of example +[Click here to view the tutorial articles](https://brainergylab.github.io/micasense_imageprocessing/index.html). The set of example notebooks and their outputs can be viewed in your browser without downloading anything or running any code. ### How do I get set up? @@ -53,7 +53,7 @@ both before running `git clone` or you may have issues with the example data fil Next, `git clone` this repository, as it has all the code and examples you'll need. Once you have git installed and the repository cloned, you are ready to start with the first tutorial. Check out -the [setup tutorial](https://micasense.github.io/imageprocessing/MicaSense%20Image%20Processing%20Setup.html) which will +the [setup tutorial](https://brainergylab.github.io/micasense_imageprocessing/MicaSense%20Image%20Processing%20Setup.html) which will walk through installing and checking the necessary tools to run the remaining tutorials. ### MicaSense Library Usage @@ -69,7 +69,7 @@ This package is available on PyPi and can be installed using `pip install micase The code in these tutorials consists of two parts. First, the tutorials generally end in `.ipynb` and are the Jupyter notebooks that were used to create the web page tutorials linked above. You can run this code by opening a -terminal/iTerm (linux/mac) or Anaconda Command Prompt (Windows), navigating to the folder you cloned the git repository +terminal (linux/mac) or Command Prompt/PowerShell (Windows), navigating to the folder you cloned the git repository into, and running ```bash @@ -95,8 +95,8 @@ Want to correct an issue or expand library functionality? Fork the repository, m on github. Have a question? Please double-check that you're able to run the setup notebook successfully, and resolve any issues -with that first. If you're pulling newer code, it may be necessary in some cases to delete and re-create -your `micasense` conda environment to make sure you have all of the expected packages. +with that first. If you're pulling newer code, recreate your virtual environment or reinstall with +`pip install -e ".[test]"` to pick up dependency changes. This code is a community effort and is not supported by MicaSense support. Please don't reach out to MicaSense support for issues with this codebase; instead, work through the above troubleshooting steps and @@ -104,15 +104,17 @@ then [create an issue on github](https://github.com/brainergylab/micasense_image ### Tests -Tests for many library functions are included in the `tests` diretory. Install the `pytest` module through your package -manager (e.g. `pip install pytest`) and then tests can be run from the main directory using the command: +Tests for many library functions are included in the `tests` directory. Install test dependencies and run from the repository root: ```bash +pip install -e ".[test]" pytest ``` +Coverage and default options are configured in `pyproject.toml` (`pytest>=9`). + Test execution can be relatively slow (2-3 minutes) as there is a lot of image processing occuring in some of the tests, -and quite a bit of re-used IO. To speed up tests, install the `pytest-xdist` plugin using `conda` or `pip` and achieve a +and quite a bit of re-used IO. To speed up tests, install the `pytest-xdist` plugin using `pip` and achieve a significant speed up by running tests in parallel. ```bash diff --git a/batch_processing_script.py b/batch_processing_script.py index 0ea5c977..fd997a98 100644 --- a/batch_processing_script.py +++ b/batch_processing_script.py @@ -3,12 +3,10 @@ import time from pathlib import Path -import numpy as np import pandas as pd from mapboxgl.utils import df_to_geojson -from skimage.transform import ProjectiveTransform - from micasense.capture import Capture +from micasense.warp_io import load_warp_matrices import micasense.imageset as imageset parser = argparse.ArgumentParser( @@ -114,19 +112,11 @@ if Path("./" + warp_matrices_filename).is_file(): print("Found existing warp matrices for camera", cam_serial) - load_warp_matrices = np.load(warp_matrices_filename, allow_pickle=True) - loaded_warp_matrices = [] - for matrix in load_warp_matrices: - if panchro_cam: - transform = ProjectiveTransform(matrix=matrix.astype("float64")) - loaded_warp_matrices.append(transform) - else: - loaded_warp_matrices.append(matrix.astype("float32")) - + loaded = load_warp_matrices(warp_matrices_filename, as_projective=panchro_cam) if panchro_cam: - warp_matrices_SIFT = loaded_warp_matrices + warp_matrices_SIFT = loaded else: - warp_matrices = loaded_warp_matrices + warp_matrices = loaded print("Loaded warp matrices from", Path("./" + warp_matrices_filename).resolve()) else: print("No warp matrices found at expected location:", warp_matrices_filename) diff --git a/micasense/capture.py b/micasense/capture.py index a4cda72e..88e85e77 100644 --- a/micasense/capture.py +++ b/micasense/capture.py @@ -104,7 +104,6 @@ def __init__(self, images, panel_corners=None): self.__aligned_capture = None self.__aligned_radiometric_pan_sharpened_capture = None - self.__sift_warp_matrices = None def set_panel_corners(self, panel_corners): """ @@ -1160,35 +1159,37 @@ def SIFT_align_capture( descriptors = [] img_index = list(range(len(self.images))) img_index.pop(ref) - ref_shape = self.images[ref].raw().shape + ref_image = self.images[ref] + ref_raw = ref_image.raw() + ref_shape = ref_raw.shape rest_shape = self.images[img_index[0]].raw().shape scale = np.array(ref_shape) / np.array(rest_shape) # use the calibrated warp matrices to verify keypoints warp_matrices_calibrated = self.get_warp_matrices(ref_index=ref) - ref_image_SIFT = self.images[ref].undistorted(self.images[ref].raw()) - if rest_shape != ref_shape: - ref_image_SIFT = resize(ref_image_SIFT, rest_shape) - ref_image_SIFT = (ref_image_SIFT / ref_image_SIFT.max() * 65535).astype( - np.uint16 - ) + ref_img = ref_image.undistorted(ref_raw) + if ref_shape != rest_shape: + ref_img = resize(ref_img, rest_shape) + ref_image_SIFT = (ref_img / ref_img.max() * 65535).astype(np.uint16) descriptor_extractor.detect_and_extract(ref_image_SIFT) keypoints_ref = descriptor_extractor.keypoints descriptor_ref = descriptor_extractor.descriptors if verbose > 1: logger.info("found %d keypoints in the reference image", len(keypoints_ref)) - match_images = [] ratio = [] filter_tr = [] + raw_shapes = {} img_index = np.array(img_index) # extract keypoints % descriptors for ix in img_index: - img = self.images[ix].undistorted(self.images[ix].raw()) + raw_band = self.images[ix].raw() + raw_shapes[ix] = raw_band.shape + img = self.images[ix].undistorted(raw_band) if not img.shape == rest_shape: # if we have a thermal image, upsample to match the resolution of the multispec images - img_base = self.images[ix].raw()[self.images[ix].raw() > 0].min() + img_base = raw_band[raw_band > 0].min() img = img.astype(float) img[img > 0] = img[img > 0] - img_base img = resize(img, rest_shape) @@ -1202,7 +1203,6 @@ def SIFT_align_capture( else: # less strict filtering for the BLUE images filter_tr.append(err_blue) - match_images.append(img) descriptor_extractor.detect_and_extract(img) keypoints.append(descriptor_extractor.keypoints) descriptors.append(descriptor_extractor.descriptors) @@ -1251,11 +1251,9 @@ def SIFT_align_capture( warp_matrices_calibrated[ix] = np.dot(warp_blue_ref, warpBLUE[ix]) models = [] - kp_image = [] - kp_ref = [] for m, k, ix, t in zip(matches, keypoints, img_index, filter_tr): # we need to down scale the thermal image for the proper transform - scale_i = np.array(self.images[ix].raw().shape) / np.array(rest_shape) + scale_i = np.array(raw_shapes[ix]) / np.array(rest_shape) filtered_kpi, filtered_kpr, filtered_match, err = self.filter_keypoints( k, @@ -1283,13 +1281,13 @@ def SIFT_align_capture( # most of the time this will occur for the thermal image, as we have a hard time # finding a good matches between panchro & thermal in most cases else: - raise Exception(f"no match for index {ix}") + P = ProjectiveTransform(matrix=warp_matrices_calibrated[ix]) + logger.warning( + "Insufficient SIFT matches for band index %s (%s); using calibrated warp matrix.", + ix, + self.images[ix].band_name, + ) models.append(P) - kp_image.append(kpi) - kp_ref.append(kpr) - img = self.images[ix].undistorted(self.images[ix].raw()) - - # no need for the upsampled stacks here if verbose > 0: logger.info("Finished aligning band %d", ix) diff --git a/micasense/imageset.py b/micasense/imageset.py index a8e5ecc7..36ce038c 100644 --- a/micasense/imageset.py +++ b/micasense/imageset.py @@ -29,9 +29,10 @@ """ import fnmatch -import multiprocessing import os +from micasense.mp_config import spawn_pool + import exiftool import micasense.capture as capture @@ -170,12 +171,12 @@ def save_stacks( ) if multiprocess: - pool = multiprocessing.Pool(processes=multiprocessing.cpu_count()) - for i, _ in enumerate(pool.imap_unordered(save_capture, save_params_list)): - if progress_callback is not None: - progress_callback(float(i) / float(len(save_params_list))) - pool.close() - pool.join() + with spawn_pool() as pool: + for i, _ in enumerate( + pool.imap_unordered(save_capture, save_params_list) + ): + if progress_callback is not None: + progress_callback(float(i) / float(len(save_params_list))) else: for params in save_params_list: save_capture(params) diff --git a/micasense/imageutils.py b/micasense/imageutils.py index 17dbd29c..62298564 100644 --- a/micasense/imageutils.py +++ b/micasense/imageutils.py @@ -23,7 +23,6 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -import multiprocessing import os import logging @@ -387,20 +386,13 @@ def align_capture( ) warp_matrices = [None] * len(alignment_pairs) - # required to work across linux/mac/windows, see https://stackoverflow.com/questions/47852237 - if multithreaded and multiprocessing.get_start_method() != "spawn": - try: - multiprocessing.set_start_method("spawn", force=True) - except ValueError: - multithreaded = False + from micasense.mp_config import spawn_pool if multithreaded: - pool = multiprocessing.Pool(processes=multiprocessing.cpu_count()) - for _, mat in enumerate(pool.imap_unordered(align, alignment_pairs)): - warp_matrices[mat["match_index"]] = mat["warp_matrix"] - logger.info("Finished aligning band %s", mat["match_index"]) - pool.close() - pool.join() + with spawn_pool() as pool: + for _, mat in enumerate(pool.imap_unordered(align, alignment_pairs)): + warp_matrices[mat["match_index"]] = mat["warp_matrix"] + logger.info("Finished aligning band %s", mat["match_index"]) else: # Single-threaded alternative for pair in alignment_pairs: diff --git a/micasense/mp_config.py b/micasense/mp_config.py new file mode 100644 index 00000000..16ca8ae3 --- /dev/null +++ b/micasense/mp_config.py @@ -0,0 +1,10 @@ +"""Multiprocessing helpers for rawpy/OpenMP safety.""" + +import multiprocessing as mp +from multiprocessing.pool import Pool +from typing import Optional + + +def spawn_pool(processes: Optional[int] = None) -> Pool: + """Process pool using spawn (safe with rawpy/libraw OpenMP on Linux).""" + return mp.get_context("spawn").Pool(processes=processes or mp.cpu_count()) diff --git a/micasense/warp_io.py b/micasense/warp_io.py new file mode 100644 index 00000000..a3ce508a --- /dev/null +++ b/micasense/warp_io.py @@ -0,0 +1,48 @@ +"""Load and save band alignment warp matrices (.npy).""" + +from pathlib import Path +from typing import List, Union + +import numpy as np +from skimage.transform import ProjectiveTransform + +MatrixLike = Union[np.ndarray, ProjectiveTransform] + + +def _matrix_to_array(matrix: MatrixLike) -> np.ndarray: + if isinstance(matrix, ProjectiveTransform): + return np.asarray(matrix.params, dtype=np.float64) + return np.asarray(matrix, dtype=np.float64) + + +def warp_matrices_to_arrays(matrices: List[MatrixLike]) -> np.ndarray: + """Convert warp matrices to an object array of 3×3 float64 (on-disk format).""" + return np.array([_matrix_to_array(m) for m in matrices], dtype=object) + + +def arrays_to_warp_matrices(arrays: np.ndarray, *, as_projective: bool = True) -> list: + """ + Convert a loaded object array back to warp matrices. + + :param as_projective: If True, return ``ProjectiveTransform`` (SIFT / skimage). + If False, return ``ndarray`` (OpenCV alignment path). + """ + result = [] + for item in arrays: + arr = np.asarray(item) + if as_projective: + result.append(ProjectiveTransform(matrix=arr.astype(np.float64))) + else: + result.append(arr.astype(np.float32)) + return result + + +def save_warp_matrices(path: Union[Path, str], matrices: List[MatrixLike]) -> None: + """Save warp matrices in the standard ``.npy`` format (object array, pickle).""" + np.save(Path(path), warp_matrices_to_arrays(matrices), allow_pickle=True) + + +def load_warp_matrices(path: Union[Path, str], *, as_projective: bool = True) -> list: + """Load warp matrices written by :func:`save_warp_matrices`.""" + arrays = np.load(Path(path), allow_pickle=True) + return arrays_to_warp_matrices(arrays, as_projective=as_projective) diff --git a/micasense_conda_env.yml b/micasense_conda_env.yml deleted file mode 100644 index 845e1f77..00000000 --- a/micasense_conda_env.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: micasense -channels: - - conda-forge -dependencies: - - python=3.8 - - opencv=4.8 - - numpy - - jupyter - - matplotlib - - pysolar - - gdal - - scikit-image - - pytest - - git - - git-lfs - - pandas - - imageio - - nb_conda - - requests - - packaging - - pip - - pytest-xdist - - pip: - - pyzbar - - mapboxgl - - pyexiftool - - jenkspy - - rawpy diff --git a/pyproject.toml b/pyproject.toml index 058f969a..2ae6db34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "requests", "numpy", "opencv-python", + "rawpy", "gdal", "pysolar", "matplotlib", @@ -46,8 +47,8 @@ Homepage = "https://github.com/brainergylab/micasense_imageprocessing" [project.optional-dependencies] test = [ - "pytest >= 7.4.3", - "pytest-cov", + "pytest>=9", + "pytest-cov>=6", ] docs = [ "jupyter", @@ -64,5 +65,19 @@ exclude = ["data*", "tests*"] [tool.pytest] testpaths = ["tests"] markers = [ - "newgdal", + "newgdal: tests that require newer GDAL stack behavior", ] +addopts = [ + "-m", + "not newgdal", + "--cov=micasense", + "--cov-report=xml", + "--cov-report=term-missing", +] + +[tool.coverage.run] +source = ["micasense"] + +[tool.coverage.report] +show_missing = true +skip_covered = true diff --git a/tests/test_sift_align.py b/tests/test_sift_align.py new file mode 100644 index 00000000..f02f97ae --- /dev/null +++ b/tests/test_sift_align.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +"""Tests for SIFT_align_capture and multiprocessing spawn configuration.""" + +import numpy as np + +from micasense.mp_config import spawn_pool + + +def test_sift_align_smoke(non_panel_rededge_capture): + matrices = non_panel_rededge_capture.SIFT_align_capture(ref=0, min_matches=10) + assert len(matrices) == len(non_panel_rededge_capture.images) + assert np.allclose(matrices[0], np.eye(3)) + + +def test_sift_align_same_shape_ref(non_panel_rededge_capture): + """MS band as ref when all bands share resolution must not raise NameError.""" + matrices = non_panel_rededge_capture.SIFT_align_capture(ref=2, min_matches=10) + assert len(matrices) == len(non_panel_rededge_capture.images) + assert np.allclose(matrices[2], np.eye(3)) + + +def test_sift_align_lwir_fallback(non_panel_altum_capture): + """Altum LWIR should fall back to calibrated warp instead of aborting.""" + matrices = non_panel_altum_capture.SIFT_align_capture(ref=0, min_matches=10) + assert len(matrices) == len(non_panel_altum_capture.images) + assert np.allclose(matrices[0], np.eye(3)) + + +def test_spawn_pool_uses_spawn_context(): + with spawn_pool(1) as pool: + assert pool._ctx.get_start_method() == "spawn" diff --git a/tests/test_warp_io.py b/tests/test_warp_io.py new file mode 100644 index 00000000..9d271357 --- /dev/null +++ b/tests/test_warp_io.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +"""Tests for warp matrix save/load.""" + +import numpy as np +from skimage.transform import ProjectiveTransform + +from micasense.warp_io import ( + load_warp_matrices, + save_warp_matrices, + warp_matrices_to_arrays, +) + + +def test_warp_matrices_round_trip_ndarray(panel_altum_capture, tmp_path): + matrices = panel_altum_capture.get_warp_matrices() + path = tmp_path / "warp.npy" + save_warp_matrices(path, matrices) + loaded = load_warp_matrices(path) + for orig, transform in zip(matrices, loaded): + np.testing.assert_allclose(orig, transform.params, atol=1e-6) + + +def test_warp_matrices_round_trip_projective(tmp_path): + matrices = [ + ProjectiveTransform(matrix=np.eye(3)), + ProjectiveTransform(matrix=np.diag([1.0, 1.0, 1.0])), + ] + path = tmp_path / "warp_sift.npy" + save_warp_matrices(path, matrices) + loaded = load_warp_matrices(path) + for orig, loaded_t in zip(matrices, loaded): + np.testing.assert_allclose(orig.params, loaded_t.params, atol=1e-6) + + +def test_load_as_arrays(tmp_path): + arrays = warp_matrices_to_arrays([np.eye(3), np.eye(3) * 2]) + path = tmp_path / "warp_cv.npy" + np.save(path, arrays, allow_pickle=True) + loaded = load_warp_matrices(path, as_projective=False) + assert len(loaded) == 2 + assert all(isinstance(m, np.ndarray) for m in loaded) + np.testing.assert_allclose(loaded[0], np.eye(3), atol=1e-6)