From 959a7da873a9f62e88c6077791ea326878529094 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Fri, 8 Mar 2024 13:08:27 -0600 Subject: [PATCH 1/9] Introduce artist classes, starting with Line --- data_prototype/artist.py | 169 ++++++++++++++++++++++++++++++ data_prototype/conversion_edge.py | 2 - data_prototype/description.py | 10 +- examples/animation.py | 5 +- examples/data_frame.py | 10 +- examples/first.py | 12 +-- examples/widgets.py | 21 ++-- 7 files changed, 205 insertions(+), 24 deletions(-) create mode 100644 data_prototype/artist.py diff --git a/data_prototype/artist.py b/data_prototype/artist.py new file mode 100644 index 0000000..80e9089 --- /dev/null +++ b/data_prototype/artist.py @@ -0,0 +1,169 @@ +from typing import Sequence + + +import matplotlib.path as mpath +import matplotlib.colors as mcolors +import matplotlib.lines as mlines +import matplotlib.path as mpath +import matplotlib.transforms as mtransforms +import numpy as np + +from .containers import DataContainer, ArrayContainer, DataUnion +from .description import Desc, desc_like +from .conversion_edge import Edge, TransformEdge, FuncEdge, Graph + + +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._edges = list(edges) + + def draw(self, renderer, edges: Sequence[Edge]) -> None: ... + + +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.zorder = 2 + + 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, edges=None): + + if edges is None: + edges = [] + + if self.axes is not None: + desc: Desc = Desc(("N",), np.dtype("f8"), coordinates="data") + xy: dict[str, Desc] = {"x": desc, "y": desc} + edges.append( + TransformEdge( + "data", + xy, + desc_like(xy, coordinates="axes"), + transform=self.axes.transData - self.axes.transAxes, + ) + ) + edges.append( + TransformEdge( + "axes", + desc_like(xy, coordinates="axes"), + desc_like(xy, coordinates="display"), + transform=self.axes.transAxes, + ) + ) + + self._artist.draw(renderer, edges) + + +class Line(Artist): + def __init__(self, container, edges=None, **kwargs): + super().__init__(container, edges, **kwargs) + + defaults = ArrayContainer( + **{ + "color": "C0", # TODO: interactions with cycler/rcparams? + "linewidth": 1, + "linestyle": "-", + } + ) + + self._container = DataUnion(defaults, self._container) + # These are a stand-in for units etc... just kind of placed here as no-ops + self._edges += [ + FuncEdge.from_func( + "xvals", lambda x: x, "naive", "data", inverse=lambda x: x + ), + FuncEdge.from_func( + "yvals", lambda y: y, "naive", "data", inverse=lambda y: y + ), + ] + + def draw(self, renderer, edges: Sequence[Edge]) -> None: + g = Graph(list(edges) + self._edges) + desc = Desc(("N",), np.dtype("f8"), "display") + xy = {"x": desc, "y": desc} + conv = g.evaluator(self._container.describe(), xy) + query, _ = self._container.query(g) + x, y = conv.evaluate(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_foreground(mcolors.to_rgba(query["color"]), isRGBA=True) + gc.set_linewidth(query["linewidth"]) + gc.set_dashes(*mlines._get_dash_pattern(query["linestyle"])) + # add the line to the render buffer + renderer.draw_path(gc, path, mtransforms.IdentityTransform()) + + +class Image(Artist): + def __init__(self, container, edges=None, **kwargs): + super().__init__(container, edges, **kwargs) + + defaults = ArrayContainer( + **{ + "cmap": "viridis", + "norm": "linear", + } + ) + + self._container = DataUnion(defaults, self._container) + # These are a stand-in for units etc... just kind of placed here as no-ops + self._edges += [ + FuncEdge.from_func( + "xvals", lambda x: x, "naive", "data", inverse=lambda x: x + ), + FuncEdge.from_func( + "yvals", lambda y: y, "naive", "data", inverse=lambda y: y + ), + ] + + def draw(self, renderer, edges: Sequence[Edge]) -> None: + g = Graph(list(edges) + self._edges) + ... diff --git a/data_prototype/conversion_edge.py b/data_prototype/conversion_edge.py index af19c98..89ba14f 100644 --- a/data_prototype/conversion_edge.py +++ b/data_prototype/conversion_edge.py @@ -42,9 +42,7 @@ def from_edges(cls, name: str, edges: Sequence[Edge], output: dict[str, Desc]): 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 diff --git a/data_prototype/description.py b/data_prototype/description.py index a963a92..1e04db2 100644 --- a/data_prototype/description.py +++ b/data_prototype/description.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import TypeAlias, Tuple, Union +from typing import TypeAlias, Tuple, Union, overload import numpy as np @@ -121,6 +121,14 @@ def compatible(a: dict[str, "Desc"], b: dict[str, "Desc"]) -> bool: return True +@overload +def desc_like(desc: Desc, shape=None, dtype=None, coordinates=None) -> Desc: ... +@overload +def desc_like( + desc: dict[str, Desc], shape=None, dtype=None, coordinates=None +) -> dict[str, Desc]: ... + + def desc_like(desc, shape=None, dtype=None, coordinates=None): if isinstance(desc, dict): return {k: desc_like(v, shape, dtype, coordinates) for k, v in desc.items()} diff --git a/examples/animation.py b/examples/animation.py index 70f0d25..0714a6c 100644 --- a/examples/animation.py +++ b/examples/animation.py @@ -22,7 +22,8 @@ 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 Line, CompatibilityArtist as CA class SinOfTime: @@ -64,7 +65,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..ee2c2cf 100644 --- a/examples/data_frame.py +++ b/examples/data_frame.py @@ -4,14 +4,14 @@ =============== 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 Line, CompatibilityArtist as CA from data_prototype.containers import DataFrameContainer th = np.linspace(0, 4 * np.pi, 256) @@ -34,9 +34,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..d4cad11 100644 --- a/examples/first.py +++ b/examples/first.py @@ -4,26 +4,26 @@ ================= 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 Line, CompatibilityArtist 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)") +lw = Line(fc, linewidth=5, color="green", label="sin (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="blue", label="cos (pandas)") fig, ax = plt.subplots() -ax.add_artist(lw) -ax.add_artist(lw2) +ax.add_artist(CompatibilityArtist(lw)) +ax.add_artist(CompatibilityArtist(lw2)) ax.set_xlim(0, np.pi * 4) ax.set_ylim(-1.1, 1.1) diff --git a/examples/widgets.py b/examples/widgets.py index 0e12f0b..00a7339 100644 --- a/examples/widgets.py +++ b/examples/widgets.py @@ -14,9 +14,9 @@ import matplotlib.pyplot as plt from matplotlib.widgets import Slider, Button -from data_prototype.wrappers import LineWrapper +from data_prototype.artist import Line, CompatibilityArtist as CA from data_prototype.containers import FuncContainer -from data_prototype.conversion_node import FunctionConversionNode +from data_prototype.conversion_edge import FuncEdge class SliderContainer(FuncContainer): @@ -119,15 +119,20 @@ 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), + "user", + "display", + ) + ], + linewidth=5, ) -ax.add_artist(lw) +ax.add_artist(CA(lw)) # Create a `matplotlib.widgets.Button` to reset the sliders to initial values. From 1d8bf27168ddab60f2523d4ba0895e08918c2ebc Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Tue, 9 Apr 2024 16:24:40 -0500 Subject: [PATCH 2/9] Make default description coordinates 'auto' --- data_prototype/description.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/data_prototype/description.py b/data_prototype/description.py index 1e04db2..aa79e38 100644 --- a/data_prototype/description.py +++ b/data_prototype/description.py @@ -17,7 +17,7 @@ class Desc: # 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( @@ -123,6 +123,8 @@ def compatible(a: dict[str, "Desc"], b: dict[str, "Desc"]) -> bool: @overload def desc_like(desc: Desc, shape=None, dtype=None, coordinates=None) -> Desc: ... + + @overload def desc_like( desc: dict[str, Desc], shape=None, dtype=None, coordinates=None From 46556148bef8e62a181c0162ce4a51c896954d1b Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Tue, 9 Apr 2024 16:27:21 -0500 Subject: [PATCH 3/9] Add weights to graphs, add DefaultEdge with a high weight --- data_prototype/conversion_edge.py | 283 +++++++++++++++++++++++------- 1 file changed, 218 insertions(+), 65 deletions(-) diff --git a/data_prototype/conversion_edge.py b/data_prototype/conversion_edge.py index 89ba14f..21c31e7 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,16 +33,28 @@ 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: @@ -48,10 +64,59 @@ def evaluate(self, input: dict[str, Any]) -> dict[str, Any]: @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",), np.dtype("f8"), 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? @@ -65,6 +130,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 @@ -77,7 +143,7 @@ def from_func( if isinstance(output, str): output = {k: Desc(("N",), np.dtype("f8"), 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}) @@ -88,8 +154,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: @@ -98,8 +166,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, ) @@ -125,63 +202,129 @@ 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 + + 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 = [] + 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 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(order=True) + class Node: + weight: float + desc: dict[str, Desc] + prev_node: Node | None = None + edge: Edge | None = None + + 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): + if n.weight < best.weight: + best = n + continue + for e in sub_edges: + if Desc.compatible(n.desc, e.input): + 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 @@ -194,32 +337,42 @@ 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) From 85378e47cd02f255652f9840dda0bbf3d7581a9e Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Tue, 9 Apr 2024 16:28:49 -0500 Subject: [PATCH 4/9] Implement line as new style artist --- data_prototype/artist.py | 80 +------------------------ data_prototype/line.py | 123 +++++++++++++++++++++++++++++++++++++++ examples/first.py | 13 ++++- examples/mapped.py | 51 +++++++++++----- examples/widgets.py | 11 ++-- 5 files changed, 179 insertions(+), 99 deletions(-) create mode 100644 data_prototype/line.py diff --git a/data_prototype/artist.py b/data_prototype/artist.py index 80e9089..a7afa47 100644 --- a/data_prototype/artist.py +++ b/data_prototype/artist.py @@ -1,16 +1,10 @@ from typing import Sequence - -import matplotlib.path as mpath -import matplotlib.colors as mcolors -import matplotlib.lines as mlines -import matplotlib.path as mpath -import matplotlib.transforms as mtransforms import numpy as np from .containers import DataContainer, ArrayContainer, DataUnion from .description import Desc, desc_like -from .conversion_edge import Edge, TransformEdge, FuncEdge, Graph +from .conversion_edge import Edge, TransformEdge class Artist: @@ -26,7 +20,8 @@ def __init__( edges = edges or [] self._edges = list(edges) - def draw(self, renderer, edges: Sequence[Edge]) -> None: ... + def draw(self, renderer, edges: Sequence[Edge]) -> None: + return class CompatibilityArtist: @@ -98,72 +93,3 @@ def draw(self, renderer, edges=None): ) self._artist.draw(renderer, edges) - - -class Line(Artist): - def __init__(self, container, edges=None, **kwargs): - super().__init__(container, edges, **kwargs) - - defaults = ArrayContainer( - **{ - "color": "C0", # TODO: interactions with cycler/rcparams? - "linewidth": 1, - "linestyle": "-", - } - ) - - self._container = DataUnion(defaults, self._container) - # These are a stand-in for units etc... just kind of placed here as no-ops - self._edges += [ - FuncEdge.from_func( - "xvals", lambda x: x, "naive", "data", inverse=lambda x: x - ), - FuncEdge.from_func( - "yvals", lambda y: y, "naive", "data", inverse=lambda y: y - ), - ] - - def draw(self, renderer, edges: Sequence[Edge]) -> None: - g = Graph(list(edges) + self._edges) - desc = Desc(("N",), np.dtype("f8"), "display") - xy = {"x": desc, "y": desc} - conv = g.evaluator(self._container.describe(), xy) - query, _ = self._container.query(g) - x, y = conv.evaluate(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_foreground(mcolors.to_rgba(query["color"]), isRGBA=True) - gc.set_linewidth(query["linewidth"]) - gc.set_dashes(*mlines._get_dash_pattern(query["linestyle"])) - # add the line to the render buffer - renderer.draw_path(gc, path, mtransforms.IdentityTransform()) - - -class Image(Artist): - def __init__(self, container, edges=None, **kwargs): - super().__init__(container, edges, **kwargs) - - defaults = ArrayContainer( - **{ - "cmap": "viridis", - "norm": "linear", - } - ) - - self._container = DataUnion(defaults, self._container) - # These are a stand-in for units etc... just kind of placed here as no-ops - self._edges += [ - FuncEdge.from_func( - "xvals", lambda x: x, "naive", "data", inverse=lambda x: x - ), - FuncEdge.from_func( - "yvals", lambda y: y, "naive", "data", inverse=lambda y: y - ), - ] - - def draw(self, renderer, edges: Sequence[Edge]) -> None: - g = Graph(list(edges) + self._edges) - ... diff --git a/data_prototype/line.py b/data_prototype/line.py new file mode 100644 index 0000000..2e6e463 --- /dev/null +++ b/data_prototype/line.py @@ -0,0 +1,123 @@ +from typing import Sequence + +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 Edge, Graph, CoordinateEdge, DefaultEdge + + +class Line(Artist): + def __init__(self, container, edges=None, **kwargs): + super().__init__(container, edges, **kwargs) + + colordesc = Desc((), str, "display") # ... this needs thinking... + floatdesc = Desc((), float, "display") + strdesc = Desc((), str, "display") + + self._edges += [ + CoordinateEdge.from_coords("xycoords", {"x": "auto", "y": "auto"}, "data"), + CoordinateEdge.from_coords("color", {"color": Desc((), str)}, "display"), + CoordinateEdge.from_coords( + "linewidth", {"linewidth": Desc((), np.dtype("f8"))}, "display" + ), + CoordinateEdge.from_coords( + "linestyle", {"linestyle": Desc((), str)}, "display" + ), + CoordinateEdge.from_coords( + "markeredgecolor", {"markeredgecolor": Desc((), str)}, "display" + ), + CoordinateEdge.from_coords( + "markerfacecolor", {"markerfacecolor": Desc((), str)}, "display" + ), + CoordinateEdge.from_coords( + "markersize", {"markersize": Desc((), float)}, "display" + ), + CoordinateEdge.from_coords( + "markeredgewidth", {"markeredgewidth": Desc((), float)}, "display" + ), + CoordinateEdge.from_coords("marker", {"marker": Desc((), str)}, "display"), + DefaultEdge.from_default_value("color_def", "color", colordesc, "C0"), + DefaultEdge.from_default_value("linewidth_def", "linewidth", floatdesc, 1), + DefaultEdge.from_default_value("linestyle_def", "linestyle", strdesc, "-"), + DefaultEdge.from_default_value( + "mec_def", "markeredgecolor", colordesc, "C0" + ), + DefaultEdge.from_default_value( + "mfc_def", "markerfacecolor", colordesc, "C0" + ), + DefaultEdge.from_default_value("ms_def", "markersize", floatdesc, 6), + DefaultEdge.from_default_value("mew_def", "markeredgewidth", floatdesc, 1), + DefaultEdge.from_default_value("marker_def", "marker", strdesc, "None"), + ] + # 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, edges: Sequence[Edge]) -> None: + g = Graph(list(edges) + self._edges) + desc = Desc(("N",), np.dtype("f8"), "display") + colordesc = Desc((), str, "display") # ... this needs thinking... + floatdesc = Desc((), float, "display") + strdesc = Desc((), str, "display") + + require = { + "x": desc, + "y": desc, + "color": colordesc, + "linewidth": floatdesc, + "linestyle": strdesc, + "markeredgecolor": colordesc, + "markerfacecolor": colordesc, + "markersize": floatdesc, + "markeredgewidth": floatdesc, + "marker": strdesc, + } + + 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 + + # make the Path object + path = mpath.Path(np.vstack([x, y]).T) + # make an configure the graphic context + gc = renderer.new_gc() + 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_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/examples/first.py b/examples/first.py index d4cad11..e16c153 100644 --- a/examples/first.py +++ b/examples/first.py @@ -11,7 +11,8 @@ import numpy as np import pandas as pd -from data_prototype.artist import Line, CompatibilityArtist +from data_prototype.artist import CompatibilityArtist +from data_prototype.line import Line from data_prototype.containers import FuncContainer, SeriesContainer fc = FuncContainer({"x": (("N",), lambda x: x), "y": (("N",), np.sin)}) @@ -19,7 +20,15 @@ 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 = Line(sc, linewidth=3, linestyle=":", 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(CompatibilityArtist(lw)) diff --git a/examples/mapped.py b/examples/mapped.py index 1b96ed2..0ea8285 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((), str, "auto")}, + {"linewidth": Desc((), str, "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((), str, "auto")}, + {"color": Desc((), str, "display")}, + ), + FuncEdge.from_func( + "ls", + lambda cat: {"A": "-", "B": ":", "C": "--"}[cat], + {"cat": Desc((), str, "auto")}, + {"linestyle": Desc((), str, "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/widgets.py b/examples/widgets.py index 00a7339..49585dd 100644 --- a/examples/widgets.py +++ b/examples/widgets.py @@ -14,8 +14,10 @@ import matplotlib.pyplot as plt from matplotlib.widgets import Slider, Button -from data_prototype.artist import Line, CompatibilityArtist as CA +from data_prototype.artist import CompatibilityArtist as CA +from data_prototype.line import Line from data_prototype.containers import FuncContainer +from data_prototype.description import Desc from data_prototype.conversion_edge import FuncEdge @@ -126,11 +128,12 @@ def _query_hash(self, graph, parent_coordinates): FuncEdge.from_func( "color", lambda color: cmap((color / (2 * np.pi)) % 1), - "user", - "display", + {"color": Desc((1,), np.float64)}, + {"color": Desc((), np.float64, "display")}, ) ], - linewidth=5, + linewidth=5.0, + linestyle="-", ) ax.add_artist(CA(lw)) From f01e33c3b622ce29869b7d24419fd40f90dcb51c Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Tue, 9 Apr 2024 16:32:36 -0500 Subject: [PATCH 5/9] WIP: Implement new style image class --- data_prototype/image.py | 78 +++++++++++++++++++++++++++++++++++++++++ examples/subsample.py | 6 ++-- 2 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 data_prototype/image.py diff --git a/data_prototype/image.py b/data_prototype/image.py new file mode 100644 index 0000000..d7b17fd --- /dev/null +++ b/data_prototype/image.py @@ -0,0 +1,78 @@ +from typing import Sequence + +import matplotlib.cm as mcm +import matplotlib.colors as mcolors +import matplotlib.transforms as mtransforms +import numpy as np + +from .artist import Artist +from .description import Desc, desc_like +from .conversion_edge import Edge, FuncEdge, Graph, CoordinateEdge, DefaultEdge + + +class Image(Artist): + def __init__(self, container, edges=None, **kwargs): + super().__init__(container, edges, **kwargs) + + strdesc = Desc((), str) + arrdesc = Desc(("M", "N"), np.float64) + normdesc = Desc((), mcolors.Normalize, "norm") + cmapdesc = Desc((), mcolors.Colormap, "cmap") + + self._edges += [ + CoordinateEdge.from_coords( + "A_pcolor", {"A": Desc(("M", "N"), np.float64)}, "data" + ), + CoordinateEdge.from_coords( + "A_rgb", {"A": Desc(("M", "N", 3), np.float64)}, "rgb" + ), + CoordinateEdge.from_coords( + "A_rgba", {"A": Desc(("M", "N", 4), np.float64)}, "rgba" + ), + FuncEdge.from_func( + "norm_obj", + lambda norm: mcm._auto_norm_from_scale(norm), + {"norm": desc_like(strdesc, coordinates="norm")}, + {"norm": normdesc}, + ), + FuncEdge.from_func( + "cmap_obj", + lambda cmap: mcm._colormaps[cmap], + {"cmap": desc_like(strdesc, coordinates="cmap")}, + {"cmap": cmapdesc}, + ), + FuncEdge.from_func( + "A_norm", + lambda A, norm: norm(A), + {"A": desc_like(arrdesc), "norm": normdesc}, + {"A": desc_like(arrdesc, coordinates="norm")}, + ), + FuncEdge.from_func( + "A_cmap", + lambda A, cmap: cmap(A), + {"A": desc_like(arrdesc, coordinates="norm"), "cmap": cmapdesc}, + {"A": Desc(("M", "N", 4), np.float64, coordinates="rgba")}, + ), + DefaultEdge.from_default_value("cmap_def", "cmap", strdesc, "viridis"), + DefaultEdge.from_default_value("norm_def", "norm", strdesc, "linear"), + CoordinateEdge.from_coords( + "A_rgba", {"A": Desc(("M", "N", 4), np.float64, "rgba")}, "display" + ), + ] + + def draw(self, renderer, edges: Sequence[Edge]) -> None: + import matplotlib.pyplot as plt + + g = Graph(list(edges) + self._edges) + g.visualize() + plt.show() + conv = g.evaluator( + self._container.describe(), + {"A": Desc(("M", "N", 4), np.float64, "display")}, + ) + query, _ = self._container.query(g) + image = conv.evaluate(query)["A"] + + gc = renderer.new_gc() + renderer.draw_image(gc, image, mtransforms.IdentityTransform()) + ... diff --git a/examples/subsample.py b/examples/subsample.py index b963d88..234afc7 100644 --- a/examples/subsample.py +++ b/examples/subsample.py @@ -21,7 +21,7 @@ import numpy as np from data_prototype.description import Desc, desc_like -from data_prototype.wrappers import ImageWrapper +from data_prototype.artist import Image, CompatibilityArtist as CA from skimage.transform import downscale_local_mean @@ -74,10 +74,10 @@ def query( 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() From 3e7a0ec4788a0b6b0b6d2b8b8785fcfd31b73af4 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Tue, 9 Apr 2024 16:54:02 -0500 Subject: [PATCH 6/9] Remove dtype from Desc --- data_prototype/artist.py | 4 +- data_prototype/containers.py | 26 ++++++------ data_prototype/conversion_edge.py | 7 ++-- data_prototype/description.py | 15 +++---- data_prototype/image.py | 28 +++++-------- data_prototype/line.py | 66 +++++++++++++------------------ data_prototype/wrappers.py | 2 +- examples/animation.py | 11 +++--- examples/data_frame.py | 3 +- examples/lissajous.py | 14 +++---- examples/mapped.py | 12 +++--- examples/subsample.py | 11 +++--- examples/widgets.py | 4 +- 13 files changed, 87 insertions(+), 116 deletions(-) diff --git a/data_prototype/artist.py b/data_prototype/artist.py index a7afa47..da5259e 100644 --- a/data_prototype/artist.py +++ b/data_prototype/artist.py @@ -1,7 +1,5 @@ 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, TransformEdge @@ -73,7 +71,7 @@ def draw(self, renderer, edges=None): edges = [] if self.axes is not None: - desc: Desc = Desc(("N",), np.dtype("f8"), coordinates="data") + desc: Desc = Desc(("N",), coordinates="data") xy: dict[str, Desc] = {"x": desc, "y": desc} edges.append( TransformEdge( diff --git a/data_prototype/containers.py b/data_prototype/containers.py index 9640e01..4c46d34 100644 --- a/data_prototype/containers.py +++ b/data_prototype/containers.py @@ -86,11 +86,7 @@ def __init__(self, **data): self._data = data self._cache_key = str(uuid.uuid4()) self._desc = { - k: ( - Desc(v.shape, v.dtype) - if isinstance(v, np.ndarray) - else Desc((), type(v)) - ) + k: (Desc(v.shape) if isinstance(v, np.ndarray) else Desc(())) for k, v in data.items() } @@ -117,7 +113,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 +167,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 +192,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 +239,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 +252,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 +298,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 +339,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 21c31e7..674d304 100644 --- a/data_prototype/conversion_edge.py +++ b/data_prototype/conversion_edge.py @@ -83,8 +83,7 @@ def from_coords( # 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",), np.dtype("f8"), v) - for k, v in input.items() + 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()} @@ -139,9 +138,9 @@ 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, weight, inverse is not None, func, inverse) diff --git a/data_prototype/description.py b/data_prototype/description.py index aa79e38..5bffe0b 100644 --- a/data_prototype/description.py +++ b/data_prototype/description.py @@ -1,8 +1,6 @@ from dataclasses import dataclass from typing import TypeAlias, Tuple, Union, overload -import numpy as np - ShapeSpec: TypeAlias = Tuple[Union[str, int], ...] @@ -16,7 +14,6 @@ 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 = "auto" @staticmethod @@ -122,22 +119,20 @@ def compatible(a: dict[str, "Desc"], b: dict[str, "Desc"]) -> bool: @overload -def desc_like(desc: Desc, shape=None, dtype=None, coordinates=None) -> Desc: ... +def desc_like(desc: Desc, shape=None, coordinates=None) -> Desc: ... @overload def desc_like( - desc: dict[str, Desc], shape=None, dtype=None, coordinates=None + desc: dict[str, Desc], shape=None, coordinates=None ) -> dict[str, Desc]: ... -def desc_like(desc, shape=None, dtype=None, coordinates=None): +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 index d7b17fd..a163b37 100644 --- a/data_prototype/image.py +++ b/data_prototype/image.py @@ -1,9 +1,7 @@ from typing import Sequence import matplotlib.cm as mcm -import matplotlib.colors as mcolors import matplotlib.transforms as mtransforms -import numpy as np from .artist import Artist from .description import Desc, desc_like @@ -14,21 +12,15 @@ class Image(Artist): def __init__(self, container, edges=None, **kwargs): super().__init__(container, edges, **kwargs) - strdesc = Desc((), str) - arrdesc = Desc(("M", "N"), np.float64) - normdesc = Desc((), mcolors.Normalize, "norm") - cmapdesc = Desc((), mcolors.Colormap, "cmap") + strdesc = Desc(()) + arrdesc = Desc(("M", "N")) + normdesc = Desc((), "norm") + cmapdesc = Desc((), "cmap") self._edges += [ - CoordinateEdge.from_coords( - "A_pcolor", {"A": Desc(("M", "N"), np.float64)}, "data" - ), - CoordinateEdge.from_coords( - "A_rgb", {"A": Desc(("M", "N", 3), np.float64)}, "rgb" - ), - CoordinateEdge.from_coords( - "A_rgba", {"A": Desc(("M", "N", 4), np.float64)}, "rgba" - ), + CoordinateEdge.from_coords("A_pcolor", {"A": Desc(("M", "N"))}, "data"), + CoordinateEdge.from_coords("A_rgb", {"A": Desc(("M", "N", 3))}, "rgb"), + CoordinateEdge.from_coords("A_rgba", {"A": Desc(("M", "N", 4))}, "rgba"), FuncEdge.from_func( "norm_obj", lambda norm: mcm._auto_norm_from_scale(norm), @@ -51,12 +43,12 @@ def __init__(self, container, edges=None, **kwargs): "A_cmap", lambda A, cmap: cmap(A), {"A": desc_like(arrdesc, coordinates="norm"), "cmap": cmapdesc}, - {"A": Desc(("M", "N", 4), np.float64, coordinates="rgba")}, + {"A": Desc(("M", "N", 4), coordinates="rgba")}, ), DefaultEdge.from_default_value("cmap_def", "cmap", strdesc, "viridis"), DefaultEdge.from_default_value("norm_def", "norm", strdesc, "linear"), CoordinateEdge.from_coords( - "A_rgba", {"A": Desc(("M", "N", 4), np.float64, "rgba")}, "display" + "A_rgba", {"A": Desc(("M", "N", 4), "rgba")}, "display" ), ] @@ -68,7 +60,7 @@ def draw(self, renderer, edges: Sequence[Edge]) -> None: plt.show() conv = g.evaluator( self._container.describe(), - {"A": Desc(("M", "N", 4), np.float64, "display")}, + {"A": Desc(("M", "N", 4), "display")}, ) query, _ = self._container.query(g) image = conv.evaluate(query)["A"] diff --git a/data_prototype/line.py b/data_prototype/line.py index 2e6e463..56f893f 100644 --- a/data_prototype/line.py +++ b/data_prototype/line.py @@ -16,44 +16,34 @@ class Line(Artist): def __init__(self, container, edges=None, **kwargs): super().__init__(container, edges, **kwargs) - colordesc = Desc((), str, "display") # ... this needs thinking... - floatdesc = Desc((), float, "display") - strdesc = Desc((), str, "display") + scalar = Desc((), "display") # ... this needs thinking... self._edges += [ CoordinateEdge.from_coords("xycoords", {"x": "auto", "y": "auto"}, "data"), - CoordinateEdge.from_coords("color", {"color": Desc((), str)}, "display"), + CoordinateEdge.from_coords("color", {"color": Desc(())}, "display"), + CoordinateEdge.from_coords("linewidth", {"linewidth": Desc(())}, "display"), + CoordinateEdge.from_coords("linestyle", {"linestyle": Desc(())}, "display"), CoordinateEdge.from_coords( - "linewidth", {"linewidth": Desc((), np.dtype("f8"))}, "display" + "markeredgecolor", {"markeredgecolor": Desc(())}, "display" ), CoordinateEdge.from_coords( - "linestyle", {"linestyle": Desc((), str)}, "display" + "markerfacecolor", {"markerfacecolor": Desc(())}, "display" ), CoordinateEdge.from_coords( - "markeredgecolor", {"markeredgecolor": Desc((), str)}, "display" + "markersize", {"markersize": Desc(())}, "display" ), CoordinateEdge.from_coords( - "markerfacecolor", {"markerfacecolor": Desc((), str)}, "display" + "markeredgewidth", {"markeredgewidth": Desc(())}, "display" ), - CoordinateEdge.from_coords( - "markersize", {"markersize": Desc((), float)}, "display" - ), - CoordinateEdge.from_coords( - "markeredgewidth", {"markeredgewidth": Desc((), float)}, "display" - ), - CoordinateEdge.from_coords("marker", {"marker": Desc((), str)}, "display"), - DefaultEdge.from_default_value("color_def", "color", colordesc, "C0"), - DefaultEdge.from_default_value("linewidth_def", "linewidth", floatdesc, 1), - DefaultEdge.from_default_value("linestyle_def", "linestyle", strdesc, "-"), - DefaultEdge.from_default_value( - "mec_def", "markeredgecolor", colordesc, "C0" - ), - DefaultEdge.from_default_value( - "mfc_def", "markerfacecolor", colordesc, "C0" - ), - DefaultEdge.from_default_value("ms_def", "markersize", floatdesc, 6), - DefaultEdge.from_default_value("mew_def", "markeredgewidth", floatdesc, 1), - DefaultEdge.from_default_value("marker_def", "marker", strdesc, "None"), + 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"), ] # Currently ignoring: # - cap/join style @@ -70,22 +60,20 @@ def __init__(self, container, edges=None, **kwargs): def draw(self, renderer, edges: Sequence[Edge]) -> None: g = Graph(list(edges) + self._edges) - desc = Desc(("N",), np.dtype("f8"), "display") - colordesc = Desc((), str, "display") # ... this needs thinking... - floatdesc = Desc((), float, "display") - strdesc = Desc((), str, "display") + desc = Desc(("N",), "display") + scalar = Desc((), "display") # ... this needs thinking... require = { "x": desc, "y": desc, - "color": colordesc, - "linewidth": floatdesc, - "linestyle": strdesc, - "markeredgecolor": colordesc, - "markerfacecolor": colordesc, - "markersize": floatdesc, - "markeredgewidth": floatdesc, - "marker": strdesc, + "color": scalar, + "linewidth": scalar, + "linestyle": scalar, + "markeredgecolor": scalar, + "markerfacecolor": scalar, + "markersize": scalar, + "markeredgewidth": scalar, + "marker": scalar, } conv = g.evaluator(self._container.describe(), require) 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 0714a6c..6e1fe4e 100644 --- a/examples/animation.py +++ b/examples/animation.py @@ -23,7 +23,8 @@ from data_prototype.conversion_node import FunctionConversionNode from data_prototype.wrappers import FormattedText -from data_prototype.artist import Line, CompatibilityArtist as CA +from data_prototype.artist import CompatibilityArtist as CA +from data_prototype.line import Line class SinOfTime: @@ -33,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( diff --git a/examples/data_frame.py b/examples/data_frame.py index ee2c2cf..201a33f 100644 --- a/examples/data_frame.py +++ b/examples/data_frame.py @@ -11,7 +11,8 @@ import numpy as np import pandas as pd -from data_prototype.artist import Line, CompatibilityArtist as CA +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) 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 0ea8285..85cd636 100644 --- a/examples/mapped.py +++ b/examples/mapped.py @@ -30,22 +30,22 @@ FuncEdge.from_func( "lw", lambda lw: min(1 + lw, 5), - {"lw": Desc((), str, "auto")}, - {"linewidth": Desc((), str, "display")}, + {"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((), str, "auto")}, - {"color": Desc((), str, "display")}, + {"j": Desc((), "auto")}, + {"color": Desc((), "display")}, ), FuncEdge.from_func( "ls", lambda cat: {"A": "-", "B": ":", "C": "--"}[cat], - {"cat": Desc((), str, "auto")}, - {"linestyle": Desc((), str, "display")}, + {"cat": Desc((), "auto")}, + {"linestyle": Desc((), "display")}, ), ] diff --git a/examples/subsample.py b/examples/subsample.py index 234afc7..072d66a 100644 --- a/examples/subsample.py +++ b/examples/subsample.py @@ -21,7 +21,8 @@ import numpy as np from data_prototype.description import Desc, desc_like -from data_prototype.artist import Image, CompatibilityArtist as CA +from data_prototype.artist import CompatibilityArtist as CA +from data_prototype.image import Image from skimage.transform import downscale_local_mean @@ -38,9 +39,9 @@ class Subsample: def describe(self): return { - "xextent": Desc([2], float), - "yextent": Desc([2], float), - "image": Desc([], float), + "xextent": Desc((2,)), + "yextent": Desc((2,)), + "image": Desc(("M", "N")), } def query( @@ -48,7 +49,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 diff --git a/examples/widgets.py b/examples/widgets.py index 49585dd..ac19874 100644 --- a/examples/widgets.py +++ b/examples/widgets.py @@ -128,8 +128,8 @@ def _query_hash(self, graph, parent_coordinates): FuncEdge.from_func( "color", lambda color: cmap((color / (2 * np.pi)) % 1), - {"color": Desc((1,), np.float64)}, - {"color": Desc((), np.float64, "display")}, + {"color": Desc((1,))}, + {"color": Desc((), "display")}, ) ], linewidth=5.0, From ab2a630e11345a5dae18cce778f2172f6e9843c6 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Wed, 10 Apr 2024 14:03:55 -0500 Subject: [PATCH 7/9] Add CompatibilityAxes (as opposed to compatibilityArtist) --- data_prototype/artist.py | 80 ++++++++++++++++++++++++++++++++++++++++ examples/first.py | 10 +++-- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/data_prototype/artist.py b/data_prototype/artist.py index da5259e..c7d56d6 100644 --- a/data_prototype/artist.py +++ b/data_prototype/artist.py @@ -91,3 +91,83 @@ def draw(self, renderer, edges=None): ) self._artist.draw(renderer, edges) + + +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.zorder = 2 + self._children = [] + + 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, edges=None): + if edges is None: + edges = [] + + if self.axes is not None: + desc: Desc = Desc(("N",), coordinates="data") + xy: dict[str, Desc] = {"x": desc, "y": desc} + edges.append( + TransformEdge( + "data", + xy, + desc_like(xy, coordinates="axes"), + transform=self.axes.transData - self.axes.transAxes, + ) + ) + edges.append( + TransformEdge( + "axes", + desc_like(xy, coordinates="axes"), + desc_like(xy, coordinates="display"), + transform=self.axes.transAxes, + ) + ) + + # TODO independent zorder + for c in self._children: + c.draw(renderer, edges) + + def add_artist(self, artist): + self._children.append(artist) + + 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_) diff --git a/examples/first.py b/examples/first.py index e16c153..f9b719a 100644 --- a/examples/first.py +++ b/examples/first.py @@ -11,7 +11,7 @@ import numpy as np import pandas as pd -from data_prototype.artist import CompatibilityArtist +from data_prototype.artist import CompatibilityAxes from data_prototype.line import Line from data_prototype.containers import FuncContainer, SeriesContainer @@ -30,9 +30,11 @@ markersize=12, ) -fig, ax = plt.subplots() -ax.add_artist(CompatibilityArtist(lw)) -ax.add_artist(CompatibilityArtist(lw2)) +fig, nax = plt.subplots() +ax = CompatibilityAxes(nax) +nax.add_artist(ax) +ax.add_artist(lw) +ax.add_artist(lw2) ax.set_xlim(0, np.pi * 4) ax.set_ylim(-1.1, 1.1) From 7ebe0d582177bd90ec24e37a99ac948d2cceddc7 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Fri, 19 Apr 2024 14:02:43 -0500 Subject: [PATCH 8/9] visibility, zorder, parent/aliases --- data_prototype/artist.py | 177 ++++++++++++++++++++---------- data_prototype/containers.py | 9 +- data_prototype/conversion_edge.py | 46 +++++++- data_prototype/description.py | 19 +++- data_prototype/image.py | 125 ++++++++++++++------- data_prototype/line.py | 26 ++++- examples/animation.py | 21 ++-- examples/first.py | 11 +- examples/subsample.py | 11 +- 9 files changed, 310 insertions(+), 135 deletions(-) diff --git a/data_prototype/artist.py b/data_prototype/artist.py index c7d56d6..d2f2db1 100644 --- a/data_prototype/artist.py +++ b/data_prototype/artist.py @@ -1,8 +1,11 @@ +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, TransformEdge +from .conversion_edge import Edge, Graph, TransformEdge class Artist: @@ -16,11 +19,28 @@ def __init__( self._container = DataUnion(container, kwargs_cont) edges = edges or [] - self._edges = list(edges) - - def draw(self, renderer, edges: Sequence[Edge]) -> None: + 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. @@ -42,10 +62,44 @@ class CompatibilityArtist: def __init__(self, artist: Artist): self._artist = artist - self.axes = None + 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 @@ -65,32 +119,19 @@ def set_clip_path(self, path): def get_animated(self): return False - def draw(self, renderer, edges=None): + def get_visible(self): + return self._visible - if edges is None: - edges = [] + def set_visible(self, visible): + self._visible = visible - if self.axes is not None: - desc: Desc = Desc(("N",), coordinates="data") - xy: dict[str, Desc] = {"x": desc, "y": desc} - edges.append( - TransformEdge( - "data", - xy, - desc_like(xy, coordinates="axes"), - transform=self.axes.transData - self.axes.transAxes, - ) - ) - edges.append( - TransformEdge( - "axes", - desc_like(xy, coordinates="axes"), - desc_like(xy, coordinates="display"), - transform=self.axes.transAxes, - ) - ) + def draw(self, renderer, graph=None): + if not self.get_visible(): + return - self._artist.draw(renderer, edges) + if graph is None: + graph = Graph([]) + self._artist.draw(renderer, graph + self._graph) class CompatibilityAxes: @@ -111,11 +152,44 @@ class CompatibilityAxes: """ def __init__(self, axes): - self.axes = axes + self._axes = axes self.figure = None self._clippath = None + self._visible = True self.zorder = 2 - self._children = [] + 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 @@ -135,39 +209,28 @@ def set_clip_path(self, path): def get_animated(self): return False - def draw(self, renderer, edges=None): - if edges is None: - edges = [] + def draw(self, renderer, graph=None): + if not self.visible: + return + if graph is None: + graph = Graph([]) - if self.axes is not None: - desc: Desc = Desc(("N",), coordinates="data") - xy: dict[str, Desc] = {"x": desc, "y": desc} - edges.append( - TransformEdge( - "data", - xy, - desc_like(xy, coordinates="axes"), - transform=self.axes.transData - self.axes.transAxes, - ) - ) - edges.append( - TransformEdge( - "axes", - desc_like(xy, coordinates="axes"), - desc_like(xy, coordinates="display"), - transform=self.axes.transAxes, - ) - ) + graph = graph + self._graph - # TODO independent zorder - for c in self._children: - c.draw(renderer, edges) + for _, c in self._children: + c.draw(renderer, graph) - def add_artist(self, artist): - self._children.append(artist) + 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 4c46d34..fd45106 100644 --- a/data_prototype/containers.py +++ b/data_prototype/containers.py @@ -82,11 +82,16 @@ 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) if isinstance(v, np.ndarray) else Desc(())) + k: ( + Desc(v.shape, coordinates.get(k, "auto")) + if isinstance(v, np.ndarray) + else Desc(()) + ) for k, v in data.items() } diff --git a/data_prototype/conversion_edge.py b/data_prototype/conversion_edge.py index 674d304..9091783 100644 --- a/data_prototype/conversion_edge.py +++ b/data_prototype/conversion_edge.py @@ -225,8 +225,11 @@ def inverse(self) -> "TransformEdge": class Graph: - def __init__(self, edges: Sequence[Edge]): - self._edges = edges + 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: @@ -245,7 +248,7 @@ def __init__(self, edges: Sequence[Edge]): s |= keys self._subgraphs[overlapping[0]][1].append(edge) else: - edges_combined = [] + edges_combined = [edge] for n in overlapping: keys |= self._subgraphs[n][0] edges_combined.extend(self._subgraphs[n][1]) @@ -253,21 +256,45 @@ def __init__(self, edges: Sequence[Edge]): 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: 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(order=True) + @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)) @@ -276,12 +303,12 @@ class Node: n = q.get() if n.weight > best.weight: continue - if Desc.compatible(n.desc, output_subset): + 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): + if Desc.compatible(n.desc, e.input, aliases=self._aliases): d = n.desc | e.output w = n.weight + e.weight @@ -328,6 +355,7 @@ class Node: 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): @@ -376,3 +404,9 @@ def node_format(x): 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 5bffe0b..bdaf183 100644 --- a/data_prototype/description.py +++ b/data_prototype/description.py @@ -103,17 +103,32 @@ 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 diff --git a/data_prototype/image.py b/data_prototype/image.py index a163b37..c8821fe 100644 --- a/data_prototype/image.py +++ b/data_prototype/image.py @@ -1,70 +1,113 @@ -from typing import Sequence +import numpy as np -import matplotlib.cm as mcm +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 Edge, FuncEdge, Graph, CoordinateEdge, DefaultEdge +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, **kwargs): + 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 - strdesc = Desc(()) arrdesc = Desc(("M", "N")) - normdesc = Desc((), "norm") - cmapdesc = Desc((), "cmap") + + 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("A_pcolor", {"A": Desc(("M", "N"))}, "data"), - CoordinateEdge.from_coords("A_rgb", {"A": Desc(("M", "N", 3))}, "rgb"), - CoordinateEdge.from_coords("A_rgba", {"A": Desc(("M", "N", 4))}, "rgba"), - FuncEdge.from_func( - "norm_obj", - lambda norm: mcm._auto_norm_from_scale(norm), - {"norm": desc_like(strdesc, coordinates="norm")}, - {"norm": normdesc}, + CoordinateEdge.from_coords("xycoords", {"x": "auto", "y": "auto"}, "data"), + CoordinateEdge.from_coords( + "image_coords", {"image": Desc(("M", "N"), "auto")}, "data" ), FuncEdge.from_func( - "cmap_obj", - lambda cmap: mcm._colormaps[cmap], - {"cmap": desc_like(strdesc, coordinates="cmap")}, - {"cmap": cmapdesc}, + "image_norm", + lambda image: self.norm(image), + {"image": desc_like(arrdesc, coordinates="data_resampled")}, + {"image": desc_like(arrdesc, coordinates="norm")}, ), FuncEdge.from_func( - "A_norm", - lambda A, norm: norm(A), - {"A": desc_like(arrdesc), "norm": normdesc}, - {"A": desc_like(arrdesc, coordinates="norm")}, + "image_cmap", + lambda image: self.cmap(image), + {"image": desc_like(arrdesc, coordinates="norm")}, + {"image": Desc(("M", "N", 4), coordinates="rgba")}, ), FuncEdge.from_func( - "A_cmap", - lambda A, cmap: cmap(A), - {"A": desc_like(arrdesc, coordinates="norm"), "cmap": cmapdesc}, - {"A": Desc(("M", "N", 4), coordinates="rgba")}, - ), - DefaultEdge.from_default_value("cmap_def", "cmap", strdesc, "viridis"), - DefaultEdge.from_default_value("norm_def", "norm", strdesc, "linear"), - CoordinateEdge.from_coords( - "A_rgba", {"A": Desc(("M", "N", 4), "rgba")}, "display" + "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, ] - def draw(self, renderer, edges: Sequence[Edge]) -> None: - import matplotlib.pyplot as plt + self._graph = Graph(self._edges, (("data", "data_resampled"),)) - g = Graph(list(edges) + self._edges) - g.visualize() - plt.show() + def draw(self, renderer, graph: Graph) -> None: + if not self.get_visible(): + return + g = graph + self._graph conv = g.evaluator( self._container.describe(), - {"A": Desc(("M", "N", 4), "display")}, + { + "image": Desc(("O", "P", 4), "display"), + "x": Desc(("X",), "display"), + "y": Desc(("Y",), "display"), + }, ) query, _ = self._container.query(g) - image = conv.evaluate(query)["A"] + 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() - renderer.draw_image(gc, image, mtransforms.IdentityTransform()) - ... + 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 index 56f893f..026cb10 100644 --- a/data_prototype/line.py +++ b/data_prototype/line.py @@ -1,5 +1,3 @@ -from typing import Sequence - import matplotlib.path as mpath import matplotlib.colors as mcolors import matplotlib.lines as mlines @@ -9,7 +7,7 @@ from .artist import Artist from .description import Desc -from .conversion_edge import Edge, Graph, CoordinateEdge, DefaultEdge +from .conversion_edge import Graph, CoordinateEdge, DefaultEdge class Line(Artist): @@ -18,7 +16,7 @@ def __init__(self, container, edges=None, **kwargs): scalar = Desc((), "display") # ... this needs thinking... - self._edges += [ + edges = [ CoordinateEdge.from_coords("xycoords", {"x": "auto", "y": "auto"}, "data"), CoordinateEdge.from_coords("color", {"color": Desc(())}, "display"), CoordinateEdge.from_coords("linewidth", {"linewidth": Desc(())}, "display"), @@ -45,6 +43,7 @@ def __init__(self, container, edges=None, **kwargs): 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 @@ -58,8 +57,10 @@ def __init__(self, container, edges=None, **kwargs): # - non-str markers # Each individually pretty easy, but relatively rare features, focusing on common cases - def draw(self, renderer, edges: Sequence[Edge]) -> None: - g = Graph(list(edges) + self._edges) + 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... @@ -81,10 +82,20 @@ def draw(self, renderer, edges: Sequence[Edge]) -> None: 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)) @@ -93,6 +104,9 @@ def draw(self, renderer, edges: Sequence[Edge]) -> None: 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) diff --git a/examples/animation.py b/examples/animation.py index 6e1fe4e..d3e276c 100644 --- a/examples/animation.py +++ b/examples/animation.py @@ -47,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() - - 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() + 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) def update(frame, art): diff --git a/examples/first.py b/examples/first.py index f9b719a..b797a8a 100644 --- a/examples/first.py +++ b/examples/first.py @@ -15,8 +15,9 @@ 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 = Line(fc, linewidth=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") @@ -33,9 +34,9 @@ fig, nax = plt.subplots() ax = CompatibilityAxes(nax) nax.add_artist(ax) -ax.add_artist(lw) -ax.add_artist(lw2) +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.ion() plt.show() diff --git a/examples/subsample.py b/examples/subsample.py index 072d66a..97a67d7 100644 --- a/examples/subsample.py +++ b/examples/subsample.py @@ -23,6 +23,7 @@ from data_prototype.description import Desc, desc_like 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 @@ -39,8 +40,8 @@ class Subsample: def describe(self): return { - "xextent": Desc((2,)), - "yextent": Desc((2,)), + "x": Desc((2,)), + "y": Desc((2,)), "image": Desc(("M", "N")), } @@ -66,12 +67,14 @@ 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) From e5b54555e919787b57c69f9e3e385f5b8f918d16 Mon Sep 17 00:00:00 2001 From: Kyle Sunden Date: Fri, 19 Apr 2024 14:13:07 -0500 Subject: [PATCH 9/9] rm plt.ion --- examples/first.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/first.py b/examples/first.py index b797a8a..4357c9e 100644 --- a/examples/first.py +++ b/examples/first.py @@ -38,5 +38,4 @@ ax.add_artist(lw2, 2) ax.set_xlim(0, np.pi * 4) ax.set_ylim(-1.1, 1.1) -plt.ion() plt.show()