Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Run Python tests

on:
push:
branches: [ main ] # - add for limiting it only to pushes on a main branch
paths: # triggers testing action if only any python / 2 other files have been changed
- '**.py'
- 'requirements.txt'
- '.github/workflows/test.yaml'
pull_request:

jobs:
test:
runs-on: windows-latest

strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11"]

steps:
- name: Checkout code # loads code from a repo
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
pip install -e .
pip install numba

- name: Run tests
run: pytest
16 changes: 14 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,19 @@ Logging of changes between package versions (generated and uploaded to pypi.org)

All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).


### [0.1.0] - 2026-01-07

#### Added
- Automatic testing workflow for GitHub - run collected tests by ***pytest*** library after merging with the main branch;
- Bumped up version to a minor release version (0.1.0) for designating persistence of developed API (methods).

#### Fixed
- Returning types of class methods;
- Formulations in docstrings for class methods.


### [0.0.15] - 2025-02-14

Expand All @@ -28,7 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0


### [0.0.13] - 2024-06-13
Added the **ZernPSF** class class with methods for calculation, visualization, and convolution
Added the **ZernPSF** class with methods for calculation, visualization, and convolution
with a 2D PSF kernel, corresponding to the Zernike polynomial.


Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2025 Sergei Klykov
Copyright (c) 2026 Sergei Klykov

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
2 changes: 1 addition & 1 deletion build_api_dict_pdoc.bat
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
pdoc ./src/zernpy/zernikepol.py ./src/zernpy/zernpsf.py -o ./docs/api --logo ../for_favicon.png --logo ../for_favicon.png --no-show-source --footer-text "zernpy ver. 0.0.15, 2025 Sergei Klykov"
pdoc ./src/zernpy/zernikepol.py ./src/zernpy/zernpsf.py -o ./docs/api --logo ../for_favicon.png --logo ../for_favicon.png --no-show-source --footer-text "zernpy ver. 0.1.0, 2026 Sergei Klykov"
timeout /t 25
8 changes: 4 additions & 4 deletions docs/api/index.html

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/api/search.js

Large diffs are not rendered by default.

38 changes: 19 additions & 19 deletions docs/api/zernpy/zernikepol.html

Large diffs are not rendered by default.

278 changes: 274 additions & 4 deletions docs/api/zernpy/zernpsf.html

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
[project]
name = "zernpy"
version = "0.0.15"
dynamic = ["version"] # Discovering of the version by the setuptools (see below: in the 'file' or 'attr')
authors = [
{name = "Sergei Klykov"},
{email = "[email protected]"}
]
description = "Calculation of real Zernike polynomials values, associated PSFs, plotting of their profiles in polar coordinates"
readme = "README.md"
# license = {file = "LICENSE"} # includes the whole text in METADATA, maybe not so convienient
license = {text = "MIT"} # short descriptive name of the used license
requires-python = ">=3.8"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
dependencies = [
Expand All @@ -38,6 +36,9 @@ exclude = ["tests"]
[tool.setuptools.exclude-package-data]
zernpy = ["*.png"] # should exclude image files from distribution

[tool.setuptools.dynamic]
version = {attr = "zernpy.__version__"} # Variable set in the __init__.py

[build-system]
requires = ["setuptools>=61.0"]
requires = ["setuptools>=75.0"]
build-backend = "setuptools.build_meta"
4 changes: 3 additions & 1 deletion src/zernpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

"""

__version__ = "0.0.15" # Straightforward way of specifying package version and including it to the package attributes
__version__ = "0.1.0" # Straightforward way of specifying package version and including it to the package attributes

if __name__ == "__main__":
# use absolute imports for importing as module
Expand All @@ -24,3 +24,5 @@
from .zernikepol import generate_polynomials, fit_polynomials, generate_random_phases, fit_polynomials_vectors, generate_phases_image
from .zernpsf import ZernPSF # class for ZernPSF auto export on the import call of the package
from .zernpsf import force_get_psf_compilation # function for precompile functions by numba library
__all__ = ["ZernPol", "generate_polynomials", "fit_polynomials", "generate_random_phases", "fit_polynomials_vectors",
"generate_phases_image", "ZernPSF", "force_get_psf_compilation"]
61 changes: 32 additions & 29 deletions src/zernpy/zernikepol.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Also, provides a few functions useful for fitting set of Zernike polynomials to an image with phases.

@author: Sergei Klykov, @year: 2024, @licence: MIT \n
@author: Sergei Klykov, @year: 2026, @licence: MIT \n

"""
# %% Global imports
Expand All @@ -16,7 +16,7 @@
import matplotlib.pyplot as plt
import random
import time
from typing import Union, Sequence
from typing import Union, Sequence, Tuple

# %% Local (package-scoped) imports
if __name__ == "__main__" or __name__ == Path(__file__).stem or __name__ == "__mp_main__":
Expand Down Expand Up @@ -226,7 +226,7 @@ def __init__(self, **kwargs):
else:
self.warn_mes_dr = ""

def get_indices(self):
def get_indices(self) -> Tuple[Tuple[int, int], int, int, int]:
"""
Return the tuple with following orders: ((m, n), OSA index, Noll index, Fringe index).

Expand All @@ -238,7 +238,7 @@ def get_indices(self):
"""
return (self.__m, self.__n), self.__osa_index, self.__noll_index, self.__fringe_index

def get_mn_orders(self) -> tuple:
def get_mn_orders(self) -> Tuple[int, int]:
"""
Return tuple with the (azimuthal, radial) orders, i.e. return (m, n).

Expand Down Expand Up @@ -349,7 +349,8 @@ def __eq__(self, other) -> bool:
raise ValueError("Provided object for comparison isn't instance of the ZernPol class")

# %% Polynomial values calculation in various forms
def polynomial_value(self, r: Union[float, np.ndarray], theta: Union[float, np.ndarray], use_exact_eq: bool = False):
def polynomial_value(self, r: Union[float, np.ndarray], theta: Union[float, np.ndarray],
use_exact_eq: bool = False) -> Union[float, np.ndarray]:
"""
Calculate Zernike polynomial value(-s) within the unit circle.

Expand Down Expand Up @@ -396,10 +397,8 @@ def polynomial_value(self, r: Union[float, np.ndarray], theta: Union[float, np.n

"""
# Checking input parameters for avoiding errors and unexpectable values
# Check radii type and that they are not lying outside range [0.0, 1.0] - unit circle
r = ZernPol._check_radii(r)
# Checking that angles lie in the range [0, 2*pi] and their type
theta = ZernPol._check_angles(theta)
r = ZernPol._check_radii(r) # Check radii type and that they are not lying outside range [0.0, 1.0] - unit circle
theta = ZernPol._check_angles(theta) # Checking that angles lie in the range [0, 2*pi] and their type
# Checking coincidence of shapes if theta and r are arrays
if isinstance(r, type(np.zeros(1))) and isinstance(theta, type(np.zeros(1))):
if r.shape != theta.shape:
Expand All @@ -424,10 +423,12 @@ def polynomial_value(self, r: Union[float, np.ndarray], theta: Union[float, np.n
return 0.0
elif isinstance(r, np.ndarray):
return np.zeros(shape=r.shape)
else:
return 0.0 # default scalar value
else:
return nTr*radial_polynomial_eq(self, r)

def radial(self, r: Union[float, np.ndarray], use_exact_eq: bool = False):
def radial(self, r: Union[float, np.ndarray], use_exact_eq: bool = False) -> Union[float, np.ndarray]:
"""
Calculate R(m, n) - radial Zernike function value(-s) within the unit circle.

Expand Down Expand Up @@ -489,10 +490,12 @@ def radial(self, r: Union[float, np.ndarray], use_exact_eq: bool = False):
return 0.0
elif isinstance(r, np.ndarray):
return np.zeros(shape=r.shape)
else:
return 0.0 # default scalar value
else:
return radial_polynomial_eq(self, r)

def triangular(self, theta: Union[float, np.ndarray]):
def triangular(self, theta: Union[float, np.ndarray]) -> Union[float, np.ndarray]:
"""
Calculate triangular Zernike function value(-s) within the unit circle.

Expand All @@ -516,12 +519,10 @@ def triangular(self, theta: Union[float, np.ndarray]):
Calculated value(-s) of Zernike triangular function on provided angle.

"""
# Check theta type and that angles are lying in the single period range [0, 2pi]
theta = ZernPol._check_angles(theta)
# Calculation using imported function
theta = ZernPol._check_angles(theta) # Check theta type and that angles are lying in the single period range [0, 2pi]
return triangular_function(self, theta)

def radial_dr(self, r: Union[float, np.ndarray], use_exact_eq: bool = False):
def radial_dr(self, r: Union[float, np.ndarray], use_exact_eq: bool = False) -> Union[float, np.ndarray]:
"""
Calculate derivative of radial Zernike polynomial value(-s) within the unit circle.

Expand Down Expand Up @@ -581,10 +582,12 @@ def radial_dr(self, r: Union[float, np.ndarray], use_exact_eq: bool = False):
return 0.0
elif isinstance(r, np.ndarray):
return np.zeros(shape=r.shape)
else:
return 0.0 # default scalar value
else:
return radial_derivative_eq(self, r)

def triangular_dtheta(self, theta: Union[float, np.ndarray]):
def triangular_dtheta(self, theta: Union[float, np.ndarray]) -> Union[float, np.ndarray]:
"""
Calculate derivative from triangular function on angle theta.

Expand All @@ -608,13 +611,12 @@ def triangular_dtheta(self, theta: Union[float, np.ndarray]):
Calculated derivative value(-s) of Zernike triangular function on provided angle.

"""
# Check input parameter type and attempt to convert to acceptable types
theta = ZernPol._check_angles(theta)
theta = ZernPol._check_angles(theta) # Check input parameter type and attempt to convert to acceptable types
return triangular_derivative(self, theta)

def normf(self):
def normf(self) -> float:
"""
Calculate normalization factor for the Zernike polynomial calculated according to the References below.
Calculate normalization factor (such that Var(Z) = 1) for the associated Zernike polynomial (according to the References below).

References
----------
Expand Down Expand Up @@ -714,7 +716,7 @@ def get_fringe_index(m: int, n: int) -> int:
return (1 + (n + abs(m))//2)**2 - 2*abs(m) + (1 - np.sign(m))//2

@staticmethod
def index2orders(**kwargs) -> tuple:
def index2orders(**kwargs) -> Tuple[int, int]:
"""
Return tuple as (azimuthal, radial) orders for the specified by osa_, noll_ or fringe_index input parameter.

Expand Down Expand Up @@ -898,7 +900,7 @@ def _sum_zernikes_meshgrid(coefficients: Sequence[float], polynomials: Sequence,
Sum over the provided polar coordinates.

"""
S = 0.0 # default value - sum
S = np.empty(shape=(2, 2)) # default value - sum
if len(coefficients) != len(polynomials):
raise ValueError("Lengths of coefficients and polynomials aren't equal")
else:
Expand Down Expand Up @@ -1333,9 +1335,9 @@ def _check_angles(angles: Union[list, tuple, float, int, np.ndarray]) -> Union[f


# %% Independent functions defs.
def generate_polynomials(max_order: int = 10) -> tuple:
def generate_polynomials(max_order: int = 10) -> Tuple[ZernPol]:
"""
Generate tuple with ZernPol instances (ultimately, representing Zernike polynomials) indexed using OSA scheme, starting with Piston(m=0,n=0).
Generate tuple with ZernPol instances (ultimately, representing polynomials) indexed using OSA scheme, starting with Piston(m=0,n=0).

Parameters
----------
Expand Down Expand Up @@ -1374,7 +1376,7 @@ def generate_polynomials(max_order: int = 10) -> tuple:


def generate_random_phases(max_order: int = 4, img_width: int = 513, img_height: int = 513,
round_digits: int = 4) -> tuple:
round_digits: int = 4) -> Tuple[np.ndarray, np.ndarray, Tuple[ZernPol]]:
"""
Generate phases image (profile) for random set of polynomials with randomly selected amplitudes.

Expand Down Expand Up @@ -1500,7 +1502,8 @@ def generate_phases_image(polynomials: tuple = (), polynomials_amplitudes: tuple


def fit_polynomials(phases_image: np.ndarray, polynomials: tuple, crop_radius: float = 1.0, suppress_warnings: bool = False,
strict_circle_border: bool = False, round_digits: int = 4, return_cropped_image: bool = False) -> tuple:
strict_circle_border: bool = False, round_digits: int = 4,
return_cropped_image: bool = False) -> Tuple[np.ndarray, Union[np.ndarray, None]]:
"""
Fit provided Zernike polynomials (instances of ZernPol class) as the input tuple to the 2D phase image.

Expand Down Expand Up @@ -1592,7 +1595,7 @@ def fit_polynomials_vectors(polynomials: tuple, phases_vector: np.ndarray, radii
return zernike_coefficients


def compare_performances(min_order: int, max_order: int) -> tuple:
def compare_performances(min_order: int, max_order: int) -> Tuple[int, int, str]:
"""
Compare performances of radial polynomials calculation by using recursive and exact equations.

Expand Down Expand Up @@ -1638,7 +1641,7 @@ def compare_performances(min_order: int, max_order: int) -> tuple:
polynomial.radial(test_r, use_exact_eq=True) # calculate radial polynomials over vector of radii
t2 = time.perf_counter()
t_exact_ms = round(1000*(t2-t1), 3)
return (t_recursive_ms, t_exact_ms, f"Used polynomials: {i}", f"Radii: {n_points}")
return t_recursive_ms, t_exact_ms, f"Used polynomials: {i}", f"Radii: {n_points}"


def _estimate_high_order_calc_times():
Expand Down Expand Up @@ -1818,7 +1821,7 @@ def check_conformity():
# Below - fitting procedure on the provided phases image
polynomials_amplitudes2, cropped_img2 = fit_polynomials(phases_image2, polynomials, return_cropped_image=True,
strict_circle_border=strict_border, crop_radius=crop_r)
print("Difference between used amplitudes and fitted ones:", pols_coeffs-polynomials_amplitudes2)
print("Difference between used amplitudes and fitted ones:", np.asarray(pols_coeffs) - polynomials_amplitudes2)
plt.figure(); plt.axis("off"); im = plt.imshow(cropped_img2, cmap="jet"); plt.tight_layout(); plt.subplots_adjust(0, 0, 1, 1)
plt.colorbar(mappable=im)

Expand Down
Loading