Skip to content

feat(compositing): add reusable alpha "over" array primitive - #333

Open
MAfarrag wants to merge 9 commits into
mainfrom
feat/compositing-alpha-over
Open

feat(compositing): add reusable alpha "over" array primitive#333
MAfarrag wants to merge 9 commits into
mainfrom
feat/compositing-alpha-over

Conversation

@MAfarrag

@MAfarrag MAfarrag commented Sep 5, 2026

Copy link
Copy Markdown
Member

Description

Adds cleopatra.glyphs.base.compositing.alpha_over, a NumPy-only Porter-Duff "over" compositing primitive, so the
leaf render package owns the alpha-"over" formula that higher layers (pyramids-eo, digital-earth) currently
re-derive privately. The operator carries the two edge cases those copies get wrong: un-premultiplying the RGBA
blend, and guarding the divide-by-zero where the output alpha is zero.

Arrays are channel-last (H, W, C) — matplotlib's and Pillow's image layout, and cleopatra's throughout:

  • RGB background (H, W, 3) → 3-band result fg_rgb * fg_a + bg * (1 - fg_a) (no alpha channel).
  • RGBA background (H, W, 4) → 4-band result with out_a = fg_a + bg_a * (1 - fg_a), un-premultiplied and
    divide-by-zero guarded.

Deviation from the issue's sketch: the issue proposed a band-first (4, H, W) / (3, H, W) signature. This
implements channel-last instead, because that is cleopatra's native image convention and it lets the existing
watermark halo delegate directly. Band-first callers (the GDAL/rasterio convention) transpose at their own
boundary, e.g. np.moveaxis(arr, 0, -1).

Real in-repo consumer: styling/watermark.py held a private _alpha_over copy of this exact operator (added
in #314) with a TODO to adopt the centralised version once it landed. This PR deletes that copy and points the halo
compositing at the shared alpha_over, so there is one implementation rather than two.

No new dependencies and no change to the public API surface beyond the new submodule, which is imported directly
(from cleopatra.glyphs.base.compositing import alpha_over), matching the package convention that the roots
re-export nothing.

Issues

Type of change

Check relevant points.

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update
  • Dev changes (CI/pyproject.toml/docs/examples/testing)

How Has This Been Tested?

New tests/test_compositing.py covers the RGB-over and RGBA-over paths, opaque/transparent foreground,
per-pixel alpha broadcast, integer inputs, the un-premultiplied partial-over-partial blend, the divide-by-zero
guard (including a mixed covered/uncovered image), and every shape/ndim validation branch.

  • pytest tests/test_compositing.py tests/test_watermark.py --cov=cleopatra.glyphs.base.compositing --cov-branch → 100% line + branch on the new module; the watermark tests are unchanged and green, proving the
    delegation is behavior-preserving.
  • pytest --doctest-modules src/cleopatra/glyphs/base/compositing.py → doctests pass.
  • Full non-e2e suite: pytest -m "not e2e" → 2717 passed.

Checklist:

  • updated version number in pyproject.toml
  • added changes to History.rst
  • updated the latest version in README file
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

Add `cleopatra.glyphs.base.compositing.alpha_over`, a NumPy-only
Porter-Duff "over" operator with the two edge cases that make private
copies drift -- un-premultiplying the RGBA blend and guarding the
divide-by-zero where the output alpha is zero -- so the leaf render
package owns the formula everything else composites through.

- channel-last (H, W, C): an RGB background gives a 3-band result, an
  RGBA background a 4-band one; this matches cleopatra's matplotlib/PIL
  image layout rather than the issue's band-first (C, H, W) sketch, and
  lets the watermark halo delegate directly (band-first callers
  transpose at their own boundary)
- replace the private watermark _alpha_over copy with the shared
  primitive and drop its forward-referencing TODO
- validate array shapes, naming the offending array in the error

Closes #306
Add scenarios that line/branch coverage does not capture: alpha_over
leaves its inputs unmutated, coerces array-like (list) inputs through
np.asarray, and an opaque foreground over an RGBA canvas reports full
coverage on the RGBA branch's alpha extreme.
Coerce inputs to floating point without forcing float64: a float32
foreground/background now composites to float32 instead of doubling the
working set to float64, which matters for a primitive meant to be reused
across the render stack on large rasters. Only non-floating inputs (int,
bool) are promoted, to float64. Documents the dtype behaviour in Returns
and pins it with a float32 test.
The purity test only exercised the RGBA-background branch; add the
matching check for the 3-band RGB path so both code paths are pinned as
non-mutating.
- correct the RGB-branch formula, which named an undefined `bg_rgb`; the
  code multiplies the whole `background` array on that path
- document that the module must stay NumPy-only to keep `styling` and
  `glyphs` free of an import cycle
- state that the [0, 1] input range is a trusted, unvalidated precondition
  the divide-by-zero guard relies on
- make the shape-error doctest robust to NumPy's tuple repr via +ELLIPSIS
Bring the touched files to the repo's pre-commit-canonical state (ruff
0.15.22 format + check --fix):
- wrap long assert messages in test_compositing.py to the 88-col ruler
- drop now-unnecessary quotes from watermark's annotations (the module
  already carries `from __future__ import annotations`)

No behavioural change; assertion values and code paths are untouched.
NumPy's arithmetic operators are typed as returning Any, so the RGB
branch tripped mypy's no-any-return while the RGBA branch (via
np.concatenate) was already clean. Bind the blend to an ndarray-annotated
local before returning so both branches type-check; mypy is clean on the
module.
The Returns note said only "integer inputs are promoted"; boolean inputs
are promoted to float64 too. Also reword "keep their precision" to "keep
their own width -- never upcast", so a float16 pair is understood to blend
at float16 precision rather than reading as a promise of more.
Add explicit cases for the dtype-coercion behaviour the docstring now
describes -- float16 preserved, mixed float32/float64 resolving to
float64, bool promoted to float64 -- and for a zero-sized (0, 5, 4) image
compositing to an empty result without raising.
@sonarqubecloud

sonarqubecloud Bot commented Sep 5, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(compositing): add reusable alpha "over" array primitive (alpha_over)

1 participant