├── .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 | ![](multi-module.png) 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 | ![](examples/pool_with_template.png) 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 , , tags 12 | custom_head: additonal lines to add to the , ignored if inline = False 13 | id_prefix: additional text in front of the figure's elements, useful if multiple figures are in on .html 14 | """ 15 | self._inline = inline 16 | self._custom_head = custom_head 17 | self._prefix = id_prefix 18 | self._thread_pool = ThreadPoolExecutor() 19 | 20 | @property 21 | def style(self) -> str: 22 | """ The required CSS style for the figure to display correctly. """ 23 | return "\n".join([ 24 | '', 34 | ]) 35 | 36 | def _html_color(self, rgb) -> str: 37 | return f"rgb({rgb[0]},{rgb[1]},{rgb[2]})" 38 | 39 | def _make_image(self, c: ImageComponent, dims, pos, elem_id): 40 | # Generate the image data 41 | elem_idx = self._prefix + "img-" + elem_id 42 | imgtag = c.data.make_html(c.bounds.width, c.bounds.height) 43 | 44 | html_code = f"
" 53 | 54 | html_code += "' >" + imgtag + frame + "" 55 | return html_code 56 | 57 | def assemble_grid(self, components: List[Component], output_dir: str): 58 | html_lines = [] 59 | for c in components: 60 | elem_id = f"fig{c.figure_idx}-grid{c.grid_idx}" 61 | if c.row_idx >= 0: 62 | elem_id += f"-row{c.row_idx}" 63 | if c.col_idx >= 0: 64 | elem_id += f"-col{c.col_idx}" 65 | 66 | # Position arguments are the same for all components 67 | if c.bounds is not None: 68 | pos = f"top: {c.bounds.top}mm; left: {c.bounds.left}mm; " 69 | dims = f"width: {c.bounds.width}mm; height: {c.bounds.height}mm;" 70 | 71 | if isinstance(c, ImageComponent): 72 | html_lines.append(self._thread_pool.submit(self._make_image, c, dims, pos, elem_id)) 73 | 74 | if isinstance(c, TextComponent): 75 | elem_idx = self._prefix + c.type + "-" + elem_id 76 | 77 | color = "color: " + self._html_color(c.color) + "; " 78 | fontsize = "font-size: " + f'{c.fontsize}pt' + "; " 79 | horz_align = "text-align: " + c.horizontal_alignment + "; " 80 | 81 | rotation = "" 82 | if c.rotation == -90: 83 | rotation = f"transform: rotate(90deg) translateX({-c.bounds.width}mm);" 84 | rotation += "transform-origin: bottom left;" 85 | elif c.rotation == 90: 86 | rotation = f"transform: rotate(-90deg) translateX({-c.bounds.height}mm);" 87 | rotation += "transform-origin: top left;" 88 | 89 | # Need to flip the dimensions if we rotate 90 | if rotation != "": 91 | dims = f"width: {c.bounds.height}mm; height: {c.bounds.width}mm;" 92 | 93 | if c.background_color is not None: 94 | bgn = "background-color: " + self._html_color(c.background_color) + "; " 95 | else: 96 | bgn = "" 97 | pad = f"padding-top: {c.padding.height_mm}mm; padding-bottom: {c.padding.height_mm}mm; " 98 | pad += f"padding-left: {c.padding.width_mm}mm; padding-right: {c.padding.width_mm}mm; " 99 | pad += "box-sizing: border-box; " 100 | 101 | aligncls = "centeralign" 102 | if c.vertical_alignment == "top": 103 | aligncls = "topalign" 104 | elif c.vertical_alignment == "bottom": 105 | aligncls = "botalign" 106 | 107 | html_lines.append("\n".join([ 108 | f"
", 110 | "

", 111 | c.content.replace('\\\\', '
'), 112 | "

", 113 | "
" 114 | ])) 115 | 116 | if isinstance(c, RectangleComponent): 117 | html_code = "
' 125 | html_code += f'' 126 | html_code += f'' 129 | html_code += '
' 130 | html_lines.append(html_code) 131 | 132 | return html_lines 133 | 134 | def combine_grids(self, data: List[List[Union[str, Future]]], idx: int, bounds: Bounds) -> List[Union[str, Future]]: 135 | # Create a container div for each row 136 | figure_id = self._prefix + "figure-" + str(idx) 137 | pos = f"top: {bounds.top}mm; left: {bounds.left}mm; " 138 | dims = f"width: {bounds.width}mm; height: {bounds.height}mm; " 139 | 140 | # Flatten the inner lines and combine 141 | result = ["
"] 142 | for grid in data: 143 | for line in grid: 144 | result.append(line) 145 | result.append("
") 146 | 147 | return result 148 | 149 | def combine_rows(self, data: List[List[Union[str, Future]]], bounds: Bounds) -> str: 150 | # Create a container div to make sure that everything can be moved around on a final page 151 | pos = f"top: {bounds.top}mm; left: {bounds.left}mm; " 152 | dims = f"width: {bounds.width}mm; height: {bounds.height}mm; " 153 | 154 | # Synchronize all export tasks 155 | html_code = "
\n" 156 | for row in data: 157 | for line in row: 158 | if isinstance(line, Future): 159 | html_code += line.result() + "\n" 160 | else: 161 | html_code += line + "\n" 162 | html_code += "
\n" 163 | return html_code 164 | 165 | def write_to_file(self, data: str, filename: str): 166 | with open(filename, "w") as f: 167 | if not self._inline: 168 | f.writelines([ 169 | "", 170 | "", 171 | "", 172 | ]) 173 | f.write(self.style) 174 | f.write(self._custom_head) 175 | f.writelines([ 176 | "" 177 | "" 178 | ]) 179 | 180 | f.write(data) 181 | 182 | if not self._inline: 183 | f.write("") 184 | -------------------------------------------------------------------------------- /figuregen/layout.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Sequence, Tuple 3 | 4 | @dataclass 5 | class TextFieldLayout: 6 | size: float = 0 7 | """ Size (height or width, depending on rotation) of this text field. In mm""" 8 | offset: float = 0 9 | """ Space between this field and the corresponding grid component (e.g., distance between an image and its caption), in mm """ 10 | rotation: int = 0 11 | """ Counter clockwise rotation of the text in degrees. Some backends might only support +-90 """ 12 | fontsize: float = 8 13 | """ Fontsize in pt """ 14 | line_space: float = 1.2 15 | """ Line spacing for multi-line content, as a multiple of the font size """ 16 | text_color: Tuple[float, float, float] = (0, 0, 0) 17 | """ Color (sRGB, from 0 to 255) of the text """ 18 | background_colors: Sequence[Sequence[float]] | Sequence[float] | None = None 19 | """ Background colors (sRGB, from 0 to 255) of the text, can be a list of colors, e.g., one per row, or a single color that will be applied to all """ 20 | vertical_alignment: str | None = None 21 | """ Vertical text alignment, must be one of: "top", "bottom", "center"; If None, the default alignment based on the type of text field is used """ 22 | horizontal_alignment: str = "center" 23 | """ Horizontal text alignment, must be one of: "left", "right", "center" """ 24 | 25 | TOP = "north" 26 | LEFT = "west" 27 | BOTTOM = "south" 28 | RIGHT = "east" 29 | 30 | @dataclass 31 | class GridLayout: 32 | row_space = 0.8 33 | """ Vertical padding between rows of images in the grid, in mm """ 34 | 35 | column_space = 0.8 36 | """ Horizontal padding between columns of images in the grid, in mm """ 37 | 38 | padding: dict[str, float] = field(default_factory=lambda: { 39 | "north": 0.0, 40 | "west": 0.0, 41 | "east": 0.0, 42 | "south": 0.0 43 | }) 44 | """ Padding applied to the left, right, top, and bottom of the whole grid, in mm """ 45 | 46 | def set_padding(self, left: float | None = None, right: float | None = None, top: float | None = None, bottom: float | None = None, column: float | None = None, row: float | None = None): 47 | """ Convenience setter to specify multiple paddings at once 48 | """ 49 | if top != None: self.padding[TOP] = top 50 | if left != None: self.padding[LEFT] = left 51 | if right != None: self.padding[RIGHT] = right 52 | if bottom != None: self.padding[BOTTOM] = bottom 53 | if column != None: self.column_space = column 54 | if row != None: self.row_space = row 55 | 56 | captions: dict[str, TextFieldLayout] = field(default_factory=lambda: { 57 | "north": TextFieldLayout(fontsize=6), 58 | "south": TextFieldLayout(fontsize=6), 59 | "east": TextFieldLayout(fontsize=6, rotation=-90), 60 | "west": TextFieldLayout(fontsize=6, rotation=90) 61 | }) 62 | """ Layouting properties of the captions to the left, top, right, and bottom of each image """ 63 | 64 | titles: dict[str, TextFieldLayout] = field(default_factory=lambda: { 65 | "north": TextFieldLayout(fontsize=8), 66 | "south": TextFieldLayout(fontsize=8), 67 | "east": TextFieldLayout(fontsize=8, rotation=-90), 68 | "west": TextFieldLayout(fontsize=8, rotation=90) 69 | }) 70 | """ Layouting properties of the grid titles """ 71 | 72 | row_titles: dict[str, TextFieldLayout] = field(default_factory=lambda: { 73 | "east": TextFieldLayout(fontsize=7, rotation=-90), 74 | "west": TextFieldLayout(fontsize=7, rotation=90) 75 | }) 76 | """ Layouting properties of the row titles """ 77 | 78 | column_titles: dict[str, TextFieldLayout] = field(default_factory=lambda: { 79 | "north": TextFieldLayout(fontsize=7), 80 | "south": TextFieldLayout(fontsize=7) 81 | }) 82 | """ Layouting properties of the column titles """ -------------------------------------------------------------------------------- /figuregen/matplot_lineplot.py: -------------------------------------------------------------------------------- 1 | from .element_data import * 2 | from .util import units 3 | 4 | import numpy as np 5 | from threading import Lock 6 | 7 | import matplotlib 8 | matplotlib.use('pgf') 9 | import matplotlib.pyplot as plt 10 | from matplotlib.ticker import FormatStrFormatter 11 | 12 | matplot_mutex = Lock() 13 | 14 | # ------ HELPER ------- 15 | def _check_axis(axis): 16 | if not (axis in ['x', 'y']): 17 | raise Error('Incorrect axis. Try: "x" or "y".') 18 | 19 | def _default_rotation(axis): 20 | if axis == 'x': 21 | return 'horizontal' 22 | return 'vertical' 23 | 24 | def _interpret_rotation(rotation): 25 | if rotation == 0: 26 | return 'horizontal' 27 | if rotation == 90 or rotation == -90: 28 | return 'vertical' 29 | if rotation in ['horizontal', 'vertical']: 30 | return rotation 31 | raise Error('Incorrect rotation value. Try: 0/(-)90 or "horizontal"/"vertical".') 32 | 33 | def _setup_fonts(plt, font_properties): 34 | plt.rcParams.update({ 35 | "text.usetex": True, # use inline math for tikz 36 | "pgf.rcfonts": False, # don't setup fonts from rc parameters 37 | "pgf.texsystem": "pdflatex", 38 | "font.family": font_properties['font_family'], 39 | "pgf.preamble": "\n".join([ 40 | r"\usepackage[utf8]{inputenc}", 41 | r"\usepackage[T1]{fontenc}", 42 | r"\usepackage" + font_properties['tex_package'] 43 | ]) 44 | }) 45 | 46 | def _label_alignment(rotation : str): 47 | return 'top' if rotation == 'horizontal' else 'bottom' 48 | 49 | def _plot_lines(ax, data, colors, linewidth, linestyles): 50 | i = 0 51 | for d in data: 52 | if colors is not None and len(colors) > i: 53 | ax.plot(d[0], d[1], linewidth=linewidth, color=np.array(colors[i])/255.0, linestyle=linestyles[i]) 54 | else: 55 | ax.plot(d[0], d[1], linewidth=linewidth, linestyle=linestyles[i]) 56 | i += 1 57 | 58 | def _apply_axis_range(ax, props): 59 | if "range" in props["x"]: 60 | ax.set_xlim([ props["x"]['range'][0], props["x"]['range'][1] ]) 61 | if "range" in props["y"]: 62 | ax.set_ylim([ props["y"]['range'][0], props["y"]['range'][1] ]) 63 | 64 | def _apply_axes_properties(ax, props): 65 | if props["x"]["use_log_scale"]: 66 | ax.set_xscale('log') 67 | if props["y"]["use_log_scale"]: 68 | ax.set_yscale('log') 69 | 70 | def set_ticks(ax, props): 71 | if props['ticks'] is not None: 72 | ax.set_ticks(props['ticks']) 73 | if not props['use_scientific_notations']: # can only apply if we have specific ticks 74 | ax.set_ticklabels(props['ticks']) 75 | ax.set_minor_formatter(FormatStrFormatter("")) 76 | 77 | set_ticks(ax.xaxis, props["x"]) 78 | set_ticks(ax.yaxis, props["y"]) 79 | 80 | def _set_labels(fig, ax, labels, fontsize, pad): 81 | ''' 82 | Sets fontsize (pt), labels and their rotation. 83 | The labels are placed at each end of the axes so that we don't waste too much space. 84 | The correct label position will be calculated automatically. 85 | Currently the user needs to find suitable ticks, so that labels and ticks don't overlap! 86 | ''' 87 | axis_labels = labels 88 | ax.set_xlabel(axis_labels['x']['text'], fontsize=fontsize, ha="right", 89 | va=_label_alignment(axis_labels['x']['rotation']), rotation=axis_labels['x']['rotation']) 90 | ax.set_ylabel(axis_labels['y']['text'], fontsize=fontsize, ha="right", 91 | va=_label_alignment(axis_labels['y']['rotation']), rotation=axis_labels['y']['rotation']) 92 | 93 | # compute axis size in points 94 | bbox = ax.get_window_extent().transformed(fig.dpi_scale_trans.inverted()) 95 | width, height = bbox.width * 72, bbox.height * 72 96 | 97 | # compute relative padding 98 | lwX = ax.spines['bottom'].get_linewidth() 99 | # TODO if use_scientific_notations is True, then take ~70% of fontsize * ~1/4 and add it to the padding 100 | 101 | lwY = 0 #ax.spines['left'].get_linewidth() 102 | 103 | # coordinates in percentage of the figure main body size! 104 | ax.xaxis.set_label_coords(1, -(pad - lwX) / height) 105 | 106 | # coordinates in percentage of the figure main body size! 107 | ax.yaxis.set_label_coords(-(pad - lwY) / width, 1) 108 | 109 | def _apply_axes_properties_and_labels(fig, ax, axis_properties, labels, config, fontsize): 110 | _apply_axis_range(ax, axis_properties) 111 | 112 | if not config['has_right_axis']: 113 | ax.spines['right'].set_visible(False) 114 | ax.yaxis.set_ticks_position('left') 115 | if not config['has_upper_axis']: 116 | ax.spines['top'].set_visible(False) 117 | ax.xaxis.set_ticks_position('bottom') 118 | 119 | tick_lw_pt = config['tick_linewidth_pt'] 120 | plt.tick_params(width=tick_lw_pt, length=(tick_lw_pt * 4), labelsize=fontsize, pad=(tick_lw_pt * 2)) 121 | _set_labels(fig, ax, labels, fontsize, pad=(tick_lw_pt * 6)) 122 | # if use_scientific_notations True, displaystyle is used in pgf --> offset of ticks changes 123 | 124 | _apply_axes_properties(ax, axis_properties) 125 | 126 | def _place_marker(ax, marker_data): 127 | try: 128 | vlines = marker_data['vertical_lines'] 129 | except: 130 | vlines = [] 131 | 132 | for vl in vlines: 133 | ax.axvline(x=vl['pos'], color=np.array(vl['color'])/255.0, linewidth=vl['linewidth_pt'], 134 | linestyle=vl['linestyle']) 135 | 136 | 137 | # ------ FINALLY -------- 138 | class MatplotLinePlot(Plot): 139 | def __init__(self, aspect_ratio, data) -> None: 140 | """Creates a line plot using matplotlib with a pgf (i.e. LaTeX) backend 141 | 142 | Args: 143 | aspect_ratio (float): Height/width ratio of the plotting area (used for alignment and grid sizing) 144 | data (list): A list of plot lines. Each element is a pair of two equal-sized lists: the x and y coordinates. 145 | """ 146 | self.aspect_ratio = aspect_ratio 147 | self._data = data 148 | self._names = None 149 | self._linestyles = [ "solid" for _ in data ] 150 | self._labels = {} 151 | self._axis_properties = {} 152 | self._markers = {} 153 | self._font = { 154 | "tex_package": "{libertine}", 155 | "font_family": "sans-serif", 156 | "fontsize_pt": 7 157 | } 158 | self._grid = { 159 | "color": [ 230, 230, 230 ], 160 | "linewidth_pt": 0.25, 161 | "linestyle": "-" 162 | } 163 | self._config = { 164 | "plot_linewidth_pt": 0.8, 165 | "tick_linewidth_pt": 0.6, 166 | "has_upper_axis": False, 167 | "has_right_axis": False 168 | } 169 | self._colors = [ 170 | [232, 181, 88], 171 | [5, 142, 78], 172 | [94, 163, 188], 173 | [181, 63, 106], 174 | [20, 20, 20] 175 | ] 176 | self.set_axis_label("x", "") 177 | self.set_axis_label("y", "") 178 | self.set_axis_properties("x", [], use_log_scale=False) 179 | self.set_axis_properties("y", [], use_log_scale=False) 180 | 181 | def get_colors(self): 182 | return self._colors 183 | 184 | def set_colors(self, color_list): 185 | ''' 186 | color list contains a list of colors. A color is defined as [r,g,b] while each channel 187 | ranges from 0 to 255. 188 | ''' 189 | self._colors = color_list 190 | 191 | def get_axis_label(self, axis=None): 192 | if axis is None: 193 | return self._labels 194 | _check_axis(axis) 195 | try: 196 | return self._labels[axis] 197 | except: 198 | raise Error('Label is not defined.') 199 | 200 | def set_axis_label(self, axis, txt, rotation=None): 201 | _check_axis(axis) 202 | 203 | if rotation is not None: 204 | rotation = _interpret_rotation(rotation) 205 | else: 206 | rotation = _default_rotation(axis) 207 | 208 | self._labels[axis] = {} 209 | self._labels[axis]['text'] = txt 210 | self._labels[axis]['rotation'] = rotation 211 | 212 | def get_axis_properties(self, axis): 213 | _check_axis(axis) 214 | try: 215 | return self._axis_properties[axis] 216 | except: 217 | raise Error('Label is not defined.') 218 | 219 | def set_axis_properties(self, axis, ticks, range=None, use_log_scale=True, use_scientific_notations=False): 220 | ''' 221 | The user should find and define suitable ticks so that the labels and ticks don't overlap. 222 | Would be nice to do that automatically at some point. 223 | ''' 224 | _check_axis(axis) 225 | if range is not None and len(range) != 2: 226 | raise Error('You need exactly two values to specify range: [min, max]') 227 | 228 | self._axis_properties[axis] = {} 229 | if range is not None: 230 | self._axis_properties[axis]['range'] = range 231 | self._axis_properties[axis]['ticks'] = ticks 232 | self._axis_properties[axis]['use_log_scale'] = use_log_scale 233 | self._axis_properties[axis]['use_scientific_notations'] = use_scientific_notations 234 | 235 | def get_v_line(self): 236 | try: 237 | test = self._markers['vertical_lines'][0] 238 | return self._markers['vertical_lines'] 239 | except: 240 | return [] 241 | 242 | def set_v_line(self, pos, color, linestyle, linewidth_pt=.8): 243 | ''' 244 | Currently, we only implemented "vertical_lines" 245 | linestyle allows matplotlib inputs, e.g. (0,(4,6)) is valid. 246 | ''' 247 | try: 248 | test = self._markers['vertical_lines'][0] 249 | except: 250 | self._markers['vertical_lines'] = [] 251 | self._markers['vertical_lines'].append({ 252 | 'pos': pos, 253 | 'color': color, 254 | "linestyle": linestyle, 255 | "linewidth_pt": linewidth_pt, 256 | }) 257 | 258 | def get_font(self): 259 | return self._font 260 | 261 | def set_font(self, fontsize_pt=None, font_family=None, tex_package=None): 262 | if fontsize_pt is not None: 263 | self._font["fontsize_pt"] = fontsize_pt 264 | if font_family is not None: 265 | self._font["font_family"] = font_family 266 | if tex_package is not None: 267 | self._font["tex_package"] = tex_package 268 | 269 | def set_grid_properties(self, color=None, linewidth_pt=None, linestyle=None): 270 | if color is not None: 271 | self._grid["color"] = color 272 | if linewidth_pt is not None: 273 | self._grid["linewidth_pt"] = linewidth_pt 274 | if linestyle is not None: 275 | self._grid["linestyle"] = linestyle 276 | 277 | def show_upper_axis(self, show=True): 278 | self._config["has_upper_axis"] = show 279 | 280 | def show_right_axis(self, show=True): 281 | self._config["has_right_axis"] = show 282 | 283 | def set_linewidth(self, plot_line_pt=None, tick_line_pt=None): 284 | if plot_line_pt is not None: 285 | self._config['plot_linewidth_pt'] = plot_line_pt 286 | if tick_line_pt is not None: 287 | self._config['tick_linewidth_pt'] = tick_line_pt 288 | 289 | def set_linestyle(self, idx: int, linestyle): 290 | ''' Sets the linestyle of an individual plot line. Value can be anything supported by matplotlib 291 | https://matplotlib.org/stable/gallery/lines_bars_and_markers/linestyles.html 292 | ''' 293 | self._linestyles[idx] = linestyle 294 | 295 | def set_legend(self, names): 296 | ''' Enables a legend and uses the given list of strings for the names 297 | ''' 298 | assert len(names) == len(self._data), "Must have exactly one name per plot line" 299 | self._names = names 300 | 301 | def _make(self, width_mm, height_mm, filename): 302 | matplot_mutex.acquire() 303 | try: 304 | _setup_fonts(plt, self._font) 305 | figsize = units.mm_to_inches(np.array([width_mm, height_mm])) 306 | 307 | #constrained_layout: https://matplotlib.org/3.2.1/tutorials/intermediate/constrainedlayout_guide.html 308 | fig, ax = plt.subplots(figsize=figsize, constrained_layout=True) 309 | fig.set_constrained_layout_pads(w_pad=0, h_pad=0, hspace=0., wspace=0.) 310 | 311 | _plot_lines(ax, self._data, self._colors, self._config['plot_linewidth_pt'], self._linestyles) 312 | _apply_axes_properties_and_labels(fig, ax, self._axis_properties, self._labels, 313 | self._config, self._font['fontsize_pt']) 314 | plt.grid(color=np.array(self._grid['color'])/255.0, linestyle=self._grid['linestyle'], 315 | linewidth=self._grid['linewidth_pt']) 316 | _place_marker(ax, self._markers) 317 | 318 | if self._names is not None: 319 | ax.legend(self._names) 320 | 321 | plt.savefig(filename, pad_inches=0.0, dpi=500) 322 | finally: 323 | matplot_mutex.release() 324 | 325 | def make_raster(self, width_mm, height_mm, filename): 326 | self._make(width_mm, height_mm, filename + ".png") 327 | return filename + ".png" 328 | 329 | def make_pdf(self, width_mm, height_mm, filename): 330 | self._make(width_mm, height_mm, filename + ".pdf") 331 | return filename + ".pdf" -------------------------------------------------------------------------------- /figuregen/pdflatex.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | import os 3 | import shutil 4 | import subprocess 5 | from .tikz import TikzBackend 6 | from .backend import Backend, Component, Bounds 7 | from typing import List 8 | 9 | class PdfBackend(Backend): 10 | """ 11 | Generates a TikZ picture and compiles it with pdflatex to a .pdf 12 | Requires pdflatex in the PATH. 13 | 14 | Currently only supports a single figure being created at a time. That is, multi-threaded applications 15 | need to create one PdfBackend object per figure. Further, if an intermediate directory is set, that directory 16 | cannot be the same for two figures that are created at the same time, as they might overwrite each other's files. 17 | """ 18 | 19 | def __init__(self, intermediate_dir = None, preamble_lines=[ "\\usepackage[utf8]{inputenc}", 20 | "\\usepackage[T1]{fontenc}", "\\usepackage{libertine}" ]): 21 | self._custom_preamble = "\n".join(preamble_lines) 22 | self._tikz_gen = TikzBackend(False) 23 | 24 | if intermediate_dir is not None: 25 | self._temp_folder = None 26 | self._intermediate_dir = intermediate_dir 27 | os.makedirs(intermediate_dir, exist_ok=True) 28 | else: 29 | self._temp_folder = tempfile.TemporaryDirectory() 30 | self._intermediate_dir = self._temp_folder.name 31 | 32 | def __del__(self): 33 | if self._temp_folder is not None: 34 | self._temp_folder.cleanup() 35 | self._temp_folder = None 36 | 37 | @property 38 | def preamble(self) -> str: 39 | return '\n'.join([ 40 | "\\documentclass[varwidth=500cm, border=0pt]{standalone}", 41 | ]) 42 | 43 | def assemble_grid(self, components: List[Component], output_dir: str): 44 | return self._tikz_gen.assemble_grid(components, self._intermediate_dir) 45 | 46 | def combine_grids(self, data, idx: int, bounds: Bounds): 47 | return self._tikz_gen.combine_grids(data, idx, bounds) 48 | 49 | def combine_rows(self, data, bounds: Bounds): 50 | return self._tikz_gen.combine_rows(data, bounds) 51 | 52 | def write_to_file(self, data, filename): 53 | _, ext = os.path.splitext(filename) 54 | assert ext.lower() == ".pdf", "Filename should have .pdf extension!" 55 | 56 | tikz_filename = os.path.join(self._intermediate_dir, "figure.tikz") 57 | self._tikz_gen.write_to_file(data, tikz_filename) 58 | 59 | tex_code = "\n".join([ 60 | self.preamble, 61 | self._custom_preamble, 62 | self._tikz_gen.preamble, 63 | self._tikz_gen.header, 64 | "\\begin{document}", 65 | "\\input{figure.tikz}", 66 | "\\end{document}", 67 | ]) 68 | 69 | tex_filename = os.path.join(self._intermediate_dir, "figure.tex") 70 | pdf_filename = os.path.join(self._intermediate_dir, "figure.pdf") 71 | with open(tex_filename, "w") as f: 72 | f.write(tex_code) 73 | 74 | try: 75 | subprocess.check_call([ 76 | "pdflatex", 77 | "-interaction=batchmode", 78 | "figure.tex" 79 | ], cwd=self._intermediate_dir, stdout=subprocess.DEVNULL) 80 | except subprocess.CalledProcessError: 81 | from texsnip import extract_errors, red 82 | 83 | logfile = os.path.join(self._intermediate_dir, "figure.log") 84 | if not os.path.exists(logfile): 85 | print("Error: pdflatex failed, but no log was written.") 86 | else: 87 | print("Error: pdflatex failed with the following errors:") 88 | print("\n".join([errline for errline in extract_errors(logfile)])) 89 | print(red(f"Error: pdflatex failed. You can view the full log in {logfile}. " 90 | "This path can be changed by specifying an intermediate_dir")) 91 | 92 | shutil.copy(pdf_filename, filename) -------------------------------------------------------------------------------- /figuregen/pgf_lineplot.py: -------------------------------------------------------------------------------- 1 | from .element_data import * 2 | 3 | import os 4 | import tempfile 5 | import subprocess 6 | import shutil 7 | import decimal 8 | 9 | class PgfLinePlot(Plot): 10 | def __init__(self, aspect_ratio, data, dpi=300, axis_lines="left", tex_dir=None) -> None: 11 | """Creates a line plot using LaTeX with the pgfplots package 12 | 13 | Arguments: 14 | aspect_ratio (float): Height/width ratio of the plotting area (used for alignment and grid sizing) 15 | data (list): A list of plot lines. Each element is a pair of two equal-sized lists: the x and y coordinates. 16 | axis_lines (str): Either "left" (arrows on the left and bottom always) or "middle" (arrows at 0 coordinate) 17 | """ 18 | self.aspect_ratio = aspect_ratio 19 | self._data = data 20 | self._markers = {} 21 | self.set_font(7, "{libertine}") 22 | self.set_linewidth(0.8, 0.6) 23 | self._colors = [ 24 | [232, 181, 88], 25 | [5, 142, 78], 26 | [94, 163, 188], 27 | [181, 63, 106], 28 | [20, 20, 20] 29 | ] 30 | self._labels = {} 31 | self.set_axis_label("x", "", False) 32 | self.set_axis_label("y", "", True) 33 | self._axis_properties = {} 34 | self.set_axis_properties("x", [], use_log_scale=False) 35 | self.set_axis_properties("y", [], use_log_scale=False) 36 | self.set_padding(5, 5, 0) 37 | self._dpi = dpi 38 | self._axis_lines = axis_lines 39 | self._tex_dir = tex_dir 40 | self._legend = {} 41 | self.set_legend(line_length_mm=3, line_width_pt=1, pos=(1,1), anchor="north east", 42 | frame_line_width_pt=0.5, fontsize_pt=7, color_srgb=(100,100,100)) 43 | 44 | def get_colors(self): 45 | return self._colors 46 | 47 | def set_colors(self, color_list): 48 | ''' 49 | color list contains a list of colors. A color is defined as [r,g,b] while each channel 50 | ranges from 0 to 255. 51 | ''' 52 | self._colors = color_list 53 | 54 | def set_legend(self, names=None, line_length_mm=None, line_width_pt=None, pos=None, anchor=None, 55 | frame_line_width_pt=None, fontsize_pt=None, color_srgb=None): 56 | if names is not None: 57 | self._legend["names"] = names 58 | if line_length_mm is not None: 59 | self._legend["line_length"] = line_length_mm 60 | if line_width_pt is not None: 61 | self._legend["line_width"] = line_width_pt 62 | if pos is not None: 63 | self._legend["pos"] = pos 64 | if anchor is not None: 65 | self._legend["anchor"] = anchor 66 | if frame_line_width_pt is not None: 67 | self._legend["frame_line_width"] = frame_line_width_pt 68 | if fontsize_pt is not None: 69 | self._legend["fontsize"] = fontsize_pt 70 | if color_srgb is not None: 71 | self._legend["color"] = color_srgb 72 | 73 | def set_axis_label(self, axis, txt, vertical=False): 74 | self._labels[axis] = {} 75 | self._labels[axis]['text'] = txt.replace("\n", "\\\\{}") 76 | self._labels[axis]['vertical'] = vertical 77 | 78 | def set_axis_properties(self, axis, ticks, range=None, use_log_scale=True, use_scientific_notations=False): 79 | ''' 80 | The user should find and define suitable ticks so that the labels and ticks don't overlap. 81 | Would be nice to do that automatically at some point. 82 | ''' 83 | if range is not None and len(range) != 2: 84 | raise Error('You need exactly two values to specify range: [min, max]') 85 | 86 | self._axis_properties[axis] = {} 87 | if range is not None: 88 | self._axis_properties[axis]['range'] = range 89 | self._axis_properties[axis]['ticks'] = ticks 90 | self._axis_properties[axis]['use_log_scale'] = use_log_scale 91 | self._axis_properties[axis]['use_scientific_notations'] = use_scientific_notations 92 | 93 | def set_font(self, fontsize_pt=None, tex_package=None): 94 | if fontsize_pt is not None: 95 | self._fontsize_pt = fontsize_pt 96 | if tex_package is not None: 97 | self._font_tex_package = tex_package 98 | 99 | def set_linewidth(self, plot_line_pt=None, tick_line_pt=None): 100 | if plot_line_pt is not None: 101 | self._plot_linewidth_pt = plot_line_pt 102 | if tick_line_pt is not None: 103 | self._tick_linewidth_pt = tick_line_pt 104 | 105 | def set_padding(self, bottom_mm=None, left_mm=None, right_mm=None): 106 | if bottom_mm is not None: 107 | self._pad_bot_mm = bottom_mm 108 | if left_mm is not None: 109 | self._pad_left_mm = left_mm 110 | if right_mm is not None: 111 | self._pad_right_mm = right_mm 112 | 113 | def set_v_line(self, pos, color, linestyle=[], linewidth_pt=.8, phase_shift=0): 114 | ''' Adds a vertical line to the plot 115 | 116 | Args: 117 | color: the sRGB color of the line, each value in range [0, 255] 118 | linestyle: a list of dash lengths following the pattern: [on, off, on, off, ...]. 119 | An empty list corresponds to a solid line 120 | phase_shift: offset added to the dash pattern 121 | ''' 122 | try: 123 | test = self._markers['vertical_lines'][0] 124 | except: 125 | self._markers['vertical_lines'] = [] 126 | self._markers['vertical_lines'].append({ 127 | 'pos': pos, 128 | 'color': color, 129 | "linestyle": linestyle, 130 | "linewidth_pt": linewidth_pt, 131 | "linephase": phase_shift, 132 | }) 133 | 134 | def set_h_line(self, pos, color, linestyle=[], linewidth_pt=.8, phase_shift=0): 135 | ''' Adds a horizontal line to the plot 136 | Args: 137 | color: the sRGB color of the line, each value in range [0, 255] 138 | linestyle: a list of dash lengths following the pattern: [on, off, on, off, ...]. 139 | An empty list corresponds to a solid line 140 | ''' 141 | try: 142 | test = self._markers['horizontal_lines'][0] 143 | except: 144 | self._markers['horizontal_lines'] = [] 145 | self._markers['horizontal_lines'].append({ 146 | 'pos': pos, 147 | 'color': color, 148 | "linestyle": linestyle, 149 | "linewidth_pt": linewidth_pt, 150 | "linephase": phase_shift, 151 | }) 152 | 153 | def _compile_tex(self, tex, name): 154 | """ Compiles the given LaTeX code. 155 | 156 | Args: 157 | - tex The file content of the LaTeX file to compile 158 | - name Name of the output without the .pdf extension 159 | - intermediate_dir Specify an existing directory here and .tex and .log files will be kept there 160 | """ 161 | if self._tex_dir is not None and os.path.isdir(self._tex_dir): 162 | temp_folder = None 163 | temp_dir = os.path.abspath(self._tex_dir) 164 | else: 165 | temp_folder = tempfile.TemporaryDirectory() 166 | temp_dir = temp_folder.name 167 | 168 | with open(os.path.join(temp_dir, f"{os.path.basename(name)}.tex"), "w") as fp: 169 | fp.write(tex) 170 | 171 | try: 172 | subprocess.check_call(["pdflatex", "-interaction=nonstopmode", f"{os.path.basename(name)}.tex"], 173 | cwd=temp_dir, stdout=subprocess.DEVNULL) 174 | except subprocess.CalledProcessError: 175 | from texsnip import extract_errors, red 176 | 177 | logfile = os.path.join(temp_dir, f"{os.path.basename(name)}.log") 178 | if not os.path.exists(logfile): 179 | print(red("Error: pdflatex failed, but no log was written.")) 180 | else: 181 | print("\n".join([errline for errline in extract_errors(logfile)])) 182 | print(red(f"Error: pdflatex failed. Syntax error or missing package? " 183 | "You can view the full log in {logfile}. This path can be changed by specifying an intermediate_dir")) 184 | extract_errors(f"{os.path.basename(name)}.log") 185 | raise 186 | 187 | try: 188 | shutil.copy(os.path.join(temp_dir, f"{os.path.basename(name)}.pdf"), f"{name}.pdf") 189 | except shutil.SameFileError: 190 | pass # If the file is already where it is supposed to be, we don't do anything 191 | 192 | if temp_folder is not None: 193 | temp_folder.cleanup() 194 | 195 | def _ticks_to_str(self, axis): 196 | ticks = self._axis_properties[axis]['ticks'] 197 | if ticks is None or len(ticks) == 0: 198 | return "\\empty" 199 | tick_str = [f"{t}" for t in ticks] 200 | return "{" + ",".join(tick_str) + "}" 201 | 202 | @staticmethod 203 | def _dash_pattern_to_str(pattern, phase): 204 | if pattern is None: 205 | return "{}" 206 | names = ["on", "off"] 207 | seq = "dash pattern = {" 208 | for i in range(len(pattern)): 209 | seq += f"{names[i % 2]} {pattern[i]} " 210 | seq += "}," 211 | phase = "dash phase = {" + str(phase) + "}," 212 | return seq + phase 213 | 214 | def _clip_ticks(self, axis): 215 | # Ensure that all ticks fall within the range, otherwise LaTeX will not compile 216 | if 'range' in self._axis_properties[axis]: 217 | if self._axis_properties[axis]['ticks'] is not None: 218 | clipped = [] 219 | for t in self._axis_properties[axis]['ticks']: 220 | if t > self._axis_properties[axis]['range'][0] and t < self._axis_properties[axis]['range'][1]: 221 | clipped.append(t) 222 | self._axis_properties[axis]['ticks'] = clipped 223 | 224 | def _clean(self): 225 | self._clip_ticks("x") 226 | self._clip_ticks("y") 227 | 228 | def _make_tex(self, width, height): 229 | self._clean() 230 | 231 | tex_code = "" 232 | 233 | preamble_lines = [ 234 | "\\documentclass{article}", 235 | "\\pagenumbering{gobble}", 236 | "\\usepackage{xcolor}", 237 | "\\usepackage{graphicx}", 238 | "\\usepackage[utf8]{inputenc}", 239 | "\\usepackage[T1]{fontenc}", 240 | "\\usepackage{geometry}", 241 | "\\usepackage{tikz}", 242 | "\\usepackage{pgfplots}", 243 | "\\pgfplotsset{compat=newest}", 244 | "\\pgfplotsset{", 245 | " legend image code/.code={", 246 | f" \\draw[mark repeat=2,mark phase=2,line width={self._legend['line_width']}pt]", 247 | f" plot coordinates {{(0cm,0cm)({self._legend['line_length']}mm,0cm)}};", 248 | " }", 249 | "}", 250 | 251 | "\\usepackage" + self._font_tex_package, 252 | 253 | "\\newcommand{\\width}{" + f"{width}mm" + "}", 254 | "\\newcommand{\\height}{" + f"{height}mm" + "}", 255 | "\\newcommand{\\padbot}{" + f"{self._pad_bot_mm}mm" + "}", 256 | "\\newcommand{\\padleft}{" + f"{self._pad_left_mm}mm" + "}", 257 | "\\newcommand{\\padright}{" + f"{self._pad_right_mm}mm" + "}", 258 | 259 | "\\geometry{", 260 | " papersize={\\width,\\height},", 261 | " total={\\width,\\height},", 262 | " left=0mm,", 263 | " top=0mm,", 264 | "}", 265 | 266 | "\\makeatletter \\newcommand{\\pgfplotsdrawaxis}{\\pgfplots@draw@axis} \\makeatother", 267 | "\\pgfplotsset{axis line on top/.style={", 268 | "axis line style=transparent,", 269 | "ticklabel style=transparent,", 270 | "tick style=transparent,", 271 | "axis on top=false,", 272 | "after end axis/.append code={", 273 | " \\pgfplotsset{axis line style=opaque,", 274 | " ticklabel style=opaque,", 275 | " tick style=opaque,", 276 | " grid=none}", 277 | " \\pgfplotsdrawaxis}", 278 | " }", 279 | "}", 280 | "\\definecolor{legendframecolor}{RGB}{" + 281 | f"{self._legend['color'][0]},{self._legend['color'][1]},{self._legend['color'][2]}" + "}", 282 | ] 283 | 284 | if self._colors is not None: 285 | i = 0 286 | for clr in self._colors: 287 | preamble_lines.append("\\definecolor{color" + f"{i}" + "}{RGB}{" 288 | + f"{clr[0]},{clr[1]},{clr[2]}" + "}") 289 | i += 1 290 | 291 | if 'vertical_lines' in self._markers: 292 | i = 0 293 | for m in self._markers['vertical_lines']: 294 | clr = m["color"] 295 | preamble_lines.append("\\definecolor{vertlinecolor" + f"{i}" + "}{RGB}{" 296 | + f"{clr[0]},{clr[1]},{clr[2]}" + "}") 297 | i += 1 298 | 299 | if 'horizontal_lines' in self._markers: 300 | i = 0 301 | for m in self._markers['horizontal_lines']: 302 | clr = m["color"] 303 | preamble_lines.append("\\definecolor{horzlinecolor" + f"{i}" + "}{RGB}{" 304 | + f"{clr[0]},{clr[1]},{clr[2]}" + "}") 305 | i += 1 306 | preamble_lines.append("\\definecolor{gridcolor}{RGB}{220,220,220}") 307 | 308 | tex_code += "\n".join(preamble_lines) + "\n" 309 | 310 | body_start_lines = [ 311 | "\\begin{document}", 312 | "\\raggedleft", 313 | "\\begin{tikzpicture}[trim axis left]", 314 | "\\clip (-\\padleft,-\\padbot) rectangle (\\width-\\padleft, \\height-\\padbot);", 315 | "\\begin{axis}[", 316 | " scale only axis,", 317 | " height=\\height-\\padbot,", 318 | " width=\\width-\\padleft-\\padright,", 319 | f" axis lines = {self._axis_lines},", 320 | " xlabel near ticks,", 321 | " xlabel={" + self._labels["x"]["text"] + "},", 322 | " ylabel={" + self._labels["y"]["text"] + "},", 323 | f" xtick={self._ticks_to_str('x')},", 324 | f" ytick={self._ticks_to_str('y')},", 325 | " yminorticks=false,", 326 | " xminorticks=false,", 327 | " yticklabel style={inner sep=1pt},", # distance between y tick labels and the marker 328 | " xticklabel style={inner sep=1pt},", # distance between x tick labels and the marker 329 | " legend pos=north west,", 330 | " ymajorgrids=true,", 331 | " xmajorgrids=true,", 332 | " grid style={solid, line width=0.25pt, gridcolor},", 333 | " axis line on top,", 334 | " xlabel style={", 335 | " inner sep=3pt,", 336 | " at={([xshift=3pt] ticklabel* cs:1.0)}, anchor=north east,", 337 | " text width=\\width,", 338 | " align=right,", 339 | " },", 340 | " ylabel style={", 341 | " inner sep=3pt," 342 | 343 | " at={([yshift=3pt] ticklabel* cs:1.0)}, anchor=south east, " 344 | if self._labels["y"]["vertical"] else 345 | " at={([yshift=0pt] ticklabel* cs:1.0)}, anchor=north east,", 346 | 347 | " rotate=0," if self._labels["y"]["vertical"] else " rotate=270,", 348 | " text width=\\height,", 349 | " align=right,", 350 | " },", 351 | " label style={", 352 | " font=\\fontsize{" + f"{self._fontsize_pt}" + "pt}{" + f"{self._fontsize_pt}" + "pt}\\selectfont", 353 | " },", 354 | " tick label style={", 355 | " font=\\fontsize{" + f"{self._fontsize_pt}" + "pt}{" + f"{self._fontsize_pt}" + "pt}\\selectfont", 356 | " },", 357 | f" line width={self._plot_linewidth_pt}pt,", 358 | " axis line style={line width=" + f"{self._tick_linewidth_pt}" + "pt},", 359 | " tick style={", 360 | f" line width={self._tick_linewidth_pt}pt,", 361 | " color=black", 362 | " },", 363 | " x tick label style={", 364 | " /pgf/number format/.cd,", 365 | " scaled x ticks = false,", 366 | " fixed," if not self._axis_properties["x"]['use_scientific_notations'] else "", 367 | " /tikz/.cd", 368 | " },", 369 | " y tick label style={", 370 | " /pgf/number format/.cd,", 371 | " scaled y ticks = false,", 372 | " fixed," if not self._axis_properties["y"]['use_scientific_notations'] else "", 373 | " /tikz/.cd", 374 | " },", 375 | " legend cell align={left},", 376 | " legend style = {", 377 | f" at={{({self._legend['pos'][0]},{self._legend['pos'][1]})}},", 378 | f" anchor ={self._legend['anchor']},", 379 | f" line width={self._legend['frame_line_width']},", 380 | " draw=legendframecolor,", 381 | f" font=\\fontsize{{ {self._legend['fontsize']}pt}}{{ {self._legend['fontsize']}pt}}\\selectfont,", 382 | " },", 383 | ] 384 | 385 | if not self._axis_properties["x"]['use_scientific_notations']: 386 | body_start_lines.append(" log ticks with fixed point,") 387 | 388 | if 'range' in self._axis_properties['x']: 389 | body_start_lines.append(f" xmin={self._axis_properties['x']['range'][0]}, xmax={self._axis_properties['x']['range'][1]},") 390 | # body_start_lines.append(f" restrict x to domain={self._axis_properties['x']['range'][0]}:{self._axis_properties['x']['range'][1]},") 391 | if 'range' in self._axis_properties['y']: 392 | body_start_lines.append(f" ymin={self._axis_properties['y']['range'][0]}, ymax={self._axis_properties['y']['range'][1]},") 393 | # body_start_lines.append(f" restrict y to domain={self._axis_properties['y']['range'][0]}:{self._axis_properties['y']['range'][1]},") 394 | 395 | if self._axis_properties["x"]['use_log_scale']: 396 | body_start_lines.append(" xmode=log,") 397 | if self._axis_properties["y"]['use_log_scale']: 398 | body_start_lines.append(" ymode=log,") 399 | 400 | body_start_lines.append("]") 401 | tex_code += "\n".join(body_start_lines) + "\n" 402 | 403 | # Add the actual plot lines 404 | for line_idx in range(len(self._data)): 405 | plot_code = [ 406 | "\\addplot[", 407 | f" color=color{line_idx}" if self._colors is not None and len(self._colors) > line_idx else "", 408 | "]", 409 | "coordinates {", 410 | ] 411 | coords = "" 412 | for i in range(len(self._data[line_idx][0])): 413 | x = self._data[line_idx][0][i] 414 | y = self._data[line_idx][1][i] 415 | # Format as fixed point because pgfplots does not understand scientific notation. 416 | # We convert to decimal, because that is the easiest way to get Python to include all digits 417 | coords += f"({decimal.Decimal(x):f},{decimal.Decimal(y):f})" 418 | plot_code.append(coords) 419 | plot_code.append("};") 420 | 421 | plot_code = "\n".join(plot_code) + "\n" 422 | 423 | # Long lines of text will cause pdflatex to crash, so we need to wrap the lines 424 | max_line_len = 200 425 | if len(plot_code) > max_line_len: 426 | while plot_code: 427 | tex_code += plot_code[ : max_line_len] + "\n" 428 | plot_code = plot_code[max_line_len : ] 429 | else: 430 | tex_code += plot_code 431 | 432 | if "names" in self._legend and self._legend["names"] is not None: 433 | tex_code += "\\addlegendentry{" + self._legend["names"][line_idx] + "}\n" 434 | 435 | # Add vertical and horizontal markers 436 | if "vertical_lines" in self._markers: 437 | i = 0 438 | for m in self._markers["vertical_lines"]: 439 | codelines = [ 440 | "\\draw[", 441 | f" vertlinecolor{i},", 442 | f" line width={m['linewidth_pt']}pt,", 443 | self._dash_pattern_to_str(m["linestyle"], m["linephase"]), 444 | "]", 445 | "({axis cs:" + f"{m['pos']}" + ",0}|-{rel axis cs:0,1}) -- ({axis cs:" 446 | + f"{m['pos']}" + ",0}|-{rel axis cs:0,0});" 447 | ] 448 | tex_code += "\n".join(codelines) + "\n" 449 | i += 1 450 | if "horizontal_lines" in self._markers: 451 | i = 0 452 | for m in self._markers["horizontal_lines"]: 453 | codelines = [ 454 | "\\draw[", 455 | f" horzlinecolor{i},", 456 | f" line width={m['linewidth_pt']}pt,", 457 | self._dash_pattern_to_str(m["linestyle"], m["linephase"]), 458 | "]", 459 | "({rel axis cs:1,0}|-{axis cs:0," + f"{m['pos']}" + 460 | "}) -- ({rel axis cs:0,0}|-{axis cs:0," + f"{m['pos']}" + "});" 461 | ] 462 | tex_code += "\n".join(codelines) + "\n" 463 | i += 1 464 | 465 | body_end_lines = [ 466 | "\\end{axis}", 467 | "\\end{tikzpicture}", 468 | "\\end{document}", 469 | ] 470 | tex_code += "\n".join(body_end_lines) + "\n" 471 | 472 | return tex_code 473 | 474 | def make_pdf(self, width, height, filename): 475 | tex = self._make_tex(width, height) 476 | self._compile_tex(tex, filename) 477 | return filename + ".pdf" 478 | 479 | def make_raster(self, width, height, filename): 480 | fn = self.make_pdf(width, height, filename) 481 | from pdf2image.pdf2image import convert_from_path 482 | convert_from_path(fn, dpi=self._dpi, transparent=True, fmt="png", output_file=filename, single_file=True) 483 | return filename + ".png" -------------------------------------------------------------------------------- /figuregen/powerpoint.py: -------------------------------------------------------------------------------- 1 | from pptx import Presentation 2 | from pptx.util import Mm, Pt 3 | from pptx.dml.color import RGBColor 4 | from pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR 5 | from pptx.enum.text import PP_ALIGN 6 | import importlib.resources as pkg_resources 7 | from concurrent.futures import ThreadPoolExecutor, Future 8 | from threading import Lock 9 | from .backend import * 10 | 11 | class PptxBackend(Backend): 12 | ''' A very basic .pptx generator that only roughly matches results of other backends. 13 | 14 | in PPTX format we have the following limitations due to our dependency python-pptx: 15 | - ignore background colors 16 | - ignore vertical alignment and padding of titles 17 | - do not support 'dashed' frames - if a frame is 'dashed' the frame in pptx will be normal (but still has a frame) 18 | - only support text rotation by 0° and +-90° 19 | ''' 20 | def __init__(self): 21 | self._thread_pool = ThreadPoolExecutor() 22 | self._slide_mutex = Lock() 23 | 24 | def assemble_grid(self, components: List[Component], output_dir: str): 25 | return components 26 | 27 | def combine_grids(self, data: List[List[Component]], idx: int, bounds: Bounds) -> List[Component]: 28 | flat = [] 29 | for row in data: 30 | flat.extend(row) 31 | return flat 32 | 33 | def _add_image(self, c: Component, slide): 34 | # Write image to temp folder 35 | with tempfile.TemporaryDirectory() as tmpdir: 36 | fname = c.data.make_raster(c.bounds.width, c.bounds.height, os.path.join(tmpdir, "image")) 37 | self._slide_mutex.acquire() 38 | shape = slide.shapes.add_picture(fname, Mm(c.bounds.left), Mm(c.bounds.top), 39 | width=Mm(c.bounds.width)) 40 | shape.shadow.inherit = False 41 | self._slide_mutex.release() 42 | 43 | if c.has_frame: 44 | self._slide_mutex.acquire() 45 | offset = Pt(c.frame_linewidth) / 2 46 | shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Mm(c.bounds.left) + offset, 47 | Mm(c.bounds.top) + offset, Mm(c.bounds.width) - Pt(c.frame_linewidth), 48 | Mm(c.bounds.height) - Pt(c.frame_linewidth)) 49 | shape.shadow.inherit = False 50 | shape.line.color.rgb = RGBColor(c.frame_color[0], c.frame_color[1], c.frame_color[2]) 51 | shape.line.width = Pt(c.frame_linewidth) 52 | # shape.line.join_type = 'Miter' # Removes rounded edges, but is not supported, yet (sadly) 53 | shape.fill.background() 54 | self._slide_mutex.release() 55 | 56 | def combine_rows(self, data: List[Component], bounds: Bounds): 57 | # We load a template from a file to have some nicer line styles etc by default 58 | # (they cannot currently be specified via python-pptx) 59 | with tempfile.TemporaryDirectory() as tmpdir: 60 | themedata = pkg_resources.read_binary(__package__, "theme.pptx") 61 | p = os.path.join(tmpdir, "theme.pptx") 62 | with open(p, "wb") as f: 63 | f.write(themedata) 64 | prs = Presentation(p) 65 | 66 | # Create a single slide presentation with a blank slide 67 | # prs = Presentation() 68 | prs.slide_height = Mm(bounds.height) 69 | prs.slide_width = Mm(bounds.width) 70 | blank_slide_layout = prs.slide_layouts[6] 71 | slide = prs.slides.add_slide(blank_slide_layout) 72 | 73 | # Add all our elements to the slide 74 | flat = [] 75 | for row in data: 76 | flat.extend(row) 77 | 78 | # Generate all images in parallel 79 | futures = [] 80 | for c in flat: 81 | if isinstance(c, ImageComponent): 82 | futures.append(self._thread_pool.submit(self._add_image, c, slide)) 83 | for f in futures: 84 | f.result() 85 | 86 | # Add everything else afterwards, to ensure proper z-order 87 | for c in flat: 88 | if isinstance(c, TextComponent): 89 | if c.rotation == 90.0 or c.rotation == -90.0: 90 | # The shape is rotated about its center. We want a rotation about the top left corner instead. 91 | # Since we only allow 90° rotations, we can correct for that with a simple translation 92 | pos_top = c.bounds.top + c.bounds.height / 2. - c.bounds.width / 2. 93 | pos_left = c.bounds.left - c.bounds.height / 2. + c.bounds.width / 2. 94 | 95 | # swap height and width 96 | height, width = c.bounds.width, c.bounds.height 97 | 98 | shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Mm(pos_left), Mm(pos_top), 99 | Mm(width), Mm(height)) 100 | # tikz rotation is counter-clockwise, pptx clockwise (we switch in pptx) 101 | shape.rotation = -c.rotation 102 | else: 103 | shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Mm(c.bounds.left), Mm(c.bounds.top), 104 | Mm(c.bounds.width), Mm(c.bounds.height)) 105 | 106 | shape.shadow.inherit = False 107 | 108 | # Background color 109 | if c.background_color is not None: 110 | shape.fill.solid() 111 | shape.fill.fore_color.rgb = RGBColor(c.background_color[0], c.background_color[1], 112 | c.background_color[2]) 113 | else: 114 | shape.fill.background() 115 | shape.line.fill.background() 116 | 117 | # Text properties 118 | text_frame = shape.text_frame 119 | p = text_frame.paragraphs[0] 120 | p.alignment = { 121 | "center": PP_ALIGN.CENTER, "left": PP_ALIGN.LEFT, "right": PP_ALIGN.RIGHT 122 | }[c.horizontal_alignment] 123 | 124 | text_frame.margin_top = 0 125 | text_frame.margin_bottom = 0 126 | 127 | if c.horizontal_alignment == 'right': 128 | text_frame.margin_right = Mm(c.padding.width_mm) 129 | text_frame.margin_left = 0 130 | else: 131 | text_frame.margin_right = 0 132 | text_frame.margin_left = Mm(c.padding.width_mm) 133 | 134 | run = p.add_run() 135 | run.text = c.content.replace("\\\\", "\n") 136 | run.font.color.rgb = RGBColor(c.color[0], c.color[1], c.color[2]) 137 | run.font.size = Pt(c.fontsize) 138 | 139 | if isinstance(c, RectangleComponent): 140 | shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Mm(c.bounds.left), Mm(c.bounds.top), 141 | Mm(c.bounds.width), Mm(c.bounds.height)) 142 | shape.shadow.inherit = False 143 | shape.line.color.rgb = RGBColor(c.color[0], c.color[1], c.color[2]) 144 | shape.line.width = Pt(c.linewidth) 145 | # shape.line.join_type = 'Miter' # Removes rounded edges, but is not supported, yet (sadly) 146 | shape.fill.background() 147 | 148 | if isinstance(c, LineComponent): 149 | shape = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, Mm(c.from_x), Mm(c.from_y), 150 | Mm(c.to_x), Mm(c.to_y)) 151 | shape.shadow.inherit = False 152 | shape.line.color.rgb = RGBColor(c.color[0], c.color[1], c.color[2]) 153 | shape.line.width = Pt(c.linewidth) 154 | 155 | return prs 156 | 157 | def write_to_file(self, data, filename: str): 158 | data.save(filename) -------------------------------------------------------------------------------- /figuregen/theme.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mira-13/figure-gen/888b0f4eb05f7e9c0f701c6ac9306521d5056c22/figuregen/theme.pptx -------------------------------------------------------------------------------- /figuregen/tikz.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | from .backend import * 3 | import importlib.resources as pkg_resources 4 | from concurrent.futures import ThreadPoolExecutor, Future 5 | 6 | class TikzBackend(Backend): 7 | """ Generates the code for a TikZ picture representing the figure. 8 | Default file ending is .tikz, use \\input{figure.tikz} to include in LaTeX. 9 | """ 10 | 11 | def __init__(self, include_header=True): 12 | """ 13 | Args: 14 | include_header: boolean, set to false to not include the macro definitions in the generated file. 15 | """ 16 | self._include_header = include_header 17 | self._thread_pool = ThreadPoolExecutor() 18 | 19 | def add_overlay(self, tikz_code: str): 20 | """ Adds overlay code that will be stitched on top of the generated figure. """ 21 | pass 22 | 23 | @property 24 | def header(self) -> str: 25 | """ The TikZ macros used for the figure components """ 26 | return pkg_resources.read_text(__package__, 'commands.tikz') 27 | 28 | @property 29 | def preamble(self) -> str: 30 | """ The minimum set of \\usepackage's for the figure to display correctly. """ 31 | return '\n'.join(["\\usepackage{calc}", "\\usepackage{tikz}"]) 32 | 33 | def _sanitize_latex_path(self, path): 34 | # Assume that pdflatex will be run from the same folder and strip the directory name from the path 35 | return "\\detokenize{" + os.path.basename(path) + "}" 36 | 37 | def _latex_color(self, rgb): 38 | if rgb is None: 39 | return "" 40 | return "rgb,255:red," + str(rgb[0]) + ";green," + str(rgb[1]) + ";blue," + str(rgb[2]) 41 | 42 | def _make_image(self, c: ImageComponent, dims: str, anchor: str, output_dir: str, elem_id: str) -> str: 43 | # Generate the image data 44 | prefix = "img-" + elem_id 45 | file_prefix = os.path.join(output_dir, prefix) 46 | try: 47 | filename = c.data.make_pdf(c.bounds.width, c.bounds.height, file_prefix) 48 | except NotImplementedError: 49 | filename = c.data.make_raster(c.bounds.width, c.bounds.height, file_prefix) 50 | 51 | # Assemble the position arguments 52 | fname = "{" + self._sanitize_latex_path(filename) + "}" 53 | name = "{" + prefix + "}" 54 | 55 | # Check if there is a frame and emit the correct command 56 | if c.has_frame: 57 | linewidth = "{" + f'{c.frame_linewidth}pt' + "}" 58 | color = "{" + self._latex_color(c.frame_color) + "}" 59 | return "\\makeframedimagenode" + dims + fname + name + anchor + color + linewidth + "\n" 60 | else: 61 | return "\\makeimagenode" + dims + fname + name + anchor + "\n" 62 | 63 | def assemble_grid(self, components: List[Component], output_dir: str) -> List[Union[Future, str]]: 64 | tikz_lines = [] 65 | for c in components: 66 | elem_id = f"fig{c.figure_idx}-grid{c.grid_idx}" 67 | if c.row_idx >= 0: 68 | elem_id += f"-row{c.row_idx}" 69 | if c.col_idx >= 0: 70 | elem_id += f"-col{c.col_idx}" 71 | 72 | # Position arguments are the same for all components 73 | if c.bounds is not None: 74 | dims = "{" + f"{c.bounds.width}" + "mm}" + "{" + f"{c.bounds.height}" + "mm}" 75 | anchor = "{(" + f"{c.bounds.left}mm, {-c.bounds.top}mm" + ")}" 76 | 77 | if isinstance(c, ImageComponent): 78 | tikz_lines.append(self._thread_pool.submit( 79 | self._make_image, c, dims, anchor, output_dir, elem_id)) 80 | 81 | if isinstance(c, TextComponent): 82 | prefix = c.type + "-" + elem_id 83 | name = "{" + prefix + "}" 84 | fontsize = "{" + f'{c.fontsize}pt' + "}" 85 | color = "{" + self._latex_color(c.color) + "}" 86 | content = "{" + c.content + "}" 87 | rotation = "{" + str(c.rotation) + "}" 88 | fill_color = "{" + self._latex_color(c.background_color) + "}" 89 | 90 | node = "\\maketextnode" if c.rotation % 180 < 20 else "\\maketextnodeflipped" 91 | 92 | vert_align = "{c}" 93 | if c.vertical_alignment == "top": 94 | vert_align = "{t}" 95 | elif c.vertical_alignment == "bottom": 96 | vert_align = "{b}" 97 | 98 | horz_align = "{\\centering}" 99 | if c.horizontal_alignment == "left": 100 | horz_align = "{\\raggedright}" 101 | elif c.horizontal_alignment == "right": 102 | horz_align = "{\\raggedleft}" 103 | 104 | pad_vert = "{" + str(c.padding.height_mm) + "mm}" 105 | pad_horz = "{" + str(c.padding.width_mm) + "mm}" 106 | 107 | tikz_lines.append(node + dims + name + anchor + color + fontsize + fill_color + rotation + 108 | vert_align + horz_align + pad_vert + pad_horz + content) 109 | 110 | if isinstance(c, RectangleComponent): 111 | color = "{" + self._latex_color(c.color) + "}" 112 | linewidth = "{" + str(c.linewidth) + "pt}" 113 | linestyle = "{dashed}" if c.dashed else "{solid}" 114 | tikz_lines.append("\\makerectangle" + dims + anchor + color + linewidth + linestyle) 115 | 116 | if isinstance(c, LineComponent): 117 | parent_name = "{" + "img-" + elem_id + "}" 118 | color = "{" + self._latex_color(c.color) + "}" 119 | linewidth = "{" + str(c.linewidth) + "pt}" 120 | start = "{" + f"({c.from_x}mm, {-c.from_y}mm)"+ "}" 121 | end = "{" + f"({c.to_x}mm, {-c.to_y}mm)"+ "}" 122 | tikz_lines.append("\\makeclippedline" + parent_name + start + end + linewidth + color) 123 | 124 | return tikz_lines 125 | 126 | def combine_grids(self, data, idx: int, bounds: Bounds) -> List[Union[Future, str]]: 127 | # Create an empty "background" node to make sure that outer paddings are not cropped away 128 | figure_id = "{figure-" + str(idx) + "}" 129 | dims = "{" + f"{bounds.width}" + "mm}" + "{" + f"{bounds.height}" + "mm}" 130 | anchor = "{(" + f"{bounds.left}mm, {-bounds.top}mm" + ")}" 131 | tikz_code = "\\makebackgroundnode" + dims + anchor + figure_id + "\n" 132 | 133 | # Flatten the list of list into a single list with one element per component 134 | result = [tikz_code] 135 | for grid in data: 136 | for d in grid: 137 | result.append(d) 138 | return result 139 | 140 | def combine_rows(self, data: List[List[Union[Future, str]]], bounds: Bounds) -> str: 141 | # Synchronize all export futures and combine the lines 142 | tikz_code = "" 143 | for fig in data: 144 | for c in fig: 145 | if isinstance(c, Future): 146 | tikz_code += c.result() + "\n" 147 | else: 148 | tikz_code += c + "\n" 149 | return tikz_code 150 | 151 | def write_to_file(self, data: str, filename: str): 152 | with open(filename, "w") as f: 153 | if self._include_header: 154 | f.write(self.header) 155 | f.write("\n") 156 | f.write("\\begin{tikzpicture}\n") 157 | f.write(data) 158 | f.write("\\end{tikzpicture}") -------------------------------------------------------------------------------- /figuregen/util/__init__.py: -------------------------------------------------------------------------------- 1 | # __all__ = ['image'] 2 | 3 | from . import image -------------------------------------------------------------------------------- /figuregen/util/image.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from simpleimageio import lin_to_srgb, luminance, exposure, average_color_channels, zoom 4 | 5 | def crop(img, left, top, width, height): 6 | assert top >= 0 and left >= 0, "crop is outside the image" 7 | assert left + width <= img.shape[1], "crop is outside the image" 8 | assert top + height <= img.shape[0], "crop is outside the image" 9 | 10 | assert img.ndim == 2 or img.ndim == 3, "not an image" 11 | 12 | if img.ndim == 3: 13 | return img[top:top+height,left:left+width,:] 14 | elif img.ndim == 2: 15 | return img[top:top+height,left:left+width] 16 | 17 | class Cropbox: 18 | def __init__(self, top, left, height, width, scale=1): 19 | self.top = top 20 | self.left = left 21 | self.bottom = top + height 22 | self.right = left + width 23 | self.height = height 24 | self.width = width 25 | self.scale = scale 26 | 27 | def crop(self, image): 28 | c = crop(image, self.left, self.top, self.width, self.height) 29 | return zoom(c, self.scale) 30 | 31 | @property 32 | def marker_pos(self): 33 | return self.get_marker_pos() 34 | 35 | def get_marker_pos(self): 36 | return [self.left, self.top] 37 | 38 | @property 39 | def marker_size(self): 40 | return self.get_marker_size() 41 | 42 | def get_marker_size(self): 43 | return [self.right - self.left, self.bottom - self.top] 44 | 45 | class SplitImage: 46 | def __init__(self, list_img, vertical=True, degree=15, weights=None): 47 | ''' 48 | This class allows to split several images and make one image out of them. 49 | The weights define how much space each image will take within that new image, 50 | and the degree along with vertical (boolean) decides how these images are split. 51 | Returns one raw image data. 52 | 53 | args: 54 | list_img (list of image arrays): provide preferably two or three images. 55 | You can provide more (but it might look ugly). 56 | degree (integer): A value between -45° and 45°. 57 | vertical (boolean): Either uses a vertical or horizontal splitting. 58 | weights (list of floats): Matches the weights to each image in list_img. 59 | ''' 60 | self.degree = degree 61 | if self.degree > 45 or self.degree < -45: 62 | print( 63 | 'Warning: SplitImage should get a degree between -45° and 45°, else the weights might not work as intended. ' 64 | f'Try setting "vertical = {not vertical}" instead.' 65 | ) 66 | self.is_vertical = vertical 67 | self.num_img = len(list_img) 68 | assert self.num_img > 1, "at least two images are required" 69 | 70 | self.weights = self._normalize_weights(weights) 71 | self.img_width = list_img[0].shape[1] 72 | self.img_height = list_img[0].shape[0] 73 | self.split_image = np.tile([x / 255 for x in [0,0,0]], (self.img_height, self.img_width, 1)) 74 | # self.degree_rad = degree * np.pi / 180.0 75 | self.tan_degree_rad = np.tan(degree * np.pi / 180.0) 76 | 77 | self.start_pos = [] 78 | self.end_pos = [] 79 | self._make_split_image(list_img) 80 | 81 | def _calculate_default_weights(self): 82 | scale = 1.0 + int(self.degree / 10) * 0.25 83 | weights = np.zeros(self.num_img) 84 | weights.fill(scale) 85 | weights[0] = 1.0 86 | weights[-1] = 1.0 87 | return weights 88 | 89 | def _normalize_weights(self, weights): 90 | if weights is None: 91 | weights = self._calculate_default_weights() 92 | 93 | assert len(weights) == self.num_img, "need one weight per image" 94 | 95 | # normalize weight scaling 96 | weights /= np.sum(weights) 97 | return weights 98 | 99 | def _make_split_image(self, list_img): 100 | if not self.is_vertical: 101 | cur_pos = 0 102 | for i in range(len(list_img)): 103 | for col in range(self.img_width): 104 | rel_w = float(self.img_width) * 0.5 - col 105 | offset = self.tan_degree_rad * rel_w 106 | 107 | if i == 0: 108 | start = 0 109 | else: 110 | start = int(cur_pos + offset) 111 | 112 | if i == len(list_img) - 1: 113 | end = self.img_height 114 | else: 115 | end = int(cur_pos + self.weights[i] * self.img_height + offset) 116 | 117 | start = max(0, start) 118 | end = min(self.img_height, end) 119 | 120 | # save start and end position to draw a line between images 121 | self.split_image[start:end, col] = list_img[i][start:end, col] 122 | if col == 0 and start != 0.: 123 | self.start_pos.append((start, col)) 124 | if col == self.img_width-1 and end != self.img_height: 125 | self.end_pos.append((end, col)) 126 | 127 | cur_pos += self.weights[i] * self.img_height 128 | 129 | else: 130 | cur_pos = 0 131 | for i in range(len(list_img)): 132 | for row in range(self.img_height): 133 | rel_h = float(self.img_height) * 0.5 - row 134 | offset = self.tan_degree_rad * rel_h 135 | 136 | if i == 0: 137 | start = 0 138 | else: 139 | start = int(cur_pos + offset) 140 | 141 | if i == len(list_img) - 1: 142 | end = self.img_width 143 | else: 144 | end = int(cur_pos + self.weights[i] * self.img_width + offset) 145 | 146 | start = max(0, start) 147 | end = min(self.img_width, end) 148 | 149 | self.split_image[row,start:end] = list_img[i][row,start:end] 150 | 151 | 152 | 153 | cur_pos += self.weights[i] * self.img_width 154 | 155 | def get_image(self): 156 | return self.split_image 157 | 158 | def get_start_positions(self): 159 | cur_pos = 0 160 | positions = [] 161 | if not self.is_vertical: 162 | for i in range(1, self.num_img): 163 | cur_pos += self.weights[i-1] * self.img_height 164 | offset = self.tan_degree_rad * (float(self.img_width) * 0.5) 165 | r = int(cur_pos + offset) 166 | c = 0 167 | positions.append((r, c)) 168 | return positions 169 | for i in range(1, self.num_img): 170 | cur_pos += self.weights[i-1] * self.img_width 171 | offset = self.tan_degree_rad * (float(self.img_height) * 0.5) 172 | c = int(cur_pos + offset) 173 | r = 0 174 | positions.append((r, c)) 175 | return positions 176 | 177 | def get_end_positions(self): 178 | cur_pos = 0 179 | positions = [] 180 | if not self.is_vertical: 181 | for i in range(1,self.num_img): 182 | cur_pos += self.weights[i-1] * self.img_height 183 | offset = self.tan_degree_rad * (float(self.img_width) * 0.5) 184 | r = int(cur_pos - offset) 185 | c = self.img_width 186 | positions.append((r, c)) 187 | return positions 188 | for i in range(1,self.num_img): 189 | cur_pos += self.weights[i-1] * self.img_width 190 | offset = self.tan_degree_rad * (float(self.img_height) * 0.5) 191 | c = int(cur_pos - offset) 192 | r = self.img_height 193 | positions.append((r, c)) 194 | return positions 195 | 196 | def get_weights(self): 197 | return self.weights 198 | 199 | from simpleimageio import mse, relative_mse, relative_mse_outlier_rejection 200 | 201 | def squared_error(img, ref): 202 | return (img - ref)**2 203 | 204 | def relative_squared_error(img, ref, epsilon=0.0001): 205 | return (img - ref)**2 / (ref**2 + epsilon) 206 | 207 | def sape(img, ref): 208 | ''' Computes the symmetric absolute precentage error 209 | ''' 210 | err = np.absolute(ref - img) 211 | normalizer = np.absolute(img) + np.absolute(ref) 212 | mask = normalizer != 0 213 | err[mask] /= normalizer[mask] 214 | return err 215 | 216 | def smape(img, ref): 217 | ''' Computes the symmetric mean absolute percentage error 218 | ''' 219 | return np.average(sape(img,ref)) -------------------------------------------------------------------------------- /figuregen/util/jupyter.py: -------------------------------------------------------------------------------- 1 | #PDF imports 2 | from pdf2image import convert_from_path 3 | import IPython 4 | from IPython.display import Image 5 | from IPython.display import display 6 | import simpleimageio 7 | import numpy as np 8 | 9 | # HTML imports 10 | from IPython.core.display import HTML 11 | import re 12 | 13 | def loadpdf(pdfname, dpi=1000): 14 | images = convert_from_path(pdfname, dpi=dpi) 15 | return np.array(images[0]) 16 | 17 | def convert(pdfname, dpi=1000): 18 | img = loadpdf(pdfname, dpi) 19 | simpleimageio.write(pdfname.replace('.pdf', '.png'), simpleimageio.srgb_to_lin(img / 255)) 20 | return pdfname.replace('.pdf', '.png') 21 | 22 | def displaypdf(pdfname): 23 | filename = convert(pdfname) 24 | IPython.display.display(Image(filename)) 25 | 26 | def loadhtml(html_file): 27 | with open(html_file) as f: 28 | html = f.read() 29 | figure = (re.findall(r"(.*)", html, re.DOTALL)[0]) 30 | 31 | table = { 32 | "module": "position: absolute; ", 33 | "title-container": "position: absolute; margin-top: 0; margin-bottom: 0;display: flex; align-items: center; justify-content: center; ", 34 | "title-content": "margin-top: 0; margin-bottom: 0;", 35 | "element": "position: absolute; margin: 0; " 36 | } 37 | 38 | for c, s in table.items(): 39 | figure = figure.replace(f'class="{c}" style="', f'class="{c}" style="{s}') 40 | figure = figure.replace(f'class="{c}"', '') 41 | 42 | return figure 43 | 44 | def displayhtml(html_file): 45 | display(HTML(data=loadhtml(html_file))) -------------------------------------------------------------------------------- /figuregen/util/templates.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, List 2 | import numpy as np 3 | 4 | from . import image 5 | from .. import figuregen as fig 6 | 7 | class CropComparison: 8 | """ Matrix of cropped and zoomed images next to a reference image. 9 | 10 | Derived classes can change some behaviour, like the choice of error metric, by overriding the 11 | corresponding methods. 12 | 13 | Additional content can be added (or removed) by the user simply by accessing the individual 14 | grids in the generated list of grids. 15 | """ 16 | def __init__(self, reference_image, method_images, crops: List[image.Cropbox], 17 | scene_name = None, method_names = None, use_latex = False): 18 | """ Shows a reference image next to a grid of crops from different methods. 19 | 20 | Args: 21 | reference_image: a reference image (or any other image to put full-size in the lefthand grid) 22 | method_images: list of images, each corresponds to a new column in the crop grid 23 | crops: list of crops to take from each method, each creates a new row and a marker on the reference 24 | scene_name: [optional] string, name of the scene to put underneath the reference image 25 | method_names: [optional] list of string, names for the reference and each method, to put above the crops 26 | use_latex: set to true to pretty-print captions with LaTeX commands (requires TikZ backend) 27 | 28 | Returns: 29 | A list of two grids: 30 | The first is a single image (reference), the second a series of crops, one or more for each method. 31 | """ 32 | self._reference_image = reference_image 33 | self._method_images = method_images 34 | self.use_latex = use_latex 35 | 36 | self._errors = [ 37 | self.compute_error(reference_image, m) 38 | for m in method_images 39 | ] 40 | self._crop_errors = [ 41 | [ 42 | self.compute_error(crop.crop(reference_image), crop.crop(m)) 43 | for m in method_images 44 | ] 45 | for crop in crops 46 | ] 47 | 48 | # Create the grid for the reference image 49 | self._ref_grid = fig.Grid(1, 1) 50 | self._ref_grid[0, 0].image = self.tonemap(reference_image) 51 | for crop in crops: 52 | self._ref_grid[0, 0].set_marker(crop.marker_pos, crop.marker_size, color=[255,255,255]) 53 | 54 | if scene_name is not None: 55 | self._ref_grid.set_col_titles("bottom", [scene_name]) 56 | 57 | # Create the grid with the crops 58 | self._crop_grid = fig.Grid(num_cols=len(method_images) + 1, num_rows=len(crops)) 59 | for row in range(len(crops)): 60 | self._crop_grid[row, 0].image = self.tonemap(crops[row].crop(reference_image)) 61 | for col in range(len(method_images)): 62 | self._crop_grid[row, col + 1].image = self.tonemap(crops[row].crop(method_images[col])) 63 | 64 | # Put error values underneath the columns 65 | error_strings = [ f"{self.error_metric_name}" ] 66 | error_strings.extend([ self.error_string(i, self.errors) for i in range(len(self.errors)) ]) 67 | self._crop_grid.set_col_titles("bottom", error_strings) 68 | 69 | crop_layout = self._crop_grid.layout 70 | crop_layout.row_space = 1 71 | crop_layout.column_space = 1 72 | crop_layout.column_titles[fig.BOTTOM] = fig.TextFieldLayout(fontsize=8, size=2.8, offset=0.5) 73 | 74 | # If given, show method names on top 75 | if method_names is not None: 76 | self._crop_grid.set_col_titles("top", method_names) 77 | crop_layout.column_titles[fig.TOP] = fig.TextFieldLayout(fontsize=8, size=2.8, offset=0.25) 78 | 79 | self._ref_grid.copy_layout(self._crop_grid) 80 | self._ref_grid.layout.padding[fig.RIGHT] = 1 81 | 82 | def tonemap(self, img): 83 | return fig.JPEG(image.lin_to_srgb(img), quality=80) 84 | 85 | @property 86 | def error_metric_name(self) -> str: 87 | return "relMSE" 88 | 89 | def compute_error(self, reference_image, method_image) -> Tuple[str, List[float]]: 90 | return image.relative_mse(method_image, reference_image) 91 | 92 | def error_string(self, index: int, errors: List[float]): 93 | """ Generates the human-readable error string for the i-th element in a list of error values. 94 | 95 | Args: 96 | index: index in the list of errors 97 | errors: list of error values, one per method, in order 98 | """ 99 | if self.use_latex and index == np.argmin(errors): 100 | return f"$\\mathbf{{{errors[index]:.2f} ({errors[index]/errors[0]:.2f}\\times)}}$" 101 | elif self.use_latex: 102 | return f"${errors[index]:.2f} ({errors[index]/errors[0]:.2f}\\times)$" 103 | else: 104 | return f"{errors[index]:.2f} ({errors[index]/errors[0]:.2f}x)" 105 | 106 | @property 107 | def crop_errors(self) -> List[List[float]]: 108 | """ Error values within the cropped region of each method. 109 | First dimension is the crop, second the method. 110 | """ 111 | return self._crop_errors 112 | 113 | @property 114 | def errors(self) -> List[float]: 115 | return self._errors 116 | 117 | @property 118 | def figure_row(self) -> List[fig.Grid]: 119 | return [ self._ref_grid, self._crop_grid ] 120 | 121 | class FullSizeWithCrops: 122 | """ Side-by-side comparison of full-size images. Below each image is a row of crops. 123 | 124 | Derived classes can change some behaviour, like the choice of error metric, by overriding the 125 | corresponding methods. 126 | 127 | Additional content can be added (or removed) by the user simply by accessing the individual 128 | grids in the generated list of grids. 129 | """ 130 | def __init__(self, reference_image, method_images, crops: List[image.Cropbox], 131 | crops_below = True, method_names = None, use_latex = False): 132 | """ Shows a reference image next to a grid of crops from different methods. 133 | 134 | Args: 135 | reference_image: a reference image (or any other image to put full-size in the lefthand grid) 136 | method_images: list of images, each corresponds to a new column in the crop grid 137 | crops: list of crops to take from each method, each creates a new row and a marker on the reference 138 | crops_below: [optional] if False, the crops will be a column to the right of each image 139 | method_names: [optional] list of string, names for the reference and each method, to put above the crops 140 | use_latex: set to true to pretty-print captions with LaTeX commands (requires TikZ backend) 141 | 142 | Returns: 143 | A list of two grids: 144 | The first is a single image (reference), the second a series of crops, one or more for each method. 145 | """ 146 | self._reference_image = reference_image 147 | self._method_images = method_images 148 | self.use_latex = use_latex 149 | self._crops_below = crops_below 150 | 151 | self._errors = [ 152 | self.compute_error(reference_image, m) 153 | for m in method_images 154 | ] 155 | self._crop_errors = [ 156 | [ 157 | self.compute_error(crop.crop(reference_image), crop.crop(m)) 158 | for m in method_images 159 | ] 160 | for crop in crops 161 | ] 162 | 163 | # Put in one list to make our life easier in the following 164 | images = [reference_image] 165 | images.extend(method_images) 166 | 167 | # Create the grid for the reference image 168 | self._ref_grid = [ fig.Grid(1, 1) for _ in range(len(images)) ] 169 | for i in range(len(images)): 170 | self._ref_grid[i][0, 0].image = self.tonemap(images[i]) 171 | for crop in crops: 172 | self._ref_grid[i][0, 0].set_marker(crop.marker_pos, crop.marker_size, color=[255,255,255]) 173 | 174 | # Create the grid with the crops 175 | if self._crops_below: 176 | self._crop_grid = [ 177 | fig.Grid(num_cols=len(crops), num_rows=1) 178 | for _ in range(len(images)) 179 | ] 180 | for i in range(len(images)): 181 | for col in range(len(crops)): 182 | self._crop_grid[i][0, col].image = self.tonemap(crops[col].crop(images[i])) 183 | else: 184 | self._crop_grid = [ 185 | fig.Grid(num_cols=1, num_rows=len(crops)) 186 | for _ in range(len(images)) 187 | ] 188 | for i in range(len(images)): 189 | for row in range(len(crops)): 190 | self._crop_grid[i][row, 0].image = self.tonemap(crops[row].crop(images[i])) 191 | 192 | # Add padding to the right of all but the last image 193 | for i in range(len(images) - 1): 194 | self._ref_grid[i].layout.padding[fig.RIGHT] = 1 195 | self._crop_grid[i].layout.padding[fig.RIGHT] = 1 196 | if self._crops_below: 197 | self._ref_grid[i].layout.padding[fig.BOTTOM] = 1 198 | 199 | if self._crops_below: 200 | self._ref_grid[-1].layout.padding[fig.BOTTOM] = 1 201 | else: 202 | self._ref_grid[-1].layout.padding[fig.RIGHT] = 1 203 | 204 | # Put error values underneath the columns 205 | if self._crops_below: 206 | for i in range(len(images)): 207 | if i > 0: 208 | err = self.error_string(i - 1, self.errors) 209 | else: 210 | err = self.error_metric_name 211 | self._crop_grid[i].set_title("bottom", method_names[i] + "\\\\" + err) 212 | self._crop_grid[i].layout.titles[fig.BOTTOM] = fig.TextFieldLayout(size=6, offset=1, fontsize=8) 213 | else: 214 | pass # TODO 215 | 216 | # TODO this requires titles spanning multiple grids (the image and its crops)! 217 | # error_strings = [ f"{self.error_metric_name}" ] 218 | # error_strings.extend([ self.error_string(i, self.errors) for i in range(len(self.errors)) ]) 219 | # self._crop_grid.set_col_titles("bottom", error_strings) 220 | # self._crop_grid.layout.set_padding(column=1, row=1) 221 | # self._crop_grid.layout.set_col_titles("bottom", fontsize=8, field_size_mm=2.8, offset_mm=0.5) 222 | 223 | # If given, show method names on top 224 | # TODO combine with error values, and always show both or neither 225 | # if method_names is not None: 226 | # self._crop_grid.set_col_titles("top", method_names) 227 | # self._crop_grid.layout.set_col_titles("top", fontsize=8, field_size_mm=2.8, offset_mm=0.25) 228 | 229 | # self._ref_grid.copy_layout(self._crop_grid) 230 | # self._ref_grid.layout.set_padding(right=1) 231 | # TODO set appropriate paddings for alignment etc 232 | 233 | def tonemap(self, img): 234 | return fig.JPEG(image.lin_to_srgb(img), quality=80) 235 | 236 | @property 237 | def error_metric_name(self) -> str: 238 | return "relMSE" 239 | 240 | def compute_error(self, reference_image, method_image) -> Tuple[str, List[float]]: 241 | return image.relative_mse(method_image, reference_image) 242 | 243 | def error_string(self, index: int, errors: List[float]): 244 | """ Generates the human-readable error string for the i-th element in a list of error values. 245 | 246 | Args: 247 | index: index in the list of errors 248 | errors: list of error values, one per method, in order 249 | """ 250 | if self.use_latex and index == np.argmin(errors): 251 | return f"$\\mathbf{{{errors[index]:.2f} ({errors[index]/errors[0]:.2f}\\times)}}$" 252 | elif self.use_latex: 253 | return f"${errors[index]:.2f} ({errors[index]/errors[0]:.2f}\\times)$" 254 | else: 255 | return f"{errors[index]:.2f} ({errors[index]/errors[0]:.2f}x)" 256 | 257 | @property 258 | def crop_errors(self) -> List[List[float]]: 259 | """ Error values within the cropped region of each method. 260 | First dimension is the crop, second the method. 261 | """ 262 | return self._crop_errors 263 | 264 | @property 265 | def errors(self) -> List[float]: 266 | return self._errors 267 | 268 | @property 269 | def figure(self) -> List[List[fig.Grid]]: 270 | if self._crops_below: 271 | return [ self._ref_grid, self._crop_grid ] 272 | else: 273 | grids = [] 274 | for i in range(len(self._method_images) + 1): 275 | grids.append(self._ref_grid[i]) 276 | grids.append(self._crop_grid[i]) 277 | return [ grids ] -------------------------------------------------------------------------------- /figuregen/util/tex.py: -------------------------------------------------------------------------------- 1 | 2 | def outline(text: str, outline_clr=[10,10,10], text_clr=[250,250,250]) -> str: 3 | """ Creates a colored outline around the given text. 4 | 5 | Requires the "contour" package in the preamble, preferably with the [outline] option. That is: 6 | \usepackage[outline]{contour} 7 | 8 | Currently, this package is not included by default - might change in the future. 9 | 10 | Args: 11 | text: The text to outline 12 | outline_clr: Color of the outline as an sRGB triple 13 | text_clr: Color of the text as an sRGB triple 14 | 15 | Returns: 16 | LaTeX code to render the outlined text with the specified colors. 17 | """ 18 | if outline_clr is None: 19 | res = "\\definecolor{FillClr}{RGB}{" + f"{text_clr[0]},{text_clr[1]},{text_clr[2]}" + "}" 20 | return res + "\\textcolor{FillClr}{" + text + "}" 21 | 22 | res = "\\DeclareDocumentCommand{\\Outlined}{ O{black} O{white} O{0.55pt} m }{"\ 23 | "\\contourlength{#3}"\ 24 | "\\contour{#2}{\\textcolor{#1}{#4}}"\ 25 | "}" 26 | res += "\\definecolor{FillClr}{RGB}{" + f"{text_clr[0]},{text_clr[1]},{text_clr[2]}" + "}" 27 | res += "\\definecolor{StrokeClr}{RGB}{" + f"{outline_clr[0]},{outline_clr[1]},{outline_clr[2]}" + "}" 28 | 29 | res += "\\Outlined[FillClr][StrokeClr][0.55pt]{"+ text + "}" 30 | return res -------------------------------------------------------------------------------- /figuregen/util/units.py: -------------------------------------------------------------------------------- 1 | def mm_to_inches(mm): 2 | return mm * 0.03937007874 -------------------------------------------------------------------------------- /grid-layout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mira-13/figure-gen/888b0f4eb05f7e9c0f701c6ac9306521d5056c22/grid-layout.png -------------------------------------------------------------------------------- /multi-module.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mira-13/figure-gen/888b0f4eb05f7e9c0f701c6ac9306521d5056c22/multi-module.png -------------------------------------------------------------------------------- /setup.ps1: -------------------------------------------------------------------------------- 1 | python -m pip install build 2 | rm -Recurse -Force .\build 3 | rm -Recurse -Force .\dist 4 | python -m build 5 | python -m pip install --upgrade .\dist\figuregen-1.2.0.tar.gz -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name='figuregen', 8 | version='1.2.0', 9 | description='Figure Generator', 10 | long_description=long_description, 11 | long_description_content_type="text/markdown", 12 | url='https://github.com/Mira-13/figure-gen', 13 | author='Mira Niemann', 14 | author_email='mira.niemann@gmail.com', 15 | packages=setuptools.find_packages(), 16 | classifiers=[ 17 | "Programming Language :: Python :: 3", 18 | "License :: OSI Approved :: MIT License", 19 | "Operating System :: OS Independent", 20 | ], 21 | python_requires='>=3.11', 22 | install_requires=[ 23 | 'matplotlib>=3.2.1', 24 | 'python-pptx', 25 | 'simpleimageio', 26 | 'texsnip>=1.1.0' 27 | ], 28 | zip_safe=False, 29 | include_package_data=True 30 | ) 31 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | python -m pip install build 2 | rm -rf ./build 3 | rm -rf ./dist 4 | python -m build 5 | python -m pip install --upgrade ./dist/figuregen-1.2.0-py3-none-any.whl -------------------------------------------------------------------------------- /tests/test_alignment.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import numpy as np 3 | 4 | import figuregen 5 | import figuregen.calculate as calc 6 | from figuregen.tikz import TikzBackend 7 | 8 | colors = [ 9 | [232, 181, 88], #yellow 10 | [5, 142, 78], #green 11 | [94, 163, 188], #light-blue 12 | [181, 63, 106], #pink 13 | [82, 110, 186], #blue 14 | [186, 98, 82] #orange-crab 15 | ] 16 | 17 | class TestAlignment(unittest.TestCase): 18 | def test_single_image_correct_size(self): 19 | grid = figuregen.Grid(1, 1) 20 | 21 | img_blue = np.tile([x / 255 for x in colors[2]], (32, 64, 1)) 22 | grid[0, 0].image = figuregen.PNG(img_blue) 23 | 24 | backend = TikzBackend() 25 | sz = backend.compute_aligned_sizes([grid], 13) 26 | 27 | self.assertAlmostEqual(sz[0][0].width_mm, 13) 28 | self.assertAlmostEqual(sz[0][0].height_mm, 13 / 2) 29 | 30 | self.assertAlmostEqual(sz[0][1].width_mm, 13) 31 | self.assertAlmostEqual(sz[0][1].height_mm, 13 / 2) 32 | 33 | def test_image_with_title_correct_pos(self): 34 | grid = figuregen.Grid(1, 1) 35 | 36 | img_blue = np.tile([x / 255 for x in colors[2]], (32, 64, 1)) 37 | grid[0, 0].image = figuregen.PNG(img_blue) 38 | 39 | grid.set_col_titles("top", ["hi there"]) 40 | grid.layout.set_col_titles("top", 5, 0.5) 41 | 42 | backend = TikzBackend() 43 | sz = backend.compute_aligned_sizes([grid], 13) 44 | 45 | # grid size should include title 46 | self.assertAlmostEqual(sz[0][0].width_mm, 13) 47 | self.assertAlmostEqual(sz[0][0].height_mm, 13 / 2 + 5.5) 48 | 49 | # image size should be same as without title 50 | self.assertAlmostEqual(sz[0][1].width_mm, 13) 51 | self.assertAlmostEqual(sz[0][1].height_mm, 13 / 2) 52 | 53 | imgpos = calc.image_pos(grid, sz[0][1], 0, 0) 54 | self.assertAlmostEqual(imgpos[0], 5.5) 55 | self.assertAlmostEqual(imgpos[1], 0) 56 | 57 | if __name__ == "__main__": 58 | unittest.main() --------------------------------------------------------------------------------