Commit 432d6483 authored by Wallenfang, Nils's avatar Wallenfang, Nils

Merge branch 'master' into 'data_processing'

# Conflicts:
#   quality_check_gui.py
parents 893db35f 34ec7b23
......@@ -5,18 +5,26 @@
Tasks related to the quality check algorithms.
* [ ] automatically filter tiles without tissue
## Previews
- [X] Put font file in resources directory
- [x] Add checkbox to mark outlier pixels in preview
- Also save clean preview
- [X] Save marked preview in preview directory
## GUI / UX
* [ ] add default config file that is read when no `config.ini` exists
* [X] add default config file that is read when no `config.ini` exists
- push this default config to repo instead of specifig config
- [ ] implement a logging functionality
- [ ] use separate GUI and logic threads to prevent stutter
- [X] implement a logging functionality
- [X] use separate GUI and logic threads to prevent stutter
## Technical
- [ ] validate config input
- [ ] add requirements.txt or setup.py
- [ ] use default config file that is used when no config is found
- [X] use default config file that is used when no config is found
# DONE
- [X] introduce pixel sampling quality check
\ No newline at end of file
- [X] introduce pixel sampling quality check
- [X] automatically filter tiles without tissue
\ No newline at end of file
......@@ -51,3 +51,47 @@ def analyze_section(root_path, quality_check, on_tile_done=None):
on_tile_done(x, y, is_outlier, error_measure)
return error_measure_matrix
def analyze_section_with_coords(root_path, quality_check, on_tile_done=None):
"""
Args:
root_path (str): Path of the root directory of the raw data
quality_check (QualityCheck): object of type SineCheck that defines an error measure for each tile.
on_tile_done (function(int, int, list)): Callback function. If passed, this function
will be called after every processed tile. The function will be called with the x- and
y-indices of the tile as int and a list that contains outlier image indices (or is empty).
Returns:
[np.array()]: 2d-array with dimensions corresponding to number of tiles. Each entry
of the 2d-array is a float with the error measure of the respective tile.
[np.array()]: TODO
"""
# get last (alphabetic ordering) directory name in root_path to extract x- and y-dimension
directories = sorted([f for f in listdir(root_path) if isdir(join(root_path, f))])
last_dir = directories[-1]
xdim, ydim = last_dir.split("_")
# tiles are zero indexed
xdim, ydim = int(xdim) + 1, int(ydim) + 1
error_measure_matrix = np.zeros(shape=(xdim, ydim))
outlier_matrix = np.empty(shape=(xdim, ydim), dtype=object)
filtered_matrix = np.empty(shape=(xdim, ydim), dtype=object)
# iterate over each tile
for x in range(xdim):
for y in range(ydim):
# call analysis and save outlier indices
img_path = f"{root_path}/{str(x).zfill(2)}_{str(y).zfill(2)}/"
data = read_data(img_path)
error_measure, outlier_coords, filtered_coords = quality_check.calc_error_measure(data, return_coords=True)
error_measure_matrix[x, y] = error_measure
outlier_matrix[x, y] = outlier_coords
filtered_matrix[x, y] = filtered_coords
is_outlier = quality_check.threshold(error_measure)
if on_tile_done is not None:
on_tile_done(x, y, is_outlier, error_measure)
return error_measure_matrix, outlier_matrix, filtered_matrix
......@@ -71,7 +71,7 @@ def generate_preview(tiff_path, overlap=0.28, scaling=None, image_index=0, origi
# tiles are zero indexed
nx, ny = int(xdim) + 1, int(ydim) + 1
if scaling is None:
if scaling is None or scaling == "":
scaling = determine_scaling_factor(nx, ny, overlap)
# resolution of cropped tile (without overlapping region)
......@@ -126,7 +126,9 @@ def generate_preview(tiff_path, overlap=0.28, scaling=None, image_index=0, origi
def insert_labels(preview, xdim, ydim):
img = Image.fromarray(preview)
draw = ImageDraw.Draw(img)
# font = ImageFont.truetype(config['PREVIEWS']['font_path'], 12)
preview_tile_x = preview.shape[0] / xdim
font = ImageFont.truetype('resources/DejaVuSans.ttf', int(preview_tile_x*0.08))
# TODO scale font with resolution
# tile dimensions
res_x = int(preview.shape[0] / xdim)
......@@ -138,11 +140,77 @@ def insert_labels(preview, xdim, ydim):
# should be black or white
avgColor = np.mean(preview[x * res_x:x * res_x + 20, y * res_y:y * res_y + 20])
# draw.text((y * res_y, x * res_x), f"{x:02d}_{y:02d}", fill=(255 if avgColor > 1000 else 0))
draw.text((y * res_y, x * res_x), f"{xdim - x - 1:02d}_{ydim - y - 1:02d}", (2**12 if avgColor < 1200 else 0))
draw.text((y * res_y, x * res_x), f"{xdim - x - 1:02d}_{ydim - y - 1:02d}",
2 ** 12, font=font) # if avgColor < 1200 else 0
return np.asarray(img)
def mark_preview(preview, matrix, outlier_coords, filtered_coords):
"""
Draw outlier markings in a given preview image
Args:
preview: Preview image as array.
matrix: Error measure matrix with dimensions corresponding to number of tiles in x/y
outlier_coords: Coordinates of outlier pixels per tile (unscaled)
filtered_coords: Coordinates of filtered pixels per tile (unscaled)
Returns: Marked preview image as array.
"""
preview = preview.copy()
overlap = 0.28
res_x, res_y = preview.shape[0] // matrix.shape[0], preview.shape[1] // matrix.shape[1]
# width to be used for red rectangles and pixel markings
width = int(res_x / min(matrix.shape) * 0.05 + 0.5)
idx = np.argwhere((matrix < 0.775) & (matrix != -1))
img = Image.fromarray(preview / 2 ** 4).convert('RGB')
draw = ImageDraw.Draw(img)
full_x, full_y = preview.shape
print(idx)
# mark outlier tiles with red square
for x, y in idx:
draw.rectangle([
full_y - (y + 1) * res_y,
full_x - (x + 1) * res_x,
full_y - y * res_y - 1,
full_x - x * res_x - 1], outline='red', width=width)
# mark coords
for x in range(matrix.shape[0]):
for y in range(matrix.shape[1]):
coords = filtered_coords[x, y]
for coord in coords:
# TODO respect overlap!
y_from = int(full_y - (y + 1) * res_y + coord[1]/tile_y * res_y + 0.5)
x_from = int(full_x - (x + 1) * res_x + coord[0]/tile_x * res_x + 0.5)
y_to = y_from + width
x_to = x_from + width
# print(f'draw rectangle from ({y_from, x_from}) to ({y_to, x_to})')
draw.rectangle([
y_from, x_from, y_to, x_to
], fill='blue')
coords = outlier_coords[x, y]
if matrix[x, y] == -1:
continue
# mark outliers with red filled squares
for coord in coords:
# TODO respect overlap!
y_from = int(full_y - (y + 1) * res_y + coord[0, 1]/tile_y * res_y + 0.5)
x_from = int(full_x - (x + 1) * res_x + coord[0, 0]/tile_x * res_x + 0.5)
y_to = y_from + width
x_to = x_from + width
# print(f'draw rectangle from ({y_from, x_from}) to ({y_to, x_to})')
draw.rectangle([
y_from, x_from, y_to, x_to
], fill='red')
# mark filtered coords with blue filled squares
return np.array(img)
if __name__ == '__main__':
def main():
preview = generate_preview('/data/PLI-Group/Nils/stitching_minimal/')
......
......@@ -5,22 +5,83 @@ from tkinter import ttk
from utils.read_config import config
class ToolTip:
"""
see
https://stackoverflow.com/questions/20399243/display-message-when-hovering-over-something-with-mouse-cursor-in-python
"""
def __init__(self, widget):
self.widget = widget
self.tipwindow = None
self.id = None
self.text = None
self.x = self.y = 0
def showtip(self, text):
# Display text in tooltip window
self.text = text
if self.tipwindow or not self.text:
return
x, y, cx, cy = self.widget.bbox("insert")
x = x + self.widget.winfo_rootx() + 57
y = y + cy + self.widget.winfo_rooty() + 27
self.tipwindow = tw = tkinter.Toplevel(self.widget)
tw.wm_overrideredirect(1)
tw.wm_geometry("+%d+%d" % (x, y))
label = tkinter.Label(tw, text=self.text, justify=tkinter.LEFT,
background="#ffffe0", relief=tkinter.SOLID, borderwidth=1,
font=("tahoma", "8", "normal"))
label.pack(ipadx=1)
def hidetip(self):
tw = self.tipwindow
self.tipwindow = None
if tw:
tw.destroy()
def add_tooltip(widget, text):
toolTip = ToolTip(widget)
def enter(event):
toolTip.showtip(text)
def leave(event):
toolTip.hidetip()
widget.bind('<Enter>', enter)
widget.bind('<Leave>', leave)
class PreviewOptions(tkinter.LabelFrame):
def __init__(self, master):
super().__init__(master=master, text='Preview generation')
# add the state variable to master frame for ease of access
master.preview_state = tkinter.IntVar()
master.preview_state.set(1)
master.mark_outliers_state = tkinter.IntVar()
master.mark_outliers_state.set(0)
self.chk_preview = tkinter.Checkbutton(self, text=' Generate preview', variable=master.preview_state)
self.chk_mark_outliers = tkinter.Checkbutton(self, text=' Mark outliers', variable=master.mark_outliers_state)
master.overlap = tkinter.StringVar()
master.overlap.set('0.28')
master.scaling = tkinter.StringVar()
master.scaling.set('')
self.overlap_label = tkinter.Label(self, text='Overlap: ')
self.overlap_entry = tkinter.Entry(self, textvariable=master.overlap)
self.scaling_label = tkinter.Label(self, text='Scaling factor: ')
self.scaling_entry = tkinter.Entry(self, textvariable=master.scaling)
add_tooltip(self.scaling_label, 'use automatic scaling factor if left empty')
add_tooltip(self.scaling_entry, 'use automatic scaling factor if left empty')
self.chk_preview.grid(row=0, column=0)
self.overlap_label.grid(row=1, column=0)
self.overlap_entry.grid(row=1, column=1)
self.chk_preview.grid(row=0, column=0, sticky='W')
self.chk_mark_outliers.grid(row=1, column=0, sticky='W')
self.overlap_label.grid(row=2, column=0)
self.overlap_entry.grid(row=2, column=1)
self.scaling_label.grid(row=3, column=0)
self.scaling_entry.grid(row=3, column=1)
class QualityCheckOptions(tkinter.LabelFrame):
......@@ -41,8 +102,9 @@ class QualityCheckOptions(tkinter.LabelFrame):
self.dropdown = ttk.OptionMenu(self, master.dropdown_state, options[2], *options)
self.chk_quality.grid(row=0, column=0)
self.method_label.grid(row=1, column=0)
self.dropdown.grid(row=1, column=1)
if config.getboolean('GUI', 'debug'):
self.method_label.grid(row=1, column=0)
self.dropdown.grid(row=1, column=1)
class DirectoryChooser(tkinter.Frame):
......@@ -127,4 +189,4 @@ class GUI(tkinter.Frame):
if severity != 'debug':
self.result_text.insert('end', msg + '\n')
self.result_text.see(tkinter.END)
\ No newline at end of file
self.result_text.see(tkinter.END)
......@@ -43,7 +43,6 @@ def get_samples_grid(data, num=400):
"""
dim = data.shape[1:]
stack = np.zeros((num, data.shape[0]))
grid_length = int(np.sqrt(num))
for y in range(grid_length):
for x in range(grid_length):
......@@ -59,6 +58,33 @@ def get_samples_grid(data, num=400):
return stack
def get_samples_grid_coords(data, num=400):
"""
Get samples that are evenly distributed over picture.
"""
dim = data.shape[1:]
stack = np.zeros((num, data.shape[0]))
coords = []
grid_length = int(np.sqrt(num))
for y in range(grid_length):
for x in range(grid_length):
stack[y * grid_length + x, :] = data[:,
y * dim[0] // grid_length + dim[0] % grid_length,
x * dim[1] // grid_length + dim[1] % grid_length]
coords.append((y * dim[0] // grid_length + dim[0] % grid_length,
x * dim[1] // grid_length + dim[1] % grid_length))
# sample remainder randomly
for i in range(grid_length ** 2, num):
rand_x, rand_y = np.random.randint(0, data.shape[1]), np.random.randint(0, data.shape[2])
stack[i, :] = data[:, rand_x, rand_y]
coords.append((rand_y,
rand_x))
return stack, np.array(coords)
def get_samples_random(data, num=1):
dims = data.shape[1:]
stack = np.zeros((num, data.shape[0]))
......@@ -110,23 +136,22 @@ class SineCheck(QualityCheck):
self.measure_func = measure_func
self.threshold_func = threshold_func
def calc_error_measure(self, data, debug_plot=False):
def calc_error_measure(self, data, return_coords=False, debug_plot=False):
"""
For a given tile data (image stack) calculate an error measure that represents
how well the signal of each pixel over the image stack corresponds to a sine signal.
Args:
data: tile data (shape=(n, y_dim, x_dim))
return_coords (bool): return indices of outlier pixels for this tile in addition to error measure if set
to True.
debug_plot: bool for debugging purposes, if set to True display each sine fit along with the datapoints.
Returns:
Aggregated error measure (e.g. mean of all sine fit errors). This function also returns -1 if more than 30%
of fits had to be discarded because the signal was too weak.
"""
filtered_coords = [] # samples that have been ignored due to weakness of signal
errors = []
samples = get_samples_grid(data, num=self.num)
# number of samples that have been ignored due to weakness of signal
filtered_counter = 0
samples, coords = get_samples_grid_coords(data, num=self.num)
for i, sample in enumerate(samples):
try:
......@@ -138,22 +163,32 @@ class SineCheck(QualityCheck):
if fit.amplitude < 10:
# too much noise / not enough refraction
# ignore this sample
filtered_counter += 1
filtered_coords.append(coords[i])
continue
if fit.mean > 2000:
# very bright, likely to be outside of tissue
# ignore this sample
filtered_counter += 1
filtered_coords.append(coords[i])
continue
error = self.measure_func(sample, fit)
errors.append(error)
if filtered_counter / self.num > 0.775:
# more than 75% of samples ignored, unable to judge this tile
return -1
errors = np.array(errors)
if return_coords:
idx = np.argwhere(errors < 0.7)
outlier_coords = coords[idx]
if len(filtered_coords) / self.num > 0.775:
# more than 75% of samples ignored, unable to judge this tile
return -1, outlier_coords, filtered_coords
else:
return self.aggregate_func(errors), outlier_coords, filtered_coords
else:
return self.aggregate_func(np.array(errors))
if len(filtered_coords) / self.num > 0.775:
# more than 75% of samples ignored, unable to judge this tile
return -1
else:
return self.aggregate_func(errors)
def threshold(self, error_measure):
if error_measure == -1: # unable to evaluate
......
......@@ -6,6 +6,7 @@ from datetime import datetime
from os import listdir
from os.path import isdir
import logging
import threading
import numpy as np
import matplotlib.pyplot as plt
......@@ -13,9 +14,10 @@ import wx
from dateutil.tz import tzlocal
from wx.lib.agw.multidirdialog import (DD_DIR_MUST_EXIST, DD_MULTIPLE,
MultiDirDialog)
from PIL import Image
from analyze_section import analyze_section
from generate_preview import IndexOrigin, generate_preview
from analyze_section import analyze_section, analyze_section_with_coords
from generate_preview import IndexOrigin, generate_preview, mark_preview
from utils.upload_log import upload_log
from gui_components import GUI
from qchecks.zero_check import ZeroCheck
......@@ -23,8 +25,8 @@ from qchecks.sine_check import SineCheck
from qchecks.sine_measures import r2_measure
from utils.read_config import config
__version__ = '0.1.1'
__revision_date = '09.06.2020'
__version__ = '0.1.3'
__revision_date = '28.07.2020'
# TODO add file with python dependencies, especially opencv and wxpython, maybe add error handling if these libraries
......@@ -92,47 +94,62 @@ def get_logger_filename(dir_name):
def clicked_start(frame):
entry = frame.dir_frame.tiff_path.get()
for path in entry.split(';'):
norm = os.path.normpath(path)
# identify the section that is being analysed by its directory name
dir_name = norm.split(os.sep)[-2]
path = check_directory(path, frame)
if not path:
# invalid directory
continue
if frame.preview_state.get(): # generate previews
frame.progress_text.set(f'{dir_name}: Generating preview..')
frame.update()
try:
process_preview_gen(frame, path)
except ValueError as e:
frame.print('Invalid preview resolution provided, see config file "config.ini".')
# init logger
logger_path = get_logger_filename(dir_name)
frame.logger = logging.getLogger()
logging.basicConfig(level=logging.INFO, )
fh = logging.FileHandler(logger_path)
fh.setLevel(logging.DEBUG)
frame.logger.addHandler(fh)
if frame.quality_state.get(): # run quality check
frame.progress_text.set(f'{dir_name}: Running quality check..')
frame.update()
process_quality_check(frame, path, logger_path)
frame.progress_text.set('Done!')
def save_preview(preview, frame, tiff_path):
def logic_thread_task():
preview = None
for directory_entry in entry.split(';'):
paths = check_directory(directory_entry, frame)
if not paths:
# invalid directory
continue
for path in paths:
norm = os.path.normpath(path)
# identify the section that is being analysed by its directory name
dir_name = norm.split(os.sep)[-2]
frame.print(f'--- {dir_name} ---\n')
if frame.preview_state.get(): # generate previews
frame.progress_text.set(f'{dir_name}: Generating preview..')
frame.update()
try:
preview = process_preview_gen(frame, path)
except ValueError as e:
frame.print('Invalid preview resolution provided, see config file "config.ini".')
# init logger
log_path = get_logger_filename(dir_name)
frame.logger = logging.getLogger()
logging.basicConfig(level=logging.INFO, )
fh = logging.FileHandler(log_path)
fh.setLevel(logging.DEBUG)
frame.logger.addHandler(fh)
if frame.quality_state.get(): # run quality check
frame.progress_text.set(f'{dir_name}: Running quality check..')
frame.print(f'Running quality check..')
frame.update()
error_matrix, outlier_coords, filtered_coords = process_quality_check(frame, path)
frame.print(f'Saved logfile at "{log_path}".')
if frame.mark_outliers_state.get(): # generate preview with marked outliers
# TODO catch error when no preview was generated
marked_preview = mark_preview(preview, error_matrix, outlier_coords, filtered_coords)
norm = os.path.normpath(directory_entry)
# identify the section that is being analysed by its directory name
dir_name = norm.split(os.sep)[-2] + '_marked'
save_preview(marked_preview, frame, dir_name)
frame.print(f'')
frame.progress_text.set('Done!')
threading.Thread(target=logic_thread_task).start()
def save_preview(preview, frame, dir_name):
preview_root = config['PREVIEWS']['preview_path']
# save preview to a fixed path
time_stamp = datetime.now(tzlocal()).strftime('%Y%m%d_%H-%M')
norm = os.path.normpath(tiff_path)
# identify the section that is being analysed by its directory name
dir_name = norm.split(os.sep)[-2]
preview_path = f'{preview_root}/{dir_name}_{time_stamp}.png'
# TODO ensure no file overrides happen
......@@ -155,7 +172,7 @@ def process_preview_gen(frame, tiff_path):
frame.progress["value"] = frame.progress["value"] + 1
frame.progress.update()
# read overlap from gui
# read overlap from GUI
overlap = frame.overlap.get()
bad_overlap = False
try:
......@@ -167,17 +184,33 @@ def process_preview_gen(frame, tiff_path):
frame.print(f'Invalid overlap provided, using default value {default_overlap}.')
overlap = default_overlap
# read scaling factor from GUI
scaling = frame.scaling.get()
if not scaling == "":
bad_scaling = False
try:
scaling = float(scaling)
except ValueError:
bad_scaling = True
if bad_scaling or scaling <= 0:
frame.print(f'Invalid scaling factor provided, using automatic scaling.')
scaling = None
# benchmark time
time_pre = time.time()
preview = generate_preview(tiff_path, overlap=overlap, origin=IndexOrigin.BOTTOM_RIGHT,
preview = generate_preview(tiff_path, overlap=overlap, scaling=scaling, origin=IndexOrigin.BOTTOM_RIGHT,
on_tile_done=progressbar_callback, insert_index_labels=True)
time_post = time.time()
frame.print(f'Generated preview in {time_post - time_pre:.2f}s')
norm = os.path.normpath(tiff_path)
# identify the section that is being analysed by its directory name
dir_name = norm.split(os.sep)[-2]
save_preview(preview, frame, dir_name)
save_preview(preview, frame, tiff_path)
return preview
def process_quality_check(frame, tiff_path, logger_path):
......@@ -200,13 +233,13 @@ def process_quality_check(frame, tiff_path, logger_path):
frame.progress.update()
outlier_coords = None
filtered_coords = None
frame.progress["value"] = 0
if tiff_path is None:
tiff_path = frame.tiff_path.get()
frame.print(f"Analyzing section {tiff_path}")
# determine method to be used based on drop down menu
method_string = frame.dropdown_state.get()
......@@ -233,8 +266,11 @@ def process_quality_check(frame, tiff_path, logger_path):
# call the actual analysis method
time_pre = time.time()
error_matrix = analyze_section(
tiff_path, quality_check, on_tile_done=progressbar_callback)
if frame.mark_outliers_state.get() == 1:
error_matrix, outlier_coords, filtered_coords = analyze_section_with_coords(tiff_path, quality_check, on_tile_done=progressbar_callback)
else:
error_matrix = analyze_section(
tiff_path, quality_check, on_tile_done=progressbar_callback)
# save error matrix
# use name of section as file name for matrix
......@@ -270,6 +306,9 @@ def check_directory(tiff_path, frame):
tiff_path = os.path.join(tiff_path, directories[0])
directories = ([f for f in listdir(tiff_path) if isdir(os.path.join(tiff_path, f))])
# if there are multiple directories, return each one
# postpone check if every single subdirectory is valid
if not directories or len(directories[-1].split("_")) != 2:
frame.print(
f"{tiff_path}: Invalid directory, please select a root directory containing a subdir for each tile.")
......@@ -293,16 +332,20 @@ def check_directory(tiff_path, frame):
# reset progress bar
frame.progress["value"] = 0
return tiff_path
return [tiff_path]
def main():
tk = tkinter.Tk()
GUI(tk, clicked_start, clicked_browse)
# TODO add version / timestamp to some config or global variable
tk.title(f"PIL Quality Check - v{__version__} ({__revision_date})")
tk.title(f"PLI Quality Check - v{__version__} ({__revision_date})")
tk.grid_columnconfigure(0, weight=1)
tk.grid_rowconfigure(0, weight=1)
p1 = tkinter.PhotoImage(file='resources/icon.png')
# Setting icon of master window
tk.iconphoto(False, p1)
tk.mainloop()