From fef59966cd8bdb5578165d45007a03ab19d58f6c Mon Sep 17 00:00:00 2001 From: xdssio Date: Fri, 19 Aug 2022 17:54:14 +0200 Subject: [PATCH 1/6] basic implementation --- packages/vaex-core/vaex/dataframe.py | 21 +- packages/vaex-core/vaex/expression.py | 170 +++++++++---- packages/vaex-core/vaex/expresso.py | 6 + packages/vaex-core/vaex/formatting.py | 26 +- packages/vaex-core/vaex/registry.py | 3 +- packages/vaex-core/vaex/vision.py | 334 ++++++++++++++++++++++++++ tests/ml/vision_test.py | 12 + 7 files changed, 520 insertions(+), 52 deletions(-) create mode 100644 packages/vaex-core/vaex/vision.py create mode 100644 tests/ml/vision_test.py diff --git a/packages/vaex-core/vaex/dataframe.py b/packages/vaex-core/vaex/dataframe.py index 1a05a8aaa7..7fe851c2f4 100644 --- a/packages/vaex-core/vaex/dataframe.py +++ b/packages/vaex-core/vaex/dataframe.py @@ -332,6 +332,16 @@ def is_datetime(self, expression): def is_string(self, expression): return vaex.array_types.is_string_type(self.data_type(expression)) + def is_image(self, expression): + try: + import PIL + except ModuleNotFoundError: + raise RuntimeError("Please install pillow for image support") + if self.data_type(expression) != object: + return False + value = self.dropna(column_names=[expression]).head(1)[expression].values[0] + return hasattr(value, '_repr_png_') + def is_category(self, column): """Returns true if column is a category.""" column = _ensure_string_from_expression(column) @@ -3988,7 +3998,7 @@ def table_part(k1, k2, parts): if columns_sliced is not None and j >= columns_sliced: column_index += 1 # skip over the slice/ellipsis value = values[name][i] - value = _format_value(value) + value = _format_value(value, value_format=format) values_list[column_index+1][1].append(value) # parts += [""] # return values_list @@ -4011,7 +4021,10 @@ def table_part(k1, k2, parts): values_list = dict(values_list) # print(values_list) import tabulate - table_text = str(tabulate.tabulate(values_list, headers="keys", tablefmt=format)) + tablefmt = format + if tablefmt == "html": + tablefmt = "unsafehtml" + table_text = str(tabulate.tabulate(values_list, headers="keys", tablefmt=tablefmt)) # Tabulate 0.8.7+ escapes html :() table_text = table_text.replace('<i style='opacity: 0.6'>', "") table_text = table_text.replace('</i>', "") @@ -4052,7 +4065,7 @@ def table_part(k1, k2, parts): parts += ["{:,}".format(i + k1)] for name in column_names: value = data_parts[name][i] - value = _format_value(value) + value = _format_value(value, value_format=format) parts += ["%r" % value] parts += [""] return parts @@ -4084,7 +4097,7 @@ def _output_css(self): def _repr_mimebundle_(self, include=None, exclude=None, **kwargs): # TODO: optimize, since we use the same data in both versions # TODO: include latex version - return {'text/html':self._head_and_tail_table(format='html'), 'text/plain': self._head_and_tail_table(format='plain')} + return {'html': self._head_and_tail_table(format='html'), 'text/plain': self._head_and_tail_table(format='plain')} def _repr_html_(self): """Representation for Jupyter.""" diff --git a/packages/vaex-core/vaex/expression.py b/packages/vaex-core/vaex/expression.py index a970d89236..173951f194 100644 --- a/packages/vaex-core/vaex/expression.py +++ b/packages/vaex-core/vaex/expression.py @@ -24,7 +24,6 @@ import vaex.serialize from . import expresso - try: from StringIO import StringIO except ImportError: @@ -35,7 +34,6 @@ except AttributeError: collectionsAbc = collections - # TODO: repeated from dataframe.py default_shape = 128 PRINT_MAX_COUNT = 10 @@ -43,11 +41,9 @@ expression_namespace = {} expression_namespace['nan'] = np.nan - expression_namespace = {} expression_namespace['nan'] = np.nan - _binary_ops = [ dict(code="+", name='add', op=operator.add), dict(code="in", name='contains', op=operator.contains), @@ -100,7 +96,8 @@ def f(a, b): # print(op, a, b) if isinstance(b, str) and self.dtype.is_datetime: b = np.datetime64(b) - if self.df.is_category(self.expression) and self.df._future_behaviour and not isinstance(b, Expression): + if self.df.is_category(self.expression) and self.df._future_behaviour and not isinstance(b, + Expression): labels = self.df.category_labels(self.expression) if b not in labels: raise ValueError(f'Value {b} not present in {labels}') @@ -137,6 +134,7 @@ def f(a, b): b = f'scalar_datetime("{b}")' expression = '({0} {1} {2})'.format(a.expression, op['code'], b) return Expression(self.ds, expression=expression) + attrs['__%s__' % op['name']] = f if op['name'] in reversable: def f(a, b): @@ -153,6 +151,7 @@ def f(a, b): b = b.expression expression = '({2} {1} {0})'.format(a.expression, op['code'], b) return Expression(self.ds, expression=expression) + attrs['__r%s__' % op['name']] = f wrap(op) @@ -162,7 +161,9 @@ def f(a): self = a expression = '{0}({1})'.format(op['code'], a.expression) return Expression(self.ds, expression=expression) + attrs['__%s__' % op['name']] = f + wrap(op) return type(future_class_name, future_class_parents, attrs) @@ -172,6 +173,7 @@ class DateTime(object): Usually accessed using e.g. `df.birthday.dt.dayofweek` """ + def __init__(self, expression): self.expression = expression @@ -181,6 +183,17 @@ class TimeDelta(object): Usually accessed using e.g. `df.delay.td.days` """ + + def __init__(self, expression): + self.expression = expression + + +class Image(object): + """Image operations + + Operations for images based on PIL/Pillow + """ + def __init__(self, expression): self.expression = expression @@ -190,12 +203,14 @@ class StringOperations(object): Usually accessed using e.g. `df.name.str.lower()` """ + def __init__(self, expression): self.expression = expression class StringOperationsPandas(object): """String operations using Pandas Series (much slower)""" + def __init__(self, expression): self.expression = expression @@ -366,6 +381,7 @@ def _assert_struct_dtype(self): class Expression(with_metaclass(Meta)): """Expression class""" + def __init__(self, ds, expression, ast=None, _selection=False): self.ds = ds assert not isinstance(ds, Expression) @@ -471,7 +487,6 @@ def __bool__(self): return expresso.node_to_string(self.ast.left) != expresso.node_to_string(self.ast.comparators[0]) return True - @property def df(self): # lets gradually move to using .df @@ -513,12 +528,14 @@ def to_dask_array(self, chunks="auto"): dtype = self.dtype chunks = da.core.normalize_chunks(chunks, shape=self.shape, dtype=dtype.numpy) name = 'vaex-expression-%s' % str(uuid.uuid1()) + def getitem(df, item): assert len(item) == 1 item = item[0] start, stop, step = item.start, item.stop, item.step assert step in [None, 1] return self.evaluate(start, stop, parallel=False) + dsk = da.core.getem(name, chunks, getitem=getitem, shape=self.shape, dtype=dtype.numpy) dsk[name] = self return da.Array(dsk, name, chunks, dtype=dtype.numpy) @@ -591,7 +608,7 @@ def __getitem__(self, slicer): indices, fields = slicer else: raise NotImplementedError - + if indices != slice(None): expr = self.df[indices][self.expression] else: @@ -618,6 +635,11 @@ def __abs__(self): """Returns the absolute value of the expression""" return self.abs() + @property + def vision(self): + """Gives access to image operations via :py:class:`Image`""" + return Image(self) + @property def dt(self): """Gives access to datetime operations via :py:class:`DateTime`""" @@ -663,9 +685,11 @@ def expand(self, stop=[]): """ stop = _ensure_strings_from_expressions(stop) + def translate(id): if id in self.ds.virtual_columns and id not in stop: return self.ds.virtual_columns[id] + expr = expresso.translate(self.ast, translate) return Expression(self.ds, expr) @@ -684,6 +708,7 @@ def variables(self, ourself=False, expand_virtual=True, include_virtual=True): {'x', 'y'} """ variables = set() + def record(varname): # always do this for selection if self._selection and self.df.has_selection(varname): @@ -694,7 +719,8 @@ def record(varname): if (include_virtual and (varname != self.expression)) or (varname == self.expression and ourself): variables.add(varname) if expand_virtual: - variables.update(self.df[self.df.virtual_columns[varname]].variables(ourself=include_virtual, include_virtual=include_virtual)) + variables.update(self.df[self.df.virtual_columns[varname]].variables(ourself=include_virtual, + include_virtual=include_virtual)) # we usually don't want to record ourself elif varname != self.expression or ourself: variables.add(varname) @@ -705,11 +731,13 @@ def record(varname): variables -= {'df'} for varname in self._ast_slices: if varname in self.df.virtual_columns and varname not in variables: - if (include_virtual and (f"df['{varname}']" != self.expression)) or (f"df['{varname}']" == self.expression and ourself): + if (include_virtual and (f"df['{varname}']" != self.expression)) or ( + f"df['{varname}']" == self.expression and ourself): variables.add(varname) if expand_virtual: if varname in self.df.virtual_columns: - variables |= self.df[self.df.virtual_columns[varname]].variables(ourself=include_virtual, include_virtual=include_virtual) + variables |= self.df[self.df.virtual_columns[varname]].variables(ourself=include_virtual, + include_virtual=include_virtual) elif f"df['{varname}']" != self.expression or ourself: variables.add(varname) @@ -741,6 +769,7 @@ def walk(node): if isinstance(obj, FunctionSerializablePickle): obj = obj.f return [node_repr, fname, obj, deps] + return walk(expresso._graph(expression)) def _graphviz(self, dot=None): @@ -748,6 +777,7 @@ def _graphviz(self, dot=None): from graphviz import Graph, Digraph node = self._graph() dot = dot or Digraph(comment=self.expression) + def walk(node): if isinstance(node, six.string_types): dot.node(node, node) @@ -760,6 +790,7 @@ def walk(node): dep_id, dep = walk(dep) dot.edge(node_id, dep_id) return node_id, node + walk(node) return dot @@ -786,30 +817,43 @@ def tolist(self, i1=None, i2=None): def __repr__(self): return self._repr_plain_() - def _repr_plain_(self): + def _repr_mimebundle_(self, include=None, exclude=None, **kwargs): + # TODO: optimize, since we use the same data in both versions + # TODO: include latex version + return {"html": self._repr_html_(), 'text/plain': self._repr_plain_()} + + + def _repr_values(self, value_format='plain'): from .formatting import _format_value - def format(values): + + def format_values(values): for i in range(len(values)): value = values[i] - yield _format_value(value) + yield _format_value(value, value_format=value_format) + colalign = ("right",) * 2 try: N = len(self.ds) if N <= PRINT_MAX_COUNT: - values = format(self.evaluate(0, N)) - values = tabulate.tabulate([[i, k] for i, k in enumerate(values)], tablefmt='plain', colalign=colalign) + values = format_values(self.evaluate(0, N)) + values = [[i, k] for i, k in enumerate(values)] + values = tabulate.tabulate(values, tablefmt='plain', colalign=colalign) else: - values_head = format(self.evaluate(0, PRINT_MAX_COUNT//2)) - values_tail = format(self.evaluate(N - PRINT_MAX_COUNT//2, N)) - values_head = list(zip(range(PRINT_MAX_COUNT//2), values_head)) +\ - list(zip(range(N - PRINT_MAX_COUNT//2, N), values_tail)) - values = tabulate.tabulate([k for k in values_head], tablefmt='plain', colalign=colalign) + values_head = format_values(self.evaluate(0, PRINT_MAX_COUNT // 2)) + values_tail = format_values(self.evaluate(N - PRINT_MAX_COUNT // 2, N)) + values = list(zip(range(PRINT_MAX_COUNT // 2), values_head)) + \ + list(zip(range(N - PRINT_MAX_COUNT // 2, N), values_tail)) + values = tabulate.tabulate([k for k in values], tablefmt='plain', colalign=colalign) values = values.split('\n') width = max(map(len, values)) separator = '\n' + '...'.center(width, ' ') + '\n' - values = "\n".join(values[:PRINT_MAX_COUNT//2]) + separator + "\n".join(values[PRINT_MAX_COUNT//2:]) + '\n' + values = "\n".join(values[:PRINT_MAX_COUNT // 2]) + separator + "\n".join( + values[PRINT_MAX_COUNT // 2:]) + '\n' except Exception as e: values = 'Error evaluating: %r' % e + return values + + def _repr_info(self): expression = self.expression if len(expression) > 60: expression = expression[:57] + '...' @@ -823,11 +867,26 @@ def format(values): state = "expression" line = 'Length: {:,} dtype: {} ({})\n'.format(len(self.ds), dtype, state) info += line - info += '-' * (len(line)-1) + '\n' - info += values + info += '-' * (len(line) - 1) + '\n' + return info + + def _repr_html_(self): + info = self._repr_info() + if self.is_image(): + # TODO set up as plain like other expression + info = info.replace("dtype: object", "dtype: image") + info += self.ds[[self.expression]]._repr_html_() + else: + info += self._repr_values(value_format='html') + return f"
{info}
" + + def _repr_plain_(self): + info = self._repr_info() + info += self._repr_values() return info - def count(self, binby=[], limits=None, shape=default_shape, selection=False, delay=False, edges=False, progress=None): + def count(self, binby=[], limits=None, shape=default_shape, selection=False, delay=False, edges=False, + progress=None): '''Shortcut for ds.count(expression, ...), see `Dataset.count`''' kwargs = dict(locals()) del kwargs['self'] @@ -985,6 +1044,7 @@ def value_counts(self, dropna=False, dropnan=False, dropmissing=False, ascending counter_type = counter_type_from_dtype(data_type_item, transient) counters = [None] * self.ds.executor.thread_pool.nthreads + def map(thread_index, i1, i2, selection_masks, blocks): ar = blocks[0] if counters[thread_index] is None: @@ -1001,10 +1061,13 @@ def map(thread_index, i1, i2, selection_masks, blocks): else: counters[thread_index].update(ar) return 0 + def reduce(a, b): - return a+b + return a + b + progressbar = vaex.utils.progressbars(progress, title="value counts") - self.ds.map_reduce(map, reduce, [self.expression], delay=False, progress=progressbar, name='value_counts', info=True, to_numpy=False) + self.ds.map_reduce(map, reduce, [self.expression], delay=False, progress=progressbar, name='value_counts', + info=True, to_numpy=False) counters = [k for k in counters if k is not None] counter = counters[0] for other in counters[1:]: @@ -1072,7 +1135,8 @@ def reduce(a, b): return Series(counts, index=keys) @docsubst - def unique(self, dropna=False, dropnan=False, dropmissing=False, selection=None, axis=None, array_type='list', progress=None, delay=False): + def unique(self, dropna=False, dropnan=False, dropmissing=False, selection=None, axis=None, array_type='list', + progress=None, delay=False): """Returns all unique values. :param dropmissing: do not count missing values @@ -1082,9 +1146,11 @@ def unique(self, dropna=False, dropnan=False, dropmissing=False, selection=None, :param progress: {progress} :param bool array_type: {array_type} """ - return self.ds.unique(self, dropna=dropna, dropnan=dropnan, dropmissing=dropmissing, selection=selection, array_type=array_type, axis=axis, progress=progress, delay=delay) + return self.ds.unique(self, dropna=dropna, dropnan=dropnan, dropmissing=dropmissing, selection=selection, + array_type=array_type, axis=axis, progress=progress, delay=delay) - def nunique(self, dropna=False, dropnan=False, dropmissing=False, selection=None, axis=None, progress=None, delay=False): + def nunique(self, dropna=False, dropnan=False, dropmissing=False, selection=None, axis=None, progress=None, + delay=False): """Counts number of unique values, i.e. `len(df.x.unique()) == df.x.nunique()`. :param dropmissing: do not count missing values @@ -1092,16 +1158,20 @@ def nunique(self, dropna=False, dropnan=False, dropmissing=False, selection=None :param dropna: short for any of the above, (see :func:`Expression.isna`) :param bool axis: Axis over which to determine the unique elements (None will flatten arrays or lists) """ + def key_function(): fp = vaex.cache.fingerprint(self.fingerprint(), dropna, dropnan, dropmissing, selection, axis) return f'nunique-{fp}' + @vaex.cache._memoize(key_function=key_function, delay=delay) def f(): - value = self.unique(dropna=dropna, dropnan=dropnan, dropmissing=dropmissing, selection=selection, axis=axis, array_type=None, progress=progress, delay=delay) + value = self.unique(dropna=dropna, dropnan=dropnan, dropmissing=dropmissing, selection=selection, axis=axis, + array_type=None, progress=progress, delay=delay) if delay: return value.then(len) else: return len(value) + return f() def countna(self): @@ -1162,7 +1232,8 @@ def jit_pythran(self, verbose=False): expression = self.expression if expression in self.ds.virtual_columns: expression = self.ds.virtual_columns[self.expression] - all_vars = self.ds.get_column_names(virtual=True, strings=True, hidden=True) + list(self.ds.variables.keys()) + all_vars = self.ds.get_column_names(virtual=True, strings=True, hidden=True) + list( + self.ds.variables.keys()) vaex.expresso.validate_expression(expression, all_vars, funcs, names) names = list(set(names)) types = ", ".join(str(self.ds.data_type(name)) + "[]" for name in names) @@ -1179,7 +1250,9 @@ def f({0}): m.update(code.encode('utf-8')) module_name = "pythranized_" + m.hexdigest() # print(m.hexdigest()) - module_path = pythran.compile_pythrancode(module_name, code, extra_compile_args=["-DBOOST_SIMD", "-march=native"] + [] if verbose else ["-w"]) + module_path = pythran.compile_pythrancode(module_name, code, extra_compile_args=["-DBOOST_SIMD", + "-march=native"] + [] if verbose else [ + "-w"]) module = imp.load_dynamic(module_name, module_path) function_name = "f_" + m.hexdigest() @@ -1187,7 +1260,7 @@ def f({0}): return Expression(self.ds, "{0}({1})".format(function.name, argstring)) finally: - logger.setLevel(log_level) + logger.setLevel(log_level) def _rename(self, old, new, inplace=False): expression = self if inplace else self.copy() @@ -1220,7 +1293,8 @@ def isin(self, values, use_hashmap=True): """ if use_hashmap: # easiest way to create a set is using the vaex dataframe - values = np.array(values, dtype=self.dtype.numpy) # ensure that values are the same dtype as the expression (otherwise the set downcasts at the C++ level during execution) + values = np.array(values, + dtype=self.dtype.numpy) # ensure that values are the same dtype as the expression (otherwise the set downcasts at the C++ level during execution) df_values = vaex.from_arrays(x=values) ordered_set = df_values._set(df_values.x) var = self.df.add_variable('var_isin_ordered_set', ordered_set, unique=True) @@ -1359,6 +1433,7 @@ def try_nan(x): return np.isnan(x) except: return False + mapper_nan_key_mask = np.array([try_nan(k) for k in mapper_keys]) mapper_has_nan = mapper_nan_key_mask.sum() > 0 if mapper_nan_key_mask.sum() > 1: @@ -1391,7 +1466,8 @@ def try_nan(x): if allow_missing: if default_value is not None: value0 = list(mapper.values())[0] - assert np.issubdtype(type(default_value), np.array(value0).dtype), "default value has to be of similar type" + assert np.issubdtype(type(default_value), + np.array(value0).dtype), "default value has to be of similar type" else: if only_has_nan: pass # we're good, the hash mapper deals with nan @@ -1428,7 +1504,8 @@ def try_nan(x): key_set_name = df.add_variable('map_key_set', ordered_set, unique=True) choices_name = df.add_variable('map_choices', choices, unique=True) if allow_missing: - expr = '_map({}, {}, {}, use_missing={!r}, axis={!r})'.format(self, key_set_name, choices_name, use_masked_array, axis) + expr = '_map({}, {}, {}, use_missing={!r}, axis={!r})'.format(self, key_set_name, choices_name, + use_masked_array, axis) else: expr = '_map({}, {}, {}, axis={!r})'.format(self, key_set_name, choices_name, axis) return Expression(df, expr) @@ -1440,6 +1517,9 @@ def is_masked(self): def is_string(self): return self.df.is_string(self.expression) + def is_image(self): + return self.df.is_image(self.expression) + class FunctionSerializable(object): pass @@ -1511,6 +1591,7 @@ def __init__(self, expression, arguments, argument_dtypes, return_dtype, verbose else: def placeholder(*args, **kwargs): raise Exception('You chose not to compile this function (locally), but did invoke it') + self.f = placeholder def state_get(self): @@ -1594,18 +1675,20 @@ def compile(self): @fuse() def f({0}): return {1} -'''.format(argstring, self.expression)#, ";".join(conversions)) +'''.format(argstring, self.expression) # , ";".join(conversions)) if self.verbose: print("generated code") print(code) - scope = dict()#cupy=cupy) + scope = dict() # cupy=cupy) exec(code, scope) func = scope['f'] + def wrapper(*args): args = [vaex.array_types.to_numpy(k) for k in args] args = [vaex.utils.to_native_array(arg) if isinstance(arg, np.ndarray) else arg for arg in args] args = [cupy.asarray(arg) if isinstance(arg, np.ndarray) else arg for arg in args] return cupy.asnumpy(func(*args)) + return wrapper @@ -1620,6 +1703,7 @@ def __call__(self, *args, **kwargs): def _apply(self, *args, **kwargs): length = len(args[0]) result = [] + def fix_type(v): # TODO: only when column is str type? if isinstance(v, np.str_): @@ -1628,6 +1712,7 @@ def fix_type(v): return v.decode('utf8') else: return v + args = [vaex.array_types.tolist(k) for k in args] for i in range(length): scalar_result = self.f(*[fix_type(k[i]) for k in args], **{key: value[i] for key, value in kwargs.items()}) @@ -1642,15 +1727,17 @@ def __init__(self, dataset, name, f): self.dataset = dataset self.name = name - if not vaex.serialize.can_serialize(f): # if not serializable, assume we can use pickle + if not vaex.serialize.can_serialize(f): # if not serializable, assume we can use pickle f = FunctionSerializablePickle(f) self.f = f def __call__(self, *args, **kwargs): - arg_string = ", ".join([str(k) for k in args] + ['{}={:r}'.format(name, value) for name, value in kwargs.items()]) + arg_string = ", ".join( + [str(k) for k in args] + ['{}={:r}'.format(name, value) for name, value in kwargs.items()]) expression = "{}({})".format(self.name, arg_string) return Expression(self.dataset, expression) + class FunctionBuiltin(object): def __init__(self, dataset, name, **kwargs): @@ -1660,6 +1747,7 @@ def __init__(self, dataset, name, **kwargs): def __call__(self, *args, **kwargs): kwargs = dict(kwargs, **self.kwargs) - arg_string = ", ".join([str(k) for k in args] + ['{}={:r}'.format(name, value) for name, value in kwargs.items()]) + arg_string = ", ".join( + [str(k) for k in args] + ['{}={:r}'.format(name, value) for name, value in kwargs.items()]) expression = "{}({})".format(self.name, arg_string) return Expression(self.dataset, expression) diff --git a/packages/vaex-core/vaex/expresso.py b/packages/vaex-core/vaex/expresso.py index d0d719552a..ee5cef5cb7 100644 --- a/packages/vaex-core/vaex/expresso.py +++ b/packages/vaex-core/vaex/expresso.py @@ -127,6 +127,9 @@ def validate_expression(expr, variable_set, function_set=[], names=None): validate_expression(expr.value, variable_set, function_set, names) elif isinstance(expr, ast_Constant): pass # like True and False + elif isinstance(expr, _ast.Tuple): + for el in expr.elts: + validate_expression(el, variable_set, function_set, names) elif isinstance(expr, _ast.List): for el in expr.elts: validate_expression(el, variable_set, function_set, names) @@ -381,6 +384,9 @@ def visit_Str(self, node): def visit_List(self, node): return "[{}]".format(", ".join([self.visit(k) for k in node.elts])) + def visit_Tuple(self, node): + return "({})".format(" ".join([self.visit(k) + "," for k in node.elts])) + def pow(self, left, right): return "({left} ** {right})".format(left=left, right=right) diff --git a/packages/vaex-core/vaex/formatting.py b/packages/vaex-core/vaex/formatting.py index 4e070783ae..055d10c25f 100644 --- a/packages/vaex-core/vaex/formatting.py +++ b/packages/vaex-core/vaex/formatting.py @@ -1,3 +1,5 @@ +from base64 import b64encode + import numpy as np import numbers import six @@ -6,16 +8,28 @@ from vaex import datatype, struct MAX_LENGTH = 50 +IMAGE_WIDTH = 100 +IMAGE_HEIGHT = 100 + def _trim_string(value): if len(value) > MAX_LENGTH: - value = repr(value[:MAX_LENGTH-3])[:-1] + '...' + value = repr(value[:MAX_LENGTH - 3])[:-1] + '...' return value -def _format_value(value): + +def _format_value(value, value_format='plain'): + if value_format == "html" and hasattr(value, '_repr_png_'): + data = value._repr_png_() + base64_data = b64encode(data) + data_encoded = base64_data.decode('ascii') + url_data = f"data:image/png;base64,{data_encoded}" + plain = f'' + return plain + # print("value = ", value, type(value), isinstance(value, numbers.Number)) - if isinstance(value, pa.lib.Scalar): + elif isinstance(value, pa.lib.Scalar): if datatype.DataType(value.type).is_struct: value = struct.format_struct_item_vaex_style(value) else: @@ -44,13 +58,13 @@ def _format_value(value): tmp = datetime.timedelta(seconds=value / np.timedelta64(1, 's')) ms = tmp.microseconds s = np.mod(tmp.seconds, 60) - m = np.mod(tmp.seconds//60, 60) + m = np.mod(tmp.seconds // 60, 60) h = tmp.seconds // 3600 d = tmp.days if ms: - value = str('%i days %+02i:%02i:%02i.%i' % (d,h,m,s,ms)) + value = str('%i days %+02i:%02i:%02i.%i' % (d, h, m, s, ms)) else: - value = str('%i days %+02i:%02i:%02i' % (d,h,m,s)) + value = str('%i days %+02i:%02i:%02i' % (d, h, m, s)) return value elif isinstance(value, numbers.Number): value = str(value) diff --git a/packages/vaex-core/vaex/registry.py b/packages/vaex-core/vaex/registry.py index ef625ac0db..e364c63748 100644 --- a/packages/vaex-core/vaex/registry.py +++ b/packages/vaex-core/vaex/registry.py @@ -11,7 +11,8 @@ 'str_pandas': vaex.expression.StringOperationsPandas, 'dt': vaex.expression.DateTime, 'td': vaex.expression.TimeDelta, - 'struct': vaex.expression.StructOperations + 'struct': vaex.expression.StructOperations, + 'vision': vaex.expression.Image } diff --git a/packages/vaex-core/vaex/vision.py b/packages/vaex-core/vaex/vision.py new file mode 100644 index 0000000000..e45612020f --- /dev/null +++ b/packages/vaex-core/vaex/vision.py @@ -0,0 +1,334 @@ +__author__ = 'yonatanalexander' + +import glob +import os +import pathlib +import functools +from io import StringIO, BytesIO +import collections +import numpy as np +import matplotlib.colors +import warnings +from base64 import b64encode +from PIL import Image +from io import BytesIO +import base64 +import vaex +import vaex.utils + +try: + import PIL + import base64 +except: + PIL = vaex.utils.optional_import("PIL.Image", modules="pillow") + + +def get_paths(path, suffix=None): + if isinstance(path, list): + return functools.reduce(lambda a, b: get_paths(a, suffix=suffix) + get_paths(b, suffix=suffix), path) + if os.path.isfile(path): + files = [path] + elif os.path.isdir(path): + files = [] + if suffix is not None: + files = [str(path) for path in pathlib.Path(path).rglob(f"*{suffix}")] + else: + for suffix in ['jpg', 'png', 'jpeg', 'ppm', 'thumbnail']: + files.extend([str(path) for path in pathlib.Path(path).rglob(f"*{suffix}")]) + elif isinstance(path, str) and len(glob.glob(path)) > 0: + return glob.glob(path) + else: + raise ValueError( + f"path: {path} do not point to an image, a directory of images, or a nested directory of images, or a glob path of files") + # TODO validate the files without opening it + return files + + +def _safe_apply(f, image_array): + try: + return f(image_array) + except: + return None + + +def _infer(item): + if isinstance(item, np.ndarray): + decode = numpy_2_pil + elif isinstance(item, bytes): + decode = bytes_2_pil + elif isinstance(item, str): + if os.path.isfile(item): + decode = PIL.Image.open() + else: + decode = base64_2_pil + return _safe_apply(decode, item) + + +@vaex.register_function(scope='vision') +def infer(images): + return np.array([_infer(image) for image in images], dtype="O") + + +@vaex.register_function(scope='vision') +def open(path, suffix=None): + files = get_paths(path=path, suffix=suffix) + df = vaex.from_arrays(path=files) + df['image'] = df['path'].vision.from_path() + return df + + +@vaex.register_function(scope='vision') +def resize(images, size, resample=3): + images = [image.resize(size, resample=resample) for image in images] + return np.array(images, dtype="O") + + +@vaex.register_function(scope='vision') +def to_numpy(images): + images = [np.array(image.convert('RGB')).astype(object) for image in images] + return np.array(images, dtype="O") + + +@vaex.register_function(scope='vision') +def to_bytes(arrays, format='png'): + images = [pil_2_bytes(image_array, format=format) for image_array in arrays] + return np.array(images, dtype="O") + + +@vaex.register_function(scope='vision') +def to_str(arrays, format='png'): + images = [pil_2_base64(image_array, format) for image_array in arrays] + return np.array(images, dtype="O") + + +@vaex.register_function(scope='vision') +def from_numpy(arrays): + images = [_safe_apply(numpy_2_pil, image_array) for image_array in arrays] + return np.array(images, dtype="O") + + +@vaex.register_function(scope='vision') +def from_bytes(arrays): + images = [_safe_apply(bytes_2_pil, image_array) for image_array in arrays] + return np.array(images, dtype="O") + + +@vaex.register_function(scope='vision') +def from_str(arrays): + images = [_safe_apply(base64_2_pil, image_array) for image_array in arrays] + return np.array(images, dtype="O") + + +@vaex.register_function(scope='vision') +def from_path(arrays): + images = [_safe_apply(PIL.Image.open, image_array) for image_array in vaex.array_types.tolist(arrays)] + return np.array(images, dtype="O") + + +def rgba_2_pil(rgba): + # TODO remove? + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + im = PIL.Image.fromarray(rgba[::-1], "RGBA") # , "RGBA", 0, -1) + return im + + +def numpy_2_pil(array): + return Image.fromarray(array) + + +def pil_2_bytes(im, format="png"): + f = BytesIO() + im.save(f, format) + return f.getvalue() + + +def bytes_2_pil(b): + return PIL.Image.open(BytesIO(b)) + + +def base64_2_pil(b): + return PIL.Image.open(BytesIO(base64.b64decode(b))) + + +def pil_2_base64(im, format=format): + return base64.b64encode(pil_2_bytes(im, format=format)) + + +def rgba_to_url(rgba): + bit8 = rgba.dtype == np.uint8 + if not bit8: + rgba = (rgba * 255.).astype(np.uint8) + im = rgba_2_pil(rgba) + data = pil_2_bytes(im) + data = b64encode(data) + data = data.decode("ascii") + imgurl = "data:image/png;base64," + data + "" + return imgurl + + +pdf_modes = collections.OrderedDict() +pdf_modes["multiply"] = np.multiply +pdf_modes["screen"] = lambda a, b: a + b - a * b +pdf_modes["darken"] = np.minimum +pdf_modes["lighten"] = np.maximum + +cairo_modes = collections.OrderedDict() +cairo_modes["saturate"] = (lambda aA, aB: np.minimum(1, aA + aB), + lambda aA, xA, aB, xB, aR: (np.minimum(aA, 1 - aB) * xA + aB * xB) / aR) +cairo_modes["add"] = (lambda aA, aB: np.minimum(1, aA + aB), + lambda aA, xA, aB, xB, aR: (aA * xA + aB * xB) / aR) + +modes = list(pdf_modes.keys()) + list(cairo_modes.keys()) + + +def background(shape, color="white", alpha=1, bit8=True): + rgba = np.zeros(shape + (4,)) + rgba[:] = np.array(matplotlib.colors.colorConverter.to_rgba(color)) + rgba[..., 3] = alpha + if bit8: + return (rgba * 255).astype(np.uint8) + else: + return rgba + + +def fade(image_list, opacity=0.5, blend_mode="multiply"): + result = image_list[0] * 1. + for i in range(1, len(image_list)): + result[result[..., 3] > 0, 3] = 1 + layer = image_list[i] * 1.0 + layer[layer[..., 3] > 0, 3] = opacity + result = blend([result, layer], blend_mode=blend_mode) + return result + + +def blend(image_list, blend_mode="multiply"): + bit8 = image_list[0].dtype == np.uint8 + image_list = image_list[::-1] + rgba_dest = image_list[0] * 1 + if bit8: + rgba_dest = (rgba_dest / 255.).astype(np.float) + for i in range(1, len(image_list)): + rgba_source = image_list[i] + if bit8: + rgba_source = (rgba_source / 255.).astype(np.float) + # assert rgba_source.dtype == image_list[0].dtype, "images have different types: first has %r, %d has %r" % (image_list[0].dtype, i, rgba_source.dtype) + + aA = rgba_source[:, :, 3] + aB = rgba_dest[:, :, 3] + + if blend_mode in pdf_modes: + aR = aA + aB * (1 - aA) + else: + aR = cairo_modes[blend_mode][0](aA, aB) + mask = aR > 0 + for c in range(3): # for r, g and b + xA = rgba_source[..., c] + xB = rgba_dest[..., c] + if blend_mode in pdf_modes: + f = pdf_modes[blend_mode](xB, xA) + with np.errstate(divide='ignore', invalid='ignore'): # these are fine, we are ok with nan's in vaex + result = ((1. - aB) * aA * xA + (1. - aA) * aB * xB + aA * aB * f) / aR + else: + result = cairo_modes[blend_mode][1](aA, xA, aB, xB, aR) + with np.errstate(divide='ignore', invalid='ignore'): # these are fine, we are ok with nan's in vaex + result = (np.minimum(aA, 1 - aB) * xA + aB * xB) / aR + # print(result) + rgba_dest[:, :, c][(mask,)] = np.clip(result[(mask,)], 0, 1) + rgba_dest[:, :, 3] = np.clip(aR, 0., 1) + rgba = rgba_dest + if bit8: + rgba = (rgba * 255).astype(np.uint8) + return rgba + + +def monochrome(I, color, vmin=None, vmax=None): + """Turns a intensity array to a monochrome 'image' by replacing each intensity by a scaled 'color' + + Values in I between vmin and vmax get scaled between 0 and 1, and values outside this range are clipped to this. + + Example + >>> I = np.arange(16.).reshape(4,4) + >>> color = (0, 0, 1) # red + >>> rgb = vx.image.monochrome(I, color) # shape is (4,4,3) + + :param I: ndarray of any shape (2d for image) + :param color: sequence of a (r, g and b) value + :param vmin: normalization minimum for I, or np.nanmin(I) when None + :param vmax: normalization maximum for I, or np.nanmax(I) when None + :return: + """ + if vmin is None: + vmin = np.nanmin(I) + if vmax is None: + vmax = np.nanmax(I) + normalized = (I - vmin) / (vmax - vmin) + return np.clip(normalized[..., np.newaxis], 0, 1) * np.array(color) + + +def polychrome(I, colors, vmin=None, vmax=None, axis=-1): + """Similar to monochrome, but now do it for multiple colors + + Example + >>> I = np.arange(32.).reshape(4,4,2) + >>> colors = [(0, 0, 1), (0, 1, 0)] # red and green + >>> rgb = vx.image.polychrome(I, colors) # shape is (4,4,3) + + :param I: ndarray of any shape (3d will result in a 2d image) + :param colors: sequence of [(r,g,b), ...] values + :param vmin: normalization minimum for I, or np.nanmin(I) when None + :param vmax: normalization maximum for I, or np.nanmax(I) when None + :param axis: axis which to sum over, by default the last + :return: + """ + axes_length = len(I.shape) + allaxes = list(range(axes_length)) + otheraxes = list(allaxes) + otheraxes.remove((axis + axes_length) % axes_length) + otheraxes = tuple(otheraxes) + + if vmin is None: + vmin = np.nanmin(I, axis=otheraxes) + if vmax is None: + vmax = np.nanmax(I, axis=otheraxes) + normalized = (I - vmin) / (vmax - vmin) + return np.clip(normalized, 0, 1).dot(colors) + + +# c_r + c_g + c_b +def _repr_png_(self): + from matplotlib import pylab + fig, ax = pylab.subplots() + self.plot(axes=ax, f=np.log1p) + import vaex.utils + if all([k is not None for k in [self.vx, self.vy, self.vcounts]]): + N = self.vx.grid.shape[0] + bounds = self.subspace_bounded.bounds + print(bounds) + positions = [vaex.utils.linspace_centers(bounds[i][0], bounds[i][1], N) for i in + range(self.subspace_bounded.subspace.dimension)] + print(positions) + mask = self.vcounts.grid > 0 + vx = np.zeros_like(self.vx.grid) + vy = np.zeros_like(self.vy.grid) + vx[mask] = self.vx.grid[mask] / self.vcounts.grid[mask] + vy[mask] = self.vy.grid[mask] / self.vcounts.grid[mask] + # vx = self.vx.grid / self.vcounts.grid + # vy = self.vy.grid / self.vcounts.grid + x2d, y2d = np.meshgrid(positions[0], positions[1]) + ax.quiver(x2d[mask], y2d[mask], vx[mask], vy[mask]) + # print x2d + # print y2d + # print vx + # print vy + # ax.quiver(x2d, y2d, vx, vy) + ax.title.set_text(r"$\log(1+counts)$") + ax.set_xlabel(self.subspace_bounded.subspace.expressions[0]) + ax.set_ylabel(self.subspace_bounded.subspace.expressions[1]) + # pylab.savefig + # from .io import StringIO + from six import StringIO + file_object = StringIO() + fig.canvas.print_png(file_object) + pylab.close(fig) + return file_object.getvalue() diff --git a/tests/ml/vision_test.py b/tests/ml/vision_test.py new file mode 100644 index 0000000000..33a59843f4 --- /dev/null +++ b/tests/ml/vision_test.py @@ -0,0 +1,12 @@ +import glob + +basedir = 'tests/data/images' + +import vaex.vision +import PIL + +def test_image_open(): + df = vaex.vision.open(basedir) + assert isinstance(df.image.tolist()[0], PIL.Image.Image) + assert df.image.vision.as_numpy().shape == (len(df), 261, 350, 3) + assert df.image.vision.resize((8, 4)).vision.as_numpy().shape == (2, 4, 8, 4) From 9ae4ade19d7930a8dc6d7dc7e4c199574a0a2377 Mon Sep 17 00:00:00 2001 From: xdssio Date: Mon, 22 Aug 2022 12:06:29 +0200 Subject: [PATCH 2/6] working v1 --- packages/vaex-core/vaex/vision.py | 23 ++++++++++++++++------- tests/ml/vision_test.py | 4 ++-- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/vaex-core/vaex/vision.py b/packages/vaex-core/vaex/vision.py index e45612020f..9c5b3ef3c2 100644 --- a/packages/vaex-core/vaex/vision.py +++ b/packages/vaex-core/vaex/vision.py @@ -58,7 +58,7 @@ def _infer(item): decode = bytes_2_pil elif isinstance(item, str): if os.path.isfile(item): - decode = PIL.Image.open() + decode = PIL.Image.open else: decode = base64_2_pil return _safe_apply(decode, item) @@ -70,10 +70,13 @@ def infer(images): @vaex.register_function(scope='vision') -def open(path, suffix=None): +def open(path, suffix=None, lazy=False): files = get_paths(path=path, suffix=suffix) - df = vaex.from_arrays(path=files) - df['image'] = df['path'].vision.from_path() + if lazy: + df = vaex.from_arrays(path=files) + df['image'] = df['path'].vision.from_path() + else: + df = vaex.from_arrays(path=files, image=from_path(files)) return df @@ -85,8 +88,8 @@ def resize(images, size, resample=3): @vaex.register_function(scope='vision') def to_numpy(images): - images = [np.array(image.convert('RGB')).astype(object) for image in images] - return np.array(images, dtype="O") + images = [pil_2_numpy(image) for image in images] + return np.array(images, dtype="O").reshape(-1) @vaex.register_function(scope='vision') @@ -134,7 +137,13 @@ def rgba_2_pil(rgba): def numpy_2_pil(array): - return Image.fromarray(array) + return Image.fromarray(np.uint8(array)) + + +def pil_2_numpy(im): + if im: + return np.array(im).astype(object) + return None def pil_2_bytes(im, format="png"): diff --git a/tests/ml/vision_test.py b/tests/ml/vision_test.py index 33a59843f4..b47e1cfdfd 100644 --- a/tests/ml/vision_test.py +++ b/tests/ml/vision_test.py @@ -8,5 +8,5 @@ def test_image_open(): df = vaex.vision.open(basedir) assert isinstance(df.image.tolist()[0], PIL.Image.Image) - assert df.image.vision.as_numpy().shape == (len(df), 261, 350, 3) - assert df.image.vision.resize((8, 4)).vision.as_numpy().shape == (2, 4, 8, 4) + assert df.image.vision.to_numpy().shape == (len(df), 261, 350, 3) + assert df.image.vision.resize((8, 4)).vision.to_numpy().shape == (16, 4, 8, 3) From 5d70c32fd495c351e1f9bdd68d1ecedbd3baf6cb Mon Sep 17 00:00:00 2001 From: xdssio Date: Mon, 22 Aug 2022 18:46:23 +0200 Subject: [PATCH 3/6] v2 works --- packages/vaex-core/vaex/formatting.py | 21 ++- packages/vaex-core/vaex/vision.py | 231 ++++---------------------- tests/ml/vision_test.py | 8 +- 3 files changed, 51 insertions(+), 209 deletions(-) diff --git a/packages/vaex-core/vaex/formatting.py b/packages/vaex-core/vaex/formatting.py index 055d10c25f..e6c1637311 100644 --- a/packages/vaex-core/vaex/formatting.py +++ b/packages/vaex-core/vaex/formatting.py @@ -20,16 +20,20 @@ def _trim_string(value): def _format_value(value, value_format='plain'): - if value_format == "html" and hasattr(value, '_repr_png_'): - data = value._repr_png_() - base64_data = b64encode(data) - data_encoded = base64_data.decode('ascii') - url_data = f"data:image/png;base64,{data_encoded}" - plain = f'' - return plain + if value_format == "html": + if hasattr(value, '_repr_png_'): + data = value._repr_png_() + base64_data = b64encode(data) + data_encoded = base64_data.decode('ascii') + url_data = f"data:image/png;base64,{data_encoded}" + plain = f'' + return plain + elif hasattr(value, 'shape') and len(value.shape) > 1: + return _trim_string(str(value).replace('\n', '
')) + # print("value = ", value, type(value), isinstance(value, numbers.Number)) - elif isinstance(value, pa.lib.Scalar): + if isinstance(value, pa.lib.Scalar): if datatype.DataType(value.type).is_struct: value = struct.format_struct_item_vaex_style(value) else: @@ -68,6 +72,7 @@ def _format_value(value, value_format='plain'): return value elif isinstance(value, numbers.Number): value = str(value) + else: value = repr(value) value = _trim_string(value) diff --git a/packages/vaex-core/vaex/vision.py b/packages/vaex-core/vaex/vision.py index 9c5b3ef3c2..4b3b64726a 100644 --- a/packages/vaex-core/vaex/vision.py +++ b/packages/vaex-core/vaex/vision.py @@ -4,21 +4,18 @@ import os import pathlib import functools -from io import StringIO, BytesIO import collections import numpy as np import matplotlib.colors import warnings -from base64 import b64encode -from PIL import Image -from io import BytesIO -import base64 +import io import vaex import vaex.utils try: import PIL import base64 + except: PIL = vaex.utils.optional_import("PIL.Image", modules="pillow") @@ -47,49 +44,54 @@ def get_paths(path, suffix=None): def _safe_apply(f, image_array): try: return f(image_array) - except: + except Exception as e: return None def _infer(item): + if hasattr(item, 'as_py'): + item = item.as_py() if isinstance(item, np.ndarray): decode = numpy_2_pil + elif isinstance(item, int): + item = np.ndarray(item) + decode = numpy_2_pil elif isinstance(item, bytes): decode = bytes_2_pil elif isinstance(item, str): if os.path.isfile(item): decode = PIL.Image.open else: - decode = base64_2_pil + decode = str_2_pil + else: + raise RuntimeError(f"Can't handle item {item}") return _safe_apply(decode, item) @vaex.register_function(scope='vision') def infer(images): - return np.array([_infer(image) for image in images], dtype="O") + images = [_infer(image) for image in images] + return np.array(images, dtype="O") @vaex.register_function(scope='vision') -def open(path, suffix=None, lazy=False): +def open(path, suffix=None): files = get_paths(path=path, suffix=suffix) - if lazy: - df = vaex.from_arrays(path=files) - df['image'] = df['path'].vision.from_path() - else: - df = vaex.from_arrays(path=files, image=from_path(files)) + df = vaex.from_arrays(path=files) + df['image'] = df['path'].vision.infer() return df @vaex.register_function(scope='vision') -def resize(images, size, resample=3): - images = [image.resize(size, resample=resample) for image in images] +def resize(images, size, resample=3, **kwargs): + images = [image.resize(size, resample=resample, **kwargs) for image in images] return np.array(images, dtype="O") @vaex.register_function(scope='vision') def to_numpy(images): images = [pil_2_numpy(image) for image in images] - return np.array(images, dtype="O").reshape(-1) + return np.array(images, dtype="O") @vaex.register_function(scope='vision') @@ -99,8 +101,8 @@ def to_bytes(arrays, format='png'): @vaex.register_function(scope='vision') -def to_str(arrays, format='png'): - images = [pil_2_base64(image_array, format) for image_array in arrays] +def to_str(arrays, format='png', encoding=None): + images = [pil_2_str(image_array, format=format, encoding=encoding) for image_array in arrays] return np.array(images, dtype="O") @@ -118,7 +120,7 @@ def from_bytes(arrays): @vaex.register_function(scope='vision') def from_str(arrays): - images = [_safe_apply(base64_2_pil, image_array) for image_array in arrays] + images = [_safe_apply(str_2_pil, image_array) for image_array in arrays] return np.array(images, dtype="O") @@ -137,31 +139,33 @@ def rgba_2_pil(rgba): def numpy_2_pil(array): - return Image.fromarray(np.uint8(array)) + return PIL.Image.fromarray(np.uint8(array)) def pil_2_numpy(im): - if im: + if im is not None: return np.array(im).astype(object) return None def pil_2_bytes(im, format="png"): - f = BytesIO() + f = io.BytesIO() im.save(f, format) - return f.getvalue() + return base64.b64encode(f.getvalue()) def bytes_2_pil(b): - return PIL.Image.open(BytesIO(b)) + return PIL.Image.open(io.BytesIO(base64.b64decode(b))) -def base64_2_pil(b): - return PIL.Image.open(BytesIO(base64.b64decode(b))) +def pil_2_str(im, format="png", encoding=None): + args = [encoding] if encoding else [] + return pil_2_bytes(im, format=format).decode(*args) -def pil_2_base64(im, format=format): - return base64.b64encode(pil_2_bytes(im, format=format)) +def str_2_pil(im, encoding=None): + args = [encoding] if encoding else [] + return bytes_2_pil(im.encode(*args)) def rgba_to_url(rgba): @@ -170,174 +174,7 @@ def rgba_to_url(rgba): rgba = (rgba * 255.).astype(np.uint8) im = rgba_2_pil(rgba) data = pil_2_bytes(im) - data = b64encode(data) + data = base64.b64encode(data) data = data.decode("ascii") imgurl = "data:image/png;base64," + data + "" return imgurl - - -pdf_modes = collections.OrderedDict() -pdf_modes["multiply"] = np.multiply -pdf_modes["screen"] = lambda a, b: a + b - a * b -pdf_modes["darken"] = np.minimum -pdf_modes["lighten"] = np.maximum - -cairo_modes = collections.OrderedDict() -cairo_modes["saturate"] = (lambda aA, aB: np.minimum(1, aA + aB), - lambda aA, xA, aB, xB, aR: (np.minimum(aA, 1 - aB) * xA + aB * xB) / aR) -cairo_modes["add"] = (lambda aA, aB: np.minimum(1, aA + aB), - lambda aA, xA, aB, xB, aR: (aA * xA + aB * xB) / aR) - -modes = list(pdf_modes.keys()) + list(cairo_modes.keys()) - - -def background(shape, color="white", alpha=1, bit8=True): - rgba = np.zeros(shape + (4,)) - rgba[:] = np.array(matplotlib.colors.colorConverter.to_rgba(color)) - rgba[..., 3] = alpha - if bit8: - return (rgba * 255).astype(np.uint8) - else: - return rgba - - -def fade(image_list, opacity=0.5, blend_mode="multiply"): - result = image_list[0] * 1. - for i in range(1, len(image_list)): - result[result[..., 3] > 0, 3] = 1 - layer = image_list[i] * 1.0 - layer[layer[..., 3] > 0, 3] = opacity - result = blend([result, layer], blend_mode=blend_mode) - return result - - -def blend(image_list, blend_mode="multiply"): - bit8 = image_list[0].dtype == np.uint8 - image_list = image_list[::-1] - rgba_dest = image_list[0] * 1 - if bit8: - rgba_dest = (rgba_dest / 255.).astype(np.float) - for i in range(1, len(image_list)): - rgba_source = image_list[i] - if bit8: - rgba_source = (rgba_source / 255.).astype(np.float) - # assert rgba_source.dtype == image_list[0].dtype, "images have different types: first has %r, %d has %r" % (image_list[0].dtype, i, rgba_source.dtype) - - aA = rgba_source[:, :, 3] - aB = rgba_dest[:, :, 3] - - if blend_mode in pdf_modes: - aR = aA + aB * (1 - aA) - else: - aR = cairo_modes[blend_mode][0](aA, aB) - mask = aR > 0 - for c in range(3): # for r, g and b - xA = rgba_source[..., c] - xB = rgba_dest[..., c] - if blend_mode in pdf_modes: - f = pdf_modes[blend_mode](xB, xA) - with np.errstate(divide='ignore', invalid='ignore'): # these are fine, we are ok with nan's in vaex - result = ((1. - aB) * aA * xA + (1. - aA) * aB * xB + aA * aB * f) / aR - else: - result = cairo_modes[blend_mode][1](aA, xA, aB, xB, aR) - with np.errstate(divide='ignore', invalid='ignore'): # these are fine, we are ok with nan's in vaex - result = (np.minimum(aA, 1 - aB) * xA + aB * xB) / aR - # print(result) - rgba_dest[:, :, c][(mask,)] = np.clip(result[(mask,)], 0, 1) - rgba_dest[:, :, 3] = np.clip(aR, 0., 1) - rgba = rgba_dest - if bit8: - rgba = (rgba * 255).astype(np.uint8) - return rgba - - -def monochrome(I, color, vmin=None, vmax=None): - """Turns a intensity array to a monochrome 'image' by replacing each intensity by a scaled 'color' - - Values in I between vmin and vmax get scaled between 0 and 1, and values outside this range are clipped to this. - - Example - >>> I = np.arange(16.).reshape(4,4) - >>> color = (0, 0, 1) # red - >>> rgb = vx.image.monochrome(I, color) # shape is (4,4,3) - - :param I: ndarray of any shape (2d for image) - :param color: sequence of a (r, g and b) value - :param vmin: normalization minimum for I, or np.nanmin(I) when None - :param vmax: normalization maximum for I, or np.nanmax(I) when None - :return: - """ - if vmin is None: - vmin = np.nanmin(I) - if vmax is None: - vmax = np.nanmax(I) - normalized = (I - vmin) / (vmax - vmin) - return np.clip(normalized[..., np.newaxis], 0, 1) * np.array(color) - - -def polychrome(I, colors, vmin=None, vmax=None, axis=-1): - """Similar to monochrome, but now do it for multiple colors - - Example - >>> I = np.arange(32.).reshape(4,4,2) - >>> colors = [(0, 0, 1), (0, 1, 0)] # red and green - >>> rgb = vx.image.polychrome(I, colors) # shape is (4,4,3) - - :param I: ndarray of any shape (3d will result in a 2d image) - :param colors: sequence of [(r,g,b), ...] values - :param vmin: normalization minimum for I, or np.nanmin(I) when None - :param vmax: normalization maximum for I, or np.nanmax(I) when None - :param axis: axis which to sum over, by default the last - :return: - """ - axes_length = len(I.shape) - allaxes = list(range(axes_length)) - otheraxes = list(allaxes) - otheraxes.remove((axis + axes_length) % axes_length) - otheraxes = tuple(otheraxes) - - if vmin is None: - vmin = np.nanmin(I, axis=otheraxes) - if vmax is None: - vmax = np.nanmax(I, axis=otheraxes) - normalized = (I - vmin) / (vmax - vmin) - return np.clip(normalized, 0, 1).dot(colors) - - -# c_r + c_g + c_b -def _repr_png_(self): - from matplotlib import pylab - fig, ax = pylab.subplots() - self.plot(axes=ax, f=np.log1p) - import vaex.utils - if all([k is not None for k in [self.vx, self.vy, self.vcounts]]): - N = self.vx.grid.shape[0] - bounds = self.subspace_bounded.bounds - print(bounds) - positions = [vaex.utils.linspace_centers(bounds[i][0], bounds[i][1], N) for i in - range(self.subspace_bounded.subspace.dimension)] - print(positions) - mask = self.vcounts.grid > 0 - vx = np.zeros_like(self.vx.grid) - vy = np.zeros_like(self.vy.grid) - vx[mask] = self.vx.grid[mask] / self.vcounts.grid[mask] - vy[mask] = self.vy.grid[mask] / self.vcounts.grid[mask] - # vx = self.vx.grid / self.vcounts.grid - # vy = self.vy.grid / self.vcounts.grid - x2d, y2d = np.meshgrid(positions[0], positions[1]) - ax.quiver(x2d[mask], y2d[mask], vx[mask], vy[mask]) - # print x2d - # print y2d - # print vx - # print vy - # ax.quiver(x2d, y2d, vx, vy) - ax.title.set_text(r"$\log(1+counts)$") - ax.set_xlabel(self.subspace_bounded.subspace.expressions[0]) - ax.set_ylabel(self.subspace_bounded.subspace.expressions[1]) - # pylab.savefig - # from .io import StringIO - from six import StringIO - file_object = StringIO() - fig.canvas.print_png(file_object) - pylab.close(fig) - return file_object.getvalue() diff --git a/tests/ml/vision_test.py b/tests/ml/vision_test.py index b47e1cfdfd..1acb26ea7b 100644 --- a/tests/ml/vision_test.py +++ b/tests/ml/vision_test.py @@ -1,12 +1,12 @@ -import glob +import vaex.vision +import PIL basedir = 'tests/data/images' -import vaex.vision -import PIL def test_image_open(): df = vaex.vision.open(basedir) + assert df.shape == (16, 2) assert isinstance(df.image.tolist()[0], PIL.Image.Image) - assert df.image.vision.to_numpy().shape == (len(df), 261, 350, 3) + assert df.image.vision.to_numpy().shape == (16, 261, 350, 3) assert df.image.vision.resize((8, 4)).vision.to_numpy().shape == (16, 4, 8, 3) From 2853113bd283fe361cfd12d0a234adacce41578b Mon Sep 17 00:00:00 2001 From: xdssio Date: Wed, 24 Aug 2022 11:14:30 +0200 Subject: [PATCH 4/6] upload image data for testing --- tests/data/images/cats/cat.4865.jpg | Bin 0 -> 21874 bytes tests/data/images/cats/cat.9021.jpg | Bin 0 -> 9771 bytes tests/data/images/dogs/dog.2423.jpg | Bin 0 -> 7101 bytes tests/data/images/dogs/dog.8091.jpg | Bin 0 -> 15674 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100755 tests/data/images/cats/cat.4865.jpg create mode 100755 tests/data/images/cats/cat.9021.jpg create mode 100755 tests/data/images/dogs/dog.2423.jpg create mode 100755 tests/data/images/dogs/dog.8091.jpg diff --git a/tests/data/images/cats/cat.4865.jpg b/tests/data/images/cats/cat.4865.jpg new file mode 100755 index 0000000000000000000000000000000000000000..4818086f221a78b51f626bee00c5b467e6d05362 GIT binary patch literal 21874 zcmbTcWl&sC^!GV1zyQH@a2bLO5}d)^eb4|&aEAbc69^I@Oa_Mph6!#13>KUSuEE_2 z8a((fAtams^K9)_?W^5Ax4PfluCD&vKBsQ?`JTUve>VYCdRn?#06aVZ0Po)e_`3p7 z2atk5#2_M4Vq)S4q@)ihz*H3E

c54Afu<$3q@&4sI@9L77Lq0+Iq;+@h*tlCttj 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 literal 0 HcmV?d00001 diff --git a/tests/data/images/cats/cat.9021.jpg b/tests/data/images/cats/cat.9021.jpg new file mode 100755 index 0000000000000000000000000000000000000000..aeddb381a9f3cc2cff45f98072856f753a4e8987 GIT binary patch literal 9771 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 d7Oz@VhfGjzvmg=XITP9?#b~=Q?M5X!|3u8{_2S z1b{#Q0NN?Q_9$=^kPsCW6BUsV6BCn^kdTyt?Us?2mQj^g*aOp6*VWNd*V5G6Z+cJ< zfkbF(8QB>l%`B{~t#u6@T#j0zO|7gg{xb+jLQ+yjT1I8}ZWRl-7Tn_h*|yt&Jz^kX z&}j%r3jpr{LH2;QI{@{abqa(2%YgrBATUHoSVUAzTtadupkX%v20Iy$r2$(I3$!57Io=zbWChqeCoC9H_~q2qGZwN+4pjC z^B5(iEOr^Eyy8LK6#_L;@C?c&-d`0w%ok^K@1I>=AD%l|?9FS7qTu&e)HWd957e{+ohQV`Hi@*sNv z8-VNevjQ9?D7@_{UQ~5S-FjHzLm0NOq)eO(_Y4zn-c2R4;h06G&#B=gne{zmV4( z>wl8mtS~W1Z7>|MuLlQOBoG&zG1)nchzXL+w42#9Sl{&OK9zEjhftgAMparggmr;( z&CpOU+i3p2diWjM7xQBj#F&X|WU?!|-_<_rWJf_tMW(i@EK70$1BKWb+^Ua;{ee$k z;-oOZ1RZ|@;|PTogwjh;3TRg7iSD8AL$)}xL6HWjR`4O?fui`F5Hqe}&)G=Tyy^bB zviOv$vCe1AhP~GTu}^qKkR%{SvE^3 zi0nQ(Mxj4Kj>Ry=ZhmtQQZ?T6>L5rg^EJnA9iLJ6x!kk0eisv2EjC$jQD%z-%>!3- zan4&07Lz_jalzriy_m=fs|Svliym|?IYpc!#6y06hMyYAy&*hvr=;*04YJG>37{qr zmiaIy@yMotSmlxqA9Xw6 zB#9N(`Bx5`okpaWLMS46T2=7Vq%TGmf)u+0wju~yj<6yh4i&RS>|mp0LP0Hw2-|KB zk>Qts)y!ps3#dE0WB27!=tl$`WbB0E5JjO_h*)VvJfQ*(Y9XBpb=KhnEOeo(tN8Z{5{kk10mEtvPy}EFHgD1$}JHr`!^v3L~O|}+g?s@(9`p>6H zWZP?!7$X#$YiL%cv0e&oVsimg=y~X3NwSR(5HVsrQABYINZxHILlWbFcB1%3Fa>HL z*<*AY_`-bD3Q?xGMUs9^(qLUo5qXxr=V3jBF#|}u;Kw*uFiymAU z%QzRB%UNY{0i+;?GbOlB0o?F=7;$R!>#;1nL#%Sex=R{vtIH;r71YLey`=o+6tCdc zhCdeXh(!#sB_c`Dn^?VTTthhb;3qo`VHhty8V=4CF~^GTAVVzsAmkl&K@>kM2Od}S>{C1UxY;;M6ArHLJT&OJC$O~JBYc+ zI!#|1l;uK8Qn&}9VmJ>L1X_|TLysw;_JJpJ8uUns<-4)YVLrPs0LEjmJ|IK7B%jp8 z48UQWkyr@HwuB;LXvTp~&AZF%eM;>Wuo+Gr5xMqBN{kH~ftE@XSWWadIG3M0x-Xvy z{e4v^Gb8FZd&@rayjHCro=8c!T&5Ox(e>CQC-UfUjmOen3Ds#)=Ni z{sPKP+AD%>bdr{m2B9T$5eZmGOaCZT#vPn1 zijh{wh)*Gbq%W|3hPtiE>u{z$*U30JPnyN?c{I^( zoegdGfQnt5$wyh&_r%WRN{-MkvDVm7puV2Y?9<>wRRU;cW#D!0J{-lztq;7%g(1$` z*Q%L{S*LC}JD^Ma(c9L1(MDn){$E z|KE75g|y!NE_PT%+%y&(0?r*Pq)(FW(mu2Tm{t_KbC*xpDn~*Hb%`SpI`5$h2iYxR z#1~zD3Hrc-85*=6_%xD&hKr=gAVV@;ChnzNH_!zqE8KsVGm)ol-r0FrrrpJRpQ3dh zON^|@dhleEE~j_uS((rc=P9Afiu1?3-@eyxyNiu-LH;y&>3T_#WnDk4laBnL{Q$ni z16R0I4i{^negD4IqhOgc^4tPo2{(e-+T9ZeWR{)v#Vi@XN=AH3b;+7IsH!|E9i#WN@Z zwNd9^f+*L>VyCEI-uyjWE6;fD)^JAcHsGvzrTK7iNOX?zlHXPHREIFf7OgL5^

F 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 CNdVvg literal 0 HcmV?d00001 diff --git a/tests/data/images/dogs/dog.8091.jpg b/tests/data/images/dogs/dog.8091.jpg new file mode 100755 index 0000000000000000000000000000000000000000..5234c8b7c428e37ea2652947edd66bd2c2f40271 GIT binary patch literal 15674 zcmbW8cTiJN^yd=-1dt+469^sYBHe^ur1##H-a&d5qzGc9cVg%z^d=yPC`buKdaoj( z_bNre^|$+*{q5{uzuoiZyqP!e%)Re3XXf5B_k7>q*}p4*`)W$6N&p}b0092G0Dl(% z3IGx?md`bc; z7C|{eYF%qEs~3$>L~;QUn|ysIt=`xXyReOSBr(Z@hY%>;BMwe3ZXOX)v8Ql^xWY3< zC1n*=HGKm^BV!X&Gg~`*2S+Do7aw0g|A4@t;HWpzF>hnvAyZP*(lau%vQdRa#U-Vf zvhs?CkBv>upITZ!fA8w<>BaW-4~$PtPEF6u&dvW?#jUMxY;JAu9G{$?o&UbLyt@7m zE+7E(zp(z@{}OCE?4AOqMhY|=w| z&DY;J7Qe5iEKwXZho>HGz0UE?*v-=4)<)sCAXV(3rqs^Ylol?W7Ci8NmBGt^irZSX z5bgzGv*b^lDQ_|B9_k2@Vo;yyH?U__yDFx|nH%==9afA@g*ITQ18%tn zOLX`>$;xiOSfBubka<@upnN;e2Y9j3!BoxG0(f8#l5U*9ckWYDVk40!Z-E)4-Q%-v zu-DTzOC3)9Gr714)gDJ95P&f+6FCt_&W*o-ubL7=;7jwnE0JI08D-CGw7`A61<51m zZ!Am#Yg08%Mk{w$gsB%hh3LxM`FfGmfcRb97HMMYHuZ@%Ds=n))fB z(M`n*ke&|n%*e>e5fL`R*=YQP!F_7mP8=bX^2B9c+B(R%XSXH zp^_qi>!Ai>XY(U3z`~@`n+MFI2F8 zJQ~D-V9$$B?>MLwnMo5`tWBl`#FDGq+nAO#NY*aE|AGr=xAlHE<~;p8MVE2Xnrj1x zkWJL$HFh~}n~pv+iSsn0ys1aOB>uRJcd4rGs0(7+`-laY9xh>9H6g-i-s%Gb1=a=G%-g>c5)IO6p zT+6KeI1tiN`dc=po)ZAOqxqn%C*-2p_cijv1~+Hkc#(Yg6HhLZZZzs;R*k3dmHwxS zO}4YbEm4}vh%Tn_q=#T&L&zU}1yWxgaFa?EX5pwpLqQCkV$QiZ_GXy))9{gq?g+}lxu%H>rKyN z*qAJFp6!)xo~^o~0QEODc`fOE?xkEo6;-U;WPPm9wxLG@C`FvpVk*Xesh6i@t;9AR zd2l5lW2W2GS$!{~<-r!%ia=9m0q^lBqt=+d(XNfw3w1;zlUe(=>AbMRd}AnY*6c?I zFYar7zYwOTg_m(*cTZ445`T1E@4gsC>)C90rH|!dG(IZ6EK0!!G!f5=q6YJs~%)%-}0WFfmz1-Ea z-Byy~l}Ps%WB1hwEi;rdRdUh%dbZA%ijeyZh~)pidcVU9sYASrG!r(= zf5YRcn}jOvfx2k@kl#===Kso_j26sdMq#4q7QH< zg5E6ekvJ~qTt+t<1IcA^qEJ%V;T_hrLC7o0ex~Fe3HA%tdLKnrHYjK0IsWl4j-4>M znd8biT%^mFO@qRT9fNx?*# zDfPmNNo|pV%;$qp&!>m?%uH#s!PjTk)1~lg<~(_JpJm=g^Bh*$_-3c9ekeGp3zhnP zes$N(Nr>`yS(vHu%;=qHt!R}GshBbX-xxh?f0X&&v}lpp0SdY-lKFMZEizrToMx!f z;tbLndwHLuC_361Ddh{8R}2=d46sQ6ZH* z{@mqNVjZ<_@sRM|@dfl6^@5e<1~0(~)ByHU3hiMuuq}MDuAI(v@$jT%Z`Ohbk$A8G zf$J}q5ET_B4x|ohA3Ryto{RpGV`d*;y#{(RJa%RDh6?;nNNK-_neJ8dsAdG8+9f_| zLR#|NLia*Zw88ai$$09{Lxv$Pk!xKeGtK}Qg(1YHr zvGT>59|5Gcv^L0BvHSw};WTrUc}3|rJZ9Xp%6VI47fK<M45HvSC?n^@unUhWdDwrmRPg+0(O1<`StZ*!0_h*CRBSw6_Cbq>Zn$gFRB?TLQ67F2lK>olgSu7QW-%Kv|H3gM&EIIfGDoCh3fmZlnEOi zD6+LniXmtNIIxA60jr@0=<=6gr3Z^%DPew$*l)P!nT3}am6^<2!hJIhyY9aL-o)=; zJ(qo^OCwKIEF`^!#Lw*EtcBCFjuT#sU1G0VX`55XDYU%wjYrHJ-_`Cp7^(iGkFd_A zhs{)O$=nyIHonknX-@KdptSbqTF-m^@~vx*AI+4#A8GOZAbkCbZW>)hz<l`R zT)pV>ckkHcrp;?>)&R!yr3EpoxULrgqjqf) z6d9PquTvwf)wcYW8tdgbtKaC&F5SiOQikQ`BO-2&Cyr|wrYM2^q)wIJ3=8$LNM`*}_0?T3_|ugS0j3lMwr5-Sfb%zOrS z7HlJRz9SS-up{>A`jxxe$}c5lC{uVf&(+0u=_hTYwJ{wj$}CW0_-%-B>(+I9bCXr3 z(OUeX(9>mwMY>^er-9%dCtY22FVcITol^vN_6Tfuqy@&#_INGcq0dZWWvVSlkAHvAk%L-dVB_Ad`Fp{dw3|n-jO(1U?HiIf4S70H z{9k$IId4K{+xUaq!nIepxq}E2WedMtQ@nqmS(@*YEH}BuI)+`V8Hz#geNYxPhy1;8w z$0KX7X>oQ1m4;+qSf#l3e`URFSsK2Us{i4IIDH|deW83V^Va21(zx<3z|LuWphkC2 z0OF^+BX!H$>G4Qt;Dl+FeR|B;K{KU?gEBEV(KR`B^sJPP;f>*0lmKs=J0Mh*ajNnW zZ(5K=B)4#`i+RlQ)%pIm=DlrsLSdSg)ttuj(&Xi=x4rVox#`}%L&-@LnVy6n`?rk? zT9a9@q^(;@iUM*DHmZijn%lQy#^+?>%QVsZko_E!M#oDp)6vCP_JR1;%Hw5a);4K% zngxBw#mY*krF3NRiWQQbjh&*#IXtIj$Q0zTLif&`mK|NgLph!FpfHsbQL~_U%uz&E zq{uLdEVd4J3zHdc5fQB|8EOp2&i>n0b8#9!+omqmOqYsnf4-g35=>9MR8c9r^r>tCOrg>SjN(kwHJQw$KIQ1m8n-@cQcdi;p= zLYtZ4ff762N*4~Vif|wys7IS;Pa=1&=tjBzi_WLyER!QB0(`V<8K+l#%vWD$=ap`~ zX#C?yZD%JS9dP72CrOE;H*Yg(9Bioj5#{ElP4)VGt34}W6{%)6DyK-J1N=`1Cn}sQ zQ+QcspnP0PTfX-=C|HKYs+-y6o^Or?o9vYNgo#fyk=dxY7rx-17qFcDSJVTyukZZU zE`ATWd)rz^%|Lp2x8~SX(|F66BN4TtmjxvzW@eoMu)r@Fwyjvdi(5fUL4QG~)4?$PxaGG`EQz(fi}b#ng7Spm#Zxo71bw+5`}EjtP1LOh~If1?oo3mnAQJ91=O} z%Rds=30nuS`5>cX$X(>;XcA@drUsSgXGfkZ>M5X5V)HBGpu(6nRX5#MJps9W)A{(U zxS)4qpY_bO4A#6Qrn3DTgNE1H5?vb4>f$`R=_oWNQwl4o$cPFe${~F}U$&HMgV_qo zROY9+VYW>G!CPmV-vx;-a)YFLKwMsLDOp4WmVt-aGkDalR}r9wVJj6hcCRrteCB5K z3<172B+{;2nT#cA@yMssaI6Y+T-EUZJvQ&M%|`Ic@wp0PoJ@7FX!Yd7=6)Y{ol4VU zfFWNof33$;DnJ$d)KSz@Qrz4c`{r~$zeNOcCRK}E<&-A*rzqWQNBm&iA44wWhVAik zX){mo)cX%o@7u!;Ke<$ryuyl<q7Qd^NzxOA#4 z^==ix%qJr*qLupO*dRSFiX=)3Rw;qyj~b06VhEl8m5L^9Ja*B$_uRJqVm(wg54bu^t6mmx}xU8xXJql(l z{AgG=Wy8t5*(Abhn&c=+7w)+vXheVL-$_gQu5ly$e*D~dv5kL((yyehW6X*`4~VCp z`PR9^3uP)GwXICTBTFvTe_qT^J{dO)r+E`4O9F|)(pt0gy%F53Hayg0+0SjvVm&XC z`2j{81kYF=oh-k+RA)}18en~yH{rZ8!{*wtJKd%o)8}|>lP^>@`(vG)5G)(|QJ*t^ z#HFN|rYS%2Kg%)-CXe*dE%#ih%3R6v>LKE&l=iIJ8Y#VxMgiKbTa74EM&EN$d;O!qJM+HvgY z`Fz>j)$0oGQ!*=G2^SLF;mY9nUbn7-qjgf&#QeyND1xvjes&d7nV;XsF}$moch2TC zHsVq~cBmQ5=5XXhNU4^HE#r`Nl^ojbS|=X7w})o6pU`JRnp6kYjcWlTE(85^OAVv_ zIvt@duN=sV@PC(neB9o`M~E{h`YPiLY7VCB>}ij`8efdL!yCKG!J74h@l!3yOIRzc zC{IZ??S3VQ8#S7{>eKae`idq5l_G&FdYk5{>O3XTlRuCDHv%bQr6jd!)?v!|Fhdb+IdJD zIx>XB7qjc+Y<3azh^nI%#WGWvRbWN3I?yO?vE-jf@dy=#<76#RUa;btO{5rOu#V6o zpAKy1#R`MXPvv7kORGb|Q_JG>4(V?NH@xqtaN)s*TlQ?WRK!wVI#a!g`MBfPZ`Z9V zE94=+ZIn+d2P?J@K1^RP)|Mq14_!Recr?7~&6TL*cko&~?7~wzDs)$U~VT(rq%W`f-vAv4*^tKysWV}k<%zpEb0k3%_4`6+YcAc|{EFDcX6 zW~Y2ek{GV>$o#T$1y=)Y-y0{VJdQI-X=^Yp1zsFpPCo5R?Sz0g4Ev;;+EzokHzqlp zSbw4GLG}CBM)*8q-{vs*0)JYo&HNt;i>7}(b!1KQ0NqPA3wSWn<|Hq7Xm~fmOsW=M zAuD56-0x)nISqQp(+U}W3Nkc$I-;XoUk#`EjHeGEfsD@KUA=y4J86({{AxhUfck3u zgF}X*VEjV7xux_e{{BL4-mS|JicLm8I|t1|Z^KY2hKNhJM;@x^-q7{Z)(0e{Dse<| zntW)Y(#F9(N#5jaN-gT38p10GJ0j5G6m?kt>gI^75_gELq<-p^T>_F(Qws*7r{9nS z7Ld~;l_642FyH)p_>54O@gEN!W5Avjs?2O)WRA2`%MLjg-fX8ytP~IQ%0{~yA-vWd zD#ZN=o1LhODrGlF_Vk&@gx`EqP-V53PTNvGh*#-uaj(F%4jJtT>s!K<(g7X<&VXMN zgh?b?llh~d>9mueZB2X~v+##(p!urryB$ONl>+>@R2A?*7rg9L+w&=%le{K~G0x>S zO@CmeyCAVdwbUu~XYx8+|86Q^8FrLW?l!Pb+t~N|i=gR%J-gYFd&&IXQ^r?8+Yvp% z8m>)%;lLf2ru1*AR56k8J_f4lF)b-Y&e0Q+_7X9E@O0TlrMNRIE_dj8s7&l`2G&9* zM`;HN5LuH1skJOv#t<^>r#S}+dWxT5Pw$#G?>I)+qlY$Ds={9Cy}q8>7B3wOV!pd0 z7oJ&%t+xCHKt-_H#Em?l{t=I6EfGs7jTC3lq@R?yco# z8O@&vGOtD0&7}}Q*qJb_b)s_~q-CQ|`?3k%LJ{YOx({Y|L}^wP7}A=PMP`_InVz4c zS6EL}IWk9DTC6wvW??5T+-RBCE@RC;l@`@(u#My(1qJl)lEgGt6g7mVel<5t5gVLr zz1qla`WMDG1{UwTht{tP!Zbg7p zkrD@HOD09Kc=?bOD}yvcE_RLu60X83T?D#pJ$bH6Je$rXGO>}F=A9Qe?(Jr`Dm^o$L7s?u0 zbsd&;B^{C0^2fk@Z+3mpghJ3Es;9P1&+D^UK4yJ}0zCyN?saaSRrL#*HH(6exc$(uP*?q_4ep@Y-&==tbDt+AIaexS7upt78<>-08lqXJ14@nZfa z{?xI(ZNWlNib_>?=t|J=>)zJK+Pbc78Q$>6Gp!hmJV}b0w=9H~>IQo?u5zQDpS%T-^W35FJQk(NbF&#ssB^-XE;MKmh!_2$>)w75=<3o&pLDR zSm?Isq*JDQcfI}62gcG$t*>S$h#J&{_7ma&Cf>L^S{RC}1uVl&o08J%Kf3mUyw zvxe8zWIPP(Q34z>>v$CvHQj9F;O3R;y)qFz;lxoU>r`CA-~=4hQ|#m@V&E~@%u@p- z$Jz}cq_@hj0A%oSUNt})`UrA?SFG@vEq6y#2$*{QZ#B?GLMW5e@flp3kM5G*l{7Z= zLSy?!G>jg^mp+FkLcoc0cTJ5!U;DQmQGmdkwz|C)s@scV6g!#NKu)R|io;A0@`kdYS7%>}t! zASK#=Ub`Yg^P0GtI&te*ZP_`MY0Rn(cM7AUj94xG+B|q-}em z_S+t@AO0isN?kQXqW+Hqv@%ONo_n3f8H4FbQ<#7)@Lg?`Y-L@Y{gyZmRY`Paa$uDO zg7sb9?Sk}-bM9Lky01QkA{fXl5_Q_biSA}tYlO(X!_r*9K8jxNR^RV>7wV<(2FKC0 zQN%q2k2E9p49*KzJWJEHe@HjR38)kg6V-B-FN|(C;yzd#zyAL5tn{{*=P#fn;Bi7g ztGda^Y;q>lRN|I5{+M*#chh0u>I-nZEmUo;mXDEUsm)W8?$;}2S3VtUT$r?ug;D+N z(1EA$iRwkgzydoM@UK+pRiHF9F^u)`>&IZ zdx9p{bmA?@86`&>Q|4QS=G<9$c+Axd4B@!Hl%I377Pp#KOi?a%rvg%#nLlk?kk`Fq zEaKr@BD37zi|(p}oSoMwn17UGNfTIH%(Ix9$SCfI1QkXKc&VZmf9N1e4pmWxP}cB= zu*t#i6aF`YSK|@GQcgf+0LoJ`R8w%+lR)J?T8DM1S+M0drU#lcneY}>#RSnkM76rC zO2SU{#m4!0^()4#Rf=Odh&j>cH9c!~CY^v4Urr?M1|IOEq!r-Mb}zEcU@aUh+M{e1 zb1qF4{o@0!%U-QU2~4|bHmRgOM`0Bz_&lx0^nJ_PX`WgCv~OUo+w1~Q3 ze#)NpgP;%H{b!|Ihi1l}S@urF7PO-BvcT0e`$f7vPBWrIl@NpDo|XthZ*@9fp=^+H zl4ihXk*7jR$zNw@V7PM8^n4Tk9>?bxVEx3drJEqTE3{JwJfN(g|IlBoO2bwj&F0># zx~+tVY$g(47=lb0qZLa4HnF2(cX8FSx&zfliuZA8LG{twP6nn?T zMGVK>mto~^fu>$fHRLR7A@=GZ91I=u$a!I@9n|9O-scEWjc{KIlMpQ)Nniyj6& z^SWdSlfK^PE!S~TEBPiI$J*x3U6k-#E*`V3ZT>Y#hL#Ojy$p6V>D? zjskc}p3a8{U5Jd&uITam$dWSzRT7_+y3oQVTdzF1fchJq7nNS6n!Tegw zQ^J>OF`I_JvT0v9?+9AjcTnwzRVd4b%;e5lHlYuhhXEkQs~&%&r&Q_~MZQ?io3s5~ z-i$c2oS};Ldm&eZf}a?S-lh>XUzu=1*-$RRU)&7b^?%Y#2gj@nwUn5nWh2xPE-6elkv?-5N5(t@jQnfAE1^oO@jVHPDqRQT{zR~Rh zUT*CaV`7srb<7MOX|kNAf918m5>Rp9N+@+#OK2m$hwEejo4+-o%%=wERn}IUU!n(8 zU_IHkO@bfSC5WU3@hBgYr?=CyNgD=O`SBtR3P(#P7*2c8$s_tRG(p))0E)Co$Qg!j zfXRj5gd3lBi1!(3NHad@Qo;J&wb6#1E5|nZos^b@esT7FYxRa)IJ|6t)+e0!d0Q)) z{LN5Ko3f|GEfkogs`Zf)40huD)qnJn0((e{MBqla zuxJRhIK76&mOfEoy20q~Zs~hJQBrUMnZGdy*J*1&7;U!a=q9VoKz$A^r;p0yI$QlQ z$$UBMv=SW04n)2BTyjtt7KDPF<|NyNIkwBeRO}#{7^!G;18A?0F`fTtCU0_=Q>oRS zww5Y_quI(p_j`otSSs4+ji4BCWZhhm-Qh?4Ypths4dHMo$Cu&Xi=7fm!BNi#J*^Xj z*sc540&2kB;`$bnnL6_F9tb!NuT;egK26KW^XqL2567E?^gKa-Rw$UYxp7EwvY4If zo)5C0xW{|~=j@Rp;zq4zY;ZI_gzI7wKJOqXpjKB4tNyH zl&dwF%m68ZF(U<)EWhj6b`2-bZgym~e)W(=im)X?W*GylK!>O^B3kQ6BAn(QxO-Kw zF>TRYkL31d(tQ~Ec)U$$*jQj7-1c4uSmri(0A*}Hv}dXs`QUcG4gr#K&QfF(ipmu` zPA_}79DqNW;Vpr6tp;{1{uoP#2_i&Fiqf@#{W5|6rR?wM@-9z9rwqo*Cje|&5ZkK-IWcxYVB3BNPvR7w2G9)#- z?F|shMM@*iqZ`vFP{D6D@Ct3$AmF^I-Ps6X%pYW5DtaZ78*d*$F*2Q1E-j$obEv26 z$a}j%B=xYo5XboOjVC?lZSv$)%ks(Qn&Jud_K^5|KdU6gu_%V@qb)1S#;KYxhsI)n zH1Tc>V`1`Ws7=WhB2}r&Z1~)mbGeu|y+>N7buEnJ=nQ4qrSU$?WDR$z<(k9n$k?$4{3zm8yPEAT_^E};?kJf2Y=(W(Zd}Fv zLy~Jq2^q_dC)kQt|Bu-$Hhxahre#Ig$o*IP0ueE5YMh)ZO8h%tLIdmR4)5b4tghuj z`pVnSb!3u>8v@`hm-EA}Kd&a)gsivxejGj!Q)w1J>naL&7V_PiMYD0 z#=%<>C`}QO35XJz=;x5})c(@cHq?E=!SQ0PhtiY(x~Hbir35=G@%I-5ZbvX}FA`&|$igU+2 zPZ_eAx`~$dcprJpg%i-pQXKn@coK*sx>{7NeeDO`9DX)cbh|2z^$o|`8gLT)jAooW zaHa3ohnxi{!aX}x7rkGLe|t%s{*gYxn1y&Aa0b0L(tf{0)i14*o_+MHnQUS))LH!( zPhPiog-OS5?o!2Jbd+)>m`H`PSxdONtTR(WZe{u};O$d|GD+F8AEBxs$+Mb3DK}SL zXP**NyA-g5-E*+g@IS>xlFF!u!7^aSgd6_5M=>wQdmTc32ENk!5VKTYhbyu^@v(G- z{{=M0SV!AHPE@elY9}>&1n?0s6kngp;bh5(8RAX27nW5J=hotD?-^+04Q|3C2Kp0> z9)RP>^KOGHIaBX(+3`>S5~N?gkag+UFqBt|R3&XsR6~ZQYOJvi@&Jd!j!FrVX>=E$ z-334WAO6@qoIu*VE2V$Br`y-DYlw0Hh13o#em#8Y;`<4Zq&$fI({;8j530(J*hW*B zjU;<7PNS0(yrwHY`}EQBIhC;)t!X`~+*k#XK8s1~{4m>*5 zl4eU=$ewJUHu2^VO^b;Jfu}Rq8Q)B~unL*;sS>DcC%6tu24F;+>pc9!Gub?`Wwi#O zdjKNgW*P{K}h;!jIS%bV+L6Z@5T%ayaB6vw8Kd|tEp{hFvX zR;>e1wUZ6Gw!d$>`AoMTUh6PcMKz%r!(1c*lrf8GT}oD8GUnet7EviPhR_Ce`11Dh zz!#kG(i48gt$wzp=Y{ky<(lGtQgk{?JntqsK{^uVa|1TwCzqFI{kHr#%6T0NV$JFgvmqcHbM9=S3Q4yfPG> z*1mSlI*!{woZn2$`@=Kwh3#q0)c>~ZFyW>Mkp}|ISVeQku06K(@Wp3cS7zV>7v~XKg=C8!( zHM`pS;=am)P4pxiziAqIw&?!Thq{WO5oQ8gJ#E-b{I1cAaX`cJz&DW^Evb(tQ%2?= z^k($=vG3n(LJrNRW2tpX#)yIM*#c%}nrzIw8Z3)pK9RhIjSX#+9B=oMpyq=tM_!3q zuGuonY(Y1qKE$=`4)G+3UtWD|V_ZBdoHvaduH^6O#U&A2Fv%IN-A#Er}8_pAWwbl=xCNn!|&sT4A;nexm6IH$Ok**GIxqy@{322x%v$8 zx+n2l&W4bD_)=b`^dz5G6r+b4Y$a|j`KCPf)%c>u{&#$=Z1CjojHAIH$Bk;rgq%ww zCGyM>b}zY2XA@WT+Fq9^*_|-Fk>!25Y{bCz&FYiSm^nu6Une4r2UiO-bd0k_W#}>E zs%5V+@tP=8kX zZ-L(V;}N=TPiOJI$R#oCdQ@WYfLag6=fj~MciE02R3ru-x<{T<*?i>tRp(_ej)pw) z^Irf7Ghg?$muj0p^V`>CbB1fF#%wL@(;5nJS{EUlRJ2D!e_2u^6}voB)eaj9`s|fW57%F!Nyd= z!L-#{djrtcP0?lNw}V?m3|^=K$}bZ<-WP6uq-^Q_pDWAvx>y@tz7i~2j%+aHN$nGS z)pBuGsVWB8M3j$vjUKtS7JH-p4#R7T2+o#N>UY>$hNZJ%Vd>EU`InIcdZ*h2xL!JS z<3);A+8v1oX9ZjIvEIsCpMmA;4Y33S8zmOH{#=L&zN4vj!j_IEwCjJFE*!Nx-p;x) zJa8pu(UB5$`^zLCjA&xb=C_ZwGpBoX>uWak^zECKkBG6^g5WZBAfQJB+*{E zYp3mc;AlpqYir&HuU(YZ*4r_^=T9+Z%#J#Q=M=*kFg z4}POT>Jkhi5y+z^)#^*de*$97QoYtYz@-SP{Akk_UG|D!4(!AnT6dNvW;tZy`=F74 zr<)0ZHQ?3fDE5z$SKuLfTHD2_@wOPMmxJ(y+bO2$x*JQklt7z(16!AvoA_tVJQBH2 z5?oBK|4cg4$E%_3j1&{)PLcax5biwBQX)=LEpkdeT4+QdhJwlCB9{r?H4vTG1bPM@YPBjUS8w7d(I;76k?>Mp$9DRUSOr;X+XQ$C{$};F2HR?h zK+ffG4zlpT{*vL4uypdhfRa=AMiMR5r@y0Y)`~iA(#F}*z0WgGE%k9iR@D$8o)3!H z_Ckw$a65rp0BpkzIOl}m+v(51cXbmQ*B_O~rY3B}f4N{y3t3I`(&|9?;~>SpzwCoZg1*Jpe4QyD;tvd9H_}kpxHkCoWMe?Pylm78 zwbDMkYqBfJ;w*BFw&Qjh`$WCRJ5Fgpj=14XIg)?#De3s1in6n_1}z!7fS&rphv+6Rhu>cT4cT6zDK3?3m1NfQ z&a3^|V5sh6-GyHZ4e%@FQ<{=`-6cj29F@QY<_VnUPkMKE!dag|Ce~t_c*$a{+Rb+v z_9?*67m(!IK%`bl`S$sF)UWkF;<7E^0MRL-n3risK{dP~YH?d(Yr1?4(oIgFh(s*1 znuNM@d%n6xex`)L-ZsIkUiQ2o2^WPefo+y>JIwo_!>p3 z3+hk!yl_5LywY~@d>0ZF_@%p?X$chfE{%2D&<{4swnr81?O}q^iqE@dS}UVtgnNQT ziUz4;id##(<-V_ZbC>jQA$mllmGxEZMr_9$g&A5S{>sN0bB>a``l;8xni0f(r&oECJAQmc_wJitCdE5OcOfVdvyYys zd8@ZPx*BeasOIHoXXpwzt9htfHoIdmkv?=tQPg)FvnL*GY=ZGs6YQ%AkXID4I>7h- zw=%pTe<-6i9QkTk&h(5aiToj=oGtk?(=-aWuO^5(8|xh;>5j8=NK1|b>Hh97=A(E$ zTqZBH9$04Se~LE1UL2xL*D0X?T;M&u5ziY9%A0J zU33Z1XGluqStR9ot$(QV&Mb%u;H}z!O=6?$bD?2{tar5)8W`u+(52>HK)ZP z>t1eqy+v(84q-YKb$=#vD1q;Of(pl%zkuFP?qXGszs1nNLULVsYDOIz)7N}R5TfPw zP^TDiWPW*%^K8Kt3HrWf&_po9m|0F(G~`5Gfc|ap6qwBPTIuj(9B_i&a4%z1(&*A0 z4okli(!`0ZTeU07#q|1U!_I5E@cPn;*x(n%LO0(6M8N|H)(>c(Tjxb$%gbRIp7+eD zsC#Rk&TqzBC>Gm4st|Sdmo?WT_FNQUHW7)7@E%D*DQ*y{tvmnmCk8J0hd+2n&x$gE zA7`4L6+b9%VS`rOpW=h}i>xQVZQ{(B8&+icarqNne1@eE_0lcr4*;8UY=4W$hVpJC0XyPWHqut~eLlq{R&yK( zA^~o*)}~K01U2UT@TV)?hlz)j^rd9WQ(C6q%G{JL8B=s@9^Xda*YD*+Wux9)Z__K-Ej6pbvk(AW{#Skl=GUnyB z<-5+so+63ZNZ+k3zXqV^`S%TT5GHPb) zOzaSyJzlD&CVTJOB;v_NGAa;#Jx&ctUU9~VUxQugLaQKDmDZ zW;Qgr;(DaNIdX2KoQc>S@v?|pn!SY&RpY#!pZaer2@yfBrxjB?EPNT9uCygL3So4f zB7o|s{!(fzM~qvXZbU**K1kxTmx>&s|60eC=7^g#Jkl93-7Ba|-2(HYsb&E~X&*rT zsXD>q5%E5#cW)VZ{>{uI95tkZhbBqZA{5r;qPg?JnmIvk@2+Ut!gt4uHs^EtVN)zb zSGx&39KF(dz;cLJxCUeqMlZ!*^5H23lb3#e?4qPD)*HAxVut&L zl0Sj;GBryWiHbUm`YZ0XXn12X#Hc)p&$rD~F;UKZNFwWDp7fo*I#ZAJoCb>VrWKTZ zi(mrn6_x;h=qP3r)DKD~3TWTgiuffRi zGz)tl@sy2c6{?$(6TRB{8bD>5TlBG|1Ku9v{6W&u|B zrd$@bqJ!@kq1i7*K`SWhjAwPa96?fiM{`4}Pa?>~510BgO1=zLxm6Tkbn3e(hE0T@ zl>#tlCG6or#l4X8V##9^z5t`wtigu{@f8CrcS(>oN$^=INCt_PUZ*jj5tSH!s(W6D zOd_Fh;VKnuIKSckcJf14kr~qA)gv&b$db%hw5|K?#djnZkgDW)nyx&s2!m4#4inMU z1hiK?_>-n93%ULz=DpGSURJUT8_ZP`TsnCn>Jg;LwDs?|ljeNDf?c>)&t0Rgsllw% z=2??&CHK5=>M(Cp43f7nw^wMfF8Ge-mm+NgbKsxGqC;Ip0jA}Qf3qq7EV<+1%ExEF r`F3cK6HjAE6n9QjIdl9^PW~@2s)R;&Qp5W2Oq4y8t1ye{@7(_YN;#d* literal 0 HcmV?d00001 From 7e734d5b2605f9c67c8dbf9e91a00dc2dbb94c97 Mon Sep 17 00:00:00 2001 From: xdssio Date: Wed, 24 Aug 2022 11:14:37 +0200 Subject: [PATCH 5/6] add filename --- packages/vaex-core/vaex/vision.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/vaex-core/vaex/vision.py b/packages/vaex-core/vaex/vision.py index 4b3b64726a..ba76d12c39 100644 --- a/packages/vaex-core/vaex/vision.py +++ b/packages/vaex-core/vaex/vision.py @@ -4,9 +4,7 @@ import os import pathlib import functools -import collections import numpy as np -import matplotlib.colors import warnings import io import vaex @@ -15,7 +13,6 @@ try: import PIL import base64 - except: PIL = vaex.utils.optional_import("PIL.Image", modules="pillow") @@ -82,6 +79,12 @@ def open(path, suffix=None): return df +@vaex.register_function(scope='vision') +def filename(images): + images = [image.filename if hasattr(image, 'filename') else None for image in images] + return np.array(images, dtype="O") + + @vaex.register_function(scope='vision') def resize(images, size, resample=3, **kwargs): images = [image.resize(size, resample=resample, **kwargs) for image in images] From 06968ce56a188a3ced3807a1551d559beb971dda Mon Sep 17 00:00:00 2001 From: xdssio Date: Wed, 24 Aug 2022 11:51:23 +0200 Subject: [PATCH 6/6] added tests --- tests/ml/vision_test.py | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/tests/ml/vision_test.py b/tests/ml/vision_test.py index 1acb26ea7b..723b56e3a8 100644 --- a/tests/ml/vision_test.py +++ b/tests/ml/vision_test.py @@ -4,9 +4,36 @@ basedir = 'tests/data/images' -def test_image_open(): +def test_vision_conversions(): df = vaex.vision.open(basedir) - assert df.shape == (16, 2) + df['image_bytes'] = df['image'].vision.to_bytes() + df['image_str'] = df['image'].vision.to_str() + df['image_array'] = df['image'].vision.resize((10, 10)).vision.to_numpy() + + assert isinstance(df['image_bytes'].vision.from_bytes().values[0], PIL.Image.Image) + assert isinstance(df['image_str'].vision.from_str().values[0], PIL.Image.Image) + assert isinstance(df['image_array'].vision.from_numpy().values[0], PIL.Image.Image) + + assert isinstance(df['image_bytes'].vision.infer().values[0], PIL.Image.Image) + assert isinstance(df['image_str'].vision.infer().values[0], PIL.Image.Image) + assert isinstance(df['image_array'].vision.infer().values[0], PIL.Image.Image) + assert isinstance(df['path'].vision.infer().values[0], PIL.Image.Image) + + +def test_vision_open(): + df = vaex.vision.open(basedir) + assert df.shape == (4, 2) + assert vaex.vision.open(basedir + '/dogs').shape == (2, 2) + assert vaex.vision.open(basedir + '/dogs/dog*').shape == (2, 2) + assert vaex.vision.open(basedir + '/dogs/dog.2423.jpg').shape == (1, 2) + assert vaex.vision.open([basedir + '/dogs/dog.2423.jpg', basedir + '/cats/cat.4865.jpg']).shape == (2, 2) + assert 'path' in df + assert 'image' in df + + +def test_vision(): + df = vaex.vision.open(basedir) + assert df.shape == (4, 2) assert isinstance(df.image.tolist()[0], PIL.Image.Image) - assert df.image.vision.to_numpy().shape == (16, 261, 350, 3) - assert df.image.vision.resize((8, 4)).vision.to_numpy().shape == (16, 4, 8, 3) + assert df.image.vision.to_numpy().shape == (4, 261, 350, 3) + assert df.image.vision.resize((8, 4)).vision.to_numpy().shape == (4, 4, 8, 3)