Source code for zooui.objects.mediaobjects.tiledmediaobject

## ZooUI - Zooming User Interface
## Copyright (C) 2009 David Roberts <d@vidr.cc>
##
## This program is free software; you can redistribute it and/or
## modify it under the terms of the GNU General Public License
## as published by the Free Software Foundation; either version 3
## of the License, or (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program; if not, see <https://www.gnu.org/licenses/>.

"""Tiled media to be displayed in the ZUI."""

import logging
import math
import os
import tempfile
from typing import Any

## Performance optimization note:
## Phase 2 optimizations replace 2**x with math.exp2(x) (1.85x faster)
## and math.log(x, 2) with math.log2(x) (2x faster) throughout the codebase.
## These changes are performance-critical for zoom operations.
from PySide6 import QtCore, QtGui

# Process-based conversion for parallel media conversion
from zooui.converters import converterrunner
from zooui.logger import get_logger
from zooui.objects.mediaobjects.mediaobject import LoadError, MediaObject, RenderMode
from zooui.tilesystem import tilemanager as TileManager

# Subdivide a ppm image into tiles that fit the mediaobject frame
# Process-based tiling for parallel image tiling
from zooui.tilesystem.tiler.tilerrunner import TilingHandle, submit_tiling


[docs] class TiledMediaObject(MediaObject): """ Constructor : TiledMediaObject(media_id, scene, autofit) Parameters : media_id : str scene : Scene autofit : bool TiledMediaObject(media_id, scene, autofit) --> None TileMedia object wraps given media_id in MediaObject type, (calling converter if necessary) so that it can be rendered in the ZUI. If `autofit` is True, then once the media has loaded it will be fitted to the area occupied by the placeholder. Supported formats: - PDF files (.pdf) via PDFConverter - PPM files (.ppm) used directly - Image files (JPG, PNG, GIF, TIFF, etc.) via VipsConverter For any acceptable filetype the adequate converter gets called. The converter returns a ppm image file on which we can run the tiler on. """ def __init__(self, media_id: str, scene: Any, autofit: bool = True, deferred: bool = False) -> None: """ Constructor : TiledMediaObject(media_id, scene, autofit, deferred) Parameters : media_id : str scene : Scene autofit : bool (default=True) deferred : bool (default=False) TiledMediaObject(media_id, scene, autofit, deferred) --> None Initialize a new TiledMediaObject from the media identified by media_id, the parent Scene referenced by scene, and optionally autofit behavior. If autofit is True, once the media has loaded it will be fitted to the area occupied by the placeholder. If deferred is True, conversion and tiling setup is skipped — the caller is responsible for initiating those operations. This is used by PdfMediaObject to manage multi-page PDFs. Sets up conversion and tiling infrastructure based on the media file type. Initializes caching variables for tileblocks and rendering optimization. """ # Initialize the parent MediaObject with media_id and scene reference # This sets up self._media_id, self._scene, and PhysicalObject attributes MediaObject.__init__(self, media_id, scene) # Store whether the media should auto-fit to the placeholder area once loaded # When True, the loaded image will be resized to fill the placeholder bounding box self.__autofit: bool = autofit # Flag indicating whether the tile data has been fully loaded from TileManager # Starts as False; set to True in _try_load() once the (0,0,0) tile is available self._loaded: bool = False # Path to the temporary PPM file created during conversion # Set to None initially; assigned a tempfile path if conversion is needed self.__tmpfile: str | None = None # Reference to the active ConversionHandle (wraps a Future from process pool) # None if no conversion is needed (e.g., already tiled or is a .ppm file) self.__converter: converterrunner.ConversionHandle | None = None # Reference to the TilingHandle that manages process-based tiling # None until __run_tiler() is called after conversion completes self.__tiler: TilingHandle | None = None # Logger instance for this specific TiledMediaObject, named with the media_id # Used for debug/info/error logging throughout the object's lifecycle self.__logger: logging.Logger = get_logger(f"TiledMediaObject.{media_id}") # Maximum tile level (zoom depth) for this media # Initialized to 0; updated from TileManager metadata once tiles are loaded self.__maxtilelevel: int = 0 # Width and height of the media in pixels # Initialized to default_size (256, 256); updated from TileManager metadata once loaded self.__width: int self.__height: int self.__width, self.__height = self.default_size # Aspect ratio of the media (width / height) # None until loaded from TileManager metadata; used for efficient size calculations self.__aspect_ratio: float | None = None ## for caching tileblocks # The cached QImage containing the rendered tileblock # Reused across frames if the visible tile region hasn't changed self.__tileblock: QtGui.QImage | None = None # Tuple identifying the cached tileblock: (tilelevel, row_min, col_min, row_max, col_max) # Used to determine if the cache is still valid for the current view self.__tileblock_id: tuple[int, int, int, int, int] | None = None # Whether the cached tileblock contains all final (fully loaded) tiles # If False, the tileblock may need re-rendering when higher quality tiles become available self.__tileblock_final: bool = False # Number of render cycles since the tileblock was last re-rendered # Compared against self.tempcache to decide when to refresh non-final tileblocks self.__tileblock_age: int = 0 if deferred: return # Check if TileManager already has tiles for this media_id # TileManager.tiled() returns True if the media has already been tiled and stored if TileManager.tiled(self._media_id): # Media is already tiled; request loading of the root tile (level=0, row=0, col=0) # This triggers the TileManager to begin loading tile data from disk TileManager.load_tile((self._media_id, 0, 0, 0)) else: # Media has not been tiled yet; needs conversion and/or tiling self.__logger.info("need to tile media") # Create a temporary file with .ppm extension for the converter output # tempfile.mkstemp() returns (file_descriptor, file_path) fd: int fd, self.__tmpfile = tempfile.mkstemp(".ppm") # Close the file descriptor immediately; the converter will open the file itself os.close(fd) # Determine which converter to use based on file extension if self._media_id.lower().endswith(".pdf"): # PDF files: submit to process pool for PDF-to-PPM conversion # submit_pdf_conversion() returns a Future object future: Any = converterrunner.submit_pdf_conversion(self._media_id, self.__tmpfile) # Wrap the Future in a ConversionHandle for progress/error tracking self.__converter = converterrunner.ConversionHandle(future, self._media_id, self.__tmpfile) # Store path to the PPM file that the converter will produce self.__ppmfile: str = self.__tmpfile elif self._media_id.lower().endswith(".ppm"): ## assume media_id is a local PPM file # No conversion needed; the media_id itself is the PPM file path self.__logger.info("assuming media is a local PPM file") self.__ppmfile: str = self._media_id else: # All other image formats (JPG, PNG, GIF, TIFF, etc.): use Vips conversion # submit_vips_conversion() returns a Future object future: Any = converterrunner.submit_vips_conversion(self._media_id, self.__tmpfile) # Wrap the Future in a ConversionHandle for progress/error tracking self.__converter = converterrunner.ConversionHandle(future, self._media_id, self.__tmpfile) # Store path to the PPM file that the converter will produce self.__ppmfile: str = self.__tmpfile # Class variable: indicates this media object does NOT support transparency # Tiled images are fully opaque, so they can hide objects behind them transparent: bool = False ## initial size of the object before the actual dimensions have been ## loaded # Class variable: default placeholder dimensions (width, height) in pixels # Used until the actual media dimensions are loaded from TileManager metadata default_size: tuple[int, int] = (256, 256) ## maximum number of cycles to cache temporary tiles for # Class variable: number of render cycles before non-final tileblocks are refreshed # After this many cycles, temporary (lower quality) tiles will be re-rendered tempcache: int = 5
[docs] def _reset_for_page(self, new_media_id: str) -> None: """ Method : _reset_for_page(new_media_id) Parameters : new_media_id : str _reset_for_page(new_media_id) --> None Reset internal state and switch to a different media_id for page navigation. Invalidates all cached tileblocks, then starts loading the root tile for the new media_id. Dimension data (width, height, aspect_ratio, maxtilelevel) is kept from the previous page so that the bounding box for autofit is computed correctly during the transition. Used by PdfMediaObject when changing pages. """ self._media_id = new_media_id self._loaded = False self.__tileblock = None self.__tileblock_id = None self.__tileblock_final = False self.__tileblock_age = 0 self.__converter = None self.__tiler = None self.__logger = get_logger(f"TiledMediaObject.{new_media_id}") if TileManager.tiled(self._media_id): TileManager.load_tile((self._media_id, 0, 0, 0))
@property def __progress(self) -> float: """ Property : __progress Parameters : None __progress --> float Calculate and return the current loading progress as a float between 0.0 and 1.0. If only tiler is active, returns tiler progress. If only converter is active, returns half of converter progress. If both are active, returns average of both progresses. """ # If neither converter nor tiler has been created, progress is 0% if self.__converter is None and self.__tiler is None: return 0.0 elif self.__converter is None: # Only tiler is active (e.g., PPM file that didn't need conversion) # Tiler progress represents the full 0.0-1.0 range return self.__tiler.progress elif self.__tiler is None: # Only converter is active (tiler hasn't started yet) # Converter progress is scaled to 0.0-0.5 range (first half of total progress) return 0.5 * self.__converter.progress else: # Both converter and tiler are active # Average both progresses: converter contributes 50%, tiler contributes 50% return 0.5 * (self.__converter.progress + self.__tiler.progress) def __pixpos2rowcol(self, pixpos: tuple[float, float], tilescale: float) -> tuple[int, int]: """ Method : __pixpos2rowcol(pixpos, tilescale) Parameters : pixpos : Tuple[float, float] tilescale : float __pixpos2rowcol(pixpos, tilescale) --> Tuple[int, int] Convert the on-screen pixel position to a (row,col) tile position. """ # Get the top-left corner of this media object on screen # self.topleft returns (x, y) tuple in screen coordinates o: tuple[float, float] = self.topleft # Calculate column index: horizontal offset from object's left edge, divided by scaled tile size # (pixpos[0] - o[0]) gives the pixel distance from the object's left edge # Dividing by (tilescale * tilesize) converts that pixel distance to a tile column index col: int = int((pixpos[0] - o[0]) / (tilescale * self.__tilesize)) # Calculate row index: vertical offset from object's top edge, divided by scaled tile size # (pixpos[1] - o[1]) gives the pixel distance from the object's top edge # Dividing by (tilescale * tilesize) converts that pixel distance to a tile row index row: int = int((pixpos[1] - o[1]) / (tilescale * self.__tilesize)) return (row, col) def __rowcol_bound(self, tilelevel: int) -> tuple[int, int]: """ Method : __rowcol_bound(tilelevel) Parameters : tilelevel : int __rowcol_bound(tilelevel) --> Tuple[int, int] Return the maximum row and column for the given tilelevel. """ if tilelevel <= 0: # At tile level 0 or below, there is only one tile: (row=0, col=0) row_bound: int = 0 col_bound: int = 0 elif self.__aspect_ratio: # Aspect ratio is available (media has been loaded) # Use aspect ratio to calculate bounds more efficiently if self.__aspect_ratio >= 1.0: ## width >= height (landscape orientation) # At this tilelevel, there are 2^tilelevel columns col_bound = (1 << tilelevel) - 1 # Rows are fewer because height < width; divide by aspect ratio row_bound = int((1 << tilelevel) / self.__aspect_ratio) - 1 else: ## height > width (portrait orientation) # Columns are fewer because width < height; multiply by aspect ratio col_bound = int((1 << tilelevel) * self.__aspect_ratio) - 1 # At this tilelevel, there are 2^tilelevel rows row_bound = (1 << tilelevel) - 1 else: # Fallback: calculate bounds from actual pixel dimensions # tile_pixsize is the pixel size each tile covers at this tilelevel # Higher tilelevels have smaller tiles; maxtilelevel has tilesize pixels per tile tile_pixsize: int = self.__tilesize * 2 ** (self.__maxtilelevel - tilelevel) # Calculate the maximum row/col by dividing total dimensions by tile pixel size # (self.__height - 1) ensures we get the correct last tile index row_bound = int((self.__height - 1) / tile_pixsize) col_bound = int((self.__width - 1) / tile_pixsize) return row_bound, col_bound def __render_tileblock(self, tileblock_id: tuple[int, int, int, int, int], mode: int) -> QtGui.QImage: """ Method : __render_tileblock(tileblock_id, mode) Parameters : tileblock_id : Tuple[int, int, int, int, int] mode : int __render_tileblock(tileblock_id, mode) --> QImage Render, cache, and return the tileblock given the unique tileblock_id and render mode. Precondition: mode is equal to either :attr:`RenderMode.Draft` or :attr:`RenderMode.HighQuality` """ self.__logger.debug("rendering tileblock") # Unpack the tileblock_id tuple into its component values # tilelevel: the zoom level of tiles to render # row_min/col_min: top-left tile coordinates # row_max/col_max: bottom-right tile coordinates tilelevel: int row_min: int col_min: int row_max: int col_max: int tilelevel, row_min, col_min, row_max, col_max = tileblock_id # Get the bottom-right tile to determine its actual pixel dimensions # The bottom-right tile may be smaller than tilesize if the image doesn't divide evenly brtile: Any = TileManager.get_tile_robust((self._media_id, tilelevel, row_max, col_max)) # Calculate total pixel dimensions of the tileblock # Width: number of full tiles * tilesize + actual width of the rightmost tile w: int = self.__tilesize * (col_max - col_min) + brtile.size[0] # Height: number of full tiles * tilesize + actual height of the bottom tile h: int = self.__tilesize * (row_max - row_min) + brtile.size[1] # Create a new QImage to hold the assembled tileblock # Format_RGB32: 32-bit RGB format (0xffRRGGBB), no alpha channel tileblock: QtGui.QImage = QtGui.QImage(w, h, QtGui.QImage.Format_RGB32) # Create a QPainter to draw individual tiles onto the tileblock image tileblock_painter: QtGui.QPainter = QtGui.QPainter() # Begin painting on the tileblock QImage # QPainter.begin() must be called before any drawing operations tileblock_painter.begin(tileblock) # Track whether all tiles in the block are in their final (highest quality) state # Starts True; set to False if any tile is not yet fully loaded tileblock_final: bool = True # Iterate over every tile position in the rectangular region [row_min..row_max, col_min..col_max] for row in range(row_min, row_max + 1): for col in range(col_min, col_max + 1): # Construct the unique tile identifier tuple # Format: (media_id, tilelevel, row, col) tile_id: tuple[str, int, int, int] = (self._media_id, tilelevel, row, col) try: # Try to get the fully loaded tile from TileManager's cache tile: Any = TileManager.get_tile(tile_id) except TileManager.TileNotLoaded: # Tile exists in the tile store but hasn't been loaded into memory yet # Mark the tileblock as non-final since we're using a substitute tileblock_final = False if mode == RenderMode.HighQuality: # In HQ mode: cut (generate) a temporary tile without expiry # cut_tile returns (tile, is_final) tuple; [0] gets just the tile tile = TileManager.cut_tile(tile_id)[0] else: # In Draft mode: cut a temporary tile with tempcache expiry cycles tile = TileManager.cut_tile(tile_id, self.tempcache)[0] except TileManager.TileNotAvailable: # Tile is not available at all (may need to be generated from parent tiles) if mode == RenderMode.HighQuality: # In HQ mode: generate tile without expiry tile: Any final: bool tile, final = TileManager.cut_tile(tile_id) else: # In Draft mode: generate tile with tempcache expiry cycles tile, final = TileManager.cut_tile(tile_id, self.tempcache) # If the generated tile is not final quality, mark the whole block non-final if not final: tileblock_final = False # Calculate the pixel position of this tile within the tileblock image # Offset from the top-left tile (col_min, row_min) of the block x: int = self.__tilesize * (col - col_min) y: int = self.__tilesize * (row - row_min) # Draw the tile onto the tileblock image at the calculated position tile.draw(tileblock_painter, x, y) # End the painting session; releases the QPainter from the QImage tileblock_painter.end() # Delete the old cached tileblock to free memory before replacing it del self.__tileblock # Cache the newly rendered tileblock and its metadata self.__tileblock = tileblock self.__tileblock_id = tileblock_id self.__tileblock_final = tileblock_final # Reset the age counter since this is a freshly rendered tileblock self.__tileblock_age = 0 return tileblock def __render_media(self, painter: Any, mode: int) -> None: """ Method : __render_media(painter, mode) Parameters : painter : QPainter mode : int __render_media(painter, mode) --> None Render the media using the given painter and render mode. Precondition: mode is equal to one of the constants defined in :class:`RenderMode` Note: Size visibility is checked by the scene via is_size_visible(). """ # Select the image scaling transformation mode based on render quality # FastTransformation uses nearest-neighbor interpolation (fast but lower quality) if mode == RenderMode.Draft or mode == RenderMode.HighQuality: transform_mode: QtCore.Qt.TransformationMode = QtCore.Qt.FastTransformation # Get the viewport dimensions (width, height) in pixels viewport_size: tuple[float, float] = self._scene.viewport_size # Calculate the combined zoom level (scene zoom + object zoom) # This determines which tile level to use and the sub-tile scaling zoomlevel: float = self.zoomlevel + self._scene.zoomlevel # tilelevel is the integer ceiling of zoomlevel # Tiles are stored at integer zoom levels; we pick the next higher level tilelevel: int = math.ceil(zoomlevel) # tilescale is the fractional scaling between the tile's native resolution and display # When zoomlevel == tilelevel, tilescale = 1.0 (tiles displayed at native size) # When zoomlevel < tilelevel, tilescale < 1.0 (tiles are scaled down) tilescale: float = 2 ** (zoomlevel - tilelevel) # Convert viewport corners to tile row/col coordinates # (0,0) is the top-left of the viewport; viewport_size is the bottom-right row_min: int col_min: int row_min, col_min = self.__pixpos2rowcol((0, 0), tilescale) row_max: int col_max: int row_max, col_max = self.__pixpos2rowcol(viewport_size, tilescale) # Clamp minimum row/col to 0 (cannot have negative tile indices) row_min = max(row_min, 0) col_min = max(col_min, 0) # Get the maximum valid row/col for this tilelevel # Tiles beyond these bounds don't exist row_bound: int col_bound: int row_bound, col_bound = self.__rowcol_bound(tilelevel) # Clamp maximum row/col to the actual tile bounds row_max = min(row_max, row_bound) col_max = min(col_max, col_bound) if row_max < row_min or col_max < col_min: ## the image does not fall within the viewport # No tiles are visible; skip rendering entirely return # Create the tileblock identifier tuple for cache lookup # Uniquely identifies the visible tile region at the current zoom level tileblock_id: tuple[int, int, int, int, int] = (tilelevel, row_min, col_min, row_max, col_max) # Determine whether the cached tileblock needs to be re-rendered if (self.__tileblock_id != tileblock_id) or ( not self.__tileblock_final and (mode == RenderMode.HighQuality or self.__tileblock_age >= self.tempcache) ): ## the cached tileblock is different to the required ## one, so we have draw the new tileblock ## we also re-render the tileblock if it is not final ## and either we are in HQ mode or the tileblock ## is at least self.tempcache cycles old tileblock: QtGui.QImage = self.__render_tileblock(tileblock_id, mode) else: # Reuse the cached tileblock and increment its age counter tileblock = self.__tileblock self.__tileblock_age += 1 # Scale the tileblock image to match the current display zoom # tilescale adjusts for the fractional zoom between integer tile levels image_scaled: QtGui.QImage = tileblock.scaled( int(tilescale * tileblock.width()), int(tilescale * tileblock.height()), QtCore.Qt.IgnoreAspectRatio, transform_mode, ) # Calculate the on-screen position where the tileblock should be drawn # Start from the object's top-left corner and offset by the tile region's position o: tuple[float, float] = self.topleft # x position: object left edge + horizontal offset to the first visible tile column x: float = o[0] + int(tilescale * self.__tilesize * col_min) # y position: object top edge + vertical offset to the first visible tile row y: float = o[1] + int(tilescale * self.__tilesize * row_min) # Draw the scaled tileblock image onto the painter at the calculated position # painter.drawImage(x, y, image) renders the QImage at screen coordinates (x, y) painter.drawImage(int(x), int(y), image_scaled) def __render_placeholder(self, painter: Any) -> None: """ Method : __render_placeholder(painter) Parameters : painter : QPainter __render_placeholder(painter) --> None Render a placeholder indicating that the image is still loading, using the given painter. """ # Get the on-screen position and size of this media object # topleft returns (x, y) screen coordinates of the top-left corner x: float y: float x, y = self.topleft # onscreen_size returns (width, height) in screen pixels w: float h: float w, h = self.onscreen_size try: # Fill the placeholder rectangle with a dark gray background # painter.fillRect(x, y, width, height, color) draws a filled rectangle painter.fillRect(x, y, w, h, QtCore.Qt.darkGray) except TypeError: ## rectangle dimensions could not be converted to ints # This can happen when dimensions are extremely large or NaN pass else: # fillRect succeeded; now draw progress text on top of the placeholder if self.__progress > 0: # Conversion/tiling has started; show percentage progress # Set text color to white for visibility against dark gray background painter.setPen(QtGui.QColor(255, 255, 255)) # Create a font sized proportionally to the placeholder width # w/4 makes the percentage text roughly 25% of the placeholder width font: QtGui.QFont = QtGui.QFont() font.setPointSizeF(w / 4) painter.setFont(font) # Draw the progress percentage centered in the placeholder rectangle # QtCore.Qt.AlignCenter centers text both horizontally and vertically # int(self.__progress * 100) converts 0.0-1.0 to 0-100 percentage painter.drawText(x, y, w, h, QtCore.Qt.AlignCenter, str(int(self.__progress * 100)) + "%") else: # No progress yet; show "loading..." text # Set text color to white for visibility painter.setPen(QtGui.QColor(255, 255, 255)) # Create a smaller font (w/10) for the "loading..." text font: QtGui.QFont = QtGui.QFont() font.setPointSizeF(w / 10) painter.setFont(font) # Draw "loading..." centered in the placeholder rectangle painter.drawText(x, y, w, h, QtCore.Qt.AlignCenter, "loading...")
[docs] def _try_load(self) -> None: """ Method : _try_load() Parameters : None _try_load() --> None Try to load the (0,0,0) tile from the TileManager. """ try: # Attempt to retrieve the root tile (level=0, row=0, col=0) from TileManager # If this succeeds, the media has been fully tiled and is ready to render TileManager.get_tile((self._media_id, 0, 0, 0)) except TileManager.TileNotLoaded: # Tile exists in the store but hasn't been loaded into memory yet # This is expected during the loading process; we'll retry on next render cycle self.__logger.info("(0,0,0) tile not loaded yet") pass except (TileManager.MediaNotTiled, TileManager.TileNotAvailable): # The media could not be tiled at all; raise a LoadError to signal failure raise LoadError("unable to correctly tile the image") from None else: # Root tile loaded successfully; media is ready for rendering self.__logger.info("media loaded") self._loaded = True if self.__tiler: ## destroy tiler to close tmpfile (required to unlink on Windows) # Setting to None allows garbage collection of the PPMTiler object self.__tiler = None # Clean up the temporary PPM file if self.__tmpfile is not None: try: os.unlink(self.__tmpfile) except FileNotFoundError: pass except Exception: self.__logger.exception(f"unable to unlink temporary file '{self.__tmpfile}'") # Save the current bounding box before updating dimensions # These are needed for autofit to maintain the placeholder's screen position old_x1: float old_y1: float old_x1, old_y1 = self.topleft old_x2: float old_y2: float old_x2, old_y2 = self.bottomright # Save the current centre position for restoration after autofit old_centre: tuple[float, float] = self.centre # Load the actual media dimensions from TileManager metadata # These replace the default_size (256, 256) placeholder values self.__width = TileManager.get_metadata(self._media_id, "width") self.__height = TileManager.get_metadata(self._media_id, "height") # Load the maximum tile level (deepest zoom level available) self.__maxtilelevel = TileManager.get_metadata(self._media_id, "maxtilelevel") # Load the tile size in pixels (typically 256) self.__tilesize = TileManager.get_metadata(self._media_id, "tilesize") # Load the aspect ratio (width / height) for efficient size calculations self.__aspect_ratio = TileManager.get_metadata(self._media_id, "aspect_ratio") if self.__autofit: ## fit to area occupied by placeholder # Resize and reposition the media to fill the old placeholder bounding box # fit() adjusts zoom level and position to maximize size within the box self.fit((old_x1, old_y1, old_x2, old_y2)) # Restore the centre position to keep the object centered where it was self.centre = old_centre
def __run_tiler(self) -> None: """ Method : __run_tiler() Parameters : None __run_tiler() --> None Run the tiler (after checking that there is an image to run the tiler on). """ # Check if the PPM file exists on disk before attempting to tile it # os.path.exists() returns True if the file path exists if not os.path.exists(self.__ppmfile): ## there was a problem converting, or the input file ## never actually existed if self.__converter and self.__converter.error: # Converter reported an error; propagate it as a LoadError raise LoadError(self.__converter.error) else: # No converter error available; raise a generic LoadError raise LoadError("there was a problem converting and/or loading the input file") # Determine the output tile format based on the input file extension # JPG files are tiled as JPG to preserve compression; everything else uses PNG if self._media_id.lower().endswith(".jpg"): filext: str = "jpg" else: filext: str = "png" try: # Submit tiling job to process pool for parallel tile creation # submit_tiling returns a Future that will contain the tiling result future = submit_tiling(self.__ppmfile, self._media_id, filext) self.__tiler = TilingHandle(future, self.__ppmfile, self._media_id) except OSError: # IOError during tiler creation indicates a file read problem raise LoadError("there was an error creating the tiler: %s") from None
[docs] def render(self, painter: Any, mode: int) -> None: """ Method : TiledMediaObject.render(painter, mode) Parameters : painter : QPainter mode : int TiledMediaObject.render(painter, mode) --> None Render the tiled media using the given painter and rendering mode. If the media is loaded, renders the actual media content. Otherwise, renders a placeholder showing loading progress. Handles tiler initialization if converter has finished but tiler hasn't started yet. Precondition: mode is equal to one of the constants defined in :class:`RenderMode` """ # If the media is fully loaded, render the actual tiled image if self._loaded: self.__render_media(painter, mode) elif self.__tiler and self.__tiler.error: # The tiler encountered an error during the tiling process # Log the error but don't raise an exception to avoid crashing the render loop self.__logger.exception(f"an error ocurred during the tiling process: {self.__tiler.error}") # Check if TileManager now has tiles available for this media # Uses 'if' instead of 'elif' to allow loading even after the _loaded check above if TileManager.tiled(self._media_id): # Attempt to load the root tile and update media dimensions self._try_load() if self._loaded: # Loading succeeded; render the actual media content self.__render_media(painter, mode) else: # Root tile not yet in memory; show placeholder while loading self.__render_placeholder(painter) elif self.__tiler is None and (self.__converter is None or self.__converter.progress == 1.0): ## the tiler has not been run yet and either ## it was assumed that media_id is a local PPM ## file or the converter has just finished # Start the tiling process now that the PPM file is ready self.__run_tiler() # Show placeholder while tiling is in progress self.__render_placeholder(painter) else: # Conversion/tiling still in progress; show placeholder with progress self.__render_placeholder(painter)
[docs] def is_size_visible(self, mode: int) -> bool: """ Tiled image size visibility check. Returns False if image is too small (<= 1 pixel), otherwise returns super().is_size_visible(mode). """ if not super().is_size_visible(mode): return False # TiledMediaObject specific: minimum size check return min(self.onscreen_size) > 1
@property def onscreen_size(self) -> tuple[float, float]: """ Property : TiledMediaObject.onscreen_size Parameters : None TiledMediaObject.onscreen_size --> Tuple[float, float] Return the on-screen size of the tiled media. Calculates the width and height based on aspect ratio if available, otherwise uses the actual width and height scaled by zoom levels. Returns (0,0) if the media dimensions are not yet known. """ if self.__aspect_ratio: # Aspect ratio is available (media has been loaded) if self.__aspect_ratio >= 1.0: ## width >= height (landscape orientation) # Width is determined by the combined zoom level and tile size # 2^(scene_zoom + object_zoom) * tilesize gives the full-width at this zoom w: float = math.exp2(self._scene.zoomlevel + self.zoomlevel) * self.__tilesize # Height is derived from width using the aspect ratio (width / height) h: float = w / self.__aspect_ratio else: ## height > width (portrait orientation) # Height is determined by the combined zoom level and tile size h: float = math.exp2(self._scene.zoomlevel + self.zoomlevel) * self.__tilesize # Width is derived from height using the aspect ratio w: float = h * self.__aspect_ratio return (w, h) elif self.__width == 0 or self.__height == 0: # Media dimensions are not yet known (still loading) return (0, 0) else: # Fallback: calculate from actual pixel dimensions and zoom levels # scale converts from tile-level pixels to screen pixels # Subtracting maxtilelevel accounts for the tile hierarchy depth scale: float = 2 ** (self._scene.zoomlevel + self.zoomlevel - self.__maxtilelevel) # Multiply pixel dimensions by scale to get on-screen size w: float = self.__width * scale h: float = self.__height * scale return (w, h)