Skip to content
Draft
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,8 @@ tiled = imgviz.tile(
<td><pre><a href="examples/tile.py">examples/tile.py</a></pre></td>
<td><img src="examples/assets/tile.jpg" width="52.21052631578947%" /></td>
</tr>
<tr>
<td><pre><a href="examples/tint.py">examples/tint.py</a></pre></td>
<td><img src="examples/assets/tint.jpg" width="97.25490196078431%" /></td>
</tr>
</table>
Binary file added examples/assets/tint.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions examples/tint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env python

import matplotlib.pyplot as plt

import imgviz


def tint() -> None:
img = imgviz.data.arc2017()["rgb"]
panels = [
("original", img),
("red", imgviz.tint(img, "red")),
("green", imgviz.tint(img, "green")),
("blue", imgviz.tint(img, "blue")),
]

plt.figure(dpi=200)
for i, (title, panel) in enumerate(panels):
plt.subplot(1, 4, i + 1)
plt.title(title)
plt.imshow(panel)
plt.axis("off")


if __name__ == "__main__":
from _base import run_example

run_example(tint)
1 change: 1 addition & 0 deletions imgviz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@
from ._pixelate import pixelate
from ._resize import resize
from ._tile import tile
from ._tint import tint
63 changes: 63 additions & 0 deletions imgviz/_tint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from __future__ import annotations

from typing import overload

import cmap as _cmap
import numpy as np
from numpy.typing import NDArray


@overload
def tint(
image: NDArray[np.uint8], color: _cmap.ColorLike, alpha: float = ...
) -> NDArray[np.uint8]: ...


@overload
def tint(
image: NDArray[np.floating], color: _cmap.ColorLike, alpha: float = ...
) -> NDArray[np.floating]: ...


def tint(image: NDArray, color: _cmap.ColorLike, alpha: float = 0.3) -> NDArray:
"""Wash a whole image toward a solid color at a given opacity.

A one-liner for flagging thumbnails in tile sheets, e.g. red-tinting a
rejected comparison. The default ``alpha=0.3`` is a soft 30% wash; raise it
toward 0.5 for stronger flagging or lower it toward 0.1 for a subtle tint.

Args:
image: RGB image with shape (H, W, 3), either uint8 in [0, 255] or
float in [0, 1].
color: Wash color, any cmap-compatible value: a name ("red"), hex
("#ff0000"), or (r, g, b) tuple of ints in [0, 255], e.g.
(255, 0, 0), or floats in [0, 1], e.g. (1.0, 0.0, 0.0).
alpha: Wash opacity in [0, 1]; 0 returns the input, 1 the solid color.

Returns:
Tinted image with the same shape and dtype as the input.

Example:
>>> import imgviz
>>> image = imgviz.data.arc2017()["rgb"]
>>> flagged = imgviz.tint(image, "red", alpha=0.3)
"""
if image.ndim != 3 or image.shape[2] != 3:
raise ValueError(
f"image must be RGB with shape (H, W, 3), got {image.shape}; "
"use imgviz.asrgb to convert"
)
is_float = np.issubdtype(image.dtype, np.floating)
if image.dtype != np.uint8 and not is_float:
raise ValueError(f"image must be uint8 or float, got {image.dtype}")
if not 0 <= alpha <= 1:
raise ValueError(f"alpha must be in [0, 1], got {alpha}")
if alpha == 0:
return image.copy()

rgba = _cmap.Color(color).rgba if is_float else _cmap.Color(color).rgba8
solid = np.array(rgba[:3], dtype=np.float64)
blended = (1 - alpha) * image + alpha * solid
if is_float:
return blended.astype(image.dtype)
return blended.round().astype(np.uint8)
60 changes: 60 additions & 0 deletions tests/unit/_tint_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import numpy as np
import pytest
from cmap import ColorLike
from numpy.typing import NDArray

import imgviz


@pytest.fixture
def rgb() -> NDArray[np.uint8]:
return imgviz.data.arc2017()["rgb"]


def test_tint(rgb: NDArray[np.uint8]) -> None:
res = imgviz.tint(rgb, "red", alpha=0.3)
assert res.shape == rgb.shape
assert res.dtype == rgb.dtype
assert not np.array_equal(res, rgb)


def test_tint_alpha_zero_returns_input_unchanged(rgb: NDArray[np.uint8]) -> None:
res = imgviz.tint(rgb, "red", alpha=0.0)
assert np.array_equal(res, rgb)
assert res is not rgb # returns a copy, not the input


def test_tint_alpha_one_is_solid_color(rgb: NDArray[np.uint8]) -> None:
res = imgviz.tint(rgb, (10, 20, 30), alpha=1.0)
assert np.array_equal(res, np.full_like(rgb, [10, 20, 30]))


@pytest.mark.parametrize("color", ["red", "#00ff00", (0, 0, 255)])
def test_tint_accepts_color_formats(rgb: NDArray[np.uint8], color: ColorLike) -> None:
res = imgviz.tint(rgb, color, alpha=0.5)
assert res.shape == rgb.shape


@pytest.mark.parametrize("dtype", [np.float32, np.float64])
def test_tint_float(dtype: type[np.floating]) -> None:
img = np.random.rand(20, 20, 3).astype(dtype)
res = imgviz.tint(img, "red", alpha=0.5)
assert res.dtype == dtype
assert res.min() >= 0 and res.max() <= 1
assert np.array_equal(imgviz.tint(img, "red", alpha=0.0), img)


def test_tint_rejects_non_rgb() -> None:
with pytest.raises(ValueError, match="must be RGB"):
imgviz.tint(np.zeros((10, 10), dtype=np.uint8), "red")


def test_tint_rejects_invalid_dtype() -> None:
with pytest.raises(ValueError, match="uint8 or float"):
imgviz.tint(np.zeros((10, 10, 3), dtype=np.int32), "red")


@pytest.mark.parametrize("alpha", [-0.1, 1.5, float("nan")])
def test_tint_rejects_invalid_alpha(rgb: NDArray[np.uint8], alpha: float) -> None:
with pytest.raises(ValueError, match="alpha must be"):
imgviz.tint(rgb, "red", alpha=alpha)
Loading