Source code for flow360.plugins.report.uvf_shutter

"""
This module is responsible for communicating with UVF-shutter service
"""

import asyncio
import json
import os
import reprlib
import time
import zipfile
from collections import defaultdict
from functools import wraps
from typing import Callable, List, Literal, Optional, Tuple, Union
from urllib.parse import urljoin

import pydantic as pd
from flow360_schema.framework.base_model import Flow360BaseModel
from flow360_schema.models.simulation.camera import Camera

from flow360.environment import Env
from flow360.exceptions import (
    Flow360RuntimeError,
    Flow360WebError,
    Flow360WebNotFoundError,
)
from flow360.log import log
from flow360.plugins.report.report_context import ReportContext

here = os.path.dirname(os.path.abspath(__file__))


class ShutterRequestBaseModel(Flow360BaseModel):
    """
    Base model for UVF shutter requests
    """


ShutterObjectTypes = Literal["slices", "qcriterion", "isosurface", "boundaries"]


class Resource(Flow360BaseModel):
    """
    Resource context for identifying the source of a request.

    Parameters
    ----------
    path_prefix : str
        Prefix with identifier for the user initiating the request.
    id : str
        Identifier for the case associated with the request.
    """

    path_prefix: str
    id: str
    type: str = "case"

    model_config = Flow360BaseModel.model_config.copy()
    model_config.update({"frozen": True})


class Resolution(Flow360BaseModel):
    """
    Screen resolution settings.

    Parameters
    ----------
    width : int, default=1920
        Width of the resolution in pixels.
    height : int, default=1080
        Height of the resolution in pixels.
    """

    width: pd.PositiveInt = 1920
    height: pd.PositiveInt = 1080


class Settings(Flow360BaseModel):
    """
    Configuration settings for a scene.

    Parameters
    ----------
    resolution : Resolution
        Resolution settings for the scene.
    """

    resolution: Resolution = Resolution()

    model_config = Flow360BaseModel.model_config.copy()
    model_config.update({"frozen": True})


class SetObjectVisibilityPayload(Flow360BaseModel):
    """
    Payload for setting the visibility of objects.

    Parameters
    ----------
    object_ids : List[UvfObjectTypes]
        List of object identifiers for which visibility will be set.
    visibility : bool
        Boolean indicating the visibility state.
    """

    object_ids: List[Union[ShutterObjectTypes, str]]
    visibility: bool


class SetColormapPayload(Flow360BaseModel):
    """
    SetColormapPayload
    """

    type: Literal["Rainbow"] = "Rainbow"
    steps: Optional[
        List[float]
    ]  # eg: [0, 0.25, 0.5, 0.75, 1.0], // Always sorted before use, 0 and 1 implicitly added if not specified


class SetFieldPayload(Flow360BaseModel):
    """
    Payload for setting field parameters on an object.

    Parameters
    ----------
    object_id : ShutterObjectTypes
        Identifier of the object for field setting.
    field_name : str
        Name of the field to modify.
    min_max : Tuple[float, float]
        Minimum and maximum values for the field.
    """

    object_id: ShutterObjectTypes
    field_name: str
    min_max: Tuple[float, float]
    is_log_scale: bool
    # colormap: Optional[SetColormapPayload] = None


class TakeLegendScreenshotPayload(Flow360BaseModel):
    """
    Payload for taking a legend screenshot.

    This payload is used to capture just the legend (color scale, range, and optional title) for
    the specified object (e.g., slice, boundary). The legend is saved as an image file.

    Parameters
    ----------
    object_id : ShutterObjectTypes
        Identifier of the object whose legend will be captured.
    file_name : str
        The name of the output screenshot file, aliased as "filename".
    type : str, default="png"
        Image format for the legend screenshot.
    width : int, default=400
        Width of the screenshot in pixels.
    height : int, default=60
        Height of the screenshot in pixels.
    title : str, optional
        Title to display on the legend. If omitted, the default is the field name being visualized.
    """

    object_id: ShutterObjectTypes
    file_name: str = pd.Field(alias="filename")
    type: str = "png"
    width: Optional[pd.PositiveInt] = 400
    height: Optional[pd.PositiveInt] = 60
    title: Optional[str] = None


class SetLICPayload(Flow360BaseModel):
    """
    Payload for setting the visibility of objects.

    Parameters
    ----------
    object_id : ShutterObjectTypes
        Object identifier for which LIC will be set.
    visibility : bool
        Boolean indicating the visibility state.
    """

    object_id: str
    visibility: bool


[docs] class TopCamera(Camera): """ Camera looking down from above (along +Z). """ position: Tuple[float, float, float] = pd.Field((0.0, 0.0, 1.0)) look_at: Tuple[float, float, float] = pd.Field((0.0, 0.0, 0.0)) up: Tuple[float, float, float] = pd.Field((0.0, 1.0, 0.0)) type: Literal["TopCamera"] = pd.Field("TopCamera")
[docs] class LeftCamera(Camera): """ Camera looking from the positive Y side toward the origin (i.e. along -Y). """ position: Tuple[float, float, float] = pd.Field((0.0, -1.0, 0.0)) look_at: Tuple[float, float, float] = pd.Field((0.0, 0.0, 0.0)) up: Tuple[float, float, float] = pd.Field((0.0, 0.0, 1.0)) type: Literal["LeftCamera"] = pd.Field("LeftCamera")
[docs] class RearCamera(Camera): """ Camera looking from negative X toward the origin (i.e. along +X). """ position: Tuple[float, float, float] = pd.Field((1.0, 0.0, 0.0)) look_at: Tuple[float, float, float] = pd.Field((0.0, 0.0, 0.0)) up: Tuple[float, float, float] = pd.Field((0.0, 0.0, 1.0)) type: Literal["RearCamera"] = pd.Field("RearCamera")
[docs] class FrontCamera(Camera): """ Camera looking from positive X side toward the origin (i.e. along -X). """ position: Tuple[float, float, float] = pd.Field((-1.0, 0.0, 0.0)) look_at: Tuple[float, float, float] = pd.Field((0.0, 0.0, 0.0)) up: Tuple[float, float, float] = pd.Field((0.0, 0.0, 1.0)) type: Literal["FrontCamera"] = pd.Field("FrontCamera")
[docs] class BottomCamera(Camera): """ Camera looking up from below (along -Z). """ position: Tuple[float, float, float] = pd.Field((0.0, 0.0, -1.0)) look_at: Tuple[float, float, float] = pd.Field((0.0, 0.0, 0.0)) up: Tuple[float, float, float] = pd.Field((0.0, -1.0, 0.0)) type: Literal["BottomCamera"] = pd.Field("BottomCamera")
[docs] class FrontLeftBottomCamera(Camera): """ Camera placed front-left-bottom, diagonally looking at the model.] """ position: Tuple[float, float, float] = pd.Field((-1.0, -1.0, -1.0)) look_at: Tuple[float, float, float] = pd.Field((0.0, 0.0, 0.0)) up: Tuple[float, float, float] = pd.Field((0.0, 0.0, 1.0)) type: Literal["FrontLeftBottomCamera"] = pd.Field("FrontLeftBottomCamera")
[docs] class RearRightBottomCamera(Camera): """ Camera placed rear-right-bottom, diagonally looking at the model. """ position: Tuple[float, float, float] = pd.Field((1.0, 1.0, -1.0)) look_at: Tuple[float, float, float] = pd.Field((0.0, 0.0, 0.0)) up: Tuple[float, float, float] = pd.Field((0.0, 0.0, 1.0)) type: Literal["RearRightBottomCamera"] = pd.Field("RearRightBottomCamera")
[docs] class FrontLeftTopCamera(Camera): """ Camera placed front-left-top, diagonally looking at the model. """ position: Tuple[float, float, float] = pd.Field((-1.0, -1.0, 1.0)) look_at: Tuple[float, float, float] = pd.Field((0.0, 0.0, 0.0)) up: Tuple[float, float, float] = pd.Field((0.0, 0.0, 1.0)) type: Literal["FrontLeftTopCamera"] = pd.Field("FrontLeftTopCamera")
[docs] class RearLeftTopCamera(Camera): """ Camera placed rear-left-top, diagonally looking at the model. """ position: Tuple[float, float, float] = pd.Field((1.0, -1.0, 1.0)) look_at: Tuple[float, float, float] = pd.Field((0.0, 0.0, 0.0)) up: Tuple[float, float, float] = pd.Field((0.0, 0.0, 1.0)) type: Literal["RearLeftTopCamera"] = pd.Field("RearLeftTopCamera")
class SetCameraPayload(Camera): """ Alias for Camera """ class TakeScreenshotPayload(Flow360BaseModel): """ Payload for taking a screenshot. Parameters ---------- file_name : str Name of the file for saving the screenshot. type : str, default="png" Type of the screenshot file format. """ file_name: str = pd.Field(alias="filename") type: str = "png" class ResetFieldPayload(Flow360BaseModel): """ Payload for resetting a field on an object. Parameters ---------- object_id : ShutterObjectTypes Identifier of the object for which the field is reset. """ object_id: ShutterObjectTypes class FocusPayload(Flow360BaseModel): """ Payload for focusing camera on an object. Parameters ---------- object_id : ShutterObjectTypes Identifier of the object for which the field is reset. zoom: pd.PositiveFloat Zoom multiplier can be used to add padding, default 1 """ object_ids: List[Union[ShutterObjectTypes, str]] zoom: Optional[pd.PositiveFloat] = 1 ActionTypes = Literal[ "focus", "set-object-visibility", "set-field", "set-lic", "reset-field", "set-camera", "take-screenshot", "take-legend-screenshot", ] class ActionPayload(Flow360BaseModel): """ Defines an action to be taken on an object in a scene. Parameters ---------- action : ACTIONTYPES Type of action to be performed. payload : Optional[Union[SetObjectVisibilityPayload, SetFieldPayload, TakeScreenshotPayload, ResetFieldPayload]] Data required for the specified action. """ action: ActionTypes payload: Optional[ Union[ SetObjectVisibilityPayload, SetFieldPayload, TakeScreenshotPayload, TakeLegendScreenshotPayload, ResetFieldPayload, SetCameraPayload, FocusPayload, SetLICPayload, ] ] = None class Scene(Flow360BaseModel): """ Represents a scene with specific settings and scripted actions. Parameters ---------- name : str Name of the scene. settings : Settings Configuration settings for the scene. script : List[ActionPayload] List of actions to execute within the scene. """ name: str settings: Settings = Settings() script: List[ActionPayload] class ScenesData(Flow360BaseModel): """ Data structure holding multiple scenes and associated resource. Parameters ---------- resource : Resource Resource data related to the source. scenes : List[Scene] List of scenes with actions and settings. """ resource: Resource scenes: List[Scene] class Flow360WebNotAvailableError(Flow360WebError): """ Exception raised when the Flow360 web service is unavailable. """ def http_interceptor(func): """ Decorator that intercepts HTTP responses and raises appropriate exceptions based on response status. """ @wraps(func) async def wrapper(*args, **kwargs): """A wrapper function""" log.debug(f"call: {func.__name__}({reprlib.repr(args)}, {reprlib.repr(kwargs)})") start_time = time.time() async with await func(*args, **kwargs) as resp: log.debug(f"response: {resp}") if resp.status == 503: error_message = await resp.json() raise Flow360WebNotAvailableError(f"Web: not available {error_message}") if resp.status == 400: error_message = await resp.json() log.debug(f"{error_message=}") raise Flow360WebError(f"Web: Bad request error: {error_message}") if resp.status == 404: error_message = await resp.json() raise Flow360WebNotFoundError(f"Web: Not found error: {error_message}") if resp.status == 200: end_time = time.time() execution_time = end_time - start_time log.debug(f"UVF Shutter execution time: {execution_time:.4f} seconds") try: content_type = resp.headers.get("Content-Type", "") if "application/json" in content_type: result = await resp.json() return result.get("data") data = await resp.read() log.debug("received binary data") return data except Exception as e: log.error(f"Exception occurred while reading response: {e}") raise error_message = await resp.text() raise Flow360WebError(f"Web: Unexpected response error: {resp.status}, {error_message}") return wrapper def combine(model_a, model_b, key_to_combine, eq: callable): """ Combines attributes of two models based on a specified key if they satisfy a given equality condition. This function checks if two models are considered equal using the provided `eq` function. If they are equal, it combines the values of the specified attribute (referenced by `key_to_combine`) into a single list, updates the first model with the combined values, and returns the updated model along with a flag indicating that the models were combined. Parameters ---------- model_a : Any The first model to combine. model_b : Any The second model to combine. key_to_combine : str The name of the attribute in both models to combine. eq : callable A function that takes two models as input and returns `True` if the models are considered equal, `False` otherwise. Returns ------- tuple A tuple containing: - The updated model (`model_a`) if the models were combined, or the original `model_a` if not. - A boolean flag indicating whether the models were combined (`True`) or not (`False`). Raises ------ TypeError If the specified attribute (`key_to_combine`) is not a list in both models. Example ------- >>> model_a = SomeModel(attr=["item1"]) >>> model_b = SomeModel(attr=["item2"]) >>> eq = lambda a, b: a.some_property == b.some_property >>> combined_model, combined = combine(model_a, model_b, "attr", eq) >>> print(combined_model.attr) # ['item1', 'item2'] >>> print(combined) # True """ if eq(model_a, model_b): attr_a = getattr(model_a, key_to_combine) attr_b = getattr(model_b, key_to_combine) if isinstance(attr_a, list) and isinstance(attr_b, list): combined_attr = attr_a + attr_b new_model = model_a.copy(update={key_to_combine: combined_attr}) return new_model, True raise TypeError(f"The attribute '{key_to_combine}' is not a list in both models.") return model_a, False def make_shutter_context(context: ReportContext): """ Extracts relevant data for shutter from context """ context_data = { "data_storage": context.data_storage, "url": context.shutter_url, "access_token": context.shutter_access_token, "screenshot_process_function": context.shutter_screenshot_process_function, "process_screenshot_in_parallel": context.process_screenshot_in_parallel, } context_data = {k: v for k, v in context_data.items() if v is not None} return context_data class ShutterBatchService: """ Service to collect and process UVF shutter requests. """ def __init__(self, data_storage=".", shutter_url=None, shutter_access_token=None): self.data_storage = data_storage self.shutter_url = shutter_url self.shutter_access_token = shutter_access_token self.requests = defaultdict(list) def _merge_similar_scenes(self, resource, new_scenes): stored_scenes: List[Scene] = self.requests.get(resource, []) for scene in new_scenes: appended = False # pylint: disable=unnecessary-lambda-assignment eq = lambda a, b: a.name == b.name and a.settings == b.settings for i, stored_scene in enumerate(stored_scenes): combined_scene, appended = combine(stored_scene, scene, "script", eq) if appended: stored_scenes[i] = combined_scene break if appended is False: stored_scenes.append(scene.copy()) return stored_scenes def _merge_visibility_actions(self, actions: List[ActionPayload]) -> List[ActionPayload]: """ Merges consecutive 'set-object-visibility' actions with the same 'visibility' value. Does not merge actions if there are other actions (like 'screenshot') in between. """ merged_actions = [] last_visibility_action = None for action in actions: if action.action == "set-object-visibility": payload = action.payload if not isinstance(payload, SetObjectVisibilityPayload): raise TypeError("Payload must be of type SetObjectVisibilityPayload") if ( last_visibility_action and payload.visibility == last_visibility_action.payload.visibility ): combined_object_ids = set(last_visibility_action.payload.object_ids) | set( payload.object_ids ) last_visibility_action.payload.object_ids = list(combined_object_ids) else: if last_visibility_action: merged_actions.append(last_visibility_action) last_visibility_action = action.copy() else: if last_visibility_action: merged_actions.append(last_visibility_action) last_visibility_action = None merged_actions.append(action) if last_visibility_action: merged_actions.append(last_visibility_action) return merged_actions def _remove_redundant_visibility_actions( self, actions: List[ActionPayload] ) -> List[ActionPayload]: """ Optimizes 'set-object-visibility' actions by removing redundant actions and sequences. Rules: - If a 'set-object-visibility' action with the same 'object_ids' and 'visibility' has already occurred, remove the subsequent duplicate action. - If a sequence of 'set-object-visibility' actions (regardless of other actions in between) is repeated later in the action list with the same 'object_ids' and 'visibility', remove the latter sequence. - Other action types are preserved and ignored in this optimization. Note: - This function assumes that the order of actions is important and preserves the order of other actions. """ optimized_actions = [] visibility_sequence = [] current_sequence = [] for action in actions: if action.action == "set-object-visibility": payload = action.payload if not isinstance(payload, SetObjectVisibilityPayload): raise TypeError("Payload must be of type SetObjectVisibilityPayload") current_sequence.append(action) else: if current_sequence: sequence_key = tuple( (tuple(sorted(a.payload.object_ids)), a.payload.visibility) for a in current_sequence ) if sequence_key != visibility_sequence: visibility_sequence = sequence_key optimized_actions.extend(current_sequence) current_sequence = [] optimized_actions.append(action) if current_sequence: sequence_key = tuple( (tuple(sorted(a.payload.object_ids)), a.payload.visibility) for a in current_sequence ) if sequence_key != visibility_sequence: optimized_actions.extend(current_sequence) return optimized_actions def _remove_redundant_set_field_actions( self, actions: List[ActionPayload] ) -> List[ActionPayload]: """ Removes redundant 'set-field' actions when the payload has not changed between calls. Rules: - If a 'set-field' action has the same payload as the previous one and no intervening actions affect the field settings, remove the latter 'set-field' action. - Other action types are preserved and ignored in this optimization unless they affect the field settings. Note: - This function assumes that the order of actions is important and preserves the order of other actions. """ optimized_actions = [] last_set_field_payload = None field_affecting_actions = {"reset-field", "set-field"} for action in actions: if action.action == "set-field": payload = action.payload if not isinstance(payload, SetFieldPayload): raise TypeError("Payload must be of type SetFieldPayload") if last_set_field_payload == payload: continue last_set_field_payload = payload elif action.action in field_affecting_actions: last_set_field_payload = None optimized_actions.append(action) return optimized_actions def add_request(self, request: ScenesData): """ Adds a new request to the batch and processes it to optimize actions. This method merges the provided request's scenes with any existing scenes for the same resource, ensuring that similar scenes are combined and redundant actions are removed. Parameters ---------- request : ScenesData The new request containing scenes and associated resource information. Processing Steps ---------------- 1. Merges similar scenes based on their name and settings. 2. Optimizes scripts within each scene by: - Merging consecutive 'set-object-visibility' actions with the same visibility value. - Removing redundant 'set-object-visibility' actions across the script. - Eliminating redundant 'set-field' actions when the payload is unchanged. Raises ------ TypeError If an invalid payload type is encountered during processing. Example ------- >>> resource = Resource(path_prefix="user", id="case1") >>> scenes = [Scene(name="scene1", settings=Settings(), script=[])] >>> request = ScenesData(resource=resource, scenes=scenes) >>> service = ShutterBatchService() >>> service.add_request(request) """ stored_scenes = self._merge_similar_scenes(request.resource, request.scenes) for stored_scene in stored_scenes: stored_scene.script = self._merge_visibility_actions(stored_scene.script) for stored_scene in stored_scenes: stored_scene.script = self._remove_redundant_visibility_actions(stored_scene.script) for stored_scene in stored_scenes: stored_scene.script = self._remove_redundant_set_field_actions(stored_scene.script) self.requests[request.resource] = stored_scenes def get_batch_requests(self): """ Retrieves all batched requests currently stored in the service. This method compiles the collected scenes and their associated resources into a list of `ScenesData` objects, which can be sent to the UVF shutter service for processing. Returns ------- List[ScenesData] A list of `ScenesData` objects, where each object represents a resource and its associated scenes. """ return [ ScenesData(resource=resource, scenes=scenes) for resource, scenes in self.requests.items() ] def process_requests(self, context): """ Processes the collected requests by grouping and combining them. """ img_files = Shutter(**make_shutter_context(context)).get_images( "None", self.get_batch_requests() ) return img_files class Shutter(Flow360BaseModel): """ Model representing UVF shutter request data and configuration settings. Parameters ---------- data_storage : str, default="." Path to the directory where data will be stored. url : str URL endpoint for the shutter service, defaults to "https://shutter-api.{Env.current.domain}". use_cache : bool Whether to force generate data or use cached data process_screenshot_in_parallel : bool Whether to process screenshots concurrently """ data_storage: str = "." url: str = pd.Field(default_factory=lambda: f"https://shutter-api.{Env.current.domain}") use_cache: bool = True process_screenshot_in_parallel: bool = True access_token: Optional[str] = None screenshot_process_function: Optional[Callable] = None # pylint: disable=too-many-locals async def _get_3d_images_api(self, screenshots: dict[str, Tuple]) -> dict[str, list]: try: import backoff # pylint: disable=import-outside-toplevel except ImportError as err: raise RuntimeError( "backoff is not installed. Please install backoff to use this functionality." ) from err try: import aiohttp # pylint: disable=import-outside-toplevel except ImportError as err: raise RuntimeError( "aiohttp is not installed. Please install aiohttp to use this functionality." ) from err @backoff.on_exception( lambda: backoff.constant(3), Flow360WebNotAvailableError, max_time=3600 ) @http_interceptor async def _get_image_sequence( session: aiohttp.ClientSession, url: str, shutter_request: list[dict] ) -> str: log.debug( f"sending request to uvf-shutter: {url=}, {type(shutter_request)=}, {len(json.dumps(shutter_request))=}" ) return session.post(url, json=shutter_request) async with aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=3600), headers={"Authorization": f"Bearer {self.access_token}"}, ) as session: tasks = [] for _, _, shutter_request in screenshots: tasks.append( _get_image_sequence( session=session, url=urljoin(self.url, "/sequence/run"), shutter_request=shutter_request, ) ) log.debug(f"request to shutter: {json.dumps(shutter_request)}") responses = await asyncio.gather(*tasks, return_exceptions=True) for response, (_, img_folder, _) in zip(responses, screenshots): if not isinstance(response, Exception): os.makedirs(img_folder, exist_ok=True) zip_file_path = os.path.join(img_folder, "images.zip") with open(zip_file_path, "wb") as f: f.write(response) log.info(f"Zip file saved to {zip_file_path}") for response in responses: if isinstance(response, Exception): raise response img_files = {} for case_id, img_folder, _ in screenshots: zip_file_path = os.path.join(img_folder, "images.zip") with zipfile.ZipFile(zip_file_path, "r") as zip_ref: zip_ref.extractall(path=img_folder) extracted = zip_ref.namelist() img_files[case_id] = [os.path.join(img_folder, file) for file in extracted] log.info(f"Extracted files: {extracted}") return img_files async def sequential_screenshot_generator( self, screenshots: List[Tuple], process_function: Optional[Callable] ): """ process screenshot sequentially """ img_files_generated = {} for screenshot in screenshots: img_files_generated_single_run = await process_function([screenshot]) img_files_generated.update(img_files_generated_single_run) return img_files_generated # pylint: disable=too-many-branches def get_images( self, fig_name, data: List[ScenesData], regenerate_if_not_found: bool = True ) -> dict[str, List]: """ Generates or retrieves cached image files for scenes. Parameters ---------- fig_name : str The base name for the image file. data : List[ScenesData] A list of scene data objects to generate images for. Returns ------- dict[str, List[str]] A dictionary with case IDs as keys and lists of image file paths as values. """ screenshots = [] cached_files = {} for data_item in data: case_id = data_item.resource.id img_folder = os.path.join(self.data_storage, case_id) img_name = fig_name + ".png" img_full_path = os.path.join(img_folder, img_name) if not os.path.exists(img_full_path) or self.use_cache is False: if regenerate_if_not_found is True: screenshots.append( ( case_id, img_folder, data_item.model_dump( by_alias=True, exclude_unset=True, exclude_none=True ), ) ) else: raise Flow360RuntimeError( f"File: {img_name=} not found, shutter generation failed." ) else: log.debug(f"File: {img_name=} exists in cache, using.") if case_id not in cached_files: cached_files[case_id] = [img_full_path] else: cached_files[case_id].append(img_full_path) img_files_generated = {} if len(screenshots) > 0: if self.screenshot_process_function is not None: process_function = self.screenshot_process_function else: process_function = self._get_3d_images_api if self.process_screenshot_in_parallel: img_files_generated = asyncio.run(process_function(screenshots)) else: img_files_generated = asyncio.run( self.sequential_screenshot_generator(screenshots, process_function) ) return {**img_files_generated, **cached_files}