# coding: utf-8
from time import time
import numpy as np
from multiprocessing import Process, Pipe
from multiprocessing.connection import Connection
from .._global import OptionalModule
try:
from PIL import ImageTk, Image
except (ModuleNotFoundError, ImportError):
ImageTk = OptionalModule("pillow")
Image = OptionalModule("pillow")
try:
import cv2
except (ModuleNotFoundError, ImportError):
cv2 = OptionalModule("opencv-python")
try:
import tkinter as tk
except (ModuleNotFoundError, ImportError):
tk = OptionalModule("tkinter")
[docs]class Hist_generator(Process):
"""Process to generate the histogram of images.
Note:
It only takes a :mod:`Pipe` at :meth:`__init__`, all the data will be
transferred through it. See :meth:`run` for more info.
"""
def __init__(self, pipe: Connection) -> None:
Process.__init__(self)
self.pipe = pipe
[docs] def run(self) -> None:
"""Expects a tuple of 3 args through the pipe:
- out_size (:obj:`tuple`): The dimensions of the output histogram image.
- hist_range (:obj:`tuple`, e.g.: `(0,256)` for full scale `uint8`): The
lower and upper value of the histogram.
- img: A numpy array with the image. If not single channel, it will be
converted to a single channel.
"""
while True:
out_size, hist_range, img = self.pipe.recv()
if not isinstance(out_size, tuple):
break
hist_range = hist_range[0], hist_range[1] + 1
# np.histogram removes 1 value to the output array, no idea why...
if len(img.shape) == 3:
img = np.mean(img, axis=2)
assert len(img.shape) == 2, "Invalid image: shape= " + str(img.shape)
# The actual histogram
h = np.histogram(img, bins=np.arange(*hist_range))[0]
x = np.arange(out_size[1]) # The base of the image
# We need to interpolate the histogram on the size of the output image
l0 = hist_range[1] - hist_range[0] - 1
fx = np.arange(0, out_size[1], out_size[1] / l0, dtype=np.float)
# fx *= out_size[1] / len(fx)
h2 = np.interp(x, fx, h)
h2 = np.nan_to_num(h2 * out_size[0] / max(h2.max(), 1))
out_img = np.zeros(out_size)
for i in range(out_size[1]):
out_img[0:int(out_size[0] - h2[i]), i] = 255
self.pipe.send(out_img)
[docs]class Camera_config:
"""Class creating a graphical interface to configure a camera.
Note:
It will launch :meth:`create_window` when instanciated. Just call:
>>> import crappy
>>> camera = crappy.camera.Fake_camera()
>>> crappy.tool.Camera_config(camera)
It takes a single arg: the camera class (it must inherit from
`crappy.camera.Camera`).
"""
def __init__(self, camera) -> None:
self.camera = camera
self.label_shape = 800, 600
self.scale_length = 350
self.last_reticle_pos = (0, 0)
self.root = tk.Tk()
self.root.protocol("WM_DELETE_WINDOW", self.stop)
self.zoom_step = .1
self.reset_zoom()
self.img = self.camera.read_image()[1]
self.on_resize()
self.convert_img()
self.create_window()
self.update_img()
self.start_histogram()
self.update_histogram()
self.refresh_rate = 1 / 50
self.low = 0
self.high = 255
self.t = time()
self.t_fps = self.t
self.go = True
# self.main()
def stop(self) -> None:
self.hist_pipe.send((0, 0, 0))
self.hist_pipe.recv()
self.hist_process.join()
self.go = False
self.root.destroy()
def start_histogram(self) -> None:
self.hist_pipe, self.p2 = Pipe()
self.hist_process = Hist_generator(self.p2)
self.hist_process.start()
self.hist_pipe.send(((80, self.label_shape[1]), (0, 256), self.img))
# ============ Initialization of the tkinter window and widgets ============
def create_window(self) -> None:
self.root.grid_rowconfigure(1, weight=1)
self.root.grid_columnconfigure(0, weight=1)
self.img_label = tk.Label(self.root)
self.img_label.configure(image=self.c_img)
self.hist_label = tk.Label(self.root)
self.hist_label.grid(row=0, column=0)
# self.img_label.pack(fill=tk.BOTH)
self.img_label.grid(row=1, column=0,
rowspan=len(self.camera.settings_dict) + 2,
sticky=tk.N + tk.E + tk.S + tk.W)
self.create_inputs()
self.create_infos()
self.img_label.bind('<Motion>', self.update_reticle)
self.img_label.bind('<4>', self.zoom_in)
self.img_label.bind('<5>', self.zoom_out)
self.root.bind('<MouseWheel>', self.zoom)
self.img_label.bind('<1>', self.start_move)
self.img_label.bind('<B1-Motion>', self.move)
def create_inputs(self) -> None:
settings = list(self.camera.settings_dict.keys())
settings.sort(key=lambda e: str(type(self.camera.settings[e].limits)))
self.scales = {}
self.radios = {}
self.checks = {}
i = 0
for i, k in enumerate(settings):
s = self.camera.settings[k]
if type(s.limits) == tuple:
self.create_scale(s, i)
elif type(s.limits) == bool:
self.create_check(s, i)
elif type(s.limits) == dict:
self.create_radio(s, i)
self.lower_frame = tk.Frame()
self.lower_frame.grid(column=1, row=i + 3)
self.apply_button = tk.Button(self.lower_frame, text="Apply",
command=self.apply_settings)
self.apply_button.pack()
# self.apply_button.grid(column=1, row=i+3)
def create_infos(self) -> None:
self.info_frame = tk.Frame()
self.info_frame.grid(row=0, column=1)
self.fps_label = tk.Label(self.info_frame, text="fps:")
self.fps_label.pack()
self.auto_range = tk.IntVar()
self.range_check = tk.Checkbutton(self.info_frame,
text="Auto range",
variable=self.auto_range)
self.range_check.pack()
self.auto_apply = tk.IntVar()
self.auto_apply_check = tk.Checkbutton(self.info_frame,
text="Auto apply",
variable=self.auto_apply)
self.auto_apply_check.pack()
self.minmax_label = tk.Label(self.info_frame, text="min: max:")
self.minmax_label.pack()
self.range_label = tk.Label(self.info_frame, text="range:")
self.range_label.pack()
self.bits_label = tk.Label(self.info_frame, text="detected bits:")
self.bits_label.pack()
self.zoom_label = tk.Label(self.info_frame, text="Zoom: 100%")
self.zoom_label.pack()
self.reticle_label = tk.Label(self.info_frame, text="Y:0 X:0 V=0")
self.reticle_label.pack()
def create_scale(self, setting, pos: int) -> None:
f = tk.Frame(self.root)
f.grid(row=pos + 2, column=1, sticky=tk.E + tk.W)
if type(setting.limits[0]) is float:
step = (setting.limits[1] - setting.limits[0]) / 1000
else:
step = 1 # To go through all possible int values
tk.Label(f, text=setting.name).pack()
self.scales[setting.name] = tk.Scale(f, orient='horizontal',
resolution=step,
length=self.scale_length,
from_=setting.limits[0],
to=setting.limits[1])
self.scales[setting.name].pack()
self.scales[setting.name].set(setting.value)
def create_radio(self, setting, pos: int) -> None:
self.radios[setting.name] = tk.IntVar()
f = tk.Frame(self.root)
f.grid(row=pos + 2, column=1, sticky=tk.E + tk.W)
tk.Label(f, text=setting.name + ":").pack(anchor=tk.W)
for k, v in setting.limits.items():
r = tk.Radiobutton(f, text=k, variable=self.radios[setting.name],
value=v)
if setting.value == v:
r.select()
r.pack(anchor=tk.W)
def create_check(self, setting, pos: int) -> None:
self.checks[setting.name] = tk.IntVar()
f = tk.Frame(self.root)
f.grid(row=pos + 2, column=1, sticky=tk.E + tk.W)
b = tk.Checkbutton(f, text=setting.name,
variable=self.checks[setting.name])
if setting.value:
b.select()
b.pack(anchor=tk.W)
# ============ Tools ==============
def detect_bits(self) -> int:
b = 8
o = 256
m = self.img.max()
while o < m:
b += 1
o *= 2
return b
def get_label_shape(self) -> tuple:
r = self.img_label.winfo_height() - 2, self.img_label.winfo_width() - 2
return r
[docs] def resized(self) -> bool:
"""Returns :obj:`True` if window has been resized and saves the new
coordinates"""
new = self.get_label_shape()
# Integer rounding can lead to resizing loop if we compare exact values
# so let's resize only if the difference is significant (more than 2pix)
# if new != self.label_shape:
if sum([abs(i - j) for i, j in zip(new, self.label_shape)]) >= 5:
if new[0] > 0 and new[1] > 0:
self.label_shape = new
return True
return False
[docs] def convert_img(self) -> None:
"""Converts the image to `uint` if necessary, then to a `PhotoImage`."""
if len(self.img.shape) == 3:
self.img = self.img[:, :, ::-1] # BGR to RGB
if self.img.dtype != np.uint8:
# if self.auto_range.get():
# Ok, this is not a very clean way, but at first auto_range is not
# defined
try:
assert self.auto_range.get()
# ar=True, 16bits
self.low = np.percentile(self.img, 1)
self.high = np.percentile(self.img, 99)
self.img8 = ((np.clip(self.img, self.low, self.high) -
self.low) * 256 / self.high).astype(np.uint8)
except (AssertionError, AttributeError):
# ar=False, 16 bits
self.img8 = (self.img / 2 ** (self.detect_bits() - 8)).astype(np.uint8)
else:
try:
assert self.auto_range.get()
# ar=True, 8bits
self.low = np.percentile(self.img, 1)
self.high = np.percentile(self.img, 99)
self.img8 = ((np.clip(self.img, self.low, self.high) -
self.low) * 256 / self.high)
except (AssertionError, AttributeError):
# ar=False, 8bits
self.img8 = self.img
slx = slice(int(self.img.shape[0] * self.zoom_window[0]),
int(self.img.shape[0] * self.zoom_window[2]))
sly = slice(int(self.img.shape[1] * self.zoom_window[1]),
int(self.img.shape[1] * self.zoom_window[3]))
self.resize_img((sly, slx))
def resize_img(self, sl: tuple) -> None:
self.c_img = ImageTk.PhotoImage(Image.fromarray(
cv2.resize(self.img8[sl[1], sl[0]], tuple(reversed(self.img_shape)),
interpolation=0)))
# Interpolation=0 means nearest neighbor
# Resize somehow takes x,y when EVERYTHING else takes y,x thanks numpy !
# =============== Update functions ===============
def update_histogram(self) -> None:
while not self.hist_pipe.poll():
pass
h = self.hist_pipe.recv()
if self.auto_range.get():
lo = int(self.low / 2 ** self.detect_bits() * h.shape[1])
hi = int(self.high / 2 ** self.detect_bits() * h.shape[1])
if lo < h.shape[1]:
h[:, lo] = 127
if hi < h.shape[1]:
h[:, hi] = 127
self.hist = ImageTk.PhotoImage(Image.fromarray(h))
self.hist_label.configure(image=self.hist)
self.hist_pipe.send(((80, self.label_shape[1]),
(0, 2 ** self.detect_bits()),
self.img))
def update_img(self) -> None:
self.convert_img()
self.img_label.configure(image=self.c_img)
def update_infos(self) -> None:
self.minmax_label.configure(text="min: {} max: {}".format(self.img.min(),
self.img.max()))
self.bits_label.configure(text="detected bits: {} ({} values)".format(
self.detect_bits(), 2**self.detect_bits()))
self.range_label.configure(text='Range: {}-{}'.format(self.low, self.high))
self.update_reticle(self.last_reticle_pos)
def update_reticle(self, event) -> None:
if isinstance(event, tuple):
y, x = event
if y > self.img.shape[0] or x > self.img.shape[1]:
y = x = 0
else:
y, x = self.get_img_coord(event.y, event.x)
self.reticle_label.configure(text="Y:{} X:{} V={}".format(
y, x, self.img[y, x]))
self.last_reticle_pos = (y, x)
# ============ Zoom related methods ===============
def reset_zoom(self) -> None:
self.zoom_level = 1
self.zoom_window = (0, 0, 1, 1)
def get_img_coord(self, ey: float, ex: float) -> tuple:
ey -= (self.label_shape[0] - self.img_shape[0]) / 2
ex -= (self.label_shape[1] - self.img_shape[1]) / 2
miny, minx, maxy, maxx = self.zoom_window
miny, maxy = int(miny * self.img.shape[0]), int(maxy * self.img.shape[0])
minx, maxx = int(minx * self.img.shape[1]), int(maxx * self.img.shape[1])
ry, rx = ey / self.img_shape[0], ex / self.img_shape[1]
y, x = int(ry * (maxy - miny) + miny + .5), int(
rx * (maxx - minx) + minx + .5)
return min(max(miny, y), maxy - 1), min(max(minx, x), maxx - 1)
[docs] def make_new_window(self, ey: float, ex: float) -> None:
"""Hang on, it is not that complicated: given the location of the zoom
event this will compute the new zoom window.
It is represented by 4 floats between 0 and 1 `miny`, `minx`, `maxy`,
`maxx`, default is `0, 0, 1, 1`.
"""
# The zoom factor (%)
z = (1 + self.zoom_step) ** self.zoom_level
self.zoom_label.configure(text="Zoom: %.1f%%" % (100 * z))
# The previous values are used to compute the actual displacement
pminy, pminx, pmaxy, pmaxx = self.zoom_window
# Make the event position global (we get the position on the label itself)
ex *= self.label_shape[1] / self.img_shape[1]
ex -= (self.label_shape[1] - self.img_shape[1]) / (2 * self.img_shape[1])
# ex is now somewhere between 0 and 1, reflecting abscissa on the image
# Last edit: cast it on the whole image, taking zoom in account
ex = ex * (pmaxx - pminx) + pminx
# Now ex*img.shape[1] would be the coordinate of the pixel
# Where it is located on the picture, relatively to the previous window
rx = (ex - pminx) / (pmaxx - pminx)
# And now we can compute the new window abscissa
minx = ex - rx / z
maxx = 1 / z + minx
# Exactly the same procedure for y:
ey *= self.label_shape[0] / self.img_shape[0]
ey -= (self.label_shape[0] - self.img_shape[0]) / (2 * self.img_shape[0])
ey = ey * (pmaxy - pminy) + pminy
ry = (ey - pminy) / (pmaxy - pminy)
miny = ey - ry / z
maxy = 1 / z + miny
# Warp if we are zooming out of the image
if minx < 0:
minx = 0
maxx = 1 / z
if miny < 0:
miny = 0
maxy = 1 / z
if maxx > 1:
maxx = 1
minx = 1 - 1 / z
if maxy > 1:
maxy = 1
miny = 1 - 1 / z
self.zoom_window = (miny, minx, maxy, maxx)
[docs] def move_window(self, y: int, x: int) -> None:
"""Given the movement of the mouse, it will recompute the zoom_window."""
z = (1 + self.zoom_step) ** self.zoom_level
miny = self.previous_window[0] + y / z
maxy = self.previous_window[2] + y / z
minx = self.previous_window[1] + x / z
maxx = self.previous_window[3] + x / z
if minx < 0:
minx = 0
maxx = 1 / z
if miny < 0:
miny = 0
maxy = 1 / z
if maxx > 1:
maxx = 1
minx = 1 - 1 / z
if maxy > 1:
maxy = 1
miny = 1 - 1 / z
self.zoom_window = (miny, minx, maxy, maxx)
# ========== Callback functions ===========
[docs] def apply_settings(self) -> None:
"""Callback for the apply button.
As its name suggests, it will apply all the edited settings.
"""
# Applying scales values to the camera
for name, scale in self.scales.items():
if self.camera.settings[name].value != scale.get():
self.camera.settings[name].value = scale.get()
# Applying checks (boolean values)
for name, value in self.checks.items():
if bool(self.camera.settings[name].value) != bool(value.get()):
self.camera.settings[name].value = bool(value.get())
# And applying the radios (selectable values)
for name, value in self.radios.items():
if self.camera.settings[name].value != value.get():
self.camera.settings[name].value = value.get()
# READING from the camera to move the scales to the actual values
for name, scale in self.scales.items():
if self.camera.settings[name].value != scale.get():
scale.set(self.camera.settings[name].value)
# Updating the window
self.img = self.camera.read_image()[1]
self.update_img()
self.on_resize()
[docs] def on_resize(self) -> None:
"""Recomputes the new shape of the resized image."""
ratio = min(self.label_shape[0] / self.img.shape[0],
self.label_shape[1] / self.img.shape[1])
self.img_shape = (int(self.img.shape[0] * ratio),
int(self.img.shape[1] * ratio))
[docs] def zoom(self, event) -> None:
"""For windows, only one type of wheel event."""
# This event is relative to the window!
event.y -= self.img_label.winfo_y()
if event.num == 5 or event.delta < 0:
self.zoom_out(event)
elif event.num == 4 or event.delta > 0:
self.zoom_in(event)
[docs] def zoom_in(self, event) -> None:
"""Called when scrolling in."""
self.zoom_level += 1
ls = self.get_label_shape()
self.make_new_window(event.y / ls[0], event.x / ls[1])
[docs] def zoom_out(self, event) -> None:
"""Called when scrolling out."""
if self.zoom_level == 0:
return
elif self.zoom_level < 0:
self.zoom_level = 0
else:
self.zoom_level -= 1
ls = self.get_label_shape()
self.make_new_window(event.y / ls[0], event.x / ls[1])
[docs] def start_move(self, event) -> None:
"""To save the coordinates before actually moving."""
self.mv_start = (event.y, event.x)
self.previous_window = self.zoom_window
[docs] def move(self, event) -> None:
"""Moving the image when dragging it with the mouse."""
mvy = (self.mv_start[0] - event.y) / self.img_shape[0]
mvx = (self.mv_start[1] - event.x) / self.img_shape[1]
self.move_window(mvy, mvx)
# ============ And finally, the main loop ===========
def main(self) -> None:
loop = 0
while self.go:
loop += 1 # For the fps counter
self.img = self.camera.read_image()[1]
curr_t = time()
if curr_t - self.t >= self.refresh_rate:
# Time to refresh the window
if curr_t - self.t_fps > .5:
# Time to update the fps
self.fps_label.configure(text="fps=%.1f" %
(loop / (curr_t - self.t_fps)))
self.t_fps = curr_t
loop = 0
# And auto apply the settings if needed
if self.auto_apply.get():
self.apply_settings()
if self.hist_pipe.poll():
# The histogram process has finished computing: let's update
self.update_histogram()
self.update_img()
self.update_infos()
self.t = curr_t
if self.resized():
# Even if it is not time to update, the window has been resized
self.on_resize()
self.update_img()
self.root.update()
print("Camera config done !")