diff --git a/data_prototype/artist.py b/data_prototype/artist.py new file mode 100644 index 0000000..d2f2db1 --- /dev/null +++ b/data_prototype/artist.py @@ -0,0 +1,236 @@ +from bisect import insort +from typing import Sequence + +import numpy as np + +from .containers import DataContainer, ArrayContainer, DataUnion +from .description import Desc, desc_like +from .conversion_edge import Edge, Graph, TransformEdge + + +class Artist: + required_keys: dict[str, Desc] + + # defaults? + def __init__( + self, container: DataContainer, edges: Sequence[Edge] | None = None, **kwargs + ): + kwargs_cont = ArrayContainer(**kwargs) + self._container = DataUnion(container, kwargs_cont) + + edges = edges or [] + self._visible = True + self._graph = Graph(edges) + self._clip_box: DataContainer = ArrayContainer( + {"x": "parent", "y": "parent"}, + **{"x": np.asarray([0, 1]), "y": np.asarray([0, 1])} + ) + + def draw(self, renderer, graph: Graph) -> None: + return + + def set_clip_box(self, container: DataContainer) -> None: + self._clip_box = container + + def get_clip_box(self, container: DataContainer) -> DataContainer: + return self._clip_box + + def get_visible(self): + return self._visible + + def set_visible(self, visible): + self._visible = visible + + +class CompatibilityArtist: + """A compatibility shim to ducktype as a classic Matplotlib Artist. + + At this time features are implemented on an "as needed" basis, and many + are only implemented insofar as they do not fail, not necessarily providing + full functionality of a full MPL Artist. + + The idea is to keep the new Artist class as minimal as possible. + As features are added this may shrink. + + The main thing we are trying to avoid is the reliance on the axes/figure + + Ultimately for useability, whatever remains shimmed out here may be rolled in as + some form of gaurded option to ``Artist`` itself, but a firm dividing line is + useful for avoiding accidental dependency. + """ + + def __init__(self, artist: Artist): + self._artist = artist + + self._axes = None + self.figure = None + self._clippath = None + self._visible = True + self.zorder = 2 + self._graph = Graph([]) + + @property + def axes(self): + return self._axes + + @axes.setter + def axes(self, ax): + self._axes = ax + + if self._axes is None: + self._graph = Graph([]) + return + + desc: Desc = Desc(("N",), coordinates="data") + xy: dict[str, Desc] = {"x": desc, "y": desc} + self._graph = Graph( + [ + TransformEdge( + "data", + xy, + desc_like(xy, coordinates="axes"), + transform=self._axes.transData - self._axes.transAxes, + ), + TransformEdge( + "axes", + desc_like(xy, coordinates="axes"), + desc_like(xy, coordinates="display"), + transform=self._axes.transAxes, + ), + ], + aliases=(("parent", "axes"),), + ) + + def set_figure(self, fig): + self.figure = fig + + def is_transform_set(self): + return True + + def get_mouseover(self): + return False + + def get_clip_path(self): + self._clippath + + def set_clip_path(self, path): + self._clippath = path + + def get_animated(self): + return False + + def get_visible(self): + return self._visible + + def set_visible(self, visible): + self._visible = visible + + def draw(self, renderer, graph=None): + if not self.get_visible(): + return + + if graph is None: + graph = Graph([]) + self._artist.draw(renderer, graph + self._graph) + + +class CompatibilityAxes: + """A compatibility shim to add to traditional matplotlib axes. + + At this time features are implemented on an "as needed" basis, and many + are only implemented insofar as they do not fail, not necessarily providing + full functionality of a full MPL Artist. + + The idea is to keep the new Artist class as minimal as possible. + As features are added this may shrink. + + The main thing we are trying to avoid is the reliance on the axes/figure + + Ultimately for useability, whatever remains shimmed out here may be rolled in as + some form of gaurded option to ``Artist`` itself, but a firm dividing line is + useful for avoiding accidental dependency. + """ + + def __init__(self, axes): + self._axes = axes + self.figure = None + self._clippath = None + self._visible = True + self.zorder = 2 + self._children: list[tuple[float, Artist]] = [] + + @property + def axes(self): + return self._axes + + @axes.setter + def axes(self, ax): + self._axes = ax + + if self._axes is None: + self._graph = Graph([]) + return + + desc: Desc = Desc(("N",), coordinates="data") + xy: dict[str, Desc] = {"x": desc, "y": desc} + self._graph = Graph( + [ + TransformEdge( + "data", + xy, + desc_like(xy, coordinates="axes"), + transform=self._axes.transData - self._axes.transAxes, + ), + TransformEdge( + "axes", + desc_like(xy, coordinates="axes"), + desc_like(xy, coordinates="display"), + transform=self._axes.transAxes, + ), + ], + aliases=(("parent", "axes"),), + ) + + def set_figure(self, fig): + self.figure = fig + + def is_transform_set(self): + return True + + def get_mouseover(self): + return False + + def get_clip_path(self): + self._clippath + + def set_clip_path(self, path): + self._clippath = path + + def get_animated(self): + return False + + def draw(self, renderer, graph=None): + if not self.visible: + return + if graph is None: + graph = Graph([]) + + graph = graph + self._graph + + for _, c in self._children: + c.draw(renderer, graph) + + def add_artist(self, artist, zorder=1): + insort(self._children, (zorder, artist), key=lambda x: x[0]) + + def set_xlim(self, min_=None, max_=None): + self.axes.set_xlim(min_, max_) + + def set_ylim(self, min_=None, max_=None): + self.axes.set_ylim(min_, max_) + + def get_visible(self): + return self._visible + + def set_visible(self, visible): + self._visible = visible diff --git a/data_prototype/containers.py b/data_prototype/containers.py index 9640e01..fd45106 100644 --- a/data_prototype/containers.py +++ b/data_prototype/containers.py @@ -82,14 +82,15 @@ class NoNewKeys(ValueError): ... class ArrayContainer: - def __init__(self, **data): + def __init__(self, coordinates: dict[str, str] | None = None, /, **data): + coordinates = coordinates or {} self._data = data self._cache_key = str(uuid.uuid4()) self._desc = { k: ( - Desc(v.shape, v.dtype) + Desc(v.shape, coordinates.get(k, "auto")) if isinstance(v, np.ndarray) - else Desc((), type(v)) + else Desc(()) ) for k, v in data.items() } @@ -117,7 +118,7 @@ def update(self, **data): class RandomContainer: def __init__(self, **shapes): - self._desc = {k: Desc(s, np.dtype(float)) for k, s in shapes.items()} + self._desc = {k: Desc(s) for k, s in shapes.items()} def query( self, @@ -171,7 +172,7 @@ def __init__( def _split(input_dict): out = {} for k, (shape, func) in input_dict.items(): - self._desc[k] = Desc(shape, np.dtype(float)) + self._desc[k] = Desc(shape) out[k] = func return out @@ -196,7 +197,7 @@ def query( # if hash_key in self._cache: # return self._cache[hash_key], hash_key - desc = Desc(("N",), np.dtype("f8")) + desc = Desc(("N",)) xy = {"x": desc, "y": desc} data_lim = graph.evaluator( desc_like(xy, coordinates="data"), @@ -243,8 +244,8 @@ def __init__(self, raw_data, num_bins: int): self._raw_data = raw_data self._num_bins = num_bins self._desc = { - "edges": Desc((num_bins + 1 + 2,), np.dtype(float)), - "density": Desc((num_bins + 2,), np.dtype(float)), + "edges": Desc((num_bins + 1 + 2,)), + "density": Desc((num_bins + 2,)), } self._full_range = (raw_data.min(), raw_data.max()) self._cache: MutableMapping[Union[str, int], Any] = LFUCache(64) @@ -256,7 +257,7 @@ def query( ) -> Tuple[Dict[str, Any], Union[str, int]]: dmin, dmax = self._full_range - desc = Desc(("N",), np.dtype("f8")) + desc = Desc(("N",)) xy = {"x": desc, "y": desc} data_lim = graph.evaluator( desc_like(xy, coordinates="data"), @@ -302,8 +303,8 @@ def __init__(self, series: pd.Series, *, index_name: str, col_name: str): self._index_name = index_name self._col_name = col_name self._desc = { - index_name: Desc((len(series),), series.index.dtype), - col_name: Desc((len(series),), series.dtype), + index_name: Desc((len(series),)), + col_name: Desc((len(series),)), } self._hash_key = str(uuid.uuid4()) @@ -343,9 +344,9 @@ def __init__( self._desc: Dict[str, Desc] = {} if self._index_name is not None: - self._desc[self._index_name] = Desc((len(df),), df.index.dtype) + self._desc[self._index_name] = Desc((len(df),)) for col, out in self._col_name_dict.items(): - self._desc[out] = Desc((len(df),), df[col].dtype) + self._desc[out] = Desc((len(df),)) self._hash_key = str(uuid.uuid4()) diff --git a/data_prototype/conversion_edge.py b/data_prototype/conversion_edge.py index af19c98..9091783 100644 --- a/data_prototype/conversion_edge.py +++ b/data_prototype/conversion_edge.py @@ -1,10 +1,13 @@ +from __future__ import annotations + from collections.abc import Sequence from typing import Callable from dataclasses import dataclass +from queue import PriorityQueue from typing import Any import numpy as np -from data_prototype.containers import Desc +from data_prototype.description import Desc, desc_like from matplotlib.transforms import Transform @@ -14,6 +17,7 @@ class Edge: name: str input: dict[str, Desc] output: dict[str, Desc] + weight: float = 1 invertable: bool = True def evaluate(self, input: dict[str, Any]) -> dict[str, Any]: @@ -21,7 +25,7 @@ def evaluate(self, input: dict[str, Any]) -> dict[str, Any]: @property def inverse(self) -> "Edge": - return Edge(self.name + "_r", self.output, self.input) + return Edge(self.name + "_r", self.output, self.input, self.weight) @dataclass @@ -29,31 +33,89 @@ class SequenceEdge(Edge): edges: Sequence[Edge] = () @classmethod - def from_edges(cls, name: str, edges: Sequence[Edge], output: dict[str, Desc]): + def from_edges( + cls, + name: str, + edges: Sequence[Edge], + output: dict[str, Desc], + weight: float | None = None, + ): input = {} intermediates = {} invertable = True + edge_sum = 0 for edge in edges: + edge_sum += edge.weight input |= {k: v for k, v in edge.input.items() if k not in intermediates} intermediates |= edge.output if not edge.invertable: invertable = False - return cls(name, input, output, invertable, edges) + + if weight is None: + weight = edge_sum + + return cls(name, input, output, weight, invertable, edges) def evaluate(self, input: dict[str, Any]) -> dict[str, Any]: for edge in self.edges: - print(input) input |= edge.evaluate({k: input[k] for k in edge.input}) - print(input) return {k: input[k] for k in self.output} @property def inverse(self) -> "SequenceEdge": return SequenceEdge.from_edges( - self.name + "_r", [e.inverse for e in self.edges[::-1]], self.input + self.name + "_r", + [e.inverse for e in self.edges[::-1]], + self.input, + self.weight, ) +@dataclass +class CoordinateEdge(Edge): + """Change coordinates without changing values""" + + @classmethod + def from_coords( + cls, name: str, input: dict[str, Desc | str], output: str, weight: float = 1 + ): + # dtype/shape is reductive here, but I like the idea of being able to just + # supply only the input/output coordinates for many things + # could also see lowering default weight for this edge, but just defaulting everything to 1 for now + inp = { + k: v if isinstance(v, Desc) else Desc(("N",), v) for k, v in input.items() + } + outp = {k: desc_like(v, coordinates=output) for k, v in inp.items()} + + return cls(name, inp, outp, weight) + + @property + def inverse(self) -> Edge: + return Edge(f"{self.name}_r", self.output, self.input, self.weight) + + +@dataclass +class DefaultEdge(Edge): + """Provide default values with a high weight""" + + weight = 1e6 + value: Any = None + + @classmethod + def from_default_value( + cls, + name: str, + key: str, + output: Desc, + value: Any, + weight=1e6, + ) -> "DefaultEdge": + return cls(name, {}, {key: output}, weight, invertable=False, value=value) + + def evaluate(self, input: dict[str, Any]) -> dict[str, Any]: + return {k: self.value for k in self.output} + + @dataclass class FuncEdge(Edge): # TODO: more explicit callable boundaries? @@ -67,6 +129,7 @@ def from_func( func: Callable, input: str | dict[str, Desc], output: str | dict[str, Desc], + weight: float = 1, inverse: Callable | None = None, ): # dtype/shape is reductive here, but I like the idea of being able to just @@ -75,11 +138,11 @@ def from_func( import inspect input_vars = inspect.signature(func).parameters.keys() - input = {k: Desc(("N",), np.dtype("f8"), input) for k in input_vars} + input = {k: Desc(("N",), input) for k in input_vars} if isinstance(output, str): - output = {k: Desc(("N",), np.dtype("f8"), output) for k in input.keys()} + output = {k: Desc(("N",), output) for k in input.keys()} - return cls(name, input, output, inverse is not None, func, inverse) + return cls(name, input, output, weight, inverse is not None, func, inverse) def evaluate(self, input: dict[str, Any]) -> dict[str, Any]: res = self.func(**{k: input[k] for k in self.input}) @@ -90,8 +153,10 @@ def evaluate(self, input: dict[str, Any]) -> dict[str, Any]: return res elif isinstance(res, tuple): if len(res) != len(self.output): + if len(self.output) == 1: + return {k: res for k in self.output} raise RuntimeError( - f"Expected {len(self.output)} return values, got {len(res)}" + f"Expected {len(self.output)} return values, got {len(res)} in {self.name}" ) return {k: v for k, v in zip(self.output, res)} elif len(self.output) == 1: @@ -100,8 +165,17 @@ def evaluate(self, input: dict[str, Any]) -> dict[str, Any]: @property def inverse(self) -> "FuncEdge": + + if self.inverse_func is None: + raise RuntimeError("Trying to invert a non-invertable edge") + return FuncEdge.from_func( - self.name + "_r", self.inverse_func, self.output, self.input, self.func + self.name + "_r", + self.inverse_func, + self.output, + self.input, + self.weight, + self.func, ) @@ -127,67 +201,161 @@ def evaluate(self, input: dict[str, Any]) -> dict[str, Any]: @property def inverse(self) -> "TransformEdge": + if self.transform is None: + raise RuntimeError("Trying to invert a non-invertable edge") + if isinstance(self.transform, Callable): return TransformEdge( self.name + "_r", self.output, self.input, + self.weight, True, lambda: self.transform().inverted(), ) return TransformEdge( - self.name + "_r", self.output, self.input, True, self.transform.inverted() + self.name + "_r", + self.output, + self.input, + self.weight, + True, + self.transform.inverted(), ) class Graph: - def __init__(self, edges: Sequence[Edge]): - self._edges = edges - # TODO: precompute some internal representation? - # - Nodes between edges, potentially in discrete subgraphs - # - Inversions are not included right now + def __init__( + self, edges: Sequence[Edge], aliases: tuple[tuple[str, str], ...] = () + ): + self._edges = tuple(edges) + self._aliases = aliases + + self._subgraphs: list[tuple[set[str], list[Edge]]] = [] + for edge in self._edges: + keys = set(edge.input) | set(edge.output) + + overlapping = [] + + for n, (sub_keys, sub_edges) in enumerate(self._subgraphs): + if keys & sub_keys: + overlapping.append(n) + + if not overlapping: + self._subgraphs.append((keys, [edge])) + elif len(overlapping) == 1: + s = self._subgraphs[overlapping[0]][0] + s |= keys + self._subgraphs[overlapping[0]][1].append(edge) + else: + edges_combined = [edge] + for n in overlapping: + keys |= self._subgraphs[n][0] + edges_combined.extend(self._subgraphs[n][1]) + for n in overlapping[::-1]: + self._subgraphs.pop(n) + self._subgraphs.append((keys, edges_combined)) + + def _resolve_alias(self, coord: str) -> str: + while True: + for coa, cob in self._aliases: + if coord == coa: + coord = cob + break + else: + break + return coord def evaluator(self, input: dict[str, Desc], output: dict[str, Desc]) -> Edge: - # May wish to solve for each output independently - # Probably can be smarter here and prune more effectively. - q: list[tuple[dict[str, Desc], tuple[Edge, ...]]] = [(input, ())] - - def trace(x: dict[str, Desc]) -> tuple[tuple[str, str], ...]: - return tuple(sorted([(k, v.coordinates) for k, v in x.items()])) - - explored: set[tuple[tuple[str, str], ...]] = set() - explored.add(trace(input)) - edges = () - while q: - v, edges = q.pop() - if Desc.compatible(v, output): - break - for e in self._edges: - if Desc.compatible(v, e.input): - w = (v | e.output, (*edges, e)) - w_trace = trace(w[0]) - if w_trace in explored: - # This may need to be more explicitly checked... - # May not accurately be checking what we consider "in" - continue - explored.add(w_trace) - q.append(w) - else: - # TODO: case where non-linear solving is needed - raise NotImplementedError( - "This may be possible, but is not a simple case already considered" - ) - if len(edges) == 0: + out_edges = [] + + for sub_keys, sub_edges in self._subgraphs: + if not (sub_keys & set(output) or sub_keys & set(input)): + continue + + output_subset = {k: v for k, v in output.items() if k in sub_keys} + sub_edges = sorted(sub_edges, key=lambda x: x.weight) + + @dataclass + class Node: + weight: float + desc: dict[str, Desc] + prev_node: Node | None = None + edge: Edge | None = None + + def __le__(self, other): + return self.weight <= other.weight + + def __lt__(self, other): + return self.weight < other.weight + + def __ge__(self, other): + return self.weight >= other.weight + + def __gt__(self, other): + return self.weight > other.weight + + q: PriorityQueue[Node] = PriorityQueue() + q.put(Node(0, input)) + + best: Node = Node(np.inf, {}) + while not q.empty(): + n = q.get() + if n.weight > best.weight: + continue + if Desc.compatible(n.desc, output_subset, aliases=self._aliases): + if n.weight < best.weight: + best = n + continue + for e in sub_edges: + if Desc.compatible(n.desc, e.input, aliases=self._aliases): + d = n.desc | e.output + w = n.weight + e.weight + + q.put(Node(w, d, n, e)) + if np.isinf(best.weight): + # TODO: case where non-linear solving is needed + # this plotting is in here for debugging purposes, it should be removed at some point + import matplotlib.pyplot as plt + + self.visualize(input) + plt.show() + raise NotImplementedError( + "This may be possible, but is not a simple case already considered" + ) + + edges = [] + n = best + while n.prev_node is not None: + edges.insert(0, n.edge) + n = n.prev_node + if len(edges) == 0: + continue + elif len(edges) == 1: + out_edges.append(edges[0]) + else: + out_edges.append(SequenceEdge.from_edges("eval", edges, output_subset)) + + found_outputs = set() + for out in out_edges: + found_outputs |= set(out.output) + if missing := set(output) - found_outputs: + import matplotlib.pyplot as plt + + self.visualize(input) + plt.show() + raise RuntimeError(f"Could not find path to resolve all outputs: {missing}") + + if len(out_edges) == 0: return Edge("noop", input, output) - elif len(edges) == 1: - return edges[0] - else: - return SequenceEdge.from_edges("eval", edges, output) + if len(out_edges) == 1: + return out_edges[0] + return SequenceEdge.from_edges("eval", out_edges, output) def visualize(self, input: dict[str, Desc] | None = None): import networkx as nx import matplotlib.pyplot as plt + from pprint import pformat def node_format(x): @@ -196,33 +364,49 @@ def node_format(x): G = nx.DiGraph() if input is not None: - q: list[dict[str, Desc]] = [input] - explored: set[tuple[tuple[str, str], ...]] = set() - explored.add(tuple(sorted(((k, v.coordinates) for k, v in q[0].items())))) - G.add_node(node_format(q[0])) - while q: - n = q.pop() - for e in self._edges: - if Desc.compatible(n, e.input): - w = n | e.output - if node_format(w) not in G: - G.add_node(node_format(w)) - explored.add( - tuple( - sorted(((k, v.coordinates) for k, v in w.items())) + + for _, edges in self._subgraphs: + q: list[dict[str, Desc]] = [input] + explored: set[tuple[tuple[str, str], ...]] = set() + explored.add( + tuple(sorted(((k, v.coordinates) for k, v in q[0].items()))) + ) + G.add_node(node_format(q[0])) + while q: + n = q.pop() + for e in edges: + if Desc.compatible(n, e.input): + w = n | e.output + if node_format(w) not in G: + G.add_node(node_format(w)) + explored.add( + tuple( + sorted( + ((k, v.coordinates) for k, v in w.items()) + ) + ) ) - ) - q.append(w) - if node_format(w) != node_format(n): - G.add_edge(node_format(n), node_format(w), name=e.name) + q.append(w) + if node_format(w) != node_format(n): + G.add_edge(node_format(n), node_format(w), name=e.name) else: + # don't bother separating subgraphs,as the end result is exactly the same here for edge in self._edges: G.add_edge( node_format(edge.input), node_format(edge.output), name=edge.name ) - pos = nx.planar_layout(G) + try: + pos = nx.planar_layout(G) + except Exception: + pos = nx.circular_layout(G) plt.figure() nx.draw(G, pos=pos, with_labels=True) nx.draw_networkx_edge_labels(G, pos=pos) # plt.show() + + def __add__(self, other: Graph) -> Graph: + aself = {k: v for k, v in self._aliases} + aother = {k: v for k, v in other._aliases} + aliases = tuple((aself | aother).items()) + return Graph(self._edges + other._edges, aliases) diff --git a/data_prototype/description.py b/data_prototype/description.py index a963a92..bdaf183 100644 --- a/data_prototype/description.py +++ b/data_prototype/description.py @@ -1,7 +1,5 @@ from dataclasses import dataclass -from typing import TypeAlias, Tuple, Union - -import numpy as np +from typing import TypeAlias, Tuple, Union, overload ShapeSpec: TypeAlias = Tuple[Union[str, int], ...] @@ -16,8 +14,7 @@ class Desc: # - what is the relative size to the other variable values (N vs N+1) # We are probably going to have to implement a DSL for this (😞) shape: ShapeSpec - dtype: np.dtype - coordinates: str = "naive" + coordinates: str = "auto" @staticmethod def validate_shapes( @@ -106,28 +103,51 @@ def validate_shapes( return None @staticmethod - def compatible(a: dict[str, "Desc"], b: dict[str, "Desc"]) -> bool: + def compatible( + a: dict[str, "Desc"], + b: dict[str, "Desc"], + aliases: tuple[tuple[str, str], ...] = (), + ) -> bool: """Determine if ``a`` is a valid input for ``b``. Note: ``a`` _may_ have additional keys. """ + + def resolve_aliases(coord): + while True: + for coa, cob in aliases: + if coord == coa: + coord = cob + break + else: + break + return coord + try: Desc.validate_shapes(b, a) except (KeyError, ValueError): return False for k, v in b.items(): - if a[k].coordinates != v.coordinates: + if resolve_aliases(a[k].coordinates) != resolve_aliases(v.coordinates): return False return True -def desc_like(desc, shape=None, dtype=None, coordinates=None): +@overload +def desc_like(desc: Desc, shape=None, coordinates=None) -> Desc: ... + + +@overload +def desc_like( + desc: dict[str, Desc], shape=None, coordinates=None +) -> dict[str, Desc]: ... + + +def desc_like(desc, shape=None, coordinates=None): if isinstance(desc, dict): - return {k: desc_like(v, shape, dtype, coordinates) for k, v in desc.items()} + return {k: desc_like(v, shape, coordinates) for k, v in desc.items()} if shape is None: shape = desc.shape - if dtype is None: - dtype = desc.dtype if coordinates is None: coordinates = desc.coordinates - return Desc(shape, dtype, coordinates) + return Desc(shape, coordinates) diff --git a/data_prototype/image.py b/data_prototype/image.py new file mode 100644 index 0000000..c8821fe --- /dev/null +++ b/data_prototype/image.py @@ -0,0 +1,113 @@ +import numpy as np + +import matplotlib as mpl +import matplotlib.colors as mcolors +import matplotlib.transforms as mtransforms + +from .artist import Artist +from .description import Desc, desc_like +from .conversion_edge import FuncEdge, Graph, CoordinateEdge + + +def _interpolate_nearest(image, x, y): + magnification = 1 # TODO + l, r = x + width = int(((round(r) + 0.5) - (round(l) - 0.5)) * magnification) + + xpix = np.digitize(np.arange(width), np.linspace(0, r - l, image.shape[1] + 1)) + + b, t = y + height = int(((round(t) + 0.5) - (round(b) - 0.5)) * magnification) + ypix = np.digitize(np.arange(height), np.linspace(0, t - b, image.shape[0] + 1)) + + out = np.empty((height, width, 4)) + + out[:, :, :] = image[ + ypix[:, None].clip(0, image.shape[0] - 1), + xpix[None, :].clip(0, image.shape[1] - 1), + :, + ] + return out + + +class Image(Artist): + def __init__(self, container, edges=None, norm=None, cmap=None, **kwargs): + super().__init__(container, edges, **kwargs) + if norm is None: + norm = mcolors.Normalize() + if cmap is None: + cmap = mpl.colormaps["viridis"] + self.norm = norm + self.cmap = cmap + + arrdesc = Desc(("M", "N")) + + self._interpolation_edge = FuncEdge.from_func( + "interpolate_nearest_rgba", + _interpolate_nearest, + { + "image": Desc(("M", "N", 4), coordinates="rgba"), + "x": Desc(("X",), coordinates="display"), + "y": Desc(("Y",), coordinates="display"), + }, + {"image": Desc(("O", "P", 4), coordinates="rgba_resampled")}, + ) + + self._edges += [ + CoordinateEdge.from_coords("xycoords", {"x": "auto", "y": "auto"}, "data"), + CoordinateEdge.from_coords( + "image_coords", {"image": Desc(("M", "N"), "auto")}, "data" + ), + FuncEdge.from_func( + "image_norm", + lambda image: self.norm(image), + {"image": desc_like(arrdesc, coordinates="data_resampled")}, + {"image": desc_like(arrdesc, coordinates="norm")}, + ), + FuncEdge.from_func( + "image_cmap", + lambda image: self.cmap(image), + {"image": desc_like(arrdesc, coordinates="norm")}, + {"image": Desc(("M", "N", 4), coordinates="rgba")}, + ), + FuncEdge.from_func( + "image_display", + lambda image: (image * 255).astype(np.uint8), + {"image": Desc(("O", "P", 4), "rgba_resampled")}, + {"image": Desc(("O", "P", 4), "display")}, + ), + self._interpolation_edge, + ] + + self._graph = Graph(self._edges, (("data", "data_resampled"),)) + + def draw(self, renderer, graph: Graph) -> None: + if not self.get_visible(): + return + g = graph + self._graph + conv = g.evaluator( + self._container.describe(), + { + "image": Desc(("O", "P", 4), "display"), + "x": Desc(("X",), "display"), + "y": Desc(("Y",), "display"), + }, + ) + query, _ = self._container.query(g) + evald = conv.evaluate(query) + image = evald["image"] + x = evald["x"] + y = evald["y"] + + clip_conv = g.evaluator( + self._clip_box.describe(), + {"x": Desc(("N",), "display"), "y": Desc(("N",), "display")}, + ) + clip_query, _ = self._clip_box.query(g) + clipx, clipy = clip_conv.evaluate(clip_query).values() + + gc = renderer.new_gc() + gc.set_clip_rectangle( + mtransforms.Bbox.from_extents(clipx[0], clipy[0], clipx[1], clipy[1]) + ) + renderer.draw_image(gc, x[0], y[0], image) # TODO vector backend transforms diff --git a/data_prototype/line.py b/data_prototype/line.py new file mode 100644 index 0000000..026cb10 --- /dev/null +++ b/data_prototype/line.py @@ -0,0 +1,125 @@ +import matplotlib.path as mpath +import matplotlib.colors as mcolors +import matplotlib.lines as mlines +import matplotlib.markers as mmarkers +import matplotlib.transforms as mtransforms +import numpy as np + +from .artist import Artist +from .description import Desc +from .conversion_edge import Graph, CoordinateEdge, DefaultEdge + + +class Line(Artist): + def __init__(self, container, edges=None, **kwargs): + super().__init__(container, edges, **kwargs) + + scalar = Desc((), "display") # ... this needs thinking... + + edges = [ + CoordinateEdge.from_coords("xycoords", {"x": "auto", "y": "auto"}, "data"), + CoordinateEdge.from_coords("color", {"color": Desc(())}, "display"), + CoordinateEdge.from_coords("linewidth", {"linewidth": Desc(())}, "display"), + CoordinateEdge.from_coords("linestyle", {"linestyle": Desc(())}, "display"), + CoordinateEdge.from_coords( + "markeredgecolor", {"markeredgecolor": Desc(())}, "display" + ), + CoordinateEdge.from_coords( + "markerfacecolor", {"markerfacecolor": Desc(())}, "display" + ), + CoordinateEdge.from_coords( + "markersize", {"markersize": Desc(())}, "display" + ), + CoordinateEdge.from_coords( + "markeredgewidth", {"markeredgewidth": Desc(())}, "display" + ), + CoordinateEdge.from_coords("marker", {"marker": Desc(())}, "display"), + DefaultEdge.from_default_value("color_def", "color", scalar, "C0"), + DefaultEdge.from_default_value("linewidth_def", "linewidth", scalar, 1), + DefaultEdge.from_default_value("linestyle_def", "linestyle", scalar, "-"), + DefaultEdge.from_default_value("mec_def", "markeredgecolor", scalar, "C0"), + DefaultEdge.from_default_value("mfc_def", "markerfacecolor", scalar, "C0"), + DefaultEdge.from_default_value("ms_def", "markersize", scalar, 6), + DefaultEdge.from_default_value("mew_def", "markeredgewidth", scalar, 1), + DefaultEdge.from_default_value("marker_def", "marker", scalar, "None"), + ] + self._graph = self._graph + Graph(edges) + # Currently ignoring: + # - cap/join style + # - url + # - antialiased + # - snapping + # - sketch + # - gap color + # - draw style (steps) + # - fill style/alt_marker_path + # - markevery + # - non-str markers + # Each individually pretty easy, but relatively rare features, focusing on common cases + + def draw(self, renderer, graph: Graph) -> None: + if not self.get_visible(): + return + g = graph + self._graph + desc = Desc(("N",), "display") + scalar = Desc((), "display") # ... this needs thinking... + + require = { + "x": desc, + "y": desc, + "color": scalar, + "linewidth": scalar, + "linestyle": scalar, + "markeredgecolor": scalar, + "markerfacecolor": scalar, + "markersize": scalar, + "markeredgewidth": scalar, + "marker": scalar, + } + + conv = g.evaluator(self._container.describe(), require) + query, _ = self._container.query(g) + x, y, color, lw, ls, *marker = conv.evaluate(query).values() + mec, mfc, ms, mew, mark = marker + + clip_conv = g.evaluator( + self._clip_box.describe(), + {"x": Desc(("N",), "display"), "y": Desc(("N",), "display")}, + ) + clip_query, _ = self._clip_box.query(g) + clipx, clipy = clip_conv.evaluate(clip_query).values() + + # make the Path object + path = mpath.Path(np.vstack([x, y]).T) + # make an configure the graphic context + gc = renderer.new_gc() + gc.set_clip_rectangle( + mtransforms.Bbox.from_extents(clipx[0], clipy[0], clipx[1], clipy[1]) + ) + gc.set_foreground(color) + gc.set_linewidth(lw) + gc.set_dashes(*mlines._scale_dashes(*mlines._get_dash_pattern(ls), lw)) + # add the line to the render buffer + renderer.draw_path(gc, path, mtransforms.IdentityTransform()) + + if mark != "None" and ms > 0: + gc = renderer.new_gc() + gc.set_clip_rectangle( + mtransforms.Bbox.from_extents(clipx[0], clipy[0], clipx[1], clipy[1]) + ) + gc.set_linewidth(mew) + gc.set_foreground(mec) + marker_ = mmarkers.MarkerStyle(mark) + marker_path = marker_.get_path() + marker_trans = marker_.get_transform() + w = renderer.points_to_pixels(ms) + marker_trans = marker_trans.scale(w) + mfc = mcolors.to_rgba(mfc) + renderer.draw_markers( + gc, + marker_path, + marker_trans, + path, + mtransforms.IdentityTransform(), + mfc, + ) diff --git a/data_prototype/wrappers.py b/data_prototype/wrappers.py index e998dfa..f80eb9b 100644 --- a/data_prototype/wrappers.py +++ b/data_prototype/wrappers.py @@ -132,7 +132,7 @@ def _query_and_transform(self, renderer) -> Dict[str, Any]: # actually query the underlying data. This returns both the (raw) data # and key to use for caching. - desc = Desc(("N",), np.dtype("f8"), coordinates="data") + desc = Desc(("N",), coordinates="data") xy = {"x": desc, "y": desc} edges = [ TransformEdge( diff --git a/examples/animation.py b/examples/animation.py index 70f0d25..d3e276c 100644 --- a/examples/animation.py +++ b/examples/animation.py @@ -22,7 +22,9 @@ from data_prototype.conversion_node import FunctionConversionNode -from data_prototype.wrappers import LineWrapper, FormattedText +from data_prototype.wrappers import FormattedText +from data_prototype.artist import CompatibilityArtist as CA +from data_prototype.line import Line class SinOfTime: @@ -32,10 +34,10 @@ class SinOfTime: def describe(self): return { - "x": Desc([self.N], float), - "y": Desc([self.N], float), - "phase": Desc([], float), - "time": Desc([], float), + "x": Desc((self.N,)), + "y": Desc((self.N,)), + "phase": Desc(()), + "time": Desc(()), } def query( @@ -45,18 +47,15 @@ def query( ) -> Tuple[Dict[str, Any], Union[str, int]]: th = np.linspace(0, 2 * np.pi, self.N) - def next_time(): - cur_time = time.time() + cur_time = time.time() - phase = 2 * np.pi * (self.scale * cur_time % 60) / 60 - return { - "x": th, - "y": np.sin(th + phase), - "phase": phase, - "time": cur_time, - }, hash(cur_time) - - return next_time() + phase = 2 * np.pi * (self.scale * cur_time % 60) / 60 + return { + "x": th, + "y": np.sin(th + phase), + "phase": phase, + "time": cur_time, + }, hash(cur_time) def update(frame, art): @@ -64,7 +63,7 @@ def update(frame, art): sot_c = SinOfTime() -lw = LineWrapper(sot_c, lw=5, color="green", label="sin(time)") +lw = CA(Line(sot_c, linewidth=5, color="green", label="sin(time)")) fc = FormattedText( sot_c, FunctionConversionNode.from_funcs( diff --git a/examples/data_frame.py b/examples/data_frame.py index 19f8392..201a33f 100644 --- a/examples/data_frame.py +++ b/examples/data_frame.py @@ -4,14 +4,15 @@ =============== Wrapping a :class:`pandas.DataFrame` using :class:`.containers.DataFrameContainer` -and :class:`.wrappers.LineWrapper`. +and :class:`.artist.Line`. """ import matplotlib.pyplot as plt import numpy as np import pandas as pd -from data_prototype.wrappers import LineWrapper +from data_prototype.artist import CompatibilityArtist as CA +from data_prototype.line import Line from data_prototype.containers import DataFrameContainer th = np.linspace(0, 4 * np.pi, 256) @@ -34,9 +35,9 @@ fig, (ax1, ax2) = plt.subplots(2, 1) -ax1.add_artist(LineWrapper(dc1, lw=5, color="green", label="sin")) -ax2.add_artist(LineWrapper(dc2, lw=5, color="green", label="sin")) -ax2.add_artist(LineWrapper(dc3, lw=5, color="blue", label="cos")) +ax1.add_artist(CA(Line(dc1, linewidth=5, color="green", label="sin"))) +ax2.add_artist(CA(Line(dc2, linewidth=5, color="green", label="sin"))) +ax2.add_artist(CA(Line(dc3, linewidth=5, color="blue", label="cos"))) for ax in (ax1, ax2): ax.set_xlim(0, np.pi * 4) ax.set_ylim(-1.1, 1.1) diff --git a/examples/first.py b/examples/first.py index 50ac6b9..4357c9e 100644 --- a/examples/first.py +++ b/examples/first.py @@ -4,27 +4,38 @@ ================= Demonstrating the differences between :class:`.containers.FuncContainer` and -:class:`.containers.SeriesContainer` using :class:`.wrappers.LineWrapper`. +:class:`.containers.SeriesContainer` using :class:`.artist.Line`. """ import matplotlib.pyplot as plt import numpy as np import pandas as pd -from data_prototype.wrappers import LineWrapper +from data_prototype.artist import CompatibilityAxes +from data_prototype.line import Line from data_prototype.containers import FuncContainer, SeriesContainer -fc = FuncContainer({"x": (("N",), lambda x: x), "y": (("N",), np.sin)}) -lw = LineWrapper(fc, lw=5, color="green", label="sin (function)") + +fc = FuncContainer({"x": (("N",), lambda x: x), "y": (("N",), lambda x: np.sin(1 / x))}) +lw = Line(fc, linewidth=5, color="green", label="sin(1/x) (function)") th = np.linspace(0, 2 * np.pi, 16) sc = SeriesContainer(pd.Series(index=th, data=np.cos(th)), index_name="x", col_name="y") -lw2 = LineWrapper(sc, lw=3, color="blue", label="cos (pandas)") +lw2 = Line( + sc, + linewidth=3, + linestyle=":", + color="C0", + label="cos (pandas)", + marker=".", + markersize=12, +) -fig, ax = plt.subplots() -ax.add_artist(lw) -ax.add_artist(lw2) +fig, nax = plt.subplots() +ax = CompatibilityAxes(nax) +nax.add_artist(ax) +ax.add_artist(lw, 3) +ax.add_artist(lw2, 2) ax.set_xlim(0, np.pi * 4) ax.set_ylim(-1.1, 1.1) - plt.show() diff --git a/examples/lissajous.py b/examples/lissajous.py index c6b7865..777838e 100644 --- a/examples/lissajous.py +++ b/examples/lissajous.py @@ -32,13 +32,13 @@ class Lissajous: def describe(self): return { - "x": Desc([self.N], float), - "y": Desc([self.N], float), - "time": Desc([], float), - "sizes": Desc([], float), - "paths": Desc([], float), - "edgecolors": Desc([], str), - "facecolors": Desc([self.N], str), + "x": Desc((self.N,)), + "y": Desc((self.N,)), + "time": Desc(()), + "sizes": Desc(()), + "paths": Desc(()), + "edgecolors": Desc(()), + "facecolors": Desc((self.N,)), } def query( diff --git a/examples/mapped.py b/examples/mapped.py index 1b96ed2..85cd636 100644 --- a/examples/mapped.py +++ b/examples/mapped.py @@ -12,29 +12,46 @@ from matplotlib.colors import Normalize -from data_prototype.wrappers import LineWrapper, FormattedText +from data_prototype.wrappers import FormattedText +from data_prototype.artist import CompatibilityArtist as CA +from data_prototype.line import Line from data_prototype.containers import ArrayContainer +from data_prototype.description import Desc from data_prototype.conversion_node import FunctionConversionNode +from data_prototype.conversion_edge import FuncEdge + cmap = plt.colormaps["viridis"] cmap.set_over("k") cmap.set_under("r") norm = Normalize(1, 8) -line_converter = FunctionConversionNode.from_funcs( - { - # arbitrary functions - "lw": lambda lw: min(1 + lw, 5), - # standard color mapping - "color": lambda j: cmap(norm(j)), - # categorical - "ls": lambda cat: {"A": "-", "B": ":", "C": "--"}[cat[()]], - }, -) +line_edges = [ + FuncEdge.from_func( + "lw", + lambda lw: min(1 + lw, 5), + {"lw": Desc((), "auto")}, + {"linewidth": Desc((), "display")}, + ), + # Probably should separate out norm/cmap step + # Slight lie about color being a string here, because of limitations in impl + FuncEdge.from_func( + "cmap", + lambda j: cmap(norm(j)), + {"j": Desc((), "auto")}, + {"color": Desc((), "display")}, + ), + FuncEdge.from_func( + "ls", + lambda cat: {"A": "-", "B": ":", "C": "--"}[cat], + {"cat": Desc((), "auto")}, + {"linestyle": Desc((), "display")}, + ), +] text_converter = FunctionConversionNode.from_funcs( { - "text": lambda j, cat: f"index={j[()]} class={cat[()]!r}", + "text": lambda j, cat: f"index={j[()]} class={cat!r}", "y": lambda j: j, "x": lambda x: 2 * np.pi, }, @@ -53,13 +70,15 @@ "y": np.sin(th + j * delta) + j, "j": np.asarray(j), "lw": np.asarray(j), - "cat": np.asarray({0: "A", 1: "B", 2: "C"}[j % 3]), + "cat": {0: "A", 1: "B", 2: "C"}[j % 3], } ) ax.add_artist( - LineWrapper( - ac, - line_converter, + CA( + Line( + ac, + line_edges, + ) ) ) ax.add_artist( diff --git a/examples/subsample.py b/examples/subsample.py index b963d88..97a67d7 100644 --- a/examples/subsample.py +++ b/examples/subsample.py @@ -21,7 +21,9 @@ import numpy as np from data_prototype.description import Desc, desc_like -from data_prototype.wrappers import ImageWrapper +from data_prototype.artist import CompatibilityArtist as CA +from data_prototype.image import Image +from data_prototype.containers import ArrayContainer from skimage.transform import downscale_local_mean @@ -38,9 +40,9 @@ class Subsample: def describe(self): return { - "xextent": Desc([2], float), - "yextent": Desc([2], float), - "image": Desc([], float), + "x": Desc((2,)), + "y": Desc((2,)), + "image": Desc(("M", "N")), } def query( @@ -48,7 +50,7 @@ def query( graph, parent_coordinates="axes", ) -> Tuple[Dict[str, Any], Union[str, int]]: - desc = Desc(("N",), np.dtype("f8"), coordinates="data") + desc = Desc(("N",), coordinates="data") xy = {"x": desc, "y": desc} data_lim = graph.evaluator(xy, desc_like(xy, coordinates="axes")).inverse @@ -65,19 +67,21 @@ def query( yscale = int(np.ceil((yi2 - yi1) / 50)) return { - "xextent": [x1, x2], - "yextent": [y1, y2], + "x": [x1, x2], + "y": [y1, y2], "image": downscale_local_mean(Z[xi1:xi2, yi1:yi2], (xscale, yscale)), }, hash((x1, x2, y1, y2)) +non_sub = ArrayContainer(**{"image": Z, "x": np.array([0, 1]), "y": np.array([0, 10])}) + sub = Subsample() cmap = mpl.colormaps["coolwarm"] norm = Normalize(-2.2, 2.2) -im = ImageWrapper(sub, cmap=cmap, norm=norm) +im = Image(sub, cmap=cmap, norm=norm) fig, ax = plt.subplots() -ax.add_artist(im) +ax.add_artist(CA(im)) ax.set_xlim(-3, 3) ax.set_ylim(-3, 3) plt.show() diff --git a/examples/widgets.py b/examples/widgets.py index 0e12f0b..ac19874 100644 --- a/examples/widgets.py +++ b/examples/widgets.py @@ -14,9 +14,11 @@ import matplotlib.pyplot as plt from matplotlib.widgets import Slider, Button -from data_prototype.wrappers import LineWrapper +from data_prototype.artist import CompatibilityArtist as CA +from data_prototype.line import Line from data_prototype.containers import FuncContainer -from data_prototype.conversion_node import FunctionConversionNode +from data_prototype.description import Desc +from data_prototype.conversion_edge import FuncEdge class SliderContainer(FuncContainer): @@ -119,15 +121,21 @@ def _query_hash(self, graph, parent_coordinates): frequency=freq_slider, phase=phase_slider, ) -lw = LineWrapper( +lw = Line( fc, # color map phase (scaled to 2pi and wrapped to [0, 1]) - FunctionConversionNode.from_funcs( - {"color": lambda color: cmap((color / (2 * np.pi)) % 1)} - ), - lw=5, + [ + FuncEdge.from_func( + "color", + lambda color: cmap((color / (2 * np.pi)) % 1), + {"color": Desc((1,))}, + {"color": Desc((), "display")}, + ) + ], + linewidth=5.0, + linestyle="-", ) -ax.add_artist(lw) +ax.add_artist(CA(lw)) # Create a `matplotlib.widgets.Button` to reset the sliders to initial values.