Reputation: 46303
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:
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?
Or maybe another system with tiles? (but I don't see how)
Notes:
Many image viewers like Google Picasa (long discontinued freeware) do this:
Note that the right vertical scrollbar doesn't reflect the real progress, because the scrollbar handle would be too small and be imprecise for 100k images - here it's more a joystick-like scrollbar
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 code to generate 10k images quickly with a text number drawn on them:
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
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:
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.
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.
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.
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.
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()
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
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:
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
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.
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:
Result when scrolled to the end:
Upvotes: 3
Reputation: 161
Try this solution:
20 images
at a time, dynamically removing off-screen
images.old images
that go out of view (better performance).lazy loading
when scrolling down to prevent memory overflow
.mouse scrolling
for smooth navigation (works on Windows, Linux, and Mac).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:
Upvotes: 0
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 :
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