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);

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:

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.
display()or Visual Studio Code or web browserdisplayfunction 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.