Skip to content

Commit 577ff7e

Browse files
add grayscale and bitonal algorithms (#1234)
1 parent c0a5085 commit 577ff7e

5 files changed

Lines changed: 148 additions & 1 deletion

File tree

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
* add `grayscale` and `bitonal` algorithms
6+
57
## 0.24.0 (2025-09-23)
68

79
### Misc

docs/src/user_guide/algorithms.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ We added a set of custom algorithms:
2323
- `std`: Return the **Standard Deviation** along the `bands` axis.
2424
- `var`: Return **Variance** along the `bands` axis.
2525
- `sum`: Return **Sum** along the `bands` axis.
26-
26+
- `grayscale`: Return a **grayscale** version of an image using ITU-R 601-2 luma transformation.
27+
- `bitonal`: All values larger than 127 are set to 255 (white), all other values to 0 (black).
2728

2829
### Usage
2930

src/titiler/core/tests/test_algorithms.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,3 +350,71 @@ def test_math_algorithm(name, numpy_method, options):
350350
out.array, numpy_method(img.array, axis=0, keepdims=True, **options)
351351
)
352352
assert out.array[0, 1, 1] is numpy.ma.masked
353+
354+
355+
def test_bitonal_algorithm():
356+
"""Test bitonal algorithm."""
357+
algo = default_algorithms.get("bitonal")()
358+
359+
arr = numpy.ma.MaskedArray(
360+
numpy.zeros((1, 256, 256), dtype="uint8"),
361+
mask=numpy.zeros((1, 256, 256), dtype="bool"),
362+
)
363+
arr.data[0, 100:200, 100:200] = 200
364+
arr.mask[0, 120:130, 120:130] = True
365+
img = ImageData(arr)
366+
out = algo(img)
367+
assert out.array.shape == (1, 256, 256)
368+
assert out.array.dtype == "uint8"
369+
assert out.array[0, 125, 125] is numpy.ma.masked
370+
assert out.array[0, 150, 150] == 255
371+
assert out.array[0, 50, 50] == 0
372+
373+
arr = numpy.ma.MaskedArray(
374+
numpy.zeros((3, 256, 256), dtype="uint8"),
375+
mask=numpy.zeros((3, 256, 256), dtype="bool"),
376+
)
377+
arr.data[:, 100:200, 100:200] = 200
378+
arr.mask[0, 120:130, 120:130] = True
379+
img = ImageData(arr)
380+
out = algo(img)
381+
assert out.array.shape == (1, 256, 256)
382+
assert out.array.dtype == "uint8"
383+
assert out.array[0, 125, 125] is numpy.ma.masked
384+
assert out.array[0, 150, 150] == 255
385+
assert out.array[0, 50, 50] == 0
386+
387+
arr = numpy.ma.MaskedArray(
388+
numpy.zeros((4, 256, 256), dtype="uint8"),
389+
mask=numpy.zeros((4, 256, 256), dtype="bool"),
390+
)
391+
img = ImageData(arr)
392+
with pytest.raises(ValueError):
393+
out = algo(img)
394+
395+
396+
def test_grayscale_algorithm():
397+
"""Test grayscale algorithm."""
398+
algo = default_algorithms.get("grayscale")()
399+
400+
arr = numpy.ma.MaskedArray(
401+
numpy.zeros((3, 256, 256), dtype="uint8"),
402+
mask=numpy.zeros((3, 256, 256), dtype="bool"),
403+
)
404+
arr.data[:, 100:200, 100:200] = 200
405+
arr.mask[0, 120:130, 120:130] = True
406+
img = ImageData(arr)
407+
out = algo(img)
408+
assert out.array.shape == (1, 256, 256)
409+
assert out.array.dtype == "uint8"
410+
assert out.array[0, 125, 125] is numpy.ma.masked
411+
assert out.array[0, 150, 150] != 0
412+
assert out.array[0, 50, 50] == 0
413+
414+
arr = numpy.ma.MaskedArray(
415+
numpy.zeros((2, 256, 256), dtype="uint8"),
416+
mask=numpy.zeros((2, 256, 256), dtype="bool"),
417+
)
418+
img = ImageData(arr)
419+
with pytest.raises(ValueError):
420+
out = algo(img)

src/titiler/core/titiler/core/algorithm/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
BaseAlgorithm,
1616
)
1717
from titiler.core.algorithm.dem import Contours, HillShade, Slope, TerrainRGB, Terrarium
18+
from titiler.core.algorithm.image import ToBitonal, ToGrayScale
1819
from titiler.core.algorithm.index import NormalizedIndex
1920
from titiler.core.algorithm.math import _Max, _Mean, _Median, _Min, _Std, _Sum, _Var
2021
from titiler.core.algorithm.ops import CastToInt, Ceil, Floor
@@ -36,6 +37,8 @@
3637
"std": _Std,
3738
"var": _Var,
3839
"sum": _Sum,
40+
"grayscale": ToGrayScale,
41+
"bitonal": ToBitonal,
3942
}
4043

4144

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""titiler.core.algorithm Images"""
2+
3+
import numpy
4+
from rio_tiler.models import ImageData
5+
6+
from titiler.core.algorithm.base import BaseAlgorithm
7+
8+
9+
class ToGrayScale(BaseAlgorithm):
10+
"""Transform a RGB Image to Grayscale."""
11+
12+
title: str = "Transform a RGB Image to Grayscale"
13+
description: str = "Transform a RGB Image to Grayscale using the ITU-R 601-2 luma."
14+
15+
# metadata
16+
output_nbands: int = 1
17+
18+
def __call__(self, img: ImageData) -> ImageData:
19+
"""RGB to L."""
20+
if img.count < 3:
21+
raise ValueError(
22+
f"Cannot apply `grayscale` algorithm on image with {img.count} bands."
23+
)
24+
25+
arr = (
26+
img.array[0] * 299 / 1000
27+
+ img.array[1] * 587 / 1000
28+
+ img.array[2] * 114 / 1000
29+
)
30+
return ImageData(
31+
arr.astype(img.array.dtype),
32+
assets=img.assets,
33+
crs=img.crs,
34+
band_names=["grayscale"],
35+
bounds=img.bounds,
36+
cutline_mask=img.cutline_mask,
37+
)
38+
39+
40+
class ToBitonal(BaseAlgorithm):
41+
"""Transform an Image to Bitonal."""
42+
43+
title: str = "Transform an Image to Bitonal"
44+
description: str = "All values larger than 127 are set to 255 (white), all other values to 0 (black)."
45+
46+
# metadata
47+
output_nbands: int = 1
48+
output_dtype: str = "uint8"
49+
50+
def __call__(self, img: ImageData) -> ImageData:
51+
"""Image to Bitonal"""
52+
if img.count == 3:
53+
# Convert to Grayscale
54+
arr = (
55+
img.array[0] * 299 / 1000
56+
+ img.array[1] * 587 / 1000
57+
+ img.array[2] * 114 / 1000
58+
)
59+
elif img.count == 1:
60+
arr = img.array
61+
else:
62+
raise ValueError(
63+
f"Cannot apply `bitonal` algorithm on image with {img.count} bands."
64+
)
65+
66+
return ImageData(
67+
numpy.ma.where(arr > 127, 255, 0).astype("uint8"),
68+
assets=img.assets,
69+
crs=img.crs,
70+
bounds=img.bounds,
71+
band_names=["bitonal"],
72+
cutline_mask=img.cutline_mask,
73+
)

0 commit comments

Comments
 (0)