@@ -0,0 +1,97 @@
Loading
1 +
"""QtTestImage class.
2 +
"""
3 +
from typing import Callable, Tuple
4 +
5 +
import numpy as np
6 +
from qtpy.QtWidgets import QFrame, QPushButton, QVBoxLayout
7 +
8 +
from .qt_labeled_spin_box import QtLabeledSpinBox
9 +
from .test_image import create_tiled_text_array
10 +
11 +
12 +
class QtTestImageLayout(QVBoxLayout):
13 +
    """Layout for the Test Image frame.
14 +
15 +
    Parameters
16 +
    ----------
17 +
    on_create : Callable[[], None]
18 +
        Called when the create test image button is pressed.
19 +
    """
20 +
21 +
    def __init__(self, on_create: Callable[[], None]):
22 +
        super().__init__()
23 +
        self.addStretch(1)
24 +
25 +
        # Dimension controls.
26 +
        image_size_range = range(1, 65536, 100)
27 +
        self.width = QtLabeledSpinBox("Image Width", 1024, image_size_range)
28 +
        self.height = QtLabeledSpinBox("Image Height", 1024, image_size_range)
29 +
        self.addLayout(self.width)
30 +
        self.addLayout(self.height)
31 +
32 +
        tile_size_range = range(1, 4096, 100)
33 +
        self.tile_size = QtLabeledSpinBox("Tile Size", 64, tile_size_range)
34 +
        self.addLayout(self.tile_size)
35 +
36 +
        # Test image button.
37 +
        button = QPushButton("Create Test Image")
38 +
        button.setToolTip("Create a new test image")
39 +
        button.clicked.connect(on_create)
40 +
        self.addWidget(button)
41 +
42 +
    def get_image_size(self) -> Tuple[int, int]:
43 +
        """Return the configured image size.
44 +
45 +
        Return
46 +
        ------
47 +
        Tuple[int, int]
48 +
            The [width, height] requested by the user.
49 +
        """
50 +
        return (self.width.spin.value(), self.height.spin.value())
51 +
52 +
    def get_tile_size(self) -> int:
53 +
        """Return the configured tile size.
54 +
55 +
        Return
56 +
        ------
57 +
        int
58 +
            The requested tile size.
59 +
        """
60 +
        return self.tile_size.spin.value()
61 +
62 +
63 +
class QtTestImage(QFrame):
64 +
    """Frame with controls to create a new test image.
65 +
66 +
    Parameters
67 +
    ----------
68 +
    viewer : Viewer
69 +
        The napari viewer.
70 +
    layer : Layer
71 +
        The layer we are hook up to.
72 +
    """
73 +
74 +
    # This is a class attribute so that we use a unique index napari-wide,
75 +
    # not just within in this one QtRender widget, this one layer.
76 +
    image_index = 0
77 +
78 +
    def __init__(self, viewer, layer):
79 +
        super().__init__()
80 +
        self.viewer = viewer
81 +
        self.layer = layer
82 +
        self.layout = QtTestImageLayout(self._create_test_image)
83 +
        self.setLayout(self.layout)
84 +
85 +
    def _create_test_image(self) -> None:
86 +
        """Create a new test image."""
87 +
        image_size = self.layout.get_image_size()
88 +
        images = [
89 +
            create_tiled_text_array(x, 16, 16, image_size) for x in range(5)
90 +
        ]
91 +
        data = np.stack(images, axis=0)
92 +
93 +
        unique_name = f"test-image-{QtTestImage.image_index:003}"
94 +
        QtTestImage.image_index += 1
95 +
96 +
        layer = self.viewer.add_image(data, rgb=True, name=unique_name)
97 +
        layer.tile_size = self.layout.get_tile_size()

@@ -0,0 +1,85 @@
Loading
1 +
"""RenderSpinBox class.
2 +
"""
3 +
from typing import Callable
4 +
5 +
from qtpy.QtCore import Qt
6 +
from qtpy.QtWidgets import QHBoxLayout, QLabel, QSpinBox
7 +
8 +
9 +
class QtLabeledSpinBox(QHBoxLayout):
10 +
    """A label plus a SpinBox for the QtRender widget.
11 +
12 +
    This was cobbled together quickly for QtRender. We could probably use
13 +
    some napari-standard control instead?
14 +
15 +
    Parameters
16 +
    ----------
17 +
    label_text : str
18 +
        The label shown next to the spin box.
19 +
    initial_value : int
20 +
        The initial value of the spin box.
21 +
    spin_range : range
22 +
        The min/max/step of the spin box.
23 +
    connect : Callable[[int], None]
24 +
        Called when the user changes the value of the spin box.
25 +
26 +
    Attributes
27 +
    ----------
28 +
    spin : QSpinBox
29 +
        The spin box.
30 +
    """
31 +
32 +
    def __init__(
33 +
        self,
34 +
        label_text: str,
35 +
        initial_value: int,
36 +
        spin_range: range,
37 +
        connect: Callable[[int], None] = None,
38 +
    ):
39 +
        super().__init__()
40 +
        self.connect = connect
41 +
42 +
        self.addWidget(QLabel(label_text))
43 +
        self.spin = self._create(initial_value, spin_range)
44 +
        self.addWidget(self.spin)
45 +
46 +
    def _create(self, initial_value: int, spin_range: range) -> QSpinBox:
47 +
        """Return the configured QSpinBox.
48 +
49 +
        Parameters
50 +
        ----------
51 +
        initial_value : int
52 +
            The initial value of the QSpinBox.
53 +
        spin_range : range
54 +
            The start/stop/step of the QSpinBox.
55 +
56 +
        Return
57 +
        ------
58 +
        QSpinBox
59 +
            The configured QSpinBox.
60 +
        """
61 +
        spin = QSpinBox()
62 +
63 +
        spin.setMinimum(spin_range.start)
64 +
        spin.setMaximum(spin_range.stop)
65 +
        spin.setSingleStep(spin_range.step)
66 +
        spin.setValue(initial_value)
67 +
68 +
        spin.setKeyboardTracking(False)  # correct?
69 +
        spin.setAlignment(Qt.AlignCenter)
70 +
71 +
        spin.valueChanged.connect(self._on_change)
72 +
        return spin
73 +
74 +
    def _on_change(self, value: int) -> None:
75 +
        """Called when the spin spin value was changed.
76 +
77 +
        value : int
78 +
            The new value of the SpinBox.
79 +
        """
80 +
        # We must clearFocus or it would double-step, no idea why.
81 +
        self.spin.clearFocus()
82 +
83 +
        # Notify any connection we have.
84 +
        if self.connect is not None:
85 +
            self.connect(value)

@@ -0,0 +1,62 @@
Loading
1 +
import numpy as np
2 +
import pytest
3 +
4 +
from napari.layers.image.experimental.octree import _combine_tiles
5 +
6 +
7 +
def _square(value):
8 +
    return np.array([[value, value], [value, value]])
9 +
10 +
11 +
def test_combine():
12 +
    """Test _combine_tiles().
13 +
14 +
    Combine 1, 2 or 4 tiles into a single bigger one.
15 +
    """
16 +
    # Create 4 square arrays:
17 +
    # 0  1  2  3
18 +
    # -----------
19 +
    # 00 11 22 33
20 +
    # 00 11 22 33
21 +
    tiles = [np.array(_square(i)) for i in range(4)]
22 +
23 +
    with pytest.raises(ValueError):
24 +
        _combine_tiles(tiles[0], tiles[1], tiles[2])  # Too few values.
25 +
26 +
    with pytest.raises(ValueError):
27 +
        _combine_tiles(tiles[0], None, None, None, None)  # Too many values.
28 +
29 +
    # Combine them the 4 major ways:
30 +
31 +
    # case1: corner
32 +
    # 0X
33 +
    # XX
34 +
    case1 = _combine_tiles(tiles[0], None, None, None)
35 +
    assert case1.shape == (2, 2)
36 +
    assert (case1 == tiles[0]).all()
37 +
38 +
    # case2: bottom edge
39 +
    # 01
40 +
    # XX
41 +
    case2 = _combine_tiles(tiles[0], tiles[1], None, None)
42 +
    assert case2.shape == (2, 4)
43 +
    assert (case2[0:2, 0:2] == tiles[0]).all()
44 +
    assert (case2[0:2, 3:5] == tiles[1]).all()
45 +
46 +
    # case3: right edge
47 +
    # 0X
48 +
    # 2X
49 +
    case3 = _combine_tiles(tiles[0], None, tiles[2], None)
50 +
    assert case3.shape == (4, 2)
51 +
    assert (case3[0:2, 0:2] == tiles[0]).all()
52 +
    assert (case3[3:5, 0:2] == tiles[2]).all()
53 +
54 +
    # case4: interior
55 +
    # 01
56 +
    # 23
57 +
    case4 = _combine_tiles(tiles[0], tiles[1], tiles[2], tiles[3])
58 +
    assert case4.shape == (4, 4)
59 +
    assert (case4[0:2, 0:2] == tiles[0]).all()
60 +
    assert (case4[0:2, 3:5] == tiles[1]).all()
61 +
    assert (case4[3:5, 0:2] == tiles[2]).all()
62 +
    assert (case4[3:5, 3:5] == tiles[3]).all()

@@ -61,10 +61,10 @@
Loading
61 61
            self.setCurrentWidget(controls)
62 62
63 63
    def _get_widget(self, layer):
64 -
        from ...layers.image.experimental.octree_image import OctreeImage
64 +
        from ....layers.image.experimental.octree_image import OctreeImage
65 65
66 66
        if isinstance(layer, OctreeImage):
67 -
            return QtRender(layer)
67 +
            return QtRender(self.viewer, layer)
68 68
        else:
69 69
            return self.empty_widget
70 70

@@ -0,0 +1,73 @@
Loading
1 +
"""create_text_image: create a PIL image with centered text.
2 +
"""
3 +
import numpy as np
4 +
from PIL import Image, ImageDraw, ImageFont
5 +
6 +
7 +
def draw_text(image, text, nx=0.5, ny=0.5):
8 +
9 +
    font = ImageFont.truetype('Arial Black.ttf', size=72)
10 +
    (text_width, text_height) = font.getsize(text)
11 +
    x = nx * image.width - text_width / 2
12 +
    y = ny * image.height - text_height / 2
13 +
14 +
    color = 'rgb(255, 255, 255)'  # white
15 +
16 +
    draw = ImageDraw.Draw(image)
17 +
    draw.text((x, y), text, fill=color, font=font)
18 +
    draw.rectangle([0, 0, image.width, image.height], width=5)
19 +
20 +
21 +
def draw_text_tiled(image, text, nrows=1, ncols=1):
22 +
23 +
    print(f"Creating {nrows}x{ncols} text image: {text}")
24 +
25 +
    try:
26 +
        font = ImageFont.truetype('Arial Black.ttf', size=74)
27 +
    except OSError:
28 +
        font = ImageFont.load_default()
29 +
    (text_width, text_height) = font.getsize(text)
30 +
31 +
    color = 'rgb(255, 255, 255)'  # white
32 +
    draw = ImageDraw.Draw(image)
33 +
34 +
    for row in range(nrows + 1):
35 +
        for col in range(ncols + 1):
36 +
            x = (col / ncols) * image.width - text_width / 2
37 +
            y = (row / nrows) * image.height - text_height / 2
38 +
39 +
            draw.text((x, y), text, fill=color, font=font)
40 +
    draw.rectangle([0, 0, image.width, image.height], width=5)
41 +
42 +
43 +
def create_text_array(text, nx=0.5, ny=0.5, size=(1024, 1024)):
44 +
    text = str(text)
45 +
    image = Image.new('RGB', size)
46 +
    draw_text(image, text, nx, ny)
47 +
    return np.array(image)
48 +
49 +
50 +
def create_tiled_text_array(text, nrows, ncols, size=(1024, 1024)):
51 +
    text = str(text)
52 +
    image = Image.new('RGB', size)
53 +
    draw_text_tiled(image, text, nrows, ncols)
54 +
    return np.array(image)
55 +
56 +
57 +
def create_tiled_test_1(text, nrows, ncols, size=(1024, 1024)):
58 +
    text = str(text)
59 +
    image = Image.new('RGB', size)
60 +
    draw_text_tiled(image, text, nrows, ncols)
61 +
    return np.array(image)
62 +
63 +
64 +
def test():
65 +
    image = Image.new('RGB', (1024, 1024))
66 +
    draw_text_tiled(image, "1", 10, 10)
67 +
    outfile = "image.png"
68 +
    image.save(outfile)
69 +
    print(f"Wrote: {outfile}")
70 +
71 +
72 +
if __name__ == '__main__':
73 +
    test()

@@ -260,7 +260,9 @@
Loading
260 260
            from ..components.experimental.chunk import async_config
261 261
262 262
            if async_config.octree_visuals:
263 -
                from .experimental.qt_render_container import QtRenderContainer
263 +
                from .experimental.render.qt_render_container import (
264 +
                    QtRenderContainer,
265 +
                )
264 266
265 267
                return QtViewerDockWidget(
266 268
                    self,

@@ -5,7 +5,7 @@
Loading
5 5
6 6
from ....types import ArrayLike
7 7
from .._image_slice import ImageSlice
8 -
from .octree import ChunkData, Octree
8 +
from .octree import Octree
9 9
10 10
LOGGER = logging.getLogger("napari.async")
11 11
@@ -19,16 +19,27 @@
Loading
19 19
        image: ArrayLike,
20 20
        image_converter: Callable[[ArrayLike], ArrayLike],
21 21
        rgb: bool,
22 +
        tile_size: int,
22 23
        octree_level: int,
24 +
        data_corners,
23 25
    ):
24 26
        LOGGER.debug("OctreeImageSlice.__init__")
25 27
        super().__init__(image, image_converter, rgb)
26 28
29 +
        self._tile_size = tile_size
27 30
        self._octree = None
28 31
        self._octree_level = octree_level
32 +
        self._data_corners = data_corners
29 33
30 34
    @property
31 -
    def num_octree_levels(self):
35 +
    def num_octree_levels(self) -> int:
36 +
        """Return the number of levels in the octree.
37 +
38 +
        Return
39 +
        ------
40 +
        int
41 +
            The number of levels in the octree.
42 +
        """
32 43
        return self._octree.num_levels
33 44
34 45
    def _set_raw_images(
@@ -51,28 +62,27 @@
Loading
51 62
        # is a *single* scale image and we create an octree on the fly just
52 63
        # so we have something to render.
53 64
        # with block_timer("create octree", print_time=True):
54 -
        self._octree = Octree.from_image(image)
65 +
        self._octree = Octree.from_image(image, self._tile_size)
55 66
56 -
        # None means use coarsest level.
57 -
        if self._octree_level is None:
67 +
        # Set to max level if we had no previous level (None) or if
68 +
        # our previous level was too high for this new tree.
69 +
        if (
70 +
            self._octree_level is None
71 +
            or self._octree_level >= self._octree.num_levels
72 +
        ):
58 73
            self._octree_level = self._octree.num_levels - 1
59 74
60 75
        # self._octree.print_tiles()
61 76
62 77
    @property
63 78
    def view_chunks(self):
64 -
        """Chunks currently in view."""
65 -
        print(f"view_chunks: octree_level={self._octree_level}")
79 +
        """Return the chunks currently in view."""
80 +
81 +
        # This will be None if we have not been drawn yet.
82 +
        if self._data_corners is None:
83 +
            return []
84 +
85 +
        # TODO_OCTREE: soon we will compute which level to draw.
66 86
        level = self._octree.levels[self._octree_level]
67 -
        nrows = len(level)
68 -
        chunks = []
69 -
        x = 0
70 -
        y = 0
71 -
        size = 1 / nrows
72 -
        for row in level:
73 -
            x = 0
74 -
            for tile in row:
75 -
                chunks.append(ChunkData(tile, [x, y], size))
76 -
                x += size
77 -
            y += size
78 -
        return chunks
87 +
88 +
        return level.get_chunks(self._data_corners)

@@ -1,16 +1,88 @@
Loading
1 1
"""VispyTiledImageLayer class.
2 2
"""
3 3
import numpy as np
4 +
from vispy.scene.visuals import Line
4 5
from vispy.visuals.transforms import STTransform
5 6
6 7
from ...layers.image.experimental.octree import ChunkData
7 8
from ..image import Image as ImageNode
8 9
from ..vispy_image_layer import VispyImageLayer
9 10
11 +
GRID_WIDTH = 3
12 +
GRID_COLOR = (1, 0, 0, 1)
13 +
14 +
15 +
def _chunk_outline(chunk: ChunkData) -> np.ndarray:
16 +
    """Return line verts that outline the given chunk.
17 +
18 +
    Parameters
19 +
    ----------
20 +
    chunk : ChunkData
21 +
        Create outline of this chunk.
22 +
23 +
    Return
24 +
    ------
25 +
    np.ndarray
26 +
        The chunk verts for 'segments' mode.
27 +
    """
28 +
    x, y = chunk.pos
29 +
    h, w = chunk.data.shape[:2]
30 +
    w *= chunk.scale[1]
31 +
    h *= chunk.scale[0]
32 +
33 +
    # Outline very chunk which means we double-draw all interior lines,
34 +
    # but we can worry about that later if it causes perf or other issues.
35 +
    return np.array(
36 +
        (
37 +
            [x, y],
38 +
            [x + w, y],
39 +
            [x + w, y],
40 +
            [x + w, y + h],
41 +
            [x + w, y + h],
42 +
            [x, y + h],
43 +
            [x, y + h],
44 +
            [x, y],
45 +
        ),
46 +
        dtype=np.float32,
47 +
    )
48 +
10 49
11 50
class ImageChunk:
51 +
    """The ImageNode for a single chunk.
52 +
53 +
    This class will grow soon...
54 +
    """
55 +
12 56
    def __init__(self):
13 57
        self.node = ImageNode(None, method='auto')
58 +
        self.node.order = 0
59 +
60 +
61 +
class TileGrid:
62 +
    """The grid that shows the outlines of all the tiles."""
63 +
64 +
    def __init__(self):
65 +
        self.reset()
66 +
        self.line = Line(
67 +
            connect='segments', color=GRID_COLOR, width=GRID_WIDTH
68 +
        )
69 +
        self.line.order = 10
70 +
71 +
    def reset(self) -> None:
72 +
        """Reset the grid to have no lines."""
73 +
        self.verts = np.zeros((0, 2), dtype=np.float32)
74 +
75 +
    def add_chunk_outline(self, chunk: ChunkData) -> None:
76 +
        """Add the outline of the given chunk to the grid.
77 +
78 +
        Parameters
79 +
        ----------
80 +
        chunk : ChunkData
81 +
            Add the outline of this chunk.
82 +
        """
83 +
        chunk_verts = _chunk_outline(chunk)
84 +
        self.verts = np.vstack([self.verts, chunk_verts])
85 +
        self.line.set_data(self.verts)
14 86
15 87
16 88
class VispyTiledImageLayer(VispyImageLayer):
@@ -49,17 +121,12 @@
Loading
49 121
50 122
    def __init__(self, layer):
51 123
        self.chunks = {}
124 +
        self.grid = TileGrid()
52 125
53 126
        # This will call our self._on_data_change().
54 127
        super().__init__(layer)
55 128
56 -
    def _outline_chunk(self, data):
57 -
        line = np.array([255, 0, 0])
58 -
        data[0, :, :] = line
59 -
        data[-1, :, :] = line
60 -
        data[:, 0, :] = line
61 -
        data[:, -1, :] = line
62 -
        return data
129 +
        self.grid.line.parent = self.node
63 130
64 131
    def _create_image_chunk(self, chunk: ChunkData):
65 132
        """Add a new chunk.
@@ -71,23 +138,14 @@
Loading
71 138
        """
72 139
        image_chunk = ImageChunk()
73 140
74 -
        data = self._outline_chunk(chunk.data)
141 +
        self.grid.add_chunk_outline(chunk)
75 142
76 143
        # Parent VispyImageLayer will process the data then set it.
77 -
        self._set_node_data(image_chunk.node, data)
144 +
        self._set_node_data(image_chunk.node, chunk.data)
78 145
79 -
        # Make the new ImageChunk a child positioned with us.
146 +
        # Add this new ImageChunk as child of self.node, transformed into place.
80 147
        image_chunk.node.parent = self.node
81 -
        pos = [chunk.pos[0] * 1024, chunk.pos[1] * 1024]
82 -
        size = chunk.size * 16
83 -
        # pos = [512, 0]
84 -
        # size = 7
85 -
86 -
        # print(pos, size)
87 -
88 -
        image_chunk.node.transform = STTransform(
89 -
            translate=pos, scale=[size, size]
90 -
        )
148 +
        image_chunk.node.transform = STTransform(chunk.scale, chunk.pos)
91 149
92 150
        return image_chunk
93 151
@@ -98,8 +156,13 @@
Loading
98 156
            # Do nothing if we are not yet loaded.
99 157
            return
100 158
159 +
        # For now, nuke all the old chunks.
160 +
        for image_chunk in self.chunks.values():
161 +
            image_chunk.node.parent = None
162 +
        self.chunks = {}
163 +
        self.grid.reset()
164 +
101 165
        for chunk in self.layer.view_chunks:
102 166
            chunk_id = id(chunk.data)
103 167
            if chunk_id not in self.chunks:
104 -
                # print(f"Adding chunk {chunk_id}")
105 168
                self.chunks[chunk_id] = self._create_image_chunk(chunk)

@@ -5,6 +5,8 @@
Loading
5 5
from ._chunked_slice_data import ChunkedSliceData
6 6
from ._octree_image_slice import OctreeImageSlice
7 7
8 +
DEFAULT_TILE_SIZE = 64
9 +
8 10
9 11
class OctreeImage(Image):
10 12
    """OctreeImage layer.
@@ -15,22 +17,41 @@
Loading
15 17
    """
16 18
17 19
    def __init__(self, *args, **kwargs):
20 +
        self._tile_size = DEFAULT_TILE_SIZE
18 21
        self._octree_level = None
22 +
        self._data_corners = None
19 23
        super().__init__(*args, **kwargs)
20 24
        self.events.add(octree_level=Event)
21 25
26 +
    @property
27 +
    def tile_size(self) -> int:
28 +
        return self._tile_size
29 +
30 +
    @tile_size.setter
31 +
    def tile_size(self, tile_size: int) -> None:
32 +
        self._tile_size = tile_size
33 +
22 34
    @property
23 35
    def octree_level(self):
36 +
        """Return the currently displayed octree level."""
24 37
        return self._octree_level
25 38
39 +
    @property
40 +
    def num_octree_levels(self) -> int:
41 +
        """Return the total number of octree levels."""
42 +
        return self._slice.num_octree_levels
43 +
26 44
    @octree_level.setter
27 -
    def octree_level(self, level):
28 -
        max_level = self._slice.num_octree_levels - 1
29 -
        if level > max_level:
30 -
            self._octree_level = max_level
31 -
            self.events.octree_level()  # Report we modified the request.
32 -
        else:
33 -
            self._octree_level = level
45 +
    def octree_level(self, level: int):
46 +
        """Set the octree level we should be displaying.
47 +
48 +
        Parameters
49 +
        ----------
50 +
        level : int
51 +
            Display this octree level.
52 +
        """
53 +
        assert 0 <= level < self.num_octree_levels
54 +
        self._octree_level = level
34 55
        self.refresh()  # Create new slice with this level.
35 56
36 57
    def _new_empty_slice(self):
@@ -40,7 +61,9 @@
Loading
40 61
            self._get_empty_image(),
41 62
            self._raw_to_displayed,
42 63
            self.rgb,
64 +
            self._tile_size,
43 65
            self._octree_level,
66 +
            self._data_corners,
44 67
        )
45 68
        self._empty = True
46 69
@@ -62,3 +85,17 @@
Loading
62 85
63 86
        if has_event:
64 87
            self.events.octree_level()
88 +
89 +
    def _update_draw(self, scale_factor, corner_pixels, shape_threshold):
90 +
91 +
        # If self._data_corners was not set yet, we have not been drawn
92 +
        # yet, and we need to refresh to draw ourselves for the first time.
93 +
        need_refresh = self._data_corners is None
94 +
95 +
        self._data_corners = self._transforms[1:].simplified.inverse(
96 +
            corner_pixels
97 +
        )
98 +
        super()._update_draw(scale_factor, corner_pixels, shape_threshold)
99 +
100 +
        if need_refresh:
101 +
            self.refresh()

@@ -0,0 +1,45 @@
Loading
1 +
"""QtAsync widget.
2 +
"""
3 +
import numpy as np
4 +
from qtpy.QtGui import QImage, QPixmap
5 +
from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget
6 +
7 +
from .qt_image_info import QtImageInfo
8 +
from .qt_test_image import QtTestImage
9 +
10 +
# Global so no matter where you create the test image it increases.
11 +
test_image_index = 0
12 +
13 +
14 +
class QtRender(QWidget):
15 +
    """Dockable widget for render controls.
16 +
17 +
    Attributes
18 +
    ----------
19 +
    """
20 +
21 +
    def __init__(self, viewer, layer):
22 +
        """Create our windgets.
23 +
        """
24 +
        super().__init__()
25 +
        self.layer = layer
26 +
27 +
        layout = QVBoxLayout()
28 +
29 +
        layout.addWidget(QtImageInfo(layer))
30 +
31 +
        self.mini_map = QLabel()
32 +
        layout.addWidget(self.mini_map)
33 +
34 +
        layout.addStretch(1)
35 +
        layout.addWidget(QtTestImage(viewer, layer))
36 +
        self.setLayout(layout)
37 +
38 +
        data = np.zeros((50, 50, 4), dtype=np.uint8)
39 +
        data[:, 25, :] = (255, 255, 255, 255)
40 +
        data[25, :, :] = (255, 255, 255, 255)
41 +
42 +
        image = QImage(
43 +
            data, data.shape[1], data.shape[0], QImage.Format_RGBA8888,
44 +
        )
45 +
        self.mini_map.setPixmap(QPixmap.fromImage(image))
0 46
imilarity index 95%
1 47
ename from napari/_qt/experimental/qt_render_container.py
2 48
ename to napari/_qt/experimental/render/qt_render_container.py

@@ -0,0 +1,65 @@
Loading
1 +
"""QtImageInfo class.
2 +
"""
3 +
from typing import Callable
4 +
5 +
from qtpy.QtWidgets import QFrame, QLabel, QVBoxLayout
6 +
7 +
from .qt_labeled_spin_box import QtLabeledSpinBox
8 +
9 +
10 +
class QtImageInfoLayout(QVBoxLayout):
11 +
    """Layout of the image info frame.
12 +
13 +
    Parameters
14 +
    ----------
15 +
    layer : Layer
16 +
        The layer we are associated with.
17 +
    on_new_octree_level : Callable[[int], None]
18 +
        Call this when the octree level is changed.
19 +
    """
20 +
21 +
    def __init__(self, layer, on_new_octree_level: Callable[[int], None]):
22 +
        super().__init__()
23 +
24 +
        # Octree level SpinBox.
25 +
        max_level = layer.num_octree_levels - 1
26 +
        self.octree_level = QtLabeledSpinBox(
27 +
            "Octree Level",
28 +
            max_level,
29 +
            range(0, max_level, 1),
30 +
            connect=on_new_octree_level,
31 +
        )
32 +
        self.addLayout(self.octree_level)
33 +
34 +
        shape = layer.data.shape
35 +
        height, width = shape[1:3]  # Which dims are really width/height?
36 +
37 +
        # Dimension related labels.
38 +
        self.addWidget(QLabel(f"Shape: {shape}"))
39 +
        self.addWidget(QLabel(f"Width: {width}"))
40 +
        self.addWidget(QLabel(f"Height: {height}"))
41 +
        self.addWidget(QLabel(f"Tile Size: {layer.tile_size}"))
42 +
43 +
44 +
class QtImageInfo(QFrame):
45 +
    """Frame showing the octree level and image dimensions.
46 +
47 +
    layer : Layer
48 +
        Show info about this layer.
49 +
    """
50 +
51 +
    def __init__(self, layer):
52 +
        super().__init__()
53 +
54 +
        def _update_layer(value):
55 +
            layer.octree_level = value
56 +
57 +
        layout = QtImageInfoLayout(layer, _update_layer)
58 +
        self.setLayout(layout)
59 +
60 +
        def _update_layout(event=None):
61 +
            layout.octree_level.spin.setValue(layer.octree_level)
62 +
63 +
        # Update layout now and hook to event for future updates.
64 +
        _update_layout()
65 +
        layer.events.octree_level.connect(_update_layout)

@@ -26,10 +26,15 @@
Loading
26 26
        The size of the chunk, the chunk is square/cubic.
27 27
    """
28 28
29 -
    def __init__(self, data: ArrayLike, pos: Tuple[float, float], size: float):
29 +
    def __init__(
30 +
        self,
31 +
        data: ArrayLike,
32 +
        pos: Tuple[float, float],
33 +
        scale: Tuple[float, float],
34 +
    ):
30 35
        self.data = data
31 36
        self.pos = pos
32 -
        self.size = size
37 +
        self.scale = scale
33 38
34 39
35 40
def _create_tiles(array: np.ndarray, tile_size: int) -> np.ndarray:
@@ -53,6 +58,8 @@
Loading
53 58
54 59
    tiles = []
55 60
61 +
    print(f"_create_tiles array={array.shape} tile_size={tile_size}")
62 +
56 63
    row = 0
57 64
    while row < rows:
58 65
        row_tiles = []
@@ -67,221 +74,266 @@
Loading
67 74
    return tiles
68 75
69 76
70 -
def _create_downsampled_tile(
71 -
    ul: np.ndarray, ur: np.ndarray, ll: np.ndarray, lr: np.ndarray
72 -
) -> np.ndarray:
77 +
def _get_tile(tiles: TileArray, row, col):
78 +
    try:
79 +
        return tiles[row][col]
80 +
    except IndexError:
81 +
        return None
82 +
83 +
84 +
def _none(items):
85 +
    return all(x is None for x in items)
86 +
87 +
88 +
def _combine_tiles(*tiles: np.ndarray) -> np.ndarray:
89 +
    """Combine 1-4 tiles into a single tile.
90 +
91 +
    Parameters
92 +
    ----------
93 +
    tiles
94 +
        The 4 child tiles, some might be None.
95 +
    """
96 +
    if len(tiles) != 4:
97 +
        raise ValueError("Must have 4 values")
98 +
99 +
    if tiles[0] is None:
100 +
        raise ValueError("Position 0 cannot be None")
101 +
102 +
    # The layout of the children is:
103 +
    # 0 1
104 +
    # 2 3
105 +
    if _none(tiles[1:4]):
106 +
        # 0 X
107 +
        # X X
108 +
        return tiles[0]
109 +
    elif _none(tiles[2:4]):
110 +
        # 0 1
111 +
        # X X
112 +
        return np.hstack(tiles[0:2])
113 +
    elif _none((tiles[1], tiles[3])):
114 +
        # 0 X
115 +
        # 2 X
116 +
        return np.vstack((tiles[0], tiles[2]))
117 +
    else:
118 +
        # 0 1
119 +
        # 2 3
120 +
        row1 = np.hstack(tiles[0:2])
121 +
        row2 = np.hstack(tiles[2:4])
122 +
        return np.vstack((row1, row2))
123 +
124 +
125 +
def _create_downsampled_tile(*tiles: np.ndarray) -> np.ndarray:
73 126
    """Create one parent tile from four child tiles.
74 127
75 128
    Parameters
76 129
    ----------
77 -
    ul : np.ndarray
78 -
        Upper left child tile.
79 -
    ur : np.ndarray
80 -
        Upper right child tile.
81 -
    ll : np.ndarray
82 -
        Lower left child tile.
83 -
    lr : np.ndarray
84 -
        Lower right child tile.
130 +
    tiles
131 +
        The 4 child tiles, some could be None.
85 132
    """
86 -
    row1 = np.hstack((ul, ur))
87 -
    row2 = np.hstack((ll, lr))
88 -
    combined_tile = np.vstack((row1, row2))
133 +
    # Combine 1-4 tiles together.
134 +
    combined_tile = _combine_tiles(*tiles)
89 135
90 136
    # Down sample by half.
91 137
    return ndi.zoom(combined_tile, [0.5, 0.5, 1])
92 138
93 139
94 140
def _create_coarser_level(tiles: TileArray) -> TileArray:
95 -
    """Return the next coarser level of tiles.
141 +
    """Return a level that is one level coarser.
96 142
97 143
    Combine each 2x2 group of tiles into one downsampled tile.
98 144
145 +
    Parameters
146 +
    ----------
99 147
    tiles : TileArray
100 148
        The tiles to combine.
149 +
150 +
    Returns
151 +
    -------
152 +
    TileArray
153 +
        The coarser level of tiles.
101 154
    """
102 155
103 -
    new_tiles = []
156 +
    level = []
104 157
105 158
    for row in range(0, len(tiles), 2):
106 159
        row_tiles = []
107 160
        for col in range(0, len(tiles[row]), 2):
108 -
            tile = _create_downsampled_tile(
109 -
                tiles[row][col],
110 -
                tiles[row][col + 1],
111 -
                tiles[row + 1][col],
112 -
                tiles[row + 1][col + 1],
161 +
            # The layout of the children is:
162 +
            # 0 1
163 +
            # 2 3
164 +
            group = (
165 +
                _get_tile(tiles, row, col),
166 +
                _get_tile(tiles, row, col + 1),
167 +
                _get_tile(tiles, row + 1, col),
168 +
                _get_tile(tiles, row + 1, col + 1),
113 169
            )
170 +
            tile = _create_downsampled_tile(*group)
114 171
            row_tiles.append(tile)
115 -
        new_tiles.append(row_tiles)
172 +
        level.append(row_tiles)
173 +
174 +
    return level
175 +
116 176
117 -
    return new_tiles
177 +
class OctreeInfo:
178 +
    def __init__(self, base_shape, tile_size: int):
179 +
        self.base_shape = base_shape
180 +
        self.tile_size = tile_size
118 181
119 182
120 -
def _print_level_tiles(tiles):
121 -
    """Print information about these tiles.
183 +
class OctreeLevel:
184 +
    """One level of the octree.
185 +
186 +
    A level contains a 2D or 3D array of tiles.
122 187
    """
123 -
    num_rows = len(tiles)
124 188
125 -
    num_cols = None
126 -
    for row in tiles:
127 -
        if num_cols is None:
128 -
            num_cols = len(row)
129 -
        else:
130 -
            assert num_cols == len(row)
189 +
    def __init__(self, info: OctreeInfo, level_index: int, tiles: TileArray):
190 +
        self.info = info
191 +
        self.level_index = level_index
192 +
        self.tiles = tiles
131 193
132 -
    print(f"{num_rows} x {num_cols} = {num_rows * num_cols}")
194 +
        self.scale = 2 ** self.level_index
195 +
        self.num_rows = len(self.tiles)
196 +
        self.num_cols = len(self.tiles[0])
133 197
134 -
    for row in tiles:
135 -
        for tile in row:
136 -
            pass  # print(tile.shape)
198 +
    def print_info(self):
199 +
        """Print information about this level."""
200 +
        nrows = len(self.tiles)
201 +
        ncols = len(self.tiles[0])
202 +
        print(f"level={self.level_index} dim={nrows}x{ncols}")
137 203
204 +
    def draw_mini_map(self, row_range: range, col_range: range):
205 +
        """Draw mini-map with X's for which tiles we are drawing."""
138 206
139 -
def _create_node(levels: Levels, level_index: int, row: int = 0, col: int = 0):
140 -
    """Return an Octree node and its child nodes recursively.
207 +
        def _within(value, value_range):
208 +
            return value >= value_range.start and value < value_range.stop
141 209
142 -
    Parameters
143 -
    ----------
144 -
    levels : Levels
145 -
        The tiles in each level of the octree.
146 -
    level_index : int
147 -
        Create a node at this level.
148 -
    row : int
149 -
        Create a node at this ro.
150 -
    col : int
151 -
        Create a node at this col.
152 -
    """
210 +
        for row in range(0, self.num_rows):
211 +
            for col in range(0, self.num_cols):
212 +
                if _within(row, row_range) and _within(col, col_range):
213 +
                    marker = "X"
214 +
                else:
215 +
                    marker = "."
216 +
                print(marker, end='')
153 217
154 -
    if level_index < 0:
155 -
        return None
218 +
            print("")
219 +
220 +
    def get_chunks(self, data_corners) -> List[ChunkData]:
221 +
        """Return chunks that are within this rectangular region of the data.
222 +
223 +
        Parameters
224 +
        ----------
225 +
        data_corners
226 +
            Return chunks within this rectangular region.
227 +
        """
228 +
        chunks = []
156 229
157 -
    # print(f"Building level = {level_index}")
158 -
    level = levels[level_index]
159 -
    next_index = level_index - 1
230 +
        # TODO_OCTREE: generalize with data_corner indices we need to use.
231 +
        data_rows = [data_corners[0][1], data_corners[1][1]]
232 +
        data_cols = [data_corners[0][2], data_corners[1][2]]
160 233
161 -
    nrow = row * 2
162 -
    ncol = col * 2
234 +
        row_range = self.row_range(data_rows)
235 +
        col_range = self.column_range(data_cols)
163 236
164 -
    node = OctreeNode(row, col, level[row][col])
165 -
    node.children = [
166 -
        _create_node(levels, next_index, nrow, ncol),
167 -
        _create_node(levels, next_index, nrow, ncol + 1),
168 -
        _create_node(levels, next_index, nrow + 1, ncol),
169 -
        _create_node(levels, next_index, nrow + 1, ncol + 1),
170 -
    ]
237 +
        self.draw_mini_map(row_range, col_range)
171 238
172 -
    return node
239 +
        scale = self.scale
240 +
        scale_vec = [scale, scale]
173 241
242 +
        tile_size = self.info.tile_size
174 243
175 -
def _print_levels(levels: Levels):
176 -
    """Print information about the levels.
244 +
        # Iterate over every tile in the rectangular region.
245 +
        data = None
246 +
        y = row_range.start * tile_size
247 +
        for row in row_range:
248 +
            x = col_range.start * tile_size
249 +
            for col in col_range:
177 250
178 -
    Parameters
179 -
    ----------
180 -
    levels : Levels
181 -
        Print information about these levels.
182 -
    """
183 -
    print(f"{len(levels)} levels:")
184 -
    for level in levels:
185 -
        _print_level_tiles(level)
251 +
                data = self.tiles[row][col]
252 +
                pos = [x, y]
186 253
254 +
                if 0 not in data.shape:
255 +
                    chunks.append(ChunkData(data, pos, scale_vec))
187 256
188 -
def _print_tiles(node, level=0):
189 -
    assert node is not None
190 -
    assert node.tile is not None
191 -
    node.print_info(level)
192 -
    for child in node.children:
193 -
        if child is not None:
194 -
            _print_tiles(child, level + 1)
257 +
                x += data.shape[1] * scale
258 +
            y += data.shape[0] * scale
195 259
260 +
        return chunks
196 261
197 -
class OctreeNode:
198 -
    """Octree Node.
262 +
    def tile_range(self, span, num_tiles):
263 +
        """Return tiles indices needed to draw the span."""
199 264
200 -
    Child indexes
201 -
    -------------
202 -
    OCTREE_TODO: This order was picked arbitrarily. If there is another
203 -
    ordering which makes more sense, we should switch to it.
265 +
        def _clamp(val, min_val, max_val):
266 +
            return max(min(val, max_val), min_val)
204 267
205 -
    -Z [0..3]
206 -
    +Z [4..7]
268 +
        tile_size = self.info.tile_size
207 269
208 -
        -X X+
209 -
    -Y 0 1
210 -
    +Y 3 2
270 +
        tiles = [span[0] / tile_size, span[1] / tile_size]
271 +
        new_min = _clamp(tiles[0], 0, num_tiles - 1)
272 +
        new_max = _clamp(tiles[1], 0, num_tiles - 1)
273 +
        clamped = [new_min, new_max + 1]
211 274
212 -
        -X X+
213 -
    -Y 4 5
214 -
    +Y 7 6
275 +
        span_int = [int(x) for x in clamped]
276 +
        return range(*span_int)
215 277
216 -
    Parameters
217 -
    ----------
218 -
    row : int
219 -
        The row of this octree node in its level.
220 -
    col : int
221 -
        The col of this octree node in its level.
222 -
    data : np.ndarray
223 -
        The image data for this octree node.
224 -
    """
278 +
    def row_range(self, span):
279 +
        """Return row indices which span image coordinates [y0..y1]."""
280 +
        return self.tile_range(span, self.num_rows)
225 281
226 -
    def __init__(self, row: int, col: int, data: np.ndarray):
227 -
        assert data is not None
228 -
        assert row >= 0
229 -
        assert col >= 0
230 -
        self.row = row
231 -
        self.col = col
232 -
        self.data = data
233 -
        self.children = None
282 +
    def column_range(self, span):
283 +
        """Return column indices which span image coordinates [x0..x1]."""
284 +
        return self.tile_range(span, self.num_cols)
234 285
235 -
    def print_info(self, level):
236 -
        """Print information about this octree node.
237 286
238 -
        level : int
239 -
            The level of this node in the tree.
240 -
        """
241 -
        indent = "    " * level
242 -
        print(
243 -
            f"{indent}level={level} row={self.row:>3}, col={self.col:>3} "
244 -
            f"shape={self.tile.shape}"
245 -
        )
287 +
def _one_tile(tiles: TileArray) -> bool:
288 +
    return len(tiles) == 1 and len(tiles[0]) == 1
246 289
247 290
248 291
class Octree:
249 -
    """An octree.
292 +
    """A region octree that holds hold 2D or 3D images.
293 +
294 +
    Today the octree is full/complete meaning every node has 4 or 8
295 +
    children, and every leaf node is at the same level of the tree. This
296 +
    makes sense for region/image trees, because the image exists
297 +
    everywhere.
298 +
299 +
    Since we are a complete tree we don't need actual nodes with references
300 +
    to the node's children. Instead, every level is just an array, and
301 +
    going from parent to child or child to parent is trivial, you just
302 +
    need to double or half the indexes.
303 +
304 +
    Future Work: Geometry
305 +
    ---------------------
306 +
    Eventually we want our octree to hold geometry, not just images.
307 +
    Geometry such as points and meshes. For geometry a sparse octree might
308 +
    make more sense than this full/complete region octree.
309 +
310 +
    With geometry there might be lots of empty space in between small dense
311 +
    pockets of geometry. Some parts of tree might need to be very deep, but
312 +
    it would be a waste for the tree to be that deep everywhere.
250 313
251 314
    Parameters
252 315
    ----------
253 -
    root : OctreeNode
254 -
        The root of the tree.
316 +
    base_shape : Tuple[int, int]
317 +
        The shape of the full base image.
255 318
    levels : Levels
256 -
        All the levels of the tree
257 -
258 -
    TODO_OCTREE: Do we need/want to store self.levels?
319 +
        All the levels of the tree.
259 320
    """
260 321
261 -
    def __init__(self, root: OctreeNode, levels: Levels):
262 -
        self.root = root
263 -
        self.levels = levels
322 +
    def __init__(self, base_shape: Tuple[int, int], levels: Levels):
323 +
        self.base_shape = base_shape
324 +
        self.levels = [
325 +
            OctreeLevel(base_shape, i, level)
326 +
            for (i, level) in enumerate(levels)
327 +
        ]
264 328
        self.num_levels = len(self.levels)
265 329
266 -
    def print_tiles(self):
330 +
    def print_info(self):
267 331
        """Print information about our tiles."""
268 -
        _print_tiles(self.root)
269 -
270 -
    @classmethod
271 -
    def from_levels(cls, levels: Levels):
272 -
        """Create a tree from the given levels.
273 -
274 -
        Parameters
275 -
        ----------
276 -
        levels : Levels
277 -
            All the tiles to include in the tree.
278 -
        """
279 -
        root_level = len(levels) - 1
280 -
        root = _create_node(levels, root_level)
281 -
        return cls(root, levels)
332 +
        for level in self.levels:
333 +
            level.print_info()
282 334
283 335
    @classmethod
284 -
    def from_image(cls, image: np.ndarray):
336 +
    def from_image(cls, image: np.ndarray, tile_size: int):
285 337
        """Create octree from given single image.
286 338
287 339
        Parameters
@@ -289,18 +341,18 @@
Loading
289 341
        image : ndarray
290 342
            Create the octree for this single image.
291 343
        """
292 -
        TILE_SIZE = 64
293 -
        tiles = _create_tiles(image, TILE_SIZE)
344 +
        info = OctreeInfo(image.shape, tile_size)
345 +
        tiles = _create_tiles(image, info.tile_size)
294 346
        levels = [tiles]
295 347
296 348
        # Keep combining tiles until there is one root tile.
297 -
        while len(levels[-1]) > 1:
349 +
        while not _one_tile(levels[-1]):
298 350
            next_level = _create_coarser_level(levels[-1])
299 351
            levels.append(next_level)
300 352
301 353
        # _print_levels(levels)
302 354
303 -
        return Octree.from_levels(levels)
355 +
        return Octree(info, levels)
304 356
305 357
306 358
if __name__ == "__main__":
Files Coverage
napari 83.16%
Project Totals (356 files) 83.16%
Untitled
Untitled
Untitled
Untitled
1
ignore:
2
  - napari/_version.py
3
  - napari/resources
4
coverage:
5
  status:
6
    project:
7
      default:
8
        target: auto
9
        threshold: 0.5%  # coverage can drop by up to 0.5% while still posting success
10
    patch: off
11
comment:
12
  require_changes: true  # if true: only post the PR comment if coverage changes
Sunburst
The inner-most circle is the entire project, moving away from the center are folders then, finally, a single file. The size and color of each slice is representing the number of statements and the coverage, respectively.
Icicle
The top section represents the entire project. Proceeding with folders and finally individual files. The size and color of each slice is representing the number of statements and the coverage, respectively.
Grid
Each block represents a single file in the project. The size and color of each block is represented by the number of statements and the coverage, respectively.
Loading