From c9d9a1d33d158a621aad40feb9e99d604e38d25f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rasmus=20Vester=20Th=C3=B8gersen?= Date: Tue, 21 Mar 2023 16:48:11 +0100 Subject: [PATCH] Initial commit --- README.md | 2 + plotter/animations.py | 25 +++ plotter/auxillary.py | 44 +++++ plotter/colours.py | 50 +++++ plotter/interactive.py | 17 ++ plotter/plots.py | 424 +++++++++++++++++++++++++++++++++++++++++ setup.py | 11 ++ 7 files changed, 573 insertions(+) create mode 100644 plotter/animations.py create mode 100644 plotter/auxillary.py create mode 100644 plotter/colours.py create mode 100644 plotter/interactive.py create mode 100644 plotter/plots.py create mode 100644 setup.py diff --git a/README.md b/README.md index 9c96646..f917dba 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,4 @@ # plotter A tool to arrive at some nice looking plots a little quicker. + +Based on what was originally written for the NAFUMA-package (https://www.github.com/rasmusthog/nafuma), and now forked out for more general use. \ No newline at end of file diff --git a/plotter/animations.py b/plotter/animations.py new file mode 100644 index 0000000..31ccd6c --- /dev/null +++ b/plotter/animations.py @@ -0,0 +1,25 @@ +import plotter.auxillary as aux + +from PIL import Image +import os + + +def make_animation(paths, options={}): + + default_options = { + 'save_folder': '.', + 'save_filename': 'animation.gif', + 'fps': 5 + } + + options = aux.update_options(options=options, default_options=default_options) + + + frames = [] + for path in paths: + frame = Image.open(path) + frames.append(frame) + + frames[0].save(os.path.join(options['save_folder'], options['save_filename']), format='GIF', append_images=frames[1:], save_all=True, duration=(1/options['fps'])*1000, loop=0) + + diff --git a/plotter/auxillary.py b/plotter/auxillary.py new file mode 100644 index 0000000..ee44dde --- /dev/null +++ b/plotter/auxillary.py @@ -0,0 +1,44 @@ +import json +import os + +def update_options(options, default_options, required_options=None): + ''' Takes a dictionary of options along with a list of required options and dictionary of default options, and sets all keyval-pairs of options that is not already defined to the default values''' + + for option in default_options.keys(): + if option not in options.keys(): + options[option] = default_options[option] + + + return options + +def save_options(options, path, ignore=None): + ''' Saves any options dictionary to a JSON-file in the specified path''' + + options_copy = options.copy() + + if ignore: + if not isinstance(ignore, list): + ignore = [ignore] + + for i in ignore: + options_copy[i] = 'Removed' + + + if not os.path.isdir(os.path.dirname(path)): + if os.path.dirname(path): + os.makedirs(os.path.dirname(path)) + + + with open(path, 'w') as f: + json.dump(options_copy, f, skipkeys=True, indent=4) + + +def load_options(path): + ''' Loads JSON-file into a dictionary''' + + with open(path, 'r') as f: + options = json.load(f) + + return(options) + + diff --git a/plotter/colours.py b/plotter/colours.py new file mode 100644 index 0000000..165d933 --- /dev/null +++ b/plotter/colours.py @@ -0,0 +1,50 @@ +import plotter.auxillary as aux + +import numpy as np + +import importlib +import itertools + +def generate_colours(palettes, kind=None): + + if kind == 'single': + colour_cycle = itertools.cycle(palettes) + + else: + # Creates a list of all the colours that is passed in the colour_cycles argument. Then makes cyclic iterables of these. + colour_collection = [] + for palette in palettes: + mod = importlib.import_module("palettable.colorbrewer.%s" % palette[0]) + colour = getattr(mod, palette[1]).mpl_colors + colour_collection = colour_collection + colour + + colour_cycle = itertools.cycle(colour_collection) + + + return colour_cycle + + + +def mix_colours(colour1, colour2, options): + + default_options = { + 'number_of_colours': 10, + 'weights': None + } + + options = aux.update_options(options=options, default_options=default_options) + + if not options['weights']: + options['weights'] = [x/options['number_of_colours'] for x in range(options['number_of_colours'])] + + colours = [] + for weight in options['weights']: + colour = [] + + for c1, c2 in zip(colour1, colour2): + colour.append(np.round(((1-weight)*c1 + weight*c2), 5)) + + colours.append(colour) + + + return colours \ No newline at end of file diff --git a/plotter/interactive.py b/plotter/interactive.py new file mode 100644 index 0000000..a13d434 --- /dev/null +++ b/plotter/interactive.py @@ -0,0 +1,17 @@ +def ipywidgets_update(func, data, options={}, **kwargs): + ''' A general ipywidgets update function that can be passed to ipywidgets.interactive. To use this, you can run: + + import ipywidgets as widgets + import plotter.interactive as plint + + w = widgets.interactive(plint.ipywidgets_update, func=widgets.fixed(my_func), plot_data=widgets.fixed(plot_data), options=widgets.fixed(options), key1=widget1, key2=widget2, key3=widget3) + + where key1, key2, key3 etc. are the values in the options-dictionary you want widget control of, and widget1, widget2, widget3 etc. are widgets to control these values, e.g. widgets.IntSlider(value=1, min=0, max=10) + ''' + + # Update the options-dictionary with the values from the widgets + for key in kwargs: + options[key] = kwargs[key] + + # Call the function with the plot_data and options-dictionaries + func(data=data, options=options) \ No newline at end of file diff --git a/plotter/plots.py b/plotter/plots.py new file mode 100644 index 0000000..e87243a --- /dev/null +++ b/plotter/plots.py @@ -0,0 +1,424 @@ +# Helper functions +import plotter.auxillary as aux +import plotter.colours as col + +# For plotting +import matplotlib.pyplot as plt +import itertools +from matplotlib.lines import Line2D +from matplotlib.ticker import (MultipleLocator) +from matplotlib.patches import Rectangle + +# To create insets +from mpl_toolkits.axes_grid1.inset_locator import (inset_axes, InsetPosition, BboxPatch, BboxConnector) +from matplotlib.transforms import TransformedBbox + +def prepare_plot(options={}): + ''' A general function to prepare a plot based on contents of options['rc_params'] and options['format_params']. + + rc_params is a dictionary with keyval-pairs corresponding to rcParams in matplotlib, to give the user full control over this. Please consult the matplotlib-documentation + + format_params will determine the size, aspect ratio, resolution etc. of the figure. Should be modified to conform with any requirements from a journal.''' + + if 'rc_params' in options.keys(): + rc_params = options['rc_params'] + else: + rc_params = {} + + + if 'format_params' in options.keys(): + format_params = options['format_params'] + else: + format_params = {} + + + default_format_params = { + 'single_column_width': 8.3, + 'double_column_width': 17.1, + 'column_type': 'single', + 'width_ratio': '1:1', + 'aspect_ratio': '1:1', + 'width': None, + 'height': None, + 'compress_width': 1, + 'compress_height': 1, + 'upscaling_factor': 1.0, + 'dpi': 600, + 'nrows': 1, + 'ncols': 1, + 'grid_ratio_height': None, + 'grid_ratio_width': None + } + + format_params = aux.update_options(options=format_params, default_options=default_format_params) + + + # Reset run commands + plt.rcdefaults() + + # Update run commands if any is passed (will pass an empty dictionary if not passed) + update_rc_params(rc_params) + + if not format_params['width']: + format_params['width'] = determine_width(format_params=format_params) + + if not format_params['height']: + format_params['height'] = determine_height(format_params=format_params, width=format_params['width']) + + format_params['width'], format_params['height'] = scale_figure(format_params=format_params, width=format_params['width'], height=format_params['height']) + + if format_params['nrows'] == 1 and format_params['ncols'] == 1: + fig, ax = plt.subplots(figsize=(format_params['width'], format_params['height']), dpi=format_params['dpi']) + + return fig, ax + + else: + if not format_params['grid_ratio_height']: + format_params['grid_ratio_height'] = [1 for i in range(format_params['nrows'])] + + if not format_params['grid_ratio_width']: + format_params['grid-ratio_width'] = [1 for i in range(format_params['ncols'])] + + fig, axes = plt.subplots(nrows=format_params['nrows'], ncols=format_params['ncols'], figsize=(format_params['width'],format_params['height']), + gridspec_kw={'height_ratios': format_params['grid_ratio_height'], 'width_ratios': format_params['grid_ratio_width']}, + facecolor='w', dpi=format_params['dpi']) + + return fig, axes + + +def adjust_plot(fig, ax, options): + ''' A general function to adjust plot according to contents of the options-dictionary ''' + + + default_options = { + 'plot_kind': None, # defaults to None, but should be utilised when requiring special formatting for a particular plot + 'xlabel': None, 'ylabel': None, + 'xunit': None, 'yunit': None, + 'xlabel_pad': 4.0, 'ylabel_pad': 4.0, + 'hide_x_labels': False, 'hide_y_labels': False, # Whether the main labels on the x- and/or y-axes should be hidden + 'hide_x_ticklabels': False, 'hide_y_ticklabels': False, # Whether ticklabels on the x- and/or y-axes should be hidden + 'hide_x_ticks': False, 'hide_y_ticks': False, # Whether the ticks on the x- and/or y-axes should be hidden + 'x_tick_locators': None, 'y_tick_locators': None, # The major and minor tick locators for the x- and y-axes + 'rotation_x_ticks': 0, 'rotation_y_ticks': 0, # Degrees the x- and/or y-ticklabels should be rotated + 'xticks': None, 'yticks': None, # Custom definition of the xticks and yticks. This is not properly implemented now. + 'xlim': None, 'ylim': None, # Limits to the x- and y-axes + 'xlim_reset': False, 'ylim_reset': False, # For use in setting limits of backgrounds - forcing reset of xlim and ylim, useful when more axes + 'title': None, # Title of the plot + 'backgrounds': [], + 'legend': False, 'legend_position': ['lower center', (0.5, -0.1)], 'legend_ncol': 1, # Toggles on/off legend. Specifices legend position and the number of columns the legend should appear as. + 'subplots_adjust': {'left': None, 'right': None, 'top': None, 'bottom': None, 'wspace': None, 'hspace': None}, # Adjustment of the Axes-object within the Figure-object. Fraction of the Figure-object the left, bottom, right and top edges of the Axes-object will start. + 'marker_edges': None, + 'text': None # Text to show in the plot. Should be a list where the first element is the string, and the second is a tuple with x- and y-coordinates. Could also be a list of lists to show more strings of text. + } + + + options = aux.update_options(options=options, default_options=default_options) + + # Set labels on x- and y-axes + if not options['hide_y_labels']: + if not options['yunit']: + ax.set_ylabel(f'{options["ylabel"]}', labelpad=options['ylabel_pad']) + else: + ax.set_ylabel(f'{options["ylabel"]} [{options["yunit"]}]', labelpad=options['ylabel_pad']) + + else: + ax.set_ylabel('') + + if not options['hide_x_labels']: + if not options['xunit']: + ax.set_xlabel(f'{options["xlabel"]}', labelpad=options['xlabel_pad']) + else: + ax.set_xlabel(f'{options["xlabel"]} [{options["xunit"]}]', labelpad=options['xlabel_pad']) + else: + ax.set_xlabel('') + + # Set multiple locators + if options['y_tick_locators']: + ax.yaxis.set_major_locator(MultipleLocator(options['y_tick_locators'][0])) + ax.yaxis.set_minor_locator(MultipleLocator(options['y_tick_locators'][1])) + + if options['x_tick_locators']: + ax.xaxis.set_major_locator(MultipleLocator(options['x_tick_locators'][0])) + ax.xaxis.set_minor_locator(MultipleLocator(options['x_tick_locators'][1])) + + + # FIXME THIS NEEDS REWORK FOR IT TO FUNCTION PROPERLY! + #if options['xticks']: + # ax.set_xticks(np.arange(plot_data['start'], plot_data['end']+1)) + # ax.set_xticklabels(options['xticks']) + # else: + # ax.set_xticks(np.arange(plot_data['start'], plot_data['end']+1)) + # ax.set_xticklabels([x/2 for x in np.arange(plot_data['start'], plot_data['end']+1)]) + + # Hide x- and y- ticklabels + if options['hide_y_ticklabels']: + ax.tick_params(axis='y', direction='in', which='both', labelleft=False, labelright=False) + else: + plt.xticks(rotation=options['rotation_x_ticks']) + #ax.set_xticklabels(ax.get_xticks(), rotation = options['rotation_x_ticks']) + + if options['hide_x_ticklabels']: + ax.tick_params(axis='x', direction='in', which='both', labelbottom=False, labeltop=False) + else: + pass + #ax.set_yticklabels(ax.get_yticks(), rotation = options['rotation_y_ticks']) + + + # Hide x- and y-ticks: + if options['hide_y_ticks']: + ax.tick_params(axis='y', direction='in', which='both', left=False, right=False) + else: + ax.tick_params(axis='y', direction='in', which='both', left=True, right=True) + + if options['hide_x_ticks']: + ax.tick_params(axis='x', direction='in', which='both', bottom=False, top=False) + else: + ax.tick_params(axis='x', direction='in', which='both', bottom=True, top=True) + + + + # Set title + if options['title']: + ax.set_title(options['title'], fontsize=plt.rcParams['font.size']) + + + + #### DRAW/REMOVE LEGEND #### + # Options: + # 'legend_position': (default ['lower center', (0.5, -0.1)]) - Follows matplotlib's way of specifying legend position + # 'legend_ncol': (default 1) # Number of columns to write the legend in + # Also requires options to contain values in colours, markers and labels. (No defaults) + + if ax.get_legend(): + ax.get_legend().remove() + + + if options['legend']: + # Make palette and linestyles from original parameters + if not options['colours']: + colours = col.generate_colours(palettes=options['palettes']) + else: + colours = itertools.cycle(options['colours']) + + + markers = itertools.cycle(options['markers']) + + # Create legend + active_markers = [] + active_labels = [] + + for label in options['labels']: + + + # Discard next linestyle and colour if label is _ + if label == '_': + _ = next(colours) + _ = next(markers) + + else: + marker = next(markers) + if not marker: + active_markers.append(Line2D([], [], color=next(colours))) + else: + active_markers.append(Line2D([], [], markerfacecolor=next(colours), markeredgecolor=options['marker_edges'], markersize=10, color=(1,1,1,0), marker=marker)) + + active_labels.append(label) + + + + ax.legend(active_markers, active_labels, frameon=False, loc=options['legend_position'][0], bbox_to_anchor=options['legend_position'][1], ncol=options['legend_ncol']) + #fig.legend(handles=patches, loc=options['legend_position'][0], bbox_to_anchor=options['legend_position'][1], frameon=False) + + + + # Adjust where the axes start within the figure. Default value is 10% in from the left and bottom edges. Used to make room for the plot within the figure size (to avoid using bbox_inches='tight' in the savefig-command, as this screws with plot dimensions) + plt.subplots_adjust(**options['subplots_adjust']) + + + # If limits for x- and y-axes is passed, sets these. + if options['xlim'] is not None: + ax.set_xlim(options['xlim']) + + if options['ylim'] is not None: + ax.set_ylim(options['ylim']) + + + #### DRAW BACKGROUNDS #### + # options['backgrounds'] should contain a dictionary or a list of dictionaries. Options to be specified are listed below. + + if options['backgrounds']: + + if not isinstance(options['backgrounds'], list): + options['backgrounds'] = [options['backgrounds']] + + + for background in options['backgrounds']: + default_background_options = { + 'colour': (0,0,0), + 'alpha': 0.2, + 'xlim': list(ax.get_xlim()), + 'ylim': list(ax.get_ylim()), + 'zorder': 0, + 'edgecolour': None, + 'linewidth': None + } + + + background = aux.update_options(options=background, default_options=default_background_options) + + if options['xlim_reset']: + background['xlim'] = list(ax.get_xlim()) + if options['ylim_reset']: + background['ylim'] = list(ax.get_ylim()) + + if not background['xlim'][0]: + background['xlim'][0] = ax.get_xlim()[0] + if not background['xlim'][1]: + background['xlim'][1] = ax.get_xlim()[1] + if not background['ylim'][0]: + background['ylim'][0] = ax.get_ylim()[0] + if not background['ylim'][1]: + background['ylim'][1] = ax.get_ylim()[1] + + ax.add_patch(Rectangle( + xy=(background['xlim'][0], background['ylim'][0]), # Anchor point + width=background['xlim'][1]-background['xlim'][0], # Width of background + height=background['ylim'][1]-background['ylim'][0], # Height of background + zorder=background['zorder'], # Placement in stack + facecolor=(background['colour'][0], background['colour'][1], background['colour'][2], background['alpha']), # Colour + edgecolor=background['edgecolour'], # Edgecolour + linewidth=background['linewidth']) # Linewidth + ) + + + # Add custom text + if options['text']: + + # If only a single element, put it into a list so the below for-loop works. + if isinstance(options['text'][0], str): + options['text'] = [options['text']] + + # Plot all passed texts + for text in options['text']: + ax.text(x=text[1][0], y=text[1][1], s=text[0]) + + return fig, ax + + +def determine_width(format_params): + ''' ''' + + conversion_cm_inch = 0.3937008 # cm to inch + + if format_params['column_type'] == 'single': + column_width = format_params['single_column_width'] + elif format_params['column_type'] == 'double': + column_width = format_params['double_column_width'] + + column_width *= conversion_cm_inch + + + width_ratio = [float(num) for num in format_params['width_ratio'].split(':')] + + + width = column_width * width_ratio[0]/width_ratio[1] + + + return width + + +def determine_height(format_params, width): + + aspect_ratio = [float(num) for num in format_params['aspect_ratio'].split(':')] + + height = width/(aspect_ratio[0] / aspect_ratio[1]) + + return height + + +def scale_figure(format_params, width, height): + width = width * format_params['upscaling_factor'] * format_params['compress_width'] + height = height * format_params['upscaling_factor'] * format_params['compress_height'] + + return width, height + + +def update_rc_params(rc_params): + ''' Update all passed run commands in matplotlib''' + + if rc_params: + for key in rc_params.keys(): + plt.rcParams.update({key: rc_params[key]}) + +def prepare_inset_axes(parent_ax, options): + + default_options = { + 'hide_inset_x_labels': False, # Whether x labels should be hidden + 'hide_inset_x_ticklabels': False, + 'hide_inset_x_ticks': False, + 'rotation_inset_x_ticks': 0, + 'hide_inset_y_labels': False, # whether y labels should be hidden + 'hide_inset_y_ticklabels': False, + 'hide_inset_y_ticks': False, + 'rotation_inset_y_ticks': 0, + 'inset_x_tick_locators': [100, 50], # Major and minor tick locators + 'inset_y_tick_locators': [10, 5], + 'inset_position': [0.1,0.1,0.3,0.3], + 'inset_bounding_box': [0,0,0.1, 0.1], + 'inset_marks': [None, None], + 'legend_position': ['upper center', (0.20, 0.90)], # the position of the legend passed as arguments to loc and bbox_to_anchor respectively, + 'connecting_corners': [1,2] + } + + + options = aux.update_options(options=options, required_options=default_options.keys(), default_options=default_options) + + + # Create a set of inset Axes: these should fill the bounding box allocated to + # them. + inset_ax = plt.axes(options["inset_bounding_box"]) + # Manually set the position and relative size of the inset axes within ax1 + ip = InsetPosition(parent_ax, options['inset_position']) + inset_ax.set_axes_locator(ip) + + if options['connecting_corners'] and len(options["connecting_corners"]) == 2: + connect_inset(parent_ax, inset_ax, loc1a=options['connecting_corners'][0], loc2a=options['connecting_corners'][1], loc1b=options['connecting_corners'][0], loc2b=options['connecting_corners'][1], fc='none', ec='black') + elif options['connecting_corners'] and len(options['connecting_corners']) == 4: + connect_inset(parent_ax, inset_ax, loc1a=options['connecting_corners'][0], loc2a=options['connecting_corners'][1], loc1b=options['connecting_corners'][2], loc2b=options['connecting_corners'][3], fc='none', ec='black', ls='--') + + inset_ax.xaxis.set_major_locator(MultipleLocator(options['inset_x_tick_locators'][0])) + inset_ax.xaxis.set_minor_locator(MultipleLocator(options['inset_x_tick_locators'][1])) + + + inset_ax.yaxis.set_major_locator(MultipleLocator(options['inset_y_tick_locators'][0])) + inset_ax.yaxis.set_minor_locator(MultipleLocator(options['inset_y_tick_locators'][1])) + + + + + return inset_ax + + + + +def connect_inset(parent_axes, inset_axes, loc1a=1, loc1b=1, loc2a=2, loc2b=2, **kwargs): + rect = TransformedBbox(inset_axes.viewLim, parent_axes.transData) + + pp = BboxPatch(rect, fill=False, **kwargs) + parent_axes.add_patch(pp) + + p1 = BboxConnector(inset_axes.bbox, rect, loc1=loc1a, loc2=loc1b, **kwargs) + inset_axes.add_patch(p1) + p1.set_clip_on(False) + p2 = BboxConnector(inset_axes.bbox, rect, loc1=loc2a, loc2=loc2b, **kwargs) + inset_axes.add_patch(p2) + p2.set_clip_on(False) + + return pp, p1, p2 + + + + + + + \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a85ec0d --- /dev/null +++ b/setup.py @@ -0,0 +1,11 @@ +from setuptools import setup, find_packages + +setup(name='plotter', + version='0.1', + description='A plotting tool written on top of matplotlib to arrive at nice looking plots a little quicker.', + url='https://github.com/rasmusthog/plotter', + author='Rasmus Vester Thøgersen', + author_email='github@rasmusthog.me', + license='GPLv3', + packages=find_packages(), + zip_safe=False)