# coding: utf-8
import tkinter as tk
from tkinter.messagebox import showerror
from _tkinter import TclError
from platform import system
import numpy as np
from time import time, sleep
from typing import Optional, Tuple, Union
from pkg_resources import resource_string
from io import BytesIO
import logging
from multiprocessing import current_process, Event, Queue
from multiprocessing.queues import Queue as MPQueue
from .config_tools import Zoom, HistogramProcess
from ...camera.meta_camera.camera_setting import CameraBoolSetting, \
CameraChoiceSetting, CameraScaleSetting
from ...camera.meta_camera import Camera
from ..._global import OptionalModule
try:
from PIL import ImageTk, Image
except (ModuleNotFoundError, ImportError):
ImageTk = OptionalModule("pillow")
Image = OptionalModule("pillow")
[docs]
class CameraConfig(tk.Tk):
"""This class is a GUI allowing the user to visualize the images from a
:class:`~crappy.camera.Camera` before a Crappy test starts, and to tune the
settings of the Camera.
It is meant to be user-friendly and interactive. It is possible to zoom on
the image using the mousewheel, and to move on the zoomed image by
right-clicking and dragging.
In addition to the image, the interface also displays a histogram of the
pixel values, an FPS counter, a detected bits counter, the minimum and
maximum pixel values, and the value and position of the pixel currently under
the mouse. A checkbox allows auto-adjusting the pixel range to get a better
contrast.
This class is used as is by the :class:`~crappy.blocks.Camera`, but also
subclassed to provide more specific functionalities to other camera-related
Blocks like :class:`~crappy.blocks.VideoExtenso` or
:class:`~crappy.blocks.DICVE`.
This class is a child of :obj:`tkinter.Tk`. It relies on the
:class:`~crappy.tool.camera_config.config_tools.Zoom` and
:class:`~crappy.tool.camera_config.config_tools.HistogramProcess` tools. It
also interacts with instances of the
:class:`~crappy.camera.meta_camera.camera_setting.CameraSetting` class.
.. versionadded:: 1.4.0
.. versionchanged:: 2.0.0 renamed from *Camera_config* to *CameraConfig*
"""
[docs]
def __init__(self,
camera: Camera,
log_queue: MPQueue,
log_level: Optional[int],
max_freq: Optional[float]) -> None:
"""Initializes the interface and displays it.
Args:
camera: The :class:`~crappy.camera.Camera` object in charge of acquiring
the images.
log_queue: A :obj:`multiprocessing.Queue` for sending the log messages to
the main :obj:`~logging.Logger`, only used in Windows.
.. versionadded:: 2.0.0
log_level: The minimum logging level of the entire Crappy script, as an
:obj:`int`.
.. versionadded:: 2.0.0
max_freq: The maximum frequency this window is allowed to loop at. It is
simply the ``freq`` attribute of the :class:`~crappy.blocks.Camera`
Block.
.. versionadded:: 2.0.0
"""
super().__init__()
self._camera = camera
self.shape: Optional[Union[Tuple[int, int], Tuple[int, int, int]]] = None
self.dtype = None
self._logger: Optional[logging.Logger] = None
# Instantiating objects for the process managing the histogram calculation
self._stop_event = Event()
self._processing_event = Event()
self._img_in = Queue(maxsize=0)
self._img_out = Queue(maxsize=0)
self._histogram_process = HistogramProcess(
stop_event=self._stop_event, processing_event=self._processing_event,
img_in=self._img_in, img_out=self._img_out, log_level=log_level,
log_queue=log_queue)
# Attributes containing the several images and histograms
self._img = None
self._pil_img = None
self._original_img = None
self._hist = None
self._pil_hist = None
# Other attributes used in this class
self._low_thresh = None
self._high_thresh = None
self._move_x = None
self._move_y = None
self._run = True
self._n_loops = 0
self._max_freq = max_freq
# Settings for adjusting the behavior of the zoom
self._zoom_ratio = 0.9
self._zoom_step = 0
self._max_zoom_step = 15
# Settings of the root window
self.title(f'Configuration window for the camera: {type(camera).__name__}')
self.protocol("WM_DELETE_WINDOW", self.finish)
self._zoom_values = Zoom()
# Initializing the interface
self._set_variables()
self._set_traces()
self._set_layout()
self._set_bindings()
self._add_settings()
self.update()
[docs]
def main(self) -> None:
"""Constantly updates the image and the information on the GUI, until asked
to stop.
.. versionadded:: 1.5.10
"""
# Starting the histogram calculation process
self._histogram_process.start()
self._n_loops = 0
start_time = time()
while self._run:
# Remaining below the max allowed frequency
if self._max_freq is None or (self._n_loops <
self._max_freq * (time() - start_time)):
# Update the image, the histogram and the information
self._update_img()
# Update the FPS counter
if time() - start_time > 0.5:
self._fps_var.set(self._n_loops / (time() - start_time))
self._n_loops = 0
start_time = time()
[docs]
def log(self, level: int, msg: str) -> None:
"""Record log messages for the CameraConfig window.
Also instantiates the :obj:`~logging.Logger` when logging the first
message.
Args:
level: An :obj:`int` indicating the logging level of the message.
msg: The message to log, as a :obj:`str`.
.. versionadded:: 2.0.0
"""
if self._logger is None:
self._logger = logging.getLogger(
f"{current_process().name}.{type(self).__name__}")
self._logger.log(level, msg)
def report_callback_exception(self, exc: Exception, val: str, tb) -> None:
"""Method displaying an error message in case an exception is raised in a
:mod:`tkinter` callback.
.. versionadded:: 2.0.0
"""
self._logger.exception(f"Caught exception in {type(self).__name__}: "
f"{exc.__name__}({val})", exc_info=tb)
showerror("Error !", message=f"{exc.__name__}\n{val}")
def finish(self) -> None:
"""Method called when the user tries to close the configuration window.
Mostly intended for being overwritten.
.. versionadded:: 2.0.0
"""
self.stop()
def stop(self) -> None:
"""Method called for gracefully stopping the GUI.
Stops the process calculating the histogram, and destroys the GUI.
.. versionadded:: 2.0.0
"""
# Stopping the event loop and the histogram process
self._run = False
self._stop_event.set()
sleep(0.1)
# Killing the histogram process if it's still alive
if self._histogram_process.is_alive():
self.log(logging.WARNING, "The histogram process failed to stop, "
"killing it !")
self._histogram_process.terminate()
self.log(logging.DEBUG, "Destroying the configuration window")
try:
self.destroy()
except TclError:
self.log(logging.WARNING, "Cannot destroy the configuration window, "
"ignoring")
def _set_layout(self) -> None:
"""Creates and places the different elements of the display on the GUI."""
self.log(logging.DEBUG, "Setting the interface layout")
# The main frame of the window
self._main_frame = tk.Frame()
self._main_frame.pack(fill='both', expand=True)
# The frame containing the image and the histogram
self._graphical_frame = tk.Frame(self._main_frame)
self._graphical_frame.pack(expand=True, fill="both", anchor="w",
side="left")
# The image row will expand 4 times as fast as the histogram row
self._graphical_frame.columnconfigure(0, weight=1)
self._graphical_frame.rowconfigure(0, weight=1)
self._graphical_frame.rowconfigure(1, weight=4)
# Adapting the default dimension of the GUI according to the screen size
screen_width = self.winfo_screenwidth()
screen_height = self.winfo_screenheight()
if screen_width < 1600 or screen_height < 900:
min_width, min_height = 600, 450
else:
min_width, min_height = 800, 600
# The label containing the histogram
self._hist_canvas = tk.Canvas(self._graphical_frame, height=80,
width=min_width, highlightbackground='black',
highlightthickness=1)
self._hist_canvas.grid(row=0, column=0, sticky='nsew')
# The label containing the image
self._img_canvas = tk.Canvas(self._graphical_frame, width=min_width,
height=min_height)
self._img_canvas.grid(row=1, column=0, sticky='nsew')
# The frame containing the information on the image and the settings
self._text_frame = tk.Frame(self._main_frame, highlightbackground='black',
highlightthickness=1)
self._text_frame.pack(expand=True, fill='y', anchor='ne')
# The frame containing the information on the image
self._info_frame = tk.Frame(self._text_frame, highlightbackground='black',
highlightthickness=1)
self._info_frame.pack(expand=False, fill='both', anchor='n', side='top',
ipady=2)
# The information on the image
self._fps_label = tk.Label(self._info_frame, textvariable=self._fps_txt)
self._fps_label.pack(expand=False, fill='none', anchor='n', side='top')
self._auto_range_button = tk.Checkbutton(self._info_frame,
text='Auto range',
variable=self._auto_range)
self._auto_range_button.pack(expand=False, fill='none', anchor='n',
side='top')
self._auto_apply_button = tk.Checkbutton(self._info_frame,
text='Auto apply',
variable=self._auto_apply)
self._auto_apply_button.pack(expand=False, fill='none', anchor='n',
side='top')
self._min_max_label = tk.Label(self._info_frame,
textvariable=self._min_max_pix_txt)
self._min_max_label.pack(expand=False, fill='none', anchor='n', side='top')
self._bits_label = tk.Label(self._info_frame, textvariable=self._bits_txt)
self._bits_label.pack(expand=False, fill='none', anchor='n', side='top')
self._zoom_label = tk.Label(self._info_frame, textvariable=self._zoom_txt)
self._zoom_label.pack(expand=False, fill='none', anchor='n', side='top')
self._reticle_label = tk.Label(self._info_frame,
textvariable=self._reticle_txt)
self._reticle_label.pack(expand=False, fill='none', anchor='n', side='top')
# The frame containing the settings, the message and the update button
self._sets_frame = tk.Frame(self._text_frame)
self._sets_frame.pack(expand=True, fill='both', anchor='e', side='top')
# Tha label warning the user
self._validate_text = tk.Label(
self._sets_frame,
text='To validate the choice of the settings and start the test, simply '
'close this window.',
fg='#f00', wraplength=300)
self._validate_text.pack(expand=False, fill='none', ipadx=5, ipady=5,
padx=5, pady=5, anchor='n', side='top')
# The update button
self._create_buttons()
# The frame containing the settings
self._settings_frame = tk.Frame(self._sets_frame,
highlightbackground='black',
highlightthickness=1)
self._settings_frame.pack(expand=True, fill='both', anchor='n', side='top')
# The canvas containing the settings
self._settings_canvas = tk.Canvas(self._settings_frame)
self._settings_canvas.pack(expand=True, fill='both', anchor='w',
side='left')
self._canvas_frame = tk.Frame(self._settings_canvas)
self._id = self._settings_canvas.create_window(
0, 0, window=self._canvas_frame, anchor='nw',
width=self._settings_canvas.winfo_reqwidth(), tags='canvas window')
# Creating the scrollbar
self._vbar = tk.Scrollbar(self._settings_frame, orient="vertical")
self._vbar.pack(expand=True, fill='y', side='right')
self._vbar.config(command=self._custom_yview)
# Associating the scrollbar with the settings canvas
self._settings_canvas.config(yscrollcommand=self._vbar.set)
def _create_buttons(self) -> None:
"""This method is meant to simplify the addition of extra buttons in
subclasses."""
self._update_button = tk.Button(self._sets_frame, text="Apply Settings",
command=self._update_settings)
self._update_button.pack(expand=False, fill='none', ipadx=5, ipady=5,
padx=5, pady=5, anchor='n', side='top')
def _custom_yview(self, *args) -> None:
"""Custom handling of the settings canvas scrollbar, that does nothing
if the entire canvas is already visible."""
if self._settings_canvas.yview() == (0., 1.):
return
self._settings_canvas.yview(*args)
def _set_bindings(self) -> None:
"""Sets the bindings for the different events triggered by the user."""
self.log(logging.DEBUG, "Setting the interface bindings")
# Bindings for the settings canvas
self._settings_canvas.bind("<Configure>", self._configure_canvas)
self._settings_frame.bind('<Enter>', self._bind_mouse)
self._settings_frame.bind('<Leave>', self._unbind_mouse)
# Different mousewheel handling depending on the platform
if system() == "Linux":
self._img_canvas.bind('<4>', self._on_wheel_img)
self._img_canvas.bind('<5>', self._on_wheel_img)
else:
self._img_canvas.bind('<MouseWheel>', self._on_wheel_img)
# Bindings for the image canvas
self._img_canvas.bind('<Motion>', self._update_coord)
self._img_canvas.bind('<ButtonPress-3>', self._start_move)
self._img_canvas.bind('<B3-Motion>', self._move)
# It's more efficient to bind the resizing to the graphical frame
self._graphical_frame.bind("<Configure>", self._on_img_resize)
self._graphical_frame.bind("<Configure>", self._on_hist_resize)
def _bind_mouse(self, _: tk.Event) -> None:
"""Binds the mousewheel to the settings canvas scrollbar when the user
hovers over the canvas."""
self.log(logging.DEBUG, "Binding the mouse to the image canvas")
if system() == "Linux":
self._settings_frame.bind_all('<4>', self._on_wheel_settings)
self._settings_frame.bind_all('<5>', self._on_wheel_settings)
else:
self._settings_frame.bind_all('<MouseWheel>', self._on_wheel_settings)
def _unbind_mouse(self, _: tk.Event) -> None:
"""Unbinds the mousewheel to the settings canvas scrollbar when the mouse
leaves the canvas."""
self.log(logging.DEBUG, "Unbinding the mouse from the image canvas")
self._settings_frame.unbind_all('<4>')
self._settings_frame.unbind_all('<5>')
self._settings_frame.unbind_all('<MouseWheel>')
def _configure_canvas(self, event: tk.Event) -> None:
"""Adjusts the size of the scrollbar according to the size of the settings
canvas whenever it is being resized."""
self.log(logging.DEBUG, "The image canvas has been resized")
# Adjusting the height of the settings window inside the canvas
self._settings_canvas.itemconfig(
self._id, width=event.width,
height=self._canvas_frame.winfo_reqheight())
# Setting the scroll region according to the height of the settings window
self._settings_canvas.configure(
scrollregion=(0, 0, self._canvas_frame.winfo_reqwidth(),
self._canvas_frame.winfo_reqheight()))
def _on_wheel_settings(self, event: tk.Event) -> None:
"""Scrolls the canvas up or down upon wheel motion."""
# Do nothing if the entire canvas is already visible
if self._settings_canvas.yview() == (0., 1.):
return
# Different wheel management in Windows and Linux
if system() == "Linux":
delta = 1 if event.num == 4 else -1
else:
delta = int(event.delta / abs(event.delta))
self._settings_canvas.yview_scroll(-delta, "units")
def _on_wheel_img(self, event: tk.Event) -> None:
"""Zooms in or out on the image upon mousewheel motion.
Handles the specific cases when the mouse is not on the image, or the
maximum or minimum zoom levels are reached.
"""
# If the mouse is on the canvas but not on the image, do nothing
if not self._check_event_pos(event):
return
self.log(logging.DEBUG, "Zooming on the canvas")
pil_width = self._pil_img.width
pil_height = self._pil_img.height
zoom_x_low, zoom_x_high = self._zoom_values.x_low, self._zoom_values.x_high
zoom_y_low, zoom_y_high = self._zoom_values.y_low, self._zoom_values.y_high
# Different wheel management in Windows and Linux
if system() == "Linux":
delta = 1 if event.num == 4 else -1
else:
delta = int(event.delta / abs(event.delta))
# Handling the cases when the minimum or maximum zoom levels are reached
self._zoom_step += delta
if self._zoom_step < 0:
self._zoom_step = 0
self._zoom_level.set(100)
return
elif self._zoom_step == 0:
self._zoom_values.reset()
self._zoom_level.set(100)
self._on_img_resize()
return
elif self._zoom_step > self._max_zoom_step:
self._zoom_step = self._max_zoom_step
self._zoom_level.set(100 * (1 / self._zoom_ratio) ** self._max_zoom_step)
return
# Correcting the event position to make it relative to the image and not
# the canvas
zero_x = (self._img_canvas.winfo_width() - pil_width) / 2
zero_y = (self._img_canvas.winfo_height() - pil_height) / 2
corr_x = event.x - zero_x
corr_y = event.y - zero_y
# The position of the mouse on the image as a ratio between 0 and 1
x_ratio = corr_x * (zoom_x_high - zoom_x_low) / pil_width
y_ratio = corr_y * (zoom_y_high - zoom_y_low) / pil_height
# Updating the upper and lower limits of the image on the display
ratio = self._zoom_ratio if delta < 0 else 1 / self._zoom_ratio
self._zoom_values.update_zoom(x_ratio, y_ratio, ratio)
# Redrawing the image and updating the information
self._on_img_resize()
self._zoom_level.set(100 * (1 / self._zoom_ratio) ** self._zoom_step)
def _update_coord(self, event: tk.Event) -> None:
"""Updates the coordinates of the pixel pointed by the mouse on the
image."""
self.log(logging.DEBUG, "Updating the coordinates of the current pixel")
# If the mouse is on the canvas but not on the image, do nothing
if not self._check_event_pos(event):
return
x_coord, y_coord = self._coord_to_pix(event.x, event.y)
self._x_pos.set(x_coord)
self._y_pos.set(y_coord)
self._update_pixel_value()
def _update_pixel_value(self) -> None:
"""Updates the display of the gray level value of the pixel currently being
pointed by the mouse."""
self.log(logging.DEBUG, "Updating the value of the current pixel")
try:
self._reticle_val.set(np.average(self._original_img[self._y_pos.get(),
self._x_pos.get()]))
except IndexError:
self._x_pos.set(0)
self._y_pos.set(0)
self._reticle_val.set(np.average(self._original_img[self._y_pos.get(),
self._x_pos.get()]))
def _coord_to_pix(self, x: int, y: int) -> Tuple[int, int]:
"""Converts the coordinates of the mouse in the GUI referential to
coordinates on the original image."""
pil_width = self._pil_img.width
pil_height = self._pil_img.height
zoom_x_low, zoom_x_high = self._zoom_values.x_low, self._zoom_values.x_high
zoom_y_low, zoom_y_high = self._zoom_values.y_low, self._zoom_values.y_high
img_height, img_width, *_ = self._img.shape
# Correcting the event position to make it relative to the image and not
# the canvas
zero_x = (self._img_canvas.winfo_width() - pil_width) / 2
zero_y = (self._img_canvas.winfo_height() - pil_height) / 2
corr_x = x - zero_x
corr_y = y - zero_y
# Convert the relative coordinate of the mouse on the display to coordinate
# of the mouse on the original image
x_disp = corr_x / pil_width * (zoom_x_high - zoom_x_low) * img_width
y_disp = corr_y / pil_height * (zoom_y_high - zoom_y_low) * img_height
# The coordinate of the upper left corner of the displayed image
# (potentially zoomed) on the original image
x_trim = zoom_x_low * img_width
y_trim = zoom_y_low * img_height
return min(int(x_disp + x_trim),
img_width - 1), min(int(y_disp + y_trim), img_height - 1)
def _start_move(self, event: tk.Event) -> None:
"""Stores the position of the mouse upon left-clicking on the image."""
# If the mouse is on the canvas but not on the image, do nothing
if not self._check_event_pos(event):
return
self.log(logging.DEBUG, "Drag started")
# Stores the position of the mouse relative to the top left corner of the
# image
zero_x = (self._img_canvas.winfo_width() - self._pil_img.width) / 2
zero_y = (self._img_canvas.winfo_height() - self._pil_img.height) / 2
self._move_x = event.x - zero_x
self._move_y = event.y - zero_y
def _move(self, event: tk.Event) -> None:
"""Drags the image upon prolonged left-clik and drag from the user."""
# If the mouse is on the canvas but not on the image, do nothing
if not self._check_event_pos(event):
return
self.log(logging.DEBUG, "Drag ended")
pil_width = self._pil_img.width
pil_height = self._pil_img.height
zoom_x_low, zoom_x_high = self._zoom_values.x_low, self._zoom_values.x_high
zoom_y_low, zoom_y_high = self._zoom_values.y_low, self._zoom_values.y_high
# Getting the position delta, in the coordinates of the display
zero_x = (self._img_canvas.winfo_width() - pil_width) / 2
zero_y = (self._img_canvas.winfo_height() - pil_height) / 2
delta_x_disp = self._move_x - (event.x - zero_x)
delta_y_disp = self._move_y - (event.y - zero_y)
# Converting the position delta to a ratio between 0 and 1 relative to the
# size of the original image
delta_x = delta_x_disp * (zoom_x_high - zoom_x_low) / pil_width
delta_y = delta_y_disp * (zoom_y_high - zoom_y_low) / pil_height
# Actually updating the display
self._zoom_values.update_move(delta_x, delta_y)
# Resetting the original position, otherwise the drag never ends
self._move_x = event.x - zero_x
self._move_y = event.y - zero_y
def _check_event_pos(self, event: tk.Event) -> bool:
"""Checks whether the mouse is on the image, and not between the image and
the border of the canvas. Returns :obj:`True` if it is on the image,
:obj:`False` otherwise."""
if self._pil_img is None:
return False
if abs(event.x -
self._img_canvas.winfo_width() / 2) > self._pil_img.width / 2:
return False
if abs(event.y -
self._img_canvas.winfo_height() / 2) > self._pil_img.height / 2:
return False
return True
def _add_settings(self) -> None:
"""Adds the settings of the camera to the GUI."""
self.log(logging.DEBUG, "Adding the camera settings to the interface")
# First, sort the settings by type for a nicer display
sort_sets = sorted(self._camera.settings.values(),
key=lambda setting: setting.type.__name__)
for cam_set in sort_sets:
if isinstance(cam_set, CameraBoolSetting):
self._add_bool_setting(cam_set)
elif isinstance(cam_set, CameraScaleSetting):
self._add_slider_setting(cam_set)
elif isinstance(cam_set, CameraChoiceSetting):
self._add_choice_setting(cam_set)
def _add_bool_setting(self, cam_set: CameraBoolSetting) -> None:
"""Adds a setting represented by a checkbutton."""
self.log(logging.DEBUG, f"Adding the boolean setting {cam_set.name}")
cam_set.tk_var = tk.BooleanVar(value=cam_set.value)
cam_set.tk_obj = tk.Checkbutton(self._canvas_frame,
text=cam_set.name,
variable=cam_set.tk_var,
command=self._auto_apply_bool_settings)
cam_set.tk_obj.pack(anchor='w', side='top', expand=False, fill='none',
padx=5, pady=2)
def _add_slider_setting(self, cam_set: CameraScaleSetting) -> None:
"""Adds a setting represented by a scale bar."""
self.log(logging.DEBUG, f"Adding the slider setting {cam_set.name}")
# The scale bar is slightly different if the setting type is int or float
if cam_set.type == int:
cam_set.tk_var = tk.IntVar(value=cam_set.value)
else:
cam_set.tk_var = tk.DoubleVar(value=cam_set.value)
cam_set.tk_obj = tk.Scale(self._canvas_frame,
label=f'{cam_set.name} :',
variable=cam_set.tk_var,
resolution=cam_set.step,
orient='horizontal',
from_=cam_set.lowest,
to=cam_set.highest)
cam_set.tk_obj.bind("<ButtonRelease-1>", self._auto_apply_scale_settings)
cam_set.tk_obj.pack(anchor='center', side='top', expand=False,
fill='x', padx=5, pady=2)
def _add_choice_setting(self, cam_set: CameraChoiceSetting) -> None:
"""Adds a setting represented by a list of radio buttons."""
self.log(logging.DEBUG, f"Adding the choice setting {cam_set.name}")
cam_set.tk_var = tk.StringVar(value=cam_set.value)
label = tk.Label(self._canvas_frame, text=f'{cam_set.name} :')
label.pack(anchor='w', side='top', expand=False, fill='none',
padx=12, pady=2)
for value in cam_set.choices:
tk_obj = tk.Radiobutton(self._canvas_frame,
text=value,
variable=cam_set.tk_var,
value=value,
command=self._auto_apply_choice_settings)
tk_obj.pack(anchor='w', side='top', expand=False,
fill='none', padx=5, pady=2)
cam_set.tk_obj.append(tk_obj)
def _set_variables(self) -> None:
"""Sets the text and numeric variables holding information about the
display."""
self.log(logging.DEBUG, "Setting the interface variables")
# The FPS counter
self._fps_var = tk.DoubleVar(value=0.)
self._fps_txt = tk.StringVar(
value=f'fps = {self._fps_var.get():.2f}\n(might be lower in this GUI '
f'than actual)')
# The variable for enabling or disabling the auto range
self._auto_range = tk.BooleanVar(value=False)
# The variable for enabling or disabling the auto apply
self._auto_apply = tk.BooleanVar(value=False)
# The minimum and maximum pixel value counters
self._min_pixel = tk.IntVar(value=0)
self._max_pixel = tk.IntVar(value=0)
self._min_max_pix_txt = tk.StringVar(
value=f'min: {self._min_pixel.get():d}, '
f'max: {self._max_pixel.get():d}')
# The number of detected bits counter
self._nb_bits = tk.IntVar(value=0)
self._bits_txt = tk.StringVar(
value=f'Detected bits: {self._nb_bits.get():d}')
# The display of the current zoom level
self._zoom_level = tk.DoubleVar(value=100.0)
self._zoom_txt = tk.StringVar(
value=f'Zoom: {self._zoom_level.get():.1f}%')
# The display of the current pixel position and value
self._x_pos = tk.IntVar(value=0)
self._y_pos = tk.IntVar(value=0)
self._reticle_val = tk.IntVar(value=0)
self._reticle_txt = tk.StringVar(value=f'X: {self._x_pos.get():d}, '
f'Y: {self._y_pos.get():d}, '
f'V: {self._reticle_val.get():d}')
def _set_traces(self) -> None:
"""Sets the traces for automatically updating the display when a variable
is modified."""
self.log(logging.DEBUG, "Setting the interface traces")
self._fps_var.trace_add('write', self._update_fps)
self._min_pixel.trace_add('write', self._update_min_max)
self._max_pixel.trace_add('write', self._update_min_max)
self._nb_bits.trace_add('write', self._update_bits)
self._zoom_level.trace_add('write', self._update_zoom)
self._x_pos.trace_add('write', self._update_reticle)
self._y_pos.trace_add('write', self._update_reticle)
self._reticle_val.trace_add('write', self._update_reticle)
self._auto_apply.trace_add('write', self._update_apply_settings)
def _update_fps(self, _, __, ___) -> None:
"""Auto-update of the FPS display."""
self._fps_txt.set(f'fps = {self._fps_var.get():.2f}\n'
f'(might be lower in this GUI than actual)')
def _update_min_max(self, _, __, ___) -> None:
"""Auto-update of the minimum and maximum pixel values display."""
self._min_max_pix_txt.set(f'min: {self._min_pixel.get():d}, '
f'max: {self._max_pixel.get():d}')
def _update_bits(self, _, __, ___) -> None:
"""Auto-update of the number of detected bits display."""
self._bits_txt.set(f'Detected bits: {self._nb_bits.get():d}')
def _update_zoom(self, _, __, ___) -> None:
"""Auto-update of the current zoom level display."""
self._zoom_txt.set(f'Zoom: {self._zoom_level.get():.1f}%')
def _update_reticle(self, _, __, ___) -> None:
"""Auto-update of the current pixel position and value display."""
self._reticle_txt.set(f'X: {self._x_pos.get():d}, '
f'Y: {self._y_pos.get():d}, '
f'V: {self._reticle_val.get():d}')
def _update_apply_settings(self, _, __, ___) -> None:
"""Disable the Apply Settings button when Auto apply button is checked."""
if self._auto_apply.get():
self._update_button['state'] = 'disabled'
else:
self._update_button['state'] = 'normal'
def _update_settings(self) -> None:
"""Tries to update the settings values upon clicking on the Apply Settings
button, and checks that the settings have been correctly set."""
for setting in self._camera.settings.values():
# Applying all the settings that differ from the read value
if setting.value != setting.tk_var.get():
setting.value = setting.tk_var.get()
# Reading the actual value of all the settings
setting.tk_var.set(setting.value)
def _auto_apply_scale_settings(self, _: tk.Event):
"""Applies the settings without clicking on the Apply Settings
button when the Auto apply button is checked.
The scale settings will be applied when the slicer is released.
"""
if self._auto_apply.get():
self._update_settings()
def _auto_apply_bool_settings(self):
"""Applies the settings without clicking on the Apply Settings
button when the Auto apply button is checked.
The bool settings will be applied when the bool button is checked.
"""
if self._auto_apply.get():
self._update_settings()
def _auto_apply_choice_settings(self):
"""Applies the settings without clicking on the Apply Settings
button when the Auto apply button is checked.
The choice settings will be applied when the choice button is checked.
"""
if self._auto_apply.get():
self._update_settings()
def _cast_img(self, img: np.ndarray) -> None:
"""Casts the image to 8-bits as a greater precision is not required.
May also interpolate the image to obtain a higher contrast, depending on
the user's choice.
"""
# First, convert BGR to RGB
if len(img.shape) == 3:
img = img[:, :, ::-1]
# If the auto_range is set, adjusting the values to the range
if self._auto_range.get():
self.log(logging.DEBUG, "Applying auto range to the image")
self._low_thresh, self._high_thresh = np.percentile(img, (3, 97))
self._img = ((np.clip(img, self._low_thresh, self._high_thresh) -
self._low_thresh) * 255 /
(self._high_thresh - self._low_thresh)).astype('uint8')
# The original image still needs to be saved as 8-bits
bit_depth = np.ceil(np.log2(np.max(img) + 1))
self._original_img = (img / 2 ** (bit_depth - 8)).astype('uint8')
# Or if the image is not already 8 bits, casting to 8 bits
elif img.dtype != np.uint8:
self.log(logging.DEBUG, "Casting the image to 8 bits")
bit_depth = np.ceil(np.log2(np.max(img) + 1))
self._img = (img / 2 ** (bit_depth - 8)).astype('uint8')
self._original_img = np.copy(self._img)
# Else, the image is usable as is
else:
self._img = img
self._original_img = np.copy(img)
# Updating the information
self._nb_bits.set(int(np.ceil(np.log2(np.max(img) + 1))))
self._max_pixel.set(int(np.max(img)))
self._min_pixel.set(int(np.min(img)))
def _resize_img(self) -> None:
"""Resizes the received image so that it fits in the image canvas and
complies with the chosen zoom level."""
if self._img is None:
return
self.log(logging.DEBUG, "Resizing the image to fit in the window")
# First, apply the current zoom level
# The width and height values are inverted in NumPy
img_height, img_width, *_ = self._img.shape
y_min_pix = int(img_height * self._zoom_values.y_low)
y_max_pix = int(img_height * self._zoom_values.y_high)
x_min_pix = int(img_width * self._zoom_values.x_low)
x_max_pix = int(img_width * self._zoom_values.x_high)
zoomed_img = self._img[y_min_pix: y_max_pix, x_min_pix: x_max_pix]
# Creating the pillow image from the zoomed numpy array
pil_img = Image.fromarray(zoomed_img)
# Resizing the image to make it fit in the image canvas
img_canvas_width = self._img_canvas.winfo_width()
img_canvas_height = self._img_canvas.winfo_height()
zoomed_img_ratio = pil_img.width / pil_img.height
img_label_ratio = img_canvas_width / img_canvas_height
if zoomed_img_ratio >= img_label_ratio:
new_width = img_canvas_width
new_height = max(int(img_canvas_width / zoomed_img_ratio), 1)
else:
new_width = max(int(img_canvas_height * zoomed_img_ratio), 1)
new_height = img_canvas_height
self._pil_img = pil_img.resize((new_width, new_height))
def _display_img(self) -> None:
"""Displays the image in the center of the image canvas."""
if self._pil_img is None:
return
self.log(logging.DEBUG, "Displaying the image")
self._image_tk = ImageTk.PhotoImage(self._pil_img)
self._img_canvas.create_image(int(self._img_canvas.winfo_width() / 2),
int(self._img_canvas.winfo_height() / 2),
anchor='center', image=self._image_tk)
def _on_img_resize(self, _: Optional[tk.Event] = None) -> None:
"""Resizes the image and updates the display when the zoom level has
changed or the GUI has been resized."""
self.log(logging.DEBUG, "The image canvas was resized")
self._resize_img()
self._display_img()
self.update()
def _calc_hist(self) -> None:
"""Calculates the histogram of the current image."""
if self._original_img is None:
return
# Don't calculate histogram if a calculation is already running
if self._processing_event.is_set():
self.log(logging.DEBUG, "A calculation is running for the histogram, "
"not sending image for calculation")
return
# If no calculation is running, sending a new image for calculation
else:
# Reshaping the image before sending to the histogram process
self.log(logging.DEBUG, "Preparing image for histogram calculation")
hist_img = Image.fromarray(self._original_img)
if hist_img.width > 320 or hist_img.height > 240:
factor = min(320 / hist_img.width, 240 / hist_img.height)
hist_img = hist_img.resize((max(int(hist_img.width * factor), 1),
max(int(hist_img.height * factor), 1)))
# The histogram is calculated on a grey level image
if len(self._original_img.shape) == 3:
hist_img = hist_img.convert('L')
# Sending the image to the histogram process
self.log(logging.DEBUG, "Sending image for histogram calculation")
self._img_in.put_nowait((hist_img, self._auto_range.get(),
self._low_thresh, self._high_thresh))
# Checking if a histogram is available for display
while not self._img_out.empty():
self._hist = self._img_out.get_nowait()
self.log(logging.DEBUG, "Received histogram from histogram process")
def _resize_hist(self) -> None:
"""Resizes the histogram image to make it fit in the GUI."""
if self._hist is None:
return
self.log(logging.DEBUG, "Resizing the histogram to fit in the window")
pil_hist = Image.fromarray(self._hist)
hist_canvas_width = self._hist_canvas.winfo_width()
hist_canvas_height = self._hist_canvas.winfo_height()
self._pil_hist = pil_hist.resize((hist_canvas_width, hist_canvas_height))
def _display_hist(self) -> None:
"""Displays the histogram image in the GUI."""
if self._pil_hist is None:
return
self.log(logging.DEBUG, "Displaying the histogram")
self._hist_tk = ImageTk.PhotoImage(self._pil_hist)
self._hist_canvas.create_image(int(self._hist_canvas.winfo_width() / 2),
int(self._hist_canvas.winfo_height() / 2),
anchor='center', image=self._hist_tk)
def _on_hist_resize(self, _: tk.Event) -> None:
"""Resizes the histogram and updates the display when the GUI has been
resized."""
self._resize_hist()
self._display_hist()
self.update()
def _update_img(self) -> None:
"""Acquires an image from the camera, casts and resizes it, calculates its
histogram, displays them and updates the image information."""
self.log(logging.DEBUG, "Updating the image")
ret = self._camera.get_image()
# Flag raised if no image could be grabbed
no_img = False
# If no frame could be grabbed from the camera
if ret is None:
no_img = True
# If it's the first call, generate error image to initialize the window
if not self._n_loops:
self.log(logging.WARNING, "Could not get an image from the camera, "
"displaying an error image instead")
ret = None, np.array(Image.open(BytesIO(resource_string(
'crappy', 'tool/data/no_image.png'))))
# Otherwise, just pass
else:
self.log(logging.DEBUG, "No image returned by the camera")
self.update()
sleep(0.001)
return
self._n_loops += 1
_, img = ret
if not no_img and img.dtype != self.dtype:
self.dtype = img.dtype
if not no_img and img.shape != self.shape:
self.shape = img.shape
self._cast_img(img)
self._resize_img()
self._calc_hist()
self._resize_hist()
self._display_img()
self._display_hist()
self._update_pixel_value()
self.update()