diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 6903e72c..ecbbbd90 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -10,8 +10,8 @@ - Mahipal Raythattha Documentation infrastructure -- Chris Colbert - OpenCV wrappers +- S. Chris Colbert + OpenCV wrappers, Scivi, Qt and Gtk gui bits. - Holger Rapp OpenCV functions and better OSX library loader diff --git a/scikits/image/io/_plugins/_colormixer.pyx b/scikits/image/io/_plugins/_colormixer.pyx new file mode 100644 index 00000000..2aa2161b --- /dev/null +++ b/scikits/image/io/_plugins/_colormixer.pyx @@ -0,0 +1,550 @@ +# -*- python -*- + +"""Colour Mixer + +NumPy does not do overflow checking when adding or multiplying +integers, so currently the only way to clip results efficiently +(without making copies of the data) is with an extension such as this +one. + +""" + +import numpy as np +cimport numpy as np + +import cython + +cdef extern from "math.h": + float exp(float) nogil + float pow(float, float) nogil + + +@cython.boundscheck(False) +def add(np.ndarray[np.uint8_t, ndim=3] img, + np.ndarray[np.uint8_t, ndim=3] stateimg, + int channel, int amount): + """Add a given amount to a colour channel of `stateimg`, and + store the result in `img`. Overflow is clipped. + + Parameters + ---------- + img : (M, N, 3) ndarray of uint8 + Output image. + stateimg : (M, N, 3) ndarray of uint8 + Input image. + channel : int + Channel (0 for "red", 1 for "green", 2 for "blue"). + amount : int + Value to add. + + """ + cdef int height = img.shape[0] + cdef int width = img.shape[1] + cdef int k = channel + cdef int n = amount + + cdef np.int16_t op_result + + cdef int i, j + with nogil: + for i from 0 <= i < height: + for j from 0 <= j < width: + op_result = (stateimg[i,j,k] + n) + if op_result > 255: + img[i, j, k] = 255 + elif op_result < 0: + img[i, j, k] = 0 + else: + img[i, j, k] = op_result + + +@cython.boundscheck(False) +def multiply(np.ndarray[np.uint8_t, ndim=3] img, + np.ndarray[np.uint8_t, ndim=3] stateimg, + int channel, float amount): + """Multiply a colour channel of `stateimg` by a certain amount, and + store the result in `img`. Overflow is clipped. + + Parameters + ---------- + img : (M, N, 3) ndarray of uint8 + Output image. + stateimg : (M, N, 3) ndarray of uint8 + Input image. + channel : int + Channel (0 for "red", 1 for "green", 2 for "blue"). + amount : float + Multiplication factor. + + """ + cdef int height = img.shape[0] + cdef int width = img.shape[1] + cdef int k = channel + cdef float n = amount + + cdef float op_result + + cdef int i, j + with nogil: + for i from 0 <= i < height: + for j from 0 <= j < width: + op_result = (stateimg[i,j,k] * n) + if op_result > 255: + img[i, j, k] = 255 + elif op_result < 0: + img[i, j, k] = 0 + else: + img[i, j, k] = op_result + + +@cython.boundscheck(False) +def brightness(np.ndarray[np.uint8_t, ndim=3] img, + np.ndarray[np.uint8_t, ndim=3] stateimg, + float factor, int offset): + """Modify the brightness of an image. + 'factor' is multiplied to all channels, which are + then added by 'amount'. Overflow is clipped. + + Parameters + ---------- + img : (M, N, 3) ndarray of uint8 + Output image. + stateimg : (M, N, 3) ndarray of uint8 + Input image. + factor : float + Multiplication factor. + offset : int + Ammount to add to each channel. + + """ + + cdef int height = img.shape[0] + cdef int width = img.shape[1] + + cdef float op_result + + cdef int i, j, k + with nogil: + for i from 0 <= i < height: + for j from 0 <= j < width: + for k from 0 <= k < 3: + op_result = ((stateimg[i,j,k] * factor + offset)) + + if op_result > 255: + img[i, j, k] = 255 + elif op_result < 0: + img[i, j, k] = 0 + else: + img[i, j, k] = op_result + + +@cython.boundscheck(False) +@cython.cdivision(True) +def sigmoid_gamma(np.ndarray[np.uint8_t, ndim=3] img, + np.ndarray[np.uint8_t, ndim=3] stateimg, + float alpha, float beta): + + cdef int height = img.shape[0] + cdef int width = img.shape[1] + + cdef float c1, c2, r, g, b + cdef int i, j, k + + with nogil: + for i from 0 <= i < height: + for j from 0 <= j < width: + r = stateimg[i,j,0] / 255. + g = stateimg[i,j,1] / 255. + b = stateimg[i,j,2] / 255. + + c1 = 1 / (1 + exp(beta)) + c2 = 1 / (1 + exp(beta - alpha)) - c1 + + r = 1 / (1 + exp(beta - r * alpha)) + r = (r - c1) / c2 + + g = 1 / (1 + exp(beta - g * alpha)) + g = (g - c1) / c2 + + b = 1 / (1 + exp(beta - b * alpha)) + b = (b - c1) / c2 + + img[i,j,0] = (r * 255) + img[i,j,1] = (g * 255) + img[i,j,2] = (b * 255) + + +@cython.boundscheck(False) +def gamma(np.ndarray[np.uint8_t, ndim=3] img, + np.ndarray[np.uint8_t, ndim=3] stateimg, + float gamma): + + cdef int height = img.shape[0] + cdef int width = img.shape[1] + + cdef float r, g, b + + cdef int i, j + + if gamma == 0: + gamma = 0.00000000000000000001 + gamma = 1./gamma + + with nogil: + for i from 0 <= i < height: + for j from 0 <= j < width: + r = stateimg[i,j,0] / 255. + g = stateimg[i,j,1] / 255. + b = stateimg[i,j,2] / 255. + + img[i,j,0] = (pow(r, gamma) * 255) + img[i,j,1] = (pow(g, gamma) * 255) + img[i,j,2] = (pow(b, gamma) * 255) + + +@cython.cdivision(True) +cdef void rgb_2_hsv(float* RGB, float* HSV) nogil: + cdef float R, G, B, H, S, V, MAX, MIN + R = RGB[0] + G = RGB[1] + B = RGB[2] + + if R > 255: + R = 255 + elif R < 0: + R = 0 + else: + pass + + if G > 255: + G = 255 + elif G < 0: + G = 0 + else: + pass + + if B > 255: + B = 255 + elif B < 0: + B = 0 + else: + pass + + if R < G: + MIN = R + MAX = G + else: + MIN = G + MAX = R + + if B < MIN: + MIN = B + elif B > MAX: + MAX = B + else: + pass + + V = MAX / 255. + + if MAX == MIN: + H = 0. + elif MAX == R: + H = (60 * (G - B) / (MAX - MIN) + 360) % 360 + elif MAX == G: + H = 60 * (B - R) / (MAX - MIN) + 120 + else: + H = 60 * (R - G) / (MAX - MIN) + 240 + + if MAX == 0: + S = 0 + else: + S = 1 - MIN / MAX + + HSV[0] = H + HSV[1] = S + HSV[2] = V + +@cython.cdivision(True) +cdef void hsv_2_rgb(float* HSV, float* RGB) nogil: + cdef float H, S, V + cdef float f, p, q, t, r, g, b + cdef int hi + + H = HSV[0] + S = HSV[1] + V = HSV[2] + + if H > 360: + H = H % 360 + elif H < 0: + H = 360 - ((-1 * H) % 360) + else: + pass + + if S > 1: + S = 1 + elif S < 0: + S = 0 + else: + pass + + if V > 1: + V = 1 + elif V < 0: + V = 0 + else: + pass + + hi = ((H / 60.)) % 6 + f = (H / 60.) - ((H / 60.)) + p = V * (1 - S) + q = V * (1 - f * S) + t = V * (1 - (1 -f) * S) + + if hi == 0: + r = V + g = t + b = p + elif hi == 1: + r = q + g = V + b = p + elif hi == 2: + r = p + g = V + b = t + elif hi == 3: + r = p + g = q + b = V + elif hi == 4: + r = t + g = p + b = V + else: + r = V + g = p + b = q + + RGB[0] = r + RGB[1] = g + RGB[2] = b + + +def py_hsv_2_rgb(H, S, V): + '''Convert an HSV value to RGB. + + Automatic clipping. + + Parameters + ---------- + H : float + From 0. - 360. + S : float + From 0. - 1. + V : float + From 0. - 1. + + Returns + ------- + out : (R, G, B) ints + Each from 0 - 255 + + conversion convention from here: + http://en.wikipedia.org/wiki/HSL_and_HSV + + ''' + cdef float HSV[3] + cdef float RGB[3] + + HSV[0] = H + HSV[1] = S + HSV[2] = V + + hsv_2_rgb(HSV, RGB) + + R = int(RGB[0] * 255) + G = int(RGB[1] * 255) + B = int(RGB[2] * 255) + + return (R, G, B) + +def py_rgb_2_hsv(R, G, B): + '''Convert an HSV value to RGB. + + Automatic clipping. + + Parameters + ---------- + R : int + From 0. - 255. + G : int + From 0. - 255. + B : int + From 0. - 255. + + Returns + ------- + out : (H, S, V) floats + Ranges (0...360), (0...1), (0...1) + + conversion convention from here: + http://en.wikipedia.org/wiki/HSL_and_HSV + + ''' + cdef float HSV[3] + cdef float RGB[3] + + RGB[0] = R + RGB[1] = G + RGB[2] = B + + rgb_2_hsv(RGB, HSV) + + H = HSV[0] + S = HSV[1] + V = HSV[2] + + return (H, S, V) + + +@cython.boundscheck(False) +def hsv_add(np.ndarray[np.uint8_t, ndim=3] img, + np.ndarray[np.uint8_t, ndim=3] stateimg, + float h_amt, float s_amt, float v_amt): + """Modify the image color by specifying additive HSV Values. + + Since the underlying images are RGB, all three values HSV + must be specified at the same time. + + The RGB triplet in the image is converted to HSV, the operation + is applied, and then the HSV triplet is converted back to RGB + + HSV values are scaled to H(0. - 360.), S(0. - 1.), V(0. - 1.) + then the operation is performed and any overflow is clipped, then the + reverse transform is performed. Those are the ranges to keep in mind, + when passing in values. + + Parameters + ---------- + img : (M, N, 3) ndarray of uint8 + Output image. + stateimg : (M, N, 3) ndarray of uint8 + Input image. + h_amt : float + Ammount to add to H channel. + s_amt : float + Ammount to add to S channel. + v_amt : float + Ammount to add to V channel. + + + """ + + cdef int height = img.shape[0] + cdef int width = img.shape[1] + + cdef float HSV[3] + cdef float RGB[3] + + cdef int i, j + + with nogil: + for i from 0 <= i < height: + for j from 0 <= j < width: + RGB[0] = stateimg[i, j, 0] + RGB[1] = stateimg[i, j, 1] + RGB[2] = stateimg[i, j, 2] + + rgb_2_hsv(RGB, HSV) + + # Add operation + HSV[0] += h_amt + HSV[1] += s_amt + HSV[2] += v_amt + + hsv_2_rgb(HSV, RGB) + + RGB[0] *= 255 + RGB[1] *= 255 + RGB[2] *= 255 + + img[i, j, 0] = RGB[0] + img[i, j, 1] = RGB[1] + img[i, j, 2] = RGB[2] + + +@cython.boundscheck(False) +def hsv_multiply(np.ndarray[np.uint8_t, ndim=3] img, + np.ndarray[np.uint8_t, ndim=3] stateimg, + float h_amt, float s_amt, float v_amt): + """Modify the image color by specifying multiplicative HSV Values. + + Since the underlying images are RGB, all three values HSV + must be specified at the same time. + + The RGB triplet in the image is converted to HSV, the operation + is applied, and then the HSV triplet is converted back to RGB + + HSV values are scaled to H(0. - 360.), S(0. - 1.), V(0. - 1.) + then the operation is performed and any overflow is clipped, then the + reverse transform is performed. Those are the ranges to keep in mind, + when passing in values. + + Note that since hue is in degrees, it makes no sense to multiply + that channel, thus an add operation is performed on the hue. And the + values given for h_amt, should be the same as for hsv_add + + Parameters + ---------- + img : (M, N, 3) ndarray of uint8 + Output image. + stateimg : (M, N, 3) ndarray of uint8 + Input image. + h_amt : float + Ammount to add to H channel. + s_amt : float + Ammount by which to multiply S channel. + v_amt : float + Ammount by which to multiply V channel. + + + """ + + cdef int height = img.shape[0] + cdef int width = img.shape[1] + + cdef float HSV[3] + cdef float RGB[3] + + cdef int i, j + + with nogil: + for i from 0 <= i < height: + for j from 0 <= j < width: + RGB[0] = stateimg[i, j, 0] + RGB[1] = stateimg[i, j, 1] + RGB[2] = stateimg[i, j, 2] + + rgb_2_hsv(RGB, HSV) + + # Multiply operation + HSV[0] += h_amt + HSV[1] *= s_amt + HSV[2] *= v_amt + + hsv_2_rgb(HSV, RGB) + + RGB[0] *= 255 + RGB[1] *= 255 + RGB[2] *= 255 + + img[i, j, 0] = RGB[0] + img[i, j, 1] = RGB[1] + img[i, j, 2] = RGB[2] + + + + + + diff --git a/scikits/image/io/_plugins/_histograms.pyx b/scikits/image/io/_plugins/_histograms.pyx new file mode 100644 index 00000000..41ec79a1 --- /dev/null +++ b/scikits/image/io/_plugins/_histograms.pyx @@ -0,0 +1,83 @@ +import numpy as np +cimport numpy as np + +import cython + + +cdef inline float tri_max(float a, float b, float c): + cdef float MAX + + if a > b: + MAX = a + else: + MAX = b + + if MAX > c: + return MAX + else: + return c + + +@cython.boundscheck(False) +def histograms(np.ndarray[np.uint8_t, ndim=3] img, int nbins): + '''Calculate the channel histograms of the current image. + + Parameters + ---------- + img : ndarray, uint8, ndim=3 + The image to calculate the histogram. + nbins : int + The number of bins. + + Returns + ------- + out : (rcounts, gcounts, bcounts, vcounts) + The binned histograms of the RGB channels and grayscale intensity. + + This is a NAIVE histogram routine, meant primarily for fast display. + + ''' + cdef int width = img.shape[1] + cdef int height = img.shape[0] + cdef np.ndarray[np.int32_t, ndim=1] r + cdef np.ndarray[np.int32_t, ndim=1] g + cdef np.ndarray[np.int32_t, ndim=1] b + cdef np.ndarray[np.int32_t, ndim=1] v + + r = np.zeros((nbins,), dtype=np.int32) + g = np.zeros((nbins,), dtype=np.int32) + b = np.zeros((nbins,), dtype=np.int32) + v = np.zeros((nbins,), dtype=np.int32) + + cdef int i, j, k, rbin, gbin, bbin, vbin + cdef float bin_width = 255./ nbins + cdef float R, G, B, V + + for i in range(height): + for j in range(width): + R = img[i, j, 0] + G = img[i, j, 1] + B = img[i, j, 2] + V = tri_max(R, G, B) + + rbin = (R / bin_width) + gbin = (G / bin_width) + bbin = (B / bin_width) + vbin = (V / bin_width) + + # fully open last bin + if R == 255: + rbin -= 1 + if G == 255: + gbin -= 1 + if B == 255: + bbin -= 1 + if V == 255: + vbin -= 1 + + r[rbin] += 1 + g[gbin] += 1 + b[bbin] += 1 + v[vbin] += 1 + + return (r, g, b, v) diff --git a/scikits/image/io/_plugins/matplotlib_plugin.ini b/scikits/image/io/_plugins/matplotlib_plugin.ini index 84dc2e53..39d78fbb 100644 --- a/scikits/image/io/_plugins/matplotlib_plugin.ini +++ b/scikits/image/io/_plugins/matplotlib_plugin.ini @@ -1,4 +1,4 @@ [matplotlib] description = Display or save images using Matplotlib -provides = imshow, imsave +provides = imshow, _app_show diff --git a/scikits/image/io/_plugins/matplotlib_plugin.py b/scikits/image/io/_plugins/matplotlib_plugin.py index 349bbe8d..7fb70da2 100644 --- a/scikits/image/io/_plugins/matplotlib_plugin.py +++ b/scikits/image/io/_plugins/matplotlib_plugin.py @@ -1,4 +1,5 @@ -try: - from matplotlib.pyplot import imshow, imsave -except ImportError: - print "Could not import Matplotlib." +from matplotlib.pyplot import imshow, show + +def _app_show(): + show() + diff --git a/scikits/image/io/_plugins/pil_plugin.ini b/scikits/image/io/_plugins/pil_plugin.ini index be54e541..7afa244f 100644 --- a/scikits/image/io/_plugins/pil_plugin.ini +++ b/scikits/image/io/_plugins/pil_plugin.ini @@ -1,4 +1,4 @@ [pil] description = Image reading via the Python Imaging Library -provides = imread +provides = imread, imsave diff --git a/scikits/image/io/_plugins/pil_plugin.py b/scikits/image/io/_plugins/pil_plugin.py index e50cbe6c..2aa3e9d4 100644 --- a/scikits/image/io/_plugins/pil_plugin.py +++ b/scikits/image/io/_plugins/pil_plugin.py @@ -6,44 +6,90 @@ import numpy as np try: from PIL import Image except ImportError: - print 'Could not load Python Imaging Library' -else: - def imread(fname, as_grey=False, dtype=None): - """Load an image from file. + raise ImportError("The Python Image Library could not be found. " + "Please refer to http://pypi.python.org/pypi/PIL/ " + "for further instructions.") - """ - im = Image.open(fname) - if im.mode == 'P': - if palette_is_grayscale(im): - im = im.convert('L') - else: - im = im.convert('RGB') +def imread(fname, as_grey=False, dtype=None): + """Load an image from file. - if as_grey and not \ - im.mode in ('1', 'L', 'I', 'F', 'I;16', 'I;16L', 'I;16B'): - im = im.convert('F') + """ + im = Image.open(fname) + if im.mode == 'P': + if _palette_is_grayscale(im): + im = im.convert('L') + else: + im = im.convert('RGB') - return np.array(im, dtype=dtype) + if as_grey and not \ + im.mode in ('1', 'L', 'I', 'F', 'I;16', 'I;16L', 'I;16B'): + im = im.convert('F') - def palette_is_grayscale(pil_image): - """Return True if PIL image in palette mode is grayscale. + return np.array(im, dtype=dtype) - Parameters - ---------- - pil_image : PIL image - PIL Image that is in Palette mode. +def _palette_is_grayscale(pil_image): + """Return True if PIL image in palette mode is grayscale. - Returns - ------- - is_grayscale : bool - True if all colors in image palette are gray. - """ - assert pil_image.mode == 'P' - # get palette as an array with R, G, B columns - palette = np.asarray(pil_image.getpalette()).reshape((256, 3)) - # Not all palette colors are used; unused colors have junk values. - start, stop = pil_image.getextrema() - valid_palette = palette[start:stop] - # Image is grayscale if channel differences (R - G and G - B) - # are all zero. - return np.allclose(np.diff(valid_palette), 0) + Parameters + ---------- + pil_image : PIL image + PIL Image that is in Palette mode. + + Returns + ------- + is_grayscale : bool + True if all colors in image palette are gray. + """ + assert pil_image.mode == 'P' + # get palette as an array with R, G, B columns + palette = np.asarray(pil_image.getpalette()).reshape((256, 3)) + # Not all palette colors are used; unused colors have junk values. + start, stop = pil_image.getextrema() + valid_palette = palette[start:stop] + # Image is grayscale if channel differences (R - G and G - B) + # are all zero. + return np.allclose(np.diff(valid_palette), 0) + +def imsave(fname, arr): + """Save an image to disk. + + Parameters + ---------- + fname : str + Name of destination file. + arr : ndarray of uint8 or float + Array (image) to save. Arrays of data-type uint8 should have + values in [0, 255], whereas floating-point arrays must be + in [0, 1]. + + Notes + ----- + Currently, only 8-bit precision is supported. + + """ + arr = np.asarray(arr).squeeze() + + if arr.ndim not in (2, 3): + raise ValueError("Invalid shape for image array: %s" % arr.shape) + + if arr.ndim == 3: + if arr.shape[2] not in (3, 4): + raise ValueError("Invalid number of channels in image array.") + + # Image is floating point, assume in [0, 1] + if np.issubdtype(arr.dtype, float): + arr = arr * 255 + + arr = arr.astype(np.uint8) + + if arr.ndim == 2: + mode = 'L' + + elif arr.shape[2] in (3, 4): + mode = {3: 'RGB', 4: 'RGBA'}[arr.shape[2]] + + # Force all integers to bytes + arr = arr.astype(np.uint8) + + img = Image.fromstring(mode, (arr.shape[1], arr.shape[0]), arr.tostring()) + img.save(fname) diff --git a/scikits/image/io/_plugins/q_color_mixer.py b/scikits/image/io/_plugins/q_color_mixer.py new file mode 100644 index 00000000..4c37cc4f --- /dev/null +++ b/scikits/image/io/_plugins/q_color_mixer.py @@ -0,0 +1,348 @@ +# the module for the qt color_mixer plugin +from PyQt4 import QtGui, QtCore +from PyQt4.QtGui import (QWidget, QStackedWidget, QSlider, QVBoxLayout, + QGridLayout, QLabel) + +from util import ColorMixer + + +class IntelligentSlider(QWidget): + ''' A slider that adds a 'name' attribute and calls a callback + with 'name' as an argument to the registerd callback. + + This allows you to create large groups of sliders in a loop, + but still keep track of the individual events + + It also prints a label below the slider. + + The range of the slider is hardcoded from zero - 1000, + but it supports a conversion factor so you can scale the results''' + + def __init__(self, name, a, b, callback): + QWidget.__init__(self) + self.name = name + self.callback = callback + self.a = a + self.b = b + self.manually_triggered = False + + self.slider = QSlider() + self.slider.setRange(0, 1000) + self.slider.setValue(500) + self.slider.valueChanged.connect(self.slider_changed) + + self.name_label = QLabel() + self.name_label.setText(self.name) + self.name_label.setAlignment(QtCore.Qt.AlignCenter) + + self.value_label = QLabel() + self.value_label.setText('%2.2f' % (self.slider.value() * self.a + self.b)) + self.value_label.setAlignment(QtCore.Qt.AlignCenter) + + self.layout = QGridLayout(self) + self.layout.addWidget(self.name_label, 0, 0) + self.layout.addWidget(self.slider, 1, 0, QtCore.Qt.AlignHCenter) + self.layout.addWidget(self.value_label, 2, 0) + + # bind this to the valueChanged signal of the slider + def slider_changed(self, val): + val = self.val() + self.value_label.setText(str(val)[:4]) + + if not self.manually_triggered: + self.callback(self.name, val) + + def set_conv_fac(self, a, b): + self.a = a + self.b = b + + def set_value(self, val): + self.manually_triggered = True + self.slider.setValue(int((val - self.b) / self.a)) + self.value_label.setText('%2.2f' % val) + self.manually_triggered = False + + def val(self): + return self.slider.value() * self.a + self.b + + +class MixerPanel(QWidget): + '''A color mixer to hook up to an image. + You pass the image you the panel to operate on + and it operates on that image in place. You also + pass a callback to be called to trigger a refresh. + This callback is called every time the mixer modifies + your image.''' + def __init__(self, img): + QWidget.__init__(self) + + self.img = img + self.mixer = ColorMixer(self.img) + self.callback = None + + #--------------------------------------------------------------- + # ComboBox + #--------------------------------------------------------------- + + self.combo_box_entries = ['RGB Color', 'HSV Color', + 'Brightness/Contrast', + 'Gamma', + 'Gamma (Sigmoidal)'] + self.combo_box = QtGui.QComboBox() + for entry in self.combo_box_entries: + self.combo_box.addItem(entry) + self.combo_box.currentIndexChanged.connect(self.combo_box_changed) + + #--------------------------------------------------------------- + # RGB color sliders + #--------------------------------------------------------------- + + # radio buttons + self.rgb_add = QtGui.QRadioButton('Additive') + self.rgb_mul = QtGui.QRadioButton('Multiplicative') + self.rgb_mul.toggled.connect(self.rgb_radio_changed) + self.rgb_add.toggled.connect(self.rgb_radio_changed) + + # sliders + rs = IntelligentSlider('R', 0.51, -255, self.rgb_changed) + gs = IntelligentSlider('G', 0.51, -255, self.rgb_changed) + bs = IntelligentSlider('B', 0.51, -255, self.rgb_changed) + self.rs = rs + self.gs = gs + self.bs = bs + + self.rgb_widget = QWidget() + self.rgb_widget.layout = QGridLayout(self.rgb_widget) + self.rgb_widget.layout.addWidget(self.rgb_add, 0, 0, 1, 3) + self.rgb_widget.layout.addWidget(self.rgb_mul, 1, 0, 1, 3) + self.rgb_widget.layout.addWidget(self.rs, 2, 0) + self.rgb_widget.layout.addWidget(self.gs, 2, 1) + self.rgb_widget.layout.addWidget(self.bs, 2, 2) + + + #--------------------------------------------------------------- + # HSV sliders + #--------------------------------------------------------------- + + # radio buttons + self.hsv_add = QtGui.QRadioButton('Additive') + self.hsv_mul = QtGui.QRadioButton('Multiplicative') + self.hsv_mul.toggled.connect(self.hsv_radio_changed) + self.hsv_mul.toggled.connect(self.hsv_radio_changed) + + # sliders + hs = IntelligentSlider('H', 0.36, -180, self.hsv_changed) + ss = IntelligentSlider('S', 0.002, 0, self.hsv_changed) + vs = IntelligentSlider('V', 0.002, 0, self.hsv_changed) + self.hs = hs + self.ss = ss + self.vs = vs + + self.hsv_widget = QWidget() + self.hsv_widget.layout = QGridLayout(self.hsv_widget) + self.hsv_widget.layout.addWidget(self.hsv_add, 0, 0, 1, 3) + self.hsv_widget.layout.addWidget(self.hsv_mul, 1, 0, 1, 3) + self.hsv_widget.layout.addWidget(self.hs, 2, 0) + self.hsv_widget.layout.addWidget(self.ss, 2, 1) + self.hsv_widget.layout.addWidget(self.vs, 2, 2) + + + #--------------------------------------------------------------- + # Brightness/Contrast sliders + #--------------------------------------------------------------- + + # sliders + cont = IntelligentSlider('x', 0.002, 0, self.bright_changed) + bright = IntelligentSlider('+', 0.51, -255, self.bright_changed) + self.cont = cont + self.bright = bright + + # layout + self.bright_widget = QWidget() + self.bright_widget.layout = QtGui.QGridLayout(self.bright_widget) + self.bright_widget.layout.addWidget(self.cont, 0, 0) + self.bright_widget.layout.addWidget(self.bright, 0, 1) + + + #----------------------------------------------------------------------- + # Gamma Slider + #----------------------------------------------------------------------- + gamma = IntelligentSlider('gamma', 0.005, 0, self.gamma_changed) + self.gamma = gamma + + # layout + self.gamma_widget = QWidget() + self.gamma_widget.layout = QtGui.QGridLayout(self.gamma_widget) + self.gamma_widget.layout.addWidget(self.gamma, 0, 0) + + + #--------------------------------------------------------------- + # Sigmoid Gamma sliders + #--------------------------------------------------------------- + + # sliders + alpha = IntelligentSlider('alpha', 0.011, 1, self.sig_gamma_changed) + beta = IntelligentSlider('beta', 0.012, 0, self.sig_gamma_changed) + self.a_gamma = alpha + self.b_gamma = beta + + # layout + self.sig_gamma_widget = QWidget() + self.sig_gamma_widget.layout = QtGui.QGridLayout(self.sig_gamma_widget) + self.sig_gamma_widget.layout.addWidget(self.a_gamma, 0, 0) + self.sig_gamma_widget.layout.addWidget(self.b_gamma, 0, 1) + + #--------------------------------------------------------------- + # Buttons + #--------------------------------------------------------------- + self.commit_button = QtGui.QPushButton('Commit') + self.commit_button.clicked.connect(self.commit_changes) + self.revert_button = QtGui.QPushButton('Revert') + self.revert_button.clicked.connect(self.revert_changes) + + #--------------------------------------------------------------- + # Mixer Layout + #--------------------------------------------------------------- + self.sliders = QStackedWidget() + self.sliders.addWidget(self.rgb_widget) + self.sliders.addWidget(self.hsv_widget) + self.sliders.addWidget(self.bright_widget) + self.sliders.addWidget(self.gamma_widget) + self.sliders.addWidget(self.sig_gamma_widget) + + self.layout = QtGui.QGridLayout(self) + self.layout.addWidget(self.combo_box, 0, 0) + self.layout.addWidget(self.sliders, 1, 0) + self.layout.addWidget(self.commit_button, 2, 0) + self.layout.addWidget(self.revert_button, 3, 0) + + #--------------------------------------------------------------- + # State Initialization + #--------------------------------------------------------------- + + self.combo_box.setCurrentIndex(0) + self.rgb_mul.setChecked(True) + self.hsv_mul.setChecked(True) + + + def set_callback(self, callback): + self.callback = callback + + def combo_box_changed(self, index): + self.sliders.setCurrentIndex(index) + self.reset() + + def rgb_radio_changed(self): + self.reset() + + def hsv_radio_changed(self): + self.reset() + + def reset(self): + self.reset_sliders() + self.mixer.set_to_stateimg() + if self.callback: + self.callback() + + def reset_sliders(self): + # handle changing the conversion factors necessary + if self.rgb_add.isChecked(): + self.rs.set_conv_fac(0.51, -255) + self.rs.set_value(0) + self.gs.set_conv_fac(0.51, -255) + self.gs.set_value(0) + self.bs.set_conv_fac(0.51, -255) + self.bs.set_value(0) + else: + self.rs.set_conv_fac(0.002, 0) + self.rs.set_value(1.) + self.gs.set_conv_fac(0.002, 0) + self.gs.set_value(1.) + self.bs.set_conv_fac(0.002, 0) + self.bs.set_value(1.) + + self.hs.set_value(0) + if self.hsv_add.isChecked(): + self.ss.set_conv_fac(0.002, -1) + self.ss.set_value(0) + self.vs.set_conv_fac(0.002, -1) + self.vs.set_value(0) + else: + self.ss.set_conv_fac(0.002, 0) + self.ss.set_value(1.) + self.vs.set_conv_fac(0.002, 0) + self.vs.set_value(1.) + + self.bright.set_value(0) + self.cont.set_value(1.) + + self.gamma.set_value(1) + self.a_gamma.set_value(1) + self.b_gamma.set_value(0.5) + + + def rgb_changed(self, name, val): + if name == 'R': + channel = self.mixer.RED + elif name == 'G': + channel = self.mixer.GREEN + else: + channel = self.mixer.BLUE + + if self.rgb_mul.isChecked(): + self.mixer.multiply(channel, val) + elif self.rgb_add.isChecked(): + self.mixer.add(channel, val) + else: + pass + + if self.callback: + self.callback() + + def hsv_changed(self, name, val): + h = self.hs.val() + s = self.ss.val() + v = self.vs.val() + + if self.hsv_mul.isChecked(): + self.mixer.hsv_multiply(h, s, v) + elif self.hsv_add.isChecked(): + self.mixer.hsv_add(h, s, v) + else: + pass + + if self.callback: + self.callback() + + def bright_changed(self, name, val): + b = self.bright.val() + c = self.cont.val() + self.mixer.brightness(c, b) + + if self.callback: + self.callback() + + def gamma_changed(self, name, val): + self.mixer.gamma(val) + + if self.callback: + self.callback() + + def sig_gamma_changed(self, name, val): + ag = self.a_gamma.val() + bg = self.b_gamma.val() + self.mixer.sigmoid_gamma(ag, bg) + + if self.callback: + self.callback() + + def commit_changes(self): + self.mixer.commit_changes() + self.reset_sliders() + + def revert_changes(self): + self.mixer.revert() + self.reset_sliders() + + if self.callback: + self.callback() \ No newline at end of file diff --git a/scikits/image/io/_plugins/q_histogram.py b/scikits/image/io/_plugins/q_histogram.py new file mode 100644 index 00000000..cfdc889f --- /dev/null +++ b/scikits/image/io/_plugins/q_histogram.py @@ -0,0 +1,140 @@ +import numpy as np + +from PyQt4.QtGui import QWidget, QPainter, QGridLayout, QColor + +from util import histograms + + +class ColorHistogram(QWidget): + '''A Class which draws a scaling histogram in + a widget. + + Where counts are the bin values in the histogram + and colormap is a tuple of (R, G, B) tuples the same length + as counts. These are the colors to apply to the histogram bars. + Colormap can also contain a single tuple (R, G, B), in which case this is + the color applied to all bars of that histogram. + + The histogram assumes the bins were evenly spaced. + ''' + + def __init__(self, counts, colormap): + QWidget.__init__(self) + self._validate_input(counts, colormap) + self.counts = counts + self.n = np.sum(self.counts) + self.colormap = colormap + self.setMinimumSize(100, 50) + + def _validate_input(self, counts, colormap): + if len(counts) != len(colormap): + if len(colormap) != 3: + msg = '''Colormap must be a list of 3-tuples the same + length as counts or a 3-tuple''' + raise ValueError(msg) + + def paintEvent(self, evt): + # get the widget dimensions + orig_width = self.width() + orig_height = self.height() + + # fill perc % of the widget + perc = 1 + width = int(orig_width * perc) + height = int(orig_height * perc) + + # get the starting origin + x_orig = int((orig_width - width) / 2) + # we want to start at the bottom and draw up. + y_orig = orig_height - int((orig_height - height) / 2) + + # a running x-position + running_pos = x_orig + + # calculate to number of bars + nbars = len(self.counts) + + # calculate the bar widths, this compilcation is + # necessary because integer trunction severly cripples + # the layout. + remainder = width % nbars + bar_width = [int(width / nbars)] * nbars + for i in range(remainder): + bar_width[i]+=1 + + paint = QPainter() + paint.begin(self) + + # determine the scaling factor + max_val = np.max(self.counts) + scale = 1. * height / max_val + + # determine if we have a colormap and drop into the appopriate + # loop. + if hasattr(self.colormap[0], '__iter__'): + # assume we have a colormap + for i in range(len(self.counts)): + bar_height = self.counts[i] + r, g, b = self.colormap[i] + paint.setPen(QColor(r, g, b)) + paint.setBrush(QColor(r, g, b)) + paint.drawRect(running_pos, y_orig, bar_width[i], + -bar_height) + running_pos += bar_width[i] + + else: + # we have a tuple + r, g, b = self.colormap + paint.setPen(QColor(r, g, b)) + paint.setBrush(QColor(r, g, b)) + for i in range(len(self.counts)): + bar_height = self.counts[i] * scale + paint.drawRect(running_pos, y_orig, bar_width[i], + -bar_height) + running_pos += bar_width[i] + + paint.end() + + + def update_hist(self, counts, cmap): + self._validate_input(counts, cmap) + self.counts = counts + self.colormap = cmap + self.repaint() + + +class QuadHistogram(QWidget): + '''A class which uses ColorHistogram to draw + the 4 histograms of an image. R, G, B, and Value. + + The 4 histograms are layout out in a grid, + and can be specified horizontal or vertical, + and in which order ie. ['R', 'G', 'B', 'V'] + ''' + + def __init__(self, img, layout='vertical', order=['R', 'G', 'B', 'V']): + QWidget.__init__(self) + r, g, b, v = histograms(img, 100) + self.r_hist = ColorHistogram(r, (255, 0, 0)) + self.g_hist = ColorHistogram(g, (0, 255, 0)) + self.b_hist = ColorHistogram(b, (0, 0, 255)) + self.v_hist = ColorHistogram(v, (0, 0, 0)) + + self.layout = QGridLayout(self) + + order_map = {'R': self.r_hist, 'G': self.g_hist, 'B': self.b_hist, + 'V': self.v_hist} + + if layout=='vertical': + for i in range(len(order)): + self.layout.addWidget(order_map[order[i]], i, 0) + elif layout=='horizontal': + for i in range(len(order)): + self.layout.addWidget(order_map[order[i]], 0, i) + + def update_hists(self, img): + r, g, b, v = histograms(img, 100) + self.r_hist.update_hist(r, (255, 0, 0)) + self.g_hist.update_hist(g, (0, 255, 0)) + self.b_hist.update_hist(b, (0, 0, 255)) + self.v_hist.update_hist(v, (0, 0, 0)) \ No newline at end of file diff --git a/scikits/image/io/_plugins/qt_plugin.ini b/scikits/image/io/_plugins/qt_plugin.ini index ae6cfbd4..9e98ba81 100644 --- a/scikits/image/io/_plugins/qt_plugin.ini +++ b/scikits/image/io/_plugins/qt_plugin.ini @@ -1,4 +1,4 @@ [qt] description = Fast image display using the Qt library -provides = imshow, _app_show +provides = imshow, _app_show, imsave diff --git a/scikits/image/io/_plugins/qt_plugin.py b/scikits/image/io/_plugins/qt_plugin.py index 032d6409..b58e2c38 100644 --- a/scikits/image/io/_plugins/qt_plugin.py +++ b/scikits/image/io/_plugins/qt_plugin.py @@ -1,64 +1,116 @@ from util import prepare_for_display, window_manager, GuiLockError - import numpy as np import sys +# We try to aquire the gui lock first or else the gui import might +# trample another GUI's PyOS_InputHook. +window_manager.acquire('qt') + try: - # We try to aquire the gui lock first or else the gui import might - # trample another GUI's PyOS_InputHook. - window_manager.acquire('qt') + from PyQt4.QtGui import (QApplication, QMainWindow, QImage, QPixmap, + QLabel, QWidget) + from PyQt4 import QtCore, QtGui -except GuiLockError, gle: - print gle +except ImportError: + window_manager._release('qt') -else: - try: - from PyQt4.QtGui import (QApplication, QMainWindow, QImage, QPixmap, - QLabel) + raise ImportError("""\ + PyQt4 libraries not installed. Please refer to - except ImportError: - print 'PyQT4 libraries not installed. Plugin not loaded.' - window_manager._release('qt') + http://www.riverbankcomputing.co.uk/software/pyqt/intro + for more information. PyQt4 is GPL licensed. For an + LGPL equivalent, see + + http://www.pyside.org + """) + +app = None + +class ImageLabel(QLabel): + def __init__(self, parent, arr): + QLabel.__init__(self) + + # we need to hold a reference to + # arr because QImage doesn't copy the data + # and the buffer must be alive as long + # as the image is alive. + self.arr = arr + + # we also need to pass in the row-stride to + # the constructor, because we can't guarantee + # that every row of the numpy data is + # 4-byte aligned. Which Qt would require + # if we didnt pass the stride. + self.img = QImage(arr.data, arr.shape[1], arr.shape[0], + arr.strides[0], QImage.Format_RGB888) + self.pm = QPixmap.fromImage(self.img) + self.setPixmap(self.pm) + self.setAlignment(QtCore.Qt.AlignTop) + self.setMinimumSize(100, 100) + + def resizeEvent(self, evt): + width = self.width() + pm = QPixmap.fromImage(self.img) + self.pm = pm.scaledToWidth(width) + self.setPixmap(self.pm) + + +class ImageWindow(QMainWindow): + def __init__(self, arr, mgr): + QMainWindow.__init__(self) + self.setWindowTitle('scikits.image') + self.mgr = mgr + self.main_widget = QWidget() + self.layout = QtGui.QGridLayout(self.main_widget) + self.setCentralWidget(self.main_widget) + + self.label = ImageLabel(self, arr) + self.layout.addWidget(self.label, 0, 0) + self.layout.addLayout + self.mgr.add_window(self) + self.main_widget.show() + + def closeEvent(self, event): + # Allow window to be destroyed by removing any + # references to it + self.mgr.remove_window(self) + + +def imshow(arr, fancy=False): + global app + if not app: + app = QApplication([]) + + arr = prepare_for_display(arr) + + if not fancy: + iw = ImageWindow(arr, window_manager) else: + from scivi import SciviImageWindow + iw = SciviImageWindow(arr, window_manager) - app = None + iw.show() - class ImageWindow(QMainWindow): - def __init__(self, arr, mgr): - QMainWindow.__init__(self) - self.mgr = mgr - img = QImage(arr.data, arr.shape[1], arr.shape[0], - QImage.Format_RGB888) - pm = QPixmap.fromImage(img) - label = QLabel() - label.setPixmap(pm) - label.show() +def _app_show(): + global app + if app and window_manager.has_windows(): + app.exec_() + else: + print 'No images to show. See `imshow`.' - self.label = label - self.setCentralWidget(self.label) - self.mgr.add_window(self) - def closeEvent(self, event): - # Allow window to be destroyed by removing any - # references to it - self.mgr.remove_window(self) - - def imshow(arr): - global app - - if not app: - app = QApplication([]) - - arr = prepare_for_display(arr) - - iw = ImageWindow(arr, window_manager) - iw.show() - - def _app_show(): - global app - if app and window_manager.has_windows(): - app.exec_() - else: - print 'No images to show. See `imshow`.' +def imsave(filename, img): + # we can add support for other than 3D uint8 here... + img = prepare_for_display(img) + qimg = QImage(img.data, img.shape[1], img.shape[0], + img.strides[0], QImage.Format_RGB888) + saved = qimg.save(filename) + if not saved: + from textwrap import dedent + msg = dedent( + '''The image was not saved. Allowable file formats + for the QT imsave plugin are: + BMP, JPG, JPEG, PNG, PPM, TIFF, XBM, XPM''') + raise RuntimeError(msg) diff --git a/scikits/image/io/_plugins/scivi.py b/scikits/image/io/_plugins/scivi.py new file mode 100644 index 00000000..c6197717 --- /dev/null +++ b/scikits/image/io/_plugins/scivi.py @@ -0,0 +1,233 @@ +''' +Scivi is written/maintained/developed by: + +S. Chris Colbert - sccolbert@gmail.com + +Scivi is free software and is part of the scikits.image project. + +Scivi is governed by the licenses of the scikits.image project. + +Please report any bugs to the author. + +The scivi module is not meant to be used directly. + +Use scikits.image.io.imshow(img, fancy=True)''' + +from textwrap import dedent +import numpy as np +import sys + +from PyQt4 import QtCore, QtGui +from PyQt4.QtGui import (QApplication, QMainWindow, QImage, QPixmap, + QLabel, QWidget, QVBoxLayout, QSlider, + QPainter, QColor, QFrame, QLayoutItem) + +from q_color_mixer import MixerPanel +from q_histogram import QuadHistogram + + +class ImageLabel(QLabel): + def __init__(self, parent, arr): + QLabel.__init__(self) + self.parent = parent + + # we need to hold a reference to + # arr because QImage doesn't copy the data + # and the buffer must be alive as long + # as the image is alive. + self.arr = arr + + # we also need to pass in the row-stride to + # the constructor, because we can't guarantee + # that every row of the numpy data is + # 4-byte aligned. Which Qt would require + # if we didnt pass the stride. + self.img = QImage(arr.data, arr.shape[1], arr.shape[0], + arr.strides[0], QImage.Format_RGB888) + self.pm = QPixmap.fromImage(self.img) + self.setPixmap(self.pm) + self.setAlignment(QtCore.Qt.AlignTop) + self.setMinimumSize(100, 100) + self.setMouseTracking(True) + + def mouseMoveEvent(self, evt): + self.parent.label_mouseMoveEvent(evt) + + def resizeEvent(self, evt): + width = self.width() + pm = QPixmap.fromImage(self.img) + self.pm = pm.scaledToWidth(width) + self.setPixmap(self.pm) + + def update_image(self): + width = self.width() + pm = QPixmap.fromImage(self.img) + pm = pm.scaledToWidth(width) + self.setPixmap(pm) + + +class RGBHSVDisplay(QWidget): + def __init__(self): + QWidget.__init__(self) + self.posx_label = QLabel('X-pos:') + self.posx_value = QLabel() + self.posy_label = QLabel('Y-pos:') + self.posy_value = QLabel() + self.r_label = QLabel('R:') + self.r_value = QLabel() + self.g_label = QLabel('G:') + self.g_value = QLabel() + self.b_label = QLabel('B:') + self.b_value = QLabel() + self.h_label = QLabel('H:') + self.h_value = QLabel() + self.s_label = QLabel('S:') + self.s_value = QLabel() + self.v_label = QLabel('V:') + self.v_value = QLabel() + + self.layout = QtGui.QGridLayout(self) + self.layout.addWidget(self.posx_label, 0, 0) + self.layout.addWidget(self.posx_value, 0, 1) + self.layout.addWidget(self.posy_label, 1, 0) + self.layout.addWidget(self.posy_value, 1, 1) + self.layout.addWidget(self.r_label, 0, 2) + self.layout.addWidget(self.r_value, 0, 3) + self.layout.addWidget(self.g_label, 1, 2) + self.layout.addWidget(self.g_value, 1, 3) + self.layout.addWidget(self.b_label, 2, 2) + self.layout.addWidget(self.b_value, 2, 3) + self.layout.addWidget(self.h_label, 0, 4) + self.layout.addWidget(self.h_value, 0, 5) + self.layout.addWidget(self.s_label, 1, 4) + self.layout.addWidget(self.s_value, 1, 5) + self.layout.addWidget(self.v_label, 2, 4) + self.layout.addWidget(self.v_value, 2, 5) + + def update_vals(self, data): + xpos, ypos, r, g, b, h, s, v = data + self.posx_value.setText(str(xpos)[:5]) + self.posy_value.setText(str(ypos)[:5]) + self.r_value.setText(str(r)[:5]) + self.g_value.setText(str(g)[:5]) + self.b_value.setText(str(b)[:5]) + self.h_value.setText(str(h)[:5]) + self.s_value.setText(str(s)[:5]) + self.v_value.setText(str(v)[:5]) + + + +class SciviImageWindow(QMainWindow): + def __init__(self, arr, mgr): + QMainWindow.__init__(self) + + self.arr = arr + + self.mgr = mgr + self.main_widget = QWidget() + self.layout = QtGui.QGridLayout(self.main_widget) + self.setCentralWidget(self.main_widget) + + self.label = ImageLabel(self, arr) + self.layout.addWidget(self.label, 0, 0) + self.layout.addLayout + self.mgr.add_window(self) + self.main_widget.show() + + self.setWindowTitle('Scivi - The scikits.image viewer.') + + self.mixer_panel = MixerPanel(self.arr) + self.layout.addWidget(self.mixer_panel, 0, 2) + self.mixer_panel.show() + self.mixer_panel.set_callback(self.refresh_image) + + self.rgbv_hist = QuadHistogram(self.arr) + self.layout.addWidget(self.rgbv_hist, 0, 1) + self.rgbv_hist.show() + + self.rgb_hsv_disp = RGBHSVDisplay() + self.layout.addWidget(self.rgb_hsv_disp, 1, 0) + self.rgb_hsv_disp.show() + + self.layout.setColumnStretch(0, 1) + self.layout.setRowStretch(0, 1) + + self.save_file = QtGui.QPushButton('Save to File') + self.save_file.clicked.connect(self.save_to_file) + self.save_stack = QtGui.QPushButton('Save to Stack') + self.save_stack.clicked.connect(self.save_to_stack) + self.save_file.show() + self.save_stack.show() + + self.layout.addWidget(self.save_stack, 1, 1) + self.layout.addWidget(self.save_file, 1, 2) + + + def closeEvent(self, event): + # Allow window to be destroyed by removing any + # references to it + self.mgr.remove_window(self) + + def update_histograms(self): + self.rgbv_hist.update_hists(self.arr) + + def refresh_image(self): + self.label.update_image() + self.update_histograms() + + def scale_mouse_pos(self, x, y): + width = self.label.pm.width() + height = self.label.pm.height() + x_frac = 1. * x / width + y_frac = 1. * y / height + width = self.arr.shape[1] + height = self.arr.shape[0] + new_x = int(width * x_frac) + new_y = int(height * y_frac) + return(new_x, new_y) + + def label_mouseMoveEvent(self, evt): + x = evt.x() + y = evt.y() + x, y = self.scale_mouse_pos(x, y) + + # handle tracking out of array bounds + maxw = self.arr.shape[1] + maxh = self.arr.shape[0] + if x >= maxw or y >= maxh or x < 0 or y < 0: + r = g = b = h = s = v = '' + else: + r = self.arr[y,x,0] + g = self.arr[y,x,1] + b = self.arr[y,x,2] + h, s, v = self.mixer_panel.mixer.rgb_2_hsv_pixel(r, g, b) + + self.rgb_hsv_disp.update_vals((x, y, r, g, b, h, s, v)) + + + def save_to_stack(self): + from scikits.image import io + img = self.arr.copy() + io.push(img) + msg = dedent(''' + The image has been pushed to the io stack. + Use io.pop() to retrieve the most recently + pushed image.''') + msglabel = QLabel(msg) + dialog = QtGui.QDialog() + ok = QtGui.QPushButton('OK', dialog) + ok.clicked.connect(dialog.accept) + ok.setDefault(True) + dialog.layout = QtGui.QGridLayout(dialog) + dialog.layout.addWidget(msglabel, 0, 0, 1, 3) + dialog.layout.addWidget(ok, 1, 1) + dialog.exec_() + + def save_to_file(self): + from scikits.image import io + filename = str(QtGui.QFileDialog.getSaveFileName()) + if len(filename) == 0: + return + io.imsave(filename, self.arr) + + diff --git a/scikits/image/io/_plugins/util.py b/scikits/image/io/_plugins/util.py index 6dd4f8e7..a9936bdc 100644 --- a/scikits/image/io/_plugins/util.py +++ b/scikits/image/io/_plugins/util.py @@ -1,7 +1,15 @@ import numpy as np +import _colormixer +import _histograms +import threading # utilities to make life easier for plugin writers. +try: + import multiprocessing + CPU_COUNT = multiprocessing.cpu_count() +except ImportError: + CPU_COUNT = 2 class GuiLockError(Exception): def __init__(self, msg): @@ -149,3 +157,287 @@ def prepare_for_display(npy_img): raise ValueError('Image must have 2 or 3 dimensions') return out + + +def histograms(img, nbins): + '''Calculate the channel histograms of the current image. + + Parameters + ---------- + img : ndarray, ndim=3, dtype=np.uint8 + nbins : int + The number of bins. + + Returns + ------- + out : (rcounts, gcounts, bcounts, vcounts) + The binned histograms of the RGB channels and intensity values. + + This is a NAIVE histogram routine, meant primarily for fast display. + + ''' + + return _histograms.histograms(img, nbins) + + +class ImgThread(threading.Thread): + def __init__(self, func, *args): + super(ImgThread, self).__init__() + self.func = func + self.args = args + + def run(self): + self.func(*self.args) + +class ThreadDispatch(object): + def __init__(self, img, stateimg, func, *args): + + width = img.shape[1] + height = img.shape[0] + self.cores = CPU_COUNT + self.threads = [] + self.chunks = [] + + if self.cores == 1: + self.chunks.append((img, stateimg)) + + elif self.cores >= 4: + self.chunks.append((img[:(height/4), :, :], + stateimg[:(height/4), :, :])) + self.chunks.append((img[(height/4):(height/2), :, :], + stateimg[(height/4):(height/2), :, :])) + self.chunks.append((img[(height/2):(3*height/4), :, :], + stateimg[(height/2):(3*height/4), :, :])) + self.chunks.append((img[(3*height/4):, :, :], + stateimg[(3*height/4):, :, :])) + + # if they dont have 1, or 4 or more, 2 is good. + else: + self.chunks.append((img[:(height/2), :, :], + stateimg[:(height/2), :, :])) + self.chunks.append((img[(height/2):, :, :], + stateimg[(height/2):, :, :])) + + for i in range(self.cores): + self.threads.append(ImgThread(func, self.chunks[i][0], + self.chunks[i][1], *args)) + + def run(self): + for t in self.threads: + t.start() + for t in self.threads: + t.join() + + + +class ColorMixer(object): + ''' a class to manage mixing colors in an image. + The input array must be an RGB uint8 image. + + The mixer maintains an original copy of the image, + and uses this copy to query the pixel data for operations. + It also makes a copy for sharing state across operations. + That is, if you add to a channel, and multiply to same channel, + the two operations are carried separately and the results + averaged together. + + it modifies your array in place. This ensures that if you + bust over a threshold, you can always come back down. + + The passed values to a function are always considered + absolute. Thus to threshold a channel completely you + can do mixer.add(RED, 255). Or to double the intensity + of the blue channel: mixer.multiply(BLUE, 2.) + + To reverse these operations, respectively: + mixer.add(RED, 0), mixer.multiply(BLUE, 1.) + + The majority of the backend is implemented in Cython, + so it should be quite quick. + ''' + + RED = 0 + GREEN = 1 + BLUE = 2 + + valid_channels = [RED, GREEN, BLUE] + + def __init__(self, img): + if type(img) != np.ndarray: + raise ValueError('Image must be a numpy array') + if img.dtype != np.uint8: + raise ValueError('Image must have dtype uint8') + if img.ndim != 3 or img.shape[2] != 3: + raise ValueError('Image must be 3 channel MxNx3') + + self.img = img + self.origimg = img.copy() + self.stateimg = img.copy() + + def get_stateimage(self): + return self.stateimg + + def commit_changes(self): + self.stateimg[:] = self.img[:] + + def revert(self): + self.stateimg[:] = self.origimg[:] + self.img[:] = self.stateimg[:] + + def set_to_stateimg(self): + self.img[:] = self.stateimg[:] + + def add(self, channel, ammount): + '''Add the specified ammount to the specified channel. + + Parameters + ---------- + channel : flag + the color channel to operate on + RED, GREED, or BLUE + ammount : integer + the ammount of color to add to the channel, + can be positive or negative. + + ''' + assert channel in self.valid_channels + pool = ThreadDispatch(self.img, self.stateimg, + _colormixer.add, channel, ammount) + pool.run() + + + + def multiply(self, channel, ammount): + '''Mutliply the indicated channel by the specified value. + + Parameters + ---------- + channel : flag + the color channel to operate on + RED, GREED, or BLUE + ammount : integer + the ammount of color to add to the channel, + can be positive or negative. + + ''' + assert channel in self.valid_channels + pool = ThreadDispatch(self.img, self.stateimg, + _colormixer.multiply, channel, ammount) + pool.run() + + + def brightness(self, factor, offset): + '''Adjust the brightness off an image with an offset and factor. + + Parameters + ---------- + offset : integer + The ammount to add to each channel. + factor : float + The factor to multiply each channel by. + + result = clip((pixel + offset)*factor) + + ''' + pool = ThreadDispatch(self.img, self.stateimg, + _colormixer.brightness, factor, offset) + pool.run() + + + def sigmoid_gamma(self, alpha, beta): + pool = ThreadDispatch(self.img, self.stateimg, + _colormixer.sigmoid_gamma, alpha, beta) + pool.run() + + + def gamma(self, gamma): + pool = ThreadDispatch(self.img, self.stateimg, + _colormixer.gamma, gamma) + pool.run() + + def hsv_add(self, h_amt, s_amt, v_amt): + '''Adjust the H, S, V channels of an image by a constant ammount. + This is similar to the add() mixer function, but operates over the + entire image at once. Thus all three additive values, H, S, V, must + be supplied simultaneously. + + Parameters + ---------- + h_amt : float + The ammount to add to the hue (-180..180) + s_amt : float + The ammount to add to the saturation (-1..1) + v_amt : float + The ammount to add to the value (-1..1) + + ''' + pool = ThreadDispatch(self.img, self.stateimg, + _colormixer.hsv_add, h_amt, s_amt, v_amt) + pool.run() + + def hsv_multiply(self, h_amt, s_amt, v_amt): + '''Adjust the H, S, V channels of an image by a constant ammount. + This is similar to the add() mixer function, but operates over the + entire image at once. Thus all three additive values, H, S, V, must + be supplied simultaneously. + + Note that since hue is in degrees, it makes no sense to multiply + that channel, thus an add operation is performed on the hue. And the + values given for h_amt, should be the same as for hsv_add + + Parameters + ---------- + h_amt : float + The ammount to to add to the hue (-180..180) + s_amt : float + The ammount to multiply to the saturation (0..1) + v_amt : float + The ammount to multiply to the value (0..1) + + ''' + pool = ThreadDispatch(self.img, self.stateimg, + _colormixer.hsv_multiply, h_amt, s_amt, v_amt) + pool.run() + + def rgb_2_hsv_pixel(self, R, G, B): + '''Convert an RGB value to HSV + + Parameters + ---------- + R : int + Red value + G : int + Green value + B : int + Blue value + + Returns + ------- + out : (H, S, V) Floats + The HSV values + + ''' + H, S, V = _colormixer.py_rgb_2_hsv(R, G, B) + return (H, S, V) + + def hsv_2_rgb_pixel(self, H, S, V): + '''Convert an HSV value to RGB + + Parameters + ---------- + H : float + Hue value + S : float + Saturation value + V : float + Intensity value + + Returns + ------- + out : (R, G, B) ints + The RGB values + + ''' + R, G, B = _colormixer.py_hsv_2_rgb(H, S, V) + return (R, G, B) + diff --git a/scikits/image/io/io.py b/scikits/image/io/io.py index 66d3b82b..a2a431ac 100644 --- a/scikits/image/io/io.py +++ b/scikits/image/io/io.py @@ -1,6 +1,35 @@ -__all__ = ['imread', 'imsave', 'imshow', 'show'] +__all__ = ['imread', 'imsave', 'imshow', 'show', 'push', 'pop'] from scikits.image.io._plugins import call as call_plugin +import numpy as np + +# Shared image queue +_image_stack = [] + +def push(img): + """Push an image onto the shared image stack. + + Parameters + ---------- + img : ndarray + Image to push. + + """ + if not isinstance(img, np.ndarray): + raise ValueError("Can only push ndarrays to the image stack.") + + _image_stack.append(img) + +def pop(): + """Pop and image from the shared image stack. + + Returns + ------- + img : ndarray + Image popped from the stack. + + """ + return _image_stack.pop() def imread(fname, as_grey=False, dtype=None, plugin=None, flatten=None, **plugin_args): diff --git a/scikits/image/io/setup.py b/scikits/image/io/setup.py new file mode 100644 index 00000000..9c9cf023 --- /dev/null +++ b/scikits/image/io/setup.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python + +from scikits.image._build import cython + +import os.path + +base_path = os.path.abspath(os.path.dirname(__file__)) + +def configuration(parent_package='', top_path=None): + from numpy.distutils.misc_util import Configuration, get_numpy_include_dirs + + config = Configuration('io', parent_package, top_path) + config.add_data_dir('tests') + config.add_data_files('_plugins/*.ini') + + # This function tries to create C files from the given .pyx files. If + # it fails, we build the checked-in .c files. + cython(['_plugins/_colormixer.pyx', '_plugins/_histograms.pyx'], + working_path=base_path) + + config.add_extension('_plugins._colormixer', + sources=['_plugins/_colormixer.c'], + include_dirs=[get_numpy_include_dirs()]) + + config.add_extension('_plugins._histograms', + sources=['_plugins/_histograms.c'], + include_dirs=[get_numpy_include_dirs()]) + + return config + +if __name__ == '__main__': + from numpy.distutils.core import setup + setup(maintainer = 'scikits.image Developers', + maintainer_email = 'scikits-image@googlegroups.com', + description = 'Image I/O Routines', + url = 'http://stefanv.github.com/scikits.image/', + license = 'Modified BSD', + **(configuration(top_path='').todict()) + ) diff --git a/scikits/image/io/tests/test_colormixer.py b/scikits/image/io/tests/test_colormixer.py new file mode 100644 index 00000000..72e4aed0 --- /dev/null +++ b/scikits/image/io/tests/test_colormixer.py @@ -0,0 +1,140 @@ +from numpy.testing import * +import numpy as np + +import scikits.image.io._plugins._colormixer as cm + +class ColorMixerTest(object): + def setup(self): + self.state = np.ones((18, 33, 3), dtype=np.uint8) * 200 + self.img = np.zeros_like(self.state) + + def test_basic(self): + self.op(self.img, self.state, 0, self.positive) + assert_array_equal(self.img[..., 0], + self.py_op(self.state[..., 0], self.positive)) + + def test_clip(self): + self.op(self.img, self.state, 0, self.positive_clip) + assert_array_equal(self.img[..., 0], + np.ones_like(self.img[..., 0]) * 255) + + def test_negative(self): + self.op(self.img, self.state, 0, self.negative) + assert_array_equal(self.img[..., 0], + self.py_op(self.state[..., 0], self.negative)) + + def test_negative_clip(self): + self.op(self.img, self.state, 0, self.negative_clip) + assert_array_equal(self.img[..., 0], + np.zeros_like(self.img[..., 0])) + + +class TestColorMixerAdd(ColorMixerTest): + op = cm.add + py_op = np.add + positive = 50 + positive_clip = 56 + negative = -50 + negative_clip = -220 + + +class TestColorMixerMul(ColorMixerTest): + op = cm.multiply + py_op = np.multiply + positive = 1.2 + positive_clip = 2 + negative = 0.5 + negative_clip = -0.5 + + +class TestColorMixerBright(object): + + def setup(self): + self.state = np.ones((18, 33, 3), dtype=np.uint8) * 200 + self.img = np.zeros_like(self.state) + + def test_brightness_pos(self): + cm.brightness(self.img, self.state, 1.25, 1) + assert_array_equal(self.img, np.ones_like(self.img) * 251) + + def test_brightness_neg(self): + cm.brightness(self.img, self.state, 0.5, -50) + assert_array_equal(self.img, np.ones_like(self.img) * 50) + + def test_brightness_pos_clip(self): + cm.brightness(self.img, self.state, 2, 0) + assert_array_equal(self.img, np.ones_like(self.img) * 255) + + def test_brightness_neg_clip(self): + cm.brightness(self.img, self.state, 0, 0) + assert_array_equal(self.img, np.zeros_like(self.img)) + + +class TestColorMixer(object): + + def setup(self): + self.state = np.ones((18, 33, 3), dtype=np.uint8) * 50 + self.img = np.zeros_like(self.state) + + def test_sigmoid(self): + import math + alpha = 1.5 + beta = 1.5 + c1 = 1 / (1 + math.exp(beta)) + c2 = 1 / (1 + math.exp(beta - alpha)) - c1 + state = self.state / 255. + cm.sigmoid_gamma(self.img, self.state, alpha, beta) + img = 1 / (1 + np.exp(beta - state * alpha)) + img = np.asarray((img - c1) / c2 * 255, dtype='uint8') + assert_almost_equal(img, self.img) + + def test_gamma(self): + gamma = 1.5 + cm.gamma(self.img, self.state, gamma) + img = np.asarray(((self.state/255.)**(1/gamma))*255, dtype='uint8') + assert_array_almost_equal(img, self.img) + + def test_rgb_2_hsv(self): + r = 255 + g = 0 + b = 0 + h, s, v = cm.py_rgb_2_hsv(r, g, b) + assert_almost_equal(np.array([h]), np.array([0])) + assert_almost_equal(np.array([s]), np.array([1])) + assert_almost_equal(np.array([v]), np.array([1])) + + def test_hsv_2_rgb(self): + h = 0 + s = 1 + v = 1 + r, g, b = cm.py_hsv_2_rgb(h, s, v) + assert_almost_equal(np.array([r]), np.array([255])) + assert_almost_equal(np.array([g]), np.array([0])) + assert_almost_equal(np.array([b]), np.array([0])) + + + def test_hsv_add(self): + cm.hsv_add(self.img, self.state, 360, 0, 0) + assert_almost_equal(self.img, self.state) + + def test_hsv_add_clip_neg(self): + cm.hsv_add(self.img, self.state, 0, 0, -1) + assert_equal(self.img, np.zeros_like(self.state)) + + def test_hsv_add_clip_pos(self): + cm.hsv_add(self.img, self.state, 0, 0, 1) + assert_equal(self.img, np.ones_like(self.state)*255) + + def test_hsv_mul(self): + cm.hsv_multiply(self.img, self.state, 360, 1, 1) + assert_almost_equal(self.img, self.state) + + def test_hsv_mul_clip_neg(self): + cm.hsv_multiply(self.img, self.state, 0, 0, 0) + assert_equal(self.img, np.zeros_like(self.state)) + + + + +if __name__ == "__main__": + run_module_suite() diff --git a/scikits/image/io/tests/test_histograms.py b/scikits/image/io/tests/test_histograms.py new file mode 100644 index 00000000..76589e3b --- /dev/null +++ b/scikits/image/io/tests/test_histograms.py @@ -0,0 +1,28 @@ +from numpy.testing import * +import numpy as np + +import scikits.image.io._plugins._colormixer as cm +from scikits.image.io._plugins._histograms import histograms + +class TestHistogram: + def test_basic(self): + img = np.ones((50, 50, 3), dtype=np.uint8) + r, g, b, v = histograms(img, 255) + + for band in (r, g, b, v): + yield assert_equal, band.sum(), 50*50 + + def test_counts(self): + channel = np.arange(255).reshape(51, 5) + img = np.empty((51, 5, 3), dtype='uint8') + img[:,:,0] = channel + img[:,:,1] = channel + img[:,:,2] = channel + r, g, b, v = histograms(img, 255) + assert_array_equal(r, g) + assert_array_equal(r, b) + assert_array_equal(r, v) + assert_array_equal(r, np.ones(255)) + +if __name__ == "__main__": + run_module_suite() diff --git a/scikits/image/io/tests/test_io.py b/scikits/image/io/tests/test_io.py new file mode 100644 index 00000000..dbf5a3fc --- /dev/null +++ b/scikits/image/io/tests/test_io.py @@ -0,0 +1,17 @@ +from numpy.testing import * +import numpy as np + +import scikits.image.io as io + +def test_stack_basic(): + x = np.arange(12).reshape(3, 4) + io.push(x) + + assert_array_equal(io.pop(), x) + +@raises(ValueError) +def test_stack_non_array(): + io.push([[1, 2, 3]]) + +if __name__ == "__main__": + run_module_suite() diff --git a/scikits/image/io/tests/test_imread.py b/scikits/image/io/tests/test_pil.py similarity index 50% rename from scikits/image/io/tests/test_imread.py rename to scikits/image/io/tests/test_pil.py index fc77e0c7..0dbb877f 100644 --- a/scikits/image/io/tests/test_imread.py +++ b/scikits/image/io/tests/test_pil.py @@ -1,9 +1,12 @@ import os.path import numpy as np +from numpy.testing import * + +from tempfile import NamedTemporaryFile from scikits.image import data_dir -from scikits.image.io import imread -from scikits.image.io._plugins.pil_plugin import palette_is_grayscale +from scikits.image.io import imread, imsave +from scikits.image.io._plugins.pil_plugin import _palette_is_grayscale def test_imread_flatten(): # a color image is flattened and returned as float32 @@ -26,6 +29,26 @@ def test_imread_palette(): def test_palette_is_gray(): from PIL import Image gray = Image.open(os.path.join(data_dir, 'palette_gray.png')) - assert palette_is_grayscale(gray) + assert _palette_is_grayscale(gray) color = Image.open(os.path.join(data_dir, 'palette_color.png')) - assert not palette_is_grayscale(color) + assert not _palette_is_grayscale(color) + +class TestSave: + def roundtrip(self, dtype, x, scaling=1): + f = NamedTemporaryFile(suffix='.png') + imsave(f.name, x) + f.seek(0) + y = imread(f.name) + + assert_array_almost_equal((x * scaling).astype(np.int32), y) + + def test_imsave_roundtrip(self): + for shape in [(10, 10), (10, 10, 3), (10, 10, 4)]: + for dtype in (np.uint8, np.uint16, np.float32, np.float64): + x = np.ones(shape, dtype=dtype) * np.random.random(shape) + + if np.issubdtype(dtype, float): + yield self.roundtrip, dtype, x, 255 + else: + x = (x * 255).astype(dtype) + yield self.roundtrip, dtype, x diff --git a/scikits/image/scripts/__init__.py b/scikits/image/scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scikits/image/scripts/scivi b/scikits/image/scripts/scivi new file mode 100755 index 00000000..0da676ea --- /dev/null +++ b/scikits/image/scripts/scivi @@ -0,0 +1,6 @@ +#!/usr/bin/env python + +if __name__ == "__main__": + from scikits.image.scripts import scivi + scivi.main() + diff --git a/scikits/image/scripts/scivi.py b/scikits/image/scripts/scivi.py new file mode 100644 index 00000000..553b975f --- /dev/null +++ b/scikits/image/scripts/scivi.py @@ -0,0 +1,13 @@ +"""scikits.image viewer""" +def main(): + import scikits.image.io as io + import sys + + if len(sys.argv) != 2: + print "Usage: scivi " + sys.exit(-1) + + io.use_plugin('qt') + io.imshow(io.imread(sys.argv[1]), fancy=True) + io.show() + diff --git a/scikits/image/setup.py b/scikits/image/setup.py index 1f30754d..fbf45a5c 100644 --- a/scikits/image/setup.py +++ b/scikits/image/setup.py @@ -7,13 +7,12 @@ def configuration(parent_package='', top_path=None): config.add_subpackage('opencv') config.add_subpackage('graph') + config.add_subpackage('io') def add_test_directories(arg, dirname, fnames): if dirname.split(os.path.sep)[-1] == 'tests': config.add_data_dir(dirname) - config.add_data_files('io/_plugins/*.ini') - # Add test directories from os.path import isdir, dirname, join, abspath rel_isdir = lambda d: isdir(join(curpath, d)) diff --git a/setup.py b/setup.py index 160fca2d..bbff4ce5 100644 --- a/setup.py +++ b/setup.py @@ -78,4 +78,9 @@ if __name__ == "__main__": packages=setuptools.find_packages(), include_package_data=True, zip_safe=False, # the package can run out of an .egg file + + entry_points={ + 'console_scripts': [ + 'scivi = scikits.image.scripts.scivi:main'] + }, )