Metadata-Version: 2.4
Name: voronoip
Version: 0.2.1
Summary: Weighted Voronoi Diagrams — multiplicative, additive and power (Laguerre) modes
Author-email: Emerson Marreiros <ec2763@gmail.com>
License: MIT
Project-URL: Homepage, https://github.com/emerson-marreiros/voronoip
Project-URL: Repository, https://github.com/emerson-marreiros/voronoip
Project-URL: Issues, https://github.com/emerson-marreiros/voronoip/issues
Keywords: voronoi,weighted voronoi,computational geometry,diagram,laguerre
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Operating System :: OS Independent
Classifier: Topic :: Scientific/Engineering :: Visualization
Classifier: Topic :: Scientific/Engineering :: Mathematics
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.23
Requires-Dist: matplotlib>=3.5
Requires-Dist: scipy>=1.9
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-env; extra == "dev"
Requires-Dist: build; extra == "dev"
Requires-Dist: twine; extra == "dev"
Dynamic: license-file

# voronoip — Weighted Voronoi Diagrams for Python

A Python library for constructing and visualising **weighted Voronoi
diagrams** and **power diagrams**, in two flavours:

- **Raster** (`WeightedVoronoi` and its mode-locked shortcuts
  `PowerDiagram`, `MultiplicativeVoronoi`, `AdditiveVoronoi`) — fast,
  pixel-grid based, 2-D only.
- **Exact geometry** (`Voronoi`, `PowerVoronoi`) — real convex
  polygons/polyhedra via half-space intersection, compatible with
  `shapely.geometry.Polygon` and similar geometry libraries. `Voronoi`
  works in **2-D or 3-D**; `PowerVoronoi` is 2-D-only but adds a
  cell-adjacency query (`are_neighbors`), handy for coverage/interference
  analyses.

| Mode | Distance function | Effect of larger weight |
|---|---|---|
| `"multiplicative"` | `dist(p,g) / w(g)` | larger region |
| `"additive"` | `dist(p,g) − w(g)` | larger region |
| `"power"` | `dist(p,g)² − w(g)²` | larger region (classic power diagram) |
| `"power_load"` (via `PowerDiagram`) | `dist(p,g)² + w(g)²` | **smaller** region (weight = load) |

---

## Installation

```bash
pip install voronoip
```

Or, for local development:

```bash
pip install numpy scipy matplotlib
# clone / copy voronoip/ into your project
```

Dependencies: **numpy**, **scipy** (computational geometry — half-space
intersection, convex hulls), **matplotlib** (for visualisation). All
three are required.

---

## Quick start

```python
import numpy as np
from voronoip import WeightedVoronoi

pts = np.array([[0.2, 0.3],
                [0.7, 0.6],
                [0.5, 0.1],
                [0.1, 0.9]])
w   = np.array([1.0, 2.5, 0.5, 1.8])

wv = WeightedVoronoi(pts, w, mode="multiplicative", resolution=512)
wv.compute()
wv.plot()          # shows an interactive matplotlib figure
wv.to_png("out.png")
```

Or, for exact 2-D/3-D geometry (real polygons, no rasterisation):

```python
import numpy as np
from voronoip import Voronoi

pts = np.random.rand(10, 2) * 100
vor = Voronoi(pts)                 # standard (unweighted) Voronoi

for poly in vor.polygons:          # each poly is an (K, 2) ndarray of vertices
    print(poly)
```

---

## Two mistakes almost everyone makes at first

Before jumping into the examples below, read this. These two mistakes
account for the vast majority of `TypeError` / `AttributeError` reports
from new users — both examples further down show exactly how to avoid
them.

### 1. `compute()` returns the object itself, not a list of regions

```python
# WRONG — celulas becomes the WeightedVoronoi object, not a list
celulas = diagrama.compute()
for celula in celulas:        # TypeError: 'WeightedVoronoi' object is not iterable
    ...

# CORRECT — compute() returns self (useful for chaining);
#    the actual list of regions lives in .regions
diagrama.compute()
for regiao in diagrama.regions:
    ...

# Also valid, thanks to chaining:
regioes = diagrama.compute().regions
```

### 2. `WeightedVoronoi` is **raster-based** — `VoronoiRegion` has no `.vertices`

If you've used `scipy.spatial.Voronoi` before, you're used to each
region being a polygon with `.vertices`. `WeightedVoronoi` works
differently: it rasterises the diagram onto a pixel grid (`label_grid`),
and each `VoronoiRegion` is described by a boolean **pixel mask**, not a
list of polygon corners.

```python
# WRONG — VoronoiRegion has no .vertices attribute
poligono = np.array(regiao.vertices)
plt.fill(poligono[:, 0], poligono[:, 1])

# CORRECT — let the built-in plot() draw the diagram for you
fig, ax = diagrama.plot()

# Or, if you need region data programmatically:
regiao.pixel_mask     # (H, W) bool — True where pixels belong to this region
regiao.area            # int — pixel count
regiao.centroid         # (x, y) — mean position of the region
```

**If you need real polygon vertices**, use `Voronoi` (2-D/3-D, exact
geometry) or `PowerDiagram.cell(i)` (2-D, "load" weight convention)
instead of `WeightedVoronoi` — see *Exact geometric diagrams* below.

```python
from voronoip import Voronoi
from shapely.geometry import Polygon

vor = Voronoi(pts)
area = Polygon(vor.polygons[0]).area   # real vertices, works with shapely
```

---

## API reference

### `WeightedVoronoi(points, weights, **kwargs)`

| Parameter | Default | Description |
|---|---|---|
| `points` | — | `(N, 2)` generator coordinates |
| `weights` | — | `(N,)` generator weights |
| `mode` | `"multiplicative"` | distance metric |
| `resolution` | `512` | pixels along longer axis |
| `domain` | auto (bounding box + 5 %) | `((xmin,xmax),(ymin,ymax))` |
| `palette` | `"tab20"` | matplotlib colormap name |
| `show_generators` | `True` | draw seed points |
| `show_weights` | `False` | annotate weights |
| `show_boundaries` | `True` | draw cell edges |

> **Tip:** always pass `points` and `weights` as `float` arrays
> (`np.array([...], dtype=float)` or simply use `1.0` instead of `1`).
> Integer arrays work in most cases, but mixing them with weight-based
> division (`mode="multiplicative"`) can produce unexpected integer
> truncation in edge cases — floats avoid the ambiguity entirely.

#### Methods

```python
wv.compute()                     # rasterise the diagram (required first) — returns self

wv.plot(**kwargs)                # returns (fig, ax)
wv.plot_distance_field()         # heat-map of min weighted distance
wv.plot_comparison()             # side-by-side of all 3 modes

wv.owner(x, y)                   # generator index owning (x, y)
wv.region_of(x, y)               # VoronoiRegion containing (x, y)
wv.nearest_generators(x, y, k=3) # k nearest generators by weighted dist

wv.to_png("out.png", dpi=150)
wv.to_svg("out.svg")
wv.to_csv("out.csv")             # index, x, y, weight, area, centroid
wv.to_label_array()              # (H, W) int ndarray — copy
```

> **Important:** every query method (`owner`, `region_of`,
> `nearest_generators`) and every plotting/export method requires
> `.compute()` to have been called first. Calling them beforehand
> raises `RuntimeError: Call .compute() before accessing diagram data
> or plotting.` — this is intentional, not a bug.

#### Key attributes (after `compute()`)

| Attribute | Type | Description |
|---|---|---|
| `label_grid` | `(H, W) int32` | generator index per pixel |
| `dist_grid` | `(H, W) float64` | minimum weighted distance per pixel |
| `regions` | `list[VoronoiRegion]` | one object per generator — **this is what you iterate over** |

---

## Exact geometric diagrams

Two entry points give you real polygon/polyhedron vertices instead of a
pixel grid:

### `Voronoi(points, mode="standard", radii=None, bbox=None)`

Works in **2-D or 3-D**. Computes each cell as a bounded convex
polytope via half-space intersection (`scipy.spatial`) — no
rasterisation, no resolution parameter, exact area/volume.

| Parameter | Default | Description |
|---|---|---|
| `points` | — | `(N, 2)` or `(N, 3)` site coordinates |
| `mode` | `"standard"` | `"standard"` (unweighted) or `"power"` |
| `radii` | `None` | `(N,)` weights, required if `mode="power"`. **Larger radius → larger cell** (classic power-diagram convention) |
| `bbox` | auto (bounding box + 5 %) | `[xmin, ymin, xmax, ymax]` (2-D) or `[xmin, ymin, zmin, xmax, ymax, zmax]` (3-D) |

```python
import numpy as np
from voronoip import Voronoi

# 2-D, standard
pts = np.random.rand(10, 2) * 100
vor = Voronoi(pts)
vor.polygons        # list of (K, 2) ndarrays — ordered vertices, one per site

# 2-D, power diagram ("radii" = strength — bigger radius covers more)
pts = np.array([[20, 20], [80, 20], [50, 50], [20, 80], [80, 80], [50, 80]])
radii = np.array([10, 5, 8, 12, 6, 9])
vor = Voronoi(pts, mode="power", radii=radii)

# 3-D — each cell is a list of triangular faces (from the cell's convex hull)
pts3d = np.random.rand(20, 3) * 100
vor3d = Voronoi(pts3d)
for cell in vor3d.polygons:
    for face in cell:            # face: (3, 3) ndarray of triangle vertices
        ...
```

Every site's cell is clipped to `bbox`; a cell can come back empty
(`np.empty((0, 2))` in 2-D, `[]` in 3-D) if its weight is too small
relative to its neighbours to have any territory left — this is a real
power-diagram phenomenon ("site elimination"), not a bug.

### `PowerDiagram(points, weights, bbox=None, **kwargs)`

A `WeightedVoronoi` subclass (raster diagram + `.regions`/`.plot()` all
still work) that additionally computes exact polygon cells, but with the
distance **`dist(p,g)² + w(g)²`** — the mirror image of `Voronoi(mode=
"power")`: **larger weight → smaller cell**. Meant for "load" semantics,
e.g. an overloaded base station should cover less area, not more.

```python
from voronoip import PowerDiagram

sites   = [[10, 20], [40, 25], [25, 50], [60, 40]]
carga   = [30, 10, 20, 15]              # higher load ...
pd = PowerDiagram(sites, carga, bbox=[0, 0, 70, 60])   # ... shrinks the cell

pd.get_cells()       # list[VoronoiRegion] — raster regions, like WeightedVoronoi
pd.cell(0)           # list[(x, y)] — exact polygon vertices for site 0,
                      # compatible with shapely.geometry.Polygon(pd.cell(0))
pd.plot(show_sites=True, show_weights=True)
```

> If you instead want the classic convention (larger weight = larger
> area) with a flat `bbox`, `get_cells()`/`.plot(show_sites=...)`
> convenience API, but without the load-flip, use `Voronoi(mode=
> "power", radii=weights)` instead.

### `PowerVoronoi(points, weights, bbox=None)`

2-D only. Same classic convention as `Voronoi(mode="power")` (**larger
weight → larger cell** — e.g. weight = signal strength), but geared
towards network/coverage analyses: it also tells you which cells are
actual neighbours (share a boundary edge), which is the piece `Voronoi`
doesn't give you directly.

```python
from voronoip import PowerVoronoi

gNB    = [[15, 20], [45, 22], [30, 55], [70, 60], [82, 18], [60, 42]]
signal = [0.95, 0.75, 0.88, 0.70, 0.65, 0.80]   # e.g. normalised RSRP

pv = PowerVoronoi(points=gNB, weights=signal)
cells = pv.compute()          # list[(K, 2) ndarray] — also stored in pv.cells

pv.are_neighbors(0, 1)        # True/False — do cells 0 and 1 share an edge?
pv.neighbors_of(0)            # set of site indices adjacent to cell 0

fig, ax = pv.plot(show_points=True, point_labels=True, cmap="viridis")
```

> `are_neighbors` is exact, not a distance heuristic: it checks which
> bisecting half-spaces actually form part of the final cell boundary
> (as opposed to being dominated by other constraints and never
> touching it) — so it correctly says "no" for two cells that are close
> but separated by a third cell in between.

### `MultiplicativeVoronoi` / `AdditiveVoronoi`

Same convenience wrapper as `PowerDiagram` (flat `bbox`, eager
`.compute()`, `.get_cells()`), just with `mode="multiplicative"` /
`mode="additive"` fixed instead of `"power_load"`. These two do **not**
have a geometric `.cell()` — their distance functions don't produce
straight-line cell boundaries (Apollonius circles / hyperbolas), so
only the raster `.regions` / `.plot()` API is available.

```python
from voronoip import MultiplicativeVoronoi, AdditiveVoronoi

mv = MultiplicativeVoronoi(sites, weights, bbox=[0, 0, 70, 60])
mv.get_cells()
```

---

### `VoronoiRegion`

```python
r = wv.regions[0]

r.index          # int — generator index
r.generator      # (2,) float — (x, y)
r.weight         # float
r.pixel_mask     # (H, W) bool
r.color          # (R, G, B) tuple

r.area           # int — number of pixels
r.centroid       # (2,) float — mean (x, y) of mask pixels
r.boundary_pixels # (K, 2) row/col indices of boundary pixels
```

> Note that `r.pixel_mask` is the only true source of geometry for a
> region. `area`, `centroid` and `boundary_pixels` are all *derived*
> from it — there is no separate vector representation.

---

### `voronoip.generators`

```python
from voronoip.generators import (
    random_generators,           # uniform random
    grid_generators,             # regular grid with optional jitter
    poisson_disk_generators,     # Bridson blue-noise sampling
)

pts, w = random_generators(n=20, weight_range=(0.5, 2.0), seed=42)
pts, w = grid_generators(nx=6, ny=6, jitter=0.04, seed=0)
pts, w = poisson_disk_generators(min_dist=0.1, seed=7)
```

All functions return `(points, weights)` tuples ready for
`WeightedVoronoi`.

---

### `voronoip.metrics`

```python
from voronoip.metrics import (
    multiplicative_weighted_distance,  # scalar
    additive_weighted_distance,
    power_distance,
    batch_multiplicative,              # vectorised over generators
    batch_additive,
    batch_power,
)
```

---

## Full worked examples

The two examples below are deliberately written end-to-end, including
the result of `.regions` and `.owner()`, so you can copy them as a
starting template for your own scripts without hitting the two
mistakes described above.

### Example 1 — Basic diagram with 4 weighted points

```python
import numpy as np
import matplotlib.pyplot as plt
from voronoip import WeightedVoronoi

# Points (x, y) — always use floats
pontos = np.array([
    [1.0, 1.0],
    [5.0, 2.0],
    [3.0, 6.0],
    [7.0, 7.0]
])

# Weight associated with each point
pesos = np.array([1.0, 2.0, 0.5, 3.0])

# Create the weighted Voronoi object
vor = WeightedVoronoi(
    points=pontos,
    weights=pesos,
    mode="multiplicative",
    resolution=512,
    show_weights=True       # annotate weights directly on the plot
)

# compute() returns self — do NOT reassign it to "regioes"
vor.compute()

# ── Visualization (built-in, no manual polygon drawing needed) ─────
fig, ax = vor.plot()
ax.set_title("Diagrama de Voronoi Ponderado - voronoip")
plt.show()

# ── Region data — iterate over .regions, not over vor itself ───────
print("Regiões:")
for regiao in vor.regions:
    print(regiao)
    print(f"  Área:      {regiao.area} px")
    print(f"  Centróide: {regiao.centroid}")
    print(f"  Peso:      {regiao.weight}")

# ── Query which region owns an arbitrary point ──────────────────────
x, y = 4.0, 4.0
idx = vor.owner(x, y)
print(f"\nDono do ponto ({x}, {y}): gerador {idx} → {vor.regions[idx]}")
```

### Example 2 — Antenna signal coverage (real-world use case)

```python
import numpy as np
import matplotlib.pyplot as plt
from voronoip import WeightedVoronoi

# Antenna locations
antenas = np.array([
    [2.0, 8.0],
    [8.0, 9.0],
    [5.0, 5.0],
    [1.0, 2.0],
    [9.0, 3.0]
])

# Signal strength (weight) — higher power covers a larger area
potencia = np.array([5.0, 3.0, 2.0, 1.0, 4.0])

diagrama = WeightedVoronoi(
    points=antenas,
    weights=potencia,
    mode="multiplicative",
    resolution=512,
    show_generators=False,   # we'll draw the antennas manually below
    show_weights=False,
)

diagrama.compute()           # no reassignment — returns self

# ── Visualization ────────────────────────────────────────────────────
fig, ax = diagrama.plot()

# Custom antenna markers (triangles instead of the default dots)
ax.scatter(antenas[:, 0], antenas[:, 1],
           s=180, c="black", marker="^", zorder=6)

# Power labels
for i, p in enumerate(potencia):
    x, y = antenas[i]
    ax.text(x + 0.15, y, f"P={p}", fontsize=9, zorder=7,
            bbox=dict(boxstyle="round,pad=0.2", fc="white", alpha=0.6, lw=0))

ax.set_title("Cobertura de Antenas usando Voronoi Ponderado")
ax.set_xlabel("X")
ax.set_ylabel("Y")
ax.grid(True, alpha=0.3)
plt.show()

# ── Coverage data per antenna — iterate over .regions ───────────────
print("Cobertura por antena:")
for regiao in diagrama.regions:
    i = regiao.index
    cx, cy = regiao.centroid
    print(f"  Antena {i+1} (P={potencia[i]}) "
          f"→ área: {regiao.area} px  "
          f"centróide: ({cx:.2f}, {cy:.2f})")

# ── Signal intensity heat-map ───────────────────────────────────────
fig2, ax2 = diagrama.plot_distance_field(cmap="plasma")
ax2.set_title("Intensidade de Sinal (distância ponderada)")
plt.show()
```

---

## More examples

### Comparison of all three modes

```python
wv = WeightedVoronoi(pts, w, mode="multiplicative", resolution=400)
wv.compute()
fig, axes = wv.plot_comparison(figsize=(18, 6))
```

### Distance field heat-map

```python
wv.plot_distance_field(cmap="plasma")
```

### Querying which region owns a point

```python
idx = wv.owner(0.5, 0.5)
region = wv.region_of(0.5, 0.5)
print(region)
# VoronoiRegion(index=1, generator=(0.700, 0.600), weight=2.500, area=14832 px)
```

### Exporting

```python
wv.to_png("voronoi.png", dpi=200)
wv.to_svg("voronoi.svg")
wv.to_csv("voronoi.csv")
```

---

## Limitations

- `WeightedVoronoi` (and its `PowerDiagram` / `MultiplicativeVoronoi` /
  `AdditiveVoronoi` shortcuts) are raster-first — the `.regions` /
  `.plot()` API is pixel-mask based, and diagram accuracy there scales
  with `resolution` (low resolutions show visibly blocky cell edges).
  Use `Voronoi`, or `PowerDiagram.cell(i)`, for exact vector geometry.
- `Voronoi` and `PowerDiagram.cell(i)` only support **convex** cells,
  which is inherent to the power-diagram family — `Multiplicative` /
  `AdditiveVoronoi` cells can be non-convex, so they have no geometric
  equivalent.
- `Voronoi` and `PowerVoronoi`'s per-cell computation is O(N) half-spaces
  × a linear program, so O(N²) overall — fine up to a few thousand sites
  (a 729-site 3-D case takes ~10 s on a laptop), but not built for very
  large N.
- `MultiplicativeVoronoi` / `AdditiveVoronoi` have no `.cell()` — their
  bisectors are curved (Apollonius circles / hyperbolas), not straight
  lines, so they can't be represented as exact polygons.

---

## Project structure

```
voronoip/
├── __init__.py      # public API
├── diagram.py       # WeightedVoronoi + PowerDiagram/MultiplicativeVoronoi/AdditiveVoronoi
├── voronoi.py        # Voronoi (2-D/3-D) and PowerVoronoi (2-D + adjacency) classes
├── geometry.py        # 2-D half-space clipping used by PowerDiagram.cell()
├── geometry_nd.py      # N-D half-space intersection used by Voronoi
├── region.py            # VoronoiRegion dataclass
├── generators.py         # random / grid / Poisson-disk seed generators
└── metrics.py              # distance functions + registry
tests/
├── test_voronoip.py  # WeightedVoronoi / metrics / generators test suite
└── test_voronoi.py    # Voronoi / PowerVoronoi / PowerDiagram / mode-variant test suite
README.md
```

---

## License

MIT
