Skip to content
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

Implement image/png repr #343

Merged
merged 12 commits into from
Sep 21, 2021
43 changes: 3 additions & 40 deletions examples/ipympl.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@
"source": [
"# Interactions with other widgets and layouting\n",
"\n",
"When you want to embed the figure into a layout of other widgets you should call `plt.ioff()` before creating the figure otherwise code inside of `plt.figure()` will display the canvas automatically and outside of your layout. "
"When you want to embed the figure into a layout of other widgets you should call `plt.ioff()` before creating the figure otherwise `plt.figure()` will trigger a display of the canvas automatically and outside of your layout. "
]
},
{
Expand All @@ -225,7 +225,6 @@
"# this is default but if this notebook is executed out of order it may have been turned off\n",
"plt.ion()\n",
"\n",
"\n",
"fig = plt.figure()\n",
"ax = fig.gca()\n",
"ax.imshow(Z)\n",
Expand Down Expand Up @@ -268,35 +267,6 @@
")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Fixing the double display with `ipywidgets.Output`\n",
"\n",
"Using `plt.ioff` use matplotlib to avoid the double display of the plot. You can also use `ipywidgets.Output` to capture the plot display to prevent this"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"out = widgets.Output()\n",
"with out:\n",
" fig = plt.figure()\n",
"\n",
"ax = fig.gca()\n",
"ax.imshow(Z)\n",
"\n",
"widgets.AppLayout(\n",
" center=out,\n",
" footer=widgets.Button(icon='check'),\n",
" pane_heights=[0, 6, 1]\n",
")"
]
},
{
"cell_type": "markdown",
"metadata": {},
Expand Down Expand Up @@ -446,18 +416,11 @@
"display(widgets.VBox([slider, fig.canvas]))\n",
"display(out)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
Expand All @@ -471,7 +434,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.7.8"
"version": "3.9.7"
},
"widgets": {
"application/vnd.jupyter.widget-state+json": {
Expand Down
141 changes: 117 additions & 24 deletions ipympl/backend_nbagg.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@
import io

from IPython.display import display, HTML
from IPython import get_ipython
from IPython import version_info as ipython_version_info

from ipywidgets import DOMWidget, widget_serialization
from traitlets import (
Unicode, Bool, CInt, Float, List, Instance, CaselessStrEnum, Enum,
Unicode, Bool, CInt, List, Instance, CaselessStrEnum, Enum,
default
)

import matplotlib
from matplotlib import rcParams
from matplotlib import is_interactive
from matplotlib import rcParams, is_interactive
from matplotlib.backends.backend_webagg_core import (FigureManagerWebAgg,
FigureCanvasWebAggCore,
NavigationToolbar2WebAgg,
Expand All @@ -40,7 +41,6 @@ def connection_info():
use.

"""
from matplotlib._pylab_helpers import Gcf
result = []
for manager in Gcf.get_all_fig_managers():
fig = manager.canvas.figure
Expand Down Expand Up @@ -83,16 +83,8 @@ def __init__(self, canvas, *args, **kwargs):
def export(self):
buf = io.BytesIO()
self.canvas.figure.savefig(buf, format='png', dpi='figure')
# Figure width in pixels
pwidth = (self.canvas.figure.get_figwidth() *
self.canvas.figure.get_dpi())
# Scale size to match widget on HiDPI monitors.
if hasattr(self.canvas, 'device_pixel_ratio'): # Matplotlib 3.5+
width = pwidth / self.canvas.device_pixel_ratio
else:
width = pwidth / self.canvas._dpi_ratio
data = "<img src='data:image/png;base64,{0}' width={1}/>"
data = data.format(b64encode(buf.getvalue()).decode('utf-8'), width)
Comment on lines -86 to -95
Copy link
Collaborator

Choose a reason for hiding this comment

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

@martinRenou why is it ok to remove this dpi scaling? It seems like we will now have imperfect behavior on hi dpi displays.

Copy link
Member Author

Choose a reason for hiding this comment

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

This might have been a mistake, we should open an issue to track this

Copy link
Member

Choose a reason for hiding this comment

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

I'll do it, actually. If I understand correctly, the PNG doesn't get saved at high res.

Copy link
Member

Choose a reason for hiding this comment

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

Note: we should not determine the dpi based on the browser because it could differ between clients.
It should be a fixed value.

data = "<img src='data:image/png;base64,{0}'/>"
data = data.format(b64encode(buf.getvalue()).decode('utf-8'))
display(HTML(data))

@default('toolitems')
Expand Down Expand Up @@ -160,7 +152,9 @@ class Canvas(DOMWidget, FigureCanvasWebAggCore):
_png_is_old = Bool()
_force_full = Bool()
_current_image_mode = Unicode()
_dpi_ratio = Float(1.0)

# Static as it should be the same for all canvases
current_dpi_ratio = 1.0

def __init__(self, figure, *args, **kwargs):
DOMWidget.__init__(self, *args, **kwargs)
Expand All @@ -172,9 +166,15 @@ def _handle_message(self, object, content, buffers):
# Every content has a "type".
if content['type'] == 'closing':
self._closed = True

elif content['type'] == 'initialized':
_, _, w, h = self.figure.bbox.bounds
self.manager.resize(w, h)

elif content['type'] == 'set_dpi_ratio':
Canvas.current_dpi_ratio = content['dpi_ratio']
self.manager.handle_json(content)

else:
self.manager.handle_json(content)

Expand Down Expand Up @@ -208,6 +208,41 @@ def send_binary(self, data):
def new_timer(self, *args, **kwargs):
return TimerTornado(*args, **kwargs)

def _repr_mimebundle_(self, **kwargs):
# now happens before the actual display call.
if hasattr(self, '_handle_displayed'):
self._handle_displayed(**kwargs)
plaintext = repr(self)
if len(plaintext) > 110:
plaintext = plaintext[:110] + '…'

buf = io.BytesIO()
self.figure.savefig(buf, format='png', dpi='figure')
data_url = b64encode(buf.getvalue()).decode('utf-8')

martinRenou marked this conversation as resolved.
Show resolved Hide resolved
data = {
'text/plain': plaintext,
'image/png': data_url,
'application/vnd.jupyter.widget-view+json': {
'version_major': 2,
'version_minor': 0,
'model_id': self._model_id
}
}

return data

def _ipython_display_(self, **kwargs):
"""Called when `IPython.display.display` is called on a widget.
Note: if we are in IPython 6.1 or later, we return NotImplemented so
that _repr_mimebundle_ is used directly.
"""
if ipython_version_info >= (6, 1):
raise NotImplementedError

data = self._repr_mimebundle_(**kwargs)
display(data, raw=True)

if matplotlib.__version__ < '3.4':
# backport the Python side changes to match the js changes
def _handle_key(self, event):
Expand Down Expand Up @@ -294,14 +329,18 @@ class _Backend_ipympl(_Backend):
FigureCanvas = Canvas
FigureManager = FigureManager

_to_show = []
_draw_called = False

@staticmethod
def new_figure_manager_given_figure(num, figure):
canvas = Canvas(figure)
if 'nbagg.transparent' in rcParams and rcParams['nbagg.transparent']:
figure.patch.set_alpha(0)
manager = FigureManager(canvas, num)

if is_interactive():
manager.show()
_Backend_ipympl._to_show.append(figure)
figure.canvas.draw_idle()

def destroy(event):
Expand All @@ -312,17 +351,17 @@ def destroy(event):
return manager

@staticmethod
def show(block=None):
# TODO: something to do when keyword block==False ?
def show(close=None, block=None):
# # TODO: something to do when keyword block==False ?
interactive = is_interactive()

managers = Gcf.get_all_fig_managers()
if not managers:
manager = Gcf.get_active()
if manager is None:
return

interactive = is_interactive()

for manager in managers:
manager.show()
try:
display(manager.canvas)
# metadata=_fetch_figure_metadata(manager.canvas.figure)

# plt.figure adds an event which makes the figure in focus the
# active one. Disable this behaviour, as it results in
Expand All @@ -333,3 +372,57 @@ def show(block=None):

if not interactive:
Gcf.figs.pop(manager.num, None)
finally:
if manager.canvas.figure in _Backend_ipympl._to_show:
_Backend_ipympl._to_show.remove(manager.canvas.figure)

@staticmethod
def draw_if_interactive():
# If matplotlib was manually set to non-interactive mode, this function
# should be a no-op (otherwise we'll generate duplicate plots, since a
# user who set ioff() manually expects to make separate draw/show
# calls).
if not is_interactive():
return

manager = Gcf.get_active()
if manager is None:
return
fig = manager.canvas.figure

# ensure current figure will be drawn, and each subsequent call
# of draw_if_interactive() moves the active figure to ensure it is
# drawn last
try:
_Backend_ipympl._to_show.remove(fig)
except ValueError:
# ensure it only appears in the draw list once
pass
# Queue up the figure for drawing in next show() call
_Backend_ipympl._to_show.append(fig)
_Backend_ipympl._draw_called = True


def flush_figures():
if rcParams['backend'] == 'module://ipympl.backend_nbagg':
if not _Backend_ipympl._draw_called:
return

try:
# exclude any figures that were closed:
active = set([
fm.canvas.figure for fm in Gcf.get_all_fig_managers()
])

for fig in [
fig for fig in _Backend_ipympl._to_show if fig in active]:
# display(fig.canvas, metadata=_fetch_figure_metadata(fig))
display(fig.canvas)
finally:
# clear flags for next round
_Backend_ipympl._to_show = []
_Backend_ipympl._draw_called = False


ip = get_ipython()
ip.events.register('post_execute', flush_figures)
3 changes: 0 additions & 3 deletions js/src/mpl_widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,6 @@ export class MPLCanvasModel extends widgets.DOMWidgetModel {

this.image.src = image_url;

// Tell Jupyter that the notebook contents must change.
this.send_message('ack');

this.waiting = false;
}

Expand Down