2025, Nov 30 13:00

How to Get Pixel-Precise, Nearest-Neighbor Inline Images in VS Code Jupyter on High-DPI Windows

Inline images blur in VS Code Jupyter on high-DPI Windows. Fix it with CSS that respects devicePixelRatio, uses image-rendering: pixelated, for 1:1 pixels.

Pixel-precise image display in a Jupyter notebook inside Visual Studio Code can fall apart on high-DPI Windows setups. When the OS scaling is, say, 150%, a 1-pixel checkerboard rendered inline tends to blur, even though saving the same image to a PNG is perfectly crisp. If you expect that 1 source pixel equals 1 physical pixel on the screen and want integer scaling only via nearest neighbor, the default inline rendering path won’t guarantee it.

Reproducing the issue

The example below builds a tiny black-and-white pattern using a NumPy array and displays it inline. The output looks soft in a notebook on a high-DPI monitor, despite saving the image to disk producing razor-sharp pixels.

from PIL import Image
from IPython.display import display
import numpy as np
grid_bits = np.array([
    [0, 1, 0, 1],
    [1, 0, 1, 0],
    [0, 1, 0, 1],
    [1, 0, 1, 0]
], dtype=np.uint8)
mono_img = Image.fromarray(grid_bits * 255).convert(mode='1')
display(mono_img)

Saving the same image to file, then inspecting it in an editor, shows that the content itself is correct; the blur is in the way the notebook frontend renders the pixels inline.

Why the pixels blur inline

The notebook frontend runs in a browser context. On Windows with display scaling, the browser maps CSS pixels to device pixels using window.devicePixelRatio. If you render the image at its nominal CSS width and let the browser scale, you don’t get a guaranteed 1:1 mapping. The browser also smooths when resizing, so sharp black/white edges soften. Merely upscaling the source image with nearest neighbor doesn’t fully solve this, because the frontend still applies its own scaling. To get true pixels inline, the image must be drawn with CSS that compensates for devicePixelRatio and forces nearest-neighbor rendering.

Workaround: manual devicePixelRatio and pixel-exact HTML

The following approach reads devicePixelRatio in a JavaScript cell, then uses that value in Python to size the image via CSS and enforce pixelated rendering. This produces a sharp inline result. You’ll need to update the ratio manually whenever Windows scaling changes.

%%javascript
const dprVal = window.devicePixelRatio;
alert("devicePixelRatio: " + dprVal);
dpr_value = 1.875  # set to the value shown in the alert
from PIL import Image
from IPython.display import HTML, display
import numpy as np
import io
import base64
# 32x32 checkerboard
canvas_bits = np.zeros((32, 32), dtype=np.uint8)
canvas_bits[1::2, ::2] = 1
canvas_bits[::2, 1::2] = 1
def show_pixel_grid(bit_array):
    img_obj = Image.fromarray(bit_array * 255).convert('1')
    buf = io.BytesIO()
    img_obj.save(buf, format='PNG')
    b64_img = base64.b64encode(buf.getvalue()).decode('utf-8')
    html_snippet = f"""
    <style>
      .pix-art {{
        width: calc({img_obj.width}px / {dpr_value});
        image-rendering: pixelated;
        display: block;
        margin: 0;
        padding: 0;
      }}
    </style>
    <img class="pix-art" src="data:image/png;base64,{b64_img}">
    """
    display(HTML(html_snippet))
show_pixel_grid(canvas_bits)

This HTML forces nearest-neighbor with image-rendering: pixelated and divides the CSS width by devicePixelRatio, so one image pixel maps to one physical pixel.

Improved approach: anywidget and automatic scaling correction

If you prefer not to enter devicePixelRatio manually, you can let the frontend compute it and apply the correction at render time. The snippet below uses anywidget to render a base64 PNG and adjust width and height by window.devicePixelRatio on the client. It also removes the white background of the output cell.

import anywidget
from traitlets import Unicode
from IPython.display import display
from IPython.display import HTML
import io
import base64
class PixelInlineWidget(anywidget.AnyWidget):
    _esm = """
    export function render({ model, el }) {
      const imgEl = document.createElement("img");
      el.appendChild(imgEl);
      function refreshImg() {
        const encoded = model.get("png_b64");
        if (encoded) {
          const w = model.get("w_css");
          const h = model.get("h_css");
          imgEl.src = `data:image/png;base64,${encoded}`;
          imgEl.style.imageRendering = "pixelated";
          imgEl.style.width  = (w / window.devicePixelRatio) + "px";  // correct for browser/OS scaling
          imgEl.style.height = (h / window.devicePixelRatio) + "px";  // correct for browser/OS scaling
          imgEl.style.display = "block";
          imgEl.style.margin  = "0";
          imgEl.style.padding = "0";
        }
      }
      model.on("change:png_b64", refreshImg);
      refreshImg();
    }
    """
    png_b64 = Unicode("").tag(sync=True)
    w_css = Unicode("").tag(sync=True)
    h_css = Unicode("").tag(sync=True)
def show_crisp(pil_img, scale: int = 1):
    mem = io.BytesIO()
    pil_img.save(mem, format='PNG')
    encoded_png = base64.b64encode(mem.getvalue()).decode('utf-8')
    widget = PixelInlineWidget(png_b64=encoded_png,
                               w_css=str(pil_img.width * scale),
                               h_css=str(pil_img.width * scale))
    display(widget)
    display(HTML("""
    <style>
    .cell-output-ipywidget-background, .jp-OutputArea, .jp-Cell-outputWrapper {
      background-color: transparent !important;
      padding: 0 !important;
    }
    </style>
    """))

Usage remains straightforward. Build your array, convert to a 1-bit PIL image, and render it inline with pixel accuracy.

import numpy as np
from PIL import Image
s = 4
pattern = np.tile(np.array([[0, 1], [1, 0]], dtype=np.uint8), (s // 2, s // 2))
img_out = Image.fromarray(pattern * 255).convert('1')
show_crisp(img_out)

Why this matters

When you inspect pixel-level data, soft edges mask the ground truth. Whether you are debugging bitmaps, visualizing masks, or checking binary patterns, you need a strict one-pixel-to-one-pixel mapping. The browser’s scaling breaks that guarantee unless you compensate for devicePixelRatio and disable smoothing.

Takeaways

If inline images look blurry in a notebook on a high-DPI Windows desktop, the content is not the issue—rendering is. Encode the image as PNG in memory, render it with CSS image-rendering: pixelated, and size it by dividing its CSS dimensions by the current devicePixelRatio. You can do it manually by reading the ratio in JavaScript and plugging it into Python, or you can let anywidget apply the correction automatically at render time. Both approaches keep the image in RAM and display it inline, so you get the exact pixels you computed.