Configure Gtk.Picture to scale using nearest-neighbor?

I have the following in a Python script showing a Gtk4 UI:

img = gen_image(64, 32)  # Generate RGB data
pixbuf = GdkPixbuf.Pixbuf.new_from_data(
    img,
    colorspace=GdkPixbuf.Colorspace.RGB,
    has_alpha=False,
    bits_per_sample=8,
    width=64,
    height=32,
    rowstride=64 * 3,
)
texture = Gdk.Texture.new_for_pixbuf(pixbuf)
self.screen = Gtk.Picture.new_for_paintable(texture)

The widget displays scales to fill the window as desired, but it scales the contained image using bilinear interpolation, which makes the image blurry since the image is so low resolution. I use Gtk.Picture because I want the image to fill available window space when the window is resized, so I do not know is a good resolution to prescale to with the GdkPixbuf. Prescaling is more of a mitigation anyway, not a solution.

How do reconfigure this widget to interpolate using nearest-neighbour?

Use gdk_pixbuf_scale_simple() with interp_type=GDK_INTERP_NEAREST

This not the same.

If I scale it with gdk_pixbuf_scale_simple, and the picture widget displays it larger still, it is still fuzzy because it is still doing bilinear interpolation.

The picture widget itself needs to scale nearest-neighbour to ensure a crisp image.

You can just fix it at certain size. If you need it to dynamically change the size of the widget then you need to use a customized GtkSnapshot flow to get a paintable.

Either create your own widget and override snapshot, or set a signal that would change the paintable every time the size changes. It would be roughly something like:

snapshot = Gtk.Snapshot.new() 
snapshot.append_scaled_texture(texture, Gsk.ScalingFilter.NEAREST, size_rect)
paintable = snapshot.free_to_paintable(size_rect)
self.picture.set_paintable(paintable)

Or this if you create your own widget/inherent and override the snapshot:

snapshot.append_scaled_texture(texture, Gsk.ScalingFilter.NEAREST, size_rect)

Thanks. I learned that Gtk.Picture calls Gdk.Paintable.snapshot to get its data, so I tried to subclass Picture to specialize the behavior:

class NearestPicture(Gtk.Picture):
    def do_snapshot(self, snapshot):
        paintable = self.get_paintable()
        snapshot.append_scaled_texture(paintable, Gsk.ScalingFilter.NEAREST, (100, 100))

I’m confused by this because

  1. The snapshot method takes width and height, but if I add these to do_snapshot, Python complains that the method is the wrong signature: do_snapshot() missing 2 required positional arguments: 'width' and 'height'
  2. Without width and height, the overridden method is called, but then how do I know what width and height I need to scale the pixbuf to?
  3. I get the error 'Snapshot' object has no attribute 'append_scaled_texture', which confuses me since the documentation says this method should exist. Interestingly, append_texture does exist, but doesn’t let you specify interpolation.

You cannot subclass GdkTexture: it’s a final class.

At most you can implement the GdkPaintable interface yourself.

Figured it out.

The ability to render an image with a configurable interpolation seems pretty straightforward, but instead I had to subclass the generic widget and implement my own scaling, which is not intuitive to me.

I suppose this is because a paintable is not necessarily pixel data, in which case interpolation does not apply, so the generic interface doesn’t provide for this circumstance and when pixel data is scaled, it just makes an assumption on the interpolation method. It’s weird to me, but is what it is.

My solution:

#!/usr/bin/python3

import sys
import gi

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
gi.require_version("GdkPixbuf", "2.0")
from gi.repository import Gtk, Adw, GdkPixbuf  # noqa: E402
from gi.repository import Gdk, Graphene  # noqa: E402


class BlockyImage(Gtk.Widget):
    def __init__(self, pixbuf, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.pixbuf = pixbuf

    def do_snapshot(self, snapshot):
        w = self.get_width()
        h = self.get_height()
        pixbuf = self.pixbuf.scale_simple(w, h, GdkPixbuf.InterpType.NEAREST)
        texture = Gdk.Texture.new_for_pixbuf(pixbuf)
        snapshot.append_texture(texture, Graphene.Rect().init(0, 0, w, h))


class MainWindow(Gtk.ApplicationWindow):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        img = sum([[x * 8, y * 8, 0] for x in range(32) for y in range(16)], [])
        pixbuf = GdkPixbuf.Pixbuf.new_from_data(
            img,
            colorspace=GdkPixbuf.Colorspace.RGB,
            has_alpha=False,
            bits_per_sample=8,
            width=32,
            height=16,
            rowstride=32 * 3,
        )
        self.screen = BlockyImage(pixbuf)
        self.screen.set_hexpand(True)
        self.screen.set_vexpand(True)
        self.screen.set_valign(Gtk.Align.FILL)
        self.screen.set_halign(Gtk.Align.FILL)
        self.hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        self.hbox.append(self.screen)
        self.set_child(self.hbox)


class MyApp(Adw.Application):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.connect("activate", self.on_activate)

    def on_activate(self, app):
        self.win = MainWindow(application=app)
        self.win.present()


app = MyApp(application_id="test.local.test1")
app.run(sys.argv)