├── .github
└── workflows
│ └── publish.yml
├── .gitignore
├── .vscode
├── launch.json
└── settings.json
├── LICENSE
├── MANIFEST.in
├── README.md
├── Tutorial.ipynb
├── examples
├── data
│ ├── BalanceOptimal.json
│ ├── Defensive.json
│ └── Stratified.json
├── errors
│ └── pool
│ │ ├── pool__full.json
│ │ ├── pool__path.json
│ │ ├── pool__radiance.json
│ │ └── pool__upsmcmc.json
├── figure_in_figure.py
├── full_size_with_crops.py
├── images
│ └── pool
│ │ ├── pool-60s-full.exr
│ │ ├── pool-60s-path.exr
│ │ ├── pool-60s-radiance.exr
│ │ ├── pool-60s-upsmcmc.exr
│ │ └── pool.exr
├── jpeg_export.py
├── matplot_module.py
├── multi_module.py
├── multi_plot_module.py
├── plotgrid.png
├── plotgrid.py
├── pool-siggraph.png
├── pool.py
├── pool_with_template.png
├── pool_with_template.py
├── siggraph_example.py
├── single-grid.png
├── single_module.py
├── split-comparison.png
├── split_comparison.py
├── vertical-stack.png
└── vertical_stack.py
├── figure_generator_layout.pptx
├── figuregen
├── __init__.py
├── backend.py
├── calculate.py
├── commands.tikz
├── element_data.py
├── figuregen.py
├── html.py
├── layout.py
├── matplot_lineplot.py
├── pdflatex.py
├── pgf_lineplot.py
├── powerpoint.py
├── theme.pptx
├── tikz.py
└── util
│ ├── __init__.py
│ ├── image.py
│ ├── jupyter.py
│ ├── templates.py
│ ├── tex.py
│ └── units.py
├── grid-layout.png
├── multi-module.png
├── setup.ps1
├── setup.py
├── setup.sh
└── tests
└── test_alignment.py
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 | on: [push]
3 |
4 | jobs:
5 | pack:
6 | runs-on: ubuntu-latest
7 |
8 | environment:
9 | name: pypi
10 | url: https://pypi.org/p/figuregen
11 | permissions:
12 | id-token: write
13 |
14 | steps:
15 | - uses: actions/checkout@v3
16 |
17 | - uses: actions/setup-python@v4
18 | with:
19 | python-version: '3.11'
20 |
21 | - name: Fetch Python build dependencies
22 | run: python -m pip install --user build wheel
23 |
24 | - name: Build
25 | run: python -m build
26 |
27 | - name: Publish to PyPI
28 | uses: pypa/gh-action-pypi-publish@release/v1
29 | with:
30 | skip-existing: true
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated figures and intermediate files
2 | gen_*.json
3 | /examples/*.tex
4 | /examples/*.pdf
5 | /examples/*.png
6 | /examples/*.tikz
7 | /examples/*.html
8 | /tests/*.pdf
9 | !grid-layout.png
10 | !vertical-stack.png
11 | !split-comparison.png
12 | !pool-siggraph.png
13 | !single-grid.png
14 | !plotgrid.png
15 | !pool_with_template.png
16 | *.exr
17 | log
18 | *.pptx
19 | !figure_generator_layout.pptx
20 | !theme.pptx
21 |
22 | # siggraph sample data
23 | *__path.json
24 | *__pgfull6.json
25 | *__full.json
26 | *__radiance.json
27 | *__pgrad6.json
28 | *__pgfull7.json
29 | *__pgrad7.json
30 | *__upsmcmc.json
31 | !examples/errors/pool/*
32 | !examples/images/pool/*
33 |
34 | # Python compiled files
35 | __pycache__
36 | .ipynb_checkpoints
37 |
38 | # Visual studio generated / user specific
39 | .vs
40 | uploadpip.ps1
41 |
42 | # Python setuptools / wheel / pip generated files
43 | build/*
44 | figuregen.egg-info/*
45 | dist/*
46 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": "Python: Current File",
9 | "type": "python",
10 | "request": "launch",
11 | "program": "${file}",
12 | "cwd": "${fileDirname}",
13 | "console": "integratedTerminal",
14 | "env": {
15 | "PYTHONPATH": "${workspaceFolder}"
16 | }
17 | }
18 | ]
19 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "python.analysis.extraPaths": ["tests", "examples"]
3 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Mira Niemann & Pascal Grittmann
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include figuregen/commands.tikz
2 | include figuregen/theme.pptx
3 | include commands.tikz
4 |
5 | exclude tests
6 | exclude examples
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Figure Generator
2 |
3 | This is an awesome figure generator. It generates figures in pdf-, html- and pptx-format.
4 | The following image shows the output of one of our test files ("tests/pool.py"):
5 | 
6 |
7 | This tool might help not only to create final figures, but also to analyze images faster: We offer a bunch of error metrics that allows not only to compare images visually but also mathematically.
8 |
9 | Why did we create a figure generator?
10 |
11 | In rendering research, it is quite common to create figures of "comparison"-type. Meaning, that we start with a set of generated images, that needs to be compared. Often, one rendered scene is not enough, therefore, we need several comparison figures - preferably in a similar or same style as the other created figures.
12 |
13 | We support _grids_ (images that are grid-like arranged) and simple _line-plotting_. To get a further understanding what _grids_ are, you might want to have a look at our tutorial ([Tutorial.ipynb](Tutorial.ipynb)).
14 |
15 | ## Dependencies
16 |
17 | Mandatory:
18 | - Python 3.11+ with opencv-python, simpleimageio, and texsnip
19 |
20 | Optional:
21 | - For the .pdf backend: pdflatex (in path) with at least: tikz, calc, standalone, fontenc, libertine, inputenc.
22 | - For the .pptx backend: python-pptx
23 | - To include pdf files as image data: PyPDF2, and pdf2image ([which requires poppler](https://pypi.org/project/pdf2image/)).
24 |
25 | ## Quickstart
26 |
27 | You can install the figure generator and all mandatory dependencies with a simple:
28 |
29 | ```
30 | python -m pip install figuregen
31 | ```
32 |
33 | The fastest way to get a first figure is by using an existing template:
34 |
35 | ```Python
36 | import simpleimageio as sio
37 | import figuregen
38 | from figuregen.util.templates import CropComparison
39 | from figuregen.util.image import Cropbox
40 |
41 | figure = CropComparison(
42 | reference_image=sio.read("images/pool/pool.exr"),
43 | method_images=[
44 | sio.read("images/pool/pool-60s-path.exr"),
45 | sio.read("images/pool/pool-60s-upsmcmc.exr"),
46 | sio.read("images/pool/pool-60s-radiance.exr"),
47 | sio.read("images/pool/pool-60s-full.exr"),
48 | ],
49 | crops=[
50 | Cropbox(top=100, left=200, height=96, width=128, scale=5),
51 | Cropbox(top=100, left=450, height=96, width=128, scale=5),
52 | ],
53 | scene_name="Pool",
54 | method_names=["Reference", "Path Tracer", "UPS+MCMC", "Radiance-based", "Ours"]
55 | )
56 |
57 | # here you can modify the figure layout and data
58 | # ...
59 |
60 | # Generate the figure with the pdflatex backend and default settings
61 | figuregen.figure([figure.figure_row], width_cm=17.7, filename="pool_with_template.pdf")
62 | ```
63 | 
64 |
65 | The template simply creates a list of `Grid` objects that can be modified and extended arbitrarily before passing it to the `figure()` function.
66 |
67 | Examples and inspiration for creating your own figure layouts can be found in [our examples](examples) or the [Jupyter tutorial](Tutorial.ipynb).
68 |
69 | ## Examples
70 |
71 | Clicking on an image below leads to the test that created the corresponding figure.
72 |
73 | ### Vertical stacks
74 | [](examples/vertical_stack.py)
75 |
76 | ### Split Comparison
77 | [
](examples/split_comparison.py)
78 |
79 | ### Crop Comparison
80 | [
](examples/siggraph_example.py)
81 |
82 | ### Plots
83 | [
](examples/plotgrid.py)
84 |
85 | ### Grid with titles, labels, markers, and frames
86 | [
](examples/single_module.py)
87 |
--------------------------------------------------------------------------------
/examples/figure_in_figure.py:
--------------------------------------------------------------------------------
1 | import figuregen
2 | import os
3 | import numpy as np
4 | import vertical_stack
5 |
6 | # Note: LaTeX and PPTX-backend do not support html files
7 | # if test_html is True, we only generate the figure with the html backend
8 | test_html = False
9 |
10 | # PNG test
11 | blue=[82, 110, 186]
12 | img_blue = np.tile([x / 255 for x in blue], (32, 32, 1))
13 | img_png = figuregen.PNG(img_blue)
14 |
15 | if test_html:
16 | # HTML test
17 | figuregen.figure(vertical_stack.v_grids, width_cm=15., filename='v-stacked.html')
18 | htmlfile = os.path.abspath('v-stacked.html')
19 | img_test = figuregen.HTML(htmlfile, aspect_ratio=0.3)
20 | else:
21 | # PDF test
22 | figuregen.figure(vertical_stack.v_grids, width_cm=15., filename='v-stacked.pdf')
23 | pdffile = os.path.abspath('v-stacked.pdf')
24 | img_test = figuregen.PDF(pdffile)
25 |
26 | images = [
27 | img_test,
28 | img_png,
29 | ]
30 |
31 | # ---- GRIDS ----
32 | grid = figuregen.Grid(1, 1)
33 | grid.layout.padding[figuregen.RIGHT] = 1.5
34 | grid.layout.padding[figuregen.BOTTOM] = 1
35 | e1 = grid.get_element(0,0).set_image(images[0])
36 |
37 | grid2 = figuregen.Grid(1, 1)
38 | e2 = grid2.get_element(0,0).set_image(images[1])
39 | grid2.layout.padding[figuregen.BOTTOM] = 1
40 |
41 | all_grids = [
42 | [grid, grid],
43 | [grid, grid2]
44 | ]
45 |
46 | if __name__ == "__main__":
47 | if test_html:
48 | figuregen.figure(all_grids, width_cm=28., filename='figure_in_figure.html')
49 | else:
50 | figuregen.figure(all_grids, width_cm=28., filename='figure_in_figure.html')
51 | figuregen.figure(all_grids, width_cm=28., filename='figure_in_figure.pptx')
52 | figuregen.figure(all_grids, width_cm=28., filename='figure_in_figure.pdf')
--------------------------------------------------------------------------------
/examples/full_size_with_crops.py:
--------------------------------------------------------------------------------
1 | import simpleimageio as sio
2 | import figuregen
3 | from figuregen.util.templates import FullSizeWithCrops
4 | from figuregen.util.image import Cropbox
5 |
6 | figure = FullSizeWithCrops(
7 | reference_image=sio.read("images/pool/pool.exr"),
8 | method_images=[
9 | sio.read("images/pool/pool-60s-path.exr"),
10 | sio.read("images/pool/pool-60s-upsmcmc.exr"),
11 | sio.read("images/pool/pool-60s-radiance.exr"),
12 | sio.read("images/pool/pool-60s-full.exr"),
13 | ],
14 | crops=[
15 | Cropbox(top=100, left=200, height=96, width=128, scale=5),
16 | Cropbox(top=100, left=450, height=96, width=128, scale=5),
17 | ],
18 | method_names=["Reference", "Path Tracer", "UPS+MCMC", "Radiance-based", "Ours"]
19 | ).figure
20 |
21 | figuregen.figure(figure, width_cm=17.7, filename="full_size_with_crops.pdf")
22 |
23 | try:
24 | from figuregen.util import jupyter
25 | jupyter.convert('full_size_with_crops.pdf', 300)
26 | except:
27 | print('Warning: pdf could not be converted to png')
28 |
29 | # Create the same figure again, but this time with the crops on the right hand side of each image
30 | figure = FullSizeWithCrops(
31 | reference_image=sio.read("images/pool/pool.exr"),
32 | method_images=[
33 | sio.read("images/pool/pool-60s-path.exr"),
34 | sio.read("images/pool/pool-60s-radiance.exr"),
35 | sio.read("images/pool/pool-60s-full.exr"),
36 | ],
37 | crops=[
38 | Cropbox(top=100, left=200, height=96, width=128, scale=5),
39 | Cropbox(top=100, left=450, height=96, width=128, scale=5),
40 | ],
41 | method_names=["Reference", "Path Tracer", "UPS+MCMC", "Radiance-based", "Ours"],
42 | crops_below=False
43 | ).figure
44 |
45 | figuregen.figure(figure, width_cm=17.7, filename="full_size_with_crops_side.pdf")
46 |
47 | try:
48 | from figuregen.util import jupyter
49 | jupyter.convert('full_size_with_crops_side.pdf', 300)
50 | except:
51 | print('Warning: pdf could not be converted to png')
52 |
53 |
--------------------------------------------------------------------------------
/examples/images/pool/pool-60s-full.exr:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mira-13/figure-gen/888b0f4eb05f7e9c0f701c6ac9306521d5056c22/examples/images/pool/pool-60s-full.exr
--------------------------------------------------------------------------------
/examples/images/pool/pool-60s-path.exr:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mira-13/figure-gen/888b0f4eb05f7e9c0f701c6ac9306521d5056c22/examples/images/pool/pool-60s-path.exr
--------------------------------------------------------------------------------
/examples/images/pool/pool-60s-radiance.exr:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mira-13/figure-gen/888b0f4eb05f7e9c0f701c6ac9306521d5056c22/examples/images/pool/pool-60s-radiance.exr
--------------------------------------------------------------------------------
/examples/images/pool/pool-60s-upsmcmc.exr:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mira-13/figure-gen/888b0f4eb05f7e9c0f701c6ac9306521d5056c22/examples/images/pool/pool-60s-upsmcmc.exr
--------------------------------------------------------------------------------
/examples/images/pool/pool.exr:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mira-13/figure-gen/888b0f4eb05f7e9c0f701c6ac9306521d5056c22/examples/images/pool/pool.exr
--------------------------------------------------------------------------------
/examples/jpeg_export.py:
--------------------------------------------------------------------------------
1 | import figuregen
2 | import numpy as np
3 |
4 | img_blue = np.tile([x / 255 for x in [94, 163, 188]], (32, 64, 1))
5 | img_yellow = np.tile([x / 255 for x in [232, 181, 88]], (32, 64, 1))
6 |
7 | grid = figuregen.Grid(1, 2)
8 | grid[0, 0].set_image(figuregen.JPEG(img_blue))
9 | grid[0, 1].set_image(figuregen.JPEG(img_yellow))
10 |
11 | figuregen.horizontal_figure([grid], 8, "jpeg_export.pdf")
12 | figuregen.horizontal_figure([grid], 8, "jpeg_export.pptx")
13 | figuregen.horizontal_figure([grid], 8, "jpeg_export.html")
14 |
15 | grid = figuregen.Grid(1, 2)
16 | grid[0, 0].set_image(figuregen.PNG(img_blue))
17 | grid[0, 1].set_image(figuregen.PNG(img_yellow))
18 |
19 | figuregen.horizontal_figure([grid], 8, "png_export.pdf")
20 | figuregen.horizontal_figure([grid], 8, "png_export.pptx")
21 | figuregen.horizontal_figure([grid], 8, "png_export.html")
--------------------------------------------------------------------------------
/examples/matplot_module.py:
--------------------------------------------------------------------------------
1 | import figuregen
2 | import figuregen.util
3 | import numpy as np
4 |
5 | # generate test data
6 | seconds = np.linspace(1, 60, 42) #linespace(start, stop, num): Return evenly spaced numbers over a specified interval
7 | errors_1 = 100/seconds
8 | errors_2 = 80/seconds
9 |
10 | data = [
11 | (seconds, errors_1), (seconds, errors_2)
12 | ]
13 |
14 | plot_color = [
15 | [30, 180, 202],
16 | [170, 40, 20]
17 | ]
18 |
19 | plot = figuregen.MatplotLinePlot(1.15, data)
20 | plot.set_axis_label('x', "sec", rotation="horizontal")
21 | plot.set_axis_label('y', "error", rotation="vertical")
22 | plot.set_axis_properties('x', range=[1, 65], ticks=[5, 25, 50], use_log_scale=False, use_scientific_notations=False)
23 | plot.set_axis_properties('y', range=None, ticks=[5, 10, 30, 50], use_log_scale=True, use_scientific_notations=False)
24 | plot.set_v_line(pos=10.5, color=[242, 113, 0], linestyle=(0,(4,6)), linewidth_pt=0.6)
25 | plot.set_colors(plot_color)
26 | plot.set_linestyle(1, "dashed")
27 | plot.set_legend(["first line", "second line"])
28 |
29 | # ----- PLOT Module -----
30 | plot_module = figuregen.Grid(1,1)
31 | plot_module.get_element(0,0).set_image(plot)
32 |
33 | if __name__ == "__main__":
34 | figuregen.horizontal_figure([plot_module], width_cm=11., filename='matplot_test.pdf')
35 | figuregen.horizontal_figure([plot_module], width_cm=11., filename='matplot_test.pptx')
36 | figuregen.horizontal_figure([plot_module], width_cm=11., filename='matplot_test.html')
--------------------------------------------------------------------------------
/examples/multi_module.py:
--------------------------------------------------------------------------------
1 | import figuregen
2 | import numpy as np
3 | import single_module
4 |
5 | # generate test images
6 | blue = np.tile([0.2,0.3,0.9], (32, 32, 1))
7 | yellow = np.tile([0.9,0.8,0.2], (32, 32, 1))
8 |
9 | # load the two images
10 | images = [
11 | figuregen.PNG(blue),
12 | figuregen.PNG(yellow)
13 | ]
14 |
15 | # ---- Grid Module ----
16 | grid0 = figuregen.Grid(1, 1)
17 | grid0.layout.set_padding(right=0.5)
18 | e0 = grid0.get_element(0,0).set_image(images[1])
19 | e0.set_frame(0.3, [0,0,0])
20 | e0.set_marker(pos=[2,12], size=[10,10], color=[155, 155, 155], linewidth_pt=0.6)
21 | e0.set_marker(pos=[15,1], size=[10,15], color=[186, 98, 82], linewidth_pt=0.6)
22 |
23 | # ---- Grid Module ----
24 | grid1 = figuregen.Grid(2, 2)
25 | layout1 = grid1.layout
26 | layout1.set_padding(top=0.2, right=0.5)
27 |
28 | # fill grid with image data
29 | for row in range(2):
30 | for col in range(2):
31 | img = images[col]
32 | e = grid1.get_element(row,col).set_image(img)
33 | e.set_frame(0.3, [0,0,0])
34 |
35 | grid1.set_col_titles('south', ['Blue', 'Yellow'])
36 | layout1.column_titles[figuregen.BOTTOM] = figuregen.TextFieldLayout(size=4., offset=0.2, background_colors=[[200, 200, 255], [255, 255, 200]])
37 |
38 | grid1.set_row_titles('east', ['Awesome 1', 'Awesome 2'])
39 | layout1.row_titles[figuregen.RIGHT] = figuregen.TextFieldLayout(3., background_colors=(186, 98, 82),
40 | text_color=(255,255,255), rotation=-90)
41 |
42 | grid1.set_title('north', 'Top Title')
43 |
44 |
45 | # ---- Grid Module ----
46 | grid2 = single_module.grid
47 |
48 |
49 | # ------ ALL ------
50 | modules = [
51 | grid0,
52 | grid1,
53 | grid2
54 | ]
55 |
56 | if __name__ == "__main__":
57 | figuregen.horizontal_figure(modules, width_cm=18., filename='multimodule_test.pdf')
58 | figuregen.horizontal_figure(modules, width_cm=18., filename='multimodule_test.pptx')
59 | figuregen.horizontal_figure(modules, width_cm=18., filename='multimodule_test.html')
--------------------------------------------------------------------------------
/examples/multi_plot_module.py:
--------------------------------------------------------------------------------
1 | import multi_module
2 | import matplot_module
3 | import figuregen
4 | import figuregen.util
5 |
6 | multi_type_modules = [
7 | multi_module.grid0,
8 | multi_module.grid1,
9 | matplot_module.plot_module,
10 | ]
11 |
12 | if __name__ == "__main__":
13 | figuregen.horizontal_figure(multi_type_modules, width_cm=18., filename='multiplot_test.pdf')
14 | figuregen.horizontal_figure(multi_type_modules, width_cm=18., filename='multiplot_test.pptx')
15 | figuregen.horizontal_figure(multi_type_modules, width_cm=18., filename='multiplot_test.html')
--------------------------------------------------------------------------------
/examples/plotgrid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mira-13/figure-gen/888b0f4eb05f7e9c0f701c6ac9306521d5056c22/examples/plotgrid.png
--------------------------------------------------------------------------------
/examples/plotgrid.py:
--------------------------------------------------------------------------------
1 | import figuregen
2 | import numpy as np
3 | import json
4 |
5 | def make_row(method_name, title, mark_strata=False, problem_range=None, show_titles=False):
6 | with open(f"data/{method_name}.json") as f:
7 | data = json.load(f)
8 |
9 | grid = figuregen.Grid(num_cols=6, num_rows=1)
10 |
11 | xticks = [0, 1]
12 | colors = [
13 | [68, 104, 167],
14 | [244, 146, 42],
15 | [211, 95, 85],
16 | [89, 151, 223],
17 | [138, 74, 201],
18 | ]
19 |
20 | # Add the problem overview
21 | plot = figuregen.PgfLinePlot(aspect_ratio=0.9, data=[
22 | (data["PdfA"]["X"], data["PdfA"]["Y"]),
23 | (data["PdfB"]["X"], data["PdfB"]["Y"]),
24 | (data["Integrand"]["X"], data["Integrand"]["Y"])
25 | ])
26 | plot.set_font(fontsize_pt=7)
27 | plot.set_linewidth(plot_line_pt=1)
28 | plot.set_axis_properties("x", xticks, range=[0, 1.1], use_log_scale=False)
29 | plot.set_axis_properties("y", [], range=problem_range, use_log_scale=False)
30 | plot.set_padding(left_mm=1, bottom_mm=3)
31 | plot.set_colors(colors)
32 |
33 | if mark_strata:
34 | marker_width = 0.5
35 | marker_color = [100, 100, 100]
36 | marker_style = [1]
37 | plot.set_v_line(0.25, marker_color, marker_style, marker_width)
38 | plot.set_v_line(0.50, marker_color, marker_style, marker_width)
39 | plot.set_v_line(0.75, marker_color, marker_style, marker_width)
40 | plot.set_v_line(1.00, marker_color, marker_style, marker_width)
41 |
42 | grid.get_element(0, 0).set_image(plot)
43 |
44 | # Add the different methods
45 | variance_captions = [""]
46 | def plot_method(name, idx, crop=True):
47 | plot = figuregen.PgfLinePlot(aspect_ratio=0.9, data=[
48 | (data[name]["WeightA"]["X"], data[name]["WeightA"]["Y"]),
49 | (data[name]["WeightB"]["X"], data[name]["WeightB"]["Y"])
50 | ])
51 | plot.set_font(fontsize_pt=7)
52 | plot.set_linewidth(plot_line_pt=1)
53 | plot.set_padding(left_mm=6 if not crop else 3, bottom_mm=3)
54 | plot.set_colors(colors)
55 |
56 | if mark_strata:
57 | plot.set_v_line(0.25, marker_color, marker_style, marker_width)
58 | plot.set_v_line(0.50, marker_color, marker_style, marker_width)
59 | plot.set_v_line(0.75, marker_color, marker_style, marker_width)
60 | plot.set_v_line(1.00, marker_color, marker_style, marker_width)
61 |
62 | if not crop:
63 | max_val_a = np.max(data[name]["WeightA"]["Y"])
64 | min_val_a = np.min(data[name]["WeightA"]["Y"])
65 | max_val_b = np.max(data[name]["WeightB"]["Y"])
66 | min_val_b = np.min(data[name]["WeightB"]["Y"])
67 | max_val = max(max_val_a, max_val_b)
68 | min_val = min(min_val_a, min_val_b)
69 | lo_tick = int(min_val / 10) * 10
70 | hi_tick = int(max_val / 10) * 10
71 | if hi_tick == 0:
72 | hi_tick = 1
73 | if lo_tick == 0:
74 | lo_tick = -1
75 | else:
76 | min_val = 0
77 | max_val = 1
78 | lo_tick = 0
79 | hi_tick = 1
80 | range = [min_val * 1.15, max_val * 1.15]
81 | plot.set_axis_properties("x", xticks, range=[0, 1.15], use_log_scale=False)
82 | plot.set_axis_properties("y", [lo_tick, hi_tick], range=range, use_log_scale=False)
83 |
84 | grid.get_element(0, idx).set_image(plot)
85 | variance_captions.append(f"Variance: {data[name]['Variance']:.4f}")
86 |
87 | plot_method("Average", 1)
88 | plot_method("RecipVar", 2)
89 | plot_method("Balance", 3)
90 | plot_method("VarAware", 4)
91 | plot_method("Optimal", 5, False)
92 |
93 | # Add titles
94 | grid.set_row_titles("left", [title])
95 | grid.layout.row_titles[figuregen.LEFT] = figuregen.TextFieldLayout(size=3, offset=0.5, fontsize=8, rotation=90)
96 | if show_titles:
97 | grid.set_col_titles("bottom", [
98 | "a) Integrand and densities",
99 | "b) Average",
100 | "c) Opt. const.",
101 | "d) Balance heuristic",
102 | "e) Var-aware",
103 | "f) Optimal weights"
104 | ])
105 | grid.layout.column_titles[figuregen.BOTTOM] = figuregen.TextFieldLayout(size=6, offset=0.5, fontsize=8)
106 |
107 | grid.set_col_titles("top", variance_captions)
108 | grid.layout.column_titles[figuregen.TOP] = figuregen.TextFieldLayout(size=3, offset=0.5, fontsize=8)
109 |
110 | # Add space between the rows (needs to be removed for the last one)
111 | grid.layout.set_padding(bottom=2)
112 |
113 | return [grid]
114 |
115 | rows = [
116 | make_row("BalanceOptimal", "1) Product", problem_range=[0,2.5]),
117 | make_row("Defensive", "2) Defensive", problem_range=[0,2.5]),
118 | make_row("Stratified", "3) Stratified", True, problem_range=[0,4.5], show_titles=True),
119 | ]
120 |
121 | # Remove bottom padding from the last row
122 | rows[-1][0].layout.padding[figuregen.BOTTOM] = 0
123 |
124 | if __name__ == "__main__":
125 | import time
126 | start = time.time()
127 | figuregen.figure(rows, 15.4, "plotgrid.pdf")
128 | figuregen.figure(rows, 15.4, "plotgrid.pptx")
129 | figuregen.figure(rows, 15.4, "plotgrid.html")
130 | print(time.time() - start)
131 |
132 | try:
133 | from figuregen.util import jupyter
134 | jupyter.convert('plotgrid.pdf', 300)
135 | except:
136 | print('Warning: pdf could not be converted to png')
--------------------------------------------------------------------------------
/examples/pool-siggraph.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mira-13/figure-gen/888b0f4eb05f7e9c0f701c6ac9306521d5056c22/examples/pool-siggraph.png
--------------------------------------------------------------------------------
/examples/pool.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import figuregen as fig
4 | from figuregen.util import image
5 | import simpleimageio
6 |
7 | scene, seconds = 'pool', 60
8 | method_list = ['path', 'upsmcmc', 'radiance', 'full', None]
9 | method_titles = ['PT', 'VCM+MLT', 'Method A', 'Method B', 'Reference']
10 | baseline = 2
11 |
12 | # left, top, width, height
13 | crops = [[400, 120, 40, 30], [595, 81, 40, 30], [123, 300, 40, 30]]
14 |
15 | # ---------- Data Gathering: images -------------
16 | def get_image(method=None):
17 | if method is None:
18 | path = os.path.join('images', scene, scene+".exr")
19 | else:
20 | sec_string = '-'+str(seconds)+'s-'
21 | path = os.path.join('images', scene, scene+sec_string+method+".exr")
22 |
23 | img = simpleimageio.read(path)
24 | return image.lin_to_srgb(simpleimageio.read(path))
25 |
26 | ref_img = get_image()
27 | m_images = [get_image(m) for m in method_list[:-1]]
28 |
29 | #-------- Data Gathering: errors & captions ----------
30 | def get_error(method_img):
31 | rMSE = image.relative_mse(img=method_img, ref=ref_img)
32 | return rMSE
33 |
34 | def get_captions():
35 | i = 0
36 | captions = []
37 | errors = [ get_error(method_img) for method_img in m_images ]
38 |
39 | for method in method_titles[:-1]:
40 | relMSE = round(errors[i], 3)
41 | if i == baseline:
42 | speedup = '(base)'
43 | else:
44 | speedup = round(errors[baseline] * 1/relMSE, 1)
45 | speedup = '('+str(speedup)+'x)'
46 |
47 | string_caption = method + '\n' + str(relMSE) + ' ' + speedup
48 | captions.append(string_caption)
49 | i+=1
50 |
51 | captions.append('Reference'+'\n'+'relMSE ('+str(seconds)+'s)')
52 |
53 | return captions
54 |
55 |
56 | # ---------- REFERENCE Module ----------
57 | ref_grid = fig.Grid(1,1)
58 | reference = ref_grid.get_element(0,0).set_image(fig.PNG(ref_img))
59 |
60 | # marker
61 | for crop in crops:
62 | reference.set_marker(pos=[crop[0], crop[1]], size=[crop[2], crop[3]], color=[255, 255, 255], linewidth_pt=0.6)
63 |
64 | # titles
65 | ref_grid.set_title('top', 'Pool')
66 |
67 | # layout
68 | ref_layout = ref_grid.layout
69 | ref_layout.padding[fig.TOP] = 0.1
70 | ref_layout.padding[fig.RIGHT] = 0.5
71 | ref_layout.titles[fig.TOP] = fig.TextFieldLayout(size=6., offset=0.2, fontsize=8)
72 |
73 |
74 | # ---------- COMPARE Module ----------
75 | num_rows = len(crops)
76 | num_cols = len(method_list)
77 | comp_grid = fig.Grid(num_rows, num_cols)
78 |
79 | def crop_image(img, crop_args):
80 | img = image.crop(img, *crop_args)
81 | img = image.zoom(img, 10)
82 | return img
83 |
84 | for row in range(0,num_rows):
85 | for col in range(0,num_cols):
86 | img = fig.PNG(crop_image(get_image(method_list[col]), crops[row]))
87 | e = comp_grid.get_element(row, col).set_image(img)
88 |
89 | # titles
90 | comp_grid.set_col_titles('top', get_captions())
91 |
92 | # layout
93 | c_layout = comp_grid.layout
94 | c_layout.padding[fig.TOP] = 0.1
95 | c_layout.row_space = 0.4
96 | c_layout.column_space = 0.4
97 | c_layout.column_titles[fig.TOP] = fig.TextFieldLayout(size=6., offset=0.2, fontsize=8)
98 |
99 | # ------ create figure --------
100 | if __name__ == "__main__":
101 | fig.horizontal_figure([ref_grid, comp_grid], width_cm=15., filename=scene+'.pdf')
102 | fig.horizontal_figure([ref_grid, comp_grid], width_cm=15., filename=scene+'.pptx')
103 | fig.horizontal_figure([ref_grid, comp_grid], width_cm=15., filename=scene+'.html')
--------------------------------------------------------------------------------
/examples/pool_with_template.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mira-13/figure-gen/888b0f4eb05f7e9c0f701c6ac9306521d5056c22/examples/pool_with_template.png
--------------------------------------------------------------------------------
/examples/pool_with_template.py:
--------------------------------------------------------------------------------
1 | import simpleimageio as sio
2 |
3 | # ------------------------------------------
4 | # For development / testing only: add parent directory to python path so we can load the package without installing it
5 | # DO NOT use this if you have installed figuregen via pip
6 | import sys, os
7 | sys.path.insert(1, os.path.join(sys.path[0], '..'))
8 | # -------------------------------------------
9 |
10 | import figuregen
11 | from figuregen.util.templates import CropComparison
12 | from figuregen.util.image import Cropbox
13 |
14 | figure = CropComparison(
15 | reference_image=sio.read("images/pool/pool.exr"),
16 | method_images=[
17 | sio.read("images/pool/pool-60s-path.exr"),
18 | sio.read("images/pool/pool-60s-upsmcmc.exr"),
19 | sio.read("images/pool/pool-60s-radiance.exr"),
20 | sio.read("images/pool/pool-60s-full.exr"),
21 | ],
22 | crops=[
23 | Cropbox(top=100, left=200, height=96, width=128, scale=5),
24 | Cropbox(top=100, left=450, height=96, width=128, scale=5),
25 | ],
26 | scene_name="Pool",
27 | method_names=["Reference", "Path Tracer", "UPS+MCMC", "Radiance-based", "Ours"]
28 | )
29 |
30 | figuregen.figure([figure.figure_row], width_cm=17.7, filename="pool_with_template.pdf")
31 |
32 | try:
33 | from figuregen.util import jupyter
34 | jupyter.convert('pool_with_template.pdf', 300)
35 | except:
36 | print('Warning: pdf could not be converted to png')
37 |
--------------------------------------------------------------------------------
/examples/siggraph_example.py:
--------------------------------------------------------------------------------
1 | import os
2 | import simpleimageio
3 | import json
4 |
5 | # ------------------------------------------
6 | # For development / testing only: add parent directory to python path so we can load the package without installing it
7 | # DO NOT use this if you have installed figuregen via pip
8 | import sys
9 | sys.path.insert(1, os.path.join(sys.path[0], '..'))
10 | # -------------------------------------------
11 |
12 | import figuregen as fig
13 | from figuregen.util import image
14 |
15 |
16 | # ---------- Data Gathering ----------
17 | idx = 2 # scene_idx, only pool scene will be in repo included
18 | scene = ['bookshelf', 'glossy-kitchen', 'pool']
19 | seconds = [120, 90, 60]
20 | baseline = 2
21 | method_list = ['path', 'upsmcmc', 'radiance', 'full', None]
22 | method_titles = ['Method A)', 'Method B)', 'Method C)', 'Method D)', 'Reference']
23 |
24 | crops = [
25 | [ # bookshelf
26 | image.Cropbox(top=191, left=369, height=30, width=40, scale=5),
27 | image.Cropbox(top=108, left=238, height=30, width=40, scale=5)
28 | ],
29 | [ # glossy-kitchen
30 | image.Cropbox(top=120, left=100, height=30, width=40, scale=5),
31 | image.Cropbox(top=325, left=212, height=30, width=40, scale=5)
32 | ],
33 | [ # pool
34 | image.Cropbox(top=120, left=400, height=30, width=40, scale=5),
35 | image.Cropbox(top=81, left=595, height=30, width=40, scale=5)
36 | ]
37 | ]
38 | colors = [
39 | [232, 181, 88],
40 | [5, 142, 78],
41 | [94, 163, 188],
42 | [181, 63, 106],
43 | [255, 255, 255]
44 | ]
45 |
46 | def get_image(scene, seconds, method=None, cropbox=None):
47 | if method is None:
48 | path = os.path.join('images', scene, scene+".exr")
49 | else:
50 | sec_string = '-'+str(seconds)+'s-'
51 | path = os.path.join('images', scene, scene+sec_string+method+".exr")
52 |
53 | img = simpleimageio.read(path)
54 | if isinstance(cropbox, image.Cropbox):
55 | img = cropbox.crop(img)
56 | return image.lin_to_srgb(img)
57 |
58 | # ----- Helper for Comparision Module to generate content -----
59 | def get_error(scene, method, seconds, metric='MRSE*'):
60 | p = os.path.join('errors', scene, scene+'__'+method+'.json')
61 | with open(p) as json_file:
62 | data = json.load(json_file)
63 | idx = data['timesteps'].index(seconds)
64 | error = data['data'][idx][metric]
65 | return round(error, 8)
66 |
67 | def get_captions(scene, method_titles, baseline, seconds):
68 | i = 0
69 | captions = []
70 | errors = [ get_error(scene, method, seconds) for method in method_list[:-1] ]
71 |
72 | for method in method_titles[:-1]:
73 | relMSE = round(errors[i], 3)
74 | if i == baseline:
75 | speedup = '(baseline)'
76 | else:
77 | speedup = round(errors[baseline] * 1/relMSE, 2)
78 | speedup = '('+str(speedup)+'x)'
79 |
80 | string_caption = method + '\n' + str(relMSE) + ' ' + speedup
81 | captions.append(string_caption)
82 | i+=1
83 |
84 | captions.append('Reference'+'\n'+'relMSE ('+str(seconds)+'s)')
85 |
86 | return captions
87 |
88 | # ----- Helper for Plot Module to generate content -----
89 | def load_error(scene, method, metric='MRSE*', clip=True):
90 | with open('errors/%s/%s__%s.json' % (scene, scene, method)) as json_file:
91 | data = json.load(json_file)
92 |
93 | x = data['timesteps']
94 | y = [ e[metric] for e in data['data'] ]
95 |
96 | si = 0
97 | x, y = x[si:], y[si:]
98 |
99 | rmin = 0
100 | ddx, ddy = [], []
101 | for i in range(len(y)):
102 | if i == 0 or (y[i-1] != y[i] and (not clip or y[i] < rmin)):
103 | ddx.append(x[i])
104 | ddy.append(y[i])# * x[i])
105 | rmin = y[i]
106 |
107 | return (ddx, ddy)
108 |
109 | def get_plot_data(scene, method_list):
110 | return [
111 | load_error(scene, method, metric='MRSE*', clip=True) for method in method_list[:-1]
112 | ]
113 |
114 | # ---------- REFERENCE Module ----------
115 | ref_grid = fig.Grid(1,1)
116 | ref_img = get_image(scene[idx], seconds[idx])
117 | reference = ref_grid.get_element(0,0).set_image(fig.PNG(ref_img))
118 |
119 | # marker
120 | for crop in crops[idx]:
121 | reference.set_marker(pos=crop.get_marker_pos(), size=crop.get_marker_size(),
122 | color=[242, 113, 0], linewidth_pt=0.6)
123 | # titles
124 | ref_grid.set_title('south', scene[idx].replace('-',' ').title())
125 |
126 | # layout
127 | l = ref_grid.layout
128 | l.padding[fig.BOTTOM] = 0.1
129 | l.padding[fig.RIGHT] = 0.5
130 | l.titles[fig.BOTTOM] = fig.TextFieldLayout(size=7., offset=0.5, fontsize=8)
131 |
132 |
133 | # ---------- COMPARE Module ----------
134 | num_rows = len(crops[idx])
135 | num_cols = len(method_list)
136 | comp_grid = fig.Grid(num_rows, num_cols)
137 |
138 | # set images
139 | for row in range(0,num_rows):
140 | for col in range(0,num_cols):
141 | img = get_image(scene[idx], seconds[idx], method=method_list[col], cropbox=crops[idx][row])
142 | e = comp_grid.get_element(row, col)
143 | e.set_image(fig.PNG(img))
144 |
145 | # titles
146 | comp_grid.set_col_titles(fig.BOTTOM, get_captions(scene[idx], method_titles, baseline, seconds[idx]))
147 |
148 | # layout
149 | l = comp_grid.layout
150 | l.padding[fig.BOTTOM] = ref_grid.layout.padding[fig.BOTTOM]
151 | l.padding[fig.RIGHT] = ref_grid.layout.padding[fig.RIGHT]
152 | l.row_space = 0.5
153 | l.column_space = 0.5
154 | l.column_titles[fig.BOTTOM] = fig.TextFieldLayout(size=7., offset=0.5, fontsize=8, background_colors=colors, vertical_alignment="center")
155 |
156 | # ---------- PLOT Module ----------
157 | xticks = [
158 | [3, 20, s] for s in seconds
159 | ]
160 | vline_positions = [
161 | (28.32, 28.78),
162 | (19.92, 20.23),
163 | (11.44, 11.17),
164 | (13.97, 18.62)
165 | ]
166 |
167 | plot = fig.MatplotLinePlot(aspect_ratio=1.1, data=get_plot_data(scene[idx], method_list))
168 | plot.set_colors(colors)
169 |
170 | plot.set_axis_label('x', "Time [s]")
171 | plot.set_axis_label('y', "Error\n[relMSE]")
172 |
173 | plot.set_axis_properties('x', ticks=xticks[idx], range=[2.5, 800])
174 | plot.set_axis_properties('y', ticks=[0.01, 0.1, 1.0])
175 |
176 | plot.set_v_line(pos=vline_positions[idx][0], color=colors[baseline], linestyle=(0,(4,6)), linewidth_pt=0.6)
177 | plot.set_v_line(pos=vline_positions[idx][1], color=colors[3], linestyle=(-5,(4,6)), linewidth_pt=0.6)
178 |
179 | plot_module = fig.Grid(1,1)
180 | plot_module.get_element(0,0).set_image(plot)
181 |
182 | # ---- TOGETHER ----
183 | modules = [ref_grid, comp_grid, plot_module]
184 |
185 | if __name__ == "__main__":
186 | fig.horizontal_figure(modules, width_cm=18., filename=scene[idx]+'-siggraph.pdf')
187 | fig.horizontal_figure(modules, width_cm=18., filename=scene[idx]+'-siggraph.pptx')
188 | fig.horizontal_figure(modules, width_cm=18., filename=scene[idx]+'-siggraph.html')
189 |
190 | try:
191 | from figuregen.util import jupyter
192 | jupyter.convert(scene[idx]+'-siggraph.pdf', 300)
193 | except:
194 | print('Warning: pdf could not be converted to png')
--------------------------------------------------------------------------------
/examples/single-grid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mira-13/figure-gen/888b0f4eb05f7e9c0f701c6ac9306521d5056c22/examples/single-grid.png
--------------------------------------------------------------------------------
/examples/single_module.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 |
3 | # ------------------------------------------
4 | # For development / testing only: add parent directory to python path so we can load the package without installing it
5 | # DO NOT use this if you have installed figuregen via pip
6 | import sys, os
7 | sys.path.insert(1, os.path.join(sys.path[0], '..'))
8 | # -------------------------------------------
9 |
10 | import figuregen
11 | import unittest
12 |
13 | # --- define colors and images ---
14 | colors = [
15 | [232, 181, 88], #yellow
16 | [5, 142, 78], #green
17 | [94, 163, 188], #light-blue
18 | [181, 63, 106], #pink
19 | [82, 110, 186], #blue
20 | [186, 98, 82] #orange-crab
21 | ]
22 |
23 | # generate test images
24 | img_blue = np.tile([x / 255 for x in colors[2]], (32, 64, 1))
25 | img_yellow = np.tile([x / 255 for x in colors[0]], (32, 64, 1))
26 |
27 | # load the two images
28 | images = [
29 | img_blue,
30 | img_yellow
31 | ]
32 |
33 | # ------ Create Grid including markers, frames, (sub)titles, labels, etc. -------
34 | n_rows, n_cols = 2, 3
35 | grid = figuregen.Grid(n_rows, n_cols)
36 |
37 | # fill grid with image data
38 | for row in range(n_rows):
39 | for col in range(n_cols):
40 | img = figuregen.PNG(images[row])
41 | grid.get_element(row,col).set_image(img)
42 |
43 | grid.layout.set_padding(top=0.5, bottom=1.5)
44 |
45 | # marker
46 | grid.get_element(0,0).set_marker(pos=[32,12], size=[15,10], color=colors[4])
47 | grid.get_element(0,0).set_marker(pos=[1,1], size=[15,10], color=colors[-1], linewidth_pt=0.9,
48 | is_dashed=True)
49 |
50 | # frame
51 | grid.get_element(0,1).set_frame(linewidth=2., color=colors[4])
52 |
53 | # subtitles for specific elements
54 | grid.get_element(0,0).set_caption('caption a)')
55 | grid.get_element(0,1).set_caption('caption b)')
56 | grid.get_element(0,2).set_caption('caption c)')
57 | grid.get_element(1,0).set_caption('caption d)')
58 | grid.get_element(1,1).set_caption('caption e)')
59 | grid.get_element(1,2).set_caption('caption f)')
60 | grid.layout.captions[figuregen.BOTTOM] = figuregen.TextFieldLayout(size=4.0, fontsize=9, text_color=(170,170,170))
61 |
62 | # labels (examples, each element can have in total 6 labels on each valid position)
63 | e4 = grid.get_element(1,0)
64 | e4.set_label("bottom center", pos='bottom_center', width_mm=25., height_mm=4.0, offset_mm=[1.0, 1.0],
65 | fontsize=9, bg_color=None)
66 | e4.set_label("top\\\\right", pos='top_right', width_mm=8., height_mm=7.0, offset_mm=[1.0, 1.0],
67 | fontsize=9, bg_color=[255,255,255], txt_padding_mm=0.4)
68 | e4.set_label("top\\\\left", pos='top_left', width_mm=8., height_mm=7.0, offset_mm=[1.0, 1.0],
69 | fontsize=9, bg_color=colors[-1], txt_color=[255,255,255], txt_padding_mm=1.5)
70 |
71 | # grid specific titles
72 | grid.set_title('top', 'Top Title')
73 | grid.layout.titles[figuregen.TOP] = figuregen.TextFieldLayout(5., offset=2.,fontsize=12,
74 | background_colors=colors[5], text_color=(255,255,255))
75 |
76 | grid.set_title('south', 'Bottom Title') #use defaults
77 |
78 | grid.set_title('right', 'Right Title') #use defaults
79 |
80 | grid.set_title('left', 'Left Title')
81 | grid.layout.titles[figuregen.LEFT] = figuregen.TextFieldLayout(4., offset=2., fontsize=12, rotation=90)
82 |
83 | # Row and column titles
84 | grid.set_row_titles('left', ['Row titles', 'are better'])
85 | grid.layout.row_titles[figuregen.LEFT] = figuregen.TextFieldLayout(10., offset=1., fontsize=9, rotation=0,
86 | background_colors=[colors[4],colors[2]])
87 |
88 | grid.set_col_titles('north', ['Column titles', 'are', 'the best'])
89 | #layout.set_col_titles('north', 10., offset_mm=1., fontsize=9, bg_color=[200, 180, 220])
90 |
91 | if __name__ == "__main__":
92 | figuregen.figure([[grid]], width_cm=18., filename='single-grid.pdf')
93 | figuregen.figure([[grid]], width_cm=18., filename='single-grid.pptx')
94 | figuregen.figure([[grid]], width_cm=18., filename='single-grid.html')
95 |
96 | try:
97 | from figuregen.util import jupyter
98 | jupyter.convert('single-grid.pdf', 300)
99 | except:
100 | print('Warning: pdf could not be converted to png')
--------------------------------------------------------------------------------
/examples/split-comparison.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mira-13/figure-gen/888b0f4eb05f7e9c0f701c6ac9306521d5056c22/examples/split-comparison.png
--------------------------------------------------------------------------------
/examples/split_comparison.py:
--------------------------------------------------------------------------------
1 | import figuregen
2 | from figuregen.util import image
3 | import numpy as np
4 |
5 | # ---------- Data Gathering ----------
6 | # define some colors (r,g,b)
7 | yellow=[232, 181, 88]
8 | l_blue=[94, 163, 188]
9 | blue=[82, 110, 186]
10 | orange=[186, 98, 82]
11 |
12 | # generate test images
13 | img_blue = np.tile([x / 255 for x in blue], (320, 640, 1))
14 | img_l_blue = np.tile([x / 255 for x in l_blue], (320, 640, 1))
15 | img_yellow = np.tile([x / 255 for x in yellow], (320, 640, 1))
16 | img_orange = np.tile([x / 255 for x in orange], (320, 640, 1))
17 |
18 | # load images
19 | images = [
20 | [
21 | image.SplitImage([img_blue, img_l_blue, img_yellow], degree=-20, weights=[0.5, 0.8, 0.5]),
22 | image.SplitImage([img_yellow, img_orange], degree=15, vertical=False, weights=[0.5, 1.0])
23 | ],
24 | [
25 | image.SplitImage([img_yellow, img_orange], weights=[1.0, 1.0], degree=30),
26 | image.SplitImage([img_yellow, img_l_blue, img_blue], vertical=False, weights=[1, 2, 3], degree=0),
27 | ]
28 | ]
29 |
30 | # ---------- Simple Grid with SplitImages ----------
31 | n_rows = 2
32 | top_cols = 2
33 | top_grid = figuregen.Grid(num_rows=n_rows, num_cols=top_cols)
34 |
35 | # fill grid with image data
36 | for row in range(n_rows):
37 | for col in range(top_cols):
38 | s_img = images[row][col]
39 | raw_img = figuregen.PNG(s_img.get_image())
40 | e = top_grid.get_element(row,col).set_image(raw_img)
41 | e.draw_lines(s_img.get_start_positions(), s_img.get_end_positions(), linewidth_pt=0.5, color=[0,0,0])
42 |
43 | top_grid.set_col_titles('top', ['Horizontal Split', 'Vertical Split', 'C)', 'D)'])
44 |
45 | # LAYOUT: Specify paddings (unit: mm)
46 | top_lay = top_grid.layout
47 | top_lay.set_padding(column=1.0, right=1.5)
48 | top_lay.column_titles[figuregen.TOP].size = 5.0
49 |
50 | if __name__ == "__main__":
51 | width_cm = 15
52 | basename = "split-comparison"
53 | figuregen.figure([[top_grid]], width_cm=width_cm, filename=basename+'.pdf',
54 | backend=figuregen.PdfBackend(intermediate_dir="log"))
55 | figuregen.figure([[top_grid]], width_cm=width_cm, filename=basename+'.pptx')
56 | figuregen.figure([[top_grid]], width_cm=width_cm, filename=basename+'.html')
57 |
58 | try:
59 | from figuregen.util import jupyter
60 | jupyter.convert(basename+'.pdf', 300)
61 | except:
62 | print('Warning: pdf could not be converted to png')
--------------------------------------------------------------------------------
/examples/vertical-stack.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mira-13/figure-gen/888b0f4eb05f7e9c0f701c6ac9306521d5056c22/examples/vertical-stack.png
--------------------------------------------------------------------------------
/examples/vertical_stack.py:
--------------------------------------------------------------------------------
1 | import figuregen
2 | from figuregen import util
3 | import simpleimageio
4 | import os
5 |
6 | # ---------- Data Gathering ----------
7 | method_titles = ['Reference', 'PT', 'VCM+MLT']
8 | method_filenames = ['pool.exr', 'pool-60s-path.exr', 'pool-60s-upsmcmc.exr']
9 | method_filenames = [os.path.join('images', 'pool', f) for f in method_filenames]
10 |
11 | def get_image(filename=None, cropbox=None):
12 | '''
13 | Example how to load and process image data (simpleimageio to srgb).
14 |
15 | return:
16 | srgb image
17 | '''
18 | img = simpleimageio.read(filename)
19 | if isinstance(cropbox, util.image.Cropbox):
20 | img = cropbox.crop(img)
21 | return util.image.lin_to_srgb(img)
22 |
23 | # define cropping positions and marker colors
24 | crops = [
25 | util.image.Cropbox(top=120, left=400, height=30, width=40, scale=5),
26 | util.image.Cropbox(top=81, left=595, height=30, width=40, scale=5)
27 | ]
28 | crop_colors = [
29 | [255,110,0],
30 | [0,200,100]
31 | ]
32 |
33 | def get_error(method, cropbox=None):
34 | m_img = get_image(method, cropbox)
35 | r_img = get_image(method_filenames[0], cropbox)
36 | rMSE = figuregen.util.image.relative_mse(img=m_img, ref=r_img)
37 | return str(round(rMSE, 5))
38 |
39 | def place_label(element, txt, pos='bottom_left'):
40 | element.set_label(txt, pos, width_mm=7.8, height_mm=2.5, offset_mm=[0.4, 0.4],
41 | fontsize=6, bg_color=[20,20,20], txt_color=[255,255,255], txt_padding_mm=0.2)
42 |
43 | # ---------- Horizontal Figure TOP ----------
44 | top_cols = len(method_filenames)
45 | top_grid = figuregen.Grid(num_rows=1, num_cols=top_cols)
46 |
47 | # fill grid with image data
48 | for col in range(top_cols):
49 | e = top_grid.get_element(0, col)
50 | e.set_image(figuregen.PNG(get_image(method_filenames[col])))
51 |
52 | if col == 0: # reference
53 | place_label(e, txt='relMSE')
54 | else: # Method
55 | rmse = get_error(method_filenames[col])
56 | place_label(e, txt=rmse)
57 |
58 | # Add markers for all crops
59 | c_idx = 0
60 | for c in crops:
61 | e.set_marker(c.get_marker_pos(), c.get_marker_size(),
62 | color=crop_colors[c_idx], linewidth_pt=0.5)
63 | c_idx += 1
64 |
65 | top_grid.set_col_titles('top', method_titles)
66 |
67 | # Specify paddings (unit: mm)
68 | top_lay = top_grid.layout
69 | top_lay.column_space = 1.0
70 | top_lay.padding[figuregen.BOTTOM] = 0.25
71 |
72 | # ---------- Horizontal Figure BOTTOM ----------
73 | # One grid for each method
74 | bottom_cols = len(crops)
75 | bottom_grids = [figuregen.Grid(num_rows=1, num_cols=bottom_cols) for _ in range(len(method_filenames))]
76 |
77 | # fill grid with images
78 | sub_fig_idx = 0
79 | for sub_fig in bottom_grids:
80 | for col in range(bottom_cols):
81 | method = method_filenames[sub_fig_idx]
82 | image = get_image(method, crops[col])
83 | e = sub_fig.get_element(0, col).set_image(figuregen.PNG(image))
84 |
85 | e.set_frame(linewidth=0.8, color=crop_colors[col])
86 |
87 | if sub_fig_idx != 0: # Method
88 | rmse = get_error(method, crops[col])
89 | place_label(e, txt=rmse)
90 | sub_fig_idx += 1
91 |
92 | # Specify paddings (unit: mm)
93 | for sub_fig in bottom_grids:
94 | sub_fig.layout.set_padding(column=0.5, right=1.0, row=0.5)
95 | bottom_grids[-1].layout.set_padding(right=0.0) # remove last padding
96 |
97 | # ---------- V-STACK of Horizontal Figures (create figure) ----------
98 | v_grids = [
99 | [top_grid],
100 | bottom_grids
101 | ]
102 |
103 | if __name__ == "__main__":
104 | figuregen.figure(v_grids, width_cm=18., filename='vertical-stack.pdf')
105 | figuregen.figure(v_grids, width_cm=18., filename='vertical-stack.pptx')
106 | figuregen.figure(v_grids, width_cm=18., filename='vertical-stack.html')
107 |
108 | try:
109 | from figuregen.util import jupyter
110 | jupyter.convert('vertical-stack.pdf', 300)
111 | except:
112 | print('Warning: pdf could not be converted to png')
--------------------------------------------------------------------------------
/figure_generator_layout.pptx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mira-13/figure-gen/888b0f4eb05f7e9c0f701c6ac9306521d5056c22/figure_generator_layout.pptx
--------------------------------------------------------------------------------
/figuregen/__init__.py:
--------------------------------------------------------------------------------
1 | # Import API functions
2 | from .figuregen import *
3 | from .layout import GridLayout, TextFieldLayout, LEFT, TOP, BOTTOM, RIGHT
4 | from .element_data import *
5 | from .matplot_lineplot import MatplotLinePlot
6 | from .pgf_lineplot import PgfLinePlot
--------------------------------------------------------------------------------
/figuregen/backend.py:
--------------------------------------------------------------------------------
1 | from typing import List, Sequence, Tuple
2 | from dataclasses import dataclass, field
3 | import os
4 | from .figuregen import *
5 | from . import calculate as calc
6 | from .element_data import *
7 |
8 | @dataclass
9 | class Bounds:
10 | top: float
11 | left: float
12 | width: float
13 | height: float
14 |
15 | @dataclass
16 | class Component:
17 | bounds: Bounds
18 | figure_idx: int
19 | grid_idx: int
20 | row_idx: int
21 | col_idx: int
22 |
23 | @dataclass
24 | class ImageComponent(Component):
25 | data: ElementData
26 | has_frame: bool
27 | frame_linewidth: float
28 | frame_color: Sequence[float]
29 |
30 | @dataclass
31 | class TextComponent(Component):
32 | content: str
33 | rotation: float
34 | fontsize: float
35 | color: Sequence[float]
36 | background_color: Sequence[float] | None
37 | type: str
38 | horizontal_alignment: str = "center"
39 | padding: calc.Size = field(default_factory=lambda: calc.Size(0, 0))
40 | vertical_alignment: str = "center"
41 |
42 | @dataclass
43 | class RectangleComponent(Component):
44 | color: Tuple[float, float, float]
45 | linewidth: float
46 | dashed: bool
47 |
48 | @dataclass
49 | class LineComponent(Component):
50 | from_x: float
51 | from_y: float
52 | to_x: float
53 | to_y: float
54 | linewidth: float
55 | color: Tuple[float, float, float]
56 |
57 | class Backend:
58 | def generate(self, grids: List[List[Grid]], width_mm: float, filename: str):
59 | output_dir = os.path.dirname(filename)
60 |
61 | gen_rows = []
62 | top = 0
63 | row_idx = 0
64 | for row in grids:
65 | sizes = self.compute_aligned_sizes(row, width_mm)
66 |
67 | # generate all grids
68 | gen_grids = []
69 | left = 0
70 | for grid_idx in range(len(row)):
71 | bounds = Bounds(top, left, sizes[grid_idx][0].width_mm, sizes[grid_idx][0].height_mm)
72 | components = self.gen_grid(grids[row_idx][grid_idx], bounds, sizes[grid_idx][1])
73 |
74 | # Set the correct figure and grid indices on all components
75 | for c in components:
76 | c.figure_idx = row_idx
77 | c.grid_idx = grid_idx
78 |
79 | gen_grids.append(self.assemble_grid(components, output_dir))
80 | left += sizes[grid_idx][0].width_mm
81 |
82 | bounds = Bounds(top, 0, width_mm, sizes[0][0].height_mm)
83 | gen_rows.append(self.combine_grids(gen_grids, row_idx, bounds))
84 | top += sizes[0][0].height_mm
85 | row_idx += 1
86 |
87 | # Combine all rows
88 | result = self.combine_rows(gen_rows, Bounds(0, 0, width_mm, top))
89 | self.write_to_file(result, filename)
90 |
91 | def compute_aligned_sizes(self, grids: List[Grid], width_mm: float) -> List[Tuple[calc.Size, calc.Size]]:
92 | """
93 | Computes the sizes of all grids and contained images so that their heights match and they fill the given
94 | width exactly.
95 |
96 | Returns:
97 | a list where each element is a tuple of (grid size, image size)
98 | """
99 | num_modules = len(grids)
100 | assert(num_modules != 0)
101 |
102 | if num_modules == 1:
103 | elem_size = calc.element_size_from_width(grids[0], width_mm)
104 | h = calc.total_height(grids[0], elem_size)
105 | return [ (calc.Size(width_mm, h), elem_size) ]
106 |
107 | sum_inverse_aspect_ratios = 0
108 | inverse_aspect_ratios = []
109 | for g in grids:
110 | a = g.rows / g.cols * g.aspect_ratio
111 | sum_inverse_aspect_ratios += 1/a
112 | inverse_aspect_ratios.append(1/a)
113 |
114 | sum_fixed_deltas = 0
115 | i = 0
116 | for m in grids:
117 | w_fix = calc.min_width(m)
118 | h_fix = calc.min_height(m)
119 | sum_fixed_deltas += w_fix - h_fix * inverse_aspect_ratios[i]
120 | i += 1
121 |
122 | height = (width_mm - sum_fixed_deltas) / sum_inverse_aspect_ratios
123 |
124 | sizes = []
125 | for m in grids:
126 | elem_size = calc.element_size_from_height(m, height)
127 | w = calc.total_width(m, elem_size)
128 | sizes.append((calc.Size(w, height), elem_size))
129 | return sizes
130 |
131 | def gen_lines(self, element: ElementView, img_pos_top, img_pos_left, img_size: calc.Size) -> List[Component]:
132 | try:
133 | lines = element.elem['lines']
134 | except:
135 | return []
136 |
137 | if lines == []:
138 | return []
139 |
140 | if isinstance(element.image, RasterImage):
141 | # Coordinates are in pixels
142 | w_scale = img_size.width_mm / element.image.width_px
143 | h_scale = img_size.height_mm / element.image.height_px
144 | else:
145 | # Coordinates are between 0 and 1.
146 | w_scale = img_size.width_mm
147 | h_scale = img_size.height_mm
148 |
149 | result = []
150 | for l in lines:
151 | start_h = l['from'][0] * h_scale + img_pos_top
152 | start_w = l['from'][1] * w_scale + img_pos_left
153 | end_h = l['to'][0] * h_scale + img_pos_top
154 | end_w = l['to'][1] * w_scale + img_pos_left
155 | rgb = (l['color'][0], l['color'][1], l['color'][2])
156 | bounds = Bounds(img_pos_top, img_pos_left, img_size.width_mm, img_size.height_mm)
157 | result.append(LineComponent(bounds, -1, -1, -1, -1, start_w, start_h, end_w, end_h, l['linewidth'], rgb))
158 |
159 | return result
160 |
161 | def _gen_label(self, img_pos_top, img_pos_left, img_width, img_height, cfg, label_pos) -> TextComponent | None:
162 | try:
163 | cfg = cfg[label_pos]
164 | except KeyError:
165 | return None
166 |
167 | alignment = label_pos.split('_')[-1]
168 | is_top = (label_pos.split('_')[0] == 'top')
169 |
170 | rect_width, rect_height = cfg['width_mm'], cfg['height_mm']
171 |
172 | # determine the correct offsets depending on wether it is in the corner or center
173 | if alignment == 'center':
174 | offset_w, offset_h = 0, cfg['offset_mm']
175 | else:
176 | offset_w = cfg['offset_mm'][0]
177 | offset_h = cfg['offset_mm'][1]
178 |
179 | # determine pos_top of rectangle
180 | if is_top:
181 | pos_top = img_pos_top + offset_h
182 | else:
183 | pos_top = img_pos_top + img_height - rect_height - offset_h
184 |
185 | # determine pos_left of rectangle based on alignment
186 | if alignment == 'center':
187 | pos_left = img_pos_left + (img_width * 1/2.) - (rect_width * 1/2.)
188 | elif alignment == 'left':
189 | pos_left = img_pos_left + offset_w
190 | else: # right
191 | pos_left = img_pos_left + img_width - rect_width - offset_w
192 |
193 | bounds = Bounds(pos_top, pos_left, rect_width, rect_height)
194 |
195 | padding = calc.Size(cfg['padding_mm'], cfg['padding_mm'])
196 |
197 | c = TextComponent(bounds, -1, -1, -1, -1, cfg["text"], 0, cfg['fontsize'], cfg['text_color'],
198 | cfg['background_color'], "label-" + label_pos, alignment, padding, "top" if is_top else "bottom")
199 |
200 | return c
201 |
202 | def gen_labels(self, element: ElementView, img_pos_top, img_pos_left, img_size: calc.Size) -> List[Component]:
203 | try:
204 | cfg = element.elem['label']
205 | except:
206 | return []
207 |
208 | labels = []
209 | for label_pos in ['top_center', 'top_left', 'top_right', 'bottom_center', 'bottom_left', 'bottom_right']:
210 | l = self._gen_label(img_pos_top, img_pos_left, img_size.width_mm, img_size.height_mm, cfg, label_pos)
211 | if l is None:
212 | continue
213 | labels.append(l)
214 | return labels
215 |
216 | def gen_markers(self, element: ElementView, img_pos_top, img_pos_left, img_size: calc.Size) -> List[Component]:
217 | try:
218 | markers = element.elem['crop_marker']
219 | except:
220 | return []
221 |
222 | if isinstance(element.image, RasterImage):
223 | # Coordinates are in pixels
224 | w_scale = img_size.width_mm / element.image.width_px
225 | h_scale = img_size.height_mm / element.image.height_px
226 | else:
227 | # Coordinates are between 0 and 1.
228 | w_scale = img_size.width_mm
229 | h_scale = img_size.height_mm
230 |
231 | result = []
232 | for m in markers:
233 | if m['linewidth'] > 0.0:
234 | pos_top = img_pos_top + (m['pos'][1] * h_scale)
235 | pos_left = img_pos_left + (m['pos'][0] * w_scale)
236 | w = m['size'][0] * w_scale
237 | h = m['size'][1] * h_scale
238 | bounds = Bounds(pos_top, pos_left, w, h)
239 | result.append(RectangleComponent(bounds, -1, -1, -1, -1, m['color'], m['linewidth'], m['dashed']))
240 | return result
241 |
242 | def gen_images(self, grid: Grid, grid_bounds: Bounds, img_size: calc.Size) -> List[Component]:
243 | """ Generates a list of figure components for all images and their lables, captions, frames, and markers """
244 | images = []
245 | for row_idx in range(grid.rows):
246 | for col_idx in range(grid.cols):
247 | element = grid[row_idx, col_idx]
248 | assert element.image is not None
249 | assert isinstance(element.image, ElementData)
250 |
251 | # Position of the main image
252 | pos_top, pos_left = calc.image_pos(grid, img_size, col_idx, row_idx)
253 | pos_top += grid_bounds.top
254 | pos_left += grid_bounds.left
255 | bounds = Bounds(pos_top, pos_left, img_size.width_mm, img_size.height_mm)
256 |
257 | # If there is a frame, get is properties
258 | has_frame = "frame" in element.elem and element.elem["frame"] is not None
259 | linewidth = 0
260 | color = [0,0,0]
261 | if has_frame:
262 | linewidth = element.elem["frame"]["line_width"]
263 | color = element.elem["frame"]["color"]
264 |
265 | images.append(ImageComponent(bounds, -1, -1, row_idx, col_idx, element.image,
266 | has_frame, linewidth, color))
267 |
268 | markers = self.gen_lines(element, pos_top, pos_left, img_size)
269 | for m in markers:
270 | m.row_idx = row_idx
271 | m.col_idx = col_idx
272 | images.extend(markers)
273 |
274 | markers = self.gen_labels(element, pos_top, pos_left, img_size)
275 | for m in markers:
276 | m.row_idx = row_idx
277 | m.col_idx = col_idx
278 | images.extend(markers)
279 |
280 | markers = self.gen_markers(element, pos_top, pos_left, img_size)
281 | for m in markers:
282 | m.row_idx = row_idx
283 | m.col_idx = col_idx
284 | images.extend(markers)
285 | return images
286 |
287 | def gen_south_captions(self, grid: Grid, grid_bounds: Bounds, img_size: calc.Size) -> List[Component]:
288 | layout = grid.layout.captions['south']
289 | if layout.size == 0:
290 | return []
291 |
292 | captions = []
293 | for row_idx in range(grid.rows):
294 | for col_idx in range(grid.cols):
295 | if 'captions' not in grid[row_idx, col_idx].elem:
296 | continue
297 |
298 | txt_content = grid[row_idx, col_idx].elem['captions']['south']
299 |
300 | if txt_content == '':
301 | continue
302 |
303 | (top, left) = calc.south_caption_pos(grid, img_size, col_idx, row_idx)
304 | bounds = Bounds(top + grid_bounds.top, left + grid_bounds.left,
305 | img_size.width_mm, layout.size)
306 |
307 | captions.append(TextComponent(bounds, -1, -1, row_idx, col_idx, txt_content, layout.rotation,
308 | layout.fontsize, layout.text_color, [255, 255, 255], "caption",
309 | vertical_alignment=layout.vertical_alignment or "top", horizontal_alignment=layout.horizontal_alignment))
310 |
311 | return captions
312 |
313 | def gen_titles(self, grid: Grid, grid_bounds: Bounds, img_size: calc.Size) -> List[Component]:
314 | titles = []
315 | for direction in ['north', 'east', 'south', 'west']:
316 | if direction not in grid.data['titles']:
317 | continue
318 |
319 | content = grid.data['titles'][direction]
320 | (top, left, width, height) = calc.titles_pos_and_size(grid, img_size, direction)
321 | bounds = Bounds(top + grid_bounds.top, left + grid_bounds.left, width, height)
322 |
323 | if width == 0 or height == 0 or content == "":
324 | continue
325 |
326 | default_align = "top" if direction == 'south' else "bottom"
327 |
328 | t = grid.layout.titles[direction]
329 | titles.append(TextComponent(bounds, -1, -1, -1, -1, content, t.rotation, t.fontsize,
330 | t.text_color, self._compute_bg_colors(t.background_colors, 1)[0], "title-" + direction,
331 | vertical_alignment=t.vertical_alignment or default_align, horizontal_alignment=t.horizontal_alignment))
332 | return titles
333 |
334 | def _compute_bg_colors(self, bg_color_properties, num) -> list[list[float] | None]:
335 | if bg_color_properties is None:
336 | return [None for _ in range(num)]
337 | elif not hasattr(bg_color_properties[0], '__iter__'): # single constant color for all
338 | return [bg_color_properties for _ in range(num)]
339 | else:
340 | return bg_color_properties
341 |
342 | def _gen_row_col_titles(self, direction: str, layout: dict[str, TextFieldLayout], num: int, pos_fn, contents: List[str], is_row):
343 | titles = []
344 | if calc.size_of(layout, direction)[0] != 0.0:
345 | bg_colors = self._compute_bg_colors(layout[direction].background_colors, num)
346 | t = layout[direction]
347 |
348 | for i in range(num):
349 | bounds = pos_fn(i)
350 | if bounds.width == 0 or bounds.height == 0:
351 | continue
352 |
353 | txt = contents[i]
354 | if txt == "":
355 | continue
356 |
357 | default_align = "top" if direction == 'south' else "bottom"
358 |
359 | if is_row:
360 | titles.append(TextComponent(bounds, -1, -1, i, -1, txt, t.rotation, t.fontsize,
361 | t.text_color, bg_colors[i], "rowtitle-" + direction,
362 | vertical_alignment=t.vertical_alignment or default_align, horizontal_alignment=t.horizontal_alignment))
363 | else:
364 | titles.append(TextComponent(bounds, -1, -1, -1, i, txt, t.rotation, t.fontsize,
365 | t.text_color, bg_colors[i], "coltitle-" + direction,
366 | vertical_alignment=t.vertical_alignment or default_align, horizontal_alignment=t.horizontal_alignment))
367 | return titles
368 |
369 | def gen_row_titles(self, grid: Grid, grid_bounds: Bounds, img_size: calc.Size) -> List[Component]:
370 | titles = []
371 | for direction in ['east', 'west']:
372 | def pos_fn(idx):
373 | (top, left, width, height) = calc.row_titles_pos(grid, img_size, idx + 1, direction)
374 | return Bounds(top + grid_bounds.top, left + grid_bounds.left, width, height)
375 |
376 | if direction not in grid.data['row_titles']:
377 | continue
378 |
379 | contents = grid.data['row_titles'][direction]['content']
380 | t = self._gen_row_col_titles(direction, grid.layout.row_titles, grid.rows, pos_fn,
381 | contents, True)
382 | titles.extend(t)
383 |
384 | return titles
385 |
386 | def gen_column_titles(self, grid: Grid, grid_bounds: Bounds, img_size: calc.Size) -> List[Component]:
387 | titles = []
388 | for direction in ['north', 'south']:
389 | def pos_fn(idx):
390 | (top, left, width, height) = calc.column_titles_pos(grid, img_size, idx + 1, direction)
391 | return Bounds(top + grid_bounds.top, left + grid_bounds.left, width, height)
392 |
393 | if direction not in grid.data['column_titles']:
394 | continue
395 |
396 | contents = grid.data['column_titles'][direction]['content']
397 | t = self._gen_row_col_titles(direction, grid.layout.column_titles, grid.cols, pos_fn,
398 | contents, False)
399 | titles.extend(t)
400 |
401 | return titles
402 |
403 | def gen_grid(self, grid: Grid, bounds: Bounds, img_size: calc.Size) -> List[Component]:
404 | result = []
405 | result.extend(self.gen_images(grid, bounds, img_size))
406 | result.extend(self.gen_south_captions(grid, bounds, img_size))
407 | result.extend(self.gen_titles(grid, bounds, img_size))
408 | result.extend(self.gen_row_titles(grid, bounds, img_size))
409 | result.extend(self.gen_column_titles(grid, bounds, img_size))
410 | return result
411 |
412 | def assemble_grid(self, components: List[Component], output_dir: str):
413 | raise NotImplementedError()
414 |
415 | def combine_grids(self, data, idx: int, bounds: Bounds):
416 | raise NotImplementedError()
417 |
418 | def combine_rows(self, data, bounds: Bounds):
419 | raise NotImplementedError()
420 |
421 | def write_to_file(self, data, filename):
422 | raise NotImplementedError()
423 |
--------------------------------------------------------------------------------
/figuregen/calculate.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from typing import Tuple
3 | from .figuregen import *
4 |
5 | @dataclass
6 | class Size:
7 | width_mm: float
8 | height_mm: float
9 |
10 | def pt_to_mm(x):
11 | return x * 0.352778
12 |
13 | def compute_sum_space(txtField: TextFieldLayout):
14 | return txtField.offset + txtField.size if txtField.size > 0.0 else 0
15 |
16 | def sum_caption_spacing(layout: GridLayout, position: str, factor: float) -> float:
17 | return factor * compute_sum_space(layout.captions[position])
18 |
19 | def sum_title_spacing(layout: GridLayout, position: str):
20 | return compute_sum_space(layout.titles[position])
21 |
22 | def sum_row_title_spacing(layout: GridLayout, position):
23 | return compute_sum_space(layout.row_titles[position])
24 |
25 | def sum_col_title_spacing(layout: GridLayout, position):
26 | return compute_sum_space(layout.column_titles[position])
27 |
28 | def min_width(grid: Grid):
29 | '''
30 | Minimum width is the sum of all fixed space/padding based on the json-config file, including titles, offsets, paddings, etc.
31 | So basically: everything except for the images.
32 | '''
33 | return (
34 | grid.layout.column_space * (grid.cols - 1)
35 | + grid.layout.padding['west']
36 | + grid.layout.padding['east']
37 | + sum_caption_spacing(grid.layout, 'east', grid.cols)
38 | + sum_caption_spacing(grid.layout, 'west', grid.cols)
39 | + sum_title_spacing(grid.layout, 'east')
40 | + sum_title_spacing(grid.layout, 'west')
41 | + sum_row_title_spacing(grid.layout, 'east')
42 | + sum_row_title_spacing(grid.layout, 'west')
43 | )
44 |
45 | def fixed_inner_width(grid: Grid):
46 | '''
47 | Fixed inner width is the sum of spacing between all images, which also includes element captions
48 | '''
49 | return (
50 | grid.layout.column_space * (grid.cols - 1)
51 | + sum_caption_spacing(grid.layout, 'east', grid.cols - 1)
52 | + sum_caption_spacing(grid.layout, 'west', grid.cols - 1)
53 | )
54 |
55 | def body_width(grid: Grid, image_size: Size):
56 | '''
57 | body means: all images and their spaces/padding inbetween the images.
58 | Not included are: column/row titles and titles as well as their corresping offsets.
59 | '''
60 | return fixed_inner_width(grid) + grid.cols * image_size.width_mm
61 |
62 | def total_width(grid: Grid, image_size: Size):
63 | '''
64 | Includes everything that takes up width: padding, images, captions, row titles,
65 | east/west titles and all corresponding offsets
66 | '''
67 | return (
68 | body_width(grid, image_size)
69 | + sum_caption_spacing(grid.layout, 'east', 1) # add to inner body one more
70 | + sum_caption_spacing(grid.layout, 'west', 1) # same reason as above
71 |
72 | # add outer titles
73 | + sum_row_title_spacing(grid.layout, 'east')
74 | + sum_row_title_spacing(grid.layout, 'west')
75 | + sum_title_spacing(grid.layout, 'east')
76 | + sum_title_spacing(grid.layout, 'west')
77 |
78 | + grid.layout.padding['west'] + grid.layout.padding['east']
79 | )
80 |
81 | def min_height(grid: Grid):
82 | '''
83 | Minimum height is the sum of all fixed space/padding based on the json-config file, including titles, offsets, paddings, etc.
84 | So basically: everything except for the images.
85 | '''
86 | return (
87 | grid.layout.row_space * (grid.rows -1)
88 | + grid.layout.padding['north']
89 | + grid.layout.padding['south']
90 | + sum_caption_spacing(grid.layout, 'north', grid.rows)
91 | + sum_caption_spacing(grid.layout, 'south', grid.rows)
92 | + sum_title_spacing(grid.layout, 'north')
93 | + sum_title_spacing(grid.layout, 'south')
94 | + sum_col_title_spacing(grid.layout, 'north')
95 | + sum_col_title_spacing(grid.layout, 'south')
96 | )
97 |
98 | def fixed_inner_height(grid: Grid):
99 | '''
100 | Fixed inner height is the sum of spacing between all images, which also includes element captions
101 | '''
102 | return (
103 | grid.layout.row_space * (grid.rows -1)
104 | + sum_caption_spacing(grid.layout, 'north', grid.rows-1)
105 | + sum_caption_spacing(grid.layout, 'south', grid.rows-1)
106 | )
107 |
108 | def body_height(grid: Grid, image_size: Size):
109 | '''
110 | body means: all images and their spaces/padding inbetween the images.
111 | Not included are: column/row titles and titles as well as their corresping offsets.
112 | '''
113 | return fixed_inner_height(grid) + grid.rows * image_size.height_mm
114 |
115 | def total_height(grid: Grid, image_size: Size):
116 | '''
117 | Includes everything that takes up height: padding, images, captions, column titles,
118 | north/south titles and all corresponding offsets
119 | '''
120 | return (
121 | body_height(grid, image_size)
122 | + sum_caption_spacing(grid.layout, 'north', 1) # add to inner body one more
123 | + sum_caption_spacing(grid.layout, 'south', 1) # add to inner body one more
124 |
125 | # add outer titles
126 | + sum_col_title_spacing(grid.layout, 'north')
127 | + sum_col_title_spacing(grid.layout, 'south')
128 | + sum_title_spacing(grid.layout, 'north')
129 | + sum_title_spacing(grid.layout, 'south')
130 |
131 | + grid.layout.padding['north'] + grid.layout.padding['south']
132 | )
133 |
134 | def element_size_from_width(grid: Grid, total_width: float) -> Size:
135 | """ Computes the size of all individual images in the grid based on the given total width. """
136 | min_w = min_width(grid)
137 | width_per_img = (total_width - min_w) / grid.cols
138 | if width_per_img < 1.0:
139 | if width_per_img < 0.0:
140 | print(f'Warning: Element width computed to be negative. Probably due to an extreme aspect ratio.'
141 | f'Total height: ({total_width} - {min_w}) / {grid.cols} = {width_per_img}')
142 | else:
143 | print(f'Warning: Width per element is {width_per_img}, which is less than 1mm.'
144 | 'Probably due to an extreme aspect ratio or too many elements.')
145 |
146 | return Size(width_per_img, width_per_img * grid.aspect_ratio)
147 |
148 | def element_size_from_height(grid: Grid, total_height: float) -> Size:
149 | """ Computes the size of all individual images in the grid based on the given total height. """
150 | min_h = min_height(grid)
151 | height_per_img = (total_height - min_h) / grid.rows
152 | if height_per_img < 1.0:
153 | if height_per_img < 0.0:
154 | print(f'Warning: Element height computed to be negative. Probably due to an extreme aspect ratio.'
155 | f'Total height: ({total_height} - {min_h}) / {grid.rows} = {height_per_img}')
156 | else:
157 | print(f'Warning: Height per element is {height_per_img} which is less than 1mm.'
158 | 'Probably due to an extreme aspect ratio or too many elements.')
159 | return Size(height_per_img / grid.aspect_ratio, height_per_img)
160 |
161 | def size_of(txt_layout: dict[str, TextFieldLayout], direction: str) -> Tuple[float, float]:
162 | if direction not in ['north', 'east', 'south', 'west']:
163 | raise Error("Error: Invalid direction value: " + direction +".")
164 |
165 | d = txt_layout[direction]
166 | return (0, 0) if d.size == 0 else (d.size, d.offset)
167 |
168 | def image_pos(grid: Grid, image_size: Size, column, row):
169 | """ Computes the position of an image in the given grid, relative to the grid's top left corner. """
170 | title_top = sum(size_of(grid.layout.titles, 'north'))
171 | title_left = sum(size_of(grid.layout.titles, 'west'))
172 | col_title_top = sum(size_of(grid.layout.column_titles, 'north'))
173 | row_title_left = sum(size_of(grid.layout.row_titles, 'west'))
174 | img_south_capt = sum(size_of(grid.layout.captions, 'south')) * (row)
175 |
176 | top = grid.layout.padding['north'] + title_top + col_title_top
177 | top += (grid.layout.row_space + image_size.height_mm)*(row) + img_south_capt
178 |
179 | left = grid.layout.padding['west'] + title_left + row_title_left
180 | left += (grid.layout.column_space + image_size.width_mm)*(column)
181 |
182 | return top, left
183 |
184 | def south_caption_pos(grid: Grid, image_size: Size, column, row):
185 | top, left = image_pos(grid, image_size, column, row)
186 | top += image_size.height_mm + grid.layout.captions['south'].offset
187 | return top, left
188 |
189 | def titles_pos_and_size(grid: Grid, image_size: Size, direction: str):
190 | '''
191 | Note: this does not include element captions, yet. Because it was never really used in tikz or elsewhere.
192 | '''
193 | offset_left = grid.layout.padding['west']
194 | offset_top = grid.layout.padding['north']
195 |
196 | if direction == 'north' or direction == 'south':
197 | width = image_size.width_mm * grid.cols + grid.layout.column_space*(grid.cols - 1)
198 | height = size_of(grid.layout.titles, direction)[0]
199 |
200 | offset_left += sum(size_of(grid.layout.titles, 'west')) + sum(size_of(grid.layout.row_titles, 'west'))
201 | if direction == 'south':
202 | offset_top += sum(size_of(grid.layout.captions, 'south')) * grid.rows
203 | offset_top += grid.layout.padding['north'] + sum(size_of(grid.layout.titles, 'north')) + sum(size_of(grid.layout.column_titles, 'north'))
204 | offset_top += (grid.layout.row_space*(grid.rows - 1)) + image_size.height_mm * grid.rows
205 | offset_top += sum(size_of(grid.layout.column_titles, 'south')) + size_of(grid.layout.titles, 'south')[1]
206 |
207 | return offset_top, offset_left, width, height
208 |
209 | elif direction == 'east' or direction == 'west':
210 | height = image_size.height_mm * grid.rows + grid.layout.row_space*(grid.rows - 1)
211 | height += sum(size_of(grid.layout.captions, 'south')) * (grid.rows - 1)
212 | width = size_of(grid.layout.titles, direction)[0]
213 |
214 | offset_top += sum(size_of(grid.layout.titles, 'north'))
215 | offset_top += sum(size_of(grid.layout.column_titles, 'north'))
216 | if direction == 'east':
217 | offset_left += sum(size_of(grid.layout.titles, 'west')) + sum(size_of(grid.layout.row_titles, 'west'))
218 | offset_left += (grid.layout.column_space*(grid.cols - 1)) + image_size.width_mm * grid.cols
219 | offset_left += sum(size_of(grid.layout.row_titles, 'east')) + size_of(grid.layout.titles, 'east')[1]
220 |
221 | return offset_top, offset_left, width, height
222 |
223 | else:
224 | assert False, "Error: Invalid direction value: " + direction +". (html module)"
225 |
226 | def row_titles_pos(grid: Grid, image_size: Size, cur_row, direction):
227 | if not(direction == 'east' or direction == 'west'):
228 | raise Error("Error: Invalid direction value for row titles: " + direction +". Expected 'east' or 'west'.")
229 |
230 | width = size_of(grid.layout.row_titles, direction)[0]
231 | height = image_size.height_mm
232 |
233 | offset_left = grid.layout.padding['west'] + sum(size_of(grid.layout.titles, 'west'))
234 | offset_top = grid.layout.padding['north'] + sum(size_of(grid.layout.titles, 'north')) + sum(size_of(grid.layout.column_titles, 'north'))
235 | offset_top += (grid.layout.row_space + image_size.height_mm) * (cur_row - 1)
236 | offset_top += sum(size_of(grid.layout.captions, 'south')) * (cur_row-1)
237 | if direction == 'east':
238 | offset_left += sum(size_of(grid.layout.row_titles, 'west'))
239 | offset_left += (grid.layout.column_space*(grid.cols - 1)) + image_size.width_mm * grid.cols
240 | offset_left += size_of(grid.layout.row_titles, 'east')[1]
241 |
242 | return offset_top, offset_left, width, height
243 |
244 | def column_titles_pos(grid: Grid, image_size: Size, cur_column, direction):
245 | if not(direction == 'north' or direction == 'south'):
246 | raise "Error: Invalid direction value for column titles: " + direction +". Expected 'north' or 'south'."
247 |
248 | width = image_size.width_mm
249 | height = size_of(grid.layout.column_titles, direction)[0]
250 |
251 | offset_top = grid.layout.padding['north'] + sum(size_of(grid.layout.titles, 'north'))
252 | offset_left = grid.layout.padding['west'] + sum(size_of(grid.layout.titles, 'west'))
253 | offset_left += (grid.layout.column_space + image_size.width_mm) *(cur_column - 1)
254 | offset_left += sum(size_of(grid.layout.row_titles, 'west'))
255 | if direction == 'south':
256 | offset_top += sum(size_of(grid.layout.captions, 'south')) * grid.rows
257 | offset_top += sum(size_of(grid.layout.column_titles, 'north'))
258 | offset_top += (grid.layout.row_space*(grid.rows - 1)) + image_size.height_mm * grid.rows
259 | offset_top += size_of(grid.layout.column_titles, 'south')[1]
260 |
261 | return offset_top, offset_left, width, height
--------------------------------------------------------------------------------
/figuregen/commands.tikz:
--------------------------------------------------------------------------------
1 | % Generates an image node with the given size and position.
2 | % Arguments: width, height, filename, name, anchor
3 | \newcommand{\makeimagenode}[5]{
4 | \node[anchor=north west, minimum width=#1, minimum height=#2, inner sep=0, outer sep=0] (#4) at #5 {};
5 | \node[anchor=north west, minimum width=#1, minimum height=#2, inner sep=0, outer sep=0] (#4-content) at #5 {\includegraphics[width=#1, height=#2]{#3}};
6 | }
7 |
8 | % Generates an image node with the given size and position.
9 | % Clips a small portion of the content so when putting a frame around the image it does not flicker.
10 | % Arguments: width, height, filename, name, anchor, color, linewidth
11 | \newcommand{\makeframedimagenode}[7]{
12 | \node[anchor=north west, minimum width=#1, minimum height=#2, inner sep=0, outer sep=0] (#4) at #5 {};
13 | \begin{scope}
14 | \clip
15 | ([xshift = #7 * 0.5, yshift = #7 * 0.5]#4.south west)
16 | rectangle
17 | ([xshift = -#7 * 0.5, yshift = -#7 * 0.5]#4.north east);
18 |
19 | \node[anchor=north west, minimum width=#1, minimum height=#2, inner sep=0, outer sep=0] (#4-content) at #5 {\includegraphics[width=#1, height=#2]{#3}};
20 | \end{scope}
21 | \node[anchor=center, minimum width=#1-#7, minimum height=#2-#7, inner sep=0, outer sep=0,
22 | draw={#6}, line width=#7] (#4-frame) at (#4.center) {};
23 | }
24 |
25 | % Generates a node with text inside. The text is clipped to the node so it does not overlap
26 | % with other content.
27 | % Arguments: width, height, name, anchor, text color, fontsize, fill color, rotation, vertical alignment, horizontal alignment, vertical padding, horizontal padding, content
28 | \newcommand\maketextnode[9]{%
29 | % We use garbage names because proper ones interfere randomly and unpredictably in a
30 | % distribution-dependent way with some of the TikZ and LaTeX commands we use below
31 | \def\myargA{#1}%
32 | \def\myargB{#2}%
33 | \def\myargC{#3}%
34 | \def\myargD{#4}%
35 | \def\myargE{#5}%
36 | \def\myargF{#6}%
37 | \def\myargG{#7}%
38 | \def\myargH{#8}%
39 | \def\myargI{#9}%
40 | \maketextnodecontinued
41 | }
42 | \newcommand\maketextnodecontinued[4]{%
43 | \ifx\myargG\empty
44 | \node[anchor=north west, minimum width=\myargA, minimum height=\myargB, inner sep=0, outer sep=0] (\myargC) at \myargD {};
45 | \else
46 | \node[anchor=north west, minimum width=\myargA, minimum height=\myargB, inner sep=0, outer sep=0,fill={\myargG}] (\myargC) at \myargD {};
47 | \fi
48 |
49 | \begin{scope}
50 | \clip (\myargC.south west) rectangle (\myargC.north east);
51 | \node[anchor=center, minimum width=\myargA, minimum height=\myargB, rotate=\myargH, text={\myargE},
52 | inner sep=0, outer sep=0] at (\myargC.center)
53 | {
54 | \begin{minipage}[c][\myargB-#2-#2][\myargI]{\myargA-#3-#3} \fontsize{\myargF}{\myargF} \selectfont #1
55 | #4\strut
56 | \end{minipage}
57 | };
58 | \end{scope}
59 | }
60 |
61 | % Generates a node with text inside. The text is clipped to the node so it does not overlap
62 | % with other content. Width and height of the innermost node are flipped, use this for rotated text.
63 | % Arguments: width, height, name, anchor, text color, fontsize, content, fill color, rotation, vertical alignment, horizontal alignment, vertical padding, horizontal padding
64 | \newcommand\maketextnodeflipped[9]{%
65 | % We use garbage names because proper ones interfere randomly and unpredictably in a
66 | % distribution-dependent way with some of the TikZ and LaTeX commands we use below
67 | \def\myargA{#1}%
68 | \def\myargB{#2}%
69 | \def\myargC{#3}%
70 | \def\myargD{#4}%
71 | \def\myargE{#5}%
72 | \def\myargF{#6}%
73 | \def\myargG{#7}%
74 | \def\myargH{#8}%
75 | \def\myargI{#9}%
76 | \maketextnodeflippedcontinued
77 | }
78 | \newcommand\maketextnodeflippedcontinued[4]{%
79 | \ifx\myargG\empty
80 | \node[anchor=north west, minimum width=\myargA, minimum height=\myargB, inner sep=0, outer sep=0] (\myargC) at \myargD {};
81 | \else
82 | \node[anchor=north west, minimum width=\myargA, minimum height=\myargB, inner sep=0, outer sep=0,fill={\myargG}] (\myargC) at \myargD {};
83 | \fi
84 |
85 | \begin{scope}
86 | \clip (\myargC.south west) rectangle (\myargC.north east);
87 | \node[anchor=center, minimum width=\myargA, minimum height=\myargB, rotate=\myargH, text={\myargE},
88 | inner sep=0, outer sep=0] at (\myargC.center)
89 | {
90 | \begin{minipage}[c][\myargA-#3-#3][\myargI]{\myargB-#2-#2} \fontsize{\myargF}{\myargF} \selectfont #1
91 | #4\strut
92 | \end{minipage}
93 | };
94 | \end{scope}
95 | }
96 |
97 | % Generates a rectangle marker (outline, no fill)
98 | % Arguments: width, height, anchor, color, linewidth, linestyle (dashed or solid)
99 | \newcommand{\makerectangle}[6]{
100 | \node[anchor=north west, minimum width=#1, minimum height=#2, draw={#4}, line width=#5, #6,
101 | inner sep=0, outer sep=0] () at #3 {};
102 | }
103 |
104 | % Draws a line on top of another node, clipped to never leave the node
105 | % Arguments: parent node name, start, end, line width, line color
106 | \newcommand{\makeclippedline}[5]{
107 | \begin{scope}
108 | \clip (#1.south west) rectangle (#1.north east);
109 | \draw[color={#5}, line width=#4] #2 -- #3;
110 | \end{scope}
111 | }
112 |
113 | % Generates an empty node (no fill, no draw) to enforce a minimum size for the full tikzpicture
114 | % Arguments: width, height, anchor, name
115 | \newcommand{\makebackgroundnode}[4]{
116 | \node[anchor=north west, minimum width=#1, minimum height=#2, inner sep=0, outer sep=0] (#4) at #3 {};
117 | }
118 |
--------------------------------------------------------------------------------
/figuregen/element_data.py:
--------------------------------------------------------------------------------
1 | import shutil
2 | import os
3 | import base64
4 | import simpleimageio
5 | import numpy as np
6 | import tempfile
7 |
8 | class Error(Exception):
9 | def __init__(self, message):
10 | self.message = message
11 |
12 | class ElementData:
13 | @property
14 | def aspect_ratio(self):
15 | """ Aspect ratio (height / width) of the grid element. """
16 | pass
17 |
18 | def make_raster(self, width, height, base_filename) -> str:
19 | ''' Writes the element to a raster file format (e.g, .png, .jpg)
20 |
21 | This function should always be implemented. All backends use it as the fallback option.
22 | The exact file format is implementation specific and might be controlled by the user.
23 |
24 | Args:
25 | width: Desired width of the image in [mm]
26 | height: Desired height of the image in [mm]
27 | base_filename: Backend-generated filename prefix that can be used to generate a unique file
28 | in the correct location
29 |
30 | Returns:
31 | The filename of the generated file.
32 | '''
33 | raise NotImplementedError()
34 |
35 | def make_pdf(self, width, height, base_filename) -> str:
36 | ''' Writes the element to a .pdf file.
37 |
38 | Args:
39 | width: Desired width of the image in [mm]
40 | height: Desired height of the image in [mm]
41 | base_filename: Backend-generated filename prefix that can be used to generate a unique file
42 | in the correct location
43 |
44 | Returns:
45 | The filename of the generated file.
46 | '''
47 | raise NotImplementedError()
48 |
49 | def make_html(self, width, height) -> str:
50 | ''' Writes the element to a static .html page, images should be embedded as base64.
51 |
52 | The default generates a raster image in a temporary file and encodes it as base64 .png
53 |
54 | Args:
55 | width: Desired width of the image in [mm]
56 | height: Desired height of the image in [mm]
57 |
58 | Returns:
59 | The generated inline html code.
60 | '''
61 | temp_folder = tempfile.TemporaryDirectory()
62 | fname = self.make_raster(width, height, os.path.join(temp_folder.name, "image"))
63 | with open(fname, "rb") as f:
64 | b64 = base64.b64encode(f.read())
65 | temp_folder.cleanup()
66 |
67 | html = "
"
69 | return html
70 |
71 | class Plot(ElementData):
72 | ''' Base class for all generated images and plots.
73 |
74 | Offers a user-controllable aspect ratio
75 | '''
76 | @property
77 | def aspect_ratio(self):
78 | return self._aspect
79 |
80 | @aspect_ratio.setter
81 | def aspect_ratio(self, v):
82 | self._aspect = v
83 |
84 | class Image(ElementData):
85 | @property
86 | def width_px(self):
87 | raise NotImplementedError()
88 |
89 | @property
90 | def height_px(self):
91 | raise NotImplementedError()
92 |
93 | class PDF(Image):
94 | ''' Loads and embeds the first page of a pdf file.
95 |
96 | Additional dependencies: pypdf and pdf2image (the latter requires poppler to be installed and in the PATH)
97 | '''
98 | def __init__(self, filename, dpi=300, use_jpeg=False):
99 | self.file = filename
100 | self.dpi = dpi
101 | self.ext = '.jpg' if use_jpeg else '.png'
102 | self.mimetype = 'data:image/jpeg;base64,' if use_jpeg else 'data:image/png;base64,'
103 |
104 | @Image.aspect_ratio.getter
105 | def aspect_ratio(self):
106 | from pypdf import PdfReader
107 | box = PdfReader(open(self.file, "rb")).pages[0].mediabox
108 | width_pt = box.upper_right[0]
109 | height_pt = box.upper_right[1]
110 | return float(float(height_pt) / float(width_pt))
111 |
112 | def convert(self):
113 | from pdf2image.pdf2image import convert_from_path
114 | images = convert_from_path(self.file, dpi=self.dpi, last_page=1)
115 | return np.array(images[0]) / 255
116 |
117 | def make_raster(self, width, height, base_filename) -> str:
118 | img = self.convert()
119 | simpleimageio.write(base_filename + self.ext, simpleimageio.srgb_to_lin(img))
120 | return base_filename + self.ext
121 |
122 | def make_pdf(self, width, height, base_filename) -> str:
123 | shutil.copy(self.file, base_filename + ".pdf")
124 | return base_filename + ".pdf"
125 |
126 | class RasterImage(Image):
127 | ''' Abstract base class for all supported raster image types. '''
128 | def __init__(self, raw_image_or_filename):
129 | '''
130 | Either provide raw image data OR a filename.
131 | '''
132 | assert raw_image_or_filename is not None
133 |
134 | if isinstance(raw_image_or_filename, str):
135 | self.file = raw_image_or_filename
136 | self.raw = simpleimageio.lin_to_srgb(simpleimageio.read(self.file))
137 | self.width = self.raw.shape[1]
138 | self.height = self.raw.shape[0]
139 | else:
140 | self.file = None
141 | self.raw = raw_image_or_filename
142 | self.width = self.raw.shape[1]
143 | self.height = self.raw.shape[0]
144 |
145 | @Image.width_px.getter
146 | def width_px(self):
147 | return self.width
148 |
149 | @Image.height_px.getter
150 | def height_px(self):
151 | return self.height
152 |
153 | @Image.aspect_ratio.getter
154 | def aspect_ratio(self):
155 | return float(self.height / float(self.width))
156 |
157 | def convert(self, out_filename):
158 | simpleimageio.write(out_filename, simpleimageio.srgb_to_lin(self.raw))
159 |
160 | class PNG(RasterImage):
161 | ''' A raster image that will be converted to .png '''
162 | def __init__(self, raw_image_or_filename):
163 | self.ext = ".png"
164 | RasterImage.__init__(self, raw_image_or_filename)
165 |
166 | def make_raster(self, width, height, base_filename) -> str:
167 | filename = base_filename + self.ext
168 | self.convert(filename)
169 | return filename
170 |
171 | class JPEG(RasterImage):
172 | ''' A raster image that will be converted to .jpg '''
173 | def __init__(self, raw_image_or_filename, quality=85):
174 | self.ext = ".jpg"
175 | self.quality = quality
176 | RasterImage.__init__(self, raw_image_or_filename)
177 |
178 | def make_raster(self, width, height, base_filename) -> str:
179 | filename = base_filename + self.ext
180 | simpleimageio.write(filename, simpleimageio.srgb_to_lin(self.raw), self.quality)
181 | return filename
182 |
183 | class HTML(Image):
184 | ''' Embeds a .html.
185 |
186 | Currently ownly useful to the HTML backend, as it does not render the webpage.
187 | '''
188 | def __init__(self, filename, aspect_ratio):
189 | self.file = filename
190 | self.a_ratio = float(aspect_ratio)
191 |
192 | @Image.aspect_ratio.getter
193 | def aspect_ratio(self):
194 | return self.a_ratio
195 |
--------------------------------------------------------------------------------
/figuregen/figuregen.py:
--------------------------------------------------------------------------------
1 | from typing import List, Self
2 | from .layout import GridLayout, TextFieldLayout, LEFT, TOP, BOTTOM, RIGHT
3 | from .element_data import *
4 | import copy
5 | import os
6 | from dataclasses import dataclass
7 |
8 | class Error(Exception):
9 | def __init__(self, message):
10 | self.message = message
11 |
12 | class GridError(Exception):
13 | def __init__(self, row, col, message):
14 | self.message = f"Error in row {row}, column {col}: {message}"
15 |
16 | def _map_position(position):
17 | if position == 'top':
18 | return 'north'
19 | if position == 'bottom':
20 | return 'south'
21 | if position == 'left':
22 | return 'west'
23 | if position == 'right':
24 | return 'east'
25 |
26 | if position in ['north', 'east', 'south', 'west']:
27 | return position
28 |
29 | raise Error('Incorrect position. Try: "top"/"left"/... or "north"/"west"/...')
30 |
31 | class ElementView:
32 | '''
33 | A 'Grid' contains one or multiple elements depending on num_row and num_col.
34 | This class will help make changes in the settings for each element.
35 | You should however 'set_images' for each element, else unknown behaviour.
36 | '''
37 | def __init__(self, grid, row, col):
38 | self.elem = grid.data["elements"][row][col]
39 | self.row = row
40 | self.col = col
41 | self.layout = grid.layout
42 |
43 | @property
44 | def image(self) -> ElementData:
45 | return self.elem['image']
46 |
47 | @image.setter
48 | def image(self, image: ElementData):
49 | self.set_image(image)
50 |
51 | def set_image(self, image: ElementData):
52 | if not isinstance(image, ElementData):
53 | try:
54 | image = PNG(image)
55 | except:
56 | raise GridError(self.row, self.col, 'set_image needs an image of type figuregen.Image (e.g. figuregen.PNG)'
57 | 'or of type figuregen.Plot (e.g. figuregen.MatplotLinePlot)')
58 | print("Deprecation warning: interpreted image raw data as figuregen.PNG")
59 | self.elem['image'] = image
60 | return self
61 |
62 | def set_frame(self, linewidth, color=[0,0,0]):
63 | '''
64 | linewidth (float): unit is in pt
65 | color: [r,g,b] each channel with int range (0-255)
66 | '''
67 | self.elem['frame'] = { "line_width": linewidth, "color": color }
68 | return self
69 |
70 | def draw_lines(self, start_positions, end_positions, linewidth_pt=0.5, color=[0,0,0]):
71 | '''
72 | start_positions/end_positions (list of tuples): defines the position of the line to draw
73 | on top of the image. Needs a tuple (x: row, y: column) in pixel.
74 | '''
75 | # Validate arguments
76 | if linewidth_pt <= 0.0:
77 | raise GridError(self.row, self.col, f'invalid linewidth: {linewidth_pt}. Please choose a '
78 | 'positive linewidth_pt > 0.')
79 | if not isinstance(start_positions, list) or start_positions == []:
80 | raise GridError(self.row, self.col, 'Invalid argument "start_positions" needs to be a '
81 | f'list that is not empty. Given: {start_positions}.')
82 | if not isinstance(end_positions, list) or end_positions == []:
83 | raise GridError(self.row, self.col, 'Invalid argument "end_positions" needs to be a '
84 | f'list that is not empty. Given: {end_positions}.')
85 | if len(start_positions) != len(end_positions):
86 | raise GridError(self.row, self.col, 'You have more start positions than end positions (or reverse).')
87 | if len(start_positions[0]) != 2 or len(end_positions[0]) != 2:
88 | raise GridError(self.row, self.col, 'Invalid argument "start_positions"/"end_positions" should'
89 | 'be a list of tuples. Each tuple represents the x and y coordination in pixels.')
90 |
91 | try:
92 | self.elem["lines"].append({"from": start_positions[0], "to": end_positions[0], "color": color, "linewidth": linewidth_pt})
93 | except:
94 | self.elem["lines"] = []
95 | self.elem["lines"].append({"from": start_positions[0], "to": end_positions[0], "color": color, "linewidth": linewidth_pt})
96 |
97 | for i in range(1, len(start_positions)):
98 | self.elem["lines"].append({"from": start_positions[i], "to": end_positions[i], "color": color, "linewidth": linewidth_pt})
99 |
100 | def set_marker_properties(self, linewidth=1.5, is_dashed=False):
101 | print('Warning, function does not change marker properties anymore: set_marker_properties got replaced by set_marker.')
102 | return self
103 |
104 | def set_marker(self, pos, size, color=[255,255,255], linewidth_pt=1.0, is_dashed=False):
105 | '''
106 | Draws a rectangle on top of an image.
107 |
108 | args:
109 | pos (tuple): starting position (left, top) in pixel
110 | size (tuple): size of the rectangle (width, height) in pixel
111 | '''
112 | if linewidth_pt <= 0.0:
113 | raise Error('set_marker: invalid linewidth "'+str(linewidth_pt)+'". Please choose a positive linewidth_pt > 0.')
114 |
115 | try:
116 | _ = self.elem["crop_marker"][0]
117 | except:
118 | self.elem["crop_marker"] = []
119 |
120 | self.elem["crop_marker"].append({"pos": pos, "size": size, "color": color, "linewidth": linewidth_pt, "dashed": is_dashed})
121 | return self
122 |
123 | def set_caption(self, txt_content):
124 | '''
125 | A (south) caption is placed below an image.
126 | In case the corresponding field height is not set yet, we set a 'default' value. This makes sure, that
127 | the content (provided by the user) will be shown. The user can set/overwrite the layout for captions anytime.
128 | '''
129 | self.elem["captions"] = {}
130 | self.elem["captions"]["south"] = str(txt_content)
131 |
132 | # check if caption layout is already set, if not, set a field_size,
133 | # so that the user is not confused, why content isn't shown
134 | if self.layout.captions[BOTTOM].size == 0:
135 | self.layout.captions[BOTTOM].size = 6
136 | return self
137 |
138 | def set_label(self, txt_content, pos, width_mm=10., height_mm=3.0, offset_mm=[1.0, 1.0],
139 | fontsize=6, bg_color=None, txt_color=[0,0,0], txt_padding_mm=1.0):
140 | '''
141 | Write text on top of an image.
142 |
143 | args:
144 | pos (str): e.g. 'bottom_right', 'top_left', 'top_center' (= 'top'), ...
145 | offset_mm (tuple): defines where the label is placed exactly.
146 | We recommend to set bg_color, if you want to experiment with offsets.
147 | fontsize (float): unit point
148 | bg/txt_color (list): rgb integers ranging from 0 to 255.
149 | '''
150 |
151 | if not(pos in ['bottom', 'top', 'bottom_left', 'bottom_right', 'bottom_center', 'top_left', 'top_right', 'top_center']):
152 | raise Error("Label position '"+ pos +"' is invalid. Valid positions are: 'bottom_left',"
153 | "'bottom_right', 'bottom_center' (= 'bottom'), 'top_left', 'top_right', or 'top_center' (= 'top').")
154 | if pos == 'bottom' or pos == 'top':
155 | pos += '_center'
156 |
157 | try:
158 | self.elem["label"][pos] = {}
159 | except:
160 | self.elem["label"] = {}
161 |
162 | if 'center' in pos:
163 | try:
164 | offset_mm = offset_mm[0]
165 | except:
166 | pass
167 |
168 | self.elem["label"][pos] = {
169 | "text": str(txt_content),
170 | "fontsize": fontsize,
171 | "line_space": 1.2,
172 | "text_color": txt_color,
173 | "background_color": bg_color,
174 | "width_mm": width_mm,
175 | "height_mm": height_mm,
176 | "offset_mm": offset_mm,
177 | "padding_mm": txt_padding_mm
178 | }
179 |
180 | def validate(self):
181 | if not "image" in self.elem:
182 | raise ValidationError(-1, -1, -1, -1, "Image not set")
183 |
184 |
185 | @dataclass
186 | class ValidationError(Exception):
187 | grid_row: int
188 | grid_col: int
189 | figure_row: int
190 | figure_col: int
191 | message: str
192 |
193 | def __str__(self):
194 | return f"Error in grid (row = {self.figure_row}, col = {self.figure_col}), element (row = {self.grid_row}, col = {self.grid_col}): {self.message}"
195 |
196 |
197 | class Grid:
198 | def __init__(self, num_rows, num_cols):
199 | ''' Create an empty grid
200 | '''
201 | self.data = {
202 | "elements": [[{} for _ in range(num_cols)] for _ in range(num_rows)],
203 | "row_titles": {},
204 | "column_titles": {},
205 | "titles": {},
206 | "layout": GridLayout()
207 | }
208 | self.rows = num_rows
209 | self.cols = num_cols
210 |
211 | def __getitem__(self, rowcol) -> ElementView:
212 | return self.get_element(*rowcol)
213 |
214 | def get_element(self, row, col) -> ElementView:
215 | return ElementView(self, row, col)
216 |
217 | @property
218 | def aspect_ratio(self):
219 | """ Aspect ratio (height / width) of all images in the grid.
220 | Currently assumes that the user set them correctly such that they are all equal to
221 | the top left image.
222 | """
223 | return self[0, 0].image.aspect_ratio
224 |
225 | @property
226 | def layout(self) -> GridLayout:
227 | return self.data["layout"]
228 |
229 | def copy_layout(self, other: Self) -> None:
230 | ''' Copies the layout of another grid. Useful to quickly align paddings and font settings.
231 |
232 | Args:
233 | other: the Grid object to copy the layout from
234 | '''
235 | assert isinstance(other, Grid)
236 | self.data["layout"] = copy.deepcopy(other.data["layout"])
237 |
238 | def set_title(self, position, txt_content) -> Self:
239 | '''
240 | If the user has not specified the title size in the layout yet, it will be set to a default value of 6mm.
241 |
242 | Args:
243 | position: one of 'north'/'west'/... or 'top'/'right'/...
244 |
245 | Returns:
246 | This object (for chaining purposes)
247 | '''
248 | pos = _map_position(position)
249 | self.data["titles"][pos] = str(txt_content)
250 |
251 | # set a field_size (if not already done), so that the user is not confused, why content isn't shown
252 | if self.layout.titles[pos].size == 0:
253 | self.layout.titles[pos].size = 6
254 | return self
255 |
256 | def set_row_titles(self, position: str, txt_list: list):
257 | '''
258 | Args:
259 | position: string (valid: 'west'/'east' or 'right'/'left')
260 | txt_list: string list with one title for each row
261 | '''
262 | pos = _map_position(position)
263 | if pos in ['north', 'south']:
264 | raise Error("Invalid position for row_title. Try: 'west'/'east' or 'right'/'left'")
265 |
266 | assert isinstance(txt_list, list), "Please provide a list of strings, not a simple string."
267 | assert len(txt_list) >= self.rows, "Please provide a title for every row."
268 |
269 | try:
270 | self.data['row_titles'][pos]['content'] = txt_list
271 | except:
272 | self.data['row_titles'][pos] = {}
273 | self.data['row_titles'][pos]['content'] = txt_list
274 |
275 | # set a field_size (if not already done), so that the user is not confused, why content isn't shown
276 | if self.layout.row_titles[pos].size == 0:
277 | self.layout.row_titles[pos].size = 3
278 | return self
279 |
280 | def set_col_titles(self, position, txt_list):
281 | '''
282 | position: string (valid: 'north'/'south' or 'top'/'bottom')
283 | txt_list: string list of num_cols
284 | '''
285 | pos = _map_position(position)
286 | if pos in ['west', 'east']:
287 | raise Error('Invalid position for column_title. Try: "north"/"south" or "top"/"bottom"')
288 | if not isinstance(txt_list, list):
289 | raise Error ("'set_col_titles': Please give a list of strings, not a simple string. The length of the list should cover the number of columns.")
290 | if len(txt_list) < self.cols:
291 | raise Error ("'set_col_titles': length of provided list is less than number of columns.")
292 |
293 | try:
294 | self.data['column_titles'][pos]['content'] = txt_list
295 | except:
296 | self.data['column_titles'][pos] = {}
297 | self.data['column_titles'][pos]['content'] = txt_list
298 |
299 | # set a field_size (if not already done), so that the user is not confused, why content isn't shown
300 | if self.layout.column_titles[pos].size == 0:
301 | self.layout.column_titles[pos].size = 3
302 | return self
303 |
304 | def validate(self):
305 | for row in range(self.rows):
306 | for col in range(self.cols):
307 | try:
308 | self[row, col].validate()
309 | except ValidationError as error:
310 | error.grid_col = col
311 | error.grid_row = row
312 | raise error
313 |
314 |
315 | from .backend import Backend
316 | from .tikz import TikzBackend
317 | from .pdflatex import PdfBackend
318 | from .html import HtmlBackend
319 | from .powerpoint import PptxBackend
320 |
321 | def _backend_from_filename(filename: str) -> Backend:
322 | """ Guesses the correct backend based on the filename """
323 | extension = os.path.splitext(filename)[1].lower()
324 | if extension == ".pptx":
325 | return PptxBackend()
326 | elif extension == ".html":
327 | return HtmlBackend()
328 | elif extension == ".pdf":
329 | return PdfBackend()
330 | elif extension == ".tikz":
331 | return TikzBackend()
332 | else:
333 | raise ValueError(f"Could not derive backend from extension '{filename}'. Please specify.")
334 |
335 | def figure(grids: List[List[Grid]], width_cm: float, filename: str, backend: Backend | None = None):
336 | """
337 | Grid rows: Creates a figure by putting grids next to each other, from left to right.
338 | Grid columns: stacks rows vertically.
339 | Aligns the height of the given grids such that they fit the given total width.
340 |
341 | Args:
342 | grids: a list of lists of Grids (figuregen.Grid), which stacks horizontal figures vertically
343 | width_cm: total width of the figure in centimeters
344 | backend: a Backend object that will be used to create the figure, or None to use a default
345 | """
346 | if backend is None:
347 | backend = _backend_from_filename(filename)
348 |
349 | errors: List[ValidationError] = []
350 | for row in range(len(grids)):
351 | for col in range(len(grids[row])):
352 | try:
353 | grids[row][col].validate()
354 | except ValidationError as err:
355 | err.figure_col = col
356 | err.figure_row = row
357 | errors.append(err)
358 |
359 | if len(errors) > 0:
360 | print("Figure data is invalid:")
361 | for err in errors:
362 | print(f" - {err}")
363 | return
364 |
365 | backend.generate(grids, width_cm * 10, filename)
366 |
367 | def horizontal_figure(grids, width_cm: float, filename, backend: Backend | None = None):
368 | """
369 | Creates a figure by putting grids next to each other, from left to right.
370 | Aligns the height of the given grids such that they fit the given total width.
371 |
372 | Args:
373 | grids: a list of Grids (figuregen.Grid)
374 | width_cm: total width of the figure in centimeters
375 | backend: a Backend object that will be used to create the figure, or None to use a default
376 | """
377 | figure([grids], width_cm, filename, backend)
--------------------------------------------------------------------------------
/figuregen/html.py:
--------------------------------------------------------------------------------
1 | from typing import Union
2 | from .backend import *
3 | from concurrent.futures import ThreadPoolExecutor, Future
4 |
5 | class HtmlBackend(Backend):
6 | def __init__(self, inline=False, custom_head: str = "", id_prefix=""):
7 | """
8 | Creates a new HTML backend that emits a static webpage with embedded base64 images.
9 |
10 | Args:
11 | inline: boolean, if true does not generate ,
",
111 | c.content.replace('\\\\', '
'),
112 | "