Skip to content

Commit e730142

Browse files
authored
Export to pillow graph (#345)
* feat: plot allow reverse, add method for tree_to_pillow_graph * docs: update docs for new export to pillow graph method * feat: margins to replace default margins instead * test: test for export to pillow graph * feat: round up for width and height for py38 * bump: v0.24.0, add pillow graph export * docs: update doc wording
1 parent 20cfee5 commit e730142

16 files changed

+347
-28
lines changed

CHANGELOG.md

+6-1
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
8+
9+
## [0.24.0] - 2025-02-09
810
### Added:
911
- Docs: Tips for setting custom coordinates for plots.
12+
- Tree Exporter: `tree_to_pillow_graph` method to export tree to pillow image in graph format.
1013
### Changed:
14+
- Plot: Allow `reverse` argument to allow top-bottom y coordinates in Reingold Tilford algorithm.
1115
- Docs: Add more elaboration for exporting to image for tree and dag.
1216
- Misc: Split tree/construct and tree/export into multiple files.
1317

@@ -725,7 +729,8 @@ ignore null attribute columns.
725729
- Utility Iterator: Tree traversal methods.
726730
- Workflow To Do App: Tree use case with to-do list implementation.
727731

728-
[Unreleased]: https://github.com/kayjan/bigtree/compare/0.23.1...HEAD
732+
[Unreleased]: https://github.com/kayjan/bigtree/compare/0.24.0...HEAD
733+
[0.24.0]: https://github.com/kayjan/bigtree/compare/0.23.1...0.24.0
729734
[0.23.1]: https://github.com/kayjan/bigtree/compare/0.23.0...0.23.1
730735
[0.23.0]: https://github.com/kayjan/bigtree/compare/0.22.3...0.23.0
731736
[0.22.3]: https://github.com/kayjan/bigtree/compare/0.22.2...0.22.3

assets/construct_binarytree.png

79 Bytes
Loading

assets/demo/pillow_graph.png

4.23 KB
Loading

assets/export_tree_dot.png

-31 Bytes
Loading

assets/export_tree_dot_callable.png

-33 Bytes
Loading

assets/tree.dot

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
strict digraph G {
2+
graph [bb="0,0,162,180",
3+
rankdir=TB
4+
];
5+
node [label="\N"];
6+
a0 [height=0.5,
7+
label=a,
8+
pos="99,162",
9+
width=0.75];
10+
b0 [height=0.5,
11+
label=b,
12+
pos="63,90",
13+
width=0.75];
14+
a0 -> b0 [pos="e,71.304,107.15 90.65,144.76 86.425,136.55 81.192,126.37 76.419,117.09"];
15+
c0 [height=0.5,
16+
label=c,
17+
pos="135,90",
18+
width=0.75];
19+
a0 -> c0 [pos="e,126.7,107.15 107.35,144.76 111.58,136.55 116.81,126.37 121.58,117.09"];
20+
d0 [height=0.5,
21+
label=d,
22+
pos="27,18",
23+
width=0.75];
24+
b0 -> d0 [pos="e,35.304,35.147 54.65,72.765 50.425,64.548 45.192,54.373 40.419,45.093"];
25+
e0 [height=0.5,
26+
label=e,
27+
pos="99,18",
28+
width=0.75];
29+
b0 -> e0 [pos="e,90.696,35.147 71.35,72.765 75.575,64.548 80.808,54.373 85.581,45.093"];
30+
}

assets/tree.png

11.8 KB
Loading

assets/tree_pillow_graph.jpg

6.34 KB
Loading

assets/tree_pillow_graph.png

1.38 KB
Loading

bigtree/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
tree_to_nested_dict,
3838
tree_to_newick,
3939
tree_to_pillow,
40+
tree_to_pillow_graph,
4041
tree_to_polars,
4142
yield_tree,
4243
)

bigtree/tree/export/__init__.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
from .dataframes import tree_to_dataframe, tree_to_polars # noqa
22
from .dictionaries import tree_to_dict, tree_to_nested_dict # noqa
3-
from .images import tree_to_dot, tree_to_mermaid, tree_to_pillow # noqa
3+
from .images import ( # noqa
4+
tree_to_dot,
5+
tree_to_mermaid,
6+
tree_to_pillow,
7+
tree_to_pillow_graph,
8+
)
49
from .stdout import ( # noqa
510
hprint_tree,
611
hyield_tree,
@@ -20,6 +25,7 @@
2025
"tree_to_nested_dict",
2126
"tree_to_dot",
2227
"tree_to_pillow",
28+
"tree_to_pillow_graph",
2329
"tree_to_mermaid",
2430
"tree_to_newick",
2531
]

bigtree/tree/export/images.py

+194-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import collections
4+
import re
45
from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar, Union
56

67
from bigtree.node import node
@@ -24,6 +25,7 @@
2425

2526
__all__ = [
2627
"tree_to_dot",
28+
"tree_to_pillow_graph",
2729
"tree_to_pillow",
2830
"tree_to_mermaid",
2931
]
@@ -67,8 +69,10 @@ def tree_to_dot(
6769
6870
Export to image, dot file, etc.
6971
70-
>>> graph.write_png("assets/docstr/tree.png")
71-
>>> graph.write_dot("assets/docstr/tree.dot")
72+
>>> graph.write_png("assets/tree.png")
73+
>>> graph.write_dot("assets/tree.dot")
74+
75+
![Export to Dot](https://github.com/kayjan/bigtree/raw/master/assets/tree.png)
7276
7377
Export to string
7478
@@ -207,6 +211,193 @@ def _recursive_append(parent_name: Optional[str], child_node: T) -> None:
207211
return _graph
208212

209213

214+
@exceptions.optional_dependencies_image("Pillow")
215+
def tree_to_pillow_graph(
216+
tree: T,
217+
node_content: str = "{node_name}",
218+
*,
219+
margin: Optional[Dict[str, int]] = None,
220+
height_buffer: Union[int, float] = 20,
221+
width_buffer: Union[int, float] = 10,
222+
font_family: str = "",
223+
font_size: int = 12,
224+
font_colour: Union[Tuple[int, int, int], str] = "black",
225+
text_align: str = "center",
226+
bg_colour: Union[Tuple[int, int, int], str] = "white",
227+
rect_margin: Optional[Dict[str, int]] = None,
228+
rect_fill: Union[Tuple[int, int, int], str] = "white",
229+
rect_outline: Union[Tuple[int, int, int], str] = "black",
230+
rect_width: Union[float, int] = 1,
231+
) -> Image.Image:
232+
r"""Export tree to PIL.Image.Image object. Object can be
233+
converted to other formats, such as jpg, or png. Image will look
234+
like a tree/graph-like structure.
235+
236+
Customisations:
237+
238+
- To change the margin of tree within diagram, vary `margin`
239+
- To change the margin of the text within node, vary `rect_margin`
240+
- For more separation between nodes, change `height_buffer` and `width_buffer`
241+
242+
Examples:
243+
>>> from bigtree import Node, tree_to_pillow_graph
244+
>>> root = Node("a", age=90)
245+
>>> b = Node("b", age=65, parent=root)
246+
>>> c = Node("c", age=60, parent=root)
247+
>>> d = Node("d", age=40, parent=b)
248+
>>> e = Node("e", age=35, parent=b)
249+
>>> pillow_image = tree_to_pillow_graph(root, node_content="{node_name}\nAge: {age}")
250+
251+
Export to image (PNG, JPG) file, etc.
252+
253+
>>> pillow_image.save("assets/tree_pillow_graph.png")
254+
>>> pillow_image.save("assets/tree_pillow_graph.jpg")
255+
256+
![Export to Pillow Graph](https://github.com/kayjan/bigtree/raw/master/assets/tree_pillow_graph.png)
257+
258+
Args:
259+
tree (Node): tree to be exported
260+
node_content (str): display text in node
261+
margin (Dict[str, int]): margin of diagram
262+
height_buffer (Union[int, float]): height buffer between node layers, in pixels
263+
width_buffer (Union[int, float]) : width buffer between sibling nodes, in pixels
264+
font_family (str): file path of font family, requires .ttf file, defaults to DejaVuSans
265+
font_size (int): font size, defaults to 12
266+
font_colour (Union[Tuple[int, int, int], str]): font colour, accepts tuple of RGB values or string, defaults to black
267+
text_align (str): text align for multi-line text
268+
bg_colour (Union[Tuple[int, int, int], str]): background of image, accepts tuple of RGB values or string, defaults to white
269+
rect_margin (Dict[str, int]): (for rectangle) margin of text to rectangle, in pixels
270+
rect_fill (Union[Tuple[int, int, int], str]): (for rectangle) colour to use for fill
271+
rect_outline (Union[Tuple[int, int, int], str]): (for rectangle) colour to use for outline
272+
rect_width (Union[float, int]): (for rectangle) line width, in pixels
273+
274+
Returns:
275+
(PIL.Image.Image)
276+
"""
277+
default_margin = {"t": 10, "b": 10, "l": 10, "r": 10}
278+
default_rect_margin = {"t": 5, "b": 5, "l": 5, "r": 5}
279+
if not margin:
280+
margin = default_margin
281+
else:
282+
margin = {**default_margin, **margin}
283+
if not rect_margin:
284+
rect_margin = default_rect_margin
285+
else:
286+
rect_margin = {**default_rect_margin, **rect_margin}
287+
288+
# Initialize font
289+
if not font_family:
290+
from urllib.request import urlopen
291+
292+
dejavusans_url = "https://github.com/kayjan/bigtree/raw/master/assets/DejaVuSans.ttf?raw=true"
293+
font_family = urlopen(dejavusans_url)
294+
try:
295+
font = ImageFont.truetype(font_family, font_size)
296+
except OSError:
297+
raise ValueError(
298+
f"Font file {font_family} is not found, set `font_family` parameter to point to a valid .ttf file."
299+
)
300+
301+
# Calculate image dimension from text, otherwise override with argument
302+
_max_text_width = 0
303+
_max_text_height = 0
304+
_image = Image.new("RGB", (0, 0))
305+
_draw = ImageDraw.Draw(_image)
306+
307+
def get_node_text(_node: T, _node_content: str) -> str:
308+
pattern = re.compile(r"\{(.*)\}")
309+
matches = re.findall(pattern, _node_content)
310+
for match in matches:
311+
_node_content = _node_content.replace(
312+
f"{{{match}}}",
313+
str(_node.get_attr(match)) if _node.get_attr(match) else "",
314+
)
315+
return _node_content
316+
317+
for _, _, _node in yield_tree(tree):
318+
l, t, r, b = _draw.multiline_textbbox(
319+
(0, 0), get_node_text(_node, node_content), font=font
320+
)
321+
_max_text_width = max(
322+
_max_text_width, l + r + rect_margin.get("l", 0) + rect_margin.get("r", 0)
323+
)
324+
_max_text_height = max(
325+
_max_text_height, t + b + rect_margin.get("t", 0) + rect_margin.get("b", 0)
326+
)
327+
328+
# Get x, y, coordinates and height, width of diagram
329+
from bigtree.utils.plot import reingold_tilford
330+
331+
tree = tree.copy()
332+
reingold_tilford(
333+
tree,
334+
subtree_separation=_max_text_width + width_buffer,
335+
sibling_separation=_max_text_width + width_buffer,
336+
level_separation=_max_text_height + height_buffer,
337+
x_offset=0.5 * _max_text_width + margin.get("l", 0),
338+
y_offset=0.5 * _max_text_height + margin.get("t", 0),
339+
reverse=True, # top-left corner is (0, 0)
340+
)
341+
342+
_width, _height = 0, 0
343+
_width_margin = 0.5 * _max_text_width + margin.get("r", 0)
344+
_height_margin = 0.5 * _max_text_height + margin.get("b")
345+
for _, _, _node in yield_tree(tree):
346+
_width = max(_width, _node.get_attr("x") + _width_margin)
347+
_height = max(_height, _node.get_attr("y") + _height_margin)
348+
_width = int(round(_width + 0.5, 0))
349+
_height = int(round(_height + 0.5, 0))
350+
351+
# Initialize and draw image
352+
image = Image.new("RGB", (_width, _height), bg_colour)
353+
image_draw = ImageDraw.Draw(image)
354+
355+
for _, _, _node in yield_tree(tree):
356+
_x, _y = _node.get_attr("x"), _node.get_attr("y")
357+
x1, x2 = _x - 0.5 * _max_text_width, _x + 0.5 * _max_text_width
358+
y1, y2 = _y - 0.5 * _max_text_height, _y + 0.5 * _max_text_height
359+
# Draw box
360+
image_draw.rectangle(
361+
[x1, y1, x2, y2], fill=rect_fill, outline=rect_outline, width=rect_width
362+
)
363+
# Draw text
364+
image_draw.text(
365+
(x1 + rect_margin.get("l", 0), y1 + rect_margin.get("t", 0)),
366+
get_node_text(_node, node_content),
367+
font=font,
368+
fill=font_colour,
369+
align=text_align,
370+
)
371+
# Draw line to parent
372+
if _node.parent:
373+
_child_x, _child_y = (
374+
_node.get_attr("x"),
375+
_node.get_attr("y") - 0.5 * _max_text_height,
376+
)
377+
_parent_x, _parent_y = (
378+
_node.parent.get_attr("x"),
379+
_node.parent.get_attr("y") + 0.5 * _max_text_height,
380+
)
381+
middle_y = (_child_y + _parent_y) / 2
382+
image_draw.line(
383+
(_parent_x, _parent_y, _parent_x, middle_y),
384+
fill=rect_outline,
385+
width=rect_width,
386+
)
387+
image_draw.line(
388+
(_parent_x, middle_y, _child_x, middle_y),
389+
fill=rect_outline,
390+
width=rect_width,
391+
)
392+
image_draw.line(
393+
(_child_x, _child_y, _child_x, middle_y),
394+
fill=rect_outline,
395+
width=rect_width,
396+
)
397+
398+
return image
399+
400+
210401
@exceptions.optional_dependencies_image("Pillow")
211402
def tree_to_pillow(
212403
tree: T,
@@ -220,7 +411,7 @@ def tree_to_pillow(
220411
**kwargs: Any,
221412
) -> Image.Image:
222413
"""Export tree to PIL.Image.Image object. Object can be
223-
converted to other format, such as jpg, or png. Image will be
414+
converted to other formats, such as jpg, or png. Image will be
224415
similar format as `print_tree`, accepts additional keyword arguments
225416
as input to `yield_tree`.
226417

bigtree/utils/plot.py

+12-2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def reingold_tilford(
2626
level_separation: float = 1.0,
2727
x_offset: float = 0.0,
2828
y_offset: float = 0.0,
29+
reverse: bool = False,
2930
) -> None:
3031
"""
3132
Algorithm for drawing tree structure, retrieves `(x, y)` coordinates for a tree structure.
@@ -77,9 +78,12 @@ def reingold_tilford(
7778
level_separation (float): fixed distance between adjacent levels of the tree
7879
x_offset (float): graph offset of x-coordinates
7980
y_offset (float): graph offset of y-coordinates
81+
reverse (bool): graph begins bottom to top by default, set to True for top to bottom y coordinates
8082
"""
8183
_first_pass(tree_node, sibling_separation, subtree_separation)
82-
x_adjustment = _second_pass(tree_node, level_separation, x_offset, y_offset)
84+
x_adjustment = _second_pass(
85+
tree_node, level_separation, x_offset, y_offset, reverse
86+
)
8387
_third_pass(tree_node, x_adjustment)
8488

8589

@@ -333,6 +337,7 @@ def _second_pass(
333337
level_separation: float,
334338
x_offset: float,
335339
y_offset: float,
340+
reverse: bool,
336341
cum_mod: Optional[float] = 0.0,
337342
max_depth: Optional[int] = None,
338343
x_adjustment: Optional[float] = 0.0,
@@ -356,6 +361,7 @@ def _second_pass(
356361
level_separation (float): fixed distance between adjacent levels of the tree (constant across iteration)
357362
x_offset (float): graph offset of x-coordinates (constant across iteration)
358363
y_offset (float): graph offset of y-coordinates (constant across iteration)
364+
reverse (bool): graph begins bottom to top by default, set to True for top to bottom y coordinates
359365
cum_mod (Optional[float]): cumulative `mod + shift` for tree/subtree from the ancestors
360366
max_depth (Optional[int]): maximum depth of tree (constant across iteration)
361367
x_adjustment (Optional[float]): amount of x-adjustment for third pass, in case any x-coordinates goes below 0
@@ -369,7 +375,10 @@ def _second_pass(
369375
final_x: float = (
370376
tree_node.get_attr("x") + tree_node.get_attr("shift") + cum_mod + x_offset
371377
)
372-
final_y: float = (max_depth - tree_node.depth) * level_separation + y_offset
378+
if reverse:
379+
final_y: float = (tree_node.depth - 1) * level_separation + y_offset
380+
else:
381+
final_y = (max_depth - tree_node.depth) * level_separation + y_offset
373382
tree_node.set_attrs({"x": final_x, "y": final_y})
374383

375384
# Pre-order iteration (NLR)
@@ -381,6 +390,7 @@ def _second_pass(
381390
level_separation,
382391
x_offset,
383392
y_offset,
393+
reverse,
384394
cum_mod + tree_node.get_attr("mod") + tree_node.get_attr("shift"),
385395
max_depth,
386396
x_adjustment,

0 commit comments

Comments
 (0)