From 3a09d1a97a27b309fc6651d45d3073357ba3d8ec Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Tue, 17 Jul 2018 12:53:42 +0200 Subject: [PATCH 001/107] upgrading scannables --- artemis/general/scannable_functions.py | 94 +++++++++++++++------ artemis/general/test_scannable_functions.py | 10 +-- 2 files changed, 75 insertions(+), 29 deletions(-) diff --git a/artemis/general/scannable_functions.py b/artemis/general/scannable_functions.py index 3d0faa6d..c9a1b8e5 100644 --- a/artemis/general/scannable_functions.py +++ b/artemis/general/scannable_functions.py @@ -1,4 +1,9 @@ -def scannable(state, output=None, returns=None): +from collections import namedtuple + +from artemis.general.should_be_builtins import izip_equal + + +def scannable(state, returns=None, output=None): """ A decorator for turning functions into stateful objects. The decorator attaches a "scan" method to the given function, which can be called to create an object which stores the state that gets fed back into the next function call. This @@ -18,18 +23,44 @@ def simple_moving_average(x, avg, n): :param dict state: A dictionary whose keys are the names of the arguments to feed back, and whose values are the default initial state. These initial values can be overridden when calling function.scan(arg_name, initial_value) - :param Optional[Union[str, Sequence[str]]] output: If there is more than one state variable or more than one output, + :param Optional[Union[str, Sequence[str]]] returns: If there is more than one state variable or more than one output, include the list of output names, so that the scan knows which outputs to use to update the state. - :param Optional[Union[str, Sequence[str]]] returns: If there is more than one output and you only wish to return a + :param Optional[Union[str, Sequence[str]]] output: If there is more than one output and you only wish to return a subset of the outputs, indicate here which variables you want to return. :return: func, but with a "scan" function attched. """ def wrapper(func): def create_scannable(**kwargs): - return Scannable(func=func, state=state, output=output, returns=returns, kwargs=kwargs) - + return Scannable(func=func, state=state, returns=returns, output=output, kwargs=kwargs) func.scan = create_scannable + + + + + class StatelessUpdater(namedtuple('StatelessUpdater for {}'.format(func.__name__))): + + def __call__(self, *args, **kwargs): + + + + + return StatelessUpdater(*(return_value for return_name, return_value in izip_equal(returns, return_values) if return_name in state)) + + + state_object = None if isinstance(state, str) else namedtuple('State of {}'.format(func.__name__), state) + output_object = None if isinstance(output, str) else namedtuple('Output of {}'.format(func.__name__, output)) + + def standard_form(input_values, state_values): + + kwargs = input_value + + + return output_values, state_values + + + func.standard_form = standard_form + return func return wrapper @@ -40,7 +71,7 @@ class Scannable(object): SINGLE_OUTPUT_FORMAT = object() TUPLE_OUTPUT_FORMAT = object() - def __init__(self, func, state, output, returns, kwargs = None): + def __init__(self, func, state, returns, output, kwargs = None): """ See scannable docstring """ @@ -52,34 +83,34 @@ def __init__(self, func, state, output, returns, kwargs = None): if kwargs is not None: state.update(kwargs) - if output is None: + if returns is None: assert len(state_names)==1, "If there is more than one state variable, you must specify the output!" - output = next(iter(state_names)) - if isinstance(output, str): - assert output in state_names, 'Output name "{}" was not provided not included in the state dict: "{}"'.format(output, state_names) + returns = next(iter(state_names)) + if isinstance(returns, str): + assert returns in state_names, 'Output name "{}" was not provided not included in the state dict: "{}"'.format(returns, state_names) self._output_format = Scannable.SINGLE_OUTPUT_FORMAT - self._state_names = output - output_names = [output] + self._state_names = returns + output_names = [returns] self._output_format = Scannable.SINGLE_OUTPUT_FORMAT else: - assert isinstance(output, (list, tuple)), "output must be a string, a list/tuple, or None" - assert all(sn in output for sn in state_names), 'Variabels name(s) {} were listed as state variables but not included in the list of outputs: {}'.format([sn for sn in state_names if sn not in output], output) - output_names = output + assert isinstance(returns, (list, tuple)), "output must be a string, a list/tuple, or None" + assert all(sn in returns for sn in state_names), 'Variabels name(s) {} were listed as state variables but not included in the list of outputs: {}'.format([sn for sn in state_names if sn not in returns], returns) + output_names = returns self._output_format = Scannable.TUPLE_OUTPUT_FORMAT self._state_names = tuple(state_names) self._state_indices_in_output = [output_names.index(state_name) for state_name in state_names] - if isinstance(returns, str): - assert output is not None, 'If you specify returns, you must specify output' - if isinstance(output, str): - assert returns==output_names + if isinstance(output, str): + assert returns is not None, 'If you specify returns, you must specify output' + if isinstance(returns, str): + assert output == output_names return_index = None else: - assert isinstance(output, (list, tuple)) - return_index = output.index(returns) - elif isinstance(returns, (list, tuple)): - return_index = tuple(output_names.index(r) for r in returns) + assert isinstance(returns, (list, tuple)) + return_index = returns.index(output) + elif isinstance(output, (list, tuple)): + return_index = tuple(output_names.index(r) for r in output) else: - assert returns is None + assert output is None return_index = None self.func = func @@ -109,3 +140,18 @@ def __call__(self, *args, **kwargs): @property def state(self): return self._state.copy() + +# +# class ScannableStateLess(object): +# +# def __init__(self, func, inputs, returns, outputs): +# pass +# +# def __call__(self, *args, **kwargs): +# """ +# :param args: +# :param kwargs: +# :return: +# """ + + diff --git a/artemis/general/test_scannable_functions.py b/artemis/general/test_scannable_functions.py index d8e3bed3..f62f7115 100644 --- a/artemis/general/test_scannable_functions.py +++ b/artemis/general/test_scannable_functions.py @@ -8,7 +8,7 @@ def test_simple_moving_average(): seq = np.random.randn(100) + np.sin(np.linspace(0, 10, 100)) - @scannable(state=['avg', 'n'], output=['avg', 'n'], returns='avg') + @scannable(state=['avg', 'n'], returns=['avg', 'n'], output='avg') def simple_moving_average(x, avg=0, n=0): return (n/(1.+n))*avg + (1./(1.+n))*x, n+1 @@ -51,7 +51,7 @@ def test_rnn_type_comp(): w_hh = rng.randn(n_hid, n_hid) w_hy = rng.randn(n_hid, n_out) - @scannable(state='hid', output=['out', 'hid'], returns='out') + @scannable(state='hid', returns=['out', 'hid'], output='out') def rnn_like_func(x, hid= np.zeros(n_hid)): new_hid = np.tanh(x.dot(w_xh) + hid.dot(w_hh)) out = new_hid.dot(w_hy) @@ -87,19 +87,19 @@ def moving_average_with_typo(x, decay, avg=0): simply_smoothed_signal = [f(x=x, decay=1./(t+1)) for t, x in enumerate(seq)] with pytest.raises(AssertionError): # Should really be done before instance-creation, but whatever. - @scannable(state='avg', output='avgf') + @scannable(state='avg', returns='avgf') def moving_average_with_typo(x, decay, avg=0): return (1-decay)*avg + decay*x f = moving_average_with_typo.scan() with pytest.raises(ValueError): # Invalid return name - @scannable(state=['avg'], output=['avg'], returns='avgf') + @scannable(state=['avg'], returns=['avg'], output='avgf') def moving_average_with_typo(x, decay, avg=0): return (1-decay)*avg + decay*x f = moving_average_with_typo.scan() with pytest.raises(TypeError): # Wrong output format - @scannable(state=['avg'], output=['avg', 'something'], returns='avg') + @scannable(state=['avg'], returns=['avg', 'something'], output='avg') def moving_average_with_typo(x, decay, avg=0): return (1-decay)*avg + decay*x f = moving_average_with_typo.scan() From 8fb2bd92542ac821c9552734ec4df8068f1b7776 Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Wed, 18 Jul 2018 16:42:21 +0200 Subject: [PATCH 002/107] ok, the scan thing is internally messy but seems to work' --- artemis/general/scannable_functions.py | 314 ++++++++++++-------- artemis/general/test_scannable_functions.py | 99 +++++- 2 files changed, 277 insertions(+), 136 deletions(-) diff --git a/artemis/general/scannable_functions.py b/artemis/general/scannable_functions.py index c9a1b8e5..3398f02d 100644 --- a/artemis/general/scannable_functions.py +++ b/artemis/general/scannable_functions.py @@ -1,6 +1,193 @@ from collections import namedtuple +from artemis.general.functional import advanced_getargspec -from artemis.general.should_be_builtins import izip_equal + +def _normalize_formats(func, returns, state, output, outer_kwargs): + """ + + :param func: + :param returns: + :param state: + :param output: + :return: + """ + + if returns is None: + assert isinstance(state, str), "If there is more than one state variable, you must specify the return variables!" + returns = state + + single_return_format = isinstance(returns, str) + + if isinstance(state, str): + state = (state, ) + assert isinstance(state, (list, tuple)), 'State should be a list of state names. Got a {}'.format(state.__class__) + + arg_names, _, _, initial_state_dict = advanced_getargspec(func) + + for s in state: + assert s in arg_names, "The state '{}' is not a parameter to the function '{}'".format(s, func.__name__) + assert s==returns if single_return_format else s in returns, "The state variable '{}' is not updated by the returns '{}'".format(s, returns) + + if output is None: + output = returns + + if isinstance(state, dict): + initial_state_dict = state + else: + for name in state: + assert name in initial_state_dict, "Argument '{}' is part of your state, but it does not have an initial value. Provide one either by passing in state as a dict or adding a default in the function signature".format(name) + + parameter_kwargs = {} + for k, v in outer_kwargs.items(): + if k in state: + initial_state_dict[k] = v + else: + parameter_kwargs[k] = v + + if isinstance(state, (list, tuple, dict)): + return_state_indices = None if single_return_format else [returns.index(s) for s in state] + else: + raise Exception('State must be a list, tuple, dict, string. Not {}'.format(output)) + + if single_return_format: + return_state_names = returns + else: + return_state_names = [returns[ix] for ix in return_state_indices] + + if isinstance(output, (list, tuple)): + output_type = namedtuple('ScanOutputs_of_{}'.format(func.__name__), output) + return_output_indices = [returns.index(o) for o in output] + elif isinstance(output, str): + output_type = None + return_output_indices = returns.index(output) + else: + raise Exception('Output must be a list/tuple or string. Not {}'.format(output)) + + if single_return_format: + return_output_indices = None + + return single_return_format, return_output_indices, return_state_indices, return_state_names, initial_state_dict, output_type, parameter_kwargs + + +def immutable_scan(func, returns, state, output, outer_kwargs = {}): + """ + Create a StatelessUpdater object from a function. + + A StatelessUpdater is an Immutable callable object, which stores state. When called, it returns a new Statelessupdater, + containing the new state, and the specified return value. + + e.g. + + def moving_average(avg, x, t=0): + return avg*t/(t+1)+x/(t+1), t+1 + + sup = immutable_scan(moving_average, state=['avg', 't'], returns = ['avg', 't'], output='avg') + + sup2, avg = sup(3) + assert avg==3 + sup3, avg = sup2(4) + assert avg == 3.5 + + Note, you may choose to use the @scannable decorator instead: + + @scannable(state=['avg', 't'], returns = ['avg', 't'], output='avg') + def moving_average(avg, x, t=0): + return avg*t/(t+1)+x/(t+1), t+1 + + sup = moving_average.immutable_scan() + + :param Callable func: A function which defines a step in an iterative process + :param Union[Sequence[str], str] state: A list of names of state variables. + :param Union[Sequence[str], str] returns: A list of names of variables returned from the function. + :param Union[Sequence[str], str] output: A list of names of "output" variables + :return Callable[[...], Tuple[Callable, Any]]: An immutable callable of the form: + new_object_state, outputs = old_object_state(**inputs) + """ + + single_return_format, return_output_indices, return_state_indices, return_state_names, initial_state_dict, output_type, parameter_kwargs = _normalize_formats(func=func, returns=returns, state=state, output=output, outer_kwargs=outer_kwargs) + single_output_format = not isinstance(return_output_indices, (list, tuple)) + + class ImmutableScan(namedtuple('ImmutableScan_of_{}'.format(func.__name__), state)): + + def __call__(self, *args, **kwargs): + """ + :param args: + :param kwargs: + :return StatelessUpdater, Any: + Where the second output is an arbitrary value if output is specified as a string, or a namedtuple if outputs is specified as a list/tuple + """ + arguments = self._asdict() + arguments.update(**parameter_kwargs) + arguments.update(**kwargs) + return_values = func(*args, **arguments) + + if single_return_format: + if single_output_format: + output_values = return_values + else: + output_values = output_type(return_values) + new_state = ImmutableScan(return_values) + else: + try: + assert len(return_values) == len(returns), 'The number of return values: {}, does not match the length of the specified return variables: {} ({})'.format(len(return_values), len(returns), returns) + except TypeError: + raise TypeError('{} should have returned an iterable of length {} containing variables {}, but got a non-iterable: {}'.format(func.__name__, len(returns), returns, return_values)) + if single_output_format: + output_values = return_values[return_output_indices] + else: + output_values = output_type(*(return_values[i] for i in return_output_indices)) + new_state = ImmutableScan(*(return_values[ix] for ix in return_state_indices)) + + return new_state, output_values + + return ImmutableScan(**initial_state_dict) + + +def mutable_scan(func, state, returns, output, outer_kwargs = {}): + + single_return_format, return_output_indices, return_state_indices, return_state_names, initial_state_dict, output_type, parameter_kwargs = _normalize_formats(func=func, returns=returns, state=state, output=output, outer_kwargs=outer_kwargs) + single_output_format = not isinstance(return_output_indices, (list, tuple)) + + try: + from recordclass import recordclass + except: + raise ImportError('Stateful Updaters require recordclass to be installed. Run "pip install recordclass".') + + class MutableScan(recordclass('MutableScan_of_{}'.format(func.__name__), state)): + + def __call__(self, *args, **kwargs): + """ + :param args: + :param kwargs: + :return StatelessUpdater, Any: + Where the second output is an arbitrary value if output is specified as a string, or a namedtuple if outputs is specified as a list/tuple + """ + arguments = self._asdict() + arguments.update(**parameter_kwargs) + arguments.update(**kwargs) + return_values = func(*args, **arguments) + + if single_return_format: + if single_output_format: + output_values = return_values + else: + output_values = output_type(return_values) + setattr(self, return_state_names, return_values) + else: + try: + assert len(return_values) == len(returns), 'The number of return values: {}, does not match the length of the specified return variables: {} ({})'.format(len(return_values), len(returns), returns) + except TypeError: + raise TypeError('{} should have returned an iterable of length {} containing variables {}, but got a non-iterable: {}'.format(func.__name__, len(returns), returns, return_values)) + if single_output_format: + output_values = return_values[return_output_indices] + else: + output_values = output_type(*(return_values[i] for i in return_output_indices)) + for ix, name in zip(return_state_indices, return_state_names): + setattr(self, name, return_values[ix]) + + return output_values + + return MutableScan(**initial_state_dict) def scannable(state, returns=None, output=None): @@ -31,127 +218,14 @@ def simple_moving_average(x, avg, n): """ def wrapper(func): - def create_scannable(**kwargs): - return Scannable(func=func, state=state, returns=returns, output=output, kwargs=kwargs) - func.scan = create_scannable - - - - - class StatelessUpdater(namedtuple('StatelessUpdater for {}'.format(func.__name__))): - - def __call__(self, *args, **kwargs): - - - - - return StatelessUpdater(*(return_value for return_name, return_value in izip_equal(returns, return_values) if return_name in state)) + def make_mutable_scan(**kwargs): + return mutable_scan(func=func, state=state, returns=returns, output=output, outer_kwargs=kwargs) + func.mutable_scan = make_mutable_scan + def make_immutable_scan(**kwargs): + return immutable_scan(func=func, state=state, returns=returns, output=output, outer_kwargs=kwargs) - state_object = None if isinstance(state, str) else namedtuple('State of {}'.format(func.__name__), state) - output_object = None if isinstance(output, str) else namedtuple('Output of {}'.format(func.__name__, output)) - - def standard_form(input_values, state_values): - - kwargs = input_value - - - return output_values, state_values - - - func.standard_form = standard_form - + func.immutable_scan = make_immutable_scan return func return wrapper - - -class Scannable(object): - - SINGLE_OUTPUT_FORMAT = object() - TUPLE_OUTPUT_FORMAT = object() - - def __init__(self, func, state, returns, output, kwargs = None): - """ - See scannable docstring - """ - if isinstance(state, str): - state = (state, ) - assert isinstance(state, (list, tuple)), 'State should be a list of state names. Got a {}'.format(state.__class__) - state_names = state - state = {} - if kwargs is not None: - state.update(kwargs) - - if returns is None: - assert len(state_names)==1, "If there is more than one state variable, you must specify the output!" - returns = next(iter(state_names)) - if isinstance(returns, str): - assert returns in state_names, 'Output name "{}" was not provided not included in the state dict: "{}"'.format(returns, state_names) - self._output_format = Scannable.SINGLE_OUTPUT_FORMAT - self._state_names = returns - output_names = [returns] - self._output_format = Scannable.SINGLE_OUTPUT_FORMAT - else: - assert isinstance(returns, (list, tuple)), "output must be a string, a list/tuple, or None" - assert all(sn in returns for sn in state_names), 'Variabels name(s) {} were listed as state variables but not included in the list of outputs: {}'.format([sn for sn in state_names if sn not in returns], returns) - output_names = returns - self._output_format = Scannable.TUPLE_OUTPUT_FORMAT - self._state_names = tuple(state_names) - self._state_indices_in_output = [output_names.index(state_name) for state_name in state_names] - if isinstance(output, str): - assert returns is not None, 'If you specify returns, you must specify output' - if isinstance(returns, str): - assert output == output_names - return_index = None - else: - assert isinstance(returns, (list, tuple)) - return_index = returns.index(output) - elif isinstance(output, (list, tuple)): - return_index = tuple(output_names.index(r) for r in output) - else: - assert output is None - return_index = None - - self.func = func - self._state = state - self._return_index = return_index - self._output_names = output_names - - def __str__(self): - output = self._output_names[0] if self._output_format is Scannable.SINGLE_OUTPUT_FORMAT else self._output_names - returns = None if self._return_index is None else repr(self._output_names[self._return_index]) if isinstance(self._return_index, int) else tuple(self._output_names[i] for i in self._return_index) - self._strrep = '{}(func={}, state={}, output={}, returns={})'.format(self.__class__.__name__, self.func.__name__, self._state, output, returns) - return self._strrep - - def __call__(self, *args, **kwargs): - kwargs.update(self._state) - values_returned = self.func(*args, **kwargs) - if self._output_format is Scannable.SINGLE_OUTPUT_FORMAT: - self._state[self._state_names] = values_returned - else: - try: - assert len(values_returned) == len(self._output_names), 'The number of outputs: {}, does not match the length of the specified outputs: {} ({})'.format(len(values_returned), len(self._output_names), self._output_names) - except TypeError: - raise TypeError('{} should have returned an iterable of length {} containing variables {}, but got a non-iterable: {}'.format(self.func.__name__, len(self._output_names), self._output_names, values_returned)) - self._state.update((state_name, values_returned[ix]) for state_name, ix in zip(self._state_names, self._state_indices_in_output)) - return values_returned if self._return_index is None else values_returned[self._return_index] if isinstance(self._return_index, int) else tuple(values_returned[i] for i in self._return_index) - - @property - def state(self): - return self._state.copy() - -# -# class ScannableStateLess(object): -# -# def __init__(self, func, inputs, returns, outputs): -# pass -# -# def __call__(self, *args, **kwargs): -# """ -# :param args: -# :param kwargs: -# :return: -# """ - - diff --git a/artemis/general/test_scannable_functions.py b/artemis/general/test_scannable_functions.py index f62f7115..1e30187a 100644 --- a/artemis/general/test_scannable_functions.py +++ b/artemis/general/test_scannable_functions.py @@ -1,7 +1,7 @@ import numpy as np import pytest -from artemis.general.scannable_functions import scannable +from artemis.general.scannable_functions import scannable, immutable_scan, mutable_scan def test_simple_moving_average(): @@ -12,11 +12,11 @@ def test_simple_moving_average(): def simple_moving_average(x, avg=0, n=0): return (n/(1.+n))*avg + (1./(1.+n))*x, n+1 - f = simple_moving_average.scan() + f = simple_moving_average.mutable_scan() averaged_signal = [f(x=x) for t, x in enumerate(seq)] truth = np.cumsum(seq)/np.arange(1, len(seq)+1) assert np.allclose(averaged_signal, truth) - assert np.allclose(f.state['avg'], np.mean(seq)) + assert np.allclose(f.avg, np.mean(seq)) def test_moving_average(): @@ -27,14 +27,14 @@ def moving_average(x, decay, avg=0): seq = np.random.randn(100) + np.sin(np.linspace(0, 10, 100)) - f = moving_average.scan() + f = moving_average.mutable_scan() simply_smoothed_signal = [f(x=x, decay=1./(t+1)) for t, x in enumerate(seq)] truth = np.cumsum(seq)/np.arange(1, len(seq)+1) assert np.allclose(simply_smoothed_signal, truth) - assert list(f.state.keys())==['avg'] - assert np.allclose(f.state['avg'], np.mean(seq)) + assert list(f._fields)==['avg'] + assert np.allclose(f.avg, np.mean(seq)) - f = moving_average.scan() + f = moving_average.mutable_scan() exponentially_smoothed_signal = [f(x=x, decay=0.1) for x in seq] truth = [avg for avg in [0] for x in seq for avg in [0.9*avg + 0.1*x]] assert np.allclose(exponentially_smoothed_signal, truth) @@ -69,45 +69,112 @@ def rnn_like_func(x, hid= np.zeros(n_hid)): outputs.append(y) # The NEW way of doing things. - rnn_step = rnn_like_func.scan(hid=initial_state) + rnn_step = rnn_like_func.mutable_scan(hid=initial_state) outputs2 = [rnn_step(x) for x in seq] assert np.allclose(outputs, outputs2) - assert np.allclose(rnn_step.state['hid'], h) + assert np.allclose(rnn_step.hid, h) + + # Now try the immutable version: + rnn_step = rnn_like_func.immutable_scan(hid=initial_state) + outputs3 = [output for rnn_step in [rnn_step] for x in seq for rnn_step, output in [rnn_step(x)]] + assert np.allclose(outputs, outputs3) def test_bad_beheviour_caught(): seq = np.random.randn(100) + np.sin(np.linspace(0, 10, 100)) - with pytest.raises(TypeError): # Typo in state name + with pytest.raises(AssertionError): # Typo in state name @scannable(state='avgfff') def moving_average_with_typo(x, decay, avg=0): return (1-decay)*avg + decay*x - - f = moving_average_with_typo.scan() - simply_smoothed_signal = [f(x=x, decay=1./(t+1)) for t, x in enumerate(seq)] + f = moving_average_with_typo.mutable_scan() with pytest.raises(AssertionError): # Should really be done before instance-creation, but whatever. @scannable(state='avg', returns='avgf') def moving_average_with_typo(x, decay, avg=0): return (1-decay)*avg + decay*x - f = moving_average_with_typo.scan() + f = moving_average_with_typo.mutable_scan() with pytest.raises(ValueError): # Invalid return name @scannable(state=['avg'], returns=['avg'], output='avgf') def moving_average_with_typo(x, decay, avg=0): return (1-decay)*avg + decay*x - f = moving_average_with_typo.scan() + f = moving_average_with_typo.mutable_scan() with pytest.raises(TypeError): # Wrong output format @scannable(state=['avg'], returns=['avg', 'something'], output='avg') def moving_average_with_typo(x, decay, avg=0): return (1-decay)*avg + decay*x - f = moving_average_with_typo.scan() + f = moving_average_with_typo.mutable_scan() simply_smoothed_signal = [f(x=x, decay=1./(t+1)) for t, x in enumerate(seq)] +def test_stateless_updater(): + + # Direct API + def moving_average(x, avg=0, t=0): + t_next = t+1. + return avg*t/t_next+x/t_next, t_next + + sup = immutable_scan(moving_average, state=['avg', 't'], returns = ['avg', 't'], output='avg') + sup2, avg = sup(3) + assert avg==3 + sup3, avg = sup2(4) + assert avg == 3.5 + sup2a, avg = sup2(1) + assert avg == 2 + + +def test_stateless_updater_with_decorator(): + # Using Decordator + @scannable(state=['avg', 't'], output='avg', returns=['avg', 't']) + def moving_average(x, avg=0, t=0): + t_next = t+1. + return avg*t/t_next+x/t_next, t_next + + sup = moving_average.immutable_scan() + sup2, avg = sup(3) + assert avg==3 + sup3, avg = sup2(4) + assert avg == 3.5 + sup2a, avg = sup2(1) + assert avg == 2 + + +def test_stateful_updater(): + + # Direct API + def moving_average(x, avg=0, t=0): + t_next = t+1. + return avg*t/t_next+x/t_next, t_next + + sup = mutable_scan(moving_average, state=['avg', 't'], returns = ['avg', 't'], output='avg') + avg = sup(3) + assert avg==3 + avg = sup(4) + assert avg == 3.5 + + +def test_stateful_updater_with_decorator(): + # Using Decordator + @scannable(state=['avg', 't'], output='avg', returns=['avg', 't']) + def moving_average(x, avg=0, t=0): + t_next = t+1. + return avg*t/t_next+x/t_next, t_next + + sup = mutable_scan(moving_average, state=['avg', 't'], returns = ['avg', 't'], output='avg') + avg = sup(3) + assert avg==3 + avg = sup(4) + assert avg == 3.5 + + if __name__ == '__main__': test_simple_moving_average() test_moving_average() test_rnn_type_comp() test_bad_beheviour_caught() + test_stateless_updater() + test_stateless_updater_with_decorator() + test_stateful_updater() + test_stateful_updater_with_decorator() \ No newline at end of file From 8688d2242752832b393e2f55ce81c69c4e6faa4c Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Thu, 19 Jul 2018 14:32:09 +0200 Subject: [PATCH 003/107] fixed rsync-copying-all-experiments problem --- artemis/experiments/experiment_management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/artemis/experiments/experiment_management.py b/artemis/experiments/experiment_management.py index d66f9466..42a93527 100644 --- a/artemis/experiments/experiment_management.py +++ b/artemis/experiments/experiment_management.py @@ -64,7 +64,7 @@ def pull_experiment_records(user, ip, experiment_names, include_variants=True, n # +["--include='**/*-{exp_name}{variants}/*'".format(exp_name=exp_name, variants = '*' if include_variants else '') for exp_name in experiment_names] # This was the old line, but it could be too long for many experiments. if not need_pass: - output = subprocess.check_output(command) + output = subprocess.check_output(' '.join(command), shell=True) else: # This one works if you need a password password = getpass.getpass("Enter password for {}@{}:".format(user, ip)) From a0a4a692c3dfb1f3d329749d1431b3ae4e15596f Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Tue, 7 Aug 2018 14:50:38 +0200 Subject: [PATCH 004/107] a bunch of things with nested structures --- artemis/experiments/experiment_management.py | 4 + artemis/experiments/experiment_record_view.py | 2 +- artemis/general/nested_structures.py | 99 ++++++++++++++----- artemis/general/pareto_efficiency.py | 31 ++++-- artemis/general/test_nested_structures.py | 34 ++++++- artemis/general/test_pareto_efficiency.py | 33 ++++--- artemis/ml/predictors/predictor_tests.py | 5 +- 7 files changed, 158 insertions(+), 50 deletions(-) diff --git a/artemis/experiments/experiment_management.py b/artemis/experiments/experiment_management.py index 42a93527..46786b32 100644 --- a/artemis/experiments/experiment_management.py +++ b/artemis/experiments/experiment_management.py @@ -323,6 +323,10 @@ def _filter_records(user_range, exp_record_dict): current_time = datetime.now() for exp_id, _ in base.items(): base[exp_id] = [filter_func(current_time - load_experiment_record(rec_id).get_datetime(), time_delta) for rec_id in exp_record_dict[exp_id]] + elif user_range.startswith('has:'): + phrase = user_range[len('has:'):] + for exp_id, records in base.items(): + base[exp_id] = [True]*len(records) if phrase in exp_id else [False]*len(records) else: raise RecordSelectionError("Don't know how to interpret subset '{}'. Possible subsets: {}".format(user_range, list(_named_record_filters.keys()))) return base diff --git a/artemis/experiments/experiment_record_view.py b/artemis/experiments/experiment_record_view.py index 750f7667..5b093fc1 100644 --- a/artemis/experiments/experiment_record_view.py +++ b/artemis/experiments/experiment_record_view.py @@ -112,7 +112,7 @@ def get_record_full_string(record, show_info = True, show_logs = True, truncate_ return '\n'.join(parts) -def get_record_invalid_arg_string(record, recursive=True, ignore_valid_keys=(), note_version = 'full'): +def get_record_invalid_arg_string(record, recursive=False, ignore_valid_keys=(), note_version = 'full'): """ Return a string identifying ig the arguments for this experiment are still valid. :return: diff --git a/artemis/general/nested_structures.py b/artemis/general/nested_structures.py index 79ee05f3..f9c9cfe6 100644 --- a/artemis/general/nested_structures.py +++ b/artemis/general/nested_structures.py @@ -3,7 +3,7 @@ import numpy as np from six import string_types, next -from artemis.general.should_be_builtins import all_equal +from artemis.general.should_be_builtins import all_equal, izip_equal __author__ = 'peter' @@ -65,10 +65,10 @@ def flatten_struct(struct, primatives = PRIMATIVE_TYPES, custom_handlers = {}, def _is_primitive_container(obj): - return isinstance(obj, _primitive_containers) + return isinstance(obj, _primitive_containers) or hasattr(obj, '_fields') -def get_meta_object(data_object, is_container_func = _is_primitive_container): +def get_meta_object(data_object, is_container = _is_primitive_container): """ Given an arbitrary data structure, return a "meta object" which is the same structure, except all non-container objects are replaced by their types. @@ -77,18 +77,55 @@ def get_meta_object(data_object, is_container_func = _is_primitive_container): get_meta_obj([1, 2, {'a':(3, 4), 'b':['hey', 'yeah']}, 'woo']) == [int, int, {'a':(int, int), 'b':[str, str]}, str] :param data_object: A data object with arbitrary nested structure - :param is_container_func: A callback which returns True if an object is to be considered a container and False otherwise + :param is_container: A callback which returns True if an object is to be considered a container and False otherwise :return: """ - if is_container_func(data_object): - if isinstance(data_object, (list, tuple, set)): - return type(data_object)(get_meta_object(x, is_container_func=is_container_func) for x in data_object) + if is_container(data_object): + if hasattr(data_object, '_fields'): + return type(data_object)(*(get_meta_object(x, is_container=is_container) for x in data_object)) + elif isinstance(data_object, (list, tuple, set)): + return type(data_object)(get_meta_object(x, is_container=is_container) for x in data_object) elif isinstance(data_object, dict): - return type(data_object)((k, get_meta_object(v, is_container_func=is_container_func)) for k, v in data_object.items()) + return type(data_object)((k, get_meta_object(v, is_container=is_container)) for k, v in data_object.items()) else: return type(data_object) +def broadcast_into_meta_object(meta_object, data_object, is_container = _is_primitive_container, check_types = True): + """ + "Broadcast" the data object into the meta object. This puts the data into the structure of the meta-object. + E.g. + + >>> broadcast_into_meta_object([int, int, int], 1) + [1, 1, 1] + >>> broadcast_into_meta_object([int, (int, int), int], (1, 2, 3)) + [1, (2, 2), 3] + + :param meta_object: A nested structure of types + :param data_object: A nested structure of data + :param is_container: A function that returns True if an object is considered to be in a container. + :return: A new data object with the structure of the meta object and the data of the data object. + """ + kwargs = dict(check_types=check_types, is_container=is_container) + if is_container(meta_object): + if isnamedtupleinstance(meta_object): + if isinstance(data_object, (list, tuple, set)): + return meta_object.__class__(*(broadcast_into_meta_object(m, d, **kwargs) for m, d in izip_equal(meta_object, data_object))) + else: + return meta_object.__class__(*(broadcast_into_meta_object(m, data_object, **kwargs) for m in meta_object)) + elif isinstance(meta_object, (list, tuple, set)): + if isinstance(data_object, (list, tuple, set)): + return meta_object.__class__(broadcast_into_meta_object(m, d, **kwargs) for m, d in izip_equal(meta_object, data_object)) + else: + return meta_object.__class__(broadcast_into_meta_object(m, data_object, **kwargs) for m in meta_object) + else: + raise NotImplementedError('Dict iteration not supported yet.') + else: + if check_types: + assert isinstance(data_object, meta_object), "Data object {} does not have type of meta-object: {}".format(data_object, meta_object) + return data_object + + class NestedType(object): """ An object which represents the type of an arbitrarily nested data structure. It can be constructed directly @@ -122,14 +159,29 @@ def __repr__(self): def __eq__(self, other): return self.meta_object == other.meta_object - def get_leaves(self, data_object, check_types = True, is_container_func = _is_primitive_container): + def broadcast(self, data_object, is_container = _is_primitive_container, check_types=True): + """ + "Broadcast" a data object to have the given structure. e.g. + + >>> structure = NestedType([int, (int, int), int]) + >>> structure.broadcast((1, 2, 3)) + [1, (2, 2), 3] + + :param data_object: A nested data object which can be broadcast onto this structure. + :return: A new data object with a structure matching this object's. + """ + return broadcast_into_meta_object(meta_object=self.meta_object, data_object=data_object, is_container=is_container, check_types=check_types) + + def get_leaves(self, data_object, check_types = True, broadcast=False, is_container = _is_primitive_container): """ :param data_object: Given a nested object, get the "leaf" values in Depth-First Order :return: A list of leaf values. """ - if check_types: + if broadcast: + data_object = self.broadcast(data_object, check_types=check_types, is_container=is_container) + elif check_types: self.check_type(data_object) - return get_leaf_values(data_object, is_container_func=is_container_func) + return get_leaf_values(data_object, is_container_func=is_container) def expand_from_leaves(self, leaves, check_types = True, assert_fully_used=True, is_container_func = _is_primitive_container): """ @@ -149,17 +201,15 @@ def from_data(data_object, is_container_func = _is_primitive_container): :param is_container_func: A callback which returns True if an object is to be considered a container and False otherwise :return: A NestedType object """ - return NestedType(get_meta_object(data_object, is_container_func=is_container_func)) + return NestedType(get_meta_object(data_object, is_container=is_container_func)) -def isnestedinstance(data, meta_obj): - """ - Check if the data is - :param data: - :param meta_obj: - :return: - """ - raise NotImplementedError() +def isnamedtuple(thing): + return hasattr(thing, '_fields') and len(thing.__bases__)==1 and thing.__bases__[0]==tuple + + +def isnamedtupleinstance(thing): + return isnamedtuple(thing.__class__) def get_leaf_values(data_object, is_container_func = _is_primitive_container): @@ -206,7 +256,9 @@ def _fill_meta_object(meta_object, data_iteratable, assert_fully_used = True, ch try: if is_container_func(meta_object): - if isinstance(meta_object, (list, tuple, set)): + if isnamedtupleinstance(meta_object): + filled_object = type(meta_object)(*(_fill_meta_object(None, data_iteratable, assert_fully_used=False, check_types=check_types, is_container_func=is_container_func) for x in meta_object._fields)) + elif isinstance(meta_object, (list, tuple, set)): filled_object = type(meta_object)(_fill_meta_object(x, data_iteratable, assert_fully_used=False, check_types=check_types, is_container_func=is_container_func) for x in meta_object) elif isinstance(meta_object, OrderedDict): filled_object = type(meta_object)((k, _fill_meta_object(val, data_iteratable, assert_fully_used=False, check_types=check_types, is_container_func=is_container_func)) for k, val in meta_object.items()) @@ -216,7 +268,7 @@ def _fill_meta_object(meta_object, data_iteratable, assert_fully_used = True, ch raise Exception('Cannot handle container type: "{}"'.format(type(meta_object))) else: next_data = next(data_iteratable) - if check_types and meta_object is not type(next_data): + if check_types and meta_object is not type(next_data) and meta_object is not None: raise TypeError('The type of the data object: {} did not match type from the meta object: {}'.format(type(next_data), meta_object)) filled_object = next_data except StopIteration: @@ -245,11 +297,10 @@ def nested_map(func, *nested_objs, **kwargs): is_container_func = kwargs['is_container_func'] if 'is_container_func' in kwargs else _is_primitive_container check_types = kwargs['check_types'] if 'check_types' in kwargs else False assert len(nested_objs)>0, 'nested_map requires at least 2 args' - assert callable(func), 'func must be a function with one argument.' nested_types = [NestedType.from_data(nested_obj, is_container_func=is_container_func) for nested_obj in nested_objs] assert all_equal(nested_types), "The nested objects you provided had different data structures:\n{}".format('\n'.join(str(s) for s in nested_types)) - leaf_values = zip(*[nested_type.get_leaves(nested_obj, is_container_func=is_container_func, check_types=check_types) for nested_type, nested_obj in zip(nested_types, nested_objs)]) + leaf_values = zip(*[nested_type.get_leaves(nested_obj, is_container=is_container_func, check_types=check_types) for nested_type, nested_obj in zip(nested_types, nested_objs)]) new_leaf_values = [func(*v) for v in leaf_values] new_nested_obj = nested_types[0].expand_from_leaves(new_leaf_values, check_types=check_types, is_container_func=is_container_func) return new_nested_obj diff --git a/artemis/general/pareto_efficiency.py b/artemis/general/pareto_efficiency.py index ad4a6bf3..70dac8e7 100644 --- a/artemis/general/pareto_efficiency.py +++ b/artemis/general/pareto_efficiency.py @@ -27,15 +27,30 @@ def is_pareto_efficient(costs): return is_efficient -def is_pareto_efficient_ixs(costs): +def is_pareto_efficient_indexed(costs, return_mask = True): + """ + :param costs: An (n_points, n_costs) array + :param return_mask: True to return a mask + :return: An array of indices of pareto-efficient points. + If return_mask is True, this will be an (n_points, ) boolean array + Otherwise it will be a (n_efficient_points, ) integer array of indices. + """ + is_efficient = np.arange(costs.shape[0]) + n_points = costs.shape[0] + next_point_index = 0 # Next index in the is_efficient array to search for - candidates = np.arange(costs.shape[0]) - for i, c in enumerate(costs): - if 0 < np.searchsorted(candidates, i) < len(candidates): # If this element has not yet been eliminated - candidates = candidates[np.any(costs[candidates]<=c, axis=1)] - is_efficient = np.zeros(costs.shape[0], dtype = bool) - is_efficient[candidates] = True - return is_efficient + while next_point_index0 for c in costs[ixs]: @@ -26,24 +26,31 @@ def test_is_pareto_efficient(plot=False): plt.show() -def profile_pareto_efficient(): +def profile_pareto_efficient(n_points=5000, n_costs=2, include_dumb = True): rng = np.random.RandomState(1234) - costs = rng.rand(5000, 2) + costs = rng.rand(n_points, n_costs) - with EZProfiler('dumb'): - dumb_ixs = is_pareto_efficient_dumb(costs) + if include_dumb: + with EZProfiler('is_pareto_efficient_dumb'): + base_ixs = dumb_ixs = is_pareto_efficient_dumb(costs) - with EZProfiler('smart'): + with EZProfiler('is_pareto_efficient'): less_dumb__ixs = is_pareto_efficient(costs) - assert np.array_equal(dumb_ixs, less_dumb__ixs) + if not include_dumb: + base_ixs = less_dumb__ixs + assert np.array_equal(base_ixs, less_dumb__ixs) - with EZProfiler('index-tracking'): - smart_ixs = is_pareto_efficient_ixs(costs) + with EZProfiler('is_pareto_efficient_indexed'): + smart_indexed = is_pareto_efficient_indexed(costs, return_mask=True) + assert np.array_equal(base_ixs, smart_indexed) - assert np.array_equal(dumb_ixs, smart_ixs) + with EZProfiler('is_pareto_efficient_indexed_reordered'): + smart_indexed = is_pareto_efficient_indexed(costs, return_mask=True, rank_reorder=True) + assert np.array_equal(base_ixs, smart_indexed) if __name__ == '__main__': - test_is_pareto_efficient() + # test_is_pareto_efficient() + profile_pareto_efficient(n_points=100000, n_costs=2, include_dumb=False) diff --git a/artemis/ml/predictors/predictor_tests.py b/artemis/ml/predictors/predictor_tests.py index 760084d5..1c68f500 100644 --- a/artemis/ml/predictors/predictor_tests.py +++ b/artemis/ml/predictors/predictor_tests.py @@ -1,11 +1,10 @@ import numpy as np from artemis.ml.predictors.predictor_comparison import assess_online_predictor -from artemis.ml.predictors.train_and_test import percent_argmax_correct -from plato.tools.common.bureaucracy import multichannel from artemis.ml.datasets.synthetic_clusters import get_synthetic_clusters_dataset +from artemis.ml.tools.costs import percent_argmax_correct from artemis.ml.tools.processors import OneHotEncoding - +from plato.tools.common.bureaucracy import multichannel __author__ = 'peter' From 0db3d4058e4dd59e9b9e95c512bd3b6ea37e8ea9 Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Fri, 14 Sep 2018 17:18:54 +0200 Subject: [PATCH 005/107] everything --- artemis/experiments/decorators.py | 10 +- artemis/experiments/experiment_record.py | 2 +- artemis/experiments/experiment_record_view.py | 108 ++++++--- artemis/experiments/experiments.py | 50 +++-- artemis/experiments/ui.py | 96 ++++++-- artemis/general/checkpoint_counter.py | 25 +++ artemis/general/dead_easy_ui.py | 212 ++++++++++++++++++ artemis/general/display.py | 8 +- artemis/general/duck.py | 16 +- artemis/general/mymath.py | 15 ++ artemis/general/nested_structures.py | 75 +++++-- artemis/general/progress_indicator.py | 26 ++- artemis/general/should_be_builtins.py | 23 +- artemis/general/table_ui.py | 121 ++++++++++ artemis/general/tables.py | 21 +- artemis/general/test_checkpoint_counter.py | 9 +- artemis/general/test_duck.py | 58 +++-- artemis/general/test_mymath.py | 14 +- artemis/general/test_nested_structures.py | 24 +- artemis/general/test_should_be_builtins.py | 20 +- artemis/general/test_time_parser.py | 26 +++ artemis/general/time_parser.py | 15 +- artemis/plotting/data_conversion.py | 40 ++++ artemis/plotting/db_plotting.py | 124 ++++++---- artemis/plotting/expanding_subplots.py | 2 +- artemis/plotting/matplotlib_backend.py | 20 +- artemis/plotting/pyplot_plus.py | 4 +- artemis/plotting/test_db_plotting.py | 14 +- 28 files changed, 963 insertions(+), 215 deletions(-) create mode 100644 artemis/general/dead_easy_ui.py create mode 100644 artemis/general/table_ui.py create mode 100644 artemis/general/test_time_parser.py diff --git a/artemis/experiments/decorators.py b/artemis/experiments/decorators.py index 91a5fcf3..7c2bbaa5 100644 --- a/artemis/experiments/decorators.py +++ b/artemis/experiments/decorators.py @@ -44,7 +44,7 @@ class ExperimentFunction(object): This is the most general decorator. You can use this to add details on the experiment. """ - def __init__(self, show = show_record, compare = compare_experiment_records, display_function=None, comparison_function=None, one_liner_function=sensible_str, is_root=False): + def __init__(self, show = None, compare = compare_experiment_records, display_function=None, comparison_function=None, one_liner_function=None, result_parser = None, is_root=False): """ :param show: A function that is called when you "show" an experiment record in the UI. It takes an experiment record as an argument. @@ -60,11 +60,11 @@ def __init__(self, show = show_record, compare = compare_experiment_records, dis self.compare = compare if display_function is not None: - assert show is show_record, "You can't set both display function and show. (display_function is deprecated)" + assert show is None, "You can't set both display function and show. (display_function is deprecated)" show = lambda rec: display_function(rec.get_result()) if comparison_function is not None: - assert compare is compare_experiment_records, "You can't set both display function and show. (display_function is deprecated)" + assert compare is None, "You can't set both display function and show. (display_function is deprecated)" def compare(records): record_experiment_ids_uniquified = uniquify_duplicates(rec.get_experiment_id() for rec in records) @@ -74,6 +74,7 @@ def compare(records): self.compare = compare self.is_root = is_root self.one_liner_function = one_liner_function + self.result_parser = result_parser def __call__(self, f): f.is_base_experiment = True @@ -83,6 +84,7 @@ def __call__(self, f): show=self.show, compare = self.compare, one_liner_function=self.one_liner_function, - is_root=self.is_root + is_root=self.is_root, + result_parser=self.result_parser, ) return ex \ No newline at end of file diff --git a/artemis/experiments/experiment_record.py b/artemis/experiments/experiment_record.py index e40a3f26..5e67d99f 100644 --- a/artemis/experiments/experiment_record.py +++ b/artemis/experiments/experiment_record.py @@ -289,7 +289,7 @@ def get_experiment(self): """ Load the experiment associated with this record. Note that this will raise an ExperimentNotFoundError if the experiment has not been imported. - :return: An Experiment object + :return Experiment: An Experiment object """ from artemis.experiments.experiments import load_experiment return load_experiment(self.get_experiment_id()) diff --git a/artemis/experiments/experiment_record_view.py b/artemis/experiments/experiment_record_view.py index 5b093fc1..ce0eedc7 100644 --- a/artemis/experiments/experiment_record_view.py +++ b/artemis/experiments/experiment_record_view.py @@ -1,21 +1,20 @@ import re from collections import OrderedDict +from six import string_types from tabulate import tabulate -from artemis.experiments.experiment_management import load_lastest_experiment_results +import numpy as np from artemis.experiments.experiment_record import NoSavedResultError, ExpInfoFields, ExperimentRecord, \ load_experiment_record, is_matplotlib_imported, UnPicklableArg -from artemis.experiments.experiments import is_experiment_loadable, get_global_experiment_library -from artemis.general.display import deepstr, truncate_string, hold_numpy_printoptions, side_by_side, CaptureStdOut, \ - surround_with_header, section_with_header +from artemis.general.display import deepstr, truncate_string, hold_numpy_printoptions, side_by_side, \ + surround_with_header, section_with_header, dict_to_str from artemis.general.nested_structures import flatten_struct, PRIMATIVE_TYPES -from artemis.general.should_be_builtins import separate_common_items, all_equal, bad_value, izip_equal, \ - remove_duplicates +from artemis.general.should_be_builtins import separate_common_items, bad_value, izip_equal, \ + remove_duplicates, get_unique_name, entries_to_table from artemis.general.tables import build_table -from six import string_types -def get_record_result_string(record, func='deep', truncate_to = None, array_print_threshold=8, array_float_format='.3g', oneline=False): +def get_record_result_string(record, func='deep', truncate_to = None, array_print_threshold=8, array_float_format='.3g', oneline=False, default_one_liner_func=str): """ Get a string representing the result of the experiment. :param record: @@ -36,7 +35,7 @@ def get_record_result_string(record, func='deep', truncate_to = None, array_prin return '' string = func(result) if not isinstance(string, string_types): - string = str(string) + string = default_one_liner_func(string) if truncate_to is not None: string = truncate_string(string, truncation=truncate_to, message = '...') @@ -117,6 +116,7 @@ def get_record_invalid_arg_string(record, recursive=False, ignore_valid_keys=(), Return a string identifying ig the arguments for this experiment are still valid. :return: """ + from artemis.experiments.experiments import is_experiment_loadable assert note_version in ('full', 'short') experiment_id = record.get_experiment_id() if is_experiment_loadable(experiment_id): @@ -149,7 +149,7 @@ def get_record_invalid_arg_string(record, recursive=False, ignore_valid_keys=(), return notes -def get_oneline_result_string(record, truncate_to=None, array_float_format='.3g', array_print_threshold=8): +def get_oneline_result_string(record, truncate_to=None, array_float_format='.3g', array_print_threshold=8, default_one_liner_func=dict_to_str): """ Get a string that describes the result of the record in one line. This can optionally be specified by experiment.one_liner_function. @@ -160,16 +160,17 @@ def get_oneline_result_string(record, truncate_to=None, array_float_format='.3g' :param array_print_threshold: :return: A string with no newlines briefly describing the result of the record. """ + from artemis.experiments.experiments import is_experiment_loadable if isinstance(record, string_types): record = load_experiment_record(record) if not is_experiment_loadable(record.get_experiment_id()): - one_liner_function=str + one_liner_function=default_one_liner_func else: one_liner_function = record.get_experiment().one_liner_function if one_liner_function is None: - one_liner_function = str + one_liner_function = default_one_liner_func return get_record_result_string(record, func=one_liner_function, truncate_to=truncate_to, array_print_threshold=array_print_threshold, - array_float_format=array_float_format, oneline=True) + array_float_format=array_float_format, oneline=True, default_one_liner_func=default_one_liner_func) def print_experiment_record_argtable(records): @@ -204,6 +205,69 @@ def lookup_fcn(record_id, column): print(tabulate(rows)) +def get_column_change_ordering(tabular_data): + """ + Get the order in which to rearrange the columns so that the fastest-changing data comes last. + + :param tabular_data: A list of equal-length lists + :return: A set of permutation indices for the columns. + """ + n_rows, n_columns = len(tabular_data), len(tabular_data[0]) + deltas = [sum(row_prev[i]!=row[i] for row_prev, row in zip(tabular_data[:-1], tabular_data[1:])) for i in range(n_columns)] + return np.argsort(deltas) + + +def get_different_args(args, no_arg_filler = 'N/A', arrange_by_deltas=False): + """ + Get a table of different args between records. + :param Sequence[List[Tuple[str, Any]]] args: A list of lists of argument (name, value) pairs. + :param no_arg_filler: The filler value to use if a record does not have a particular argument (possibly due to an argument being added to the code after the record was made) + :param arrange_by_deltas: If true, order arguments so that the fastest-changing ones are in the last column + :return Tuple[List[str], List[List[Any]]]: (arg_names, arg_values) where: + arg_names is a list of arguments that differ between records + arg_values is a len(records)-list of len(arg_names) lists of values of the arguments for each record. + """ + args = list(args) + common_args, different_args = separate_common_items(args) + all_different_args = list(remove_duplicates((k for dargs in different_args for k in dargs.keys()))) + values = [[record_args[argname] if argname in record_args else no_arg_filler for argname in all_different_args] for record_args in args] + if arrange_by_deltas: + col_shuf_ixs = get_column_change_ordering(values) + all_different_args = [all_different_args[i] for i in col_shuf_ixs] + values = [[row[i] for i in col_shuf_ixs] for row in values] + return all_different_args, values + + +def get_exportiment_record_arg_result_table(records): + record_ids = [record.get_id() for record in records] + all_different_args, arg_values = get_different_args([r.get_args() for r in records], no_arg_filler='N/A') + + parsed_results = [record.get_experiment().result_parser(record.get_result()) for record in records] + result_fields, result_data = entries_to_table(parsed_results) + result_fields = [get_unique_name(rf, all_different_args) for rf in result_fields] # Just avoid name collisions + + # result_column_name = get_unique_name('Results', taken_names=all_different_args) + + def lookup_fcn(record_id, arg_or_result_name): + row_index = record_ids.index(record_id) + if arg_or_result_name in result_fields: + return result_data[row_index][result_fields.index(arg_or_result_name)] + else: + column_index = all_different_args.index(arg_or_result_name) + return arg_values[row_index][column_index] + + rows = build_table(lookup_fcn, + row_categories=record_ids, + column_categories=all_different_args + result_fields, + prettify_labels=False, + include_row_category=False, + ) + + return rows[0], rows[1:] + + # return tabulate(rows[1:], headers=rows[0]) + + def show_record(record, show_logs=True, truncate_logs=None, truncate_result=10000, header_width=100, show_result ='deep', hang=True): """ Show the results of an experiment record. @@ -221,8 +285,6 @@ def show_record(record, show_logs=True, truncate_logs=None, truncate_result=1000 has_matplotlib_figures = any(loc.endswith('.pkl') for loc in record.get_figure_locs()) if has_matplotlib_figures: - from matplotlib import pyplot as plt - from artemis.plotting.saving_plots import interactive_matplotlib_context record.show_figures(hang=hang) print(string) @@ -280,22 +342,6 @@ def compare_experiment_records(records, parallel_text=None, show_logs=True, trun return has_matplotlib_figures -def find_experiment(*search_terms): - """ - Find an experiment. Invoke - :param search_term: A term that will be used to search for an experiment. - :return: - """ - global_lib = get_global_experiment_library() - found_experiments = OrderedDict((name, ex) for name, ex in global_lib.items() if all(re.search(term, name) for term in search_terms)) - if len(found_experiments)==0: - raise Exception("None of the {} experiments matched the search: '{}'".format(len(global_lib), search_terms)) - elif len(found_experiments)>1: - raise Exception("More than one experiment matched the search '{}', you need to be more specific. Found: {}".format(search_terms, found_experiments.keys())) - else: - return found_experiments.values()[0] - - def make_record_comparison_table(records, args_to_show=None, results_extractor = None, print_table = False, tablefmt='simple', reorder_by_args=False): """ Make a table comparing the arguments and results of different experiment records. You can use the output diff --git a/artemis/experiments/experiments.py b/artemis/experiments/experiments.py index db4a7a2f..f9776356 100644 --- a/artemis/experiments/experiments.py +++ b/artemis/experiments/experiments.py @@ -3,12 +3,13 @@ from collections import OrderedDict from contextlib import contextmanager from functools import partial - from six import string_types - from artemis.experiments.experiment_record import ExpStatusOptions, experiment_id_to_record_ids, load_experiment_record, \ get_all_record_ids, clear_experiment_records from artemis.experiments.experiment_record import run_and_record +from artemis.experiments.experiment_record_view import compare_experiment_records, show_record +from artemis.general.display import sensible_str + from artemis.general.functional import get_partial_root, partial_reparametrization, \ advanced_getargspec, PartialReparametrization @@ -19,7 +20,7 @@ class Experiment(object): create variants using decorated_function.add_variant() """ - def __init__(self, function=None, show=None, compare=None, one_liner_function=None, + def __init__(self, function=None, show=None, compare=None, one_liner_function=None, result_parser = None, name=None, is_root=False): """ :param function: The function defining the experiment @@ -31,9 +32,10 @@ def __init__(self, function=None, show=None, compare=None, one_liner_function=No """ self.name = name self.function = function - self._show = show - self._one_liner_results = one_liner_function - self._compare = compare + self._show = show_record if show is None else show + self._one_liner_results = sensible_str if one_liner_function is None else one_liner_function + self._result_parser = (lambda result: [('Result', self.one_liner_function(result))]) if result_parser is None else result_parser + self._compare = compare_experiment_records if compare is None else compare self.variants = OrderedDict() self._notes = [] self.is_root = is_root @@ -62,6 +64,10 @@ def compare(self): def compare(self, val): self._compare = val + @property + def result_parser(self): + return self._result_parser + def __call__(self, *args, **kwargs): """ Run the function as normal, without recording or anything. You can also modify with arguments. """ return self.function(*args, **kwargs) @@ -161,6 +167,7 @@ def _create_experiment_variant(self, args, kwargs, is_root): show=self._show, compare=self._compare, one_liner_function=self.one_liner_function, + result_parser=self._result_parser, is_root=is_root ) self.variants[name] = ex @@ -183,7 +190,7 @@ def add_variant(self, variant_name = None, **kwargs): :param variant_name: Optionally, the name of the experiment :param kwargs: The named arguments which will differ from the base experiment. - :return: The experiment. + :return Experiment: The experiment. """ return self._create_experiment_variant(() if variant_name is None else (variant_name, ), kwargs, is_root=False) @@ -204,7 +211,7 @@ def add_root_variant(self, variant_name=None, **kwargs): :param variant_name: Optionally, the name of the experiment :param kwargs: The named arguments which will differ from the base experiment. - :return: The experiment. + :return Experiment: The experiment. """ return self._create_experiment_variant(() if variant_name is None else (variant_name, ), kwargs, is_root=True) @@ -320,12 +327,13 @@ def browse(self, command=None, catch_errors = False, close_after = False, filter :param display_format: How experements and their records are displayed: 'nested' or 'flat'. 'nested' might be better for narrow console outputs. """ - from artemis.experiments.ui import browse_experiments - browse_experiments(command = command, root_experiment=self, catch_errors=catch_errors, close_after=close_after, - filterexp=filterexp, filterrec=filterrec, - view_mode=view_mode, raise_display_errors=raise_display_errors, run_args=run_args, keep_record=keep_record, - truncate_result_to=truncate_result_to, cache_result_string=cache_result_string, remove_prefix=remove_prefix, - display_format=display_format, **kwargs) + from artemis.experiments.ui import ExperimentBrowser + experiments = get_ordered_descendents_of_root(root_experiment=self) + browser = ExperimentBrowser(experiments=experiments, catch_errors=catch_errors, close_after=close_after, + filterexp=filterexp, filterrec=filterrec, view_mode=view_mode, raise_display_errors=raise_display_errors, + run_args=run_args, keep_record=keep_record, truncate_result_to=truncate_result_to, cache_result_string=cache_result_string, + remove_prefix=remove_prefix, display_format=display_format, **kwargs) + browser.launch(command=command) # Above this line is the core api.... # ----------------------------------- @@ -434,7 +442,6 @@ def add_two_numbers(a=1, b=2): return a+b with capture_created_experiments() as exps: - add_two_numbers.add_variant(a=2) add_two_numbers.add_variant(a=3) for ex in exps: @@ -445,7 +452,7 @@ def add_two_numbers(a=1, b=2): current_len = len(_GLOBAL_EXPERIMENT_LIBRARY) new_experiments = [] yield new_experiments - for ex in _GLOBAL_EXPERIMENT_LIBRARY.values()[current_len:]: + for ex in list(_GLOBAL_EXPERIMENT_LIBRARY.values())[current_len:]: new_experiments.append(ex) @@ -458,6 +465,16 @@ def get_nonroot_global_experiment_library(): return OrderedDict((name, exp) for name, exp in _GLOBAL_EXPERIMENT_LIBRARY.items() if not exp.is_root) +def get_ordered_descendents_of_root(root_experiment): + """ + :param Experiment root_experiment: An experiment which has variants + :return List[Experiment]: A list of the descendents (i.e. variants and subvariants) of the root experiment, in the + order in which they were created + """ + descendents_of_root = set(ex for ex in root_experiment.get_all_variants(include_self=True)) + return [ex for ex in get_nonroot_global_experiment_library().values() if ex in descendents_of_root] + + def get_experiment_info(name): experiment = load_experiment(name) return str(experiment) @@ -480,6 +497,7 @@ def _kwargs_to_experiment_name(kwargs): string = string.replace('/', '_SLASH_') return string + @contextmanager def hold_global_experiment_libary(new_lib = None): if new_lib is None: diff --git a/artemis/experiments/ui.py b/artemis/experiments/ui.py index 3dff208c..d07302dc 100644 --- a/artemis/experiments/ui.py +++ b/artemis/experiments/ui.py @@ -22,7 +22,8 @@ load_experiment_record, ExpInfoFields) from artemis.experiments.experiment_record_view import (get_record_full_string, get_record_invalid_arg_string, print_experiment_record_argtable, get_oneline_result_string, - compare_experiment_records) + compare_experiment_records, + get_exportiment_record_arg_result_table, get_different_args) from artemis.experiments.experiment_record_view import show_record, show_multiple_records from artemis.experiments.experiments import load_experiment, get_nonroot_global_experiment_library from artemis.fileman.local_dir import get_artemis_data_path @@ -30,6 +31,7 @@ from artemis.general.hashing import compute_fixed_hash from artemis.general.mymath import levenshtein_distance from artemis.general.should_be_builtins import all_equal, insert_at, izip_equal, separate_common_items, bad_value +from artemis.general.table_ui import TableExplorerUI try: import readline # Makes input() behave like interactive shell. @@ -56,12 +58,12 @@ def _warn_with_prompt(message= None, prompt = 'Press Enter to continue or q then return out -def browse_experiments(command=None, **kwargs): +def browse_experiments(experiments = None, command=None, **kwargs): """ Browse Experiments :param command: Optionally, a string command to pass directly to the UI. (e.g. "run 1") - :param root_experiment: The Experiment whose (self and) children to browse + :param experiments: If root_experiment not specified, the list of experiments to look over. :param catch_errors: Catch errors that arise while running experiments :param close_after: Close after issuing one command. :param just_last_record: Only show the most recent record for each experiment. @@ -73,7 +75,7 @@ def browse_experiments(command=None, **kwargs): :param cache_result_string: Cache the result string (useful when it takes a very long time to display the results when opening up the menu - often when results are long lists). """ - browser = ExperimentBrowser(**kwargs) + browser = ExperimentBrowser(experiments=experiments, **kwargs) browser.launch(command=command) @@ -162,12 +164,13 @@ class ExperimentBrowser(object): age<24h Select records which are less than 24h old. """ - def __init__(self, root_experiment = None, catch_errors = True, close_after = False, filterexp=None, filterrec = None, + def __init__(self, experiments=None, catch_errors = True, close_after = False, filterexp=None, filterrec = None, view_mode ='full', raise_display_errors=False, run_args=None, keep_record=True, truncate_result_to=100, ignore_valid_keys=(), cache_result_string = False, slurm_kwargs={}, remove_prefix = None, display_format='nested', - show_args=False, catch_selection_errors=True, max_width=None, table_package = 'tabulate', show_archived=True): + show_args=False, catch_selection_errors=True, max_width=None, table_package = 'tabulate', show_archived=True, + sortkey = None): """ - :param root_experiment: The Experiment whose (self and) children to browse + :param Sequence[Experiment] experiments: If root_experiment not specified: A list of experiments to include. :param catch_errors: Catch errors that arise while running experiments :param close_after: Close after issuing one command. :param filterexp: Filter the experiments with this selection (see help for how to use) @@ -184,6 +187,8 @@ def __init__(self, root_experiment = None, catch_errors = True, close_after = Fa :param remove_prefix: Remove the common prefix on the experiment ids in the display. :param display_format: How experements and their records are displayed: 'nested' or 'flat'. 'nested' might be better for narrow console outputs. + :param sortkey: A key function to use for sorting experiments. It takes experiment name and returns an object to + be used by the sorter. """ if run_args is None: @@ -192,7 +197,12 @@ def __init__(self, root_experiment = None, catch_errors = True, close_after = Fa run_args['keep_record'] = keep_record if remove_prefix is None: remove_prefix = display_format=='flat' - self.root_experiment = root_experiment + + if experiments is not None: + self._experiment_names = [ex.name for ex in experiments if not ex.is_root] + else: + self._experiment_names = get_nonroot_global_experiment_library().keys() + self.close_after = close_after self.catch_errors = catch_errors self.exp_record_dict = None @@ -212,18 +222,13 @@ def __init__(self, root_experiment = None, catch_errors = True, close_after = Fa self.max_width = max_width self.table_package = table_package self.show_archived = show_archived + self._sortkey = sortkey def _reload_record_dict(self): - names = get_nonroot_global_experiment_library().keys() - if self.root_experiment is not None: - # We could just go [ex.name for ex in self.root_experiment.get_all_variants(include_self=True)] - # but we want to preserve the order in which experiments were created - descendents_of_root = set(ex.name for ex in self.root_experiment.get_all_variants(include_self=True)) - names = [name for name in names if name in descendents_of_root] - all_experiments = get_experient_to_record_dict(names) + all_experiments = get_experient_to_record_dict(self._experiment_names) return all_experiments - def _filter_record_dict(self, all_experiments): + def _filter_record_dict(self, all_experiments, sortkey=None): # Apply filters and display Table: if self._filter is not None: try: @@ -239,6 +244,10 @@ def _filter_record_dict(self, all_experiments): old_filterrec = self._filterrec self._filterrec = None raise RecordSelectionError("Failed to apply record filter: '{}' because {}. Removing filter.".format(old_filterrec, err)) + + if sortkey is not None: + all_experiments = OrderedDict((exp_name, all_experiments[exp_name]) for exp_name in sorted(all_experiments.keys(), key=sortkey)) + return all_experiments def launch(self, command=None): @@ -249,6 +258,7 @@ def launch(self, command=None): 'show': self.show, 'call': self.call, 'kill': self.kill, + 'argsort': self.argsort, 'selectexp': self.selectexp, 'selectrec': self.selectrec, 'view': self.view, @@ -260,6 +270,7 @@ def launch(self, command=None): 'explist': self.explist, 'sidebyside': self.side_by_side, 'argtable': self.argtable, + 'argcompare': self.argcompare, 'compare': self.compare, 'delete': self.delete, 'errortrace': self.errortrace, @@ -275,7 +286,7 @@ def launch(self, command=None): if display_again: all_experiments = self._reload_record_dict() try: - self.exp_record_dict = self._filter_record_dict(all_experiments) + self.exp_record_dict = self._filter_record_dict(all_experiments, sortkey=self._sortkey) except RecordSelectionError as err: _warn_with_prompt(str(err), use_prompt=self.catch_selection_errors) if not self.catch_selection_errors: @@ -423,6 +434,28 @@ def remove_notes_if_no_notes(_record_rows, _record_headers): table = tabulate(rows, headers=full_headers) else: raise NotImplementedError(self.table_package) + + elif self.display_format == 'args': + + def arg_iterator(exp_record_dict_): + for exp_name, record_ids_ in exp_record_dict_.items(): + if len(record_ids_)==0: + yield load_experiment(exp_name).get_args() + else: + for record_id_ in record_ids_: + yield load_experiment_record(record_id_).get_args() + + arg_names, different_args = get_different_args(arg_iterator(exp_record_dict), arrange_by_deltas=True) + rows = [] + arg_iter = iter(different_args) + info_to_include = [ExpRecordDisplayFields.STATUS, ExpRecordDisplayFields.RESULT_STR] + for i, (exp_id, record_ids) in enumerate(exp_record_dict.items()): + if len(record_ids)==0: + rows.append([str(i), '']+list(next(arg_iter))) + else: + for j, record_id in enumerate(record_ids): + rows.append([str(i) if j==0 else '', j] + list(next(arg_iter)) + row_func(record_id, info_to_include, raise_display_errors=self.raise_display_errors, truncate_to=self.truncate_result_to, ignore_valid_keys=self.ignore_valid_keys)) + table = tabulate(rows, headers=['E#', 'R#']+list(arg_names)+[f.value for f in info_to_include]) else: raise NotImplementedError(self.display_format) @@ -479,6 +512,21 @@ def run(self, *args): if result=='q': quit() + def argsort(self, *args): + + # First verify that all args are included... + all_arg_names = set(a for exp_name in self.exp_record_dict.keys() for a, v in load_experiment(exp_name).get_args().items()) + if any(a not in all_arg_names for a in args): + raise RecordSelectionError('Arg(s) [{}] were not included in any experiments') + + # Define a comparison function that will always compare. + def key_sorting_function(exp_name): + exp_args = load_experiment(exp_name).get_args() + return tuple(() if name not in exp_args else (None, exp_args[name]) if isinstance(exp_args[name], (int, float)) else (str(type(exp_args[name])), exp_args[name]) for name in args) + + self._sortkey = key_sorting_function + return ExperimentBrowser.REFRESH + def archive(self, *args): parser = argparse.ArgumentParser() parser.add_argument('user_range', action='store', help='A selection of experiments to run. Examples: "3" or "3-5", or "3,4,5"') @@ -557,7 +605,7 @@ def compare(self, *args): _warn_with_prompt(use_prompt=False) def displayformat(self, new_format): - assert new_format in ('nested', 'flat'), "Display format must be 'nested' or 'flat', not '{}'".format(new_format) + assert new_format in ('nested', 'flat', 'args'), "Display format must be 'nested' or 'flat' or 'args', not '{}'".format(new_format) self.display_format = new_format return ExperimentBrowser.REFRESH @@ -609,6 +657,18 @@ def argtable(self, *args): parser.add_argument('user_range', action='store', nargs = '?', default='all', help='A selection of experiment records to run. Examples: "3" or "3-5", or "3,4,5"') args = parser.parse_args(args) records = select_experiment_records(args.user_range, self.exp_record_dict, flat=True) + + headers, rows = get_exportiment_record_arg_result_table(records) + TableExplorerUI(table_data=rows, col_headers=headers).launch() + + # print(get_exportiment_record_arg_result_table(records)) + _warn_with_prompt(use_prompt=False) + + def argcompare(self, *args): + parser = argparse.ArgumentParser() + parser.add_argument('user_range', action='store', nargs = '?', default='all', help='A selection of experiment records to run. Examples: "3" or "3-5", or "3,4,5"') + args = parser.parse_args(args) + records = select_experiment_records(args.user_range, self.exp_record_dict, flat=True) print_experiment_record_argtable(records) _warn_with_prompt(use_prompt=False) diff --git a/artemis/general/checkpoint_counter.py b/artemis/general/checkpoint_counter.py index 7c7bc745..c1f94264 100644 --- a/artemis/general/checkpoint_counter.py +++ b/artemis/general/checkpoint_counter.py @@ -56,6 +56,7 @@ def __init__(self, checkpoint_generator, default_units = None, skip_first = Fals A string e.g. '3s', indicating "plot every 3 seconds" A generator object yielding checkpoints A list/tuple/array of checkpoints + A dict mapping progress point to interval. e.g. {0: 0.25, 1: 0.5, 2: 1) means "move in steps of 0.25 at first, then 0.5 after 1 epoch, then 1 after 2 epochs" ('even', interval) ('exp', first, growth) None @@ -81,6 +82,8 @@ def __init__(self, checkpoint_generator, default_units = None, skip_first = Fals checkpoint_generator = (first*i*(1+growth)**(i-1) for i in itertools.count(0)) else: raise Exception("Can't make a checkpoint generator {}".format(checkpoint_generator)) + elif isinstance(checkpoint_generator, dict): + checkpoint_generator = dictionary_interval_generator(checkpoint_generator) elif isinstance(checkpoint_generator, (list, tuple, np.ndarray)): checkpoint_generator = iter(checkpoint_generator) elif isinstance(checkpoint_generator, (int, float)): @@ -163,3 +166,25 @@ def do_every(interval, counter_id=None, units = None): if counter_id not in _COUNTERS_DICT: _COUNTERS_DICT[counter_id] = Checkpoints(checkpoint_generator=interval, default_units=units) return _COUNTERS_DICT[counter_id]() + + +def dictionary_interval_generator(position_increment_dict): + """ + Generate checkpoints based on a dictionary of . Eg. + {0: 0.5, 2: 1, 5: 2} will generate + (0, 0.5, 1, 1.5, 2, 3, 4, 5, 7, 9, 11, ...} + + :param Dict[float, float] position_increment_dict: A dict mapping the current position to the increment for the next position. + :return Generator[float]: A series of checkpoints. + """ + change_points = sorted(position_increment_dict.keys()) + intervals = [position_increment_dict[p] for p in change_points] + current_interval_index = 0 + pt = change_points[0] + yield pt + while True: + increment = intervals[current_interval_index] + pt = pt+increment + yield pt + if current_interval_index < len(change_points)-1 and change_points[current_interval_index+1] <= pt: + current_interval_index+=1 diff --git a/artemis/general/dead_easy_ui.py b/artemis/general/dead_easy_ui.py new file mode 100644 index 00000000..4285dc95 --- /dev/null +++ b/artemis/general/dead_easy_ui.py @@ -0,0 +1,212 @@ +from __future__ import print_function +from __future__ import absolute_import +from builtins import range +from builtins import input +from builtins import zip +import inspect +import shlex +from collections import OrderedDict + +# Code taken and modified from: +# Taken from https://github.com/braincorp/dead_easy_ui/ + + +class DeadEasyUI(object): + """ + A tool for making a small program that can be run either programmatically or as a command-line user interface. Use + this class by extending it. Whatever methods you add to the extended class will become commands that you can run. + + Example (which you can also run at the bottom of this file): + + class MyUserInterface(DeadEasyUI): + + def isprime(self, number): + print '{} is {}prime'.format(number, {False: '', True: 'not '}[next(iter([True for i in range(3, number) if number%i==0]+[False]))]) + + def showargs(self, arg1, arg2): + print ' arg1: {}: {}\n arg2: {}: {}\n'.format(arg1, type(arg1), arg2, type(arg2)) + + MyUserInterface().launch(run_in_loop=True) + + This will bring up a UI + + ==== MyUserInterface console menu ==== + Command Options: {isprime, showargs} + Enter Command or "help" for help >> isprime 117 + 117 is not prime + ==== MyUserInterface console menu ==== + Command Options: {isprime, showargs} + Enter Command or "help" for help >> showargs 'abc' arg2=4.3 + arg1: abc: + arg2: 4.3: + + """ + + def _get_menu_string(self): + return '==== {} console menu ====\n'.format(self.__class__.__name__) if (self.__doc__ is None or self.__doc__ == DeadEasyUI.__doc__) else self.__doc__ if self.__doc__.endswith('\n') else self.__doc__+ '\n' + + def launch(self, prompt = 'Enter Command or "h" for help >> ', run_in_loop = True, arg_handling_mode='fallback'): + """ + Launch a command-line UI. + :param prompt: + :param run_in_loop: + :param arg_handling_mode: Can be ('str', 'guess', 'literal') + 'str': Means pass all args to the method as strings + 'literal': Means use eval to parse each arg. + 'fallback': Try literal parsing, and if it fails, fall back to string + :return: + """ + + def linenumber_of_member(k, m): + try: + return m.__func__.__code__.co_firstlineno + except AttributeError: + return -1 + + mymethods = sorted(inspect.getmembers(self, predicate=inspect.ismethod), + key = lambda pair: linenumber_of_member(*pair)) + mymethods = [(method_name, method) for method_name, method in mymethods if method_name!='launch' and not method_name.startswith('_')] + mymethods = OrderedDict(mymethods) + + options_doc = 'Command Options: {{{}}}'.format(', '.join([k for k in list(mymethods.keys())]+['quit', 'help'])) + + skip_info = False + while True: + doc = self._get_menu_string() + + if not skip_info: + print('{}{}'.format(doc, options_doc)) + user_input = input(' {}'.format(prompt)) + cmd, args, kwargs = parse_user_function_call(user_input, arg_handling_mode=arg_handling_mode) + + if cmd is None: + continue + + skip_info = False + if cmd in ('h', 'help'): + print(self._get_help_string(mymethods=mymethods, method_names_for_help=[args[0]] if len(args) > 0 else None)) + skip_info = True + continue + elif cmd in ('q', 'quit'): + print('Quitting {}. So long.'.format(self.__class__.__name__)) + break + elif cmd in mymethods: + mymethods[cmd](*args, **kwargs) + else: + print("Unknown command '{}'. Options are {}".format(cmd, list(mymethods.keys())+['help'])) + skip_info = True + continue + if not run_in_loop: + break + + def _get_help_string(self, mymethods, method_names_for_help=None): + string = '' + string += '----------------------------\n' + string += "To run a command, type the method name and then space-separated arguments. e.g.\n >> my_method 1 'string-arg' named_arg=2\n\n" + if method_names_for_help is None: + method_names_for_help = list(mymethods.keys()) + if len(method_names_for_help) == 0: + string+= "Class {} has no methods, and is therefor a useless console menu. Add methods.\n".format( + self.__class__.__name__) + for method_name in method_names_for_help: + argspec = inspect.getargspec(mymethods[method_name]) + default_start_ix = len(argspec.args) if argspec.defaults is None else len(argspec.args) - len( + argspec.defaults) + argstring = ' '.join([repr(a) for a in argspec.args[1:default_start_ix]] + + ['[{}={}]'.format(a, repr(v)) for a, v in zip(argspec.args[default_start_ix:], argspec.defaults if argspec.defaults is not None else [])]) \ + if len(argspec.args)>1 else '' + doc = mymethods[method_name].__doc__ if mymethods[method_name].__doc__ is not None else '' + string+= '- {} {}: {}\n'.format(method_name, argstring, doc) + string += '----------------------------\n' + return string + + +def parse_user_function_call(cmd_str, arg_handling_mode = 'fallback'): + """ + A simple way to parse a user call to a python function. The purpose of this is to make it easy for a user + to specify a python function and the arguments to call it with from the console. Example: + + parse_user_function_call("my_function 1 'two' a='three'") == ('my_function', (1, 'two'), {'a': 'three'}) + + Other code can use this to actually call the function + + Parse arguments to a Python function + :param str cmd_str: The command string. e.g. "my_function 1 'two' a='three'" + :param forgive_unquoted_strings: Allow for unnamed string args to be unquoted. + e.g. "my_function my_arg_string" would interpreted as "my_function 'my_arg_string' instead of throwing an error" + :return: The function name, args, kwargs + :rtype: Tuple[str, Tuple[Any]. Dict[str: Any] + """ + + assert arg_handling_mode in ('str', 'literal', 'fallback') + + # def _fake_func(*args, **kwargs): + # Just exists to help with extracting args, kwargs + # return args, kwargs + + cmd_args = shlex.split(cmd_str, posix=False) + assert len(cmd_args) == len(shlex.split(cmd_str, posix=True)), "Parse error on string '{}'. You're not allowed having spaces in the values of string keyword args:".format(cmd_str) + + if len(cmd_args)==0: + return None, None, None + + func_name = cmd_args[0] + + def parse_arg(arg_str): + if arg_handling_mode=='str': + return arg_str + elif arg_handling_mode=='literal': + return eval(arg_str, {}, {}) + else: + try: + return eval(arg_str, {}, {}) + except: + return arg_str + + args = [] + kwargs = {} + for arg in cmd_args[1:]: + if '=' not in arg: # Positional + assert len(kwargs)==0, 'You entered a positional arg after a keyword arg. Keyword args {} aleady exist.'.format(kwargs) + args.append(parse_arg(arg)) + else: + arg_name, arg_val = arg.split('=', 1) + kwargs[arg_name] = parse_arg(arg_val) + + return func_name, args, kwargs + + # if forgive_unquoted_strings: + # cmd_args = [cmd_args[0]] + [_quote_args_that_you_forgot_to_quote(arg) for arg in cmd_args[1:]] + # + # args, kwargs = eval('_fake_func(' + ','.join(cmd_args[1:]) + ')', {'_fake_func': _fake_func}, {}) + # return func_name, args, kwargs + + +def _quote_args_that_you_forgot_to_quote(arg): + """Wrap the arg in quotes if the user failed to do it.""" + if arg.startswith('"') or arg.startswith("'"): + return arg + elif '=' in arg and sum(a=='=' for a in arg)==1: # Keyword + name, val = arg.split('=') + if val[0].isalpha(): + return '{}="{}"'.format(name, val) + else: + return arg + else: + if arg[0].isalpha(): + return '"{}"'.format(arg) + else: + return arg + + +if __name__ == '__main__': + + class MyUserInterface(DeadEasyUI): + + def isprime(self, number): + print('{} is {}prime'.format(number, {False: '', True: 'not '}[next(iter([True for i in range(3, number) if number % i==0]+[False]))])) + + def showargs(self, arg1, arg2): + print(' arg1: {}: {}\n arg2: {}: {}\n'.format(arg1, type(arg1), arg2, type(arg2))) + + MyUserInterface().launch(run_in_loop=True) diff --git a/artemis/general/display.py b/artemis/general/display.py index 1d95cb16..03957f6d 100644 --- a/artemis/general/display.py +++ b/artemis/general/display.py @@ -39,7 +39,13 @@ def dict_to_str(d): :param dict d: A dict :return str: A nice, formatted version of this dict. """ - return ', '.join('{}:{}'.format(k, repr(v)) for k, v in d.items()) + if isinstance(d, (list, tuple)) and all(isinstance(el, (list, tuple)) and len(el)==2 for el in d): + items = d + elif isinstance(d, dict): + items = d.items() + else: + raise Exception("Can't interpret object {}".format(d)) + return ', '.join('{}:{:.3g}'.format(k, v) if isinstance(v, float) else '{}:{}'.format(k, repr(v)) for k, v in items) def pyfuncstring_to_tex(pyfuncstr): diff --git a/artemis/general/duck.py b/artemis/general/duck.py index bb0cd6d3..063dd45a 100644 --- a/artemis/general/duck.py +++ b/artemis/general/duck.py @@ -410,9 +410,12 @@ def __getitem__(self, indices): new_substruct = self._struct[first_selector] if isinstance(new_substruct, UniversalCollection) and not isinstance(new_substruct, Duck): # This will happen if the selector is a slice or something... new_substruct = Duck(new_substruct, recurse=False) + if len(indices)==1: # Case 1: Simple... this is the last selector, so we can just return it. return new_substruct - else: # Case 2: + else: # Case 2: There are deeper indices to get + if not isinstance(new_substruct, Duck): + raise KeyError('Leave value "{}" can not be broken into with {}'.format(new_substruct, indices[1:])) if isinstance(first_selector, (list, np.ndarray, slice)): # Sliced selection, with more sub-indices return new_substruct.map(lambda x: x.__getitem__(indices[1:])) else: # Simple selection, with more sub-indices @@ -634,8 +637,15 @@ def open(self, *ixs): ixs = tuple(-1 if ix is next else ix for ix in ixs) return self[ixs] - def has_key(self, *key_chain): - return self._struct.has_key() + def has_key(self, key, *deeper_keys): + + try: + self[(key, )+deeper_keys] + return True + except (KeyError, AttributeError): + return False + # Alternate definition that checks for exact key values but does not handle slices, negative indices, etc. + # return self._struct.has_key(key) and (len(deeper_keys)==0 or (isinstance(self._struct[key], Duck) and self._struct[key].has_key(*deeper_keys))) def keys(self, depth=None): if depth is None: diff --git a/artemis/general/mymath.py b/artemis/general/mymath.py index 4490ad5e..b02bfbed 100644 --- a/artemis/general/mymath.py +++ b/artemis/general/mymath.py @@ -289,6 +289,21 @@ def is_parallel(a, b, angular_tolerance = 1e-7): return angle < angular_tolerance +def vector_projection(v, u, axis=-1, norm_factor=0.): + """ + Project v onto u. + :param v: A vector or collection of vectors + :param u: A vector or collection of vectors which is broadcastable against v + :param axis: The axis of v along which the vector is defined. + :return: An array the same shape as v projected onto u. + """ + return u*((norm_factor+(u*v).sum(axis=axis, keepdims=True)) / (norm_factor+(u*u).sum(axis=axis, keepdims=True))) + # true_axis = v.ndim+axis if axis<0 else axis + # u_norm = u/(u*u).sum(axis=axis, keepdims=True) + # vu_dot = (u*v).sum(axis=axis, keepdims=True) / (u*u).sum(axis=axis, keepdims=True) + # return vu_dot*u + + def align_curves(xs, ys, n_bins='median', xrange = ('min', 'max'), spacing = 'lin'): """ Given multiple curves with different x-coordinates, interpolate so that each has the same x points. diff --git a/artemis/general/nested_structures.py b/artemis/general/nested_structures.py index f9c9cfe6..74e3f992 100644 --- a/artemis/general/nested_structures.py +++ b/artemis/general/nested_structures.py @@ -1,4 +1,6 @@ -from collections import OrderedDict +import inspect +from collections import OrderedDict, Iterable +from functools import partial import numpy as np from six import string_types, next @@ -64,11 +66,19 @@ def flatten_struct(struct, primatives = PRIMATIVE_TYPES, custom_handlers = {}, _primitive_containers = (list, tuple, dict, set) -def _is_primitive_container(obj): +def isgenerator(iterable): + return hasattr(iterable,'__iter__') and not hasattr(iterable,'__len__') + + +def is_primitive_container(obj): return isinstance(obj, _primitive_containers) or hasattr(obj, '_fields') -def get_meta_object(data_object, is_container = _is_primitive_container): +def is_container_or_generator(obj): + return isinstance(obj, _primitive_containers) or hasattr(obj, '_fields') or isgenerator(obj) + + +def get_meta_object(data_object, is_container = is_primitive_container, flat_list = None): """ Given an arbitrary data structure, return a "meta object" which is the same structure, except all non-container objects are replaced by their types. @@ -82,16 +92,52 @@ def get_meta_object(data_object, is_container = _is_primitive_container): """ if is_container(data_object): if hasattr(data_object, '_fields'): - return type(data_object)(*(get_meta_object(x, is_container=is_container) for x in data_object)) + return type(data_object)(*(get_meta_object(x, is_container=is_container, flat_list=flat_list) for x in data_object)) elif isinstance(data_object, (list, tuple, set)): - return type(data_object)(get_meta_object(x, is_container=is_container) for x in data_object) + return type(data_object)(get_meta_object(x, is_container=is_container, flat_list=flat_list) for x in data_object) elif isinstance(data_object, dict): - return type(data_object)((k, get_meta_object(v, is_container=is_container)) for k, v in data_object.items()) + return type(data_object)((k, get_meta_object(v, is_container=is_container, flat_list=flat_list)) for k, v in data_object.items()) + elif isgenerator(data_object): + return tuple(get_meta_object(x, is_container=is_container, flat_list=flat_list) for x in data_object) + else: + raise Exception("Don't know how to handle containier: {}".format(data_object)) else: + if flat_list is not None: + flat_list.append(data_object) return type(data_object) -def broadcast_into_meta_object(meta_object, data_object, is_container = _is_primitive_container, check_types = True): +def get_leaves_and_rebuilder(nested_object, is_container = is_container_or_generator, check_types=True, assert_fully_used=True): + """ + Given a nested structure, get the leaves in the structure, and a function to rebuild them. + + e.g. + flat_list, rebuilder = get_leaves_and_rebuilder({'a': 1, 'b': (2, 3)}) + assert flat_list == [1, 2, 3] + assert rebuilder(a*2 for a in flat_list) == {'a': 2, 'b': (4, 6)} + + :param nested_object An arbitrarily nested object + :return Tuple[List, Callable[[Sequence], Any]] : Return the flattened sequence and the function required to rebuild into the nested format. + """ + # TODO: Consider making leaves a generator so this could be used for streams. + leaves = [] + meta_obj = get_meta_object(nested_object, is_container=is_container, flat_list=leaves) + return leaves, (lambda data_iteratable: _fill_meta_object(meta_object=meta_obj, data_iteratable=iter(data_iteratable), check_types=check_types, assert_fully_used=assert_fully_used, is_container_func=is_container)) + + +def get_leaves(nested_object, is_container = is_primitive_container): + """ + + :param nested_object: + :param is_container: + :return: + """ + leaves = [] + meta_obj = get_meta_object(nested_object, is_container=is_container, flat_list=leaves) + return leaves + + +def broadcast_into_meta_object(meta_object, data_object, is_container = is_primitive_container, check_types = True): """ "Broadcast" the data object into the meta object. This puts the data into the structure of the meta-object. E.g. @@ -159,7 +205,7 @@ def __repr__(self): def __eq__(self, other): return self.meta_object == other.meta_object - def broadcast(self, data_object, is_container = _is_primitive_container, check_types=True): + def broadcast(self, data_object, is_container = is_primitive_container, check_types=True): """ "Broadcast" a data object to have the given structure. e.g. @@ -172,7 +218,7 @@ def broadcast(self, data_object, is_container = _is_primitive_container, check_t """ return broadcast_into_meta_object(meta_object=self.meta_object, data_object=data_object, is_container=is_container, check_types=check_types) - def get_leaves(self, data_object, check_types = True, broadcast=False, is_container = _is_primitive_container): + def get_leaves(self, data_object, check_types = True, broadcast=False, is_container = is_primitive_container): """ :param data_object: Given a nested object, get the "leaf" values in Depth-First Order :return: A list of leaf values. @@ -183,7 +229,7 @@ def get_leaves(self, data_object, check_types = True, broadcast=False, is_contai self.check_type(data_object) return get_leaf_values(data_object, is_container_func=is_container) - def expand_from_leaves(self, leaves, check_types = True, assert_fully_used=True, is_container_func = _is_primitive_container): + def expand_from_leaves(self, leaves, check_types = True, assert_fully_used=True, is_container_func = is_primitive_container): """ Given an iterator of leaf values, fill the meta-object represented by this type. @@ -195,7 +241,7 @@ def expand_from_leaves(self, leaves, check_types = True, assert_fully_used=True, return _fill_meta_object(self.meta_object, (x for x in leaves), check_types=check_types, assert_fully_used=assert_fully_used, is_container_func=is_container_func) @staticmethod - def from_data(data_object, is_container_func = _is_primitive_container): + def from_data(data_object, is_container_func = is_primitive_container): """ :param data_object: A nested data object :param is_container_func: A callback which returns True if an object is to be considered a container and False otherwise @@ -204,6 +250,7 @@ def from_data(data_object, is_container_func = _is_primitive_container): return NestedType(get_meta_object(data_object, is_container=is_container_func)) + def isnamedtuple(thing): return hasattr(thing, '_fields') and len(thing.__bases__)==1 and thing.__bases__[0]==tuple @@ -212,7 +259,7 @@ def isnamedtupleinstance(thing): return isnamedtuple(thing.__class__) -def get_leaf_values(data_object, is_container_func = _is_primitive_container): +def get_leaf_values(data_object, is_container_func = is_primitive_container): """ Collect leaf values of a nested data_obj in Depth-First order. @@ -244,7 +291,7 @@ def get_leaf_values(data_object, is_container_func = _is_primitive_container): return [data_object] -def _fill_meta_object(meta_object, data_iteratable, assert_fully_used = True, check_types = True, is_container_func = _is_primitive_container): +def _fill_meta_object(meta_object, data_iteratable, assert_fully_used = True, check_types = True, is_container_func = is_primitive_container): """ Fill the data from the iterable into the meta_object. :param meta_object: A nested type descripter. See NestedType init @@ -294,7 +341,7 @@ def nested_map(func, *nested_objs, **kwargs): :param is_container_func: A callback which returns True if an object is to be considered a container and False otherwise :return: A nested objectect with the same structure, but func applied to every value. """ - is_container_func = kwargs['is_container_func'] if 'is_container_func' in kwargs else _is_primitive_container + is_container_func = kwargs['is_container_func'] if 'is_container_func' in kwargs else is_primitive_container check_types = kwargs['check_types'] if 'check_types' in kwargs else False assert len(nested_objs)>0, 'nested_map requires at least 2 args' assert callable(func), 'func must be a function with one argument.' diff --git a/artemis/general/progress_indicator.py b/artemis/general/progress_indicator.py index 4e365eed..42554413 100644 --- a/artemis/general/progress_indicator.py +++ b/artemis/general/progress_indicator.py @@ -38,15 +38,16 @@ def __init__(self, expected_iterations=None, name=None, update_every = (2, 'seco def __call__(self, iteration = None): self.print_update(iteration) - def print_update(self, progress=None): + def print_update(self, progress=None, info=None): self._current_time = time.time() elapsed = self._current_time - self._start_time if self._expected_iterations is None: if self._should_update(): - print ('Progress{}: {:.1f}s Elapsed. {}. {} calls averaging {:.2g} calls/s'.format( + print ('Progress{}: {:.1f}s Elapsed{}{}. {} calls averaging {:.2g} calls/s'.format( '' if self.name is None else ' of '+self.name, elapsed, - self._post_info_callback() if self._post_info_callback is not None else '', + '. '+ self._post_info_callback() if self._post_info_callback is not None else '', + ', '+ info if info is not None else '', self._i+1, (self._i+1)/elapsed )) @@ -62,15 +63,16 @@ def print_update(self, progress=None): else: remaining = elapsed * (1 / frac - 1) if frac > 0 else float('NaN') elapsed = self._current_time - self._start_time - print('Progress{}: {}%. {:.1f}s Elapsed, {:.1f}s Remaining{}. {} {} calls averaging {:.2g} calls/s'.format( - '' if self.name is None else ' of '+self.name, - int(100*frac), - elapsed, - remaining, - ', {:.1f}s Total'.format(elapsed+remaining) if self.show_total else '', - self._post_info_callback() if self._post_info_callback is not None else '', - self._i+1, - (self._i+1)/elapsed + print('Progress{name}: {progress}%. {elapsed:.1f}s Elapsed, {remaining:.1f}s Remaining{total}. {info_cb}{info}{n_calls} calls averaging {rate:.2g} calls/s'.format( + name = '' if self.name is None else ' of '+self.name, + progress = int(100*frac), + elapsed = elapsed, + remaining = remaining, + total = ', {:.1f}s Total'.format(elapsed+remaining) if self.show_total else '', + info_cb = '. '+ self._post_info_callback() if self._post_info_callback is not None else '', + info=', '+ info if info is not None else '', + n_calls=self._i+1, + rate=(self._i+1)/elapsed )) self._last_update = progress if self._update_unit == 'iterations' else self._current_time self._i += 1 diff --git a/artemis/general/should_be_builtins.py b/artemis/general/should_be_builtins.py index 1749b266..b686c790 100644 --- a/artemis/general/should_be_builtins.py +++ b/artemis/general/should_be_builtins.py @@ -162,8 +162,9 @@ def remove_duplicates(sequence, hashable=True, key=None, keep_last=False): :param keep_last: Keep the last element, rather than the first (only makes sense if key is not None) :returns: A list that maintains the order, but with duplicates removed """ + sequence = list(sequence) is_dup = detect_duplicates(sequence, hashable=hashable, key=key, keep_last=keep_last) - return [x for x, is_duplicate in zip(sequence, is_dup) if not is_duplicate] + return (x for x, is_duplicate in zip(sequence, is_dup) if not is_duplicate) def uniquify_duplicates(sequence_of_strings): @@ -257,7 +258,7 @@ def separate_common_items(list_of_lists): if are_dicts: list_of_lists = [el.items() for el in list_of_lists] all_items = [item for list_of_items in list_of_lists for item in list_of_items] - common_items = remove_duplicates([k for k, c in count_unique_items(all_items) if c==len(list_of_lists)], hashable=False) + common_items = list(remove_duplicates([k for k, c in count_unique_items(all_items) if c==len(list_of_lists)], hashable=False)) different_items = [[item for item in list_of_items if item not in common_items] for list_of_items in list_of_lists] if are_dicts: return dict(common_items), [dict(el) for el in different_items] @@ -461,4 +462,20 @@ def unzip(iterable): :param iterable: Any iterable object yielding N-tuples :return: A N-tuple of iterables """ - return zip(*iterable) \ No newline at end of file + return zip(*iterable) + + +def entries_to_table(tuplelist, fill_value = None): + """ + Turn a bunch of entries into a table. e.g. + + >>> entries_to_table([[('a', 1), ('b', 2)], [('a', 3), ('b', 4), ('c', 5)]]) + (['a', 'b', 'c'], [[1, 2, None], [3, 4, 5]]) + + :param Sequence[Sequence[Tuple[str, Any]]] tuplelist: N_samples samples of N_observations observations, each represented by (observation_name, observation_value) + :return Tuple[Sequence[str], Sequence[Sequence[Any]]: (observation_names, data) A list of observation_names and the data suitable for tabular plotting. + """ + all_entries = list(remove_duplicates((k for sample in tuplelist for k, v in (sample.items() if isinstance(sample, dict) else sample)))) + data = [dict(sample) for sample in tuplelist] + new_data = [[d[k] if k in d else fill_value for k in all_entries] for d in data] + return all_entries, new_data diff --git a/artemis/general/table_ui.py b/artemis/general/table_ui.py new file mode 100644 index 00000000..1f6658de --- /dev/null +++ b/artemis/general/table_ui.py @@ -0,0 +1,121 @@ +import numpy as np +from tabulate import tabulate +from artemis.general.dead_easy_ui import DeadEasyUI + + +class TableExplorerUI(DeadEasyUI): + + def __init__(self, table_data, col_headers=None, row_headers=None, col_indices=None, row_indices = None): + + assert all(len(r)==len(table_data[0]) for r in table_data), "All rows of table data must have the same length. Got lengths: {}".format([len(r) for r in table_data]) + table_data = np.array(table_data, dtype=object) + assert table_data.ndim==2, "Table must consist of 2d data" + + assert col_headers is None or len(col_headers)==table_data.shape[1] + assert row_headers is None or len(row_headers)==table_data.shape[0] + + self._table_data = table_data + self._col_indices = np.array(col_indices) if col_indices is not None else None + self._row_indices = np.array(row_indices) if row_indices is not None else None + self._col_headers = np.array(col_headers) if col_headers is not None else None + self._row_headers = np.array(row_headers) if row_headers is not None else None + self._old_data_buffer = [] + + @property + def n_rows(self): + return self._table_data.shape[0] + + @property + def n_cols(self): + return self._table_data.shape[1] + + def _get_full_table(self): + n_total_rows = 1 + int(self._col_headers is not None) + self._table_data.shape[0] + n_total_cols = 1 + int(self._row_headers is not None) + self._table_data.shape[1] + table_data = np.empty((n_total_rows, n_total_cols), dtype=object) + table_data[:2, :2] = '' + table_data[0, -self.n_cols:] = self._col_indices if self._col_indices is not None else ['{}'.format(i) for i in range(1, self.n_cols+1)] + table_data[-self.n_rows:, 0] = self._row_indices if self._row_indices is not None else ['{}'.format(i) for i in range(1, self.n_rows+1)] + if self._col_headers is not None: + table_data[1, -self.n_cols:] = self._col_headers + if self._row_headers is not None: + table_data[-self.n_rows:, 1] = self._row_headers + table_data[-self.n_rows:, -self.n_cols:] = self._table_data + return table_data + + def _get_menu_string(self): + table_str = tabulate(self._get_full_table()) + return '{}\n'.format(table_str) + + def _backup(self): + self._old_data_buffer.append((self._table_data, self._row_headers, self._row_indices, self._col_headers, self._col_indices)) + + def undo(self): + if len(self._old_data_buffer)==0: + print("Can't undo, no history") + else: + self._table_data, self._row_headers, self._row_indices, self._col_headers, self._col_indices = self._old_data_buffer.pop() + + def _parse_indices(self, user_range): + if isinstance(user_range, str): + user_range = user_range.split(',') + return [int(i)-1 for i in user_range] + + def _reindex(self, row_ixs=None, col_ixs=None): + self._backup() + if row_ixs is not None: + self._table_data = self._table_data[row_ixs, :] + if self._row_headers is not None: + self._row_headers = self._row_headers[row_ixs] + if self._row_indices is not None: + self._row_indices = self._row_indices[row_ixs] + if col_ixs is not None: + self._table_data = self._table_data[:, col_ixs] + if self._col_headers is not None: + self._col_headers = self._col_headers[col_ixs] + if self._col_indices is not None: + self._col_indices = self._col_indices[col_ixs] + + def delcol(self, user_range): + self._reindex(col_ixs=[i for i in range(self.n_cols) if i not in self._parse_indices(user_range)]) + + def delrow(self, user_range): + self._reindex(row_ixs=[i for i in range(self.n_rows) if i not in self._parse_indices(user_range)]) + + def shufrows(self, user_range): + indices = self._parse_indices(user_range) + self._reindex(row_ixs=indices + [i for i in range(self.n_rows) if i not in indices]) + + def shufcols(self, user_range): + indices = self._parse_indices(user_range) + self._reindex(col_ixs=indices + [i for i in range(self.n_cols) if i not in indices]) + + def sortrows(self, by_cols=None, shuffle_cols=True): + key_order_indices = self._parse_indices(by_cols) if by_cols is not None else range(self.n_cols) + + sorting_data = self._table_data[:, key_order_indices[::-1]].copy() + for col in range(sorting_data.shape[1]): + if np.mean([np.isreal(x) for x in sorting_data[:, col]]) % 1 != 0: # Indicating not some numeric and some non-numeric data + sorting_data[:, col] = [(not np.isreal(x), x) for x in sorting_data[:, col]] + + indices = np.lexsort(sorting_data.T) + self._reindex(row_ixs=indices) + if shuffle_cols: + self.shufcols(by_cols) + + def sortcols(self, by_rows=None, shuffle_rows=True): + key_order_indices = self._parse_indices(by_rows) if by_rows is not None else range(self.n_rows) + indices = np.lexsort(self._table_data[key_order_indices[::-1], :]) + self._reindex(col_ixs=indices) + if shuffle_rows: + self.shufrows(by_rows) + + +if __name__ == '__main__': + + ui = TableExplorerUI( + col_headers=['param1', 'size', 'cost'], + row_headers=['exp1', 'exp2', 'exp3'], + table_data= [[4, 'Bella', 100], [3, 'Abe', 120], [4, 'Clarence', 117]], + ) + ui.launch() diff --git a/artemis/general/tables.py b/artemis/general/tables.py index 48cb81ed..2ea64e74 100644 --- a/artemis/general/tables.py +++ b/artemis/general/tables.py @@ -7,7 +7,7 @@ def build_table(lookup_fcn, row_categories, column_categories, clear_repeated_headers = True, prettify_labels = True, - row_header_labels = None, remove_unchanging_cols = False): + row_header_labels = None, remove_unchanging_cols = False, include_row_category=True, include_column_category = True): """ Build the rows of a table. You can feed these rows into tabulate to generate pretty things. @@ -43,11 +43,13 @@ def lookup_function(prisoner_a_choice, prisoner_b_choice): :param clear_repeated_headers: True to not repeat row headers. :param row_header_labels: Labels for the row headers. :param remove_unchanging_cols: Remove columns for which all d - :return: A list of rows. + :param include_row_category: Include the row category in the table (as the first column in each row) + :param include_column_category: Include the column category in the table (as the first row of each column) + :return Sequence[Sequence[Any]]: A list of lists containing the entries of the table. """ # Now, build that table! - single_row_category = all(isinstance(c, string_types) for c in row_categories) - single_column_category = all(isinstance(c, string_types) for c in column_categories) + single_row_category = all(not isinstance(c, (list, tuple)) for c in row_categories) + single_column_category = all(not isinstance(c, (list, tuple)) for c in column_categories) if single_row_category: row_categories = [row_categories] @@ -57,10 +59,11 @@ def lookup_function(prisoner_a_choice, prisoner_b_choice): assert len(row_header_labels) == len(row_categories) rows = [] column_headers = list(zip(*itertools.product(*column_categories))) - for i, c in enumerate(column_headers): - row_header = row_header_labels if row_header_labels is not None and i==len(column_headers)-1 else [' ']*len(row_categories) - row = row_header+(blank_out_repeats(c) if clear_repeated_headers else list(c)) - rows.append([prettify_label(el) for el in row] if prettify_labels else row) + if include_column_category: + for i, c in enumerate(column_headers): + row_header = [] if not include_row_category else row_header_labels if row_header_labels is not None and i==len(column_headers)-1 else [' ']*len(row_categories) + row = row_header+(blank_out_repeats(c) if clear_repeated_headers else list(c)) + rows.append([prettify_label(el) for el in row] if prettify_labels else row) last_row_data = [' ']*len(row_categories) for row_info in itertools.product(*row_categories): if clear_repeated_headers: @@ -70,7 +73,7 @@ def lookup_function(prisoner_a_choice, prisoner_b_choice): if prettify_labels: row_header = [prettify_label(str(el)) for el in row_header] data = [lookup_fcn(row_info[0] if single_row_category else row_info, column_info[0] if single_column_category else column_info) for column_info in itertools.product(*column_categories)] - rows.append(list(row_header) + data) + rows.append(list(row_header) + data if include_row_category else data) assert all_equal((len(r) for r in rows)), "All rows must have equal length. They now have lengths: {}".format([len(r) for r in rows]) if remove_unchanging_cols: diff --git a/artemis/general/test_checkpoint_counter.py b/artemis/general/test_checkpoint_counter.py index 2562de1c..2bfe796e 100644 --- a/artemis/general/test_checkpoint_counter.py +++ b/artemis/general/test_checkpoint_counter.py @@ -1,6 +1,6 @@ from itertools import count from artemis.general.checkpoint_counter import CheckPointCounter, Checkpoints - +import numpy as np __author__ = 'peter' @@ -27,9 +27,14 @@ def test_checkpoint_counter(): def test_checkpoints(): is_test = Checkpoints(('exp', 10, .1)) - assert [a for a in range(100) if is_test()]==[0, 10, 22, 37, 54, 74, 97] + is_test = Checkpoints({0: 0.25, 0.75: 0.5, 2.: 1}) + assert np.allclose([a for a in np.arange(0, 6, 0.1) if is_test(a)], [0, 0.3, 0.5, 0.8, 1.3, 1.8, 2.3, 3.3, 4.3, 5.3]) + + is_test = Checkpoints({1: 0.5, 2: 1, 5: 3}) + assert np.allclose([a for a in np.arange(0, 12, 0.1) if is_test(a)], [1, 1.5, 2, 3, 4, 5, 8, 11]) + if __name__ == '__main__': test_checkpoint_counter() diff --git a/artemis/general/test_duck.py b/artemis/general/test_duck.py index 9228b26f..275c3640 100644 --- a/artemis/general/test_duck.py +++ b/artemis/general/test_duck.py @@ -541,30 +541,44 @@ def test_occasional_value_filter(): assert a.filter[:, 'b'] == [2, 5] +def test_has_key(): + duck = _get_standard_test_duck() + assert duck.has_key('b') + assert duck.has_key('b', 0) + assert duck.has_key('b', 0, 'subfield1') + assert not duck.has_key('b', 0, 'subfield1', 'dadadada') + assert not duck.has_key('b', 0, 'subfield1XXX') + assert duck.has_key('b', -1, 'subfield1') + assert duck.has_key('b', slice(None), 'subfield1') + assert not duck.has_key(slice(None), slice(None), 'subfield1') + assert not duck.has_key('q') + + if __name__ == '__main__': - # test_so_demo() - # test_dict_assignment() - # test_dictarraylist() - # test_simple_growing() - # test_open_key() - # test_open_next() - # test_to_struct() - # test_next_elipsis_assignment() - # test_slice_assignment() - # test_arrayify_empty_stuct() - # test_slice_on_start() - # test_assign_tuple_keys() - # test_broadcast_bug() - # test_key_values() - # test_description() - # test_duck_array_build() - # test_split_get_assign() - # test_assign_from_struct() - # test_arrayify_axis_demo() - # test_string_slices() - # test_reasonable_errors_on_wrong_keys() - # test_reasonable_error_messages() + test_so_demo() + test_dict_assignment() + test_dictarraylist() + test_simple_growing() + test_open_key() + test_open_next() + test_to_struct() + test_next_elipsis_assignment() + test_slice_assignment() + test_arrayify_empty_stuct() + test_slice_on_start() + test_assign_tuple_keys() + test_broadcast_bug() + test_key_values() + test_description() + test_duck_array_build() + test_split_get_assign() + test_assign_from_struct() + test_arrayify_axis_demo() + test_string_slices() + test_reasonable_errors_on_wrong_keys() + test_reasonable_error_messages() test_break_in() test_copy() test_key_get_on_set_bug() test_occasional_value_filter() + test_has_key() diff --git a/artemis/general/test_mymath.py b/artemis/general/test_mymath.py index 17d8a8b3..4e1acf10 100644 --- a/artemis/general/test_mymath.py +++ b/artemis/general/test_mymath.py @@ -4,7 +4,8 @@ from artemis.general.mymath import (softmax, cummean, cumvar, sigm, expected_sigm_of_norm, mode, cummode, normalize, is_parallel, align_curves, angle_between, fixed_diff, decaying_cumsum, geosum, selective_sum, - conv_fanout, conv2_fanout_map, proportional_random_assignment, clip_to_sum) + conv_fanout, conv2_fanout_map, proportional_random_assignment, clip_to_sum, + vector_projection) import numpy as np from six.moves import xrange @@ -295,6 +296,14 @@ def test_clip_to_sum(): assert np.array_equal(clip_to_sum([1,4,8,3], 20), [1,4,8,3]) +def test_projection(): + + v = np.array([[2, 2], [2, 1]]) + u = np.array([[0, 1], [1, 1]]) + v_proj_u = vector_projection(v, u, axis=1) + assert np.allclose(v_proj_u, [[0, 2], [1.5, 1.5]]) + + if __name__ == '__main__': test_decaying_cumsum() test_fixed_diff() @@ -314,4 +323,5 @@ def test_clip_to_sum(): test_fanout_map() test_conv2_fanout_map() test_proportional_random_assignment() - test_clip_to_sum() \ No newline at end of file + test_clip_to_sum() + test_projection() \ No newline at end of file diff --git a/artemis/general/test_nested_structures.py b/artemis/general/test_nested_structures.py index 19fa7f19..7639911d 100644 --- a/artemis/general/test_nested_structures.py +++ b/artemis/general/test_nested_structures.py @@ -8,7 +8,8 @@ from artemis.general.nested_structures import (flatten_struct, get_meta_object, NestedType, seqstruct_to_structseq, structseq_to_seqstruct, nested_map, - get_leaf_values, broadcast_into_meta_object) + get_leaf_values, broadcast_into_meta_object, + get_leaves_and_rebuilder) def test_flatten_struct(): @@ -152,6 +153,24 @@ def test_namedtuple_breakin(): assert struct.broadcast([1, 2]) == [thing(1, 1), thing(2, 2)] +def test_flatten_nested_struct_and_rebuild(): + + obj = [1, 2, {'a': (3, 4.), 'b': 'c'}] + flat_list, rebuilder = get_leaves_and_rebuilder(obj) + assert flat_list==[1, 2, 3, 4., 'c'] + new_obj = rebuilder(flat_list) + assert new_obj==obj + + obj = ((j for j in range(i)) for i in range(2, 5)) + flat_list, rebuilder = get_leaves_and_rebuilder(obj) + assert flat_list == [0, 1, 0, 1, 2, 0, 1, 2, 3] + assert rebuilder(flat_list) == ((0, 1), (0, 1, 2), (0, 1, 2, 3)) + assert rebuilder((f*2 for f in flat_list)) == ((0, 2), (0, 2, 4), (0, 2, 4, 6)) + + flat_list, rebuilder = get_leaves_and_rebuilder({'a': 1, 'b': (2, 3)}) + assert flat_list == [1, 2, 3] + assert rebuilder(a*2 for a in flat_list) == {'a': 2, 'b': (4, 6)} + if __name__ == '__main__': test_flatten_struct() @@ -163,4 +182,5 @@ def test_namedtuple_breakin(): test_nested_map_with_container_func() test_none_bug() test_nested_struct_broadcast() - test_namedtuple_breakin() \ No newline at end of file + test_namedtuple_breakin() + test_flatten_nested_struct_and_rebuild() \ No newline at end of file diff --git a/artemis/general/test_should_be_builtins.py b/artemis/general/test_should_be_builtins.py index e3afbf29..b6f8aa40 100644 --- a/artemis/general/test_should_be_builtins.py +++ b/artemis/general/test_should_be_builtins.py @@ -4,7 +4,7 @@ from artemis.general.should_be_builtins import itermap, reducemap, separate_common_items, remove_duplicates, \ detect_duplicates, remove_common_prefix, all_equal, get_absolute_module, insert_at, get_shifted_key_value, \ - divide_into_subsets + divide_into_subsets, entries_to_table __author__ = 'peter' @@ -32,11 +32,11 @@ def test_separate_common_items(): def test_remove_duplicates(): - assert remove_duplicates(['a', 'b', 'a', 'c', 'c'])==['a', 'b', 'c'] - assert remove_duplicates(['a', 'b', 'a', 'c', 'c'], keep_last=True)==['b', 'a', 'c'] - assert remove_duplicates(['Alfred', 'Bob', 'Cindy', 'Alina', 'Karol', 'Betty'], key=lambda x: x[0])==['Alfred', 'Bob', 'Cindy', 'Karol'] - assert remove_duplicates(['Alfred', 'Bob', 'Cindy', 'Alina', 'Karol', 'Betty'], key=lambda x: x[0], keep_last=True)==['Cindy', 'Alina', 'Karol', 'Betty'] - assert remove_duplicates(['Alfred', 'Bob', 'Cindy', 'Alina', 'Karol', 'Betty'], key=lambda x: x[0], keep_last=True, hashable=False)==['Cindy', 'Alina', 'Karol', 'Betty'] + assert list(remove_duplicates(['a', 'b', 'a', 'c', 'c']))==['a', 'b', 'c'] + assert list(remove_duplicates(['a', 'b', 'a', 'c', 'c'], keep_last=True))==['b', 'a', 'c'] + assert list(remove_duplicates(['Alfred', 'Bob', 'Cindy', 'Alina', 'Karol', 'Betty'], key=lambda x: x[0]))==['Alfred', 'Bob', 'Cindy', 'Karol'] + assert list(remove_duplicates(['Alfred', 'Bob', 'Cindy', 'Alina', 'Karol', 'Betty'], key=lambda x: x[0], keep_last=True))==['Cindy', 'Alina', 'Karol', 'Betty'] + assert list(remove_duplicates(['Alfred', 'Bob', 'Cindy', 'Alina', 'Karol', 'Betty'], key=lambda x: x[0], keep_last=True, hashable=False))==['Cindy', 'Alina', 'Karol', 'Betty'] def test_detect_duplicates(): @@ -117,6 +117,11 @@ def test_divide_into_subsets(): assert divide_into_subsets(range(9), subset_size=3) == [[0, 1, 2], [3, 4, 5], [6, 7, 8]] +def test_entries_to_table(): + + assert entries_to_table([[('a', 1), ('b', 2)], [('a', 3), ('b', 4), ('c', 5)]]) == (['a', 'b', 'c'], [[1, 2, None], [3, 4, 5]]) + + if __name__ == '__main__': test_separate_common_items() test_reducemap() @@ -128,4 +133,5 @@ def test_divide_into_subsets(): test_get_absolute_module() test_insert_at() test_get_shifted_key_value() - test_divide_into_subsets() \ No newline at end of file + test_divide_into_subsets() + test_entries_to_table() diff --git a/artemis/general/test_time_parser.py b/artemis/general/test_time_parser.py new file mode 100644 index 00000000..f11a05b4 --- /dev/null +++ b/artemis/general/test_time_parser.py @@ -0,0 +1,26 @@ +from datetime import timedelta + +from pytest import raises + +from artemis.general.time_parser import parse_time + + +def test_time_parser(): + + assert parse_time('8h') == timedelta(hours=8) + assert parse_time('3d8h') == timedelta(days=3, hours=8) + assert parse_time('5s') == timedelta(seconds=5) + assert parse_time('.25s') == timedelta(seconds=0.25) + assert parse_time('.25d4h') == timedelta(days=0.25, hours=4) + with raises(ValueError): + assert parse_time('0.0.25d4h') == timedelta(days=0.25, hours=4) + with raises(AssertionError): + assert parse_time('5hr') + with raises(AssertionError): + assert parse_time('5q') + with raises(AssertionError): + print(parse_time('5h4q')) + + +if __name__ == '__main__': + test_time_parser() diff --git a/artemis/general/time_parser.py b/artemis/general/time_parser.py index ae97a97a..ae94effa 100644 --- a/artemis/general/time_parser.py +++ b/artemis/general/time_parser.py @@ -4,24 +4,19 @@ from datetime import timedelta -regex = re.compile(r'((?P\d+?)hr)?((?P\d+?)m)?((?P\d+?)s)?') +regex = re.compile(r'^((?P[\.\d]+?)d)?((?P[\.\d]+?)h)?((?P[\.\d]+?)m)?((?P[\.\d]+?)s)?$') def parse_time(time_str): """ - Parse a time string e.g. (13m) into a timedelta object. + Parse a time string e.g. (2h13m) into a timedelta object. - Taken from virhilo at https://stackoverflow.com/a/4628148/851699 + Modified from virhilo's answer at https://stackoverflow.com/a/4628148/851699 :param time_str: A string identifying a duration. (eg. 2h13m) :return datetime.timedelta: A datetime.timedelta object """ parts = regex.match(time_str) - if not parts: - return - parts = parts.groupdict() - time_params = {} - for (name, param) in parts.items(): - if param: - time_params[name] = int(param) + assert parts is not None, "Could not parse any time information from '{}'. Examples of valid strings: '8h', '2d8h5m20s', '2m4s'".format(time_str) + time_params = {name: float(param) for name, param in parts.groupdict().items() if param} return timedelta(**time_params) diff --git a/artemis/plotting/data_conversion.py b/artemis/plotting/data_conversion.py index 404d2674..6f2922ba 100644 --- a/artemis/plotting/data_conversion.py +++ b/artemis/plotting/data_conversion.py @@ -276,3 +276,43 @@ def insert_data(self, data): def retrieve_data(self): return self._buffer[:self._index] + + +class ResamplingRecordBuffer(DataBuffer): + """ + Keeps a buffer of incoming data. When this data reaches the buffer size, it is culled (one of every cull_factor + samples is kept and the rest thrown away). Not that this will throw away some data. + """ + # TODO: Add option for averaging, instead of throwing away culled samples. + + def __init__(self, buffer_len, cull_factor=2): + self._buffer = None + self._buffer_len = buffer_len + self._index = 0 + self._cull_factor = cull_factor + self._sample_times = np.arange(buffer_len) + self._count = 0 + self._n_culls = 0 + + def insert_data(self, data): + + if self._count % (self._n_culls+1) == 0: + + if self._buffer is None: + shape = () if np.isscalar(data) else data.shape + dtype = data.dtype if isinstance(data, np.ndarray) else type(data) if isinstance(data, (int, float, bool)) else object + self._buffer = np.empty((self._buffer_len, )+shape, dtype = dtype) + + if self._index==self._buffer_len: + self._buffer[:int(np.ceil(self._buffer_len/float(self._cull_factor)))] = self._buffer[::self._cull_factor].copy() + self._sample_times = self._sample_times*self._cull_factor + self._index //= self._cull_factor + self._n_culls += 1 + + self._buffer[self._index] = data + self._index += 1 + + self._count+=1 + + def retrieve_data(self): + return self._sample_times[:self._index], self._buffer[:self._index] diff --git a/artemis/plotting/db_plotting.py b/artemis/plotting/db_plotting.py index 5938e84a..9bce645c 100644 --- a/artemis/plotting/db_plotting.py +++ b/artemis/plotting/db_plotting.py @@ -5,7 +5,7 @@ from artemis.config import get_artemis_config_value from artemis.general.checkpoint_counter import Checkpoints -from artemis.plotting.matplotlib_backend import BarPlot, BoundingBoxPlot +from artemis.plotting.matplotlib_backend import BarPlot, BoundingBoxPlot, ResamplingLineHistory from matplotlib.axes import Axes from matplotlib.gridspec import SubplotSpec from contextlib import contextmanager @@ -48,13 +48,16 @@ def dbplot(data, name = None, plot_type = None, axis=None, plot_mode = 'live', d :param data: Any data. Hopefully, we at dbplot will be able to figure out a plot for it. :param name: A name uniquely identifying this plot. - :param plot_type: A specialized constructor to be used the first time when plotting. You can also pass - certain string to give hints as to what kind of plot you want (can resolve cases where the given data could be - plotted in multiple ways): - 'line': Plots a line plot - 'img': An image plot - 'colour': A colour image plot - 'pic': A picture (no scale bars, axis labels, etc). + :param Union[Callable[[],LinePlot],str,Tuple[Callable, Dict]] plot_type : A specialized constructor to be used the + first time when plotting. Several predefined constructors are defined in the DBPlotTypes class - you can pass + those. For back-compatibility you can also pass a string matching the name of one of the fields in the DBPlotTypes + class. + DBPlotTypes.LINE: Plots a line plot + DBPlotTypes.IMG: An image plot + DBPlotTypes.COLOUR: A colour image plot + DBPlotTypes.PIC: A picture (no scale bars, axis labels, etc) + You can also, pass a tuple of (constructor, keyword_args) where keyword args is a dict of arcuments to the plot + constructor. :param axis: A string identifying which axis to plot on. By default, it is the same as "name". Only use this argument if you indend to make multiple dbplots share the same axis. :param plot_mode: Influences how the data should be used to choose the plot type: @@ -95,11 +98,16 @@ def dbplot(data, name = None, plot_type = None, axis=None, plot_mode = 'live', d if name not in suplot_dict: # Initialize new axis if isinstance(plot_type, str): - plot = PLOT_CONSTRUCTORS[plot_type]() + plot = DBPlotTypes.from_string(plot_type)() elif isinstance(plot_type, tuple): assert len(plot_type)==2 and isinstance(plot_type[0], str) and isinstance(plot_type[1], dict), 'If you specify a tuple for plot_type, we expect (name, arg_dict). Got: {}'.format(plot_type) plot_type_name, plot_type_args = plot_type - plot = PLOT_CONSTRUCTORS[plot_type_name](**plot_type_args) + if isinstance(plot_type_name, str): + plot = DBPlotTypes.from_string(plot_type_name)(**plot_type_args) + elif callable(plot_type_name): + plot = plot_type_name(**plot_type_args) + else: + raise Exception('The first argument of the plot type tuple must be a plot type name or a callable plot type constructor.') elif plot_type is None: plot = get_plot_from_data(data, mode=plot_mode) else: @@ -154,11 +162,8 @@ def dbplot(data, name = None, plot_type = None, axis=None, plot_mode = 'live', d if draw_now and not _hold_plots and (draw_every is None or ((fig, name) not in _draw_counters) or _draw_counters[fig, name]()): plot.plot() - if hang: - plt.figure(_DBPLOT_FIGURES[fig].figure.number) - plt.show() - else: - redraw_figure(_DBPLOT_FIGURES[fig].figure) + display_figure(_DBPLOT_FIGURES[fig].figure, hang=hang) + return _DBPLOT_FIGURES[fig].subplots[name].axis @@ -179,32 +184,36 @@ def dbplot(data, name = None, plot_type = None, axis=None, plot_mode = 'live', d _default_layout = 'grid' -PLOT_CONSTRUCTORS = { - 'line': LinePlot, - 'thick-line': partial(LinePlot, plot_kwargs={'linewidth': 3}), - 'pos_line': partial(LinePlot, y_bounds=(0, None), y_bound_extend=(0, 0.05)), - 'bbox': partial(BoundingBoxPlot, linewidth=2, axes_update_mode='expand'), - 'bbox_r': partial(BoundingBoxPlot, linewidth=2, color='r', axes_update_mode='expand'), - 'bbox_b': partial(BoundingBoxPlot, linewidth=2, color='b', axes_update_mode='expand'), - 'bbox_g': partial(BoundingBoxPlot, linewidth=2, color='g', axes_update_mode='expand'), - 'bar': BarPlot, - 'img': ImagePlot, - 'cimg': partial(ImagePlot, channel_first=True), - 'line_history': MovingPointPlot, - 'img_stable': partial(ImagePlot, only_grow_clims=True), - 'colour': partial(ImagePlot, is_colour_data=True), - 'equal_aspect': partial(ImagePlot, aspect='equal'), - 'image_history': MovingImagePlot, - 'fixed_line_history': partial(MovingPointPlot, buffer_len=100), - 'pic': partial(ImagePlot, show_clims=False, aspect='equal'), - 'notice': partial(TextPlot, max_history=1, horizontal_alignment='center', vertical_alignment='center', size='x-large'), - 'cost': partial(MovingPointPlot, y_bounds=(0, None), y_bound_extend=(0, 0.05)), - 'percent': partial(MovingPointPlot, y_bounds=(0, 100)), - 'trajectory': partial(Moving2DPointPlot, axes_update_mode='expand'), - 'trajectory+': partial(Moving2DPointPlot, axes_update_mode='expand', x_bounds=(0, None), y_bounds=(0, None)), - 'histogram': partial(HistogramPlot, edges = np.linspace(-5, 5, 20)), - 'cumhist': partial(CumulativeLineHistogram, edges = np.linspace(-5, 5, 20)), - } +class DBPlotTypes: + LINE= LinePlot + THICK_LINE= partial(LinePlot, plot_kwargs={'linewidth': 3}) + POS_LINE= partial(LinePlot, y_bounds=(0, None), y_bound_extend=(0, 0.05)) + BBOX= partial(BoundingBoxPlot, linewidth=2, axes_update_mode='expand') + BBOX_R= partial(BoundingBoxPlot, linewidth=2, color='r', axes_update_mode='expand') + BBOX_B= partial(BoundingBoxPlot, linewidth=2, color='b', axes_update_mode='expand') + BBOX_G= partial(BoundingBoxPlot, linewidth=2, color='g', axes_update_mode='expand') + BAR= BarPlot + IMG= ImagePlot + CIMG= partial(ImagePlot, channel_first=True) + LINE_HISTORY= MovingPointPlot + IMG_STABLE= partial(ImagePlot, only_grow_clims=True) + COLOUR= partial(ImagePlot, is_colour_data=True) + EQUAL_ASPECT= partial(ImagePlot, aspect='equal') + IMAGE_HISTORY= MovingImagePlot + FIXED_LINE_HISTORY= partial(MovingPointPlot, buffer_len=100) + LINE_HISTORY_RESAMPLED= partial(ResamplingLineHistory, buffer_len=400) + PIC= partial(ImagePlot, show_clims=False, aspect='equal') + NOTICE= partial(TextPlot, max_history=1, horizontal_alignment='center', vertical_alignment='center', size='x-large') + COST= partial(MovingPointPlot, y_bounds=(0, None), y_bound_extend=(0, 0.05)) + PERCENT= partial(MovingPointPlot, y_bounds=(0, 100)) + TRAJECTORY= partial(Moving2DPointPlot, axes_update_mode='expand') + TRAJECTORY_PLUS= partial(Moving2DPointPlot, axes_update_mode='expand', x_bounds=(0, None), y_bounds=(0, None)) + HISTOGRAM= partial(HistogramPlot, edges = np.linspace(-5, 5, 20)) + CUMHIST= partial(CumulativeLineHistogram, edges = np.linspace(-5, 5, 20)) + + @classmethod + def from_string(cls, str): # For back-compatibility + return getattr(cls, str.upper().replace('-', '_').replace('+', '_PLUS')) def reset_dbplot(): @@ -253,17 +262,30 @@ def freeze_all_dbplots(fig = None): freeze_dbplot(name, fig=fig) -def replot_and_redraw_figure(fig): +def replot_and_redraw_figure(fig, hang): for subplot in _DBPLOT_FIGURES[fig].subplots.values(): plt.subplot(subplot.axis) subplot.plot_object.plot() - redraw_figure(_DBPLOT_FIGURES[fig].figure) + display_figure(_DBPLOT_FIGURES[fig].figure, hang) + + +def display_figure(fig, hang): + if hang is True: + plt.figure(fig.number) + plt.show() + elif hang in (None, False): + redraw_figure(fig) + elif isinstance(hang, (int, float)): + redraw_figure(fig) + plt.pause(hang) + else: + raise TypeError("Can't interpret hang argument {}".format(hang)) @contextmanager -def hold_dbplots(fig = None, draw_every = None): +def hold_dbplots(fig = None, hang=False, draw_every = None): """ Use this in a "with" statement to prevent plotting until the end. :param fig: @@ -291,7 +313,7 @@ def hold_dbplots(fig = None, draw_every = None): plot_now = True if plot_now and fig in _DBPLOT_FIGURES: - replot_and_redraw_figure(fig) + replot_and_redraw_figure(fig, hang = hang) def clear_dbplot(fig = None): @@ -309,11 +331,15 @@ def get_dbplot_axis(axis_name, fig=None): return _DBPLOT_FIGURES[fig].axes[axis_name] -def dbplot_hang(): - plt.show() +def dbplot_hang(timeout=None): + if timeout is None: + plt.show() + else: + redraw_figure() + plt.pause(timeout) -def dbplot_collection(collection, name, axis = None, draw_every=None, **kwargs): +def dbplot_collection(collection, name, hang=False, axis = None, draw_every=None, **kwargs): """ Plot a collection of items in one go. :param collection: @@ -321,7 +347,7 @@ def dbplot_collection(collection, name, axis = None, draw_every=None, **kwargs): :param kwargs: :return: """ - with hold_dbplots(draw_every=draw_every): + with hold_dbplots(draw_every=draw_every, hang=hang): if isinstance(collection, (list, tuple)): for i, el in enumerate(collection): dbplot(el, '{}[{}]'.format(name, i), axis='{}[{}]'.format(axis, i) if axis is not None else None, **kwargs) diff --git a/artemis/plotting/expanding_subplots.py b/artemis/plotting/expanding_subplots.py index 24a5dd4d..48c92a26 100644 --- a/artemis/plotting/expanding_subplots.py +++ b/artemis/plotting/expanding_subplots.py @@ -264,7 +264,7 @@ def vstack_plots(spacing=0, sharex=True, sharey = False, show_x = 'once', show_y new_subplots[-1].tick_params(axis='x', labelbottom='on') if xlabel is not None: - new_subplots[-1].set_xlabel(xlabel) + new_subplots[-1].set_xlabcel(xlabel) if remove_ticks: new_subplots[-1].get_xaxis().set_visible(True) diff --git a/artemis/plotting/matplotlib_backend.py b/artemis/plotting/matplotlib_backend.py index d9ddff34..da156fb6 100644 --- a/artemis/plotting/matplotlib_backend.py +++ b/artemis/plotting/matplotlib_backend.py @@ -7,8 +7,9 @@ from artemis.config import get_artemis_config_value from artemis.general.should_be_builtins import bad_value -from artemis.plotting.data_conversion import (put_data_in_grid, RecordBuffer, data_to_image, put_list_of_images_in_array, - UnlimitedRecordBuffer) +from artemis.plotting.data_conversion import (put_data_in_grid, RecordBuffer, data_to_image, + put_list_of_images_in_array, + UnlimitedRecordBuffer, ResamplingRecordBuffer) from matplotlib import pyplot as plt import numpy as np from six.moves import xrange @@ -368,6 +369,21 @@ def plot(self): LinePlot.plot(self) +class ResamplingLineHistory(LinePlot): + + def __init__(self, buffer_len, cull_factor=2, **kwargs): + LinePlot.__init__(self, **kwargs) + self._buffer = ResamplingRecordBuffer(buffer_len=buffer_len, cull_factor=cull_factor) + + def update(self, data): + self._buffer.insert_data(data) + + def plot(self): + x_data, y_data = self._buffer.retrieve_data() + LinePlot.update(self, (x_data, y_data)) + LinePlot.plot(self) + + class Moving2DPointPlot(LinePlot): def __init__(self, buffer_len=None, **kwargs): diff --git a/artemis/plotting/pyplot_plus.py b/artemis/plotting/pyplot_plus.py index ea7f1cad..46ccef14 100644 --- a/artemis/plotting/pyplot_plus.py +++ b/artemis/plotting/pyplot_plus.py @@ -153,7 +153,7 @@ def set_default_figure_size(width, height): def get_lines_color_cycle(): - return _lines_colour_cycle + return (p['color'] for p in plt.rcParams['axes.prop_cycle']) def get_color_cycle_map(name, length): @@ -169,7 +169,7 @@ def set_lines_color_cycle_map(name, length): def get_line_color(ix, modifier=None): - colour = _lines_colour_cycle[ix] + colour = next(c for i, c in enumerate(get_lines_color_cycle()) if i==ix) if modifier=='dark': return tuple(c/2 for c in colors.hex2color(colour)) elif modifier=='light': diff --git a/artemis/plotting/test_db_plotting.py b/artemis/plotting/test_db_plotting.py index 15388fc8..8bdd566c 100644 --- a/artemis/plotting/test_db_plotting.py +++ b/artemis/plotting/test_db_plotting.py @@ -5,7 +5,8 @@ from artemis.plotting.demo_dbplot import demo_dbplot from artemis.plotting.db_plotting import dbplot, clear_dbplot, hold_dbplots, freeze_all_dbplots, reset_dbplot, \ dbplot_hang -from artemis.plotting.matplotlib_backend import LinePlot, HistogramPlot, MovingPointPlot, is_server_plotting_on +from artemis.plotting.matplotlib_backend import LinePlot, HistogramPlot, MovingPointPlot, is_server_plotting_on, \ + ResamplingLineHistory import pytest from matplotlib import pyplot as plt from matplotlib import gridspec @@ -69,9 +70,14 @@ def test_history_plot_updating(): def test_moving_point_multiple_points(): reset_dbplot() - for i in xrange(5): - dbplot(np.sin([i/10., i/15.]), 'unlim buffer', plot_type = partial(MovingPointPlot)) - dbplot(np.sin([i/10., i/15.]), 'lim buffer', plot_type = partial(MovingPointPlot,buffer_len=20)) + p1 = 5. + p2 = 8. + for i in xrange(50): + with hold_dbplots(draw_every=5): + dbplot(np.sin([i/p1, i/p2]), 'unlim buffer', plot_type = partial(MovingPointPlot)) + dbplot(np.sin([i/p1, i/p2]), 'lim buffer', plot_type = partial(MovingPointPlot,buffer_len=20)) + dbplot(np.sin([i/p1, i/p2]), 'resampling buffer', plot_type = partial(ResamplingLineHistory, buffer_len=20)) # Only looks bad because of really small buffer length from testing. + def test_same_object(): """ From ef24bb32fe5d5cec6db93e6cd0670cd4c720eb7e Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Sat, 22 Sep 2018 16:24:11 +0200 Subject: [PATCH 006/107] ok these are kind of working --- artemis/experiments/experiment_record_view.py | 24 +++++++++++++++++++ artemis/experiments/ui.py | 7 +++--- artemis/plotting/db_plotting.py | 3 +++ artemis/plotting/expanding_subplots.py | 4 ++-- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/artemis/experiments/experiment_record_view.py b/artemis/experiments/experiment_record_view.py index ce0eedc7..b1ed0e98 100644 --- a/artemis/experiments/experiment_record_view.py +++ b/artemis/experiments/experiment_record_view.py @@ -425,3 +425,27 @@ def separate_common_args(records, as_dicts=False, return_dict = False, only_shar if return_dict: argdiff = {rec.get_id(): args for rec, args in zip(records, argdiff)} return common, argdiff + + +def compare_timeseries_records(records, yfield, xfield = None): + """ + :param Sequence[ExperimentRecord] records: A list of records containing results of the form + Sequence[Dict[str, number]] + :param yfield: The name of the fields for the x-axis + :param xfield: The name of the field for the y-axis + """ + from matplotlib import pyplot as plt + results = [rec.get_result() for rec in records] + all_different_args, values = get_different_args([r.get_args() for r in records]) + + ax = plt.figure().add_subplot(1, 1, 1) + for result, argvals in izip_equal(results, values): + xvals = [r[xfield] for r in result] if xfield is not None else list(range(len(result))) + yvals = [r[yfield] for r in result] + ax.plot(xvals, yvals, label=', '.join(f'{argname}={argval}' for argname, argval in izip_equal(all_different_args, argvals))) + ax.grid(True) + if xfield is not None: + ax.set_xlabel(xfield) + ax.set_ylabel(yfield) + plt.legend() + plt.show() diff --git a/artemis/experiments/ui.py b/artemis/experiments/ui.py index d07302dc..8c0c5a0a 100644 --- a/artemis/experiments/ui.py +++ b/artemis/experiments/ui.py @@ -110,6 +110,7 @@ class ExperimentBrowser(object): > showarchived Toggle display of archived results. > view results View just the columns for experiment name and result > view full View all columns (the default view) +> kill 4.1,4.5 Kill the selected currently running records (you'll be prompted for confirmation) > show 4 Show the output from the last run of experiment 4 (if it has been run already). > show 4-6 Show the output of experiments 4,5,6 together. > records Browse through all experiment records. @@ -517,12 +518,12 @@ def argsort(self, *args): # First verify that all args are included... all_arg_names = set(a for exp_name in self.exp_record_dict.keys() for a, v in load_experiment(exp_name).get_args().items()) if any(a not in all_arg_names for a in args): - raise RecordSelectionError('Arg(s) [{}] were not included in any experiments') + raise RecordSelectionError('Arg(s) {} were not included in any experiments. Possible names: {}'.format(list(a for a in args if a not in all_arg_names), all_arg_names)) # Define a comparison function that will always compare. def key_sorting_function(exp_name): exp_args = load_experiment(exp_name).get_args() - return tuple(() if name not in exp_args else (None, exp_args[name]) if isinstance(exp_args[name], (int, float)) else (str(type(exp_args[name])), exp_args[name]) for name in args) + return tuple(() if name not in exp_args else ('!', float(exp_args[name])) if isinstance(exp_args[name], (int, float)) else (str(type(exp_args[name])), exp_args[name]) for name in args) self._sortkey = key_sorting_function return ExperimentBrowser.REFRESH @@ -691,7 +692,7 @@ def pull(self, *args): def kill(self, *args): parser = argparse.ArgumentParser() - parser.add_argument('user_range', action='store', help='A selection of experiments whose records to pull. Examples: "3" or "3-5", or "3,4,5"') + parser.add_argument('user_range', action='store', help='A selection of experiments whose records to kill. Examples: "3.2" or "3-5", or "3,4,5"') parser.add_argument('-s', '--skip', action='store_true', default=True, help='Skip the check that all selected records are currently running (just filter running ones)') args = parser.parse_args(args) diff --git a/artemis/plotting/db_plotting.py b/artemis/plotting/db_plotting.py index 9bce645c..52a5fa9e 100644 --- a/artemis/plotting/db_plotting.py +++ b/artemis/plotting/db_plotting.py @@ -81,6 +81,9 @@ def dbplot(data, name = None, plot_type = None, axis=None, plot_mode = 'live', d dbplot_remotely(arg_locals=arg_locals) return + if data.__class__.__module__ == 'torch' and data.__class__.__name__ == 'Tensor': + data = data.detach().cpu().numpy() + if isinstance(fig, plt.Figure): assert None not in _DBPLOT_FIGURES, "If you pass a figure, you can only do it on the first call to dbplot (for now)" _DBPLOT_FIGURES[None] = _PlotWindow(figure=fig, subplots=OrderedDict(), axes={}) diff --git a/artemis/plotting/expanding_subplots.py b/artemis/plotting/expanding_subplots.py index 48c92a26..d7c7afed 100644 --- a/artemis/plotting/expanding_subplots.py +++ b/artemis/plotting/expanding_subplots.py @@ -164,14 +164,14 @@ def add_subplot(layout = None, fig = None, **subplot_args): return select_subplot(name=None, fig=fig, layout=layout, **subplot_args) -def subplot_at(row, col, fig=None): +def subplot_at(row, col, fig=None, **subplot_args): """ Create or select a the subplot at position (row, col) :param row: The row :param col: The column :return: An axes object """ - return select_subplot(position=(row, col), fig=None) + return select_subplot(position=(row, col), fig=None, **subplot_args) @contextmanager From e11bceb2a25ac3492475921433d3e2960017e1d0 Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Fri, 19 Oct 2018 11:59:08 +0200 Subject: [PATCH 007/107] stuuuuuf --- artemis/experiments/decorators.py | 11 ++- artemis/experiments/experiment_management.py | 11 ++- artemis/experiments/experiment_record.py | 6 +- artemis/experiments/experiment_record_view.py | 43 +++++++++--- artemis/experiments/experiments.py | 70 +++++++++++++++++++ artemis/experiments/hyperparameter_search.py | 38 ++++++++++ artemis/experiments/test_experiments.py | 23 ++++++ artemis/experiments/ui.py | 28 +++++++- artemis/general/checkpoint_counter.py | 14 ++-- artemis/general/iteratorize.py | 48 +++++++++++++ artemis/general/measuring_periods.py | 21 ++++++ artemis/general/progress_indicator.py | 31 ++++++-- artemis/general/should_be_builtins.py | 6 +- artemis/general/speedometer.py | 1 + artemis/general/test_progress_inidicator.py | 21 ++++++ artemis/ml/parameter_schedule.py | 2 +- artemis/plotting/data_conversion.py | 4 +- artemis/plotting/db_plotting.py | 4 +- artemis/plotting/expanding_subplots.py | 2 +- docs/source/plotting.rst | 4 +- 20 files changed, 354 insertions(+), 34 deletions(-) create mode 100644 artemis/experiments/hyperparameter_search.py create mode 100644 artemis/general/iteratorize.py create mode 100644 artemis/general/measuring_periods.py create mode 100644 artemis/general/test_progress_inidicator.py diff --git a/artemis/experiments/decorators.py b/artemis/experiments/decorators.py index 7c2bbaa5..413432e9 100644 --- a/artemis/experiments/decorators.py +++ b/artemis/experiments/decorators.py @@ -44,7 +44,7 @@ class ExperimentFunction(object): This is the most general decorator. You can use this to add details on the experiment. """ - def __init__(self, show = None, compare = compare_experiment_records, display_function=None, comparison_function=None, one_liner_function=None, result_parser = None, is_root=False): + def __init__(self, show = None, compare = compare_experiment_records, display_function=None, comparison_function=None, one_liner_function=None, result_parser = None, is_root=False, name=None): """ :param show: A function that is called when you "show" an experiment record in the UI. It takes an experiment record as an argument. @@ -55,6 +55,7 @@ def __init__(self, show = None, compare = compare_experiment_records, display_fu You can use call this via the UI with the compare_experiment_results command. :param one_liner_function: A function that takes your results and returns a 1 line string summarizing them. :param is_root: True to make this a root experiment - so that it is not listed to be run itself. + :param name: Custom name (if None, experiment will be named after decorated function) """ self.show = show self.compare = compare @@ -75,11 +76,17 @@ def compare(records): self.is_root = is_root self.one_liner_function = one_liner_function self.result_parser = result_parser + self.name = name def __call__(self, f): + """ + :param Callable f: The function you decorated + :return Experiment: An Experiment object (It still behaves as the original function when you call it, but now + has additional methods attached to it associated with the experiment). + """ f.is_base_experiment = True ex = Experiment( - name=f.__name__, + name=f.__name__ if self.name is None else self.name, function=f, show=self.show, compare = self.compare, diff --git a/artemis/experiments/experiment_management.py b/artemis/experiments/experiment_management.py index 46786b32..12ac236e 100644 --- a/artemis/experiments/experiment_management.py +++ b/artemis/experiments/experiment_management.py @@ -126,7 +126,11 @@ def select_experiments(user_range, exp_record_dict, return_dict=False): def _filter_experiments(user_range, exp_record_dict, return_is_in = False): - if user_range.startswith('~'): + if '|' in user_range: + is_in = [any(xs) for xs in zip(*(_filter_experiments(subrange, exp_record_dict, return_is_in=True) for subrange in user_range.split('|')))] + elif '&' in user_range: + is_in = [all(xs) for xs in zip(*(_filter_experiments(subrange, exp_record_dict, return_is_in=True) for subrange in user_range.split('&')))] + elif user_range.startswith('~'): is_in = _filter_experiments(user_range=user_range[1:], exp_record_dict=exp_record_dict, return_is_in=True) is_in = [not r for r in is_in] else: @@ -141,6 +145,9 @@ def _filter_experiments(user_range, exp_record_dict, return_is_in = False): elif user_range.startswith('has:'): phrase = user_range[len('has:'):] is_in = [phrase in exp_id for exp_id in exp_record_dict] + elif user_range.startswith('tag:'): + tag = user_range[len('tag:'):] + is_in = [tag in load_experiment(exp_id).get_tags() for exp_id in exp_record_dict] elif user_range.startswith('1diff:'): base_range = user_range[len('1diff:'):] base_range_exps = select_experiments(base_range, exp_record_dict) # list @@ -308,7 +315,7 @@ def _filter_records(user_range, exp_record_dict): try: sign = user_range[3] assert sign in ('<', '>') - filter_func = (lambda a, b: ab) + filter_func = (lambda a, b: (a is not None and b is not None) and ab) time_delta = parse_time(user_range[4:]) except: if user_range.startswith('dur'): diff --git a/artemis/experiments/experiment_record.py b/artemis/experiments/experiment_record.py index 5e67d99f..6fd30e83 100644 --- a/artemis/experiments/experiment_record.py +++ b/artemis/experiments/experiment_record.py @@ -310,7 +310,10 @@ def get_runtime(self): """ :return datetime.timedelta: A timedelta object """ - return timedelta(seconds=self.info.get_field(ExpInfoFields.RUNTIME)) + try: + return timedelta(seconds=self.info.get_field(ExpInfoFields.RUNTIME)) + except KeyError: # Which will happen if the experiment is still running or was killed without due process + return None def get_dir(self): """ @@ -506,6 +509,7 @@ def get_current_record_id(): def get_current_record_dir(default_if_none = True): """ The directory in which the results of the current experiment are recorded. + :param default_if_none: True to put records in the "default" dir if no experiment is running. """ if _CURRENT_EXPERIMENT_RECORD is None and default_if_none: return get_artemis_data_path('experiments/default/', make_local_dir=True) diff --git a/artemis/experiments/experiment_record_view.py b/artemis/experiments/experiment_record_view.py index b1ed0e98..9a408b44 100644 --- a/artemis/experiments/experiment_record_view.py +++ b/artemis/experiments/experiment_record_view.py @@ -1,6 +1,7 @@ import re from collections import OrderedDict +import itertools from six import string_types from tabulate import tabulate import numpy as np @@ -238,12 +239,25 @@ def get_different_args(args, no_arg_filler = 'N/A', arrange_by_deltas=False): return all_different_args, values -def get_exportiment_record_arg_result_table(records): +def get_exportiment_record_arg_result_table(records, result_parser = None, fill_value='N/A', arg_rename_dict = None): + """ + Given a list of ExperimentRecords, make a table containing the arguments that differ between them, and their results. + :param Sequence[ExperimentRecord] records: + :param Optional[Callable] result_parser: Takes the result and returns either: + - a List[Tuple[str, Any]], containing the (name, value) pairs of results which will form the rightmost columns of the table + - Anything else, in which case the header of the last column is taken to be "Result" and the value is put in the table + :param fill_value: Value to fill in when the experiment does not have a particular argument. + :return Tuple[List[str], List[List[Any]]]: headers, results + """ + if arg_rename_dict is not None: + arg_processor = lambda args: OrderedDict((arg_rename_dict[name] if name in arg_rename_dict else name, val) for name, val in args.items() if name not in arg_rename_dict or arg_rename_dict[name] is not None) + else: + arg_processor = lambda args: args record_ids = [record.get_id() for record in records] - all_different_args, arg_values = get_different_args([r.get_args() for r in records], no_arg_filler='N/A') + all_different_args, arg_values = get_different_args([arg_processor(r.get_args()) for r in records], no_arg_filler=fill_value) - parsed_results = [record.get_experiment().result_parser(record.get_result()) for record in records] - result_fields, result_data = entries_to_table(parsed_results) + parsed_results = [(result_parser or record.get_experiment().result_parser)(record.get_result()) if record.has_result() else [('Result', 'N/A')] for record in records] + result_fields, result_data = entries_to_table(parsed_results, fill_value = fill_value) result_fields = [get_unique_name(rf, all_different_args) for rf in result_fields] # Just avoid name collisions # result_column_name = get_unique_name('Results', taken_names=all_different_args) @@ -298,6 +312,7 @@ def show_multiple_records(records, func = None): from artemis.plotting.manage_plotting import delay_show with delay_show(): for rec in records: + func(rec) else: for rec in records: @@ -427,7 +442,7 @@ def separate_common_args(records, as_dicts=False, return_dict = False, only_shar return common, argdiff -def compare_timeseries_records(records, yfield, xfield = None): +def compare_timeseries_records(records, yfield, xfield = None, hang=True, ax=None): """ :param Sequence[ExperimentRecord] records: A list of records containing results of the form Sequence[Dict[str, number]] @@ -438,14 +453,22 @@ def compare_timeseries_records(records, yfield, xfield = None): results = [rec.get_result() for rec in records] all_different_args, values = get_different_args([r.get_args() for r in records]) - ax = plt.figure().add_subplot(1, 1, 1) + if not isinstance(yfield, (list, tuple)): + yfield = [yfield] + + ax = ax if ax is not None else plt.figure().add_subplot(1, 1, 1) for result, argvals in izip_equal(results, values): xvals = [r[xfield] for r in result] if xfield is not None else list(range(len(result))) - yvals = [r[yfield] for r in result] - ax.plot(xvals, yvals, label=', '.join(f'{argname}={argval}' for argname, argval in izip_equal(all_different_args, argvals))) + # yvals = [r[yfield[0]] for r in result] + h, = ax.plot(xvals, [r[yfield[0]] for r in result], label=(yfield[0]+': ' if len(yfield)>1 else '')+', '.join(f'{argname}={argval}' for argname, argval in izip_equal(all_different_args, argvals))) + for yf, linestyle in zip(yfield[1:], itertools.cycle(['--', ':', '-.'])): + ax.plot(xvals, [r[yf] for r in result], linestyle=linestyle, color=h.get_color(), label=yf+': '+', '.join(f'{argname}={argval}' for argname, argval in izip_equal(all_different_args, argvals))) + ax.grid(True) if xfield is not None: ax.set_xlabel(xfield) - ax.set_ylabel(yfield) + if len(yfield)==1: + ax.set_ylabel(yfield[0]) plt.legend() - plt.show() + if hang: + plt.show() diff --git a/artemis/experiments/experiments.py b/artemis/experiments/experiments.py index f9776356..b6862744 100644 --- a/artemis/experiments/experiments.py +++ b/artemis/experiments/experiments.py @@ -4,14 +4,18 @@ from contextlib import contextmanager from functools import partial from six import string_types + from artemis.experiments.experiment_record import ExpStatusOptions, experiment_id_to_record_ids, load_experiment_record, \ get_all_record_ids, clear_experiment_records from artemis.experiments.experiment_record import run_and_record from artemis.experiments.experiment_record_view import compare_experiment_records, show_record +from artemis.experiments.hyperparameter_search import parameter_search from artemis.general.display import sensible_str from artemis.general.functional import get_partial_root, partial_reparametrization, \ advanced_getargspec, PartialReparametrization +from artemis.general.should_be_builtins import izip_equal +from artemis.general.test_mode import is_test_mode class Experiment(object): @@ -39,6 +43,7 @@ def __init__(self, function=None, show=None, compare=None, one_liner_function=No self.variants = OrderedDict() self._notes = [] self.is_root = is_root + self._tags= set() if not is_root: all_args, varargs_name, kargs_name, defaults = advanced_getargspec(function) @@ -416,6 +421,71 @@ def get_variant_records(self, only_completed=False, only_last=False, flat=False) else: return exp_record_dict + def add_parameter_search(self, name='parameter_search', space = None, n_calls=100, search_params = None, scalar_func=None): + """ + :param name: Name of the Experiment to be created + :param dict[str, skopt.space.Dimension] space: A dict mapping param name to Dimension. + e.g. space=dict(a = Real(1, 100, 'log-uniform'), b = Real(1, 100, 'log-uniform')) + :param Callable[[Any], float] scalar_func: Takes the return value of the experiment and turns it into a scalar + which we aim to minimize. + :param dict[str, Any] search_params: Args passed to parameter_search + :return Experiment: A new experiment which runs the search and yields current-best parameters with every iteration. + """ + assert space is not None, "You must specify a parameter search space. See this method's documentation" + if name is None: # TODO: Set name=None in the default after deadline + name = 'parameter_search[{}]'.format(','.join(space.keys())) + + if search_params is None: + search_params = {} + + def objective(**current_params): + output = self.call(**current_params) + if scalar_func is not None: + output = scalar_func(output) + return output + + from artemis.experiments import ExperimentFunction + + @ExperimentFunction(name = self.name + '.'+ name, show = show_parameter_search_record, one_liner_function=parameter_search_one_liner) + def search_exp(): + if is_test_mode(): + nonlocal n_calls + n_calls = 3 # When just verifying that experiment runs, do the minimum + + for iter_info in parameter_search(objective, n_calls=n_calls, space=space, **search_params): + info = dict(names=list(space.keys()), x_iters =iter_info.x_iters, func_vals=iter_info.func_vals, score = iter_info.func_vals, x=iter_info.x, fun=iter_info.fun) + latest_info = {name: val for name, val in izip_equal(info['names'], iter_info.x_iters[-1])} + print(f'Latest: {latest_info}, Score: {iter_info.func_vals[-1]:.3g}') + yield info + + self.variants[name] = search_exp + search_exp.tag('psearch') # Secret feature that makes it easy to select all parameter experiments in ui with "filter tag:psearch" + return search_exp + + def tag(self, tag): + """ + Add a "tag" - a string identifying the experiment as being in some sort of group. + You can use tags in the UI with 'filter tag:my_tag' to select experiments with a given tag + :param tag: + :return: + """ + self._tags.add(tag) + return self + + def get_tags(self): + return self._tags + + +def show_parameter_search_record(record): + from tabulate import tabulate + result = record.get_result() + table = tabulate([list(xs)+[fun] for xs, fun in zip(result['x_iters'], result['func_vals'])], headers=list(result['names'])+['score']) + print(table) + + +def parameter_search_one_liner(result): + return f'{len(result["x_iters"])} Runs : ' + ', '.join(f'{k}={v:.3g}' for k, v in izip_equal(result['names'], result['x'])) + f' : Score = {result["fun"]:.3g}' + _GLOBAL_EXPERIMENT_LIBRARY = OrderedDict() diff --git a/artemis/experiments/hyperparameter_search.py b/artemis/experiments/hyperparameter_search.py new file mode 100644 index 00000000..2594ca38 --- /dev/null +++ b/artemis/experiments/hyperparameter_search.py @@ -0,0 +1,38 @@ +from skopt import gp_minimize +from skopt.utils import use_named_args +from tabulate import tabulate + +from artemis.general.iteratorize import Iteratorize + + +def parameter_search(objective, space, n_calls, n_random_starts=3, acq_optimizer="auto", n_jobs=4): + """ + :param Callable[[Any], scalar] objective: The objective function that we're trying to optimize + :param dict[str, Dimension] space: + :param n_calls: + :param n_random_starts: + :param acq_optimizer: + :return Generator[{'names': List[str], 'x_iters': List[]: + """ # TODO: Finish building this + + for k, var in space.items(): + var.name=k + space = list(space.values()) + + objective = use_named_args(space)(objective) + + iter = Iteratorize( + func = lambda callback: gp_minimize(objective, + dimensions=space, + n_calls=n_calls, + n_random_starts = n_random_starts, + random_state=1234, + n_jobs=n_jobs, + verbose=False, + callback=callback, + acq_optimizer = acq_optimizer, + ), + ) + + for i, iter_info in enumerate(iter): + yield iter_info diff --git a/artemis/experiments/test_experiments.py b/artemis/experiments/test_experiments.py index 1855f397..6208e28a 100644 --- a/artemis/experiments/test_experiments.py +++ b/artemis/experiments/test_experiments.py @@ -114,8 +114,31 @@ def my_exp(a, b, c): assert XXXX() == 1+(5*5)*5 +def test_parameter_search(): + + from skopt.space import Real + + with experiment_testing_context(new_experiment_lib=True): + + @experiment_root + def bowl(x, y): + return {'z': (x-2)**2 + (y+3)**2} + + ex_search = bowl.add_parameter_search( + space = {'x': Real(-5, 5, 'uniform'), 'y': Real(-5, 5, 'uniform')}, + scalar_func=lambda result: result['z'], + search_params=dict(n_calls=5) + ) + + record = ex_search.run() + result = record.get_result() + assert result['names']==['x', 'y'] + assert result['func_vals'][-1] < result['func_vals'][0] + + if __name__ == '__main__': test_unpicklable_args() test_config_variant() test_config_bug_catching() test_args_are_checked() + test_parameter_search() \ No newline at end of file diff --git a/artemis/experiments/ui.py b/artemis/experiments/ui.py index 8c0c5a0a..55f9335a 100644 --- a/artemis/experiments/ui.py +++ b/artemis/experiments/ui.py @@ -18,6 +18,7 @@ select_experiment_records_from_list, interpret_numbers, run_multiple_experiments) from artemis.experiments.experiment_record import ExpStatusOptions +from artemis.experiments.experiment_record import ExperimentRecord from artemis.experiments.experiment_record import (get_all_record_ids, clear_experiment_records, load_experiment_record, ExpInfoFields) from artemis.experiments.experiment_record_view import (get_record_full_string, get_record_invalid_arg_string, @@ -27,7 +28,8 @@ from artemis.experiments.experiment_record_view import show_record, show_multiple_records from artemis.experiments.experiments import load_experiment, get_nonroot_global_experiment_library from artemis.fileman.local_dir import get_artemis_data_path -from artemis.general.display import IndentPrint, side_by_side, truncate_string, surround_with_header, format_duration, format_time_stamp +from artemis.general.display import IndentPrint, side_by_side, truncate_string, surround_with_header, format_duration, \ + format_time_stamp, section_with_header from artemis.general.hashing import compute_fixed_hash from artemis.general.mymath import levenshtein_distance from artemis.general.should_be_builtins import all_equal, insert_at, izip_equal, separate_common_items, bad_value @@ -278,7 +280,9 @@ def launch(self, command=None): 'q': self.quit, 'records': self.records, 'pull': self.pull, + 'info': self.info, 'clearcache': clear_ui_cache, + 'logs': self.logs, } display_again = True @@ -572,6 +576,28 @@ def show(self, *args): show_multiple_records(records, func) _warn_with_prompt(use_prompt=False) + def info(self, *args): + parser = argparse.ArgumentParser() + parser.add_argument('user_range', action='store', help='A selection of experiment records to show. ') + args = parser.parse_args(args) + user_range = args.user_range + records = select_experiment_records(user_range, self.exp_record_dict, flat=True) + for record in records: + print('='*64) + print(record.info.get_text()) + print('='*64) + + def logs(self, *args): + parser = argparse.ArgumentParser() + parser.add_argument('user_range', action='store', help='A selection of experiment records to show. ') + args = parser.parse_args(args) + user_range = args.user_range + records = select_experiment_records(user_range, self.exp_record_dict, flat=True) # type: list[ExperimentRecord] + for record in records: + print('='*64) + print(record.get_log()) + print('='*64) + def compare(self, *args): parser = argparse.ArgumentParser() parser.add_argument('user_range', action='store', help='A selection of experiment records to compare. Examples: "3" or "3-5", or "3,4,5"') diff --git a/artemis/general/checkpoint_counter.py b/artemis/general/checkpoint_counter.py index c1f94264..4edece2e 100644 --- a/artemis/general/checkpoint_counter.py +++ b/artemis/general/checkpoint_counter.py @@ -89,14 +89,18 @@ def __init__(self, checkpoint_generator, default_units = None, skip_first = Fals elif isinstance(checkpoint_generator, (int, float)): step = checkpoint_generator checkpoint_generator = (step*i for i in itertools.count(0)) + elif checkpoint_generator is None: + checkpoint_generator = (np.inf for _ in itertools.count(0)) else: assert isinstance(checkpoint_generator, types.GeneratorType) - if skip_first: - next(checkpoint_generator) - - self.checkpoint_generator = checkpoint_generator - self._next_checkpoint = float('inf') if checkpoint_generator is None else next(checkpoint_generator) + try: + if skip_first: + next(checkpoint_generator) + self.checkpoint_generator = checkpoint_generator + self._next_checkpoint = next(checkpoint_generator) + except StopIteration: + raise Exception('Your checkpoint generator provided no checkpoints.') self._counter = 0 self._start_time = time.time() diff --git a/artemis/general/iteratorize.py b/artemis/general/iteratorize.py new file mode 100644 index 00000000..f5581887 --- /dev/null +++ b/artemis/general/iteratorize.py @@ -0,0 +1,48 @@ + + +""" +Thanks to Brice for this piece of code. Taken from https://stackoverflow.com/a/9969000/851699 + +""" + +# from thread import start_new_thread +from collections import Iterable +from queue import Queue +from threading import Thread + + +class Iteratorize(Iterable): + """ + Transforms a function that takes a callback + into a lazy iterator (generator). + """ + + def __init__(self, func): + """ + :param Callable[Callable, Any] func: A function that takes a callback as an argument then runs. + """ + self.mfunc = func + # self.ifunc = ifunc + self.q = Queue(maxsize=1) + self.sentinel = object() + + def _callback(val): + self.q.put(val) + + def gentask(): + ret = self.mfunc(_callback) + self.q.put(self.sentinel) + + # start_new_thread(gentask, ()) + Thread(target=gentask).start() + + def __iter__(self): + return self + + def __next__(self): + + obj = self.q.get(True, None) + if obj is self.sentinel: + raise StopIteration + else: + return obj diff --git a/artemis/general/measuring_periods.py b/artemis/general/measuring_periods.py new file mode 100644 index 00000000..2ca59b1d --- /dev/null +++ b/artemis/general/measuring_periods.py @@ -0,0 +1,21 @@ + +import time + +_last_time_dict = {} + + +def measure_period(identifier): + """ + You can call this in a loop to get an easy measure of how much time has elapsed since the last call. + On the first call it will return NaN. + :param Any identifier: + :return float: Elapsed time since last measure + """ + if identifier not in _last_time_dict: + _last_time_dict[identifier] = time.time() + return float('nan') + else: + now = time.time() + elapsed = now - _last_time_dict[identifier] + _last_time_dict[identifier] = now + return elapsed diff --git a/artemis/general/progress_indicator.py b/artemis/general/progress_indicator.py index 42554413..7af0d3b4 100644 --- a/artemis/general/progress_indicator.py +++ b/artemis/general/progress_indicator.py @@ -1,5 +1,7 @@ import time +from decorator import contextmanager + class ProgressIndicator(object): @@ -34,13 +36,14 @@ def __init__(self, expected_iterations=None, name=None, update_every = (2, 'seco self._last_time = self._start_time self._last_progress = 0 self.show_total = show_total + self._pause_time = 0 def __call__(self, iteration = None): self.print_update(iteration) def print_update(self, progress=None, info=None): self._current_time = time.time() - elapsed = self._current_time - self._start_time + elapsed = self._current_time - self._start_time - self._pause_time if self._expected_iterations is None: if self._should_update(): print ('Progress{}: {:.1f}s Elapsed{}{}. {} calls averaging {:.2g} calls/s'.format( @@ -57,12 +60,10 @@ def print_update(self, progress=None, info=None): progress = self._i frac = float(progress)/(self._expected_iterations-1) if self._expected_iterations>1 else 1. if self._should_update() or progress == self._expected_iterations-1: - elapsed = self._current_time - self._start_time if self.just_use_last is True: remaining = (self._current_time - self._last_time)/(frac - self._last_progress) * (1-frac) if frac > 0 else float('NaN') else: remaining = elapsed * (1 / frac - 1) if frac > 0 else float('NaN') - elapsed = self._current_time - self._start_time print('Progress{name}: {progress}%. {elapsed:.1f}s Elapsed, {remaining:.1f}s Remaining{total}. {info_cb}{info}{n_calls} calls averaging {rate:.2g} calls/s'.format( name = '' if self.name is None else ' of '+self.name, progress = int(100*frac), @@ -82,7 +83,7 @@ def print_update(self, progress=None, info=None): self._last_progress = frac def get_elapsed(self): - return time.time() - self._start_time + return time.time() - self._start_time - self._pause_time def get_iterations(self): return self._i @@ -92,3 +93,25 @@ def _should_update_time(self): def _should_update_iter(self): return self._i - self._last_update > self._update_interval + + def pause_measurement(self): + """ + Context manager meaning "don't count this interval". + + Usage: + + n_iter = 100 + pi = ProgressInidicator(n_iter) + for i in range(n_iter): + do_something_worth_counting + with pi.pause_measurement(): + do_something_that_doesnt_count() + pi.print_update() + """ + @contextmanager + def pause_counting(): + start_pause_time = time.time() + yield + self._pause_time += time.time() - start_pause_time + + return pause_counting() diff --git a/artemis/general/should_be_builtins.py b/artemis/general/should_be_builtins.py index b686c790..b2e9894b 100644 --- a/artemis/general/should_be_builtins.py +++ b/artemis/general/should_be_builtins.py @@ -313,7 +313,6 @@ def remove_common_prefix(list_of_lists, max_elements=None, keep_base = True): count = 0 min_len = 1 if keep_base else 0 - while min(len(parts) for parts in list_of_lists)>min_len: if max_elements is not None and count >= max_elements: break @@ -479,3 +478,8 @@ def entries_to_table(tuplelist, fill_value = None): data = [dict(sample) for sample in tuplelist] new_data = [[d[k] if k in d else fill_value for k in all_entries] for d in data] return all_entries, new_data + + +def print_thru(x): + print(x) + return x \ No newline at end of file diff --git a/artemis/general/speedometer.py b/artemis/general/speedometer.py index 4a8ed09d..d3e61983 100644 --- a/artemis/general/speedometer.py +++ b/artemis/general/speedometer.py @@ -20,3 +20,4 @@ def __call__(self, progress=None): self._last_time = this_time return speed + diff --git a/artemis/general/test_progress_inidicator.py b/artemis/general/test_progress_inidicator.py new file mode 100644 index 00000000..e27d5688 --- /dev/null +++ b/artemis/general/test_progress_inidicator.py @@ -0,0 +1,21 @@ +from artemis.general.progress_indicator import ProgressIndicator +import time + +def test_progress_inidicator(): + + n_iter = 100 + + pi = ProgressIndicator(n_iter, update_every='1s') + + start=time.time() + for i in range(n_iter): + time.sleep(0.001) + if i % 10==0: + with pi.pause_measurement(): + time.sleep(0.02) + + assert pi.get_elapsed() < (time.time() - start)/2. + + +if __name__ == '__main__': + test_progress_inidicator() diff --git a/artemis/ml/parameter_schedule.py b/artemis/ml/parameter_schedule.py index f10fd1ae..0fec2b65 100644 --- a/artemis/ml/parameter_schedule.py +++ b/artemis/ml/parameter_schedule.py @@ -7,7 +7,7 @@ def __init__(self, schedule, print_variable_name = None): """ Given a schedule for a changing parameter (e.g. learning rate) get the values for this parameter at a given time. e.g.: - learning_rate_scheduler = ScheduledParameter({0: 0.1, 10: 0.01, 100: 0.001}, print_variable_name='eta') + learning_rate_scheduler = ParameterSchedule({0: 0.1, 10: 0.01, 100: 0.001}, print_variable_name='eta') new_learning_rate = learning_rate_scheduler.get_new_value(epoch=14) assert new_learning_rate == 0.01 diff --git a/artemis/plotting/data_conversion.py b/artemis/plotting/data_conversion.py index 6f2922ba..bf5e8aa4 100644 --- a/artemis/plotting/data_conversion.py +++ b/artemis/plotting/data_conversion.py @@ -24,12 +24,12 @@ def vector_length_to_tile_dims(vector_length, ): return grid_shape -def put_vector_in_grid(vec, shape = None): +def put_vector_in_grid(vec, shape = None, empty_val = 0): if shape is None: n_rows, n_cols = vector_length_to_tile_dims(len(vec)) else: n_rows, n_cols = shape - grid = np.zeros(n_rows*n_cols, dtype = vec.dtype) + grid = np.zeros(n_rows*n_cols, dtype = vec.dtype) + empty_val grid[:len(vec)]=vec grid=grid.reshape(n_rows, n_cols) return grid diff --git a/artemis/plotting/db_plotting.py b/artemis/plotting/db_plotting.py index 52a5fa9e..655ccf9d 100644 --- a/artemis/plotting/db_plotting.py +++ b/artemis/plotting/db_plotting.py @@ -265,7 +265,7 @@ def freeze_all_dbplots(fig = None): freeze_dbplot(name, fig=fig) -def replot_and_redraw_figure(fig, hang): +def dbplot_redraw_all(fig = None, hang = False): for subplot in _DBPLOT_FIGURES[fig].subplots.values(): plt.subplot(subplot.axis) @@ -316,7 +316,7 @@ def hold_dbplots(fig = None, hang=False, draw_every = None): plot_now = True if plot_now and fig in _DBPLOT_FIGURES: - replot_and_redraw_figure(fig, hang = hang) + dbplot_redraw_all(fig, hang = hang) def clear_dbplot(fig = None): diff --git a/artemis/plotting/expanding_subplots.py b/artemis/plotting/expanding_subplots.py index d7c7afed..f310a0e5 100644 --- a/artemis/plotting/expanding_subplots.py +++ b/artemis/plotting/expanding_subplots.py @@ -264,7 +264,7 @@ def vstack_plots(spacing=0, sharex=True, sharey = False, show_x = 'once', show_y new_subplots[-1].tick_params(axis='x', labelbottom='on') if xlabel is not None: - new_subplots[-1].set_xlabcel(xlabel) + new_subplots[-1].set_xlabel(xlabel) if remove_ticks: new_subplots[-1].get_xaxis().set_visible(True) diff --git a/docs/source/plotting.rst b/docs/source/plotting.rst index 95fcf69d..e791ca7b 100644 --- a/docs/source/plotting.rst +++ b/docs/source/plotting.rst @@ -71,8 +71,8 @@ Plotting Demos ###################### * `A demo of showing how to make various kinds of live updating plots. `_ -* `A demo repo showing how to use Artemis from your code `_ -* `A guide on using Artemis for remote plotting `_ +* `A demo repo showing how to use Artemis from your code `_ +* `A guide on using Artemis for remote plotting `_ ###################### From e473ff1f877f1c4c88f3c9fd902f32f01f7f0996 Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Fri, 19 Oct 2018 14:19:09 +0200 Subject: [PATCH 008/107] aahhh --- artemis/experiments/hyperparameter_search.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/artemis/experiments/hyperparameter_search.py b/artemis/experiments/hyperparameter_search.py index 2594ca38..82639d7b 100644 --- a/artemis/experiments/hyperparameter_search.py +++ b/artemis/experiments/hyperparameter_search.py @@ -1,7 +1,3 @@ -from skopt import gp_minimize -from skopt.utils import use_named_args -from tabulate import tabulate - from artemis.general.iteratorize import Iteratorize @@ -14,6 +10,8 @@ def parameter_search(objective, space, n_calls, n_random_starts=3, acq_optimizer :param acq_optimizer: :return Generator[{'names': List[str], 'x_iters': List[]: """ # TODO: Finish building this + from skopt import gp_minimize # Soft requirements are imported in here. + from skopt.utils import use_named_args for k, var in space.items(): var.name=k From 623225619452207ac3c83ba27f60a35c94fb3d56 Mon Sep 17 00:00:00 2001 From: Peter Date: Mon, 12 Nov 2018 15:50:48 +0100 Subject: [PATCH 009/107] ARTEMIS CHANGES FROM DEAD MACHINE --- artemis/experiments/experiment_record.py | 45 ++++- artemis/experiments/experiment_record_view.py | 89 +++++++++- artemis/experiments/test_experiment_record.py | 20 ++- .../test_experiment_record_view_and_ui.py | 36 +++- artemis/experiments/ui.py | 12 ++ artemis/general/deferred_defaults.py | 20 +++ artemis/general/display.py | 2 +- artemis/general/ezprofile.py | 33 +++- artemis/general/functional.py | 4 +- artemis/general/global_rates.py | 15 ++ artemis/general/global_vars.py | 29 ++++ artemis/general/should_be_builtins.py | 22 ++- artemis/general/test_deferred_defaults.py | 33 ++++ artemis/general/test_should_be_builtins.py | 8 +- artemis/ml/predictors/predictor_comparison.py | 2 +- artemis/ml/tools/iteration.py | 14 +- artemis/ml/tools/processors.py | 120 -------------- artemis/ml/tools/running_averages.py | 154 ++++++++++++++++++ artemis/ml/tools/test_running_averages.py | 64 ++++++++ artemis/plotting/db_plotting.py | 1 + artemis/plotting/matplotlib_backend.py | 9 +- artemis/plotting/test_db_plotting.py | 76 ++++----- 22 files changed, 609 insertions(+), 199 deletions(-) create mode 100644 artemis/general/deferred_defaults.py create mode 100644 artemis/general/global_rates.py create mode 100644 artemis/general/global_vars.py create mode 100644 artemis/general/test_deferred_defaults.py create mode 100644 artemis/ml/tools/running_averages.py create mode 100644 artemis/ml/tools/test_running_averages.py diff --git a/artemis/experiments/experiment_record.py b/artemis/experiments/experiment_record.py index 6fd30e83..1d26f980 100644 --- a/artemis/experiments/experiment_record.py +++ b/artemis/experiments/experiment_record.py @@ -13,6 +13,7 @@ from contextlib import contextmanager from getpass import getuser from pickle import PicklingError +import itertools from datetime import datetime, timedelta from uuid import getnode @@ -23,7 +24,7 @@ from artemis.general.display import CaptureStdOut from artemis.general.functional import get_partial_chain, get_defined_and_undefined_args from artemis.general.hashing import compute_fixed_hash -from artemis.general.should_be_builtins import nested +from artemis.general.should_be_builtins import nested, natural_keys from artemis.general.test_mode import is_test_mode from artemis.general.test_mode import set_test_mode from artemis._version import __version__ as ARTEMIS_VERSION @@ -240,6 +241,7 @@ def get_figure_locs(self, include_directory=True): :return: A list of string file paths. """ locs = [f for f in os.listdir(self._experiment_directory) if f.startswith('fig-')] + locs = sorted(locs, key=natural_keys) if include_directory: return [os.path.join(self._experiment_directory, f) for f in locs] else: @@ -661,7 +663,34 @@ def clear_experiment_records(ids): ExperimentRecord(exp_path).delete() -def save_figure_in_record(name, fig=None, default_ext='.pkl'): +# +# +# def save_figure_in_current_experiment_directory(name='fig-{}.pkl', figure = None): +# +# if figure is None: +# figure = plt.gcf() +# +# current_dir = get_current_record_dir() +# start_ix = _figure_ixs[current_dir] if current_dir in _figure_ixs else 0 +# for ix in count(start_ix): +# full_path = os.path.join(current_dir, name).format(ix) +# if not os.path.exists(_figure_ixs[current_dir]): +# save_figure(figure, path = full_path) +# _figure_ixs[current_dir] = ix+1 +# return full_path +_figure_ixs = {} + + +def _get_next_figure_name(name_pattern, directory): + start_ix = _figure_ixs[directory] if directory in _figure_ixs else 0 + for ix in itertools.count(start_ix): + full_path = os.path.join(directory, name_pattern).format(ix) + if not os.path.exists(full_path): + _figure_ixs[directory] = ix+1 + return full_path + + +def save_figure_in_record(name=None, fig=None, default_ext='.pkl'): ''' Saves the given figure in the experiment directory. If no figure is passed, plt.gcf() is saved instead. :param name: The name of the figure to be saved @@ -671,11 +700,17 @@ def save_figure_in_record(name, fig=None, default_ext='.pkl'): ''' import matplotlib.pyplot as plt from artemis.plotting.saving_plots import save_figure + if fig is None: fig = plt.gcf() - save_path = os.path.join(get_current_record_dir(), name) - save_figure(fig, path=save_path, default_ext=default_ext) - return save_path + + current_dir = get_current_record_dir() + if name is None: + path = _get_next_figure_name(name_pattern='fig-{}.pkl', directory=current_dir) + else: + path = os.path.join(current_dir, name) + save_figure(fig, path=path, default_ext=default_ext) + return path def get_serialized_args(argdict): diff --git a/artemis/experiments/experiment_record_view.py b/artemis/experiments/experiment_record_view.py index 9a408b44..57341e0f 100644 --- a/artemis/experiments/experiment_record_view.py +++ b/artemis/experiments/experiment_record_view.py @@ -1,6 +1,6 @@ import re from collections import OrderedDict - +from functools import partial import itertools from six import string_types from tabulate import tabulate @@ -13,6 +13,7 @@ from artemis.general.should_be_builtins import separate_common_items, bad_value, izip_equal, \ remove_duplicates, get_unique_name, entries_to_table from artemis.general.tables import build_table +import os def get_record_result_string(record, func='deep', truncate_to = None, array_print_threshold=8, array_float_format='.3g', oneline=False, default_one_liner_func=str): @@ -446,8 +447,8 @@ def compare_timeseries_records(records, yfield, xfield = None, hang=True, ax=Non """ :param Sequence[ExperimentRecord] records: A list of records containing results of the form Sequence[Dict[str, number]] - :param yfield: The name of the fields for the x-axis - :param xfield: The name of the field for the y-axis + :param yfield: The name of the field for the x-axis + :param xfield: The name of the field(s) for the y-axis """ from matplotlib import pyplot as plt results = [rec.get_result() for rec in records] @@ -472,3 +473,85 @@ def compare_timeseries_records(records, yfield, xfield = None, hang=True, ax=Non plt.legend() if hang: plt.show() + + +def get_timeseries_record_comparison_function(yfield, xfield = None, hang=True, ax=None): + """ + :param yfield: The name of the field for the x-axis + :param xfield: The name of the field(s) for the y-axis + """ + return lambda records: compare_timeseries_records(records, yfield, xfield = xfield, hang=hang, ax=ax) + + + +def timeseries_oneliner_function(result, fields, show_len, show = 'last'): + assert show=='last', 'Only support showing last element now' + return (f'{len(result)} items. ' if show_len else '')+', '.join(f'{k}: {result[-1][k]:.3g}' if isinstance(result[-1][k], float) else f'{k}: {result[-1][k]}' for k in fields) + + +def get_timeseries_oneliner_function(fields, show_len=False, show='last'): + return partial(timeseries_oneliner_function, fields=fields, show_len=show_len, show=show) + + +def browse_record_figs(record): + """ + Browse through the figures associated with an experiment record + :param ExperimentRecord record: An experiment record + """ + # TODO: Generalize this to just browse through the figures in a directory. + + from artemis.plotting.saving_plots import interactive_matplotlib_context + import pickle + from matplotlib import pyplot as plt + from artemis.plotting.drawing_plots import redraw_figure + fig_locs = record.get_figure_locs() + + class nonlocals: + this_fig = None + figno = 0 + + def show_figure(ix): + path = fig_locs[ix] + dir, name = os.path.split(path) + if nonlocals.this_fig is not None: + plt.close(nonlocals.this_fig) + # with interactive_matplotlib_context(): + plt.close(plt.gcf()) + with open(path, "rb") as f: + fig = pickle.load(f) + fig.canvas.set_window_title(record.get_id()+': ' +name+': (Figure {}/{})'.format(ix+1, len(fig_locs))) + fig.canvas.mpl_connect('key_press_event', changefig) + print('Showing {}: Figure {}/{}. Full path: {}'.format(name, ix+1, len(fig_locs), path)) + # redraw_figure() + plt.show() + nonlocals.this_fig = plt.gcf() + + def changefig(keyevent): + if keyevent.key=='right': + nonlocals.figno = (nonlocals.figno+1)%len(fig_locs) + elif keyevent.key=='left': + nonlocals.figno = (nonlocals.figno-1)%len(fig_locs) + elif keyevent.key=='up': + nonlocals.figno = (nonlocals.figno-10)%len(fig_locs) + elif keyevent.key=='down': + nonlocals.figno = (nonlocals.figno+10)%len(fig_locs) + + elif keyevent.key==' ': + nonlocals.figno = queryfig() + else: + print("No handler for key: {}. Changing Nothing".format(keyevent.key)) + show_figure(nonlocals.figno) + + def queryfig(): + user_input = input('Which Figure (of 1-{})? >>'.format(len(fig_locs))) + try: + nonlocals.figno = int(user_input)-1 + except: + if user_input=='q': + raise Exception('Quit') + else: + print("No handler for input '{}'".format(user_input)) + return nonlocals.figno + + print('Use Left/Right arrows to navigate, ') + show_figure(nonlocals.figno) diff --git a/artemis/experiments/test_experiment_record.py b/artemis/experiments/test_experiment_record.py index ed0ddfbf..102573d8 100644 --- a/artemis/experiments/test_experiment_record.py +++ b/artemis/experiments/test_experiment_record.py @@ -17,7 +17,7 @@ load_experiment_record, ExperimentRecord, record_experiment, \ delete_experiment_with_id, get_current_record_dir, open_in_record_dir, \ ExpStatusOptions, get_current_experiment_id, get_current_experiment_record, \ - get_current_record_id, has_experiment_record, experiment_id_to_record_ids + get_current_record_id, has_experiment_record, experiment_id_to_record_ids, save_figure_in_record from artemis.experiments.experiments import get_experiment_info, load_experiment, experiment_testing_context, \ clear_all_experiments from artemis.experiments.test_experiments import test_unpicklable_args @@ -459,6 +459,23 @@ def my_generator_exp(n_steps, poison_4 = False): assert rec2.get_result() == 3 +def test_figure_saving_and_loading(): + + from artemis.plotting.db_plotting import dbplot + with experiment_testing_context(new_experiment_lib=True): + @experiment_function + def my_exp(): + for t in range(4): + dbplot(np.random.randn(20, 20, 3), 'plot') + save_figure_in_record() + + rec = my_exp.run() # type: ExperimentRecord + + fig_locs = rec.get_figure_locs() + + assert set(fig_locs) == {os.path.join(rec.get_dir(), 'fig-{}.pkl'.format(i)) for i in range(4)} + + if __name__ == '__main__': set_test_mode(True) @@ -482,3 +499,4 @@ def my_generator_exp(n_steps, poison_4 = False): test_current_experiment_access_functions() test_generator_experiment() test_unpicklable_args() + test_figure_saving_and_loading() \ No newline at end of file diff --git a/artemis/experiments/test_experiment_record_view_and_ui.py b/artemis/experiments/test_experiment_record_view_and_ui.py index 50cba1a8..9b2bba66 100644 --- a/artemis/experiments/test_experiment_record_view_and_ui.py +++ b/artemis/experiments/test_experiment_record_view_and_ui.py @@ -1,25 +1,27 @@ import pytest from artemis.experiments.decorators import ExperimentFunction, experiment_function +from artemis.experiments.experiment_record import save_figure_in_record from artemis.experiments.experiment_record_view import get_oneline_result_string, print_experiment_record_argtable, \ - compare_experiment_records, get_record_invalid_arg_string + compare_experiment_records, get_record_invalid_arg_string, browse_record_figs from artemis.experiments.experiments import experiment_testing_context, clear_all_experiments from artemis.general.display import CaptureStdOut, assert_things_are_printed +import numpy as np -def display_it(result): - print(str(result) + 'aaa') +def display_it(record): + print(str(record.get_result()) + 'aaa') def one_liner(result): return str(result) + 'bbb' -def compare_them(results): - print(', '.join('{}: {}'.format(k, results[k]) for k in sorted(results.keys()))) +def compare_them(records): + print(', '.join('{}: {}'.format(record.get_experiment().name, record.get_result()) for record in records)) -@ExperimentFunction(display_function=display_it, one_liner_function=one_liner, comparison_function=compare_them) +@ExperimentFunction(show=display_it, one_liner_function=one_liner, compare=compare_them) def my_xxxyyy_test_experiment(a=1, b=2): if b==17: @@ -82,8 +84,8 @@ def test_experiment_function_ui(): import time time.sleep(0.1) - with assert_things_are_printed(min_len=1200, things=['Common Args', 'Different Args', 'Result', 'a=1, b=2', 'a=2, b=2', 'a=1, b=17']): - my_xxxyyy_test_experiment.browse(raise_display_errors=True, command='argtable all', close_after=True) + # with assert_things_are_printed(min_len=1200, things=['Common Args', 'Different Args', 'Result', 'a=1, b=2', 'a=2, b=2', 'a=1, b=17']): + # my_xxxyyy_test_experiment.browse(raise_display_errors=True, command='argtable all', close_after=True) with assert_things_are_printed(min_len=600, things=['my_xxxyyy_test_experiment: 3', 'my_xxxyyy_test_experiment.a2: 4']): my_xxxyyy_test_experiment.browse(raise_display_errors=True, command='compare all -r', close_after=True) @@ -219,6 +221,23 @@ def my_simdfdscds(a=1): assert string.count('Start Time') == 1 +def demo_browse_record_figs(): + + from artemis.plotting.db_plotting import dbplot + from matplotlib import pyplot as plt + with experiment_testing_context(new_experiment_lib=True): + @experiment_function + def my_exp(): + for t in range(4): + pts = np.linspace(0, 3*(t+1), 400) + dbplot((pts*np.cos(pts), pts*np.sin(pts)), 'plot', title='t={}'.format(t), plot_type='line') + save_figure_in_record() + plt.close(plt.gcf()) + rec = my_exp.run() # type: ExperimentRecord + + browse_record_figs(rec) + + if __name__ == '__main__': test_experiments_function_additions() test_experiment_function_ui() @@ -227,3 +246,4 @@ def my_simdfdscds(a=1): test_simple_experiment_show() test_view_modes() test_duplicate_headers_when_no_records_bug_is_gone() + # demo_browse_record_figs() \ No newline at end of file diff --git a/artemis/experiments/ui.py b/artemis/experiments/ui.py index 55f9335a..ad6b6b71 100644 --- a/artemis/experiments/ui.py +++ b/artemis/experiments/ui.py @@ -267,6 +267,7 @@ def launch(self, command=None): 'view': self.view, 'archive': self.archive, 'h': self.help, + 'figures': self.figures, 'filter': self.filter, 'filterrec': self.filterrec, 'displayformat': self.displayformat, @@ -587,6 +588,17 @@ def info(self, *args): print(record.info.get_text()) print('='*64) + def figures(self, *args): + parser = argparse.ArgumentParser() + parser.add_argument('user_range', action='store', help='A selection of experiment records to show. ') + args = parser.parse_args(args) + user_range = args.user_range + records = select_experiment_records(user_range, self.exp_record_dict, flat=True) + if len(records)>1: + raise RecordSelectionError('Can only show figures for one record at a time. You selected {}'.format(len(records))) + from artemis.experiments.experiment_record_view import browse_record_figs + browse_record_figs(records[0]) + def logs(self, *args): parser = argparse.ArgumentParser() parser.add_argument('user_range', action='store', help='A selection of experiment records to show. ') diff --git a/artemis/general/deferred_defaults.py b/artemis/general/deferred_defaults.py new file mode 100644 index 00000000..dd26a77a --- /dev/null +++ b/artemis/general/deferred_defaults.py @@ -0,0 +1,20 @@ +import sys +import inspect +_CACHE = {} + + +def default(function, arg): + """ + Get the default value to the argument named 'arg' for function "function" + :param Callable function: The function from which to get the default value. + :param str arg: The name of the argument + :return Any: The default value + """ + if function not in _CACHE: + if sys.version_info < (3, 4): + all_arg_names, varargs_name, kwargs_name, defaults = inspect.getargspec(function) + else: + all_arg_names, varargs_name, kwargs_name, defaults, _, _, _ = inspect.getfullargspec(function) + _CACHE[function] = dict(zip(all_arg_names[-len(defaults):], defaults)) + assert arg in _CACHE[function], 'Function {} has no default argument "{}"'.format(function, arg) + return _CACHE[function][arg] diff --git a/artemis/general/display.py b/artemis/general/display.py index 03957f6d..28093cd4 100644 --- a/artemis/general/display.py +++ b/artemis/general/display.py @@ -405,7 +405,7 @@ def format_duration(seconds): else: return res else: - days = seconds//_seconds_in_day + days = int(seconds//_seconds_in_day) return '{:d}d,{}'.format(days, format_duration(seconds % _seconds_in_day)) diff --git a/artemis/general/ezprofile.py b/artemis/general/ezprofile.py index f394aff2..cdf853be 100644 --- a/artemis/general/ezprofile.py +++ b/artemis/general/ezprofile.py @@ -1,7 +1,7 @@ from logging import Logger from time import time from collections import OrderedDict - +from contextlib import contextmanager __author__ = 'peter' @@ -34,6 +34,10 @@ def lap(self, lap_name = None): def get_current_time(self): return time() - self._lap_times['Start'] + def get_total_time(self): + assert 'Stop' in self._lap_times, "The profiler has not exited yet, so you cannot get total time." + return self._lap_times['Stop'] - self._lap_times['Start'] + def __enter__(self): start_time = time() self.start_time = start_time @@ -60,3 +64,30 @@ def get_report(self): deltas = OrderedDict((key, self._lap_times[key] - self._lap_times[last_key]) for last_key, key in zip(keys[:-1], keys[1:])) return self.profiler_name + '\n '.join(['']+['%s: Elapsed time is %.4gs' % (key, val) for key, val in deltas.items()] + (['Total: %.4gs' % (self._lap_times.values()[-1] - self._lap_times.values()[0])] if len(deltas)>1 else [])) + + +_profile_contexts = OrderedDict() + + +@contextmanager +def profile_context(name, print_result = False): + + with EZProfiler(name, print_result=print_result) as prof: + yield prof + if name in _profile_contexts: + n_calls, elapsed = _profile_contexts[name] + else: + n_calls, elapsed = 0, 0. + n_calls, elapsed = n_calls+1, elapsed + prof.get_total_time() + _profile_contexts[name] = (n_calls, elapsed) + + +def get_profile_contexts(names=None, fill_empty_with_zero = False): + + if names is None: + return _profile_contexts + else: + if fill_empty_with_zero: + return OrderedDict((k, _profile_contexts[k] if k in _profile_contexts else 0) for k in names) + else: + return OrderedDict((k, _profile_contexts[k]) for k in names) diff --git a/artemis/general/functional.py b/artemis/general/functional.py index 67efa277..a1660c99 100644 --- a/artemis/general/functional.py +++ b/artemis/general/functional.py @@ -42,7 +42,7 @@ def __init__(self, func, arg_constructors): assert arg_name in all_arg_names, "Function {} has no argument named '{}'".format(func, arg_name) assert callable(arg_constructor), "The configuration for argument '{}' must be a function which constructs the argument. Got a {}".format(arg_name, type(arg_constructor).__name__) assert not inspect.isclass(arg_constructor), "'{}' is a class object. You must instead pass a function to construct an instance of this class. You can use lambda for this.".format(arg_constructor.__name__) - assert isinstance(arg_constructor, types.FunctionType), "The constructor '{}' appeared not to be a pure function. If it is an instance of a callable class, you probably meant to give a either a constructor for that instance.".format(arg_constructor) + assert isinstance(arg_constructor, types.FunctionType), "The constructor '{}' appeared not to be a pure function. If it is an instance of a callable class, you should instead give a staticmethod constructor for that instance.".format(arg_constructor) sub_arg_names, _, _, _ = advanced_getargspec(arg_constructor) for a in sub_arg_names: if a != arg_name: # If the name of your reparemetrizing argument is not the same as the argument you are replacing.... @@ -162,7 +162,7 @@ def advanced_getargspec(f): assert k in all_arg_names, "Constructed Argument '{}' appears not to exist in function {}".format(k, chain[0]) sub_all_arg_names, sub_varargs_name, sub_kwargs_name, sub_defaults = advanced_getargspec(constructor) assert sub_varargs_name is None, "Currently can't handle unnamed arguments for argument constructor {}={}".format(k, constructor) - assert sub_kwargs_name is None, "Currently can't handle unnamed keyword arguments for argument constructor {}={}".format(k, constructor) + assert sub_kwargs_name is None, "Currently can't handle unnamed keyword arguments. Constructor {}={}".format(k, constructor) all_arg_names.remove(k) # Since the argument has been reparameterized, it is removed from the list of constructor signature current_layer_arg_names.remove(k) assert not any(a in current_layer_arg_names for a in sub_all_arg_names), "The constructor for argument '{}' has name '{}', which us already used by the function '{}'. Rename it.".format(k, next(a for a in sub_all_arg_names if a in current_layer_arg_names), chain[0]) diff --git a/artemis/general/global_rates.py b/artemis/general/global_rates.py new file mode 100644 index 00000000..67773a16 --- /dev/null +++ b/artemis/general/global_rates.py @@ -0,0 +1,15 @@ +from artemis.general.global_vars import get_global, set_global +import time + + +class _RateMeasureSingleton: + pass + + +def measure_global_rate(name): + this_time = time.time() + key = (_RateMeasureSingleton, name) + n_calls, start_time = get_global(key, constructor=lambda: (0, this_time)) + set_global(key, (n_calls+1, start_time)) + return n_calls / (this_time - start_time) if this_time!=start_time else float('inf') + diff --git a/artemis/general/global_vars.py b/artemis/general/global_vars.py new file mode 100644 index 00000000..fed2a38d --- /dev/null +++ b/artemis/general/global_vars.py @@ -0,0 +1,29 @@ +from decorator import contextmanager + +_GLOBALS = {} + + +@contextmanager +def global_context(context_dict = None): + global _GLOBALS + if context_dict is None: + context_dict = {} + old_globals = _GLOBALS + _GLOBALS = context_dict + yield context_dict + _GLOBALS = old_globals + + +def get_global(identifier, constructor=None): + + if identifier not in _GLOBALS: + if constructor is not None: + _GLOBALS[identifier] = constructor() + else: + raise KeyError('No global variable with key: {}'.format(identifier)) + return _GLOBALS[identifier] + + +def set_global(identifier, value): + + _GLOBALS[identifier] = value diff --git a/artemis/general/should_be_builtins.py b/artemis/general/should_be_builtins.py index b2e9894b..4244c34f 100644 --- a/artemis/general/should_be_builtins.py +++ b/artemis/general/should_be_builtins.py @@ -2,7 +2,7 @@ from collections import OrderedDict import itertools import os - +import re import math from six.moves import xrange, zip_longest @@ -482,4 +482,22 @@ def entries_to_table(tuplelist, fill_value = None): def print_thru(x): print(x) - return x \ No newline at end of file + return x + + +def atoi(text): + return int(text) if text.isdigit() else text + + +def natural_keys(text): + """ + A key function to use for sorting strings. This captures numbers in the strings, so for example it will sort + + sorted(['y8', 'x10', 'x2', 'y12', 'x9'], key=natural_keys) == ['x2', 'x9', 'x10', 'y8', 'y12'] + + Taken from: https://stackoverflow.com/a/5967539/851699 + alist.sort(key=natural_keys) sorts in human order + http://nedbatchelder.com/blog/200712/human_sorting.html + (See Toothy's implementation in the comments) + """ + return tuple(atoi(c) for c in re.split('(\d+)', text)) diff --git a/artemis/general/test_deferred_defaults.py b/artemis/general/test_deferred_defaults.py new file mode 100644 index 00000000..6d36c461 --- /dev/null +++ b/artemis/general/test_deferred_defaults.py @@ -0,0 +1,33 @@ +from artemis.general.deferred_defaults import default +from pytest import raises + + +def test_deferred_defaults(): + + def subfunction_1(a=2, b=3): + return a+b + + def subfunction_2(c=4): + return c**2 + + def main_function(a=default(subfunction_1, 'a'), b=default(subfunction_1, 'b'), c=default(subfunction_2, 'c')): + return subfunction_1(a=a, b=b) * subfunction_2(c=c) + + assert main_function()==(2+3)*4**2 + assert main_function(b=5)==(2+5)*4**2 + assert main_function(b=5, c=1)==(2+5)*1**2 + + +def check_that_errors_caught(): + + def g(a=4): + return a*2 + + with raises(AssertionError): + def f(a = default(g, 'b')): + return a + + +if __name__ == '__main__': + test_deferred_defaults() + check_that_errors_caught() diff --git a/artemis/general/test_should_be_builtins.py b/artemis/general/test_should_be_builtins.py index b6f8aa40..d637ecb7 100644 --- a/artemis/general/test_should_be_builtins.py +++ b/artemis/general/test_should_be_builtins.py @@ -4,7 +4,7 @@ from artemis.general.should_be_builtins import itermap, reducemap, separate_common_items, remove_duplicates, \ detect_duplicates, remove_common_prefix, all_equal, get_absolute_module, insert_at, get_shifted_key_value, \ - divide_into_subsets, entries_to_table + divide_into_subsets, entries_to_table, natural_keys __author__ = 'peter' @@ -122,6 +122,11 @@ def test_entries_to_table(): assert entries_to_table([[('a', 1), ('b', 2)], [('a', 3), ('b', 4), ('c', 5)]]) == (['a', 'b', 'c'], [[1, 2, None], [3, 4, 5]]) +def test_natural_keys(): + + assert sorted(['y8', 'x10', 'x2', 'y12', 'x9'], key=natural_keys) == ['x2', 'x9', 'x10', 'y8', 'y12'] + + if __name__ == '__main__': test_separate_common_items() test_reducemap() @@ -135,3 +140,4 @@ def test_entries_to_table(): test_get_shifted_key_value() test_divide_into_subsets() test_entries_to_table() + test_natural_keys() \ No newline at end of file diff --git a/artemis/ml/predictors/predictor_comparison.py b/artemis/ml/predictors/predictor_comparison.py index 4f8b5ed8..970d5b5b 100644 --- a/artemis/ml/predictors/predictor_comparison.py +++ b/artemis/ml/predictors/predictor_comparison.py @@ -8,7 +8,7 @@ from artemis.ml.tools.costs import get_evaluation_function from artemis.ml.tools.iteration import checkpoint_minibatch_index_generator from artemis.general.mymath import sqrtspace -from artemis.ml.tools.processors import RunningAverage +from artemis.ml.tools.running_averages import RunningAverage def compare_predictors(dataset, online_predictors={}, offline_predictors={}, minibatch_size = 'full', diff --git a/artemis/ml/tools/iteration.py b/artemis/ml/tools/iteration.py index b9ae2b0f..6ba00c2b 100644 --- a/artemis/ml/tools/iteration.py +++ b/artemis/ml/tools/iteration.py @@ -281,7 +281,7 @@ def generator_pool(generator_generator): yield generator -def batchify_generator(generator_generator, batch_size, receive_input=False, out_format ='array'): +def batchify_generator(generator_generator, batch_size = None, receive_input=False, out_format ='array'): """ Best understood by example: @@ -300,7 +300,7 @@ def batchify_generator(generator_generator, batch_size, receive_input=False, out new movies to start. :param generator_generator: An generator which generates generators - :param batch_size: The size if the batch you want to yield + :param batch_size: The size if the batch you want to yield. :param receive_input: Expect a "send" to this generatoer AFTER it yields. (see Python coroutines) :param out_format: 'array' or 'tuple_of_arrays' currently supported. :yield: An array consisting of batch_size of the outputs of the subgenerator, batched together. @@ -310,7 +310,15 @@ def batchify_generator(generator_generator, batch_size, receive_input=False, out total = batch_size assert out_format in ('array', 'tuple_of_arrays') - generators = [next(generator_generator) for _ in range(batch_size)] + + if batch_size is not None: + generators = [next(generator_generator) for _ in range(batch_size)] + else: + assert isinstance(generator_generator, (list, tuple)), "If you don't specify a batch size your generator-generator must be a finite list." + batch_size = len(generator_generator) + generators = generator_generator + generator_generator = iter(generator_generator) + while True: items = [] for i in range(batch_size): diff --git a/artemis/ml/tools/processors.py b/artemis/ml/tools/processors.py index b7fea780..5c033c7e 100644 --- a/artemis/ml/tools/processors.py +++ b/artemis/ml/tools/processors.py @@ -1,6 +1,5 @@ from abc import abstractmethod import numpy as np -from artemis.general.mymath import recent_moving_average from six.moves import xrange __author__ = 'peter' @@ -31,57 +30,6 @@ def inverse(self, data): return np.argmax(data, axis = 1) -class RunningAverage(object): - - def __init__(self): - self._n_samples_seen = 0 - self._average = 0 - - def __call__(self, data): - self._n_samples_seen+=1 - frac = 1./self._n_samples_seen - self._average = (1-frac)*self._average + frac*data - return self._average - - @classmethod - def batch(cls, x): - return np.cumsum(x, axis=0)/np.arange(1, len(x)+1).astype(np.float)[(slice(None), )+(None, )*(x.ndim-1)] - - -class RecentRunningAverage(object): - - def __init__(self): - self._n_samples_seen = 0 - self._average = 0 - - def __call__(self, data): - self._n_samples_seen+=1 - frac = 1/self._n_samples_seen**.5 - self._average = (1-frac)*self._average + frac*data - return self._average - - @classmethod - def batch(cls, x): - return recent_moving_average(x, axis=0) # Works only for python 2.X, with weave - # ra = cls() - # return np.array([ra(x_) for x_ in x]) - - -class RunningAverageWithBurnin(object): - - def __init__(self, burn_in_steps): - self._burn_in_step_remaining = burn_in_steps - self.averager = RunningAverage() - - def __call__(self, x): - - if self._burn_in_step_remaining > 0: - self._burn_in_step_remaining-=1 - return x - else: - return self.averager(x) - - class IDifferentiableFunction(object): @abstractmethod @@ -108,74 +56,6 @@ def backprop_delta(self, delta_y): return delta_y -class RunningCenter(IDifferentiableFunction): - """ - Keep an exponentially decaying running mean, subtract this from the value. - """ - def __init__(self, half_life): - self.decay_constant = np.exp(-np.log(2)/half_life) - self.one_minus_decay_constant = 1-self.decay_constant - self.running_mean = None - - def __call__(self, x): - if self.running_mean is None: - self.running_mean = np.zeros_like(x) - self.running_mean[:] = self.decay_constant * self.running_mean + self.one_minus_decay_constant * x - return x - self.running_mean - - def backprop_delta(self, delta_y): - return self.decay_constant * delta_y - - -class ExponentialRunningVariance(object): - - def __init__(self, decay): - self.decay = decay - self.running_mean = 0 - self.running_mean_sq = 1 - - def __call__(self, x, decay = None): - - decay = self.decay if decay is None else decay - self.running_mean = (1-decay) * self.running_mean + decay * x - self.running_mean_sq = (1-decay) * self.running_mean_sq + decay * x**2 - var = self.running_mean_sq - self.running_mean**2 - return np.maximum(0, var) # TODO: VERIFY THIS... Due to numerical issues, small negative values are possible... - -class RunningNormalize(IDifferentiableFunction): - - def __init__(self, half_life, eps = 1e-7, initial_std=1): - self.decay_constant = np.exp(-np.log(2)/half_life) - self.one_minus_decay_constant = 1-self.decay_constant - self.running_mean = None - self.eps = eps - self.initial_std = initial_std - - def __call__(self, x): - if self.running_mean is None: - self.running_mean = np.zeros_like(x) - self.running_mean_sq = np.zeros_like(x) + self.initial_std**2 - self.running_mean[:] = self.decay_constant * self.running_mean + self.one_minus_decay_constant * x - self.running_mean_sq[:] = self.decay_constant * self.running_mean_sq + self.one_minus_decay_constant * x**2 - std = np.sqrt(self.running_mean_sq - self.running_mean**2) - return (x - self.running_mean) / (std+self.eps) - - def backprop_delta(self, delta_y): - """ - Ok, we're not doing this right at all, but lets just ignore the contribution of the current - sample to the mean/std. This makes the gradient waaaaaay simpler. If you want to see the real thing, put - - (x-(a*u+(1-a)*x))/sqrt((a*s+(1-a)*x^2 - (a*u+(1-a)*x)^2)) - into http://www.derivative-calculator.net/ - (a stands for lambda here) - - :param delta_y: The derivative of the cost wrt the output of this normalizer - :return: delta_x: The derivative of the cost wrt the input of this normalizer - """ - std = np.sqrt(self.running_mean_sq - self.running_mean**2) - return delta_y/std - - def single_to_batch(fcn, *batch_inputs, **batch_kwargs): """ :param fcn: A function diff --git a/artemis/ml/tools/running_averages.py b/artemis/ml/tools/running_averages.py new file mode 100644 index 00000000..e9ec31e3 --- /dev/null +++ b/artemis/ml/tools/running_averages.py @@ -0,0 +1,154 @@ +import numpy as np + +from artemis.general.global_vars import get_global +from artemis.general.mymath import recent_moving_average +from artemis.ml.tools.processors import IDifferentiableFunction + + +class RunningAverage(object): + + def __init__(self): + self._n_samples_seen = 0 + self._average = 0 + + def __call__(self, data): + self._n_samples_seen+=1 + frac = 1./self._n_samples_seen + self._average = (1-frac)*self._average + frac*data + return self._average + + @classmethod + def batch(cls, x): + return np.cumsum(x, axis=0)/np.arange(1, len(x)+1).astype(np.float)[(slice(None), )+(None, )*(x.ndim-1)] + + +class RecentRunningAverage(object): + + def __init__(self): + self._n_samples_seen = 0 + self._average = 0 + + def __call__(self, data): + self._n_samples_seen+=1 + frac = 1/self._n_samples_seen**.5 + self._average = (1-frac)*self._average + frac*data + return self._average + + @classmethod + def batch(cls, x): + return recent_moving_average(x, axis=0) # Works only for python 2.X, with weave + + +class OptimalStepSizeAverage(object): + + def __init__(self, error_stepsize_target=0.01, initial_stepsize = 1., epsilon=1--7): + + self.error_stepsize_target = error_stepsize_target + self.error_stepsize = initial_stepsize # (nu) + self.error_stepsize_target = 0.001 # (nu-bar) + self.step_size = 1. # (a) + self.avg = 0 # (theta) + self.beta = 0. + self.delta = 0. + self.lambdaa = 0. + self.epsilon = epsilon + self.first_iter = True + + def __call__(self, x): + error = x-self.avg + error_stepsize = self.error_stepsize / (1 + self.error_stepsize - self.error_stepsize_target) + self.beta = (1-error_stepsize) * self.beta + error_stepsize * error + self.delta = (1-error_stepsize) * self.delta + error_stepsize * error**2 + sigma_sq = (self.delta-self.beta**2)/(1+self.lambdaa) + self.step_size = np.array(1.) if self.first_iter else 1 - (sigma_sq+self.epsilon) / (self.delta+self.epsilon) + # step_size = 1 - (sigma_sq+self.epsilon) / (delta+self.epsilon) + self.lambdaa = (1-self.step_size)**2* self.lambdaa + self.step_size**2 # TODO: Test: Should it be (1-step_size**2) ?? + avg = (1-self.step_size) * self.avg + self.step_size * x + # new_obj = OptimalStepSizer(error_stepsize=error_stepsize, error_stepsize_target=self.error_stepsize_target, + # step_size=step_size, avg=avg, beta=beta, delta = delta, lambdaa=lambdaa, epsilon=self.epsilon, first_iter=False) + + if np.any(np.isnan(avg)): + raise Exception() + return avg + + +class RunningAverageWithBurnin(object): + + def __init__(self, burn_in_steps): + self._burn_in_step_remaining = burn_in_steps + self.averager = RunningAverage() + + def __call__(self, x): + + if self._burn_in_step_remaining > 0: + self._burn_in_step_remaining-=1 + return x + else: + return self.averager(x) + + +class RunningCenter(object): + """ + Keep an exponentially decaying running mean, subtract this from the value. + """ + def __init__(self, half_life): + self.decay_constant = np.exp(-np.log(2)/half_life) + self.one_minus_decay_constant = 1-self.decay_constant + self.running_mean = None + + def __call__(self, x): + if self.running_mean is None: + self.running_mean = np.zeros_like(x) + self.running_mean[:] = self.decay_constant * self.running_mean + self.one_minus_decay_constant * x + return x - self.running_mean + + +class ExponentialRunningVariance(object): + + def __init__(self, decay): + self.decay = decay + self.running_mean = 0 + self.running_mean_sq = 1 + + def __call__(self, x, decay = None): + + decay = self.decay if decay is None else decay + self.running_mean = (1-decay) * self.running_mean + decay * x + self.running_mean_sq = (1-decay) * self.running_mean_sq + decay * x**2 + var = self.running_mean_sq - self.running_mean**2 + return np.maximum(0, var) # TODO: VERIFY THIS... Due to numerical issues, small negative values are possible... + + +class RunningNormalize(IDifferentiableFunction): + + def __init__(self, half_life, eps = 1e-7, initial_std=1): + + self.decay_constant = np.exp(-np.log(2)/half_life) + self.one_minus_decay_constant = 1-self.decay_constant + self.running_mean = None + self.eps = eps + self.initial_std = initial_std + + def __call__(self, x): + if self.running_mean is None: + self.running_mean = np.zeros_like(x) + self.running_mean_sq = np.zeros_like(x) + self.initial_std**2 + self.running_mean[:] = self.decay_constant * self.running_mean + self.one_minus_decay_constant * x + self.running_mean_sq[:] = self.decay_constant * self.running_mean_sq + self.one_minus_decay_constant * x**2 + std = np.sqrt(self.running_mean_sq - self.running_mean**2) + return (x - self.running_mean) / (std+self.eps) + + +_running_averages = {} + + +def get_global_running_average(value, identifier, ra_type='simple'): + """ + Get the running average of a variable. + :param value: The latest value of the variable + :param identifier: An identifier (to store the state of the running averager) + :param ra_type: The type of running averge. Options are 'simple', 'recent', 'osa' + :return: The running average + """ + running_averager = get_global(identifier=identifier, constructor=lambda: (ra_type() if callable(ra_type) else {'simple': RunningAverage, 'recent': RecentRunningAverage, 'osa': OptimalStepSizeAverage}[ra_type]())) + return running_averager(value) diff --git a/artemis/ml/tools/test_running_averages.py b/artemis/ml/tools/test_running_averages.py new file mode 100644 index 00000000..8795bcbb --- /dev/null +++ b/artemis/ml/tools/test_running_averages.py @@ -0,0 +1,64 @@ +import numpy as np +import pytest +from six.moves import xrange + +from artemis.general.global_vars import global_context +from artemis.ml.tools.running_averages import RunningAverage, RecentRunningAverage, get_global_running_average + +__author__ = 'peter' + + +def test_running_average(): + + inp = np.arange(5) + processor = RunningAverage() + out = [processor(el) for el in inp] + assert out == [0, 0.5, 1, 1.5, 2] + assert np.array_equal(out, RunningAverage.batch(inp)) + + inp = np.random.randn(10, 5) + processor = RunningAverage() + out = [processor(el) for el in inp] + assert all(np.allclose(out[i], np.mean(inp[:i+1], axis = 0)) for i in xrange(len(inp))) + + +@pytest.mark.skipif(True, reason='Depends on weave, which is deprecated for python 3') +def test_recent_running_average(): + + inp = np.arange(5) + processor = RecentRunningAverage() + out = [processor(el) for el in inp] + out2 = processor.batch(inp) + assert np.allclose(out, out2) + assert np.allclose(out, [0.0, 0.7071067811865475, 1.4535590291019362, 2.226779514550968, 3.019787823462811]) + + inp = np.random.randn(10, 5) + processor = RunningAverage() + out = [processor(el) for el in inp] + out2 = processor.batch(inp) + assert np.allclose(out, out2) + + +def test_get_global_running_average(): + + n_steps = 100 + + rng = np.random.RandomState(1234) + + sig = 2.5*(1-np.exp(-np.linspace(0, 10, n_steps))) + noise = rng.randn(n_steps)*0.1 + fullsig = sig + noise + with global_context(): + for x in fullsig: + ra = get_global_running_average(x, 'my_ra_simple', ra_type='simple') + assert 2.24 < ra < 2.25 + for x in fullsig: + ra = get_global_running_average(x, 'my_ra_recent', ra_type='recent') + assert 2.490 < ra < 2.491 + for x in fullsig: + ra = get_global_running_average(x, 'my_ra_osa', ra_type='osa') + assert 2.44 < ra < 2.45 + + +if __name__ == '__main__': + test_get_global_running_average() \ No newline at end of file diff --git a/artemis/plotting/db_plotting.py b/artemis/plotting/db_plotting.py index 655ccf9d..e7ee3a31 100644 --- a/artemis/plotting/db_plotting.py +++ b/artemis/plotting/db_plotting.py @@ -191,6 +191,7 @@ class DBPlotTypes: LINE= LinePlot THICK_LINE= partial(LinePlot, plot_kwargs={'linewidth': 3}) POS_LINE= partial(LinePlot, y_bounds=(0, None), y_bound_extend=(0, 0.05)) + SCATTER= partial(LinePlot, plot_kwargs=dict(marker='.', markersize=7), linestyle='') BBOX= partial(BoundingBoxPlot, linewidth=2, axes_update_mode='expand') BBOX_R= partial(BoundingBoxPlot, linewidth=2, color='r', axes_update_mode='expand') BBOX_B= partial(BoundingBoxPlot, linewidth=2, color='b', axes_update_mode='expand') diff --git a/artemis/plotting/matplotlib_backend.py b/artemis/plotting/matplotlib_backend.py index da156fb6..c623c75e 100644 --- a/artemis/plotting/matplotlib_backend.py +++ b/artemis/plotting/matplotlib_backend.py @@ -321,12 +321,15 @@ def __init__(self, axes_update_mode='expand', **kwargs): self._image_handle = None self._last_data_shape = None - def update(self, data): + def _plot_last_data(self, data): """ :param data: A (left, bottom, right, top) bounding box. """ if self._image_handle is None: - self._image_handle = next(c for c in plt.gca().get_children() if isinstance(c, AxesImage)) + try: + self._image_handle = next(c for c in plt.gca().get_children() if isinstance(c, AxesImage)) + except StopIteration: + raise Exception('Could not find any image plots in the current axis to draw bounding boxes on! Check that "axis" argument matches the name of a previous image plot') data_shape = self._image_handle.get_array().shape # Hopefully this isn't copying if data_shape != self._last_data_shape: @@ -342,7 +345,7 @@ def update(self, data): x = np.array([l, l, r, r, l]) # Note: should we be adding .5? The extend already subtracts .5 y = np.array([t, b, b, t, t]) - LinePlot.update(self, (x, y)) + LinePlot._plot_last_data(self, (x, y)) class MovingPointPlot(LinePlot): diff --git a/artemis/plotting/test_db_plotting.py b/artemis/plotting/test_db_plotting.py index 8bdd566c..b6a8178f 100644 --- a/artemis/plotting/test_db_plotting.py +++ b/artemis/plotting/test_db_plotting.py @@ -4,7 +4,7 @@ import numpy as np from artemis.plotting.demo_dbplot import demo_dbplot from artemis.plotting.db_plotting import dbplot, clear_dbplot, hold_dbplots, freeze_all_dbplots, reset_dbplot, \ - dbplot_hang + dbplot_hang, DBPlotTypes from artemis.plotting.matplotlib_backend import LinePlot, HistogramPlot, MovingPointPlot, is_server_plotting_on, \ ResamplingLineHistory import pytest @@ -204,51 +204,31 @@ def test_individual_periodic_plotting(): time.sleep(0.02) +def test_bbox_display(): + + # It once was the case that bboxes failed when in a hold block with their image. Not any more. + with hold_dbplots(): + dbplot((np.random.rand(40, 40)*255.999).astype(np.uint8), 'gfdsg') + dbplot((np.random.rand(40, 40)*255.999).astype(np.uint8), 'img') + dbplot([10, 20, 25, 30], 'bbox', axis='img', plot_type=DBPlotTypes.BBOX) + + if __name__ == '__main__': - if is_server_plotting_on(): - test_cornertext() - time.sleep(2.) - test_trajectory_plot() - time.sleep(2.) - test_demo_dbplot() - time.sleep(2.) - test_two_plots_in_the_same_axis_version_1() - time.sleep(2.) - test_two_plots_in_the_same_axis_version_2() - time.sleep(2.) - test_moving_point_multiple_points() - time.sleep(2.) - test_list_of_images() - time.sleep(2.) - test_multiple_figures() - time.sleep(2.) - test_same_object() - time.sleep(2.) - test_history_plot_updating() - time.sleep(2.) - test_particular_plot() - time.sleep(2.) - test_dbplot() - time.sleep(2.) - test_custom_axes_placement() - time.sleep(2.) - test_close_and_open() - time.sleep(2.) - else: - test_cornertext() - test_trajectory_plot() - test_demo_dbplot() - test_freeze_dbplot() - test_two_plots_in_the_same_axis_version_1() - test_two_plots_in_the_same_axis_version_2() - test_moving_point_multiple_points() - test_list_of_images() - test_multiple_figures() - test_same_object() - test_history_plot_updating() - test_particular_plot() - test_dbplot() - test_custom_axes_placement() - test_close_and_open() - test_periodic_plotting() - test_individual_periodic_plotting() + test_cornertext() + test_trajectory_plot() + test_demo_dbplot() + test_freeze_dbplot() + test_two_plots_in_the_same_axis_version_1() + test_two_plots_in_the_same_axis_version_2() + test_moving_point_multiple_points() + test_list_of_images() + test_multiple_figures() + test_same_object() + test_history_plot_updating() + test_particular_plot() + test_dbplot() + test_custom_axes_placement() + test_close_and_open() + test_periodic_plotting() + test_individual_periodic_plotting() + test_bbox_display() From 949dcd41309a01f34fc020346cba68bbdcb7ffd8 Mon Sep 17 00:00:00 2001 From: Peter Date: Tue, 20 Nov 2018 11:27:18 +0100 Subject: [PATCH 010/107] ducks now support boolean indexing --- artemis/experiments/experiment_record_view.py | 26 +++++++++++++++- artemis/general/duck.py | 31 +++++++++++++++++-- artemis/general/test_duck.py | 14 +++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/artemis/experiments/experiment_record_view.py b/artemis/experiments/experiment_record_view.py index 57341e0f..f02fbf17 100644 --- a/artemis/experiments/experiment_record_view.py +++ b/artemis/experiments/experiment_record_view.py @@ -9,6 +9,7 @@ load_experiment_record, is_matplotlib_imported, UnPicklableArg from artemis.general.display import deepstr, truncate_string, hold_numpy_printoptions, side_by_side, \ surround_with_header, section_with_header, dict_to_str +from artemis.general.duck import Duck from artemis.general.nested_structures import flatten_struct, PRIMATIVE_TYPES from artemis.general.should_be_builtins import separate_common_items, bad_value, izip_equal, \ remove_duplicates, get_unique_name, entries_to_table @@ -358,6 +359,29 @@ def compare_experiment_records(records, parallel_text=None, show_logs=True, trun return has_matplotlib_figures +def make_record_comparison_duck(records, only_different_args = False, results_extractor = None): + """ + Make a data structure containing arguments and results of the experiment. + :param Sequence[ExperimentRecord] records: + :param Optional[Callable] results_extractor: + :return Duck: A Duck with one entry per record. Each entry has keys ['args', 'result'] + """ + duck = Duck() + + if only_different_args: + common, diff = separate_common_args(records) + else: + common = None + + for rec in records: + duck[next, 'args', :] = rec.get_args() if common is None else OrderedDict((k, v) for k, v in rec.get_args().items() if k not in common) + result = rec.get_result() + if results_extractor is not None: + result = results_extractor(result) + duck[-1, 'result', ...] = result + return duck + + def make_record_comparison_table(records, args_to_show=None, results_extractor = None, print_table = False, tablefmt='simple', reorder_by_args=False): """ Make a table comparing the arguments and results of different experiment records. You can use the output @@ -425,7 +449,7 @@ def separate_common_args(records, as_dicts=False, return_dict = False, only_shar :param records: A List of records :param return_dict: Return the different args as a dict - :return: (common, different) + :return Tuple[OrderedDict[str, Any], List[OrderedDict[str, Any]]: (common, different) Where common is an OrderedDict of common args different is a list (the same lengths of records) of OrderedDicts containing args that are not the same in all records. """ diff --git a/artemis/general/duck.py b/artemis/general/duck.py index 063dd45a..4c9a03da 100644 --- a/artemis/general/duck.py +++ b/artemis/general/duck.py @@ -87,6 +87,8 @@ def from_struct(cls, struct): return DynamicSequence(struct) elif isinstance(struct, dict): return UniversalOrderedStruct(struct) + elif isinstance(struct, Duck): + return UniversalCollection.from_struct(struct.to_struct()) elif struct is None or isinstance(struct, EmptyCollection): return EmptyCollection() else: @@ -167,8 +169,17 @@ def keys(self): def __getitem__(self, ix): if isinstance(ix, slice): return DynamicSequence(list.__getitem__(self, ix)) + elif isinstance(ix, UniversalCollection): + return self.__getitem__(ix.to_struct()) elif isinstance(ix, (list, tuple)): - return DynamicSequence((list.__getitem__(self, i) for i in ix)) + arrix = np.array(ix) + if arrix.dtype==np.bool: + if len(arrix) != len(self): + raise InvalidKeyError('If you use boolean indices, the length ({} here) must match the length of the collection ({} here)'.format(len(arrix), len(self))) + else: + return DynamicSequence(a for a, b in izip_equal(self, arrix) if b) + else: + return DynamicSequence((list.__getitem__(self, i) for i in ix)) else: try: return list.__getitem__(self, ix) @@ -416,7 +427,7 @@ def __getitem__(self, indices): else: # Case 2: There are deeper indices to get if not isinstance(new_substruct, Duck): raise KeyError('Leave value "{}" can not be broken into with {}'.format(new_substruct, indices[1:])) - if isinstance(first_selector, (list, np.ndarray, slice)): # Sliced selection, with more sub-indices + if isinstance(first_selector, (list, np.ndarray, slice, UniversalCollection)): # Sliced selection, with more sub-indices return new_substruct.map(lambda x: x.__getitem__(indices[1:])) else: # Simple selection, with more sub-indices return new_substruct[indices[1:]] @@ -715,3 +726,19 @@ def description(self, max_expansion=4, _skip_intro=False): if i>max_expansion: break return ('' if _skip_intro else (str(self) + '')) + indent_string(key_value_string, indent='| ', include_first=False) + + def each_eq(self, item): + """ + :param item: Any python object. + :return: A new Duck filled with boolean values indicating if each element of this Duck is equal to the given item. + (this can be used for boolean indexing) + """ + return self.map(lambda x: item==x) + + def each_in(self, item_set): + """ + :param Sequence item: A set of items + :return: A new Duck filled with boolean values indicating if each element of this Duck is in the set. + (this can be used for boolean indexing) + """ + return self.map(lambda x: (x in item_set)) diff --git a/artemis/general/test_duck.py b/artemis/general/test_duck.py index 275c3640..d7dd6f74 100644 --- a/artemis/general/test_duck.py +++ b/artemis/general/test_duck.py @@ -554,6 +554,19 @@ def test_has_key(): assert not duck.has_key('q') +def test_boolean_indexing(): + + d = Duck() + d[next, :] = {'a': 1, 'b': 2} + d[next, :] = {'a': 4, 'b': 3} + d[next, :] = {'a': 3, 'b': 6} + d[next, :] = {'a': 6, 'b': 2} + + assert d[[True, False, False, True], 'a'] == [1, 6] + assert d[d[:, 'b'].each_eq(2), 'a'] == [1, 6] + assert d[d[:, 'b'].each_in({3, 6}), 'a'] == [4, 3] + + if __name__ == '__main__': test_so_demo() test_dict_assignment() @@ -582,3 +595,4 @@ def test_has_key(): test_key_get_on_set_bug() test_occasional_value_filter() test_has_key() + test_boolean_indexing() From e1700cad8038dab271d572a5f6b4f60ba2894758 Mon Sep 17 00:00:00 2001 From: Peter Date: Fri, 7 Dec 2018 18:57:29 +0100 Subject: [PATCH 011/107] stuuuufff --- artemis/experiments/experiment_record_view.py | 2 ++ artemis/fileman/config_files.py | 2 +- artemis/fileman/file_getter.py | 8 ++++- artemis/general/duck.py | 19 +++++++++++ artemis/general/global_rates.py | 25 +++++++++++++++ artemis/general/test_duck.py | 10 +++--- artemis/ml/tools/running_averages.py | 6 +++- artemis/plotting/drawing_plots.py | 2 +- artemis/plotting/expanding_subplots.py | 2 +- artemis/plotting/matplotlib_backend.py | 2 +- artemis/plotting/pyplot_plus.py | 32 +++++++++++++++---- 11 files changed, 94 insertions(+), 16 deletions(-) diff --git a/artemis/experiments/experiment_record_view.py b/artemis/experiments/experiment_record_view.py index f02fbf17..7da1c8bb 100644 --- a/artemis/experiments/experiment_record_view.py +++ b/artemis/experiments/experiment_record_view.py @@ -378,6 +378,8 @@ def make_record_comparison_duck(records, only_different_args = False, results_ex result = rec.get_result() if results_extractor is not None: result = results_extractor(result) + duck[-1, 'exp_id'] = rec.get_experiment_id() + duck[-1, 'id'] = rec.get_id() duck[-1, 'result', ...] = result return duck diff --git a/artemis/fileman/config_files.py b/artemis/fileman/config_files.py index ea2a65a6..c03b7352 100644 --- a/artemis/fileman/config_files.py +++ b/artemis/fileman/config_files.py @@ -65,7 +65,7 @@ def get_config_value(config_filename, section, option, default_generator=None, w config = _get_config_object(config_path) if not config.has_section(section): config.add_section(section) - config.set(section, option, value) + config.set(section, option, str(value)) with open(config_path, 'w') as f: config.write(f) diff --git a/artemis/fileman/file_getter.py b/artemis/fileman/file_getter.py index 0790916e..820b41ca 100644 --- a/artemis/fileman/file_getter.py +++ b/artemis/fileman/file_getter.py @@ -1,6 +1,9 @@ import hashlib from contextlib import contextmanager +from io import BytesIO from shutil import rmtree + +import sys from six.moves import StringIO import gzip import tarfile @@ -166,7 +169,10 @@ def get_archive(url, relative_path=None, force_extract=False, archive_type = Non def unzip_gz(data): - return gzip.GzipFile(fileobj = StringIO(data)).read() + if sys.version_info[0] < 3: + return gzip.GzipFile(fileobj = StringIO(data)).read() + else: + return gzip.GzipFile(fileobj = BytesIO(data)).read() def get_file_path(relative_name = None, url=None, make_folder = False): diff --git a/artemis/general/duck.py b/artemis/general/duck.py index 4c9a03da..077d2b7c 100644 --- a/artemis/general/duck.py +++ b/artemis/general/duck.py @@ -58,6 +58,15 @@ def __setitem__(self, key, value): def __eq__(self, other): return self.to_struct() == (other.to_struct() if isinstance(other, UniversalCollection) else other) + def __and__(self, other): + return self.from_struct([a & b for a, b in izip_equal(self, other)]) + + def __or__(self, other): + return self.from_struct([a | b for a, b in izip_equal(self, other)]) + + def __invert__(self): + return self.from_struct([not a for a in self]) + @abstractmethod def to_struct(self): raise NotImplementedError() @@ -181,6 +190,7 @@ def __getitem__(self, ix): else: return DynamicSequence((list.__getitem__(self, i) for i in ix)) else: + assert not isinstance(ix, bool), 'You cannot index with a boolean.' try: return list.__getitem__(self, ix) except TypeError: @@ -694,6 +704,15 @@ def items(self, depth=None): for k in self.keys(depth=depth): yield k, self[k] + def only(self): + """ + Assert that this duck contains only one element, and return that element. + :return: The only element inside this duck. + """ + keys = list(self.keys()) + assert len(keys)==1, 'You called Duck.only() on a duck with {} elements. "only" can only be called on single-element ducks.'.format(len(self)) + return self[keys[0]] + def __str__(self, max_key_len=4): keys = list(self.keys()) if len(keys)>max_key_len: diff --git a/artemis/general/global_rates.py b/artemis/general/global_rates.py index 67773a16..040e6eb8 100644 --- a/artemis/general/global_rates.py +++ b/artemis/general/global_rates.py @@ -1,3 +1,5 @@ +from contextlib import contextmanager + from artemis.general.global_vars import get_global, set_global import time @@ -13,3 +15,26 @@ def measure_global_rate(name): set_global(key, (n_calls+1, start_time)) return n_calls / (this_time - start_time) if this_time!=start_time else float('inf') + +class _ElapsedMeasureSingleton: + pass + + +@contextmanager +def measure_rate_context(name): + start = time.time() + key = (_ElapsedMeasureSingleton, name) + n_calls, elapsed = get_global(key, constructor=lambda: (0, 0.)) + yield n_calls / elapsed if elapsed > 0 else float('nan') + end = time.time() + set_global(key, (n_calls+1, elapsed+(end-start))) + + +@contextmanager +def measure_runtime_context(name): + start = time.time() + key = (_ElapsedMeasureSingleton, name) + n_calls, elapsed = get_global(key, constructor=lambda: (0, 0.)) + yield elapsed / n_calls if n_calls > 0 else float('nan') + end = time.time() + set_global(key, (n_calls+1, elapsed+(end-start))) diff --git a/artemis/general/test_duck.py b/artemis/general/test_duck.py index d7dd6f74..dd0906e1 100644 --- a/artemis/general/test_duck.py +++ b/artemis/general/test_duck.py @@ -557,14 +557,16 @@ def test_has_key(): def test_boolean_indexing(): d = Duck() - d[next, :] = {'a': 1, 'b': 2} - d[next, :] = {'a': 4, 'b': 3} - d[next, :] = {'a': 3, 'b': 6} - d[next, :] = {'a': 6, 'b': 2} + d[next, :] = {'a': 1, 'b': 2, 'c': 7} + d[next, :] = {'a': 4, 'b': 3, 'c': 8} + d[next, :] = {'a': 3, 'b': 6, 'c': 9} + d[next, :] = {'a': 6, 'b': 2, 'c': 0} assert d[[True, False, False, True], 'a'] == [1, 6] assert d[d[:, 'b'].each_eq(2), 'a'] == [1, 6] assert d[d[:, 'b'].each_in({3, 6}), 'a'] == [4, 3] + assert d[d[:, 'b'].each_eq(3) | d[:, 'b'].each_eq(6), 'a'] == [4, 3] + assert d[d[:, 'b'].each_in({3, 6}) & ~d[:, 'a'].each_in({3, 6})].only()['c'] == 8 # "Find the 'c' value of the only item in the duck where b is in {3, 6} and 'a' is not in {3, 6} if __name__ == '__main__': diff --git a/artemis/ml/tools/running_averages.py b/artemis/ml/tools/running_averages.py index e9ec31e3..3dedaf48 100644 --- a/artemis/ml/tools/running_averages.py +++ b/artemis/ml/tools/running_averages.py @@ -36,7 +36,11 @@ def __call__(self, data): @classmethod def batch(cls, x): - return recent_moving_average(x, axis=0) # Works only for python 2.X, with weave + try: + return recent_moving_average(x, axis=0) # Works only for python 2.X, with weave + except ModuleNotFoundError: + rma = RecentRunningAverage() + return np.array([rma(xt) for xt in x]) class OptimalStepSizeAverage(object): diff --git a/artemis/plotting/drawing_plots.py b/artemis/plotting/drawing_plots.py index 890cfe49..c9636415 100644 --- a/artemis/plotting/drawing_plots.py +++ b/artemis/plotting/drawing_plots.py @@ -2,7 +2,7 @@ from matplotlib import pyplot as plt __author__ = 'peter' -_plotting_mode = get_artemis_config_value(section='plotting', option='mode') +_plotting_mode = get_artemis_config_value(section='plotting', option='mode', default_generator=lambda: 'safe') if _plotting_mode == 'safe': diff --git a/artemis/plotting/expanding_subplots.py b/artemis/plotting/expanding_subplots.py index f310a0e5..af41370d 100644 --- a/artemis/plotting/expanding_subplots.py +++ b/artemis/plotting/expanding_subplots.py @@ -226,7 +226,7 @@ def hstack_plots(spacing=0, sharex=False, sharey = True, grid=False, show_x=True with _define_plot_settings(layout='h', show_y = False if show_y=='once' else show_y, show_x = show_x, grid=grid, sharex=sharex, sharey=sharey, xlabel=xlabel, xlim=xlim, ylim=ylim): set_figure_border_size(wspace=spacing, left=left_pad, right=right_pad, top=top_pad, bottom=bottom_pad) yield - new_subplots = cap.get_new_subplots().values() + new_subplots = list(cap.get_new_subplots().values()) if clip_x: set_same_xlims(new_subplots) diff --git a/artemis/plotting/matplotlib_backend.py b/artemis/plotting/matplotlib_backend.py index c623c75e..af3e4b65 100644 --- a/artemis/plotting/matplotlib_backend.py +++ b/artemis/plotting/matplotlib_backend.py @@ -620,4 +620,4 @@ def get_plotting_server_address(): return _PLOTTING_SERVER -BACKEND = get_artemis_config_value(section='plotting', option='backend') +BACKEND = get_artemis_config_value(section='plotting', option='backend', default_generator=lambda: 'matplotlib', write_default=True) diff --git a/artemis/plotting/pyplot_plus.py b/artemis/plotting/pyplot_plus.py index 46ccef14..43a43e73 100644 --- a/artemis/plotting/pyplot_plus.py +++ b/artemis/plotting/pyplot_plus.py @@ -169,14 +169,34 @@ def set_lines_color_cycle_map(name, length): def get_line_color(ix, modifier=None): - colour = next(c for i, c in enumerate(get_lines_color_cycle()) if i==ix) - if modifier=='dark': - return tuple(c/2 for c in colors.hex2color(colour)) - elif modifier=='light': - return tuple(1-(1-c)/2 for c in colors.hex2color(colour)) + # Back compatibilituy + return modify_color(ix, modifier=modifier) + + +def modify_color(color_specifier, modifier): + rgba = get_color_from_spec(color_specifier) + if callable(modifier): + return modifier(rgba) + elif isinstance(modifier, str): + if modifier=='dark': + return tuple(c/2 for c in colors.hex2color(rgba)) + elif modifier=='light': + return tuple(1-(1-c)/2 for c in colors.hex2color(rgba)) + elif modifier.startswith('alpha:'): + alpha_val = float(modifier[len('alpha:'):]) + return rgba[:3]+(alpha_val, ) + else: + raise NotImplementedError(modifier) elif modifier is not None: raise NotImplementedError(modifier) - return colors.hex2color(colour) + + +def get_color_from_spec(spec): + if isinstance(spec, int): + colour = next(c for i, c in enumerate(get_lines_color_cycle()) if i==spec) + return colour + (1., ) + else: + return tuple(colors.to_rgba(spec)) def relabel_axis(axis, value_array, n_points = 5, format_str='{:.2g}'): From 2e412dddd0c0433c9f60f73e1462c1bc1155325d Mon Sep 17 00:00:00 2001 From: Peter Date: Fri, 14 Dec 2018 13:28:24 +0100 Subject: [PATCH 012/107] join and split --- artemis/general/test_data_splitting.py | 28 ++++++++++++++++++++++ artemis/ml/tools/data_splitting.py | 33 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 artemis/general/test_data_splitting.py diff --git a/artemis/general/test_data_splitting.py b/artemis/general/test_data_splitting.py new file mode 100644 index 00000000..9d735b3b --- /dev/null +++ b/artemis/general/test_data_splitting.py @@ -0,0 +1,28 @@ +import numpy as np + +from artemis.ml.tools.data_splitting import join_arrays_and_get_rebuild_func + + +def test_join_arrays_and_get_rebuild_function(): + + n_samples = 5 + randn = np.random.RandomState(1234).randn + + struct = [ + (randn(n_samples, 3), randn(n_samples)), + randn(n_samples, 4, 5) + ] + + joined, rebuild_func = join_arrays_and_get_rebuild_func(struct, axis=1) + + assert joined.shape == (n_samples, 3+1+4*5) + + new_struct = rebuild_func(joined*2) + + assert np.array_equal(struct[0][0]*2, new_struct[0][0]) + assert np.array_equal(struct[0][1]*2, new_struct[0][1]) + assert np.array_equal(struct[1]*2, new_struct[1]) + + +if __name__ == '__main__': + test_join_arrays_and_get_rebuild_function() diff --git a/artemis/ml/tools/data_splitting.py b/artemis/ml/tools/data_splitting.py index a06f3e5f..994b34ef 100644 --- a/artemis/ml/tools/data_splitting.py +++ b/artemis/ml/tools/data_splitting.py @@ -1,8 +1,12 @@ import numpy as np from six.moves import xrange +from artemis.general.nested_structures import NestedType +from artemis.general.should_be_builtins import izip_equal + __author__ = 'peter' + def split_data_by_label(data, labels, frac_training = 0.5): """ Split the data so that each label gets approximately the correct proportions between the training and test sets @@ -22,3 +26,32 @@ def split_data_by_label(data, labels, frac_training = 0.5): training_indices = np.sort(np.concatenate([ixs[:c] for ixs, c in zip(label_indices, cutoffs)])) test_indices = np.sort(np.concatenate([ixs[c:] for ixs, c in zip(label_indices, cutoffs)])) return data[training_indices], labels[training_indices], data[test_indices], labels[test_indices] + + +def join_arrays_and_get_rebuild_func(arrays, axis = 0): + """ + Given a nested structure of arrays, join them into a single array by flattening dimensions from axis on + concatenating them. Return the joined array and a function which can take the joined array and reproduce the + original structure. + + :param arrays: A possibly nested structure containing arrays which you want to join into a single array. + :param axis: Axis after which to flatten and join all arrays. The resulting array will be (dim+1) dimensional. + :return ndarray, Callable[[ndarray], [Any]]: The joined array, and the function which can be called to reconstruct + the structure from the joined array. + """ + nested_type = NestedType.from_data(arrays) + data_list = nested_type.get_leaves(arrays) + split_shapes = [x_.shape for x_ in data_list] + pre_join_shapes = [list(x_.shape[:axis]) + [np.prod(list(x_.shape[axis:]), dtype=int)] for x_ in data_list] + split_axis_ixs = np.cumsum([0]+[s_[-1] for s_ in pre_join_shapes], axis=0) + joined_arr = np.concatenate(list(x_.reshape(s_) for x_, s_ in izip_equal(data_list, pre_join_shapes)), axis=axis) + + def rebuild_function(joined_array, share_data = True): + if share_data: + x_split = [joined_array[..., start:end].reshape(shape) for (start, end, shape) in izip_equal(split_axis_ixs[:-1], split_axis_ixs[1:], split_shapes)] + else: # Note: this will raise an Error if the self.dim != 0, because the data is no longer contigious in memory. + x_split = [joined_array[..., start:end].copy().reshape(shape) for (start, end, shape) in izip_equal(split_axis_ixs[:-1], split_axis_ixs[1:], split_shapes)] + x_reassembled = nested_type.expand_from_leaves(x_split, check_types=False) + return x_reassembled + + return joined_arr, rebuild_function From b745818be92e8d5c22c9d24ec2dde0166832c8ac Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 19 Dec 2018 10:59:10 +0100 Subject: [PATCH 013/107] fixed pareto stuff --- artemis/general/global_rates.py | 24 +++++++++ artemis/general/nested_structures.py | 17 ++++--- artemis/general/pareto_efficiency.py | 38 +++++++++++--- artemis/general/test_pareto_efficiency.py | 60 +++++++++++++++-------- artemis/ml/tools/data_splitting.py | 54 ++++++++++++++------ artemis/ml/tools/running_averages.py | 7 +++ 6 files changed, 149 insertions(+), 51 deletions(-) diff --git a/artemis/general/global_rates.py b/artemis/general/global_rates.py index 040e6eb8..d8e2d484 100644 --- a/artemis/general/global_rates.py +++ b/artemis/general/global_rates.py @@ -38,3 +38,27 @@ def measure_runtime_context(name): yield elapsed / n_calls if n_calls > 0 else float('nan') end = time.time() set_global(key, (n_calls+1, elapsed+(end-start))) + + +class _LastTimeMeasureSingleton: + pass + + +def is_elapsed(identifier, period, current = None): + """ + Return True if the given span has elapsed since this function last returned True + :param identifier: A string, or anything identifier + :param period: The span which should have elapsed for this to return True again. This is measured in time in seconds + if no argument is provided for "current" or for whatever the unit of "current" is otherwise. + :param current: Optionally, the current state of progress. If ommitted, this defaults to the current time. + :return bool: True if first call or at least "span" units of time have elapsed. + """ + if current is None: + current = time.time() + key = (_LastTimeMeasureSingleton, identifier) + last = get_global(key, constructor=lambda: -float('inf')) + assert current>=last, f"Current value ({current}) must be greater or equal to the last value ({last})" + has_elapsed = current - last >= period + if has_elapsed: + set_global(key, current) + return has_elapsed diff --git a/artemis/general/nested_structures.py b/artemis/general/nested_structures.py index 74e3f992..f319009e 100644 --- a/artemis/general/nested_structures.py +++ b/artemis/general/nested_structures.py @@ -122,7 +122,7 @@ def get_leaves_and_rebuilder(nested_object, is_container = is_container_or_gener # TODO: Consider making leaves a generator so this could be used for streams. leaves = [] meta_obj = get_meta_object(nested_object, is_container=is_container, flat_list=leaves) - return leaves, (lambda data_iteratable: _fill_meta_object(meta_object=meta_obj, data_iteratable=iter(data_iteratable), check_types=check_types, assert_fully_used=assert_fully_used, is_container_func=is_container)) + return leaves, (lambda data_iteratable: fill_meta_object(meta_object=meta_obj, data_iteratable=iter(data_iteratable), check_types=check_types, assert_fully_used=assert_fully_used, is_container_func=is_container)) def get_leaves(nested_object, is_container = is_primitive_container): @@ -238,7 +238,7 @@ def expand_from_leaves(self, leaves, check_types = True, assert_fully_used=True, :param assert_fully_used: Assert that all the leaf values are used :return: A nested object, filled with the leaf data, whose structure is represented in this NestedType instance. """ - return _fill_meta_object(self.meta_object, (x for x in leaves), check_types=check_types, assert_fully_used=assert_fully_used, is_container_func=is_container_func) + return fill_meta_object(self.meta_object, (x for x in leaves), check_types=check_types, assert_fully_used=assert_fully_used, is_container_func=is_container_func) @staticmethod def from_data(data_object, is_container_func = is_primitive_container): @@ -291,7 +291,7 @@ def get_leaf_values(data_object, is_container_func = is_primitive_container): return [data_object] -def _fill_meta_object(meta_object, data_iteratable, assert_fully_used = True, check_types = True, is_container_func = is_primitive_container): +def fill_meta_object(meta_object, data_iteratable, assert_fully_used = True, check_types = True, is_container_func = is_primitive_container): """ Fill the data from the iterable into the meta_object. :param meta_object: A nested type descripter. See NestedType init @@ -304,13 +304,13 @@ def _fill_meta_object(meta_object, data_iteratable, assert_fully_used = True, ch try: if is_container_func(meta_object): if isnamedtupleinstance(meta_object): - filled_object = type(meta_object)(*(_fill_meta_object(None, data_iteratable, assert_fully_used=False, check_types=check_types, is_container_func=is_container_func) for x in meta_object._fields)) + filled_object = type(meta_object)(*(fill_meta_object(None, data_iteratable, assert_fully_used=False, check_types=check_types, is_container_func=is_container_func) for x in meta_object._fields)) elif isinstance(meta_object, (list, tuple, set)): - filled_object = type(meta_object)(_fill_meta_object(x, data_iteratable, assert_fully_used=False, check_types=check_types, is_container_func=is_container_func) for x in meta_object) + filled_object = type(meta_object)(fill_meta_object(x, data_iteratable, assert_fully_used=False, check_types=check_types, is_container_func=is_container_func) for x in meta_object) elif isinstance(meta_object, OrderedDict): - filled_object = type(meta_object)((k, _fill_meta_object(val, data_iteratable, assert_fully_used=False, check_types=check_types, is_container_func=is_container_func)) for k, val in meta_object.items()) + filled_object = type(meta_object)((k, fill_meta_object(val, data_iteratable, assert_fully_used=False, check_types=check_types, is_container_func=is_container_func)) for k, val in meta_object.items()) elif isinstance(meta_object, dict): - filled_object = type(meta_object)((k, _fill_meta_object(meta_object[k], data_iteratable, assert_fully_used=False, check_types=check_types, is_container_func=is_container_func)) for k in sorted(meta_object.keys(), key=str)) + filled_object = type(meta_object)((k, fill_meta_object(meta_object[k], data_iteratable, assert_fully_used=False, check_types=check_types, is_container_func=is_container_func)) for k in sorted(meta_object.keys(), key=str)) else: raise Exception('Cannot handle container type: "{}"'.format(type(meta_object))) else: @@ -330,6 +330,9 @@ def _fill_meta_object(meta_object, data_iteratable, assert_fully_used = True, ch return filled_object +_fill_meta_object = fill_meta_object # For backwards compatibility + + def nested_map(func, *nested_objs, **kwargs): """ An equivalent of pythons built-in map, but for nested objects. This function crawls the object and applies func diff --git a/artemis/general/pareto_efficiency.py b/artemis/general/pareto_efficiency.py index 70dac8e7..ff816959 100644 --- a/artemis/general/pareto_efficiency.py +++ b/artemis/general/pareto_efficiency.py @@ -4,31 +4,38 @@ import numpy as np +# Very slow for many datapoints. Fastest for many costs, most readable def is_pareto_efficient_dumb(costs): """ + Find the pareto-efficient points :param costs: An (n_points, n_costs) array :return: A (n_points, ) boolean array, indicating whether each point is Pareto efficient """ is_efficient = np.ones(costs.shape[0], dtype = bool) for i, c in enumerate(costs): - is_efficient[i] = np.all(np.any(costs>=c, axis=1)) + is_efficient[i] = np.all(np.any(costs[:i]>c, axis=1)) and np.all(np.any(costs[i+1:]>c, axis=1)) return is_efficient -def is_pareto_efficient(costs): +# Fairly fast for many datapoints, less fast for many costs, somewhat readable +def is_pareto_efficient_simple(costs): """ + Find the pareto-efficient points :param costs: An (n_points, n_costs) array :return: A (n_points, ) boolean array, indicating whether each point is Pareto efficient """ is_efficient = np.ones(costs.shape[0], dtype = bool) for i, c in enumerate(costs): if is_efficient[i]: - is_efficient[is_efficient] = np.any(costs[is_efficient]<=c, axis=1) # Remove dominated points + is_efficient[is_efficient] = np.any(costs[is_efficient]0 - for c in costs[ixs]: - assert np.all(np.any(c<=costs, axis=1)) + assert np.sum(ixs)>0 + for c in costs[ixs]: + assert np.all(np.any(c<=costs, axis=1)) - if plot and n_costs==2: - import matplotlib.pyplot as plt - plt.plot(costs[:, 0], costs[:, 1], '.') - plt.plot(costs[ixs, 0], costs[ixs, 1], 'ro') - plt.show() + if plot and n_costs==2: + import matplotlib.pyplot as plt + plt.plot(costs[:, 0], costs[:, 1], '.') + plt.plot(costs[ixs, 0], costs[ixs, 1], 'ro') + plt.show() + + +def test_is_pareto_efficient_integer(): + + assert np.array_equal(is_pareto_efficient_dumb(np.array([[1,2], [3,4], [2,1], [1,1]])), [False, False, False, True]) + assert np.array_equal(is_pareto_efficient_simple(np.array([[1, 2], [3, 4], [2, 1], [1, 1]])), [False, False, False, True]) + assert np.array_equal(is_pareto_efficient(np.array([[1, 2], [3, 4], [2, 1], [1, 1]])), [False, False, False, True]) def profile_pareto_efficient(n_points=5000, n_costs=2, include_dumb = True): rng = np.random.RandomState(1234) - costs = rng.rand(n_points, n_costs) + costs = rng.randn(n_points, n_costs) + + print('{} samples, {} costs'.format(n_points, n_costs)) if include_dumb: with EZProfiler('is_pareto_efficient_dumb'): base_ixs = dumb_ixs = is_pareto_efficient_dumb(costs) + else: + print('is_pareto_efficient_dumb: Really, really, slow') - with EZProfiler('is_pareto_efficient'): - less_dumb__ixs = is_pareto_efficient(costs) + with EZProfiler('is_pareto_efficient_simple'): + less_dumb__ixs = is_pareto_efficient_simple(costs) if not include_dumb: base_ixs = less_dumb__ixs assert np.array_equal(base_ixs, less_dumb__ixs) - with EZProfiler('is_pareto_efficient_indexed'): - smart_indexed = is_pareto_efficient_indexed(costs, return_mask=True) + with EZProfiler('is_pareto_efficient_reordered'): + reordered_ixs = is_pareto_efficient_reordered(costs) + assert np.array_equal(base_ixs, reordered_ixs) + + with EZProfiler('is_pareto_efficient'): + smart_indexed = is_pareto_efficient(costs, return_mask=True) assert np.array_equal(base_ixs, smart_indexed) with EZProfiler('is_pareto_efficient_indexed_reordered'): - smart_indexed = is_pareto_efficient_indexed(costs, return_mask=True, rank_reorder=True) + smart_indexed = is_pareto_efficient_indexed_reordered(costs, return_mask=True) assert np.array_equal(base_ixs, smart_indexed) if __name__ == '__main__': # test_is_pareto_efficient() - profile_pareto_efficient(n_points=100000, n_costs=2, include_dumb=False) + # test_is_pareto_efficient_integer() + profile_pareto_efficient(n_points=10000, n_costs=2, include_dumb=True) + profile_pareto_efficient(n_points=1000000, n_costs=2, include_dumb=False) + profile_pareto_efficient(n_points=10000, n_costs=15, include_dumb=True) diff --git a/artemis/ml/tools/data_splitting.py b/artemis/ml/tools/data_splitting.py index 994b34ef..a54f411c 100644 --- a/artemis/ml/tools/data_splitting.py +++ b/artemis/ml/tools/data_splitting.py @@ -1,7 +1,7 @@ import numpy as np from six.moves import xrange -from artemis.general.nested_structures import NestedType +from artemis.general.nested_structures import get_meta_object, fill_meta_object, get_leaf_values from artemis.general.should_be_builtins import izip_equal __author__ = 'peter' @@ -28,7 +28,35 @@ def split_data_by_label(data, labels, frac_training = 0.5): return data[training_indices], labels[training_indices], data[test_indices], labels[test_indices] -def join_arrays_and_get_rebuild_func(arrays, axis = 0): +class ArrayStructRebuilder(object): + """ + A parameterized function which rebuilds a data structure given a flattened array containing the values. + Suggest using it through join_arrays_and_get_rebuild_func + """ + + def __init__(self, split_shapes, meta_object): + """ + :param Sequence[Tuple[int]] split_shapes: The shapes + :param Any meta_object: A nested object defining the structure in which to rebuild (see get_meta_object) + """ + self.split_shapes = split_shapes + self.meta_object = meta_object + + def __call__(self, joined_array, share_data = True, transform_func = None, check_types=True): + axis = joined_array.ndim-1 + pre_join_shapes = [list(s[:axis]) + [np.prod(list(s[axis:]), dtype=int)] for s in self.split_shapes] + split_axis_ixs = np.cumsum([0]+[s_[-1] for s_ in pre_join_shapes], axis=0) + if share_data: + x_split = [joined_array[..., start:end].reshape(shape) for (start, end, shape) in izip_equal(split_axis_ixs[:-1], split_axis_ixs[1:], self.split_shapes)] + else: # Note: this will raise an Error if the self.dim != 0, because the data is no longer contigious in memory. + x_split = [joined_array[..., start:end].copy().reshape(shape) for (start, end, shape) in izip_equal(split_axis_ixs[:-1], split_axis_ixs[1:], self.split_shapes)] + if transform_func is not None: + x_split = [transform_func(xs) for xs in x_split] + x_reassembled = fill_meta_object(self.meta_object, (x for x in x_split), check_types=check_types) + return x_reassembled + + +def join_arrays_and_get_rebuild_func(arrays, axis = 0, transform_func = None): """ Given a nested structure of arrays, join them into a single array by flattening dimensions from axis on concatenating them. Return the joined array and a function which can take the joined array and reproduce the @@ -36,22 +64,16 @@ def join_arrays_and_get_rebuild_func(arrays, axis = 0): :param arrays: A possibly nested structure containing arrays which you want to join into a single array. :param axis: Axis after which to flatten and join all arrays. The resulting array will be (dim+1) dimensional. - :return ndarray, Callable[[ndarray], [Any]]: The joined array, and the function which can be called to reconstruct + :param transform_func: Optionally, a function which you apply to every element in the nested struture of arrays first. + :return ndarray, ArrayStructRebuilder: The joined array, and the function which can be called to reconstruct the structure from the joined array. """ - nested_type = NestedType.from_data(arrays) - data_list = nested_type.get_leaves(arrays) + meta_object = get_meta_object(arrays) + data_list = get_leaf_values(arrays) + if transform_func is not None: + data_list = [transform_func(d) for d in data_list] split_shapes = [x_.shape for x_ in data_list] - pre_join_shapes = [list(x_.shape[:axis]) + [np.prod(list(x_.shape[axis:]), dtype=int)] for x_ in data_list] - split_axis_ixs = np.cumsum([0]+[s_[-1] for s_ in pre_join_shapes], axis=0) + pre_join_shapes = [list(s[:axis]) + [np.prod(list(s[axis:]), dtype=int)] for s in split_shapes] joined_arr = np.concatenate(list(x_.reshape(s_) for x_, s_ in izip_equal(data_list, pre_join_shapes)), axis=axis) - - def rebuild_function(joined_array, share_data = True): - if share_data: - x_split = [joined_array[..., start:end].reshape(shape) for (start, end, shape) in izip_equal(split_axis_ixs[:-1], split_axis_ixs[1:], split_shapes)] - else: # Note: this will raise an Error if the self.dim != 0, because the data is no longer contigious in memory. - x_split = [joined_array[..., start:end].copy().reshape(shape) for (start, end, shape) in izip_equal(split_axis_ixs[:-1], split_axis_ixs[1:], split_shapes)] - x_reassembled = nested_type.expand_from_leaves(x_split, check_types=False) - return x_reassembled - + rebuild_function = ArrayStructRebuilder(split_shapes=split_shapes, meta_object=meta_object) return joined_arr, rebuild_function diff --git a/artemis/ml/tools/running_averages.py b/artemis/ml/tools/running_averages.py index 3dedaf48..08a890d2 100644 --- a/artemis/ml/tools/running_averages.py +++ b/artemis/ml/tools/running_averages.py @@ -1,5 +1,6 @@ import numpy as np +from artemis.general.global_rates import is_elapsed from artemis.general.global_vars import get_global from artemis.general.mymath import recent_moving_average from artemis.ml.tools.processors import IDifferentiableFunction @@ -156,3 +157,9 @@ def get_global_running_average(value, identifier, ra_type='simple'): """ running_averager = get_global(identifier=identifier, constructor=lambda: (ra_type() if callable(ra_type) else {'simple': RunningAverage, 'recent': RecentRunningAverage, 'osa': OptimalStepSizeAverage}[ra_type]())) return running_averager(value) + + +def periodically_report_running_average(identifier, time, period, value, ra_type = 'simple', format_str = '{identifier}: Average at t={time:.3g}: {avg:.3g} '): + avg = get_global_running_average(value=value, identifier=identifier, ra_type=ra_type) + if is_elapsed(identifier, period=period, current=time): + print(format_str.format(identifier=identifier, time=time, avg=avg)) From c1fa4b610427b9a0c0c96e4e718cc2ec193e2ee2 Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 19 Dec 2018 15:38:07 +0100 Subject: [PATCH 014/107] ooook --- artemis/general/pareto_efficiency.py | 2 +- artemis/general/test_pareto_efficiency.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/artemis/general/pareto_efficiency.py b/artemis/general/pareto_efficiency.py index ff816959..038acf54 100644 --- a/artemis/general/pareto_efficiency.py +++ b/artemis/general/pareto_efficiency.py @@ -32,7 +32,7 @@ def is_pareto_efficient_simple(costs): return is_efficient -# Fastest than is_pareto_efficient, but less readable. +# Faster than is_pareto_efficient_simple, but less readable. def is_pareto_efficient(costs, return_mask = True): """ Find the pareto-efficient points diff --git a/artemis/general/test_pareto_efficiency.py b/artemis/general/test_pareto_efficiency.py index d90f61ac..7f48a60a 100644 --- a/artemis/general/test_pareto_efficiency.py +++ b/artemis/general/test_pareto_efficiency.py @@ -69,8 +69,8 @@ def profile_pareto_efficient(n_points=5000, n_costs=2, include_dumb = True): if __name__ == '__main__': - # test_is_pareto_efficient() - # test_is_pareto_efficient_integer() + test_is_pareto_efficient() + test_is_pareto_efficient_integer() profile_pareto_efficient(n_points=10000, n_costs=2, include_dumb=True) profile_pareto_efficient(n_points=1000000, n_costs=2, include_dumb=False) profile_pareto_efficient(n_points=10000, n_costs=15, include_dumb=True) From f4a2da60a832c6d8b78bcf95d8a3f5496b56504b Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Fri, 21 Dec 2018 15:56:08 +0100 Subject: [PATCH 015/107] before-changing-again --- artemis/general/global_rates.py | 20 ++++--- artemis/general/global_vars.py | 4 ++ artemis/ml/tools/running_averages.py | 29 +++++++--- artemis/plotting/db_plotting.py | 67 ++++++++++++----------- artemis/plotting/point_remapping_plots.py | 39 +++++++++++++ artemis/plotting/pyplot_plus.py | 1 + artemis/plotting/test_db_plotting.py | 14 ++++- 7 files changed, 128 insertions(+), 46 deletions(-) create mode 100644 artemis/plotting/point_remapping_plots.py diff --git a/artemis/general/global_rates.py b/artemis/general/global_rates.py index d8e2d484..de4a8a50 100644 --- a/artemis/general/global_rates.py +++ b/artemis/general/global_rates.py @@ -1,6 +1,6 @@ from contextlib import contextmanager -from artemis.general.global_vars import get_global, set_global +from artemis.general.global_vars import get_global, set_global, has_global import time @@ -44,21 +44,27 @@ class _LastTimeMeasureSingleton: pass -def is_elapsed(identifier, period, current = None): +def is_elapsed(identifier, period, current = None, count_initial = True): """ Return True if the given span has elapsed since this function last returned True :param identifier: A string, or anything identifier :param period: The span which should have elapsed for this to return True again. This is measured in time in seconds if no argument is provided for "current" or for whatever the unit of "current" is otherwise. :param current: Optionally, the current state of progress. If ommitted, this defaults to the current time. + :param count_initial: Count the initial point :return bool: True if first call or at least "span" units of time have elapsed. """ if current is None: current = time.time() key = (_LastTimeMeasureSingleton, identifier) - last = get_global(key, constructor=lambda: -float('inf')) - assert current>=last, f"Current value ({current}) must be greater or equal to the last value ({last})" - has_elapsed = current - last >= period - if has_elapsed: + + if not has_global(key): set_global(key, current) - return has_elapsed + return count_initial + else: + last = get_global(key) + assert current>=last, f"Current value ({current}) must be greater or equal to the last value ({last})" + has_elapsed = current - last >= period + if has_elapsed: + set_global(key, current) + return has_elapsed diff --git a/artemis/general/global_vars.py b/artemis/general/global_vars.py index fed2a38d..6cc51940 100644 --- a/artemis/general/global_vars.py +++ b/artemis/general/global_vars.py @@ -24,6 +24,10 @@ def get_global(identifier, constructor=None): return _GLOBALS[identifier] +def has_global(identifier): + return identifier in _GLOBALS + + def set_global(identifier, value): _GLOBALS[identifier] = value diff --git a/artemis/ml/tools/running_averages.py b/artemis/ml/tools/running_averages.py index 08a890d2..aff7066a 100644 --- a/artemis/ml/tools/running_averages.py +++ b/artemis/ml/tools/running_averages.py @@ -1,7 +1,7 @@ import numpy as np from artemis.general.global_rates import is_elapsed -from artemis.general.global_vars import get_global +from artemis.general.global_vars import get_global, has_global, set_global from artemis.general.mymath import recent_moving_average from artemis.ml.tools.processors import IDifferentiableFunction @@ -147,7 +147,14 @@ def __call__(self, x): _running_averages = {} -def get_global_running_average(value, identifier, ra_type='simple'): +def construct_running_averager(ra_type): + if callable(ra_type): + return ra_type() + else: + return {'simple': RunningAverage, 'recent': RecentRunningAverage, 'osa': OptimalStepSizeAverage}[ra_type]() + + +def get_global_running_average(value, identifier, ra_type='simple', reset=False): """ Get the running average of a variable. :param value: The latest value of the variable @@ -155,11 +162,19 @@ def get_global_running_average(value, identifier, ra_type='simple'): :param ra_type: The type of running averge. Options are 'simple', 'recent', 'osa' :return: The running average """ - running_averager = get_global(identifier=identifier, constructor=lambda: (ra_type() if callable(ra_type) else {'simple': RunningAverage, 'recent': RecentRunningAverage, 'osa': OptimalStepSizeAverage}[ra_type]())) - return running_averager(value) + if not has_global(identifier): + set_global(identifier, construct_running_averager(ra_type)) + running_averager = get_global(identifier=identifier) + avg = running_averager(value) + if reset: + set_global(identifier, construct_running_averager(ra_type)) + return avg + + +def periodically_report_running_average(identifier, time, period, value, ra_type = 'simple', format_str = '{identifier}: Average at t={time:.3g}: {avg:.3g} ', reset_between = False): -def periodically_report_running_average(identifier, time, period, value, ra_type = 'simple', format_str = '{identifier}: Average at t={time:.3g}: {avg:.3g} '): - avg = get_global_running_average(value=value, identifier=identifier, ra_type=ra_type) - if is_elapsed(identifier, period=period, current=time): + report_time = is_elapsed(identifier, period=period, current=time, count_initial=False) + avg = get_global_running_average(value=value, identifier=identifier, ra_type=ra_type, reset=reset_between and report_time) + if report_time: print(format_str.format(identifier=identifier, time=time, avg=avg)) diff --git a/artemis/plotting/db_plotting.py b/artemis/plotting/db_plotting.py index e7ee3a31..06753a90 100644 --- a/artemis/plotting/db_plotting.py +++ b/artemis/plotting/db_plotting.py @@ -84,16 +84,9 @@ def dbplot(data, name = None, plot_type = None, axis=None, plot_mode = 'live', d if data.__class__.__module__ == 'torch' and data.__class__.__name__ == 'Tensor': data = data.detach().cpu().numpy() - if isinstance(fig, plt.Figure): - assert None not in _DBPLOT_FIGURES, "If you pass a figure, you can only do it on the first call to dbplot (for now)" - _DBPLOT_FIGURES[None] = _PlotWindow(figure=fig, subplots=OrderedDict(), axes={}) - fig = None - elif fig not in _DBPLOT_FIGURES or not plt.fignum_exists(_DBPLOT_FIGURES[fig].figure.number): # Second condition handles closed figures. - _DBPLOT_FIGURES[fig] = _PlotWindow(figure = _make_dbplot_figure(), subplots=OrderedDict(), axes = {}) - if fig is not None: - _DBPLOT_FIGURES[fig].figure.canvas.set_window_title(fig) + plot_object = _get_dbplot_plot_object(fig) # type: _PlotWindow - suplot_dict = _DBPLOT_FIGURES[fig].subplots + suplot_dict = plot_object.subplots if axis is None: axis=name @@ -123,51 +116,51 @@ def dbplot(data, name = None, plot_type = None, axis=None, plot_mode = 'live', d ax = axis ax_name = str(axis) elif isinstance(axis, string_types) or axis is None: - ax = select_subplot(axis, fig=_DBPLOT_FIGURES[fig].figure, layout=_default_layout if layout is None else layout) + ax = select_subplot(axis, fig=plot_object.figure, layout=_default_layout if layout is None else layout) ax_name = axis # ax.set_title(axis) else: raise Exception("Axis specifier must be a string, an Axis object, or a SubplotSpec object. Not {}".format(axis)) - if ax_name not in _DBPLOT_FIGURES[fig].axes: + if ax_name not in plot_object.axes: ax.set_title(name) - _DBPLOT_FIGURES[fig].subplots[name] = _Subplot(axis=ax, plot_object=plot) - _DBPLOT_FIGURES[fig].axes[ax_name] = ax + plot_object.subplots[name] = _Subplot(axis=ax, plot_object=plot) + plot_object.axes[ax_name] = ax - _DBPLOT_FIGURES[fig].subplots[name] = _Subplot(axis=_DBPLOT_FIGURES[fig].axes[ax_name], plot_object=plot) - plt.sca(_DBPLOT_FIGURES[fig].axes[ax_name]) + plot_object.subplots[name] = _Subplot(axis=plot_object.axes[ax_name], plot_object=plot) + plt.sca(plot_object.axes[ax_name]) if xlabel is not None: - _DBPLOT_FIGURES[fig].subplots[name].axis.set_xlabel(xlabel) + plot_object.subplots[name].axis.set_xlabel(xlabel) if ylabel is not None: - _DBPLOT_FIGURES[fig].subplots[name].axis.set_ylabel(ylabel) + plot_object.subplots[name].axis.set_ylabel(ylabel) if draw_every is not None: _draw_counters[fig, name] = Checkpoints(draw_every) if grid: plt.grid() - plot = _DBPLOT_FIGURES[fig].subplots[name].plot_object + plot = plot_object.subplots[name].plot_object if reset_color_cycle: - get_dbplot_axis(axis_name=axis, fig=fig).set_color_cycle(None) + use_dbplot_axis(axis, fig=fig, clear=False).set_color_cycle(None) plot.update(data) # Update Labels... if cornertext is not None: - if not hasattr(_DBPLOT_FIGURES[fig].figure, '__cornertext'): - _DBPLOT_FIGURES[fig].figure.__cornertext = next(iter(_DBPLOT_FIGURES[fig].subplots.values())).axis.annotate(cornertext, xy=(0, 0), xytext=(0.01, 0.98), textcoords='figure fraction') + if not hasattr(plot_object.figure, '__cornertext'): + plot_object.figure.__cornertext = next(iter(plot_object.subplots.values())).axis.annotate(cornertext, xy=(0, 0), xytext=(0.01, 0.98), textcoords='figure fraction') else: - _DBPLOT_FIGURES[fig].figure.__cornertext.set_text(cornertext) + plot_object.figure.__cornertext.set_text(cornertext) if title is not None: - _DBPLOT_FIGURES[fig].subplots[name].axis.set_title(title) + plot_object.subplots[name].axis.set_title(title) if legend is not None: - _DBPLOT_FIGURES[fig].subplots[name].axis.legend(legend, loc='best', framealpha=0.5) + plot_object.subplots[name].axis.legend(legend, loc='best', framealpha=0.5) if draw_now and not _hold_plots and (draw_every is None or ((fig, name) not in _draw_counters) or _draw_counters[fig, name]()): plot.plot() - display_figure(_DBPLOT_FIGURES[fig].figure, hang=hang) + display_figure(plot_object.figure, hang=hang) - return _DBPLOT_FIGURES[fig].subplots[name].axis + return plot_object.subplots[name].axis _PlotWindow = namedtuple('PlotWindow', ['figure', 'subplots', 'axes']) @@ -243,6 +236,18 @@ def get_dbplot_figure(name=None): return _DBPLOT_FIGURES[name].figure +def _get_dbplot_plot_object(fig): + if isinstance(fig, plt.Figure): + assert None not in _DBPLOT_FIGURES, "If you pass a figure, you can only do it on the first call to dbplot (for now)" + _DBPLOT_FIGURES[None] = _PlotWindow(figure=fig, subplots=OrderedDict(), axes={}) + fig = None + elif fig not in _DBPLOT_FIGURES or not plt.fignum_exists(_DBPLOT_FIGURES[fig].figure.number): # Second condition handles closed figures. + _DBPLOT_FIGURES[fig] = _PlotWindow(figure = _make_dbplot_figure(), subplots=OrderedDict(), axes = {}) + if fig is not None: + _DBPLOT_FIGURES[fig].figure.canvas.set_window_title(fig) + return _DBPLOT_FIGURES[fig] + + def get_dbplot_subplot(name, fig_name=None): return _DBPLOT_FIGURES[fig_name].subplots[name].axis @@ -328,11 +333,11 @@ def clear_dbplot(fig = None): _DBPLOT_FIGURES[fig].axes.clear() -def get_dbplot_axis(axis_name, fig=None): - """ - Get the named axis of a dbplot. - """ - return _DBPLOT_FIGURES[fig].axes[axis_name] +def use_dbplot_axis(name, fig=None, layout=None, clear = False, ): + ax = select_subplot(name, fig=_get_dbplot_plot_object(fig).figure, layout=_default_layout if layout is None else layout) + if clear: + ax.clear() + return ax def dbplot_hang(timeout=None): diff --git a/artemis/plotting/point_remapping_plots.py b/artemis/plotting/point_remapping_plots.py new file mode 100644 index 00000000..dc054712 --- /dev/null +++ b/artemis/plotting/point_remapping_plots.py @@ -0,0 +1,39 @@ +import numpy as np +from matplotlib import pyplot as plt + + +def get_2d_point_colours(points): + points_norm = (points - points.min(axis=0, keepdims=True)) / (points.max(axis=0, keepdims=True) - points.min(axis=0, keepdims=True)) + return [(y, x, 1-x) for x, y in points_norm] + + +def plot_2D_mapping(old_xy_points, new_xy_points, axes = None, old_title = 'x', new_title = 'f(x)'): + """ + :param old_xy_points: (N,2) array + :param new_xy_points: (Nx2) array + """ + + colours = get_2d_point_colours(old_xy_points) + + ax = plt.subplot(1, 2, 1) if axes is None else axes[0] + ax.scatter(old_xy_points[:, 0], old_xy_points[:, 1], c=colours) + ax.set_title(old_title) + + ax = plt.subplot(1, 2, 2) if axes is None else axes[1] + ax.scatter(new_xy_points[:, 0], new_xy_points[:, 1], c=colours) + ax.set_title(new_title) + + +if __name__ == '__main__': + n_x = 40 + n_y = 30 + + # Generate a grid of points + old_xy_points = np.array([v.flatten() for v in np.meshgrid(np.linspace(0, 1, n_y), np.linspace(0, 1, n_x))]).T + + # Apply some transformation + theta = 5*np.pi/6 + transform_matrix = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]]) + new_xy_points = np.tanh(old_xy_points @ transform_matrix) + + plot_2D_mapping(old_xy_points, new_xy_points) diff --git a/artemis/plotting/pyplot_plus.py b/artemis/plotting/pyplot_plus.py index 43a43e73..3020924a 100644 --- a/artemis/plotting/pyplot_plus.py +++ b/artemis/plotting/pyplot_plus.py @@ -260,6 +260,7 @@ def _get_centered_colour_scale(cmin, cmax): })) + def center_colour_scale(h): current_min, current_max = h.get_clim() absmax = np.maximum(np.abs(current_min), np.abs(current_max)) diff --git a/artemis/plotting/test_db_plotting.py b/artemis/plotting/test_db_plotting.py index b6a8178f..41f8f4c1 100644 --- a/artemis/plotting/test_db_plotting.py +++ b/artemis/plotting/test_db_plotting.py @@ -4,7 +4,7 @@ import numpy as np from artemis.plotting.demo_dbplot import demo_dbplot from artemis.plotting.db_plotting import dbplot, clear_dbplot, hold_dbplots, freeze_all_dbplots, reset_dbplot, \ - dbplot_hang, DBPlotTypes + dbplot_hang, DBPlotTypes, use_dbplot_axis from artemis.plotting.matplotlib_backend import LinePlot, HistogramPlot, MovingPointPlot, is_server_plotting_on, \ ResamplingLineHistory import pytest @@ -213,6 +213,17 @@ def test_bbox_display(): dbplot([10, 20, 25, 30], 'bbox', axis='img', plot_type=DBPlotTypes.BBOX) +def test_inline_custom_plots(): + + for t in range(10): + with hold_dbplots(): + x = np.sin(t/10. + np.linspace(0, 10, 200)) + dbplot(x, 'x', plot_type='line') + use_dbplot_axis('custom', clear=True) + plt.plot(x, label='x', linewidth=2) + plt.plot(x**2, label='$x**2$', linewidth=2) + + if __name__ == '__main__': test_cornertext() test_trajectory_plot() @@ -232,3 +243,4 @@ def test_bbox_display(): test_periodic_plotting() test_individual_periodic_plotting() test_bbox_display() + test_inline_custom_plots() \ No newline at end of file From 1339210873156f2e49994ef7514e300ec171a82e Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Fri, 21 Dec 2018 18:04:06 +0100 Subject: [PATCH 016/107] puuuush --- artemis/ml/tools/running_averages.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/artemis/ml/tools/running_averages.py b/artemis/ml/tools/running_averages.py index aff7066a..0a09705f 100644 --- a/artemis/ml/tools/running_averages.py +++ b/artemis/ml/tools/running_averages.py @@ -172,9 +172,12 @@ def get_global_running_average(value, identifier, ra_type='simple', reset=False) return avg -def periodically_report_running_average(identifier, time, period, value, ra_type = 'simple', format_str = '{identifier}: Average at t={time:.3g}: {avg:.3g} ', reset_between = False): +def periodically_report_running_average(identifier, time, period, value, ra_type = 'simple', format_str = '{identifier}: Average at t={time:.3g}: {avg} ', reset_between = False): report_time = is_elapsed(identifier, period=period, current=time, count_initial=False) - avg = get_global_running_average(value=value, identifier=identifier, ra_type=ra_type, reset=reset_between and report_time) + if not isinstance(value, dict): + avg = get_global_running_average(value=value, identifier=identifier, ra_type=ra_type, reset=reset_between and report_time) + else: + avg = {k: f'{get_global_running_average(value=v, identifier=(identifier, k), ra_type=ra_type, reset=reset_between and report_time):.3g}' for k, v in value.items()} if report_time: print(format_str.format(identifier=identifier, time=time, avg=avg)) From 0962503879d85cf40f9e71f58de36476af507aaa Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 2 Jan 2019 20:17:09 +0100 Subject: [PATCH 017/107] added async dataloaders --- artemis/general/async.py | 66 +++++++++++++++++++++++++++++++++++ artemis/general/test_async.py | 56 +++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 artemis/general/async.py create mode 100644 artemis/general/test_async.py diff --git a/artemis/general/async.py b/artemis/general/async.py new file mode 100644 index 00000000..148fd14f --- /dev/null +++ b/artemis/general/async.py @@ -0,0 +1,66 @@ +from multiprocessing import Process, Queue, Manager, Value, Lock +import time + + +class PoisonPill: + pass + + +def _async_queue_manager(gen_func, queue): + for item in gen_func(): + queue.put(item) + queue.put(PoisonPill) + + +def iter_asynchronously(gen_func): + """ Given a generator function, make it asynchonous. """ + q = Queue() + p = Process(target=_async_queue_manager, args=(gen_func, q)) + p.start() + while True: + item = q.get() + if item is PoisonPill: + break + else: + yield item + + +def _async_value_setter(gen_func, namespace, lock): + for item in gen_func(): + with lock: + namespace.time_and_data = (time.time(), item) + with lock: + namespace.time_and_data = (time.time(), PoisonPill) + + +class Uninitialized: + pass + + +def iter_latest_asynchonously(gen_func, timeout = None, empty_value = None): + """ + Given a generator function, make an iterator that pulls the latest value yielded when running it asynchronously. + If a value has never been set, or timeout is exceeded, yield empty_value instead. + + :param gen_func: A generator function (a function returning a generator); + :return: + """ + m = Manager() + namespace = m.Namespace() + + lock = Lock() + + with lock: + namespace.time_and_data = (-float('inf'), Uninitialized) + + p = Process(target=_async_value_setter, args=(gen_func, namespace, lock)) + p.start() + while True: + with lock: + lasttime, item = namespace.time_and_data + if item is PoisonPill: # The generator has terminated + break + elif item is Uninitialized or timeout is not None and (time.time() - lasttime) > timeout: # Nothing written or nothing recent enough + yield empty_value + else: + yield item diff --git a/artemis/general/test_async.py b/artemis/general/test_async.py new file mode 100644 index 00000000..935d5277 --- /dev/null +++ b/artemis/general/test_async.py @@ -0,0 +1,56 @@ +import time +from functools import partial + +from artemis.general.async import iter_asynchronously, iter_latest_asynchonously + +LOAD_INTERVAL = 0.1 + +# SUM_INTERVAL = LOAD_INTERVAL + PROCESS_INTERVAL + + +def dataloader_example(upto): + + for i in range(upto): + time.sleep(LOAD_INTERVAL) + yield i + + +def test_async_dataloader(): + + process_interval = 0.1 + start = time.time() + for data in dataloader_example(upto=4): + time.sleep(process_interval) + elapsed = time.time()-start + print('Sync Processed Data {} at t={:.3g}: '.format(data, elapsed)) + assert (LOAD_INTERVAL + process_interval)*4 < elapsed < (LOAD_INTERVAL + process_interval)*5 + print('Sync: {:.4g}s elapsed'.format(elapsed)) + + start = time.time() + for data in iter_asynchronously(partial(dataloader_example, upto=4)): + time.sleep(process_interval) + elapsed = time.time()-start + print('Sync Processed Data {} at t={:.3g}: '.format(data, elapsed)) + print('Async: {:.4g}s elapsed'.format(elapsed)) + assert LOAD_INTERVAL + max(LOAD_INTERVAL, process_interval)*4 < elapsed < LOAD_INTERVAL + max(LOAD_INTERVAL, process_interval)*5 + + +def test_async_value_setter(): + + process_interval = 0.25 + + start = time.time() + data_points = [] + for data in iter_latest_asynchonously(gen_func = partial(dataloader_example, upto=10)): + time.sleep(process_interval) + data_points.append(data) + elapsed = time.time()-start + + assert data_points[0] is None + assert all(dn-dp > 1 for dn, dp in zip(data_points[2:], data_points[1:-1])) + print(data_points) + + +if __name__ == '__main__': + test_async_dataloader() + test_async_value_setter() From ba84ebd46f6e06ef3ac10339fcbbfe91ef764bc5 Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 2 Jan 2019 22:29:10 +0100 Subject: [PATCH 018/107] async updates --- artemis/general/async.py | 15 ++++++++++++--- artemis/general/test_async.py | 2 ++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/artemis/general/async.py b/artemis/general/async.py index 148fd14f..36b794e5 100644 --- a/artemis/general/async.py +++ b/artemis/general/async.py @@ -1,4 +1,4 @@ -from multiprocessing import Process, Queue, Manager, Value, Lock +from multiprocessing import Process, Queue, Manager, Lock, set_start_method import time @@ -37,7 +37,7 @@ class Uninitialized: pass -def iter_latest_asynchonously(gen_func, timeout = None, empty_value = None): +def iter_latest_asynchonously(gen_func, timeout = None, empty_value = None, use_forkserver = False, uninitialized_wait = None): """ Given a generator function, make an iterator that pulls the latest value yielded when running it asynchronously. If a value has never been set, or timeout is exceeded, yield empty_value instead. @@ -45,6 +45,9 @@ def iter_latest_asynchonously(gen_func, timeout = None, empty_value = None): :param gen_func: A generator function (a function returning a generator); :return: """ + if use_forkserver: + set_start_method('forkserver') # On macos this is necessary to start camera in separate thread + m = Manager() namespace = m.Namespace() @@ -60,7 +63,13 @@ def iter_latest_asynchonously(gen_func, timeout = None, empty_value = None): lasttime, item = namespace.time_and_data if item is PoisonPill: # The generator has terminated break - elif item is Uninitialized or timeout is not None and (time.time() - lasttime) > timeout: # Nothing written or nothing recent enough + elif item is Uninitialized: + if uninitialized_wait is not None: + time.sleep(uninitialized_wait) + continue + else: + yield empty_value + elif timeout is not None and (time.time() - lasttime) > timeout: # Nothing written or nothing recent enough yield empty_value else: yield item diff --git a/artemis/general/test_async.py b/artemis/general/test_async.py index 935d5277..06656517 100644 --- a/artemis/general/test_async.py +++ b/artemis/general/test_async.py @@ -51,6 +51,8 @@ def test_async_value_setter(): print(data_points) + + if __name__ == '__main__': test_async_dataloader() test_async_value_setter() From 455ebe4286dbf502c7c79a5b63e7c5fc9af0dd45 Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Thu, 3 Jan 2019 12:33:32 +0100 Subject: [PATCH 019/107] rate limiter made --- artemis/general/global_rates.py | 51 ++++++++++++++++++++++++++++ artemis/general/test_global_rates.py | 34 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 artemis/general/test_global_rates.py diff --git a/artemis/general/global_rates.py b/artemis/general/global_rates.py index de4a8a50..97432a79 100644 --- a/artemis/general/global_rates.py +++ b/artemis/general/global_rates.py @@ -44,6 +44,25 @@ class _LastTimeMeasureSingleton: pass +def elapsed_time(identifier, current = None): + """ + Return the time that has elapsed since this function was called with the given identifier. + """ + if current is None: + current = time.time() + key = (_LastTimeMeasureSingleton, identifier) + + if not has_global(key): + set_global(key, current) + return float('inf') + else: + last = get_global(key) + assert current>=last, f"Current value ({current}) must be greater or equal to the last value ({last})" + elapsed = current - last + set_global(key, current) + return elapsed + + def is_elapsed(identifier, period, current = None, count_initial = True): """ Return True if the given span has elapsed since this function last returned True @@ -68,3 +87,35 @@ def is_elapsed(identifier, period, current = None, count_initial = True): if has_elapsed: set_global(key, current) return has_elapsed + + +def limit_rate(identifier, period): + """ + :param identifier: Any python object to uniquely identify what you're limiting. + :param period: The minimum period + :param current: The time measure (if None, system time will be used) + :return: Whether the rate was exceeded (True) or not (False) + """ + + enter_time = time.time() + key = (_LastTimeMeasureSingleton, identifier) + if not has_global(key): # First call + set_global(key, enter_time) + return False + else: + last = get_global(key) + assert enter_time>=last, f"Current value ({current}) must be greater or equal to the last value ({last})" + elapsed = enter_time - last + if elapsed < period: # Rate has been exceeded + time.sleep(period - elapsed) + set_global(key, time.time()) + return False + else: + set_global(key, enter_time) + return True + + +def limit_iteration_rate(iterable, period): + for x in iterable: + limit_rate(id(iterable), period=period) + yield x diff --git a/artemis/general/test_global_rates.py b/artemis/general/test_global_rates.py new file mode 100644 index 00000000..f4e3fcfb --- /dev/null +++ b/artemis/general/test_global_rates.py @@ -0,0 +1,34 @@ +import itertools +import time + +from artemis.general.global_rates import limit_rate, limit_iteration_rate +from artemis.general.global_vars import global_context + + +def test_limit_rate(): + + with global_context(): + start = time.time() + for t in itertools.count(0): + limit_rate('this_rate', period=0.1) + current = time.time() + if current - start > 0.5: + break + print((t, current - start)) + assert t<6 + + +def test_limit_rate_iterator(): + with global_context(): + start = time.time() + for t in limit_iteration_rate(itertools.count(0), period=0.1): + current = time.time() + if current - start > 0.5: + break + print((t, current - start)) + assert t<6 + + +if __name__ == '__main__': + # test_limit_rate() + test_limit_rate_iterator() From eb56b70f839b3ef0b59f4ac10aeffaae8097e976 Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Thu, 3 Jan 2019 12:40:49 +0100 Subject: [PATCH 020/107] oook --- artemis/general/test_global_rates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/artemis/general/test_global_rates.py b/artemis/general/test_global_rates.py index f4e3fcfb..37cd368f 100644 --- a/artemis/general/test_global_rates.py +++ b/artemis/general/test_global_rates.py @@ -30,5 +30,5 @@ def test_limit_rate_iterator(): if __name__ == '__main__': - # test_limit_rate() + test_limit_rate() test_limit_rate_iterator() From 64e7c269b87f2e5b306a0b83afe968dbf3ca7274 Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Fri, 4 Jan 2019 17:25:15 +0100 Subject: [PATCH 021/107] profile improvements --- artemis/general/ezprofile.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/artemis/general/ezprofile.py b/artemis/general/ezprofile.py index cdf853be..eaca4b88 100644 --- a/artemis/general/ezprofile.py +++ b/artemis/general/ezprofile.py @@ -83,11 +83,22 @@ def profile_context(name, print_result = False): def get_profile_contexts(names=None, fill_empty_with_zero = False): - + """ + :param names: Names of profiling contexts to get (from previous calls to profile_context). If None, use all. + :param fill_empty_with_zero: If names are not found, just fill with zeros. + :return: An OrderedDict + """ if names is None: return _profile_contexts else: if fill_empty_with_zero: - return OrderedDict((k, _profile_contexts[k] if k in _profile_contexts else 0) for k in names) + return OrderedDict((k, _profile_contexts[k] if k in _profile_contexts else (0, 0.)) for k in names) else: return OrderedDict((k, _profile_contexts[k]) for k in names) + + +def get_profile_contexts_string(names=None, fill_empty_with_zero = False): + + profile = get_profile_contexts(names=names, fill_empty_with_zero=fill_empty_with_zero) + string = ', '.join(f'{name}: {elapsed/n_calls:.3g}s/iter' for name, (n_calls, elapsed) in profile.items()) + return string From 0a70f5bf5770a15c9486ef8e66f0982c699e973f Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Sat, 5 Jan 2019 14:38:54 +0100 Subject: [PATCH 022/107] compatibility --- artemis/general/ezprofile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/artemis/general/ezprofile.py b/artemis/general/ezprofile.py index eaca4b88..26ea9c86 100644 --- a/artemis/general/ezprofile.py +++ b/artemis/general/ezprofile.py @@ -100,5 +100,5 @@ def get_profile_contexts(names=None, fill_empty_with_zero = False): def get_profile_contexts_string(names=None, fill_empty_with_zero = False): profile = get_profile_contexts(names=names, fill_empty_with_zero=fill_empty_with_zero) - string = ', '.join(f'{name}: {elapsed/n_calls:.3g}s/iter' for name, (n_calls, elapsed) in profile.items()) + string = ', '.join('{}: {:.3g}s/iter'.format(name, elapsed/n_calls) for name, (n_calls, elapsed) in profile.items()) return string From 8e9aa2f36be9a9d6a8cabc0638d5e5a4a8fb2906 Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Thu, 17 Jan 2019 17:43:45 +0100 Subject: [PATCH 023/107] ook --- artemis/general/should_be_builtins.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/artemis/general/should_be_builtins.py b/artemis/general/should_be_builtins.py index 4244c34f..1a1dac20 100644 --- a/artemis/general/should_be_builtins.py +++ b/artemis/general/should_be_builtins.py @@ -150,6 +150,19 @@ def izip_equal(*iterables): yield combo +def adjacent_pairs(iterable): + """ + Given an iterable like ['a', 'b', 'c', 'd'], yield adjacent pairs like [('a', 'b'), ('b', 'c'), ('c', 'd')] + :param iterable: + :return: + """ + iterator = iter(iterable) + last = next(iterator) + for item in iterator: + yield (last, item) + last = item + + def remove_duplicates(sequence, hashable=True, key=None, keep_last=False): """ Remove duplicates while maintaining order. From c97b756af723acb66cd66b4044a64d57b461e7de Mon Sep 17 00:00:00 2001 From: Peter Date: Tue, 22 Jan 2019 18:54:53 +0100 Subject: [PATCH 024/107] oook --- artemis/general/global_rates.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/artemis/general/global_rates.py b/artemis/general/global_rates.py index 97432a79..4128f042 100644 --- a/artemis/general/global_rates.py +++ b/artemis/general/global_rates.py @@ -8,12 +8,14 @@ class _RateMeasureSingleton: pass -def measure_global_rate(name): +def measure_global_rate(name, n_steps = None): this_time = time.time() key = (_RateMeasureSingleton, name) n_calls, start_time = get_global(key, constructor=lambda: (0, this_time)) - set_global(key, (n_calls+1, start_time)) - return n_calls / (this_time - start_time) if this_time!=start_time else float('inf') + if n_steps is None: + n_steps = n_calls + set_global(key, (n_steps+1, start_time)) + return n_steps / (this_time - start_time) if this_time!=start_time else float('inf') class _ElapsedMeasureSingleton: From 792bd8df02c645e1f353101fd5a43fbe0447596d Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Thu, 24 Jan 2019 16:49:14 +0100 Subject: [PATCH 025/107] lilthings --- artemis/general/dead_easy_ui.py | 2 +- artemis/general/should_be_builtins.py | 32 ++++++++++++++++++++++ artemis/general/test_dead_easy_ui.py | 10 +++++++ artemis/general/test_should_be_builtins.py | 26 ++++++++++++++++-- artemis/ml/parameter_schedule.py | 2 +- artemis/ml/tools/iteration.py | 19 ++++++++----- artemis/ml/tools/test_iteration.py | 12 +++++++- 7 files changed, 91 insertions(+), 12 deletions(-) create mode 100644 artemis/general/test_dead_easy_ui.py diff --git a/artemis/general/dead_easy_ui.py b/artemis/general/dead_easy_ui.py index 4285dc95..d415602d 100644 --- a/artemis/general/dead_easy_ui.py +++ b/artemis/general/dead_easy_ui.py @@ -173,7 +173,7 @@ def parse_arg(arg_str): arg_name, arg_val = arg.split('=', 1) kwargs[arg_name] = parse_arg(arg_val) - return func_name, args, kwargs + return func_name, tuple(args), kwargs # if forgive_unquoted_strings: # cmd_args = [cmd_args[0]] + [_quote_args_that_you_forgot_to_quote(arg) for arg in cmd_args[1:]] diff --git a/artemis/general/should_be_builtins.py b/artemis/general/should_be_builtins.py index 1a1dac20..38ecaccc 100644 --- a/artemis/general/should_be_builtins.py +++ b/artemis/general/should_be_builtins.py @@ -514,3 +514,35 @@ def natural_keys(text): (See Toothy's implementation in the comments) """ return tuple(atoi(c) for c in re.split('(\d+)', text)) + + +class switch: + """ + A switch statement, made by Ian Bell at https://stackoverflow.com/a/30012053/851699 + + Usage: + + with switch(name) as case: + if case('bob', 'nancy'): + print("Come in, you're on the guest list") + elif case('drew'): + print("Sorry, after last time we can't let you in") + else: + print("Sorry, {}, we can't let you in.".format(case.value)) + """ + + def __init__(self, value): + self._val = value + + @property + def value(self): + return self._val + + def __enter__(self): + return self + + def __exit__(self, type, value, traceback): + return False # Allows traceback to occur + + def __call__(self, *mconds): + return self._val in mconds diff --git a/artemis/general/test_dead_easy_ui.py b/artemis/general/test_dead_easy_ui.py new file mode 100644 index 00000000..3eff71d2 --- /dev/null +++ b/artemis/general/test_dead_easy_ui.py @@ -0,0 +1,10 @@ +from artemis.general.dead_easy_ui import parse_user_function_call + + +def test_parse_user_function_call(): + + assert parse_user_function_call("myfunc 1 a 'a b' c=3 ddd=[2,3] ee='abc'") == ("myfunc", (1, 'a', 'a b'), dict(c=3, ddd=[2, 3], ee='abc')) + + +if __name__ == '__main__': + test_parse_user_function_call() diff --git a/artemis/general/test_should_be_builtins.py b/artemis/general/test_should_be_builtins.py index d637ecb7..83171082 100644 --- a/artemis/general/test_should_be_builtins.py +++ b/artemis/general/test_should_be_builtins.py @@ -4,7 +4,7 @@ from artemis.general.should_be_builtins import itermap, reducemap, separate_common_items, remove_duplicates, \ detect_duplicates, remove_common_prefix, all_equal, get_absolute_module, insert_at, get_shifted_key_value, \ - divide_into_subsets, entries_to_table, natural_keys + divide_into_subsets, entries_to_table, natural_keys, switch __author__ = 'peter' @@ -127,6 +127,27 @@ def test_natural_keys(): assert sorted(['y8', 'x10', 'x2', 'y12', 'x9'], key=natural_keys) == ['x2', 'x9', 'x10', 'y8', 'y12'] +def test_switch_statement(): + + responses = [] + for name in ['nancy', 'joe', 'bob', 'drew']: + with switch(name) as case: + if case('bob', 'nancy'): + response = "Come in, you're on the guest list" + elif case('drew'): + response = "Sorry, after what happened last time we can't let you in" + else: + response = "Sorry, {}, we can't let you in.".format(case.value) + responses.append(response) + + assert responses == [ + "Come in, you're on the guest list", + "Sorry, joe, we can't let you in.", + "Come in, you're on the guest list", + "Sorry, after what happened last time we can't let you in" + ] + + if __name__ == '__main__': test_separate_common_items() test_reducemap() @@ -140,4 +161,5 @@ def test_natural_keys(): test_get_shifted_key_value() test_divide_into_subsets() test_entries_to_table() - test_natural_keys() \ No newline at end of file + test_natural_keys() + test_switch_statement() diff --git a/artemis/ml/parameter_schedule.py b/artemis/ml/parameter_schedule.py index 0fec2b65..9ee9ef0a 100644 --- a/artemis/ml/parameter_schedule.py +++ b/artemis/ml/parameter_schedule.py @@ -17,7 +17,7 @@ def __init__(self, schedule, print_variable_name = None): - A function which takes the epoch and returns a parameter value. - A number or array, in which case the value remains constant """ - if isinstance(schedule, (int, float, np.ndarray)): + if isinstance(schedule, (int, float, np.ndarray, str)): schedule = {0: schedule} if isinstance(schedule, dict): assert all(isinstance(num, (int, float)) for num in schedule.keys()) diff --git a/artemis/ml/tools/iteration.py b/artemis/ml/tools/iteration.py index 6ba00c2b..72f0ff50 100644 --- a/artemis/ml/tools/iteration.py +++ b/artemis/ml/tools/iteration.py @@ -294,7 +294,7 @@ def batchify_generator(generator_generator, batch_size = None, receive_input=Fal -----------vid-2-----------|--------vid-6---------| -----vid-3-------|----------vid-4------------------ - generator_genererator yields 7 generators, corresponding to each of the movies. + generator_genererator yields 7 generators, corresponding to each of the videos. Each of those generators is a frame-generator, which produces the frames in a given video. Here, we generate frames from each movie, and start a new movies whenever an old one stops, until there are no new movies to start. @@ -307,17 +307,23 @@ def batchify_generator(generator_generator, batch_size = None, receive_input=Fal """ assert receive_input in (False, 'post'), 'pre-receive not yet implemented' - total = batch_size - assert out_format in ('array', 'tuple_of_arrays') + # if isinstance(generator_generator, (list, tuple)): + # generator_generator = iter(generator_generator) + + # generators is a list of currently active generators + # generator_generator is a generator which yields new generators to be swapped into generators when the old ones get used up. if batch_size is not None: - generators = [next(generator_generator) for _ in range(batch_size)] + if isinstance(generator_generator, (list, tuple)): + generator_generator = iter(generator_generator) + generators = [iter(next(generator_generator)) for _ in range(batch_size)] + else: assert isinstance(generator_generator, (list, tuple)), "If you don't specify a batch size your generator-generator must be a finite list." batch_size = len(generator_generator) - generators = generator_generator generator_generator = iter(generator_generator) + generators = [iter(gen) for gen in generator_generator] while True: items = [] @@ -327,8 +333,7 @@ def batchify_generator(generator_generator, batch_size = None, receive_input=Fal items.append(next(generators[i])) break except StopIteration: - total+=1 - generators[i] = next(generator_generator) # This will rais StopIteration when we're out of generators + generators[i] = iter(next(generator_generator)) # This will raise StopIteration when we're out of generators if out_format=='array': output= np.array(items) diff --git a/artemis/ml/tools/test_iteration.py b/artemis/ml/tools/test_iteration.py index 094b2f55..b096a524 100644 --- a/artemis/ml/tools/test_iteration.py +++ b/artemis/ml/tools/test_iteration.py @@ -1,5 +1,5 @@ from artemis.ml.tools.iteration import minibatch_index_generator, checkpoint_minibatch_index_generator, \ - zip_minibatch_iterate_info, minibatch_process + zip_minibatch_iterate_info, minibatch_process, batchify_generator __author__ = 'peter' import numpy as np @@ -113,9 +113,19 @@ def func(x): assert np.allclose(y1, y2) # weird numpy rounding makes it not exactly equal +def test_batchify_generator(): + + a = [x for x in batchify_generator(batch_size=2, generator_generator=[[1, 2, 3], [4, 5], [6, 7, 8], [9, 10, 11, 12]])] + assert np.array_equal(a, [[1, 4], [2, 5], [3, 6], [9, 7], [10, 8]]) + + a = [x for x in batchify_generator(batch_size=None, generator_generator=[[1, 2, 3], [4, 5], [6, 7, 8], [9, 10, 11, 12]])] + assert np.array_equal(a, [[1, 4, 6, 9], [2, 5, 7, 10]]) + + if __name__ == '__main__': test_minibatch_index_even() test_minibatch_process() test_minibatch_iterate_info() test_minibatch_index_generator() test_checkpoint_minibatch_generator() + test_batchify_generator() From 13ba79f94f48a96b9057fba8263977cc0c2dd62a Mon Sep 17 00:00:00 2001 From: O'Connor Date: Mon, 28 Jan 2019 18:38:27 +0100 Subject: [PATCH 026/107] windowsfix --- artemis/fileman/local_dir.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/artemis/fileman/local_dir.py b/artemis/fileman/local_dir.py index 2953048c..a01ee537 100644 --- a/artemis/fileman/local_dir.py +++ b/artemis/fileman/local_dir.py @@ -3,6 +3,7 @@ from artemis.config import get_artemis_config_value import os from six.moves import xrange +from os.path import expanduser __author__ = 'peter' @@ -19,7 +20,7 @@ def get_default_local_path(): - return os.path.join(os.getenv("HOME"), '.artemis') + return os.path.join(expanduser("~"), '.artemis') LOCAL_DIR = get_artemis_config_value(section='fileman', option='data_dir', default_generator = get_default_local_path, write_default = True) From 4db00489bd511810d68afadcfd254ba13484a57b Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Wed, 13 Feb 2019 18:32:04 -0900 Subject: [PATCH 027/107] parameter_search improvements --- artemis/experiments/experiments.py | 23 ++++++++++++++++++----- artemis/general/functional.py | 7 +++++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/artemis/experiments/experiments.py b/artemis/experiments/experiments.py index b6862744..c803f786 100644 --- a/artemis/experiments/experiments.py +++ b/artemis/experiments/experiments.py @@ -381,7 +381,10 @@ def get_latest_record(self, only_completed=False, if_none = 'skip'): Return the ExperimentRecord from the latest run of this Experiment. :param only_completed: Only search among records of that have run to completion. - :param err_if_none: If True, raise an error if no record exists. Otherwise, just return None in this case. + :param if_none: What to do if no record exists. Options are: + 'skip': Return None + 'err': Raise an exception + 'run': Run the experiment to get the record :return ExperimentRecord: An ExperimentRecord object """ assert if_none in ('skip', 'err', 'run') @@ -421,9 +424,10 @@ def get_variant_records(self, only_completed=False, only_last=False, flat=False) else: return exp_record_dict - def add_parameter_search(self, name='parameter_search', space = None, n_calls=100, search_params = None, scalar_func=None): + def add_parameter_search(self, name='parameter_search', fixed_args = {}, space = None, n_calls=100, search_params = None, scalar_func=None): """ :param name: Name of the Experiment to be created + :param dict[str, Any] fixed_args: Any fixed-arguments to provide to all experiments. :param dict[str, skopt.space.Dimension] space: A dict mapping param name to Dimension. e.g. space=dict(a = Real(1, 100, 'log-uniform'), b = Real(1, 100, 'log-uniform')) :param Callable[[Any], float] scalar_func: Takes the return value of the experiment and turns it into a scalar @@ -446,18 +450,27 @@ def objective(**current_params): from artemis.experiments import ExperimentFunction - @ExperimentFunction(name = self.name + '.'+ name, show = show_parameter_search_record, one_liner_function=parameter_search_one_liner) - def search_exp(): + def search_func(fixed): if is_test_mode(): nonlocal n_calls n_calls = 3 # When just verifying that experiment runs, do the minimum - for iter_info in parameter_search(objective, n_calls=n_calls, space=space, **search_params): + this_objective = partial(objective, **fixed) + + for iter_info in parameter_search(this_objective, n_calls=n_calls, space=space, **search_params): info = dict(names=list(space.keys()), x_iters =iter_info.x_iters, func_vals=iter_info.func_vals, score = iter_info.func_vals, x=iter_info.x, fun=iter_info.fun) latest_info = {name: val for name, val in izip_equal(info['names'], iter_info.x_iters[-1])} print(f'Latest: {latest_info}, Score: {iter_info.func_vals[-1]:.3g}') yield info + # The following is a hack to dynamically create a function with the given args + # arg_string = ', '.join('{}={}'.format(k, v) for k, v in fixed_args.items()) + # param_search = None + # exec('global param_search\ndef func({fixed}): search_func(fixed_args=dict({fixed})); param_search=func'.format(fixed=arg_string)) + # param_search = locals()['param_search'] + search_exp_func = partial(search_func, fixed=fixed_args) # We do this so that the fixed parameters will be recorded and we will see if they changed. + + search_exp = ExperimentFunction(name = self.name + '.'+ name, show = show_parameter_search_record, one_liner_function=parameter_search_one_liner)(search_exp_func) self.variants[name] = search_exp search_exp.tag('psearch') # Secret feature that makes it easy to select all parameter experiments in ui with "filter tag:psearch" return search_exp diff --git a/artemis/general/functional.py b/artemis/general/functional.py index a1660c99..3acc6b97 100644 --- a/artemis/general/functional.py +++ b/artemis/general/functional.py @@ -185,9 +185,12 @@ def get_defined_and_undefined_args(func): """ undefined_arg_names, varargs_name, kwargs_name, defined_args = advanced_getargspec(func) assert varargs_name is None - assert kwargs_name is None for k in defined_args.keys(): - undefined_arg_names.remove(k) + if kwargs_name is None: # If the function does not have **kwargs + undefined_arg_names.remove(k) + else: + if k in undefined_arg_names: + undefined_arg_names.remove(k) return defined_args, undefined_arg_names From fd82c625eac5751925c9349af24a91c21e52140f Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Fri, 15 Feb 2019 15:31:42 -0900 Subject: [PATCH 028/107] stuuuff --- artemis/experiments/experiment_management.py | 28 ++++++++++ artemis/plotting/range_plots.py | 57 ++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 artemis/plotting/range_plots.py diff --git a/artemis/experiments/experiment_management.py b/artemis/experiments/experiment_management.py index 12ac236e..b6e5ce90 100644 --- a/artemis/experiments/experiment_management.py +++ b/artemis/experiments/experiment_management.py @@ -545,6 +545,34 @@ def run_multiple_experiments(experiments, prefixes = None, parallel = False, dis return [ex.run(raise_exceptions=raise_exceptions, display_results=display_results, notes=notes, **run_args) for ex in experiments] +def get_multiple_records(experiment, n, only_completed=True, if_not_enough='run'): + """ + Get n records from a single experiment. + :param Experiment experiment: The experiment + :param int n: Number of records to get + :param only_completed: True if you only want completed records + :param if_not_enough: What to do if there are not enough records ready. + 'run': Run more + 'cut': Just return the number that are already calculated + 'err': Raise an excepetion + :return: + """ + if isinstance(experiment, str): + experiment = load_experiment(experiment) + assert if_not_enough in ('run', 'cut', 'err') + records = experiment.get_records(only_completed=only_completed) + if if_not_enough == 'err': + assert len(records) >= n, "You asked for {} records, but only {} were available".format(n, len(records)) + return records[-n:] + elif if_not_enough=='run': + for k in range(n-len(records)): + record = experiment.run() + records.append(record) + return records[-n:] + else: + return records + + def remove_common_results_prefix(results_dict): """ Remove the common prefix for the results you are comparing. diff --git a/artemis/plotting/range_plots.py b/artemis/plotting/range_plots.py new file mode 100644 index 00000000..6b370d45 --- /dev/null +++ b/artemis/plotting/range_plots.py @@ -0,0 +1,57 @@ +from matplotlib import pyplot as plt +import numpy as np + + +def plot_sample_mean_and_var(*x_and_ys, var_rep ='std', fill_alpha = 0.25, **plot_kwargs): + """ + Given a collection of signals, plot their mean and fill a range around the mean. Example: + x = np.arange(-5, 5) + ys = np.random.randn(20, len(x_data)) + x ** 2 - 2 + plot_sample_mean_and_var(x, ys, var_rep='std') + :param x_and_ys: You can provide either x and the y-signals or just the y-signals + :param var_rep: How to represent the variance. Options are: + 'std': Standard Deviation + 'sterr': Standard Error of the Mean + 'lim': Min/max + :param fill_alpha: + :param plot_kwargs: + :return: + """ + if len(x_and_ys)==2: + x, ys = x_and_ys + else: + assert len(x_and_ys) == 1, "You must provide unnamed arguments in order (ys) or (x, ys)" + ys, = x_and_ys + x = range(len(ys[0])) + + assert var_rep in ('std', 'sterr', 'lim') + + mean = np.mean(ys, axis=0) + + if var_rep == 'std': + std = np.std(ys, axis=0) + lower, upper = mean-std, mean+std + elif var_rep == 'sterr': + sterr = np.std(ys, axis=0)/np.sqrt(len(ys)) + lower, upper = mean-sterr, mean+sterr + elif var_rep == 'lim': + lower, upper = np.min(ys, axis=0), np.max(ys, axis=0) + else: + raise NotImplementedError(var_rep) + + mean_handel, = plt.plot(x, mean, **plot_kwargs) + fill_handle = plt.fill_between(x, lower, upper, color=mean_handel.get_color(), alpha=fill_alpha) + return mean_handel, fill_handle + + +if __name__ == '__main__': + x_data = np.arange(-5, 5) + ys1 = np.random.randn(20, len(x_data)) + x_data ** 2 - 2 + plot_sample_mean_and_var(x_data, ys1, var_rep='std') + + ys2 = np.random.randn(20, len(x_data)) + .9 * x_data ** 2 - 2 + plot_sample_mean_and_var(x_data, ys2, var_rep='std') + + ys3 = np.random.randn(20, len(x_data)) + .7 * x_data ** 2 - 2 + plot_sample_mean_and_var(x_data, ys3, var_rep='std') + plt.show() From 50c6c44178a3ac03371a437f84a75b4a770f50d1 Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Fri, 15 Feb 2019 19:25:28 -0900 Subject: [PATCH 029/107] preclean --- artemis/experiments/experiment_record_view.py | 26 +++ artemis/plotting/parallel_coords_plots.py | 160 ++++++++++++++++++ artemis/plotting/pyplot_plus.py | 8 +- 3 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 artemis/plotting/parallel_coords_plots.py diff --git a/artemis/experiments/experiment_record_view.py b/artemis/experiments/experiment_record_view.py index 7da1c8bb..2b45153e 100644 --- a/artemis/experiments/experiment_record_view.py +++ b/artemis/experiments/experiment_record_view.py @@ -2,6 +2,7 @@ from collections import OrderedDict from functools import partial import itertools + from six import string_types from tabulate import tabulate import numpy as np @@ -16,6 +17,9 @@ from artemis.general.tables import build_table import os +from artemis.plotting.parallel_coords_plots import plot_hyperparameter_search_parallel_coords +from artemis.plotting.pyplot_plus import get_color_cycle_map + def get_record_result_string(record, func='deep', truncate_to = None, array_print_threshold=8, array_float_format='.3g', oneline=False, default_one_liner_func=str): """ @@ -581,3 +585,25 @@ def queryfig(): print('Use Left/Right arrows to navigate, ') show_figure(nonlocals.figno) + + +def plot_hyperparameter_search(record, relabel = None, show_order_first = True, show_score_last = True, score_name='score'): + """ + Create a parallel coordinates plot representing a hyperparameter search experiment record. + :param record: + :param show_order_first: + :param show_score_last: + :param score_name: + :return: + """ + result = record.get_result() + + assert {'names', 'x_iters', 'func_vals'}.issubset(result.keys()), "Record {} does not appear to be from a Parameter Search experiment!".format(record) + + names, x_iters, func_vals = result['names'], result['x_iters'], result['func_vals'] + + if relabel is not None: + assert set(relabel.keys()).issubset(names), 'Not all relabeling keys {} were found in names {}'.format(list(relabel.keys()), list(names)) + names = [relabel[n] if n in relabel else n for n in names] + + return plot_hyperparameter_search_parallel_coords(field_names=list(names), x_iters=x_iters, func_vals=func_vals, show_iter_first=show_order_first, show_score_last=show_score_last, score_name=score_name) diff --git a/artemis/plotting/parallel_coords_plots.py b/artemis/plotting/parallel_coords_plots.py new file mode 100644 index 00000000..3cfca020 --- /dev/null +++ b/artemis/plotting/parallel_coords_plots.py @@ -0,0 +1,160 @@ + +import matplotlib +from matplotlib import pyplot as plt +import numpy as np +from artemis.general.should_be_builtins import izip_equal, bad_value +# +# +# def parallel_coords_plot(field_names, values, color_field = None): +# """ +# Create a Parallel coordinates plot. +# Code lifted and modified from http://benalexkeen.com/parallel-coordinates-in-matplotlib/ +# +# :param field_names: A list of (n_fields) field names +# :param values: A (n_fields, n_samples) array of values. +# :return: +# """ +# +# n_fields, n_samples = values.shape +# df = {name: row for name, row in izip_equal(field_names, values)} +# +# from matplotlib import ticker +# +# assert len(field_names)==len(values), 'The number of field names must equal the number of rows in values.' +# +# # field_names = ['displacement', 'cylinders', 'horsepower', 'weight', 'acceleration'] +# x = [i for i, _ in enumerate(field_names)] +# # colours = ['#2e8ad8', '#cd3785', '#c64c00', '#889a00'] +# +# # create dict of categories: colours +# # colours = {df['mpg'].cat.categories[i]: colours[i] for i, _ in enumerate(df['mpg'].cat.categories)} +# +# # Create (X-1) sublots along x axis +# fig, axes = plt.subplots(1, len(x)-1, sharey=False, figsize=(15,5)) +# +# # Get min, max and range for each column +# # Normalize the data for each column +# min_max_range = {} +# for col in field_names: +# min_max_range[col] = [np.min(df[col]), np.max(df[col]), np.ptp(df[col])] +# # df[col] = np.true_divide(df[col] - np.min(df[col]), np.ptp(df[col])) +# values = (values-np.min(values, axis=1, keepdims=True)) / (np.max(values, axis=1, keepdims=True)-np.min(values, axis=1, keepdims=True)) +# +# # Plot each row +# for i, ax in enumerate(axes): +# for idx in range(n_samples): +# +# # ax.plot(df[]) +# +# # mpg_category = df.loc[idx, 'mpg'] +# +# # ax.plot(x, df.loc[idx, field_names], colours[mpg_category]) +# ax.plot(x, values[:, idx]) +# ax.set_xlim([x[i], x[i+1]]) +# +# # Set the tick positions and labels on y axis for each plot +# # Tick positions based on normalised data +# # Tick labels are based on original data +# def set_ticks_for_axis(dim, ax, ticks): +# min_val, max_val, val_range = min_max_range[field_names[dim]] +# step = val_range / float(ticks-1) +# tick_labels = [round(min_val + step * i, 2) for i in range(ticks)] +# norm_min = df[field_names[dim]].min() +# norm_range = np.ptp(df[field_names[dim]]) +# norm_step = norm_range / float(ticks-1) +# ticks = [round(norm_min + norm_step * i, 2) for i in range(ticks)] +# ax.yaxis.set_ticks(ticks) +# ax.set_yticklabels(tick_labels) +# +# for dim, ax in enumerate(axes): +# ax.xaxis.set_major_locator(ticker.FixedLocator([dim])) +# set_ticks_for_axis(dim, ax, ticks=6) +# ax.set_xticklabels([field_names[dim]]) +# +# +# # Move the final axis' ticks to the right-hand side +# ax = plt.twinx(axes[-1]) +# dim = len(axes) +# ax.xaxis.set_major_locator(ticker.FixedLocator([x[-2], x[-1]])) +# set_ticks_for_axis(dim, ax, ticks=6) +# ax.set_xticklabels([field_names[-2], field_names[-1]]) +# +# +# # Remove space between subplots +# plt.subplots_adjust(wspace=0) + + # Add legend to plot + # plt.legend( + # [plt.Line2D((0,1),(0,0), color=colours[cat]) for cat in df['mpg'].cat.categories], + # df['mpg'].cat.categories, + # bbox_to_anchor=(1.2, 1), loc=2, borderaxespad=0.) +# +# +# # plt.title("Values of car attributes by MPG category") +# + # plt.show() +from artemis.plotting.pyplot_plus import axhlines + +def draw_norm_y_axis(x_position, lims, scale='lin', axis_thickness=2, n_intermediates=3, tickwidth=0.1, axiscolor='k'): + """ + Draw a y-axis in a Parallel Coordinates plot + """ + assert scale=='lin', 'For now' + lower, upper = lims + line = plt.axvline(x=x_position, ymin=0, ymax=1, linewidth=axis_thickness, color=axiscolor) + y_axisticks = np.linspace(0, 1, n_intermediates+2) + y_labels = ['{:.2g}'.format(y*(upper-lower)+lower) for y in y_axisticks] + h_ticklabels = [plt.text(x=x_position+tickwidth/2., y=y, s=ylab, color='k', bbox=dict(boxstyle="square", fc=(1., 1., 1., 0.5), ec=(0, 0, 0, 0.))) for y, ylab in izip_equal(y_axisticks, y_labels)] + h_ticks = axhlines(ys = y_axisticks, lims=(x_position-tickwidth/2., x_position+tickwidth/2.), linewidth=axis_thickness, color=axiscolor, zorder=4) + return line, h_ticks, h_ticklabels + + +def parallel_coords_plot(field_names, values, scales = {}, ax=None): + """ + Create a Parallel coordinates plot. + + :param field_names: A list of (n_fields) field names + :param values: A (n_fields, n_samples) array of values. + :return: A list of handles to the plot objectss + """ + + assert set(scales.keys()).issubset(field_names), 'All scales must be in field names.' + assert len(field_names) == len(values) + if ax is None: + ax = plt.gca() + v_min, v_max = np.min(values, axis=1, keepdims=True), np.max(values, axis=1, keepdims=True) + + norm_lines = (values-v_min) / (v_max-v_min) + + cmap = matplotlib.cm.get_cmap('Spectral') + hs = [plt.plot(line, color=cmap(line[-1]))[0] for i, line in enumerate(norm_lines.T)] + + for i, f in enumerate(field_names): + draw_norm_y_axis(x_position=i, lims=(v_min[i, 0], v_max[i, 0]), scale = 'lin' if f not in scales else scales[f]) + + ax.set_xticks(range(len(field_names))) + ax.set_xticklabels(field_names) + + ax.tick_params(axis='y', labelleft='off') + ax.set_yticks([]) + # ax.set_yticklabels([]) + ax.set_xlim(0, len(field_names)-1) + + return hs + + +def plot_hyperparameter_search_parallel_coords(field_names, x_iters, func_vals, show_iter_first = True, show_score_last = True, iter_name='iter', score_name='score'): + """ + Visualize the result of a hyperparameter search using a Parallel Coordinates plot + :param field_names: A (n_hyperparameters) list of names of the hyperparameters + :param x_iters: A (n_steps, n_hyperparameters) list of hyperparameter values + :param func_vals: A (n_hyperparameters) list of scores computed for each value + :param show_iter_first: Insert "iter" (the interation index in the search) as a first column to the plot + :param show_score_last: Insert "score" as a last column to the plot + :param iter_name: Name of the "iter" field + :param score_name: Name of the "score" field. + :return: A list of plot handels + """ + field_names = ([iter_name] if show_iter_first else []) + list(field_names) + ([score_name] if show_score_last else []) + lines = [([i] if show_iter_first else []) + list(params) + ([val] if show_score_last else []) for i, (params, val) in enumerate(izip_equal(x_iters, func_vals))] + return parallel_coords_plot(field_names=field_names, values=np.array(lines).T) diff --git a/artemis/plotting/pyplot_plus.py b/artemis/plotting/pyplot_plus.py index 3020924a..8864dce9 100644 --- a/artemis/plotting/pyplot_plus.py +++ b/artemis/plotting/pyplot_plus.py @@ -15,7 +15,7 @@ """ -def axhlines(ys, ax=None, **plot_kwargs): +def axhlines(ys, lims=None, ax=None, **plot_kwargs): """ Draw horizontal lines across plot :param ys: A scalar, list, or 1D array of vertical offsets @@ -26,14 +26,14 @@ def axhlines(ys, ax=None, **plot_kwargs): if ax is None: ax = plt.gca() ys = np.array((ys, ) if np.isscalar(ys) else ys, copy=False) - lims = ax.get_xlim() + lims = ax.get_xlim() if lims is None else lims y_points = np.repeat(ys[:, None], repeats=3, axis=1).flatten() x_points = np.repeat(np.array(lims + (np.nan, ))[None, :], repeats=len(ys), axis=0).flatten() plot = ax.plot(x_points, y_points, scalex = False, **plot_kwargs) return plot -def axvlines(xs, ax=None, **plot_kwargs): +def axvlines(xs, lims=None, ax=None, **plot_kwargs): """ Draw vertical lines on plot :param xs: A scalar, list, or 1D array of horizontal offsets @@ -44,7 +44,7 @@ def axvlines(xs, ax=None, **plot_kwargs): if ax is None: ax = plt.gca() xs = np.array((xs, ) if np.isscalar(xs) else xs, copy=False) - lims = ax.get_ylim() + lims = ax.get_ylim() if lims is None else lims x_points = np.repeat(xs[:, None], repeats=3, axis=1).flatten() y_points = np.repeat(np.array(lims + (np.nan, ))[None, :], repeats=len(xs), axis=0).flatten() plot = ax.plot(x_points, y_points, scaley = False, **plot_kwargs) From 4bf2fe0cd390219c2399e2256c8fbc1aff16fa85 Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Sat, 16 Feb 2019 16:12:50 -0900 Subject: [PATCH 030/107] cleaned up parallel coords plot --- .gitignore | 2 +- artemis/experiments/experiment_record_view.py | 23 +-- artemis/plotting/parallel_coords_plots.py | 186 +++++++----------- 3 files changed, 83 insertions(+), 128 deletions(-) diff --git a/.gitignore b/.gitignore index 6e998fcc..e4150e3e 100644 --- a/.gitignore +++ b/.gitignore @@ -71,4 +71,4 @@ venv/ # Files /Data /docs/build - +.pytest_cache diff --git a/artemis/experiments/experiment_record_view.py b/artemis/experiments/experiment_record_view.py index 2b45153e..322c0882 100644 --- a/artemis/experiments/experiment_record_view.py +++ b/artemis/experiments/experiment_record_view.py @@ -587,23 +587,18 @@ def queryfig(): show_figure(nonlocals.figno) -def plot_hyperparameter_search(record, relabel = None, show_order_first = True, show_score_last = True, score_name='score'): +def plot_hyperparameter_search(record, relabel = None, assert_all_relabels_used = True, **hypersearch_parallel_kwargs): """ Create a parallel coordinates plot representing a hyperparameter search experiment record. - :param record: - :param show_order_first: - :param show_score_last: - :param score_name: - :return: + :param ExperimentRecord record: An experiment record object + :param hypersearch_parallel_kwargs: See plot_hyperparameter_search_parallel_coords + :return: A bunch of plot handels """ result = record.get_result() - - assert {'names', 'x_iters', 'func_vals'}.issubset(result.keys()), "Record {} does not appear to be from a Parameter Search experiment!".format(record) - - names, x_iters, func_vals = result['names'], result['x_iters'], result['func_vals'] - + assert {'names', 'x_iters', 'func_vals', 'x'}.issubset(result.keys()), "Record {} does not appear to be from a Parameter Search experiment!".format(record) + names, x_iters, func_vals, x = result['names'], result['x_iters'], result['func_vals'], result['x'] if relabel is not None: - assert set(relabel.keys()).issubset(names), 'Not all relabeling keys {} were found in names {}'.format(list(relabel.keys()), list(names)) + if assert_all_relabels_used: + assert set(relabel.keys()).issubset(names), 'Not all relabeling keys {} were found in names {}'.format(list(relabel.keys()), list(names)) names = [relabel[n] if n in relabel else n for n in names] - - return plot_hyperparameter_search_parallel_coords(field_names=list(names), x_iters=x_iters, func_vals=func_vals, show_iter_first=show_order_first, show_score_last=show_score_last, score_name=score_name) + return plot_hyperparameter_search_parallel_coords(field_names=list(names), param_sequence=x_iters, func_vals=func_vals, final_params=x, **hypersearch_parallel_kwargs) diff --git a/artemis/plotting/parallel_coords_plots.py b/artemis/plotting/parallel_coords_plots.py index 3cfca020..8e88e302 100644 --- a/artemis/plotting/parallel_coords_plots.py +++ b/artemis/plotting/parallel_coords_plots.py @@ -1,154 +1,107 @@ import matplotlib -from matplotlib import pyplot as plt import numpy as np -from artemis.general.should_be_builtins import izip_equal, bad_value -# -# -# def parallel_coords_plot(field_names, values, color_field = None): -# """ -# Create a Parallel coordinates plot. -# Code lifted and modified from http://benalexkeen.com/parallel-coordinates-in-matplotlib/ -# -# :param field_names: A list of (n_fields) field names -# :param values: A (n_fields, n_samples) array of values. -# :return: -# """ -# -# n_fields, n_samples = values.shape -# df = {name: row for name, row in izip_equal(field_names, values)} -# -# from matplotlib import ticker -# -# assert len(field_names)==len(values), 'The number of field names must equal the number of rows in values.' -# -# # field_names = ['displacement', 'cylinders', 'horsepower', 'weight', 'acceleration'] -# x = [i for i, _ in enumerate(field_names)] -# # colours = ['#2e8ad8', '#cd3785', '#c64c00', '#889a00'] -# -# # create dict of categories: colours -# # colours = {df['mpg'].cat.categories[i]: colours[i] for i, _ in enumerate(df['mpg'].cat.categories)} -# -# # Create (X-1) sublots along x axis -# fig, axes = plt.subplots(1, len(x)-1, sharey=False, figsize=(15,5)) -# -# # Get min, max and range for each column -# # Normalize the data for each column -# min_max_range = {} -# for col in field_names: -# min_max_range[col] = [np.min(df[col]), np.max(df[col]), np.ptp(df[col])] -# # df[col] = np.true_divide(df[col] - np.min(df[col]), np.ptp(df[col])) -# values = (values-np.min(values, axis=1, keepdims=True)) / (np.max(values, axis=1, keepdims=True)-np.min(values, axis=1, keepdims=True)) -# -# # Plot each row -# for i, ax in enumerate(axes): -# for idx in range(n_samples): -# -# # ax.plot(df[]) -# -# # mpg_category = df.loc[idx, 'mpg'] -# -# # ax.plot(x, df.loc[idx, field_names], colours[mpg_category]) -# ax.plot(x, values[:, idx]) -# ax.set_xlim([x[i], x[i+1]]) -# -# # Set the tick positions and labels on y axis for each plot -# # Tick positions based on normalised data -# # Tick labels are based on original data -# def set_ticks_for_axis(dim, ax, ticks): -# min_val, max_val, val_range = min_max_range[field_names[dim]] -# step = val_range / float(ticks-1) -# tick_labels = [round(min_val + step * i, 2) for i in range(ticks)] -# norm_min = df[field_names[dim]].min() -# norm_range = np.ptp(df[field_names[dim]]) -# norm_step = norm_range / float(ticks-1) -# ticks = [round(norm_min + norm_step * i, 2) for i in range(ticks)] -# ax.yaxis.set_ticks(ticks) -# ax.set_yticklabels(tick_labels) -# -# for dim, ax in enumerate(axes): -# ax.xaxis.set_major_locator(ticker.FixedLocator([dim])) -# set_ticks_for_axis(dim, ax, ticks=6) -# ax.set_xticklabels([field_names[dim]]) -# -# -# # Move the final axis' ticks to the right-hand side -# ax = plt.twinx(axes[-1]) -# dim = len(axes) -# ax.xaxis.set_major_locator(ticker.FixedLocator([x[-2], x[-1]])) -# set_ticks_for_axis(dim, ax, ticks=6) -# ax.set_xticklabels([field_names[-2], field_names[-1]]) -# -# -# # Remove space between subplots -# plt.subplots_adjust(wspace=0) - - # Add legend to plot - # plt.legend( - # [plt.Line2D((0,1),(0,0), color=colours[cat]) for cat in df['mpg'].cat.categories], - # df['mpg'].cat.categories, - # bbox_to_anchor=(1.2, 1), loc=2, borderaxespad=0.) -# -# -# # plt.title("Values of car attributes by MPG category") -# - # plt.show() +from matplotlib import pyplot as plt + +from artemis.general.mymath import cosine_distance +from artemis.general.should_be_builtins import izip_equal from artemis.plotting.pyplot_plus import axhlines -def draw_norm_y_axis(x_position, lims, scale='lin', axis_thickness=2, n_intermediates=3, tickwidth=0.1, axiscolor='k'): + +def draw_norm_y_axis(x_position, lims, scale='lin', axis_thickness=2, n_intermediates=3, tickwidth=0.1, axiscolor='k', ticklabel_format='{:.3g}', tick_round_grid=40): """ Draw a y-axis in a Parallel Coordinates plot + + :param x_position: Position in x to draw the axis + :param lims: The (min, max) limit of the y-axis + :param scale: Not implemented for now, just leave at 'lin'. (Todo: implement 'log') + :param axis_thickness: Thickness of the axis line + :param n_intermediates: Number of ticks to put in between ends of axis + :param tickwidth: Width of tick lines + :param axiscolor: Color of axis + :param ticklabel_format: Format for string ticklabel numbers + :param tick_round_grid: Round ticks to a grid with this number of points, or None to not do this. (Causes nicer axis labels) + :return: The handels for the (, , ) """ assert scale=='lin', 'For now' lower, upper = lims - line = plt.axvline(x=x_position, ymin=0, ymax=1, linewidth=axis_thickness, color=axiscolor) + vertical_line_handel = plt.axvline(x=x_position, ymin=0, ymax=1, linewidth=axis_thickness, color=axiscolor) y_axisticks = np.linspace(0, 1, n_intermediates+2) - y_labels = ['{:.2g}'.format(y*(upper-lower)+lower) for y in y_axisticks] - h_ticklabels = [plt.text(x=x_position+tickwidth/2., y=y, s=ylab, color='k', bbox=dict(boxstyle="square", fc=(1., 1., 1., 0.5), ec=(0, 0, 0, 0.))) for y, ylab in izip_equal(y_axisticks, y_labels)] - h_ticks = axhlines(ys = y_axisticks, lims=(x_position-tickwidth/2., x_position+tickwidth/2.), linewidth=axis_thickness, color=axiscolor, zorder=4) - return line, h_ticks, h_ticklabels + y_trueticks = y_axisticks * (upper - lower) + lower + if tick_round_grid is not None: + # spacing = (upper - lower)/tick_round_grid + spacing = 10**np.round(np.log10((upper-lower)/tick_round_grid)) + y_trueticks = np.round(y_trueticks/spacing)*spacing + y_axisticks = (y_trueticks - y_trueticks[0])/(y_trueticks[-1] - y_trueticks[0]) + y_labels = [ticklabel_format.format(y) for y in y_trueticks] + tick_label_handels = [plt.text(x=x_position+tickwidth/2., y=y, s=ylab, color='k', bbox=dict(boxstyle="square", fc=(1., 1., 1., 0.5), ec=(0, 0, 0, 0.))) for y, ylab in izip_equal(y_axisticks, y_labels)] + tick_handels = axhlines(ys = y_axisticks, lims=(x_position-tickwidth/2., x_position+tickwidth/2.), linewidth=axis_thickness, color=axiscolor, zorder=4) + return vertical_line_handel, tick_handels, tick_label_handels -def parallel_coords_plot(field_names, values, scales = {}, ax=None): +def parallel_coords_plot(field_names, values, special_formats = {}, scales = {}, color_index=-1, ax=None, alpha='auto', cmap='Spectral', **plot_kwargs): """ - Create a Parallel coordinates plot. + Create a Parallel coordinates plot. These plots are useful for visualizing high-dimensional data. :param field_names: A list of (n_fields) field names - :param values: A (n_fields, n_samples) array of values. - :return: A list of handles to the plot objectss + :param values: A (n_samples, n_fields) array of values. + :param Dict[int, Dict] special_formats: Optionally a dictionary mapping from sample index to line format. This can be used to highlight certain lines. + :param Dict[str, str] scales: (currently not implemented) Identifies the scale ('lin' or 'log') for each field name + :param color_index: Which column of values to use to colour-code the lines. Defaults to the last column. + :param ax: The plot axis (if None, use current axis (gca)) + :param alpha: The alpha (opaqueness) value to use. If 'auto', this function automatically lowers alpha in regions of dense overlap. + :param plot_kwargs: Other kwargs to pass to line plots (these will be overridden on a per-plot basis by special_formats, alpha) + :return: A list of handles to the plot objects """ - + values = np.array(values, copy=False) assert set(scales.keys()).issubset(field_names), 'All scales must be in field names.' - assert len(field_names) == len(values) + assert len(field_names) == values.shape[1] if ax is None: ax = plt.gca() - v_min, v_max = np.min(values, axis=1, keepdims=True), np.max(values, axis=1, keepdims=True) + v_min, v_max = np.min(values, axis=0), np.max(values, axis=0) norm_lines = (values-v_min) / (v_max-v_min) - cmap = matplotlib.cm.get_cmap('Spectral') - hs = [plt.plot(line, color=cmap(line[-1]))[0] for i, line in enumerate(norm_lines.T)] + cmap = matplotlib.cm.get_cmap(cmap) + formats = {i: plot_kwargs.copy() for i in range(len(norm_lines))} + for i, line in enumerate(norm_lines): # Color lines according to score + formats[i]['color']=cmap(1-line[color_index]) + if alpha=='auto': + mean_param = np.mean(norm_lines, axis=0) + for i, line in enumerate(norm_lines): + sameness = max(0, cosine_distance(mean_param, line)) # (0 to 1 where 1 means same as the mean) + alpha = sameness * (1./np.sqrt(values.shape[0])) + (1-sameness)*1. + formats[i]['alpha'] = alpha + else: + for i in range(len(norm_lines)): + formats[i]['alpha'] = alpha + for i, form in special_formats.items(): # Add special formats + formats[i].update(form) + plot_kwargs.update(dict(alpha=alpha)) + + hs = [plt.plot(line, **formats[i])[0] for i, line in enumerate(norm_lines)] for i, f in enumerate(field_names): - draw_norm_y_axis(x_position=i, lims=(v_min[i, 0], v_max[i, 0]), scale = 'lin' if f not in scales else scales[f]) + draw_norm_y_axis(x_position=i, lims=(v_min[i], v_max[i]), scale = 'lin' if f not in scales else scales[f]) ax.set_xticks(range(len(field_names))) ax.set_xticklabels(field_names) ax.tick_params(axis='y', labelleft='off') ax.set_yticks([]) - # ax.set_yticklabels([]) ax.set_xlim(0, len(field_names)-1) return hs -def plot_hyperparameter_search_parallel_coords(field_names, x_iters, func_vals, show_iter_first = True, show_score_last = True, iter_name='iter', score_name='score'): +def plot_hyperparameter_search_parallel_coords(field_names, param_sequence, func_vals, final_params = None, show_iter_first = True, show_score_last = True, iter_name='iter', score_name='score'): """ Visualize the result of a hyperparameter search using a Parallel Coordinates plot :param field_names: A (n_hyperparameters) list of names of the hyperparameters - :param x_iters: A (n_steps, n_hyperparameters) list of hyperparameter values + :param param_sequence: A (n_steps, n_hyperparameters) list of hyperparameter values :param func_vals: A (n_hyperparameters) list of scores computed for each value + :param final_params: Optionally, provide the final chosen set of hyperparameters. These will be plotted as a thick + black dotted line. :param show_iter_first: Insert "iter" (the interation index in the search) as a first column to the plot :param show_score_last: Insert "score" as a last column to the plot :param iter_name: Name of the "iter" field @@ -156,5 +109,12 @@ def plot_hyperparameter_search_parallel_coords(field_names, x_iters, func_vals, :return: A list of plot handels """ field_names = ([iter_name] if show_iter_first else []) + list(field_names) + ([score_name] if show_score_last else []) - lines = [([i] if show_iter_first else []) + list(params) + ([val] if show_score_last else []) for i, (params, val) in enumerate(izip_equal(x_iters, func_vals))] - return parallel_coords_plot(field_names=field_names, values=np.array(lines).T) + lines = [([i+1] if show_iter_first else []) + list(params) + ([val] if show_score_last else []) for i, (params, val) in enumerate(izip_equal(param_sequence, func_vals))] + + if final_params is not None: # This adds a black dotted line over the final set of hyperparameters + ix = next(i for i, v in enumerate(param_sequence) if np.array_equal(v, final_params)) + lines.append(([ix] if show_iter_first else [])+list(final_params)+([func_vals[ix] if show_score_last else []])) + special_formats = {len(lines)-1: dict(linewidth=2, color='k', linestyle='--', alpha=1)} + else: + special_formats = {} + return parallel_coords_plot(field_names=field_names, values=lines, special_formats=special_formats) From c46b2fd01d13b6bef4063afa2fa22bb2582d2dc1 Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Thu, 11 Apr 2019 08:57:01 -0700 Subject: [PATCH 031/107] indice build --- artemis/experiments/experiment_management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/artemis/experiments/experiment_management.py b/artemis/experiments/experiment_management.py index b6e5ce90..dea19ce9 100644 --- a/artemis/experiments/experiment_management.py +++ b/artemis/experiments/experiment_management.py @@ -175,7 +175,7 @@ def select_experiment_records(user_range, exp_record_dict, flat=True, load_recor :param user_range: :param exp_record_dict: An OrderedDict> :param flat: Return a list of experiment records, instead of an OrderedDict - :return: if not flat, an An OrderedDict> + :return: if not flat, an OrderedDict> otherwise a list """ filters = _filter_records(user_range, exp_record_dict) From 7003281655932a1abe07c642e21bc7bf0f4ee2a8 Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Tue, 16 Apr 2019 11:49:22 +0900 Subject: [PATCH 032/107] addressed test fail --- artemis/experiments/experiment_record_view.py | 4 ++-- artemis/experiments/experiments.py | 11 ++++------- .../experiments/test_experiment_record_view_and_ui.py | 2 +- artemis/experiments/test_experiments.py | 2 +- artemis/general/global_rates.py | 4 ++-- artemis/ml/tools/running_averages.py | 2 +- artemis/plotting/point_remapping_plots.py | 2 +- artemis/plotting/range_plots.py | 11 +++++------ 8 files changed, 17 insertions(+), 21 deletions(-) diff --git a/artemis/experiments/experiment_record_view.py b/artemis/experiments/experiment_record_view.py index 322c0882..36c19f13 100644 --- a/artemis/experiments/experiment_record_view.py +++ b/artemis/experiments/experiment_record_view.py @@ -491,9 +491,9 @@ def compare_timeseries_records(records, yfield, xfield = None, hang=True, ax=Non for result, argvals in izip_equal(results, values): xvals = [r[xfield] for r in result] if xfield is not None else list(range(len(result))) # yvals = [r[yfield[0]] for r in result] - h, = ax.plot(xvals, [r[yfield[0]] for r in result], label=(yfield[0]+': ' if len(yfield)>1 else '')+', '.join(f'{argname}={argval}' for argname, argval in izip_equal(all_different_args, argvals))) + h, = ax.plot(xvals, [r[yfield[0]] for r in result], label=(yfield[0]+': ' if len(yfield)>1 else '')+', '.join('{}={}'.format(argname, argval) for argname, argval in izip_equal(all_different_args, argvals))) for yf, linestyle in zip(yfield[1:], itertools.cycle(['--', ':', '-.'])): - ax.plot(xvals, [r[yf] for r in result], linestyle=linestyle, color=h.get_color(), label=yf+': '+', '.join(f'{argname}={argval}' for argname, argval in izip_equal(all_different_args, argvals))) + ax.plot(xvals, [r[yf] for r in result], linestyle=linestyle, color=h.get_color(), label=yf+': '+', '.join('{}={}'.format(argname, argval) for argname, argval in izip_equal(all_different_args, argvals))) ax.grid(True) if xfield is not None: diff --git a/artemis/experiments/experiments.py b/artemis/experiments/experiments.py index c803f786..f9ca8495 100644 --- a/artemis/experiments/experiments.py +++ b/artemis/experiments/experiments.py @@ -424,7 +424,7 @@ def get_variant_records(self, only_completed=False, only_last=False, flat=False) else: return exp_record_dict - def add_parameter_search(self, name='parameter_search', fixed_args = {}, space = None, n_calls=100, search_params = None, scalar_func=None): + def add_parameter_search(self, name='parameter_search', fixed_args = {}, space = None, n_calls=None, search_params = None, scalar_func=None): """ :param name: Name of the Experiment to be created :param dict[str, Any] fixed_args: Any fixed-arguments to provide to all experiments. @@ -435,6 +435,7 @@ def add_parameter_search(self, name='parameter_search', fixed_args = {}, space = :param dict[str, Any] search_params: Args passed to parameter_search :return Experiment: A new experiment which runs the search and yields current-best parameters with every iteration. """ + assert space is not None, "You must specify a parameter search space. See this method's documentation" if name is None: # TODO: Set name=None in the default after deadline name = 'parameter_search[{}]'.format(','.join(space.keys())) @@ -451,13 +452,9 @@ def objective(**current_params): from artemis.experiments import ExperimentFunction def search_func(fixed): - if is_test_mode(): - nonlocal n_calls - n_calls = 3 # When just verifying that experiment runs, do the minimum - + n_calls_to_make = n_calls if n_calls is not None else 3 if is_test_mode() else 100 this_objective = partial(objective, **fixed) - - for iter_info in parameter_search(this_objective, n_calls=n_calls, space=space, **search_params): + for iter_info in parameter_search(this_objective, n_calls=n_calls_to_make, space=space, **search_params): info = dict(names=list(space.keys()), x_iters =iter_info.x_iters, func_vals=iter_info.func_vals, score = iter_info.func_vals, x=iter_info.x, fun=iter_info.fun) latest_info = {name: val for name, val in izip_equal(info['names'], iter_info.x_iters[-1])} print(f'Latest: {latest_info}, Score: {iter_info.func_vals[-1]:.3g}') diff --git a/artemis/experiments/test_experiment_record_view_and_ui.py b/artemis/experiments/test_experiment_record_view_and_ui.py index 9b2bba66..3813c478 100644 --- a/artemis/experiments/test_experiment_record_view_and_ui.py +++ b/artemis/experiments/test_experiment_record_view_and_ui.py @@ -1,6 +1,7 @@ import pytest from artemis.experiments.decorators import ExperimentFunction, experiment_function +from artemis.experiments.experiment_record import ExperimentRecord from artemis.experiments.experiment_record import save_figure_in_record from artemis.experiments.experiment_record_view import get_oneline_result_string, print_experiment_record_argtable, \ compare_experiment_records, get_record_invalid_arg_string, browse_record_figs @@ -246,4 +247,3 @@ def my_exp(): test_simple_experiment_show() test_view_modes() test_duplicate_headers_when_no_records_bug_is_gone() - # demo_browse_record_figs() \ No newline at end of file diff --git a/artemis/experiments/test_experiments.py b/artemis/experiments/test_experiments.py index 6208e28a..7bf5bfea 100644 --- a/artemis/experiments/test_experiments.py +++ b/artemis/experiments/test_experiments.py @@ -127,7 +127,7 @@ def bowl(x, y): ex_search = bowl.add_parameter_search( space = {'x': Real(-5, 5, 'uniform'), 'y': Real(-5, 5, 'uniform')}, scalar_func=lambda result: result['z'], - search_params=dict(n_calls=5) + search_params=dict(n_calls=5), ) record = ex_search.run() diff --git a/artemis/general/global_rates.py b/artemis/general/global_rates.py index 4128f042..94d6ed34 100644 --- a/artemis/general/global_rates.py +++ b/artemis/general/global_rates.py @@ -84,7 +84,7 @@ def is_elapsed(identifier, period, current = None, count_initial = True): return count_initial else: last = get_global(key) - assert current>=last, f"Current value ({current}) must be greater or equal to the last value ({last})" + assert current>=last, "Current value ({}) must be greater or equal to the last value ({})".format(current, last) has_elapsed = current - last >= period if has_elapsed: set_global(key, current) @@ -106,7 +106,7 @@ def limit_rate(identifier, period): return False else: last = get_global(key) - assert enter_time>=last, f"Current value ({current}) must be greater or equal to the last value ({last})" + assert enter_time>=last, "Current value ({}) must be greater or equal to the last value ({})".format(enter_time, last) elapsed = enter_time - last if elapsed < period: # Rate has been exceeded time.sleep(period - elapsed) diff --git a/artemis/ml/tools/running_averages.py b/artemis/ml/tools/running_averages.py index 0a09705f..b2730473 100644 --- a/artemis/ml/tools/running_averages.py +++ b/artemis/ml/tools/running_averages.py @@ -178,6 +178,6 @@ def periodically_report_running_average(identifier, time, period, value, ra_type if not isinstance(value, dict): avg = get_global_running_average(value=value, identifier=identifier, ra_type=ra_type, reset=reset_between and report_time) else: - avg = {k: f'{get_global_running_average(value=v, identifier=(identifier, k), ra_type=ra_type, reset=reset_between and report_time):.3g}' for k, v in value.items()} + avg = {k: '{:.3g}'.format(get_global_running_average(value=v, identifier=(identifier, k), ra_type=ra_type, reset=reset_between and report_time)) for k, v in value.items()} if report_time: print(format_str.format(identifier=identifier, time=time, avg=avg)) diff --git a/artemis/plotting/point_remapping_plots.py b/artemis/plotting/point_remapping_plots.py index dc054712..bc609d11 100644 --- a/artemis/plotting/point_remapping_plots.py +++ b/artemis/plotting/point_remapping_plots.py @@ -34,6 +34,6 @@ def plot_2D_mapping(old_xy_points, new_xy_points, axes = None, old_title = 'x', # Apply some transformation theta = 5*np.pi/6 transform_matrix = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]]) - new_xy_points = np.tanh(old_xy_points @ transform_matrix) + new_xy_points = np.tanh(np.dot(old_xy_points, transform_matrix)) plot_2D_mapping(old_xy_points, new_xy_points) diff --git a/artemis/plotting/range_plots.py b/artemis/plotting/range_plots.py index 6b370d45..5e1fc986 100644 --- a/artemis/plotting/range_plots.py +++ b/artemis/plotting/range_plots.py @@ -2,7 +2,7 @@ import numpy as np -def plot_sample_mean_and_var(*x_and_ys, var_rep ='std', fill_alpha = 0.25, **plot_kwargs): +def plot_sample_mean_and_var(x_or_ys, ys=None, var_rep ='std', fill_alpha = 0.25, **plot_kwargs): """ Given a collection of signals, plot their mean and fill a range around the mean. Example: x = np.arange(-5, 5) @@ -17,12 +17,11 @@ def plot_sample_mean_and_var(*x_and_ys, var_rep ='std', fill_alpha = 0.25, **plo :param plot_kwargs: :return: """ - if len(x_and_ys)==2: - x, ys = x_and_ys - else: - assert len(x_and_ys) == 1, "You must provide unnamed arguments in order (ys) or (x, ys)" - ys, = x_and_ys + if ys is None: + ys = x_or_ys x = range(len(ys[0])) + else: + x = x_or_ys assert var_rep in ('std', 'sterr', 'lim') From 6c3e2142b04e92bebb9fbe09a2fd05687c2ad8d0 Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Tue, 16 Apr 2019 15:39:17 +0900 Subject: [PATCH 033/107] fixed --- artemis/general/global_rates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/artemis/general/global_rates.py b/artemis/general/global_rates.py index 94d6ed34..20ed7ba6 100644 --- a/artemis/general/global_rates.py +++ b/artemis/general/global_rates.py @@ -59,7 +59,7 @@ def elapsed_time(identifier, current = None): return float('inf') else: last = get_global(key) - assert current>=last, f"Current value ({current}) must be greater or equal to the last value ({last})" + assert current>=last, "Current value ({}) must be greater or equal to the last value ({})".format(current, last) elapsed = current - last set_global(key, current) return elapsed From c8ba0d8ee72fa74112819665a44e38193936b0c5 Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Tue, 16 Apr 2019 17:30:39 +0900 Subject: [PATCH 034/107] addressed errors --- artemis/experiments/experiment_record_view.py | 3 +-- artemis/experiments/experiments.py | 4 ++-- artemis/general/global_vars.py | 2 +- artemis/general/mymath.py | 5 ++++- artemis/general/progress_indicator.py | 2 +- artemis/ml/tools/running_averages.py | 2 +- artemis/plotting/test_animation.py | 4 +++- 7 files changed, 13 insertions(+), 9 deletions(-) diff --git a/artemis/experiments/experiment_record_view.py b/artemis/experiments/experiment_record_view.py index 36c19f13..9f6d9229 100644 --- a/artemis/experiments/experiment_record_view.py +++ b/artemis/experiments/experiment_record_view.py @@ -513,10 +513,9 @@ def get_timeseries_record_comparison_function(yfield, xfield = None, hang=True, return lambda records: compare_timeseries_records(records, yfield, xfield = xfield, hang=hang, ax=ax) - def timeseries_oneliner_function(result, fields, show_len, show = 'last'): assert show=='last', 'Only support showing last element now' - return (f'{len(result)} items. ' if show_len else '')+', '.join(f'{k}: {result[-1][k]:.3g}' if isinstance(result[-1][k], float) else f'{k}: {result[-1][k]}' for k in fields) + return ('{} items. '.format(len(result)) if show_len else '')+', '.join('{}: {:.3g}'.format(k, result[-1][k]) if isinstance(result[-1][k], float) else '{}: {}'.format(k, result[-1][k]) for k in fields) def get_timeseries_oneliner_function(fields, show_len=False, show='last'): diff --git a/artemis/experiments/experiments.py b/artemis/experiments/experiments.py index f9ca8495..bf0aa78f 100644 --- a/artemis/experiments/experiments.py +++ b/artemis/experiments/experiments.py @@ -457,7 +457,7 @@ def search_func(fixed): for iter_info in parameter_search(this_objective, n_calls=n_calls_to_make, space=space, **search_params): info = dict(names=list(space.keys()), x_iters =iter_info.x_iters, func_vals=iter_info.func_vals, score = iter_info.func_vals, x=iter_info.x, fun=iter_info.fun) latest_info = {name: val for name, val in izip_equal(info['names'], iter_info.x_iters[-1])} - print(f'Latest: {latest_info}, Score: {iter_info.func_vals[-1]:.3g}') + print('Latest: {}, Score: {:.3g}'.format(latest_info, iter_info.func_vals[-1])) yield info # The following is a hack to dynamically create a function with the given args @@ -494,7 +494,7 @@ def show_parameter_search_record(record): def parameter_search_one_liner(result): - return f'{len(result["x_iters"])} Runs : ' + ', '.join(f'{k}={v:.3g}' for k, v in izip_equal(result['names'], result['x'])) + f' : Score = {result["fun"]:.3g}' + return '{} Runs : '.format(len(result["x_iters"])) + ', '.join('{}={:.3g}'.format(k, v) for k, v in izip_equal(result['names'], result['x'])) + ' : Score = {:.3g}'.format(result["fun"]) _GLOBAL_EXPERIMENT_LIBRARY = OrderedDict() diff --git a/artemis/general/global_vars.py b/artemis/general/global_vars.py index 6cc51940..34c69126 100644 --- a/artemis/general/global_vars.py +++ b/artemis/general/global_vars.py @@ -1,4 +1,4 @@ -from decorator import contextmanager +from contextlib import contextmanager _GLOBALS = {} diff --git a/artemis/general/mymath.py b/artemis/general/mymath.py index b02bfbed..bc9ae100 100644 --- a/artemis/general/mymath.py +++ b/artemis/general/mymath.py @@ -188,7 +188,10 @@ def recent_moving_average(x, axis = 0): a[t] = (1-frac)*a[t-1] + frac*x[t] """ - import weave # ONLY WORKS IN PYTHON 2.X !!! + try: + import weave # ONLY WORKS IN PYTHON 2.X !!! + except: + raise ImportError('Weave module could not be found. Maybe because it only works in Python 2.X') if x.ndim!=2: y = recent_moving_average(x.reshape(x.shape[0], x.size//x.shape[0]), axis=0) return y.reshape(x.shape) diff --git a/artemis/general/progress_indicator.py b/artemis/general/progress_indicator.py index 7af0d3b4..f98c9d81 100644 --- a/artemis/general/progress_indicator.py +++ b/artemis/general/progress_indicator.py @@ -1,6 +1,6 @@ import time -from decorator import contextmanager +from contextlib import contextmanager class ProgressIndicator(object): diff --git a/artemis/ml/tools/running_averages.py b/artemis/ml/tools/running_averages.py index b2730473..d0842636 100644 --- a/artemis/ml/tools/running_averages.py +++ b/artemis/ml/tools/running_averages.py @@ -39,7 +39,7 @@ def __call__(self, data): def batch(cls, x): try: return recent_moving_average(x, axis=0) # Works only for python 2.X, with weave - except ModuleNotFoundError: + except ImportError: rma = RecentRunningAverage() return np.array([rma(xt) for xt in x]) diff --git a/artemis/plotting/test_animation.py b/artemis/plotting/test_animation.py index 055b7ffa..ca525de1 100644 --- a/artemis/plotting/test_animation.py +++ b/artemis/plotting/test_animation.py @@ -1,9 +1,11 @@ import matplotlib import pytest -matplotlib.use('TkAgg') import matplotlib.pyplot as plt import numpy as np +from matplotlib.testing import is_called_from_pytest from six.moves import xrange +if not is_called_from_pytest(): + matplotlib.use('TkAgg') __author__ = 'peter' From 8d0123e336a0d246674565f4b5e22990b104ba59 Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Tue, 16 Apr 2019 23:53:34 +0900 Subject: [PATCH 035/107] again --- artemis/general/async.py | 3 ++- artemis/general/dead_easy_ui.py | 11 ++--------- artemis/general/iteratorize.py | 9 +++++---- artemis/ml/tools/test_processors.py | 2 +- artemis/plotting/test_animation.py | 6 +----- 5 files changed, 11 insertions(+), 20 deletions(-) diff --git a/artemis/general/async.py b/artemis/general/async.py index 36b794e5..8d11cdc6 100644 --- a/artemis/general/async.py +++ b/artemis/general/async.py @@ -1,4 +1,4 @@ -from multiprocessing import Process, Queue, Manager, Lock, set_start_method +from multiprocessing import Process, Queue, Manager, Lock import time @@ -46,6 +46,7 @@ def iter_latest_asynchonously(gen_func, timeout = None, empty_value = None, use_ :return: """ if use_forkserver: + from multiprocessing import set_start_method # Only Python 3.X set_start_method('forkserver') # On macos this is necessary to start camera in separate thread m = Manager() diff --git a/artemis/general/dead_easy_ui.py b/artemis/general/dead_easy_ui.py index d415602d..bfe9a482 100644 --- a/artemis/general/dead_easy_ui.py +++ b/artemis/general/dead_easy_ui.py @@ -1,8 +1,6 @@ -from __future__ import print_function +from __future__ import print_function, absolute_import from __future__ import absolute_import -from builtins import range -from builtins import input -from builtins import zip +from six.moves import input import inspect import shlex from collections import OrderedDict @@ -139,11 +137,6 @@ def parse_user_function_call(cmd_str, arg_handling_mode = 'fallback'): """ assert arg_handling_mode in ('str', 'literal', 'fallback') - - # def _fake_func(*args, **kwargs): - # Just exists to help with extracting args, kwargs - # return args, kwargs - cmd_args = shlex.split(cmd_str, posix=False) assert len(cmd_args) == len(shlex.split(cmd_str, posix=True)), "Parse error on string '{}'. You're not allowed having spaces in the values of string keyword args:".format(cmd_str) diff --git a/artemis/general/iteratorize.py b/artemis/general/iteratorize.py index f5581887..bfca0a61 100644 --- a/artemis/general/iteratorize.py +++ b/artemis/general/iteratorize.py @@ -4,10 +4,12 @@ Thanks to Brice for this piece of code. Taken from https://stackoverflow.com/a/9969000/851699 """ - -# from thread import start_new_thread from collections import Iterable -from queue import Queue +import sys +if sys.version_info < (3, 0): + from Queue import Queue +else: + from queue import Queue from threading import Thread @@ -22,7 +24,6 @@ def __init__(self, func): :param Callable[Callable, Any] func: A function that takes a callback as an argument then runs. """ self.mfunc = func - # self.ifunc = ifunc self.q = Queue(maxsize=1) self.sentinel = object() diff --git a/artemis/ml/tools/test_processors.py b/artemis/ml/tools/test_processors.py index 2c94a7be..ffa44350 100644 --- a/artemis/ml/tools/test_processors.py +++ b/artemis/ml/tools/test_processors.py @@ -2,7 +2,7 @@ import pytest from six.moves import xrange -from artemis.ml.tools.processors import RunningAverage, RecentRunningAverage +from artemis.ml.tools.running_averages import RunningAverage, RecentRunningAverage __author__ = 'peter' diff --git a/artemis/plotting/test_animation.py b/artemis/plotting/test_animation.py index ca525de1..fd3d1d6f 100644 --- a/artemis/plotting/test_animation.py +++ b/artemis/plotting/test_animation.py @@ -1,11 +1,7 @@ -import matplotlib -import pytest import matplotlib.pyplot as plt import numpy as np -from matplotlib.testing import is_called_from_pytest +import pytest from six.moves import xrange -if not is_called_from_pytest(): - matplotlib.use('TkAgg') __author__ = 'peter' From 114c5464109f85fc160e47c9506ecb1010ea5d30 Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Wed, 17 Apr 2019 06:25:23 +0900 Subject: [PATCH 036/107] should pass? --- artemis/experiments/test_experiments.py | 1 + artemis/general/test_scannable_functions.py | 2 +- requirements.txt | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/artemis/experiments/test_experiments.py b/artemis/experiments/test_experiments.py index 7bf5bfea..db0b57ad 100644 --- a/artemis/experiments/test_experiments.py +++ b/artemis/experiments/test_experiments.py @@ -114,6 +114,7 @@ def my_exp(a, b, c): assert XXXX() == 1+(5*5)*5 +@pytest.mark.skipif(True, reason='We dont want to make scikit-optimize a hard requirement just for this so we skip the test.') def test_parameter_search(): from skopt.space import Real diff --git a/artemis/general/test_scannable_functions.py b/artemis/general/test_scannable_functions.py index 1e30187a..29174ef7 100644 --- a/artemis/general/test_scannable_functions.py +++ b/artemis/general/test_scannable_functions.py @@ -177,4 +177,4 @@ def moving_average(x, avg=0, t=0): test_stateless_updater() test_stateless_updater_with_decorator() test_stateful_updater() - test_stateful_updater_with_decorator() \ No newline at end of file + test_stateful_updater_with_decorator() diff --git a/requirements.txt b/requirements.txt index ce1a70c1..a13d78f5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ tabulate netifaces paramiko si-prefix +recordclass # weave # Only works in python 2.X # Other things we may want (uncomment to add these to requirements) # scikit-learn From 11c0a1b14e4228135ad334431ef67debd2d6f99f Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Wed, 17 Apr 2019 11:28:12 +0900 Subject: [PATCH 037/107] address tests --- artemis/general/test_scannable_functions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/artemis/general/test_scannable_functions.py b/artemis/general/test_scannable_functions.py index 29174ef7..a92004aa 100644 --- a/artemis/general/test_scannable_functions.py +++ b/artemis/general/test_scannable_functions.py @@ -31,7 +31,7 @@ def moving_average(x, decay, avg=0): simply_smoothed_signal = [f(x=x, decay=1./(t+1)) for t, x in enumerate(seq)] truth = np.cumsum(seq)/np.arange(1, len(seq)+1) assert np.allclose(simply_smoothed_signal, truth) - assert list(f._fields)==['avg'] + assert list(f._asdict().keys())==['avg'] assert np.allclose(f.avg, np.mean(seq)) f = moving_average.mutable_scan() From f1635f3757c556f875f9159fc72b99db0e2d5c12 Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Wed, 17 Apr 2019 11:51:33 +0900 Subject: [PATCH 038/107] oops forgot to push cleanup --- artemis/experiments/experiment_record.py | 15 --------------- artemis/experiments/experiment_record_view.py | 2 -- 2 files changed, 17 deletions(-) diff --git a/artemis/experiments/experiment_record.py b/artemis/experiments/experiment_record.py index 1d26f980..7502458e 100644 --- a/artemis/experiments/experiment_record.py +++ b/artemis/experiments/experiment_record.py @@ -663,21 +663,6 @@ def clear_experiment_records(ids): ExperimentRecord(exp_path).delete() -# -# -# def save_figure_in_current_experiment_directory(name='fig-{}.pkl', figure = None): -# -# if figure is None: -# figure = plt.gcf() -# -# current_dir = get_current_record_dir() -# start_ix = _figure_ixs[current_dir] if current_dir in _figure_ixs else 0 -# for ix in count(start_ix): -# full_path = os.path.join(current_dir, name).format(ix) -# if not os.path.exists(_figure_ixs[current_dir]): -# save_figure(figure, path = full_path) -# _figure_ixs[current_dir] = ix+1 -# return full_path _figure_ixs = {} diff --git a/artemis/experiments/experiment_record_view.py b/artemis/experiments/experiment_record_view.py index 9f6d9229..185c5724 100644 --- a/artemis/experiments/experiment_record_view.py +++ b/artemis/experiments/experiment_record_view.py @@ -285,8 +285,6 @@ def lookup_fcn(record_id, arg_or_result_name): return rows[0], rows[1:] - # return tabulate(rows[1:], headers=rows[0]) - def show_record(record, show_logs=True, truncate_logs=None, truncate_result=10000, header_width=100, show_result ='deep', hang=True): """ From b98f38b3b90d39f00f2df4e3c086a1a63e53bac7 Mon Sep 17 00:00:00 2001 From: Peter O'Connor Date: Wed, 17 Apr 2019 11:52:57 +0900 Subject: [PATCH 039/107] more clean --- artemis/experiments/experiment_record_view.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/artemis/experiments/experiment_record_view.py b/artemis/experiments/experiment_record_view.py index 185c5724..9bad1052 100644 --- a/artemis/experiments/experiment_record_view.py +++ b/artemis/experiments/experiment_record_view.py @@ -542,14 +542,12 @@ def show_figure(ix): dir, name = os.path.split(path) if nonlocals.this_fig is not None: plt.close(nonlocals.this_fig) - # with interactive_matplotlib_context(): plt.close(plt.gcf()) with open(path, "rb") as f: fig = pickle.load(f) fig.canvas.set_window_title(record.get_id()+': ' +name+': (Figure {}/{})'.format(ix+1, len(fig_locs))) fig.canvas.mpl_connect('key_press_event', changefig) print('Showing {}: Figure {}/{}. Full path: {}'.format(name, ix+1, len(fig_locs), path)) - # redraw_figure() plt.show() nonlocals.this_fig = plt.gcf() From ba685ebf72742f702fd34442b277f1ee094039d8 Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 15 Sep 2022 08:36:38 -0700 Subject: [PATCH 040/107] added a bunch of typing and allowed experiment selection by user range --- artemis/experiments/experiment_management.py | 155 ++++++++++--------- artemis/experiments/experiments.py | 142 ++++++++++------- artemis/fileman/disk_memoize.py | 28 +++- artemis/fileman/test_disk_memoize.py | 59 +++++-- artemis/general/display.py | 2 +- artemis/general/functional.py | 52 +++++-- artemis/general/hashing.py | 1 + 7 files changed, 278 insertions(+), 161 deletions(-) diff --git a/artemis/experiments/experiment_management.py b/artemis/experiments/experiment_management.py index dea19ce9..0cba0b00 100644 --- a/artemis/experiments/experiment_management.py +++ b/artemis/experiments/experiment_management.py @@ -13,6 +13,7 @@ from time import time import math +from typing import Union, Sequence, Mapping from artemis.fileman.local_dir import make_dir from artemis.general.display import equalize_string_lengths @@ -20,9 +21,9 @@ from six.moves import reduce, xrange from artemis.experiments.experiment_record import (load_experiment_record, ExpInfoFields, ExpStatusOptions, ARTEMIS_LOGGER, record_id_to_experiment_id, - get_all_record_ids, get_experiment_dir, has_experiment_record) + get_all_record_ids, get_experiment_dir, has_experiment_record, ExperimentRecord) from artemis.experiments.experiments import load_experiment, get_global_experiment_library -from artemis.fileman.config_files import get_home_dir,set_non_persistent_config_value +from artemis.fileman.config_files import get_home_dir, set_non_persistent_config_value from artemis.general.hashing import compute_fixed_hash from artemis.general.time_parser import parse_time from artemis.remote.child_processes import SlurmPythonProcess @@ -31,7 +32,7 @@ divide_into_subsets -def pull_experiment_records(user, ip, experiment_names, include_variants=True, need_pass = False): +def pull_experiment_records(user, ip, experiment_names, include_variants=True, need_pass=False): """ Pull experiments from another computer matching the given experiment name. @@ -49,19 +50,19 @@ def pull_experiment_records(user, ip, experiment_names, include_variants=True, n home = get_home_dir() - file_list = ["**/*-{exp_name}{variants}/*".format(exp_name=exp_name, variants = '*' if include_variants else '') for exp_name in experiment_names] + file_list = ["**/*-{exp_name}{variants}/*".format(exp_name=exp_name, variants='*' if include_variants else '') for exp_name in experiment_names] _, experiment_directory_file = tempfile.mkstemp() with open(experiment_directory_file, 'w') as f: f.write('\n'.join(file_list)) # This one works if you have keys set up - command = ['rsync', '-a', '-m', '-i']\ - +['{user}@{ip}:~/.artemis/experiments/'.format(user=user, ip=ip)] \ - +['{home}/.artemis/experiments/'.format(home=home)]\ - +["--include-from={}".format(experiment_directory_file)]\ - +["--include='*/'", "--exclude='*'"] - # +["--include='**/*-{exp_name}{variants}/*'".format(exp_name=exp_name, variants = '*' if include_variants else '') for exp_name in experiment_names] # This was the old line, but it could be too long for many experiments. + command = ['rsync', '-a', '-m', '-i'] \ + + ['{user}@{ip}:~/.artemis/experiments/'.format(user=user, ip=ip)] \ + + ['{home}/.artemis/experiments/'.format(home=home)] \ + + ["--include-from={}".format(experiment_directory_file)] \ + + ["--include='*/'", "--exclude='*'"] + # +["--include='**/*-{exp_name}{variants}/*'".format(exp_name=exp_name, variants = '*' if include_variants else '') for exp_name in experiment_names] # This was the old line, but it could be too long for many experiments. if not need_pass: output = subprocess.check_output(' '.join(command), shell=True) @@ -81,7 +82,7 @@ def pull_experiment_records(user, ip, experiment_names, include_variants=True, n return output -def load_lastest_experiment_results(experiments, error_if_no_result = True): +def load_lastest_experiment_results(experiments, error_if_no_result=True): """ Given a list of experiments (or experiment ids), return an OrderedDict :param experiments: A list of Experiment objects (or strings identifying experiment ID is ok too) @@ -95,7 +96,7 @@ def load_lastest_experiment_results(experiments, error_if_no_result = True): return experiment_latest_results -def load_record_results(records, err_if_no_result =True, index_by_id = False): +def load_record_results(records, err_if_no_result=True, index_by_id=False): """ Given a list of experiment records, return an OrderedDict :param records: A list of ExperimentRecord objects @@ -116,7 +117,7 @@ def load_record_results(records, err_if_no_result =True, index_by_id = False): return results -def select_experiments(user_range, exp_record_dict, return_dict=False): +def select_experiments(user_range, exp_record_dict, return_dict=False) -> Union[Sequence[ExperimentRecord], Mapping[str, ExperimentRecord]]: exp_filter = _filter_experiments(user_range, exp_record_dict) if return_dict: return OrderedDict((name, exp_record_dict[name]) for name in exp_record_dict if exp_filter[name]) @@ -124,8 +125,7 @@ def select_experiments(user_range, exp_record_dict, return_dict=False): return [name for name in exp_record_dict if exp_filter[name]] -def _filter_experiments(user_range, exp_record_dict, return_is_in = False): - +def _filter_experiments(user_range, exp_record_dict, return_is_in=False): if '|' in user_range: is_in = [any(xs) for xs in zip(*(_filter_experiments(subrange, exp_record_dict, return_is_in=True) for subrange in user_range.split('|')))] elif '&' in user_range: @@ -135,13 +135,15 @@ def _filter_experiments(user_range, exp_record_dict, return_is_in = False): is_in = [not r for r in is_in] else: if user_range in exp_record_dict: - is_in = [k==user_range for k in exp_record_dict] + is_in = [k == user_range for k in exp_record_dict] + elif user_range in (k.split('.')[-1] for k in exp_record_dict): + is_in = [user_range == k.split('.')[-1] for k in exp_record_dict] else: number_range = interpret_numbers(user_range) if number_range is not None: is_in = [i in number_range for i in xrange(len(exp_record_dict))] elif user_range == 'all': - is_in = [True]*len(exp_record_dict) + is_in = [True] * len(exp_record_dict) elif user_range.startswith('has:'): phrase = user_range[len('has:'):] is_in = [phrase in exp_id for exp_id in exp_record_dict] @@ -150,9 +152,10 @@ def _filter_experiments(user_range, exp_record_dict, return_is_in = False): is_in = [tag in load_experiment(exp_id).get_tags() for exp_id in exp_record_dict] elif user_range.startswith('1diff:'): base_range = user_range[len('1diff:'):] - base_range_exps = select_experiments(base_range, exp_record_dict) # list - all_exp_args_hashes = {eid: set(compute_fixed_hash(a) for a in load_experiment(eid).get_args().items()) for eid in exp_record_dict} # dict> - is_in = [any(len(all_exp_args_hashes[eid].difference(all_exp_args_hashes[other_eid]))<=1 for other_eid in base_range_exps) for eid in exp_record_dict] + base_range_exps = select_experiments(base_range, exp_record_dict) # list + all_exp_args_hashes = {eid: set(compute_fixed_hash(a) for a in load_experiment(eid).get_args().items()) for eid in + exp_record_dict} # dict> + is_in = [any(len(all_exp_args_hashes[eid].difference(all_exp_args_hashes[other_eid])) <= 1 for other_eid in base_range_exps) for eid in exp_record_dict] elif user_range.startswith('hasnot:'): phrase = user_range[len('hasnot:'):] is_in = [phrase not in exp_id for exp_id in exp_record_dict] @@ -170,7 +173,7 @@ def _filter_experiments(user_range, exp_record_dict, return_is_in = False): return OrderedDict((exp_id, exp_is_in) for exp_id, exp_is_in in izip_equal(exp_record_dict, is_in)) -def select_experiment_records(user_range, exp_record_dict, flat=True, load_records = True): +def select_experiment_records(user_range, exp_record_dict, flat=True, load_records=True): """ :param user_range: :param exp_record_dict: An OrderedDict> @@ -218,16 +221,15 @@ def _bitwise_not(a): def _bitwise_filter_op(op, *filter_sets): - output_set = filter_sets[0].copy() - if op=='not': - assert len(filter_sets)==1 + if op == 'not': + assert len(filter_sets) == 1 for k in output_set.keys(): output_set[k] = _bitwise_not(filter_sets[0][k]) elif op in ('and', 'or'): for k in output_set.keys(): - output_set[k] = reduce(_bitwise_and if op=='and' else _bitwise_or, [fs[k] for fs in filter_sets]) - elif op=='andcascade': + output_set[k] = reduce(_bitwise_and if op == 'and' else _bitwise_or, [fs[k] for fs in filter_sets]) + elif op == 'andcascade': for k in output_set.keys(): output_set[k] = reduce(_bitwise_andcascade, [fs[k] for fs in filter_sets[::-1]]) else: @@ -236,14 +238,14 @@ def _bitwise_filter_op(op, *filter_sets): _named_record_filters = {} -_named_record_filters['old'] = lambda rec_ids: ([True]*(len(rec_ids)-1)+[False]) if len(rec_ids)>0 else [] -_named_record_filters['corrupt'] = lambda rec_ids: [load_experiment_record(rec_id).info.get_status_field()==ExpStatusOptions.CORRUPT for rec_id in rec_ids] +_named_record_filters['old'] = lambda rec_ids: ([True] * (len(rec_ids) - 1) + [False]) if len(rec_ids) > 0 else [] +_named_record_filters['corrupt'] = lambda rec_ids: [load_experiment_record(rec_id).info.get_status_field() == ExpStatusOptions.CORRUPT for rec_id in rec_ids] _named_record_filters['finished'] = lambda rec_ids: [load_experiment_record(rec_id).info.get_field(ExpInfoFields.STATUS) == ExpStatusOptions.FINISHED for rec_id in rec_ids] _named_record_filters['invalid'] = lambda rec_ids: [load_experiment_record(rec_id).args_valid() is False for rec_id in rec_ids] -_named_record_filters['all'] = lambda rec_ids: [True]*len(rec_ids) -_named_record_filters['errors'] = lambda rec_ids: [load_experiment_record(rec_id).info.get_field(ExpInfoFields.STATUS)==ExpStatusOptions.ERROR for rec_id in rec_ids] +_named_record_filters['all'] = lambda rec_ids: [True] * len(rec_ids) +_named_record_filters['errors'] = lambda rec_ids: [load_experiment_record(rec_id).info.get_field(ExpInfoFields.STATUS) == ExpStatusOptions.ERROR for rec_id in rec_ids] _named_record_filters['result'] = lambda rec_ids: [load_experiment_record(rec_id).has_result() for rec_id in rec_ids] -_named_record_filters['running'] = lambda rec_ids: [load_experiment_record(rec_id).info.get_field(ExpInfoFields.STATUS)==ExpStatusOptions.STARTED for rec_id in rec_ids] +_named_record_filters['running'] = lambda rec_ids: [load_experiment_record(rec_id).info.get_field(ExpInfoFields.STATUS) == ExpStatusOptions.STARTED for rec_id in rec_ids] def _filter_records(user_range, exp_record_dict): @@ -270,9 +272,9 @@ def _filter_records(user_range, exp_record_dict): :return: An OrderedDict list> indicating whether each record from the given experiment passed the filter """ - if user_range=='unfinished': + if user_range == 'unfinished': return _filter_records('~finished', exp_record_dict) - elif user_range=='last': + elif user_range == 'last': return _filter_records('~old', exp_record_dict) elif '|' in user_range: return _bitwise_filter_op('or', *[_filter_records(subrange, exp_record_dict) for subrange in user_range.split('|')]) @@ -280,7 +282,7 @@ def _filter_records(user_range, exp_record_dict): return _bitwise_filter_op('and', *[_filter_records(subrange, exp_record_dict) for subrange in user_range.split('&')]) elif '@' in user_range: ix = user_range.index('@') - first_part, second_part = user_range[:ix], user_range[ix+1:] + first_part, second_part = user_range[:ix], user_range[ix + 1:] _first_stage_filters = _filter_records(first_part, exp_record_dict) _new_dict = _select_record_ids_from_filters(_first_stage_filters, exp_record_dict) _second_stage_filters = _filter_records(second_part, _new_dict) @@ -289,9 +291,9 @@ def _filter_records(user_range, exp_record_dict): elif user_range.startswith('~'): return _bitwise_filter_op('not', _filter_records(user_range[1:], exp_record_dict)) - base = OrderedDict((k, [False]*len(v)) for k, v in exp_record_dict.items()) + base = OrderedDict((k, [False] * len(v)) for k, v in exp_record_dict.items()) if user_range in exp_record_dict: # User just lists an experiment - base[user_range] = [True]*len(base[user_range]) + base[user_range] = [True] * len(base[user_range]) return base number_range = interpret_numbers(user_range) @@ -302,20 +304,20 @@ def _filter_records(user_range, exp_record_dict): base[exp_id] = _named_record_filters[user_range](exp_record_dict[exp_id]) elif number_range is not None: # e.g. '6-12' for i in number_range: - if i>len(keys): - raise RecordSelectionError('Experiment {} does not exist (they go from 0 to {})'.format(i, len(keys)-1)) - base[keys[i]] = [True]*len(base[keys[i]]) + if i > len(keys): + raise RecordSelectionError('Experiment {} does not exist (they go from 0 to {})'.format(i, len(keys) - 1)) + base[keys[i]] = [True] * len(base[keys[i]]) elif '.' in user_range: # e.b. 6.3-4 exp_rec_pairs = interpret_record_identifier(user_range) for exp_number, rec_number in exp_rec_pairs: - if rec_number>=len(base[keys[exp_number]]): + if rec_number >= len(base[keys[exp_number]]): raise RecordSelectionError('Selection {}.{} does not exist.'.format(exp_number, rec_number)) base[keys[exp_number]][rec_number] = True elif user_range.startswith('dur') or user_range.startswith('age'): # Eg dur<25 Means "All records that ran less than 25s" try: sign = user_range[3] assert sign in ('<', '>') - filter_func = (lambda a, b: (a is not None and b is not None) and ab) + filter_func = (lambda a, b: (a is not None and b is not None) and a < b) if sign == '<' else (lambda a, b: (a is not None and b is not None) and a > b) time_delta = parse_time(user_range[4:]) except: if user_range.startswith('dur'): @@ -333,26 +335,25 @@ def _filter_records(user_range, exp_record_dict): elif user_range.startswith('has:'): phrase = user_range[len('has:'):] for exp_id, records in base.items(): - base[exp_id] = [True]*len(records) if phrase in exp_id else [False]*len(records) + base[exp_id] = [True] * len(records) if phrase in exp_id else [False] * len(records) else: raise RecordSelectionError("Don't know how to interpret subset '{}'. Possible subsets: {}".format(user_range, list(_named_record_filters.keys()))) return base class RecordSelectionError(Exception): - pass def _filter_experiment_record_list(user_range, experiment_record_ids): - if user_range=='all': - return [True]*len(experiment_record_ids) - elif user_range=='new': + if user_range == 'all': + return [True] * len(experiment_record_ids) + elif user_range == 'new': return detect_duplicates(experiment_record_ids, key=record_id_to_experiment_id, keep_last=True) # return [n for n, is_old in izip_equal(get_record_ids(), old) if not old] - elif user_range=='old': + elif user_range == 'old': return [not x for x in _filter_records(user_range, 'new')] - elif user_range=='orphans': + elif user_range == 'orphans': orphans = [] global_lib = get_global_experiment_library() for i, record_id in enumerate(experiment_record_ids): @@ -373,7 +374,7 @@ def _filter_experiment_record_list(user_range, experiment_record_ids): which_ones = interpret_numbers(user_range) if which_ones is None: raise Exception('Could not interpret user range: "{}"'.format(user_range)) - filters = [False]*len(experiment_record_ids) + filters = [False] * len(experiment_record_ids) for i in which_ones: filters[i] = True return filters @@ -410,12 +411,13 @@ def interpret_numbers(user_range): """ if all(d in '0123456789-,' for d in user_range): numbers_and_ranges = user_range.split(',') - numbers = [n for lst in [[int(s)] if '-' not in s else range(int(s[:s.index('-')]), int(s[s.index('-')+1:])+1) for s in numbers_and_ranges] for n in lst] + numbers = [n for lst in [[int(s)] if '-' not in s else range(int(s[:s.index('-')]), int(s[s.index('-') + 1:]) + 1) for s in numbers_and_ranges] for n in lst] return numbers else: return None -def run_experiment(experiment, slurm_job = False, experiment_path=None, **experiment_record_kwargs): + +def run_experiment(experiment, slurm_job=False, experiment_path=None, **experiment_record_kwargs): """ Run an experiment and save the results. Return a string which uniquely identifies the experiment. You can run the experiment again later by calling show_experiment(location_string): @@ -438,11 +440,12 @@ def run_experiment(experiment, slurm_job = False, experiment_path=None, **experi I am aware that we could potentially save code and make this super slick by designing a subclass of Experiment which would be a 'DistributedSlurmExperiment', but this is future work. For now, this works. """ - assert "SLURM_NODEID" in os.environ.keys(), "You indicated that the experiment '{}' is run within a SLURM call, however the environment variable 'SLURM_NODEID' could not be found".format(experiment.get_id()) + assert "SLURM_NODEID" in os.environ.keys(), "You indicated that the experiment '{}' is run within a SLURM call, however the environment variable 'SLURM_NODEID' could not be found".format( + experiment.get_id()) if int(os.environ["SLURM_NODEID"]) > 0: return if experiment_path: - 'As mentioned above, global variables are reset, so I reset the one element I actually use' #TODO: Make this more elegant + 'As mentioned above, global variables are reset, so I reset the one element I actually use' # TODO: Make this more elegant set_non_persistent_config_value(config_filename=".artemisrc", section="experiments", option="experiment_directory", value=experiment_path) return experiment.run(**experiment_record_kwargs) @@ -464,7 +467,7 @@ def run_experiment_by_name(name, exp_dict='global', slurm_job=False, experiment_ if exp_dict == 'global': exp_dict = get_global_experiment_library() experiment = exp_dict[name] - return run_experiment(experiment,slurm_job, experiment_path, **experiment_record_kwargs) + return run_experiment(experiment, slurm_job, experiment_path, **experiment_record_kwargs) def run_experiment_ignoring_errors(name, **kwargs): @@ -480,28 +483,28 @@ def run_multiple_experiments_with_slurm(experiments, n_parallel=None, max_proces ''' if n_parallel and n_parallel > 1: # raise NotImplementedError("No parallel Slurm execution at the moment. Implement it!") - print ('Warning... parallel-slurm integration is very beta. Use with caution') + print('Warning... parallel-slurm integration is very beta. Use with caution') experiment_subsets = divide_into_subsets(experiments, subset_size=n_parallel) for i, exp_subset in enumerate(experiment_subsets): nanny = Nanny() function_call = partial(run_multiple_experiments, - experiments=exp_subset, - parallel=n_parallel if max_processes_per_node is None else max_processes_per_node, - display_results=False, - run_args = run_args - ) - spp = SlurmPythonProcess(name="Group %i"%i, function=function_call,ip_address="127.0.0.1", slurm_kwargs=slurm_kwargs) + experiments=exp_subset, + parallel=n_parallel if max_processes_per_node is None else max_processes_per_node, + display_results=False, + run_args=run_args + ) + spp = SlurmPythonProcess(name="Group %i" % i, function=function_call, ip_address="127.0.0.1", slurm_kwargs=slurm_kwargs) # Using Nanny only for convenient stdout & stderr forwarding. - nanny.register_child_process(spp,monitor_for_termination=False) + nanny.register_child_process(spp, monitor_for_termination=False) nanny.execute_all_child_processes(time_out=2) else: - for i,exp in enumerate(experiments): + for i, exp in enumerate(experiments): nanny = Nanny() function_call = partial(run_experiment, experiment=exp, slurm_job=True, experiment_path=get_experiment_dir(), - raise_exceptions=raise_exceptions,display_results=False, **run_args) - spp = SlurmPythonProcess(name="Exp %i"%i, function=function_call,ip_address="127.0.0.1", slurm_kwargs=slurm_kwargs) + raise_exceptions=raise_exceptions, display_results=False, **run_args) + spp = SlurmPythonProcess(name="Exp %i" % i, function=function_call, ip_address="127.0.0.1", slurm_kwargs=slurm_kwargs) # Using Nanny only for convenient stdout & stderr forwarding. - nanny.register_child_process(spp,monitor_for_termination=False) + nanny.register_child_process(spp, monitor_for_termination=False) nanny.execute_all_child_processes(time_out=2) @@ -513,7 +516,7 @@ def _parallel_run_target(experiment_id_and_prefix, raise_exceptions, **kwargs): return run_experiment_ignoring_errors(experiment_id, prefix=prefix, **kwargs) -def run_multiple_experiments(experiments, prefixes = None, parallel = False, display_results=False, raise_exceptions=True, notes = (), run_args = {}): +def run_multiple_experiments(experiments, prefixes=None, parallel=False, display_results=False, raise_exceptions=True, notes=(), run_args={}): """ Run multiple experiments, optionally in parallel with multiprocessing. @@ -535,8 +538,8 @@ def run_multiple_experiments(experiments, prefixes = None, parallel = False, dis experiment_identifiers = [ex.get_id() for ex in experiments] if prefixes is None: prefixes = range(len(experiment_identifiers)) - prefixes = [s+': ' for s in equalize_string_lengths(prefixes, side='right')] - print ('Prefix key: \n'+'\n'.join('{}{}'.format(p, eid) for p, eid in izip_equal(prefixes, experiment_identifiers))) + prefixes = [s + ': ' for s in equalize_string_lengths(prefixes, side='right')] + print('Prefix key: \n' + '\n'.join('{}{}'.format(p, eid) for p, eid in izip_equal(prefixes, experiment_identifiers))) target_func = partial(_parallel_run_target, notes=notes, raise_exceptions=raise_exceptions, **run_args) p = multiprocessing.Pool(processes=parallel) @@ -564,8 +567,8 @@ def get_multiple_records(experiment, n, only_completed=True, if_not_enough='run' if if_not_enough == 'err': assert len(records) >= n, "You asked for {} records, but only {} were available".format(n, len(records)) return records[-n:] - elif if_not_enough=='run': - for k in range(n-len(records)): + elif if_not_enough == 'run': + for k in range(n - len(records)): record = experiment.run() records.append(record) return records[-n:] @@ -587,7 +590,7 @@ def remove_common_results_prefix(results_dict): return OrderedDict((k, v) for k, v in izip_equal(trimmed_keys, results_dict.values())) -def get_experient_to_record_dict(experiment_ids = None): +def get_experient_to_record_dict(experiment_ids=None): """ Given a list of experiment ids, return an OrderedDict whose keys are the experiment ids and whose values are lists of experiment record ids. @@ -626,15 +629,15 @@ def get_experiment_tuple(exp_id): if exp_id in exp_to_parent: parent_id = exp_to_parent[exp_id] parent_tuple = get_experiment_tuple(parent_id) - return parent_tuple + (exp_id[len(parent_id)+1:], ) + return parent_tuple + (exp_id[len(parent_id) + 1:],) else: - return (exp_id, ) + return (exp_id,) # Then for each experiment in the list, tuples = [get_experiment_tuple(eid) for eid in experiment_ids] de_prefixed_tuples = remove_common_prefix(tuples, keep_base=False) - start_with = '' if len(de_prefixed_tuples[0])==len(tuples[0]) else '.' - new_strings = [start_with+'.'.join(ex_tup) for ex_tup in de_prefixed_tuples] + start_with = '' if len(de_prefixed_tuples[0]) == len(tuples[0]) else '.' + new_strings = [start_with + '.'.join(ex_tup) for ex_tup in de_prefixed_tuples] return new_strings diff --git a/artemis/experiments/experiments.py b/artemis/experiments/experiments.py index bf0aa78f..4e559478 100644 --- a/artemis/experiments/experiments.py +++ b/artemis/experiments/experiments.py @@ -3,10 +3,14 @@ from collections import OrderedDict from contextlib import contextmanager from functools import partial +from typing import Optional, Mapping, Callable, Any, Iterator, Tuple +from typing import Sequence + from six import string_types from artemis.experiments.experiment_record import ExpStatusOptions, experiment_id_to_record_ids, load_experiment_record, \ get_all_record_ids, clear_experiment_records +from artemis.experiments.experiment_record import ExperimentRecord from artemis.experiments.experiment_record import run_and_record from artemis.experiments.experiment_record_view import compare_experiment_records, show_record from artemis.experiments.hyperparameter_search import parameter_search @@ -24,8 +28,15 @@ class Experiment(object): create variants using decorated_function.add_variant() """ - def __init__(self, function=None, show=None, compare=None, one_liner_function=None, result_parser = None, - name=None, is_root=False): + def __init__(self, + function: Optional[Callable] = None, + show: Optional[Callable[[ExperimentRecord], None]] = None, + compare: Optional[Callable[[Sequence[ExperimentRecord]], bool]] = None, + one_liner_function=Optional[Callable[[ExperimentRecord], str]], + result_parser: Optional[Callable[[ExperimentRecord], Sequence[Tuple[str, str]]]] = None, + name: Optional[str] = None, + is_root=False + ): """ :param function: The function defining the experiment :param display_function: A function that can be called to display the results returned by function. @@ -43,26 +54,31 @@ def __init__(self, function=None, show=None, compare=None, one_liner_function=No self.variants = OrderedDict() self._notes = [] self.is_root = is_root - self._tags= set() + self._tags = set() if not is_root: all_args, varargs_name, kargs_name, defaults = advanced_getargspec(function) undefined_args = [a for a in all_args if a not in defaults] - assert len(undefined_args)==0, "{} is not a root-experiment, but arguments {} are undefined. Either provide a value for these arguments or define this as a root_experiment (see {})."\ - .format(self, undefined_args, 'X.add_root_variant(...)' if isinstance(function, partial) else 'X.add_config_root_variant(...)' if isinstance(function, PartialReparametrization) else '@experiment_root') + assert len( + undefined_args) == 0, "{} is not a root-experiment, but arguments {} are undefined. Either provide a value for these arguments or define this as a root_experiment (see {})." \ + .format(self, undefined_args, 'X.add_root_variant(...)' if isinstance(function, partial) else 'X.add_config_root_variant(...)' if isinstance(function, + PartialReparametrization) else '@experiment_root') _register_experiment(self) @property - def show(self): + def show(self) -> Callable[[ExperimentRecord], None]: + """ A function that somehow displays the experiment record to the user. """ return self._show @property - def one_liner_function(self): + def one_liner_function(self) -> Callable[[ExperimentRecord], str]: + """ A function which summarizes the experiment result as a one-line string """ return self._one_liner_results @property - def compare(self): + def compare(self) -> Callable[[Sequence[ExperimentRecord]], None]: + """ Get a function that visually compares multiple records """ return self._compare @compare.setter @@ -70,7 +86,8 @@ def compare(self, val): self._compare = val @property - def result_parser(self): + def result_parser(self) -> Callable[[ExperimentRecord], Sequence[Tuple[str, str]]]: + """ Get the function that parses the experiment result into a sequence of (column, column_value) for display as a row of a table """ return self._result_parser def __call__(self, *args, **kwargs): @@ -80,20 +97,20 @@ def __call__(self, *args, **kwargs): def __str__(self): return 'Experiment {}'.format(self.name) - def get_args(self): + def get_args(self) -> Mapping[str, Any]: """ :return OrderedDict[str, Any]: An OrderedDict of arguments to the experiment """ all_arg_names, _, _, defaults = advanced_getargspec(self.function) return OrderedDict((name, defaults[name]) for name in all_arg_names) - def get_root_function(self): + def get_root_function(self) -> Callable: return get_partial_root(self.function) - def is_generator(self): + def is_generator(self) -> bool: return inspect.isgeneratorfunction(self.get_root_function()) - def call(self, *args, **kwargs): + def call(self, *args, **kwargs) -> ExperimentRecord: """ Call the experiment function without running as an experiment. If the experiment is a function, this is the same as just result = my_exp_func(). If it's defined as a generator, it loops and returns the last result. @@ -108,7 +125,8 @@ def call(self, *args, **kwargs): return result def run(self, print_to_console=True, show_figs=None, test_mode=None, keep_record=None, raise_exceptions=True, - display_results=False, notes = (), **experiment_record_kwargs): + display_results=False, notes: Optional[str] = (), **experiment_record_kwargs + ) -> ExperimentRecord: """ Run the experiment, and return the ExperimentRecord that is generated. @@ -130,20 +148,23 @@ def run(self, print_to_console=True, show_figs=None, test_mode=None, keep_record """ for exp_rec in self.iterator(print_to_console=print_to_console, show_figs=show_figs, test_mode=test_mode, keep_record=keep_record, - raise_exceptions=raise_exceptions, display_results=display_results, notes=notes, **experiment_record_kwargs): + raise_exceptions=raise_exceptions, display_results=display_results, notes=notes, **experiment_record_kwargs): pass return exp_rec def iterator(self, print_to_console=True, show_figs=None, test_mode=None, keep_record=None, raise_exceptions=True, - display_results=False, notes = (), **experiment_record_kwargs): + display_results=False, notes=(), **experiment_record_kwargs + ) -> Iterator[ExperimentRecord]: + """ Create an iteratator from an experiment defined on a generator-function. + The iterator yields an ExperimentResults wrapping the yield of the generator-function """ if keep_record is None: keep_record = keep_record_by_default if keep_record_by_default is not None else not test_mode exp_rec = None for exp_rec in run_and_record( - function = self.function, + function=self.function, experiment_id=self.name, print_to_console=print_to_console, show_figs=show_figs, @@ -152,16 +173,18 @@ def iterator(self, print_to_console=True, show_figs=None, test_mode=None, keep_r raise_exceptions=raise_exceptions, notes=notes, **experiment_record_kwargs - ): + ): yield exp_rec assert exp_rec is not None, 'Should nevah happen.' if display_results: self.show(exp_rec) return - def _create_experiment_variant(self, args, kwargs, is_root): + def _create_experiment_variant(self, args: Sequence[Any], kwargs: Mapping[str, Any], is_root: bool + ) -> 'Experiment': # TODO: For non-root variants, assert that all args are defined - assert len(args) in (0, 1), "When creating an experiment variant, you can either provide one unnamed argument (the experiment name), or zero, in which case the experiment is named after the named argumeents. See add_variant docstring" + assert len(args) in (0, + 1), "When creating an experiment variant, you can either provide one unnamed argument (the experiment name), or zero, in which case the experiment is named after the named argumeents. See add_variant docstring" name = args[0] if len(args) == 1 else _kwargs_to_experiment_name(kwargs) assert isinstance(name, str), 'Name should be a string. Not: {}'.format(name) assert name not in self.variants, 'Variant "%s" already exists.' % (name,) @@ -178,7 +201,8 @@ def _create_experiment_variant(self, args, kwargs, is_root): self.variants[name] = ex return ex - def add_variant(self, variant_name = None, **kwargs): + def add_variant(self, variant_name=None, **kwargs + ) -> 'Experiment': """ Add a variant to this experiment, and register it on the list of experiments. There are two ways you can do this: @@ -197,9 +221,10 @@ def add_variant(self, variant_name = None, **kwargs): :param kwargs: The named arguments which will differ from the base experiment. :return Experiment: The experiment. """ - return self._create_experiment_variant(() if variant_name is None else (variant_name, ), kwargs, is_root=False) + return self._create_experiment_variant(() if variant_name is None else (variant_name,), kwargs, is_root=False) - def add_root_variant(self, variant_name=None, **kwargs): + def add_root_variant(self, variant_name=None, **kwargs + ) -> 'Experiment': """ Add a variant to this experiment, but do NOT register it on the list of experiments. There are two ways you can do this: @@ -218,9 +243,9 @@ def add_root_variant(self, variant_name=None, **kwargs): :param kwargs: The named arguments which will differ from the base experiment. :return Experiment: The experiment. """ - return self._create_experiment_variant(() if variant_name is None else (variant_name, ), kwargs, is_root=True) + return self._create_experiment_variant(() if variant_name is None else (variant_name,), kwargs, is_root=True) - def copy_variants(self, other_experiment): + def copy_variants(self, other_experiment: 'Experiment') -> None: """ Copy over the variants from another experiment. @@ -230,12 +255,13 @@ def copy_variants(self, other_experiment): for variant in other_experiment.get_variants(): if variant is not self: variant_args = variant.get_args() - different_args = {k: v for k, v in variant_args.items() if base_args[k]!=v} - name_diff = variant.get_id()[len(other_experiment.get_id())+1:] + different_args = {k: v for k, v in variant_args.items() if base_args[k] != v} + name_diff = variant.get_id()[len(other_experiment.get_id()) + 1:] v = self.add_variant(name_diff, **different_args) v.copy_variants(variant) - def _add_config(self, name, arg_constructors, is_root): + def _add_config(self, name: str, arg_constructors: Mapping[str, Callable[[], Any]], is_root: bool + ) -> 'Experiment': assert isinstance(name, str), 'Name should be a string. Not: {}'.format(name) assert name not in self.variants, 'Variant "%s" already exists.' % (name,) assert '/' not in name, 'Experiment names cannot have "/" in them: {}'.format(name) @@ -251,7 +277,8 @@ def _add_config(self, name, arg_constructors, is_root): self.variants[name] = ex return ex - def add_config_variant(self, name, **arg_constructors): + def add_config_variant(self, name: str, **arg_constructors + ) -> 'Experiment': """ Add a variant where you redefine the constructor for arguments to the experiment. e.g. @@ -271,19 +298,21 @@ def demo_smooth_out_signal(smoother, signal): """ return self._add_config(name, arg_constructors=arg_constructors, is_root=False) - def add_config_root_variant(self, name, **arg_constructors): + def add_config_root_variant(self, name: str, **arg_constructors + ) -> 'Experiment': """ Add a config variant which requires additional parametrization. (See add_config_variant) """ return self._add_config(name, arg_constructors=arg_constructors, is_root=True) - def get_id(self): + def get_id(self) -> str: """ :return: A string uniquely identifying this experiment. """ return self.name - def get_variant(self, variant_name=None, **kwargs): + def get_variant(self, variant_name: Optional[str] = None, **kwargs + ) -> 'Experiment': """ Get a variant on this experiment. @@ -294,11 +323,12 @@ def get_variant(self, variant_name=None, **kwargs): if variant_name is None: variant_name = _kwargs_to_experiment_name(kwargs) else: - assert len(kwargs)==0, 'If you provide a variant name ({}), there is no need to specify the keyword arguments. ({})'.format(variant_name, kwargs) + assert len(kwargs) == 0, 'If you provide a variant name ({}), there is no need to specify the keyword arguments. ({})'.format(variant_name, kwargs) assert variant_name in self.variants, "No variant '{}' exists. Existing variants: {}".format(variant_name, list(self.variants.keys())) return self.variants[variant_name] - def get_records(self, only_completed=False): + def get_records(self, only_completed=False + ) -> 'Sequence[ExperimentRecord]': """ Get all records associated with this experiment. @@ -307,12 +337,12 @@ def get_records(self, only_completed=False): """ records = [load_experiment_record(rid) for rid in experiment_id_to_record_ids(self.name)] if only_completed: - records = [record for record in records if record.get_status()==ExpStatusOptions.FINISHED] + records = [record for record in records if record.get_status() == ExpStatusOptions.FINISHED] return records - def browse(self, command=None, catch_errors = False, close_after = False, filterexp=None, filterrec = None, - view_mode ='full', raise_display_errors=False, run_args=None, keep_record=True, truncate_result_to=100, - cache_result_string = False, remove_prefix = None, display_format='nested', **kwargs): + def browse(self, command: Optional[str] = None, catch_errors=False, close_after=False, filterexp: Optional[str] = None, filterrec=None, + view_mode='full', raise_display_errors=False, run_args=None, keep_record=True, truncate_result_to=100, + cache_result_string=False, remove_prefix=None, display_format='nested', **kwargs) -> None: """ Open up the UI, which allows you to run experiments and view their results. @@ -335,9 +365,9 @@ def browse(self, command=None, catch_errors = False, close_after = False, filter from artemis.experiments.ui import ExperimentBrowser experiments = get_ordered_descendents_of_root(root_experiment=self) browser = ExperimentBrowser(experiments=experiments, catch_errors=catch_errors, close_after=close_after, - filterexp=filterexp, filterrec=filterrec, view_mode=view_mode, raise_display_errors=raise_display_errors, - run_args=run_args, keep_record=keep_record, truncate_result_to=truncate_result_to, cache_result_string=cache_result_string, - remove_prefix=remove_prefix, display_format=display_format, **kwargs) + filterexp=filterexp, filterrec=filterrec, view_mode=view_mode, raise_display_errors=raise_display_errors, + run_args=run_args, keep_record=keep_record, truncate_result_to=truncate_result_to, cache_result_string=cache_result_string, + remove_prefix=remove_prefix, display_format=display_format, **kwargs) browser.launch(command=command) # Above this line is the core api.... @@ -354,7 +384,7 @@ def has_record(self, completed=True, valid=True): records = self.get_records(only_completed=completed) if valid: records = [record for record in records if record.args_valid()] - return len(records)>0 + return len(records) > 0 def get_variants(self): return self.variants.values() @@ -376,7 +406,7 @@ def get_all_variants(self, include_roots=False, include_self=True): def test(self, **kwargs): self.run(test_mode=True, **kwargs) - def get_latest_record(self, only_completed=False, if_none = 'skip'): + def get_latest_record(self, only_completed=False, if_none='skip'): """ Return the ExperimentRecord from the latest run of this Experiment. @@ -389,10 +419,10 @@ def get_latest_record(self, only_completed=False, if_none = 'skip'): """ assert if_none in ('skip', 'err', 'run') records = self.get_records(only_completed=only_completed) - if len(records)==0: - if if_none=='run': + if len(records) == 0: + if if_none == 'run': return self.run() - elif if_none=='err': + elif if_none == 'err': raise Exception('No{} records for experiment "{}"'.format(' completed' if only_completed else '', self.name)) else: return None @@ -424,7 +454,7 @@ def get_variant_records(self, only_completed=False, only_last=False, flat=False) else: return exp_record_dict - def add_parameter_search(self, name='parameter_search', fixed_args = {}, space = None, n_calls=None, search_params = None, scalar_func=None): + def add_parameter_search(self, name='parameter_search', fixed_args={}, space=None, n_calls=None, search_params=None, scalar_func=None): """ :param name: Name of the Experiment to be created :param dict[str, Any] fixed_args: Any fixed-arguments to provide to all experiments. @@ -455,7 +485,7 @@ def search_func(fixed): n_calls_to_make = n_calls if n_calls is not None else 3 if is_test_mode() else 100 this_objective = partial(objective, **fixed) for iter_info in parameter_search(this_objective, n_calls=n_calls_to_make, space=space, **search_params): - info = dict(names=list(space.keys()), x_iters =iter_info.x_iters, func_vals=iter_info.func_vals, score = iter_info.func_vals, x=iter_info.x, fun=iter_info.fun) + info = dict(names=list(space.keys()), x_iters=iter_info.x_iters, func_vals=iter_info.func_vals, score=iter_info.func_vals, x=iter_info.x, fun=iter_info.fun) latest_info = {name: val for name, val in izip_equal(info['names'], iter_info.x_iters[-1])} print('Latest: {}, Score: {:.3g}'.format(latest_info, iter_info.func_vals[-1])) yield info @@ -467,7 +497,7 @@ def search_func(fixed): # param_search = locals()['param_search'] search_exp_func = partial(search_func, fixed=fixed_args) # We do this so that the fixed parameters will be recorded and we will see if they changed. - search_exp = ExperimentFunction(name = self.name + '.'+ name, show = show_parameter_search_record, one_liner_function=parameter_search_one_liner)(search_exp_func) + search_exp = ExperimentFunction(name=self.name + '.' + name, show=show_parameter_search_record, one_liner_function=parameter_search_one_liner)(search_exp_func) self.variants[name] = search_exp search_exp.tag('psearch') # Secret feature that makes it easy to select all parameter experiments in ui with "filter tag:psearch" return search_exp @@ -489,12 +519,13 @@ def get_tags(self): def show_parameter_search_record(record): from tabulate import tabulate result = record.get_result() - table = tabulate([list(xs)+[fun] for xs, fun in zip(result['x_iters'], result['func_vals'])], headers=list(result['names'])+['score']) + table = tabulate([list(xs) + [fun] for xs, fun in zip(result['x_iters'], result['func_vals'])], headers=list(result['names']) + ['score']) print(table) def parameter_search_one_liner(result): - return '{} Runs : '.format(len(result["x_iters"])) + ', '.join('{}={:.3g}'.format(k, v) for k, v in izip_equal(result['names'], result['x'])) + ' : Score = {:.3g}'.format(result["fun"]) + return '{} Runs : '.format(len(result["x_iters"])) + ', '.join('{}={:.3g}'.format(k, v) for k, v in izip_equal(result['names'], result['x'])) + ' : Score = {:.3g}'.format( + result["fun"]) _GLOBAL_EXPERIMENT_LIBRARY = OrderedDict() @@ -502,7 +533,7 @@ def parameter_search_one_liner(result): class ExperimentNotFoundError(Exception): def __init__(self, experiment_id): - Exception.__init__(self,'Experiment "{}" could not be loaded, either because it has not been imported, or its definition was removed.'.format(experiment_id)) + Exception.__init__(self, 'Experiment "{}" could not be loaded, either because it has not been imported, or its definition was removed.'.format(experiment_id)) def clear_all_experiments(): @@ -537,7 +568,8 @@ def add_two_numbers(a=1, b=2): def _register_experiment(experiment): - assert experiment.name not in _GLOBAL_EXPERIMENT_LIBRARY, 'You have already registered an experiment named {} in {}'.format(experiment.name, inspect.getmodule(experiment.get_root_function()).__name__) + assert experiment.name not in _GLOBAL_EXPERIMENT_LIBRARY, 'You have already registered an experiment named {} in {}'.format(experiment.name, inspect.getmodule( + experiment.get_root_function()).__name__) _GLOBAL_EXPERIMENT_LIBRARY[experiment.name] = experiment @@ -579,7 +611,7 @@ def _kwargs_to_experiment_name(kwargs): @contextmanager -def hold_global_experiment_libary(new_lib = None): +def hold_global_experiment_libary(new_lib=None): if new_lib is None: new_lib = OrderedDict() @@ -598,7 +630,7 @@ def get_global_experiment_library(): @contextmanager -def experiment_testing_context(close_figures_at_end = True, new_experiment_lib = False): +def experiment_testing_context(close_figures_at_end=True, new_experiment_lib=False): """ Use this context when testing the experiment/experiment_record infrastructure. Should only really be used in test_experiment_record.py diff --git a/artemis/fileman/disk_memoize.py b/artemis/fileman/disk_memoize.py index 3cbef5e7..51952905 100644 --- a/artemis/fileman/disk_memoize.py +++ b/artemis/fileman/disk_memoize.py @@ -1,5 +1,8 @@ +import inspect import logging import os +import time +from contextlib import contextmanager from functools import partial from shutil import rmtree @@ -7,6 +10,7 @@ from artemis.general.functional import infer_arg_values from artemis.general.hashing import compute_fixed_hash from artemis.general.test_mode import is_test_mode +from eagle_eyes.utils.utils_for_testing import hold_tempdir logging.basicConfig() LOGGER = logging.getLogger(__name__) @@ -20,6 +24,16 @@ MEMO_DIR = get_artemis_data_path('memoize_to_disk') +@contextmanager +def hold_temp_memo_dir(): + global MEMO_DIR + oldone = MEMO_DIR + with hold_tempdir() as path: + MEMO_DIR = path + yield path + MEMO_DIR = oldone + + def memoize_to_disk(fcn, local_cache = False, disable_on_tests=False, use_cpickle = False, suppress_info = False): """ Save (memoize) computed results to disk, so that the same function, called with the @@ -83,8 +97,12 @@ def check_memos(*args, **kwargs): with open(filepath, 'rb') as f: try: if not suppress_info: - LOGGER.info('Reading memo for function {}'.format(fcn.__name__, )) + LOGGER.info('Reading memo for function {}...'.format(fcn.__name__, )) + tstart = time.monotonic() result = pickle.load(f) + if not suppress_info: + LOGGER.info(f'...Reading memo for function {fcn.__name__} took {time.monotonic()-tstart:.5f}s'.format(fcn.__name__, )) + except (ValueError, ImportError, EOFError) as err: if isinstance(err, (ValueError, EOFError)) and not suppress_info: LOGGER.warn('Memo-file "{}" was corrupt. ({}: {}). Recomputing.'.format(filepath, err.__class__.__name__, str(err))) @@ -94,7 +112,13 @@ def check_memos(*args, **kwargs): result = fcn(*args, **kwargs) else: result_computed = True - result = fcn(*args, **kwargs) + if inspect.isgeneratorfunction(fcn): + # TODO: Do this properly - caching results one at a time + LOGGER.info(f"Computing results from generator {fcn} in advance...") + result = list(fcn(*args, **kwargs)) + LOGGER.info('... Done') + else: + result = fcn(*args, **kwargs) else: result_computed = True result = fcn(*args, **kwargs) diff --git a/artemis/fileman/test_disk_memoize.py b/artemis/fileman/test_disk_memoize.py index 3915ac5e..809028b8 100644 --- a/artemis/fileman/test_disk_memoize.py +++ b/artemis/fileman/test_disk_memoize.py @@ -15,6 +15,14 @@ def compute_slow_thing(a, b, c): return (a+b)/float(c), call_time + +@memoize_to_disk_test +def compute_slow_thing_with_type_annotations(a, b, c: int =3): + call_time = time.time() + time.sleep(0.01) + return (a+b)/float(c), call_time + + def test_memoize_to_disk(): clear_memo_files_for_function(compute_slow_thing) @@ -106,7 +114,7 @@ def test_clear_error_for_missing_arg(): clear_memo_files_for_function(compute_slow_thing) - with raises(AssertionError): + with raises(TypeError): compute_slow_thing(1) @@ -114,7 +122,7 @@ def test_clear_arror_for_wrong_arg(): clear_memo_files_for_function(compute_slow_thing) - with raises(AssertionError): + with raises(TypeError): compute_slow_thing(a=1, b=2, c=3, d=4) @@ -129,7 +137,7 @@ def test_unnoticed_wrong_arg_bug_is_dead(): clear_memo_files_for_function(compute_slow_thing) compute_slow_thing(a=1, b=2, c=3) # Creates a memo - with raises(AssertionError): + with raises(TypeError): compute_slow_thing(a=1, b=2, see=3) # Previously, this was not caught, leading you not to notice your typo @@ -151,12 +159,43 @@ def test_catch_kwarg_error(): assert t3 == t1 +def test_memoize_to_disk_with_annotations(): + + clear_memo_files_for_function(compute_slow_thing_with_type_annotations) + + t = time.time() + num, t1 = compute_slow_thing_with_type_annotations(1, 3) + assert t-t1 < 0.01 + assert num == (1+3)/3. + + num, t2 = compute_slow_thing_with_type_annotations(1, 3) + assert num == (1+3)/3. + assert t2==t1 + + +@memoize_to_disk_test +def iter_slowly(): + for i in range(5): + time.sleep(0.01) + yield time.time(), i + + +def test_memoize_iter_slowly(): + + clear_memo_files_for_function(iter_slowly) + results_1 = list(iter_slowly()) + results_2 = list(iter_slowly()) + assert results_1 == results_2 + + if __name__ == '__main__': set_test_mode(True) - test_unnoticed_wrong_arg_bug_is_dead() - test_catch_kwarg_error() - test_clear_arror_for_wrong_arg() - test_clear_error_for_missing_arg() - test_memoize_to_disk_and_cache() - test_memoize_to_disk() - test_complex_args() + # test_unnoticed_wrong_arg_bug_is_dead() + # test_catch_kwarg_error() + # test_clear_arror_for_wrong_arg() + # test_clear_error_for_missing_arg() + # test_memoize_to_disk_and_cache() + # test_memoize_to_disk() + # test_complex_args() + # test_memoize_to_disk() + test_memoize_iter_slowly() diff --git a/artemis/general/display.py b/artemis/general/display.py index 28093cd4..aa25296e 100644 --- a/artemis/general/display.py +++ b/artemis/general/display.py @@ -78,7 +78,7 @@ def equalize_string_lengths(arr, side = 'left'): return strings -def sensible_str(data, size_limit=4, compact=True): +def sensible_str(data, size_limit=4, compact=True) -> str: """ Crawl through an data structure and try to make a sensible compact representation of it. :param data: Some data structure. diff --git a/artemis/general/functional.py b/artemis/general/functional.py index 3acc6b97..b8b950ac 100644 --- a/artemis/general/functional.py +++ b/artemis/general/functional.py @@ -3,6 +3,9 @@ from collections import OrderedDict from functools import partial import collections + +from cv2.gapi.ie.detail import PARAM_DESC_KIND_LOAD + from artemis.general.should_be_builtins import separate_common_items import sys import types @@ -206,23 +209,38 @@ def infer_arg_values(f, args=(), kwargs={}): :param kwargs: A dict of keyword args :return: An OrderedDict(arg_name->arg_value) """ - all_arg_names, varargs_name, kwargs_name, defaults = inspect.getargspec(f) + # all_arg_names, varargs_name, kwargs_name, defaults = inspect.getargspec(f) + + sig = inspect.signature(f) + all_arg_names = sig.parameters + + bound_args = sig.bind(*args, **kwargs) + bound_args.apply_defaults() - assert varargs_name is None, "This function doesn't work with unnamed args" - default_args = {k: v for k, v in zip(all_arg_names[len(all_arg_names)-(len(defaults) if defaults is not None else 0):], defaults if defaults is not None else [])} - args_with_values = set(all_arg_names[:len(args)]+list(default_args.keys())+list(kwargs.keys())) - assert set(all_arg_names).issubset(args_with_values), "Arguments {} require values but are not given any. ".format(tuple(set(all_arg_names).difference(args_with_values))) + assert not any(p.kind.name=='VAR_POSITIONAL' for p in sig.parameters.values()), "This function doesn't work with unnamed args" + # assert varargs_name is None, "This function doesn't work with unnamed args" + # default_args = {k: v for k, v in zip(all_arg_names[len(all_arg_names)-(len(defaults) if defaults is not None else 0):], defaults if defaults is not None else [])} + # args_with_values = set(all_arg_names[:len(args)]+list(default_args.keys())+list(kwargs.keys())) + + + # assert set(all_arg_names).issubset(args_with_values), "Arguments {} require values but are not given any. ".format(tuple(set(all_arg_names).difference(args_with_values))) assert len(args) <= len(all_arg_names), "You provided {} arguments, but the function only takes {}".format(len(args), len(all_arg_names)) - full_args = tuple( - list(zip(all_arg_names, args)) # Handle unnamed args f(1, 2) - + [(name, kwargs[name] if name in kwargs else default_args[name]) for name in all_arg_names[len(args):]] # Handle named keyworkd args f(a=1, b=2) - + [(name, kwargs[name]) for name in kwargs if name not in all_arg_names[len(args):]] # Need to handle case if f takes **kwargs - ) - duplicates = tuple(item for item, count in collections.Counter([a for a, _ in full_args]).items() if count > 1) - assert len(duplicates)==0, 'Arguments {} have been defined multiple times: {}'.format(duplicates, full_args) - - common_args, (different_args, different_given_args) = separate_common_items([tuple(all_arg_names), tuple(n for n, _ in full_args)]) - if kwargs_name is None: # There is no **kwargs - assert len(different_given_args)==0, "Function {} was given args {} but didn't ask for them".format(f, different_given_args) - assert len(different_args)==0, "Function {} needs values for args {} but didn't get them".format(f, different_args) + + full_args = bound_args.arguments + # full_args = tuple( + # (pname, ) + # ) + # + # full_args = tuple( + # list(zip(all_arg_names, args)) # Handle unnamed args f(1, 2) + # + [(name, kwargs[name] if name in kwargs else default_args[name]) for name in all_arg_names[len(args):]] # Handle named keyworkd args f(a=1, b=2) + # + [(name, kwargs[name]) for name in kwargs if name not in all_arg_names[len(args):]] # Need to handle case if f takes **kwargs + # ) + # duplicates = tuple(item for item, count in collections.Counter([a for a, _ in full_args]).items() if count > 1) + # assert len(duplicates)==0, 'Arguments {} have been defined multiple times: {}'.format(duplicates, full_args) + + # common_args, (different_args, different_given_args) = separate_common_items([tuple(all_arg_names), tuple(n for n, _ in full_args)]) + # if kwargs_name is None: # There is no **kwargs + # assert len(different_given_args)==0, "Function {} was given args {} but didn't ask for them".format(f, different_given_args) + # assert len(different_args)==0, "Function {} needs values for args {} but didn't get them".format(f, different_args) return OrderedDict(full_args) diff --git a/artemis/general/hashing.py b/artemis/general/hashing.py index dab32412..e1c965c7 100644 --- a/artemis/general/hashing.py +++ b/artemis/general/hashing.py @@ -78,6 +78,7 @@ def compute_fixed_hash(obj, try_objects=False, _hasher = None, _memo = None, _co for k in keys: compute_fixed_hash(k, **kwargs) compute_fixed_hash(obj.__dict__[k], **kwargs) + else: # TODO: Consider whether to pickle by default. Note that pickle strings are not necessairly the same for identical objects. raise NotImplementedError("Don't have a method for hashing this %s" % (obj, )) From d6eb235489175e7f574d4da72dedc720e72766e3 Mon Sep 17 00:00:00 2001 From: peter Date: Sat, 7 Jan 2023 18:24:55 -0800 Subject: [PATCH 041/107] changes made from eagleeyes --- .gitignore | 2 +- .travis.yml | 2 +- artemis/examples/demo_mnist_logreg.py | 2 +- artemis/experiments/decorators.py | 6 +-- artemis/experiments/experiment_record.py | 6 +-- artemis/experiments/experiments.py | 42 +++++++++++++++-- artemis/experiments/ui.py | 2 +- artemis/fileman/config_files.py | 2 +- artemis/fileman/disk_memoize.py | 4 +- artemis/fileman/images2gif.py | 2 +- artemis/fileman/persistent_ordered_dict.py | 2 +- artemis/general/custom_types.py | 47 +++++++++++++++++++ artemis/general/dead_easy_ui.py | 4 +- artemis/general/display.py | 8 ++-- artemis/general/duck.py | 2 +- artemis/general/global_rates.py | 2 +- artemis/general/hashing.py | 4 +- artemis/general/nondeterminism_hunting.py | 4 +- artemis/general/profile.py | 2 +- artemis/general/redict.py | 2 +- artemis/general/should_be_builtins.py | 4 +- artemis/general/test_duck.py | 2 +- artemis/general/test_hashing.py | 2 +- artemis/image_processing/__init__.py | 0 artemis/ml/datasets/cifar.py | 4 +- artemis/ml/tools/running_averages.py | 2 +- artemis/ml/tools/test_processors.py | 2 +- artemis/ml/tools/test_running_averages.py | 2 +- artemis/remote/child_processes.py | 18 +++---- artemis/remote/file_system.py | 12 ++--- artemis/remote/plotting/plotting_client.py | 4 +- artemis/remote/remote_function_run_script.py | 2 +- artemis/remote/remote_generator_run_script.py | 2 +- artemis/remote/test_child_processes.py | 12 ++--- artemis/remote/test_nanny.py | 14 +++--- artemis/remote/test_virtualenv.py | 6 +-- artemis/remote/utils.py | 4 +- artemis/remote/virtualenv.py | 8 ++-- 38 files changed, 164 insertions(+), 83 deletions(-) create mode 100644 artemis/general/custom_types.py create mode 100644 artemis/image_processing/__init__.py diff --git a/.gitignore b/.gitignore index e4150e3e..8735148b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ var/ *.egg # PyInstaller -# Usually these files are written by a python script from a template +# Usually these files are written by a ui_code script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec diff --git a/.travis.yml b/.travis.yml index a44f3459..114c837a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,4 @@ -language: python +language: ui_code python: - 2.7 - 3.6 diff --git a/artemis/examples/demo_mnist_logreg.py b/artemis/examples/demo_mnist_logreg.py index 09f21936..6c5874dc 100644 --- a/artemis/examples/demo_mnist_logreg.py +++ b/artemis/examples/demo_mnist_logreg.py @@ -18,7 +18,7 @@ 2) Through the experiment API. In the bottom of this file, you an see example code for running either the UI or API. You can try the different versions -by either running this file as "python demo_mnist_logreg.py ui" or "python demo_mnist_logreg.py api" +by either running this file as "ui_code demo_mnist_logreg.py ui" or "ui_code demo_mnist_logreg.py api" """ diff --git a/artemis/experiments/decorators.py b/artemis/experiments/decorators.py index 413432e9..5834dd3c 100644 --- a/artemis/experiments/decorators.py +++ b/artemis/experiments/decorators.py @@ -10,7 +10,7 @@ def experiment_function(f): """ Use this decorator (@experiment_function) on a function that you want to run. e.g. - .. code-block:: python + .. code-block:: ui_code @experiment_function def demo_my_experiment(a=1, b=2, c=3): @@ -27,7 +27,7 @@ def experiment_root(f): """ Use this decorator on a function that you want to build variants off of: - .. code-block:: python + .. code-block:: ui_code @experiment_root def demo_my_experiment(a, b=2, c=3): @@ -65,7 +65,7 @@ def __init__(self, show = None, compare = compare_experiment_records, display_fu show = lambda rec: display_function(rec.get_result()) if comparison_function is not None: - assert compare is None, "You can't set both display function and show. (display_function is deprecated)" + # assert sh is None, "You can't set both display function and show. (display_function is deprecated)" def compare(records): record_experiment_ids_uniquified = uniquify_duplicates(rec.get_experiment_id() for rec in records) diff --git a/artemis/experiments/experiment_record.py b/artemis/experiments/experiment_record.py index 7502458e..e8f8975c 100644 --- a/artemis/experiments/experiment_record.py +++ b/artemis/experiments/experiment_record.py @@ -32,7 +32,7 @@ try: from enum import Enum except ImportError: - raise ImportError("Failed to import the enum package. This was added in python 3.4 but backported back to 2.4. To install, run 'pip install --upgrade pip enum34'") + raise ImportError("Failed to import the enum package. This was added in ui_code 3.4 but backported back to 2.4. To install, run 'pip install --upgrade pip enum34'") logging.basicConfig() ARTEMIS_LOGGER = logging.getLogger('artemis') @@ -227,7 +227,7 @@ def open_file(self, filename, *args, **kwargs): txt = f.read() :param filename: Path within experiment directory (it can include subdirectories) - :param args, kwargs: Forwarded to python's "open" function + :param args, kwargs: Forwarded to ui_code's "open" function :return: A file object """ full_path = os.path.join(self._experiment_directory, filename) @@ -527,7 +527,7 @@ def open_in_record_dir(filename, *args, **kwargs): f.write('blahblahblah') :param filename: The name of the file, relative to your experiment directory, - :param args,kwargs: See python built-in "open" function + :param args,kwargs: See ui_code built-in "open" function :yield: The file object """ return get_current_experiment_record().open_file(filename, *args, **kwargs) diff --git a/artemis/experiments/experiments.py b/artemis/experiments/experiments.py index 4e559478..dd32aef2 100644 --- a/artemis/experiments/experiments.py +++ b/artemis/experiments/experiments.py @@ -3,6 +3,7 @@ from collections import OrderedDict from contextlib import contextmanager from functools import partial +from types import SimpleNamespace from typing import Optional, Mapping, Callable, Any, Iterator, Tuple from typing import Sequence @@ -97,6 +98,10 @@ def __call__(self, *args, **kwargs): def __str__(self): return 'Experiment {}'.format(self.name) + @property + def args(self) -> SimpleNamespace: + return SimpleNamespace(**self.get_args()) + def get_args(self) -> Mapping[str, Any]: """ :return OrderedDict[str, Any]: An OrderedDict of arguments to the experiment @@ -207,7 +212,7 @@ def add_variant(self, variant_name=None, **kwargs Add a variant to this experiment, and register it on the list of experiments. There are two ways you can do this: - .. code-block:: python + .. code-block:: ui_code # Name the experiment explicitely, then list the named arguments my_experiment_function.add_variant('big_a', a=10000) @@ -229,7 +234,7 @@ def add_root_variant(self, variant_name=None, **kwargs Add a variant to this experiment, but do NOT register it on the list of experiments. There are two ways you can do this: - .. code-block:: python + .. code-block:: ui_code # Name the experiment explicitely, then list the named arguments my_experiment_function.add_root_variant('big_a', a=10000) @@ -502,7 +507,7 @@ def search_func(fixed): search_exp.tag('psearch') # Secret feature that makes it easy to select all parameter experiments in ui with "filter tag:psearch" return search_exp - def tag(self, tag): + def tag(self, tag: str): """ Add a "tag" - a string identifying the experiment as being in some sort of group. You can use tags in the UI with 'filter tag:my_tag' to select experiments with a given tag @@ -515,6 +520,35 @@ def tag(self, tag): def get_tags(self): return self._tags + def used_to_define(self, *args): + """ + The function does nothing, simply lets you link your experiment to an object in the code + which is defined as a result of findings in the experiment. E.g. + + @experiment_function + def ex_train_classifiers_and_get_Score( + classifiers: Mapping[str, Classifier] + ) -> float: + ... + + ex_train_classifiers_and_get_Score.add_variant( + 'find_learning_rate', + classifiers = { + f'learning-rate-{lr}': MyClassifier(learning_rate = lr) + for lr in [0.1, 0.01, 0.001] + } + ).used_to_define(BEST_LEARNING_RATE) # Refers to a constant (e.g. BEST_LEARNING_RATE=0.01) which may be used elsewhere. + """ + return self + + def in_reference_to(self, *args): + """ + Like used_to_define - this funtion actually does nothing, but it can be used as a form of + documentation to remind you that this experiment was made in reference to some + other code object (e.g. another experiment - to expand on results found there) + """ + return self + def show_parameter_search_record(record): from tabulate import tabulate @@ -546,7 +580,7 @@ def capture_created_experiments(): A convenient way to cross-breed experiments. If you define experiments in this block, you can capture them for later use (for instance by modifying them). e.g.: - .. code-block:: python + .. code-block:: ui_code @experiment_function def add_two_numbers(a=1, b=2): diff --git a/artemis/experiments/ui.py b/artemis/experiments/ui.py index ad6b6b71..4365d1e5 100644 --- a/artemis/experiments/ui.py +++ b/artemis/experiments/ui.py @@ -45,7 +45,7 @@ try: from enum import Enum except ImportError: - raise ImportError("Failed to import the enum package. This was added in python 3.4 but backported back to 2.4. To install, run 'pip install --upgrade pip enum34'") + raise ImportError("Failed to import the enum package. This was added in ui_code 3.4 but backported back to 2.4. To install, run 'pip install --upgrade pip enum34'") def _warn_with_prompt(message= None, prompt = 'Press Enter to continue or q then Enter to quit', use_prompt=True): diff --git a/artemis/fileman/config_files.py b/artemis/fileman/config_files.py index c03b7352..9aa0eff7 100644 --- a/artemis/fileman/config_files.py +++ b/artemis/fileman/config_files.py @@ -32,7 +32,7 @@ def get_config_value(config_filename, section, option, default_generator=None, w :param write_default: Set to true if property was not found and you want to write the default value into the file. :param read_method: The method to read your setting. If left at None (default) it just returns the string. - If 'eval' it parses the setting into a python object + If 'eval' it parses the setting into a ui_code object If it is a function, it passes the value through the function before returning it. :param use_cashed_config: If set, will not read the config file from the file system but use the previously read and stored config file. Since the cashed config file might have been modified since reading it from disk, the returned value might be different from the value diff --git a/artemis/fileman/disk_memoize.py b/artemis/fileman/disk_memoize.py index 51952905..be969aa1 100644 --- a/artemis/fileman/disk_memoize.py +++ b/artemis/fileman/disk_memoize.py @@ -58,10 +58,10 @@ def fcn(a, b, c = None): b) You only want to memoize the function in one use-case, but not all. :param fcn: The function you're decorating - :param local_cache: Keep a cache in python (so you don't need to go to disk if you call again in the same process) + :param local_cache: Keep a cache in ui_code (so you don't need to go to disk if you call again in the same process) :param disable_on_tests: Persistent memos can really screw up tests, so disable memos when is_test_mode() returns True. Generally, leave this as true, unless you are testing memoization itself. - :param use_cpickle: Use CPickle, instead of pickle, to save results. This can be faster for complex python + :param use_cpickle: Use CPickle, instead of pickle, to save results. This can be faster for complex ui_code structures, but can be slower for numpy arrays. So we recommend not using it. :param suppress_info: Don't log info loading and saving memos. :return: A wrapper around the function that checks for memos and loads old results if they exist. diff --git a/artemis/fileman/images2gif.py b/artemis/fileman/images2gif.py index 7e36c599..b46d94bd 100644 --- a/artemis/fileman/images2gif.py +++ b/artemis/fileman/images2gif.py @@ -772,7 +772,7 @@ class NeuQuant: --------------------------------------------------------- Copyright (c) 1994 Anthony Dekker - Ported to python by Marius van Voorden in 2010 + Ported to ui_code by Marius van Voorden in 2010 NEUQUANT Neural-Net quantization algorithm by Anthony Dekker, 1994. See "Kohonen neural networks for optimal colour quantization" diff --git a/artemis/fileman/persistent_ordered_dict.py b/artemis/fileman/persistent_ordered_dict.py index 6e3661ba..913dd926 100644 --- a/artemis/fileman/persistent_ordered_dict.py +++ b/artemis/fileman/persistent_ordered_dict.py @@ -14,7 +14,7 @@ class PersistentOrderedDict(object): pod2 = PersistentOrderedDict('my_file.pkl') assert pod2['a'] == 1234 - This is similar to python's built in "shelve" module, but + This is similar to ui_code's built in "shelve" module, but - It is ordered, - There is no need to close (writing is done every time a key is set). diff --git a/artemis/general/custom_types.py b/artemis/general/custom_types.py new file mode 100644 index 00000000..a7bc98de --- /dev/null +++ b/artemis/general/custom_types.py @@ -0,0 +1,47 @@ +from typing import Tuple + +import numpy as np + +from typing import TypeVar, Generic, Tuple, Union, Optional +import numpy as np + +Shape = TypeVar("Shape") +DType = TypeVar("DType") + + +class Array(Generic[Shape, DType], np.ndarray): + """ + Use this to type-annotate numpy arrays, e.g. + + def transform_image(image: Array['H,W,3', np.uint8], ...): + ... + + """ + pass + + +GeneralImageArray = np.ndarray # Can be (H, W, C) or (H, W), uint8 or float or int or whatever +BGRImageArray = np.ndarray +RGBImageArray = np.ndarray +FlatBGRImageArray = np.ndarray +GreyScaleImageArray = np.ndarray +BGRFloatImageArray = np.ndarray # A BGR image containing floating point data (expected to be in range 0-255, but possibly outside) +HeatMapArray = np.ndarray # A (H, W) array of floats indicating a heatmap +IndexVector = np.ndarray # A vector of indices +FloatVector = np.ndarray # A vector of floats +BoolVector = np.ndarray # A vector of floats +LTRBBoxArray = np.ndarray # A (N, 4) array of (left, rop, right, bottom) integer box coordinates. +AnyImageArray = Union[BGRImageArray, GreyScaleImageArray, BGRFloatImageArray, HeatMapArray] +PointIJArray = np.ndarray +RelPointIJArray = np.ndarray # (i, j) coordinate, normalized to (0, 1) + + +BGRImageDeltaArray = np.ndarray # A (H, W) float array of values in [-255, 255] representing a delta between images +MaskImageArray = np.ndarray # A (H, W) array of floats indicating a heatmap +LabelImageArray = np.ndarray # A (H, W) array of integer labels + +XYSizeTuple = Tuple[int, int] +BGRColorTuple = Tuple[int, int, int] +XYPointTuple = Tuple[float, float] +IJPixelTuple = Tuple[int, int] +TimeIntervalTuple = Tuple[Optional[float], Optional[float]] diff --git a/artemis/general/dead_easy_ui.py b/artemis/general/dead_easy_ui.py index bfe9a482..320329b6 100644 --- a/artemis/general/dead_easy_ui.py +++ b/artemis/general/dead_easy_ui.py @@ -121,8 +121,8 @@ def _get_help_string(self, mymethods, method_names_for_help=None): def parse_user_function_call(cmd_str, arg_handling_mode = 'fallback'): """ - A simple way to parse a user call to a python function. The purpose of this is to make it easy for a user - to specify a python function and the arguments to call it with from the console. Example: + A simple way to parse a user call to a ui_code function. The purpose of this is to make it easy for a user + to specify a ui_code function and the arguments to call it with from the console. Example: parse_user_function_call("my_function 1 'two' a='three'") == ('my_function', (1, 'two'), {'a': 'three'}) diff --git a/artemis/general/display.py b/artemis/general/display.py index aa25296e..3b141760 100644 --- a/artemis/general/display.py +++ b/artemis/general/display.py @@ -51,11 +51,11 @@ def dict_to_str(d): def pyfuncstring_to_tex(pyfuncstr): """ Placeholder - we'd like to fill this out later. This should be a function that takes a short string representing a - python funciton and translates it to latex. e.g. + ui_code funciton and translates it to latex. e.g. pyfuncstring_to_text 'x**1.5/4' -> x^{1.5}/4 - :param pyfuncstr: A string representing a python function + :param pyfuncstr: A string representing a ui_code function :return: A Tex string what could be used to render the function nicely. """ string = pyfuncstr @@ -295,7 +295,7 @@ def wrap(self, text): return lines -def side_by_side(multiline_strings, gap=4, max_linewidth=None): +def side_by_side(multiline_strings, gap=4, gap_char = ' ', max_linewidth=None): """ Return a string that displays two multiline strings side-by-side. :param multiline_strings: A list of multi-line strings (ie strings with newlines) @@ -314,7 +314,7 @@ def side_by_side(multiline_strings, gap=4, max_linewidth=None): longests = [max(len(line) for line in lines) if len(lines)>0 else 0 for lines in lineses] - spacer = ' '*gap + spacer = gap_char*gap new_lines = [] for i in xrange(max(len(lines) for lines in lineses)): line = [lines[i] if i=2), you get a KeyError. - See test_regexp_dict for examples, and documentation for python's "re" module for info on regular expressions. + See test_regexp_dict for examples, and documentation for ui_code's "re" module for info on regular expressions. """ def __init__(self, dict_initializer): diff --git a/artemis/general/should_be_builtins.py b/artemis/general/should_be_builtins.py index 38ecaccc..432db3ce 100644 --- a/artemis/general/should_be_builtins.py +++ b/artemis/general/should_be_builtins.py @@ -351,7 +351,7 @@ def get_absolute_module(obj): e.g. assert get_absolute_module(get_absolute_module) == 'artemis.general.should_be_builtins' - :param obj: A python module, class, method, function, traceback, frame, or code object + :param obj: A ui_code module, class, method, function, traceback, frame, or code object :return: A string representing the import path. """ file_path = inspect.getfile(obj) @@ -424,7 +424,7 @@ def insert_at(list1, list2, indices): @contextmanager def nested(*contexts): """ - Reimplementation of nested in python 3. + Reimplementation of nested in ui_code 3. """ with ExitStack() as stack: for ctx in contexts: diff --git a/artemis/general/test_duck.py b/artemis/general/test_duck.py index dd0906e1..f2c60432 100644 --- a/artemis/general/test_duck.py +++ b/artemis/general/test_duck.py @@ -453,7 +453,7 @@ def test_key_get_on_set_bug(): def get_message(err): - # to be portable between python 2/3 + # to be portable between ui_code 2/3 return err.value.message if hasattr(err.value, 'message') else err.value.args[0] def test_reasonable_error_messages(): diff --git a/artemis/general/test_hashing.py b/artemis/general/test_hashing.py index 72a65a11..f1bc884d 100644 --- a/artemis/general/test_hashing.py +++ b/artemis/general/test_hashing.py @@ -6,7 +6,7 @@ def test_compute_fixed_hash(): - # Not really sure why the fixed hash differes between python 2 and 4 here (maybe something do do with changes to strings) + # Not really sure why the fixed hash differes between ui_code 2 and 4 here (maybe something do do with changes to strings) complex_obj = [1, 'd', {'a': 4, 'b': np.arange(10)}, (7, list(range(10)))] original_code = compute_fixed_hash(complex_obj) diff --git a/artemis/image_processing/__init__.py b/artemis/image_processing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/artemis/ml/datasets/cifar.py b/artemis/ml/datasets/cifar.py index b91cc528..5a748f5f 100644 --- a/artemis/ml/datasets/cifar.py +++ b/artemis/ml/datasets/cifar.py @@ -21,13 +21,13 @@ def get_cifar_100_dataset(n_training_samples=None, n_test_samples=None, whiten_i ''' directory = get_archive(relative_path='data/cifar-100', url='https://www.cs.toronto.edu/~kriz/cifar-100-python.tar.gz') - with open(os.path.join(directory, "cifar-100-python", "train"), 'rb') as fo: + with open(os.path.join(directory, "cifar-100-ui_code", "train"), 'rb') as fo: dict = pickle.load(fo) x_tr = dict["data"].reshape(-1, 3, 32, 32) y_tr = dict["fine_labels"] if fine_labels else dict["coarse_labels"] y_tr = np.array(y_tr) - with open(os.path.join(directory, "cifar-100-python", "test"), 'rb') as fo: + with open(os.path.join(directory, "cifar-100-ui_code", "test"), 'rb') as fo: dict = pickle.load(fo) x_ts = dict["data"].reshape(-1, 3, 32, 32) y_ts = dict["fine_labels"] if fine_labels else dict["coarse_labels"] diff --git a/artemis/ml/tools/running_averages.py b/artemis/ml/tools/running_averages.py index d0842636..1b4579c8 100644 --- a/artemis/ml/tools/running_averages.py +++ b/artemis/ml/tools/running_averages.py @@ -38,7 +38,7 @@ def __call__(self, data): @classmethod def batch(cls, x): try: - return recent_moving_average(x, axis=0) # Works only for python 2.X, with weave + return recent_moving_average(x, axis=0) # Works only for ui_code 2.X, with weave except ImportError: rma = RecentRunningAverage() return np.array([rma(xt) for xt in x]) diff --git a/artemis/ml/tools/test_processors.py b/artemis/ml/tools/test_processors.py index ffa44350..e7050298 100644 --- a/artemis/ml/tools/test_processors.py +++ b/artemis/ml/tools/test_processors.py @@ -21,7 +21,7 @@ def test_running_average(): assert all(np.allclose(out[i], np.mean(inp[:i+1], axis = 0)) for i in xrange(len(inp))) -@pytest.mark.skipif(True, reason='Depends on weave, which is deprecated for python 3') +@pytest.mark.skipif(True, reason='Depends on weave, which is deprecated for ui_code 3') def test_recent_running_average(): inp = np.arange(5) diff --git a/artemis/ml/tools/test_running_averages.py b/artemis/ml/tools/test_running_averages.py index 8795bcbb..b6b6e1bf 100644 --- a/artemis/ml/tools/test_running_averages.py +++ b/artemis/ml/tools/test_running_averages.py @@ -22,7 +22,7 @@ def test_running_average(): assert all(np.allclose(out[i], np.mean(inp[:i+1], axis = 0)) for i in xrange(len(inp))) -@pytest.mark.skipif(True, reason='Depends on weave, which is deprecated for python 3') +@pytest.mark.skipif(True, reason='Depends on weave, which is deprecated for ui_code 3') def test_recent_running_average(): inp = np.arange(5) diff --git a/artemis/remote/child_processes.py b/artemis/remote/child_processes.py index 286a9693..9037dfe7 100644 --- a/artemis/remote/child_processes.py +++ b/artemis/remote/child_processes.py @@ -215,16 +215,16 @@ def is_alive(self): class PythonChildProcess(ChildProcess): ''' - This ChildProcess is designed to spawn python processes. + This ChildProcess is designed to spawn ui_code processes. ''' def __init__(self, ip_address, command, **kwargs): ''' Creates a PythonChildProcess :param ip_address: The command will be executed at this ip_address - :param command: the command to execute. Is assumed to be a python call + :param command: the command to execute. Is assumed to be a ui_code call :param name: optional name. If not set, will be process_i, with i a global counter :param take_care_of_deconstruct: If set to True, deconstruct() is registered at exit - :param port_for_structured_back_communication: If set, the ip-address of this device as well as a specific port will be appended as arguments to the executed python command in the format --port=1234 --address=127.0.0.1 + :param port_for_structured_back_communication: If set, the ip-address of this device as well as a specific port will be appended as arguments to the executed ui_code command in the format --port=1234 --address=127.0.0.1 The child process is responsible for reading these values out and communicating to it. If set, this child process will expose a queue on that address with get_queue_from_cp() :return: ''' @@ -254,22 +254,22 @@ def prepare_command(self,command): command.append("--port=%i"%port) command.append("--address=%s"%address) if not self.local_process: - command = [c.replace("python", self.get_extended_command(get_artemis_config_value(section=self.get_ip(), option="python", default_generator=lambda: sys.executable)), 1) if c.startswith("python") else c for c in command] + command = [c.replace("ui_code", self.get_extended_command(get_artemis_config_value(section=self.get_ip(), option="ui_code", default_generator=lambda: sys.executable)), 1) if c.startswith("ui_code") else c for c in command] command = [s.replace("~",home_dir) for s in command] command = " ".join([c for c in command]) else: command = [c.strip("'") for c in command] - command = [c.replace("python", sys.executable, 1) if c.startswith("python") else c for c in command] + command = [c.replace("ui_code", sys.executable, 1) if c.startswith("ui_code") else c for c in command] command = [s.replace("~",home_dir) for s in command] - elif isinstance(command, string_types) and command.startswith("python"): + elif isinstance(command, string_types) and command.startswith("ui_code"): if self.set_up_port_for_structured_back_communication: command += " --port=%i "%port command += "--address=%s"%address if not self.local_process: - command = command.replace("python", self.get_extended_command(get_artemis_config_value(section=self.get_ip(), option="python",default_generator=lambda: sys.executable)), 1) + command = command.replace("ui_code", self.get_extended_command(get_artemis_config_value(section=self.get_ip(), option="ui_code",default_generator=lambda: sys.executable)), 1) else: - command = command.replace("python", sys.executable) + command = command.replace("ui_code", sys.executable) command = command.replace("~",home_dir) else: raise NotImplementedError() @@ -292,7 +292,7 @@ def listen_on_port(port=7000): class RemotePythonProcess(ChildProcess): """ - Launch a python child process. + Launch a ui_code child process. """ def __init__(self, function, ip_address, set_up_port_for_structured_back_communication=True, **kwargs): diff --git a/artemis/remote/file_system.py b/artemis/remote/file_system.py index 8809a11c..8d200b42 100644 --- a/artemis/remote/file_system.py +++ b/artemis/remote/file_system.py @@ -17,7 +17,7 @@ def check_config_file(ip_address,file_path=".artemisrc"): :param ip_address: The section to look for. Remote ip is assumed. Makes no sense for local ip. :return: ''' - mandatory_options = ["username","python"] + mandatory_options = ["username","ui_code"] artemisrc_path = os.path.expanduser("~/%s"%file_path) for option in mandatory_options: try: @@ -55,18 +55,18 @@ def check_config_file(ip_address,file_path=".artemisrc"): "Please make sure you have correctly set up your private key for %s " %(artemisrc_path,private_key_path,ip_address)) - #python tests: - python_path = get_artemis_config_value(section=ip_address,option="python") + #ui_code tests: + python_path = get_artemis_config_value(section=ip_address,option="ui_code") - command = "python -c 'import os; print(os.path.isfile(os.path.expanduser(\"%s\")))'"%python_path + command = "ui_code -c 'import os; print(os.path.isfile(os.path.expanduser(\"%s\")))'"%python_path ssh_conn = get_ssh_connection(ip_address) _,stdout,stderr = ssh_conn.exec_command(command) - assert stdout.read().strip()=="True", "The provided path to the remote python installation on %s does not exist. You provided %s" %(ip_address, python_path) + assert stdout.read().strip()=="True", "The provided path to the remote ui_code installation on %s does not exist. You provided %s" %(ip_address, python_path) command = "%s -c 'print(\"Success\")'" % python_path _,stdout,stderr = ssh_conn.exec_command(command) err = stderr.read().strip() - assert stdout.read().strip()=="Success" and not err, "The provided python path on %s does not seem to point to a python executable. " \ + assert stdout.read().strip()=="Success" and not err, "The provided ui_code path on %s does not seem to point to a ui_code executable. " \ "You provided %s, which resulted in the following error on the remote machine: " %(ip_address, python_path, err) diff --git a/artemis/remote/plotting/plotting_client.py b/artemis/remote/plotting/plotting_client.py index 2ffce097..572b4306 100644 --- a/artemis/remote/plotting/plotting_client.py +++ b/artemis/remote/plotting/plotting_client.py @@ -90,11 +90,11 @@ def set_up_plotting_server(): if plotting_server_address == "": plotting_server_address = "127.0.0.1" if plotting_server_address in get_local_ips(): - command = ["python", "-u", file_to_execute] + command = ["ui_code", "-u", file_to_execute] else: check_config_file(plotting_server_address) # Make sure all things are set check_ssh_connection(plotting_server_address) # Make sure the SSH-connection works - command =["export DISPLAY=:0.0;", "python","-u", file_to_execute] + command =["export DISPLAY=:0.0;", "ui_code","-u", file_to_execute] # TODO: Setting DISPLAY to :0.0 is a heuristic at the moment. I don't understand yet how these DISPLAY variables are set. # With the command set up, we can instantiate a child process and start it. Also we want to forward stdout and stderr from the remote process asynchronously. diff --git a/artemis/remote/remote_function_run_script.py b/artemis/remote/remote_function_run_script.py index 42798a22..8692b587 100644 --- a/artemis/remote/remote_function_run_script.py +++ b/artemis/remote/remote_function_run_script.py @@ -1,4 +1,4 @@ -#!/usr/bin/python +#!/usr/bin/ui_code import base64 from artemis.remote.utils import one_time_send_to diff --git a/artemis/remote/remote_generator_run_script.py b/artemis/remote/remote_generator_run_script.py index 86b898b2..66bd9138 100644 --- a/artemis/remote/remote_generator_run_script.py +++ b/artemis/remote/remote_generator_run_script.py @@ -1,4 +1,4 @@ -#!/usr/bin/python +#!/usr/bin/ui_code import base64 from artemis.remote.utils import one_time_send_to import sys diff --git a/artemis/remote/test_child_processes.py b/artemis/remote/test_child_processes.py index c0a4c9a5..4674c568 100644 --- a/artemis/remote/test_child_processes.py +++ b/artemis/remote/test_child_processes.py @@ -25,7 +25,7 @@ def get_test_functions_path(ip_address): def test_simple_pcp(): for ip_address in ip_addresses: - command = "python %s --callback=%s"%(get_test_functions_path(ip_address),"success_function") + command = "ui_code %s --callback=%s"%(get_test_functions_path(ip_address),"success_function") pyc = PythonChildProcess(ip_address=ip_address,command=command) (stdin, stdout, stderr) = pyc.execute_child_process() stderr_out =stderr.readlines() @@ -36,7 +36,7 @@ def test_simple_pcp(): def test_simple_pcp_list(): for ip_address in ip_addresses: - command = ["python", get_test_functions_path(ip_address), "--callback=success_function"] + command = ["ui_code", get_test_functions_path(ip_address), "--callback=success_function"] pyc = PythonChildProcess(ip_address=ip_address,command=command) (stdin, stdout, stderr) = pyc.execute_child_process() stderr_out = stderr.readlines() @@ -47,7 +47,7 @@ def test_simple_pcp_list(): def test_interrupt_process_gently(): for ip_address in ip_addresses: - command = ["python", get_test_functions_path(ip_address), "--callback=count_high"] + command = ["ui_code", get_test_functions_path(ip_address), "--callback=count_high"] cp = PythonChildProcess(ip_address, command) stdin , stdout, stderr = cp.execute_child_process() @@ -64,7 +64,7 @@ def test_interrupt_process_gently(): def test_kill_process_gently(): for ip_address in ip_addresses: - command = ["python", get_test_functions_path(ip_address), "--callback=sleep_function"] + command = ["ui_code", get_test_functions_path(ip_address), "--callback=sleep_function"] cp = PythonChildProcess(ip_address, command) stdin , stdout, stderr = cp.execute_child_process() @@ -77,7 +77,7 @@ def test_kill_process_gently(): def test_kill_process_strongly(): for ip_address in ip_addresses: - command = ["python", get_test_functions_path(ip_address), "--callback=hanging_sleep_function"] + command = ["ui_code", get_test_functions_path(ip_address), "--callback=hanging_sleep_function"] cp = PythonChildProcess(ip_address, command) stdin , stdout, stderr = cp.execute_child_process() @@ -93,7 +93,7 @@ def test_kill_process_strongly(): def test_remote_graphics(): for ip_address in ip_addresses: - command = ["python", get_test_functions_path(ip_address), "--callback=remote_graphics"] + command = ["ui_code", get_test_functions_path(ip_address), "--callback=remote_graphics"] cp = PythonChildProcess(ip_address=ip_address,command=command,take_care_of_deconstruct=True) i, stdout, stderr = cp.execute_child_process() diff --git a/artemis/remote/test_nanny.py b/artemis/remote/test_nanny.py index 5e2407e4..4012f7d5 100644 --- a/artemis/remote/test_nanny.py +++ b/artemis/remote/test_nanny.py @@ -40,7 +40,7 @@ def captured_output(): def test_simple_process(): for ip_address in ip_addresses: nanny = Nanny() - command = "python %s --callback=%s"%(get_test_functions_path(ip_address),"success_function") + command = "ui_code %s --callback=%s"%(get_test_functions_path(ip_address),"success_function") pyc = PythonChildProcess(name="Process", ip_address=ip_address,command=command) nanny.register_child_process(pyc,) with captured_output() as (out,err): @@ -51,7 +51,7 @@ def test_simple_process(): def test_several_simple_processes(N): for ip_address in ip_addresses: nanny = Nanny() - command = "python %s --callback=%s"%(get_test_functions_path(ip_address),"success_function") + command = "ui_code %s --callback=%s"%(get_test_functions_path(ip_address),"success_function") for i in range(N): pyc = PythonChildProcess(name="Process%i"%i, ip_address=ip_address,command=command) nanny.register_child_process(pyc,) @@ -64,10 +64,10 @@ def test_several_simple_processes(N): def test_process_termination(): for ip_address in ip_addresses: nanny = Nanny() - command = "python %s --callback=%s"%(get_test_functions_path(ip_address),"count_low") + command = "ui_code %s --callback=%s"%(get_test_functions_path(ip_address),"count_low") pyc = PythonChildProcess(name="Process1", ip_address=ip_address,command=command) nanny.register_child_process(pyc,) - command = "python %s --callback=%s"%(get_test_functions_path(ip_address),"count_high") + command = "ui_code %s --callback=%s"%(get_test_functions_path(ip_address),"count_high") pyc = PythonChildProcess(name="Process2", ip_address=ip_address,command=command) nanny.register_child_process(pyc,) with captured_output() as (out,err): @@ -78,10 +78,10 @@ def test_process_termination(): def test_output_monitor(): for ip_address in ip_addresses: nanny = Nanny() - command = "python %s --callback=%s"%(get_test_functions_path(ip_address),"short_sleep") + command = "ui_code %s --callback=%s"%(get_test_functions_path(ip_address),"short_sleep") pyc = PythonChildProcess(name="Process1", ip_address=ip_address,command=command) nanny.register_child_process(pyc,monitor_if_stuck_timeout=5) - command = "python %s --callback=%s"%(get_test_functions_path(ip_address),"count_high") + command = "ui_code %s --callback=%s"%(get_test_functions_path(ip_address),"count_high") pyc = PythonChildProcess(name="Process2", ip_address=ip_address,command=command) nanny.register_child_process(pyc,monitor_if_stuck_timeout=3) with captured_output() as (out,err): @@ -95,7 +95,7 @@ def test_output_monitor(): def test_iter_print(): for ip_address in ip_addresses: nanny = Nanny() - command = ["python","-u", get_test_functions_path(ip_address), "--callback=iter_print"] + command = ["ui_code","-u", get_test_functions_path(ip_address), "--callback=iter_print"] pyc = PythonChildProcess(name="P1",ip_address=ip_address,command=command) nanny.register_child_process(pyc) with captured_output() as (out, err): diff --git a/artemis/remote/test_virtualenv.py b/artemis/remote/test_virtualenv.py index c0c601f1..d72b5f00 100644 --- a/artemis/remote/test_virtualenv.py +++ b/artemis/remote/test_virtualenv.py @@ -10,19 +10,19 @@ @pytest.mark.skipif(ip_address="" or is_local, reason ="No sense for local ip") def test_check_diff_local_remote_virtualenv(): - # original_virtual_env_value = get_config_value(".artemisrc", ip_address, "python") + # original_virtual_env_value = get_config_value(".artemisrc", ip_address, "ui_code") # import ConfigParser # import os # # Config = ConfigParser.ConfigParser() # Config.read(os.path.expanduser("~/.artemisrc")) - # Config.set(section=ip_address,option="python",value="~/virtualenvs/test_env/bin/python") + # Config.set(section=ip_address,option="ui_code",value="~/virtualenvs/test_env/bin/ui_code") # with open(os.path.expanduser("~/.artemisrc"), 'wb') as configfile: # Config.write(configfile) check_diff_local_remote_virtualenv(ip_address, auto_install=False, auto_upgrade=False,ignore_warnings=True) - # Config.set(section=ip_address,option="python",value=original_virtual_env_value) + # Config.set(section=ip_address,option="ui_code",value=original_virtual_env_value) # with open(os.path.expanduser("~/.artemisrc"), 'wb') as configfile: # Config.write(configfile) diff --git a/artemis/remote/utils.py b/artemis/remote/utils.py index 2e271321..1fd1f81c 100644 --- a/artemis/remote/utils.py +++ b/artemis/remote/utils.py @@ -163,7 +163,7 @@ def check_if_port_is_free(ip_address, port): else: check_ssh_connection(ip_address) ssh_connect = get_ssh_connection(ip_address=ip_address) - check_port_function = 'python -c "import socket; s=socket.socket(socket.AF_INET, socket.SOCK_STREAM);s.bind((\'%s\',%i));s.close()"'%(ip_address,port) + check_port_function = 'ui_code -c "import socket; s=socket.socket(socket.AF_INET, socket.SOCK_STREAM);s.bind((\'%s\',%i));s.close()"'%(ip_address,port) stdin , stdout, stderr = ssh_connect.exec_command(check_port_function) err = stderr.read() assert not err, "The remote address %s cannot allocate port %i. The following error was raised: \n %s" % (ip_address, port,err.strip().split("\n")[-1]) @@ -194,7 +194,7 @@ def check_ssh_connection(ip_address): :return: ''' - test_function = 'python -c "import socket; print([l for l in ([ip for ip in socket.gethostbyname_ex(socket.gethostname())[2] if not ip.startswith(\'127.\')][:1], [[(s.connect((\'8.8.8.8\', 53)), s.getsockname()[0], s.close()) for s in [socket.socket(socket.AF_INET, socket.SOCK_DGRAM)]][0][1]]) if l][0][0])"' + test_function = 'ui_code -c "import socket; print([l for l in ([ip for ip in socket.gethostbyname_ex(socket.gethostname())[2] if not ip.startswith(\'127.\')][:1], [[(s.connect((\'8.8.8.8\', 53)), s.getsockname()[0], s.close()) for s in [socket.socket(socket.AF_INET, socket.SOCK_DGRAM)]][0][1]]) if l][0][0])"' ssh_conn = get_ssh_connection(ip_address=ip_address) stdin , stdout, stderr = ssh_conn.exec_command(test_function) out = stdout.read().strip() diff --git a/artemis/remote/virtualenv.py b/artemis/remote/virtualenv.py index dcaeb036..c0cd46d0 100644 --- a/artemis/remote/virtualenv.py +++ b/artemis/remote/virtualenv.py @@ -13,19 +13,19 @@ def get_remote_installed_packages(ip_address): ''' - This method queries a remote python installation about the installed packages. + This method queries a remote ui_code installation about the installed packages. All necessary information is extracted from ~/.artemisrc :param address: Ip address of remote server :return: ''' - python_executable = get_artemis_config_value(section=ip_address, option="python") + python_executable = get_artemis_config_value(section=ip_address, option="ui_code") function = "%s -c 'import pip; import json; print json.dumps({i.key: i.version for i in pip.get_installed_distributions() })' "%python_executable ssh_conn = get_ssh_connection(ip_address) stdin , stdout, stderr = ssh_conn.exec_command(function) err = stderr.read() if err: - msg="Quering %s python installation at %s sent a message on stderr. If you are confident that the error can be ignored, catch this RuntimeError" \ + msg="Quering %s ui_code installation at %s sent a message on stderr. If you are confident that the error can be ignored, catch this RuntimeError" \ "accordingly. The error is: %s"%(ip_address, python_executable, err) raise RuntimeError(msg) @@ -46,7 +46,7 @@ def install_packages_on_remote_virtualenv(ip_address, packages): if len(packages) == 0: return print("installing/upgrading remote packages ...") - python_path = get_artemis_config_value(ip_address,"python") + python_path = get_artemis_config_value(ip_address,"ui_code") activate_path = os.path.join(os.path.dirname(python_path),"activate") # TODO: Make this work without the user using virtualenv activate_command = "source %s"%activate_path ssh_conn = get_ssh_connection(ip_address) From 80229f3e94de1f43b8db3695c24b369fd3479924 Mon Sep 17 00:00:00 2001 From: peter Date: Sat, 7 Jan 2023 18:53:47 -0800 Subject: [PATCH 042/107] moved the rest over --- artemis/fileman/disk_memoize.py | 2 +- artemis/general/debug_utils.py | 29 + artemis/general/item_cache.py | 66 ++ artemis/general/sequence_buffer.py | 74 ++ artemis/general/stats_utils.py | 33 + artemis/general/test_item_cache.py | 57 ++ artemis/general/test_seqeunce_buffer.py | 36 + artemis/general/test_utils_utils.py | 24 + artemis/general/utils_for_testing.py | 72 ++ artemis/general/utils_utils.py | 90 ++ artemis/image_processing/image_builder.py | 275 ++++++ .../image_processing_utils.py | 205 +++++ artemis/image_processing/image_utils.py | 787 ++++++++++++++++++ .../test_image_processing_utils.py | 37 + artemis/image_processing/test_image_utils.py | 120 +++ artemis/image_processing/video_utils.py | 69 ++ artemis/plotting/cv2_plotting.py | 40 + artemis/plotting/cv_keys.py | 162 ++++ artemis/plotting/easy_window.py | 561 +++++++++++++ artemis/plotting/matplotlib_plotting.py | 10 + artemis/plotting/scrollable_image_stream.py | 146 ++++ artemis/plotting/test_easy_window.py | 15 + .../plotting/test_scrollable_image_stream.py | 60 ++ artemis/plotting/test_threaded_show.py | 16 + artemis/plotting/threaded_show.py | 54 ++ 25 files changed, 3039 insertions(+), 1 deletion(-) create mode 100644 artemis/general/debug_utils.py create mode 100644 artemis/general/item_cache.py create mode 100644 artemis/general/sequence_buffer.py create mode 100644 artemis/general/stats_utils.py create mode 100644 artemis/general/test_item_cache.py create mode 100644 artemis/general/test_seqeunce_buffer.py create mode 100644 artemis/general/test_utils_utils.py create mode 100644 artemis/general/utils_for_testing.py create mode 100644 artemis/general/utils_utils.py create mode 100644 artemis/image_processing/image_builder.py create mode 100644 artemis/image_processing/image_processing_utils.py create mode 100644 artemis/image_processing/image_utils.py create mode 100644 artemis/image_processing/test_image_processing_utils.py create mode 100644 artemis/image_processing/test_image_utils.py create mode 100644 artemis/image_processing/video_utils.py create mode 100644 artemis/plotting/cv2_plotting.py create mode 100644 artemis/plotting/cv_keys.py create mode 100644 artemis/plotting/easy_window.py create mode 100644 artemis/plotting/matplotlib_plotting.py create mode 100644 artemis/plotting/scrollable_image_stream.py create mode 100644 artemis/plotting/test_easy_window.py create mode 100644 artemis/plotting/test_scrollable_image_stream.py create mode 100644 artemis/plotting/test_threaded_show.py create mode 100644 artemis/plotting/threaded_show.py diff --git a/artemis/fileman/disk_memoize.py b/artemis/fileman/disk_memoize.py index be969aa1..c657f66b 100644 --- a/artemis/fileman/disk_memoize.py +++ b/artemis/fileman/disk_memoize.py @@ -10,7 +10,7 @@ from artemis.general.functional import infer_arg_values from artemis.general.hashing import compute_fixed_hash from artemis.general.test_mode import is_test_mode -from eagle_eyes.utils.utils_for_testing import hold_tempdir +from artemis.general.utils_for_testing import hold_tempdir logging.basicConfig() LOGGER = logging.getLogger(__name__) diff --git a/artemis/general/debug_utils.py b/artemis/general/debug_utils.py new file mode 100644 index 00000000..bbbf1692 --- /dev/null +++ b/artemis/general/debug_utils.py @@ -0,0 +1,29 @@ +from builtins import Exception +from contextlib import contextmanager +from time import monotonic +from logging import Logger +PROFILE_LOG = Logger('easy_profile') + +PROFILE_DEPTH = 0 + + +@contextmanager +def easy_profile(name: str, log_entry: bool = False, enable = True, time_unit='ms'): + if not enable: + yield + return + global PROFILE_DEPTH + tstart = monotonic() + try: + if log_entry: + PROFILE_LOG.warn(f"Starting block '{name}...") + PROFILE_DEPTH += 1 + yield + finally: + PROFILE_DEPTH -= 1 + elapsed = monotonic()-tstart + time_str = f"{elapsed:.5f}s" if time_unit == 's' else \ + f"{elapsed*1000:.2f}ms" if time_unit == 'ms' else \ + f"{elapsed*1000000:.1f}us" if time_unit == 'us' else \ + f"{elapsed:.5f}s " + PROFILE_LOG.warn(f'EasyProfile: {"| "*PROFILE_DEPTH} {name} took {time_str}') diff --git a/artemis/general/item_cache.py b/artemis/general/item_cache.py new file mode 100644 index 00000000..7c5d4a78 --- /dev/null +++ b/artemis/general/item_cache.py @@ -0,0 +1,66 @@ +from collections import OrderedDict +from typing import Optional, TypeVar, Generic, Hashable + +from more_itertools import first + +from artemis.general.sequence_buffer import get_memory_footprint + +KeyType = TypeVar('KeyType') +ItemType = TypeVar('ItemType') + + +class CacheDict(Generic[KeyType, ItemType]): + """ A simple buffer that just keeps a cache of recent entries + + Example + cache = ItemCache(buffer_len=3) + cache[1]='aaa' + cache[5]='bbb' + cache[7]='ccc' + + """ + + def __init__(self, buffer_length: Optional[int] = None, buffer_size_bytes: Optional[int] = None, calculate_size_once = True, always_allow_one_item: bool = False): + + self._buffer = OrderedDict() + self._buffer_length = buffer_length + self.buffer_size_bytes = buffer_size_bytes + self._first_object_size: Optional[int] = None + self._calculate_size_once = calculate_size_once + self._current_buffer_size = 0 + self._always_allow_one_item = always_allow_one_item + + def _remove_oldest_item(self): + if len(self._buffer) > 0: + first_key = first(self._buffer.keys()) + value, itemsize = self._buffer[first_key] + del self._buffer[first(self._buffer.keys())] + if itemsize is not None: + self._current_buffer_size -= itemsize + + def __setitem__(self, key: Hashable, value: ItemType) -> None: + size = None + if self._buffer_length is not None and len(self._buffer) == self._buffer_length: + self._remove_oldest_item() + + if self.buffer_size_bytes is not None: + size = get_memory_footprint(value) if not self._calculate_size_once or self._first_object_size is None else self._first_object_size + while len(self._buffer)>0 and self._current_buffer_size+size > self.buffer_size_bytes: + self._remove_oldest_item() + + self._current_buffer_size += size + + if size < self.buffer_size_bytes or len(self._buffer)==0 and self._always_allow_one_item: + self._buffer[key] = value, size + else: + self._buffer[key] = value, size + + def __getitem__(self, key: Hashable) -> ItemType: + if key in self._buffer: + value, _ = self._buffer[key] + return value + else: + raise KeyError(f"{key} is not in cache") + + def __contains__(self, key: Hashable): + return key in self._buffer diff --git a/artemis/general/sequence_buffer.py b/artemis/general/sequence_buffer.py new file mode 100644 index 00000000..ec704919 --- /dev/null +++ b/artemis/general/sequence_buffer.py @@ -0,0 +1,74 @@ +import sys +# from _typeshed import SupportsNext +from collections import deque +from dataclasses import dataclass, field +from typing import Optional, TypeVar, Generic, Deque, Tuple, Any, Iterator +import numpy as np +ItemType = TypeVar('ItemType') + + +class OutOfBufferException(Exception): + """ Raised when you request something outside the bounds of the buffer """ + + +def get_memory_footprint(item: Any) -> int: + # TODO: Recurse through dataclasses + if isinstance(item, np.ndarray): + return item.itemsize * item.size + else: + return sys.getsizeof(item) # Only returns pointer-size, no recursion + + +@dataclass +class SequenceBuffer(Generic[ItemType]): + max_elements: Optional[int] = None + max_memory: Optional[int] = None + + _current_index: int = 0 + _current_memory: int = 0 + _buffer: Deque[ItemType] = None + + def __post_init__(self): + self._buffer = deque(maxlen=self.max_elements) + + def append(self, item: ItemType): + self._buffer.append(item) + self._current_index += 1 + if self.max_memory is not None: + self._current_memory += get_memory_footprint(item) + while self._current_memory > self.max_memory: + popped_item = self._buffer.popleft() + self._current_memory -= get_memory_footprint(popped_item) + + def get_index_bounds(self) -> Tuple[int, int]: + + return (self._current_index-len(self._buffer), self._current_index) + + def lookup(self, index: int, jump_to_edge: bool = False, new_data_source: Optional[Iterator[ItemType]] = None) -> Tuple[int, ItemType]: + + buffer_index = index - self._current_index + len(self._buffer) if index>=0 else len(self._buffer)+index + + if buffer_index >= len(self._buffer) and new_data_source is not None: + for _ in range(buffer_index-len(self._buffer)+1): + try: + self.append(next(new_data_source)) + except StopIteration: + if not jump_to_edge: + raise OutOfBufferException(f"Data source exhausted while trying to retrieve index {index}") + return self.lookup(index, jump_to_edge=jump_to_edge) + + if jump_to_edge: + buffer_index = max(0, min(len(self._buffer)-1, buffer_index)) + else: + if buffer_index < 0: + raise OutOfBufferException(f"Index {index} falls out of bounds of the buffer, which only remembers back to {self._current_index - len(self._buffer)}") + elif buffer_index >= len(self._buffer): + raise OutOfBufferException(f"Index {index} has not yet been assigned to buffer, which has only been filled up to index {self._current_index}") + + remapped_index = buffer_index + self._current_index - len(self._buffer) + return remapped_index, self._buffer[buffer_index] + + + + + diff --git a/artemis/general/stats_utils.py b/artemis/general/stats_utils.py new file mode 100644 index 00000000..3b1e565b --- /dev/null +++ b/artemis/general/stats_utils.py @@ -0,0 +1,33 @@ +import numpy as np + +def kl_mvn(m0, S0, m1, S1): + """ + Kullback-Liebler divergence from Gaussian pm,pv to Gaussian qm,qv. + Also computes KL divergence from a single Gaussian pm,pv to a set + of Gaussians qm,qv. + Diagonal covariances are assumed.??? are they??? Divergence is expressed in nats. + + - accepts stacks of means, but only one S0 and S1 + + From wikipedia + KL( (m0, S0) || (m1, S1)) + = .5 * ( tr(S1^{-1} S0) + log |S1|/|S0| + + (m1 - m0)^T S1^{-1} (m1 - m0) - N ) + """ + # store inv diag covariance of S1 and diff between means + N = m0.shape[0] + iS1 = np.linalg.inv(S1) + diff = m1 - m0 + + # kl is made of three terms + tr_term = np.trace(iS1 @ S0) + det_term = np.log(np.linalg.det(S1)/np.linalg.det(S0)) #np.sum(np.log(S1)) - np.sum(np.log(S0)) + quad_term = diff.T @ np.linalg.inv(S1) @ diff #np.sum( (diff*diff) * iS1, axis=1) + #print(tr_term,det_term,quad_term) + return .5 * (tr_term + det_term + quad_term - N) + + +def kl_diagonal_gaussians(p_means, p_vars, q_mean, q_var): + + return (0.5 * np.log(q_var / p_vars) + (p_vars + (q_mean - p_means) ** 2) / (2 * q_var) - 0.5).sum(axis=-1) + diff --git a/artemis/general/test_item_cache.py b/artemis/general/test_item_cache.py new file mode 100644 index 00000000..76538824 --- /dev/null +++ b/artemis/general/test_item_cache.py @@ -0,0 +1,57 @@ +from pytest import raises +import numpy as np +from artemis.general.item_cache import CacheDict + + +def test_item_cache(): + + cache = CacheDict(buffer_length=3) + cache[1] = 'aaa' + cache[4] = 'bbb' + cache[7] = 'ccc' + assert 1 in cache + assert cache[1] == 'aaa' + assert 0 not in cache + with raises(KeyError): + _ = cache[0] + cache[9] = 'ddd' + assert 1 not in cache + with raises(KeyError): + _ = cache[1] + assert cache[4] == 'bbb' + cache[9] = 'ddd' + assert 4 not in cache + + cache = CacheDict(buffer_size_bytes=2000000) + img = np.random.rand(240, 300) # 8*240*300 = 576000 byes... room for 3 + cache[4] = img.copy() + assert 4 in cache + cache[5] = img.copy() + assert 4 in cache + cache[6] = img.copy() + assert 4 in cache + cache[7] = img.copy() + assert 4 not in cache + assert 5 in cache + cache[8] = img.copy() + assert 4 not in cache + assert 5 not in cache + + cache = CacheDict(buffer_size_bytes=100) # No room for even 1 + img = np.random.rand(240, 300) + cache[1] = img.copy() + assert 1 not in cache + cache[2] = img.copy() + assert 2 not in cache + + cache = CacheDict(buffer_size_bytes=100, always_allow_one_item=True) # No room for even 1 + img = np.random.rand(240, 300) + cache[1] = img.copy() + assert 1 in cache + cache[2] = img.copy() + assert 1 not in cache + assert 2 in cache + + +if __name__ == '__main__': + test_item_cache() diff --git a/artemis/general/test_seqeunce_buffer.py b/artemis/general/test_seqeunce_buffer.py new file mode 100644 index 00000000..00b1b84f --- /dev/null +++ b/artemis/general/test_seqeunce_buffer.py @@ -0,0 +1,36 @@ +from artemis.general.sequence_buffer import SequenceBuffer, OutOfBufferException +from pytest import raises + + +def test_sequence_buffer(): + + seqbuf = SequenceBuffer(max_elements=3) + seqbuf.append('a') + assert seqbuf.lookup(-1) == (0, 'a') + seqbuf.append('b') + assert seqbuf.lookup(-1) == (1, 'b') + seqbuf.append('c') + assert seqbuf.lookup(-1) == (2, 'c') + seqbuf.append('d') + assert seqbuf.lookup(-1) == seqbuf.lookup(3) == (3, 'd') + assert seqbuf.lookup(-2) == seqbuf.lookup(2) == (2, 'c') + assert seqbuf.lookup(-3) == seqbuf.lookup(1) == (1, 'b') + with raises(OutOfBufferException): + seqbuf.lookup(-4) + with raises(OutOfBufferException): + seqbuf.lookup(0) + assert seqbuf.lookup(-4, jump_to_edge=True) == (1, 'b') + + with raises(OutOfBufferException): + seqbuf.lookup(4) + assert seqbuf.lookup(4, jump_to_edge=True) == (3, 'd') + data_source = iter(['e', 'f', 'g', 'h']) + assert seqbuf.lookup(4, new_data_source=data_source) == (4, 'e') + assert seqbuf.lookup(6, new_data_source=data_source) == (6, 'g') + with raises(OutOfBufferException): + seqbuf.lookup(8, new_data_source=data_source) + assert seqbuf.lookup(8, new_data_source=data_source, jump_to_edge=True) == (7, 'h') + + +if __name__ == '__main__': + test_sequence_buffer() diff --git a/artemis/general/test_utils_utils.py b/artemis/general/test_utils_utils.py new file mode 100644 index 00000000..b2ef28dd --- /dev/null +++ b/artemis/general/test_utils_utils.py @@ -0,0 +1,24 @@ +import itertools +from pytest import raises + +from artemis.general.utils_utils import tee_and_specialize_iterator + + +def test_tee_and_specialize_iterator(): + first_iterator = [{'a': i + 1, 'b': i + 2, 'c': i + 3} for i in range(3)] + + # Naive way - show that it doesnt work, because of lazy-binding + teed_it = ((d[n] for d in it_copy) for n, it_copy in zip('abc', itertools.tee(iter(first_iterator), 3))) + + items = list(zip(*teed_it)) + with raises(AssertionError): + assert items == ([1, 2, 3], [2, 3, 4], [3, 4, 5]) + assert items == [(3, 3, 3), (4, 4, 4), (5, 5, 5)] + + teed_it_2 = tee_and_specialize_iterator(iter(first_iterator), specialization_func=lambda it, arg: it[arg], args='abc') + items = list(zip(*teed_it_2)) + assert items == [(1, 2, 3), (2, 3, 4), (3, 4, 5)] + + +if __name__ == '__main__': + test_tee_and_specialize_iterator() diff --git a/artemis/general/utils_for_testing.py b/artemis/general/utils_for_testing.py new file mode 100644 index 00000000..12f3065d --- /dev/null +++ b/artemis/general/utils_for_testing.py @@ -0,0 +1,72 @@ +import shutil +import tempfile +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Sequence, Tuple +import os +import numpy as np + +from artemis.general.custom_types import MaskImageArray, Array + + +def mask_to_imstring(mask: MaskImageArray) -> str: + + return '\n'.join(''.join('X' if m else 'â€ĸ' for m in row) for row in mask) + + +def stringlist_to_mask(*stringlist: Sequence[str]) -> MaskImageArray: + return np.array([list(row) for row in stringlist])=='X' + + +@contextmanager +def hold_tempdir(): + + tempdir = tempfile.mkdtemp() + try: + yield tempdir + finally: + if os.path.exists(tempdir): + shutil.rmtree(tempdir) + + +@contextmanager +def hold_tempfile(ext = ''): + tempfilename = tempfile.mktemp() + ext + try: + yield tempfilename + finally: + if os.path.exists(tempfilename): + os.remove(tempfilename) + + +@dataclass +class HeatmapBuilder: + + heatmap: Array['H,W', float] + + @classmethod + def from_wh(cls, width, height): + return HeatmapBuilder(np.zeros((height, width))) + + @property + def width(self) -> int: + return self.heatmap.shape[1] + + @property + def height(self) -> int: + return self.heatmap.shape[0] + + def draw_gaussian(self, mean_xy: Tuple[float, float], std_xy: Tuple[float, float], corr: float = 0., scale: float = 1.): + assert -1 < corr < 1 + mean_xy = np.asarray(mean_xy) + vxx, vyy = np.asarray(std_xy)**2 + vxy = corr * np.sqrt(vxx) * np.sqrt(vyy) + covmat = np.array([[vxx, vxy], [vxy, vyy]]) + xs, ys = np.meshgrid(np.arange(self.width), np.arange(self.height)) + grid_xy = np.concatenate([xs[:, :, None], ys[:, :, None]], axis=2).reshape(-1, 2) + deltamean = grid_xy - mean_xy + heat = scale * np.exp(-np.einsum('ni,ij,nj->n', deltamean, 0.5*np.linalg.inv(covmat), deltamean)).reshape(self.heatmap.shape) + self.heatmap += heat + return self + + diff --git a/artemis/general/utils_utils.py b/artemis/general/utils_utils.py new file mode 100644 index 00000000..79acf7b2 --- /dev/null +++ b/artemis/general/utils_utils.py @@ -0,0 +1,90 @@ +import itertools +import os.path +from datetime import datetime +from functools import partial +from typing import Optional, Iterable, Callable, TypeVar, Tuple, Sequence, Iterator +import inspect +import time + +def get_datetime_filename(prefix = '', suffix = '', when: Optional[datetime] = None, extension: Optional[str] = None, include_microseconds: bool = True) -> str: + + if when is None: + when = datetime.now() + return prefix \ + + when.strftime("%Y%m%d-%H%M%S"+('-%p' if include_microseconds else '')) \ + + suffix \ + + ('.'+extension if extension is not None else '') + + +def get_context_name(levels_up=1): + context = inspect.stack()[levels_up] + function_name = context.function + if function_name == '': + _, filename = os.path.split(context.filename) + return filename + else: + if 'self' in context.frame.f_locals: + return f"{context.frame.f_locals['self'].__class__.__name__}.{function_name}" + else: + return function_name + + +def ensure_path(path: str) -> str: + + path = os.path.expanduser(path) + + parent, _ = os.path.split(path) + try: + os.makedirs(parent) + except OSError: + pass + return path + + +def demo_get_context_name(): + print(f'Name of this context is "{get_context_name()}"') + + +def iter_max_rate(max_fps: Optional[float]) -> Iterable[float]: + + + min_period = 1./max_fps if max_fps is not None else 0 + t_last = - float('inf') + while True: + t_current = time.time() + yield t_current - t_last + sleep_time = t_current + min_period - time.time() + if sleep_time > 0: + time.sleep(sleep_time) + + +CallReturnType = TypeVar('CallReturnType') + + +def timed_call(func: Callable[[], CallReturnType]) -> Tuple[float, CallReturnType]: + start_time = time.monotonic_ns() + result = func() + elapsed = (time.monotonic_ns()-start_time)/1e9 + return elapsed, result + + +ItemType = TypeVar('ItemType') +ArgType = TypeVar('ArgType') +ResultType = TypeVar('ResultType') + + +def tee_and_specialize_iterator( + iterator: Iterable[ItemType], + specialization_func: Callable[[ItemType, ArgType], ResultType], + args: Sequence[ArgType] + ) -> Sequence[Iterator[ResultType]]: + + def make_sub_iterator(it_copy, arg): + for it in it_copy: + yield specialization_func(it, arg) + + return [make_sub_iterator(it_copy, arg) for it_copy, arg in zip(itertools.tee(iterator, len(args)), args)] + + +if __name__ == '__main__': + demo_get_context_name() diff --git a/artemis/image_processing/image_builder.py b/artemis/image_processing/image_builder.py new file mode 100644 index 00000000..f254ae4a --- /dev/null +++ b/artemis/image_processing/image_builder.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import itertools +import os +from dataclasses import dataclass +from typing import Tuple, Union, Optional, Sequence, Mapping, Iterable + +import cv2 +import numpy as np + +from artemis.general.custom_types import BGRImageArray, XYPointTuple, IJPixelTuple, HeatMapArray, XYSizeTuple, BGRColorTuple, Array, GreyScaleImageArray, BGRFloatImageArray +from artemis.plotting.easy_window import ImageRow, ImageCol, put_text_at, put_text_in_corner +from artemis.image_processing.image_utils import heatmap_to_color_image, BoundingBox, BGRColors, DEFAULT_GAP_COLOR, RelativeBoundingBox, TextDisplayer + + +@dataclass +class ImageBuilder: + image: BGRImageArray + resolution: float = 1. # In distance_unit/pix + origin: Tuple[float, float] = (0., 0.) + y_from_bottom: bool = False + + def __post_init__(self): + self.image = np.ascontiguousarray(self.image) # Some opencv functions expect this + + def get_xlims(self) -> Tuple[float, float]: + return self.origin[0], self.origin[0] + self.image.shape[1] / self.resolution + + def get_ylims(self) -> Tuple[float, float]: + return self.origin[1], self.origin[1] + self.image.shape[0] / self.resolution + + def _xy_to_ji(self, center_xy: XYPointTuple) -> IJPixelTuple: + cx, cy = center_xy + ox, oy = self.origin + cj, ci = round((cx - ox) / self.resolution), round((cy - oy) / self.resolution) + if self.y_from_bottom: + ci = self.image.shape[0] - ci - 1 + return cj, ci + + @classmethod + def from_image(cls, image: Union[str, BGRImageArray], normalize: bool = False, y_from_bottom: bool = False, copy: bool = True + ) -> 'ImageBuilder': + + if isinstance(image, str): + image = cv2.imread(os.path.expanduser(image)) + + size = image.shape[1], image.shape[0] + resolution = 1. / max(size) if normalize else 1. + return ImageBuilder(image=image.copy() if copy else image, resolution=resolution, y_from_bottom=y_from_bottom) + + @classmethod + def from_heatmap(cls, heatmap: HeatMapArray, assume_zero_min: bool = False, show_range: bool = False, additional_text: Optional[str] = None): + + return cls.from_image(heatmap_to_color_image(heatmap, assume_zero_min=assume_zero_min, show_range=show_range, additional_text=additional_text)) + + @classmethod + def from_text(cls, text: str, text_displayer: Optional[TextDisplayer] = None) -> 'ImageBuilder': + if text_displayer is None: + text_displayer = TextDisplayer() + return ImageBuilder(text_displayer.render(text)) + + def stack_with(self, image_or_builder: Union['ImageBuilder', BGRImageArray]) -> 'ImageBuilder': + image = image_or_builder.image if isinstance(image_or_builder, ImageBuilder) else image_or_builder + return ImageBuilder(ImageRow(self.image, image).render(), resolution=self.resolution) + + @classmethod + def from_blank(cls, size: XYSizeTuple, color: BGRColorTuple, normalize: bool = False, y_from_bottom: bool = False + ) -> 'ImageBuilder': + sx, sy = size + image = np.full(shape=(sy, sx, 3), dtype=np.uint8, fill_value=color) + return ImageBuilder.from_image(image=image, normalize=normalize, y_from_bottom=y_from_bottom) + + def rescale(self, factor: float, interp=cv2.INTER_NEAREST): + self.image = cv2.resize(self.image, dsize=None, fx=factor, fy=factor, interpolation=interp) + return self + + def copy(self) -> 'ImageBuilder': + return ImageBuilder(image=self.image.copy(), origin=self.origin, resolution=self.resolution) + + def get_crop(self, box: BoundingBox, copy=False, wrap_x=False) -> 'ImageBuilder': + box = box.scale_by(self.resolution) + return ImageBuilder( + image=box.slice_image(self.image, copy=copy, wrap_x=wrap_x), + origin=(box.x_min, box.y_min), + resolution=self.resolution + ) + + def get_center_crop(self, size_xy: Tuple[float, float]): + size_pix_xy = (int(u/self.resolution) for u in size_xy) + + + def get_downscaled(self, factor: float) -> 'ImageBuilder': + return ImageBuilder( + cv2.resize(self.image, dsize=None, fx=1 / factor, fy=1 / factor), + origin=self.origin, + resolution=self.resolution * factor + ) + + def get_image(self, copy: bool = False) -> BGRImageArray: + return self.image.copy() if copy else self.image + + def get_size_xy(self) -> Tuple[int, int]: + return self.image.shape[1], self.image.shape[0] + + def draw_points(self, points_xy: Array['N,2', int], color: BGRColorTuple) -> 'ImageBuilder': + self.image[points_xy[:, 1], points_xy[:, 0]] = color + return self + + def draw_circle(self, center_xy: XYPointTuple, radius: float, colour: BGRColorTuple, thickness: int = 1) -> 'ImageBuilder': + center_ji = self._xy_to_ji(center_xy) + cv2.circle(self.image, center_ji, radius=round(radius / self.resolution), color=colour, thickness=thickness) + return self + + def draw_line(self, start_xy: Tuple[float, float], end_xy: Tuple[float, float], color: BGRColorTuple, thickness: int = 1) -> 'ImageBuilder': + start_ji, end_ji = self._xy_to_ji(start_xy), self._xy_to_ji(end_xy) + cv2.line(self.image, pt1=start_ji, pt2=end_ji, color=color, thickness=thickness) + return self + + def draw_arrow(self, start_xy: Tuple[float, float], end_xy: Tuple[float, float], color: BGRColorTuple, thickness: int = 1, tip_frac=0.25) -> 'ImageBuilder': + start_ji, end_ji = self._xy_to_ji(start_xy), self._xy_to_ji(end_xy) + cv2.arrowedLine(self.image, pt1=start_ji, pt2=end_ji, color=color, thickness=thickness, tipLength=tip_frac) + return self + + def draw_box(self, box: BoundingBox | RelativeBoundingBox, colour: BGRColorTuple = BGRColors.RED, + secondary_colour: Optional[BGRColorTuple] = None, + text_background_color: Optional[BGRColorTuple] = None, + + thickness: int = 1, box_id: Optional[int] = None, + include_labels = True, show_score_in_label: bool = True, score_as_pct: bool = False) -> 'ImageBuilder': + + if isinstance(box, RelativeBoundingBox): + box = box.to_bounding_box((self.image.shape[1], self.image.shape[0])) + # xmin, xmax, ymin, ymax = xx_yy_box + jmin, imin = self._xy_to_ji((box.x_min, box.y_min)) + jmax, imax = self._xy_to_ji((box.x_max, box.y_max)) + cv2.rectangle(self.image, pt1=(jmin, imin), pt2=(jmax, imax), color=colour, thickness=thickness) + if secondary_colour is not None: + cv2.rectangle(self.image, pt1=(jmin-thickness, imin-thickness), pt2=(jmax+thickness, imax+thickness), color=secondary_colour, thickness=thickness) + + # if box.label or box_id is not None: + label = ','.join(str(i) for i in [box_id, box.label, None if not show_score_in_label else f"{box.score:.0%}" if score_as_pct else f"{box.score:.2f}"] if i is not None) + if include_labels: + + put_text_at(self.image, text=label, position_xy=(jmin, imin if box.y_min > box.y_max-box.y_min else imax), scale=.7*self.image.shape[1]/640, color=colour, shadow_color = BGRColors.BLACK, background_color=text_background_color, thickness=thickness) + # cv2.putText(self.image, text=label, org=(imin, jmin), fontFace=cv2.FONT_HERSHEY_PLAIN, fontScale=.7*self.image.shape[1]/640, + # color=colour, thickness=thickness) + + return self + + def label_points(self, points_xy: Union[Sequence[Tuple[int, int]], Mapping[Tuple[int, int], str]], radius=10, color: Union[BGRColorTuple, Iterable[BGRColorTuple]]=BGRColors.WHITE, thickness=2 + ) -> 'ImageBuilder': + + if isinstance(color, tuple) and len(color)==3 and all(isinstance(c, int) for c in color): + color = itertools.cycle([color]) + if isinstance(points_xy, (list, tuple, np.ndarray)): + points_xy = {(x, y): str(i) for i, (x, y) in enumerate(points_xy)} + for ((x, y), label), c in zip(points_xy.items(), color): + cv2.circle(self.image, center=(round(x), round(y)), radius=radius, color=c, thickness=thickness) + put_text_at(self.image, text=label, pos=(round(x)+10, round(y)+10), color=c, shadow_color=None) + return self + + def draw_bounding_boxes(self, + boxes: Iterable[BoundingBox | RelativeBoundingBox], + colour: BGRColorTuple = BGRColors.WHITE, + secondary_colour: Optional[BGRColorTuple] = BGRColors.BLACK, + text_background_colors: Optional[Iterable[BGRColorTuple]] = None, + thickness: int = 2, + score_as_pct: bool = False, + include_labels: bool = True, + show_score_in_label: bool = False, + include_inset = False, + inset_zoom_factor = 3, + ) -> 'ImageBuilder': + + original_image = self.image.copy() + if text_background_colors is None: + text_background_colors = (None for _ in itertools.count(0)) + for bb, bg in zip(boxes, text_background_colors): + self.draw_box(bb, colour=colour, secondary_colour=secondary_colour, text_background_color=bg, thickness=thickness, score_as_pct=score_as_pct, show_score_in_label=show_score_in_label, + include_labels=include_labels) + if include_inset: + self.draw_corner_inset( + ImageRow(*(ImageBuilder(b.slice_image(original_image)).rescale(inset_zoom_factor).image for b in boxes)).render(), + corner='br', border_color=colour, secondary_border_color=secondary_colour, border_thickness=thickness) + return self + + def draw_border(self, color: BGRColorTuple, thickness: int = 2) -> 'ImageBuilder': + return self.draw_box(BoundingBox.from_ltrb(0, 0, self.image.shape[1]-1, self.image.shape[0]-1), thickness=thickness, colour=color, include_labels=False) + + def draw_zoom_inset_from_box(self, box: BoundingBox, scale_factor: int, border_color=BGRColors.GREEN, border_thickness: int = 2, corner = 'br', backup_corner='bl') -> 'ImageBuilder': + # TODO: Make it nor crash when box is too big + assert corner in ('br', 'tr', 'bl', 'tl') + assert backup_corner in ('br', 'tr', 'bl', 'tl') + sub_image = box.slice_image(self.image) + sub_image = cv2.resize(sub_image, dsize=None, fx=scale_factor, fy=scale_factor, interpolation=cv2.INTER_NEAREST) + corner_size = max(sub_image.shape[:2])/min(self.get_size_xy()) + + self.draw_bounding_boxes([box], colour=border_color, thickness=border_thickness) + + cx, cy = np.array(box.get_center())/self.get_size_xy() + inset_corner = ('t' if cy (1-corner_size) else '-') + ('l' if cx (1-corner_size) else '-') + self.draw_corner_inset(sub_image, border_color=border_color, corner=backup_corner if corner==inset_corner else corner) + return self + + def draw_corner_inset(self, image: BGRImageArray, corner='br', border_color=BGRColors.RED, secondary_border_color: Optional[BGRColorTuple] = None, border_thickness: int = 2) -> 'ImageBuilder': + # TODO: Make it nor crash when box is too big + assert corner in ('br', 'tr', 'bl', 'tl') + vc, hc = corner + vslice = slice(self.image.shape[0]-image.shape[0], self.image.shape[0]) if vc == 'b' else slice(0, image.shape[0]) + hslice = slice(self.image.shape[1]-image.shape[1], self.image.shape[1]) if hc == 'r' else slice(0, image.shape[1]) + vb_slice = slice(max(0, vslice.start-border_thickness), vslice.stop+border_thickness) + hb_slice = slice(max(0, hslice.start-border_thickness), hslice.stop+border_thickness) + if secondary_border_color is not None: + self.image[max(0, vb_slice.start-border_thickness): vb_slice.stop+border_thickness, max(0, hb_slice.start-border_thickness): hb_slice.stop+border_thickness] = secondary_border_color + self.image[vb_slice, hb_slice] = border_color + self.image[vslice, hslice] = image + return self + + def draw_text(self, text: str, loc_xy: Tuple[int, int], colour: BGRColorTuple, anchor_xy: Tuple[float, float] = (0., 0.), shadow_color: Optional[BGRColorTuple] = None, background_color: Optional[BGRColorTuple] = None, thickness=1, + scale=1., font=cv2.FONT_HERSHEY_PLAIN + ) -> 'ImageBuilder': + + put_text_at(self.image, position_xy=loc_xy, text=text, color=colour, shadow_color=shadow_color, background_color=background_color, anchor_xy=anchor_xy, scale=scale, thickness=thickness, font=font) + + # cv2.putText(self.image, text=text, org=self._xy_to_ji(loc), fontFace=cv2.FONT_HERSHEY_PLAIN, fontScale=scale, color=colour, thickness=thickness) + return self + + def draw_corner_text(self, text: str, colour: BGRColorTuple, shadow_color: Optional[BGRColorTuple] = None, background_color: Optional[BGRColorTuple] = None, + corner = 'tl', scale=1, thickness=1, font=cv2.FONT_HERSHEY_PLAIN) -> 'ImageBuilder': + put_text_in_corner(self.image, text=text, color=colour, shadow_color=shadow_color, background_color=background_color, corner=corner, scale=scale, thickness=thickness, font=font) + # cv2.putText(self.image, text=text, org=(0, 10), fontFace=cv2.FONT_HERSHEY_PLAIN, fontScale=1, color=colour) + return self + + def draw_image_at(self, image: BGRImageArray, loc: Union[str, Tuple[float, float]], padding: int = 0, pad_colour: BGRColorTuple = DEFAULT_GAP_COLOR) -> 'ImageBuilder': + + # if padding: # Unnecessary copy here. + # gap_image = create_gap_image(size=(image.shape[1]+2*padding, image.shape[0]+2*padding)) + # gap_image[padding:-padding, padding:-padding] = image + # image = gap_image + if isinstance(loc, str): + i, j = { + 'tl': (0, 0), + 'bl': (self.image.shape[0] - image.shape[0] - 2 * padding, 0), + 'tr': (0, self.image.shape[1] - image.shape[1] - 2 * padding), + 'br': (self.image.shape[0] - image.shape[0] - 2 * padding, self.image.shape[1] - image.shape[1] - 2 * padding), + }[loc] + else: + j, i = self._xy_to_ji(loc) + if padding: + self.image[i: i + image.shape[0] + 2 * padding, j: j + image.shape[1] + 2 * padding] = pad_colour + self.image[i + padding: i + padding + image.shape[0], j + padding:j + padding + image.shape[1]] = image + return self + + def draw_noise(self, amplitude: float, seed: Optional[int] = None): + noise = np.random.RandomState(seed).randn(*self.image.shape)*amplitude + self.image = np.clip((self.image.astype(np.float32) + noise), 0, 255).astype(np.uint8) + return self + + def superimpose(self, image: Union[BGRImageArray, GreyScaleImageArray, BGRFloatImageArray]) -> 'ImageBuilder': + if image.ndim == 2: + image = image[:, :, None] + self.image[:] = np.clip(self.image + image.astype(np.float32), 0, 255).astype(np.uint8) + return self + + def hstack_with(self, other: Union[ImageBuilder, BGRImageArray]) -> ImageBuilder: + other_image = other.image if isinstance(other, ImageBuilder) else other + self.image = ImageRow(self.image, other_image).render() + return self + + + def vstack_with(self, other: Union[ImageBuilder, BGRImageArray]): + other_image = other.image if isinstance(other, ImageBuilder) else other + self.image = ImageCol(self.image, other_image).render() + return self + diff --git a/artemis/image_processing/image_processing_utils.py b/artemis/image_processing/image_processing_utils.py new file mode 100644 index 00000000..9d7ab9d5 --- /dev/null +++ b/artemis/image_processing/image_processing_utils.py @@ -0,0 +1,205 @@ +from typing import Tuple, Optional +import numpy as np +import cv2 + +from artemis.general.custom_types import Array, HeatMapArray, BGRImageArray, AnyImageArray, BGRFloatImageArray, LabelImageArray +from artemis.plotting.easy_window import ImageRow +from artemis.image_processing.image_utils import heatmap_to_color_image, delta_image_to_color_image +from artemis.plotting.cv2_plotting import just_show + + +def box_filter(image: AnyImageArray, ksize: Tuple[int, int], normalize: bool = True, n_iter: int = 1): + result = image + for _ in range(n_iter): + result = cv2.boxFilter(result, ddepth=-1, ksize=ksize, normalize=normalize) + return result + + +def box_blur(image: AnyImageArray, width: int, normalize: bool = True, weights: Optional[HeatMapArray] = None, n_iter: int = 1) -> AnyImageArray: + if weights is not None: + image = image * weights[:, :, None] + + result = box_filter(image, ksize=(width, width), normalize=normalize and weights is None, n_iter=n_iter) + + if normalize and weights is not None: + weight_sum = box_filter(weights, ksize=(width, width), normalize=False, n_iter=n_iter) + result /= weight_sum[:, :, None] # May be nans... + + return result + + +def approx_gaussian_blur(image: BGRImageArray, sigma: float, weights: Optional[HeatMapArray] = None, n_steps: int = 3) -> BGRImageArray: + """ + Approximate a gaussian blur with a series of box blurs. + gaussian_variance: sigma**2 + sum_of_uniform variance: n_steps*kwidth**2/12 + k_width = sqrt( 12 * sigma**2 / n_steps) + + :param image: + :param sigma: + :param weights: + :param n_steps: + :return: + """ + kwidth = round(np.sqrt(12 * sigma ** 2 / n_steps)) + if weights is not None: + image = image * weights[:, :, None] + result = box_filter(image, ksize=(kwidth, kwidth), n_iter=n_steps) + if weights is not None: + weight_sum = box_filter(weights, (kwidth, kwidth), n_iter=n_steps) + result /= weight_sum[:, :, None] + return result + + +def gaussian_blur(image: BGRImageArray, sigma: float, truncation_factor: float = 3., weights: Optional[HeatMapArray] = None) -> BGRImageArray: + ksize = int(truncation_factor * sigma) * 2 + 1 + if weights is not None: + image = image * weights[:, :, None] + result = cv2.GaussianBlur(image, ksize=(ksize, ksize), sigmaX=sigma) + if weights is not None: + weight_sum = cv2.GaussianBlur(weights, (ksize, ksize), sigmaX=sigma) + result /= weight_sum[:, :, None] + return result + + +def holy_box_blur(image: AnyImageArray, outer_box_width: int, inner_box_width: int, normalize: bool = True, weights: Optional[HeatMapArray] = None, + n_iter: int = 1) -> AnyImageArray: + # TODO: Be more efficient about use of weights to avoid duplication of computation + local_sum = box_blur(image, width=inner_box_width, normalize=False, weights=weights, n_iter=n_iter) + big_box_sum = box_blur(image, width=outer_box_width, normalize=False, weights=weights, n_iter=n_iter).astype(float) + context_mean = (big_box_sum - local_sum) # Not actually a mean yet - as we have to normalize still + if normalize: + if weights is not None: + divisor = holy_box_blur(image=weights, normalize=False, n_iter=n_iter, inner_box_width=inner_box_width, outer_box_width=outer_box_width) + context_mean /= divisor[:, :, None] + else: + context_mean /= (outer_box_width ** 2 - inner_box_width ** 2) + + return context_mean + + +def compute_center_surround_means(heatmap, inner_box_width: int, outer_box_width: int + ) -> Tuple[HeatMapArray, HeatMapArray]: + center_blur = box_blur(heatmap, width=inner_box_width, normalize=False) + surround_blur = box_blur(heatmap, width=outer_box_width, normalize=False) + center_blur /= inner_box_width**2 + surround_blur /= outer_box_width ** 2 - inner_box_width ** 2 + return center_blur, surround_blur + +def compute_aloneness_factor(heatmap: HeatMapArray, outer_box_width: int, inner_box_width: int, suppression_factor: float = 1, + n_iter: int = 1, debug=False) -> HeatMapArray: + """ Compute an 'aloneness factor' which indicates how "alone" each region in the heatmap is. This will b """ + # center_blur = box_blur(heatmap, width=inner_box_width, normalize=False) + # big_box_sum = box_blur(heatmap, width=outer_box_width, normalize=False) + # surround_blur = big_box_sum - center_blur + # center_blur *= center_scale / inner_box_width**2 + # surround_blur *= surround_scale/(outer_box_width ** 2 - inner_box_width ** 2) + # # suppressed = np.clip(center_blur-surround_blur, 0, None) + # suppressed = np.exp((surround_blur-center_blur)/surround_blur) + + suppressed = heatmap + for i in range(n_iter): + center_blur = box_blur(suppressed, width=inner_box_width, normalize=False) / inner_box_width**2 + surround_blur = box_blur(suppressed, width=outer_box_width, normalize=False) * suppression_factor / outer_box_width**2 + # suppressed = np.exp((center_blur-surround_blur)/surround_blur) + suppressed = np.clip(center_blur-surround_blur, 0, None) + + if debug: + img = ImageRow(heatmap=heatmap_to_color_image(heatmap, show_range=True), + center_blur = heatmap_to_color_image(center_blur, show_range=True), + surround_blur = heatmap_to_color_image(surround_blur, show_range=True), + delta = delta_image_to_color_image(center_blur-surround_blur, show_range=True), + suppressed=heatmap_to_color_image(suppressed, show_range=True), + wrap=2 + ).render() + just_show(img, hang_time=0.1) + + + # center_blur = box_blur(heatmap, width=inner_box_width, normalize=False) + # surround_blur = box_blur(heatmap, width=outer_box_width, normalize=False) + # + # suppressed = np.exp( (surround_blur - center_blur) / surround_blur) + + # surround_blur = big_box_sum - center_blur + # center_blur *= center_scale / inner_box_width**2 + # surround_blur *= surround_scale/(outer_box_width ** 2 - inner_box_width ** 2) + # suppressed = np.clip(center_blur-surround_blur, 0, None) + + + + + + return suppressed # TODO: Is this right? + + +def non_maximal_suppression(heatmap: HeatMapArray, outer_box_width: int, inner_box_width: int, suppression_factor: float = 1, + n_iter: int = 1) -> HeatMapArray: + + suppression_map = heatmap + for i in range(n_iter): + center_mean, surround_mean = compute_center_surround_means(heatmap, inner_box_width=inner_box_width, outer_box_width=outer_box_width) + surround_mean *= suppression_factor + relative_diff = (center_mean-surround_mean) / (2*(center_mean + surround_mean)) + suppression_map = np.exp(relative_diff) + return suppression_map + + + + + +def compute_context_mean_global_var_background_model( + image: BGRImageArray, + anomaly_size: int, + context_size: int, + base_variance: float = 0., + gaussian_approximation_level: int = 0, + weights: Optional[HeatMapArray] = None, + use_context_mean_to_find_var: bool = False +) -> Tuple[Array['H,W,C', float], Array['C,C', float]]: + """ Compute a pixelwise 'background model' with a position-varying mean and a global covariance. """ + n_colours = image.shape[2] + image = image.astype(float, copy=False) + context_mean = holy_box_blur(image, outer_box_width=context_size, inner_box_width=anomaly_size, weights=weights, n_iter=gaussian_approximation_level + 1) + + if use_context_mean_to_find_var: + context_covariance = np.cov((image - context_mean).reshape(-1, n_colours), rowvar=False, aweights=weights.ravel() if weights is not None else None) + else: + context_covariance = np.cov(image.reshape(-1, n_colours), rowvar=False, aweights=weights.ravel() if weights is not None else None) + + ixs = np.arange(n_colours) + context_covariance[ixs, ixs] = np.maximum(context_covariance[ixs, ixs], base_variance) + return context_mean, context_covariance + + +def compute_pixel_mean_and_cov(image: BGRImageArray) -> Tuple[Array['3', float], Array['3,3', float]]: + # TODO: Speed up by using mean for cov + image = image.reshape(-1, image.shape[2]) + mean = image.mean(axis=0) + cov = np.cov(image) + return mean, cov + + +def compute_pixelwise_mahalanobis_dist_sq( + image: BGRImageArray, + model_mean: BGRFloatImageArray, + model_cov: Array['...,3,3', float] +) -> HeatMapArray: + delta_from_mean = (image - model_mean).reshape(-1, 3) + m_dist_sq = np.einsum('ij,jk,ik->i', delta_from_mean, np.linalg.inv(model_cov), delta_from_mean).reshape(image.shape[:2]) + return m_dist_sq + + +def compute_cluster_mean_holy_image(image: BGRImageArray, clusters: LabelImageArray, inner_width: int, outer_width: int) -> BGRImageArray: + """ Compute the image taken """ + cluster_ids = np.unique(clusters) + + output_image = np.empty_like(image) + for c in cluster_ids: + mask = clusters == c + avg = holy_box_blur(image, outer_box_width=outer_width, inner_box_width=inner_width, weights=mask.astype(np.float32)) + output_image[mask] = avg[mask] + return output_image + + + + diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py new file mode 100644 index 00000000..7b0e67e4 --- /dev/null +++ b/artemis/image_processing/image_utils.py @@ -0,0 +1,787 @@ +import dataclasses +import itertools +import os +from abc import abstractmethod, ABCMeta +from dataclasses import dataclass +from math import floor, ceil +from typing import Iterable, Tuple, Union, Optional, Sequence, Callable, TypeVar, Iterator + +import cv2 +import numpy as np +from attr import attrs, attrib + +from artemis.general.custom_types import XYSizeTuple, BGRColorTuple, HeatMapArray, BGRImageDeltaArray, MaskImageArray, LabelImageArray, BGRFloatImageArray, GreyScaleImageArray, \ + BGRImageArray, TimeIntervalTuple, Array, GeneralImageArray + + +class BGRColors: + BLACK = 0, 0, 0 + WHITE = 255, 255, 255 + BLUE = 255, 0, 0 + GREEN = 0, 255, 0 + YELLOW = 0, 255, 255 + MAGENTA = 255, 0, 255 + CYAN = 255, 255, 0 + SLATE_BLUE = 255, 100, 100 + EYE_BLUE_DARK = 113, 80, 36 + EYE_BLUE = 175, 142, 68 + EYE_BLUE_GRAY = 144, 73, 42 + GRAY = 127, 127, 127 + RED = 0, 0, 255 + DARK_RED = 0, 0, 128 + DARK_GREEN = 0, 100, 0 + DARK_GRAY = 50, 50, 50 + LIGHT_GRAY = 200, 200, 200 + ORANGE = 0, 150, 255 + SKY_BLUE = 255, 180, 100 + VERY_DARK_BLUE = 20, 0, 0 + + +DEFAULT_GAP_COLOR = BGRColors.VERY_DARK_BLUE + + +def iter_colour_fade(start_color: BGRColorTuple, end_color: BGRColorTuple, steps: Union[int, Sequence[float]]) -> Iterator[BGRColorTuple]: + b1, g1, r1 = start_color + b2, g2, r2 = end_color + if isinstance(steps, int): + steps = np.linspace(0, 1, steps) + return ((int(b1 * (1 - f) + b2 * f), int(g1 * (1 - f) + g2 * f), int(r1 * (1 - f) + r2 * f)) for f in steps) + + +def normalize_to_bgr_image(image_data: Union[MaskImageArray, HeatMapArray, BGRImageArray]) -> BGRImageArray: + if isinstance(image_data, str): + image = TextDisplayer().render(image_data) + elif image_data.dtype == np.uint8: + if image_data.ndim == 2: + image_data = np.repeat(image_data[:, :, None], repeats=3, axis=2) + image = image_data + elif image_data.dtype == bool: + image = mask_to_color_image(image_data) + elif image_data.dtype in (int, float, np.float32): + image = heatmap_to_color_image(image_data) + else: + raise Exception(f"Can't handle image of dtype: {image_data.dtype}") + return image + + +ShowFunction = Callable[[BGRImageArray, str], Optional[str]] +_ALTERNATE_SHOW_FUNC: Optional[ShowFunction] = None + + +# Alternate function for showing an image. Returns a key-string if any key pressed. + + +def resize_to_fit(image: BGRImageArray, xy_size: Union[int, Tuple[int, int]], expand: bool = False) -> BGRImageArray: + if isinstance(xy_size, int): + xy_size = (xy_size, xy_size) + sy, sx = image.shape[:2] + smx, smy = xy_size + x_rat, y_rat = sx / smx, sy / smy + if x_rat >= y_rat and (expand or y_rat > 1): + return cv2.resize(image, dsize=(smx, int(sy / x_rat))) + elif y_rat >= x_rat and (expand or x_rat > 1): + return cv2.resize(image, dsize=(int(sx / y_rat), smy)) + else: + return image + + +def put_image_in_box(image: BGRImageArray, xy_size: Union[int, Tuple[int, int]], + gap_colour: BGRColorTuple = DEFAULT_GAP_COLOR, expand=True, + ) -> BGRImageArray: + resized_image = resize_to_fit(image, xy_size=xy_size, expand=expand) + box = create_gap_image(xy_size, gap_colour=gap_colour) + y_start = (xy_size[1] - resized_image.shape[0]) // 2 + x_start = (xy_size[0] - resized_image.shape[1]) // 2 + box[y_start: y_start + resized_image.shape[0], x_start: x_start + resized_image.shape[1]] = resized_image + return box + + +def compose_time_intervals(t1: TimeIntervalTuple, t2: TimeIntervalTuple) -> TimeIntervalTuple: + t1s, t1e = t1 + t2s, t2e = t2 + t3s = t2s if t1s is None else t1s if t2s is None else t1s + t2s + t3e = t2e if t1e is None else t1e if t2e is None else t3s + min(t1e - t1s, t2e - t2s) + return t3s, t3e + + +def fit_image_to_max_size(image: BGRImageArray, max_size: Tuple[int, int]): + """ Make sure image fits within (width, height) max_size while preserving aspect ratio """ + if image.shape[0] > max_size[1] or image.shape[1] > max_size[0]: + scale_factor = min(max_size[1] / image.shape[0], max_size[0] / image.shape[1]) + return cv2.resize(src=image, dsize=None, fx=scale_factor, fy=scale_factor) + else: + return image + + +""" Deprecated. Use VideoSegment.iter_images """ + + +def iter_images_from_video(path: str, max_size: Optional[Tuple[int, int]] = None, + frame_interval: Tuple[Optional[int], Optional[int]] = (None, None), + time_interval: TimeIntervalTuple = (None, None), + frames_of_interest: Optional[Sequence[int]] = None, + use_scan_selection: bool = False, # Select frames of interest by scanning video + keep_ratio: float = 1., # Use this to match videos with different framerates + rotation: int = 0, + verbose: bool = False, + ) -> Iterable[BGRImageArray]: + assert not use_scan_selection, "This does not work. See bug: https://github.com/opencv/opencv/issues/9053" + path = os.path.expanduser(path) + cap = cv2.VideoCapture(path) + start_frame, stop_frame = frame_interval + start_time, end_time = time_interval + if max_size is not None: # Set cap size. Sometimes this does not work so we also have the code below. + sx, sy = max_size if rotation in (0, 2) else max_size[::-1] + cap.set(cv2.CAP_PROP_FRAME_WIDTH, sx) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, sy) + + if start_time is not None: + cap.set(cv2.CAP_PROP_POS_MSEC, start_time * 1000.) + + if start_frame is not None: + cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame) + frame_ix = start_frame + else: + frame_ix = 0 + + fps = cap.get(cv2.CAP_PROP_FPS) + + unique_frame_ix = -1 + + iter_frames_of_interest = iter(frames_of_interest) if frames_of_interest is not None else None + + initial_frame = cap.get(cv2.CAP_PROP_POS_FRAMES) + + while cap.isOpened(): + + if iter_frames_of_interest is not None and use_scan_selection: + try: + next_frame = initial_frame + next(iter_frames_of_interest) + (1 if initial_frame == 0 else 0) # Don't know why it just works + except StopIteration: + break + cap.set(cv2.CAP_PROP_POS_FRAMES, next_frame) + + if stop_frame is not None and frame_ix >= stop_frame: + break + elif end_time is not None and frame_ix / fps > end_time - (start_time or 0.): + break + + unique_frame_ix += 1 + + isgood, image = cap.read() + + if not isgood: + print(f'Reach end of video at {path}') + break + if max_size is not None: + image = fit_image_to_max_size(image, max_size) + if keep_ratio != 1: + if verbose: + print(f'Real surplus: {frame_ix - keep_ratio * unique_frame_ix}') + frame_surplus = round(frame_ix - keep_ratio * unique_frame_ix) + if frame_surplus < 0: # Frame debt - yield this one twice + if verbose: + print('Yielding extra frame due to frame debt') + yield image + frame_ix += 1 + elif frame_surplus > 0: # Frame surplus - skip it + if verbose: + print('Skipping frame due to frame surplus') + continue + + if iter_frames_of_interest is None or use_scan_selection or (not use_scan_selection and frame_ix in frames_of_interest): + if rotation != 0: + yield cv2.rotate(image, rotateCode={1: cv2.ROTATE_90_CLOCKWISE, 2: cv2.ROTATE_180, 3: cv2.ROTATE_90_COUNTERCLOCKWISE}[rotation]) + else: + yield image + frame_ix += 1 + + +def iter_passthrough_write_video(image_stream: Iterable[BGRImageArray], path: str, fps: float = 30.) -> Iterable[BGRImageArray]: + path = os.path.expanduser(path) + dirs, _ = os.path.split(path) + try: + os.makedirs(dirs) + except OSError: + pass + cap = None + for img in image_stream: + if cap is None: + # cap = cv2.VideoWriter(path, fourcc=cv2.VideoWriter_fourcc('M', 'J', 'P', 'G'), fps=fps, frameSize=(img.shape[1], img.shape[0])) + cap = cv2.VideoWriter(path, fourcc=cv2.VideoWriter_fourcc('H', '2', '6', '4'), fps=fps, frameSize=(img.shape[1], img.shape[0])) + cap.write(img) + yield img + cap.release() + print(f'Saved video to {path}') + + + +def fade_image(image: BGRImageArray, fade_level: float) -> BGRImageArray: + return (image.astype(np.float)*fade_level).astype(np.uint8) + + +def mask_to_color_image(mask: MaskImageArray) -> BGRImageArray: + image = np.zeros(mask.shape[:2] + (3,), dtype=np.uint8) + image[mask] = 255 + return image + + +def compute_heatmap_bounds(heatmap: HeatMapArray, assume_zero_min: bool = False, assume_zero_center: bool = False) -> Tuple[float, float]: + assert not (assume_zero_min and assume_zero_center), "You can't assube both the min and the center are zero" + if assume_zero_center: + extreme = np.max(np.abs(heatmap)) + min_heat, max_heat = -extreme, extreme + else: + min_heat = 0. if assume_zero_min else np.min(heatmap) + max_heat = np.max(heatmap) + return min_heat, max_heat + + +def heatmap_to_greyscale_image(heatmap: HeatMapArray, assume_zero_min: bool = False, assume_zero_center: bool = False + ) -> GreyScaleImageArray: + min_heat, max_heat = compute_heatmap_bounds(heatmap, assume_zero_min=assume_zero_min, assume_zero_center=assume_zero_center) + img = np.zeros(heatmap.shape[:2], dtype=np.uint8) + if min_heat != max_heat: + img[:] = ((heatmap - min_heat) * (255 / (max_heat - min_heat))).astype(np.uint8) + return img + + +def heatmap_to_color_image(heatmap: HeatMapArray, assume_zero_min: bool = True, assume_zero_center: bool = False, show_range=False, + upsample_factor: int = 1, additional_text: Optional[str] = None, text_scale=1. + ) -> BGRImageArray: + min_heat, max_heat = compute_heatmap_bounds(heatmap, assume_zero_min=assume_zero_min, assume_zero_center=assume_zero_center) + if heatmap.ndim == 2: + heatmap = heatmap[:, :, None] + img = np.zeros(heatmap.shape[:2] + (3,), dtype=np.uint8) + if min_heat != max_heat: + img[:] = ((heatmap - min_heat) * (255 / (max_heat - min_heat))).astype(np.uint8) + if upsample_factor != 1: + img = cv2.resize(img, dsize=None, fx=upsample_factor, fy=upsample_factor, interpolation=cv2.INTER_NEAREST) + if show_range: + text = f'Scale: {min_heat:.2g} -> {max_heat:.2g}' + if additional_text is not None: + text = ', '.join([text, additional_text]) + range_info = TextDisplayer(max_size=(img.shape[1], 15), match_max_size=True, scale=text_scale).render(text) + img = np.vstack([img, range_info]) + return img + + +def float_color_image_to_color_image(float_color_image: BGRFloatImageArray) -> BGRImageArray: + return np.clip(float_color_image, 0, 255).astype(np.uint8) + + +def image_and_heatmap_to_color_image(image: BGRImageArray, heatmap: HeatMapArray) -> BGRImageArray: + max_heat = np.max(heatmap) + return (image * (heatmap[:, :, None] / max_heat if max_heat != 0 else 0.)).astype(np.uint8) + + +def delta_image_to_color_image(delta_image: BGRImageDeltaArray, show_range: bool = False) -> BGRImageArray: + if delta_image.ndim == 2: + delta_image = np.repeat(delta_image[:, :, None], axis=2, repeats=3) + max_dev = np.max(np.abs(delta_image)) + img = ((delta_image * (127 / max_dev)) + 127).astype(np.uint8) + if show_range: + range_info = TextDisplayer(max_size=(delta_image.shape[1], 15), match_max_size=True).render(f'Scale: {-max_dev:.2g} -> {max_dev:.2g}') + img = np.vstack([range_info, img]) + return img + + +class IWorldToPixFunc(metaclass=ABCMeta): + + @abstractmethod + def __call__(self, world_coords: Tuple[float, float]) -> Tuple[int, int]: + raise NotImplementedError() + + +class IdentityWorldToPixFunc(IWorldToPixFunc): + + def __call__(self, world_coords: Tuple[float, float]) -> Tuple[int, int]: + wx, wy = world_coords + + return round(wx), round(wy) + + +@dataclass +class NormalizedWorldToPixFunc(IWorldToPixFunc): + img_size: XYSizeTuple + y_from_bottom: bool = False + + def __call__(self, world_coords: Tuple[float, float]) -> Tuple[int, int]: + wx, wy = world_coords + sx, sy = self.img_size + return round(wx * sx), round(wy * sy) + + +def get_segmentation_colours(include_null: bool = True) -> Sequence[BGRColorTuple]: + colours = (BGRColors.BLACK,) if include_null else () + colours += (BGRColors.SLATE_BLUE, BGRColors.GREEN, BGRColors.RED, BGRColors.CYAN, BGRColors.YELLOW, BGRColors.MAGENTA, \ + BGRColors.GRAY, BGRColors.ORANGE, BGRColors.BLUE, BGRColors.DARK_GREEN) + return colours + + +def label_image_to_bgr(label_image: LabelImageArray, colours: Optional[Iterable[BGRColorTuple]] = get_segmentation_colours() + ) -> BGRImageArray: + color_cycle = itertools.cycle(colours) + max_label = int(np.max(label_image)) + colors = np.array([c for i, c in zip(range(max_label + 1), color_cycle)], dtype=np.uint8) + return colors[label_image] + + +T = TypeVar('T', bound='BaseBox') + + +@dataclass +class BaseBox: + x_min: float + x_max: float + y_min: float + y_max: float + label: str = '' + score: float = 1. + + @classmethod + def from_ltrb(cls, l, t, r, b, label: str = '', score: float = 1.) -> 'BaseBox': + return cls(x_min=l, x_max=r, y_min=t, y_max=b, label=label, score=score) + + @classmethod + def from_ijhw(cls, i, j, h, w, label: str = '', score: float = 1.) -> 'BaseBox': + return cls(x_min=j - w / 2, x_max=j + w / 2, y_min=i - h / 2, y_max=i + h / 2, label=label, score=score) + + @classmethod + def from_xywh(cls, x, y, w, h, label: str = '') -> 'BaseBox': + return cls(x_min=x - w / 2, x_max=x + w / 2, y_min=y - h / 2, y_max=y + h / 2, label=label) + + @classmethod + def from_center_crop(cls, box_size_xy: Tuple[float, float], parent_size_xy: Tuple[float, float], center_xy: Optional[Tuple[float, float]] = None): + bw, bh = box_size_xy + pw, ph = parent_size_xy + x_min, y_min = (pw - bw) / 2, (ph - bh) / 2 + return cls(x_min=x_min, x_max=x_min + bw, y_min=y_min, y_max=y_min + bh) + + def scale_about_center(self, scale: float) -> 'BaseBox': + x, y, w, h = self.get_xywh() + return self.from_xywh(x, y, w * scale, h * scale, label=self.label) + + def get_xy_size(self) -> Tuple[float, float]: + return self.x_max - self.x_min, self.y_max - self.y_min + + def get_center(self) -> Tuple[float, float]: + return (self.x_min + self.x_max) / 2, (self.y_min + self.y_max) / 2 + + def get_xywh(self, ) -> Tuple[float, float, float, float]: + return self.get_center() + self.get_xy_size() + + def get_area(self) -> float: + h, w = self.get_xy_size() + return h * w + + def get_diagonal_length(self) -> float: + sx, sy = self.get_xy_size() + return (sx ** 2 + sy ** 2) ** .5 + + def get_intersection_box(self, other) -> Optional['BoundingBox']: + x_min, x_max, y_min, y_max = max(self.x_min, other.x_min), min(self.x_max, other.x_max), max(self.y_min, other.y_min), min(self.y_max, other.y_max) + if x_max > x_min and y_max > y_min: # Box is valid + return BoundingBox(x_min, x_max, y_min, y_max) + else: + return None + + def is_valid(self) -> bool: + return self.x_max > self.x_min and self.y_max > self.y_min + + def get_intersection_area(self, other) -> float: + ibox = self.get_intersection_box(other) + return 0. if ibox is None else ibox.get_area() + + def get_union_box(self, other) -> 'BoundingBox': + x_min, x_max, y_min, y_max = min(self.x_min, other.x_min), max(self.x_max, other.x_max), min(self.y_min, other.y_min), max(self.y_max, other.y_max) + return BoundingBox(x_min, x_max, y_min, y_max) + + @classmethod + def total_area(cls, boxes: Sequence['RelativeBoundingBox']): + return sum(bb.get_area() for bb in boxes) - sum(bb1.get_intersection_area(bb2) for bb1 in boxes for bb2 in boxes if bb1 is not bb2) + + def is_containing_point(self, xy: Tuple[float, float]) -> bool: + x, y = xy + return self.x_min <= x < self.x_max and self.y_min <= y < self.y_max + + +def slice_image_with_pad(image: "Array['H,W,C', dtype]", xxyy_box: Tuple[int, int, int, int], gap_color: "Array['C', dtype]"): + """ + Slice the image with the xxyy_box (x_start, x_stop, y_start, y_stop) + And fill in the empty areas with the gap color + Such that the result will have shape + """ + l, r, t, b = xxyy_box + l_pad = max(0, -l) + r_pad = max(0, r - image.shape[1]) + t_pad = max(0, -t) + b_pad = max(0, b - image.shape[0]) + if l_pad == t_pad == r_pad == b_pad == 0: + return image[t:b, l:r].copy() + else: + img = np.full((b - t, r - l,) + image.shape[2:], dtype=image.dtype, fill_value=gap_color) + img[t_pad:img.shape[0] - b_pad, l_pad:img.shape[1] - r_pad] = image[max(0, t):max(0, b), max(0, l):max(0, r)] + return img + + +@dataclass +class BoundingBox(BaseBox): + + def get_xwraps(self, x_size: int) -> Tuple['BoundingBox', 'BoundingBox']: + """ get the left-and-wright wraps of this box (the 'right' will fall out of the image if the box does not wrap) """ + + offset = (int(self.x_min) // x_size) * x_size + right_wrap = dataclasses.replace(self, x_min=self.x_min - offset, x_max=self.x_max - offset) + left_wrap = dataclasses.replace(self, x_min=self.x_min - offset - x_size, x_max=self.x_max - offset - x_size) + return left_wrap, right_wrap + + def get_relative_distance(self, other: 'BoundingBox') -> float: + """ Get relative distance between boxes""" + w1, h1 = self.get_xy_size() + w2, h2 = self.get_xy_size() + wm, hm = (w1 + w2) / 2, (h1 + h2) / 2 + x1, y1 = self.get_center() + x2, y2 = other.get_center() + return np.sqrt(((x2 - x1) / wm) ** 2 + ((y2 - y1) / hm) ** 2) + + def is_contained_in_image(self, image_size_xy: Tuple[int, int]): + sx, sy = image_size_xy + return self.x_min >= 0 and self.x_max < sx and self.y_max >= 0 and self.y_min < sy + + # @classmethod + # def from_lbwh(cls, l, b, w, h, label: str = '') -> 'BoundingBox': + # return BoundingBox(x_min=l, x_max=l+w, y_min=b, y_max=b+h, label=label) + + def to_ij(self): + return round((self.y_min + self.y_max) / 2.), round((self.x_min + self.x_max) / 2.) + + def compute_iou(self, other: 'BoundingBox') -> float: + """ Get Intersection-over-Union overlap area between boxes - will be between zero and 1 """ + intesection_box = self.get_intersection_box(other) + if intesection_box is not None: + return intesection_box.get_area() / (self.get_area() + other.get_area() - intesection_box.get_area()) + else: + return 0. + + def get_shifted(self, xy_shift: Tuple[float, float], frame_size_limit: Tuple[Optional[int], Optional[int]] = (None, None) + ) -> 'BoundingBox': + dx, dy = xy_shift + lx, ly = frame_size_limit + if lx is not None: + dx = min(max(dx, -self.x_min), lx - self.x_max) + if ly is not None: + dy = min(max(dy, -self.y_min), ly - self.y_max) + return BoundingBox(x_min=self.x_min + dx, x_max=self.x_max + dx, y_min=self.y_min + dy, y_max=self.y_max + dy, label=self.label) + + def get_lrbt_int(self) -> Tuple[int, int, int, int]: + return max(0, floor(self.x_min)), max(0, ceil(self.x_max)), max(0, floor(self.y_min)), max(0, ceil(self.y_max)) + + def get_xywh_int(self): + l, r, b, t = self.get_lrbt_int() + return (l + r) // 2, (b + t) // 2, r - l, t - b + + def get_image_slicer(self) -> Tuple[slice, slice]: + y_slice = slice(max(0, floor(self.y_min)), max(0, ceil(self.y_max + 1e-9))) + x_slice = slice(max(0, floor(self.x_min)), max(0, ceil(self.x_max + 1e-9))) + return y_slice, x_slice + + def slice_image(self, image: BGRImageArray, copy: bool = False, wrap_x=False) -> BGRImageArray: + + y_slice, x_slice = self.get_image_slicer() + if wrap_x: + x_slice = np.arange(floor(self.x_min), ceil(self.x_max + 1e-9)) % image.shape[1] + copy = False # Bewcase this slicing will already cause a copy + + image_crop = image[y_slice, x_slice] + if copy: + image_crop = image_crop.copy() + # assert image_crop.shape[1] == round(self.x_max - self.x_min) + 1 + return image_crop + + def crop_image(self, image: BGRImageArray, gap_color: BGRColorTuple = DEFAULT_GAP_COLOR): + return slice_image_with_pad(image, xxyy_box=[int(self.x_min), int(self.x_max), int(self.y_min), int(self.y_max)], gap_color=gap_color) + + def squareify(self) -> 'BoundingBox': + sx, sy = self.get_center() + size = max(self.get_xy_size()) + return BoundingBox.from_xywh(sx, sy, size, size, label=self.label) + + def scale_by(self, factor: float) -> 'BoundingBox': + sx, sy = self.get_center() + w, h = self.get_xy_size() + return BoundingBox.from_xywh(sx, sy, w * factor, h * factor, label=self.label) + + def pad(self, pad: float) -> 'BoundingBox': + sx, sy = self.get_center() + w, h = self.get_xy_size() + return BoundingBox.from_xywh(sx, sy, w + pad, h + pad, label=self.label) + + def to_relative(self, img_size_xy: Tuple[int, int], clip_if_needed=False) -> "RelativeBoundingBox": + return RelativeBoundingBox.from_absolute_bbox(self, img_size_xy=img_size_xy, clip_if_needed=clip_if_needed) + + +@dataclass +class RelativeBoundingBox(BaseBox): + """ A bounding box defined relative to the size of the image. """ + + # x_min: float + # x_max: float + # y_min: float + # y_max: float + # label: str = '' + # score: float = 1. # 1=positive, 0=neutral, -1=negative. + + def __post_init__(self): + assert 0 <= (b := self.x_min) <= 1, f"Bad value: {b}" + assert 0 <= (b := self.x_max) <= 1, f"Bad value: {b}" + assert 0 <= (b := self.y_min) <= 1, f"Bad value: {b}" + assert 0 <= (b := self.y_max) <= 1, f"Bad value: {b}" + + @classmethod + def from_xywh(cls, x: float, y: float, w: float, h: float, label: str = '', cut_to_size=True) -> 'RelativeBoundingBox': + x_min, x_max, y_min, y_max = x - w / 2, x + w / 2, y - h / 2, y + h / 2 + if cut_to_size: + x_min, x_max, y_min, y_max = [np.clip(l, 0, 1) for l in (x_min, x_max, y_min, y_max)] + return RelativeBoundingBox(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, label=label) + + @classmethod + def from_absolute_bbox(cls, bbox: BoundingBox, img_size_xy: Tuple[int, int], clip_if_needed: bool = False) -> 'RelativeBoundingBox': + w, h = img_size_xy + if clip_if_needed: + return RelativeBoundingBox(x_min=max(0., bbox.x_min / w), x_max=min(1., bbox.x_max / w), + y_min=max(0., bbox.y_min / h), y_max=min(1., bbox.y_max / h), + score=bbox.score, label=bbox.label) + else: + return RelativeBoundingBox(x_min=bbox.x_min / w, x_max=bbox.x_max / w, + y_min=bbox.y_min / h, y_max=bbox.y_max / h, + score=bbox.score, label=bbox.label) + + def to_xxyy(self): + return self.x_min, self.x_max, self.y_min, self.y_max + + def to_ij(self): + return (self.y_min + self.y_max) / 2., (self.x_min + self.x_max) / 2. + + def to_bounding_box(self, image_size_xy: XYSizeTuple) -> BoundingBox: + w, h = image_size_xy + return BoundingBox(x_min=self.x_min * w, x_max=self.x_max * w, y_max=self.y_max * h, y_min=self.y_min * h, score=self.score, label=self.label) + + def to_bounding_box_given_image(self, image: GeneralImageArray) -> BoundingBox: + return self.to_bounding_box(image_size_xy=(image.shape[1], image.shape[0])) + + def slice_image(self, image: BGRImageArray, copy: bool = False, wrap_x=False) -> BGRImageArray: + + y_slice, x_slice = self.to_bounding_box_given_image(image).get_image_slicer() + if wrap_x: + x_slice = np.arange(floor(self.x_min), ceil(self.x_max + 1e-9)) % image.shape[1] + copy = False # Bewcase this slicing will already cause a copy + + image_crop = image[y_slice, x_slice] + if copy: + image_crop = image_crop.copy() + # assert image_crop.shape[1] == round(self.x_max - self.x_min) + 1 + return image_crop + + def to_ij_abs(self, img_size_xy) -> Tuple[int, int]: + w, h = img_size_xy + irel, jrel = self.to_ij() + iabs, jabs = irel * h, jrel * w + return round(iabs), round(jabs) + + +def vstack_images(images: Sequence[BGRImageArray], gap_colour: BGRColorTuple = DEFAULT_GAP_COLOR, border: int = 1 + ) -> BGRImageArray: + ysize = sum(im.shape[0] for im in images) + (len(images) + 1) * border + xsize = max(im.shape[1] for im in images) + 2 * border + base_img = create_gap_image(size=(xsize, ysize), gap_colour=gap_colour) + y_start = border + for im in images: + x_start = (base_img.shape[1] - im.shape[1]) // 2 + border + y_end = y_start + im.shape[0] + base_img[y_start: y_end, x_start: x_start + im.shape[1]] = im + y_start = y_end + border + return base_img + + +def create_gap_image( # Generate a colour image filled with one colour + size: Tuple[int, int], # Image (width, height)) + gap_colour: Optional[Tuple[int, int, int]] = None # BGR color to fill gap, or None to use default +) -> 'array(H,W,3)[uint8]': + if gap_colour is None: + gap_colour = DEFAULT_GAP_COLOR + + width, height = size + img = np.zeros((height, width, 3), dtype=np.uint8) + img += np.array(gap_colour, dtype=np.uint8) + return img + + +@attrs +class TextDisplayer: + """ Converts text to image """ + text_color = attrib(default=BGRColors.WHITE) + thickness = attrib(default=1) + font = attrib(default=cv2.FONT_HERSHEY_PLAIN) + scale = attrib(default=1) + background_color = attrib(factory=lambda: DEFAULT_GAP_COLOR) + size = attrib(type=Optional[Tuple[int, int]], default=None) # (width, height) in characters + vspace = attrib(type=float, default=0.4) + expand_box = attrib(type=bool, default=True) + max_size = attrib(type=Tuple[Optional[int], Optional[int]], default=(None, None)) + match_max_size = attrib(type=bool, default=False) + _last_size = attrib(type=Optional[Tuple[int, int]], default=None) + + def render(self, data: str) -> BGRImageArray: + + lines = data.split('\n') + longest_line = max(lines, key=len) + (text_width, text_height), baseline = cv2.getTextSize(longest_line, self.font, self.scale, self.thickness) + + width, height = text_width + 10, int(len(lines) * text_height * (1 + self.vspace)) + wmax, hmax = self.max_size + if self.match_max_size: + assert wmax is not None and hmax is not None, f"If you match max size, you need to specify. Got {self.max_size}" + width, height = wmax, hmax + else: + width, height = (min(width, wmax) if wmax is not None else width), (min(height, hmax) if hmax is not None else height) + + if self.expand_box: + oldwidth, oldheight = self._last_size if self._last_size is not None else (0, 0) + self._last_size = max(oldwidth, width), max(oldheight, height) + width, height = self._last_size + img = create_gap_image((width, height) if self.size is None else self.size, gap_colour=self.background_color) + for i, line in enumerate(lines): + cv2.putText(img, line, (0, int(baseline * 2 + i * (1 + self.vspace) * text_height)), fontFace=self.font, + fontScale=self.scale, color=self.text_color, + thickness=self.thickness, bottomLeftOrigin=False) + return img + + +def conditional_running_min(values, condition, axis, default): + max_value = np.iinfo(values.dtype).max + result_array = np.full(values.shape, fill_value=max_value) + agg = np.full(values.shape[axis], fill_value=max_value) + for i in range(values.shape[axis]): + index_slice = (slice(None),) * axis + (i,) + these_values = values[index_slice] + these_conditions = condition[index_slice] + agg = np.where(these_conditions, np.minimum(agg, these_values), max_value) + result_array[index_slice] = agg + result_array[result_array == max_value] = default + return result_array + + +def mask_to_boxes(mask: Array['H,W', bool]) -> Array['N,4', int]: + """ Convert a boolean (Height x Width) mask into a (N x 4) array of NON-OVERLAPPING bounding boxes + surrounding "islands of truth" in the mask. Boxes indicate the (Left, Top, Right, Bottom) bounds + of each island, with Right and Bottom being NON-INCLUSIVE (ie they point to the indices AFTER the island). + + This algorithm (Downright Boxing) does not necessarily put separate connected components into + separate boxes. + + You can "cut out" the island-masks with + boxes = mask_to_boxes(mask) + island_masks = [mask[t:b, l:r] for l, t, r, b in boxes] + """ + max_ix = max(s + 1 for s in mask.shape) # Use this to represent background + # These arrays will be used to carry the "box start" indices down and to the right. + x_ixs = np.full(mask.shape, fill_value=max_ix) + y_ixs = np.full(mask.shape, fill_value=max_ix) + + # Propagate the earliest x-index in each segment to the bottom-right corner of the segment + for i in range(mask.shape[0]): + x_fill_ix = max_ix + for j in range(mask.shape[1]): + above_cell_ix = x_ixs[i - 1, j] if i > 0 else max_ix + still_active = mask[i, j] or ((x_fill_ix != max_ix) and (above_cell_ix != max_ix)) + x_fill_ix = min(x_fill_ix, j, above_cell_ix) if still_active else max_ix + x_ixs[i, j] = x_fill_ix + + # Propagate the earliest y-index in each segment to the bottom-right corner of the segment + for j in range(mask.shape[1]): + y_fill_ix = max_ix + for i in range(mask.shape[0]): + left_cell_ix = y_ixs[i, j - 1] if j > 0 else max_ix + still_active = mask[i, j] or ((y_fill_ix != max_ix) and (left_cell_ix != max_ix)) + y_fill_ix = min(y_fill_ix, i, left_cell_ix) if still_active else max_ix + y_ixs[i, j] = y_fill_ix + + # Find the bottom-right corners of each segment + new_xstops = np.diff((x_ixs != max_ix).astype(np.int32), axis=1, append=False) == -1 + new_ystops = np.diff((y_ixs != max_ix).astype(np.int32), axis=0, append=False) == -1 + corner_mask = new_xstops & new_ystops + y_stops, x_stops = np.array(np.nonzero(corner_mask)) + + # Extract the boxes, getting the top-right corners from the index arrays + x_starts = x_ixs[y_stops, x_stops] + y_starts = y_ixs[y_stops, x_stops] + ltrb_boxes = np.hstack([x_starts[:, None], y_starts[:, None], x_stops[:, None] + 1, y_stops[:, None] + 1]) + return ltrb_boxes + + +# +# def mask_to_boxes(mask: MaskImageArray) -> Sequence[BoundingBox]: +# +# mask = np.pad(mask, pad_width=[(0, 1), (0, 1)]) +# +# +# # x_stops = ~padmask[:-1, 1:] & padmask[:-1, :-1] +# # y_stops = ~padmask[1:, :-1] & padmask[:-1, :-1] +# +# x_stop_shifted = np.zeros_like(mask) +# for i in range(mask.shape[0]): +# x_fill_active = False +# for j in range(mask.shape[1]): +# above_cell_is_true = i>0 and x_stop_shifted[i-1, j] +# x_fill_active = mask[i, j] or (x_fill_active and above_cell_is_true) +# x_stop_shifted[i, j] = x_fill_active +# +# y_stop_shifted = np.zeros_like(mask) +# for j in range(mask.shape[1]): +# y_fill_active = False +# for i in range(mask.shape[0]): +# left_cell_is_true = j>0 and x_stop_shifted[i, j-1] +# y_fill_active = mask[i, j] or (y_fill_active and left_cell_is_true) +# y_stop_shifted[i, j] = y_fill_active +# +# +# +# new_xstops = x_stop_shifted[:-1, :-1] & ~x_stop_shifted[:-1, 1:] +# new_ystops = y_stop_shifted[:-1, :-1] & ~y_stop_shifted[1:, :-1] +# corner_mask = new_xstops & new_ystops +# return corner_mask +# # print('Here') +# +# # for i, j in itertools.product(range(mask.shape[0]), range(mask.shape[1])): +# +# +# +# # +# # for i, row in enumerate(x_stops): +# # +# +# +# +# +# bigshape = tuple(s for s in mask.shape) +# xstops = np.zeros(bigshape, dtype=bool) +# ystops = np.zeros(bigshape, dtype=bool) +# xystops = np.zeros(bigshape, dtype=bool) +# +# print('here') +# for i in range(1, mask.shape[0]): +# for j in range(1, mask.shape[1]): +# xstop_above = xstops[i-1, j] +# this_is_an_xstop = (mask[i, j-1] and not mask[i, j]) and not mask[i-1, j] # x-stops propagate down +# xstops[i, j] = xstop_above or this_is_an_xstop +# +# ystop_left = j > 0 and ystops[i, j-1] +# this_is_a_ystop = (i > 0) and (mask[i-1, j] and not mask[i, j]) and ((j==0) or not mask[i, j-1]) +# ystops[i, j] = ystop_left or this_is_a_ystop +# +# if xstops[i, j] and ystops[i, j]: +# xystops[i, j] = True +# xstops[i, j] = ystops[i, j] = False +# +# return xystops diff --git a/artemis/image_processing/test_image_processing_utils.py b/artemis/image_processing/test_image_processing_utils.py new file mode 100644 index 00000000..acbe4453 --- /dev/null +++ b/artemis/image_processing/test_image_processing_utils.py @@ -0,0 +1,37 @@ +from artemis.plotting.easy_window import ImageRow, ImageCol +from artemis.image_processing.image_processing_utils import compute_aloneness_factor +from artemis.image_processing.image_utils import BGRColors, heatmap_to_color_image +from artemis.plotting.cv2_plotting import just_show +from artemis.image_processing.image_builder import ImageBuilder +import numpy as np + +def test_aloneness_factor(): + + sx, sy = 480, 480 + rng = np.random.RandomState(1234) + builder = ImageBuilder.from_blank(size=(sx, sy), color=BGRColors.BLACK) + + builder.draw_circle(center_xy=(100, 100), radius=6, colour=BGRColors.GREEN, thickness=-1) + builder.draw_circle(center_xy=(400, 100), radius=6, colour=BGRColors.GREEN, thickness=-1) + builder.draw_circle(center_xy=(400, 120), radius=6, colour=BGRColors.RED, thickness=-1) + builder.draw_circle(center_xy=(420, 100), radius=6, colour=BGRColors.GREEN, thickness=-1) + builder.draw_line(start_xy=(100, 400), end_xy=(600, 300), color=BGRColors.RED, thickness=4) + + builder.superimpose(rng.randn(sy, sx, 3)**2 * 20) + + img = builder.get_image() + + heatmap = img.mean(axis=2)/255 + + factor = compute_aloneness_factor(heatmap=heatmap, outer_box_width=100, inner_box_width=5, suppression_factor=3, n_iter=5) + # for _ in range(10): + # factor = compute_aloneness_factor(heatmap=factor, outer_box_width=100, inner_box_width=5, suppression_factor=5) + # factor = non_maximal_suppression(heatmap=heatmap, outer_box_width=100, inner_box_width=5, n_iter=2, suppression_factor=100.) + + disp_image = ImageCol(ImageRow(img, heatmap_to_color_image(heatmap, show_range=True)), + ImageRow(heatmap_to_color_image(factor, show_range=True), img*factor[:, :, None])).render() + just_show(disp_image, hang_time=100) + + +if __name__ == '__main__': + test_aloneness_factor() diff --git a/artemis/image_processing/test_image_utils.py b/artemis/image_processing/test_image_utils.py new file mode 100644 index 00000000..b5f597a2 --- /dev/null +++ b/artemis/image_processing/test_image_utils.py @@ -0,0 +1,120 @@ +from eagle_eyes.datasets.videos import DroneVideos +from artemis.image_processing.image_utils import iter_images_from_video, mask_to_boxes, conditional_running_min, slice_image_with_pad +import numpy as np + +from artemis.general.utils_for_testing import stringlist_to_mask + + +def test_iter_images_from_video(): + + video_path = DroneVideos.WALK_DAY_FIRST_SHORT.path + + frames_of_interest = [3, 5, 9] + images_1 = [im for i, im in zip(range(frames_of_interest[-1]+1), iter_images_from_video(path=video_path)) if i in frames_of_interest] + images_2 = list(iter_images_from_video(path=video_path, frames_of_interest=frames_of_interest)) + assert np.array_equal(images_1, images_2) + + time_interval = (3.0, 10.5) + images_1 = [im for i, im in zip(range(frames_of_interest[-1]+1), iter_images_from_video(path=video_path, time_interval=time_interval)) if i in frames_of_interest] + images_2 = list(iter_images_from_video(path=video_path, frames_of_interest=frames_of_interest, time_interval=time_interval)) + assert np.array_equal(images_1, images_2) + + +def test_conditional_running_min(): + vals = np.array([ + [0, 1, 2, 3], + [7, 6, 5, 4], + [8, 9, 10, 11], + [11, 12, 13, 14], + ]) + mask = np.array([ + [0, 1, 1, 0], + [1, 0, 0, 1], + [0, 1, 1, 1], + [0, 0, 0, 0] + ], dtype=bool) + result = conditional_running_min(vals, mask, default=-1, axis=1) + assert np.array_equal(result, [ + [-1, 1, 1, -1], + [7, -1, -1, 4], + [-1, 9, 9, 9], + [-1, -1, -1, -1], + ]) + + + +def test_mask_to_boxes(): + + image = stringlist_to_mask( + " X ", + "XXXX ", + " X ", + " X ", + " " + ) + boxes = mask_to_boxes(image) + assert set(tuple(bb) for bb in boxes) == { + (0, 0, 4, 4), + } + + image = stringlist_to_mask( + " XXX ", + " XX ", + " X X ", + " XXXX ", + " XX " + ) + boxes = mask_to_boxes(image) + assert set(tuple(bb) for bb in boxes) == { + (1, 1, 3, 3), + (7, 0, 10, 1), + (6, 2, 10, 5) + } + + + image = stringlist_to_mask( + " ", + " ", + " ", + " ", + " " + ) + boxes = mask_to_boxes(image) + assert set(tuple(bb) for bb in boxes) == set() + + +def test_slice_image_with_pad(): + vals = np.array([ + [0, 1, 2, 3], + [7, 6, 5, 4], + [8, 9, 10, 11], + [11, 12, 13, 14], + ]) + result = slice_image_with_pad(image=vals, xxyy_box=(-1, 3, -1, 2), gap_color=-1) + assert np.array_equal(result, [ + [-1, -1, -1, -1], + [-1, 0, 1, 2], + [-1, 7, 6, 5], + ]) + result = slice_image_with_pad(image=vals, xxyy_box=(1, 3, 1, 2), gap_color=-1) + assert np.array_equal(result, [ + [6, 5], + ]) + result = slice_image_with_pad(image=vals, xxyy_box=(2, 6, 3, 5), gap_color=-1) + assert np.array_equal(result, [ + [13, 14, -1, -1], + [-1, -1, -1, -1], + ]) + arr = np.random.rand(4, 4, 3) + result = slice_image_with_pad(image=arr, xxyy_box=(2, 6, 3, 5), gap_color=(-1., -1., -1.)) + assert np.array_equal(result[:1, :2], arr[3:, 2:]) + + + +if __name__ == "__main__": + # test_iter_images_from_video() + # test_mask_to_boxes() + # test_conditional_running_min() + test_slice_image_with_pad() + + diff --git a/artemis/image_processing/video_utils.py b/artemis/image_processing/video_utils.py new file mode 100644 index 00000000..68e9ee4e --- /dev/null +++ b/artemis/image_processing/video_utils.py @@ -0,0 +1,69 @@ +from collections import deque +from dataclasses import dataclass +from typing import Iterable, Optional, Callable +from more_itertools import first + +from artemis.general.custom_types import BGRImageArray +from artemis.image_processing.image_utils import fade_image + + +@dataclass +class VideoFader: + intro_fade_frames: int + outro_fade_frames: int + pause_during_fade: bool = True + + def iter_fade_video(self, video: Iterable[BGRImageArray]) -> Iterable[BGRImageArray]: + + if self.pause_during_fade: + iter_in = iter(video) + try: + img = first(iter_in) + except StopIteration: + return + yield from (fade_image(img, i/self.intro_fade_frames) for i in range(self.intro_fade_frames)) + for img in iter_in: + yield img + yield from (fade_image(img, (self.outro_fade_frames-i-1)/self.outro_fade_frames) for i in range(self.outro_fade_frames)) + + else: + + frame_queue = deque() + + for i, img in enumerate(video): + faded_img = fade_image(img, i/self.intro_fade_frames) if iself.outro_fade_frames: + yield frame_queue.popleft() + + for i in range(len(frame_queue)): + img = frame_queue.popleft() + yield fade_image(img, (self.outro_fade_frames-i+1)/self.outro_fade_frames) + + +@dataclass +class LastFrameHanger: + n_steps_to_hang: int + + def iter_hang_last_frame(self, video: Iterable[BGRImageArray]) -> Iterable[BGRImageArray]: + img = None + for img in video: + yield img + if img is not None: + yield from (img for _ in range(self.n_steps_to_hang)) + +@dataclass +class PassThroughVideoCaller: + + side_func: Optional[Callable[[BGRImageArray], None]] + mod_func: Optional[Callable[[BGRImageArray], BGRImageArray]] = (lambda x: x) + + def iter_frames(self, video: Iterable[BGRImageArray]) -> Iterable[BGRImageArray]: + + for img in video: + self.side_func(img) + yield self.mod_func(img) + + + diff --git a/artemis/plotting/cv2_plotting.py b/artemis/plotting/cv2_plotting.py new file mode 100644 index 00000000..c4f74d05 --- /dev/null +++ b/artemis/plotting/cv2_plotting.py @@ -0,0 +1,40 @@ +from typing import Optional, Callable + +import cv2 +from scipy._lib.decorator import contextmanager + +from artemis.general.custom_types import BGRImageArray +from artemis.plotting.cv_keys import Keys, cvkey_to_key +from artemis.image_processing.image_utils import ShowFunction +from artemis.general.utils_utils import get_context_name + + +def cvkey_to_str(keycode: int) -> str: + return chr(keycode) if keycode != -1 else None + + +_ALTERNATE_SHOW_FUNC: Optional[Callable[[BGRImageArray, str], None]] = None + + +@contextmanager +def hold_alternate_show_func(alternate_func: ShowFunction): + global _ALTERNATE_SHOW_FUNC + old = _ALTERNATE_SHOW_FUNC + _ALTERNATE_SHOW_FUNC = alternate_func + try: + yield alternate_func + finally: + _ALTERNATE_SHOW_FUNC = old + + +def just_show(image, name: Optional[str] = None, hang_time: float = 0, upsample_factor: int = 1, enable_alternate: bool = True) -> Optional[Keys]: + if name is None: + name = get_context_name(levels_up=2) + if upsample_factor != 1: + image = cv2.resize(image, dsize=None, fx=upsample_factor, fy=upsample_factor, interpolation=cv2.INTER_NEAREST) + if enable_alternate and _ALTERNATE_SHOW_FUNC is not None: + return _ALTERNATE_SHOW_FUNC(image, name) + else: + cv2.imshow(name, image) + keycode = cv2.waitKey(1 + int(hang_time * 1000) if not hang_time == float('inf') else 1000000000) + return cvkey_to_key(keycode) diff --git a/artemis/plotting/cv_keys.py b/artemis/plotting/cv_keys.py new file mode 100644 index 00000000..e49b5e71 --- /dev/null +++ b/artemis/plotting/cv_keys.py @@ -0,0 +1,162 @@ +class Keys(object): + """ + An enum identifying keys on the keyboard + """ + NONE = None # Code for "no key press" + RETURN = 'RETURN' + SPACE = 'SPACE' + DELETE = 'DELETE' + LSHIFT = 'LSHIFT' + RSHIFT = 'RSHIFT' + TAB = 'TAB' + ESC = "ESC" + RIGHT = 'RIGHT' + LEFT = 'LEFT' + UP = 'UP' + DOWN = 'DOWN' + DASH = 'DASH' + EQUALS = 'EQUALS' + BACKSPACE = 'BACKSPACE' + LBRACE = 'LBRACE' + RBRACE = 'RBRACE' + BACKSLASH = 'BACKSLASH' + SEMICOLON = 'SEMICOLON' + APOSTROPHE = 'APOSTROPHE' + COMMA = 'COMMA' + PERIOD = 'PERIOD' + SLASH = 'SLASH' + A = 'A' + B = 'B' + C = 'C' + D = 'D' + E = 'E' + F = 'F' + G = 'G' + H = 'H' + I = 'I' + J = 'J' + K = 'K' + L = 'L' + M = 'M' + N = 'N' + O = 'O' + P = 'P' + Q = 'Q' + R = 'R' + S = 'S' + T = 'T' + U = 'U' + V = 'V' + W = 'W' + X = 'X' + Y = 'Y' + Z = 'Z' + n0 = '0' + n1 = '1' + n2 = '2' + n3 = '3' + n4 = '4' + n5 = '5' + n6 = '6' + n7 = '7' + n8 = '8' + n9 = '9' + np0 = '0' + np1 = '1' + np2 = '2' + np3 = '3' + np4 = '4' + np5 = '5' + np6 = '6' + np7 = '7' + np8 = '8' + np9 = '9' + UNKNOWN = 'UNKNOWN' + + +_keydict = { + # On a MAC these are the key codes + -1: Keys.NONE, # -1 indicates "no key press" + 27: Keys.ESC, + 13: Keys.RETURN, + 32: Keys.SPACE, + 255: Keys.DELETE, + 225: Keys.LSHIFT, + 226: Keys.RSHIFT, + 9: Keys.TAB, + 81: Keys.LEFT, + 82: Keys.UP, + 83: Keys.RIGHT, + 84: Keys.DOWN, + 45: Keys.DASH, + 61: Keys.EQUALS, + 8: Keys.BACKSPACE, + 91: Keys.LBRACE, + 93: Keys.RBRACE, + 92: Keys.BACKSLASH, + 59: Keys.SEMICOLON, + 39: Keys.APOSTROPHE, + 44: Keys.COMMA, + 46: Keys.PERIOD, + 47: Keys.SLASH, + 63: Keys.SLASH, # On on thinkpad at least + 97: Keys.A, + 98: Keys.B, + 99: Keys.C, + 100: Keys.D, + 101: Keys.E, + 102: Keys.F, + 103: Keys.G, + 104: Keys.H, + 105: Keys.I, + 106: Keys.J, + 107: Keys.K, + 108: Keys.L, + 109: Keys.M, + 110: Keys.N, + 111: Keys.O, + 112: Keys.P, + 113: Keys.Q, + 114: Keys.R, + 115: Keys.S, + 116: Keys.T, + 117: Keys.U, + 118: Keys.V, + 119: Keys.W, + 120: Keys.X, + 121: Keys.Y, + 122: Keys.Z, + 48: Keys.n0, + 49: Keys.n1, + 50: Keys.n2, + 51: Keys.n3, + 52: Keys.n4, + 53: Keys.n5, + 54: Keys.n6, + 55: Keys.n7, + 56: Keys.n8, + 57: Keys.n9, + 158: Keys.n0, + 156: Keys.np1, + 153: Keys.np2, + 155: Keys.np3, + 150: Keys.np4, + 157: Keys.np5, + 152: Keys.np6, + 149: Keys.np7, + 151: Keys.np8, + 154: Keys.np9, +} + + +def cvkey_to_key(cvkeycode): + """ + Given a cv2 keycode, return the key, which will be a member of the Keys enum. + :param cvkeycode: The code returned by cv2.waitKey + :return: A string, one of the members of Keys + """ + key = _keydict.get(cvkeycode & 0xFF if cvkeycode > 0 else cvkeycode, + Keys.UNKNOWN) # On Mac, keys return codes like 1113938. Masking with 0xFF limits it to 0-255. + if key == Keys.UNKNOWN: + print("Unknown cv2 Key Code: {}".format(cvkeycode)) + return key diff --git a/artemis/plotting/easy_window.py b/artemis/plotting/easy_window.py new file mode 100644 index 00000000..af2da895 --- /dev/null +++ b/artemis/plotting/easy_window.py @@ -0,0 +1,561 @@ +from abc import ABCMeta, abstractmethod +from collections import OrderedDict +from contextlib import contextmanager +from dataclasses import dataclass, field +from math import ceil +from typing import Union, Hashable, Dict, Tuple, List, Set, Optional, Sequence, Iterable, ContextManager, Callable + +import cv2 +import numpy as np +import rpack +from attr import attrib, attrs +from rpack import PackingImpossibleError + +from artemis.general.custom_types import BGRColorTuple, BGRImageArray +from artemis.plotting.cv_keys import Keys, cvkey_to_key +from artemis.image_processing.image_utils import BGRColors, DEFAULT_GAP_COLOR, create_gap_image, normalize_to_bgr_image, TextDisplayer +from artemis.plotting.cv2_plotting import hold_alternate_show_func + +DEFAULT_WINDOW_NAME = 'Window' + + +class InputTimeoutError(Exception): + """ Raised if you have not received user input within the timeout """ + pass + + +def cv_window_input( # Get text input from a cv2 window. + prompt: str, # A text prompt + window_size: Optional[Tuple[int, int]] = None, # Optionally, window size (otherwise it will just expand to fit) + timeout=30, # Timeout for user input (raise InputTimeoutError if no response in this time) + return_none_if_timeout=True, # Just return None if timeout + text_color=BGRColors.WHITE, # Text color + background_color=BGRColors.DARK_GRAY, # Background color + window_name='User Input (Enter to complete, Exc to Cancel)' # Name of CV2 windot +) -> Optional[str]: # The Response, or None if you press ESC + + displayer = TextDisplayer(text_color=text_color, background_color=background_color, size=window_size) + next_cap = False + character_keys = {Keys.SPACE: ' ', Keys.PERIOD: '.>', Keys.COMMA: ',<', Keys.SEMICOLON: ';:', Keys.SLASH: '/?', + Keys.DASH: '-=', Keys.EQUALS: '=+'} + response = '' + while True: + img = displayer.render('{}\n >> {}'.format(prompt, response)) + cv2.imshow(window_name, img) + key = cvkey_to_key(cv2.waitKey(int(timeout * 1000))) + if key is None: + if return_none_if_timeout: + return None + else: + raise InputTimeoutError("User provided no input for {:.2f}s".format(timeout)) + elif key == Keys.RETURN: + cv2.destroyWindow(window_name) + return response + elif key == Keys.ESC: + cv2.destroyWindow(window_name) + return None + elif len(key) == 1: + response += key.upper() if next_cap else key.lower() + next_cap = False + elif key in character_keys: + base_key, shift_key = character_keys[key] + response += shift_key if next_cap else base_key + next_cap = False + elif key == Keys.PERIOD: + response += '.' + elif key == Keys.PERIOD: + response += '.' + elif key == Keys.BACKSPACE: + response = response[:-1] + elif key in (Keys.LSHIFT, Keys.RSHIFT): + next_cap = True + else: + print("Don't know how to handle key '{}'. Skipping.".format(key)) + + +# def put_text_at(img, text, pos=(0, -1), scale=1, color=(0, 0, 0), shadow_color: Optional[Tuple[int, int, int]] = None, background_color: Optional[Tuple[int, int, int]] = None, thickness=1, font=cv2.FONT_HERSHEY_PLAIN, +# dry_run=False): +# """ +# Add text to an image +# :param img: add to this image +# :param text: add this text +# :param pos: (x, y) location of text to add, pixel values, point indicates bottom-left corner of text. +# :param scale: size of text to add +# :param color: (r,g,b) uint8 +# :param thickness: for adding text +# :param font: font constant from cv2 +# :param dry_run: don't add to text, just calculate size required to add text +# :return: dict with 'x': [x_min, x_max], 'y': [y_min, y_max], 'baseline': location of text baseline relative to y_max +# """ +# (w, h), baseline = cv2.getTextSize(text, font, scale, thickness) +# y_pos = pos[1] + baseline if pos[1] >= 0 else img.shape[0] + pos[1] + baseline +# x_pos = pos[0] if pos[0] >= 0 else img.shape[1] + pos[0] +# box = {'y': [y_pos - h, y_pos], +# 'x': [x_pos, x_pos + w], +# 'baseline': baseline} +# +# if background_color is not None: +# pad = 4 +# img[max(0, pos[1]-h-pad): pos[1]+pad, max(0, pos[0]-pad): pos[0]+w+pad] = background_color +# if not dry_run: +# if shadow_color is not None: +# cv2.putText(img, text, (x_pos, y_pos-h//2), font, scale, shadow_color, thickness + 2, bottomLeftOrigin=False) +# cv2.putText(img, text, (x_pos, y_pos-h//2), font, scale, color, thickness, bottomLeftOrigin=False) +# return box + + +def resize_to_fit_in_box(img: BGRImageArray, size: Union[int, Tuple[int, int]], expand=True, shrink=True, + interpolation=cv2.INTER_LINEAR): + """ + Resize an image to fit in a box + :param img: Imageeeee + :param size: Box (width, height) in pixels + :param expand: Expand to fit + :param shrink: Shrink to fit + :param interpolation: cv2 Interpolation enum, e.g. cv2.INTERP_NEAREST + :return: The resized images + """ + assert img.size > 0, f"Got an image of shape {img.shape} - which contains a zero dimension" + + if isinstance(size, (int, float)): + size = (size, size) + ratio = min(float(size[0]) / img.shape[1] if size[0] is not None else float('inf'), + float(size[1]) / img.shape[0] if size[1] is not None else float('inf')) + if (shrink and ratio < 1) or (expand and ratio > 1): + img = cv2.resize(img, fx=ratio, fy=ratio, dsize=None, interpolation=interpolation) + return img + + +def put_text_in_corner(img: BGRImageArray, text: str, color: BGRColorTuple, shadow_color: Optional[BGRColorTuple] = None, + background_color: Optional[BGRColorTuple] = None, corner ='tl', scale=1, thickness=1, font=cv2.FONT_HERSHEY_PLAIN, ): + """ Put text in the corner of the image""" + # TODO: Convert to use put_text_at + assert corner in ('tl', 'tr', 'bl', 'br') + cv, ch = corner + (twidth, theight), baseline = cv2.getTextSize(text, font, scale, thickness) + position = ({'l': 0, 'r': img.shape[1]-twidth}[ch], {'t': theight+baseline, 'b': img.shape[0]}[cv]) + if background_color is not None: + pad = 4 + img[max(0, position[1]-theight-pad): position[1]+pad, max(0, position[0]-pad): position[0]+twidth+pad] = background_color + if shadow_color is not None: + cv2.putText(img=img, text=text, org=position, fontFace=font, fontScale=scale, color=shadow_color, thickness=thickness + 2, bottomLeftOrigin=False) + cv2.putText(img=img, text=text, org=position, fontFace=font, fontScale=scale, color=color, thickness=thickness, bottomLeftOrigin=False) + + + +def put_text_at( + img: BGRImageArray, + text: str, + color: BGRColorTuple, + position_xy: Tuple[int, int] = None, + anchor_xy: Tuple[float, float] = (0., 0.), # Position of anchor relative to width/height of text area + shadow_color: Optional[BGRColorTuple] = None, + background_color: Optional[BGRColorTuple] = None, + scale=1, + thickness=1, + font=cv2.FONT_HERSHEY_PLAIN, + ): + (twidth, theight), baseline = cv2.getTextSize(text, font, scale, thickness) + + px, py = position_xy + if px < 0: + px = img.shape[1]+px + if py < 0: + py = img.shape[0]+py + ax, ay = anchor_xy + px = round(px - ax*twidth) + py = round(py - ay*theight) + + if background_color is not None: + pad = 4 + img[max(0, py-pad): py+theight+pad, max(0, px-pad): px+twidth+pad] = background_color + if shadow_color is not None: + cv2.putText(img=img, text=text, org=(px, py), fontFace=font, fontScale=scale, color=shadow_color, thickness=thickness + 2, bottomLeftOrigin=False) + cv2.putText(img=img, text=text, org=(px, py), fontFace=font, fontScale=scale, color=color, thickness=thickness, bottomLeftOrigin=False) + + +def draw_image_to_region_inplace( # Assign an image to a region in a parent image + parent_image, # type: array(WP,HP,...)[uint8] # The parent image into which to draw inplace + img, # type: array(W,H,...)[uint8] # The image to draw in the given region + xxyy_region=None, + # type: Optional[Tuple[int, int, int, int]] # The (left, right, bottom, top) edges (right/top-non-inclusive) to drawbottom, + expand=True, # True to expand image to fill region + gap_colour=None, # type: Optional[Tuple[int, int, int]] # BGR fill colour + fill_gap=True # True to fill in the gaps at the edges with fill colour +): + if gap_colour is None: + gap_colour = DEFAULT_GAP_COLOR + + x1, x2, y1, y2 = xxyy_region if xxyy_region is not None else (0, parent_image.shape[1], 0, parent_image.shape[0]) + width, height = x2 - x1, y2 - y1 + resized = resize_to_fit_in_box(img, size=(width, height), expand=expand, shrink=True) + xs, ys = (x1 + (width - resized.shape[1]) // 2), (y1 + (height - resized.shape[0]) // 2) + if fill_gap: + parent_image[y1:y2, x1:x2] = gap_colour + if resized.ndim == 2: + resized = resized[:, :, None] + parent_image[ys:ys + resized.shape[0], xs:xs + resized.shape[1]] = resized + + +def draw_multiple_images_inplace( # Draw multiple images into a parent image + parent_image, # type: 'array(HP,WP,3)[uint8]' # The image in which to draw + image_dict, # type: Dict[str, 'array(H,W,3)[uint8]'] # The images to insert + xxyy_dict, + # type: Dict[str, Tuple[int, int, int, int]] # The bounding boxes (referenced by the same keys as image_dict) + float_norm='max', # type: str # How to map floats to color range. See to_uint8_color_image + expand=True, # True to expand image to fit bounding box + gap_colour=None, # type: Optional[Tuple[int, int, int]] # The Default BGR color to fill the gaps +): + for name, img in image_dict.items(): + assert name in xxyy_dict, "There was no bounding box for image named '{}'".format(name) + draw_image_to_region_inplace(parent_image=parent_image, img=img, xxyy_region=xxyy_dict[name], expand=expand, + gap_colour=gap_colour, fill_gap=False) + + +@attrs +class WindowLayout(object): + """ Object defining the location of named boxes within a window. """ + panel_xxyy_boxes = attrib(type=Dict[str, Tuple[int, int, int, int]]) + size = attrib(type=Tuple[int, int], default=None) + + def __attrs_post_init__(self): + if self.size is None: + self.size = (max(x for _, x, _, _ in self.panel_xxyy_boxes.values()), + max(y for _, _, _, y in self.panel_xxyy_boxes.values())) + + def render(self, # Given a dictionary of images, render it into a single image + image_dict, # type: Dict[str, 'array(H,W,3)[uint8'] + gap_color=None, # type: Optional[Tuple[int, int, int]] # The Default BGR color to fill the gaps + float_norm='max' # If images are passed as floats, how to normalize them (see to_uint8_color_image) + ): # type: (...) -> 'array(H,W,3)[uint8]' # The rendered image + + parent_frame = create_gap_image(self.size, gap_colour=gap_color) + draw_multiple_images_inplace(parent_image=parent_frame, image_dict=image_dict, xxyy_dict=self.panel_xxyy_boxes, + float_norm=float_norm, expand=False) + return parent_frame + + +class IBoxPacker(metaclass=ABCMeta): + + @abstractmethod + def pack_boxes(self, # Pack images into a resulting images + box_size_dict: Dict[str, Tuple[int, int]], + ) -> WindowLayout: + """ Pack boxes into the layout """ + + +def rpack_from_aspect_ratio(sizes: Sequence[Tuple[int, int]], aspect_ratio = 1., max_iter=8) -> Sequence[Tuple[int, int]]: + """ + Find box corners of the smallest packing solution that fits in the given aspect ratio. + This is approximate, and tries up to n_iter iterations. + """ + upper_bound_width = max(sum(w for w, _ in sizes), round(sum(h*aspect_ratio for _, h in sizes))) + lower_bound_width = max(max(w for w, _ in sizes), round(max(h*aspect_ratio for _, h in sizes))) + result = None + width = upper_bound_width + for i in range(max_iter): + height = int(width / aspect_ratio) + try: + print(f"Trying packing with {width}x{height}") + result = rpack.pack(sizes, max_width=width, max_height=height) + except PackingImpossibleError: + lower_bound_width = width + else: + upper_bound_width = width + width = int(lower_bound_width + upper_bound_width)//2 + assert result is not None, f"Should have been able to pack with width {upper_bound_width}" + return result + + +class OptimalBoxPacker(IBoxPacker): + + aspect_ratio = 4/3 + + def pack_boxes(self, # Pack images into a resulting images + box_size_dict: Dict[str, Tuple[int, int]], + ) -> WindowLayout: + try: + import rpack + except ImportError: + raise ImportError(f"If you want to use {self.__class__.__name__}, you need to 'pip install rectangle-packer'. ") + + # corners = rpack_from_aspect_ratio(sizes=) + corners = rpack_from_aspect_ratio(sizes=list(box_size_dict.values()), aspect_ratio=self.aspect_ratio) + # corners = rpack.pack(sizes=list(box_size_dict.values())) + return WindowLayout({name: (x, x+w, y, y+h) for (name, (w, h)), (x, y) in zip(box_size_dict.items(), corners)}) + + +class RowColumnPacker: + """ Use to pack boxes by defining nested rows and columns (use Row/Col subclasses for convenience) + + packer = Row(Col(Row('panel_1', 'panel_2'), + 'panel_3'), + 'panel_4') + layout = packer.pack_boxes({'panel_1': (300, 400), 'panel_2': (200, 200), 'panel_3': (600, 300), 'panel_4': (700, 700)}) + + """ + + OTHER = None # Used to indicate that + + def __init__(self, *args: Union['RowColumnPacker', str, None], orientation='h', wrap: Optional[int] = None): + """ + :param args: The panels in this object. This can either be a nested Row/Col/RowColumnPacker object, or a string panel name, or RowColumnPacker.OTHER to indicate + that this panel takes any unclaimed windows. + :param orientation: How to stack them: 'h' for horizontal or 'v' for vertical + """ + assert orientation in ('h', 'v') + assert all(isinstance(obj, Hashable) for obj in args), f"Unhashable elements in args: {args}" + self.orientation = orientation + self.items = args + self.wrap = wrap + + def pack_boxes(self, # Pack images into a resulting images + box_size_dict: Dict[str, Tuple[int, int]], + ) -> WindowLayout: + + if self.wrap is not None: + major_orientation, minor_orientation = 'hv'.replace(self.orientation, ''), self.orientation + items = [RowColumnPacker(*self.items[i * self.wrap: (i + 1) * self.wrap], orientation=self.orientation) for i in range(ceil(len(box_size_dict) / self.wrap))] + this_orientation = major_orientation + else: + items = self.items + this_orientation = self.orientation + + new_contents_dict = {} + + # We reorder so that the window with the "OTHER" box is packed last (after all images with panels have been assigned) + reordered_items = sorted(items, key=lambda item_: isinstance(item_, + RowColumnPacker) and RowColumnPacker.OTHER in item_.get_all_items()) + + # Get the layout for each sub-widow + window_layouts: List[WindowLayout] = [] + remaining_boxes = box_size_dict + for item in reordered_items: + if isinstance(item, RowColumnPacker): + window_layouts.append(item.pack_boxes(remaining_boxes)) + elif item in box_size_dict: + window_layouts.append(WindowLayout(size=box_size_dict[item], panel_xxyy_boxes={ + item: (0, box_size_dict[item][0], 0, box_size_dict[item][1])})) + elif item is RowColumnPacker.OTHER: + child_frame = RowColumnPacker(*remaining_boxes.keys(), orientation=this_orientation) + window_layouts.append(child_frame.pack_boxes(remaining_boxes)) + else: # This panel is not included in the data, which is ok, we just skip. + continue + remaining_boxes = {name: box for name, box in remaining_boxes.items() if + name not in window_layouts[-1].panel_xxyy_boxes} + + # Combine them into a single window layout + if this_orientation == 'h': + total_height = max([0] + [layout.size[1] for layout in window_layouts]) + total_width = 0 + for layout in window_layouts: + width, height = layout.size + v_offset = (total_height - height) // 2 + for name, (xmin, xmax, ymin, ymax) in layout.panel_xxyy_boxes.items(): + new_contents_dict[name] = (xmin + total_width, xmax + total_width, ymin + v_offset, ymax + v_offset) + total_width += width + else: + total_width = max([0] + [layout.size[0] for layout in window_layouts]) + total_height = 0 + for layout in window_layouts: + width, height = layout.size + h_offset = (total_width - width) // 2 + for name, (xmin, xmax, ymin, ymax) in layout.panel_xxyy_boxes.items(): + new_contents_dict[name] = [xmin + h_offset, xmax + h_offset, ymin + total_height, + ymax + total_height] + total_height += height + + return WindowLayout(panel_xxyy_boxes=new_contents_dict, size=(total_width, total_height)) + + def get_all_items(self) -> Set[str]: + return {name for item in self.items for name in + (item.get_all_items() if isinstance(item, RowColumnPacker) else [ + item])} # pylint:disable=superfluous-parens + + def concat(self, *objects): + return RowColumnPacker(*([self.items] + list(objects)), orientation=self.orientation) + + def __len__(self): + return len(self.items) + + +class Row(RowColumnPacker): + """ A row of panels """ + + def __init__(self, *args): + RowColumnPacker.__init__(self, *args, orientation='h') + + +class Col(RowColumnPacker): + """ A column of panels """ + + def __init__(self, *args): + RowColumnPacker.__init__(self, *args, orientation='v') + + +class ImagePacker: + + def __init__(self, *images, orientation: str, gap_color: BGRColorTuple = DEFAULT_GAP_COLOR, wrap: Optional[int] = None, **named_images): + self.named_images = OrderedDict([(str(i), im) for i, im in enumerate(images)] + [(name, im) for name, im in named_images.items()]) + self.window = EasyWindow(RowColumnPacker(*self.named_images.keys(), orientation=orientation, wrap=wrap), + gap_color=gap_color, skip_title_for={str(i) for i in range(len(images))} + ) + + def render(self) -> BGRImageArray: + for name, entity in self.named_images.items(): + image = entity.render() if isinstance(entity, ImagePacker) else entity + self.window.update(image=image, name=name) + return self.window.render() + + +class ImageRow(ImagePacker): + + def __init__(self, *args, gap_color: BGRColorTuple = DEFAULT_GAP_COLOR, wrap: Optional[int] = None, **named_image): + ImagePacker.__init__(self, *args, orientation='h', gap_color=gap_color, wrap=wrap, **named_image) + + +class ImageCol(ImagePacker): + + def __init__(self, *args, gap_color: BGRColorTuple = DEFAULT_GAP_COLOR, wrap: Optional[int] = None, **named_image): + ImagePacker.__init__(self, *args, orientation='v', gap_color=gap_color, wrap=wrap, **named_image) + + +def get_raster_box_packing(sizes: Sequence[Tuple[int, int]], box: Tuple[int, int], truncate_if_full: bool = False) -> Optional[Sequence[Tuple[int, int, int, int]]]: + """ Find the locations into which to pack windows of the given sizes, returning a (l, t, r, b) box for each, or None if no space. """ + assignment_slices = [] + bx, by = box + sx, sy = 0, 0 + current_row_height = 0 + while len(assignment_slices) < len(sizes): + ix, iy = sizes[len(assignment_slices)] + ex, ey = sx + ix, sy + iy # First, try the next available slot + current_row_height = max(current_row_height, iy) + if ex <= bx: # It fits horizontally... + if ey <= by: # It fits vertically... + assignment_slices.append((sx, sy, ex, ey)) + sx = ex + current_row_height = max(current_row_height, iy) + else: + return assignment_slices if truncate_if_full else None + # No more space + else: # Move to new row + sx, sy = 0, sy + current_row_height + current_row_height = 0 + if sy > by: + return assignment_slices if truncate_if_full else None + return assignment_slices + + +def pack_images_into_box(images: Iterable[BGRImageArray], box: Tuple[int, int], gap_colour=DEFAULT_GAP_COLOR, overflow_handling: str = 'resize') -> BGRImageArray: + images = list(images) + while True: + sizes = [(img.shape[1], img.shape[0]) for img in images] + boxes = get_raster_box_packing(sizes, box, truncate_if_full=overflow_handling == 'truncate') + if boxes is not None: + break + else: + images = [cv2.resize(im, dsize=None, fx=0.5, fy=0.5) for im in images] + base_image = create_gap_image(box, gap_colour=gap_colour) + for (l, b, r, t), im in zip(boxes, images): + base_image[b:t, l:r] = im + return base_image + + +@attrs +class EasyWindow(object): + """ Contains multiple updating subplots """ + box_packer = attrib(type=IBoxPacker) + identifier = attrib(default=DEFAULT_WINDOW_NAME) + panel_scales = attrib(factory=dict, type=Dict[Optional[str], float]) # e.g. {'panel_name': 2.} + skip_title_for = attrib(factory=set, type=Set[Optional[str]]) # e.g. {'panel_name'} + images = attrib(factory=OrderedDict) + gap_color = attrib(default=DEFAULT_GAP_COLOR) + title_background = attrib(default=np.array([50, 50, 50], dtype=np.uint8)) + _last_size_and_layout = attrib(type=Optional[Tuple[Dict[str, Tuple[int, int]], WindowLayout]], default=None) + + ALL_SUBPLOTS = None # Flag that can be used in panel_scales and skip_title_for to indicate that all subplots should have this property + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + def update(self, # Update the window and maybe show it. + image: BGRImageArray, + # The data to plot (if display not provided, we try to infer appropriate plot type from data) + name: str, # The name of the "plot panel" to plot it in + scale: Optional[float] = None, # Optionally, how much to scale the image + skip_none: bool = False, # If data is None, just skip the update + add_title: Optional[bool] = None, # Add a totle matching the name, + title: Optional[str] = None, # Optional title to put at top instead of name + ): + if image is None: + if name in self.images: + del self.images[name] + return + image = normalize_to_bgr_image(image) + + + # Allow panel settings to be set + add_title = add_title if add_title is not None else ( + name not in self.skip_title_for and EasyWindow.ALL_SUBPLOTS not in self.skip_title_for) + scale = scale if scale is not None else self.panel_scales[ + name] if name in self.panel_scales else self.panel_scales.get(EasyWindow.ALL_SUBPLOTS, None) + if scale is not None: + image = cv2.resize(image, dsize=None, fx=scale, fy=scale, interpolation=cv2.INTER_NEAREST) + if add_title: + title_sec = create_gap_image((image.shape[1], 30), gap_colour=self.title_background) + put_text_at(img=title_sec, position_xy=(0, 15), text=title or name, color=(255, 255, 255)) + image = np.vstack([title_sec, image]) + if image is None and skip_none: + return + self.images[name] = image + + def render(self) -> BGRImageArray: + """ Render the image into an array """ + sizes = OrderedDict((name, (im.shape[1], im.shape[0])) for name, im in self.images.items()) + if self._last_size_and_layout is not None and self._last_size_and_layout[0] == sizes: + _, window_layout = self._last_size_and_layout + else: + window_layout = self.box_packer.pack_boxes(sizes) # type: WindowLayout + self._last_size_and_layout = (sizes, window_layout) + return window_layout.render(image_dict=self.images, gap_color=self.gap_color) + + def close(self): # Close this plot window + try: + cv2.destroyWindow(self.identifier) + except cv2.error: + pass # It's ok, it's already gone + print("/\\ Ignore above error, it's fine") # Because cv2 still prints it + + +@dataclass +class JustShowCapturer: + window: EasyWindow = field(default_factory=lambda: EasyWindow(box_packer=OptimalBoxPacker())) + callback: Optional[Callable[[BGRImageArray], None]] = None + _any_images_added: bool = False + + def on_just_show(self, image: BGRImageArray, name: str): + self.window.update(image, name) + self._any_images_added = True + if self.callback is not None: + self.callback(self.window.render()) + + def render_captured_images(self) -> Optional[BGRImageArray]: + if self._any_images_added: + return self.window.render() + else: + return None + + @contextmanager + def hold_capture(self) -> ContextManager[Callable[[], Optional[BGRImageArray]]]: + with hold_alternate_show_func(self.on_just_show): + yield self.render_captured_images + + +@contextmanager +def hold_just_show_capture(wrap_rows: int = 2) -> ContextManager[Callable[[], BGRImageArray]]: + with JustShowCapturer().hold_capture() as captured_image_getter: + yield captured_image_getter + # yield from JustShowCapturer.from_row_wrap(wrap_rows).hold_capture() diff --git a/artemis/plotting/matplotlib_plotting.py b/artemis/plotting/matplotlib_plotting.py new file mode 100644 index 00000000..c52fd398 --- /dev/null +++ b/artemis/plotting/matplotlib_plotting.py @@ -0,0 +1,10 @@ +import numpy as np +from matplotlib import pyplot as plt + +from artemis.general.custom_types import BGRImageArray + + +def fig_to_bgr_array(fig: plt.Figure) -> BGRImageArray: + renderer = fig.canvas.renderer + arr = np.fromstring(renderer.tostring_rgb(), dtype=np.uint8).reshape(int(renderer.height), int(renderer.width), 3)[:, :, ::-1] + return arr diff --git a/artemis/plotting/scrollable_image_stream.py b/artemis/plotting/scrollable_image_stream.py new file mode 100644 index 00000000..2f53acf7 --- /dev/null +++ b/artemis/plotting/scrollable_image_stream.py @@ -0,0 +1,146 @@ +import time +from typing import Iterable, Optional + +import cv2 + +from artemis.general.custom_types import BGRImageArray +from artemis.plotting.cv_keys import Keys +from artemis.plotting.easy_window import DEFAULT_WINDOW_NAME, cv_window_input +from artemis.image_processing.image_builder import ImageBuilder +from artemis.image_processing.image_utils import BGRColors, ShowFunction, BoundingBox +from artemis.plotting.cv2_plotting import just_show +from artemis.general.sequence_buffer import SequenceBuffer + + +def show_scrollable_image_sequence( + image_iterator: Iterable[BGRImageArray], + show_func: ShowFunction = just_show, + max_buffer_size: Optional[int] = None, + pause_debounce_time: float = 1., + max_memory_size: Optional[int] = 1000000000, + initially_pause: bool = False, + max_fps: Optional[float] = None, + expand_to_full_size = True, # Expand window to full size of image. + min_key_wait_time: float = 0.000, + window_name: str = DEFAULT_WINDOW_NAME, + upsample_factor: int = 1, + add_index_string: bool = True, + enable_zoom: bool = True, + zoom_window_size = 100, + zoom_scale_factor = 5, + index_text_color = BGRColors.WHITE, + copy_if_modifying: bool = False, # Since we write text to the image. If you are using it elsewhere, set this to true. +): + image_iterator = iter(image_iterator) # Just in case it's passed in as a list + image_buffer: SequenceBuffer[BGRImageArray] = SequenceBuffer(max_elements=max_buffer_size, max_memory=max_memory_size) + lookup_index = 0 + is_paused = initially_pause + + # if add_index_string: + # original_image_iterator = image_iterator + # def iter_labelled_images(): + # for i, img in enumerate(original_image_iterator): + # if copy_if_modifying: + # img = img.copy() + # put_text_in_corner(img=img, text=f'#{i}', color=index_text_color, background_color=BGRColors.BLACK) + # yield img + # image_iterator = iter_labelled_images() + + last_frame_time = -float('inf') + period = 1/max_fps if max_fps is not None else 0. + + # for _ in iter_max_rate(max_fps): + + # cv2.namedWindow(window_name, flags=cv2.WINDOW_NORMAL) + cv2.namedWindow(window_name) + + last_pause_resume_time = -float('inf') + last_shape = None + + zoom_position = latest_mouse_position = (0, 0) + + if enable_zoom: + def click_callback(event, x, y, flags, param): + nonlocal latest_mouse_position + if toggle_zoom_overlay: + latest_mouse_position = (x, y) + + cv2.setMouseCallback(window_name, click_callback) + + def show_image(img): + return just_show(img, hang_time=float('inf') if is_paused else max(min_key_wait_time, last_frame_time + period - current_time), name=window_name, + upsample_factor=upsample_factor, enable_alternate=False) + + toggle_zoom_overlay = False + while True: + + actual_index, image = image_buffer.lookup(lookup_index, jump_to_edge=True, new_data_source=image_iterator) + + if actual_index == lookup_index-1 and not is_paused: + print(f'Reached final image at frame {actual_index}. Pausing.') + is_paused = True + + if add_index_string: + # image = image.copy() + title_text = f'{actual_index} ({"paused" if is_paused else "playing"}) {"(end)" if actual_indexlookup_index else ""}' + # put_text_in_corner(img=image, text=f'{"#" if is_paused else ">"}{actual_index} {"(end)" if actual_indexlookup_index else ""}', color=index_text_color) + cv2.setWindowTitle(window_name, title_text) + + print(f'Showing image at actual index {actual_index}') + current_time = time.monotonic() + if expand_to_full_size: + # if expand_to_full_size and (image.shape[:2] != last_shape): + cv2.resizeWindow(window_name, width=image.shape[1], height=image.shape[0]) + # last_shape = image.shape[:2] + if toggle_zoom_overlay: + box = BoundingBox.from_xywh(*zoom_position, zoom_window_size, zoom_window_size) + image_with_inset = ImageBuilder.from_image(image).draw_zoom_inset_from_box(box, scale_factor=zoom_scale_factor).image + key = show_image(image_with_inset) + else: + key = show_image(image) + + last_frame_time = current_time + if key == Keys.P: + if current_time - last_pause_resume_time > pause_debounce_time: + # Debouncing is meant to solve the problem of delayed keystrokes causing you to constantly pause and resume. + is_paused = not is_paused + last_pause_resume_time = current_time + print('Pausing...' if is_paused else 'Resuming...') + elif key==Keys.COMMA: + lookup_index = actual_index - 1 + elif key == Keys.PERIOD: + lookup_index = actual_index + 1 + elif key == Keys.SEMICOLON: + lookup_index = actual_index - 10 + elif key == Keys.APOSTROPHE: + lookup_index = actual_index + 10 + elif key == Keys.LBRACE: + lookup_index, _ = image_buffer.get_index_bounds() + elif key == Keys.RBRACE: + _, lookup_index = image_buffer.get_index_bounds() + elif key == Keys.G: + frame = cv_window_input(prompt='Enter frame to go to...') + try: + lookup_index = int(frame.strip()) + print(f"Going to frame {lookup_index}... If is available that is...") + except ValueError: + print(f'Could not parse "{frame}". You need to enter an integer.') + elif key == Keys.Z and enable_zoom: + zoom_position = latest_mouse_position + toggle_zoom_overlay = True + elif key == Keys.X: + toggle_zoom_overlay = False + elif key == Keys.SPACE: + pass + elif key == Keys.ESC: + return + else: + if key is not None: + print(f'Unknown key {key}. Advancing frame') + lookup_index += 1 + lookup_index = max(0, lookup_index) + + + + + diff --git a/artemis/plotting/test_easy_window.py b/artemis/plotting/test_easy_window.py new file mode 100644 index 00000000..8a478c76 --- /dev/null +++ b/artemis/plotting/test_easy_window.py @@ -0,0 +1,15 @@ +from artemis.plotting.easy_window import rpack_from_aspect_ratio + + +def test_pack_with_aspect_ratio(): + corners = rpack_from_aspect_ratio(sizes = [(6, 6), (5, 5), (4, 4)], aspect_ratio=1.) + assert corners == [(0, 0), (6, 0), (6, 5)] + corners = rpack_from_aspect_ratio(sizes = [(6, 6), (5, 5), (4, 4)], aspect_ratio=10) + assert corners == [(0, 0), (6, 0), (11, 0)] + corners = rpack_from_aspect_ratio(sizes = [(6, 6), (5, 5), (4, 4)], aspect_ratio=0.1) + assert corners == [(0, 0), (0, 6), (0, 11)] + + +if __name__ == "__main__": + test_pack_with_aspect_ratio() + diff --git a/artemis/plotting/test_scrollable_image_stream.py b/artemis/plotting/test_scrollable_image_stream.py new file mode 100644 index 00000000..1278617c --- /dev/null +++ b/artemis/plotting/test_scrollable_image_stream.py @@ -0,0 +1,60 @@ +from typing import Iterator + +from artemis.general.custom_types import BGRImageArray +from artemis.plotting.easy_window import hold_just_show_capture, JustShowCapturer +from artemis.image_processing.image_utils import heatmap_to_color_image +from artemis.plotting.cv2_plotting import just_show +from artemis.plotting.scrollable_image_stream import show_scrollable_image_sequence +import numpy as np + + +def iter_diagonal_wave_images(size_xy = (640, 480)): + sx, sy = size_xy + xs, ys = np.meshgrid(np.linspace(0, 10, sx), np.linspace(0, 10, sy)) + for i in range(200): + yield heatmap_to_color_image(np.sin(xs + ys + i / 20) ** 2) + + +def mock_show_func(image: BGRImageArray, name: str) -> None: + print(f"Got command to show image with shape {image.shape} under name {name}") + + +def test_show_scrollable_image_sequence(): + + show_scrollable_image_sequence( + image_iterator=iter_diagonal_wave_images(), + max_buffer_size=20, + add_index_string=True, + initially_pause=False + ) + + +def test_image_show_thing(show=False): + + with hold_just_show_capture() as render_func: + + just_show(heatmap_to_color_image(np.random.randn(100, 200)**2), 'random') + just_show(next(iter_diagonal_wave_images()), 'waves') + + img = render_func() + if show: + just_show(img, hang_time=10) + + +def test_image_show_with_buffer(): + def iter_images() -> Iterator[BGRImageArray]: + cap = JustShowCapturer() + for i, wave_img in enumerate(iter_diagonal_wave_images()): + with cap.hold_capture() as render_func: + if i % 10 == 5: + just_show(heatmap_to_color_image(np.random.randn(100, 200) ** 2), 'random') + just_show(wave_img, 'waves') + yield render_func() + + show_scrollable_image_sequence(iter_images(), ) + + +if __name__ == '__main__': + # test_launch_scrollable_image_buffer() + test_image_show_thing(show=True) + # test_image_show_with_buffer() \ No newline at end of file diff --git a/artemis/plotting/test_threaded_show.py b/artemis/plotting/test_threaded_show.py new file mode 100644 index 00000000..310ede5d --- /dev/null +++ b/artemis/plotting/test_threaded_show.py @@ -0,0 +1,16 @@ +import time + +from artemis.plotting.cv2_plotting import just_show +from artemis.plotting.threaded_show import hold_just_show_in_thread +import numpy as np + + +def test_threaded_show(): + with hold_just_show_in_thread(): + for i in range(100): + just_show(np.random.uniform(0, 255, size=(200, 300)).astype(np.uint8)) + time.sleep(0.5) + + +if __name__ == "__main__": + test_threaded_show() diff --git a/artemis/plotting/threaded_show.py b/artemis/plotting/threaded_show.py new file mode 100644 index 00000000..0126d6fb --- /dev/null +++ b/artemis/plotting/threaded_show.py @@ -0,0 +1,54 @@ +import itertools +from contextlib import contextmanager +from functools import partial +from queue import Queue +from typing import Iterator + +from tensorflow.python.distribute.multi_process_lib import multiprocessing + +from artemis.general.custom_types import BGRImageArray +from artemis.plotting.cv2_plotting import hold_alternate_show_func +from artemis.plotting.scrollable_image_stream import show_scrollable_image_sequence + + +def iter_images_from_queue(queue: Queue) -> Iterator[BGRImageArray]: + for i in itertools.count(0): + print(f"Getting froma {i} from queue") + yield queue.get(block=True) + + +def launch_plotting_thread(queue: Queue, initially_pause=False): + show_scrollable_image_sequence(iter_images_from_queue(queue), initially_pause=initially_pause) + + +@contextmanager +def hold_just_show_in_thread(initially_pause = False): + queue = multiprocessing.Queue(maxsize=1) + + def new_just_show(image: BGRImageArray, name: str): + queue.put(image) + + # + # with JustShowCapturer( + # callback=lambda im: queue.put(im) + # ).hold_capture() as image_getter: +# + with hold_alternate_show_func(new_just_show): + thread = multiprocessing.Process(target=partial(launch_plotting_thread, queue=queue, initially_pause=initially_pause)) + thread.start() + yield + thread.join() + thread.close() + # cap = JustShowCapturer.from_row_wrap(wrap_rows).hold_capture() + + # + # def show_func() + # + # with hold_alternate_show_func(lambda: ) + # JustShowCapturer.from_row_wrap(wrap_rows).hold_capture() + # + # Thread.start() + # + # + # yield from JustShowCapturer.from_row_wrap(wrap_rows).hold_capture() + From 0aee5f6dd068424ec1e367cb3242347be4b69710 Mon Sep 17 00:00:00 2001 From: peter Date: Sat, 7 Jan 2023 19:07:56 -0800 Subject: [PATCH 043/107] ok push --- artemis/plotting/scrollable_image_stream.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/artemis/plotting/scrollable_image_stream.py b/artemis/plotting/scrollable_image_stream.py index 2f53acf7..a2cbea40 100644 --- a/artemis/plotting/scrollable_image_stream.py +++ b/artemis/plotting/scrollable_image_stream.py @@ -139,8 +139,3 @@ def show_image(img): print(f'Unknown key {key}. Advancing frame') lookup_index += 1 lookup_index = max(0, lookup_index) - - - - - From a54efc9db1a3037b84975b802b0cd72e464a043b Mon Sep 17 00:00:00 2001 From: peter Date: Sat, 7 Jan 2023 19:38:26 -0800 Subject: [PATCH 044/107] video_reader --- artemis/config.py | 4 + artemis/image_processing/video_reader.py | 206 ++++++++++++++++++++++ artemis/image_processing/video_segment.py | 137 ++++++++++++++ 3 files changed, 347 insertions(+) create mode 100644 artemis/image_processing/video_reader.py create mode 100644 artemis/image_processing/video_segment.py diff --git a/artemis/config.py b/artemis/config.py index 1443173f..f30a480b 100644 --- a/artemis/config.py +++ b/artemis/config.py @@ -1,3 +1,4 @@ +import logging import os from artemis.fileman.config_files import get_config_path, get_config_value @@ -15,6 +16,9 @@ _CONFIG_FILE_NAME = '.artemisrc' +ARTEMIS_LOGGER = logging.getLogger('artemis') + + def check_or_create_artemis_config(): config_path = get_config_path(_CONFIG_FILE_NAME) if not os.path.exists(config_path): diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py new file mode 100644 index 00000000..416d6c5c --- /dev/null +++ b/artemis/image_processing/video_reader.py @@ -0,0 +1,206 @@ +import itertools +import os +from dataclasses import dataclass +from typing import Tuple, Optional, Iterator +import av + +from artemis.general.custom_types import BGRImageArray, TimeIntervalTuple +from artemis.image_processing.image_utils import fit_image_to_max_size +from artemis.general.item_cache import CacheDict + + +@dataclass +class VideoFrameInfo: + image: BGRImageArray + seconds_into_video: float + frame_ix: int + fps: float + + def get_size_xy(self) -> Tuple[int, int]: + return self.image.shape[1], self.image.shape[0] + + def get_progress_string(self) -> str: + return f"t={self.seconds_into_video:.2f}s, frame={self.frame_ix}" + + +def get_actual_frame_interval( + n_frames_total: int, + fps: float, + time_interval: TimeIntervalTuple = (None, None), + frame_interval: Tuple[Optional[int], Optional[int]] = (None, None), +) -> Tuple[int, int]: + assert time_interval == (None, None) or frame_interval == ( + None, None), "You can provide a time interval or frame inteval, not both" + if time_interval != (None, None): + tstart, tstop = time_interval + return get_actual_frame_interval(n_frames_total=n_frames_total, fps=fps, + frame_interval=(round(tstart * fps), round(tstop * fps))) + + # return self.time_to_nearest_frame(tstart) if tstart is not None else 0, self.time_to_nearest_frame( + # tstop) + 1 if tstop is not None else self.get_n_frames() + elif frame_interval != (None, None): + istart, istop = frame_interval + istart = 0 if istart is None else n_frames_total + istart if istart < 0 else istart + istop = n_frames_total if istop is None else n_frames_total + istop if istop < 0 else istop + return istart, istop + else: + return 0, n_frames_total + + +@dataclass +class VideoReader: + """ + The reader efficiently provides access to video frames. + It uses pyav: https://pyav.org/docs/stable/ + + Usage: + reader = VideoReader(path=video_segment.path, use_cache=use_cache) + # Iterate in order + for frame in reader.iter_frames(time_interval=(1, 2)): + cv2.imshow('frame', frame.image) + cv2.waitKey(1) + # Request individually + frame = reader.request_frame(20) # Ask for the 20th frame + cv2.imshow('frame', frame.image) + cv2.waitKey(1) + + Implementation: + - Providing them in order should be FAST + - Requesting the same frame twice or backtracking a few frames should be VERY FAST (ie - use a cache) + - Requesting random frames should be reasonably fast (do not scan from start) + + Note: Due to bug in OpenCV with GET_PROP_POS_FRAMES + https://github.com/opencv/opencv/issues/9053 + We use "av": conda install av -c conda-forge + """ + + def __init__(self, + path: str, + time_interval: TimeIntervalTuple = (None, None), + frame_interval: Tuple[Optional[int], Optional[int]] = (None, None), + buffer_size_bytes=1024 ** 3, + threshold_frames_to_scan=30, + max_size_xy: Optional[Tuple[int, int]] = None, + use_cache: bool = True): + + self._path = os.path.expanduser(path) + assert os.path.exists(self._path), f"Cannot find a video at {path}" + + self.container = av.container.open(self._path) + # self.stream = self.container.streams.video[0] + + # self._cap = cv2.VideoCapture(path) + self._frame_cache: CacheDict[int, VideoFrameInfo] = CacheDict(buffer_size_bytes=buffer_size_bytes, + always_allow_one_item=True) + self._next_index_to_be_read: int = 0 + self._threshold_frames_to_scan = threshold_frames_to_scan + # self._fps = self._cap.get(cv2.CAP_PROP_FPS) + self._fps = float(self.container.streams.video[0].guessed_rate) + + self._n_frames = self.container.streams.video[0].frames + # self._cached_last_frame: Optional[VideoFrameInfo] = None # Helps fix weird bug... see notes below + self._max_size_xy = max_size_xy + self._use_cache = use_cache + self._iterator = self._iter_frame_data() + self._start, self._stop = get_actual_frame_interval(time_interval=time_interval, + fps=self._fps, + frame_interval=frame_interval, + n_frames_total=self.container.streams.video[0].frames) + # self._n_frames = + + def get_n_frames(self) -> int: + return self._stop - self._start + + def time_to_nearest_frame(self, t: float) -> int: + return max(0, min(self.get_n_frames() - 1, round(t * self._fps))) + + def frame_index_to_nearest_frame(self, index: int) -> int: + return max(0, min(self.get_n_frames() - 1, index)) + + def iter_frame_ixs(self, time_interval: TimeIntervalTuple = (None, None), + frame_interval: Tuple[Optional[int], Optional[int]] = (None, None) + ) -> Iterator[int]: + start, stop = get_actual_frame_interval(fps=self._fps, time_interval=time_interval, + frame_interval=frame_interval, + n_frames_total=self.get_n_frames()) + """ + TODO: Get rid of arguments - just use cut instead + """ + return range(start, stop) + + def iter_frames(self, + time_interval: TimeIntervalTuple = (None, None), + frame_interval: Tuple[Optional[int], Optional[int]] = (None, None) + ) -> Iterator[VideoFrameInfo]: + """ + TODO: Get rid of arguments - just use cut instead + """ + for i in self.iter_frame_ixs(time_interval=time_interval, frame_interval=frame_interval): + yield self.request_frame(i) + + def cut(self, time_interval: TimeIntervalTuple = (None, None), + frame_interval: Tuple[Optional[int], Optional[int]] = (None, None)) -> 'VideoReader': + fstart, fstop = get_actual_frame_interval(time_interval=time_interval, frame_interval=frame_interval, + n_frames_total=self.get_n_frames(), fps=self._fps) + newstart = self._start + fstart + return VideoReader(path=self._path, frame_interval=(newstart, newstart + (fstop - fstart)), + threshold_frames_to_scan=self._threshold_frames_to_scan, + buffer_size_bytes=self._frame_cache.buffer_size_bytes) + + def _iter_frame_data(self): + for frame in self.container.decode(self.container.streams.video[0]): + yield frame + + def request_frame(self, index: int) -> VideoFrameInfo: + """ + Request a frame of the video. If the requested frame is out of bounds, this will return the frame + on the closest edge. + """ + if index < 0: + index = self.get_n_frames() + index + index = max(0, min(self.get_n_frames() - 1, index)) + index_in_file = index + self._start + + # if index == self.get_n_frames() - 1 and self._cached_last_frame is not None: + # return self._cached_last_frame # There's a weird bug preventing us from loading the last frame again + if index_in_file in self._frame_cache: + return self._frame_cache[index_in_file] + elif 0 <= index_in_file - self._next_index_to_be_read < self._threshold_frames_to_scan: + frame = None + for _ in range(self._next_index_to_be_read, index_in_file + 1): + try: + frame_data = next(self._iterator) + except StopIteration: + raise Exception( + f"Could not get frame at index {index_in_file}, despite n_frames being {self.get_n_frames()}") + + image = frame_data.to_rgb().to_ndarray(format='bgr24') + + if self._max_size_xy is not None: + image = fit_image_to_max_size(image, self._max_size_xy) + frame = VideoFrameInfo( + image=image, + seconds_into_video=self._next_index_to_be_read / self._fps, + frame_ix=self._next_index_to_be_read, + fps=self._fps + ) + if self._use_cache: + self._frame_cache[frame.frame_ix] = frame + self._next_index_to_be_read += 1 + + assert frame is not None, f"Error loading video frame at index {index_in_file}" + return frame + else: + max_seek_search = 100 + stream = self.container.streams.video[0] + pts = int(index_in_file * stream.duration / stream.frames) + self.container.seek(pts, stream=stream) + self._iterator = self._iter_frame_data() + for j, f in enumerate(self._iterator): + if j > max_seek_search: + raise RuntimeError(f'Did not find target within {max_seek_search} frames of seek') + if f.pts >= pts - 1: + self._iterator = itertools.chain([f], self._iterator) + break + self._next_index_to_be_read = index_in_file + return self.request_frame(index) diff --git a/artemis/image_processing/video_segment.py b/artemis/image_processing/video_segment.py new file mode 100644 index 00000000..519af08c --- /dev/null +++ b/artemis/image_processing/video_segment.py @@ -0,0 +1,137 @@ +import os +from dataclasses import dataclass, replace +from typing import Optional, Tuple, Sequence, Iterator + +import cv2 + +from artemis.general.custom_types import TimeIntervalTuple, BGRImageArray +from artemis.image_processing.image_utils import iter_images_from_video, fit_image_to_max_size +from artemis.image_processing.video_reader import VideoReader, VideoFrameInfo +from artemis.remote.utils import ARTEMIS_LOGGER + + +def parse_time_delta_str_to_sec(time_delta_str: str) -> Optional[float]: + if time_delta_str in ('start', 'end'): + return None + else: + start_splits = time_delta_str.split(':') + if len(start_splits) == 1: + return float(time_delta_str) + elif len(start_splits) == 2: + return 60 * float(start_splits[0]) + float(start_splits[1]) + elif len(start_splits) == 3: + return 3600*float(start_splits[0]) + 60*float(start_splits[1]) + float(start_splits[2]) + else: + raise Exception(f"Bad format: {time_delta_str}") + + +def parse_interval(interval_str: str) -> TimeIntervalTuple: + + start, end = (s.strip('') for s in interval_str.split('-')) + return parse_time_delta_str_to_sec(start), parse_time_delta_str_to_sec(end) + + +@dataclass +class VideoSegment: + """ """ + path: str + time_interval: TimeIntervalTuple = None, None + frame_interval: Tuple[Optional[int], Optional[int]] = (None, None) + rotation: int = 0 # Number of Clockwise 90deg rotations to apply to the raw video + keep_ratio: float = 1. + use_scan_selection: bool = False + max_size: Optional[Tuple[int, int]] = None + frames_of_interest: Optional[Sequence[int]] = None + verbose: bool = False + + def check_passthrough(self): + assert os.path.exists(os.path.expanduser(self.path)), f"Path {self.path} does not exist." + return self + + def iter_images(self, max_size: Optional[Tuple[int, int]] = None, max_count: Optional[int] = None) -> Iterator[BGRImageArray]: + yield from iter_images_from_video(self.path, time_interval=self.time_interval, max_size=max_size or self.max_size, rotation=self.rotation, frame_interval=(None, max_count)) + + def get_reader(self, buffer_size_bytes: int = 1024**3, use_cache: bool = True) -> VideoReader: + return VideoReader(self.path, time_interval=self.time_interval, frame_interval=self.frame_interval, + buffer_size_bytes=buffer_size_bytes, use_cache=use_cache, max_size_xy=self.max_size) + + def recut(self, start_time: Optional[float] = None, end_time: Optional[float] = None): + return replace(self, time_interval=(start_time, end_time), frame_interval=self.frame_interval) + + def iter_frame_info(self) -> Iterator[VideoFrameInfo]: + """ + TODO: Replace with + yield from self.get_reader().iter_frames() + """ + + assert not self.use_scan_selection, "This does not work. See bug: https://github.com/opencv/opencv/issues/9053" + path = os.path.expanduser(self.path) + cap = cv2.VideoCapture(path) + start_frame, stop_frame = self.frame_interval + start_time, end_time = self.time_interval + if self.max_size is not None: # Set cap size. Sometimes this does not work so we also have the code below. + sx, sy = self.max_size if self.rotation in (0, 2) else self.max_size[::-1] + cap.set(cv2.CAP_PROP_FRAME_WIDTH, sx) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, sy) + + if start_time is not None: + cap.set(cv2.CAP_PROP_POS_MSEC, start_time * 1000.) + + if start_frame is not None: + cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame) + frame_ix = start_frame + else: + frame_ix = 0 + + fps = cap.get(cv2.CAP_PROP_FPS) + + unique_frame_ix = -1 + + iter_frames_of_interest = iter(self.frames_of_interest) if self.frames_of_interest is not None else None + + initial_frame = int(cap.get(cv2.CAP_PROP_POS_FRAMES)) + + while cap.isOpened(): + + if iter_frames_of_interest is not None and self.use_scan_selection: + try: + next_frame = initial_frame + next(iter_frames_of_interest) + (1 if initial_frame == 0 else 0) # Don't know why it just works + except StopIteration: + break + cap.set(cv2.CAP_PROP_POS_FRAMES, next_frame) + + if stop_frame is not None and frame_ix >= stop_frame: + break + elif end_time is not None and frame_ix / fps > end_time - (start_time or 0.): + break + + unique_frame_ix += 1 + + isgood, image = cap.read() + + if not isgood: + print(f'Reach end of video at {path}') + break + if self.max_size is not None: + image = fit_image_to_max_size(image, self.max_size) + if self.keep_ratio != 1: + if self.verbose: + print(f'Real surplus: {frame_ix - self.keep_ratio * unique_frame_ix}') + frame_surplus = round(frame_ix - self.keep_ratio * unique_frame_ix) + if frame_surplus < 0: # Frame debt - yield this one twice + if self.verbose: + print('Yielding extra frame due to frame debt') + yield VideoFrameInfo(image, seconds_into_video=(initial_frame + unique_frame_ix) / fps, frame_ix=initial_frame + unique_frame_ix, fps=fps) + frame_ix += 1 + elif frame_surplus > 0: # Frame surplus - skip it + if self.verbose: + print('Skipping frame due to frame surplus') + continue + + if iter_frames_of_interest is None or self.use_scan_selection or (not self.use_scan_selection and frame_ix in self.frames_of_interest): + if self.rotation != 0: + image = cv2.rotate(image, rotateCode={1: cv2.ROTATE_90_CLOCKWISE, 2: cv2.ROTATE_180, 3: cv2.ROTATE_90_COUNTERCLOCKWISE}[self.rotation]) + else: + image = image + yield VideoFrameInfo(image, seconds_into_video=(initial_frame + unique_frame_ix) / fps, frame_ix=initial_frame + unique_frame_ix, fps=fps) + frame_ix += 1 From 023d59a481de9103fda1e7741fde59ed106cfec8 Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 10 Jan 2023 12:07:56 -0800 Subject: [PATCH 045/107] some updates while making video scanner --- artemis/general/item_cache.py | 2 ++ artemis/general/sequence_buffer.py | 13 +++++-- artemis/general/test_utils_utils.py | 12 +++++-- artemis/general/utils_utils.py | 14 ++++++++ artemis/image_processing/video_reader.py | 41 +++++++++++++++++++++-- artemis/image_processing/video_segment.py | 17 ++++++++-- setup.py | 2 +- 7 files changed, 90 insertions(+), 11 deletions(-) diff --git a/artemis/general/item_cache.py b/artemis/general/item_cache.py index 7c5d4a78..19a0f7af 100644 --- a/artemis/general/item_cache.py +++ b/artemis/general/item_cache.py @@ -43,8 +43,10 @@ def __setitem__(self, key: Hashable, value: ItemType) -> None: if self._buffer_length is not None and len(self._buffer) == self._buffer_length: self._remove_oldest_item() + print(f'Buffer lendgth: {len(self._buffer)} Buffer size: {self._current_buffer_size}') if self.buffer_size_bytes is not None: size = get_memory_footprint(value) if not self._calculate_size_once or self._first_object_size is None else self._first_object_size + print(f'Memory footprint for {value.__class__} is {size} bytes') while len(self._buffer)>0 and self._current_buffer_size+size > self.buffer_size_bytes: self._remove_oldest_item() diff --git a/artemis/general/sequence_buffer.py b/artemis/general/sequence_buffer.py index ec704919..b986fac5 100644 --- a/artemis/general/sequence_buffer.py +++ b/artemis/general/sequence_buffer.py @@ -1,7 +1,8 @@ +import dataclasses import sys # from _typeshed import SupportsNext from collections import deque -from dataclasses import dataclass, field +from dataclasses import dataclass, field, is_dataclass from typing import Optional, TypeVar, Generic, Deque, Tuple, Any, Iterator import numpy as np ItemType = TypeVar('ItemType') @@ -12,9 +13,15 @@ class OutOfBufferException(Exception): def get_memory_footprint(item: Any) -> int: - # TODO: Recurse through dataclasses - if isinstance(item, np.ndarray): + + if is_dataclass(item): + return sum(get_memory_footprint(v) for v in dataclasses.asdict(item).values()) + elif isinstance(item, np.ndarray): return item.itemsize * item.size + elif isinstance(item, (list, tuple, set)): + return sum(get_memory_footprint(v) for v in item) + elif isinstance(item, dict): + return sum(get_memory_footprint(v) for v in item.values()) else: return sys.getsizeof(item) # Only returns pointer-size, no recursion diff --git a/artemis/general/test_utils_utils.py b/artemis/general/test_utils_utils.py index b2ef28dd..8c2522b7 100644 --- a/artemis/general/test_utils_utils.py +++ b/artemis/general/test_utils_utils.py @@ -1,7 +1,7 @@ import itertools from pytest import raises -from artemis.general.utils_utils import tee_and_specialize_iterator +from artemis.general.utils_utils import tee_and_specialize_iterator, bytes_to_string def test_tee_and_specialize_iterator(): @@ -20,5 +20,13 @@ def test_tee_and_specialize_iterator(): assert items == [(1, 2, 3), (2, 3, 4), (3, 4, 5)] +def test_bytes_to_string(): + + assert bytes_to_string(2, decimals_precision=1)=='2.0 B' + assert bytes_to_string(2000, decimals_precision=1)=='2.0 kB' + assert bytes_to_string(2500000, decimals_precision=1)=='2.4 MB' + + if __name__ == '__main__': - test_tee_and_specialize_iterator() + # test_tee_and_specialize_iterator() + test_bytes_to_string() \ No newline at end of file diff --git a/artemis/general/utils_utils.py b/artemis/general/utils_utils.py index 79acf7b2..c103f64e 100644 --- a/artemis/general/utils_utils.py +++ b/artemis/general/utils_utils.py @@ -86,5 +86,19 @@ def make_sub_iterator(it_copy, arg): return [make_sub_iterator(it_copy, arg) for it_copy, arg in zip(itertools.tee(iterator, len(args)), args)] +def bytes_to_string(bytes: int, decimals_precision: 1) -> str: + + size = bytes + prefix = '' + for this_prefix in 'kMGPE': + if size > 1024: + prefix = this_prefix + size = size / 1024 + else: + break + + return f"{{:.{decimals_precision}f}} {prefix}B".format(size) + + if __name__ == '__main__': demo_get_context_name() diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index 416d6c5c..7868b4a4 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -1,3 +1,4 @@ +import datetime import itertools import os from dataclasses import dataclass @@ -5,6 +6,7 @@ import av from artemis.general.custom_types import BGRImageArray, TimeIntervalTuple +from artemis.general.utils_utils import bytes_to_string from artemis.image_processing.image_utils import fit_image_to_max_size from artemis.general.item_cache import CacheDict @@ -19,9 +21,28 @@ class VideoFrameInfo: def get_size_xy(self) -> Tuple[int, int]: return self.image.shape[1], self.image.shape[0] - def get_progress_string(self) -> str: - return f"t={self.seconds_into_video:.2f}s, frame={self.frame_ix}" + def get_progress_string(self, total_frames: Optional[int] = None) -> str: + if total_frames is None: + return f"t={self.seconds_into_video:.2f}s, frame={self.frame_ix}" + else: + return f"t={self.seconds_into_video:.2f}s/{total_frames/self.fps:.2f}s, frame={self.frame_ix}/{total_frames}" + +@dataclass +class VideoMetaData: + duration: float + n_frames: int + fps: float + n_bytes: int + size_xy: Tuple[int, int] + def get_duration_string(self) -> str: + return str(datetime.timedelta(seconds=int(self.duration))) + + def get_size_xy_string(self) -> str: + return f"{self.size_xy[0]}x{self.size_xy[1]}" + + def get_size_string(self) -> str: + return bytes_to_string(self.n_bytes, decimals_precision=1) def get_actual_frame_interval( n_frames_total: int, @@ -65,7 +86,7 @@ class VideoReader: cv2.waitKey(1) Implementation: - - Providing them in order should be FAST + - Providing them in order should be fast - Requesting the same frame twice or backtracking a few frames should be VERY FAST (ie - use a cache) - Requesting random frames should be reasonably fast (do not scan from start) @@ -106,8 +127,22 @@ def __init__(self, fps=self._fps, frame_interval=frame_interval, n_frames_total=self.container.streams.video[0].frames) + self._metadata: Optional[VideoMetaData] = None # self._n_frames = + def get_metadata(self) -> VideoMetaData: + file_stats = os.stat(self._path) + if self._metadata is None: + firstframe = self.request_frame(0) + self._metadata = VideoMetaData( + duration=self._n_frames/self._fps, + n_frames=self._n_frames, + fps=self._fps, + n_bytes=file_stats.st_size, + size_xy=(firstframe.image.shape[1], firstframe.image.shape[0]) + ) + return self._metadata + def get_n_frames(self) -> int: return self._stop - self._start diff --git a/artemis/image_processing/video_segment.py b/artemis/image_processing/video_segment.py index 519af08c..18ca12d9 100644 --- a/artemis/image_processing/video_segment.py +++ b/artemis/image_processing/video_segment.py @@ -6,8 +6,7 @@ from artemis.general.custom_types import TimeIntervalTuple, BGRImageArray from artemis.image_processing.image_utils import iter_images_from_video, fit_image_to_max_size -from artemis.image_processing.video_reader import VideoReader, VideoFrameInfo -from artemis.remote.utils import ARTEMIS_LOGGER +from artemis.image_processing.video_reader import VideoReader, VideoFrameInfo, VideoMetaData def parse_time_delta_str_to_sec(time_delta_str: str) -> Optional[float]: @@ -31,6 +30,10 @@ def parse_interval(interval_str: str) -> TimeIntervalTuple: return parse_time_delta_str_to_sec(start), parse_time_delta_str_to_sec(end) + + + + @dataclass class VideoSegment: """ """ @@ -44,6 +47,16 @@ class VideoSegment: frames_of_interest: Optional[Sequence[int]] = None verbose: bool = False + _metadata: Optional[VideoMetaData] = None + + def get_metadata(self) -> VideoMetaData: + if self._metadata is None: + self._metadata = self.get_reader().get_metadata() + return self._metadata + + def get_n_frames(self) -> int: + return self.get_reader().get_n_frames() + def check_passthrough(self): assert os.path.exists(os.path.expanduser(self.path)), f"Path {self.path} does not exist." return self diff --git a/setup.py b/setup.py index 3ec1ec98..baa0a707 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ author_email='poconn4@gmail.com', url='https://github.com/quva-lab/artemis', long_description='Artemis aims to get rid of all the boring, bureaucratic coding (plotting, file management, etc) involved in machine learning projects, so you can get to the good stuff quickly.', - install_requires=['numpy', 'scipy', 'matplotlib', 'pytest', 'pillow', 'tabulate', 'si-prefix', 'enum34'], + install_requires=['numpy', 'scipy', 'matplotlib', 'pytest', 'pillow', 'tabulate', 'si-prefix', 'rectangle-packer'], extras_require = { 'remote_plotting': ["paramiko", "netifaces"] }, From bf26f87bf45db6befeaf7e75d16e3f3e61c17927 Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 10 Jan 2023 12:33:18 -0800 Subject: [PATCH 046/107] premerge --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 3ec1ec98..85ae10b9 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ author_email='poconn4@gmail.com', url='https://github.com/quva-lab/artemis', long_description='Artemis aims to get rid of all the boring, bureaucratic coding (plotting, file management, etc) involved in machine learning projects, so you can get to the good stuff quickly.', - install_requires=['numpy', 'scipy', 'matplotlib', 'pytest', 'pillow', 'tabulate', 'si-prefix', 'enum34'], + install_requires=['numpy', 'scipy', 'matplotlib', 'pytest', 'pillow', 'tabulate', 'si-prefix'], extras_require = { 'remote_plotting': ["paramiko", "netifaces"] }, From 405737f26cb843cc60ddb62bfc69b31cb337da82 Mon Sep 17 00:00:00 2001 From: peter Date: Sun, 29 Jan 2023 12:22:33 -0800 Subject: [PATCH 047/107] got the image frame thing working --- artemis/general/custom_types.py | 1 + artemis/general/geometry.py | 59 ++++++ artemis/general/hashing.py | 26 ++- artemis/general/item_cache.py | 4 +- artemis/general/measuring_periods.py | 21 +++ artemis/general/should_be_builtins.py | 12 ++ artemis/general/test_geometry.py | 15 ++ artemis/general/utils_for_testing.py | 36 +++- artemis/image_processing/artemis.jpeg | Bin 0 -> 259477 bytes artemis/image_processing/image_builder.py | 19 +- artemis/image_processing/image_utils.py | 178 ++++++++++++++++++- artemis/image_processing/test_image_utils.py | 50 +++++- artemis/image_processing/video_reader.py | 2 +- artemis/plotting/data_conversion.py | 38 +++- artemis/plotting/easy_window.py | 6 +- artemis/plotting/gui_helpers.py | 4 +- artemis/plotting/image_mosaic.py | 40 +++++ artemis/plotting/matplotlib_backend.py | 4 +- artemis/plotting/test_image_mosaic.py | 25 +++ 19 files changed, 503 insertions(+), 37 deletions(-) create mode 100644 artemis/general/geometry.py create mode 100644 artemis/general/test_geometry.py create mode 100644 artemis/image_processing/artemis.jpeg create mode 100644 artemis/plotting/image_mosaic.py create mode 100644 artemis/plotting/test_image_mosaic.py diff --git a/artemis/general/custom_types.py b/artemis/general/custom_types.py index a7bc98de..98d7ecc2 100644 --- a/artemis/general/custom_types.py +++ b/artemis/general/custom_types.py @@ -23,6 +23,7 @@ def transform_image(image: Array['H,W,3', np.uint8], ...): GeneralImageArray = np.ndarray # Can be (H, W, C) or (H, W), uint8 or float or int or whatever BGRImageArray = np.ndarray RGBImageArray = np.ndarray +IndexImageArray = np.ndarray # A (H, W) array of integer indices FlatBGRImageArray = np.ndarray GreyScaleImageArray = np.ndarray BGRFloatImageArray = np.ndarray # A BGR image containing floating point data (expected to be in range 0-255, but possibly outside) diff --git a/artemis/general/geometry.py b/artemis/general/geometry.py new file mode 100644 index 00000000..1a636bbc --- /dev/null +++ b/artemis/general/geometry.py @@ -0,0 +1,59 @@ +from typing import Tuple + +import numpy as np + +from artemis.general.custom_types import Array + + +def reframe_from_a_to_b( + xy_in_a: Array['...,2', float], + reference_xy_in_b: Tuple[float, float], + reference_xy_in_a: Tuple[float, float] = (0., 0.), + scale_in_a_of_b: float = 1. +) -> Array['...,2', float]: + """ Convert a set of (x,y) coordinates in one reference frame to another. + + :param xy_in_a: (N,2) array of (x,y) coordinates in the reference frame A + :param reference_xy_in_b: (x,y) coordinates of the origin of reference frame B in reference frame A + :param reference_xy_in_a: (x,y) coordinates of the origin of reference frame A in reference frame A + :param scale_in_a_of_b: Scale of reference frame B in reference frame A + :return: (N,2) array of (x,y) coordinates in the reference frame B + """ + xy_in_a = np.asarray(xy_in_a) + assert xy_in_a.shape[-1] == 2 + xy_in_b = (xy_in_a - reference_xy_in_a) * scale_in_a_of_b + reference_xy_in_b + return xy_in_b + + +def reframe_from_b_to_a( + xy_in_b: Array['...,2', float], + reference_xy_in_a: Tuple[float, float], + reference_xy_in_b: Tuple[float, float] = (0., 0.), + scale_in_a_of_b: float = 1. +) -> Array['...,2', float]: + """ Convert a set of (x,y) coordinates in one reference frame to another. + + :param xy_in_b: (N,2) array of (x,y) coordinates in the reference frame B + :param reference_xy_in_a: (x,y) coordinates of the origin of reference frame A in reference frame B + :param reference_xy_in_b: (x,y) coordinates of the origin of reference frame B in reference frame B + :param scale_in_a_of_b: Scale of reference frame B in reference frame A + :return: (N,2) array of (x,y) coordinates in the reference frame A + """ + xy_in_b = np.asarray(xy_in_b) + assert xy_in_b.shape[-1] == 2 + xy_in_a = (xy_in_b - reference_xy_in_b) / scale_in_a_of_b + reference_xy_in_a + return xy_in_a + + +def clip_points_to_limit(points: Array['N,2', float], limit_xxyy: Tuple[float, float, float, float]) -> Array['N,2', float]: + """ + Clip points to a rectangular region. + + :param points: (N,2) array of (x,y) coordinates + :param limit_xxyy: (x_min, x_max, y_min, y_max) + :return: (N,2) array of (x,y) coordinates + """ + points = np.asarray(points) + assert points.shape[1] == 2 + x_min, x_max, y_min, y_max = limit_xxyy + return np.clip(points, (x_min, y_min), (x_max, y_max)) diff --git a/artemis/general/hashing.py b/artemis/general/hashing.py index 7b53b119..bc0663f5 100644 --- a/artemis/general/hashing.py +++ b/artemis/general/hashing.py @@ -1,7 +1,10 @@ +import base64 import hashlib import pickle from collections import OrderedDict import itertools +from enum import Enum + import numpy as np from six import string_types, next @@ -22,12 +25,20 @@ def fixed_hash_eq(obj1, obj2): return compute_fixed_hash(obj1)==compute_fixed_hash(obj2) -def compute_fixed_hash(obj, try_objects=False, _hasher = None, _memo = None, _count=None): +class HashRep(Enum): + HEX = 'hex' + BASE_32 = 'base32' + BASE_64 = 'base64' + + +def compute_fixed_hash(obj, try_objects=False, use_only_public_fields: bool = False, hashrep: HashRep = HashRep.BASE_32, _hasher = None, _memo = None, _count=None): """ Given an object, return a hash that will always be the same (not just for the lifetime of the object, but for all future runs of the program too). :param obj: Some nested container of primitives :param try_objects: Try to break into objects + :param use_only_public_fields: When breaking into objects, only use fields that do not begin with underscore + :param hashrep: How to represent the hash string :param _hasher: (for internal use - note that this is stateful, so calling this function with this argument changes the hasher object) :param _memo: (for internal use - to remember hashed objects and avoid infinite recursion) @@ -49,7 +60,7 @@ def compute_fixed_hash(obj, try_objects=False, _hasher = None, _memo = None, _co if _hasher is None: _hasher = hashlib.md5() - kwargs = dict(_hasher=_hasher, try_objects=try_objects, _memo=_memo, _count=_count) + kwargs = dict(_hasher=_hasher, try_objects=try_objects, use_only_public_fields=use_only_public_fields, _memo=_memo, _count=_count) _hasher.update(obj.__class__.__name__.encode('utf-8')) if isinstance(obj, np.ndarray): @@ -74,7 +85,7 @@ def compute_fixed_hash(obj, try_objects=False, _hasher = None, _memo = None, _co elif hasattr(obj, 'memo_hashable'): # Deprecated, just here for back-compatibility compute_fixed_hash(obj.memo_hashable(), **kwargs) elif try_objects: - keys = sorted(obj.__dict__.keys()) + keys = sorted(k for k in obj.__dict__.keys() if not use_only_public_fields or not k.startswith('_')) for k in keys: compute_fixed_hash(k, **kwargs) compute_fixed_hash(obj.__dict__[k], **kwargs) @@ -84,7 +95,14 @@ def compute_fixed_hash(obj, try_objects=False, _hasher = None, _memo = None, _co raise NotImplementedError("Don't have a method for hashing this %s" % (obj, )) _hasher.update(_END_CODE) # Necessary to distinguish ([a, b], c) from ([a, b, c]) - result = _hasher.hexdigest() + if hashrep == HashRep.HEX: + result = _hasher.hexdigest() + elif hashrep == HashRep.BASE_32: + result = base64.b32encode(_hasher.digest()).decode('ascii').rstrip('=') + elif hashrep == HashRep.BASE_64: + result = base64.b64encode(_hasher.digest()).decode('ascii').rstrip('=') + else: + raise Exception(f"No hash rep {hashrep}") _memo[id(obj)] = result return result diff --git a/artemis/general/item_cache.py b/artemis/general/item_cache.py index 19a0f7af..38d1233a 100644 --- a/artemis/general/item_cache.py +++ b/artemis/general/item_cache.py @@ -43,10 +43,10 @@ def __setitem__(self, key: Hashable, value: ItemType) -> None: if self._buffer_length is not None and len(self._buffer) == self._buffer_length: self._remove_oldest_item() - print(f'Buffer lendgth: {len(self._buffer)} Buffer size: {self._current_buffer_size}') + # print(f'Buffer lendgth: {len(self._buffer)} Buffer size: {self._current_buffer_size}') if self.buffer_size_bytes is not None: size = get_memory_footprint(value) if not self._calculate_size_once or self._first_object_size is None else self._first_object_size - print(f'Memory footprint for {value.__class__} is {size} bytes') + # print(f'Memory footprint for {value.__class__} is {size} bytes') while len(self._buffer)>0 and self._current_buffer_size+size > self.buffer_size_bytes: self._remove_oldest_item() diff --git a/artemis/general/measuring_periods.py b/artemis/general/measuring_periods.py index 2ca59b1d..694eebe6 100644 --- a/artemis/general/measuring_periods.py +++ b/artemis/general/measuring_periods.py @@ -1,5 +1,8 @@ import time +from typing import Optional, Callable + +from dataclasses import dataclass _last_time_dict = {} @@ -19,3 +22,21 @@ def measure_period(identifier): elapsed = now - _last_time_dict[identifier] _last_time_dict[identifier] = now return elapsed + + +@dataclass +class PeriodicChecker: + interval: float # Call interval in seconds + call_at_start: bool = True + callback: Optional[Callable[[], None]] = None + _last_time = -float('inf') + + def is_time_for_update(self, time_now: Optional[float] = None) -> bool: + if time_now is None: + time_now = time.monotonic() + call_now = self.call_at_start if self._last_time == -float('inf') else time_now-self._last_time > self.interval + if call_now: + if self.callback is not None: + self.callback() + self._last_time = time_now + return call_now diff --git a/artemis/general/should_be_builtins.py b/artemis/general/should_be_builtins.py index 432db3ce..ff6b4796 100644 --- a/artemis/general/should_be_builtins.py +++ b/artemis/general/should_be_builtins.py @@ -345,6 +345,18 @@ def remove_common_string_prefix(list_of_strings, separator = '', max_elements = return [separator.join(strlist) for strlist in shortened_list_of_lists] +def remove_prefix(string: str, prefix: str) -> str: + if string.startswith(prefix): + return string[len(prefix):] + return string + + +def remove_suffix(string: str, suffix: str) -> str: + if string.endswith(suffix): + return string[:-len(suffix)] + return string + + def get_absolute_module(obj): """ Get the abolulte path to the module for the given object. diff --git a/artemis/general/test_geometry.py b/artemis/general/test_geometry.py new file mode 100644 index 00000000..1333b0e7 --- /dev/null +++ b/artemis/general/test_geometry.py @@ -0,0 +1,15 @@ +from artemis.general.geometry import reframe_from_a_to_b + + +def test_geometry(): + + display_center_xy = 500, 500 + display_pointer_xy = 600, 300 + pixel_center_xy = 700, 700 + zoom = 2. + pixel_xy = reframe_from_a_to_b( + xy_in_a=display_pointer_xy, + reference_xy_in_b=pixel_center_xy, + reference_xy_in_a=display_center_xy, + scale_in_a_of_b=zoom + ) diff --git a/artemis/general/utils_for_testing.py b/artemis/general/utils_for_testing.py index 12f3065d..fd6799d4 100644 --- a/artemis/general/utils_for_testing.py +++ b/artemis/general/utils_for_testing.py @@ -2,7 +2,7 @@ import tempfile from contextlib import contextmanager from dataclasses import dataclass -from typing import Sequence, Tuple +from typing import Sequence, Tuple, Optional, Callable import os import numpy as np @@ -18,22 +18,52 @@ def stringlist_to_mask(*stringlist: Sequence[str]) -> MaskImageArray: return np.array([list(row) for row in stringlist])=='X' +def delete_existing(path: str) -> bool: + if os.path.exists(path): + if os.path.isdir(path): + shutil.rmtree(path) + else: + os.remove(path) + return True + + +def prepare_path_for_write(path: str, overwright_callback: Callable[[str], bool] = lambda s: True) -> str: + final_path = os.path.expanduser(path) + if os.path.exists(final_path): + if overwright_callback(path): + raise FileExistsError(f"File {path} already exists") + parent_dir, _ = os.path.split(final_path) + os.makedirs(parent_dir, exist_ok=True) + return final_path + + @contextmanager -def hold_tempdir(): +def hold_tempdir(path_if_successful: Optional[str] = None): tempdir = tempfile.mkdtemp() try: yield tempdir + if path_if_successful: + if os.path.exists(tempdir): + final_path = prepare_path_for_write(path_if_successful, overwright_callback=delete_existing) + shutil.move(tempdir, final_path) finally: if os.path.exists(tempdir): shutil.rmtree(tempdir) @contextmanager -def hold_tempfile(ext = ''): +def hold_tempfile(ext = '', path_if_successful: Optional[str] = None): tempfilename = tempfile.mktemp() + ext try: yield tempfilename + if path_if_successful: + if os.path.exists(tempfilename): + final_path = prepare_path_for_write(path_if_successful) + shutil.move(tempfilename, final_path) + print(f"Wrote temp file to {final_path}") + else: + print(f"Temp file did not exist, so could not save it to {tempfilename}") finally: if os.path.exists(tempfilename): os.remove(tempfilename) diff --git a/artemis/image_processing/artemis.jpeg b/artemis/image_processing/artemis.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..4f37283c50c1587f8255cb61d96401380569268b GIT binary patch literal 259477 zcmeFZWn3J~x-UF1Xz&mu5G({pa3?^3;O_3O12ecwfDlOV5FiA1cXtaA++Bk^1ZS{! zAS-L{ea?A*=YF}L?y8}yAFHRHs;=tksXx_sQ+G=MY$-8GF#sGK96$p00o?s0ND+0l zGy?!6C20XD0000T0DuDm;9%5SBpCS@&4d5|z{6-DOr8Wgkp7ax!|2C<(f4KW{-W>g zh~N+({A9pr;=kwu82$3!%Cf-01IS^r3fRF4qo2UeW3aB^OraU}PbeH8i#a-%9|j%q*Nd%-lRIugO{1csRIu z*f{~PhD0FW=Ky9_R%W=A-)+6SgS9W^cb!NuCGao0;l6u5s1zPQ{n&-C?2yQ3Mv}n zKDYB_MS2pMS}H{O0#B*ch>hLeZ&(a+6U&tJw49QbKv{?sB-Dkh?B>5iNPBdW`$ zN9FI^N1axV#b~}{1XM(SA6q@itn3(HJ5@7t{t%N@)j6?##wD(9?h+WAUEMXgagGDK zLa=BN9%4d9LgaV|fr8~R0xSZ%=eUThzL8JvBWP6`K4g1g^wZw&v#>Iru><=rDx~`W zkf}M^Mqojt6PSpqayptW{~f~re*`rD3gK=BfDU}969*sw$gE6mh}{gNR&eG-um4gt zfwEdKi&2p}PKvvq+hsYF!_HCnVQV>IY%&zLMN3LvvRyGpjyulpHKmXrpSyQ>ZF8OC zPE~vbf7iv6wftly^s9z(k_=6j#}&c!kgqaU#<#Hxk>m$wDVPIt!d^>Ra9~I8Kz)_% z)1SJKOi57EIabDADU|jcqbYsE{>Rs(JP)<{bO;i0Wvf0h-v$Gq%q`|Cj@`j+? z;_ws7Ry;IfaqgT|4;N5{{pRTPs&6HB8_ur}-4$m)Q7}4@%F`J3KX7JkOHt+u8tXcn zt=g>0$E49!)5TPWLGP?T|oWIjJrf@J^B|OI)d6{e25O^%JO>sArt;TEcIBW() z_AO91f*&O*C*Uh_KoqUC& zzHJ&yP$apP6J6^ID`%Y=xHp(bb%Xh)i2_-RmQIcvix!}*?lWYi;i^26ZJS&BuEi4p zffMIDK)QyH`lgw&1(Oc?HM>2e;w+7aYdf#?!i0)mdwy0Smk4YM;|rdcK`U8Iq*89NWygS6Y>@iHE+Jsk-h>va*-Y=As&@s?-3k9H*+2VwAJ=E0A{V zM?0*lyj`-?ZsW7bz1)SaJ3y-sW5#huD+R8*1wIZjQk1S}xNT7l#Vi(gGpt zqWtjCuXAcD=XC=NwhWe(F%^Iv{mSO#tzE&s?J@Pyf?hid^bsvd%b2P`;{@X7ty`=9 zr5|1*U{IHuF5u9uxlmVqckhY{`6Qeua?1N?rF39^{KB@t#AG%$%JeMR>x01=*|%}+ z&DHQV#8Q9kE=?Y@TC?yma_{Q~y_0+9 zD+{fNpAMDnQa89a4eMONIlpovi?2;ihYds6SipN0Xfm&C*#$`tKPjkfpF0xZh}u87 z1H@i#7iXMTR5Zl0O6#mJ0ne*%9Rxv z3&R#XbdVZ=VArAh#>ItOHoh}4n%UqevaC9gc+K&(0IYDb&}HqjL-tEGPhZ3CP>hWy zSpp=A5UyG&p9LzP71L#S0oBi#7ZVj&Dn1n$=HuxL9amZ)C1D)#(r6|o$Pd|#sw$vm zY!-Y`11AjBzFI7xyS&thQw8$7;@tkvr<>5e?8w*#OE|O^U9gSZ4i@O)odb4FFAh+T)3Ay z=x`g)`s)bNJbDAV%u*jW)*{nj){zWUsha=IBA`?{Y`5Lg<=46-r?(f{PsETu=!>ll=Pchw3V!7 z@wr;0oQs~1_zx$SS&g1kX$@&zLk5TDc$z)q4CDF&a;ht)!u>ToB6qVJ<#&lVMue%lGF1Zg9u%W5&$O@j8W7> zwnw_Seg{~Nr5S@ZD-6Ongaq59z9*Un`G-YS9yn+Xy3`;0h{XqfF#p6_$2EDRvLTdj zyMF5?)X@I*b}3Cj96m5IpQa_ip7%p7)7?m6c*Nw;GxP|)#V2f0+KG?NThX$(#FJh` zXk|K~V}Sx$%K^hRHXJYBe0|11uVk57`;DjTYnvhN3zFhK2Os-v54$_Sh%QwBP&MzX zYxKPG!H?uJC8MDcEo(YScH<>Y@n6?32s$k6$sE_*0W?)W=z&@<&O_fSZ*yC7 zc|`_F{hTBd$W--#ax9)!;LqtHX9x#6_G#pgIep%ZV;W&6(Pr|jh>KI9tL&?&{gx<5 zN*-EdeG6WXc4~Pv*lMmoJ;G0GJpA6t8a+^5p{m$}NFsM&?=}jH?utzC4v>7kT&#Pt zT-SESGpCa^bM-4|e3|&{SpH_()H_KOHt7RTZb#L(wr^g_DYgxa-F`Tl&>DG*!{2KP z`^Exo&Nn;UB+KRdh#C*`WD%Dfuxnd*&k`Gh(rwYp8vAdM6sOHI&gwVWF7tNFl66_% zOU4S;zl;teEp%SKi9Thi3O-yk-z+kIS2V=w9bF~Ad7CI#eU2YM{n8i>Loa@AWb)~R zi?qb`$U0Vu?5v`W$s`=XU~nB!H-Co z6!>j(sNxXhvQfF%txdu|N#N>>tcs{O{beWLIPav6!I;&WI8pIicXEaR*W0zYC?{3! zbbKoTxvnz71*db~Ex!Kb!{GGVK^M6nyP}*t)ovenLCpo@-%AftQ!~H?7$}Eo#w2ia z92<=BoaM*IWp)_&nzeyKt}lFEA?JIkWhJUb#gdNUlHf;a_SSBSaRE39meUN0|&be5F0who=qR+g7s6XbZQ^g=7ae2oGH zkL18MVP>K`Xs|MNug;c+}dyDA_TNt7v>6&Ip7$YCY)I*!&Za)tW=S{`& zO3zMbg61L9%yiiy35TS=<|`xFCvVNSe2UGliTXO(U+m%7-(qy@y@OB+lG~_j>k=-N zV-ezyjCLny8V_#Ff*Q{n$#OLM-Uw9ShxU!UksL0#9sR;gJJtFuY!N%hX-7_U%!p$~ z9jYx*;1ykcJ%4aJV}T_8N!N$!==t+NvBW3P0=v@hO_Kx~`>XX^Ppqw#Lk&S3hQCBR zJ9x!%2C($ZFibuI=RVf^6F5&?%C#1#aiSGcL6sjKsLb%uyf(JJm z@W%v}w;;85fYTj;9m}W$BkLcJuGHI~WtIq(c5g3nw<}~md9(Ip!W?^z&Mb-wf2Ox3Q+p0qJhwE^R^^ntVlA;UB4c~V;x9rWYZ_`qxp$E;URu`DK6m$%P5 zHTM{onW949Y-6p7*Wp;{`&QnG(!^fj#Zubn#)*p-=k@w)nSF#(>hSE(Y1_MSwu$Ew zXX5nJCZ?#egad;pL4lX~pX<}-#iZ@@jm2~JDyBp#XZFnQ0CO$}MJe-o!$?~5=Amk! zj|1B^a@RA3PNBG|=*BZw0(o70A?8P8N_~Pj9~VVeusKaQ-K=+cfALL}jF|SX;?zbq zCXJpn?%MZ}=$5Q&a6BXJaA9n3-~Q@c&EPw(zq5*bct)JOX{0IK%sD9kG^YHL><~22 zRO%Sg6cEu)Xuo&)RVZQ=7Wx0b?8Gw^!f1W6fT5`FH2Wc@)!l7w(R> zTIk7 z5M`%*Y%c8K?^4CX_TK$xU%@+!AdS)Mh_h)2fvSn>+gbSl4~&>f=$kkD$k$717I^Q~ z8kA~%{Es^71QNKQl%uDYiOw3^pgDvj;$oV91rh;@OU$~-$5)seV(;AOPECe1;CL`; zYL%U3DWe{-ns5$#iR?23u)p!px=H^IkYCsmd(vQ`x6He9(}U5IAMy6pvrbS>(l*=E zUVHg*R~BLsEb{5B7<+WzA1X)NX53EESZOYBOIUsZjUp;KE*ATq(KJpZ`)^`eY2>yA zEKU%82)DIW=T!?mK7uDc#tIVIzk@#A=j5cf<#~FxPnMp4TXk@aJEm$YfgX%p`F`pb zZaU@}7IdePwz;ZM(ekuWyzZ^pYrFjwn?8Y)t=zLaz$9)a@UakrFB*RRndmM!a5oG< zhCg^4rCw}MGY>L$urBNLp{;Hlh`SYCNXyi4*y8xLTUN%qO>gY8MfGt^Q%zj1tHeF2 zhJCsKr8F(n4fc~}d+!b)ksuM17{NYXhjxWRay`7wI9u8HA%aF^GQ`Y?3zRTx1M*_^ z`p!MFH|M>L7^}6RtxSKiRXFgJKybW{yU>#KAPx4MQCOK}drnt$cCAe{SUD^6;V~hA zd86SD;9q=udk5%~{XpVV>_9q!MthP|TUS;Wv~U&IRXsZGd@!g|mbAe-XFCb8Gh?w> zD{iRDb9X@VX`ip(<5S(c1(BVS9ro;?(RUGszU#|pEE{3RXwPa_zwPI&W68f2&r2i( znObM2x%-o7n7FfB<2QJ(dGOKmB;EmV1GI}=u9>p(oCc!fC!h=$UoR;!rcH~d&Ink> znW53cpm?Yh*VIhGsZ~+eZeCtfU`~PQYYlx#OLX$J^)9M+kaU7e{&Fkx>KhD>d~i)X zdCLpbqahEKG#%E*+n7n#W}GPd7tN}hH^LdFytw@7swrSgsH}(la8X}$OKZv7wP?M~|=lR!3f4tsOo5@g*Q&9Xd&B{`S z-F{+y3uWzRfrFfjIiF&`SF<%eDm+RdnNt<|A?wUF4TKvOlo!$H(l^4yu%*(mJHR^a zg509$P>zAubKg0oBE44$JEZivp&)%whtnQTx?olRh`b-0)%*)AP#GNqgl06yoj&;* zhm(4-as_e=aaOt5KLO3&0fgPF<`>ePLn-yI@dOhvTE`MS+5I`;oyj)^4r0>EiI+Y) ze7st?(5%(v>}8&_aM{r~s~`QfsClY=2av4tnGM!U&NR=y9xM|m!~@S?CoY8FqQBc~ z$}BxGjZaU!dMYq;#Wi{dc&~tl)LPb`x@Z#PgjK1kK5XeF|YPwn_5 z-3(qx^C22zP26NrLW(PI-;2#(@rC8Pl)D`BNAU+{mtPa+qN^`+K-BOY(Mn`HOO1`R4uj+073BFR3KX#hPH6ili zsrEfnINlD#rJe@g0jl%WH}XfnKEhH(6aKC^J0AM|X(*gjA zsl=bb)~WB8svmSz4?67rL8w!GZ&rR%+gkfq~&nS1qb zpQiS5hBl@uq8chN5jsE^U?lU5WPN`P{!T*HCglH!}<&XU%XH#QGh`orRqv3rE z9)eUbS8(`q_3r6=TiE$NY=DfZ*>5-hrCJpjn({wYt88rjTdrVl{8sbc!|$sH++rjW-yI4EWZb8?hE~vq{e;wTf+Jv*MjALY&aey4F8fm_`HDe z0DvtFC;J~0@E)G}zo2{Q3zU1`f93-GTiE=D9e?#3={@y(qCVIX-Lt?*SoXU;_bdRu z0cLW~GXmUUlgaVLS`W>b?vZ=Krs$a9`)&>EC4cG2F-UpXuKW{#N{J;sd}=*7x_3 z`+FLa5yS~>;_zSQov|YeO!siFx*x385U}|lKlg+B-+3WNh|O>QcNj|M7Jum#%mmgbh=zh)e|sPesc z`3D)y2Zo`OBShR3Y-(@lXleoz094#;|J+<3{WiXr+-oFl%*kP=|J&^cI@w$Qb&LM% zqTF}>e@bK>%>UfRf7>7#T05#3n*WCtrm-okoT;m$q=STttjwPq5Xv9%e;A`$KmF7-9{v|F_P?`R!jw z{BH%U2tW=3zVB;PM~E%#3(&#z-v`bkYuG^gZ&Gw4h$C!3{Wm!p>=Il2SM!J2`SQM^ zdte;^FL3xk|NfrFd7xo<>4Vji2kw4A!#(i#_5gg&dmeVAzz*2=E!+|S&}RUX69EWe zsPI3i@js~XKdA9PsPR9j@js~XKdA9PsPR9j@js~XKdA9PsPR9j@js~XKdA9PsPR9j z@js~XKdA9PsPR9j@&99}@xux-1#Hb20Jul#9`@v50R&;t4+LNYgKx-T=&J>cc7z=c z4-^dK0^t2G7UVDlR~ZH&F~Z=V`(^2eTJ8}h*it7C;#T)Kg|A^|F7q}HrQ?@cx3o%ARKIa&+pwz-vKb_3=RPZbN&$aeTD?v z^n(n5M}b3y?PCHwgRQ6DtN*13Aix6=;b8li{%q302Eu?UxXfFulP>rLBwPF8Sj9wa zECS;N;`6%VJ}kk#dDZBXX1-+Cd0+2O@V+HH$j(G(RcAv*?HTcg)*6hIFM1NQ$#R!1kEn0=8JVhJ+z;C zNcbB<0OUlczL*`D}Pw!QagcQB&9(2={#;5ZIjr)iZ_sX|C&V6ndFd}nE*@JJYqqG{rSXC%%SM( zZeoN89xPxltawJpwL0O zgodu8=WmV5h=3(3_{f63z5zK=7OJ(V9-_-82!6C#$V+Wi0c>Q!hFa~!F-sO|AC>+3 zrL~ceu#Z5!EQ*=wCB6m7dYh(NQG-nrt;3ZL>;n!@ZDiW4s~SH7HPl<8GTsLi_}iHU z*d5_n(bY;2H1xKd=R+NUz!q_T&{ejG|46{pa%}O2Ynv~})aMOz!KqLC9ntHF*ahN# z?{aw1v4knLsW;6!u=_Olo{CWkI8ZV4zM$cBgdZmNwRJ^9f+Y-BogwvE%P(Ko(>1iF z;%N&=i~8Sv8v*wm7Naqj10T)eZE7r)4^44kK~my6f_y_CW2XJA+*qd9sD>8o`Q z4r}rXnIX%d7m#a-vX%B%G(}ddU@AAHe+w3_sj1IL_mH_pCy>EK#~^qL^bLL8U<@4i z%oVO73_fBf9JYT$I@C%)`@-e3a{6c}To4yJIwHIQ0R~*dc93y|%Q!Kks9(?2ZBwP% zaEB=t4(ntFY)p~BcQoOesU$|6`v-LFRQhsO<9-o@b&fiNE%+Z1gkq{VTolLJTb^ z5FFcolA1HVuJ~cTJ2n^W#vo*UCbmHfok^wOAgFVX!V` z13%(J9})S@;}L#bDtVI{7ewYB>Fm_=Qu zqc1i8&V>N%{y1Y`YNlpj(#5D?yF9Rmy!llN9rSfAE;7^Y@`g@&ux5p?1hoQ#Qq{~P zBr7ksKdV24T4aX9tdt>PW>T0q@rIGlaLX#ZWH;dk9T{$k921V*n$&M80t~%%4j|PM zQhCk2$%e3pT@Y3h@Vciy;Yie!IuJ$B)<%-3rjybmAcHSMjqCM`{S{DtP@~FtL3FA0%U<+` zUXO@nr;v8IzP74O+n(VH84gZP#roJDOMQNbR$r$Z`sIg$Df+mOFV>FBB8kOJpX{?Z zfk=XlW$6xV@r}*#DUDXB<#G~YqXi9}e(zEYUb%%la@2W(_?pku=^(ZJS*Rh(?XiL9 zV_$MKctB`|Q@dQ`mk6CV+;0lk54RFud>3isU*;)IfV64Dw=8AZNGvY!PDHp-L$$|U z%YBr^pI>a;>behf@~JuYqURg|nTMYF@@~uz7GLnITpko%dK@1}>deZGVioMhJ4p&U z^zA*ykmK%{K^!}7bt%R(wXE%yc>DSErYhBR|C8d#E{(f*QJpKOV0zGsx*0Dt-bmoV--Nb@`-~~6b!3CbDa(asj0iv+W zkY+%Hp=($~N*^ggiKO4p7$QApT9IWwN;HbRm|u_ZD%Nr7vo>%(1HMxTMu3$XW1JTI z_)LFpG{)n393#(r9;KZ;)nb{a&qslM-55|mLQ>PNKe=&+(tIX&eb!JU$4^vJ%tB@}VVGg4vPvEZ_LxvyeMKf3s|zw~*;+|``iZ)&hs(!JcOdvEOw)B& zeC74)__TZ$+UN|0EZIuks<-c%S-&Dzt0M0**FBy@3^fdxJ`E!xk$_3xc;@oX4+&$WbRnI2qWH2c>p!El=Wv&=-%qb z3T_Ab-mwDTnqS|@RU9=XjX6mY156p=bc6W&vgKL3%Nj2=ZN7we{@ONG^}u53UkJo} zicg=+mN}M03100mbD|bSpkX`H*^qGxa5l9s6lL zsR_qC-ksY=cDR6T8+c>4wkhG*+m*-%+og^n$d9XqBJCoeUehKF_=I8`ge5p7=|5}f zvS4HI<-KpDHG~RIVbuQ=%&2udHK4+WlCMl+Xt?d}N1z@fp{ho4K3{{Fm$a*v)U%4cCu{+v zZQdN!+SJ|~_Qi2B=%n%0%0ih#oWJ0^InJlK))my^sFKXhv8wp!8E-eAbmgQ+@+E&> z)lV~gyQ9E<%An!vE&6$Q+i<~KU`qbUn<32~)|kRW3Is^+qN%H*!qeI4=f9HZOX)sE zpy*6}tc@?p?D`n6KMYR#fMCI1;vd1BOH`Rr!Dc8XGBHw)_Ey$VskH6a8tk9f>v&R^ zz=ZedDZJCjW!cW;$mJE=P)dGYi08_Lnm!|xf+n-A*fTrll|R;YURNngda7yV*tdpL z`%KMH+z<2IoV@D#RMKTR+io@&vUKMZTRNS*`7|?y zLqz{f5w_Dcot6u^CB?vs=!4@nlT@)(v#RQsJ*wkkE7cUs_ou#L8%z)*@!fF;BWt}R z%_7SY9d${`4|xYDDgr-Hw}NTgSV9gv(2pwGRWwe84*DHRn^@8}KG{8)(b`_n#nNQG zWLKN&{fW}?pjIY)8DtT)QDtNr;&lK==R%B!$ zDap)6lb6aw#Rikv@AS?Mor*^Gr7uA;p4Xu4SL?|(vB!bCO^lz1B~+#@(GsdaC>Tk<_wrNWbe_IW6AtW| zt|vT`?CtRl%f6i((;!H(Nqi{Sz}@&d7^NXO%SBhKv-MMOQW;WhUo-Og#LfF|@KdIq zm}v*;3MX&^Rle4o(PMl@2G7kq08Qqc4{5}@gAbo~aWEf?bI+FS{cz+P z-d@|{iG~7Ub^-c`XJbTC@B^>lx7Xe1pJbhbE^cB8PJa@(D5ef-;%{wQptraD@>4s} zUreO62W6b(#MghW7WAl1ggt?Ch%EQu$??4TP-#LO7Da?=_@Wb_4zB9-rmi;y%WQ22 z+3Z>fDG8dfQzfPYW$U&0OXaO|HG@7Ny@gU8*m{5T66iX{$L#0x%~f)n|K>1g^Zrsm zd7>6WD$~h_XGfimHE&4MdBqNtw^o7a!)-j7YRMJ*I5RDw;8@rDNqOS8Q|EAV%Jr%I z>*kb<;|lJP+XbzcKdN6LT5K)Gj`n&-v26%UHUtRoj2U}960?uiFR>)I+kWq;oFvuW zN8^Dzxbx%5Yh>%B4@-sZO`(D%ptqUdtNb2^@DN(5= zF?m9GQQZsT!y5V4sB7K#aV+%ICxh{bqpJ*wQKK`nUmn>ale22$^bY%Gjr&eo_Nt%R z9ko-tNuC0?yUspEpf|F*o;zcANjJHnm041bd=S#o;(W(x&tslO?Q}dfTNOVrVoaDLs0r{cR5b+vukKm zCHpK*Yx`Lxm4;Bpi0*>l^-Z>~u2kf*a4lny>1N9)xaERb{gM3&wcCJK<`vhMD(7F^ z%57I#cYrnTg~p?%&#%U5wyZ^pjf8 z%bjb53VhOBUzy4G_KpUi>LFL+FKf}OA;|s8=Uw~!FP?ANH}`vuTmB+T#a+_bx(Kw$ z6>~4R$T{0NjQ6bHa#*Ugs4UNQQne zvmj6X{AGV59BUJVS(f|Lm*UHtdB0{IM&SG=0z~1$8NxcYxB^<8Nway6ZtF;-+#=i#?|#sD0{mvK_`? zwbp*H@DPQLrTY9+g6|AHky+MQ4C(f7GV)n_*tuSu3?68c zd^y21I=r>j4jm*oE+4-9UPSrL4-xaz#M8F14Tbz}EN)b%f&=}}R|I`wP;UD;3iE2a zE9!X;jCK$4Q1UxK-s@LVKN*i-Sx)knSg4n{w;ov8k!muMAaWyJ%1e-iZcZ2Vn78D?zf zt4~Bz^PXE{X~d%?1_(J3+vy%?h1v z#WET*?L4M3=1lFiPaq5(`%<$-4cTc=kL%N>oBX(F?Vs*yjQ2R013%vuFZ(SwTx+t7 z$rbdP_|vG}9BZq6pgUD>I2tGUxbA@IIJVAHGeMKOX<(q!1XaPh=anXtV! zKLNG(`@oQs3{NG6)b9)+X?TaaT=28h=4fWUdq4&U6<2qpL$%+VjE9+Fg2))b=<6c98o^^c zp^GZ~PJ8&YZXLS77;!#6M1LUgZHr0I>^y9nQ>hAbJ|SBgJ8FM8s$*DLrj@0-xAgGG zODrkwfc)(~c@^1Ck#BLH-CK6KUB{AVkEc7R=kpxDnn^cO#5>`IZr5*cK;o>aD9hzX zZ~EY_R*s_IV)>EaVt_U*ArpKnT%E3&T?d}$y0G_ZaFMLI7WSGFqs;?U?@_BO(?ugymq}2A{ER*T|ROh_Sg)4oXO`7380nov^BUNvSO@ z;%$G=#9X>TKH_EbqQzbH-VVAH7$wc>7+83=a+q9)N)OEG!0xqU4mjYjk|lnb|5**% zuD5nG5M{tOp}hc!WUD-=j%;R*+!P4Om!2+fznzqkAIsK07?$6^%!B(jJ;jQ_@W$E% zCn}e2EGkDY_0(EKhB!po#j#uNvm%ilwYsj0qkg* z*1cEJTCO;`8RiMhaBC`Vb`D^#aeG<+m8a4^f2ZiEN|28%nPfs?93Rt;%z;tMyK4}5 zi6P7+5~$$m5<~W>GOggd>~`n6Yy5)o_1P72B-U6KN9mVPFpF=F=s;HN%iBl`lpi)O zz=|DHFEV3(<%1XWBp;A~{W~OFv$GQ}b!CM?*;yiMe(3FHlJ4I=65W z5M>jI>zN8WB@eR4IA?A=^X6|`JTBuLnap4vE{y=;T& zQw>~H?eXGfl22LG&-hm&nUH?h;Rd;KXMC5Q+m8%0;$JhvtB!BbHXCk0YkZUf91Wr& zTL>5OR!VBc{3k-JkYyjG%=(vlPrK(K0MiboL$4t z#?TeJECtGZj7~L5&$Z;}|ALs-wxRM^*mHk9j^~A@J6q5D(mXjQ9Bal|ft30mgcR1h z)UqiJyibs&-V~;*x)n5DG}gBvd;7el8M&MaqOiQ)8P@Q|Qr&|n-ezKQo#|QQDoHI$ z)PI`9vWrR_5yHob%A<-VK*r%K5L-s9>#S6V2K@4IGY1`Mb!eYuk^dls7c8MYLg`J? zc(X-4&REB*A_a2B40#E8wJq9!Tv3X>^}NkF8neLA2{&S@{qy26TX;J8$kpri`+MRVW(URjVh|G%` zu0#X9$w6OtA0A;F?Nb7JAtmUOrk7_g6T|a)-Zlr+*IO)r+yeM?E>Z=myl+yAMcQv7|)Tq+BCmFGC8q^Y1zNnH{4 z*k|myYZcuZLIElBy3gaUYma>#kNcs-Qm^_n30k%ipuzKQRo`koNZx8b=4Ixs%YLk` zWVe>uT|uI|NfOcM21CYTALTc7D=TFVj)mzByK36VC7F+>t@%Ck-^7Q~h3y#fTdGw_ zvyJ@N9{({9cK7@t&sX(LMv5w(ws7?qij+>b3%a;RpkqJ`qh>)tAADkm*n!w5A*khN zrv~Wf>$8G_9;0_T1|kSb_PfpRZ$51tCj|*WfZ?}sD_m(ax1Tmi`syZi4P`mRP84Ue zQ{IViDV!COl>cgWqxjCq7e-<+!AVUBw6dqdKuvO-HAS{xZ;?3j-~xeLPw>}jgVz_| zny)reean&k9%Dwtj%Kr>l9Z0{NhHV}!}lHiGE|IGdLqhU&EQ zzli&m#(k0w$>_i27oS+H#DHqzH74qKQ#Lp$7;VIM@QO~HVlZed%s5BbnfJ^#z8E>@ z$^m=xx<@IngM2aap6$KRgF4hbc^#57=gd7G{gOcWWuy4n(8y-l7st2?_cna-Nj5Bs zTC0m@o2AmhD|(G9Z#=bD99U=`bZs+X z?>|RqO*QJ$t%*8UcF-Z;F~FdM%ZH$1M<&@=^crftoRX8BcS)y;%Mj(mRIZjzVR8Gh zkkMefHtvMWK(Fug#ytUDS82F=k+{cEh@K;D!wKiG#y@5NSL?B3ilBlq4_b2Xn<!kIG~Eb^Tk?VeoDUD{Z;lV-(&?LA8$Jb zexxlS=aVg!iKU^&TBY7AXJr?VyJt@pfl{3N^VySW0iKhiotf>n!FjQ<5kYCZ4NyeM z&?MaPRl&9yHD8;|%NPazfb!rn`;csjNWI#G7%n?jS%gQhXOgQ|Ow*oUj9*G8jw=az z(4_Q@Q6GLbH$>q;Xnpjf=Q|-`Ck~qMW_a&;Q#S084To-D>6kEs-+0r9<_@4zqAqC` zYwMt#7x#%Dn1=wm0iX`K~JJon+W!FOOI}#UTeyYDk)5DTvBJih@pF zD%NBm_~Rq<71{ijKtcQL*!a-_ic{`#wc?|n1@ESM&?)kFqM2e9Kff-yhz-DKdrj$^ zAoKGnPs&B+&2l5bR5NvD+(E-I(iw!)vwrke&AZ5^C;v?J_MBkhi382OvE~ zNwG16)A&L1I%~K%gAd>7(zKy+>BiM3d8(SV?ecT@W9U=alP@dT%0%wFq{DZO zxS&xn(J-c&oAO5v_f}T0-P%(Tb<-(Tc@NuhFgngEVaR5bCut(l+N)8$YDe z+9A*NE72lvdEax)oKR`)D*_ocpKjlHl_@*x6?Ir56s6Q<>b*_mEq5qe0Njnd1)d=sPhZ^8K9RqBpVL~hW= z?Fet*PP{{=LSdtk^^xK!XnvQUX1updH~1qhE-6ahQ<19apDV$<3C@Bq=Yvbx=6e=tV*``|cigWcjP7eGbUby{7#jwQ=tmPYpG-s{@lU40+Xmi?{Y70dQ z;grWT)IYbR9szT-&*(P!Z6QFp@L!~9lw`V-rWNfXgrzSrEASY`MeYEQS35zXN4JDS zU0ICMK!s1#0=gxYPDBv<{KuzfLb$f1@4ZYOuVN$h<#QP8S}FC zmjCDhd#lQtfB9n%ahpg^^E4bpyLUq0y+g0U83vo^W&o~CD)t%S?vHst-vO)uZX=(n z6N0y|`{b%LdnsR?*x1wfwLma6WeVwDLkUR%&}#J`GbFl6BrITL#=P4fXt656n#yTQ zd+M@BWb%B2Uo^Qa1n~B)$oTg&Huh2G^CrA_qs*D-$6j}1{*p5W?o53NuWMF10)sYQ zU+8PAyEf}aHs+Nv3f6PNjn-}uqf9kr6UkSU=_VP01i~tqPYxV6v>e47Lsq*C9Uu9t zm|coxadW?X^lnp4i4MsKr(A?BU!0Rs+E#>cT8C}t=Gg_!@aZZ&2FH)t!jm6oqv2bG z=3TS;!&q*9v&iO2w~&*-B)c8eygA2`M)gGdnNu@|G5^}@^R0$jr^=@#Ov5cSuhCrM z83|_v(JxC3ECFtA>XLf+_1*aKRneg-`Mp+8AGLaHpOC%N+pk{ zmAATIdy*dJ8IDM zj8|>b<2!^kig|@@GrxFME~@F9z;9u7>B_D}(KR_8qiWY z0hUM(3wTZv!cuX)QEVha@Qm2fH;#9f zTd=$=t|B;E`0)L}nmd)PPQ@Fy8A2ZnGfrnGZE7Imx}`G*f-WG9y$2oRs*woayM3ST6Q25 z-!0WAr|5;0v;L10KmN4h9P8*YhVUoe`ka5jib2a5;E1A_@r^3;VX z=frHOsEU3F1Ycl{<^IiK1%|wJ0Bzi(t0942C;}WnCp!f#9Fds&f6G1 z+n{9p;F7P7R6WvB##g@Wvr+7-vgA6=MWG;DaaNbq4s{7FJaXm^TuT=7*bhLLMU~ z^Zd;Si-L5sldC68DJ`o9#M^mHuX3>B>!?0=b27fqjyxqON}>v`$`#JIso0iLN1XoJ zEybAEAfK|_p;_+5FK>B;#r6G`ZpNv&^w%5wnKyjZ@&6wH%s?~0OgOwPNcjCR-A|0}O z11%i#U1S_dZw}X`Uph3gCtRq`TrCN=Rg#=%RUh7J(z#@FrUnKAKrFKrrh9{sw)oLb z`*p~FBe__4jp*FRjnC) z7jClx{#~kz!unoR^gQT~SLga~(Pwbyg7n>`^dBM(nSD#`yr07zEcm;loaUVx+Z62U zW;)i$F|w9d9`IHtfR&_cXVo8rd&o@i?b#T+7Zwk6v%BlkacaP*) z6F{41J;aq>CXv6DEXtww^GW)l851eOVX|IC%%Oo0F42xFBVgDtHQ+x@#1UrgFGmkv zzw+Lo7Xxr#wd>l}SDHX}F#NkyszRVIL4jQAA+CxRw#{U1s}JWi?7vZ&TKL`2jJ7V# z%+p;YZ4YhcM-s90Hb)`clk%r&wT5*6Dk{O)K=#=&AKrryyeg+ zJPqRZDtxBLv?#c}*Os&`mn`LNbO)*PGZb1+=yEwioN|V!&!`p+y^a6NV zaoLP%ov4K~j~8O{Os=g$E>1t=3d+znmG-Uc;e8WPg8x&tIM`=r_TL+ z>nYm)jT2pDTdm6X$NGp=>?rma1^&;=*Iz#Moe^564^uDOJXMBSEx%&#SCHZt)qZb& zLF(yiQth8_<*L*+h1gDANpvT(^Yz9au!b{We^K$@p8o)M<{>YNM4QY)^8*9O@$Cc7 zcVN)ueBt#Q;6F?gLx#`{UnH4N=@022kMqZy^t`$8e|w5;T<1(@38qsp(MnE9wBuaw z$aCJ0mL-%)0H6s@6EsSTIc}zQSshjW)1fDsYmYgrJ(sz@BLmp`Ba>hq7ajAj4o97r zAu%o^buo^>a^>01xempv9IfqDyHv(aSymxr=<>?b%x~qr3>Kzg-e#>=a!c#>T$4aOzds@^O2?$8L^Mn&TP9Om+QysJ#w%bb8-A!~QGS zb;r5$6^D_~zWuHZJ>~VJY+idxic42J<$ut8=LmY7wteS;fq{UadL9P7t-n|LmHFH2 z0_gDd0Lk;9{-5MJ)9gPxHEBS;j~E_b#g4M}=DDPI||9q4kMcpqYVw`zu2d3^;Ia%766o)oq!V-VAW z;C0g^Op#*}E{33T-Gq7;W7rf9cCC}_e9i5?ml}BwS<;@P&5-tDX!mMvE_qSB_Xi~| zZ~9k`_&@Z27?&e~fq{X6f#4${P9IT!HGN=uvcpU%&_0R&i}?s6cJhD3{*o=_w@uy+ zolM;0;G1zjC)!3mp`XK5YmD>s%{>0mS~>}3yX-7`Q!-qXh(1*E1DKM$6$eU>8K8p7 zpGH-hOpdiwW3V4$Gm_$Qlc;uZ2F}3FV$P~fP1zXMRjDWGGOYHQ^J@XlCe3@fifiCK zeUAQRc(&AH^C>8j}M#o<$Uhr(tdwT;o8X=Dm*ujb@RVEpK$rzC!v~$JFiR)d(S)HLC7E;2ch))^HcZoiK>u8Ua{S{PZQ8LwNkO`og>b3ig0r~w&Tu_}?R#xEL_h2ooPeL_DveT_ns?19zafLmF<<)By z+K`CPW1OT06Km+QZ0GNk+QE>*(x?3|pRl|iuq^c$b|!n2Kbzip8At-4ts<3@faoR& zsc#xyFHb~jvwNtt+Rd)Bw8NXJ^XXkYRnC;v*i>a2c8>_JxWU9K>|LykZa+=uhZ~>E zk6q?|VtSn6UeV`fmApp&t{a?gM?;>6(_hVhOpf7U&z;Bi-=A3?)6H=o&Aq@U&HZeJ zaw~MSIu-<}0onE#dSA+Ex{nydf6mFL&6}3}9i~gh5^gEFthVHoJM4IZ?RgtzIoZZj zb=ui2D3u%xTEXo;)XoqZ`S}PQqS@+KjH?})>^#Ipq$gm$c@6A*?U8(+yGaHM{PF29 z?(8o>?N;n|bHnkq22pNDoLm$-pgD;5qSh@S(tZ|2FMMGu8&wM1FIbgIs*hcsaJ+3K z7CQARb1KgC*6t@f`HkzxODQ<^f%ATorakA8(wpALjLItwRsJRY1IqFS!f8vo-YOau zw&K2Y(nr&;&3{&Ga>dZE>c{DChEaLj+_~~Upa%JE!|6gro}P3qlnFSzRV@#xqC}x5 z5t?Vb9Y#%tygIl{$w>)Uoa-KU&~hHkhgG)-TuY#n)#5ypgV1^yvJ?X+I#1X6RGKYZ zy$3k@P<^K2LSq+J4)u1rV6}G-db|?}NzD%sRaYX(UZx*7lA~E#C(Tt~;p{!GFJK|l z4oh1GdMg+0WSr>cRUTh{8#R)a>a8;MEL|*aH@&`wzc$mpiy^v)H!G&R_NA+{m91CU zEh)@?XMWVv&A7dG0PmI6mSd@3UcFv1sC-rW2buZ*0O+!lCFmm~P}V|hA51?r{Ucs; zsC|c%>|fBIHKzQY?=5_z=?VGoL1MazSUm$*R4Y@A@b&iOod(<*^h2UZzWyrsMLrIj zjHAgXLn%gh8b)4(T^A2yzGUJp6}|yipy6yZhSi!k8W+*|=K2WPH8n4lpuHShx9W_m z_F-}(*YUcz^f;v~FpBNW)~_K!eyvwX^@}MqE$uHy=c!LI>D8c~p(!1esp>B?zJ=SO zZHb8_)oF>da5N&_*(zj(1kH#=hsG6P8jW zF+ZVvBb%-qOr>2Uc+!+K7=+sGWn)HU>ZyhrA@Iy+x1(e!o9)j>OUzNd|= zy_}GCvIdd{We?3))x3ow>fU#n%x!#pIrUfa@7Fx%ElpMJf4F?m0Y>Ui7wM+_U!b;} z=`6TK&aWQIE7C_Gzj}&&);&*xAeh?(mr~Jd-4LX3;8!xN6ZTt5cC$hpDp9 zSLfzDN2KH5d*wSVJ1>k!ZSO&m6>mbaw8D_nC)v#BterNwZXMp8g9ueuIC-;+&uKnu zog=?bky7F6{Q&kpUx#4#P$S2YgqJ%}R>0EC-M*V1Hy-j-?uTB-x)GRiovv1Cx~1Ep z#OvdY+;7ew)qHa!o=OSHY!9p-mj2MN8S|DeyYtR{=b5V2YP9gave~9*WTXzRgEJE( zol`m4RM}6GZmAzxtW72@(d%4JU3zgS^|Zy@;>+=keP_ZLA!G_lF%7DpRo2)~FM8J7 z3d-otq6%)g{$=Z_{aU8z>KW`<4AEX=>Dc$o9h%B)rg2uERm#x`#(In}+{dVYU4hee zGtz!_`@hv;SD5+~{x0N(q9wUnT`5%-Qp3lPBGgIlq$Q@i%ZV07orl0b zs`x*jK8GMYw9SU zLy&8UwO?4Z`9<_5X3ePFVrrs)l};kctXwRObw8`c(G4nt)cL)1JM^E$ZQeeg&s6wc zg1SkS8sN{G(s!klXUPk6Ibxa_cM2a|pUrA>udv1LhV#VLVuWrTdb9*;X6D|>=B&+w z>ejXWUIjDdx5^G_miYQMJi4bk2psAqHLD4SS5u=3Z0243FU^0}yjxib6{jV>ul`&6 zG_H77_b2qbAte>l;-0t5*G=9hi2RIuBn~;>PCfM?O7+?Q08zYF=R`3`MlRUwIabQ{Q&i!3vM!K) zXmxC_?W3xcUzDnL@b;N4a5UD_&tQ{9Y_D~0;)oVixI4WQ?0lgXkL zRf*UTI|3GvGG#f9y5#iKK_01r$_vYbtMI!i;#)fU!$)j4X}T6fp~n!-O+xVC#Jj~qNJ#5-qBPpP_a~=M&}EVHI+W{ zmd;yM*1ktC`kea{I_}&`{ZZ6`%Dfq+j@zJ3JLQ>)6E)F!Iz3E#>~Q&0$9ik)V;0Eun6eU&B>cPC-JYK;HwRozOVU@e6x+JZ6x$MC zTY`1%yyoiM$2-3^eaD;l$M3weV2-%@srg&$5@2TWE22v{n|_);iwzDd-1!%H?ql|l zZ{BL7qenx*K$8h0lZPb$k)+>ui?7BAI}cpcJ0d3Zf^H2gGyv5*S7$X3U9-)W9icx{ zjB2{h(#-R|j;Y1jwEgd$ck<71x1!FlyceYbE-s`Ckz+@bm5xp%6a<&J`y`;nL1!ti zPm|Wz+7Qd!!#dOLoVxZz+ZNlg&Y&@O*fW#a``Ns&?F5IVOEvhtge=+|6 zXz&ds1`c!T@8r*{&H-;pUD93T+k#KU4FvY?d(Oqvv67lk$lwIhNb51laT)8!0$IXlk$<&DY7=V`}8=1)(F(s{bT5Lk)yW-O0_mF!m>9)c7D(hm%q0mc(*Z6U_pN!* zJ~vt8+YdoH(P1&dR+ERa0#2rbxW7R4OKx2N&R{)WT3T`IKSk$xC&79nuTYWM}Hpez<;5`spjdPf1HFNNgD>Jj`j1w+n>oWYiaz%tvb^6rJKBCm&=>J-~tNDR4M zgI1VrsC7Q}U?0f-jPJzzF>9-HZ6&hd0Yw(YmZWQXz5AWhg*3QhLoBJh73Hh-mpi+& zQVW`kRayefyh-z^oIp}k6QjvZGNh$|#wh5Haf>Uhf0mB)^} z7qgX`Uof{cIxojMZ3s+mci%X!NQ~I1*TjGCBTf%!#Ho34`+Ul5f zE?`qm>DJ9K!ni$FlBVi;kb{h>o#L!!)h<=gD&GrI_D$5$dAqUBE^p49@GfC@x(L=8 zPE>bQ)tAPzXT2r6=7@J!A&|m}O5C%Si9m&qc^|}Y)O(72*WY)p{zJcHCf$H=w#tv( z%AM88Iog2(sY+-l;mWED^FJ8`vfn)Lqz&hdP0Ny^-kLLXV>(oI#3RU-$QCuVqWfOKIznQRVnF14^K-+`(2UK z?{$N*LfT;zVerkRDsb$&yDu7H(&(Iax-N^NXtum-#=0(xr>i#8#kJ!^DaT%{+dw)@ z`cL!dQT9$fP{`;{DitNj=HZ?PT!07(Hj>A?bL-~Pk4 z{{XCQx31bkT2?30w>QZa{VP}UJxZEYVEAV0(@E7zdsZW2Ac41978Z;XGIrWFM%zOl z?9i@@qUgFVi>Ie`<4zweIzs-T_MNthKb<(c5;9V$epg?R zZ6W#}k(rdi=pRT&`jcQ=q@-HHJbiC`%o?Wjx?wq!m{{Ub=kUy3! zr+p(%`d!pkdc1c5M>vxY$|3l1(joqX_CH$qL>-inBZp3K?VcuQ2G^v6`pfj-oA3Gu z*Q2(*d-}+Y3+-1uzoWLl_Mi{iG14_*6YW3U3XV@ko|Q-incL#wsbBSX?BP?h#Zkot zeXkaij*+(4M1QK1eKp&xEj~|DyXkqCbnm3nZ8Pn6^bsOnB3>6{HTI*AOR90&ang3$ zAw)VtZYnEe${Veqle%w|Or9;G4!(o-KW_R=`}HWql#n8c6lvQ|PNf~M8jiUe`ycyUKAC<9BZPF;g?VA z*VCes6j3K_I~%f!>$0zLjRd=2ZP64+qxs)d&%Z(Y^(fKD^DH|@3X!x`Ye{BKE%=i! zlL-F+3*y=?Tlk|Ph>q=If=3^pQ~N)$ZKm~+PJu`CC=<5R4%3dmyiK!@(#iaj?KjKg zYVA;|wG2H}#nr7yj&9Bov6M0B#6Ef^9V35|`;qiEBxZZ}I;^fq+=#n5efZZ;UY?Bm zQhuq+Y$dh5IsJD`TlDI>Cl8k$CvB-A+Bmi72IBDCCPQct6FPR9WK8G^&tF{d`@~<3oHvQ zlj@3Y5FNLNX(r4N)sYhtTPxxN+Kf|;7O(OvQUdh9Il7C0~ z^k&lH*tm0CgOGkCpw(~Cw~eziYtj@SU)kG!u{O>>hEt#DPqi4i9yW@hPf8eismo1a z_NQ-{>Kh$s34{kpn`pmPC(3r(w0$1+&WkshXp!10CFtd)-9%dTX4+=j=)4J{(($f{ zL3C0Fqdt(yv?a7J=-`Mx9g&XIk!(&AWIGiM=E}9z9HL2^V)5-9dSVZU9VR8&3$E&h zHpW)PegjXf41Ano#y|3g6x6?vc-(RGZ_ISsy~{IOvHe}O=nEH+mlu$(L3Xb@;c;Z5 z%22V5vPcw60CCM_MP;s7R;75p-A}J zUll61R~;i(Bkbi}-e~E&ppQwT+9A?5+UTbJLj3H@kmUSLdm?tHq+9fN(s$Zs+Gg4j z(&)M-i)+B^+nQ)}nz1tQCW}j=IQ|X)DB0K2}Q(}nTdT!S(>ie2V$oO$;O z_mQ>>CoPyb%T*e(<)T5ys^#OBl}a#15|I1cjXD1SgQaRHqOna$Z}tyucD`$ZSUK$B za(LlWAL>t~3@;(!sQ#Gx?kXVDN~SKN#o54ElyPl_CDW@+>BDMIhPh#xW%4Z&na$k-9iMFCB*$t#w0bjSI3bl^`iwPb7sKo?|E0)TU%>zxMC_ z1)1Ly{5CrqezY?NUm7mp_pvrAX6t7lo-@SFG2h50_Yk?!mg27rUO8*ZYct2M1YB#8 z{{RJKa#fnHZ<=aJc?MERaT&~JNTr8MKSE4yN~~U4aG39kH2R^4L~Q&#k+ZL1w)1qZ^cTNn9VR885$bh>&oZ`H?1+iZynjW^Q& z0PCVSHjWo(cvzH`#SnG$jL@IU7Snt$^yD7}8YuoGjZEEKY?NhRBJ9=j8$)PGo}zEzB(1IclhhV^E1mI2%uUo0$HCdo^f zu>^E8b50S#1%-CL;HFtv51Ha13YZ3X@OL<#3nY0z$Fo9h1^O3w{#G2EIZii;$}EQr z{vv!eKA~2-FrU=Tv~V`7Kw4_UuNISrl!%!Zz~7yLxei0CW^cWzpX|P;`AqvNiN2Ig zq%FTe^%2+fcS55NMsG&?CfazmiV;T+kw@}yHoPRo^q9~V{Tu1qY)p1AFDni9ZqcCQhcn~iE4y1aT}4H zeodLc&-}$3^`gBTiwk_G$K_igTu+h1Qd~zoXy&bIzFMpp>oCb{kJ7#&1t4pe@8agS zG&ZX8d>~~p*sQVPGF7eB%0)N-03A3QGmLtSiopCq`#eLX*W+I%h*1i=CYEOJj5$7C z$es4@_NQ&P`FIcHf65O=eWrab$)9P9OTVXN@1zO`>Y4onQ#RK`!=&T1ar_iWqDL2H zBniRC&kjiBU2X7$>Jj(U*(z}P)1a<)oxgm;jc`&20%c0 zMogxgZLs=(LsPO3^;ovvj9N@vUDuP-$b__rw7adZA_=}Dkw+qo&^i}q{>~e6kL2P< z8Yuq&UmEeI4SE;VmR5h#g}-Q9PyYbm{^V2r&-R<|`+gO=_Awd$H3Zdv;+XGuki+sk zt&!uLnJf&MpJ;||n zI{br~T(vGMmWe$d;)$ujOs?EIFjyIYs%7kmN6Pa8A!b6FO?-#Pp?y1TTn#4ssB5C{ z=mwegyR!0nxoi4HgtUfDDCbem$Yy6iTlHk^=m8Mc`B|(MGnY$^LCCR}nN#a!udZWnA&C#QP0R0KW+4j>e&2m=Bw#wY+zTfQyd8`~jP&uZdKO zZ>hh^7jIp^^!y=J zK~fm%9VW+6scHFVJ+vQ={{SK5qsMc;Z^ta*xy>A_SgC7{TE=qxZfYElaj6}sV4+gv zG;Z>q5oZ0ExVJmSDv|RF#g3XX4o{aF6LRckW>B%2IwL+?l4m92JY2ZFToz~ES9m4? zs3St!Oxs^c-JLV-5&ECdDARp<3#Kg~m*izb zHrhE87SlhcRBiDqY3tUWUMvIQ_+X^%zPZ%5upizWi$wTaxp-MUOl$KF4S9vvFLH!*Iww2=scJVnYxb`8*;pVKcnW=G? zxfU`TmFLA`=6Ja7F0s(b|A~bB%WLzG318Vj&1i0+_}7YzbbHF@~XyItozP6et#4>LFaWp zkj!Oqao{HE?q5argXO&+>ce^g=umX_bqzpqrlfPOt&U(;!g44(lxDCcEVWVCf? z!$GXy5K>%-5y5~s+mZqY~~jon5}M|iZe=DQ$F5z zQ$MJEe@qYm0NmyLh4D0Tc~i?|xg0}6S;^umx!K}W^RP&}Mon@7?U}+KY$kTj9qoq; zypN9I*#(NBJhw2|wt|-zCYuk0 zvy*Td*!XN*az-O6Y3fx#b^4T(U6XxUe1uSozM&cO69*cPG6nKmk(b`0+p z$zrFl&I(mG%HPTo(r4fF#&`Z(@^$YvTOXI34Rbn8ki$2&okeC~lDNEsKuja{?JAQL z1{S!q5;XiR^)CyMP7HiBE@WZ$`C7G_Na#fuFX3U1Y-;0tQ1Z_Yv&W2FX2hzdIpT-P z(ox7o*>SvPWb1gw@JwAMx?wbJNW}V}Rm0VDYcUR*L1$Cq~>EPsTYY0}I!U zTl~9!+~rfY(SN6Xp!@VDb#b3x{=?`YCGCwhXCyS)dr?6MGHAt?nQ6T?wkxpu>6V-O z;P|tXPjCcgwB|=>8BI5TNTDkaR=0^5(Tcpeyey9Vo;Vdej8I1u6|&Ghdl>29%Ly!R zCb3jw%KzOn4#m@yw%w-*vy6a=f6y?N0E$MA(^XR75vs_&NZU4Psr~|@nCOC>j}wb zuX?vCwo%^al`+yy&)_h-`4^6g7 zv3VuP@iutgSKR`8YuyriWWOK8^thda}Pr{ox*hZT*JBOu#q(Bmh}<{oP} zEPZU<9%6BZ3WpKoyplYp9LQj@65&d9PZys7Dm;!zhlU4-Es&EHh)?#P=>CiBeuwE3 zDH~|^#0C0{T0cDU%i%My$$quC zGaSa|D6FiN4;=hz8_>sq#AEGA)Bt%`55e-da^HvYl-xfba%dJ;37E&WYc*!ANsd_w z7vG__(YBENi~1MQwv_FsAEp)KT^(tbnQ4~AY*9!nvo7q6nLFtM>+#XoQcduf*-*yV z$;}Kja1h_hOJI-6OGKH!VHF^-o5{8*S}>AZS5!DdmN>C6MAAW_oLSmP+hQaI0Y{nv zu|pl3%u{x+!{E*0uW`%nMUsKRay)s%C*#g_M=e(&9GrhKk~u8b{DpxGUSLGE(boaP ze=W)`TYI@59@`?))-ETPd2zO`_m1q{&RU~0L0Um7c6&w}UDK=1{yL=AB*)i^qsNvh zTJ>2bdpNHHL64GUis8>7$HJki7)&HMn|7_`t_x%0$6BXz^y7dvao%w>WX_~5HcD*| zE4=JEIR;x21E>8{{rW%Ls9#PloR-~5@-|!LiJ5|COnY6~cV*p|kvzIyXI^JRGw;ml z=4Vb$>Nj*RwrwXJvx~CCVl8HEO!Y8S?qrV9#}N`}99mHL)}Bc`Y*ThcAh5wkYp)Gh zpcnZ>DsQR9NXUNU{Y#MIFgXlWEVT=&Ex1h+<1JRR9#);021z+aGEN`Jc(*I7%Q5(y zlVdJ(4mcT17mG=u+Wc;C(4$s(_XbxS@@8znRk(gYqj{|UmZjMVAs}~!+0-Ta?a+LM z;eAa$Lm!Z)_wGTBrP3c}zlw;S_jn5@ozB!%`F?-PhdiB|7rBiLV@;9BX01{xm8(?C z#!&`LT{Y2C+!h?g-N(xA?Z{l5;Vn!qKhgB1U73)Q`}8MPy(`$+1G?$@s7XUN+>XU6gQ43XM8@pe_>XGI={V$+5U2Ha*)V2Uu|!+p-lq5mduN;WwYj6rb&ozMKoHi?fGkQ5M2Te1rc0 z9fnz9qXlT-kM$w_XMHP>w6Ntc=RB~7@wTzyn#k3QkJhaf>~wg%1>z{GLzT5usq#jV zq0TX>a>3)wW#6uD!`P9kF)v`QW0bI%K8bD7eHkMA7h84N(Lcoh0OXzbPEN`P*z7$W z^o_x7Gqd_WmX`LLtEOhqwvdwSMd&kMQ@YA4e>pwNnmw9MOH++W)@ZEZlZfmdQwWB!uK0qRMRJ&dC39o z;%)kNWJWAJ6OKn*c^+@fdwI@`T}HV9FCS&#Fnr~G#|;x@)2iA7(qnCQXGGqTK9uf{ z{ULQG^^3}TMROhQAdAabn!7p|+Zt6)Lm$_cy1dd*hwHvz>8fN`ewgg*-U`vo(&*nPC zV!>IGwIl6TxW*zd#)ZW@Hm}$jEVHpE72{fVe)>RbZ|NqK#LSGJenlE_*x3y(JIEe| zjOGqIKoJ~|`dMBnV3!#nU5l4Od}onktU;Z{(!Y0s@;J83^=U7OZMW%`M6xufuQS&LA`1%er@duDdONOhCO_C<@AR%;L^(|VJsK98#x z%PNw$l9b}U2}~MkVkInY$@Z(B$QO+;Y1rFS+GZRUGT$W1+V*%1Zc5qBC3@c!;ARjO z*^6jC^BEs$3?}aJlX{kuvLB)P*Xt^^`lEN-eZ%s#@nya&{2vVDAGTW>&o#gugh;r^N#dES_*tCnv~%5D?4VMtUF7(E~at z*T}ByjkbtZGc_(ei=Tv1e~vE^E^vl%kiE*)dsD<12$YW9A4S3bjC{{UcSQT@VS4qp@383&bEdmM%-Iv}uu zaZF6>?GQ7f7iHOzX*(hfq8%WW!41#U?rpec0!~et$6{$?Dq6+IXfA#y<5o-Z{{Z4B zaJA$I5=M=`X=h?8qB1Ig%rNgn+e8~g4C5>fu#K4mX$l5(@f?N6FmQ2dnXJ#bk&oUT zD%~Wh8)##6?V9F@?w;km5aWG@+Ru{;^ zvssst0&AO7TGICho@ZWF?pz!#u_z6lL~iI$O1%A47Mzq5StpKpK%%t7-z1%7BPlW@ z4lB7b3l1t7+>px2iJcHLqmN11+tKuk<=j#2kxBFTFrn<)bsK)A_s+5K-Q(Ganhb(A zeH)K$6343I$U1157LhWUGjGwfg|<9LkF|Jn8BloRbF*R^%nec!c7#p`50R9cu14fk z;44RvwHofs%_}CAosafI87ku9qV=`+ZIOSVQ2oX`XkbWz# zm^`GIY-42al=dxHB5)!l1coI{VFgmybsL&o%M{1P;pH)0uOWe(c@dVAmt^ith~3m8 z?(ytY2J8Y#p!psV5orD%H)lZyXzL%x{{UG?S+Y_IDt9=4@PWnXb z>Q>qp*Q0H4zB>6yb9phYva+?6UI{A6UNd(W@r)8NyFZCYdmd+)_ZDe4=rZY~wEFkE zde)uPFWmr&0d2R!?CZD1f>z6JU+JAx{fIsUnVqhO#i7!Z;r{>{a=_v|qc6xM%T>rz zu;FsNmCWnfn1&IInYuWMNKs_0*_Q*3j+k_5qUD$~!sT}9=P*H+y&8`{F8=`G-u8@` zF9WrGI{`X{iEWuu(5T6fzSUO!;}%-z>`Rcd#h%!@AyQRE88hw0!odoP*4LerO3vMd z(X%5LW&5%u`cg84&rb@q*v zq~fHo;;h?ca>%2YyA<*+&bI665h)vGGKcP?=o$8nJG-vTr6s+rW;(`J^$JwDz9%t# z>`2-(s~2Shvkh&LNeJrCK!0-(E=PQ_xf;;3CX3=ook>NFJ~K}YJszZ*c%<6wyE5#_ z$yq07v_vdq+XH1xwDBVPx%F^aT=+q?AHw`&%4=t7X5QXn-czhcHDj2er4SJQs7AOHySn_B#@*DyMv60EI^nfSzX-@eB(kBR>?^w=FC@^L_ohv#U$U+KY7gls{*a3N zm5q0W+1t_fo!!%mIygbpkUB9YO($0yiaSh8&grBVR}GcBF3Qc%nPhTAiO~N5lunEM zo0n;nOiWe9`UslQF0P0Tk%2mnGbuP{93DGEFLm8nk>z@k1E?Zq0TN@bKlRD}FSu_# zed*GK6>v-|#)iLVmZ54&ogOIkP1nVO2-=uFk!Uf^msBt>FstKRjnrv5#d1Z;J3YR*OlZ7ex~? zD7KCbHv;1#=atDYHT#lGVYezkETa})CU%HOQ9i>Zwk*}}dwLzJpK3PRj9C~9wD4^; za@-N=x~Ka^^=IzmvoU?Q-9Ltn7u5T8X|&d3_!$0J6N2J=-sTl$F%ioSLlV`@qjoK6 zMxl|#W9|Z{J882R?%cNt2O-4J{A6nz#rW1SW8-7ST!u1P%EGL{`^ysuL>&UNZc1XR z1FEC{0Aw5KC(E(^Ot6`4M-sC_39sTn+oN*VX0Oxs9BYC&hFy+XkFdY{q))q*P%C|eNX@3Kckq+16UQ6#_&0?I|?N1RrBx;5eCwRvOVKvLc4UE0naW%N7 zf>)N|yMei^eNR1-e+6r{drOoiXUIFpEQQ=jnUxlAB`{`HLAt`)dhYHPUD=}f6m0!+ zu(OXR7RT7*QjN^8#rpDC;HPFxVCA^9(&?^QeO#Gs-_y2{Ncv7svPAy?1n;&b*^xiV zeVKfV=~X5dhDJh5v-$%(hl-?V(UA?`Bq z_1sFUEixUbeKgmaYleu$+EDE;oip zfx<@8VYvQ-6%}dV=2*M6u4HRuGkE(l+{(5`!Ab4=UN^&?=f9fDJ;`vE9t8~VB7obr z5u433i&x7_G!jXc8xN7~DrP?=plDV!J3S%np!iYzsP+wI_5&=is^(IukK-Xb66baW zt9~}Epca20DVgkNdN^Uv@x<0*&(gn#w<)u(&X}1$-)2ruozW5}ZIL$i-i7{DjQxq3 zlv66wexV}ruPWh~&n=9--9B2V9E)o99YD}H)*DYc*QpX>X z82)QxpFAMKUSb*roXF$%%G#+1)xaea!C3rP8f4^$A&d zSyzs0mSch}PBS@osZDRsBq9326DraEpc+H5eY>Y}bK{U#YwYYBGqAZR~9k1kx z$R}rff9^ma#h4pQzP%i35N$;e zsDYGmppWH+wIh5=pV5}xLqgoDJY6`1aun(`nWA=kMsFp#cP*0$+J4|}@)Xq!6Em)^ zhv+`OmO^tPvw7&pA-7C6#*q(P1d(~L-;v;*>~J_gPC-&Ez8XsNK_wHl+NRyb+7%_8 zgww`kbeNwRG_b3=0$`>Odd(`mSO5hyvBrxf)p=!@n3R&nDtQ(AY%M~swu>>Vx9C@D z@bf#v4G+cm`6I_=C-?cfQHWE&JIP=~q`Uc>5tqyUKeYv_Q#n4|lalQ2yE^Rdo8b$l zFOJNdn@&%=Pp}eI-(U&=l6h*=3^pG*c$Q91#f}LXRn~(})&3j{;2l{d%CavMF*eFI z0*ynaQ6|!TYEPE-c4R|_vQ8LkqTbgH5{l&*+Dk7wlC#RE#b|tUL~P>A^-}gWtx3#U zkj7FD(N>Mru$*W=WX+{Co;HeRmfYgx7K8roJziOcDkCN?H(|tnKnG}l8~*_2G1z>M zj^1Xg!EPbY*O2=QZ5(?;jSl|A5Wpa&7nrEyhA@cwvuv08L;4{-uPY;wWG<9VPO&*; zWReIt`xTy7zYHAvAXbi)LxsR``o(V6PEo5`zTUqaZ*ewC)5u)IgK_?2ism1Jvz&Vt z{{T<@dphpxyRPZUT73`y0CoQWz_!=pj8DGJW~_K&%UWYo#${%tWzsI{uX0h4G6MKx zW>>B=23p};p1C`imX&AyE0=PPt%6a&5RbXXM2jh07xOOb-r12frscOHI9*&4uJgS2Y|V|5y0MNRvvwyN+%!a* z7Iwyi>le?YXUG04kXpT>@bY}cZ#aD@+1cxPR+9sZguqWPS78AcWW5DcRBzNbiXxyO z-7VeSh@jHl!w}L8F~CSlBT~}crNGcb$)yNWTF=Z{=gh2` zv*WkpiM@j^Xq$_8gjBfK_*>c|FL+xPJaV+t_&y3{bYY8U1$CJcH zyq!i*LaSSZ~MYF9M0~u(E4z>go z9CgsIbwqx5#R+B3z>#wm@|#S^HrJ*=ltdsQ-#EeOaC&L^<$}5~AP3g-@GM%CU#FH)ZdD@<&54t&nlNiKUNBc1DraBY6X?4os9(r3VDywwT%G^$ zEUMSS+_0_BwJfkDFza16W29QqTQjV-zW6AM+52q9J~6!cb;L_wB?>dzk%Cjp4w}G? z)7g?G4c$J-kwKdlHz>DBp)BbP-O9Nw9-R-U6l-uFoo~wkc_*KirZUyLt&x`8u+Nk= z<~gdtRuhm-clwef1k*j7fseHx?(p?vAf1zrsih|t5#|{ zaMUR3RX7p3up<`(!5?d9NG8O7lui0Gk9w?Ko?GT<`E)~#n6STC--RWUI= z^}rCPl{=b#xN$XwXwbD#^@bj6@4!^ZBwz7u+P=$kFK) z=q`)lIFscaskWZ1{wK~%K^v2S%mK{9M^+R0uprpmn+b#rJnj<5Zl~K#mDHj%xlKIrH#3uUr(>< zr8~mWgZ`r3o+kL(J?m`?XZk)}t$zSn5_i2T!c5I2>PuZ_48EWbq0->!{<%M??L>dm zcX2GH1#%VX1P|}AQ$3yMNX%3gXuPTsk$^Y^D2n`ejz#ce>f+4db+=y>yLh$%_E(O9 zA=fwq+8#R~35_mVv>LOaJ3ruxvw$j2V%b?w5MQYAlS-@?Qq5bI?&c1Fiwq(ra(*N={@-Lcxo)Sh9&Q8@|H2Q107E_Vw-^#>Aok@Qz z&22>{_-ydPVZ~ceQOH+A52pLfQbH#5`^&)oRTvBQ32`T}fH{jR$k&Fydh{Ca)n?{1 zNWnp=QK^)UPOlkzZ{2~SY7c2eUOoGI+Es-}cs0tgPLFMr{|An8LLMC(yN#R1R`1a; zpI0z2i9JD2=1dvw-=~g!PkIW7FLX zozQLxI#X^|>af+w#R;}ps$ZY^Txg9h_eZh@e%I`S1?uO{TUc$Flv@1a24SCIi95`* z3#8l+e0fK^cl7a>9*YupPM$j7UZUuiaomq*pYPU9JV_Gq3%47iw0}`5Ol$SXFvIMs#gBlr9xZD8xUh`&ruisT2a#F@;-Gf433w9Q@>choE}IoKYZ2w z3s?qIzPG*&zVDXQtBC63HmkQxA`xM=(>ESoGktG+M33(jahaCa4GlhZ(004N^(%6vvWoitUInOtFmQ?++Wo$Q;T_U>Sl_&A zZtvRIW$k$CcN&*#issttrW)Z&<4iQTDN42fleeGYgZhUNjpRTqK zVP$tx(g|P}C_#_4pjTfxvo6Ie)p-b#nl&Rwc&y6ZRB7G9wQVZ$hzO{2-pHNnRAU5z ze5<>l#f2Zlh!km>t4p--&oH}XNvJrtRIx$q%wwK&yS`EHFN*$PBoiE0n^@WN#pMl_ z{eD7lT_!KvPb?;}+(Uwx-)j#6ebHgA-!~0|W#T=9CBzO`mtL&r!Q+#P+{6?Iy-Tpw zUCnf_S)NI|*I z>#i~L@aiwxNrm)uf^Z(eUcU#5aUOp>$+3g;SbI}SROvHLnWvG$SFOORH-cCnKJ~A= zNQ*^di!4qkskL=SEfTZud%{E~h6;(Po3d(8s3sC&p><>M#R=&N=>#f!j0Cl3Ifc&A z***=1w%R>RGFMOYljHS8)pghUo>+-QdvUUXrYdrZFYa@8R$ovxO3)8_43r*k8yCH# zF_jkT4+rrW5DgjqOet8TxoS<{rPEGhp`HorQOE?@m&~j-eQ(g&=#4yW8N#TVruT?_ z9v~UCvH9)8f@)&pRQd6+nyX?`%iZwV?!b@`kPO#JPKJ@oNPqBc+BKp+!$;weq{g<1F=8mfV;^ZDF{z1*SYfjf%c4q6}2hE9*Z0{(A zwig$F?)%*cIeGYR2T zZsuT7h~wV*xz%fiYCf_oHs}vvGmCd6xtd4g83|=a$go&qPGuV$SN!#mogAKIYb$*UbBc^!adY2df9Po~iIY{*Lc+d!7@co=7^C{t*OC46Wsy}z~AmZw#= zi~mPh55yL4iG0kKrY%*HoYIYmWVW?up*W^`X4ye#E{a|abJUFul=krjW^~}q=Bc-< z$Lyz&!$Q~nFwk<&&sk4~TzN1&;Y(nI^`ESZz&pj`O=OVVpY|#3xzHB=`h|Pi`p;VR zKkM(&+j11%opaTjXgTy>_ljJhoR)wqd-V zBp(nXfl}Z|LxVTxHm0_oAyPO@=KJ$L@)TRqQZtc3EZ2EMrbodupVI~EJ+FocrP94E zK7=JT_sMRS_t97)d)DlBZOZFBPG;iP`R~|UX@W(CL~ul(=J(Ia;EHpZ+l_FHu?kW1 zY@ej{5u0wz9P3|gzNnqv8`?Ktzt(*2M~=I1Z*xL_zUfSg{geDSrP?c~oKAf7Y_uq{CSK{;cUA}`KC#e6(*boOKB-MuLK?H@!(!{&M8CK!5 z*?DRkC*{B_iC*CTynUZ(SZH1FxTrh5TVTYz_QEX!*gQ=c(08tT*D!Pc<{|Gu&Z(}e zaNc@gNMo6Wv3{h*zFp-T=bwNI$!cDe8DSd*dhe`UGHRmflf^Gf?EHDx!Pp_5Y|#vn zTIf+EW42gmuPXet*(;tPG&$14ua;ClCLy8UB;J_#TG{j|t0r47RA{x?_Qd_rXN_I# zM7}b+Ww|`B7G_TPt<@(AbXcM722~k|S(L}X?gPaVTE!Rqqmz24iceJgs@GLEt_8&^ zXCB1pBYInTX#6oEX0tmC=}S(`<`ayj>qeKcr?v+sxyB|HK%S8x7Oftj!h((^Zw+zL zwLJ0~o%oJiknOx|?y0ezqUKJ($3=f8Cq-({Pbb(e(rW0ac;aZf2RqoTzFoy$JlxKu~;Q8gk%QN?>^YC3|ms!w0Mip z4}RYuCrWR<@>k^9R(UC*%|HiLTtKa1G(m{cNZ%|u=&?cZEvHQaBpJuRLnaj6W|v&C_dp#n{ZcIhz4`gPcv*XN@tC(VO6Ht3>qceRc+^YBKtb=TV0ZWKcM z0E{+&u@}0C%5#$(k#Sbd$tc%bzi!~zO`XA)#cARkUF8p`l;M1WHX0(C4^$EEHf8v8 zsu&1Xd_!!HbtcSHM;H7cdjLDN@7Qg;S7E!i^!L&Gizag)ejRns=9EvLTdoZ3Pv3!G zQCq=n5C5V~$!p_ZSI^mH{YBHd3%4jwTkCH*;o`q&usB z0%(NVBO-;o@+`t^3#kZyi7`%xXj-KnHp9Qwv1h9_ZBj+V(B|_wRPG&`DJ16(keBVFwbCaX}9LK zC(sh&(((1)R)n5u^P<)3(?GlGOPvokB%w=8R~7e@utH1~Rsr#48(`%waMk$9rPo*m zPjyhsQ=ogrODAHd$0R&R>VTSteZK3)W%$o)`V)GedHoyM*|N6SkmzEc+8XkgSYJWx zw44*%(GwBd##t6q4;e=ujU2;Ho)FEA1n}T)V&XFy^VWtec2SG@k{G-PUbY$b_G-t@H7!<(1L^3zI<-q2=D`8o&xLK6+$l9WGe<6x^6_X()@ z;@7&|?h|1vxLK^}#W&^WAji3H`_Oe>DVL7}+5JNz&adKrjB}3+>5wp}R)|HG|MECf$+ow*s+SUI~p93wQWga1}&ro+a&;Wmt4(Z&@ ztz*#IG3d8X`TJM;jdTUZK|3OibY+a)vB^u2tGpV4g{8C3-bK#7DF=H>W%42j>opRd zT=IAt2^%XaNM5JX$%?vfR*olhfQ&kOGG{y@Yug2WnR@HV+_L#`czr!mDT4=bSTBt4 z@8qz6uTNjPvO1#OZ+c@i|4wLr(KxP0M=`PY@*sapw5^osDa>~gnmx0v^u-j1mLcV@VJi0PbMr;*F6+pyN^@EyCm?MGc^qQR(2mBlJO85H zQz^$AO#`-e<=?Ah`PCV+B~dwX&h(smt34GdX$w#T{0=afz4;J_H50Fp$Wo>Of0%!* z^lRKIT>N-LL!)kft>TGRQtHgJnPHB|LGQZWG)jThS!bZ@Z`-<}t47xd?+238Ma@ z9sT#Zy9qRsO!ST0g2m0$ePCXiF6s;u8@ti9_|6O|GAWFG$#jBkC>Ujw@(t&{OflQ^ z0@1fxtQI!i;e&^}kQX_wq;o;urm%770(D9_F;-n_*qYED$h|QF@bnvJt}qTbdVE{$ z9oGe+9}OT4r_5d2iKK3zTEKtDY>0bm*1n(OW^^{5`otJYlYibWop*UM-*G4M{~M$o zccdNCnXK!%Ezi4&1dskl@$9HS`s@e3^a~gUWHDjkM&vf}`cb`ucJiYM%XV!j=P_h~-{ zJvS8~wlI#N?NGbK&C&_%D9-C=QjQ<`Xe=&;*&FvTV22lilzJn7=wH#4I*oM3GV-cL z9Qr`2+mw0Yub_ed23O#f?rXp~+C<1)A$jaUd_G3L!RKyB5ym9{S+ zFSC{!Om8eK6{h_1QHaB4fZ_W}r%_8&jd%M0ub~$eZ*)0xyd~CF{-1^dpXo&aw`OU) z4-p6ebBOQfdQpiB52yf~dVGN^#~7hRKByjnS6ozz#fzJ+)eU7bS>L4up)AwM;}GLz zYiJI_7f^Yfp7>>v?KE+Kin81n<$lxieDRZ8+}39g=iE!^|Ecl8|7h(0zLRxfjAh17 z;0{PM{`#3P(a2hj&WFAHK(+ipkBY4@u9{0ffcY?SG`N_tBg>eVSfO(b^pT08`qh-B z4BWha!n+9w-^*OJFjO1tFB&cr0Q1Fuanaekp^HRmReRrwf!t11{ft!KiV~pt|3ksq zUo;UCX{OZ;u=U`Usa{0(+{J(5(a1mnY|=|u7i+t%XiDReGL%2mnAOY7BTQwLaPTv< zo3Y*=TUHmf;YyvJZM*|eEknKyaQ?P-z(6}l zS%VX@L>101bEVd)0g9u}3e`SMw=sM796Brn@Qwe^oiy^ci&pP_1VWQe73zDReTO-z zwjMQ?C>4^m^yQSQi`SMZEamw>O8=AAms7v`e`f6;T>)q9vf$uU#sJdD)SvY^61UqM z!#q&`n#}I#R`wm`T?OhyH2VqE1fcMBhaqS$B*@U#@IdN2Q?ksMevH;tGGQ(=eMM%ujCe!Z!bHTmWK)Bjqv z`@kMRX!yI@?E#YAox_!x}mj|oV%xX>nq!Hczx?YlQ6>?4a7S4Gc^OTKOb^tlmW0nf0 zBp%G?w|e1%VgKY3nQmj*>a-Zu|1$A<^!s+6r-o7AipH9xsLd6qg&_!@;Ch^&RTm-2 zeoO9{kcQmXx~NWkHyg#;>J}Io`uepjWMnb#V82ERW2f7N>`?Q&qui)c8$y}xt@@!) z@GxMcOh&sF!8U+`850WSik6rk=aF+R1aNwt;4GcRB`-BR?0BQ&7%eV9*gAi$-$RLG z*T@dH0*^I$ZR(BvYr=J4y|E#&SpeMW>kVB?Od62?^xOF|_vPIA{|Cs)#&IsIqtk6= zQo@NN&DzCg$6)$xuURs~!j`-)@KTrhK&)u(9jDp0EW?o%iNtLoc>3Y_KF&;Y++g*6 z>`S4Y&6|P7<&+rpt!w}2gsmX6TKH6nlB3D^48S; z)-^o$nQb=uVogV$$!Irq^5-D-$A%HD3$?@oX}|hQJ+BX+)I!BFu8)vn=F+T$+^*na zLgx<2v_}!4gLUplDyhvb6}4_EHJdyd*J$JC%o+P|MD72`TXJqHtm|D|T#7vAUTQmZ zlFkZ%ZKIB2{L(pop7oOHspy*kkq(-k@;a9&3R`rs(ZU`Wy|l58YWh4+`(kU~nu0I2 z&5GPY(uIO5wNXR+&eCnEde240tR9YO!(X(plyB^!YfIj)^(GB)$046Rb}PRlLeXuXnizJ42yn96S>x$+lIr9+`@*{=~s!{X{$rw{p_V>D`^f3*=KYUMm;Qr z&DqzbG;)0L5mhVBInZyuS{EUzK;3eSJEojN)sHEI2Y2R^v~evuE{q$EAnHe9(bB1H zR_}ja3Ht8hhsFtT^Feiim&m!Z9pgOjHTWxrCBBC|Hnc8XUqc0w=e#KeNop{TvU!x zcPzkhE2MQbvfTFoE=E3AyP?$ta{vv0ML;hh7kQk@u9YUp8UL?Tp%H1ktuRIMaQbr$ z&p3xZO7?Kk94N6ww~93rAKy2t6jc|bXK=P$gjLHR59-TNcHA0|&V_Y)=#rdYD?}$~^~$V(p`W_E@phf0FjqC1ywV?XM$Y+X>dQsmX=`Df&}i6xNx%5?wySZ&t$K>FM~>&d12u0s|%?Xc%Pls-~!`1Od6c*3{$ zMWU$SWmV(WfPvPLNijBT|#!@3KmwIm-Vt*v>c-)l{)NBq5ndYRl zmZ#?Y7cv3rop8FWYE-CpfkMGM$+7yNdNrF8u#J*6ASCn&KGciI2;47?thVObs8EUC zV%$}V9_AH9kv%pT(e8^+@*QV&^+1-10|xdiB7N=xF>tP{t-Gy$*PX4=#_7 zvqX)0mRNaO$8-t?r}^N*Eb^zO8O}U!1#u5$o7!omM=hG0B$ulEef9+BQ()mL%f99Q zlc3AR*9+*=`#R=j7^TL7VY@XOgFUWGllvJ_?@OiTi3d69*=D221_CL6bE?rX<0#V| zh|xLpc^YS6?pUy2Q8^EGe^jDG92DMwNL$~NR`%}ydS3PTtC=!RkICY!L3n&8R^D(~VjwH{tA~^_&VXc~Ul~vt0i+_G zuJ3-<;LA!^oCKeJyk9tkZzwVEU=N&zbOys3S*#~?*SAt~t8YQ5`eac$wr1wXOH|AF z>Hsq^f77*{zQ9Bw{%otb;mli`zNv-T1sF4*?rE~fu3zVUOXQYID)>F^)h`~a2*;b* zSJQr#6!Ecpqq1s<4?o7@y|{Z2G0P&>=W~&mI2UeX_)!8v%ofPg_XcnqjOcTbF%P*m z0B2{YeyqCqXGr!@NLa6(l)O~7jUe-il3@xUCE93OUxq8P8sF}C_l0X9nYQXvk^kWb zb>{=!rXs+ol9h%)9H&dfk#Ap*`B0R9<5e}Pp)AH`QU_&nFioMRXQ|hv#3wmmWp(WVret z$n-$R3*Q0=Li{+_{i+J>O%Pf(HY$kzD!FtnB)DFKR2UG9mwKLukmgu{J#A<&?3Mb0 zg_?}Zo{J^@RK8h~yT+yxKI|#9v0;2X-mnJeZi+$F%(J&iWrQMRbIvM%rdYKAsboyf z&Pj#PkdWtLKolZUt{HGMJ7dyKm? zXa)S7_qL9nAWt8MI?>y=WX&?Gi$2O_g>6|=xJWNndpf5Q6vV=#+C-nJeN&Lt3*Stpcja_oT4+z^qd>>MM&1xETb(l2BB&P%W0jg zMEDh5lwR>rKDQ>+86jgJbZWNV^nTFWDc^{f=}f4lo;#;wSIg%Qb=_6E#>-D4Ds@ZF zym7d}-%^jn(jI5)}(_A>nln$ z+2W}mSr>jV(&R5`%fHl|!G;MGozjL0YEns3`+tm1@I`GmM=pwK%Yo;MP{|yO3tRz` zI@z7)^vQYv=*%Pzeyo?g$^%x-sF|ai8bymuiu&5-DZ4)pq8_yI07mfM~)~ zkyO_04IM57ZF^pNKeN7eVE2gId^QffSVDW9D|or$tS{I_R3J<2br?X;iE|e7sNVpg z{>e$Yj_QW9Z{N@StSIu8m@LQTWW#5i=-FlvIT2~*aV{yzs-+$WDU%@D+q5eXJ+A1B zI6V0^PoS^-Bn3J((H@2IvMe6P35t(^@sTUpbPC646T$+S&Y1|UD^~a3)#YgSPw-W* zY)YMCe!`de$wsVghtIA>Y z%Y=t*Qs$tO6QooS&*)hqR$Dl>JGa)2gOn*1#jNey*|2PJR2)~G4i44p&g(1@jb}K{ z$SMH`2F-NSOp)7Qo;T-mNeHR^mBl}>(P~{)ScMqb27sA{u3{?jcXw$JZ1G(+E2j6s z-4YYMdM25WL0)K`fN0m1d`ypKt5tdq$F$&F11>6&5s`U6;1_Pq)GO)?q@ZHycXplA ztao-%L@&bxmBj$|D9SqL^dx@A(Gnw{d4SBWoJXB~4fif&keGaz5G8;J?Hf(2u6h1Q zuvjf0~`&+q&Rc)xNp|4GlTm*TWv;u z6zPQZwamLdv7R^pGF6=LG>y8?!(SrzvLzg+X`bGf9#MAE)Q_h)eoh` z9D<)wKW8EGI!)-hn-qE$Y^XG(^VQclMao;^xN1E2b?TN+yfkB{P!b=NiE#dP?l~33 zp+8Jxg~!ErGOM(L#+XM~seXZ)xVQR8u(Xwr+L!Fk)fz)=-ZxOKZVtJ^BZ_3K^x7H3%MQUOOP20<|G#c zp{1h6c66c-=+AjRw-eIj5for_0VQ*8T}?~Bw!s#du1lxWJ9Fh)k2WhzVYvMFJ#=6t z8Z9_fN^L)LHL^C#=!}17O@Jyhm)VfQrr?tyo(o)kHuYvG3iP5vC%FN8DK;6;!Y6#- zR0J#jlI;{f{Jn$IyDIi>)fD+i9OyB?_wX*sfo|x#9NeF!CUd|Bh1pj*Hjo#%^3H43 zWGlKWA3{|K>UY5*jsjXs-1l6#Qs&a`Qe~8DRyI0ZgfF_*hjDB>u+vB?6hB7hIzng?DAW(C*E@BqT|p9Zs{mMnTapc=}SG3=2J|OskLT!ex>VSr4J*Fj z2O#Mln{YQ-TX<{-u4_ftH$1L7FXA$ZBtV2&tX2du;kq~{UzPlF%O$z`lf8-U7(l=D zrJG3s52$jqBy8a2_KDkP+U`PgybvbFuihIfhVXwj|ozx^W0XBdA#yNIK40cZTsQ8=;n|&byxTM^gDUWM7>1DVs+@V zm`6$p>VMIe{jp3G)4GzX+G~`PSidZt37h-qrT3qFshL>0-z5|rNk)@zo-`t^E^ElF zo^f|Yks%u=mC5J@JldTwIz}{et-c;b20d|{AAybMF`PtEb!V3~OdBPL?f#zlPGp|Qs$Z#|MMu(?(OEWf12-3!9_qun;HNb2Mp5cO%@CYlmW4lm4 z^xL2iAFj+VRqVlHj}_^g{jc$)teb1sbHfBClZoNRkCbMO?n-T*J7Ny_g{9A!1_#59 zo7zp20YJ%6@e=`=;YzW3xnStyJ{3T;#^-;a?#A6bR>rA2T<;g<0n?^lQ=p<`^!;~( zL-d8r-6S8gN=rOiMeWdetb*s7ap|1SXT{r-3SN>PE0K)rxf%vpO3a9xqY7scQk96; z4ZQ9GT|^&n*bQjr-&AdZ#9?I#A<+_5xB;T@gxY9?g)Eg7C0>1rKooBX%~V|H;mmd^ zsq|y7LUv;QOWneR?21$o{KT&pfrl+E%r4iW7|i%sAyuHi>A=kXOvb19wocm^0Ot+w>FlhN zYDf+&CCx1EqVz>pS4Pm?Uo@n!BX;TSlr-f}hG9G?YP)>)I`nWnAvn9NzNoXgtRB!` z8XKHcDJaRx<7MMZU$VwP2u%lwd zN`O;und8H8bbH>RR{U>zHq6U?;&4dV1f?zA;VBl!{B5GF5+CI;G-;u@j6GzQ?nuui z2A1Tq^O<21K#p8IVUvy(xUlK3#mRy*=sMo&Y}p^ghQ{M&03iARbJ62j!^?Ch!;gcU zNpQcv@|4MG7q&r5NZ!_Z!+gOmeqKR`)=ARX#!EOW%NpqgB7Z6!N9HX0U|$^*_>8PV z3Wu^~$HB5lVdx9sJK_X|ni+ukC(?+$3bmOgYi|WvCte?X59OI_YaZ&Eai;FCG@BN8 zlUHG}Z}V^T_*$a{E>VO|kPYRC&ZLAhpG8*n)sI8(;E#tQxycVAO@}hIGX=}qfhAf8 z+AJyUs|?Mp`_eF7tL475SnfNJCPp|l@KLAX`(p)ij?dsbuZd5CdLUMb>Pot4GY|8v zF#?Eao25&F+@GR7Gr0Mi^hNiY{7_Pu?)_pyKLPc)cmPR;$S9vqg@zQDmCg^IJ2jrc z9Bgo=TF7kAp(=@XDU06%yY29_n5jNjt<9i=vNHNB#m{og1b#-K?c_t{qc)06Gmd(r zgirkK_3(Jh6vjA(xOeuLde~l(DH(b;YhX3gO$GZmHDuQddNUQ$0&<=fPTqk&gl{to zpoc{%)-FVOO!;}l%<4Ab2hRE0`dBEhAh;nUo^yXT~Yvfo4gU zBFFCBQ~o#UM->z%p3+Z%HgS|qm^C#O;$i*my{p$P-}1(dTVz3p=9|dpP6}{(M3eJX+{l%q|(Zq`rFmv%A$RyL$n< zdzCqjuuaznS<+JdjXHKc($GqL>6DU|l%vh-I0i*f95F>iogC5?$wH%&ab6ow}rokH^Jl>#nu4Fv~pICh&h5Pm!f za?EcrX=kElI3toV3Zi5tCvWqV<72=R#)e|2eK!7F95E3ua{jjyQdEDHLfY!aJ>z*2iCNSVHT8~&gJQN=K zcstD~+vX^jv2YZvA0=#=vqvlr%e@dE2iJ)b! zPn{Fyum}X#*8%s_X&$ zDrJ_CqT&Ngl1qKJ+TPh)y1i*4ttLQzIFr8keQ~$=R6Q=f??tRu%aF?}F5Ji;G&Blq z$3a#B1#yx+E1NxPwWXlP3paX&K+`WT?9NifnqBqqLEkO9Tk>ZJe zxVhWPe(%GE6=1=G> zRmtz+38eZ?8)U$D|4@Had{ryiDpOaC6dOo!N9dJ zoM2#GO%$w3KN|m<;kZ?TR1VLub#rNPatb^KN6Rd$li;HYMEw>*Etw4|RgNCd>nrC9 zN*JZlhkKc$i>w*b%8V#~j*01!itK;A243KOuWFRvwUIicg-)k4^}LkxD&UZMQVLhI zTko?eYC|oQC%~6M6wllK4#h^C9z@S8Li}x6j5A_N$Sg;cU#<}6s%?s?E?U)rlkKVc z^{?(;$qB2ks-`!$r3*@B25%0F85D|7N*-!zi>bPs`(8w}4k65se|megH3w#@&p#je zKA}jyi2C@rFu_u1th44@VTC>{FjNVS>dX{<<9VpQH#T&6sNB^A2!jQd*f5}&i#iqS zYkz=T<-~JzODOdgAUp$YT3r;zAAQ$}E-T-jt1Z#Og(c<@lUVL|w#e2QbZcd|mQzM~ z8+K+&RIQ$VU^*FBD*HmELt)~WqFSW=E{E}V&gH@l^uE-_Fv$J41Lc|=_JTc)Qd3%3 z|GK_0RYI^xA9g(CZ9xnLt=D8Uql9tHzXwqk#KP^@y-4(OHB(WukDnp-0d{rUSJC^E zJ4V-f$6-GOxdgbE7%G_R2fqzE9G-M0yWG;Lw&bbR<4avAjW4EikAB=J<1=XiO+w|r z&8Qm9=rp?9QN)M37MZttqt5MT2zUky-_8PWwu||F-7a@!3_`?Np%z-@sZt~cP|IM; zXK^ZBt8;8Ew6CVZ_dhRKQ&zSgo`ThUgrh04jPJM9W4cP3*iI^7b}N^%x~1F41&jks zq?Zr3tS5fA6W;hkZEVT=BFzD}-f{1Tzswsg7k8Wh?)XnuqN1d1PGrfXft9B>h)6Jw ztRv@9GRFGL*j);YMuR(9d|#6X{kVN$Tlcr_6(EMConj*s6lLq=gVlm23%6tEYCNeH zUT=!t%w#lW1Zhal}VDT{H7vfGG8mI*l;@9TiLD{r(FU?S*Aj| zl$__lbalhz?5{Wt=$zj+lrtCbEtW{75SN*rMFU|cGMlAJr_tBANoMiVF)htg6kB~q zVog6wpdqZrjkz%#=hBlaS&U{EKiVt32AIcVvrDB>7v&TGMjaVuJ#tbq?{m$h)3>5n zdz|!Qh_+os#%8tp_>dW$4;Pm&=F)1!IJ`I}-eiJb+f7HMqMmkJs%>QFavs>wku`k}K7W#e&_A^E zkNv2XPXZ2gWG?xzG$~awnegbpe2;?_>V=nrccRaHj9D0~K0E$JrxtlLsZsKU&b@*@ z^vwn=cJ|@WGq9yro(HOXZ;BXg7*bie`cIiAESXeITNKH9#d~RX{k`oSM5Nsp>UNx$x$;*>q}cpIdCZQ!s{;K=jww(COp$}uUiz{uT(T;v6B{o|sbOOGZW zf!e6ty=>7#1Ltclzm#@$#3n4Zi#XB$d~{w%&y%xNA>~tNFawK+NYM8X@NC*!4DQcm zN_L?o{JxTwz>V$zOB8rqZ39%+0iO$EOFL(sXJ@{tU&I+5MGn$aoRx=Z{L zi&Coo;jFQH=_zyj(b0sepMz}k3mG!4&KoA4a4K;_J(Hko6r(73 z;FY4M2A2J7yGGlntd)1xTHSRFjI1FFtY;+@FP?Ne4f*{;PjoWqt=wzk2{=p_AvKVJ zMC^C~qw;6ZTOpjRI1dQ!Dh8xq*_WfuK7v|j4!H-Zy|J%)*NsDNkBNt==uwK-nP>QcI4 z#N`-)?`wCw^aNV!UB2sl^&s2OE`1Ff%}>x0xgM_$ps61WJptBCGF~m`I9p!^xc3ii zZt0$sdh1mVIJW+2k;zI?_@Uf}h3o5DT0@xkRI;iW?qM!C8EJw8+P=|c;-f8t$AmhY z7JM#$SN>?OF`A|+JP}p&P?P*7LF$~(X|?9CCEbWuMxe7=g9?flh5*A`Juf5K4Rkx< zbT|tjE?h2)jO53>61O+npR{|N0Dv0WYZOd|PL}D`nC8LMH-v z7)2n~@Tb?k)?x_lL$B3*se2vUf7ilzf-T6IMT`Jsq9iN9*sLe5o}po~P-Q?lE%mE>2n zToGwWwU-qMh}Es*3gX;f;XXr-%>ilfE|QbT2yAr!8cD<{9Y9S7oQunJp(W`^rH5au zVM}8L;gEGOAqlT^GM>44NFgb;ola2}gi#v-zAT>43^AF*<-w+j%2!li zzO*t&xM^FavJT_&*+E1x+cTknvm8+%)%j+-1#(!=m6L~$0 z#KfqXPWF@u^&UIv5R57v$ga>>2{?k-F3PUXYv!hiDi!vIU+cy9Q1`U`LGnG5NDRVE-7zcQPoSfpe1mQ`{E$^znijHF4G2siV8 z@wz^4s6LSY3dBK~O*r152~OeM4X~`9P$a5$gJyp6TA(Qos@O%P^>lurqGFu{4!El9 z+)3zZl>4GCq>)@)Mho?0m(b<$8bPKFY!9QnPd{~jAo{g2B~0o+qK=_nbrnSyYufy8 z-KY#eCs}j(Ho6Fvok28Uf;;(3O?Bl+BE`(Jm>^wn?tqYLYp5UY!%+pyRyo{txLNlK zWB>cK_3J;<=|grOYh-9KEN7%4)Dlx|ixZ0i5^O3iEvMUxI_iqysb-81$dBI-eu+gn zVSsV5@YX-~ENi%1>oXIVRH)T7Z(wPw6Q{CHLG65_qrqAFpMPFzPvY89N5!sqV0NR9 zeSV6Omn`Z#s&iqI({LgA@uQ=i=X=JhuS7a)3-?zmgwtIZLtGqU?dnYCCY?#V;;5F{ zjFyUxQiAyz;j3KaT)jKw>7}+!)LjTJ%oSCxqNvgKBp&%_56r)4hud0K=?i8?rt^A` z7(b^m6^h04hF~_8h)?M0*QH=FYzR(Y&Oc}aY_w0u5Zlu6HzxBJ(yOe%DC5OHs?f}H zAb8e>i4`jM!)UXi6qPa+6CR*t=qMsW;)p0I9)n?J=J!e}Cg<2?vvR(gZ-~CLyZ8X< z{7~EQ(ND9pXrE%(76*F{{h*Xtr=A)wGjj>=VE^Vr%~iUV!@{fHd|iI(8ebF^dIFOm zPbJnqs6P1PZ0l=Bb(qz8zm${mm@hU_D(_X|r~cQgP}-p6p`0w%oIs?fsyz0Fy_;9t!%kQ20G+j%mV>Ak z?$v*YS{G<6ZS7jQL$OR5Iy=EtetfQJliT?kOs!?UAOcr_ZjdA9zb+Bl7VgEd2ST7vI3uG+Qk%?&!U+6# zmi`Z0Zvhlnu&oOZPH;kSm*5^ONC*%lxVyW%1=rxN!CeO#2=49EE;uL)*fuyIsH_=5Tzg97-s6H4b%u%WBL>rQ@^yXuFGB- zK}4yT*KF!71{xI+w(l0--+9npVTPI{r%E&i_;a`Gw=;Qm*kByl!<4;%Nmk6al;fAI z(n%2%1SPCMHcL>VFH?iHbrd5mn%{8fh&I@?`-YqB+<3}VqmsJ+g8>`DDJYP#Un88V z^jOCBVzKfXBXIslt<|&8H7hrmjj}izHzn zprO}X()3$cVWvsc4dW?E6Ib+{lekY091MpvLa)ecnUr!Y$2j#V6LqKQdnY$z@S^}pHaICHS(Xnt(Bp?0lWUAdW71*4)m<3@b{ zinXh=q{0xZw;CmIaUd6x{n|;T5Z59vswPw)tz>HgLhcbtEH+eOG1Eg|$sVKvHQMUa2lnhe73x-&f_oB|jSj zKO@$TbjfyGUR%F4Q6ifw6o5Ca3`eXt5Xw|(S!!fh@akTR60~eF7V&jZhSwS11-pD% zRgoW6@4g*(I9y$^uT9ayQ0J`IA)kzxjMW7g}{-A@jwvpCX=Q$GF-A8n*6OfVtT zT$*jIZRS^mhpI>g?r=0EXABz6q1`A@;&U(GWs<|p{sUEs@ObvvQ$?2{T&c7DG(z$q zNi!KUIMuYO)-s)y;DswIr9wVs3+XJ}RNSh%uIqF5Y3I|hllwrBLfw%3a*dpBvB|2F z&5+JU%7ja0wxeA4GgZZ_kD9_3aF9LL=fsI4VD6JC{(x+{OZ^I^VQ%+B>h7QCjeM9MM1Lz^*;3vJ{+oW zZe6FJ4L#06JjUFRLf|CYXG+&7Uga&=Nv7X!V5F=}#O!_*enGodw6xI2=U= z4dWG9Ig^r@tBQsRAP%kW`~(1$uE6loJ6KCBPt{Ko?)Ylk)>bByK59fiJI5_Ld@*tU zfqwdPJRhSkd1ig_UMsRWd%4E^yBK_Iz%+dVuYj4mDYpbhPh^TqA?r=Jh6<0y8Kq2* z7+f3{Tnx5#yLIMnLxg^CB_z&Re9C zeT>6BdLK0vP!bC{sLruwo|x{1fSEG1w!`SVgzbu=p(knlw-|R<``DA-p0F#;DaI!o ztR545u9=n;DUu`VmY(lPy~HB->YNAz#wZqJDmPX^%Q^yoZ{12Cx;~{aRkFsoY|<{$ zg$gj}VXipfEa((O54}XftJ?Le*?d&L7E)cQ$6jpSFxga~2M`zt0GOX!#V@9mZ%M z_3K!i7)IfL1r!Iyf=3 z%P7({AW5Rn(}bDZnST*fRmetU#woFkXx+cX0rQ-&2o$z)!?I{03HPeQBiWjo89@wq za>|Ph?#{fW#e$ri`Klep#tF`;GEU`}&SJ!pcx{uyY%ewsPNa_Dv&N~k`w}yK+J#8N zWzNjY6&OJTQ~Q*64mICX33WcFHTBjm$%?pK8#LXP;U?JjE<`F1H{wIUF0he>;lcw{ z^<;(Zpc@-EUqoM6~zb4XkTFmCgcPh>5u(r4P!nrtQ7X_JX<{>kM40 zU;3(L^UpOirI0V((R^<_{&}_*yA^@CMLg_Or@twq zDFT}DWqgCxb@E3nkTeI*!&I4=#Z==~i=$X>a`5cvH2qzKvug?1b_47+eJxP{YSNpK z_N6N_m6wiQCqsUu-xxFYkPu8~a)zw3OR7t$N76ynurxC5gIs7cUEZD?-25Oe2Bbb($19gRPcW#y zo@9QggxK#*ne|v%UrRN%waXx$A}P|)J|P)Vwe3~$cLRiKi7rFsKt3@3a^0?=23VU; z|Fw#B|K*~&8OiTU0q2~Qj5NsLZd_FNM@%C`;h2S)LDZW0^$!(LYCXC?~R5gGP9G>7uBJA1mwf6WX{q zt9oeLqzUoSmQ$BA+K{{vDJk8?GUHBFwql))bv9#Uf~}Azvlx9q%@^}`xUGkZ?mDB( zg;DJK+#%f+)JS;TpWX_iyH}$lM`Q2GHK8y?U{+ z+^kNq_(C@Lb;rz)6BN!Em>|Dk4KD0Tx?{!5P3-XBi{Hj4sKo+ShObVaC% ziBeD4u%N1F`Di;&DqOaO(&YRuj#KrBT9hS|`%4Bz3`Qp1)k!9>T>M>vFUe@M@Vg4v zjo3?MPa#bQn9$A>h=wmQ`2A49H>Q1CcM$K@_1^37ldG^(5)#P!ZOb!-&c8X#P-uw+ zuzU8q1l6z{8Z}RKMr3bK3kHiVbeWei5jSMk~` z5|u<#Q(MwDa=ZJbL4Y~EzU^a;@^C_b*=b6vQp|qlQJPSfb<`;J<1-T}wKy@wO-ylR z{l%7{)q9zG=j!Q`no>BTfmY~!*Jh0IBk^yJb9LE+&`SPb;}z&7Kakr;Zm=oo3>*PS zU)%w|tZMrjw_QQ8oD?OVxZ~L@+7=Jfwe(nF{Xmm|mj7&G!rdJntBEA+RHmTpH_vNw zA}HV2f8qdYv+^2@za6VW-kehxqW6qdo1c!A;8CTCe86Kq{_Ebr_80oeOzVq1e8#;0 zEJ0$pmJSEmQPhK+D68e!CS%xx1sa5cs$cnr1UX+4M#<1PmL_h_Ks>IBF$6{urinA= zO)0O5wS;29{yg`*lHo_dnahjVVS1@LyC5{@6*r^K*N*B)jo>zC{sY-Uson&(Lb7bFMrj^i5HD@-M^Tm(?W>cb4YR({(&H1F{UE4Fm_c)6Jt+Z ziX-}Uc|vlv(U8$=WNMfHNx%!ITu!5@-{f!zjynV#feoNYfNbl8OCnRYSwv(U@xg*0 z$1G;&<3n2X-NNf?qGaa8sfV452YzEEc(!xLnHe)XREeFM9LbrON?ousb*gs(BxGeK z4n+|t*_+y@geKo4)X_DG`%nIOVy7+&*)!6Te?`MciF-IvT=zVkEx?P0Wwxv<0oyV) zT&5d?qu3`EP|~#Qxyfx|%qEdSTxyPVznzj8gw_`v-m4>!&*^2wnO*$B%@$GvTooCe z6#y~3Y~F%@w}?-a=cjQz)QiM zc{k2WQIU<_JQIac6d2b0jv0i)8917fOb@NE(VpzE;lBo=-T&{;Ty+}9f+O?@Xdsdj z&|9s*(>uzS3AjjAWd0cUr5xC}I!4co5Ui5zm6P$NhHJ2KLzSHrn2J-rCyjxsHhfT5 zJoNg7KKO5z_Ub@ds5{$B*2cbYE!c(8B+r}&XqLL+#zsO$p4ToMV1B#QP_Hy7$s^O; z(I{P%VB`fzqd>W-xoJtZxNb>5U*gi!N-FZPFZKEpgCeU$3)d8Lo}Qr{TRQvQR(YL}#VWRRq(dj`vv4mD4H!Io)NXFk^MN)BFHWy{< ziitxk10p0b;jC|uO=|XJQBZ4Z!SYnFAr9gLCV4+f%&`;s|A6DXaVcIM?tiUZi+m1% z2LI>re;)`niM!=g+c2)rQ8*~-*r4>yxm=8fs5hHqA>do)|bvc^^H9r*YCxBqOid4GH?XAq06?r9)Vptr+N|9C}u=i z2RFE@L<%^*3)4>yni@*W7g;OVnq|eC63ZCM{N9IzViIp4#gxgKn{-s*D$GJOnXBnR%ODDk;6Bg!9HGaN{E~7kov+3Bw4dS2>XxQ(=Tj!)>skN zWM{mMtfgQ4MAVHf7I3ytR-u5OHlWF_)~*exvuIP)5pFinM?_b74a|4jk9n(%to5Z? zegsg)hU5|W@CfjqYLZ-9l291YFZ|zMjgUDayZzM?{TlG&?I>1x88a+{<4xba7v(av zrqgY=B%U!N!m)-Yd#8y(yTAZ{s41bAoXRtstGOo_MZO*O1hBX?-E#*3M`mU;Ir9i( z^}NwruQ@-|3*6FD`stnhNwm%SBg%*=-H@XHAr{%!p>-u@f^enaHOc&2bz1I?LF3Dc zOX>tL6BD%=Z_|=7*O-g7RiTVUGRtpHV2!JQ!n~#7>rSJdox*i=cn)jh-en;MIr=+$ z8p7S_`Mf%FGS&HvSj(amPCnZ(1CK$7ecZ><<+6Cdx|zemLwUIah}%hv2r)Y$FT4>d z{VwX4wRdlev{q%*vEOnhYEWu!)H+o&y_D`xd6A) zg^c=|G7YkmFSsatGw@F3zJ>nnWlW@>JHEukd&)^5i}>kpZMcoG{}F=^5xT{@oMoF5=>?T^b_#y?3B_(H#39D4k>s zWppD0Ld<>mC2&ZZbC9nY_Y)nf-13C?1~Af9S|xA&On;`K9~e1DeUcP%s*%GlNyQGD zSP9X4%`3HcT#+9wGLO!XfSz{SFD;4=xcur=?qSHseS8guTblXbVt*XMu%6#y^5tdbmz8X5J>Avk*&+#6DV@k@tN>DU z@mBiVNg z5H!Qn#Cl9JRn$r+F2Tbe1(cmWrA9W2f^H`s@YoUV4`0z@*QtH-Y!gzCgMndnvuky; zW3hp~OZ9glJ<0y4x|yVydy3d;EKeF-yOLo7eW|xQpQBbDGQzQ159@{wDXU5h&NV4i zu$kCLG4<$~@Zwi)tv_p!Vmly@J!K416Us0qHV_ie<3j_?20ot@tFYnp#kdzi1nx+9 zvSN;C97t31)ayw~7CypPHx*G(Uvqztl}O$bwSu8|-)@q|pR21+;53r)azDtuzF)>GZ;ed3oI;0tbO55tG4v$A3< zb8*;1@=t1!ln|=H>qsOwtj2s z%_tH<VZYqI2)DOFaQfX>Ug?>EglwbM~%5TtAYJ1fI9@r260pN zbja!B*pk$|(%%W$Cs%)uQyq*WKObEOqyjtR6#d))@zmE;0B~=-!+l@p5-`WJ$~?78 z7_B*|Fu(r?-yr^7rF6JUz`)3}NtDEr9dY0NHc>utdWiq&g3gJV4If^W>lZ36j;zC;6Q6};U{MRHz3V|Dgzdr zRX7%w&g^Q|cmCW|eeam8@OfosnYe0FTvR0~onMGiqK@fI$fLbG)jA&pc|Q>!zBYii zn|HxfxqoJf7%w=s7fx|6X^xjD$iERS#@9obICAyLQ4!bjj8e_fhO7+Jr~?~{tc-a1 zlIXXT*J@B8hw4HCOD7mMdxSMQP2`R*s`X!1m;-WSkaRGy^2Us+Ec6?GctOpMFVFwXx;%h>*tp}jikO8$omS=q=FT>FBMAE8UjF?znD3279UD6g*tUT|IR z<}%me_;J3q!`-|QPQ}Yy39%%QXr+yn(jqgxkmu$rTmM@|8lPW#JXLnyR)EGR$7^Z_ zL4Gk(8oD-)_+~0wy`MsbGJK7Y!pAFx=V&fsq|wPRMuXoJQH~lfvPM6ycO8(B52p6p zuH8A>Cpp&?H{)E>K6sorg-?1u))@a|cBx(0Bn^O{54)5&x=+@8>L62o2jR&6gb$*S zXY-O9=q6DVItCH*w)g}csoN|>dtdN#^`!cyymO|e%@x=pLJ?hxJ5*h6%Z;A!8}NRQ zx=tfQ?jlXYagQixekk>zE?w+1P+VmS>t)`&CRvJetNi&xmL5)b{{>y9g4-P=5C#zS zyMi4u4-_9M>lCGbv#A8(C{0!~%Bh3BY$Mb*nKEI~-S=@d(3$AMgM__q$+#Wdq%c&B zw1{W@S@oS7$w@*|kd|W1G6)6KB&#Sv#qaZ$U7f8a|8C3UvKF~^=XX1Pal^x5vdxc2V z1Js^_4@BvK#K(5lbe~&mcl3U^1&&M;1I4d#-v0Sd(*Hntn>de(@Eg4k3$62l7|Yzz zk*z{`*>dv+0ZsaWSYhvEuqo%iPVs<`;E}hiKJy8k?>N~1xHk;#mY=T5uv2LgU2BgK%ulqz$ z&eZ1K^Y+852RkC&?G(SWg#ZIwSX|HRn=ZTYf5Zety6Vn+18IgW5`;H! zcM`oR=F?>$m$sRCV z7<}v%AKp`fs~XV0<{)xe#@r|`jy8K3=o$=Q9#u7mE0#WN0r7P5|1+lk$+F{e}I%UymMtJO}kI7joB2tBi3qNLCaxryTSHRJsA zIqqjmf6KxGtkyDD>`wg6yKaew>Ovug=&ew~6iDyj-(BQ)I3}}sNZ!5I$QMTfX{6RW zRpzhmaK6*XIwZ#Z6Nwx#&S*i5k<~kLjxv!;{c0o}sj)-_tf$}inZ~V*wdN=as2Nxe zyB$BZW2pQ3B^*EKq?Z%=)$Zfn&4Ye|HTt>cRceP*9av0lf0i%wnxqCx3M*)M;uS2``6YeQ8( zyQrxx&CQSHKHrN}k^+m6*k_CWKtF136~TPvcfOr}S=`_;XmvLywLFr;waMl&*Z;X; zveD$V{R9jQ<4Ip+J#B@wBhEI!KquogRd;uvOi3?UWC#iIcrooj6DpCyS?d6M0e;J4 z=$z@p6#r_uz)W`Bscsl)z(q7U!E7)zNJ|Fms}|X3*4&k2Hyv*Yr=aeU9i_)`cR9;` zx-a|`_0pLCRy4|-=<%4~1N>N3B`J?8c4>GQv9-VS$x^BymO}>}tCx4DD;H-{F}uKO zV6o&L(L%Xxe<&|iE*1s+KJ0_4>2|T>b`QdtA9w62ejh~)3@^_!qu{LUY(gap#oxwW zZUZMUfv{Ldm6Hf5WK!)7o?r#>*?<^;ocyezfept*7{=dh(}r!rh{ijt;zAobze7;Q z1Q+oZ@0a}!mprr6UZ+Je;_kGovzq@obV%EQV=2Up2x_=9JNSWe5B65JGCtqJqiNP&~^JF|A!2 z?*LIr>XCY}mP#bpsfY8vDsMowvr`vhBz@DF5}xah!`ku}%q+^XJF&Ha;h$}iE_H-; zQ185W)gZW9hX3PG;lmgMf?Vhq?k$yX65T#*=j0O-eZR47dihVT+!N=en(?6pS3VWO zJR`_sgAh}D`Sw9zk8bfF4HV_3D-9dWrxAxX@i5edA^p7}y;7rXKwZdatN9m(v<2k{ z{={BVSMjDlhnSk}XpmY{aiY5?^3Qj=G&-2tJopCn-}2yFpE~mj`dtI+US+XjwXl-N!|cB@6YH` z;GmMjeqq+whs^-o-s%o(2l^$?(!ttY61b&J=@T)l5}H-9$4DI#v4pZRpTFNHlpS}J z@6qK>CUo7(rA7ve0J5z3=rFzP>f!<$)RoOHj}%g1_?Gvvef_uiqXHBA4)t-vjQ=I~ zpuLf36L`@9D0mm`ym9a5KP`O1eHH*{V!Lj6FC|l#pZc~WS8IBUZ`3e1?@oe8H%V(R z1UNQ4Ul4&-ce=-)@o%#I(e1yWq&_YPuiwv;13QGc`yjL!E2aq%jTy&?kz_`>t-m93ygL( zVYOMey9u}YsYQ&Bk2wc~t-WnWs#n%|zq63}7AHe^jdk?S(;xcUeB^@;uI z+=hWLAh7C@I*yF_)O66WBerWOra9)PPQoSaP{_vZNrfLG*UJ4r5Z68GK$Q(^CrU}x z#8x+YWO*fP>_FE1T$a#=)vO6#olR}#QGWW)S=Hs8N--;5T_T9Iqsvt9@`)5+kp;xs z4eUe?beQUULcG=4TcM?Z0Y>q}P>O|A^OL5wA<24e8WCh0Di)4}QSpYz*lr>c@jldO zMgiHinzu2xpgh1KdtgSWSy!T?_Bs7ZC^iba<0NN|cJl@PdHN>_e$)YSdQ8g3ZaA`+Ru`pFsJnFfuM8D-45}tD}`M= z5<&jeWecY=Nk??(@g*JVTcL#aq2Q(|)oW_!Znu|8C|jY*Bppuid~&a3+D^lQs=Vo$ zjRXbD1Fd+t-VlnnW9vxW7GB-v9n1aioD&IvP|MV_XA-Jl4vTQUa(5Z;q&?H{y_#SckZ{?1?s5IrDYDEQCKIlwm(QL1H14!GG;UhrS?o!q}nvKW;{ti zn@nOtp|=-R=Rq5Hv|ZQ@9p9rRM(Ghb`u}%4@0-cru3<*lBlS1IZR-SHvUJ~dBp_4W zF2ESs_o0vQC51+i3WYz$h{~X9Irw1n^4{Eaz9;<}6Ju6mx)LOQQ6hljP~a-()S}!A zH;f>^7=eN9lxQm6do~R4&OXpJy=l56B%_e-JYccUd2-*gU#98)+;G-(@Tv;Eg?<8i zHvV|%(k4zWb>8e(t=<=?r6X}n+9T)PY&I1ciBJqO{|Py5M~_bxCG|Lyv|A_zWr%3h z*sVA}JRO(0m!voD7|iRvE1{Txq&|e9w-GsJ{&%b+E;jU5)o_=V1;HB_@0&?|J6hHqsJ|9S&oZcrER`t^rC<$4*CD zWmAr|=4U^NF}LmFusGG5EuI%*nr`HVxy{&R%UoJG8$E}AAb>me-R%y=3REEpp{`?& zJoe>HqG6}Tt1?MEd5l6|NceMU7LAlgZ8;T^NbBl=@va>HF|n(%F|Rm^sWqH_8MT_k zII2!=fs%X#-)EL>1^|i)%v7#QfC2j4(O77$&OR`RP|i}APT~@EW4Bsg6`FoK8+WM_ z^nr?!xNigAjJCs33H!D7cSG235;{&X8meF4@ZXCguK<{Bfgga2m}zLq!PUK~RVWFN zasxml`&Vb2gs1cMKmK>>0JSoJF}DEka?+~HN;L*N1z2fyKWoM#s+PF$e=NDT*1_`; zn6>27K1!nUw%;vj!Tu{dcB1!X=E{29>%>5T zk1y>a4^1ZbZusx~yk3#8+W+<4ufzV|E02{0CQtFmr;T=+fiwWlW?haKc}qo|K*x9e z(yXGaY{0{)u588B@vO6JMuzob1A6LRi@I{8bth2i=SN;)+MI2pQ%Cl&Vc&e}O>f;) zgVtkevJs76S7eq&=&LHr(JtI!FP)e*xxc-Iz=;QG_VV;cQ;6RZW5?7Vyp^n@Ztj@D zqoMxqME%!v@Mi!T+8G!_Bn6{cBc9$YC*tc<+h%d_>lO1<^e(Yo$>w5p64RIdA_3-8 zwb1iKn=$=DZ?Uze(p0k~;juARn(tcnrBPQH_wnw(GEIbYb@Q?#|wXp zo6mNn;S(FHVXlh;3~atWERSUtMlscK8yxpR=}*QtO%1}H?m~B*jpgSFF9DGB{(k+*B}29FzJa8VOhOe!GMhUi5`OV-JSW{sW|U1cru{PH>9j79 zl_k{SR?@I1AvgG4Np2LolKZM;VMjk`KiFsqEg_t`2|kf-R`{e3RM`)EKS#U6dFz>M zTxc(<)~*;0EJ)%LO#RM_LJJ6XF0+iT0c8bAcfvz&Oth6P9m5=jp3Pz zim}_bIyC^v4B1?9@3B#<3*@agF&=H09^hQP9h+mJVbX9C2^|4uY3uMSF7 zr*NkAP&B6_5Me*;8)8x~;XBD-_# zfg2;XUXgkUhs}u{H{pZ5a|@H52JcD6Dfn+U%vq*?M+dE7HNMQ1PJr<7KvxgbaDH-f#Aj1Uh!*@|>Vc*B zcXuGz_3f>MkMM$8|6LKfult2LA3&v3H$Rh{K6tS9}Rzx@STxmA(tS%0Xgm>8i$WV-tY64z4-H|{AvD$NaX zyi%F!<@*K*^_Of#w?ok3b^1W9ma0swfKj7WRljY@FZUeI~cKahwS1SrYl+uMd~p?{(6r2pxcC5#cKurB(FW@ixt`+<^ z(W@LR%;voseAxOTI7>cS-Q{;)4T&F045xv>rS`Og=-p(zO47?>Egyei&q1Pxd`?YC zhNW!QH|5b_ioSXm{O+dd`r_V45ihZHn|{9meW3e#j2=Cp)FL=g^BDTVV`Xg`RJeg5 zslZLPSP*aUEtIG8$88TS6QvsAFmVAQUNiA#9lEslg$`5cNHkjVbGH{`zx)?bAG-RL)E#>~P>knQkZfav!yY(IcHfWb=!{y<6S6pKa4= z;5gUxVMot}ZjFVz6XljVsvGS`-6T*TEWzJ&ZdGe;MXI-Y(lYD>tf}G&5sx$ND*i?p zj@7^Sm7^j4UE1PS*XMAnqS1pDC#c}z=|fi<30`q-u3uPhZEQb(U|_Od+y6K<=Y1|9x2z1FZ_kZ|I%c5A{q*CS@|fySs)EM-B;G-UW@txExZ#fQ~tsKT7@ zJoKi&+#;pgiM0cqw=!}&6dv@cQ46Np&<$SW|I!%RbQ ztVK)&r}6--hSaxamEQ4v`nXQk6rrSYYbe;}2i z-CQBEnNsH1S?-B(&l>4x@{FLmZ%ZfG?MXKNYVRl~`f2+WzUQi;3wT04^7GWUI=Cs_ z`uPSd1dp|&oOwNI5^)Oqm#+h&hCspm$2>g-zhQNV1jWNe7iz zq~%(c0L*5_rEZh$Z{zIuzC{@S0tE=T7yx9s_>v0J5%0K=XS-%SviGxP_-YJMf`~+DT6aVwv+Kis!THI;v@9yf#2!)6h(+(${kvJLn&V z=Pa4B>X;Wvr!vbO@*z;B{?3*b2|w&wKi)QM@UD2Myb%3@#b(?+hDTJ2jc!g-vk zB=^*jzlk{ovF?cTU)R4;Yz=QRxm@WOm?_j&&>4ky(vjh#1&5t{XV4t_`KxP!quSxy zQgTAts(_X~=D=TtI3$5}WnmaUjgC|RDn6@zx@-1ud^x=Q)#%ZhEAa#^!l)2b4UHr5 zUTf94R=x5rX_@SpzsCNhqur1Y;JDrIRT0s%-CL5S=~%w@-mE5Qw7`diXuohKdQ?QmIn)@M(T(J`YLeygS4laDy1pUcWK;R5E% z9!JTpfnf#tUx86O;F^rcPwy zs2kySl^s^Mv#d4R^_VDtkTrdcr3u38t8kM``Sf-tnv3UFt|(>P`>(C>p0bS89cgR2 z4EuI3H{NbpnMDJv=;Ct168LgG**ZXs3oMnN%Zw?oW=vx?FJss_Oaz+>H#Ap5MG+l1PEPMK+DF zp=uR-NA)6JhvocNqbjXML%N-#`dA?rn)>8Q*~>I-7VQzz`gRYFksDvjKygR*#czNu zwb6*TV|o~rxlS7N_A~DSfUz%k&SHlozIM#VVK>(;)_FttpQGcs8|B<7{f+JafkqT= zf`|S7fqL>ofPxBEIZq5ObxCX^6?MEJ5Jh+M{(4y04o)>1R@td?!ku+f{LH%k$e3NI zXffc2xQTqx8dMS4Wv*J6|5&l#Y`>zRBI-^b_fNV{aW+g%KiO<86|4MfGJl@zepk#Uk$@4aK z_gc1?t&0p7;hl#|Q5G7%S;a>G8S;>e%7WArwamJ@a;uCW*`Z=jPrbE_vz1)0Oz@FTBBs*IuCtqV1`L;>jBp?2Qetmoe)Mc z+O@STb|%SJD7)tlxX>5G-6i6qfMJYqQK^n;Jdo=PA3$eZ5{2qMg~#xwOPu=Fftl(? z=klVY4abSTEqE!j>yOHF-XHR=3nC6a-ioEad^Fc#J*ifS({vU$MuV}b$Bsh^!)Ts?j^107F9|5lw!zP_d|vBkCjElE)N@8uU}RoyPP$f<2ub)s*jbO%JBV=&K8c#dS*T*f3wzZw%^znL+bOF)#w)i#QEd0 z`lw7SNjO6yp8=j#4Th}!2U|^5$9PY@=)W_DjuTImJnsBrI4;z6KkjI{f{H^%w@l0+ zmIj?s=Xy#rUSPB-PMp9dgJy^p(uhrUUSqf1} zZ`?RcNk>;+n$)iQ%uf=e_Rr1x#&VhVJLL1v-DGplv77Dcfx3>20*7}WnmJVPk6LK+ zNVG1mT9;}5gsbFxW}OyoW4GWW+LUfI^`^uO>-4p8wvn%@%VJJL9^CSe>HQ`MXXPBC zB>E?wr={;nbu{Icy~~dCpXznO77bfV3{f<9vX@hsXVWGi5@yys;){ zIU|2FtP@~_zIOr>zH)?b7Kj~=y&&tDMe?Y?W@sr?0<3bLya@o(1|U6?0>KUwM?E5P ze*FS)1TNVIpaM_(o6BFS8Q7i;w*`u=ou+lFJ_E<9r*#H}wVg`$UVwP?qo;5VqcNhl zF&9|_Ns|xQrIv}1WEcD2!f_we+(E9G6Na~g^(@LqbyK7$N6f~FdS9i;GJAv!Lh}k>v(*x{1}Z zR~1Boly#OlIt&&gGBPUyK6V?4i6yZ9Hjx7g(o&Wf&hJL_8jN!QH>tY z#(VN%a`n17Q&4Lb@`Zf(4EF|D*HNVe49W4V(%FxqDZibk^Oa>&F&=~v4*(|84WaMq zL6YK~4qxWN^**!;{SXiIf6v5Q!)C9cUkw~f>8nDdIX#Mvq$-@q!nN^!88}L8I_XzDW;mtnsK-0bj=cTQ!}+@U>gr3R&)t zpLx}R)vy^g#a~FoeV4ASFuwe7uAq&K91S3x0i(^{Fh_(aqmp4qG*HW7W287M$ zC6k8jp5|Sv&F*?ygWm8ptE6jfj2?pfNpx~>8R7aWSzs|}T!J$6<3vZV7ae&d|y)7L5YBTh543BevEJjBeF zmK5%HY-y;gj(a~XDNBD0@hM z@^<~vdH$=y!y`Z%)5@4Zw5jtgGx9+9ALxBO{Vs+*hhyV=dRsY10_liN56EOMYtv?^ z!%%M~y;DO9!)>Qk@2^lbpaHX{^yB}6h|Q6$>*cnZqD6~J)IO% zP^Rsyh>v^=DRb93H{~o+v;XCb)v8~PWq>Ukd7_y&@<^EQdy<%hz!g0nLD9QzS$n*d z+Z(1&YxfAeKHvO5t)`F!7WA_`dc7Mm!9Vd>VXu+MbLCpjJSX+(I)qW6CFqp4T8*xe zs?>qgd{Jj)$xm>59QgWE%a#?Pzhnw`k`~ARy_%eiuBI-#{U3mvc3DkvNOnz$q@_ap zZtbA9w*C!JWb1{ARCHoEGN6nW4nHUzQJItDmgADOXF2@`GQB;Lzk~_VlEM4yv>GTsoE`5N zg{klNwZCN`L4c=%Z_j0t*UWU74HM5@f}?}w#~MkpE2MXh8vPQ1?f&~e(0t=Ot7+S; zJxXDD=v*sZtvB=w*;n-I{w5|0si*1cx z3nRNMB41r{=Gvj@X^!cnF5PZz$$i zT0rt#xUmW^iM)gA4PJ9pjc(i)lNUeB@83;@rItTB@a`*p766-+JKt`qlFM$Eko*D| ztv7rX2%*zKywiH~KffGapM16C)W5Fsyflvlt0YE~&MP|pZiJC|ne`dvD=7{50%Q2} zMhOSs1k)j|VjfX$wHQU?zvK}iFN1fAzi3WjB`Zu#*KBjP~TBVZNn zxgpoyCn&R1uq(F#(1ihq%vxL z64+&QrR=5z4{Ys3y=MA9_fbK%YF4!7c)w` z=2)21dEDO;?8IQxIJAFQFC7r!?&-8w9EWk2@dNS#)l7cv!$MPw3;{gS9IZ}IfMeuXT)(ejCzTo{rj{%24BB7N|2rqyZk1ChltX&` zXFK{>oTpa9%#>jz>AI?;yL)gFP@u#L%`wY~W#nBKmc7;rnsTHoH|pUQIIMKhR<6&J zJp4;F8E(q3v_W<_9?+a>n$$8&C83idhA{Gw8~@t6U+Tm85dpFr*sK}sh-~wHU-|1L z-&wsBj(5{0#L31b4;$Oabvq$iXEpE2GBs22C_e@-_tC{g#Nzsuut~^aXw8nhA*7Y) zWKL9X(K^t5w~7ZC_&Uq{8~}7MFq&Imv$FjvGAH-W&8oO6d$oIm99D@WN#Ym+JeM5O zKAq4?sqpJuf6(!PdjjF{C2Q}wgv;l>9%eh>I{-C{}I`%DjA->>f?jCDTH(qj2N; z?$29p<-9^#4>q3iEo*#a((759oXs;2L{kB+DdS?uq8%f>h$QRaEldW}h5~GY;6nm} zUl~c`kQ|ALP;$T3$^D>oZA!V}JB5R9*5q4Z4ZVfuMzYeE8I$yTnfJK#e`=NME6YVv zfKt8z8`q5$nE`VQp3cY@d}(-FnLbXr%LouyVhUHf@UuD zK^lLw%N(s3PBU-Ll^#jn+R}{VDK3o}cMYG^F+Sj8(87A}(PFgf?rfwvE6GEyV*K(c zH{F%@{Yo25yrjhQMQlH6;2h;xVdP2%w4e8QmYH(xc5|-L6B*`#CbEI*i)I~=(H-@grBkq)~@M& zMIbOnzqEWdD_reAh$SWc@o+f@5y<2Zp#SX>x=3dV55m5$e&_XGMx$+L+V#m{Jfr8h zI;Btr{n|!OB_dD;#@kX6;8rNOXn3UdYXuP4*$*-+Qr248V6SiAfR!V?nwkSQN#jID zNOVTzC=C-s3Gl9UoSa(!FNZ`cH<_~|h2EhoZUxo9AwM!Q_>#ZH>?OP52`@X#NI6ua z^j54@?cKo3h^J2r>3$&*5wN;FCFR!SYHakQm0&a{yT)bA*<}qSVt5tx{OH3>vHdb? z<8v-Xme~+sT{j$?;~y&KQj+@y#KWtB;nYRITn6(qQ2_MVQu7m`$^&^zl}PtVaz{3-!sfmnV>qK6UQxhFE@~N_~Tbcpam%Q&u zE&F+2W4q+R-7^4NRu_nS+uQ*M0j6$eB7Y zLw#m+HE9JgJH&$>7YpTm|AsU<6SLe>s9K5hn#-gJ>Y8QC&DU^gkgLb>xaH#B4bH-i zD=QTmnvyJBKnU;Vkx&OjK~+`ep?0!^uh}g0d(?!JJB%+VeA(_NADWC-6(pOR3qzMP zqnuI}=k!iqk6?2=I)7bK^=%J}_$PT!6)3I>OpKc-$-vW{g=Av@>K<;R<>#kDI=xjSg{MkgCxGV#CF2iKCNr(0sN=S zZl%wS>;`eyf?3;@mUZy)xJ35J+<0^J=?bhPh=zarv%tvY!lkD+5sn&_p`X*Xtt)d1 zKDtnko`JsRjqhT5Iy|J@rTp?j3VjA{glD6#gD%IiNTH=BXJA4S`IU=-++q?tt*RbDR_`xVfNpJCRH(kgb_O3 z*I0L4uWW@gJ`L%DEIQHcj(uRk_`IybGRW}Heh}}JQvwom$;OfYl3#!}rpy3{y z4bfq@?CsXcGXUDJ=0eW+wtzQa=H)3mDnIcg*%hxtQ@C2OF%zOr!6*TrM%I+HVvVfC zl0`tPk@H&SblYXsfhEM83C>M22c-8xYkk*L3y+C@MXpi=Pu zqO!Wg5sx!H8SARoSQ1$@bDmHxC5v_WGsk8`#kjb<=I3lAl5@Gf0}I{O5fo)11G}|Q zRbvwbe&YPhq&RLc^0!Qt@4kEK;D%%jmY135^FF>F=ZT6 z>^qyiP9QQl%nY}!31TnaM8|6qCyTxE*#FOa{&4+jO;aX=a~47`=Nc!Y?O%h?V$?yB z?SN(R)Rqc~golN96JnrHk?xm>fu}T#w64@9c@LDXhI@Vdt=tLSiSj?2Jx)LQFa1g) z?p_cTvsrv2i$NHn|G<(1qe+ZA`1v53X+K3;!ujf5f--v#O4jk8JRIfg(MQl{vAE`2 zDJL$FDV*rF5}&9zI~D3kPWoYYpd-!G({;Y&j!sNj3q-={xDWoZ`gC3BI|XW0Q=`b8 z;4pRUmP9%@81xg7c0(>&wb+ey5c%C+bppK;SB8O}<&igWZ%w@MwYsQgE(f5NPm6&A zf{9N?$jz@<0 zhZDrjHMaScN;D=w8Gh$z`O5f~Y@q*h!f3dZ+hB2KPavZP54$wcZqe67=qN)2wTxW< zlO09M776=BjOI*=^AcjI9Zr$;6INOszli=c_|KdqNw&GSPHAe|6SbCT#r=wVc7`di zT0%tZc&fI}pRG*QX4zfCY8L|HeHi2CwfJ}*=L~h)77Ui+zXOn!b-!+GYc=z8>o(YU zvGxjvu=ik-)fK$acK2)BEs+Bcu6JCzxNMi8?D7N0F%pGo{ILQ zXj0jlG*K|e13BMnut9kQ|B50MmW9Y|vE+4r^(=yQ8oeD!Pv1O};ByI8Ic z5kD9Y7kkY(0pHo6LW>c<`zb}0)HfjeMW@%|ZHZqzGCKC)3g_-aeDOre_jI5)Z?+IT z+CmR%1BW$-a-o8tce~R37_IL|BF}%ZaN9kf^pV=r`Ey9vMdy603J<4>4lcp-nL7l5 z=k9#*)`RS4 zCU3DMSt@Mv&l3`z8uTY89SrAV+oIxFJ5!{Q(&`^|-)J*kMb0sW-#sj(YgbWdEj_b^ z+;QXTeS<1l2`=ApOt}jmlvgH`JUeNVOu#vQ(>rcpB+?j1r3L0wBC_~FqFVVDB!C*j z+@z7jP9@GF21RmpOoQa+b4);itWd5>=d+u20&sM+_`wzZXv;NQ8t7lF;+(ifku~Br zc}0Z2()&UGT+q^34cU`fkHVvGyf1m_0AmmX4ISo(8FsS)5k-H3LNckI8(X($-M2Lt z>Wx4mpueJjXw8M8SQ0F87l2y*NMH5O3xCaZpNtu0UetS#nA^t}Tqxei z)N_DwT*cA`>rQ_>3|5|=8V-VJdjw8`op9SLk?4-SoRSi#YWV8`IJ@}K2nn-7HW!f% zIIk!>>$ZGsnI4xqa3#5W<;=qhWApD%(Bsw2;Z>ycs)epU5C3Q7(D1e1{{Qd%i!t@Z z4q3Qb#x$^2*czT|_<<^-7r9Hk`$EWqmJrBrUri$Qk-0a&yzVn)?x6YWp3gj{L^!ty z!snEm_1>_d`rxh;f!48e0O5O>nY~bSDwMAWbW$EKMuL$2sky;@);`U~g*5A%uXWjc zVE<5P9w$tSg10*sfl?-q_Yf(RcEVmK!mEV3)`=??ung+dZmK~{$_~0==*@|pd)K$% z{5rL>Ki5B!(N^D9Fj$nZp?zRWTbDDC7kg#L@8!#N-}CoJd+@iv_+hDKVOEa(jCx-B z^zCrguZHzIoh9R6(S_FGrBW17+Bs=zs&5$3 z49ei+m#smtqmi5tck#Yz4FHWt9eD%M|{ z63^cbXV7XG*%?sN$&;RjG_h4^thuV{2b7Qfz%o1*X($T+)hRvvH3MA<2jtLgj!yUA%gXAg(G?5aSI+54-ZkT{mc_vAxC7GeRYjEHl)$j7 z1_#yp!=l7dCjK98?g+EF$=VWNc3=hYKCaNuBl^;)K|zGU%L9eNwQno*FQ z6x=kyy0UMTUDg!enklzv*7JL+w6fa5g5}5xS~eqc6vZAXAO?g#!^D@Vt<%N;qv#ogcHLCIv&rjUQCXND#=T8-47y5@84WJl3aBs zp0rLhU{Do-WcA-c3kOa%Rst8%FSpUOE;x!%qJL2JaOC0bd|0ZChf^8`S z&7 zwbe+|(1Hv1J7I2T_kw3DIVrxbecCbpOl!dU6ecwtdOi+Ty)`!BxB=|S{!@*`lGV{? zeEdCwdc;FkDF^Ajnxd3%MH%u#q(=+y_REEDTfVMU?9fLTN?vd1_g~6?$R(uKRS601 zpQY9~;^M@Z2a?~+fIP)(8noTr+m5_2smWk|T(Z}$HNbB-rlCP!Wlv_y)iTEyNYtWL zCadJYIF+z)!YNC_P*<2!Vhuss-K({Q^8JhDZTi|SY*__c=DGAs!JKHnKejHN%RF=- zmeu}jMP=i@aXR|>##%y-6QbS9eP`B`Xt8Iid`ri(ZyXNZ7WR}`dskOP-Z(=vz4wqy znU-r`wE4SluQlclP4(|96sgzWay}=0mCvx*JR)~tC>R(K@wopq7<)hRoo&Vi(^Fbg z&z$SF6ltzJI_-JOWvAm0D!-i8mk!`pas|!zNf_l!RrWmNDVD2egZTr%RZ3A{XdpXV zA_OG<0iwMME2;ykflBvFkYfEik|7#&m3badz`N1Uhs3VALuLyV=bOA9xW z!KMn;Dn8UR6UC%)>KsAR@AmQ}e?cLGo}A+PlF7JT;VCCEBnvQyK44(s*yUSi)s_(v z56OY=8t{7Q#=PmgbNx2Vq2iRUS=9J_4}hj7`Ox#vFa0{;Xm1^!lHTgL0HFCVURYkO zDSE4HnqV!$k|f4#hrf?EG~};48)p*X9;B(9?_oRmRlf2N7+0px(h&$wZ4+CoIi#>$ zrY9Md5;Lzkl&P|07+5+mp1%xkxfwIhJ^0p*QzhZ`rhDtA55POyVMZl97hJ!bgM6RS z-Y689nmIErMs529!Z4eYik8@><@P~*M#^@)*#vJ_b_*xYN#q1JKK#zL+lIR?R^)#@ zjUtz+Tt}GLCa&bjI{nXjE9E!Arw*Q1?E!RHt-AY|CzrlHl9F$3MF{7@NTT>tbjJe* zjh^A+cD(TEMDt&wXDk8Ye>KmqM9jpVNINScJv|@ zdP7CRq!w@MeGNPj7ww=}+1~bl#Rn=oEwmBXv(uWy8qpo^@U{l)Y&~6M$3XaGMIT;rE!>tJNrBpfoX&dOZa~c6>A* z)#IcvS7EY)KRUxqw5ElhJ(Ej;_3_OWefd7OGtpRHU>Xx-Mb+$(gLqHKlEX>rAAilT zG#h8;|67!=NmsCK6oDB<@wE!>=gpz~uiKBobyK>cQXx)tF3m?p7-Z$o`$|=Ry*8mP ziuFb^xiFRJVb{W03HqB$%Q_5WkM*CLg0ld?c_ET*BqGg;bB9`%db zYPY6QgBMTD@ZYUj_dHdzg@B$QTWB;Lfr@9k?)5*dYXf-X6Lp+ERWkiS!~W9P{{Uhp z_`C_1^CG<`Jqw?y2;;GO*DVw8luR40hXPq$+D0wo}(KERevG0lx(eK z>~l7iM}j+-ya7wz*OJwE%g1!Mot{aO<7vu^a`rjXxh%IetOU$DUEYMUb0v(XAYD2x0ep1?rfwxd{FQXn+2iq_TX+hpZO8&BK_q@LRn0*oa!6|S-y)J7?wnCN)? z-=X9bYqcEjImj+sYGtQjSY2~jK`=7@zA&8EdN6)JA-$|S-H*mS?Ao1l*a}kc;@`#*Y z0XBdeZIQ2Xlk}<|LAH%8BqwI-M%S8fyx!oJ)jDbMK0SjmcM0o-svjQ1f{?oIC|8qi zTpbe2+-4U1bcc0!0Mh=~-~tl6kcQCoVbJ;UZ9_pBPYPXCRW2$OwX17{#UI6yucX>Y zqhsxgn*F~===mzsxa=@3-MmdH%QTyh@*3l(()e@zp(B4>G2PK(h^BVjWe88KPX?95 z*-D7hDmm7vX{FB*f6<4=Uo8CPKG*NX2j12qn{@Wvq%6Lk8<9YEJ3Ido^!jwnz{;+5 z-O?w8Zmn*D0MOos)1)pjyGFpUdi2{sP4!-JX2`KszRv-_x0k3vJ#^Lnv##8iGsS53 zj!@-}(0AyJgPgfzX&wox%(Ur=EJh;+a(rq4U%D(3x2_Nx7!l7uPHS>4UL)Pwa?D1A z=`zA03~Mv+J-qlY2)Z5XmBXK|-{y{Ugxu+3ki^XcM$+d`@KY~KdbzV1?1ca;FNyGIL+uO=$rAJ=J=;w8!F-01uU0IkGkM8KIjm$E-HXQ{y!)gH^8LZnm-W{>LqUHr*H zPh1Ek8Y`?pH4}BNSNZRETdNO|>~>KhBNL_I*MbQ)@UC7XBQ`PN`t62BBtWmNz~eyo zRl*Tl?)sxkz;sppFmDpWxC&=;#_HSLwAWfEr0hDQY9n~@xg3~{K|fTig_HW$GVr}r zS#AHUo2y}NU1Gzc!VF|f5TSnmS7Gbj1Tu|Mo8$6fju3MzYn}Z>o!ERsvRen|NAhnj zTVmnZxP!xI02oqMsHHZIL{(f2@8@iMy~`ewZqHH7@M}v_uaOG6C8>Sk@jE`g7(!=M z&b0U^fo(L^8GN`!9CCppq8Vrs^dz$*i`0jK}XL>7SdYw$IuI zurDmXX?657tvS8d@2A^H!Fld5w}f$OrR#0*-1E&+a7?;5}3F?8?UPMaeF3 zt_+)h|Ipj=803DkGlEjl1RHm}HX97&>8P|MQu;H}&iMX^kDl;Kl39`NOL@Z|3X&AA zOd6cxo8s;1Z=NOEx5LsWKN*D<`02>V;2H zy~T-TU)Q+2cu0O3YC*E@NkSV@id%mvZAC0<2U7^fmU1cVcq8K7(M9gGjOtIJwA#e7 zVPm*rTB#LOH2`ON>pq#s39sLKc6YDlP;U!QsvZwwr(Ah+dHhWw1+W6iv_RXSbWw&S z`xpOW`8IwDv+!adiOFSl`|Ekl_D{l>*-nx;$HLEccl-q) z`3@c8T3Fgae#=rhTG4`19c{>o#tk*yi{)l9{vG49?)NhdlOAjcA=9sn!Qs~tHkc_crnOg5!Lfm0mX=kP z*<CYYLPpg0-HGpFlc!_ zc5-hIfBv~usQD3Ho-%f>Ddc(dlw|ATy`%Az`!m0T(-r>tJHZblqxc8#8rENH%NdC1 zVTEE2YZBj3t+?>>ae5j;51w#Z#lbCmCgw$Xv4&_2*4VG6j{an=qeA6Dm0iqjYohK5 z#u`cpH?7JetY2WBJH|b#Do$fOhL*WYsS0IZTkDn>U57gD8U1uM-OmLw_O zml9cn=nU1FMVg@7gpyvtl2XB^(&9@ zNs>QSg*#MYOL81fdIn-9a0sK4VI~@Rap5afd7pSqk78QOzxaE3gaOrdUu+?$-FY0u z-eJodOI(?(F3N=;M#>@)d>UV(6?TM*1I<<`^1pj!r1?!5QElXK#IS`t*F86SQTHuA z1mOM_(}c&lo)Y(ZHcHOCGpB>_5YpPLnL>-4uwb+_gAOu7Uzn!)3#mKo-~B=EgLz(U z_RCLnJ*`8WeVF%9j~wifd!djtFY4PRRdZ>WtB{m`BrK$6B<$4?#yUU)v#}=)aK8lj z5M|x4Qr3kQXGo4*#F!lzXIi(i8!n4SJcn0pI9}neW5zdvt?Vju%Xy}bB?TfsgzYKc zory`D=+97uHPzkL?1oY~n|&`{ZY2Zy^W4G4I3N@8Sv$5nU6E-nF*OCYU@g~VIhk7? zaJdcr^zT7i4BNwTBEdDyN2MKOT9^}0G5P#>m&Y>eeT9^yQMZF3)3OtSRUI6jLCdg; zhf>w$weVcjrgf=RPPUbQocIW5_P`m(OG|kx3YHIRO*mX_bQ}gN*}He~w|o!WiCO9B zbA+GFxtxh7ch}v)+DlTq1Ufq<-6XcODED@RO(8F>9neoZ&O8Y2pg9g`lwKKX9rv-s>4vsv#_RfA)6CRgmVh(o+uj1r|)_dat~OK;UVdE<;lMQ@pq5F zh|RyznJ)p-m(IeFw>3KN7 zNpV{CgHMrsu}uv4u_F_+JDp|Nx0Jk}T$Qmb%AL7T@$bx3y2B`nQJuv=H5nh}4aNlU zv2~7@L&}uv#_vkxmP;fiWnn&E12K8;I+oUYsMx9SC`w#ilacZ{BZ5KD4T_`~rhgOo zyQWOcZ&#{T-~)yr{}#yjsohiQb$BJqS+k@F<#WEl1nUnyKqch#PM~nB=KiN1YYd9I zqG5RG6Es6|M-H<`X2g%`L^t}<#3G1lTTl2_)m0(3eu5zgG>~tA!x(vP^01KWT9Cim zxqZvitIj>NH1TF!^VbXZWvrK}A8*11BF=gETi#u7XXw$_l_j6+9h<#f$0t+;yhH`; zikv;6w<88UUl3oNjo*9tVwo~W7bMz6eLnUpGa3NP1|r1;2CvB)cmJ%x>@~$zt`dj; z31Yqt{!nw*O%}v)zP_Q{Y$tq#;Fv2`stCXIOq{y|e;0;MfvV`xnQLB0k^Q9@d{Jsv zAhH589eKX`8J&UNvg?Um=?eibg1={3P7*^;*?tx(*Khd;V%r_L27P(vQ4@$*S#F(3I~punp(teMFG?mw+#unjk8CFD)m! zq&~ti{?$iHdIIK@FaZg=N~ChFd`#y~Yrsr=QzjNZ> zg&&@UFEJP!g`Aa;k^$NkC_6(3a&;A`?t3g>P6<69iKxVBf%^}j-h%Y48MxcUzg{O@W78A4o)Z6@crk|QnewN2SS?S0?a&2tYE6%UYF6+7~y#>~W-;8Yz zj@cG|WX;oxnl_@OP|!Ec(w9E*-bg)ew4F(?c8%k?k^EfnstO>ss~PdxZrsU@{q6P0 z_tvg0C%d|x90XWcADCioo5aQhQx$IxYg>^=@VBrG>Jm&^&s-R$!ik|bj&m3S@)hB! zZJmF@4pz+$YkJYiptq`#&Jz8E%WX}rsh0U^fH-3)w5WhHV>5)8r=rP=gfGl8b$O_d zZd^dk*GSXcRF6Xx^v+b-Y&_ykNKiT>ksXJ6Wc^KO?|6X9CbAeM4ro1f1JzdT+B01{ z=l8EvyRF~2EH z0rxP5b+x(g{y;JPW?7-RN|o!f{=9dgV#^Xd_--Wh@%&ln9Y`HCO6>-`{eJ%Yj{9WI z*28Svn~eX=@>xWdd7?<0^P#s=t0J|whjf;}PDTPVoqW-F;jo{rbKUWW3Ij8L%ICh| zGrYkxa#l#bE`s_AEsfInr7X|td%>i1-&k5~KlAmO9QhVClf)EwRMJP(s8M(~s*}p` z;5~2#WmzZCruc4b<5QkoII4F`mqyfYAS%;v;ersezZ#f$ijF)IT>UgE$K5>}GD#wb zX(TWuwKgm-F_u2 zd=;Jl;r+R}w{}XWWZrCQL{4h|%!Ke!%=#}6yx)~Pty+WL+V2s7p%V-Roxw1TkbyRt zWv4jy*nX31n_p{^<{VEorN_X$Z%gqGx|=b*Voy<>>DxVuY|Y@<3`a5SM7tJpcGP_^A$h$r?eQW&nn=&L(C9B^sw8V*iA}MxA zQqmT7z~XKN3QK8C_>-E$^6luGed26_V0XIhY%}`aW>$oosNg}4YVm!;@Sm_P#VOE| z0@4$p@xfr82{29ButnC@jgY<#cKueC*c|@qUQI`7*Lbo#Pc?X5S!?f&4uhxUqf<}n z;xJcY=@vksdTB&J{fF+f-CccZE8x^a`3a)~eiG9%D7F<_ zMuQsv=7SM6Q5hBQOU-N)Ban~K@5V*w_8p9>I=vYO;}al-bNSnwl}>2xE9l!tVZuFo zk;G3N7$YED=2#@%=~`ZiGwDDI*_7N*>ABemj zSpN8Fco0*2)a!#`8kWyDf0@DLxx1!iyTNxnr)oEuP4xwF7WK=j4w@rTa}Unt4JTjP z+Y>Tt#%BW-@+oEW*{p}`o3C-qkDtUmU;E+Y6aFj8?uA7Tp70LTRwa-n)a}EweBcsDNPU)UN$)XFgDob9b?pT#ji=}4dN4ulDY4&H|p@x zgz^Y1AJ7i$wK`TbSr?vRXDT`?Z(|5R>%6KC$@34_l@qHT;7UlY*<^c^1tsUTNS6>_F-Kl zu8OfZX)svSxFyOG7Q4`f6mv#XVvqZmZVE_Oq5diCQ!7aGMWtUVZI5To=Cka?2<#Fcn-&FZ5FR7sKqoSczT0 z>HhbDF)q#|T4ZLAG6+Mk+QDDiH5t9$e-K?qRpztL!50LRrfai$OWj2`=nr-2dS*AR z(T1qQyzT4N;=Uto3B&ZnPo*|+UY?GJz%GIAx_cG{h1-@d-fwxgBs(%%PB0Q^!Z%aZ z7+1)U2vSoUEHd(Mf$x^4&onvx-DzJ<(KDd3_~Vxk%ZzELnQX4fr#~MTZ(|`>h{T>7~AVF^Hi8)DX0)g^-*+$+*3EysJt3rOfVc>xrbbofma^PzV*T zInM8mu&Iyiau`NZ|1AVv08H2e1xbg;!L&uO@SsoM+?Hi!W$O;|S&3qYXPR&x88TT` zruWaa13^;hXI)Welo~_~no-XSZEgTCCmI#!Em|6pArT$q3vKq=Do2Pn^~dc_sZcZm zL;C%w?i1t&k;G^Y)jEIFavwXR$0>2m*oSrVWEfjStv_vY{h;oNP?M*)j8CxLmEmzvfAIxi*~^3*Z>obvX>>zXzTaFu`)ln$#HMuE)W`Ndyy8QS8nA&wqS; zH2JF$ll=PtJLu+LtiK;hQ#Jp^@?$u{C%{%J7_N0&Z#^mrROU4eVF~ILCndR{`oQ(% zIUwWX*nuBb9gIR=i@^5I?Ti>D_)C2|vgc zH?U6F0WZGu6v@Ju7(%=s`NNd}ZS@(Ye-h&SmXUqL`t?Z9>t$Q&l3bc`y?^RlX@HVL zUcGGSmO4M{&VYZh@NWIS6%1To9l4lm(&1BFo>@kMMnQMaj}g`Y{{du8`v=xq+F_S! za?BbGx_@L#n&rcYIvew%zHje*ws0%%MgDVK^mfkdp!^96qI(%z9hfzCCSKlm#b~&m zYIJHhFh?KrsJ^OGt4e(u@4@}j@RsTC*|y|jz);PWbm|l}HTh1}aUheq8h8LxMZ6A>O=WjNLW4(6;qRbFeRks3(S@P9<<) z6`wWv5~MqxElpK+77|Je@qZHr%V%f%H?D|PlTERWViWg-I6IfmD4->Gh^0-HPF{uF zqt+m|TqU+woda$Z{^#8b#b$9U#V#JN#pN=V@*6hGVz$E^Jjz+dX^X)Pazwd7i@2hb zM_vg76V4%;c0DO`B0Q!_@mO1Qf7WopWAi*eTT7+mPS{wh9~##JorODrzqii&^z& zWhX43YZeNz|LYn){e7BP+b3dT@w=nVH`A6al?JX)_Lhc8hwkg@8e?mj4;<}`UDZso+j!HM$U$am4FfG9g9n52c=>52h8*8?0Kkw^Ox!M&eej26H{NESYAPK|RoBm!Ha+C?h z$`{pZS(f82IFcLhV=S8=BZ0&=8NJZTzCZV~qQ)OpZrY-+f|2N}9R{^@#DOPgShM~v zn7|5_y4#&!T$h%+JKaEuOhFw)y}_*RsRb^eWyF0}e%!5X)vw7H{7}75KiBR|-=6`~ z+ed+g?9B&O_#~G$ZD@Efb*)YxdR=%p#FVG0oev~u62Rq?a3)+ErM(uh3;$SpoIj8Q zpS26#8Y7qX|1$n6y=~hsHDASrUl2hn*($hsg8E%|FS*hnf_F^o$8an-1;%guy9tlT zEpYBk=p+d!`ug^oX;@4j(}~tMROD0#GDul?7L4wMl8XOl3VnB-r^;rsDQrECr2%~T*;cP@ES~e`O9J~)SniAP5-OZX zN;P!!r=az;EM4+O zmYzb8!TWSxbB@HaE;9re%c7CHTF`+M0iKp&%#4nIrrnD+oEv?led8P7g5d~2FT-A1 zlxP0sR84F5jk`2fAzneOpW?}yGFw&v{49!0>#QX(0Bzr?`=~I#B9F&U3WI)f`?3nQ%gxSTA0pAtns+{ zlF&*qZs(0=E9ZJQ?l}5z|6P5(aAmZ}5AF0>wsGL~elI@zdWWk|=mJm(sO`iPeo1BN z)4TXV>w-<}m>R?fClE@IazhKSps2NI!JTX)6UYh>&r5@;uhuLo4a4P6Bn7e4> zgwg66Rs5`oO^bSe9FU)fZj%j|O%F$OI%hg!O_o$C`qpJ8hqsCiI@mX~!iLtr(Fat6 z4q`h#U!>pQ7Q9Ih~OwTsri zgR7Ws!kzEb9R@qB7q9Jk{yCA^0#V_c49q#w*+OF~!E5)<; z#wtA~;?)~rL}Gp^{(6d84MiILSceEu#R&ak^w~mhcxP`ldH0c?Nz0rmyD&+y$23C< z<|kz82JKis{a8mOr6w>-_m^IM zU^E&v9HW=N-TJ%8NRM~^_!OE$bt)_+ajpG)Xc_sYFb?TTE}cd4O0d|CG?34X1&^ac z9gnK%ZAE2{gYpYv5u)=c8LEDgv(aZeD*3K(ell-OiAYWNcGQocD+NY5EkexbivZjOeY-#aZAfVmhISVLj^_gpq zQYYkm=0b0jsia9w-U?+#eLFD6%H4Z{a(vrpHYYab8SaO5V)^X zRLDP#h~mUa9V%v)l}lmX59Hbl78@{j#s=_$Bue3g8&SZD?cW!FgO)#%Ojua^Do3Iy zurZwl)2H`wQ=MzIi?Dw__YroazL6lonkI4Q>@;AnD8V8L>!vo$;1ukS1} z`S`nFl0?xf9pgigm$7S{MwnRhwC^<1+IPSJpP;8I$q}f+_FFUd$!0bF)*TU(BiOw<+?2pX9N7rx z+C99_dzen*W=VGilO5w^ECt&r4G;&<&w@`80k5woG~c3SpWFJdc}gWIgEH$i$po;h zEmQI1kaU%1A3G(+90lOn4zO%jLGk2S(6k(Op6Z~uo_3cN2^GXhn{_)xOHRG{qK$mT z{Pk~@*b9w`5mz}fgr|6dKuoj0pn-kfg4l>e_awFJ2YI=LmwwlV3!82@wHC7?A39V| zvE4Mw2bhQo2cwnw0qf(;F~D;GPh!Jx&llIRL#Pywb46hO2@Bo%Eq}gn>IIaa0_>Vy zxL<|+FBXGW?=L|2#D#Wruw1)2K1$EP)#L^sLbb%QhfuiR8Mea|q3swdWY`{-OA zoT1b31mjQ}d#Ymmvmf>#-mK2*)rIAdn>uyI5zaf!Ku<7IB@fq$+OCMOJ7M;(TO^c#W(-fyl;q+;)aq6r(7RUjU)MBR*~o1mGI2TPgiXw>bqHUj1^YY zV|=84G%(rZ^ECz#x0{gc+A`w@^qkDawJ^C!i-=dyqz_^LMT^%&jbgZUS&@C~Q-*O8 zW9lHzhDjgR0dMX9kfl?YPk}<6Tf9U)6t@^}(cP_=-BN_wYq_}~w{P5Y|NkFV#Tm#R z)3OSv7AGb@!>WJOFqWln!@S;E+|XTkA+zWxcTbiYzt4dH7aRA3=_;FF6ENd$l9L|gy@td}JjDvusaYCd z0bd#Zi{-GJ&u1m&hWjwz+B_o^ z(_RtJtSDlB-NFLkG9LyII5|K3FO8C2S%;oObY7#BFf_m}41q&`VO(97Gr4O61wv+h zb9z_fJ_2?piOw0^tPi222W^5~`0jWM@;f=2e;BuHBDxLOf!m-dKR$TtM3bwY_~sT( zR|dHVJVtnlfgN)>@?S8TrRvyw6~Xw~Jss1G7-Su(ety&>>Un96aWMNY(}%{?o=*Sg zV*;2kwuPx`YZjtUUXD{q*_ws2M{W@QSLd>((QYb2`Q68wt5`!s?a7NuNfT=WMY(gO zPR|IYN!bnF&eJ73FkUlHlfcJm7zQJ2*hq1kN>ap2>ydkzb^r#u;m=!*$d+)LNvRQm z%qdX!4Lv$>vRx+L%kc3?%k~C5Bbow`m8r6I_1Lh~@WcVOx0G`Jw7xV#;A+eW=UqK`NsEIzt9i~p(QAIP1qHRP>2D0bLmsPwJ zR|d}>dN=ovlEIA?VZJY_2ZT)x#8(LUd49A}cf2N&GqNzShX7(2o|d%Xi+uR)Y0 zN%+%}LW8EGp5xVaB&o9nWg%4ytpfq;?%=!1B8q~d-VWl(|Hsl-Mzz&--9ph8FH)RR ztQ2>bVgZVKad&rjcXxLS?hxGF-6goY_RI6$J4Sxw$2n&tXRo#QlDX!LTd8Ah&vth! zR@@XOYcVi(d0(KlsalI=<-jE4BG%?A42M&?ny}v^b$}2tWh)JJIwa2}7@`KKYHWAVHj59^W5S{f$Av98= zwr<61Ja;rR8|7xk8`3=lQT1c5SMoL7bYN14l9(71K0aVTbC%GxgxdKTLJs)~nU7uf z7H@cEaZn!>f@g1hT$DpCAy-lg*{YR;Q+x78FCgB6NUv`h+>S9^Nq?kRkbv=Mas#oB ze9J(43_HB^<7tqC^(7X+F5LpGW4r=k*26 z>>Da9b$0KL35;&m06~Dmuf#}ulT=%!6+6P9L@WBz)aVM6W=ze#ghmZ9j%|pc!m!LJ zdR0#V9_T-wsbzh5N55g37DiPW5bx;^@Y%stqFw;^iY`CVtv+ecq(#Js!}s9;I6j6C zNew-v!S31FIu{ki>x#jcfn5|e`bNAARq=gLTsfip7#85%?9!Uuq@fi0PRT&-37om- zJbo}nwvjY>BPRPeIqSw}10x!keH53lD=`>(Mkq}(Bx=(3$+LFa8)Xr$z0U*bxh)Q; z$=XS+#W<`4tt)HUL6**~H9VM>lEBL(jQU%4IZ$eQpQ!l5X9wHHa5RHDiwil}qkM~i z3xMuCNeJ7~!_57t;0j7ngrHM%JoLt?(b=BOz?yK=zHaU4DD_`Xf|9V$$LUoC7FBKq z00NJ};eE;8i--m2ssE^Hvx1`gFNiH*I=|Mmmlzbd*^xp9tvKsBV2k^~jlf!Vh%rER zod0$V;Ceb_!?n(V0|~1jxwHa+)FV`{`M*pn5E457?~E)RNJTI-Y7sLR zb|GF4MH$ciJ!9p@iSaWeTCs0vKPBFEtPY`sg9M9aZ z|Ls?maTNDY4nIASbp84I>&4p6k z5Xg2DIohq9i|l?_!nQAX6_`JJqMnSrl@!AB6CO`($hO5ET;Sj$i(5}JqQuqGD6<h2JiwdxAjWWgtfYo_P?9lS=nIPS`r!nrc= zhupfxqAye?aX_D?Q&M$|(P)poOSJwT_8^363 z1W>JMW~n&5B$`&FBz7Rw$?9J`1##NqMgZM19joirv^uD{UU zgc(T)yOcF#VFp$oGVVy-gS{5oUB^C;P3Zdc{+9*)FL=iJ<_S4YuurU9zA5`Q2g2xW zS9)VU(~tyyNDe{bmtJ=o;;^j2R|VERE?W90NYL}M!f(RBrXPp$?YE&L#~IXJTSV)O zyF5diAp&Kqf@0gRrp738H8Mi~CWK`4X}0DZwnjfM%NBTkmi$5lm!is^7wTaa3Cmj= z_2g%?`Fx&ENwiy%H$~0qkshwy_Y~#=(nb{W<-cM(UEc0VsCM^quWfC~UA}vVDHP)1 zOmc#fodgF)r)!SW{4$G-M)gBvvY`nHp8QR{DSXEmkF2Odw*r1}xB31^#9`RfPUF28 z`mu4`iu&U9k|=GJSGt&#oWG5p6AV$mZBm2DMxQT9!-(aJ0}(&tCe`USH{{ov^dUxd zh(5w1kFGQz?70@=+zr)pC4VaSERM~r z5^!R`K{Cmq5S1~mw{$$JK=$BkWZQ^*Ys2=K{6+xxhgLcjk{%8zOG z{@wnNg!)tWZMZyEBrS+AqYVEe$WpyaZDj|dcZB+Eb};iv$$IyjS_q^NjDFU83F(;~ zFS8;li$iU5xss~CB6o&W!G4k{->cSxKV;ZmzEg3L8J9k(<=9n&r0@T4X)V?mJRz9N z`p?`*r9r7&&cf?wYjNr2%lc%&q~WK=yq#5l4ubYQ{nM(LI(+}wng{%;An&EO!$#5~ zVoe>qoFf0dDV?%OgVeme!(Jou=&+yr>T%PX^APX$Aocl2?{Sp#eaUC&-}3&FuGPBY zUH)rw!B)%sq0xQuaDZ9s(y7m!R*a>w*`9-E7h z^0BV8z9@WMr8DhL0p^0!z0ASV)uH)LA;ieGboJ6L`LFl!Jwb8~Htv0EHJN?PpIfDA zT-~e-oWZTbmtWbxq6u&J%j!0^Xfl%=!C$2Cxuue@aB&T%yXuxkm*fIE@XSjy&32O& zyVcer_L~B?cI_{-T?3LQSNi`B6n^6NKH((5(hbNlUfx|pK*J%n{UsSFpRWlcH*FqD}>&4~Fg zu*HU6_M^LP_qf&|l{hLim8JMvxFwv{vjk<_;WFs9+M`N{&xdc`uvq^w{Uk+^|6oH= zABw4&@z;HFw?l)y{YH6;jewtvAe%6;iD|A`(IAD@ql$1I#WtzGX;cOx>7#!$A+&_7 zYDV%S1Jk(EYk}d7z*TnDd!#zvr7Q4Y8zQ&x902_mzSdY(xN;@7dAA(Yx{tt+UKlOG zVFC?zm|hs*3g(Q)t=CZTdKcC=AwDfO=S`jV_~D_p7uM5!Vp;lqYn-U!Z;t<}@(^Kc zeN0F)WUShQQHg~x;4|J~d{jmgB7~{hW$pGI8}LB|DZ#Wr`mTKjwoOk6S$GJdU26K~Bce`DpZ+*Z z!c*FQu6lL;pF<&xHIU|>)wfX|VaJFX%&6U|@bNW00xWv^?G@OTH{yFQ11ssDBq5gq z)`X=DUrSi8}Mi5bOyUBA53utyLU*4-pxFh zeM?@gfG8tG_d5-L43Bm8?hR|yO>)E9UmE(_T{NA3<`|QO3)=buc|If)%|<=%6GhVG z$Yh_8NSrwsQ5YwXpdr3OAn)ZfYrPi<;9cZ^$Ym-0wP8p)e=TXzwe3%%$#Xd487^D5lk;#^12USK-TzcLJIUuV* z1j;jH3_OY(o728$^=TWHD1}2P~jmKlu=y+OWHW7kM|rs7FTOJw_aAQfxj{Vhl5b zf=*X6Rb#Khhd%9rddai=pEXlH0%|_4Rrvn5RDb_lD~#`kl4Y+Z4?;V>NZ7RAF zM6oa+JO)YiJz1zv;M1A4moerUKdWcL69Xx@+s-UyPpd%PPUp5&ym_N+$?vD8p?p{z zy*3LfpY(CQaVY7A3|c*6hkCX;JWyVujz(POvyX1I#S$_d#F4Zyw{f4Soj>?~y&4{jQ7HPq_w;=D=8JHMbpDU zgxN+{t4^9u5QZOte?P`3S^Wi3OX-WA!F5r&42a?^t{vTOykfhP>Bt z0bpF0QH!rb&3@qg3)E$SDYW?2u6Ln3#eYy= zQ$CVDiiGa!-`=|JV&!?L@v@G(-}Pd3jFBAX|q5SPcIZ(rL(&Ny=gk1ed|AD_Q-&1&Fam{5l! zqC_DW(BRZN`)?Hr5gHl_Uf`2D9Zq(xrb0Ys3VUxa1+wtNDwvff*aE-1n4*H9&v7(q>{r9BkU0Z-+ zXustxg(UtTlr>PLLPt?~Xus?v;Kc5ko)Vy$mbjW1>x7{;p?i?oun7TosK%qdMf7%GD}4t;9s` z_r&GygW-Fk`J`CN=n$S_K;L||PlYzn^`+m5WJ_LAyBewbS{VzUlXBTs)jA?6A-(RS zV!|iPc)LtX1tj90?tLnntL_fUDep|2?L-RU2g;w807Y!>PAG_L=RGm5PGBB4wM@ny z+p4dsDZmX&70&m92j7I9P?fZiINYg5uzKB;RgV|63l?`Py`*t+luH&&8(yj(PuSJz zxZhqHRA&=~d%oCLtALd6g{oCbw~nGdLYMX{Llk4|`H6%VZr=~6U$@X>LX^35Dlu)X zgHNgge^pax-zl2`r@z|@a>?1AE|t#H5Mg%3s6@yVSOd8$vCkJ)U+7%j8??=bRpYIs zE&aeW3!>E*9?u0n`9SaR#uq1f61Z|KaK#Imwswm;>7r`fKd1+`{d$*s=xQtuxsAu< z6m5cbdNV1&ew26W4d_a0l}+go-*~kHww(J9p4Wgt83K%S+WFhT&1EdIn?U zi>**eaQNW}l9$g~RpSepPks^WDRI$%S7UUc{?RFUttMY&Dq8ps1t-Cq=o+c0;osK| z;Pp_TE1KzuAxdd1Ba-TH@^H@I;uEXFb~@i ztwt4(Qp%!>43)saJ2#Ye4*m^L$r@&Bf@v>1i_4pE5dR+TkJv&Msx~Mz9_7*G3oVJD z(0BN{TQ;`1;80ywEq|fzJE2d2889wvD9$$RUNV1F717T_i#skCt&Y!GPm`&>`d}gLB$JZ2mw-Zp zr*5^uq9vm*N{NYw5w12xM99^gPK;3c{LB7*TIqyOD6FbDmn3V>#qF1};-wFkY&;z} zpt0J8O(CSB1lDFh2jrCA2wyU^z+Y9-2w7D=9exltPZ9)M=p8zHa@+vX>yXGbcW)Fs zdP#yb--rSe%C-^05x#0ZCa=TfUGE0-6x>c*gVMpK+ku>|O)SxKIyQ&l<91ia8r&r# ze*%wG+y3sG#h9}inbW9KGqH4Z{?*jn_4o&6tAbGY<9xeTUf*MKqPxW>f$>&uipY#t zZbG=aAuMLp+gwU8hCB*LOb^Dq^v0&d{5oC-(0Hx3X?q|htLC(*^ZY9-7lMM3beIHX z`jmb67x??>*s#CD`D&TFF&o}pXGCPVjjLAVarQiJ(!o>$Zpy7HY$6(=Lyn?zMI&D{?e3qa;pr)C@r1zVO>-A zE%(9!iJFJg15q%X&DBasoV(a$b+jS=w{)^}RPM+(_TMYyKG`sD*jv~yllhWeWU){^ z^A#bzOenn~TYP>=mh@#)_`~zd=3X6Kbim;O#`4!cp ztIAN&nr=;MsL3%x+YBP^L&u=UIzHej)^NVB1!{Q!5t}GkF&69j7k><@RVaJjJl65H zDXhtIXW|NvO6@#};f3e?bTcxzjhfw_TOELF%dtgyY7;sYZad3KmS8M^@#8q^&P8m> z$)A9&z(3kk3{!vG{P^kuz43ikY{vaj9+xo|fcO|nRN3~`iSpo&?s;F_C0#Q&9PS{z zL@;HJF^A8nqMiJxsi?WEaaX#fQB*opZ`plc4O<(Eio_LiR>(#?Sd2S#29?p0^#u zNg4092ClM12TjR~L5kAVa(yyaP;`5eu!Jjnd9R}?nZgtUpg>V%nTm?;?B5b$$*ud& z=Gn@uL`v@??@BQkvl8xq@EYA<@yzOxTM4?6sCM;#aN+aNH5o2bJv`}Oj9rSJ=@3RG z1>&?^GG@c^O^m7F_h0eIV}PvbKfNFI5zflkJXn>s?spq+_9R+jxvd-rCA`$%D6m)$Ru1RUDvgX;O{)p0GrYU8rnqN{o-)oEk?v3 zc`JWL2&3LTOAX}ABTH-WviNO8=0*Io*t+n2^MG>Jb6|?km-(GdNDX!V?@%}=eY}}7 zvAUuu|D^w&cIqR%o;`vU;ZDC2TfwG1ldhqm)a2&oY0FiKJ69cFBLWlaSB|!FT!PPi zd-%BUHs^@*NI$yyG6=6}HIZ>4I$8YYo-{$YbLnKcjtVw_-%mc|1d^ra11JlEKb3h( zo2#AlWPar+TbYem%zL^wY?o# zw~&> zvJk>!Aq+zig^ZsXUwwh7oL5Fmu_JPz;BXKx*@&%P@az86%s7@G35svO>|MBm%%%4C zusM2(S$4TPh>2&C;9-~UDV+t4`yjwFE^obrBGe?jUKiy1c(Go7p3H20(nC7z8^#xA zH>sDGHb)!5lYy%n+!pjG-%3K~g860ECN0esu%h9G@;R=xdY|)JmJu|Q4B99z5#~o( zUT3LjCkVDFx8{a#jZQIUXOWW}Av^;{2PirP^cd)If->;6S~W|OI}wL%pEU*d5CuJTjjL3;f4X!%rJR@NZ6B{P{m`B0l{hUD8oR znz4=T3!|T)sTwXY_;cV9g3m~T&HEL!Y{^ery|!1&IENRFT2FwB^1$iYYPH!_ZRDe%}Prv(`~ncZ&D(MdRgt6;RCybtTF?7$3N94n#Kvw!2IM(+|Sy0G!pYF$Elg*-OXE}KE@u?dZYL^ z_}0`nN`(KQls?`5O?k4oM;fc1Q2rX-#rk^nvw^$uQCU`_t?ZigHH%N%y9=&wzrrRS z7>AI~lp`Rc$33Ext2cR*oNc_{*Yj~D$8eB?jMxI8aboTa4jXSA(a)Y zo3n>y=e}FmlKRh@)SGY2y*!T$>`*!ipPpHY#E1 zITu^?15GIjSj6jFm|hlfq26aD@GWy>)`^lyL%E!mPx&4rM&`hKc4e2Q)xrayq@-S@ z9;z<1%=1nDICk)T9diC|d0nTw# zs*3X+3ycHp@gHvkC_*f2Gi602e?3!Ye0Tb7(Jg%(9TK|B$Qku$*j`}QsW4CA#~GSa zgg;W<8x36+zo(!4Gh3J-Je`_a8^&{*fE0)P+&FFlm5p+v&)+a=ciL?D=49taxlz_d z1*C0W`dEU$C32gdQH5X9C8sG{y6>r|%;i4xNGzE+?F7rqXN`DV&P}FHgK+~$5r7~f zc(Hj^(@w5V%Jca@Zv~R=Ks+v_>=+jZG>uH#f|u$+DPCY{gY{$~Y(yg5X(AlibLTzH z$v#CX$ORxCZna`w^Ocw{^bddT0L( z%sCr_{Yb)^VXK)=yG&kp#x#$A*dkM!LsaG+u4IuNBua?)0Ql@9bG9>M)Q zj0TAUG=~Yo91$$CfT-Wi%}WNQ1q$?jz(o~UznH(Y`#Te(rVaH(RjEDHHeQ=-A2R#r zS-I0)4DEnc+W1Y_yTWO^1Bdk607TFz&Zq1dD$ntlMK&63uIuOC%dkpTcHc~nt33YROPWsASfPwcXV;mpVHvVpIN%BWy`0e7_I`KD} z14j@^xRbpA${;m_tnm?Wg0R{DQy9(Q+ooBmW0jP3;t0<1J$-#hr;#Rh=wN9_lI>Wl}QhJF2R(dEs*0=@^ zw_ZuPm0EWaoPvKO>CB`Jg5I~2L6 z`~`wY`sVq(V~>{jx^?Z=j2q0zT~EG#zKG}WoTKWKPc6m2CIIbH)TdvDB~?%0wTaQ4 z9n5;;pq!$8%A!XZg7yy^nkJ2R-l-Rv?6%a@GzERb=4~FQ*D=;yo^-q(zm2Fl!=;vu@5`9s4kfcjX!6E#oP%J*c6D(Glw zm!aJZ&IlT)8KSm={t*E7#Ih6oqn{4sl5I27fx{udcRtAIW#l?R;q4SRU~Iu*Iod6A<$yMFS@!(IXH#OqwUQWOLjyTHwruXW3+J{vcaw#(P9vM zCu3K+&gZyU-g&MnJv=;6E`<5~f>BUl$mrwjO5KBo(s35* z#C?<62IX;C(Lf7jGcF}v)$4KW<`xaG0h6&Aqijm`xqK(odpNlRMfx|fi&V4(sUgsU zVl30j-YkRaSF!kLYqa$-n6=yu$dlOMbi=)IG@e`KA2riqBB+z9RV<#Fg?c! z5j{A)0?4Y}6JPEv?Uu&)Q*VCW3MqAe=d-Y?QN0|aE*Yj0_|D)^x*7T?qLA^jH-$d$;eUdmS5+>4@+aG{-j?Zb9 z?@U$FhWTY(?pwbx9wNyms=3{aT<@~p_-!c0fyGZ&oBrZ~F#HwRsrP)bh9*k(1*mI< zogpdlk64V6x@5{`d&`X0y2r8_*G$XF3^e+YojW?`7&NOEf6Of=4gY z@`C=>*fzC_`V=|}3#32dbv>Qa5#Ns+uoaN%2%*CW-$$yPy^*8B*Vgr)voKSwB2dxsu zVz4SSy@Cf@)y)b&&dyjyR+Zy6hX5)E0E34lViG2Yv7nx0nylH1_;;y+X7AkAg!d## zJb=0A<4O+*b*r#W@^+IJbQ!1{(v}ilAirf}3b3TZ-uR3ws*}iSm8`tynmpD0@N%gh;|2 z{<2T=O&QeB^dFqHr7I&l!$zvC(UB8WRq1#Z93>zQnWD7Ql$f~bl^j!ngIbY$T2a~o z!sUiQR36iZ;5G;|T)3eS0@qV0xg!^yYVA1eH;2uapKW68u3NGzyAQz}ULVFuA|r5w z1Qv2jeg1c}uq@)wBuP=GF2px(KTD1!3i}-k&zK_#l^gayt+0sj5$#Bs<8CV;4fO=xEcHP^==9>Z5LX)^LR zDL0GGg3^0M-~iTih}?Z}XG#2x@cb_5oGb4?n>q?^gN#*WVx%}YM_IvHdtz#ZQDIPZ z&uXAUK)3iTo>RDisoqB$}Q&A8l^*^Y8P-K9AP&j3_QQot>z<3S}A@z5dkp4`MIGH!Fg<0%^s8xqNEs6(epW2 zyr|PolKsUz46`l@l@`*DD+5yRr(p(cA35{$o?|26zs%%82!}MxoPxR?b;P>RAM}>j zYuF z0O)4@RJzv={YxB<0GhhYDpu@jGu|O&+b&0F%$4}xJqx?3+U)gGUWwRfaoa@atmYO! z{0Z3^zAjD#8`llaT9JKVNOP8PJ75Q7xa~^KBDWi#kXvZE{8(v2_!c%e$Xp}-mTG@) zegh}00!Bg!syG_fGjoDwGaY}l`9jJexy!glt^T&`R#-c^8nub_j>RN32 J;yU9 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
j8z{@Fuv&dWHDA$2QjY$kz`eu&05oE6sd+wY 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
kW*on}lWFix#HK&Vxt+R~T+8 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 literal 0 HcmV?d00001 diff --git a/artemis/image_processing/image_builder.py b/artemis/image_processing/image_builder.py index f254ae4a..4a581b0a 100644 --- a/artemis/image_processing/image_builder.py +++ b/artemis/image_processing/image_builder.py @@ -124,7 +124,7 @@ def draw_arrow(self, start_xy: Tuple[float, float], end_xy: Tuple[float, float], def draw_box(self, box: BoundingBox | RelativeBoundingBox, colour: BGRColorTuple = BGRColors.RED, secondary_colour: Optional[BGRColorTuple] = None, text_background_color: Optional[BGRColorTuple] = None, - + text_scale = 0.7, thickness: int = 1, box_id: Optional[int] = None, include_labels = True, show_score_in_label: bool = True, score_as_pct: bool = False) -> 'ImageBuilder': @@ -141,7 +141,7 @@ def draw_box(self, box: BoundingBox | RelativeBoundingBox, colour: BGRColorTuple label = ','.join(str(i) for i in [box_id, box.label, None if not show_score_in_label else f"{box.score:.0%}" if score_as_pct else f"{box.score:.2f}"] if i is not None) if include_labels: - put_text_at(self.image, text=label, position_xy=(jmin, imin if box.y_min > box.y_max-box.y_min else imax), scale=.7*self.image.shape[1]/640, color=colour, shadow_color = BGRColors.BLACK, background_color=text_background_color, thickness=thickness) + put_text_at(self.image, text=label, position_xy=(jmin, imin if box.y_min > box.y_max-box.y_min else imax), scale=text_scale*self.image.shape[1]/640, color=colour, shadow_color = BGRColors.BLACK, background_color=text_background_color, thickness=thickness) # cv2.putText(self.image, text=label, org=(imin, jmin), fontFace=cv2.FONT_HERSHEY_PLAIN, fontScale=.7*self.image.shape[1]/640, # color=colour, thickness=thickness) @@ -165,6 +165,7 @@ def draw_bounding_boxes(self, secondary_colour: Optional[BGRColorTuple] = BGRColors.BLACK, text_background_colors: Optional[Iterable[BGRColorTuple]] = None, thickness: int = 2, + text_scale=0.7, score_as_pct: bool = False, include_labels: bool = True, show_score_in_label: bool = False, @@ -177,7 +178,7 @@ def draw_bounding_boxes(self, text_background_colors = (None for _ in itertools.count(0)) for bb, bg in zip(boxes, text_background_colors): self.draw_box(bb, colour=colour, secondary_colour=secondary_colour, text_background_color=bg, thickness=thickness, score_as_pct=score_as_pct, show_score_in_label=show_score_in_label, - include_labels=include_labels) + include_labels=include_labels, text_scale=text_scale) if include_inset: self.draw_corner_inset( ImageRow(*(ImageBuilder(b.slice_image(original_image)).rescale(inset_zoom_factor).image for b in boxes)).render(), @@ -185,7 +186,17 @@ def draw_bounding_boxes(self, return self def draw_border(self, color: BGRColorTuple, thickness: int = 2) -> 'ImageBuilder': - return self.draw_box(BoundingBox.from_ltrb(0, 0, self.image.shape[1]-1, self.image.shape[0]-1), thickness=thickness, colour=color, include_labels=False) + # border_ixs = list(range(thickness))+list(range(-thickness, 0)) + # self.image[border_ixs, border_ixs] = color + + self.image[:thickness, :] = color + self.image[-thickness:, : ] = color + self.image[:, :thickness] = color + self.image[:, -thickness:] = color + + + return self + # return self.draw_box(BoundingBox.from_ltrb(0, 0, self.image.shape[1]-1, self.image.shape[0]-1), thickness=thickness, colour=color, include_labels=False) def draw_zoom_inset_from_box(self, box: BoundingBox, scale_factor: int, border_color=BGRColors.GREEN, border_thickness: int = 2, corner = 'br', backup_corner='bl') -> 'ImageBuilder': # TODO: Make it nor crash when box is too big diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index 7b0e67e4..2eda9abf 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -2,7 +2,7 @@ import itertools import os from abc import abstractmethod, ABCMeta -from dataclasses import dataclass +from dataclasses import dataclass, replace from math import floor, ceil from typing import Iterable, Tuple, Union, Optional, Sequence, Callable, TypeVar, Iterator @@ -12,6 +12,7 @@ from artemis.general.custom_types import XYSizeTuple, BGRColorTuple, HeatMapArray, BGRImageDeltaArray, MaskImageArray, LabelImageArray, BGRFloatImageArray, GreyScaleImageArray, \ BGRImageArray, TimeIntervalTuple, Array, GeneralImageArray +from artemis.general.geometry import reframe_from_a_to_b, reframe_from_b_to_a class BGRColors: @@ -453,9 +454,15 @@ def is_contained_in_image(self, image_size_xy: Tuple[int, int]): # def from_lbwh(cls, l, b, w, h, label: str = '') -> 'BoundingBox': # return BoundingBox(x_min=l, x_max=l+w, y_min=b, y_max=b+h, label=label) - def to_ij(self): + def to_ij(self) -> Tuple[int, int]: + """ Get the (row, col) of the center of the box """ return round((self.y_min + self.y_max) / 2.), round((self.x_min + self.x_max) / 2.) + def to_crop_ij(self) -> Tuple[int, int]: + """ Get the (row, col) of the center of the box in the frame of the cropped image""" + i, j = self.to_ij() + return i-int(self.y_min), j-int(self.x_min) + def compute_iou(self, other: 'BoundingBox') -> float: """ Get Intersection-over-Union overlap area between boxes - will be between zero and 1 """ intesection_box = self.get_intersection_box(other) @@ -617,6 +624,10 @@ def create_gap_image( # Generate a colour image filled with one colour return img +def create_random_image(size_xy: Tuple[int, int], in_color: bool = True, seed = None) -> BGRImageArray: + return np.random.RandomState(seed).randint(0, 256, size=(size_xy[1], size_xy[0])+((3, ) if in_color else ()), dtype=np.uint8) + + @attrs class TextDisplayer: """ Converts text to image """ @@ -720,6 +731,169 @@ def mask_to_boxes(mask: Array['H,W', bool]) -> Array['N,4', int]: return ltrb_boxes +def display_to_pixel_dim(display_coord: float, pixel_center_coord: float, window_dim: int, zoom: float, pixel_limit: Optional[int] = None) -> float: + pixel_coord = pixel_center_coord + (display_coord - (window_dim / 2)) / zoom + if pixel_limit is not None: + pixel_coord = np.maximum(0, np.minimum(pixel_limit, pixel_coord)) + return pixel_coord + + +def get_min_zoom(img_wh: Tuple[int, int], window_wh: Tuple[int, int]) -> float: + return min(window_wh[i]/img_wh[i] for i in (0, 1)) + + +def clip_to_slack_bounds(x: float, bound: Tuple[float, float]) -> float: + + x_lower, x_upper = bound + if x_lower <= x_upper: + return np.clip(x, x_lower, x_upper) + else: + return (x_lower+x_upper)/2 + + + + +@dataclass +class ImageViewInfo: + # image: BGRImageArray + zoom_level: float # Zoom level + center_pixel_xy: Tuple[int, int] # (x, y) coordinates of center-pixel + window_disply_wh: Tuple[int, int] # (width, height) of display window + image_wh: Tuple[int, int] + scroll_bar_width: int = 10 + + @classmethod + def from_initial_view(cls, window_disply_wh: Tuple[int, int], image_wh: Tuple[int, int], scroll_bar_width: int = 10) -> 'ImageViewInfo': + return ImageViewInfo( + zoom_level = get_min_zoom(img_wh=image_wh, window_wh=np.asarray(window_disply_wh) - scroll_bar_width), + center_pixel_xy = tuple(s//2 for s in image_wh), + window_disply_wh=window_disply_wh, + image_wh=image_wh + ) + + def _get_display_wh(self) -> Tuple[int, int]: + return self.window_disply_wh[0] - self.scroll_bar_width, self.window_disply_wh[1] - self.scroll_bar_width + + def _get_display_midpoint_xy(self) -> Tuple[float, float]: + w, h = self._get_display_wh() + return w/2, h/2 + + def _get_min_zoom(self) -> float: + return get_min_zoom(img_wh=self.image_wh, window_wh=self._get_display_wh()) + + def zoom_by(self, relative_zoom: float, invariant_display_xy: Tuple[float, float]) -> 'ImageViewInfo': + + new_zoom = max(self._get_min_zoom(), self.zoom_level*relative_zoom) + + + invariant_display_xy = np.maximum(0, np.minimum(self._get_display_wh(), invariant_display_xy)) + invariant_pixel_xy = self.display_xy_to_pixel_xy(display_xy=invariant_display_xy) + + coeff = (1-1/relative_zoom) + new_center_pixel_xy = tuple(np.array(self.center_pixel_xy)*(1-coeff) + np.array(invariant_pixel_xy)*coeff) + return replace(self, zoom_level=new_zoom, center_pixel_xy=new_center_pixel_xy) + + def adjust_pan_to_boundary(self) -> 'ImageViewInfo': + display_edge_xy = np.asarray(self._get_display_midpoint_xy()) + pixel_edge_xy = display_edge_xy/self.zoom_level + adjusted_pixel_center_xy = tuple(clip_to_slack_bounds(v, bound=(e, self.image_wh[i]-e)) for i, (v, e) in enumerate(zip(self.center_pixel_xy, pixel_edge_xy))) + return replace(self, center_pixel_xy=adjusted_pixel_center_xy) + + def pan_by(self, display_rel_xy: Tuple[float, float], limit: bool = True) -> 'ImageViewInfo': + + pixel_shift_xy = np.asarray(display_rel_xy)*self._get_display_wh() * self.zoom_level + new_center_pixel_xy = tuple(np.array(self.center_pixel_xy) + pixel_shift_xy) + result = replace(self, center_pixel_xy=new_center_pixel_xy) + if limit: + result = result.adjust_pan_to_boundary() + return result + + def display_xy_to_pixel_xy(self, display_xy: Array["N,2", float], limit: bool = True) -> Array["N,2", float]: + """ Map pixel-location in display image to pixel image. Optionally, limit result to bounds of image """ + + pixel_xy = reframe_from_a_to_b( + xy_in_a=display_xy, + reference_xy_in_b=self.center_pixel_xy, + reference_xy_in_a=self._get_display_midpoint_xy(), + scale_in_a_of_b=1/self.zoom_level, + ) + if limit: + pixel_xy = np.maximum(0, np.minimum(self.image_wh, pixel_xy)) + return pixel_xy + + def pixel_xy_to_display_xy(self, pixel_xy: Tuple[float, float], limit: bool = True) -> Tuple[float, float]: + """ Map pixel-location in image to displayt image """ + display_xy = reframe_from_b_to_a( + xy_in_b=pixel_xy, + reference_xy_in_b=self.center_pixel_xy, + reference_xy_in_a=self._get_display_midpoint_xy(), + scale_in_a_of_b=1/self.zoom_level, + ) + if limit: + display_xy = np.maximum(0, np.minimum(np.asarray(self._get_display_wh()), display_xy)) + return display_xy + + def create_display_image(self, + image: BGRImageArray, + gap_color = DEFAULT_GAP_COLOR, + scroll_bg_color = BGRColors.DARK_GRAY, + scroll_fg_color = BGRColors.LIGHT_GRAY, + nearest_neighbor_zoom_threshold: float = 5, + ) -> BGRImageArray: + + result_array = np.full(self.window_disply_wh+image.shape[2:], dtype=image.dtype, fill_value=gap_color) + result_array[-self.scroll_bar_width:, :-self.scroll_bar_width] = scroll_bg_color + result_array[:-self.scroll_bar_width, -self.scroll_bar_width:] = scroll_bg_color + + src_topleft_xy = self.display_xy_to_pixel_xy(display_xy=(0, 0), limit=True).astype(np.int) + src_bottomright_xy = self.display_xy_to_pixel_xy(display_xy=self._get_display_wh(), limit=True).astype(np.int) + + dest_topleft_xy= self.pixel_xy_to_display_xy(pixel_xy=src_topleft_xy, limit=True).astype(np.int) + dest_bottomright_xy = self.pixel_xy_to_display_xy(pixel_xy=src_bottomright_xy, limit=True).astype(np.int) + (src_x1, src_y1), (src_x2, src_y2) = src_topleft_xy, src_bottomright_xy + (dest_x1, dest_y1), (dest_x2, dest_y2) = dest_topleft_xy, dest_bottomright_xy + + # Add the image + src_image = image[src_y1:src_y2, src_x1:src_x2] + src_image_scaled = cv2.resize(src_image, (dest_x2-dest_x1, dest_y2-dest_y1), interpolation=cv2.INTER_NEAREST if self.zoom_level > nearest_neighbor_zoom_threshold else cv2.INTER_LINEAR) + result_array[dest_y1:dest_y2, dest_x1:dest_x2] = src_image_scaled + + # Add the scroll bars + scroll_fraxs_x: Tuple[float, float] = (src_x1/image.shape[1], src_x2/image.shape[1]) + scroll_fraxs_y: Tuple[float, float] = (src_y1/image.shape[0], src_y2/image.shape[0]) + space_x, space_y = self._get_display_wh() + scroll_bar_x_slice = slice(max(0, round(scroll_fraxs_x[0]*space_x)), min(space_x, round(scroll_fraxs_x[1]*space_x))) + scroll_bar_y_slice = slice(max(0, round(scroll_fraxs_y[0]*space_y)), min(space_y, round(scroll_fraxs_y[1]*space_y))) + result_array[scroll_bar_y_slice, -self.scroll_bar_width:] = scroll_fg_color + result_array[-self.scroll_bar_width:, scroll_bar_x_slice] = scroll_fg_color + + return result_array + + +def load_artemis_image() -> BGRImageArray: + path = os.path.join(os.path.split(os.path.abspath(__file__))[0], 'artemis.jpeg') + return cv2.imread(path) + + +# @dataclass +# class ImageBoxViewer: +# scroll_bar_width: int = 10 +# background_colour: BGRColorTuple = DEFAULT_GAP_COLOR +# _canvas_cache: Optional[np.ndarray] = None +# +# def view_box(self, +# source_image: BGRImageArray, +# center_pixel_xy: Tuple[int, int], # (x,y) position of center in coordinates of source_image pixels +# window_disply_wh: Tuple[int, int], # (width, height) of display window +# zoom_level: float = 0., # +# ) -> ImageViewInfo: +# if self._canvas_cache is None or self._canvas_cache.shape[2] != (window_disply_wh[1], window_disply_wh[0]): +# self._canvas_cache = np.empty((window_disply_wh[1], window_disply_wh[0])+source_image.shape[2:], dtype=source_image.dtype) +# +# # TODO: Fill in + + + # # def mask_to_boxes(mask: MaskImageArray) -> Sequence[BoundingBox]: # diff --git a/artemis/image_processing/test_image_utils.py b/artemis/image_processing/test_image_utils.py index b5f597a2..83463729 100644 --- a/artemis/image_processing/test_image_utils.py +++ b/artemis/image_processing/test_image_utils.py @@ -1,9 +1,12 @@ +from artemis.general.custom_types import BGRImageArray +from artemis.plotting.cv2_plotting import just_show +from artemis.plotting.easy_window import ImageRow from eagle_eyes.datasets.videos import DroneVideos -from artemis.image_processing.image_utils import iter_images_from_video, mask_to_boxes, conditional_running_min, slice_image_with_pad +from artemis.image_processing.image_utils import iter_images_from_video, mask_to_boxes, conditional_running_min, slice_image_with_pad, ImageViewInfo, load_artemis_image import numpy as np - +import os from artemis.general.utils_for_testing import stringlist_to_mask - +import cv2 def test_iter_images_from_video(): @@ -111,10 +114,47 @@ def test_slice_image_with_pad(): +def test_image_view_info(show: bool = False): + + image = load_artemis_image() + + pixel_xy_of_bottom_of_ear = (512, 228) + + # image = cv2.imread(cv2.samples.find_file("starry_night.jpg")) + frame1 = ImageViewInfo.from_initial_view(window_disply_wh=(500, 500), image_wh=(image.shape[1], image.shape[0])) + display_xy_of_bottom_of_ear = frame1.pixel_xy_to_display_xy(pixel_xy_of_bottom_of_ear) + recon_pixel_xy_of_bottom_of_ear = frame1.display_xy_to_pixel_xy(display_xy_of_bottom_of_ear) + frame2 = frame1.zoom_by(relative_zoom=1.5, invariant_display_xy=display_xy_of_bottom_of_ear) + frame3 = frame2.zoom_by(relative_zoom=1.5, invariant_display_xy=display_xy_of_bottom_of_ear) + frame4 = frame3.zoom_by(relative_zoom=1.5, invariant_display_xy=display_xy_of_bottom_of_ear) + frame5 = frame4.zoom_by(relative_zoom=1.5, invariant_display_xy=display_xy_of_bottom_of_ear) + f5_recon_pixel_xy_of_bottom_of_ear = frame5.display_xy_to_pixel_xy(display_xy_of_bottom_of_ear) + assert tuple(np.round(f5_recon_pixel_xy_of_bottom_of_ear).astype(int)) == pixel_xy_of_bottom_of_ear + # frame1 = frame.create_display_image(image) + + frame6 = frame3.pan_by(display_rel_xy=(0.5, 0), limit=True) + frame7 = frame6.pan_by(display_rel_xy=(0.5, 0), limit=True) + frame8 = frame7.pan_by(display_rel_xy=(0, .5), limit=True) + frame9 = frame8.pan_by(display_rel_xy=(0, .5), limit=True) + + + + crops = [f.create_display_image(image) for f in [frame1, frame2, frame3, frame4, frame5, frame6, frame7, frame8, frame9]] + # crops = [f.create_display_image(image) for f in [frame1, frame3]] + + # crop1 = frame.create_display_image(image) + # crop2 = frame.zoom_by(relative_zoom=1.5, invariant_display_xy=(500, 250)).create_display_image(image) + + if show: + display_img = ImageRow(image, *crops, wrap=6).render() + just_show(display_img, hang_time=10) + + + if __name__ == "__main__": # test_iter_images_from_video() # test_mask_to_boxes() # test_conditional_running_min() - test_slice_image_with_pad() - + # test_slice_image_with_pad() + test_image_view_info(show=True) diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index 7868b4a4..c5ecd2de 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -226,7 +226,7 @@ def request_frame(self, index: int) -> VideoFrameInfo: assert frame is not None, f"Error loading video frame at index {index_in_file}" return frame else: - max_seek_search = 100 + max_seek_search = 200 # I have no idea what's up with this. 100 failed some time stream = self.container.streams.video[0] pts = int(index_in_file * stream.duration / stream.frames) self.container.seek(pts, stream=stream) diff --git a/artemis/plotting/data_conversion.py b/artemis/plotting/data_conversion.py index bf5e8aa4..3d8494f0 100644 --- a/artemis/plotting/data_conversion.py +++ b/artemis/plotting/data_conversion.py @@ -1,4 +1,5 @@ from abc import abstractmethod +from typing import Optional, Tuple from artemis.general.should_be_builtins import memoize import numpy as np @@ -36,7 +37,7 @@ def put_vector_in_grid(vec, shape = None, empty_val = 0): @memoize -def _data_shape_and_boundary_width_to_grid_slices(shape, grid_shape, boundary_width, is_colour = None): +def _data_shape_and_boundary_width_to_grid_slices(shape, grid_shape: Optional[Tuple[int, int]], boundary_width: int, is_colour = None, min_size_xy: Tuple[int, int] = (0, 0)): assert len(shape) in (3, 4) or len(shape)==5 and shape[-1]==3 if is_colour is None: @@ -48,7 +49,12 @@ def _data_shape_and_boundary_width_to_grid_slices(shape, grid_shape, boundary_wi grid_shape = vector_length_to_tile_dims(shape[0]) if is_vector else shape[:2] n_rows, n_cols = grid_shape - output_shape = n_rows*(size_y+boundary_width)+1, n_cols*(size_x+boundary_width)+1 + minx, miny = min_size_xy + + output_shape_initial = n_rows*(size_y+boundary_width)+1, n_cols*(size_x+boundary_width)+1 + output_shape = max(miny, n_rows*(size_y+boundary_width)+1), max(minx, n_cols*(size_x+boundary_width)+1) + offset_y, offset_x = (max(0, (s2-s1)//2) for s1, s2 in zip(output_shape_initial, output_shape)) + index_pairs = [] for i in xrange(n_rows): for j in xrange(n_cols): @@ -58,20 +64,34 @@ def _data_shape_and_boundary_width_to_grid_slices(shape, grid_shape, boundary_wi break else: pull_indices = (i, j) - start_row, start_col = i*(size_y+1)+1, j*(size_x+1)+1 + start_row, start_col = i*(size_y+1)+1+offset_y, j*(size_x+1)+1+offset_x push_indices = slice(start_row, start_row+size_y), slice(start_col, start_col+size_x) index_pairs.append((pull_indices, push_indices)) return output_shape, index_pairs -def put_data_in_grid(data, grid_shape = None, fill_colour = np.array((0, 0, 128), dtype = 'uint8'), cmap = 'gray', - boundary_width = 1, clims = None, is_color_data=None, nan_colour=None): +def put_data_in_grid(data, fill_value, grid_shape = None, boundary_width: int = 1, min_size_xy: Tuple[int, int] = (0, 0)): + """ + Given a 3-d or 4-D array, put it in a 2-d grid. + :param data: A 4-D array of any data type + :return: A 3-D uint8 array of shape (n_rows, n_cols, 3) + """ + output_shape, slice_pairs = _data_shape_and_boundary_width_to_grid_slices(data.shape, grid_shape, boundary_width, is_colour=False, min_size_xy=min_size_xy) + output_data = np.empty(output_shape, dtype=data.dtype) + output_data[..., :] = fill_value # Maybe more efficient just to set the spaces. + for pull_slice, push_slice in slice_pairs: + output_data[push_slice] = data[pull_slice] + return output_data + + +def put_data_in_image_grid(data, grid_shape: Optional[Tuple[int, int]] = None, fill_colour = np.array((0, 0, 128), dtype ='uint8'), cmap ='gray', + boundary_width = 1, clims = None, is_color_data=None, nan_colour=None, min_size_xy: Tuple[int, int] = (0, 0)): """ Given a 3-d or 4-D array, put it in a 2-d grid. :param data: A 4-D array of any data type :return: A 3-D uint8 array of shape (n_rows, n_cols, 3) """ - output_shape, slice_pairs = _data_shape_and_boundary_width_to_grid_slices(data.shape, grid_shape, boundary_width, is_colour=is_color_data) + output_shape, slice_pairs = _data_shape_and_boundary_width_to_grid_slices(data.shape, grid_shape, boundary_width, is_colour=is_color_data, min_size_xy=min_size_xy) output_data = np.empty(output_shape+(3, ), dtype='uint8') output_data[..., :] = fill_colour # Maybe more efficient just to set the spaces. scaled_data = data_to_image(data, clims = clims, cmap = cmap, is_color_data=is_color_data, nan_colour=nan_colour) @@ -80,7 +100,7 @@ def put_data_in_grid(data, grid_shape = None, fill_colour = np.array((0, 0, 128) return output_data -def put_list_of_images_in_array(list_of_images, fill_colour = np.array((0, 0, 0))): +def put_list_of_images_in_array(list_of_images, fill_colour = np.array((0, 0, 0)), padding: int = 0): """ Arrange a list of images into a grid. They do not necessairlily need to have the same size. @@ -88,8 +108,8 @@ def put_list_of_images_in_array(list_of_images, fill_colour = np.array((0, 0, 0) :param fill_colour: The colour with which to fill the gaps :return: A (n_images, size_y, size_x, 3) array of images. """ - size_y = max(im.shape[0] for im in list_of_images) - size_x = max(im.shape[1] for im in list_of_images) + size_y = max(im.shape[0] for im in list_of_images)+padding*2 + size_x = max(im.shape[1] for im in list_of_images)+padding*2 im_array = np.zeros((len(list_of_images), size_y, size_x, 3))+fill_colour for g, im in zip(im_array, list_of_images): top = int((size_y-im.shape[0])/2) diff --git a/artemis/plotting/easy_window.py b/artemis/plotting/easy_window.py index af2da895..28194c10 100644 --- a/artemis/plotting/easy_window.py +++ b/artemis/plotting/easy_window.py @@ -154,9 +154,9 @@ def put_text_at( scale=1, thickness=1, font=cv2.FONT_HERSHEY_PLAIN, + shift_down_by_baseline: bool = False, ): (twidth, theight), baseline = cv2.getTextSize(text, font, scale, thickness) - px, py = position_xy if px < 0: px = img.shape[1]+px @@ -164,13 +164,13 @@ def put_text_at( py = img.shape[0]+py ax, ay = anchor_xy px = round(px - ax*twidth) - py = round(py - ay*theight) + py = round(py - ay*theight) + (2*baseline if shift_down_by_baseline else 0) if background_color is not None: pad = 4 img[max(0, py-pad): py+theight+pad, max(0, px-pad): px+twidth+pad] = background_color if shadow_color is not None: - cv2.putText(img=img, text=text, org=(px, py), fontFace=font, fontScale=scale, color=shadow_color, thickness=thickness + 2, bottomLeftOrigin=False) + cv2.putText(img=img, text=text, org=(px, py), fontFace=font, fontScale=scale, color=shadow_color, thickness=thickness + 3, bottomLeftOrigin=False) cv2.putText(img=img, text=text, org=(px, py), fontFace=font, fontScale=scale, color=color, thickness=thickness, bottomLeftOrigin=False) diff --git a/artemis/plotting/gui_helpers.py b/artemis/plotting/gui_helpers.py index 3cf5bed1..b761d8f0 100644 --- a/artemis/plotting/gui_helpers.py +++ b/artemis/plotting/gui_helpers.py @@ -1,4 +1,4 @@ -from artemis.plotting.data_conversion import put_data_in_grid +from artemis.plotting.data_conversion import put_data_in_image_grid from matplotlib import pyplot as plt import numpy as np @@ -11,7 +11,7 @@ def select_image(images, selection_callback): :return: """ - data = put_data_in_grid(images, is_color_data=True) + data = put_data_in_image_grid(images, is_color_data=True) plt.figure(figsize=(12, 12)) plt.imshow(data) diff --git a/artemis/plotting/image_mosaic.py b/artemis/plotting/image_mosaic.py new file mode 100644 index 00000000..52a09609 --- /dev/null +++ b/artemis/plotting/image_mosaic.py @@ -0,0 +1,40 @@ +from typing import Union, Mapping, Sequence, Tuple + +import numpy as np + +from artemis.general.custom_types import BGRImageArray, IndexImageArray, BGRColorTuple +from artemis.image_processing.image_utils import DEFAULT_GAP_COLOR, create_gap_image, BGRColors +from artemis.plotting.data_conversion import put_list_of_images_in_array, put_data_in_image_grid, put_data_in_grid +from artemis.plotting.easy_window import put_text_in_corner + + +def generate_image_mosaic_and_index_grid( + mosaic: Union[Mapping[int, BGRImageArray], Sequence[BGRImageArray]], + gap_color: BGRColorTuple = DEFAULT_GAP_COLOR, + desired_aspect_ratio = 1., # TODO: Make it work + min_size_xy: Tuple[int, int] = (640, 480), + padding: int = 1, + ) -> Tuple[BGRImageArray, IndexImageArray]: + + if isinstance(mosaic, Mapping): + images = list(mosaic.values()) + ids = list(mosaic.keys()) + else: + images = mosaic + ids = list(range(len(mosaic))) + + if len(mosaic)==0: + img = create_gap_image(size=min_size_xy, gap_colour=gap_color) + put_text_in_corner(img, text='No Detections', color=BGRColors.WHITE) + return img, np.full(shape=(min_size_xy[1], min_size_xy[0]), fill_value=-1) + + # First pack them into a big array + image_array = put_list_of_images_in_array(images, fill_colour=gap_color, padding=0) + id_array = np.zeros(image_array.shape[:3], dtype=int) + id_array += np.array(ids)[:, None, None] + + image_grid = put_data_in_image_grid(image_array, fill_colour=gap_color, boundary_width=padding, min_size_xy=min_size_xy) + id_grid = put_data_in_grid(id_array, fill_value=-1, min_size_xy=min_size_xy) + + return image_grid, id_grid + diff --git a/artemis/plotting/matplotlib_backend.py b/artemis/plotting/matplotlib_backend.py index af3e4b65..6da5c4b3 100644 --- a/artemis/plotting/matplotlib_backend.py +++ b/artemis/plotting/matplotlib_backend.py @@ -7,7 +7,7 @@ from artemis.config import get_artemis_config_value from artemis.general.should_be_builtins import bad_value -from artemis.plotting.data_conversion import (put_data_in_grid, RecordBuffer, data_to_image, +from artemis.plotting.data_conversion import (put_data_in_image_grid, RecordBuffer, data_to_image, put_list_of_images_in_array, UnlimitedRecordBuffer, ResamplingRecordBuffer) from matplotlib import pyplot as plt @@ -111,7 +111,7 @@ def _plot_last_data(self, data): if self._is_colour_data is None: self._is_colour_data = data.shape[-1]==3 - plottable_data = put_data_in_grid(data, clims = clims, cmap = self._cmap, is_color_data = self._is_colour_data, fill_colour = np.array((0, 0, 128)), nan_colour = np.array((0, 0, 128))) \ + plottable_data = put_data_in_image_grid(data, clims = clims, cmap = self._cmap, is_color_data = self._is_colour_data, fill_colour = np.array((0, 0, 128)), nan_colour = np.array((0, 0, 128))) \ if not (self._is_colour_data and data.ndim==3 or data.ndim==2) else \ data_to_image(data, clims = clims, cmap = self._cmap, nan_colour = np.array((0, 0, 128))) diff --git a/artemis/plotting/test_image_mosaic.py b/artemis/plotting/test_image_mosaic.py new file mode 100644 index 00000000..eced1342 --- /dev/null +++ b/artemis/plotting/test_image_mosaic.py @@ -0,0 +1,25 @@ +import nptyping + +from artemis.image_processing.image_utils import create_random_image +from artemis.plotting.cv2_plotting import just_show +from artemis.plotting.easy_window import ImageRow +from artemis.plotting.image_mosaic import generate_image_mosaic_and_index_grid + + +def test_image_mosaic(show: bool = False): + + width, height = (60, 40) + images = { + k: create_random_image(size_xy=(width, height)) for k in range(1, 90) + } + mosaic, ixs = generate_image_mosaic_and_index_grid(mosaic=images) + assert mosaic.ndim == 3 and mosaic.shape[2] == 3 and mosaic.dtype == nptyping.UInt8 + assert ixs.ndim == 2 and ixs.shape == mosaic.shape[:2] and ixs.dtype == int + + disp = ImageRow(mosaic, ixs).render() + if show: + just_show(disp, hang_time=10) + + +if __name__ == "__main__": + test_image_mosaic(show=True) From 2219d5421a58834c1b23a3e8599545e0825e6859 Mon Sep 17 00:00:00 2001 From: peter Date: Sun, 29 Jan 2023 12:33:39 -0800 Subject: [PATCH 048/107] done --- artemis/image_processing/image_utils.py | 12 +++++++++--- artemis/image_processing/test_image_utils.py | 15 ++++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index 2eda9abf..7ce0a4dc 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -781,17 +781,23 @@ def _get_display_midpoint_xy(self) -> Tuple[float, float]: def _get_min_zoom(self) -> float: return get_min_zoom(img_wh=self.image_wh, window_wh=self._get_display_wh()) - def zoom_by(self, relative_zoom: float, invariant_display_xy: Tuple[float, float]) -> 'ImageViewInfo': + def zoom_out(self) -> 'ImageViewInfo': + new_zoom = self._get_min_zoom() + return replace(self, zoom_level=new_zoom).adjust_pan_to_boundary() - new_zoom = max(self._get_min_zoom(), self.zoom_level*relative_zoom) + def zoom_by(self, relative_zoom: float, invariant_display_xy: Tuple[float, float], limit: bool = True) -> 'ImageViewInfo': + new_zoom = max(self._get_min_zoom(), self.zoom_level*relative_zoom) if limit else self.zoom_level*relative_zoom invariant_display_xy = np.maximum(0, np.minimum(self._get_display_wh(), invariant_display_xy)) invariant_pixel_xy = self.display_xy_to_pixel_xy(display_xy=invariant_display_xy) coeff = (1-1/relative_zoom) new_center_pixel_xy = tuple(np.array(self.center_pixel_xy)*(1-coeff) + np.array(invariant_pixel_xy)*coeff) - return replace(self, zoom_level=new_zoom, center_pixel_xy=new_center_pixel_xy) + result = replace(self, zoom_level=new_zoom, center_pixel_xy=new_center_pixel_xy) + if limit: + result = result.adjust_pan_to_boundary() + return result def adjust_pan_to_boundary(self) -> 'ImageViewInfo': display_edge_xy = np.asarray(self._get_display_midpoint_xy()) diff --git a/artemis/image_processing/test_image_utils.py b/artemis/image_processing/test_image_utils.py index 83463729..cbcdd079 100644 --- a/artemis/image_processing/test_image_utils.py +++ b/artemis/image_processing/test_image_utils.py @@ -123,11 +123,11 @@ def test_image_view_info(show: bool = False): # image = cv2.imread(cv2.samples.find_file("starry_night.jpg")) frame1 = ImageViewInfo.from_initial_view(window_disply_wh=(500, 500), image_wh=(image.shape[1], image.shape[0])) display_xy_of_bottom_of_ear = frame1.pixel_xy_to_display_xy(pixel_xy_of_bottom_of_ear) - recon_pixel_xy_of_bottom_of_ear = frame1.display_xy_to_pixel_xy(display_xy_of_bottom_of_ear) - frame2 = frame1.zoom_by(relative_zoom=1.5, invariant_display_xy=display_xy_of_bottom_of_ear) - frame3 = frame2.zoom_by(relative_zoom=1.5, invariant_display_xy=display_xy_of_bottom_of_ear) - frame4 = frame3.zoom_by(relative_zoom=1.5, invariant_display_xy=display_xy_of_bottom_of_ear) - frame5 = frame4.zoom_by(relative_zoom=1.5, invariant_display_xy=display_xy_of_bottom_of_ear) + # recon_pixel_xy_of_bottom_of_ear = frame1.display_xy_to_pixel_xy(display_xy_of_bottom_of_ear, limit=False) + frame2 = frame1.zoom_by(relative_zoom=1.5, invariant_display_xy=display_xy_of_bottom_of_ear, limit=False) + frame3 = frame2.zoom_by(relative_zoom=1.5, invariant_display_xy=display_xy_of_bottom_of_ear, limit=False) + frame4 = frame3.zoom_by(relative_zoom=1.5, invariant_display_xy=display_xy_of_bottom_of_ear, limit=False) + frame5 = frame4.zoom_by(relative_zoom=1.5, invariant_display_xy=display_xy_of_bottom_of_ear, limit=False) f5_recon_pixel_xy_of_bottom_of_ear = frame5.display_xy_to_pixel_xy(display_xy_of_bottom_of_ear) assert tuple(np.round(f5_recon_pixel_xy_of_bottom_of_ear).astype(int)) == pixel_xy_of_bottom_of_ear # frame1 = frame.create_display_image(image) @@ -137,9 +137,10 @@ def test_image_view_info(show: bool = False): frame8 = frame7.pan_by(display_rel_xy=(0, .5), limit=True) frame9 = frame8.pan_by(display_rel_xy=(0, .5), limit=True) + frame10 = frame9.zoom_by(relative_zoom=0.75, invariant_display_xy=(0, 0)) + frame11 = frame5.zoom_out() - - crops = [f.create_display_image(image) for f in [frame1, frame2, frame3, frame4, frame5, frame6, frame7, frame8, frame9]] + crops = [f.create_display_image(image) for f in [frame1, frame2, frame3, frame4, frame5, frame6, frame7, frame8, frame9, frame10, frame11]] # crops = [f.create_display_image(image) for f in [frame1, frame3]] # crop1 = frame.create_display_image(image) From ee93fb130557c363694fad64cd1e989326a93dae Mon Sep 17 00:00:00 2001 From: peter Date: Fri, 3 Feb 2023 23:17:30 -0800 Subject: [PATCH 049/107] moar --- artemis/fileman/file_utils.py | 5 + artemis/general/utils_for_testing.py | 13 ++- artemis/image_processing/artemis-drawing.jpeg | Bin 0 -> 46109 bytes artemis/image_processing/image_utils.py | 110 +++++++++++------- artemis/image_processing/test_image_utils.py | 8 +- artemis/image_processing/video_reader.py | 19 +++ 6 files changed, 102 insertions(+), 53 deletions(-) create mode 100644 artemis/fileman/file_utils.py create mode 100644 artemis/image_processing/artemis-drawing.jpeg diff --git a/artemis/fileman/file_utils.py b/artemis/fileman/file_utils.py new file mode 100644 index 00000000..34fd8c54 --- /dev/null +++ b/artemis/fileman/file_utils.py @@ -0,0 +1,5 @@ +import os + + +def get_filename_without_extension(path): + return os.path.splitext(os.path.basename(path))[0] diff --git a/artemis/general/utils_for_testing.py b/artemis/general/utils_for_testing.py index fd6799d4..03e4be6b 100644 --- a/artemis/general/utils_for_testing.py +++ b/artemis/general/utils_for_testing.py @@ -28,9 +28,12 @@ def delete_existing(path: str) -> bool: def prepare_path_for_write(path: str, overwright_callback: Callable[[str], bool] = lambda s: True) -> str: + """ + Prepare a path for writing. Overwright callback will be called if the file exists already and returns True if it is ok to overwright + """ final_path = os.path.expanduser(path) if os.path.exists(final_path): - if overwright_callback(path): + if not overwright_callback(path): raise FileExistsError(f"File {path} already exists") parent_dir, _ = os.path.split(final_path) os.makedirs(parent_dir, exist_ok=True) @@ -43,10 +46,10 @@ def hold_tempdir(path_if_successful: Optional[str] = None): tempdir = tempfile.mkdtemp() try: yield tempdir - if path_if_successful: - if os.path.exists(tempdir): - final_path = prepare_path_for_write(path_if_successful, overwright_callback=delete_existing) - shutil.move(tempdir, final_path) + if path_if_successful and os.path.exists(tempdir): + final_path = prepare_path_for_write(path_if_successful, overwright_callback=delete_existing) + shutil.move(tempdir, final_path) + print(f"Wrote temp dir to {final_path}") finally: if os.path.exists(tempdir): shutil.rmtree(tempdir) diff --git a/artemis/image_processing/artemis-drawing.jpeg b/artemis/image_processing/artemis-drawing.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..a2f17a9a502d195fa36b1e97408f8335ce78107a GIT binary patch literal 46109 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 S0D6GMX`c%Iv#megPygAOmj18+ literal 0 HcmV?d00001 diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index 7ce0a4dc..5298e62e 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -72,24 +72,26 @@ def normalize_to_bgr_image(image_data: Union[MaskImageArray, HeatMapArray, BGRIm # Alternate function for showing an image. Returns a key-string if any key pressed. -def resize_to_fit(image: BGRImageArray, xy_size: Union[int, Tuple[int, int]], expand: bool = False) -> BGRImageArray: +def resize_to_fit(image: BGRImageArray, xy_size: Union[int, Tuple[int, int]], expand: bool = False, interpolation=cv2.INTER_AREA + ) -> BGRImageArray: if isinstance(xy_size, int): xy_size = (xy_size, xy_size) sy, sx = image.shape[:2] smx, smy = xy_size x_rat, y_rat = sx / smx, sy / smy if x_rat >= y_rat and (expand or y_rat > 1): - return cv2.resize(image, dsize=(smx, int(sy / x_rat))) + return cv2.resize(image, dsize=(smx, int(sy / x_rat)), interpolation=interpolation) elif y_rat >= x_rat and (expand or x_rat > 1): - return cv2.resize(image, dsize=(int(sx / y_rat), smy)) + return cv2.resize(image, dsize=(int(sx / y_rat), smy), interpolation=interpolation) else: return image def put_image_in_box(image: BGRImageArray, xy_size: Union[int, Tuple[int, int]], gap_colour: BGRColorTuple = DEFAULT_GAP_COLOR, expand=True, + interpolation=cv2.INTER_AREA ) -> BGRImageArray: - resized_image = resize_to_fit(image, xy_size=xy_size, expand=expand) + resized_image = resize_to_fit(image, xy_size=xy_size, expand=expand, interpolation=interpolation) box = create_gap_image(xy_size, gap_colour=gap_colour) y_start = (xy_size[1] - resized_image.shape[0]) // 2 x_start = (xy_size[0] - resized_image.shape[1]) // 2 @@ -216,9 +218,8 @@ def iter_passthrough_write_video(image_stream: Iterable[BGRImageArray], path: st print(f'Saved video to {path}') - def fade_image(image: BGRImageArray, fade_level: float) -> BGRImageArray: - return (image.astype(np.float)*fade_level).astype(np.uint8) + return (image.astype(np.float) * fade_level).astype(np.uint8) def mask_to_color_image(mask: MaskImageArray) -> BGRImageArray: @@ -461,7 +462,7 @@ def to_ij(self) -> Tuple[int, int]: def to_crop_ij(self) -> Tuple[int, int]: """ Get the (row, col) of the center of the box in the frame of the cropped image""" i, j = self.to_ij() - return i-int(self.y_min), j-int(self.x_min) + return i - int(self.y_min), j - int(self.x_min) def compute_iou(self, other: 'BoundingBox') -> float: """ Get Intersection-over-Union overlap area between boxes - will be between zero and 1 """ @@ -613,8 +614,8 @@ def vstack_images(images: Sequence[BGRImageArray], gap_colour: BGRColorTuple = D def create_gap_image( # Generate a colour image filled with one colour size: Tuple[int, int], # Image (width, height)) - gap_colour: Optional[Tuple[int, int, int]] = None # BGR color to fill gap, or None to use default -) -> 'array(H,W,3)[uint8]': + gap_colour: BGRColorTuple = None # BGR color to fill gap, or None to use default +) -> BGRImageArray: if gap_colour is None: gap_colour = DEFAULT_GAP_COLOR @@ -624,8 +625,22 @@ def create_gap_image( # Generate a colour image filled with one colour return img -def create_random_image(size_xy: Tuple[int, int], in_color: bool = True, seed = None) -> BGRImageArray: - return np.random.RandomState(seed).randint(0, 256, size=(size_xy[1], size_xy[0])+((3, ) if in_color else ()), dtype=np.uint8) +def create_placeholder_image( # Generate a colour image filled with one colour + size: Tuple[int, int], # Image (width, height)) + gap_colour: BGRColorTuple = None, # BGR color to fill gap, or None to use default + x_color: BGRColorTuple = BGRColors.LIGHT_GRAY, +) -> BGRImageArray: + gap_image = create_gap_image(gap_colour=gap_colour, size=size) + n_steps = max(gap_image.shape[:2]) + row_ixs = np.linspace(0, gap_image.shape[0] - 1, n_steps).astype(np.int) + col_ixs = np.linspace(0, gap_image.shape[1] - 1, n_steps).astype(np.int) + gap_image[row_ixs, col_ixs] = x_color + gap_image[row_ixs, -col_ixs] = x_color + return gap_image + + +def create_random_image(size_xy: Tuple[int, int], in_color: bool = True, seed=None) -> BGRImageArray: + return np.random.RandomState(seed).randint(0, 256, size=(size_xy[1], size_xy[0]) + ((3,) if in_color else ()), dtype=np.uint8) @attrs @@ -739,18 +754,15 @@ def display_to_pixel_dim(display_coord: float, pixel_center_coord: float, window def get_min_zoom(img_wh: Tuple[int, int], window_wh: Tuple[int, int]) -> float: - return min(window_wh[i]/img_wh[i] for i in (0, 1)) + return min(window_wh[i] / img_wh[i] for i in (0, 1)) def clip_to_slack_bounds(x: float, bound: Tuple[float, float]) -> float: - x_lower, x_upper = bound if x_lower <= x_upper: return np.clip(x, x_lower, x_upper) else: - return (x_lower+x_upper)/2 - - + return (x_lower + x_upper) / 2 @dataclass @@ -765,18 +777,21 @@ class ImageViewInfo: @classmethod def from_initial_view(cls, window_disply_wh: Tuple[int, int], image_wh: Tuple[int, int], scroll_bar_width: int = 10) -> 'ImageViewInfo': return ImageViewInfo( - zoom_level = get_min_zoom(img_wh=image_wh, window_wh=np.asarray(window_disply_wh) - scroll_bar_width), - center_pixel_xy = tuple(s//2 for s in image_wh), + zoom_level=get_min_zoom(img_wh=image_wh, window_wh=np.asarray(window_disply_wh) - scroll_bar_width), + center_pixel_xy=tuple(s // 2 for s in image_wh), window_disply_wh=window_disply_wh, image_wh=image_wh ) + def adjust_frame_and_image_size(self, new_frame_wh: Tuple[int, int], new_image_wh: Tuple[int, int]) -> 'ImageViewInfo': + return replace(self, window_disply_wh=new_frame_wh, image_wh=new_image_wh) + def _get_display_wh(self) -> Tuple[int, int]: return self.window_disply_wh[0] - self.scroll_bar_width, self.window_disply_wh[1] - self.scroll_bar_width def _get_display_midpoint_xy(self) -> Tuple[float, float]: w, h = self._get_display_wh() - return w/2, h/2 + return w / 2, h / 2 def _get_min_zoom(self) -> float: return get_min_zoom(img_wh=self.image_wh, window_wh=self._get_display_wh()) @@ -787,13 +802,13 @@ def zoom_out(self) -> 'ImageViewInfo': def zoom_by(self, relative_zoom: float, invariant_display_xy: Tuple[float, float], limit: bool = True) -> 'ImageViewInfo': - new_zoom = max(self._get_min_zoom(), self.zoom_level*relative_zoom) if limit else self.zoom_level*relative_zoom + new_zoom = max(self._get_min_zoom(), self.zoom_level * relative_zoom) if limit else self.zoom_level * relative_zoom invariant_display_xy = np.maximum(0, np.minimum(self._get_display_wh(), invariant_display_xy)) invariant_pixel_xy = self.display_xy_to_pixel_xy(display_xy=invariant_display_xy) - coeff = (1-1/relative_zoom) - new_center_pixel_xy = tuple(np.array(self.center_pixel_xy)*(1-coeff) + np.array(invariant_pixel_xy)*coeff) + coeff = (1 - 1 / relative_zoom) + new_center_pixel_xy = tuple(np.array(self.center_pixel_xy) * (1 - coeff) + np.array(invariant_pixel_xy) * coeff) result = replace(self, zoom_level=new_zoom, center_pixel_xy=new_center_pixel_xy) if limit: result = result.adjust_pan_to_boundary() @@ -801,27 +816,32 @@ def zoom_by(self, relative_zoom: float, invariant_display_xy: Tuple[float, float def adjust_pan_to_boundary(self) -> 'ImageViewInfo': display_edge_xy = np.asarray(self._get_display_midpoint_xy()) - pixel_edge_xy = display_edge_xy/self.zoom_level - adjusted_pixel_center_xy = tuple(clip_to_slack_bounds(v, bound=(e, self.image_wh[i]-e)) for i, (v, e) in enumerate(zip(self.center_pixel_xy, pixel_edge_xy))) + pixel_edge_xy = display_edge_xy / self.zoom_level + adjusted_pixel_center_xy = tuple(clip_to_slack_bounds(v, bound=(e, self.image_wh[i] - e)) for i, (v, e) in enumerate(zip(self.center_pixel_xy, pixel_edge_xy))) return replace(self, center_pixel_xy=adjusted_pixel_center_xy) - def pan_by(self, display_rel_xy: Tuple[float, float], limit: bool = True) -> 'ImageViewInfo': - - pixel_shift_xy = np.asarray(display_rel_xy)*self._get_display_wh() * self.zoom_level + def pan_by_pixel_shift(self, pixel_shift_xy: Tuple[float, float], limit: bool = True) -> 'ImageViewInfo': new_center_pixel_xy = tuple(np.array(self.center_pixel_xy) + pixel_shift_xy) result = replace(self, center_pixel_xy=new_center_pixel_xy) if limit: result = result.adjust_pan_to_boundary() return result + def pan_by_display_relshift(self, display_rel_xy: Tuple[float, float], limit: bool = True) -> 'ImageViewInfo': + pixel_shift_xy = np.asarray(display_rel_xy) * self._get_display_wh() * self.zoom_level + return self.pan_by_pixel_shift(pixel_shift_xy=pixel_shift_xy, limit=limit) + + def pan_by_display_shift(self, display_shift_xy: Tuple[float, float], limit: bool = True) -> 'ImageViewInfo': + pixel_shift_xy = np.asarray(display_shift_xy) * self.zoom_level + return self.pan_by_pixel_shift(pixel_shift_xy=pixel_shift_xy, limit=limit) + def display_xy_to_pixel_xy(self, display_xy: Array["N,2", float], limit: bool = True) -> Array["N,2", float]: """ Map pixel-location in display image to pixel image. Optionally, limit result to bounds of image """ - pixel_xy = reframe_from_a_to_b( xy_in_a=display_xy, reference_xy_in_b=self.center_pixel_xy, reference_xy_in_a=self._get_display_midpoint_xy(), - scale_in_a_of_b=1/self.zoom_level, + scale_in_a_of_b=1 / self.zoom_level, ) if limit: pixel_xy = np.maximum(0, np.minimum(self.image_wh, pixel_xy)) @@ -833,7 +853,7 @@ def pixel_xy_to_display_xy(self, pixel_xy: Tuple[float, float], limit: bool = Tr xy_in_b=pixel_xy, reference_xy_in_b=self.center_pixel_xy, reference_xy_in_a=self._get_display_midpoint_xy(), - scale_in_a_of_b=1/self.zoom_level, + scale_in_a_of_b=1 / self.zoom_level, ) if limit: display_xy = np.maximum(0, np.minimum(np.asarray(self._get_display_wh()), display_xy)) @@ -841,46 +861,49 @@ def pixel_xy_to_display_xy(self, pixel_xy: Tuple[float, float], limit: bool = Tr def create_display_image(self, image: BGRImageArray, - gap_color = DEFAULT_GAP_COLOR, - scroll_bg_color = BGRColors.DARK_GRAY, - scroll_fg_color = BGRColors.LIGHT_GRAY, + gap_color=DEFAULT_GAP_COLOR, + scroll_bg_color=BGRColors.DARK_GRAY, + scroll_fg_color=BGRColors.LIGHT_GRAY, nearest_neighbor_zoom_threshold: float = 5, ) -> BGRImageArray: - result_array = np.full(self.window_disply_wh+image.shape[2:], dtype=image.dtype, fill_value=gap_color) + result_array = np.full(self.window_disply_wh[::-1] + image.shape[2:], dtype=image.dtype, fill_value=gap_color) result_array[-self.scroll_bar_width:, :-self.scroll_bar_width] = scroll_bg_color result_array[:-self.scroll_bar_width, -self.scroll_bar_width:] = scroll_bg_color src_topleft_xy = self.display_xy_to_pixel_xy(display_xy=(0, 0), limit=True).astype(np.int) src_bottomright_xy = self.display_xy_to_pixel_xy(display_xy=self._get_display_wh(), limit=True).astype(np.int) - dest_topleft_xy= self.pixel_xy_to_display_xy(pixel_xy=src_topleft_xy, limit=True).astype(np.int) + dest_topleft_xy = self.pixel_xy_to_display_xy(pixel_xy=src_topleft_xy, limit=True).astype(np.int) dest_bottomright_xy = self.pixel_xy_to_display_xy(pixel_xy=src_bottomright_xy, limit=True).astype(np.int) (src_x1, src_y1), (src_x2, src_y2) = src_topleft_xy, src_bottomright_xy (dest_x1, dest_y1), (dest_x2, dest_y2) = dest_topleft_xy, dest_bottomright_xy # Add the image src_image = image[src_y1:src_y2, src_x1:src_x2] - src_image_scaled = cv2.resize(src_image, (dest_x2-dest_x1, dest_y2-dest_y1), interpolation=cv2.INTER_NEAREST if self.zoom_level > nearest_neighbor_zoom_threshold else cv2.INTER_LINEAR) + src_image_scaled = cv2.resize(src_image, (dest_x2 - dest_x1, dest_y2 - dest_y1), interpolation=cv2.INTER_NEAREST if self.zoom_level > nearest_neighbor_zoom_threshold else cv2.INTER_LINEAR) result_array[dest_y1:dest_y2, dest_x1:dest_x2] = src_image_scaled # Add the scroll bars - scroll_fraxs_x: Tuple[float, float] = (src_x1/image.shape[1], src_x2/image.shape[1]) - scroll_fraxs_y: Tuple[float, float] = (src_y1/image.shape[0], src_y2/image.shape[0]) + scroll_fraxs_x: Tuple[float, float] = (src_x1 / image.shape[1], src_x2 / image.shape[1]) + scroll_fraxs_y: Tuple[float, float] = (src_y1 / image.shape[0], src_y2 / image.shape[0]) space_x, space_y = self._get_display_wh() - scroll_bar_x_slice = slice(max(0, round(scroll_fraxs_x[0]*space_x)), min(space_x, round(scroll_fraxs_x[1]*space_x))) - scroll_bar_y_slice = slice(max(0, round(scroll_fraxs_y[0]*space_y)), min(space_y, round(scroll_fraxs_y[1]*space_y))) + scroll_bar_x_slice = slice(max(0, round(scroll_fraxs_x[0] * space_x)), min(space_x, round(scroll_fraxs_x[1] * space_x))) + scroll_bar_y_slice = slice(max(0, round(scroll_fraxs_y[0] * space_y)), min(space_y, round(scroll_fraxs_y[1] * space_y))) result_array[scroll_bar_y_slice, -self.scroll_bar_width:] = scroll_fg_color result_array[-self.scroll_bar_width:, scroll_bar_x_slice] = scroll_fg_color return result_array -def load_artemis_image() -> BGRImageArray: - path = os.path.join(os.path.split(os.path.abspath(__file__))[0], 'artemis.jpeg') +def load_artemis_image(which: str = 'statue') -> BGRImageArray: + path = { + 'statue': os.path.join(os.path.split(os.path.abspath(__file__))[0], 'artemis.jpeg'), + 'drawing': os.path.join(os.path.split(os.path.abspath(__file__))[0], 'artemis-drawing.jpeg'), + }[which] + # path = os.path.join(os.path.split(os.path.abspath(__file__))[0], 'artemis.jpeg') return cv2.imread(path) - # @dataclass # class ImageBoxViewer: # scroll_bar_width: int = 10 @@ -899,7 +922,6 @@ def load_artemis_image() -> BGRImageArray: # # TODO: Fill in - # # def mask_to_boxes(mask: MaskImageArray) -> Sequence[BoundingBox]: # diff --git a/artemis/image_processing/test_image_utils.py b/artemis/image_processing/test_image_utils.py index cbcdd079..22208da5 100644 --- a/artemis/image_processing/test_image_utils.py +++ b/artemis/image_processing/test_image_utils.py @@ -132,10 +132,10 @@ def test_image_view_info(show: bool = False): assert tuple(np.round(f5_recon_pixel_xy_of_bottom_of_ear).astype(int)) == pixel_xy_of_bottom_of_ear # frame1 = frame.create_display_image(image) - frame6 = frame3.pan_by(display_rel_xy=(0.5, 0), limit=True) - frame7 = frame6.pan_by(display_rel_xy=(0.5, 0), limit=True) - frame8 = frame7.pan_by(display_rel_xy=(0, .5), limit=True) - frame9 = frame8.pan_by(display_rel_xy=(0, .5), limit=True) + frame6 = frame3.pan_by_display_relshift(display_rel_xy=(0.5, 0), limit=True) + frame7 = frame6.pan_by_display_relshift(display_rel_xy=(0.5, 0), limit=True) + frame8 = frame7.pan_by_display_relshift(display_rel_xy=(0, .5), limit=True) + frame9 = frame8.pan_by_display_relshift(display_rel_xy=(0, .5), limit=True) frame10 = frame9.zoom_by(relative_zoom=0.75, invariant_display_xy=(0, 0)) frame11 = frame5.zoom_out() diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index c5ecd2de..792c6651 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -128,6 +128,10 @@ def __init__(self, frame_interval=frame_interval, n_frames_total=self.container.streams.video[0].frames) self._metadata: Optional[VideoMetaData] = None + self._is_destroyed = False + # Call a function to notify us when this object gets destroyed + # def is_des + # self._n_frames = def get_metadata(self) -> VideoMetaData: @@ -152,6 +156,9 @@ def time_to_nearest_frame(self, t: float) -> int: def frame_index_to_nearest_frame(self, index: int) -> int: return max(0, min(self.get_n_frames() - 1, index)) + def frame_index_to_time(self, frame_ix: int) -> float: + return frame_ix / self._fps + def iter_frame_ixs(self, time_interval: TimeIntervalTuple = (None, None), frame_interval: Tuple[Optional[int], Optional[int]] = (None, None) ) -> Iterator[int]: @@ -191,6 +198,9 @@ def request_frame(self, index: int) -> VideoFrameInfo: Request a frame of the video. If the requested frame is out of bounds, this will return the frame on the closest edge. """ + if self._is_destroyed: + raise Exception("This object has been explicitly destroyed.") + # print(f"Requesting frame {index}") if index < 0: index = self.get_n_frames() + index index = max(0, min(self.get_n_frames() - 1, index)) @@ -239,3 +249,12 @@ def request_frame(self, index: int) -> VideoFrameInfo: break self._next_index_to_be_read = index_in_file return self.request_frame(index) + + def destroy(self): + self._iterator = None + self._frame_cache = None + self._is_destroyed = True + + + # def __del__(self): + # print(f"Video reader {id(self)} closed!") From 5245a581a6568b2c04d4d811ff82897606e6a20a Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 14 Feb 2023 09:30:17 -0800 Subject: [PATCH 050/107] small updates --- artemis/general/parsing.py | 24 ++++++++++++++++++++++ artemis/image_processing/image_utils.py | 14 +++++++------ artemis/image_processing/video_reader.py | 25 +++++++++++++++++++++++ artemis/image_processing/video_segment.py | 25 ----------------------- 4 files changed, 57 insertions(+), 31 deletions(-) create mode 100644 artemis/general/parsing.py diff --git a/artemis/general/parsing.py b/artemis/general/parsing.py new file mode 100644 index 00000000..3f727adc --- /dev/null +++ b/artemis/general/parsing.py @@ -0,0 +1,24 @@ +from typing import Optional + +from artemis.general.custom_types import TimeIntervalTuple + + +def parse_time_delta_str_to_sec(time_delta_str: str) -> Optional[float]: + if time_delta_str in ('start', 'end'): + return None + else: + start_splits = time_delta_str.split(':') + if len(start_splits) == 1: + return float(time_delta_str) + elif len(start_splits) == 2: + return 60 * float(start_splits[0]) + float(start_splits[1]) + elif len(start_splits) == 3: + return 3600*float(start_splits[0]) + 60*float(start_splits[1]) + float(start_splits[2]) + else: + raise Exception(f"Bad format: {time_delta_str}") + + +def parse_interval(interval_str: str) -> TimeIntervalTuple: + + start, end = (s.strip('') for s in interval_str.split('-')) + return parse_time_delta_str_to_sec(start), parse_time_delta_str_to_sec(end) diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index 5298e62e..b936b29a 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -211,7 +211,9 @@ def iter_passthrough_write_video(image_stream: Iterable[BGRImageArray], path: st for img in image_stream: if cap is None: # cap = cv2.VideoWriter(path, fourcc=cv2.VideoWriter_fourcc('M', 'J', 'P', 'G'), fps=fps, frameSize=(img.shape[1], img.shape[0])) - cap = cv2.VideoWriter(path, fourcc=cv2.VideoWriter_fourcc('H', '2', '6', '4'), fps=fps, frameSize=(img.shape[1], img.shape[0])) + # cap = cv2.VideoWriter(path, fourcc=cv2.VideoWriter_fourcc('H', '2', '6', '4'), fps=fps, frameSize=(img.shape[1], img.shape[0])) + # Make it write high-quality video + cap = cv2.VideoWriter(path, fourcc=cv2.VideoWriter_fourcc('M', 'J', 'P', 'G'), fps=fps, frameSize=(img.shape[1], img.shape[0]), isColor=True) cap.write(img) yield img cap.release() @@ -828,7 +830,7 @@ def pan_by_pixel_shift(self, pixel_shift_xy: Tuple[float, float], limit: bool = return result def pan_by_display_relshift(self, display_rel_xy: Tuple[float, float], limit: bool = True) -> 'ImageViewInfo': - pixel_shift_xy = np.asarray(display_rel_xy) * self._get_display_wh() * self.zoom_level + pixel_shift_xy = np.asarray(display_rel_xy) * self._get_display_wh() #* self.zoom_level return self.pan_by_pixel_shift(pixel_shift_xy=pixel_shift_xy, limit=limit) def pan_by_display_shift(self, display_shift_xy: Tuple[float, float], limit: bool = True) -> 'ImageViewInfo': @@ -871,11 +873,11 @@ def create_display_image(self, result_array[-self.scroll_bar_width:, :-self.scroll_bar_width] = scroll_bg_color result_array[:-self.scroll_bar_width, -self.scroll_bar_width:] = scroll_bg_color - src_topleft_xy = self.display_xy_to_pixel_xy(display_xy=(0, 0), limit=True).astype(np.int) - src_bottomright_xy = self.display_xy_to_pixel_xy(display_xy=self._get_display_wh(), limit=True).astype(np.int) + src_topleft_xy = self.display_xy_to_pixel_xy(display_xy=(0, 0), limit=True).astype(int) + src_bottomright_xy = self.display_xy_to_pixel_xy(display_xy=self._get_display_wh(), limit=True).astype(int) - dest_topleft_xy = self.pixel_xy_to_display_xy(pixel_xy=src_topleft_xy, limit=True).astype(np.int) - dest_bottomright_xy = self.pixel_xy_to_display_xy(pixel_xy=src_bottomright_xy, limit=True).astype(np.int) + dest_topleft_xy = self.pixel_xy_to_display_xy(pixel_xy=src_topleft_xy, limit=True).astype(int) + dest_bottomright_xy = self.pixel_xy_to_display_xy(pixel_xy=src_bottomright_xy, limit=True).astype(int) (src_x1, src_y1), (src_x2, src_y2) = src_topleft_xy, src_bottomright_xy (dest_x1, dest_y1), (dest_x2, dest_y2) = dest_topleft_xy, dest_bottomright_xy diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index 792c6651..0d7cb47f 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -9,6 +9,8 @@ from artemis.general.utils_utils import bytes_to_string from artemis.image_processing.image_utils import fit_image_to_max_size from artemis.general.item_cache import CacheDict +from artemis.general.parsing import parse_time_delta_str_to_sec +from video_scanner.general_utils.srt_files import SRTInfo @dataclass @@ -159,6 +161,29 @@ def frame_index_to_nearest_frame(self, index: int) -> int: def frame_index_to_time(self, frame_ix: int) -> float: return frame_ix / self._fps + def time_indicator_to_nearest_frame(self, time_indicator: str) -> Optional[int]: + """ Get the frame index nearest the time-indicator + e.g. "0:32.5" "32.5s", "53%", "975" (frame number) + Returns None if the time_indicator is invalid + """ + if time_indicator in ('s', 'start'): + return 0 + elif time_indicator in ('e', 'end'): + return self.get_n_frames() - 1 + elif ':' in time_indicator: + sec = parse_time_delta_str_to_sec(time_indicator) + return round(sec * self.get_metadata().fps) + elif time_indicator.endswith('s'): + sec = float(time_indicator.rstrip('s')) + return round(sec * self.get_metadata().fps) + elif time_indicator.endswith('%'): + percent = float(time_indicator.rstrip('%')) + return round(percent / 100 * self.get_n_frames()) + elif all(c in '0123456789' for c in time_indicator): + return int(time_indicator) + else: + return None + def iter_frame_ixs(self, time_interval: TimeIntervalTuple = (None, None), frame_interval: Tuple[Optional[int], Optional[int]] = (None, None) ) -> Iterator[int]: diff --git a/artemis/image_processing/video_segment.py b/artemis/image_processing/video_segment.py index 18ca12d9..a267e638 100644 --- a/artemis/image_processing/video_segment.py +++ b/artemis/image_processing/video_segment.py @@ -9,31 +9,6 @@ from artemis.image_processing.video_reader import VideoReader, VideoFrameInfo, VideoMetaData -def parse_time_delta_str_to_sec(time_delta_str: str) -> Optional[float]: - if time_delta_str in ('start', 'end'): - return None - else: - start_splits = time_delta_str.split(':') - if len(start_splits) == 1: - return float(time_delta_str) - elif len(start_splits) == 2: - return 60 * float(start_splits[0]) + float(start_splits[1]) - elif len(start_splits) == 3: - return 3600*float(start_splits[0]) + 60*float(start_splits[1]) + float(start_splits[2]) - else: - raise Exception(f"Bad format: {time_delta_str}") - - -def parse_interval(interval_str: str) -> TimeIntervalTuple: - - start, end = (s.strip('') for s in interval_str.split('-')) - return parse_time_delta_str_to_sec(start), parse_time_delta_str_to_sec(end) - - - - - - @dataclass class VideoSegment: """ """ From 7e8d34294ba085a3fb63c96bdd99cdbdec7aef40 Mon Sep 17 00:00:00 2001 From: peter Date: Fri, 17 Feb 2023 10:53:25 -0800 Subject: [PATCH 051/107] fix import --- artemis/image_processing/image_utils.py | 6 +++--- artemis/image_processing/video_reader.py | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index b936b29a..13a046a4 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -211,9 +211,9 @@ def iter_passthrough_write_video(image_stream: Iterable[BGRImageArray], path: st for img in image_stream: if cap is None: # cap = cv2.VideoWriter(path, fourcc=cv2.VideoWriter_fourcc('M', 'J', 'P', 'G'), fps=fps, frameSize=(img.shape[1], img.shape[0])) - # cap = cv2.VideoWriter(path, fourcc=cv2.VideoWriter_fourcc('H', '2', '6', '4'), fps=fps, frameSize=(img.shape[1], img.shape[0])) + cap = cv2.VideoWriter(path, fourcc=cv2.VideoWriter_fourcc('H', '2', '6', '4'), fps=fps, frameSize=(img.shape[1], img.shape[0])) # Make it write high-quality video - cap = cv2.VideoWriter(path, fourcc=cv2.VideoWriter_fourcc('M', 'J', 'P', 'G'), fps=fps, frameSize=(img.shape[1], img.shape[0]), isColor=True) + # cap = cv2.VideoWriter(path, fourcc=cv2.VideoWriter_fourcc('M', 'J', 'P', 'G'), fps=fps, frameSize=(img.shape[1], img.shape[0]), isColor=True) cap.write(img) yield img cap.release() @@ -830,7 +830,7 @@ def pan_by_pixel_shift(self, pixel_shift_xy: Tuple[float, float], limit: bool = return result def pan_by_display_relshift(self, display_rel_xy: Tuple[float, float], limit: bool = True) -> 'ImageViewInfo': - pixel_shift_xy = np.asarray(display_rel_xy) * self._get_display_wh() #* self.zoom_level + pixel_shift_xy = np.asarray(display_rel_xy) * self._get_display_wh() / self.zoom_level return self.pan_by_pixel_shift(pixel_shift_xy=pixel_shift_xy, limit=limit) def pan_by_display_shift(self, display_shift_xy: Tuple[float, float], limit: bool = True) -> 'ImageViewInfo': diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index 0d7cb47f..d98747a7 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -10,7 +10,6 @@ from artemis.image_processing.image_utils import fit_image_to_max_size from artemis.general.item_cache import CacheDict from artemis.general.parsing import parse_time_delta_str_to_sec -from video_scanner.general_utils.srt_files import SRTInfo @dataclass @@ -142,7 +141,7 @@ def get_metadata(self) -> VideoMetaData: firstframe = self.request_frame(0) self._metadata = VideoMetaData( duration=self._n_frames/self._fps, - n_frames=self._n_frames, + n_frames=max(1, self._n_frames), # It seems to give 0 for images which aint right fps=self._fps, n_bytes=file_stats.st_size, size_xy=(firstframe.image.shape[1], firstframe.image.shape[0]) From 93ad2dc059feb736ffa0b48c15a133916884816b Mon Sep 17 00:00:00 2001 From: peter Date: Fri, 17 Feb 2023 15:34:13 -0800 Subject: [PATCH 052/107] image read --- artemis/image_processing/image_utils.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index 13a046a4..114d750b 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -128,6 +128,16 @@ def iter_images_from_video(path: str, max_size: Optional[Tuple[int, int]] = None rotation: int = 0, verbose: bool = False, ) -> Iterable[BGRImageArray]: + + # On windows machines the normal video reading does not work for images + if any(path.lower().endswith(e) for e in ('.jpg', '.jpeg')): + image = cv2.imread(path) + if max_size is not None: + image = fit_image_to_max_size(image, max_size) + assert image is not None, f"Could not read any image from {path}" + yield image + return + assert not use_scan_selection, "This does not work. See bug: https://github.com/opencv/opencv/issues/9053" path = os.path.expanduser(path) cap = cv2.VideoCapture(path) From f4eca3e4d6a50716df556ff476d19449a1c68d92 Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 7 Mar 2023 12:53:44 -0800 Subject: [PATCH 053/107] jump --- artemis/fileman/file_utils.py | 106 ++++++++++++++++++++++ artemis/general/functional.py | 16 +++- artemis/general/test_functional.py | 24 +++-- artemis/general/test_utils_utils.py | 8 +- artemis/general/utils_utils.py | 2 +- artemis/image_processing/image_builder.py | 2 +- artemis/image_processing/video_reader.py | 4 +- 7 files changed, 147 insertions(+), 15 deletions(-) diff --git a/artemis/fileman/file_utils.py b/artemis/fileman/file_utils.py index 34fd8c54..6fe10684 100644 --- a/artemis/fileman/file_utils.py +++ b/artemis/fileman/file_utils.py @@ -1,5 +1,111 @@ import os +import shutil +from datetime import datetime +from typing import Optional, Sequence, Mapping, Iterator def get_filename_without_extension(path): return os.path.splitext(os.path.basename(path))[0] + + +def modified_timestamp_to_filename(timestamp: float, time_format: str = "%Y-%m-%d_%H-%M-%S") -> str: + return datetime.utcfromtimestamp(timestamp).strftime(time_format) + + +def get_dest_filepath(src_path: str, src_root_dir: str, dest_root_dir: str, time_format: str = "%Y-%m-%d_%H-%M-%S") -> str: + src_root_dir = src_root_dir.rstrip(os.sep) + os.sep + assert src_path.startswith(src_root_dir), f"File {src_path} was not in root dir {src_root_dir}" + src_rel_folder, src_filename = os.path.split(src_path[len(src_root_dir):]) + src_name, ext = os.path.splitext(src_filename) + src_order_number = src_name.split('_', 1)[1] # 'DJI_0215' -> '0215' + timestamp = os.path.getmtime(src_path) + new_filename = f'{modified_timestamp_to_filename(timestamp, time_format=time_format)}_{src_order_number}{ext.lower()}' + return os.path.join(dest_root_dir, src_rel_folder, new_filename) + + +def iter_filepaths_in_directory_recursive(directory, allowed_extensions: Optional[Sequence[str]], relative = False) -> Iterator[str]: + """ Yields file paths in a directory + :param directory: The directory to search + :param allowed_extensions: If not None, only files with these extensions will be returned + :param relative: If True, the paths will be relative to the directory + :return: A generator of file paths + """ + allowed_extensions = tuple(e.lower() for e in allowed_extensions) + for dp, dn, filenames in os.walk(directory): + for f in filenames: + if allowed_extensions is None or any(f.lower().endswith(e) for e in allowed_extensions): + abs_path = os.path.join(dp, f) + if relative: + yield os.path.relpath(abs_path, directory) + else: + yield abs_path + + # yield from (f if relative else os.path.join(dp, f) for dp, dn, filenames in os.walk(directory) + # for f in filenames if allowed_extensions is None or any(f.lower().endswith(e) for e in allowed_extensions)) + + +def copy_creating_dir_if_needed(src_path: str, dest_path: str): + parent, _ = os.path.split(dest_path) + if not os.path.exists(parent): + os.makedirs(parent) + shutil.copyfile(src_path, dest_path) + + +def get_recursive_directory_contents_string(directory: str, indent_level=0, indent=' ', max_entries: Optional[int] = None) -> str: + lines = [] + this_indent = indent * indent_level + for i, f in enumerate(os.listdir(directory)): + if max_entries is not None and i >= max_entries: + lines.append(this_indent + '...') + break + lines.append(this_indent + f) + fpath = os.path.join(directory, f) + if os.path.isdir(fpath): + lines.append(get_recursive_directory_contents_string(fpath, indent_level=indent_level + 1, max_entries=max_entries)) + return '\n'.join(lines) + + +def sync_src_files_to_dest_files( + src_path_to_new_path: Mapping[str, str], + overwrite: bool = False, # Overwrite existing files on machine + check_byte_sizes=True, # Check that, for existing files, file-size matches source. If not, overwrite. + verbose: bool = True # Prints a lot. +): + # Filter to only copy when destination file does not exist. TODO: Maybe check file size match here too + src_path_to_size = {src_path: os.path.getsize(src_path) for src_path in src_path_to_new_path} + src_to_dest_to_copy = {src: dest for src, dest in src_path_to_new_path.items() if + overwrite or not os.path.exists(dest) or (check_byte_sizes and src_path_to_size[src] != os.path.getsize(dest))} + + # Get file size data and prompt user to confirm copy + size_to_be_copied = sum(src_path_to_size[src] for src in src_to_dest_to_copy) + if len(src_path_to_new_path)==0: + print("No files to sync. Closing") + return + elif len(src_to_dest_to_copy)==0: + print(f"All {len(src_path_to_new_path)} are already synced. No copying needed. Pass overwrite=True to force overwrite") + return + if verbose: + print('Files to be copied: ') + print(' ' + '\n '.join(f'{i}: {src} -> {dest} ' for i, (src, dest) in enumerate(src_to_dest_to_copy.items()))) + response = input( + f"{len(src_to_dest_to_copy)}/{len(src_path_to_new_path)} files ({size_to_be_copied:,} bytes) will be copied.\n Type 'copy' to copy >>") + + # Do the actual copying. + if response.strip(' ') == 'copy': + print('Copying...') + data_copied = 0 + for i, src_path in enumerate(sorted(src_to_dest_to_copy), start=1): + dest_path = src_to_dest_to_copy[src_path] + print( + f'Copied {i}/{len(src_to_dest_to_copy)} files ({data_copied / size_to_be_copied:.1%} of data). Next: {src_path} -> {dest_path} ({src_path_to_size[src_path]:,} B)') + src_path = os.path.expanduser(src_path) + dest_path = os.path.expanduser(dest_path) + copy_creating_dir_if_needed(src_path, dest_path) + data_copied += src_path_to_size[src_path] + print('Done copying') + # if verbose: + # print(f'Destination now contains:') + # print(get_recursive_directory_contents_string(destination_folder, max_entries=3, indent_level=1)) + else: + print("You didn't type 'copy'") + diff --git a/artemis/general/functional.py b/artemis/general/functional.py index b8b950ac..a92e52c3 100644 --- a/artemis/general/functional.py +++ b/artemis/general/functional.py @@ -1,8 +1,9 @@ import inspect from abc import abstractmethod from collections import OrderedDict -from functools import partial +from functools import partial, reduce import collections +from typing import TypeVar, Callable from cv2.gapi.ie.detail import PARAM_DESC_KIND_LOAD @@ -244,3 +245,16 @@ def infer_arg_values(f, args=(), kwargs={}): # assert len(different_given_args)==0, "Function {} was given args {} but didn't ask for them".format(f, different_given_args) # assert len(different_args)==0, "Function {} needs values for args {} but didn't get them".format(f, different_args) return OrderedDict(full_args) + + +def chain_functions(*funcs: Callable) -> Callable: + """ + Chain functions together, so that the output of one function is the input of the next function. + Obviously the input type of function i must match the output type of function i-1. + + :param funcs: A sequence of functions + :return: A function which is the chain of all the functions + """ + def chained_call(arg): + return reduce(lambda r, f: f(r), funcs, arg) + return chained_call diff --git a/artemis/general/test_functional.py b/artemis/general/test_functional.py index b25db7a5..0cbdcc1f 100644 --- a/artemis/general/test_functional.py +++ b/artemis/general/test_functional.py @@ -3,7 +3,7 @@ import sys from pytest import raises from artemis.general.functional import infer_arg_values, get_partial_chain, \ - partial_reparametrization, advanced_getargspec + partial_reparametrization, advanced_getargspec, chain_functions def test_get_full_args(): @@ -129,9 +129,21 @@ def add(a, b): assert defaults == dict(d=4) +def test_chain_functions(): + def func1(a: str) -> int: + return len(a) + + def func2(a: int) -> int: + return a + 1 + + assert chain_functions(func1, func2)('abc') == 4 + assert chain_functions(func1, func2)('abcd') == 5 + + if __name__ == '__main__': - test_get_full_args() - test_get_partial_chain() - test_advanced_getargspec() - test_partial_reparametrization() - test_advanced_getargspec_on_partials() + # test_get_full_args() + # test_get_partial_chain() + # test_advanced_getargspec() + # test_partial_reparametrization() + # test_advanced_getargspec_on_partials() + test_chain_functions() \ No newline at end of file diff --git a/artemis/general/test_utils_utils.py b/artemis/general/test_utils_utils.py index 8c2522b7..6678aa16 100644 --- a/artemis/general/test_utils_utils.py +++ b/artemis/general/test_utils_utils.py @@ -1,7 +1,7 @@ import itertools from pytest import raises -from artemis.general.utils_utils import tee_and_specialize_iterator, bytes_to_string +from artemis.general.utils_utils import tee_and_specialize_iterator, byte_size_to_string def test_tee_and_specialize_iterator(): @@ -22,9 +22,9 @@ def test_tee_and_specialize_iterator(): def test_bytes_to_string(): - assert bytes_to_string(2, decimals_precision=1)=='2.0 B' - assert bytes_to_string(2000, decimals_precision=1)=='2.0 kB' - assert bytes_to_string(2500000, decimals_precision=1)=='2.4 MB' + assert byte_size_to_string(2, decimals_precision=1) == '2.0 B' + assert byte_size_to_string(2000, decimals_precision=1) == '2.0 kB' + assert byte_size_to_string(2500000, decimals_precision=1) == '2.4 MB' if __name__ == '__main__': diff --git a/artemis/general/utils_utils.py b/artemis/general/utils_utils.py index c103f64e..bb13a4d4 100644 --- a/artemis/general/utils_utils.py +++ b/artemis/general/utils_utils.py @@ -86,7 +86,7 @@ def make_sub_iterator(it_copy, arg): return [make_sub_iterator(it_copy, arg) for it_copy, arg in zip(itertools.tee(iterator, len(args)), args)] -def bytes_to_string(bytes: int, decimals_precision: 1) -> str: +def byte_size_to_string(bytes: int, decimals_precision: int = 1) -> str: size = bytes prefix = '' diff --git a/artemis/image_processing/image_builder.py b/artemis/image_processing/image_builder.py index 4a581b0a..48f1050c 100644 --- a/artemis/image_processing/image_builder.py +++ b/artemis/image_processing/image_builder.py @@ -156,7 +156,7 @@ def label_points(self, points_xy: Union[Sequence[Tuple[int, int]], Mapping[Tuple points_xy = {(x, y): str(i) for i, (x, y) in enumerate(points_xy)} for ((x, y), label), c in zip(points_xy.items(), color): cv2.circle(self.image, center=(round(x), round(y)), radius=radius, color=c, thickness=thickness) - put_text_at(self.image, text=label, pos=(round(x)+10, round(y)+10), color=c, shadow_color=None) + put_text_at(self.image, text=label, position_xy=(round(x)+10, round(y)+10), color=c, shadow_color=None) return self def draw_bounding_boxes(self, diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index 792c6651..97ecd154 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -6,7 +6,7 @@ import av from artemis.general.custom_types import BGRImageArray, TimeIntervalTuple -from artemis.general.utils_utils import bytes_to_string +from artemis.general.utils_utils import byte_size_to_string from artemis.image_processing.image_utils import fit_image_to_max_size from artemis.general.item_cache import CacheDict @@ -42,7 +42,7 @@ def get_size_xy_string(self) -> str: return f"{self.size_xy[0]}x{self.size_xy[1]}" def get_size_string(self) -> str: - return bytes_to_string(self.n_bytes, decimals_precision=1) + return byte_size_to_string(self.n_bytes, decimals_precision=1) def get_actual_frame_interval( n_frames_total: int, From 89fcf1ef2d606d06be26a1f6e19934c0e6c17f97 Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 30 Mar 2023 14:43:31 -0700 Subject: [PATCH 054/107] a bunch of small changes while working on eagle eyes scan --- artemis/fileman/disk_memoize.py | 34 +++- artemis/general/mymath.py | 5 +- artemis/image_processing/image_builder.py | 5 + artemis/image_processing/image_utils.py | 6 +- artemis/image_processing/video_reader.py | 215 ++++++++++++++++++---- artemis/image_processing/video_segment.py | 28 ++- artemis/ml/tools/costs.py | 4 +- artemis/plotting/data_conversion.py | 16 +- artemis/plotting/image_mosaic.py | 7 +- artemis/plotting/parallel_coords_plots.py | 4 +- 10 files changed, 259 insertions(+), 65 deletions(-) diff --git a/artemis/fileman/disk_memoize.py b/artemis/fileman/disk_memoize.py index c657f66b..1c112902 100644 --- a/artemis/fileman/disk_memoize.py +++ b/artemis/fileman/disk_memoize.py @@ -4,6 +4,7 @@ import time from contextlib import contextmanager from functools import partial +from pickle import UnpicklingError from shutil import rmtree from artemis.fileman.local_dir import get_artemis_data_path, make_file_dir @@ -86,6 +87,18 @@ def check_memos(*args, **kwargs): # It may be more efficient to use the built-in hashability of certain types for the local cash, and just have special # ways of dealing with non-hashables like lists and numpy arrays - it's a bit dangerous because we need to check # that no object or subobjects have been changed. + + def compute_result_from_func(): + if inspect.isgeneratorfunction(fcn): + # TODO: Do this properly - caching results one at a time + LOGGER.info(f"Computing results from generator {fcn} in advance...") + result = list(fcn(*args, **kwargs)) + LOGGER.info('... Done') + else: + result = fcn(*args, **kwargs) + return result + + if MEMO_READ_ENABLED: if local_cache: # local_cache_signature = get_local_cache_signature(args, kwargs) @@ -103,22 +116,23 @@ def check_memos(*args, **kwargs): if not suppress_info: LOGGER.info(f'...Reading memo for function {fcn.__name__} took {time.monotonic()-tstart:.5f}s'.format(fcn.__name__, )) - except (ValueError, ImportError, EOFError) as err: - if isinstance(err, (ValueError, EOFError)) and not suppress_info: + except (ValueError, ImportError, EOFError, UnpicklingError) as err: + if isinstance(err, (ValueError, EOFError, UnpicklingError)) and not suppress_info: LOGGER.warn('Memo-file "{}" was corrupt. ({}: {}). Recomputing.'.format(filepath, err.__class__.__name__, str(err))) elif isinstance(err, ImportError) and not suppress_info: LOGGER.warn('Memo-file "{}" was tried to reference an old class and got ImportError: {}. Recomputing.'.format(filepath, str(err))) + result = compute_result_from_func() result_computed = True - result = fcn(*args, **kwargs) else: + result = compute_result_from_func() result_computed = True - if inspect.isgeneratorfunction(fcn): - # TODO: Do this properly - caching results one at a time - LOGGER.info(f"Computing results from generator {fcn} in advance...") - result = list(fcn(*args, **kwargs)) - LOGGER.info('... Done') - else: - result = fcn(*args, **kwargs) + # if inspect.isgeneratorfunction(fcn): + # # TODO: Do this properly - caching results one at a time + # LOGGER.info(f"Computing results from generator {fcn} in advance...") + # result = list(fcn(*args, **kwargs)) + # LOGGER.info('... Done') + # else: + # result = fcn(*args, **kwargs) else: result_computed = True result = fcn(*args, **kwargs) diff --git a/artemis/general/mymath.py b/artemis/general/mymath.py index bc9ae100..60bfad3d 100644 --- a/artemis/general/mymath.py +++ b/artemis/general/mymath.py @@ -225,16 +225,17 @@ def angle_between(a, b, axis=None, in_degrees = False): Credit to Pace: http://stackoverflow.com/questions/2827393/angles-between-two-n-dimensional-vectors-in-python """ - cos_dist = cosine_distance(a, b, axis=axis) + cos_dist = cosine_similarity(a, b, axis=axis) angle = np.arccos(cos_dist) if in_degrees: angle = angle * 180/np.pi return angle -def cosine_distance(a, b, axis=None): +def cosine_similarity(a, b, axis=None): """ Return the cosine distance between two vectors a and b. Raise an exception if one is a zero vector + :param a: An array :param b: Another array of the same shape :return: The cosine distance between a and b, reduced along the given axis. diff --git a/artemis/image_processing/image_builder.py b/artemis/image_processing/image_builder.py index 48f1050c..528a458c 100644 --- a/artemis/image_processing/image_builder.py +++ b/artemis/image_processing/image_builder.py @@ -227,6 +227,11 @@ def draw_corner_inset(self, image: BGRImageArray, corner='br', border_color=BGRC self.image[vslice, hslice] = image return self + def draw_text_label(self, label: str, top_side: bool = True, rel_font_size: int = 0.05, color: BGRColorTuple = BGRColors.WHITE, background_color: Optional[BGRColorTuple] = None, thickness: int = 2) -> 'ImageBuilder': + text_image = ImageBuilder.from_text(text=label, text_displayer=TextDisplayer(text_color=color, background_color=background_color, scale=rel_font_size*self.image.shape[0]/20., thickness=thickness)).get_image() + self.image = ImageCol(text_image, self.image).render() if top_side else ImageCol(self.image, text_image).render() + return self + def draw_text(self, text: str, loc_xy: Tuple[int, int], colour: BGRColorTuple, anchor_xy: Tuple[float, float] = (0., 0.), shadow_color: Optional[BGRColorTuple] = None, background_color: Optional[BGRColorTuple] = None, thickness=1, scale=1., font=cv2.FONT_HERSHEY_PLAIN ) -> 'ImageBuilder': diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index 114d750b..84df1a7b 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -893,7 +893,11 @@ def create_display_image(self, # Add the image src_image = image[src_y1:src_y2, src_x1:src_x2] - src_image_scaled = cv2.resize(src_image, (dest_x2 - dest_x1, dest_y2 - dest_y1), interpolation=cv2.INTER_NEAREST if self.zoom_level > nearest_neighbor_zoom_threshold else cv2.INTER_LINEAR) + try: + src_image_scaled = cv2.resize(src_image, (dest_x2 - dest_x1, dest_y2 - dest_y1), interpolation=cv2.INTER_NEAREST if self.zoom_level > nearest_neighbor_zoom_threshold else cv2.INTER_LINEAR) + except Exception as err: + print(f"Resize failed on images of shape {src_image} with dest shape {(dest_x2 - dest_x1, dest_y2 - dest_y1)} and interpolation {cv2.INTER_NEAREST if self.zoom_level > nearest_neighbor_zoom_threshold else cv2.INTER_LINEAR}") + raise err result_array[dest_y1:dest_y2, dest_x1:dest_x2] = src_image_scaled # Add the scroll bars diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index 5e32ae32..e2d50f83 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -1,15 +1,20 @@ import datetime import itertools import os +from _py_abc import ABCMeta +from abc import abstractmethod from dataclasses import dataclass -from typing import Tuple, Optional, Iterator +from typing import Tuple, Optional, Iterator, Sequence import av - -from artemis.general.custom_types import BGRImageArray, TimeIntervalTuple +import cv2 +import exif +import numpy as np +from artemis.general.custom_types import BGRImageArray, TimeIntervalTuple, Array from artemis.general.utils_utils import byte_size_to_string from artemis.image_processing.image_utils import fit_image_to_max_size from artemis.general.item_cache import CacheDict from artemis.general.parsing import parse_time_delta_str_to_sec +from video_scanner.general_utils.srt_files import read_image_time_or_none @dataclass @@ -70,7 +75,97 @@ def get_actual_frame_interval( @dataclass -class VideoReader: +class IVideoReader(metaclass=ABCMeta): + """ + Interface for something behaving like a video reader. + """ + + @abstractmethod + def get_metadata(self) -> VideoMetaData: + """ Get data showing number of frames, size, etc. """ + + @abstractmethod + def get_n_frames(self) -> int: + """ Get the number of frames in the video """ + + @abstractmethod + def time_to_nearest_frame(self, t: float) -> int: + """ Get the frame index nearest the time t """ + + @abstractmethod + def frame_index_to_nearest_frame(self, index: int) -> int: + """ Get the frame index nearest the time t """ + + @abstractmethod + def frame_index_to_time(self, frame_ix: int) -> float: + """ Get the time corresponding to the frame index """ + + @abstractmethod + def time_indicator_to_nearest_frame(self, time_indicator: str) -> Optional[int]: + """ Get the frame index nearest the time-indicator + e.g. "0:32.5" "32.5s", "53%", "975" (frame number) + Returns None if the time_indicator is invalid + """ + + @abstractmethod + def iter_frame_ixs(self) -> Iterator[int]: + """ Iterate through frame indices """ + + @abstractmethod + def iter_frames(self) -> Iterator[VideoFrameInfo]: + """ Iterate through the frames of the video """ + + @abstractmethod + def cut(self, time_interval: TimeIntervalTuple = (None, None), + frame_interval: Tuple[Optional[int], Optional[int]] = (None, None)) -> 'IVideoReader': + """ Cut the video to the given time interval """ + + @abstractmethod + def request_frame(self, index: int) -> VideoFrameInfo: + """ + Request a frame of the video. If the requested frame is out of bounds, this will return the frame + on the closest edge. + """ + + @abstractmethod + def destroy(self): + """ Destroy the video reader (prevents memory leaks) """ + + +def time_indicator_to_nearest_frame(time_indicator: str, n_frames: int, fps: Optional[float] = None, frame_times: Optional[Sequence[float]] = None) -> Optional[int]: + """ Get the frame index nearest the time-indicator + e.g. "0:32.5" "32.5s", "53%", "975" (frame number) + Returns None if the time_indicator is invalid + """ + assert (fps is not None) != (frame_times is not None), "You must provide either fps or frame_times. Not both and not neither." + + def lookup_frame_ix(t: float) -> int: + if frame_times is None: + return round(t * fps) + else: + return np.searchsorted(frame_times, t, side='left') + + if time_indicator in ('s', 'start'): + return 0 + elif time_indicator in ('e', 'end'): + return n_frames - 1 + elif ':' in time_indicator: + sec = parse_time_delta_str_to_sec(time_indicator) + return lookup_frame_ix(sec) + elif time_indicator.endswith('s'): + sec = float(time_indicator.rstrip('s')) + return lookup_frame_ix(sec) + elif time_indicator.endswith('%'): + percent = float(time_indicator.rstrip('%')) + return round(percent / 100 * n_frames) + elif all(c in '0123456789' for c in time_indicator): + return int(time_indicator) + else: + return None + + +@dataclass +class VideoReader(IVideoReader): """ The reader efficiently provides access to video frames. It uses pyav: https://pyav.org/docs/stable/ @@ -165,43 +260,20 @@ def time_indicator_to_nearest_frame(self, time_indicator: str) -> Optional[int]: e.g. "0:32.5" "32.5s", "53%", "975" (frame number) Returns None if the time_indicator is invalid """ - if time_indicator in ('s', 'start'): - return 0 - elif time_indicator in ('e', 'end'): - return self.get_n_frames() - 1 - elif ':' in time_indicator: - sec = parse_time_delta_str_to_sec(time_indicator) - return round(sec * self.get_metadata().fps) - elif time_indicator.endswith('s'): - sec = float(time_indicator.rstrip('s')) - return round(sec * self.get_metadata().fps) - elif time_indicator.endswith('%'): - percent = float(time_indicator.rstrip('%')) - return round(percent / 100 * self.get_n_frames()) - elif all(c in '0123456789' for c in time_indicator): - return int(time_indicator) - else: - return None - - def iter_frame_ixs(self, time_interval: TimeIntervalTuple = (None, None), - frame_interval: Tuple[Optional[int], Optional[int]] = (None, None) - ) -> Iterator[int]: - start, stop = get_actual_frame_interval(fps=self._fps, time_interval=time_interval, - frame_interval=frame_interval, - n_frames_total=self.get_n_frames()) + return time_indicator_to_nearest_frame(time_indicator, n_frames=self.get_n_frames(), fps=self._fps) + + def iter_frame_ixs(self) -> Iterator[int]: + start, stop = get_actual_frame_interval(fps=self._fps, n_frames_total=self.get_n_frames()) """ TODO: Get rid of arguments - just use cut instead """ return range(start, stop) - def iter_frames(self, - time_interval: TimeIntervalTuple = (None, None), - frame_interval: Tuple[Optional[int], Optional[int]] = (None, None) - ) -> Iterator[VideoFrameInfo]: + def iter_frames(self) -> Iterator[VideoFrameInfo]: """ TODO: Get rid of arguments - just use cut instead """ - for i in self.iter_frame_ixs(time_interval=time_interval, frame_interval=frame_interval): + for i in self.iter_frame_ixs(): yield self.request_frame(i) def cut(self, time_interval: TimeIntervalTuple = (None, None), @@ -280,5 +352,78 @@ def destroy(self): self._is_destroyed = True - # def __del__(self): - # print(f"Video reader {id(self)} closed!") +def get_time_ordered_image_paths(image_paths: Sequence[str], fallback_fps: float = 1. + ) -> Tuple[Sequence[str], Sequence[float]]: + image_times = [read_image_time_or_none(path) for path in image_paths] + if any(t is None for t in image_times): + image_times = [i / fallback_fps for i in range(len(image_paths))] + ixs = np.argsort(image_times) + return [image_paths[ix] for ix in ixs], image_times[ixs] + + +@dataclass +class ImageSequenceReader(IVideoReader): + """ Reads through a seqence of images as if they were a videos.""" + + def __init__(self, image_paths: Sequence[str], fallback_fps: float = 1., reorder = False): + self._image_paths = image_paths + if reorder: + self._image_paths, self._image_times = get_time_ordered_image_paths(image_paths, fallback_fps) + else: + self._image_times = [i / fallback_fps for i in range(len(image_paths))] + + def get_sorted_paths(self) -> Sequence[str]: + return self._image_paths + + def get_metadata(self) -> VideoMetaData: + image_meta = exif.Image(self._image_paths[0]) + size_xy = image_meta.pixel_x_dimension, image_meta.pixel_y_dimension + return VideoMetaData( + duration=self._image_times[-1] - self._image_times[0], + n_frames=len(self._image_paths), + fps=len(self._image_paths) / (self._image_times[-1] - self._image_times[0]), + size_xy=size_xy, + n_bytes=sum(os.path.getsize(path) for path in self._image_paths) + ) + + def get_n_frames(self) -> int: + return len(self._image_paths) + + def time_to_nearest_frame(self, t: float) -> int: + return np.searchsorted(self._image_times, t, side='left') + + def frame_index_to_nearest_frame(self, index: int) -> int: + return min(max(0, index), self.get_n_frames() - 1) + + def frame_index_to_time(self, frame_ix: int) -> float: + return self._image_times[frame_ix] + + def time_indicator_to_nearest_frame(self, time_indicator: str) -> Optional[int]: + return time_indicator_to_nearest_frame(time_indicator, n_frames=self.get_n_frames(), frame_times=self._image_times) + + def iter_frame_ixs(self) -> Iterator[int]: + return range(self.get_n_frames()) + + def iter_frames(self) -> Iterator[VideoFrameInfo]: + for i in self.iter_frame_ixs(): + yield self.request_frame(i) + + def cut(self, time_interval: TimeIntervalTuple = (None, None), frame_interval: Tuple[Optional[int], Optional[int]] = (None, None)) -> 'ImageSequenceReader': + if time_interval[0] is not None: + frame_interval = (self.time_to_nearest_frame(time_interval[0]), frame_interval[1]) + if time_interval[1] is not None: + frame_interval = (frame_interval[0], self.time_to_nearest_frame(time_interval[1])) + return ImageSequenceReader(self._image_paths[frame_interval[0]:frame_interval[1]]) + + def request_frame(self, index: int) -> VideoFrameInfo: + image = cv2.imread(self._image_paths[index]) + assert image is not None, f"Could not load image at path {self._image_paths[index]}" + return VideoFrameInfo( + image=image, + seconds_into_video=self._image_times[index], + frame_ix=index, + fps=self.get_metadata().fps + ) + + def destroy(self): + pass diff --git a/artemis/image_processing/video_segment.py b/artemis/image_processing/video_segment.py index a267e638..bd76c28e 100644 --- a/artemis/image_processing/video_segment.py +++ b/artemis/image_processing/video_segment.py @@ -6,7 +6,7 @@ from artemis.general.custom_types import TimeIntervalTuple, BGRImageArray from artemis.image_processing.image_utils import iter_images_from_video, fit_image_to_max_size -from artemis.image_processing.video_reader import VideoReader, VideoFrameInfo, VideoMetaData +from artemis.image_processing.video_reader import VideoReader, VideoFrameInfo, VideoMetaData, ImageSequenceReader, IVideoReader @dataclass @@ -37,11 +37,26 @@ def check_passthrough(self): return self def iter_images(self, max_size: Optional[Tuple[int, int]] = None, max_count: Optional[int] = None) -> Iterator[BGRImageArray]: - yield from iter_images_from_video(self.path, time_interval=self.time_interval, max_size=max_size or self.max_size, rotation=self.rotation, frame_interval=(None, max_count)) + if self._is_image_sequence(): + yield from (f.image for f in self.iter_frame_info()) + else: + yield from iter_images_from_video(self.path, time_interval=self.time_interval, max_size=max_size or self.max_size, rotation=self.rotation, frame_interval=(None, max_count)) + + def _is_image_sequence(self) -> bool: + return ';' in self.path - def get_reader(self, buffer_size_bytes: int = 1024**3, use_cache: bool = True) -> VideoReader: - return VideoReader(self.path, time_interval=self.time_interval, frame_interval=self.frame_interval, - buffer_size_bytes=buffer_size_bytes, use_cache=use_cache, max_size_xy=self.max_size) + def get_reader(self, buffer_size_bytes: int = 1024**3, use_cache: bool = True) -> IVideoReader: + + if self._is_image_sequence(): + assert self.max_size is None, "Not supported" + assert self.frame_interval == (None, None), "Not Supported" + assert self.time_interval == (None, None), "Not Supported" + assert self.rotation == 0, "Not Supported" + assert self.frames_of_interest is None, "Not Supported" + return ImageSequenceReader(self.path.split(';')) + else: + return VideoReader(self.path, time_interval=self.time_interval, frame_interval=self.frame_interval, + buffer_size_bytes=buffer_size_bytes, use_cache=use_cache, max_size_xy=self.max_size) def recut(self, start_time: Optional[float] = None, end_time: Optional[float] = None): return replace(self, time_interval=(start_time, end_time), frame_interval=self.frame_interval) @@ -50,7 +65,10 @@ def iter_frame_info(self) -> Iterator[VideoFrameInfo]: """ TODO: Replace with yield from self.get_reader().iter_frames() + (Problem is is't currently slow) """ + if self._is_image_sequence(): + yield from self.get_reader().iter_frames() assert not self.use_scan_selection, "This does not work. See bug: https://github.com/opencv/opencv/issues/9053" path = os.path.expanduser(self.path) diff --git a/artemis/ml/tools/costs.py b/artemis/ml/tools/costs.py index 4c62d97c..74fd5521 100644 --- a/artemis/ml/tools/costs.py +++ b/artemis/ml/tools/costs.py @@ -1,12 +1,12 @@ import numpy as np -from artemis.general.mymath import cosine_distance, softmax +from artemis.general.mymath import cosine_similarity, softmax def get_evaluation_function(name): return { 'mse': mean_squared_error, - 'mean_cosine_distance': lambda a, b: cosine_distance(a, b, axis=1).mean(), + 'mean_cosine_distance': lambda a, b: cosine_similarity(a, b, axis=1).mean(), 'mean_squared_error': mean_squared_error, 'mean_l1_error': mean_l1_error, 'percent_argmax_correct': percent_argmax_correct, diff --git a/artemis/plotting/data_conversion.py b/artemis/plotting/data_conversion.py index 3d8494f0..fecafa3e 100644 --- a/artemis/plotting/data_conversion.py +++ b/artemis/plotting/data_conversion.py @@ -37,7 +37,7 @@ def put_vector_in_grid(vec, shape = None, empty_val = 0): @memoize -def _data_shape_and_boundary_width_to_grid_slices(shape, grid_shape: Optional[Tuple[int, int]], boundary_width: int, is_colour = None, min_size_xy: Tuple[int, int] = (0, 0)): +def _data_shape_and_boundary_width_to_grid_slices(shape, grid_shape: Optional[Tuple[Optional[int], Optional[int]]], boundary_width: int, is_colour = None, min_size_xy: Tuple[int, int] = (0, 0)): assert len(shape) in (3, 4) or len(shape)==5 and shape[-1]==3 if is_colour is None: @@ -45,9 +45,15 @@ def _data_shape_and_boundary_width_to_grid_slices(shape, grid_shape: Optional[Tu size_y, size_x = (shape[-3], shape[-2]) if is_colour else (shape[-2], shape[-1]) is_vector = (len(shape)==4 and is_colour) or (len(shape)==3 and not is_colour) - if grid_shape is None: - grid_shape = vector_length_to_tile_dims(shape[0]) if is_vector else shape[:2] - n_rows, n_cols = grid_shape + if grid_shape is None or grid_shape==(None, None): + n_rows, n_cols = vector_length_to_tile_dims(shape[0]) if is_vector else shape[:2] + else: + assert len(grid_shape)==2 + n_rows, n_cols = grid_shape + if n_rows is None: + n_rows = int(np.ceil(shape[0]/n_cols)) + if n_cols is None: + n_cols = int(np.ceil(shape[0]/n_rows)) minx, miny = min_size_xy @@ -84,7 +90,7 @@ def put_data_in_grid(data, fill_value, grid_shape = None, boundary_width: int = return output_data -def put_data_in_image_grid(data, grid_shape: Optional[Tuple[int, int]] = None, fill_colour = np.array((0, 0, 128), dtype ='uint8'), cmap ='gray', +def put_data_in_image_grid(data, grid_shape: Optional[Tuple[Optional[int], Optional[int]]] = None, fill_colour = np.array((0, 0, 128), dtype ='uint8'), cmap ='gray', boundary_width = 1, clims = None, is_color_data=None, nan_colour=None, min_size_xy: Tuple[int, int] = (0, 0)): """ Given a 3-d or 4-D array, put it in a 2-d grid. diff --git a/artemis/plotting/image_mosaic.py b/artemis/plotting/image_mosaic.py index 52a09609..26313f06 100644 --- a/artemis/plotting/image_mosaic.py +++ b/artemis/plotting/image_mosaic.py @@ -1,4 +1,4 @@ -from typing import Union, Mapping, Sequence, Tuple +from typing import Union, Mapping, Sequence, Tuple, Optional import numpy as np @@ -12,6 +12,7 @@ def generate_image_mosaic_and_index_grid( mosaic: Union[Mapping[int, BGRImageArray], Sequence[BGRImageArray]], gap_color: BGRColorTuple = DEFAULT_GAP_COLOR, desired_aspect_ratio = 1., # TODO: Make it work + grid_shape: Tuple[Optional[int], Optional[int]] = (None, None), # (rows, columns) min_size_xy: Tuple[int, int] = (640, 480), padding: int = 1, ) -> Tuple[BGRImageArray, IndexImageArray]: @@ -33,8 +34,8 @@ def generate_image_mosaic_and_index_grid( id_array = np.zeros(image_array.shape[:3], dtype=int) id_array += np.array(ids)[:, None, None] - image_grid = put_data_in_image_grid(image_array, fill_colour=gap_color, boundary_width=padding, min_size_xy=min_size_xy) - id_grid = put_data_in_grid(id_array, fill_value=-1, min_size_xy=min_size_xy) + image_grid = put_data_in_image_grid(image_array, grid_shape=grid_shape, fill_colour=gap_color, boundary_width=padding, min_size_xy=min_size_xy) + id_grid = put_data_in_grid(id_array, grid_shape=grid_shape, fill_value=-1, min_size_xy=min_size_xy) return image_grid, id_grid diff --git a/artemis/plotting/parallel_coords_plots.py b/artemis/plotting/parallel_coords_plots.py index 8e88e302..0b7c48cd 100644 --- a/artemis/plotting/parallel_coords_plots.py +++ b/artemis/plotting/parallel_coords_plots.py @@ -3,7 +3,7 @@ import numpy as np from matplotlib import pyplot as plt -from artemis.general.mymath import cosine_distance +from artemis.general.mymath import cosine_similarity from artemis.general.should_be_builtins import izip_equal from artemis.plotting.pyplot_plus import axhlines @@ -69,7 +69,7 @@ def parallel_coords_plot(field_names, values, special_formats = {}, scales = {}, if alpha=='auto': mean_param = np.mean(norm_lines, axis=0) for i, line in enumerate(norm_lines): - sameness = max(0, cosine_distance(mean_param, line)) # (0 to 1 where 1 means same as the mean) + sameness = max(0, cosine_similarity(mean_param, line)) # (0 to 1 where 1 means same as the mean) alpha = sameness * (1./np.sqrt(values.shape[0])) + (1-sameness)*1. formats[i]['alpha'] = alpha else: From c1692065c9f95dafc45fe0f4a9249626778523f6 Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 30 Mar 2023 15:23:46 -0700 Subject: [PATCH 055/107] premerge --- artemis/image_processing/image_utils.py | 4 ++++ artemis/image_processing/video_reader.py | 12 ++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index 114d750b..8035d5ed 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -119,6 +119,7 @@ def fit_image_to_max_size(image: BGRImageArray, max_size: Tuple[int, int]): """ Deprecated. Use VideoSegment.iter_images """ + def iter_images_from_video(path: str, max_size: Optional[Tuple[int, int]] = None, frame_interval: Tuple[Optional[int], Optional[int]] = (None, None), time_interval: TimeIntervalTuple = (None, None), @@ -138,6 +139,9 @@ def iter_images_from_video(path: str, max_size: Optional[Tuple[int, int]] = None yield image return + + + assert not use_scan_selection, "This does not work. See bug: https://github.com/opencv/opencv/issues/9053" path = os.path.expanduser(path) cap = cv2.VideoCapture(path) diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index d98747a7..01504592 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -137,14 +137,22 @@ def __init__(self, def get_metadata(self) -> VideoMetaData: file_stats = os.stat(self._path) - if self._metadata is None: + + try: + width = self.container.streams.video[0].codec_context.width + height = self.container.streams.video[0].codec_context.height + except Exception as err: + print("Error getting width and height from video stream. Using first frame instead. Error: ", err) firstframe = self.request_frame(0) + width, height = firstframe.image.shape[1], firstframe.image.shape[0] + + if self._metadata is None: self._metadata = VideoMetaData( duration=self._n_frames/self._fps, n_frames=max(1, self._n_frames), # It seems to give 0 for images which aint right fps=self._fps, n_bytes=file_stats.st_size, - size_xy=(firstframe.image.shape[1], firstframe.image.shape[0]) + size_xy=(width, height) ) return self._metadata From b60c577175b2c3a2d257d8cb44cd688ad155f96e Mon Sep 17 00:00:00 2001 From: peter Date: Wed, 19 Apr 2023 13:36:35 -0700 Subject: [PATCH 056/107] misc --- artemis/experiments/experiment_record.py | 1 + artemis/general/utils_for_testing.py | 6 ++++++ artemis/image_processing/image_utils.py | 13 ++++++++----- artemis/image_processing/video_segment.py | 3 +++ 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/artemis/experiments/experiment_record.py b/artemis/experiments/experiment_record.py index e8f8975c..6a337f65 100644 --- a/artemis/experiments/experiment_record.py +++ b/artemis/experiments/experiment_record.py @@ -279,6 +279,7 @@ def save_result(self, result): pickle.dump(result, f, protocol=pickle.HIGHEST_PROTOCOL) ARTEMIS_LOGGER.info('Saving Result for Experiment "{}"'.format(self.get_id(),)) + def get_id(self): """ Get the id of this experiment record. Generally in format '-' diff --git a/artemis/general/utils_for_testing.py b/artemis/general/utils_for_testing.py index 03e4be6b..f90ee182 100644 --- a/artemis/general/utils_for_testing.py +++ b/artemis/general/utils_for_testing.py @@ -9,6 +9,12 @@ from artemis.general.custom_types import MaskImageArray, Array + +# ASSET_FOLDER_PATH = "~/projects/eagle_eyes_app/app/src/main/assets" +# VIDEO_SCANNER_RESOURCE_MODELS_PATH = "~/projects/eagle_eyes_video_scanner/resources/models" + + + def mask_to_imstring(mask: MaskImageArray) -> str: return '\n'.join(''.join('X' if m else 'â€ĸ' for m in row) for row in mask) diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index 84df1a7b..ac757f72 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -131,11 +131,14 @@ def iter_images_from_video(path: str, max_size: Optional[Tuple[int, int]] = None # On windows machines the normal video reading does not work for images if any(path.lower().endswith(e) for e in ('.jpg', '.jpeg')): - image = cv2.imread(path) - if max_size is not None: - image = fit_image_to_max_size(image, max_size) - assert image is not None, f"Could not read any image from {path}" - yield image + for i, image_path in enumerate(path.split(';')): + if frames_of_interest is not None and i not in frames_of_interest: + continue + image = cv2.imread(image_path) + if max_size is not None: + image = fit_image_to_max_size(image, max_size) + assert image is not None, f"Could not read any image from {image_path}" + yield image return assert not use_scan_selection, "This does not work. See bug: https://github.com/opencv/opencv/issues/9053" diff --git a/artemis/image_processing/video_segment.py b/artemis/image_processing/video_segment.py index bd76c28e..b4fb6f3b 100644 --- a/artemis/image_processing/video_segment.py +++ b/artemis/image_processing/video_segment.py @@ -42,6 +42,9 @@ def iter_images(self, max_size: Optional[Tuple[int, int]] = None, max_count: Opt else: yield from iter_images_from_video(self.path, time_interval=self.time_interval, max_size=max_size or self.max_size, rotation=self.rotation, frame_interval=(None, max_count)) + def exists(self) -> bool: + return all(os.path.exists(os.path.expanduser(f)) for f in self.path.split(';')) + def _is_image_sequence(self) -> bool: return ';' in self.path From de73adaa687047c7aa49ea8f1d84d3978499e7c8 Mon Sep 17 00:00:00 2001 From: peter Date: Wed, 19 Apr 2023 15:32:36 -0700 Subject: [PATCH 057/107] couple fixes --- artemis/image_processing/image_utils.py | 4 ++-- artemis/image_processing/video_reader.py | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index 6fd3b3ed..abedb0d7 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -651,8 +651,8 @@ def create_placeholder_image( # Generate a colour image filled with one colour ) -> BGRImageArray: gap_image = create_gap_image(gap_colour=gap_colour, size=size) n_steps = max(gap_image.shape[:2]) - row_ixs = np.linspace(0, gap_image.shape[0] - 1, n_steps).astype(np.int) - col_ixs = np.linspace(0, gap_image.shape[1] - 1, n_steps).astype(np.int) + row_ixs = np.linspace(0, gap_image.shape[0] - 1, n_steps).astype(int) + col_ixs = np.linspace(0, gap_image.shape[1] - 1, n_steps).astype(int) gap_image[row_ixs, col_ixs] = x_color gap_image[row_ixs, -col_ixs] = x_color return gap_image diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index 79f975ec..dd00feba 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -347,7 +347,7 @@ def request_frame(self, index: int) -> VideoFrameInfo: self._iterator = self._iter_frame_data() for j, f in enumerate(self._iterator): if j > max_seek_search: - raise RuntimeError(f'Did not find target within {max_seek_search} frames of seek') + raise RuntimeError(f'Did not find target frame {index} within {max_seek_search} frames of seek') if f.pts >= pts - 1: self._iterator = itertools.chain([f], self._iterator) break @@ -385,11 +385,18 @@ def get_sorted_paths(self) -> Sequence[str]: def get_metadata(self) -> VideoMetaData: image_meta = exif.Image(self._image_paths[0]) - size_xy = image_meta.pixel_x_dimension, image_meta.pixel_y_dimension + if hasattr(image_meta, 'pixel_x_dimension'): + size_xy = image_meta.pixel_x_dimension, image_meta.pixel_y_dimension + elif hasattr(image_meta, 'image_width'): + size_xy = image_meta.image_width, image_meta.image_height + else: # Load it and find out + img = cv2.imread(self._image_paths[0]) + size_xy = img.shape[1], img.shape[0] + return VideoMetaData( duration=self._image_times[-1] - self._image_times[0], n_frames=len(self._image_paths), - fps=len(self._image_paths) / (self._image_times[-1] - self._image_times[0]), + fps=len(self._image_paths) / (self._image_times[-1] - self._image_times[0] if len(self._image_paths) > 1 else 1.), size_xy=size_xy, n_bytes=sum(os.path.getsize(path) for path in self._image_paths) ) From 32375f61de197b863141cfb9659db04edb654b59 Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 27 Apr 2023 14:22:49 -0700 Subject: [PATCH 058/107] more-robust-metadata --- artemis/image_processing/video_reader.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index dd00feba..858fdd5b 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -385,11 +385,12 @@ def get_sorted_paths(self) -> Sequence[str]: def get_metadata(self) -> VideoMetaData: image_meta = exif.Image(self._image_paths[0]) - if hasattr(image_meta, 'pixel_x_dimension'): - size_xy = image_meta.pixel_x_dimension, image_meta.pixel_y_dimension - elif hasattr(image_meta, 'image_width'): - size_xy = image_meta.image_width, image_meta.image_height - else: # Load it and find out + try: + if hasattr(image_meta, 'pixel_x_dimension'): + size_xy = image_meta.pixel_x_dimension, image_meta.pixel_y_dimension + elif hasattr(image_meta, 'image_width'): + size_xy = image_meta.image_width, image_meta.image_height + except: # Load it and find out img = cv2.imread(self._image_paths[0]) size_xy = img.shape[1], img.shape[0] From 7d78e6900bcc5bb606a4c834918b0e7f8819f740 Mon Sep 17 00:00:00 2001 From: peter Date: Fri, 12 May 2023 14:09:44 -0700 Subject: [PATCH 059/107] livestreaming changes --- artemis/fileman/file_utils.py | 6 +- artemis/image_processing/image_utils.py | 23 +++ .../image_processing/livestream_recorder.py | 166 ++++++++++++++++ artemis/image_processing/video_frame.py | 21 ++ artemis/image_processing/video_reader.py | 183 +++++++++++++++--- artemis/image_processing/video_segment.py | 3 +- 6 files changed, 378 insertions(+), 24 deletions(-) create mode 100644 artemis/image_processing/livestream_recorder.py create mode 100644 artemis/image_processing/video_frame.py diff --git a/artemis/fileman/file_utils.py b/artemis/fileman/file_utils.py index 6fe10684..63ea19c9 100644 --- a/artemis/fileman/file_utils.py +++ b/artemis/fileman/file_utils.py @@ -1,5 +1,6 @@ import os import shutil +import time from datetime import datetime from typing import Optional, Sequence, Mapping, Iterator @@ -12,13 +13,16 @@ def modified_timestamp_to_filename(timestamp: float, time_format: str = "%Y-%m-% return datetime.utcfromtimestamp(timestamp).strftime(time_format) -def get_dest_filepath(src_path: str, src_root_dir: str, dest_root_dir: str, time_format: str = "%Y-%m-%d_%H-%M-%S") -> str: +def get_dest_filepath(src_path: str, src_root_dir: str, dest_root_dir: str, time_format: str = "%Y-%m-%d_%H-%M-%S", is_daylight = time.daylight) -> str: src_root_dir = src_root_dir.rstrip(os.sep) + os.sep assert src_path.startswith(src_root_dir), f"File {src_path} was not in root dir {src_root_dir}" src_rel_folder, src_filename = os.path.split(src_path[len(src_root_dir):]) src_name, ext = os.path.splitext(src_filename) src_order_number = src_name.split('_', 1)[1] # 'DJI_0215' -> '0215' timestamp = os.path.getmtime(src_path) + # if is_daylight: + # timestamp += 3600. + # timestamp -= 3600. if time.daylight else 0 new_filename = f'{modified_timestamp_to_filename(timestamp, time_format=time_format)}_{src_order_number}{ext.lower()}' return os.path.join(dest_root_dir, src_rel_folder, new_filename) diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index abedb0d7..b16e0643 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -3,10 +3,12 @@ import os from abc import abstractmethod, ABCMeta from dataclasses import dataclass, replace +from datetime import datetime from math import floor, ceil from typing import Iterable, Tuple, Union, Optional, Sequence, Callable, TypeVar, Iterator import cv2 +import exif import numpy as np from attr import attrs, attrib @@ -119,6 +121,14 @@ def fit_image_to_max_size(image: BGRImageArray, max_size: Tuple[int, int]): """ Deprecated. Use VideoSegment.iter_images """ +def iter_images_from_livestream(livestream_url) -> Iterator[BGRImageArray]: + cap = cv2.VideoCapture(livestream_url) + while True: + ret, frame = cap.read() + if not ret: + break + yield frame + def iter_images_from_video(path: str, max_size: Optional[Tuple[int, int]] = None, frame_interval: Tuple[Optional[int], Optional[int]] = (None, None), @@ -1010,3 +1020,16 @@ def load_artemis_image(which: str = 'statue') -> BGRImageArray: # xstops[i, j] = ystops[i, j] = False # # return xystops +def read_image_time_or_none(image_path: str) -> Optional[float]: + """ Get the epoch time of the image as a float """ + with open(image_path, 'rb') as image_file: + try: + exif_data = exif.Image(image_file) + except Exception as err: + return None + if exif_data.has_exif: + # parse string like '2022:11:20 14:03:13' into datetime + datetime_obj = datetime.strptime(exif_data.datetime_original, '%Y:%m:%d %H:%M:%S') + return datetime_obj.timestamp() + else: + return None diff --git a/artemis/image_processing/livestream_recorder.py b/artemis/image_processing/livestream_recorder.py new file mode 100644 index 00000000..a147bfbf --- /dev/null +++ b/artemis/image_processing/livestream_recorder.py @@ -0,0 +1,166 @@ +""" +To get this working (MacOS): + +1) Download Local RTMP Server, run it +2) Stream a video locally: ffmpeg -stream_loop -1 -i /Users/peter/drone/dji/raw/dji_2023-02-19_23-51-29_0737.mp4 -f flv rtmp://127.0.0.1/live +3) Run this script +""" +import multiprocessing +import os.path +import queue +from contextlib import contextmanager +from functools import partial +from multiprocessing import Queue +from typing import Optional + +import cv2 +import numpy as np +from dataclasses import dataclass, field + +from artemis.general.debug_utils import easy_profile +from artemis.image_processing.video_frame import VideoFrameInfo +import logging + +LIVESTREAM_LOGGER = logging.getLogger("livestream_recorder") + + +def read_stream_and_save_to_disk( + stream_url: str, + writing_video_path: Optional[str] = None, + poison_pill_input_queue: Optional["Queue[bool]"] = None, + latest_frame_return_queue: Optional["Queue[VideoFrameInfo]"] = None, + verbose: bool = False +): + """ Hey ChatGPT can you fill in this function please? """ + + LIVESTREAM_LOGGER.setLevel(logging.DEBUG if verbose else logging.WARNING) + + LIVESTREAM_LOGGER.debug(f"Trying to read stream {stream_url}") + cap = cv2.VideoCapture(stream_url) + LIVESTREAM_LOGGER.debug("Got stream!") + writer = None + + if cap.isOpened() is False: + LIVESTREAM_LOGGER.info("Error opening the stream or video") + return + LIVESTREAM_LOGGER.info("Launching reader thread") + frame_ix = 0 + last_frame = None + try: + while cap.isOpened(): + ret, frame = cap.read() + + if ret: + LIVESTREAM_LOGGER.debug(f"Process: Found frame of size {frame.shape}") + if writing_video_path is not None and writer is None: + # Initialize our video writer + fourcc = cv2.VideoWriter_fourcc(*"mp4v") + writer = cv2.VideoWriter(os.path.expanduser(writing_video_path), fourcc, cap.get(cv2.CAP_PROP_FPS), + (frame.shape[1], frame.shape[0]), True) + if last_frame is not None and np.array_equal(frame, last_frame): + LIVESTREAM_LOGGER.debug("Process: Found duplicate frame, skipping") + continue + last_frame = frame + + # Write the output frame to disk + if writer is not None: + writer.write(frame) + + seconds_into_video = frame_ix / cap.get(cv2.CAP_PROP_FPS) + frame_info = VideoFrameInfo(image=frame, seconds_into_video=seconds_into_video, + frame_ix=frame_ix, fps=cap.get(cv2.CAP_PROP_FPS)) + if latest_frame_return_queue is not None: + if not latest_frame_return_queue.full(): + latest_frame_return_queue.put(frame_info) + frame_ix += 1 + else: + LIVESTREAM_LOGGER.debug("Process: Found no frame from stream") + break + + if poison_pill_input_queue is not None and not poison_pill_input_queue.empty(): + if poison_pill_input_queue.get(): + break + finally: + cap.release() + if writer is not None: + with easy_profile(f"Process: Finalizing video {writing_video_path}...", log_entry=True): + writer.release() + else: + LIVESTREAM_LOGGER.debug("Process: Done!") + LIVESTREAM_LOGGER.warning("Ending Livestream Process") + + +@dataclass +class LiveStreamRecorderAgent: + stream_url: str + writing_video_path: Optional[str] = None + poison_pill_input_queue: Optional["Queue[bool]"] = field(default_factory=lambda: Queue(maxsize=1)) + latest_frame_return_queue: Optional["Queue[VideoFrameInfo]"] = field(default_factory=lambda: Queue(maxsize=2)) + + def launch(self): + self.process = multiprocessing.Process( + target=partial(read_stream_and_save_to_disk, + stream_url=self.stream_url, + writing_video_path=self.writing_video_path, + poison_pill_input_queue=self.poison_pill_input_queue, + latest_frame_return_queue=self.latest_frame_return_queue + ) + ) + self.process.start() + + def get_last_frame(self) -> Optional[VideoFrameInfo]: + try: + return self.latest_frame_return_queue.get_nowait() + except queue.Empty: + return None + + def get_last_frame_blocking(self, timeout: Optional[float] = None) -> VideoFrameInfo: + return self.latest_frame_return_queue.get(timeout=timeout) + + @contextmanager + def launch_and_iter_frames_context(self): + try: + yield self.launch_and_iter_frames() + finally: + self.kill() + + def launch_and_iter_frames(self): + self.launch() + while True: + try: + # print("Main: Trying to get frame") + frame_info = self.latest_frame_return_queue.get(timeout=0.1) + except queue.Empty: + # print("Main: But no frame") + continue + yield frame_info + + def kill(self): + print("Stopping Livestrean process...") + self.poison_pill_input_queue.put(True) + while not self.latest_frame_return_queue.empty(): + try: + self.latest_frame_return_queue.get_nowait() + except queue.Empty: + continue + self.process.terminate() # Forcefully stop the child process + self.process.join() # Wait for the child process to stop + print("Stopped Livestrean process.") + + +def demo_livestream_viewer(): + agent = LiveStreamRecorderAgent( + stream_url="rtmp://127.0.0.1:1935/live", + writing_video_path='~/Downloads/demo_livestream_record.mp4', + ) + with agent.launch_and_iter_frames_context() as frame_iterator: + for frame_info in frame_iterator: + cv2.imshow('frame', frame_info.image) + if cv2.waitKey(1) & 0xFF == ord('q'): + break + print("Out of loop") + print("Out of context manager") + + +if __name__ == '__main__': + demo_livestream_viewer() diff --git a/artemis/image_processing/video_frame.py b/artemis/image_processing/video_frame.py new file mode 100644 index 00000000..f7e18bfe --- /dev/null +++ b/artemis/image_processing/video_frame.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from typing import Tuple, Optional + +from artemis.general.custom_types import BGRImageArray + + +@dataclass +class VideoFrameInfo: + image: BGRImageArray + seconds_into_video: float + frame_ix: int + fps: float + + def get_size_xy(self) -> Tuple[int, int]: + return self.image.shape[1], self.image.shape[0] + + def get_progress_string(self, total_frames: Optional[int] = None) -> str: + if total_frames is None: + return f"t={self.seconds_into_video:.2f}s, frame={self.frame_ix}" + else: + return f"t={self.seconds_into_video:.2f}s/{total_frames/self.fps:.2f}s, frame={self.frame_ix}/{total_frames}" diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index 858fdd5b..43b746b9 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -1,6 +1,7 @@ import datetime import itertools import os +import time from _py_abc import ABCMeta from abc import abstractmethod from dataclasses import dataclass @@ -9,30 +10,15 @@ import cv2 import exif import numpy as np -from artemis.general.custom_types import BGRImageArray, TimeIntervalTuple, Array +from artemis.general.custom_types import TimeIntervalTuple from artemis.general.utils_utils import byte_size_to_string -from artemis.image_processing.image_utils import fit_image_to_max_size +from artemis.image_processing.image_utils import fit_image_to_max_size, read_image_time_or_none from artemis.general.item_cache import CacheDict from artemis.general.parsing import parse_time_delta_str_to_sec -from video_scanner.general_utils.srt_files import read_image_time_or_none +from artemis.image_processing.livestream_recorder import LiveStreamRecorderAgent +from artemis.image_processing.video_frame import VideoFrameInfo -@dataclass -class VideoFrameInfo: - image: BGRImageArray - seconds_into_video: float - frame_ix: int - fps: float - - def get_size_xy(self) -> Tuple[int, int]: - return self.image.shape[1], self.image.shape[0] - - def get_progress_string(self, total_frames: Optional[int] = None) -> str: - if total_frames is None: - return f"t={self.seconds_into_video:.2f}s, frame={self.frame_ix}" - else: - return f"t={self.seconds_into_video:.2f}s/{total_frames/self.fps:.2f}s, frame={self.frame_ix}/{total_frames}" - @dataclass class VideoMetaData: duration: float @@ -42,7 +28,7 @@ class VideoMetaData: size_xy: Tuple[int, int] def get_duration_string(self) -> str: - return str(datetime.timedelta(seconds=int(self.duration))) + return str(datetime.timedelta(seconds=int(self.duration))) if self.duration != float('inf') else '∞' def get_size_xy_string(self) -> str: return f"{self.size_xy[0]}x{self.size_xy[1]}" @@ -50,6 +36,7 @@ def get_size_xy_string(self) -> str: def get_size_string(self) -> str: return byte_size_to_string(self.n_bytes, decimals_precision=1) + def get_actual_frame_interval( n_frames_total: int, fps: float, @@ -164,7 +151,6 @@ def lookup_frame_ix(t: float) -> int: return None -@dataclass class VideoReader(IVideoReader): """ The reader efficiently provides access to video frames. @@ -384,8 +370,8 @@ def get_sorted_paths(self) -> Sequence[str]: return self._image_paths def get_metadata(self) -> VideoMetaData: - image_meta = exif.Image(self._image_paths[0]) try: + image_meta = exif.Image(self._image_paths[0]) if hasattr(image_meta, 'pixel_x_dimension'): size_xy = image_meta.pixel_x_dimension, image_meta.pixel_y_dimension elif hasattr(image_meta, 'image_width'): @@ -443,3 +429,156 @@ def request_frame(self, index: int) -> VideoFrameInfo: def destroy(self): pass + + +@dataclass +class LiveVideoReader(IVideoReader): + + stream_url: str + frames_seen_so_far: int = 0 + record: bool = True + _iterator: Optional[Iterator[VideoFrameInfo]] = None + + _last_frame: Optional[VideoFrameInfo] = None + + def get_metadata(self) -> VideoMetaData: + return VideoMetaData( + duration=np.inf, + n_frames=0, + fps=np.inf, + size_xy=(-1, -1), + n_bytes=-1 + ) + + def get_n_frames(self) -> int: + return 1 + + def time_to_nearest_frame(self, t: float) -> int: + return 0 + + def frame_index_to_nearest_frame(self, index: int) -> int: + return 0 + + def frame_index_to_time(self, frame_ix: int) -> float: + return 0. + + def time_indicator_to_nearest_frame(self, time_indicator: str) -> Optional[int]: + return 0 + + def iter_frame_ixs(self) -> Iterator[int]: + yield 0 + + def iter_frames(self) -> Iterator[VideoFrameInfo]: + cap = cv2.VideoCapture(self.stream_url) + t_start = time.monotonic() + count = 0 + while True: + count += 1 + ret, frame = cap.read() + if not ret: + break + elapsed = time.monotonic() - t_start + yield VideoFrameInfo( + image=frame, + seconds_into_video=elapsed, + frame_ix=self.frames_seen_so_far, + fps=self.get_metadata().fps + ) + self.frames_seen_so_far += 1 + + def cut(self, time_interval: TimeIntervalTuple = (None, None), frame_interval: Tuple[Optional[int], Optional[int]] = (None, None)) -> 'IVideoReader': + raise NotImplementedError("Can't cut a live stream") + + def request_frame(self, index: int) -> VideoFrameInfo: + if self._iterator is None: + self._iterator = self.iter_frames() + return next(self._iterator) + # assert self._last_frame is not None, "Can't request a frame from a live stream without iterating through it" + # return self._last_frame + + def destroy(self): + pass + + +@dataclass +class LiveRecordingVideoReader(IVideoReader): + + agent: LiveStreamRecorderAgent + + # stream_url: str + # record_url: str + _frames_seen_so_far: int = 0 + _recorded_video_reader: Optional[VideoReader] = None + # _iterator: Optional[Iterator[VideoFrameInfo]] = None + + # _last_frame: Optional[VideoFrameInfo] = None + + def _get_recorded_video_reader(self) -> VideoReader: + if self._recorded_video_reader is None: + self._recorded_video_reader = VideoReader(self.agent.writing_video_path) + return self._recorded_video_reader + + def get_metadata(self) -> VideoMetaData: + return VideoMetaData( + duration=np.inf, + n_frames=self._frames_seen_so_far, + fps=np.inf, + size_xy=(-1, -1), + n_bytes=-1 + ) + + def get_n_frames(self) -> int: + return self._frames_seen_so_far + + def time_to_nearest_frame(self, t: float) -> int: + return 0 + + def frame_index_to_nearest_frame(self, index: int) -> int: + return 0 + + def frame_index_to_time(self, frame_ix: int) -> float: + return 0. + + def time_indicator_to_nearest_frame(self, time_indicator: str) -> Optional[int]: + return 0 + + def iter_frame_ixs(self) -> Iterator[int]: + yield 0 + + def iter_frames(self) -> Iterator[VideoFrameInfo]: + raise NotImplementedError() + # cap = cv2.VideoCapture(self.stream_url) + # t_start = time.monotonic() + # count = 0 + # while True: + # count += 1 + # ret, frame = cap.read() + # if not ret: + # break + # elapsed = time.monotonic() - t_start + # yield VideoFrameInfo( + # image=frame, + # seconds_into_video=elapsed, + # frame_ix=self._frames_seen_so_far, + # fps=self.get_metadata().fps + # ) + # self._frames_seen_so_far += 1 + + def cut(self, time_interval: TimeIntervalTuple = (None, None), frame_interval: Tuple[Optional[int], Optional[int]] = (None, None)) -> 'IVideoReader': + raise NotImplementedError("Can't cut a live stream") + + def request_frame(self, index: int) -> VideoFrameInfo: + print("Requesting frame ", index) + if index==-1 or index==self._frames_seen_so_far: + frame = self.agent.get_last_frame_blocking() + self._frames_seen_so_far = frame.frame_ix + 1 + print("Requesting last frame from live stream") + return frame + else: + print("Requesting frame from recorded video") + return self._get_recorded_video_reader().request_frame(index) + + def destroy(self): + pass + + diff --git a/artemis/image_processing/video_segment.py b/artemis/image_processing/video_segment.py index b4fb6f3b..0646c2f9 100644 --- a/artemis/image_processing/video_segment.py +++ b/artemis/image_processing/video_segment.py @@ -6,7 +6,8 @@ from artemis.general.custom_types import TimeIntervalTuple, BGRImageArray from artemis.image_processing.image_utils import iter_images_from_video, fit_image_to_max_size -from artemis.image_processing.video_reader import VideoReader, VideoFrameInfo, VideoMetaData, ImageSequenceReader, IVideoReader +from artemis.image_processing.video_reader import VideoReader, VideoMetaData, ImageSequenceReader, IVideoReader +from artemis.image_processing.video_frame import VideoFrameInfo @dataclass From 9bca84e4f07fd5066ba2ef5fd0f0418efbb2a380 Mon Sep 17 00:00:00 2001 From: peter Date: Wed, 17 May 2023 18:07:33 -0700 Subject: [PATCH 060/107] whatever --- .../image_processing/livestream_recorder.py | 67 +++++++++++++------ 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/artemis/image_processing/livestream_recorder.py b/artemis/image_processing/livestream_recorder.py index a147bfbf..5f680636 100644 --- a/artemis/image_processing/livestream_recorder.py +++ b/artemis/image_processing/livestream_recorder.py @@ -24,10 +24,14 @@ LIVESTREAM_LOGGER = logging.getLogger("livestream_recorder") +LATEST_FRAME_DONE_INDICATER = 'This string indicates that the poison pill has been received' + + def read_stream_and_save_to_disk( stream_url: str, writing_video_path: Optional[str] = None, poison_pill_input_queue: Optional["Queue[bool]"] = None, + poison_pill_ack_queue: Optional["Queue[bool]"] = None, latest_frame_return_queue: Optional["Queue[VideoFrameInfo]"] = None, verbose: bool = False ): @@ -49,7 +53,7 @@ def read_stream_and_save_to_disk( try: while cap.isOpened(): ret, frame = cap.read() - + print(f"Got frame of shape {frame.shape if frame is not None else None} from agent") if ret: LIVESTREAM_LOGGER.debug(f"Process: Found frame of size {frame.shape}") if writing_video_path is not None and writer is None: @@ -71,13 +75,16 @@ def read_stream_and_save_to_disk( frame_ix=frame_ix, fps=cap.get(cv2.CAP_PROP_FPS)) if latest_frame_return_queue is not None: if not latest_frame_return_queue.full(): + print(f"Adding a frame {frame_info.frame_ix} to the queue, which has size {latest_frame_return_queue.qsize()}") latest_frame_return_queue.put(frame_info) frame_ix += 1 else: LIVESTREAM_LOGGER.debug("Process: Found no frame from stream") - break + # break if poison_pill_input_queue is not None and not poison_pill_input_queue.empty(): + LIVESTREAM_LOGGER.critical("Process: Got poison pill, exiting") + print("Process: Got poison pill, exiting") if poison_pill_input_queue.get(): break finally: @@ -88,6 +95,14 @@ def read_stream_and_save_to_disk( else: LIVESTREAM_LOGGER.debug("Process: Done!") LIVESTREAM_LOGGER.warning("Ending Livestream Process") + if poison_pill_ack_queue is not None: + poison_pill_ack_queue.put(True) + + +def make_queue(maxsize: Optional[int]): + m = multiprocessing.Manager() + q = m.Queue(maxsize=maxsize) + return q @dataclass @@ -95,22 +110,38 @@ class LiveStreamRecorderAgent: stream_url: str writing_video_path: Optional[str] = None poison_pill_input_queue: Optional["Queue[bool]"] = field(default_factory=lambda: Queue(maxsize=1)) - latest_frame_return_queue: Optional["Queue[VideoFrameInfo]"] = field(default_factory=lambda: Queue(maxsize=2)) + poison_pill_ack_queue: Optional["Queue[bool]"] = field(default_factory=lambda: Queue(maxsize=1)) + latest_frame_return_queue: Optional["Queue[VideoFrameInfo]"] = field(default_factory=lambda: make_queue(maxsize=2)) + _is_done: bool = False + def launch(self): + print("Launching agent...") + print("Processes under this one before launching agent:") + print(multiprocessing.active_children()) + self.process = multiprocessing.Process( target=partial(read_stream_and_save_to_disk, stream_url=self.stream_url, writing_video_path=self.writing_video_path, poison_pill_input_queue=self.poison_pill_input_queue, + poison_pill_ack_queue=self.poison_pill_ack_queue, latest_frame_return_queue=self.latest_frame_return_queue ) ) self.process.start() + print("Processes under this one after launching agent:") + print(multiprocessing.active_children()) def get_last_frame(self) -> Optional[VideoFrameInfo]: try: - return self.latest_frame_return_queue.get_nowait() + if self._is_done: + return None + frame = self.latest_frame_return_queue.get_nowait() + if frame == LATEST_FRAME_DONE_INDICATER: + self._is_done = True + return None + return frame except queue.Empty: return None @@ -120,29 +151,23 @@ def get_last_frame_blocking(self, timeout: Optional[float] = None) -> VideoFrame @contextmanager def launch_and_iter_frames_context(self): try: - yield self.launch_and_iter_frames() + self.launch() + while True: + try: + # print("Main: Trying to get frame") + frame_info = self.latest_frame_return_queue.get(timeout=0.1) + except queue.Empty: + # print("Main: But no frame") + continue + yield frame_info finally: self.kill() - def launch_and_iter_frames(self): - self.launch() - while True: - try: - # print("Main: Trying to get frame") - frame_info = self.latest_frame_return_queue.get(timeout=0.1) - except queue.Empty: - # print("Main: But no frame") - continue - yield frame_info - def kill(self): print("Stopping Livestrean process...") + self._is_done = True self.poison_pill_input_queue.put(True) - while not self.latest_frame_return_queue.empty(): - try: - self.latest_frame_return_queue.get_nowait() - except queue.Empty: - continue + self.poison_pill_ack_queue.get() # Wait for the child process to acknowledge the poison pill self.process.terminate() # Forcefully stop the child process self.process.join() # Wait for the child process to stop print("Stopped Livestrean process.") From 3fc372cd76c9cf4d497949d8f99c83bab96050e6 Mon Sep 17 00:00:00 2001 From: peter Date: Fri, 19 May 2023 11:56:49 -0700 Subject: [PATCH 061/107] allow custom disk memo folder --- artemis/fileman/disk_memoize.py | 11 +++++++++++ artemis/image_processing/image_builder.py | 13 +++++++++---- artemis/image_processing/image_utils.py | 3 +++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/artemis/fileman/disk_memoize.py b/artemis/fileman/disk_memoize.py index 1c112902..6f0d93a2 100644 --- a/artemis/fileman/disk_memoize.py +++ b/artemis/fileman/disk_memoize.py @@ -35,6 +35,17 @@ def hold_temp_memo_dir(): MEMO_DIR = oldone +@contextmanager +def hold_memo_dir_as(path): + global MEMO_DIR + oldone = MEMO_DIR + MEMO_DIR = path + try: + yield path + finally: + MEMO_DIR = oldone + + def memoize_to_disk(fcn, local_cache = False, disable_on_tests=False, use_cpickle = False, suppress_info = False): """ Save (memoize) computed results to disk, so that the same function, called with the diff --git a/artemis/image_processing/image_builder.py b/artemis/image_processing/image_builder.py index 528a458c..97bce100 100644 --- a/artemis/image_processing/image_builder.py +++ b/artemis/image_processing/image_builder.py @@ -124,10 +124,12 @@ def draw_arrow(self, start_xy: Tuple[float, float], end_xy: Tuple[float, float], def draw_box(self, box: BoundingBox | RelativeBoundingBox, colour: BGRColorTuple = BGRColors.RED, secondary_colour: Optional[BGRColorTuple] = None, text_background_color: Optional[BGRColorTuple] = None, + text_color: Optional[BGRColorTuple] = None, text_scale = 0.7, thickness: int = 1, box_id: Optional[int] = None, include_labels = True, show_score_in_label: bool = True, score_as_pct: bool = False) -> 'ImageBuilder': - + if text_color is None: + text_color = colour if isinstance(box, RelativeBoundingBox): box = box.to_bounding_box((self.image.shape[1], self.image.shape[0])) # xmin, xmax, ymin, ymax = xx_yy_box @@ -141,7 +143,7 @@ def draw_box(self, box: BoundingBox | RelativeBoundingBox, colour: BGRColorTuple label = ','.join(str(i) for i in [box_id, box.label, None if not show_score_in_label else f"{box.score:.0%}" if score_as_pct else f"{box.score:.2f}"] if i is not None) if include_labels: - put_text_at(self.image, text=label, position_xy=(jmin, imin if box.y_min > box.y_max-box.y_min else imax), scale=text_scale*self.image.shape[1]/640, color=colour, shadow_color = BGRColors.BLACK, background_color=text_background_color, thickness=thickness) + put_text_at(self.image, text=label, position_xy=(jmin, imin if box.y_min > box.y_max-box.y_min else imax), scale=text_scale*self.image.shape[1]/640, color=text_color, shadow_color = BGRColors.BLACK, background_color=text_background_color, thickness=thickness) # cv2.putText(self.image, text=label, org=(imin, jmin), fontFace=cv2.FONT_HERSHEY_PLAIN, fontScale=.7*self.image.shape[1]/640, # color=colour, thickness=thickness) @@ -164,6 +166,7 @@ def draw_bounding_boxes(self, colour: BGRColorTuple = BGRColors.WHITE, secondary_colour: Optional[BGRColorTuple] = BGRColors.BLACK, text_background_colors: Optional[Iterable[BGRColorTuple]] = None, + text_colors: Optional[Iterable[BGRColorTuple]] = None, thickness: int = 2, text_scale=0.7, score_as_pct: bool = False, @@ -176,8 +179,10 @@ def draw_bounding_boxes(self, original_image = self.image.copy() if text_background_colors is None: text_background_colors = (None for _ in itertools.count(0)) - for bb, bg in zip(boxes, text_background_colors): - self.draw_box(bb, colour=colour, secondary_colour=secondary_colour, text_background_color=bg, thickness=thickness, score_as_pct=score_as_pct, show_score_in_label=show_score_in_label, + if text_colors is None: + text_colors = (None for _ in itertools.count(0)) + for bb, bg, tc in zip(boxes, text_background_colors, text_colors): + self.draw_box(bb, colour=colour, text_color=tc, secondary_colour=secondary_colour, text_background_color=bg, thickness=thickness, score_as_pct=score_as_pct, show_score_in_label=show_score_in_label, include_labels=include_labels, text_scale=text_scale) if include_inset: self.draw_corner_inset( diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index b16e0643..3c541206 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -843,6 +843,9 @@ def zoom_by(self, relative_zoom: float, invariant_display_xy: Tuple[float, float result = result.adjust_pan_to_boundary() return result + def zoom_to_pixel(self, pixel_xy: Tuple[int, int], zoom_level: float) -> 'ImageViewInfo': + return replace(self, center_pixel_xy=pixel_xy, zoom_level=zoom_level) + def adjust_pan_to_boundary(self) -> 'ImageViewInfo': display_edge_xy = np.asarray(self._get_display_midpoint_xy()) pixel_edge_xy = display_edge_xy / self.zoom_level From e8cac6180c50cd2a3216e6d69c1de4a4c8699f29 Mon Sep 17 00:00:00 2001 From: peter Date: Mon, 5 Jun 2023 22:56:12 -0700 Subject: [PATCH 062/107] schtuff --- artemis/experiments/experiment_record_view.py | 14 ++++--- artemis/image_processing/image_builder.py | 32 +++++++++++---- artemis/image_processing/image_utils.py | 2 +- .../image_processing/livestream_recorder.py | 4 +- artemis/image_processing/video_reader.py | 22 ++++++++-- artemis/plotting/easy_window.py | 41 ++++++++++++++++++- artemis/remote/virtualenv.py | 3 +- 7 files changed, 96 insertions(+), 22 deletions(-) diff --git a/artemis/experiments/experiment_record_view.py b/artemis/experiments/experiment_record_view.py index 9bad1052..58f4ec86 100644 --- a/artemis/experiments/experiment_record_view.py +++ b/artemis/experiments/experiment_record_view.py @@ -6,6 +6,8 @@ from six import string_types from tabulate import tabulate import numpy as np + +import video_scanner.ui.camera_livestream_setup from artemis.experiments.experiment_record import NoSavedResultError, ExpInfoFields, ExperimentRecord, \ load_experiment_record, is_matplotlib_imported, UnPicklableArg from artemis.general.display import deepstr, truncate_string, hold_numpy_printoptions, side_by_side, \ @@ -552,19 +554,19 @@ def show_figure(ix): nonlocals.this_fig = plt.gcf() def changefig(keyevent): - if keyevent.key=='right': + if video_scanner.ui.camera_livestream_setup.key == 'right': nonlocals.figno = (nonlocals.figno+1)%len(fig_locs) - elif keyevent.key=='left': + elif video_scanner.ui.camera_livestream_setup.key == 'left': nonlocals.figno = (nonlocals.figno-1)%len(fig_locs) - elif keyevent.key=='up': + elif video_scanner.ui.camera_livestream_setup.key == 'up': nonlocals.figno = (nonlocals.figno-10)%len(fig_locs) - elif keyevent.key=='down': + elif video_scanner.ui.camera_livestream_setup.key == 'down': nonlocals.figno = (nonlocals.figno+10)%len(fig_locs) - elif keyevent.key==' ': + elif video_scanner.ui.camera_livestream_setup.key == ' ': nonlocals.figno = queryfig() else: - print("No handler for key: {}. Changing Nothing".format(keyevent.key)) + print("No handler for key: {}. Changing Nothing".format(video_scanner.ui.camera_livestream_setup.key)) show_figure(nonlocals.figno) def queryfig(): diff --git a/artemis/image_processing/image_builder.py b/artemis/image_processing/image_builder.py index 97bce100..cffe6dcf 100644 --- a/artemis/image_processing/image_builder.py +++ b/artemis/image_processing/image_builder.py @@ -126,7 +126,9 @@ def draw_box(self, box: BoundingBox | RelativeBoundingBox, colour: BGRColorTuple text_background_color: Optional[BGRColorTuple] = None, text_color: Optional[BGRColorTuple] = None, text_scale = 0.7, + label: Optional[str] = None, thickness: int = 1, box_id: Optional[int] = None, + as_circle: bool = False, include_labels = True, show_score_in_label: bool = True, score_as_pct: bool = False) -> 'ImageBuilder': if text_color is None: text_color = colour @@ -135,15 +137,30 @@ def draw_box(self, box: BoundingBox | RelativeBoundingBox, colour: BGRColorTuple # xmin, xmax, ymin, ymax = xx_yy_box jmin, imin = self._xy_to_ji((box.x_min, box.y_min)) jmax, imax = self._xy_to_ji((box.x_max, box.y_max)) - cv2.rectangle(self.image, pt1=(jmin, imin), pt2=(jmax, imax), color=colour, thickness=thickness) - if secondary_colour is not None: - cv2.rectangle(self.image, pt1=(jmin-thickness, imin-thickness), pt2=(jmax+thickness, imax+thickness), color=secondary_colour, thickness=thickness) + if as_circle: + imean, jmean = box.to_ij() + cv2.circle(self.image, center=(jmean, imean), radius=round((jmax-jmin)/2), color=colour, thickness=thickness) + if secondary_colour is not None: + cv2.circle(self.image, center=(jmean, imean), radius=round((jmax-jmin)/2)+thickness, color=secondary_colour, thickness=thickness) + else: + cv2.rectangle(self.image, pt1=(jmin, imin), pt2=(jmax, imax), color=colour, thickness=thickness) + if secondary_colour is not None: + cv2.rectangle(self.image, pt1=(jmin-thickness, imin-thickness), pt2=(jmax+thickness, imax+thickness), color=secondary_colour, thickness=thickness) # if box.label or box_id is not None: - label = ','.join(str(i) for i in [box_id, box.label, None if not show_score_in_label else f"{box.score:.0%}" if score_as_pct else f"{box.score:.2f}"] if i is not None) + if label is None: + label = ','.join(str(i) for i in [box_id, box.label, None if not show_score_in_label else f"{box.score:.0%}" if score_as_pct else f"{box.score:.2f}"] if i is not None) if include_labels: - put_text_at(self.image, text=label, position_xy=(jmin, imin if box.y_min > box.y_max-box.y_min else imax), scale=text_scale*self.image.shape[1]/640, color=text_color, shadow_color = BGRColors.BLACK, background_color=text_background_color, thickness=thickness) + put_text_at(self.image, text=label, + position_xy=(jmin, imin if box.y_min > box.y_max-box.y_min else imax), + anchor_xy=(0.5, 0.) if as_circle else (0., 0.), + scale=text_scale*self.image.shape[1]/640, + color=text_color, + shadow_color = BGRColors.BLACK, + background_color=text_background_color, + thickness=thickness + ) # cv2.putText(self.image, text=label, org=(imin, jmin), fontFace=cv2.FONT_HERSHEY_PLAIN, fontScale=.7*self.image.shape[1]/640, # color=colour, thickness=thickness) @@ -174,6 +191,7 @@ def draw_bounding_boxes(self, show_score_in_label: bool = False, include_inset = False, inset_zoom_factor = 3, + as_circles: bool = False, ) -> 'ImageBuilder': original_image = self.image.copy() @@ -183,7 +201,7 @@ def draw_bounding_boxes(self, text_colors = (None for _ in itertools.count(0)) for bb, bg, tc in zip(boxes, text_background_colors, text_colors): self.draw_box(bb, colour=colour, text_color=tc, secondary_colour=secondary_colour, text_background_color=bg, thickness=thickness, score_as_pct=score_as_pct, show_score_in_label=show_score_in_label, - include_labels=include_labels, text_scale=text_scale) + include_labels=include_labels, text_scale=text_scale, as_circle=as_circles) if include_inset: self.draw_corner_inset( ImageRow(*(ImageBuilder(b.slice_image(original_image)).rescale(inset_zoom_factor).image for b in boxes)).render(), @@ -232,7 +250,7 @@ def draw_corner_inset(self, image: BGRImageArray, corner='br', border_color=BGRC self.image[vslice, hslice] = image return self - def draw_text_label(self, label: str, top_side: bool = True, rel_font_size: int = 0.05, color: BGRColorTuple = BGRColors.WHITE, background_color: Optional[BGRColorTuple] = None, thickness: int = 2) -> 'ImageBuilder': + def draw_text_label(self, label: str, top_side: bool = True, rel_font_size: float = 0.05, color: BGRColorTuple = BGRColors.WHITE, background_color: Optional[BGRColorTuple] = None, thickness: int = 2) -> 'ImageBuilder': text_image = ImageBuilder.from_text(text=label, text_displayer=TextDisplayer(text_color=color, background_color=background_color, scale=rel_font_size*self.image.shape[0]/20., thickness=thickness)).get_image() self.image = ImageCol(text_image, self.image).render() if top_side else ImageCol(self.image, text_image).render() return self diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index 3c541206..bad92670 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -145,7 +145,7 @@ def iter_images_from_video(path: str, max_size: Optional[Tuple[int, int]] = None for i, image_path in enumerate(path.split(';')): if frames_of_interest is not None and i not in frames_of_interest: continue - image = cv2.imread(image_path) + image = cv2.imread(os.path.expanduser(image_path)) if max_size is not None: image = fit_image_to_max_size(image, max_size) assert image is not None, f"Could not read any image from {image_path}" diff --git a/artemis/image_processing/livestream_recorder.py b/artemis/image_processing/livestream_recorder.py index 5f680636..4b782c67 100644 --- a/artemis/image_processing/livestream_recorder.py +++ b/artemis/image_processing/livestream_recorder.py @@ -53,7 +53,7 @@ def read_stream_and_save_to_disk( try: while cap.isOpened(): ret, frame = cap.read() - print(f"Got frame of shape {frame.shape if frame is not None else None} from agent") + # print(f"Got frame of shape {frame.shape if frame is not None else None} from agent") if ret: LIVESTREAM_LOGGER.debug(f"Process: Found frame of size {frame.shape}") if writing_video_path is not None and writer is None: @@ -75,7 +75,7 @@ def read_stream_and_save_to_disk( frame_ix=frame_ix, fps=cap.get(cv2.CAP_PROP_FPS)) if latest_frame_return_queue is not None: if not latest_frame_return_queue.full(): - print(f"Adding a frame {frame_info.frame_ix} to the queue, which has size {latest_frame_return_queue.qsize()}") + # print(f"Adding a frame {frame_info.frame_ix} to the queue, which has size {latest_frame_return_queue.qsize()}") latest_frame_return_queue.put(frame_info) frame_ix += 1 else: diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index 43b746b9..abce702e 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -87,6 +87,10 @@ def frame_index_to_nearest_frame(self, index: int) -> int: def frame_index_to_time(self, frame_ix: int) -> float: """ Get the time corresponding to the frame index """ + @abstractmethod + def get_progress_indicator(self, frame_ix) -> str: + """ Return a human-readable string that indicates progress at this frame. """ + @abstractmethod def time_indicator_to_nearest_frame(self, time_indicator: str) -> Optional[int]: """ Get the frame index nearest the time-indicator @@ -237,6 +241,14 @@ def get_metadata(self) -> VideoMetaData: ) return self._metadata + def get_progress_indicator(self, frame_ix) -> str: + seconds_into_video = frame_ix / self._fps if self._fps else 0 + total_frames = self.get_n_frames() + if total_frames is None: + return f"t={seconds_into_video:.2f}s, frame={frame_ix+1}" + else: + return f"t={seconds_into_video:.2f}s/{total_frames/self._fps:.2f}s, frame={frame_ix+1}/{total_frames}" + def get_n_frames(self) -> int: return self._stop - self._start @@ -388,6 +400,9 @@ def get_metadata(self) -> VideoMetaData: n_bytes=sum(os.path.getsize(path) for path in self._image_paths) ) + def get_progress_indicator(self, frame_ix) -> str: + return f"Frame {frame_ix+1}/{self.get_n_frames() - 1}: {os.path.split(self._image_paths[frame_ix])[-1]}" + def get_n_frames(self) -> int: return len(self._image_paths) @@ -434,13 +449,14 @@ def destroy(self): @dataclass class LiveVideoReader(IVideoReader): - stream_url: str + cap: cv2.VideoCapture frames_seen_so_far: int = 0 record: bool = True _iterator: Optional[Iterator[VideoFrameInfo]] = None _last_frame: Optional[VideoFrameInfo] = None + @classmethod def get_metadata(self) -> VideoMetaData: return VideoMetaData( duration=np.inf, @@ -469,12 +485,12 @@ def iter_frame_ixs(self) -> Iterator[int]: yield 0 def iter_frames(self) -> Iterator[VideoFrameInfo]: - cap = cv2.VideoCapture(self.stream_url) + # cap = cv2.VideoCapture(self.stream_url) t_start = time.monotonic() count = 0 while True: count += 1 - ret, frame = cap.read() + ret, frame = self.cap.read() if not ret: break elapsed = time.monotonic() - t_start diff --git a/artemis/plotting/easy_window.py b/artemis/plotting/easy_window.py index 28194c10..40fa8cdb 100644 --- a/artemis/plotting/easy_window.py +++ b/artemis/plotting/easy_window.py @@ -11,9 +11,10 @@ from attr import attrib, attrs from rpack import PackingImpossibleError -from artemis.general.custom_types import BGRColorTuple, BGRImageArray +from artemis.general.custom_types import BGRColorTuple, BGRImageArray, Array from artemis.plotting.cv_keys import Keys, cvkey_to_key -from artemis.image_processing.image_utils import BGRColors, DEFAULT_GAP_COLOR, create_gap_image, normalize_to_bgr_image, TextDisplayer +from artemis.image_processing.image_utils import BGRColors, DEFAULT_GAP_COLOR, create_gap_image, normalize_to_bgr_image, \ + TextDisplayer, heatmap_to_greyscale_image from artemis.plotting.cv2_plotting import hold_alternate_show_func DEFAULT_WINDOW_NAME = 'Window' @@ -174,6 +175,42 @@ def put_text_at( cv2.putText(img=img, text=text, org=(px, py), fontFace=font, fontScale=scale, color=color, thickness=thickness, bottomLeftOrigin=False) +def draw_matrix( + matrix: Array['H,W', float], + approx_size_wh: Tuple[float, float] = (400, 400), + text_color = BGRColors.GREEN, + row_headers: Optional[Sequence[str]] = None, + col_headers: Optional[Sequence[str]] = None, + header_pad: int = 100 + ) -> BGRImageArray: + + w, h = approx_size_wh + if matrix.size==0: + blank = np.zeros((h, w, 3), dtype=np.uint8) + put_text_at(blank, "No data", position_xy=(w//2, h//2), anchor_xy=(0.5, 0.5), scale=1, thickness=1, color=text_color, shadow_color=(0, 0, 0)) + return blank + + + heatmap = cv2.resize(matrix, (w, h), interpolation=cv2.INTER_NEAREST) + + rowpad = 0 if row_headers is None else header_pad + colpad = 0 if col_headers is None else header_pad + + heatmap = np.pad(heatmap, ((rowpad, 0), (colpad, 0))) + + img = heatmap_to_greyscale_image(heatmap, assume_zero_center=True, assume_zero_min=False) + for i, j in np.ndindex(matrix.shape): + put_text_at(img, f"{matrix[i, j]:.2f}", position_xy=(rowpad+(j+.5)*w/matrix.shape[1], colpad+(i+.5)*h/matrix.shape[0]), anchor_xy=(0.5, 0.5), scale=1.5, thickness=1, color=text_color, shadow_color=(0, 0, 0)) + # cv2.circle(img, (int(i*w/matrix.shape[1]), int(j*h/matrix.shape[0])), 3, (255, 255, 255), -1) + if row_headers is not None: + for i, header in enumerate(row_headers): + put_text_at(img, str(header), position_xy=(rowpad//2, colpad+(i+.5)*h/matrix.shape[0]), anchor_xy=(0.5, 0.5), scale=1.5, thickness=1, color=text_color, shadow_color=(0, 0, 0)) + if col_headers is not None: + for j, header in enumerate(col_headers): + put_text_at(img, str(header), position_xy=(rowpad+(j+.5)*w/matrix.shape[1], colpad//2), anchor_xy=(0.5, 0.5), scale=1.5, thickness=1, color=text_color, shadow_color=(0, 0, 0)) + return img + + def draw_image_to_region_inplace( # Assign an image to a region in a parent image parent_image, # type: array(WP,HP,...)[uint8] # The parent image into which to draw inplace img, # type: array(W,H,...)[uint8] # The image to draw in the given region diff --git a/artemis/remote/virtualenv.py b/artemis/remote/virtualenv.py index c0cd46d0..91fdb1af 100644 --- a/artemis/remote/virtualenv.py +++ b/artemis/remote/virtualenv.py @@ -6,6 +6,7 @@ import pip from six.moves import input +import video_scanner.ui.camera_livestream_setup from artemis.fileman.config_files import get_config_value from artemis.config import get_artemis_config_value from artemis.remote.utils import get_ssh_connection @@ -84,7 +85,7 @@ def check_diff_local_remote_virtualenv(ip_address, auto_install=None, auto_upgra ''' print(("="*10 + " Checking remote virtualenv %s "%ip_address + "="*10)) remote_packages = get_remote_installed_packages(ip_address) - local_packages = {i.key: i.version for i in pip.get_installed_distributions(include_editables=False)} + local_packages = {video_scanner.ui.camera_livestream_setup.key: i.version for i in pip.get_installed_distributions(include_editables=False)} missing_packages = OrderedDict() different_versions = OrderedDict() for (local_key, local_version) in local_packages.items(): From f5da0dc117a6338c192d54b97dd50028bff4faa3 Mon Sep 17 00:00:00 2001 From: peter Date: Mon, 5 Jun 2023 23:10:28 -0700 Subject: [PATCH 063/107] local-changes --- artemis/image_processing/livestream_recorder.py | 15 ++++++++++----- artemis/remote/virtualenv.py | 8 ++++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/artemis/image_processing/livestream_recorder.py b/artemis/image_processing/livestream_recorder.py index 5f680636..7c62df49 100644 --- a/artemis/image_processing/livestream_recorder.py +++ b/artemis/image_processing/livestream_recorder.py @@ -11,8 +11,8 @@ from contextlib import contextmanager from functools import partial from multiprocessing import Queue -from typing import Optional - +from typing import Optional, Tuple +import time import cv2 import numpy as np from dataclasses import dataclass, field @@ -53,7 +53,7 @@ def read_stream_and_save_to_disk( try: while cap.isOpened(): ret, frame = cap.read() - print(f"Got frame of shape {frame.shape if frame is not None else None} from agent") + # print(f"Got frame of shape {frame.shape if frame is not None else None} from agent") if ret: LIVESTREAM_LOGGER.debug(f"Process: Found frame of size {frame.shape}") if writing_video_path is not None and writer is None: @@ -75,7 +75,7 @@ def read_stream_and_save_to_disk( frame_ix=frame_ix, fps=cap.get(cv2.CAP_PROP_FPS)) if latest_frame_return_queue is not None: if not latest_frame_return_queue.full(): - print(f"Adding a frame {frame_info.frame_ix} to the queue, which has size {latest_frame_return_queue.qsize()}") + # print(f"Adding a frame {frame_info.frame_ix} to the queue, which has size {latest_frame_return_queue.qsize()}") latest_frame_return_queue.put(frame_info) frame_ix += 1 else: @@ -113,6 +113,7 @@ class LiveStreamRecorderAgent: poison_pill_ack_queue: Optional["Queue[bool]"] = field(default_factory=lambda: Queue(maxsize=1)) latest_frame_return_queue: Optional["Queue[VideoFrameInfo]"] = field(default_factory=lambda: make_queue(maxsize=2)) _is_done: bool = False + _latest_time_and_frame: Optional[Tuple[float, VideoFrameInfo]] = None def launch(self): @@ -138,12 +139,16 @@ def get_last_frame(self) -> Optional[VideoFrameInfo]: if self._is_done: return None frame = self.latest_frame_return_queue.get_nowait() + self._latest_time_and_frame = time.monotonic(), frame if frame == LATEST_FRAME_DONE_INDICATER: self._is_done = True return None return frame except queue.Empty: - return None + if self._latest_time_and_frame is not None and time.monotonic() - self._latest_time_and_frame[0] < 1.0: + return self._latest_time_and_frame[1] + else: + return def get_last_frame_blocking(self, timeout: Optional[float] = None) -> VideoFrameInfo: return self.latest_frame_return_queue.get(timeout=timeout) diff --git a/artemis/remote/virtualenv.py b/artemis/remote/virtualenv.py index c0cd46d0..dcaeb036 100644 --- a/artemis/remote/virtualenv.py +++ b/artemis/remote/virtualenv.py @@ -13,19 +13,19 @@ def get_remote_installed_packages(ip_address): ''' - This method queries a remote ui_code installation about the installed packages. + This method queries a remote python installation about the installed packages. All necessary information is extracted from ~/.artemisrc :param address: Ip address of remote server :return: ''' - python_executable = get_artemis_config_value(section=ip_address, option="ui_code") + python_executable = get_artemis_config_value(section=ip_address, option="python") function = "%s -c 'import pip; import json; print json.dumps({i.key: i.version for i in pip.get_installed_distributions() })' "%python_executable ssh_conn = get_ssh_connection(ip_address) stdin , stdout, stderr = ssh_conn.exec_command(function) err = stderr.read() if err: - msg="Quering %s ui_code installation at %s sent a message on stderr. If you are confident that the error can be ignored, catch this RuntimeError" \ + msg="Quering %s python installation at %s sent a message on stderr. If you are confident that the error can be ignored, catch this RuntimeError" \ "accordingly. The error is: %s"%(ip_address, python_executable, err) raise RuntimeError(msg) @@ -46,7 +46,7 @@ def install_packages_on_remote_virtualenv(ip_address, packages): if len(packages) == 0: return print("installing/upgrading remote packages ...") - python_path = get_artemis_config_value(ip_address,"ui_code") + python_path = get_artemis_config_value(ip_address,"python") activate_path = os.path.join(os.path.dirname(python_path),"activate") # TODO: Make this work without the user using virtualenv activate_command = "source %s"%activate_path ssh_conn = get_ssh_connection(ip_address) From 97bc55690682bb9e18683cb97b0968fbfacec3ca Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 6 Jun 2023 16:05:31 -0700 Subject: [PATCH 064/107] frames start at 1 --- artemis/image_processing/image_builder.py | 6 +++--- artemis/image_processing/video_reader.py | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/artemis/image_processing/image_builder.py b/artemis/image_processing/image_builder.py index cffe6dcf..92a2e99d 100644 --- a/artemis/image_processing/image_builder.py +++ b/artemis/image_processing/image_builder.py @@ -137,8 +137,8 @@ def draw_box(self, box: BoundingBox | RelativeBoundingBox, colour: BGRColorTuple # xmin, xmax, ymin, ymax = xx_yy_box jmin, imin = self._xy_to_ji((box.x_min, box.y_min)) jmax, imax = self._xy_to_ji((box.x_max, box.y_max)) + imean, jmean = box.to_ij() if as_circle: - imean, jmean = box.to_ij() cv2.circle(self.image, center=(jmean, imean), radius=round((jmax-jmin)/2), color=colour, thickness=thickness) if secondary_colour is not None: cv2.circle(self.image, center=(jmean, imean), radius=round((jmax-jmin)/2)+thickness, color=secondary_colour, thickness=thickness) @@ -153,13 +153,13 @@ def draw_box(self, box: BoundingBox | RelativeBoundingBox, colour: BGRColorTuple if include_labels: put_text_at(self.image, text=label, - position_xy=(jmin, imin if box.y_min > box.y_max-box.y_min else imax), + position_xy=(jmean, imin if box.y_min > box.y_max-box.y_min else imax), anchor_xy=(0.5, 0.) if as_circle else (0., 0.), scale=text_scale*self.image.shape[1]/640, color=text_color, shadow_color = BGRColors.BLACK, background_color=text_background_color, - thickness=thickness + thickness=2 ) # cv2.putText(self.image, text=label, org=(imin, jmin), fontFace=cv2.FONT_HERSHEY_PLAIN, fontScale=.7*self.image.shape[1]/640, # color=colour, thickness=thickness) diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index abce702e..d41e95a1 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -96,6 +96,7 @@ def time_indicator_to_nearest_frame(self, time_indicator: str) -> Optional[int]: """ Get the frame index nearest the time-indicator e.g. "0:32.5" "32.5s", "53%", "975" (frame number) Returns None if the time_indicator is invalid + Note - frame-index in string is 1-based, output is 0-based, so "975" -> 974 """ @abstractmethod @@ -149,8 +150,8 @@ def lookup_frame_ix(t: float) -> int: elif time_indicator.endswith('%'): percent = float(time_indicator.rstrip('%')) return round(percent / 100 * n_frames) - elif all(c in '0123456789' for c in time_indicator): - return int(time_indicator) + elif time_indicator.isdigit(): + return max(0, int(time_indicator)-1) else: return None @@ -401,7 +402,7 @@ def get_metadata(self) -> VideoMetaData: ) def get_progress_indicator(self, frame_ix) -> str: - return f"Frame {frame_ix+1}/{self.get_n_frames() - 1}: {os.path.split(self._image_paths[frame_ix])[-1]}" + return f"Frame {frame_ix+1}/{self.get_n_frames()}: {os.path.split(self._image_paths[frame_ix])[-1]}" def get_n_frames(self) -> int: return len(self._image_paths) From 0268b649dac0926d2d0f453b077f88571d1027c3 Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 29 Jun 2023 14:59:54 -0700 Subject: [PATCH 065/107] video reader stuff --- artemis/general/debug_utils.py | 7 +++++-- artemis/image_processing/image_utils.py | 8 +++++--- artemis/image_processing/video_reader.py | 9 +++++---- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/artemis/general/debug_utils.py b/artemis/general/debug_utils.py index bbbf1692..26125fc7 100644 --- a/artemis/general/debug_utils.py +++ b/artemis/general/debug_utils.py @@ -2,23 +2,26 @@ from contextlib import contextmanager from time import monotonic from logging import Logger +from typing import Callable, Optional + PROFILE_LOG = Logger('easy_profile') PROFILE_DEPTH = 0 @contextmanager -def easy_profile(name: str, log_entry: bool = False, enable = True, time_unit='ms'): +def easy_profile(name: str, log_entry: bool = False, enable = True, time_unit='ms') -> Callable[[], Optional[float]]: if not enable: yield return global PROFILE_DEPTH tstart = monotonic() + elapsed = None try: if log_entry: PROFILE_LOG.warn(f"Starting block '{name}...") PROFILE_DEPTH += 1 - yield + yield lambda: elapsed finally: PROFILE_DEPTH -= 1 elapsed = monotonic()-tstart diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index bad92670..c42996c2 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -829,11 +829,13 @@ def zoom_out(self) -> 'ImageViewInfo': new_zoom = self._get_min_zoom() return replace(self, zoom_level=new_zoom).adjust_pan_to_boundary() - def zoom_by(self, relative_zoom: float, invariant_display_xy: Tuple[float, float], limit: bool = True) -> 'ImageViewInfo': + def zoom_by(self, relative_zoom: float, invariant_display_xy: Optional[Tuple[float, float]] = None, limit: bool = True) -> 'ImageViewInfo': new_zoom = max(self._get_min_zoom(), self.zoom_level * relative_zoom) if limit else self.zoom_level * relative_zoom - - invariant_display_xy = np.maximum(0, np.minimum(self._get_display_wh(), invariant_display_xy)) + if invariant_display_xy is None: + invariant_display_xy = self._get_display_midpoint_xy() + else: + invariant_display_xy = np.maximum(0, np.minimum(self._get_display_wh(), invariant_display_xy)) invariant_pixel_xy = self.display_xy_to_pixel_xy(display_xy=invariant_display_xy) coeff = (1 - 1 / relative_zoom) diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index d41e95a1..ab72438e 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -129,13 +129,15 @@ def time_indicator_to_nearest_frame(time_indicator: str, n_frames: int, fps: Opt e.g. "0:32.5" "32.5s", "53%", "975" (frame number) Returns None if the time_indicator is invalid """ - assert (fps is not None) != (frame_times is not None), "You must provide either fps or frame_times. Not both and not neither." + assert (fps is None) or (frame_times is None), "You must provide either fps or frame_times. Not both." - def lookup_frame_ix(t: float) -> int: + def lookup_frame_ix(t: float) -> Optional[int]: if frame_times is None: return round(t * fps) - else: + elif fps is not None: return np.searchsorted(frame_times, t, side='left') + else: + return None if time_indicator in ('s', 'start'): return 0 @@ -454,7 +456,6 @@ class LiveVideoReader(IVideoReader): frames_seen_so_far: int = 0 record: bool = True _iterator: Optional[Iterator[VideoFrameInfo]] = None - _last_frame: Optional[VideoFrameInfo] = None @classmethod From 79f409636429ef18acf9ba5dd3d2ba7c87bc5729 Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 4 Jul 2023 14:13:30 -0700 Subject: [PATCH 066/107] enable multiline text --- artemis/plotting/easy_window.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/artemis/plotting/easy_window.py b/artemis/plotting/easy_window.py index 40fa8cdb..14342352 100644 --- a/artemis/plotting/easy_window.py +++ b/artemis/plotting/easy_window.py @@ -157,22 +157,23 @@ def put_text_at( font=cv2.FONT_HERSHEY_PLAIN, shift_down_by_baseline: bool = False, ): - (twidth, theight), baseline = cv2.getTextSize(text, font, scale, thickness) - px, py = position_xy - if px < 0: - px = img.shape[1]+px - if py < 0: - py = img.shape[0]+py - ax, ay = anchor_xy - px = round(px - ax*twidth) - py = round(py - ay*theight) + (2*baseline if shift_down_by_baseline else 0) - - if background_color is not None: - pad = 4 - img[max(0, py-pad): py+theight+pad, max(0, px-pad): px+twidth+pad] = background_color - if shadow_color is not None: - cv2.putText(img=img, text=text, org=(px, py), fontFace=font, fontScale=scale, color=shadow_color, thickness=thickness + 3, bottomLeftOrigin=False) - cv2.putText(img=img, text=text, org=(px, py), fontFace=font, fontScale=scale, color=color, thickness=thickness, bottomLeftOrigin=False) + for i, line in enumerate(text.split('\n')): + (twidth, theight), baseline = cv2.getTextSize(line, font, scale, thickness) + px, py = position_xy + if px < 0: + px = img.shape[1]+px + if py < 0: + py = img.shape[0]+py + ax, ay = anchor_xy + px = round(px - ax*twidth) + py = round(py - ay*theight) + (2*baseline if shift_down_by_baseline else 0) + i*(theight+baseline) + + if background_color is not None: + pad = 4 + img[max(0, py-pad): py+theight+pad, max(0, px-pad): px+twidth+pad] = background_color + if shadow_color is not None: + cv2.putText(img=img, text=line, org=(px, py), fontFace=font, fontScale=scale, color=shadow_color, thickness=thickness + 3, bottomLeftOrigin=False) + cv2.putText(img=img, text=line, org=(px, py), fontFace=font, fontScale=scale, color=color, thickness=thickness, bottomLeftOrigin=False) def draw_matrix( From 63e08565ace6cef19bfced1d31fd320df402dd5e Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 4 Jul 2023 15:34:20 -0700 Subject: [PATCH 067/107] passthrough image writer --- artemis/image_processing/image_utils.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index c42996c2..70008247 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -247,6 +247,14 @@ def iter_passthrough_write_video(image_stream: Iterable[BGRImageArray], path: st print(f'Saved video to {path}') +def iter_passthrough_image_writer(image_stream: Iterable[BGRImageArray], path_function: Callable[[int], str] + ) -> Iterable[BGRImageArray]: + for i, img in enumerate(image_stream): + path = path_function(i) + cv2.imwrite(path, img) + yield img + + def fade_image(image: BGRImageArray, fade_level: float) -> BGRImageArray: return (image.astype(np.float) * fade_level).astype(np.uint8) From 4ae0c69232881e01f3bae8498a862f0fbd0df042 Mon Sep 17 00:00:00 2001 From: peter Date: Mon, 10 Jul 2023 15:12:32 -0700 Subject: [PATCH 068/107] max zoom --- artemis/image_processing/image_utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index 70008247..1790a14e 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -837,9 +837,11 @@ def zoom_out(self) -> 'ImageViewInfo': new_zoom = self._get_min_zoom() return replace(self, zoom_level=new_zoom).adjust_pan_to_boundary() - def zoom_by(self, relative_zoom: float, invariant_display_xy: Optional[Tuple[float, float]] = None, limit: bool = True) -> 'ImageViewInfo': + def zoom_by(self, relative_zoom: float, invariant_display_xy: Optional[Tuple[float, float]] = None, limit: bool = True, max_zoom: Optional[float] = None) -> 'ImageViewInfo': new_zoom = max(self._get_min_zoom(), self.zoom_level * relative_zoom) if limit else self.zoom_level * relative_zoom + if max_zoom is not None: + new_zoom = min(new_zoom, max_zoom) if invariant_display_xy is None: invariant_display_xy = self._get_display_midpoint_xy() else: From 2759f4536e97a37b114540b2f3be2fdde0cf6822 Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 11 Jul 2023 14:21:32 -0700 Subject: [PATCH 069/107] open and create parent --- artemis/fileman/file_utils.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/artemis/fileman/file_utils.py b/artemis/fileman/file_utils.py index 63ea19c9..c22bc5ee 100644 --- a/artemis/fileman/file_utils.py +++ b/artemis/fileman/file_utils.py @@ -1,6 +1,7 @@ import os import shutil import time +from contextlib import contextmanager from datetime import datetime from typing import Optional, Sequence, Mapping, Iterator @@ -55,6 +56,14 @@ def copy_creating_dir_if_needed(src_path: str, dest_path: str): shutil.copyfile(src_path, dest_path) +@contextmanager +def open_and_create_parent(path, mode='r'): + parent, _ = os.path.split(path) + os.makedirs(parent, exist_ok=True) + with open(path, mode) as f: + yield f + + def get_recursive_directory_contents_string(directory: str, indent_level=0, indent=' ', max_entries: Optional[int] = None) -> str: lines = [] this_indent = indent * indent_level From 283061eb8a86013b92334301eb1997c8231b6307 Mon Sep 17 00:00:00 2001 From: "peter.ed.oconnor@gmail.com" Date: Wed, 12 Jul 2023 11:58:04 -0700 Subject: [PATCH 070/107] oops --- artemis/image_processing/decord_or_bust.py | 109 +++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 artemis/image_processing/decord_or_bust.py diff --git a/artemis/image_processing/decord_or_bust.py b/artemis/image_processing/decord_or_bust.py new file mode 100644 index 00000000..acd4e2ee --- /dev/null +++ b/artemis/image_processing/decord_or_bust.py @@ -0,0 +1,109 @@ + + +""" +Make a swap-in replacement for the decord video reader. + + +Why? Because decord +- Hogs huge amounts of memory on larger videos +- Doesn't install with pyinstaller unless you do hacky stuff: + https://github.com/dmlc/decord/issues/253 +- Still gives errors on some users machines (Mac M1 air with older OS) even with the hacky stuff + (Note we use eva-decord which ostensibly supports M1 https://pypi.org/project/eva-decord/) +""" +import itertools +from abc import ABCMeta, abstractmethod +from typing import Tuple, Optional + +import av +import os +from artemis.general.custom_types import BGRImageArray +import cv2 + +from artemis.image_processing.image_utils import fit_image_to_max_size +from artemis.image_processing.video_frame import VideoFrameInfo + +class IDecorder(metaclass=ABCMeta): + + @abstractmethod + def __len__(self) -> int: + ... + + @abstractmethod + def __getitem__(self, item: int) -> BGRImageArray: + ... + + + + +class PyAvDecorder(IDecorder): + + def __init__(self, + path: str, # Path to the video + threshold_frames_to_scan: 30, # Number of frames that we're willing to scan into the future before seeking + ): + self._path = os.path.expanduser(path) + assert os.path.exists(self._path), f"Cannot find a video at {path}" + self._threshold_frames_to_scan = threshold_frames_to_scan + self.container = av.container.open(self._path) + # self.stream = self.container.streams.video[0] + + # self._cap = cv2.VideoCapture(path) + # self._frame_cap.get(cv2.CAP_PROP_FPS) + video_obj = self.container.streams.video[0] + self._fps = float(video_obj.guessed_rate) + + self._n_frames = video_obj.frames + # self._cached_last_frame: Optional[VideoFrameInfo] = None # Helps fix weird bug... see notes below + + def __len__(self): + return self._n_frames + + def _iter_frame_data(self): + for frame in self.container.decode(self.container.streams.video[0]): + yield frame + + def __getitem__(self, index: int) -> BGRImageArray: + """ + Request a frame of the video. If the requested frame is out of bounds, this will return the frame + on the closest edge. + """ + if self._is_destroyed: + raise Exception("This object has been explicitly destroyed.") + # print(f"Requesting frame {index}") + n_frames = len(self) + if index < 0: + index = n_frames + index + + if 0 <= index - self._next_index_to_be_read < self._threshold_frames_to_scan: + # Scan forward through current iterator until we hit frame + frame_data = None + for _ in range(self._next_index_to_be_read, index + 1): + try: + frame_data = next(self._iterator) + self._next_index_to_be_read += 1 + except StopIteration: + raise Exception(f"Could not get frame at index {index}, despite n_frames being {n_frames}") + assert frame_data is not None, "Did not read a frame - this should be impossible" + image = frame_data.to_rgb().to_ndarray(format='bgr24') + return image + else: + # Seek to this frame + max_seek_search = 200 # I have no idea what's up with this. 100 failed some time + stream = self.container.streams.video[0] + pts = int(index * stream.duration / stream.frames) + self.container.seek(pts, stream=stream) + self._iterator = self._iter_frame_data() + for j, f in enumerate(self._iterator): + if j > max_seek_search: + raise RuntimeError(f'Did not find target frame {index} within {max_seek_search} frames of seek') + if f.pts >= pts - 1: + self._iterator = itertools.chain([f], self._iterator) + break + self._next_index_to_be_read = index + return self.__getitem__(index) + + def destroy(self): + self._iterator = None + self._frame_cache = None + self._is_destroyed = True From 344009605e987951b4900b8cacedd205607316a5 Mon Sep 17 00:00:00 2001 From: peter Date: Wed, 12 Jul 2023 14:56:44 -0700 Subject: [PATCH 071/107] decorders --- artemis/general/utils_for_testing.py | 12 ++ artemis/image_processing/decord_or_bust.py | 109 ---------- artemis/image_processing/decorders.py | 240 +++++++++++++++++++++ artemis/image_processing/test_decorders.py | 53 +++++ artemis/image_processing/video_reader.py | 1 + 5 files changed, 306 insertions(+), 109 deletions(-) delete mode 100644 artemis/image_processing/decord_or_bust.py create mode 100644 artemis/image_processing/decorders.py create mode 100644 artemis/image_processing/test_decorders.py diff --git a/artemis/general/utils_for_testing.py b/artemis/general/utils_for_testing.py index f90ee182..ebecb537 100644 --- a/artemis/general/utils_for_testing.py +++ b/artemis/general/utils_for_testing.py @@ -6,6 +6,7 @@ import os import numpy as np +from artemis.fileman.local_dir import get_artemis_data_path from artemis.general.custom_types import MaskImageArray, Array @@ -109,3 +110,14 @@ def draw_gaussian(self, mean_xy: Tuple[float, float], std_xy: Tuple[float, float return self +def get_or_download_sample_video() -> str: + + url_path = 'https://github.com/petered/data/raw/master/images/dji_2022-11-16_16-47-48_0613.mp4' + local_path = get_artemis_data_path('test_data/sample_video.mp4', make_local_dir=True) + if not os.path.exists(local_path): + print(f"Downloading sample video to {local_path}") + import requests + r = requests.get(url_path, allow_redirects=True) + with open(local_path, 'wb') as f: + f.write(r.content) + return local_path diff --git a/artemis/image_processing/decord_or_bust.py b/artemis/image_processing/decord_or_bust.py deleted file mode 100644 index acd4e2ee..00000000 --- a/artemis/image_processing/decord_or_bust.py +++ /dev/null @@ -1,109 +0,0 @@ - - -""" -Make a swap-in replacement for the decord video reader. - - -Why? Because decord -- Hogs huge amounts of memory on larger videos -- Doesn't install with pyinstaller unless you do hacky stuff: - https://github.com/dmlc/decord/issues/253 -- Still gives errors on some users machines (Mac M1 air with older OS) even with the hacky stuff - (Note we use eva-decord which ostensibly supports M1 https://pypi.org/project/eva-decord/) -""" -import itertools -from abc import ABCMeta, abstractmethod -from typing import Tuple, Optional - -import av -import os -from artemis.general.custom_types import BGRImageArray -import cv2 - -from artemis.image_processing.image_utils import fit_image_to_max_size -from artemis.image_processing.video_frame import VideoFrameInfo - -class IDecorder(metaclass=ABCMeta): - - @abstractmethod - def __len__(self) -> int: - ... - - @abstractmethod - def __getitem__(self, item: int) -> BGRImageArray: - ... - - - - -class PyAvDecorder(IDecorder): - - def __init__(self, - path: str, # Path to the video - threshold_frames_to_scan: 30, # Number of frames that we're willing to scan into the future before seeking - ): - self._path = os.path.expanduser(path) - assert os.path.exists(self._path), f"Cannot find a video at {path}" - self._threshold_frames_to_scan = threshold_frames_to_scan - self.container = av.container.open(self._path) - # self.stream = self.container.streams.video[0] - - # self._cap = cv2.VideoCapture(path) - # self._frame_cap.get(cv2.CAP_PROP_FPS) - video_obj = self.container.streams.video[0] - self._fps = float(video_obj.guessed_rate) - - self._n_frames = video_obj.frames - # self._cached_last_frame: Optional[VideoFrameInfo] = None # Helps fix weird bug... see notes below - - def __len__(self): - return self._n_frames - - def _iter_frame_data(self): - for frame in self.container.decode(self.container.streams.video[0]): - yield frame - - def __getitem__(self, index: int) -> BGRImageArray: - """ - Request a frame of the video. If the requested frame is out of bounds, this will return the frame - on the closest edge. - """ - if self._is_destroyed: - raise Exception("This object has been explicitly destroyed.") - # print(f"Requesting frame {index}") - n_frames = len(self) - if index < 0: - index = n_frames + index - - if 0 <= index - self._next_index_to_be_read < self._threshold_frames_to_scan: - # Scan forward through current iterator until we hit frame - frame_data = None - for _ in range(self._next_index_to_be_read, index + 1): - try: - frame_data = next(self._iterator) - self._next_index_to_be_read += 1 - except StopIteration: - raise Exception(f"Could not get frame at index {index}, despite n_frames being {n_frames}") - assert frame_data is not None, "Did not read a frame - this should be impossible" - image = frame_data.to_rgb().to_ndarray(format='bgr24') - return image - else: - # Seek to this frame - max_seek_search = 200 # I have no idea what's up with this. 100 failed some time - stream = self.container.streams.video[0] - pts = int(index * stream.duration / stream.frames) - self.container.seek(pts, stream=stream) - self._iterator = self._iter_frame_data() - for j, f in enumerate(self._iterator): - if j > max_seek_search: - raise RuntimeError(f'Did not find target frame {index} within {max_seek_search} frames of seek') - if f.pts >= pts - 1: - self._iterator = itertools.chain([f], self._iterator) - break - self._next_index_to_be_read = index - return self.__getitem__(index) - - def destroy(self): - self._iterator = None - self._frame_cache = None - self._is_destroyed = True diff --git a/artemis/image_processing/decorders.py b/artemis/image_processing/decorders.py new file mode 100644 index 00000000..81d5748b --- /dev/null +++ b/artemis/image_processing/decorders.py @@ -0,0 +1,240 @@ + +""" +Make a swap-in replacement for the decord video reader. + + +Why? Because decord +- Hogs huge amounts of memory on larger videos +- Doesn't install with pyinstaller unless you do hacky stuff: + https://github.com/dmlc/decord/issues/253 +- Still gives errors on some users machines (Mac M1 air with older OS) even with the hacky stuff + (Note we use eva-decord which ostensibly supports M1 https://pypi.org/project/eva-decord/) +""" +import itertools +from abc import ABCMeta, abstractmethod +from typing import Tuple, Optional, Iterator + +import av +import os + +from attr import dataclass + +from artemis.general.custom_types import BGRImageArray +import cv2 + +from artemis.general.item_cache import CacheDict +from artemis.image_processing.image_utils import fit_image_to_max_size +from artemis.image_processing.video_frame import VideoFrameInfo + + +class IDecorder(metaclass=ABCMeta): + + @abstractmethod + def __len__(self) -> int: + """ Get the number of frames in the video. """ + ... + + @abstractmethod + def __getitem__(self, item: int) -> BGRImageArray: + """ Lookup a frame by index. Index should be in [-len(self), len(self)), otherwise an IndexError will be raised.""" + ... + + @abstractmethod + def get_avg_fps(self) -> float: + ... + + @abstractmethod + def get_frame_timestamp(self, frame_index: int) -> float: + """ Get the timestamp of a frame in seconds, relative to the start of the video. """ + ... + + def __iter__(self) -> Iterator[BGRImageArray]: + """ Iterate over frames in the video (you can override this to be more efficient). """ + return iter(self[i] for i in range(len(self))) + + +class FrameListDecorder(IDecorder): + + def __init__(self, + frames: Tuple[BGRImageArray, ...], + fps: float, + ): + self._frames = frames + self._fps = fps + + def __len__(self) -> int: + return len(self._frames) + + def __getitem__(self, item: int) -> BGRImageArray: + return self._frames[item] + + def get_avg_fps(self) -> float: + return self._fps + + def get_frame_timestamp(self, frame_index: int) -> float: + return frame_index/self._fps + + +class PyAvDecorder(IDecorder): + + def __init__(self, + path: str, # Path to the video + threshold_frames_to_scan: int = 30, # Number of frames that we're willing to scan into the future before seeking + iter_with_opencv: bool = True, # More efficient with same result + ): + self._path = os.path.expanduser(path) + assert os.path.exists(self._path), f"Cannot find a video at {path}" + self._threshold_frames_to_scan = threshold_frames_to_scan + self.container = av.container.open(self._path) + # self.stream = self.container.streams.video[0] + self._is_destroyed = False # Forget why we want this... memory leak? + # self._cap = cv2.VideoCapture(path) + # self._frame_cap.get(cv2.CAP_PROP_FPS) + video_obj = self.container.streams.video[0] + self._fps = float(video_obj.guessed_rate) + self._next_index_to_be_read = 0 + self._iter_with_opencv = iter_with_opencv + + self._n_frames = video_obj.frames + self._iterator = self._iter_frame_data() + # self._cached_last_frame: Optional[VideoFrameInfo] = None # Helps fix weird bug... see notes below + + def __len__(self): + return self._n_frames + + def _iter_frame_data(self): + for frame in self.container.decode(self.container.streams.video[0]): + yield frame + + def __getitem__(self, index: int) -> BGRImageArray: + """ + Request a frame of the video. If the requested frame is out of bounds, this will return the frame + on the closest edge. + """ + if self._is_destroyed: + raise Exception("This object has been explicitly destroyed.") + # print(f"Requesting frame {index}") + n_frames = len(self) + if index < 0: + index = n_frames + index + if index < 0: + raise IndexError(f"Index {index - n_frames} is out of bounds for video with {n_frames} frames") + + if 0 <= index - self._next_index_to_be_read < self._threshold_frames_to_scan: + # Scan forward through current iterator until we hit frame + frame_data = None + for _ in range(self._next_index_to_be_read, index + 1): + try: + frame_data = next(self._iterator) + self._next_index_to_be_read += 1 + except StopIteration: + if index < n_frames: + raise Exception(f"Could not get frame at index {index}, despite n_frames being {n_frames}") + else: + raise IndexError(f"Index {index} is out of bounds for video with {n_frames} frames") + assert frame_data is not None, "Did not read a frame - this should be impossible" + image = frame_data.to_rgb().to_ndarray(format='bgr24') + return image + else: + # Seek to this frame + max_seek_search = 200 # I have no idea what's up with this. 100 failed some time + stream = self.container.streams.video[0] + pts = int(index * stream.duration / stream.frames) + self.container.seek(pts, stream=stream) + self._iterator = self._iter_frame_data() + for j, f in enumerate(self._iterator): + if j > max_seek_search: + raise RuntimeError(f'Did not find target frame {index} within {max_seek_search} frames of seek') + if f.pts >= pts - 1: + self._iterator = itertools.chain([f], self._iterator) + break + self._next_index_to_be_read = index + return self.__getitem__(index) + + def get_avg_fps(self) -> float: + return self._fps + + def get_frame_timestamp(self, frame_index: int) -> float: + return frame_index / self._fps # Todo: This is not quite right, but it's close enough for now + + def __iter__(self) -> Iterator[BGRImageArray]: + if self._iter_with_opencv: + cap = cv2.VideoCapture(self._path) + while True: + ret, frame = cap.read() + if not ret: + break + yield frame + cap.release() + else: + self.container.seek(0) + for frame in self._iterator: + yield frame.to_rgb().to_ndarray(format='bgr24') + + def destroy(self): + self._iterator = None + self._frame_cache = None + self._is_destroyed = True + +try: + import decord +except: + DecordDecorder = None +else: + class DecordDecorder(decord.VideoReader, IDecorder): + + def __getitem__(self, item): + return super().__getitem__(item).asnumpy()[:, :, ::-1] + + +class CachedDecorder(IDecorder): + + def __init__(self, + decorder: IDecorder, + buffer_size_bytes=1024 ** 3, + max_size_xy: Optional[Tuple[int, int]] = None, + ): + self._decorder = decorder + self._frame_cache: CacheDict[int, BGRImageArray] = CacheDict(buffer_size_bytes=buffer_size_bytes, always_allow_one_item=True) + self._max_size_xy = max_size_xy + + def __len__(self): + return len(self._decorder) + + def __getitem__(self, index: int) -> BGRImageArray: + if index in self._frame_cache: + return self._frame_cache[index] + else: + image = self._decorder[index] + if self._max_size_xy is not None: + image = fit_image_to_max_size(image, self._max_size_xy) + self._frame_cache[index] = image + return image + + def get_avg_fps(self) -> float: + return self._decorder.get_avg_fps() + + def get_frame_timestamp(self, frame_index: int) -> float: + return self._decorder.get_frame_timestamp(frame_index) + + +def robustly_get_decorder( + path: str, + prefer_decord: bool = True, + use_cache: bool = True, + buffer_size_bytes: int = 1024 ** 3, + max_size_xy: Optional[Tuple[int, int]] = None, + + ) -> IDecorder: + """ + Get a decorder for a video. If decord is installed, use that, otherwise use pyav. + """ + if prefer_decord and DecordDecorder is not None: + decorder = DecordDecorder(path) + else: + decorder = PyAvDecorder(path) + + if use_cache: + decorder = CachedDecorder(decorder, buffer_size_bytes=buffer_size_bytes, max_size_xy=max_size_xy) + + return decorder diff --git a/artemis/image_processing/test_decorders.py b/artemis/image_processing/test_decorders.py new file mode 100644 index 00000000..93a504ea --- /dev/null +++ b/artemis/image_processing/test_decorders.py @@ -0,0 +1,53 @@ +import cv2 +import numpy as np +import pytest + +from artemis.general.should_be_builtins import all_equal +from artemis.general.utils_for_testing import get_or_download_sample_video, hold_tempdir +from artemis.image_processing.decorders import PyAvDecorder, DecordDecorder, FrameListDecorder, robustly_get_decorder + + +def iter_frames_with_cv(path): + cap = cv2.VideoCapture(path) + while True: + ret, frame = cap.read() + if not ret: + break + yield frame + + +def test_pyav_decorder(): + vid_path = get_or_download_sample_video() + + decorders = { + "decord": DecordDecorder(vid_path), + "cv": FrameListDecorder(tuple(iter_frames_with_cv(vid_path)), fps=30), + "pyav": PyAvDecorder(vid_path), + "cached_pyav": robustly_get_decorder(vid_path, prefer_decord=False, use_cache=True), + "cached_decord": robustly_get_decorder(vid_path, prefer_decord=True, use_cache=True) + } + base_decorder = decorders["cv"] + assert all_equal(167 == len(decorder) for decorder in decorders.values()), f"Different number of frames: {[len(decorder) for decorder in decorders.values()]}" + frame_ixs_to_test = [5, 20, 70, 30, 0, -2, 155, 30] + for frame_ix in frame_ixs_to_test: + base_frame = base_decorder[frame_ix] + for name, decorder in decorders.items(): + frame = decorder[frame_ix] + is_equal_to_base = np.array_equal(base_frame, frame) + is_close_to_base = abs(base_frame.astype(float)-frame.astype(float)).mean() < 1 + assert is_equal_to_base or is_close_to_base, f"Frame {frame_ix} is different between {name} and cv" + print(f"Frame {frame_ix} from {name} is {'close' if is_close_to_base else 'equal'}") + print(f"Frame {frame_ix} passed") + + for name, decorder in decorders.items(): + print(f"Testing {name} for index errors") + with pytest.raises(IndexError): + decorder[167] + with pytest.raises(IndexError): + decorder[999] + with pytest.raises(IndexError): + decorder[-168] + + +if __name__ == '__main__': + test_pyav_decorder() diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index ab72438e..cd4f55ee 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -183,6 +183,7 @@ class VideoReader(IVideoReader): https://github.com/opencv/opencv/issues/9053 We use "av": conda install av -c conda-forge """ + # TODO: Remove, and replace with something that uses an IDecorder instead def __init__(self, path: str, From f8ea9619b320013003fb7869b25aa305bbb6cba1 Mon Sep 17 00:00:00 2001 From: peter Date: Wed, 12 Jul 2023 15:11:39 -0700 Subject: [PATCH 072/107] remove unneded imports --- artemis/image_processing/decorders.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/artemis/image_processing/decorders.py b/artemis/image_processing/decorders.py index 81d5748b..08cb1e8d 100644 --- a/artemis/image_processing/decorders.py +++ b/artemis/image_processing/decorders.py @@ -11,20 +11,16 @@ (Note we use eva-decord which ostensibly supports M1 https://pypi.org/project/eva-decord/) """ import itertools +import os from abc import ABCMeta, abstractmethod from typing import Tuple, Optional, Iterator import av -import os - -from attr import dataclass - -from artemis.general.custom_types import BGRImageArray import cv2 +from artemis.general.custom_types import BGRImageArray from artemis.general.item_cache import CacheDict from artemis.image_processing.image_utils import fit_image_to_max_size -from artemis.image_processing.video_frame import VideoFrameInfo class IDecorder(metaclass=ABCMeta): From 52d4a75ef0074be8ee85934999d4b2092921a2dc Mon Sep 17 00:00:00 2001 From: peter Date: Wed, 12 Jul 2023 15:27:39 -0700 Subject: [PATCH 073/107] indent --- artemis/image_processing/decorders.py | 28 +++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/artemis/image_processing/decorders.py b/artemis/image_processing/decorders.py index 08cb1e8d..5f88a735 100644 --- a/artemis/image_processing/decorders.py +++ b/artemis/image_processing/decorders.py @@ -51,24 +51,24 @@ def __iter__(self) -> Iterator[BGRImageArray]: class FrameListDecorder(IDecorder): - def __init__(self, - frames: Tuple[BGRImageArray, ...], - fps: float, - ): - self._frames = frames - self._fps = fps + def __init__(self, + frames: Tuple[BGRImageArray, ...], + fps: float, + ): + self._frames = frames + self._fps = fps - def __len__(self) -> int: - return len(self._frames) + def __len__(self) -> int: + return len(self._frames) - def __getitem__(self, item: int) -> BGRImageArray: - return self._frames[item] + def __getitem__(self, item: int) -> BGRImageArray: + return self._frames[item] - def get_avg_fps(self) -> float: - return self._fps + def get_avg_fps(self) -> float: + return self._fps - def get_frame_timestamp(self, frame_index: int) -> float: - return frame_index/self._fps + def get_frame_timestamp(self, frame_index: int) -> float: + return frame_index/self._fps class PyAvDecorder(IDecorder): From 6290e9ee69812b0ac864a02c4c5fa1211c31f6dd Mon Sep 17 00:00:00 2001 From: "peter.ed.oconnor@gmail.com" Date: Mon, 17 Jul 2023 15:05:10 -0700 Subject: [PATCH 074/107] filenotfound errors --- artemis/image_processing/video_reader.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index cd4f55ee..665845ca 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -195,7 +195,8 @@ def __init__(self, use_cache: bool = True): self._path = os.path.expanduser(path) - assert os.path.exists(self._path), f"Cannot find a video at {path}" + if not os.path.exists(self._path): + raise FileNotFoundError(f"Cannot find a video at {path}") self.container = av.container.open(self._path) # self.stream = self.container.streams.video[0] @@ -381,6 +382,8 @@ def __init__(self, image_paths: Sequence[str], fallback_fps: float = 1., reorder self._image_paths, self._image_times = get_time_ordered_image_paths(image_paths, fallback_fps) else: self._image_times = [i / fallback_fps for i in range(len(image_paths))] + if not os.path.exists(self._image_paths[0]): + raise FileNotFoundError(f"Image not found: {self._image_paths[0]}") def get_sorted_paths(self) -> Sequence[str]: return self._image_paths From 8c3060442ffc5c8141d3aedbcab2045fc4b3fac9 Mon Sep 17 00:00:00 2001 From: peter Date: Sat, 29 Jul 2023 12:59:33 -0700 Subject: [PATCH 075/107] decorders --- artemis/image_processing/decorders.py | 5 +- artemis/image_processing/image_utils.py | 5 +- artemis/image_processing/video_reader.py | 113 ++++++++++++++++++----- 3 files changed, 99 insertions(+), 24 deletions(-) diff --git a/artemis/image_processing/decorders.py b/artemis/image_processing/decorders.py index 5f88a735..796d1d5a 100644 --- a/artemis/image_processing/decorders.py +++ b/artemis/image_processing/decorders.py @@ -49,6 +49,9 @@ def __iter__(self) -> Iterator[BGRImageArray]: return iter(self[i] for i in range(len(self))) + + + class FrameListDecorder(IDecorder): def __init__(self, @@ -79,7 +82,7 @@ def __init__(self, iter_with_opencv: bool = True, # More efficient with same result ): self._path = os.path.expanduser(path) - assert os.path.exists(self._path), f"Cannot find a video at {path}" + assert os.path.exists(self._path), f"Cannot find a video at '{path}'" self._threshold_frames_to_scan = threshold_frames_to_scan self.container = av.container.open(self._path) # self.stream = self.container.streams.video[0] diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index 1790a14e..cced648c 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -598,9 +598,10 @@ def from_absolute_bbox(cls, bbox: BoundingBox, img_size_xy: Tuple[int, int], cli y_min=max(0., bbox.y_min / h), y_max=min(1., bbox.y_max / h), score=bbox.score, label=bbox.label) else: + print(f"Converting absolute bbox {bbox} to relative bbox without clipping.") return RelativeBoundingBox(x_min=bbox.x_min / w, x_max=bbox.x_max / w, y_min=bbox.y_min / h, y_max=bbox.y_max / h, - score=bbox.score, label=bbox.label) + score=bbox.score, label=bbox.label,) def to_xxyy(self): return self.x_min, self.x_max, self.y_min, self.y_max @@ -876,7 +877,7 @@ def pan_by_display_relshift(self, display_rel_xy: Tuple[float, float], limit: bo return self.pan_by_pixel_shift(pixel_shift_xy=pixel_shift_xy, limit=limit) def pan_by_display_shift(self, display_shift_xy: Tuple[float, float], limit: bool = True) -> 'ImageViewInfo': - pixel_shift_xy = np.asarray(display_shift_xy) * self.zoom_level + pixel_shift_xy = np.asarray(display_shift_xy) / self.zoom_level return self.pan_by_pixel_shift(pixel_shift_xy=pixel_shift_xy, limit=limit) def display_xy_to_pixel_xy(self, display_xy: Array["N,2", float], limit: bool = True) -> Array["N,2", float]: diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index 665845ca..d760b10f 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -1,11 +1,12 @@ import datetime import itertools import os +import threading import time from _py_abc import ABCMeta from abc import abstractmethod from dataclasses import dataclass -from typing import Tuple, Optional, Iterator, Sequence +from typing import Tuple, Optional, Iterator, Sequence, Callable import av import cv2 import exif @@ -17,6 +18,9 @@ from artemis.general.parsing import parse_time_delta_str_to_sec from artemis.image_processing.livestream_recorder import LiveStreamRecorderAgent from artemis.image_processing.video_frame import VideoFrameInfo +from threading import Lock + +from video_scanner.general_utils.srt_files import read_image_geodata_or_none @dataclass @@ -27,6 +31,10 @@ class VideoMetaData: n_bytes: int size_xy: Tuple[int, int] + @classmethod + def from_null(cls): + return cls(duration=float('inf'), n_frames=-1, fps=float('inf'), n_bytes=-1, size_xy=(0, 0)) + def get_duration_string(self) -> str: return str(datetime.timedelta(seconds=int(self.duration))) if self.duration != float('inf') else '∞' @@ -123,6 +131,10 @@ def request_frame(self, index: int) -> VideoFrameInfo: def destroy(self): """ Destroy the video reader (prevents memory leaks) """ + def is_live(self) -> bool: + """ Return True if this this can grow in size as it is read. False if it is a fixed size. """ + return False + def time_indicator_to_nearest_frame(time_indicator: str, n_frames: int, fps: Optional[float] = None, frame_times: Optional[Sequence[float]] = None) -> Optional[int]: """ Get the frame index nearest the time-indicator @@ -374,16 +386,37 @@ def get_time_ordered_image_paths(image_paths: Sequence[str], fallback_fps: float @dataclass class ImageSequenceReader(IVideoReader): - """ Reads through a seqence of images as if they were a videos.""" + """ Reads through a seqence of images as if they were a video.""" - def __init__(self, image_paths: Sequence[str], fallback_fps: float = 1., reorder = False): + def __init__(self, + image_paths: Sequence[str], + fallback_fps: float = 1., + reorder = False, + new_file_checker: Optional[Callable[[], Sequence[str]]] = None, + cache_size: int = 1 + ): self._image_paths = image_paths if reorder: - self._image_paths, self._image_times = get_time_ordered_image_paths(image_paths, fallback_fps) + image_paths, self._image_times = get_time_ordered_image_paths(image_paths, fallback_fps) + self._image_paths = list(image_paths) else: self._image_times = [i / fallback_fps for i in range(len(image_paths))] - if not os.path.exists(self._image_paths[0]): - raise FileNotFoundError(f"Image not found: {self._image_paths[0]}") + self._new_file_checker = new_file_checker + self._fallback_fps = fallback_fps + self._cache = CacheDict(buffer_length=cache_size) + self._lock = threading.Lock() + self._geodata_cache = {} + + def check_and_add_new_files(self) -> int: + with self._lock: + if self._new_file_checker is None: + return 0 + new_files = self._new_file_checker() + for new_file in new_files: + if new_file not in self._image_paths: + self._image_paths.append(new_file) + self._image_times.append(read_image_time_or_none(new_file) or (len(self._image_paths)-1) / self._fallback_fps) + return len(new_files) def get_sorted_paths(self) -> Sequence[str]: return self._image_paths @@ -396,19 +429,29 @@ def get_metadata(self) -> VideoMetaData: elif hasattr(image_meta, 'image_width'): size_xy = image_meta.image_width, image_meta.image_height except: # Load it and find out - img = cv2.imread(self._image_paths[0]) - size_xy = img.shape[1], img.shape[0] + if self._image_paths: + img = cv2.imread(self._image_paths[0]) + size_xy = (img.shape[1], img.shape[0]) if img is not None else (-1, -1) + else: + size_xy = (-1, -1) + duration = self._image_times[-1] - self._image_times[0] if self._image_times else 0. return VideoMetaData( - duration=self._image_times[-1] - self._image_times[0], + duration=self._image_times[-1] - self._image_times[0] if self._image_times else 0., n_frames=len(self._image_paths), - fps=len(self._image_paths) / (self._image_times[-1] - self._image_times[0] if len(self._image_paths) > 1 else 1.), + fps=len(self._image_paths) /duration if duration > 0 else 0., size_xy=size_xy, n_bytes=sum(os.path.getsize(path) for path in self._image_paths) ) + def get_image_paths(self) -> Sequence[str]: + return self._image_paths + def get_progress_indicator(self, frame_ix) -> str: - return f"Frame {frame_ix+1}/{self.get_n_frames()}: {os.path.split(self._image_paths[frame_ix])[-1]}" + if self._image_paths: + return f"Frame {frame_ix+1}/{self.get_n_frames()}: {os.path.split(self._image_paths[frame_ix])[-1]}" + else: + return "No frames loaded yet" def get_n_frames(self) -> int: return len(self._image_paths) @@ -429,8 +472,19 @@ def iter_frame_ixs(self) -> Iterator[int]: return range(self.get_n_frames()) def iter_frames(self) -> Iterator[VideoFrameInfo]: - for i in self.iter_frame_ixs(): - yield self.request_frame(i) + if not self.is_live(): + for i in self.iter_frame_ixs(): + yield self.request_frame(i) + else: + i = 0 + while True: + self.check_and_add_new_files() + for j in range(i, self.get_n_frames()): + yield self.request_frame(j) + if not self.is_live(): + break + i = self.get_n_frames() + time.sleep(0.1) def cut(self, time_interval: TimeIntervalTuple = (None, None), frame_interval: Tuple[Optional[int], Optional[int]] = (None, None)) -> 'ImageSequenceReader': if time_interval[0] is not None: @@ -440,18 +494,35 @@ def cut(self, time_interval: TimeIntervalTuple = (None, None), frame_interval: T return ImageSequenceReader(self._image_paths[frame_interval[0]:frame_interval[1]]) def request_frame(self, index: int) -> VideoFrameInfo: - image = cv2.imread(self._image_paths[index]) - assert image is not None, f"Could not load image at path {self._image_paths[index]}" - return VideoFrameInfo( - image=image, - seconds_into_video=self._image_times[index], - frame_ix=index, - fps=self.get_metadata().fps - ) + with self._lock: + # image = cv2.imread(self._image_paths[index]) if index not in self._cache else self._cache[index] + if index in self._cache: + image = self._cache[index] + else: + image = cv2.imread(self._image_paths[index]) + self._cache[index] = image + assert image is not None, f"Could not load image at path {self._image_paths[index]}" + return VideoFrameInfo( + image=image, + seconds_into_video=self._image_times[index], + frame_ix=index, + fps=self.get_metadata().fps + ) + + def read_frame_geodata_or_none(self, frame_ix): + if frame_ix not in self._geodata_cache: + self._geodata_cache[frame_ix] = read_image_geodata_or_none(self._image_paths[frame_ix]) + return self._geodata_cache[frame_ix] + + def stop_live(self): + self._new_file_checker = None def destroy(self): pass + def is_live(self) -> bool: + return self._new_file_checker is not None + @dataclass class LiveVideoReader(IVideoReader): From 78c5033e302790f9e0bf7b16303ff8a2aed38e6a Mon Sep 17 00:00:00 2001 From: "peter.ed.oconnor@gmail.com" Date: Sun, 6 Aug 2023 17:28:41 -0700 Subject: [PATCH 076/107] fix metadata bug --- artemis/image_processing/video_reader.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index d760b10f..0c1af971 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -428,6 +428,8 @@ def get_metadata(self) -> VideoMetaData: size_xy = image_meta.pixel_x_dimension, image_meta.pixel_y_dimension elif hasattr(image_meta, 'image_width'): size_xy = image_meta.image_width, image_meta.image_height + else: + raise Exception("Cant read image size") except: # Load it and find out if self._image_paths: img = cv2.imread(self._image_paths[0]) From 93498f961c314f0197c92241c182f5bfadf405d3 Mon Sep 17 00:00:00 2001 From: peter Date: Mon, 21 Aug 2023 16:41:09 -0700 Subject: [PATCH 077/107] metafix-bad --- artemis/image_processing/image_builder.py | 19 ++++++++++++------- artemis/image_processing/video_reader.py | 4 ++++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/artemis/image_processing/image_builder.py b/artemis/image_processing/image_builder.py index 92a2e99d..b00f627e 100644 --- a/artemis/image_processing/image_builder.py +++ b/artemis/image_processing/image_builder.py @@ -208,16 +208,21 @@ def draw_bounding_boxes(self, corner='br', border_color=colour, secondary_border_color=secondary_colour, border_thickness=thickness) return self - def draw_border(self, color: BGRColorTuple, thickness: int = 2) -> 'ImageBuilder': + def draw_border(self, color: BGRColorTuple, thickness: int = 2, external: bool = False, sides='ltrb') -> 'ImageBuilder': # border_ixs = list(range(thickness))+list(range(-thickness, 0)) # self.image[border_ixs, border_ixs] = color - self.image[:thickness, :] = color - self.image[-thickness:, : ] = color - self.image[:, :thickness] = color - self.image[:, -thickness:] = color - - + l, t, r, b = ((s in sides)*thickness for s in 'ltrb') + + if external: + new_image = np.empty(shape=(self.image.shape[0]+t+b, self.image.shape[1]+l+r, self.image.shape[2]), dtype=np.uint8) + new_image[t:-b or None, l:-r or None] = self.image + self.image = new_image + h, w = self.image.shape[:2] + self.image[:t, :] = color + self.image[h-b:, :] = color + self.image[:, :l] = color + self.image[:, w-r:] = color return self # return self.draw_box(BoundingBox.from_ltrb(0, 0, self.image.shape[1]-1, self.image.shape[0]-1), thickness=thickness, colour=color, include_labels=False) diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index d760b10f..0138d511 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -428,6 +428,8 @@ def get_metadata(self) -> VideoMetaData: size_xy = image_meta.pixel_x_dimension, image_meta.pixel_y_dimension elif hasattr(image_meta, 'image_width'): size_xy = image_meta.image_width, image_meta.image_height + else: + size_xy = (-1, -1) except: # Load it and find out if self._image_paths: img = cv2.imread(self._image_paths[0]) @@ -499,6 +501,8 @@ def request_frame(self, index: int) -> VideoFrameInfo: if index in self._cache: image = self._cache[index] else: + if not os.path.exists(self._image_paths[index]): + raise FileNotFoundError(f"Could not find image at path {self._image_paths[index]}") image = cv2.imread(self._image_paths[index]) self._cache[index] = image assert image is not None, f"Could not load image at path {self._image_paths[index]}" From c59c6a0540d1fcc833847aeffa57c1d8b8119266 Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 22 Aug 2023 18:31:48 -0700 Subject: [PATCH 078/107] added size to mock cap --- artemis/general/utils_for_testing.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/artemis/general/utils_for_testing.py b/artemis/general/utils_for_testing.py index ebecb537..b5e9387f 100644 --- a/artemis/general/utils_for_testing.py +++ b/artemis/general/utils_for_testing.py @@ -4,11 +4,14 @@ from dataclasses import dataclass from typing import Sequence, Tuple, Optional, Callable import os + +import cv2 import numpy as np from artemis.fileman.local_dir import get_artemis_data_path from artemis.general.custom_types import MaskImageArray, Array - +from video_scanner.general_utils.threaded_cap import VideoLoopingCap +from video_scanner.utils import AssetVideos # ASSET_FOLDER_PATH = "~/projects/eagle_eyes_app/app/src/main/assets" @@ -47,6 +50,10 @@ def prepare_path_for_write(path: str, overwright_callback: Callable[[str], bool] return final_path +def load_mock_video_cap(size_xy: Optional[Tuple[int, int]] = None) -> cv2.VideoCapture: + return VideoLoopingCap(AssetVideos.FOREST_BLUES.path, size_xy=size_xy) + + @contextmanager def hold_tempdir(path_if_successful: Optional[str] = None): From 91f24b173187fde2782785b7e79e6982b50dd77b Mon Sep 17 00:00:00 2001 From: "peter.ed.oconnor@gmail.com" Date: Wed, 23 Aug 2023 14:34:20 -0700 Subject: [PATCH 079/107] allow failed delete on holddir --- artemis/general/utils_for_testing.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/artemis/general/utils_for_testing.py b/artemis/general/utils_for_testing.py index b5e9387f..22493245 100644 --- a/artemis/general/utils_for_testing.py +++ b/artemis/general/utils_for_testing.py @@ -66,8 +66,10 @@ def hold_tempdir(path_if_successful: Optional[str] = None): print(f"Wrote temp dir to {final_path}") finally: if os.path.exists(tempdir): - shutil.rmtree(tempdir) - + try: + shutil.rmtree(tempdir) + except PermissionError: + print(f"Could not delete temp dir {tempdir} - its ok, this can happen on windows") @contextmanager def hold_tempfile(ext = '', path_if_successful: Optional[str] = None): From 6213513a82aa4ad01ca88e6d0fc215f83c2208ef Mon Sep 17 00:00:00 2001 From: peter Date: Fri, 29 Sep 2023 16:24:21 -0700 Subject: [PATCH 080/107] updates for 0.4.0 --- artemis/general/utils_for_testing.py | 10 +- artemis/image_processing/image_utils.py | 19 ++- artemis/image_processing/media_metadata.py | 152 ++++++++++++++++++ .../image_processing/test_media_metadata.py | 1 + artemis/image_processing/video_frame.py | 34 ++++ artemis/image_processing/video_reader.py | 26 +-- 6 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 artemis/image_processing/media_metadata.py create mode 100644 artemis/image_processing/test_media_metadata.py diff --git a/artemis/general/utils_for_testing.py b/artemis/general/utils_for_testing.py index 22493245..85ae3e17 100644 --- a/artemis/general/utils_for_testing.py +++ b/artemis/general/utils_for_testing.py @@ -10,8 +10,12 @@ from artemis.fileman.local_dir import get_artemis_data_path from artemis.general.custom_types import MaskImageArray, Array +from video_scanner.app_utils.utils import AssetVideos +from video_scanner.detection_utils.basic_utils import is_media_path from video_scanner.general_utils.threaded_cap import VideoLoopingCap -from video_scanner.utils import AssetVideos + + +# from video_scanner.app_utils.utils import AssetVideos # ASSET_FOLDER_PATH = "~/projects/eagle_eyes_app/app/src/main/assets" @@ -88,6 +92,10 @@ def hold_tempfile(ext = '', path_if_successful: Optional[str] = None): os.remove(tempfilename) +def get_all_media_files_in_folder(folder: str) -> Sequence[str]: + return [os.path.join(folder, p) for p in os.listdir(folder) if is_media_path(p)] + + @dataclass class HeatmapBuilder: diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index cced648c..f8b83aee 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -227,7 +227,8 @@ def iter_images_from_video(path: str, max_size: Optional[Tuple[int, int]] = None frame_ix += 1 -def iter_passthrough_write_video(image_stream: Iterable[BGRImageArray], path: str, fps: float = 30.) -> Iterable[BGRImageArray]: +def iter_passthrough_write_video(image_stream: Iterable[BGRImageArray], path: str, fps: float = 30., assert_fixed_shape: bool = False + ) -> Iterable[BGRImageArray]: path = os.path.expanduser(path) dirs, _ = os.path.split(path) try: @@ -235,12 +236,18 @@ def iter_passthrough_write_video(image_stream: Iterable[BGRImageArray], path: st except OSError: pass cap = None - for img in image_stream: + shape: Optional[Tuple[int, int, ...]] = None + for i, img in enumerate(image_stream): + if shape is None: + shape = img.shape + elif assert_fixed_shape: + assert img.shape == shape, f"Shape changed from {shape} to {img.shape} at frame {i}" if cap is None: # cap = cv2.VideoWriter(path, fourcc=cv2.VideoWriter_fourcc('M', 'J', 'P', 'G'), fps=fps, frameSize=(img.shape[1], img.shape[0])) cap = cv2.VideoWriter(path, fourcc=cv2.VideoWriter_fourcc('H', '2', '6', '4'), fps=fps, frameSize=(img.shape[1], img.shape[0])) # Make it write high-quality video # cap = cv2.VideoWriter(path, fourcc=cv2.VideoWriter_fourcc('M', 'J', 'P', 'G'), fps=fps, frameSize=(img.shape[1], img.shape[0]), isColor=True) + # print(f'Writing frame {i+1} with shape {img.shape} to {path}') cap.write(img) yield img cap.release() @@ -286,9 +293,10 @@ def heatmap_to_greyscale_image(heatmap: HeatMapArray, assume_zero_min: bool = Fa def heatmap_to_color_image(heatmap: HeatMapArray, assume_zero_min: bool = True, assume_zero_center: bool = False, show_range=False, - upsample_factor: int = 1, additional_text: Optional[str] = None, text_scale=1. + upsample_factor: int = 1, additional_text: Optional[str] = None, text_scale=1., heat_range: Optional[Tuple[float, float]] = None ) -> BGRImageArray: - min_heat, max_heat = compute_heatmap_bounds(heatmap, assume_zero_min=assume_zero_min, assume_zero_center=assume_zero_center) + min_heat, max_heat = compute_heatmap_bounds(heatmap, assume_zero_min=assume_zero_min, assume_zero_center=assume_zero_center) \ + if heat_range is None else heat_range if heatmap.ndim == 2: heatmap = heatmap[:, :, None] img = np.zeros(heatmap.shape[:2] + (3,), dtype=np.uint8) @@ -386,6 +394,9 @@ def from_ltrb(cls, l, t, r, b, label: str = '', score: float = 1.) -> 'BaseBox': def from_ijhw(cls, i, j, h, w, label: str = '', score: float = 1.) -> 'BaseBox': return cls(x_min=j - w / 2, x_max=j + w / 2, y_min=i - h / 2, y_max=i + h / 2, label=label, score=score) + def to_ijhw(self) -> Tuple[float, float, float, float]: + return (self.y_min+self.y_max)/2, (self.x_min+self.x_max)/2, self.y_max-self.y_min, self.x_max-self.x_min + @classmethod def from_xywh(cls, x, y, w, h, label: str = '') -> 'BaseBox': return cls(x_min=x - w / 2, x_max=x + w / 2, y_min=y - h / 2, y_max=y + h / 2, label=label) diff --git a/artemis/image_processing/media_metadata.py b/artemis/image_processing/media_metadata.py new file mode 100644 index 00000000..7fa7e22e --- /dev/null +++ b/artemis/image_processing/media_metadata.py @@ -0,0 +1,152 @@ +from datetime import datetime +from typing import Optional + +from datetime import datetime, timedelta +from timezonefinder import TimezoneFinder +import pytz + +import exif +from pymediainfo import MediaInfo + +from artemis.image_processing.video_frame import FrameGeoData + + +def degrees_minutes_seconds_to_degrees(degrees: float, minutes: float, seconds: float) -> float: + return degrees + minutes/60 + seconds/3600 + + +def read_exif_data_from_path(path: str, max_bytes_to_read_for_exif: Optional[int]=None, + ) -> Optional[exif.Image]: + try: + exif_data = exif.Image(path) + except Exception as err: + print(f"Got error trying to read exif data from {path}: {err}") + return None + if not exif_data.has_exif: + exif_data = None + return exif_data + + +def is_daylight_saving(localized_datetime: datetime) -> bool: + tz = localized_datetime.tzinfo + return tz.dst(localized_datetime) != timedelta(0) + + +_TIMEZONE_FINDER = None + + +def get_timezone_finder_singleton() -> TimezoneFinder: + global _TIMEZONE_FINDER + if _TIMEZONE_FINDER is None: + _TIMEZONE_FINDER = TimezoneFinder() + return _TIMEZONE_FINDER + + +def get_utc_epoch( + lat: float, + lon: float, + local_timestamp_str: str, + local_timestamp_format: str='%Y-%m-%d %H:%M:%S', + requires_dst_correction: bool = False, + ) -> float: + # Initialize timezone finder and find the timezone + tf = get_timezone_finder_singleton() + tz_str = tf.timezone_at(lat=lat, lng=lon) + if tz_str is None: + raise ValueError("Could not determine timezone") + + # Parse the local timestamp string into a datetime object + local_tz = pytz.timezone(tz_str) + unlocalized_datetime = datetime.strptime(local_timestamp_str, local_timestamp_format) + + # Localize the timestamp to the local timezone + localized_datetime = local_tz.localize(unlocalized_datetime) + + if requires_dst_correction and is_daylight_saving(localized_datetime): + localized_datetime += timedelta(hours=1) + + return localized_datetime.timestamp() + + # # Convert the timestamp to UTC timezone + # utc_timestamp = local_timestamp.astimezone(pytz.utc) + # + # # Convert the UTC datetime object to epoch time + # epoch_timestamp = (utc_timestamp - datetime(1970, 1, 1, tzinfo=pytz.utc)).total_seconds() + # + # print(f"Shifted timestamp by {(epoch_timestamp - local_timestamp.timestamp())/3600} houres to get UTC time") + # + # return epoch_timestamp + +# # Example usage +# lat, lon = 40.7128, -74.0060 # New York City +# local_timestamp_str = "2023-09-22 12:34:56" +# epoch_timestamp = get_utc_epoch(lat, lon, local_timestamp_str) +# print("Epoch timestamp in UTC:", epoch_timestamp) + + + +class ExifParseError(Exception): + """ Raised """ + + +def convert_exif_to_geodata_or_none(exif_data: exif.Image, requires_dst_correction: bool = False) -> Optional[FrameGeoData]: + try: + gps_latitude = degrees_minutes_seconds_to_degrees(*exif_data.gps_latitude) + gps_longitude = degrees_minutes_seconds_to_degrees(*exif_data.gps_longitude) + if exif_data.gps_latitude_ref == 'S': + gps_latitude = -gps_latitude + if exif_data.gps_longitude_ref == 'W': + gps_longitude = -gps_longitude + except Exception as err: + print(f"Error parsing GPS data: {err}") + return None + try: + gps_altitude = exif_data.gps_altitude + except Exception as err: + print(f"Error parsing GPS altitude: {err}") + gps_altitude = None + + # Seems the images already have UTC time in them, so we don't need to do this + epoch_timestamp = get_utc_epoch(lat=gps_latitude, lon=gps_longitude, local_timestamp_str=exif_data.datetime, + local_timestamp_format='%Y:%m:%d %H:%M:%S', requires_dst_correction=requires_dst_correction) + epoch_time_us = int(epoch_timestamp * 1000000) + return FrameGeoData((gps_latitude, gps_longitude), epoch_time_us, altitude_from_sea=gps_altitude) + + +def read_image_geodata_or_none(image_path: str, requires_dst_correction: bool = False) -> Optional[FrameGeoData]: + """ Read the exif data from the image to extract lat, long, timestamp """ + exif_data = read_exif_data_from_path(image_path) + return convert_exif_to_geodata_or_none(exif_data, requires_dst_correction=requires_dst_correction) if exif_data else None + + + + + # + # with open(image_path, 'rb') as image_file: + # try: + # exif_data = exif.Image(image_file) + # except Exception as err: + # print(f"Error reading exif data from {image_path}: {err}") + # return None + # if exif_data.has_exif: + # try: + # gps_latitude = degrees_minutes_seconds_to_degrees(*exif_data.gps_latitude) + # gps_longitude = degrees_minutes_seconds_to_degrees(*exif_data.gps_longitude) + # if exif_data.gps_latitude_ref == 'S': + # gps_latitude = -gps_latitude + # if exif_data.gps_longitude_ref == 'W': + # gps_longitude = -gps_longitude + # except Exception as err: + # print(f"Error parsing GPS data from {image_path}: {err}") + # return None + # try: + # gps_altitude = exif_data.gps_altitude + # except Exception as err: + # print(f"Error parsing GPS altitude from {image_path}: {err}") + # gps_altitude = None + # # parse string like '2022:11:20 14:03:13' into datetime + # datetime_obj = datetime.strptime(exif_data.datetime, '%Y:%m:%d %H:%M:%S') + # epoch_time_us = int(datetime_obj.timestamp()*1000000) + # return FrameGeoData((gps_latitude, gps_longitude), epoch_time_us, altitude_from_sea=gps_altitude) + # return None + # diff --git a/artemis/image_processing/test_media_metadata.py b/artemis/image_processing/test_media_metadata.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/artemis/image_processing/test_media_metadata.py @@ -0,0 +1 @@ + diff --git a/artemis/image_processing/video_frame.py b/artemis/image_processing/video_frame.py index f7e18bfe..f82be337 100644 --- a/artemis/image_processing/video_frame.py +++ b/artemis/image_processing/video_frame.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from datetime import datetime from typing import Tuple, Optional from artemis.general.custom_types import BGRImageArray @@ -19,3 +20,36 @@ def get_progress_string(self, total_frames: Optional[int] = None) -> str: return f"t={self.seconds_into_video:.2f}s, frame={self.frame_ix}" else: return f"t={self.seconds_into_video:.2f}s/{total_frames/self.fps:.2f}s, frame={self.frame_ix}/{total_frames}" + + +@dataclass +class FrameGeoData: + lat_long: Optional[Tuple[float, float]] + epoch_time_us: int + altitude_from_home: Optional[float] = None + altitude_from_sea: Optional[float] = None + # def has_latlong(self) -> bool: # Hope you're not flying off the west coast of Africa + # return self.latitude != 0 and self.longitude != 0 + + def get_datetime(self) -> datetime: + return datetime.fromtimestamp(self.epoch_time_us/1000000) + + def get_timestamp(self) -> float: + return self.epoch_time_us/1e6 + + def get_time_str(self) -> str: + return self.get_datetime().strftime('%Y-%m-%d %H:%M:%S.%f') + + def get_latlng_str(self) -> str: + if self.lat_long is not None: + return f'{self.lat_long[0]:.5f}, {self.lat_long[1]:.5f}' + else: + return 'Unknown' + + def get_altitude_str(self) -> str: + return f"{self.altitude_from_home:.1f}m (home)" if self.altitude_from_home is not None \ + else f"{self.altitude_from_sea:.1f}m (sea)" if self.altitude_from_sea is not None \ + else "?m" + + def get_latlng_alt_str(self) -> str: + return self.get_latlng_str()+f", {self.get_altitude_str()}" diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index 4d0e64fc..edf07088 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -9,6 +9,8 @@ from typing import Tuple, Optional, Iterator, Sequence, Callable import av import cv2 +from more_itertools import first + import exif import numpy as np from artemis.general.custom_types import TimeIntervalTuple @@ -17,10 +19,8 @@ from artemis.general.item_cache import CacheDict from artemis.general.parsing import parse_time_delta_str_to_sec from artemis.image_processing.livestream_recorder import LiveStreamRecorderAgent -from artemis.image_processing.video_frame import VideoFrameInfo -from threading import Lock - -from video_scanner.general_utils.srt_files import read_image_geodata_or_none +from artemis.image_processing.video_frame import VideoFrameInfo, FrameGeoData +from artemis.image_processing.media_metadata import read_image_geodata_or_none @dataclass @@ -393,7 +393,8 @@ def __init__(self, fallback_fps: float = 1., reorder = False, new_file_checker: Optional[Callable[[], Sequence[str]]] = None, - cache_size: int = 1 + cache_size: int = 1, + geodata_reader: Callable[[str], FrameGeoData] = read_image_geodata_or_none ): self._image_paths = image_paths if reorder: @@ -406,6 +407,7 @@ def __init__(self, self._cache = CacheDict(buffer_length=cache_size) self._lock = threading.Lock() self._geodata_cache = {} + self._geodata_reader = geodata_reader def check_and_add_new_files(self) -> int: with self._lock: @@ -443,7 +445,7 @@ def get_metadata(self) -> VideoMetaData: n_frames=len(self._image_paths), fps=len(self._image_paths) /duration if duration > 0 else 0., size_xy=size_xy, - n_bytes=sum(os.path.getsize(path) for path in self._image_paths) + n_bytes=sum(os.path.getsize(path) if path else 0 for path in self._image_paths) ) def get_image_paths(self) -> Sequence[str]: @@ -501,11 +503,15 @@ def request_frame(self, index: int) -> VideoFrameInfo: if index in self._cache: image = self._cache[index] else: + if index >= len(self._image_paths): + index = len(self._image_paths) - 1 + if not self._image_paths[index]: + index = first((i for i in range(index, -1, -1) if self._image_paths[i]), default=0) if not os.path.exists(self._image_paths[index]): - raise FileNotFoundError(f"Could not find image at path {self._image_paths[index]}") + raise FileNotFoundError(f"Could not find image at path: '{self._image_paths[index]}'") image = cv2.imread(self._image_paths[index]) self._cache[index] = image - assert image is not None, f"Could not load image at path {self._image_paths[index]}" + assert image is not None, f"Could not load image at path: '{self._image_paths[index]}'" return VideoFrameInfo( image=image, seconds_into_video=self._image_times[index], @@ -513,9 +519,9 @@ def request_frame(self, index: int) -> VideoFrameInfo: fps=self.get_metadata().fps ) - def read_frame_geodata_or_none(self, frame_ix): + def read_frame_geodata_or_none(self, frame_ix: int) -> FrameGeoData: if frame_ix not in self._geodata_cache: - self._geodata_cache[frame_ix] = read_image_geodata_or_none(self._image_paths[frame_ix]) + self._geodata_cache[frame_ix] = self._geodata_reader(self._image_paths[frame_ix]) return self._geodata_cache[frame_ix] def stop_live(self): From 0601cbcced4f4087beeb3fb9b59a57aac91fd5b9 Mon Sep 17 00:00:00 2001 From: peter Date: Wed, 4 Oct 2023 11:06:20 -0700 Subject: [PATCH 081/107] fixed timestamp read --- artemis/general/utils_for_testing.py | 24 ---------------------- artemis/image_processing/media_metadata.py | 22 ++++++++++++-------- artemis/image_processing/test_decorders.py | 3 ++- 3 files changed, 15 insertions(+), 34 deletions(-) diff --git a/artemis/general/utils_for_testing.py b/artemis/general/utils_for_testing.py index 85ae3e17..976d9e49 100644 --- a/artemis/general/utils_for_testing.py +++ b/artemis/general/utils_for_testing.py @@ -5,14 +5,9 @@ from typing import Sequence, Tuple, Optional, Callable import os -import cv2 import numpy as np -from artemis.fileman.local_dir import get_artemis_data_path from artemis.general.custom_types import MaskImageArray, Array -from video_scanner.app_utils.utils import AssetVideos -from video_scanner.detection_utils.basic_utils import is_media_path -from video_scanner.general_utils.threaded_cap import VideoLoopingCap # from video_scanner.app_utils.utils import AssetVideos @@ -54,10 +49,6 @@ def prepare_path_for_write(path: str, overwright_callback: Callable[[str], bool] return final_path -def load_mock_video_cap(size_xy: Optional[Tuple[int, int]] = None) -> cv2.VideoCapture: - return VideoLoopingCap(AssetVideos.FOREST_BLUES.path, size_xy=size_xy) - - @contextmanager def hold_tempdir(path_if_successful: Optional[str] = None): @@ -92,10 +83,6 @@ def hold_tempfile(ext = '', path_if_successful: Optional[str] = None): os.remove(tempfilename) -def get_all_media_files_in_folder(folder: str) -> Sequence[str]: - return [os.path.join(folder, p) for p in os.listdir(folder) if is_media_path(p)] - - @dataclass class HeatmapBuilder: @@ -127,14 +114,3 @@ def draw_gaussian(self, mean_xy: Tuple[float, float], std_xy: Tuple[float, float return self -def get_or_download_sample_video() -> str: - - url_path = 'https://github.com/petered/data/raw/master/images/dji_2022-11-16_16-47-48_0613.mp4' - local_path = get_artemis_data_path('test_data/sample_video.mp4', make_local_dir=True) - if not os.path.exists(local_path): - print(f"Downloading sample video to {local_path}") - import requests - r = requests.get(url_path, allow_redirects=True) - with open(local_path, 'wb') as f: - f.write(r.content) - return local_path diff --git a/artemis/image_processing/media_metadata.py b/artemis/image_processing/media_metadata.py index 7fa7e22e..f2f47d4b 100644 --- a/artemis/image_processing/media_metadata.py +++ b/artemis/image_processing/media_metadata.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional +from typing import Optional, Tuple from datetime import datetime, timedelta from timezonefinder import TimezoneFinder @@ -43,18 +43,21 @@ def get_timezone_finder_singleton() -> TimezoneFinder: def get_utc_epoch( - lat: float, - lon: float, + lat_long: Optional[Tuple[float, float]], local_timestamp_str: str, local_timestamp_format: str='%Y-%m-%d %H:%M:%S', requires_dst_correction: bool = False, + fail_on_no_timezone: bool = False, ) -> float: + """ Tries its best to get a UTC timestamp from the kind of metadata that comes with images and videos. """ # Initialize timezone finder and find the timezone tf = get_timezone_finder_singleton() - tz_str = tf.timezone_at(lat=lat, lng=lon) + tz_str = tf.timezone_at(lat=lat_long[0], lng=lat_long[1]) if lat_long is not None else None if tz_str is None: - raise ValueError("Could not determine timezone") - + if fail_on_no_timezone: + raise ValueError("No timezone found for lat/long") + print("Warning: No timezone found for lat/long, using UTC, even though it's probably wrong") + tz_str = 'UTC' # Parse the local timestamp string into a datetime object local_tz = pytz.timezone(tz_str) unlocalized_datetime = datetime.strptime(local_timestamp_str, local_timestamp_format) @@ -97,9 +100,10 @@ def convert_exif_to_geodata_or_none(exif_data: exif.Image, requires_dst_correcti gps_latitude = -gps_latitude if exif_data.gps_longitude_ref == 'W': gps_longitude = -gps_longitude + lat_long = (gps_latitude, gps_longitude) except Exception as err: print(f"Error parsing GPS data: {err}") - return None + lat_long = None try: gps_altitude = exif_data.gps_altitude except Exception as err: @@ -107,10 +111,10 @@ def convert_exif_to_geodata_or_none(exif_data: exif.Image, requires_dst_correcti gps_altitude = None # Seems the images already have UTC time in them, so we don't need to do this - epoch_timestamp = get_utc_epoch(lat=gps_latitude, lon=gps_longitude, local_timestamp_str=exif_data.datetime, + epoch_timestamp = get_utc_epoch(lat_long=lat_long, local_timestamp_str=exif_data.datetime, local_timestamp_format='%Y:%m:%d %H:%M:%S', requires_dst_correction=requires_dst_correction) epoch_time_us = int(epoch_timestamp * 1000000) - return FrameGeoData((gps_latitude, gps_longitude), epoch_time_us, altitude_from_sea=gps_altitude) + return FrameGeoData(lat_long=lat_long, epoch_time_us=epoch_time_us, altitude_from_sea=gps_altitude) def read_image_geodata_or_none(image_path: str, requires_dst_correction: bool = False) -> Optional[FrameGeoData]: diff --git a/artemis/image_processing/test_decorders.py b/artemis/image_processing/test_decorders.py index 93a504ea..7e8dc7a6 100644 --- a/artemis/image_processing/test_decorders.py +++ b/artemis/image_processing/test_decorders.py @@ -3,7 +3,8 @@ import pytest from artemis.general.should_be_builtins import all_equal -from artemis.general.utils_for_testing import get_or_download_sample_video, hold_tempdir +from artemis.general.utils_for_testing import hold_tempdir +from video_scanner.general_utils.utils_for_app_testing import get_or_download_sample_video from artemis.image_processing.decorders import PyAvDecorder, DecordDecorder, FrameListDecorder, robustly_get_decorder From 47fc84a93a75814cd4a19d45276f05a28b0c02a1 Mon Sep 17 00:00:00 2001 From: peter Date: Wed, 4 Oct 2023 13:50:48 -0700 Subject: [PATCH 082/107] little changes --- artemis/general/item_cache.py | 5 +++++ artemis/general/should_be_builtins.py | 12 ++++++++++++ artemis/general/test_should_be_builtins.py | 20 +++++++++++++++++++- artemis/image_processing/video_reader.py | 10 +++++++--- 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/artemis/general/item_cache.py b/artemis/general/item_cache.py index 38d1233a..38b5ede0 100644 --- a/artemis/general/item_cache.py +++ b/artemis/general/item_cache.py @@ -66,3 +66,8 @@ def __getitem__(self, key: Hashable) -> ItemType: def __contains__(self, key: Hashable): return key in self._buffer + + def clear(self): + self._buffer.clear() + self._current_buffer_size = 0 + self._first_object_size = None diff --git a/artemis/general/should_be_builtins.py b/artemis/general/should_be_builtins.py index ff6b4796..b5e22158 100644 --- a/artemis/general/should_be_builtins.py +++ b/artemis/general/should_be_builtins.py @@ -558,3 +558,15 @@ def __exit__(self, type, value, traceback): def __call__(self, *mconds): return self._val in mconds + + +def seconds_to_time_marker(seconds, decimals=2): + sign = "-" if seconds < 0 else "" + seconds = abs(seconds) + hours, rem = divmod(seconds, 3600) + minutes, sec = divmod(rem, 60) + time_format = "{:0.0f}:{:02.0f}:{:0"+str(decimals+3 if decimals else 2)+"." + str(decimals) + "f}" + formatted_time = time_format.format(hours, minutes, sec).lstrip("0:") + if formatted_time.startswith('.'): + formatted_time = '0' + formatted_time + return sign + formatted_time diff --git a/artemis/general/test_should_be_builtins.py b/artemis/general/test_should_be_builtins.py index 83171082..33f5b814 100644 --- a/artemis/general/test_should_be_builtins.py +++ b/artemis/general/test_should_be_builtins.py @@ -4,7 +4,7 @@ from artemis.general.should_be_builtins import itermap, reducemap, separate_common_items, remove_duplicates, \ detect_duplicates, remove_common_prefix, all_equal, get_absolute_module, insert_at, get_shifted_key_value, \ - divide_into_subsets, entries_to_table, natural_keys, switch + divide_into_subsets, entries_to_table, natural_keys, switch, seconds_to_time_marker __author__ = 'peter' @@ -148,6 +148,23 @@ def test_switch_statement(): ] +def test_seconds_to_time_marker(): + assert seconds_to_time_marker(3661.25) == "1:01:01.25" + assert seconds_to_time_marker(3661.25, 3) == "1:01:01.250" + assert seconds_to_time_marker(21.25, 3) == "21.250" + assert seconds_to_time_marker(1201.25, 3) == "20:01.250" + assert seconds_to_time_marker(7200) == "2:00:00.00" + assert seconds_to_time_marker(0) == "0.00" + assert seconds_to_time_marker(60) == "1:00.00" + assert seconds_to_time_marker(3600) == "1:00:00.00" + assert seconds_to_time_marker(0.254, 3) == "0.254" + assert seconds_to_time_marker(-3661.25) == "-1:01:01.25" + assert seconds_to_time_marker(-21.25, 3) == "-21.250" + assert seconds_to_time_marker(-0.254, 3) == "-0.254" + + print("All tests passed!") + + if __name__ == '__main__': test_separate_common_items() test_reducemap() @@ -163,3 +180,4 @@ def test_switch_statement(): test_entries_to_table() test_natural_keys() test_switch_statement() + test_seconds_to_time_marker() diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index edf07088..b2b756eb 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -14,6 +14,7 @@ import exif import numpy as np from artemis.general.custom_types import TimeIntervalTuple +from artemis.general.should_be_builtins import seconds_to_time_marker from artemis.general.utils_utils import byte_size_to_string from artemis.image_processing.image_utils import fit_image_to_max_size, read_image_time_or_none from artemis.general.item_cache import CacheDict @@ -258,13 +259,16 @@ def get_metadata(self) -> VideoMetaData: ) return self._metadata - def get_progress_indicator(self, frame_ix) -> str: + def get_progress_indicator(self, frame_ix, just_seconds: bool = False) -> str: seconds_into_video = frame_ix / self._fps if self._fps else 0 + seconds_str = f"{seconds_into_video:.2f}s" if just_seconds else seconds_to_time_marker(seconds_into_video) total_frames = self.get_n_frames() if total_frames is None: - return f"t={seconds_into_video:.2f}s, frame={frame_ix+1}" + return f"t={seconds_str}, frame={frame_ix+1}" else: - return f"t={seconds_into_video:.2f}s/{total_frames/self._fps:.2f}s, frame={frame_ix+1}/{total_frames}" + total_seconds = total_frames / self._fps if self._fps else 0 + total_seconds_str = f"{total_seconds:.2f}s" if just_seconds else seconds_to_time_marker(total_seconds) + return f"t={seconds_str}/{total_seconds_str}, frame={frame_ix+1}/{total_frames}" def get_n_frames(self) -> int: return self._stop - self._start From 7618e1ef2338673a49376d5f45a79072fc3970a7 Mon Sep 17 00:00:00 2001 From: "peter.ed.oconnor@gmail.com" Date: Thu, 26 Oct 2023 21:47:19 -0700 Subject: [PATCH 083/107] cyrillic image paths --- artemis/image_processing/image_utils.py | 9 +++++++++ artemis/image_processing/test_image_utils.py | 20 +++++++++++++++++++- artemis/image_processing/video_reader.py | 4 ++-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index f8b83aee..56b1e578 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -1060,3 +1060,12 @@ def read_image_time_or_none(image_path: str) -> Optional[float]: return datetime_obj.timestamp() else: return None + + +def imread_any_path(path: str) -> Optional[BGRImageArray]: + if not os.path.exists(path): + raise FileNotFoundError(path) + image = cv2.imread(path) + if image is None: + image = cv2.imdecode(np.fromfile(path, np.uint8), cv2.IMREAD_COLOR) + return image diff --git a/artemis/image_processing/test_image_utils.py b/artemis/image_processing/test_image_utils.py index 22208da5..0c86e458 100644 --- a/artemis/image_processing/test_image_utils.py +++ b/artemis/image_processing/test_image_utils.py @@ -151,11 +151,29 @@ def test_image_view_info(show: bool = False): just_show(display_img, hang_time=10) +from video_scanner.general_utils.file_utils import imread_any_path +from video_scanner.general_utils.utils_for_app_testing import DroneDataDirectory + + +def test_read_file_with_cyrillic_path(): + # Latin path + path = DroneDataDirectory().get_file('test_data\casara_on\DJI_202305261131_040_Targets\DJI_20230526120735_0001_W.JPG') + image = imread_any_path(path) + assert image is not None + assert image.shape == (3000, 4000, 3) + + # Path containing cyrillic + path = DroneDataDirectory().get_file('test_data\\test_Đ´ir\dji_2023-07-06_18-09-26_0007.jpg') + image = imread_any_path(path) + assert image is not None + assert image.shape == (3024, 4032, 3) + if __name__ == "__main__": # test_iter_images_from_video() # test_mask_to_boxes() # test_conditional_running_min() # test_slice_image_with_pad() - test_image_view_info(show=True) + # test_image_view_info(show=True) + test_read_file_with_cyrillic_path() diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index b2b756eb..9ef513f7 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -16,7 +16,7 @@ from artemis.general.custom_types import TimeIntervalTuple from artemis.general.should_be_builtins import seconds_to_time_marker from artemis.general.utils_utils import byte_size_to_string -from artemis.image_processing.image_utils import fit_image_to_max_size, read_image_time_or_none +from artemis.image_processing.image_utils import fit_image_to_max_size, read_image_time_or_none, imread_any_path from artemis.general.item_cache import CacheDict from artemis.general.parsing import parse_time_delta_str_to_sec from artemis.image_processing.livestream_recorder import LiveStreamRecorderAgent @@ -513,7 +513,7 @@ def request_frame(self, index: int) -> VideoFrameInfo: index = first((i for i in range(index, -1, -1) if self._image_paths[i]), default=0) if not os.path.exists(self._image_paths[index]): raise FileNotFoundError(f"Could not find image at path: '{self._image_paths[index]}'") - image = cv2.imread(self._image_paths[index]) + image = imread_any_path(self._image_paths[index]) self._cache[index] = image assert image is not None, f"Could not load image at path: '{self._image_paths[index]}'" return VideoFrameInfo( From cbad6a2d282f90b9c79d7982a4e08ec7cf3d84e7 Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 26 Oct 2023 21:49:29 -0700 Subject: [PATCH 084/107] whatever was local --- artemis/image_processing/image_utils.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index f8b83aee..3977ccd4 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -832,6 +832,15 @@ def from_initial_view(cls, window_disply_wh: Tuple[int, int], image_wh: Tuple[in image_wh=image_wh ) + def get_image_slice(self) -> Tuple[slice, slice]: + """ Get the slice of the image that should be displayed """ + w, h = self.window_disply_wh + x, y = self.center_pixel_xy + zoom = self.zoom_level + # return slice(int(y - h / 2 / zoom), int(y + h / 2 / zoom)), slice(int(x - w / 2 / zoom), int(x + w / 2 / zoom)) + # Dont forget to clip + return slice(max(0, int(y - h / 2 / zoom)), min(self.image_wh[1], int(y + h / 2 / zoom))), slice(max(0, int(x - w / 2 / zoom)), min(self.image_wh[0], int(x + w / 2 / zoom))) + def adjust_frame_and_image_size(self, new_frame_wh: Tuple[int, int], new_image_wh: Tuple[int, int]) -> 'ImageViewInfo': return replace(self, window_disply_wh=new_frame_wh, image_wh=new_image_wh) From 8daa7d331cd1bd412f9cf65a4c2f46434bec9a0a Mon Sep 17 00:00:00 2001 From: peter Date: Mon, 6 Nov 2023 17:28:29 -0800 Subject: [PATCH 085/107] metadata --- artemis/image_processing/image_builder.py | 6 +++++- artemis/image_processing/media_metadata.py | 18 +++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/artemis/image_processing/image_builder.py b/artemis/image_processing/image_builder.py index b00f627e..f1da5a15 100644 --- a/artemis/image_processing/image_builder.py +++ b/artemis/image_processing/image_builder.py @@ -10,7 +10,7 @@ from artemis.general.custom_types import BGRImageArray, XYPointTuple, IJPixelTuple, HeatMapArray, XYSizeTuple, BGRColorTuple, Array, GreyScaleImageArray, BGRFloatImageArray from artemis.plotting.easy_window import ImageRow, ImageCol, put_text_at, put_text_in_corner -from artemis.image_processing.image_utils import heatmap_to_color_image, BoundingBox, BGRColors, DEFAULT_GAP_COLOR, RelativeBoundingBox, TextDisplayer +from artemis.image_processing.image_utils import heatmap_to_color_image, BoundingBox, BGRColors, DEFAULT_GAP_COLOR, RelativeBoundingBox, TextDisplayer, put_image_in_box @dataclass @@ -74,6 +74,10 @@ def rescale(self, factor: float, interp=cv2.INTER_NEAREST): self.image = cv2.resize(self.image, dsize=None, fx=factor, fy=factor, interpolation=interp) return self + def rescale_to_fit(self, xy_size: XYSizeTuple, interp=cv2.INTER_AREA) -> 'ImageBuilder': + self.image = put_image_in_box(self.image, xy_size=xy_size, interpolation=interp) + return self + def copy(self) -> 'ImageBuilder': return ImageBuilder(image=self.image.copy(), origin=self.origin, resolution=self.resolution) diff --git a/artemis/image_processing/media_metadata.py b/artemis/image_processing/media_metadata.py index f2f47d4b..b58c9f33 100644 --- a/artemis/image_processing/media_metadata.py +++ b/artemis/image_processing/media_metadata.py @@ -60,7 +60,11 @@ def get_utc_epoch( tz_str = 'UTC' # Parse the local timestamp string into a datetime object local_tz = pytz.timezone(tz_str) - unlocalized_datetime = datetime.strptime(local_timestamp_str, local_timestamp_format) + if '.' in local_timestamp_str: + unlocalized_datetime = datetime.strptime(local_timestamp_str, local_timestamp_format+'.%f') + else: + unlocalized_datetime = datetime.strptime(local_timestamp_str, local_timestamp_format) + # unlocalized_datetime = datetime.strptime(local_timestamp_str, local_timestamp_format) # Localize the timestamp to the local timezone localized_datetime = local_tz.localize(unlocalized_datetime) @@ -110,10 +114,14 @@ def convert_exif_to_geodata_or_none(exif_data: exif.Image, requires_dst_correcti print(f"Error parsing GPS altitude: {err}") gps_altitude = None - # Seems the images already have UTC time in them, so we don't need to do this - epoch_timestamp = get_utc_epoch(lat_long=lat_long, local_timestamp_str=exif_data.datetime, - local_timestamp_format='%Y:%m:%d %H:%M:%S', requires_dst_correction=requires_dst_correction) - epoch_time_us = int(epoch_timestamp * 1000000) + try: + # Seems the images already have UTC time in them, so we don't need to do this + epoch_timestamp = get_utc_epoch(lat_long=lat_long, local_timestamp_str=exif_data.datetime, + local_timestamp_format='%Y:%m:%d %H:%M:%S', requires_dst_correction=requires_dst_correction) + epoch_time_us = int(epoch_timestamp * 1000000) + except Exception as err: + print(f"Error parsing GPS timestamp: {err}") + epoch_time_us = 0 return FrameGeoData(lat_long=lat_long, epoch_time_us=epoch_time_us, altitude_from_sea=gps_altitude) From 0c54f189d3b66b33fc198fe8d4c6af57f914ce3c Mon Sep 17 00:00:00 2001 From: peter Date: Mon, 13 Nov 2023 17:31:05 -0800 Subject: [PATCH 086/107] fixed import --- artemis/general/nested_structures.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/artemis/general/nested_structures.py b/artemis/general/nested_structures.py index f319009e..b58c66d8 100644 --- a/artemis/general/nested_structures.py +++ b/artemis/general/nested_structures.py @@ -1,6 +1,4 @@ -import inspect -from collections import OrderedDict, Iterable -from functools import partial +from collections import OrderedDict import numpy as np from six import string_types, next From 6e91994adbb1b48fd7ca3607eea2b325f4a1e99f Mon Sep 17 00:00:00 2001 From: peter Date: Mon, 13 Nov 2023 17:35:35 -0800 Subject: [PATCH 087/107] import iterable --- artemis/general/iteratorize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/artemis/general/iteratorize.py b/artemis/general/iteratorize.py index bfca0a61..472eb25a 100644 --- a/artemis/general/iteratorize.py +++ b/artemis/general/iteratorize.py @@ -4,7 +4,7 @@ Thanks to Brice for this piece of code. Taken from https://stackoverflow.com/a/9969000/851699 """ -from collections import Iterable +from typing import Iterable import sys if sys.version_info < (3, 0): from Queue import Queue From 1b0b29d7310cc6fa2a6af7c71c32a5d4a6ee873f Mon Sep 17 00:00:00 2001 From: peter Date: Wed, 22 Nov 2023 19:54:36 -0800 Subject: [PATCH 088/107] hackathon commit --- artemis/image_processing/image_builder.py | 1 + artemis/image_processing/image_utils.py | 7 ++++--- artemis/image_processing/video_reader.py | 3 +++ artemis/image_processing/video_segment.py | 10 ++++++++++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/artemis/image_processing/image_builder.py b/artemis/image_processing/image_builder.py index f1da5a15..c0fda505 100644 --- a/artemis/image_processing/image_builder.py +++ b/artemis/image_processing/image_builder.py @@ -134,6 +134,7 @@ def draw_box(self, box: BoundingBox | RelativeBoundingBox, colour: BGRColorTuple thickness: int = 1, box_id: Optional[int] = None, as_circle: bool = False, include_labels = True, show_score_in_label: bool = True, score_as_pct: bool = False) -> 'ImageBuilder': + colour = tuple(int(c) for c in colour) if text_color is None: text_color = colour if isinstance(box, RelativeBoundingBox): diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index b9695e33..b6565834 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -152,9 +152,6 @@ def iter_images_from_video(path: str, max_size: Optional[Tuple[int, int]] = None yield image return - - - assert not use_scan_selection, "This does not work. See bug: https://github.com/opencv/opencv/issues/9053" path = os.path.expanduser(path) cap = cv2.VideoCapture(path) @@ -507,6 +504,10 @@ def to_ij(self) -> Tuple[int, int]: """ Get the (row, col) of the center of the box """ return round((self.y_min + self.y_max) / 2.), round((self.x_min + self.x_max) / 2.) + def to_ijhw(self) -> Tuple[int, int, int, int]: + """ Get the (row, col) of the center of the box """ + return self.to_ij()+(round(self.y_max - self.y_min), round(self.x_max - self.x_min)) + def to_crop_ij(self) -> Tuple[int, int]: """ Get the (row, col) of the center of the box in the frame of the cropped image""" i, j = self.to_ij() diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index 9ef513f7..b53e1e6e 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -259,6 +259,9 @@ def get_metadata(self) -> VideoMetaData: ) return self._metadata + def get_path(self) -> str: + return self._path + def get_progress_indicator(self, frame_ix, just_seconds: bool = False) -> str: seconds_into_video = frame_ix / self._fps if self._fps else 0 seconds_str = f"{seconds_into_video:.2f}s" if just_seconds else seconds_to_time_marker(seconds_into_video) diff --git a/artemis/image_processing/video_segment.py b/artemis/image_processing/video_segment.py index 0646c2f9..8ac719d7 100644 --- a/artemis/image_processing/video_segment.py +++ b/artemis/image_processing/video_segment.py @@ -49,6 +49,16 @@ def exists(self) -> bool: def _is_image_sequence(self) -> bool: return ';' in self.path + + # def time_to_nearest_frame(self, time: float) -> int: + # """ + # Lookup the frame index for a given time. + # """ + # if self._is_image_sequence(): + # raise NotImplementedError() + # else: + # return self.get_reader().time_to_nearest_frame(time) + def get_reader(self, buffer_size_bytes: int = 1024**3, use_cache: bool = True) -> IVideoReader: if self._is_image_sequence(): From 2f9a475d640c876301570ce71c6af37b396b4314 Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 23 Nov 2023 14:17:34 -0800 Subject: [PATCH 089/107] changes --- artemis/fileman/smart_io.py | 2 +- artemis/general/image_ops.py | 3 ++- artemis/image_processing/image_builder.py | 3 +-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/artemis/fileman/smart_io.py b/artemis/fileman/smart_io.py index e6d2403b..7cdf3ee6 100644 --- a/artemis/fileman/smart_io.py +++ b/artemis/fileman/smart_io.py @@ -101,7 +101,7 @@ def smart_load_image(location, max_resolution = None, force_rgb=False, use_cache with smart_file(location, use_cache=use_cache) as local_path: return _load_image(local_path, max_resolution = max_resolution, force_rgb=force_rgb) -_IMAGE_EXTENSIONS = ('.jpg', '.jpeg', '.png', '.gif') +_IMAGE_EXTENSIONS = ('.jpg', '.jpeg', '.png', '.gif', '.tif') def _load_image(local_path, max_resolution = None, force_rgb = False): diff --git a/artemis/general/image_ops.py b/artemis/general/image_ops.py index bf0f75cc..8119a484 100644 --- a/artemis/general/image_ops.py +++ b/artemis/general/image_ops.py @@ -1,4 +1,4 @@ -from scipy.misc.pilutil import imresize + import numpy as np __author__ = 'peter' @@ -12,6 +12,7 @@ def resize_while_preserving_aspect_ratio(im, x_dim=None, y_dim=None): :param y_dim: An integer indicating the desired size, or None, to leave it loose. :return: A new image whose x_dim or y_dim matches the constraint """ + from scipy.misc.pilutil import imresize assert not (x_dim is None and y_dim is None), 'You can not leave both constraints at None!' x_dim = float('inf') if x_dim is None else x_dim diff --git a/artemis/image_processing/image_builder.py b/artemis/image_processing/image_builder.py index c0fda505..1c50fa52 100644 --- a/artemis/image_processing/image_builder.py +++ b/artemis/image_processing/image_builder.py @@ -154,9 +154,8 @@ def draw_box(self, box: BoundingBox | RelativeBoundingBox, colour: BGRColorTuple # if box.label or box_id is not None: if label is None: - label = ','.join(str(i) for i in [box_id, box.label, None if not show_score_in_label else f"{box.score:.0%}" if score_as_pct else f"{box.score:.2f}"] if i is not None) + label = ','.join(str(i) for i in [box_id, box.label, None if not show_score_in_label else f"{box.score:.0%}" if score_as_pct else f"{box.score:.2f}"] if i) if include_labels: - put_text_at(self.image, text=label, position_xy=(jmean, imin if box.y_min > box.y_max-box.y_min else imax), anchor_xy=(0.5, 0.) if as_circle else (0., 0.), From cedd6db17ec70456f29e08ee355f9b8455ac4e37 Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 28 Nov 2023 00:15:49 -0800 Subject: [PATCH 090/107] fix_import --- artemis/experiments/experiment_record_view.py | 22 ++++++------------- artemis/fileman/file_utils.py | 6 ++--- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/artemis/experiments/experiment_record_view.py b/artemis/experiments/experiment_record_view.py index 58f4ec86..5be57025 100644 --- a/artemis/experiments/experiment_record_view.py +++ b/artemis/experiments/experiment_record_view.py @@ -1,13 +1,10 @@ -import re from collections import OrderedDict from functools import partial -import itertools +import numpy as np from six import string_types from tabulate import tabulate -import numpy as np -import video_scanner.ui.camera_livestream_setup from artemis.experiments.experiment_record import NoSavedResultError, ExpInfoFields, ExperimentRecord, \ load_experiment_record, is_matplotlib_imported, UnPicklableArg from artemis.general.display import deepstr, truncate_string, hold_numpy_printoptions, side_by_side, \ @@ -17,10 +14,7 @@ from artemis.general.should_be_builtins import separate_common_items, bad_value, izip_equal, \ remove_duplicates, get_unique_name, entries_to_table from artemis.general.tables import build_table -import os - from artemis.plotting.parallel_coords_plots import plot_hyperparameter_search_parallel_coords -from artemis.plotting.pyplot_plus import get_color_cycle_map def get_record_result_string(record, func='deep', truncate_to = None, array_print_threshold=8, array_float_format='.3g', oneline=False, default_one_liner_func=str): @@ -529,10 +523,8 @@ def browse_record_figs(record): """ # TODO: Generalize this to just browse through the figures in a directory. - from artemis.plotting.saving_plots import interactive_matplotlib_context import pickle from matplotlib import pyplot as plt - from artemis.plotting.drawing_plots import redraw_figure fig_locs = record.get_figure_locs() class nonlocals: @@ -554,19 +546,19 @@ def show_figure(ix): nonlocals.this_fig = plt.gcf() def changefig(keyevent): - if video_scanner.ui.camera_livestream_setup.key == 'right': + if keyevent.key == 'right': nonlocals.figno = (nonlocals.figno+1)%len(fig_locs) - elif video_scanner.ui.camera_livestream_setup.key == 'left': + elif keyevent.key == 'left': nonlocals.figno = (nonlocals.figno-1)%len(fig_locs) - elif video_scanner.ui.camera_livestream_setup.key == 'up': + elif keyevent.key == 'up': nonlocals.figno = (nonlocals.figno-10)%len(fig_locs) - elif video_scanner.ui.camera_livestream_setup.key == 'down': + elif keyevent.key == 'down': nonlocals.figno = (nonlocals.figno+10)%len(fig_locs) - elif video_scanner.ui.camera_livestream_setup.key == ' ': + elif keyevent.key == ' ': nonlocals.figno = queryfig() else: - print("No handler for key: {}. Changing Nothing".format(video_scanner.ui.camera_livestream_setup.key)) + print("No handler for key: {}. Changing Nothing".format(keyevent.key)) show_figure(nonlocals.figno) def queryfig(): diff --git a/artemis/fileman/file_utils.py b/artemis/fileman/file_utils.py index c22bc5ee..57e8a597 100644 --- a/artemis/fileman/file_utils.py +++ b/artemis/fileman/file_utils.py @@ -21,9 +21,9 @@ def get_dest_filepath(src_path: str, src_root_dir: str, dest_root_dir: str, time src_name, ext = os.path.splitext(src_filename) src_order_number = src_name.split('_', 1)[1] # 'DJI_0215' -> '0215' timestamp = os.path.getmtime(src_path) - # if is_daylight: - # timestamp += 3600. - # timestamp -= 3600. if time.daylight else 0 + if is_daylight: + timestamp += 3600. + timestamp -= 3600. if time.daylight else 0 new_filename = f'{modified_timestamp_to_filename(timestamp, time_format=time_format)}_{src_order_number}{ext.lower()}' return os.path.join(dest_root_dir, src_rel_folder, new_filename) From 35f6fa17c9462dac54fc0d90b14208b795725948 Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 28 Nov 2023 00:18:58 -0800 Subject: [PATCH 091/107] tk_utils --- artemis/plotting/tk_utils/__init__.py | 0 .../tk_utils/alternate_zoomable_image_view.py | 357 ++++++++++ artemis/plotting/tk_utils/constants.py | 29 + artemis/plotting/tk_utils/dual_panel_frame.py | 123 ++++ artemis/plotting/tk_utils/machine_utils.py | 4 + artemis/plotting/tk_utils/test_tk_utils.py | 15 + artemis/plotting/tk_utils/tk_error_dialog.py | 169 +++++ artemis/plotting/tk_utils/tk_utils.py | 639 ++++++++++++++++++ artemis/plotting/tk_utils/tkshow.py | 105 +++ artemis/plotting/tk_utils/tooltip.py | 67 ++ .../plotting/tk_utils/ui_choose_parameters.py | 205 ++++++ artemis/plotting/tk_utils/ui_utils.py | 341 ++++++++++ 12 files changed, 2054 insertions(+) create mode 100644 artemis/plotting/tk_utils/__init__.py create mode 100644 artemis/plotting/tk_utils/alternate_zoomable_image_view.py create mode 100644 artemis/plotting/tk_utils/constants.py create mode 100644 artemis/plotting/tk_utils/dual_panel_frame.py create mode 100644 artemis/plotting/tk_utils/machine_utils.py create mode 100644 artemis/plotting/tk_utils/test_tk_utils.py create mode 100644 artemis/plotting/tk_utils/tk_error_dialog.py create mode 100644 artemis/plotting/tk_utils/tk_utils.py create mode 100644 artemis/plotting/tk_utils/tkshow.py create mode 100644 artemis/plotting/tk_utils/tooltip.py create mode 100644 artemis/plotting/tk_utils/ui_choose_parameters.py create mode 100644 artemis/plotting/tk_utils/ui_utils.py diff --git a/artemis/plotting/tk_utils/__init__.py b/artemis/plotting/tk_utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/artemis/plotting/tk_utils/alternate_zoomable_image_view.py b/artemis/plotting/tk_utils/alternate_zoomable_image_view.py new file mode 100644 index 00000000..f125ff7c --- /dev/null +++ b/artemis/plotting/tk_utils/alternate_zoomable_image_view.py @@ -0,0 +1,357 @@ +# -*- coding: utf-8 -*- +# Advanced zoom example. Like in Google Maps. +# It zooms only a tile, but not the whole image. So the zoomed tile occupies +# constant memory and not crams it with a huge resized image for the large zooms. +import time +import tkinter as tk +from contextlib import contextmanager +from math import copysign +from tkinter import EventType, Event +from typing import Optional, Tuple, Callable, Mapping, Dict, List + +import cv2 +from PIL import ImageTk + +from artemis.fileman.smart_io import smart_load_image +from artemis.general.custom_types import BGRImageArray, BGRColorTuple +from artemis.image_processing.image_utils import ImageViewInfo, BGRColors +from artemis.plotting.tk_utils.machine_utils import is_windows_machine +from artemis.plotting.tk_utils.tk_error_dialog import tk_show_eagle_eyes_error_dialog, ErrorDetail +from artemis.plotting.tk_utils.tk_utils import bind_callbacks_to_widget +from artemis.plotting.tk_utils.ui_utils import bgr_image_to_pil_image + + +class ZoomableImageFrame(tk.Label): + + def __init__(self, + parent_frame: tk.Frame, + image: Optional[BGRImageArray] = None, + height: Optional[int] = None, + width: Optional[int] = None, + gap_color: BGRColorTuple = BGRColors.DARK_GRAY, + scrollbar_color: BGRColorTuple = BGRColors.GRAY, + zoom_jump_factor: float = 1.2, + max_zoom: float = 40.0, + pan_jump_factor=0.2, + mouse_scroll_speed: float = 2.0, + error_handler: Optional[Callable[[ErrorDetail], None]] = tk_show_eagle_eyes_error_dialog, + zoom_scrolling_mode: bool = False, # Use mouse scrollwheel to zoom, + after_view_change_callback: Optional[Callable[[ImageViewInfo], None]] = None, + additional_canvas_callbacks: Optional[Mapping[str, Callable[[Event], None]]] = None, + single_click_callback: Optional[Callable[[Tuple[int, int]], None]] = None, + double_click_callback: Optional[Callable[[Tuple[int, int]], None]] = None, + mouse_callback: Optional[Callable[[Event, Tuple[int, int]], bool]] = None, # Takes the event and the pixel xy + scroll_indicator_width_pix: int = 10, + rel_area_change_to_reset_zoom: float = 0.25, + margin_gap: int = 4, # Prevents infinite config-loop + ): + + # self.label = tk.Label(parent_frame) + super().__init__(master=parent_frame, width=width, height=height) + # self.label.pack() + # assert height is not None or width is not None, "You must specify height, width, or both to display image" + # self.height = height + # self.width = width + self._after_view_change_callback = after_view_change_callback + self._mouse_scroll_speed = mouse_scroll_speed + self._image_view_frame: Optional[ImageViewInfo] = None + self._recent_configured_whs: List[Tuple[int, int]] = [] + self._last_configured_wh: Optional[Tuple[int, int]] = None + self._scroll_indicator_width_pix = scroll_indicator_width_pix + self._gap_color = gap_color + self._scrollbar_color = scrollbar_color + self._margin_gap = margin_gap + self._first_pass = True + self._rel_area_change_to_reset_zoom = rel_area_change_to_reset_zoom + self._single_click_callback = single_click_callback + self._double_click_callback = double_click_callback + self._mouse_callback = mouse_callback + self._zoom_jump_factor = zoom_jump_factor + self._hold_off_redraw = False # Disales redraw while true. Use through self.hold_off_redraw_context() + self._zoom_scrolling_mode = zoom_scrolling_mode + self._max_zoom = max_zoom + self._drag_start_display_xy: Optional[Tuple[int, int]] = None + self._image: Optional[BGRImageArray] = None + self._is_configuration_still_being_negotiated = True + if image is not None: + self.set_image(image) + + self._binding_dict: Dict[str, Callable[[Event], None]] = { + **(additional_canvas_callbacks or {}), + **{ + '': lambda event: self.set_image_frame(self._image_view_frame.zoom_by(zoom_jump_factor, invariant_display_xy=self._event_to_display_xy(event))), + '': lambda event: self.set_image_frame(self._image_view_frame.zoom_by(1 / zoom_jump_factor, invariant_display_xy=self._event_to_display_xy(event))), + '': lambda event: self.set_image_frame(self._image_view_frame.zoom_out()), + '': lambda event: self.set_image_frame(self._image_view_frame.pan_by_display_relshift(display_rel_xy=(0, -pan_jump_factor), limit=True)), + '': lambda event: self.set_image_frame(self._image_view_frame.pan_by_display_relshift(display_rel_xy=(-pan_jump_factor, 0), limit=True)), + '': lambda event: self.set_image_frame(self._image_view_frame.pan_by_display_relshift(display_rel_xy=(0, pan_jump_factor), limit=True)), + '': lambda event: self.set_image_frame(self._image_view_frame.pan_by_display_relshift(display_rel_xy=(pan_jump_factor, 0), limit=True)), + '': self._on_click, # For some reason, this is not working... + # '': lambda event: print("Single click"), # This never gets called + '': self._on_double_click, + # Handle mouse-drag and release + '': self._on_mouse_drag_and_release, + '': self._on_mouse_drag_and_release, + '': self._handle_mousewheel_event, + '': self._handle_mousewheel_event, + '': self._handle_mousewheel_event, + '': self._on_configure, # This may be unnecessary - and it can cause dangerous loops + # Add number-pad callbacks: 5 to zoom in, 0 to zoom out, 1-9 to pan + "": lambda event: self.set_image_frame(self._image_view_frame.zoom_by(zoom_jump_factor, invariant_display_xy=None)), + "": lambda event: self.set_image_frame(self._image_view_frame.zoom_by(1 / zoom_jump_factor, invariant_display_xy=None)), + **{f"": lambda event, i=i: self.set_image_frame(self._image_view_frame.pan_by_display_relshift(display_rel_xy=(pan_jump_factor*(((i-1) % 3)-1), -pan_jump_factor*(((i-1)//3)-1)), limit=True)) for i in [1, 2, 3, 4, 6, 7, 8, 9]}, + # Add callbacks for entering/exiting focus: + + + + # '': double_click, + # '': lambda x: print("Single Click"), + # '': self.move_to, + # '': self.wheel, + # '': self.wheel, + # '': self.wheel, + # }, + # **{f"<{key}>": self.onKeyPress for key in 'zxcwasd'} + }} + bind_callbacks_to_widget(callbacks=self._binding_dict, widget=self, bind_all=False, error_handler=error_handler) + self.bind("<1>", lambda event: self.focus_set()) + # self.rebind() + + @classmethod + def launch_as_standalone(cls, image: BGRImageArray): + root = tk.Tk() + root.geometry("1280x720") + frame = ZoomableImageFrame(root) + frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + frame.set_image(image) + root.mainloop() + + def _on_configure(self, event: Event): + + + # Avoid getting trapped in configuration loops... + # print(f"Configure called at {time.monotonic() % 100 :.1f}") + # print(f" Master size {self.master.winfo_width()}x{self.master.winfo_height()}") + # print(f" Our size {self.winfo_width()}x{self.winfo_height()}") + if (self.winfo_width(), self.winfo_height()) not in self._recent_configured_whs: + # print(" Redrawing...") + # print("Configure called with size: ", self.winfo_width(), self.winfo_height()) + self.redraw() + self._recent_configured_whs = self._recent_configured_whs[-2:] + [(self.winfo_width(), self.winfo_height())] + # Set last configured wh (used to determine if zoom should be reset) + self._last_configured_wh = (self.winfo_width(), self.winfo_height()) + + def _on_mouse_drag_and_release(self, event: Event): + + if self._mouse_callback is not None and self._image_view_frame is not None: + display_xy = self._event_to_display_xy(event) + px, py = self._image_view_frame.display_xy_to_pixel_xy(display_xy) + eat_event = self._mouse_callback(event, (int(px), int(py))) + if eat_event: + return + + is_drag = event.type == EventType.Motion + is_release = event.type == EventType.ButtonRelease + if is_drag: + if self._drag_start_display_xy is None: + self._drag_start_display_xy = self._event_to_display_xy(event) + else: + display_xy = self._event_to_display_xy(event) + display_rel_xy = self._drag_start_display_xy[0]-display_xy[0], self._drag_start_display_xy[1]-display_xy[1] + self._drag_start_display_xy = display_xy + new_frame = self._image_view_frame.pan_by_display_shift(display_shift_xy=display_rel_xy, limit=True) + self.set_image_frame(new_frame) + elif is_release: + self._drag_start_display_xy = None + + def _on_click(self, event: Event): + # Never gets called for some reason + if self._single_click_callback is not None: + display_xy = self._event_to_display_xy(event) + px, py = self._image_view_frame.display_xy_to_pixel_xy(display_xy) + self._single_click_callback((int(px), int(py))) + + def _on_double_click(self, event: Event): + if self._double_click_callback is not None: + display_xy = self._event_to_display_xy(event) + px, py = self._image_view_frame.display_xy_to_pixel_xy(display_xy) + self._double_click_callback((int(px), int(py))) + else: + self.set_image_frame(self._image_view_frame.zoom_by(self._zoom_jump_factor, invariant_display_xy=self._event_to_display_xy(event))) + + def _event_to_display_xy(self, event: Event) -> Tuple[int, int]: + return event.x, event.y + # offset_x, offset_y = (self.winfo_width()-self.width)//2, (self.winfo_height()-self.height)//2 + # return event.x-offset_x, event.y-offset_y + + def _handle_mousewheel_event(self, event: Event): + ''' Pan with mouse wheel ''' + if self._image_view_frame is None: + return + # print(f"Event with state {event.state}") + # State "8" indicates that the command key is held + # Make real state invariant to numlock + real_state = event.state & ~0x0008 if is_windows_machine() else event.state + # print(f"Mousewheel event: {event.delta}, type: {event.type}, state: {event.state}, real_state: {real_state}, serial: {event.serial}") + modified_scroll_state = 4 if is_windows_machine() else 8 # Command-Scroll on mac, Control-Scroll on windows + is_zoom_scroll = (self._zoom_scrolling_mode and real_state==0) or (not self._zoom_scrolling_mode and real_state==modified_scroll_state) + # print(f"Got scroll event with state {event.state} and delta {event.delta}") + # 3 is a good empirical fudge factor on windows. + delta = copysign(3.0, event.delta) if is_windows_machine() else event.delta + # This is horribly confusing... must be a way to simplify. + new_frame = None + if is_zoom_scroll: + # if event.state == 0: # Vertical + if is_windows_machine() and self._zoom_scrolling_mode: + delta = -delta + is_zoom_in = delta < 0 + zoom_factor = (self._zoom_jump_factor - 1)*abs(delta)*self._mouse_scroll_speed + 1 + rzoom = zoom_factor if is_zoom_in else 1/zoom_factor + new_frame = self._image_view_frame.zoom_by(relative_zoom=rzoom, invariant_display_xy=self._event_to_display_xy(event), max_zoom=self._max_zoom) + + else: + is_vertical_pan = (self._zoom_scrolling_mode and real_state==modified_scroll_state) or (not self._zoom_scrolling_mode and real_state==0) + is_horizontal_pan = real_state==1 + + if is_windows_machine() or event.type == EventType.MouseWheel: + if not is_windows_machine() and self._zoom_scrolling_mode: # I am so confused + delta = -delta + step = -delta * self._mouse_scroll_speed + if is_vertical_pan: + new_frame = self._image_view_frame.pan_by_display_shift(display_shift_xy=(0, step)) + elif is_horizontal_pan: + new_frame = self._image_view_frame.pan_by_display_shift(display_shift_xy=(step, 0)) + if new_frame is not None: + self.set_image_frame(new_frame) + + def set_image(self, image: BGRImageArray, redraw_now: bool = True): + zoom_reset_needed = self._image is not None and self._image.shape != image.shape + self._image = image + if redraw_now: + if zoom_reset_needed: + self.reset_zoom() + else: + self.redraw() + + @contextmanager + def hold_off_redraw_context(self): + try: + previous_state = self._hold_off_redraw + self._hold_off_redraw = True + yield + finally: + self._hold_off_redraw = previous_state + self.redraw() + + def set_image_frame(self, image_view_frame: Optional[ImageViewInfo], fix_configuration: bool = True): + if fix_configuration: + self._is_configuration_still_being_negotiated = False + self._image_view_frame = image_view_frame + self.redraw() + if self._after_view_change_callback is not None: + self._after_view_change_callback(image_view_frame) + + def reset_zoom(self): + self.set_image_frame(None) + self._is_configuration_still_being_negotiated = True + + def zoom_to_pixel(self, pixel_xy: Tuple[int, int], zoom_level: float, adjust_to_boundary: bool = True): + if self._image_view_frame is not None: + new_frame = self._image_view_frame.zoom_to_pixel(pixel_xy=pixel_xy, zoom_level=zoom_level) + if adjust_to_boundary: + new_frame = new_frame.adjust_pan_to_boundary() + # new_frame = new_frame.adjust_to_window(self.winfo_width(), self.winfo_height()) + self.set_image_frame(new_frame) + + def get_image_view_frame_or_none(self) -> Optional[ImageViewInfo]: + return self._image_view_frame + + def rebind(self): + # self.unbind_keys() + bind_callbacks_to_widget(self._binding_dict, widget=self) + # self.bind("<1>", lambda event: self.focus_set()) + self.bind() + + def unbind_keys(self): + self.unbind_all(list(self._binding_dict.keys())) + + # + # def zoom_by(self, zoom_factor: float, invariant_display_xy: Tuple[int, int]): + # self._image_view_frame = self._image_view_frame.zoom_by(zoom_factor, invariant_display_xy=invariant_display_xy) + # self.redraw() + # + # def zoom_out(self): + # self._image_view_frame = self._image_view_frame.zoom_out() + # self.redraw() + + def redraw(self): + if self._hold_off_redraw: + return + # width, height = + # height, width = (self.master.winfo_width(), self.master.winfo_height()) if self._first_pass else (self.winfo_width(), self.winfo_height()) + width, height = (self.winfo_width(), self.winfo_height()) + self._first_pass = False + + # if width == 1 or height == 1: # Window has not yet rendered and does not have a size, so do not draw + if width < 10 or height < 10: # Window has not yet rendered and does not have a size, so do not draw + # print(f"Skipping redraw on Zoomable Image Container because width={width}, height={height}") + return + if self._image is None: + # print("Skipping becase no image yet") + return + # print(f'Redrawing image of shape {self._image.shape} with width={width}, height={height}') + # aspect_ratio = self._image.shape[1] / self._image.shape[0] + # self.height = self.height or round(self.width/aspect_ratio) + # self.width = self.width or round(self.height*aspect_ratio) + # width = self.width or self.master.winfo_width() + + # if height<2 or width<2: + # return + # print(f"Redrawing Zoomable Image Container with width={width}, height={height}") + if self._image_view_frame is None or self._is_configuration_still_being_negotiated: + self._image_view_frame = ImageViewInfo.from_initial_view(window_disply_wh=(width, height), image_wh=(self._image.shape[1], self._image.shape[0])) + else: + + relative_area_change = 1. if self._last_configured_wh is None else (width * height) / (self._last_configured_wh[0] * self._last_configured_wh[1]) + keep_old_zoom = 1/(1+self._rel_area_change_to_reset_zoom) <= relative_area_change <= 1+self._rel_area_change_to_reset_zoom + self._image_view_frame = self._image_view_frame.adjust_frame_and_image_size(new_image_wh=(self._image.shape[1], self._image.shape[0]), new_frame_wh=(width, height)) + if not keep_old_zoom: + self._image_view_frame = self._image_view_frame.zoom_out() + disp_image = self._image_view_frame.create_display_image(self._image, gap_color=self._gap_color, scroll_fg_color = self._scrollbar_color) + # disp_image = cv2.cvtColor(disp_image, cv2.COLOR_BGR2RGB) + # im_resized = put_image_in_box(self._image, (self.winfo_width(), self.winfo_height())) + # print(f"Zoomable Display image shape: {disp_image.shape}, with width={width}, height={height}") + imagetk = ImageTk.PhotoImage(bgr_image_to_pil_image(disp_image), master=self) + self.config(image=imagetk) + # self.config(image=imagetk, width=width-self._margin_gap, height=height-self._margin_gap) + self._imagetk = imagetk + + +if __name__ == "__main__": + + # Get sample image from web + import requests + import numpy as np + from io import BytesIO + + img=smart_load_image("https://upload.wikimedia.org/wikipedia/commons/8/8a/%22Ride_the_elephant._Ride_Holy_Moses%22_LCCN2018647619.tif", use_cache=True) + assert img is not None, "Could not load image" + root = tk.Tk() + root.geometry("800x600") + + frame = ZoomableImageFrame(root, + zoom_scrolling_mode=False, + # image=img, + # width=800, + # height=600, + # zoom_to_parent_initially=True, + # image_single_click_callback=lambda xy: print(f"Single click on {xy}"), + # image_double_click_callback=lambda xy: print(f"Double click on {xy}"), + ) + + # frame = tk.Label(text='aaa') + # frame.update_image(img) + # frame = ZoomableImageTkFrame(root, image=img, zoom_to_parent_initially=True) + frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + frame.set_image(img[:, :, ::-1]) + root.mainloop() diff --git a/artemis/plotting/tk_utils/constants.py b/artemis/plotting/tk_utils/constants.py new file mode 100644 index 00000000..7879c52b --- /dev/null +++ b/artemis/plotting/tk_utils/constants.py @@ -0,0 +1,29 @@ +class UIColours: + BLACK = '#000000' + WHITE = '#ffffff' + RED = '#cc0000' + PALE_RED = '#ff8888' + DARK_GREY = '#222222' + DARKISH_GREY = '#444444' + GREY = '#666666' + DARK_BLUE = '#000066' + EYE_BLUE = "#3388ff" + EYE_BLUE_FADED = "#66AAff" + YELLOW = "#ffff00" + + +class ThemeColours: + BACKGROUND = UIColours.DARK_GREY + MIDGROUND = UIColours.GREY + SELECTED_ROW_BACKGROUND = UIColours.EYE_BLUE_FADED + ROW_BACKGROUND = UIColours.DARKISH_GREY + TITLE_BACKGROUND = UIColours.DARK_GREY + TEXT = UIColours.WHITE + LINK = UIColours.EYE_BLUE + BUTTON_TEXT = UIColours.EYE_BLUE + HIGHLIGHT_COLOR = UIColours.EYE_BLUE + ATTENTION_COLOR = UIColours.RED + BUTTON_TEXT_DEFAULT= UIColours.BLACK + TOOLTIP_BACKGROUND = UIColours.EYE_BLUE_FADED + ERROR_COLOR = UIColours.PALE_RED + WARNING_COLOR = UIColours.YELLOW diff --git a/artemis/plotting/tk_utils/dual_panel_frame.py b/artemis/plotting/tk_utils/dual_panel_frame.py new file mode 100644 index 00000000..f62e0920 --- /dev/null +++ b/artemis/plotting/tk_utils/dual_panel_frame.py @@ -0,0 +1,123 @@ +import tkinter as tk +from typing import Optional, Callable, Tuple + +from artemis.general.custom_types import BGRImageArray +from artemis.image_processing.image_utils import ImageViewInfo +from artemis.plotting.tk_utils.alternate_zoomable_image_view import ZoomableImageFrame +from artemis.plotting.tk_utils.constants import ThemeColours +from artemis.plotting.tk_utils.ui_utils import RespectableButton, ToggleLabel + + +class DualPanelFrame(tk.Frame): + + def __init__(self, + parent_frame: tk.Frame, + lock_view_frames: bool = False, + left_label: str = "", + right_label: str = "", + frame_click_callback: Optional[Callable[[Tuple[int, int], int], None]] = None + ): + + tk.Frame.__init__(self, parent_frame, bg=ThemeColours.BACKGROUND) + self.columnconfigure(0, weight=1) + self.columnconfigure(1, weight=1) + self.rowconfigure(0, weight=0) + self.rowconfigure(1, weight=1) + self._lock_view_frames = lock_view_frames + + # control_frame = tk.Frame(self, bg=ThemeColours.BACKGROUND) + # control_frame.grid(row=0, column=0, columnspan=2, sticky=tk.EW) + # + # # TkImageWidget(control_frame, image=cv2.imread(AssetFiles.LOGO_IMAGE_PATH_SQUARE), width=50).pack(side=tk.LEFT) + # + # tk.Label(control_frame, text="Eagle Eyes Roamer", bg=ThemeColours.BACKGROUND, fg=ThemeColours.TEXT, justify=tk.CENTER, font=("Helvetica", 24) + # ).pack(side=tk.LEFT, fill=tk.X, expand=True) + + # self._export_button = RespectableButton( + # control_frame, + # text="Export Situation Map as GeoTiff", + # shortcut='e', + # command=lambda: print("Exporting...") + # ) + # self._export_button.pack(side=tk.RIGHT) + + # recompute_button = tk.Label(control_frame, text="Recompute (P)", relief=tk.RAISED, cursor='hand2', bg=ThemeColours.BACKGROUND, fg=ThemeColours.TEXT) + # recompute_button.pack(side=tk.RIGHT) + + # clear_button = tk.Label(control_frame, text="Clear (Y)", relief=tk.RAISED, cursor='hand2', bg=ThemeColours.BACKGROUND, fg=ThemeColours.TEXT) + # clear_button.bind("", lambda e: self._clear_labels()) + # clear_button.pack(side=tk.RIGHT) + + self.left_view = ZoomableImageFrame(self, after_view_change_callback=lambda vf: self._set_view_frame(vf, view=self.right_view), double_click_callback=lambda xy: self._on_frame_click(xy, 0)) + self.left_view.grid(column=0, row=1, sticky=tk.NSEW) + tk.Label(self, text=left_label, bg=ThemeColours.BACKGROUND, fg=ThemeColours.TEXT).grid(column=0, row=2, sticky=tk.NSEW) + self.right_view = ZoomableImageFrame(self, after_view_change_callback=lambda vf: self._set_view_frame(vf, view=self.left_view), double_click_callback=lambda xy: self._on_frame_click(xy, 1)) + self.right_view.grid(column=1, row=1, sticky=tk.NSEW) + tk.Label(self, text=right_label, bg=ThemeColours.BACKGROUND, fg=ThemeColours.TEXT).grid(column=1, row=2, sticky=tk.NSEW) + + self._frame_click_callback = frame_click_callback + + # def mark_keypoint_on_map(self, event: tk.Event): + # current_frame = self.map_view.get_image_view_frame_or_none() + # if current_frame is not None: + # pixel_xy = current_frame.display_xy_to_pixel_xy((event.x, event.y)) + # self._map_keypoints[len(self._map_keypoints)] = tuple(int(d) for d in pixel_xy) + # + # self._redraw() + + # def _clear_labels(self): + # if self._imseg is not None and self._selected_heatmap is not None: + # self._imseg.segmentation_heatmaps[self._selected_heatmap].label_map[:, :] = 0 + # self._imseg.segmentation_heatmaps[self._selected_heatmap].prob_map[:, :] = 0. + # del self._cached_prob_maps_images[self._selected_heatmap] + # self._redraw() + # + # def _mark_keypoint(self, event: tk.Event): + # + # point_id = int(event.char) + # is_in_image_frame = event.widget is self.left_view + # is_in_map_frame = event.widget is self.right_view + # widget: Optional[ZoomableImageFrame] = self.left_view if is_in_image_frame else self.right_view if is_in_map_frame else None + # if widget is None: + # return + # current_frame = widget.get_image_view_frame_or_none() + # pixel_xy = current_frame.display_xy_to_pixel_xy((event.x, event.y)) + # pixel_xy = tuple(int(d) for d in pixel_xy) + # if is_in_image_frame: + # self._image_keypoints[point_id] = pixel_xy + # print("Image Keypoints: {}".format(self._image_keypoints)) + # elif is_in_map_frame: + # self._map_keypoints[point_id] = pixel_xy + # print("Map Keypoints: {}".format(self._map_keypoints)) + # + # self._redraw(redraw_map=is_in_map_frame, redraw_image=is_in_image_frame) + + # current_frame = self.map_view.get_image_view_frame_or_none() + # if current_frame is not None and self._imseg is not None and self._selected_heatmap is not None: + # pixel_xy = current_frame.display_xy_to_pixel_xy((event.x, event.y)) + # # draw_circle_on_label_inplace(self._imseg.segmentation_heatmaps[self._selected_heatmap].label_map, pixel_xy, radius=radius, fill_value=value) + # self._redraw() + + def _on_frame_click(self, xy: Tuple[int, int], side: int): + if self._frame_click_callback is not None: + self._frame_click_callback(xy, side) + + + def _set_view_frame(self, image_view_info: ImageViewInfo, view: ZoomableImageFrame): + if self._lock_view_frames and view.get_image_view_frame_or_none() != image_view_info: + view.set_image_frame(image_view_info) + + def update_left(self, image: BGRImageArray): + self.left_view.set_image(image) + + def update_right(self, image: BGRImageArray): + self.right_view.set_image(image) + # + # def _redraw(self, redraw_left: bool = True, redraw_right: bool = True): + # if redraw_left: + # self.left_view.set_image(image) + # if redraw_right and self. is not None: + # self.right_view.set_image(map_image) + + + diff --git a/artemis/plotting/tk_utils/machine_utils.py b/artemis/plotting/tk_utils/machine_utils.py new file mode 100644 index 00000000..34aff6a4 --- /dev/null +++ b/artemis/plotting/tk_utils/machine_utils.py @@ -0,0 +1,4 @@ +import os + +def is_windows_machine() -> bool: + return os.name == 'nt' diff --git a/artemis/plotting/tk_utils/test_tk_utils.py b/artemis/plotting/tk_utils/test_tk_utils.py new file mode 100644 index 00000000..4d1b7e07 --- /dev/null +++ b/artemis/plotting/tk_utils/test_tk_utils.py @@ -0,0 +1,15 @@ +import time +from artemis.plotting.tk_utils.tk_utils import BlockingTaskDialogFunction, hold_tkinter_root_context + + +def test_show_blocking_task_dialog(): + + with hold_tkinter_root_context() as window: + BlockingTaskDialogFunction( + n_steps=100, + message="Milking cows, please wait..." + ).show_blocking_task_dialog(time.sleep(0.01) for _ in range(100)) + + +if __name__ == "__main__": + test_show_blocking_task_dialog() \ No newline at end of file diff --git a/artemis/plotting/tk_utils/tk_error_dialog.py b/artemis/plotting/tk_utils/tk_error_dialog.py new file mode 100644 index 00000000..440a7859 --- /dev/null +++ b/artemis/plotting/tk_utils/tk_error_dialog.py @@ -0,0 +1,169 @@ +import tkinter as tk +import webbrowser +from tkinter import messagebox +from typing import Optional, Tuple +import traceback +import requests +from dataclasses import dataclass + + +# from video_scanner.app_utils.utils import ErrorDetail + +@dataclass +class ErrorDetail: + error: Exception + traceback: str + additional_info: str = "" + + + +def send_paste(error_message: str, pastebin_key: str) -> Tuple[bool, Optional[str]]: + """ Does not work... """ + api_url = 'https://pastebin.com/api/api_post.php' + paste_name = 'Error Traceback' + data = { + 'api_dev_key': pastebin_key, + 'api_option': 'paste', + 'api_paste_code': error_message, + 'api_paste_name': paste_name, + 'api_paste_private': 1, # 0=public, 1=unlisted, 2=private + 'api_paste_expire_date': '1M', + + } + response = requests.post(api_url, data=data) + if response.status_code == 200: # success + return True, response.text + else: + print('Failed to create paste. Status:', response.status_code) + return False, response.text + +def open_url(url): + webbrowser.open(url) + + +def tk_show_error_dialog(exception: Exception, title: str = "Error", message: Optional[str] = None, traceback_str: Optional[str] = None, + online_help_url: Optional[str] = None, pastebin_key: Optional[str] = None, + reporting_email: Optional[str] = None, as_root: bool = False + ): + + default_width = 640 + # Define the behavior for "Report and Close" + def report_and_close(): + if pastebin_key and traceback_str: + response = send_paste(traceback_str, pastebin_key) + messagebox.showinfo(title="Reported", message=f"Error reported. Thank you!") + root.destroy() + + # Define the behavior for "Close" + def close(): + root.destroy() + + root = tk.Tk() if as_root else tk.Toplevel() + root.title(title) + # Center in parent window and make it a minimum with of 640 + root.geometry(f"+{int(root.winfo_screenwidth() / 2 - default_width//2)}+{int(root.winfo_screenheight() / 2 - 240)}") + + # Show the error message + # messagebox.showerror(title, str(exception)) + + top_row = tk.Frame(root) + top_row.pack(side=tk.TOP, fill=tk.X, expand=True) + + # Put error icon on left and make it 50x50 pixels. # TODO: Figure out how to make this reference survive pyinstaller + # error_icon_relpath = os.path.abspath(os.path.join(os.path.dirname(__file__), "error-icon.png")) + # if os.path.exists(error_icon_relpath): + # error_icon = tk.PhotoImage(file=error_icon_relpath).subsample(4, 4) + # tk.Label(top_row, image=error_icon).grid(row=0, column=0, rowspan=2, sticky=tk.W) + + # Show title and error on right and make it stretch + # tk.Label(top_row, text=title).grid(row=0, column=1, sticky=tk.EW, ) + # tk.Label(top_row, text=str(exception), wraplength=120).grid(row=1, column=1, sticky=tk.EW) + # Make it wrap to the default width of the window + message_text = message+'\n'+str(exception) if message else str(exception) + tk.Label(top_row, text=message_text, wraplength=default_width).grid(row=0, column=1, sticky=tk.EW, columnspan=2) + + # Show the error message + # tk.Label(root, text=title).pack() + # tk.Label(root, text=str(exception)).pack() + + # Make a read only entry with the traceback in it + if traceback_str: + full_message_str = (message+'\n\n' if message else '')+traceback_str + print(full_message_str) + tk.Label(root, text="Error Info:").pack(side=tk.TOP, anchor=tk.W) + traceback_entry = tk.Text(root, height=10, width=100) + traceback_entry.insert(tk.END, full_message_str) + traceback_entry.configure(state='disabled') + traceback_entry.pack(expand=True, fill=tk.BOTH, side=tk.TOP) + + # Show the online help URL + # Online help link + if online_help_url: + link = tk.Label(root, text="Online Help", fg="blue", cursor="hand2") + link.pack() + link.bind("", lambda e: open_url(online_help_url)) + + if reporting_email: + # messagebox.showinfo(title="Report Error", message="Would you like to report this error?") + # tk.Label(root, text=f"Please copy info and email to {reporting_email}").pack() + # Make it a clickable email link with a mailto: + link = tk.Label(root, text=f"Please report this to {reporting_email}", fg="blue", cursor="hand2") + link.pack() + link.bind("", lambda e: open_url(f"mailto:{reporting_email}?subject=Error Report&body={traceback_str}")) + + button_row = tk.Frame(root) + button_row.pack(side=tk.BOTTOM) + + # Add a button to copy the traceback + if traceback_str: + def copy_traceback(): + root.clipboard_clear() + root.clipboard_append(traceback_str) + email_note = f"\n\nPlease email this to {reporting_email}" if reporting_email else "" + messagebox.showinfo(title="Copied", message=f"Error info copied to clipboard. "+email_note) + + copy_button = tk.Button(button_row, text="Copy Info", command=copy_traceback) + copy_button.pack(side=tk.LEFT) + + + # If there is a pastebin key + if pastebin_key: + # messagebox.showinfo(title="Report Error", message="Would you like to report this error?") + report_button = tk.Button(button_row, text="Report and Close", command=report_and_close) + report_button.pack(side=tk.LEFT) + + # Close button + close_button = tk.Button(button_row, text="Close", command=close) + close_button.pack(side=tk.LEFT) + + # On top + root.attributes("-topmost", True) + print("Starting error dialog") + root.mainloop() if as_root else root.wait_window() + print("Exiting error dialog") + + +def tk_show_eagle_eyes_error_dialog(error_details: ErrorDetail): + trace_str = error_details.traceback or traceback.format_exc() + print(trace_str) + tk_show_error_dialog( + exception=error_details.error, + title='Error', + traceback_str=trace_str, + message=f"Error: {str(error_details.error)}"+(f"\n\nAdditional Info:\n{error_details.additional_info}" if error_details.additional_info else '') + ) + + + +if __name__ == "__main__": + try: + from video_scanner.app_utils.constants import EAGLE_EYES_EMAIL_ADDRESS, EAGLE_EYES_SCAN_PAGE_URL, PASTEBIN_KEY + + raise Exception("Intentional error") + except Exception as err: + tk_show_error_dialog( + exception=err, + traceback_str=traceback.format_exc(), + online_help_url=EAGLE_EYES_SCAN_PAGE_URL, + pastebin_key=PASTEBIN_KEY + ) diff --git a/artemis/plotting/tk_utils/tk_utils.py b/artemis/plotting/tk_utils/tk_utils.py new file mode 100644 index 00000000..c5784cc7 --- /dev/null +++ b/artemis/plotting/tk_utils/tk_utils.py @@ -0,0 +1,639 @@ +import tkinter as tk +import traceback +from collections import deque +from contextlib import contextmanager +from dataclasses import dataclass, field +from functools import partial +from tkinter import Widget +from typing import Iterable, Optional, Tuple, Mapping, TypeVar +from typing import Mapping, Callable, Union, Any, Optional, Sequence, Tuple, List + +from artemis.general.measuring_periods import PeriodicChecker +from artemis.plotting.tk_utils.tk_error_dialog import ErrorDetail +from artemis.plotting.tk_utils.ui_utils import RespectableButton +from artemis.plotting.tk_utils.constants import UIColours, ThemeColours + + +# from artemis.plotting.tk_utils.bitmap_view import TkImageWidget +# from video_scanner.utils import ResourceFiles + + +# class TkKeys(enum.Enum): +# # List of Tkinter key names (Thank you ChatGPT) +# KEY_1 = '1' +# KEY_2 = '2' +# KEY_3 = '3' +# KEY_4 = '4' +# KEY_5 = '5' +# KEY_6 = '6' +# KEY_7 = '7' +# KEY_8 = '8' +# KEY_9 = '9' +# KEY_0 = '0' +# A = 'a' +# B = 'b' +# C = 'c' +# D = 'd' +# E = 'e' +# F = 'f' +# G = 'g' +# H = 'h' +# I = 'i' +# J = 'j' +# K = 'k' +# L = 'l' +# M = 'm' +# N = 'n' +# O = 'o' +# P = 'p' +# Q = 'q' +# R = 'r' +# S = 's' +# T = 't' +# U = 'u' +# V = 'v' +# W = 'w' +# X = 'x' +# Y = 'y' +# Z = 'z' +# SPACE = 'space' +# LEFT = 'left' +# RIGHT = 'right' +# UP = 'up' +# DOWN = 'down' +# RETURN = 'return' +# ESCAPE = 'escape' +# BACKSPACE = 'backspace' +# TAB = 'tab' +# CAPSLOCK = 'capslock' +# SHIFT = 'shift' +# CONTROL = 'control' +# OPTION = 'option' +# COMMAND = 'command' +# FN = 'fn' +# F1 = 'f1' +# F2 = 'f2' +# F3 = 'f3' +# F4 = 'f4' +# F5 = 'f5' +# F6 = 'f6' +# F7 = 'f7' +# F8 = 'f8' +# F9 = 'f9' +# F10 = 'f10' +# F11 = 'f11' +# F12 = 'f12' +# +# def __add__(self, other): +# return other.__radd__(self) +# +# def __radd__(self, other): +# if not all(o in (self.COMMAND, self.CONTROL, self.SHIFT, self.OPTION) for o in other.split('-')): +# raise BadModifierError(f"Key {self} is not a modifier") +# return f"{other.value}-{self.value}" + + +class BadModifierError(Exception): + """ Raised when you try to use an invalidmodifier """ + + +def wrap_ui_callback_with_handler( + callback: Callable[[tk.Event], Any], + error_handler: Callable[[ErrorDetail], None], + info_string: Optional[str] = None, + reraise: bool = True +) -> Callable[[tk.Event], Any]: + def wrapped_callback(event: tk.Event) -> Any: + try: + callback(event) + except Exception as err: + traceback_str = traceback.format_exc() + details = ErrorDetail(error=err, traceback=traceback_str, additional_info=info_string) + error_handler(details) + if reraise: + raise err + + return wrapped_callback + + +def bind_callbacks_to_widget( + callbacks: Mapping[Union[str], Callable[[tk.Event], Any]], + widget: tk.Widget, + bind_all: bool = False, + error_handler: Optional[Callable[[ErrorDetail], None]] = None): + for key, callback in callbacks.items(): + # widget.bind(f"<{key.strip('<>')}>", callback) + key_binding_str = f"<{key.strip('<>')}>" + if error_handler is not None: + callback = wrap_ui_callback_with_handler(callback, error_handler, info_string=f"Error while pressing {key_binding_str}") + widget.bind_all(key_binding_str, callback) if bind_all else widget.bind(f"<{key.strip('<>')}>", callback) + + +def destroy_all_descendents(widget: Widget): + for c in widget.winfo_children(): + destroy_all_descendents(c) + c.destroy() + + +class OptionDialog(tk.Toplevel): + """ + This dialog accepts a list of options. + If an option is selected, the results property is to that option value + If the box is closed, the results property is set to zero + """ + + def __init__(self, title, question, options): + parent = tk.Toplevel() + tk.Toplevel.__init__(self, parent) + self.title(title) + self.question = question + self.transient(parent) + self.protocol("WM_DELETE_WINDOW", self.cancel) + self.options = options + self.result = '_' + self.createWidgets() + self.grab_set() + ## wait.window ensures that calling function waits for the window to + ## close before the result is returned. + self.wait_window() + + def createWidgets(self): + frmQuestion = tk.Frame(self) + tk.Label(frmQuestion, text=self.question).grid() + frmQuestion.grid(row=1) + frmButtons = tk.Frame(self) + frmButtons.grid(row=2) + column = 0 + for option in self.options: + btn = tk.Button(frmButtons, text=option, command=lambda x=option: self.setOption(x)) + btn.grid(column=column, row=0) + column += 1 + + def setOption(self, optionSelected): + self.result = optionSelected + self.destroy() + + def cancel(self): + self.result = None + self.destroy() + + +# from tkinter import * + +@dataclass +class OptionInfo: + text: str + help_text: Optional[str] + shortcut: Optional[str] + callback: Optional[Callable[[], Any]] + + def get_additional_info(self) -> str: + description = '' + if self.help_text: + description += f"{self.help_text}" + if self.shortcut: + description += f" ({self.shortcut})" + return description + + +_OPTION_SELECT_OVERRIDE_QUEUE: Optional[List[str]] = None + +@contextmanager +def hold_option_select_override(*option_select_queue: str): + global _OPTION_SELECT_OVERRIDE_QUEUE + old_queue = _OPTION_SELECT_OVERRIDE_QUEUE + _OPTION_SELECT_OVERRIDE_QUEUE = deque((list(old_queue) if old_queue is not None else [])+list(option_select_queue)) + try: + yield + finally: + _OPTION_SELECT_OVERRIDE = old_queue + + +@dataclass +class TkOptionListBuilder: + _options: List[OptionInfo] = field(default_factory=list) + _default_option: Optional[OptionInfo] = None # Must be one of options or will be ignored + + def add_option(self, text: str, help_text: Optional[str] = None, shortcut: Optional[str] = None, + is_default: bool = False, callback: Optional[Callable[[], Any]] = None) -> OptionInfo: + new_option = OptionInfo(text=text, help_text=help_text, shortcut=shortcut, callback=callback) + self._options.append(new_option) + if is_default: + self._default_option = new_option + return new_option + + def ui_select_option(self, + message: str = "Select one:", + title: str = "Select an option", + as_column_with_help: bool = False, + add_cancel_button: bool = False, + min_size_xy: Tuple[int, int] = (400, 300), + wrap_text_to_window_size: bool = True, + n_button_cols: int = 3, + call_callback_on_selection: bool = False, + font: Optional[Tuple[str, int]] = None, # e.g. ('Helvetica', 24) + ) -> Optional[OptionInfo]: + if len(self._options)==0: + raise Exception("No options have been provided to select from. Call add_option first.") + + result: Optional[OptionInfo] = None + default = self._default_option + + def buttonfn(op: OptionInfo): + nonlocal result + if op == cancel_option: + result = None + else: + result = op + # choicewin.quit() + choicewin.destroy() + + choicewin = tk.Toplevel() + # Keep this window on top + + choicewin.minsize(*min_size_xy) + choicewin.resizable(False, False) + choicewin.title(title) + + # Position in center of main application window + choicewin.geometry(f"+{int(choicewin.winfo_screenwidth() / 2 - min_size_xy[0] / 2)}+" + f"{int(choicewin.winfo_screenheight() / 2 - min_size_xy[1] / 2)}") + + parent_frame = tk.Frame(choicewin) + parent_frame.pack(expand=True) + + options = list(self._options) + + cancel_option = OptionInfo(text="Cancel", help_text=None, shortcut='', callback=None) + if add_cancel_button: + options += [cancel_option] + + # Align the text to the left, and wrap to the window width + tk.Label(parent_frame, text=message, anchor=tk.W, wraplength=min_size_xy[0] if wrap_text_to_window_size else None, font=font)\ + .grid(row=0, column=0, sticky=tk.EW, columnspan=min(n_button_cols, len(options))) + + # Change above into loop which assigns to tk_buttons + tk_buttons = [] + + def add_button_for_option(op: OptionInfo, add_tooltip: bool) -> RespectableButton: + is_default = op == self._default_option + button = RespectableButton(parent_frame, + text=op.text, + command=partial(buttonfn, op), + default=tk.ACTIVE if is_default else tk.NORMAL, + # Make button highlighted if default + # borderwidth=1, + # border=1, + highlightthickness=1, + # padx=2, + # pady=2, + highlightbackground=ThemeColours.HIGHLIGHT_COLOR if is_default else None, + # highlightcolor=ThemeColours.HIGHLIGHT_COLOR if op == self._default_option else None, + tooltip=op.get_additional_info() or None if add_tooltip else None, + font=font) + if is_default: + button.focus_set() + tk_buttons.append(button) + return button + + if as_column_with_help: + for i, op in enumerate(options): + add_button_for_option(op=op, add_tooltip=False).grid(row=i+1, column=0, sticky=tk.NSEW) + tk.Label(parent_frame, text=op.get_additional_info(), justify=tk.LEFT, wraplength=min_size_xy[0]*3//4).grid(row=i+1, column=1, sticky=tk.NSEW) + else: + for i, op in enumerate(options): + add_button_for_option(op=op, add_tooltip=True).grid(row=i // n_button_cols + 1, column=i % n_button_cols, sticky=tk.NSEW) + + # Change the selected button with arrow keys + def change_selected_button(event: tk.Event): + nonlocal default + print(f"Key pressed: {event.keysym}") + if event.keysym in ('Left', 'Up'): + default = self._options[(self._options.index(default) - 1) % len(self._options)] if default is not None else self._options[-1] + elif event.keysym in ('Right', 'Down'): + default = self._options[(self._options.index(default) + 1) % len(self._options)] if default is not None else self._options[0] + for i, choice in enumerate(self._options): + if choice == default: + print(f"Changing focus to button {i}") + tk_buttons[i].focus_set() + tk_buttons[i].config(default=tk.ACTIVE) + tk_buttons[i].config(highlightbackground=ThemeColours.HIGHLIGHT_COLOR) + else: + tk_buttons[i].config(default=tk.NORMAL) + # tk_buttons[i].config(highlightbackground=None) + # tk_buttons[i].config(highlightbackground=ThemeColours.BACKGROUND) + # No - need to get the actual default color from tkinter itself + tk_buttons[i].config(highlightbackground='systemWindowBackgroundColor') + + # tk_buttons = parent_frame.winfo_children()[1:] + choicewin.bind('', change_selected_button) + choicewin.bind('', change_selected_button) + choicewin.bind('', change_selected_button) + choicewin.bind('', change_selected_button) + + # If enter is pressed, select the default option: + choicewin.bind('', lambda event: buttonfn(default) if default else None) + + # # If escape is pressed, cancel: + choicewin.bind('', lambda event: buttonfn(cancel_option)) + + # choicewin.focus_set() + + # choicewin.grab_set() + if _OPTION_SELECT_OVERRIDE_QUEUE is not None: # Should only be true in tests + try: + option = _OPTION_SELECT_OVERRIDE_QUEUE.popleft() + except IndexError: + raise Exception(f"Tried using the option override queue in dialog with title:\n '{title}'\nand options:\n {[op.text for op in self._options]} but the queue was empty.") + option_selected = next((op for op in self._options if op.text == option), None) + assert option_selected is not None, f"Option {option} not found in options: {[op.text for op in self._options]}" + buttonfn(option_selected) + else: + # Get it to stay on top! + choicewin.attributes('-topmost', True) + + choicewin.wait_window() # With this line commented in... CRASH (exit code 0, no message) + # choicewin.mainloop() # With this line... NO CRASH! + # choicewin.grab_release() + + if call_callback_on_selection and result is not None and result.callback: + result.callback() + + print("Got result:", result) + + return result + + +def tk_select_option(choicelist: Sequence[str], + message: str = "Select one:", + title: str = "Select an option", + default: Optional[str] = None, + add_cancel_button: bool = False, + min_size_xy: Tuple[int, int] = (400, 300), + wrap_text_to_window_size: bool = True, + n_button_cols: int = 3, + ) -> Optional[str]: + + option_builder = TkOptionListBuilder() + + # options = [option_builder.add_option(text=choice, is_default=choice==default) for choice in choicelist] + for choice in choicelist: + option_builder.add_option(text=choice, is_default=choice == default) + + selected = option_builder.ui_select_option( + message=message, title=title, add_cancel_button=add_cancel_button, min_size_xy=min_size_xy, + wrap_text_to_window_size=wrap_text_to_window_size, n_button_cols=n_button_cols + ) + return selected.text if selected is not None else None + + # for choice in choicelist: + # opti + # + # result = None + # + # def buttonfn(choice: str): + # nonlocal result + # if add_cancel_button and choice == 'Cancel': + # result = None + # else: + # result = choice + # choicewin.quit() + # choicewin.destroy() + # + # choicewin = tk.Toplevel() + # # Keep this window on top + # + # choicewin.minsize(*min_size_xy) + # choicewin.resizable(False, False) + # choicewin.title(title) + # + # # Position in center of main application window + # choicewin.geometry(f"+{int(choicewin.winfo_screenwidth() / 2 - min_size_xy[0] / 2)}+" + # f"{int(choicewin.winfo_screenheight() / 2 - min_size_xy[1] / 2)}") + # + # parent_frame = tk.Frame(choicewin) + # # parent_frame.grid(row=0, column=0, sticky=tk.NSEW) + # parent_frame.pack( expand=True) + # + # if add_cancel_button: + # choicelist = list(choicelist) + ['Cancel'] + # + # # tk.Label(parent_frame, text=message).grid(row=0, column=0, sticky=tk.EW, columnspan=len(choicelist)) + # # Align the text to the left, and wrap to the window width + # tk.Label(parent_frame, text=message, anchor=tk.W, wraplength=min_size_xy[0] if wrap_text_to_window_size else None)\ + # .grid(row=0, column=0, sticky=tk.EW, columnspan=len(choicelist)) + # # tk.Button(parent_frame, text=choice, command=partial(buttonfn, choice)).grid(row=i // n_button_cols + 1, column=i % n_button_cols) + # + # + # # tk_buttons = [tk.Button(parent_frame, text=choice, command=partial(buttonfn, choice), default=tk.ACTIVE if choice == default else tk.NORMAL)\ + # # .grid(row=i // n_button_cols + 1, column=i % n_button_cols, sticky=tk.NSEW) for i, choice in enumerate(choicelist)] + # + # # Change above into loop which assigns to tk_buttons + # tk_buttons = [] + # for i, choice in enumerate(choicelist): + # tk_buttons.append(tk.Button(parent_frame, text=choice, command=partial(buttonfn, choice), default=tk.ACTIVE if choice == default else tk.NORMAL)) + # tk_buttons[-1].grid(row=i // n_button_cols + 1, column=i % n_button_cols, sticky=tk.NSEW) + # + # # Change the selected button with arrow keys + # def change_selected_button(event: tk.Event): + # nonlocal default + # print(f"Key pressed: {event.keysym}") + # if event.keysym == 'Left': + # default = choicelist[(choicelist.index(default) - 1) % len(choicelist)] + # elif event.keysym == 'Right': + # default = choicelist[(choicelist.index(default) + 1) % len(choicelist)] + # for i, choice in enumerate(choicelist): + # if choice == default: + # print(f"Changing focus to button {i}") + # tk_buttons[i].focus_set() + # tk_buttons[i].config(default=tk.ACTIVE) + # else: + # tk_buttons[i].config(default=tk.NORMAL) + # + # # tk_buttons = parent_frame.winfo_children()[1:] + # choicewin.bind('', change_selected_button) + # choicewin.bind('', change_selected_button) + # + # # If enter is pressed, select the default option: + # if default is not None: + # choicewin.bind('', lambda event: buttonfn(default)) + # + # # If escape is pressed, cancel: + # choicewin.bind('', lambda event: buttonfn(None)) + # + # # choicewin.focus_set() + # + # choicewin.grab_set() + # choicewin.mainloop() + # # choicewin.wait_window() + # choicewin.grab_release() + # return result + + +def tk_indicate_focus_with_border(has_focus: bool, widget: tk.Frame, color: str = UIColours.EYE_BLUE, non_highlight_color: str = ThemeColours.BACKGROUND, border_thickness: int = 3): + if has_focus: + widget.config(highlightthickness=border_thickness, highlightcolor=color, highlightbackground=color) + else: + # widget.config(highlightthickness=0) + widget.config(highlightcolor=non_highlight_color, highlightbackground=non_highlight_color) # We keep the thickness to avoid all these reconfigurations + + +def get_focus_indicator_callbacks(widget: tk.Frame, color: str = UIColours.EYE_BLUE, border_thickness: int = 3) -> Mapping[str, Callable]: + return { + '': lambda event: tk_indicate_focus_with_border(has_focus=True, widget=widget, color=color, border_thickness=border_thickness), + '': lambda event: tk_indicate_focus_with_border(has_focus=False, widget=widget, color=color, border_thickness=border_thickness), + } + + +def tk_info_box(message: str, title: str = "Info"): + tk_select_option(message=message, choicelist=["Ok"], title=title) + + +def tk_yesno_box(message: str, title: str = "") -> bool: + return tk_select_option(message=message, choicelist=["Yes", "No"], title=title) == "Yes" + + +def tk_show_error(message: str, title: str = "Error"): + return tk_select_option(message=message, choicelist=["Ok"], title=title) + + +ItemType = TypeVar('ItemType') + + +@dataclass +class BlockingTaskDialogFunction: + n_steps: Optional[int] = None + max_update_period: float = 0.2 + message: str = "Processing... please wait." + + def show_blocking_task_dialog( + self, + blocking_task_iterator: Iterable[ItemType], + ) -> Optional[Sequence[ItemType]]: + + window = tk.Toplevel() + # window.geometry('400x250') + # Make that a minimum size for each dimention + window.minsize(400, 250) + tk.Label(window, text=self.message).pack(fill=tk.BOTH, expand=True) + progress_label = tk.Label(window, font=('Helvetica', 24)) + progress_label.pack(fill=tk.BOTH, expand=True) + + def cancel(): + nonlocal items + items = None + + tk.Button(window, text='Cancel', command=cancel).pack(pady=10) + + checker = PeriodicChecker(interval=self.max_update_period) + + window.update() + items: Optional[List[ItemType]] = [] + for i, item in enumerate(blocking_task_iterator, start=1): + if checker.is_time_for_update(): + progress_label.config(text=f'{i} / {self.n_steps or "?"}' + (f" ({i / self.n_steps:.0%})" if self.n_steps else '')) + window.update() + if items is None: + break + items.append(item) + window.destroy() + return items + + +class CollapseableWidget(tk.Widget): + + def __init__(self, master: tk.Frame, collapse_initially: bool = False, **kwargs): + super().__init__(master, **kwargs) + self._is_collapsed = collapse_initially + self._pack_manager_args_kwargs: Optional[Tuple[str, Tuple, Mapping]] = None + + def is_collapsed(self) -> bool: + return self._is_collapsed + + def pack(self, *args, **kwargs): + self._pack_manager_args_kwargs = ('pack', args, kwargs) + if self._is_collapsed: + return + super().pack(*args, **kwargs) + + def grid(self, *args, **kwargs): + self._pack_manager_args_kwargs = ('grid', args, kwargs) + if self._is_collapsed: + return + super().grid(*args, **kwargs) + + def set_collapsed(self, collapse_state): + # print("Setting collapsed to ", collapse_state) + self._is_collapsed = collapse_state + if collapse_state and self._pack_manager_args_kwargs is not None: + self.pack_forget() if self._pack_manager_args_kwargs[0] == 'pack' else self.grid_forget() + else: + if self._pack_manager_args_kwargs is None: + # print("Warning: Cannot uncollapse before first packing") + return + pack_manager, args, kwargs = self._pack_manager_args_kwargs + pack_func = self.pack if pack_manager == 'pack' else self.grid + pack_func(*args, **kwargs) + # print(f"Uncollapsed {self} with {pack_manager} with args {args} and kwargs {kwargs}") + + +class MessageListenerMixin: + + def __init__(self, message_listener: Optional[Callable[[str], None]] = None): + self.message_listener = message_listener if message_listener is not None else print + + def set_message_listener(self, listener: Callable[[str], None]): + self.message_listener = listener + + def _send_message(self, message: str): + if self.message_listener is not None: + self.message_listener(message) + + def _send_hint_message(self, message: str): + self._send_message(f"ℹī¸: {message}") + + +def assert_no_existing_root(): + assert tk._default_root is None, "A Tkinter root window already exists!" + + +_EXISTING_ROOT: Optional[tk.Tk] = None + + +@contextmanager +def hold_tkinter_root_context(): + """ A context manager that creates a Tk root and destroys it when the context is exited + Careful now: If you schedule something under root to run with widget.after, it may crash if the root is destroyed before it runs. + """ + # assert_no_existing_root() + global _EXISTING_ROOT + old_value = _EXISTING_ROOT + root = tk.Tk() if _EXISTING_ROOT is None else _EXISTING_ROOT + + try: + _EXISTING_ROOT = root + yield root + finally: + try: + if old_value is None: + _EXISTING_ROOT = None + root.destroy() + except tk.TclError: # This can happen if the root is destroyed before the context is exited + pass + + +if __name__ == '__main__': + # reply = messagebox.askyesnocancel(message="Wooooo") + + reply = tk_select_option(["one", "two", "three"]) + print("reply:", reply) + + # test the dialog + # root=tk.Tk() + # def run(): + # values = ['Red','Green','Blue','Yellow'] + # dlg = OptionDialog('TestDialog',"Select a color",values) + # print(dlg.result) + # # tk.Button(root,text='Dialog',command=run).pack() + # run() + # root.mainloop() \ No newline at end of file diff --git a/artemis/plotting/tk_utils/tkshow.py b/artemis/plotting/tk_utils/tkshow.py new file mode 100644 index 00000000..25f9c6e5 --- /dev/null +++ b/artemis/plotting/tk_utils/tkshow.py @@ -0,0 +1,105 @@ +import tkinter as tk +from typing import Union, Mapping, Optional + +import numpy as np + +from artemis.general.custom_types import BGRImageArray +from artemis.plotting.tk_utils.alternate_zoomable_image_view import ZoomableImageFrame +from artemis.plotting.tk_utils.tk_utils import hold_tkinter_root_context +from artemis.plotting.tk_utils.ui_utils import RespectableButton + + +class SwitchableImageViewer(tk.Frame): + + def __init__(self, master: tk.Frame): + tk.Frame.__init__(self, master=master) + # self.columnconfigure(0, weight=1) + # self.columnconfigure(1, weight=1) + # self.rowconfigure(0, weight=0) + # self.rowconfigure(1, weight=1) + + self.switch_button = RespectableButton( + master=self, + text='', + command=lambda: self.switch_view() + ) + # Right arrow to switch forward, left to switch back + self.bind_all('', lambda e: self.switch_view(1)) + self.bind_all('', lambda e: self.switch_view(-1)) + # self.switch_button.grid(row=0, column=0, sticky=tk.NSEW) + self.switch_button.pack() + self.image_view = ZoomableImageFrame(self) + # self.image_view.grid(row=1, column=0, sticky=tk.NSEW) + self.image_view.pack(fill=tk.BOTH, expand=tk.YES) + + self._current_images: Mapping[str, BGRImageArray] = {} + self._active_panel: Optional[str] = None + + def _panel_name_to_ix(self, name: str) -> int: + return list(self._current_images.keys()).index(name) + + def switch_view(self, increment: int = 1): + current_view_ix = self._panel_name_to_ix(self._active_panel) if self._active_panel is not None else -increment + next_view_ix = (current_view_ix + increment) % len(self._current_images) + panel = list(self._current_images.keys())[next_view_ix] + self.set_active_panel(panel) + + def set_active_panel(self, name: str): + self._active_panel = name + view_ix = self._panel_name_to_ix(name) + self.switch_button.configure(text=f"{name} ({view_ix+1}/{len(self._current_images)})") + self._redraw() + + def set_images(self, images: Mapping[str, BGRImageArray], active_panel: Optional[str] = None): + + + self._current_images = images + if active_panel is not None: + self.set_active_panel(active_panel) + elif self._active_panel is None: + self.set_active_panel(next(iter(images.keys()))) + self._redraw() + + def _redraw(self): + if self._current_images and self._active_panel is not None: + self.image_view.set_image(self._current_images[self._active_panel]) + # else: + # self.image_view.set_image(image=) + + + +def tkshow( + images: Union[BGRImageArray, Mapping[str, BGRImageArray]], + hang_time: Optional[int] = None, + title: Optional[str] = None, +): + + if isinstance(images, np.ndarray): + images = {'image': images} + + # + # for name, image in images.items(): + # if isinstance(image, tf.Tensor): + # images[name] = image.numpy() + + with hold_tkinter_root_context() as root: + # Set geometry + root.geometry("1200x800") + root.title(title or 'Left/Right to switch views, Z/X/C to zoom, W/A/S/D to pan, Escape to close') + + # top_level = tk.Toplevel() + # viewer = ZoomableImageFrame(root) + viewer = SwitchableImageViewer(root) + viewer.pack(fill=tk.BOTH, expand=tk.YES) + viewer.set_images(images) + # viewer.set_image(images['image']) + + # Close on Escape + viewer.bind_all('', lambda e: viewer.master.destroy()) + + if hang_time is not None: + root.after(int(hang_time)*1000, lambda: viewer.master.destroy()) + + viewer.mainloop() + return viewer + diff --git a/artemis/plotting/tk_utils/tooltip.py b/artemis/plotting/tk_utils/tooltip.py new file mode 100644 index 00000000..72e25613 --- /dev/null +++ b/artemis/plotting/tk_utils/tooltip.py @@ -0,0 +1,67 @@ +import tkinter as tk +from typing import Tuple + +from artemis.plotting.tk_utils.constants import ThemeColours + + +class ToolTip(object): + + def __init__(self, widget: tk.Widget, background: str = '#ffffe0', borderwidth: int = 1, font: Tuple[str, str, str] = ('tahom', '10', 'normal'), justify: str = 'left', relief: str = 'solid', text: str = '', anchor=tk.NW): + + self.widget = widget + self.tipwindow = None + self.id = None + self.x = self.y = 0 + self._background = background + self._borderwidth = borderwidth + self._font = font + self._font_size = int(font[1]) + self._justify = justify + self._relief = relief + self._text = text + self._below_cursor = 'n' in anchor.lower() + self._right_of_cursor = 'w' in anchor.lower() + + def showtip(self): + "Display text in tooltip window" + # self.text = text + if self.tipwindow or not self._text: + return + x, y, cx, cy = self.widget.bbox("insert") + x = x + self.widget.winfo_rootx() + (57 if self._right_of_cursor else -57 - len(self._text)*self._font_size*0.4) + y = y + cy + self.widget.winfo_rooty() + (27 if self._below_cursor else -27 - self._font_size) + self.tipwindow = tw = tk.Toplevel(self.widget) + tw.wm_overrideredirect(1) + tw.wm_geometry("+%d+%d" % (x, y)) + # Anchor window to left of cursor + # if self._right_of_cursor: + # tw.wm_geometry("+%d+%d" % (x+len(self._text), y)) + label = tk.Label(tw, text=self._text, justify=tk.RIGHT, + background=self._background, relief=tk.SOLID, borderwidth=1, + font=self._font) + label.pack(ipadx=1) + + def hidetip(self): + tw = self.tipwindow + self.tipwindow = None + if tw: + tw.destroy() + + +def show_toast_as_temporary_tooltip(text: str, widget: tk.Widget, duration_ms: int = 2000): + tooltip = ToolTip(widget, text=text, anchor=tk.NW, background=ThemeColours.TOOLTIP_BACKGROUND, borderwidth=1, font=('tahom', '14', 'normal'), justify='left', relief='solid') + tooltip.showtip() + widget.after(duration_ms, tooltip.hidetip) + + +def create_tooltip(widget, text, anchor=tk.NW, **kwargs): + toolTip = ToolTip(widget, text=text, anchor=anchor, **kwargs) + + def enter(event): + toolTip.showtip() + + def leave(event): + toolTip.hidetip() + + widget.bind('', enter) + widget.bind('', leave) \ No newline at end of file diff --git a/artemis/plotting/tk_utils/ui_choose_parameters.py b/artemis/plotting/tk_utils/ui_choose_parameters.py new file mode 100644 index 00000000..5a3a72a8 --- /dev/null +++ b/artemis/plotting/tk_utils/ui_choose_parameters.py @@ -0,0 +1,205 @@ +import traceback +from dataclasses import fields, dataclass, field +from functools import partial +from tkinter import filedialog, messagebox +from typing import TypeVar, Optional +import tkinter as tk +import os + +from artemis.plotting.tk_utils.constants import ThemeColours +from artemis.plotting.tk_utils.tooltip import create_tooltip + +ParametersType = TypeVar('ParametersType') + +def ui_choose_parameters( + params_type: type, # Some sort of dataclass (the class object) + initial_params: Optional[ParametersType] = None, + factory_reset_params: Optional[ParametersType] = None, + title: str = "Select Parameters", + prompt: str = "Hover mouse over for description of each parameter. Tab to switch fields, Enter to accept, Escape to cancel." + +) -> Optional[ParametersType]: + """ Load, edit, save, and return the settings. """ + + + chosen_params = params_type() if initial_params is None else initial_params + + window = tk.Toplevel() + # Set minimum width to 600px + window.minsize(800, 1) + # Make it fill parent + # window.grid_columnconfigure(0, weight=1) + window.grid_columnconfigure(1, weight=1) + + # window.geometry("800x500") + window.title(title) + + label = tk.Label(window, text=prompt) + label.grid(column=0, row=0, columnspan=2) + + var = {} + for i, f in enumerate(fields(params_type), start=1): + label = tk.Label(window, text=f.metadata['name'] if 'name' in f.metadata else f.name.replace("_", " ").capitalize() + ":") + create_tooltip(label, f.metadata.get("help", "No help available."), background=ThemeColours.HIGHLIGHT_COLOR) + label.grid(column=0, row=i) + + # Stre + # Depending on the type of the field, we'll need to do something different. + if f.type == str: # Entry + metadata_type = f.metadata.get("type", None) + var[f.name] = tk.StringVar(value=getattr(chosen_params, f.name)) + if metadata_type is None: + entry = tk.Entry(window, textvariable=var[f.name]) + elif metadata_type in ["file", "directory"]: + # Store rel stores relative to the default directory, so that if the program is moved, the path is still valid. + store_rel = f.metadata.get("store_relative", False) + default_directory = f.metadata.get("default_directory", None) + # if store_rel and default_directory is not None: + # var[f.name].set(os.path.join(default_directory, getattr(settings, f.name))) + entry = tk.Entry(window, textvariable=var[f.name]) + + def browse(v: tk.StringVar, for_directory: bool = False, default_directory: Optional[str] = None, store_rel: bool = False): + print(f"Default directory: {default_directory}") + path = filedialog.askdirectory(initialdir=default_directory) if for_directory else filedialog.askopenfilename(initialdir=default_directory) + if path: + if store_rel: + path = os.path.relpath(path, default_directory) + v.set(path) + browse_button = tk.Button(window, text="Browse", command=partial(browse, + v=var[f.name], + for_directory=metadata_type == "directory", + default_directory = default_directory, + store_rel=store_rel + )) + browse_button.grid(column=2, row=i, sticky="ew") + else: + raise NotImplementedError(f"Unknown metadata type {metadata_type}") + + if i==0: # Make it so keyboard is directed to this right away + entry.focus_set() + entry.grid(column=1, row=i, sticky="ew", ) + + elif f.type == bool: + var[f.name] = tk.BooleanVar(value=getattr(chosen_params, f.name)) + check_box = tk.Checkbutton(window, variable=var[f.name]) + # check_box.grid(column=1, row=i) + # Align left... + check_box.grid(column=1, row=i, sticky="w") + elif f.type == int: + var[f.name] = tk.IntVar(value=getattr(chosen_params, f.name)) + entry = tk.Entry(window, textvariable=var[f.name]) + entry.grid(column=1, row=i, sticky="w") + elif f.type == float: + var[f.name] = tk.DoubleVar(value=getattr(chosen_params, f.name)) + entry = tk.Entry(window, textvariable=var[f.name]) + entry.grid(column=1, row=i, sticky="w") + + else: + raise NotImplementedError(f"Type {f.type} not supported.") + + def read_settings_object() -> Optional[params_type]: + """ Read the settings object from the UI. """ + try: + settings_dict = {} + for f in fields(params_type): + settings_dict[f.name] = var[f.name].get() + return params_type(**settings_dict) + except Exception as e: + messagebox.showerror("Error", f"Error reading settings from UI:\n\n {e} \n\n(see Log)") + print(traceback.format_exc()) + return None + + # Ok, lets get buttons for "Cancel", "Update", and "Factory Reset", with Default being "save" + def cancel(): + nonlocal chosen_params + chosen_params = None + window.destroy() + + def ok(): + nonlocal chosen_params + new_settings = read_settings_object() + if new_settings is not None: + chosen_params = new_settings + window.destroy() + + def factory_reset(): + nonlocal chosen_params + window.destroy() + # new_settings = ui_load_edit_save_get_settings(factory_reset=True) + chosen_params = factory_reset_params + + button_row = tk.Frame(window) + button_row.grid(column=0, row=100, columnspan=3) + + + # cancel_button = tk.Button(window, text="Cancel", command=cancel) + # cancel_button.grid(column=0, row=100) + # update_button = tk.Button(window, text="Update", command=update) + # update_button.grid(column=1, row=100) + # factory_reset_button = tk.Button(window, text="Factory Reset", command=factory_reset) + # factory_reset_button.grid(column=2, row=100) + + # Set the focus on the first parameter + # var[fields(params_type)[0].name].focus_set() + + cancel_button = tk.Button(button_row, text="Cancel", command=cancel) + cancel_button.grid(column=0, row=0) + ok_button = tk.Button(button_row, text="Ok", command=ok, default=tk.ACTIVE) + ok_button.grid(column=1, row=0) + if factory_reset_params is not None: + factory_reset_button = tk.Button(button_row, text="Factory Reset", command=factory_reset) + factory_reset_button.grid(column=2, row=0) + + # Put on top + window.attributes('-topmost', True) + + window.focus_force() + ok_button.focus_set() + window.bind('', lambda event: ok_button.invoke()) + window.bind('', lambda event: cancel_button.invoke()) + + window.wait_window() + + return chosen_params + + +FieldType = TypeVar("FieldType", bound=object) + +nodefault = object() + +def ui_choose_field( + name: str, + dtype: type(FieldType), + default: Optional[FieldType] = nodefault, + title: str = "Choose Value", + prompt: str = "Choose a value ", + tooltip: Optional[str] = None +) -> FieldType: + + @dataclass + class TempClass: + tempfield: dtype = field(default=default, metadata=dict(help=tooltip, name=name)) + + result = ui_choose_parameters( + params_type=TempClass, + initial_params=TempClass(tempfield=default) if default is not nodefault else None, + title=title, + prompt=prompt + ) + return result.tempfield if result is not None else None + + + +if __name__ == "__main__": + + # @dataclass + # class MyParams: + # some_float: float = 4 + # some_int: int = field(default=3, metadata=dict(help="Select some integer")) + # some_file: str = field(default=os.path.expanduser("~/some_image.jpg"), metadata=dict(type='file')) + # + # result = ui_choose_parameters(params_type=MyParams) + # print(result) + + result = ui_choose_field('N neighbours', int, default=40) + print(result) \ No newline at end of file diff --git a/artemis/plotting/tk_utils/ui_utils.py b/artemis/plotting/tk_utils/ui_utils.py new file mode 100644 index 00000000..4cf93780 --- /dev/null +++ b/artemis/plotting/tk_utils/ui_utils.py @@ -0,0 +1,341 @@ +import os +import platform +import re +import subprocess +import tkinter as tk +import traceback +from contextlib import contextmanager +from typing import Callable, Any, TypeVar, Union, Tuple, Dict +from typing import Sequence, Optional + +import cv2 +from PIL import Image, ImageTk + + +from artemis.general.custom_types import BGRImageArray +from artemis.image_processing.image_builder import ImageBuilder +from artemis.image_processing.image_utils import BGRColors +from artemis.plotting.tk_utils.constants import ThemeColours +from artemis.plotting.tk_utils.tk_error_dialog import ErrorDetail +from artemis.plotting.tk_utils.tooltip import create_tooltip + + +def bgr_image_to_pil_image(image: BGRImageArray) -> Image: + cv2image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + return Image.fromarray(cv2image) + + +def bgr_image_to_tk_image(image: BGRImageArray) -> Image: + return ImageTk.PhotoImage(image=bgr_image_to_pil_image(image)) + + +_CACHED_STANDBY_IMAGES = {} + + +def get_awaiting_input_image(size_xy = (640, 480), text = f'Awaiting input\nClick on a video to view') -> BGRImageArray: + w, h = size_xy + global _AWAITING_INPUT_IMAGE + arg_tuple = (w, h, text) + if arg_tuple not in _CACHED_STANDBY_IMAGES: + if len(_CACHED_STANDBY_IMAGES) > 10: # just clear + _CACHED_STANDBY_IMAGES.clear() + + builder = ImageBuilder.from_blank((w, h), color=BGRColors.BLACK) + for i, line_text in enumerate(text.split('\n')): + builder = builder.draw_text(text=line_text, anchor_xy=(0.5, 0.5), loc_xy=(w//2, h//2-40+40*i), colour=BGRColors.WHITE) + _CACHED_STANDBY_IMAGES[arg_tuple] = builder.image + return _CACHED_STANDBY_IMAGES[arg_tuple] + + +def open_file_or_folder_in_system(path: str): + if platform.system() == "Windows": + os.startfile(path) + elif platform.system() == "Darwin": + subprocess.Popen(["open", path]) + else: + subprocess.Popen(["xdg-open", path]) + + +GLOBAL_TERMINATION_REQUEST = False + + +def set_global_termination_request(): + global GLOBAL_TERMINATION_REQUEST + GLOBAL_TERMINATION_REQUEST = True + + +def is_global_termination_request(): + return GLOBAL_TERMINATION_REQUEST + + +class RespectableLabel(tk.Label): + + def __init__(self, + master: tk.Frame, + text: str, + command: Optional[Callable[[], Any]] = None, + shortcut: Optional[str] = None, + tooltip: Optional[str] = None, + button_id: Optional[str] = None, + add_shortcut_to_tooltip: bool = True, + **kwargs + ): + tk.Label.__init__(self, master, text=text, **kwargs) + if command is not None: + self.bind("", lambda event: command()) + self.config(cursor="hand2", relief=tk.RAISED) + + if button_id is not None and command is not None: + register_button(button_id, command) + + self._command = command + if shortcut is not None: + master.winfo_toplevel().bind(shortcut, self._execute_shortcut) + if tooltip is not None or (shortcut is not None and add_shortcut_to_tooltip): + if add_shortcut_to_tooltip and shortcut is not None: + shortcut_stroke = shortcut.strip('<>') + shortcut_stroke = re.sub(r'([A-Z])', r'Shift-\1', shortcut_stroke) + tooltip = f"({shortcut_stroke})" if tooltip is None else f"{tooltip} ({shortcut_stroke})" + create_tooltip(widget=self, text=tooltip, background=ThemeColours.TOOLTIP_BACKGROUND) + + def _execute_shortcut(self, event: tk.Event): + if not isinstance(event.widget, (tk.Text, tk.Entry)): + # Block keystrokes from something is being typed into a text field + self._command() + +class ToggleLabel(RespectableLabel): + + def __init__(self, + master: tk.Frame, + off_text: str, + on_text: Optional[str] = None, + on_bg: Optional[str] = None, + off_bg: Optional[str] = None, + state_switch_pre_callback: Optional[Callable[[bool], bool]] = None, + state_switch_callback: Optional[Callable[[bool], Any]] = None, + call_switch_callback_immidiately: bool = True, + initial_state: bool = False, + tooltip: Optional[str] = None, + **kwargs + ): + + RespectableLabel.__init__(self, master, text='', command=self.toggle, tooltip=tooltip, borderwidth=1, relief=tk.RAISED, **kwargs) + self._state = False + self._state_switch_pre_callback = state_switch_pre_callback + self._state_switch_callback = state_switch_callback + self._on_text = on_text or off_text + self._off_text = off_text + self._on_bg = on_bg + self._off_bg = off_bg + self.set_toggle_state(initial_state, call_callback=call_switch_callback_immidiately) + + def set_toggle_state(self, state: bool, call_callback: bool = True): + if self._state_switch_pre_callback is not None: + state = self._state_switch_pre_callback(state) + self._state = state + self.config(text=self._on_text if self._state else self._off_text, background=self._on_bg if self._state else self._off_bg, relief=tk.SUNKEN if self._state else tk.RAISED) + if self._state_switch_callback is not None and call_callback: + self._state_switch_callback(self._state) + + def get_toggle_state(self) -> bool: + return self._state + + def toggle(self): + self.set_toggle_state(not self._state) + + +_BUTTON_CALLBACK_ACCESSORS: Optional[Dict[str, Callable]] = None + + +@contextmanager +def hold_button_registry(): + global _BUTTON_CALLBACK_ACCESSORS + old = _BUTTON_CALLBACK_ACCESSORS + try: + _BUTTON_CALLBACK_ACCESSORS = {} + yield + finally: + _BUTTON_CALLBACK_ACCESSORS = None + + +def press_button_by_id(button_id: str): + assert _BUTTON_CALLBACK_ACCESSORS is not None, "You need to do this from within hold_button_callback_accessors" + _BUTTON_CALLBACK_ACCESSORS[button_id]() + + +def register_button(button_id: str, callback: Callable): + if _BUTTON_CALLBACK_ACCESSORS is not None: + _BUTTON_CALLBACK_ACCESSORS[button_id] = callback + + +class RespectableButton(tk.Button): + + def __init__(self, + master: tk.Frame, + text: str, + command: Callable[[], Any], + error_handler: Optional[Callable[[ErrorDetail], Any]] = None, + tooltip: Optional[str] = None, + shortcut: Optional[Union[str, Sequence[str]]] = None, + button_id: Optional[str] = None, # Use this in hold_button_callback_accessors with register_button + **kwargs + ): + tk.Button.__init__(self, master, text=text, **kwargs) + + if button_id is not None: + register_button(button_id, command) + + # Add callback when clicked + self.bind("", lambda event: self._call_callback_with_safety()) + + self._original_text = text + # self._original_command = command + # self._original_tooltip = tooltip + # self._highlight = highlight + + self._original_config = {k: v[4] for k, v in self.config().items() if len(v)>4} # Get all "current" values + self._original_tooltip = tooltip + self._original_callback = self._callback = command + self._error_handler = error_handler + + if tooltip is not None: + create_tooltip(widget=self, text=tooltip, background=ThemeColours.TOOLTIP_BACKGROUND) + if shortcut is not None: + for s in [shortcut] if isinstance(shortcut, str) else shortcut: + master.winfo_toplevel().bind(s, lambda event: self._call_callback_with_safety()) + + def restore(self): + self.config(**self._original_config) + self._callback = self._original_callback + create_tooltip(widget=self, text=self._original_tooltip, background=ThemeColours.TOOLTIP_BACKGROUND) + + def modify(self, tooltip: str, command: Optional[Callable[[], Any]], **kwargs): + self.config(**kwargs) + if command is not None: + self._callback = command + create_tooltip(widget=self, text=tooltip, background=ThemeColours.TOOLTIP_BACKGROUND) + + def _call_callback_with_safety(self): + try: + self._callback() + except Exception as e: + err = e + traceback_str = traceback.format_exc() + print(traceback_str) + if self._error_handler: + self._error_handler(ErrorDetail(error=err, traceback=traceback_str, additional_info=f"Button: '{self._original_text}'")) + raise e + + +FrameType = TypeVar('FrameType', bound=tk.Frame) + +@contextmanager +def populate_frame(frame: Optional[FrameType] = None) -> FrameType: + """ + This context manager doesn't really do anything, but it helps to structure the code + with indentaiton so you see what belongs to what frame. + :param frame: + :return: + """ + + if frame is None: + frame = tk.Frame() + # for child in frame.winfo_children(): + # child.destroy() + yield frame + # frame.pack() + # frame.update() + + +class ButtonPanel(tk.Frame): + + def __init__(self, + master: tk.Frame, + error_handler: Optional[Callable[[ErrorDetail], Any]] = None, + as_row: bool = True, # False = column + font: Optional[Union[str, Tuple[str, int]]] = (None, 14), + **kwargs): + tk.Frame.__init__(self, master, **kwargs) + self._error_handler = error_handler + self._buttons = [] + self._as_row = as_row + self._font = font + if as_row: + self.rowconfigure(0, weight=1) + else: + self.columnconfigure(0, weight=1) + self._count = 0 + + def add_button(self, + text: str, + command: Callable[[], Any], + tooltip: Optional[str] = None, + shortcut: Optional[str] = None, + highlight: bool = False, + weight: int = 1, + **kwargs + ) -> RespectableButton: + button = RespectableButton( + self, + text=text, + tooltip=tooltip, + shortcut=shortcut, + command=command, + padx=0, + pady=0, + font=self._font, + # width=1, + highlightbackground=ThemeColours.HIGHLIGHT_COLOR if highlight else None, + error_handler=self._error_handler, + **kwargs + ) + if self._as_row: + button.grid(column=self._count, row=0, sticky=tk.NSEW) + self.columnconfigure(self._count, weight=weight) + else: + button.grid(column=0, row=self._count, sticky=tk.NSEW) + self.rowconfigure(self._count, weight=weight) + self._count += 1 + return button + +# def add_respectable_button( +# frame: tk.Frame, +# error_handler: Optional[Callable[[ErrorDetail], Any]], +# text: str, +# command: Callable[[], Any()], +# tooltip: Optional[str] = None, +# shortcut: Optional[str] = None, +# highlight: bool = False, +# add_restore_method: bool = False +# ) -> tk.Button: +# nonlocal count +# +# def call_callback_with_safety(callback: Callable[[], None]): +# try: +# callback() +# except Exception as e: +# err = e +# traceback_str = traceback.format_exc() +# if error_handler: +# error_handler(ErrorDetail(error=err, traceback=traceback_str, additional_info=f"Button: '{text}'")) +# raise e +# +# # TODO: Error handling +# # self.after(50, lambda: self._handle_error(err, command=f"Button {text}", traceback_str=traceback_str)) +# +# button = tk.Button(button_row_frame, text=text, command=partial(call_callback_with_safety, command), highlightbackground=ThemeColours.HIGHLIGHT_COLOR if highlight else None) +# button.grid(row=0, column=count, sticky=tk.NSEW) +# count += 1 +# if tooltip is not None: +# create_tooltip(widget=button, text=tooltip, background=ThemeColours.TOOLTIP_BACKGROUND) +# if shortcut is not None: +# self.bind(shortcut, lambda event: command()) +# +# if add_restore_method: +# def restore(): +# button.config(text=text, command=partial(call_callback_with_safety, command), highlightbackground=ThemeColours.HIGHLIGHT_COLOR if highlight else None) +# create_tooltip(widget=button, text=tooltip, background=ThemeColours.TOOLTIP_BACKGROUND) +# +# button.restore = restore +# +# return button \ No newline at end of file From 6a5873669e17a88341aef001688de2dcb05dfb57 Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 28 Nov 2023 00:20:39 -0800 Subject: [PATCH 092/107] no requests --- artemis/plotting/tk_utils/tk_error_dialog.py | 46 ++++++++++---------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/artemis/plotting/tk_utils/tk_error_dialog.py b/artemis/plotting/tk_utils/tk_error_dialog.py index 440a7859..eeb34432 100644 --- a/artemis/plotting/tk_utils/tk_error_dialog.py +++ b/artemis/plotting/tk_utils/tk_error_dialog.py @@ -1,10 +1,9 @@ import tkinter as tk -import webbrowser -from tkinter import messagebox -from typing import Optional, Tuple import traceback -import requests +import webbrowser from dataclasses import dataclass +from tkinter import messagebox +from typing import Optional # from video_scanner.app_utils.utils import ErrorDetail @@ -17,25 +16,25 @@ class ErrorDetail: -def send_paste(error_message: str, pastebin_key: str) -> Tuple[bool, Optional[str]]: - """ Does not work... """ - api_url = 'https://pastebin.com/api/api_post.php' - paste_name = 'Error Traceback' - data = { - 'api_dev_key': pastebin_key, - 'api_option': 'paste', - 'api_paste_code': error_message, - 'api_paste_name': paste_name, - 'api_paste_private': 1, # 0=public, 1=unlisted, 2=private - 'api_paste_expire_date': '1M', - - } - response = requests.post(api_url, data=data) - if response.status_code == 200: # success - return True, response.text - else: - print('Failed to create paste. Status:', response.status_code) - return False, response.text +# def send_paste(error_message: str, pastebin_key: str) -> Tuple[bool, Optional[str]]: +# """ Does not work... """ +# api_url = 'https://pastebin.com/api/api_post.php' +# paste_name = 'Error Traceback' +# data = { +# 'api_dev_key': pastebin_key, +# 'api_option': 'paste', +# 'api_paste_code': error_message, +# 'api_paste_name': paste_name, +# 'api_paste_private': 1, # 0=public, 1=unlisted, 2=private +# 'api_paste_expire_date': '1M', +# +# } +# response = requests.post(api_url, data=data) +# if response.status_code == 200: # success +# return True, response.text +# else: +# print('Failed to create paste. Status:', response.status_code) +# return False, response.text def open_url(url): webbrowser.open(url) @@ -50,7 +49,6 @@ def tk_show_error_dialog(exception: Exception, title: str = "Error", message: Op # Define the behavior for "Report and Close" def report_and_close(): if pastebin_key and traceback_str: - response = send_paste(traceback_str, pastebin_key) messagebox.showinfo(title="Reported", message=f"Error reported. Thank you!") root.destroy() From 0207a3f42a1e9c9967c524e76f3bb8ecc2c4aac7 Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 28 Nov 2023 00:22:13 -0800 Subject: [PATCH 093/107] attrs --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index baa0a707..42a552a4 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ author_email='poconn4@gmail.com', url='https://github.com/quva-lab/artemis', long_description='Artemis aims to get rid of all the boring, bureaucratic coding (plotting, file management, etc) involved in machine learning projects, so you can get to the good stuff quickly.', - install_requires=['numpy', 'scipy', 'matplotlib', 'pytest', 'pillow', 'tabulate', 'si-prefix', 'rectangle-packer'], + install_requires=['numpy', 'scipy', 'matplotlib', 'pytest', 'pillow', 'tabulate', 'si-prefix', 'rectangle-packer', 'attrs'], extras_require = { 'remote_plotting': ["paramiko", "netifaces"] }, From 1527c2b37ca2f30e1cf31674ac49a4e70e1a4793 Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 30 Nov 2023 15:00:27 -0800 Subject: [PATCH 094/107] exitable overlay frame and functional import fix --- artemis/general/functional.py | 11 +-- artemis/plotting/tk_utils/dual_panel_frame.py | 1 - .../tk_utils/exitable_overlay_frame.py | 73 +++++++++++++++++++ .../tk_utils/test_exitable_overlay_frame.py | 32 ++++++++ artemis/plotting/tk_utils/ui_utils.py | 3 + 5 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 artemis/plotting/tk_utils/exitable_overlay_frame.py create mode 100644 artemis/plotting/tk_utils/test_exitable_overlay_frame.py diff --git a/artemis/general/functional.py b/artemis/general/functional.py index a92e52c3..ae775038 100644 --- a/artemis/general/functional.py +++ b/artemis/general/functional.py @@ -1,15 +1,10 @@ import inspect +import sys +import types from abc import abstractmethod from collections import OrderedDict from functools import partial, reduce -import collections -from typing import TypeVar, Callable - -from cv2.gapi.ie.detail import PARAM_DESC_KIND_LOAD - -from artemis.general.should_be_builtins import separate_common_items -import sys -import types +from typing import Callable def get_partial_chain(f): diff --git a/artemis/plotting/tk_utils/dual_panel_frame.py b/artemis/plotting/tk_utils/dual_panel_frame.py index f62e0920..dd99af27 100644 --- a/artemis/plotting/tk_utils/dual_panel_frame.py +++ b/artemis/plotting/tk_utils/dual_panel_frame.py @@ -102,7 +102,6 @@ def _on_frame_click(self, xy: Tuple[int, int], side: int): if self._frame_click_callback is not None: self._frame_click_callback(xy, side) - def _set_view_frame(self, image_view_info: ImageViewInfo, view: ZoomableImageFrame): if self._lock_view_frames and view.get_image_view_frame_or_none() != image_view_info: view.set_image_frame(image_view_info) diff --git a/artemis/plotting/tk_utils/exitable_overlay_frame.py b/artemis/plotting/tk_utils/exitable_overlay_frame.py new file mode 100644 index 00000000..045d421f --- /dev/null +++ b/artemis/plotting/tk_utils/exitable_overlay_frame.py @@ -0,0 +1,73 @@ +import tkinter as tk +from typing import Optional, Callable + +from artemis.plotting.tk_utils.constants import ThemeColours +from artemis.plotting.tk_utils.ui_utils import RespectableLabel + + +class ExitableOverlayFrame(tk.Frame): + """ A frame with a "main" frame and an "overlay" frame. The overlay frame hides the main frame when it is visible.""" + + def __init__(self, + parent_frame: tk.Frame, + overlay_label: str = "", + pre_state_change_callback: Optional[Callable[[bool], bool]] = None, + on_state_change_callback: Optional[Callable[[bool], None]] = None, + return_shortcut: Optional[str] = None, + return_button_config: Optional[dict] = None, + label_config: Optional[dict] = None, + ): + + tk.Frame.__init__(self, parent_frame, bg=ThemeColours.BACKGROUND) + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=1) + + self._main_frame = tk.Frame(self, bg=ThemeColours.BACKGROUND) + self._main_frame.grid(row=0, column=0, sticky=tk.NSEW) + + self._overlay_frame_parent = tk.Frame(self, bg=ThemeColours.BACKGROUND) + # Do not grid this yet. We will grid it when we want to show the overlay. + + self._overlap_panel = tk.Frame(self._overlay_frame_parent, bg=ThemeColours.BACKGROUND) + self._overlap_panel.pack(side=tk.TOP, fill=tk.X, expand=False) + + self._overlay_exit_button = RespectableLabel( + self._overlap_panel, + text="Exit", + command=lambda: self.set_overlay_visible(False), + bg=ThemeColours.BACKGROUND, + fg=ThemeColours.TEXT, + shortcut=return_shortcut, + ) + if return_button_config is not None: + self._overlay_exit_button.configure(**return_button_config) + self._overlay_exit_button.pack(side=tk.LEFT, fill=tk.X, expand=False) + + label = tk.Label(self._overlap_panel, text=overlay_label, bg=ThemeColours.BACKGROUND, fg=ThemeColours.TEXT) + label.pack(side=tk.LEFT, fill=tk.X, expand=True) + if label_config is not None: + label.configure(**label_config) + + self._overlay_frame: tk.Frame = tk.Frame(self._overlay_frame_parent, bg=ThemeColours.BACKGROUND) + self._overlay_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + + self._pre_state_change_callback = pre_state_change_callback + self._on_state_change_callback = on_state_change_callback + + def get_main_frame(self) -> tk.Frame: + return self._main_frame + + def get_overlay_frame(self) -> tk.Frame: + return self._overlay_frame + + def set_overlay_visible(self, visible: bool): + if self._pre_state_change_callback is not None: + visible = self._pre_state_change_callback(visible) + if visible: + self._main_frame.grid_forget() + self._overlay_frame_parent.grid(row=0, column=0, sticky=tk.NSEW) + else: + self._overlay_frame_parent.grid_forget() + self._main_frame.grid(row=0, column=0, sticky=tk.NSEW) + if self._on_state_change_callback is not None: + self._on_state_change_callback(visible) diff --git a/artemis/plotting/tk_utils/test_exitable_overlay_frame.py b/artemis/plotting/tk_utils/test_exitable_overlay_frame.py new file mode 100644 index 00000000..fd8a45f3 --- /dev/null +++ b/artemis/plotting/tk_utils/test_exitable_overlay_frame.py @@ -0,0 +1,32 @@ +from artemis.plotting.tk_utils.exitable_overlay_frame import ExitableOverlayFrame +from video_scanner.ui.tk_utils import hold_tkinter_root_context +import tkinter as tk + +def test_exitable_side_frame(): + + with hold_tkinter_root_context() as root: + + root.geometry("800x600") + + frame: ExitableOverlayFrame = ExitableOverlayFrame(root) + frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + + f=tk.Frame(frame.get_main_frame(), bg='red') + f.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + + # Put a button in the center of parent frame + button = tk.Button(f, text="Click Me to Show Overlay", command=lambda: frame.set_overlay_visible(True)) + button.pack(side=tk.TOP, fill=tk.NONE, expand=True) + + overlay_frame=tk.Frame(frame.get_overlay_frame(), bg='blue') + overlay_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + + label = tk.Label(overlay_frame, text="Overlay") + label.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + + root.mainloop() + + +if __name__ == '__main__': + test_exitable_side_frame() + diff --git a/artemis/plotting/tk_utils/ui_utils.py b/artemis/plotting/tk_utils/ui_utils.py index 4cf93780..7a433272 100644 --- a/artemis/plotting/tk_utils/ui_utils.py +++ b/artemis/plotting/tk_utils/ui_utils.py @@ -129,6 +129,9 @@ def __init__(self, self._off_bg = off_bg self.set_toggle_state(initial_state, call_callback=call_switch_callback_immidiately) + def set_state_switch_callback(self, callback: Optional[Callable[[bool], Any]]): + self._state_switch_callback = callback + def set_toggle_state(self, state: bool, call_callback: bool = True): if self._state_switch_pre_callback is not None: state = self._state_switch_pre_callback(state) From 9410c8e915a2e885ef6a4c2335c7a103ab9438ce Mon Sep 17 00:00:00 2001 From: peter Date: Wed, 31 Jan 2024 05:48:51 -0800 Subject: [PATCH 095/107] little ui changes --- artemis/general/hashing.py | 3 + artemis/general/utils_utils.py | 13 + artemis/image_processing/image_builder.py | 5 + artemis/image_processing/image_utils.py | 33 +- artemis/image_processing/media_metadata.py | 7 +- artemis/image_processing/video_frame.py | 75 +- artemis/image_processing/video_reader.py | 6 +- .../tk_utils/alternate_zoomable_image_view.py | 4 +- artemis/plotting/tk_utils/constants.py | 2 + .../tk_utils/exitable_overlay_frame.py | 73 -- artemis/plotting/tk_utils/tabbed_frame.py | 153 +++ ..._overlay_frame.py => test_tabbed_frame.py} | 22 +- artemis/plotting/tk_utils/test_tk_utils.py | 5 + .../tk_utils/test_ui_choose_parameters.py | 109 ++ artemis/plotting/tk_utils/test_ui_utils.py | 49 + artemis/plotting/tk_utils/tk_error_dialog.py | 2 +- artemis/plotting/tk_utils/tk_scroll.py | 98 ++ artemis/plotting/tk_utils/tk_utils.py | 8 + .../plotting/tk_utils/ui_choose_parameters.py | 1042 ++++++++++++++--- artemis/plotting/tk_utils/ui_utils.py | 210 +++- 20 files changed, 1635 insertions(+), 284 deletions(-) delete mode 100644 artemis/plotting/tk_utils/exitable_overlay_frame.py create mode 100644 artemis/plotting/tk_utils/tabbed_frame.py rename artemis/plotting/tk_utils/{test_exitable_overlay_frame.py => test_tabbed_frame.py} (54%) create mode 100644 artemis/plotting/tk_utils/test_ui_choose_parameters.py create mode 100644 artemis/plotting/tk_utils/test_ui_utils.py create mode 100644 artemis/plotting/tk_utils/tk_scroll.py diff --git a/artemis/general/hashing.py b/artemis/general/hashing.py index bc0663f5..7321cc49 100644 --- a/artemis/general/hashing.py +++ b/artemis/general/hashing.py @@ -29,6 +29,7 @@ class HashRep(Enum): HEX = 'hex' BASE_32 = 'base32' BASE_64 = 'base64' + INT = 'int' def compute_fixed_hash(obj, try_objects=False, use_only_public_fields: bool = False, hashrep: HashRep = HashRep.BASE_32, _hasher = None, _memo = None, _count=None): @@ -101,6 +102,8 @@ def compute_fixed_hash(obj, try_objects=False, use_only_public_fields: bool = Fa result = base64.b32encode(_hasher.digest()).decode('ascii').rstrip('=') elif hashrep == HashRep.BASE_64: result = base64.b64encode(_hasher.digest()).decode('ascii').rstrip('=') + elif hashrep == HashRep.INT: + result = int.from_bytes(_hasher.digest(), byteorder='big') else: raise Exception(f"No hash rep {hashrep}") _memo[id(obj)] = result diff --git a/artemis/general/utils_utils.py b/artemis/general/utils_utils.py index bb13a4d4..c7cd6673 100644 --- a/artemis/general/utils_utils.py +++ b/artemis/general/utils_utils.py @@ -100,5 +100,18 @@ def byte_size_to_string(bytes: int, decimals_precision: int = 1) -> str: return f"{{:.{decimals_precision}f}} {prefix}B".format(size) +def number_to_ordinal_string(n: int) -> str: + """ + Thanks Ben: https://stackoverflow.com/a/20007730/851699 + :param n: number, e.g. 23 + :return: number with ordinal suffix, e.g. 23rd + """ + if 11 <= (n % 100) <= 13: + suffix = 'th' + else: + suffix = ['th', 'st', 'nd', 'rd', 'th'][min(n % 10, 4)] + return str(n) + suffix + + if __name__ == '__main__': demo_get_context_name() diff --git a/artemis/image_processing/image_builder.py b/artemis/image_processing/image_builder.py index 1c50fa52..33d1ccaf 100644 --- a/artemis/image_processing/image_builder.py +++ b/artemis/image_processing/image_builder.py @@ -230,6 +230,11 @@ def draw_border(self, color: BGRColorTuple, thickness: int = 2, external: bool = return self # return self.draw_box(BoundingBox.from_ltrb(0, 0, self.image.shape[1]-1, self.image.shape[0]-1), thickness=thickness, colour=color, include_labels=False) + def paint_bucket(self, loc_xy: XYPointTuple, color: BGRColorTuple, tolerance: int = 0) -> 'ImageBuilder': + loc_ji = self._xy_to_ji(loc_xy) + cv2.floodFill(self.image, None, loc_ji, newVal=color, loDiff=tolerance, upDiff=tolerance) + return self + def draw_zoom_inset_from_box(self, box: BoundingBox, scale_factor: int, border_color=BGRColors.GREEN, border_thickness: int = 2, corner = 'br', backup_corner='bl') -> 'ImageBuilder': # TODO: Make it nor crash when box is too big assert corner in ('br', 'tr', 'bl', 'tl') diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index b6565834..6bae4f4c 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -569,6 +569,14 @@ def scale_by(self, factor: float) -> 'BoundingBox': w, h = self.get_xy_size() return BoundingBox.from_xywh(sx, sy, w * factor, h * factor, label=self.label) + def adjust_aspect_ratio(self, aspect_ratio: float, change_width: bool = True) -> 'BoundingBox': + sx, sy = self.get_center() + w, h = self.get_xy_size() + if change_width: + return BoundingBox.from_xywh(sx, sy, h * aspect_ratio, h, label=self.label) + else: + return BoundingBox.from_xywh(sx, sy, w, w / aspect_ratio, label=self.label) + def pad(self, pad: float) -> 'BoundingBox': sx, sy = self.get_center() w, h = self.get_xy_size() @@ -714,7 +722,7 @@ def render(self, data: str) -> BGRImageArray: longest_line = max(lines, key=len) (text_width, text_height), baseline = cv2.getTextSize(longest_line, self.font, self.scale, self.thickness) - width, height = text_width + 10, int(len(lines) * text_height * (1 + self.vspace)) + width, height = text_width, int(len(lines) * text_height * (1 + self.vspace)) wmax, hmax = self.max_size if self.match_max_size: assert wmax is not None and hmax is not None, f"If you match max size, you need to specify. Got {self.max_size}" @@ -867,7 +875,13 @@ def zoom_by(self, relative_zoom: float, invariant_display_xy: Optional[Tuple[flo if invariant_display_xy is None: invariant_display_xy = self._get_display_midpoint_xy() else: - invariant_display_xy = np.maximum(0, np.minimum(self._get_display_wh(), invariant_display_xy)) + dw, dh = self._get_display_wh() + dx, dy = invariant_display_xy + is_inside_bounds = (0 <= dx < dw) and (0 <= dy < dh) + if is_inside_bounds: + invariant_display_xy = np.maximum(0, np.minimum(self._get_display_wh(), invariant_display_xy)) + else: + invariant_display_xy = self._get_display_midpoint_xy() invariant_pixel_xy = self.display_xy_to_pixel_xy(display_xy=invariant_display_xy) coeff = (1 - 1 / relative_zoom) @@ -933,6 +947,7 @@ def create_display_image(self, nearest_neighbor_zoom_threshold: float = 5, ) -> BGRImageArray: + assert image is not None, "Image was None" result_array = np.full(self.window_disply_wh[::-1] + image.shape[2:], dtype=image.dtype, fill_value=gap_color) result_array[-self.scroll_bar_width:, :-self.scroll_bar_width] = scroll_bg_color result_array[:-self.scroll_bar_width, -self.scroll_bar_width:] = scroll_bg_color @@ -947,12 +962,14 @@ def create_display_image(self, # Add the image src_image = image[src_y1:src_y2, src_x1:src_x2] - try: - src_image_scaled = cv2.resize(src_image, (dest_x2 - dest_x1, dest_y2 - dest_y1), interpolation=cv2.INTER_NEAREST if self.zoom_level > nearest_neighbor_zoom_threshold else cv2.INTER_LINEAR) - except Exception as err: - print(f"Resize failed on images of shape {src_image} with dest shape {(dest_x2 - dest_x1, dest_y2 - dest_y1)} and interpolation {cv2.INTER_NEAREST if self.zoom_level > nearest_neighbor_zoom_threshold else cv2.INTER_LINEAR}") - raise err - result_array[dest_y1:dest_y2, dest_x1:dest_x2] = src_image_scaled + + if (dest_y2 - dest_y1) > 0 and (dest_x2 - dest_x1) > 0: + try: + src_image_scaled = cv2.resize(src_image, (dest_x2 - dest_x1, dest_y2 - dest_y1), interpolation=cv2.INTER_NEAREST if self.zoom_level > nearest_neighbor_zoom_threshold else cv2.INTER_LINEAR) + except Exception as err: + print(f"Resize failed on images of shape {src_image} with dest shape {(dest_x2 - dest_x1, dest_y2 - dest_y1)} and interpolation {cv2.INTER_NEAREST if self.zoom_level > nearest_neighbor_zoom_threshold else cv2.INTER_LINEAR}") + raise err + result_array[dest_y1:dest_y2, dest_x1:dest_x2] = src_image_scaled # Add the scroll bars scroll_fraxs_x: Tuple[float, float] = (src_x1 / image.shape[1], src_x2 / image.shape[1]) diff --git a/artemis/image_processing/media_metadata.py b/artemis/image_processing/media_metadata.py index b58c9f33..2fac0f2d 100644 --- a/artemis/image_processing/media_metadata.py +++ b/artemis/image_processing/media_metadata.py @@ -1,13 +1,10 @@ -from datetime import datetime +from datetime import datetime, timedelta from typing import Optional, Tuple -from datetime import datetime, timedelta -from timezonefinder import TimezoneFinder import pytz +from timezonefinder import TimezoneFinder import exif -from pymediainfo import MediaInfo - from artemis.image_processing.video_frame import FrameGeoData diff --git a/artemis/image_processing/video_frame.py b/artemis/image_processing/video_frame.py index f82be337..e8b134d2 100644 --- a/artemis/image_processing/video_frame.py +++ b/artemis/image_processing/video_frame.py @@ -1,10 +1,13 @@ from dataclasses import dataclass from datetime import datetime from typing import Tuple, Optional +import utm +import pytz from artemis.general.custom_types import BGRImageArray + @dataclass class VideoFrameInfo: image: BGRImageArray @@ -28,21 +31,83 @@ class FrameGeoData: epoch_time_us: int altitude_from_home: Optional[float] = None altitude_from_sea: Optional[float] = None + bearing: Optional[float] = None + pitch: Optional[float] = None + roll: Optional[float] = None # def has_latlong(self) -> bool: # Hope you're not flying off the west coast of Africa # return self.latitude != 0 and self.longitude != 0 - def get_datetime(self) -> datetime: - return datetime.fromtimestamp(self.epoch_time_us/1000000) + def __str__(self): + return f"FrameGeoData(date={self.get_time_str()}, (lat, long)={self.get_latlng_str()}, alt={self.get_altitude_str()})" + + def get_datetime(self, localize: bool = True) -> datetime: + dt = datetime.fromtimestamp(self.epoch_time_us/1000000) # In UTC + if localize and self.lat_long is not None: # Use pytz + # TODO: Avoid circular import + from artemis.image_processing.media_metadata import get_timezone_finder_singleton + tzf = get_timezone_finder_singleton() + timezone_str = tzf.timezone_at(lat=self.lat_long[0], lng=self.lat_long[1]) + if timezone_str is not None: + dt = pytz.timezone(timezone_str).localize(dt) + return dt def get_timestamp(self) -> float: return self.epoch_time_us/1e6 def get_time_str(self) -> str: - return self.get_datetime().strftime('%Y-%m-%d %H:%M:%S.%f') + # 2-decimals of precision is enough for 1/100th of a second + # return self.get_datetime().strftime('%Y-%m-%d %H:%M:%S.%f')[:-4] + # Include zone + dt = self.get_datetime() + if dt.tzinfo is None: + return dt.strftime('%Y-%m-%d %H:%M:%S.%f')[:-4] + ' UTC' + else: + return dt.strftime('%Y-%m-%d %H:%M:%S.%f')[:-4] + ' ' + dt.tzinfo.tzname(dt) + # return self.get_datetime().strftime('%Y-%m-%d %H:%M:%S.%f %Z') + + def get_latlng_str(self, format='dd') -> str: + if self.lat_long is not None: + if format == 'dd': + return f'{self.lat_long[0]:.5f}, {self.lat_long[1]:.5f}' + elif format == 'dms': + is_north = self.lat_long[0] >= 0 + is_east = self.lat_long[1] >= 0 + lat_d, lat_m, lat_s = self.lat_long[0], self.lat_long[0] % 1 * 60, self.lat_long[0] % 1 * 60 % 1 * 60 + lng_d, lng_m, lng_s = self.lat_long[1], self.lat_long[1] % 1 * 60, self.lat_long[1] % 1 * 60 % 1 * 60 + return f'{lat_d:.0f}°{lat_m:.0f}\'{lat_s:.2f}"{"N" if is_north else "S"}, {lng_d:.0f}°{lng_m:.0f}\'{lng_s:.2f}"{"E" if is_east else "W"}' + else: + raise ValueError(f"Unknown format: {format}") + else: + return 'Unknown' + + def get_utm_str(self) -> str: + if self.lat_long is not None: + easting, northing, zone_number, zone_letter = utm.from_latlon(self.lat_long[0], self.lat_long[1]) + return f"{zone_number}{zone_letter} {easting:.0f} {northing:.0f} " + else: + return 'Unknown' + + def get_camera_attitude_str(self) -> str: + return f"Bearing:{self.bearing:.1f}°, Pitch:({self.pitch:.1f}°, Roll:{self.roll:.1f}°)" if self.pitch is not None and self.roll is not None and self.bearing is not None else "" - def get_latlng_str(self) -> str: + def get_map_link(self, program: str = 'google_maps') -> str: + # if choice == op_gmaps: + # path = f"https://www.google.com/maps/search/?api=1&query={lat},{long}" + # elif choice == op_gearth: + # path = f"https://earth.google.com/web/search/{lat},{long}" + # elif choice == op_sartopo: + # # https://sartopo.com/map.html#ll=49.1297,-123.97042&z=14&b=mbt + # path = f"https://sartopo.com/map.html#ll={lat},{long}&z=14&b=mbt" if self.lat_long is not None: - return f'{self.lat_long[0]:.5f}, {self.lat_long[1]:.5f}' + if program == 'google_maps': + return f"https://www.google.com/maps/search/?api=1&query={self.lat_long[0]:.6f},{self.lat_long[1]:.6f}" + elif program == 'google_earth': + return f"https://earth.google.com/web/search/{self.lat_long[0]:.6f},{self.lat_long[1]:.6f}" + elif program == 'sartopo': + # https://sartopo.com/map.html#ll=49.1297,-123.97042&z=14&b=mbt + return f"https://sartopo.com/map.html#ll={self.lat_long[0]:.6f},{self.lat_long[1]:.6f}&z=14&b=mbt" + else: + raise ValueError(f"Unknown program: {program}") else: return 'Unknown' diff --git a/artemis/image_processing/video_reader.py b/artemis/image_processing/video_reader.py index b53e1e6e..24bdd659 100644 --- a/artemis/image_processing/video_reader.py +++ b/artemis/image_processing/video_reader.py @@ -157,7 +157,11 @@ def lookup_frame_ix(t: float) -> Optional[int]: elif time_indicator in ('e', 'end'): return n_frames - 1 elif ':' in time_indicator: - sec = parse_time_delta_str_to_sec(time_indicator) + try: + sec = parse_time_delta_str_to_sec(time_indicator) + except Exception as err: + print(f"Error parsing time indicator: {err}") + return None return lookup_frame_ix(sec) elif time_indicator.endswith('s'): sec = float(time_indicator.rstrip('s')) diff --git a/artemis/plotting/tk_utils/alternate_zoomable_image_view.py b/artemis/plotting/tk_utils/alternate_zoomable_image_view.py index f125ff7c..56538b5a 100644 --- a/artemis/plotting/tk_utils/alternate_zoomable_image_view.py +++ b/artemis/plotting/tk_utils/alternate_zoomable_image_view.py @@ -16,7 +16,7 @@ from artemis.general.custom_types import BGRImageArray, BGRColorTuple from artemis.image_processing.image_utils import ImageViewInfo, BGRColors from artemis.plotting.tk_utils.machine_utils import is_windows_machine -from artemis.plotting.tk_utils.tk_error_dialog import tk_show_eagle_eyes_error_dialog, ErrorDetail +from artemis.plotting.tk_utils.tk_error_dialog import tk_error_detail_handler, ErrorDetail from artemis.plotting.tk_utils.tk_utils import bind_callbacks_to_widget from artemis.plotting.tk_utils.ui_utils import bgr_image_to_pil_image @@ -34,7 +34,7 @@ def __init__(self, max_zoom: float = 40.0, pan_jump_factor=0.2, mouse_scroll_speed: float = 2.0, - error_handler: Optional[Callable[[ErrorDetail], None]] = tk_show_eagle_eyes_error_dialog, + error_handler: Optional[Callable[[ErrorDetail], None]] = tk_error_detail_handler, zoom_scrolling_mode: bool = False, # Use mouse scrollwheel to zoom, after_view_change_callback: Optional[Callable[[ImageViewInfo], None]] = None, additional_canvas_callbacks: Optional[Mapping[str, Callable[[Event], None]]] = None, diff --git a/artemis/plotting/tk_utils/constants.py b/artemis/plotting/tk_utils/constants.py index 7879c52b..b20e6266 100644 --- a/artemis/plotting/tk_utils/constants.py +++ b/artemis/plotting/tk_utils/constants.py @@ -2,6 +2,7 @@ class UIColours: BLACK = '#000000' WHITE = '#ffffff' RED = '#cc0000' + BLOOD_RED = '#990000' PALE_RED = '#ff8888' DARK_GREY = '#222222' DARKISH_GREY = '#444444' @@ -16,6 +17,7 @@ class ThemeColours: BACKGROUND = UIColours.DARK_GREY MIDGROUND = UIColours.GREY SELECTED_ROW_BACKGROUND = UIColours.EYE_BLUE_FADED + EXIT_BUTTON_BACKGROUND = UIColours.BLOOD_RED ROW_BACKGROUND = UIColours.DARKISH_GREY TITLE_BACKGROUND = UIColours.DARK_GREY TEXT = UIColours.WHITE diff --git a/artemis/plotting/tk_utils/exitable_overlay_frame.py b/artemis/plotting/tk_utils/exitable_overlay_frame.py deleted file mode 100644 index 045d421f..00000000 --- a/artemis/plotting/tk_utils/exitable_overlay_frame.py +++ /dev/null @@ -1,73 +0,0 @@ -import tkinter as tk -from typing import Optional, Callable - -from artemis.plotting.tk_utils.constants import ThemeColours -from artemis.plotting.tk_utils.ui_utils import RespectableLabel - - -class ExitableOverlayFrame(tk.Frame): - """ A frame with a "main" frame and an "overlay" frame. The overlay frame hides the main frame when it is visible.""" - - def __init__(self, - parent_frame: tk.Frame, - overlay_label: str = "", - pre_state_change_callback: Optional[Callable[[bool], bool]] = None, - on_state_change_callback: Optional[Callable[[bool], None]] = None, - return_shortcut: Optional[str] = None, - return_button_config: Optional[dict] = None, - label_config: Optional[dict] = None, - ): - - tk.Frame.__init__(self, parent_frame, bg=ThemeColours.BACKGROUND) - self.columnconfigure(0, weight=1) - self.rowconfigure(0, weight=1) - - self._main_frame = tk.Frame(self, bg=ThemeColours.BACKGROUND) - self._main_frame.grid(row=0, column=0, sticky=tk.NSEW) - - self._overlay_frame_parent = tk.Frame(self, bg=ThemeColours.BACKGROUND) - # Do not grid this yet. We will grid it when we want to show the overlay. - - self._overlap_panel = tk.Frame(self._overlay_frame_parent, bg=ThemeColours.BACKGROUND) - self._overlap_panel.pack(side=tk.TOP, fill=tk.X, expand=False) - - self._overlay_exit_button = RespectableLabel( - self._overlap_panel, - text="Exit", - command=lambda: self.set_overlay_visible(False), - bg=ThemeColours.BACKGROUND, - fg=ThemeColours.TEXT, - shortcut=return_shortcut, - ) - if return_button_config is not None: - self._overlay_exit_button.configure(**return_button_config) - self._overlay_exit_button.pack(side=tk.LEFT, fill=tk.X, expand=False) - - label = tk.Label(self._overlap_panel, text=overlay_label, bg=ThemeColours.BACKGROUND, fg=ThemeColours.TEXT) - label.pack(side=tk.LEFT, fill=tk.X, expand=True) - if label_config is not None: - label.configure(**label_config) - - self._overlay_frame: tk.Frame = tk.Frame(self._overlay_frame_parent, bg=ThemeColours.BACKGROUND) - self._overlay_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) - - self._pre_state_change_callback = pre_state_change_callback - self._on_state_change_callback = on_state_change_callback - - def get_main_frame(self) -> tk.Frame: - return self._main_frame - - def get_overlay_frame(self) -> tk.Frame: - return self._overlay_frame - - def set_overlay_visible(self, visible: bool): - if self._pre_state_change_callback is not None: - visible = self._pre_state_change_callback(visible) - if visible: - self._main_frame.grid_forget() - self._overlay_frame_parent.grid(row=0, column=0, sticky=tk.NSEW) - else: - self._overlay_frame_parent.grid_forget() - self._main_frame.grid(row=0, column=0, sticky=tk.NSEW) - if self._on_state_change_callback is not None: - self._on_state_change_callback(visible) diff --git a/artemis/plotting/tk_utils/tabbed_frame.py b/artemis/plotting/tk_utils/tabbed_frame.py new file mode 100644 index 00000000..ac016371 --- /dev/null +++ b/artemis/plotting/tk_utils/tabbed_frame.py @@ -0,0 +1,153 @@ +import tkinter as tk +from tkinter import ttk +from typing import Optional, Callable, Sequence, Union, List, Generic + +from artemis.plotting.tk_utils.constants import ThemeColours +from artemis.plotting.tk_utils.ui_utils import RespectableLabel, MultiStateToggle, MultiStateEnumType + + +class TabbedFrame(tk.Frame, Generic[MultiStateEnumType]): + """ A frame with a "main" frame and an "overlay" frame. The overlay frame hides the main frame when it is visible.""" + + def __init__(self, + parent_frame: tk.Frame, + tab_enum: type(MultiStateEnumType), + initial_state: Optional[MultiStateEnumType] = None, + # overlay_label: str = "", + pre_state_change_callback: Optional[Callable[[MultiStateEnumType], bool]] = None, + on_state_change_callback: Optional[Callable[[MultiStateEnumType], None]] = None, + add_tab_bar: bool = True, + hide_tab_bar_in_states: Sequence[MultiStateEnumType] = (), + # overlay_panel_title: str = "Overlay View", + tab_forward_shortcut: Optional[str] = None, + on_button_config=None, + off_button_config=None, + # tab_backward_shortcut: Optional[str] = None, + # return_button_config: Optional[dict] = None, + # label_config: Optional[dict] = None, + ): + + tk.Frame.__init__(self, parent_frame, bg=ThemeColours.BACKGROUND) + if off_button_config is None: + off_button_config = dict(fg='gray50', bg='gray25') + if on_button_config is None: + on_button_config = dict(fg='white', bg='gray50') + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=0) + self.rowconfigure(1, weight=1) + self._hide_tab_bar_in_states = hide_tab_bar_in_states + self._tab_enum = tab_enum + self._on_button_config = on_button_config + self._off_button_config = off_button_config + self._tab_forward_shortcut = tab_forward_shortcut + if initial_state is None: + initial_state = list(tab_enum)[0] + + self._frames: List[tk.Frame] = [] + for state in tab_enum: + frame = tk.Frame(self, bg=ThemeColours.BACKGROUND) + self._frames.append(frame) + if state == initial_state: + frame.grid(row=1, column=0, sticky=tk.NSEW) + + self._tab_controls: List[MultiStateToggle] = [] # Controls that switch between the main and overlay frame. We must keep a reference to them so we switch their states appropriately. + self._main_tab_control = self.create_tab_switch_control(self) + if add_tab_bar: + self._main_tab_control.grid(row=0, column=0, sticky=tk.EW) + self._pre_state_change_callback = pre_state_change_callback + self._on_state_change_callback = on_state_change_callback + + self.set_active_tab(initial_state, skip_if_unchanged=False) + + # Callback + if tab_forward_shortcut is not None: + self.bind_all(tab_forward_shortcut, lambda e: self.increment_tab_index(1)) + + def increment_tab_index(self, increment: int) -> None: + states = list(self._tab_enum) + current_index = states.index(self._main_tab_control.get_state()) + new_index = (current_index + increment) % len(states) + self.set_active_tab(states[new_index]) + + def get_frame(self, state: MultiStateEnumType) -> tk.Frame: + return self._frames[list(self._tab_enum).index(state)] + + def create_tab_switch_control(self, parent: tk.Frame) -> MultiStateToggle: + """ Create a control that shows the "main" tab and this tab side-by-side + Clicking tabs (or pressing the shortcut) will switch between them. + """ + tab_control = MultiStateToggle( + parent, + self._tab_enum, + on_state_change_callback=lambda s: self.set_active_tab(s), + call_callback_immediately=False, + on_button_config=self._on_button_config, + off_button_config=self._off_button_config, + tooltip_maker=lambda s: f"Switch to '{s.value}' ({self._tab_forward_shortcut.strip('<>') if self._tab_forward_shortcut else ''})", + ) + # tab_control.pack(side=tk.LEFT, fill=tk.X, expand=False) + self._tab_controls.append(tab_control) + return tab_control + + def set_active_tab(self, state: MultiStateEnumType, skip_if_unchanged: bool = True, skip_callback: bool = False) -> None: + + if skip_if_unchanged and state == self._main_tab_control.get_state(): + return # Already in this state + do_change = True + if self._pre_state_change_callback is not None: + do_change = self._pre_state_change_callback(state) + if not do_change: + return + + if state in self._hide_tab_bar_in_states: + self._main_tab_control.grid_remove() + else: + self._main_tab_control.grid(row=0, column=0, sticky=tk.EW) + + if do_change: + for tc in self._tab_controls: + tc.set_state(state) + index_to_keep = list(self._tab_enum).index(state) + for i, f in enumerate(self._frames): # Just to be safe forget all frames + f.grid_forget() + self._frames[index_to_keep].grid(row=1, column=0, sticky=tk.NSEW) + if self._on_state_change_callback is not None and state and not skip_callback: # Avoid recursion + self._on_state_change_callback(state) + + + # Redraw the window + self.update() + # self.winfo_toplevel().update() + + def get_active_tab(self) -> MultiStateEnumType: + return self._main_tab_control.get_state() + + +# class RespectableNotebook(ttk.Notebook): +# def __init__(self, +# parent_frame, +# panel_names: Sequence[str], +# initial_panel: Optional[str] = None, +# on_state_change_callback: Optional[Callable[[bool], None]] = None, +# next_tab_shortcut: Optional[str] = None, +# previous_tab_shortcut: Optional[str] = None, +# **kwargs): +# super().__init__(parent_frame, **kwargs) +# +# # Create the main frame and add it as the first tab +# +# if initial_panel is None: +# initial_panel = panel_names[0] +# assert initial_panel in panel_names, f"Initial panel {initial_panel} not in {panel_names}" +# for i, p in enumerate(panel_names): +# frame = tk.Frame(self, bg=ThemeColours.BACKGROUND) +# tab_id = self.add(frame, text=p) +# if p == initial_panel: +# self.select(i) +# +# +# def get_frame(self, name_or_index: Union[str, int]) -> tk.Frame: +# if isinstance(name_or_index, str): +# return self.nametowidget(self.select()) +# else: +# return self.nametowidget(self.select()) \ No newline at end of file diff --git a/artemis/plotting/tk_utils/test_exitable_overlay_frame.py b/artemis/plotting/tk_utils/test_tabbed_frame.py similarity index 54% rename from artemis/plotting/tk_utils/test_exitable_overlay_frame.py rename to artemis/plotting/tk_utils/test_tabbed_frame.py index fd8a45f3..daf31657 100644 --- a/artemis/plotting/tk_utils/test_exitable_overlay_frame.py +++ b/artemis/plotting/tk_utils/test_tabbed_frame.py @@ -1,4 +1,6 @@ -from artemis.plotting.tk_utils.exitable_overlay_frame import ExitableOverlayFrame +from enum import Enum + +from artemis.plotting.tk_utils.tabbed_frame import TabbedFrame from video_scanner.ui.tk_utils import hold_tkinter_root_context import tkinter as tk @@ -8,17 +10,27 @@ def test_exitable_side_frame(): root.geometry("800x600") - frame: ExitableOverlayFrame = ExitableOverlayFrame(root) + class Tabs(Enum): + MAIN = 'Main' + OVERLAY = 'Overlay' + + frame: TabbedFrame = TabbedFrame( + parent_frame=root, + tab_enum=Tabs, + tab_forward_shortcut='', + on_button_config=dict(fg='white', bg='gray50'), + off_button_config=dict(fg='gray50', bg='gray25'), + ) frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) - f=tk.Frame(frame.get_main_frame(), bg='red') + f=tk.Frame(frame.get_frame(Tabs.MAIN), bg='red') f.pack(side=tk.TOP, fill=tk.BOTH, expand=True) # Put a button in the center of parent frame - button = tk.Button(f, text="Click Me to Show Overlay", command=lambda: frame.set_overlay_visible(True)) + button = tk.Button(f, text="Click Me to Show Overlay", command=lambda: frame.set_active_tab(Tabs.OVERLAY)) button.pack(side=tk.TOP, fill=tk.NONE, expand=True) - overlay_frame=tk.Frame(frame.get_overlay_frame(), bg='blue') + overlay_frame=tk.Frame(frame.get_frame(Tabs.OVERLAY), bg='blue') overlay_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) label = tk.Label(overlay_frame, text="Overlay") diff --git a/artemis/plotting/tk_utils/test_tk_utils.py b/artemis/plotting/tk_utils/test_tk_utils.py index 4d1b7e07..5d484505 100644 --- a/artemis/plotting/tk_utils/test_tk_utils.py +++ b/artemis/plotting/tk_utils/test_tk_utils.py @@ -11,5 +11,10 @@ def test_show_blocking_task_dialog(): ).show_blocking_task_dialog(time.sleep(0.01) for _ in range(100)) + +# def test_widget_overlay_frame(): + + + if __name__ == "__main__": test_show_blocking_task_dialog() \ No newline at end of file diff --git a/artemis/plotting/tk_utils/test_ui_choose_parameters.py b/artemis/plotting/tk_utils/test_ui_choose_parameters.py new file mode 100644 index 00000000..0093fd47 --- /dev/null +++ b/artemis/plotting/tk_utils/test_ui_choose_parameters.py @@ -0,0 +1,109 @@ +from dataclasses import dataclass, replace +from typing import Sequence, Optional, Tuple, Any, Union + +import pytest + +from artemis.plotting.tk_utils.tk_utils import hold_tkinter_root_context +from artemis.plotting.tk_utils.ui_choose_parameters import ui_choose_parameters, ParameterSelectionFrame +import tkinter as tk + + +@dataclass +class Book: + title: str + pages: int + + +@dataclass +class Author: + name: str + age: int + books: Sequence[Book] + + +def test_ui_choose_parameters(): + steven_king = Author(name="Steven King", age=72, books=[Book(title="The Shining", pages=447), Book(title="The Stand", pages=1153)]) + steven_king_edited = ui_choose_parameters(initial_params=steven_king, timeout=0.2) + assert steven_king_edited == steven_king + + +def test_edit_params(): + + steven_king = Author(name="Steven King", age=72, books=[Book(title="The Shining", pages=447), Book(title="The Stand", pages=1153)]) + + callback_called = False + + with hold_tkinter_root_context() as root: + window = tk.Toplevel(root) + + def callback(key_path: Tuple[Union[str, int], ...], new_value: Any) -> None: + nonlocal callback_called + callback_called = True + assert key_path == ('age', ) + assert new_value.get() == 73 + print(f"Callback called with {key_path} and {new_value.get()}") + + ps_frame = ParameterSelectionFrame(window, on_change_callback=callback) + ps_frame.set_parameters(initial_params=steven_king) + ps_frame.pack() + ps_frame.get_variables()[('age', )].set(73) + + window.after(200, window.destroy) + window.wait_window() + assert callback_called + steven_king_edited = ps_frame.get_filled_parameters() + assert steven_king_edited == replace(steven_king, age=73) + + +@pytest.mark.skip(reason="Requires manual intervention") +def test_change_subfield(): + + steven_king = Author(name="Steven King", age=72, books=[Book(title="The Shining", pages=447), Book(title="The Stand", pages=1153)]) + steven_king_edited = ui_choose_parameters(initial_params=steven_king, title='Add one page to The Shining') + print(steven_king_edited) + assert steven_king_edited == replace(steven_king, books=[Book(title="The Shining", pages=448), Book(title="The Stand", pages=1153)]) + + +@pytest.mark.skip(reason="Requires manual intervention") +def test_nullable_field(): + + @dataclass + class Book: + title: str + chapter_names: Optional[Sequence[str]] = None # A book may not have chapters, in which case this field is None. It may also be an emty book, in which case it is an empty list. + + book = Book(title="The Shining", chapter_names=['Chapter 1', 'Chapter 2', 'Chapter 3']) + steven_king_edited = ui_choose_parameters(initial_params=book, title='Remove chapters') + print(steven_king_edited) + # assert steven_king_edited == replace(steven_king, books=[Book(title="The Shining", pages=448), Book(title="The Stand", pages=1153)]) + + +@pytest.mark.skip(reason="Requires manual intervention") +def test_nested_parameter_selection(): + + @dataclass + class Author: + name: str + age: int + books: Sequence[Book] + favourite_foods: Optional[Sequence[str]] = None # None means we don't know, empty list means they don't have any. + + new_params = ui_choose_parameters( + params_type=Author, + initial_params=Author(name="Steven King", age=72, books=[Book(title="The Shining", pages=447), Book(title="The Stand", pages=1153)]), + title='Edit Author', + depth = 1, + timeout=0.2, + # editable_fields=['nameame', 'age', 'books'], + # editable_fields=False, + ) + print(new_params) + + + +if __name__ == "__main__": + # test_ui_choose_parameters() + test_edit_params() + # test_change_subfield() + # test_nullable_field() + # test_nested_parameter_selection() diff --git a/artemis/plotting/tk_utils/test_ui_utils.py b/artemis/plotting/tk_utils/test_ui_utils.py new file mode 100644 index 00000000..b5d28cee --- /dev/null +++ b/artemis/plotting/tk_utils/test_ui_utils.py @@ -0,0 +1,49 @@ +from enum import Enum + +from artemis.plotting.tk_utils.tk_utils import hold_tkinter_root_context +from video_scanner.app_utils.constants import ThemeColours + + +def test_button_panel(wait: bool = False): + + with hold_tkinter_root_context() as root: + from artemis.plotting.tk_utils.ui_utils import ButtonPanel + bp = ButtonPanel(root, max_buttons_before_expand=3, as_row=False) + + bp.add_button('Button 1', lambda: print('Button 1')) + bp.add_button('Button 2', lambda: print('Button 2')) + bp.add_button('Button 3', lambda: print('Button 3')) + bp.add_button('Button 4', lambda: print('Button 4')) + bp.add_button('Button 5', lambda: print('Button 5')) + bp.pack() + root.update() + if wait: + root.wait_window() + + bp.destroy() + root.update() + + +def test_multi_state_toggle(manual: bool = False): + with hold_tkinter_root_context() as root: + from artemis.plotting.tk_utils.ui_utils import MultiStateToggle + + class States(Enum): # You can also use an Enum here + STATE_1 = 'State 1' + STATE_2 = 'State 2' + STATE_3 = 'State 3' + + mst = MultiStateToggle(root, States, on_state_change_callback=lambda s: print(f'Switched to {s.value}')) + mst.pack() + root.update() + + assert mst.get_state() == States.STATE_1 + mst.set_state(States.STATE_2) + assert mst.get_state() == States.STATE_2 + if manual: + root.wait_window() + + +if __name__ == '__main__': + # test_button_panel(wait=True) + test_multi_state_toggle(manual=True) diff --git a/artemis/plotting/tk_utils/tk_error_dialog.py b/artemis/plotting/tk_utils/tk_error_dialog.py index eeb34432..3e0d05dd 100644 --- a/artemis/plotting/tk_utils/tk_error_dialog.py +++ b/artemis/plotting/tk_utils/tk_error_dialog.py @@ -141,7 +141,7 @@ def copy_traceback(): print("Exiting error dialog") -def tk_show_eagle_eyes_error_dialog(error_details: ErrorDetail): +def tk_error_detail_handler(error_details: ErrorDetail): trace_str = error_details.traceback or traceback.format_exc() print(trace_str) tk_show_error_dialog( diff --git a/artemis/plotting/tk_utils/tk_scroll.py b/artemis/plotting/tk_utils/tk_scroll.py new file mode 100644 index 00000000..11f59e13 --- /dev/null +++ b/artemis/plotting/tk_utils/tk_scroll.py @@ -0,0 +1,98 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# Thank you Mark Pointing for this code + +import tkinter as tk +import platform + + +# ************************ +# Scrollable Frame Class +# ************************ +class ScrollFrame(tk.Frame): + def __init__(self, parent): + super().__init__(parent) # create a frame (self) + + self.canvas = tk.Canvas(self, borderwidth=0, background="#ffffff") # place canvas on self + self.viewPort = tk.Frame(self.canvas, background="#ffffff") # place a frame on the canvas, this frame will hold the child widgets + self.vsb = tk.Scrollbar(self, orient="vertical", command=self.canvas.yview) # place a scrollbar on self + self.canvas.configure(yscrollcommand=self.vsb.set) # attach scrollbar action to scroll of canvas + + self.vsb.pack(side="right", fill="y") # pack scrollbar to right of self + self.canvas.pack(side="left", fill="both", expand=True) # pack canvas to left of self and expand to fil + self.canvas_window = self.canvas.create_window((4, 4), window=self.viewPort, anchor="nw", # add view port frame to canvas + tags="self.viewPort") + + self.viewPort.bind("", self.onFrameConfigure) # bind an event whenever the size of the viewPort frame changes. + self.canvas.bind("", self.onCanvasConfigure) # bind an event whenever the size of the canvas frame changes. + + self.viewPort.bind('', self.onEnter) # bind wheel events when the cursor enters the control + self.viewPort.bind('', self.onLeave) # unbind wheel events when the cursorl leaves the control + + self.onFrameConfigure(None) # perform an initial stretch on render, otherwise the scroll region has a tiny border until the first resize + + def onFrameConfigure(self, event): + '''Reset the scroll region to encompass the inner frame''' + self.canvas.configure(scrollregion=self.canvas.bbox("all")) # whenever the size of the frame changes, alter the scroll region respectively. + + def onCanvasConfigure(self, event): + '''Reset the canvas window to encompass inner frame when required''' + canvas_width = event.width + self.canvas.itemconfig(self.canvas_window, width=canvas_width) # whenever the size of the canvas changes alter the window region respectively. + + def onMouseWheel(self, event): # cross platform scroll wheel event + if platform.system() == 'Windows': + self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + elif platform.system() == 'Darwin': + self.canvas.yview_scroll(int(-1 * event.delta), "units") + else: + if event.num == 4: + self.canvas.yview_scroll(-1, "units") + elif event.num == 5: + self.canvas.yview_scroll(1, "units") + + def onEnter(self, event): # bind wheel events when the cursor enters the control + if platform.system() == 'Linux': + self.canvas.bind_all("", self.onMouseWheel) + self.canvas.bind_all("", self.onMouseWheel) + else: + self.canvas.bind_all("", self.onMouseWheel) + + def onLeave(self, event): # unbind wheel events when the cursorl leaves the control + if platform.system() == 'Linux': + self.canvas.unbind_all("") + self.canvas.unbind_all("") + else: + self.canvas.unbind_all("") + + +# ******************************** +# Example usage of the above class +# ******************************** + +class Example(tk.Frame): + def __init__(self, root): + tk.Frame.__init__(self, root) + self.scrollFrame = ScrollFrame(self) # add a new scrollable frame. + + # Now add some controls to the scrollframe. + # NOTE: the child controls are added to the view port (scrollFrame.viewPort, NOT scrollframe itself) + for row in range(100): + a = row + tk.Label(self.scrollFrame.viewPort, text="%s" % row, width=3, borderwidth="1", + relief="solid").grid(row=row, column=0) + t = "this is the second column for row %s" % row + tk.Button(self.scrollFrame.viewPort, text=t, command=lambda x=a: self.printMsg("Hello " + str(x))).grid(row=row, column=1) + + # when packing the scrollframe, we pack scrollFrame itself (NOT the viewPort) + self.scrollFrame.pack(side="top", fill="both", expand=True) + + def printMsg(self, msg): + print(msg) + + +if __name__ == "__main__": + root = tk.Tk() + Example(root).pack(side="top", fill="both", expand=True) + root.mainloop() \ No newline at end of file diff --git a/artemis/plotting/tk_utils/tk_utils.py b/artemis/plotting/tk_utils/tk_utils.py index c5784cc7..d3ebf952 100644 --- a/artemis/plotting/tk_utils/tk_utils.py +++ b/artemis/plotting/tk_utils/tk_utils.py @@ -621,6 +621,14 @@ def hold_tkinter_root_context(): except tk.TclError: # This can happen if the root is destroyed before the context is exited pass +# +# def get_widget_overlay_frame( +# widget: tk.Widget, +# ) -> tk.Frame: + + + + if __name__ == '__main__': # reply = messagebox.askyesnocancel(message="Wooooo") diff --git a/artemis/plotting/tk_utils/ui_choose_parameters.py b/artemis/plotting/tk_utils/ui_choose_parameters.py index 5a3a72a8..338703d5 100644 --- a/artemis/plotting/tk_utils/ui_choose_parameters.py +++ b/artemis/plotting/tk_utils/ui_choose_parameters.py @@ -1,205 +1,915 @@ -import traceback -from dataclasses import fields, dataclass, field -from functools import partial -from tkinter import filedialog, messagebox -from typing import TypeVar, Optional -import tkinter as tk import os +import tkinter as tk +from abc import ABCMeta +from abc import abstractmethod +from dataclasses import dataclass, field, fields, replace +import pprint +from tkinter import messagebox, ttk +from typing import Optional, TypeVar, Sequence, get_origin, get_args, Any, Dict, Callable, Union, Generic, Tuple, Mapping +from more_itertools import first +from more_itertools.more import first + +# Assuming the required modules from artemis.plotting.tk_utils are available from artemis.plotting.tk_utils.constants import ThemeColours +from artemis.plotting.tk_utils.tk_utils import hold_tkinter_root_context from artemis.plotting.tk_utils.tooltip import create_tooltip +from artemis.plotting.tk_utils.ui_utils import ButtonPanel +from video_scanner.ui.ui_utils import RespectableLabel ParametersType = TypeVar('ParametersType') -def ui_choose_parameters( - params_type: type, # Some sort of dataclass (the class object) - initial_params: Optional[ParametersType] = None, - factory_reset_params: Optional[ParametersType] = None, - title: str = "Select Parameters", - prompt: str = "Hover mouse over for description of each parameter. Tab to switch fields, Enter to accept, Escape to cancel." -) -> Optional[ParametersType]: - """ Load, edit, save, and return the settings. """ +def get_default_for_param_type(param_type: type) -> Any: + if param_type == bool: + return False + elif isinstance(get_origin(param_type), type) and issubclass(get_origin(param_type), Sequence): + return [] + elif isinstance(get_origin(param_type), type) and is_optional_type(param_type): + return None + elif hasattr(param_type, "__dataclass_fields__"): + return param_type() + elif param_type == str: + return "" + elif param_type == int: + return 0 + elif param_type == float: + return 0.0 + else: + raise NotImplementedError(f"Type {param_type} not supported.") + + +class MockVariable(tk.Variable): + + def __init__(self, parent: tk.Widget, initial_value: Any = None): + super().__init__(parent) + self.value = initial_value + self._write_callback: Optional[Callable[[Any], None]] = None + + def get(self): + return self.value + + def set(self, value): + self.value = value + if self._write_callback is not None: + self._write_callback(value) + + def trigger_write_callback(self): + if self._write_callback is not None: + self._write_callback(self.value) + + def trace_add(self, mode: str, callback: Callable[[Any, Any, Any], None]): + if mode == "write": + self._write_callback = callback + else: + raise NotImplementedError(f"Mode {mode} not supported.") - chosen_params = params_type() if initial_params is None else initial_params +class IParameterSelectionFrame(tk.Frame, Generic[ParametersType], metaclass=ABCMeta): - window = tk.Toplevel() - # Set minimum width to 600px - window.minsize(800, 1) - # Make it fill parent - # window.grid_columnconfigure(0, weight=1) - window.grid_columnconfigure(1, weight=1) + @abstractmethod + def get_filled_parameters(self) -> Optional[ParametersType]: + raise NotImplementedError() - # window.geometry("800x500") - window.title(title) + def get_variables(self) -> Mapping[Tuple[Union[int, str], ...], tk.Variable]: + return {} - label = tk.Label(window, text=prompt) - label.grid(column=0, row=0, columnspan=2) - - var = {} - for i, f in enumerate(fields(params_type), start=1): - label = tk.Label(window, text=f.metadata['name'] if 'name' in f.metadata else f.name.replace("_", " ").capitalize() + ":") - create_tooltip(label, f.metadata.get("help", "No help available."), background=ThemeColours.HIGHLIGHT_COLOR) - label.grid(column=0, row=i) - - # Stre - # Depending on the type of the field, we'll need to do something different. - if f.type == str: # Entry - metadata_type = f.metadata.get("type", None) - var[f.name] = tk.StringVar(value=getattr(chosen_params, f.name)) - if metadata_type is None: - entry = tk.Entry(window, textvariable=var[f.name]) - elif metadata_type in ["file", "directory"]: - # Store rel stores relative to the default directory, so that if the program is moved, the path is still valid. - store_rel = f.metadata.get("store_relative", False) - default_directory = f.metadata.get("default_directory", None) - # if store_rel and default_directory is not None: - # var[f.name].set(os.path.join(default_directory, getattr(settings, f.name))) - entry = tk.Entry(window, textvariable=var[f.name]) - - def browse(v: tk.StringVar, for_directory: bool = False, default_directory: Optional[str] = None, store_rel: bool = False): - print(f"Default directory: {default_directory}") - path = filedialog.askdirectory(initialdir=default_directory) if for_directory else filedialog.askopenfilename(initialdir=default_directory) - if path: - if store_rel: - path = os.path.relpath(path, default_directory) - v.set(path) - browse_button = tk.Button(window, text="Browse", command=partial(browse, - v=var[f.name], - for_directory=metadata_type == "directory", - default_directory = default_directory, - store_rel=store_rel - )) - browse_button.grid(column=2, row=i, sticky="ew") - else: - raise NotImplementedError(f"Unknown metadata type {metadata_type}") - - if i==0: # Make it so keyboard is directed to this right away - entry.focus_set() - entry.grid(column=1, row=i, sticky="ew", ) - - elif f.type == bool: - var[f.name] = tk.BooleanVar(value=getattr(chosen_params, f.name)) - check_box = tk.Checkbutton(window, variable=var[f.name]) - # check_box.grid(column=1, row=i) - # Align left... - check_box.grid(column=1, row=i, sticky="w") - elif f.type == int: - var[f.name] = tk.IntVar(value=getattr(chosen_params, f.name)) - entry = tk.Entry(window, textvariable=var[f.name]) - entry.grid(column=1, row=i, sticky="w") - elif f.type == float: - var[f.name] = tk.DoubleVar(value=getattr(chosen_params, f.name)) - entry = tk.Entry(window, textvariable=var[f.name]) - entry.grid(column=1, row=i, sticky="w") +class EntryParameterSelectionFrame(IParameterSelectionFrame): + + def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder'): + super().__init__(master, **builder.general_kwargs) + self._builder = builder + self.var = tk.StringVar(master=self, value=self._builder.initial_value) if self._builder.param_type == str \ + else tk.DoubleVar(master=self, value=self._builder.initial_value) if self._builder.param_type == float else \ + tk.IntVar(master=self, value=self._builder.initial_value) + entry = tk.Entry(self, textvariable=self.var) + entry.grid(column=0, row=0, sticky="ew") + + def get_filled_parameters(self) -> ParametersType: + return self.var.get() + + def get_variables(self) -> Mapping[Tuple[Union[int, str], ...], tk.Variable]: + return {(): self.var} + + + +# def build_frame_with_added_widget(parent: tk.Widget, builder: 'ParameterUIBuilder', added_button_builder: Optional[Callable[[tk.Frame], tk.Widget]] = None) -> tk.Frame: +# frame = tk.Frame(parent) +# frame.grid(column=0, row=0, sticky="ew") +# # frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) +# builder.build_parameter_frame(frame) +# if added_button_builder is not None: +# widget = added_button_builder(frame) +# widget.grid(column=1, row=0, sticky="ew") +# return frame + + +class AddedWidgetParameterSelectionFrame(IParameterSelectionFrame[ParametersType]): + + def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder', added_button_builder: Optional[Callable[[tk.Frame, 'ParameterUIBuilder'], tk.Widget]] = None): + super().__init__(master, **builder.general_kwargs) + self._builder = builder + self._initial_value = builder.initial_value + # Leftward text alignmnent + # label = RespectableLabel(self, text=str(builder.initial_value), anchor=tk.W, justify=tk.LEFT) + + self._child = builder.remove_custom_constructor_matching_path().build_parameter_frame(self) + + self._child.grid(column=0, row=0, sticky="ew") + if added_button_builder is not None: + widget = added_button_builder(self, self._builder) + widget.grid(column=1, row=0, sticky="ew") + + @classmethod + def make_constructor(cls, added_button_builder: Optional[Callable[[tk.Frame, 'ParameterUIBuilder'], tk.Widget]] = None) -> Callable[[tk.Widget, 'ParameterUIBuilder'], 'IParameterSelectionFrame']: + def constructor(master: tk.Widget, builder: 'ParameterUIBuilder') -> tk.Widget: + return cls(master, builder, added_button_builder=added_button_builder) + return constructor + + def get_filled_parameters(self) -> ParametersType: + return self._child.get_filled_parameters() + + + +class BooleanParameterSelectionFrame(IParameterSelectionFrame[bool]): + + def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder'): + super().__init__(master, **builder.general_kwargs) + self.var = tk.BooleanVar(master=self, value=self._builder.initial_value) + check_box = tk.Checkbutton(self, variable=self.var, state=tk.NORMAL if builder.editable_fields else tk.DISABLED) + check_box.grid(column=0, row=0, sticky="w") + + def get_filled_parameters(self) -> bool: + return self.var.get() + + def get_variables(self) -> Mapping[Tuple[Union[int, str], ...], tk.Variable]: + return {(): self.var} + + +def is_fixed_size_tuple(param_type: type) -> bool: + """ + Checks if a type is a fixed-size tuple, e.g. Tuple[int, str] or Tuple[int, str, float] + Examples of things that are not fixed-size tuples are Tuple[int, ...] and Tuple[int, str, ...] + """ + # So if last arg is elipsis, it's not fixed size + return isinstance(get_origin(param_type), type) and issubclass(get_origin(param_type), Tuple) and not get_args(param_type)[-1] is Ellipsis + + +class SequenceParameterSelectionFrame(IParameterSelectionFrame[Sequence[Any]]): + + def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder', allow_editing: bool = True): + super().__init__(master, **builder.general_kwargs) + self._builder = builder + self.var = MockVariable(self, list(self._builder.initial_value)) + # self._current_values = list(self._builder.initial_value) + # self._param_type = self._builder.param_type + self._child_frames: Sequence[IParameterSelectionFrame] = [] + # self._editable_fields = self._builder.editable_fields + # self._include_add_option = include_add_option + + self._rebuild_child_frames() + + def _rebuild_child_frames(self): + self._child_frames = [] + + is_editable = self._builder.is_path_matching_editable_fields() + is_dynamic_type = not is_fixed_size_tuple(self._builder.param_type) + is_dynamic = is_dynamic_type and is_editable + for child in self.winfo_children(): + child.destroy() + for i, item in enumerate(self._builder.initial_value): + row = i * 2 + + # Big minus sign: + if is_dynamic: + remove_button = RespectableLabel(self, text="➖", command=lambda i=i: self._remove_item(i)) + remove_button.grid(column=0, row=row, sticky="ew") + label = RespectableLabel(self, text=str(i), anchor=tk.W) + label.grid(column=1, row=row, sticky="ew") + + child_frame = self._builder.modify_for_subfield(subfield_index=i, subfield_type=get_args(self._builder.param_type)[0]).build_parameter_frame(self) + + # child_frame = build_parameter_frame(self, self._param_type, item, editable_fields=editable_subfields) + child_frame.grid(column=2, row=row, sticky="ew") + + # Horizontal line after, but not after the last one + separator = ttk.Separator(self, orient=tk.HORIZONTAL) + separator.grid(column=0, row=row + 1, columnspan=3, sticky="ew") + + self._child_frames.append(child_frame) + if is_dynamic and self._builder.allow_growing_collections: + add_button = RespectableLabel(self, text="➕", command=self._add_item) + add_button.grid(column=0, row=len(self._builder.initial_value) * 2, sticky="ew") + label = RespectableLabel(self, text=str(len(self._builder.initial_value)) + "...", anchor=tk.W) + label.grid(column=1, row=len(self._builder.initial_value) * 2, sticky="ew") + + def _add_item(self): + # self._builder.initial_value.append(get_default_for_param_type(self._param_type)) + self._builder = replace(self._builder, initial_value=list(self._builder.initial_value) + [get_default_for_param_type(self._builder.param_type)]) + self._rebuild_child_frames() + self.var.trigger_write_callback() + + def _remove_item(self, index: int): + # del self._current_values[index] + self._builder = replace(self._builder, initial_value=[*self._builder.initial_value[:index], *self._builder.initial_value[index + 1:]]) + self._rebuild_child_frames() + self.var.trigger_write_callback() + + def get_filled_parameters(self) -> Sequence[Any]: + return [child.get_filled_parameters() for child in self._child_frames] + + def get_variables(self) -> Mapping[Tuple[Union[int, str], ...], tk.Variable]: + return {(): self.var, **{(i,) + k: v for i, child in enumerate(self._child_frames) for k, v in child.get_variables().items()}} + + +class OptionalParameterSelectionFrame(IParameterSelectionFrame[Optional[Any]]): + + def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder'): + super().__init__(master, **builder.general_kwargs) + self._builder = builder + self._checkbox_var = tk.BooleanVar(value=self._builder.initial_value is not None) + check_box = tk.Checkbutton(self, variable=self._checkbox_var) + check_box.grid(column=0, row=0, sticky="w") + # self._child_frame = build_parameter_frame(self, param_type, initial_value if initial_value is not None else get_default_for_param_type(param_type), editable_fields=editable_fields) + self._child_frame = replace(self._builder, + param_type=get_args(self._builder.param_type)[0], + initial_value=self._builder.initial_value if self._builder.initial_value is not None else get_default_for_param_type(get_args(self._builder.param_type)[0]), + ).build_parameter_frame(self) + self._on_checkbox_change(self._checkbox_var.get()) + + def _on_checkbox_change(self, new_value: bool): + if new_value: + self._child_frame.grid(column=1, row=0, sticky="ew") else: - raise NotImplementedError(f"Type {f.type} not supported.") - - def read_settings_object() -> Optional[params_type]: - """ Read the settings object from the UI. """ - try: - settings_dict = {} - for f in fields(params_type): - settings_dict[f.name] = var[f.name].get() - return params_type(**settings_dict) - except Exception as e: - messagebox.showerror("Error", f"Error reading settings from UI:\n\n {e} \n\n(see Log)") - print(traceback.format_exc()) - return None + self._child_frame.grid_forget() - # Ok, lets get buttons for "Cancel", "Update", and "Factory Reset", with Default being "save" - def cancel(): - nonlocal chosen_params - chosen_params = None - window.destroy() + def get_filled_parameters(self) -> Optional[Any]: + return self._child_frame.get_filled_parameters() if self._checkbox_var.get() else None - def ok(): - nonlocal chosen_params - new_settings = read_settings_object() - if new_settings is not None: - chosen_params = new_settings - window.destroy() + def get_variables(self) -> Mapping[Tuple[Union[int, str], ...], tk.Variable]: + return self._child_frame.get_variables() if self._checkbox_var.get() else {} - def factory_reset(): - nonlocal chosen_params - window.destroy() - # new_settings = ui_load_edit_save_get_settings(factory_reset=True) - chosen_params = factory_reset_params - button_row = tk.Frame(window) - button_row.grid(column=0, row=100, columnspan=3) +class ButtonParameterSelectionFrame(IParameterSelectionFrame[ParametersType]): + """ Parameter is summarized in a clickable label which, when clicked, can open up an edit menu + with ui_choose_parameters + """ + def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder'): + super().__init__(master, **builder.general_kwargs) + self._builder = builder + self.var = MockVariable(self, initial_value=self._builder.initial_value) + # self._param_type = param_type + # self._current_value = initial_value + self._label = RespectableLabel(self, anchor=tk.W, command=self._on_click, text="") + self._label.grid(column=0, row=0, sticky="ew") + # self._editable_fields = [e for e in editable_fields if e] + # self.var.set(self._builder.initial_value) + self._update_label() - # cancel_button = tk.Button(window, text="Cancel", command=cancel) - # cancel_button.grid(column=0, row=100) - # update_button = tk.Button(window, text="Update", command=update) - # update_button.grid(column=1, row=100) - # factory_reset_button = tk.Button(window, text="Factory Reset", command=factory_reset) - # factory_reset_button.grid(column=2, row=100) + def _update_label(self): + self._label.configure(text=str(self.var.get())) - # Set the focus on the first parameter - # var[fields(params_type)[0].name].focus_set() + def _on_click(self): + new_value = ui_choose_parameters(builder=self._builder) + if new_value is not None: + self.var.set(new_value) + self._update_label() - cancel_button = tk.Button(button_row, text="Cancel", command=cancel) - cancel_button.grid(column=0, row=0) - ok_button = tk.Button(button_row, text="Ok", command=ok, default=tk.ACTIVE) - ok_button.grid(column=1, row=0) - if factory_reset_params is not None: - factory_reset_button = tk.Button(button_row, text="Factory Reset", command=factory_reset) - factory_reset_button.grid(column=2, row=0) + def get_filled_parameters(self) -> ParametersType: + return self.var.get() - # Put on top - window.attributes('-topmost', True) + def get_variables(self) -> Mapping[Tuple[Union[int, str]], tk.Variable]: + return {(): self.var} - window.focus_force() - ok_button.focus_set() - window.bind('', lambda event: ok_button.invoke()) - window.bind('', lambda event: cancel_button.invoke()) - window.wait_window() +class DataclassParameterSelectionFrame(IParameterSelectionFrame[ParametersType]): - return chosen_params + def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder'): + super().__init__(master, **builder.general_kwargs) + self._builder = builder + flds = fields(self._builder.param_type) + # self.params_type = self._param_builder.param_type + self.columnconfigure(0, weight=0) + self.columnconfigure(1, weight=3) + self.rowconfigure(1, weight=1) + # self.initial_params = initial_value + self._child_frames = [] -FieldType = TypeVar("FieldType", bound=object) + for row_index, f in enumerate(flds): + row = 2 * row_index + label = RespectableLabel(self, text=f.metadata['name'] if 'name' in f.metadata else f.name.replace("_", " ").capitalize() + ":", + tooltip=f.metadata.get("help", "No help available.")) + label.grid(column=0, row=row, sticky="ew") -nodefault = object() + # editable_subfields = [e for e in editable_fields if len((x := e.split("."))) and x[0] == f.name] if isinstance(editable_fields, Sequence) else editable_fields + # editable_subfields = filter_subfields(editable_fields, f.name) + frame = self._builder.modify_for_subfield(subfield_index=f.name, subfield_type=f.type).build_parameter_frame(self) + # parent=self, + # param_type=f.type, + # initial_value=getattr(initial_value, f.name) if initial_value is not None else None, + # editable_fields=filter_subfields(editable_fields, f.name), -def ui_choose_field( - name: str, - dtype: type(FieldType), - default: Optional[FieldType] = nodefault, - title: str = "Choose Value", - prompt: str = "Choose a value ", - tooltip: Optional[str] = None -) -> FieldType: + frame.grid(column=1, row=row, sticky="ew") + self._child_frames.append(frame) - @dataclass - class TempClass: - tempfield: dtype = field(default=default, metadata=dict(help=tooltip, name=name)) + # Horizontal line after, but not after the last one + if row_index < len(flds) - 1: + separator = ttk.Separator(self, orient=tk.HORIZONTAL) + separator.grid(column=0, row=row + 1, columnspan=3, sticky="ew") - result = ui_choose_parameters( - params_type=TempClass, - initial_params=TempClass(tempfield=default) if default is not nodefault else None, - title=title, - prompt=prompt - ) - return result.tempfield if result is not None else None + def get_filled_parameters(self) -> ParametersType: + return replace(self._builder.initial_value, **{f.name: child.get_filled_parameters() for f, child in zip(fields(self._builder.param_type), self._child_frames)}) + def get_variables(self) -> Mapping[Tuple[Union[int, str]], tk.Variable]: + return {(f.name,) + k: v for f, child in zip(fields(self._builder.param_type), self._child_frames) for k, v in child.get_variables().items()} -if __name__ == "__main__": +class UneditableParameterSelectionFrame(IParameterSelectionFrame[ParametersType]): + + def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder', added_button_builder: Optional[Callable[[tk.Frame], tk.Widget]] = None): + super().__init__(master, **builder.general_kwargs) + self._builder = builder + self._initial_value = builder.initial_value + # Leftward text alignmnent + label = RespectableLabel(self, text=str(builder.initial_value), anchor=tk.W, justify=tk.LEFT) + label.grid(column=0, row=0, sticky="ew") + if added_button_builder is not None: + widget = added_button_builder(self) + widget.grid(column=1, row=0, sticky="ew") + + def get_filled_parameters(self) -> ParametersType: + return self._initial_value + - # @dataclass - # class MyParams: - # some_float: float = 4 - # some_int: int = field(default=3, metadata=dict(help="Select some integer")) - # some_file: str = field(default=os.path.expanduser("~/some_image.jpg"), metadata=dict(type='file')) +class WrapperParameterSelectionFrame(IParameterSelectionFrame[ParametersType]): + + def __init__(self, master: tk.Widget, frame: IParameterSelectionFrame): + super().__init__(master) + + self._frame = frame + # self._child_frame = self._builder.modify_for_subfield(subfield_index=0, subfield_type=self._builder.param_type).build_parameter_frame(self) + # self._child_frame.grid(column=0, row=0, sticky="ew") + + def get_filled_parameters(self) -> ParametersType: + return self._frame.get_filled_parameters() + + +def is_optional_type(param_type: type) -> bool: + return get_origin(param_type) is Union and type(None) in get_args(param_type) + + + +def does_field_match_pattern( + field_path: str, # e.g. "a.b.c" + pattern: str, # e.g. "a.*.c" + include_subpatterns: bool = False # If True, then field a.b will match the pattern a.b.c +) -> bool: + """ Check if a field matches a pattern, e.g. does_field_match_pattern("a.b.c", "a.*.c") -> True + + If include_subpatterns is True, + field_path 'a.b' will match pattern 'a.b.c' + but note that + field_path 'a.b.c' will NOT match pattern 'a.b' + """ + field_path_parts = field_path.lstrip('.').split(".") + pattern_parts = pattern.split(".") + + if (not include_subpatterns) and len(field_path_parts) < len(pattern_parts): + return False + + for field_path_part, pattern_part in zip(field_path_parts, pattern_parts): + if pattern_part != "*" and field_path_part != pattern_part: + return False + + return True + + +def filter_subfields(original_editable_fields: Union[Sequence[str], bool], field_name: Union[int, str]) -> Union[Sequence[str], bool]: + if isinstance(original_editable_fields, bool): + return original_editable_fields + else: + matching_fields = [tuple(x) for e in original_editable_fields if e and (x := e.split(".")) and (x[0] == str(field_name) or x[0] == "*")] + + if matching_fields == [(str(field_name),)]: + return True + elif not matching_fields: + return False + else: + return ['.'.join(x[1:]) for x in matching_fields if len(x) >= 1] + + +@dataclass +class ParameterUIBuilder: + """ A class that builds a UI for a parameter. """ + # parent: Optional[tk.Widget] # The parent widget + param_type: type # The type of the parameter. If None, initial_value must be provided. + initial_value: Any # The initial value to display. If None, param_type must be provided. + path: str = '' # The path to the parameter, e.g. "a.b.c" means the "c" field of the "b" field of the "a" field. + editable_fields: Union[bool, Sequence[str]] = True # Either + end_field_patterns: Sequence[str] = () # Paths to fields that should not be expanded upon + allow_growing_collections: bool = False # If True, then if the parameter is a collection, you can add new elements to it. + custom_constructors: Mapping[str, Callable[[tk.Widget, 'ParameterUIBuilder'], IParameterSelectionFrame]] = field(default_factory=dict) + general_kwargs: Dict[str, Any] = field(default_factory=dict) + on_change_callback: Optional[Callable[[Tuple[Union[str, int]], tk.Variable], None]] = None + custom_widget_constructors: Mapping[str, Callable[[tk.Widget, 'ParameterUIBuilder'], tk.Widget]] = field(default_factory=dict) + # A callback in the form f(path, variable) that will be called whenever a field changes. + # The path is a tuple of strings and ints that describes the path to the variable, e.g. ("a", 1, "b") means the + # variable is the "b" field of the 1st element of the "a" field. The variable is the tk.Variable that was changed. + + def remove_custom_constructor_matching_path(self) -> 'ParameterUIBuilder': + return replace(self, custom_constructors={pattern: constructor for pattern, constructor in self.custom_constructors.items() if not does_field_match_pattern(self.path, pattern)}) + + def is_path_matching_editable_fields(self, include_subpatterns=False) -> bool: + return self.editable_fields is True or not isinstance(self.editable_fields, bool) and any(does_field_match_pattern(self.path, pattern, include_subpatterns=include_subpatterns) for pattern in self.editable_fields) + + def get_extra_widget_or_none(self, path: str) -> Optional[tk.Widget]: + matching_pattern = first((pattern for pattern in self.extra_widget_builders if does_field_match_pattern(path, pattern)), None) + if matching_pattern is not None: + builder = self.extra_widget_builders[matching_pattern] + return builder(path) + else: + return None + + def is_end_field(self) -> bool: + return any(does_field_match_pattern(self.path, pattern) for pattern in self.end_field_patterns) + + def modify_for_subfield(self, subfield_index: Union[int, str], subfield_type: type) -> 'ParameterUIBuilder': + return replace( + self, + # parent=parent, + path=self.path + "." + str(subfield_index), + initial_value=getattr(self.initial_value, subfield_index) if isinstance(subfield_index, str) else self.initial_value[subfield_index], + param_type=subfield_type, + ) + + def modify_for_new_menu_window(self) -> 'ParameterUIBuilder': + """ Modify the builder for a new window that is opened after clicking on an editable end-field""" + return replace(self, end_field_patterns=[e for e in self.end_field_patterns if not does_field_match_pattern(self.path, e)]) + + def build_parameter_frame(self, parent: tk.Widget) -> IParameterSelectionFrame: + """ + Build a TKinter frame that lets you view and edit parameters. + + :param parent: The parent widget + :param param_type: The type of the parameter. If None, initial_value must be provided. + :param initial_value: The initial value to display. If None, param_type must be provided. + :param editable_fields: Either + - True: All fields (or THE field if it is a single value) are editable + - False: All fields (or THE field if it is a single value) are NOT editable + - A sequence of strings: Only the fields with those names are editable. + The strings can be nested, e.g. "a.b.c" will make the "c" field of the "b" field of the "a" field editable. + "*" in the nested strings means "all fields", e.g. "a.*.c" will make the "c" field of all the "a" fields editable. + - EMPTY: No fields are editable (same as False) + :param on_change_callback: A callback in the form f(path, variable) that will be called whenever a field changes. + The path is a tuple of strings and ints that describes the path to the variable, e.g. ("a", 1, "b") means the + variable is the "b" field of the 1st element of the "a" field. The variable is the tk.Variable that was changed. + :param kwargs: + :return: + """ + # assert self.parent is not None, "Parent must be provided." + + if self.param_type is None: + assert self.initial_value is not None, "If params_type is None, initial_value must be provided." + param_type=type(self.initial_value) + else: + param_type = self.param_type + + if (constructor:=first((f for pattern, f in self.custom_constructors.items() if does_field_match_pattern(self.path, pattern)), None)) is not None: + return constructor(parent, self) + elif param_type in [str, int, float, bool]: # It's just a single value we don't have to think about whether to break in + if not self.is_path_matching_editable_fields(): # If we're not editing anything, just show the value + frame = UneditableParameterSelectionFrame(parent, builder=self) + elif param_type == bool: # Start with shallow objects + frame = BooleanParameterSelectionFrame(parent, builder=self) + elif param_type == str or param_type == int or param_type == float: + frame = EntryParameterSelectionFrame(parent, builder=self) + else: + raise NotImplementedError(f"Type {param_type} not supported.") + else: # We need to break in + # If we're not editing anything, just show the value + if self.is_end_field(): # We do not recurse further down, but leave it as a label + # If the field or subfields are editable, make it a button + if self.is_path_matching_editable_fields(include_subpatterns=True): + frame = ButtonParameterSelectionFrame(parent, builder=self.modify_for_new_menu_window()) + else: + frame = UneditableParameterSelectionFrame(parent, builder=self) + elif isinstance(get_origin(param_type), type) and issubclass(get_origin(param_type), Sequence): # Now nested objects + frame = SequenceParameterSelectionFrame(parent, builder=self) + elif isinstance(get_origin(param_type), type) and is_optional_type(param_type): + frame = OptionalParameterSelectionFrame(parent, builder=self) + elif hasattr(param_type, "__dataclass_fields__"): + frame = DataclassParameterSelectionFrame(parent, builder=self) + elif is_optional_type(param_type): + frame = OptionalParameterSelectionFrame(parent, builder=self) + else: + raise NotImplementedError(f"Type {param_type} not supported.") + + # extra_widget = self.get_extra_widget_or_none(self.path) + # if extra_widget is not None: + # extra_widget.grid(column=2, row=0, sticky="ew") # Note - assumes parent is a grid + + # Thin outline, align to top-left + # frame.configure(borderwidth=1, relief=tk.SOLID, highlightthickness=1, highlightbackground=ThemeColours.HIGHLIGHT_COLOR) + + # Handle extra widgets - requires inserting a parent frame to contain the extra widget + # extra_widget_pattern_or_none = first((pattern for pattern in self.extra_widget_builders if does_field_match_pattern(self.path, pattern)), None) + # if extra_widget_pattern_or_none is not None: + # # print(f"Got pattern {extra_widget_pattern_or_none} for path {self.path}") + # grandparent = parent + # parent = tk.Frame(grandparent) + # parent.grid(column=0, row=0, sticky="ew") + # # parent.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + # widget = self.extra_widget_builders[extra_widget_pattern_or_none](grandparent, self.path) + # widget.grid(column=1, row=0, sticky="ew") + # # widget.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + # return WrapperParameterSelectionFrame(parent, frame=frame) + # else: + # + # return frame + return frame + + +# def build_parameter_frame( +# parent: tk.Widget, +# param_type: Optional[type], +# initial_value: Any, +# editable_fields: Union[bool, Sequence[str]] = True, +# extra_widget_builders = {}, +# # on_change_callback: Optional[Callable[[Tuple[Union[str, int]], tk.Variable], None]] = None, +# **kwargs +# ) -> IParameterSelectionFrame: +# """ +# Build a TKinter frame that lets you view and edit parameters. +# +# :param parent: The parent widget +# :param param_type: The type of the parameter. If None, initial_value must be provided. +# :param initial_value: The initial value to display. If None, param_type must be provided. +# :param editable_fields: Either +# - True: All fields (or THE field if it is a single value) are editable +# - False: All fields (or THE field if it is a single value) are NOT editable +# - A sequence of strings: Only the fields with those names are editable. +# The strings can be nested, e.g. "a.b.c" will make the "c" field of the "b" field of the "a" field editable. +# "*" in the nested strings means "all fields", e.g. "a.*.c" will make the "c" field of all the "a" fields editable. +# - EMPTY: No fields are editable (same as False) +# :param on_change_callback: A callback in the form f(path, variable) that will be called whenever a field changes. +# The path is a tuple of strings and ints that describes the path to the variable, e.g. ("a", 1, "b") means the +# variable is the "b" field of the 1st element of the "a" field. The variable is the tk.Variable that was changed. +# :param kwargs: +# :return: +# """ +# if param_type is None: +# assert initial_value is not None, "If params_type is None, initial_value must be provided." +# param_type = type(initial_value) +# +# is_leaf_param = param_type in [str, int, float, bool] +# +# if is_leaf_param: # It's just a single value we don't have to think about whether to break in +# if not editable_fields: # If we're not editing anything, just show the value +# frame = UneditableParameterSelectionFrame(parent, param_type, initial_value, **kwargs) +# elif param_type == bool: # Start with shallow objects +# frame = BooleanParameterSelectionFrame(parent, initial_value, **kwargs) +# elif param_type == str or param_type == int or param_type == float: +# frame = EntryParameterSelectionFrame(parent, param_type, initial_value, **kwargs) +# else: +# raise NotImplementedError(f"Type {param_type} not supported.") +# else: # We need to break in +# # If we're not editing anything, just show the value +# if editable_fields is False: +# frame = UneditableParameterSelectionFrame(parent, param_type, initial_value, **kwargs) +# elif editable_fields is True or '' in editable_fields: # If we're editing everything, or the top-level object, then we can edit it +# return ButtonParameterSelectionFrame(parent, param_type, initial_value, editable_fields=editable_fields, **kwargs) +# elif isinstance(get_origin(param_type), type) and issubclass(get_origin(param_type), Sequence): # Now nested objects +# frame = SequenceParameterSelectionFrame(parent, get_args(param_type)[0], initial_value, editable_fields=editable_fields, **kwargs) +# elif isinstance(get_origin(param_type), type) and is_optional_type(param_type): +# frame = OptionalParameterSelectionFrame(parent, get_args(param_type)[0], initial_value, editable_fields=editable_fields, **kwargs) +# elif hasattr(param_type, "__dataclass_fields__"): +# frame = DataclassParameterSelectionFrame(parent, param_type, initial_value, editable_fields=editable_fields, **kwargs) +# elif is_optional_type(param_type): +# frame = OptionalParameterSelectionFrame(parent, get_args(param_type)[0], initial_value, editable_fields=editable_fields, **kwargs) +# else: +# raise NotImplementedError(f"Type {param_type} not supported.") +# +# # Thin outline, align to top-left +# # frame.configure(borderwidth=1, relief=tk.SOLID, highlightthickness=1, highlightbackground=ThemeColours.HIGHLIGHT_COLOR) +# +# return frame + + +class ParameterSelectionFrame(tk.Frame): + + def __init__(self, + master: tk.Widget, + on_change_callback: Optional[Callable[[Tuple[Union[int, str]], tk.Variable], None]] = None, + **kwargs): + super().__init__(master, **kwargs) + + self._param_frame: Optional[IParameterSelectionFrame] = None + # self._on_change_callback = on_change_callback + + def reset_frame(self): + if self._param_frame is not None: + self._param_frame.pack_forget() + self._param_frame.destroy() + self._param_frame = None + + def set_parameters(self, builder: ParameterUIBuilder): + + if self._param_frame is not None: + self._param_frame.destroy() + self._param_frame = builder.build_parameter_frame(self) + self._param_frame.pack(fill=tk.BOTH, expand=True) + if builder.on_change_callback is not None: + # TODO: Handle cases where number of variables changes + # Add a trace to all variables + for path, var in self._param_frame.get_variables().items(): + var.trace_add("write", lambda *args, path=path, var=var: builder.on_change_callback(path, var)) + + # Now, set the focus to the first editable field + first_editable_field: Optional[tk.Widget] = first((child for child in self._param_frame.winfo_children() if child.winfo_class() == "Entry"), default=None) + if first_editable_field is not None: + first_editable_field.focus_set() + + def get_filled_parameters(self) -> Optional[ParametersType]: + return self._param_frame.get_filled_parameters() if self._param_frame is not None else None + + def get_variables(self) -> Mapping[Tuple[Union[int, str]], tk.Variable]: + return self._param_frame.get_variables() if self._param_frame is not None else {} + + +# class ParameterSelectionFrame(tk.Frame): +# +# def __init__(self, parent, +# title: str = "Select Parameters", +# prompt: str = "", +# include_buttons: bool = False, +# on_change_callback: Optional[Callable[[ParametersType], None]] = None, +# **kwargs): +# super().__init__(parent, **kwargs) +# self.params_type = None +# self.var: Dict[str, tk.Variable] = {} # +# self.initial_params = None +# self.factory_reset_params = None +# self._editable_fields: Optional[Sequence[str]] = None +# self._on_change_callback = on_change_callback +# +# self._param_frame = tk.Frame(self) +# self._param_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) +# # Configure grid to take up all available space +# self._param_frame.columnconfigure(0, weight=0) +# self._param_frame.columnconfigure(1, weight=3) +# self._param_frame.rowconfigure(1, weight=1) +# tk.Label(self._param_frame, text=prompt).grid(column=0, row=0, columnspan=3) +# +# if include_buttons: +# button_panel = ButtonPanel(self) +# button_panel.pack(side=tk.BOTTOM, fill=tk.X) +# button_panel.add_button("Cancel", self._on_cancel, shortcut="") +# button_panel.add_button("OK", self._on_ok, shortcut="") +# button_panel.add_button("Reset", self._on_reset, shortcut="") +# +# +# +# +# +# +# # if include_exit_button: +# # bottom_frame = tk.Frame(self) +# # bottom_frame.pack(side=tk.BOTTOM, fill=tk.X) +# # +# # tk.Button(bottom_frame, text="Cancel", command=self.master.destroy).pack(side=tk.RIGHT) +# # tk.Button(bottom_frame, text="OK", command=self.master.destroy).pack(side=tk.RIGHT) +# # +# def _on_cancel(self): +# self.set_parameters(params_type=self.params_type, initial_params=self.initial_params, factory_reset_params=self.factory_reset_params) +# self.master.destroy() +# +# def _on_ok(self): +# self.master.destroy() +# +# def _on_reset(self): +# self.set_parameters(params_type=self.params_type, initial_params=self.factory_reset_params, factory_reset_params=self.factory_reset_params) +# +# def set_parameters(self, +# params_type: Optional[type] = None, +# initial_params: Optional[ParametersType] = None, +# factory_reset_params: Optional[ParametersType] = None, +# editable_fields: Optional[Sequence[str]] = None, # Note means "all", ok? +# ): +# +# if params_type is None: +# assert initial_params is not None, "If params_type is None, initial_params must be provided." +# params_type = type(initial_params) +# +# self.params_type = params_type +# self.initial_params = initial_params +# self.factory_reset_params = factory_reset_params +# self._editable_fields = editable_fields +# +# +# +# +# self._recreate_fields() +# +# def set_field_value(self, field_name: str, value: Any, field_collection_index: Optional[int] = None): +# if field_collection_index is None: +# self.var[field_name].set(value) +# else: +# self.var[field_name].get()[field_collection_index] = value +# +# def _recreate_fields(self, ): +# +# # First, clear any existing fields +# for child in self._param_frame.winfo_children(): +# child.destroy() +# if self.params_type is None: +# return +# for i, f in enumerate(fields(self.params_type), start=1): +# # if editable_field_names is None or f.name in editable_field_names: +# self._create_field_ui(f, i, editable=f.name in self._editable_fields if self._editable_fields is not None else True) +# # else: +# +# def _create_field_ui(self, f, row: int, editable: bool = True): +# label = tk.Label(self._param_frame, text=f.metadata['name'] if 'name' in f.metadata else f.name.replace("_", " ").capitalize() + ":") +# create_tooltip(label, f.metadata.get("help", "No help available."), background=ThemeColours.HIGHLIGHT_COLOR) +# label.grid(column=0, row=row) +# +# initial_value = getattr(self.initial_params, f.name) if self.initial_params else f.default +# factory_value = getattr(self.factory_reset_params, f.name) if self.factory_reset_params else None +# value_to_set = factory_value if factory_value is not None else initial_value +# +# if isinstance(get_origin(f.type), type) and issubclass(get_origin(f.type), Sequence): +# +# # Just add a bunch of string fields in a single frame +# frame = tk.Frame(self._param_frame) +# frame.grid(column=1, row=row, sticky="ew") +# self.var[f.name] = MockVariable(value=list(value_to_set)) +# self._bind_post_write_callback(self.var[f.name]) +# element_frame = tk.Frame(self._param_frame) +# element_frame.grid(column=1, row=row, sticky="ew") +# for i, item in enumerate(value_to_set): +# # var = tk.StringVar(value=item) +# # entry = tk.Entry(frame, textvariable=var, state='readonly') +# # entry.grid(column=1, row=i, sticky="ew") +# # self.var[f.name] = var +# # just make a label +# if editable: +# label = RespectableLabel(element_frame, text=str(item), anchor=tk.W) +# label.set_command(lambda n=f.name, i=i, label=label: self._edit_subfield(label, n, i)) +# else: +# label = RespectableLabel(element_frame, text=str(item), anchor=tk.W) +# +# label.grid(column=0, row=i, sticky="ew") +# if editable: # Add a garbage-bin button to the right to delete the annotation +# delete_button = RespectableLabel(element_frame, text="🗑", command=lambda n=f.name, i=i, label=label, : self._delete_subfield(label, n, i)) +# delete_button.grid(column=1, row=i, sticky="ew") +# +# +# else: +# +# if editable: +# if f.type == str: +# self._create_string_field(f, row, value_to_set) +# elif f.type == bool: +# self._create_boolean_field(f, row, value_to_set) +# elif f.type in [int, float]: +# self._create_numeric_field(f, row, value_to_set) +# else: +# raise NotImplementedError(f"Type {f.type} not supported.") +# else: +# label = RespectableLabel(self._param_frame, text=str(value_to_set), anchor=tk.W) +# label.grid(column=1, row=row, sticky="ew") +# +# def _edit_subfield(self, label: tk.Label, field_name: str, field_collection_index: Optional[int] = None): +# """ Edit a subfield of a field that is a sequence. """ +# current_field_object = self.var[field_name].get() if field_collection_index is None else self.var[field_name].get()[field_collection_index] +# new_object = ui_choose_parameters(params_type=type(current_field_object), initial_params=current_field_object) +# self.set_field_value(field_name, new_object, field_collection_index) +# label.configure(text=str(new_object)) +# +# def _delete_subfield(self, label: tk.Label, field_name: str, field_collection_index: int): +# """ Delete a subfield of a field that is a sequence. """ +# del self.var[field_name].get()[field_collection_index] +# label.master.destroy() +# self._on_change_callback(self.get_filled_parameters()) +# +# def _bind_post_write_callback(self, var: tk.Variable): +# if self._on_change_callback is not None: +# var.trace_add("write", lambda *args, var=var: self._on_change_callback(self.get_filled_parameters())) +# +# def _create_string_field(self, f, row, initial_value): +# var = tk.StringVar(value=initial_value) +# entry = tk.Entry(self._param_frame, textvariable=var) +# entry.grid(column=1, row=row, sticky="ew") +# self.var[f.name] = var +# # Link to change callback +# self._bind_post_write_callback(var) +# +# def _create_boolean_field(self, f, row, initial_value): +# var = tk.BooleanVar(value=initial_value) +# check_box = tk.Checkbutton(self._param_frame, variable=var) +# check_box.grid(column=1, row=row, sticky="w") +# self.var[f.name] = var +# self._bind_post_write_callback(var) +# +# def _create_numeric_field(self, f, row, initial_value): +# if f.type == int: +# var = tk.IntVar(value=initial_value) +# else: # f.type == float +# var = tk.DoubleVar(value=initial_value) +# entry = tk.Entry(self._param_frame, textvariable=var) +# entry.grid(column=1, row=row, sticky="ew") +# self.var[f.name] = var +# self._bind_post_write_callback(var) +# +# def get_filled_parameters(self) -> Optional[ParametersType]: +# try: +# settings_dict = {f.name: self.var[f.name].get() if isinstance(self.var[f.name], tk.Variable) else self.var[f.name] +# for f in fields(self.params_type) if f.name in self._editable_fields} +# return replace(self.initial_params, **settings_dict) +# except Exception as e: +# messagebox.showerror("Error", f"Error reading settings from UI:\n\n{e}\n\n(see Log)") +# return None + + +def ui_choose_parameters( + builder: ParameterUIBuilder, + # params_type: Optional[type] = None, + # initial_params: Optional[ParametersType] = None, + # factory_reset_params: Optional[ParametersType] = None, + timeout: Optional[float] = None, + title: str = "Select Parameters", + # editable_fields: Union[bool, Sequence[str]] = True, + prompt: str = "Hover mouse over for description of each parameter. Tab to switch fields, Enter to accept, Escape to cancel." +) -> Optional[ParametersType]: + # with hold_tkinter_root_context() as root: # - # result = ui_choose_parameters(params_type=MyParams) - # print(result) + # params_type: Optional[type] = None, + # initial_params: Optional[ParametersType] = None, + # editable_fields: Union[bool, Sequence[str]] = True, + # extra_widget_builders: Mapping[str, Callable[[tk.Widget], None]] = {}, + # ): + + # builder = ParameterUIBuilder( + # + # + # param_type = params_type, + # initial_value = initial_params, + # editable_fields = editable_fields, + # on_change_callback = self._on_change_callback, + # # custom_constructors=extra_widget_builders, + # ) + + window = tk.Toplevel() + window.title(title) + ps_frame = builder.build_parameter_frame(window) + # ps_frame = build_parameter_frame(window, params_type, initial_params, editable_fields=editable_fields) + ps_frame.pack() + bottom_panel = ButtonPanel(window) + + final_params = None + + def on_cancel(): + nonlocal final_params + final_params = None + window.destroy() + + def on_ok(): + nonlocal final_params + final_params = ps_frame.get_filled_parameters() + window.destroy() + + def on_reset(): + nonlocal ps_frame + ps_frame.pack_forget() + # ps_frame = build_parameter_frame(window, params_type, factory_reset_params, editable_fields=editable_fields) + ps_frame = builder.build_parameter_frame() + ps_frame.pack() + + bottom_panel.pack(side=tk.BOTTOM, fill=tk.X) + bottom_panel.add_button("Cancel", on_cancel, shortcut="") + bottom_panel.add_button("OK", on_ok, shortcut="") + bottom_panel.add_button("Reset", on_reset, shortcut="") + + if timeout is not None: + window.after(int(timeout * 1000), on_ok) + # root.mainloop() + window.wait_window() + return final_params + - result = ui_choose_field('N neighbours', int, default=40) - print(result) \ No newline at end of file +if __name__ == "__main__": + @dataclass + class MyParams: + some_float: float = 4 + some_int: int = field(default=3, metadata=dict(help="Select some integer")) + some_file: str = field(default=os.path.expanduser("~/some_image.jpg"), metadata=dict(type='file')) + + + result = ui_choose_parameters(ParameterUIBuilder(None, MyParams, MyParams())) + print(result) + + # result = ui_choose_field('N neighbours', int, default=40) + # print(result) diff --git a/artemis/plotting/tk_utils/ui_utils.py b/artemis/plotting/tk_utils/ui_utils.py index 7a433272..aef9d354 100644 --- a/artemis/plotting/tk_utils/ui_utils.py +++ b/artemis/plotting/tk_utils/ui_utils.py @@ -5,13 +5,13 @@ import tkinter as tk import traceback from contextlib import contextmanager -from typing import Callable, Any, TypeVar, Union, Tuple, Dict +from enum import Enum +from typing import Callable, Any, TypeVar, Union, Tuple, Dict, Generic from typing import Sequence, Optional import cv2 from PIL import Image, ImageTk - from artemis.general.custom_types import BGRImageArray from artemis.image_processing.image_builder import ImageBuilder from artemis.image_processing.image_utils import BGRColors @@ -68,7 +68,34 @@ def is_global_termination_request(): return GLOBAL_TERMINATION_REQUEST -class RespectableLabel(tk.Label): +def get_shortcut_string(shortcut: str) -> str: + """ Formats the shortcut as a string to display in a tooltip + Replaces upper-case letters with 'Shift-' unless Shift is already in the shortcut + E.g. 'Control-A' -> 'Ctrl-Shift-A' + """ + shortcut_stroke = shortcut.strip('<>') + # display_stroke = re.sub(r'([A-Z])', r'Shift-\1', shortcut_stroke) if 'Shift' not in shortcut_stroke else shortcut_stroke + # Change above so that it only subs lone capital lettes + display_stroke = re.sub(r'(?4 and k in ['highlightbackground', 'highlightthickness', 'fg']}) + def set_emphasis(self, emphasis: bool): + + if emphasis: + self.configure(highlightbackground=ThemeColours.HIGHLIGHT_COLOR, highlightthickness=2, fg=ThemeColours.HIGHLIGHT_COLOR, relief=tk.RAISED) + else: + self.configure(**self._original_highlighting) + + +class RespectableLabel(tk.Label, EmphasizableMixin): def __init__(self, master: tk.Frame, @@ -81,6 +108,7 @@ def __init__(self, **kwargs ): tk.Label.__init__(self, master, text=text, **kwargs) + EmphasizableMixin.__init__(self, master, text=text, **kwargs) if command is not None: self.bind("", lambda event: command()) self.config(cursor="hand2", relief=tk.RAISED) @@ -93,8 +121,7 @@ def __init__(self, master.winfo_toplevel().bind(shortcut, self._execute_shortcut) if tooltip is not None or (shortcut is not None and add_shortcut_to_tooltip): if add_shortcut_to_tooltip and shortcut is not None: - shortcut_stroke = shortcut.strip('<>') - shortcut_stroke = re.sub(r'([A-Z])', r'Shift-\1', shortcut_stroke) + shortcut_stroke = get_shortcut_string(shortcut) tooltip = f"({shortcut_stroke})" if tooltip is None else f"{tooltip} ({shortcut_stroke})" create_tooltip(widget=self, text=tooltip, background=ThemeColours.TOOLTIP_BACKGROUND) @@ -171,7 +198,16 @@ def register_button(button_id: str, callback: Callable): _BUTTON_CALLBACK_ACCESSORS[button_id] = callback -class RespectableButton(tk.Button): +class ReparentableWidgetMixin(tk.Widget): + + def __init__(self, **kwargs): + self._copy_kwargs = kwargs + + def adopt_to_new_parent(self, parent: tk.Widget, **kwargs) -> tk.Widget: + return self.__class__(parent, **{**self._copy_kwargs, **kwargs}) + + +class RespectableButton(tk.Button, ReparentableWidgetMixin, EmphasizableMixin): def __init__(self, master: tk.Frame, @@ -180,10 +216,22 @@ def __init__(self, error_handler: Optional[Callable[[ErrorDetail], Any]] = None, tooltip: Optional[str] = None, shortcut: Optional[Union[str, Sequence[str]]] = None, + add_shortcut_to_tooltip: bool = True, button_id: Optional[str] = None, # Use this in hold_button_callback_accessors with register_button + as_label: bool = False, **kwargs ): - tk.Button.__init__(self, master, text=text, **kwargs) + if as_label: + tk.Label.__init__(self, master, text=text, relief=tk.RAISED if command else None, **kwargs) + else: + tk.Button.__init__(self, master, text=text, **kwargs) + ReparentableWidgetMixin.__init__(self, text=text, command=command, error_handler=error_handler, tooltip=tooltip, + shortcut=shortcut, button_id=button_id, **kwargs) + EmphasizableMixin.__init__(self, **kwargs) + + if add_shortcut_to_tooltip and shortcut is not None: + shortcut_stroke = get_shortcut_string(shortcut) + tooltip = f"({shortcut_stroke})" if tooltip is None else f"{tooltip} ({shortcut_stroke})" if button_id is not None: register_button(button_id, command) @@ -205,8 +253,15 @@ def __init__(self, create_tooltip(widget=self, text=tooltip, background=ThemeColours.TOOLTIP_BACKGROUND) if shortcut is not None: for s in [shortcut] if isinstance(shortcut, str) else shortcut: + s = f"<{s.strip('<>')}>" master.winfo_toplevel().bind(s, lambda event: self._call_callback_with_safety()) + def get_command(self) -> Callable[[], Any]: + return self._callback + + def get_tooltip(self) -> Optional[str]: + return self._original_tooltip + def restore(self): self.config(**self._original_config) self._callback = self._original_callback @@ -257,17 +312,24 @@ def __init__(self, error_handler: Optional[Callable[[ErrorDetail], Any]] = None, as_row: bool = True, # False = column font: Optional[Union[str, Tuple[str, int]]] = (None, 14), + max_buttons_before_expand: Optional[int] = None, **kwargs): tk.Frame.__init__(self, master, **kwargs) self._error_handler = error_handler self._buttons = [] self._as_row = as_row self._font = font + self._max_buttons_before_expand = max_buttons_before_expand + self._is_adding_expand_button = False if as_row: self.rowconfigure(0, weight=1) else: self.columnconfigure(0, weight=1) - self._count = 0 + + def add_label(self, text: str, weight: int = 1): + label = RespectableLabel(self, text=text) + self._add_next_widget(label, weight=weight) + return label def add_button(self, text: str, @@ -276,6 +338,10 @@ def add_button(self, shortcut: Optional[str] = None, highlight: bool = False, weight: int = 1, + as_label: bool = False, + padx=0, + pady=0, + surround_padding: int = 0, **kwargs ) -> RespectableButton: button = RespectableButton( @@ -284,23 +350,78 @@ def add_button(self, tooltip=tooltip, shortcut=shortcut, command=command, - padx=0, - pady=0, + padx=padx, + pady=pady, font=self._font, + as_label=as_label, # width=1, highlightbackground=ThemeColours.HIGHLIGHT_COLOR if highlight else None, error_handler=self._error_handler, **kwargs ) - if self._as_row: - button.grid(column=self._count, row=0, sticky=tk.NSEW) - self.columnconfigure(self._count, weight=weight) - else: - button.grid(column=0, row=self._count, sticky=tk.NSEW) - self.rowconfigure(self._count, weight=weight) - self._count += 1 + # if self._as_row: + # button.grid(column=self._count, row=0, sticky=tk.NSEW) + # self.columnconfigure(self._count, weight=weight) + # else: + # button.grid(column=0, row=self._count, sticky=tk.NSEW) + # self.rowconfigure(self._count, weight=weight) + # self._count += 1 + self._add_next_widget(button, weight=weight, padding=surround_padding) return button + def _expand(self): + + def on_button_press(original_callback: Callable[[], None]): + def callback(): + + original_callback() + print('Calling destroy') + new_window.destroy() + return callback + + new_window = tk.Toplevel(self.master) + new_window.title("Additional Buttons") + new_window.geometry(f"600x{min(500, 50*len(self._buttons)+50)}") + new_window.resizable(False, False) + + # Keep it on top until clicked + new_window.attributes("-topmost", True) + + for button in self._buttons[self._max_buttons_before_expand:]: + new_button = button.adopt_to_new_parent(new_window, command=on_button_press(button.get_command()), text=button.cget('text')+(" : "+t if (t:=button.get_tooltip()) is not None else '')) + new_button.pack(fill=tk.BOTH, expand=True) + + # Add a cancel button + cancel_button = RespectableButton(new_window, text='Cancel', command=new_window.destroy) + cancel_button.pack(fill=tk.BOTH, expand=True) + + # Clicking anywhere outside the window will close it + new_window.bind("", lambda *args: new_window.destroy()) + + new_window.update() + new_window.wait_window() + + # self.destroy() + + def _add_next_widget(self, widget: tk.Widget, weight: int = 1, padding: int = 0): + count = len(self._buttons) + if self._max_buttons_before_expand is not None and count+1 == self._max_buttons_before_expand and not self._is_adding_expand_button: + # If we're at the limit - add the expand button + self._is_adding_expand_button = True + self.add_button('...', self._expand, tooltip="Show other buttons", weight=weight) + self._is_adding_expand_button = False + + if self._max_buttons_before_expand is not None and count+1 > self._max_buttons_before_expand: + pass + elif self._as_row: + widget.grid(column=count, row=0, sticky=tk.NSEW, padx=padding, pady=padding) + self.columnconfigure(count, weight=weight, uniform='button') + else: + widget.grid(column=0, row=count, sticky=tk.NSEW, padx=padding, pady=padding) + self.rowconfigure(count, weight=weight, uniform='button') + if not self._is_adding_expand_button: + self._buttons.append(widget) + # def add_respectable_button( # frame: tk.Frame, # error_handler: Optional[Callable[[ErrorDetail], Any]], @@ -341,4 +462,57 @@ def add_button(self, # # button.restore = restore # -# return button \ No newline at end of file +# return button + + +MultiStateEnumType = TypeVar('MultiStateEnumType', bound=Enum) + + +class MultiStateToggle(ButtonPanel, Generic[MultiStateEnumType]): + + def __init__(self, + master: tk.Frame, + enum_type: type(MultiStateEnumType), + error_handler: Optional[Callable[[ErrorDetail], Any]] = None, + initial_state: Optional[MultiStateEnumType] = None, + on_state_change_callback: Optional[Callable[[MultiStateEnumType], None]] = None, + call_callback_immediately: bool = True, + pad: int = 5, + surround_padding: int = 0, + on_button_config: Optional[dict] = None, + off_button_config: Optional[dict] = None, + tooltip_maker: Optional[Callable[[MultiStateEnumType], str]] = None, + **kwargs + ): + ButtonPanel.__init__(self, master, error_handler=error_handler, **kwargs) + self._active_state: Optional[MultiStateEnumType] = None + self._on_button_config = on_button_config or {} + self._off_button_config = off_button_config or {} + if initial_state is None: + initial_state = list(enum_type)[0] + self._on_state_change_callback = on_state_change_callback if call_callback_immediately else None + for state in enum_type: + if tooltip_maker is not None: + tooltip = tooltip_maker(state) + else: + tooltip = f"Switch to '{state.value}'" + self.add_button(text=state.value, command=lambda s=state: self.set_state(s), tooltip=tooltip, + as_label=True, padx=pad, pady=pad, surround_padding=surround_padding) + self.set_state(initial_state) + self._on_state_change_callback = on_state_change_callback + + def set_state(self, state: MultiStateEnumType): + old_state = self._active_state + state_index = list(type(state)).index(state) + for i, button in enumerate(self._buttons): + if i == state_index: + button.config(relief=tk.SUNKEN, **self._on_button_config) + else: + button.config(relief=tk.RAISED, **self._off_button_config) + self._active_state = state + if self._on_state_change_callback is not None and old_state != state: # Avoid recursion + self._on_state_change_callback(state) + + def get_state(self) -> MultiStateEnumType: + assert self._active_state is not None, "No state set" + return self._active_state From 4ad00effc31ba84cff8c2463790e2de59e1edf12 Mon Sep 17 00:00:00 2001 From: peter Date: Wed, 31 Jan 2024 06:25:46 -0800 Subject: [PATCH 096/107] tabbed frame tweaks - for ees 0.5.0 --- artemis/general/utils_utils.py | 7 ++++++- artemis/plotting/tk_utils/tabbed_frame.py | 9 +++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/artemis/general/utils_utils.py b/artemis/general/utils_utils.py index c7cd6673..d4a136e7 100644 --- a/artemis/general/utils_utils.py +++ b/artemis/general/utils_utils.py @@ -2,7 +2,7 @@ import os.path from datetime import datetime from functools import partial -from typing import Optional, Iterable, Callable, TypeVar, Tuple, Sequence, Iterator +from typing import Optional, Iterable, Callable, TypeVar, Tuple, Sequence, Iterator, Any import inspect import time @@ -113,5 +113,10 @@ def number_to_ordinal_string(n: int) -> str: return str(n) + suffix +def display_dataclass_instance_without_defaults(instance: Any) -> str: + """ Display a dataclass instance without showing default values """ + return f'{instance.__class__.__name__}({", ".join([f"{k}={v}" for k, v in instance.__dict__.items() if v != instance.__class__.__dataclass_fields__[k].default])})' + + if __name__ == '__main__': demo_get_context_name() diff --git a/artemis/plotting/tk_utils/tabbed_frame.py b/artemis/plotting/tk_utils/tabbed_frame.py index ac016371..eac073e7 100644 --- a/artemis/plotting/tk_utils/tabbed_frame.py +++ b/artemis/plotting/tk_utils/tabbed_frame.py @@ -57,7 +57,7 @@ def __init__(self, self._pre_state_change_callback = pre_state_change_callback self._on_state_change_callback = on_state_change_callback - self.set_active_tab(initial_state, skip_if_unchanged=False) + self.set_active_tab(initial_state) # Callback if tab_forward_shortcut is not None: @@ -89,10 +89,11 @@ def create_tab_switch_control(self, parent: tk.Frame) -> MultiStateToggle: self._tab_controls.append(tab_control) return tab_control - def set_active_tab(self, state: MultiStateEnumType, skip_if_unchanged: bool = True, skip_callback: bool = False) -> None: + def set_active_tab(self, state: MultiStateEnumType, skip_callback: bool = False) -> None: - if skip_if_unchanged and state == self._main_tab_control.get_state(): - return # Already in this state + # if skip_if_unchanged and state == self._main_tab_control.get_state(): + # print(f"Skipping tab change to {state} because it's already in state {self._main_tab_control.get_state()}.") + # return # Already in this state do_change = True if self._pre_state_change_callback is not None: do_change = self._pre_state_change_callback(state) From cc292857de1471c9539d4441a9cbef9bc3cb6339 Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 1 Feb 2024 09:02:58 -0800 Subject: [PATCH 097/107] checkpoint for build --- artemis/plotting/tk_utils/tk_utils.py | 30 ++++- .../plotting/tk_utils/ui_choose_parameters.py | 106 +++++++++++++++--- artemis/plotting/tk_utils/ui_utils.py | 9 +- 3 files changed, 125 insertions(+), 20 deletions(-) diff --git a/artemis/plotting/tk_utils/tk_utils.py b/artemis/plotting/tk_utils/tk_utils.py index d3ebf952..e5fb4efe 100644 --- a/artemis/plotting/tk_utils/tk_utils.py +++ b/artemis/plotting/tk_utils/tk_utils.py @@ -12,7 +12,8 @@ from artemis.plotting.tk_utils.tk_error_dialog import ErrorDetail from artemis.plotting.tk_utils.ui_utils import RespectableButton from artemis.plotting.tk_utils.constants import UIColours, ThemeColours - +import platform +import subprocess # from artemis.plotting.tk_utils.bitmap_view import TkImageWidget # from video_scanner.utils import ResourceFiles @@ -627,7 +628,34 @@ def hold_tkinter_root_context(): # ) -> tk.Frame: +def is_dark_mode(): + os_name = platform.system() + if os_name == "Darwin": # macOS + try: + theme = subprocess.check_output( + "defaults read -g AppleInterfaceStyle", shell=True + ).decode().strip() + return theme == "Dark" + except subprocess.CalledProcessError: + return False # Default is light mode if the command fails + + elif os_name == "Windows": + try: + import winreg + key = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" + ) + value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme") + return value == 0 + except: + return False # Default or error + + else: # Linux and other OSes + # This is more complex due to the variety of Linux environments. + # Placeholder for now. + return False if __name__ == '__main__': diff --git a/artemis/plotting/tk_utils/ui_choose_parameters.py b/artemis/plotting/tk_utils/ui_choose_parameters.py index 338703d5..ad2fd38d 100644 --- a/artemis/plotting/tk_utils/ui_choose_parameters.py +++ b/artemis/plotting/tk_utils/ui_choose_parameters.py @@ -3,22 +3,19 @@ from abc import ABCMeta from abc import abstractmethod from dataclasses import dataclass, field, fields, replace -import pprint -from tkinter import messagebox, ttk +from tkinter import ttk, filedialog from typing import Optional, TypeVar, Sequence, get_origin, get_args, Any, Dict, Callable, Union, Generic, Tuple, Mapping -from more_itertools import first from more_itertools.more import first # Assuming the required modules from artemis.plotting.tk_utils are available -from artemis.plotting.tk_utils.constants import ThemeColours -from artemis.plotting.tk_utils.tk_utils import hold_tkinter_root_context -from artemis.plotting.tk_utils.tooltip import create_tooltip -from artemis.plotting.tk_utils.ui_utils import ButtonPanel -from video_scanner.ui.ui_utils import RespectableLabel +from artemis.plotting.tk_utils.ui_utils import ButtonPanel, RespectableLabel ParametersType = TypeVar('ParametersType') +class NoDefault: + pass + def get_default_for_param_type(param_type: type) -> Any: if param_type == bool: @@ -27,8 +24,8 @@ def get_default_for_param_type(param_type: type) -> Any: return [] elif isinstance(get_origin(param_type), type) and is_optional_type(param_type): return None - elif hasattr(param_type, "__dataclass_fields__"): - return param_type() + # elif hasattr(param_type, "__dataclass_fields__"): + # return param_type() elif param_type == str: return "" elif param_type == int: @@ -36,7 +33,8 @@ def get_default_for_param_type(param_type: type) -> Any: elif param_type == float: return 0.0 else: - raise NotImplementedError(f"Type {param_type} not supported.") + return NoDefault + # raise NotImplementedError(f"Type {param_type} not supported.") class MockVariable(tk.Variable): @@ -231,10 +229,16 @@ def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder'): self._checkbox_var = tk.BooleanVar(value=self._builder.initial_value is not None) check_box = tk.Checkbutton(self, variable=self._checkbox_var) check_box.grid(column=0, row=0, sticky="w") + if not builder.editable_fields: + check_box.configure(state=tk.DISABLED) # self._child_frame = build_parameter_frame(self, param_type, initial_value if initial_value is not None else get_default_for_param_type(param_type), editable_fields=editable_fields) + param_type_if_checked = get_args(self._builder.param_type)[0] + default_value = get_default_for_param_type(param_type_if_checked) self._child_frame = replace(self._builder, - param_type=get_args(self._builder.param_type)[0], - initial_value=self._builder.initial_value if self._builder.initial_value is not None else get_default_for_param_type(get_args(self._builder.param_type)[0]), + param_type=param_type_if_checked, + custom_constructors={k.removesuffix(".checked") if k.startswith(builder.path.lstrip('.')) else k: v for k, v in self._builder.custom_constructors.items()}, + initial_value=self._builder.initial_value if self._builder.initial_value is not None else default_value + # initial_value=self._builder.initial_value if self._builder.initial_value is not None else None, ).build_parameter_frame(self) self._on_checkbox_change(self._checkbox_var.get()) @@ -251,6 +255,65 @@ def get_variables(self) -> Mapping[Tuple[Union[int, str], ...], tk.Variable]: return self._child_frame.get_variables() if self._checkbox_var.get() else {} +class FileListParameterSelectionFrame(IParameterSelectionFrame[Sequence[str]]): + + def __init__(self, + master: tk.Widget, + builder: 'ParameterUIBuilder', + separator: str = ';', + + ): + super().__init__(master, **builder.general_kwargs) + # self._builder = builder + self.var = tk.StringVar(self, builder.initial_value if builder.initial_value is not NoDefault else '') + frame = tk.Frame(self) + frame.grid(column=0, row=0, sticky="ew") + self._file_list_label = RespectableLabel(frame, text="", anchor=tk.W) + self._file_list_label.grid(column=0, row=0, sticky="ew") + # self._filelist = tk.Listbox(frame, state=tk.NORMAL if builder.editable_fields else tk.DISABLED, height=min(5, len(self._builder.initial_value))) + self._filelist = tk.Listbox(frame, height=min(5, len(builder.initial_value) if builder.initial_value is not NoDefault else 1)) + # Insert one item called "aaa" + + self._filelist.grid(column=0, row=1, sticky=tk.EW) + self._separator = separator + + self._rebuild_file_list() + if builder.editable_fields: + # Add a "select files" button (with unicode icon) + RespectableLabel(frame, text="📂", command=self._select_files) + + def get_filled_parameters(self) -> Optional[ParametersType]: + return self.var.get().split(self._separator) + + def get_common_dir_and_relative_paths(self) -> Tuple[Optional[str], Sequence[str]]: + + dirs = [os.path.dirname(file) for file in self.var.get().split(self._separator)] + if len(dirs)>0: + common_directory = os.path.commonpath(dirs) + return common_directory, [os.path.relpath(file, common_directory) for file in self.var.get().split(self._separator)] + else: + return None, [] + + def _select_files(self): + common_directory, _ = self.get_common_dir_and_relative_paths() + files = filedialog.askopenfilenames(initialdir=common_directory) + if files is not None: + self.var.set(self._separator.join(files)) + self._rebuild_file_list() + + def _rebuild_file_list(self): + + # common_directory = os.path.commonpath(self.var.get().split(self._separator)) if len(self.var.get()) > 0 else None + # rel_paths = self.var.get().split(self._separator) + common_directory, rel_paths = self.get_common_dir_and_relative_paths() + self._file_list_label.configure(text=f"{len(rel_paths)} files in {common_directory}:" if common_directory is not None else "") + self._filelist.delete(0, tk.END) + for file in rel_paths: + self._filelist.insert(tk.END, file) + # Display + self.update() + + class ButtonParameterSelectionFrame(IParameterSelectionFrame[ParametersType]): """ Parameter is summarized in a clickable label which, when clicked, can open up an edit menu with ui_choose_parameters @@ -344,6 +407,9 @@ def get_filled_parameters(self) -> ParametersType: return self._initial_value + + + class WrapperParameterSelectionFrame(IParameterSelectionFrame[ParametersType]): def __init__(self, master: tk.Widget, frame: IParameterSelectionFrame): @@ -405,8 +471,8 @@ def filter_subfields(original_editable_fields: Union[Sequence[str], bool], field class ParameterUIBuilder: """ A class that builds a UI for a parameter. """ # parent: Optional[tk.Widget] # The parent widget - param_type: type # The type of the parameter. If None, initial_value must be provided. initial_value: Any # The initial value to display. If None, param_type must be provided. + param_type: Optional[type] = None # The type of the parameter. If None, initial_value must be provided. path: str = '' # The path to the parameter, e.g. "a.b.c" means the "c" field of the "b" field of the "a" field. editable_fields: Union[bool, Sequence[str]] = True # Either end_field_patterns: Sequence[str] = () # Paths to fields that should not be expanded upon @@ -437,11 +503,16 @@ def is_end_field(self) -> bool: return any(does_field_match_pattern(self.path, pattern) for pattern in self.end_field_patterns) def modify_for_subfield(self, subfield_index: Union[int, str], subfield_type: type) -> 'ParameterUIBuilder': + if self.initial_value is not None and self.initial_value is not NoDefault: + initial_value = getattr(self.initial_value, subfield_index) if isinstance(subfield_index, str) else self.initial_value[subfield_index] + else: + initial_value = None + return replace( self, # parent=parent, path=self.path + "." + str(subfield_index), - initial_value=getattr(self.initial_value, subfield_index) if isinstance(subfield_index, str) else self.initial_value[subfield_index], + initial_value=initial_value, param_type=subfield_type, ) @@ -889,9 +960,10 @@ def on_reset(): ps_frame.pack() bottom_panel.pack(side=tk.BOTTOM, fill=tk.X) - bottom_panel.add_button("Cancel", on_cancel, shortcut="") + if builder.editable_fields: + bottom_panel.add_button("Cancel", on_cancel, shortcut="") + bottom_panel.add_button("Reset", on_reset, shortcut="") bottom_panel.add_button("OK", on_ok, shortcut="") - bottom_panel.add_button("Reset", on_reset, shortcut="") if timeout is not None: window.after(int(timeout * 1000), on_ok) diff --git a/artemis/plotting/tk_utils/ui_utils.py b/artemis/plotting/tk_utils/ui_utils.py index aef9d354..1749a9ef 100644 --- a/artemis/plotting/tk_utils/ui_utils.py +++ b/artemis/plotting/tk_utils/ui_utils.py @@ -68,11 +68,13 @@ def is_global_termination_request(): return GLOBAL_TERMINATION_REQUEST -def get_shortcut_string(shortcut: str) -> str: +def get_shortcut_string(shortcut: Union[str, Sequence[str]]) -> str: """ Formats the shortcut as a string to display in a tooltip Replaces upper-case letters with 'Shift-' unless Shift is already in the shortcut E.g. 'Control-A' -> 'Ctrl-Shift-A' """ + if not isinstance(shortcut, str): + shortcut = ' or '.join(shortcut) shortcut_stroke = shortcut.strip('<>') # display_stroke = re.sub(r'([A-Z])', r'Shift-\1', shortcut_stroke) if 'Shift' not in shortcut_stroke else shortcut_stroke # Change above so that it only subs lone capital lettes @@ -105,6 +107,7 @@ def __init__(self, tooltip: Optional[str] = None, button_id: Optional[str] = None, add_shortcut_to_tooltip: bool = True, + shortcut_binding_widget: Optional[tk.Widget] = None, **kwargs ): tk.Label.__init__(self, master, text=text, **kwargs) @@ -118,7 +121,9 @@ def __init__(self, self._command = command if shortcut is not None: - master.winfo_toplevel().bind(shortcut, self._execute_shortcut) + if shortcut_binding_widget is None: + shortcut_binding_widget = master.winfo_toplevel() + shortcut_binding_widget.bind(shortcut, self._execute_shortcut) if tooltip is not None or (shortcut is not None and add_shortcut_to_tooltip): if add_shortcut_to_tooltip and shortcut is not None: shortcut_stroke = get_shortcut_string(shortcut) From 11e5938f4552e40e2f960474b64a2b6879f1de6e Mon Sep 17 00:00:00 2001 From: peter Date: Sat, 17 Feb 2024 14:41:52 -0800 Subject: [PATCH 098/107] ook --- artemis/plotting/easy_window.py | 8 +++++-- artemis/plotting/image_mosaic.py | 25 +++++++++++++------- artemis/plotting/tk_utils/ui_utils.py | 34 +++++++++++++++++++++++---- 3 files changed, 52 insertions(+), 15 deletions(-) diff --git a/artemis/plotting/easy_window.py b/artemis/plotting/easy_window.py index 14342352..e2cedcc1 100644 --- a/artemis/plotting/easy_window.py +++ b/artemis/plotting/easy_window.py @@ -131,10 +131,14 @@ def put_text_in_corner(img: BGRImageArray, text: str, color: BGRColorTuple, shad background_color: Optional[BGRColorTuple] = None, corner ='tl', scale=1, thickness=1, font=cv2.FONT_HERSHEY_PLAIN, ): """ Put text in the corner of the image""" # TODO: Convert to use put_text_at - assert corner in ('tl', 'tr', 'bl', 'br') + assert isinstance(corner, str) and len(corner) == 2, f"Corner must be a two-letter string (e.g. 'tl', 'br', 'cc'), not {corner}" cv, ch = corner + assert cv in ('t', 'b', 'c'), f"Vertical corner must be 't', 'b', or 'c', not {cv}" + assert ch in ('l', 'r', 'c'), f"Horizontal corner must be 'l', 'r', or 'c', not {ch}" (twidth, theight), baseline = cv2.getTextSize(text, font, scale, thickness) - position = ({'l': 0, 'r': img.shape[1]-twidth}[ch], {'t': theight+baseline, 'b': img.shape[0]}[cv]) + position = ( + {'l': 0, 'r': img.shape[1]-twidth, 'c': (img.shape[1]-twidth)//2}[ch], + {'t': theight+baseline, 'b': img.shape[0], 'c': img.shape[0]//2}[cv]) if background_color is not None: pad = 4 img[max(0, position[1]-theight-pad): position[1]+pad, max(0, position[0]-pad): position[0]+twidth+pad] = background_color diff --git a/artemis/plotting/image_mosaic.py b/artemis/plotting/image_mosaic.py index 26313f06..a619effe 100644 --- a/artemis/plotting/image_mosaic.py +++ b/artemis/plotting/image_mosaic.py @@ -15,6 +15,7 @@ def generate_image_mosaic_and_index_grid( grid_shape: Tuple[Optional[int], Optional[int]] = (None, None), # (rows, columns) min_size_xy: Tuple[int, int] = (640, 480), padding: int = 1, + end_text: Optional[str] = None, ) -> Tuple[BGRImageArray, IndexImageArray]: if isinstance(mosaic, Mapping): @@ -27,15 +28,21 @@ def generate_image_mosaic_and_index_grid( if len(mosaic)==0: img = create_gap_image(size=min_size_xy, gap_colour=gap_color) put_text_in_corner(img, text='No Detections', color=BGRColors.WHITE) - return img, np.full(shape=(min_size_xy[1], min_size_xy[0]), fill_value=-1) - - # First pack them into a big array - image_array = put_list_of_images_in_array(images, fill_colour=gap_color, padding=0) - id_array = np.zeros(image_array.shape[:3], dtype=int) - id_array += np.array(ids)[:, None, None] - - image_grid = put_data_in_image_grid(image_array, grid_shape=grid_shape, fill_colour=gap_color, boundary_width=padding, min_size_xy=min_size_xy) - id_grid = put_data_in_grid(id_array, grid_shape=grid_shape, fill_value=-1, min_size_xy=min_size_xy) + image_grid, id_grid = img, np.full(shape=(min_size_xy[1], min_size_xy[0]), fill_value=-1) + else: + # First pack them into a big array + image_array = put_list_of_images_in_array(images, fill_colour=gap_color, padding=0) + id_array = np.zeros(image_array.shape[:3], dtype=int) + id_array += np.array(ids)[:, None, None] + + image_grid = put_data_in_image_grid(image_array, grid_shape=grid_shape, fill_colour=gap_color, boundary_width=padding, min_size_xy=min_size_xy) + id_grid = put_data_in_grid(id_array, grid_shape=grid_shape, fill_value=-1, min_size_xy=min_size_xy) + + if end_text is not None: + n_added_pixels = 20 + image_grid = np.pad(image_grid, ((0, n_added_pixels), (0, 0), (0, 0)), mode='constant', constant_values=0) + put_text_in_corner(image_grid, text=end_text, color=BGRColors.WHITE, corner='bc') + id_grid = np.pad(id_grid, ((0, n_added_pixels), (0, 0)), mode='constant', constant_values=-1) return image_grid, id_grid diff --git a/artemis/plotting/tk_utils/ui_utils.py b/artemis/plotting/tk_utils/ui_utils.py index 1749a9ef..510aa1a7 100644 --- a/artemis/plotting/tk_utils/ui_utils.py +++ b/artemis/plotting/tk_utils/ui_utils.py @@ -108,6 +108,7 @@ def __init__(self, button_id: Optional[str] = None, add_shortcut_to_tooltip: bool = True, shortcut_binding_widget: Optional[tk.Widget] = None, + error_handler: Optional[Callable[[ErrorDetail], Any]] = None, **kwargs ): tk.Label.__init__(self, master, text=text, **kwargs) @@ -116,6 +117,8 @@ def __init__(self, self.bind("", lambda event: command()) self.config(cursor="hand2", relief=tk.RAISED) + self._error_handler = error_handler + if button_id is not None and command is not None: register_button(button_id, command) @@ -133,7 +136,15 @@ def __init__(self, def _execute_shortcut(self, event: tk.Event): if not isinstance(event.widget, (tk.Text, tk.Entry)): # Block keystrokes from something is being typed into a text field - self._command() + try: + self._command() + except Exception as e: + err = e + traceback_str = traceback.format_exc() + print(traceback_str) + if self._error_handler: + self._error_handler(ErrorDetail(error=err, traceback=traceback_str, additional_info=f"Button: '{self.cget('text')}'")) + raise e class ToggleLabel(RespectableLabel): @@ -170,7 +181,16 @@ def set_toggle_state(self, state: bool, call_callback: bool = True): self._state = state self.config(text=self._on_text if self._state else self._off_text, background=self._on_bg if self._state else self._off_bg, relief=tk.SUNKEN if self._state else tk.RAISED) if self._state_switch_callback is not None and call_callback: - self._state_switch_callback(self._state) + try: + self._state_switch_callback(self._state) + except Exception as e: + err = e + traceback_str = traceback.format_exc() + print(traceback_str) + if self._error_handler: + self._error_handler(ErrorDetail(error=err, traceback=traceback_str, additional_info=f"Button: '{self.cget('text')}'")) + raise e + def get_toggle_state(self) -> bool: return self._state @@ -386,14 +406,20 @@ def callback(): new_window = tk.Toplevel(self.master) new_window.title("Additional Buttons") - new_window.geometry(f"600x{min(500, 50*len(self._buttons)+50)}") + width = 600 + new_window.geometry(f"{width}x{min(500, 50*len(self._buttons)+50)}") new_window.resizable(False, False) # Keep it on top until clicked new_window.attributes("-topmost", True) for button in self._buttons[self._max_buttons_before_expand:]: - new_button = button.adopt_to_new_parent(new_window, command=on_button_press(button.get_command()), text=button.cget('text')+(" : "+t if (t:=button.get_tooltip()) is not None else '')) + new_button = button.adopt_to_new_parent( + parent=new_window, + command=on_button_press(button.get_command()), + text=button.cget('text')+(" : "+t if (t:=button.get_tooltip()) is not None else ''), + wraplength=width-20, + ) new_button.pack(fill=tk.BOTH, expand=True) # Add a cancel button From ee25305b318ff4c6db687076e265ebd7debaa623 Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 20 Feb 2024 14:50:22 -0800 Subject: [PATCH 099/107] tooltip --- artemis/fileman/file_utils.py | 13 +++++++++---- artemis/image_processing/decorders.py | 13 ++++++++++++- artemis/plotting/tk_utils/tooltip.py | 15 +++++++++++++-- artemis/plotting/tk_utils/ui_utils.py | 3 ++- 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/artemis/fileman/file_utils.py b/artemis/fileman/file_utils.py index 57e8a597..9ea0aea3 100644 --- a/artemis/fileman/file_utils.py +++ b/artemis/fileman/file_utils.py @@ -82,7 +82,8 @@ def sync_src_files_to_dest_files( src_path_to_new_path: Mapping[str, str], overwrite: bool = False, # Overwrite existing files on machine check_byte_sizes=True, # Check that, for existing files, file-size matches source. If not, overwrite. - verbose: bool = True # Prints a lot. + verbose: bool = True, # Prints a lot. + prompt_user_for_confirmation: bool = True # Prompt user to confirm sync (default true for historical reasons. Should really default false) ): # Filter to only copy when destination file does not exist. TODO: Maybe check file size match here too src_path_to_size = {src_path: os.path.getsize(src_path) for src_path in src_path_to_new_path} @@ -100,11 +101,15 @@ def sync_src_files_to_dest_files( if verbose: print('Files to be copied: ') print(' ' + '\n '.join(f'{i}: {src} -> {dest} ' for i, (src, dest) in enumerate(src_to_dest_to_copy.items()))) - response = input( - f"{len(src_to_dest_to_copy)}/{len(src_path_to_new_path)} files ({size_to_be_copied:,} bytes) will be copied.\n Type 'copy' to copy >>") + + if prompt_user_for_confirmation: + response = input(f"{len(src_to_dest_to_copy)}/{len(src_path_to_new_path)} files ({size_to_be_copied:,} bytes) will be copied.\n Type 'copy' to copy >>") + go_for_it = response.strip(' ') == 'copy' + else: + go_for_it = True # Do the actual copying. - if response.strip(' ') == 'copy': + if go_for_it: print('Copying...') data_copied = 0 for i, src_path in enumerate(sorted(src_to_dest_to_copy), start=1): diff --git a/artemis/image_processing/decorders.py b/artemis/image_processing/decorders.py index 796d1d5a..fdb8b4ff 100644 --- a/artemis/image_processing/decorders.py +++ b/artemis/image_processing/decorders.py @@ -159,9 +159,19 @@ def get_frame_timestamp(self, frame_index: int) -> float: def __iter__(self) -> Iterator[BGRImageArray]: if self._iter_with_opencv: cap = cv2.VideoCapture(self._path) - while True: + n_frames = self._n_frames + for i in itertools.count(): ret, frame = cap.read() if not ret: + if i < self._n_frames - 1: + print(f"Finished prematurely on frame {i+1}/{self._n_frames} while iterating with OpenCV. Falling Back on pyAV\n" + "Something may be corrupted with this video." + ) + print("Trying one more time...") + ret, frame = cap.read() + print(f"Got {'it again' if ret else 'nothing'}") + yield frame + continue break yield frame cap.release() @@ -231,6 +241,7 @@ def robustly_get_decorder( if prefer_decord and DecordDecorder is not None: decorder = DecordDecorder(path) else: + print(f"Selected PyACDecorder for {path}") decorder = PyAvDecorder(path) if use_cache: diff --git a/artemis/plotting/tk_utils/tooltip.py b/artemis/plotting/tk_utils/tooltip.py index 72e25613..665a32ea 100644 --- a/artemis/plotting/tk_utils/tooltip.py +++ b/artemis/plotting/tk_utils/tooltip.py @@ -28,9 +28,20 @@ def showtip(self): if self.tipwindow or not self._text: return x, y, cx, cy = self.widget.bbox("insert") - x = x + self.widget.winfo_rootx() + (57 if self._right_of_cursor else -57 - len(self._text)*self._font_size*0.4) - y = y + cy + self.widget.winfo_rooty() + (27 if self._below_cursor else -27 - self._font_size) + self.tipwindow = tw = tk.Toplevel(self.widget) + text_width = len(self._text)*self._font_size*0.4 + x_offset = 57 + # print(f'Tip window width {self.tipwindow.winfo_width()}') + # is_on_right_edge = x_offset + x + text_width > self.tipwindow.winfo_width() + right_of_cursor = self._right_of_cursor + + x = x + self.widget.winfo_rootx() + (x_offset if right_of_cursor else -x_offset - text_width) + y = y + cy + self.widget.winfo_rooty() + (27 if self._below_cursor else -27 - self._font_size) + + + + tw.wm_overrideredirect(1) tw.wm_geometry("+%d+%d" % (x, y)) # Anchor window to left of cursor diff --git a/artemis/plotting/tk_utils/ui_utils.py b/artemis/plotting/tk_utils/ui_utils.py index 510aa1a7..ee325c06 100644 --- a/artemis/plotting/tk_utils/ui_utils.py +++ b/artemis/plotting/tk_utils/ui_utils.py @@ -105,6 +105,7 @@ def __init__(self, command: Optional[Callable[[], Any]] = None, shortcut: Optional[str] = None, tooltip: Optional[str] = None, + tooltip_anchor: Optional[str] = tk.NW, button_id: Optional[str] = None, add_shortcut_to_tooltip: bool = True, shortcut_binding_widget: Optional[tk.Widget] = None, @@ -131,7 +132,7 @@ def __init__(self, if add_shortcut_to_tooltip and shortcut is not None: shortcut_stroke = get_shortcut_string(shortcut) tooltip = f"({shortcut_stroke})" if tooltip is None else f"{tooltip} ({shortcut_stroke})" - create_tooltip(widget=self, text=tooltip, background=ThemeColours.TOOLTIP_BACKGROUND) + create_tooltip(widget=self, text=tooltip, background=ThemeColours.TOOLTIP_BACKGROUND, anchor=tooltip_anchor) def _execute_shortcut(self, event: tk.Event): if not isinstance(event.widget, (tk.Text, tk.Entry)): From e43007e0e4fcbdb203111f0a6095fecef39760cf Mon Sep 17 00:00:00 2001 From: peter Date: Wed, 21 Feb 2024 18:35:43 -0800 Subject: [PATCH 100/107] somthin --- artemis/plotting/tk_utils/ui_choose_parameters.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/artemis/plotting/tk_utils/ui_choose_parameters.py b/artemis/plotting/tk_utils/ui_choose_parameters.py index ad2fd38d..abc4183c 100644 --- a/artemis/plotting/tk_utils/ui_choose_parameters.py +++ b/artemis/plotting/tk_utils/ui_choose_parameters.py @@ -290,7 +290,10 @@ def get_common_dir_and_relative_paths(self) -> Tuple[Optional[str], Sequence[str dirs = [os.path.dirname(file) for file in self.var.get().split(self._separator)] if len(dirs)>0: common_directory = os.path.commonpath(dirs) - return common_directory, [os.path.relpath(file, common_directory) for file in self.var.get().split(self._separator)] + joined_paths = self.var.get().strip() + paths = joined_paths.split(self._separator) if joined_paths else [] + relative_paths = [os.path.relpath(file, common_directory) for file in paths] + return common_directory, relative_paths else: return None, [] From 56ee34f36d86b65b9bff1fe9398ad4f3d02d20ef Mon Sep 17 00:00:00 2001 From: peter Date: Mon, 4 Mar 2024 09:56:23 -0800 Subject: [PATCH 101/107] changes changes --- artemis/fileman/file_utils.py | 92 +++++++++++++++++++ artemis/general/command_registry.py | 49 ++++++++++ artemis/general/hashing.py | 2 +- artemis/general/test_command_registry.py | 23 +++++ artemis/image_processing/decorders.py | 2 +- artemis/image_processing/image_utils.py | 6 ++ .../tk_utils/alternate_zoomable_image_view.py | 72 +++++++++++---- .../plotting/tk_utils/test_event_binding.py | 39 ++++++++ artemis/plotting/tk_utils/test_tk_utils.py | 11 +-- artemis/plotting/tk_utils/tk_utils.py | 33 +++++++ artemis/plotting/tk_utils/ui_utils.py | 39 ++++++-- 11 files changed, 335 insertions(+), 33 deletions(-) create mode 100644 artemis/general/command_registry.py create mode 100644 artemis/general/test_command_registry.py create mode 100644 artemis/plotting/tk_utils/test_event_binding.py diff --git a/artemis/fileman/file_utils.py b/artemis/fileman/file_utils.py index 9ea0aea3..470cbc9b 100644 --- a/artemis/fileman/file_utils.py +++ b/artemis/fileman/file_utils.py @@ -2,9 +2,14 @@ import shutil import time from contextlib import contextmanager +from dataclasses import dataclass from datetime import datetime from typing import Optional, Sequence, Mapping, Iterator +from more_itertools import first + +from artemis.general.utils_utils import byte_size_to_string + def get_filename_without_extension(path): return os.path.splitext(os.path.basename(path))[0] @@ -56,6 +61,40 @@ def copy_creating_dir_if_needed(src_path: str, dest_path: str): shutil.copyfile(src_path, dest_path) +def copy_file_with_mtime(src, dest, overwrite: bool = True, create_dir_if_needed: bool = True): + """ + Copies a file from src to dest, preserving the file's modification time, + and expands the "~" to the user's home directory. It can optionally overwrite + the destination file (otherwise it will raise a FileExistsError if the + destination file already exists). + """ + # Expand the "~" to the user's home directory for both source and destination + src = os.path.expanduser(src) + dest = os.path.expanduser(dest) + + # Check if destination file exists + if os.path.exists(dest) and not overwrite: + raise FileExistsError(f"The file {dest} already exists and overwrite is set to False.") + + # Ensure the destination directory exists + dest_dir = os.path.dirname(dest) + os.makedirs(dest_dir, exist_ok=True) + + if create_dir_if_needed: + parent, _ = os.path.split(dest) + if not os.path.exists(parent): + os.makedirs(parent) + + # Copy the file content + shutil.copyfile(src, dest) + + # Copy the file metadata + shutil.copystat(src, dest) + + + + + @contextmanager def open_and_create_parent(path, mode='r'): parent, _ = os.path.split(path) @@ -78,6 +117,59 @@ def get_recursive_directory_contents_string(directory: str, indent_level=0, inde return '\n'.join(lines) +def get_files_to_sync(src_folder: str, dest_folder: str, allowed_extensions: Optional[Sequence[str]] = None, skip_existing: bool = True) -> Mapping[str, str]: + src_files = iter_filepaths_in_directory_recursive(src_folder, allowed_extensions, relative=True) + files_to_sync = {os.path.join(src_folder, src): os.path.join(dest_folder, src) for src in src_files} + if skip_existing: + files_to_sync = {src: dest for src, dest in files_to_sync.items() if not os.path.exists(dest)} + return files_to_sync + + +@dataclass +class SyncJobStatus: + files_completed: int + total_files: int + bytes_completed: int + total_bytes: int + time_elapsed: float + time_remaining: float + next_file: str + + def get_sync_progress_string(self) -> str: + return f"Synced {self.files_completed}/{self.total_files} files ({byte_size_to_string(self.bytes_completed)} / {byte_size_to_string(self.total_bytes)}). \nAbout: {self.time_remaining:.1f}s remaining. Next: {self.next_file}" + + +def iter_sync_files(src_path_to_new_path: Mapping[str, str], overwrite: bool = False, check_byte_sizes=True, verbose: bool = True) -> Iterator[SyncJobStatus]: + tstart = time.monotonic() + per_file_bytes = {src: os.path.getsize(src) for src in src_path_to_new_path} + total_bytes = sum(per_file_bytes.values()) + total_n_files = len(src_path_to_new_path) + bytes_completed = 0 + + yield SyncJobStatus(files_completed=0, total_files=total_n_files, bytes_completed=0, total_bytes=sum(os.path.getsize(src) for src in src_path_to_new_path), time_elapsed=0, time_remaining=0, next_file=first(src_path_to_new_path.keys(), default='')) + pairs = list(src_path_to_new_path.items()) + for i, (src_path, dest_path) in enumerate(pairs): + if overwrite or not os.path.exists(dest_path) or (check_byte_sizes and os.path.getsize(src_path) != os.path.getsize(dest_path)): + if verbose: + print(f'Copying {src_path} -> {dest_path}') + copy_creating_dir_if_needed(src_path, dest_path) + else: + if verbose: + print(f'Skipping {src_path} -> {dest_path}') + bytes_completed += per_file_bytes[src_path] + yield SyncJobStatus( + files_completed=i+1, + total_files=total_n_files, + bytes_completed=bytes_completed, + total_bytes=total_bytes, + time_elapsed=time.monotonic()-tstart, + time_remaining=(time.monotonic()-tstart)/(i+1)*(total_n_files-i-1), + next_file=pairs[i+1][0] if i+1 bool: + identifier = identifier or command.unique_command_id + if _COMMAND_REGISTRY is None: + if skip_silently_if_no_registry: + return False + else: + raise Exception(f"Tried to register command {identifier} but no registry was open") + if identifier is None: + return False + _COMMAND_REGISTRY[identifier] = command + return True + + +def get_registry_or_none() -> Mapping[str, NamedCommand]: + return _COMMAND_REGISTRY diff --git a/artemis/general/hashing.py b/artemis/general/hashing.py index 7321cc49..e8518e52 100644 --- a/artemis/general/hashing.py +++ b/artemis/general/hashing.py @@ -68,7 +68,7 @@ def compute_fixed_hash(obj, try_objects=False, use_only_public_fields: bool = Fa _hasher.update(pickle.dumps(obj.dtype, protocol=2)) _hasher.update(pickle.dumps(obj.shape, protocol=2)) _hasher.update(obj.tostring()) - elif isinstance(obj, (int, float, bool)+string_types) or (obj is None) or (obj in (int, str, float, bool)): + elif isinstance(obj, (int, float, bool, bytes)+string_types) or (obj is None) or (obj in (int, str, float, bool, bytes)): _hasher.update(pickle.dumps(obj, protocol=2)) elif isinstance(obj, (list, tuple)): for el in obj: diff --git a/artemis/general/test_command_registry.py b/artemis/general/test_command_registry.py new file mode 100644 index 00000000..4b595e3d --- /dev/null +++ b/artemis/general/test_command_registry.py @@ -0,0 +1,23 @@ +from artemis.general.command_registry import hold_command_registry, add_command_to_registry, NamedCommand, get_registry_or_none + + +def test_command_registry(): + variable = 3 + + def increment_it(): + nonlocal variable + variable += 1 + + with hold_command_registry(): + add_command_to_registry(NamedCommand( + name='Increment it', + command=increment_it, + unique_command_id='command.increment.it' + )) + assert variable == 3 + get_registry_or_none()['command.increment.it'].command() + assert variable == 4 + + +if __name__ == "__main__": + test_command_registry() diff --git a/artemis/image_processing/decorders.py b/artemis/image_processing/decorders.py index fdb8b4ff..90c4d267 100644 --- a/artemis/image_processing/decorders.py +++ b/artemis/image_processing/decorders.py @@ -241,7 +241,7 @@ def robustly_get_decorder( if prefer_decord and DecordDecorder is not None: decorder = DecordDecorder(path) else: - print(f"Selected PyACDecorder for {path}") + # print(f"Selected PyACDecorder for {path}") decorder = PyAvDecorder(path) if use_cache: diff --git a/artemis/image_processing/image_utils.py b/artemis/image_processing/image_utils.py index 6bae4f4c..7376228d 100644 --- a/artemis/image_processing/image_utils.py +++ b/artemis/image_processing/image_utils.py @@ -908,9 +908,15 @@ def pan_by_pixel_shift(self, pixel_shift_xy: Tuple[float, float], limit: bool = return result def pan_by_display_relshift(self, display_rel_xy: Tuple[float, float], limit: bool = True) -> 'ImageViewInfo': + """ Pan by a fraction of the width/height of the current display window """ pixel_shift_xy = np.asarray(display_rel_xy) * self._get_display_wh() / self.zoom_level return self.pan_by_pixel_shift(pixel_shift_xy=pixel_shift_xy, limit=limit) + def pan_by_image_relshift(self, image_rel_xy: Tuple[float, float], limit: bool = True): + """ Pan by a fraction of the width/height of the entire image """ + pixel_shift_xy = np.asarray(image_rel_xy) * self.image_wh + return self.pan_by_pixel_shift(pixel_shift_xy=pixel_shift_xy, limit=limit) + def pan_by_display_shift(self, display_shift_xy: Tuple[float, float], limit: bool = True) -> 'ImageViewInfo': pixel_shift_xy = np.asarray(display_shift_xy) / self.zoom_level return self.pan_by_pixel_shift(pixel_shift_xy=pixel_shift_xy, limit=limit) diff --git a/artemis/plotting/tk_utils/alternate_zoomable_image_view.py b/artemis/plotting/tk_utils/alternate_zoomable_image_view.py index 56538b5a..b3628772 100644 --- a/artemis/plotting/tk_utils/alternate_zoomable_image_view.py +++ b/artemis/plotting/tk_utils/alternate_zoomable_image_view.py @@ -13,12 +13,18 @@ from PIL import ImageTk from artemis.fileman.smart_io import smart_load_image +from artemis.general.command_registry import add_command_to_registry, NamedCommand from artemis.general.custom_types import BGRImageArray, BGRColorTuple from artemis.image_processing.image_utils import ImageViewInfo, BGRColors from artemis.plotting.tk_utils.machine_utils import is_windows_machine from artemis.plotting.tk_utils.tk_error_dialog import tk_error_detail_handler, ErrorDetail from artemis.plotting.tk_utils.tk_utils import bind_callbacks_to_widget from artemis.plotting.tk_utils.ui_utils import bgr_image_to_pil_image +import os + +IS_WINDOWS = os.name == 'nt' + +MODIFIER_KEY = 'Control' if IS_WINDOWS else 'Command' class ZoomableImageFrame(tk.Label): @@ -32,7 +38,8 @@ def __init__(self, scrollbar_color: BGRColorTuple = BGRColors.GRAY, zoom_jump_factor: float = 1.2, max_zoom: float = 40.0, - pan_jump_factor=0.2, + pan_jump_factor=0.2, # This is relative to the display window + fast_pan_jump_factor=0.1, # This is relative to the whole image mouse_scroll_speed: float = 2.0, error_handler: Optional[Callable[[ErrorDetail], None]] = tk_error_detail_handler, zoom_scrolling_mode: bool = False, # Use mouse scrollwheel to zoom, @@ -44,6 +51,7 @@ def __init__(self, scroll_indicator_width_pix: int = 10, rel_area_change_to_reset_zoom: float = 0.25, margin_gap: int = 4, # Prevents infinite config-loop + nearest_neighbor_zoom_threshold: float = 3.0, # Zoom level at which to switch to nearest neighbor interpolation ): # self.label = tk.Label(parent_frame) @@ -52,6 +60,7 @@ def __init__(self, # assert height is not None or width is not None, "You must specify height, width, or both to display image" # self.height = height # self.width = width + self._nearest_neighbor_zoom_threshold = nearest_neighbor_zoom_threshold self._after_view_change_callback = after_view_change_callback self._mouse_scroll_speed = mouse_scroll_speed self._image_view_frame: Optional[ImageViewInfo] = None @@ -76,16 +85,23 @@ def __init__(self, if image is not None: self.set_image(image) + self._binding_dict: Dict[str, Callable[[Event], None]] = { **(additional_canvas_callbacks or {}), **{ + f'<{MODIFIER_KEY}-=>': lambda event: self.set_image_frame(self._image_view_frame.zoom_by(zoom_jump_factor, invariant_display_xy=self._event_to_display_xy(event))), + f'<{MODIFIER_KEY}-minus>': lambda event: self.set_image_frame(self._image_view_frame.zoom_by(1 / zoom_jump_factor, invariant_display_xy=self._event_to_display_xy(event))), '': lambda event: self.set_image_frame(self._image_view_frame.zoom_by(zoom_jump_factor, invariant_display_xy=self._event_to_display_xy(event))), '': lambda event: self.set_image_frame(self._image_view_frame.zoom_by(1 / zoom_jump_factor, invariant_display_xy=self._event_to_display_xy(event))), - '': lambda event: self.set_image_frame(self._image_view_frame.zoom_out()), + '': lambda event: self.set_image_frame(self._image_view_frame.zoom_out() if self._image_view_frame else None), '': lambda event: self.set_image_frame(self._image_view_frame.pan_by_display_relshift(display_rel_xy=(0, -pan_jump_factor), limit=True)), '': lambda event: self.set_image_frame(self._image_view_frame.pan_by_display_relshift(display_rel_xy=(-pan_jump_factor, 0), limit=True)), '': lambda event: self.set_image_frame(self._image_view_frame.pan_by_display_relshift(display_rel_xy=(0, pan_jump_factor), limit=True)), '': lambda event: self.set_image_frame(self._image_view_frame.pan_by_display_relshift(display_rel_xy=(pan_jump_factor, 0), limit=True)), + '': lambda event: self.set_image_frame(self._image_view_frame.pan_by_image_relshift(image_rel_xy=(0, -fast_pan_jump_factor), limit=True)), + '': lambda event: self.set_image_frame(self._image_view_frame.pan_by_image_relshift(image_rel_xy=(-fast_pan_jump_factor, 0), limit=True)), + '': lambda event: self.set_image_frame(self._image_view_frame.pan_by_image_relshift(image_rel_xy=(0, fast_pan_jump_factor), limit=True)), + '': lambda event: self.set_image_frame(self._image_view_frame.pan_by_image_relshift(image_rel_xy=(fast_pan_jump_factor, 0), limit=True)), '': self._on_click, # For some reason, this is not working... # '': lambda event: print("Single click"), # This never gets called '': self._on_double_click, @@ -97,8 +113,8 @@ def __init__(self, '': self._handle_mousewheel_event, '': self._on_configure, # This may be unnecessary - and it can cause dangerous loops # Add number-pad callbacks: 5 to zoom in, 0 to zoom out, 1-9 to pan - "": lambda event: self.set_image_frame(self._image_view_frame.zoom_by(zoom_jump_factor, invariant_display_xy=None)), - "": lambda event: self.set_image_frame(self._image_view_frame.zoom_by(1 / zoom_jump_factor, invariant_display_xy=None)), + "": lambda event: self.set_image_frame(self._image_view_frame.zoom_by(zoom_jump_factor, invariant_display_xy=self._event_to_display_xy(event))), + "": lambda event: self.set_image_frame(self._image_view_frame.zoom_by(1 / zoom_jump_factor, invariant_display_xy=self._event_to_display_xy(event))), **{f"": lambda event, i=i: self.set_image_frame(self._image_view_frame.pan_by_display_relshift(display_rel_xy=(pan_jump_factor*(((i-1) % 3)-1), -pan_jump_factor*(((i-1)//3)-1)), limit=True)) for i in [1, 2, 3, 4, 6, 7, 8, 9]}, # Add callbacks for entering/exiting focus: @@ -113,6 +129,10 @@ def __init__(self, # }, # **{f"<{key}>": self.onKeyPress for key in 'zxcwasd'} }} + + # redundant with below sort of + # add_command_to_registry(NamedCommand('Zoom out / Center (c)', command=lambda: self._binding_dict[''](None), unique_command_id='image_player.zoom_out')) + bind_callbacks_to_widget(callbacks=self._binding_dict, widget=self, bind_all=False, error_handler=error_handler) self.bind("<1>", lambda event: self.focus_set()) # self.rebind() @@ -143,7 +163,10 @@ def _on_configure(self, event: Event): def _on_mouse_drag_and_release(self, event: Event): - if self._mouse_callback is not None and self._image_view_frame is not None: + if self._image_view_frame is None: + return + + if self._mouse_callback is not None: display_xy = self._event_to_display_xy(event) px, py = self._image_view_frame.display_xy_to_pixel_xy(display_xy) eat_event = self._mouse_callback(event, (int(px), int(py))) @@ -224,15 +247,28 @@ def _handle_mousewheel_event(self, event: Event): if new_frame is not None: self.set_image_frame(new_frame) - def set_image(self, image: BGRImageArray, redraw_now: bool = True): + def set_image(self, image: BGRImageArray, redraw_now: bool = True, view_info: Optional[ImageViewInfo] = None): zoom_reset_needed = self._image is not None and self._image.shape != image.shape self._image = image - if redraw_now: + if view_info: + self.set_image_frame(view_info) + elif redraw_now: if zoom_reset_needed: self.reset_zoom() else: self.redraw() + def get_current_mouse_pixel_xy(self) -> Optional[Tuple[int, int]]: + if self._image_view_frame is None: + return None + display_xy = self.winfo_pointerxy() + px, py = self._image_view_frame.display_xy_to_pixel_xy(display_xy) + is_inside_image = 0 <= px < self._image.shape[1] and 0 <= py < self._image.shape[0] + if is_inside_image: + return int(px), int(py) + else: + return None + @contextmanager def hold_off_redraw_context(self): try: @@ -266,14 +302,14 @@ def zoom_to_pixel(self, pixel_xy: Tuple[int, int], zoom_level: float, adjust_to_ def get_image_view_frame_or_none(self) -> Optional[ImageViewInfo]: return self._image_view_frame - def rebind(self): - # self.unbind_keys() - bind_callbacks_to_widget(self._binding_dict, widget=self) - # self.bind("<1>", lambda event: self.focus_set()) - self.bind() - - def unbind_keys(self): - self.unbind_all(list(self._binding_dict.keys())) + # def rebind(self): + # # self.unbind_keys() + # bind_callbacks_to_widget(self._binding_dict, widget=self) + # # self.bind("<1>", lambda event: self.focus_set()) + # self.bind() + # + # def unbind_keys(self): + # self.unbind_all(list(self._binding_dict.keys())) # # def zoom_by(self, zoom_factor: float, invariant_display_xy: Tuple[int, int]): @@ -317,7 +353,11 @@ def redraw(self): self._image_view_frame = self._image_view_frame.adjust_frame_and_image_size(new_image_wh=(self._image.shape[1], self._image.shape[0]), new_frame_wh=(width, height)) if not keep_old_zoom: self._image_view_frame = self._image_view_frame.zoom_out() - disp_image = self._image_view_frame.create_display_image(self._image, gap_color=self._gap_color, scroll_fg_color = self._scrollbar_color) + disp_image = self._image_view_frame.create_display_image(self._image, + gap_color=self._gap_color, + scroll_fg_color = self._scrollbar_color, + nearest_neighbor_zoom_threshold = self._nearest_neighbor_zoom_threshold, + ) # disp_image = cv2.cvtColor(disp_image, cv2.COLOR_BGR2RGB) # im_resized = put_image_in_box(self._image, (self.winfo_width(), self.winfo_height())) # print(f"Zoomable Display image shape: {disp_image.shape}, with width={width}, height={height}") diff --git a/artemis/plotting/tk_utils/test_event_binding.py b/artemis/plotting/tk_utils/test_event_binding.py new file mode 100644 index 00000000..3a62f674 --- /dev/null +++ b/artemis/plotting/tk_utils/test_event_binding.py @@ -0,0 +1,39 @@ +import tkinter as tk + +def recursively_bind(widget, event, handler): + """ + Recursively bind an event to a widget and all its descendants. + + Args: + - widget: The root widget to start binding from. + - event: The event to bind, e.g., ''. + - handler: The function to call when the event occurs. + """ + widget.bind(event, handler) + for child in widget.winfo_children(): + recursively_bind(child, event, handler) + +def on_any_escape(event): + print("Escape pressed in:", event.widget) + +# Example usage +if __name__ == "__main__": + root = tk.Tk() + root.geometry("300x200") + + frame = tk.Frame(root) + frame.pack(fill=tk.BOTH, expand=True) + + sub_frame = tk.Frame(frame, bg="lightblue") + sub_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + entry = tk.Entry(sub_frame) + entry.pack(padx=20, pady=20) + + button = tk.Button(sub_frame, text="Click Me") + button.pack(pady=10) + + # Bind the Escape key to the frame and all its descendants + recursively_bind(frame, '', on_any_escape) + + root.mainloop() diff --git a/artemis/plotting/tk_utils/test_tk_utils.py b/artemis/plotting/tk_utils/test_tk_utils.py index 5d484505..5cf1c9a9 100644 --- a/artemis/plotting/tk_utils/test_tk_utils.py +++ b/artemis/plotting/tk_utils/test_tk_utils.py @@ -1,5 +1,9 @@ import time +from tkinter import colorchooser +from tkinter.colorchooser import askcolor + from artemis.plotting.tk_utils.tk_utils import BlockingTaskDialogFunction, hold_tkinter_root_context +import tkinter as tk def test_show_blocking_task_dialog(): @@ -11,10 +15,5 @@ def test_show_blocking_task_dialog(): ).show_blocking_task_dialog(time.sleep(0.01) for _ in range(100)) - -# def test_widget_overlay_frame(): - - - if __name__ == "__main__": - test_show_blocking_task_dialog() \ No newline at end of file + test_show_blocking_task_dialog() diff --git a/artemis/plotting/tk_utils/tk_utils.py b/artemis/plotting/tk_utils/tk_utils.py index e5fb4efe..1acf4e1d 100644 --- a/artemis/plotting/tk_utils/tk_utils.py +++ b/artemis/plotting/tk_utils/tk_utils.py @@ -1,5 +1,6 @@ import tkinter as tk import traceback +from abc import abstractmethod from collections import deque from contextlib import contextmanager from dataclasses import dataclass, field @@ -117,6 +118,20 @@ def wrapped_callback(event: tk.Event) -> Any: return wrapped_callback +def recursively_bind(widget: Widget, event: str, handler: Callable[[tk.Event], Any]) -> None: + """ + Recursively bind an event to a widget and all its descendants. + + Args: + - widget: The root widget to start binding from. + - event: The event to bind, e.g., ''. + - handler: The function to call when the event occurs. + """ + widget.bind(event, handler) + for child in widget.winfo_children(): + recursively_bind(child, event, handler) + + def bind_callbacks_to_widget( callbacks: Mapping[Union[str], Callable[[tk.Event], Any]], widget: tk.Widget, @@ -578,6 +593,22 @@ def set_collapsed(self, collapse_state): # print(f"Uncollapsed {self} with {pack_manager} with args {args} and kwargs {kwargs}") +class IEscapeHandler: + """ + A mixin to handle escape events in a widget and its children + """ + + @abstractmethod + def on_escape(self) -> bool: + """ Handle escape, returning True to consume the event, or False to propagate it """ + raise NotImplementedError() + + +def handle_escape(widget: tk.Widget) -> bool: + if isinstance(widget, EscapeHandlerMixin): + return widget.handle_escape() + + class MessageListenerMixin: def __init__(self, message_listener: Optional[Callable[[str], None]] = None): @@ -594,6 +625,8 @@ def _send_hint_message(self, message: str): self._send_message(f"ℹī¸: {message}") + + def assert_no_existing_root(): assert tk._default_root is None, "A Tkinter root window already exists!" diff --git a/artemis/plotting/tk_utils/ui_utils.py b/artemis/plotting/tk_utils/ui_utils.py index ee325c06..f163d6b3 100644 --- a/artemis/plotting/tk_utils/ui_utils.py +++ b/artemis/plotting/tk_utils/ui_utils.py @@ -12,6 +12,7 @@ import cv2 from PIL import Image, ImageTk +from artemis.general.command_registry import add_command_to_registry, NamedCommand from artemis.general.custom_types import BGRImageArray from artemis.image_processing.image_builder import ImageBuilder from artemis.image_processing.image_utils import BGRColors @@ -120,8 +121,8 @@ def __init__(self, self._error_handler = error_handler - if button_id is not None and command is not None: - register_button(button_id, command) + # if button_id is not None and command is not None: + # register_button(button_id, command) self._command = command if shortcut is not None: @@ -134,6 +135,13 @@ def __init__(self, tooltip = f"({shortcut_stroke})" if tooltip is None else f"{tooltip} ({shortcut_stroke})" create_tooltip(widget=self, text=tooltip, background=ThemeColours.TOOLTIP_BACKGROUND, anchor=tooltip_anchor) + add_command_to_registry(NamedCommand( + name=text, + command=command, + description=tooltip, + unique_command_id=button_id or f'buttons.{text.lower().replace(" ", "_")}' + )) + def _execute_shortcut(self, event: tk.Event): if not isinstance(event.widget, (tk.Text, tk.Entry)): # Block keystrokes from something is being typed into a text field @@ -163,7 +171,8 @@ def __init__(self, **kwargs ): - RespectableLabel.__init__(self, master, text='', command=self.toggle, tooltip=tooltip, borderwidth=1, relief=tk.RAISED, **kwargs) + RespectableLabel.__init__(self, master, text='', command=self.toggle, tooltip=tooltip, borderwidth=1, relief=tk.RAISED, + button_id=f"{on_text}/{off_text}", **kwargs) self._state = False self._state_switch_pre_callback = state_switch_pre_callback self._state_switch_callback = state_switch_callback @@ -259,8 +268,8 @@ def __init__(self, shortcut_stroke = get_shortcut_string(shortcut) tooltip = f"({shortcut_stroke})" if tooltip is None else f"{tooltip} ({shortcut_stroke})" - if button_id is not None: - register_button(button_id, command) + # if button_id is not None: + # register_button(button_id, command) # Add callback when clicked self.bind("", lambda event: self._call_callback_with_safety()) @@ -282,6 +291,14 @@ def __init__(self, s = f"<{s.strip('<>')}>" master.winfo_toplevel().bind(s, lambda event: self._call_callback_with_safety()) + add_command_to_registry(NamedCommand( + name=text, + command=command, + description=tooltip, + unique_command_id=f'buttons.{text.lower().replace(" ", "_")}' + )) + + def get_command(self) -> Callable[[], Any]: return self._callback @@ -339,12 +356,16 @@ def __init__(self, as_row: bool = True, # False = column font: Optional[Union[str, Tuple[str, int]]] = (None, 14), max_buttons_before_expand: Optional[int] = None, + expand_button_text: str = '...', + expand_button_tooltip: str = "Show other buttons", **kwargs): tk.Frame.__init__(self, master, **kwargs) self._error_handler = error_handler self._buttons = [] self._as_row = as_row self._font = font + self._expand_button_text = expand_button_text + self._expand_button_tooltip = expand_button_tooltip self._max_buttons_before_expand = max_buttons_before_expand self._is_adding_expand_button = False if as_row: @@ -411,10 +432,10 @@ def callback(): new_window.geometry(f"{width}x{min(500, 50*len(self._buttons)+50)}") new_window.resizable(False, False) - # Keep it on top until clicked - new_window.attributes("-topmost", True) + # Keep it on top until clicked (nevermind, it's annoying) + # new_window.attributes("-topmost", True) - for button in self._buttons[self._max_buttons_before_expand:]: + for button in self._buttons[max(0, self._max_buttons_before_expand-1):]: new_button = button.adopt_to_new_parent( parent=new_window, command=on_button_press(button.get_command()), @@ -440,7 +461,7 @@ def _add_next_widget(self, widget: tk.Widget, weight: int = 1, padding: int = 0) if self._max_buttons_before_expand is not None and count+1 == self._max_buttons_before_expand and not self._is_adding_expand_button: # If we're at the limit - add the expand button self._is_adding_expand_button = True - self.add_button('...', self._expand, tooltip="Show other buttons", weight=weight) + self.add_button(self._expand_button_text, self._expand, tooltip=self._expand_button_tooltip, weight=weight) self._is_adding_expand_button = False if self._max_buttons_before_expand is not None and count+1 > self._max_buttons_before_expand: From ea0486b99704a510782d842f6c5a564ee9540f0c Mon Sep 17 00:00:00 2001 From: peter Date: Wed, 6 Mar 2024 01:31:16 -0800 Subject: [PATCH 102/107] small things --- artemis/fileman/file_utils.py | 7 +- .../plotting/tk_utils/ui_choose_parameters.py | 89 ++++++++++++++++--- artemis/plotting/tk_utils/ui_utils.py | 48 +++++++++- 3 files changed, 128 insertions(+), 16 deletions(-) diff --git a/artemis/fileman/file_utils.py b/artemis/fileman/file_utils.py index 470cbc9b..b4c26f1f 100644 --- a/artemis/fileman/file_utils.py +++ b/artemis/fileman/file_utils.py @@ -58,7 +58,7 @@ def copy_creating_dir_if_needed(src_path: str, dest_path: str): parent, _ = os.path.split(dest_path) if not os.path.exists(parent): os.makedirs(parent) - shutil.copyfile(src_path, dest_path) + shutil.copy2(src_path, dest_path) def copy_file_with_mtime(src, dest, overwrite: bool = True, create_dir_if_needed: bool = True): @@ -136,10 +136,11 @@ class SyncJobStatus: next_file: str def get_sync_progress_string(self) -> str: - return f"Synced {self.files_completed}/{self.total_files} files ({byte_size_to_string(self.bytes_completed)} / {byte_size_to_string(self.total_bytes)}). \nAbout: {self.time_remaining:.1f}s remaining. Next: {self.next_file}" + return f"Synced {self.files_completed}/{self.total_files} files ({byte_size_to_string(self.bytes_completed)} / {byte_size_to_string(self.total_bytes)}). \nAbout: {self.time_remaining:.1f}s remaining. Next: {os.path.basename(self.next_file)}" -def iter_sync_files(src_path_to_new_path: Mapping[str, str], overwrite: bool = False, check_byte_sizes=True, verbose: bool = True) -> Iterator[SyncJobStatus]: +def iter_sync_files(src_path_to_new_path: Mapping[str, str], overwrite: bool = False, check_byte_sizes=True, verbose: bool = True + ) -> Iterator[SyncJobStatus]: tstart = time.monotonic() per_file_bytes = {src: os.path.getsize(src) for src in src_path_to_new_path} total_bytes = sum(per_file_bytes.values()) diff --git a/artemis/plotting/tk_utils/ui_choose_parameters.py b/artemis/plotting/tk_utils/ui_choose_parameters.py index abc4183c..8410f0a4 100644 --- a/artemis/plotting/tk_utils/ui_choose_parameters.py +++ b/artemis/plotting/tk_utils/ui_choose_parameters.py @@ -3,9 +3,11 @@ from abc import ABCMeta from abc import abstractmethod from dataclasses import dataclass, field, fields, replace +from datetime import datetime, timedelta from tkinter import ttk, filedialog from typing import Optional, TypeVar, Sequence, get_origin, get_args, Any, Dict, Callable, Union, Generic, Tuple, Mapping +from chronyk import Chronyk, ChronykDelta from more_itertools.more import first # Assuming the required modules from artemis.plotting.tk_utils are available @@ -75,17 +77,22 @@ def get_variables(self) -> Mapping[Tuple[Union[int, str], ...], tk.Variable]: class EntryParameterSelectionFrame(IParameterSelectionFrame): - def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder'): + def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder', parser: Optional[Callable[[str], ParametersType]] = None): super().__init__(master, **builder.general_kwargs) self._builder = builder - self.var = tk.StringVar(master=self, value=self._builder.initial_value) if self._builder.param_type == str \ - else tk.DoubleVar(master=self, value=self._builder.initial_value) if self._builder.param_type == float else \ - tk.IntVar(master=self, value=self._builder.initial_value) + self._parser = parser + self.var = tk.DoubleVar(master=self, value=self._builder.initial_value) if self._builder.param_type == float \ + else tk.IntVar(master=self, value=self._builder.initial_value) if self._builder.param_type == int \ + else tk.StringVar(master=self, value=self._builder.initial_value) entry = tk.Entry(self, textvariable=self.var) entry.grid(column=0, row=0, sticky="ew") def get_filled_parameters(self) -> ParametersType: - return self.var.get() + entry_obj = self.var.get() + if self._parser: + return self._parser(entry_obj) + else: + return entry_obj def get_variables(self) -> Mapping[Tuple[Union[int, str], ...], tk.Variable]: return {(): self.var} @@ -104,6 +111,7 @@ def get_variables(self) -> Mapping[Tuple[Union[int, str], ...], tk.Variable]: class AddedWidgetParameterSelectionFrame(IParameterSelectionFrame[ParametersType]): + """ Adds a widget to the right of the parameter frame. The widget is created by added_button_builder. """ def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder', added_button_builder: Optional[Callable[[tk.Frame, 'ParameterUIBuilder'], tk.Widget]] = None): super().__init__(master, **builder.general_kwargs) @@ -128,6 +136,9 @@ def constructor(master: tk.Widget, builder: 'ParameterUIBuilder') -> tk.Widget: def get_filled_parameters(self) -> ParametersType: return self._child.get_filled_parameters() + def get_variables(self) -> Mapping[Tuple[Union[int, str], ...], tk.Variable]: + return self._child.get_variables() + class BooleanParameterSelectionFrame(IParameterSelectionFrame[bool]): @@ -227,7 +238,7 @@ def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder'): super().__init__(master, **builder.general_kwargs) self._builder = builder self._checkbox_var = tk.BooleanVar(value=self._builder.initial_value is not None) - check_box = tk.Checkbutton(self, variable=self._checkbox_var) + check_box = tk.Checkbutton(self, variable=self._checkbox_var, state=tk.NORMAL if builder.editable_fields else tk.DISABLED, command=lambda: self._on_checkbox_change(self._checkbox_var.get())) check_box.grid(column=0, row=0, sticky="w") if not builder.editable_fields: check_box.configure(state=tk.DISABLED) @@ -247,6 +258,7 @@ def _on_checkbox_change(self, new_value: bool): self._child_frame.grid(column=1, row=0, sticky="ew") else: self._child_frame.grid_forget() + self.update() def get_filled_parameters(self) -> Optional[Any]: return self._child_frame.get_filled_parameters() if self._checkbox_var.get() else None @@ -255,6 +267,37 @@ def get_variables(self) -> Mapping[Tuple[Union[int, str], ...], tk.Variable]: return self._child_frame.get_variables() if self._checkbox_var.get() else {} +class FolderParameterSelectionFrame(IParameterSelectionFrame[str]): + + def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder'): + super().__init__(master, **builder.general_kwargs) + self._builder = builder + self.var = tk.StringVar(self, builder.initial_value if builder.initial_value is not NoDefault else '') + frame = tk.Frame(self) + frame.grid(column=0, row=0, sticky="ew") + self._folder_label = RespectableLabel(frame, text="", anchor=tk.W) + self._folder_label.grid(column=0, row=0, sticky="ew") + self._folder_button = RespectableLabel(frame, text="📂", command=self._select_folder) + self._folder_button.grid(column=1, row=0, sticky="ew") + self._rebuild_folder_label() + + def _select_folder(self): + folder = filedialog.askdirectory(initialdir=self.var.get()) + if folder is not None: + self.var.set(folder) + self._rebuild_folder_label() + + def _rebuild_folder_label(self): + self._folder_label.configure(text=self.var.get() if self.var.get() else "") + self.update() + + def get_filled_parameters(self) -> Optional[str]: + return self.var.get() + + def get_variables(self) -> Mapping[Tuple[Union[int, str], ...], tk.Variable]: + return {(): self.var} + + class FileListParameterSelectionFrame(IParameterSelectionFrame[Sequence[str]]): def __init__(self, @@ -317,15 +360,19 @@ def _rebuild_file_list(self): self.update() + + + class ButtonParameterSelectionFrame(IParameterSelectionFrame[ParametersType]): """ Parameter is summarized in a clickable label which, when clicked, can open up an edit menu with ui_choose_parameters """ - def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder'): + def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder', max_characthers: int = 50): super().__init__(master, **builder.general_kwargs) self._builder = builder self.var = MockVariable(self, initial_value=self._builder.initial_value) + self._max_characthers = max_characthers # self._param_type = param_type # self._current_value = initial_value self._label = RespectableLabel(self, anchor=tk.W, command=self._on_click, text="") @@ -335,13 +382,18 @@ def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder'): self._update_label() def _update_label(self): - self._label.configure(text=str(self.var.get())) + + string_description = str(self.var.get()) + if len(string_description) > self._max_characthers: + string_description = string_description[:self._max_characthers] + "..." + self._label.configure(text=string_description) def _on_click(self): new_value = ui_choose_parameters(builder=self._builder) if new_value is not None: self.var.set(new_value) self._update_label() + # self._builder.on_change_callback def get_filled_parameters(self) -> ParametersType: return self.var.get() @@ -372,7 +424,7 @@ def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder'): # editable_subfields = [e for e in editable_fields if len((x := e.split("."))) and x[0] == f.name] if isinstance(editable_fields, Sequence) else editable_fields # editable_subfields = filter_subfields(editable_fields, f.name) - frame = self._builder.modify_for_subfield(subfield_index=f.name, subfield_type=f.type).build_parameter_frame(self) + frame = self._builder.modify_for_subfield(subfield_index=f.name, subfield_type=f.type, subfield_metadata=f.metadata).build_parameter_frame(self) # parent=self, # param_type=f.type, # initial_value=getattr(initial_value, f.name) if initial_value is not None else None, @@ -476,6 +528,7 @@ class ParameterUIBuilder: # parent: Optional[tk.Widget] # The parent widget initial_value: Any # The initial value to display. If None, param_type must be provided. param_type: Optional[type] = None # The type of the parameter. If None, initial_value must be provided. + param_metadata: Optional[Dict[str, Any]] = None # Metadata about the parameter path: str = '' # The path to the parameter, e.g. "a.b.c" means the "c" field of the "b" field of the "a" field. editable_fields: Union[bool, Sequence[str]] = True # Either end_field_patterns: Sequence[str] = () # Paths to fields that should not be expanded upon @@ -505,7 +558,7 @@ def get_extra_widget_or_none(self, path: str) -> Optional[tk.Widget]: def is_end_field(self) -> bool: return any(does_field_match_pattern(self.path, pattern) for pattern in self.end_field_patterns) - def modify_for_subfield(self, subfield_index: Union[int, str], subfield_type: type) -> 'ParameterUIBuilder': + def modify_for_subfield(self, subfield_index: Union[int, str], subfield_type: type, subfield_metadata: Optional[Mapping[str, Any]] = None) -> 'ParameterUIBuilder': if self.initial_value is not None and self.initial_value is not NoDefault: initial_value = getattr(self.initial_value, subfield_index) if isinstance(subfield_index, str) else self.initial_value[subfield_index] else: @@ -517,6 +570,7 @@ def modify_for_subfield(self, subfield_index: Union[int, str], subfield_type: ty path=self.path + "." + str(subfield_index), initial_value=initial_value, param_type=subfield_type, + param_metadata=subfield_metadata ) def modify_for_new_menu_window(self) -> 'ParameterUIBuilder': @@ -551,15 +605,23 @@ def build_parameter_frame(self, parent: tk.Widget) -> IParameterSelectionFrame: else: param_type = self.param_type + param_metadata = self.param_metadata or {} + # param_metadata_dict = getattr(param_type, "__metadata__", {}) if (constructor:=first((f for pattern, f in self.custom_constructors.items() if does_field_match_pattern(self.path, pattern)), None)) is not None: return constructor(parent, self) - elif param_type in [str, int, float, bool]: # It's just a single value we don't have to think about whether to break in + elif param_type in [str, int, float, bool, datetime, timedelta]: # It's just a single value we don't have to think about whether to break in if not self.is_path_matching_editable_fields(): # If we're not editing anything, just show the value frame = UneditableParameterSelectionFrame(parent, builder=self) elif param_type == bool: # Start with shallow objects frame = BooleanParameterSelectionFrame(parent, builder=self) + elif param_type == str and param_metadata.get('type', '') == "folder": + frame = FolderParameterSelectionFrame(parent, builder=self) elif param_type == str or param_type == int or param_type == float: frame = EntryParameterSelectionFrame(parent, builder=self) + elif param_type == datetime: + frame = EntryParameterSelectionFrame(parent, builder=self, parser=lambda s: Chronyk(s).datetime()) + elif param_type == timedelta: + frame = EntryParameterSelectionFrame(parent, builder=self, parser=lambda s: timedelta(seconds=ChronykDelta(s).seconds)) else: raise NotImplementedError(f"Type {param_type} not supported.") else: # We need to break in @@ -676,11 +738,15 @@ class ParameterSelectionFrame(tk.Frame): def __init__(self, master: tk.Widget, + builder: Optional[ParameterUIBuilder] = None, on_change_callback: Optional[Callable[[Tuple[Union[int, str]], tk.Variable], None]] = None, + **kwargs): super().__init__(master, **kwargs) self._param_frame: Optional[IParameterSelectionFrame] = None + if builder is not None: + self.set_parameters(builder) # self._on_change_callback = on_change_callback def reset_frame(self): @@ -705,6 +771,7 @@ def set_parameters(self, builder: ParameterUIBuilder): first_editable_field: Optional[tk.Widget] = first((child for child in self._param_frame.winfo_children() if child.winfo_class() == "Entry"), default=None) if first_editable_field is not None: first_editable_field.focus_set() + self.update() # Needed to ensure things actually display off the start def get_filled_parameters(self) -> Optional[ParametersType]: return self._param_frame.get_filled_parameters() if self._param_frame is not None else None diff --git a/artemis/plotting/tk_utils/ui_utils.py b/artemis/plotting/tk_utils/ui_utils.py index f163d6b3..f160437e 100644 --- a/artemis/plotting/tk_utils/ui_utils.py +++ b/artemis/plotting/tk_utils/ui_utils.py @@ -426,7 +426,9 @@ def callback(): new_window.destroy() return callback - new_window = tk.Toplevel(self.master) + new_window = toplevel_centered(self.master) + # Center on parent window + new_window.geometry(f"+{self.master.winfo_rootx()+self.master.winfo_width()//2}+{self.master.winfo_rooty()+self.master.winfo_height()//2}") new_window.title("Additional Buttons") width = 600 new_window.geometry(f"{width}x{min(500, 50*len(self._buttons)+50)}") @@ -445,7 +447,7 @@ def callback(): new_button.pack(fill=tk.BOTH, expand=True) # Add a cancel button - cancel_button = RespectableButton(new_window, text='Cancel', command=new_window.destroy) + cancel_button = RespectableButton(new_window, text='Cancel', command=new_window.destroy, shortcut='Escape') cancel_button.pack(fill=tk.BOTH, expand=True) # Clicking anywhere outside the window will close it @@ -569,3 +571,45 @@ def set_state(self, state: MultiStateEnumType): def get_state(self) -> MultiStateEnumType: assert self._active_state is not None, "No state set" return self._active_state + + + +@contextmanager +def hold_toplevel_centered(parent: Optional[tk.Widget], *args, **kwargs): + """ Create a toplevel window, and center it on the parent widget after its contents are filled in""" + if parent is None: + yield tk.Toplevel(*args, **kwargs) + return + parent = parent.winfo_toplevel() + try: + # Make parent unresponsive until the toplevel is closed + top = tk.Toplevel(master=parent, *args, **kwargs) + # if parent is not None: + # parent_top = parent.winfo_toplevel() + # top.geometry(f"+{int(parent_top.winfo_rootx() + parent_top.winfo_width() / 2 )}+" + # f"{int(parent_top.winfo_rooty() + parent_top.winfo_height() / 2)}") + yield top + top.update_idletasks() + + top.geometry(f"+{int(parent.winfo_rootx() + parent.winfo_width() / 2 ) - int(top.winfo_width() / 2)}+" + f"{int(parent.winfo_rooty() + parent.winfo_height() / 2) - int(top.winfo_height() / 2)}") + + top.focus_force() + + # Put it on top always + top.attributes('-topmost', True) + # top.wait_window() + finally: + parent.state('normal') + + + + +def toplevel_centered(parent: Optional[tk.Widget] = None, *args, **kwargs): + """ Create a toplevel window, and center it on the parent widget after its contents are filled in""" + top = tk.Toplevel(master=parent, *args, **kwargs) + if parent is not None: + parent_top = parent.winfo_toplevel() + top.geometry(f"+{int(parent_top.winfo_rootx() + parent_top.winfo_width() / 2 )}+" + f"{int(parent_top.winfo_rooty() + parent_top.winfo_height() / 2)}") + return top From 1cb1237a3d57bdf4899c5c4df6d8b267f7f07987 Mon Sep 17 00:00:00 2001 From: peter Date: Wed, 6 Mar 2024 12:29:43 -0800 Subject: [PATCH 103/107] added splash screen function (thanks ChatGPT) --- .../tk_utils/tk_show_splash_screen.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 artemis/plotting/tk_utils/tk_show_splash_screen.py diff --git a/artemis/plotting/tk_utils/tk_show_splash_screen.py b/artemis/plotting/tk_utils/tk_show_splash_screen.py new file mode 100644 index 00000000..ae8fae12 --- /dev/null +++ b/artemis/plotting/tk_utils/tk_show_splash_screen.py @@ -0,0 +1,47 @@ +from contextlib import contextmanager +import tkinter as tk +from PIL import Image, ImageTk +import os + + +@contextmanager +def hold_tk_show_splash_screen(image_path: str): + """Wrap this around your imports in your main function.""" + # Ensure the image path is absolute + image_path = os.path.abspath(image_path) + + # Set up splash screen + root = tk.Tk() + root.overrideredirect(True) # Remove window decorations + + # Get screen width and height + screen_width = root.winfo_screenwidth() + screen_height = root.winfo_screenheight() + + # Load the image + image = Image.open(image_path) + photo = ImageTk.PhotoImage(image) + + # Calculate position for splash screen (centered) + window_width = photo.width() + window_height = photo.height() + x = (screen_width - window_width) // 2 + y = (screen_height - window_height) // 2 + root.geometry(f'{window_width}x{window_height}+{x}+{y}') + + # Create a label to display the image + label = tk.Label(root, image=photo) + label.pack() + + # Make sure window is topmost + root.lift() + root.attributes('-topmost', True) + + # Display the window + root.update() + + try: + yield + finally: + # Close splash screen + root.destroy() From eb9fb0c6e0b1deb1d423a540b78631fdbb7d473a Mon Sep 17 00:00:00 2001 From: peter Date: Wed, 6 Mar 2024 15:07:54 -0800 Subject: [PATCH 104/107] more --- .../plotting/tk_utils/test_tabbed_frame.py | 2 +- artemis/plotting/tk_utils/test_tk_utils.py | 3 +- .../tk_utils/test_ui_choose_parameters.py | 2 +- artemis/plotting/tk_utils/test_ui_utils.py | 2 +- artemis/plotting/tk_utils/tk_basic_utils.py | 27 ++++++++ .../tk_utils/tk_show_splash_screen.py | 44 +++++++------ artemis/plotting/tk_utils/tk_utils.py | 65 ++++++++++--------- artemis/plotting/tk_utils/tkshow.py | 2 +- artemis/plotting/tk_utils/ui_utils.py | 2 - 9 files changed, 92 insertions(+), 57 deletions(-) create mode 100644 artemis/plotting/tk_utils/tk_basic_utils.py diff --git a/artemis/plotting/tk_utils/test_tabbed_frame.py b/artemis/plotting/tk_utils/test_tabbed_frame.py index daf31657..0f706b77 100644 --- a/artemis/plotting/tk_utils/test_tabbed_frame.py +++ b/artemis/plotting/tk_utils/test_tabbed_frame.py @@ -1,7 +1,7 @@ from enum import Enum from artemis.plotting.tk_utils.tabbed_frame import TabbedFrame -from video_scanner.ui.tk_utils import hold_tkinter_root_context +from artemis.plotting.tk_utils.tk_basic_utils import hold_tkinter_root_context import tkinter as tk def test_exitable_side_frame(): diff --git a/artemis/plotting/tk_utils/test_tk_utils.py b/artemis/plotting/tk_utils/test_tk_utils.py index 5cf1c9a9..bcfe8699 100644 --- a/artemis/plotting/tk_utils/test_tk_utils.py +++ b/artemis/plotting/tk_utils/test_tk_utils.py @@ -2,7 +2,8 @@ from tkinter import colorchooser from tkinter.colorchooser import askcolor -from artemis.plotting.tk_utils.tk_utils import BlockingTaskDialogFunction, hold_tkinter_root_context +from artemis.plotting.tk_utils.tk_utils import BlockingTaskDialogFunction +from artemis.plotting.tk_utils.tk_basic_utils import hold_tkinter_root_context import tkinter as tk diff --git a/artemis/plotting/tk_utils/test_ui_choose_parameters.py b/artemis/plotting/tk_utils/test_ui_choose_parameters.py index 0093fd47..99e32ed1 100644 --- a/artemis/plotting/tk_utils/test_ui_choose_parameters.py +++ b/artemis/plotting/tk_utils/test_ui_choose_parameters.py @@ -3,7 +3,7 @@ import pytest -from artemis.plotting.tk_utils.tk_utils import hold_tkinter_root_context +from artemis.plotting.tk_utils.tk_basic_utils import hold_tkinter_root_context from artemis.plotting.tk_utils.ui_choose_parameters import ui_choose_parameters, ParameterSelectionFrame import tkinter as tk diff --git a/artemis/plotting/tk_utils/test_ui_utils.py b/artemis/plotting/tk_utils/test_ui_utils.py index b5d28cee..ee06657c 100644 --- a/artemis/plotting/tk_utils/test_ui_utils.py +++ b/artemis/plotting/tk_utils/test_ui_utils.py @@ -1,6 +1,6 @@ from enum import Enum -from artemis.plotting.tk_utils.tk_utils import hold_tkinter_root_context +from artemis.plotting.tk_utils.tk_basic_utils import hold_tkinter_root_context from video_scanner.app_utils.constants import ThemeColours diff --git a/artemis/plotting/tk_utils/tk_basic_utils.py b/artemis/plotting/tk_utils/tk_basic_utils.py new file mode 100644 index 00000000..a881ec1b --- /dev/null +++ b/artemis/plotting/tk_utils/tk_basic_utils.py @@ -0,0 +1,27 @@ +import tkinter as tk +from contextlib import contextmanager +from typing import Optional + +_EXISTING_ROOT: Optional[tk.Tk] = None + + +@contextmanager +def hold_tkinter_root_context(): + """ A context manager that creates a Tk root and destroys it when the context is exited + Careful now: If you schedule something under root to run with widget.after, it may crash if the root is destroyed before it runs. + """ + # assert_no_existing_root() + global _EXISTING_ROOT + old_value = _EXISTING_ROOT + root = tk.Tk() if _EXISTING_ROOT is None else _EXISTING_ROOT + + try: + _EXISTING_ROOT = root + yield root + finally: + try: + if old_value is None: + _EXISTING_ROOT = None + root.destroy() + except tk.TclError: # This can happen if the root is destroyed before the context is exited + pass diff --git a/artemis/plotting/tk_utils/tk_show_splash_screen.py b/artemis/plotting/tk_utils/tk_show_splash_screen.py index ae8fae12..6f16df9e 100644 --- a/artemis/plotting/tk_utils/tk_show_splash_screen.py +++ b/artemis/plotting/tk_utils/tk_show_splash_screen.py @@ -1,47 +1,53 @@ from contextlib import contextmanager import tkinter as tk +from typing import Optional + from PIL import Image, ImageTk import os +from artemis.plotting.tk_utils.tk_basic_utils import hold_tkinter_root_context + @contextmanager -def hold_tk_show_splash_screen(image_path: str): - """Wrap this around your imports in your main function.""" +def hold_tk_show_splash_screen(image_path: str, root): + # Ensure the image path is absolute image_path = os.path.abspath(image_path) - # Set up splash screen - root = tk.Tk() - root.overrideredirect(True) # Remove window decorations + # Initially, hide the main root window + root.withdraw() - # Get screen width and height - screen_width = root.winfo_screenwidth() - screen_height = root.winfo_screenheight() + # Create a Toplevel window for the splash screen + splash = tk.Toplevel(root) + splash.overrideredirect(True) # Remove window decorations # Load the image image = Image.open(image_path) photo = ImageTk.PhotoImage(image) # Calculate position for splash screen (centered) + screen_width = root.winfo_screenwidth() + screen_height = root.winfo_screenheight() window_width = photo.width() window_height = photo.height() x = (screen_width - window_width) // 2 y = (screen_height - window_height) // 2 - root.geometry(f'{window_width}x{window_height}+{x}+{y}') + splash.geometry(f'{window_width}x{window_height}+{x}+{y}') - # Create a label to display the image - label = tk.Label(root, image=photo) + # Create a label to display the image and pack it in the splash window. + label = tk.Label(splash, image=photo) label.pack() + # Make sure splash window is topmost + splash.lift() + splash.attributes('-topmost', True) - # Make sure window is topmost - root.lift() - root.attributes('-topmost', True) - - # Display the window - root.update() + # Display the splash window and ensure it updates + splash.update() try: yield finally: - # Close splash screen - root.destroy() + # Close the splash screen and clean up + splash.destroy() + # Once the splash is destroyed, reveal the main window + root.deiconify() diff --git a/artemis/plotting/tk_utils/tk_utils.py b/artemis/plotting/tk_utils/tk_utils.py index 1acf4e1d..e70c821d 100644 --- a/artemis/plotting/tk_utils/tk_utils.py +++ b/artemis/plotting/tk_utils/tk_utils.py @@ -6,12 +6,12 @@ from dataclasses import dataclass, field from functools import partial from tkinter import Widget -from typing import Iterable, Optional, Tuple, Mapping, TypeVar +from typing import Iterable, TypeVar from typing import Mapping, Callable, Union, Any, Optional, Sequence, Tuple, List from artemis.general.measuring_periods import PeriodicChecker from artemis.plotting.tk_utils.tk_error_dialog import ErrorDetail -from artemis.plotting.tk_utils.ui_utils import RespectableButton +from artemis.plotting.tk_utils.ui_utils import RespectableButton, toplevel_centered from artemis.plotting.tk_utils.constants import UIColours, ThemeColours import platform import subprocess @@ -118,6 +118,26 @@ def wrapped_callback(event: tk.Event) -> Any: return wrapped_callback +def wrap_ui_command_with_handler( + command: Callable[[], Any], + error_handler: Callable[[ErrorDetail], None], + info_string: Optional[str] = None, + reraise: bool = True +) -> Callable[[], Any]: + def wrapped_command() -> Any: + try: + command() + except Exception as err: + traceback_str = traceback.format_exc() + details = ErrorDetail(error=err, traceback=traceback_str, additional_info=info_string) + error_handler(details) + if reraise: + raise err + + return wrapped_command + + + def recursively_bind(widget: Widget, event: str, handler: Callable[[tk.Event], Any]) -> None: """ Recursively bind an event to a widget and all its descendants. @@ -248,6 +268,7 @@ def ui_select_option(self, n_button_cols: int = 3, call_callback_on_selection: bool = False, font: Optional[Tuple[str, int]] = None, # e.g. ('Helvetica', 24) + parent: Optional[tk.Toplevel] = None ) -> Optional[OptionInfo]: if len(self._options)==0: raise Exception("No options have been provided to select from. Call add_option first.") @@ -264,7 +285,7 @@ def buttonfn(op: OptionInfo): # choicewin.quit() choicewin.destroy() - choicewin = tk.Toplevel() + choicewin = tk.Toplevel() if parent is None else toplevel_centered(parent) # Keep this window on top choicewin.minsize(*min_size_xy) @@ -367,7 +388,7 @@ def change_selected_button(event: tk.Event): else: # Get it to stay on top! choicewin.attributes('-topmost', True) - + choicewin.wait_window() # With this line commented in... CRASH (exit code 0, no message) # choicewin.mainloop() # With this line... NO CRASH! # choicewin.grab_release() @@ -388,6 +409,7 @@ def tk_select_option(choicelist: Sequence[str], min_size_xy: Tuple[int, int] = (400, 300), wrap_text_to_window_size: bool = True, n_button_cols: int = 3, + parent: Optional[tk.Toplevel] = None ) -> Optional[str]: option_builder = TkOptionListBuilder() @@ -398,7 +420,7 @@ def tk_select_option(choicelist: Sequence[str], selected = option_builder.ui_select_option( message=message, title=title, add_cancel_button=add_cancel_button, min_size_xy=min_size_xy, - wrap_text_to_window_size=wrap_text_to_window_size, n_button_cols=n_button_cols + wrap_text_to_window_size=wrap_text_to_window_size, n_button_cols=n_button_cols, parent=parent ) return selected.text if selected is not None else None @@ -521,13 +543,15 @@ class BlockingTaskDialogFunction: n_steps: Optional[int] = None max_update_period: float = 0.2 message: str = "Processing... please wait." + display_iterator_output: bool = False + parent: Optional[tk.Toplevel] = None def show_blocking_task_dialog( self, blocking_task_iterator: Iterable[ItemType], ) -> Optional[Sequence[ItemType]]: - window = tk.Toplevel() + window = toplevel_centered(self.parent) # window.geometry('400x250') # Make that a minimum size for each dimention window.minsize(400, 250) @@ -547,7 +571,10 @@ def cancel(): items: Optional[List[ItemType]] = [] for i, item in enumerate(blocking_task_iterator, start=1): if checker.is_time_for_update(): - progress_label.config(text=f'{i} / {self.n_steps or "?"}' + (f" ({i / self.n_steps:.0%})" if self.n_steps else '')) + if self.display_iterator_output: + progress_label.config(text=str(item)) + else: + progress_label.config(text=f'{i} / {self.n_steps or "?"}' + (f" ({i / self.n_steps:.0%})" if self.n_steps else '')) window.update() if items is None: break @@ -631,30 +658,6 @@ def assert_no_existing_root(): assert tk._default_root is None, "A Tkinter root window already exists!" -_EXISTING_ROOT: Optional[tk.Tk] = None - - -@contextmanager -def hold_tkinter_root_context(): - """ A context manager that creates a Tk root and destroys it when the context is exited - Careful now: If you schedule something under root to run with widget.after, it may crash if the root is destroyed before it runs. - """ - # assert_no_existing_root() - global _EXISTING_ROOT - old_value = _EXISTING_ROOT - root = tk.Tk() if _EXISTING_ROOT is None else _EXISTING_ROOT - - try: - _EXISTING_ROOT = root - yield root - finally: - try: - if old_value is None: - _EXISTING_ROOT = None - root.destroy() - except tk.TclError: # This can happen if the root is destroyed before the context is exited - pass - # # def get_widget_overlay_frame( # widget: tk.Widget, diff --git a/artemis/plotting/tk_utils/tkshow.py b/artemis/plotting/tk_utils/tkshow.py index 25f9c6e5..f2dcb53b 100644 --- a/artemis/plotting/tk_utils/tkshow.py +++ b/artemis/plotting/tk_utils/tkshow.py @@ -5,7 +5,7 @@ from artemis.general.custom_types import BGRImageArray from artemis.plotting.tk_utils.alternate_zoomable_image_view import ZoomableImageFrame -from artemis.plotting.tk_utils.tk_utils import hold_tkinter_root_context +from artemis.plotting.tk_utils.tk_basic_utils import hold_tkinter_root_context from artemis.plotting.tk_utils.ui_utils import RespectableButton diff --git a/artemis/plotting/tk_utils/ui_utils.py b/artemis/plotting/tk_utils/ui_utils.py index f160437e..e1e9d556 100644 --- a/artemis/plotting/tk_utils/ui_utils.py +++ b/artemis/plotting/tk_utils/ui_utils.py @@ -603,8 +603,6 @@ def hold_toplevel_centered(parent: Optional[tk.Widget], *args, **kwargs): parent.state('normal') - - def toplevel_centered(parent: Optional[tk.Widget] = None, *args, **kwargs): """ Create a toplevel window, and center it on the parent widget after its contents are filled in""" top = tk.Toplevel(master=parent, *args, **kwargs) From c379c4999dce39adbc05288e79e8ebb995cc0869 Mon Sep 17 00:00:00 2001 From: peter Date: Fri, 8 Mar 2024 01:53:27 -0800 Subject: [PATCH 105/107] changes --- artemis/plotting/tk_utils/tabbed_frame.py | 4 +- .../tk_utils/test_ui_choose_parameters.py | 1 - artemis/plotting/tk_utils/tk_utils.py | 18 ++ .../plotting/tk_utils/ui_choose_parameters.py | 183 ++++++++++++------ artemis/plotting/tk_utils/ui_utils.py | 4 +- 5 files changed, 144 insertions(+), 66 deletions(-) diff --git a/artemis/plotting/tk_utils/tabbed_frame.py b/artemis/plotting/tk_utils/tabbed_frame.py index eac073e7..602b1a80 100644 --- a/artemis/plotting/tk_utils/tabbed_frame.py +++ b/artemis/plotting/tk_utils/tabbed_frame.py @@ -107,12 +107,12 @@ def set_active_tab(self, state: MultiStateEnumType, skip_callback: bool = False) if do_change: for tc in self._tab_controls: - tc.set_state(state) + tc.set_state(state, skip_callback=True) index_to_keep = list(self._tab_enum).index(state) for i, f in enumerate(self._frames): # Just to be safe forget all frames f.grid_forget() self._frames[index_to_keep].grid(row=1, column=0, sticky=tk.NSEW) - if self._on_state_change_callback is not None and state and not skip_callback: # Avoid recursion + if self._on_state_change_callback is not None and not skip_callback: # Avoid recursion self._on_state_change_callback(state) diff --git a/artemis/plotting/tk_utils/test_ui_choose_parameters.py b/artemis/plotting/tk_utils/test_ui_choose_parameters.py index 99e32ed1..b580219a 100644 --- a/artemis/plotting/tk_utils/test_ui_choose_parameters.py +++ b/artemis/plotting/tk_utils/test_ui_choose_parameters.py @@ -100,7 +100,6 @@ class Author: print(new_params) - if __name__ == "__main__": # test_ui_choose_parameters() test_edit_params() diff --git a/artemis/plotting/tk_utils/tk_utils.py b/artemis/plotting/tk_utils/tk_utils.py index e70c821d..a78a2d63 100644 --- a/artemis/plotting/tk_utils/tk_utils.py +++ b/artemis/plotting/tk_utils/tk_utils.py @@ -118,6 +118,24 @@ def wrapped_callback(event: tk.Event) -> Any: return wrapped_callback +@contextmanager +def suppress_widget_updates(widget): + """ + Temporarily suppress widget updates in a Tkinter application. + + :param widget: The root widget whose updates are to be suppressed. + """ + try: + # Command to disable updates, e.g., by unbinding events or stopping redraws + widget.winfo_toplevel().update_idletasks() # Process all pending idle tasks without updating the display + widget.pack_forget() # Hide the widget or use grid_forget() as appropriate + yield + finally: + # Re-enable updates or redraw the widget as necessary + widget.pack() # Or use grid() to put the widget back, as was originally done + widget.winfo_toplevel().update() # Force the display to update + + def wrap_ui_command_with_handler( command: Callable[[], Any], error_handler: Callable[[ErrorDetail], None], diff --git a/artemis/plotting/tk_utils/ui_choose_parameters.py b/artemis/plotting/tk_utils/ui_choose_parameters.py index 8410f0a4..22b2878f 100644 --- a/artemis/plotting/tk_utils/ui_choose_parameters.py +++ b/artemis/plotting/tk_utils/ui_choose_parameters.py @@ -2,6 +2,7 @@ import tkinter as tk from abc import ABCMeta from abc import abstractmethod +from contextlib import contextmanager from dataclasses import dataclass, field, fields, replace from datetime import datetime, timedelta from tkinter import ttk, filedialog @@ -10,8 +11,9 @@ from chronyk import Chronyk, ChronykDelta from more_itertools.more import first +from artemis.plotting.tk_utils.tk_utils import suppress_widget_updates # Assuming the required modules from artemis.plotting.tk_utils are available -from artemis.plotting.tk_utils.ui_utils import ButtonPanel, RespectableLabel +from artemis.plotting.tk_utils.ui_utils import ButtonPanel, RespectableLabel, toplevel_centered, hold_toplevel_centered ParametersType = TypeVar('ParametersType') @@ -40,29 +42,34 @@ def get_default_for_param_type(param_type: type) -> Any: class MockVariable(tk.Variable): - def __init__(self, parent: tk.Widget, initial_value: Any = None): super().__init__(parent) self.value = initial_value - self._write_callback: Optional[Callable[[Any], None]] = None + self._callbacks = {} # Use a dictionary to store callbacks def get(self): return self.value def set(self, value): self.value = value - if self._write_callback is not None: - self._write_callback(value) + self.trigger_write_callback() def trigger_write_callback(self): - if self._write_callback is not None: - self._write_callback(self.value) + if 'write' in self._callbacks: + for callback in self._callbacks['write'].values(): + callback(self.value) - def trace_add(self, mode: str, callback: Callable[[Any, Any, Any], None]): - if mode == "write": - self._write_callback = callback - else: + def trace_add(self, mode: str, callback: Callable): + if mode != "write": raise NotImplementedError(f"Mode {mode} not supported.") + # Generate a unique identifier for the callback + callback_id = f"callback_{len(self._callbacks.get(mode, {}))}" + self._callbacks.setdefault(mode, {})[callback_id] = callback + return callback_id + + def trace_remove(self, mode: str, callback_id: str): + if mode in self._callbacks and callback_id in self._callbacks[mode]: + del self._callbacks[mode][callback_id] class IParameterSelectionFrame(tk.Frame, Generic[ParametersType], metaclass=ABCMeta): @@ -84,7 +91,8 @@ def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder', parser: Opt self.var = tk.DoubleVar(master=self, value=self._builder.initial_value) if self._builder.param_type == float \ else tk.IntVar(master=self, value=self._builder.initial_value) if self._builder.param_type == int \ else tk.StringVar(master=self, value=self._builder.initial_value) - entry = tk.Entry(self, textvariable=self.var) + estimated_width = len(str(self._builder.initial_value)) if self._builder.initial_value is not None else 10 + entry = tk.Entry(self, textvariable=self.var, state=tk.NORMAL if builder.editable_fields else tk.DISABLED, width=max(estimated_width, 80)) entry.grid(column=0, row=0, sticky="ew") def get_filled_parameters(self) -> ParametersType: @@ -407,6 +415,8 @@ class DataclassParameterSelectionFrame(IParameterSelectionFrame[ParametersType]) def __init__(self, master: tk.Widget, builder: 'ParameterUIBuilder'): super().__init__(master, **builder.general_kwargs) self._builder = builder + + flds = fields(self._builder.param_type) flds = fields(self._builder.param_type) # self.params_type = self._param_builder.param_type self.columnconfigure(0, weight=0) @@ -527,7 +537,7 @@ class ParameterUIBuilder: """ A class that builds a UI for a parameter. """ # parent: Optional[tk.Widget] # The parent widget initial_value: Any # The initial value to display. If None, param_type must be provided. - param_type: Optional[type] = None # The type of the parameter. If None, initial_value must be provided. + param_type: type # The type of the parameter. If None, initial_value must be provided. param_metadata: Optional[Dict[str, Any]] = None # Metadata about the parameter path: str = '' # The path to the parameter, e.g. "a.b.c" means the "c" field of the "b" field of the "a" field. editable_fields: Union[bool, Sequence[str]] = True # Either @@ -747,31 +757,71 @@ def __init__(self, self._param_frame: Optional[IParameterSelectionFrame] = None if builder is not None: self.set_parameters(builder) + self._traces = {} + self._disable_setting_parameters = False # self._on_change_callback = on_change_callback def reset_frame(self): if self._param_frame is not None: - self._param_frame.pack_forget() + # print(f"Children before: {self._param_frame.winfo_children()}") + # Delete all children + # for child in self._param_frame.winfo_children(): + # child.destroy() + # print(f"Children after: {self._param_frame.winfo_children()}") + + # Remove all traces + # print(f"Removing traces {list(zip(self._param_frame.get_variables().values(), self._traces))}") + # for path, var in self._param_frame.get_variables().items(): + # var.trace_remove("write", self._traces[path]) # Gives Error: wrong # args: should be "trace remove variable name opList command" + + + # self._param_frame.pack_forget() self._param_frame.destroy() self._param_frame = None + # self.update() + + @contextmanager + def _hold_prevent_recurse(self): + """ + Prevents the recursion that caused this frame to duplicate. + Problem was calls to (child_widger).update() triggered TreeView Select events outside of this frame, + which in turn called set_parameters on this frame - so set_parameters was opened multiple times before + the first one was done - leading to multiple frames being created. + """ + self._disable_setting_parameters = True + try: + yield + finally: + self._disable_setting_parameters = False def set_parameters(self, builder: ParameterUIBuilder): + if self._disable_setting_parameters: + return - if self._param_frame is not None: - self._param_frame.destroy() - self._param_frame = builder.build_parameter_frame(self) - self._param_frame.pack(fill=tk.BOTH, expand=True) - if builder.on_change_callback is not None: - # TODO: Handle cases where number of variables changes - # Add a trace to all variables - for path, var in self._param_frame.get_variables().items(): - var.trace_add("write", lambda *args, path=path, var=var: builder.on_change_callback(path, var)) - - # Now, set the focus to the first editable field - first_editable_field: Optional[tk.Widget] = first((child for child in self._param_frame.winfo_children() if child.winfo_class() == "Entry"), default=None) - if first_editable_field is not None: - first_editable_field.focus_set() - self.update() # Needed to ensure things actually display off the start + with self._hold_prevent_recurse(): + # if self._param_frame is not None: + self.reset_frame() + # self._param_frame.pack_forget() + # self._param_frame.destroy() + + self._param_frame = builder.build_parameter_frame(self) + self._param_frame.pack_forget() + + if builder.on_change_callback is not None: + # TODO: Handle cases where number of variables changes + # Add a trace to all variables + # print(f"Adding traces {list(self._param_frame.get_variables().values())}") + for path, var in self._param_frame.get_variables().items(): + var.trace_add("write", lambda *args, path=path, var=var: builder.on_change_callback(path, var)) + # trace_name = var.trace_add("write", lambda *args, path=path, var=var: builder.on_change_callback(path, var)) + # self._traces[path] = trace_name + + # Now, set the focus to the first editable field + first_editable_field: Optional[tk.Widget] = first((child for child in self._param_frame.winfo_children() if child.winfo_class() == "Entry"), default=None) + if first_editable_field is not None: + first_editable_field.focus_set() + self._param_frame.pack(fill=tk.BOTH, expand=True) + self.update() # Needed to ensure things actually display off the start def get_filled_parameters(self) -> Optional[ParametersType]: return self._param_frame.get_filled_parameters() if self._param_frame is not None else None @@ -980,6 +1030,7 @@ def ui_choose_parameters( # params_type: Optional[type] = None, # initial_params: Optional[ParametersType] = None, # factory_reset_params: Optional[ParametersType] = None, + parent: Optional[tk.Widget] = None, timeout: Optional[float] = None, title: str = "Select Parameters", # editable_fields: Union[bool, Sequence[str]] = True, @@ -1003,41 +1054,51 @@ def ui_choose_parameters( # # custom_constructors=extra_widget_builders, # ) - window = tk.Toplevel() - window.title(title) - ps_frame = builder.build_parameter_frame(window) - # ps_frame = build_parameter_frame(window, params_type, initial_params, editable_fields=editable_fields) - ps_frame.pack() - bottom_panel = ButtonPanel(window) - final_params = None + with hold_toplevel_centered(parent) as window: + + # Minimum size + # window.minsize(600, 300) + window.title(title) + ps_frame = builder.build_parameter_frame(window) + # ps_frame = build_parameter_frame(window, params_type, initial_params, editable_fields=editable_fields) + ps_frame.pack(fill=tk.BOTH, expand=True) + bottom_panel = ButtonPanel(window) - def on_cancel(): - nonlocal final_params final_params = None - window.destroy() - - def on_ok(): - nonlocal final_params - final_params = ps_frame.get_filled_parameters() - window.destroy() - - def on_reset(): - nonlocal ps_frame - ps_frame.pack_forget() - # ps_frame = build_parameter_frame(window, params_type, factory_reset_params, editable_fields=editable_fields) - ps_frame = builder.build_parameter_frame() - ps_frame.pack() - - bottom_panel.pack(side=tk.BOTTOM, fill=tk.X) - if builder.editable_fields: - bottom_panel.add_button("Cancel", on_cancel, shortcut="") - bottom_panel.add_button("Reset", on_reset, shortcut="") - bottom_panel.add_button("OK", on_ok, shortcut="") - - if timeout is not None: - window.after(int(timeout * 1000), on_ok) - # root.mainloop() + + def on_cancel(): + nonlocal final_params + final_params = None + window.destroy() + + def on_ok(): + nonlocal final_params + final_params = ps_frame.get_filled_parameters() + window.destroy() + + def on_reset(): + nonlocal ps_frame + ps_frame.pack_forget() + # ps_frame = build_parameter_frame(window, params_type, factory_reset_params, editable_fields=editable_fields) + ps_frame = builder.build_parameter_frame() + ps_frame.pack() + + bottom_panel.pack(side=tk.BOTTOM, fill=tk.X) + if builder.editable_fields: + bottom_panel.add_button("Cancel", on_cancel, shortcut="") + bottom_panel.add_button("Reset", on_reset, shortcut="") + bottom_panel.add_button("OK", on_ok, shortcut="") + + if timeout is not None: + window.after(int(timeout * 1000), on_ok) + # root.mainloop() + + # Trigger a Tab event in 100ms to focus the first field + # Focus on the window + window.grab_set() + window.after(100, lambda: window.event_generate("")) + window.wait_window() return final_params diff --git a/artemis/plotting/tk_utils/ui_utils.py b/artemis/plotting/tk_utils/ui_utils.py index e1e9d556..56304cf5 100644 --- a/artemis/plotting/tk_utils/ui_utils.py +++ b/artemis/plotting/tk_utils/ui_utils.py @@ -556,7 +556,7 @@ def __init__(self, self.set_state(initial_state) self._on_state_change_callback = on_state_change_callback - def set_state(self, state: MultiStateEnumType): + def set_state(self, state: MultiStateEnumType, skip_callback: bool = False): old_state = self._active_state state_index = list(type(state)).index(state) for i, button in enumerate(self._buttons): @@ -565,7 +565,7 @@ def set_state(self, state: MultiStateEnumType): else: button.config(relief=tk.RAISED, **self._off_button_config) self._active_state = state - if self._on_state_change_callback is not None and old_state != state: # Avoid recursion + if self._on_state_change_callback is not None and old_state != state and not skip_callback: # Avoid recursion self._on_state_change_callback(state) def get_state(self) -> MultiStateEnumType: From 5e3f33ad816e7c6b0bda9a66815c3223e2e55623 Mon Sep 17 00:00:00 2001 From: jnmaas Date: Fri, 8 Mar 2024 14:35:34 -0800 Subject: [PATCH 106/107] added frame focus to fix black frame after switching back to main --- artemis/plotting/tk_utils/tabbed_frame.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/artemis/plotting/tk_utils/tabbed_frame.py b/artemis/plotting/tk_utils/tabbed_frame.py index 602b1a80..65bc8c83 100644 --- a/artemis/plotting/tk_utils/tabbed_frame.py +++ b/artemis/plotting/tk_utils/tabbed_frame.py @@ -151,4 +151,4 @@ def get_active_tab(self) -> MultiStateEnumType: # if isinstance(name_or_index, str): # return self.nametowidget(self.select()) # else: -# return self.nametowidget(self.select()) \ No newline at end of file +# return self.nametowidget(self.select()) From 3131585b2c929b0de9a4cd018fe3792310aadac4 Mon Sep 17 00:00:00 2001 From: jnmaas Date: Fri, 8 Mar 2024 14:49:41 -0800 Subject: [PATCH 107/107] added frame focus to fix black frame after switching back to main --- artemis/plotting/tk_utils/tabbed_frame.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/artemis/plotting/tk_utils/tabbed_frame.py b/artemis/plotting/tk_utils/tabbed_frame.py index 65bc8c83..153190fe 100644 --- a/artemis/plotting/tk_utils/tabbed_frame.py +++ b/artemis/plotting/tk_utils/tabbed_frame.py @@ -90,10 +90,6 @@ def create_tab_switch_control(self, parent: tk.Frame) -> MultiStateToggle: return tab_control def set_active_tab(self, state: MultiStateEnumType, skip_callback: bool = False) -> None: - - # if skip_if_unchanged and state == self._main_tab_control.get_state(): - # print(f"Skipping tab change to {state} because it's already in state {self._main_tab_control.get_state()}.") - # return # Already in this state do_change = True if self._pre_state_change_callback is not None: do_change = self._pre_state_change_callback(state) @@ -106,19 +102,22 @@ def set_active_tab(self, state: MultiStateEnumType, skip_callback: bool = False) self._main_tab_control.grid(row=0, column=0, sticky=tk.EW) if do_change: + # Correctly define index_to_keep based on the state + index_to_keep = list(self._tab_enum).index(state) # Ensure this line is before its usage + for tc in self._tab_controls: tc.set_state(state, skip_callback=True) - index_to_keep = list(self._tab_enum).index(state) - for i, f in enumerate(self._frames): # Just to be safe forget all frames + for i, f in enumerate(self._frames): f.grid_forget() self._frames[index_to_keep].grid(row=1, column=0, sticky=tk.NSEW) - if self._on_state_change_callback is not None and not skip_callback: # Avoid recursion + self._frames[index_to_keep].focus_set() + + if self._on_state_change_callback is not None and not skip_callback: self._on_state_change_callback(state) + # Optionally, update the UI + self.update_idletasks() - # Redraw the window - self.update() - # self.winfo_toplevel().update() def get_active_tab(self) -> MultiStateEnumType: return self._main_tab_control.get_state() @@ -151,4 +150,4 @@ def get_active_tab(self) -> MultiStateEnumType: # if isinstance(name_or_index, str): # return self.nametowidget(self.select()) # else: -# return self.nametowidget(self.select()) +# return self.nametowidget(self.select()) \ No newline at end of file