diff options
| author | Tim Keller <tjkeller.xyz> | 2025-05-25 21:38:37 -0500 |
|---|---|---|
| committer | Tim Keller <tjkeller.xyz> | 2025-05-25 21:38:37 -0500 |
| commit | e1a6fc09afc088dcb67263ed5923f5be41c32c31 (patch) | |
| tree | a3f80830180881ed4b55687b0870f83bdc006b2b | |
| parent | 31f1940ec8c4aba6a0c21b20eff9657d5d11cf80 (diff) | |
| download | immich-frame-e1a6fc09afc088dcb67263ed5923f5be41c32c31.tar.xz immich-frame-e1a6fc09afc088dcb67263ed5923f5be41c32c31.zip | |
use lazy caching texture list to limit number of images loaded at one time
| -rw-r--r-- | immich.py | 22 | ||||
| -rw-r--r-- | lazycachelist.py | 126 | ||||
| -rw-r--r-- | pix.py | 7 | ||||
| m--------- | static | 10 | ||||
| -rw-r--r-- | texture.py | 26 | ||||
| -rw-r--r-- | window.py | 17 |
6 files changed, 182 insertions, 26 deletions
@@ -1,5 +1,6 @@ import requests from io import BytesIO +from queue import Queue from texture import ImageTexture @@ -8,6 +9,7 @@ class ImmichConnector: def __init__(self, server_url, api_key): self.server_url = server_url.removesuffix("/") self.api_key = api_key + self.texture_load_queue = Queue() def _request(self, endpoint): return requests.get(f"{self.server_url}/api/{endpoint}", headers={ "x-api-key": self.api_key }) @@ -34,12 +36,14 @@ class ImmichConnector: mimetype = response.headers.get("Content-Type") return image_data, mimetype - def load_texture(self, pd, key, exif=None, size="preview"): - image_data, _ = self.load_image(key, size) - it = ImageTexture(image_data, exif) - pd.textures.append(it) - print(f"Loaded image {key}") - - def idle(self, pd): - for asset in self.load_album_assets("bac029a5-972b-4519-bce0-a0d74add3969"): - self.load_texture(pd, asset["id"], asset["exifInfo"]) + def load_texture_async(self, texture_list, image_texture): + self.texture_load_queue.put((texture_list, image_texture)) + + def idle(self): + size = "preview" # TODO + while True: + texture_list, image_texture = self.texture_load_queue.get() + if not texture_list.index_in_cache_range(image_texture.asset_index): + continue # Texture was never loaded so it doesn't need to be free'd + image_data, _ = self.load_image(image_texture.asset_key, size) + image_texture.initialize(image_data) diff --git a/lazycachelist.py b/lazycachelist.py new file mode 100644 index 0000000..0c37ba3 --- /dev/null +++ b/lazycachelist.py @@ -0,0 +1,126 @@ +from dataclasses import dataclass + +from texture import ImageTextureImmichAsset + +@dataclass +class Album: + id: str + range_start: int + range_end: int + assets_list: list[str] = None + + @property + def assets_count(self): + return end - start + + +class LazyCachingTextureList(): + def __init__(self, immich_connector, album_ids, max_cache_items=100): + self.immich_connector = immich_connector + assert max_cache_items >= 20, "Minimum cache items is 20" # Double small radius + + # Ring buffer + self.cache_length = max_cache_items + self.cache = [None] * max_cache_items + self.radius_small = 10 + self.radius_large = int(max_cache_items / 2) + self.cache_items_behind = 0 + self.cache_items_ahead = 0 + self.cache_index = 0 + self.asset_index = 0 + self.asset_count = 0 + + self.cached_items = 0 + + self.album_keys = album_ids + self.albums = self._get_albums() + + def index_in_cache_range(self, index): + index_range_low = (self.asset_index - self.cache_items_behind) % self.asset_count + index_range_high = (self.asset_index + self.cache_items_ahead ) % self.asset_count + if index_range_low > index_range_high: + return index_range_low <= index or index <= index_range_high + return index_range_low <= index <= index_range_high + + def _get_albums(self): + albums = [] + self.asset_count = i = 0 + albums_info = self.immich_connector.load_all_albums() + for album_info in albums_info: + id = album_info["id"] + if id not in self.album_keys: + continue + asset_count = album_info["assetCount"] + albums.append(Album(id, i, i + asset_count)) + i += asset_count + 1 + self.asset_count += asset_count + #self.asset_count = sum(( album.assets_count for album in self.albums )) + return albums + + def _get_album_asset_by_index(self, asset_index): + if asset_index < 0: + asset_index = asset_count - asset_index + for album in self.albums: + if album.range_start <= asset_index <= album.range_end: + if album.assets_list is None: + album.assets_list = self.immich_connector.load_album_assets(album.id) + return album.assets_list[asset_index - album.range_start] + raise IndexError("Index out of bounds") + + def _fill_cache(self, current_radius, final_radius, step): + if current_radius >= final_radius: + return current_radius + + for i in range(current_radius * step, final_radius * step, step): + cache_index = (self.cache_index + i) % self.cache_length + asset_index = (self.asset_index + i) % self.asset_count + if self.cache[cache_index]: + self.cache[cache_index].free() # Since this is a ring buffer, textures outside of range can just get free'd here + asset = self._get_album_asset_by_index(asset_index) + tex = ImageTextureImmichAsset(asset, asset_index) + self.immich_connector.load_texture_async(self, tex) + self.cache[cache_index] = tex + self.cached_items += 1 + + return max(current_radius, final_radius) + + def _update_cache_get_item(self, asset_index): + prev_asset_index = self.asset_index + self.asset_index = asset_index % self.asset_count + # Movement is the distance between the previous and current index in the assets list + # Since the list wraps around, fastest method is just to get the abs min of the 3 cases + movement = min( + asset_index - prev_asset_index, # No list wrap + asset_index - self.asset_count, # Wrap backwards (0 -> -1) + self.asset_count - prev_asset_index, # Wrap forwards (-1 -> 0) + key=abs) + self.cache_index = (self.cache_index + movement) % self.cache_length + + ahead = max(0, self.cache_items_ahead - movement) + behind = max(0, self.cache_items_behind + movement) + + if ahead + behind > self.cache_length: + if movement < 0: + ahead += movement + else: + behind -= movement + + #print("AHEAD/BEHIND/CACHE_I/ASSET_I/MOVEMENT:", ahead, behind, self.cache_index, self.asset_index, movement) + # TODO if ahead is 0 then clear queue + + ahead = self._fill_cache(ahead, self.radius_small, +1) # Fill small radius ahead of cache_index + behind = self._fill_cache(behind, self.radius_small, -1) # Fill small radius behind cache_index + ahead = self._fill_cache(ahead, self.radius_large, +1) # Fill large radius ahead of cache_index + + self.cache_items_ahead = ahead + self.cache_items_behind = behind + return self.cache[self.cache_index] + + def __len__(self): + return self.asset_count + + def __getitem__(self, index): + i = index % self.asset_count + if abs(index) > i: + raise IndexError("Index out of bounds") + return self._update_cache_get_item(index) @@ -3,6 +3,7 @@ import signal from threading import Thread from OpenGL.GLUT import glutLeaveMainLoop +from lazycachelist import LazyCachingTextureList from window import PixDisplay from immich import ImmichConnector from flaskapi import app @@ -19,10 +20,12 @@ def handle_sigint(sig, frame): if __name__ == "__main__": - pd = PixDisplay() immich_connector = ImmichConnector("http://192.168.1.13", "m5nqOoBc4uhAba21gZdCP3z8D3JT4GPxDXL2psd52EA") + album_keys = [ "38617851-6b57-44f1-b5f7-82577606afc4" ] + lazy_texture_list = LazyCachingTextureList(immich_connector, album_keys, 30) + pd = PixDisplay(lazy_texture_list) - t1 = Thread(target=immich_connector.idle, daemon=True, args=(pd,)) + t1 = Thread(target=immich_connector.idle, daemon=True) t1.start() app.config["pix_display"] = pd diff --git a/static b/static -Subproject f86d11c3ce1f04ee89da235d78447aed6d6d713 +Subproject 51be71d8943f6e5e0c4b28358f227860c73d53a @@ -3,6 +3,7 @@ from OpenGL.GLUT import * from OpenGL.GLU import * from PIL import Image, ExifTags + class ImageTexture: def __init__(self, image_source, exif=None): self.id = None @@ -13,6 +14,7 @@ class ImageTexture: self.width = img.width self.height = img.height self._img_data = img.tobytes("raw", "RGBA", 0, -1) # Convert image data to bytes + self.initialized = True def gl_init(self): if self.id: @@ -26,7 +28,14 @@ class ImageTexture: glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) del self._img_data # No longer needed, clear mem - print(f"Loaded texture {self.id}") + #print(f"Loaded texture {self.id}") + + def free(self): + if self.id is None: + del self._img_data + else: + glBindTexture(GL_TEXTURE_2D, 0) # Unbind + glDeleteTextures([self.id]) @staticmethod def get_exif(img): @@ -41,3 +50,18 @@ class ImageTexture: if orientation == 8: return img.rotate( 90, expand=True) return img + + +class ImageTextureImmichAsset(ImageTexture): + def __init__(self, asset, asset_index): + self.initialized = False + self.asset_index = asset_index + self.asset_key = asset["id"] + self.exif = asset.get("exif", None) + + # So that free can work + self.id = None + self._img_data = None + + def initialize(self, image_source): + super().__init__(image_source, self.exif) @@ -5,16 +5,15 @@ from time import time from queue import Queue from renderer import ImageRenderer, TransitionMix -from texture import ImageTexture class PixDisplay: - def __init__(self): + def __init__(self, textures): self.last_time = 0 self.start_time = 0 self.image_time = 0 self.paused = False - self.textures = [] + self.textures = textures self.current_texture_index = 0 self.renderer = None self.win_w = 0 @@ -42,16 +41,16 @@ class PixDisplay: self.frame_time = int(1000 / 60) # In ms def increment_texture_index(self, increment): - if len(self.textures) < 2: - return - self.transition_reverse = increment < 0 self.tex_prev = self.textures[self.current_texture_index] self.current_texture_index = (self.current_texture_index + increment) % len(self.textures) self.tex = self.textures[self.current_texture_index] - # Ensure textures are initialized + if not self.tex.initialized or not self.tex_prev.initialized: + return + + # Ensure textures are initialized for opengl self.tex_prev.gl_init() self.tex.gl_init() @@ -63,10 +62,10 @@ class PixDisplay: delta_time = current_time - self.last_time self.last_time = current_time - if not self.tex: + if not self.tex or not self.tex.initialized or not self.tex.id: self.increment_texture_index(0) # Draw black window if no textures are available - if not self.tex: + if not self.tex.id: glClearColor(0.0, 0.0, 0.0, 1.0) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) glutSwapBuffers() |
