Source code for bmquilting.utils.ui_coord

"""
    Utilities to sync a GUI with the generation, or to interrupt the generation via a GUI.
"""

from multiprocessing.shared_memory import SharedMemory
import numpy as np
import functools
import threading
import logging

logger = logging.getLogger(__name__)
logger.addHandler(logging.NullHandler())

DTYPE = np.dtype('uint32')
"""dtype used for the shared memory array"""

ON_THREAD_JOIN_TIMEOUT = 2.0
"""timeout used when joining a registered thread on JobMemoryManager exit"""


[docs] class UiCoordData: """ Manages job progress and interruption signals via SharedMemory. The shared memory buffer is expected to be a ``uint32`` array where: * Index 0: Interrupt signal (value > 0 triggers interruption). * Index 1+: Job data slots for progress tracking. :param jobs_shm_name: The unique name of the shared memory block. :param job_id: The index offset for this specific job's data slot. """ def __init__(self, jobs_shm_name: str, job_id: int): self.jobs_shm_name: str = jobs_shm_name self.job_id: int = job_id self._shm: SharedMemory | None = None self._view: np.ndarray | None = None def _connect(self) -> None: """Connects to shared memory and creates a persistent numpy view.""" if self._shm is None: self._shm = SharedMemory(name=self.jobs_shm_name) # Create a view of the entire buffer for efficiency self._view = np.ndarray( (2 + self.job_id,), dtype=DTYPE, buffer=self._shm.buf )
[docs] def raise_or_add(self, value: int = 0) -> None: """ Raises JobInterrupted if an interrupt signal is detected. Otherwise, adds count to the progress tracker shared memory array. :raises JobInterrupted: If the UI has signaled to stop. """ self._connect() if self._view[0] > 0: raise JobInterrupted() self._view[1 + self.job_id] += np.uint32(value)
[docs] def request_interrupt(self) -> None: """ Sets the interrupt signal for this shared memory block. Call this from the UI thread/process to stop the worker. """ self._connect() self._view[0] = np.uint32(1)
[docs] def close(self) -> None: """Closes the shared memory connection.""" if self._shm is not None: self._shm.close() self._shm = None self._view = None
[docs] @staticmethod def get_number_of_jobs_for(parallelization_lvl: int, batch_size: int = 1) -> int: """ :param parallelization_lvl: the parallelization_lvl used for the generation :param batch_size: how many generations will run simultaneously :return: the total number of jobs """ if batch_size > 1: n_jobs = batch_size n_jobs *= 1 if parallelization_lvl == 0 else 4 else: n_jobs = 1 if parallelization_lvl == 0 else 4 n_jobs *= parallelization_lvl if parallelization_lvl > 0 else 1 return n_jobs
[docs] @staticmethod def get_required_shm_size_and_number_of_jobs(parallelization_lvl: int, batch_size: int = 1) -> tuple[int, int]: n_jobs = UiCoordData.get_number_of_jobs_for(parallelization_lvl, batch_size) return (1 + n_jobs) * np.dtype(DTYPE).itemsize, n_jobs
[docs] class JobMemoryManager: def __init__(self, num_jobs: int): self.num_jobs = num_jobs self._shm: SharedMemory | None = None self._thread: threading.Thread | None = None @property def name(self): return self._shm.name if self._shm else None
[docs] def get_progress(self) -> int: """Reads current progress of all jobs from memory.""" view = np.ndarray((1 + self.num_jobs,), dtype=DTYPE, buffer=self._shm.buf) return np.sum(view[1:])
[docs] def stop_all(self): """Sets the interrupt flag at index 0.""" view = np.ndarray((1,), dtype=DTYPE, buffer=self._shm.buf) view[0] = 1
[docs] def register_thread(self, thread: threading.Thread): """Register the UI monitoring thread so we can join it on exit.""" self._thread = thread
[docs] def is_interrupted(self) -> bool: """Check the shared signal. Use this in the thread loop.""" if self._shm is None: return True view = np.ndarray((1,), dtype=DTYPE, buffer=self._shm.buf) return view[0] > np.uint32(0)
[docs] def create(self) -> str: size = (1 + self.num_jobs) * DTYPE.itemsize self._shm = SharedMemory(create=True, size=size) # Initialize Memory: [Interrupt(0), Job1(0), Job2(0)...] view = np.ndarray((1 + self.num_jobs,), dtype=DTYPE, buffer=self._shm.buf) view[:] = 0 return self._shm.name
def __enter__(self): self.create() return self def __exit__(self, exc_type, exc_val, exc_tb): if self._shm: # Set the global kill-switch view = np.ndarray((1,), dtype=DTYPE, buffer=self._shm.buf) view[0] = 1 # Ensure the UI thread sees the signal and stops if self._thread and self._thread.is_alive(): self._thread.join(timeout=ON_THREAD_JOIN_TIMEOUT) if self._thread.is_alive(): logger.warning("Monitoring thread failed to exit after timeout. " "Potential resource leak!") # Close & Unlink shared memory self._shm.close() try: self._shm.unlink() logger.info("UICD-JMM: shared memory was unlinked.") except FileNotFoundError: pass
[docs] class JobInterrupted(Exception): """Custom exception to signal a UI interrupt.""" pass
[docs] def handle_ui_interrupts(return_on_cancel: any = "INTERRUPTED", auto_close: bool = True, re_raise: bool = False): """ Decorator for job functions that handle UI interruptions via UiCoordData. :param return_on_cancel: The value to return if a JobInterrupted exception is caught. :param auto_close: If True, automatically calls .close() on the 'uicd' object found in arguments. :param re_raise: Raise JobInterrupted again after closing uicd. """ def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): # Identify the uicd object if it exists in args or kwargs uicd: UiCoordData | None = kwargs.get('uicd') if uicd is None: for arg in args: if isinstance(arg, UiCoordData): uicd = arg break # Run the function, catch interrupts, and close uicd shm connection try: return func(*args, **kwargs) except JobInterrupted: logger.info("HUI: JobInterrupted catched") if re_raise: logger.info("HUI: Re-Raise JobInterrupted") raise JobInterrupted() return return_on_cancel finally: if auto_close and uicd is not None: uicd.close() return wrapper return decorator
[docs] def check_ui(uicd: UiCoordData | None, to_add: int = 0): """ Helper to be called inside functions. Keeps the code readable and raises the JobInterrupt when an interrupt is triggered. """ if uicd is not None: uicd.raise_or_add(to_add)