2

I want to display an image from an array in a Jupyter notebook (inline) in Visual Studio Code. I'm running Windows 11 on a high DPI monitor with scaling set to 150%. Pixels don't render sharp in the notebook. 1 pixel in the source image should be 1 pixel on the screen. If I want to scale the image using integer scaling I can just scale the source image using nearest neighbor. I tried matplotlib and pillow. I can't get it sharp. Here is my python code:

from PIL import Image
from IPython.display import display
import numpy as np

bw_data = np.array([
    [0, 1, 0, 1],
    [1, 0, 1, 0],
    [0, 1, 0, 1],
    [1, 0, 1, 0]
], dtype=np.uint8)

# mode '1' = 1-bit black/white
bw_image = Image.fromarray(bw_data * 255).convert(mode='1')

display(bw_image)

output, Zoomed in with Gimp: Zoomed in via Gimp

This is not sharp.

Saving the image does produce a sharp image:

bw_image.save('image.png')

Result, Zoomed in with Gimp:

Zoomed in with Gimp

I use iconico magnifier V2.4 (with scaling disabled on executable) to inspect the rendered images on pixel level. And using image editors such as Gimp or paint to inspect saved images or saved screenshots (since image viewers use upscaling when zooming in instead of nearest neighbor).

I prefer not to write the image to storage first, but keep it in RAM. But this is not a hard requirement. The only requirement is that it is inline and not a separate window as that is easy to get working.

Edit: This is how this answer (which just scales the source image)(renders on my machine: Zoomed in with Gimp

As you can see scaling the source image does not fix the rendering issue. It just slightly masks it because the edges are relatively smaller. The edges are still blurry. Rending on pixel level is what is faulty. How do I display true pixels?

4
  • 1
    did you try to write it in file and open in any image viewer? Maybe it is not problem with image but with command display() or Visual Studio Code or web browser Commented Jun 3 at 13:34
  • Saving the image works just fine. The rendering is the problem. Commented Jun 3 at 14:01
  • Is there a reason why you expect the display function to know how to display a PIL Image object of type "1"? That's a binary bitmap image. Very few display engines would be able to do that. When you saved it to a file, you give it a .PNG extension, which causes PIL to format the file properly. So GIMP (or any other capable piece of software) will recognize that format, and know how to render the data correctly. Just convert the image object to some standard type (lossless, not .jpg) and it will almost certainly display correctly. Commented Jun 8 at 1:27
  • 1
    @PaulCornelius please read the question again. I think you missed it entirely. Commented Jun 8 at 8:42

2 Answers 2

1

Update: improved answer at the bottom.

Initial answer

I have found a workaround to display the image sharp.
It requires manually changing a value in the code each time you change scaling in Windows.

Step 1:

%%javascript
const ratio = window.devicePixelRatio;
alert("devicePixelRatio: " + ratio);

notification popup showing a devicePixelRatio  of 1.875

Step 2:

devicePixelRatio = 1.875 # manually enter the value that was shown

Step 3:

from PIL import Image
from IPython.display import HTML
import numpy as np
import io
import base64

# 32x32 data
bw_data = np.zeros((32,32),dtype=np.uint8)
# (odd_rows, even_columns)
bw_data[1::2,::2] = 1
# (even_rows, odd_columns)
bw_data[::2,1::2] = 1

# Build pixel-exact HTML
def display_pixel_image(np_array):
    # Convert binary image to black & white PIL image
    img = Image.fromarray(np_array * 255).convert('1')
    
    # Convert to base64-encoded PNG
    buf = io.BytesIO()
    img.save(buf, format='PNG')
    b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
    
    # HTML + CSS to counteract scaling
    html = f"""
    <style>
      .pixel-art {{
        width: calc({img.width}px / {devicePixelRatio});
        image-rendering: pixelated;
        display: block;
        margin: 0;
        padding: 0;
      }}
    </style>
    <img class="pixel-art" src="data:image/png;base64,{b64}">
    """
    display(HTML(html))

display_pixel_image(bw_data)

output:

sharp image

Visual Studio Code cannot access ipython kernel so I don't know how to retrieve devicePixelRatio from Javascript. I tried to make an ipython widget to retrieve devicePixelRatio, but was not able to refresh it automatically. If this can be done automatically then it won't require user action.

Improved answer (requires anywidget)

import anywidget
from traitlets import Unicode
from IPython.display import display
from IPython.display import HTML
import io
import base64

class InlineImageWidget(anywidget.AnyWidget):
    _esm = """
    export function render({ model, el }) {
      const img = document.createElement("img");
      el.appendChild(img);
 
      function updateImage() {
        const base64 = model.get("base64_png");
        if (base64) {
          const width = model.get("width");
          img.src = `data:image/png;base64,${base64}`;
          img.style.imageRendering = "pixelated"; 
          img.style.width  =  (width / window.devicePixelRatio) + "px";  // correct for browser/OS scaling
          img.style.height  = (height / window.devicePixelRatio) + "px"; // correct for browser/OS scaling
          img.style.display = "block";
          img.style.margin  = "0";
          img.style.padding = "0";
          //img.style.background = "transparent"; // ensure image has no background, doesn't seem to be needed
        }
      }

      model.on("change:base64_png", updateImage);
      updateImage();
    }
    """
    base64_png = Unicode("").tag(sync=True)
    width = Unicode("").tag(sync=True)
    height = Unicode("").tag(sync=True)

def display_sharp(image, scale: int = 1):
    buf = io.BytesIO()
    image.save(buf, format='PNG')
    base64_png = base64.b64encode(buf.getvalue()).decode('utf-8')
    w = InlineImageWidget(base64_png=base64_png, width=str(image.width * scale), height=str(image.width * scale))
    display(w)

    # this removes white background of the cell
    display(HTML("""
    <style>
    .cell-output-ipywidget-background, .jp-OutputArea, .jp-Cell-outputWrapper {
      background-color: transparent !important;
      padding: 0 !important;
    </style>
    """))

use:

import numpy as np
from PIL import Image

n = 4
checkerboard = np.tile(np.array([[0,1],[1,0]],dtype=np.uint8), (n//2, n//2))
img = Image.fromarray(checkerboard * 255).convert('1')
display_sharp(img)

display_sharp() converts image to base 64, calls InlineImageWidget and removes the white background of the output cell. InlineImageWidget renders the base 64 image and corrects for scaling.

Sign up to request clarification or add additional context in comments.

Comments

0

You have to choose a resampling method with nearest-neighbor interpolation. In pillow use something like

w, h         = bw_image.size
scale_factor = 16
img_scaled   = bw_image.resize((w * scale_factor, h * scale_factor), resample=Image.NEAREST)

where scale_factor is your integer scaling factor. Result:

Scaled with nearest neighbor

When using matplotlib you can also only scale for display. Remember to put %matplotlib inline in within the Jupyter notebook, then run

import matplotlib.pyplot as plt
plt.imshow(bw_data, cmap='gray', interpolation='nearest')

7 Comments

It only looks better since the blurry edge is relatively smaller compared to the solid center, it is still blurry.
I added a screenshot of how your code renders on my display to my question.
I assume you have some kind of anti-aliasing enabled at system level, likely caused by the non-integer 150% scaling. This is nothing you can resolve in python, Jupyter, etc.
I'm not necessarily looking for a solution in python or Jupyter. Scaling in windows is handled by applications, not by Windows itself. Some applications handle scaling perfectly fine and are able to render sharp pixels regardless of Windows scaling settings so there is no system level anti-aliasing. So it could be an issue in VScode.
So how do you correctly scale an alternating pixel pattern by 150%? You obviously cannot fill half a pixel.
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.