Basj
Basj

Reputation: 46303

UI element for an infinite scrolling image gallery for 100k images: which data structure?

I'm making an (infinite) scrollable GUI that displays 100x100px thumbnails of possibly 100 000 image files (JPG), in Python Tkinter.

The MCVE code below works:

enter image description here

using lazy loading: it reloads 30 new images when the scrollbar reaches the end, but it will not scale when browsing 100k images because the canvas object will become bigger and bigger and overflow memory.

What UI element to use for this? Is there a tk.Frame or tk.Canvas in which you can add new content at the bottom (e.g. y = 10 000), and destroy old content at the top (e.g. at y=0..5 000), and then downsize the canvas, for example remove the first half of it (to avoid the canvas height become higher and higher) without the GUI flickering?

Or maybe a canvas frame data structure that "cycles" like a circular buffer? This would avoid the canvas height to explode, and always stay 10 000 pixels high. When we reach y=9 000, old images at y=0..1 000 are replaced by new ones, and when scrolling at at y=10 000 in fact we are looping back to y=0. Does this exist?

enter image description here

Or maybe another system with tiles? (but I don't see how)

Notes:

Code:

import os, tkinter as tk, tkinter.ttk as ttk, PIL.Image, PIL.ImageTk
class InfiniteScrollApp(tk.Tk):
    def __init__(self, image_dir, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.geometry("800x600")
        self.image_files = [os.path.join(root, file) for root, dirs, files in os.walk(image_dir) for file in files if file.lower().endswith(('.jpg', '.jpeg'))]
        self.image_objects = []
        self.canvas = tk.Canvas(self, bg="white")
        self.scrollbar = ttk.Scrollbar(self, orient="vertical", command=self.yview)
        self.canvas.configure(yscrollcommand=self.scrollbar.set)
        self.scrollbar.pack(side="right", fill="y")
        self.canvas.pack(side="left", fill="both", expand=True)
        self.frame = tk.Frame(self.canvas, width=self.canvas.winfo_width())
        self.canvas.create_window((0, 0), window=self.frame, anchor="nw")
        self.canvas.bind_all("<MouseWheel>", self.on_scroll)
        self.load_images()
    def yview(self, *args):
        self.canvas.yview(*args)
        self.update()
    def update(self):
        canvas_height = self.canvas.bbox("all")[3]
        scroll_pos = self.canvas.yview()[1]
        if scroll_pos > 0.9:  
            self.load_images()
    def load_images(self):
        current_image_count = len(self.image_objects)
        for i in range(current_image_count, current_image_count + 30):  
            if i < len(self.image_files):
                image_path = self.image_files[i]
                img = PIL.Image.open(image_path)
                img.thumbnail((100, 100))  
                photo = PIL.ImageTk.PhotoImage(img)
                label = tk.Label(self.frame, image=photo)
                label.image = photo  
                label.pack(pady=5, padx=10)
                self.image_objects.append(label)
        self.frame.update_idletasks()
        self.canvas.config(scrollregion=self.canvas.bbox("all"))
    def on_scroll(self, event):
        self.canvas.yview_scroll(-1 * (event.delta // 120), "units")
        self.update()
app = InfiniteScrollApp("D:\images")
app.mainloop()

Sample images

Sample code to generate 10k images quickly with a text number drawn on them:

enter image description here

enter image description here

from PIL import Image, ImageDraw, ImageFont
import random
for i in range(10000):
    img = Image.new('RGB', (200, 200), (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)))
    draw = ImageDraw.Draw(img)
    font = ImageFont.truetype("arial.ttf", 30)
    draw.text((10, 10), str(i), font=font, fill=(0, 0, 0))
    img.save(f'color_image_{i:04d}.jpg')

Upvotes: 4

Views: 309

Answers (5)

Hemaoc_BMGO
Hemaoc_BMGO

Reputation: 1

To implement an infinite scrolling image gallery capable of handling 100,000 images efficiently, especially using Python's Tkinter, it's crucial to manage memory and UI performance effectively. Here's a structured approach to achieve this:

  1. Implement Virtual Scrolling with Dynamic Content Loading:

Instead of adding all images to the canvas, simulate the scrolling effect by dynamically loading and unloading images based on the current scroll position.

Canvas Height Management: Set the canvas height to a fixed value that represents the visible portion of the gallery.

Dynamic Image Rendering: Calculate which images should be visible based on the scroll position and render only those.

  1. Use a Circular Buffer for Image Management:

A circular buffer allows for efficient reuse of canvas space by recycling widgets, thereby preventing the canvas from growing indefinitely.

Fixed Number of Widgets: Maintain a fixed number of image widgets that correspond to the maximum number of images visible at any time.

Widget Recycling: As the user scrolls, update the content of these widgets to display new images, simulating an infinite scroll.

  1. Optimize Image Loading with Thumbnails:

Loading full-sized images can be resource-intensive.

Pre-generate Thumbnails: Create and use smaller versions of images for the gallery view.

Lazy Loading: Load images on-demand as they come into the viewport.

  1. Implement a Custom Scrollbar:

With a large number of images, a standard scrollbar may not provide precise control.

Proportional Scrolling: Design a scrollbar that reflects the relative position within the total number of images.

Chunk Navigation: Allow users to jump to specific sections or pages within the gallery.

  1. Example Implementation:

Below is a conceptual example using Tkinter to illustrate the approach:

import tkinter as tk
from PIL import Image, ImageTk

class InfiniteScrollGallery(tk.Tk):
    def __init__(self, image_paths, thumb_size=(100, 100), buffer_size=50):
        super().__init__()
        self.title("Infinite Scroll Gallery")
        self.geometry("800x600")
        self.image_paths = image_paths
        self.thumb_size = thumb_size
        self.buffer_size = buffer_size
        self.total_images = len(image_paths)
        self.visible_images = self.calculate_visible_images()
        self.start_index = 0

        self.canvas = tk.Canvas(self, bg="white")
        self.scrollbar = tk.Scrollbar(self, orient="vertical", command=self.on_scroll)
        self.canvas.config(yscrollcommand=self.scrollbar.set)

        self.scrollbar.pack(side="right", fill="y")
        self.canvas.pack(side="left", fill="both", expand=True)

        self.image_refs = []
        self.load_images()

    def calculate_visible_images(self):
        canvas_height = self.winfo_height()
        images_per_column = canvas_height // self.thumb_size[1]
        return images_per_column * (self.winfo_width() // self.thumb_size[0])

    def load_images(self):
        self.canvas.delete("all")
        self.image_refs.clear()
        x, y = 0, 0
        for i in range(self.visible_images):
            img_index = (self.start_index + i) % self.total_images
            img = Image.open(self.image_paths[img_index])
            img.thumbnail(self.thumb_size)
            photo = ImageTk.PhotoImage(img)
            self.image_refs.append(photo)
            self.canvas.create_image(x, y, anchor="nw", image=photo)
            x += self.thumb_size[0]
            if x >= self.winfo_width():
                x = 0
                y += self.thumb_size[1]

    def on_scroll(self, *args):
        if args[0] == "scroll":
            self.start_index += int(args[1])
        elif args[0] == "moveto":
            self.start_index = int(float(args[1]) * self.total_images)
        self.load_images()
        self.canvas.yview_moveto(args[1])

if __name__ == "__main__":
    # Example usage with a list of image file paths
    image_files = ["path/to/image1.jpg", "path/to/image2.jpg", "..."]
    app = InfiniteScrollGallery(image_files)
    app.mainloop()
  1. Additional Considerations:

Performance Testing: Regularly test the application with a large dataset to ensure smooth scrolling and responsiveness.

Memory Management: Monitor memory usage to prevent leaks, especially when loading and unloading images.

User Experience: Provide visual feedback during image loading to enhance user experience.

By implementing these strategies, you can create an efficient and responsive infinite scrolling image gallery capable of handling a large number of images.

Upvotes: 0

Olivier
Olivier

Reputation: 18250

What UI element to use for this?

Generally speaking, the natural answer to this question is: a custom widget. Many GUI frameworks allow you to intercept the paint event that the OS sends to a window, which allows you to paint it the way you like. For example, PyQt has a QPaintEvent and wxPython has a PaintEvent.

Note that Picasa was probably written in C++/Qt (see here).

To display a large number of images, the callback that processes the paint event could:

  • Read the scrollbar position
  • Determine which images are visible
  • Load them and paint them

For both performance and low memory consumption, the images could be loaded from an LRU cache (for example with lru_cache()).

At the end it'll be possible to implement it in wxPython or tk or another GUI framework

Unfortunately it seems like tk doesn't have a paint event, so this approach is not usable. It means you will need to tinker to emulate a custom widget.

Upvotes: 1

acw1668
acw1668

Reputation: 47085

You can use paging to simulate scrolling 100K images, but actually you need to show at most one page of images during scrolling.

  • create a grid of images inside a canvas widget
  • just determine which page of images to be shown during scrolling
  • then refresh the grid of images

Below is an example with a 5x5 grid of images at a time:

import os
import tkinter as tk
from tkinter import ttk
from PIL import Image, ImageTk

W = H = 100 # thumbnail size in pixels
SP = 5      # space between thumbnails in pixels

class InfiniteScrollApp(tk.Tk):
    def __init__(self, image_dir='.', *args, **kwargs):
        self.image_types = kwargs.pop('image_types', ('.jpg',))
        self.rows = kwargs.pop('rows', 5)
        self.cols = kwargs.pop('columns', 5)
        super().__init__(*args, **kwargs)
        self.resizable(0, 0)
        self.top_row = 0
        self.image_files = []
        self.images = []
        self.create_ui()
        self.load_image_files(image_dir)

    def create_ui(self):
        self.canvas = tk.Canvas(self, bg='white', highlightthickness=0,
                                width=(W+SP)*self.cols, height=(H+SP)*self.rows,
                                yscrollcommand=self.scrollbar_set)
        self.scrollbar = ttk.Scrollbar(self, orient='vertical', command=self.yview)

        self.scrollbar.pack(side='right', fill='y')
        self.canvas.pack(side='left', fill='both', expand=1)

        self.canvas.bind('<MouseWheel>', self.on_mouse_wheel)
        self.update()

    def load_image_files(self, image_dir):
        self.image_files = [os.path.join(root, file) for root, dirs, files in os.walk(image_dir)
                                                     for file in files if file.endswith(self.image_types)]
        self.total_images = len(self.image_files)
        print(f'total {self.total_images} image files found')
        self.update_images()

    def on_mouse_wheel(self, event):
        self.set_top_row(self.top_row - event.delta//120)
        self.update_images()

    def update_images(self):
        # update total_rows
        self.total_rows, x = divmod(len(self.image_files), self.cols)
        if x: self.total_rows += 1

        if self.image_files:
            self.canvas.delete('all')
            self.images.clear()
            idx = self.top_row * self.cols
            for i in range(self.rows*self.cols):
                if idx+i < self.total_images:
                    r, c = divmod(i, self.cols)
                    x, y = c*(W+SP), r*(H+SP)
                    image = Image.open(self.image_files[idx+i])
                    image.thumbnail((W, H))
                    self.images.append(ImageTk.PhotoImage(image))
                    self.canvas.create_image(x+W//2, y+H//2, image=self.images[-1], anchor='c', tag=f'img_{idx+i}')
                    self.canvas.create_rectangle(x, y, x+W, y+H, outline='gray')

            self.scrollbar_set(self.top_row/self.total_rows, (self.top_row+self.rows)/self.total_rows)

    def yview(self, *args):
        #print('yview:', args)
        total = self.total_rows - self.rows
        if args[0] == 'scroll':
            delta = (self.rows if args[-1] == 'pages' else 1) * int(args[1])
            self.set_top_row(self.top_row+delta)
        elif args[0] == 'moveto':
            self.set_top_row(int(float(args[-1]) * total))
        self.update_images()

    def scrollbar_set(self, *args):
        #print('scrollbar_set:', args)
        self.scrollbar.set(*args)

    def set_top_row(self, row):
        max_top_row = self.total_rows - self.rows
        if row < 0: row = 0
        elif row > max_top_row: row = max_top_row
        self.top_row = row

InfiniteScrollApp('./demo_images').mainloop()

Result when started:

enter image description here

Result when scrolled to the end:

enter image description here

Upvotes: 3

amrita yadav
amrita yadav

Reputation: 161

Try this solution:

  • Loading only 20 images at a time, dynamically removing off-screen images.
  • Removes old images that go out of view (better performance).
  • Uses lazy loading when scrolling down to prevent memory overflow.
  • Enabled mouse scrolling for smooth navigation (works on Windows, Linux, and Mac).
  • Tested it will Handle 100K+ images smoothly.

Updates:

  • Fast Image Recycling: I have reused image labels instead of creating/destroying them.

  • Added Circular Scrolling: Scrolling back brings back old images without any jumps.

Code:

import os
import tkinter as tk
from PIL import Image, ImageTk

class CircularImageScroller:
    def __init__(self, root):
        self.root = root
        self.root.title("Circular Infinite Image Scroller")

        # Fixed canvas size to prevent memory explosion
        self.canvas = tk.Canvas(root, height=CANVAS_HEIGHT, width=300)
        self.scrollbar = tk.Scrollbar(root, orient="vertical", command=self.canvas.yview)

        self.scroll_frame = tk.Frame(self.canvas)
        self.window_id = self.canvas.create_window((0, 0), window=self.scroll_frame, anchor="nw")

        self.canvas.configure(yscrollcommand=self.scrollbar.set)
        self.canvas.pack(side="left", fill="both", expand=True)
        self.scrollbar.pack(side="right", fill="y")

        # Load image list
        self.image_files = sorted([os.path.join(IMAGE_FOLDER, f) for f in os.listdir(IMAGE_FOLDER) if f.endswith((".jpg", ".png"))])
        self.image_count = len(self.image_files)

        self.image_slots = []  # Keep references to labels for reuse
        self.start_index = 0  # Track the current image set
        self.virtual_position = 0  # Track scrolling position

        # Create Fixed Number of Labels
        for i in range(NUM_VISIBLE_IMAGES):
            label = tk.Label(self.scroll_frame)
            label.grid(row=i, column=0, pady=5)
            self.image_slots.append(label)

        self.load_images(self.start_index)

        # Bind scroll events
        self.canvas.bind_all("<MouseWheel>", self.on_mouse_scroll)  # Windows
        self.canvas.bind_all("<Button-4>", self.on_mouse_scroll)  # Linux Scroll Up
        self.canvas.bind_all("<Button-5>", self.on_mouse_scroll)  # Linux Scroll Down

    def load_images(self, start):
        """Load images into a fixed number of slots."""
        for i in range(NUM_VISIBLE_IMAGES):
            idx = (start + i) % self.image_count  # Circular index
            img = Image.open(self.image_files[idx])
            img.thumbnail(THUMBNAIL_SIZE)
            img = ImageTk.PhotoImage(img)

            self.image_slots[i].config(image=img)
            self.image_slots[i].image = img  # Keep reference

    def on_mouse_scroll(self, event):
        """Handles mouse scrolling with a circular buffer approach."""
        if event.delta > 0 or event.num == 4:  # Scroll Up
            self.virtual_position -= 1
        else:  # Scroll Down
            self.virtual_position += 1

        # Wrap around when reaching the start or end
        if self.virtual_position < 0:
            self.virtual_position = self.image_count - NUM_VISIBLE_IMAGES
        elif self.virtual_position >= self.image_count:
            self.virtual_position = 0

        self.start_index = self.virtual_position
        self.load_images(self.start_index)

        # Keep scroll position stable
        self.canvas.yview_moveto(0.5)

# Configuration
IMAGE_FOLDER = "./images"
THUMBNAIL_SIZE = (100, 100)
NUM_VISIBLE_IMAGES = 30  # Number of images visible at a time
CANVAS_HEIGHT = 600  # Fixed height to prevent memory overflow

# Run App
root = tk.Tk()
app = CircularImageScroller(root)
root.mainloop()

Output:

tkinter

Upvotes: 0

jonathan
jonathan

Reputation: 54

Instead of trying to load all 100k images into memory, think of it like a moving window that only shows what's currently visible on screen, plus a bit extra above and below as buffer.

As you scroll, load new images at the bottom and remove ones that are way off-screen at the top. The canvas stays at a fixed height, and you just shift the content around to make it feel like continuous scrolling. Boom, no more memory problem!

That also matches your proposal of the scrollbar just showing your progress through the whole collection, like a percentage, depend how fancy you want it to look.

Pseudo-code :

  1. Track visible window range and add buffer zone above/below.
  2. When scrolling, remove images outside buffer.
  3. Near bottom? Load next batch.
  4. Too many images loaded? Shift everything up and tweak scroll position to hide the jump.

Functions you will need :

canvas.delete(item_id) - Remove specific items

canvas.move(item_id, dx, dy) - Shift items by x,y pixels

canvas.coords(item_id) - Get/set item position

canvas.bbox("all") - Get bounding box of all items

canvas.yview_moveto(fraction) - Scroll to position

For viewport tracking:

canvas.winfo_height() - Get viewport height

canvas.yview() - Get current scroll position as (start, end) fractions

Upvotes: 2

Related Questions