Source code for ArrayViewer.Charts

"""
GraphWidget and ReshapeDialog for the ArrayViewer
"""
# Author: Alex Schwarz <alex.schwarz@informatik.tu-chemnitz.de>
import re
from itertools import combinations
from PyQt5.QtWidgets import (QCompleter, QDialog, QGridLayout, QLabel,
                             QLineEdit, QSizePolicy, QTextEdit, QVBoxLayout,
                             QWidget)
from PyQt5.QtWidgets import QDialogButtonBox as DBB
from PyQt5 import QtCore
from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg
from matplotlib.figure import Figure
from matplotlib.ticker import MultipleLocator as TickMultLoc
import numpy as np
from h5py._hl.dataset import Dataset

def _flat_with_padding(Array, padding=1, fill=np.nan):
    """ Flatten ND array into a 2D array and add a padding with given fill """
    # Reshape the array to 3D
    Arr = np.reshape(Array, Array.shape[:2] + (-1, ))
    if (Array.ndim == 4 and .18 < 1.0 * Array.shape[2] / Array.shape[3] < 5.5):
        # If the Array is 4D and has reasonable ratio, keep that ratio.
        rows = Array.shape[2]
    else:
        # Get the most equal division of the last dimension
        for n in range(int(np.sqrt(Arr.shape[2])), Arr.shape[2] + 1):
            if Arr.shape[2]%n == 0:
                rows = Arr.shape[2] / n
                break
    # Add the padding to the right and bottom of the arrays
    A0 = np.ones([padding, Arr.shape[1], Arr.shape[2]]) * fill
    A1 = np.ones([Arr.shape[0] + padding, padding, Arr.shape[2]]) * fill
    pArr = np.append(np.append(Arr, A0, axis=0), A1, axis=1)
    # Stack the arrays according to the precalculated number of rows
    pA2D = np.hstack(np.split(np.hstack(pArr.T).T, rows)).T
    # Add the padding to the left and top of the arrays
    A0 = np.ones([padding, pA2D.shape[1]]) * fill
    A1 = np.ones([pA2D.shape[0] + padding, padding]) * fill
    pA2D = np.append(A1, np.append(A0, pA2D, axis=0), axis=1)
    return pA2D


def _get_shape_from_str(string):
    """
    Returns an array with the elements of the string. All brackets are
    removed as well as empty elements in the array.
    """
    return np.array([_f for _f in string.strip("()[]").split(",") if _f],
                    dtype=int)

def _set_ticks(ax, s, transp, is1DPlot=False):
    """ Set the ticks of plots according to the selected slices. """
    # Calculate the ticks for the plot by checking the limits
    limits = [l.split(':') for l in s[1:-1].split(',') if ':' in l]
    lim = np.array([l if len(l) == 3 else l+['1'] for l in limits])
    lim[lim == ''] = '0'
    lim = lim.astype(float)
    if lim.shape[0] == 1:
        lim = np.append([[0, 0, 1]], lim, axis=0)
    if not transp:
        lim = lim[(1, 0), :]
    # Set the x-ticks
    loc = ax.xaxis.get_major_locator()()
    d = (np.arange(len(loc))-1)*(loc[2] - loc[1])*lim[0, 2]+lim[0, 0]
    if all(d.astype(int) == d.astype(float)):
        ax.set_xticklabels(d.astype(int))
    else:
        ax.set_xticklabels(d.astype(float))
    if is1DPlot:
        return
    # Set the y-ticks
    loc = ax.yaxis.get_major_locator()()
    d = (np.arange(len(loc))-1)*(loc[2] - loc[1])*lim[1, 2]+lim[1, 0]
    if all(d.astype(int) == d.astype(float)):
        ax.set_yticklabels(d.astype(int))
    else:
        ax.set_yticklabels(d.astype(float))

def _suggestion(previous_val, value):
    """ Returns all possible factors """
    pfactors = []
    divisor = 2
    while value > 1:
        while value % divisor == 0:
            pfactors.append(divisor)
            value /= divisor
        divisor += 1
        if divisor * divisor > value:
            if value > 1:
                pfactors.append(value)
            break
    factors = []
    for n in range(1, len(pfactors) + 1):
        for x in combinations(pfactors, n):
            y = 1
            for a in x:
                y = y * a
            factors.append(int(y))
    factors = list(set(factors))
    factors.sort(reverse=True)
    return [previous_val + "{0},".format(i) for i in factors]

[docs]class GraphWidget(QWidget): """ Draws the data graph. """ def __init__(self, parent=None): """ Initialize the figure. """ super(GraphWidget, self).__init__(parent) # Setup the canvas, figure and axes self._figure = Figure(facecolor='white') self._canv = FigureCanvasQTAgg(self._figure) self._canv.ax = self._figure.add_axes([.15, .15, .75, .75]) self._canv.canvas = self._canv.ax.figure.canvas self._canv.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.noPrintTypes = parent.noPrintTypes self._clim = (0, 1) self._img = None self._cb = None self.has_cb = False self.has_operation = False self._colormap = 'viridis' self._operation = 'None' self._opr = (lambda x: x) self._oprdim = np.array([], dtype=int) self._oprcorr = 'None' self.cutout = np.array([]) # Add a label Text that may be changed in later Versions to display the # position and value below the mouse pointer self._layout = QVBoxLayout(self) self._layout.addWidget(self._canv) self._txt = QLabel(self) self._txt.setText('') self._layout.addWidget(self._txt) def _n_D_plot(self, ax, ui): """ Plot multi-dimensional data. """ sh = self.cutout.shape nPad = sh[0] // 100 + 1 if ui.Plot3D.isChecked() and self.cutout.ndim == 3 and sh[2] == 3: nPad = -1 mm = [np.min(self.cutout), np.max(self.cutout)] dat = np.swapaxes((self.cutout - mm[0]) / (mm[1] - mm[0]), 0, 1) else: dat = _flat_with_padding(self.cutout, nPad) if (self.cutout.dtype == np.float16): dat = dat.astype(np.float32) self._img = ax.imshow(dat, interpolation='none', aspect='auto') locx = TickMultLoc(sh[0] + nPad) ax.xaxis.set_major_locator(locx) ax.xaxis.set_ticklabels(np.arange(-sh[0], int(locx().max()), sh[0])) locy = TickMultLoc(sh[1] + nPad) ax.yaxis.set_major_locator(locy) ax.yaxis.set_ticklabels(np.arange(-sh[1], int(locy().max()), sh[1])) def _two_D_plot(self, ui, ax, s): """ Plot 2-dimensional data. """ if ui.MMM.isChecked(): ax.plot(self.cutout.max(axis=0), 'r') ax.plot(self.cutout.mean(axis=0), 'k') ax.plot(self.cutout.min(axis=0), 'b') ax.legend(["Max", "Mean", "Min"]) else: dat = self.cutout.T if (self.cutout.dtype == np.float16): dat = dat.astype(np.float32) self._img = ax.imshow(dat, interpolation='none', aspect='auto') _set_ticks(ax, s, ui.Transp.isChecked()) def _n_D_scatter(self, ax): """ Plot up to four rows as a scatter (x, y, size, color)""" if self.cutout.shape[1] < 4: col = 'b' else: col = self.cutout[:, 3] - self.cutout[:, 3].min() col /= col.max() if self.cutout.shape[1] < 3: siz = 25 else: siz = self.cutout[:, 2] - self.cutout[:, 2].min() siz = 1 + 100 * siz / siz.max() self._img = ax.scatter(self.cutout[:, 0], self.cutout[:, 1], c=col, s=siz, cmap=self._colormap)
[docs] def clear(self): """ Clear the figure. """ self._cb = None self._figure.clf()
[docs] def colorbar(self, minmax=None): """ Add a colorbar to the graph or remove it, if it is existing. """ if self._img is None: return if minmax is not None and not isinstance(self._clim[0], bool): self._img.set_clim( vmin=minmax[0] * (self._clim[1] - self._clim[0]) + self._clim[0], vmax=minmax[1] * self._clim[1] ) if not self.has_cb: if self._cb: self._cb.remove() self._cb = None elif self._cb is None: self._cb = self._figure.colorbar(self._img) self._canv.draw()
[docs] def colormap(self, mapname=None): """ Replace colormap with the given one. """ if mapname: self._colormap = mapname if self._img is None: return if self._cb: self._cb.mappable.set_cmap(mapname) self._img.set_cmap(self._colormap) self._canv.draw()
[docs] def figure(self): """ Return the local figure variable. """ return self._figure
[docs] def has_opr(self): """ Check if the operation is None. """ return self.has_operation
[docs] def renewPlot(self, data, s, scalDims, ui): """ Draw given data. """ ax = self._figure.gca() ax.clear() # Reset the minimum and maximum text ui.txtMin.setText('min : ') ui.txtMax.setText('max : ') if data is None: return if isinstance(data, self.noPrintTypes): # Print strings or lists of strings to the graph directly ax.text(-0.1, 1.1, data, va='top', wrap=True) ax.axis('off') elif isinstance(data, Dataset) and data.shape == (): # Print single values of h5py arrays to the graph directly ax.text(-0.1, 1.1, data[()], va='top', wrap=True) ax.axis('off') elif isinstance(data[0], list): # If there is an array of lists plot each element as a graph for lst in data: ax.plot(lst) else: # Cut out the chosen piece of the array and plot it self.cutout = np.array([]) self.cutout = eval("data%s.squeeze()"%s) if len(self._oprdim) and not all(np.isin(self._oprdim, scalDims)): a = np.setdiff1d(self._oprdim, scalDims) self._oprcorr = str(tuple( b - (scalDims <= b).sum() for b in a )) self.cutout = self._opr(self.cutout) else: self._oprcorr = "None" # Transpose the first two dimensions if it is chosen if ui.Transp.isChecked() and self.cutout.ndim > 1: self.cutout = np.swapaxes(self.cutout, 0, 1) # Print the Value(s) directly if self.cutout.ndim == 0 or ui.PrintFlat.isChecked(): ax.set_ylim([0, 1]) ax.text(-0.1, 1.1, self.cutout, va='top', wrap=True) ax.axis('off') # Graph an 1D-cutout elif self.cutout.ndim == 1: ax.plot(self.cutout) _set_ticks(ax, s, False, True) alim = ax.get_ylim() if alim[0] > alim[1]: ax.invert_yaxis() # 2D-cutout will be shown using imshow, scatter or plot elif self.cutout.ndim == 2: if ui.Plot2D.isChecked(): if self.cutout.shape[1] > 500: msg = "You are trying to plot more than 500 lines!" ui.info_msg(msg, -1) return ax.plot(self.cutout) _set_ticks(ax, s, not ui.Transp.isChecked(), True) elif ui.PlotScat.isChecked() and self.cutout.shape[1] <= 4: self._n_D_scatter(ax) else: self._two_D_plot(ui, ax, s) # higher-dimensional cutouts will first be flattened elif self.cutout.ndim >= 3: self._n_D_plot(ax, ui) # Reset the colorbar. A better solution would be possible, if the # axes were not cleared everytime. self.colorbar() self.colormap() if self.cutout.size > 0: self._clim = (self.cutout.min(), self.cutout.max()) # Set the minimum and maximum values from the data ui.txtMin.setText('min : ' + "%0.5f"%self._clim[0]) ui.txtMax.setText('max : ' + "%0.5f"%self._clim[1]) self._canv.draw()
[docs] def set_operation(self, operation="None"): """ Set an operation to be performed on click on a dimension. """ self.has_operation = (operation != "None") if not self.has_operation: self._oprdim = np.array([], dtype=int) self._opr = (lambda x: x) else: self._opr = (lambda x: eval("np." + operation + "(x, axis=" + self._oprcorr + ")")) return self._oprdim
[docs] def set_oprdim(self, value): """ Set the operation dimension. """ if value == -1: self._oprdim = np.array([], dtype=int) else: if value in self._oprdim: self._oprdim = self._oprdim[self._oprdim != value] else: self._oprdim = np.append(self._oprdim, value) if self.has_operation: return self._oprdim else: return []
[docs] def toggle_colorbar(self): """ Toggle the state of the colorbar """ self.has_cb = not self.has_cb self.colorbar()
[docs]class ReshapeDialog(QDialog): """ A Dialog for Reshaping the Array. """ def __init__(self, parent=None): """ Initialize. """ super(ReshapeDialog, self).__init__(parent) # Setup the basic window self.resize(400, 150) self.setWindowTitle("Reshape the current array") self.prodShape = 0 self.info_msg = parent.info_msg gridLayout = QGridLayout(self) # Add the current and new shape boxes and their labels curShape = QLabel(self) curShape.setText("current shape") gridLayout.addWidget(curShape, 0, 0, 1, 1) self.txtCurrent = QLineEdit(self) self.txtCurrent.setEnabled(False) gridLayout.addWidget(self.txtCurrent, 0, 1, 1, 1) newShape = QLabel(self) newShape.setText("new shape") gridLayout.addWidget(newShape, 1, 0, 1, 1) self.txtNew = QLineEdit(self) self.txtNew.textEdited.connect(self._key_press) self.cmpl = QCompleter([]) self.cmpl.setCompletionMode(QCompleter.UnfilteredPopupCompletion) self.txtNew.setCompleter(self.cmpl) gridLayout.addWidget(self.txtNew, 1, 1, 1, 1) # Add a button Box with "OK" and "Cancel"-Buttons self.buttonBox = DBB(DBB.Cancel|DBB.Ok, QtCore.Qt.Horizontal) gridLayout.addWidget(self.buttonBox, 3, 1, 1, 1) self.buttonBox.button(DBB.Cancel).clicked.connect(self.reject) self.buttonBox.button(DBB.Ok).clicked.connect(self.accept) def _key_press(self, keyEv): """ Whenever a key is pressed check for comma and set autofill data.""" if keyEv and keyEv[-1] == ',': shape = _get_shape_from_str(str(keyEv)) if self.prodShape%shape.prod() == 0: rest = self.prodShape // shape.prod() self.cmpl.model().setStringList(_suggestion(keyEv, rest)) else: self.cmpl.model().setStringList([keyEv + " Not fitting"]) return keyEv
[docs] def reshape_array(self, data): """ Reshape the currently selected array. """ while True: # Open a dialog to reshape self.txtCurrent.setText(str(data.shape)) self.prodShape = np.array(data.shape).prod() self.txtNew.setText("") # If "OK" is pressed if data.shape and self.exec_(): # Get the shape sting and split it sStr = str(self.txtNew.text()) if sStr == "": continue # Try if the array could be reshaped that way try: data = np.reshape(data, _get_shape_from_str(sStr)) # If it could not be reshaped, get another user input except ValueError: self.info_msg("Data could not be reshaped!", -1) continue return data, _get_shape_from_str(sStr) # If "CANCEL" is pressed return data, None
[docs]class NewDataDialog(QDialog): """ A Dialog for Creating new Data. """ def __init__(self, parent=None): """ Initialize. """ super(NewDataDialog, self).__init__(parent) # Setup the basic window self.resize(400, 150) self.setWindowTitle("Create new data or change the current one") Layout = QVBoxLayout(self) self.data = {} self.lastText = "" self.returnVal = None # Add the current and new shape boxes and their labels label = QLabel(self) label.setText(("Use 'this' to reference the current data and 'cutout'"+ " for the current view.\n"+ "Before saving enter the variable you want to save. \n"+ "Otherwise the original data will be overwritten.")) Layout.addWidget(label) self.history = QTextEdit(self) self.history.setEnabled(False) Layout.addWidget(self.history) self.cmd = QLineEdit(self) Layout.addWidget(self.cmd) self.err = QLineEdit(self) self.err.setEnabled(False) self.err.setStyleSheet("color: rgb(255, 0, 0);") Layout.addWidget(self.err) # Add a button Box with "OK" and "Cancel"-Buttons self.buttonBox = DBB(DBB.Cancel|DBB.Ok|DBB.Save, QtCore.Qt.Horizontal) Layout.addWidget(self.buttonBox) self.buttonBox.button(DBB.Cancel).clicked.connect(self.reject) self.buttonBox.button(DBB.Ok).clicked.connect(self._on_accept) self.buttonBox.button(DBB.Save).clicked.connect(self._on_save) def _on_accept(self): """ Try to run the command and append the history on pressing 'OK'. """ try: var, value = self._parsecmd(str(self.cmd.text())) self.data[var] = eval(value) except Exception as err: self.err.setText(str(err)) return self.history.append(self.cmd.text()) self.lastText = str(self.cmd.text()) self.cmd.setText("") def _on_save(self): """ Return the object currently in the textBox to the Viewer. """ if re.findall(r"\=", self.cmd.text()): return if self.cmd.text() == "": self.returnVal = re.split(r"\=", self.lastText)[0].strip() self.accept() else: self.returnVal = self.cmd.text().strip() if self.returnVal is not None: self.accept() else: return def _parsecmd(self, cmd): """ Parse the command given by the user. """ try: var, expr = cmd.split("=", 1) except ValueError: raise ValueError("No '=' in expression") for op in ['(', ')', '[', ']', '{', '}', ',', '+', '-', '*', '/', '%', '^']: expr = expr.replace(op, " " + op + " ") expr = " " + expr + " " for datum in self.data: expr = expr.replace(" " + datum + " ", "self.data['" + datum + "']") return var.strip(), expr.replace(" ", "")
[docs] def new_data(self, data, cutout): """ Generate New Data (maybe using the currently selected array). """ self.data = {'this': data, 'cutout': cutout} self.history.clear() while True: # Open a dialog to reshape self.cmd.setText("") self.cmd.setFocus() # If "Save" is pressed if self.exec_() or self.returnVal is not None: if self.data['this'] is None: return (re.split(r"\=", self.lastText)[0].strip(), self.data[self.returnVal]) if self.cmd.text() == "": return 1, self.data[self.returnVal] return str(self.cmd.text()), self.data[self.returnVal] return 0, []