ChessAnalysisPipeline 0.0.11__py3-none-any.whl → 0.0.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ChessAnalysisPipeline might be problematic. Click here for more details.

CHAP/utils/general.py CHANGED
@@ -26,8 +26,8 @@ from sys import float_info
26
26
  import numpy as np
27
27
  try:
28
28
  import matplotlib.pyplot as plt
29
- import matplotlib.lines as mlines
30
- from matplotlib.widgets import Button
29
+ from matplotlib.widgets import AxesWidget, RadioButtons
30
+ from matplotlib import cbook
31
31
  except ImportError:
32
32
  pass
33
33
 
@@ -437,7 +437,7 @@ def index_nearest(a, value):
437
437
  return (int)(np.argmin(np.abs(a-value)))
438
438
 
439
439
 
440
- def index_nearest_low(a, value):
440
+ def index_nearest_down(a, value):
441
441
  """Return index of nearest array value, rounded down"""
442
442
  a = np.asarray(a)
443
443
  if a.ndim > 1:
@@ -461,6 +461,23 @@ def index_nearest_upp(a, value):
461
461
  return index
462
462
 
463
463
 
464
+ def get_consecutive_int_range(a):
465
+ """Return a list of pairs of integers marking consecutive ranges
466
+ of integers."""
467
+ a.sort()
468
+ i = 0
469
+ int_ranges = []
470
+ while i < len(a):
471
+ j = i
472
+ while j < len(a)-1:
473
+ if a[j+1] > 1 + a[j]:
474
+ break
475
+ j += 1
476
+ int_ranges.append([a[i], a[j]])
477
+ i = j+1
478
+ return int_ranges
479
+
480
+
464
481
  def round_to_n(x, n=1):
465
482
  """Round to a specific number of decimals."""
466
483
  if x == 0.0:
@@ -880,641 +897,818 @@ def file_exists_and_readable(f):
880
897
  return f
881
898
 
882
899
 
883
- def draw_mask_1d(
884
- ydata, xdata=None, label=None, ref_data=[],
885
- current_index_ranges=None, current_mask=None,
886
- select_mask=True, num_index_ranges_max=None,
887
- title=None, xlabel=None, ylabel=None,
888
- test_mode=False, return_figure=False):
889
- """Display a 2D plot and have the user select a mask.
890
-
891
- :param ydata: data array for which a mask will be constructed
892
- :type ydata: numpy.ndarray
893
- :param xdata: x-coordinates of the reference data, defaults to
894
- None
895
- :type xdata: numpy.ndarray, optional
896
- :param label: legend label for the reference data, defaults to
897
- None
900
+ def select_mask_1d(
901
+ y, x=None, label=None, ref_data=[], preselected_index_ranges=None,
902
+ preselected_mask=None, title=None, xlabel=None, ylabel=None,
903
+ min_num_index_ranges=None, max_num_index_ranges=None,
904
+ interactive=True):
905
+ """Display a lineplot and have the user select a mask.
906
+
907
+ :param y: One-dimensional data array for which a mask will be
908
+ constructed.
909
+ :type y: numpy.ndarray
910
+ :param x: x-coordinates of the reference data,
911
+ defaults to `None`.
912
+ :type x: numpy.ndarray, optional
913
+ :param label: Legend label for the reference data,
914
+ defaults to `None`.
898
915
  :type label: str, optional
899
- :param ref_data: a list of additional reference data to
916
+ :param ref_data: A list of additional reference data to
900
917
  plot. Items in the list should be tuples of positional
901
918
  arguments and keyword arguments to unpack and pass directly to
902
- `matplotlib.axes.Axes.plot`, defaults to []
903
- :type ref_data: list[tuple[tuple, dict]]
904
- :param current_index_ranges: list of preselected index ranges to
905
- mask, defaults to None
906
- :type current_index_ranges: list[tuple[int, int]]
907
- :param current_mask: preselected boolean mask array, defaults to
908
- None
909
- :type current_mask: numpy.ndarray, optional
910
- :param select_mask: if True, user-selected ranges will be included
911
- when the returned mask is applied to `ydata`. If False, they
912
- will be excluded. Defaults to True.
913
- :type select_mask: bool, optional
914
- :param title: title for the displayed figure, defaults to None
919
+ `matplotlib.axes.Axes.plot`, defaults to `[]`.
920
+ :type ref_data: list[tuple(tuple, dict)], optional
921
+ :param preselected_index_ranges: List of preselected index ranges
922
+ to mask, defaults to `None`.
923
+ :type preselected_index_ranges: Union(list[tuple(int, int)],
924
+ list[list[int]]), optional
925
+ :param preselected_mask: Preselected boolean mask array,
926
+ defaults to `None`.
927
+ :type preselected_mask: numpy.ndarray, optional
928
+ :param title: Title for the displayed figure, defaults to `None`.
915
929
  :type title: str, optional
916
- :param xlabel: label for the x-axis of the displayed figure,
917
- defaults to None
930
+ :param xlabel: Label for the x-axis of the displayed figure,
931
+ defaults to `None`.
918
932
  :type xlabel: str, optional
919
- :param ylabel: label for the y-axis of the displayed figure,
920
- defaults to None
933
+ :param ylabel: Label for the y-axis of the displayed figure,
934
+ defaults to `None`.
921
935
  :type ylabel: str, optional
922
- :param test_mode: if True, run as a non-interactive test
923
- case. Defaults to False
924
- :type test_mode: bool, optional
925
- :param return_figure: if True, also return a matplotlib figure of
926
- the drawn mask, defaults to False
927
- :type return_figure: bool, optional
928
- :return: a boolean mask array and the list of selected index
929
- ranges (and a matplotlib figure, if `return_figure` was True).
930
- :rtype: numpy.ndarray, list[tuple[int, int]] [, matplotlib.figure.Figure]
936
+ :param min_num_index_ranges: The minimum number of selected index
937
+ ranges, defaults to `None`.
938
+ :type min_num_index_ranges: int, optional
939
+ :param max_num_index_ranges: The maximum number of selected index
940
+ ranges, defaults to `None`.
941
+ :type max_num_index_ranges: int, optional
942
+ :param interactive: Show the plot and allow user interactions with
943
+ the matplotlib figure, defults to `True`.
944
+ :type interactive: bool, optional
945
+ :return: A Matplotlib figure, a boolean mask array and the list of
946
+ selected index ranges.
947
+ :rtype: matplotlib.figure.Figure, numpy.ndarray,
948
+ list[tuple(int, int)]
931
949
  """
932
- # RV make color blind friendly
933
- def draw_selections(
934
- ax, current_include, current_exclude, selected_index_ranges):
935
- """Draw the selections."""
936
- ax.clear()
937
- if title is not None:
938
- ax.set_title(title)
939
- if xlabel is not None:
940
- ax.set_xlabel(xlabel)
941
- if ylabel is not None:
942
- ax.set_ylabel(ylabel)
943
- ax.plot(xdata, ydata, 'k', label=label)
944
- for data in ref_data:
945
- ax.plot(*data[0], **data[1])
946
- ax.legend()
947
- for low, upp in current_include:
948
- xlow = 0.5 * (xdata[max(0, low-1)]+xdata[low])
949
- xupp = 0.5 * (xdata[upp]+xdata[min(num_data-1, 1+upp)])
950
- ax.axvspan(xlow, xupp, facecolor='green', alpha=0.5)
951
- for low, upp in current_exclude:
952
- xlow = 0.5 * (xdata[max(0, low-1)]+xdata[low])
953
- xupp = 0.5 * (xdata[upp]+xdata[min(num_data-1, 1+upp)])
954
- ax.axvspan(xlow, xupp, facecolor='red', alpha=0.5)
955
- for low, upp in selected_index_ranges:
956
- xlow = 0.5 * (xdata[max(0, low-1)]+xdata[low])
957
- xupp = 0.5 * (xdata[upp]+xdata[min(num_data-1, 1+upp)])
958
- ax.axvspan(xlow, xupp, facecolor=selection_color, alpha=0.5)
959
- ax.get_figure().canvas.draw()
960
-
961
- def onclick(event):
962
- """Action taken on clicking the mouse button."""
963
- if event.inaxes in [fig.axes[0]]:
964
- selected_index_ranges.append(index_nearest_upp(xdata, event.xdata))
965
-
966
- def onrelease(event):
967
- """Action taken on releasing the mouse button."""
968
- if selected_index_ranges:
969
- if isinstance(selected_index_ranges[-1], int):
970
- if event.inaxes in [fig.axes[0]]:
971
- event.xdata = index_nearest_low(xdata, event.xdata)
972
- if selected_index_ranges[-1] <= event.xdata:
973
- selected_index_ranges[-1] = \
974
- (selected_index_ranges[-1], event.xdata)
975
- else:
976
- selected_index_ranges[-1] = \
977
- (event.xdata, selected_index_ranges[-1])
978
- draw_selections(
979
- event.inaxes, current_include, current_exclude,
980
- selected_index_ranges)
981
- else:
982
- selected_index_ranges.pop(-1)
983
-
984
- def confirm_selection(event):
985
- """Action taken on hitting the confirm button."""
986
- plt.close()
987
-
988
- def clear_last_selection(event):
989
- """Action taken on hitting the clear button."""
990
- if selected_index_ranges:
991
- selected_index_ranges.pop(-1)
950
+ # Third party modules
951
+ from matplotlib.patches import Patch
952
+ from matplotlib.widgets import Button, SpanSelector
953
+
954
+ # local modules
955
+ from CHAP.utils.general import index_nearest
956
+
957
+ def change_fig_title(title):
958
+ if fig_title:
959
+ fig_title[0].remove()
960
+ fig_title.pop()
961
+ fig_title.append(plt.figtext(*title_pos, title, **title_props))
962
+
963
+ def change_error_text(error):
964
+ if error_texts:
965
+ error_texts[0].remove()
966
+ error_texts.pop()
967
+ error_texts.append(plt.figtext(*error_pos, error, **error_props))
968
+
969
+ def get_selected_index_ranges(change_fnc=None):
970
+ selected_index_ranges = sorted(
971
+ [[index_nearest(x, span.extents[0]),
972
+ index_nearest(x, span.extents[1])+1]
973
+ for span in spans])
974
+ if change_fnc is not None:
975
+ if len(selected_index_ranges) > 1:
976
+ change_fnc(
977
+ f'Selected index ranges: {selected_index_ranges}')
978
+ elif selected_index_ranges:
979
+ change_fnc(
980
+ f'Selected ROI: {tuple(selected_index_ranges[0])}')
981
+ else:
982
+ change_fnc(f'Selected ROI: None')
983
+ return selected_index_ranges
984
+
985
+ def add_span(event, xrange_init=None):
986
+ """Callback function for the "Add span" button."""
987
+ if (max_num_index_ranges is not None
988
+ and len(spans) >= max_num_index_ranges):
989
+ change_error_text(
990
+ f'Exceeding max number of ranges, adjust an existing '
991
+ 'range or click "Reset"/"Confirm"')
992
992
  else:
993
- while current_include:
994
- current_include.pop()
995
- while current_exclude:
996
- current_exclude.pop()
997
- selected_mask.fill(False)
998
- draw_selections(
999
- ax, current_include, current_exclude, selected_index_ranges)
1000
-
1001
- def update_mask(mask, selected_index_ranges, unselected_index_ranges):
1002
- """Update the plot with the selected mask."""
1003
- for low, upp in selected_index_ranges:
1004
- selected_mask = np.logical_and(
1005
- xdata >= xdata[low], xdata <= xdata[upp])
1006
- mask = np.logical_or(mask, selected_mask)
1007
- for low, upp in unselected_index_ranges:
1008
- unselected_mask = np.logical_and(
1009
- xdata >= xdata[low], xdata <= xdata[upp])
1010
- mask[unselected_mask] = False
993
+ spans.append(
994
+ SpanSelector(
995
+ ax, select_span, 'horizontal', props=included_props,
996
+ useblit=True, interactive=interactive,
997
+ drag_from_anywhere=True, ignore_event_outside=True,
998
+ grab_range=5))
999
+ if xrange_init is None:
1000
+ xmin_init, xmax_init = min(x), 0.05*(max(x)-min(x))
1001
+ else:
1002
+ xmin_init, xmax_init = xrange_init
1003
+ spans[-1]._selection_completed = True
1004
+ spans[-1].extents = (xmin_init, xmax_init)
1005
+ spans[-1].onselect(xmin_init, xmax_init)
1006
+ plt.draw()
1007
+
1008
+ def select_span(xmin, xmax):
1009
+ """Callback function for the SpanSelector widget."""
1010
+ combined_spans = True
1011
+ while combined_spans:
1012
+ combined_spans = False
1013
+ for i, span1 in enumerate(spans):
1014
+ for span2 in spans[i+1:]:
1015
+ if (span1.extents[1] >= span2.extents[0]
1016
+ and span1.extents[0] <= span2.extents[1]):
1017
+ change_error_text(
1018
+ 'Combined overlapping spans in currently '
1019
+ 'selected mask')
1020
+ span2.extents = (
1021
+ min(span1.extents[0], span2.extents[0]),
1022
+ max(span1.extents[1], span2.extents[1]))
1023
+ span1.set_visible(False)
1024
+ spans.remove(span1)
1025
+ combined_spans = True
1026
+ break
1027
+ if combined_spans:
1028
+ break
1029
+ get_selected_index_ranges(change_error_text)
1030
+ plt.draw()
1031
+
1032
+ def reset(event):
1033
+ """Callback function for the "Reset" button."""
1034
+ if error_texts:
1035
+ error_texts[0].remove()
1036
+ error_texts.pop()
1037
+ for span in reversed(spans):
1038
+ span.set_visible(False)
1039
+ spans.remove(span)
1040
+ get_selected_index_ranges(change_error_text)
1041
+ plt.draw()
1042
+
1043
+ def confirm(event):
1044
+ """Callback function for the "Confirm" button."""
1045
+ if (min_num_index_ranges is not None
1046
+ and len(spans) < min_num_index_ranges):
1047
+ change_error_text(
1048
+ f'Select at least {min_num_index_ranges} unique index ranges')
1049
+ plt.draw()
1050
+ else:
1051
+ if error_texts:
1052
+ error_texts[0].remove()
1053
+ error_texts.pop()
1054
+ get_selected_index_ranges(change_fig_title)
1055
+ plt.close()
1056
+
1057
+ def update_mask(mask, selected_index_ranges):
1058
+ """Update the mask with the selected index ranges."""
1059
+ for min_, max_ in selected_index_ranges:
1060
+ mask = np.logical_or(
1061
+ mask,
1062
+ np.logical_and(x >= x[min_], x <= x[min(max_, num_data-1)]))
1011
1063
  return mask
1012
1064
 
1013
1065
  def update_index_ranges(mask):
1014
1066
  """
1015
- Update the currently included index ranges (where mask = True).
1067
+ Update the selected index ranges (where mask = True).
1016
1068
  """
1017
- current_include = []
1069
+ selected_index_ranges = []
1018
1070
  for i, m in enumerate(mask):
1019
1071
  if m:
1020
- if (not current_include
1021
- or isinstance(current_include[-1], tuple)):
1022
- current_include.append(i)
1072
+ if (not selected_index_ranges
1073
+ or isinstance(selected_index_ranges[-1], tuple)):
1074
+ selected_index_ranges.append(i)
1023
1075
  else:
1024
- if current_include and isinstance(current_include[-1], int):
1025
- current_include[-1] = (current_include[-1], i-1)
1026
- if current_include and isinstance(current_include[-1], int):
1027
- current_include[-1] = (current_include[-1], num_data-1)
1028
- return current_include
1076
+ if (selected_index_ranges
1077
+ and isinstance(selected_index_ranges[-1], int)):
1078
+ selected_index_ranges[-1] = \
1079
+ (selected_index_ranges[-1], i-1)
1080
+ if (selected_index_ranges
1081
+ and isinstance(selected_index_ranges[-1], int)):
1082
+ selected_index_ranges[-1] = (selected_index_ranges[-1], num_data-1)
1083
+ return selected_index_ranges
1029
1084
 
1030
1085
  # Check inputs
1031
- ydata = np.asarray(ydata)
1032
- if ydata.ndim > 1:
1033
- logger.warning(f'Invalid ydata dimension ({ydata.ndim})')
1034
- return None, None
1035
- num_data = ydata.size
1036
- if xdata is None:
1037
- xdata = np.arange(num_data)
1086
+ y = np.asarray(y)
1087
+ if y.ndim > 1:
1088
+ raise ValueError(f'Invalid y dimension ({y.ndim})')
1089
+ num_data = y.size
1090
+ if x is None:
1091
+ x = np.arange(num_data)+0.5
1038
1092
  else:
1039
- xdata = np.asarray(xdata, dtype=np.float64)
1040
- if xdata.ndim > 1 or xdata.size != num_data:
1041
- logger.warning(f'Invalid xdata shape ({xdata.shape})')
1042
- return None, None
1043
- if not np.all(xdata[:-1] < xdata[1:]):
1044
- logger.warning('Invalid xdata: must be monotonically increasing')
1045
- return None, None
1046
- if current_index_ranges is not None:
1047
- if not isinstance(current_index_ranges, (tuple, list)):
1048
- logger.warning(
1049
- 'Invalid current_index_ranges parameter '
1050
- f'({current_index_ranges}, {type(current_index_ranges)})')
1051
- return None, None
1052
- if not isinstance(select_mask, bool):
1053
- logger.warning(
1054
- f'Invalid select_mask parameter ({select_mask}, '
1055
- f'{type(select_mask)})')
1056
- return None, None
1057
- if num_index_ranges_max is not None:
1058
- logger.warning(
1059
- 'num_index_ranges_max input not yet implemented in draw_mask_1d')
1093
+ x = np.asarray(x, dtype=np.float64)
1094
+ if x.ndim > 1 or x.size != num_data:
1095
+ raise ValueError(f'Invalid x shape ({x.shape})')
1096
+ if not np.all(x[:-1] < x[1:]):
1097
+ raise ValueError('Invalid x: must be monotonically increasing')
1060
1098
  if title is None:
1061
- title = 'select ranges of data'
1062
- elif not isinstance(title, str):
1063
- illegal_value(title, 'title')
1064
- title = ''
1065
-
1066
- if select_mask:
1067
- selection_color = 'green'
1099
+ title = 'Click and drag to select ranges to include in mask'
1100
+ if preselected_index_ranges is None:
1101
+ preselected_index_ranges = []
1068
1102
  else:
1069
- selection_color = 'red'
1070
-
1071
- # Set initial selected mask and the selected/unselected index
1072
- # ranges as needed
1073
- selected_index_ranges = []
1074
- unselected_index_ranges = []
1075
- selected_mask = np.full(xdata.shape, False, dtype=bool)
1076
- if current_index_ranges is None:
1077
- if current_mask is None:
1078
- if not select_mask:
1079
- selected_index_ranges = [(0, num_data-1)]
1080
- selected_mask = np.full(xdata.shape, True, dtype=bool)
1081
- else:
1082
- selected_mask = np.copy(np.asarray(current_mask, dtype=bool))
1083
- if current_index_ranges is not None and current_index_ranges:
1084
- current_index_ranges = sorted(list(current_index_ranges))
1085
- for low, upp in current_index_ranges:
1086
- if low > upp or low >= num_data or upp < 0:
1087
- continue
1088
- low = max(low, 0)
1089
- upp = min(upp, num_data-1)
1090
- selected_index_ranges.append((low, upp))
1091
- selected_mask = update_mask(
1092
- selected_mask, selected_index_ranges, unselected_index_ranges)
1093
- if current_index_ranges is not None and current_mask is not None:
1094
- selected_mask = np.logical_and(current_mask, selected_mask)
1095
- if current_mask is not None:
1096
- selected_index_ranges = update_index_ranges(selected_mask)
1097
-
1098
- # Set up range selections for display
1099
- current_include = selected_index_ranges
1100
- current_exclude = []
1101
- selected_index_ranges = []
1102
- if not current_include:
1103
- if select_mask:
1104
- current_exclude = [(0, num_data-1)]
1105
- else:
1106
- current_include = [(0, num_data-1)]
1103
+ if (not isinstance(preselected_index_ranges, list)
1104
+ or any(not is_int_pair(v, ge=0, le=num_data)
1105
+ for v in preselected_index_ranges)):
1106
+ raise ValueError('Invalid parameter preselected_index_ranges '
1107
+ f'({preselected_index_ranges})')
1108
+ if (min_num_index_ranges is not None
1109
+ and len(preselected_index_ranges) < min_num_index_ranges):
1110
+ raise ValueError(
1111
+ 'Invalid parameter preselected_index_ranges '
1112
+ f'({preselected_index_ranges}), number of selected index '
1113
+ f'ranges must be >= {min_num_index_ranges}')
1114
+ if (max_num_index_ranges is not None
1115
+ and len(preselected_index_ranges) > max_num_index_ranges):
1116
+ raise ValueError(
1117
+ 'Invalid parameter preselected_index_ranges '
1118
+ f'({preselected_index_ranges}), number of selected index '
1119
+ f'ranges must be <= {max_num_index_ranges}')
1120
+
1121
+ spans = []
1122
+ fig_title = []
1123
+ error_texts = []
1124
+
1125
+ title_pos = (0.5, 0.95)
1126
+ title_props = {'fontsize': 'xx-large', 'horizontalalignment': 'center',
1127
+ 'verticalalignment': 'bottom'}
1128
+ error_pos = (0.5, 0.90)
1129
+ error_props = {'fontsize': 'x-large', 'horizontalalignment': 'center',
1130
+ 'verticalalignment': 'bottom'}
1131
+ excluded_props = {
1132
+ 'facecolor': 'white', 'edgecolor': 'gray', 'linestyle': ':'}
1133
+ included_props = {
1134
+ 'alpha': 0.5, 'facecolor': 'tab:blue', 'edgecolor': 'blue'}
1135
+
1136
+ fig, ax = plt.subplots(figsize=(11, 8.5))
1137
+ handles = ax.plot(x, y, color='k', label='Reference Data')
1138
+ handles.append(Patch(
1139
+ label='Excluded / unselected ranges', **excluded_props))
1140
+ handles.append(Patch(
1141
+ label='Included / selected ranges', **included_props))
1142
+ ax.legend(handles=handles)
1143
+ ax.set_xlabel(xlabel, fontsize='x-large')
1144
+ ax.set_ylabel(ylabel, fontsize='x-large')
1145
+ ax.set_xlim(x[0], x[-1])
1146
+ fig.subplots_adjust(bottom=0.0, top=0.85)
1147
+
1148
+ # Setup the preselected mask and index ranges if provided
1149
+ if preselected_mask is not None:
1150
+ preselected_index_ranges = update_index_ranges(
1151
+ update_mask(
1152
+ np.copy(np.asarray(preselected_mask, dtype=bool)),
1153
+ preselected_index_ranges))
1154
+ for min_, max_ in preselected_index_ranges:
1155
+ add_span(None, xrange_init=(x[min_], x[min(max_, num_data-1)]))
1156
+
1157
+ if not interactive:
1158
+
1159
+ get_selected_index_ranges(change_fig_title)
1160
+
1107
1161
  else:
1108
- if current_include[0][0] > 0:
1109
- current_exclude.append((0, current_include[0][0]-1))
1110
- for i in range(1, len(current_include)):
1111
- current_exclude.append(
1112
- (1+current_include[i-1][1], current_include[i][0]-1))
1113
- if current_include[-1][1] < num_data-1:
1114
- current_exclude.append((1+current_include[-1][1], num_data-1))
1115
-
1116
- # Set up matplotlib figure
1117
- plt.close('all')
1118
- fig, ax = plt.subplots()
1119
- plt.subplots_adjust(bottom=0.2)
1120
- draw_selections(
1121
- ax, current_include, current_exclude, selected_index_ranges)
1122
-
1123
- if not test_mode:
1124
- # Set up event handling for click-and-drag range selection
1125
- cid_click = fig.canvas.mpl_connect('button_press_event', onclick)
1126
- cid_release = fig.canvas.mpl_connect('button_release_event', onrelease)
1127
-
1128
- # Set up confirm / clear range selection buttons
1129
- confirm_b = Button(plt.axes([0.75, 0.015, 0.15, 0.075]), 'Confirm')
1130
- clear_b = Button(plt.axes([0.59, 0.015, 0.15, 0.075]), 'Clear')
1131
- cid_confirm = confirm_b.on_clicked(confirm_selection)
1132
- cid_clear = clear_b.on_clicked(clear_last_selection)
1133
-
1134
- # Show figure
1135
- plt.show(block=True)
1136
-
1137
- # Disconnect callbacks when figure is closed
1138
- fig.canvas.mpl_disconnect(cid_click)
1139
- fig.canvas.mpl_disconnect(cid_release)
1140
- confirm_b.disconnect(cid_confirm)
1141
- clear_b.disconnect(cid_clear)
1142
-
1143
- # Remove buttons & readjust axes before returning a figure
1144
- if return_figure:
1145
- confirm_b.ax.remove()
1146
- clear_b.ax.remove()
1147
- plt.subplots_adjust(bottom=0.0)
1148
-
1149
- # Swap selection depending on select_mask
1150
- if not select_mask:
1151
- selected_index_ranges, unselected_index_ranges = \
1152
- unselected_index_ranges, selected_index_ranges
1153
-
1154
- # Update the mask with the currently selected/unselected x-ranges
1155
- selected_mask = update_mask(
1156
- selected_mask, selected_index_ranges, unselected_index_ranges)
1157
-
1158
- # Update the currently included index ranges (where mask is True)
1159
- current_include = update_index_ranges(selected_mask)
1160
-
1161
- if return_figure:
1162
- return selected_mask, current_include, fig
1163
- return selected_mask, current_include
1164
-
1165
- def select_peaks(
1166
- ydata, xdata, peak_locations,
1167
- peak_labels=None,
1168
- mask=None,
1169
- pre_selected_peak_indices=[],
1170
- return_sorted=True,
1171
- title='', xlabel='', ylabel='',
1162
+
1163
+ change_fig_title(title)
1164
+ get_selected_index_ranges(change_error_text)
1165
+ fig.subplots_adjust(bottom=0.2)
1166
+
1167
+ # Setup "Add span" button
1168
+ add_span_btn = Button(plt.axes([0.15, 0.05, 0.15, 0.075]), 'Add span')
1169
+ add_span_cid = add_span_btn.on_clicked(add_span)
1170
+
1171
+ # Setup "Reset" button
1172
+ reset_btn = Button(plt.axes([0.45, 0.05, 0.15, 0.075]), 'Reset')
1173
+ reset_cid = reset_btn.on_clicked(reset)
1174
+
1175
+ # Setup "Confirm" button
1176
+ confirm_btn = Button(plt.axes([0.75, 0.05, 0.15, 0.075]), 'Confirm')
1177
+ confirm_cid = confirm_btn.on_clicked(confirm)
1178
+
1179
+ # Show figure for user interaction
1180
+ plt.show()
1181
+
1182
+ # Disconnect all widget callbacks when figure is closed
1183
+ add_span_btn.disconnect(add_span_cid)
1184
+ reset_btn.disconnect(reset_cid)
1185
+ confirm_btn.disconnect(confirm_cid)
1186
+
1187
+ # ...and remove the buttons before returning the figure
1188
+ add_span_btn.ax.remove()
1189
+ reset_btn.ax.remove()
1190
+ confirm_btn.ax.remove()
1191
+ plt.subplots_adjust(bottom=0.0)
1192
+
1193
+ selected_index_ranges = get_selected_index_ranges()
1194
+ if not selected_index_ranges:
1195
+ selected_index_ranges = None
1196
+
1197
+ # Update the mask with the currently selected index ranges
1198
+ selected_mask = update_mask(len(x)*[False], selected_index_ranges)
1199
+
1200
+ fig_title[0].set_in_layout(True)
1201
+ fig.tight_layout(rect=(0, 0, 1, 0.95))
1202
+
1203
+ return fig, selected_mask, selected_index_ranges
1204
+
1205
+
1206
+ def select_roi_1d(
1207
+ y, x=None, preselected_roi=None, title=None, xlabel=None, ylabel=None,
1172
1208
  interactive=True):
1173
- """
1174
- Show a plot of the 1D data provided with user-selectable markers
1175
- at the given locations. Return the locations of the markers that
1176
- the user selected with their mouse interactions.
1177
-
1178
- :param ydata: 1D array of values to plot
1179
- :type ydata: numpy.ndarray
1180
- :param xdata: values of the independent dimension corresponding to
1181
- ydata, defaults to None
1182
- :type xdata: numpy.ndarray, optional
1183
- :param peak_locations: locations of selectable markers in the same
1184
- units as xdata.
1185
- :type peak_locations: list
1186
- :param peak_labels: list of annotations for each peak, defaults to
1187
- None
1188
- :type peak_labels: list[str], optional
1189
- :param mask: boolean array representing a mask that will be
1190
- applied to the data at some later point, defaults to None
1191
- :type mask: np.ndarray, optional
1192
- :param pre_selected_peak_indices: indices of markers that should
1193
- already be selected when the figure shows up, defaults to []
1194
- :type pre_selected_peak_indices: list[int], optional
1195
- :param return_sorted: sort the indices of selected markers before
1196
- returning (otherwise: return them in the same order that the
1197
- user selected them), defaults to True
1198
- :type return_sorted: bool, optional
1199
- :param title: title for the plot, defaults to ''
1209
+ """Display a 2D plot and have the user select a single region
1210
+ of interest.
1211
+
1212
+ :param y: One-dimensional data array for which a for which a region of
1213
+ interest will be selected.
1214
+ :type y: numpy.ndarray
1215
+ :param x: x-coordinates of the data, defaults to `None`.
1216
+ :type x: numpy.ndarray, optional
1217
+ :param preselected_roi: Preselected region of interest,
1218
+ defaults to `None`.
1219
+ :type preselected_roi: tuple(int, int), optional
1220
+ :param title: Title for the displayed figure, defaults to `None`.
1200
1221
  :type title: str, optional
1201
- :param xlabel: x-axis label for the plot, defaults to ''
1222
+ :param xlabel: Label for the x-axis of the displayed figure,
1223
+ defaults to `None`.
1202
1224
  :type xlabel: str, optional
1203
- :param ylabel: y-axis label for the plot, defaults to ''
1225
+ :param ylabel: Label for the y-axis of the displayed figure,
1226
+ defaults to `None`.
1204
1227
  :type ylabel: str, optional
1205
- :param interactive: show the plot and allow user interactions with
1206
- the matplotlib figure, defults to True
1228
+ :param interactive: Show the plot and allow user interactions with
1229
+ the matplotlib figure, defults to `True`.
1207
1230
  :type interactive: bool, optional
1208
- :return: the locations of the user-selected peaks
1209
- :rtype: list
1231
+ :return: The selected region of interest as array indices and a
1232
+ matplotlib figure.
1233
+ :rtype: matplotlib.figure.Figure, tuple(int, int)
1210
1234
  """
1235
+ # Check inputs
1236
+ y = np.asarray(y)
1237
+ if y.ndim != 1:
1238
+ raise ValueError(f'Invalid image dimension ({y.ndim})')
1239
+ if preselected_roi is not None:
1240
+ if not is_int_pair(preselected_roi, ge=0, le=y.size, log=False):
1241
+ raise ValueError('Invalid parameter preselected_roi '
1242
+ f'({preselected_roi})')
1243
+ preselected_roi = [preselected_roi]
1244
+
1245
+ fig, mask, roi = select_mask_1d(
1246
+ y, x=x, preselected_index_ranges=preselected_roi, title=title,
1247
+ xlabel=xlabel, ylabel=ylabel, min_num_index_ranges=1,
1248
+ max_num_index_ranges=1, interactive=interactive)
1249
+
1250
+ return fig, tuple(roi[0])
1251
+
1252
+ def select_roi_2d(
1253
+ a, preselected_roi=None, title=None, title_a=None,
1254
+ row_label='row index', column_label='column index', interactive=True):
1255
+ """Display a 2D image and have the user select a single rectangular
1256
+ region of interest.
1257
+
1258
+ :param a: Two-dimensional image data array for which a region of
1259
+ interest will be selected.
1260
+ :type a: numpy.ndarray
1261
+ :param preselected_roi: Preselected region of interest,
1262
+ defaults to `None`.
1263
+ :type preselected_roi: tuple(int, int, int, int), optional
1264
+ :param title: Title for the displayed figure, defaults to `None`.
1265
+ :type title: str, optional
1266
+ :param title_a: Title for the image of a, defaults to `None`.
1267
+ :type title_a: str, optional
1268
+ :param row_label: Label for the y-axis of the displayed figure,
1269
+ defaults to `row index`.
1270
+ :type row_label: str, optional
1271
+ :param column_label: Label for the x-axis of the displayed figure,
1272
+ defaults to `column index`.
1273
+ :type column_label: str, optional
1274
+ :param interactive: Show the plot and allow user interactions with
1275
+ the matplotlib figure, defaults to `True`.
1276
+ :type interactive: bool, optional
1277
+ :return: The selected region of interest as array indices and a
1278
+ matplotlib figure.
1279
+ :rtype: matplotlib.figure.Figure, tuple(int, int, int, int)
1280
+ """
1281
+ # Third party modules
1282
+ from matplotlib.widgets import Button, RectangleSelector
1283
+
1284
+ # Local modules
1285
+ from CHAP.utils.general import index_nearest
1286
+
1287
+ def change_fig_title(title):
1288
+ if fig_title:
1289
+ fig_title[0].remove()
1290
+ fig_title.pop()
1291
+ fig_title.append(plt.figtext(*title_pos, title, **title_props))
1292
+
1293
+ def change_error_text(error):
1294
+ if error_texts:
1295
+ error_texts[0].remove()
1296
+ error_texts.pop()
1297
+ error_texts.append(plt.figtext(*error_pos, error, **error_props))
1298
+
1299
+ def on_rect_select(eclick, erelease):
1300
+ """Callback function for the RectangleSelector widget."""
1301
+ change_error_text(
1302
+ f'Selected ROI: {tuple(int(v) for v in rects[0].extents)}')
1303
+ plt.draw()
1304
+
1305
+ def reset(event):
1306
+ """Callback function for the "Reset" button."""
1307
+ if error_texts:
1308
+ error_texts[0].remove()
1309
+ error_texts.pop()
1310
+ rects[0].set_visible(False)
1311
+ rects.pop()
1312
+ rects.append(
1313
+ RectangleSelector(
1314
+ ax, on_rect_select, props=rect_props, useblit=True,
1315
+ interactive=interactive, drag_from_anywhere=True,
1316
+ ignore_event_outside=False))
1317
+ plt.draw()
1318
+
1319
+ def confirm(event):
1320
+ """Callback function for the "Confirm" button."""
1321
+ if error_texts:
1322
+ error_texts[0].remove()
1323
+ error_texts.pop()
1324
+ roi = tuple(int(v) for v in rects[0].extents)
1325
+ if roi[1]-roi[0] < 1 or roi[3]-roi[2] < 1:
1326
+ roi = None
1327
+ change_fig_title(f'Selected ROI: {roi}')
1328
+ plt.close()
1211
1329
 
1212
- if ydata.size != xdata.size:
1213
- raise ValueError('x and y data must have the same size')
1214
- if mask is not None and mask.size != ydata.size:
1215
- raise ValueError('mask must have the same size as data')
1330
+ fig_title = []
1331
+ error_texts = []
1216
1332
 
1333
+ # Check inputs
1334
+ a = np.asarray(a)
1335
+ if a.ndim != 2:
1336
+ raise ValueError(f'Invalid image dimension ({a.ndim})')
1337
+ if preselected_roi is not None:
1338
+ if (not is_int_series(preselected_roi, ge=0, log=False)
1339
+ or len(preselected_roi) != 4):
1340
+ raise ValueError('Invalid parameter preselected_roi '
1341
+ f'({preselected_roi})')
1342
+ if title is None:
1343
+ title = 'Click and drag to select or adjust a region of interest (ROI)'
1217
1344
 
1218
- excluded_peak_props = {
1219
- 'color': 'black', 'linestyle': '--','linewidth': 1,
1220
- 'marker': 10, 'markersize': 5, 'fillstyle': 'none'}
1221
- included_peak_props = {
1222
- 'color': 'green', 'linestyle': '-', 'linewidth': 2,
1223
- 'marker': 10, 'markersize': 10, 'fillstyle': 'full'}
1224
- masked_peak_props = {
1225
- 'color': 'gray', 'linestyle': ':', 'linewidth': 1}
1345
+ title_pos = (0.5, 0.95)
1346
+ title_props = {'fontsize': 'xx-large', 'horizontalalignment': 'center',
1347
+ 'verticalalignment': 'bottom'}
1348
+ error_pos = (0.5, 0.90)
1349
+ error_props = {'fontsize': 'xx-large', 'horizontalalignment': 'center',
1350
+ 'verticalalignment': 'bottom'}
1351
+ rect_props = {
1352
+ 'alpha': 0.5, 'facecolor': 'tab:blue', 'edgecolor': 'blue'}
1226
1353
 
1227
- # Setup reference data & plot
1228
- if mask is None:
1229
- mask = np.full(ydata.shape, True, dtype=bool)
1230
1354
  fig, ax = plt.subplots(figsize=(11, 8.5))
1231
- handles = ax.plot(xdata, ydata, label='Reference data')
1232
- handles.append(mlines.Line2D(
1233
- [], [], label='Excluded / unselected', **excluded_peak_props))
1234
- handles.append(mlines.Line2D(
1235
- [], [], label='Included / selected', **included_peak_props))
1236
- handles.append(mlines.Line2D(
1237
- [], [], label='In masked region (unselectable)', **masked_peak_props))
1238
- ax.legend(handles=handles, loc='center right')
1239
- ax.set(title=title, xlabel=xlabel, ylabel=ylabel)
1240
- fig.tight_layout()
1241
-
1242
- # Plot a vertical line marker at each peak location
1243
- peak_vlines = []
1244
- x_indices = np.arange(ydata.size)
1245
- if peak_labels is None:
1246
- peak_labels = [''] * len(peak_locations)
1247
- for i, (loc, lbl) in enumerate(zip(peak_locations, peak_labels)):
1248
- nearest_index = np.searchsorted(xdata, loc)
1249
- if nearest_index in x_indices[mask]:
1250
- if i in pre_selected_peak_indices:
1251
- peak_vline = ax.axvline(loc, **included_peak_props)
1252
- else:
1253
- peak_vline = ax.axvline(loc, **excluded_peak_props)
1254
- peak_vline.set_picker(5)
1255
- else:
1256
- if i in pre_selected_peak_indices:
1257
- logger.warning(
1258
- f'Pre-selected peak index {i} is in a masked region and '
1259
- 'will not be selectable.')
1260
- pre_selected_peak_indices.remove(i)
1261
- peak_vline = ax.axvline(loc, **masked_peak_props)
1262
- ax.text(loc, 1, lbl, ha='right', va='top', rotation=90,
1263
- transform=ax.get_xaxis_transform())
1264
- peak_vlines.append(peak_vline)
1265
-
1266
- # Indicate masked regions by gray-ing out the axes facecolor
1267
- exclude_bounds = []
1268
- for i, m in enumerate(mask):
1269
- if not m:
1270
- if (not exclude_bounds) or isinstance(exclude_bounds[-1], tuple):
1271
- exclude_bounds.append(i)
1272
- else:
1273
- if exclude_bounds and isinstance(exclude_bounds[-1], int):
1274
- exclude_bounds[-1] = (exclude_bounds[-1], i-1)
1275
- if exclude_bounds and isinstance(exclude_bounds[-1], int):
1276
- exclude_bounds[-1] = (exclude_bounds[-1], mask.size-1)
1277
- for (low, upp) in exclude_bounds:
1278
- xlow = xdata[low]
1279
- xupp = xdata[upp]
1280
- ax.axvspan(xlow, xupp, facecolor='gray', alpha=0.5)
1281
-
1282
- selected_peak_indices = pre_selected_peak_indices
1283
- if interactive:
1284
- # Setup interative peak selection
1285
- def onpick(event):
1286
- try:
1287
- peak_index = peak_vlines.index(event.artist)
1288
- except:
1289
- pass
1290
- else:
1291
- peak_vline = event.artist
1292
- if peak_index in selected_peak_indices:
1293
- peak_vline.set(**excluded_peak_props)
1294
- selected_peak_indices.remove(peak_index)
1295
- else:
1296
- peak_vline.set(**included_peak_props)
1297
- selected_peak_indices.append(peak_index)
1298
- plt.draw()
1299
- cid_pick_peak = fig.canvas.mpl_connect('pick_event', onpick)
1355
+ ax.imshow(a)
1356
+ ax.set_title(title_a, fontsize='xx-large')
1357
+ ax.set_xlabel(column_label, fontsize='x-large')
1358
+ ax.set_ylabel(row_label, fontsize='x-large')
1359
+ ax.set_xlim(0, a.shape[1])
1360
+ ax.set_ylim(a.shape[0], 0)
1361
+ fig.subplots_adjust(bottom=0.0, top=0.85)
1362
+
1363
+ # Setup the preselected range of interest if provided
1364
+ rects = [RectangleSelector(
1365
+ ax, on_rect_select, props=rect_props, useblit=True,
1366
+ interactive=interactive, drag_from_anywhere=True,
1367
+ ignore_event_outside=True)]
1368
+ if preselected_roi is not None:
1369
+ rects[0].extents = preselected_roi
1370
+
1371
+ if not interactive:
1372
+
1373
+ change_fig_title(
1374
+ f'Selected ROI: {tuple(int(v) for v in preselected_roi)}')
1375
+
1376
+ else:
1377
+
1378
+ change_fig_title(title)
1379
+ if preselected_roi is not None:
1380
+ change_error_text(
1381
+ f'Preselected ROI: {tuple(int(v) for v in preselected_roi)}')
1382
+ fig.subplots_adjust(bottom=0.2)
1383
+
1384
+ # Setup "Reset" button
1385
+ reset_btn = Button(plt.axes([0.125, 0.05, 0.15, 0.075]), 'Reset')
1386
+ reset_cid = reset_btn.on_clicked(reset)
1300
1387
 
1301
1388
  # Setup "Confirm" button
1302
- def confirm_selection(event):
1303
- plt.close()
1304
- plt.subplots_adjust(bottom=0.2)
1305
- confirm_b = Button(plt.axes([0.75, 0.05, 0.15, 0.075]), 'Confirm')
1306
- cid_confirm = confirm_b.on_clicked(confirm_selection)
1389
+ confirm_btn = Button(plt.axes([0.75, 0.05, 0.15, 0.075]), 'Confirm')
1390
+ confirm_cid = confirm_btn.on_clicked(confirm)
1307
1391
 
1308
1392
  # Show figure for user interaction
1309
1393
  plt.show()
1310
1394
 
1311
1395
  # Disconnect all widget callbacks when figure is closed
1312
- fig.canvas.mpl_disconnect(cid_pick_peak)
1313
- confirm_b.disconnect(cid_confirm)
1396
+ reset_btn.disconnect(reset_cid)
1397
+ confirm_btn.disconnect(confirm_cid)
1314
1398
 
1315
- # ...and remove the confirm button before returning the figure
1316
- confirm_b.ax.remove()
1317
- plt.subplots_adjust(bottom=0.0)
1399
+ # ... and remove the buttons before returning the figure
1400
+ reset_btn.ax.remove()
1401
+ confirm_btn.ax.remove()
1318
1402
 
1319
- selected_peaks = peak_locations[selected_peak_indices]
1320
- if return_sorted:
1321
- selected_peaks.sort()
1322
- return selected_peaks, fig
1403
+ fig_title[0].set_in_layout(True)
1404
+ fig.tight_layout(rect=(0, 0, 1, 0.95))
1323
1405
 
1324
-
1325
- def select_image_bounds(
1326
- a, axis, low=None, upp=None, num_min=None, title='select array bounds',
1327
- raise_error=False):
1328
- """
1329
- Interactively select the lower and upper data bounds for a 2D
1330
- numpy array.
1406
+ # Remove the handles before returning the figure
1407
+ if interactive:
1408
+ rects[0]._center_handle.set_visible(False)
1409
+ rects[0]._corner_handles.set_visible(False)
1410
+ rects[0]._edge_handles.set_visible(False)
1411
+
1412
+ roi = tuple(int(v) for v in rects[0].extents)
1413
+ if roi[1]-roi[0] < 1 or roi[3]-roi[2] < 1:
1414
+ roi = None
1415
+
1416
+ return fig, roi
1417
+
1418
+
1419
+ def select_image_indices(
1420
+ a, axis, b=None, preselected_indices=None, axis_index_offset=0,
1421
+ min_range=None, min_num_indices=2, max_num_indices=2, title=None,
1422
+ title_a=None, title_b=None, row_label='row index',
1423
+ column_label='column index', interactive=True):
1424
+ """Display a 2D image and have the user select a set of image
1425
+ indices in either row or column direction.
1426
+
1427
+ :param a: Two-dimensional image data array for which a region of
1428
+ interest will be selected.
1429
+ :type a: numpy.ndarray
1430
+ :param axis: The selection direction (0: row, 1: column)
1431
+ :type axis: int
1432
+ :param b: A secondary two-dimensional image data array for which
1433
+ a shared region of interest will be selected,
1434
+ defaults to `None`.
1435
+ :type b: numpy.ndarray, optional
1436
+ :param preselected_indices: Preselected image indices,
1437
+ defaults to `None`.
1438
+ :type preselected_indices: tuple(int), list(int), optional
1439
+ :param axis_index_offset: Offset in axes index range and
1440
+ preselected indices, defaults to `0`.
1441
+ :type axis_index_offset: int, optional
1442
+ :param min_range: The minimal range spanned by the selected
1443
+ indices, defaults to `None`
1444
+ :type min_range: int, optional
1445
+ :param min_num_indices: The minimum number of selected indices,
1446
+ defaults to `None`.
1447
+ :type min_num_indices: int, optional
1448
+ :param max_num_indices: The maximum number of selected indices,
1449
+ defaults to `None`.
1450
+ :type max_num_indices: int, optional
1451
+ :param title: Title for the displayed figure, defaults to `None`.
1452
+ :type title: str, optional
1453
+ :param title_a: Title for the image of a, defaults to `None`.
1454
+ :type title_a: str, optional
1455
+ :param title_b: Title for the image of b, defaults to `None`.
1456
+ :type title_b: str, optional
1457
+ :param row_label: Label for the y-axis of the displayed figure,
1458
+ defaults to `row index`.
1459
+ :type row_label: str, optional
1460
+ :param column_label: Label for the x-axis of the displayed figure,
1461
+ defaults to `column index`.
1462
+ :type column_label: str, optional
1463
+ :param interactive: Show the plot and allow user interactions with
1464
+ the matplotlib figure, defaults to `True`.
1465
+ :type interactive: bool, optional
1466
+ :return: The selected region of interest as array indices and a
1467
+ matplotlib figure.
1468
+ :rtype: matplotlib.figure.Figure, tuple(int, int, int, int)
1331
1469
  """
1332
- a = np.asarray(a)
1333
- if a.ndim != 2:
1334
- illegal_value(
1335
- a.ndim, 'array dimension', location='select_image_bounds',
1336
- raise_error=raise_error)
1337
- return None
1338
- if axis < 0 or axis >= a.ndim:
1339
- illegal_value(
1340
- axis, 'axis', location='select_image_bounds',
1341
- raise_error=raise_error)
1342
- return None
1343
- low_save = low
1344
- upp_save = upp
1345
- num_min_save = num_min
1346
- if num_min is None:
1347
- num_min = 1
1348
- else:
1349
- if num_min < 2 or num_min > a.shape[axis]:
1350
- logger.warning(
1351
- 'Invalid input for num_min in select_image_bounds, '
1352
- 'input ignored')
1353
- num_min = 1
1354
- if low is None:
1355
- min_ = 0
1356
- max_ = a.shape[axis]
1357
- low_max = a.shape[axis]-num_min
1358
- while True:
1359
- if axis:
1360
- quick_imshow(
1361
- a[:,min_:max_], title=title, aspect='auto',
1362
- extent=[min_,max_,a.shape[0],0])
1470
+ # Third party modules
1471
+ from matplotlib.widgets import TextBox, Button
1472
+
1473
+ def change_fig_title(title):
1474
+ if fig_title:
1475
+ fig_title[0].remove()
1476
+ fig_title.pop()
1477
+ fig_title.append(plt.figtext(*title_pos, title, **title_props))
1478
+
1479
+ def change_error_text(error):
1480
+ if error_texts:
1481
+ error_texts[0].remove()
1482
+ error_texts.pop()
1483
+ error_texts.append(plt.figtext(*error_pos, error, **error_props))
1484
+
1485
+ def get_selected_indices(change_fnc=None):
1486
+ selected_indices = tuple(sorted(indices))
1487
+ if change_fnc is not None:
1488
+ num_indices = len(indices)
1489
+ if len(selected_indices) > 1:
1490
+ text = f'Selected {row_column} indices: {selected_indices}'
1491
+ elif selected_indices:
1492
+ text = f'Selected {row_column} index: {selected_indices[0]}'
1363
1493
  else:
1364
- quick_imshow(
1365
- a[min_:max_,:], title=title, aspect='auto',
1366
- extent=[0,a.shape[1], max_,min_])
1367
- zoom_flag = input_yesno(
1368
- 'Set lower data bound (y) or zoom in (n)?', 'y')
1369
- if zoom_flag:
1370
- low = input_int(' Set lower data bound', ge=0, le=low_max)
1371
- break
1372
- min_ = input_int(' Set lower zoom index', ge=0, le=low_max)
1373
- max_ = input_int(
1374
- ' Set upper zoom index', ge=min_+1, le=low_max+1)
1375
- else:
1376
- if not is_int(low, ge=0, le=a.shape[axis]-num_min):
1377
- illegal_value(
1378
- low, 'low', location='select_image_bounds',
1379
- raise_error=raise_error)
1380
- return None
1381
- if upp is None:
1382
- min_ = low+num_min
1383
- max_ = a.shape[axis]
1384
- upp_min = min_
1385
- while True:
1386
- if axis:
1387
- quick_imshow(
1388
- a[:,min_:max_], title=title, aspect='auto',
1389
- extent=[min_,max_,a.shape[0],0])
1494
+ text = f'Selected {row_column} indices: None'
1495
+ if min_num_indices is not None and num_indices < min_num_indices:
1496
+ if min_num_indices == max_num_indices:
1497
+ text += \
1498
+ f', select another {max_num_indices-num_indices}'
1499
+ else:
1500
+ text += \
1501
+ f', select at least {max_num_indices-num_indices} more'
1502
+ change_fnc(text)
1503
+ return selected_indices
1504
+
1505
+ def add_index(index):
1506
+ if index in indices:
1507
+ raise ValueError(f'Ignoring duplicate of selected {row_column}s')
1508
+ elif max_num_indices is not None and len(indices) >= max_num_indices:
1509
+ raise ValueError(
1510
+ f'Exceeding maximum number of selected {row_column}s, click '
1511
+ 'either "Reset" or "Confirm"')
1512
+ elif (len(indices) and min_range is not None
1513
+ and abs(max(index, max(indices)) - min(index, min(indices)))
1514
+ < min_range):
1515
+ raise ValueError(
1516
+ f'Selected {row_column} range is smaller than required '
1517
+ 'minimal range of {min_range}: ignoring last selection')
1518
+ else:
1519
+ indices.append(index)
1520
+ if not axis:
1521
+ for ax in axs:
1522
+ lines.append(ax.axhline(indices[-1], c='r', lw=2))
1390
1523
  else:
1391
- quick_imshow(
1392
- a[min_:max_,:], title=title, aspect='auto',
1393
- extent=[0,a.shape[1], max_,min_])
1394
- zoom_flag = input_yesno(
1395
- 'Set upper data bound (y) or zoom in (n)?', 'y')
1396
- if zoom_flag:
1397
- upp = input_int(
1398
- ' Set upper data bound', ge=upp_min, le=a.shape[axis])
1399
- break
1400
- min_ = input_int(
1401
- ' Set upper zoom index', ge=upp_min, le=a.shape[axis]-1)
1402
- max_ = input_int(
1403
- ' Set upper zoom index', ge=min_+1, le=a.shape[axis])
1404
- else:
1405
- if not is_int(upp, ge=low+num_min, le=a.shape[axis]):
1406
- illegal_value(
1407
- upp, 'upp', location='select_image_bounds',
1408
- raise_error=raise_error)
1409
- return None
1410
- bounds = (low, upp)
1411
- a_tmp = np.copy(a)
1412
- a_tmp_max = a.max()
1413
- if axis:
1414
- a_tmp[:,bounds[0]] = a_tmp_max
1415
- a_tmp[:,bounds[1]-1] = a_tmp_max
1416
- else:
1417
- a_tmp[bounds[0],:] = a_tmp_max
1418
- a_tmp[bounds[1]-1,:] = a_tmp_max
1419
- print(f'lower bound = {low} (inclusive)\nupper bound = {upp} (exclusive)')
1420
- quick_imshow(a_tmp, title=title, aspect='auto')
1421
- del a_tmp
1422
- if not input_yesno('Accept these bounds (y/n)?', 'y'):
1423
- bounds = select_image_bounds(
1424
- a, axis, low=low_save, upp=upp_save, num_min=num_min_save,
1425
- title=title)
1426
- clear_imshow(title)
1427
- return bounds
1428
-
1429
-
1430
- def select_one_image_bound(
1431
- a, axis, bound=None, bound_name=None, title='select array bounds',
1432
- default='y', raise_error=False):
1433
- """
1434
- Interactively select a data boundary for a 2D numpy array.
1435
- """
1524
+ for ax in axs:
1525
+ lines.append(ax.axvline(indices[-1], c='r', lw=2))
1526
+
1527
+ def select_index(expression):
1528
+ """Callback function for the "Select row/column index" TextBox.
1529
+ """
1530
+ if not len(expression):
1531
+ return
1532
+ if error_texts:
1533
+ error_texts[0].remove()
1534
+ error_texts.pop()
1535
+ try:
1536
+ index = int(expression)
1537
+ if (index < axis_index_offset
1538
+ or index >= axis_index_offset+a.shape[axis]):
1539
+ raise ValueError
1540
+ except ValueError:
1541
+ change_error_text(
1542
+ f'Invalid {row_column} index ({expression}), enter an integer '
1543
+ f'between {axis_index_offset} and '
1544
+ f'{axis_index_offset+a.shape[axis]-1}')
1545
+ else:
1546
+ try:
1547
+ add_index(index)
1548
+ get_selected_indices(change_error_text)
1549
+ except ValueError as e:
1550
+ change_error_text(e)
1551
+ index_input.set_val('')
1552
+ for ax in axs:
1553
+ ax.get_figure().canvas.draw()
1554
+
1555
+ def reset(event):
1556
+ """Callback function for the "Reset" button."""
1557
+ if error_texts:
1558
+ error_texts[0].remove()
1559
+ error_texts.pop()
1560
+ for line in reversed(lines):
1561
+ line.remove()
1562
+ indices.clear()
1563
+ lines.clear()
1564
+ get_selected_indices(change_error_text)
1565
+ for ax in axs:
1566
+ ax.get_figure().canvas.draw()
1567
+
1568
+ def confirm(event):
1569
+ """Callback function for the "Confirm" button."""
1570
+ if len(indices) < min_num_indices:
1571
+ change_error_text(
1572
+ f'Select at least {min_num_indices} unique {row_column}s')
1573
+ for ax in axs:
1574
+ ax.get_figure().canvas.draw()
1575
+ else:
1576
+ # Remove error texts and add selected indices if set
1577
+ if error_texts:
1578
+ error_texts[0].remove()
1579
+ error_texts.pop()
1580
+ get_selected_indices(change_fig_title)
1581
+ plt.close()
1582
+
1583
+ # Check inputs
1436
1584
  a = np.asarray(a)
1437
1585
  if a.ndim != 2:
1438
- illegal_value(
1439
- a.ndim, 'array dimension', location='select_one_image_bound',
1440
- raise_error=raise_error)
1441
- return None
1586
+ raise ValueError(f'Invalid image dimension ({a.ndim})')
1442
1587
  if axis < 0 or axis >= a.ndim:
1443
- illegal_value(
1444
- axis, 'axis', location='select_one_image_bound',
1445
- raise_error=raise_error)
1446
- return None
1447
- if bound_name is None:
1448
- bound_name = 'data bound'
1449
- if bound is None:
1450
- min_ = 0
1451
- max_ = a.shape[axis]
1452
- bound_max = a.shape[axis]-1
1453
- while True:
1454
- if axis:
1455
- quick_imshow(
1456
- a[:,min_:max_], title=title, aspect='auto',
1457
- extent=[min_,max_,a.shape[0],0])
1588
+ raise ValueError(f'Invalid parameter axis ({axis})')
1589
+ if not axis:
1590
+ row_column = 'row'
1591
+ else:
1592
+ row_column = 'column'
1593
+ if not is_int(axis_index_offset, ge=0, log=False):
1594
+ raise ValueError(
1595
+ 'Invalid parameter axis_index_offset ({axis_index_offset})')
1596
+ if preselected_indices is not None:
1597
+ if not is_int_series(
1598
+ preselected_indices, ge=axis_index_offset,
1599
+ le=axis_index_offset+a.shape[axis], log=False):
1600
+ if interactive:
1601
+ logger.warning(
1602
+ 'Invalid parameter preselected_indices '
1603
+ f'({preselected_indices}), ignoring preselected_indices')
1604
+ preselected_indices = None
1458
1605
  else:
1459
- quick_imshow(
1460
- a[min_:max_,:], title=title, aspect='auto',
1461
- extent=[0,a.shape[1], max_,min_])
1462
- zoom_flag = input_yesno(
1463
- f'Set {bound_name} (y) or zoom in (n)?', 'y')
1464
- if zoom_flag:
1465
- bound = input_int(f' Set {bound_name}', ge=0, le=bound_max)
1466
- clear_imshow(title)
1467
- break
1468
- min_ = input_int(' Set lower zoom index', ge=0, le=bound_max)
1469
- max_ = input_int(
1470
- ' Set upper zoom index', ge=min_+1, le=bound_max+1)
1471
-
1472
- elif not is_int(bound, ge=0, le=a.shape[axis]-1):
1473
- illegal_value(
1474
- bound, 'bound', location='select_one_image_bound',
1475
- raise_error=raise_error)
1476
- return None
1606
+ raise ValueError('Invalid parameter preselected_indices '
1607
+ f'({preselected_indices})')
1608
+ if min_range is not None and not 2 <= min_range <= a.shape[axis]:
1609
+ raise ValueError('Invalid parameter min_range ({min_range})')
1610
+ if title is None:
1611
+ title = f'Select or adjust image {row_column} indices'
1612
+ if b is not None:
1613
+ b = np.asarray(b)
1614
+ if b.ndim != 2:
1615
+ raise ValueError(f'Invalid image dimension ({b.ndim})')
1616
+ if a.shape[0] != b.shape[0]:
1617
+ raise ValueError(f'Inconsistent image shapes({a.shape} vs '
1618
+ f'{b.shape})')
1619
+
1620
+ indices = []
1621
+ lines = []
1622
+ fig_title = []
1623
+ error_texts = []
1624
+
1625
+ title_pos = (0.5, 0.95)
1626
+ title_props = {'fontsize': 'xx-large', 'horizontalalignment': 'center',
1627
+ 'verticalalignment': 'bottom'}
1628
+ error_pos = (0.5, 0.90)
1629
+ error_props = {'fontsize': 'x-large', 'horizontalalignment': 'center',
1630
+ 'verticalalignment': 'bottom'}
1631
+ if b is None:
1632
+ fig, axs = plt.subplots(figsize=(11, 8.5))
1633
+ axs = [axs]
1477
1634
  else:
1478
- print(f'Current {bound_name} = {bound}')
1479
- a_tmp = np.copy(a)
1480
- a_tmp_max = a.max()
1481
- if axis:
1482
- a_tmp[:,bound] = a_tmp_max
1635
+ if a.shape[0]+b.shape[0] > max(a.shape[1], b.shape[1]):
1636
+ fig, axs = plt.subplots(1, 2, figsize=(11, 8.5))
1637
+ else:
1638
+ fig, axs = plt.subplots(2, 1, figsize=(11, 8.5))
1639
+ extent = (0, a.shape[1], axis_index_offset+a.shape[0], axis_index_offset)
1640
+ axs[0].imshow(a, extent=extent)
1641
+ axs[0].set_title(title_a, fontsize='xx-large')
1642
+ if b is not None:
1643
+ axs[1].imshow(b, extent=extent)
1644
+ axs[1].set_title(title_b, fontsize='xx-large')
1645
+ if a.shape[0]+b.shape[0] > max(a.shape[1], b.shape[1]):
1646
+ axs[0].set_xlabel(column_label, fontsize='x-large')
1647
+ axs[0].set_ylabel(row_label, fontsize='x-large')
1648
+ axs[1].set_xlabel(column_label, fontsize='x-large')
1649
+ else:
1650
+ axs[0].set_ylabel(row_label, fontsize='x-large')
1651
+ axs[1].set_xlabel(column_label, fontsize='x-large')
1652
+ axs[1].set_ylabel(row_label, fontsize='x-large')
1653
+ for ax in axs:
1654
+ ax.set_xlim(extent[0], extent[1])
1655
+ ax.set_ylim(extent[2], extent[3])
1656
+ fig.subplots_adjust(bottom=0.0, top=0.85)
1657
+
1658
+ # Setup the preselected indices if provided
1659
+ if preselected_indices is not None:
1660
+ preselected_indices = sorted(list(preselected_indices))
1661
+ for index in preselected_indices:
1662
+ add_index(index)
1663
+
1664
+ if not interactive:
1665
+
1666
+ get_selected_indices(change_fig_title)
1667
+
1483
1668
  else:
1484
- a_tmp[bound,:] = a_tmp_max
1485
- quick_imshow(a_tmp, title=title, aspect='auto')
1486
- del a_tmp
1487
- if not input_yesno(f'Accept this {bound_name} (y/n)?', default):
1488
- bound = select_one_image_bound(
1489
- a, axis, bound_name=bound_name, title=title)
1490
- clear_imshow(title)
1491
- return bound
1492
-
1493
-
1494
- def clear_imshow(title=None):
1495
- """Clear an image opened by quick_imshow()."""
1496
- plt.ioff()
1497
- if title is None:
1498
- title = 'quick imshow'
1499
- elif not isinstance(title, str):
1500
- raise ValueError(f'Invalid parameter title ({title})')
1501
- plt.close(fig=title)
1502
1669
 
1670
+ change_fig_title(title)
1671
+ get_selected_indices(change_error_text)
1672
+ fig.subplots_adjust(bottom=0.2)
1503
1673
 
1504
- def clear_plot(title=None):
1505
- """Clear an image opened by quick_plot()."""
1506
- plt.ioff()
1507
- if title is None:
1508
- title = 'quick plot'
1509
- elif not isinstance(title, str):
1510
- raise ValueError(f'Invalid parameter title ({title})')
1511
- plt.close(fig=title)
1674
+ # Setup TextBox
1675
+ index_input = TextBox(
1676
+ plt.axes([0.25, 0.05, 0.15, 0.075]), f'Select {row_column} index ')
1677
+ indices_cid = index_input.on_submit(select_index)
1678
+
1679
+ # Setup "Reset" button
1680
+ reset_btn = Button(plt.axes([0.5, 0.05, 0.15, 0.075]), 'Reset')
1681
+ reset_cid = reset_btn.on_clicked(reset)
1682
+
1683
+ # Setup "Confirm" button
1684
+ confirm_btn = Button(plt.axes([0.75, 0.05, 0.15, 0.075]), 'Confirm')
1685
+ confirm_cid = confirm_btn.on_clicked(confirm)
1686
+
1687
+ plt.show()
1688
+
1689
+ # Disconnect all widget callbacks when figure is closed
1690
+ index_input.disconnect(indices_cid)
1691
+ reset_btn.disconnect(reset_cid)
1692
+ confirm_btn.disconnect(confirm_cid)
1693
+
1694
+ # ... and remove the buttons before returning the figure
1695
+ index_input.ax.remove()
1696
+ reset_btn.ax.remove()
1697
+ confirm_btn.ax.remove()
1698
+
1699
+ fig_title[0].set_in_layout(True)
1700
+ fig.tight_layout(rect=(0, 0, 1, 0.95))
1701
+
1702
+ if indices:
1703
+ return fig, tuple(sorted(indices))
1704
+ return fig, None
1512
1705
 
1513
1706
 
1514
1707
  def quick_imshow(
1515
- a, title=None, path=None, name=None, save_fig=False, save_only=False,
1516
- clear=True, extent=None, show_grid=False, grid_color='w',
1517
- grid_linewidth=1, block=False, **kwargs):
1708
+ a, title=None, row_label='row index', column_label='column index',
1709
+ path=None, name=None, save_fig=False, save_only=False,
1710
+ extent=None, show_grid=False, grid_color='w', grid_linewidth=1,
1711
+ block=False, **kwargs):
1518
1712
  """Display a 2D image."""
1519
1713
  if title is not None and not isinstance(title, str):
1520
1714
  raise ValueError(f'Invalid parameter title ({title})')
@@ -1524,8 +1718,6 @@ def quick_imshow(
1524
1718
  raise ValueError(f'Invalid parameter save_fig ({save_fig})')
1525
1719
  if not isinstance(save_only, bool):
1526
1720
  raise ValueError(f'Invalid parameter save_only ({save_only})')
1527
- if not isinstance(clear, bool):
1528
- raise ValueError(f'Invalid parameter clear ({clear})')
1529
1721
  if not isinstance(block, bool):
1530
1722
  raise ValueError(f'Invalid parameter block ({block})')
1531
1723
  if not title:
@@ -1533,9 +1725,9 @@ def quick_imshow(
1533
1725
  if name is None:
1534
1726
  ttitle = re_sub(r'\s+', '_', title)
1535
1727
  if path is None:
1536
- path = f'{ttitle}.png'
1728
+ path = ttitle
1537
1729
  else:
1538
- path = f'{path}/{ttitle}.png'
1730
+ path = f'{path}/{ttitle}'
1539
1731
  else:
1540
1732
  if path is None:
1541
1733
  path = name
@@ -1558,26 +1750,25 @@ def quick_imshow(
1558
1750
  kwargs.pop('cmap')
1559
1751
  if extent is None:
1560
1752
  extent = (0, a.shape[1], a.shape[0], 0)
1561
- if clear:
1562
- try:
1563
- plt.close(fig=title)
1564
- except:
1565
- pass
1566
1753
  if not save_only:
1567
1754
  if block:
1568
1755
  plt.ioff()
1569
1756
  else:
1570
1757
  plt.ion()
1571
- plt.figure(title)
1758
+ plt.figure(figsize=(11, 8.5))
1572
1759
  plt.imshow(a, extent=extent, **kwargs)
1760
+ ax = plt.gca()
1761
+ ax.set_title(title, fontsize='xx-large')
1762
+ ax.set_xlabel(column_label, fontsize='x-large')
1763
+ ax.set_ylabel(row_label, fontsize='x-large')
1573
1764
  if show_grid:
1574
- ax = plt.gca()
1575
1765
  ax.grid(color=grid_color, linewidth=grid_linewidth)
1576
- # if title != 'quick imshow':
1577
- # plt.title = title
1766
+ if (os_path.splitext(path)[1]
1767
+ not in plt.gcf().canvas.get_supported_filetypes()):
1768
+ path += '.png'
1578
1769
  if save_only:
1579
1770
  plt.savefig(path)
1580
- plt.close(fig=title)
1771
+ plt.close()
1581
1772
  else:
1582
1773
  if save_fig:
1583
1774
  plt.savefig(path)
@@ -1588,8 +1779,8 @@ def quick_imshow(
1588
1779
  def quick_plot(
1589
1780
  *args, xerr=None, yerr=None, vlines=None, title=None, xlim=None,
1590
1781
  ylim=None, xlabel=None, ylabel=None, legend=None, path=None, name=None,
1591
- show_grid=False, save_fig=False, save_only=False, clear=True,
1592
- block=False, **kwargs):
1782
+ show_grid=False, save_fig=False, save_only=False, block=False,
1783
+ **kwargs):
1593
1784
  """Display a 2D line plot."""
1594
1785
  if title is not None and not isinstance(title, str):
1595
1786
  illegal_value(title, 'title', 'quick_plot')
@@ -1623,9 +1814,6 @@ def quick_plot(
1623
1814
  if not isinstance(save_only, bool):
1624
1815
  illegal_value(save_only, 'save_only', 'quick_plot')
1625
1816
  return
1626
- if not isinstance(clear, bool):
1627
- illegal_value(clear, 'clear', 'quick_plot')
1628
- return
1629
1817
  if not isinstance(block, bool):
1630
1818
  illegal_value(block, 'block', 'quick_plot')
1631
1819
  return
@@ -1642,14 +1830,9 @@ def quick_plot(
1642
1830
  path = name
1643
1831
  else:
1644
1832
  path = f'{path}/{name}'
1645
- if clear:
1646
- try:
1647
- plt.close(fig=title)
1648
- except:
1649
- pass
1650
1833
  args = unwrap_tuple(args)
1651
1834
  if depth_tuple(args) > 1 and (xerr is not None or yerr is not None):
1652
- logger.warning('Error bars ignored form multiple curves')
1835
+ logger.warning('Error bars ignored for multiple curves')
1653
1836
  if not save_only:
1654
1837
  if block:
1655
1838
  plt.ioff()
@@ -1669,10 +1852,6 @@ def quick_plot(
1669
1852
  vlines = [vlines]
1670
1853
  for v in vlines:
1671
1854
  plt.axvline(v, color='r', linestyle='--', **kwargs)
1672
- # if vlines is not None:
1673
- # for s in tuple(
1674
- # ([x, x], list(plt.gca().get_ylim())) for x in vlines):
1675
- # plt.plot(*s, color='red', **kwargs)
1676
1855
  if xlim is not None:
1677
1856
  plt.xlim(xlim)
1678
1857
  if ylim is not None: