diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index f8f78c52..39a2f988 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -158,3 +158,6 @@ - Fedor Morozov Drawing: Wu's anti-aliased circle + +- Michael Hansen + novice submodule diff --git a/skimage/color/colorlabel.py b/skimage/color/colorlabel.py index 8d7787aa..70803961 100644 --- a/skimage/color/colorlabel.py +++ b/skimage/color/colorlabel.py @@ -17,7 +17,8 @@ DEFAULT_COLORS = ('red', 'blue', 'yellow', 'magenta', 'green', 'indigo', 'darkorange', 'cyan', 'pink', 'yellowgreen') -color_dict = rgb_colors.__dict__ +color_dict = dict((k, v) for k, v in six.iteritems(rgb_colors.__dict__) + if isinstance(v, tuple)) def _rgb_vector(color): diff --git a/skimage/data/block.png b/skimage/data/block.png new file mode 100644 index 00000000..cd981597 Binary files /dev/null and b/skimage/data/block.png differ diff --git a/skimage/novice/__init__.py b/skimage/novice/__init__.py new file mode 100644 index 00000000..2c76c2b2 --- /dev/null +++ b/skimage/novice/__init__.py @@ -0,0 +1,75 @@ +""" +skimage.novice +============== +A special Python image submodule for beginners. + +Description +----------- +``skimage.novice`` provides a simple image manipulation interface for +beginners. It allows for easy loading, manipulating, and saving of image +files. + +This module is primarily intended for teaching and differs significantly from +the normal, array-oriented image functions used by scikit-image. + +.. note:: + + This module uses the Cartesian coordinate system, where the origin is at + the lower-left corner instead of the upper-right and the order is x, y + instead of row, column. + + +Example +------- + +We can create a Picture object open opening an image file +>>> from skimage import novice +>>> from skimage import data +>>> picture = novice.open(data.data_dir + '/chelsea.png') + +Pictures know their format +>>> print picture.format +png + +... and where they came from +>>> print picture.path.endswith('chelsea.png') +True + +... and their size +>>> print picture.size +(451, 300) +>>> print picture.width +451 + +Changing `size` resizes the picture. +>>> picture.size = (45, 30) + +You can iterate over pixels, which have RGB values between 0 and 255, +and know their location in the picture. +>>> for pixel in picture: +... if (pixel.red > 128) and (pixel.x < picture.width): +... pixel.red /= 2 + +Pictures know if they've been modified from the original file +>>> print picture.modified +True +>>> print picture.path +None + +Pictures can be indexed like arrays +>>> picture[0:20, 0:20] = (0, 0, 0) + +Saving the picture updates the path attribute, format, and modified state. +>>> picture.save('save-demo.jpg') +>>> print picture.path.endswith('save-demo.jpg') +True +>>> print picture.format +jpeg +>>> print picture.modified +False + +""" +from ._novice import Picture, open, colors, color_dict + + +__all__ = ['Picture', 'open', 'colors', 'color_dict'] diff --git a/skimage/novice/_novice.py b/skimage/novice/_novice.py new file mode 100644 index 00000000..2fc4b280 --- /dev/null +++ b/skimage/novice/_novice.py @@ -0,0 +1,424 @@ +import os +import imghdr +from collections import namedtuple + +import numpy as np +from skimage import io +from skimage import img_as_ubyte +from skimage.transform import resize +from skimage.color import color_dict +from skimage._shared import six + + +# Convert colors from `skimage.color` to uint8 and allow access through +# dict or a named tuple. +color_dict = dict((name, tuple(int(255 * c + 0.5) for c in rgb)) + for name, rgb in six.iteritems(color_dict)) +colors = namedtuple('colors', color_dict.keys())(**color_dict) + + +def open(path): + """Return Picture object from the given image path.""" + return Picture(path=os.path.abspath(path)) + + +def _verify_picture_index(index): + """Raise error if picture index is not a 2D index/slice.""" + if not (isinstance(index, tuple) and len(index) == 2): + raise IndexError("Expected 2D index but got {!r}".format(index)) + + if all(isinstance(i, int) for i in index): + return index + + # In case we need to fix the array index, convert tuple to list. + index = list(index) + + for i, dim_slice in enumerate(index): + # If either index is a slice, ensure index object returns 2D array. + if isinstance(dim_slice, int): + index[i] = dim_slice = slice(dim_slice, dim_slice + 1) + + return tuple(index) + + +def rgb_transpose(array): + """Return RGB array with first 2 axes transposed.""" + return np.transpose(array, (1, 0, 2)) + + +def array_to_xy_origin(image): + """Return view of image transformed from array to Cartesian origin.""" + return rgb_transpose(image[::-1]) + + +def xy_to_array_origin(image): + """Return view of image transformed from Cartesian to array origin.""" + return rgb_transpose(image[:, ::-1]) + + +class Pixel(object): + """A single pixel in a Picture. + + Attributes + ---------- + pic : Picture + The Picture object that this pixel references. + array : array_like + Byte array with raw image data (RGB). + x : int + Horizontal coordinate of this pixel (left = 0). + y : int + Vertical coordinate of this pixel (bottom = 0). + rgb : tuple + RGB tuple with red, green, and blue components (0-255) + + """ + def __init__(self, pic, array, x, y, rgb): + self._picture = pic + self._x = x + self._y = y + self._red = self._validate(rgb[0]) + self._green = self._validate(rgb[1]) + self._blue = self._validate(rgb[2]) + + @property + def x(self): + """Horizontal location of this pixel in the parent image(left = 0).""" + return self._x + + @property + def y(self): + """Vertical location of this pixel in the parent image (bottom = 0).""" + return self._y + + @property + def red(self): + """The red component of the pixel (0-255).""" + return self._red + + @red.setter + def red(self, value): + self._red = self._validate(value) + self._setpixel() + + @property + def green(self): + """The green component of the pixel (0-255).""" + return self._green + + @green.setter + def green(self, value): + self._green = self._validate(value) + self._setpixel() + + @property + def blue(self): + """The blue component of the pixel (0-255).""" + return self._blue + + @blue.setter + def blue(self, value): + self._blue = self._validate(value) + self._setpixel() + + @property + def rgb(self): + """The RGB color components of the pixel (3 values 0-255).""" + return (self.red, self.green, self.blue) + + @rgb.setter + def rgb(self, value): + self._red, self._green, self._blue = (self._validate(v) for v in value) + self._setpixel() + + def _validate(self, value): + """Verifies that the pixel value is in [0, 255].""" + try: + value = int(value) + if (value < 0) or (value > 255): + raise ValueError() + except ValueError: + msg = "Expected an integer between 0 and 255, but got {0} instead!" + raise ValueError(msg.format(value)) + + return value + + def _setpixel(self): + self._picture.xy_array[self._x, self._y] = self.rgb + self._picture._array_modified() + + def __eq__(self, other): + if isinstance(other, Pixel): + return self.rgb == other.rgb + + def __repr__(self): + args = self.red, self.green, self.blue + return "Pixel(red={0}, green={1}, blue={2})".format(*args) + + +class Picture(object): + """A 2-D picture made up of pixels. + + Attributes + ---------- + path : str + Path to an image file to load. + array : array + Raw RGB image data [0-255], with origin at top-left. + xy_array : array + Raw RGB image data [0-255], with origin at bottom-left. + + Examples + -------- + Load an image from a file + >>> from skimage import novice + >>> from skimage import data + >>> picture = novice.open(data.data_dir + '/chelsea.png') + + Create a blank 100 pixel wide, 200 pixel tall white image + >>> pic = Picture.from_size((100, 200), color=(255, 255, 255)) + + Use numpy to make an RGB byte array (shape is height x width x 3) + >>> import numpy as np + >>> data = np.zeros(shape=(200, 100, 3), dtype=np.uint8) + >>> data[:, :, 0] = 255 # Set red component to maximum + >>> pic = Picture(array=data) + + Get the bottom-left pixel + >>> pic[0, 0] + Pixel(red=255, green=0, blue=0) + + Get the top row of the picture + >>> pic[:, pic.height-1] + Picture(100 x 1) + + Set the bottom-left pixel to black + >>> pic[0, 0] = (0, 0, 0) + + Set the top row to red + >>> pic[:, pic.height-1] = (255, 0, 0) + + """ + def __init__(self, path=None, array=None, xy_array=None): + self._modified = False + self.scale = 1 + self._path = None + self._format = None + + n_args = len([a for a in [path, array, xy_array] if a is not None]) + if n_args != 1: + msg = "Must provide a single keyword arg (path, array, xy_array)." + ValueError(msg) + elif path is not None: + self.array = img_as_ubyte(io.imread(path)) + self._path = path + self._format = imghdr.what(path) + elif array is not None: + self.array = array + elif xy_array is not None: + self.xy_array = xy_array + + @staticmethod + def from_size(size, color='black'): + """Return a Picture of the specified size and a uniform color. + + Parameters + ---------- + size : tuple + Width and height of the picture in pixels. + color : tuple or str + RGB tuple with the fill color for the picture [0-255] or a valid + key in `color_dict`. + """ + if isinstance(color, six.string_types): + color = color_dict[color] + rgb_size = tuple(size) + (3,) + array = np.ones(rgb_size, dtype=np.uint8) * color + return Picture(array=array) + + @property + def array(self): + """Image data stored as numpy array.""" + return self._array + + @array.setter + def array(self, array): + self._array = array + self._xy_array = array_to_xy_origin(array) + + @property + def xy_array(self): + """Image data stored as numpy array with origin at the bottom-left.""" + return self._xy_array + + @xy_array.setter + def xy_array(self, array): + self._xy_array = array + self._array = xy_to_array_origin(array) + + def save(self, path): + """Saves the picture to the given path. + + Parameters + ---------- + path : str + Path (with file extension) where the picture is saved. + """ + io.imsave(path, self._rescale(self.array)) + self._modified = False + self._path = os.path.abspath(path) + self._format = imghdr.what(path) + + @property + def path(self): + """The path to the picture.""" + return self._path + + @property + def modified(self): + """True if the picture has changed.""" + return self._modified + + def _array_modified(self): + self._modified = True + self._path = None + + @property + def format(self): + """The image format of the picture.""" + return self._format + + @property + def size(self): + """The size (width, height) of the picture.""" + return self.xy_array.shape[:2] + + @size.setter + def size(self, value): + # Don't resize if no change in size + if (value[0] != self.width) or (value[1] != self.height): + # skimage dimensions are flipped: y, x + new_size = (int(value[1]), int(value[0])) + new_array = resize(self.array, new_size, order=0) + self.array = img_as_ubyte(new_array) + + self._array_modified() + + @property + def width(self): + """The width of the picture.""" + return self.size[0] + + @width.setter + def width(self, value): + self.size = (value, self.height) + + @property + def height(self): + """The height of the picture.""" + return self.size[1] + + @height.setter + def height(self, value): + self.size = (self.width, value) + + def _repr_png_(self): + return io.Image(self._rescale(self.array))._repr_png_() + + def show(self): + """Display the image.""" + io.imshow(self._rescale(self.array)) + io.show() + + def _makepixel(self, x, y): + """Create a Pixel object for a given x, y location.""" + rgb = self.xy_array[x, y] + return Pixel(self, self.array, x, y, rgb) + + def _rescale(self, array): + """Rescale image according to scale factor.""" + if self.scale == 1: + return array + new_size = (self.height * self.scale, self.width * self.scale) + return img_as_ubyte(resize(array, new_size, order=0)) + + def _get_channel(self, channel): + """Return a specific dimension out of the raw image data slice.""" + return self._array[:, :, channel] + + def _set_channel(self, channel, value): + """Set a specific dimension in the raw image data slice.""" + self._array[:, :, channel] = value + + @property + def red(self): + """The red component of the pixel (0-255).""" + return self._get_channel(0).ravel() + + @red.setter + def red(self, value): + self._set_channel(0, value) + + @property + def green(self): + """The green component of the pixel (0-255).""" + return self._get_channel(1).ravel() + + @green.setter + def green(self, value): + self._set_channel(1, value) + + @property + def blue(self): + """The blue component of the pixel (0-255).""" + return self._get_channel(2).ravel() + + @blue.setter + def blue(self, value): + self._set_channel(2, value) + + @property + def rgb(self): + """The RGB color components of the pixel (3 values 0-255).""" + return self.xy_array + + @rgb.setter + def rgb(self, value): + self.xy_array[:] = value + + def __iter__(self): + """Iterates over all pixels in the image.""" + for x in range(self.width): + for y in range(self.height): + yield self._makepixel(x, y) + + def __getitem__(self, xy_index): + """Return `Picture`s for slices and `Pixel`s for indexes.""" + xy_index = _verify_picture_index(xy_index) + if all(isinstance(index, int) for index in xy_index): + return self._makepixel(*xy_index) + else: + return Picture(xy_array=self.xy_array[xy_index]) + + def __setitem__(self, xy_index, value): + xy_index = _verify_picture_index(xy_index) + if isinstance(value, tuple): + self[xy_index].rgb = value + elif isinstance(value, Picture): + self.xy_array[xy_index] = value.xy_array + else: + raise TypeError("Invalid value type") + self._array_modified() + + def __eq__(self, other): + if not isinstance(other, Picture): + raise NotImplementedError() + return np.all(self.array == other.array) + + def __repr__(self): + return "Picture({0} x {1})".format(*self.size) + + +if __name__ == '__main__': + import doctest + doctest.testmod() diff --git a/skimage/novice/tests/test_novice.py b/skimage/novice/tests/test_novice.py new file mode 100644 index 00000000..80065e46 --- /dev/null +++ b/skimage/novice/tests/test_novice.py @@ -0,0 +1,272 @@ +import os +import tempfile + +import numpy as np +from numpy.testing import assert_equal, raises, assert_allclose +from skimage import novice +from skimage.novice._novice import (array_to_xy_origin, xy_to_array_origin, + rgb_transpose) +from skimage import data_dir + + +IMAGE_PATH = os.path.join(data_dir, "chelsea.png") +SMALL_IMAGE_PATH = os.path.join(data_dir, "block.png") + + +def _array_2d_to_RGB(array): + return np.tile(array[:, :, np.newaxis], (1, 1, 3)) + + +def test_xy_to_array_origin(): + h, w = 3, 5 + array = np.arange(h * w).reshape(h, w, 1) + out = xy_to_array_origin(array_to_xy_origin(array.copy())) + assert np.allclose(out, array) + + +def test_pic_info(): + pic = novice.open(IMAGE_PATH) + assert_equal(pic.format, "png") + assert_equal(pic.path, os.path.abspath(IMAGE_PATH)) + assert_equal(pic.size, (451, 300)) + assert_equal(pic.width, 451) + assert_equal(pic.height, 300) + assert not pic.modified + assert_equal(pic.scale, 1) + + +def test_pixel_iteration(): + pic = novice.open(SMALL_IMAGE_PATH) + num_pixels = sum(1 for p in pic) + assert_equal(num_pixels, pic.width * pic.height) + + +def test_modify(): + pic = novice.open(SMALL_IMAGE_PATH) + assert_equal(pic.modified, False) + + for p in pic: + if p.x < (pic.width / 2): + p.red /= 2 + p.green /= 2 + p.blue /= 2 + + for p in pic: + if p.x < (pic.width / 2): + assert p.red <= 128 + assert p.green <= 128 + assert p.blue <= 128 + + s = pic.size + pic.size = (pic.width / 2, pic.height / 2) + assert_equal(pic.size, (int(s[0] / 2), int(s[1] / 2))) + + assert pic.modified + assert pic.path is None + + +def test_pixel_rgb(): + pic = novice.Picture.from_size((3, 3), color=(10, 10, 10)) + pixel = pic[0, 0] + pixel.rgb = np.arange(3) + + assert_equal(pixel.rgb, np.arange(3)) + for i, channel in enumerate((pixel.red, pixel.green, pixel.blue)): + assert_equal(channel, i) + + pixel.red = 3 + pixel.green = 4 + pixel.blue = 5 + assert_equal(pixel.rgb, np.arange(3) + 3) + + for i, channel in enumerate((pixel.red, pixel.green, pixel.blue)): + assert_equal(channel, i + 3) + + +def test_pixel_rgb_float(): + pixel = novice.Picture.from_size((1, 1))[0, 0] + pixel.rgb = (1.1, 1.1, 1.1) + assert_equal(pixel.rgb, (1, 1, 1)) + + +def test_modified_on_set(): + pic = novice.Picture(SMALL_IMAGE_PATH) + pic[0, 0] = (1, 1, 1) + assert pic.modified + assert pic.path is None + + +def test_modified_on_set_pixel(): + data = np.zeros(shape=(10, 5, 3), dtype=np.uint8) + pic = novice.Picture(array=data) + + pixel = pic[0, 0] + pixel.green = 1 + assert pic.modified + + +def test_update_on_save(): + pic = novice.Picture(array=np.zeros((3, 3, 3))) + pic.size = (6, 6) + assert pic.modified + assert pic.path is None + + with tempfile.NamedTemporaryFile(suffix=".jpg") as tmp: + pic.save(tmp.name) + + assert not pic.modified + assert_equal(pic.path, os.path.abspath(tmp.name)) + assert_equal(pic.format, "jpeg") + + +def test_indexing(): + array = 128 * np.ones((10, 10, 3), dtype=np.uint8) + pic = novice.Picture(array=array) + + pic[0:5, 0:5] = (0, 0, 0) + for p in pic: + if (p.x < 5) and (p.y < 5): + assert_equal(p.rgb, (0, 0, 0)) + assert_equal(p.red, 0) + assert_equal(p.green, 0) + assert_equal(p.blue, 0) + + pic[:5, :5] = (255, 255, 255) + for p in pic: + if (p.x < 5) and (p.y < 5): + assert_equal(p.rgb, (255, 255, 255)) + assert_equal(p.red, 255) + assert_equal(p.green, 255) + assert_equal(p.blue, 255) + + pic[5:pic.width, 5:pic.height] = (255, 0, 255) + for p in pic: + if (p.x >= 5) and (p.y >= 5): + assert_equal(p.rgb, (255, 0, 255)) + assert_equal(p.red, 255) + assert_equal(p.green, 0) + assert_equal(p.blue, 255) + + pic[5:, 5:] = (0, 0, 255) + for p in pic: + if (p.x >= 5) and (p.y >= 5): + assert_equal(p.rgb, (0, 0, 255)) + assert_equal(p.red, 0) + assert_equal(p.green, 0) + assert_equal(p.blue, 255) + + +def test_picture_slice(): + array = _array_2d_to_RGB(np.arange(0, 10)[np.newaxis, :]) + pic = novice.Picture(array=array) + + x_slice = slice(3, 8) + subpic = pic[:, x_slice] + assert_allclose(subpic.array, array[x_slice, :]) + + +def test_move_slice(): + h, w = 3, 12 + array = _array_2d_to_RGB(np.linspace(0, 255, h * w).reshape(h, w)) + array = array.astype(np.uint8) + + pic = novice.Picture(array=array) + pic_orig = novice.Picture(array=array.copy()) + + # Move left cut of image to the right side. + cut = 5 + rest = pic.width - cut + temp = pic[:cut, :] + temp.array = temp.array.copy() + pic[:rest, :] = pic[cut:, :] + pic[rest:, :] = temp + + assert pic[rest:, :] == pic_orig[:cut, :] + assert pic[:rest, :] == pic_orig[cut:, :] + + +def test_negative_index(): + n = 10 + array = _array_2d_to_RGB(np.arange(0, n)[np.newaxis, :]) + # Test both x and y indices. + pic = novice.Picture(array=array) + assert pic[-1, 0] == pic[n - 1, 0] + pic = novice.Picture(array=rgb_transpose(array)) + assert pic[0, -1] == pic[0, n - 1] + + +def test_negative_slice(): + n = 10 + array = _array_2d_to_RGB(np.arange(0, n)[np.newaxis, :]) + # Test both x and y slices. + pic = novice.Picture(array=array) + assert pic[-3:, 0] == pic[n - 3:, 0] + pic = novice.Picture(array=rgb_transpose(array)) + assert pic[0, -3:] == pic[0, n - 3:] + + +def test_getitem_with_step(): + h, w = 5, 5 + array = _array_2d_to_RGB(np.linspace(0, 255, h * w).reshape(h, w)) + pic = novice.Picture(array=array) + sliced_pic = pic[::2, ::2] + assert sliced_pic == novice.Picture(array=array[::2, ::2]) + + +@raises(IndexError) +def test_1d_getitem_raises(): + pic = novice.Picture.from_size((1, 1)) + pic[1] + + +@raises(IndexError) +def test_3d_getitem_raises(): + pic = novice.Picture.from_size((1, 1)) + pic[1, 2, 3] + + +@raises(IndexError) +def test_1d_setitem_raises(): + pic = novice.Picture.from_size((1, 1)) + pic[1] = 0 + + +@raises(IndexError) +def test_3d_setitem_raises(): + pic = novice.Picture.from_size((1, 1)) + pic[1, 2, 3] = 0 + + +@raises(IndexError) +def test_out_of_bounds_indexing(): + pic = novice.open(SMALL_IMAGE_PATH) + pic[pic.width, pic.height] + + +@raises(ValueError) +def test_pixel_rgb_raises(): + pixel = novice.Picture.from_size((1, 1))[0, 0] + pixel.rgb = (-1, -1, -1) + + +@raises(ValueError) +def test_pixel_red_raises(): + pixel = novice.Picture.from_size((1, 1))[0, 0] + pixel.red = 256 + + +@raises(ValueError) +def test_pixel_green_raises(): + pixel = novice.Picture.from_size((1, 1))[0, 0] + pixel.green = 256 + + +@raises(ValueError) +def test_pixel_blue_raises(): + pixel = novice.Picture.from_size((1, 1))[0, 0] + pixel.blue = 256 + + +if __name__ == '__main__': + from numpy import testing + testing.run_module_suite()