diff --git a/.gitignore b/.gitignore index 715503a..d5e95b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ *.pyc __pycache__ + *.idea + +build/ +dist/ +*.egg-info/ + +.mypy_cache diff --git a/README.md b/README.md index bac1753..da140ef 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,22 @@ float_raster calculates pixel values with float64 precision and is capable of dr with variable pixel widths and heights. +- [Source repository](https://mpxd.net/code/jan/float_raster) +- [PyPi](https://pypi.org/project/float_raster) + + ## Installation Requirements: -* python 3 (written and tested with 3.5) +* python >=3.11 * numpy -Install with pip, via git: +Install with pip: ```bash -pip install git+https://mpxd.net/gogs/jan/float_raster.git@release +pip3 install float_raster +``` + +Alternatively, install via git +```bash +pip3 install git+https://mpxd.net/code/jan/float_raster.git@release ``` diff --git a/float_raster/LICENSE.md b/float_raster/LICENSE.md new file mode 120000 index 0000000..7eabdb1 --- /dev/null +++ b/float_raster/LICENSE.md @@ -0,0 +1 @@ +../LICENSE.md \ No newline at end of file diff --git a/float_raster/README.md b/float_raster/README.md new file mode 120000 index 0000000..32d46ee --- /dev/null +++ b/float_raster/README.md @@ -0,0 +1 @@ +../README.md \ No newline at end of file diff --git a/float_raster/__init__.py b/float_raster/__init__.py new file mode 100644 index 0000000..4efd9b0 --- /dev/null +++ b/float_raster/__init__.py @@ -0,0 +1,18 @@ +""" +Module for rasterizing polygons, with float-precision anti-aliasing on + a non-uniform rectangular grid. + +See the documentation for float_raster.raster(...) for details. +""" + +from .float_raster import ( + raster as raster, + find_intersections as find_intersections, + create_vertices as create_vertices, + clip_vertices_to_window as clip_vertices_to_window, + get_raster_parts as get_raster_parts, + ) + + +__author__ = 'Jan Petykiewicz' +__version__ = '0.8' diff --git a/float_raster.py b/float_raster/float_raster.py similarity index 74% rename from float_raster.py rename to float_raster/float_raster.py index 11cc11c..26cc4a1 100644 --- a/float_raster.py +++ b/float_raster/float_raster.py @@ -1,22 +1,19 @@ -""" -Module for rasterizing polygons, with float-precision anti-aliasing on - a non-uniform rectangular grid. - -See the documentation for raster(...) for details. -""" - -from typing import Tuple +from numpy.typing import ArrayLike, NDArray import numpy from numpy import logical_and, diff, floor, ceil, ones, zeros, hstack, full_like, newaxis from scipy import sparse -__author__ = 'Jan Petykiewicz' + +class FloatRasterError(Exception): + """ Custom exception for float_raster """ + pass -def raster(vertices: numpy.ndarray, - grid_x: numpy.ndarray, - grid_y: numpy.ndarray - ) -> numpy.ndarray: +def raster( + vertices: ArrayLike, + grid_x: ArrayLike, + grid_y: ArrayLike, + ) -> NDArray[numpy.float64]: """ Draws a polygon onto a 2D grid of pixels, setting pixel values equal to the fraction of the pixel area covered by the polygon. This implementation is written for accuracy and works with @@ -27,11 +24,14 @@ def raster(vertices: numpy.ndarray, Polygons are assumed to have clockwise vertex order; reversing the vertex order is equivalent to multiplying the result by -1. - :param vertices: 2xN ndarray containing x,y coordinates for each vertex of the polygon - :param grid_x: x-coordinates for the edges of each pixel (ie, the leftmost two columns span - x=grid_x[0] to x=grid_x[1] and x=grid_x[1] to x=grid_x[2]) - :param grid_y: y-coordinates for the edges of each pixel (see grid_x) - :return: 2D ndarray with pixel values in the range [0, 1] containing the anti-aliased polygon + Args: + vertices: 2xN ndarray containing `x,y` coordinates for each vertex of the polygon + grid_x: x-coordinates for the edges of each pixel (ie, the leftmost two columns span + `x=grid_x[0]` to `x=grid_x[1]` and `x=grid_x[1]` to `x=grid_x[2]`) + grid_y: y-coordinates for the edges of each pixel (see `grid_x`) + + Returns: + 2D ndarray with pixel values in the range [0, 1] containing the anti-aliased polygon """ vertices = numpy.array(vertices) grid_x = numpy.array(grid_x) @@ -55,15 +55,15 @@ def raster(vertices: numpy.ndarray, def find_intersections( - vertices: numpy.ndarray, - grid_x: numpy.ndarray, - grid_y: numpy.ndarray - ) -> Tuple[numpy.ndarray]: + vertices: NDArray[numpy.floating], + grid_x: NDArray[numpy.floating], + grid_y: NDArray[numpy.floating], + ) -> tuple[NDArray[numpy.float64], NDArray[numpy.float64], NDArray[numpy.float64]]: """ Find intersections between a polygon and grid lines """ if vertices.shape[0] != 2: - raise Exception('vertices must be 2xN') + raise FloatRasterError('vertices must be 2xN') min_bounds = floor(vertices.min(axis=1)) max_bounds = ceil(vertices.max(axis=1)) @@ -132,18 +132,18 @@ def find_intersections( def create_vertices( - vertices: numpy.ndarray, - grid_x: numpy.ndarray, - grid_y: numpy.ndarray, - new_vertex_data: Tuple[numpy.ndarray] = None + vertices: NDArray[numpy.floating], + grid_x: NDArray[numpy.floating], + grid_y: NDArray[numpy.floating], + new_vertex_data: tuple[NDArray[numpy.float64], NDArray[numpy.float64], NDArray[numpy.float64]] | None = None ) -> sparse.coo_matrix: """ Create additional vertices where a polygon crosses gridlines """ if vertices.shape[0] != 2: - raise Exception('vertices must be 2xN') + raise FloatRasterError('vertices must be 2xN') if grid_x.size < 1 or grid_y.size < 1: - raise Exception('Grid must contain at least one line in each direction?') + raise FloatRasterError('Grid must contain at least one line in each direction?') num_poly_vertices = vertices.shape[1] @@ -180,13 +180,14 @@ def create_vertices( return vertices + def clip_vertices_to_window( - vertices: numpy.ndarray, + vertices: NDArray[numpy.float64], min_x: float = -numpy.inf, max_x: float = numpy.inf, min_y: float = -numpy.inf, max_y: float = numpy.inf - ) -> numpy.ndarray: + ) -> NDArray[numpy.float64]: """ """ # Remove points outside the window (these will only be original points) @@ -205,40 +206,49 @@ def clip_vertices_to_window( def get_raster_parts( - vertices: numpy.ndarray, - grid_x: numpy.ndarray, - grid_y: numpy.ndarray + vertices: ArrayLike, + grid_x: ArrayLike, + grid_y: ArrayLike, ) -> sparse.coo_matrix: """ - This function performs the same task as raster(...), but instead of returning a dense array + This function performs the same task as `raster(...)`, but instead of returning a dense array of pixel values, it returns a sparse array containing the value - (-area + 1j * cover) + `(-area + 1j * cover)` for each pixel which contains a line segment, where - cover is the fraction of the pixel's y-length that is traversed by the segment, - multiplied by the sign of (y_final - y_initial) - area is the fraction of the pixel's area covered by the trapezoid formed by - the line segment's endpoints (clipped to the cell edges) and their projections - onto the pixel's left (i.e., lowest-x) edge, again multiplied by - the sign of (y_final - y_initial) + `cover` is the fraction of the pixel's y-length that is traversed by the segment, + multiplied by the sign of `(y_final - y_initial)` + `area` is the fraction of the pixel's area covered by the trapezoid formed by + the line segment's endpoints (clipped to the cell edges) and their projections + onto the pixel's left (i.e., lowest-x) edge, again multiplied by + the sign of `(y_final - y_initial)` Note that polygons are assumed to be wound clockwise. - The result from raster(...) can be obtained with - raster_result = numpy.real(lines_result) + numpy.imag(lines_result).cumsum(axis=0) + The result from `raster(...)` can be obtained with + `raster_result = numpy.real(lines_result) + numpy.imag(lines_result).cumsum(axis=0)` - :param vertices: 2xN ndarray containing x,y coordinates for each point in the polygon - :param grid_x: x-coordinates for the edges of each pixel (ie, the leftmost two columns span - x=grid_x[0] to x=grid_x[1] and x=grid_x[1] to x=grid_x[2]) - :param grid_y: y-coordinates for the edges of each pixel (see grid_x) - :return: Complex sparse COO matrix containing area and cover information + Args: + vertices: 2xN ndarray containing `x, y` coordinates for each point in the polygon + grid_x: x-coordinates for the edges of each pixel (ie, the leftmost two columns span + `x=grid_x[0]` to `x=grid_x[1]` and `x=grid_x[1]` to `x=grid_x[2]`) + grid_y: y-coordinates for the edges of each pixel (see `grid_x`) + + Returns: + Complex sparse COO matrix containing area and cover information """ + vertices = numpy.array(vertices) + grid_x = numpy.array(grid_x) + grid_y = numpy.array(grid_y) + if grid_x.size < 2 or grid_y.size < 2: - raise Exception('Grid must contain at least one full pixel') + raise FloatRasterError('Grid must contain at least one full pixel') num_xy_px = numpy.array([grid_x.size, grid_y.size]) - 1 - vertices = clip_vertices_to_window(vertices, - grid_x[0], grid_x[-1], - grid_y[0], grid_y[-1]) + vertices = clip_vertices_to_window( + vertices, + grid_x[0], grid_x[-1], + grid_y[0], grid_y[-1], + ) # If the shape fell completely outside our area, just return a blank grid if vertices.size == 0: diff --git a/float_raster/py.typed b/float_raster/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4693336 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,82 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "float_raster" +description = "High-precision anti-aliasing polygon rasterizer" +readme = "README.md" +license = { file = "LICENSE.md" } +authors = [ + { name="Jan Petykiewicz", email="jan@mpxd.net" }, + ] +homepage = "https://mpxd.net/code/jan/float_raster" +repository = "https://mpxd.net/code/jan/float_raster" +keywords = [ + "coverage", + "raster", + "anti-alias", + "polygon", + ] +classifiers = [ + "Programming Language :: Python :: 3", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "Intended Audience :: Manufacturing", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: GNU Affero General Public License v3", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)", + "Topic :: Multimedia :: Graphics :: Graphics Conversion", + ] +requires-python = ">=3.11" +dynamic = ["version"] +dependencies = [ + "numpy>=1.26", + "scipy~=1.14", + ] + +[tool.hatch.version] +path = "float_raster/__init__.py" + + +[tool.ruff] +exclude = [ + ".git", + "dist", + ] +line-length = 145 +indent-width = 4 +lint.dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +lint.select = [ + "NPY", "E", "F", "W", "B", "ANN", "UP", "SLOT", "SIM", "LOG", + "C4", "ISC", "PIE", "PT", "RET", "TCH", "PTH", "INT", + "ARG", "PL", "R", "TRY", + "G010", "G101", "G201", "G202", + "Q002", "Q003", "Q004", + ] +lint.ignore = [ + #"ANN001", # No annotation + "ANN002", # *args + "ANN003", # **kwargs + "ANN401", # Any + "ANN101", # self: Self + "SIM108", # single-line if / else assignment + "RET504", # x=y+z; return x + "PIE790", # unnecessary pass + "ISC003", # non-implicit string concatenation + "C408", # dict(x=y) instead of {'x': y} + "PLR09", # Too many xxx + "PLR2004", # magic number + "PLC0414", # import x as x + "TRY003", # Long exception message + ] + + +[[tool.mypy.overrides]] +module = [ + "scipy", + "scipy.sparse", + ] +ignore_missing_imports = true diff --git a/setup.py b/setup.py deleted file mode 100644 index 5f8374a..0000000 --- a/setup.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python - -from setuptools import setup - -setup(name='float_raster', - version='0.4', - description='High-precision anti-aliasing polygon rasterizer', - author='Jan Petykiewicz', - author_email='anewusername@gmail.com', - url='https://mpxd.net/gogs/jan/float_raster', - py_modules=['float_raster'], - install_requires=[ - 'numpy', - 'scipy', - ], - )