Skip to content

Commit

Permalink
Update performance and reduce memory usage for big Labels layer in di…
Browse files Browse the repository at this point in the history
…rect color mode (#6439)

Closes #6518
Closes #6084

# Description

In this PR, similarly to #6411, instead of using `float32` to pass data
to the GPU there we introduce heuristics for choosing smaller data
types, while keeping high performance.

Instead of complex calculation of color in the shader, a precomputed
texture array is used.
To avoid repetitive texture calculation, the textures are cached in the
`Colormap` objects.

For data of type uint8/int8/uint16/int16 we do not perform any transform
of data. We send them to the GPU as it is. This allows to reduce
computational time.

Based on experiments, the rendering performance is a little worse for
uint16/int16 than for uint8/int8. But it may depend on the GPU. Also,
using uint16/int16 means usage more GPU memory than for 8 bits type.
Still less than current main.

For datatypes using at least 32 bits, we add a preprocessing step where
we identify a set of labels that are mapped to the same color and map
all of them to the same value.
This often saves enough space to fall back to uint8/uint16. It allows
using a smaller additional array, and use less GPU memory. If there are
more than `2**16` distinct colors, then float32 is used, though
performance will be reduced.

We support only up to `2**23` distinct colors for now. 

For reduced memory usage, part of the functions used for data
preprocessing are compiled using numba. We provide a version of the
function that does not require `numba` but it limits the number of
distinct colors to `2**16` and involves additional array creation (more
memory usage).

---------

Co-authored-by: Juan Nunez-Iglesias <jni@fastmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lorenzo Gaifas <brisvag@gmail.com>
Co-authored-by: Andy Sweet <andrew.d.sweet@gmail.com>
  • Loading branch information
5 people committed Dec 15, 2023
1 parent a45683f commit 6524ee4
Show file tree
Hide file tree
Showing 13 changed files with 1,122 additions and 794 deletions.
131 changes: 122 additions & 9 deletions napari/_qt/_tests/test_qt_viewer.py
Expand Up @@ -2,7 +2,7 @@
import os
import weakref
from dataclasses import dataclass
from itertools import takewhile
from itertools import product, takewhile
from typing import List, Tuple
from unittest import mock

Expand Down Expand Up @@ -674,7 +674,7 @@ def _update_data(
label: int,
qtbot: QtBot,
qt_viewer: QtViewer,
dtype=np.uint64,
dtype: np.dtype = np.uint64,
) -> Tuple[np.ndarray, np.ndarray]:
"""Change layer data and return color of label and middle pixel of screenshot."""
layer.data = np.full((2, 2), label, dtype=dtype)
Expand Down Expand Up @@ -750,36 +750,48 @@ def test_label_colors_matching_widget_auto(
@skip_local_popups
@skip_on_win_ci
@pytest.mark.parametrize("use_selection", [True, False])
@pytest.mark.parametrize("dtype", [np.uint64, np.uint16, np.uint8, np.int16])
def test_label_colors_matching_widget_direct(
qtbot, qt_viewer_with_controls, use_selection
qtbot, qt_viewer_with_controls, use_selection, dtype
):
"""Make sure the rendered label colors match the QtColorBox widget."""
data = np.ones((2, 2), dtype=np.uint64)
data = np.ones((2, 2), dtype=dtype)
layer = qt_viewer_with_controls.viewer.add_labels(data)
layer.show_selected_label = use_selection
layer.opacity = 1.0 # QtColorBox & single layer are blending differently
layer.color = {
color = {
0: "transparent",
1: "yellow",
3: "blue",
8: "red",
1000: "green",
150: "green",
None: "white",
}
test_colors = (1, 2, 3, 8, 150, 50)

test_colors = (1, 2, 3, 8, 1000, 50)
if np.iinfo(dtype).min < 0:
color[-1] = "pink"
color[-2] = "orange"
test_colors = test_colors + (-1, -2, -10)

layer.color = color

color_box_color, middle_pixel = _update_data(
layer, 0, qtbot, qt_viewer_with_controls
layer, 0, qtbot, qt_viewer_with_controls, dtype
)
assert np.allclose([0, 0, 0, 255], middle_pixel)

for label in test_colors:
# Change color & selected color to the same label
color_box_color, middle_pixel = _update_data(
layer, label, qtbot, qt_viewer_with_controls
layer, label, qtbot, qt_viewer_with_controls, dtype
)
assert np.allclose(color_box_color, middle_pixel, atol=1), label
assert np.allclose(
color_box_color,
layer.color.get(label, layer.color[None]) * 255,
atol=1,
), label


def test_axes_labels(make_napari_viewer):
Expand Down Expand Up @@ -915,3 +927,104 @@ def test_shortcut_passing(make_napari_viewer):
)
)
assert layer.mode == "erase"


@pytest.mark.parametrize("mode", ["direct", "random"])
def test_selection_collision(qt_viewer: QtViewer, mode):
data = np.zeros((10, 10), dtype=np.uint8)
data[:5] = 10
data[5:] = 10 + 49
layer = qt_viewer.viewer.add_labels(data, opacity=1)
layer.selected_label = 10
if mode == "direct":
layer.color = {10: "red", 10 + 49: "red"}

for dtype in np.sctypes['int'] + np.sctypes['uint']:
layer.data = data.astype(dtype)
layer.show_selected_label = False
QApplication.processEvents()
canvas_screenshot = qt_viewer.screenshot(flash=False)
shape = np.array(canvas_screenshot.shape[:2])
pixel_10 = canvas_screenshot[tuple((shape * 0.25).astype(int))]
pixel_59 = canvas_screenshot[tuple((shape * 0.75).astype(int))]
npt.assert_array_equal(pixel_10, pixel_59, err_msg=f"{dtype}")
assert not np.all(pixel_10 == [0, 0, 0, 255]), dtype

layer.show_selected_label = True

canvas_screenshot = qt_viewer.screenshot(flash=False)
shape = np.array(canvas_screenshot.shape[:2])
pixel_10_2 = canvas_screenshot[tuple((shape * 0.25).astype(int))]
pixel_59_2 = canvas_screenshot[tuple((shape * 0.75).astype(int))]

npt.assert_array_equal(pixel_59_2, [0, 0, 0, 255], err_msg=f"{dtype}")
npt.assert_array_equal(pixel_10_2, pixel_10, err_msg=f"{dtype}")


def test_all_supported_dtypes(qt_viewer):
data = np.zeros((10, 10), dtype=np.uint8)
layer = qt_viewer.viewer.add_labels(data, opacity=1)

for i, dtype in enumerate(np.sctypes['int'] + np.sctypes['uint'], start=1):
data = np.full((10, 10), i, dtype=dtype)
layer.data = data
QApplication.processEvents()
canvas_screenshot = qt_viewer.screenshot(flash=False)
midd_pixel = canvas_screenshot[
tuple(np.array(canvas_screenshot.shape[:2]) // 2)
]
npt.assert_equal(
midd_pixel, layer.colormap.map(i)[0] * 255, err_msg=f"{dtype} {i}"
)

layer.color = {
0: 'red',
1: 'green',
2: 'blue',
3: 'yellow',
4: 'magenta',
5: 'cyan',
6: 'white',
7: 'pink',
8: 'orange',
9: 'purple',
10: 'brown',
11: 'gray',
}

for i, dtype in enumerate(np.sctypes['int'] + np.sctypes['uint'], start=1):
data = np.full((10, 10), i, dtype=dtype)
layer.data = data
QApplication.processEvents()
canvas_screenshot = qt_viewer.screenshot(flash=False)
midd_pixel = canvas_screenshot[
tuple(np.array(canvas_screenshot.shape[:2]) // 2)
]
npt.assert_equal(
midd_pixel, layer.colormap.map(i)[0] * 255, err_msg=f"{dtype} {i}"
)


def test_more_than_uint16_colors(qt_viewer):
# this test is slow (10s locally)
data = np.zeros((10, 10), dtype=np.uint32)
colors = {
i: (x, y, z, 1)
for i, (x, y, z) in zip(
range(256**2 + 20),
product(np.linspace(0, 1, 256, endpoint=True), repeat=3),
)
}
layer = qt_viewer.viewer.add_labels(data, opacity=1, color=colors)
assert layer._slice.image.view.dtype == np.float32

for i in [1, 1000, 100000]:
data = np.full((10, 10), i, dtype=np.uint32)
layer.data = data
canvas_screenshot = qt_viewer.screenshot(flash=False)
midd_pixel = canvas_screenshot[
tuple(np.array(canvas_screenshot.shape[:2]) // 2)
]
npt.assert_equal(
midd_pixel, layer.colormap.map(i)[0] * 255, err_msg=f"{i}"
)
19 changes: 16 additions & 3 deletions napari/_qt/_tests/test_qt_viewer_2.py
Expand Up @@ -49,8 +49,21 @@ def test_qt_viewer_data_integrity(make_napari_viewer, dtype):
assert np.allclose(datamean, imean, rtol=5e-04)


def test_fix_data_dtype_big_values():
data = np.array([0, 2, 2**17], dtype=np.uint32)
@pytest.mark.parametrize(
"dtype,expected",
[
(np.bool_, np.uint8),
(np.int8, np.float32),
(np.uint8, np.uint8),
(np.int16, np.float32),
(np.uint16, np.uint16),
(np.uint32, np.float32),
(np.float32, np.float32),
(np.float64, np.float32),
],
)
def test_fix_data_dtype_big_values(dtype, expected):
data = np.array([0, 2, 2**17], dtype=np.int32).astype(dtype)
casted = fix_data_dtype(data)
assert np.allclose(casted, data)
assert casted.dtype == np.float32
assert casted.dtype == expected
117 changes: 10 additions & 107 deletions napari/_vispy/_tests/test_vispy_labels.py
@@ -1,121 +1,24 @@
from itertools import product
from unittest.mock import patch

import numpy as np
import pytest

from napari._vispy.layers.labels import (
MAX_LOAD_FACTOR,
PRIME_NUM_TABLE,
build_textures_from_dict,
hash2d_get,
idx_to_2d,
)


@pytest.fixture(scope='module', autouse=True)
def mock_max_texture_size():
"""When running tests in this file, pretend max texture size is 2^16."""
with patch('napari._vispy.layers.labels.MAX_TEXTURE_SIZE', 2**16):
yield


def test_idx_to_2d():
assert idx_to_2d(0, (100, 100)) == (0, 0)
assert idx_to_2d(1, (100, 100)) == (0, 1)
assert idx_to_2d(101, (100, 100)) == (1, 1)
assert idx_to_2d(521, (100, 100)) == (5, 21)
assert idx_to_2d(100 * 100 + 521, (100, 100)) == (5, 21)


def test_build_textures_from_dict():
keys, values, collision = build_textures_from_dict(
{1: (1, 1, 1, 1), 2: (2, 2, 2, 2)}
values = build_textures_from_dict(
{0: (0, 0, 0, 0), 1: (1, 1, 1, 1), 2: (2, 2, 2, 2)},
max_size=10,
)
assert not collision
assert keys.shape == (37, 37)
assert values.shape == (37, 37, 4)
assert keys[0, 1] == 1
assert keys[0, 2] == 2
assert np.array_equiv(values[0, 1], (1, 1, 1, 1))
assert np.array_equiv(values[0, 2], (2, 2, 2, 2))
assert values.shape == (3, 1, 4)
assert np.array_equiv(values[1], (1, 1, 1, 1))
assert np.array_equiv(values[2], (2, 2, 2, 2))


def test_build_textures_from_dict_too_many_labels(monkeypatch):
with pytest.raises(MemoryError):
build_textures_from_dict(
{i: (i, i, i, i) for i in range(1001)}, shape=(10, 10)
)
monkeypatch.setattr(
"napari._vispy.layers.labels.PRIME_NUM_TABLE", [[61], [127]]
)
with pytest.raises(MemoryError):
def test_build_textures_from_dict_exc():
with pytest.raises(ValueError, match="Cannot create a 2D texture"):
build_textures_from_dict(
{i: (i, i, i, i) for i in range((251**2) // 2)},
{0: (0, 0, 0, 0), 1: (1, 1, 1, 1), 2: (2, 2, 2, 2)},
max_size=1,
)


def test_size_of_texture_square():
count = int(127 * 127 * MAX_LOAD_FACTOR) - 1
keys, values, *_ = build_textures_from_dict(
{i: (i, i, i, i) for i in range(count)}
)
assert keys.shape == (127, 127)
assert values.shape == (127, 127, 4)


def test_size_of_texture_rectangle():
count = int(128 * 128 * MAX_LOAD_FACTOR) + 5
keys, values, *_ = build_textures_from_dict(
{i: (i, i, i, i) for i in range(count)}
)
assert keys.shape == (251, 127)
assert values.shape == (251, 127, 4)


def test_build_textures_from_dict_collision():
keys, values, collision = build_textures_from_dict(
{1: (1, 1, 1, 1), 26: (2, 2, 2, 2), 27: (3, 3, 3, 3)}, shape=(5, 5)
)
assert collision
assert keys.shape == (5, 5)
assert keys[0, 1] == 1
assert keys[0, 2] == 26
assert keys[0, 3] == 27
assert np.array_equiv(values[0, 1], (1, 1, 1, 1))
assert np.array_equiv(values[0, 2], (2, 2, 2, 2))
assert np.array_equiv(values[0, 3], (3, 3, 3, 3))

assert hash2d_get(1, keys) == (0, 1)
assert hash2d_get(26, keys) == (0, 2)
assert hash2d_get(27, keys) == (0, 3)


def test_collide_keys():
base_keys = [x * y for x, y in product(PRIME_NUM_TABLE[0], repeat=2)]
colors = {0: (0, 0, 0, 0), 1: (1, 1, 1, 1)}
colors.update({i + 10: (1, 0, 0, 1) for i in base_keys})
colors.update({2 * i + 10: (0, 1, 0, 1) for i in base_keys})
keys, values, collision = build_textures_from_dict(colors)
assert not collision
assert keys.shape == (37, 61)
assert values.shape == (37, 61, 4)


def test_collide_keys2():
base_keys = [x * y for x, y in product(PRIME_NUM_TABLE[0], repeat=2)] + [
x * y for x, y in product(PRIME_NUM_TABLE[0], PRIME_NUM_TABLE[1])
]
colors = {0: (0, 0, 0, 0), 1: (1, 1, 1, 1)}
colors.update({i + 10: (1, 0, 0, 1) for i in base_keys})
colors.update({2 * i + 10: (0, 1, 0, 1) for i in base_keys})

# enforce collision for collision table of size 31
colors.update({31 * i + 10: (0, 0, 1, 1) for i in base_keys})
# enforce collision for collision table of size 29
colors.update({29 * i + 10: (0, 0, 1, 1) for i in base_keys})

keys, values, collision = build_textures_from_dict(colors)
assert collision
assert keys.shape == (37, 37)
assert values.shape == (37, 37, 4)
8 changes: 6 additions & 2 deletions napari/_vispy/layers/image.py
Expand Up @@ -287,7 +287,9 @@ def downsample_texture(
if self.layer.multiscale:
raise ValueError(
trans._(
"Shape of in dividual tiles in multiscale {shape} cannot exceed GL_MAX_TEXTURE_SIZE {texture_size}. Rendering is currently in {ndisplay}D mode.",
"Shape of individual tiles in multiscale {shape} cannot "
"exceed GL_MAX_TEXTURE_SIZE {texture_size}. Rendering is "
"currently in {ndisplay}D mode.",
deferred=True,
shape=data.shape,
texture_size=MAX_TEXTURE_SIZE,
Expand All @@ -296,7 +298,9 @@ def downsample_texture(
)
warnings.warn(
trans._(
"data shape {shape} exceeds GL_MAX_TEXTURE_SIZE {texture_size} in at least one axis and will be downsampled. Rendering is currently in {ndisplay}D mode.",
"data shape {shape} exceeds GL_MAX_TEXTURE_SIZE {texture_size}"
" in at least one axis and will be downsampled."
" Rendering is currently in {ndisplay}D mode.",
deferred=True,
shape=data.shape,
texture_size=MAX_TEXTURE_SIZE,
Expand Down

0 comments on commit 6524ee4

Please sign in to comment.