Compare commits

...

24 Commits
v0.3 ... master

Author SHA1 Message Date
Jan Petykiewicz 0a81c97af1 bump version to v0.7 2 years ago
Jan Petykiewicz 56989009e0 move to hatch-based build 2 years ago
jan 6ee12d8db9 update pypi tags 3 years ago
Jan Petykiewicz 38dc51aa7e update email 3 years ago
Jan Petykiewicz 81e34520aa strip whitespace from version string 3 years ago
Jan Petykiewicz 6d861d8a9c update req. python version 4 years ago
Jan Petykiewicz f8d3a6600e bump version to v0.6 4 years ago
Jan Petykiewicz e5e30a9414 Use VERSION.py instead of plain text VERSION 4 years ago
Jan Petykiewicz 522b610209 add .mypy_cache to .gitignore 4 years ago
Jan Petykiewicz ecefdff781 typing and comment updates 4 years ago
Jan Petykiewicz 085bb79ed7 add py.typed to enable downstream type checking 4 years ago
Jan Petykiewicz 94ebf9fa18 bump version to 0.5 5 years ago
Jan Petykiewicz 99b35a1561 gitignore build artifacts 5 years ago
Jan Petykiewicz 50e3822eac Create folder-based module and use VERSION file
use VERSION to avoid importing the module before it's installed
5 years ago
Jan Petykiewicz f68ab6bedb Update README 5 years ago
Jan Petykiewicz 1ab4b17247 add classifiers 5 years ago
Jan Petykiewicz 76f6c68472 add MANIFEST.in 5 years ago
jan c10fd556e2 Update source url 5 years ago
Jan Petykiewicz 219e4b9926 Use readme as long_description 6 years ago
Jan Petykiewicz 756c015b87 Move version info inside module 6 years ago
Jan Petykiewicz 3ca8afb3ef Use python3 for setup.py 6 years ago
Jan Petykiewicz 07df98cd42 bump version number 7 years ago
jan 8c8e41ac53 Rename some variables and functions, and add _very_ bare docs 7 years ago
jan 206d3885b6 Break up raster() into sub-functions. Documentation to follow... 7 years ago

7
.gitignore vendored

@ -1,3 +1,10 @@
*.pyc
__pycache__
*.idea
build/
dist/
*.egg-info/
.mypy_cache

@ -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.8
* 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
```

@ -1,181 +0,0 @@
"""
Module for rasterizing polygons, with float-precision anti-aliasing on
a non-uniform rectangular grid.
See the documentation for raster(...) for details.
"""
import numpy
from numpy import logical_and, diff, floor, ceil, ones, zeros, hstack, full_like, newaxis
from scipy import sparse
__author__ = 'Jan Petykiewicz'
def raster(poly_xy: numpy.ndarray,
grid_x: numpy.ndarray,
grid_y: numpy.ndarray
) -> numpy.ndarray:
"""
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
double precision, in contrast to most other implementations which are written for speed and
usually only allow for 256 (and often fewer) possible pixel values without performing (very
slow) super-sampling.
:param poly_xy: 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: 2D ndarray with pixel values in the range [0, 1] containing the anti-aliased polygon
"""
poly_xy = numpy.array(poly_xy)
grid_x = numpy.array(grid_x)
grid_y = numpy.array(grid_y)
if poly_xy.shape[0] != 2:
raise Exception('poly_xy must be 2xN')
if grid_x.size < 1 or grid_y.size < 1:
raise Exception('Grid must contain at least one full pixel')
num_xy_px = numpy.array([grid_x.size, grid_y.size]) - 1
min_bounds = floor(poly_xy.min(axis=1))
max_bounds = ceil(poly_xy.max(axis=1))
keep_x = logical_and(grid_x >= min_bounds[0],
grid_x <= max_bounds[0])
keep_y = logical_and(grid_y >= min_bounds[1],
grid_y <= max_bounds[1])
if not (keep_x.any() and keep_y.any()): # polygon doesn't overlap grid
return zeros(num_xy_px)
y_seg_xs = hstack((min_bounds[0], grid_x[keep_x], max_bounds[0])).T
x_seg_ys = hstack((min_bounds[1], grid_y[keep_y], max_bounds[1])).T
num_poly_vertices = poly_xy.shape[1]
'''
Calculate intersections between polygon and grid line segments
'''
xy1b = numpy.roll(poly_xy, -1, axis=1)
# Lists of initial/final coordinates for polygon segments
xi1 = poly_xy[0, :, newaxis]
yi1 = poly_xy[1, :, newaxis]
xf1 = xy1b[0, :, newaxis]
yf1 = xy1b[1, :, newaxis]
# Lists of initial/final coordinates for grid segments
xi2 = hstack((full_like(x_seg_ys, min_bounds[0]), y_seg_xs))
xf2 = hstack((full_like(x_seg_ys, max_bounds[0]), y_seg_xs))
yi2 = hstack((x_seg_ys, full_like(y_seg_xs, min_bounds[0])))
yf2 = hstack((x_seg_ys, full_like(y_seg_xs, max_bounds[1])))
# Perform calculation
dxi = xi1 - xi2
dyi = yi1 - yi2
dx1 = xf1 - xi1
dx2 = xf2 - xi2
dy1 = yf1 - yi1
dy2 = yf2 - yi2
numerator_a = dx2 * dyi - dy2 * dxi
numerator_b = dx1 * dyi - dy1 * dxi
denominator = dy2 * dx1 - dx2 * dy1
# Avoid warnings since we may multiply eg. NaN*False
with numpy.errstate(invalid='ignore', divide='ignore'):
u_a = numerator_a / denominator
u_b = numerator_b / denominator
# Find the adjacency matrix A of intersecting lines.
int_x = xi1 + dx1 * u_a
int_y = yi1 + dy1 * u_a
int_b = logical_and.reduce((u_a >= 0, u_a <= 1, u_b >= 0, u_b <= 1))
# Arrange output.
# int_adjacency_matrix[i, j] tells us if polygon segment i intersects with grid line j
# int_xy_matrix[i, j] tells us the x,y coordinates of the intersection in the form x+iy
# int_normalized_distance_1to2[i, j] tells us the fraction of the segment i
# we have to traverse in order to reach the intersection
int_adjacency_matrix = int_b
int_xy_matrix = (int_x + 1j * int_y) * int_b
int_normalized_distance_1to2 = u_a
# print('sparsity', int_adjacency_matrix.astype(int).sum() / int_adjacency_matrix.size)
'''
Insert any polygon-grid intersections as new polygon vertices
'''
# Figure out how to sort each row of the intersection matrices
# based on distance from (xi1, yi1) (the polygon segment's first point)
# This lets us insert them as new vertices in the proper order
sortix = int_normalized_distance_1to2.argsort(axis=1)
sortix_paired = (numpy.arange(num_poly_vertices)[:, newaxis], sortix)
assert(int_normalized_distance_1to2.shape[0] == num_poly_vertices)
# If any new points fall outside the window, shrink them back onto it
xy_shrunken = (numpy.real(int_xy_matrix).clip(grid_x[0], grid_x[-1]) + 1j *
numpy.imag(int_xy_matrix).clip(grid_y[0], grid_y[-1]))
# Use sortix to sort adjacency matrix and the intersection (x, y) coordinates,
# and hstack the original points to the left of the new ones
xy_with_original = hstack((poly_xy[0, :, newaxis] + 1j * poly_xy[1, :, newaxis],
xy_shrunken[sortix_paired]))
has_intersection = hstack((ones((poly_xy.shape[1], 1), dtype=bool),
int_adjacency_matrix[sortix_paired]))
# Now remove all extra entries which don't correspond to new vertices
# (ie, no intersection happened), and then flatten, creating our
# polygon-with-extra-vertices, though some extra vertices are included,
# which we must remove manually.
vertices = xy_with_original[has_intersection]
# Remove points outside the window (these will only be original points)
# Since the boundaries of the window are also pixel boundaries, this just
# makes the polygon boundary proceed along the window edge
inside = logical_and.reduce((numpy.real(vertices) <= grid_x[-1],
numpy.real(vertices) >= grid_x[0],
numpy.imag(vertices) <= grid_y[-1],
numpy.imag(vertices) >= grid_y[0]))
vertices = vertices[inside]
# Remove consecutive duplicate vertices
consecutive = numpy.ediff1d(vertices, to_begin=[1 + 1j]).astype(bool)
vertices = vertices[consecutive]
# If the shape fell completely outside our area, just return a blank grid
if vertices.size == 0:
return zeros(num_xy_px)
'''
Calculate area, cover
'''
# Calculate segment cover, area, and corresponding pixel's subscripts
poly = hstack((vertices, vertices[0]))
endpoint_avg = (poly[:-1] + poly[1:]) * 0.5
# Remove segments along the right,top edges
# (they correspond to outside pixels, but couldn't be removed until now
# because poly_xy stores points, not segments, and the edge points are needed
# when creating endpoint_avg)
non_edge = numpy.logical_and(numpy.real(endpoint_avg) < grid_x[-1],
numpy.imag(endpoint_avg) < grid_y[-1])
endpoint_final = endpoint_avg[non_edge]
x_sub = numpy.digitize(numpy.real(endpoint_final), grid_x) - 1
y_sub = numpy.digitize(numpy.imag(endpoint_final), grid_y) - 1
cover = diff(numpy.imag(poly), axis=0)[non_edge] / diff(grid_y)[y_sub]
area = (numpy.real(endpoint_final) - grid_x[x_sub]) * cover / diff(grid_x)[x_sub]
# Use coo_matrix(...).toarray() to efficiently convert from (x, y, v) pairs to ndarrays.
# We can use v = (-area + 1j * cover) followed with calls to numpy.real() and numpy.imag() to
# improve performance (Otherwise we'd have to call coo_matrix() twice. It's really inefficient
# because it involves lots of random memory access, unlike real() and imag()).
poly_grid = sparse.coo_matrix((-area + 1j * cover, (x_sub, y_sub)), shape=num_xy_px).toarray()
result_grid = numpy.real(poly_grid) + numpy.imag(poly_grid).cumsum(axis=0)
return result_grid

@ -0,0 +1 @@
../LICENSE.md

@ -0,0 +1 @@
../README.md

@ -0,0 +1,11 @@
"""
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 *
__author__ = 'Jan Petykiewicz'
__version__ = '0.7'

@ -0,0 +1,272 @@
from typing import Tuple, Optional
import numpy # type: ignore
from numpy import logical_and, diff, floor, ceil, ones, zeros, hstack, full_like, newaxis
from scipy import sparse # type: ignore
def raster(vertices: numpy.ndarray,
grid_x: numpy.ndarray,
grid_y: numpy.ndarray
) -> numpy.ndarray:
"""
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
double precision, in contrast to most other implementations which are written for speed and
usually only allow for 256 (and often fewer) possible pixel values without performing (very
slow) super-sampling.
Polygons are assumed to have clockwise vertex order; reversing the vertex order is equivalent
to multiplying the result by -1.
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)
grid_y = numpy.array(grid_y)
min_bounds = floor(vertices.min(axis=1))
max_bounds = ceil(vertices.max(axis=1))
keep_x = logical_and(grid_x >= min_bounds[0],
grid_x <= max_bounds[0])
keep_y = logical_and(grid_y >= min_bounds[1],
grid_y <= max_bounds[1])
if not (keep_x.any() and keep_y.any()): # polygon doesn't overlap grid
return zeros((grid_x.size - 1, grid_y.size - 1))
vertices = create_vertices(vertices, grid_x, grid_y)
parts_grid = get_raster_parts(vertices, grid_x, grid_y).toarray()
result_grid = numpy.real(parts_grid) + numpy.imag(parts_grid).cumsum(axis=0)
return result_grid
def find_intersections(
vertices: numpy.ndarray,
grid_x: numpy.ndarray,
grid_y: numpy.ndarray
) -> Tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray]:
"""
Find intersections between a polygon and grid lines
"""
if vertices.shape[0] != 2:
raise Exception('vertices must be 2xN')
min_bounds = floor(vertices.min(axis=1))
max_bounds = ceil(vertices.max(axis=1))
keep_x = logical_and(grid_x >= min_bounds[0],
grid_x <= max_bounds[0])
keep_y = logical_and(grid_y >= min_bounds[1],
grid_y <= max_bounds[1])
if not (keep_x.any() or keep_y.any()): # polygon doesn't overlap grid
mat_shape = (vertices.shape[1], grid_x.size + grid_y.size)
return zeros(mat_shape), zeros(mat_shape), zeros(mat_shape, dtype=bool)
y_seg_xs = hstack((min_bounds[0], grid_x[keep_x], max_bounds[0])).T
x_seg_ys = hstack((min_bounds[1], grid_y[keep_y], max_bounds[1])).T
'''
Calculate intersections between polygon and grid line segments
'''
xy1b = numpy.roll(vertices, -1, axis=1)
# Lists of initial/final coordinates for polygon segments
xi1 = vertices[0, :, newaxis]
yi1 = vertices[1, :, newaxis]
xf1 = xy1b[0, :, newaxis]
yf1 = xy1b[1, :, newaxis]
# Lists of initial/final coordinates for grid segments
xi2 = hstack((full_like(x_seg_ys, min_bounds[0]), y_seg_xs))
xf2 = hstack((full_like(x_seg_ys, max_bounds[0]), y_seg_xs))
yi2 = hstack((x_seg_ys, full_like(y_seg_xs, min_bounds[0])))
yf2 = hstack((x_seg_ys, full_like(y_seg_xs, max_bounds[1])))
# Perform calculation
dxi = xi1 - xi2
dyi = yi1 - yi2
dx1 = xf1 - xi1
dx2 = xf2 - xi2
dy1 = yf1 - yi1
dy2 = yf2 - yi2
numerator_a = dx2 * dyi - dy2 * dxi
numerator_b = dx1 * dyi - dy1 * dxi
denominator = dy2 * dx1 - dx2 * dy1
# Avoid warnings since we may multiply eg. NaN*False
with numpy.errstate(invalid='ignore', divide='ignore'):
u_a = numerator_a / denominator
u_b = numerator_b / denominator
# Find the adjacency matrix
int_x = xi1 + dx1 * u_a
int_y = yi1 + dy1 * u_a
adjacency = logical_and.reduce((u_a >= 0, u_a <= 1, u_b >= 0, u_b <= 1))
# Arrange output.
# adjacency[i, j] tells us if polygon segment i intersects with grid line j
# xy[i, j] tells us the x,y coordinates of the intersection in the form x+iy
# normalized_distance[i, j] tells us the fraction of the segment i
# we have to traverse in order to reach the intersection
xy = (int_x + 1j * int_y) * adjacency
normalized_distance = u_a
# print('sparsity', adjacency.astype(int).sum() / adjacency.size)
return normalized_distance, xy, adjacency
def create_vertices(
vertices: numpy.ndarray,
grid_x: numpy.ndarray,
grid_y: numpy.ndarray,
new_vertex_data: Optional[Tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray]] = None
) -> sparse.coo_matrix:
"""
Create additional vertices where a polygon crosses gridlines
"""
if vertices.shape[0] != 2:
raise Exception('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?')
num_poly_vertices = vertices.shape[1]
if new_vertex_data is None:
new_vertex_data = find_intersections(vertices, grid_x, grid_y)
normalized_distance, xy_matrix, adjacency_matrix = new_vertex_data
'''
Insert any polygon-grid intersections as new polygon vertices
'''
# Figure out how to sort each row of the intersection matrices
# based on distance from (xi1, yi1) (the polygon segment's first point)
# This lets us insert them as new vertices in the proper order
sortix = normalized_distance.argsort(axis=1)
sortix_paired = (numpy.arange(num_poly_vertices)[:, newaxis], sortix)
assert(normalized_distance.shape[0] == num_poly_vertices)
# if any new points fall outside the window, shrink them back onto it
xy_shrunken = (numpy.real(xy_matrix).clip(grid_x[0], grid_x[-1]) + 1j *
numpy.imag(xy_matrix).clip(grid_y[0], grid_y[-1]))
# Use sortix to sort adjacency matrix and the intersection (x, y) coordinates,
# and hstack the original points to the left of the new ones
xy_with_original = hstack((vertices[0, :, newaxis] + 1j * vertices[1, :, newaxis],
xy_shrunken[sortix_paired]))
has_intersection = hstack((ones((vertices.shape[1], 1), dtype=bool),
adjacency_matrix[sortix_paired]))
# Now remove all extra entries which don't correspond to new vertices
# (ie, no intersection happened), and then flatten, creating our
# polygon-with-extra-vertices, though some redundant vertices are included,
# which we must later remove manually.
vertices = xy_with_original[has_intersection]
return vertices
def clip_vertices_to_window(
vertices: numpy.ndarray,
min_x: float = -numpy.inf,
max_x: float = numpy.inf,
min_y: float = -numpy.inf,
max_y: float = numpy.inf
) -> numpy.ndarray:
"""
"""
# Remove points outside the window (these will only be original points)
# Since the boundaries of the window are also pixel boundaries, this just
# makes the polygon boundary proceed along the window edge
inside = logical_and.reduce((numpy.real(vertices) <= max_x,
numpy.real(vertices) >= min_x,
numpy.imag(vertices) <= max_y,
numpy.imag(vertices) >= min_y))
vertices = vertices[inside]
# Remove consecutive duplicate vertices
consecutive = numpy.ediff1d(vertices, to_begin=[1 + 1j]).astype(bool)
vertices = vertices[consecutive]
return vertices
def get_raster_parts(
vertices: numpy.ndarray,
grid_x: numpy.ndarray,
grid_y: numpy.ndarray
) -> sparse.coo_matrix:
"""
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)`
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)`
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)`
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
"""
if grid_x.size < 2 or grid_y.size < 2:
raise Exception('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])
# If the shape fell completely outside our area, just return a blank grid
if vertices.size == 0:
return sparse.coo_matrix(shape=num_xy_px)
'''
Calculate area, cover
'''
# Calculate segment cover, area, and corresponding pixel's subscripts
poly = hstack((vertices, vertices[0]))
endpoint_avg = (poly[:-1] + poly[1:]) * 0.5
# Remove segments along the right,top edges
# (they correspond to outside pixels, but couldn't be removed until now
# because 'vertices' stored points, not segments, and the edge points are needed
# when creating endpoint_avg)
non_edge = numpy.logical_and(numpy.real(endpoint_avg) < grid_x[-1],
numpy.imag(endpoint_avg) < grid_y[-1])
endpoint_avg_final = endpoint_avg[non_edge]
x_sub = numpy.digitize(numpy.real(endpoint_avg_final), grid_x) - 1
y_sub = numpy.digitize(numpy.imag(endpoint_avg_final), grid_y) - 1
cover = diff(numpy.imag(poly), axis=0)[non_edge] / diff(grid_y)[y_sub]
area = (numpy.real(endpoint_avg_final) - grid_x[x_sub]) * cover / diff(grid_x)[x_sub]
# Use coo_matrix(...).toarray() to efficiently convert from (x, y, v) pairs to ndarrays.
# We can use v = (-area + 1j * cover) followed with calls to numpy.real() and numpy.imag() to
# improve performance (Otherwise we'd have to call coo_matrix() twice. It's really inefficient
# because it involves lots of random memory access, unlike real() and imag()).
poly_grid = sparse.coo_matrix((-area + 1j * cover, (x_sub, y_sub)), shape=num_xy_px)
return poly_grid

@ -0,0 +1,38 @@
[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",
]
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.8"
dynamic = ["version"]
dependencies = [
"numpy~=1.21",
"scipy",
]
[tool.hatch.version]
path = "float_raster/__init__.py"

@ -1,16 +0,0 @@
#!/usr/bin/env python
from setuptools import setup
setup(name='float_raster',
version='0.3',
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',
],
)
Loading…
Cancel
Save