435 lines
13 KiB
Python
435 lines
13 KiB
Python
import numpy as np
|
|
from . import _colormixer
|
|
from . import _histograms
|
|
import threading
|
|
from ...util import img_as_ubyte
|
|
|
|
# utilities to make life easier for plugin writers.
|
|
|
|
import multiprocessing
|
|
CPU_COUNT = multiprocessing.cpu_count()
|
|
|
|
|
|
class GuiLockError(Exception):
|
|
def __init__(self, msg):
|
|
self.msg = msg
|
|
|
|
def __str__(self):
|
|
return self.msg
|
|
|
|
|
|
class WindowManager(object):
|
|
''' A class to keep track of spawned windows,
|
|
and make any needed callback once all the windows,
|
|
are closed.'''
|
|
def __init__(self):
|
|
self._windows = []
|
|
self._callback = None
|
|
self._callback_args = ()
|
|
self._callback_kwargs = {}
|
|
self._gui_lock = False
|
|
self._guikit = ''
|
|
|
|
def _check_locked(self):
|
|
if not self._gui_lock:
|
|
raise GuiLockError(\
|
|
'Must first acquire the gui lock before using this image manager')
|
|
|
|
def _exec_callback(self):
|
|
if self._callback:
|
|
self._callback(*self._callback_args, **self._callback_kwargs)
|
|
|
|
def acquire(self, kit):
|
|
if self._gui_lock:
|
|
raise GuiLockError(\
|
|
'The gui lock can only be acquired by one toolkit per session. \
|
|
The lock is already acquired by %s' % self._guikit)
|
|
else:
|
|
self._gui_lock = True
|
|
self._guikit = str(kit)
|
|
|
|
def _release(self, kit):
|
|
# releaseing the lock will lose all references to currently
|
|
# tracked images and the callback.
|
|
# this function is private for reason!
|
|
self._check_locked()
|
|
if str(kit) == self._guikit:
|
|
self._windows = []
|
|
self._callback = None
|
|
self._callback_args = ()
|
|
self._callback_kwargs = {}
|
|
self._gui_lock = False
|
|
self._guikit = ''
|
|
else:
|
|
raise RuntimeError('Only the toolkit that owns the lock may '
|
|
'release it')
|
|
|
|
def add_window(self, win):
|
|
self._check_locked()
|
|
self._windows.append(win)
|
|
|
|
def remove_window(self, win):
|
|
self._check_locked()
|
|
try:
|
|
self._windows.remove(win)
|
|
except ValueError:
|
|
print('Unable to find referenced window in tracked windows.')
|
|
print('Ignoring...')
|
|
else:
|
|
if len(self._windows) == 0:
|
|
self._exec_callback()
|
|
|
|
def register_callback(self, cb, *cbargs, **cbkwargs):
|
|
self._check_locked()
|
|
self._callback = cb
|
|
self._callback_args = cbargs
|
|
self._callback_kwargs = cbkwargs
|
|
|
|
def has_windows(self):
|
|
if len(self._windows) > 0:
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
window_manager = WindowManager()
|
|
|
|
|
|
def prepare_for_display(npy_img):
|
|
'''Convert a 2D or 3D numpy array of any dtype into a
|
|
3D numpy array with dtype uint8. This array will
|
|
be suitable for use in passing to gui toolkits for
|
|
image display purposes.
|
|
|
|
Parameters
|
|
----------
|
|
npy_img : ndarray, 2D or 3D
|
|
The image to convert for display
|
|
|
|
Returns
|
|
-------
|
|
out : ndarray, 3D dtype=np.uint8
|
|
The converted image. This is guaranteed to be a contiguous array.
|
|
|
|
Notes
|
|
-----
|
|
If the input image is floating point, it is assumed that the data
|
|
is in the range of 0.0 - 1.0. No check is made to assert this
|
|
condition. The image is then scaled to be in the range 0 - 255
|
|
and then cast to np.uint8
|
|
|
|
For all other dtypes, the array is simply cast to np.uint8
|
|
|
|
If a 2D array is passed, the single channel is replicated
|
|
to the 2nd and 3rd channels.
|
|
|
|
If the array contains an alpha channel, this channel is
|
|
ignored.
|
|
|
|
'''
|
|
if npy_img.ndim < 2:
|
|
raise ValueError('Image must be 2D or 3D array')
|
|
|
|
height = npy_img.shape[0]
|
|
width = npy_img.shape[1]
|
|
|
|
out = np.empty((height, width, 3), dtype=np.uint8)
|
|
npy_img = img_as_ubyte(npy_img)
|
|
|
|
if npy_img.ndim == 2 or \
|
|
(npy_img.ndim == 3 and npy_img.shape[2] == 1):
|
|
npy_plane = npy_img.reshape((height, width))
|
|
out[:, :, 0] = npy_plane
|
|
out[:, :, 1] = npy_plane
|
|
out[:, :, 2] = npy_plane
|
|
|
|
elif npy_img.ndim == 3:
|
|
if npy_img.shape[2] == 3 or npy_img.shape[2] == 4:
|
|
out[:, :, :3] = npy_img[:, :, :3]
|
|
else:
|
|
raise ValueError('Image must have 1, 3, or 4 channels')
|
|
|
|
else:
|
|
raise ValueError('Image must have 2 or 3 dimensions')
|
|
|
|
return out
|
|
|
|
|
|
def histograms(image, nbins):
|
|
'''Calculate the channel histograms of the current image.
|
|
|
|
Parameters
|
|
----------
|
|
image : ndarray, ndim=3, dtype=np.uint8
|
|
Input image.
|
|
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(image, 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):
|
|
|
|
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 don't 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(len(self.chunks)):
|
|
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.
|
|
|
|
'''
|
|
if channel not in self.valid_channels:
|
|
raise ValueError('assert_channel is not a valid channel.')
|
|
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.
|
|
|
|
'''
|
|
if channel not in self.valid_channels:
|
|
raise ValueError('assert_channel is not a valid channel.')
|
|
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)
|