Skip to content

Save output html as image file #21

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
@@ -19,21 +19,40 @@ jobs:

steps:
- uses: actions/checkout@v2

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}

- name: Install wkhtmltopdf
run: |
if [ "$RUNNER_OS" == "Linux" ]; then
# apt install important_linux_software
sudo apt-get install xvfb libfontconfig wkhtmltopdf
elif [ "$RUNNER_OS" == "Windows" ]; then
choco install wkhtmltopdf
elif [ "$RUNNER_OS" == "macOS" ]; then
brew cask install wkhtmltopdf
else
echo "$RUNNER_OS not supported"
exit 1
fi
shell: bash

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 pytest
pip install -r requirements.txt
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
pytest
61 changes: 50 additions & 11 deletions ipyplot/_html_helpers.py
Original file line number Diff line number Diff line change
@@ -3,20 +3,53 @@
required for displaying images, grid/tab layout and general styling.
"""

import os
from typing import Sequence

import imgkit
import numpy as np
import shortuuid
from numpy import str_

from ._img_helpers import _img_to_base64
from ._utils import (
_find_and_replace_html_for_imgkit,
_to_imgkit_path)

try:
from IPython.display import display, HTML
except Exception: # pragma: no cover
raise Exception('IPython not detected. Plotting without IPython is not possible') # NOQA E501


def _html_to_image(html, out_img):
if os.path.dirname(out_img) != '':
os.makedirs(os.path.dirname(out_img), exist_ok=True)

html = _find_and_replace_html_for_imgkit(html)
options = {
# 'xvfb': '',
'enable-local-file-access': '',
}
saving = True
while saving:
print("Saving output as image under: ", out_img)

try:
# path_wkthmltoimage = r'C:/Program Files/wkhtmltopdf/bin/wkhtmltoimage.exe'
# config = imgkit.config(wkhtmltoimage=path_wkthmltoimage)
imgkit.from_string(
html, out_img,
# config=config,
options=options)
except Exception as e:
if "You need to install xvfb" in str(e):
options['xvfb'] = ''
continue
raise
saving = False


def _create_tabs(
images: Sequence[object],
labels: Sequence[str or int],
@@ -25,7 +58,7 @@ def _create_tabs(
img_width: int = 150,
zoom_scale: float = 2.5,
force_b64: bool = False,
tabs_order: Sequence[str or int] = None):
tabs_order: Sequence[str or int] = None) -> str:
"""
Generates HTML code required to display images in interactive tabs grouped by labels.
For tabs ordering and filtering check out `tabs_order` param.
@@ -63,6 +96,11 @@ def _create_tabs(
By default, tabs will be sorted alphabetically based on provided labels.
This param can be also used as a filtering mechanism - only labels provided in `tabs_order` param will be displayed as tabs.
Defaults to None.
Returns
-------
str
HTML code for class tabs viewer control.
""" # NOQA E501

tab_layout_id = shortuuid.uuid()
@@ -144,7 +182,7 @@ def _create_tabs(


def _create_html_viewer(
html: str):
html: str) -> str:
"""Creates HTML code for HTML previewer.
Parameters
@@ -235,7 +273,7 @@ def _create_img(
width: int,
grid_style_uuid: str,
custom_text: str = None,
force_b64: bool = False):
force_b64: bool = False) -> str:
"""Helper function to generate HTML code for displaying images along with corresponding texts.
Parameters
@@ -272,11 +310,14 @@ def _create_img(
use_b64 = True
# if image is a string (URL) display its URL
if type(image) is str or type(image) is str_:
img_html += '<h4 style="font-size: 9px; padding-left: 10px; padding-right: 10px; width: 95%%; word-wrap: break-word; white-space: normal;">%s</h4>' % (image) # NOQA E501
matches = ['http:', 'https:', 'ftp:', 'www.']
if not any(image.lower().startswith(x) for x in matches):
image = os.path.relpath(image)
img_html += '<h4 style="font-size: 9px; margin-left: 5px; margin-right: 5px; word-wrap: break-word; white-space: normal;">%s</h4>\n' % (image) # NOQA E501
if not force_b64:
use_b64 = False
img_html += '<img src="%s"/>' % image
elif "http" in image:
elif any(image.lower().startswith(x) for x in matches):
print("WARNING: Current implementation doesn't allow to use 'force_b64=True' with images as remote URLs. Ignoring 'force_b64' flag") # NOQA E501
use_b64 = False

@@ -309,7 +350,7 @@ def _create_imgs_grid(
max_images: int = 30,
img_width: int = 150,
zoom_scale: float = 2.5,
force_b64: bool = False):
force_b64: bool = False) -> str:
"""
Creates HTML code for displaying images provided in `images` param in grid-like layout.
Check optional params for max number of images to plot, labels and custom texts to add to each image, image width and other options.
@@ -355,7 +396,7 @@ def _create_imgs_grid(
# create code with style definitions
html, grid_style_uuid = _get_default_style(img_width, zoom_scale)

html += '<div id="ipyplot-imgs-container-div-%s">' % grid_style_uuid
html += '<div class="ipyplot-imgs-container-div-%s">' % grid_style_uuid
html += ''.join([
_create_img(
x, width=img_width, label=y,
@@ -370,7 +411,7 @@ def _create_imgs_grid(
return html


def _get_default_style(img_width: int, zoom_scale: float):
def _get_default_style(img_width: int, zoom_scale: float) -> str:
"""Creates HTML code with default style definitions required for elements to be properly displayed
Parameters
@@ -389,13 +430,11 @@ def _get_default_style(img_width: int, zoom_scale: float):
style_uuid = shortuuid.uuid()
html = """
<style>
#ipyplot-imgs-container-div-%(0)s {
div.ipyplot-imgs-container-div-%(0)s {
width: 100%%;
height: 100%%;
margin: 0%%;
overflow: auto;
position: relative;
overflow-y: scroll;
}
div.ipyplot-placeholder-div-%(0)s {
6 changes: 4 additions & 2 deletions ipyplot/_img_helpers.py
Original file line number Diff line number Diff line change
@@ -33,7 +33,9 @@ def _rescale_to_width(
return rescaled_img


def _scale_wh_by_target_width(w: int, h: int, target_width: int):
def _scale_wh_by_target_width(
w: int, h: int,
target_width: int) -> (int, int):
"""Helper functions for scaling width and height based on target width.
Parameters
@@ -56,7 +58,7 @@ def _scale_wh_by_target_width(w: int, h: int, target_width: int):

def _img_to_base64(
image: str or str_ or np.ndarray or PIL.Image,
target_width: int = None):
target_width: int = None) -> str:
"""Converts image to base64 string.
Use `target_width` param to rescale the image to specific width - keeps original size by default.
14 changes: 10 additions & 4 deletions ipyplot/_plotting.py
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@
from typing import Sequence

from ._html_helpers import (
_display_html, _create_tabs, _create_imgs_grid)
_display_html, _create_tabs, _create_imgs_grid, _html_to_image)
from ._utils import _get_class_representations, _seq2arr


@@ -88,7 +88,8 @@ def plot_images(
max_images: int = 30,
img_width: int = 150,
zoom_scale: float = 2.5,
force_b64: bool = False):
force_b64: bool = False,
output_img_path: str = None):
"""
Simply displays images provided in `images` param in grid-like layout.
Check optional params for max number of images to plot, labels and custom texts to add to each image, image width and other options.
@@ -143,6 +144,9 @@ def plot_images(
zoom_scale=zoom_scale,
force_b64=force_b64)

if output_img_path is not None:
_html_to_image(html, output_img_path)

_display_html(html)


@@ -153,7 +157,8 @@ def plot_class_representations(
zoom_scale: float = 2.5,
force_b64: bool = False,
ignore_labels: Sequence[str or int] = None,
labels_order: Sequence[str or int] = None):
labels_order: Sequence[str or int] = None,
output_img_path: str = None):
"""
Displays single image (first occurence for each class) for each label/class in grid-like layout.
Check optional params for labels filtering, ignoring and ordering, image width and other options.
@@ -208,4 +213,5 @@ def plot_class_representations(
max_images=len(images),
img_width=img_width,
zoom_scale=zoom_scale,
force_b64=force_b64)
force_b64=force_b64,
output_img_path=output_img_path)
25 changes: 22 additions & 3 deletions ipyplot/_utils.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,37 @@
"""
Misc utils for IPyPlot package.
"""

import os
import re
from typing import Sequence

import numpy as np
from PIL import Image


def _to_imgkit_path(img_path: str) -> str:
# matches = ['http:', 'https:', 'ftp:', 'www.']
# if not any(x in img_path for x in matches):
# if '.' in img_path:
return "file:///" + os.path.abspath(img_path)
# return img_path


def _find_and_replace_html_for_imgkit(html: str) -> str:
# pattern = r"<img src=\"[/\.].*\""
pattern = r"<img src=\"(?!http:|https:|data:|www.|ftp:|ftps:).*\""
return re.sub(
pattern,
lambda x: '<img src="%s"' % _to_imgkit_path(
x.group().split('="')[1].replace('"', '')),
html)


def _get_class_representations(
images: Sequence[object],
labels: Sequence[str or int],
ignore_labels: Sequence[str or int] = None,
labels_order: Sequence[str or int] = None):
labels_order: Sequence[str or int] = None) -> (np.ndarray, np.ndarray):
"""Returns a list of images (and labels) representing first occurance of each label/class type.
Check optional params for labels ignoring and ordering.
For labels filtering refer to `labels_order` param.
@@ -77,7 +96,7 @@ def _get_class_representations(
return out_images, out_labels


def _seq2arr(seq: Sequence[str or int or object]):
def _seq2arr(seq: Sequence[str or int or object]) -> np.ndarray:
"""Convert sequence to numpy.ndarray.
Parameters
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -5,4 +5,5 @@ bump2version
pytest
pytest-cov
shortuuid
pandas
pandas
imgkit
Binary file added tests/data/100205.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/data/21HnHt+LMDL._AC_US436_QL65_.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/data/41edw+BCUjL._AC_US436_QL65_.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/data/out_img.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/data/out_img_b64.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
56 changes: 52 additions & 4 deletions tests/test_plotting.py

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Missing imgkit Dependency

While trying to run the tests, I encountered the following error:
ModuleNotFoundError: No module named 'imgkit'

I've seen it in the requirements.txt (or the relevant dependency file). However, it appears that imgkit is missing as a required dependency (setup.py) to ensure smooth installation and avoid runtime issues when running the tests or using the package.

Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import os
import sys
from typing import Sequence
import tempfile

import numpy as np
import pandas as pd
import pytest
from IPython.display import HTML
from PIL import Image


sys.path.append(".")
sys.path.append("../.")
import ipyplot
@@ -22,12 +24,16 @@
]

BASE_LOCAL_URLS = [
"docs/example1-tabs.jpg",
"docs/example2-images.jpg",
"docs/example3-classes.jpg",
"tests/data/21HnHt+LMDL._AC_US436_QL65_.jpg",
"tests/data/41edw+BCUjL._AC_US436_QL65_.jpg",
"tests/data/100205.jpeg",
]

TEST_OUTPUT_IMG = "tests/data/out_img.jpg"
TEST_OUTPUT_IMG_B64 = "tests/data/out_img_b64.jpg"

LOCAL_URLS_AS_PIL = list([Image.open(url) for url in BASE_LOCAL_URLS])
LOCAL_URLS_AS_NP = list([np.asarray(img) for img in LOCAL_URLS_AS_PIL])

LABELS = [
None,
@@ -38,6 +44,7 @@

TEST_DATA = [
# (imgs, labels, custom_texts)
(BASE_LOCAL_URLS, LABELS[1], LABELS[0]),
(LOCAL_URLS_AS_PIL, LABELS[1], LABELS[0]),
(BASE_NP_IMGS, LABELS[1], LABELS[0]),
(pd.Series(BASE_NP_IMGS), LABELS[1], LABELS[0]),
@@ -49,6 +56,19 @@
(LOCAL_URLS_AS_PIL, LABELS[1], LABELS[1]),
(LOCAL_URLS_AS_PIL, LABELS[2], LABELS[2]),
(LOCAL_URLS_AS_PIL, LABELS[3], LABELS[3]),
([os.path.abspath(x) for x in BASE_LOCAL_URLS], LABELS[1], LABELS[0]),
]

TEST_SAVE_OUTPUT_DATA = [
# (imgs, output_img_path, b64, out)
(BASE_LOCAL_URLS, "out.jpg", False, TEST_OUTPUT_IMG),
(BASE_LOCAL_URLS, "out.jpeg", False, TEST_OUTPUT_IMG),
(BASE_LOCAL_URLS, "out/out.jpg", False, TEST_OUTPUT_IMG),
(BASE_LOCAL_URLS, "out.jpg", True, TEST_OUTPUT_IMG_B64),
(LOCAL_URLS_AS_PIL, "out.jpg", True, TEST_OUTPUT_IMG_B64),
(LOCAL_URLS_AS_NP, "out.jpg", True, TEST_OUTPUT_IMG_B64),
(BASE_INTERNET_URLS, "out.jpg", True, TEST_OUTPUT_IMG_B64),
([os.path.abspath(x) for x in BASE_LOCAL_URLS], "out.jpg", False, TEST_OUTPUT_IMG), # NOQA E501
]


@@ -109,3 +129,31 @@ def test_plot_class_representations(
assert("Ignoring 'force_b64' flag" in captured.out)

assert(str(HTML).split("'")[1] in captured.out)


@pytest.mark.parametrize(
"imgs, output_img_path, b64, exp_output",
TEST_SAVE_OUTPUT_DATA)
def test_saving_output(
imgs, output_img_path, b64, exp_output):
with tempfile.TemporaryDirectory() as temp_dir:
test_out_path = os.path.join(temp_dir, output_img_path)
ipyplot.plot_images(
imgs,
force_b64=b64,
output_img_path=test_out_path)

assert os.path.exists(test_out_path)
assert os.stat(test_out_path).st_size > 10000
# assert filecmp.cmp(exp_output, test_out_path, shallow=False)

with tempfile.TemporaryDirectory() as temp_dir:
test_out_path = os.path.join(temp_dir, output_img_path)
ipyplot.plot_class_representations(
imgs,
labels=[0, 1, 2],
force_b64=b64,
output_img_path=test_out_path)
assert os.path.exists(test_out_path)
assert os.stat(test_out_path).st_size > 10000
# assert filecmp.cmp(exp_output, test_out_path, shallow=False)